1.1 阅读前必看
目前国内vue和react最为火热,react源码相对较难,最好从vue先入手,vue一万行多代码该如何下手,也是很多人放弃看源码的原因,那我们要有方法,从宏观到微观,先从某个局部功能入手,首先从github上克隆一份代码,可以克隆tags v2.6.11版本
#1.2 前置知识点
如果想深入的了解vue源码,至少需要以下几个知识点
#1.2.1 Flow
相信大家都知道,javascript是弱类型语言,写代码时非常爽但同时也容易犯错,所以Facebook搞了这么一个类型检测工具,可以加入类型的限制 提高代码质量
案例
下面例子如果这样调用count(1,'t')会出现意想不到的错误,这样的编程太不稳定了
function count (a,b) {
return a*b
}
如果我们对参数加以限制,只允许传递Number类型,否则Flow工具就会检测报错
function count (a:number,b:number) {
return a*b
}
vue源码中出现的flow语法
export function renderList (
val: any, // any代表任意类型
render: (
val: any,
keyOrIndex: string | number, // 以是string类型可以是number类型
index?: number // 问号在冒号之前,代表可以不传,要传的话必须是number类型
) => VNode
): ?Array<VNode>{ // 问号在冒号之后,代表参数必须要传,但可以是数组类型也可以是null和undefined,<>里的代表数组里的类型
...
}
// flow 入门https://zhuanlan.zhihu.com/p/26204569
#1.3 源码目录
├─ .circleci # 包含CircleCI持续集成/持续部署工具的配置文件
├─ benchmarks # 基准和性能测试文件,vue的跑分demo,例如大数据量的table或者渲染大量的SVG
├─ dist # 构建后输出的不同版本vue文件(UMD、common.js、生产和开发包)
├─ examples # 用vue写的一些小demo
├─ flow # 进行静态类型检测,静态类型检测类型声明文件(https://flow.org/)
├─ packages # 包含服务端渲染和模块编译器两种不同的NPM包,是提供不同使用场景使用的
├─ scripts # 存放npm脚本配置文件,结合webpack、rollup进行编译、测试、构建等操作
│ ├── config.js # 包含在`dist`中找到的所有文件的生成配置
│ └── build.js # 对config.js中所有的rollup配置进行构建
├─ src # 主要源码所在位置,核心内容
│ ├── compiler # 编译器代码,将template编译成render函数
│ ├── core # vue核心代码,包括内置组件、全局API封装、vue实例化、观察者、虚拟DOM、工具函数等
│ │ ├── components # 组件相关属性,主要是keep-Alive
│ │ ├── global-api # vue全局api。例如:Vue.use Vue.extend vue.mixin
│ │ ├── instance # 实例化相关内容,生命周期,事件等
│ │ ├── observe # 响应式核心目录,双向绑定相关文件
│ │ ├── util # 工具方法
│ │ └── vdom # 包括虚拟DOM,创建(creation)和打补丁(patching)的代码
│ ├── platforms # 包含平台特有的相关代码,vue是一个跨平台的mvvm框架(web、weex)
│ │ ├── web # web端
│ │ │ ├── compiler # web端编译相关代码,用来编译模版或render函数
│ │ │ ├── runtime # web端运行是相关代码,用来创建vue实例等
│ │ │ ├── server # 服务端渲染
│ │ │ └── util # 相关工具类
│ │ └── weex # 基于通用跨平台的web开发语言和开发经验,来构建Android、ios和web应用
│ ├── server # 服务端渲染(ssr)
│ ├── sfc # 转换单文件组件(*.vue)
│ └── shared # 全局共享的方法和常量
├─ test # test测试用例
├─ types # vue新版本支持TypeScript,主要是用typeScript声明文件
├─ .editorconfig # 文本编码样式配置文件
├─ .eslintignore # eslint校验忽略文件
├─ .eslintrc.js # eslint配置文件
├─ .flowconfig # flow配置文件
├─ LICENSE # 项目开源协议
#1.4 开始吧!
#🍅 克隆代码
git clone -b v2.6.11 https://github.com/vuejs/vue.git
#🍅 阅读源码,我们逐个击破的方式
-
响应式
vue如何实现数据的响应式,从而用数据驱动视图 -
vittualdom和DIff
vittualdom及DIff算法
2.1 概述
#2.1.1 数据、视图、vue之间的关系
vue最大的特点之一就是用数据驱动视图,只需更改数据,我们的页面就会随之改变,由此我们可以得出以下公式:
UI = render(state)
state代表数据,UI代表页面,vue将扮演着render,一旦数据变化就会把数据反应到ui上。
#2.2 Object的响应
#2.2.1 利用Object.defineProperty是数据变得可观测
相信大家都知道Object.defineProperty这个方法,vue就是用这个方法对数据进行观测的,会对所有的数据设置getter和setter,这样我们就知道了数据何时发生变化了,从而去更新相应的视图
首先我们先看这个案例
let mayun={
money:"1000亿"
}
Object.defineProperty(mayun,'money',{
get(){
console.log('mayun我被读取了')
},
set(newVal){
console.log('mayun被设置了',newVal)
}
})
mayun.money // mayun我被读取了
mayun.money="10000亿" //mayun被设置了10000亿
从上面的案例可以看出,我们读取数据时会进入get函数中;我们设置数据时会进入set函数中,这样数据就变得可观测了,用户读取和设置数据我们都会知道。
vue中的源码目录src/core/observer/index.js
// Observer观察者类,对每个对象设置getter和setter,进行依赖收集和发送更新
export class Observer {
value: any;
constructor (value: any) {
this.value = value
/**
* 给value增加一个属性'__ob__',值为该value的Observer的实例
* 这样是相当于在value上打一个补丁,避免重复操作
* 方法在util/lang.js
*/
def(value, '__ob__', this)
if (Array.isArray(value)) {
// 数组逻辑
} else {
// 操作对象的逻辑
this.walk(value)
}
}
/**
* 遍历对象上的每个属性
*/
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i])
}
}
//在对象上定义反应属性
export function defineReactive (
obj: Object, //要响应的对象
key: string, // 响应对象的键
val: any, // 对象的值
) {
const dep = new Dep() //创建一个依赖管理器
// 递归,针对子对象设置geter和setter,并返回子对象的Observer实例
let childOb = observe(val)
Object.defineProperty(obj, key, {
enumerable: true, //表示能否通过for in 循环属性
configurable: true, //是否可以删除或重新定义属性
// 在这里可以知道获取了值
get: function reactiveGetter () {
dep.depend()//收集依赖,往下面看会明白
return val
},
// 在这里可以知道更改了值
set: function reactiveSetter (newVal) {
dep.notify() // 通知所有依赖这个对象观察者进行更新
val=newVal
}
})
}
/*
* 给值创建观察者实例
* 如果观察成功就返回新的观察者实例
* 如果已经观察过了,就返回现有的
*/
function observe (value: any, asRootData: ?boolean): Observer | void {
// 如果不是对象,就不必设置getter好和setter
if (!isObject(value) {
return
}
let ob: Observer | void
//通过‘__ob__’,判断是否有Observer实例,如果已经打过标记了,就直接拿出Observer的实例对象
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__
} else if (
/**
* 确保value纯对象,且没有被是否Observer过
*/
shouldObserve && //是否Observer过,通过toggleObserving来修改
!isServerRendering() && // 是否是服务端渲染
(Array.isArray(value) || isPlainObject(value)) && //isPlainObject判断类型是否是object
Object.isExtensible(value) && //isExtensible判断对象是否可以扩展
!value._isVue // 避免vue实例被观察
) {
ob = new Observer(value)
}
return ob
}
从上面的源码可以看到,通过new Observer(obj)我们可以使对象变得可观测,那么下一步我们就要知道既然知道了数据什么时候变化,那该怎么去更新视图呢?该更新哪些视图呢,这就要先提到依赖收集。
2.2.2 依赖收集
数据发生了变化,我们不可能把整个视图都更新一遍,所以视图中谁用了这个数据,就去更新这部分视图。所以我们会把谁依赖这个数据全部都放到一个数组里,这样当数据发生变化时,我们直接遍历数组的依赖去更新视图就行了。
何时收集依赖? 在getter中调用dep.depend()
何时通知依赖去更新视图? 在setter中调用dep.notify()
#🍅 我们用dep类去存放依赖
// 源代码 `src/core/observer/dep.js`
// Dep用来管理watcher实例,watcher实例就是数据的依赖
class Dep {
constructor(){
// 存放watcher实例
this.deps=[]
}
// 添加依赖
addDep(dep){
this.deps.push(dep)
}
// 移除一个依赖
removeSub (sub: Watcher) {
remove(this.subs, sub)
}
// 当Dep上有静态属性target时,就调用Dep.target的adddDep方法,进行添加依赖到deps数组中
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
// 通知所有订阅者进行更新
notify(){
const subs = this.subs.slice()
for (let i = 0, l = subs.length; i < l; i++) {
// update的方法更新视图
subs[i].update()
}
}
}
从上面可知,我们用Dep类去存放依赖,现在我们使数据变的可观测,又知道了何时去存放依赖,何时又去通知依赖更新视图,在哪存放依赖,现在我们不知道的是依赖是谁?
2.2.3 谁是依赖?
这就引出了我们的Watcher类,它算是每个数据的依赖,每个数据可能有很多依赖,所以我们才会把这些依赖放到一个Dep类的数组里;从而如果要只要更新这个数组里的 watch实例就行了,其实watch实例中有个回调函数,就是更新视图的函数
在编译阶段会对不同的数据进行new Watcher(vm,expOrFn,cb);在wtcher类中会进行以下操作
- 我们把wacher实例放到Dep的静态属性target上
- 然后调用数据的getter,把依赖(Wathcer实例)添加到Dep实例的数组中去
- 当用户数据设置数据时,会触发new Watcher()传入的回调函数cb
/**
* 使一个对象转化成可观测对象
* @param { Component } vm vue实例
* @param { string | Function } expOrFn 表达式,要watch 的属性名称
* @param { Function } cb 更新视图的回调函数
*/
class Watcher {
constructor(vm,expOrFn,cb){
this.vm=vm //vue实例
this.getter = expOrFn //要观察的表达式
this.cb=cb //回调函数
// expOrFn可以是字符串或者函数
// 什么时候会是字符串,例如我们正常使用的时候,watch: { x: fn }, Vue内部会将 `x` 这个key 转化为字符串
// 什么时候会是函数,其实 Vue 初始化时,就是传入的渲染函数 new Watcher(vm, updateComponent, ...);
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
}
this.value=this.get()
}
get (){
let value
const vm = this.vm
// 我们把wacher实例放到Dep静态属性的target上
//vue源码中将其封装成了一个方法pushTarget,在src/core/observer/dep.js
Dep.target = this
value = this.getter.call(vm, vm) //触发getter添加依赖
Dep.target=null //释放
return value
}
update(){
// 触发回调,更新视图
this.cb.call(this.vm, value, oldValue)
}
}
/**
* 源码地址 src/core/util/lang.js
* Parse simple path.
* 把一个形如'data.a.b.c'的字符串路径所表示的值,从真实的data对象中取出来
* 例如:
* data = {a:{b:{c:2}}}
* parsePath('a.b.c')(data) // 2
*/
const bailRE = new RegExp(`[^${unicodeRegExp.source}.$_\d]`)
export function parsePath (path: string): any {
/**
* Parse simple path.
* 如果 path 参数,不包含 字母 或 数字 或 下划线,或者不包含 `.`、`$` ,直接返回
* 也就是说 obj-a, obj/a, obj*a 等值,会直接返回
*/
if (bailRE.test(path)) {
return
}
const segments = path.split('.')
return function (obj) {
for (let i = 0; i < segments.length; i++) {
if (!obj) return
obj = obj[segments[i]]
}
return obj
}
}
2.2.4 梳理一下宏观的整个流程
- 我们通过
Observer类使数据变得可观测 - 用
Dep类去存放依赖 - 用
Watcher实例去作为每个数据的依赖
举个例子:
<div id="app">
<p>{{msg}}<p>
<div v-text="msg"><div>
<div>
我这个p标签和div标签都用了msg这个数据,所以这2个都是msg的依赖;那在我们编译的时候会 new Observer(data) 使数据变得可观测;然后new Watcher(vm,expOrFn,tempcb);new Watcher(vm,expOrFn,textcb);然后触发数据的getter把2个依赖添加dep实例的数组中,当用户进行更改值的时候;会触发数据的setter,然后遍历dep数组调用依赖的update方法更新视图。
2.2.5 响应式2.0和3.0的对比
2.0 Object.defineProperty
- 2.0需要对每个属性进行监听,对data的属性是遍历+递归为每个属性设置getter和setter
- 2.0数组添加元素和长度的变化无法监视到采用的是this.$set(obj,index,value)的方法
- 对象的添加值和删除值,Object.defineProperty无法观测,采用的是this.$set(obj,key,value)的方法
3.0 proxy
- 弥补了2.0上面的缺点
- 采用惰性监听,初始化的时候不创建Observer,而是会在用到的时候去监听,效率更高,速度加倍
2.2.6 简单实现vue响应式和编译的参考代码
目录 examples/vue2.0/2
#2.3 Array的响应
#2.3.1 Array的观测
由于Array没有defineProperty属性,所以不能像Object一样进行监听属性变化,然而vue实现了特有的方法去监听Array的变化,下面让我们看看一个例子。
let arr=[]
arr.__proto__.newPush=function mutator (val){
console.log('访问到了') //访问到了
this.push.call(this,val)
}
arr.newPush(8)
console.log(arr) //[ 8 ]
从上面的例子我们可以看出,我们只要修改Array原型上的方法,就能知道什么修改了数据;同样vue中就是这么实现的,然后看vue源码的实现
// 源码目录 src/core/observer/array.js
import { def } from '../util/index'
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
/**
* copy一份数组的原型方法,防止污染Array的原型
*/
// 改变数组的7个方法
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
/**
* Intercept mutating methods and emit events
*/
methodsToPatch.forEach(function (method) {
// 缓存原生方法
const original = arrayProto[method]
/**
* 通过def给对象赋值,并设置描述符
* Object.defineProperty(obj,key,{
* value:val
* })
*/
def(arrayMethods, method, function mutator (...args) {
const result = original.apply(this, args)
// __ob__存的是Observer实例
const ob = this.__ob__
let inserted
//对数组新增元素和删除元素进行转换成响应式
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2) //args是个数组,splice(开始位置,个数,替换的元素),所以参数下标为2的是新加的元素
break
}
if (inserted) ob.observeArray(inserted) //对新增元素转换为响应式
// 通知更新
ob.dep.notify()
return result
})
})
上面的源码可以看出,vue先拷贝了一份原型上的方法,避免污染Array的原型,然后创建一个对象并指定了原型;在arrayMethods上定义了7个方法并给7个方法指定了函数 如果有新增元素,转换成响应式并触发更新
vue通过创建一个数组拦截器,在拦截器里重写了操作数组的方法,放操作数组是,从拦截器就可以观测的到操作数组
2.3.2 把拦截器挂载到数组实例上
/*
*vue中的源码目录`src/core/observer/index.js`
*/
export class Observer {
value: any;
constructor (value: any) {
this.value = value
/**
* 给value增加一个属性'__ob__',值为该value的Observer的实例
* 这样是相当于在value上打一个补丁,避免重复操作
* 方法在util/lang.js
*/
def(value, '__ob__', this)
if (Array.isArray(value)) { // 数组逻辑
if (hasProto) { //数组是否支持"__proto__"属性 const hasProto = '__proto__' in {}
protoAugment(value, arrayMethods)
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
this.observeArray(value) //深度监测,给数组下面的子元素转换给响应式
} else {
// 操作对象的逻辑
this.walk(value)
}
//直接替换原型
function protoAugment (target, src: Object) {
/* eslint-disable no-proto */
target.__proto__ = src
/* eslint-enable no-proto */
}}
// 直接添加到对象上
function copyAugment (target: Object, src: Object, keys: Array<string>) {
for (let i = 0, l = keys.length; i < l; i++) {
const key = keys[i]
def(target, key, src[key])
}
}
//对数组的成员进行observe
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}
}
/*
* 给值value创建观察者实例
* 如果观察成功就返回新的观察者实例
* 如果已经观察过了,就返回现有的
*/
function observe (value: any, asRootData: ?boolean): Observer | void {
// 如果不是对象,就不必设置getter好和setter
if (!isObject(value) {
return
}
let ob: Observer | void
//通过‘__ob__’,判断是否有Observer实例,如果已经打过标记了,就直接拿出Observer的实例对象
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__
} else if (
/**
* 确保value纯对象,且没有被是否Observer过
*/
shouldObserve && //是否Observer过,通过toggleObserving来修改
(Array.isArray(value) || isPlainObject(value)) && //isPlainObject判断类型是否是object
Object.isExtensible(value) && //isExtensible判断对象是否可以扩展
!value._isVue // 避免vue实例被观察
) {
ob = new Observer(value)
}
return ob
}
上面代码中先判断浏览器是否支持__proto__属性,如果支持就把数据的__proto__属性设置为arrayMethods;如果不支持直接则循环把方法加到value上
#2.3.3 何时收集依赖和触发依赖?
收集依赖:在getter中
触发依赖:在重写操作数组的方法中(arrayMethods)
为什么说收集依赖也在getter函数中,这不是操作的对象的吗?
new vue({
data(){
return {
test:[1,2,3,4]
}
}
})
我们对data return出来的这个对象转换成响应式进行观测;我们获取数组时,肯定是obj.test;这样的话肯定会走obj的getter中,所以我们收集依赖也是在 getter中
#🍅 收集依赖
export class Observer {
constructor (value) {
this.value = value
// 创建一个依赖管理器,用来收集数组依赖
this.dep = new Dep()
if (Array.isArray(value)) {
} else {
this.walk(value)
}
}
}
//在对象上定义反应属性
export function defineReactive (
obj: Object, //要响应的对象
key: string, // 响应对象的键
val: any, // 对象的值
) {
// 递归,针对子对象设置geter和setter,并返回子对象的Observer实例
let childOb = observe(val)
Object.defineProperty(obj, key, {
enumerable: true, //表示能否通过for in 循环属性
configurable: true, //是否可以删除或重新定义属性
// 在这里可以知道获取了值
get: function reactiveGetter () {
if (Dep.target) {
if (childOb) {
// 子对象进行依赖收集
childOb.dep.depend()
// 如果是数组,对每个成员都进行依赖收集,如果数组成员还是数组则递归;例如二维数组
if (Array.isArray(val)) {
dependArray(val)
}
}
return val
},
// 在这里可以知道更改了值
set: function reactiveSetter (newVal) {
dep.notify() // 通知所有依赖这个对象观察者进行更新
val=newVal
}
})
}
// __ob__是否转换成响应式了
function dependArray (value: Array<any>) {
for (let e, i = 0, l = value.length; i < l; i++) {
e = value[i]
e && e.__ob__ && e.__ob__.dep.depend()
if (Array.isArray(e)) {
dependArray(e)
}
}
}
举个例子:
new vue({
data(){
return {
test:[1,2,3,4]
}
}
})
分析一下整个流程
- 我们 new Observer()时候,会进去defineReactive 这个函数中,执行了observe(val)获取到了Observer 实例;并给该对象设置了getter和setter(observe(val此时传入的是数组test)
- 当调用该对象的getter的时候,我们对数组进行依赖收集,如果子对象中还有数组则对递归收集
#🍅 通知依赖
methodsToPatch.forEach(function (method) {
def(arrayMethods, method, function mutator (...args) {
const result = original.apply(this, args)
// __ob__存的是Observer实例
const ob = this.__ob__
// 通知更新
ob.dep.notify()
return result
})
})
此时我们想通知依赖,首先要能访问到依赖,这里的关键就是this,此时的this指向的是被响应的数据value,数据value上会绑定一个__ob__属性; __ob__的值是Observer实例,我们在实例中就可以访问到依赖管理器,然后只需要调用dep.notify()就可以去通知依赖了
#2.3.4 不足
arr[0]=0
arr.length=0
不足的地方就是用下标去更改数据无法监测,无法用length置空数组,vue提供set方法和delete去更改数据
二.vittualdom及 DIff算法
3.1 虚拟DOM
#3.1.1 前言
#🍅 操作真实DOM的代价
let div = document.createElement('div')
let str = ''
for (const key in div) {
str += key + ''
}
console.log(str)
从打印结果可以看出,一个dom会有很多属性;真实的dom节点入栈执行会占据很大的内存,当我们频繁的操作会产生性能问题
我们用传统的开发模式,用原生的js和jq操作DOM时,浏览器会从构建DOM树到绘制从头到尾执行一遍,如果我们更新10个dom节点,浏览器收到第一个dom请求后并不知道后面还有9次更新操作,最终会执行10次。如果第一次计算完,紧接这下一个DOM更新请求更改了前一次的DOM;那么前一次的dom更新就是白白的性能浪费,虽然计算机硬件一直迭代更新,但是操作dom的代价仍然是昂贵的,频繁操作还会出现页面卡顿,影响用户体验。
#🍅 为什么虚拟DOM?
虚拟dom就是为了解决浏览器性能问题而设计出来的,如果有10次dom更新的操作,虚拟dom不会立即去操作dom,而是将这去10次更新的diff内容保存到本地的一个js对象中,最终将这个js对象一次性patch到DOM树上,再进行后续的操作,避免大量无畏的计算量,所以用js对象模拟DOM节点的好处是页面的更新可以先全部反应到这个js对象上,操作js对象的速度显然更快,等待更新完成后,在将最终的js对象映射真实的DOM。
vue中虚拟DOM的表现
// 通过js对象描述的dom结构
{
tag: 'div'
data: {
id: 'app',
class: 'main'
},
children: [
{
tag: 'p',
text: 'this is test'
}
]
}
//最后真实渲染的dom结构
<div id="app" class="mian">
<p>this is test</p>
</div>
#3.1.2 VNode类
// 源码地址:src/core/vdom/vnode.js
// 通过vNode类,实例化出不同的虚拟DOM节点
export default class VNode {
constructor (
tag?: string, // 当前节点标签名
data?: VNodeData, // // 当前节点的数据对象,也就是标签上的属性;包括attrs,style,hook等具体包含的字段可以参考/types/vnode.d.ts
children?: ?Array<VNode>, ////数组类型,包含当前节点的子节点
text?: string, // 当前节点的文本
elm?: Node,
context?: Component,
componentOptions?: VNodeComponentOptions,
asyncFactory?: Function
) {
this.tag = tag // 当前节点标签名
this.data = data // 当前节点的数据对象,也就是标签上的属性;包括attrs,style,hook等具体包含的字段可以参考/types/vnode.d.ts
this.children = children //数组类型,包含当前节点的子节点
this.text = text // 当前节点的文本
this.elm = elm // 当前虚拟节点对应的真实的dom节点
this.ns = undefined // 节点的namespace(命名空间)
this.context = context // 编译作用域,当前节点对应的vue实例
this.fnContext = undefined // 函数组件化的作用域,当前组件对应的vue实例
this.fnOptions = undefined // 函数式组件Option选项
this.fnScopeId = undefined
this.key = data && data.key // 节点的key属性,用作节点的标识,有利于patch优化
this.componentOptions = componentOptions // 创建组件实例时会用到的选项信息
this.componentInstance = undefined //当前组件节点对应的vue实例
this.parent = undefined //组件的占位节点
this.raw = false // 是否为原生HTML或只是普通文本,innerHTML的时候为true,textContent的时候为false
this.isStatic = false //静态节点标识
this.isRootInsert = true // 是否作为根节点插入被<transition>包裹的节点,该属性的值为false
this.isComment = false //当前节点是否是注释节点
this.isCloned = false //当前节点是否为克隆节点
this.isOnce = false // 当前节点是否有v-once指令
this.asyncFactory = asyncFactory
this.asyncMeta = undefined
this.isAsyncPlaceholder = false
}
// DEPRECATED:向后兼容组件的别名
/* istanbul ignore next */
get child (): Component | void {
return this.componentInstance
}
}
VNode类中包含了描述一个真实dom节点所需要的一系列属性,通过 VNode类可以描述各种真实dom节点
3.1.3 VNode类能描述的类型节点
- EmptyVNode: 没有内容的注释节点
- TextVNode: 文本节点
- CloneVNode: 克隆节点,可以是以上任意类型的节点,唯一的区别在于isCloned属性为true
- ComponentVNode: 组件节点
- FunctionalComponent: 函数式组件节点
- ElementVNode: 普通元素节点
...
- EmptyVNode(注释节点)
// 源码地址:src/core/vdom/vnode.js
// 创建注释节点
export const createEmptyVNode = (text: string = '') => {
const node = new VNode()
node.text = text
node.isComment = true //isComment为true,说明是一个注释节点
return node
}
从上面可以看出注释节点只需2个属性,text表示是注释内容;isComment表示是否是个注释节点
- TextVNode(文本节点)
// 源码地址:src/core/vdom/vnode.js
// 创建文本节点
export function createTextVNode (val: string | number) {
return new VNode(undefined, undefined, undefined, String(val))
}
文本节点只需传入文本值即可
- CloneVNode(克隆节点)
// 源码地址:src/core/vdom/vnode.js
// 创建克隆节点
export function cloneVNode (vnode: VNode): VNode {
const cloned = new VNode(
vnode.tag,
vnode.data,
// #7975
// clone children array to avoid mutating original in case of cloning
// a child.
vnode.children && vnode.children.slice(),
vnode.text,
vnode.elm,
vnode.context,
vnode.componentOptions,
vnode.asyncFactory
)
cloned.ns = vnode.ns
cloned.isStatic = vnode.isStatic
cloned.key = vnode.key
cloned.isComment = vnode.isComment
cloned.fnContext = vnode.fnContext
cloned.fnOptions = vnode.fnOptions
cloned.fnScopeId = vnode.fnScopeId
cloned.asyncMeta = vnode.asyncMeta
cloned.isCloned = true
return cloned
}
克隆节点就是把传入的节点的属性全部赋值到新创建的节点上
-
ComponentVNode(组件节点)
源码地址:src/core/vdom/create-component.js
组件节点除了有普通元素节点的属性之外,还有2个私有的属性
- componentOptions 创建组件实例时会用到的选项信息
- componentInstance 当前组件节点对应的vue实例
-
FunctionalComponent(函数式组件节点)
源码地址:src/core/vdom/create-functional-component.js
函数式组件2个私有的属性
- fnContext 函数组件化的作用域,当前组件对应的vue实例
- fnOptions 函数式组件Option选项
-
ElementVNode(普通元素节点)
源码地址:src/core/vdom/create-element.js
#3.1.4 VNode类的作用
VNode类用js对象形式描述真实的dom,在vue初始化阶段,我们把template模板用vnode类实例化成js对象并缓存下来,当数据发生变化重新渲染页面的时候,我们把数据发生变化后用vnode类实例化的js对象与前一次缓存下来描述dom节点的js对象进行对比,找出差异;然后根据有差异的节点创建出真实的节点插入视图当中
#3.1.5 总结
虚拟dom就是用以对象的形式去描述真实的dom,用js计算的性能换取操作真实dom所消耗的性能
3.2 diff算法
#3.2.1 前言
上章我们学习了虚拟dom,知道了渲染真实dom的开销很大,如果我们修改了某个数据,如果直接渲染到真实的dom上会引起整个dom树的重绘和重排; 有没有可能我们只更新修改的那一块dom,diff算法能帮到我们,我们先根据真实的dom生成一个virtual DOM树,当virtual dom树的某个节点发生变化后生成一个新的vnode,然后Vnode和oldVnode进行对比,一边比较一边给真实DOM打补丁,找出差异的过程就是diff的过程。
vnode:数据变化后要渲染的虚拟的dom节点
oldVnode:数据变化前视图对应的真实的dom节点
#3.2.2 patch
vue中把Diff过程叫做patch过程,patch的过程就是以新的Vnode为基准,去改造旧的oldNode,让其跟新的一样;有人会说了,直接把旧的替换成新的就行了吗,如果这样做的话就是更新整个视图,而我们现在想做的是哪里变化了更新哪里。
举个例子: 现在你手上有个纸板的文档,公司让你做成电子版的;你看了看内容发现好像以前做过,于是你翻了电脑,果不其然上周公司上你做过。你仔细对比一下内容,发现只有某一段的内容不一样,于是在你面前有2个办法:1.参考纸版去改老版的。2.在建个文档,把纸版的内容从新输入到电脑。这样一看那肯定是要选择方案1,而我们的vue中也是这样。
#🍅 patch的过程就是做3件事情
- 创建节点:Vnode里有的,oldNode没有,那么就在oldNode里创建节点
- 删除节点:Vnode里没有,oldNode有,那么旧在oldNode删除节点
- 更新节点:Vnode和oldNode都有,那么以Vnode基准去更新oldNode
#🍅 了解一下oldVnode有哪些属性
<div id="test" class="main"><div>
// 上面节点对应的 oldVnode 就是
{
elm: div //对真实的节点的引用,本例中就是document.querySelector('v#test.main')
tag: 'DIV', //节点的标签
sel: 'div#test.main' //节点的选择器
data: null, // 一个存储节点属性的对象,对应节点的el[prop]属性,例如onclick , style
children: [], //存储子节点的数组,每个子节点也是vnode结构
text: null, //如果是文本节点,对应文本节点,否则为null
}
需要注意的是,elm属性引用的是此virtual dom对应的真实dom,patch的vnode参数的elm最初是null,因为patch之前它还没有对应的真实dom
3.2.3 创建节点
从上章我们知道通过Vnode类可以创建6种描述的dom节点的实例,是实际只有3种会被创建,并插入dom当中,3种分别是:元素节点、注释节点、文本节点
// 源码位置: /src/core/vdom/patch.js
function createElm (vnode, parentElm, refElm) {
const data = vnode.data
const children = vnode.children
const tag = vnode.tag
if (isDef(tag)) { //判断是否有tag标签
vnode.elm = nodeOps.createElement(tag, vnode) // 创建元素节点
createChildren(vnode, children, insertedVnodeQueue) // 创建元素节点的子节点
insert(parentElm, vnode.elm, refElm) // 插入到DOM中
} else if (isTrue(vnode.isComment)) { //判断注释属性是否是true,true的话就是注释节点
vnode.elm = nodeOps.createComment(vnode.text) // 创建注释节点
insert(parentElm, vnode.elm, refElm) // 插入到DOM中
} else { // 如果不是元素节点和注释节点,那么就是文本节点
vnode.elm = nodeOps.createTextNode(vnode.text) // 创建文本节点
insert(parentElm, vnode.elm, refElm) // 插入到DOM中
}
}
function isDef (v) {
return v !== undefined && v !== null
}
#3.2.4 删除节点
删除节点比较简单,只需调用删除元素的父元素的removeChild方法
// 源码位置: /src/core/vdom/patch.js
function removeVnodes (vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx]
if (isDef(ch)) {
if (isDef(ch.tag)) {
// 存在tag时,说明是元素节点
removeAndInvokeRemoveHook(ch) // 移除挂载dom上的节点
invokeDestroyHook(ch) //销毁的钩子
} else { // Text node
// 说明是文本节点
removeNode(ch.elm)
}
}
}
}
function isDef (v) {
return v !== undefined && v !== null
}
#3.2.5 更新节点
更新节点是vNode和oldVnode都存在时,我们需要细致的找出不同的地方
#🍅 先了解一下静态节点
<div>标题<div>
静态节点,没有绑定任何变量,第一次渲染后,以后就不会变化了
#🍅 这个函数做了一下事情
-
找到真实的dom节点,称之为elm
-
判断vNode和oldnode如果是同一个对象,则直接return
-
如果vNode和oldnode都是静态节点,则直接return
-
如果vnode没有文本节点
- 2者都有子节点且不相同,则执行updateChildren比较子节点,比较子节点在下一章介绍。
- 若只有vnode存在子节点,在判断oldVnode是否有文本,如果有就清除,然后将vnode的子节点替换到真实的dom中去
- 若只有oldVnode存在子节点,则清空dom种子节点的存在
- 若2者都没有子节点,则oldnode种有文本,则清空oldnode的文本
-
如果vnode和oldVnode都有文本节点且不相同: 则将elm的文本节点设置为vnode的文本节点(文本节点就是text对应的值)
// 源码位置: /src/core/vdom/patch.js
function patchVnode (
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) {
// vnode和oldVnode是完全一样,说明引用一致,没有什么变化;如果是就退出程序
if (oldVnode === vnode) {
return
}
// 让vnode.elm引用到现在的真实dom上,当elm修改时,vnode.elm会同步变化
const elm = vnode.elm = oldVnode.elm
// 如果vnode和oldVnode都是静态节点就退出程序,静态节点,无论数据发生任何变化都与它无关
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance
return
}
const oldCh = oldVnode.children
const ch = vnode.children
// vnode如果没有text属性
if (isUndef(vnode.text)) {
// 如果vnode的子节点和oldVnode的子节点都存在
if (isDef(oldCh) && isDef(ch)) {
// 若都存在且不相同,则更新子节点,这是diff的核心
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
}
// 若只有vnode存在子节点
else if (isDef(ch)) {
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(ch)
}
// 判断oldVnode是否有文本,如果有则清空dom中的文本,再把vnode的子节点添加到真实的DOM中
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
}
// 若只有oldVnode存在子节点
else if (isDef(oldCh)) {
// 清空dom中的节点
removeVnodes(oldCh, 0, oldCh.length - 1)
}
// 如果oldVnode和node都没有子节点,但oldVnode有text
else if (isDef(oldVnode.text)) {
// 那么清空oldVnode文本
nodeOps.setTextContent(elm, '')
}
// 如果vnode和oldVnode有text属性,但是oldVnode和vnode的text不相同
} else if (oldVnode.text !== vnode.text) {
// 不相同则用vnode.text替换真实的dom文本
nodeOps.setTextContent(elm, vnode.text)
}
}
#🍅 更新节点流程
3.2.6 diff的整个流程
#🍅 diff的比较方式
pach的过程只会进行同级比较,不会跨级,如果两个子节点一样,那么就深入检查他们的子节点,如果2个子节点不一样,就直接替换oldVnode,即使这2个子节点的子节点一样,如果第一层不一样就不会深入比较第二层
#🍅 流程
从上面看下来,我们了解到了patch要做些什么,无非就是创建、删除、更新;下面我们来分析整个流程
初始化时,通过render函数生成vNode,同时也进行了Watcher的绑定,当数据发生变化时,会执行_update方法,生成一个新的VNode对象,然后调用 __patch__方法,比较VNode和oldNode,最后将节点的差异更新到真实的DOM树上 vue在update的时候会调用以下函数
// 源码位置: /src/instance/lifecycle.js
export function lifecycleMixin (Vue: Class<Component>) {
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
const prevVnode = vpatch__ is injected in entry points
// based on the rendering backend used.
if (!prevVnode) {
// 初次渲染,会传入原生dom节点和虚拟dom节点
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// 更新
vm.$el = vm.__patch__(prevVnode, vnode)
}
}
}
vm._patch_才是进行vnode diff的核心,来看看patch是怎么打补丁的(代码只保留核心部分)
patch的会有3种情况
- 如果vnode不存在,oldVnode存在;那么需要销毁真实的dom节点
- 如果vnode存在,oldVnode不存在;那么需要创建节点
- 2者都存在,进行比较
// 源码位置: /src/core/vdom/patch.js
/**
* oldVnode 旧的真实的DOM节点
* vnode 节点变化后生成新的Vnode
*/
function patch (oldVnode, vnode) {
// 如果vnode不存在,oldVnode存在,则调用销毁钩子销毁节点invokeDestroyHook(oldVnode)
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
let isInitialPatch = false
const insertedVnodeQueue = []
// 如果oldVnode不存在,Vnode存在,那么创建新节点,则调用createElm()
if (isUndef(oldVnode)) {
// empty mount (likely as component), create new root element
isInitialPatch = true
createElm(vnode, insertedVnodeQueue)
// 当Vnode和oldVnode都存在时
} else {
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch 现有的根节点,对oldVnode和vnode进行diff,并对oldVnode打patch
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
}
}
sameVnode函数是判断2个节点是否是同一个节点
// 源码位置: /src/instance/lifecycle.js
function sameVnode (a, b) {
return (
// key值
a.key === b.key && (
(
a.tag === b.tag && //标签名
a.isComment === b.isComment && // 2个节点是否是注释节点
// 2个节点是否都定义了data,data中包含一些具体信息,例如:class,style
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b) // 当标签是input的时候,type必须相同
) || (
isTrue(a.isAsyncPlaceholder) &&
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)
)
)
)
}
#3.2.7 总结
上面我们学习了diff的基本流程,在diff的整个过程就是创建节点、删除节点、更新节点,对源码也进行了讲解;下面我们将对vnode和oldNode都包含子节点的情况进行分析,这是diff的核心。
3.3 传统的更新子节点
#3.3.1 前言
上一章我们学习了在patch过程中主要干3件事:创建节点、删除节点、更新节点,前2种都很简单,而更新节点较难些,本章我们要学习的是当vnode和oldVnode都有子节点,需要更新子节点的情况,这是dom-diff的核心,也是本章要学习的内容。
#3.3.2 如何更新子节点
#🍅 传统的节点对比方式
当vnode和oldVnode都有子节点时,我们会比较虚拟dom对象中的children,这个数组包含了所有子节点。我们把包含vnode的所有子节点数组命名为newCh而oldVnode的所有子节点命名为oldCh,现在我们通过循环对比子节点。
newCh.forEach(newChild=>{
oldCh.forEach(oldChild=>{
// 处理逻辑省略
})
})
从上面代码和图可知,假如newCh和oldCh都包含的是li标签,我们通过循环进行比较新旧子节点,newCh每项节点会跟oldCh的每项节点进行对比。
#🍅 理解未处理与已处理
从上图可知,我们假如newCh数组中的前2个子节点在dlCh数组中找到了对应的节点并给odlCh打了补丁,我们可以把已经对比过的称为已处理,没有对比的我们称未处理。
#🍅 对比中会出现以下几种情况
- 创建子节点:
newCh某个节点跟oldCh的每个节点进行对比,发现没有找到,那么需要创建节点插入到oldCh中
当newCh的第二节点在oldCh里面没找到时,我们需要创建节点,创建完节点那我们插入到什么位置?假设我们插入已处理的后面,看起来是对的,那么如果newCh第三个节点在oldCh里面再没找到时,还适用这种方式吗?
从上图可以看出newCh第三个节点在oldCh里面再没找到时,按照上面的逻辑插入已处理的后面,那么这次新增的节点将会插入oldCh第二位置,那么2个节点的位置就不对应了,应该newCh第三个节点要对应oldCh第三的位置才行,所以我们新增的节点应该插入未处理的前面就适应各种场景了
- 删除子节点:
当newCh的第一个节点都循环完后,发现oldCh里面还有未处理的节点;这就说明在newCh没有这个节点;那么我们需要删除oldCh未处理的节点
- 移动子节点:
newCh的某个节点跟oldCh每一个节点对比中发现有相同的,但是位置不同,那么我们需要更新oldCh的该节点让其与newCh的相同,在以newCh位置为基准去移动oldCh这个节点的位置,可以参考下图中,newCh的第二个节点与oldCh的最后一个节点相同,但位置不同,那么我们需要移动oldCh最后一个节点的位置
- 更新子节点:
当newCh的某个节点和oldCh某个节点相同,位置也相同,那么我们需要更新oldCh的该节点让其与newCh的相同
3.4 优化的更新子节点
#3.4.1 前言
我们上章传统的做法是拿newCh的每个子节点跟oldCh的子节点逐一对比,根据找到或没找到;不同的情况去做不同的处理。这种做法虽然能解决问题,但是有不足的地方,当子节点过多,这种循环算法变得很复杂;从而影响页面加载,本章我们将介绍vue中是怎么做的。
#🍅 传统做法的缺点
传统的做法拿newCh的每个子节点跟oldCh的子节点逐一对比,如果运气好的话newCh数组的第一个节点跟oldCh数组的第一个节点相同,那么 就可以结束newCh数组的第一个节点的循环对比,如果运气差的话要对比到oldCh数组的最后一个节点,如果oldCh数组里有20个节点就要循环20次。
#🍅 vue中优化的做法
概念介绍
- 新前:newCh数组中第一个未处理的节点
- 新后:newCh数组中最后一个未处理的节点
- 旧前:oldCh数组中第一个未处理的节点
- 旧后:oldCh数组中第一个未处理的节点
既然我们考虑到有极端的情况,那么我们不按顺序去循环对比2个数组,可以先比较数组中特殊的位置的节点,例如:
- 新前旧前对比(第一种情况)
先拿newCh数组未处理的第一个节点和oldCh数组未处理的第一个节点进行对比,如果相同就更新节点,如果不同就进入第二种情况的对比
- 新后旧后对比(第二种情况)
再拿newCh数组未处理的最后一个节点和oldCh数组未处理的最后一个节点进行对比,如果相同就更新节点,如果不同就进入第三种情况的对比
- 新后旧前对比(第三种情况)
再拿newCh数组未处理的最后一个节点和oldCh数组未处理的第一个节点进行对比,如果相同就更新节点,如果不同就进入第四种情况的对比
- 新前与旧后对比(第四种情况)
再拿newCh数组未处理的第一个节点和oldCh数组未处理的最后一个节点进行对比,如果相同就更新节点,如果不同就进入常规的对比,后面会介绍
四种情况对比如图所式
#3.4.2 新前与旧前对比
参考上一张图,这种情况对比,无需移动位置
#3.4.3 新后与旧后对比
参考上一张图,这种情况对比,无需移动位置
#3.4.4 新后与旧前对比
这种对比方式,如果newCh中未处理最后一个节点与oldCh中未处理的第一个节点对比发现相同,那么要参照newCh节点的位置去移动oldCh这个节点的位置。
#3.4.5 新前与旧后对比
这种对比方式,如果newCh中未处理第一个节点与oldCh中未处理的最后一个节点对比发现相同,那么要参照newCh节点的位置去移动oldCh这个节点的位置。
#3.4.6 源码解析
千呼万唤始出来,终于来到了我们的源码篇,根据上面的知识的铺垫,我们对vue中的diff做法有了一定了解,那么我们下面深入源码中看看是怎么做的
// 源码位置: /src/core/vdom/patch.js
//循环更新子节点
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
let oldStartIdx = 0 //oldCh开始的索引
let newStartIdx = 0 //newCh开始的索引
let oldEndIdx = oldCh.length - 1 //oldCh结束的索引
let oldStartVnode = oldCh[0] //oldCh未处理的第一个节点
let oldEndVnode = oldCh[oldEndIdx] //oldCh未处理的最后一个节点
let newEndIdx = newCh.length - 1 //newCh结束的索引
let newStartVnode = newCh[0] // newCh中未处理节点中的第一个节点
let newEndVnode = newCh[newEndIdx]// newCh中未处理节点的最后一个节点
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
// removeOnly is a special flag used only by <transition-group> removeOnly是仅由<transition group>使用的特殊标志
// to ensure removed elements stay in correct relative positions // 确保拆下的元件保持在正确的相对位置
// during leaving transitions
const canMove = !removeOnly
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(newCh)
}
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
// oldCh第一个节点不存在,索引右移,进入下一个节点
oldStartVnode = oldCh[++oldStartIdx]
} else if (isUndef(oldEndVnode)) {
// oldCh最后一个节点不存在,索引左移,进入上一个节点
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// 利用sameVnode函数,判断是否是同一个节点
// 如果oldStartVnode和newStartVnode是同一个节点,就进行patchVnode
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
// 索引右移,继续循环
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 如果oldEndVnode和newEndVnode是同一个节点,就进行patchVnode
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
// 索引左移,继续循环
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
// 如果oldStartVnode和newEndVnode是同一个节点,然后把oldStartVnode移动到oldCh所有未处理的节点之后
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
// 将oldStartVnode.elm移动到oldEndVnode.elm之后
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx] // 索引右移,获取下个节点
newEndVnode = newCh[--newEndIdx]// // 索引左移,获取上个节点
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
// 如果oldEndVnode和newStartVnode是同一个节点,,然后把oldEndVnode移动到oldCh所有未处理的节点之前
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
// 将oldEndVnode.elm移动到oldStartVnode.elm之前
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx] // 索引左移,获取上个节点
newStartVnode = newCh[++newStartIdx] // 索引右移,获取下个节点
} else {
// 上面几种都不符合的话,进行常规的循环对比patch
// createKeyToOldIdx建立key和index索引的对应关系,并返回一个对象
// 对应关系{key1:0,Key2:1,key3:2}
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
// 尝试在oldCh里找到跟newStartVnode同一个节点,并拿到这个节点的index
idxInOld = isDef(newStartVnode.key) //判断newStartVnode有没有key值
? oldKeyToIdx[newStartVnode.key] // newStartVnode有key值的话,拿到oldCh对应的index
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
// newStartVnode没有key值的话,采用循环比较的方式,在oldCh中找到newStartVnode对应的节点并拿到index
// 在oldCh里找不到与newStartVnode对应的index,说明newStartVnode是一个新节点
if (isUndef(idxInOld)) { // New element
// 创建新的dom节点,插入到oldStartVnode.elm前面
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
// 在oldCh里找不到与newStartVnode对应的index,叫vnodeToMove
vnodeToMove = oldCh[idxInOld] // 用index拿到对应的子节点
if (sameVnode(vnodeToMove, newStartVnode)) { //如果是同一个节点就进行更新节点
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
/**
* canMove为true表示需要移动节点,false则不移动
*/
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// same key but different element. treat as new element
// 如果index相同,但节点不相同,被视为新元素;创建新的dom节点
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
// 右移
newStartVnode = newCh[++newStartIdx]
}
}
/**
* 如果oldCh比newCh先遍历完,那么说明newCh里剩余的节点都是要新增的节点,
* 把[ newStartIdx, newEndIdx]之间的所有的节点都插入dom中
*/
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
// 添加newVnode中剩余的节点到parentElm中
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
/**
* 如果oldCh比newCh后遍历完,那么说明newCh里剩余的节点都是要删除的节点,
* 把[ newStartIdx, newEndIdx]之间的所有的节点都删除
*/
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
}
updateChildren中的newStartIdx、oldStartIdx、newEndIdx、oldEndIdx这几个变量,可能让我们疑惑,那么下面我们看一看是干什么用的?
- newStartIdx:newCh开始的索引
- newEndIdx:newCh结束的索引
- oldStartIdx:oldCh开始的索引
- oldEndIdx:oldCh结束的索引
这些索引的作用是,在newCh和oldCh对比的时候,从2头往中间对比
#🍅 key的作用
- 设置key以后,除了头尾两端比较之外,还可以从key生成的oldKeyToIdx对象中查找对应的节点。
- 不设置key以后,除了头尾两端比较之外,只能循环查找。
所以vue中key是vnode的唯一标记,通过key,我们的diff操作可以更加准确,更加快速。
#🍅 尽量不要用index做key
如果我们用index做key值,当我们删除一个节点,在进行diff的时候,oldCh用key对应newCh里节点可能就不是同一个节点,例如你删除索引为2的节点,那么会生成新的vnode;然而以前索引为3的节点会变成现在索引为2的节点,在进行patch的时候我们就根本不知道删除的节点为2。当然index的弊端还有其它按情况。
#3.4.7 总结
Vue 的 diff 过程可以概括为:oldCh 和 newCh 各有两个头尾的变量 oldStartIdx、oldEndIdx 和 newStartIdx、newEndIdx,它们会新节点和旧节点会进行两两对比,即一共有4种比较方式:newStartIdx对应节点和oldStartIdx对应节点 、newEndIdx对应节点 和 oldEndIdx对应节点 、newStartIdx对应节点 和 oldEndIndex对应节点、newEndIndex对应节点 和 oldStartIndex对应节点,如果以上 4 种比较都没匹配,如果设置了key,就会用 key 再进行比较,在比较的过程中,遍历会往中间靠,一旦 StartIdx > EndIdx 表明 oldCh 和 newCh 至少有一个已经遍历完了,就会结束比较。