漫谈构建工具(六): 我是如何利用 prefetch 来提升 Vite 开发模式下懒加载的性能

前言

之前在 为什么有人说 vite 快,有人却说 vite 慢? 一文中,我们有提到开发模式下使用 Vite 会有首屏和懒加载性能下降的负面效果,并且在文章的结尾提到了可以通过持久化缓存prefetch 的方式来做优化,那今天我们就来试一下其中的一种手段:通过 prefetch 优化懒加载性能。

本文的目录结果如下:

效果展示

我们先给大家展示一下没有做懒加载优化的效果和优化以后的效果:

  • webpack 的效果:

    Aug-12-2022 17-39-15.gif

    抛开 dev server 的启动速度慢以外,懒加载的性能还是很不错的。

  • Vite 没有做懒加载优化的效果

    Aug-12-2022 17-48-40.gif

    使用 Vite 以后,dev server 启动速度飞起,但首屏性能、懒加载性能和 Webpack 相比还是有很明显的差距。

  • Vite 做懒加载优化的效果

    Aug-12-2022 19-28-35.gif

    优化以后的 Vite 应用,尽管首屏性能还和以前一样,但是懒加载性能有了明显的提升。当我们打开客户管理页面时,页面会很快的完成渲染。

接下来小编就给大家具体讲一讲整个性能优化的过程。

为什么懒加载性能会差

首先我们先来解释一下为什么通过 Vite 启动 dev server 以后,懒加载性能会差(注意:性能差仅限于 dev server 启动以后首次打开应用的某一个页面)。

原因有两点:

  • 大量的 http 请求;

  • dev server 实时对浏览器请求的文件做 transform

由于 Vite 采取了 unbundle 机制,源文件并没有像 webpack 那样做合并捆绑,这就导致了不管是首屏还是懒加载,都会因为请求 jscss 等资源产生大量的 http 请求。另外,在请求过程中,dev server 需要对源文件做实时转换。其中,实时转换源文件是影响最大的。

这两种情形,和我们常用的优化手段如减少 http 请求、提前对源文件做处理背道而驰,也就导致了懒加载性能下降。

知道了性能下降的原因,那我们就可以针对性的做性能优化了。

http 请求数量多这个问题,影响较小,因此小编只重点关注源文件实时处理这个问题。解决方案也很简单,就是在用户点击某个页面之前,提前向 dev server 发起请求,让 dev server 提前去对请求文件做 transform,等到真正需要加载某个资源时,直接返回已经 transform 的资源。

这种手段也就是我们常说的 prefetch

通过 prefetch 优化懒加载性能

提到 prefetch,大家第一想到的肯定是常用的 preloadprefetchpreconnect 优化策略。

// 用于提前获取首屏资源
<link ref="preload" href="xxxxx" as="script" />

// 用于提前获取非首屏资源
<link ref="prefetch" href="xxxx"  as="script" />

// 用于提前建立 http 连接
<link ref="preconnect" href="xxx" />

由于需要优化懒加载性能,我们完全可以使用 prefetch 策略,在 html 文件中添加需要 prefetch 的资源,等到浏览器空闲的时候去提前加载资源,让 dev server 提前对请求文件做 transform。这样等切换到某个页面,真正需要获取相关资源时,可以快速 get

有了方法,那我们就开始行动吧,💪🏻。

使用 prefetch 策略,有两个问题需要解决:

  • 找到项目中需要懒加载的 path

  • prefetch 链接添加到 html 文件中;

首先是找到项目需要懒加载的 path。要完成这一步,有简单的实现,也有复杂的实现。

简单的方式,就是开发人员手动定义一个需要 prefetch 的懒加载 path 列表。这种方式简单易用,方便快捷,而且使用灵活,但是会加重开发人员的维护成本。

复杂的方式,是通过技术手段实现。这种方式,和预加载需要提前获取项目中所有的第三方依赖一样,通过对项目整个源文件做一个依赖分析,然后识别出其中需要懒加载的 path。因此我们可以像预加载一样,借助 esbuild 实现。但是这种方式有一个很大的问题,那就是由于我们需要把识别的 path 添加到 html 中,导致识别过程必须在浏览器请求 html 之前完成,这就存在阻塞浏览器首屏请求的可能,使得首屏性能下降。

综合两种方式的优劣和实现成本,小编最后决定采用第一种方式。

prefetch 链接添加到 html 文件中就非常简单了,我们可以通过 Vite 提供的 transformIndexHtml hook 实现。这个 hook 给我们提供了在 dev server 返回 html 文件之前修改 html 的机会。

所有问题都解决了,那我们就来写一个自定义 Vite plugin 来实现 prefetch 功能吧。

// PrefetchLazyPathsPlugin.ts
export const PrefetchLazyPathsPlugin = (paths: string[] = []) => {
    return {
        name: 'prefetch-lazy-paths-plugin',
        async transformIndexHtml(html: string) {
            if (!paths.length) return html;
            let prefetchStr: string = '';
            paths.forEach((item) => {
                prefetchStr += `<link rel="prefetch" href="${item}" as="script" />`;
            });
            let newHtml = html.replace('</head>', `${preloadStr}</head>`);
            return newHtml;
        },
    }
}

这个 plugin 的用法如下:

// vite.config.ts
const lazyPaths = [
    '/src/pages/order-manage/index.tsx',
    '/src/pages/customer-manage/index.tsx',
    '/src/pages/customer-group/index.tsx',
    ...
]

export default {
    ...
    plugins: [
        PrefetchLazyPathsPlugin(lazyPaths)
    ]
}

效果如下:

Aug-13-2022 21-59-40.gif

html 页面结构如下:

image.png

观察 gif 图中切换路由时页面的渲染速度,可以看到我们的优化策略是生效的,而且效果也非常不错,但是却带来一个很严重的问题,那就是首屏性能更差了。

