Vue2.0源码分析:Rollup构建,目录设计和整体流程

2,995 阅读19分钟

Vue2.0源码分析

如果觉得写得不错,请到GitHub我一个Star

下一篇:Vue2.0源码分析:响应式原理(上)

介绍和参考

本篇Vue2.6.11源码分析文章由观看Vue.js源码全方位深入解析视频,阅读深入浅出Vue.js书籍以及参考其他Vue源码分析博客而来,阅读视频和书籍请支持正版。

Vue发展简史

  • 2013年7月,Vue.jsGithub上第一次提交,此时名字叫做Element,后来被改名为Seed.js,到现在的Vue.js
  • 2013年12月,Github发布0.6版本,并正式更名为Vue.js
  • 2014年2月,在Hacker News网站上时候首次公开。
  • 2015年10月,Vue.js发布1.0.0版本。
  • 2016年10月,Vue.js发布2.0版本。

Vue版本变化

Vue2.0版本和Vue1.0版本之间虽然内部变化非常大,整个渲染层都重写了,但API层面的变化却很小,对开发者来说非常友好,另外Vue2.0版本还引入了很多特性:

  • Virtual DOM虚拟DOM。
  • 支持JSX语法。
  • 支持TypeScript
  • 支持服务端渲染ssr
  • 提供跨平台能力weex

正确理解虚拟DOMVue中的虚拟DOM借鉴了开源库snabbdom的实现,并根据自身特色添加了许多特性。引入虚拟DOM的一个很重要的好处是:绝大部分情况下,组件渲染变得更快了,而少部分情况下反而变慢了。引入虚拟DOM这项技术通常都是在解决一些问题,然而解决一个问题的同时也可能会引入其它问题,这种情况更多的是如何做权衡、如何做取舍。因此,一味的强调虚拟DOM在任何时候都能提高性能这种说法需要正确对待和理解。

核心思想Vue两大核心思想是数据驱动组件化,因此我们在介绍完源码目录设计和整体流程后,会先介绍这两方面。

源码目录设计和架构设计

源码目录设计

Vue.js源码目录设计如下:

|-- dist              # 构建目录
|-- flow              # flow的类型声明,类似于TypeScipt
|-- packages          # 衍生的npm包,例如vue-server-renderer和vue-template-compiler
|-- scripts           # 构建配置和构建脚本
|-- test              # 端到端测试和单元测试用例
|-- src               # 源代码
|   |-- compiler      # 编译相关代码
|   |-- core          # 核心代码
|   |-- platforms     # 跨平台
|   |-- server        # 服务端渲染
|   |-- sfc           # .vue文件解析逻辑
|   |-- shared        # 工具函数/共享代码

对以上目录简要做如下介绍:

  • distrollup构建目录,里面存放了所有Vue构建后不同版本的文件。

  • flow:它是Facebook出品的JavaScript静态类型检查工具,早期Vue.js选择了flow而不是现在的TypeScript来做静态类型检查,而在最新的Vue3.0版本则选择使用TypeScript来重写。

  • packagesVue.js衍生的其它npm包,它们在Vue构建时自动从源码中生成并且始终和Vue.js保持相同的版本,主要是vue-server-renderervue-template-compiler这两个包,其中最后一个包在我们使用脚手架生成项目,也就是使用.vue文件开发Vue项目时会使用到这个包。

  • scriptsrollup构建配置和构建脚本,Vue.js能够通过不同的环境构建不同的版本的秘密都在这个目录下。

  • testVue.js测试目录,自动化测试对于一个开源库来说是至关重要的,测试覆盖率在一定程度上是衡量一个库质量的一个重要指标。测试用例无论对于开发还是阅读源码,都是有很大益处的,其中通过测试用例去阅读Vue源码是普遍认为可行的一种方式。

  • src/compiler:此目录包含了与Vue.js编译相关的代码,它包括:模板编译成 AST 抽象语法树、AST 抽象语法树优化和代码生成相关代码。编译的工作可以在构建时用runtime-only版本,借助webpackvue-loader等工具或插件来进行编译。也可以在运行时,使用包含构建功能的runtime + compiler版本。显然,编译是一项比较消耗性能的工作,所以我们日常的开发中,更推荐使用runtime-only的版本开发(体积也更小),也就是通过.vue文件的形式开发。

// 需要使用带编译的版本
new Vue({
  data: {
    msg: 'hello,world'
  }
  template: '<div>{{msg}}</div>'
})

// 不需要使用带编译的版本
new Vue({
  data: {
    msg: 'hello,world'
  },
  render (h) {
    return h('div', this.msg)
  }
})
  • src/core:此目录包含了Vue.js的核心代码,包括:内置组件keep-alive、全局 API(Vue.useVue.mixinVue.extend等)、实例化、响应式相关、虚拟 DOM 和工具函数等。
