vue 源码分析:数据劫持

897 阅读5分钟

前言

Object.defineProperty() 方法,是理解 vue2.0 数据劫持的关键,若是还不了解此方法,可以先去学习,然后在阅读本文或源码。去学习

本文所写的数据劫持 demo,主要参考 vue2.0 源码。为简便易懂,仅抽离了核心功能并做出了一点小小的改变。希望本文对你阅读 vue2.0 源码的数据劫持部分有所帮助。最后,奉上案例源码,以便大家学习理解。

环境搭建

注意:确保已安装好 node 环境

  1. 新建一个文件夹: xxx

  2. 在当前文件夹,打开 PowerShell 或 DOS 窗口

  3. 执行 npm init 进行初始化(一直按回车)

  4. 使用 npm 安装所需依赖包

npm webpack webpack-cli webpack-dev-server html-webpack-plugin -D
  1. 打开 package.json 文件,修改 scripts,添加两行命令。其它基本属性,自行修改。
"scripts": {
    "dev": "webpack-dev-server", // 运行 npm run dev
    "build": "webpack" // 打包 npm run build
  },

最终结果(package.json):

{
  "name": "vue-demo",
  "version": "1.0.0",
  "description": "vue-数据劫持",
  "main": "index.js",
  "scripts": {
    "dev": "webpack-dev-server",
    "build": "webpack"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "html-webpack-plugin": "^4.4.1",
    "webpack": "^4.44.1",
    "webpack-cli": "^3.3.12",
    "webpack-dev-server": "^3.11.0"
  }
}
  1. 新建 webpack.config.js,进行基本配置。
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: './src/index.js', // 入口文件
  
  // output 指示 webpack 如何去输出、以及在哪里输出你的「bundle、asset 和
  // 其他你所打包或使用 webpack 载入的任何内容」。
  output: { 
    filename: 'js/bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  
  devtool: 'source-map', // 生成 source map
  
  resolve: {
    // 如下配置 modules 后,你可以在入口文件中使用 import Vue from 'vue'; 
    // 这种引入方式,就像在 vue 项目的 main.js 中使用一样
    modules: [path.resolve(__dirname, ''), path.resolve(__dirname, 'node_modules')]
  },
  
  devServer: {
    host: '127.0.0.1', // 本地服务
    port: 8088 // 默认端口
  },
  
  plugins: [
  // 该插件将为你生成一个 HTML5 文件, 在 body 中使用 script 标签引入
  // 你所有 webpack 生成的 bundle。
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, 'public/index.html')
    })
  ]
}

项目结构预览

  • src/index.js 项目入口 js 文件
  • src/vue 下的文件,是实现数据劫持的核心
  • public/index.html 页面入口文件

结构.jpg

代码解析

src/index.js 创建实例

通过 new 关键字,创建 Vue 实例。

import Vue from 'vue';

const vm = new Vue({
  el: '#app',
  data () {
    return {
      studentNum: 1,
      subject: ['历史', '文化'],
      bookInfo: {
        name: '三国演义',
        author: {
         name: '罗贯中',
         age: 18
        }
      },
      studentList: [{ id: 1, name: '小明' }]
    }
  }
});

console.log('vm实例', vm);
vm.studentList.push({id: 2, name: '葡萄'});
console.log('几个人', vm.studentList);

vue/index.js 入口

定义 Vue 函数并导出,以便通过 new 关键字进行实例化。同时,执行 initMixin(Vue) 函数,将 _init 初始化放法,挂载到 Vue.prototype 上。

import { initMixin } from './init';

function Vue (options) {
  // 通过关键字 new 创建 Vue实例时,便会调用 Vue 原型方法 _init 初始化数据
  this._init(options); 
}

// 执行 initMixin,会在 Vue.prototype(Vue原型)上挂载 _init 方法
initMixin(Vue); 

export default Vue;

vue/init.js 初始化

调用 initState(vm) 函数,初始化数据。

import { initState } from './state';

function initMixin (Vue) {
  Vue.prototype._init = function (options) {
    const vm = this; // 存储 this( Vue实例 )
    vm.$options = options; // 将 options 挂载到 vm 上,以便后续使用

    // Vue 实例中的 data、 props、methods、computed 和 watch,都会在 initState 函数中
    // 进行初始化。由于我们主要解说:Vue 数据劫持,所以只对 data 进行处理。
    initState(vm);
  }
}

export {
  initMixin
}

vue/state.js 初始化 data

如若 data 存在,则调用 initData(vm) 函数初始化 data。

import proxy from './proxy';
import observe from './observe/index';

function initState (vm) {
  const options = vm.$options;

  if (options.data) {
    initData(vm); // 初始化 data
  }
}

function initData (vm) {
  let data = vm.$options.data;

  // Vue 中的 data 可以是函数(Vue 中建议将 data 作为一个函数来使用),
  // 也可以是 Object --> {}
  data = vm.$data = typeof data === 'function' ? data.call(vm) : data || {};
  
  for (var key in data) {
    // proxy 实现数据代理,vm.name --> vm.$data.name
    proxy(vm, '$data', key);
  }

  // observe,对数据进行观测,以便在其发生改变时,做出反应。
  observe(vm.$data); 
}

export {
    initState
}

vue/proxy.js 数据代理

proxy() 函数实现数据代理的关键在于,使用了 Object.defineProperty() 方法。

