模拟 Vue.js 响应式原理
数据驱动
数据响应式、双向绑定、数据驱动
数据响应式
- 数据模型仅仅是普通的 javascript 对象,而当我们修改数据时,视图会自动更新,避免了繁琐的 DOM 操作,提高了开发效率;
双向绑定
- 数据改变,视图随之改变;视图改变,数据也会随之改变;
- 我们可以使用 v-model 在表单元素上创建双向绑定;
数据驱动:Vue 最独特的特性之一
- 开发过程中仅需关注数据本身,不需要关心数据是如何渲染到视图;
数据响应式的核心原理
Vue 2.x:defineProperty
- Vue 2.x 深入响应式原理
- MDN - Object.defineProperty
- 浏览器兼容 IE8 以上(不兼容 IE8)
// 模拟 Vue 中的 data 选项;
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>defineProperty</title>
</head>
<body>
<div id="app">defineProperty</div>
<script>
// let data = { msg: 'defineProperty' };
// let vm = {};
// Object.defineProperty(vm, key, {
// // 可枚举的(可遍历)
// enumerable: true,
// // 可配置的(可以使用 delete 删除,可以通过 defineProperty 重新定义)
// configurable: true,
// get() {
// console.log('get: ', data.msg);
// return data.msg;
// },
// set(newVal) {
// if (newVal === data.msg) {
// return;
// }
// data.msg = newVal;
// document.getElementById('app').textContent = data.msg;
// }
// });
let data = {
msg: 'defineProperty',
count: 11
};
let vm = {};
proxyData(data);
function proxyData(data) {
Object.keys(data).map(key => {
if (Object.hasOwnProperty.call(data, key)) {
const ele = data[key];
Object.defineProperty(vm, key, {
// 可枚举的(可遍历的)
enumerable: true,
// 可配置的(可以通过 delete 删除,可以通过 defineProperty 重新定义)
configurable: true,
get() {
console.log(`get ${key}:${data[key]}`);
return data[key];
},
set(newVal) {
if (!newVal || newVal === data[key]) return;
data[key] = newVal;
document.querySelector('#app').textContent = data[key];
console.log(`set ${key}:${data[key]}`);
}
});
}
});
}
</script>
</body>
</html>
Vue 3.x:Proxy
- MDN - Proxy;
- 直接监听对象,而非属性;
- ES6 中新增,IE 不支持,性能由浏览器优化;
// 模拟 Vue 中的 data 选项;
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Proxy</title>
</head>
<body>
<div id="app">Proxy</div>
<script>
let data = {
msg: 'Proxy',
count: 111
};
// 模拟 Vue 实例
let vm = new Proxy(data, {
// 执行代理行为的函数
// 当访问 vm 的成员时会执行
get(target, key) {
console.log(`get ${key}:${target[key]}`);
return target[key];
},
// 当设置 vm 的成员时会执行
set(target, key, newVal) {
console.log(`set ${key}:${target[key]}`);
if (target[key] === newVal) {
return;
}
target[key] = newVal;
document.querySelector('#app').textContent = target[key];
}
});
</script>
</body>
</html>
总结
- 代码层面上:Proxy 比 defineProperty 更加简洁;
- 性能层面上:Proxy 性能由浏览器进行优化,因此 Proxy 比 defineProperty 性能更优;
发布/订阅模式
- 订阅者
- 发布者
- 信号中心(事件中心)
我们假定,存在一个”信号中心“,某个任务执行完成,就向信号中心”发布“(publish)一个信号,其他任务可以向信号中心“订阅”(subscribe)这个信号,从而知道什么时候自己可以开始执行。这就叫做”发布/订阅模式“(publish-subscribe pattern)
Vue 的自定义事件
API:$on,$emit...
官方说明:v2.cn.vuejs.org/v2/guide/co…
let vm = new Vue();
vm.$on('dataChange', ()=> {
console.log('dataChange');
});
vm.$on('dataChange', ()=> {
console.log('dataChange1');
});
vm.$emit('dataChange');
兄弟组建通信过程
// eventBus.js
// 事件中心
let eventHub = new Vue()
// ComponentA.vue
// 发布者
addTodo: function () {
// 发布消息(事件)
eventHub.$emit('add-todo'), { Text: this.newTodoText })
this.newTodoText = ''
}
// ComponentB.vue
// 订阅者
created: function () {
// 订阅消息(事件)
eventHub.$on('add-todo', this.addTodo)
}
模拟 Vue 自定义事件的实现
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>发布订阅模式</title>
</head>
<body>
<script>
// 发布者
class EventEmitter {
constructor() {
// 订阅者 { eventType: [ handler1, handler2 ] }
this.subs = Object.create(null);
}
// 订阅通知
$on(eventType, handler) {
this.subs[eventType] = this.subs[eventType] || []
this.subs[eventType].push(handler)
}
// 发布通知
$emit(eventType) {
if (this.subs[eventType]) {
this.subs[eventType].forEach(handler => {
handler()
});
}
}
}
let em = new EventEmitter();
em.$on('click', () => {
console.log('on click1');
});
em.$on('click', () => {
console.log('on click2')
});
</script>
</body>
</html>
观察者模式
观察者(订阅者)- Watcher
- update():当事件发生时,具体要做的事情;
目标(发布者)- Dep
- subs 数组:存储所有的观察者;
- addSub():添加观察者;
- notify():当事件发生,调用所有观察者的 update() 方法;
没有事件中心
模拟观察者模式实现
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>观察者模式</title>
</head>
<body>
<script>
// 1. 发布者(目标)
class Dep {
// subs:存储所有的观察者;
constructor() {
this.subs = [];
}
// addSub:添加一个观察者;
addSub(sub) {
if (sub && sub.update) {
this.subs.push(sub);
}
}
// notify:当事件发生的时候,调用所有观察者的 update 方法;
notify() {
this.subs.forEach((sub) => {
sub.update();
});
}
}
// 2. 订阅者(观察舌)
class Watcher {
constructor(name) {
this.name = name || "watcherX";
}
update() {
console.log(`update: ${this.name}`);
}
**}**
let dep = new Dep();
let watcher1 = new Watcher("watcher1");
let watcher2 = new Watcher("watcher2");
let watcher3 = new Watcher("watcher3");
dep.addSub(watcher1);
dep.addSub(watcher2);
dep.addSub(watcher3);
</script>
</body>
</html>
观察者模式 与 发布/订阅者模式 对比
- 观察者模式:由具体目标调度,比如当事件触发时 Dep(目标-发布者) 就会去调用 Watcher(观察者-订阅者)的方法,所以观察者模式的订阅者与发布者之间是存在依赖的;
- 发布/订阅模式:由统一的调度中心调用,因此发布者和订阅者不需要知道对方的存在;
Vue 响应式原理模拟
整体分析
-
Vue 的基本结构
-
打印 Vue 实例观察
-
整体结构
- Vue
- 把 data 中的成员注入到 Vue 实例,并且把 data 中的成员转成 getter/setter;
- Observer
- 监听 data 中所有属性的变化;
- Compiler
- 解析指令/差值表达式;
Vue
功能
- 负责接收初始化的参数(选项);
- 负责把 data 中的属性注入到 Vue 实例,转换成 getter/setter;
- 负责调用 observer 监听 data 中所有属性的变化;
- 负责调用 compiler 解析指令/差值表达式;
结构
classDiagram
class Vue {
+ $options
+ $el
+ $data
- _proxyData()
}
代码
js/vue.js
class Vue {
constructor(options) {
// 1. 通过属性保存选项的数据;
this.$optinos = options || {};
this.$data = options.data || {};
this.$el =
typeof options.el === 'string'
? document.querySelector(options.el)
: options.el;
// 2. 把 data 中的成员转换成 getter/setter,注入到 Vue 示例中;
this._proxyData(this.$data);
// 3. 调用 observer 对象,监听数据的变化;
new Observer(this.$data);
// 4. 调用 compiler 对象,解析指令和差值表达式;
new Compiler(this);
}
_proxyData(data) {
// 遍历 data 中的所有属性;
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get() {
return data[key];
},
set(newVal) {
if (newVal === data[key]) return;
data[key] = newVal;
}
});
});
// 把 data 的属性注入到 Vue 示例中;
}
}
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>1. vue 基础结构</title>
</head>
<body>
<div id="app">
<h1>差值表达式</h1>
<h3>{{ msg }}</h3>
<h3>{{ count }}</h3>
<h1>v-text</h1>
<div v-text="msg"></div>
<h1>v-model</h1>
<input type="text" v-model="msg" />
<input type="text" v-model="count" />
</div>
<script src="./js/vue.js"></script>
<script>
let vm = new Vue({
el: '#app',
data: {
msg: 'Hello Vue',
count: 20
}
});
</script>
</body>
</html>
observer
功能
- 负责把 data 选项中的属性转换成响应式数据;
- data 中的某个属性也是对象,把该属性转换成响应式数据;
- 数据变化发送通知;
结构
classDiagram
class Observer {
+ walk(data)
+ defineReactive(obj, key, value)
}
代码
js/observer.js
class Observer {
constructor(data) {
this.walk(data);
}
/**
* 遍历 data 每个属性,调用 defineReactive 方法,把每个属性转换成响应式数据(getter、setter)
*
* @param { object } data vm 的 data 对象
*/
walk(data) {
// 1. 判断 data 是否是对象;
if (!data || typeof data !== 'object') return;
// 2. 遍历 data 对象的所有属性;
Object.keys(data).forEach(key => {
this.defineReactive(data, key, data[key]);
});
}
/**
* 调用 defineProperty 方法,把 object 中的 key 对应的属性转换成响应式数据(getter、setter)
*
* @param { object } obj data 对象
* @param { string } key data 对象的属性名
* @param { any } val data[key],可能是 object、基础数据类型
*/
defineReactive(obj, key, val) {
let _this = this;
// 如果 val 是对象,把 val 内部的属性转换成响应式数据(getter、settter)
this.walk(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
return val;
},
set(newVal) {
if (newVal === val) {
return;
}
val = newVal;
_this.walk(newVal);
// 发送通知;
}
});
}
}
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>1. vue 基础结构</title>
</head>
<body>
<div id="app">
<h1>差值表达式</h1>
<h3>{{ msg }}</h3>
<h3>{{ count }}</h3>
<h1>v-text</h1>
<div v-text="msg"></div>
<h1>v-model</h1>
<input type="text" v-model="msg" />
<input type="text" v-model="count" />
</div>
<script src="./js/observer.js"></script>
<script src="./js/vue.js"></script>
<script>
let vm = new Vue({
el: '#app',
data: {
msg: 'Hello Vue',
count: 20,
lang: {
js: '5.0',
nodejs: '2.0',
java: '3.9',
python: '1.1'
},
person: 'syc'
}
});
vm.person = {
name: 'syc',
age: 32
};
console.log(vm);
</script>
</body>
</html>
compiler
功能
- 负责编译模板,解析指令/差值表达式;
- 负责页面的首次渲染;
- 当数据变化后重新渲染视图;
其实就是 dom 操作
结构
classDiagram
class Compiler {
+ el
+ vm
+ compile(el)
+ compileElement(node)
+ compileText(node)
+ isDirective(attrName)
+ isTextNode(node)
+ isElementNode(node)
}
代码
js/compiler.js
class Compiler {
constructor(vm) {
this.el = vm.$el;
this.vm = vm;
this.compile(this.el);
}
/**
* 编译模板,处理文本节点和元素节点
*
* @param { object } el dom 对象
*/
compile(el) {
let childNodes = el.childNodes;
Array.from(childNodes).forEach(node => {
// 处理文本节点
if (this.isTextNode(node)) {
this.compileText(node);
}
// 处理元素节点
if (this.isElementNode(node)) {
this.compileElement(node);
}
// 判断 node 节点是否有子节点,如果有子节点,要递归调用 compile
if (node.childNodes && node.childNodes.length) {
this.compile(node);
}
});
}
/**
* 编译元素节点,处理指令
*
* @param { object } node 元素节点
*/
compileElement(node) {
// console.log(node.attributes);
// 遍历所有的属性节点
Array.from(node.attributes).forEach(attr => {
// 判断是否是指令
let attrName = attr.name;
if (this.isDirective(attrName)) {
// v-text ---> text
attrName = attrName.substr(2);
let key = attr.value;
this.update(node, key, attrName);
}
});
}
update(node, key, attrName) {
let updateFn = this[`${attrName}Updater`];
updateFn && updateFn(node, this.vm[key]);
}
// 处理 v-text 指令
textUpdater(node, value) {
node.textContent = value;
}
// 处理 v-model 指令
modelUpdater(node, value) {
node.value = value;
}
/**
* 编译文本节点,处理指令
*
* @param { object } node 文本节点
*/
compileText(node) {
// console.dir(node);
// {{ msg }}
let reg = /\{\{(.+?)\}\}/;
let val = node.textContent;
if (reg.test(val)) {
let key = RegExp.$1.trim();
node.textContent = val.replace(reg, this.vm[key]);
}
}
/**
* 判断元素属性是否是指令
*
* @param { string } attrName 属性名称
*/
isDirective(attrName) {
return attrName.startsWith('v-');
}
/**
* 判断节点是否是文本节点
*
* @param { object } node 节点
*/
isTextNode(node) {
return node.nodeType === 3;
}
/**
* 判断节点是否是元素节点
*
* @param { object } node 节点
*/
isElementNode(node) {
return node.nodeType === 1;
}
}
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>1. vue 基础结构</title>
</head>
<body>
<div id="app">
<h1>差值表达式</h1>
<h3>{{ msg }}</h3>
<h3>{{ count }}</h3>
<h1>v-text</h1>
<div v-text="msg"></div>
<h1>v-model</h1>
<input type="text" v-model="msg" />
<input type="text" v-model="count" />
</div>
<script src="./js/compiler.js"></script>
<script src="./js/observer.js"></script>
<script src="./js/vue.js"></script>
<script>
let vm = new Vue({
el: '#app',
data: {
msg: 'Hello Vue',
count: 20,
lang: {
js: '5.0',
nodejs: '2.0',
java: '3.9',
python: '1.1'
},
person: 'syc'
}
});
vm.person = {
name: 'syc',
age: 32
};
vm.person.name = {
first: 'song',
second: 'yuncheng'
};
</script>
</body>
</html>
Dep(Dependency)
功能
- 收集依赖,添加观察者(watcher);
- 通知所有观察者;
结构
classDiagram
class Dep {
+ subs
+ addSub(sub)
+ notify()
}
代码
js/dep.js
class Dep {
constructor() {
// subs 存储所有的观察者
this.subs = [];
}
/**
* 添加观察者
*
* @param { object } sub 观察者对象
*/
addSub(sub) {
if (sub && sub.update) {
this.subs.push(sub);
}
}
/**
* 发送通知
*/
notify() {
this.subs.forEach(sub => {
sub.update();
});
}
}
js/observer.js
class Observer {
constructor(data) {
this.walk(data);
}
/**
* 遍历 data 每个属性,调用 defineReactive 方法,把每个属性转换成响应式数据(getter、setter)
*
* @param { object } data vm 的 data 对象
*/
walk(data) {
// 1. 判断 data 是否是对象;
if (!data || typeof data !== 'object') return;
// 2. 遍历 data 对象的所有属性;
Object.keys(data).forEach(key => {
this.defineReactive(data, key, data[key]);
});
}
/**
* 调用 defineProperty 方法,把 object 中的 key 对应的属性转换成响应式数据(getter、setter)
*
* @param { object } obj data 对象
* @param { string } key data 对象的属性名
* @param { any } val data[key],可能是 object、基础数据类型
*/
defineReactive(obj, key, val) {
let _this = this;
// 负责收集依赖,并发送通知
let dep = new Dep();
// 如果 val 是对象,把 val 内部的属性转换成响应式数据(getter、settter)
this.walk(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
// 收集依赖(添加 watcher)
Dep.target && dep.addSub(Dep.target);
return val;
},
set(newVal) {
if (newVal === val) {
return;
}
val = newVal;
_this.walk(newVal);
// 发送通知;
dep.notify();
}
});
}
}
Watcher
功能
- 当数据变化触发依赖,dep 通知所有的 watcher 示例更新视图;
- 自身实例化的时候往 dep 对象中添加自己;
结构
classDiagram
class Watcher {
+ vm
+ key
+ cb
+ oldValue
+ update()
}
代码
js/watcher.js
class Watcher {
constructor(vm, key, cb) {
this.vm = vm;
// data 中的属性名称
this.key = key;
// 回调函数:负责更新视图
this.cb = cb;
// 把 watcher 对象记录到 Dep 类的静态属性 target
Dep.target = this;
// 出发 get 方法,再 get 方法中会调用 addSub
this.oldValue = vm[key];
// 设置完以后,要将 Dep 类中的 target 静态属性置 null,避免后续重复调用
Dep.target = null;
}
/**
* 当数据发生变化的时候更新视图
*/
update() {
let newValue = this.vm[this.key];
if (this.oldValue === newValue) {
return;
}
this.cb(newValue);
}
}
创建 watcher 对象
更新视图的操作其实就是操作 dom,所有的 dom 操作都在 compiler.js 中进行(把数据渲染到 dom 的位置,即处理指令和差值表达式的位置)
编译文本节点,处理差值表达式
js/compiler.js
class Compiler {
...
/**
* 编译文本节点,处理差值表达式
*
* @param { object } node 文本节点
*/
compileText(node) {
// console.dir(node);
// {{ msg }}
let reg = /\{\{(.+?)\}\}/;
let val = node.textContent;
if (reg.test(val)) {
let key = RegExp.$1.trim();
node.textContent = val.replace(reg, this.vm[key]);
// 创建 watcher 对象,
new Watcher(this.vm, key, newVal => {
node.textContent = newVal;
});
}
}
...
}
编译元素节点,处理指令
js/compiler.js
class Compiler {
...
/**
* 编译元素节点,处理指令
*
* @param { object } node 元素节点
*/
compileElement(node) {
// console.log(node.attributes);
// 遍历所有的属性节点
Array.from(node.attributes).forEach(attr => {
// 判断是否是指令
let attrName = attr.name;
if (this.isDirective(attrName)) {
// v-text ---> text
attrName = attrName.substr(2);
let key = attr.value;
this.update(node, key, attrName);
}
});
}
update(node, key, attrName) {
let updateFn = this[`${attrName}Updater`];
updateFn && updateFn.call(this, node, this.vm[key], key);
}
// 处理 v-text 指令
textUpdater(node, val, key) {
node.textContent = val;
new Watcher(this.vm, key, newVal => {
node.textContent = newVal;
});
}
// 处理 v-model 指令
modelUpdater(node, val, key) {
node.value = val;
new Watcher(this.vm, key, newVal => {
node.value = newVal;
});
}
...
}
测试
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>1. vue 基础结构</title>
</head>
<body>
<div id="app">
<h1>差值表达式</h1>
<h3>{{ msg }}</h3>
<h3>{{ count }}</h3>
<h1>v-text</h1>
<div v-text="msg"></div>
<h1>v-model</h1>
<input type="text" v-model="msg" />
<input type="text" v-model="count" />
</div>
<script src="./js/dep.js"></script>
<script src="./js/watcher.js"></script>
<script src="./js/compiler.js"></script>
<script src="./js/observer.js"></script>
<script src="./js/vue.js"></script>
<script>
let vm = new Vue({
el: '#app',
data: {
msg: 'Hello Vue',
count: 20,
lang: {
js: '5.0',
nodejs: '2.0',
java: '3.9',
python: '1.1'
},
person: 'syc'
}
});
vm.person = {
name: 'syc',
age: 32
};
vm.person.name = {
first: 'song',
second: 'yuncheng'
};
</script>
</body>
</html>
双向绑定
数据发生变化自动更新视图,视图发生变化自动更新数据
代码
class Compiler {
...
// 处理 v-model 指令
modelUpdater(node, val, key) {
node.value = val;
new Watcher(this.vm, key, newVal => {
node.value = newVal;
});
// 双向绑定
node.addEventListener('input', () => {
this.vm[key] = node.value;
});
}
...
}
总结
问题
-
给属性重新赋值成对象,是否是响应式的?是
-
给 Vue 实例新增一个成员是否是响应式的?否,参考 Vue.set( target, propertyName/index, valu…
-
Vue 中操作 data 的数据方法那些可以触发视图更新?
-
可以触发视图更新的方法有:push()、pop()、shift()、unshift()、splice()、sort()、reverse()以上这些方法会改变被操作的数组; filter()、concat()、 slice() 这些方法不会改变被操作的数组,而是返回一个新的数组;
-
不能触发视图更新的方法有: 利用索引直接设置一个数组项,如:this.array[index] = newValue;直接修改数组的长度,如:this.array.length = newLength
-
有以下两种方法可以解决不能触发视图更新:
- 可以用 this.$set(this.array, index, newValue) 或 this.array.splice(index, 1, newValue);
- 可以用 this.array.splice(newLength);
-