前言
众所周知,Vue.js 的响应式就是用了 数据劫持 + 发布 - 订阅模式,然而深其意,身为小白,每每感觉自己能回答上来,最后去有欲言又止以失败告终;作为经典的面试题之一,大多数情况下,也都只能答到“用Object.defineProperty
…”这种地步
所以写下这篇来为自己梳理一下响应式的思路
什么是 MVVM
Model,View,View-Model就是 mvvm 的的含义;
-
View
通过View-Model
的DOM Listeners
将事件绑定到Model
上 -
Model
则通过Data Bindings
来管理View
中的数据 -
View-Model
从中起到一个连接桥的作用
响应式
依照 mvvm 模型说的,当 model(data)改变时,对应的 view 也会自动改变,这就是响应式
举个????
// html
<div id="app">
<input type="text" v-model='c'>
<p>{{a.b}}</p>
<div>my message is {{c}}</div>
</div>
// js
let mvvm = new Mvvm({
el: '#app',
data: {
a: {b: '这是个例子'},
c: 10,
}
});
原理
当一个
Vue
实例创建时,vue
会遍历data
选项的属性,用Object.defineProperty
将它们转为getter/setter
并且在内部追踪相关依赖,在属性被访问和修改时通知变化。
每个组件实例 / 元素都有相应的watcher
程序实例,它会在组件渲染的过程中把属性记录为依赖,之后当依赖项的setter
被调用时,会通知watcher
重新计算,从而致使它关联的组件得以更新
总结,最重要就是三个步骤
-
数据劫持: 用
Object.defineProperty
为每个数据设置getter/setter
-
数据渲染: 为页面使用到数据的每个组件都添加一个观察者(依赖)
watcher
-
发布订阅: 为每个数据添加订阅者(依赖收集器)
dep
,并将对应的观察者添加进依赖列表,每当数据更新时,订阅者(依赖收集器)通知所有对应观察者(依赖)自动更新对应页面
实现一个 MVVM
思路
通过以上,我们知道了大概的 mvvm 运作原理,对应以上分别实现其功能即可
1、一个 数据监听 器 Observer
,对数据的所有属性进行监听,如有变动就通知订阅者 dep
2、一个 指令解析 / 渲染 器 Compile
,对每个元素节点的指令进行扫描和解析,对应替换数据,以及绑定相应的更新函数
3、一个 依赖 Watcher
类和一个 依赖收集器 dep
类
4、一个 mvvm
类
Mvvm
我们要打造一个 Mvvm,根据之前我们 mvvm 的例子
class Mvvm {constructor(option) {
this.$option = option;
// 初始化
this.init();}
init() {
// 数据监控
observe(this.$option.data);
// 编译
new Compile(this.$option.el);
}
}
这里我只写了一个函数,用类写也是可以的
/* observe 监听函数,监听 data 中的所有数据并进行数据劫持
* @params
* $data - mvvm 实例中的 data
*/
function observe(data) {
// 判断是不是对象
if (typeof data !== 'object') return
// 循环数据
Object.keys(data).forEach(key => {defineReactive(data, key, data[key]);
})
/* 数据劫持 defineReactive
* @param
* obj - 监听对象; key - 遍历对象的 key; val - 遍历对象的 val
*/
function defineReactive(obj, key, val) {
// 递归子属性
observe(val);
// 数据劫持
Object.defineProperty(obj, key, {
enumerable: true, // 可枚举
configurable: true, // 可修改
// 设置 getter 和 setter 函数来对数据劫持
get() {console.log('get!', key, val);
return val
},
set(newVal) {
// 监听新数据
observe(newVal);
console.log('set!', key, newVal);
val = newVal; // 赋值
},
})
}
}
然而单纯这样写是不够的,因为有数组这样的特例:Object.defineProperty
严格上来说是可以监听数组的变化, 但对于数组增加 length
而造成的的变化(原型方法)无法监听到的;
简单来说就是当使用数组原型方法来改写数组的时候,虽然数据被改写了,但是我们无法监听到数组本身的改写;
所以,在 Vue
中重写了数组的原型方法;
我们也来实现这个改写:
// 先获取原型上的方法, 然后创造原型重写
let methods = ['pop', 'shift', 'unshift', 'sort', 'reverse', 'splice', 'push'];
let arrProto = Array.prototype;
let newArrProto = Object.create(arrProto);
methods.forEach(method => {newArrProto[method] = function (...args) {console.log('arr change!')
// 用 function 定义该函数使得 this 指向调用的数组;如果用箭头函数 this 会指向 window
arrProto[method].call(this, ...args)
}
})
// 数据劫持
function observe(data) {
// 判断是否是数组类型
+ if (Array.isArray(data)) {
+ // 将数组数据原型指针指向自己定义好的原型对象
+ data.__proto__ = newArrProto;
+ return
+ }
...
}
指令解析
/* Compile 类,解析 dom 中所有节点上的指令
* @params
* $el - 需要渲染的标签
* $vm - mvvm 实例
*/
class Compile {constructor(el, vm) {
this.vm = vm;
this.$el = document.querySelector(el); // 挂载到编译实例方便操作
this.frag = document.createDocumentFragment(); // 运用 fragment 类进行 dom 操作以节省开销
this.reg = /\{\{(.*?)\}\}/g;
// 将所有 dom 节点移入 frag 中
while (this.$el.firstChild) {
let child = this.$el.firstChild;
this.frag.appendChild(child);
}
// 编译元素节点
this.compile(this.frag);
this.$el.appendChild(this.frag);
}
}
这样一个编译函数框架就写好了,然后需要对里面的详细函数功能进行补充;
因为我们需要在循环节点的时候识别文字节点上的 {{xxx}}
插值。。。
class Compile {
...
// 编译
compile(frag) {
// 遍历 frag node 节点
Array.from(frag.childNodes).forEach(node => {
let txt = node.textContent;
// 编译文本 {{}}
if (node.nodeType === 3 && this.reg.test(txt)) {this.compileTxt(node, RegExp.$1);
}
// 递归子节点
if (node.childNodes && node.childNodes.length) this.compile(node)
})
}
// 编译文字节点
compileTxt(node, key) {node.textContent = typeof val === 'undefined' ? '' : val;}
...
}
到这里,初次渲染页面的时候,mvvm
已经可以把实例里面的数据渲染出来了,但是还不够,因为我们需要她可以实时自动更新
发布订阅
当一个数据在 node 上有多个节点 / 组件同时引用的时候,该数据更新时,我们如何一个个的去自动更新页面?这就需要用到发布订阅模式了;
我们可以在编译的时候为页面使用到数据的每个组件都添加一个观察者(依赖)watcher
;
再为每个数据添加一个订阅者(依赖收集器)dep
,并将对应的观察者(依赖)watcher
添加进依赖列表,每当数据更新时,订阅者(依赖收集器)通知所有对应观察者(依赖)自动更新对应页面
所以需要创建一个 Dep,它可以用来收集依赖、删除依赖和向依赖发送消息
Dep
class Dep {constructor() {
// 创建一个数组,用来保存所有的依赖的路径
this.subs = [];}
// 添加依赖 @sub - 依赖(watcher 实例)addSub(sub) {this.subs.push(sub);
}
// 提醒发布
notify() {this.subs.forEach(el => el.update())
}
}
Watcher
// 观察者 / 依赖
class Watcher {constructor(vm, key, cb) {
this.vm = vm;
this.key = key;
this.cb = cb;
// 初始化时获取当前数据值
this.value = this.get();}
/* 获取当前值
* @param $boolean: true - 数据更新 / false - 初始化
* @return 当前的 vm[key]
*/
get(boolean) {
Dep.target = boolean ? null : this;
// 触发 getter,将自己添加到 dep 中
let value = UTIL.getVal(this.vm, this.key);
Dep.target = null;
return value;
}
update() {
// 取得最新值;// 只有初始化的时候触发,更新的时候不触发 getter
let nowVal = this.get(true);
// 对比旧值
if (this.value !== nowVal) {console.log('update')
this.value = nowVal;
this.cb(nowVal);
}
}
}
再回到 Compile
中,我们需要在第一遍渲染的时候还将为该组件创建一个 wacther
实例;
然后再将渲染更新的函数放到 watcher
的cb
中;
class Compile{
...
// 编译文字节点
compileTxt(node, key) {+ this.bind(node, this.vm, key, 'text');
}
+ // 绑定依赖
+ bind(node, vm, key, dir) {+ let updateFn = this.update(dir);
+ // 第一次渲染
+ updateFn && updateFn(node, UTIL.getVal(vm, key));
+ // 设置观察者
+ new Watcher(vm, key, (newVal) => {
+ // cb 以后的渲染
+ updateFn && updateFn(node, newVal);
+ });
+ }
+ // 更新
+ update(dir) {+ switch (dir) {
+ case 'text': // 文本更新
+ return (node, val) => node.textContent = typeof val === 'undefined' ? '' : val;
+ break;
+ }
+ }
...
}
完成这些,回到原来 defineReactive
中,对其进行修改,为每个数据都增添一个 dep
实例;
并在 getter
中为 dep
实例添加依赖;在 setter
中添加 dep
实例的发布函数;
function observe(data) {
...
function defineReactive(obj, key, val) {
// 递归子属性
observe(val);
// 添加依赖收集器
+ let dep = new Dep();
// 数据劫持
Object.defineProperty(obj, key, {
enumerable: true, // 可枚举
configurable: true, // 可修改
get() {console.log('get!', key, val);
// 添加订阅
+ Dep.target && dep.addSub(Dep.target);
return val
},
set(newVal) {observe(newVal);
console.log('set!', key, newVal);
val = newVal;
// 发布更新
+ dep.notify(); // 触发更新},
})
}
}
至此,一个简易的响应式 Mvvm
已经实现了,每当我们修改数据的时候,其对应的页面内容也会自动重新渲染更新;
那么双向绑定又是如何实现的呢?
双向绑定
双向绑定就是在 Compile
的时候,对 node
的元素节点进行识别,如果有 v-model
指令,则对该元素的 value
值和响应数据进行绑定,并在 update
函数中添加对应的 value
更新方法
class Compile {
// 编译
compile(frag) {
// 遍历 frag node 节点
Array.from(frag.childNodes).forEach(node => {
let txt = node.textContent;
// 编译元素节点
+ if (node.nodeType === 1) {+ this.compileEl(node);
+ // 编译文本 {{}}
} else if (node.nodeType === 3 && this.reg.test(txt)) {this.compileTxt(node, RegExp.$1);
}
// 递归子节点
if (node.childNodes && node.childNodes.length) this.compile(node)
})
}
...
+ compileEl(node) {
+ // 查找指令 v-xxx
+ let attrList = node.attributes;
+ if (!attrList.length) return;
+ [...attrList].forEach(attr => {
+ let attrName = attr.name;
+ let attrVal = attr.value;
+ // 判断是否带有‘v-’指令
+ if (attrName.includes('v-')) {
+ // 编译指令 / 绑定 标签 value 和对应 data
+ this.bind(node, this.vm, attrVal, 'model');
+ let oldVal = UTIL.getVal(this.vm, attrVal); // 获取 vm 实例 当前值
+ // 增添 input 事件监听
+ node.addEventListener('input', e => {
+ let newVal = e.target.value; // 获取输入的新值
+ if (newVal === oldVal) return;
+ UTIL.setVal(this.vm, attrVal, newVal);
+ oldVal = newVal;
+ })
+ }
+ });
+ }
...
// 更新
update(dir) {switch (dir) {
case 'text': // 文本更新
return (node, val) => node.textContent = typeof val === 'undefined' ? '' : val;
break;
+ case 'model': // model 指令更新
+ return (node, val) => node.value = typeof val === 'undefined' ? '' : val;
+ break;
}
}
}
简单来说,双向数据绑定就是给有 v-xxx
指令组件添加 addEventListner
的监听函数,一旦事件发生,就调用 setter
,从而调用dep.notify()
通知所有依赖 watcher
调用 watcher.update()
进行更新
总结
动手实现 Mvvm 的过程如下
- 利用
Object.defineProperty
的get
和set
进行数据劫持 - 利用
observe
遍历 data 数据来进行监听,并为数据创建dep
实例来收集依赖 - 利用
Compile
对dom
中的所有节点进行编译,并为组件添加wathcer
实例 - 通过
dep
&watcher
发布订阅模式实现数据与视图同步
项目源码
欢迎移步项目源码
最后
感谢阅读
欢迎指正、探讨
???? 各位喜欢的看官,欢迎 star ????