0%

Vue源码 数据劫持

双向数据绑定是 Vue 的一大亮点和重点,这里讲解一下原理并且重写一遍。版本为 2.x

原理

Vue 2.x 中的双向数据绑定原理来源于一个对象方法, Object.defineProperty , 通过这个方法我们可以定义对象属性的 gettersetter ,于是我们可以在数据被重新设置(即调用 setter 时)进行视图更新等操作。

大致思路

使用 Vue 时我们会引入 vue 的默认导出,这个默认导出是一个构造函数,我们利用这个构造函数创建一个 vue 实例,并传入我们的配置参数,例如管理的 dom 元素以及 data 等参数,虽然在一般开发中我们不会在根实例上挂载数据,但此处仅做一个简单还原。

传入参数后,我们在构造函数内调用初始化函数,初始化操作一般为挂载配置项,初始化各种数据,例如 data , computed props 等。

我们首先需要对data 做一层代理,因为在配置项中传入的 data 一般为函数,以防止组件复用时因为引用相同而数据混乱。并且在 vue 实例中无法直接访问到数据,我们把 data 挂载到实例上,并使用 Object.defineProperty 使得我们可以使用 this 直接访问到各项 data

做完代理,我们需要对数据进行响应式处理,即观察数据项的修改。首先是 data 本身,我们抽出一个 observe 函数来进行观察操作,首先判断传入的观察对象的类型,如果不是引用类型其实没有观察的必要,因为普通类型的修改已经在父级做了观察,我们需要深层观察的是子对象、子数组、子对象子数组内部的引用类型等。

如果是引用类型,我们初始化一个观察者来进行观察。在观察者内部,我们迭代传入的引用类型,对每个 keyvalue 做一个响应式处理,当访问当前对象的 key 属性时,我们返回 value 。此处可以做一些操作,例如收集依赖。当设置当前对象的时候,我们去修改 value 的值。需要注意的是,此处不能够直接去获取或者修改 obj[key] ( obj 指代做响应式处理的对象或数组),因为我们正是对 obj[key] 做了观察,当我们观察到需要获取 key 属性时,getter 内部又是获取 key 属性,会造成死循环。setter 同理。

数组无法直接做响应式处理,我们选择遍历数组,对每个元素单独观察。但此处又有一个问题,因为 Object.defineProperty 无法响应数组的变化,当我们修改了数组,例如增加了新的元素,新增的元素没有被观察,数组的修改也没有被观察到。那么我们就需要重新定义数组的修改方法,就是 push pop sort splice shift unshift reverse ,我们使用数组原型创建一个新的数组对象,重写这个数组对象上的七个方法,在方法内部还是依然执行原本的函数内容,但此处我们就可以增加一些自己的操作,例如视图刷新等。定义完这个数组对象后,我们将它赋值给需要被响应式处理的数组的原型,那么就单独影响了我们响应式的数组,而不对其余的普通数组产生影响。

在这个过程中还有一些小点,就是每次对对象的值或是数组的元素观察时,我们需要注意这个元素是否也是一个引用类型,如果是的话,我们需要再对这个元素进行观察。

代码实现

首先配置一个 webpack 环境,下载 webpack webpack-cli webpack-dev-server 以及 html-webpack-plugin 。在项目根目录配置 webpack.config.js 。我们需要配置项目的入口,出口,插件,以及模块引入的规则。

// /webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
  entry: './src/index.js', // 定义入口
  output: {
    filename: 'index.js', // 定义出口的文件名
    path: path.resolve(__dirname, 'dist') // 定义出口路径
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, 'public/index.html') // 定义文件模板位置
    })
  ],
  devtool: 'source-map',
  resolve: {
    // 配置模块引入的规则,首先从 src 目录寻找,再去 node_modules 中寻找。
    modules: [path.resolve(__dirname, 'src'), path.resolve(__dirname, 'node_modules')]
  }
};

再来看入口文件:

import Vue from 'vue';
// 实例化 Vue
let app = new Vue({
  el: '#app',
  data() {
    return {
      title: "Sirine' working time",
      time: '2020-10-11',
      info: {
        name: 'zhao',
        age: 21,
        isGood: true
      },
      say: [
        123,
        '456',
        {
          a: 123,
          b: [1, 2, 3, 4],
          c: {
            today: 'is'
          }
        }
      ]
    };
  }
});

此处只是简单创建了实例。

然后看 vue 文件夹。

// src/vue/index.js
import initState from './init'; // 引入初始化数据的函数。
function Vue(options) {
  // vue 的构造函数,初始化实例。
  this._init(options);
}
Vue.prototype._init = function (options) {
  // 在原型上挂载初始化函数,将传入的参数挂载到实例上。
  var vm = this;
  vm.$options = options;
  initState(vm); // 调用初始化数据的函数。
};

export default Vue;

接下来看 initState 这个函数。

// src/vue/init.js
import observe from './observe';
import proxyData from './proxy';
// 引入观察函数和代理数据函数。

