迫于菜🐶 - Vue.js 源码(二)

478 阅读6分钟

第一章中呢,我们对Vue.js的源码目录有了一个初略的认识,那么今天继续上菜。

今天学习 Vue.js 的源码构建咯。

构建脚本

我们知道,Vue.js 也是托管在NPM上,每一个NPM包都有一个 package.json 文件,里面包含了包的一些信息。

"name": "vue",                               // 包名
"version": "2.6.10",                         // 版本号
"main": "dist/vue.runtime.common.js",        // 入口文件
"module": "dist/vue.runtime.esm.js",         // 默认入口文件

除此之外,还提供了一个 scripts 来作为执行脚本,里面非常多的脚本,每一个脚本都是一个任务,我们通过 npm run xxx 就能执行这些不同的任务。其中与构建相关的有三条命令。

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

构建完之后生成的目标代码就都在 dist 文件夹中,里面存放了很多版本的 Vue.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 {
  // filter out weex builds by default
  builds = builds.filter(b => {
    return b.output.file.indexOf('weex') === -1
  })
}

这段代码就是先从配置文件读取配置,再通过命令行参数对构建配置做过滤,这样就可以构建出不同用途的 Vue.js 了。

再来分析一下到底是怎么取到配置的呢,打开 ' ./config '文件,直接滚到最后一行,这里暴露了 getAllBuilds 方法。

exports.getAllBuilds = () => Object.keys(builds).map(genConfig)

往上找到 builds,它是一个对象,而它的每个 key 也是一个对象。

const builds = {
  // Runtime only (CommonJS). Used by bundlers e.g. Webpack & Browserify
  'web-runtime-cjs-dev': {
    entry: resolve('web/entry-runtime.js'),
    dest: resolve('dist/vue.runtime.common.dev.js'),
    format: 'cjs',
    env: 'development',
    banner
  },
  // ...
  // Runtime only ES modules build (for bundlers)
  'web-runtime-esm': {
    entry: resolve('web/entry-runtime.js'),
    dest: resolve('dist/vue.runtime.esm.js'),
    format: 'es',
    banner
  },
  // 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',
    alias: { he: './entity-decoder' },
    banner
  }
  // ...
}

看到这里,大胆猜测一下,这些不同的配置是不是就是会构建出不同版本的Vue.js 呢,继续挖……

entry 属性是表示构建的入口 JS 文件地址。 dest 则属性表示构建后的 JS 文件地址,format 属性表示构建的格式。

入口

随便找个例子从 entry 开始分析,调用了 resolve 函数,看一下是怎么肥事。

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)
  }
}

通过 split('/')[0] 把字符串分为数组,然后取其第一个,也就是 web 设置为 base 然后作为参数传入了 aliases 方法,顺藤摸瓜看看它是做什么的。

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')
}

噢看到这里恍然大悟,实际上 aliases 也是暴露出一个对象,对象的 key 就是我们刚刚传入的 web ,然后又调用了 resolve ,可以看到它调用了 path.resolve,这是 Node.js 提供的解析路径的方法,__dirname 是当前根目录,通过 ' ../ ' 往上一级就到了 VUE 的 源码根目录,然后通过传入的字符串找到了 ' src/platforms/web ', 然后 resolve 函数通过 path.resolve(aliases[base], p.slice(base.length + 1)) 就能 找到最终的文件。

出口

那么我们再来看看出口,也就是 dest 的内容,可以看到 aliases 里是没有 ' dist ' 的,所以就会走到 resolve 函数的 else 逻辑,它返回了 path.resolve(__dirname, '../', p) ,和上面路径解析方法一致,这样就找到了 ' ./dist ' 目录,然后再生成 ./vue.runtime.common.js

格式

再看看 format 的格式,我们通过文件 ./vue.runtime.common.js./vue.runtime.esm.js 的对比可以发现,前者是 module.exports,后者是 export default ,而 ./vue.js 则是遵循 umd 格式,请看代码。

(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
  typeof define === 'function' && define.amd ? define(factory) :
  (global = global || self, global.Vue = factory());
}(this, function () { 'use strict';
// ...

大概可以看出 cjs 表示构建出来的文件遵循 CommonJS 规范,es 表示构建出来的文件遵循 ES Module 规范, umd 表示构建出来的文件遵循 UMD 规范。也就是说,可以通过不同的 format 来构建不同的版本的 JS 。

banner

banner 实际上定义了一个局部变量。

const banner =
  '/*!\n' +
  ` * Vue.js v${version}\n` +
  ` * (c) 2014-${new Date().getFullYear()} Evan You\n` +
  ' * Released under the MIT License.\n' +
  ' */'

我们随便找一个 ' ./dist '里构建好的 js 文件,可以看到这些信息

/*!
 * Vue.js v2.6.10
 * (c) 2014-2019 Evan You
 * Released under the MIT License.
 */

这里主要是标注了版本号,作者,以及License,当然这些都可以自己配置的。


回到 ' ./config ' 最后一行,最终通过 Object.keys(builds) 拿到包含所有 keys 的数组,然后 map 调用了 genConfig 函数,这样 genConfig 就能拿到每个 key。

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,
      banner: opts.banner,
      name: opts.moduleName || 'Vue'
    },
    onwarn: (msg, warn) => {
      if (!/Circular/.test(msg)) {
        warn(msg)
      }
    }
  }
  // ...

通过获取的 key ,新建了一个 config 对象,通过这样一层转换来适配 Rollup 打包所需的配置结构,比如 entry 只是在vue项目里的命名,而在 Rollup 中它叫 input ,还有一些其他的配置,这里不做深入探讨了。

最终 genConfig 返回的是一个数组,所以回到 ./build.js 中,变量 builds 也是一个数组。

let builds = require('./config').getAllBuilds()

然后通过下面的判断和过滤,最终得到我们需要编译执行的脚本。

编译

编译就比较简单了,就调用 build 函数。

function build (builds) {
  let built = 0
  const total = builds.length
  const next = () => {
    buildEntry(builds[built]).then(() => {
      built++
      if (built < total) {
        next()
      }
    }).catch(logError)
  }
  next()
}

build 函数里有一个 next 方法,里面调用了 buildEntry ,并且有一个计数,这样一个一个的进行编译。再来分析一下 buildEntry 方法。

function buildEntry (config) {
  const output = config.output
  const { file, banner } = output
  const isProd = /(min|prod)\.js$/.test(file)
  return rollup.rollup(config)
    .then(bundle => bundle.generate(output))
    .then(({ output: [{ code }] }) => {
      if (isProd) {
        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)
      }
    })
}

可以看到,传进去的参数 config 就作为 Rollup 最终编译需要的内容,编译完之后我们就能拿到 bundle ,再通过 generate 去产生 output ,它就对应着我们的目标文件。下面会对 code 做一层修改,通过 isProd 判断是否需要 terser.minify 压缩。 然后通过下面 write 方法最终生成到 ' ./dist '目录下。

总结

分析完了整个 Vue.js 的构建过程,也知道了不同作用和功能的 Vue.js 它们对应的入口以及最终编译生成的 JS 文件。同学们还是要自己跟一下源码,通过断点调试一下,才能加深印象和理解噢。