10分鐘快速精通rollup.js——Vue.js原始碼打包原理深度分析

sam9831發表於2018-11-23

本教程是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模組。
  • introoutro配置:在程式碼塊內新增程式碼註釋。

我為還不熟悉這些外掛的小夥伴準備了另一篇前置學習教程:《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目錄下建立下列打包檔案:

Vue.js打包檔案
以上就是使用build指令對Vue.js原始碼進行打包的過程,除此之外,Vue.js還提供了另外兩種打包方式:build:ssrbuild: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,大致可分為五步,如下圖所示:

Vue.js原始碼打包流程

  • 第一步:建立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配置轉為新版本的格式。對於外掛部分,每一個打包專案都會採用replaceflowbublealias外掛,其餘自定義的外掛會合併到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.js10分鐘系列教程到此完結,對本教程有任何建議非常歡迎大家給我留言,教程內容較多,謝謝大家耐心看完。

相關文章