[Building a Super App] Build Your UI Framework From Scratch

Cùng mình xây dựng một thư viện để phát triển UI giống như React chỉ với chưa tới 300 lines code!
Đăng ký tham gia Workshop để tìm hiểu kỹ hơn về chủ đề: ĐĂNG KÝ NGAY

Mở đầu

Trong bài viết này chúng ta sẽ cùng nhau xây dựng một thư viện để phát triển UI giống như React.

Mục tiêu của chúng ta là cung cấp ra một thư viện có thể render các functional Component, với các hook. Thông qua việc xây dựng thư viện này, chúng ta có thể hiểu thêm về cách mà React render một Component, cũng như cách các hooks của React hoạt động.

Ở giai đoạn đầu tiên, chúng ta tập trung vào việc cung cấp ra 3 APIs có signature giống với React, mà chưa quan tâm tới performance của các API này. Việc cải thiện performance sẽ được mô tả ở các bài viết tiếp theo. Cụ thể trong bài viết lần này, chúng ta sẽ implement một thư viện với thuật toán Stack Reconciliation.

Thư viện của chúng ta sẽ cung cấp ra 3 APIs chính:

// API giống với React.render 
function render(vnode, container);

// API giống với API React.useState 
function useState(initialValue);

// API giống với API React.useEffect
function useEffect(callback, args);

Thư viện có thể được sử dụng như sau:

function App() {
  const [count, setCount] = useState(0);
  const increase = () => setCount(count + 1);
  const decrease = () => setCount(count - 1);
  
  return (
    <div>
      <button onClick={increase}>inc</button>
      {count}
      <button onClick={decrease}>dec</button>
    </div>
  );
}

render(<App />, document.getElementById("app"));

Sau đoạn code kể chúng, chúng ta sẽ render component App vào một DOM node có id là app

1. JSX và VNode

1.1. Virtual DOM

Vậy làm thế nào mà React có thể render một component được định nghĩa từ JSX thành một DOM Element?

Khi developers định nghĩa một Component bằng JSX như <Component prop="value" />, ở bên dưới thông qua Babel Compiler, đoạn code này sẽ được biến đổi thành hàm như sau:

React.createElement(Component, {
  prop: "value"
})

Hàm này được gọi là hàm createElement , hàm trả về một Virtual Node (từ đây về sau chúng ta sẽ gọi tắt là VNode). Thay vì tương tác trực tiếp với DOM Element, các thư viện như React / Vue tạo ra các VNode, và để developers tương tác (tạo mới / update các thuộc tính / xoá) các VNode này, sau đó các thư viện mới biến đổi các hoạt động của developers thành hoạt động tương ứng với DOM Element. Mục đích của việc này nhằm để giảm thiểu các hoạt động thay đổi DOM Element, từ đó tăng hiệu năng của ứng dụng.

Mặc định Babel sẽ sử dụng hàm React.createElement để translate các JSX component thành các JS function. Tuy nhiên, chúng ta có thể thay đổi cấu hình này bằng cách, trong một đoạn code bất kỳ, add thêm comment như sau:

// @jsx h 

Thông qua comment ở trên, chúng ta báo cho Babel rằng, thay vì sử dụng hàm React.createElement, để code trở nên ngắn gọn, từ đây về sau chúng ta sử dụng hàm h để thay thế cho hàm React.createElement . hlà viết tắt của hyperscript

Hãy cùng xem xét thêm một số ví dụ khác về việc biến đổi các JSX:

// input
<div>
  <button class="red">Click me</button>
  <div class="yellow">Hello world</div>
</div>

// convert to 
h(
  "div", 
  null, 
  h("button", { class: "red" }, "Click me"),
  h("div", { class: "yellow" }, "Hello world"),
)

// output to VNode
{
  type: "div",
  props: {
    children: [
      {
        type: "button",
        props: {
          class: "red",
          children: "Click me",
        } 
      },
      {
        type: "div",
        props: {
          class: "yellow",
          children: "Hello world",
        }
      }
    ]
  }
}

