Gooact: React trong 160 dòng code JavaScript

May 26, 2018 (6y ago)

Làm thế nào để xây dựng React cho riêng mình chỉ trong vài phút.

Giới thiệu

React là một thư viện tuyệt vời - nhiều nhà phát triển ngay lập tức đã yêu thích nó vì tính đơn giản, hiệu suất và cách khai báo làm việc. Nhưng cá nhân tôi có một lý do cụ thể khiến nó trở nên đặc biệt đối với tôi - và đó là cách nó hoạt động bên dưới. Tôi tìm thấy những ý tưởng đứng đằng sau React đơn giản nhưng kỳ lạ thú vị - và tôi tin rằng sự hiểu biết nguyên tắc cốt lõi của nó sẽ giúp bạn viết mã nhanh hơn và an toàn hơn.

Trong bài viết này, tôi sẽ chỉ cho cho bạn cách viết một bản sao của React đầy đủ chức năng, bao gồm Component API và tự triển khai Virtual DOM. Nó được chia thành bốn phần - mỗi phần là một chủ đề chính:

Mỗi phần sẽ kết thúc bằng một ví dụ có link CodePen trực tiếp, vì vậy bạn có thể ngay lập tức kiểm tra tất cả các tiến trình chúng ta đã thực hiện. Bắt đầu nào.

Elements

Element là một đối tượng trọng lượng nhẹ của một DOM thực tế. Nó chứa tất cả thông tin quan trọng - như node type, attributes và danh sách children —  vì vậy nó có thể dễ dàng rendered trong tương lai. Thành phần giống như cây của các elements được gọi là VDOM - một ví dụ được hiển thị bên dưới:

{
    "type": "ul",
    "props": {
        "className": "some-list"
    },
    "children": [
        {
            "type": "li",
            "props": {
                "className": "some-list__item"
            },
            "children": [
                "One"
            ]
        },
        {
            "type": "li",
            "props": {
                "className": "some-list__item"
            },
            "children": [
                "Two"
            ]
        }
    ]
}

Thay vì viết object quái dị đó mọi lúc, hầu hết các nhà phát triển React đều sử dụng cú pháp JSX, trông giống như một sự kết hợp gọn gàng giữa mã JavaScript và các thẻ HTML:

/** @jsx createElement */ const list =
<ul className="some-list">
  <li className="some-list__item">One</li>
  <li className="some-list__item">Two</li>
</ul>
;

In order to get executed it needs to be transpiled into regular function calls — notice that pragma comment which defines what function must be used: Để được thực hiện, nó cần phải được chuyển thành các gọi hàm thông thường - chú ý comment pragma là phải luôn sử dụng:

const list = createElement(
  'ul',
  { className: 'some-list' },
  createElement('li', { className: 'some-list__item' }, 'One'),
  createElement('li', { className: 'some-list__item' }, 'Two')
);

Cuối cùng, function mong muốn được gọi - và nó được cho là trả về cấu trúc VDOM được mô tả ở trên. Việc triển khai của chúng tôi sẽ ngắn gọn - nhưng mặc dù có vẻ nguyên thủy, nó phục vụ mục đích cần một cách hoàn hảo:

const createElement = (type, props, ...children) => {
  props = props != null ? props : {};
  return { type, props, children };
};

CodePen đầu tiên có sẵn ở đây— nó chứa phương pháp được mô tả ở trên với một vài cây VDOM do nó tạo ra.

Rendering

Rendering là một quá trình biến VDOM thành DOM hiển thị. Nói chung, nó là một thuật toán khá đơn giản mà đi qua cây VDOM và tạo ra phần tử DOM tương ứng cho mỗi node:

const render = (vdom, parent = null) => {
  if (parent) parent.textContent = '';
  const mount = parent ? (el) => parent.appendChild(el) : (el) => el;
  if (typeof vdom == 'string' || typeof vdom == 'number') {
    return mount(document.createTextNode(vdom));
  } else if (typeof vdom == 'boolean' || vdom === null) {
    return mount(document.createTextNode(''));
  } else if (typeof vdom == 'object' && typeof vdom.type == 'function') {
    return mount(Component.render(vdom));
  } else if (typeof vdom == 'object' && typeof vdom.type == 'string') {
    const dom = document.createElement(vdom.type);
    for (const child of [].concat(...vdom.children)) // flatten
      dom.appendChild(render(child));
    for (const prop in vdom.props) setAttribute(dom, prop, vdom.props[prop]);
    return mount(dom);
  } else {
    throw new Error(`Invalid VDOM: ${vdom}.`);
  }
};

