这是我参与更文挑战的第3天,活动详情查看: 更文挑战
一,前言
上篇,介绍了 Vue 使用及数据初始化的流程
回顾一下,主要涉及以下几个核心点:
- initMixin 方法: 原型方法 Vue.prototype._init
- vm.$options:使 options 选项在 vm 实例上被共享
- initState 方法: Vue 初始化时,对多种数据源做集中处理
- initData 方法:data 数据的初始化
本篇,继续对 data 数据进行初始化操作,实现对象属性的劫持(单层劫持)
二,Vue 的响应式原理
问题:Vue 的响应式原理
Vue 响应式原理,核心是通过Object.defineProperty为属性添加 get、set 方法,从而实现对数据操作的劫持,下图 Data 部分所示:
三,对象的劫持
1,initData 中获取 data
上一篇中,用户初始化 Vue 时传入的 options 对象已借助 vm.$options 暴露,此处可以通过 vm.$options.data 获取到 data 数据:
src/state.js
function initData(vm){
// 拿到 vue 初始化时,用户传入的data数据
let data = vm.$options.data;
console.log("进入 state.js - initData,数据初始化操作", data)
}
上一篇提到,data 可能是函数,也可能是对象;
2,处理 data 的两种情况
1,如果 data 为函数,执行此函数,并得到函数内部返回的对象:
// utils.js
// 判断是否为函数
export function isFunction(val){
return typeof val == 'function'
}
// src/state.js#initData
// 如果 data 是函数,执行 data 拿到内部返回的 data 对象
if(isFunction(data)){
data = data(); // 注意:这样执行后,this 是 window 而不是 vm
}
测试执行结果:
let vm = new Vue({
el: '#app',
data() {
console.log("打印 data函数执行时,this的指向")
console.log(this)
return { message: 'Hello Vue' } // data 返回一个对象
}
});
此时发现,当 data 函数执行时,函数内部的 this 指向为 window
而在 Vue 的官方版本中,this 应指向当前的 vm 实例;
3,处理 this 的指向问题
在 Vue 中,data 函数执行时 this 应指向当前 vm 实例;
所以,当 data 执行时,需要data.call(vm)绑定 this 上下文为当前 vm 实例:
if(isFunction(data)){
data = data.call(vm); // 绑定 this 为 vm 实例
}
测试 data 函数中的 this 指向,已正确指向 vm 实例:
简化代码:使用三目运算符,将 data 为对象、函数的两种情况进行逻辑合并:
// data 可能是函数,也可能是对象:
// - 如果 data 是函数,执行 data 函数并绑定this为vm实例,拿到函数返回值
// - 如果 data 不是函数,一定是对象,无需处理
data = isFunction(data) ? data.call(vm) : data;
注意:只有根实例上的 data 可以是对象,组件中 data 必须为函数;
4,核心模块 observe:对数据进行观测
-
data 数据的响应式原理:通过
Object.defineProperty重写 data 上的所有属性; -
实现方案:遍历 data 对象中的所有属性,逐个通过
Object.defineProperty重新定义;- observe 模块(文件夹)
- observe 方法 (入口)
创建入口文件 observe/index.js
// src/observe/index.js
export function observe(value) {
console.log(value)
}
在 state.js 中,引入并使用 observe 方法
调用 observe 方法进行观测数据,从而实现 data 数据的响应式:
import { observe } from "./observe";
import { isFunction } from "./utils";
export function initState(vm) {
const opts = vm.$options;
if (opts.data) {
initData(vm);
}
}
function initData(vm) {
let data = vm.$options.data;
data = isFunction(data) ? data.call(vm) : data;
observe(data); // 调用 observe 实现 data 数据的响应式
}
注意:经过以上的处理,此处 data 一定是一个对象;
所以,再对 data 进行一次类型检测,如果不是对象类型就直接 return 结束;
// src/utils
/**
* 判断是否为对象:object类型且不为null
* @param {*} val
* @returns
*/
export function isObject(val) {
return typeof val == 'object' && val !== null
}
通过 observe 方法对 data 进行观测前,检测 data 必须为对象类型:
// src/observe/index.js
import { isObject } from "../utils";
export function observe(value) {
// 如果 data 不是对象,就不需要观测了,直接 return
if(!isObject(value)){
return;
}
}
5,Observer 类:对【对象】进行观测
完成后的代码逻辑:
// src/observe/index.js
// 1,经过前面的处理 value 一定是对象,如果此时不是对象说明有问题,直接 return 掉
// 注意:数组也是对象,但 value 不能是数组;(在 vue 中,data 不能是数组)
export function observe(value) {
if(!isObject(value)){
return;
}
// 观测 value 对象,实现数据响应式
return new Observer(value);
}
创建 Observer 类,遍历 data 对象,使用Object.defineProperty重新定义 data 对象中的所有属性;
// src/observe/index.js
class Observer {
// 类的构造函数
constructor(value){
// 遍历对象中的属性,使用 Object.defineProperty 重新定义
this.walk(value);
}
// 循环 data 对象,使用 Object.keys 不循环原型方法
walk(data){
Object.keys(data).forEach(key => {
// 内部使用 Object.defineProperty 重新定义 data 对象中的属性
defineReactive(data, key, data[key]);
});
}
}
/**
* 为对象obj,定义属性key,值为value
* 使用Object.defineProperty重新定义data对象中的属性
* 备注:由于Object.defineProperty性能较低,成为了vue2的性能瓶颈
* @param {*} obj 需要定义属性的对象
* @param {*} key 给对象定义的属性名
* @param {*} value 给对象定义的属性值
*/
function defineReactive(obj, key, value) {
Object.defineProperty(obj, key, {
get(){ // 闭包
return value; // 问题:value 为什么不用 obj[key]获取?
},
set(newValue) {
if (newValue === value) return
value = newValue;
}
})
}
通过 Observer 类,使用 Object.defineProperty 重新定义了 data 对象中的所有属性,此时,每个属性都具有了 get、set 方法;
闭包的使用
在 get 方法中返回 value,这里构成了闭包 value 引用上一个作用域的 value,导致 defineReactive 函数不会被销毁(合理的闭包并不会浪费太多性能,不会造成OOM可以忽略);
问题:对象观测使用了 Observer 类,为什么不将 Observer 设计为函数?
首先,class 类经过编译后,最终还是函数;
其次,当前函数的实现方式,在 Vue 原型上扩展功能会很方便;如果使用 class 类来实现,比如_init方法会到类中的成员方法,如果扩展的功能比较多,它们将会被封装在同一个类中;
最后,在 js 中使用 class 时,虽然可以同时使用 prototype,但这是不符合 ES6 语法的行为;
问题:在 Object.defineProperty 的 get 方法中,可以使用 obj[key] 取值吗?
不可以,这种写法将会导致死循环;
在 Vue 初始化完成后,所有属性都成为响应式数据,obj[key]获取属性值会触发响应式,即 get 方法会继续触发 get 方法...形成死循环,因此需要直接返回属性值;
6,功能测试
在 data 进行初始化的 initData 方法中,
通过 observe 方法观测 data 数据,实现对 data 属性的劫持
function initData(vm) {
console.log("进入 state.js - initData,数据初始化操作")
let data = vm.$options.data;
data = isFunction(data) ? data.call(vm) : data;
// data 数据的响应式:遍历对象拿到所有属性,再通过Object.defineProperty 重写 data 中的所有属性
observe(data); // 观测数据
console.log(data)
}
打印被观测后的 data 对象,查看执行效果:
至此,就实现了对 data 函数中所有属性的劫持,但是,这里仅仅实现了对 data 对象中第一层属性的劫持(嵌套的情况目前版本还不支持,需要继续进行递归观测);
7,添加 rollup 插件:优化文件的解析方式
// 目前不会自动到 observe 文件夹下找 observe/index.js 入口文件
import { observe } from "./observe";
// 需要明确指定 observe 文件夹下的 index.js 才可以
import { observe } from "./observe/index";
// 安装依赖:@rollup/plugin-node-resolve,实现以 node 方式解析文件
// npm i @rollup/plugin-node-resolve
修改 rollup 配置文件,使用 @rollup/plugin-node-resolve 插件:
import babel from 'rollup-plugin-babel'
// 引入插件
import resolve from 'rollup-plugin-node-resolve';
export default {
input:'./src/index.js',
output:{
file:'dist/vue.js',
format:'umd',
name:'Vue',
sourcemap:true,
},
plugins:[
resolve(), // 使用插件
babel({
exclude:'node_modules/**'
})
]
}
三,结尾
本篇主要介绍了 Vue 数据初始化流程中,对象属性的单层劫持,核心实现有以下几点:
- data 为函数或对象两种类型的处理;
- 处理 data 函数中 this 的指向问题;
- Observer 类,对数据进行观测;
- walk 方法,遍历 data 属性(后面深层就不能叫 data 了)
- defineReactive 方法:利用 Object.defineProperty 实现数据劫持(目前仅实现数据的单层劫持)
下一篇,对象属性的深层劫持
维护日志
- 20230107:优化语义和描述,添加代码注释,使内容更加清晰易懂;
- 20230108:调整内容排版,添加闭包分析,Observer类设计分析,get 方法返回值死循环分析;