1.2. Implement hàm chuyển đổi JSX sang VNode

Ở đây chúng ta thấy hàm h nhận vào 3 tham số:

h(type, props, children)

Tham số đầu tiên

  • type là kiểu của VNode. Type có thể là một string hoặc một hàm.
    • Trong trường hợp type là string, nó được sử dụng để đại diện cho một thẻ tương ứng của DOM Element
    • Trong trường hợp type là một hàm, nó đại diện cho Component được sử dụng để định nghĩa ra component
  • props là các thuộc tính của VNode.
    • Trong trường hợp type là string, props là các thuộc tính được gắn cho DOM Element.
    • Trong trường hợp type là hàm, props là các input đầu vào của Component
  • children là các node của của VNode
    • children có thể là một mảng chứa các node code của VNode
    • hoặc children có thể là một primitive type (string, number, undefined, null), trong trường hợp đó children đại diện cho nôi dụng của các DOM Element.

Ở đây chúng ta thấy, mặc dù hàm h nhận vào tham số thứ 3 là children, tuy nhiên khi chuyển sang VNode, VNode lại gộp children vào thành một thuộc tính của props. Mục đích của việc này làm nhằm mục đích hỗ trợ việc truyền các children vào props khi định nghĩa các Component.

Ví dụ:

// định nghĩa Component 
function Layout(props) {
  return (
    <div>
     {props.children}
    </div>
  )
}

// sử dụng
<Layout>
  <div>Content</div>
</Layout>

Chúng ta có thể implement hàm h như sau:

// index.js

const TEXT_NODE = "TEXT_NODE";

function isVNode(node) {
  return typeof node === "object" && node.type !== undefined;
}

function h(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children: children.map((child) => {
        if (isVNode(child)) return child;
        return {
          type: TEXT_NODE,
          props: {
            nodeValue: child,
          },
        };
      }),
    },
  };
}

1.3. Kiểm thử code

Để test thử đoạn code trên, chúng ta sẽ tạo ra một file index.html với nội dung như sau:

<div id="main"></div>
<script src="index.js"></script>

<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
  // @jsx h
  console.log(
    <div class="red">
      <h1>Hello</h1> world
    </div>
  );
</script>

Trong file index.html ở trên, chúng ta import thư viện babel.min.js nhằm mục đích để có thể chạy được code JSX trên browser.

Chúng ta cũng import thư index.js là file được implement như ở phần 1.2

Sau khi chạy đoạn code kể trên, ở console của browser, chúng ta có thể thấy được nội dung của VNode được in ra:

image

1.4. DOM Node và Component Node

Có 2 loại VNode, một là các VNode đại diện cho các DOM Element, một là các VNode đại diện cho các Component mà developer viết.

Ví dụ về DOM Node như sau:

<div>
  <span>hello world</span>
</div>

VNode ở trên là một VNode đại diện cho một DOM Element, bản thân VNode và các children của VNode này đều có type là một thẻ DOM.

Trong khi đó, các component node là các VNode tương ứng với các Class Component, hoặc Function Component mà developer viết. Các ví dụ sau đây đại diện cho các component node.

class Counter extends Component {
  constructor(props) {
    super(props);
  }

  render() {
     return (
       <div>count: {this.props.value}</div>
     )
  }
}

const App = (props) => {
  return (
    <div>
      <span>hello {props.name}</div>
    </div>
  )
};

Cả AppCounter trong ví dụ kể trên đều được coi là các Component Node.

Trong khi các DOM Node có thể convert trực tiếp khá dễ dàng sang DOM Element trên browser, các Component Node được render thông qua 4 bước:

  • khởi tạo một instance từ component
  • gọi hàm render từ instance, sau bước này chúng ta sẽ có được một VNode
  • recursively gọi hàm render cho VNode mới tạo được
  • gọi các life cycle tương ứng sau khi instance được mount

2. Render các DOM node

image

