雲音樂低程式碼:基於 CodeSandbox 的沙箱效能優化

雲音樂技術團隊發表於2022-06-02

圖片來源:https://unsplash.com/photos/m...

本文作者:伍六一

文章首發於我的部落格 https://github.com/mcuking/bl...

背景

距離釋出如何私有化部署 CodeSandbox 沙箱的文章《搭建一個屬於自己的線上 IDE》 已經過了一年多的時間,最開始是為了在區塊複用平臺上能夠實時構建前端程式碼並預覽效果。不過在去年雲音樂內部啟動的基於原始碼的低程式碼平臺專案中,同樣有線上實時構建前端應用的需求,最初是採用從零開發沙箱的方式,不過自研沙箱存在以下幾點問題:

  • 靈活性較差

    被構建應用的 npm 依賴需要提前被打包到沙箱本身的程式碼中,無法做到在構建過程中動態從服務獲取應用依賴內容;

  • 相容性較差

    被構建應用的技術選型比較受限,比如不支援使用 less 等;

  • 未實現與平臺的隔離

    低程式碼平臺和沙箱沒有用類似 iframe 作為隔離,會存在沙箱構建頁面的全域性變數或者樣式上被外部的低程式碼平臺汙染的問題。

當然如果繼續在這個自研沙箱上繼續開發,上面提到的問題還是可以逐步被解決的,只是需要投入更多的人力。

而 CodeSandbox 作為目最主流且成熟度較高的線上構建沙箱,不存在上面列出的問題。而且實現程式碼全部開源,也不存在安全問題。於是便決定採用私有化部署的 CodeSandbox 來替換低程式碼平臺的自研沙箱,期間工作主要分為下面兩方面:

  • 針對低程式碼平臺的定製化需求

    例如為了實現元件的拖拽到沙箱構建的頁面中,需要對沙箱構建好的頁面進行跨 iframe 的原生事件監聽,以便進一步計算拖拽的準確位置。

  • 提升沙箱構建速度

    由於低程式碼平臺需要線上搭建應用,存在兩個特點:首先是需要構建完整的前端應用程式碼而非某些程式碼片段,其次是需要頻繁地修改應用程式碼並實時檢視效果,因此對沙箱的構建效能有較高要求。

其中在提升沙箱構建速度的過程中一波三折:從最初花費接近 2 分鐘構建一個包含 antd 依賴的簡單中後臺應用,一步步優化到 1 秒左右實現秒開,甚至已經比 CodeSandbox 官網的沙箱構建速度還要更快。

補充:上面提到兩個平臺的文章介紹如下,感興趣的可以自行檢視:
低程式碼平臺: 網易雲音樂低程式碼體系建設思考與實踐
區塊複用平臺: 跨專案區塊複用方案實踐

下面就來介紹下 CodeSandbox 沙箱效能優化過程,在正式開始之前,為了方便讀者更容易理解,先簡要介紹下沙箱的構建過程。

沙箱構建過程

CodeSandbox 本質上是在瀏覽器中執行的簡化版 Webpack,下面是整個沙箱的架構圖,主要包含兩部分:線上 Bundler 部分和 Packager 服務。

沙箱原理圖

其中使用方只需引入封裝好的 Sandbox 元件即可,元件內部會建立 iframe 標籤來載入部署好的沙箱頁面,頁面中的 js 程式碼就是沙箱的核心部分 -- 線上 Bundler。沙箱構建流程中首先是 Sandbox 元件將需要包含被構建應用原始碼的 compile 指令通過 postMessage 傳遞給 iframe 內的線上 Bundler,線上 Bundler 在接收到 compile 指令後便開始構建應用,最開始會預先從 npm 打包服務獲取應用的 npm 依賴內容。

下面分別對沙箱構建的三個階段 -- 依賴預載入階段、編譯階段、執行階段,進行詳細闡述。

依賴預載入階段(Npm Preload)

為什麼需要依賴預載入階段

