Next.js 实现原理系列(3):页面渲染

本文深入探讨了Next.js中页面切换时,即使不变的部分如header也会被重新渲染的原因。问题的关键在于React的diff算法,当组件类型变化时,React会重新渲染整个子树。通过自定义_App组件,将不变的部分移出,可以避免这种情况。文章通过源码分析,详细解释了Next.js页面渲染的工作原理。
摘要由CSDN通过智能技术生成

背景

这是 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.jspages/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 函数到底干了啥呢?如果忽略细节,用最简单的话来说就是:

  1. 找到 DOM 中 <div id='__next'></div> 这个容器元素;
  2. 在这个容器元素里,渲染 <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} />,其中 ComponentappProps 中的一个 prop,pageProps 则是其余的 prop。在这种情况下,doRender 的作用可以进一步简化为:

  1. 找到 DOM 中 <div id='__next'></div> 这个容器元素;
  2. 在这个容器元素里,渲染 <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.jspages/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 组件内。

参考连接

React 的 diff 算法

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值