【Vue2.x源码学习】第三篇 - 对象的单层劫持

817 阅读6分钟

这是我参与更文挑战的第3天,活动详情查看: 更文挑战

一,前言

上篇,介绍了 Vue 使用及数据初始化的流程

回顾一下,主要涉及以下几个核心点:

  • initMixin 方法: 原型方法 Vue.prototype._init
  • vm.$options:使 options 选项在 vm 实例上被共享
  • initState 方法: Vue 初始化时,对多种数据源做集中处理
  • initData 方法:data 数据的初始化

本篇,继续对 data 数据进行初始化操作,实现对象属性的劫持(单层劫持)


二,Vue 的响应式原理

问题:Vue 的响应式原理

Vue 响应式原理,核心是通过Object.defineProperty为属性添加 get、set 方法,从而实现对数据操作的劫持,下图 Data 部分所示:

image.png


三,对象的劫持

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 实例;

image.png

3,处理 this 的指向问题

在 Vue 中,data 函数执行时 this 应指向当前 vm 实例;

所以,当 data 执行时,需要data.call(vm)绑定 this 上下文为当前 vm 实例:

if(isFunction(data)){
  data = data.call(vm);  // 绑定 this 为 vm 实例
}

测试 data 函数中的 this 指向,已正确指向 vm 实例:

image.png

简化代码:使用三目运算符,将 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可以忽略);

image.png

问题:对象观测使用了 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 对象,查看执行效果:

image.png

至此,就实现了对 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 方法返回值死循环分析;