手写Vue2源码(二)—— 数据劫持

953 阅读3分钟

前言

通过手写Vue2源码,更深入了解Vue; 在项目开发过程,一步一步实现Vue核心功能,我会将不同功能放到不同分支,方便查阅; 另外我会编写一些开发文档,阐述编码细节及实现思路; 源码地址:手写Vue2源码

数据初始化流程

先看一下入口页:

// src/index.js
import { initMixin } from "./init"

function Vue(options) {
    this._init(options)
}
initMixin(Vue)

export default Vue

我们在 new Vue() 时会执行 this._init(options);这个 _init() 从原型上获取,定义在initMixin()中;接下来我们在initMixin(Vue)中往Vue原型上添加_init()方法:

// src/init.js
import { initState } from "./state";
export function initMixin(Vue) {
  Vue.prototype._init = function (options) {
    // this指向实例
    const vm = this;
    vm.$options = options; // 在实例上添加$options属性
    callHook(vm, "beforeCreate");
    
    // 初始化状态,包括initProps、initMethod、initData、initComputed、initWatch等
    initState(vm);

    callHook(vm, "created");
    // 如果有el属性 进行模板渲染
    if (vm.$options.el) {
      vm.$mount(vm.$options.el);
    }
  };
  Vue.prototype.$mount = function(el) {}
}

_init()方法中比较核心的方法是initState(vm)vm.$mount$mount是与模板编译、VNode、diff算法、页面挂载相关,我们后面再分析;下面我们着重看一下initState(vm)

// src/state.js
import { observe } from "./observer/index";
export function initState(vm) {
  const opts = vm.$options;
  if (opts.props) {
    initProps(vm);
  }
  if (opts.methods) {
    initMethod(vm);
  }
  if (opts.data) {
    // 初始化data
    initData(vm);
  }
  if (opts.computed) {
    initComputed(vm);
  }
  if (opts.watch) {
    initWatch(vm);
  }

  function initProps() {}
  function initMethod() {}
  function initData(vm) {
    let data = vm.$options.data;
    // 往实例上添加一个属性 _data,即传入的data
    // vue组件data推荐使用函数 防止数据在组件之间共享
    data = vm._data = isFunction(data) ? data.call(vm) : data;

    // 将vm._data上的所有属性代理到 vm 上
    for (let key in data) {
      proxy(vm, "_data", key);
    }
    // 对数据进行观测 -- 数据响应式
    observe(data);
  }

  function initComputed() {}
  function initWatch() {}

  // 将vm._data上的属性代理到 vm 上
  function proxy(vm, source, key) {
    Object.defineProperty(vm, key, {
      get() {
        return vm[source][key];
      },
      set(newValue) {
        vm[source][key] = newValue;
      },
    });
  }
}

其中 initData(vm) 是数据的初始化,在该函数中我们往实例上添加了一个 vm._data 属性,保存data函数返回的数据,另外我们使用 Object.defineProperty 对数据进行代理,实现了通过 vm.key 可以访问到 vm._data[key];另外我们使用 observe(data) 对数据进行监测,observe() 就是实现数据劫持的核心逻辑。

observe()做了什么

思考一下,如何实现observe()?

  • 第一次调用data的时候,它一定是个对象(data函数返回一个对象);如果对象的属性还是个对象,那么我们就需要进行递归监听,即递归调用observe();如果对象的属性是一个基本数据类型,由于它们已经劫持过了,就无需再次劫持;所以我们需要在observe()中对数据类型进行过滤;
  • 如果已经经历过数据劫持的数据,无需再次劫持,所以我们需要在进行数据劫持的时候,添加一个标识符__ob__,并在observe()中对标识符进行判断;
  • observe()只是一个入口文件,具体的劫持流程,我们放到Observer class中执行
// src/observer/index.js
export function observe(data) {
  // 如果是object类型(对象或数组)才观测;第一次调用observe(vm.$options._data)时,_data一定是个对象(data函数返回一个对象)
  if (!(typeof data === 'object' && data !== null)) {
    return;
  }
  // 如果已经是响应式的数据,直接return
  if (data.__ob__) {
    return;
  }
  // 返回经过响应式处理之后的data
  return new Observer(data);
}

Observer是一个class,在Observer中对数据进行劫持;

class Observer如何处理对象和数组

思考一下如何进行数据劫持?

  • 在劫持时需要给数据添加一个标识 __ob__
  • 需要考虑两种数据类型:对象和数组
  • 对于对象,先遍历,使用Object.defineProperty对每个属性进行劫持;然后递归调用observe(),对子孙属性进行劫持
  • 对于数组,如果同样遍历数组,使用Object.defineProperty对每个索引进行劫持,当数组长度很长时,性能很差;考虑到用户直接通过索引修改数组的情况很少,我们通过对数据使用原型继承,在原型上重写7中改变原数组的操作方法,在数组方法中进行劫持。如果数组某一项是对象/数组,还需要递归调用observe()进行劫持。
