首頁  >  文章  >  web前端  >  Parcel原始碼的詳細分析(附範例)

Parcel原始碼的詳細分析(附範例)

不言
不言轉載
2019-03-18 10:41:022983瀏覽

這篇文章帶給大家的內容是關於Parcel原始碼的詳細分析(附範例),有一定的參考價值,有需要的朋友可以參考一下,希望對你有幫助。

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

#介紹

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

極速零設定Web應用打包工具極速打包

Parcel 使用 worker 進程去啟用多核心編譯。同時有檔案系統緩存,即使在重新啟動建置後也能快速再編譯。

將你所有的資源打包

Parcel 具備開箱即用的對 JS, CSS, HTML, 文件 及更多的支持,而且不需要插件。

自動轉換

如若有需要,Babel, PostCSS, 和 PostHTML 甚至 node_modules 套件會被用於自動轉換碼.

零設定碼分拆

使用動態 import() 語法, Parcel 將你的輸出檔案束(bundles)分拆,因此你只需要在初次載入時載入你所需要的程式碼。

熱模組替換

Parcel 不需要配置,在開發環境的時候會自動在瀏覽器內隨著你的程式碼更改而去更新模組。

友善的錯誤日誌

當遇到錯誤時,Parcel 會輸出 語法高亮的程式碼片段,幫助你定位問題。

包裝工具
#browserify 22.98 s
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 這些都是資源,並不是 webpack 中 js 是一等公民,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-bundler的 package.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.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, devDependencies 中 parcel-plugin-開頭的插件來呼叫loadEnv dotenv-expand 套件將 env.development.local, .env.development, .env.local, .env 擴充至 process.envwatch 初始化監聽檔案並綁定   來監聽文件改變hmr 起一個服務,WebSocket 向瀏覽器發送更改的資源farm 初始化多進程並指定 werker 工作文件,開啟多個 child_process 去解析編譯資源

接下來回到 bundle,isInitialBundle 是一個判斷是否為第一次建置
fs.mkdirp 建立輸出資料夾
遍歷入口文件,透過 resolveAsset,內部呼叫 resolver 解析路徑,並 getAsset 取得對應的 asset(這裡我們入口是 index.html,根據副檔名取得的是 HTMLAsset)
將 asset 加入進隊列
然後啟動 this.buildQueue.run() 對資源從入口遞歸開始打包

PromiseQueue




#PromiseQueue


ShQueue

PromiseQueue

##S 也許這裡打包######PromiseQueue######o是一個 PromiseQueue 非同步隊列###PromiseQueue 在初始化的時候傳入一個回呼函數 callback,內部維護一個參數隊列 queue,add 往隊列裡 push 一個參數,run 的時候while遍歷隊列)),佇列全部執行完畢 Promise 置為完成(resolved)(可以將其理解為 Promise.all)###這裡定義的回呼函數是 processAsset,參數就是入口檔案 index.html 的 HTMLAsset###
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原始碼的詳細分析(附範例)

缓存目录为了加速读取,避免将所有的缓存文件放在一个文件夹里,parcel 将 16进制 两位数的 256 种可能创建为文件夹,这样存取缓存文件的时候,将目标文件路径 md5 加密转换为 16进制,然后截取前两位是目录,后面几位是文件名

WorkerFarm

在上面 start 里初始化 farm 的时候,workerPath 指向了 worker.js 文件,worker.js 里有两个函数,init 和 run
WorkerFarm.getShared 初始化的时候会创建一个 new WorkerFarm ,调用 worker.js 的 init 方法,根据 cpu 获取最大的 Worker 数,并启动一半的子进程
farm.run 会通知子进程执行 worker.js 的 run 方法,如果进程数没有达到最大会再次开启一个新的子进程,子进程执行完毕后将 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 ,vue 的 html, js, css,都会进行二次或多次递归处理,最终调用 asset.postProcess 生成代码

Asset

下面说几个实现

HTMLAsset:

pretransform 调用 posthtml 将 html 解析为 PostHTMLTree(如果没有设置posthtmlrc之类的不会走)

parse 调用 posthtml-parser 将 html 解析为 PostHTMLTree

collectDependencies 用 walk 遍历 ast,找到 script, img 的 src,link 的 href 等的地址,将其加入到依赖

transform htmlnano 压缩代码

generate 处理内联的 script 和 css

postProcess posthtml-render 生成 html 代码

JSAsset:

pretransform 调用 @babel/core 将 js 解析为 AST,处理 process.env

parse 调用 @babel/parser 将 js 解析为 AST

collectDependencies 用 babylon-walk 遍历 ast, 如 ImportDeclaration,import xx from 'xx' 语法,CallExpression 找到 require调用,import 被标记为 dynamic 动态导入,将这些模块加入到依赖

transform 处理 readFileSync,__dirname, __filename, global等,如果没有设置scopeHoist 并存在 es6 module 就将代码转换为 commonjs,terser 压缩代码

generate @babel/generator 获取 js 与 sourceMap 代码

VueAsset:

parse @vue/component-compiler-utils 与 vue-template-compiler 对 .vue 文件进行解析

generate 对 html, js, css 处理,就像上面说到会对其分别调用 processAsset 进行二次解析

postProcess component-compiler-utils 的 compileTemplate, compileStyle处理 html,css,vue-hot-reload-api HMR处理,压缩代码

回到 bundle 方法:

let loadedAssets = await this.buildQueue.run() 就是上面说到的PromiseQueue 和 WorkerFarm 结合起来: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

offsets:所有 bundle 中资源位置的映射 Map ,用于生成准确的 sourcemap 。

我们的例子会被构建成:

html            ( index.html )
  |-- js        ( index.js, module1.js, module2.js )
    |-- map     ( index.js, module1.js, module2.js )

module1.js 和 module2.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

package 将generated 写入到文件
有6种打包:
CSSPackager,HTMLPackager,SourceMapPackager,JSPackager,JSConcatPackager,RawPackager
当开启 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 <p>可以看到代码被拼接成了对象的形式,接收参数 module, require 用来模块导入导出,实现了 commonjs 的模块加载机制,一个更加简化版:</p><pre class="brush:php;toolbar:false">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 <p>代码被拼接起来:</p><pre class="brush:php;toolbar:false">`(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, HMR
有 watch 监听:
文件修改,触发 onChange,将修改的资源加入构建队列,递归编译,查找缓存(这一步缓存的作用就提醒出来了),编译完设置新缓存,构建 Bundle 树,进行打包,将 change 的资源发送给浏览器,浏览器接收 hmr 更新资源

以上是Parcel原始碼的詳細分析(附範例)的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:segmentfault.com。如有侵權,請聯絡admin@php.cn刪除