const setAttribute = (dom, key, value) => {
  if (typeof value == 'function' && key.startsWith('on')) {
    const eventType = key.slice(2).toLowerCase();
    dom.__gooactHandlers = dom.__gooactHandlers || {};
    dom.removeEventListener(eventType, dom.__gooactHandlers[eventType]);
    dom.__gooactHandlers[eventType] = value;
    dom.addEventListener(eventType, dom.__gooactHandlers[eventType]);
  } else if (key == 'checked' || key == 'value' || key == 'id') {
    dom[key] = value;
  } else if (key == 'key') {
    dom.__gooactKey = value;
  } else if (typeof value != 'object' && typeof value != 'function') {
    dom.setAttribute(key, value);
  }
};

Code ở trên có vẻ trông đáng sợ, nhưng hãy làm cho mọi thứ trở nên ít phức tạp hơn bằng cách tách nó thành các phần nhỏ hơn:

CodePen thứ hai có sẵn ở đây— nó thể hiện thuật toán render trong hành động.

Patching

Patching là một quá trình hòa hợp DOM hiện có với cây VDOM mới được xây dựng.

Hãy tưởng tượng bạn có một số VDOM lồng nhau sâu và cập nhật thường xuyên. Khi một cái gì đó thay đổi, ngay cả phần nhỏ nhất - mà phải được hiển thị. Triển khai native sẽ yêu cầu render toàn bộ mỗi lần cập nhật như vậy.

Đó là lý do thực tế — xây dựng DOM và vẽ lại nó là một hoạt động khá tốn kém. Nhưng chúng ta có thể tối ưu hóa điều này bằng cách viết thuật toán và sẽ yêu cầu ít sửa đổi DOM:

Nhưng sau đó một vấn đề khác nổi lên — độ phức tạp tính toán. So sánh hai cây có độ phức tạp O(n³) — ví dụ: nếu bạn định patch một ngìn elements — nó sẽ yêu cầu một tỷ so sánh. Điều đó là quá nhìu. Thay vào đó, chúng ta sẽ triển khai một thuật toán độ phức tạp O(n) với hai giả định sau:

Trong thực tế, các giả định này có giá trị đối với hầu hết các trường hợp sử dụng thực tế. Bây giờ chúng tôi đã sẵn sàng cho một phần code khác:

const patch = (dom, vdom, parent = dom.parentNode) => {
  const replace = parent
    ? (el) => parent.replaceChild(el, dom) && el
    : (el) => el;
  if (typeof vdom == 'object' && typeof vdom.type == 'function') {
    return Component.patch(dom, vdom, parent);
  } else if (typeof vdom != 'object' && dom instanceof Text) {
    return dom.textContent != vdom ? replace(render(vdom)) : dom;
  } else if (typeof vdom == 'object' && dom instanceof Text) {
    return replace(render(vdom));
  } else if (
    typeof vdom == 'object' &&
    dom.nodeName != vdom.type.toUpperCase()
  ) {
    return replace(render(vdom));
  } else if (
    typeof vdom == 'object' &&
    dom.nodeName == vdom.type.toUpperCase()
  ) {
    const pool = {};
    const active = document.activeElement;
    for (const index in Array.from(dom.childNodes)) {
      const child = dom.childNodes[index];
      const key = child.__gooactKey || index;
      pool[key] = child;
    }
    const vchildren = [].concat(...vdom.children); // flatten
    for (const index in vchildren) {
      const child = vchildren[index];
      const key = (child.props && child.props.key) || index;
      dom.appendChild(pool[key] ? patch(pool[key], child) : render(child));
      delete pool[key];
    }
    for (const key in pool) {
      if (pool[key].__gooactInstance)
        pool[key].__gooactInstance.componentWillUnmount();
      pool[key].remove();
    }
    for (const attr of dom.attributes) dom.removeAttribute(attr.name);
    for (const prop in vdom.props) setAttribute(dom, prop, vdom.props[prop]);
    active.focus();
    return dom;
  }
};

Hãy điều tra tất cả các kết hợp có thể:

Như bạn có thể thấy, các nút text và phức tạp nói chung không tương thích và yêu cầu full render — may mắn thay đó là một sự thay đổi hiếm hoi. Nhưng những gì về sự hòa hợp của children đệ quy - nó thực hiện như sau:

