干货 | 携程商旅在 Remix 模块预加载中的探索与优化实践

作者简介

19组清风,携程资深前端开发工程师,负责商旅前端公共基础平台建设,关注NodeJs、研究效能领域。

团队热招岗位:高级/资深前端开发工程师

本文总结了携程商旅大前端团队在将框架从 Remix 1.0 升级至 Remix 2.0 过程中遇到的问题和解决方案,特别是针对 Vite 在动态模块加载优化中引发的资源加载问题。文章详细探讨了 Vite 优化 DynamicImport 的机制,并介绍了团队为解决动态引入导致 404 问题所做的定制化处理。

  • 一、引言

  • 二、模块懒加载

  • 三、Vite 中如何处理懒加载模块

  • 3.1 表象

  • 3.2 机制

  • 3.3 原理

  • 四、商旅对于 DynamicImport 的内部改造

  • 五、结尾

一、引言

去年,商旅大前端团队成功尝试将部分框架从 Next.js 迁移至 Remix,并显著提升了用户体验。由于 Remix 2.0 版本在构建工具和新功能方面进行了大量升级,我们最近决定将 Remix 1.0 版本同步升级至 Remix 2.0。

目前,商旅内部所有 Remix 项目在浏览器中均已使用 ESModule 进行资源加载。

在 Remix 1.0 版本中,我们通过在服务端渲染生成静态资源模板时,为所有静态资源动态添加 CDN 前缀来处理资源加载。简单来说,原始的 HTML 模板如下:

<script type="module">
  import init from 'assets/contact-GID3121.js';
  init();
  // ...
</script>

在每次生成模板时,我们会动态地为所有生成的 <script> 标签注入一个变量:

<script type="module">
  import init from 'https://aw-s.tripcdn.com/assets/contact-GID3121.js';
  init();
  // ...
</script>

在 Remix 1.0 下,这种工作机制完全满足我们的需求,并且运行良好。然而,在商旅从 Remix 1.0 升级到 2.0 后,我们发现某些 CSS 资源以及 modulePreload 的 JavaScript 资源仍然会出现 404 响应。

经过排查,我们发现这些 404 响应的静态资源实际上是由于在 1.0 中动态注入的 Host 变量未能生效。实际上,这是由于 Remix 升级过程中,Vite 对懒加载模块(DynamicImport)进行了优化,以提升页面性能。然而,这些优化手段在我们的应用中使用动态加载的静态资源时引发了新的问题。

这篇文章总结了我们在 Vite Preload 改造过程中的经验和心得。接下来,我们将从表象、实现和源码三个层面详细探讨 Vite 如何优化 DynamicImport,并进一步介绍携程商旅在 Remix 升级过程中对 Vite DynamicImport 所进行的定制化处理。

二、模块懒加载

懒加载(Lazy Load)是前端开发中的一种优化技术,旨在提高页面加载性能和用户体验。

懒加载的核心思想是在用户需要时才加载某些资源,而不是在页面初始加载时就加载所有资源。

除了常见的图像懒加载、路由懒加载外还有一种模块懒加载

广义上路由懒加载可以看作是模块懒加载的子集。

所谓的模块懒加载表示页面中某些模块通过动态导入(dynamic import),在需要时才加载某些 JavaScript 模块。

目前绝大多数前端构建工具中会将通过动态导入的模块进行 split chunk(代码拆分),只有在需要时才加载这些模块的 JavaScript、Css 等静态资源内容。

我们以 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;

在这个示例中:

1)Travelers 组件是立即加载并显示的。

2)Contact 组件使用 React.lazy 以及 DynamicImport 进行懒加载,只有在用户点击“添加联系人”按钮后才会加载并显示。

3)Suspense 组件用于在懒加载的组件尚未加载完成时显示一个回退内容(例如“加载中...”)。

这样,当用户点击“添加联系人”按钮时,Contact 组件才会被动态加载并显示在页面上。

所以上边的 Contact 联系人组件就可以认为是被当前页面懒加载。

三、Vite 中如何处理懒加载模块

3.1 表象

首先,我们先来通过 npm create vite@latest react -- --template react 创建一个基于 Vite 的 React 项目。

无论是 React、Vue 还是源生 JavaScript ,LazyLoad 并不局限于任何框架。这里为了方便演示我就使用 React 来举例。

想跳过简单 Demo 编写环节的小伙伴可以直接在这里 Clone Demo 仓库

首先我们通过 vite 命令行初始化一个代码仓库,之后我们对新建的代码稍做修改:

// app.tsx
import React, { Suspense } from 'react';


// 联系人组件,使用 React.lazy 进行懒加载
const Contact = React.lazy(() => import('./components/Contact'));


// 这里的手机号组件、姓名组件可以忽略
// 实际上特意这么写是为了利用 dynamicImport 的 splitChunk 特性
// vite 在构建时对于 dynamicImport 的模块是会进行 splitChunk 的
// 自然 Phone、Name 模块在构建时会被拆分为两个 chunk 文件
const Phone = () => import('./components/Phone');
const Name = () => import('./components/Name');
// 防止被 sharking 
console.log(Phone,'Phone')
console.log(Name,'Name')


const App = () => {


  return (
    <div>
      <h1>页面标题</h1>
      {/* 懒加载的联系人组件 */}
       (
        <Suspense fallback={<div>加载中...</div>}>
          <Contact />
        </Suspense>
      )
    </div>
  );
};


export default App;
// components/Contact.tsx
import React from 'react';
import Phone from './Phone';
import Name from './Name';