Như đã nói ở phần 1.2, chúng ta có 2 loại VNode:

  • các VNode hoàn toàn là DOM node
  • các VNode tương ứng với một Component từ code của Developer

Tuỳ vào từng loại VNode, mà chúng ta sẽ có các cách render khác nhau.

function isDOMNode(vnode) {
  return typeof vnode.type === 'string';
}

function render(vnode, container) {
  if (isDOMNode(vnode)) {
    return mountDOMNode(vnode, container);
  }
  mountComponentNode(vnode, container);
}

Để render một DOM VNode, chúng ta sẽ tạo ra một DOM element và set các thuộc tính của DOM element dựa theo các thuộc tính của VNode

function mountDOMNode(vnode, container) {
  const dom = createDOMNode(vnode.type, vnode.props);
	// chúng ta lưu trữ lại DOM gắn với một DOMNode 
  vnode._dom = dom;  
  container.insertBefore(dom, sibling);
  const children = vnode.props.children;
  if (children) {
    children.forEach((child) => render(child, dom));
  }
}

function isEventProp(value) {
  return value.startsWith("on");
}

function createDOMNode(type, props) {
  if (type === TEXT_NODE) {
    return document.createTextNode(String(props.nodeValue));
  }

  let dom = document.createElement(type);
  Object.keys(props).forEach((key) => {
    // skip if key is children
    if (key === "children") return;

    const value = props[key];
    if (isEventProp(key)) {
      dom.addEventListener(key.slice(2).toLocaleLowerCase(), value);
    } else {
      dom.setAttribute(key, value);
    }
  });

  return dom;
}

function mountComponentNode(vnode, container) {
	// we do not implement this function yet
}

Với trường hợp là các text node, chúng ta sẽ xử lý theo cách đặc biệt.

Ở đây, text node là các node được thể hiện như trong ví dụ sau:

hello <span>react </span> internal

Với một JSX được thể hiện như ở trên, chúng ta sẽ tạo tương ứng 3 node lần lượt là

  • Text node, với nodeValue là hello
  • Span node với nội dung là một text node
  • Text node với nodeValue là internal

Với các node bình thường, chúng ta sẽ set các thuộc tính của node bằng hàm

function isEventProp(value) {
  return value.startsWith("on");
}

function createDOMNode(dom, props) {
  if (!props) return;

  Object.keys(props).forEach((key) => {
    // skip if key is children
    if (key === 'children') return; 

    const value = props[key];
    if (isEventProp(key)) {
      dom.addEventListener(key.slice(2).toLocaleLowerCase(), value);
    } else {
      dom.setAttribute(key, value);
    }
  });
}

Ở đây, để phân biệt các event handler của DOM node, chúng ta dựa vào một thói quen được xác định từ đầu đó là tất cả các event handler đều được bắt đầu bằng chữ on .

Để test thử hàm này, trong file index.htmlchúng ta sử dụng đoạn code sau

<script type="text/babel">
  // @jsx h
  render(
    <div class="red">
      <span>Hello</span> world
    </div>,
    document.getElementById("main")
  );
</script>

Sau khi chạy, chúng ta sẽ có được kết quả:

image

3. Render các Components

image

Để render một VNode Component, trước hết chúng ta sẽ khởi tạo một instance từ Component.

Ở đây chúng ta cần phân biệt giữa Class Component và Functional Component.

function isClassComponent(fn) {
  return typeof fn === "function" && fn.render !== undefined;
}

Để phân biệt điều này, chúng ta dựa vào đặc điểm là Class Component sẽ luôn implement hàm render. Đối Functional Component, do không có hàm render nên khi khởi tạo instance, chúng ta sẽ tạo thêm hàm render cho instance đó.

function initiateComponent(vnode) {
  let instance;
  if (isClassComponent(vnode.type)) {
    // class component
    instance = new vnode.type(vnode.props);
  } else {
    instance = {
      props: vnode.props,
      render() {
        return vnode.type(this.props);
      },
    };
  }

  vnode._instance = instance;
  return instance;
}

