前端知库

怎样手写 h() 和 render,把虚拟 DOM 挂到真实 DOM 上?

h(type, props, ...children) 只是返回一个描述节点的普通对象,子节点要摊平嵌套数组并去掉 null、undefined、true、false。render 递归处理这个对象:字符串和数字用 createTextNode 建文本节点,元素用 createElement 建,再按属性类型设置 class、style、事件监听和普通特性,最后把子节点逐个 appendChild 进去。

参考答案

简答

虚拟 DOM 就是用普通对象描述界面。h('li', { className: 'item' }, '苹果') 返回 { type: 'li', props: { className: 'item' }, children: ['苹果'] },这一步不碰真实 DOM。Vue 的 h()、React 的 createElement 做的都是这件事。

h 里要整理子节点。子节点可能是 items.map(...) 返回的数组,要摊平;条件渲染会留下 false、null,要去掉。React 文档写明,null、undefined、true、false 都是空节点。

render 是递归:遇到字符串或数字,用 document.createTextNode 建文本节点;遇到元素,用 document.createElement 建元素,再按属性名分别处理,className 写成 class 特性,style 对象逐项赋值,on 开头的函数注册成事件监听,其他写成普通特性;最后把子节点逐个建好再 appendChild 进去。

要注意,这只是首次挂载。数据变了以后,真正的框架不会整棵重建,而是对比新旧虚拟 DOM 只改变化的部分,那是 diff 要解决的问题。另外文本要用 createTextNode,不要拼成 HTML 字符串再赋给 innerHTML,否则用户输入里的标签会被当成 HTML 解析。

手写 h 和 render

render 不直接用全局的 document,而是把它当参数传进来。这样同一份代码在浏览器里传真实的 document,在 Node.js 里可以传一个只实现了几个方法的模拟对象,方便运行验证。看最后一个 li 的三个子节点是怎么来的。

JavaScript
function h(type, props, ...children) {
  return {
    type,
    props: props ?? {},
    // 子节点可能是嵌套数组(比如 map 的结果),也可能是字符串和数字,统一摊平后再处理。
    children: children
      .flat(Infinity)
      .filter((child) => child !== null && child !== undefined && child !== false && child !== true),
  };
}

function render(vnode, container, doc) {
  container.appendChild(createDom(vnode, doc));
}

function createDom(vnode, doc) {
  if (typeof vnode === 'string' || typeof vnode === 'number') {
    return doc.createTextNode(String(vnode));
  }
  const el = doc.createElement(vnode.type);
  for (const [key, value] of Object.entries(vnode.props)) {
    if (key.startsWith('on') && typeof value === 'function') {
      el.addEventListener(key.slice(2).toLowerCase(), value);
    } else if (key === 'className') {
      el.setAttribute('class', value);
    } else if (key === 'style' && typeof value === 'object') {
      for (const [name, v] of Object.entries(value)) el.style[name] = v;
    } else {
      el.setAttribute(key, String(value));
    }
  }
  for (const child of vnode.children) el.appendChild(createDom(child, doc));
  return el;
}

// 用来演示的极简 document,只实现 render 用到的几个方法。
function createFakeDocument() {
  function createElement(tagName) {
    return {
      tagName,
      attributes: {},
      style: {},
      childNodes: [],
      listeners: {},
      setAttribute(name, value) { this.attributes[name] = value; },
      appendChild(child) { this.childNodes.push(child); return child; },
      addEventListener(type, fn) { this.listeners[type] = fn; },
    };
  }
  return {
    createElement,
    createTextNode: (text) => ({ nodeType: 3, textContent: text }),
  };
}

function toHtml(node) {
  if (node.nodeType === 3) return node.textContent;
  const attrs = Object.entries(node.attributes).map(([k, v]) => ` ${k}="${v}"`).join('');
  const style = Object.entries(node.style).map(([k, v]) => `${k}:${v}`).join(';');
  const styleAttr = style ? ` style="${style}"` : '';
  return `<${node.tagName}${attrs}${styleAttr}>${node.childNodes.map(toHtml).join('')}</${node.tagName}>`;
}

