TinyReact是一个极小的React-like库,用来演示React的工作原理。
TinyReact绝大部分思路来源于Building Your Own React Clone in Five Easy Steps。实现上略有区别。
前言
由于React要兼顾各种场景,其代码难免难以阅读。本文以仅120行源代码的库TinyReact演示React的核心概念vDOM和vDOM diff。
原理
React的核心本质上是增量更新。也就是说在初始化时会构建整个DOM Tree。之后如果有任何数据变化时,只需要更新变化了的部分。
为了跟踪变化,React引入了Element(Virtual DOM或者vDOM或者虚拟DOM)的概念。且在每次渲染时保留完整的vDOM。并在更新时比较本次生成的vDOM与上次的差异,这个过程叫做diff。然后根据差异对已有DOM作修改,这个过程叫patch(可参考git的diff&patch加深理解)。
我们对照React的例子来逐步构建TinyReact。
一个简单的例子,演示我们是如何使用TinyReact的
在React中,如果不用jsx,一般写法是这样
ReactDOM.render(React.createElement('div', {}, 'Hello TinyReact'), document.getElementById('app'))
在TinyReact中,renderDOM()
对应ReactDOM.render()
,createVDOM()
对应React.createElement()
renderDOM(createVDOM('div', {}, 'Hello TinyReact'), document.getElementById('app'))
createVDOM(...)
createVDOM(...)
会够建一个Plain Object。
function createVDOM(tag, props, ...children) {
return {
tag,
props,
children // 递归结构
}
}
我们的html(DOM)通常是多层嵌套的,所以vDOM通常也是一个递归结构,如:
{
"tag": "div",
"props": {className: 'root'},
"children": [
{
"tag": "div",
"props": {className: 'inner'},
"children": [
{
"tag": "input",
"props": {},
"children": []
},
]
}
]
}
renderDOM(nextVDOM, parent)
将vDOM渲染到rootDOM节点下。
function renderDOM(nextVDOM, rootDOM) {
let prevVDOM = rootDOM._vDOM
rootDOM._vDOM = nextVDOM // 将新的vDOM保存下来
let patches = diff(prevVDOM, nextVDOM, rootDOM)
console.log(patches) // Show what patches will be applied
patch(patches)
}
参照以上代码
- 初次访问
renderDOM()
,prevVDOM为空,会根据nextVDOM创建整个DOM Tree。 - 再次访问
renderDOM()
,prevVDOM为之前的vDOM,会根据diff()
的结果做增量更新(patch)。
diff(prevVDOM, nextVDOM, parent)
完整比较两棵树的时间复杂度为O(n3)。实际上这在前端是不可接受的。
React基于以下两个假设
(assumptions)将复杂度降低到了O(n)。
- Two components of the same class will generate similar trees and two components of different classes will generate different trees.
- It is possible to provide a unique key for elements that is stable across different renders.
根据第一个假设,只需要逐层比较,不用比较不同层级。如果标签(tag)不同,就会新建DOM并替换掉旧的DOM。 我们暂时不考虑第二个假设。为了简化问题,我们假设各元素在数组中的位置不会发生变化
function diff(prevVDOM, nextVDOM, parent) {
if (prevVDOM && nextVDOM) {
nextVDOM._dom = prevVDOM._dom
}
let diffs = []
if (!prevVDOM) {
diffs.push({type: 'create', prevVDOM, nextVDOM, parent})
} else if (!nextVDOM) {
diffs.push({type: 'remove', prevVDOM, nextVDOM, parent})
} else if (isPlainObject(prevVDOM) && isPlainObject(nextVDOM)) {
if (prevVDOM.tag === nextVDOM.tag) {
...
} else {
diffs.push({type: 'update', prevVDOM, nextVDOM, parent})
}
} else if (prevVDOM !== nextVDOM) {
diffs.push({type: 'update', prevVDOM, nextVDOM, parent})
}
return diffs
}
diff()
会返回一个patch对象数组,用以表示有哪些变动。patch对象结构如下:
{
type,
prevVDOM,
nextVDOM,
parent
}
属性值变化
标签(tag)相同的情况下,比较属性值是否有变化
diffs = diffs.concat(diffProps(prevVDOM.props, nextVDOM.props, prevVDOM._dom))
function diffProps(prevProps = [], nextProps = [], dom) {
return Object.keys(prevProps).reduce((diffs, key) => {
if (prevProps[key] !== nextProps[key]) {
diffs.push({
dom,
key,
value: nextProps[key],
type: 'updateProp'
})
}
return diffs
}, [])
}
该部分返回的patch略有不同。
子节点(children)变化
标签(tag)相同的情况下,要检测子节点(children)的变化。
为了简化问题,我们假设各元素在数组中的位置不会发生变化。
let checkedIndex = -1
;(prevVDOM.children || []).forEach((prevChild, index) => {
checkedIndex = index
let nextChild = (nextVDOM.children || [])[index]
diffs = diffs.concat(diff(prevChild, nextChild, prevVDOM._dom))
})
;(nextVDOM.children || []).forEach((nextChild, index) => {
if (checkedIndex >= index) return
let prevChild = (prevVDOM.children || [])[index]
diffs = diffs.concat(diff(prevChild, nextChild, prevVDOM._dom))
})
可以发现这里递归调用了diff()
,这样一次遍历完整个vDOM Tree就可以得到所有的patches。
patch(patches)
增量更新
function patch(patches) {
patches.forEach(patch => {
let {prevVDOM, nextVDOM, parent} = patch
if (patch.type === 'create') { // 创建新的DOM对象,并添加
let dom = createDOM(nextVDOM)
parent.appendChild(dom)
} else if (patch.type === 'remove') { // 直接移除DOM
parent.removeChild(prevVDOM._dom)
} else if (patch.type === 'update') { // 创建新DOM并替换
let dom = createDOM(nextVDOM)
parent.replaceChild(dom, prevVDOM._dom)
} else if (patch.type === 'updateProp') { // 属性修改
patch.dom[patch.key] = patch.value
}
})
}
有两种类型的DOM,一种是用Plain Object表示的vDOM,另一种是string表示的文本节点。
function createDOM(vdom) {
let dom = null
if (isPlainObject(vdom)) {
let {tag, props = {}, children = []} = vdom
dom = document.createElement(tag)
children.forEach(child => {
dom.appendChild(createDOM(child)) // 递归创建子节点
})
Object.keys(props).forEach(key => {
dom[key] = props[key]
})
} else {
dom = document.createTextNode(vdom)
}
vdom._dom = dom
return dom
}
结束语
这是一个极其简化的实现,只是为了演示主要流程,很多问题并没有考虑。进一步建议阅读preact及inferno的源码,这两个库相对React来说还是简单不少。
欢迎各种PR和Issue。