CodeSandbox 瀏覽器端的webpack是如何工做的? 上篇

這期來關注一下CodeSandbox, 這是一個瀏覽器端的沙盒運行環境,支持多種流行的構建模板,例如 create-react-appvue-cliparcel等等。 能夠用於快速原型開發、DEMO 展現、Bug 還原等等.css

類似的產品有不少,例如codepenJSFiddleWebpackBin(已廢棄).html

CodeSandbox 則更增強大,能夠視做是瀏覽器端的 Webpack 運行環境, 甚至在 V3 版本已經支持 VsCode 模式,支持 Vscode 的插件和 Vim 模式、還有主題.前端

另外 CodeSandbox 支持離線運行(PWA)。基本上能夠接近本地 VSCode 的編程體驗. 有 iPad 的同窗,也能夠嘗試基於它來進行開發。因此快速的原型開發我通常會直接使用 CodeSandboxvue

目錄node

筆者對 CodeSandbox 的第一印象是這玩意是運行在服務器的吧? 好比 create-react-app 要運行起來須要 node 環境,須要經過 npm 安裝一大堆依賴,而後經過 Webpack 進行打包,最後運行一個開發服務器才能在瀏覽器跑起來.github

實際上 CodeSandbox 打包和運行並不依賴於服務器, 它是徹底在瀏覽器進行的. 大概的結構以下:web

  • Editor: 編輯器。主要用於修改文件,CodeSandbox這裏集成了 VsCode, 文件變更後會通知 Sandbox 進行轉譯. 計劃會有文章專門介紹CodeSandbox的編輯器實現
  • Sandbox: 代碼運行器。Sandbox 在一個單獨的 iframe 中運行, 負責代碼的轉譯(Transpiler)和運行(Evalation). 如最上面的圖,左邊是Editor,右邊是Sandbox
  • Packager 包管理器。相似於yarn和npm,負責拉取和緩存 npm 依賴

CodeSandbox 的做者 Ives van Hoorne 也嘗試過將 Webpack 移植到瀏覽器上運行,由於如今幾乎全部的 CLI 都是使用 Webpack 進行構建的,若是能將 Webpack 移植到瀏覽器上, 能夠利用 Webpack 強大的生態系統和轉譯機制(loader/plugin),低成本兼容各類 CLI.

然而 Webpack 過重了😱,壓縮事後的大小就得 3.5MB,這還算勉強能夠接受吧;更大的問題是要在瀏覽器端模擬 Node 運行環境,這個成本過高了,得不償失。

因此 CodeSandbox 決定本身造個打包器,這個打包器更輕量,而且針對 CodeSandbox 平臺進行優化. 好比 CodeSandbox 只關心開發環境的代碼構建, 目標就是能跑起來就好了, 跟 Webpack 相比裁剪掉了如下特性:

  • 生產模式. CodeSandbox 只考慮 development 模式,不須要考慮 production一些特性,好比

    • 代碼壓縮,優化
    • Tree-shaking
    • 性能優化
    • 代碼分割
  • 文件輸出. 不須要打包成chunk
  • 服務器通訊. Sandbox直接原地轉譯和運行, 而Webpack 須要和開發服務器創建一個長鏈接用於接收指令,例如 HMR.
  • 靜態文件處理(如圖片). 這些圖片須要上傳到 CodeSandbox 的服務器
  • 插件機制等等.

因此能夠認爲CodeSandbox是一個簡化版的Webpack, 且針對瀏覽器環境進行了優化,好比使用worker來進行並行轉譯

CodeSandbox 的打包器使用了接近 Webpack Loader 的 API, 這樣能夠很容易地將 Webpack 的一些 loader 移植過來. 舉個例子,下面是 create-react-app 的實現(查看源碼):

import stylesTranspiler from "../../transpilers/style";
import babelTranspiler from "../../transpilers/babe";
// ...
import sassTranspiler from "../../transpilers/sass";
// ...

