手写 Vue2 源码之实现 new Vue

482 阅读5分钟

前言

Vue 源码一直是前端面试必问的题目,熟悉源码能让我们在面试过程中脱颖而出,熟悉源码也能让我们在开发过程中知道整个框架是如何运转的,遇到了问题,我们能第一时间知道是哪里出了问题。

今天开始,打算手写一下 Vue2 的源码,主要包括 Vue响应式原理模板转化为 ast 语法树虚拟 Dom转成真实 Dom异步更新Diff 算法watchcomputed 等等,生成一个可以使用的简易版 Vue2 框架 mini-Vue2

今天第一篇文章就从实现 new Vue() 开始,看看 new Vue() 需要进行哪些操作。

搭建项目

生成 package.json

npm init -y

打包工具选择的是 rollup,原因是rollup比较轻量,打包体积小,适合做插件、小型框架和库的开发。

安装 rollup

npm install rollup --save-dev

还需要安装 babel

npm install rollup-plugin-babel @babel/core @babel/preset-env --save-dev

安装 rollup 配置插件

npm install rollup-plugin-livereload rollup-plugin-serve rollup-plugin-uglify --save-dev

package.json 内容

devDependencies 内的包都要装上,简单说下一些包的作用:

  • @babel/core:babel核心实现
  • @babel/preset-env:es6转es5,使用这个包要基于 @babel/core
  • rollup-plugin-babel:babel 插件
  • rollup-plugin-livereload:热更新插件
  • rollup-plugin-serve:服务器插件,用于开启本地服务器
  • rollup-plugin-uglify:压缩代码

配置 rollup

在项目根目录创建rollup.config.js,这里先做一些基本的配置:

import babel from 'rollup-plugin-babel'
import { uglify } from 'rollup-plugin-uglify'
import serve from 'rollup-plugin-serve'
import livereload from 'rollup-plugin-livereload'

export default {
  input'src/index.js'// 入口文件
  output: {
    format'umd',
    file'dist/vue.js'// 打包后输出文件
    name'Vue'// 打包后的内容会挂载到window,name就是挂载到window的名称
    sourcemaptrue // 代码调试  开发环境填true
  },
  plugins: [
    babel({
      exclude'node_modules/**' // 排除node_modules下的所有文件
    }),
    // 压缩代码
    uglify(),
    // 热更新 默认监听根文件夹
    livereload(),
    // 本地服务器
    serve({
      opentrue// 自动打开页面
      port8000,
      openPage'/public/index.html'// 打开的页面
      contentBase''
    })
  ]
}

配置 babel

在项目根目录创建 .babelrc

{
  "presets": [
    "@babel/preset-env"
  ]
}

修改运行脚本命令

"scripts": {
  "dev": "rollup -c -w"
},

-c 表示执行配置文件, -w 表示监测更新。

运行项目,执行 npm run dev

看到根目录已经生成了打包出来的 dist 文件夹以及文件下的 vue.jsvue.js.map 文件。

我们在 public/index.html 引入打包出来的 vue.js

src/index.js 随便写两行 es6 代码:

再次运行 npm run dev:

dist/vue.js

可以看到,打包之后的文件内容语法已经转成了 es5,全局对象挂载了 Vue

index.html 打印 Vue 内容:

这样,我们项目框架就搭建完成。

数据初始化

创建 Vue 构造函数

在实际开发过程中我们使用 new Vue() 去实例化 vue 的实例,那我们就需要创建一个 Vue 的类或者构造函数。这里我选择构造函数的方式去创建:

let vm = new Vue({
  data: {
    name'ts',
    age18
  }
})

src/instance/index.js 里面写入我们的 Vue 构造函数,传入参数 options 为一个对象,里面的属性就是我们常见的 datacomputedmethods 等等。

/**
 * Vue 构造函数
 * @param options 为传入的对象,如:{data:{},computed:{},methods:{}}
 */
function Vue(options) {
  
}
export default Vue

Vue 构造函数原型添加 _init 方法

接下来,就需要在构造函数 Vue 里面做一些初始化的操作,不同的初始化操作逻辑可以放在不同的 js 文件里。

src/instance/index.js:

import { initMixin } from './init'
/**
 * Vue 构造函数
 * @param options 为传入的对象,如:{data:{},computed:{},methods:{}}
 */
function Vue(options) {
  this._init(options)
}
initMixin(Vue)

export default Vue

src/instance/init.js:

// 给Vue原型添加_init方法
export const initMixin = (Vue) => {
  Vue.prototype._init = function (options) {
    const vm = this;
    vm.$options = options; // 将options挂载在实例上,以$开头,和$set、$nextTick一样的命名规则
    initState(vm) // 初始化状态
  }
}

初始化 Vue 实例的时候会执行 initMixin() 和内部的 _init() 方法, initMixin_init() 挂载到了 Vue 原型方法上,并将 options 放在了实例的 属性上。后续初始化操作,我们只需要对 options 进行处理。

接下来,我们来实现初始化状态 initState 方法。

实现 initState 和 initData

src/instance/state.js:

