第 73 期 - 携程商旅在 Remix 升级中对 Vite 动态模块加载优化的实践
摘要
携程商旅前端团队将 Remix 框架从 1.0 升级到 2.0 时,Vite 对动态模块加载优化引发资源加载问题,如 404 错误。文章探讨了 Vite 优化 DynamicImport 机制,以及商旅团队的定制化处理。
一、引言部分
- 携程商旅大前端团队之前成功将部分框架从 Next.js 迁移至 Remix,提升了用户体验。之后他们决定将 Remix 1.0 升级到 2.0。
- 在 Remix 1.0 版本中,通过在服务端渲染生成静态资源模板时,为所有静态资源动态添加 CDN 前缀来处理资源加载。
- 但是升级到 2.0 后,发现某些 CSS 资源以及 modulePreload 的 JavaScript 资源出现 404 响应,原因是 Remix 升级过程中,Vite 对懒加载模块(DynamicImport)进行优化,导致 1.0 中动态注入的 Host 变量未能生效。
- 本文将从表象、实现和源码三个层面详细探讨 Vite 如何优化 DynamicImport,并介绍携程商旅的定制化处理。
二、模块懒加载
- 懒加载是前端开发中的一种优化技术,旨在提高页面加载性能和用户体验,核心思想是在用户需要时才加载某些资源。
- 模块懒加载表示页面中某些模块通过动态导入(dynamic import),在需要时才加载 JavaScript 模块。
- 以 React 为例给出了懒加载的示例代码:
import React, { Suspense, useState } from 'react';
// 出行人组件,立即加载
const Travelers = () => {
return <div>出行人组件内容</div>;
};
// 联系人组件,使用 React.lazy 进行懒加载
const Contact = React.lazy(() => import('./Contact'));
const App = () => {
const [showContact, setShowContact] = useState(false);
const handleAddContactClick = () => {
setShowContact(true);
};
return (
<div>
<h1>页面标题</h1>
{/* 出行人组件立即展示 */}
<Travelers />
{/* 添加按钮 */}
<button onClick={handleAddContactClick}>添加联系人</button>
{/* 懒加载的联系人组件 */}
{showContact && (
<Suspense fallback={<div>加载中...</div>}>
<Contact />
</Suspense>
)}
</div>
);
};
export default App;
- 在这个示例中,Travelers 组件立即加载,Contact 组件使用 React.lazy 以及 DynamicImport 进行懒加载,只有在用户点击“添加联系人”按钮后才会加载并显示,Suspense 组件用于在懒加载的组件尚未加载完成时显示回退内容。
三、Vite 中如何处理懒加载模块
3.1 表象
- 通过
npm create vite@latest react -- --template react
创建基于 Vite 的 React 项目来举例。 - 在示例代码中,在 App.tsx 里编写了一个页面,使用 dynamicImport 引入了 Contact、Phone、Name 三个模块,其中 Phone 和 Name 模块是为了利用 dynamicImport 的 splitChunk 特性。
- 在生产构建模式下,运行
npm run build && npm run start
启动应用后,会发现 head 标签中多出了 3 个 moduleprealod 的标签,这是 Vite 对于使用 dynamicImport 异步引入模块的优化方式,默认会对使用 dynamicImport 的模块收集当前模块的依赖进行 modulepreload 预加载,并且对 dynamicImport 依赖的 CSS 模块也进行了处理。
3.2 机制
- 从编译后的 JavaScript 代码角度分析 Vite 对 DynamicImport 模块的优化方式。
- 查看浏览器 head 标签中的 modulePreload 标签,声明 modulePreload 的资源分别为 Contact、Phone 和 Name 模块。
- 网页的 JS 加载顺序:App.tsx 构建后生成的 Js Assets 会使用 dynamicImport 加载 Contact.tsx 对应的 assets,而 Contact.tsx 中依赖了 name - [hash].jsx 和 phone - [hash].js 这两个 assets。
- Vite 对于 App.tsx 进行静态扫描时,发现内部存在 dynamicImport 语句,会将语句进行优化处理,如将
const Contact = React.lazy(() => import('./components/Contact'))
转化为const Contact = React.lazy(() => __vitePreload(() => import('./Contact - BGa5hZNp.js'), __vite__mapDeps([0, 1, 2])))
。 __vitePreload
方法会将__vite__mapDeps
中所有依赖的模块使用document.head.appendChild
插入所有 modulePreload 标签之后返回真实的import('./Contact - BGa5hZNp.js')
,从而实现对动态模块内部引入的所有依赖模块的动态加载优化。
3.3 原理
3.3.1 扫描 / 替换模块代码 - transform
- build - import - analysis 插件中的 transform 钩子的作用:
- 扫描动态导入语句:在每个模块中使用 es - module - lexer 扫描所有的 dynamicImport 语句,例如在 app.tsx 文件中扫描
import ('./Contact.tsx')
这样的语句。 - 注入预加载 Polyfill:对所有动态导入语句,用 magic - string 克隆源代码,结合扫描出的 dynamicImport 语句进行字符串拼接,注入预加载 Polyfill。例如
import ('./Contact.tsx')
会被转换为__vitePreload( async () => { const { Contact } = await import('./Contact.tsx') return { Contact } }, __VITE_IS_MODERN__? __VITE_PRELOAD__ : void 0, '' )
。 - 引入预加载方法:检查模块是否引入了
preloadMethod (__vitePreload)
,如果未引入,则在模块顶部添加引入。
- 扫描动态导入语句:在每个模块中使用 es - module - lexer 扫描所有的 dynamicImport 语句,例如在 app.tsx 文件中扫描
3.3.2 增加 preload 辅助语句 - resolveId/load
- 当转换后的模块中不存在 preloadMethod 声明时,Vite 在构建过程中自动插入 preloadMethod 的引入语句。
- 当模块内部引入 preloadHelperId 时,Vite 在解析该模块(如 App.tsx)过程中,通过 moduleParse 钩子分析依赖关系。
- vite:build - import - analysis 插件的 resolveId 和 load hook 会在这个过程中发挥作用,对引入 preloadHelperId 的模块,在 resolveId 和 load 阶段识别并添加 preload 方法的静态声明,preload 方法支持三个参数,如原始模块引入语句、被 dynamicImport 加载的模块的所有依赖、import.meta.url 或空字符串。
3.3.3 开启预加载优化 - renderChunk
- renderChunk 是 Rollup(Vite)插件钩子之一,用于在生成每个代码块(chunk)时进行自定义处理。
- build - import - analysis 会在渲染每一个 chunk 时,通过 renderChunk hook 来最终确定是否需要开启 modulePrealod,判断源代码中是否存在 isModernFlag,如果存在则判断生成的 chunk 是否为 esm 格式,然后进行相应替换。
3.3.4 寻找 / 加载需要预加载模块 - generateBundle
- generateBundle 是 Rollup (Vite)插件钩子之一,用于在生成最终输出文件之前对整个构建结果进行处理。
- build - import - analysis 插件中的 generateBundle 钩子用于实现对最终生成的 assets 中的内容进行修改,寻找当前生成的 assets 中所有 dynamicImport 的深层依赖文件从而替换
__VITE_PRELOAD__
变量。 - 它会遍历 bundle 中的所有 assets,对包含 preloadMarker 的文件,解析其中的 dynamicImport 语句,将相关模块及依赖模块加入 deps 集合,最终将
__VITE_PRELOAD__
替换为包含所有依赖项的__vite__mapDeps
。
四、商旅对于 DynamicImport 的内部改造
- 商旅内部对 Remix 2.0 的升级优化接近尾声,在 2.0 中如果仅在服务端模板生成时为所有 ES 模块动态添加 AresHost,会出现 modulePreload 标签和 CSS 资源加载 404 的问题,这是由 Vite 中 build - import - analysis 对 DynamicImport 的优化导致的。
- 为解决问题,商旅团队对 Remix 进行了改造,还对 Vite 中处理 DynamicImport 的逻辑进行优化,以支持在 modulePreload 开启时以及 DynamicImport 模块中的静态资源实现 Ares 的运行时 CDN Host 注入。
- 由于 Vite 的 experimental.renderBuiltUrl 属性无法获取服务端运行变量,不满足需求,商旅团队采用了其他方式,将携程相关的通用框架属性集成到 RemixContext 中,通过 script 脚本在 window 上挂载
__remixContext.aresHost
属性,然后在 Vite 内部的 build - import - analysis 插件中的 preload 函数中增加代码,为所有链接添加window.__remixContext.aresHost
属性,确保相关资源能正确加载。
五、结尾
- 商旅大前端团队较早采用 Streaming 和 ESModule 技术,Remix 在开发友好度和服务端 Streaming 处理方面相比 NFES 有独特优势,已在商旅大流量页面中得到验证。
- 本文从 preload 细节分享了遇到的问题和心得,后续还会继续分享 Remix 的技术细节和更多改造内容。
Made by 捣鼓键盘的小麦 / © 2025 Front Talk 版权所有