|-- core
|   |-- components      # 内助组件
|   |-- global-api      # 全局API
|   |-- instance        # 实例化
|   |-- observer        # 响应式
|   |-- util            # 工具函数
|   |-- vdom            # 虚拟DOM
  • src/platformVue2.0提供了跨平台的能力,在React中有React Native跨平台客户端,而在Vue2.0中其对应的跨平台就是Weex
|-- platform
|   |-- web      # web浏览器端
|   |-- weex     # native客户端
  • src/server: Vue2.0提供服务端渲染的能力,所有跟服务端渲染相关的代码都在server目录下,此部分代码是运行在服务端,而非 Web 浏览器端。

  • src/sfc:此目录的主要作用是如何把.vue文件解析成一个JavaScript对象。

  • src/shared:此目录下存放了一些在 Web 浏览器端和服务端都会用到的共享代码。

架构设计

我们通过以上目录结构可以很容易的发现,Vue.js整体分为三个部分:核心代码跨平台相关公共工具函数

同时其架构是分层的,最底层是一个构造函数(普通的函数),最上层是一个入口,也就是将一个完整的构造函数导出给用户使用。在中间层,我们需要逐渐添加一些方法和属性,主要是原型prototype相关和全局API相关。

Vue架构设计

Rollup构建版本

Rollup基础知识

Vue.js通过rollup构建工具进行构建,它是一个类似于webpack的打包工具,区别于webpack它更适合一个Library库的打包。在学习Vue.js源码之前,我们有必要知道Vue.js是如何构建不同版本的。

核心概念

webpack一样,rollup也有以下几大核心概念:

  • input:入口文件,类比于webpackentry,它指明了我们库文件入口位置。
  • output:输出位置,它指明了打包后的输出信息,包括:输出目录,打包文件名等。
  • plugins:插件,rollup在构建过程中,插件可提供一些辅助功能,例如:alias别名解析、转义ES6等。
  • external:当我们的库依赖于其它第三方库时,我们不需要把这些第三方库一起打包,而是应该把依赖写在external里面。

webpack一样,rollup同样适合使用配置文件的做法来配置打包的选项,例如:

// rollup.config.js
export default {
  input: 'src/main.js',
  output: [
    { file: 'dist/vue.js', format: 'umd', name: 'Vue' },
    { file: 'dist/vue.common.js', format: 'cjs', name: 'Vue' },
    { file: 'dist/vue.esm.js', format: 'es', name: 'Vue' }
  ]
}

构建版本说明:

  • umd:此选项构建出来的库文件主要适用于Web端,可以通过不同的方式去使用:script标签引入,ES Module规范引入和CommonJs规范引入等。
  • cjs: 此选项构建出来的库文件主要为CommonJs规范,可在Node环境中使用。
  • es:此版本构建出来的库文件主要为ES Module规范,可在支持ES Module也就是import/export的环境中使用。

有了以上配置文件,我们可以在package.json中进行如下修改:

{
  "name": "Vue",
  "version": "1.0.0",
  "scripts": {
    "dev": "rollup -w -c scripts/rollup.config.dev.js",
    "build": "rollup -c scripts/rollup.config.prod.js"
  }
}

参数说明:

  • -c:为--config的缩写,表示设置rollup打包的配置。
  • -w:为--watch的缩写,在本地开发环境添加-w参数可以监控源文件的变化,自动重新打包。

常用插件

rollup并不像webpack那样强大,它需要和其它插件配合使用才能完成特定的功能,常用的插件有:

  • @rollup/plugin-json: 支持从.json读取信息,配合rollupTree Shaking可只打包.json文件中我们用到的部分。
  • @rollup/plugin-commonjs:将CommonJs规范的模块转换为ES6提供rollup使用。
  • @rollup/plugin-node-resolve:与@rollup/plugin-commonjs插件一起使用,配合以后就可以使用node_modules下的第三方模块代码了。
  • @rollup/plugin-babel:把ES6代码转义成ES5代码,需要同时安装@babel/core@babel/preset-env插件。注意:如果使用了高于ES6标准的语法,例如async/await,则需要进行额外的配置。
  • rollup-plugin-terser:代码压缩插件,另外一种方案是rollup-plugin-uglify + uglify-es进行代码压缩,不过更推荐第一种方案。

以上插件使用方式如下:

// rollup.config.js
import commonjs from '@rollup/plugin-commonjs'
import json from '@rollup/plugin-json'
import resolve from '@rollup/plugin-node-resolve'
import babel from '@rollup/plugin-babel'
import { terser } from 'rollup-plugin-terser'

const config =  {
  input: 'src/index.js',
  output: [
    { file: 'dist/vue.js', format: 'umd', name: 'Vue' },
    { file: 'dist/vue.common.js', format: 'cjs', name: 'Vue', exports: 'auto' },
    { file: 'dist/vue.esm.js', format: 'es', name: 'Vue', exports: 'auto' }
  ],
  plugins: [
    json(),
    resolve(),
    babel(),
    commonjs(),
    terser()
  ]
}

export default config

区分生产环境和开发环境

