「查漏补缺」Vue2.0 源码重写『数据劫持』【面试必备】

1,028 阅读10分钟

前言

学习源码的目标是学习其中的设计思路,在工作业务中就可利用这些思路编写我们的项目。对于数据劫持这一块,是比较重要而且有价值学习的,今天,带着好奇心来探讨学习一下,首先,我们得明白数据劫持目的是什么,它到底做了啥?

数据劫持的目的是什么?

我们不希望原生的对对象或者数组的操作,仅仅是一个单纯的操作,我们希望在对对象赋值或者对数组 push 等方法时,我们可以增加一写操作进去,比如说让视图做数据的绑定,即数据改变的时候也让视图也跟着变化,如果仅仅一个单纯的操作,那么就 state 发生了变化,视图并没有随之变化。因此,我们需要在数据变化的时候拦截一下,在保证数据变化的同时,对我们的视图进行操作,也就是说在操作数据的过程当中,我们希望能够做更多的事情。


如若有帮助到您,请一键三连,当然,本文表述有问题的地方,欢迎读者指正,也是一个学习的过程,谢谢~

阅读须知

在环境搭建和实现相关代码之前,先提供本次代码的目录结构,不然后续一些文件名以及文件路径可能会有小伙伴有疑惑。

目录结构

--vueDemo  ---项目文件夹

---public  ---主页面
----index.html

---src  ---项目主入口
----index.js

---vue ---数据劫持源码实现
----array.js 
----config.js
----index.js
----init.js
----observe.js
----observer.js
----observerArr.js
----proxy.js
----reactive.js

---package.json
---webpack.config.js ---配置webpack

本篇实现源码

提供本次实现源代码,Give a ⭐️ if this project helped you!

Vue2.0源码重写『数据劫持』

须知

本篇代码均本人一字一句敲的,代码中有着比较详细的注释,学习了两遍终于理解了其中实现原理,关于环境搭建那一块,对于 webpack 比较熟悉的小伙伴可以直接跳过,直接到 Vue使用 这一章节开始阅读。同时,本篇还提及到了两个设计模式:观察者模式装饰者模式,是不是有点期待了呢?

后续的文章也会加快更近,带着好奇心去学习,去思考~

对于有疑惑点可以提出来,一起讨论~

初始化及环境搭建

创建一个 vueDemo 文件夹,初始化 npm

npm init -y

webpack安装

初始化我们需要 webpack 环境,执行下面代码:

npm install webpack webpack-cli webpack-dev-server

入口文件

然后,在项目根目录下创建 webpack.config.js 文件,配置一下入口文件,如下:

module.exports = {
  // 配置入口
  entry: './src/index.js'
}

然后在根目录下我们创建对应入口文件 src 文件夹。

public

其次,我们需要 html 文件,与 src 同级,我们新创建一个 public 文件夹,在其中创建 index.html文件。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue2.0源码重写『数据劫持』【面试必备】</title>
</head>
<body>
  <div id="app"></div>
</body>
</html>

配置输出

配置好了入口文件,接下来就是配置输出了:

const path = require('path');

module.exports = {
  // 配置入口
  entry: './src/index.js',
  // 配置输出
  output: {
    // 文件名称
    filename: 'bundle.js',
    // 配置路径
    path: path.resolve(__dirname, 'dist')
  },
}

配置html-webpack-plugin

处理 html 文件,我们需要一个 plugin,执行下面代码进行安装。

npm install html-webpack-plugin

在实例化 HtmlWebpackPlugin 时,会接受一个参数,其中一个就是配置模板 template

plugins: [
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, 'public/index.html')
    })
  ]

定义 source-map

打包时我们可能会出现问题,有的时候比较难发现,而通过定义 source-map 后,我们就能将错误定义到源码上来。

// 配置 source-map ,打包出错定位到源码上
devtool: 'source-map',

引入vue

在根目录下创建一个名为 vue 文件

src文件中,我们引入 Vue,此时有一个问题,假设我就想通过方式一引入可以吗?

方式一:

import Vue from '/vue';

方式二:

import Vue from '../vue';

按照目前配置好的代码是不可行的,只能通过方式二来引入,因为默认会去 node_modules 中去找。那么我们来配置一下就好了:

resolve: {
    modules: [path.resolve(__dirname, ''), path.resolve(__dirname, 'node_modules')]
  },

package.json 设置命令

上文配置好了 webpack 后,我们就需要在 package.json 文件中设置打包相关命令了:

"scripts": {
    "dev": "webpack-dev-server",
    "build": "webpack"
  },

