03Vue源码实现
Vue 源码实现
理解 Vue 的设计思想
- MVVM 模式
MVVM 框架的三要素:数据响应式、模板引擎和渲染
数据响应式:监听数据变化并在视图中更新
-
Object.defineProperty()
-
Proxy
模板引擎:提供描述视图的模板语法
-
插值:{{}}
-
指令:v-bind/v-on/v-model/v-for/v-if
渲染:如何将模板转换为 html
- 模板=>vdom=>dom
数据响应式原理
数据变更能够响应在视图中,就是数据响应式,Vue2 中利用 Object.defineProperty() 实现变更检测。
image.png简单实现
const obj = {}
function defineReactive (obj, key, val) {
Object.defineProperty(obj, key, {
get () {
console.log(`get ${key}:${val}`)
return val
},
set (newVal) {
if (newVal !== val) {
console.log(`set ${key}:${newVal}`)
val = newVal
}
}
})
}
defineReactive(obj, 'foo', 'foo')
obj.foo = 'test foo'
结合视图
<!DOCTYPE html>
<html lang="en">
<head></head>
<body>
<div id="app"></div>
<script>
const obj = {}
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
get() {
console.log(`get ${key}:${val}`);
return val
},
set(newVal) {
if (newVal !== val) {
val = newVal
update()
}
}
})
}
defineReactive(obj, 'foo', '')
obj.foo = new Date().toLocaleTimeString()
function update() {
app.innerText = obj.foo
}
setInterval(() => {
obj.foo = new Date().toLocaleTimeString()
}, 1000);
</script>
</body>
</html>
遍历需要响应化的对象
// 对象响应化:遍历每个key,定义getter、setter
function observe (obj) {
if (typeof obj !== 'object' || obj == null) {
return
}
Object.keys(obj).forEach(key => {
defineReactive(obj, key, obj[key])
})
}
const obj = { foo: 'foo', bar: 'bar', baz: { a: 1 } }
observe(obj)
obj.foo = 'foooooooooooo'
obj.bar = 'barrrrrrrrrrr'
obj.baz.a = 10 // 嵌套对象no ok
当有嵌套的对象怎么办呢?
解决方法:
function defineReactive (obj, key, val) {
observe(val) // 递归 observer 方法处理嵌套对象
Object.defineProperty(obj, key, {
//...
})
}
解决赋的值是对象的情况
obj.baz = {a:1}
obj.baz.a = 10 // no ok
set(newVal) {
if (newVal !== val) {
observe(newVal) // 新值是对象的情况
notifyUpdate()
如果添加、删除了新属性无法检测
obj.dong='林慕'
obj.dong // 并没有 get 信息
测试
set(obj,'dong','林慕’)
obj.dong
写到现在,大家应该也发现了,Object.defineProperty() 不支持检测对象,如果修改对象的话需要 Vue.set 会有一些边界判断条件,当确定是对象时,执行 defineReactive 方法,将对象进行响应式绑定。
思考:Vue 数组的响应化是如何处理的呢?
Vue 在处理数组时将可以改变数组的7个方法进行了重写,分别是 push、pop、shift、unshift、splice、sort 和 reverse。
重写数组的实现方式如下:
const arrayProto = Array.prototype
// 先克隆一份数组原型
export const arrayMethods = Object.create(arrayProto)
// 七个改变数组的方法
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
// 拦截变化方法并发出事件
methodsToPatch.forEach(function (method) {
// 缓存原方法
const original = arrayProto[method]
def(arrayMethods, method, function mutator (...args) {
// 执行原始方法
const result = original.apply(this, args)
// 额外通知变更,只有这7个方法有这个待遇
const ob = this.__ob__
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
// 对新加入对象进行响应化处理
if (inserted) ob.observerArray(inserted)
// 通知改变
ob.dep.notify()
return result
})
})
【注】:最后面的总结部分贴出的源码,未包含数组的响应式处理,如需添加,可查看数组的响应式处理有何特殊之处
Vue 中的数据响应化
目标代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<div id="app">
<p>{{counter}}</p>
</div>
<script src="node_modules/vue/dist/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
counter: 1
},
})
setInterval(() => {
app.counter++
}, 1000);
</script>
</body>
</html>
Vue 原理分析
初始化流程
image.png-
创建 Vue 实例对象 init 过程会初始化生命周期,初始化事件中心,初始化渲染、执行 beforeCreate 周期函数、初始化 data、props、computed、watcher、执行 created 周期函数等。
-
初始化后,调用 $mount 方法对 Vue 实例进行挂载(挂载的核心过程包括模板编译、渲染以及更新三个过程)。
-
如果没有在 Vue 实例上定义 render 方法而是定义了 template,那么需要经历编译阶段。需要先将 template 字符串编译成 render function,template 字符串编译步骤如下 :
-
parse 正则解析 template 字符串形成 AST(抽象语法树,是源代码的抽象语法结构的树状表现形式)
-
optimize 标记静态节点跳过 diff 算法(diff 算法是逐层进行比对,只有同层级的节点进行比对,因此时间的复杂度只有 O(n)。
-
generate 将 AST 转化成 render function 字符串
-
-
编译成 render function 后,调用 $mount 的 mountComponent 方法,先执行 beforeMount 钩子函数,然后核心是实例化一个渲染 Watcher,在它的回调函数(初始化的时候执行,以及组件实例中监测到数据发生变化时执行)中调用 updateComponent 方法(此方法调用 render 方法生成虚拟 Node,最终调用 update 方法更新 DOM)。
-
调用 render 方法将 render function 渲染成虚拟的 Node(真正的 DOM 元素是非常庞大的,因为浏览器的标准就把 DOM 设计的非常复杂。如果频繁的去做 DOM 更新,会产生一定的性能问题,而 Virtual DOM 就是用一个原生的 JavaScript 对象去描述一个 DOM 节点,所以它比创建一个 DOM 的代价要小很多,而且修改属性也很轻松,还可以做到跨平台兼容),render 方法的第一个参数是 createElement (或者说是 h 函数),这个在官方文档也有说明。
-
生成虚拟 DOM 树后,需要将虚拟 DOM 树转化成真实的 DOM 节点,此时需要调用 update 方法,update 方法又会调用 pacth 方法把虚拟 DOM 转换成真正的 DOM 节点。需要注意在图中忽略了新建真实 DOM 的情况(如果没有旧的虚拟 Node,那么可以直接通过 createElm 创建真实 DOM 节点),这里重点分析在已有虚拟Node的情况下,会通过 sameVnode 判断当前需要更新的 Node 节点是否和旧的 Node 节点相同(例如我们设置的 key 属性发生了变化,那么节点显然不同),如果节点不同那么将旧节点采用新节点替换即可,如果相同且存在子节点,需要调用 patchVNode 方法执行 diff 算法更新DOM,从而提升DOM操作的性能。
响应式流程
image.png-
new Vue() 首先先执行初始化,对 data 执行响应化处理,这个过程发生在 Observer 中
-
同时对模板执行编译,找到其中动态绑定的数据,从 data 中获取并初始化视图,这个过程发生在 Compile 中
-
同时定义⼀个更新函数和 Watcher,将来对应数据变化时 Watcher 会调用更新函数
-
由于 data 的某个 key 在⼀个视图中可能出现多次,所以每个 key 都需要⼀个管家 Dep 来管理多个 Watcher
-
将来 data 中数据⼀旦发生变化,会首先找到对应的 Dep,通知所有 Watcher 执行更新函数
涉及类型介绍
-
KVue:框架构造函数
-
Observer:执行数据响应化(分辨数据是对象还是数组)
-
Compile:编译模板,初始化视图,收集依赖(更新函数、watcher 创建)
-
Watcher:执行更新函数(更新dom)
-
Dep:管理多个 Watcher,批量更新
KVue
框架构造函数:执行初始化
- 执行初始化,对 data 执行响应化处理,kvue.js
function observe (obj) {
if (typeof obj !== 'object' || obj === null) {
return
}
new Observer(obj)
}
function defineReactive (obj, key, val) { }
class KVue {
constructor(options) {
this.$options = options
this.$data = options.data
observe(this.$data)
}
}
class Observer {
constructor(value) {
this.value = value
this.walk(value)
}
walk (obj) {
Object.keys(obj).forEach(key => {
defineReactive(obj, key, obj[key])
})
}
}
- 为 $data 做代理
class KVue {
constructor(options) {
// ...
proxy(this, '$data')
}
}
function proxy (vm) {
Object.keys(vm.$data).forEach(key => {
Object.defineProperty(vm, key, {
get () {
return vm.$data[key]
},
set (newVal) {
vm.$data[key] = newVal
}
})
})
}
编译 —— Compile
image.png初始化视图
根据节点类型编译,compile.js
class Compile {
constructor(el, vm) {
this.$vm = vm
this.$el = document.querySelector(el)
if (this.$el) {
// 编译模板
this.compile(this.$el)
}
}
compile (el) {
// 递归遍历el
const childNodes = el.childNodes
Array.from(childNodes).forEach(node => {
// 判断其类型
if (this.isElement(node)) {
console.log('编译元素:', node.nodeName)
} else if (this.isInterpolation(node)) {
console.log('编译插值文本:', node.textContent)
}
if (node.childNodes && node.childNodes.length > 0) {
this.compile(node)
}
})
}
// 元素
isElement (node) {
return node.nodeType === 1
}
// 判断是否是插值表达式{{xxx}}
isInterpolation (node) {
return node.nodeType === 3 && /\{\{(.*)\}\}/.test(node.textContent)
}
}
编译插值,compile.js
compile(el) {
// ...
} else if (this.isInerpolation(node)) {
// console.log("编译插值⽂本" + node.textContent);
this.compileText(node);
}
});
}
compileText(node) {
console.log(RegExp.$1);
node.textContent = this.$vm[RegExp.$1];
}
编译元素
compile(el) {
//...
if (this.isElement(node)) {
// console.log("编译元素" + node.nodeName);
this.compileElement(node)
}
}
compileElement(node) {
let nodeAttrs = node.attributes;
Array.from(nodeAttrs).forEach(attr => {
let attrName = attr.name;
let exp = attr.value;
if (this.isDirective(attrName)) {
let dir = attrName.substring(2);
this[dir] && this[dir](node, exp);
}
});
}
isDirective(attr) {
return attr.indexOf("k-") == 0;
}
text(node, exp) {
node.textContent = this.$vm[exp];
}
k-html
html(node, exp) {
node.innerHTML = this.$vm[exp]
}
依赖收集
视图中会用到 data 中某 key,这称为依赖。同⼀个 key 可能出现多次,每次都需要收集出来用⼀个 Watcher 来维护它们,此过程称为依赖收集。多个 Watcher 需要⼀个 Dep 来管理,需要更新时由 Dep 统⼀通知。
看下面案例,理出思路:
new Vue({
template:
`<div>
<p>{{name1}}</p>
<p>{{name2}}</p>
<p>{{name1}}</p>
<div>`,
data: {
name1: 'name1',
name2: 'name2'
}
});
image.png
实现思路
-
defineReactive 时为每⼀个 key 创建⼀个 Dep 实例
-
初始化视图时读取某个 key,例如 name1,创建⼀个 watcher1
-
由于触发 name1 的 getter 方法,便将 watcher1添加到 name1 对应的 Dep 中
-
当 name1 更新,setter 触发时,便可通过对应 Dep 通知其管理所有 Watcher 更新
创建 Watcher,kvue.js
const watchers = [] // 临时用于保存 watcher 测试用
// 监听器:负责更新视图
class Watcher {
constructor(vm, key, updateFn) {
// kvue 实例
this.vm = vm;
// 依赖 key
this.key = key;
// 更新函数
this.updateFn = updateFn;
// 临时放入 watchers 数组
watchers.push(this)
}
// 更新
update () {
this.updateFn.call(this.vm, this.vm[this.key]);
}
}
编写更新函数、创建 watcher
// 调 update 函数执插值文本赋值
compileText(node) {
// console.log(RegExp.$1);
// node.textContent = this.$vm[RegExp.$1];
this.update(node, RegExp.$1, 'text')
}
text(node, exp) {
this.update(node, exp, 'text')
}
html(node, exp) {
this.update(node, exp, 'html')
}
update(node, exp, dir) {
const fn = this[dir + 'Updater']
fn && fn(node, this.$vm[exp])
new Watcher(this.$vm, exp, function (val) {
fn && fn(node, val)
})
}
textUpdater(node, val) {
node.textContent = val;
}
htmlUpdater(node, val) {
node.innerHTML = val
}
声明 Dep
class Dep {
constructor() {
this.deps = []
}
addDep (dep) {
this.deps.push(dep)
}
notify () {
this.deps.forEach(dep => dep.update());
}
}
创建 watcher 时触发 getter
class Watcher {
constructor(vm, key, updateFn) {
Dep.target = this;
this.vm[this.key];
Dep.target = null;
}
}
依赖收集,创建 Dep 实例
defineReactive(obj, key, val) {
this.observe(val);
const dep = new Dep()
Object.defineProperty(obj, key, {
get () {
Dep.target && dep.addDep(Dep.target);
return val
},
set (newVal) {
if (newVal === val) return
dep.notify()
}
})
}
总结
以上是一个简单的 Vue 实现,此时 Watcher 监听的粒度太过于精细,导致 Watcher 过多,不需要 vdom。
后面的文章会写类似于 Vue2.0 的监听粒度问题,Vue2.0 的监听粒度会折中,每个组件一个 Watcher,当组件内部的值发生变化时,响应式系统已经知道是哪个组件发生了变化,然后在组件内部进性 diff 算法的操作,最后更新为最新的节点信息。
整体代码
html 部分:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>example</title>
<script src="./lvue.js" charset="utf-8"></script>
</head>
<body>
<div id="app">
<p>{{name}}</p>
<p k-text="name"></p>
<p>{{age}}</p>
<p>
{{doubleAge}}
</p>
<input type="text" k-model="name">
<button @click="changeName">呵呵</button>
<div k-html="html"></div>
</div>
<script src='./compile.js'></script>
<script src='./lvue.js'></script>
<script>
let k = new KVue({
el: '#app',
data: {
name: 'i am test',
age: 12,
html: '<button>这是一个按钮</button>'
},
created() {
console.log('开始啦')
setTimeout(() => {
this.name = '我是蜗牛'
}, 1600)
},
methods: {
changeName() {
this.name = 'changed name'
this.age = 1
this.id = 'xxx'
console.log(1, this)
}
}
})
</script>
</body>
</html>
Watcher、Dep 部分
class KVue {
constructor(options) {
this.$options = options // 挂载实例
this.$data = options.data // 数据响应化
// 监听拦截数据
this.observe(this.$data)
// // 模拟一个 watcher 创建
// new Watcher()
// this.$data.a
// new Watcher()
// this.$data.c.d
// // 模拟结束
new Compile(options.el, this)
// created 执行
if (options.created) {
options.created.call(this)
}
}
observe (value) {
if (!value || typeof value !== 'object') {
return
}
// 遍历该对象
Object.keys(value).forEach(key => {
this.defineReactive(value, key, value[key])
// 代理 data 中的属性到 vue 实例上
this.proxyData(key)
})
}
// 数据响应化
defineReactive (obj, key, val) {
this.observe(val) // 递归解决数据的嵌套
const dep = new Dep() // 每执行一次 defineReactive,就创建一个 Dep 实例
Object.defineProperty(obj, key, { // 数据劫持
configurable: true,
enumerable: true,
get () {
Dep.target && dep.addDep(Dep.target)
return val
},
set (newVal) {
if (newVal === val) {
return
}
val = newVal
console.log(`${key}属性更新了:${val}`)
dep.notify()
}
})
}
proxyData (key) {
Object.defineProperty(this, key, {
configurable: true,
enumerable: true,
get () {
return this.$data[key]
},
set (newVal) {
this.$data[key] = newVal
}
})
}
}
// Dep:用来管理 Watcher
class Dep {
constructor() {
this.deps = [] // 这里存放若干依赖(watcher),一个依赖对应一个属性,依赖就是视图上的引用
}
addDep (dep) {
this.deps.push(dep)
}
notify () {
this.deps.forEach(dep => dep.update())
}
}
// Watcher:小秘书,界面中的一个依赖对应一个小秘书
class Watcher {
constructor(vm, key, cb) {
this.vm = vm
this.key = key
this.cb = cb
// 将当前 Watcher 实例指定到 Dep 静态属性 target
Dep.target = this
this.vm[this.key] // 触发 getter、添加依赖
Dep.target = null
}
update () {
console.log('Watcher监听的属性更新了')
this.cb.call(this.vm, this.vm[this.key])
}
}
Compile 部分
// 用法 new Compile(el,vm)
class Compile {
constructor(el, vm) {
// 要遍历的宿主节点
this.$el = document.querySelector(el)
this.$vm = vm
// 开始编译
if (this.$el) {
// 转换内部内容为片段 Fragment
this.$fragment = this.node2Fragment(this.$el)
// 执行编译
this.compile(this.$fragment)
// 将编译完的 html 结果追加至 $el
this.$el.appendChild(this.$fragment)
}
}
// 将宿主元素中代码片段拿出来遍历,这样做比较高效
node2Fragment (el) {
const frag = document.createDocumentFragment()
// 将 el 中所有子元素搬家至 frag 中
let child
while (child = el.firstChild) {
frag.appendChild(child)
}
return frag
}
// 编译过程
compile (el) {
const childNodes = el.childNodes
Array.from(childNodes).forEach(node => {
// 类型判断
if (this.isElement(node)) {
// 元素
console.log('编译元素' + node.nodeName)
// 查找k-/@/:
const nodeAttrs = node.attributes
Array.from(nodeAttrs).forEach((attr) => {
const attrName = attr.name // 属性名
const exp = attr.value // 属性值
if (this.isDirective(attrName)) {
// k-text
const dir = attrName.substring(2)
// 执行指令
this[dir] && this[dir](node, this.$vm, exp)
}
if (this.isEvent(attrName)) {
const dir = attrName.substring(1) // @click
this.eventHandler(node, this.$vm, exp, dir)
}
})
} else if (this.isInterpolation(node)) {
// 插值文本
console.log('编译文本' + node.textContent)
this.compileText(node)
}
// 递归子节点
if (node.childNodes && node.childNodes.length > 0) {
this.compile(node)
}
})
}
compileText (node) {
console.log(RegExp.$1)
// node.textContent = this.$vm.$data[RegExp.$1]
this.update(node, this.$vm, RegExp.$1, 'text')
}
// 更新函数
update (node, vm, exp, dir) {
const updaterFn = this[dir + 'Updater']
// 初始化
updaterFn && updaterFn(node, vm[exp])
// 依赖收集
new Watcher(vm, exp, function (value) {
updaterFn && updaterFn(node, value)
})
}
html (node, vm, exp) {
this.update(node, vm, exp, 'html')
}
htmlUpdater (node, value) {
node.innerHTML = value
}
text (node, vm, exp) {
this.update(node, vm, exp, 'text')
}
// 双绑
model (node, vm, exp) {
// 指定 input 的 value 属性
this.update(node, vm, exp, 'model')
// 视图对模型响应
node.addEventListener('input', e => {
vm[exp] = e.target.value
})
}
modelUpdater (node, value) {
node.value = value
}
textUpdater (node, value) {
node.textContent = value
}
// 事件处理器
eventHandler (node, vm, exp, dir) {
let fn = vm.$options.methods && vm.$options.methods[exp]
if (dir && fn) {
node.addEventListener(dir, fn.bind(vm))
}
}
isDirective (attr) {
return attr.indexOf('k-') === 0
}
isEvent (attr) {
return attr.indexOf('@') === 0
}
isElement (node) {
return node.nodeType === 1
}
// 插值文本
isInterpolation (node) {
return node.nodeType === 3 && /\{\{(.*)\}\}/.test(node.textContent)
}
}