Vue3?如何通過虛擬DOM更新頁面詳解
引言
上一講我們主要介紹了 Vue 項(xiàng)目的首次渲染流程,在 mountComponent 中注冊了effect 函數(shù),這樣,在組件數(shù)據(jù)有更新的時(shí)候,就會通知到組件的 update 方法進(jìn)行更新
Vue 中組件更新的方式也是使用了響應(yīng)式 + 虛擬 DOM 的方式,這個(gè)我們在第一講中有介紹過 Vue 1、Vue 2 和 Vue 3 中更新方式的變化,今天我們就來詳細(xì)剖析一下 Vue 組件內(nèi)部如何通過虛擬 DOM 更新頁面的代碼細(xì)節(jié)
Vue 虛擬 DOM 執(zhí)行流程
我們從虛擬 DOM 在 Vue 的執(zhí)行流程開始講起。在 Vue 中,我們使用虛擬 DOM 來描述頁面的組件,比如下面的 template 雖然格式和 HTML 很像,但是在 Vue 的內(nèi)部會解析成 JavaScript 函數(shù),這個(gè)函數(shù)就是用來返回虛擬 DOM:
<div id="app"> <p>hello world</p> <Rate :value="4"></Rate> </div>
上面的 template 會解析成下面的函數(shù),最終返回一個(gè) JavaScript 的對象能夠描述這段HTML:
function render(){ return h('div',{id:"app"},children:[ h('p',{},'hello world'), h(Rate,{value:4}), ]) }
知道虛擬 DOM 是什么之后,那么它是怎么創(chuàng)建的呢?
DOM 的創(chuàng)建
我們簡單回憶上一講介紹的 mount 函數(shù),在代碼中,我們使用 createVNode 函數(shù)創(chuàng)建項(xiàng)目的虛擬 DOM,可以看到 Vue 內(nèi)部的虛擬 DOM,也就是 vnode,就是一個(gè)對象,通過 type、props、children 等屬性描述整個(gè)節(jié)點(diǎn):
const vnode = createVNode( ( rootComponent as ConcreteComponent, rootProps ) function _createVNode() { // 處理屬性和 class if (props) { ... } // 標(biāo)記vnode信息 const shapeFlag = isString(type) ? ShapeFlags.ELEMENT : __FEATURE_SUSPENSE__ && isSuspense(type) ? ShapeFlags.SUSPENSE : isTeleport(type) ? ShapeFlags.TELEPORT : isObject(type) ? ShapeFlags.STATEFUL_COMPONENT : isFunction(type) ? ShapeFlags.FUNCTIONAL_COMPONENT : 0 return createBaseVNode( type, props, children, patchFlag, dynamicProps, shapeFlag, isBlockNode, true ) } function createBaseVNode(type,props,children,...){ const vnode = { type, props, key: props && normalizeKey(props), ref: props && normalizeRef(props), children, shapeFlag, patchFlag, dynamicProps, ... } as VNode // 標(biāo)準(zhǔn)化子節(jié)點(diǎn) if (needFullChildrenNormalization) { normalizeChildren(vnode, children) } else if (children) { vnode.shapeFlag |= isString(children) ? ShapeFlags.TEXT_CHILDREN : ShapeFlags.ARRAY_CHILDREN } return vnode }componentUpdateFn
createVNode 負(fù)責(zé)創(chuàng)建 Vue 中的虛擬 DOM,而上一講中我們講過 mount 函數(shù)的核心邏輯就是使用 setupComponent 執(zhí)行我們寫的 <script setup>
,使用 setupRenderEffect 監(jiān)聽組件的數(shù)據(jù)變化;所以我們來到 setupRenderEffect 函數(shù)中,去完整地剖析 Vue 中虛擬 DOM 的更新邏輯
我們給組件注冊了 update 方法,這個(gè)方法使用 effect 包裹后,當(dāng)組件內(nèi)的 ref、reactive 包裹的響應(yīng)式數(shù)據(jù)變化的時(shí)候就會執(zhí)行 update 方法,觸發(fā)組件內(nèi)部的更新機(jī)制
看下面的代碼,在 setupRenderEffect 內(nèi)部的 componentUpdateFn 中,updateComponentPreRenderer 更新了屬性和 slots,并且調(diào)用 renderComponentRoot 函數(shù)創(chuàng)建新的子樹對象 nextTree,然后內(nèi)部依然是調(diào)用 patch 函數(shù)
可以看到,Vue 源碼中的實(shí)現(xiàn)首次渲染和更新的邏輯都寫在一起,我們在遞歸的時(shí)候如果對一個(gè)標(biāo)簽實(shí)現(xiàn)更新和渲染,就可以用一個(gè)函數(shù)實(shí)現(xiàn)
const componentUpdateFn = ()=>{ if (!instance.isMounted) { //首次渲染 instance, parentSuspense, isSVG ) 。。。 }else{ let { next, bu, u, parent, vnode } = instance if (next) { next.el = vnode.el updateComponentPreRender(instance, next, optimized) } else { next = vnode } const nextTree = renderComponentRoot(instance) patch( prevTree, nextTree, // parent may have changed if it's in a teleport hostParentNode(prevTree.el!)!, // anchor may have changed if it's in a fragment getNextHostNode(prevTree), instance, parentSuspense, isSVG ) } } // 注冊effect函數(shù) const effect = new ReactiveEffect( componentUpdateFn, () => queueJob(instance.update), instance.scope // track it in component's effect scope ) const update = (instance.update = effect.run.bind(effect) as S chedulerJo update() const updateComponentPreRender = ( instance: ComponentInternalInstance, nextVNode: VNode, optimized: boolean ) => { nextVNode.component = instance const prevProps = instance.vnode.props instance.vnode = nextVNode instance.next = null updateProps(instance, nextVNode.props, prevProps, optimized) updateSlots(instance, nextVNode.children, optimized) pauseTracking() // props update may have triggered pre-flush watchers. // flush them before the render update. flushPreFlushCbs(undefined, instance.update) resetTracking() }
比較關(guān)鍵的就是上面代碼中 32-39 行的 effect 函數(shù),負(fù)責(zé)注冊組件,這個(gè)函數(shù)也是 Vue 組件更新的入口函數(shù)
patch 函數(shù)
數(shù)據(jù)更新之后就會執(zhí)行 patch 函數(shù),下圖就是 patch 函數(shù)執(zhí)行的邏輯圖:
在 patch 函數(shù)中,會針對不同的組件類型執(zhí)行不同的函數(shù),組件我們會執(zhí)行 processComponent,HTML 標(biāo)簽我們會執(zhí)行 processElement:
function path(n1, n2, container){ const { type, shapeFlag } = n2 switch (type) { case Text: processText(n1, n2, container) break // 還有注釋,fragment之類的可以處理,這里忽略 default: // 通過shapeFlag判斷類型 if (shapeFlag & ShapeFlags.ELEMENT) { processElement(n1, n2, container, anchor) } else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) { processComponent(n1, n2, container) } } } function processComponent(n1, n2, container) { // 老規(guī)矩,沒有n1就是mount if (!n1) { // 初始化 component mountComponent(n2, container) } else { updateComponent(n1, n2, container) } }
由于更新之后不是首次渲染了,patch 函數(shù)內(nèi)部會執(zhí)行 updateComponent,看下面的 updateComponent 函數(shù)內(nèi)部,shouldUpdateComponent 會判斷組件是否需要更新,實(shí)際執(zhí)行的是 instance.update:
const instance = (n2.component = n1.component)! if (shouldUpdateComponent(n1, n2, optimized)) { // normal update instance.next = n2 // in case the child component is also queued, remove it to avoid // double updating the same child component in the same flush. invalidateJob(instance.update) // instance.update is the reactive effect. instance.update() } else { // no update needed. just copy over properties n2.component = n1.component n2.el = n1.el instance.vnode = n2 }
組件的子元素是由 HTML 標(biāo)簽和組件構(gòu)成,組件內(nèi)部的遞歸處理最終也是對 HTML 標(biāo)簽的處理,所以,最后組件的更新都會進(jìn)入到 processElement 內(nèi)部的 patchElement 函數(shù)中
patchElement 函數(shù)
在函數(shù) patchElement 中我們主要就做兩件事,更新節(jié)點(diǎn)自己的屬性和更新子元素
節(jié)點(diǎn)自身屬性的更新
先看自身屬性的更新,這里就能體現(xiàn)出 Vue 3 中性能優(yōu)化的思想,通過 patchFlag 可以做到按需更新:
如果標(biāo)記了 FULL_PROPS,就直接調(diào)用 patchProps;如果標(biāo)記了 CLASS,說明節(jié)點(diǎn)只有 class 屬性是動態(tài)的,其他的 style 等屬性都不需要進(jìn)行判斷和 DOM 操作
這樣就極大的優(yōu)化了屬性操作的性能
內(nèi)部執(zhí)行 hostPatchProp 進(jìn)行實(shí)際的 DOM 操作,你還記得上一講中 hostPatchProp 是從 nodeOps 中定義的嗎,其他動態(tài)屬性 STYLE、TEXT 等等也都是一樣的邏輯;Vue 3 的虛擬 DOM 真正做到了按需更新,這也是相比于 React 的一個(gè)優(yōu)勢
const patchElement = ( n1: VNode, n2: VNode, parentComponent: ComponentInternalInstance | null, parentSuspense: SuspenseBoundary | null, isSVG: boolean, slotScopeIds: string[] | null, optimized: boolean ) => { const el = (n2.el = n1.el!) let { patchFlag, dynamicChildren, dirs } = n2 patchFlag |= n1.patchFlag & PatchFlags.FULL_PROPS const oldProps = n1.props || EMPTY_OBJ const newProps = n2.props || EMPTY_OBJ // full diff patchChildren( n1, n2, el, null, parentComponent, parentSuspense, areChildrenSVG, slotScopeIds, false ) if (patchFlag > 0) { if (patchFlag & PatchFlags.FULL_PROPS) { patchProps( el, n2, oldProps, newProps, parentComponent, parentSuspense, isSVG ) } else { // class是動態(tài)的 if (patchFlag & PatchFlags.CLASS) { if (oldProps.class !== newProps.class) { hostPatchProp(el, 'class', null, newProps.class, isSVG) } } // style樣式是動態(tài)的 if (patchFlag & PatchFlags.STYLE) { hostPatchProp(el, 'style', oldProps.style, newProps.style, isSVG) } // 屬性需要diff if (patchFlag & PatchFlags.PROPS) { // const propsToUpdate = n2.dynamicProps! for (let i = 0; i < propsToUpdate.length; i++) { const key = propsToUpdate[i] const prev = oldProps[key] const next = newProps[key] // #1471 force patch value if (next !== prev || key === 'value') { hostPatchProp( el, key, prev, next, isSVG, n1.children as VNode[], parentComponent, parentSuspense, unmountChildren ) } } } } //文本是動態(tài)的 if (patchFlag & PatchFlags.TEXT) { if (n1.children !== n2.children) { hostSetElementText(el, n2.children as string) } } } }
子元素的更新
而子元素的更新是 patchChildren 函數(shù)負(fù)責(zé)的,這個(gè)函數(shù)也是虛擬 DOM 中難度最高的一個(gè)函數(shù),搞懂它還需要我們下一講中介紹的算法知識,今天我們就先理解它主要的實(shí)現(xiàn)思路
首先我們把子元素分成了文本、數(shù)組和空三個(gè)狀態(tài),新老子元素分別是這三種狀態(tài)的一個(gè),構(gòu)成了不同的執(zhí)行邏輯;這樣 patchChildren 內(nèi)部大致有五種情況需要處理:
- 如果新的子元素是空, 老的子元素不為空,直接卸載 unmount 即可
- 如果新的子元素不為空,老的子元素是空,直接創(chuàng)建加載即可
- 如果新的子元素是文本,老的子元素如果是數(shù)組就需要全部 unmount,是文本的話就需要執(zhí)行 hostSetElementText
- 如果新的子元素是數(shù)組,比如是使用 v-for 渲染出來的列表,老的子元素如果是空或者文本,直接 unmout 后,渲染新的數(shù)組即可
最復(fù)雜的情況就是新的子元素和老的子元素都是數(shù)組
最樸實(shí)無華的思路就是把老的子元素全部 unmount,新的子元素全部 mount,這樣雖然可以實(shí)現(xiàn)功能,但是沒法復(fù)用已經(jīng)存在的 DOM 元素,比如我們只是在數(shù)組中間新增了一個(gè)數(shù)據(jù),全部 DOM 都銷毀就有點(diǎn)太可惜了
所以,我們需要判斷出可以復(fù)用的 DOM 元素,如果一個(gè)虛擬 DOM 沒有改動或者屬性變了,不需要完全銷毀重建,而是更新一下屬性,最大化減少 DOM 的操作,這個(gè)任務(wù)就會交給 patchKeyedChildren 函數(shù)去完成
patchKeyedChildren 函數(shù),做的事情就是盡可能高效地把老的子元素更新成新的子元素,如何高效復(fù)用老的子元素中的 DOM 元素是 patchKeyedChildren 函數(shù)的難點(diǎn):
const patchChildren: PatchChildrenFn = ( n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized = false ) => { const c1 = n1 && n1.children const prevShapeFlag = n1 ? n1.shapeFlag : 0 const c2 = n2.children const { patchFlag, shapeFlag } = n2 // fast path if (patchFlag > 0) { if (patchFlag & PatchFlags.KEYED_FRAGMENT) { // this could be either fully-keyed or mixed (some keyed some not) // presence of patchFlag means children are guaranteed to be arrays patchKeyedChildren( c1 as VNode[], c2 as VNodeArrayChildren, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized ) return } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) { // unkeyed patchUnkeyedChildren( c1 as VNode[], c2 as VNodeArrayChildren, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized ) return } } // children has 3 possibilities: text, array or no children. if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { // text children fast path if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) { unmountChildren(c1 as VNode[], parentComponent, parentSuspense) } if (c2 !== c1) { hostSetElementText(container, c2 as string) } } else { if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) { // prev children was array if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { // two arrays, cannot assume anything, do full diff patchKeyedChildren( c1 as VNode[], c2 as VNodeArrayChildren, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized ) } else { // no new children, just unmount old unmountChildren(c1 as VNode[], parentComponent, parentSuspense, true } } else { // prev children was text OR null // new children is array OR null if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) { hostSetElementText(container, '') } // mount new if array if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { mountChildren( c2 as VNodeArrayChildren, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized ) } } } }
上面的代碼執(zhí)行邏輯如下圖所示,根據(jù) flags 判斷子元素的類型后,執(zhí)行不同的操作函數(shù):
patchChildren
最后就剩下 patchChildren 的實(shí)現(xiàn)了,這也是各類虛擬 DOM 框架中最難實(shí)現(xiàn)的函數(shù),我們需要實(shí)現(xiàn)一個(gè)高效的更新算法,能夠使用盡可能少的更新次數(shù),來實(shí)現(xiàn)從老的子元素到新的子元素的更新;
舉個(gè)例子,類似體育課站隊(duì)的時(shí)候,大家一開始站一排,但是順序是亂的,我們需要盡快把隊(duì)伍按照個(gè)頭左低右高排列
在 React 中,這種場景的處理邏輯是先進(jìn)行循環(huán),使用的是單側(cè)插入的算法,我們在排隊(duì)的時(shí)候挨個(gè)對比,如果你站我右邊,并且個(gè)頭比我高一點(diǎn),說明咱倆的相對位置和最終隊(duì)伍的位置是一致的,暫時(shí)不需要變化,如果你比我個(gè)頭矮,就需要去我左邊找到一個(gè)正確的位置插隊(duì)進(jìn)去
由于都只向單側(cè)插入,最后我們就會把所有的節(jié)點(diǎn)移動到正確的位置之上,這就是 React15 框架內(nèi)虛擬節(jié)點(diǎn) diff 的邏輯,初步實(shí)現(xiàn)了 DOM 的復(fù)用;而 Vue 2 借鑒了 snabbdom 的算法,在此基礎(chǔ)上做了第一層雙端對比的優(yōu)化
首先 Web 場景之下對一個(gè)數(shù)組元素的操作,很少有直接全部替換的,比如我們操作一個(gè)表格,大概率是更關(guān)心表格某一行的一個(gè)字段、新增一行、刪除一行,或者是對表格某個(gè)字段進(jìn)行排序,所以我們可以從純算法的場景之中加入實(shí)際應(yīng)用的場景
如果我們只是在表格里新增一行,那么可以不要一開始就開始循環(huán),而是可以先進(jìn)行節(jié)點(diǎn)的預(yù)判
比如,在下面的例子中,新的節(jié)點(diǎn)就是在老的節(jié)點(diǎn)中新增和刪除了幾個(gè)元素,我們在循環(huán)之前,先進(jìn)行頭部元素的判斷;在這個(gè)例子里,可以預(yù)判出頭部元素的 a、b、c、d 是一樣的節(jié)點(diǎn),說明節(jié)點(diǎn)不需要重新創(chuàng)建,我們只需要進(jìn)行屬性的更新,然后進(jìn)行隊(duì)尾元素的預(yù)判,可以判斷出 g 和元素也是一樣的:
a b c d e f g h
a b c d i f j g h
這樣我們虛擬 DOM diff 的邏輯就變成了下面的結(jié)構(gòu), 現(xiàn)在只需要比較 ef 和 ifg 的區(qū)別:
(a b c d) e f (g h)
(a b c) d) i f j (g h)
相比于之前的對比場景,我們需要遍歷的運(yùn)算量就大大減小了
而且,有很多場景比如新增一行或者刪除一行的簡單場景,預(yù)判完畢之后,新老元素有一個(gè)處于沒有元素的狀態(tài),我們就可以直接執(zhí)行 mount 或者 unmout 完成對比的全過程,不需要再進(jìn)行復(fù)雜的遍歷:
(a b c d)
(a b c d) e
(a b c) d
(a b c
雙端對比的原理大致就是這樣;最后雙端對比之后的執(zhí)行邏輯這一部分需要一些算法知識,下面會我詳細(xì)介紹,這里你只需要掌握大概的思路
想讓一個(gè)隊(duì)伍盡快按照個(gè)頭排好序,如果能夠計(jì)算出,在隊(duì)伍中,個(gè)頭從低到高依次遞增的最多的隊(duì)列,讓這些人站在原地不動,其余人穿插到他們中間,就可以最大化減少人員的移動,這就是一個(gè)最長底層子序列的算法問題
位運(yùn)算
前面也說了,在執(zhí)行 diff 之前,要根據(jù)需要判斷每個(gè)虛擬 DOM 節(jié)點(diǎn)有哪些屬性需要計(jì)算,因?yàn)闊o論響應(yīng)式數(shù)據(jù)怎么變化,靜態(tài)的屬性和節(jié)點(diǎn)都不會發(fā)生變化
所以我們看每個(gè)節(jié)點(diǎn) diff 的時(shí)候會做什么,在 renderer.ts 代碼文件中就可以看到代碼,主要就是通過虛擬 DOM 節(jié)點(diǎn)的 patchFlag 樹形判斷是否需要更新節(jié)點(diǎn)
方法就是使用 & 操作符來判斷操作的類型,比如 patchFlag & PatchFlags.CLASS 來判斷當(dāng)前元素的 class 是否需要計(jì)算 diff;shapeFlag & ShapeFlags.ELEMENT 來判斷當(dāng)前虛擬 DOM 是 HTML 元素還是 Component 組件;這個(gè)“&”其實(shí)就是位運(yùn)算的按位與
// class // this flag is matched when the element has dynamic class bindings. if (patchFlag & PatchFlags.CLASS) { if (oldProps.class !== newProps.class) { hostPatchProp(el, 'class', null, newProps.class, isSVG) } } // style // this flag is matched when the element has dynamic style bindings if (patchFlag & PatchFlags.STYLE) { hostPatchProp(el, 'style', oldProps.style, newProps.style, isSVG) } if (shapeFlag & ShapeFlags.ELEMENT) { processElement( n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized ) } else if (shapeFlag & ShapeFlags.COMPONENT) { processComponent( n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized ) }
上面的代碼中 & 就是按位與的操作符,這其實(shí)是二進(jìn)制上的計(jì)算符號,所以我們首先要了解一下什么是二進(jìn)制
我們?nèi)粘J褂玫臄?shù)字都是十進(jìn)制數(shù)字,比如數(shù)字 13 就是 110+3 的運(yùn)算結(jié)果,每個(gè)位置都是代表 10 的 n 次方;13 也可以使用二進(jìn)制表達(dá),因?yàn)槎M(jìn)制每個(gè)位置只能是 0 和 1 兩個(gè)數(shù)字,每個(gè)位置代表的是 2 的 n 次方,13 在二進(jìn)制里是 1101,就是 18+14+02+1*1
而在 JavaScript 中我們可以很方便地使用 toString(2) 的方式,把十進(jìn)制數(shù)字轉(zhuǎn)換成二進(jìn)制;運(yùn)算的概念很簡單,就是在二進(jìn)制上的“與”和“或”運(yùn)算:
(13).toString(2) // 1101 0 & 0 // 0 0 & 1 // 0 1 & 0 // 0 1 & 1 // 1 0 | 0 // 0 0 | 1 // 1 1 | 0 // 1 1 | 1 // 1 1 << 2 // 1左移動兩位,就是100 就是1*2平方 = 4
二進(jìn)制中,我們每個(gè)位置只能是 0 或者 1 這兩個(gè)值,& 和 | 的概念和 JavaScript 中的 && 和 || 保持一致;兩個(gè)二進(jìn)制的 & 運(yùn)算就是只有兩個(gè)二進(jìn)制位置都是 1 的時(shí)候,結(jié)果是 1,其余情況運(yùn)算結(jié)果都是 0;| 是按位置進(jìn)行“或”運(yùn)算,只有兩個(gè)二進(jìn)制位置都是 0 的時(shí)候,結(jié)果是 0,其余情況運(yùn)算結(jié)果都是 1;并且,還可以通過左移 << 和右移 >> 操作符,實(shí)現(xiàn)乘以 2 和除以 2 的效果
由于這些都是在二進(jìn)制上的計(jì)算,運(yùn)算的性能通常會比字符串和數(shù)字的計(jì)算性能要好,這也是很多框架內(nèi)部使用位運(yùn)算的原因
這么說估計(jì)你不是很理解,我們結(jié)合一個(gè) LeetCode 題看看為什么說二進(jìn)制的位運(yùn)算性能更好
為什么位運(yùn)算性能更好
我們來做一下 LeetCode231 題,題目描述很簡單,判斷數(shù)字 n 是不是 2 的冪次方,也就是說,判斷數(shù)字 n 是不是 2 的整次方,比如 2、4、8;我們可以很輕松地寫出 JavaScript 的解答,n 一直除以 2,如果有余數(shù)就是 false,否則就是 true:
var isPowerOfTwo = function (n) { if (n === 1) return true while (n > 2) { n = n / 2 if (n % 2 !== 0) return false } return n === 2 };
不過上面的解答我們可以用位運(yùn)算來優(yōu)化
先來分析一下 2 的冪次方的特點(diǎn)
2 的冪次方就是數(shù)字 1 左移動若干次,其余位置全部都是 0,所以 n-1 就是最高位變成0,其余位置都變成 1,就像十進(jìn)制里的 10000-1 = 9999。這樣,n 和 n-1 每個(gè)二進(jìn)制位的數(shù)字都不一樣,我們可以很輕松地用按位“與”來判斷這個(gè)題的答案,如果 n&n-1 是 0 的話,數(shù)字 n 就符合 2 的整次冪的特點(diǎn):
16 10000 16-1 = 15 01111 16&15 == 0 var isPowerOfTwo = function(n) { return n>0 && (n & (n - 1)) === 0 };
所以我們使用位運(yùn)算提高了代碼的整體性能
如何運(yùn)用位運(yùn)算
好,搞清楚為什么用位運(yùn)算,我們回來看 diff 判斷,如何根據(jù)位運(yùn)算的特點(diǎn),設(shè)計(jì)出權(quán)限的組合認(rèn)證方案
比如 Vue 中的動態(tài)屬性,有文本、class、style、props 幾個(gè)屬性,我們可以使用二進(jìn)制中的一個(gè)位置來表示權(quán)限,看下面的代碼,我們使用左移的方式分別在四個(gè)二進(jìn)制上標(biāo)記了 1,代表四種不同的權(quán)限,使用按位或的方式去實(shí)現(xiàn)權(quán)限授予
比如,一個(gè)節(jié)點(diǎn)如果 TEXT 和 STYLE 都需要修改,我們只需要使用 | 運(yùn)算符就可以得到 flag1 的權(quán)限表示,這就是為什么 Vue 3 中針對虛擬 DOM 類型以及虛擬 DOM 需要動態(tài)計(jì)算 diff 的樹形都做了標(biāo)記,你可以在 Vue 3 的源碼中看到下面的配置:
const PatchFlags = { TEXT:1, // 0001 CLASS: 1<<1, // 0010 STYLE:1<<2, // 0100 PROPS:1<<3 // 1000 } const flag1 = PatchFlags.TEXT | PatchFlags.STYLE // 0101 // 權(quán)限校驗(yàn) flag1 & PatchFlags.TEXT // 有權(quán)限,結(jié)果大于1 flag1 & PatchFlags.CLASS //沒有權(quán)限 是0
最長遞增子系列
然后就到了虛擬 DOM 計(jì)算 diff 中的算法了
上面我們詳細(xì)介紹了在虛擬 diff 計(jì)算中,如果新老子元素都是數(shù)組的時(shí)候,需要先做首尾的預(yù)判,如果新的子元素和老的子元素在預(yù)判完畢后,未處理的元素依然是數(shù)組,那么就需要對兩個(gè)數(shù)組計(jì)算 diff,最終找到最短的操作路徑,能夠讓老的子元素通過盡可能少的操作,更新成為新的子元素
Vue 3 借鑒了 infero 的算法邏輯,就像操場上需要按照個(gè)頭從低到高站好一樣,我們采用的思路是先尋找一個(gè)現(xiàn)有隊(duì)列中由低到高的隊(duì)列,讓這個(gè)隊(duì)列盡可能的長,它們的相對位置不需要變化,而其他元素進(jìn)行插入和移動位置,這樣就可以做到盡可能少的操作DOM
所以如何尋找這個(gè)最長遞增的序列呢?這就是今天的重點(diǎn)算法知識了,我們看 LeetCode 第 300 題,題目描述如下, 需要在數(shù)組中找到最長底層的自序列長度:
給你一個(gè)整數(shù)數(shù)組 nums,找到其中最長嚴(yán)格遞增子序列的長度
子序列是由數(shù)組派生而來的序列,刪除(或不刪除)數(shù)組中的元素而不改變其余元素的順序
例如,[3,6,2,7] 是數(shù)組 [0,3,1,6,2,2,7] 的子序列
=
輸入:nums = [10,9,2,5,3,7,101,18]
輸出:4
解釋:最長遞增子序列是 [2,3,7,101],因此長度為 4
首先我們可以使用動態(tài)規(guī)劃的思路,通過每一步的遞推,使用 dp 數(shù)組,記錄出每一步操作的最優(yōu)解,最后得到全局最優(yōu)解
在這個(gè)例子中,我們可以把 dp[i]
定義成 nums[0]
到 nums[i]
這個(gè)區(qū)間內(nèi),數(shù)組的最長遞增子序列的長度,并且 dp 數(shù)組的初始值設(shè)為 1
從左邊向右遞推,如果 nums[i+1]
> nums[i]
,dp[i+1]
就等于 dp[i]+1
;如果 nums[i+1]
< nums[i]
,就什么都不需要干,這樣我們在遍歷的過程中,就能根據(jù)數(shù)組當(dāng)前位置之前的最長遞增子序列長度推導(dǎo)出 i+1 位置的最長遞增子序列長度
所以可以得到如下解法:
/** * @param {number[]} nums * @return {number} */ const lengthOfLIS = function (nums) { let n = nums.length; if (n == 0) { return 0; } let dp = new Array(n).fill(1); for (let i = 0; i < n; i++) { for (let j = 0; j < i; j++) { if (nums[j] < nums[i]) { dp[i] = Math.max(dp[i], dp[j] + 1); } } } return Math.max(...dp) }
由于我們需要兩層循環(huán),所以這個(gè)解法的時(shí)間復(fù)雜度是 n 的平方,這個(gè)解法其實(shí)已經(jīng)不錯(cuò)了,但是還有更優(yōu)秀的解法,也就是 Vue 3 中用到的算法:貪心 + 二分
貪心 + 二分
我們再看一下這個(gè)題,貪心的思路就是在尋找最長遞增的序列,所以,[1,3]要比[1,5]好,也就是說,在這個(gè)上升的序列中,我們要讓上升速度盡可能變得慢,這樣才有可能讓后面的元素盡可能也遞增
我們可以創(chuàng)建一個(gè) arr
數(shù)組,用來保存這種策略下的最長遞增子序列
如果當(dāng)前遍歷的 nums[i]
大于 arr
的最后一個(gè)元素,也就是大于 arr
的最大值時(shí),我們把nums[i]
追加到后面即可,否則我們就在 arr
中尋找一個(gè)第一個(gè)大于 num[i]
的數(shù)字并替換它;因?yàn)槭?arr
是遞增的數(shù)列,所以在尋找插入位置的時(shí)候,我們可以使用二分查找的方式,把整個(gè)算法的復(fù)雜度變成 O(nlgn)
下面的代碼就是貪心 + 二分的解法,我們可以得到正確的最長遞增子序列的長度:
/** * @param {number[]} nums * @return {number} */ const lengthOfLIS = function (nums) { let len = nums.length if (len <= 1) { return len } let arr = [nums[0]] for (let i = 0; i < len; i++) { // nums[i] 大于 arr 尾元素時(shí),直接追加到后面,遞增序列長度+1 if (nums[i] > arr[arr.length - 1]) { arr.push(nums[i]) } else { // 否則,查找遞增子序列中第一個(gè)大于numsp[i]的元素 替換它 // 遞增序列,可以使用二分查找 let left = 0 let right = arr.length - 1 while (left < right) { let mid = (left + right) >> 1 if (arr[mid] < nums[i]) { left = mid + 1 } else { right = mid } } arr[left] = nums[i] } } return arr.length };
但是貪心 + 二分的這種解法,現(xiàn)在只能得到最長遞增子序列的長度,但是最后得到的 arr 并不一定是最長遞增子序列,因?yàn)槲覀円苿拥?num[i]
位置可能會不正確,只是得到的數(shù)組長度是正確的,所以我們需要對這個(gè)算法改造一下,把整個(gè)數(shù)組復(fù)制一份之后,最后也能得到正確的最長遞增子序列
具體代碼怎么寫呢?我們來到 Vue 3 的 renderer.ts 文件中,函數(shù) getSquenece 就是用來生成最長遞增子序列,看下面的代碼:
// https://en.wikipedia.org/wiki/Longest_increasing_subsequence function getSequence(arr: number[]): number[] { const p = arr.slice() //賦值一份arr const result = [0] let i, j, u, v, c const len = arr.length for (i = 0; i < len; i++) { const arrI = arr[i] if (arrI !== 0) { j = result[result.length - 1] if (arr[j] < arrI) { p[i] = j // 存儲在result最后一個(gè)索引的值 result.push(i) continue } u = 0 v = result.length - 1 // 二分查找,查找比arrI小的節(jié)點(diǎn),更新result的值 while (u < v) { c = (u + v) >> 1 if (arr[result[c]] < arrI) { u = c + 1 } else { v = c } } if (arrI < arr[result[u]]) { if (u > 0) { p[i] = result[u - 1] } result[u] = i } } } u = result.length v = result[u - 1] // 查找數(shù)組p 找到最終的索引 while (u-- > 0) { result[u] = v v = p[v] } return result }
這段代碼就是 Vue 3 里的實(shí)現(xiàn),result 存儲的就是長度是 i 的遞增子序列最小末位置的索引,最后計(jì)算出最長遞增子序列
我們得到 increasingNewIndexSequence 隊(duì)列后,再去遍歷數(shù)組進(jìn)行 patch 操作就可以實(shí)現(xiàn)完整的 diff 流程了:
for (i = toBePatched - 1; i >= 0; i--) { const nextIndex = s2 + i const nextChild = c2[nextIndex] as VNode const anchor = nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor if (newIndexToOldIndexMap[i] === 0) { // mount new patch( null, nextChild, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized ) } else if (moved) { // move if: // There is no stable subsequence (e.g. a reverse) // OR current node is not among the stable sequence if (j < 0 || i !== increasingNewIndexSequence[j]) { move(nextChild, container, anchor, MoveType.REORDER) } else { j-- } } }
上面代碼的思路,我們用下圖演示。做完雙端對比之后,a 和 g 已經(jīng)計(jì)算出可以直接復(fù)用 DOM,剩下的隊(duì)列中我們需要把 hbfdc 更新成 abdef
首先我們需要使用 keyToNewIndexMap 存儲新節(jié)點(diǎn)中每個(gè) key 對應(yīng)的索引,比如下圖中 key 是 c 的元素的索引就是 2;然后計(jì)算出 newIndexOldIndexMap 存儲這個(gè) key 在老的子元素中的位置,我們可以根據(jù) c 的索引是 2,在 newIndexOldIndexMap 中查詢到在老的子元素的位置是 6, 關(guān)于 newIndexOldIndexMap 的具體邏輯你可以在上面的代碼中看到:
以上就是Vue3 如何通過虛擬DOM更新頁面詳解的詳細(xì)內(nèi)容,更多關(guān)于Vue3虛擬DOM更新頁面的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
vue動態(tài)路由加載時(shí)出現(xiàn)Cannot?find?module?xxx問題
這篇文章主要介紹了vue動態(tài)路由加載時(shí)出現(xiàn)Cannot?find?module?xxx問題,具有很好的參考價(jià)值,希望對大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2023-01-01使用vue-cli導(dǎo)入Element UI組件的方法
這篇文章給大家介紹了使用vue-cli導(dǎo)入Element UI組件的方法,非常不錯(cuò),具有一定的參考借鑒價(jià)值,需要的朋友一起看看吧2018-05-05vue3.0 CLI - 2.6 - 組件的復(fù)用入門教程
這篇文章主要介紹了 vue3.0 CLI - 2.6 - 組件的復(fù)用,非常不錯(cuò),具有一定的參考借鑒價(jià)值 ,需要的朋友可以參考下2018-09-09解決vue-quill-editor上傳內(nèi)容由于圖片是base64的導(dǎo)致字符太長的問題
vue-quill-editor默認(rèn)插入圖片是直接將圖片轉(zhuǎn)為base64再放入內(nèi)容中,如果圖片較多,篇幅太長,就會比較煩惱,接下來通過本文給大家介紹vue-quill-editor上傳內(nèi)容由于圖片是base64的導(dǎo)致字符太長的問題及解決方法,需要的朋友可以參考下2018-08-08