使用vue有一段时间了,面试中也经常被问到一些关于vue框架原理性的问题,因为总感觉自己回答的不是蛮好,所以去了解一下vue的一些原理,简单的去实现了属于自己的vue(第一次写文章,写的不好的地方,还请多多包涵)
目录结构
我们先来看一下目录结构

- index.html :创建一些元素标签,引入上面4个js文件,并且实例化vue对象
- compile.js :能够对模版中的指令和插值表达式进行解析,并且赋予不同的操作
- observe.js :能够对数据对象的所有属性进行监听
- watcher.js :将Compile的解析结果,与Observer所观察的对象连接起来,建立关系,在Observer观察到对象数据变化时,接收通知,同时更新DOM
- vue.js :创建一个公共的入口对象,接收初始化的配置并且协调上面三个模块,也就是vue
这里放一张网上找的图,来帮助理解实现的思路

下面我们一起来实现一下各个模块吧
index.html
在index.html文件中,我们定义了2个插值表达式{{msg}}和{{car.brand}},以及vue中的v-html、v-model、v-on指令,并且还引入了一些js文件,实例化了vm对象,就像使用vue一样!
<div id="app">
<p v-text="msg"></p>
<p>{{msg}}</p>
<p>{{car.brand}}</p>
<p v-html="msg"></p>
<input type="text" v-model="msg">
<button v-on:click="clickFn">按钮</button>
</div>
<script src="./src/watcher.js"></script>
<script src="./src/observe.js"></script>
<script src="./src/compile.js"></script>
<script src="./src/vue.js"></script>
<script>
const vm = new Vue({
el: '#app',
data: {
msg: 'hello vue111',
car: {
brand: '丰田'
}
},
methods: {
clickFn() {
// 在vue的methods中this应该指向当前实例
this.msg = '有个好消息'
this.car.brand = '奔驰'
}
}
})
</script>
vue.js
接下来我们来实现一下vue.js模块,我们在构造函数中设置了3属性:el、$data、$methods,分别表示vm对象中的el、data、methods。为了便于维护,我们把模板编译相关的代码封装在了compile.js文件中
在创建Compile对象时,我们会传递2个参数:this.$el、this,分别是vm实例的el属性、vm实例
class Vue {
constructor(options = {}){
//给vue实例添加一些属性
this.$el = options.el
this.$data = options.data
this.$methods = option.methods
if(this.$el) {
//负责编辑模板的内容
let c = new Compile(this.$el, this )
}
}
}
compile.js
然后我们来看一下compile模块怎么实现
同样,我们创建Compile类,在构造函数中接收2个参数: el 和 vm
class Compile {
constructor(el, vm) {
// el: new vue传递的选择器
this.el = typeof el === "string" ? document.querySelector(el) : el
// vm: new 的vue实例
this.vm = vm
}
}
文档碎片 DocumentFragment
在模板编译时,要涉及到很多元素标签的编译,就会产生大量的重绘和重排,非常耗性能,幸好DOM为我们提供了文档碎片DocumentFragment,来让我们能够在内存中完成重绘和重排,最后再一次性的添加到页面中,这样就节省了很多性能
DocumentFragment 是DOM节点。它们不是主DOM树的一部分。通常的用例是创建文档片段,将元素附加到文档片段,然后将文档片段附加到DOM树。在DOM树中,文档片段被其所有的子元素所代替。
我们可以通过document.createDocumentFragment()来创建文档碎片
let fragment = document.createDocumentFragment();
好了,了解了文档碎片之后,我们继续来实现我们的compile模块,
我们可以把编译过程大致分为3个步骤:
- 利用文档碎片DocumentFragment, 把el中所有的子节点都放入到内存中
- 在内容中编译文档碎片
- 把文档碎片一次性添加到页面
代码如下
class Compile {
constructor(el, vm) {
// el: new vue传递的选择器
this.el = typeof el === "string" ? document.querySelector(el) : el
// vm: new 的vue实例
this.vm = vm
//编译模板
if(this.el) {
//1. 把el中所有的子节点都放入到内存中
let fragment = this.node2fragment(this.el)
//2. 在内存中编译fragment
this.compile(fragment)
//3. 把fragment一次性的添加到页面
this.el.appendChild(fragment)
}
}
}
为了便于维护,我们封装了node2fragment和compile方法,下面我们来具体实现一下这2个方法
node2fragment() 方法代码比较简单,就是一些DOM操作,其中toArray是我们自己封装的方法,用于将伪数组转换成数组
node2fragment(node) {
let fragment = document.createDocumentFragment()
//获取el下所有的子节点
let childNodes = node.childNodes
this.toArray(childNodes).forEach(node => {
//将el所有的子节点添加到frament中
fragment.appendChild(node)
})
return fragment
}
然后我们再实现一下compile() 方法,在这个方法里面,我们会遍历子节点,逐个解析,解析时我们考虑到了3种情况:
- 遇到元素节点,需要解析指令
- 遇到文本节点,需要解析插值表达式
- 当前节点还有子节点,需要递归解析
compile(fragment) {
let childNodes = fragment.childNodes
this.toArray(childNodes).forEach(node => {
// 编译子节点
if (this.isElementNode(node)) {
// 如果是元素, 需要解析指令
this.compileElement(node)
}
if (this.isTextNode(node)) {
// 如果是文本节点, 需要解析插值表达式
this.compileText(node)
}
// 如果当前节点还有子节点,需要递归的解析
if (node.childNodes && node.childNodes.length > 0) {
this.compile(node)
}
})
}
上面代码判断中用到的一些条件判断的方法,我们也进行了封装
/* 工具方法 */
toArray(likeArray) {
return [].slice.call(likeArray)
}
isElementNode(node) {
//nodeType: 节点的类型 1:元素节点 3:文本节点
return node.nodeType === 1
}
isTextNode(node) {
return node.nodeType === 3
}
}
弄清楚条件判断后,我们再来实现一下compileElement(node)和this.compileText(node)方法
先来实现compileElement(node)方法,在这个房里里面,我们会获取元素节点的属性,并解析v-开头的指令。解析指令时,我们考虑了2种情况:
- 以
v-on:开头的用于注册事件的指令,例如v-on:click v-text、v-modol之类的指令
// 解析html标签
compileElement(node) {
// 1. 获取到当前节点下所有的属性
let attributes = node.attributes
this.toArray(attributes).forEach(attr => {
// 2. 解析vue的指令(所以以v-开头的属性)
let attrName = attr.name
if (this.isDirective(attrName)) {
//获取属性名
let type = attrName.slice(2)
//获取属性值
let expr = attr.value
//解析指令v-开头的指令
//1.v-on:click指令,即给元素注册事件
//2.v-html、v-text之类的指令
}
})
}
//工具方法
isDirective(attrName) {
return attrName.startsWith("v-")
}
指令的解析我们封装在了一个对象中CompileUtil中,这里其实用到了设计模式中的策略模式
策略模式指的是定义一系列的算法,把它们一个个封装起来。将不变的部分和变化的部分隔开是每个设计模式的主题,策略模式也不例外,策略模式的目的就是将算法的使用与算法的实现分离开来。
在CompileUtil中我们封装了处理v-text、v-html、v-model、v-on:指令
其中有一点要注意,为了处理复杂类型的数据,我们又封装了getVMValue方法,如下图所示:


