大前端百科全书,前端界的百科全书,记录前端各相关领域知识点,方便后续查阅及面试准备
关键词:发布订阅、观察者、数据劫持、依赖收集、Object.defineProperty、Proxy、Observer、Compiler、Watcher、Dep……
- vue的双向绑定的原理是什么?
- Vue是如何收集依赖的?
- 说一下Vue template到render的过程
vue的双向绑定的原理是什么?
vue.js是采用数据劫持结合发布者-订阅者模式的方式,通过ES5提供的Object.defineProperty()方法来劫持(监视)各个属性的setter,getter,在数据变动的时发布消息给订阅者,触发相应的监听回调。并且,由于是在不同的数据上触发同步,可以精确的将变更发送发送给绑定的视图,而不是对所有的数据都执行一次检测。
具体的步骤:
-
需要observer的数据对象进行递归遍历,包括
子属性对象的属性,都加上getter和setter这样的话,给这个对象的某个值赋值,就会触发setter,那么就能监听到了数据变化 -
compile解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图
-
Watcher订阅者是Observer和compile之间通信桥梁,主要做的事情是:
- 在自身实例化时往属性订阅器(dep)里面添加自己
- 自身必须有一个update()的方法
- 待属性变动dep.notify()通知的时候,能调用自身的update()方法,并触发compile中绑定的回调,则功成身退
-
MVVM作为数据绑定的入口,整合observer、compile和watcher三者,通过observer来监听自己的model数据变化,通过compile来编译模板指令,最终利用watcher搭起的observer和compile之间的通信桥梁,达到数据变化 --> 试图更新;视图交互变化(input) --> 数据model变更的双向绑定效果
版本比较
- vue是基于依赖收集的双向绑定
- 3.0版本之前使用Object.definePropetry
- 3.0新版使用Proxy
1.基于数据劫持/依赖收集 的双向绑定的优点
- 不需要显示的调用,Vue利用数据劫持+发布订阅,可以直接通知变化并且驱动视图
- 直接得到精确的变化数据,劫持了属性setter,当属性值改变,我们可以精确的获取变化的内容newValue,不需要额外的diff操作
2.Object.defineProperty的缺点
- 不能监听数组:因为数组没有getter和setter,因为数组长度不确定,如果太长性能负担太大
- 只能监听属性,而不是整个对象,需要遍历循环属性
- 只能监听属性变化,不能监听属性的删减
3.proxy的好处
- 可以监听数组
- 监听整个对象不是属性
- 13种来截方法,强大很多
- 返回新对象而不是直接修改原对象,更符合immutable;
4.proxy的缺点
- 兼容性不好,而且无法用polyfill磨平;
Vue是如何收集依赖的?
在初始化vue每个组件时,会对组件的data进行初始化,就会将由普通对象变成响应式对象,在这个过程中便会进行依赖收集的相关逻辑
function defieneReactive(obj, key, val){
const dep = new Dep();
//...
Object.defineProperty(obj, key,{
//...
get: function reactiveGetter(){
if(Dep.target){
dep.depend();
//...
}
return val
}
})
}
上面的代码主要说明: const dep=new Dep() 实例化一个Dep实例,然后能在get函数中通过dep.depend() 进行依赖收集
Dep
Dep是整个依赖收集的核心
class Dep {
static target;
subs;
constructor () {
...
this.subs = [];
}
addSub (sub) { // 添加
this.subs.push(sub)
}
removeSub (sub) { // 移除
remove(this.sub, sub)
}
depend () { // target添加
if(Dep.target){
Dep.target.addDep(this)
}
}
notify () { // 响应
const subs = this.subds.slice();
for(let i = 0;i < subs.length; i++){
subs[i].update()
}
}
}
Dep是一个class,里面有一个静态属性static, 指向全局唯一的Watcher,保证了同一时间全局只有一个watcher被计算,另一个属性subs则是一个watcher数组,所以dep实际上就是对watcher的管理
watcher
class Watcher {
getter;
...
constructor (vm, expression){
...
this.getter = expression;
this.get();
}
get () {
pushTarget(this);
value = this.getter.call(vm, vm)
...
return value
}
addDep (dep){
...
dep.addSub(this)
}
...
}
function pushTarget (_target) {
Dep.target = _target
}
watcher是一个class,定义了一些方法,其中和依赖收集相关的函数是get、addDep
过程
在实例化Vue时,依赖收集的相关过程
初始化状态 initState,这中间便会通过defineReactive将数据变成响应式对象,其中的getter部分便是用来收集的。
初始化最终会走mount过程,其中会实例化watcher,进入watcher中,便会执行this.get()方法
updateComponent = ()=>{
vm._update(vm._render())
}
new Watcher(vm,updateComponent)
get方法中的pushTarget实际上就是把Dep.target赋值为当前的watcher,this.getter.call(vm,vm),这里的getter会执行vm._render()方法,在这个过程中便会触发数据对象的getter
那么每个对象值的getter都持有一个dep,在触发getter的时候会调用dep.depend()方法,也就是会执行Dep.target.addDep(this)
刚才Dep.target已经被赋值为watcher,于是就执行addDep方法,然后走到dep.addSub()方法,便将当前的watcher订阅到这个数据持有的dep的subs中,这个是为了后续数据变化的时候能通知到哪些subs做准备
所以在vm._render()过程中,会触发所有的数据的getter,这样已经完成了一个依赖收集的过程
说一下Vue template到render的过程
过程解析
vue的模板编译过程如下:
template - ast - render函数
vue在模板编译中执行compileToFunctions将template转化成render函数
compileToFunctions的主要核心点:
- 调用parse方法将template转化为ast树(抽象语法树)
parse的目的:是把template转化为ast树,它是一种用js对象的形式来描述整个模板。
解析过程:利用正则表达式顺序解析模板,当解析到开始标签、闭合标签、文本的时候都会分别执行对应的回调函数,来达到构成ast树
ast元素节点(type)总共三种类型:普通元素--1,表达式--2,纯文本--3
- 对静态节点做优化
这个过程主要分析出哪些是静态节点,在其做一个标记,为后面的更新渲染可以直接跳过,静态节点做优化
深度遍历AST, 查看每个子树的节点元素是否为静态节点或者静态节点根。如果为静态节点,他们生成的dom永远不会改变,这对运行时模板更新起到优化作用
- 生成代码 generate
将 ast 抽象语法树编译成render字符串并将静态部分放到staticRender中,最后通过new Function(render)生成render函数
实现双向数据绑定
index.js作用
- 数据劫持 _proxyData,把data的数据同步到vm上:this.data.name => this.name
- new Observer 数据劫持
- compiler编译模板,生成render function字符串:with(code)……
observer.js
- 递归遍历,数据劫持 defineReactive,创建 dep = new Dep()
- Object.property get属性的时候,添加到dep实例的subs上:dep.addSub(Dep.target)
- Object.property set属性的时候,调用dep.notify通知subs的所有Watcher进行update => patch方法,dom更新
compiler.js
- 编译template
- for循环遍历childNodes(文本、元素节点)
- 创建观察者 new Watcher(),将实例挂载到 Dep.target
watcher.js
- Dep.target = this,把观察者watcher放在Dep.target上
- update方法,更新dom,后面经过patch方法
dep.js 订阅者
- addSub,将watcher push进subs中
- notify,通知subs中的所有watcher,调用update方法
demo代码
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>Document</title>
</head>
<body>
<div id="app">
<p>msg:{{ msg }}</p>
<p>age:{{ age }}</p>
<div v-text="msg"></div>
<input v-model="msg" type="text" />
</div>
<script type="module">
import Vue from "./js/vue.js";
let vm = new Vue({
el: "#app",
data: {
msg: "123",
age: 21,
},
});
window.vm = vm
</script>
</body>
</html>
vue.js
import Observer from "./observer.js";
import Compiler from "./compiler.js";
export default class Vue {
constructor(options) {
this.$options = options || {};
this.$el =
typeof options.el === "string"
? document.querySelector(options.el)
: options.el;
this.$data = options.data || {};
// 看到这里为什么做了两个重复性的操作呢?
// 重复性两次把 data 的属性转为响应式
// 在obsever.js 中是把 data 的所有属性 加到 data 自身 变为响应式 转成 getter setter方式
// 在vue.js 中 也把 data的 的所有属性 加到 Vue 上,是为了以后方面操作可以用 Vue 的实例直接访问到 或者在 Vue 中使用 this 访问
this._proxyData(this.$data);
// 使用 Obsever 把data中的数据转为响应式
new Observer(this.$data);
// 编译模板
new Compiler(this);
}
// 把data 中的属性注册到 Vue
_proxyData(data) {
Object.keys(data).forEach((key) => {
// 进行数据劫持
// 把每个data的属性 到添加到 Vue 转化为 getter setter方法
Object.defineProperty(this, key, {
// 设置可以枚举
enumerable: true,
// 设置可以配置
configurable: true,
// 获取数据
get() {
return data[key];
},
// 设置数据
set(newValue) {
// 判断新值和旧值是否相等
if (newValue === data[key]) return;
// 设置新值
data[key] = newValue;
},
});
});
}
}
observer.js
import Dep from "./dep.js";
export default class Observer {
constructor(data) {
// 用来遍历 data
this.walk(data);
}
// 遍历 data 转为响应式
walk(data) {
// 判断 data是否为空 和 对象
if (!data || typeof data !== "object") return;
// 遍历 data
Object.keys(data).forEach((key) => {
// 转为响应式
this.defineReactive(data, key, data[key]);
});
}
// 转为响应式
// 要注意的 和vue.js 写的不同的是
// vue.js中是将 属性给了 Vue 转为 getter setter
// 这里是 将data中的属性转为getter setter
defineReactive(obj, key, value) {
// 如果是对象类型的 也调用walk 变成响应式,不是对象类型的直接在walk会被return
this.walk(value);
const _this = this;
// 创建 Dep 对象
let dep = new Dep();
Object.defineProperty(obj, key, {
// 设置可枚举
enumerable: true,
// 设置可配置
configurable: true,
// 获取值
get() {
// 在这里添加观察者对象 Dep.target 表示观察者
Dep.target && dep.addSub(Dep.target);
return value;
},
// 设置值
set(newValue) {
// 判断旧值和新值是否相等
if (newValue === value) return;
// 设置新值
value = newValue;
// 赋值的话如果是newValue是对象,对象里面的属性也应该设置为响应式的
_this.walk(newValue);
// 触发通知 更新视图
dep.notify();
},
});
}
}
compiler.js
import Watcher from "./watcher.js";
export default class Compiler {
// vm 指 Vue 实例
constructor(vm) {
// 拿到 vm
this.vm = vm;
// 拿到 el
this.el = vm.$el;
// 编译模板
this.compile(this.el);
}
// 编译模板
compile(el) {
// 获取子节点 如果使用 forEach 遍历就把伪数组转为真的数组
let childNodes = [...el.childNodes];
childNodes.forEach((node) => {
// 根据不同的节点类型进行编译
// 文本类型的节点
if (this.isTextNode(node)) {
// 编译文本节点
this.compileText(node);
} else if (this.isElementNode(node)) {
//元素节点
this.compileElement(node);
}
// 判断是否还存在子节点考虑递归
if (node.childNodes && node.childNodes.length) {
// 继续递归编译模板
this.compile(node);
}
});
}
// 判断是否是 文本 节点
isTextNode(node) {
return node.nodeType === 3;
}
// 编译文本节点(简单的实现)
compileText(node) {
// 核心思想利用把正则表达式把{{}}去掉找到里面的变量
// 再去Vue找这个变量赋值给node.textContent
let reg = /\{\{(.+?)\}\}/;
// 获取节点的文本内容
let val = node.textContent;
// 判断是否有 {{}}
if (reg.test(val)) {
// 获取分组一 也就是 {{}} 里面的内容 去除前后空格
let key = RegExp.$1.trim();
// 进行替换再赋值给node
node.textContent = val.replace(reg, this.vm[key]);
// 创建观察者
new Watcher(this.vm, key, newValue => {
node.textContent = newValue;
});
}
}
// 判断是否是元素节点
isElementNode(node) {
return node.nodeType === 1;
}
// 判断元素的属性是否是 vue 指令
isDirective(attr) {
return attr.startsWith("v-");
}
// 编译元素节点这里只处理指令
compileElement(node) {
// 获取到元素节点上面的所有属性进行遍历
![...node.attributes].forEach((attr) => {
// 获取属性名
let attrName = attr.name;
// 判断是否是 v- 开头的指令
if (this.isDirective(attrName)) {
// 除去 v- 方便操作
attrName = attrName.substr(2);
// 获取 指令的值就是 v-text = "msg" 中msg
// msg 作为 key 去Vue 找这个变量
let key = attr.value;
// 指令操作 执行指令方法
// vue指令很多为了避免大量个 if判断这里就写个 uapdate 方法
this.update(node, key, attrName);
}
});
}
// 添加指令方法 并且执行
update(node, key, attrName) {
// 比如添加 textUpdater 就是用来处理 v-text 方法
// 我们应该就内置一个 textUpdater 方法进行调用
// 加个后缀加什么无所谓但是要定义相应的方法
let updateFn = this[attrName + "Updater"];
// 如果存在这个内置方法 就可以调用了
updateFn && updateFn(node, key, this.vm[key], this);
}
// 提前写好 相应的指定方法比如这个 v-text
// 使用的时候 和 Vue 的一样
textUpdater(node, key, value, context) {
node.textContent = value;
// 创建观察者2
new Watcher(context.vm, key, (newValue) => {
node.textContent = newValue;
});
}
// v-model
modelUpdater(node, key, value, context) {
node.value = value;
// 创建观察者
new Watcher(context.vm, key, (newValue) => {
node.value = newValue;
});
// 这里实现双向绑定 监听input 事件修改 data中的属性
node.addEventListener("input", () => {
console.log('+++++++++', node.value)
context.vm[key] = node.value;
});
}
}
watcher.js
import Dep from "./dep.js";
/**
* 数据更新后 收到通知之后 调用 update 进行更新
*/
export default class Watcher {
constructor(vm, key, cb) {
this.vm = vm;
// key是data中的key
this.key = key;
// 回调函数,更新视图的具体方法
this.cb = cb;
// 把观察者存放在 Dep.target
Dep.target = this;
// 旧数据 更新视图的时候要进行比较
// 还有一点就是 vm[key] 这个时候就触发了 get 方法
// 之前在 get 把 观察者 通过dep.addSub(Dep.target) 添加到了 dep.subs中
this.oldValue = vm[key];
// Dep.target 就不用存在了 因为上面的操作已经存好了
Dep.target = null;
}
// 观察者中的必备方法 用来更新视图
update() {
// 获取新值
let newValue = this.vm[this.key];
// 比较旧值和新值
if (newValue === this.oldValue) return;
// 调用具体的更新方法
this.cb(newValue);
}
}
dep.js
export default class Dep {
constructor() {
// 存储观察者
this.subs = [];
}
// 添加观察者
addSub(sub) {
// 判断观察者是否存在和是否用友 update 方法
if (sub && sub.update) {
this.subs.push(sub);
}
}
// 通知方法
notify() {
// 触发每个观察者的更新方法
this.subs.forEach((sub) => {
sub.update();
});
}
}