在《petite-vue源码剖析-v-if和v-for的工作原理》我们了解到v-for在静态视图中的工作原理,而这里我们将深入了解在更新渲染时v-for是如何运作的。

逐行解析

// 文件 ./src/directives/for.ts

/* [\s\S]*表示识别空格字符和非空格字符若干个,默认为贪婪模式,即 `(item, index) in value` 就会匹配整个字符串。
* 修改为[\s\S]*?则为懒惰模式,即`(item, index) in value`只会匹配`(item, index)`
*/
const forAliasRE = /([\s\S]*?)\s+(?:in)\s+([\s\S]*?)/
// 用于移除`(item, index)`中的`(`和`)`
const stripParentRE= /^\(|\)$/g
// 用于匹配`item, index`中的`, index`,那么就可以抽取出value和index来独立处理
const forIteratorRE = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/ type KeyToIndexMap = Map<any, number> // 为便于理解,我们假设只接受`v-for="val in values"`的形式,并且所有入参都是有效的,对入参有效性、解构等代码进行了删减
export const _for = (el: Element, exp: string, ctx: Context) => {
// 通过正则表达式抽取表达式字符串中`in`两侧的子表达式字符串
const inMatch = exp.match(forAliasRE) // 保存下一轮遍历解析的模板节点
const nextNode = el.nextSibling // 插入锚点,并将带`v-for`的元素从DOM树移除
const parent = el.parentElement!
const anchor = new Text('')
parent.insertBefore(anchor, el)
parent.removeChild(el) const sourceExp = inMatch[2].trim() // 获取`(item, index) in value`中`value`
let valueExp = inMatch[1].trim().replace(stripParentRE, '').trim() // 获取`(item, index) in value`中`item, index`
let indexExp: string | undefined let keyAttr = 'key'
let keyExp =
el.getAttribute(keyAttr) ||
el.getAttribute(keyAttr = ':key') ||
el.getAttribute(keyAttr = 'v-bind:key')
if (keyExp) {
el.removeAttribute(keyExp)
// 将表达式序列化,如`value`序列化为`"value"`,这样就不会参与后面的表达式运算
if (keyAttr === 'key') keyExp = JSON.stringify(keyExp)
} let match
if (match = valueExp.match(forIteratorRE)) {
valueExp = valueExp.replace(forIteratorRE, '').trim() // 获取`item, index`中的item
indexExp = match[1].trim() // 获取`item, index`中的index
} let mounted = false // false表示首次渲染,true表示重新渲染
let blocks: Block[]
let childCtxs: Context[]
let keyToIndexMap: KeyToIndexMap // 用于记录key和索引的关系,当发生重新渲染时则复用元素 const createChildContexts = (source: unknown): [Context[], KeyToIndexMap] => {
const map: KeyToIndexMap = new Map()
const ctxs: Context[] = [] if (isArray(source)) {
for (let i = 0; i < source.length; i++) {
ctxs.push(createChildContext(map, source[i], i))
}
} return [ctxs, map]
} // 以集合元素为基础创建独立的作用域
const createChildContext = (
map: KeyToIndexMap,
value: any, // the item of collection
index: number // the index of item of collection
): Context => {
const data: any = {}
data[valueExp] = value
indexExp && (data[indexExp] = index)
// 为每个子元素创建独立的作用域
const childCtx = createScopedContext(ctx, data)
// key表达式在对应子元素的作用域下运算
const key = keyExp ? evaluate(childCtx.scope, keyExp) : index
map.set(key, index)
childCtx.key = key return childCtx
} // 为每个子元素创建块对象
const mountBlock = (ctx: Conext, ref: Node) => {
const block = new Block(el, ctx)
block.key = ctx.key
block.insert(parent, ref)
return block
} ctx.effect(() => {
const source = evaluate(ctx.scope, sourceExp) // 运算出`(item, index) in items`中items的真实值
const prevKeyToIndexMap = keyToIndexMap
// 生成新的作用域,并计算`key`,`:key`或`v-bind:key`
;[childCtxs, keyToIndexMap] = createChildContexts(source)
if (!mounted) {
// 为每个子元素创建块对象,解析子元素的子孙元素后插入DOM树
blocks = childCtxs.map(s => mountBlock(s, anchor))
mounted = true
}
else {
// 更新渲染逻辑!!
// 根据key移除更新后不存在的元素
for (let i = 0; i < blocks.length; i++) {
if (!keyToIndexMap.has(blocks[i].key)) {
blocks[i].remove()
}
} const nextBlocks: Block[] = []
let i = childCtxs.length
let nextBlock: Block | undefined
let prevMovedBlock: Block | undefined
while (i--) {
const childCtx = childCtxs[i]
const oldIndex = prevKeyToIndexMap.get(childCtx.key)
let block
if (oldIndex == null) {
// 旧视图中没有该元素,因此创建一个新的块对象
block = mountBlock(childCtx, newBlock ? newBlock.el : anchor)
}
else {
// 旧视图中有该元素,元素复用
block = blocks[oldIndex]
// 更新作用域,由于元素下的`:value`,`{{value}}`等都会跟踪scope对应属性的变化,因此这里只需要更新作用域上的属性,即可触发子元素的更新渲染
Object.assign(block.ctx.scope, childCtx.scope)
if (oldIndex != i) {
// 元素在新旧视图中的位置不同,需要移动
if (
blocks[oldIndex + 1] !== nextBlock ||
prevMoveBlock === nextBlock
) {
prevMovedBlock = block
// anchor作为同级子元素的末尾
block.insert(parent, nextBlock ? nextBlock.el : anchor)
}
}
}
nextBlocks.unshift(nextBlock = block)
}
blocks = nextBlocks
}
}) return nextNode
}