function proxy (vm, target, key) {
  // Object.defineProperty() 方法会直接在一个对象上定义一个新属性,
  // 或者修改一个对象的现有属性,并返回此对象。
  
  // 将属性都挂载到 vm(Vue实例)上,并设置属性的 getter/setter,
  // 以实现数据代理:vm.name --> vm.$data.name
  
  Object.defineProperty(vm, key, {
    get () {
      return vm[target][key]; // vm[target][key] --> vm.$data.name
    },
    set (newValue) {
      vm[target][key] = newValue;
    }
  });
}

export default proxy;

vue/observe/index.js 观察数据

执行 observe(val) 函数时,会先判断参数 val 是否为对象(数组也是对象)。若不是,则阻止运行。若是,则调用 new Observer(val) ,它会根据 val 是数组( [object Array] )还是对象( [object Object] )来调用不同的方法观察数据对象。

import observeArray from './observeArray';
import { defineReactive } from './reactive';
import { arrayMethods } from './array';
import { isObject } from '../shared/util';

function observe (val) {
  // 检查 val 是否为对象(注意:在 js 中,数组也是对象,isObject并不排除数组)。
  if (!isObject(val)) return;
  return new Observer(val);
}

function Observer (val) {
  if (Array.isArray(val)) {
    // arrayMethods 中存储的是:重写的数组方法,例如:push、unshift 等。
    // 重写为了在更改数组中的数据时,做出更多操作。比如,通过 push 方法向
    // 数组中新添数据时, 需要对新的数据进行劫持,设置 getter/setter,否则
    // 它们将不能在后续的修改中做出反应。实际上,重写的数组方法,其内部依旧使用
    // 数组的原生方法来实现数据的增、删。

    val.__proto__ = arrayMethods; // 使用 __proto__ 拦截原型链来增加目标对象
    observeArray(val); // 观察数组(Array)的每一项
  } else {
    this.walk(val); // 观察对象(Object --> {})
  }
}

// 遍历所有属性并将它们转换为 getter/setter。仅当值类型为 Object 时才应调用此方法
Observer.prototype.walk = function (data) {
  // Object.keys() 方法会返回一个由一个给定对象的自身可枚举属性组成的数组,
  // 数组中属性名的排列顺序和正常循环遍历该对象时返回的顺序一致 。
  const keys = Object.keys(data);
   
  for (let i = 0; i < keys.length; i ++) {
    const key = keys[i]; // 属性
    const value = data[key]; // 属性值
    // 在对象上定义一个反应性属性
    defineReactive(data, key, value);
  }
}

export default observe;

vue/shared/util.js

// 对象检测
export function isObject (obj) {
    return obj !== null && typeof obj === 'object'
}

vue/observe/array.js 数组方法重写

重写数组方法(例如:push、unshift 等),并不是要替换它们,而是为了在更改数组中的数据时,做出更多操作。比如,通过 push 方法向数组中新添数据时,要对这些新的数据进行劫持,否则它们在后续的更改中无法做出反应。

import observeArray from './observeArray';

// 存储数组方法
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
];

const slice = Array.prototype.slice;
const arrayProto = Array.prototype; // 存储数组原型
const arrayMethods = Object.create(arrayProto); // 创建一个新的数组原型对象

methodsToPatch.forEach(function (method) {
  
  const original = arrayProto[method]; // 缓存数组的原方法
  
  arrayMethods[method] = function () {

    let inserted; // 存储数组中新增的值,默认undefined
    let args = slice.call(arguments); // 将 arguments 转成一个新的数组并返回

    // 这里可以不要返回值,直接写:original.apply(this, args)
    const result = original.apply(this, args); // 调用数组原生方法,对数组进行增、删。

    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args;
        break;
      case 'splice':
        // splice() 方法用于添加或删除数组中的元素
        // 删除:splice(0, 1) --> args 即 [0, 1]
        // 增加:splice(1, 0, '新增') --> args 即 [1, 0, '新增']

        // slice() 方法可从已有的数组中返回选定的元素
        // args.slice(2),固定下标值为 2,是因为 splice 的使用方式:
        // splice,若是删除,则 args.slice(2) 返回空数组
        // splice,若是新增,则 args.slice(2) 返回一个新数组,里面是所有新增的数据
        inserted = args.slice(2);
        break;
      default:
        break;
    }

    // inserted 为真(空数组 --> [],也是真),则调用 observeArray() 方法对其进行观察
    inserted && observeArray(inserted);

    return result;
  }
});

export {
  arrayMethods
}

vue/observe/observeArray.js 递归观察数组的每一项

import observe from './index';

function observeArray (arr) {
    // 遍历数组 arr, 递归观察它的每一项
  for (let i = 0; i < arr.length; i ++) {
    observe(arr[i]); 
  }
}

export default observeArray;

vue/observe/reactive.js 劫持数据

import observe from "./index";

function defineReactive (data, key, value) {
  
  // 递归观察value,它可能是一个对象
  observe(value); 

  // Object.defineProperty() 方法会直接在一个对象上定义一个新属性,
  // 或者修改一个对象的现有属性,并返回此对象。它是实现数据劫持的关键所在。
  Object.defineProperty(data, key, {
    get: function reactiveGetter () {
      return value;
    },
    set: function reactiveSetter (newValue) {
      if (newValue === value) return; // 同名属性,不需要重新赋值或观察
      observe(value); // 递归观察value,它可能是一个对象
      value = newValue;
    }
  });
}

export {
  defineReactive
;

结束语

只阅读而不敲代码,这是不行的。记得敲一遍,边敲边调试,唯有如此,方能深刻理解。