在第一章中呢,我们对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 文件。同学们还是要自己跟一下源码,通过断点调试一下,才能加深印象和理解噢。