Sau khi instance được khởi tạo, chúng ta gọi hàm render của instance để nhận được một VNode.

Việc tiếp theo là chúng ta render VNode đó

function mountComponentNode(vnode, container) {
  const instance = initiateComponent(vnode);
  const newVNode = instance.render();
  render(newVNode, container);
}

Để cài đặt các life cycle của một Class Component, sau khi render chúng ta cần gọi tới life cycle componentDidMount của instance. Chúng ta sửa lại hàm mountComponentNode bằng cách thêm vào một dòng

function mountComponentNode(vnode, container) {
  const instance = initiateComponent(vnode);
  const newVNode = instance.render();
  render(newVNode, container);
	callInstanceLifeCycle(instance, "componentDidMount");
}

function callInstanceLifeCycle(instance, name, ...params) {
  // skip if the life cycle is not defined
  if (instance[name] === undefined) return;
  instance[name].call(instance, ...params);
}

Chúng ta có thể test việc render Component bằng cách sử dụng đoạn code dưới đây

<script type="text/babel">
  // @jsx h
  class Welcome extends Component {
    constructor(props) {
      super(props);
    }

    render() {
      return <span>{this.props.name}</span>;
    }

    componentDidMount() {
      console.log("component did mount", this);
    }
  }

  const App = () => {
    return (
      <div class="red">
        hello <Welcome name="world" />
      </div>
    );
  };
  render(<App />, document.getElementById("main"));
</script>

Kết quả là dòng chữ hello world sẽ được in ra màn hình

image

4. Cập nhật Class Components

Class Component của chúng ta ở trên chưa hỗ trợ các hàm để cập nhật trạng thái, vì vậy với ví dụ dưới đây, khi click vào các button increase, hoặc decrease, chúng ta sẽ không thấy website có sự thay đổi

<script type="text/babel">
  // @jsx h
  class App extends Component {
    constructor(props) {
      super(props);
      this.state = {
        count: 0
      };
    }

    increase() {
      console.log("increase");
      this.setState({
        count: this.state.count + 1
      });
    }

    decrease() {
      console.log("decrease");
      this.setState({
        count: this.state.count - 1
      });
    }

    render() {
      return (
        <div>
          <button onClick={this.increase}>increase</button>
          {this.state.count}
          <button onClick={this.decrease}>decrease</button>
        </div>
      );
    }
  }
  render(<App />, document.getElementById("main"));
</script>

Chúng ta sẽ cần cung cấp thêm hàm setState cho Component

Component.prototype.setState = function (newState) {
  // skip if state is not changed
  if (newState === this.state) return;
  this.state = newState;
  rerenderInstance(this, this._vnode);
};

function rerenderInstance(instance, oldVNode) {
  const newVNode = instance.render();
  patch(oldVNode, newVNode);
  instance._vnode = newVNode;
}

Mỗi khi setState làm biến đổi state của instance, chúng ta sẽ gọi lại hàm render của instance, từ đó tạo ra một VNode mới. Sau đó, chúng ta so sánh VNode mới và VNode cũ để tìm kiếm những điểm khác biệt giữa 2 VNode này, từ đó thực hiện các thao tác thay đổi DOM Element tương ứng.

Ở đây, hàm patch nhận nhiệm vụ so sánh hai VNode

function patch(n1, n2) {
  // call life cycle for n1 when it is unmount
  if (!isDOMNode(n1)) {
    const instance = n1._instance;
    callInstanceLifeCycle(instance, "componentWillUnmount");
    patch(instance._vnode, n2);
    callInstanceLifeCycle(instance, "componentDidUnmount");
    return;
  }

  // call life cycle for n2 when it mount
  if (!isDOMNode(n2)) {
    const instance = initiateComponent(n2);
    instance._vnode = instance.render();
    patch(n1, instance._vnode);
    callInstanceLifeCycle(instance, "componentDidMount");
    return;
  }

  // after these 2 steps, we could have n1 and n2 is DOM Node
  if (n1.type !== n2.type) {
		const dom = n1._dom;
    render(n2, dom.parentNode, dom);
    unmount(n1);
    return;
  }

  // patch current dom node
  updateDOMAttributes(n1._dom, n2.props, n1.props);
  n2._dom = n1._dom;

  // patch children
  patchChildren(n1, n2, n1._dom);
}