由於在瀏覽器環境中很難安裝前端應用的 node_modules 資源,所以編譯階段需要從服務端獲取依賴的 npm 包的模組資源,通過 npm 包的入口檔案欄位(package#main 等)和 meta 資訊計算 npm 包中指定模組在 CDN 上的具體路徑,然後請求獲取模組內容。舉個例子:

如果前端應用的某檢視模組 demo.js 引用了 react 依賴,如下圖:

import React from 'react';
const Demo = () => (<div>Demo</div>);
export default Demo;

在編譯完 demo.js 模組後會繼續編譯該模組的依賴 react,首先會從 CDN 上獲取 reactpackage.json 模組內容和 react 的 meta 資訊:

https://unpkg.com/react@17.0....

https://unpkg.com/react@17.0....

然後計算得到 react 包入口檔案的具體路徑(整個過程也就是 file resolve 的過程),從 CDN 上請求該模組內容:

https://unpkg.com/react@17.0....

接著繼續編譯該模組及其依賴,如此遞迴編譯直到將應用中所有被引用到的依賴模組編譯完成。

可見瀏覽器端實現的沙箱在整個編譯應用過程中需要不斷從 CDN 上獲取 npm 包的模組內容,產生非常多的 HTTP 請求,也就是傳說中的 HTTP 請求瀑布流。又因為瀏覽器對同一域名下的併發 HTTP 請求數量有限制(例如針對 HTTP/1.x 版本的 HTTP 請求,其中 Chrome 瀏覽器限制數量為 6 個),最終導致整個編譯過程非常耗時。

依賴預載入階段的執行機制

為了解決這個問題,於是便有了依賴預載入階段 -- 即在開始編譯應用之前,沙箱先從 npm 打包服務中請求應用依賴的 npm 包內容,而打包服務會將 npm 包的被匯出的模組打包成一個 JSON 模組返回,該模組也被稱為 Manifest。 例如下面就是 react 包的 Manifest 模組的連結和截圖:

https://prod-packager-packages.codesandbox.io/v2/packages/react/17.0.2.json

Manifest

這樣獲取每個 npm 包的內容只需要傳送一個 HTTP 請求就可以了。

在依賴預載入階段,沙箱會請求應用中所有依賴包的 Manifest,然後合併成一個 Manifest。目的是為了在接下來的編譯階段,沙箱只需要從 Manifest 中查詢 npm 包的某個具體模組即可。當然如果在 Manifest 中找不到,沙箱還是會從 CDN 上請求該模組以確保編譯過程順利進行。

Packager 服務的原理

上面提到的 npm 打包服務(也稱 Packager 服務)的基本原理如下:

先通過 yarn 將指定 npm 包安裝到磁碟上,然後解析 npm 包入口檔案的 AST 中的 require 語句,接著遞迴解析被 require 模組,最終將所有被引用的模組打包到 Manifest 檔案中輸出(目的是為了剔除 npm 包中多餘模組,例如文件等)

簡而言之依賴預載入階段就是為了避免在編譯階段產生大量請求導致編譯時間過長。和 Vite 的依賴預構建的部分目標是相同的 -- 依賴預構建

注意:這裡之所以如此詳細地介紹依賴預載入階段存在的必要性和執行機制,主要是為了後面闡述沙箱效能優化部分做鋪墊。讀者讀到效能優化部分有些不理解的話,可以再返回來溫習下。

編譯階段(Transpilation)

簡單來說編譯階段就是從應用的入口檔案開始, 對原始碼進行編譯, 解析 AST,找出下級依賴模組,然後遞迴編譯,最終形成一個依賴關係圖。其中模組之間互相引用遵循的是 CommonJS 規範。

補充:關於模擬 CommonJS 的內容可以參考下面關於 Webpack 的文章,由於篇幅問題這裡就不展開了:webpack系列 —— 模組化原理-CommonJS

編譯階段

執行階段(Evaluation)

和編譯階段一樣,也是從入口檔案開始,使用 eval 執行入口檔案,如果執行過程中呼叫了 require,則遞迴 eval 被依賴的模組。

到此沙箱的構建過程就闡述完了,更多詳細內容可參考以下文章:

提升沙箱構建速度

接下來就進入到本文的主題 -- 如何提升沙箱的構建速度。整個過程會以文章開頭提到的包含 antd 依賴的簡單中後臺應用的構建為例,闡述如何逐步將構建速度從 2 分鐘優化到 1s 左右。主要有以下四個方面:

  • 快取 Packager 服務打包結果
  • 減少編譯階段單個 npm 包模組請求數量
  • 開啟 Service-Worker + CacheStorage 快取
  • 實現類 Webpack Externals 功能

快取 Packager 服務打包結果

通過對沙箱構建應用過程的分析,首先發現的問題是在依賴預載入階段從 Packager 服務請求 antd 包的 Manifest 耗時 1 分鐘左右,有時甚至會有請求超時的情況。根據前面對 Packager 服務原理的闡述,可以判斷出導致耗時的原因主要是 antd 包(包括其依賴)體積較大,無論是下載 antd 包還是從 antd 包入口檔案遞迴打包所有引用的模組都會非常耗時。

對此可以將 Packager 服務的打包結果快取起來,沙箱再次請求時則直接從快取中讀取並返回,無需再走下載+打包的過程。其中快取的具體方式讀者可根據自身情況來決定。至於首次打包過慢問題,可以針對常用的 npm 包提前請求 Packager 服務來觸發打包,以保證在構建應用過程中可以快速獲取到 npm 包的 Manifest。

在快取了 Packager 服務打包結果之後,應用的構建時間就從近 2 分鐘優化到了 70s 左右。

減少編譯階段單個 npm 包模組請求數量

繼續分析沙箱在編譯階段的網路請求時,會發現會有大量的 antd 包和 @babel/runtime 包相關的模組請求,如下圖所示:

請求瀑布流

根據上面沙箱原理部分的講解可以知道,依賴預載入階段就是為了避免在編譯階段產生大量 npm 單模組請求而設計的,那為什麼還會有這麼多的請求呢?原因總結來說有兩個:

  • Packager 服務和沙箱構建時確定 npm 包的入口檔案不同
  • npm 包本身沒有指定入口檔案或入口檔案不能關聯所有編譯時會用到的模組

Packager 服務和沙箱構建時確定 npm 包的入口檔案不同

antd 包的為例,該包本身的依賴大部分為內部元件 rc-xxx,其 package.json 同時包含兩個欄位 mainmodule,以 rc-slider 為例,下面是該包的 package.json 有關入口檔案定義部分(注意其中入口檔名沒有字尾):

{
  "main": "./lib/index",
  "module": "./es/index",
  "name": "rc-slider",
  "version": "10.0.0-alpha.4"
}

我們已經知道了 Packager 服務是從 npm 包的入口檔案開始,遞迴將所有被引用的模組打包成 Manifest 返回的。其中 module 欄位優先順序高於 main 欄位,所以 Packager 服務會以 ./es/index.js 作為入口檔案開始打包。但在完成 Manifest 打包後和正式返回給沙箱前,還會校驗 package.jsonmodule 欄位定義的入口檔案是否在 npm 包中真實存在,如果不存在則會將 module 欄位從 package.json 中刪除。

不幸的是檢驗入口檔案是否真實存在的邏輯中沒有考慮到檔名沒有字尾的情況,而恰好該 npm 包的 module 欄位沒有寫檔案字尾,所以在返回的 Manifest 中 rc-sliderpackage.jsonmodule 欄位被刪除了。

接下來是瀏覽器側的沙箱開始編譯應用,編譯到 rc-slider 依賴時,由於 rc-sliderpackage.jsonmodule 欄位被刪除,所以是按照 main 欄位指定的 ./lib/index.js 模組作為入口檔案開始編譯,但是 Manifest 中只有 es 目錄下的模組,所以只能在編譯過程中從 CDN 動態請求 lib 下的模組,由此產生了大量 HTTP 請求阻塞編譯。

請求瀑布流

有關 Packager 服務沒有相容入口檔名無字尾的問題,筆者已經向 CodeSandbox 官方提交 PR 修復了,點選檢視

接下來再看另外一個例子 -- ramda 包的 package.json 中有關入口檔案部分:

{
  "exports": {
    ".": {
      "require": "./src/index.js",
      "import": "./es/index.js",
      "default": "./src/index.js"
    },
    "./es/": "./es/",
    "./src/": "./src/",
    "./dist/": "./dist/"
  },
  "main": "./src/index.js",
  "module": "./es/index.js",
  "name": "ramda",
  "version": "0.28.0"
}

Packager 服務是 module 欄位指定的 ./es/index.js 作為入口開始打包的,但編譯階段中沙箱卻最終選擇 export.default 指定的 ./src/index.js 作為入口開始編譯,進而也產生了大量的單個模組的請求。

問題的本質就是【Packager 服務打包 npm 包時】和【沙箱構建應用時】確定 npm 包入口檔案的策略並不完全一致,想要根治該問題就要對其兩側的確定入口檔案的策略。

沙箱側確定入口檔案的邏輯在 packages/sandpack-core/src/resolver/utils/pkg-json.ts 中。

Packager 服務側相關邏輯在 functions/packager/packages/find-package-infos.ts / functions/packager/packages/resolve-required-files.ts / functions/packager/utils/resolver.ts 中。

讀者可自行決定選擇 以 Packager 服務側還是沙箱側的 npm 入口檔案的確定策略 作為統一標準,總之一定要保證兩側的策略是一致的。

npm 包本身沒有入口檔案或入口檔案不能關聯所有編譯時會用到的模組

首先分析下 @babel/runtime 包,通過該包的 package.json 可以發現其並沒有定義入口檔案,一般使用該包都是直接引用包中的具體模組,例如 var _classCallCheck = require("@babel/runtime/helpers/classCallCheck");,所以按照 Packager 服務的打包原理是無法將該包中的編譯時會用到的模組打包到 Manifest 中的,最終導致編譯階段產生大量單個模組的請求。

對此筆者也只是採用特殊情況特殊處理的方式:在打包沒有定義入口檔案或入口檔案不能關聯所有編譯時會用到的模組的 npm 包時,在 npm 打包過程中手動將指定目錄下或指定模組打包到 Manifest 中。例如對於 @babel/runtime 包來說,就是在打包過程中將其根目錄下的所有檔案都手動的打包到 Manifest 中。目前還沒有更好的解法,如果讀者有更好的解法歡迎留言。

當然如果是內部的 npm 包,也可以在 package.json 中增加類似 sandpackEntries 的自定義欄位,即指定多個入口檔案,便於 Packager 服務將編譯階段用到的模組儘可能都打包到 Manifest 中。例如針對低程式碼平臺的元件可能會分為正常模式和設計模式,其中設計模式是為了在低程式碼平臺更方便的拖動元件和配置元件引數等,會在 index.js 之外再定義 designer.js 作為設計模式下元件入口檔案,這種情況就可以指定多個入口檔案(多個入口概念僅針對 Packager 服務)。相關改造是在 functions/packager/packages/resolve-required-files.ts 中的 resolveRequiredFiles 函式,如下圖所示:

define multi entries

通過減少編譯階段單個 npm 包模組請求數量,應用的構建時間從 70s 左右降到了 35s 左右。

開啟 Service-Worker + CacheStorage 快取

筆者在分析大量 npm 包單個模組請求問題時,也在 CodeSandbox 官方站點的沙箱中構建完全相同的應用,並沒有遇到這個問題,後來才發現官網只是將已經請求過的資源快取起來。也就是說在第一次使用 CodeSandbox 或在瀏覽器隱身模式下構建應用,還是會遇到大量 HTTP 請求問題。

那麼官網是如何快取的呢?首先通過 Service-Worker 攔截應用構建過程中的請求,如果發現是需要被快取的資源,則先從 CacheStorage 中查詢是否已快取過,沒有則繼續請求遠端服務,並將請求返回的內容快取一份到 CacheStorage 中;如果查詢到對應快取,則直接從 CacheStorage 讀取並返回,從而減少請求時間。

如下圖所示,CodeSandbox 快取內容主要包括:

  1. 沙箱頁面的靜態資源模組
  2. 從 Packager 服務請求的 npm 包的 Manifest
  3. 從 CDN 請求的 npm 包單個模組內容

cacheStorage

不過 CodeSandbox 在對外提供的沙箱版本中將快取功能關閉了,我們需要開啟該功能,相關程式碼在 packages/app/src/sandbox/index.ts 中,如下圖所示:

cacheStorage

另外該快取功能是通過 SWPrecacheWebpackPlugin 外掛實現的 -- 在打包 CodeSandbox 沙箱程式碼時,啟用 SWPrecacheWebpackPlugin 外掛並向其傳入具體的快取策略配置,然後會在構建物中自動生成 service-worker.js 指令碼,最後在沙箱執行時註冊執行該指令碼即可開啟快取功能。這裡我們需要做的是將其中快取策略的地址修改成我們私有化部署的沙箱對應地址即可,具體模組在 packages/app/config/webpack.prod.js 中:

cacheStorage

補充:SWPrecacheWebpackPlugin 外掛主要是作用避免手動編寫 Service Worker 指令碼,開發者只需要提供具體的快取策略即可,更多細節可點選下面連結:https://www.npmjs.com/package...

開啟瀏覽器側的快取之後,應用的構建時間基本可以穩定到 12s 左右。

實現類 Webpack Externals 功能

以上三個方面的優化基本都是在網路方面 -- 或增加快取或減少請求數量。那麼編譯和執行程式碼本身是否可以進一步優化呢?接下來就一起來分析下。

筆者在使用瀏覽器除錯工具除錯沙箱的編譯過程時發現一個問題:即使應用中僅僅使用了 antd 包的一個元件,例如:

import React from 'react';
import { Button } from 'antd';
const Btn = () => (<Button>Click Me</Button>);
export default Btn;

但仍會編譯 antd 包內所有元件關聯的模組,最終導致編譯時間過長。經過排查發現主要原因是 antd 的入口檔案中引用了全部元件。下面是 es 模式下的入口檔案 antd/es/index.js 的部分程式碼:

export { default as Affix } from './affix';
export { default as Anchor } from './anchor';
export { default as AutoComplete } from './auto-complete';
...

根據上面編譯階段和執行階段的講解我們可以知道,沙箱會從 antd 入口檔案開始對所有被引用的模組進行遞迴編譯和執行。

因為沙箱也使用 babel 編譯 js 檔案,所以筆者最開始想到的是在編譯 js 檔案時整合 babel-plugin-import 外掛,該外掛的作用就是實現元件的按需引入,點選檢視外掛更多細節。下面的程式碼編譯效果會更直觀一些:

import { Button } from 'antd';
      ↓ ↓ ↓ ↓ ↓ ↓
var _button = require('antd/lib/button');

整合該外掛後發現沙箱構建速度的確有所提升,但隨著應用使用的元件增多,構建速度會越慢。那麼是否有更好的方式來減少甚至不需編要譯模組呢?有,實現類 Webpack Externals 功能,下面是整個功能的原理:

1. 在編譯階段跳過 antd 包的編譯,以減少編譯時間。

2. 在執行階段開始之前先通過 script 標籤全域性載入和執行 antd 的 umd 形式的構建物,如此以來 antd 包中匯出的內容就被掛載到 window 物件上了。接下來在執行編譯後的程式碼時,如果發現需要引用的antd 包中的元件,則從 window 物件獲取返回即可。由於不再需要執行 antd 包所有元件關聯的模組,所以執行階段的時間也會減少。

注:這裡涉及到 Webpack Externals 和 umd 模組規範的概念,由於篇幅問題就不在這裡細說了,有興趣可通過下面連結瞭解:

思路有了,接下來就開始對 CodeSandbox 原始碼進行改造:

首先是編譯階段的改造,當編譯完某個模組時,會新增該模組的依賴然後繼續編譯。在新增依賴時,判斷如果依賴是被 external 的 npm 包則直接退出,以阻斷進一步對該依賴的編譯。

具體程式碼在 packages/sandpack-core/src/transpiled-module/transpiled-module.ts,改動如下圖所示:

external 編譯階段

然後是執行階段的改造,因為 CodeSandbox 最終是將所有模組編譯成 CommonJS 模組然後模擬 CommonJS 的環境來執行(上面的沙箱構建過程部分有提到)。所以只需要在模擬的 require 函式中判斷如果是被 external 的 npm 包引用模組,直接從 window 物件獲取返回即可。

具體程式碼在 packages/sandpack-core/src/transpiled-module/transpiled-module.ts,改動如下圖所示:

external 執行階段

另外在沙箱開始執行編譯後的程式碼之前,需要動態建立 script 標籤來載入和執行 antd 包 umd 形式的構建物,幸運的是 CodeSandbox 已經提供了動態載入外部 js/css 資源的能力,不需要額外開發。只需要將需要 js/css 資源的連結通過 externalResources 引數傳給沙箱即可。

最後就需要在 sandbox.config.json 檔案中配置相關引數即可,如下圖所示:

{
  "externals": {
    "react": "React",
    "react-dom": "ReactDOM",
    "antd": "antd"
  },
  "externalResources": [
    "https://unpkg.com/react@17.0.2/umd/react.development.js",
    "https://unpkg.com/react-dom@17.0.2/umd/react-dom.development.js",
    "https://unpkg.com/antd@4.18.3/dist/antd.min.js",
    "https://unpkg.fn.netease.com/antd@4.18.3/dist/antd.css"
  ]
}
補充:sandbox.config.json 檔案中的內容會在沙箱構建獲取到,該檔案是放在被構建應用的根目錄下。點選檢視 configuration 詳情

最終經過上面四個方面的優化,沙箱只需 1s 左右即可完成對整個應用的構建,效果如下圖所示:

沙箱構建效果圖

未來規劃

那麼沙箱的構建效能優化方案是否就已經接近完美了呢?

答案當然是否定的,讀者可以試想下,隨著構建應用的規模變大,需要編譯和執行的模組也會增多,CodeSandbox 沙箱這種通過應用的入口檔案遞迴編譯所有引用模組,然後再從應用入口檔案遞迴執行所有引用模組的模式,必然還會導致整個構建時間不可避免地增加。

那麼是否有更好的方式呢?最近很流行的 Vite 提供了一種思路:在應用程式碼執行過程中,通過 ES Module 方式引用了其他模組,瀏覽器會發起一個請求獲取該模組,伺服器攔截請求匹配到對應模組後對其進行編譯並返回。這種不需要對應用模組進行提前全量編譯,按需動態編譯的方式會極大縮應用構建時間,應用越複雜構建速度的優勢越明顯。

筆者正在嘗試改造 Vite 使其能夠執行在瀏覽器中,過程中的收穫會總結到沙箱系列下一篇文章中 -- 《搭建一個瀏覽器版 Vite 沙箱》,沙箱原型的實現程式碼也會同步到 https://github.com/mcuking/vi... 中,敬請期待!

結束語

在使用者端的瀏覽器中實現可以執行程式碼(涵蓋前端 / Node 服務等應用的程式碼)的沙箱環境,相對在服務端容器中執行程式碼的方式,具有不佔用服務資源、運營成本低、啟動速度快等優勢,在很多應用場景下都可以創造可觀的價值。另外瀏覽器版沙箱也是為數不多的富前端應用,整個沙箱應用的主體功能都是在瀏覽器中實現,對前端開發工作提出了更大的挑戰。

下圖是筆者這兩年在沙箱領域的一些嘗試,歡迎感興趣的同學一起交流:https://github.com/mcuking/blog

沙箱規劃圖

參考資料

本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!

相關文章