const Contact = () => {
  return <div>
    <h3>联系人组件</h3>
    {/* 联系人组件依赖的手机号以及姓名组件 */}
    <Phone></Phone>
    <Name></Name>
  </div>;
};


export default Contact;
// components/Phone.tsx
import React from 'react';


const Phone = () => {
  return <div>手机号组件</div>;
};


export default Phone;
// components/Name.tsx
import React from 'react';


const Name = () => {
  return <div>姓名组件</div>;
};


export default Name;

上边的 Demo 中,我们在 App.tsx 中编写了一个简单的页面。

页面中使用 dynamicImport 引入了三个模块,分别为:

  • Contact 联系人模块

  • Phone 手机模块

  • Name 姓名模块

对于 App.tsx 中动态引入的 Phone 和 Name 模块,我们仅仅是利用动态引入实现在构建时的代码拆分。所以这里在 App.tsx 中完全可以忽略这两个模块。

简单来说 vite 中对于使用 dynamicImport 的模块会在构建时单独拆分成为一个 chunk (通常情况下一个 chunk 就代表构建后的一个单独 javascript 文件)。

重点在于 App.tsx 中动态引入的联系人模块,我们在 App.tsx 中使用 dynamicImport 引入了 Contact 模块。

同时,在 Contact 模块中我们又引入了 Phone、Name 两个模块。

由于在 App.tsx 中我们已经使用 dynamicImport 将 Phone 和 Name 强制拆分为两个独立的 chunk,自然 Contact 在构建时相当于依赖了 Phone 和 Name 这两个模块的独立 chunk。

此时,让我们直接直接运行 npm run build && npm run start 启动应用(只有在生产构建模式下才会开启对于 dynamicImport 的优化)。

打开浏览器后我们会发现,在 head 标签中多出了 3 个 moduleprealod 的标签:

92899e7130fdfc2ade9365f0b681d332.jpeg

简单来说,这便是 vite 对于使用 dynamicImport 异步引入模块的优化方式,默认情况下 Vite 会对于使用 dynamicImport 的模块收集当前模块的依赖进行 modulepreload 进行预加载。

当然,对于 dynamicImport,Vite 内部不仅对 JS 模块进行了依赖模块的 modulePreload 处理,同时也对 dynamicImport 依赖的 CSS 模块进行了处理。

不过,让我们先聚焦于 dynamicImport 的 JavaScript 优化上吧。

3.2 机制

在探讨源码实现之前,我们先从编译后的 JavaScript 代码角度来分析 Vite 对 DynamicImport 模块的优化方式。

首先,我们先查看浏览器 head 标签中的 modulePreload 标签可以发现,声明 modulePreload 的资源分别为 Contact 联系人模块、Phone 手机模块以及 Name 姓名模块。

从表现上来说,简单来说可以用这段话来描述 Vite 内部对于动态模块加载的优化:

项目在构建时,首次访问页面会加载 App.tsx 对应生成的 chunk 代码。App.tsx 对应的页面在渲染时会依赖 dynamicImport 的 Contact 联系人模块。

此时,Vite 内部会对使用 dynamicImport 的 Contact 进行模块分析,发现联系人模块内部又依赖了 Phone 以及 Name 两个 chunk。

简单来讲我们网页的 JS 加载顺序可以用下面的草图来表达:

599e747ff834487f65d139acb0b6a360.jpeg

App.tsx 构建后生成的 Js Assets 会使用 dynamicImport 加载 Contact.tsx 对应的 assets。

而 Contact.tsx 中则依赖了 name-[hash].jsx 和 phone-[hash].js 这两个 assets。

Vite 对于 App.tsx 进行静态扫描时,会发现内部存在使用 dynamicImport 语句。此时会将所有的 dynamicImport 语句进行优化处理,简单来说会将

const Contact = React.lazy(() => import('./components/Contact'))

转化为

const Contact = React.lazy(() =>
    __vitePreload(() => import('./Contact-BGa5hZNp.js'), __vite__mapDeps([0, 1, 2])))
  • __vitePreload 是构建时 Vite 对于使用 dynamicImport 插入的动态加载的优化方法。

  • __vite__mapDeps([0, 1, 2])则是传递给 __vitePreload 的第二个参数,它表示当前动态引入的 dynamicImport 包含的所有依赖 chunk,也就是 Contact(自身)、Phone、Name 三个 chunk。

简单来说 __vitePreload 方法首先会将 __vite__mapDeps 中所有依赖的模块使用 document.head.appendChild 插入所有 modulePreload 标签之后返回真实的 import('./Contact-BGa5hZNp.js')。

最终,Vite 通过该方式就会对于动态模块内部引入的所有依赖模块实现对于动态加载模块的深层 chunk 使用 modulePreload 进行动态加载优化。

3.3 原理

在了解了 Vite 内部对 modulePreload 的基本原理和机制后,接下来我们将深入探讨 Vite 的构建过程,详细分析其动态模块加载优化的实现方式。

Vite 在构建过程中对 dynamicImport 的优化主要体现在 vite:build-import-analysis 插件中。

接下来,我们将通过分析 build-import-analysis 插件的源代码,深入探讨 Vite 是如何实现 modulePreload 优化的。

3.3.1 扫描/替换模块代码 - transform

首先,build-import-analysis 中存在 transform hook

简单来说,transform 钩子用于在每个模块被加载和解析之后,对模块的代码进行转换。这个钩子允许我们对模块的内容进行修改或替换,比如进行代码转换、编译、优化等操作。

上边我们讲过,vite 在构建时扫描源代码中的所有 dynamicImport 语句同时会将所有 dynamicImport 语句增加 __vitePreload的 polyfill 优化方法。

所谓的 transform Hook 就

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值