let CompileUtil = {
//处理v-text
text(node, vm, expr) {
node.textContent = this.getVMValue(vm, expr)
// 通过watcher对象,监听expr的数据的变化,一旦变化了,执行回调函数
},
//处理v-html
html(node, vm, expr) {
node.innerHTML = this.getVMValue(vm, expr)
},
//处理v-model
model(node, vm, expr) {
let self = this
node.value = this.getVMValue(vm, expr)
// 实现双向的数据绑定, 给node注册input事件,当当前元素的value值发生改变,修改对应的数据
node.addEventListener("input", function() {
self.setVMValue(vm, expr, this.value)
})
},
//处理v-on:
eventHandler(node, vm, type, expr) {
// 给当前元素注册事件即可
let eventType = type.split(":")[1]
let fn = vm.$methods && vm.$methods[expr]
if (eventType && fn) {
node.addEventListener(eventType, fn.bind(vm))
}
},
// 这个方法用于获取VM中的数据
getVMValue(vm, expr) {
// 获取到data中的数据
let data = vm.$data
expr.split(".").forEach(key => {
data = data[key]
})
return data
},
setVMValue(vm, expr, value) {
let data = vm.$data
let arr = expr.split(".")
arr.forEach((key, index) => {
// 如果index是最后一个
if (index < arr.length - 1) {
data = data[key]
} else {
data[key] = value
}
})
}
}
封装完这些指令处理方法后,我们再来完善compileElement(node)方法
// 解析html标签
compileElement(node) {
// 1. 获取到当前节点下所有的属性
let attributes = node.attributes
this.toArray(attributes).forEach(attr => {
let attrName = attr.name
if (this.isDirective(attrName)) {
let type = attrName.slice(2)
let expr = attr.value
//解析v-on指令
if (this.isEventDirective(type)) {
CompileUtil["eventHandler"](node, this.vm, type, expr)
} else {
//解析v-text、v-html、v-model
CompileUtil[type] && CompileUtil[type](node, this.vm, expr)
}
}
})
}
好了,处理元素节点的compileElement(node)方法,我们已经大致实现,接下来,我们再来实现一下处理文本节点的compileText(node)方法
为了便于维护,我们把实现方法mustache依然封装在CompileTil对象中
// 解析文本节点
compileText(node) {
CompileUtil.mustache(node, this.vm)
}
let CompileUtil = {
mustache(node, vm) {
let txt = node.textContent
let reg = /\{\{(.+)\}\}/
if (reg.test(txt)) {
let expr = RegExp.$1
node.textContent = txt.replace(reg, this.getVMValue(vm, expr))
}
},
// 这个方法用于获取VM中的数据
getVMValue(vm, expr) {
// 获取到data中的数据
let data = vm.$data
expr.split(".").forEach(key => {
data = data[key]
})
return data
},
}
致辞我们大致完成了编译模块Compile.js
Observe.js
为了实现响应式,如果vue实例中的data里的数据发生改变时,我们就需要通知html里面的内容及时更新过来,在Observe.js模块中,我们主要劫持了vue实例中的data里面所有的数据,方便我们在获取或者设置data中数据的时候,实现我们的逻辑
在实现data里面的数据劫持之前,我们先讨论一个前置知识Object.defineProperty()
下面代码中,我们通过Object.defineProperty()方法劫持了obj对象的likeGame属性的getter和setter
在获取obj对象的likeGame属性时,会执行get()方法,改变likeGame属性时,会执行set方法
let obj = {
likeGame: "英雄联盟"
}
let temp = obj['likeGame'] //1、获取`obj`对象的`likeGame`属
temo.likeGame = "地下城" //2、改变`likeGame`属性
Object.defineProperty(obj, 'likeGame', {
configurable: true, // 表示属性可以配置
enumerable: true, // 表示这个属性可以遍历
get() {
// 每次获取对象的这个属性的时候,就会被这个get方法给劫持到
console.log('获取了obj对象的likeGame属性,执行了get方法')
return temp
},
// 每次设置这个对象的属性的时候,就会被set方法劫持到
set(newValue) {
console.log('设置了obj对象的likeGame属性,执行了set方法')
temp = newValue
}
})
//最终打印
//获取了obj对象的likeGame属性,执行了get方法 1
//设置了obj对象的likeGame属性,执行了set方法 2
了解了Object.defineProperty后,我们继续来实现observe.js模块
我们在构造函数里面接收传过来的参数data,即vue实例中的data数据,并且通过调用walk()方法来完成data数据劫持
为了给data对象的key设置getter和setter,我们把数据劫持代码封装在defineReactive()方法中,该方法会在下面实现
class Observer {
constructor(data) {
this.data = data
this.walk(data)
}
/* 核心方法 */
/* 遍历data中所有的数据,都添加上getter和setter */
walk(data) {
if (!data || typeof data != "object") {
return
}
Object.keys(data).forEach(key => {
// 给data对象的key设置getter和setter
this.defineReactive(data, key, data[key])
//递归
this.walk(data[key])
})
}
注意到,上面代码中用到了递归this.walk(data[key]),我们去掉这一行代码会怎么样呢
假如,data是下面的代码结构,我们去掉this.walk(data[key])这一行代码,就只能劫持data对象的like属性,没法劫持到like里面的animal属性,所以这里用到了递归
data = {
like : { animal : dog}
}
在defineReactive()方法代码如下,目前这个方法里只是劫持到了data里面的数据,还没有写一些响应式的逻辑,不着急,我们后面再来实现
defineReactive(obj, key, value) {
let that = this
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
return value
},
set(newValue) {
if (value === newValue) {
return
}
value = newValue
}
})
}
接下来,我们还要再vue.js模块的构造函数中创建Observe对象,这样页面加载时,就监视了vue实例的data属性
class Vue {
// 给vue实例增加属性
this.$el = options.el
this.$data = options.data
this.$methods = options.methods
// 监视data中的数据(加上这一行代码)
new Observer(this.$data)
if (this.$el) {
// compile负责解析模板的内容
let c = new Compile(this.$el, this)
}
}
watcher.js
在实现了编译模块compile.js和数据劫持模块observe.js后,我们接下来要做的就是把这2个模块关联起来,为了实现这样的效果,当vue实例中的data数据发生改变时,通知视图层去同步更新内容,来看看watcher.js模块里面的代码
在watcher.js模块里面的update()方法用来更新视图
class Watcher {
// vm: 当前的vue实例
// expr: data中数据的名字
// 一旦数据发生了改变,需要调用cb
constructor(vm, expr, callBack) {
this.vm = vm
this.expr = expr
this.callBack = callBack
// this表示的就是新创建的watcher对象
// 存储到Dep.target属性上
Dep.target = this
// 清空Dep.target
Dep.target = null
}
}
update() {
let oldValue = this.oldValue
let newValue = this.getVMValue(this.vm, this.expr)
this.callBack(newValue, oldValue)
}
//用于获取vm中的数据
getVMValue(vm, expr) {
// 获取到data中的数据
let data = vm.$data
expr.split(".").forEach(key => {
data = data[key]
})
return data
}
}
假如vue实例中的data有一个属性为name,那么页面中可能就有很多地方都要用到name,例如
<p v-text="name"></p>
<p>{{name}}</p>
<p v-html="name"></p>
因此我们就需要给data中每个属性都要创建一个容器来管理这些需要依赖name属性的元素标签,其实也叫订阅者
我们在watcher.js模块在创建Dep类来管理这些订阅者
在Dep中,通过addSub()方法添加订阅者,通过notify方法通知所有的订阅者去更新视图
/* dep对象用于管理所有的订阅者和通知这些订阅者 */
class Dep {
constructor() {
// 用于管理订阅者的容器
this.subs = []
}
// 添加订阅者
addSub(watcher) {
this.subs.push(watcher)
}
// 通知
notify() {
// 遍历所有的订阅者,调用watcher的update方法
this.subs.forEach(sub => {
sub.update()
})
}
}
视图层,每一个用到了v-开头的指令的元素,实际上就是一个订阅者,因此在Compile.js中解析指令时,每一次解析指令,我们都要创建一个订阅者对象
所有我们再来修改下Compile.js模块中解析v-开头的指令的逻辑,而我们把这部分逻辑封装在了CompileUtil中,直接修改CompileUtil中的代码即可
可以看到我们在每个方法里面都new了一个Watcher对象,既订阅者
let CompileUtil = {
mustache(node, vm) {
let txt = node.textContent
let reg = /\{\{(.+)\}\}/
if (reg.test(txt)) {
let expr = RegExp.$1
node.textContent = txt.replace(reg, this.getVMValue(vm, expr))
new Watcher(vm, expr, newValue => {
node.textContent = txt.replace(reg, newValue)
})
}
},
// 处理v-text指令
text(node, vm, expr) {
node.textContent = this.getVMValue(vm, expr)
// 通过watcher对象,监听expr的数据的变化,一旦变化了,执行回调函数
new Watcher(vm, expr, (newValue, oldValue) => {
node.textContent = newValue
})
},
html(node, vm, expr) {
node.innerHTML = this.getVMValue(vm, expr)
new Watcher(vm, expr, newValue => {
node.innerHTML = newValue
})
},
model(node, vm, expr) {
let self = this
node.value = this.getVMValue(vm, expr)
// 实现双向的数据绑定, 给node注册input事件,当当前元素的value值发生改变,修改对应的数据
node.addEventListener("input", function() {
self.setVMValue(vm, expr, this.value)
})
new Watcher(vm, expr, newValue => {
node.value = newValue
})
}
}
接下来我们再来管理这些订阅者,就是给data里面每个属性都创建一个容器来管理这些订阅者
在observe.js模块的defineReactive(),我们劫持了data里面的每个属性,一次我们在劫持每个属性时,就创建一个Dep实例来管理解析指令时创建的订阅者,我们来完善defineReactive()方法
在defineReactive()方法开头我们实例化了Dep对象,然后在get()里面添加订阅者,在set方法里面发布通知,让所有的订阅者更新内容
// data中的每一个数据都应该维护一个dep对象
// dep保存了所有的订阅了该数据的订阅者
defineReactive(obj, key, value) {
let that = this
let dep = new Dep() //增加的代码
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
// 如果Dep.target中有watcher对象,存储到订阅者数组中
Dep.target && dep.addSub(Dep.target) //增加的代码
return value
},
set(newValue) {
if (value === newValue) {
return
}
value = newValue
// 如果newValue是一个对象,也应该对她进行劫持
that.walk(newValue) //增加的代码
// 发布通知,让所有的订阅者更新内容
dep.notify() //增加的代码
}
})
}
好了基本上已经差不多了,我们最后再做一个优化,用过vue的都知道,我们可以直接用vue实例方法来点出data数据和方法,但是在我们的Demo里面只能通过vm.$data.name和vm.$method.getName()来访问vm的data数据和方法
let attr = vm.name
vm.getName()
我们可以把vm里面的data和method代理到vm上面来,下面再来完善一下vue.js模块里面的代码
实现原理说白了就是利用Object.defineProperty()来劫持vm的属性和方法
例如我们要访问vm实例里name属性,会这样写 :vm.name
通过proxy()里面定义的get()方法,可以知道,最后获取到的其实是vm.$data.name,method也是同理
/* 定义一个类,用于创建vue实例 */
class Vue {
constructor(options = {}) {
// 给vue实例增加属性
this.$el = options.el
this.$data = options.data
this.$methods = options.methods
// 监视data中的数据
new Observer(this.$data)
// 把data中所有的数据代理到了vm上
this.proxy(this.$data)
// 把methods中所有的数据代理到了vm上
this.proxy(this.$methods)
// 如果指定了el参数,对el进行解析
if (this.$el) {
// compile负责解析模板的内容
// 需要:模板和数据
let c = new Compile(this.$el, this)
}
}
proxy(data) {
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get() {
return data[key]
},
set(newValue) {
if (data[key] == newValue) {
return
}
data[key] = newValue
}
})
})
}
}
结束语
vue数据绑定原理,在面试中也是很容易被问到的,自己尝试着实现一下,以后面试再被问到vue原理之类的问题时,或许可以回答的更细致一点