本教程是rollup.js系列教程的最後一篇,我將基於Vue.js框架,深度分析Vue.js原始碼打包過程,讓大家深入理解複雜的前端框架是如何利用rollup.js進行打包的。通過這一篇教程的學習,相信大家可以更好地應用rollup.js為自己的專案服務。
前置學習——基礎知識
要理解Vue.js
的打包原始碼,需要掌握以下知識點:
fs
模組:Node.js
內建模組,用於本地檔案系統處理;path
模組:Node.js
內建模組,用於本地路徑解析;buble
模組:用於ES6+
語法編譯;flow
模組:用於Javascript
原始碼靜態檢查;zlib
模組:Node.js
內建模組,用於使用gzip
演算法進行檔案壓縮;terser
模組:用於Javascript
程式碼壓縮和美化。
我將這些基礎知識點整理成一篇前置學習教程:《10分鐘快速精通rollup.js——前置學習之基礎知識篇》,感興趣的小夥伴可以看看。
前置學習——rollup.js外掛
rollup.js
進階教程中講解了rollup.js
的部分常用外掛:
rollup-plugin-resolve
:整合外部模組程式碼;rollup-plugin-commonjs
:支援CommonJS
模組;rollup-plugin-babel
:編譯ES6+
語法為ES2015
;rollup-plugin-json
:支援json
模組;rollup-plugin-uglify
:程式碼壓縮(不支援ES
模組);
為了理解Vue.js
的打包原始碼,我們還需要學習以下rollup.js
外掛及知識:
rollup-plugin-buble
外掛:編譯ES6+
語法為ES2015
,無需配置,比babel
更輕量;rollup-plugin-alias
外掛:替換模組路徑中的別名;rollup-plugin-flow-no-whitespace
外掛:去除flow
靜態型別檢查程式碼;rollup-plugin-replace
外掛:替換程式碼中的變數為指定值;rollup-plugin-terser
外掛:程式碼壓縮,取代uglify
,支援ES
模組。intro
和outro
配置:在程式碼塊內新增程式碼註釋。
我為還不熟悉這些外掛的小夥伴準備了另一篇前置學習教程:《10分鐘快速精通rollup.js——前置學習之rollup.js外掛篇》。
Vue.js原始碼打包
Vue.js
的打包過程並不複雜,首先要將Vue.js
原始碼clone到本地:
git clone https://github.com/vuejs/vue.git
複製程式碼
安裝依賴:
cd vue
npm i
複製程式碼
開啟package.json檢視scripts:
"scripts": {
"build": "node scripts/build.js",
"build:ssr": "npm run build -- web-runtime-cjs,web-server-renderer",
"build:weex": "npm run build -- weex",
}
複製程式碼
我們先通過build指令進行打包:
$ npm run build
> vue@2.5.17-beta.0 build /Users/sam/WebstormProjects/vue
> node scripts/build.js
dist/vue.runtime.common.js 209.20kb
dist/vue.common.js 288.22kb
dist/vue.runtime.esm.js 209.18kb
dist/vue.esm.js 288.20kb
dist/vue.runtime.js 219.55kb
dist/vue.runtime.min.js 60.24kb (gzipped: 21.62kb)
dist/vue.js 302.27kb
dist/vue.min.js 85.19kb (gzipped: 30.86kb)
packages/vue-template-compiler/build.js 121.88kb
packages/vue-template-compiler/browser.js 228.17kb
packages/vue-server-renderer/build.js 220.73kb
packages/vue-server-renderer/basic.js 304.00kb
packages/vue-server-renderer/server-plugin.js 2.92kb
packages/vue-server-renderer/client-plugin.js 3.03kb
複製程式碼
打包成功後會在dist目錄下建立下列打包檔案:
以上就是使用build指令對Vue.js原始碼進行打包的過程,除此之外,Vue.js還提供了另外兩種打包方式:build:ssr
和build:weex
,先嚐試build:ssr
指令:
$ npm run build:ssr
> vue@2.5.17-beta.0 build:ssr /Users/sam/WebstormProjects/vue
> npm run build -- web-runtime-cjs,web-server-renderer
> vue@2.5.17-beta.0 build /Users/sam/WebstormProjects/vue
> node scripts/build.js "web-runtime-cjs,web-server-renderer"
dist/vue.runtime.common.js 209.20kb
packages/vue-server-renderer/build.js 220.73kb
packages/vue-server-renderer/basic.js 304.00kb
packages/vue-server-renderer/server-plugin.js 2.92kb
packages/vue-server-renderer/client-plugin.js 3.03kb
複製程式碼
再嘗試build:weex
:
$ npm run build:weex
> vue@2.5.17-beta.0 build:weex /Users/sam/WebstormProjects/vue
> npm run build -- weex
> vue@2.5.17-beta.0 build /Users/sam/WebstormProjects/vue
> node scripts/build.js "weex"
packages/weex-vue-framework/factory.js 193.79kb
packages/weex-vue-framework/index.js 5.68kb
packages/weex-template-compiler/build.js 109.11kb
複製程式碼
通過命令列日誌可以看出這兩個指令和build指令沒有本質區別,都是通過node
執行scripts/build.js原始碼,只是附帶的引數不同:
node scripts/build.js # build
node scripts/build.js "web-runtime-cjs,web-server-renderer" # build:ssr
node scripts/build.js "weex" # build:weex
複製程式碼
可見scripts/build.js是解讀Vue.js
原始碼打包的關鍵。下面我們就來分析Vue.js
的原始碼打包流程。
Vue.js打包流程分析
Vue.js
原始碼打包基於rollup.js
的API,大致可分為五步,如下圖所示:
- 第一步:建立dist目錄。檢查是否存在dist目錄,如果不存在,則進行建立;
- 第二步:生成
rollup
配置檔案。通過scripts/config.js生成rollup
的配置檔案; - 第三步:
rollup
配置檔案過濾。根據傳入的引數,對rollup
配置檔案的內容進行過濾,排除不必要的打包專案。 - 第四步:遍歷配置打包,生成打包原始碼。遍歷配置檔案專案,通過
rollup
的API進行打包,並生成打包後的原始碼。 - 第五步:原始碼輸出檔案,gzip壓縮測試。如果輸出的是最終產品,則通過
terser
進行最小化壓縮並通過zlib
進行gzip壓縮測試,並在控制檯輸出測試結果,最後將原始碼內容輸出到指定檔案中,完成打包。
Vue.js打包原始碼分析
下面我們將深入Vue.js
打包原始碼,解析打包的原理和細節。
友情提示:建議閱讀原始碼之前先將之前提供的四份教程全部看完:
建立dist目錄
執行npm run build
時,會從scripts/build.js開始執行:
// scripts/build.js
const fs = require('fs')
const path = require('path')
const zlib = require('zlib')
const rollup = require('rollup')
const terser = require('terser')
if (!fs.existsSync('dist')) {
fs.mkdirSync('dist')
}
複製程式碼
前5行分別匯入了5個模組,這5個模組的用途在前置學習教程中已經詳細過。第7行通過同步方法判斷dist目錄是否存在,如果不存在則通過同步方法建立dist目錄。
生成rollup配置
生成dist目錄後,通過以下程式碼生成了rollup
的配置檔案:
// scripts/build.js
let builds = require('./config').getAllBuilds()
複製程式碼
程式碼雖然只有短短一句,但是做了很多事情。首先它載入了scripts/config.js模組,然後呼叫其中的getAllBuilds()
方法。下面我們來分析scripts/config.js的載入過程,載入config.js時先執行了以下內容:
// scripts/config.js
const path = require('path')
const buble = require('rollup-plugin-buble')
const alias = require('rollup-plugin-alias')
const cjs = require('rollup-plugin-commonjs')
const replace = require('rollup-plugin-replace')
const node = require('rollup-plugin-node-resolve')
const flow = require('rollup-plugin-flow-no-whitespace')
複製程式碼
這些外掛的用途和用法在進階教程和前置教程中都有介紹。
const version = process.env.VERSION || require('../package.json').version
const weexVersion = process.env.WEEX_VERSION || require('../packages/weex-vue-framework/package.json').version
複製程式碼
上述程式碼是從package.json中獲取Vue
的版本號和Weex
的版本號。
const banner =
'/*!\n' +
` * Vue.js v${version}\n` +
` * (c) 2014-${new Date().getFullYear()} Evan You\n` +
' * Released under the MIT License.\n' +
' */'
複製程式碼
上述程式碼生成了banner文字,在Vue
程式碼打包後,會寫在檔案頂部。
const weexFactoryPlugin = {
intro () {
return 'module.exports = function weexFactory (exports, document) {'
},
outro () {
return '}'
}
}
複製程式碼
上述程式碼僅用於打包weex-factory
原始碼時使用:
// Weex runtime factory
'weex-factory': {
weex: true,
entry: resolve('weex/entry-runtime-factory.js'),
dest: resolve('packages/weex-vue-framework/factory.js'),
format: 'cjs',
plugins: [weexFactoryPlugin]
}
複製程式碼
接下來匯入了scripts/alias.js模組:
const aliases = require('./alias')
複製程式碼
alias.js模組輸出了一個物件,這個物件中定義了所有的別名及其對應的絕對路徑:
// 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'),
entries: resolve('src/entries'),
sfc: resolve('src/sfc')
}
複製程式碼
這個模組中定義了resolve()
方法,用於生成絕對路徑:
const resolve = p => path.resolve(__dirname, '../', p)
複製程式碼
__dirname
為當前模組對應的路徑,即scripts/
目錄,../
表示上一級目錄,即專案的根目錄,然後通過path.resolve()
方法將專案的根目錄與傳入的相對路徑結合起來形成最終結果。回到scripts/config.js模組,我們繼續向下執行:
// scripts/config.js
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)
}
}
複製程式碼
config.js也定義了一個resolve()
方法,該方法接收一個路徑引數p,假設p為web/entry-runtime.js
,則第一步獲取的base為web,然後到alias模組輸出的物件aliases中尋找對應的別名是否存在,web模組對應的別名是存在的,它的值為:
web: resolve('src/platforms/web')
複製程式碼
所以會將別名的實際路徑與檔名進行拼接,獲取檔案的真實路徑。檔名的獲取方法是:
p.slice(base.length + 1)
複製程式碼
如果傳入的路徑為:dist/vue.runtime.common.js
,則會查詢別名dist,該別名是不存在的,所以會執行另外一條路徑,將專案根路徑與傳入的引數路徑進行拼接,即執行下面這段程式碼:
return path.resolve(__dirname, '../', p)
複製程式碼
這與scripts/alias.js模組的實現是類似的。接下來config.js模組中定義了builds變數,程式碼節選如下:
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
}
}
複製程式碼
這個變數中呼叫resolve()
方法生成了檔案的真實路徑,由於配置項採用的是rollup.js
老版本的配置名稱,在新版本中已經被廢棄,所以緊接著config.js模組又定義了一個genConfig(name)
方法來解決這個問題:
function genConfig (name) {
const opts = builds[name]
const config = {
input: opts.entry,
external: opts.external,
plugins: [
replace({
__WEEX__: !!opts.weex,
__WEEX_VERSION__: weexVersion,
__VERSION__: version
}),
flow(),
buble(),
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)
}
}
}
if (opts.env) {
config.plugins.push(replace({
'process.env.NODE_ENV': JSON.stringify(opts.env)
}))
}
Object.defineProperty(config, '_name', {
enumerable: false,
value: name
})
return config
}
複製程式碼
這個方法的用途是將老版本的rollup.js
配置轉為新版本的格式。對於外掛部分,每一個打包專案都會採用replace
、flow
、buble
和alias
外掛,其餘自定義的外掛會合併到plugins中,通過以下程式碼實現:
plugins: [].concat(opts.plugins || []),
複製程式碼
genConfig()
方法還判斷了環境變數NODE_ENV
是否需要被替換:
if (opts.env) {
config.plugins.push(replace({
'process.env.NODE_ENV': JSON.stringify(opts.env)
}))
}
複製程式碼
上述程式碼判斷了傳入的opts中是否存在env引數,如果存在,則會將程式碼中的process.env.NODE_ENV
部分替換為JSON.stringify(opts.env)
:
,如傳入的env值為development,則生成的結果為帶雙引號的development
"development"
複製程式碼
除此之外,genConfig()
方法還將builds物件的key儲存在config物件中:
Object.defineProperty(config, '_name', {
enumerable: false,
value: name
})
複製程式碼
如果builds的key為web-runtime-cjs
,則生成的config為:
config = {
'_name': 'web-runtime-cjs'
}
複製程式碼
最後config.js模組定義了getAllBuilds()
方法:
if (process.env.TARGET) {
module.exports = genConfig(process.env.TARGET)
} else {
exports.getBuild = genConfig
exports.getAllBuilds = () => Object.keys(builds).map(genConfig)
}
複製程式碼
該方法首先判斷環境變數TARGET是否定義,在build的三種方法中沒有定義TARGET環境變數,所以會執行else中的邏輯,else邏輯中會暴露一個getBuild()
方法和getAllBuilds()
方法,getAllBuilds()
方法會獲取builds物件的key陣列,進行遍歷並呼叫genConfig()
方法生成配置物件,這樣rollup
的配置就生成了。
rollup配置過濾
我們回到scripts/build.js模組,配置生成完畢後,將對配置項進行過濾,因為每一種打包模式都將輸出不同的結果,過濾部分的原始碼如下:
// scripts/build.js
// filter builds via command line arg
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
})
}
複製程式碼
首先分析build命令,該命令實際執行指令為:
node scripts/build.js
複製程式碼
所以process.argv的內容為:
[ '/Users/sam/.nvm/versions/node/v11.2.0/bin/node',
'/Users/sam/WebstormProjects/vue/scripts/build.js' ]
複製程式碼
不存在process.argv[2],所以會執行else中的內容:
builds = builds.filter(b => {
return b.output.file.indexOf('weex') === -1
})
複製程式碼
這段程式碼的用途是排除weex
的程式碼打包,通過output.file是否包含weex
字串判斷是否為weex
程式碼。build:ssr
命令實際執行指令為:
node scripts/build.js "web-runtime-cjs,web-server-renderer"
複製程式碼
此時process.argv的值為:
[ '/Users/sam/.nvm/versions/node/v11.2.0/bin/node',
'/Users/sam/WebstormProjects/vue/scripts/build.js',
'web-runtime-cjs,web-server-renderer' ]
複製程式碼
process.argv[2]的值為web-runtime-cjs,web-server-renderer
,所以會執行if中的邏輯:
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)
複製程式碼
這個方法首先將引數通過逗號分隔為一個filters陣列,然後遍歷builds陣列,尋找output.file或_name中任一個包含filters中任一個的配置項。比如filters的第一個元素為:web-runtime-cjs,則會尋找output.file或_name中包含web-runtime-cjs
的配置項,_name之前分析過,它指向配置項的key,此時會找到下面的配置項符合條件:
'web-runtime-cjs': {
entry: resolve('web/entry-runtime.js'),
dest: resolve('dist/vue.runtime.common.js'),
format: 'cjs',
banner
}
複製程式碼
那麼該配置就會被保留,並最終被打包。
rollup打包
配置過濾完之後就會呼叫打包函式:
build(builds)
複製程式碼
build函式定義如下:
function build (builds) {
let built = 0 // 當前打包項序號
const total = builds.length // 需要打包的總次數
const next = () => {
buildEntry(builds[built]).then(() => {
built++ // 打包完成後序號加1
if (built < total) {
next() // 如果打包序號小於打包總次數,則繼續執行next()函式
}
}).catch(logError) // 輸出錯誤資訊
}
next() // 呼叫next()函式
}
複製程式碼
build()
函式接收builds引數,進行遍歷,並呼叫buildEntry()
函式執行實際的打包邏輯,buildEntry()
函式返回一個Promise物件,如果出錯,會呼叫logError(e)
函式列印報錯資訊:
function logError (e) {
console.log(e)
}
複製程式碼
打包的核心函式是buildEntry(config)
function buildEntry (config) {
const output = config.output // 獲取config的output配置項
const { file, banner } = output // 獲取output中的file和banner
const isProd = /min\.js$/.test(file) // 判斷file中是否以min.js結尾,如果是則標記isProd為true
return rollup.rollup(config) // 執行rollup打包
.then(bundle => bundle.generate(output)) // 將打包的結果生成原始碼
.then(({ code }) => { // 獲取打包生成的原始碼
if (isProd) { // 判斷是否為isProd
const minified = (banner ? banner + '\n' : '') + terser.minify(code, { // 執行程式碼最小化打包,並在程式碼標題處手動新增banner,因為最小化打包會導致註釋被刪除
output: {
ascii_only: true // 只支援ascii字元
},
compress: {
pure_funcs: ['makeMap'] // 過濾makeMap函式
}
}).code // 獲取最小化打包的程式碼
return write(file, minified, true) // 將程式碼寫入輸出路徑
} else {
return write(file, code) // 將程式碼寫入輸出路徑
}
})
}
複製程式碼
如果理解了rollup
的原理及terser
的使用方法,理解上述程式碼並不難,這裡與我們之前使用rollup
打包不同之處在於採用了手動新增banner註釋和手動輸出程式碼檔案,而之前都是rollup
自動輸出。之前我們採用的方法為:
const bundle = await rollup.rollup(input) // 獲取打包物件bundle
bundle.write(output) // 將打包物件輸出到檔案
複製程式碼
而Vue.js
採用的方法是:
const bundle = await rollup.rollup(input) // 獲取打包物件bundle
const { code, map } = await bundle.generate(output) // 根據bundle生成原始碼和source map
複製程式碼
通過bundle獲取原始碼,然後手動輸出到檔案中。
原始碼輸出
原始碼輸出主要是呼叫write()
函式,這裡需要提供3個引數:
- dest:輸出檔案的絕對路徑,通過output.file獲取;
- code:原始碼字串,通過
bundle.generate()
獲取; - zip:是否需要進行gzip壓縮測試,如果isProd為true,則zip為true,反之為false。
function write (dest, code, zip) {
return new Promise((resolve, reject) => {
function report (extra) { // 輸出日誌函式
console.log(blue(path.relative(process.cwd(), dest)) + ' ' + getSize(code) + (extra || '')) // 列印檔名稱、檔案容量和gzip壓縮測試結果
resolve()
}
fs.writeFile(dest, code, err => {
if (err) return reject(err) // 如果報錯則直接呼叫reject()方法
if (zip) { // 如果isProd則進行gzip測試
zlib.gzip(code, (err, zipped) => { // 通過gzip對原始碼進行壓縮測試
if (err) return reject(err)
report(' (gzipped: ' + getSize(zipped) + ')') // 測試成功後獲取gzip字串長度並輸出gizp容量
})
} else {
report() // 輸出日誌
}
})
})
}
複製程式碼
這裡有幾個細節需要注意,第一是獲取當前命令列路徑到最終生成檔案的相對路徑:
path.relative(process.cwd(), dest)
複製程式碼
第二是呼叫blue()
函式生成命令列藍色的文字:
function blue (str) {
return '\x1b[1m\x1b[34m' + str + '\x1b[39m\x1b[22m'
}
複製程式碼
第三是獲取檔案容量的方法:
function getSize (code) {
return (code.length / 1024).toFixed(2) + 'kb'
}
複製程式碼
這三個方法不難理解,但是都非常實用,大家在開發過程中可以多多借鑑。
總結
大家可以發現當我們具備了基礎知識後,再分析Vue.js
的原始碼打包過程並不複雜,所以建議大家工作中可以借鑑這種學習方式,將基礎知識點先抽離出來,單獨搞明白後再攻克複雜的原始碼。rollup.js
10分鐘系列教程到此完結,對本教程有任何建議非常歡迎大家給我留言,教程內容較多,謝謝大家耐心看完。