const preset = new Preset(
  "create-react-app",
  ["web.js", "js", "json", "web.jsx", "jsx", "ts", "tsx"],
  {
    hasDotEnv: true,
    setup: manager => {
      const babelOptions = {
        /*..*/
      };
      preset.registerTranspiler(
        module =>
          /\.(t|j)sx?$/.test(module.path) && !module.path.endsWith(".d.ts"),
        [
          {
            transpiler: babelTranspiler,
            options: babelOptions
          }
        ],
        true
      );
      preset.registerTranspiler(
        module => /\.svg$/.test(module.path),
        [
          { transpiler: svgrTranspiler },
          {
            transpiler: babelTranspiler,
            options: babelOptions
          }
        ],
        true
      );
      // ...
    }
  }
);

能夠看出, CodeSandbox的Preset和Webpack的配置長的差很少. 不過, 目前你只能使用 CodeSandbox 預約義的 Preset, 不支持像 Webpack 同樣進行配置, 我的以爲這個是符合 CodeSandbox 定位的,這是一個快速的原型開發工具,你還折騰 Webpack 幹嗎?

目前支持這些Preset:


基本目錄結構

CodeSandbox 的客戶端是開源的,否則就沒有本文了,它的基本目錄結構以下:

  • packages

    • app CodeSandbox應用

      • app 編輯器實現
      • embed 網頁內嵌運行 codesandbox
      • sandbox 運行沙盒,在這裏執行代碼構建和預覽,至關於一個縮略版的 Webpack. 運行在單獨的 iframe 中

        • eval

          • preset

            • create-react-app
            • parcel
            • vue-cli
            • ...
          • transpiler

            • babel
            • sass
            • vue
            • ...
        • compile.ts 編譯器
    • common 放置通用的組件、工具方法、資源
    • codesandbox-api: 封裝了統一的協議,用於 sandbox 和 editor 之間通訊(基於postmessage)
    • codesandbox-browserfs: 這是一個瀏覽器端的‘文件系統’,模擬了 NodeJS 的文件系統 API,支持在本地或從多個後端服務中存儲或獲取文件.
    • react-sandpack: codesandbox公開的SDK,能夠用於自定義本身的codesandbox

源碼在這


項目構建過程

packager -> transpilation -> evaluation

Sandbox 構建分爲三個階段:

  • Packager 包加載階段,下載和處理全部npm模塊依賴
  • Transpilation 轉譯階段,轉譯全部變更的代碼, 構建模塊依賴圖
  • Evaluation 執行階段,使用 eval 運行模塊代碼進行預覽

下面會按照上述的步驟來描述其中的技術點

Packager

儘管 npm 是個'黑洞',咱們仍是離不開它。 其實大概分析一下前端項目的 node_modules,80%是各類開發依賴組成的.

因爲 CodeSandbox 已經包攬了代碼構建的部分,因此咱們並不須要devDependencies, 也就是說 在CodeSandbox 中咱們只須要安裝全部實際代碼運行須要的依賴,這能夠減小成百上千的依賴下載. 因此暫且不用擔憂瀏覽器會扛不住.

WebpackDllPlugin

CodeSandbox 的依賴打包方式受 WebpackDllPlugin 啓發,DllPlugin 會將全部依賴都打包到一個dll文件中,並建立一個 manifest 文件來描述dll的元數據(以下圖).

Webpack 轉譯時或者 運行時能夠根據 manifest 中的模塊索引(例如__webpack_require__('../node_modules/react/index.js'))來加載 dll 中的模塊。 由於WebpackDllPlugin是在運行或轉譯以前預先對依賴的進行轉譯,因此在項目代碼轉譯階段能夠忽略掉這部分依賴代碼,這樣能夠提升構建的速度(真實場景對npm依賴進行Dll打包提速效果並不大):

manifest文件

在線打包服務

基於這個思想, CodeSandbox 構建了本身的在線打包服務, 和WebpackDllPlugin不同的是,CodeSandbox是在服務端預先構建Manifest文件的, 並且不區分Dll和manifest文件。 具體思路以下:

簡而言之,CodeSandbox 客戶端拿到package.json以後,將dependencies轉換爲一個由依賴和版本號組成的Combination(標識符, 例如 v1/combinations/babel-runtime@7.3.1&csbbust@1.0.0&react@16.8.4&react-dom@16.8.4&react-router@5.0.1&react-router-dom@5.0.1&react-split-pane@0.1.87.json), 再拿這個 Combination 到服務器請求。服務器會根據 Combination 做爲緩存鍵來緩存打包結果,若是沒有命中緩存,則進行打包.