webpack.config.js 文件代码

下面给出本文配置 webpack 源码:

const path = require('path'),
      HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  // 配置入口
  entry: './src/index.js',
  // 配置输出
  output: {
    // 文件名称
    filename: 'bundle.js',
    // 配置路径
    path: path.resolve(__dirname, 'dist')
  },
  // 配置 source-map ,打包出错定位到源码上
  devtool: 'source-map',
  resolve: {
    modules: [path.resolve(__dirname, ''), path.resolve(__dirname, 'node_modules')]
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, 'public/index.html')
    })
  ]
}

Vue使用

options API

我们在使用 Vue 的时候,会进行实例化操作,如下代码,在使用 Vue 函数的时候,传入了一个对象,而这个对象其实就叫做 options,因此,Vue 2.0 又叫做 options API 就是这个道理。传入的对象都是一些选项,比如 data、methods、computed、生命周期函数等等,需要的时候按照规范传入即可。

import Vue from 'vue';
// options
let vm = new Vue({
  el: '#app',
  data() {
    return {
      title: '学生列表',
      classNum: 1,
      total: 2,
      teachers: ['张三', '李四'],
      students: [
        {
          id: 1,
          name: '小红'
        },
        {
          id: 2,
          name: '小明'
        }
      ]
    }
  }
})

Vue函数 / 初始化

在上文我们知道 Vue 是个函数,那么我们用 es5语法来写的话,就要写对应构造函数了。

function Vue(options){
  
}

export default Vue;

Vue 2.0 中做的事情主要是给 Vue 函数的原型上添加方法。这就涉及到原型、原型链、原型对象、创建对象方法等相关知识了,可以查漏补缺~

Vue 会有一个初始化函数,在初始化的时候完成对数据的劫持,如下代码所示:

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

// 初始化
Vue.prototype._init = function (options) {
  // 保存实例
  var vm = this;
  vm.$options = options; // 将 options 挂载到实例上

  initState(vm); // 初始化状态
}

export default Vue;

initState / 初始化状态

上文代码我们在函数原型上定义了初始化方法,将 options 挂载到实例上,现在我们需要初始化状态,此时,创建一个名为 init.js 的文件,来编写 initState 函数,同理,在 Vue 2.0 源码中,你还可以看到 initMixin 函数,这些都是放在 init.js 文件,由于本文探讨数据劫持,因此就只整理关于 initState 函数:

function initState(vm) {
  var options = vm.$options;
  // 判断 data 是否存在
  if (options.data) {
    initData(vm); // 初始化数据
  }
}

function initData(vm) {
  var data = vm.$options.data;
  // 将 data 挂载到 vm 的 _data 上
  vm._data = data = typeof data === 'function' ? data.call(vm) : data || {};

}

export {
  initState
}

initData

初始化状态时,首先要判断这个 data 是否存在,存在的话才会进行初始化数据的操作,在上述代码中,我们对数据进行了处理,将 data 挂载到 vm_data 上,可能比较疑惑的就是下面这一行代码:

vm._data = data = typeof data === 'function' ? data.call(vm) : data || {};

现在来分析一下,对于传过来的data,我们想象,是不是实例化 Vue 的时候传过来的?可能会有如下两种传参方式:

// 第一种方式:
let vm = new Vue({
  el: '#app',
  data() {
    return {
      // xxx
    }
  }
})
// 第二种方式:
let vm = new Vue({
  el: '#app',
  data: {
    return {
      // xxx
    }
  }
})

对于第一种传过来的就是一个 data 函数,我们如果不执行的话,是不会得到我们想要的数据的,而对于第二种直接传对象的方式,有可能啥也没有,因此需要一个默认值 {},所以就会有上文那一行代码。

数据是如何访问的?

好了,上述问题解决完了之后,我们又来了一个新的问题:数据是如何访问的?

我们不妨打印一下 titile

console.log(vm._data.title); // 学生列表

然而这种方式不是我们想要的,在 vue 2.0 中,我们可以通过 vm.title来访问,或者在 methods中直接通过 this.title 来访问。

proxy 代理数据

为了解决上述问题,此时就需要我们设置一下代理了,创建一个名为 proxy.js文件(并非用 ES6 proxy),在里面我们定义我们的代理函数:

function proxyData(vm, target, key) {
  Object.defineProperty(vm, key, {
    get() {
      // 相当于将vm._data.title 转变成 vm.title
      return vm[target][key];
    },
    set(newVal) {
      // set 同理
      vm[target][key] = newVal;
    }
  })
}
export default proxyData;

