深入Parcel--架構與流程篇

李13發表於2019-04-02

深入Parcel--架構與流程篇

本篇文章是對 Parce 的原始碼解析,程式碼基本架構與執行流程,帶你瞭解打包工具的內部原理,在這之前你如果對 parcel 不熟悉可以先到 Parcel官網 瞭解

介紹

下面是偷懶從官網抄下來的介紹:

極速零配置Web應用打包工具

  • 極速打包 Parcel 使用 worker 程式去啟用多核編譯。同時有檔案系統快取,即使在重啟構建後也能快速再編譯。
  • 將你所有的資源打包 Parcel 具備開箱即用的對 JS, CSS, HTML, 檔案 及更多的支援,而且不需要外掛。
  • 自動轉換 如若有需要,Babel, PostCSS, 和 PostHTML 甚至 node_modules 包會被用於自動轉換程式碼.
  • 零配置程式碼分拆 使用動態 import() 語法, Parcel 將你的輸出檔案束(bundles)分拆,因此你只需要在初次載入時載入你所需要的程式碼。
  • 熱模組替換 Parcel 無需配置,在開發環境的時候會自動在瀏覽器內隨著你的程式碼更改而去更新模組。
  • 友好的錯誤日誌 當遇到錯誤時,Parcel 會輸出 語法高亮的程式碼片段,幫助你定位問題。
打包工具 時間
browserify 22.98s
webpack 20.71s
parcel 9.98s
parcel - with cache 2.64s

打包工具

我們常用的打包工具大致功能:

  • 模組化(程式碼的拆分, 合併, Tree-Shaking 等)
  • 編譯(es6,7,8 sass typescript 等)
  • 壓縮 (js, css, html包括圖片的壓縮)
  • HMR (熱替換)

version

parcel-bundler 版本:

"version": "1.11.0"

檔案架構

|-- assets          資源目錄 繼承自 Asset.js
|-- builtins        用於最終構建
|-- packagers       打包
|-- scope-hoisting  作用域提升 Tree-Shake
|-- transforms      轉換程式碼為 AST
|-- utils           工具
|-- visitors        遍歷 js AST樹 收集依賴等

|-- Asset.js          資源
|-- Bundle.js         用於構建 bundle 樹
|-- Bundler.js        主目錄  
|-- FSCache.js        快取
|-- HMRServer.js      HMR伺服器提供 WebSocket
|-- Parser.js         根據副檔名獲取對應 Asset
|-- Pipeline.js       多執行緒執行方法
|-- Resolver.js       解析模組路徑
|-- Server.js         靜態資源伺服器
|-- SourceMap.js      SourceMap
|-- cli.js            cli入口 解析命令列引數
|-- worker.js         多執行緒入口
複製程式碼

流程

說明

Parcel是面向資源的,JavaScript,CSS,HTML 這些都是資源,並不是 webpackjs 是一等公民,Parcel 會自動的從入口檔案開始分析這些檔案 和 模組中的依賴,然後構建一個 bundle 樹,並對其進行打包輸出到指定目錄

一個簡單的例子

我們從一個簡單的例子開始瞭解 parcel 內部原始碼與流程

index.html
  |-- index.js
    |-- module1.js
    |-- module2.js
複製程式碼

上面是我們例子的結構,入口為 index.html, 在 index.html 中我們用 script 標籤引用了 src/index.js,在 index.js 中我們引入了2個子模組

執行

npx parcel index.html 或者 ./node_modules/.bin/parcel index.html,或者使用 npm script

cli

"bin": {
    "parcel": "bin/cli.js"
}
複製程式碼

檢視 parcel-bundlerpackage.json 找到 bin/cli.js,在cli.js裡又指向 ../src/cli

const program = require('commander');

program
  .command('serve [input...]') // watch build
  ...
  .action(bundle);

program.parse(process.argv);

