博主在最近之前的面试中,被问得最多就是Vue双向绑定的原理,现在面试阶段已经结束了,所以就想手撸一下Vue双向绑定的实现。本文将会包括下面几个大的部分
- Object.defineProperty()实现数据劫持
- Dep类收集和通知依赖
- Watch来监听依赖和通知渲染
- Compile模板变异
- 拓展computed计算属性和mounted钩子函数
数据模板
// mvvm.html
<body>
<div id="app">
<h1>{{info.name}}</h1>
<p>{{time}}毕业于{{school}}</p>
<p>目前从事于{{info.work}}</p>
希望您喜欢这篇文章--{{title}}
</div>
<!--实现的mvvm-->
<script src="mvvm.js"></script>
<script>
// 写法和Vue一样
let mvvm = new Mvvm({
el: '#app',
data: {
time: '2016年',
info: {
name: 'hcc',
work: 'web前端'
},
title: 'Vue双向绑定'
}
});
</script>
</body>
到目前为止,Vue还是通过Object.defineProperty
来实现数据的劫持,据说之后会升级到Proxy,就是就不用通过Vue.set来添加新的属性,这里不会对Object.defineProperty的基本用法讲解,详情请看mdn。
我们都知道Vue是基于数据驱动,那我们怎么知道哪些dom用到了哪些数据,数据和dom如何实现双向的更新呢?
<div id="#app">
<div>{{name}}</div>
<div>{{val}}</div>
</div>
我们之所以观察数据,目的肯定是当数据变化的时候,我们可以通知哪些使用了这些数据的地方。这里就有2个问题了:
- 哪些地方使用了这些数据 (通过get来收集依赖)
- 数据变化的时候更新数据 (通过set来通知变化)
function Mvvm(options = {}) {
this.$options = options //将所有属性挂载到实例上面, 和vm.$options同步
let data = this._data = this.$options.data
// 劫持数据
observe(data)
}
- 观察data对象,给对象进行Object.defineProperty监听
- 对于直接新增的对象属性,不存在监听get和set
- 深度响应 因为每次赋予一个新对象时会给这个新对象增加defineProperty(数据劫持)
使用observe来深度观察数据,Observe来观察数据
// 方便递归调用
function observe(data) {
// 不是对象或者不存在就直接return掉
// 防止递归溢出
if (!data || typeof data !== 'object') {
return
}
new Observe(data)
}
//观察数据的主要逻辑
function Observe(data) {
// 对data的每一个属性进行监听(get, set)
for (let key in data) {
let val = data[key]
observe(val) //深度查找对象
Object.defineProperty(data, key, {
configurable: true,
get() {
return val
},
set(newVal) {
if (val === newVal) {
return
}
val = newVal
observe(newVal) //设置了新值也需要监听
}
})
}
}
这里会有2个地方存在疑惑,第一个是为什么要深度查找对象,第二个是set的时候为什么又要执行一次observe。
我们知道Vue中,我们可以直接通过this.name拿到data中的数据,而不用通过this._data.name才能拿到数据,这里的数据代理就是让我们少一层。
function Mvvm(options = {}) {
this.$options = options //将所有属性挂载到实例上面, 和vm.$options同步
let data = this._data = this.$options.data
// 劫持数据
observe(data)
// 数据代理
for (let key in data) {
Object.defineProperty(this, key, {
configurable: true,
get() {
return this._data[key]
},
set(newVal) {
this._data[key] = newVal //这里调用了data的set
}
})
}
}
到这里我们已经基本完成了数据的劫持和数据的代理,但是页面的效果还没有展示出来,下面我们要通过compile把模板进行简单的编译
看不到效果,你可能有点云里雾里,很多文章到这一步会告诉你要收集依赖,看到效果我们再往后讲,你们可能会更加的理解。也方便之后的调试。
在数据代理和数据劫持后,开始渲染模板,首先我们知道肯定要接收包裹容器的dom,获取示例上面的属性,暂时的接收2个,之后需要再添加
function Mvvm(options = {}) {
this.$options = options //将所有属性挂载到实例上面, 和vm.$options同步
let data = this._data = this.$options.data
// 劫持数据
observe(data)
// 数据代理
for (let key in data) {
...
}
// 模板编译
new Complie(this.$options.el, this)
}
function Compile(el, vm) {
// 获取dom元素
vm.$el = document.querySelector(el)
// https://blog.csdn.net/u012657197/article/details/76205901
// const fragment = document.createElement('fragment')
const fragment = document.createDocumentFragment() //创建一个fragment片段存放dom
while (child = vm.$el.firstChild) {
fragment.appendChild(child)
}
function replace(frag) {
Array.from(frag.childNodes).map(node => {
let txt = node.textContent //获取文本
let reg = /\{\{(.*)?\}\}/g
if (node.nodeType === 3 && reg.test(txt)) { // 有文本同时又包含了{{}}
console.log(RegExp.$1) // info.name, title
let val = vm
let arrKeys = RegExp.$1.split('.') // [info, name]
arrKeys.map(key => {
val = val[key]
}) // this[info][name]
node.textContent = txt.replace(reg, val)
}
// 如果内部还有子节点
if (node.childNodes && node.childNodes.length) {
replace(node)
}
})
}
replace(fragment)
vm.$el.appendChild(fragment)
}
这里我们已经可以看到实际的效果了。但是还是没有完成,数据的变化的时候,dom跟着变化,所以接下来我们要处理发布订阅来收集依赖。
首先要明白为什么需要引入发布订阅的模式,订阅什么?我们的需求是数据发送改变的同时,dom也要相应的更新,所以我们肯定要知道哪些dom中运用到了哪些数据,并把这些依赖收集起来,用于之后变化后通知对应的dom。
<template>
<div>{{name}}</div>
<div>{{name}}</div>
</template>
像上述代码中,模板有2个地方运用到了name,当name变化的时候,要将这2处都通知到,那我们从哪里收集依赖呢?
还记得我们模板渲染的代码中有这么一段:
// 这里已经触发了getter
arrKeys.map(key => {
val = val[key]
}) // this[info][name]
node.textContent = txt.replace(reg, val)
每一次的dom渲染数据,肯定是触发了getter来获取我们初始的数据,当数据更新了,肯定是触发了对应属性的setter。所以得出: getter的时候收集依赖,setter的时候触发依赖。
// 收集依赖肯定不止一次运用,所以我们调用一个构造函数来创建对象
function Dep() {
this.subs = [] // 存放所以运用到name的依赖
}
// 添加依赖
Dep.prototype.addDep = function(sub) {
this.subs.push(sub)
}
// 通知依赖,更新dom
Dep.prototype.notify = function() {
this.subs.length && this.subs.forEach(sub => sub.update)
}
订阅我们数据的地方可能有很多处,比如模板,computed,watch,我们不可能对于每一种情况都分别处理,显然,我们需要一个中间者,来帮我们处理不同的情况,它能够做到我们在收集依赖的阶段把这个封装好的类的示例放进去,通知也只通知它一个,它能够帮我们负责通知其他地方。Vue里面取名叫做watcher。
watcher作为一个中间值,数据变化通知watcher,然后watcher通知其他地方。
// 类似于这样,一个用于获取新的值的对象 一个数据变化的值,一个回调函数通知变化,
vm.$watcher(vm, 'info.name' , (newVal, oldVal) =>{
dosomething...
})
function Watcher(vm, exp, fn) {
this.exp = exp // 要通知的值
this.fn = fn // 回调函数,一般是更新dom
}
// 触发回调函数,通知
Watcher.prototype.update = function (newVal) {
this.fn(newVal)
}
let watcher = new Watcher(() => {console.log(1)})
现在我们的需求有变成,怎么在exp这个属性发生改变的时候,触发fn。
- 现在我们要订阅一个事件,当数据改变需要重新刷新视图,这就需要在replace替换的逻辑里来处理
- 通过new Watcher把数据订阅一下,数据一变就执行改变内容的操作
// 首先把watcher加入到Compile中,用来更新数据
// 模板编译
function Complie(el, vm) {
// 获取dom元素
vm.$el = document.querySelector(el)
function replace(frag) {
Array.from(frag.childNodes).map(node => {
let txt = node.textContent
let reg = /\{\{(.*)?\}\}/g
if (node.nodeType === 3 && reg.test(txt)) { // 有文本同时又包含了{{}}
console.log(RegExp.$1) // info.name, title
let val = vm
let arrKeys = RegExp.$1.split('.') // [info, name]
arrKeys.map(key => {
val = val[key]
}) // this[info][name]
// 添加watcher,监听之后的更新
new Watcher(vm, RegExp.$1, (newVal) => {
node.textContent = txt.replace(reg, newVal)
})
node.textContent = txt.replace(reg, val) // 初始化更新
....
}
// Watch改变为
function Watcher(vm, exp, fn) {
this.exp = exp
this.vm = vm
this.fn = fn // 回调函数,一般是更新dom
let arrKeys = exp.split('.')
let val = vm
Dep.target = this // 收集的依赖对象是watcher
// 这里只是单纯的想要触发对应的exp的getter,值并没有用除,(可以简化)
arrKeys.forEach(key => {
val = val[key] // 这里触发了getter, 而getter里面收集依赖
})
Dep.target = null // 已经触发了getter,可以清除
}
添加了watcher之后,我们开始处理getter收集依赖,setter更新依赖
function Observe(data) {
// 对data的每一个属性进行监听
for (let key in data) {
let dep = new Dep() //生成依赖收集
let val = data[key]
observe(val) //深度查找对象
Object.defineProperty(data, key, {
configurable: true,
get() {
// 收集watcher
Dep.target && dep.addDep(Dep.target)
return val
},
set(newVal) {
if (val === newVal) {
return
}
val = newVal
observe(newVal) //设置了新值也需要监听
dep.notify(newVal) //通知数据更新了
}
})
}
}
到这里基本的Mvvm的原理已经完成,有几点的还没有写出来:
- 监听数组的变化
- 对于数据的处理,多个{{}}的处理
- 一些内部数据传递的细节
v-model本质上是一个语法糖,原理其实很简单
// 元素节点
if (node.nodeType === 1) {
let nodeAttr = node.attributes // 获去所有的属性值
console.log(nodeAttr) // {0: type, 1: v-model} 类数组对象
if (nodeAttr.length) {
Array.from(nodeAttr).forEach((attr) => {
let name = attr.name // v-model
let exp = attr.value // info.age
if (name.includes('v-')) {
let val = vm
exp.split('.').map(key => {
val = val[key] // this[info][age]
})
node.value = val
}
// 监听变化
new Watcher(vm, exp, (newVal) => {
node.value = newVal
})
// 双向绑定
node.addEventListener('input', (e) => {
vm[exp] = e.target.value
})
})
}
}
function Mvvm(options = {}) {
this.$options = options //将所有属性挂载到实例上面, 和vm.$options同步
let data = this._data = this.$options.data
// 计算属性执行
initComputed.call(this)
// 劫持数据
observe(data)
// 数据代理
...
// 模板编译
new Complie(this.$options.el, this)
// 触发mounted
options.mounted.call(this); // 这就实现了mounted钩子函数
}
function initComputed() {
let computed = this.$options.computed
let arrKeys = Object.keys(computed) // [oldAag, youngAge]
arrKeys.map(key => {
Object.defineProperty(this, key, {
configurable: true,
get: typeof computed[key] === 'function' ? computed[key] : computed[key].get
})
})
}
- 计算属性值改变的时候还是有问题,需要优化