怎样手写 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 的三个子节点是怎么来的。
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)?》。