正如你在上面看到的那样,我们可以像webpack一样进行开发环境和生产环境的配置区分,我们把和rollup构建相关的文件都放在scripts目录下:

|-- scripts
|   |-- rollup.config.base.js      # 公共配置
|   |-- rollup.config.dev.js       # 开发环境配置
|   |-- rollup.config.prod.js      # 生产环境配置

根据我们的拆分逻辑,rollup.config.base.js代码如下:

import commonjs from '@rollup/plugin-commonjs'
import json from '@rollup/plugin-json'
import resolve from '@rollup/plugin-node-resolve'
import babel from '@rollup/plugin-babel'

const config =  {
  input: 'src/index.js',
  plugins: [
    json(),
    resolve(),
    babel(),
    commonjs()
  ]
}

export default config

rollup.config.dev.js代码如下:

import baseConfig from './rollup.config.base.js'
import serve from 'rollup-plugin-serve'
import { name } from '../package.json'

const config =  {
  ...baseConfig,
  output: [
    { file: 'dist/vue.js', format: 'umd', name },
    { file: 'dist/vue.common.js', format: 'cjs', name, exports: 'auto' },
    { file: 'dist/vue.esm.js', format: 'es', name, exports: 'default' }
  ],
  plugins: [
    ...baseConfig.plugins,
    serve({
      open: true,
      port: '4300',
      openPage: '/example/index.html',
      contentBase: ''
    })
  ]
}
export default config

配置说明:本地开发环境下,我们可以有选择的添加rollup-plugin-serve插件,它类似于webpack-dev-server,能在开发环境下起一个服务方便我们进行开发和代码调试。

rollup.config.prod.js代码如下:

import baseConfig from './rollup.config.base.js'
import { terser } from 'rollup-plugin-terser'
import { name } from '../package.json'
const config =  {
  ...baseConfig,
  output: [
    { file: 'dist/vue.min.js', format: 'umd', name },
    { file: 'dist/vue.common.min.js', format: 'cjs', name, exports: 'auto' },
    { file: 'dist/vue.esm.min.js', format: 'es', name, exports: 'default' }
  ],
  plugins: [
    ...baseConfig.plugins,
    terser()
  ]
}

export default config

配置说明:生产环境下,我们需要对代码进行压缩处理,对ES ModuleCommonJsUMD等规范分别生成其对应的压缩文件。

分别运行npm run devnpm run build之后,我们可以得到如下的目录:

|-- dist
|   |-- vue.js            # UMD未压缩版本
|   |-- vue.min.js        # UMD压缩版本
|   |-- vue.esm.js        # ES Module未压缩版本
|   |-- vue.esm.min.js    # ES Module压缩版本
|   |-- vue.common.js     # CommonJs未压缩版本
|   |-- vue.common.min.js # CommonJs压缩版本

最后,如果我们像Vue.js一样构建的是一个库文件,那么我们还需要在package.json进行如下配置:

{
  "main": "dist/vue.common.js",
  "module": "dist/vue.esm.js"
}

Vue中的Rollup构建

在阅读Vue.js源码时,我们首先应该去看其package.json文件内容,在Vue.js项目中其精简掉与compilerweexssr相关的内容以后,如下所示:

{
  "name": "vue",
  "version": "2.6.11",
  "main": "dist/vue.runtime.common.js",
  "module": "dist/vue.runtime.esm.js",
  "scripts": {
    "dev": "rollup -w -c scripts/config.js --environment TARGET:web-full-dev",
    "dev:cjs": "rollup -w -c scripts/config.js --environment TARGET:web-runtime-cjs-dev",
    "dev:esm": "rollup -w -c scripts/config.js --environment TARGET:web-runtime-esm",
    "dev:ssr": "rollup -w -c scripts/config.js --environment TARGET:web-server-renderer",
    "build": "node scripts/build.js"
  }
}

我们可以从上面很容易的发现,其精简后的内容和我们在rollup基础知识里面的配置十分相似,其构建脚本同样放置在scripts目录下。在scripts目录下,我们需要重点关注下面几个文件:

  • alias.js:与rollup构建别名相关的配置。
  • config.js:与rollup构建不同版本相关的代码。
  • build.jsrollup构建不同压缩版本Vue.js文件相关代码。

alias别名

我们在开发Vue应用时,经常会用到@别名,其中@代表src目录:

// 使用别名
import HelloWorld from '@/components/HelloWorld.vue'

// 相当于
import HelloWorld from 'src/components/HelloWorld.vue'

scripts/alias.js中,我们可以发现其别名配置代码如下:

const path = require('path')
const resolve = p => path.resolve(__dirname, '../', p)

module.exports = {
  vue: resolve('src/platforms/web/entry-runtime-with-compiler'),
  compiler: resolve('src/compiler'),
  core: resolve('src/core'),
  shared: resolve('src/shared'),
  web: resolve('src/platforms/web'),
  weex: resolve('src/platforms/weex'),
  server: resolve('src/server'),
  sfc: resolve('src/sfc')
}

