vue源码解读一:认识vue源码文件结构

519 阅读5分钟

本文主要内容摘抄自黄轶老师的慕课网课程Vue.js 源码全方位深入解析 全面深入理解Vue实现原理,主要用于个人学习和复习,不用作其他用途。

Vue.js 源码目录设计

Vue.js 的源码都在 src 目录下,其目录结构如下。

src
├── compiler        # 编译相关 
├── core            # 核心代码 
├── platforms       # 不同平台的支持
├── server          # 服务端渲染
├── sfc             # .vue 文件解析
├── shared          # 共享代码

image.png

compiler

compiler 目录包含 Vue.js 所有编译相关的代码。它包括把模板解析成 ast 语法树,ast 语法树优化,代码生成等功能。

编译的工作可以在构建时做(借助 webpack、vue-loader 等辅助插件);也可以在运行时做,使用包含构建功能的 Vue.js。显然,编译是一项耗性能的工作,所以更推荐前者——离线编译

core

core 目录包含了 Vue.js 的核心代码,包括内置组件、全局 API 封装,Vue 实例化、观察者、虚拟 DOM、工具函数等等。这里的代码可谓是 Vue.js 的灵魂,也是我们之后需要重点分析的地方。

platform

Vue.js 是一个跨平台的 MVVM 框架,它可以跑在 web 上,也可以配合 weex 跑在 native 客户端上。platform 是 Vue.js 的入口,2 个目录代表 2 个主要入口,分别打包成运行在 web 上和 weex 上的 Vue.js。

image.png

server

Vue.js 2.0 支持了服务端渲染,所有服务端渲染相关的逻辑都在这个目录下。注意:这部分代码是跑在服务端的 Node.js,不要和跑在浏览器端的 Vue.js 混为一谈。

服务端渲染主要的工作是把组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将静态标记"混合"为客户端上完全交互的应用程序。

sfc

通常我们开发 Vue.js 都会借助 webpack 构建, 然后通过 .vue 单文件(single-file components)来编写组件。

这个目录下的代码逻辑会把 .vue 文件内容解析成一个 JavaScript 的对象。

shared

Vue.js 会定义一些工具方法,这里定义的工具方法都是会被浏览器端的 Vue.js 和服务端的 Vue.js 所共享的。

Vue.js 源码构建

构建脚本

通常一个基于 NPM 托管的项目都会有一个 package.json 文件,它是对项目的描述文件,它的内容实际上是一个标准的 JSON 对象。

我们通常会配置 script 字段作为 NPM 的执行脚本,Vue.js 源码构建的脚本如下:

{
  "script": {
    "build": "node scripts/build.js",
    "build:ssr": "npm run build -- web-runtime-cjs, web-server-renderer",
    "build:weex": "npm run build -- weex"
  }
}

这里总共有 3 条命令,作用都是构建 Vue.js,后面 2 条是在第一条命令的基础上,添加一些环境参数。

当在命令行运行 npm run build 的时候,实际上就会执行 node scripts/build.js

构建过程

先打开构建的入口 JS 文件,在 scripts/build.js 中:

// 获取构建配置
let builds = require('./config').getAllBuilds()

// 通过命令行参数过来配置
if (process.argv[2]) {
  const filters = process.argv[2].split(',')
  builds = builds.filter(b => {
    return filters.some(f => b.output.file.indexOf(f) > -1 || b._name.indexOf(f) > -1)
  })
} else {
  builds = builds.filter(b => {
    return b.output.file.indexOf('weex') === -1
  })
}

build(builds)

这段代码逻辑非常简单,先从配置文件读取配置,再通过命令行参数对构建配置做过滤,这样就可以构建出不同用途的 Vue.js 了。接下来我们看一下配置文件,在 scripts/config.js 中:

这里列举了一些 Vue.js 构建的配置,如下:

const builds = {
  // Runtime only (CommonJS). Used by bundlers e.g. Webpack & Browserify
  'web-runtime-cjs': {
    entry: resolve('web/entry-runtime.js'),
    dest: resolve('dist/vue.runtime.common.js'),
    format: 'cjs',
    banner
  },
  // Runtime+compiler CommonJS build (CommonJS)
  'web-full-cjs': {
    entry: resolve('web/entry-runtime-with-compiler.js'),
    dest: resolve('dist/vue.common.js'),
    format: 'cjs',
    alias: { he: './entity-decoder' },
    banner
  }
  ...
}