// src/observer/index.js
class Observer {
  // 通过new命令生成class实例时,会自动调用constructor()
  constructor(data) {
    // 在数据data上新增属性 data.__ob__;指向经过new Observer(data)创建的实例,可以访问Observer.prototype上的方法observeArray、walk等
    // 所有被劫持过的数据都有__ob__属性(通过这个属性可以判断数据是否被检测过)
    Object.defineProperty(data, "__ob__", {
      // 值指代的就是Observer的实例,即监控的数据
      value: this,
      //  设为不可枚举,防止在forEach对每一项响应式的时候监控__ob__,造成死循环
      enumerable: false,
      writable: true,
      configurable: true,
    });

    /**
     * 思考一下数组如何进行响应式?
     * 和对象一样,对每一个属性进行代理吗?
     * 如果数组长度为10000,给每一项设置代理,性能非常差!
     * 用户很少通过索引操作数组,我们只需要重写数组的原型方法,在方法中进行响应式即可。
     */
    if (Array.isArray(data)) {
      // 数组响应式处理
      // 重写数组的原型方法,将data原型指向重写后的对象
      data.__proto__ = arrayMethods;

      // 如果数组中的数据是对象,需要监控对象的变化
      this.observeArray(data);
    } else {
      // 对象响应式处理
      this.walk(data);
    }
  }
  observeArray(data) {
    // 【关键】遍历数组,递归调用observe,监控数组每一项(observe会筛选出对象和数组,其他的不监控)的改变,数组长度很长的话,会影响性能
    // 【*********】数组并没有对索引进行监控,但是对数组的原型方法进行了改写,还对每一项(数组和对象类型)进行了监控
    data.forEach((item) => {
      observe(item);
    });
  }
  walk(data) {
    Object.keys(data).forEach((key) => {
      // 对data中的每个属性进行响应式处理
      defineReactive(data, key, data[key]);
    });
  }
}

使用defineReactive对对象的数据进行劫持

思考一下defineReactive需要实现哪些功能?

  • 使用 Object.defineProperty() 劫持对象属性;
  • 对属性值递归调用observe()
// src/observer/index.js
function defineReactive(data, key, value) {
  observe(value); // 【关键】递归,劫持对象中所有层级的所有属性
  // 如果Vue数据嵌套层级过深 >> 性能会受影响【******************************】

  Object.defineProperty(data, key, {
    get() {
      // todo...收集依赖
      return value;
    },
    set(newVal) {
      // 对新数据进行观察
      observe(newVal);
      value = newVal;
      // todo...更新视图
    },
  });
}

在get()中进行依赖收集,在set()中更新视图,后续再实现;

数组数据劫持

实现思路:

  1. 数组数据通过原型继承,重写数据的原型
  2. 在原型上重写数组方法
  3. 如果数组方法有新增数据,需要对新增数据进行劫持
  4. 遍历数组,如果数组元素是对象/数组类型,还需要进行递归劫持

实现第1、4步:

// src/observer/index.js
class Observer {
  constructor(data) {
    if (Array.isArray(data)) {
      // 1. 数组数据通过原型继承,重写数据的原型
      data.__proto__ = arrayMethods;
      // 4. 遍历数组,如果数组元素是对象/数组类型,还需要进行递归劫持
      this.observeArray(data);
    } else {
      // 对象响应式处理
      this.walk(data);
    }
  }
  observeArray(data) {
    data.forEach((item) => {
      observe(item);
    });
  }
}

实现第2、3步:

// src/observer/array.js
let oldArrayPrototype = Array.prototype
export let arrayMethods = Object.create(oldArrayPrototype)

let methods = [
    'push',
    'pop',
    'unshift',
    'shift',
    'sort',
    'reverse',
    'splice'
]
methods.forEach(method => {
    // 2. 在原型上重写数组方法
    arrayMethods[method] = function(...args) {
        const result = oldArrayPrototype[method].call(this,...args)
        const ob = this.__ob__;
        let inserted;
        switch (method) {
            case 'push':
            case 'unshift':
                inserted = args;
                break;
            case "splice":
                inserted = args.slice(2);
            default:
                break;
        }
        // 3. 如果数组方法有新增数据,需要对新增数据进行劫持
        if (inserted) ob.observeArray(inserted);
        return result
    }
})

Vue2数据劫持的缺点及开发注意事项

缺点:

  • 无法原生劫持数组
    • 通过调用重写的数组方法修改数据能被监测到
    • 由于数组经过了遍历递归劫持,如果数组元素是对象,修改对象属性也能触发响应式【但是通过索引修改该对象整体,无法触发响应式;因为defineReactive劫持的是该对象属性,而不是该对象本身】
    • 如果是嵌套的多维数组,修改深层数组也能触发响应式(在defineReactive中的getter里,对多维数组进行了遍历递归依赖收集,所以多维数组也会影响性能;后续文章再补充)
    • 通过索引修改基本类型的数组元素,或修改数组长度,新增/删除元素,无法被监测
  • 新增和删除属性无法被劫持到:对象的劫持使用的是Object.defineProperty(),新增和删除属性无法触发getter、setter
  • 对象劫持需要一次性递归到底,给所有层级所有属性添加getter、setter,当数据复杂层级很深时,会影响性能

开发注意事项:

  • 使用$set$delete修改和删除数据
  • 当需要新增对象子属性时,可以通过直接修改整个父属性来触发setter,例如:this.obj = Object.assign(this.obj, {k: v}),而使用 this.obj.k = v 无法触发setter

系列文章