普通视图

发现新文章,点击刷新页面。
昨天以前首页

nextjs学习6:服务端组件和客户端组件

2026年1月5日 11:14

服务端组件和客户端组件是 Next.js 中非常重要的概念。

如果没有细致的了解过,你可能会简单的以为所谓服务端组件就是 SSR,客户端组件就是 CSR,服务端组件在服务端进行渲染,客户端组件在客户端进行渲染等等,实际上并非如此。

本篇就深入学习和探究 Next.js 的双组件模型

服务端组件

介绍

在 Next.js 中,组件默认就是服务端组件服务端组件一般会在function 前面加上async(不加也行)。往往意味着你需要利用服务端能力(比如异步数据获取),而 Next.js 的默认规则会让这类组件天然运行在服务端。

举个例子,新建 app/todo/page.js,代码如下:

export default async function Page() {
  const res = await fetch('https://jsonplaceholder.typicode.com/todos')
  const data = (await res.json()).slice(0, 10)
  console.log(data)
  return <ul>
    {data.map(({ title, id }) => {
      return <li key={id}>{title}</li>
    })}
  </ul>
}

请求会在服务端执行,并将渲染后的 HTML 发送给客户端:

image.png

因为在服务端执行,console 打印的结果也只可能会出现在命令行中,而非客户端浏览器中

优势

  1. 数据获取:通常服务端环境(网络、性能等)更好,离数据源更近,在服务端获取数据会更快。通过减少数据加载时间以及客户端发出的请求数量来提高性能。

  2. 安全:在服务端保留敏感数据和逻辑,不用担心暴露给客户端。服务端组件不会生成客户端 Chunk.js仅在服务端渲染为 HTML,代码不暴露给浏览器)。

  3. bundle 大小:服务端组件的代码不会打包到 bundle 中,减少了 bundle 包的大小。

  4. 初始页面加载和 FCP:服务端渲染生成 HTML,快速展示 UI。

  5. Streaming:服务端组件可以将渲染工作拆分为 chunks,并在准备就绪时将它们流式传输到客户端。用户可以更早看到页面的部分内容,而不必等待整个页面渲染完毕。

因为服务端组件的诸多好处,在实际项目开发的时候,能使用服务端组件就尽可能使用服务端组件

限制

虽然使用服务端组件有很多好处,但使用服务端组件也有一些限制,比如不能使用 useState 管理状态,不能使用浏览器的 API 等等。

RSC 与 SSR

了解了这两个基本概念,现在让我们来回顾下 React Server Components 和 Server-side Rendering,表面上看,RSC 和 SSR 非常相似,都发生在服务端,都涉及到渲染,目的都是更快的呈现内容。但实际上,这两个技术概念是相互独立的。

正如它们的名字所表明的那样,Server-side Rendering 的重点在于 Rendering,React Server Components 的重点在于 Components

简单来说:

  • RSC 提供了更细粒度的组件渲染方式,可以在组件中直接获取数据,而不用像传统的 SSR 顶层获取数据
  • RSC 在服务端进行渲染,组件依赖的代码不会打包到 bundle 中,而 SSR 需要将组件的所有依赖都打包到 bundle 中

当然两者最大的区别是:

SSR 是在服务端将组件渲染成 HTML 发送给客户端,而 RSC 是将组件渲染成一种特殊的格式,我们称之为 RSC Payload

这个 RSC Payload 的渲染是在服务端,但不会一开始就返回给客户端,而是在客户端请求相关组件的时候才返回给客户端,RSC Payload 会包含组件渲染后的数据和样式,客户端收到 RSC Payload 后会重建 React 树,修改页面 DOM。

让我们本地开启一下当时 React 提供的 Server Components Demo:

image.png

你会发现 localhost 这个 HTML 页面的内容就跟 CSR 一样,都只有一个用于挂载的 DOM 节点。当点击左侧 Notes 列表的时候,会发送请求,这个请求的地址是http://localhost:4000/react?location={"selectedId":3,"isEditing":false,"searchText":""}

返回的结果是:

image.png

除此之外没有其他的请求了。其实这条请求返回的数据就是 RSC Payload。

让我们看下这条请求,我们请求的这条笔记的标题是 Make a thing,具体内容是 It's very easy to make some……,我们把返回的数据具体查看一下,你会发现,返回的请求里包含了这些数据:

image.png

不仅包含数据,完整渲染后的 DOM 结构也都包含了。

客户端收到 RSC Payload 后就会根据这其中的内容修改 DOM。而且在这个过程,页面不会刷新,页面实现了 partial rendering(部分更新)

这也就带来了我们常说的 SSR 和 RSC 的最大区别,那就是状态的保持。SSR 每次都是一个新的 HTML 页面,所以状态不会保持(传统的做法是 SSR 初次渲染,然后 CSR 更新,这种情况,状态可以保持,不过现在讨论的是 SSR,对于两次 SSR,状态是无法维持的)。

但是 RSC 不同,RSC 会被渲染成一种特殊的格式(RSC Payload),可以多次重新获取,然后客户端根据这个特殊格式更新 UI,而不会丢失客户端状态。

客户端组件

使用客户端组件,你需要在文件顶部添加一个 "use client" 声明,修改 app/todo/page.js,代码如下:

'use client'

import { useEffect, useState } from 'react';

function getRandomInt(min, max) {
  const minCeiled = Math.ceil(min);
  const maxFloored = Math.floor(max);
  return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled);
}

export default function Page() {

  const [list, setList] = useState([]);

  const fetchData = async () => {
    const res = await fetch('https://jsonplaceholder.typicode.com/todos')
    const data = (await res.json()).slice(0, getRandomInt(1, 10))
    setList(data)
  }

  useEffect(() => {
    fetchData()
  }, [])

  return (
    <>
      <ul>
        {list.map(({ title, id }) => {
          return <li key={id}>{title}</li>
        })}
      </ul>
      <button onClick={() => {
        location.reload()
      }}>换一批</button>
    </>
  )
}

在这个例子中,我们使用了 useEffect、useState 等 React API,也给按钮添加了点击事件、使用了浏览器的 API。无论使用哪个都需要先声明为客户端组件。

注意:"use client"用于声明服务端和客户端组件模块之间的边界。当你在文件中定义了一个 "use client",导入的其他模块包括子组件,都会被视为客户端 bundle 的一部分。

它的优势是:

  1. 交互性:客户端组件可以使用 state、effects 和事件监听器,意味着用户可以与之交互;
  2. 浏览器 API:客户端组件可以使用浏览器 API 如地理位置、localStorage 等;

服务端组件 VS 客户端组件

1、如何选择使用?

image.png

组件类型 执行 / 渲染位置 核心特征
服务端组件(SC) 仅在服务端(Node.js 环境)执行,渲染为 HTML 片段 / React 服务端数据结构 可直接访问数据库、后端接口,无浏览器 API 限制,代码不会发送到客户端
客户端组件(CC) 先在服务端做 “首屏渲染”(生成 HTML),再在客户端(浏览器)hydrate(水合)并运行 可使用 useState/useEffect 等 Hooks、访问 window/document,代码会打包发送到客户端

2、渲染环境

服务端组件只会在服务端渲染,但客户端组件会在服务端渲染一次,然后在客户端渲染。

这是什么意思呢?让我们写个例子,新建 app/client/page.js,代码如下:

'use client'

import { useState } from 'react';

console.log('client')

export default function Page() {

  console.log('client Page')

  const [text, setText] = useState('init text');

  return (
    <button onClick={() => {
      setText('change text')
    }}>{text}</button>
  )
}

新建 app/server/page.js,代码如下:

console.log('server')

export default function Page() {

  console.log('server Page')

  return (
    <button>button</button>
  )
}

现在运行 npm run build,会打印哪些数据呢?

答案是无论客户端组件还是服务端组件,都会打印:

image.png

而且根据输出的结果,无论是 /client还是 /server走的都是静态渲染。

当运行 npm run start的时候,又会打印哪些数据呢?

答案是命令行中并不会有输出,访问 /client的时候,浏览器会有打印:

image.png

访问 /server的时候,浏览器不会有任何打印:

image.png

客户端组件在浏览器中打印,这可以理解,毕竟它是客户端组件,当然要在客户端运行。可是客户端组件为什么在编译的时候会运行一次呢?

让我们看下 /client 的返回:

image.png

你会发现 init text其实是来自于 useState 中的值,但是却依然输出在 HTML 中。

这就是编译客户端组件的作用,为了第一次加载的时候能更快的展示出内容。

所以,其实所谓服务端组件、客户端组件并不直接对应于物理上的服务器和客户端。服务端组件运行在构建时和服务端,客户端组件运行在构建时、服务端(生成初始 HTML)和客户端(管理 DOM)

3、交替使用服务端组件和客户端组件

实际开发的时候,不可能纯用服务端组件或者客户端组件,当交替使用的时候,一定要注意一点,那就是:

服务端组件可以直接导入客户端组件,但客户端组件并不能导入服务端组件

1. 服务端组件能导入客户端组件:符合 “渲染流向”

服务端组件的核心作用是在服务端组装页面骨架、获取数据,而客户端组件是为了处理交互(点击、输入、状态)。Next.js 设计时,把 SC 作为 “页面的根 / 容器”,CC 作为 “交互子节点”,这种 “父(SC)包含子(CC)” 的结构完全契合渲染逻辑

执行过程

  1. 服务端执行 SC 时,遇到导入的 CC,不会直接执行CC 的代码(CC 的代码是给浏览器用的),而是将 CC 标记为 需要客户端水合的组件;
  2. 服务端把 SC 渲染为 HTML 片段,同时把 CC 的占位标记和 CC 的打包代码路径一起发给客户端;
  3. 客户端接收到页面后,先渲染 SC 生成的静态内容,再加载 CC 的代码并完成水合,让 CC 具备交互能力。

整个React 树会变成这样:

image.png

其中黄色节点表示 React Server Component。在服务端,React 会将其渲染会一个包含基础 HTML 标签和客户端组件占位的树。

因为客户端组件的数据和结构在客户端渲染的时候才知道,所以客户端组件此时在树中使用特殊的占位进行替代。

当然这个树不可能直接就发给客户端,React 会做序列化处理,客户端收到后会在客户端根据这个数据重构 React 树,然后用真正的客户端组件填充占位,渲染最终的结果。

image.png

2. 客户端组件不能能导入客户端组件

'use client'
 
// 这是不可以的
import ServerComponent from './Server-Component'
 
export default function ClientComponent({ children }) {
  const [count, setCount] = useState(0)
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
 
      <ServerComponent />
    </>
  )
}

正如介绍客户端组件时所说:

"use client"用于声明服务端和客户端组件模块之间的边界。当你在文件中定义了一个 "use client",导入的其他模块包括子组件,都会被视为客户端 bundle 的一部分。

组件默认是服务端组件,但当组件导入到客户端组件中会被认为是客户端组件。客户端组件不能导入服务端组件,其实是在告诉你,如果你在服务端组件中使用了诸如 Node API 等,该组件可千万不要导入到客户端组件中

另外,渲染逻辑闭环被打破,Next.js 的渲染逻辑是 “服务端先处理静态 / 数据层(SC)→ 客户端再处理交互层(CC)”,是单向的 “服务端 → 客户端” 流向。如果允许 CC 导入 SC,相当于让 “客户端” 反向控制 “服务端”。

但你可以将服务端组件以 props 的形式传给客户端组件:

'use client'
 
import { useState } from 'react'
 
export default function ClientComponent({ children }) {
  const [count, setCount] = useState(0)
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      {children}
    </>
  )
}


import ClientComponent from './client-component'
import ServerComponent from './server-component'
 
export default function Page() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  )
}

使用这种方式,<ClientComponent> 和 <ServerComponent> 代码解耦且独立渲染。

4、组件渲染原理

1. 在服务端

Next.js 使用 React API 编排渲染,渲染工作会根据路由和 Suspense 拆分成多个块(chunks),每个块分两步进行渲染:

  1. React 将服务端组件渲染成一个特殊的数据格式称为 React Server Component Payload (RSC Payload)
  2. Next.js 使用 RSC Payload 和客户端组件代码在服务端渲染 HTML;

RSC payload 中包含如下这些信息:

  1. 服务端组件的渲染结果
  2. 客户端组件占位符和引用文件
  3. 从服务端组件传给客户端组件的数据

为什么会包含“客户端组件占位符和引用文件”呢?

1. 占位符:告诉客户端 “这里有个需要交互的组件,先留位置”

其实在上面我们已经说了,服务端组件(SC)执行在服务端,客户端组件(CC)执行在浏览器,两者的职责边界是:SC 负责搭骨架,CC 负责加交互。但 SC 在服务端渲染时,根本无法执行 CC 的代码(CC 依赖浏览器 API、React 状态等),只能做 “标记”,这就是 “占位符 + 引用文件” 的核心作用。

SC 渲染时,遇到导入的 CC,不会生成 CC 的真实 DOM(因为 CC 还没在客户端激活),而是生成一个特殊的占位标记(RSC 协议里的 JSON 标记) ,比如:

// 简化的 RSC payload 片段
{ 
    "type": "client.component",
    "id": "cc-123", // 唯一标识 
    "fallback": "<div>加载中...</div>" // 可选的占位内容 
}

这个占位符的作用:

  • 保证页面结构完整:客户端拿到 payload 后,先渲染 SC 生成的静态内容 + CC 的占位符,不会出现 “交互组件位置空白” 的情况,避免布局错乱;
  • 标记待激活区域:告诉 React 运行时这个位置的组件需要后续加载客户端代码并水合,是客户端激活 CC 的 锚点。

2. 引用文件:告诉客户端去哪找这个 CC 的交互代码

CC 的代码会被 Next.js 打包成独立的客户端 JS 包(比如 static/chunks/cc-123.js),RSC payload 中会附带这个包的引用路径和哈希值,比如:

{ 
   "type": "client.reference", 
   "id": "cc-123", 
   "filePath": "/_next/static/chunks/cc-123.js", 
   "name": "ClientButton" 
}

这个引用的核心价值:

  • 按需加载:客户端只会加载页面中实际用到的 CC 代码,而不是把所有 CC 代码都打包进首屏(比如页面有 10 个 CC,但首屏只显示 2 个,就只加载这 2 个的代码),减少客户端 JS 体积;
  • 精准激活:React 运行时根据引用路径下载对应的 CC 代码后,能精准替换掉之前的占位符,完成 CC 的水合(让 CC 具备 useState/useEffect 等交互能力);
  • 版本控制:通过哈希值(比如 cc-123.abc123.js)实现缓存复用,后续页面如果用到同一个 CC,客户端不用重复下载。

为什么包含从服务端组件传给客户端组件的数据?

如果 RSC payload 不附带这份数据,CC 激活后只能自己通过 fetch 去请求相同的数据,会导致:

  • 重复的网络请求: 服务端查一次数据库,客户端又查一次,浪费服务器资源;
  • 额外的网络延迟:CC 要等 fetch 返回才能渲染,出现 “占位符→加载中→真实内容” 的二次等待。而 SC 把数据直接塞进 payload,CC 激活后能直接用。

2. 在客户端

  1. 加载渲染的 HTML 快速展示一个非交互界面(Non-interactive UI)
  2. RSC Payload 会被用于协调(reconcile)客户端和服务端组件树,并更新 DOM
  3. JavaScript 代码被用于水合客户端组件,使应用程序具有交互性(Interactive UI)

image.png

注意:上图描述的是页面初始加载的过程。其中 SC 表示 Server Components 服务端组件,CC 表示 Client Components 客户端组件。

在前一篇文章中讲到 Suspense 和 Streaming 也有一些问题没有解决,比如该加载的 JavaScript 代码没有少、所有组件都必须水合,即使组件不需要水合。

使用服务端组件和客户端组件就可以解决这个问题,服务端组件的代码不会打包到客户端 bundle 中。渲染的时候,只有客户端组件需要进行水合,服务端组件无须水合

而在后续导航的时候:

image.png

后续导航(客户端路由导航)  则是 Next.js 基于 next/navigation(App Router)实现的客户端侧无刷新导航,核心是 “按需加载资源 + 局部更新页面 + 保留客户端状态”,全程不触发浏览器的整页刷新。

核心前提:后续导航的触发条件

用户点击 Next.js 提供的 <Link> 组件(而非原生 <a> 标签)、调用 useRouter().push()/replace() 等客户端路由方法时,会触发后续导航;

如果直接刷新页面 / 输入 URL,仍会走首次导航流程。

完整流程(App Router)

  • Next.js 的客户端路由运行时(next/navigation 底层)会拦截 <Link> 点击事件,阻止浏览器的默认页面跳转(event.preventDefault());

  • 客户端向服务端发起一个轻量的 RSC 请求(不是整页 HTML 请求),请求目标路由的 Server Components 渲染结果(即 RSC Payload,格式是特殊的 JSON 流);这个请求只会获取目标路由的 Server Components 渲染出的静态内容、客户端组件的占位符 + 代码引用、服务端传给客户端组件的数据

  • React 运行时接收 RSC Payload 后,RSC Payload 内容如下:

image.png

不仅包含数据,完整渲染后的 DOM 结构也都包含了。

客户端收到 RSC Payload 后就会根据这其中的内容修改 DOM。而且在这个过程,页面不会刷新,页面实现了 partial rendering(部分更新)

也就是,先渲染 SC 生成的静态内容,替换当前页面的主内容区域,同时保留页面的公共布局(比如导航栏、页脚),这就是局部更新,公共部分不重新渲染。

如果目标路由包含新的客户端组件(未在当前页面加载过),Next.js 会根据 RSC Payload 中的 “客户端组件引用路径”,异步加载对应的客户端 JS 包(体积很小,按需加载);已加载过的客户端组件会复用缓存,不会重复下载。

  • 对客户端组件来说:先渲染占位符(比如加载中),等对应的 JS 包下载完成后,完成 “水合”(激活交互,比如 useState/useEffect 生效),替换占位符为真实交互组件;

  • 整个过程中,页面的 <head> 标签(标题、meta 等)会被 Next.js 自动更新(基于目标路由的 generateMetadata 或 metadata 配置),但不会刷新页面

  • Next.js 调用浏览器的 history.pushState()/replaceState() API,更新地址栏 URL,但不会触发浏览器的 popstate 整页刷新;

  • 客户端状态(比如全局 Redux 状态、组件内的 useState、表单输入值)会被保留(除非主动重置),比如从 /home 跳转到 /post/123,导航栏的登录状态、全局主题设置不会丢失。

nextjs学习3:动态路由、路由组、平行路由

2025年12月31日 14:50

动态路由(Dynamic Routes)

有的时候,你并不能提前知道路由的地址,就比如根据 URL 中的 id 参数展示该 id 对应的文章内容,文章那么多,我们不可能一一定义路由,这个时候就需要用到动态路由。

[folderName]

使用动态路由,你需要将文件夹的名字用方括号括住,比如 [id][slug]。这个路由的名字会作为 params prop 传给布局、页面、路由处理程序等。

举个例子,我们在 app/blog 目录下新建一个名为 [slug] 的文件夹,在该文件夹新建一个 page.js 文件,代码如下:


// app/blog/[slug]/page.js
export default function Page({ params }) {
  return <div>My Post: {params.slug}</div>
}

当你访问 /blog/a的时候,params 的值为 { slug: 'a' }; 当你访问 /blog/yayu的时候,params 的值为 { slug: 'yayu' }

 [...folderName]

在命名文件夹的时候,如果你在方括号内添加省略号,比如 [...folderName],这表示捕获所有后面所有的路由片段。

也就是说,app/shop/[...slug]/page.js会匹配 /shop/clothes,也会匹配 /shop/clothes/tops/shop/clothes/tops/t-shirts等等。

// app/shop/[...slug]/page.js
export default function Page({ params }) {
  return <div>My Shop: {JSON.stringify(params)}</div>
}

当你访问 /shop/a的时候,params 的值为 { slug: ['a'] }

当你访问 /shop/a/b的时候,params 的值为 { slug: ['a', 'b'] }

当你访问 /shop/a/b/c的时候,params 的值为 { slug: ['a', 'b', 'c'] }

路由组(Route groups)

在 app目录下,文件夹名称通常会被映射到 URL 中,但你可以将文件夹标记为路由组,阻止文件夹名称被映射到 URL 中。

使用路由组,你可以将路由和项目文件按照逻辑进行分组,但不会影响 URL 路径结构。路由组可用于比如:

  1. 按站点、意图、团队等将路由分组
  2. 在同一层级中创建多个布局,甚至是创建多个根布局

那么该如何标记呢?

把文件夹用括号括住就可以了,就比如 (dashboard)

按逻辑分组

将路由按逻辑分组,但不影响 URL 路径:

image.png

你会发现,最终的 URL 中省略了带括号的文件夹(上图中的(marketing)(shop))。

创建不同布局

借助路由组,即便在同一层级,也可以创建不同的布局:

image.png

在这个例子中,/account 、/cart/checkout 都在同一层级。但是 /account和 /cart使用的是 /app/(shop)/layout.js布局和app/layout.js布局,/checkout使用的是 app/layout.js

平行路由(Parallel Routes):vue中的插槽

平行路由可以使你在同一个布局中同时或者有条件的渲染一个或者多个页面(类似于 Vue 的插槽功能)。

本质是一种控制页面渲染区域的机制,允许在同一个 URL 下同时渲染多个独立的路由组件

实现多区域独立渲染,比如侧边栏、主内容区、弹窗等区域各自对应不同的路由,且互不干扰。

用途 1:条件渲染

image.png

平行路由的使用方式是将文件夹以 @作为开头进行命名,比如在上图中就定义了两个插槽 @team 和 @analytics

插槽会作为 props 传给共享的父布局。在上图中,app/layout.js 从 props 中获取了 @team 和 @analytics 两个插槽的内容,并将其与 children 并行渲染:

// app/layout.js
// 这里我们用了 ES6 的解构,写法更简洁一点
export default function Layout({ children, team, analytics }) {
  return (
    <>
      {children}
      {team}
      {analytics}
    </>
  )
}

注:从这张图也可以看出,children prop 其实就是一个隐式的插槽,/app/page.js相当于 app/@children/page.js

除了让它们同时展示,你也可以根据条件判断展示:

image.png

在这个例子中,先在布局中获取用户的登录状态,如果登录,显示 dashboard 页面,没有登录,显示 login 页面。这样做的一大好处就在于代码完全分离。

用途 2:独立路由处理

平行路由可以让你为每个路由定义独立的错误处理和加载界面:

image.png

用途 3:子导航

注意我们描述 team 和 analytics 时依然用的是“页面”这个说法,因为它们就像书写正常的页面一样使用 page.js。

除此之外,它们也能像正常的页面一样,添加子页面,比如我们在 @analytics 下添加两个子页面:/page-views and /visitors

image.png

平行路由跟路由组一样,不会影响 URL,所以 /@analytics/page-views/page.js 对应的地址是 /page-views/@analytics/visitors/page.js 对应的地址是 /visitors,你可以导航至这些路由:

// app/layout.js
import Link from "next/link";

export default function RootLayout({ children, analytics }) {
  return (
    <html>
      <body>
        <nav>
          <Link href="/">Home</Link>
          <br />
          <Link href="/page-views">Page Views</Link>
          <br />
          <Link href="/visitors">Visitors</Link>
        </nav>
        <h1>root layout</h1>
        {analytics}
        {children}
      </body>
    </html>
  );
}

当导航至这些子页面的时候,子页面的内容会取代 /@analytics/page.js 以 props 的形式注入到布局中

这也就是说,每个插槽都可以有自己独立的导航和状态管理,就像一个小型应用一样。这种特性适合于构建复杂的应用如 dashboard。

路由组和平行路由的区别

  1. 路由组是文件层面的组织工具,核心解决结构混乱分组布局复用问题,不改变渲染逻辑;
  2. 平行路由渲染层面的控制机制,核心解决 多区域并行渲染 问题,允许同一个 URL 下渲染多个独立的路由组件。

nextjs学习2:app router

2025年12月31日 14:27

本文是基于 nextjs v14 版本进行展示。

最快捷的创建 Next.js 项目的方式是使用 create-next-app脚手架,你只需要运行:

npx create-next-app@14

推荐使用 tailwindcss ,这也是 Next.js 推荐的 CSS 方案,很多 example 都会用它。

运行项目

查看项目根目录 package.json 文件的代码:

  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  }

开发的时候使用 npm run dev。部署的时候先使用 npm run build 构建生产代码,再执行 npm run start 运行生产项目。运行 npm run lint 则会执行 ESLint 语法检查。

next build

执行 next build 将会创建项目的生产优化版本:

image.png

从上图可以看出,构建时会输出每条路由的信息,比如 Size 和 First Load JS。注意这些值指的都是 gzip 压缩后的大小。其中 First Load JS 会用绿色、黄色、红色表示,绿色表示高性能,黄色或红色表示需要优化。

这里要解释一下 Size 和 First Load JS 的含义。

正常我们开发的 Next.js 项目,其页面表现类似于单页应用,即路由跳转(我们称之为“导航”)的时候,页面不会刷新,而会加载目标路由所需的资源然后展示,所以:

加载目标路由一共所需的 JS 大小 = 每个路由都需要依赖的 JS 大小(shared by all) + 目标路由单独依赖的 JS 大小

其中:

  • 加载目标路由一共所需的 JS 大小就是 First Load JS
  • 目标路由单独依赖的 JS 大小就是 Size
  • 每个路由都需要依赖的 JS 大小就是图中单独列出来的 First load JS shared by all

以上图中的 / 路由地址为例,87.6 kB(First Load JS)= 533 B(Size) + 87.1 kB(First load JS shared by all)

next start

生产模式下,使用 next start运行程序。不过要先执行 next build构建出生产代码。运行的时候,跟开发模式相同,程序默认开启在 http://localhost:3000。如果你想更改端口号:

npx next start -p 4000

app Router

app router 的目录结构类似于:

src/
└── app
    ├── page.js 
    ├── layout.js
    ├── template.js
    ├── loading.js
    ├── error.js
    └── not-found.js
    ├── about
    │   └── page.js
    └── more
        └── page.js

定义路由(Routes)

首先是定义路由,文件夹被用来定义路由。

每个文件夹都代表一个对应到 URL 片段的路由片段。创建嵌套的路由,只需要创建嵌套的文件夹。举个例子,下图的 app/dashboard/settings目录对应的路由地址就是 /dashboard/settings

image.png

定义页面(Pages)

那如何保证这个路由可以被访问呢?你需要创建一个特殊的名为 page.js 的文件。至于为什么叫 page.js呢?除了 page 有“页面”这个含义之外,你可以理解为这是一种约定或者规范。

image.png

  • app/page.js 对应路由 /
  • app/dashboard/page.js 对应路由 /dashboard
  • app/dashboard/settings/page.js 对应路由/dashboard/settings
  • analytics 目录下因为没有 page.js 文件,所以没有对应的路由。这个文件可以被用于存放组件、样式表、图片或者其他文件。

那 page.js 的代码该如何写呢?最常见的是展示 UI,比如:

// app/page.js
export default function Page() {
  return <h1>Hello, Next.js!</h1>
}

 定义布局(Layouts)

布局是指多个页面共享的 UI。

在导航的时候,布局会保留状态、保持可交互性并且不会重新渲染,比如用来实现后台管理系统的侧边导航栏。

定义一个布局,你需要新建一个名为 layout.js的文件,该文件默认导出一个 React 组件,该组件应接收一个 children prop,chidren 表示子布局(如果有的话)或者子页面。

举个例子,我们新建目录和文件如下图所示:

image.png

相关代码如下:

// app/dashboard/layout.js
export default function DashboardLayout({
  children,
}) {
  return (
    <section>
      <nav>nav</nav>
      {children}
    </section>
  )
}

// app/dashboard/page.js
export default function Page() {
  return <h1>Hello, Dashboard!</h1>
}

同一文件夹下如果有 layout.js 和 page.js,page 会作为 children 参数传入 layout。换句话说,layout 会包裹同层级的 page。

定义加载界面(Loading UI)

dashboard 目录下我们新建一个 loading.js

image.png

loading.js的代码如下:

// app/dashboard/loading.js
export default function DashboardLoading() {
  return <>Loading dashboard...</>
}

同级的 page.js 代码如下:

// app/dashboard/page.js
async function getData() {
  await new Promise((resolve) => setTimeout(resolve, 3000))
  return {
    message: 'Hello, Dashboard!',
  }
}

# 一定要是async定义的组件,不然loading.js不会生效
export default async function DashboardPage(props) {
  const { message } = await getData()
  return <h1>{message}</h1>
}

不再需要其他的代码,loading 的效果就实现了。

就是这么简单。其关键在于 page.js导出了一个 async 函数

loading.js 的实现原理是将 page.js和下面的 children 用 <Suspense> 包裹。因为page.js导出一个 async 函数,Suspense 得以捕获数据加载的 promise,借此实现了 loading 组件的关闭。

image.png

定义 404 页面

顾名思义,当该路由不存在的时候展示的内容。

Next.js 项目默认的 not-found 效果如下:

image.png

如果你要替换这个效果,只需要在 app 目录下新建一个 not-found.js,代码示例如下:

nextjs学习1:回顾服务端渲染SSR

2025年12月30日 19:49

服务端渲染的发展历史

传统的服务端渲染

传统的服务端渲染有 asp, jsp(java), ejs(nodejs)等,服务端语言往往通过这些模板引擎将数据 datahtml 结构在服务端组装,返回一个完整的静态的 html 字符串给客户端,由客户端直接显示。

缺点

  • 前后端不分离,前后端代码混在一个工程目录中,维护不方便,。
  • 用户体验不佳,每次页面有改动都需要重新加载整个页面。比如,一个列表页面,当用户增加一项时,后台需要重新组装数据 datahtml 结构,返回一个新的页面给前端,这样用户才能看到页面的变化。
  • 服务端压力大,不仅要响应静态 html 文件,还要响应数据api接口。

客户端渲染(CSR)

在现代化的前端项目中,客户端渲染的代表性技术栈是 vue/react/angular,我们常常使用它们来构建客户端单页或者多页应用程序。

SPA 构建程序为例,在浏览器端首先渲染的是一套空的 html,然后下载bundle.js并执行,通过 JavaScript 直接进行页面的渲染和路由跳转等操作,所有的数据通过 ajax 请求从服务端获取。

路由的跳转是通过history api 实现的,它最大的作用是能改变url地址,但是不会刷新页面,也就是不会发送请求。这样对用户操作来说就非常的无感,克服了传统服务端渲染每次都要请求服务器的困扰。

缺点

  • 首屏加载慢,因为第一次会请求一个空的html文件,再去加载 bundle.js等打包后的文件。
  • 不利于网站 SEO,因为首次请求回来的是空的 html 文件,爬虫无法获取有效内容信息,其实现在的爬虫也能爬取spa网站了。

现代服务端渲染(同构)

我们现在讲的服务端渲染概念,是指在前端范畴或者说在 Vue/React 等单页面技术范畴内的,基于 Nodejs server 运行环境的服务端渲染方案,这种方案的本质是同构渲染。它的步骤如下:

  1. Nodejs 中运行相同的前端代码,将用Vue/React框架写的代码转化为html 结构,然后返回给浏览器渲染,这样爬虫就能爬取到完整的页面信息。
  2. 客户端获取到服务端返回的页面后,再进行注水(hydrate)化处理,由客户端代码(SPA代码)来接管页面。

为什么要进行注水处理呢?

因为服务端环境毕竟不同于浏览器环境,缺少浏览器环境必要的变量和API。比如,页面中的点击事件就无法在服务端进行注册,因为在服务端环境中是没有DOM节点的概念的,它只是一堆字符串而已,自然无法使用 document.addEventListener 这样的API。也就是如果客户端代码不接管页面,那么页面里面所有的点击事件将不可用。

什么是同构?

同构简单来讲就是服务端和客户端复用同一套代码。比如,页面html结构、store数据存储、router路由都能共享一套代码。这就是所谓的现代服务端渲染:同构

缺点

  1. SSR 的数据获取必须在组件渲染之前;
  2. 组件的 JavaScript 必须先加载到客户端,才能开始水合;
  3. 所有组件必须先水合,然后才能跟其中任意一个组件交互;

可以看出 SSR 这种技术大开大合,加载整个页面的数据,加载整个页面的 JavaScript,水合整个页面,还必须按此顺序串行执行。如果有某些部分慢了,都会导致整体效率降低。

此外,SSR 只用于页面的初始化加载,对于后续的交互、页面更新、数据更改,SSR 并无作用

为什么选择 SSR?

相比于客户端渲染 CRS (单页面应用),SSR 主要的好处是:

更快的内容呈现

尤其是网络连接缓慢或设备运行速度缓慢的时候,服务端标记不需要等待所有的 JavaScript 脚本都被下载并执行之后才显示,所以用户可以更快看到完整的渲染好的内容。这带来了更好的用户体验,同时对于内容呈现时间和转化率呈正相关的应用来说尤为关键

更好的搜索引擎优化 (SEO)

因为后端会一次性的把网站内容返回给前端,所以搜索引擎爬虫会直接读取完整的渲染出来的页面。但如果你的JavaScript 脚本是通过 API 调用获取内容,则爬虫不会等待页面加载完成。这意味着如果你的页面有异步加载的内容且 SEO 很重要,那么你可能需要 SSR。

除了上面两个优点外,这里还有一些点来决定是是否选用SSR:

  • 开发一致性。浏览器特有的 API 只能在特定的生命周期钩子中使用;一些外部的库在服务端渲染应用中可能需要经过特殊处理。
  • 需要更多的构建设定和部署要求。不同于一个完全静态的 SPA 可以部署在任意的静态文件服务器,服务端渲染应用需要一个能够运行 Nodejs 服务器的环境。
  • 更多的服务端负载。在 Nodejs 中渲染一个完整的应用会比仅供应静态文件产生更密集的 CPU 运算。所以如果流量很高,请务必准备好与其负载相对应的服务器,并采取明智的缓存策略。

在应用中使用 SSR 之前,你需要问自己的第一个问题是:你是否真的需要它?

它通常是由内容呈现时间对应用的重要程度决定的

例如,如果你正在搭建一个内部管理系统,几百毫秒的初始化加载时间对它来说无关紧要,这种情况下就没有必要使用 SSR。然而,如果内容呈现时间非常关键,SSR 可以助你实现最佳的初始加载性能。

SSR vs 预渲染

如果你仅希望通过 SSR 来改善一些推广页面 (例如 //about/contact 等) 的 SEO,那么预渲染也许会更合适。和使用动态编译 HTML 的 web 服务器相比,预渲染可以在构建时为指定的路由生成静态 HTML 文件。

如果你正在使用 webpack,你可以通过 prerender-spa-plugin 来支持预渲染。

现代服务端渲染(同构)大致实现原理

通过前面的介绍,服务端渲染就是返回一个带有具体内容的 html 字符串给浏览器,那么这个具体的内容是什么呢?

这个具体的内容就是用 Vue 开发的页面内容,但是如果直接把带有 Vue 语法塞进 html 模板浏览器根本无法识别,因此,服务端渲染也需要使用 Vite 进行编译打包转化为浏览器能识别的javascript语法。

根据同构概念理解,客户端和服务端是共用同一套的页面内容代码的,所以客户端和服务端需要分别打包编译。

首先就是编写通用代码,适用于客户端和服务端。

一. 编写通用代码

由于平台 API 的差异,当运行在不同环境中时,我们写的通用代码将与纯客户端代码不会完全相同。需要注意一下几点:

1. 避免状态单例

在纯客户端应用程序中,每个用户会在他们各自的浏览器中使用新的应用程序实例。对于服务器端渲染,我们也希望如此。

但是,Node.js 服务器是一个长期运行的进程。当我们的代码进入该进程时,它将进行一次取值并留存在内存中。这意味着如果创建一个单例对象,它将在每个传入的请求之间共享。

所以,必须要求每个请求都应该都是全新的、独立的应用程序实例,以便不会有交叉请求造成的状态污染。

因此,我们不应该直接创建一个应用程序实例,而是应该暴露一个可以重复执行的工厂函数,为每个请求创建新的应用程序实例:

对原有客户端代码代码进行改造:

main.js

# 原有代码
const app = createApp(App)
app.config.globalProperties.$message = ElMessage
app.use(router)
app.use(store, key)
app.use(ElementPlus)
app.use(i18n)
app.mount('#app')

# 改造后代码:变为工厂函数
import { createSSRApp } from 'vue'
export function createApp() {
  const app = createSSRApp(App)
  const store = createSSRStore()
  const router = createSSRRouter()
  const i18n = createSSRI18n()
  sync(store, router)
  app.config.globalProperties.$message = ElMessage
  app.use(store, key)
  app.use(router)
  app.use(ElementPlus)
  app.use(i18n)

  return { app, router, store }
}

同理,项目中的数据存储store,路由router等都需要改造成工厂函数的形式,比如路由:

export function createSSRRouter() {
  return createRouter({
    # import.meta.env.SSR是vite提供环境变量
    # 服务端渲染只能用createMemoryHistory
    history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
    routes
  })
}

2. 组件生命周期钩子函数

由于服务端没有动态更新,所有的生命周期钩子函数中,只有 beforeCreate 和 created 会在服务器端渲染 (SSR) 过程中被调用。这就是说任何其他生命周期钩子函数中的代码(例如 beforeMount 或 mounted),只会在客户端执行。

此外还需要注意的是,你应该避免在 beforeCreate 和 created 生命周期时产生全局副作用的代码,例如在其中使用 setInterval 设置 timer。在纯客户端的代码中,我们可以设置一个 timer,然后在 beforeDestroy 或 destroyed 生命周期时将其销毁。但是,由于在 SSR 期间并不会调用销毁钩子函数,所以 timer 将永远保留下来。

3. 访问特定平台API

通用代码不可接受特定平台的 API,因此如果你的代码中,直接使用了像 window 或 document,这种仅浏览器可用的全局变量,则会在 Node.js 中执行时抛出错误,反之也是如此。

对于共享于服务器和客户端,但用于不同平台 API 的任务,建议将平台特定实现包含在通用 API 中,例如,axios是一个 HTTP 客户端,可以向服务器和客户端都暴露相同的 API。

请注意,考虑到如果第三方 library 不是以上面的通用用法编写,则将其集成到服务器渲染的应用程序中,可能会很棘手。

二、 构建步骤

编写好通用代码之后,就需要使用构建工具webpackVite进行打包构建,我们将会采用Vite进行构建,构建过程如下:

image.png

1. 创建客户端入口和服务端入口文件

一个典型的 SSR 应用应该有如下的源文件结构:

- index.html # 模版html文件
- server.ts # main application server
- src/
  - main.js # 公共通用代码,不是入口文件
  - entry-client.ts  # 客户端入口: 将应用挂载到一个 DOM 元素上
  - entry-server.ts  # 服务端入口:使用某框架的 SSR API 渲染该应用

index.html

首先看下index.html文件,它原来长这个样子:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>一个经典的前台项目</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

<script type="module" src="/src/main.js"></script>中的type="module"的含义与带 defer 的普通脚本一致:

  • 不会阻塞 HTML 解析;
  • 会等待 HTML 解析完成后,按脚本在页面中的顺序执行;
  • 普通脚本(无 defer/async)会阻塞解析,执行完才继续解析 HTML。

开发完代码后,执行npm run build进行打包后长这个样子:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>一个经典的前台项目</title>
    <script type="module" crossorigin src="/assets/index-IiCrRs2g.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-NLCfHQLN.css">
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

其中的index-IiCrRs2g.js是整个前端应用的核心入口文件,其核心作用是启动 Vue 应用、挂载根组件,并加载应用运行所需的核心依赖 / 资源。

现在需要改造如下:

index.html 将需要引用 entry-client.ts,而不是原来的main.ts,并包含一个占位标记供给服务端渲染时注入:

<div id="app"><!--ssr-outlet--></div>
<script type="module" src="/src/entry-client.ts"></script>

服务端渲染后的html字符串会替换<!--ssr-outlet-->/src/entry-client.ts代码主要是用于接管页面,使其具备交互能力。

entry-client.ts

import { createApp } from './main'

const { app, router, store } = createApp()

router.isReady().then(() => {
  app.mount('#app')
})

entry-server.ts

import { createApp } from './main'
import { renderToString } from '@vue/server-renderer'

// 服务端渲染核心函数
export async function render(url) {
  const { app, router, store } = createApp() 
  # 根据url来渲染对应的页面
  await router.push(url) 
  await router.isReady() 
  const appHtml = await renderToString(app) 
  return appHtml
}

server.ts

const fs = require('fs')
const path = require('path')
const express = require('express')
const serveStatic = require('serve-static')
const { createServer: createViteServer } = require('vite')

async function createServer() {
  const app = express()

  const vite = await createViteServer({
    server: { middlewareMode: 'ssr' }
  })
 
  app.use(vite.middlewares)

  app.use('*', async (req, res) => {
    const url = req.originalUrl
    # 1. 读取 index.html
    let template = fs.readFileSync(path.resolve(__dirname, 'index.html'),
          'utf-8')
          
    template = await vite.transformIndexHtml(url, template)      
          
    # 2. entry-server.ts 暴露了render方法
    let render = (await vite.ssrLoadModule('/src/entry-server.ts')).render
       
    # 3. 根据url渲染对应的
    const appHtml = await render(url)
    
    # 4. 插入到div中
    const html = template.replace('<!--ssr-outlet-->', appHtml)
    
    res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
   
  })

  app.listen(3000, () => {
    console.log('node server run at:', isProd ? '生产环境' : '开发环境')
  })
}

createServer()

package.json

package.json 中的 dev 脚本也应该相应地改变,使用服务器脚本:

"scripts": {
    // "dev": "vite"
    "dev": "cross-env NODE_ENV=development node server.js",
}

2. 打包客户端和服务端代码

package.json 中的脚本应该看起来像这样:

{
  "scripts": {
    "dev": "node server",
    "build:client": "vite build --outDir dist/client",
    "build:server": "vite build --outDir dist/server --ssr src/entry-server.js "
  }
}

使用 --ssr 标志表明这将会是一个 SSR 构建,同时需要指定 SSR 的入口。

接着,在 server.js 中,通过 process.env.NODE_ENV 条件,需要添加一些用于生产环境的特定逻辑:

  • index.html模版变更:使用 dist/client/index.html 作为模板,而不是根目录的 index.html,因为前者包含了到客户端构建的正确资源链接。
  • 服务端入口文件变更:原来是/src/entry-server.js, 现在要使用 import('./dist/server/entry-server.js')

修改后代码如下:

const fs = require('fs')
const path = require('path')
const express = require('express')
const serveStatic = require('serve-static')
const { createServer: createViteServer } = require('vite')

const isProd = process.env.NODE_ENV === 'production'

async function createServer() {
  const app = express()

  const vite = await createViteServer({
    server: { middlewareMode: 'ssr' }
  })
  
  # 在生产环境需要vite与express进行脱钩
  if (!isProd) {
    # 使用 vite 的 Connect 实例作为中间件,利用这个中间件来起一个静态资源服务器
    app.use(vite.middlewares)
  } else {
    # 在生产环境,利用express框架自带的中间件serve-static,利用这个中间件来起一个静态资源服务器
    # 把dist/client文件夹下的资源都可以访问
    app.use(
      serveStatic(path.resolve(__dirname, 'dist/client'), { index: false })
    )
  }

  app.use('*', async (req, res) => {
    const url = req.originalUrl
    let template
    let render
    try {
      # 在生产环境需要vite与express进行脱钩
      if (!isProd) {
        // 1. 读取 index.html
        template = fs.readFileSync(
          path.resolve(__dirname, 'index.html'),
          'utf-8'
        )
        
        # 2. 应用 Vite 进行 HTML 转换,这将会注入 Vite HMR 客户端,
        template = await vite.transformIndexHtml(url, template)
        
        # 3. 加载服务器入口文件,vite.ssrLoadModule 将自动转换你的 ESM 源码使之可以在 Node.js 中运行。既然是加载文件,肯定是异步的,所以使用await
        render = (await vite.ssrLoadModule('/src/entry-server.ts')).render
      } else {
        # 1. 生产环境需要加载编译后的模版文件index.html
        template = fs.readFileSync(
          path.resolve(__dirname, 'dist/client/index.html'),
          'utf-8'
        )
        # 2. 使用SSR构建后的最终render函数
        render = require('./dist/server/entry-server.js').render
      }

      # 4. 渲染应用的 HTML
      const appHtml = await render(url, manifest)

      # 5. 注入渲染后的应用程序 HTML 到模板中。
      const html = template
        .replace('<!--ssr-outlet-->', appHtml)
       
      // 6. 返回渲染后的 HTML。
      res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
    } catch (e) {
      ...
    }
  })

  app.listen(3000, () => {
    console.log('node server run at:', isProd ? '生产环境' : '开发环境')
  })
}

createServer()

三、数据获取

在服务器端渲染(SSR)期间,我们本质上是在渲染我们应用程序的"快照",所以如果应用程序依赖于一些异步数据,那么在开始渲染过程之前,需要先预取和解析好这些数据

另一个需要关注的问题是在客户端代码接管时,需要获取到与服务器端应用程序完全相同的数据,否则客户端应用程序会因为使用与服务器端应用程序不同的状态,然后导致水合失败。

为了解决这个问题,获取的数据需要位于视图组件之外,即放置在专门的数据预取存储容器(data store)或"状态容器(state container)"中。

为此,我们将使用官方状态管理库 Vuex。

那么,我们在哪里放置dispatch 数据预取 action的代码?

事实上,给定路由所需的数据,也是在该路由上渲染组件时所需的数据。所以在路由组件中放置数据预取逻辑,是很自然的事情。

我们将在路由组件上暴露出一个自定义静态函数 asyncData。注意,由于此函数会在组件实例化之前调用,所以它无法访问 this。需要将 store 和路由信息作为参数传递进去:

<script setup>
import { ref } from 'vue'

async function asyncData({ store, route }: any) {
 return store.dispatch('getRoomList')
}

defineExpose({ asyncData })
</script>

1. 服务器端数据预取

在 entry-server.js 中,我们可以通过路由获得相匹配的组件,如果组件暴露出 asyncData,我们就调用这个方法。然后我们需要将解析完成的数据,绑定到到window上。

修改entry-server.ts:

export async function render(url: string, manifest: any) {
  const { app, router, store } = createApp()
  
  await router.push(url)
  await router.isReady()

  const matchedComponents = router.currentRoute.value.matched.flatMap(record =>
    Object.values(record.components)
  )
 
  await Promise.all(
    matchedComponents.map((Component: any) => {
      # 如果组件中定义了asyncData函数,说明这个组件需要去后台获取接口数据
      if (Component.asyncData) {
        return Component.asyncData({
          // 传入store和当前route,store参数用来执行store.dispatch,发起请求
          store,
          route: router.currentRoute
        })
      }
      return []
    })
  )

  const appHtml = await renderToString(app)
  
  # 此时state里面包含了请求接口获取的数据,然后就把这个数据绑定到window某个属性上
  # 这样当同构的时候,客户端的store.state就能用这个值作为初始化化数据,就不用再去调一次接口了
  const state = store.state
 
  return { appHtml, state }
}

修改模板文件index.html:

<div id="app"><!--ssr-outlet--></div>
<script type="module" src="/src/entry-client.ts"></script>
<script>
  window.__INITIAL_STATE__ = '<!--vuex-state-->'
</script>

修改server.ts

const { appHtml, state } = await render(url)
const html = template
    .replace('<!--ssr-outlet-->', appHtml)  
    .replace("'<!--vuex-state-->'", JSON.stringify(state))

修改entry-client.js: 使用__INITIAL_STATE__来初始化store,这样客户端接管时也是带有数据的。

const { app, router, store } = createApp()

if ((window as any).__INITIAL_STATE__) {
  store.replaceState((window as any).__INITIAL_STATE__)
}

2. 客户端数据预取

首先思考下为什么会有客户端数据预取?

当我从服务端请求页面内容(/login)后,客户端代码立即接管页面,此时页面跳转时不会再向服务端发送请求了。

当从/login页面跳转到/home页面,由于组件的部分生命周期在服务端不能使用,我们没有在代码中写onMounted钩子函数,也就无法执行获取接口的函数,那么此时/home页面是没有数据的。所以就需要在客户端进行数据预取,既然组件生命周期钩子不能用,还有什么钩子可以用呢?

答案是路由钩子。

修改entry-client.ts:

router.isReady().then(() => {
  # beforeResolve表示所有的异步组件全部resolve了
  router.beforeResolve((to, from, next) => {
    # 找出两个匹配列表的差异组件。
    # 如果你刷新当前页面,会发送请求到服务器,服务前拼接好数据和html返回前端,但是有了这个路由钩子,它还会再去请求一遍数据,这就相当于前后台都去请求了一次回去,这没有必要。
    # 所以需要做一个判断是否是在刷新页面,也就是to和from是不是一样的,如果一样的,就是刷新操作,那么actived为空,不会执行后面的逻辑,也就是客户端不会再次请求数据接口,用服务端带过来的数据就可以了,这就是防止客户端数据二次预取。
    const toComponents = router
      .resolve(to)
      .matched.flatMap(record => Object.values(record.components))
    const fromComponents = router
      .resolve(from)
      .matched.flatMap(record => Object.values(record.components))

    const actived = toComponents.filter((c, i) => {
      return fromComponents[i] !== c
    })
    # 客户端预取数据有两种方式:
    # 一种是在匹配到路由视图之后就跳转,然后去请求接口,
    # 另一种是在请求接口数据返回后再进行跳转,此时页面是包含数据的
    if (!actived.length) {
      return next()
    } else {
      # 第一种: 匹配路由之后直接跳转,然后去请求接口
      // next()
    }
    // 显示loading
    const loadingInstance = ElLoading.service({
      lock: true,
      text: 'Loading',
      background: 'rgba(0, 0, 0, 0.7)'
    })

    Promise.all(
      actived.map((Component: any) => {
        if (Component.asyncData) {
          return Component.asyncData({
            store,
            route: router.currentRoute
          })
        }
        return []
      })
    ).then(() => {
      // 关闭loading
      loadingInstance.close()
      # 第二种: 等数据请求之后再跳转
      next()
    })
  })

  app.mount('#app')
})

至此,整个服务端渲染就基本完成了。

react学习15:基于 React Router 实现 keepalive

2025年12月30日 16:47

当路由切换的时候,react router 会销毁之前路由的组件,然后渲染新的路由对应的组件。

在一些场景下,这样是有问题的。

比如移动端很多长列表,用户划了很久之后,点击某个列表项跳到详情页,之后又跳回来,但是这时候列表页的组件销毁重新创建,又回到了最上面。

比如移动端填写了某个表单,有的表单需要跳到别的页面获取数据,然后跳回来,跳回来发现组件销毁重新创建,之前填的都没了。

类似这种场景,就需要路由切换的时候不销毁组件,也就是 keepalive。

我们先复现下这个场景:

npx create-vite

选择 react + typescript 创建项目。

安装 react-router:

npm i --save react-router-dom

在 App.tsx 写下路由:

import { useState } from 'react';
import {  Link, useLocation, RouterProvider, createBrowserRouter, Outlet } from 'react-router-dom';

const Layout = () => {
    const { pathname } = useLocation();

    return (
        <div>
            <div>当前路由: {pathname}</div>
            <Outlet/>
        </div>
    )
}

const Aaa = () => {
    const [count, setCount] = useState(0);

    return <div>
      <p>{count}</p>
      <p>
        <button onClick={() => setCount(count => count + 1)}>加一</button>
      </p>
      <Link to='/bbb'>去 Bbb 页面</Link><br/>
      <Link to='/ccc'>去 Ccc 页面</Link>
    </div>
};

const Bbb = () => {
    const [count, setCount] = useState(0);

    return <div>
      <p>{count}</p>
      <p><button onClick={() => setCount(count => count + 1)}>加一</button></p>
      <Link to='/'>去首页</Link>
    </div>
};

const Ccc = () => {
    return <div>
      <p>ccc</p>
      <Link to='/'>去首页</Link>
    </div>
};

const routes = [
  {
    path: "/",
    element: <Layout></Layout>,
    children: [
      {
        path: "/",
        element: <Aaa></Aaa>,
      },
      {
        path: "/bbb",
        element: <Bbb></Bbb>
      },
      {
        path: "/ccc",
        element: <Ccc></Ccc>
      }
    ]
  }
];

export const router = createBrowserRouter(routes);

const App = () => {
    return <RouterProvider router={router}/>
}

export default App;

这里有 /、/bbb、/ccc 这三个路由。

一级路由渲染 Layout 组件,里面通过 Outlet 指定渲染二级路由的地方。

二级路由 / 渲染 Aaa 组件,/bbb 渲染 Bbb 组件,/ccc 渲染 Ccc 组件。

这里的 Outlet 组件,也可以换成 useOutlet,效果一样:

image.png

image.png

默认路由切换,对应的组件就会销毁。

我们有时候不希望切换路由时销毁页面组件,也就是希望能实现 keepalive。

怎么做呢?

其实很容易想到,我们把所有需要 keepalive 的组件保存到一个全局对象。

然后渲染的时候把它们都渲染出来,路由切换只是改变显示隐藏。

按照这个思路来写一下:

新建 KeepAliveLayout.tsx:

import React, { createContext, useContext } from 'react';
import { useOutlet, useLocation, matchPath } from 'react-router-dom'
import type { FC, PropsWithChildren, ReactNode } from 'react';

interface KeepAliveLayoutProps extends PropsWithChildren{
    keepPaths: Array<string | RegExp>;
    keepElements?: Record<string, ReactNode>;
    dropByPath?: (path: string) => void;
}

type KeepAliveContextType = Omit<Required<KeepAliveLayoutProps>, 'children'>;

const keepElements: KeepAliveContextType['keepElements'] = {};

export const KeepAliveContext = createContext<KeepAliveContextType>({
    keepPaths: [],
    keepElements,
    dropByPath(path: string) {
        keepElements[path] = null;
    }
});

const isKeepPath = (keepPaths: Array<string | RegExp>, path: string) => {
    let isKeep = false;
    for(let i = 0; i< keepPaths.length; i++) {
        let item = keepPaths[i];
        if (item === path) {
            isKeep = true;
        }
        if (item instanceof RegExp && item.test(path)) {
            isKeep = true;
        }
        if (typeof item === 'string' && item.toLowerCase() === path) {
            isKeep = true;
        }
    }
    return isKeep;
}

export function useKeepOutlet() {
    const location = useLocation();
    const element = useOutlet();

    const { keepElements, keepPaths } = useContext(KeepAliveContext);
    const isKeep = isKeepPath(keepPaths, location.pathname);

    if (isKeep) {
        keepElements![location.pathname] = element;
    }

    return <>
        {
            Object.entries(keepElements).map(([pathname, element]) => (
                <div 
                    key={pathname}
                    style={{ height: '100%', width: '100%', position: 'relative', overflow: 'hidden auto' }}
                    className="keep-alive-page"
                    hidden={!matchPath(location.pathname, pathname)}
                >
                    {element}
                </div>
            ))
        }
        {!isKeep && element}
    </>
}

const KeepAliveLayout: FC<KeepAliveLayoutProps> = (props) => {
    const { keepPaths, ...other } = props;

    const { keepElements, dropByPath } = useContext(KeepAliveContext);

    return (
        <KeepAliveContext.Provider value={{ keepPaths, keepElements, dropByPath }} {...other} />
    )
}

export default KeepAliveLayout;

ts 相关知识点

PropsWithChildren

在 React 中,PropsWithChildren 是 TypeScript 提供的一个工具类型,用于为组件的 props 类型添加 children 属性的类型定义。

当你定义组件的 props 类型时,如果组件需要接收 children(子元素),可以使用 PropsWithChildren 来自动包含 children 的类型,无需手动声明。

它的本质是一个泛型类型,定义如下(简化版):

type PropsWithChildren<P = {}> = P & { children?: React.ReactNode };
import { type PropsWithChildren } from 'react';

// 自定义 props 类型
type CardProps = {
  title: string;
  className?: string;
};

// 使用 PropsWithChildren 包含 children
const Card = ({ title, className, children }: PropsWithChildren<CardProps>) => {
  return (
    <div className={className}>
      <h2>{title}</h2>
      <div>{children}</div>
    </div>
  );
};

// 使用组件
const App = () => {
  return (
    <Card title="卡片标题" className="card">
      <p>这是卡片内容</p>
    </Card>
  );
};

Record、 Require、 Omit

  • Record 是创建一个 key value 的对象类型:
 keepElements?: Record<string, ReactNode>;
  • Requried 是去掉可选 -?

  • Omit 是删掉其中的部分属性:

type KeepAliveContextType = Omit<Required<KeepAliveLayoutProps>, 'children'>;

如果要知道某个属性的类型呢? 如下代码:

const keepElements: KeepAliveContextType['keepElements'] = {};

KeepAliveContextType['keepElements'] 就返回了 keepElements 属性的类型。

是不是感觉ts跟编程一样。

继续往下看:

const KeepAliveLayout: FC<KeepAliveLayoutProps> = (props) => {
    const { keepPaths, ...other } = props;

    const { keepElements, dropByPath } = useContext(KeepAliveContext);

    return (
        <KeepAliveContext.Provider value={{ keepPaths, keepElements, dropByPath }} {...other} />
    )
}

export default KeepAliveLayout;

首先从父组件中传入props,其中包括定义的 keepPaths, 然后从useContext 取出其他值,然后通过KeepAliveContext.Provider 的value 进行设置,这样子组件就能获取到这些值。

然后暴露一个 useKeepOutlet 的 hook:

export function useKeepOutlet() {
    const location = useLocation();
    const element = useOutlet();

    const { keepElements, keepPaths } = useContext(KeepAliveContext);
    const isKeep = isKeepPath(keepPaths, location.pathname);

    if (isKeep) {
        keepElements![location.pathname] = element;
    }

    return <>
        {
            Object.entries(keepElements).map(([pathname, element]) => (
                <div 
                    key={pathname}
                    style={{ height: '100%', width: '100%', position: 'relative', overflow: 'hidden auto' }}
                    className="keep-alive-page"
                    hidden={!matchPath(location.pathname, pathname)}
                >
                    {element}
                </div>
            ))
        }
        {!isKeep && element}
    </>
}

用 useLocation 拿到当前路由,用 useOutlet 拿到对应的组件。

判断下当前路由是否在需要 keepalive 的路由内,是的话就保存到 keepElements。

然后渲染所有的 keepElements,如果不匹配 matchPath 就隐藏。

并且如果当前路由不在 keepPaths 内,就直接渲染对应的组件: {!isKeep && element} 。

其实原理比较容易看懂:在 context 中保存所有需要 keepalive 的组件,全部渲染出来,通过路由是否匹配来切换对应组件的显示隐藏。

在 App.tsx 里引入测试下:

在外面包一层 KeepAliveLayout 组件:

const App = () => {
    return (
    <KeepAliveLayout keepPaths={['/bbb', '/']}>
      <RouterProvider router={router}/>
    </KeepAliveLayout>
    )
}

<RouterProvider router={router}/>会作为children传递到KeepAliveLayout组件中。

然后把 useOutlet 换成 useKeepOutlet:

const Layout = () => {
    const { pathname } = useLocation();

    const element = useKeepOutlet()

    return (
        <div>
            <div>当前路由: {pathname}</div>
            { element }
            {/* <Outlet/> */}
        </div>
    )
}

总结

路由切换会销毁对应的组件,但很多场景我们希望路由切换组件不销毁,也就是 keepalive。

react router 并没有实现这个功能,需要我们自己做。

我们在 context 中保存所有需要 keepalive 的组件,然后渲染的时候全部渲染出来,通过路由是否匹配来切换显示隐藏。

这样实现了 keepalive。

这个功能是依赖 React Router 的 useLocation、useOutlet、matchPath 等 api 实现的,和路由功能密不可分。

❌
❌