Do hàm patch có thể nhận vào các VNode là kiểu DOM Node, hoặc Component Node, vì thế khi chạy patch chúng ta cần cố gắng convert các Component Node về DOM Node trước.

Trong trường hợp node cũ n1 chưa phải là DOM Node, thì khi gọi patch, chúng ta sẽ khởi tạo một instance mới, vì vậy instance cũ từ n1 cần được gọi tới các hàm để clean up.

Trong trường hợp node mới n2, chưa phải là DOM Node, thì khi gọi patch, chúng ta sẽ cần phải khởi tạo instance mới từ n2, và sau khi render instance mới xong, chúng ta sẽ cần gọi tới hàm componentDidMount

Khi đã có được 2 DOM Node rồi, công việc của hàm patch khá đơn giản.
Nếu 2 node n1 và n2 là khác kiểu nhau, tức là lúc này chúng ta đang cần tạo ra một DOM Element mới. Vì vậy chúng ta sẽ phải render node n2 vào thay thế cho vị trí của dom element từ n1, đồng thời unmount dom element gắn với n1.

Hàm unmount được cài đặt như sau

function unmount(vnode, depth = 0) {
  if (isDOMNode(vnode)) {
    // we only call dom remove for the depth 0
    if (depth === 0) {
      const parent = vnode._dom.parentNode;
      parent.removeChild(vnode._dom);
    }

    if (vnode.props.children) {
      vnode.props.children.forEach((child) => unmount(child, depth + 1));
    }

    return parent;
  } else {
    const instance = vnode._instance;
    callInstanceLifeCycle(instance, "componentWillUnmount");
    const dom = unmount(instance._vnode, depth);
    callInstanceLifeCycle(instance, "componentDidUnmount");
    return dom;
  }
}

Ở đây chúng ta cần kiểm tra depth === 0 thì mới remove dom, vì khi đã remove một node khỏi DOM Element rồi, thì các con của node đó, cũng không cần thiết phải remove nữa. Làm như vậy để tiết kiệm số các thao tác trên DOM của chúng ta.

Để insert một DOM element mới thay thế cho vị trí của DOM Element từ n1, chúng ta sửa lại hàm render để chấp nhận thêm tham số sibling, là node ở sau vị trí cần thêm vào

export function render(vnode, container, sibling) {
  if (isDOMNode(vnode)) {
    return mountDOMNode(vnode, container, sibling);
  }
  mountComponentNode(vnode, container, sibling);
}

function mountDOMNode(vnode, container, sibling) {
  const dom = createDOMNode(vnode.type, vnode.props);
  vnode._dom = dom;
  if (!sibling) {
    container.appendChild(dom);
  } else {
    container.insertBefore(dom, sibling);
  }
  const children = vnode.props.children;
  if (children) {
    children.forEach((child) => render(child, dom));
  }
}

function mountComponentNode(vnode, container, sibling) {
  const instance = initiateComponent(vnode);
  const newVNode = instance.render();
  render(newVNode, container, sibling);
  instance._vnode = newVNode;
  callInstanceLifeCycle(instance, "componentDidMount");
}

Khi có sibling, thay vì gọi API .appendChild , chúng ta sẽ gọi API insertBefore(dom, sibling) để insert node mới vào trước node cũ.

Trong trường hợp node n1 và n2 là giống nhau, chúng ta sẽ cần update các thuộc tính của DOM từ n2, và remove đi các thuộc tính cũ từ n1