此时,返回到 init.js 文件,导入我们写好的代理函数 proxyData,对我们挂载的数据进行代理操作,如下:

import proxyData from './proxy';
function initState(vm) {
  var options = vm.$options;
  // 判断 data 是否存在
  if (options.data) {
    initData(vm); // 初始化数据
  }
}

function initData(vm) {
  var data = vm.$options.data;
  // 将 data 挂载到 vm 的 _data 上
  vm._data = data = typeof data === 'function' ? data.call(vm) : data || {};
  /* 进行代理 */
  for (var key in data) {
    proxyData(vm, '_data', key);
  }
}

export {
  initState
}

此时,返回 src/index.js 文件,我们就直接可以用 vm.title 来获取对应数据了。

console.log(vm.title); // 学生列表

观察数据

观察者模式

在上一章节,我们完成了数据状态初始化,并且代理了数据,现在就需要观察了,这里涉及到 观察者模式 ,就是对 data 进行观察,并且还需要对 data 内部也需要观察。对于内部是对象情况,我们就需要进行数据劫持,而如果是数组的话,还需要对相关方法进行拦截,这里就先提及一下,在后文我们来详细探讨一下,带着好奇心学习这个观察者模式

init.js 文件中,对数据进行观察

// 观察 data
observe(vm._data);

创建一个名为 observe.js 的文件来专门写观察函数 observe

import Observer from './observer';

function observe(data) {
  // 观察对象,如果不是对象形式,直接返回即可
  if(typeof data !== 'object' || data === null) return;
  // 添加观察者
  return new Observer(data);
}
export default observe;

添加观察者

我们观察的是对象,对于非对象形式,我们直接返回即可,然后需要给对象添加观察者,因此我们还需要创建一个名为 Observer.js 的文件,设置观察者,此时就有一个问题了,对于对象和数组我们处理方式是不一样的,对象可以用 defineProperty,而数组需要自己去写相应方法,因此,我们需要进行判断:

function Observer(data) {
  // 处理数组
  if (Array.isArray(data)) {

  } else {
    this.walk(data);
  }
}

// 原型上方法 walk
Observer.prototype.walk = function (data) {
  // 响应式需求,我们需要重新定义对象
  // 获取对象中的 key 数组
  var keys = Object.keys(data);

  for (var i = 0; i < keys.length; i++) {
    var key = keys[i],
      val = data[key];
    // 处理响应式
    defineReactiveData(data, key, val);
  }
}

export default Observer;

先处理对象

对于上述代码,我们优先处理对象,而对于数组的话,我们在后文继续探讨分析。

对于对象的话,由于响应式需求,我们就需要重新定义对象,拿到对象的 key-val,然后通过defineReactiveData 方法来处理响应式,因此我们又需要建立一个名为 reactive.js 的文件:

import observe from './observe';
function defineReactiveData(data, key, val) {
  // 有可能当前val还是一个对象,因此继续观察当前 val
  observe(val);
  Object.defineProperty(data, key, {
    get() {
      console.log('响应式数据-获取', val);
      return val;
    },
    set(newVal) {
      console.log('响应式数据-设置', newVal);
      if (newVal === val) return;
      // 可能更新的这个newVal 还是一个对象或者数组,我们需要再观察一下
      observe(newVal); 
      val = newVal;
    }
  })
}
export default defineReactiveData;

注意点! 上述代码中,我们首先对 val 进行了一个递归操作,然后对 set 设置的 newVal 也进行了观察,这是为什么呢?

因为当我们观察对象的时候,有可能这个对象里面还有一个对象,因此我们就需要进行递归操作。我们不妨在 data 增加一个数据如下:

info:{
  club:{
    name: '篮球',
    num: 30
  }
},

然后在 src/index.js 文件中,我们打印一下:

console.log(vm.info);

查看下图,通过添加观察者,我们的对象数据通过 defineProperty 方法都有了 getset 方法,相当于外包了一层。

对象里面也有对应 get 和 set

后处理数组

上一章节,我们处理完了对象,现在我们来好好探讨一下处理数组的方式,对源码有一定了解的小伙伴肯定知道 Object.defineProperty 并不是用来劫持数组的,原本是来劫持对象的,那么我们需要拦截数组的 7 个改变原数组的方法,并且对于修改原数组的方法,我们也不希望它通过原型上的方法直接进行更改。

其次,对于 push、unshifit、splice 这些方法,它们会新增一项进去,而对于新增的这一项我们又需要观察它是不是数组,是不是对象,或者是原始值,这显然麻烦了起来,而用原本 Array 原型上方法我们没办法满足这些需求,因此我们需要重写原型上的方法。

