Vue | 2021-03-09 01:54:09 2861次 3次
前言
在之前的源码分析文章中总是贴出大量的代码,也一直找不到一种好的方式来怎么写源码分析(尽管这是一件平庸的事),这个过程中更多的了为了让自己后续回顾能快速的浮现出代码组织。毕竟,【自嗨】也是一件快乐的事!
在写 react 源码分析的过程中更多了为了学习它的思想,至于代码组织只是思想的一种实现;
在写 jq 源码分析中,更多的为了学习它的代码组织和功能上的设计,一些公共方法的抽象分离;
在写 webpack 源码分析中,仅仅为了跑通流程,学习一种插件结构的设计;
在写 vue 源码中,精简了很多内容,更多的为了跑通一个流程和思想上的复现以及代码组织设计;
言归正传,本篇的渲染引擎的分析开写。
Vnode
vue3 中做了一些优化,静态节点提升、block 块等,帮助运行时的效率提升。
其中 shapeFlag 代表了节点类型,patchFlag 在编译时生成,可以用来做 patch 时优化处理,区分动态和静态。
编译时会创建动态节点 block tree,结合 patchFlag 实现细粒度的更新,将生成的动态节点放入一个数组中,diff 时直接取出用,避免了递归树检查。
编译器会生成 openBlock、createVnode、createBlock 等方法,如下:
// 一种树的遍历,先按照层级顺序执行openBlock,然后再是树的完成过程(叶子节点开始) // openBlock1 --> openBlock2 --> Frag --> div (openBlock(1), createBlock('div', null, [ ((openBlock(2), createBlock("Frag", null, [ 222, 333 ]) )) ]))
最后创建完成的 currentBlock 会被挂载到根 vnode 的 dynamicChildren 上。
Mount
组件的初始加载会进行 render 的挂载,挂载 render 的时候会判断当前组件返回值:
如果是一个函数,则认为当前组件中采用 jsx 的写法或者说 createVnode 方法,不需要走 vue 的 compiler 系统。
如果是一个对象,则认为当前组件中有模板写法,则需要进行编译,同时将 setUp 中返回的对象传给 render,建立起编 译后的模板和数据的关联。
同时,将数据和响应式系统通过 effect 关联上,达到响应式的组件级别的更新:
instance.update = effect(() => { if(!instance.isMounted){ let { bm, m } = instance; // Life onBeforeMount bm && invokeArrayFns(bm); // 初始加载 const subTree = (instance.subTree = renderComponentRoot(instance)) patch(null, subTree, container); // Life onMounted m && queuePostRenderEffect(m); instance.isMounted = true; }else{ // 更新 } },{ scheduler: job => { queueJob(job); } })
export function renderComponentRoot( instance: ComponentInstance ): VNode{ const { render, setupState } = instance; // 触发render方法,这里面用到了响应式中数据的话,则会对 effect 响应式处理 return normalizeVNode(render(setupState)); }
对于普通节点的初始加载,则是递归 vnode 树即可,从子节点开始层层 parent.insertBefore 操作,最终挂载到根节点上,同时注意属性也要挂载到真实节点。
Update
组件的更新,当数据变化时,则会触发 effect,初始挂载后将信息记录在 subTree 上,更新时重新执行 render 计算出新的虚拟dom 树,再次进行 patch 比较:
instance.update = effect(() => { if(!instance.isMounted){ ... }else{ const { bu, u } = instance; // Life onBeforeUpdate bu && invokeArrayFns(bu); // 数据更新 前后树对比 继续patch const nextTree = renderComponentRoot(instance); const prevTree = instance.subTree; // 更换 instance.subTree = nextTree; patch(prevTree, nextTree, hostParentNode(prevTree.el!)!); // Life onUpdated u && queuePostRenderEffect(u); } },{ scheduler: job => { queueJob(job); } })
对于节点的更新,利用到编译优化的点,patchFlag 大于 0 的时候是动态属性,比如动态 class、动态 style、其他动态属性仅对比当前 dom 的制定属性,不会全量对比 props,除非命中 PatchFlags.FULL_PROPS 或者不优化(并且dynamicChildren为空)时再进行全量的属性对比。
接着如果命中 block 优化的条件,则进行 patchBlockChildren,根据新旧根节点上的 dynamicChildren 直接遍历进行 patch。
无法进行优化处理的再进入大名鼎鼎的 diff 阶段。
Diff
首先进入 patchChildren 进行节点类型的对比:
1 new: text old: array
2 new: text old: text | null
3 new: array old: array +++++++++
4 new: null old: array
5 new: null old: text | null
6 new: array old: text | null
if(shapeFlag & ShapeFlags.TEXT_CHILDREN){ if(prevShapeFlag & ShapeFlags.ARRAY_CHILDREN){ // 卸载组件 container.innerHTML = ''; } if(c2 !== c1){ hostSetElementText(container, c2 as string) } } else { if(prevShapeFlag & ShapeFlags.ARRAY_CHILDREN){ if(shapeFlag & ShapeFlags.ARRAY_CHILDREN){ // array 前后对比 patchKeyedChildren(c1 as VNode[], c2 as VNodeArrayChildren, container); }else{ // 卸载组件 container.innerHTML = ''; } } else { if(prevShapeFlag & ShapeFlags.TEXT_CHILDREN){ hostSetElementText(container, ''); } if(shapeFlag & ShapeFlags.ARRAY_CHILDREN){ // 加载数组类型组件 mountChildren( c2, container) } } }
接下来逻辑最多的就是有 key 节点对比 patchKeyedChildren,这个对比的过程中源码的注释很清晰,这里描述下过程:
1、头比较
old: (a b) c new: (a b) d e ----------------------------------- while (i <= e1 && i <= e2) { const n1 = c1[i]; const n2 = c2[i] = normalizeVNode(c2[i]); if (isSameVNodeType(n1, n2)) { patch(n1, n2, container); } else { break; } i++; }
2、尾比较
old: a (b c) new: d e (b c) --------------------------------- while (i <= e1 && i <= e2) { const n1 = c1[e1]; const n2 = c2[e2] = normalizeVNode(c2[e2]); if (isSameVNodeType(n1, n2)) { patch(n1, n2, container); } else { break; } e1--; e2--; }
3、同序列挂载
old: (a b) new: (a b) c // i = 2, e1 = 1, e2 = 2 或 old: (a b) new: c (a b) // i = 0, e1 = -1, e2 = 0 ---------------------------------- if (i > e1) { while (i <= e2) { patch(null, c2[i] = normalizeVNode(c2[i]), container) i++; } }
4、同序列卸载
old: (a b) c new: (a b) // i = 2, e1 = 2, e2 = 1 或 new: a (b c) old: (b c) // i = 0, e1 = 0, e2 = -1 --------------------------------- else if(i > e2){ while (i <= e1) { hostRemove(c1[i].el as any); i++; } }
5、未知序列
// [i ... e1 + 1]: a b [c d e] f g // [i ... e2 + 1]: a b [e c d h] f g // i = 2, e1 = 4, e2 = 5
未知序列这里是 diff 的核心逻辑,设计复杂,但目的比较明确,就是保证最小移动从而减小 dom 操作。为了保证找到最小的移动,就是要求出这个最长递增子序列,依据的是新的节点相对于旧节点的位置索引确定,过程如下:
1. 收集新节点 key 和 index(去除相同头尾后的部分)
// 根据上面的注释数据。。 const s1 = i, s2 = i; // 抛头去尾后 新节点 key 的集合 const keyToNewIndexMap: Map<string | number, number> = new Map(); // 将新的节点key和index存入一个map for(i = s2; i <= e2; i++){ const nextChild = (c2[i] = normalizeVNode(c2[i])); const key = nextChild.key; if(key != null){ keyToNewIndexMap.set(key, i); } }
2. 遍历旧节点(去除相同头尾后的部分),创造一个索引变化的数组,为了后面计算 LIS
for(i = s1; i <= e1; i++){ const prevChild = c1[i]; ... let newIndex, prevChildKey = prevChild.key; ... if(prevChildKey != null){ newIndex = keyToNewIndexMap.get(prevChildKey); }else{ ... } // 新节点中不存在 旧的需要移除 if(newIndex === undefined){ hostRemove(prevChild.el as any); }else{ // 这里生成数组 newIndexToOldIndexMap[newIndex - s2] = i + 1; // maxNewIndexSoFar 设计的巧妙,比如 c d e 变成 e c d 的时候 // 旧节点 遍历到 e 的时候发现 newIndex 是小于 这个变量的 // 那么意味这节点需要移动,此时再求最长递增子序列,进行后续的移动操作 if (newIndex >= maxNewIndexSoFar) { maxNewIndexSoFar = newIndex; } else { moved = true; } patch(prevChild, c2[newIndex] as VNode, container); patched++; } }
这一小步中还有其他细节需要考虑,这里不贴具体代码了,列举 case 如下:
// a b [c d e] f g // a b [] f g ----------------------- // a b [c d e q(noKey)] f g // a b [e c d h q(noKey)] f g
5.1、最长递增子序列
这里和 vue 源码的写法有些出入,不过核心思路都是一样的,我进行了一个代码的更精简,采用二分法的思路:
首先进入 patchChildren 进行节点类型的对比:
* 最长递增子序列
* [6,3,5,10,11,2,9,14,13,7,4,8,16]
*
* 第一步:
* 按照扑克牌的算法进行二分法查找,得到最终每一堆中牌尾的索引
*
* 堆1 堆2 堆3 堆4 堆5
* 6 5 10 11 14
* 3 4 9 8 13
* 2 7 16
*
* 牌尾:[2, 4, 7, 8, 16] 对应索引:[5, 10, 9, 11, 12]
* ----------------------------------------------------
*
* 第二步:
* 求解目标(唯一值)
* pArr的计算,新的牌入堆的时候右侧一定大于左侧的值,所以新插入的需要记录上一个堆的牌尾索引
* pArr: [-1, -1, 1, 2, 3, -1, 2, 4, 4, 2, 5, 9, 11]
* 由于堆的最右侧的牌尾是最大的
* 所以可以从牌尾索引倒序从 pArr 中往前寻找
* 最终为目标值。
*
* 实际目标不唯一,比如 16 后面是 15 (此时最长公共子序列结果至少有两个)
export function getSequence(arr: number[]) { let topIndex = []; let pArr = []; // 牌堆数初始化为 0 let piles = 0; for (let i = 0, len = arr.length; i < len; i++) { let poker = arr[i]; let left = 0, right = piles; while (left < right) { let mid = Math.floor((left + right) / 2); if (arr[topIndex[mid]] < poker) { left = mid + 1; } else { right = mid; } } // 新建堆 if (left == piles){ piles++ }; // 记录每一堆牌顶索引 topIndex[left] = i; pArr.push(topIndex[left-1] || -1); } // 挑选 let u = topIndex.length; let v = topIndex[u - 1]; while (u-- > 0) { topIndex[u] = v; v = pArr[v]; } // 增长序列 return topIndex; }
5.2、最小移动
... for (i = toBePatched - 1; i >= 0; i--) { ... // [0,1,2,0] // lis: [1, 2] // 此时 1和2保持不动,其他的节点做移动操作,保证了最小移动 if (j < 0 || i !== increasingNewIndexSequence[j]) { hostInsert(nextChild.el!, container, anchor) } else { j--; } ... }
这里完成了一个组件的渲染和更新能力,下一篇继续看生命周期的设计和调度。
3人赞