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

本教程是rollup.js系列教程的最後一篇,我將基於Vue.js框架,深度分析Vue.js源碼打包過程,讓你們深刻理解複雜的前端框架是如何利用rollup.js進行打包的。經過這一篇教程的學習,相信你們能夠更好地應用rollup.js爲本身的項目服務。前端

前置學習——基礎知識

要理解Vue.js的打包源碼,須要掌握如下知識點:vue

  • fs模塊:Node.js內置模塊,用於本地文件系統處理;
  • path模塊:Node.js內置模塊,用於本地路徑解析;
  • buble模塊:用於ES6+語法編譯;
  • flow模塊:用於Javascript源碼靜態檢查;
  • zlib模塊:Node.js內置模塊,用於使用gzip算法進行文件壓縮;
  • terser模塊:用於Javascript代碼壓縮和美化。

我將這些基礎知識點整理成一篇前置學習教程:《10分鐘快速精通rollup.js——前置學習之基礎知識篇》,感興趣的小夥伴能夠看看。node

前置學習——rollup.js插件

rollup.js進階教程中講解了rollup.js的部分經常使用插件:git

  • rollup-plugin-resolve:集成外部模塊代碼;
  • rollup-plugin-commonjs:支持CommonJS模塊;
  • rollup-plugin-babel:編譯ES6+語法爲ES2015
  • rollup-plugin-json:支持json模塊;
  • rollup-plugin-uglify:代碼壓縮(不支持ES模塊);

爲了理解Vue.js的打包源碼,咱們還須要學習如下rollup.js插件及知識:github

  • 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插件篇》web

Vue.js源碼打包

Vue.js的打包過程並不複雜,首先要將Vue.js源碼clone到本地:算法

git clone https://github.com/vuejs/vue.git
複製代碼

安裝依賴:npm

cd vue
npm i
複製代碼

打開package.json查看scripts:json

"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分鐘系列教程到此完結,對本教程有任何建議很是歡迎你們給我留言,教程內容較多,謝謝你們耐心看完。

相關文章
相關標籤/搜索