/**
 * Vue 初始化状态,进行data computed watch props等属性的初始化
 * @param vm 实例对象
 */
export const initState = function (vm) {
  const options = vm.$options
  if (options.data) {
    initData(vm)
  }
  // ...还会有其他属性的初始化操作
}
function initData(vm) {
  let data = vm.$options.data
  data = typeof data === 'function' ? data.apply(vm) : data // 判断data类型,可能是函数也可能是对象
  console.log(data)
}

小结

数据初始化主要是对传入的参数 options 进行处理,每个不同类型的函数操作都需要抽离单独的 js 文件进行管理,通过传递 vm 让每个函数在处理的时候都能拿到实例对象,进而拿到 $options。目前,我们只做 data 的初始化,拿到传入的 data 之后,接下来我们将对 data 进行数据劫持,实现数据响应式。

实现对象响应式原理

vue2 使用的是 0bject.defineProperty 进行数据劫持实现响应式。我们在 initData 函数拿到了 data 值,在这里我们可以开始实现响应式。

observe 方法和 Observe 类

src/instance/state.js:

import { observe } from './observe/index'
/**
 * Vue 初始化状态,进行data computed watch props等属性的初始化
 * @param vm 实例对象
 */
export const initState = function (vm) {
  const options = vm.$options
  if (options.data) {
    initData(vm)
  }
  // ...还会有其他属性的初始化操作
}
function initData(vm) {
  let data = vm.$options.data
  data = typeof data === 'function' ? data.apply(vm) : data // 判断data类型,可能是函数也可能是对象
  vm._data = data
  observe(data)
}

src/observe/index.js:

export function observe(data) {
  if (typeof data !== 'object' && data !== 'null'return // data不是对象就不用劫持
  return new Observe(data)
}

class Observe {
  constructor(data) {
    this.walking(data)
  }
  walking(data) {
    Object.keys(data).forEach((key) => defineReactive(data, key, data[key])) // 遍历 data 对象属性,依次执行defineReactive方法进行数据劫持
  }
}

这里调用 observe 方法,先对 data 数据类型进行判断,只对 object 类型进行数据劫持。然后对 data 对象进行遍历,依次去执行 defineReactive 方法。

Object.defineProperty

/**
 * defineReactive 通过Object.defineProperty api 对数据进行数据劫持
 * @param target 目标数据对象
 * @param key 属性
 * @param value 值
 */
export function defineReactive(target, key, value) {
  Object.defineProperty(target, key, {
    // 访问属性时候执行  
    get() {
      return value
    },
    // 修改属性值时候执行
    set(newValue) {
      if (value === newValue) return // 新值和旧值相等就不用赋值
      value = newValue
    }
  })
}

defineReactive 方法中,我们通过 Object.definePropertydata 的所有属性通过 getset 进行了数据劫持。

我们打印 index.html 中的 vm:

可以看到 _data 下的 agename 都已经成功被劫持,但是我们访问是 vm._data.age,和我们正常访问属性 vm.age 不一样,我们需要做到访问 vm.age 的时候,实际访问的是 vm._data.age,这里可以通过代理的方式实现。

src/instance/state.js:

import { observe } from './observe/index'
/**
 * Vue 初始化状态,进行data computed watch props等属性的初始化
 * @param vm 实例对象
 */
export const initState = function (vm) {
  const options = vm.$options
  if (options.data) {
    initData(vm)
  }
  // ...还会有其他属性的初始化操作
}
function initData(vm) {
  let data = vm.$options.data
  data = typeof data === 'function' ? data.apply(vm) : data // 判断data类型,可能是函数也可能是对象
  vm._data = data
  observe(data)
  for (const key in data) {
    handleProxy(vm, '_data', key)
  }
}
function handleProxy(vm, target, key) {
  Object.defineProperty(vm, key, {
    get() {
      return vm[target][key]
    },
    set(newValue) {
      vm[target][key] = newValue
    }
  })
}

通过函数 handleProxy, 我们对 data 上的属性进行代理,当我们访问 vm.name 的时候,实际访问的是 vm._data.name,这样我们就能调用 vm.属性 进行访问属性了。

深层次对象劫持

现在我们只要做了一层对象的属性劫持,如果属性下面又是对象,我们就需要用递归执行 observe 方法。

未被代理

添加递归逻辑

export function defineReactive(target, key, value) {
  observe(value) // 深层次对象递归
  Object.defineProperty(target, key, {
    get() {
      // 访问属性时候执行
      return value
    },
    set(newValue) {
      // 修改属性值时候执行
      if (value === newValue) return // 新值和旧值相等就不用赋值
      value = newValue
    }
  })
}

添加响应式

总结

这篇文章,主要是介绍了 new Vue 阶段做了哪些事情,总结一下就是以下几点:

  1. vue.prototype._init(option)
  2. initState(vm)
  3. initData(vm)
  4. observer(vm.data)
  5. new Observer(data)
  6. 调用 walk 方法,遍历 data 中的每个属性,监听数据的变化
  7. 执行 Object.defineProperty 监听数据读取和设置

源码地址github.com/liy1wen/min…