小编也分析了一下原因,主要是 prefetch 策略使得 dev server 需要在首屏期间需要同时处理首屏资源 + 懒加载页面资源,导致首屏资源的响应速度变慢,使得首屏性能下降。

这样子的性能优化肯定是没有意义的,我们不能牺牲首屏性能去提升懒加载性能。

那有没有更好的 prefech 策略呢?或者说我们能不能在首屏完成以后再去 prefetch 呢?

答案是可以的。我们可以通过 fetch API, 在合适的时机手动调用这个 API 来实现 prefetch

prefetch 二次优化

通过手动调用 fetch API 的方式来实现 prefetch,问题也有两个:

  • 什么时候调用 fetch

  • 调用 fetch 代码怎么注入?

什么时候调用 fetch 的问题,我们可以通过 onload 来处理。onload 会在页面所有资源加载完毕以后触发,这样我们就可以在 onload 内部调用 fetch 去实现 prefetch

而注入 fetch 代码的问题,也好处理。我们可以通过 Vite 提供的 transform hook 实现。这个 hook 给我们提供了在 dev server 返回 transform 以后的代码之前,修改代码的机会。通过这个 hook,我们可以在入口文件 /src/index.tsx 完成 transform 以后、返回浏览器之前注入 fetch 代码。

有了方案,那我们就来写代码实现吧。

// PrefetchLazyPathsPlugin.ts
export const PrefetchLazyPathsPlugin = (entry: string, paths: string[] = []) => {
    return {
        name: 'prefetch-lazy-paths-plugin',
        async transform(code, id) {
            // 只对入口文件做代码注入
            if (id.includes(entry)) {
                return `
                    ${code};
                    window.onload = () => {
                        const lazyPages = ${JSON.stringify(paths)};
                        lazyPages.forEach(item => fetch(item));
                    }
                `
            }
            return code;
        },
    }
}

这个 Plugin 的用法如下:

// vite.config.ts
const lazyPaths = [
    '/src/pages/order-manage/index.tsx',
    '/src/pages/customer-manage/index.tsx',
    '/src/pages/customer-group/index.tsx',
    ...
]

export default {
    ...
    plugins: [
        PrefetchLazyPathsPlugin('/src/index.tsx', lazyPaths);
    ]
}

效果如下:

Aug-14-2022 09-47-55.gif

入口文件 /src/index.tsx 的代码如下:

image.png

这次优化以后,首屏性能不像上一次那么差,懒加载性能也非常优秀,优化策略生效,😄。但依然存在一个问题,那就是首个懒加载页面展示特别慢。

我们还是简单做一下分析。整个首页的加载过程为:

  1. 首屏加载;
  2. prefetch 懒加载资源;
  3. 根据当前路由懒加载 order-manage 页面;

prefetch 导致 dev-server 需要同时处理 order-manage 页面资源 + 其他懒加载页面资源,导致 order-manage 资源响应速度变慢,使得 order-manage 页面渲染速度变慢。

这个问题同样也有解决方案,我们可以在首页懒加载完成以后再去 prefetch

prefetch 三次优化

首页懒加载完成以后再次 prefetch 有一个问题:我们怎么确定懒加载已完成。

解决这个问题也有两个难点:

  • 如何量化懒加载已经完成 ?

  • 用户首页懒加载的页面无法确定,我们怎么注入 prefetch 代码 ?

针对这个问题,小编目前没有特别好的解决方案,只能通过一个非常笨的方法:

  • 给每个懒加载页面都注入 prefetch 代码;

  • 人为通过 setTimeout 延迟 1000 ms 去 prefetch 来防止首个懒加载页面资源响应速度变慢;

优化以后的代码实现如下:

// PrefetchLazyPathsPlugin.ts
export const PrefetchLazyPathsPlugin = (paths: string[] = [], timeout: number = 1000) => {
    return {
        name: 'prefetch-lazy-paths-plugin',
        async transform(code, id) {
            if (paths.length) {
                for(let path of paths) {
                    if (id.includes(path)) {
                        return `
                            ${code};
                            const lazyPages = ${JSON.stringify(paths)};
                            setTimeout(() => {
                              lazyPages.forEach(item => fetch(item));
                            }, timeout);
                        `
                    }
                }
            }
            return code;
        }
    }
}

这个 Plugin 的用法如下:

// vite.config.ts
const lazyPaths = [
    '/src/pages/order-manage/index.tsx',
    '/src/pages/customer-manage/index.tsx',
    '/src/pages/customer-group/index.tsx',
    ...
];

export default {
    ...
    plugins: [
        PrefetchLazyPathsPlugin(lazyPaths, 1000);
    ]
}

效果如下:

Aug-14-2022 15-25-21.gif

懒加载页面代码如下:

image.png

老实说,这并不是一个完美的策略,依旧存在如下问题:

  • timeout 需要开发人员去调整;

  • 如果用户切换到某个页面,而这个时候如果 dev server 还没有完成 prefetch 资源的转换工作,那么要访问的页面可能会花较长的时间才能渲染完成;

不过,方案三比起方案一和方案二来说,依旧是有进步的。该方案最完美的应用场景是首屏渲染完成以后,用户在当前页面停留一段时间,然后再去切换到其他页面。(用户的使用习惯也是这样的吧,😂)。

结束语

到这里,关于 Vite 开发模式下懒加载的性能优化就结束了。

为了优化懒加载性能,小编一共实践了三种技术方案,其中方案三满意度最高。尽管对开发人员的心智负担最高,但效果最好。如果小伙伴们感兴趣,可以自己去试试。另外,如果小伙伴们有更好的方案,可以在留言区告诉小编,小编会马上去尝试的。