创建一个名为 array.js 文件,用来专门处理数组,在此之前,我们先创建一个名为 config.js 文件,用来存放我们需要操作数组方法。

以下就是可以改变原数组的 7 个方法(关于改变原数组这一块面试也是常考点,正好复习了一下)

var ARR_METHODS = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
];

export {
  ARR_METHODS
}

装饰者模式

上文我们探讨了数组无法劫持的原因,下面我们就引出新的设计模式——装饰者模式,来重写数组原型方法,具体如下述代码,先取出数组原型,拷贝一份,避免影响到了原来的原型链。

然后通过遍历方法,我们重写一下对应方法,先调用原来的方法,来获得我们更新的数据,然后对于 push、unshifit、splice 这3个方法,会有新增数组项的操作,我们将这新增项保存到 newArr 中,然后观察这个新增项,看它是不是对象还是数组还是原始值。

import { ARR_METHODS } from './config';

var originArrMethods = Array.prototype,  // 取出数组原型
  arrMethods = Object.create(originArrMethods); // 拷贝一份,原因:避免影响到了原来的原型链

// 装饰者模式,重写原型方法
ARR_METHODS.map(function (m) {
  arrMethods[m] = function () {
    // 把arguments 类数组转化成数组
    var args = Array.prototype.slice.call(arguments);
    // 先调用原来的方法
    var rt = originArrMethods[m].apply(this, args);

    var newArr; // 用来存储新增的那一项

    switch (m) {
      case 'push':
      case 'unshift':
        newArr = args;
        break;
      case 'splice':
        newArr = args.slice(2);
        break;
      default:
        break;
    }
    // 如果有新增项,那么需要观察这个新增项
    newArr && ObserverArr(newArr);
    return rt; // 返回调用原来方法得到的结果
  }
})

对于观察新增项,我们再新增一个名为 observerArr.js 的文件,如下代码:

import observe from './observe';
function observerArr(arr) {
  for (var i = 0; i < arr.length; i++) {
    observe(arr[i]);
  }
}
export default observerArr;

然后调用原本写好的 observe 方法进行观察即可,至于添加观察者那一块,我们上文不是还有空着的代码块嘛,下面我们来进行补充。

import defineReactiveData from './reactive';
import observerArr from './observerArr';
import { arrMethods } from './array';
function Observer(data) {
  // 处理数组
  if (Array.isArray(data)) {
    data.__proto__ = arrMethods; // 将重写数组的prototype替换到data上的prototype
    observerArr(data); // 可能数组里面还有数组,我们还需要再次观察一下
  } else {
    this.walk(data);
  }
}

// 原型上方法 walk
Observer.prototype.walk = function (data) {
  // 响应式需求,我们需要重新定义对象
  // 获取对象中的 key 数组
  var keys = Object.keys(data);

  for (var i = 0; i < keys.length; i++) {
    var key = keys[i],
      val = data[key];
    // 处理响应式
    defineReactiveData(data, key, val);
  }
}

export default Observer;

测试

基本上重写代码就完工了,我们来测试一下代码,看是否实现了对数据的劫持操作

console.log(vm.title); // 学生列表
console.log(vm.info);
console.log(vm);
console.log(vm.teachers[0]);
console.log(vm.info.club.num = 40);
console.log(vm.students.splice(1, 1, {
  id: 3,
  name: '小白'
}));

打印结果如下:


本文到此也就结束了,原本以为特别难理解的数据劫持今天通过这一篇梳理也是明白了许多,里面还涉及到了设计模式以及有价值的设计思路。后续我会继续学习 Vue3.0 源码重写『数据劫持』 ,文章也会加快更新,下篇再见啦~

本文参考

【全网首发:完结】Vue2.0源码重写『数据劫持』【面试必备】

感谢小野老师的对Vue2.0源码重写『数据劫持』的细致讲解,给老师打call,建议大家可以结合视频看一看,看完会恍然大悟的!

最后

文章产出不易,还望各位小伙伴们支持一波!

往期精选:

小狮子前端の笔记仓库

leetcode-javascript:LeetCode 力扣的 JavaScript 解题仓库,前端刷题路线(思维导图)

小伙伴们可以在Issues中提交自己的解题代码,🤝 欢迎Contributing,可打卡刷题,Give a ⭐️ if this project helped you!

访问超逸の博客,方便小伙伴阅读玩耍~

学如逆水行舟,不进则退