手写vue2 - 实现$mount和简单diff算法
前言
在我们之前的《手写vue - 实现数据响应式、数据双向绑定和事件监听》中,有些许不足:
- 直接模板 => dom,跳过了虚拟dom的生成和相关操作。
- 由于没有VNode,每当data中的数据发生变化时,都会进行实时的更新,增加了程序的负担。
- 每个key对应一个Watcher,当其中一个值发生变化时,都会遍历执行更新方法,在Vue2是每个组件实例对应一个Watcher,利用VNode和diff算法减少更新的次数,且是批量异步更新。
我们在拜读Vue2源码之后,参考源码的编程设计思路,对我们之前编写的案例进行改进,主要是以下几个方面的改进:
- 一个组件只有一个Watcher,从而减少更新方法的触发次数,降低性能消耗;
- 增加Vnode的概念,利用我们的简单diff算法,不直接对模板中的真实dom进行操作。
一、明确思路
1. Vue2的设计思路
在Vue2中,组件实例的创建挂载到渲染挂载的执行顺序是:
- $mount()挂载,其中会创建一个Watcher,也就是一个组件实例对应一个Watcher;
- 定义updateComponent()组件更新方法,将其保存到Watcher中;
- 当视图需要更新时,updateComponent()调用reader()获取vndoe,执行_update()将vnode转化为真实dom;
- _update()中调用__patch__(),也就采用diff算法。
关于Vue2源码相关内容,感兴趣的同学可以看下我之前写的《vue2源码解析(一) - new Vue()的初始化过程》,可能会对我们这次“造轮子”有所帮助。
2. 改造思路
- 增加$mount(),updateComponent(),_update()等方法;
- 定义diff算法的相关方法__patch__()等。
- 改造Watcher类和Dep类,即两者关系为1 : n;
- 我们案例中去除了之前的Compile编译器,所以没有指令和事件监听相关内容,感兴趣的可以对应加上。
二、手写Vue2
1. 定义数据响应式和通知依赖更新
这部分代码与我们之前基本保持一致。
// 定义响应式数据的方法
function defineReactive(obj, key, val) {
// 递归,将对象进行深层次响应式处理
// 如obj = { foo: 'foo', bar: { a: 1 } }
observe(val)
// 为每个key创建Dep实例
const dep = new Dep()
Object.defineProperty(obj, key, {
get() {
// 依赖收集,Dep.target为Watcher对象
Dep.target && dep.addDep(Dep.target)
return val
},
set(newVal) {
if (newVal != val) {
// 考虑到用户可能对对象进行 obj.bar = { b: 2 } 的操作
// 重新对新值newVal做响应式处理
observe(newVal)
val = newVal
// 更新依赖
dep.notify()
}
}
})
}
// 将普通对象转化为响应式对象的方法
function observe(obj) {
// 判断对象类型,若对象类型不是object或对象值为null,则不跳出
// 这里我暂不考虑对象类型为Array时的情况
if (typeof obj !== 'object' || obj === null) {
return
}
new Observer(obj)
}
// 用户对原对象追加新属性,对新属性做响应式处理的方法
// 仿Vue.$set()方法
function set(obj, key, val) {
defineReactive(obj, key, val)
}
// 代理,作用:使用户能直接通过vm实例访问到data里的数据,即this.xxx。否则需this.$data.xxx
function proxy(vm) {
Object.keys(vm.$data).forEach(key => {
Object.defineProperty(vm, key, {
get() {
return vm.$data[key]
},
set(newVal) {
vm.$data[key] = newVal
}
})
})
}
// Observer类,对传入的value值做响应式处理
class Observer {
constructor(value) {
this.value = value
if (Array.isArray(value)) {
// todo
} else {
this.walk(value)
}
}
walk(obj) {
// 遍历对象的属性,做响应式处理
// 如obj = { foo: 'foo', bar: { a: 1 } }
Object.keys(obj).forEach(key => {
defineReactive(obj, key, obj[key])
})
}
}
2. JVue类和更新渲染
我们改造的重点就是这一部分。主要是下面几点:
- 定义$mount()挂载方法,创建Watcher保存组件渲染更新方法;
- 编写我们简版的diff算法和相关dom操作方法。
// jvue类
class JVue {
constructor(options) {
this.$options = options
this.$data = options.data
// $data做响应式处理
observe(this.$data)
// 代理。作用:使用户能直接通过vm实例访问到data里的数据,即this.xxx。否则需this.$data.xxx
proxy(this)
if (options.el) {
this.$mount(options.el)
}
}
// $mount
$mount(el) {
// 获取根节点元素
this.$el = document.querySelector(el)
// 组件的渲染函数
const updateComponent = () => {
// 从选项options中获取reader函数
const { reader } = this.$options
// reader()方法的作用就是获取虚拟dom,$createElement方法就是reader()中参数h
const vnode = reader.call(this, this.$createElement)
// 把vnode转化成真实dom
this._update(vnode)
}
// 为每个组件实例创建一个Watcher
new Watcher(this, updateComponent)
}
// 这个就是我们reader()中的参数h。
// 参数:tag标签;props标签属性,可为空;children子节点,也可能是文本标签。
// 注意:元素的childrenNodes与text互斥,即文本标签不可能存在子节点
$createElement(tag, props, children) {
return { tag, props, children }
}
// 根据判断更新节点信息
_update(vnode) {
// 获取上一次的vnode树
const prevVnode = this._vnode
// 若老节点树不存在,则初始化,否则更新
if(!prevVnode) {
// 初始化
this.__patch__(this.$el, vnode)
} else {
// 更新
this.__patch__(prevVnode, vnode)
}
}
__patch__(oldVnode, vnode) {
// 判断oldVnode是否为真实dom
// 是则将vnode转化为真实dom,添加到根节点
// 否则遍历判断新老两棵vnode树,做增删改操作
if(oldVnode.nodeType) { // 真实dom,初始化操作
// 获取根元素的父节点,即body
const parent = oldVnode.parentNode
// 获取根元素的下个节点
const refElm = oldVnode.nextSibling
// 递归创建子节点
const el = this.createElm(vnode)
// 在body下,根节点旁插入el
parent.insertBefore(el, refElm)
// 删除之前的根节点
parent.removeChild(oldVnode)
// 保存vdone,用于下次更新判断
this._vnode = vnode
} else { // 更新操作
// 获取vnode对应的真实dom,用于做真实dom操作
const el = vnode.el = oldVnode.el
// 判断是否为同一个元素
if(oldVnode.tag === vnode.tag) {
// props属性更新
this.propsOps(el, oldVnode, vnode)
// children更新
// 获取新老节点的children
const oldCh = oldVnode.children
const newCh = vnode.children
// 若新节点为文本
if(typeof newCh === 'string') {
// 若老节点也为文本
if(typeof oldCh === 'string') {
// 若新老节点文本内容不一致,则文本内容替换为新文本内容
if(newCh !== oldCh) {
el.textContent = newCh
}
} else { // 若老节点有子节点,则情况后设置文本内容
el.textContent = newCh
}
} else { // 若新节点有子节点
// 若老节点无子节点,为文本,则清空文本后创建并新增子节点
if(typeof oldCh === 'string') {
el.textContent = ''
newCh.forEach(children => this.createElm(children))
} else { // 若老节点也有子节点,则检查更新
this.updateChildren(el, oldCh, newCh)
}
}
}
}
}
// 创建节点元素
createElm(vnode) {
// 创建一个真实dom
const el = document.createElement(vnode.tag)
// 若存在props属性,则处理
if(vnode.props) {
// 遍历设置元素attribute属性
for (const key in vnode.props) {
el.setAttribute(key, vnode.props[key])
}
}
// 若存在chilren,则处理
if(vnode.children) {
// 判断children类型
if(typeof vnode.children === 'string') {
// 该节点为文本
el.textContent = vnode.children
} else {
// 该节点有子节点
// 递归遍历创建子节点,追加到元素下
vnode.children.forEach(v => {
const child = this.createElm(v)
el.appendChild(child)
})
}
}
// 保存真实dom,用于diff算法做真实dom操作
vnode.el = el
return el
}
// 节点的props属性操作方法
propsOps(el, oldVnode, newVnode) {
// 获取新老节点的属性列表
const oldProps = oldVnode.props || {}
const newProps = newVnode.props || {}
// 遍历新属性列表
for (const key in newProps) {
// 若老节点中不存在新节点的属性,则删除该属性
if (!(key in oldProps)) {
el.removeAttribute(key)
} else {
// 否则更新属性内容
const oldValue = oldProps[key]
const newValue = newProps[key]
if(oldValue !== newValue) {
el.setAttribute(key, newValue)
}
}
}
}
// 更新子节点
updateChildren(parentElm, oldCh, newCh) {
// 获取新老子节点树的最小长度
const len = Math.min(oldCh.length, newCh.length)
// 根据最小长度len遍历做节点更新
for (let i = 0; i < len; i++) {
this.__patch__(oldCh[i], newCh[i])
}
// 判断新老节点树的长度,做新增或删除操作
// 若老节点树长度大于新新节点树长度,则删除多余节点,反之则新增节点
if(oldCh.length > newCh.length) {
oldCh.slice(len).forEach(child => {
const el = this.createElm(child)
parentElm.removeChild(this.createElm(child))
})
} else if(newCh.length > oldCh.length) {
newCh.slice(len).forEach(child => {
const el = this.createElm(child)
parentElm.appendChild(el)
})
}
}
}
3. Watcher类收集依赖
仿源码的编程思路,将传入的fn(即updateComponent())保存到getter中,由get()方法触发依赖收集和执行更新渲染函数。
/ watcher类
// 监听器类。负责依赖更新
class Watcher {
// vm: vue实例
// fn: vm组件实例对应的渲染更新方法
constructor(vm, fn) {
this.vm = vm
this.getter = fn
// 触发依赖收集和执行渲染函数
this.get()
}
// 触发依赖收集和执行渲染函数
get() {
// 触发依赖收集
Dep.target = this
// 执行渲染函数
this.getter.call(this.vm)
Dep.target = null
}
update() {
this.get()
}
}
4. Dep类依赖管理和通知更新
这一部分也没有太大的变化,只是将Deps的类型由Array改为了Set。使Dep和Watcher的关系变为N : 1。
// dep类
// 依赖收集,统一通知执行依赖中的各个更新函数
class Dep {
// 为Vue.$data中的每个key创建一个依赖数组
constructor() {
this.deps = new Set()
}
// 为Vue.$data中的每个key依赖数组deps追加对应的watcher
// 追加时机:响应式对象的get()方法中,即在对Vue.$date做响应式处理时的defineReactive方法的get()中收集依赖
addDep(watcher) {
this.deps.add(watcher)
}
// 当响应式数据更新时,进行统一通知更新依赖
notify() {
this.deps.forEach(watcher => watcher.update())
}
}
5. 测试案例
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
<div id="app">
</div>
<script src="jvue.js"></script>
<script>
const app = new JVue({
el: "#app",
data: {
counter: 1,
desc: '<span style="color:red">哈哈哈哈</span>',
text: ''
},
reader(h) {
return h('div', {id: '#app'}, [
h('p', null, 'first blood ' + this.counter)
])
},
methods: {
onClick() {
alert('冬瓜冬瓜我是西瓜')
}
}
});
setInterval(() => {
app.counter++;
}, 1000);
</script>
三、总结
手写Vue2后,对于Vue2的源码总结
1. Vue的挂载渲染流程
$mount() => updateComponent() => reader() => _update() => __patch__()
2. Watcher
- Watcher的创建发生在$mount()
- 每个组件对应一个reader Watcher
3. reader()和__patch__()
- diff算法发生在__patch__()中
- reader()的作用是生成vnod
- __patch__()的作用是将vnode转化为真实dom