function updateDOMAttributes(dom, newProps, oldProps) {
  // handle text node in a special case
  if (dom.nodeType === Node.TEXT_NODE) {
    const oldValue = oldProps.nodeValue;
    const newValue = newProps.nodeValue;
    if (oldValue != newValue) {
      dom.nodeValue = newValue;
    }
    return;
  }

  // set new props
  Object.keys(newProps).forEach((key) => {
    if (key === "children") return;
    const newValue = newProps[key];
    const oldValue = oldProps[key];
    if (newValue === oldValue) return;
    if (isEventProp(key)) {
      const event = key.slice(2).toLocaleLowerCase();
      dom.removeEventListener(event, oldValue);
      dom.addEventListener(event, newValue);
    } else {
      dom.setAttribute(key, newValue);
    }
  });

  // delete old props
  Object.keys(oldProps).forEach((key) => {
    // skip children key
    if (key === "children") return;
    // skip if we already set it
    if (newProps[key] !== undefined) return;
    const value = oldProps[key];
    if (isEventProp(key)) {
      const event = key.slice(2).toLocaleLowerCase();
      dom.removeEventListner(event, value);
    } else {
      dom.removeAttribute(key);
    }
  });
}

Cuối cùng, sau khi đã cập nhật vào DOM hiện tại, chúng ta sẽ thực hiện thuật toán diff tiếp tục với các child của n1 và n2

function patchChildren(n1, n2, container) {
  const oldChildren = n1.props.children || [];
  const newChildren = n2.props.children || [];
  const minLength = Math.min(oldChildren.length, newChildren.length);
  for (let i = 0; i < minLength; i++) {
    patch(oldChildren[i], newChildren[i]);
  }
  if (oldChildren.length > newChildren.length) {
    oldChildren.slice(minLength).forEach((child) => {
      unmount(child);
    });
  } else if (newChildren.length > oldChildren.length) {
    newChildren.slice(minLength).forEach((child) => {
      render(child, container);
    });
  }
}

Ở đây, hàm patchChildren được implement một cách đơn giản. Chú ý ở đây, chúng ta chưa đề cập tới các vấn đề về performance, vì thế hàm patchChildren đang chạy chưa được nhanh. Việc cải thiện hàm này sẽ được đề cập tới ở các bài viết lần sau.

Sau khi implement các thay đổi như trên, chúng ta sẽ có thể thấy kết quả như sau

image

5. Cập nhật Functional Component với Hooks

Functional Component khi muốn thay đổi trạng thái của Component cần phải sử dụng Hooks. Ví dụ về class component ở phần 4, có thể được viết dưới dạng Functional Component như sau

<script type="text/babel">
  // @jsx h
  function App() {
    const [count, setCount] = useState(0);
    const increase = () => {
      if (count === 0) return;
      console.log("increase");
      setCount(count + 1);
    };
    const decrease = () => {
      console.log("increase");
      setCount(count + 1);
    };

    return (
      <div>
        <button onClick={increase}>increase</button>
        {count}
        <button onClick={decrease}>decrease</button>
      </div>
    );
  }
  render(<App />, document.getElementById("main"));
</script>

Vậy Hooks thực chất là gì? Hãy cùng xem hình ảnh dưới đây về Hooks, chúng ta sẽ hiểu hơn về cách cài đặt Hooks như nào

image

Như đã nói ở trên, Functional Component instance không có bất cứ state nào gắn với nó, vì vậy khi khởi tạo instance cho Functional Component, chúng ta sẽ khởi tạo thêm 2 biến cho instance này

  • __hooks là một mảng các object. Giá trị của các object này tương ứng với trạng thái của từng hook gắn với nó.
  • __hooksPointer là một biến kiểu number trả về vị trí hiện tại mà hook đang trỏ tới trong mảng.

Mỗi khi chúng ta render lại instance, chúng ta sẽ reset lại vị trí của __hooksPointer để trỏ về vị trí 0. Mỗi lần chúng ta tạo mới một hook thông qua các lời gọi như useState, hoặc useEffect, thực chất chúng ta đang truy cập vào một vị trí đang có của __hooks từ vị trí __hooksPointer hoặc là chúng ta đang tạo thêm một vị trị mới.

