前言
大家好,我是HoMeTown,今天聊一聊老生长谈的Vue之双向数据绑定
。
What?
首先我们先看什么是双向数据绑定
?
对于不是很了解设计模式
的朋友,你可以先理解一下单向数据绑定
,就是把数据
绑定到视图
,每次触发操作修改了数据
,视图
就会更新,数据 -> 视图,可以理解为MV
,数据驱动视图
。
举个🌰:
网络异常,图片无法展示
|
通过点击按钮set name
,触发点击事件,手动更新变量name
的值为HoMeTown
,但是当我改变input
输入框里的值,变量 name
的值却不变,如下图:
网络异常,图片无法展示
|
那么双向数据绑定就是在单向的基础上,通过操作更新视图
,数据
自动更新,那上面的🌰来讲,就是我输入Input,变量name的值动态改变。视图 -> 数据,可以理解为VM
,视图驱动数据
。
How?
Vue
中的双向数据绑定由三个重要部分组成:
- 数据层(Model):应用的数据及业务逻辑
- 视图层(View):应用的展示效果,各类UI组件
- 业务逻辑层(ViewModel):框架封装的核心,它负责将数据与视图关联起来
这个分层的架构方案,用专业术语来讲就是MVVM
。
ViewModel
ViewModel干了两件事儿:
- 数据变化后更新视图
- 视图变化后更新数据
它由两个重要部分组成:
- 监听器(Ovserver):对所有数据的属性进行监听
- 解析器(Compiler):对每个元素节点的指令进行扫描解析,根据指令模板替换数据,以及绑定相应的更新函数
动手实现
要做什么
在Vue
中,双向数据绑定的流程为:
- new Vue()执行初始化,对data执行响应化处理,这个过程发生在
Observe
中 - 对模板执行编译,找到其中动态绑定的数据,从data中获取并初始化视图,这个过程发生在
Compile
中 - 定义一个更新函数和
Watcher
,将来对应数据变化时,Watcher
调用更新函数 - 由于data的某个属性在视图中可能出现N次,所以每个属性都需要一个
Dep
来管理多个Watcher
- data中的数据一旦发生变化,首先会找到对应的
Dep
,然后通知这个Dep
下所有的Watcher
执行更新函数
参考下图:
网络异常,图片无法展示
|
Do it
首先定义一个Vue
类, 做三件事
- 数据劫持
- 属性代理
- 模板编译
class Vue { constructor(options) { this.$data = options.data this.$options = options // 数据劫持 observe(this.$data) // 属性代理 proxy(this.$data) // 模板编译 compile(el, this) } } 复制代码
接下来开始实现observe
函数,做三件事
- 递归data,劫持每一个
- getter的时候收集
- setter的时候通知执行
function observe(obj) { // 递归终止条件 if(!obj || typeof obj !== 'object') return // 是空的 && 不是一个对象 Object.keys(obj).forEach( key => { // 当前key对应的value const value = obj[key] // value能到这里,有可能是object,需要递归劫持 observe(value) // 为当前的key所对应的属性添加getter & setter Object.defineProrerty(obj, key, { // 当且仅当该属性的 `enumerable` 键值为 `true` 时,该属性才会出现在对象的枚举属性中。 enumerable: true, // 当且仅当该属性的 `configurable` 键值为 `true` 时,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除。 configurable: true, get() { // 将new出来的Watcher实例进行收集 Dep.target ? dep.addSub(Dep.target) : null }, set(newValue) { if( val !== newValue ) dep.notify() // 通知执行 } }) }) } 复制代码
接下来实现Dep
类,做两件事儿:
- 依赖收集
- 通知执行
//依赖收集的类 class Dep { constructor() { // 所有Watcher实例存在这里 this.subs = [] } // 添加Watcher实例 addSub(watcher) { this.subs.push(watcher) } // 通知Watcher实例执行更新函数 notify() { this.subs.forEach( w => w.update()) } } 复制代码
接下来实现订阅者Watcher
类,做两件事:
- 提供Dep.target
- 提供更新数据的方法
class Watcher { // callback中,记录了当前watcher如何更新自己的文本内容 // 与此同时,需要拿到最新的数据,所以,在new Watcher的时候,需要传递vm进来 // 因为需要知道在vm很多属性中,哪个数据,才是当前自己所需要的数据,所以,new Watcher的时候,需要指定key constructor(vm, key, callback) { this.vm = vm this.key = key this.callback = callback // 把创建的watcher实例,在Dep.addSub时,存进Dep的subs里 Dep.target = this; // 自定义target属性 key.split(".").reduce((newobj, k) => newobj[k], vm); Dep.target = null; } // 发布者通知Watcher更新的方法 update() { const value = this.key.split(".").reduce((newobj, k) => newobj[key], this.vm); this.callback(value) } } 复制代码
最后实现compile
,对HTML结构进行模板编译的方法:
function compile(el, vm) { // 获取elDom元素 vm.$el = document.querySelector(el); // 创建文档碎片,提高Dom操作性能 const fragment = document.createDocumentFragment(); // 取出来 while ((childNode = vm.$el.firstChild)) { fragment.appendChild(childNode); } // 进行模板编译 replace(fragment) // 放进去呀 vm.$el.appendChild(fragement) function replace(node) { // 定义匹配插值表达式的正则 const regMustache = /\{\{\s*(\S+)\s*\}\}/; // \S匹配任何非空白字符 // \s匹配任何空白字符,包括空格、制表符、换页符等等。 // ()非空白字符提取出来,用一个小括号进行分组 // 当前的node节点是一个文本子节点,需要进行替换 if(node.nodeType == 3) { const text = node.textContent // 文本子节点的字符串内容 const execResult = regMustache.exec(text) //为一个数组,索引为0的为{{name}},为1的为name,exec() 方法用于检索字符串中的正则表达式的匹配。 if(execResult) { const value = execResult[1].split(".").reduce((newobj, k) => newobj[k], vm) node.textContent = text.replace(regMustache, value) // 此时,就可以创建Watcher实例,将这个方法存到watcher上,调用update就执行 new Watcher(vm, execResult[1], (newValue) => { node.textContent = text.replace(regMustache, newValue) }); // good good } // 递归结束 return } // 判断当前的node节点是否为input输入框 if(node.nodeType === 1 && node.tagName.toUpperCase() === 'INPUT' ){ // 首先要做v-model,就得先拿到属性节点 const attrs = Array.from(node.attributes); const findResult = attrs.find((x) => x.name === "v-model"); if(findResult) { // 当前有v-model,获取值 const expStr = findResult.value; const value = expStr.split(".").reduce((newobj, k) => newobj[k], vm); node.value = value; // 创建Watcher实例 new Watcher(vm, expStr, (newValue) => { node.value = newValue }) // 监听input事件,拿到文本框最新的值,然后更新到vm上 node.addEventListener("input", e => { const keys = expStr.split(".") const keysLen = keys.length const obj = keys.slice(0, keysLen - 1).reduce((newobj, k) => newobj[k], vm); obj[keys[keysLen - 1]] = e.target.value }) } } // 走到这,证明不是文本节点,递归处理 node.childNodes.forEach( child => replace(child)) } } 复制代码
测试
还是用最开始我们的那个🌰,修改如下:
HTML
<div id="app"> <p>name:<span id="nameBox">{{name}}</span></p> <input v-model="name" id="ipt" type="text" /> <button id="set">Set name</button> </div> 复制代码
JS
const vm = new Vue({ el: "#app", data: { name: "No name yet!", }, }); const setBtn = document.getElementById("set"); setBtn.onclick = function () { vm.name = "Is HoMeTown!!"; }; 复制代码
点击按钮,修改Vue
实例vm
的属性name = 'Is HoMeTown!!'
:
网络异常,图片无法展示
|
可以看到已经成功了!这是单向,然后我们试一试,修改输入框
的内容,上方name
的值不会不跟着改变:
网络异常,图片无法展示
|
SUCCESS!!!!!!!
总结
Vue中,双向数据绑定的原理总结的来说有几点:
- observe 进行数据劫持,getter时添加
Watcher
,setter时通知Watcher.update
- Dep类实现
依赖收集与通知执行
- Watcher类实现
订阅者执行更新
- compile 进行模板编译,解析
v-model
,给input
添加事件
完结~