從0實現一個tiny react(二)
ui = f(d)! 這是react考慮ui的方式,開發者可以把重心放到d 資料上面來了。 從開發者的角度來講 d一旦改變,react將會把ui重新渲染,使其再次滿足
ui = f(d), 開發者沒有任何dom操作, 交給react就好!!
怎麼重新渲染呢? (一)文 中我們實現了一種方式, state改變的時候,用新的dom樹替換一下老的dom樹, 這是完全可行的。
考慮一下這個例子 線上演示地址:
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 (可能跟機器配置有關)
當點選的時候, 由於呼叫this.setState({})
, 頁面將會重新渲染, 再次建立10000萬個div, 但是實際上這裡的DOM一點也沒改。
應用越複雜, 無用功越多,卡頓越明顯
為了解決這個問題, react提出了virtual-dom的概念:vnode(純js物件) `代表` dom, 在渲染之前, 先比較出oldvnode和newvode的 區別。 然後增量的
更新dom。 virtual-dom 使得ui=f(d) 得以在實際專案上使用。
(注意: virtual-dom 並不會加快應用速度, 只是讓應用在不直接操作dom的情況下,通過暴力的比較,增量更新 讓應用沒有那麼慢)
如何增量更新呢?
複用DOM
回想一下, 在 (一) render函式 裡面對於每一個判定為 dom型別的VDOM, 是直接建立一個新的DOM:
...
else if(typeof vnode.nodeName == "string") {
dom = document.createElement(vnode.nodeName)
...
}
...
一定要建立一個 新的DOM 結構嗎?<br/>
考慮這種情況:假如一個元件, 初次渲染為 renderBefore, 呼叫setState再次渲染為 renderAfter 呼叫setState再再次渲染為 renderAfterAfter。 VNODE如下
const renderBefore = {
tagName: `div`,
props: {
width: `20px`,
className: `xx`
},
children:[vnode1, vnode2, vnode3]
}
const renderAfter = {
tagName: `div`,
props: {
width: `30px`,
title: `yy`
},
children:[vnode1, vnode2]
}
const renderAfterAfter = {
tagName: `span`,
props: {
className: `xx`
},
children:[vnode1, vnode2, vnode3]
}
renderBefore 和renderAfter 都是div, 只不過props和children有部分割槽別,那我們是不是可以通過修改DOM屬性, 修改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, parent, comp, 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(), v, 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, v, 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, null, olddomChild)
olddomChild = olddomChild && olddomChild.nextSibling
}
//刪除多餘的子節點
while (olddomChild) {
let next = olddomChild.nextSibling
olddom.removeChild(olddomChild)
olddomChild = next
}
綜上所述 完整的diffDOM 如下:
function diffDOM(vnode, parent, comp, olddom) {
const {onlyInLeft, bothIn, onlyInRight} = diffObject(vnode.props, olddom.__vnode.props)
setAttrs(olddom, onlyInLeft)
removeAttrs(olddom, onlyInRight)
diffAttrs(olddom, bothIn.left, bothIn.right)
let olddomChild = olddom.firstChild
for(let i = 0; i < vnode.children.length; i++) {
render(vnode.children[i], olddom, null, 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, bothIn, onlyInRight} = diffObject(vnode.props, 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: `wcb}, {key: `wcc}]
函式f 通過刪除, 插入操作,把olddom的children順序, 改為和 newProps裡面的children一樣 (按照key值一樣)。類似與 字串距離,
對於這個問題, 我將會另開一篇文章
總結
通過 diff 比較渲染前後 DOM的差別來複用實際的, 我們的效能得到了提高。現在 render方法的描述: <br/>
render 方法是根據的vnode, 渲染到實際的dom,如果存在olddom會先嚐試複用的 一個遞迴方法 (由於元件 最終一定會render html的標籤。 所以這個遞迴一定是能夠正常返回的)
- vnode是字串, 如果存在olddom, 且可以複用, 複用之。否則建立textNode節點
- 當vnode.nodeName是 字串的時候, 如果存在olddom, 且可以複用, 複用之。否則建立dom節點, 根據props設定節點屬性, 遍歷render children
- 當vnode.nodeName是 function的時候, 獲取render方法的返回值 vnode`, 執行render(vnode`)