手写vue - 虚拟DOM、diff算法
面试题:
1. 请简述Diff算法的执行过程
diff 的过程就是调用 名为 patch 的函数, 比较新旧节点, 一边比较,一边给真实DOM打补丁(定点更新)。
patch函数接受2个参数:oldVnode 和 vnode,分别代表 旧的节点 和 新的虚拟节点。
这个函数会比较 oldVnode 和 vnode 是否 相同, 即 函数 sameVnode(oldVnode, vnode)
-
老节点oldVnode.nodeType存在,即 老节点是dom元素,则初始化: createElm(vnode),将新vnode=>dom, parentEl直接appendChild,完成初始化
-
老节点oldVnode.nodeType不存在,即新老vnode都是虚拟dom,则需要对新旧两个vnode树进行比较,增加删除操作
-
新老vnode树的顶节点是否是同一个节点(tag、key相同,即为同一个节点):
-
同一个节点: 则进行props操作、children操作: 、
-
props操作:遍历新vnode树中的props,查看旧vnode树中是否存在该属性,不存在直接设置;存在,比较属性值是否相同,相同就不管,不相同,则重新设置属性值
-
children操作:
① 新vnode树的children的typeof如果是string,即,新节点是文本节点,查看旧节点是否是文本节点:如果旧节点是文本节点,且值与新节点的值不相同,则直接设置dom的contentText为新节点的children值;
② 新vnode树的children是数组,即,存在子节点: 查看旧vnode树的children:
a. 如果旧vnode树的children是文本,则,遍历新vnode的children,进行逐个createElm(child),并appendChild进去
b. 如果旧vnode树的children是数组,则更新子节点 updateChildren(el, oldch, newCh)
-
-
不同节点: 则直接createElm(新vnode树),再拿到老节点的el,获取父元素parentEl,用replaceChild方法进行替换
-
2. 既然Vue通过数据劫持可以精准探测数据变化,为什么还需要虚拟DOM进行diff检测差异?
现代前端框架有2种方式侦测变化,一种是pull, 一种是push
- pull:代表是React, React是如何侦测变化的呢?用setStateAPI显示更新,然后React会进行一层层的Virtual Dom Diff操作找出差异,然后Patch到DOM上,React从一开始就不知道到底是哪发生了变化,只是知道「有变化了」,然后再进行比较暴力的Diff操作查找「哪发生变化了」,另外一个代表就是Angular的脏检查操作。
- push:Vue的响应式系统是push的代表。当Vue程序初始化时就会对数据data进行依赖收集,一旦数据发生变化,响应式系统就会立刻得知,因此Vue是一开始就知道是『在哪里发生了变化』,但这又产生一个问题,Vue的响应式系统通常绑定一个数据就需要一个Watcher,一旦我们的绑定细粒度过高就会产生大量的Watcher,这会带来内存及依赖追踪的开销,而细粒度锅底会无法精准侦测变化。因此,Vue的设计是选择中等细粒度的方案,在组件级别进行push侦测的方式,也就是那套响应式系统,一个组件一个Watcher,通常我们会第一时间侦测到发生变化的组件,然后在组件内部进行Virtual Dom Diff 获取更加具体的差异,而VDom-diff 则是pull操作,Vue是push+pull结合的方式进行变化侦测的。
vue代码
之前的《手写vue - 实现数据响应式、数据双向绑定和事件监听》中,有些许不足:
- 直接模板 => dom,跳过了虚拟dom的生成和相关操作。
- 没有VNode,每当data中的数据发生变化时,都会进行实时的更新,增加了程序的负担。
- 每个key对应一个Watcher,当其中一个值发生变化时,都会遍历执行更新方法,在Vue2是每个组件实例对应一个Watcher,利用VNode和diff算法减少更新的次数,且是批量异步更新。
改进:
-
一个组件只有一个Watcher,从而减少更新方法的触发次数,降低性能消耗;
-
增加Vnode的概念,利用我们的简单diff算法,不直接对模板中的真实dom进行操作。
vue对象
// 1. 一个组件一个Watcher
// 2. diff算法
// 流程梳理:
// vue实例化 => 触发this.$mount(el),$mount()挂载
// => 创建一个Watcher实例➕将组件更新函数 updateComponent方法传入到Watcher中,等watcher需要update的时候,进行调用updateComponent方法
// => 当视图需要更新的时候,调用updateComponent方法,该方法中调用reader方法来获取vnode树,执行_update()将vnode转为真实dom。
// => _update()方法中调用__patch__方法用来进行diff算法
class Vue {
constructor(options) {
this.$options = options
this.$data = options.data()
this.$mounted = options.mounted
// 数据代理
this.proxy(this.$data)
// 数据响应式处理
this.observe(this.$data)
if (options.el) {
this.$mount(options.el)
}
if (this.$mounted) {
this.$mounted.call(this)
}
}
// 数据代理 this.$data.title ==> this.title
proxy(data) {
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
get() {
return this.$data[key]
},
set(v) {
this.$data[key] = v
}
})
})
}
// 响应式数据
// data数据中如果只为null,typeof null 只为object
observe(data) {
if (typeof data !== 'object' || data === null) {
return;
}
Object.keys(data).forEach(key => {
// 若this.form 的值: { a: 1 }, 递归处理所有乘次
this.observe(data[key])
new Observe(this, data, key)
})
}
// 挂载:1. 实例化Watcher; => 2. 将updateComponent组件更新方法传入watcher中,用于①初始化 ②组件更新; => 3. 将updateComponent组件更新方法中,reader获取vnode树, 用_update(vnode)方法转为真实dom
$mount(el) {
this.$el = document.querySelector(el)
// reader函数是用来获取vnode树的,形参h: $createElement => 将输入的参数变成vnode
const updateComponent = () => {
const {reader} = this.$options
const vnode = reader.call(this, this.$createElement)
console.log(vnode);
// 将vnode转为真实dom
this._update(vnode)
}
new Watcher(this, updateComponent)
}
// vnode -> dom
// 获取上一次更新的vnode树: ① 如果不存在,则为初始化; ② 如果存在,则为更新操作
// __patch__方法: 将虚拟节点=>dom,并更新到视图上
_update(vnode) {
// 获取上一次的vnode树
const prevVnode = this._vnode // this._vnode 在__patch__方法(即diff算法时,进行存储的)
if (!prevVnode) {
this.__patch__(this.$el, vnode)
} else {
this.__patch__(prevVnode, vnode)
}
}
// __patch__: 补丁函数: 将需要更新的节点,从vnode => dom ,打补丁到页面对应的dom上(定点更新)
// 两个参数:① oldVnode:老节点; ② newVnode:新节点 => vnode:{tag, props, children}
// ①判断oldVnode是否是真实dom,如果是真实dom,则为初始化,直接将newVnode => dom, 追加到oldVnode这个真实dom上去
// ②oldVnode是虚拟dom,则比较oldVnode、newVnode之间不同,进行diff算法增加、删除
// ②-1: 判断oldVnode、newVnode是否是同一个节点(通过比较tag、key,如果一样,则同一个节点)
// ②-2: 若是同一个节点,则进行propsOps属性更新、childrenOps子节点更新
// ②-3: 不是同一个节点,则进行节点替换
__patch__(oldVnode, newVnode) {
if (oldVnode.nodeType) {
const parent = oldVnode.parentNode
const nextNode = oldVnode.nextSibling
// vnode => dom
const el = this.createElm(newVnode)
parent.insertBefore(el, nextNode)
parent.removeChild(oldVnode)
this._vnode = newVnode
} else { // 更新
// 判断是否为同一个元素:tag相同,key相同,这里就不考虑key了
if (oldVnode.tag === newVnode.tag) {
// 获取oldVnode对应的真实dom, 用于做真实的dom操作, 并将这个真实dom,存储到新节点的el变量上,以方便下次更新是使用
const el = newVnode.el = oldVnode.el // const el = oldVnode.el;newVnode.el = el
// 属性更新
this.propsOps(el, oldVnode, newVnode)
// children更新
this.childrenOps(el, oldVnode, newVnode)
}
// 不是同一个元素
else {
// todo
// el.parentNode.replaceChild(this.createElm(newVnode), el)
const oldEl = oldVnode.el
const parentEl = oldEl.parentNode
const newEl = this.createElm(newVnode)
newVnode.el = newEl
parentEl.replaceChild(newEl, oldEl)
}
this._vnode = newVnode
}
}
// children更新
// 获取新旧节点的children:oldCh、newCh
// 判断新节点的children值,是否是字符串 => newCh是字符串 => 判断① oldCh是字符串,oldCh与newCh如果不相同,则替换,设置textContent值; => ② oldCh是数组,有节点,则el直接替换,设置textContent值;
// newCh是数组 => 如果① oldCh是字符串 => 则遍历新节点的newCh,对每一个子节点vnode,进行vnode->dom,即createElm方法,再追加到el上 => ② oldCh是数组,有子节点 => 用diff算法,更新子节点
childrenOps(el, oldVnode, newVnode) {
const oldCh = oldVnode.children
const newCh = newVnode.children
if (typeof newCh === 'string') {
if (typeof oldCh === 'string') {
if (newCh !== oldCh) {
el.textContent = newCh
}
} else {
el.textContent = newCh
}
}
// 新节点的children是数组,即:新节点有子节点
else {
if (typeof oldCh === 'string') {
el.textContent = ''
newCh.forEach(childVnode => {
const childElm = this.createElm(childVnode)
el.appendChild(childElm)
})
}
// 新旧节点都有子节点:更新子节点
else {
this.updateChildren(el, oldCh, newCh)
}
}
}
// 更新子节点
// 前提:新旧节点都有子节点
// ① 在新旧vnode树下,取最小vnode树 => 取两个数组最小长度minLen
// ② 遍历最小长度minLen,比较新旧vnode树下对应的子节点,是否相同(tag、key是否相同),相同节点则更新属性、children; 不相同,则删除旧节点,新增节点 => 就是this.__patch__(oldVnode, newVnode)==> 打补丁,直接把节点新增上去
// ③ 判断新旧vnode树,谁长?
// ④ 旧节点树长 => 则把旧vnode树截取(minLen => length)这么长,再遍历,删除el中的旧节点
// ⑤ 新节点树长 => 则把新vnode树截取(minLen => length)这么长,再遍历,新增el中的新节点
updateChildren(parentEl, oldCh, newCh) {
const minLen = Math.min(oldCh.length, newCh.length)
for (let i = 0; i < minLen; i++) {
this.__patch__(oldCh[i], newCh[i])
}
if (oldCh.length > newCh.length) {
oldCh.slice(minLen).forEach(child => {
parentEl.removeChild(this.createElm(child))
})
}
if (oldCh.length < newCh.length) {
newCh.slice(minLen).forEach(child => {
parentEl.appendChild(this.createElm(child))
})
}
}
// 节点属性操作
// 分别获取新旧节点的属性列表
// 遍历新节点属性列表 => 判断新节点属性在旧节点中是否存在 => 若不存在,则dom直接新增该属性; => 存在,看新旧节点属性值是否相同,相同就不用处理,不相同,就重新设置属性值
propsOps(el, oldVnode, newVnode) {
const oldProps = oldVnode.props || {}
const newProps = newVnode.props || {}
for (let propName in newProps) {
if (!propName in oldProps) {
el.removeAttribute(propName)
} else if (oldProps[propName] !== newProps[propName]) {
el.setAttribute(propName, newProps[propName])
}
}
}
// vnode => dom
// vnode:{tag, props, children}
createElm(vnode) {
const el = document.createElement(vnode.tag)
// 有props
if (vnode.props) {
Object.keys(vnode.props).forEach(prop => {
el.setAttribute(prop, vnode.props[prop])
})
}
// 有children
if (vnode.children) {
if (typeof vnode.children === 'string') {
el.textContent = vnode.children
} else {
vnode.children.forEach(child => {
const childNode = this.createElm(child)
el.appendChild(childNode)
})
}
}
vnode.el = el
return el
}
$createElement(tag, props, children) {
return {tag, props, children}
}
}
定义数据响应式 observer代码
/ 定义数据响应式
// 1. data中每一个属性,都定义一个Dep,用来收集依赖
// 2. 读取key的时候,收集一个依赖
// 3. 数据发生改变的时候,通知更新
class Observe {
constructor(vm, data, key) {
this.$vm = vm
this.defineReactive(data, key, data[key])
}
defineReactive(data, key, val) {
const vm = this.$vm
const dep = new Dep()
Object.defineProperty(data, key, {
get() {
if (Dep.target) {
dep.addDep(Dep.target)
}
return val
},
set(v) {
if (v !== val) {
// 考虑到用户将this.title以前为string字符串,后进行重新赋值为this.title = {name: '我是标题'}
// 重新对新值v做响应式处理
vm.observe(v)
val = v
// 通知依赖更新
dep.notice()
}
}
})
}
}
依赖收集 Dep代码
class Dep {
constructor() {
this.deps = new Set()
}
addDep(dep) {
this.deps.add(dep)
}
notice() {
this.deps.forEach(dep => {
dep.update()
})
}
}
watcher代码
// 一个组件只有一个watcher
// 参数callback: 组件发生改变时,触发的组件更新函数updateComponent
class Watcher {
constructor(vm, callback) {
this.$vm = vm
this.$cb = callback
// vue实例化时,执行更新函数:初始化
this.getter()
}
// 触发 收集依赖、执行渲染函数
getter() {
// wathcer实例赋值给Dep.target,方便data中数据get()方法中进行收集
Dep.target = this
// 执行渲染函数:
this.$cb.call(this.$vm)
console.log('vue实例化,updateComponent组件更新方法执行!')
Dep.target = null
}
update() {
// 组件更新时,执行组件渲染函数
this.getter()
}
}