+7

Câu hỏi thế kỷ "Virtual DOM là gì?" và cách tạo ra nó

Hello tất cả mọi người. 😄

Đã lâu không lên một bài viết chất lượng cho ae JS ngâm cứu. Qua tham khảo trên internet tìm material để quyết định thì chợt có một số thực tế hiện này về các bài viết về Virtual DOM là chỉ đang chăm chăm vào mấy cái pros, cons(thực tế là còn đưa ra thông tin sai lệch) hay mấy cái khái niệm ở phía trên mà gần như chưa thể hiện ra cho các bạn lập trình viên FE nói riêng hay cả ngành lập trình nói chung. Hẳn là các bạn đã từng nghe mấy câu như dưới đây trong quá trình tìm hiểu về Virtual DOM của bản thân:

  1. Virtual DOM là best vì nó sử dụng trong React mà React là hịn nhất.
  2. Virtual DOM chỉ đơn giản là một cách thể hiện DOM thành object và mỗi khi object đó thay đổi thì ánh xạ thay đổi qua DOM.
  3. Virtual DOM là nhanh, performance cao.
  4. Bla bla..... nói chung là phần lớn đều đánh giá cao.

Ngoài ra các khóa học rẻ tiền(tiền đóng nhiều mà nhận về kiến thức bề nổi) cho lập trình viên tay ngang từ ngành khác hoặc thậm chí là các bạn học ngành CNTT cũng đang có một thực trạng là dạy cho nhanh đi làm được rồi bâng quơ các khái niệm quan trọng hay gần đây mình thấy một số khóa học luôn kiểu "Facebook => React => React is da best" từ đó tạo ra các quan niệm sai lầm cho học viên.

Thực tế thì các blog nước ngoài đã có nhiều bài viết chê Virtual DOM:

Mặc dù vậy vì nó hot(cho dù nó không nhanh như lời đồn) nên mình cũng sẽ viết bài để b* fame nó tý hehe =))

FPI Warning ⚠️⚠️⚠️:

Bài viết này sẽ tập trung vào basic thay vì liệt kê đầy đủ vào chính xác 100% tính năng của virtual dom so với thực tế như react-reconciler nên nếu các bạn muốn biết thêm về các react hoạt động với virtual dom chi tiết hơn thì vui lòng tham khảo tại repo react/react-reconciler

Ok các thông tin bên lề vậy là đủ rồi giờ vào thực tế này:

Trong bài viết này lưu ý với mọi người là các thông tin bề nổi về Virtual DOM thì đã có rất nhiều người viết mình sẽ không làm lại điều đó. Việc của chúng ta hôm nay là "Create your own Virtual DOM" theo câu nói "Talk is cheap. Show me the code". Let's get started:

Đầu tiên các bạn hãy mở VSCode lên còn tại sao là VSCode thì là mình thích thì mình chọn thôi 😁😅😅😅(Đùa vậy chứ mọi người thích dùng gì cũng được nha). Tạo 1 file đặt tên gì cũng được như mình sẽ đặt là vnode.js. Với nội dung như sau.

Bước 1: Tạo function createVNode với các tham số đầu vào gồm có:

  • tag: Tạo vnode theo html tag ví dụ div, span, p,....
  • props: Chính là các thuộc tính của vnode này thường sẽ là các javascript attribute của thẻ ví dụ id, class, style,....
  • dom: DOM node tương ứng với VNode hiện tại.
  • parent: Parrent VNode của VNode hiện tại.
  • child: Children VNodes của VNode hiện tại.
  • sibling: Các VNode đồng cấp.

Function này sẽ trả ra các thuộc tính của VNode.

function createVNode(tag, props) {
  return {
    tag,
    props,
    dom: null,
    parent: null,
    child: null,
    sibling: null
  };
}

Bước 2: Tạo function createDom hàm này sẽ nhận vào vnode đã tạo từ hàm createVNode ở trên và thực hiện render VNode lên Origin DOM

  • Logic sẽ là trước hết chúng ta check nếu là text element thì render luôn text node còn ngược tại thì tạo tag theo vnode(div, span, p ,....)
  • Ngoài ra cần thực hiện map props vào vào attribute của dom mới tạo ra thông qua việc lặp qua các key của props. Một số props đặc biệt như style hoăc event thì cần handle đặc biệt hơn chút.
