背景
这是 Next.js 实现原理系列的第 3 篇,承接之前对 Link 和 Router 的讨论,来看看页面渲染的原理。
本篇主要回答下面这个问题:
- 在 Next.js 中,切换 Page 时,为什么页面上不变的部分也会被重新渲染?
搞懂了这个问题,也就能大致明白 Next.js 页面渲染的原理了。
注:本系列文章基于 Next.js v12.0.4 版本。
切换 Page 时,为什么页面上不变的部分也会被重新渲染?
你也许注意过,在 Next.js 中切换 Page 时,页面上即使是不变的部分(例如 header)也会被重新渲染,这一点和常规的 React SPA 完全不同。如下图所示:
上面这个例子使用的是 Next.js 的官方网站 https://nextjs.org,它本身就是使用 Next.js 搭建的。可以看到,如果你把 header 的背景色修改为黄色,再切换到另一个页面,header 的黄色就不见了,说明它被重新渲染过。
这不能不说有些令人意外。虽然看不到源码,但这两个页面显然使用的应该是同一个 header 组件,没有任何理由应该重新渲染一遍。为了确认这一点,我自己新建了一个 project 试了一下,发现如果有 pages/a.js
和 pages/b.js
这两个 page,并且它们都引用了同一个组件 C,那么在 a 和 b 之间通过链接切换时,C 的确会重新渲染。
你可能会说,是因为 Next.js 在服务端渲染吧!并非如此,因为当你第一次访问这个网站的时候,拿到的 HTML 确实是在服务端渲染好的,但在进行后续的站内页面切换时,Next.js 采用的也是客户端渲染,换句话说和常规的 React SPA 应用没有区别(关于这一点可以参考我的另一篇文章:一箭双雕:浅谈 Next.js 页面导航(page navigation)原理)。如果你用 create-react-app
创建一个常规的 React SPA 应用,就绝对不会出现这样的事情。
当然了,Next.js 中并不是没有避免相同组件重复渲染的方法!只不过上面的例子没有使用而已。如果你希望 header 不要重复渲染,那么可以新建一个 pages/_app.js
文件:
// pages/_app.js
import React from 'react';
import Layout from '../components/Layout';
function MyApp({ Component, pageProps }) {
return (
<Layout>
<Component {...pageProps} />
</Layout>
)
}
export default MyApp
然后把 header 相关的东西放到 <Layout />
组件中就可以了。这样的确能够实现预期的效果。关于这个方法,可以参考官方文档中的说明:https://nextjs.org/docs/advanced-features/custom-app,但文档也没提到为什么这个方法就能奏效。
下面我们就来看一下,为什么正常情况下相同组件也会重复渲染,以及为什么使用自定义 App 组件能够解决这个问题。
源码片段:doRender
那么,当你点击一个 Next.js 的 Link 时,到底发生了什么呢?经过中间层层的函数调用,最终 packages/next/client/index.tsx
下的 doRender
函数会被调用。下面是注释之后的相关代码片段:
// packages/next/client/index.tsx
// 171 行
// Next.js 会把 <div id='__next'></div> 这个元素作为根元素,渲染新页面。所以 Next.js 应用中都可以找到这个元素。
const appElement: HTMLElement | null = document.getElementById('__next')
// 省略中间部分
// 705 行
// 渲染新页面
function doRender(input: RenderRouteInfo): Promise<any> {
// 省略中间部分
// 714 行
// 这里的 Component 就是要渲染的新页面,对应你在应用的 /pages 目录下定义的某个页面组件
const appProps: AppProps = {
...props,
Component: isRSC ? RSCComponent : Component,
err,
router,
}
// 省略中间部分
// 858 行
// App 组件就是你在应用的 pages/_app.js 中定义的组件
const elem: JSX.Element = (
<>
<Head callback={onHeadCommit} />
<AppContainer>
{/* 这里是关键! */}
<App {...appProps} />
<Portal type="next-route-announcer">
<RouteAnnouncer />
</Portal>
</AppContainer>
</>
)
// 871 行
// appElement 就是 <div id='__next'></div> 容器元素,定义见上面。这里会在该容器元素中渲染新页面
renderReactElement(appElement!, (callback) => (
<Root callbacks={[callback, onRootCommit]}>
{process.env.__NEXT_STRICT_MODE ? (
<React.StrictMode>{elem}</React.StrictMode>
) : (
elem
)}
</Root>
))
那么这个 doRender
函数到底干了啥呢?如果忽略细节,用最简单的话来说就是:
- 找到 DOM 中
<div id='__next'></div>
这个容器元素; - 在这个容器元素里,渲染
<App {...appProps} />
这个组件;
第一点比较好理解:每个 Next.js 应用都是在 <div id='__next'></div>
这个容器元素内被渲染的,因此也可以将 “DOM 中是否存在 <div id='__next'></div>
这个容器元素” 作为“该网站是否基于 Next.js”的判断标准。
第二点比较麻烦,什么是 <App {...appProps} />
?这需要拆开来看:
App
指的就是pages/_app.js
中定义的App
组件。如果使用create-next-app
创建 Next 应用,会自动生成pages/_app.js
文件,不过即便你把这个文件删了也没关系,Next.js 会使用默认的App
组件(其定义和create-next-app
默认生成的相同);appProps
中最重要的 prop 是Component
,也就是新页面所对应的组件,即你在pages/
目录下面定义的相应的页面组件;
首先让我们看一下 create-next-app
默认生成的 pages/_app.js
:
import '../styles/globals.css'
function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />
}
export default MyApp
也就是说,如果使用默认生成的 pages/_app.js
,那么渲染 <App {...appProps} />
就相当于渲染 <Component {...pageProps} />
,其中 Component
是 appProps
中的一个 prop,pageProps
则是其余的 prop。在这种情况下,doRender
的作用可以进一步简化为:
- 找到 DOM 中
<div id='__next'></div>
这个容器元素; - 在这个容器元素里,渲染
<Component {...pageProps} />
这个组件,Component
即是当前页面对应的页面组件;
问题的关键:React 的 diff 算法
上面说到,每次切换页面时,Next.js 会在 <div id='__next'></div>
这个容器元素内,渲染 <Component {...pageProps} />
。
通过查看上面源码片段中 renderReactElement
函数的定义可以发现,在切换页面时,用来渲染 <Component {...pageProps} />
的是 ReactDOM.render
函数。
所以在调用 renderReactElement
函数之后,React 会比较新旧两个的 React 元素树之间的区别,根据 diff 算法,适当对 DOM 元素进行重新渲染或者更新。
重点来了:如果你了解过 React 的 diff 算法,就很可能会记得:为了简化算法复杂度,React 在遇到新元素的类型和旧元素的类型不同时,会直接舍弃该元素下面的整个元素树,重新渲染。
这样一来,问题终于浮出水面了!回到我们的假设,有两个 page 分别定义在 pages/a.js
和 pages/b.js
中,它们都引用了相同的 header 组件 C。那么从 page A 切换到 page B 时,如果忽略不重要的外层元素,那么旧的元素树就是:
// 旧的元素树(忽略外层元素)
// 对页面 A 来说,Component 即是 pages/a.js 中定义的 A 组件
<A {...pageProps} />
新的元素树就是:
// 新的元素树(忽略外层元素)
// 对页面 B 来说,Component 即是 pages/b.js 中定义的 B 组件
<B {...pageProps} />
可以看到,从组件 <A />
到 <B />
,元素(这里相当于是组件)的类型已经发生了变化!这个时候 React 会选择直接重新渲染这一部分的 DOM,所以尽管 <A />
和 <B />
下面都有相同的 header 组件 C,C 对应的 DOM 部分也会被重新渲染!
为什么自定义 App
组件可以解决这个问题
明白了上面的问题之后,也就不难理解为什么自定义 App
组件可以解决这个问题了:通过自定义 App
组件,你可以把不随页面改变的组件放在 <Component {...pageProps} />
之外,这样即便 <Component {...pageProps} />
每次重新渲染,也不会影响到你那一部分的不变组件了。
小结
由于 Next.js 的设计,以及 React 的 diff 算法本身,每次切换页面时,页面组件都会被重新渲染。如果想要避免页面上的某个部分重新渲染,就不能把它放到相应的页面组件内,而是放到自定义的 App
组件内。