在瀏覽器中,把 Vite 跑起來了!

語言: CN / TW / HK

首發於公眾號前端從進階到入院,歡迎關注。

大家好,我是 ssh,前幾天在推上衝浪的時候,看到 Francois Valdy 宣佈他製作了 browser-vite,成功把 Vite 成功在瀏覽器中執行起來了。這引起了我的興趣,如何把重度依賴 node 的一個 Vite 跑在瀏覽器上?接下來,就和我一起探索揭祕吧。

簡而言之的原理

  • Service Worker:用來取代 Vite 的 HTTP 伺服器。

  • Web Worker:執行 browser-vite 來處理主執行緒。

  • 檔案系統被一個 in-memory 的模擬檔案系統替代。

  • 轉換特殊副檔名 (.ts, .tsx, .scss…) 的匯入。

遇到的挑戰

沒有真正的檔案系統

Vite 用檔案系統完成了很多工作。讀取專案的檔案、監聽檔案改變、globs 的處理等等……在瀏覽器的模擬實現的記憶體檔案系統中,這些就很難實現了,所以 browser-vite 刪除了監聽、globs 和配置檔案來把複雜性降低。

專案檔案被儲存在記憶體檔案系統中,所以 broswer-vite 和 vite plugins 可以正常處理它們。

沒有 “node_modules”

Vite 依賴 node_modules 的存在來解析依賴。在啟動時會把他們預打包(Dependencing Pre-Bundling)來優化。

同樣為了降低複雜度,所以 broswer-vite 非常小心的從 Vite 中刪除了 node_modules 解析和依賴預打包。

所以使用 browser-vite 的使用者需要建立一個 Vite plugin 來解析裸模組匯入。

正則表示式“後行斷言”

Vite 中的一些程式碼用了後行斷言。在 Node.js 裡沒問題,但是 Safari 不支援。

所以作者重寫了這些正則。

熱更新(HMR)

Vite 用了 WebSockets 來在服務端(node)和客戶端(browser)之間同步程式碼變更。

在 browser-vite 中,服務端是 ServiceWorker + Vite worker,客戶端是 iframe。所以作者把 WebSockets 切換成了對 iframe 使用 post message。

如何使用

截止本文撰寫時間為止,這個工具還沒有做到開箱即用,如果想使用的話,需要閱讀很多 Vite 內部的處理細節。

如果感興趣的話,可以保持關注 browser-vite’s README 來獲取最新的使用方式。

安裝

安裝 browser-vite npm 包。

$ npm install --save browser-vite

或者

$ npm install --save [email protected]:browser-vite

來將 "vite" 的 import 改寫到 "browser-vite"

iframe - browser-vite 的視窗

需要一個 iframe 來顯示由 browser-vite 提供的內部頁面。

Service Worker - 瀏覽器內的 Web 伺服器

Service Worker 會捕獲到來自 iframe 的特定 url 請求。

一個使用 workbox 的例子:

workbox.routing.registerRoute(
  /^https?:\/\/HOST/BASE_URL\/(\/.*)$/,
  async ({
    request,
    params,
    url,
  }: import('workbox-routing/types/RouteHandler').RouteHandlerCallbackContext): Promise<Response> => {
    const req = request?.url || url.toString();
    const [pathname] = params as string[];
    // send the request to vite worker
    const response = await postToViteWorker(pathname)
    return response;
  }
);

大多數情況下,對 "Vite Worker" 傳送訊息用的是 postMessagebroadcast-channel

Vite Worker - 處理請求

Vite Worker是一個 Web Worker,它會處理 Service Worker 捕獲的請求。

建立 Vite 伺服器的示例:

import {
  transformWithEsbuild,
  ModuleGraph,
  transformRequest,
  createPluginContainer,
  createDevHtmlTransformFn,
  resolveConfig,
  generateCodeFrame,
  ssrTransform,
  ssrLoadModule,
  ViteDevServer,
  PluginOption
} from 'vite';

