10分鐘快速精通rollup.js——Vue.js原始碼打包原理深度分析
本教程是rollup.js系列教程的最後一篇,我將基於目前非常流行的Vue.js框架,深度分析Vue.js原始碼打包過程,讓大家深入理解複雜的前端框架是如何利用rollup.js進行打包的。通過這一篇教程的學習,相信大家可以更好地應用rollup.js為自己的專案服務。
說明:本教程基於Vue.js 2.5.17-beta.0版本原始碼
前置學習——基礎知識
要看懂Vue.js的打包原始碼,需要掌握以下知識點:
- fs模組:Node.js內建模組,用於本地檔案系統處理;
- path模組:Node.js內建模組,用於本地路徑解析;
- buble模組:用於ES6+語法編譯;
- flow模組:用於Javascript原始碼靜態檢查;
- zlib模組:Node.js內建模組,用於使用gzip演算法進行檔案壓縮;
- terser模組:用於Javascript程式碼壓縮和美化。
我將這些基礎知識點整理成一篇前置學習教程: ofollow,noindex">《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 > [email protected] 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 > [email protected] build:ssr /Users/sam/WebstormProjects/vue > npm run build -- web-runtime-cjs,web-server-renderer > [email protected] 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 > [email protected] build:weex /Users/sam/WebstormProjects/vue > npm run build -- weex > [email protected] 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.js的API進行打包,並生成打包後的原始碼。
- 第五步:原始碼輸出檔案,gzip壓縮測試。如果輸出的是最終產品,則通過terser進行最小化壓縮並通過zlib進行gzip壓縮測試,並在控制檯輸出測試結果,最後將原始碼內容輸出到指定檔案中,完成打包。
Vue.js打包原始碼分析
下面我們將深入Vue.js打包原始碼,解析Vue.js打包的原理和細節。
友情提示:建議閱讀原始碼之前先將之前提供的四份教程全部看完: 《10分鐘快速入門rollup.js》 《10分鐘快速進階rollup.js》 《10分鐘快速精通rollup.js——前置學習之基礎知識篇》 《10分鐘快速精通rollup.js——前置學習之rollup.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.js打包不同之處在於採用了手動新增banner註釋和手動輸出程式碼檔案,而之前都是rollup.js自動輸出。之前我們採用的方法為:
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分鐘系列教程到此完結,對本教程有任何建議非常歡迎大家給我留言,後續大家想看哪些技術的教程也可以給我留言,教程內容較多,謝謝大家耐心看完,祝大家學習進步。