async function bundle(main, command) {
  const Bundler = require('./Bundler');

  const bundler = new Bundler(main, command);

  if (command.name() === 'serve' && command.target === 'browser') {
    const server = await bundler.serve();

    if (server && command.open) {...啟動自動開啟瀏覽器}
  } else {
    bundler.bundle();
  }
}
複製程式碼

cli.js 中利用 commander 解析命令列並呼叫 bundle 方法 有 serve, watch, build 3個命令來呼叫 bundle 函式,執行 pracel index.html 預設為 serve,所以呼叫的是 bundler.serve 方法

進入 Bundler.js

bundler.serve

async serve(port = 1234, https = false, host) {
    this.server = await Server.serve(this, port, host, https);
    try {
      await this.bundle();
    } catch (e) {}
    return this.server;
  }
複製程式碼

bundler.serve 方法 呼叫 serveStatic 起了一個靜態服務指向 最終打包的資料夾 下面就是重要的 bundle 方法

bundler.bundle

async bundle() {
    // 載入外掛 設定env 啟動多執行緒 watcher hmr
    await this.start();

    if (isInitialBundle) {
      // 建立 輸出目錄
      await fs.mkdirp(this.options.outDir);

      this.entryAssets = new Set();
      for (let entry of this.entryFiles) {
          let asset = await this.resolveAsset(entry);
          this.buildQueue.add(asset);
          this.entryAssets.add(asset);
      }
    }

    // 打包佇列中的資源
    let loadedAssets = await this.buildQueue.run();

    // findOrphanAssets 獲取所有資源中獨立的沒有父Bundle的資源
    let changedAssets = [...this.findOrphanAssets(), ...loadedAssets];

    // 因為接下來要構建 Bundle 樹,先對上一次的 Bundle樹 進行 clear 操作
    for (let asset of this.loadedAssets.values()) {
      asset.invalidateBundle();
    }

    // 構建 Bundle 樹
    this.mainBundle = new Bundle();
    for (let asset of this.entryAssets) {
      this.createBundleTree(asset, this.mainBundle);
    }

    // 獲取新的最終打包檔案的url
    this.bundleNameMap = this.mainBundle.getBundleNameMap(
      this.options.contentHash
    );
    // 將程式碼中的舊檔案url替換為新的
    for (let asset of changedAssets) {
      asset.replaceBundleNames(this.bundleNameMap);
    }

    // 將改變的資源通過websocket傳送到瀏覽器
    if (this.hmr && !isInitialBundle) {
      this.hmr.emitUpdate(changedAssets);
    }

    // 對資源打包
    this.bundleHashes = await this.mainBundle.package(
      this,
      this.bundleHashes
    );

    // 將獨立的資源刪除
    this.unloadOrphanedAssets();

    return this.mainBundle;
  }
複製程式碼

我們一步步先從 this.start

start

if (this.farm) {
  return;
}

await this.loadPlugins();

if (!this.options.env) {
  await loadEnv(Path.join(this.options.rootDir, 'index'));
  this.options.env = process.env;
}

if (this.options.watch) {
  this.watcher = new Watcher();
  this.watcher.on('change', this.onChange.bind(this));
}

if (this.options.hmr) {
  this.hmr = new HMRServer();
  this.options.hmrPort = await this.hmr.start(this.options);
}

this.farm = await WorkerFarm.getShared(this.options, {
  workerPath: require.resolve('./worker.js')
  });
複製程式碼

start:

  • 開頭的判斷 防止多次執行,也就是說 this.start 只會執行一次
  • loadPlugins 載入外掛,找到 package.json 檔案 dependencies, devDependenciesparcel-plugin-開頭的外掛進行呼叫
  • loadEnv 載入環境變數,利用 dotenv, dotenv-expand 包將 env.development.local, .env.development, .env.local, .env 擴充套件至 process.env
  • watch 初始化監聽檔案並繫結 change 回撥函式,內部 child_process.fork 起一個子程式,使用 chokidar 包來監聽檔案改變
  • hmr 起一個服務,WebSocket 向瀏覽器傳送更改的資源
  • farm 初始化多程式並指定 werker 工作檔案,開啟多個 child_process 去解析編譯資源