对于单个配置,它是遵循 Rollup 的构建规则的。其中 entry 属性表示构建的入口 JS 文件地址,dest 属性表示构建后的 JS 文件地址。format 属性表示构建的格式,cjs 表示构建出来的文件遵循 CommonJS 规范,es 表示构建出来的文件遵循 ES Module 规范。 umd 表示构建出来的文件遵循 UMD 规范。

以 web-runtime-cjs 配置为例,它的 entry 是 resolve('web/entry-runtime.js'),先来看一下 resolve 函数的定义,源码目录:scripts/config.js

// alias.js
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')
}


// config.js
const aliases = require('./alias')
const resolve = p => {
  const base = p.split('/')[0]
  if (aliases[base]) {
    return path.resolve(aliases[base], p.slice(base.length + 1))
  } else {
    return path.resolve(__dirname, '../', p)
  }
}

这里的 resolve 函数实现非常简单,它先把 resolve 函数传入的参数 p 通过 / 做了分割成数组,然后取数组第一个元素设置为 base。在我们这个例子中,参数 p 是 web/entry-runtime.js,那么 base 则为 webbase 并不是实际的路径,它的真实路径借助了别名的配置。通过这样就找到了最终的入口文件。

接下来看看是如何用rollup打包的,从中可以学到很多东西。

  • 通过promise加递归的方式,把多个异步任务串行执行
  • 利用rollup提供的rollup方法进行打包,而不是使用命令行的方式打包,这样就更加灵活
  • 利用第三方库terser进行代码压缩
  • 利用nodejs的内置模块zlib进行压缩
// 可能一次打包多个模块规范的包,那么可以通过promise 和 递归迭代来实现
function build (builds) {
  let built = 0
  const total = builds.length
  const next = () => {
    buildEntry(builds[built]).then(() => {
      built++
      if (built < total) {
        next()
      }
    }).catch(logError)
  }

  next()
}

// 返回一个promise
function buildEntry (config) {
  const output = config.output
  const { file, banner } = output
  const isProd = /(min|prod)\.js$/.test(file)
  // rollup 不仅可以用命令行进行打包,还提供了一个方法,让我们在nodejs环境中执行打包
  return rollup.rollup(config)
    .then(bundle => bundle.generate(output))
    .then(({ output: [{ code }] }) => {
      if (isProd) {
        // 采用terser库进行压缩
        const minified = (banner ? banner + '\n' : '') + terser.minify(code, {
          toplevel: true,
          output: {
            ascii_only: true
          },
          compress: {
            pure_funcs: ['makeMap']
          }
        }).code
        // 把压缩的代码写进文件中
        return write(file, minified, true)
      } else {
        return write(file, code)
      }
    })
}

// 使用promise包裹
function write (dest, code, zip) {
  return new Promise((resolve, reject) => {
    function report (extra) {
      console.log(blue(path.relative(process.cwd(), dest)) + ' ' + getSize(code) + (extra || ''))
      resolve()
    }

    fs.writeFile(dest, code, err => {
      if (err) return reject(err)
      if (zip) {
        // 采用nodejs的内置模块zlib进行压缩
        zlib.gzip(code, (err, zipped) => {
          if (err) return reject(err)
          report(' (gzipped: ' + getSize(zipped) + ')')
        })
      } else {
        report()
      }
    })
  })
}

function getSize (code) {
  return (code.length / 1024).toFixed(2) + 'kb'
}

function blue (str) {
  return '\x1b[1m\x1b[34m' + str + '\x1b[39m\x1b[22m'
}

Runtime Only VS Runtime + Compiler

通常我们利用 vue-cli 去初始化我们的 Vue.js 项目的时候会询问我们用 Runtime Only 版本的还是 Runtime + Compiler 版本。下面我们来对比这两个版本。

  • Runtime Only

我们在使用 Runtime Only 版本的 Vue.js 的时候,通常需要借助如 webpack 的 vue-loader 工具把 .vue 文件编译成 JavaScript,因为是在编译阶段做的,所以它只包含运行时的 Vue.js 代码,因此代码体积也会更轻量

  • Runtime + Compiler