难点突破

上述代码最难理解就是通过key复用元素那一段了

const nextBlocks: Block[] = []
let i = childCtxs.length
let nextBlock: Block | undefined
let prevMovedBlock: Block | undefined
while (i--) {
const childCtx = childCtxs[i]
const oldIndex = prevKeyToIndexMap.get(childCtx.key)
let block
if (oldIndex == null) {
// 旧视图中没有该元素,因此创建一个新的块对象
block = mountBlock(childCtx, newBlock ? newBlock.el : anchor)
}
else {
// 旧视图中有该元素,元素复用
block = blocks[oldIndex]
// 更新作用域,由于元素下的`:value`,`{{value}}`等都会跟踪scope对应属性的变化,因此这里只需要更新作用域上的属性,即可触发子元素的更新渲染
Object.assign(block.ctx.scope, childCtx.scope)
if (oldIndex != i) {
// 元素在新旧视图中的位置不同,需要移动
if (
/* blocks[oldIndex + 1] !== nextBlock 用于对重复键减少没必要的移动(如旧视图为1224,新视图为1242)
* prevMoveBlock === nextBlock 用于处理如旧视图为123,新视图为312时,blocks[oldIndex + 1] === nextBlock导致无法执行元素移动操作
*/
blocks[oldIndex + 1] !== nextBlock ||
prevMoveBlock === nextBlock
) {
prevMovedBlock = block
// anchor作为同级子元素的末尾
block.insert(parent, nextBlock ? nextBlock.el : anchor)
}
}
}
nextBlocks.unshift(nextBlock = block)
}

我们可以通过示例通过人肉单步调试理解

示例1

旧视图(已渲染): 1,2,3

新视图(待渲染): 3,2,1

  1. 循环第一轮

    childCtx.key = 1
    i = 2
    oldIndex = 0
    nextBlock = null
    prevMovedBlock = null

    prevMoveBlock === nextBlock

    于是将旧视图的block移动到最后,视图(已渲染): 2,3,1

  2. 循环第二轮

    childCtx.key = 2
    i = 1
    oldIndex = 1

    更新作用域

  3. 循环第三轮

    childCtx.key = 3
    i = 0
    oldIndex = 2
    nextBlock = block(.key=2)
    prevMovedBlock = block(.key=1)

    于是将旧视图的block移动到nextBlock前,视图(已渲染): 3,2,1

示例2 - 存在重复键

旧视图(已渲染): 1,2,2,4

新视图(待渲染): 1,2,4,2

此时prevKeyToIndexMap.get(2)返回2,而位于索引为1的2的信息被后者覆盖了。

  1. 循环第一轮

    childCtx.key = 2
    i = 3
    oldIndex = 2
    nextBlock = null
    prevMovedBlock = null

    于是将旧视图的block移动到最后,视图(已渲染): 1,2,4,2

  2. 循环第二轮

    childCtx.key = 4
    i = 2
    oldIndex = 3
    nextBlock = block(.key=2)
    prevMovedBlock = block(.key=2)

    于是将旧视图的block移动到nextBlock前,视图(已渲染): 1,2,4,2

  3. 循环第三轮

    childCtx.key = 2
    i = 1
    oldIndex = 2
    nextBlock = block(.key=4)
    prevMovedBlock = block(.key=4)

    由于blocks[oldIndex+1] === nextBlock,因此不用移动元素

  4. 循环第四轮