export async function createServer = async () => {
  const config = await resolveConfig(
    {
      plugins: [
        // virtual plugin to provide vite client/env special entries (see below)
        viteClientPlugin,
        // virtual plugin to resolve NPM dependencies, e.g. using unpkg, skypack or another provider (browser-vite only handles project files)
        nodeResolvePlugin,
        // add vite plugins you need here (e.g. vue, react, astro ...)
      ]
      base: BASE_URL, // as hooked in service worker
      // not really used, but needs to be defined to enable dep optimizations
      cacheDir: 'browser',
      root: VFS_ROOT,
      // any other configuration (e.g. resolve alias)
    },
    'serve'
  );
  const plugins = config.plugins;
  const pluginContainer = await createPluginContainer(config);
  const moduleGraph = new ModuleGraph((url) => pluginContainer.resolveId(url));

  const watcher: any = {
    on(what: string, cb: any) {
      return watcher;
    },
    add() {},
  };
  const server: ViteDevServer = {
    config,
    pluginContainer,
    moduleGraph,
    transformWithEsbuild,
    transformRequest(url, options) {
      return transformRequest(url, server, options);
    },
    ssrTransform,
    printUrls() {},
    _globImporters: {},
    ws: {
      send(data) {
        // send HMR data to vite client in iframe however you want (post/broadcast-channel ...)
      },
      async close() {},
      on() {},
      off() {},
    },
    watcher,
    async ssrLoadModule(url) {
      return ssrLoadModule(url, server, loadModule);
    },
    ssrFixStacktrace() {},
    async close() {},
    async restart() {},
    _optimizeDepsMetadata: null,
    _isRunningOptimizer: false,
    _ssrExternals: [],
    _restartPromise: null,
    _forceOptimizeOnRestart: false,
    _pendingRequests: new Map(),
  };

  server.transformIndexHtml = createDevHtmlTransformFn(server);

  // apply server configuration hooks from plugins
  const postHooks: ((() => void) | void)[] = [];
  for (const plugin of plugins) {
    if (plugin.configureServer) {
      postHooks.push(await plugin.configureServer(server));
    }
  }

  // run post config hooks
  // This is applied before the html middleware so that user middleware can
  // serve custom content instead of index.html.
  postHooks.forEach((fn) => fn && fn());

  await pluginContainer.buildStart({});
  await runOptimize(server);
  
  return server;
}

通過 browser-vite 處理請求的虛擬碼:

import {
  transformRequest,
  isCSSRequest,
  isDirectCSSRequest,
  injectQuery,
  removeImportQuery,
  unwrapId,
  handleFileAddUnlink,
  handleHMRUpdate,
} from 'vite/dist/browser';

...

async (req) => {
  let { url, accept } = req
  const html = accept?.includes('text/html');
  // strip ?import
  url = removeImportQuery(url);
  // Strip valid id prefix. This is prepended to resolved Ids that are
  // not valid browser import specifiers by the importAnalysis plugin.
  url = unwrapId(url);
  // for CSS, we need to differentiate between normal CSS requests and
  // imports
  if (isCSSRequest(url) && accept?.includes('text/css')) {
    url = injectQuery(url, 'direct');
  }
  let path: string | undefined = url;
  try {
    let code;
    path = url.slice(1);
    if (html) {
      code = await server.transformIndexHtml(`/${path}`, fs.readFileSync(path,'utf8'));
    } else {
      const ret = await transformRequest(url, server, { html });
      code = ret?.code;
    }
    // Return code reponse
  } catch (err: any) {
    // Return error response
  }
}

檢視 Vite 內部中介軟體原始碼 獲取更多細節。

和 Stackblitz WebContainers 相比如何

"WebContainers":在瀏覽器中執行 Node.js

Stackblitz 的 WebContainers 也可以在瀏覽器中執行Vite。你可以去優雅的去 vite.new 擁有一個工作環境。

作者表示自己不是 WebContainers 方面的專家,但簡而言之,browser-vite 在 Vite 級別上模擬了 FS 和 HTTPS 伺服器,WebContainers 在 Node.js 級別上模擬了 FS 和其他很多東西,而 Vite 只需做一些額外的修改就可在上面執行。

它可以將 node_modules 儲存在瀏覽器的 WebContainer 中。但它不會直接執行 npm 或 yarn,可能是因為會佔用太多空間。他們將這些命令連結到 Turbo ———— 他們的包管理器。

WebContainers 也可以執行其他框架,如 RemixSvelteKitAstro

這很神奇✨這是令人興奮的🤯 作者對 WebContainer 的團隊表示巨大的尊重,Stackblitz 團隊牛逼!

WebContainers 的一個缺點是,它目前只能在 Chrome 上執行,但可能很快就會在 Firefox 上執行。browser-vite 目前適用於 Chrome、Firefox和Safari瀏覽器。

簡而言之,WebContainers在較低的抽象級別上執行Vite。browser-vite在更高的抽象層次上執行,非常接近Vite本身。

打個比方,對於那些復古遊戲玩家來說,browser-vite 有點像 UltraHLE(任天堂 N64 模擬器)🕹️😊

(*) gametechwiki.com: 高/低層級模擬器

作者接下來的計劃

browser-vite 是作者計劃的解決方案中的核心。打算逐步推廣到他們的全系列產品中:

  • Backlight.dev
  • Components.studio
  • WebComponents.dev
  • Replic.dev (即將釋出的新應用)

展望未來,作者將繼續在 browser-vite 中投入,並向上遊報告。上個月他們還宣佈向 Evan You 和 Patak贊助來支援 Vite,以支援這個超讚的專案。

想知道更多?

參考資料