JackyLove 的技术人生

人生艰难,唯有一技傍身才能慢慢走向通途。个人博客,记录生活,分享技术,记录成长。专注全栈技术,next技术。

第23章—优化篇懒加载

首次发表于 2024-03-22, 更新于 2024-03-22

懒加载

懒加载,英文:Lazy Loading,又被称为“延迟加载”,其重要性不言而喻。这是因为随着互联网的发展,网页资源大小在快速增长,为了提高加载的速度,带来更好的用户体验,便产生了懒加载这种技术理念,减少初始加载的资源,让部分资源等到合适的时候再去加载。

Next.js 基于懒加载做了很多优化,实现了延迟加载客户端组件和导入库,只在需要的时候才在客户端引入它们。举个例子,比如延迟加载模态框相关的代码,直到用户点击打开的时候。

在 Next.js 中有两种方式实现懒加载:

  1. 使用 React.lazy()Suspense
  2. 使用 next/dynamic实现动态导入

默认情况下,服务端组件自动进行代码分隔,并且可以使用流将 UI 片段逐步发送到客户端,所以懒加载应用于客户端。

React.lazy 与 Suspense

我们先讲讲 React 的 lazy 方法,lazy 可以实现延迟加载组件代码,直到组件首次被渲染。换句话说,直到组件需要渲染的时候才加载组件的代码。使用示例如下:

import { lazy } from 'react';

const MarkdownPreview = lazy(() => import('./MarkdownPreview.js'));

通过在组件外部调用 lazy 方法声明一个懒加载的 React 组件,非常适合搭配 <Suspense> 组件使用:

<Suspense fallback={<Loading />}>
  <h2>Preview</h2>
  <MarkdownPreview />
</Suspense>

一个简单完整的例子如下:

import { Suspense, lazy } from 'react';

const MarkdownPreview = lazy(() => import('./MarkdownPreview.js'));

export default function Page() {
  return (
    <Suspense fallback={'loading'}>
      <h2>Preview</h2>
      <MarkdownPreview />
    </Suspense>
  );
}

当然这个例子在实际开发中并无意义,因为延迟加载的目的在于需要的时候才去加载,结果这里没有条件判断就直接开始了加载,那还用延迟加载干什么,徒然降低了性能和加载时间。

React 官网提供了一个非常的好的完整示例:https://react.dev/reference/react/lazy#suspense-for-code-splitting

import { useState, Suspense, lazy } from 'react';
import Loading from './Loading.js';

const MarkdownPreview = lazy(() => delayForDemo(import('./MarkdownPreview.js')));

export default function MarkdownEditor() {
  const [showPreview, setShowPreview] = useState(false);
  const [markdown, setMarkdown] = useState('Hello, **world**!');
  return (
    <>
      <textarea value={markdown} onChange={e => setMarkdown(e.target.value)} />
      <label>
        <input type="checkbox" checked={showPreview} onChange={e => setShowPreview(e.target.checked)} />
        Show preview
      </label>
      <hr />
      {showPreview && (
        <Suspense fallback={<Loading />}>
          <h2>Preview</h2>
          <MarkdownPreview markdown={markdown} />
        </Suspense>
      )}
    </>
  );
}

// 添加一个固定的延迟时间,以便你可以看到加载状态
function delayForDemo(promise) {
  return new Promise(resolve => {
    setTimeout(resolve, 2000);
  }).then(() => promise);
}

在这个例子中,只有当用户点击了 Show preview 选择框,showPreviewtrue 的时候才去加载 <Suspense><MarkdownPreview> 组件,这是更符合实际开发中的例子。

效果如下:

1.gif

next/dynamic

不过开发 Next.js 应用的时候,大部分时候并不需要用到 React.lazy 和 Suspense,使用 next/dynamic 即可,它本质就是 React.lazy 和 Suspense 的复合实现。在 apppages目录下都可以使用。

1. 基本示例

它的基本用法如下:

import dynamic from 'next/dynamic'
 
const WithCustomLoading = dynamic(
  () => import('../components/WithCustomLoading'),
  {
    loading: () => <p>Loading...</p>,
  }
)
 
export default function Page() {
  return (
    <div>
      <WithCustomLoading />
    </div>
  )
}

dynamic 函数的第一个参数表示加载函数,用法同 lazy 函数。第二个参数表示配置项,可以设置加载组件,如同 Suspense 中的 fallback。看似很简单,但使用的时候也有很多细节要注意:

  1. import() 中的路径不能是模板字符串或者是变量
  2. import() 必须在 dynamic() 中调用
  3. dynamic() 跟 lazy() 函数一样,需要放在模块顶层

前面我们讲过懒加载只应用于客户端的,如果动态导入的是一个服务端组件,只有这个服务端组件中的客户端组件才会被懒加载,服务端组件本身是不会懒加载的。

// app/page.js
import dynamic from 'next/dynamic'
 
// Server Component:
const ServerComponent = dynamic(() => import('../components/ServerComponent'))
 
export default function ServerComponentExample() {
  return (
    <div>
      <ServerComponent />
    </div>
  )
}

2. 跳过 SSR

之前讲客户端组件和服务端组件的时候,客户端组件默认也是会被预渲染的(SSR)。如果要禁用客户端组件的预渲染,可以将 ssr 选项设置为 false。让我们看个例子:

'use client'
// app/page.js
import { useState } from 'react'
import dynamic from 'next/dynamic'
 
// Client Components:
const ComponentA = dynamic(() => import('../components/a.js'))
const ComponentB = dynamic(() => import('../components/b.js'))
const ComponentC = dynamic(() => import('../components/c.js'), { ssr: false })
 
