效果图
如何实现一个双向绑定已经是一个老生常谈的话题了,最近也写了一个 双向绑定 demo,最终呈现如下(demo丑了点勿怪):
点击 demo预览 可以在线预览
前言
最近整理收藏夹发现了 自己手动实现简单的双向数据绑定mvvm 这篇博客,它以非常简单易懂的例子讲解了 Vue 响应式的核心——双向绑定的实现。看完之后,我也写了一个 双向绑定 并新增了几个简单功能:
- 生命周期
- 方法和事件
- 计算属性
下面我便来简述一下这些功能的实现。
什么是MVVM
谈及 双向绑定 就不得不提 MVVM模式,它是是Model-View-ViewModel的简写。
在前端页面中,把Model用纯JavaScript对象表示,View负责显示,两者做到了最大限度的分离。
把Model和View关联起来的就是ViewModel。ViewModel负责把Model的数据同步到View显示出来,还负责把View的修改同步回Model。
在 Vue 应用中我们可以将 <div id="app"></div>
内的模板看作是 View ,将 data
函数的返回值看作为 Model ,而 ViewModel 就是双向绑定功能,它可以感知数据的变化,用来连接 View 和 Model 。
Vue 中的 ViewModel 做了什么,举个最简单的例子:
<div id="app">
<h1>{{ msg }}</h1>
<input type="text" v-model="msg" />
</div>
<script>
new Vue({
el: '#app',
data () {
return { msg: 'Hello world' }
}
})
</script>
将 Hello world
赋给 h1 和 input 标签,这里是初始化模板,Compile 做的事。 而通过修改 msg 的值,随之 h1 和 input 值被改变,在 input 内键入其他值从而改变 h1 和 msg 的值,实现了这样的功能便就是实现了一个 双向绑定了。实现一个简单的 Vue双向绑定可以拆分为以下功能:
- Compile 解析模板初始化页面
- Dep 依赖收集、通知
- Watcher 提供依赖,更新视图
- Observer 侦听数据变化,通知视图更新
上图是我画的双向绑定的流程图,应该比文字要直观很多,下面直接上代码。
options
可以先看下最终的调用代码,我们尝试模拟一个已存在的框架的时候,可以通过现有的结果去推断我们的代码如何设计。
new Vue({
el: '#app',
data() {
return {
foo: 'hello',
bar: 'world'
}
},
computed: {
fooBar() {
return this.foo + ' ' + this.bar
},
barFoo() {
return this.bar + ' ' + this.foo
}
},
mounted() {
console.log(document.querySelector('h1'));
console.log(this.foo);
},
methods: {
clickHandler() {
this.foo = 'hello'
this.bar = 'world'
console.log('实现一个简单事件!')
console.log(this)
}
}
})
Vue 类
上述代码 1:1 还原 Vue 的调用写法,很明显自然而然就写一个 Vue 类,传一个 options 对象作为参数。
class Vue {
constructor(options) {
this.$options = options
this.$el = document.querySelector(options.el)
// 缓存data中的key,用来数据劫持
// (ps: Vue 将 options.data 中的属性挂在 Vue实例 上, Object.defineProperty 劫持的其实是 Vue实例 上的属性, options.data 里的数据初始化之后应该用处不大)
this.$depKeys = Object.keys({...options.data(), ...options.computed})
// 计算属性的依赖数据
this._computedDep = {}
this._addProperty(options)
this._getComputedDep(options.computed)
this._init()
}
_init() {
observer(this)
new Compile(this)
}
// 获取计算属性依赖
_getComputedDep(computed) {
Object.keys(computed).forEach(key => {
const computedFn = computed[key]
const computedDeps = this._getDep(computedFn.toString())
computedDeps.forEach(dep => {
if (!this._computedDep[dep]) {
this._computedDep[dep] = {
[key]: computed[key]
}
} else {
Object.assign(this._computedDep[dep], {
[key]: computed[key]
})
}
})
})
}
_getDep(fnStr) {
const NOT_REQUIRED = ['(', ')', '{', '}', '+', '*', '/', '\'']
return fnStr.replace(/[\r\n ]/g, '')
.split('')
.filter(item => !NOT_REQUIRED.includes(item))
.join('')
.split('return')[1]
.split('this.')
.filter(Boolean)
}
// 将 data 和 methods 中的值注入Vue实例中(实现在方法或生命周期等能直接用 this[key] 来取值)
_addProperty(options) {
const {computed, data, methods} = options
const _computed = {}
Object.keys(computed).forEach(key => {
_computed[key] = computed[key].call(data())
})
const allData = {...data(), ...methods, ..._computed}
Object.keys(allData).forEach(key => {
this[key] = allData[key]
})
}
}
Vue 类中调用了 observer
和 Compile
来进行初始化操作,收集一些必要参数挂在 Vue 实例上方便后续操作。在这里我额外将 data 、 computed 以及 methods 挂在了 Vue 实例,这里为何这样做后面会提到。
Compile
// 编译模板
class Compile {
constructor(vm) {
this.vm = vm
this._init()
}
_init() {
// 搬来 Vue 中匹配 {{ 插值 }} 的正则
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/;
// 获取主容器
const container = this.vm.$el
// 创建虚拟节点
const fragment = document.createDocumentFragment()
// 只取元素节点
let firstChild = container.firstElementChild
while (firstChild) {
// 这里 append 一个,container 就会少一个 子元素,若没有子元素则会返回 null
fragment.appendChild(firstChild)
firstChild = container.firstElementChild
}
fragment.childNodes.forEach(node => {
// 将属性节点(ArrayLike)转换为数组
[...node.attributes].forEach(attr => {
// 匹配 v-model 指令
if (attr.name === 'v-model') {
const key = attr.value
node.value = this.vm[key]
new Watcher(this.vm, key, val => {
node.value = val
})
node.addEventListener('input', e => {
// input 事件触发 set 方法,并通知 Watcher 实例操作变更dom
this.vm[key] = e.target.value
})
}
// 匹配 @click 绑定点击事件
if (attr.name === '@click') {
// 使用 bind 将此函数内部 this 改为 Vue实例
node.addEventListener('click', this.vm[attr.value].bind(this.vm))
}
})
// 匹配双花括号插值(textContent取赋值 比 innerText 好一点)
if (node.textContent && defaultTagRE.test(node.textContent)) {
console.log(node.textContent);
const key = RegExp.$1.trim()
// 替换 {{}} 后的文本,用于初始化页面
const replaceTextContent = node.textContent.replace(defaultTagRE, this.vm[key])
// 移除 {{}} 后的文本,用于响应性更新
const removeMustache = node.textContent.replace(defaultTagRE, '')
node.textContent = replaceTextContent
new Watcher(this.vm, key, val => {
node.textContent = removeMustache + val
})
}
})
// 将 虚拟节点 添加到主容器中(这里可以将虚拟节点理解为 Vue 中的 template 标签,只起到一个包裹作用不会存在真实标签)
this.vm.$el.appendChild(fragment)
// 此处定义 mounted 生命周期
typeof this.vm.$options.mounted === 'function' && this.vm.$options.mounted.call(this.vm)
}
}
如果你想第一时间看到成果的话,先写 Compile
准没错。我这里贴上最终的完整代码,所以代码会比较多,细细拆分的话有以下几个功能:
- 解析
#app
内容,将元素中的{{}}
或v-model
转为实际值赋上,并追加到一个虚拟节点中。关于为何使用createDocumentFragment
方法创建一个虚拟节点,第一个好处就是方便,第二个好处就是减少性能开销了。在浏览器中,每一次的添加和删除元素都会引起页面的回流,它需要重新计算其他元素的位置。如果有几百个元素依次加入到dom中就会引起几百次重绘,而将所有元素添加到 虚拟节点 中则只会引起一次回流重绘。 - 生成
Watcher
实例,它缓存了依赖key,并加入了更新dom数据的方法,为的就是在依赖值被更改的时候去更新dom。
Observer
function observer(vm) {
const dep = new Dep()
const {_computedDep} = vm
vm.$depKeys.forEach(key => {
let value = vm[key]
Object.defineProperty(vm, key, {
enumerable: true,
configurable: true,
get() {
if (Dep.target) {
// 添加订阅者-Watcher实例
dep.add(Dep.target)
}
return value
},
set(newVal) {
value = newVal
// (ps:可以在此处根据 key 值通知对应的 Watcher 进行更新)
dep.notify()
Object.keys(_computedDep).forEach(computedDep => {
// 在 set 中匹配对应的依赖项更新对应的计算属性
if (key === computedDep) {
for (let getter in _computedDep[key]) {
vm[getter] = _computedDep[key][getter].call(vm)
}
}
})
}
})
})
}
observer
其实是一个侦听器,这里用来监测 data 和 computed 的改动并通知dom更新,那么这里的 Object.defineProperty
方法就是 Vue 得以实现双向绑定的基石了。关于 computed 计算属性的自动收集依赖真的有点难,Vue源码中奇奇怪怪的转调实在看不下去,只好写了个简单粗暴的方式实现了。计算属性本质上是一个方法的返回值,所有我这里的实现原理就是:一个依赖key对应多个computed方法,检测依赖key的更新,同时触发computed方法。
Dep
和 Watcher
代码都比较简单没什么好讲的,就不贴了,文章后面会贴上源代码。
如何实现生命周期?
要是在以前问我Vue中的生命周期是如何实现的?我还真的不知道,我很少看面试题。最初接触Vue时我猜测它的生命周期可能是我不知道的某些js api实现的。其实在某些js代码执行之前或执行之后,来调用一个生命周期函数就形成了生命周期。在 Compile
中,在模板解析完毕填入到dom后,调用 mounted
函数便实现了 mounted 生命周期。
如何方便的调取 data 值和 计算属性?
在mothods、生命周期以及computed中都是需要获取data和计算属性,在 Vue 中直接随心所欲的用 this
就可以调取所有值。在上面我写了一句话 通过现有的结果去推断我们的代码如何设计 ,最简单粗暴的方式将这些数据挂在 Vue实例 上就迎刃而解了,Vue 中其实就是这样做的。
总结
确实我的代码相较于 网上例子 可能比较长,因为加了一些额外的功能,它只是我对于 Vue 其他功能实现的一个探索。
在 2020 年末和 2021 年初给我的感觉就一个字——“忙“,忙的没时间写博客,没时间做其他事,所以这篇博客没有时间由浅入深的写的比较通俗易懂。细小功能点一点点拆分写,篇幅长10倍不止,紧赶慢赶也算写出来了,算是2021年1月的的唯一一篇产出吧。
源码
参考
- 自己手动实现简单的双向数据绑定mvvm
- 网上例子地毯式注释版 (我在看上面代码时加的注释)