大家好,我是 ssh,前几天在推上冲浪的时候,看到 Francois Valdy 宣布他制作了 browser-vite,成功把 Vite 成功在浏览器中运行起来了。这引起了我的兴趣,如何把重度依赖 node 的一个 Vite 跑在浏览器上?接下来,就和我一起探索揭秘吧。
Vite 用文件系统完成了很多工作。读取项目的文件、监听文件改变、globs 的处理等等……在浏览器的模拟实现的内存文件系统中,这些就很难实现了,所以 browser-vite 删除了监听、globs 和配置文件来把复杂性降低。
项目文件被保存在内存文件系统中,所以 broswer-vite 和 vite plugins 可以正常处理它们。
Vite 依赖 node_modules
的存在来解析依赖。在启动时会把他们预打包(Dependencing Pre-Bundling)来优化。
同样为了降低复杂度,所以 broswer-vite 非常小心的从 Vite 中删除了 node_modules
解析和依赖预打包。
所以使用 browser-vite 的用户需要创建一个 Vite plugin 来解析裸模块导入。
Vite 中的一些代码用了后行断言。在 Node.js 里没问题,但是 Safari 不支持。
所以作者重写了这些正则。
Vite 用了 WebSockets 来在服务端(node)和客户端(browser)之间同步代码变更。
在 browser-vite 中,服务端是 ServiceWorker + Vite worker,客户端是 iframe。所以作者把 WebSockets 切换成了对 iframe 使用 post message。
截止本文撰写时间为止,这个工具还没有做到开箱即用,如果想使用的话,需要阅读很多 Vite 内部的处理细节。
安装 browser-vite npm 包。
$ npm install --save browser-vite
或者
$ npm install --save vite@npm:browser-vite
来将 "vite" 的 import 改写到 "browser-vite"
需要一个 iframe 来显示由 browser-vite 提供的内部页面。
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" 发送消息用的是 postMessage 和 broadcast-channel。
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 内部中间件源码 获取更多细节。
"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 也可以运行其他框架,如 Remix、SvelteKit 或 Astro。
这很神奇✨这是令人兴奋的🤯 作者对 WebContainer 的团队表示巨大的尊重,Stackblitz 团队牛逼!
WebContainers 的一个缺点是,它目前只能在 Chrome 上运行,但可能很快就会在 Firefox 上运行。browser-vite 目前适用于 Chrome、Firefox和Safari浏览器。
简而言之,WebContainers在较低的抽象级别上运行Vite。browser-vite在更高的抽象层次上运行,非常接近Vite本身。
打个比方,对于那些复古游戏玩家来说,browser-vite 有点像 UltraHLE(任天堂 N64 模拟器)🕹️😊
(*) gametechwiki.com: 高/低层级模拟器
browser-vite 是作者计划的解决方案中的核心。打算逐步推广到他们的全系列产品中:
展望未来,作者将继续在 browser-vite 中投入,并向上游报告。