const EVENTS = ["onClick", "onChange"];

function createDom(vnode) {
  const dom =
    vnode.tag === "TEXT_ELEMENT"
      ? document.createTextNode(vnode.props.textContent)
      : document.createElement(vnode.tag);
  if (vnode.tag !== "TEXT_ELEMENT") {
    const isProperty = (key) => key !== "children";
    Object.keys(vnode.props)
      .filter(isProperty)
      .forEach((name) => {
        if (name === "style") {
          const style = vnode.props.style;
          Object.keys(style).forEach((styleKey) => {
            dom.style[styleKey] = style[styleKey];
          });
        } else {
          dom[EVENTS.includes(name) ? name.toLowerCase() : name] =
            vnode.props[name];
        }
      });
  }

  return dom;
}

Bước 3: Tạo render function để thực hiện việc render app của chúng ta. Function này sẽ có đầu vào

  • element: Root element để bắt đầu công việc render virtual dom.
  • container: VNode khởi tạo của ứng dụng Note: hàm này giống như hàm ReactDOM.createRoot hoạt động.

Ngoài ra các bạn có thể thấy mình khai báo thêm 2 biến global là nextUnitOfWork thường sẽ được gán một VNode ở trên để virtual dom biết công việc cần thực hiện tiếp theo(next render) là VNode nào, việc này sẽ lặp đi lặp lại liên tục việc tạo DOM node mới hoặc update một cái đã có sẵn. Biến nextUnitOfWork phân tách việc render ra DOM thành nhiều phần nhỏ cho riêng mỗi VNode từ đó biết được quá trình render đang được thực hiện ở đâu trên component tree nhờ vào biến này mà chúng ta có thể perform việc render incrementally, tạm dừng hoặc tiếp tục một cách chủ động (Incremental rendering) việc này sẽ hạn chế việc bị block main thread giống như Stack rendering phiên bản virtual dom của react cũ(Sẽ lặp qua toàn bộ component tree mỗi khi có update). Một biến khác là wipRoot viết tắt cho Work in Progress Root là biến lưu trữ work in progress tree(nơi trạng thái mới của UI đang được xây dựng thông qua virtual dom) sau khi the work in progress tree được áp dụng cho DOM thì work in progress tree sẽ trở thành current tree và giá trị của wipRoot sẽ được tiếp tục được đặt lại cho lần cập nhật DOM tiếp theo. Trong react-reconciler hai biến nextUnitOfWorkwipRoot sẽ cùng nhau quản lý quá trình render của react và giúp chúng ta có thể implement các feature nâng cao như async render, ngắt quãng quá trình render, tiếp tục render hoặc thực hiện việc render các thành phần ưu tiên. Tuy nhiên việc này cần hiểu biết nhiều về React Fiber's design nên trong bài viết mình sẽ chỉ implement cơ bản.

Logic: Tạo rootVNode thông qua hàm createVNode với tag là ROOT và pass props với children là root element được truyền vào. Gán nextUnitOfWorkwipRoot bằng rootVNode, gán dom của wipRoot bằng container để bắt đầu render container và các children của nó. Thực hiện vòng lặp vô hạn với điều kiện nextUnitOfWork vẫn còn giá trị, hàm performUnitOfWork sẽ được định nghĩa sau. Check nếu wipRoot có giá trị thì thực hiện commit render phase với hàm commitRoot sẽ được định nghĩa sau.

let nextUnitOfWork = null;
let wipRoot = null;

function render(element, container) {
  const rootVNode = createVNode("ROOT", { children: [element] });
  nextUnitOfWork = rootVNode;
  wipRoot = rootVNode;
  wipRoot.dom = container;

  while (nextUnitOfWork) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }

  if (wipRoot) {
    commitRoot(wipRoot);
  }
}