core别名为例,在Vue.js源码中,我们通过别名进行如下引入:

// 使用core别名
import Vue from 'core/instance/index.js'

// 相当于
import Vue from 'src/core/instance/index.js'

其中alias.js文件是在config.js中引入并使用的:

// config.js文件
import alias from 'rollup-plugin-alias'
import aliases from './alias.js'

function genConfig () {
  const config = {
    plugins: [
      alias(Object.assign({}, aliases))
    ])
  }
  return config
}

注意:由于Vue.js中使用rollup主版本以及其周边插件的版本较低,如果你使用了最新的rollup版本或者其周边的插件,需要按照最新插件的配置要求来,这里以最新的@rollup/plugin-alias插件为例:

const path = require('path')
const resolve = p => path.resolve(__dirname, '../', p)

module.exports = [
  { file: 'vue', replacement: resolve('src/platforms/web/entry-runtime-with-compiler') },
  { file: 'compiler', replacement: resolve('src/compiler') },
  { file: 'core', replacement: resolve('src/core') },
  { file: 'shared', replacement: resolve('src/shared') },
  { file: 'web', replacement: resolve('src/platforms/web' },
  { file: 'weex', replacement: resolve('src/platforms/weex') },
  { file: 'server', replacement: resolve('src/server') },
  { file: 'sfc', replacement: resolve('src/sfc') }
]

其在config.js新的使用方式同样需要做调整,如下:

// config.js文件
import alias from '@rollup/plugin-alias'
import aliases from './alias.js'

function genConfig () {
  const config = {
    plugins: [
      alias({ entries: aliases })
    ])
  }
  return config
}

config.js

首先我们从package.json打包命令中可以看到,在development环境下它通过-c指定了rollup的配置文件,所以会使用到scripts/config.js文件,并且打包命令还提供了一个叫做TARGET的环境变量:

{
  "scripts": {
    "dev": "rollup -w -c scripts/config.js --environment TARGET:web-full-dev",
    "dev:cjs": "rollup -w -c scripts/config.js --environment TARGET:web-runtime-cjs-dev",
    "dev:esm": "rollup -w -c scripts/config.js --environment TARGET:web-runtime-esm",
  }
}

那么在scripts/config.js文件下,我们可以看到它是通过module.exports导出的一个对象:

function genConfig (name) {
  const opts = builds[name]
  const config = {
    input: opts.entry,
    external: opts.external,
    plugins: [
      flow(),
      alias(Object.assign({}, aliases, opts.alias))
    ].concat(opts.plugins || []),
    output: {
      file: opts.dest,
      format: opts.format,
      name: opts.moduleName || 'Vue'
    },
    onwarn: (msg, warn) => {
      if (!/Circular/.test(msg)) {
        warn(msg)
      }
    }
  }
  return config
}
if (process.env.TARGET) {
  module.exports = genConfig(process.env.TARGET)
} else {
  exports.getBuild = genConfig
  exports.getAllBuilds = () => Object.keys(builds).map(genConfig)
}

在以上代码中,我们可以看到module.exports导出的对象,主要是通过genConfig()函数返回的,其中这个函数接受的参数正是我们在打包命令中提供的环境变量TARGET。我们再来粗略的看一下genConfig()函数,它的主要作用依然是生成rollup几大核心配置,然后返回配置完毕后的对象。

我们再来看一个叫做builds的对象,由于在源码中它的内容非常多,为了节省篇幅我们精简后其代码如下:

const builds = {
  // Runtime+compiler CommonJS build (CommonJS)
  'web-full-cjs-dev': {
    entry: resolve('web/entry-runtime-with-compiler.js'),
    dest: resolve('dist/vue.common.dev.js'),
    format: 'cjs',
    env: 'development',
  },
  'web-full-cjs-prod': {
    entry: resolve('web/entry-runtime-with-compiler.js'),
    dest: resolve('dist/vue.common.prod.js'),
    format: 'cjs',
    env: 'production'
  },
  // Runtime+compiler ES modules build (for bundlers)
  'web-full-esm': {
    entry: resolve('web/entry-runtime-with-compiler.js'),
    dest: resolve('dist/vue.esm.js'),
    format: 'es'
  },
  // Runtime+compiler development build (Browser)
  'web-full-dev': {
    entry: resolve('web/entry-runtime-with-compiler.js'),
    dest: resolve('dist/vue.js'),
    format: 'umd',
    env: 'development'
  },
  // Runtime+compiler production build  (Browser)
  'web-full-prod': {
    entry: resolve('web/entry-runtime-with-compiler.js'),
    dest: resolve('dist/vue.min.js'),
    format: 'umd',
    env: 'production'
  }
}

我们可以发现它的键名正好是我们打包命令中提供的环境变量TARGET的值,这里以web-full-dev为例,它通过web-full-dev这个键可以得到一个对象:

{
  entry: resolve('web/entry-runtime-with-compiler.js'),
  dest: resolve('dist/vue.js'),
  format: 'umd',
  env: 'development'
}