接下來回到 bundleisInitialBundle 是一個判斷是否是第一次構建 fs.mkdirp 建立輸出資料夾 遍歷入口檔案,通過 resolveAsset,內部呼叫 resolver 解析路徑,並 getAsset 獲取到對應的 asset(這裡我們入口是 index.html,根據副檔名獲取到的是 HTMLAsset) 將 asset 新增進佇列 然後啟動 this.buildQueue.run() 對資源從入口遞迴開始打包

PromiseQueue

這裡 buildQueue 是一個 PromiseQueue 非同步佇列 PromiseQueue 在初始化的時候傳入一個回撥函式 callback,內部維護一個引數佇列 queueadd 往佇列裡 push 一個引數,run 的時候while遍歷佇列 callback(...queue.shift()),佇列全部執行完畢 Promise 置為完成(resolved)(可以將其理解為 Promise.all) 這裡定義的回撥函式是 processAsset,引數就是入口檔案 index.htmlHTMLAsset

async processAsset(asset, isRebuild) {
  if (isRebuild) {
    asset.invalidate();
    if (this.cache) {
      this.cache.invalidate(asset.name);
    }
  }

  await this.loadAsset(asset);
}
複製程式碼

processAsset 函式內先判斷是否是 Rebuild ,是第一次構建,還是 watch 監聽檔案改變進行的重建,如果是重建則對資源的屬性重置,並使其快取失效 之後呼叫 loadAsset 載入資源編譯資源

loadAsset

async loadAsset(asset) {
    if (asset.processed) {
      return;
    }

    // Mark the asset processed so we don't load it twice
    asset.processed = true;

    // 先嚐試讀快取,快取沒有在後臺載入和編譯
    asset.startTime = Date.now();
    let processed = this.cache && (await this.cache.read(asset.name));
    let cacheMiss = false;
    if (!processed || asset.shouldInvalidate(processed.cacheData)) {
      processed = await this.farm.run(asset.name);
      cacheMiss = true;
    }

    asset.endTime = Date.now();
    asset.buildTime = asset.endTime - asset.startTime;
    asset.id = processed.id;
    asset.generated = processed.generated;
    asset.hash = processed.hash;
    asset.cacheData = processed.cacheData;

    // 解析和載入當前資源的依賴項
    let assetDeps = await Promise.all(
      dependencies.map(async dep => {
          dep.parent = asset.name;
          let assetDep = await this.resolveDep(asset, dep);
          if (assetDep) {
            await this.loadAsset(assetDep);
          }
          return assetDep;
      })
    );

    if (this.cache && cacheMiss) {
      this.cache.write(asset.name, processed);
    }
  }
複製程式碼

loadAsset 在開始有個判斷防止重複編譯 之後去讀快取,讀取失敗就呼叫 this.farm.run 在多程式裡編譯資源 編譯完就去載入並編譯依賴的檔案 最後如果是新的資源沒有用到快取,就重新設定一下快取 下面說一下這裡嗎涉及的兩個東西:快取 FSCache 和 多程式 WorkerFarm

FSCache

read 讀取快取,並判斷最後修改時間和快取的修改時間 write 寫入快取

深入Parcel--架構與流程篇

快取目錄為了加速讀取,避免將所有的快取檔案放在一個資料夾裡,parcel16進位制 兩位數的 256 種可能建立為資料夾,這樣存取快取檔案的時候,將目標檔案路徑 md5 加密轉換為 16進位制,然後擷取前兩位是目錄,後面幾位是檔名

WorkerFarm

在上面 start 裡初始化 farm 的時候,workerPath 指向了 worker.js 檔案,worker.js 裡有兩個函式,initrun WorkerFarm.getShared 初始化的時候會建立一個 new WorkerFarm ,呼叫 worker.jsinit 方法,根據 cpu 獲取最大的 Worker 數,並啟動一半的子程式 farm.run 會通知子程式執行 worker.jsrun 方法,如果程式數沒有達到最大會再次開啟一個新的子程式,子程式執行完畢後將 Promise狀態更改為完成 worker.run -> pipeline.process -> pipeline.processAsset -> asset.process Asset.process 處理資源:

async process() {
    if (!this.generated) {
      await this.loadIfNeeded();
      await this.pretransform();
      await this.getDependencies();
      await this.transform();
      this.generated = await this.generate();
    }

    return this.generated;
  }
複製程式碼

將上面的程式碼內部擴充套件一下:

async process() {
  // 已經有就不需要編譯
  if (!this.generated) {
    // 載入程式碼
    if (this.contents == null) {
      this.contents = await this.load();
    }
    // 可選。在收集依賴之前轉換。
    await this.pretransform();
    // 將程式碼解析為 AST 樹
    if (!this.ast) {
      this.ast = await this.parse(this.contents);
    }
    // 收集依賴
    await this.collectDependencies();
    // 可選。在收集依賴之後轉換。
    await this.transform();
    // 生成程式碼
    this.generated = await this.generate();
  }

  return this.generated;
}

// 最後處理程式碼
async postProcess(generated) {
  return generated
}
複製程式碼

processAsset 中呼叫 asset.process 生成 generated 這個generated 不一定是最終程式碼 ,像 html裡內聯的 script ,vuehtml, js, css,都會進行二次或多次遞迴處理,最終呼叫 asset.postProcess 生成程式碼

Asset

下面說幾個實現 HTMLAsset

  • pretransform 呼叫 posthtmlhtml 解析為 PostHTMLTree(如果沒有設定posthtmlrc之類的不會走)
  • parse 呼叫 posthtml-parserhtml 解析為 PostHTMLTree
  • collectDependencies 用 walk 遍歷 ast,找到 script, imgsrclinkhref 等的地址,將其加入到依賴
  • transform htmlnano 壓縮程式碼
  • generate 處理內聯的 scriptcss
  • postProcess posthtml-render 生成 html 程式碼

JSAsset

  • pretransform 呼叫 @babel/corejs 解析為 AST,處理 process.env
  • parse 呼叫 @babel/parserjs 解析為 AST
  • collectDependencies 用 babylon-walk 遍歷 ast, 如 ImportDeclarationimport xx from 'xx' 語法,CallExpression 找到 require呼叫,import 被標記為 dynamic 動態匯入,將這些模組加入到依賴
  • transform 處理 readFileSync__dirname, __filename, global等,如果沒有設定scopeHoist 並存在 es6 module 就將程式碼轉換為 commonjsterser 壓縮程式碼
  • generate @babel/generator 獲取 jssourceMap 程式碼

VueAsset

  • parse @vue/component-compiler-utilsvue-template-compiler.vue 檔案進行解析
  • generate 對 html, js, css 處理,就像上面說到會對其分別呼叫 processAsset 進行二次解析
  • postProcess component-compiler-utilscompileTemplate, compileStyle處理 html,cssvue-hot-reload-api HMR處理,壓縮程式碼

回到 bundle 方法:

let loadedAssets = await this.buildQueue.run() 就是上面說到的PromiseQueueWorkerFarm 結合起來:buildQueue.run —> processAsset -> loadAsset -> farm.run -> worker.run -> pipeline.process -> pipeline.processAsset -> asset.process,執行之後所有資源編譯完畢,並返回入口資源loadedAssets就是 index.html 對應的 HTMLAsset 資源

之後是 let changedAssets = [...this.findOrphanAssets(), ...loadedAssets] 獲取到改變的資源

findOrphanAssets 是從所有資源中查詢沒有 parentBundle 的資源,也就是獨立的資源,這個 parentBundle 會在等會的構建 Bundle 樹中被賦值,第一次構建都沒有 parentBundle,所以這裡會重複入口檔案,這裡的 findOrphanAssets 的作用是在第一次構建之後,檔案change的時候,在這個檔案 import了新的一個檔案,因為新檔案沒有被構建過 Bundle 樹,所以沒有 parentBundle,這個新檔案也被標記物 change

