深刻 Parcel--架構與流程篇

本篇文章是對 Parce 的源碼解析,代碼基本架構與執行流程,帶你瞭解打包工具的內部原理,在這以前你若是對 parcel 不熟悉能夠先到 Parcel官網 瞭解javascript

介紹

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

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

  • 極速打包 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

打包工具

咱們經常使用的打包工具大體功能:vue

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

version

parcel-bundler 版本:java

"version": "1.11.0"node

文件架構

|-- 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 樹,並對其進行打包輸出到指定目錄webpack

一個簡單的例子

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

index.html
  |-- index.js
    |-- module1.js
    |-- module2.js
複製代碼

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

執行

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

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 寫入緩存

緩存目錄爲了加速讀取,避免將全部的緩存文件放在一個文件夾裏,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 更加詳細講解與代碼實現

相關文章
相關標籤/搜索