然后配合resolve函数和上面我们已经提到过的别名配置,就可以构造下面这样的rollup配置对象:

{
  // 省略其它
  input: 'src/platforms/web/entry-runtime-with-compiler.js',
  output: {
    dest: 'dist/vue.js',
    format: 'umd',
    name: 'Vue'
  }
}

build.js

srcipts/build.js文件的作用就是通过配置然后生成不同版本的压缩文件,其中它获取配置的方式同样是在scripts/config.js文件中,其中关键代码为:

// config.js中导出
exports.getAllBuilds = () => Object.keys(builds).map(genConfig)

// build.js中引入
let builds = require('./config').getAllBuilds()

从入口到构造函数整体流程

整体流程

在之前的介绍中,我们知道Vue.js内部会根据Web浏览器Weex跨平台和SSR服务端渲染不同的环境寻找不同的入口文件,但其核心代码是在src/core目录下,我们这一节的主要目的是为了搞清楚从入口文件到Vue构造函数执行,这期间的整体流程。

在分析完从入口到构造函数的各个部分的流程后,我们可以得到一份大的流程图:

initGlobalAPI流程

我们会在src/core/index.js文件中看到如下精简代码:

import Vue from './instance/index'
import { initGlobalAPI } from './global-api/index'
initGlobalAPI(Vue)

export default Vue

在以上代码中,我们发现它引入了Vue随后调用了initGlobalAPI()函数,此函数的作用是挂载一些全局API方法。

initGlobalAPI

我们首先能在src/core/global-api文件夹下看到如下目录结构:

|-- global-api        
|   |-- index.js      # 入口文件
|   |-- assets.js     # 挂载filter、component和directive
|   |-- extend.js     # 挂载extend方法
|   |-- mixin.js      # 挂载mixin方法
|   |-- use.js        # 挂载use方法

随后在index.js入口文件中,我们能看到如下精简代码:

import { initUse } from './use'
import { initMixin } from './mixin'
import { initExtend } from './extend'
import { initAssetRegisters } from './assets'
import { set, del } from '../observer/index'
import { observe } from 'core/observer/index'
import { extend, nextTick } from '../util/index'

export function initGlobalAPI (Vue: GlobalAPI) {
  Vue.set = set
  Vue.delete = del
  Vue.nextTick = nextTick

  Vue.observable = (obj) => {
    observe(obj)
    return obj
  }

  initUse(Vue)
  initMixin(Vue)
  initExtend(Vue)
  initAssetRegisters(Vue)
}

我们能从以上代码很清晰的看到在index.js入口文件中,会在Vue构造函数上挂载各种全局API函数,其中setdeletenextTickobservable直接赋值为一个函数,而其他几种API则是调用了一个以init开头的方法,我们以initAssetRegisters()方法为例,它的精简代码如下:

// ['component','directive', 'filter']
import { ASSET_TYPES } from 'shared/constants'

export function initAssetRegisters (Vue: GlobalAPI) {
  ASSET_TYPES.forEach(type => {
    Vue[type] = function () {
      // 省略了函数的参数和函数实现代码
    }
  })
}

其中ASSET_TYPES是一个定义在src/shared/constants.js中的一个数组,然后在initAssetRegisters()方法中遍历这个数组,依次在Vue构造函数上挂载Vue.component()Vue.directive()Vue.filter()方法,另外三种init开头的方法调用挂载对应的全局API是一样的道理:

// initUse
export function initUse(Vue) {
  Vue.use = function () {}
}

// initMixin
export function initMixin(Vue) {
  Vue.mixin = function () {}
}

// initExtend
export function initExtend(Vue) {
  Vue.extend = function () {}
}

最后,我们发现还差一个Vue.compile()方法,它其实是在runtime+compile版本才会有的一个全局方法,因此它在src/platforms/web/entry-runtime-with-compile.js中被定义:

import Vue from './runtime/index'
import { compileToFunctions } from './compiler/index'
Vue.compile = compileToFunctions
export default Vue

因此我们根据initGlobalAPI()方法的逻辑,可以得到如下流程图: initGlobalAPI流程图

initMixin流程

在上一节我们讲到了initGlobalAPI的整体流程,这一节,我们来介绍initMixin的整体流程。首选,我们把目光回到src/core/index.js文件中:

import Vue from './instance/index'
import { initGlobalAPI } from './global-api/index'
initGlobalAPI(Vue)

export default Vue

我们发现,它从别的模块中引入了大Vue,那么接下来我们的首要任务就是揭开Vue构造函数的神秘面纱。

在看src/core/instance/index.js代码之前,我们发现instance目录结构如下:

|-- instance
|   |-- render-helpers      # render渲染相关的工具函数目录
|   |-- events.js           # 事件处理相关
|   |-- init.js             # _init等方法相关
|   |-- inject.js           # inject和provide相关
|   |-- lifecycle.js        # 生命周期相关
|   |-- proxy.js            # 代理相关
|   |-- render.js           # 渲染相关
|   |-- state.js            # 数据状态相关
|   |-- index.js            # 入口文件