function initState(vm) {
  var options = vm.$options;
  // 首先取出 options
  if (options.data) {
    // 判断参数中是否有 data ,有才执行初始化。
    initData(vm);
  }
}
function initData(vm) {
  var _data = vm.$options.data; // 取出数据
  _data = vm._data = typeof _data === 'function' ? _data.call(vm) : _data || {};
  // 将数据挂载到实例上,并且判断是否是函数,是的话调用它,否的话保持原样,并确保至少是一个对象。
  for (var key in _data) {
    // 遍历 data 做一层代理。
    proxyData(vm, '_data', key);
  }
  // 观察这个 data
  observe(_data);
}
export default initState;

首先看 proxyData 函数。

// src/vue/proxy.js
function proxyData(data, target, key) {
  // 传入的参数分别是做代理的对象,挂载的属性名,和当前的属性名
  Object.defineProperty(data, key, {
    get() {
      // 当我们获取 data[key] 时,实际上是从 data[target][key] 中获取。
      return data[target][key];
    },
    set(newVal) {
      // 当我们设置 data[key] 时,实际上设置在了 data[target][key]。
      data[target][key] = newVal;
    }
  });
}
export default proxyData;

此处做一个解释,我们在 initData 中将 options 中的 data 挂载到了 vm._data 上,而我们想要直接调用 vm.xxx 来获取,但是 vm 上并没有这个属性,它实际上是在 vm._data.xxx 上,所以我们做一层代理,当访问 vm.xxx 时,我们返回 vm._data.xxx ,当设置 vm.xxx = yyy 时,实际上是执行了 vm._data.xxx = yyy

然后再看 observe 函数。

// src/vue/observe.js
import Observer from './observer'; // 引入实际的观察者

function observe(data) {
  if (typeof data !== 'object' || data === null) return;
  // 首先判断数据类型,如果不是引用类型,不需要再深入观察了。
  return new Observer(data); // 否则,返回一个观察者实例。
}

export default observe;

当我们想要深入观察时,都会调用 observe 函数,所以我们需要判断类型,原始类型已经在父级中被观察了,不需要继续深入。

// src/vue/observer.js
import defineReactiveData from './reactive';
import observeArr from './observeArr';
import arrMethods from './array';

function Observer(data) {
  if (Array.isArray(data)) {
    // 判断数据是否为数组,是数组的话,给它赋予变异数组方法,再观察这个数组。
    data.__proto__ = arrMethods;
    observeArr(data); // 观察数组的方法。
  } else {
    // 如果是对象的话。
    var keys = Object.keys(data); // 遍历对象的键值
    keys.forEach(key => {
      var value = data[key]; // 获取每个值
      defineReactiveData(data, key, value);
      // 将对象,键名,键值传入响应式函数进行响应式绑定。
    });
  }
}

export default Observer;

此处引入的函数较多,一个个看。

先看对象部分,逻辑已经非常清晰,对这个对象的每个键值进行 getter setter 重写,我们需要先取出每个值形成闭包用来访问,否则会出现死循环问题。

// src/vue/reactive.js
import observe from './observe';

function defineReactiveData(data, key, value) {
  observe(value); // 继续观察这个传入的值,可能需要深入观察。
  Object.defineProperty(data, key, {
    get() {
      console.log('响应式 get', value);
      return value;
    },
    set(newVal) {
      console.log('响应式 set', value, newVal);
      if (value === newVal) return;
      value = newVal;
    }
  });
}

export default defineReactiveData;

依然是熟悉的自定义 settergetter ,函数内部可以写入许多其余逻辑来进行其他操作。

此处调用 observe(value) 是因为,data[key] 的值可能是数组和对象,它们的值是一个引用地址,不是覆盖的情况下无法监听到变化, 我们需要再次观察它,使用 observe 来判断,如果是引用类型会深入观察,不是的话就直接返回。

谈完了对象,再来看数组。

Object.defineProperty 无法监听到数组的变化,所以我们需要遍历数组,进行单独的监听。

// src/vue/observeArr.js
import observe from './observe';

function observeArr(arr) {
  arr.forEach(item => {
    observe(item);
  });
}

export default observeArr;

而数组对于自身变化的方法我们需要进行重写,否则数组的修改没有办法监听。

// src/vue/array.js
import ARRAY_METHODS from './config';
import observe from './observe';

var arrayPrototype = Array.prototype; // 取出数组的原型对象
var arrMethods = Object.create(arrayPrototype); // 根据这个原型对象创建一个数组

ARRAY_METHODS.forEach(m => {
  // 遍历七个数组方法,对我们创建的这个数组上写入我们自定义的方法。
  arrMethods[m] = function () {
    var args = arrayPrototype.slice.call(arguments); // 转换传入的参数为数组。
    var result = arrayPrototype[m].apply(this, args); // 调用原本的方法。

    console.log('动态修改数组', m);

    var newItem; // 此处用于监听新增的数据。
    switch (m) {
      case 'push':
      case 'unshift':
        newItem = args;
        break;
      // 如果是 push unshift 方法,只需要监听我们传入的参数即可。
      case 'splice':
        newItem = args.slice(2);
        break;
      // 如果是 splice 方法,我们监听可能存在的第三个参数以及更多。
      default:
        break;
    }
    newItem && observe(newItem); // 如果存在新的数据,我们进行监听。

    return result; // 将原本数组的返回指返回。
  };
});

export default arrMethods;

到此差不多完成了简单的数据劫持,当我们动态修改这些数据的时候,会被监听到并且进行一些需要的操作。