我们如果没有对代码做预编译,但又使用了 Vue 的 template 属性并传入一个字符串,则需要在客户端编译模板,如下所示:

// 需要编译器的版本
new Vue({
  template: '<div>{{ hi }}</div>'
})

// 这种情况不需要
new Vue({
  render (h) {
    return h('div', this.hi)
  }
})

因为在 Vue.js 2.0 中,最终渲染都是通过 render 函数,如果写 template 属性,则需要编译成 render 函数,那么这个编译过程会发生运行时,所以需要带有编译器的版本。

很显然,这个编译过程对性能会有一定损耗,所以通常我们更推荐使用 Runtime-Only 的 Vue.js。

尽管在实际开发过程中我们会用 Runtime Only 版本开发比较多,但为了分析 Vue 的编译过程,我们这门课重点分析的源码是 Runtime + Compiler 的 Vue.js。

从入口开始

在 web 应用下,我们来分析 Runtime + Compiler 构建出来的 Vue.js,它的入口是 src/platforms/web/entry-runtime-with-compiler.js

image.png

import Vue from './runtime/index'

const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && query(el)
  ...  
  return mount.call(this, el, hydrating)
}

export default Vue

那么,当我们的代码执行 import Vue from 'vue' 的时候,就是从这个入口执行代码来初始化 Vue, 那么 Vue 到底是什么,它是怎么初始化的,我们来一探究竟。

在这个入口 JS 的上方我们可以找到 Vue 的来源:import Vue from './runtime/index',我们先来看一下这块儿的实现,它定义在 src/platforms/web/runtime/index.js 中:

import Vue from 'core/index'

Vue.prototype.__patch__ = inBrowser ? patch : noop

Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

export default Vue

这里关键的代码是 import Vue from 'core/index',我们来看一下真正初始化 Vue 的地方,在 src/core/index.js 中:

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

initGlobalAPI(Vue)
...

export default Vue

这里有 2 处关键的代码,import Vue from './instance/index' 和 initGlobalAPI(Vue),初始化全局 Vue API,我们先来看第一部分,在 src/core/instance/index.js 中:

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 的庐山真面目,它实际上就是一个用 Function 实现的类,我们只能通过 new Vue 去实例化它。

为何 Vue 不用 ES6 的 Class 去实现呢?

我们往后看这里有很多 xxxMixin 的函数调用,并把 Vue 当参数传入,它们的功能都是给 Vue 的 prototype 上扩展一些方法,Vue 按功能把这些扩展分散到多个模块中去实现,而不是在一个模块里实现所有,这种方式是用 Class 难以实现的。这么做的好处是非常方便代码的维护和管理,这种编程技巧也非常值得我们去学习。

initGlobalAPI

Vue.js 在整个初始化过程中,除了给它的原型 prototype 上扩展方法,还会给 Vue 这个对象本身扩展全局的静态方法,它的定义在 src/core/global-api/index.js 中:

export function initGlobalAPI (Vue: GlobalAPI) {
    ...
    Vue.set = set
    Vue.delete = del
    Vue.nextTick = nextTick
    // 在Vue上挂载了静态成员options
    Vue.options = Object.create(null)
    // ASSET_TYPES: 'component', 'directive', 'filter'
    // 给options里面增加components, directives, filters选项属性
    ASSET_TYPES.forEach(type => {
      Vue.options[type + 's'] = Object.create(null)
    })
    // 在options上的存储一个Vue本身,后面会用到
    Vue.options._base = Vue
    // builtInComponents表示内置组件,比如keep-alive
    extend(Vue.options.components, builtInComponents)
    // 注册use方法
    initUse(Vue)
    // 注册mixin方法
    initMixin(Vue)
    // 注册extend方法
    initExtend(Vue)
    // 给vue增加'component', 'directive', 'filter'方法
    initAssetRegisters(Vue)
}

至此,Vue 的初始化过程基本介绍完毕。通过本文的学习现在对 Vue 是什么有一个直观的认识,它本质上就是一个用 Function 实现的 Class,然后它的原型 prototype 以及它本身都扩展了一系列的方法和属性。