Vue双向绑定原理
Posted pengnima
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Vue双向绑定原理相关的知识,希望对你有一定的参考价值。
参考文章:https://www.cnblogs.com/kidney/p/6052935.html?utm_source=gold_browser_extension
最简单的 双向绑定
//极简双向绑定
let $input = document.querySelector("input");
let $p = document.querySelector("p");
// 数据
let obj = {};
//通过输入事件改变 数据
$input.addEventListener("input", e => {
obj.txt = e.target.value;
});
//通过 数据拦截 改变 视图
Object.defineProperty(obj, "txt", {
set(val) {
obj._txt = val;
$p.innerText = val;
$input.value = val;
},
});
真正的双向绑定的最终实现
<div id="app">
<input type="text" v-model="text" />
{{ text }}
</div>
<script>
var vm = new Vue({
el: "#app",
data: {
text: "你好,世界",
},
});
</script>
将上面划分成几个子任务:
- 输入框 以及 文本节点与 data 中的数据绑定。 DOM 节点绑定
- 输入框内容变化时,data 中数据同步变化。 view -> model
- data 中的数据变化时,文本节点的内容同步变化。 model -> view
任务 一 思路:
循环#app 下的所有子节点,将其挂载到文档片段中,并进行数据的绑定初始化,最后将文档片段返回到 #app 中
1. 创建一个 Vue 的构造函数
/**
* 创建一个Vue的 构造函数
* @param {Object} options
*/
function Vue(options) {
this.data = options.data; //将用户声明的data挂载到实例上
let id = options.el; // 静态私有属性,内部自己调用
// 任务2
// 劫持监听 data 中的属性
observe(this.data, this);
// 全部挂载到文档片段,在返回到 #app中,第二个参数 就是 Vue的实例,因为 data 已经挂载到实例上了
let fragment = nodeToFragment(document.querySelector(id), this);
document.querySelector(id).appendChild(fragment);
}
2. DomcumentFragment (文档片段)
当需要添加多个 dom 元素时,如果先将这些元素添加到 DocumentFragment 中,再统一将 DocumentFragment 添加到页面,会减少页面渲染 dom 的次数,效率会明显提升。
DocumentFragment (文档片段)可以看作节点容器,它可以包含多个子节点,当我们将它插入到 DOM 中时,只有它的子节点会插入目标节点,所以把它看作一组节点的容器。
使用 DocumentFragment 处理节点,速度和性能远远优于直接操作 DOM。
Vue 进行编译时,就是将挂载目标的所有子节点劫持(真的是劫持,通过 append 方法,DOM 中的节点会被自动删除)到 DocumentFragment 中,经过一番处理后,再将 DocumentFragment 整体返回插入挂载目标。
/**
* 挂载到文档片段中
* @param node ‘#app’
*/
function nodeToFragment(node, vm) {
let fragment = document.createDocumentFragment();
let child = null;
//循环#app的子孩子,直到没有子孩子,退出循环
while ((child = node.firstChild)) {
compile(child, vm); // 调用 数据绑定初始化函数
fragment.appendChild(child); //把 挂载在 #app 上的子节点全部劫到文档片段中
}
return fragment;
}
3. 数据绑定的初始化
/**
* 数据绑定初始化
* @param {htmlDocument} node
* @param {*} vm
*/
function compile(node, vm) {
let reg = /{{(.*)}}/;
//如果节点类型为 元素
if (node.nodeType == 1) {
// 遍历元素的属性,看看是否有 v-model
for (const attr of node.attributes) {
if (attr.nodeName == "v-model") {
let name = attr.nodeValue;
// 任务 2
// 监听 输入事件,并赋值,由于vm 的msg有被数据劫持,所以可以如此
node.addEventListener("input", function (e) {
vm[name] = e.target.value;
});
node.value = vm[name]; // 将 vm 里data的 对应的数据 的值给该 node
// vm.data 被任务2的 vm 替换
//node.value = vm.data[attr.nodeValue]; // 将 vm 里data的 对应的数据 的值给该 node
node.removeAttribute("v-model"); //为了不让在前端看到该属性,赋值完后移除该属性
}
}
// 元素节点的 子节点中如果有 其他类型的,递归
for (const txtNode of node.childNodes) {
compile(txtNode, vm);
}
}
//如果节点类型为 文本
if (node.nodeType == 3) {
// 如果有 mustache 语法
if (reg.test(node.nodeValue)) {
let name = RegExp.$1; // 获取 正则匹配到的值,并去除两边的空白
name = name.trim();
node.nodeValue = vm[name]; //这里节点是文本节点,所以要用 nodeValue
}
}
}
任务 二 思路:
向输入框输入数据时,首先触发 input 事件(或者 keyup、change 事件),在相应的事件处理程序中,我们获取输入框的 value 并赋值给 vm 实例的 msg 属性。
利用 defineProperty 将 data 中的 msg 设置为 vm 的访问器属性,因此给 vm.msg 赋值,就会触发 set 方法。
在 set 方法中主要做两件事,第一是更新属性的值,第二是通知监听者修改数据。
/**
* 劫持函数
* @param {Vue实例}} vm
* @param {属性名} key
* @param {旧值} oldVal
*/
function defineDataProperty(vm, key, oldVal) {
Object.defineProperty(vm, key, {
get() {
return oldVal;
},
set(newVal) {
if (oldVal === newVal) return;
oldVal = newVal;
console.log(oldVal);
},
});
}
/**
* 劫持vm.data中所有的属性
* @param {vm.data} data
* @param {Vue实例} vm
*/
function observe(data, vm) {
Object.keys(data).forEach(key => {
// 【注】要劫持的是 vm.data里的属性,但是实际确实让 vm 来控制。
// 即: vm.msg 等价于 vm.data.msg
defineDataProperty(vm, key, data[key]);
});
}
-
为什么明明是 vm.data.msg 里的数据,却可以用 vm.msg 来操作?
因为在进行 数据劫持,defineProperty 的时候,第一参数是 vm,第二参数为 msg,然后会创建一个 vm 的 msg 属性,所以可以看到 vm 和 vm.data 都有一个 msg 属性。(如果第一参数是 vm.data,那么就没 vm 什么事了) -
有了 数据劫持,就可以在 compile 函数中,给 nodeType 为元素的节点添加 监听 input 事件,当 value 改变时,去改变 vm.msg ,这样就可以 view -> model。数据层的数据就改变了
任务 三 思路:
vm 的 msg 属性发生了变化,但是 其他文本节点也没发生变化。
So,这里用 发布订阅者模式,定义 一对多的关系。让多个订阅者同时监听某一个对象,该对象发生改变时,就让发布者通知所有订阅者。
// 发布订阅者模式
class Dep {
constructor() {
this.subs = [];
}
static target = null;
add(sub) {
this.subs.push(sub);
}
notify() {
this.subs.forEach(v => {
v.update();
});
}
}
class Watcher {
constructor(vm, node, name) {
Dep.target = this;
this.node = node; // 绑定的文本节点
this.name = name; // 绑定的data属性名
this.vm = vm; // Vue实例
this.update(); // 触发 vm 的属性的 get()访问器
Dep.target = null;
}
update() {
// 更新
this.node.nodeValue = this.vm[this.name];
}
}
每个属性都需要有一个 发布者, html 文档中的每个对应的文本节点都需要有一个 订阅者
- 每次 defineProperty 之前就 new 一个发布者
- compile 时候,每次遇到一个 文本节点,就 new 一个订阅者,之后构造函数中会马上将该订阅者与 dep 绑定
如何绑定? new 订阅者时,会将自身赋值给 Dep.target,且会触发 vm 的 get 访问器,该访问器会 dep.add(Dep.target)
总结
主要原理是通过: 数据劫持 + 发布订阅者模式
- 先对数据进行劫持 Object.defineDataProperty ,且 new 一个发布者
- 数据劫持的第一个参数是 vm 实例,第二个参数是 key(data 数据里的 key)
- get 访问器中,如果 Dep.target 有值,那么将其 添加至 dep.subs 中(Dep.target 会在 new 订阅者时有值)
- set 访问器中,dep.notify()
- 解析文档中的元素、文本节点,(内部细节:依次遍历首位子元素,分析节点做处理,并将其转移至文档片段中,最后文档片段在移动到‘#app‘里)
- 当遇到文本节点时,就 new 一个订阅者
- 当遇到元素节点,遍历循环看是否有 v-model 属性,有的话,给其添加一个 input 事件,该事件将 值 value 赋值给了 vm 的属性,这样会触发 vm 属性的 set 访问器,会通知其他订阅者修改数据
以上是关于Vue双向绑定原理的主要内容,如果未能解决你的问题,请参考以下文章