childCtx.key = 1
i = 0
oldIndex = 0

由于i === oldIndex,因此不用移动元素

和React通过key复用元素的区别?

React通过key复用元素是采取如下算法

  1. 第一次遍历新旧元素(左到右)

    1. 若key不同即跳出遍历,进入第二轮遍历

      • 此时通过变量lastPlacedIndex记录最后一个key匹配的旧元素位置用于控制旧元素移动
    2. 若key相同但元素类型不同,则创建新元素替换掉旧元素
  2. 遍历剩下未遍历的旧元素 - 以旧元素.key为键,旧元素为值通过Map存储
  3. 第二次遍历剩下未遍历的新元素(左到右)
    1. 从Map查找是否存在的旧元素,若没有则创建新元素
    2. 若存在则按如下规则操作:
      • 若从Map查找的旧元素的位置大于lastPlacedIndex则将旧元素的位置赋值给lastPlacedIndex,若元素类型相同则复用旧元素,否则创建新元素替换掉旧元素
      • 若从Map查找的旧元素的位置小于lastPlacedIndex则表示旧元素向右移动,若元素类型相同则复用旧元素,否则创建新元素替换掉旧元素(lastPlacedIndex的值保持不变)
  4. 最后剩下未遍历的旧元素将被删除

第二次遍历时移动判断是,假定lastPlacedIndex左侧的旧元素已经和新元素匹配且已排序,若发现旧元素的位置小于lastPlacedIndex,则表示lastPlacedIndex左侧有异类必须向右挪动。

petite-vue的算法是

  1. 每次渲染时都会生成以元素.key为键,元素为值通过Map存储,并通过prevKeyToIndexMap保留指向上一次渲染的Map
  2. 遍历旧元素,通过当前Map筛选出当前渲染中将被移除的元素,并注意移除
  3. 遍历新元素(右到左)
    1. 若key相同则复用
    2. 若key不同则通过旧Map寻找旧元素,并插入最右最近一个已处理的元素前面

它们的差别

  1. petite-vue无法处理key相同但元素类型不同的情况(应该说不用处理比较适合),而React可以

    // petite-vue
    createApp({
    App: {
    // 根本没有可能key相同而元素类型不同嘛
    $template: `
    <div v-for="item in items" :key="item.id"></div>
    `
    }
    }) // React
    function App() {
    const items = [...]
    return (
    items.map(item => {
    if (item.type === 'span') {
    return (<span key={item.id}></span>)
    }
    else {
    return (<div key={item.id}></div>)
    }
    })
    )
    }
  2. 由于petite-vue对重复key进行优化,而React会对重复key执行同样的判断和操作

  3. petite-vue是即时移动元素,而React是运算后再移动元素,并且对于旧视图为123,新视图为312而言,petite-vue将移动3次元素,而React仅移动2次元素

后续

和DOM节点增删相关的操作我们已经了解得差不多了,后面我们一起阅读关于事件绑定、属性和v-modal等指令的源码吧!