可以看到,目录结构文件有很多,而且包含的面也非常杂,但我们现在只需要对我们最关心的几个部分做介绍:

  • events.js:处理事件相关,例如:$on$off$emit以及$once等方法的实现。
  • init.js:此部分代码逻辑包含了Vue从创建实例到实例挂载阶段的所有主要逻辑。
  • lifecycle.js:生命周期相关,例如:$destroy$activated$deactivated
  • state.js:数据状态相关,例如:dataprops以及computed等。
  • render.js:渲染相关,其中最值得关注的是Vue.prototype._render渲染函数的定义。

在介绍了instance目录结构的及其各自的作用以后,我们再来看入口文件,其实入口文件这里才是Vue构造函数庐山真面目:

import { initMixin } from './init'
import { stateMixin } from './state'
import { renderMixin } from './render'
import { eventsMixin } from './events'
import { lifecycleMixin } from './lifecycle'
import { warn } from '../util/index'

function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)
  ) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}

initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

export default Vue

代码分析:

  • Vue构造函数其实就是一个普通的函数,我们只能通过new操作符进行访问,既new Vue()的形式,Vue函数内部也使用了instanceof操作符来判断实例的父类是否为Vue构造函数,不是的话则在开发环境下输出一个警告信息。
  • 除了声明Vue构造函数,这部分的代码也调用了几种mixin方法,其中每种mixin方法各司其职,处理不同的内容。

从以上代码中,我们能得到src/core/instance/index.js文件非常直观的代码逻辑流程图:

instance流程

接下来我们的首要任务是弄清楚_init()函数的代码逻辑以及initMixin的整体流程。我们从上面的代码发现,在构造函数内部会调用this._init()方法,也就是说:

// 实例化时,会调用this._init()方法。
new Vue({
  data: {
    msg: 'Hello, Vue.js'
  }
})

然后,我们在init.js中来看initMixin()方法是如何被定义的:

export function initMixin (Vue) {
  Vue.prototype._init = function (options) {
    // 省略代码
  }
}

我们可以发现,initMixin()方法的主要作用就是在Vue.prototype上定义一个_init()实例方法,接下来我们来看一下_init()函数的具体实现逻辑:

Vue.prototype._init = function (options) {
    const vm = this
    // 1. 合并配置
    if (options && options._isComponent) {
      initInternalComponent(vm, options)
    } else {
      vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    }

    // 2.render代理
    if (process.env.NODE_ENV !== 'production') {
      initProxy(vm)
    } else {
      vm._renderProxy = vm
    }

    // 3.初始化生命周期、初始化事件中心、初始化inject,
    //   初始化state、初始化provide、调用生命周期
    vm._self = vm
    initLifecycle(vm)
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm)
    initState(vm)
    initProvide(vm)
    callHook(vm, 'created')

    // 4.挂载
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }

因为我们是要分析initMixin整体流程,对于其中某些方法的具体实现逻辑会在后续进行详细的说明,因此我们可以从以上代码得到initMixin的整体流程图。 initMixin流程

stateMixin流程

stateMixin主要是处理跟实例相关的属性和方法,它会在Vue.prototype上定义实例会使用到的属性或者方法,这一节我们主要任务是弄清楚stateMixin的主要流程。在src/core/instance/state.js代码中,它精简后如下所示:

import { set, del } from '../observer/index'
export function stateMixin (Vue) {
  // 定义$data, $props
  const dataDef = {}
  dataDef.get = function () { return this._data }
  const propsDef = {}
  propsDef.get = function () { return this._props }
  Object.defineProperty(Vue.prototype, '$data', dataDef)
  Object.defineProperty(Vue.prototype, '$props', propsDef)

  // 定义$set, $delete, $watch
  Vue.prototype.$set = set
  Vue.prototype.$delete = del
  Vue.prototype.$watch = function() {}
}

我们可以从上面代码中发现,stateMixin()方法中在Vue.prototype上定义的几个属性或者方法,全部都是和响应式相关的,我们来简要分析一下以上代码:

  • $data和$props:根据以上代码,我们发现$data$props分别是_data_props的访问代理,从命名中我们可以推测,以下划线开头的变量,我们一般认为是私有变量,然后通过$data$props来提供一个对外的访问接口,虽然可以通过属性的get()方法去取,但对于这两个私有变量来说是并不能随意set,对于data来说不能替换根实例,而对于props来说它是只读的。因此在原版源码中,还劫持了set()方法,当设置$data或者$props时会报错:
if (process.env.NODE_ENV !== 'production') {
  dataDef.set = function () {
    warn(
      'Avoid replacing instance root $data. ' +
      'Use nested data properties instead.',
      this
    )
  }
  propsDef.set = function () {
    warn(`$props is readonly.`, this)
  }
}
  • $set$deletesetdelete这两个方法被定义在跟instance目录平级的observer目录下,在stateMixin()中,它们分别赋值给了$set$delete方法,而在initGlobalAPI中,也同样使用到了这两个方法,只不过一个是全局方法,一个是实例方法。

  • $watch:在stateMixin()方法中,详细实现了$watch()方法,此方法实现的核心是通过一个watcher实例来监听。当取消监听时,同样是使用watcher实例相关的方法,关于watcher我们会在后续响应式章节详细介绍。

Vue.prototype.$watch = function (
    expOrFn: string | Function,
    cb: any,
    options?: Object
  ): Function {
    const vm: Component = this
    if (isPlainObject(cb)) {
      return createWatcher(vm, expOrFn, cb, options)
    }
    options = options || {}
    options.user = true
    const watcher = new Watcher(vm, expOrFn, cb, options)
    if (options.immediate) {
      try {
        cb.call(vm, watcher.value)
      } catch (error) {
        handleError(error, vm, `callback for immediate watcher "${watcher.expression}"`)
      }
    }
    return function  () {
      watcher.teardownunwatchFn()
    }
  }

在以上代码分析完毕后,我们可以得到stateMixin如下流程图: stateMixin流程

eventsMixin流程

在使用Vue做开发的时候,我们一定经常使用到$emit$on$off$once等几个实例方法,eventsMixin主要做的就是在Vue.prototype上定义这四个实例方法:

export function eventsMixin (Vue) {
  // 定义$on
  Vue.prototype.$on = function (event, fn) {}

  // 定义$once
  Vue.prototype.$once = function (event, fn) {}

  // 定义$off
  Vue.prototype.$off = function (event, fn) {}

  // 定义$emit
  Vue.prototype.$emit = function (event) {}
}

通过以上代码,我们发现eventsMixin()所做的事情就是使用发布-订阅模式来处理事件,接下来让我们先使用发布-订阅实现自己的事件中心,随后再来回顾源码。

$on的实现

$on方法的实现比较简单,我们先来实现一个基础版本的:

function Vue () {
  this._events = Object.create(null)
}

Vue.prototype.$on = function (event, fn) {
  if (!this._events[event]) {
    this._events[event] = []
  }
  this._events[event].push(fn)
  return this
}

接下来对比一下Vue源码中,关于$on的实现:

Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component {
  const vm: Component = this
  if (Array.isArray(event)) {
    for (let i = 0, l = event.length; i < l; i++) {
      vm.$on(event[i], fn)
    }
  } else {
    (vm._events[event] || (vm._events[event] = [])).push(fn)
    // optimize hook:event cost by using a boolean flag marked at registration
    // instead of a hash lookup
    if (hookRE.test(event)) {
      vm._hasHookEvent = true
    }
  }
  return vm
}

代码分析:

  1. 我们发现在Vue源码中,$on方法还接受一个数组event,这其实是在Vue2.2.0版本以后才有的,当传递一个event数组时,会通过遍历数组的形式递归调用$on方法。
  2. 我们还发现,所有$on的事件全部绑定在_events私有属性上,这个属性其实是在我们上面已经提到过的initEvents()方法中被定义的。
export function initEvents (vm) {
  vm._events = Object.create(null)
}

$emit的实现

我们先来实现一个简单的$emit方法:

Vue.prototype.$emit = function (event) {
  const cbs = this._events[event]
  if (cbs) {
    const args = Array.prototype.slice.call(arguments, 1)
    for (let i = 0; i < cbs.length; i++) {
      const cb = cbs[i]
      cb && cb.apply(this, args)
    }
  }
  return this
}

接下来,我们使用$emit$on来配合测试事件的监听和触发:

const app = new Vue()
app.$on('eat', (food) => {
  console.log(`eating ${food}!`)
})
app.$emit('eat', 'orange')
// eating orange!

最后我们来看Vue源码中关于$emit的实现:

Vue.prototype.$emit = function (event: string): Component {
  const vm: Component = this
  // ...省略处理边界代码
  let cbs = vm._events[event]
  if (cbs) {
    cbs = cbs.length > 1 ? toArray(cbs) : cbs
    const args = toArray(arguments, 1)
    const info = `event handler for "${event}"`
    for (let i = 0, l = cbs.length; i < l; i++) {
      invokeWithErrorHandling(cbs[i], vm, args, vm, info)
    }
  }
  return vm
}

代码分析:

  1. 从整体上看,$emit实现方法非常简单,第一步从_events对象中取出对应的cbs,接着一个个遍历cbs数组、调用并传参。
  2. invokeWithErrorHandling代码中会使用try/catch把我们函数调用并执行的地方包裹起来,当函数调用出错时,会执行VuehandleError()方法,这种做法不仅更加友好,而且对错误处理也非常有用。

$off的实现

$off方法的实现,相对来说比较复杂一点,因为它需要根据不同的传参做不同的事情:

  • 当没有提供任何参数时,移除全部事件监听。
  • 当只提供event参数时,只移除此event对应的监听器。
  • 同时提供event参数和fn回调,则只移除此event对应的fn这个监听器。