Bước 4: Tạo các function thực hiện viện render node gồm performUnitOfWorkrenderChildren Hàm performUnitOfWork sẽ nhận vào vnode và thực hiện việc render nó lên DOM thông qua createDom nếu vnode chưa tồn tại trên DOM và gán lại giá trị thuộc tính dom của vnode thành giá trị được trả về từ việc tạo DOM. Tiếp tục phía dưới chúng ta sẽ cần render children thông qua hàm renderChildren. Tại renderChildren chúng ta sẽ tiếp tục có một vòng lặp thực hiện việc tạo vnode cho child elements sau đó gán parrent của vnode mới tạo là wipNodeprevSibling, tiếp theo nếu là first element thì gán giá trị của vnode vào child cho wipNode. Nếu không phải first element thì sẽ thực hiện gán giá trị vnode vào sibling cho prevSibling. Hàm renderChildren sẽ thực hiện việc mutate giá trị của wipNode để tạo ra tree cho các component con của nó nếu có.

function performUnitOfWork(vnode) {
  if (!vnode.dom) {
    vnode.dom = createDom(vnode);
  }

  if (vnode.props.children) {
    renderChildren(vnode, vnode.props.children);
  }

  if (vnode.child) {
    return vnode.child;
  }
  let nextVNode = vnode;
  while (nextVNode) {
    if (nextVNode.sibling) {
      return nextVNode.sibling;
    }
    nextVNode = nextVNode.parent;
  }

  return null;
}

function renderChildren(wipVNode, elements) {
  let index = 0;
  let prevSibling = null;

  while (index < elements.length) {
    const element = elements[index];
    let newVNode = null;
    if (typeof element !== "string") {
      newVNode = createVNode(element.tag, element.props);
    } else {
      newVNode = createVNode("TEXT_ELEMENT", { textContent: element });
    }

    newVNode.parent = wipVNode;

    if (index === 0) {
      wipVNode.child = newVNode;
    } else {
      prevSibling.sibling = newVNode;
    }

    prevSibling = newVNode;
    index++;
  }
}

Bước 5: Commit phase Hàm commitWork sẽ nhận vào vnode và thực hiện việc appendChild các dom đã được render từ các bước trước đó vào domParrent và đệ quy việc commit phase cho childsibling nếu có. Hàm commitRoot sẽ có trách nhiệp khởi tạo rootVNode và reset giá trị work in progress.

function commitRoot(rootVNode) {
  commitWork(rootVNode.child);
  wipRoot = null;
}

function commitWork(vnode) {
  if (!vnode) return;

  const domParent = vnode.parent.dom;
  domParent.appendChild(vnode.dom);

  commitWork(vnode.child);

  commitWork(vnode.sibling);
}

Như các bạn thấy rằng việc phải lưu lại một bản sao của DOM dưới dạng object và checking nó thay đổi ở đâu rồi render ở lại là tốn bộ nhớ là tất nhiên rồi 😂, ngoài ra performance của react vẫn không phải quá nhanh trong năm 2023 này nên mong tương lại react sẽ phát triển gì đó hay ho hơn để cải thiện, và anh em lại được học cái mới 😃.

Giờ chạy thử nào với tạo project mới với vite để sử dụng được script dạng module 😅😅 vite.config.js

import { defineConfig } from 'vite'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [],
})

package.json

{
  "name": "my-vdom",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "devDependencies": {
    "vite": "^5.0.0"
  },
    "author": "quangnv"
}

index.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

src/App.js chỗ này sử dụng object thay thế jsx do nếu muốn sử dụng jsx thì chúng ta cần phải viết plugin(vite or webpack) cho virtual dom mới tạo để parse từ code jsx sang cấu trúc hợp lệ với virtual DOM mới của chúng ta nên mình sẽ gợi ý một số thứ cần để mọi người tham khảo và thực hiện trong tương lai: JSX Babel parser, AST hoặc mình sẽ tiếp tục phần này trong tương lai 😇 còn tạm thời thì cứ như dưới đã.