const doc = createFakeDocument();
const root = doc.createElement('div');
let clicks = 0;
const items = ['苹果', '香蕉'];

const vnode = h('ul', { className: 'list', style: { color: 'red' } },
  items.map((item) => h('li', null, item)),
  false,
  h('li', { onClick: () => { clicks += 1; } }, '共 ', items.length, ' 项'),
);

render(vnode, root, doc);
const ul = root.childNodes[0];

console.log(toHtml(ul)); // <ul class="list" style="color:red"><li>苹果</li><li>香蕉</li><li>共 2 项</li></ul>
console.log(ul.childNodes.length); // 3
console.log(ul.childNodes[2].childNodes.length); // 3
ul.childNodes[2].listeners.click();
console.log(clicks); // 1

先看 h 的结果。items.map(...) 返回的是数组,它整个作为 h('ul', ...) 的一个子节点传进来,flat(Infinity) 把它摊平成两个 li。false 来自条件渲染的写法,比如 isEmpty && h('p', null, '暂无'),条件不成立时留下的就是 false,被过滤掉了。所以 ul 最后只有 3 个子节点。

最后一个 li 有 3 个子节点,因为 '共 '、2、' 项' 是三个参数,各自建了一个文本节点。数字 2 在 createDom 里先转成字符串,再交给 createTextNode。浏览器把相邻的文本节点显示在一起,所以页面上看到的是“共 2 项”。

为什么 true 也要过滤?因为 React 把 null、undefined、true、false 都当作空节点,不渲染出任何东西,这里也照这个规则。注意数字 0 不在其中,count && h(...) 在 count 为 0 时会渲染出一个“0”。

属性为什么要分几种处理

props 里的东西不全是 HTML 特性,混在一起直接 setAttribute 会出问题。

className 是 JavaScript 这一侧的名字,因为 class 是保留字,对应的 HTML 特性叫 class,所以要换名字再写。style 传的是对象,setAttribute('style', obj) 只会得到 "[object Object]",要逐项写到元素的 style 上;MDN 说明 element.style 读写的是元素的行内样式。onClick 是函数,写成特性没有意义,要去掉 on 前缀、转成小写,用 addEventListener('click', fn) 注册。

这只是最常见的几类。真实框架还要处理 value、checked 这类应该设置 DOM 属性而不是 HTML 特性的情况,以及 key、ref 这类根本不该出现在 DOM 上的特殊属性。React 文档写明,key 和 ref 不会出现在 element.props 上。

为什么不拼 HTML 字符串

也可以让 render 输出 HTML 字符串,再赋给 container.innerHTML,服务端渲染就是这样输出的。可是这样一来,文本里的 <、& 都必须先转义,漏一个地方,用户输入的 <img onerror=...> 就会被浏览器当成标签执行。用 createTextNode 建文本节点,内容始终是纯文本,不会被解析成 HTML。

另外字符串没法携带事件函数,拼完以后还得再找到元素去绑定事件。直接建 DOM 节点时,可以在创建的同时注册监听。

数据变了以后怎么办

上面的 render 每次都从头建一棵新的 DOM 树。数据一变就整棵重建,代价大,原来节点上的状态也跟着没了。框架的做法是保留上一次的虚拟 DOM,生成新的以后两边对比,只把差异应用到真实 DOM 上。React 的做法见《React 的 diff 算法怎样处理单节点和多节点?key 起什么作用?和 Vue 有什么不同?》。

常见追问

为什么 h 要把 props 为 null 的情况变成空对象?

不写属性时通常传 null,后面遍历属性时就不用每次判断。React 文档写明,createElement 的 props 传 null 和传空对象效果一样。

怎样支持函数组件?

在 createDom 里多判断一种情况:vnode.type 是函数时,就用 { ...vnode.props, children: vnode.children } 调用它,拿到它返回的虚拟 DOM,再对返回值递归调用 createDom。组件本身不对应任何真实节点,只负责产出下一层的描述。加上状态以后的写法见《怎样写一个 mini React(createElement、render、useState)?》。