invalidateBundle 因為接下來要構建新的樹所以呼叫重置所有資源上一次樹的屬性

createBundleTree 構建 Bundle 樹: 首先一個入口資源會被建立成一個 bundle,然後動態的 import() 會被建立成子 bundle ,這引發了程式碼的拆分。

當不同型別的檔案資源被引入,兄弟 bundle 就會被建立。例如你在 JavaScript 中引入了 CSS 檔案,那它會被放置在一個與 JavaScript 檔案對應的兄弟 bundle 中。

如果資源被多於一個 bundle 引用,它會被提升到 bundle 樹中最近的公共祖先中,這樣該資源就不會被多次打包。

Bundle

  • type:它包含的資源型別 (例如:js, css, map, ...)
  • name:bundle 的名稱 (使用 entryAsset 的 Asset.generateBundleName() 生成)
  • parentBundle:父 bundle ,入口 bundle 的父 bundle 是 null
  • entryAsset:bundle 的入口,用於生成名稱(name)和聚攏資源(assets)
  • assets:bundle 中所有資源的集合(Set)
  • childBundles:所有子 bundle 的集合(Set)
  • siblingBundles:所有兄弟 bundle 的集合(Set)
  • siblingBundlesMap:所有兄弟 bundle 的對映 Map<String(Type: js, css, map, ...), Bundle>
  • offsets:所有 bundle 中資源位置的對映 Map<Asset, number(line number inside the bundle)> ,用於生成準確的 sourcemap 。

我們的例子會被構建成:

html            ( index.html )
  |-- js        ( index.js, module1.js, module2.js )
    |-- map     ( index.js, module1.js, module2.js )
複製程式碼

module1.jsmodule2.js 被提到了與 index.js 同級,map 因為型別不同被放到了 子bundle

一個複雜點的樹:

// 資源樹
index.html
  |-- index.css
  |-- bg.png
  |-- index.js
    |-- module.js
複製程式碼
// mainBundle
html            ( index.html )
  |-- js        ( index.js, module.js )
    |-- map     ( index.map, module.map )
  |-- css       ( index.css )
    |-- js      ( index.css, css-loader.js bundle-url.js )
    |-- map     ( css-loader.js, bundle-url.js )
  |-- png       ( bg.png )
複製程式碼

因為要對 css 熱更新,所以新增了 css-loader.js, bundle-url.js 兩個 js

replaceBundleNames替換引用:生成樹之後將程式碼中的檔案引用替換為最終打包的檔名,如果是生產環境會替換為 contentHash 根據內容生成 hash

hmr更新: 判斷啟用 hmr 並且不是第一次構建的情況,呼叫 hmr.emitUpdate 將改變的資源傳送給瀏覽器

Bundle.package 打包

unloadOrphanedAssets 將獨立的資源刪除

package

packagegenerated 寫入到檔案 有6種打包: CSSPackagerHTMLPackagerSourceMapPackagerJSPackagerJSConcatPackagerRawPackager 當開啟 scopeHoist 時用 JSConcatPackager 否則 JSPackager 圖片等資源用 RawPackager

最終我們的例子被打包成 index.html, src.[hash].js, src.[hash].map 3個檔案

index.html 裡的 js 路徑被替換成立最終打包的地址

我們看一下打包的 js:

parcelRequire = (function (modules, cache, entry, globalName) {
  // Save the require from previous bundle to this closure if any
  var previousRequire = typeof parcelRequire === 'function' && parcelRequire;
  var nodeRequire = typeof require === 'function' && require;

  function newRequire(name, jumped) {
    if (!cache[name]) {
      localRequire.resolve = resolve;
      localRequire.cache = {};

      var module = cache[name] = new newRequire.Module(name);

      modules[name][0].call(module.exports, localRequire, module, module.exports, this);
    }

    return cache[name].exports;

    function localRequire(x){
      return newRequire(localRequire.resolve(x));
    }

    function resolve(x){
      return modules[name][4][x] || x;
    }
  }
  for (var i = 0; i < entry.length; i++) {
    newRequire(entry[i]);
  }
  // Override the current require with this new one
  return newRequire;
})({"src/module1.js":[function(require,module,exports) {
"use strict";

},{}],"src/module2.js":[function(require,module,exports) {
"use strict";

},{}],"src/index.js":[function(require,module,exports) {
"use strict";

var _module = require("./module");

var _module2 = require("./module1");

var _module3 = require("./module2");
console.log(_module.m);
},{"./module":"src/module.js","./module1":"src/module1.js","./module2":"src/module2.js","fs":"node_modules/parcel-bundler/src/builtins/_empty.js"}]
,{}]},{},["node_modules/parcel-bundler/src/builtins/hmr-runtime.js","src/index.js"], null)
//# sourceMappingURL=/src.a2b27638.map
複製程式碼

可以看到程式碼被拼接成了物件的形式,接收引數 module, require 用來模組匯入匯出,實現了 commonjs 的模組載入機制,一個更加簡化版:

parcelRequire = (function (modules, cache, entry, globalName) {
  function newRequire(id){
    if(!cache[id]){
      let module = cache[id] = { exports: {} }
      modules[id][0].call(module.exports, newRequire, module, module.exports, this);
    }
    return cache[id]
  }
  for (var i = 0; i < entry.length; i++) {
    newRequire(entry[i]);
  }
  return newRequire;
})()
複製程式碼

程式碼被拼接起來:

`(function(modules){
  //...newRequire
})({` +
  asset.id +
    ':[function(require,module,exports) {\n' +
        asset.generated.js +
      '\n},' +
'})'
複製程式碼
(function(modules){
  //...newRequire
})({
  "src/index.js":[function(require,module,exports){
    // code
  }]
})
複製程式碼

hmr-runtime

上面打包的 js 中還有個 hmr-runtime.js 太長被我省略了 hmr-runtime.js 建立一個 WebSocket 監聽服務端訊息 修改檔案觸發 onChange 方法,onChange 將改變的資源 buildQueue.add 加入構建佇列,重新呼叫 bundle 方法,打包資源,並呼叫 emitUpdate 通知瀏覽器更新 當瀏覽器接收到服務端有新資源更新訊息時 新的資源就會設定或覆蓋之前的模組 modules[asset.id] = new Function('require', 'module', 'exports', asset.generated.js) 對模組進行更新:

function hmrAccept(id){
  // dispose 回撥
  cached.hot._disposeCallbacks.forEach(function (cb) {
    cb(bundle.hotData);
  });

  delete bundle.cache[id]; // 刪除之前快取
  newRequire(id); // 重新此載入

  // accept 回撥
  cached.hot._acceptCallbacks.forEach(function (cb) {
    cb();
  });

  // 遞迴父模組 進行更新
  getParents(global.parcelRequire, id).some(function (id) {
    return hmrAccept(global.parcelRequire, id);
  });
}
複製程式碼

至此整個打包流程結束

總結

parcle index.html 進入 cli,啟動Server呼叫 bundle,初始化配置(Plugins, env, HMRServer, Watcher, WorkerFarm),從入口資源開始,遞迴編譯(babel, posthtml, postcss, vue-template-compiler等),編譯完設定快取,構建 Bundle 樹,進行打包 如果沒有 watch 監聽,結束關閉 Watcher, Worker, HMRwatch 監聽: 檔案修改,觸發 onChange,將修改的資源加入構建佇列,遞迴編譯,查詢快取(這一步快取的作用就提醒出來了),編譯完設定新快取,構建 Bundle 樹,進行打包,將 change 的資源傳送給瀏覽器,瀏覽器接收 hmr 更新資源

最後

通過此文章希望你對 parcel 的大致流程,打包工具原理有更深的瞭解 瞭解更多請關注專欄,後續 深入Parcel 同系列文章,對 AssetPackagerWorkerHMRscopeHoistFSCacheSourceMapimport 更加詳細講解與程式碼實現

相關文章