petite-vue-源码剖析-v-for重新渲染工作原理的更多相关文章

  1. petite-vue源码剖析-双向绑定`v-model`的工作原理

    前言 双向绑定v-model不仅仅是对可编辑HTML元素(select, input, textarea和附带[contenteditable=true])同时附加v-bind和v-on,而且还能利用 ...

  2. petite-vue源码剖析-v-if和v-for的工作原理

    深入v-if的工作原理 <div v-scope="App"></div> <script type="module"> i ...

  3. petite-vue源码剖析-属性绑定`v-bind`的工作原理

    关于指令(directive) 属性绑定.事件绑定和v-modal底层都是通过指令(directive)实现的,那么什么是指令呢?我们一起看看Directive的定义吧. //文件 ./src/dir ...

  4. petite-vue源码剖析-事件绑定`v-on`的工作原理

    在书写petite-vue和Vue最舒服的莫过于通过@click绑定事件,而且在移除元素时框架会帮我们自动解除绑定.省去了过去通过jQuery的累赘.而事件绑定在petite-vue中就是一个指令(d ...

  5. Vue源码探究-虚拟DOM的渲染

    Vue源码探究-虚拟DOM的渲染 在虚拟节点的实现一篇中,除了知道了 VNode 类的实现之外,还简要地整理了一下DOM渲染的路径.在这一篇中,主要来分析一下两条路径的具体实现代码. 按照创建 Vue ...

  6. Vue 源码解读(3)—— 响应式原理

    前言 上一篇文章 Vue 源码解读(2)-- Vue 初始化过程 详细讲解了 Vue 的初始化过程,明白了 new Vue(options) 都做了什么,其中关于 数据响应式 的实现用一句话简单的带过 ...

  7. Vue源码学习(零):内部原理解析

    本篇文章是在阅读<剖析 Vue.js 内部运行机制>小册子后总结所得,想要了解详细内容,请参考原文:https://juejin.im/book/5a36661851882538e2259 ...

  8. Spark源码剖析(五):Master原理与源码剖析(下)

    一. 状态改变机制源码分析 在剖析Master核心的资源调度算法之前,让我们先来看看Master的状态改变机制. Driver状态改变  可以看出,一旦Driver状态发生改变,基本没有好事情,后果要 ...

  9. Spark源码剖析(九):TaskScheduler原理与源码剖析

    接着上期内核源码(六)的最后,DAGSchedule会将每个Job划分一系列stage,然后为每个stage创建一批task(数量与partition数量相同),并计算其运行的最佳位置,最后针对这一批 ...

  10. OkHttp3源码详解(六) Okhttp任务队列工作原理

    1 概述 1.1 引言 android完成非阻塞式的异步请求的时候都是通过启动子线程的方式来解决,子线程执行完任务的之后通过handler的方式来和主线程来完成通信.无限制的创建线程,会给系统带来大量 ...

随机推荐

  1. oracle中的常用函数、字符串函数、数值类型函数、日期函数,聚合函数。

    一.字符串的常用函数. --一.oracle 字符串常用函数 --1. concat 连接字符串的函数,只能连接[两个]字符串. 字符写在括号中,并用逗号隔开! --2."||"符 ...

  2. Luogu_P2048

    超级钢琴 有 \(n\) 个音符,编号从 \(1\) 到 \(n\) .第 \(i\) 个音符的美妙度为 \(A_i\) . 我们要找到 \(k\) 段不同超级和弦组成的乐曲,每段乐曲的连续音符个数 ...

  3. JavaScript闭包的那些事

    JavaScript闭包 1.函数在JavaScript中的地位 在介绍闭包之前,可以先聊聊函数在JavaScript中的地位,因为闭包的存在是与函数息息相关的. JavaScript之所以可以称之为 ...

  4. Core Animation的使用步骤

  5. ldconfig及LD_LIBRARY_PATH

    ldconfig是一个动态链接库管理命令,为了让动态链接库为系统所共享,还需运行动态链接库的管理命令:ldconfig.ldconfig 命令的用途,主要是在默认搜寻目录 (/lib和/usr/lib ...

  6. JS快速入门(一)

    目录 Javascript快速入门(一) 变量的命名规则 变量与常量 变量 常量 数据类型 数值型:Number 字符串:String 常用方法: 布尔类型:Boolean 未定义类型 :Undefi ...

  7. docker | jenkins 实现自动化部署项目,后端躺着把运维的钱挣了!(上)

    前言 背景 最近在帮学校导师写项目,团队有4个人,项目前后端分离.如果是选择瀑布式开发:(在约定好接口的情况下)A.B同学写前端,C.D同学写后端,然后约定一个时间统一联调,最后将项目交付安装到客户机 ...

  8. 【CF457D】Bingo!(数学 期望)

    题目链接 大意 给定\(N,M,K\),表示有一个\(N*N\)的空矩阵,\(M\)个不同的数. 随机地把\(M\)个数中的\(N^2\)个数丢进这个空矩阵中(\(M\ge N^2\)) 再从\(M\ ...

  9. 巧用python进行接口自动化测试

    一.接口自动化测试的流程 1.需求分析 1.1请求(url,方法.数据) 2.挑选需要做自动化测试的接口 3.设计自动化测试用例 4.搭建自动化测试环境 5.设计自动化执行框架(报告.参数化. 用例执 ...

  10. SQL注入的原理及一般步骤

    原理 SQL注入是一种攻击方式,在这种攻击方式中,恶意代码被插入到字符串中,然后该字符串传递到SQL Server的实例以进行分析和执行.任何构成SQL语句的过程都应进行注入检查,因为SQL Serv ...