打包實際上仍是使用yarn來下載全部依賴,只不過這裏爲了剔除 npm 模塊中多餘的文件,服務端還遍歷了全部依賴的入口文件(package.json#main), 解析 AST 中的 require 語句,遞歸解析被 require 模塊. 最終造成一個依賴圖, 只保留必要的文件.

最終輸出 Manifest 文件,它的結構大概以下, 他就至關於WebpackDllPlugin的dll.js+manifest.json的結合體:

{
  // 模塊內容
  "contents": {
    "/node_modules/react/index.js": {
      "content": "'use strict';↵↵if ....", // 代碼內容
      "requires": [                        // 依賴的其餘模塊
        "./cjs/react.development.js",
      ],
    },
    "/node_modules/react-dom/index.js": {/*..*/},
    "/node_modules/react/package.json": {/*...*/},
    //...
  },
  // 模塊具體安裝版本號
  "dependencies": [{name: "@babel/runtime", version: "7.3.1"}, {name: "csbbust", version: "1.0.0"},/*…*/],
  // 模塊別名, 好比將react做爲preact-compat的別名
  "dependencyAliases": {},
  // 依賴的依賴, 即間接依賴信息. 這些信息能夠從yarn.lock獲取
  "dependencyDependencies": {
    "object-assign": {
      "entries": ["object-assign"], // 模塊入口
      "parents": ["react", "prop-types", "scheduler", "react-dom"], // 父模塊
      "resolved": "4.1.1",
      "semver": "^4.1.1",
    }
    //...
  }
}
Serverless 思想


值得一提的是 CodeSandbox 的 Packager 後端使用了 Serverless(基於 AWS Lambda),基於 Serverless 的架構讓 Packager 服務更具伸縮性,能夠靈活地應付高併發的場景。使用 Serverless 以後 Packager 的響應時間顯著提升,並且費用也下去了。

Packager 也是開源的, 圍觀

回退方案

AWS Lambda函數是有侷限性的, 好比/tmp最多隻能有 500MB 的空間. 儘管大部分依賴打包場景不會超過這個限額, 爲了加強可靠性(好比上述的方案可能出錯,也可能漏掉一些模塊), Packager還有回退方案.

後來CodeSanbox做者開發了新的Sandbox,支持把包管理的步驟放置到瀏覽器端, 和上面的打包方式結合着使用。原理也比較簡單: 在轉譯一個模塊時,若是發現模塊依賴的npm模塊未找到,則惰性從遠程下載回來. 來看看它是怎麼處理的:

在回退方案中CodeSandbox 並不會將 package.json 中全部的包都下載下來,而是在模塊查找失敗時,惰性的去加載。好比在轉譯入口文件時,發現 react 這個模塊沒有在本地緩存模塊隊列中,這時候就會到遠程將它下載回來,而後接着轉譯。

也就是說,由於在轉譯階段會靜態分析模塊的依賴,只須要將真正依賴的文件下載回來,而不須要將整個npm包下載回來,節省了網絡傳輸的成本.

CodeSandbox 經過 unpkg.comcdn.jsdelivr.net 來獲取模塊的信息以及下載文件, 例如

  • 獲取 package.json: https://unpkg.com/react@latest/package.json
  • 包目錄結構獲取: https://unpkg.com/antd@3.17.0/?meta 這個會遞歸返回該包的全部目錄信息
  • 具體文件下載: https://unpkg.com/react@16.8.6/cjs/react.production.min.js 或者 https://cdn.jsdelivr.net/npm/@babel/runtime@7.3.1/helpers/interopRequireDefault.js

Transpilation

講完 Packager 如今來看一下 Transpilation, 這個階段從應用的入口文件開始, 對源代碼進行轉譯, 解析AST,找出下級依賴模塊,而後遞歸轉譯,最終造成一個'依賴圖':

CodeSandbox 的整個轉譯器是在一個單獨的 iframe 中運行的:

Editor 負責變動源代碼,源代碼變動會經過 postmessage 傳遞給 Compiler,這裏面會攜帶 Module+template

  • Module 中包含全部源代碼內容和模塊路徑,其中還包含 package.json, Compiler 會根據 package.json 來讀取 npm 依賴;
  • template 表示 Compiler 的 Preset,例如create-react-appvue-cli, 定義了一些 loader 規則,用來轉譯不一樣類型的文件, 另外preset也決定了應用的模板和入口文件。 經過上文咱們知道, 這些 template 目前的預約義的.

基本對象

在詳細介紹 Transpilation 以前先大概看一些基本對象,瞭解這些對象之間的關係:

  • Manager 這是 Sandbox 的核心對象,負責管理配置信息(Preset)、項目依賴(Manifest)、以及維護項目全部模塊(TranspilerModule)
  • Manifest 經過上文的 Packager 咱們知道,Manifest 維護全部依賴的 npm 模塊信息
  • TranspiledModule 表示模塊自己。這裏面維護轉譯的結果、代碼執行的結果、依賴的模塊信息,負責驅動具體模塊的轉譯(調用 Transpiler)和執行
  • Preset 一個項目構建模板,例如 vue-clicreate-react-app. 配置了項目文件的轉譯規則, 以及應用的目錄結構(入口文件)
  • Transpiler 等價於 Webpack 的 loader,負責對指定類型的文件進行轉譯。例如 babel、typescript、pug、sass 等等
  • WorkerTranspiler 這是 Transpiler 的子類,調度一個 Worker池來執行轉譯任務,從而提升轉譯的性能

Manager

Manager是一個管理者的角色,從大局上把控整個轉譯和執行的流程. 如今來看看總體的轉譯流程:

大局上基本上能夠劃分爲如下四個階段:

  • 配置階段:配置階段會建立 Preset 對象,肯定入口文件等等. CodeSandbox 目前只支持限定的幾種應用模板,例如 vue-cli、create-react-app。不一樣模板之間目錄結構的約定是不同的,例如入口文件和 html 模板文件。另外文件處理的規則也不同,好比 vue-cli 須要處理.vue文件。
  • 依賴下載階段: 即 Packager 階段,下載項目的全部依賴,生成 Manifest 對象
  • 變更計算階段:根據 Editor 傳遞過來的源代碼,計算新增、更新、移除的模塊。
  • 轉譯階段:真正開始轉譯了,首先從新轉譯上個階段計算出來的須要更新的模塊。接着從入口文件做爲出發點,轉譯和構建新的依賴圖。這裏不會重複轉譯沒有變化的模塊以及其子模塊

TranspiledModule

TranspiledModule用於管理某個具體的模塊,這裏面會維護轉譯和運行的結果、模塊的依賴信息,並驅動模塊的轉譯和執行:

TranspiledModule 會從Preset中獲取匹配當前模塊的Transpiler列表的,遍歷Transpiler對源代碼進行轉譯,轉譯的過程當中會解析AST,分析模塊導入語句, 收集新的依賴; 當模塊轉譯完成後,會遞歸轉譯依賴列表。 來看看大概的代碼:

async transpile(manager: Manager) {
    // 已轉譯
    if (this.source)  return this
    // 避免重複轉譯, 一個模塊只轉譯一次
    if (manager.transpileJobs[this.getId()]) return this;
    manager.transpileJobs[this.getId()] = true;

    // ...重置狀態 

    // 🔴從Preset獲取Transpiler列表
    const transpilers = manager.preset.getLoaders(this.module, this.query);

    // 🔴 鏈式調用Transpiler
    for (let i = 0; i < transpilers.length; i += 1) {
      const transpilerConfig = transpilers[i];
      // 🔴構建LoaderContext,見下文
      const loaderContext = this.getLoaderContext(
        manager,
        transpilerConfig.options || {}
      );

      // 🔴調用Transpiler轉譯源代碼
      const {
        transpiledCode,
        sourceMap,
      } = await transpilerConfig.transpiler.transpile(code, loaderContext); // eslint-disable-line no-await-in-loop

      if (this.errors.length) {
        throw this.errors[0];
      }
    }

    this.logWarnings();

    // ...

    await Promise.all(
      this.asyncDependencies.map(async p => {
        try {
          const tModule = await p;
          this.dependencies.add(tModule);
          tModule.initiators.add(this);
        } catch (e) {
          /* let this handle at evaluation */
        }
      })
    );
    this.asyncDependencies = [];

    // 🔴遞歸轉譯依賴的模塊
    await Promise.all(
      flattenDeep([
        ...Array.from(this.transpilationInitiators).map(t =>
          t.transpile(manager)
        ),
        ...Array.from(this.dependencies).map(t => t.transpile(manager)),
      ])
    );

    return this;
  }

Transpiler

Transpiler等價於webpack的loader,它配置方式以及基本API也和webpack(查看webpack的loader API)大概保持一致,好比鏈式轉譯和loader-context. 來看一下Transpiler的基本定義:

export default abstract class Transpiler {
  initialize() {}

  dispose() {}

  cleanModule(loaderContext: LoaderContext) {}

  // 🔴 代碼轉換
  transpile(
    code: string,
    loaderContext: LoaderContext
  ): Promise<TranspilerResult> {
    return this.doTranspilation(code, loaderContext);
  }

  // 🔴 抽象方法,由具體子類實現
  abstract doTranspilation(
    code: string,
    loaderContext: LoaderContext
  ): Promise<TranspilerResult>;

  // ...
}

Transpiler的接口很簡單,transpile接受兩個參數:

  • code即源代碼.
  • loaderContext 由TranspiledModule提供, 能夠用來訪問一下轉譯上下文信息,好比Transpiler的配置、 模塊查找、註冊依賴等等。大概外形以下:
export type LoaderContext = {
  // 🔴 信息報告
  emitWarning: (warning: WarningStructure) => void;
  emitError: (error: Error) => void;
  emitModule: (title: string, code: string, currentPath?: string, overwrite?: boolean, isChild?: boolean) => TranspiledModule;
  emitFile: (name: string, content: string, sourceMap: SourceMap) => void;
  // 🔴 配置信息
  options: {
    context: string;
    config?: object;
    [key: string]: any;
  };
  sourceMap: boolean;
  target: string;
  path: string;
  addTranspilationDependency: (depPath: string, options?: { isAbsolute?: boolean; isEntry?: boolean; }) => void;
  resolveTranspiledModule: ( depPath: string, options?: { isAbsolute?: boolean; ignoredExtensions?: Array<string>; }) => TranspiledModule;
  resolveTranspiledModuleAsync: ( depPath: string, options?: { isAbsolute?: boolean; ignoredExtensions?: Array<string>; }) => Promise<TranspiledModule>;
    // 🔴 依賴收集
  addDependency: ( depPath: string, options?: { isAbsolute?: boolean; isEntry?: boolean; }) => void;
  addDependenciesInDirectory: ( depPath: string, options?: { isAbsolute?: boolean; isEntry?: boolean; }) => void;
  _module: TranspiledModule;
};

先從簡單的開始,來看看JSON模塊的Transpiler實現, 每一個Transpiler子類須要實現doTranspilation,接收源代碼,並異步返回處理結果:

class JSONTranspiler extends Transpiler {
  doTranspilation(code: string) {
    const result = `
      module.exports = JSON.parse(${JSON.stringify(code || '')})
    `;

    return Promise.resolve({
      transpiledCode: result,
    });
  }
}

BabelTranspiler

並非全部模塊都像JSON這麼簡單,好比Typescript和Babel。 爲了提升轉譯的效率,Codesandbox會利用Worker來進行多進程轉譯,多Worker的調度工做由WorkerTranspiler完成,這是Transpiler的子類,維護了一個Worker池。Babel、Typescript、Sass這類複雜的轉譯任務都是基於WorkerTranspiler實現的:

其中比較典型的實現是BabelTranspiler, 在Sandbox啓動時就會預先fork三個worker,來提升轉譯啓動的速度, BabelTranspiler會優先使用這三個worker來初始化Worker池:

// 使用worker-loader fork三個loader,用於處理babel編譯
import BabelWorker from 'worker-loader?publicPath=/&name=babel-transpiler.[hash:8].worker.js!./eval/transpilers/babel/worker/index.js';

window.babelworkers = [];
for (let i = 0; i < 3; i++) {
  window.babelworkers.push(new BabelWorker());
}

這裏面使用到了webpack的worker-loader, 將指定模塊封裝爲 Worker 對象。讓 Worker 更容易使用:

// App.js
import Worker from "./file.worker.js";

const worker = new Worker();

worker.postMessage({ a: 1 });
worker.onmessage = function(event) {};

worker.addEventListener("message", function(event) {});

BabelTranpiler具體的流程以下:

WorkerTranspiler會維護空閒的Worker隊列和一個任務隊列, 它的工做就是驅動Worker來消費任務隊列。具體的轉譯工做在Worker中進行:


Evaluation

雖然稱爲打包器(bundler), 可是 CodeSandbox 並不會進行打包,也就是說他不會像 Webpack 同樣,將全部的模塊都打包合併成 chunks 文件.

Transpilation入口文件開始轉譯, 再分析文件的模塊導入規則,遞歸轉譯依賴的模塊. 到Evaluation階段,CodeSandbox 已經構建出了一個完整的依賴圖. 如今要把應用跑起來了🏃

Evaluation 的原理也比較簡單,和 Transpilation 同樣,也是從入口文件開始: 使用eval執行入口文件,若是執行過程當中調用了require,則遞歸 eval 被依賴的模塊

若是你瞭解過 Node 的模塊導入原理,你能夠很容易理解這個過程:

  • ① 首先要初始化 html,找到index.html文件,將 document.body.innerHTML 設置爲 html 模板的 body 內容.
  • ② 注入外部資源。用戶能夠自定義一些外部靜態文件,例如 css 和 js,這些須要 append 到 head 中
  • ③ evaluate 入口模塊
  • ④ 全部模塊都會被轉譯成 CommonJS 模塊規範。因此須要模擬這個模塊環境。大概看一下代碼:

    // 實現require方法
    function require(path: string) {
      // ... 攔截一些特殊模塊
    
      // 在Manager對象中查找模塊
      const requiredTranspiledModule = manager.resolveTranspiledModule(
        path,
        localModule.path
      );
    
      // 模塊緩存, 若是存在緩存則說明不須要從新執行
      const cache = requiredTranspiledModule.compilation;
    
      return cache
        ? cache.exports
        : // 🔴遞歸evaluate
          manager.evaluateTranspiledModule(
            requiredTranspiledModule,
            transpiledModule
          );
    }
    
    // 實現require.resolve
    require.resolve = function resolve(path: string) {
      return manager.resolveModule(path, localModule.path).path;
    };
    
    // 模擬一些全局變量
    const globals = {};
    globals.__dirname = pathUtils.dirname(this.module.path);
    globals.__filename = this.module.path;
    
    // 🔴放置執行結果,即CommonJS的module對象
    this.compilation = {
      id: this.getId(),
      exports: {}
    };
    
    // 🔴eval
    const exports = evaluate(
      this.source.compiledCode,
      require,
      this.compilation,
      manager.envVariables,
      globals
    );
  • ⑤ 使用 eval 來執行模塊。一樣看看代碼:

    export default function(code, require, module, env = {}, globals = {}) {
      const exports = module.exports;
      const global = g;
      const process = buildProcess(env);
      g.global = global;
      const allGlobals = {
        require,
        module,
        exports,
        process,
        setImmediate: requestFrame,
        global,
        ...globals
      };
    
      const allGlobalKeys = Object.keys(allGlobals);
      const globalsCode = allGlobalKeys.length ? allGlobalKeys.join(", ") : "";
      const globalsValues = allGlobalKeys.map(k => allGlobals[k]);
      // 🔴將代碼封裝到一個函數下面,全局變量以函數形式傳入
      const newCode = `(function evaluate(` + globalsCode + `) {` + code + `\n})`;
      (0, eval)(newCode).apply(this, globalsValues);
    
      return module.exports;
    }

Ok!到這裏 Evaluation 就解釋完了,實際的代碼比這裏要複雜得多,好比 HMR(hot module replacement)支持, 有興趣的讀者,能夠本身去看 CodeSandbox 的源碼.


技術地圖

一不當心又寫了一篇長文,要把這麼複雜代碼講清楚真是一個挑戰, 我還作的不夠好,按照以往的經驗,這又是一篇無人問津的文章, 別說是大家, 我本身都不怎麼有耐心看這類文章, 後面仍是儘可能避免吧!

  • worker-loader: 將指定模塊封裝爲Worker
  • babel: JavaScript代碼轉譯,支持ES, Flow, Typescript
  • browserfs: 在瀏覽器中模擬Node環境
  • localForage: 客戶端存儲庫,優先使用(IndexedDB or WebSQL)這些異步存儲方案,提供類LocalStorage的接口
  • lru-cache: least-recently-used緩存

擴展

相關文章
相關標籤/搜索