CodePen thứ ba có sẵn ở đây — bao gồm ví dụ nhỏ về list patching.

Components

Component về mặt khái niệm tương tự như hàm JavaScript — nó có đầu vào tùy ý được gọi là "props" và trả về tập các elements mô tả những gì sẽ xuất hiện trên màn hình. Nó có thể được định nghĩa là một stateless function hoặc derived class với trạng thái bên trong của riêng và tập các phương thức và các lifecycle hooks. Tôi sẽ ngắn gọn về lý thuyết - tốt hơn hãy xem code:

class Component {
  constructor(props) {
    this.props = props || {};
    this.state = null;
  }

  static render(vdom, parent = null) {
    const props = Object.assign({}, vdom.props, { children: vdom.children });
    if (Component.isPrototypeOf(vdom.type)) {
      const instance = new vdom.type(props);
      instance.componentWillMount();
      instance.base = render(instance.render(), parent);
      instance.base.__gooactInstance = instance;
      instance.base.__gooactKey = vdom.props.key;
      instance.componentDidMount();
      return instance.base;
    } else {
      return render(vdom.type(props), parent);
    }
  }

  static patch(dom, vdom, parent = dom.parentNode) {
    const props = Object.assign({}, vdom.props, { children: vdom.children });
    if (dom.__gooactInstance && dom.__gooactInstance.constructor == vdom.type) {
      dom.__gooactInstance.componentWillReceiveProps(props);
      dom.__gooactInstance.props = props;
      return patch(dom, dom.__gooactInstance.render());
    } else if (Component.isPrototypeOf(vdom.type)) {
      const ndom = Component.render(vdom);
      return parent ? parent.replaceChild(ndom, dom) && ndom : ndom;
    } else if (!Component.isPrototypeOf(vdom.type)) {
      return patch(dom, vdom.type(props));
    }
  }

  setState(nextState) {
    if (this.base && this.shouldComponentUpdate(this.props, nextState)) {
      const prevState = this.state;
      this.componentWillUpdate(this.props, nextState);
      this.state = nextState;
      patch(this.base, this.render());
      this.componentDidUpdate(this.props, prevState);
    } else {
      this.state = nextState;
    }
  }

  shouldComponentUpdate(nextProps, nextState) {
    return nextProps != this.props || nextState != this.state;
  }

  componentWillReceiveProps(nextProps) {
    return undefined;
  }

  componentWillUpdate(nextProps, nextState) {
    return undefined;
  }

  componentDidUpdate(prevProps, prevState) {
    return undefined;
  }

  componentWillMount() {
    return undefined;
  }

  componentDidMount() {
    return undefined;
  }

  componentWillUnmount() {
    return undefined;
  }
}

Các static methods được gọi internally:

Các Instance methods có nghĩa là có thể bị ghi đè hoặc được gọi trong các derived classes do người dùng định nghĩa:

Lưu ý rằng phương thức render bị thiếu — nó được định nghĩa trong các child classes. CodePen cuối cùng có ở đây — với tất cả các code chúng tôi đã thực hiện cho đến đây cùng với một ví dụ to-do đơn giản.

Kết luận

Đó là tất cả của tôi — chúng ta có một bản sao React đầy đủ chức năng ngay bây giờ. Tôi sẽ gọi nó là Gooact — đó sẽ là một món quà nhỏ cho người bạn tốt của tôi. Chúng ta hãy xem xét kỹ hơn các kết quả:

Mục đích chính của bài viết này là để chứng minh các nguyên tắc cốt lõi của cấu trúc bên trong React mà không cần phải đi sâu vào các API phụ trợ - đó là lý do tại sao chúng bị thiếu một số thứ sau trong Gooact:

Như bạn có thể thấy, đó là một lĩnh vực tuyệt vời cho các tính năng và cải tiến mới - repository có sẵn ở đây, do đó, vì vậy đừng ngần ngại fork và thử nghiệm. Bạn thậm chí có thể cài đặt nó bằng cách sử dụng NPM!

Tôi muốn cảm ơn toàn bộ React Team đã tạo một thư viện tuyệt vời, làm cho cuộc sống của hàng nghìn nhà phát triển trở nên dễ dàng hơn. Đặc biệt cảm ơn đến Preact tác giả chính là Jason Miller — bài viết này đã được lấy cảm hứng từ cách tối giản nó được thực hiện.