v-for
紧接着前面的系列文章,今天的目标就是学习v-for这个指令,petite-vue中的用法比较灵活,首先就来认识一下语法吧。
认识语法
- ...of...
<ul>
<li v-for="item of list" :key="item.id">
<input v-model="item.text" />
</li>
</ul>
- ...in...
<ul>
<li v-for="item in list" :key="item.id">
<input v-model="item.text" />
</li>
</ul>
- ({ ... }, index) in ...
<ul>
<li v-for="({ id, text }, index) in list" :key="id">
<div>{{ index }} {{ { id, text } }}</div>
</li>
</ul>
第一种和第二种差不太多,第三种使用了解构赋值的方式;此外,v-for的目标数据支持数组、对象和数字三种格式,相比前面我们分析的指令,v-for显得又不太一样,那么如果要我们来实现,该怎么设计呢,先只考虑第一种使用方式(...of...),我希望的样子应该是这样的:
const scope = { list: [...] };
function for_dir(ele) {
const valueExp = ‘item‘;
const sourceExp = ‘list‘;
const keyExp = ‘item.id‘;
for (let i = 0; i < scope[sourceExp].length; i++) {
createForItem(ele, { [valueExp]: scope[sourceExp][i] }, i);
}
}
function createForItem(ele, source, index) {
// ...
}
首先我需要解析出v-for的指令值,分解出valueExp、sourceExp这些关键信息,然后通过循环创建每一个子项,这里通过包装一个与每个子项对应的数据对象,构建一个相对隔离的上下文,当然在petite-vue里面是通过childContext来实现的。除此之外,还有key这个很重要的属性,不管是在vue还是react中,针对列表渲染都是一个性能优化的重要手段,那么在更新的时候通过比对key来减少DOM操作,后面将介绍petite-vue是怎么实现性能优化的。
指令语法解析
v-for指令值其实可以分为两个部分,in/of作为分隔符,前面一部分是子节点的上下文需要的数据映射关系,后一部分是主数据源映射关系,那么首先通过正则分离出这两部分吧;
const forAliasRE = /([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/;
两个分组对应我们关注的两部分前后两部分指令值,这里稍微说一下这条正则表达式吧,in|of
匹配v-for的固定语法,(?:in|of)
表示不分组只匹配特定结构,因此最终匹配的结果只有([\s\S]*?)
和([\s\S]*)
这两个分组,\s\S
会匹配任意字符,*?
代表尽量少匹配,因为in/of前后会有空格,这才完成了第一步,接下来要针对valueExp(第一个分组匹配的内容)进行更加精细的判断,这里再回顾一下valueExp可能的几种格式:
- { id, text }【, index, objIndex】
- [ id, text ]【, index, objIndex】
- item【, index, objIndex】
针对前面两种格式,需要以}]
作为分隔线,前面一部分是结构化赋值,后面是索引,结构化赋值的对象可能是数组,这里要判断一下,然后将里面赋值的标识符提取出来放入数组中;后面的索引包含index、objIndex,作为选填项;
input:
`({ id, text }, index) of list`
output:
let sourceExp = ‘list‘;
let valueExp = ‘{ id, text }‘;
let isArrayDestructure = false;
let destructureBindings = [‘id‘, ‘text‘];
至此就完成了指令语法的解析工作,代码就不贴了,主要是那几个比较复杂的正则表达式,大家可以查看这里。
循环创建
前面也提到过,针对每一个子项需要有单独的上下文(childContext),而childContext保存着数据对象,而childContext和当前的context又是什么关系呢,其实就是childContext.scope__proto__指向context.scope,确保状态访问的有效性和有序性,接下来梳理一下大体流程:
- 根据sourceExp获取数据源,判断数据源的类型;
if (Array.isArray(source)) {
for (let i = 0; i < source.length; i++) {
...
}
} else if (typeof source === ‘number‘) {
for (let i = 0; i < source; i++) {
...
}
} else if (isObject(source)) {
for (let key in source) {
...
}
}
- 创建childContext
const parentScope = ctx.scope;
const mergedScope = Object.create(parentScope); // 建立mergedScope与parentContext.scope的原型联系
Object.defineProperties(mergedScope, Object.getOwnPropertyDescriptors(data)); // 用上一步获取到的子项数据data填充mergedScope对象
const reactiveProxy = reactive(new Proxy(mergedScope, { // mergedScope包装成响应式
set(target, key, val, receiver) {
// when setting a property that doesn‘t exist on current scope,
// do not create it on the current scope and fallback to parent scope.
if (receiver === reactiveProxy && !target.hasOwnProperty(key)) {
return Reflect.set(parentScope, key, val);
}
return Reflect.set(target, key, val, receiver);
}
}));
return {
...ctx,
scope: reactiveProxy,
}
-
建立index和key之间的映射关系
index在第一步循环过程中可以获得,key如果没有设置,默认就是index,index和key都准备好后,通过Map保存,方便后面更新优化; -
创建DOM节点
源代码中引入Block来进行动态节点管理,负责保存节点模板及父节点,还有插入、删除等操作,比较简单,就不多说,具体可以查看源码;
更新优化
更新的时候,要点就是比较前后两次渲染的节点差异,主要分为新增、删除和更新,首先假定更新前后两次数据如下:
---mount---
blocks = [b1, b2, b3];
keyToIndexMap = { b1->0, b2->1, b3->2 }; // key<->index映射关系
---update---
blocks = [b1, b2, b3]; // mount时block数组
prevKeyToIndexMap = { b1->0, b2->1, b3->2 }; // mount时的映射关系
nextBlocks = []; // 当前更新需要渲染的block,后面会通过算法填充,最终结果[b1, b3, b4]
keyToIndexMap = { b1->0, b3->1, b4->2 }; // 本次update根据状态对象新生成的映射关系对象
首先我们考虑一下删除的情况,通过keyToIndexMap和blocks即可判断,如果blocks每一项的key没有包含在keyToIndexMap中,那么意味着mount时的block需要删除,就像例子中的b2,代码如下:
for (let i = 0; i < blocks.length; i++) {
if (!keyToIndexMap.has(blocks[i].key)) {
blocks[i].remove();
}
}
我们再来考虑下新增,要判断是否新增还是比较简单的,key没有在prevKeyToIndexMap就对了,然后创建新的Block对象放入nextBlocks中保存起来,这里有个问题就是对于新增的这个节点,插入的具体位置在哪呢,有可能插入末尾,有可能插入中间,那么需要一个定位的基准点,这个基准点肯定需要前后两次对比才能确定下来,也必然和新插入的节点相邻吧,就像insertBefore那样,具体的算法后面再讲,到此就剩下最后一种情况了--更新。更新可能时位置变动,可能是ui变动,位置变动通过比对这个block的key在keyToIndexMap和prevKeyToIndexMap的索引就可以得出,ui变动就是状态值的变动,将scope合并即可。分析到这里,流程应该比较清楚了,还有个问题就是前面说的基准点,接下来就在代码里寻找答案吧;
const nextBlocks = [];
let i = childCtxs.length; // childCtxs每次更新都会根据状态值重新生成,保存本次需要渲染的上下文信息
while (i--) {
const childCtx = childCtxs[i]; // 当前判断的上下文
const oldIndex = prevKeyToIndexMap.get(childCtx.key); // 上下文的key和对应的block对象的key相同
const next = childCtxs[i + 1]; //
const nextBlockOldIndex = next && prevKeyToIndexMap.get(next.key); // 下一个上下文在上一次渲染的索引
const nextBlock =
nextBlockOldIndex == null ? undefined : blocks[nextBlockOldIndex]; // 如果nextBlockOldIndex存在,说明childCtx对应的block在上一次渲染时,后面有block,以此为基准点
if (oldIndex == null) { // key在prevKeyToIndexMap不存在,那么必然是新增
// new
nextBlocks[i] = mountBlock(
childCtx,
nextBlock ? nextBlock.el : anchor
);
} else {
// update
const block = (nextBlocks[i] = blocks[oldIndex]); // 更新,直接复用存在的block
Object.assign(block.ctx.scope, childCtx.scope); // scope合并,确保ui更新
if (oldIndex !== i) { // 位置发生了变化
// moved
if (blocks[oldIndex + 1] !== nextBlock) {
block.insert(parent, nextBlock ? nextBlock.el : anchor);
}
}
}
}
blocks = nextBlocks;
通过上面的代码分析,nextBlock是很重要的定位点,确定新插入和更新的位置,至此就分析完毕v-for指令的实现了,具体实现的完整代码点击这里。