Chính vì đặc điểm này, mà React team phải đề ra một quy tắc khi gọi hooks đó là không được gọi hook trong các vòng lặp, các điều kiện hoặc là các nested function. Các hooks bắt buộc phải được gọi ở top level của một React Functional Component. Mục đích của việc này là nhằm mục đích khi chúng ta gọi lại hàm render của một Functional Component instance, thì vị trí của các hook không thay đổi, từ đó chúng ta vẫn truy cập vào đúng trạng thái

Để implement được hook với Functional Component, chúng ta cần sửa lại hàm initiateComponent như sau:

let currentInstance;

function initiateComponent(vnode) {
  let instance;
  if (isClassComponent(vnode.type)) {
    // class component
    instance = new vnode.type(vnode.props);
  } else {
    instance = {
      props: vnode.props,
      __hooks: [],
      __hooksPointer: 0,
      render() {
        currentInstance = this;
        this.__hooksPointer = 0;
        const result = vnode.type(this.props);
        currentInstance = null;
        return result;
      },
    };
  }

	// ....
}

Hàm getHookState trả về cho chúng ta state của một hook tại thời điểm bất kỳ.

Nếu như hook chưa có, chúng ta sẽ tạo mới thêm hook và đẩy vào trong mảng __hooks của instance hiện tại. Với mỗi hook thuộc nhóm useState, ở trong state của hook, chúng ta chỉ cần lưu giữ lại giá trị của hook mà thôi.

Hook useState được cài đặt như sau

export function useState(initialValue) {
  const instance = currentInstance;
  const state = getHookState({ value: initialValue });
  const setter = (value) => {
    if (state.value === value) return;
    state.value = value;
    if (instance._vnode) {
      rerenderInstance(instance, instance._vnode);
    }
  };

  return [state.value, setter];
}

function getHookState(initialState) {
  const hooks = currentInstance.__hooks;
  const idx = currentInstance.__hooksPointer;
  currentInstance.__hooksPointer++;
  if (idx < hooks.length) {
    return hooks[idx];
  }

  const state = initialState;
  hooks.push(state);
  return state;
}

Hàm getHookState trả về cho chúng ta state của một hook tại thời điểm bất kỳ.

Nếu như hook chưa có, chúng ta sẽ tạo mới thêm hook và đẩy vào trong mảng __hooks của instance hiện tại. Với mỗi hook thuộc nhóm useState, ở trong state của hook, chúng ta chỉ cần lưu giữ lại giá trị của hook mà thôi.

Hook useEffect được cài đặt như sau

export function useEffect(callback, deps) {
  const state = getHookState({ deps });
  const isChange =
    deps.length === 0 ||
    state.deps.length === 0 ||
    deps.length != state.deps.length ||
    deps.some((item, i) => item !== state.deps[i]);
  if (isChange) {
    if (state.cleanUp && typeof state.cleanUp === "function") {
      state.cleanUp();
    }

    state.cleanUp = callback();
  }
}

Với mỗi useEffect hooks, chúng ta sẽ lưu giữ hai giá trị

  • deps là một mảng các biến mà chúng ta sẽ watch. Nếu giá trị của deps thay đổi, chúng ta sẽ phải chạy lại hàm callback
  • cleanUp là hàm được gọi để clean up effect hiện tại. Hàm này sẽ được gọi trước khi chúng ta gọi tới callback

Hàm cleanUp của hook useEffect cũng cần được gọi mỗi khi mà instance được unmount. Vì vậy chúng ta sẽ sửa lại hàm initiateComponent để add thêm life cycle componentDidUnmount cho Functional Component Instance:

function initiateComponent(vnode) {
  let instance;
  if (isClassComponent(vnode.type)) {
    // class component
    instance = new vnode.type(vnode.props);
  } else {
    instance = {
			...,

      componentDidUnmount() {
        this.__hooks.forEach((hook) => {
          if (hook.cleanUp) {
            hook.cleanUp();
          }
        });
      },

    };
  }

	...
}