在了解了以上功能点后,我们来实现一个简单的$off方法:

Vue.prototype.$off = function (event, fn) {
  // 没有传递任何参数
  if (!arguments.length) {
    this._events = Object.create(null)
    return this
  }
  // 传递了未监听的event
  const cbs = this._events[event]
  if (!cbs) {
    return this
  }
  // 没有传递fn
  if (!fn) {
    this._events[event] = null
    return this
  }
  // event和fn都传递了
  let i = cbs.length
  let cb
  while (i--) {
    cb = cbs[i]
    if (cb === fn) {
      cbs.splice(i, 1)
      break
    }
  }
  return this
}

接下来,我们撰写测试代码:

const app = new Vue()
function eatFood (food) {
  console.log(`eating ${food}!`)
}
app.$on('eat', eatFood)
app.$emit('eat', 'orange')
app.$off('eat', eatFood)
// 不执行回调
app.$emit('eat', 'orange')

最后我们来看Vue源码中关于$off的实现:

Vue.prototype.$off = function (event?: string | Array<string>, fn?: Function): Component {
  const vm: Component = this
  // all
  if (!arguments.length) {
    vm._events = Object.create(null)
    return vm
  }
  // array of events
  if (Array.isArray(event)) {
    for (let i = 0, l = event.length; i < l; i++) {
      vm.$off(event[i], fn)
    }
    return vm
  }
  // specific event
  const cbs = vm._events[event]
  if (!cbs) {
    return vm
  }
  if (!fn) {
    vm._events[event] = null
    return vm
  }
  // specific handler
  let cb
  let i = cbs.length
  while (i--) {
    cb = cbs[i]
    if (cb === fn || cb.fn === fn) {
      cbs.splice(i, 1)
      break
    }
  }
  return vm
}

$once的实现

关于$once方法的实现比较简单,可以简单的理解为在回调之后立马调用$off,因此我们来实现一个简单的$once方法:

Vue.prototype.$once = function (event, fn) {
  function onFn () {
    this.$off(event, onFn)
    fn.apply(this, arguments)
  }
  this.$on(event, onFn)
  return this
}

接着我们对比一下Vue源码中的$once方法:

Vue.prototype.$once = function (event: string, fn: Function): Component {
  const vm: Component = this
  function on () {
    vm.$off(event, on)
    fn.apply(vm, arguments)
  }
  on.fn = fn
  vm.$on(event, on)
  return vm
}

注意:在源码中$once的实现是在回调函数中使用fn绑定了原回调函数的引用,在上面已经提到过的$off方法中也同样进行了cb.fn === fn的判断。

在实现完以上几种方法后,我们可以得到eventsMixin如下流程图: eventsMixin流程

lifecycleMixin流程

和以上其它几种方法一样,lifecycleMixin主要是定义实例方法和生命周期,例如:$forceUpdate()$destroy,另外它还定义一个_update的私有方法,其中$forceUpdate()方法会调用它,因此lifecycleMixin精简代码如下:

export function lifecycleMixin (Vue) {
  // 私有方法
  Vue.prototype._update = function () {}

  // 实例方法
  Vue.prototype.$forceUpdate = function () {
    if (this._watcher) {
      this._watcher.update()
    }
  }
  Vue.prototype.$destroy = function () {}
}

代码分析:

  • _update()会在组件渲染的时候调用,其具体的实现我们会在组件章节详细介绍
  • $forceUpdate()为一个强制Vue实例重新渲染的方法,它的内部调用了_update,也就是强制组件重选编译挂载。
  • $destroy()为组件销毁方法,在其具体的实现中,会处理父子组件的关系,事件监听,触发生命周期等操作。

lifecycleMixin()方法的代码不是很多,我们也能很容易的得到如下流程图:

renderMixin流程

相比于以上几种方法,renderMixin是最简单的,它主要在Vue.prototype上定义各种私有方法和一个非常重要的实例方法:$nextTick,其精简代码如下:

export function renderMixin (Vue) {
  // 挂载各种私有方法,例如this._c,this._v等
  installRenderHelpers(Vue.prototype)
  Vue.prototype._render = function () {}

  // 实例方法
  Vue.prototype.$nextTick = function (fn) {
    return nextTick(fn, this)
  }
}

代码分析:

  • installRenderHelpers:它会在Vue.prototype上挂载各种私有方法,例如this._n = toNumberthis._s = toStringthis._v = createTextVNodethis._e = createEmptyVNode
  • _render()_render()方法会把模板编译成VNode,我们会在其后的编译章节详细介绍。
  • nextTick:就像我们之前介绍过的,nextTick会在Vue构造函数上挂载一个全局的nextTick()方法,而此处为实例方法,本质上引用的是同一个nextTick

在以上代码分析完毕后,我们可以得到renderMixin如下流程图:

下一篇:Vue2.0源码分析:响应式原理(上)