export default function ClientComponentExample() {
  const [showMore, setShowMore] = useState(false)
 
  return (
    <div>
      {/* 立刻加载,但会使用一个独立的客户端 bundle */}
      <ComponentA />
 
      {/* 按需加载 */}
      {showMore && <ComponentB />}
      <button onClick={() => setShowMore(!showMore)}>Toggle</button>
 
      {/* 只在客户端加载 */}
      <ComponentC />
    </div>
  )
}

三个组件内容相同,都是:

'use client'

export default function Page() {
  return <h1>Hello World!</h1>
}

加载效果如下:

10.gif

从效果上看,设置 ssrfalse<ComponentC> 会比 <ComponentA> 晚显示,<ComponentB> 在点击的时候才会显示。

这三个组件的加载到底有什么区别呢?

首先是预渲染,ComponentA 默认会被预渲染,ComponentC 因为设置了 ssrfalse,不会被预渲染,也就是说,如果查看页面的 HTML 源码,可以看到这样的渲染代码:

<div>
  <h1>Hello World!</h1>
  <button>Toggle</button>
  <template data-dgst="NEXT_DYNAMIC_NO_SSR_CODE"></template>
</div>

ComponentA 渲染了 HTML,ComponentC 只是留了一个占位。所以加载的时候,ComponentA 立刻就渲染了出来,ComponentC 会先显示空白,然后再展示出内容。

其次是 bundle,三个动态加载的组件都会打包成一个单独的包,ComponentAComponentC 的包都会尽快加载,CompoentB 的包会在点击按钮的时候才加载:

image.png

那你可能要问了,这个组件不就一个 Hello World!吗?ComponentB 在点击的时候才渲染,确实需要加载。ComponentC 只有一个占位,也确实需要加载,可 ComponentA 有什么可加载的?

这个例子比较简单,但实际开发并不如此,SSR 只能渲染出无交互的 HTML,还需要再加载一个 JS 文件,用于给比如 HTML 元素上添加事件,使其具有交互能力等(这个过程又称为水合)。所以使用懒加载的组件 Next.js 会打包一个单独的 bundle。

3. 导入命名导出(Named Exports)

JavaScript 支持两种导出方式:默认导出(default export)和命名导出(named export)。

// 默认导出
export default function add(a, b) {
  return a + b;
}
// 命名导出
export function add(a, b) {
  return a + b;
}

如果要动态导入一个命名导出的组件,用法会略有不同,直接举个示例代码:

假如要导入 Hello 组件,然而 Hello 组件以命名导出的形式导出:

'use client'
// components/hello.js
export function Hello() {
  return <p>Hello!</p>
}

关键字 import 可以像调用函数一样来动态的导入模块。以这种方式调用,将返回一个 promise,将模块作为对象传入 then 函数:

// app/page.js
import dynamic from 'next/dynamic'
 
const ClientComponent = dynamic(() =>
  import('../components/hello').then((mod) => mod.Hello)
)

加载外部库

使用 import()函数可以按需加载外部库,比如当用户在搜索框输入的时候才开始加载模糊搜索库,这个例子就演示了如何使用 fuse.js 实现模糊搜索。

'use client'
// app/page.js
import { useState } from 'react'
 
const names = ['Tim', 'Joe', 'Bel', 'Lee']
 
export default function Page() {
  const [results, setResults] = useState()
 
  return (
    <div>
      <input
        type="text"
        placeholder="Search"
        onChange={async (e) => {
          const { value } = e.currentTarget
          const Fuse = (await import('fuse.js')).default
          const fuse = new Fuse(names)
 
          setResults(fuse.search(value))
        }}
      />
      <pre>Results: {JSON.stringify(results, null, 2)}</pre>
    </div>
  )
}

谈谈懒加载

本篇的最后,我们简单聊聊在开发中使用懒加载的感受。Next.js 中的懒加载看似很好,但其实应用中有很多局限。就以刚才的例子为例:

'use client'
 
import { useState } from 'react'
import dynamic from 'next/dynamic'
 
const ComponentA = dynamic(() => import('../components/a.js'))
const ComponentB = dynamic(() => import('../components/b.js'))
const ComponentC = dynamic(() => import('../components/c.js'), { ssr: false })
 
export default function ClientComponentExample() {
  const [showMore, setShowMore] = useState(false)
 
  return (
    <div>
      <ComponentA />
      {showMore && <ComponentB />}
      <button onClick={() => setShowMore(!showMore)}>Toggle</button>
      <ComponentC />
    </div>
  )
}

首先,为了实现懒加载,我们需要将组件抽离到单独的文件中,这样做虽然有些繁琐,倒也可以接受。

其次,在这个例子中,其实只有 ComponentB 应用懒加载是有用的,ComponentA 和 ComponentC 应用懒加载,会导致初始加载的时候多加载两个 bundle,反而因为浏览器同时请求多个 bundle 降低了加载速度。所以懒加载的例子都是应用于那些初始并不渲染的组件。

最后,为了保证用户有一个流畅的体验,其实我们并不希望交互的时候才开始获取 JS,代码其实是应该预获取的。但是预获取的逻辑是需要开发者自己定义的。这就造成了更多的工作量。

参考链接

  1. https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/dynamic.tsx
  2. https://www.builder.io/blog/the-challenges-of-lazy-loading-in-javascript
  3. https://www.fullctx.dev/p/lazy-loading-in-react-and-nextjs
© Copyright 2025 JackyLove 的技术人生. Powered with by CreativeDesignsGuru