一、经典面试题
1、vue响应式原理
首先了解Vue中的三个核心类:
Observer: 给对象属性添加getter和setter,用于依赖收集 和 派发更新。Dep:用于收集当前响应式对象的依赖关系,每个响应式对象都有一个dep实例,dep.subs = watcher[ ]。当数据发生变更的时候,会通过dep.notify( )通知各个watcher。Watcher:观察者对象,render watcher、computed watcher、user watcher(相当于watch)
依赖收集
- initState时,对
computed属性初始化时,会触发computed watcher依赖收集 - initState时, 对监听属性初始化的时候,触发的
user watcher依赖收集(这里就是我们常写的那个watch) - render时, 触发
render watcher依赖收集 - re-render 时,vm.render()再次执⾏,会移除所有 subs 中的 watcher 的订阅,重新赋值。
派发更新
-
组件中对响应式的数据进行了修改,会触发setter逻辑
-
调用
dep.notify() -
遍历所有的
subs(Watcher 实例) ,调用每一个watcher的update方法
总结原理:
当创建vue实例的时候,vue会遍历data里的属性,Object.defineProperty为属性添加getter和setter对数据的读取进行劫持 (getter ⽤来依赖收集,setter ⽤来派发更新),并且在内部追踪依赖,在属性被访问和修改时通知变化 。
每个组件实例会有相应的 watcher 实例,会在组件渲染的过程中记录依赖的所有数据属性进⾏ 依赖收集,之后依赖项被改动时,setter ⽅法会通知依赖与此 data 的 watcher 实例重新计算(派 发更新),从⽽使它关联的组件重新渲染 。
2、计算属性的实现原理
计算属性更建议做一些类型/格式的转换或者简单的计算
computed watcher,计算属性的监听器。
computed watcher 持有一个dep实例,通过 this.dirty 属性标记计算属性是否需要重新求值。
当computed的依赖值改变后,就会通知订阅的watcher进行更新,对于computed watcher会将 dirty属性设置为true,并且进行计算属性方法的调用。
- computed 所谓的缓存是指什么?
计算属性是基于他的响应式依赖进行缓存的,只有依赖发生变化的时候才会重新求值
- 那computed缓存存在的意义是什么?或者说你经常在什么时候使用?
比如计算属性方法内部操作非常的耗时,遍历一个极大的数组,计算一次可能要耗时1s
- 以下情况,computed可以监听到数据的变化吗?
{{ storageMsg }}
computed:{
storageMsg:function(){
return sessionStorage.getItem('xxx')
},
time:function(){
return Date.now()
}
}
created(){
sessionStorage.setItem('xxx',1111)
},
onClick(){
sessionStorage.setItem('xxx',Math.rendom())
}
答案:不可以。没有经过响应式。
3、Vue.nextTick的原理
// 两种使用方式
Vue.nextTick(()=>{
// TODO
})
await Vue.nextTick();
// TODO
Vue 是 异步执⾏dom更新的,⼀旦观察到数据变化,Vue就会开启⼀个异步队列,然后把在同⼀个事件循环 (event loop) 当中观察到数据变化的 watcher 推送进这个队列。如果这个 watcher被触发多次,只会被推送到队列⼀次。
这种缓冲⾏为可以有效的去掉重复数据造成的不必要的计算和DOm操作。⽽在下⼀个事件循 环时,Vue会清空队列,并进⾏必要的DOM更新。
异步方法优先级:Promise.then() -> MutationObserver -> setImmediate -> setTimeout
enevt loop执行顺序: 宏任务 -> 微任务 ->UI render
所以可以理解为, nextTick会优先尝试使⽤微任务, 如果浏览器不⽀持, 就⽤宏任务
一般什么时候需要使用Vue.nextTick?
在数据变化后要执行某个操作,而这个操作依赖因你数据改变而改变的dom,这个操作就应该被放到 Vue.nextTick 会调用
// 例子:
<template>
<div v-if='loaded' ref='test'></div>
</template>
async showDiv(){
this.loaded = true;
this.$refs.test.xxx() // 此时拿不到div上面的任何数据,为undefined
await Vue.nextTick()
this.$refs.test.xxx() // 这个时候可以拿到方法
}
二、手写一个简单的vue,实现响应式的更新
1、新建一个目录
- index.html 主页面
- vue.js Vue主文件
- compiler.js 编译模板,解析指令。 v-model v-html
- dep.js 收集依赖关系,存储观察者 // 以发布-订阅的形式实现
- observer.js 数据劫持
- watcher.js 观察者对象类
2、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>vue-model</title>
</head>
<body>
<div id="app"></div>
</body>
</html>
3、初始化Vue class
/**
* 包括vue 构造函数,接收各种配置参数等等
*/
export default class vue {
constructor(options = {}) {
console.log(options);
// 带$相当于私有变量
this.$options = options;
this.$data = options.$data;
this.$methods = options.$methods;
this.initRootElement(options);
}
/**
* 获取根元素,并存储到vue实例,简单检查一下传入的el是否合规
*/
initRootElement(options) {
if (typeof options.el == 'string') {
// 传⼊的是元素id或者class
this.$el = document.querySelector(options.el);
} else if (typeof options.el instanceof HTMLElement) {
// el:"div", //对应的 html类型
this.$el = options.el;
}
if (!this.$el) {
throw new Error('传入的数据不正确');
}
}
}
4、验证一下,新建一个index.js
import Vue from './vue.js';
const vm = new Vue({
el: '#app',
data: {
msg: 'hello word'
},
methods: {
handler() {
console.log('1111111111===', 1111111111);
}
}
});
console.log(vm);
- 输入一个错误的el。 例如 el: '#app1'
- 输入一个htmlelement
5、vue里可以通过this来获取data里面的属性
/**
* 包括vue 构造函数,接收各种配置参数等等
*/
export default class vue {
constructor(options = {}) {
// 带$相当于私有变量
this.$options = options;
// 获取 data
this.$data = options.data;
this.$methods = options.methods;
this.initRootElement(options);
// 利用Object.defineProperty将data里面的属性注入到vue实例中
this._proxyData(this.$data);
}
/**
* 获取根元素,并存储到vue实例,简单检查一下传入的el是否合规
*/
initRootElement(options) {
if (typeof options.el == 'string') {
this.$el = document.querySelector(options.el);
} else if (typeof options.el instanceof HTMLElement) {
this.$el = options.el;
}
if (!this.$el) {
throw new Error('传入的数据不正确');
}
}
// 把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
},
})
})
}
}
6、声明核心类
具体的实现先不管,写好注释,对于这种外界可能会引用到的方法,使用jsDoc
export default class Dep {
constructor() {
// 存储所有的观察者
this.subs = [];
}
// 添加观察者
addsubs() {}
// 发送通知
notify() {}
}
export default class Observer {
constructor() {}
// 用于递归data里面的所有属性
traverse(data) {}
// 给传入的数据设置
defineReactive(obj, key, val) {
// TODO 递归遍历
}
}
export default class Watcher {
constructor(vm, key, cb) {}
// 当数据变化的时候更新视图
update() {}
}
export default class Compiler {
constructor(vm) {}
// 编译模板 el-根元素
compile(el) {}
}
7、整体实现
想⼀下应该怎么调⽤这些⽅法? 在vue初始化的时候都应该做些什么?
import Observer from './observer.js';
import Compiler from './compiler.js';
/**
* 包括vue 构造函数,接收各种配置参数等等
*/
export default class vue {
constructor(options = {}) {
console.log(options);
// 带$相当于私有变量
this.$options = options;
this.$data = options.data;
this.$methods = options.methods;
this.initRootElement(options);
// 利用Object.defineProperty将data里面的属性注入到vue实例中
this._proxyData(this.$data);
// 实例化observer对象,监听数据变化
new Observer(this.$data);
// 实例化compiler对象,解析指令和模板表达式
new Compiler(this);
}
/**
* 获取根元素,并存储到vue实例,简单检查一下传入的el是否合规
*/
initRootElement(options) {
if (typeof options.el == 'string') {
this.$el = document.querySelector(options.el);
} else if (typeof options.el instanceof HTMLElement) {
this.$el = options.el;
}
if (!this.$el) {
throw new Error('传入的数据不正确');
}
}
_proxyData(data) {
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get() {
return data[key];
},
set(newValue) {
// 如果数据没有变化不做处理
if (data[key] === newValue) return;
data[key] = newValue;
}
});
});
}
}
完善 dep.js
发布订阅模式
记住, dep是⽤来存储所有观察者的, 也就是watcher.
⽽我们watcher的定义, 每个watcher都会有⼀个update⽅法对吧, ⽤来更新视图的?
- addSub, 我们如果发现watcher没有update⽅法, 也就没必要添加到subs⾥了.
- notify, 是提供给外界调⽤的, 当数据有变更的时候, 外界会调⽤notify去通知各个watcher, 也就 是执⾏watcher.update()
/**
* 发布订阅模式:
* 存储所有观察者,watcher
* 每个watcher都有一个update
* 通知subs里的每个watcher实例,触发update方法
*
*/
export default class Dep {
constructor() {
// 存储所有的观察者
this.subs = [];
}
// 添加观察者
addSub(watcher) {
// 判断观察者是否存在 和 是否拥有update方法
if (watcher && watcher.update) {
this.subs.push(watcher);
}
}
// 发送通知
notify() {
// 触发每个观察者的更新方法
this.subs.forEach(watcher => {
watcher.update();
});
}
}
// 问题:
// Dep 在哪里实例化? 在哪里addSub?Observer遍历各个属性进行实例化的
// Dep notify 在哪里调用
完善watcher
import Dep from './dep.js';
export default class Watcher {
/**
* @param {*} vm vue实例
* @param {*} key data属性名
* @param {*} cb 负责更新视图的回调函数
*/
constructor(vm, key, cb) {
this.vm = vm;
this.key = key;
this.cb = cb;
// 此时 Dep.target 作为一个全局变量理解,放的就是这个watcher
Dep.target = this;
// 旧数据 更新视图的时候要进行比较
// 还有一点就是 vm[key] 这个时候就触发了observer的 get 方法
// 之前在 get 把 观察者 通过dep.addSub(Dep.target) 添加到了 dep.subs中
this.oldVal = vm[key]; // 旧值
// Dep.target 就不用存在了 因为上面的操作已经存好了
Dep.target = null;
}
// 当数据变化的时候更新视图
update() {
let newValue = this.vm[this.key]; // 新值
if (this.oldVal === newValue) {
return;
}
this.cb(newValue);
}
}
// watcher初始化获取oldVal的时候,会去做一些什么操作?添加依赖的操作
// 通过vm[key]获取oldVal前,为什么要将当前的实例挂在Dep上,获取之后为什么又置为null?先收集依赖进行暂存,用完后赋值为空
// update 方法是在什么时候执行的? notify时调用update
完善compiler.js
import Watcher from './watcher.js';
export default class Compiler {
// vm 指 Vue 实例
constructor(vm) {
this.el = vm.$el;
this.vm = vm;
this.methods = vm.$methods;
// 编译模板
this.compile(vm.$el);
}
// 编译模板 el-根元素
compile(el) {
const childNodes = el.childNodes; // 子节点 - 伪数组
// 伪数组必须转换成真实数组才可进行遍历---不然在低版本浏览器上面不支持forEach
Array.from(childNodes).forEach(node => {
if (this.isTextNode(node)) {
// 文本节点
this.compileText(node);
} else if (this.isElementNode(node)) {
// 元素节点
this.compileElement(node);
}
// 有子节点,递归调用
if (node.childNodes && node.childNodes.length > 0) {
// 继续递归编译模板
this.compile(node);
}
});
}
// 编译文本节点(简单的实现)
compileText(node) {
// 核心思想利用把正则表达式把{{}}去掉找到里面的变量
// 再去Vue找这个变量赋值给node.textContent
// {{msg}}] msg: hello word
const reg = /{{(.+?)}}/;
// 获取节点的文本内容
const value = node.textContent; // hello word
// 判断是否有 {{}}
if (reg.test(value)) {
// 获取分组一 也就是 {{}} 里面的内容 去除前后空格
const key = RegExp.$1.trim(); // msg
// 进行替换再赋值给node
node.textContent = value.replace(reg, this.vm[key]);
// 添加观察者
new Watcher(this.vm, key, newValue => {
// 数据改变时更新
node.textContent = newValue;
});
}
}
// 编译元素节点这里只处理指令
compileElement(node) {
// 获取到元素节点上面的所有属性进行遍历
if (node.attributes.length) {
Array.from(node.attributes).forEach(attr => {
// 遍历元素节点的所有属性
const attrName = attr.name; // v-model v-text v-html v-on:click
if (this.isDirective(attrName)) {
let directiveName = attrName.indexOf(':') > -1 ? attrName.substr(5) : attrName.substr(2);
let key = attr.value; // msg
// TODO 更新元素节点
// vue指令很多为了避免大量个 if判断这里就写个 uapdate 方法
this.update(node, key, directiveName);
}
});
}
}
// 添加指令方法 并且执行
update(node, key, directiveName) {
// 比如添加 textUpdater 就是用来处理 v-text 方法
// 我们应该就内置一个 textUpdater 方法进行调用
// 加个后缀加什么无所谓但是要定义相应的方法
const updateFn = this[directiveName + 'Updater'];
// 如果存在这个内置方法 就可以调用了
// 绑定this执行,不然方法里面的this就是undefined
updateFn && updateFn.call(this, node, this.vm[key], key, directiveName);
}
// 解析v-text
textUpdater(node, val, key) {
node.textContent = val;
new Watcher(this.vm, key, newValue => {
console.log(newValue);
node.textContent = newValue;
});
}
// 解析v-mdoel
modelUpdater(node, val, key) {
console.log('val===', val);
node.value = val;
new Watcher(this.vm, key, newValue => {
node.value = newValue;
});
// 双向绑定
node.addEventListener('input', () => {
this.vm[key] = node.value;
});
}
// 解析v-html
htmlUpdater(node, val, key) {
console.log(val);
node.innerHTML = val;
new Watcher(this.vm, key, newValue => {
node.innerHTML = newValue;
});
}
// 解析v-on:click
clickUpdater(node, val, key, directiveName) {
node.addEventListener(directiveName, this.methods[key]);
}
// 判断是否是文本节点
isTextNode(node) {
return node.nodeType === 3;
}
// 判断是否是元素节点
isElementNode(node) {
return node.nodeType === 1;
}
// 判断元素属性是否是指令
isDirective(attrName) {
// v-xxx
return attrName.startsWith('v-');
}
}
完善observer.js
// 给每个属性添加getter/setter,用于依赖收集和派发更新
import Dep from './dep.js';
export default class Observer {
constructor(data) {
this.traverse(data);
}
// 用于递归data里面的所有属性
traverse(data) {
if (!data || typeof data !== 'object') {
return;
}
Object.keys(data).forEach(key => {
this.defineReactive(data, key, data[key]);
});
}
// 给传入的数据设置 getter/setter
defineReactive(obj, key, val) {
// TODO 递归遍历
this.traverse(val);
const that = this;
const dep = new Dep();
Object.defineProperty(obj, key, {
configurable: true,
enumerable: true,
get() {
// 在这里添加观察者对象 Dep.target 表示观察者
Dep.target && dep.addSub(Dep.target);
return val;
},
set(newValue) {
if (newValue == val) {
return;
}
val = newValue;
// 赋值的话如果是newValue是对象,对象里面的属性也应该设置为响应式的
// 例如: info.age = {name:1111}
that.traverse(newValue);
dep.notify();
}
});
}
}
在模板编译的过程中,遇到模板中绑定的变量,就会解析,并创建 watcher,会在 Watcher 类 的内部获取旧值,即当前的值。
这样就触发了 get,在 get 中就可以将这个 watcher 添加到 Dep 的 subs 数组中进⾏统⼀管 理。
因为在代码中获取 data 中的值操作⽐较多,会经常触发 get,我们⼜要保证 watcher 不会被 重复添加,所以在 Watcher 类中,获取旧值并保存后,⽴即将 Dep.target 赋值为 null,并且 在触发 get 时对 Dep.target 进⾏了判空,存在才调⽤ Dep 的 addSub 进⾏添加。
验证⼀下
import Vue from './vue.js';
const vm = new Vue({
el: '#app',
data: {
msg: 'hello word',
count: 0,
testHtml: '<ul><li>1</li><li>2</li><li>3</li></ul>'
},
methods: {
handler() {
console.log('1111111111===', 1111111111);
},
}
});
<!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>vue-model</title>
</head>
<body>
<div id="app">
msg:{{msg}}
<div v-text="msg"></div>
<input v-model="msg" type="text" />
<button v-on:click="handler">按钮</button>
</div>
<script src="./index.js" type="module"></script>
</body>
</html>
源码在这里: github.com/goodjiang/h…