1. 核心技术
掌握核心技术,事半功倍,关键技术点如下
- compile : 解析片段,碎片化文档-- documentFragment
- 观察-订阅者模式,进行数据绑定--数据劫持
- 动态数据,发布通知
2. 背景代码了解
先附上html关键代码段,Id为app的DOM节点,引入各种关键js文件,具体功能通过文件名可以大致了解
<div id="app">
<input type="text" v-model="message.a">
<ul>
<li>{{message.a}}</li>
</ul>
<br>
<input type="text" v-model="name"> <br>
我的名字是:{{name}}<br>
{{number}}元
<button v-click="increment">增加</button>
</div>
<script src="mvvm.js"></script>
<script src="compile.js"></script>
<script src="observer.js"></script>
<script src="watcher.js"></script>
<script>
new MVVM({
el: '#app',
data: {
message: {
a: 'hello world'
},
name: 'Hi, River'
},
methods: {
increment() {
console.log(this.name)
this.number++
}
}
})
</script>
new MVVM 实例化代码,开始实例化一个对象
class MVVM {
constructor(options) {
this.$el = options.el
this.$data = options.data
if (this.$el) {
new Observer(this.$data) // 1.添加观察者模式
new Compile(this.$el, this) // 2.编译 解析文档 -- documentFragment
}
}
}
3. 解析文档 -- documentFragment
文档解析思路
- 把template文档片段编译成fragment存在内存中
- 把fragment片段中的 *{{ }}、v-model 、v-text、v-html、*等替换成data里面中的值
- 把编译好的 fragment 片段塞入到 dom节点中
3.1 真实DOM移入到内存中fragment
this.el = document.querySelector(node)
// 1. 先把这些真实DOM移入到内存中fragment
let fragment = this.nodeToFragment(this.el)
nodeToFragment(node) {
let fragment = document.createDocumentFragment()
var child
while (child = node.firstChild) {
fragment.appendChild(child)
}
return fragment
}
3.2 编译 => 提取想要的元素节点
把fragment片段中的 *{{ }}、v-model 、v-text、v-html、*等替换成data里面中的值
// 2. 把内存的fragment, 编译 => 提取想要的元素节点
let fragment = this.compile(fragment)
compile(fragment) {
// 当前父节点节点的子节点,包含文本节点,类数组对象
let childNodes = fragment.childNodes
Array.from(childNodes).forEach(node => {
// node 节点有元素节点/文本节点, 需要区分2者进行
if (this.isElementNode(node)) { // 元素节点
// 编译元素
this.compileElement(node)
// 如果是元素节点,这可能是嵌套内容,所以要在遍历一遍,如 ul > li
this.compile(node)
} else { // 文本节点
this.compileText(node)
}
})
}
编译分为元素编译和文本编译,元素编译的同时,添加元素节点事件监听事件,把实例text值随时更新,元素编译和文本编译编译方法统一放在CompileUtil方法里面,方法如下
compileElement(node, vm) {
let attr = node.attributes
Array.from(attr).forEach(attr => {
if (attr.name == 'v-model') {
node.removeAttribute('v-model')
// node.value = this.getValue(v.value) // 直接赋值
let [, type] = attr.name.split('-')// 解构赋值[v,model]
CompileUtil[type](node, this.vm, attr.value)
} else if (attr.name == 'v-click') {
let methodName = attr.value
node.addEventListener('click', function () {
return vm.$methods[methodName].bind(vm.$data)()
})
}
})
}
compileText(node, vm) {
// let reg = /\{\{(.)*\}\}/g
let str = node.textContent
let reg = /\{\{([^}]+)\}\}/g
if (reg.test(node.textContent)) {
CompileUtil['text'](node, this.vm, str)
}
}
CompileUtil方法统一对文本,元素节点,等进行归一放置
CompileUtil = {
model(node, vm, key) {
let updateFn = this.updater['modelUpdater']
// 为每个元素节点添加监听事件,如果值变化,则动态更改data中对应的对象的值。
node.addEventListener('input', e => {
let newValue = e.target.value
this.setValue(vm, key, newValue) // view -> model 即 VM模式
})
updateFn && updateFn(node, this.getVal(vm, key))
},
text(node, vm, str) {
// str是一个缓存字符串,保留原始的字符串结构,如 ‘我的名字是:{{name}}’, 后面会针对{{}}内容进行替换
let updateFn = this.updater['textUpdater']
let value = this.getTextVal(vm, str)
updateFn && updateFn(node, value)
},
updater: {
// 更新文本
textUpdater(node, value) {
node.textContent = value
},
// 更新输入框的值
modelUpdater(node, value) {
node.value = value
}
}
通过方法setValue 动态更改 data 对应的值, 相反获取data对应的值通过 getVal获得。
setValue(vm, target, newValue) {
let keys = target.split('.') // 将对象先拆开成数组
// 收敛
return keys.reduce((prev, next, currentIndex) => {
// 如果到对象最后一项时则开始赋值,如message:{a:1}将拆开成message.a = 1
if (currentIndex === keys.length - 1) {
return prev[next] = newValue
}
return prev[next]
}, vm.$data)
}
getVal(vm, expr) {
expr = expr.split('.')
return expr.reduce((prev, next) => { // vm.$data.a.b
return prev[next]
}, vm.$data)
}
3.3 把编译好的 fragment 片段塞入到 dom节点中
this.el.appendChild(fragment)
以上步骤做好之后,会发现input框输入的时候,data中对应的值已经可以动态做变化了,但页面做数据绑定的地方,没有做对应的变化。 MVVM模式中的 view -> model 已经打通了,接下来要实现的就是 model -> view 的动态更新。
4.观察/订阅者模式,进行数据绑定–数据劫持
通过修改vm实例的属性,该改变输入框的内容与文本节点的内容。当我们修改输入框,改变了vm实例的属性,这是1对1的。
但是,我们可能在页面中多处用到 data中的属性,这是1对多的。也就是说,改变1个model的值可以改变多个view中的值。
订阅/发布者模式 订阅发布模式(又称观察者模式)定义了一种一对多的关系,让多个观察者同时监听某一个主题对象,这个主题对象的状态发生改变时就会通知所有观察者对象。 发布者发出通知 => 主题对象收到通知并推送给订阅者 => 订阅者执行相应操作
接下来该如何添加订阅/观察者模式呢?再复习一下原理图

在 compile 编译 HTML 的过程中,会为每个与数据绑定相关的节点生成一个订阅者 watcher,watcher 会将自己添加到相应属性的 dep 容器中。
- 修改输入框内容 => 在事件回调函数中修改属性值 => 触发属性的 set 方法。
- 发出通知 dep.notify() => 触发订阅者的 update 方法 => 更新视图。
这里的关键逻辑是:如何将 watcher 添加到关联属性的 dep 中。 注意: 把所有赋值的操作改为了 添加一个 Watcher 订阅者
CompileUtil = {
model(node, vm, key) {
let updateFn = this.updater['modelUpdater']
+ // 通过观察者进行赋值
+ new Watcher(vm, key, () => {
+ updateFn && updateFn(node, this.getVal(vm, key))
+ })
// 为每个元素节点添加监听事件,如果值变化,则动态更改data中对应的对象的值。
node.addEventListener('input', e => {
let newValue = e.target.value
this.setValue(vm, key, newValue) // view -> model 即 VM模式
})
updateFn && updateFn(node, this.getVal(vm, key))
},
text(node, vm, str) {
// str是一个缓存字符串,保留原始的字符串结构,如 ‘我的名字是:{{name}}’, 后面会针对{{}}内容进行替换
let updateFn = this.updater['textUpdater']
let value = this.getTextVal(vm, str)
+ str.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
+ // 解析时遇到模板中需要替换为数据值的变量时,应添加一个观察者
+ // 当变量重新赋值时,调用更新值节点到Dom的方法
+ // new(实例化)后将调用observe.js中get方法
+ let key = arguments[1]
+ new Watcher(vm, key, () => {
+ updateFn && updateFn(node, this.getTextVal(vm, str))
+ }) // 通过观察者进行赋值
+ })
updateFn && updateFn(node, value)
},
...
到目前为止,可能会觉得有点乱,因为observer还没做介绍。
observer -> dep -> watcher 是如何串联起来,他们都做了些什么,咋们一个个过一遍
4.1 observer
observer其实原理很简单,就是把data中的各个对象的值,通过Object.defineProperty进行一一绑定。
observer(data) {
//将data数据原有属性改成set和get的形式,如果data不为对象,则直接返回
if (!data || typeof data !== 'object') {
return
}
Object.keys(data).forEach(key => {
// 数据劫持--数据绑定
this.defineReactive(data, key, data[key])
this.observer(data[key]) // 递归
})
}
数据绑定方法 defineReactive,先大致了解一下关键的代码 Object.defineProperty,dep相关可以暂时跳过, 如下
defineReactive(obj, key, value) {
let dep = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
if (Dep.target) {
dep.addSub(Dep.target)
}
return value
},
set(newValue) {
if (value !== newValue) {
console.log('我是值变化了', newValue)
value = newValue
// 一旦数据变化,立马通知订阅者
dep.notify()
}
}
})
}
4.2 dep
dep做了什么呢,其实dep就是把每个观察者和observer观察的对象关联起来,建立个一对多的关系。subs保留所有观察者,也就是每一个watcher。
class Dep {
constructor() {
// 订阅的数组
this.subs = []
}
// 保存观察者
addSub(sub) {
this.subs.push(sub)
}
// 广播
notify() {
this.subs.forEach(function (sub) {
sub.update()
})
}
}
4.3 watcher
watcher 又做了些什么呢? 编译过程中是不直接把data中的值赋值到 {{ xx }} 中的xx,而是到每个赋值的地方,通过添加一个新的订阅者 new Watcher。创建watcher过过程中,发生以下几件事情。
- 把 watcher 赋值给全局对象 Dep.target
- 把 vm, node, name 存在当前对象中
- 进行数据赋值更新,也就是通过获取data中的值(关键步骤:改方法会触发步骤4.1中 Object.defineProperty的get方法,此时get方法中判断到 Dep.target 不为空,则把该watcher添加到订阅列表dep.subs中 ),多么巧妙的设计。
- watcher做完以上步骤之后,把Dep.target置空,以免出现订阅者和观察者出现紊乱的情况。
class Watcher {
constructor(vm, name, cb) {
Dep.target = this // Dep.target 是一个全局变量
this.vm = vm
this.name = name
this.cb = cb
this.update()
Dep.target = null
}
update() {
let newValue = this.getVal(this.vm, this.name)
this.cb(newValue)
}
getVal(vm, key) {
let keys = key.split('.')
return keys.reduce((prev, next) => { // vm.$data.a.b
return prev[next]
}, vm.$data)
}
}
通过以上 4.1,4.2,4.3 三个步骤之后,observer -> dep -> watcher 建立起彼此的关联。 实现了双向数据绑定,4.3 watcher 中的update方法混合着2种更新方法,每个对应的订阅者,其实只需要其中的一个,如input输入框值的变化,其实只需要(html->data)进行更改;反过来,data中的值变化,只需要(data->html)的单向传递,对该方法进行优化,更新的方法由创建new Watcher的地方进行传入,作为回调方法,回调方法指向CompileUtil对应的2个方法
// 更新文本
textUpdater(node, value) {
node.textContent = value
}
// 更新输入框的值
modelUpdater(node, value) {
node.value = value
}
截止到目前为止,我们实现了 v-model -> data, data-> v-model 的双向数据绑定, 同时实现了v-click方法,但VUE的语法中还存在 v-text, v-html,v-on等指令,如有兴趣,后期读者可以自行实现以上方法。
5.写在最后
截止到目前为止,VUE的双向数据实现原理大体上已经打通了,但还有很多领域知识点还为了解和学习。
如 Object.difineProperty 其实并不是想象中那么美好,是否有可替代的方法进行优化改进呢,答案是肯定的,vue3.0已经对该种方式的数据劫持进行了改进,严格来说应该是选用替代方法,即ES6中的proxy代理。
那么proxy代理如何实现双向数据绑定呢,请移步到 proxy模拟实现双向数据绑定 进行了解。
本文所用到的 源码