从0实现一个tiny react(二)
考虑一下这个例子 在线演示地址:
class AppWithNoVDOM extends Component { constructor(props) { super(props) } testApp3() { let result = [] for(let i = 0; i < 10000 ; i++) { result.push(<div style={{ width: '30px',color: 'red',fontSize: '12px',fontWeight: 600,height: '20px',textAlign: 'center',margin:'5px',padding: '5px',border:'1px solid red',position: 'relative',left: '10px',top: '10px',}} title={i} >{i}</div>) } return result } render() { return ( <div width={100}> <a onClick={e => { this.setState({}) }}>click me</a> {this.testApp3()} </div> ) } } const startTime = new Date().getTime() render(<App/>,document.getElementById("root")) console.log("duration:",new Date().getTime() - startTime) ... setState(state) { setTimeout(() => { this.state = state const vnode = this.render() let olddom = getDOM(this) const startTime = new Date().getTime() render(vnode,olddom.parentNode,this,olddom) console.log("duration:",new Date().getTime() - startTime) },0) } ...
我们在 render,setState 设置下时间点。 在10000万个div的情况下, 第一次render和setState触发的render 耗时大概在180ms (可能跟机器配置有关)
首次render将会创建大量的DOM元素, 耗时不可避免。 但是setState引起的渲染, 完全是可以复用之前创建的dom的,因为这里只是调用一下setState,并没有实质操作, 实际上DOM一点也没改。 <br/>
毕竟dom操作是很慢
复用DOM
回想一下,在 (一) 里面对于每一个判定为 dom类型的VDOM, 是直接创建一个新的DOM:
... else if(typeof vnode.nodeName == "string") { dom = document.createElement(vnode.nodeName) ... } ...
一定要创建一个 新的DOM 结构吗?<br/>
考虑这种情况:假如一个组件, 初次渲染为 renderBefore, 调用setState再次渲染为 renderAfter 调用setState再再次渲染为 renderAfterAfter。 VDOM如下
const renderBefore = { tagName: 'div',props: { width: '20px',className: 'xx' },children:[vnode1,vnode2,vnode3] } const renderAfter = { tagName: 'div',props: { width: '30px',title: 'yy' },vnode2] } const renderAfterAfter = { tagName: 'span',props: { className: 'xx' },vnode3] }
renderBefore 和renderAfter 都是div, props和children有部分区别,可以通过DOM操作把 rederBefore 变化为renderAfter, 从而避开DOM创建。 而 renderAfter和renderAfterAfter
属于不同的DOM类型, 浏览器还没提供修改DOM类型的Api,是无法复用的, 是一定要创建新的DOM的。
可以得出几个原则如下:
- 不同元素类型是无法复用的, span 是无法变成 div的。
-
对于相同元素:
- 更新属性,
- 复用子节点。
现在的代码可能是这样的:
... else if(typeof vnode.nodeName == "string") { if(!olddom || olddom.nodeName != vnode.nodeName.toUpperCase()) { createNewDom(vnode,parent,comp,olddom) } else { diffDOM(vnode,olddom) // 包括 更新属性, 子节点复用 } } ...
更新属性
对于 renderBefore => renderAfter 。 属性部分需要做3件事情。
- renderBefore 和 renderAfter 的属性交集 如果值不同, 更新值 updateAttr
- renderBefore 和 renderAfter 的属性差集 置空 removeAttr
- renderAfter 和 renderBefore 的属性差集 设置新值 setAttr
const {onlyInLeft,bothIn,onlyInRight} = diffObject(newProps,oldProps) setAttrs(olddom,onlyInLeft) removeAttrs(olddom,onlyInRight) diffAttrs(olddom,bothIn.left,bothIn.right) function diffObject(leftProps,rightProps) { const onlyInLeft = {} const bothLeft = {} const bothRight = {} const onlyInRight = {} for(let key in leftProps) { if(rightProps[key] === undefined) { onlyInLeft[key] = leftProps[key] } else { bothLeft[key] = leftProps[key] bothRight[key] = rightProps[key] } } for(let key in rightProps) { if(leftProps[key] === undefined) { onlyInRight[key] = rightProps[key] } } return { onlyInRight,onlyInLeft,bothIn: { left: bothLeft,right: bothRight } } } function setAttrs(dom,props) { const allKeys = Object.keys(props) allKeys.forEach(k => { const v = props[k] if(k == "className") { dom.setAttribute("class",v) return } if(k == "style") { if(typeof v == "string") { dom.style.cssText = v //IE } if(typeof v == "object") { for (let i in v) { dom.style[i] = v[i] } } return } if(k[0] == "o" && k[1] == "n") { const capture = (k.indexOf("Capture") != -1) dom.addEventListener(k.substring(2).toLowerCase(),v,capture) return } dom.setAttribute(k,v) }) } function removeAttrs(dom,props) { for(let k in props) { if(k == "className") { dom.removeAttribute("class") continue } if(k == "style") { dom.style.cssText = "" //IE continue } if(k[0] == "o" && k[1] == "n") { const capture = (k.indexOf("Capture") != -1) const v = props[k] dom.removeEventListener(k.substring(2).toLowerCase(),capture) continue } dom.removeAttribute(k) } } /** * 调用者保证newProps 与 oldProps 的keys是相同的 * @param dom * @param newProps * @param oldProps */ function diffAttrs(dom,newProps,oldProps) { for(let k in newProps) { let v = newProps[k] let ov = oldProps[k] if(v === ov) continue if(k == "className") { dom.setAttribute("class",v) continue } if(k == "style") { if(typeof v == "string") { dom.style.cssText = v } else if( typeof v == "object" && typeof ov == "object") { for(let vk in v) { if(v[vk] !== ov[vk]) { dom.style[vk] = v[vk] } } for(let ovk in ov) { if(v[ovk] === undefined){ dom.style[ovk] = "" } } } else { //typeof v == "object" && typeof ov == "string" dom.style = {} for(let vk in v) { dom.style[vk] = v[vk] } } continue } if(k[0] == "o" && k[1] == "n") { const capture = (k.indexOf("Capture") != -1) let eventKey = k.substring(2).toLowerCase() dom.removeEventListener(eventKey,ov,capture) dom.addEventListener(eventKey,capture) continue } dom.setAttribute(k,v) } }
'新'的dom结构 属性和 renderAfter对应了。<br/>
但是 children部分 还是之前的
操作子节点
之前 操作子节点的代码:
for(let i = 0; i < vnode.children.length; i++) { render(vnode.children[i],dom,null,null) }
render 的第3个参数comp '谁渲染了我', 第4个参数olddom '之前的旧dom元素'。现在复用旧的dom, 所以第4个参数可能是有值的 代码如下:
let olddomChild = olddom.firstChild for(let i = 0; i < vnode.children.length; i++) { render(vnode.children[i],olddom,olddomChild) olddomChild = olddomChild && olddomChild.nextSibling } //删除多余的子节点 while (olddomChild) { let next = olddomChild.nextSibling olddom.removeChild(olddomChild) olddomChild = next }
综上所述 完整的diffDOM 如下:
function diffDOM(vnode,olddom) { const {onlyInLeft,onlyInRight} = diffObject(vnode.props,olddom.__vnode.props) setAttrs(olddom,onlyInLeft) removeAttrs(olddom,onlyInRight) diffAttrs(olddom,bothIn.right) let olddomChild = olddom.firstChild for(let i = 0; i < vnode.children.length; i++) { render(vnode.children[i],olddomChild) olddomChild = olddomChild && olddomChild.nextSibling } while (olddomChild) { //删除多余的子节点 let next = olddomChild.nextSibling olddom.removeChild(olddomChild) olddomChild = next } olddom.__vnode = vnode }
由于需要在diffDOM的时候 从olddom获取 oldVNODE(即 diffObject(vnode.props,olddom.__vnode.props))。 所以:
// 在创建的时候 ... let dom = document.createElement(vnode.nodeName) dom.__vnode = vnode ... // diffDOM ... const {onlyInLeft,olddom.__vnode.props) ... olddom.__vnode = vnode // 更新完之后, 需要把__vnode的指向 更新 ...
另外 对于 TextNode的复用:
... if(typeof vnode == "string" || typeof vnode == "number") { if(olddom && olddom.splitText) { if(olddom.nodeValue !== vnode) { olddom.nodeValue = vnode } } else { dom = document.createTextNode(vnode) if(olddom) { parent.replaceChild(dom,olddom) } else { parent.appendChild(dom) } } } ...
重新 跑一下开头 的例子 新的复用DOM演示 setState后渲染时间变成了 20ms 左右。 从 180ms 到20ms 差不多快有一个数量级的差距了。
到底快了多少,取决于前后结构的相似程度, 如果前后结构基本相同,diff是有意义的减少了DOM操作。
复用子节点 - key
初始渲染 ... render() { return ( <div> <WeightCompA/> <WeightCompB/> <WeightCompC/> </div> ) } ... setState再次渲染 ... render() { return ( <div> <span>hi</span> <WeightCompA/> <WeightCompB/> <WeightCompC/> </div> ) } ...
我们之前的子节点复用顺序就是按照DOM顺序, 显然这里如果这样处理的话, 可能导致组件都复用不了。 针对这个问题, React是通过给每一个子组件提供一个 "key"属性来解决的
对于拥有 同样key的节点, 认为结构相同。 所以问题变成了:
f([{key: 'wca'},{key: 'wcb},{key: 'wcc}]) = [{key:'spanhi'},{key: 'wca'},{key: 'wcc}]
函数f 通过删除, 插入操作,把olddom的children顺序, 改为和 newProps里面的children一样 (按照key值一样)。类似与 字符串距离,
对于这个问题, 我将会另开一篇文章
总结
通过 diff 比较渲染前后 DOM的差别来复用实际的, 我们的性能得到了提高。现在 render方法的描述: <br/>
render 方法是根据的vnode, 渲染到实际的dom,如果存在olddom会先尝试复用的 一个递归方法 (由于组件 最终一定会render html的标签。 所以这个递归一定是能够正常返回的)