Sau khi cài đặt xong, chúng ta có thể thử với ví dụ sau

<script type="text/babel">
  // @jsx h
  function App() {
    const [count, setCount] = useState(0);
    const [time, setTime] = useState(new Date().getTime());

    const increase = () => {
      console.log("increase");
      setCount(count + 1);
    };
    const decrease = () => {
      if (count <= 0) return;
      console.log("decrease");
      setCount(count - 1);
    };

    useEffect(() => {
      const interval = setInterval(() => {
        console.log("change time");
        setTime(new Date().getTime());
      }, 1000);

      return () => {
        clearInterval(interval);
      };
    }, []);

    return (
      <div>
        <button onClick={increase}>increase</button>
        {time}: {count}
        <button onClick={decrease}>decrease</button>
      </div>
    );
  }
  render(<App />, document.getElementById("main"));
</script>

Kết quả sẽ là

image

Tổng kết

Vậy là chỉ với chưa tới 300 line, chúng ta đã có thể implement được một thư viện có tương đối đầy đủ tính năng giống với React. Hy vọng thông qua bài viết này các bạn có thể hiểu rõ hơn về cách React hoạt động.

Toàn bộ source code của thư viện được đặt tại địa chỉ: [Github]

Các bạn có thể xem để nắm chi tiết toàn bộ source code. Nếu còn bất kì vướng mắc hay câu hỏi nào, bạn có thể để lại bình luận bên dưới, mình sẽ hỗ trợ giải đáp nhé.

9 Likes

WORKSHOP SHARING 03: Build Your UI Framework From Scratch - Đăng ký ngay

Chuỗi workshop sharing 02 tuần 01 lần của Tini App nay đã quay trở lại với chủ đề: Build Your UI Framework From Scratch. Lần này, chúng ta sẽ tiếp tục đồng hành với diễn giả Nguyễn Trung Kiên - Giám đốc Kỹ sư Phần mềm tại Tiki. Đến với workshop, các bạn sẽ được tìm hiểu sâu hơn về cách xây dựng một thư viện để phát triển UI có tương đối đầy đủ tính năng giống với React. Đặc biệt, bạn sẽ biết cách cung cấp các APIs có signature giống React, và việc cải thiện performance của các API thông qua buổi chia sẻ này.

Hình thức tổ chức:

  • Offline: phòng Hạ Long Bay - Tiki Bay - Văn phòng Tiki TP HCM (dành cho Tikie)
  • Online: qua Google meet

Thời gian: Vào lúc 16h00 - 17h30 ngày 20/10/2022.

Bạn có thể để lại bình luận tại bài viết này nếu có bất kì vướng mắc hay câu hỏi nào, diễn giả và đội ngũ Tini App sẽ hỗ trợ bạn. Đặc biệt, những phần quà hấp dẫn sẽ dành cho người có câu hỏi hay nhất.

ĐĂNG KÝ NGAY: TẠI ĐÂY

2 Likes

Good sharing, thank you anh ạ

1 Like

Workshop “Build Your UI Framework From Scratch” đã khép lại. Qua buổi chia sẻ, người tham dự đã biết cách tạo ra một thư viện có thể render các functional Component, với các hook. Ngoài ra, kiến thức về cách mà React render một Component, cũng như cách các hooks của React hoạt động cũng đã được anh Nguyễn Trung Kiên truyền tải chi tiết. Hy vọng các bài tập anh Kiên ra đưa ra sẽ giúp các bạn hiểu và ứng dụng tốt hơn chủ đề này vào thực tế.

Xin chân thành cảm ơn các bạn đã tham gia và đồng hành cùng Tini App. BTC gửi bạn slide tổng hợp thông tin và video ghi lại nội dung chia sẻ qua đường dẫn bên dưới:

Hẹn gặp lại các bạn trong các workshop tiếp theo.

1 Like