前言
在上一篇文章 Vue2.x 响应式原理分析(一)咱们曾经搞清楚了数据响应式的原理,那么明天无妨就让咱们利用上次的实现来造一个简版的 Vue 吧!
创立一个 MVVM 类
// TVue.js
/**
* @desc: TVue 是一个 MVVM 类,也是咱们本人手写的简版 Vue
* @params {} options 实例创立时传入的选项
*/
class TVue {constructor (options) {
this.$options = options
this.$data = options.data
}
}
TVue 在创立的时候须要做两件事:
- 对传入的数据做响应式解决;
- 编译模版将后果渲染
class TVue {constructor (options) {
this.$options = options
this.$data = options.data
// 1. 响应式实现
observe(this.$data)
// 2. 编译
if (options.el) {this.$mount(options.el)
}
}
}
⚠️咱们平时在应用 data 属性值的时候为什么能够间接通过 this.xxx 拜访,而无需通过 this.data.xxx 来拜访呢?
这是因为 Vue 源码里做了代理,将 vm 实例的 data 属性值间接代理到了 vm 实例上
这里咱们也能够学习源码来实现一个代理办法
function proxy (vm) {Object.keys(vm.$data).forEach(key=>{
Object.defineProperty(vm, key, {get() {return vm.$data[key]
},
set(v) {vm.$data[key] = v
}
})
})
}
// 此时 咱们的 TVue 应该如下:class TVue {constructor (options) {
this.$options = options
this.$data = options.data
// 1. 响应式实现
observe(this.$data)
// 1.5 为 $data 做代理
proxy(this)
// 2. 编译
if (options.el) {this.$mount(options.el)
}
}
}
observe 办法实现
通过上篇文章的分享,Vue2.x 中利用了 JS 语言个性 Object.defineProperty(),通过定义对象属性 getter/setter 拦挡对属性的拜访,上面让咱们来回顾下 observe 办法的创立和性能吧
function observe(obj) {if (typeof obj !== 'object' || typeof === null) {
// 如果传入的数据不是对象或者为 null 不做操作,间接返回
return
}
// 只有是对象,就创立一个伴生的 Observer 实例
new Observer(obj)
}
// Observer 对象依据数据类型执行对应的响应化操作
// defineReactive 定义对象属性的 getter/setter
// getter 负责增加依赖,setter 负责告诉更新
class Observer {constructor(options) {if (Array.isArray(obj)) {// todo: 数组有非凡解决,可在源码 Array.js 查阅 这次咱们先不做数组响应式的解决} else {this.walk(options)
}
}
walk(obj) {
// 遍历对象的所有属性并对其做响应式解决
Object.keys(obj).forEach(key => {defineReactive(obj, key, obj[key]
})
}
}
function defineReactive (obj, key, val) {
// 向下递归遍历
observe(val)
Object.defineProperty(obj, key, {get () {
// todo: 依赖收集
return val
},
set(newVal) {if (newVal !== val) {
val = newVal
// 解决赋的值是对象的状况(譬如 test.foo={f1: 666})
observe(val)
// todo: 派发订阅
}
}
})
}
Watcher 和 Dep 的创立
1. Watcher
- 依赖收集后保留在 deps 里
- 变动的时候 deps 作为发布者告诉 watcher
- watcher 进行回调渲染
class Watch {
// expOrFn: 创立 Watcher 实例时传入的渲染函数
constructor (vm, expOrFn){
this.vm = vm
this.getter = expOrFn
// 触发依赖收集
this.get()}
get() {
Dep.target = this
this.getter.call(this.vm)
Dep.target = null
}
update() {
// Dep 将来回告诉更新
this.get()}
}
2. Dep
- 发布者,能够订阅多个观察者
- 收集依赖后会有一个或者多个 watcher
- 一旦有变动便告诉所有 watcher
class Dep {
// 依赖:和响应式对象的 key 一一对应
constructor (){
// 避免反复创立
this.deps = new Set()}
addDep (watcher) {this.deps.add(watcher)
}
notify() {this.deps.forEach(watcher => watcher.update())
}
}
3. 关系
- Dep 负责管理一组 Watcher,包含 watcher 实例的增删及告诉更新
- Watcher 解析一个表达式并收集依赖,当数值变动时触发回调函数,罕用于 $watch API 和指令中。每个组件也会有对应的 Watcher,数值变动会触发其 update 函数导致从新渲染
4. 革新 defineReactive 办法 — 创立 Dep 实例,收集依赖 & 派发订阅
function defineReactive (obj, key, val) {
// 如果 val 是对象,须要递归解决之
observe(val)
// 创立 Dep 实例
const dep = new Dep()
Object.defineProperty(obj, key, {get () {
// 取值触发依赖收集
Dep.target && dep.addDep(Dep.target)
return val
},
set(newVal) {if (newVal !== val) {
val = newVal
// 如果 newVal 是对象,也要做响应式解决
observe(val)
// 告诉更新
dep.notify()}
}
})
}
编译模版,$mount 实现
1. $mount 创立
function $mount (el){this.$el = document.createElement(el)
// 定义更新函数 理论调用是在 lifeCycleMixin 中定义的_update 和 renderMixin 中定义的_render
const updateComponent = ()=> {const { render} = this.$options
// 执行渲染函数,获取 vnode
const vnode = render.call(this, this.$createElement)
this._update(vnode)
}
// 创立一个 watcher 实例
new Watcher(this, updateComponent)
}
2. $createElement 和 _update 实现
- $createElement 只做一件事:返回虚构 dom($createElement 理论就是传递给 render 函数的 h)
function $createElement(tag, props, children) {return { tag, props, children}
}
- _update 函数 负责更新 dom,转换 vnode 为 dom
function _update(vnode) {
const prevVnode = this._vnode
if(!prevVnode) {
// 初始化
this.__patch__(this.$el, vnode)
} else {
// 更新
this.__patch__(prevVnode, vnode)
}
}
3. patch
patch 是 createPatchFunction 的返回值,传递 nodeOps 和 modules 是 web 平台特地实现
patch 实现
首先进行树级别比拟,可能有三种状况: 增删改。
- new VNode 不存在就删;
- old VNode 不存在就增;
-
都存在就执行 diff 执行更新
比拟两个 VNode,包含三种类型操作: 属性更新、文本更新、子节点更新 具体规定如下:
- 新老节点 均有 children 子节点,则对子节点进行 diff 操作,调用 **updateChildren
- 如果 新节点有子节点而老节点没有子节点 **,先清空老节点的文本内容,而后为其新增子节点。
- 当 新节点没有子节点而老节点有子节点 的时候,则移除该节点的所有子节点。
- 当 新老节点都无子节点 的时候,只是文本的替换。
function __patch__(oldVnode, Vnode) {
// oldVnode 是 dom
if (oldVnode.nodeType) {
const parent = oldVnode.parentElement
const refElm = oldVnode.nextSibling
// 将虚构 dom 转换成实在 dom,并插入文档中
const el = this.createElm(vnode)
parent.insertBefore(el, refElm)
// 删除老的节点
parent.removeChild(oldVnode)
} else {
// 获取 dom
const el = vnode.el = oldVnode.el
// 新老节点是标签雷同 则比拟子节点
if (oldVnode.tag === vnode.tag) {
const oldCh = oldVnode.children
const newCh = vnode.children
/**
* 新旧节点 diff 情景
* 1. 新老节点都是 string(文本更新)* 2. 新老节点都是数组(首尾 diff)* 3. 新节点为数组,老节点为 string(递归创立 dom 树)* 4. 新节点是 string, 老节点是数组(间接将新节点赋值给老节点)*/
if (typeof newCh === 'string') {
// 新的为 string
if (typeof oldCh === 'string') {
// 新老都是 string
if (newCh !== oldCh) {el.textContent = newCh}
} else {
// 新的是 string 老的不是 间接对 dom 做文本更新操作
el.textContent = newCh
}
} else {
// 新的为数组
// 1. 新的是数组,老的为文本(阐明新增了子元素,须要递归创立新的 dom 树)
if (typeof oldCh === 'string') {
oldCh.innerHTML = ''
newCh.forEach(vnode => this.createElm(vnode))
} else {// 2. 新老节点都是数组(源码是做首位 diff 优化算法)
this.updateChildren(el, oldCh, newCh)
}
}
} else {// 不是同一标签 临时不思考}
}
// 保留以后 vnode
this._vnode = vnode
}
4. updateChildren 比对新旧两个 VNode 的 children 得出最小操作
- 执行一个双循环是传统形式,vue 中针对 web 场景特点做了特地的算法优化
- 在新老两组 VNode 节点的左右头尾两侧都有一个变量标记,在 遍历过程中这几个变量都会向两头聚拢 。当oldStartIdx > oldEndIdx 或者 newStartIdx > newEndIdx 时完结循环
-
上面是遍历规定:
- 当 oldStartVnode 和 newStartVnode 或者 oldEndVnode 和 newEndVnode 满足 sameVnode,间接将该 VNode 节点进行 patchVnode 即可,不需再遍历就实现了一次循环
- 如果 oldStartVnode 与 newEndVnode 满足 sameVnode。阐明 oldStartVnode 曾经跑到了 oldEndVnode 前面去了,进行 patchVnode 的同时还须要将实在 DOM 节点挪动到 oldEndVnode 的前面
- 如果 oldEndVnode 与 newStartVnode 满足 sameVnode,阐明 oldEndVnode 跑到了 oldStartVnode 的前 面,进行 patchVnode 的同时要将 oldEndVnode 对应 DOM 挪动到 oldStartVnode 对应 DOM 的后面。
- 如果以上状况均不合乎,则在 old VNode 中找与 newStartVnode 雷同的节点,若存在执行 patchVnode,同时将 elmToMove 挪动到 oldStartIdx 对应的 DOM 的后面。
- 当然也有可能 newStartVnode 在 old VNode 节点中找不到统一的 sameVnode,这个时候会调用 createElm 创立一个新的 DOM 节点。
-
至此循环完结,然而咱们还须要解决剩下的节点。
- 当完结时 oldStartIdx > oldEndIdx,这个时候旧的 VNode 节点曾经遍历完了,然而新的节点还没有。说 明了新的 VNode 节点实际上比老的 VNode 节点多,须要将剩下的 VNode 对应的 DOM 插入到实在 DOM 中,此时调用 addVnodes(批量调用 createElm 接口)。
- 然而,当完结时 newStartIdx > newEndIdx 时,阐明新的 VNode 节点曾经遍历完了,然而老的节点还有 残余,须要从文档中删 的节点删除
⚠️原算法比较复杂,能够间接去源码查阅,以下咱们能够实现一个没有通过优化的硬更新操作
// 更新孩子
updateChildren (parentElm, odlCh, newCh) {const len = Math.min(oldCh.length, newCh.length)
// 遍历较短的子数组
for(let i =0; i<len; i++) {this.__patch__(oldCh[i], newCh[i])
}
// newCh 若是更长的那个,新增
if(newCh.length > oldCh.length) {newCh.slice(len).forEach(vnode=>{const el = this.createElm(vnode)
parentElm.appendChild(el)
})
} else if(newCh.length < old.length){parentElm.removeChild(vnode.el)
}
}
5. createElm 递归创立 dom 树
createElm(vnode) {const el = document.createElement(vnode.tag)
// 解决 props
if(vnode.props) {for(const key in vnode.props) {el.setAttribute(key, vnode.props[key])
}
}
// 解决 children
if (vnode.children) {
// 解决文本
if(typeof vnode.children === 'string') {el.textContent = vnode.children} else {
// 子元素解决
vnode.children.forEach(vnode=>{const child = this.createElm(vnode)
el.appendChild(child)
})
}
vnode.el = el
return el
}
}
6. 残缺版本的 TVue 源码
function defineReactive(obj, key, val) {
// ! 向下递归遍历
observe(val)
// 创立 Dep 实例
const dep = new Dep()
Object.defineProperty(obj, key, {get() {console.log(`get ${key}: ${val}`)
Dep.target && dep.addDep(Dep.target)
return val
},
set(newVal) {if (newVal !== val) {console.log(`set ${key}: ${newVal}`)
val = newVal
//! 解决赋的值是对象的状况(譬如 test.foo={f1: 666})
observe(val)
dep.notify()}
}
})
}
function observe(obj) {if (typeof obj !== 'object' || obj === null) {return}
// * 只有 obj 是对象,就创立一个伴生的 Observer 实例
new Observer(obj)
}
function proxy(vm) {Object.keys(vm.$data).forEach(key => {
Object.defineProperty(vm, key, {get() {return vm.$data[key]
},
set(v) {vm.$data[key] = v
}
})
})
}
class Observer {constructor(options) {if (Array.isArray(options)) {// todo 数组有非凡解决} else {this.walk(options)
}
}
walk(obj) {Object.keys(obj).forEach(key => {defineReactive(obj, key, obj[key])
})
}
}
class TVue {constructor(options) {
this.$options = options
this.$data = options.data
// ! 1. 数据响应式
observe(this.$data)
// ! 1.5 代理 将 data 中的所有属性代理到 JVue 实例上不便用户应用
proxy(this)
// ! 2. 编译
// new Compile(options.el, this)
if (options.el) {this.$mount(options.el)
}
}
$mount (el) {
// 获取宿主元素
this.$el = document.querySelector(el)
const updateComponent = () => {
// 执行渲染函数
const {render} = this.$options;
// 实在 dom 操作版实现
// const el = render.call(this);
// const parent = this.$el.parentElement;
// parent.insertBefore(el, this.$el.nextSibling);
// parent.removeChild(this.$el);
// this.$el = el;
// vnode 版本实现
const vnode = render.call(this, this.$createElement)
this._update(vnode)
}
// 创立一个 Watcher 实例
new Watcher(this, updateComponent)
}
$createElement (tag, props, children) {
return {
tag,
props,
children
}
}
_update (vnode) {
const prevVnode = this._vnode
if (!prevVnode) {this.__patch__(this.$el, vnode)
} else {this.__patch__(prevVnode, vnode)
}
}
__patch__ (oldVnode, vnode) {
// oldVnode 是 dom
if (oldVnode.nodeType) {
const parent = oldVnode.parentElement
const refElm = oldVnode.nextSibling
// props
// children
const el = this.createElm(vnode)
parent.insertBefore(el, refElm)
parent.removeChild(oldVnode)
} else {
// update
// 获取 dom
const el = vnode.el = oldVnode.el
if (oldVnode.tag === vnode.tag) {
const oldCh = oldVnode.children
const newCh = vnode.children
/**
* 新旧节点 diff
* 1. 新老节点都是 string(文本更新)* 2. 新老节点都是数组(首尾 diff)* 3. 新节点为数组,老节点为 string(递归创立 dom 树)* 4. 新节点是 string, 老节点是数组(间接将新节点赋值给老节点)*/
if (typeof newCh === 'string') {if(typeof oldCh === 'string') {
// 新旧节点都是 string 且值不同 间接更新
if(newCh !== oldCh) {el.textContent = newCh}
} else {el.textContent = newCh}
} else {// 1. 新的是数组,老的为文本(阐明新增了子元素,须要递归创立新的 dom 树)
if (typeof oldCh === 'string') {
// 清空文本
oldCh.innerHTML = ''
newCh.forEach(vnode => this.createElm(vnode))
} else {
// 2. 新老节点都是数组
this.updateChildren(el, oldCh, newCh)
}
}
}
}
this._vnode = vnode
}
// 递归创立 dom 树
createElm (vnode) {const el = document.createElement(vnode.tag)
// 解决 props
if (vnode.props) {for (const key in vnode.porps) {el.setAttribute(key, vnode.props[key])
}
}
// 解决 children
if (vnode.children) {
// 解决文本
if (typeof vnode.children === 'string') {el.textContent = vnode.children} else {
// 子元素
vnode.children.forEach(vnode => {const child = this.createElm(vnode)
el.appendChild(child)
})
}
}
// vnode 中保留 dom
vnode.el = el
return el
}
// 更新孩子
updateChildren(parentElm, oldCh, newCh) {const len = Math.min(oldCh.length, newCh.length)
// 遍历较短的那个子数组
for (let i = 0; i < len; i++) {this.__patch__(oldCh[i], newCh[i])
}
// newCh 若是更长的那个,新增
if (newCh.length > oldCh.length) {newCh.slice(len).forEach(vnode => {const el = this.createElm(vnode)
parentElm.appendChild(el)
})
} else if(newCh.length < oldCh.length){oldCh.slice(len).forEach(vnode => {parentElm.removeChild(vnode.el)
})
}
}
}
// 负责视图更新,与依赖一一对应
class Watcher {constructor(vm, expOrFn) {
this.vm = vm;
this.getter = expOrFn;
// 触发依赖收集
this.get()}
get () {
Dep.target = this;
this.getter.call(this.vm)
Dep.target = null
}
// Dep 将来会告诉更新
update() {this.get()
}
}
// 依赖:和响应式对象的 key 一一对应
class Dep {constructor() {this.deps = new Set();
}
addDep(wather) {this.deps.add(wather)
}
notify() {this.deps.forEach(wather => wather.update())
}
}
总结
以上咱们曾经发明了一个简略版本的 TVue,实现了数据响应和异步批量更新 dom 根底性能。
当然 Vue2.x 的弱小之处远远不止于此,剩下的就留给大家去源码中去找答案吧!
以下是 Vue 中几个重要概念的简略介绍,兴许能够帮忙大家更好的了解这篇文章的总体思路
- Observer 是用来给数据增加 Dep 依赖。
- Dep 是 data 每个对象包含子对象都领有一个该对象, 当所绑定的数据有变更时, 通过 dep.notify()告诉 Watcher。
- Compile 是 HTML 指令解析器,对每个元素节点的指令进行扫描和解析,依据指令模板替换数据,以及绑定相应的更新函数。
- Watcher 是连贯 Observer 和 Compile 的桥梁,Compile 解析指令时会创立一个对应的 Watcher 并绑定 update 办法 , 增加到 Dep 对象上。
扩大
Vue 源码的学习小技巧
- 获取 Vue 源码
我的项目地址: https://github.com/vuejs/vue
-
调试环境搭建
- 装置依赖: npm i 装置 phantom.js 时即可终止
- 装置 rollup: npm i -g rollup 批改 dev 脚本,增加 sourcemap,package.json
- 运行开发命令: npm run dev 引入后面创立的 vue.js
"dev": "rollup -w -c scripts/config.js --sourcemap --environment TARGET:web- full-dev"
-
术语解释:
- runtime: 仅蕴含运行时,不蕴含编译器
- common:cjs 标准,用于 webpack1
- esm:ES 模块,用于 webpack2+
- umd: universal module definition,兼容 cjs 和 amd,用于浏