前言
由于当今版本的 vue 源码太复杂,所以我们只会挑一些它的核心部分来分析。在这之前,先实现一个最简单的自制版 Vue,然后再它的基础上考虑如何解决数据响应式变更、指令解析、生命周期钩子、模板编译等技术痛点,通过与 Vue 真正的源码对比从而得到我们的答案。
从一个例子开始
我们这个最简版的 Vue 能实现什么功能呢?
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>自制版 Vue</title>
</head>
<body>
<div id="app">
<h2>{{title}}</h2>
<input v-model="content">
<h1>{{content}}</h1>
<button v-on:click="clickMe">事件绑定</button>
</div>
<script src="js/vue.js"></script>
<script type="text/javascript">
new Vue({
el: '#app',
data: {
title: '自制 vue',
content: '这是双向绑定的模板内容',
},
methods: {
clickMe () {
this.title = '点击后的标题'
},
},
mounted () {
setTimeout(() => {
this.title = 'mounted 后的标题'
}, 1000)
},
})
</script>
</body>
</html>
它可以实现实例化 Vue, v-model 的双向绑定和 v-on 的事件绑定,并支持 mounted 这个生命周期钩子。
准备工作
这个自制版 Vue 的核心功能其实就两个:
- Vue 构造函数
- 响应式数据更新系统
所以我们需要:
- 一个 Vue 类(es5 版本的构造函数也行)
class Vue {
constructor (options) {
}
}
它会接收我们之前传的那个对象作为选项,给 Vue 实例做一些初始化工作。
- 响应式数据更新系统的三大构造函数
Vue 的响应式数据更新系统本质上是利用了观察者模式,通过 Observe、Dep、Watcher 三者实现的。具体原理我们可以通过类比一个例子来说明。
很久以前,在一个叫知乎的地方也存在一个响应式数据更新系统:轮带逛
从图中我们可以看得出来:
- 当一个作者写了若干文章后,会被轮子哥收藏进收藏夹。
- 吃瓜群众既可以主动浏览收藏夹内容,也可以等收藏夹更新内容后推送给他看。
- 吃瓜群众看完文章后可以做一些自己想做的事,比如,写个读后感。
我们再来看 Vue 的响应式数据更新系统:
同样:
- 当一个 Vue 实例设置了若干 data 后,会被 Observe 加入到 Dep 中。
- Watcher 既可以主动 get Dep 内容,也可以等 Dep 更新内容后 notify 它。
- Watcher get 完 Dep 后可以 update 用户自己想执行的回调函数,比如,更新个 HTML。
所以,在这,我们需要三个构造函数:
class Observer {
constructor (data) {
}
observe () {
}
}
class Dep {
constructor () {
}
addSub () {
}
notify () {
}
}
class Watcher {
constructor () {
}
get () {
}
update () {
}
}
另外提一句:观察者模式和事件系统所采用的订阅-发布模式是不一样的:链接
Vue 类
我们需要解决这几个问题:
- 拿到传入的选项参数后怎么利用?
答:挂载为实例属性:
this.data = options.data
this.methods = options.methods
补充:真实 Vue 对选项参数的处理---mergeOptions
- 怎样实现实例和 data 对象间的关联?比如
this.name = this.data.name
?
答:将 data 对象和实例对象建立代理关系,也就是说访问实例对象属性时其实是访问了 data 对象的属性:
Object.keys(this.data).forEach(key => {
Object.defineProperty(this, key, {
enumerable: false,
configurable: true,
get () {
return this.data[key]
},
set (val) {
this.data[key] = val
}
})
})
这里我们需要把实例的这些代理键的可枚举设为 false,因为我们不想在遍历实例属性的时候把 data 的属性也获取到了。
- 怎么使用响应式数据更新系统?
答:把 data 交给 Observe 去做接下来的事情:
new Observer(this.data).observe()
- 怎么将数据编译成 HTML?
答:交给专门编译类(Compile)和渲染类(Render)去做,这里我们应该把当前挂载的 DOM 元素和加入响应式数据更新系统的实例给它们:
new Compile(options.el, this)
由于我们这个版本不涉及到 vdom 的部分,所以先略去 Render 部分。
- 怎么调用生命钩子的回调?
答:调用用户在创建实例时传入的钩子函数:
options.mounted.call(this)
补充: 真实 Vue 构造实例过程
完整代码
class Vue {
constructor (options) {
// 挂载为实例属性
this.data = options.data
this.methods = options.methods
// 将 data 对象和实例对象建立代理关系
this.initProxy()
// 使用响应式数据更新系统
new Observer(this.data).observe()
// 将数据编译成 HTML
new Compile(options.el, this)
// 调用生命钩子的回调
options.mounted.call(this)
}
initProxy () {
Object.keys(this.data).forEach(key => {
this.proxyKeys(key)
})
}
proxyKeys (key) {
Object.defineProperty(this, key, {
enumerable: false,
configurable: true,
get () {
return this.data[key]
},
set (val) {
this.data[key] = val
}
})
}
}
Observe 类
我们需要解决这几个问题:
- new Observe 具体做了什么?
答:在构造函数中将数据加入到响应式数据更新系统中
this.data = data
this.walk(data)
- observe 方法做了什么?
答:给非空数据分配一个 Observe 实例:
observe (value) {
if (value === null || typeof value !== 'object') {
return
}
return new Observer(value)
}
- 怎么确保 data 每个属性都被侦测到了?在什么时机将数据添加到 dep 里去?在什么时机让 dep notify watcher?
答:在 walk 方法中将 data 的每个的属性都加入响应系统中。在 get 数据时将数据添加到 dep 里去,在 set 数据时让 dep notify watcher。
Object.keys(data).forEach(key => {
let val = data[key]
// 创建一个迎接 data 的 Dep 实例
let dep = new Dep()
// 嵌套观察
let subVal = this.observe(val)
// 建立 data 和 dep 的联系
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get () {
// 无则加
if (Dep.target != null) {
dep.addSub(Dep.target)
}
return val
},
set (newVal) {
if (newVal === val) return
// 有则改
val = newVal
dep.notify(newVal)
}
})
})
这里我们还是选择遍历 data,然后给每个键值对创建一个 Dep 实例,并给它们建立之前图中的联系。
值得注意的是,我们需要给 data 的每层数据都进行 observe。
完整代码
class Observer {
constructor (data) {
this.data = data
this.walk(data)
}
observe (value) {
if (value === null || typeof value !== 'object') {
return
}
return new Observer(value)
}
walk (data) {
Object.keys(data).forEach(key => {
this.defineReactive(data, key, data[key])
})
}
defineReactive (data, key, val) {
// 创建一个迎接 data 的 Dep 实例
let dep = new Dep()
// 嵌套观察
let subVal = this.observe(val)
// 建立 data 和 dep 的联系
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get () {
// 无则加
if (Dep.target != null) {
dep.addSub(Dep.target)
}
return val
},
set (newVal) {
if (newVal === val) return
// 有则改
val = newVal
dep.notify(newVal)
}
})
}
}
Dep 类
我们需要解决这几个问题:
- 怎么知道自己被哪些 Watcher 订阅了?也就是 addSub 的具体过程?
答:创建一个 subs 数组保存:
addSub (sub) {
this.subs.push(sub)
}
- 数据更新后怎么让 Watcher 也知道?也就是 notify 的具体过程?
答:调用所有 watcher 的 update 方法:
notify () {
this.subs.forEach(sub => {
sub.update()
})
}
- 当前应该被加入 subs 数组的 watcher 如何确定?
答:给 Dep 一个属性 target,用它来标注依赖的 watcher:
Dep.target = null
在上面 Observer 的代码中,对加入响应式数据更新系统的数据进行 get 操作时,会通过 addSub 方法将 target 指向的 watcher 加入到 subs 中。
完整代码
class Dep {
constructor () {
// 订阅者集合
this.subs = []
}
// 添加订阅者
addSub (sub) {
this.subs.push(sub)
}
// 通知订阅者
notify () {
this.subs.forEach(sub => {
sub.update()
})
}
}
// 初始化依赖
Dep.target = null
Watcher 类
我们需要解决这几个问题:
- Watcher 实例有哪些属性?
答:从之前我们画的图可以看出,它需要当前实例对象、当前从 dep 中 get 到的值、update 需要的新值(可能是一个表达式,比如三元表达式)、update 需要的回调函数。
constructor (vm, exp, cb) {
this.cb = cb
this.vm = vm
this.exp = exp
this.value = this.get()
}
- get 的具体过程是什么样的?
答:让 Dep 的 target 指向自己,并获取实例上的数据:
get () {
// enter
Dep.target = this
let value = this.vm[this.exp]
// leave
Dep.target = null
return value
}
- update 具体要怎么更新 HTML?
答:获取旧的数据和新的数据,然后借助一个回调函数进行更新:
update () {
let value = this.vm[this.exp]
let oldValue = this.value
// 更新
if (value !== oldValue) {
this.value = value
// 给回调函数绑定作用域
this.cb.call(this.vm, value, oldValue)
}
}
完整代码
class Watcher {
constructor (vm, exp, cb) {
this.cb = cb
this.vm = vm
this.exp = exp
this.value = this.get()
}
// 订阅后才能 get
get () {
// enter
Dep.target = this
let value = this.vm[this.exp]
// leave
Dep.target = null
return value
}
// 观察者自己的行为
update () {
let value = this.vm[this.exp]
let oldValue = this.value
// 更新
if (value !== oldValue) {
this.value = value
// 给回调函数绑定作用域
this.cb.call(this.vm, value, oldValue)
}
}
}
补充:真实的 Vue 是通过 VDOM 更新 HTML 的。Vue VIrtualDOM 介绍
Compile 类
这是我们这个最简版代码的最后一个类,它不属于数据响应式更新系统,但它是使用这个系统的用户。
我们需要解决这几个问题:
- 在什么时机使用数据响应式更新系统?怎么使用?
答:获取当前数据后。实例化一个 Watcher 观察此数据:
new Watcher(this.vm, exp, (val) => {
// 更新 DOM
})
- 如何操作 DOM 节点?
答:先创建节点、再加工节点,最后使用节点:
// 创建节点
this.fragment = this.nodeToFragment(this.el)
// 加工节点
this.compileElement(this.fragment)
// 使用节点
this.el.appendChild(this.fragment)
在创建节点这方面我们为了省事,选择了创建 fragment,而不是根据标签和 createElement 创建:
nodeToFragment (el) {
let fragment = document.createDocumentFragment()
// 将 DOM 元素移入 fragment 中
let child = el.firstChild
while (child) {
fragment.appendChild(child)
child = el.firstChild
}
return fragment
}
- 如何编译插值表达式?
答:首先从模板中剥离出插值表达式,然后给表达式求值,之后更新 DOM
// 匹配插值表达式的正则
let reg = /\{\{(.*)\}\}/
let text = node.textContent
if (this.isTextNode(node) && reg.test(text)) {
// 剥离出插值表达式
let exp = reg.exec(text)[1]
// 表达式求值
let text = this.vm[exp]
this.updateText(node, text)
// 使用数据响应系统
new Watcher(this.vm, exp, (val) => {
this.updateText(node, val)
})
}
- 如何编译 v-on 指令?
答:首先从模板中剥离出 v-on 指令、事件名和事件回调函数,然后给 DOM 元素添加事件监听:
if (this.isEventDirective(directive)) {
let dir = directive
// 获取事件名和回调函数
let eventName = dir.split(':')[1]
let cb = null
if (vm.methods) {
cb = vm.methods[exp]
}
// 添加事件监听
if (eventName && cb) {
node.addEventListener(eventName, cb.bind(vm), false)
}
}
- 如何编译 v-model 指令?
答:首先从模板中剥离出 v-moel 指令和表达式,并给表达式求值,然后给 DOM 元素添加事件监听(我们只考虑 input 元素的 input 事件):
if (this.isModelDirective(directive)) {
// 数据->html
let val = this.vm[exp]
this.modelUpdater(node, val)
new Watcher(this.vm, exp, value => {
this.modelUpdater(node, value)
})
// html 事件->数据
node.addEventListener('input', (e) => {
let newValue = e.target.value
if (val === newValue) {
return
}
this.vm[exp] = newValue
val = newValue
})
}
- 如何更新 node 节点?
答:如果是文本的话,只需要修改 DOM 节点的 textContent 属性,如果是 input 元素的话,需要修改它的 value 属性:
updateText (node, value) {
node.textContent = typeof value === 'undefined' ? '' : value
}
modelUpdater (node, value, oldValue) {
node.value = typeof value === 'undefined' ? '' : value
}
完整代码
class Compile {
constructor (el, vm) {
this.vm = vm
this.el = document.querySelector(el)
this.fragment = null
this.init()
}
init () {
if (this.el) {
// 创建节点
this.fragment = this.nodeToFragment(this.el)
// 加工节点
this.compileElement(this.fragment)
// 使用节点
this.el.appendChild(this.fragment)
} else {
throw Error('DOM 元素未找到!')
}
}
nodeToFragment (el) {
let fragment = document.createDocumentFragment()
// 将 DOM 元素移入 fragment 中
let child = el.firstChild
while (child) {
fragment.appendChild(child)
child = el.firstChild
}
return fragment
}
compileElement (el) {
let childNodes = Array.from(el.childNodes)
childNodes.forEach(node => {
// 匹配插值表达式的正则
let reg = /\{\{(.*)\}\}/
let text = node.textContent
// 细粒度绑定
if (this.isElementNode(node)) {
this.compile(node)
} else if (this.isTextNode(node) && reg.test(text)) {
this.compileText(node, reg.exec(text)[1])
}
// 递归处理子节点
if (node.childNodes != null && node.childNodes.length) {
this.compileElement(node)
}
})
}
compile (node) {
let attrs = Array.from(node.attributes)
attrs.forEach(attr => {
let attrName = attr.name
// 编译指令
if (this.isDirective(attrName)) {
let expression = attr.value
let directive = attrName.substring(2)
// v-on
if (this.isEventDirective(directive)) {
this.compileEvent(node, this.vm, expression, directive)
}
// v-model
else {
this.compileModel(node, this.vm, expression, directive)
}
node.removeAttribute(attrName)
}
})
}
compileEvent (node, vm, exp, dir) {
// 获取事件名和回调函数
let eventName = dir.split(':')[1]
let cb = null
if (vm.methods) {
cb = vm.methods[exp]
}
// 添加事件监听
if (eventName && cb) {
node.addEventListener(eventName, cb.bind(vm), false)
}
}
compileModel (node, vm, exp, dir) {
// 数据->html
let val = this.vm[exp]
this.modelUpdater(node, val)
new Watcher(this.vm, exp, value => {
this.modelUpdater(node, value)
})
// html 事件->数据
node.addEventListener('input', (e) => {
let newValue = e.target.value
if (val === newValue) {
return
}
this.vm[exp] = newValue
val = newValue
})
}
modelUpdater (node, value, oldValue) {
node.value = typeof value === 'undefined' ? '' : value
}
compileText (node, exp) {
let text = this.vm[exp]
// 先更新一次文本
this.updateText(node, text)
// 使用数据响应系统
new Watcher(this.vm, exp, (val) => {
this.updateText(node, val)
})
}
updateText (node, value) {
node.textContent = typeof value === 'undefined' ? '' : value
}
isDirective (attr) {
return attr.indexOf('v-') === 0
}
isEventDirective (dir) {
return dir.indexOf('on:') === 0
}
isElementNode (node) {
return node.nodeType === 1
}
isTextNode (node) {
return node.nodeType === 3
}
}
可以看出,我们将第2个问题的答案封装成了 init、nodeToFragment 函数,第3/4/5个问题的答案封装成了 compileElement、compile、compileText、compileEvent、compileModel 和 isDirective、isEventDirective、isElementNode、isTextNode 函数,将第6个问题的答案封装成了 updateText 、modelUpdater 函数。
结语
完整版代码预览地址
模仿是学习的方法之一,通过自己亲手创造一个 Vue,虽然是玩具级别的,但也能体会很多。下一章我们将使用一个 Vue 实例在真实的 Vue 里遨游,结合我们自制版的找出它那些令人惊叹的设计和实现。
补充:
vue 源码学习