目的
因为大家在写Vue的时候,会经常碰到,类似于,操作了数据但是视图没有更新的问题,所以写下此记录,来向大家展示Vue的响应式原理。
知识点
1.1. Vue是怎么样实现的变化侦测?
使用Object.defineProperty使数据变得可观测
let car = {
brand: 'BMW',
price: 3000
}
// 如何在我们修改car的时候 知道car 哪个属性被修改了
// 使用 js原生支持的 Object.defineProperty()
Object.defineProperty(car,'brand',{
enumerable: true,
configurable: true,
get(){
console.log('brand属性被读取了')
return val
},
set(newVal){
console.log('brand属性被修改了')
val = newVal
}
})
// 通过使用 Object.defineProperty 我们监听了car对象的brand属性的读写操作
// 当我们 显式的更改其值的时候
car.brand = 'MINI EV'
// 在控制台 可以看到我们写的 打印已经输出了
// 当我们 去读取其值的时候
car.brand
// 在控制台中也可以看到 打印出来了 ‘brand属性被读取了’
1.2. 那么 Vue的源码内是怎么处理的?
Vue内observer的源码
// 源码位置:src/core/observer/index.js
/**
* Observer类会通过递归的方式把一个对象的所有属性都转化成可观测对象
* 需要注意的是 如下的大多数代码都是伪代码 只是真实代码中的一部分
*/
export class Observer {
constructor (value) {
this.value = value
if (Array.isArray(value)) {
// 当value为数组时的逻辑
// ...
} else {
this.walk(value)
}
}
walk (obj) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i])
}
}
}
/**
* 使一个对象转化成可观测对象
* @param { Object } obj 对象
* @param { String } key 对象的key
* @param { Any } val 对象的某个key的值
*/
function defineReactive (obj,key,val) {
// 如果只传了obj和key,那么val = obj[key]
if (arguments.length === 2) {
val = obj[key]
}
if(typeof val === 'object'){
new Observer(val)
}
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get(){
console.log(`${key}属性被读取了`);
return val;
},
set(newVal){
if(val === newVal){
return
}
console.log(`${key}属性被修改了`);
val = newVal;
}
})
}
在上面代码中,可以看出Vue定义了一个observer类,它用来将一个正常的Object对象转换为可观测的Object.
通过判断值的类型,来将object类型的数据通过walk来转换为get/set形式来侦测变化。最后还在defineReactive中判断,val值的类型来递归子属性。所以,通过这段代码,可以实现,将一个 object传入到observer类中,我们就可以得到一个可观测的 Object。
1.3. Vue是怎么知道谁需要更新的?
当我们可以将数据变成可观测时候, 就引出了一个问题。我们为什么要将数据变为可观测数据?当然是为了 数据在变更时候 我们可以让视图去更新,那么我们怎么知道视图是谁呢? 总不能 我们一个数据更新了,将整个视图重新渲染一次吧。所以可以想到,视图中,谁用到了这条数据,就去更新谁。 换句话来说 “谁依赖这个数据就去更新谁”。
这时候我们就可以,把每个数据都建一个依赖数组,因为一个数据可能在多个地方使用,所以需要建立一个数组。当数据变化时候,我们去获取对应数据的依赖数组,挨个通知它们数据变了,自身该更新了。
那么依赖什么时候需要收集和更新? 可观测数据被获取的时候收集依赖,也就是 在getter中收集依赖。可观测数据被更改的时候更新依赖,也就是在setter中更新依赖。
那么我们知道了何时去收集依赖和更新依赖,但是我们要将依赖收集到哪里去呢?
最好的做法是为每个数据创建一个依赖管理器,把这个数据的所有依赖都管理起来。
假设 ,每个key都有一个数组 用来存储当前key的依赖 ,并且假设依赖是个函数,并保存在window.target上。
依赖管理器
let uid = 0
export default class Dep {
constructor() {
this.id = uid++
this.subs = []
}
// 添加
addSub(sub) {
this.subs.push(sub)
}
// 删除依赖
removeSub(sub) {
this.remove(this.subs, sub)
}
// 添加一个依赖
depend() {
console.log('收集依赖');
if(window.target) {
console.log('depend',window.target);
this.addSub(window.target)
// dep会记录 数据发生变化时需要通知哪些watcher watcher也记录了自己会被哪些dep通知
}
}
// 通知更新所有依赖
notify() {
console.log('notify','更新依赖');
const subs = this.subs.slice()
for(let i = 0,len= subs.length;i<len;i++) {
subs[i].update()
}
}
// 删除
remove(arr, item) {
// 把watcher从sub中删除调 当数据发生变化时 就不会通知这个已被删除的watcher unwatch原理
if (arr.length) {
const index = arr.indexOf(item)
if(index > -1) {
return arr.splice(index,1)
}
}
}
}
然后我们将defineReactive稍稍改造一下。
修改defineReactive
function defineReactive (obj,key,val) {
// 如果只传了obj和key,那么val = obj[key]
if (arguments.length === 2) {
val = obj[key]
}
if(typeof val === 'object'){
new Observer(val)
}
let dep = new Dep(); // 修改的地方
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get(){
console.log(`${key}属性被读取了`);
// 添加依赖
dep.depend(); // 修改的地方
return val;
},
set(newVal){
if(val === newVal){
return
}
console.log(`${key}属性被修改了`);
val = newVal;
dep.notify(); // 修改的地方
}
})
}
在上面代码中,我们收集的依赖是window.target,那么它到底是什么东西?
收集谁 换句话来说就是 当数据发生改变的时候 通知谁。
但是因为,我们用到这个数据的地方有很多,而且类型不一致,比如我可能是在template中使用,也可能在watch中使用。所以需要抽象出一个能集中处理这些情况的类,当数据变化的时候,我们只需要通知它,让他内部再通知其他的地方。所以vue 给他起了个好听的名字 “watcher”。 所以我们要收集watcher!
1.4. 那到底什么是watcher?
关于watcher有一个经典使用方式
watcher基本的使用
vm.$watch(data.a.b.c,function (newVal,oldVal) {
// 做点什么
})
可以想一下,我们该如何实现这个功能?是不是只需要将data.a.b.c属性添加到dep中就行了。当data.a.b.c的值发生变化的时候,通知watcher,watcher执行参数中的回调函数。
下面来看一下 watcher的实现
watcher的实现
import { parsePath } from '../../../util/lang'
export default class Watcher {
constructor(vm, expOrFn, cb, options) {
this.vm = vm
this.getter = parsePath(expOrFn) // 执行this.getter() 就可以读取data.a.b.c的内容
this.cb = cb
this.value = this.get()
}
get() {
// 把实例自身赋给了全局的一个唯一对象window.target上
window.target = this
// 获取一下被依赖的数据,获取被依赖数据的目的是触发该数据上面的getter
let value = this.getter.call(this.vm, this.vm)
window.target = undefined
return value
}
update() {
console.log('update', '更新依赖')
const oldValue = this.value
this.value = this.get()
this.cb.call(this.vm, this.value, oldValue)
}
}
// parsePath的代码
/**
* 解析简单路径
*/
export const unicodeRegExp = /a-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD/
/**
* 解析简单路径
* 把一个形如'data.a.b.c'的字符串路径所表示的值,从真实的data对象中取出来
* 例如:
* data = {a:{b:{c:2}}}
* parsePath('a.b.c')(data) // 2
*/
const bailRE = new RegExp(`[^${unicodeRegExp.source}.$_\\d]`)
export function parsePath (path){
if (bailRE.test(path)) {
return
}
const segments = path.split('.')
return function (obj) {
for (let i = 0; i < segments.length; i++) {
if (!obj) return
obj = obj[segments[i]]
}
return obj
}
}
watcher逻辑如下。
- 当实例化Watcher类的时候,会先执行构造函数。
- 在构造函数中调用了this.get()实例方法。
在get()方法中,通过window.target = this 把实例赋值给了全局的一个唯一对象 window.target上, 然后通过
let value = this.getter.call(this.vm, this.vm)
获取一下被依赖的数据 此步目的是为了触发数据的getter,所以我们触发了收集依赖。
- 在dep.depend() 中 取到挂载到window.target的值 也就是 watcher 实例 并存入依赖数组中,最后在get方法中将window.target释放掉
- 当数据变化的时候 会触发数据的setter,因为在setter中调用了dep.notify(),我们在dep.notify()中 遍历了所有依赖,并执行依赖的update()方法,也就是Watcher类中的update()实例方法,在update()方法中我们调用了数据变化的更新回调函数,从而更新视图。
总结来说,就是
- data通过observer转换成了getter/setter的形式来追踪其变化
- 当外界通过Watcher读取数据时,会触发getter将Watcher添加到依赖中
- 当数据发生了变化时,会触发setter,从而向Dep中的依赖(即Watcher)发送通知
- Watcher接收到通知后 会向外界发送通知 变化通知到外界后可能会触发视图更新,也可能会触发用户的某个回调函数.
1.5. 不足
虽然上面一系列的操作 通过Object.defineProperty方法实现了对object数据的可观测,但是这个方法目前只是能观测到取值和设置值的操作 ,当对object中添加一堆新的key/value时 或删除一堆已有的Key/value时,他是无法观测到的。导致我们新添加的key/value后,无法通知依赖,从而也就导致对应的视图无法进行响应式更新。解决方法也很简单Vue提供了Vue.set和Vue.delete方法。
1.6. Vue是如何将模板与数据绑定起来的?
关于这个问题, 我们需要预先知道,什么是模板编译?
在日常开发过程中,我们把写在template标签内的类原生html内容称之为模板。那么为什么是类原生呢?因为我们在template内可以利用js的变量,或是Vue指令 这些东西在html原生是不存在的。但它确实是能正常渲染出来了,这一切就要归功于Vue的模板编译了。
Vue将template内的内容进行编译,把属于Vue能力的东西进行处理。比如变量,指令等,生成渲染函数Render。Render会将模板内容生成为对应的VNode,而VNode经过一系列优化处理后,最后根据VNode创建真实的DOM节点并插入到视图中,最终完成视图的渲染。
由于这部分技术十分复杂,所以我只会进行简单的描述,告诉你Vue是如何将模板与数据绑定的,是怎么做到的数据驱动视图,而不会讲述他是如何生成render渲染函数,render渲染函数如何生成Vnode,Vnode内的Diff优化 全部略过。
在这个阶段具体流程如下
- 模板解析阶段:将一堆模板字符串用正则表达式解析成抽象语法树AST
- 优化阶段: 遍历AST.找出其中的静态节点并打上标记
- 代码生成阶段:将AST转为渲染函数。
在模板解析阶段中,会去解析模板中是否存在变量,也就是被{{}}符号包围的文本,当解析到变量时。Vue内部会获取该变量的值 并replace替换掉其{{}}变量文本。因为此步骤中,获取了变量 就触发了依赖收集所以这里的依赖就会被收集到。在以后的变量更新时,Vue内部将自动执行依赖更新,将其内部的文本重新赋值为最新值。
如下是个伪实现 根据上面说到的过程
Compile
export default class Compile {
constructor(el, vm) {
this.$el = document.querySelector(el)
this.$vm = vm
if (this.$el) {
console.log('el',this.$el);
this.$fragment = this.getNodeChirdren(this.$el)
this.compile(this.$fragment)
this.$el.appendChild(this.$fragment)
}
}
getNodeChirdren(el) {
const frag = document.createDocumentFragment()
let child
while ((child = el.firstChild)) {
frag.appendChild(child)
}
return frag
}
compile(el) {
const childNodes = el.childNodes
Array.from(childNodes).forEach((node) => {
if (node.nodeType == 1) {
} else if (node.nodeType == 2) {
} else if (node.nodeType == 3) {
//3为文本节点
this.compileText(node)
}
// 递归子节点
if (node.childNodes && node.childNodes.length > 0) {
this.compile(node)
}
})
}
compileText(node) {
console.log('compileText')
/**
* . 表示任意单个字符,不包含换行符
* + 表示匹配前面多个相同的字符
* ?表示非贪婪模式,尽可能早的结束查找
* */
const reg = /\{\{(.+?)\}\}/
const arrayReg = /\S*\[\d\]/
const reg2 = /(.*?)\[(.*?)\]/
var param = node.textContent
if (reg.test(param)) {
// $1表示匹配的第一个
const key = RegExp.$1.trim()
// const reg = /({{(.*)}})/;
// const key = String(param.match(reg)).match(reg2)[0]//获取监听的key
// 编译模板的时候,创建一个watcher实例,并在内部挂载到Dep上
console.log(this.$vm)
console.log(key)
console.log(this.$vm[key])
if(arrayReg.test(key)){
const aKey = key.match(reg2)[1]
const aVal = key.match(reg2)[2]
// console.log(this.$vm.aKey[aVal])
node.textContent = param.replace(reg, this.$vm[aKey][aVal])
new Watcher(this.$vm[aKey], aVal, (newValue) => {
// 通过回调函数,更新视图
console.log('通过回调函数,更新视图', newValue)
node.textContent = newValue
})
}else{
node.textContent = param.replace(reg, this.$vm._data[key])
new Watcher(this.$vm, key, (newValue) => {
// 通过回调函数,更新视图
console.log('通过回调函数,更新视图', newValue)
node.textContent = newValue
})
}
}
}
}
这样 就实现了一个简简单单的Vue数据驱动视图。当然,真正的Vue并没有看起来的那么简单,其内部做了非常多的边界处理以及性能上的优化,有效的避免了很多极端场景。
整理与测试
https://github.com/littleCareless/mini-vue
克隆项目→ 安装依赖 → 在template文件夹的index.html内编辑调试
执行 npm run build 将做到更改index.html 调试
使用 VsCode的live server插件 打开 index.html
在浏览器控制台中 找到Sources 在左侧的page项中 找到index.html 给你想要调试的地方打上断点 刷新浏览器 就可以调试了
在这个页面 可以看到提供了四个按钮和一个input 每个按钮调用了不同的方法
展示了$set的使用 和 双向绑定 具体实现其实可以参考我上面说的和源代码。
克隆项目→ 安装依赖 → 在examples文件夹内新起一个文件夹 文件夹内新建一个index.html
index.html 展开源码
执行 npm run dev 可以在src文件夹内随意做更改,立即生效。
调试 index.html与上诉方法并无差别.
实际应用
通过上面一系列讲解,我们可以知道Vue是如何的将数据变成响应式的。
那么我们在写Vue的时候,就需要按照他的原理来操作数据,以触发视图的更新。
1.6.1.1. 更改数据触发视图更新需要满足条件:
1、数据是被vue劫持的; 例如 vm.name
2、视图中有用到对应的数据; ``
{{name}}
如何操作对象?
// 引用数据类型新增加属性 操作方法 注:vm是vue实例
// 方法一. 此方法是替换了obj的内存指向,所以更改了obj的内存指向整个会重新劫持。
vm.obj = {
...vm.obj,
b:1000
}
// 方法二.此方法是官方提供的一个API 用以将新增的属性变为响应式数据
vm.$set(obj,b,1000)
如何操作数组?
// 数组 增删改查
// 对于数组的增删改查 应当避免使用索引和长度来操作数组 因为Vue内部只对数组的七个方法进行了劫持
// 方法一. 此方法同样是替换了array的内存指向,索引更改了array的内存指向整个会重新劫持。
vm.array = [...vm.array,[1,2,3]]
// 方法二. 利用Vue劫持的数组操作方法来进行操作 能触发视图更新的方法有:push pop unshift shift reverse splice sort
// 关于数组劫持 源码位置在 https://github.com/vuejs/vue/blob/dev/src/core/observer/array.js 在这里就可以看到vue所劫持的方法
vm.array.push(100);
vm.array.splice(2,1,30);
如何强制更新?
// 其内部还有一个强制更新视图的方法 迫使Vue.js实例重新渲染。注意它仅仅影响实例本身以及插入插槽内容的子组件,而不是所有子组件。
vm.$forceUpdate()
// 源码内部实现如下 其实内部只是执行实例watcher的update方法,就可以使Vue.js实例重新渲染。
Vue.prototype.$forceUpdate = function () {
var vm = this;
if (vm._watcher) {
vm._watcher.update();
}
};