懒加载,英文:Lazy Loading,又被称为“延迟加载”,其重要性不言而喻。这是因为随着互联网的发展,网页资源大小在快速增长,为了提高加载的速度,带来更好的用户体验,便产生了懒加载这种技术理念,减少初始加载的资源,让部分资源等到合适的时候再去加载。
Next.js 基于懒加载做了很多优化,实现了延迟加载客户端组件和导入库,只在需要的时候才在客户端引入它们。举个例子,比如延迟加载模态框相关的代码,直到用户点击打开的时候。
在 Next.js 中有两种方式实现懒加载:
React.lazy()
和 Suspense
next/dynamic
实现动态导入默认情况下,服务端组件自动进行代码分隔,并且可以使用流将 UI 片段逐步发送到客户端,所以懒加载应用于客户端。
我们先讲讲 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 选择框,showPreview
为 true
的时候才去加载 <Suspense>
和 <MarkdownPreview>
组件,这是更符合实际开发中的例子。
效果如下:
不过开发 Next.js 应用的时候,大部分时候并不需要用到 React.lazy 和 Suspense,使用 next/dynamic
即可,它本质就是 React.lazy 和 Suspense 的复合实现。在 app
和 pages
目录下都可以使用。
它的基本用法如下:
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。看似很简单,但使用的时候也有很多细节要注意:
前面我们讲过懒加载只应用于客户端的,如果动态导入的是一个服务端组件,只有这个服务端组件中的客户端组件才会被懒加载,服务端组件本身是不会懒加载的。
// app/page.js
import dynamic from 'next/dynamic'
// Server Component:
const ServerComponent = dynamic(() => import('../components/ServerComponent'))
export default function ServerComponentExample() {
return (
<div>
<ServerComponent />
</div>
)
}
之前讲客户端组件和服务端组件的时候,客户端组件默认也是会被预渲染的(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>
}
加载效果如下:
从效果上看,设置 ssr
为 false
的 <ComponentC>
会比 <ComponentA>
晚显示,<ComponentB>
在点击的时候才会显示。
这三个组件的加载到底有什么区别呢?
首先是预渲染,ComponentA
默认会被预渲染,ComponentC
因为设置了 ssr
为 false
,不会被预渲染,也就是说,如果查看页面的 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,三个动态加载的组件都会打包成一个单独的包,ComponentA
和 ComponentC
的包都会尽快加载,CompoentB 的包会在点击按钮的时候才加载:
那你可能要问了,这个组件不就一个 Hello World!吗?ComponentB
在点击的时候才渲染,确实需要加载。ComponentC
只有一个占位,也确实需要加载,可 ComponentA
有什么可加载的?
这个例子比较简单,但实际开发并不如此,SSR 只能渲染出无交互的 HTML,还需要再加载一个 JS 文件,用于给比如 HTML 元素上添加事件,使其具有交互能力等(这个过程又称为水合)。所以使用懒加载的组件 Next.js 会打包一个单独的 bundle。
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,代码其实是应该预获取的。但是预获取的逻辑是需要开发者自己定义的。这就造成了更多的工作量。