function App() {
  return {
    tag: "div",
    props: {
      children: [
        {
          tag: "h1",
          props: {
            children: ["Hello, world!"],
            style: { fontFamily: "sans-serif" },
          },
        },
        {
          tag: "p",
          props: {
            children: ["This is a paragraph."],
            style: { backgroundColor: "red", fontSize: "30px" },
          },
        },
        {
            tag: "button",
            props: {
              children: ["Click me"],
              style: { backgroundColor: "green", fontSize: "30px", cursor: 'pointer' },
              onClick: () => {console.log('click')}
            },
          },
      ],
    },
  };
}

export default App;

src/vnode/vnode.js

function createVNode(tag, props) {
  return {
    tag,
    props,
    dom: null,
    parent: null,
    child: null,
    sibling: null
  };
}

const EVENTS = ["onClick", "onChange"];

function createDom(vnode) {
  const dom =
    vnode.tag === "TEXT_ELEMENT"
      ? document.createTextNode(vnode.props.textContent)
      : document.createElement(vnode.tag);
  if (vnode.tag !== "TEXT_ELEMENT") {
    const isProperty = (key) => key !== "children";
    Object.keys(vnode.props)
      .filter(isProperty)
      .forEach((name) => {
        if (name === "style") {
          const style = vnode.props.style;
          Object.keys(style).forEach((styleKey) => {
            dom.style[styleKey] = style[styleKey];
          });
        } else {
          dom[EVENTS.includes(name) ? name.toLowerCase() : name] =
            vnode.props[name];
        }
      });
  }

  return dom;
}

let nextUnitOfWork = null;
let wipRoot = null;

function render(element, container) {
  const rootVNode = createVNode("ROOT", { children: [element] });
  nextUnitOfWork = rootVNode;
  wipRoot = rootVNode;
  wipRoot.dom = container;

  while (nextUnitOfWork) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }

  if (wipRoot) {
    commitRoot(wipRoot);
  }
}

function performUnitOfWork(vnode) {
  if (!vnode.dom) {
    vnode.dom = createDom(vnode);
  }

  if (vnode.props.children) {
    renderChildren(vnode, vnode.props.children);
  }

  if (vnode.child) {
    return vnode.child;
  }
  let nextVNode = vnode;
  while (nextVNode) {
    if (nextVNode.sibling) {
      return nextVNode.sibling;
    }
    nextVNode = nextVNode.parent;
  }

  return null;
}

function renderChildren(wipVNode, elements) {
  let index = 0;
  let prevSibling = null;

  while (index < elements.length) {
    const element = elements[index];
    let newVNode = null;
    if (typeof element !== "string") {
      newVNode = createVNode(element.tag, element.props);
    } else {
      newVNode = createVNode("TEXT_ELEMENT", { textContent: element });
    }

    newVNode.parent = wipVNode;

    if (index === 0) {
      wipVNode.child = newVNode;
    } else {
      prevSibling.sibling = newVNode;
    }

    prevSibling = newVNode;
    index++;
  }
}

function commitRoot(rootVNode) {
  commitWork(rootVNode.child);
  console.log(wipRoot);
  wipRoot = null;
}

function commitWork(vnode) {
  if (!vnode) return;

  const domParent = vnode.parent.dom;
  domParent.appendChild(vnode.dom);

  commitWork(vnode.child);

  commitWork(vnode.sibling);
}

export { render };

src/main.js


import { render } from './vnode/vnode';
import App from './App';

const rootElement = document.getElementById('root');
render(App(), rootElement);

image.png

Nhớ npm install hoặc pnpm install trước để cài vite rồi run với cmd pnpm dev và kết quả đây

Xịn xò phết rồi nhể. Có lẽ dừng bài viết về virtual DOM ở đây thôi chứ dài lê thê quá mà code thì lắm hehe. Một số chỗ có thể khá khó hiểu nên tốt nhất là mọi người hãy tự thực hiện lại các step để ngấm sâu hơn và không bị mông lung về virtual DOM nữa. À quên cái virtual DOM này rõ ràng là rất sơ khai và thiếu tính năng ví dụ như update DOM, state, effect các kiểu nhưng mà thôi nếu làm hết thì mấy anh facebook cần gì làm cái repo to tổ chảng facebook/react nữa.

Github: https://github.com/quangnv13/mini-virtual-dom


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí