阅读视图

发现新文章,点击刷新页面。

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

动态路由(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

本文是基于 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

服务端渲染的发展历史

传统的服务端渲染

传统的服务端渲染有 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

当路由切换的时候,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 实现的,和路由功能密不可分。

❌