当谈及服务器端渲染(SSR)时,在 React 阵营中 Next.js毫无疑问是一个备受关注的话题。随着前端开发领域的不断演进,传统的客户端渲染方式已经不能完全满足现代网站和应用程序对性能和用户体验的要求。而正是在这样一种背景下,Next.js凭借其强大的服务器端渲染能力和灵活易用的特性,成为了越来越多开发者心目中理想的框架选择。

初始化项目

Next.js 提供了 CLI 程序,帮助我们快速搭建Next.js开发环境,执行create-next-app命令开始初始化,根据需求选择不同的配置。

$ npx create-next-app@latest
✔ What is your project named? … next-stu
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes
✔ What import alias would you like configured? … @/*
Creating a new Next.js app in /Users/liuhao/worksapce/study/next-stu.

等依赖安装完成之后就可以就可以启动dev环境了

$ npm run dev

image-20240224201258770

路由

路由是现代前端中一个重要的概念,用于区分渲染资源路径,即当前页面应该显示什么内容。在以往使用的各种UI库中都提供了对应的路由工具,例如Vue Router、React Router等。

Next.js 的路由是基于文件系统的约定式路由,在/src/app目录下的以page命名的文件会被解析路由,以app目录为根路径作为访问路径,例如下面的文件结构(js、jsx、ts、tsx都是一样的)

 src
  └─── app
      ├── page.tsx
          └── about
                  └── page.tsx

会生成两个可访问路径//about

Nextjs: 13.4版本以前是page router,在/src/pages目录下的文件被解析为路由,例如/src/pages下有一个about.js文件,则可以通过/about路由访问。

布局

除了page.tsx之外还有layout.tsx用于定义布局,page.tsx中的内容会作为参数传入layout.tsx,例如

image-20240224210943810

在页面上会显示为

image-20240224211003817

除了layout之外,还有template可以用来控制布局,当有template的时候,布局关系会变为layout>template>page,二者可以结合使用也可以单独使用,单独使用时二者的区别是template会在子路由切换时重置状态,layout会保留。这个特性会在某些情况下比较有用,比如离开页面重置表单状态。

加载

Next.js支持定义loading页面,实现原理借助了 React 的Suspense API

<Suspense fallback={<Spin />}>
  <DataPage />
</Suspense>

支持Suspense的场景如下:

  • 支持 Suspense 的框架如 Relay 和 Next.js。
  • 使用 lazy 懒加载组件代码。
  • 使用 use 读取 Promise 的值。

在Next.js中使用loading非常简单,首先在当前路径下定义一个loading.tsx,将会在数据加载时进行渲染,然后将异步加载的组件改为async function,如下

export default async function Page() {
  const data = await new Promise<number>(resolve => {
    setTimeout(() => resolve(1), 3000)
  })
  return (
    <div>setting page{data}</div>
  )
}

此时就完成了loading的配置了。

错误捕获

Next.js提供了异常捕获能力,在路径下定义error.tsx即可在错误是显示指定内容

image-20240225105712829

动态路由

动态路由也是前端项目中一个重要的概念,用于从路径中获取参数。例如/user/:uid这个路由,访问/user/123/user/456会渲染同一套模板,数据会根据uid参数进行请求渲染。

Next.js 约定路由也提供了动态路由的方案,将文件夹命名使用中括号包裹[dynamic]即可解析为动态路由。

// /src/app/user/[uid]/page.tsx
export default function UserPage({ params }: { params: { uid: string } }) {
  const { uid } = params;

  return (
    <div>uid: { uid }</div>
  )
}

image-20240227184819044

动态路由支持多个参数,使用拓展运算符标记文件名即可实现,[...dynamic],另外Next.js支持参数可选,使用两层中括号包裹即可[[...dynamic]]。这样做的区别是/user/[[...dynamic]]会匹配到/user,而[..dynamic]必须匹配参数。二者匹配的参数都是数组类型,参数部分从左到右依次排列,尽管只有一个参数时也是如此。

image-20240227184834431

路由组

Next.js支持对约定式路由进行编组,方法是文件夹命名时使用小括号进行包裹(group),在生成路由的时候回进行忽略。这样的作用是:

  • 将目录按照功能进行编组管理
  • 对不同的组可以使用不同的布局

image-20240227184851592

平行路由

所谓平行路由,就是在同一个目录下可以解析多个渲染模板,使用@符号命名文件夹@parallel,在解析时会将内容传到父级目录的layout的props中。

image-20240227184909256

我们可以对子组进行条件渲染等操作,平行路由的每个子组可以定义独立的加载和错误捕获。但是有种情况下会出现问题:

app/parallel
├─ layout.tsx
├─ page.tsx
├─ about
│   └─ page.tsx
├─ @show
└─ @login

@show@login都是/parallel路由下的内容,当访问/parallel/about时会出现404,因为在/app/layout.tsx里面show和login都匹配不上,所以Next.js提供了default.tsx用于显示默认内容。

// /app/parallel/@show/default.tsx
export default function Default() {
  return (
    <div>Show Default</div>
  )
}

image-20240228205047383

拦截路由

拦截路由的主要用途是不切换页面进行内容预览,例如从/user跳转到/user/123时可以通过弹窗的形式查看内容

image-20240228205031868

而当直接访问/user/123时会访问完整的原始页面内容。

image-20240228205012981

拦截路由的启用方法是在文件夹之前添加以下标识:

  • (.) 表示匹配同一层级
  • (..) 表示匹配上一层级
  • (..)(..) 表示匹配上上层级。
  • (...) 表示匹配根目录

例如下面的目录结构

interceptor
├── @modal
│   ├── (.)user
│   │   └── [id]
│   │       └── page.tsx
│   └── default.tsx
├── layout.tsx
├── page.tsx
└── user
    └── [id]
        └── page.tsx

/interceptor目录下展示一个user列表

// /app/interceptor/page/tsx
export default function Page() {
  const list = ['111', '222', '333']
  return (
    <div>
      <ul>
        {list.map(id => <li key={id}><Link href={`/interceptor/user/${id}`}>{id}</Link></li>)}
      </ul>
    </div>
  )
}

正常的/interceptor/user/:id展示内容为

export default function Page({ params }: { params: { id: string } }) {
  const {id} = params
  return (
    <div>Detail, User: { id }</div>
  )
}

现在使用拦截路由进行处理(@modal/default.tsx返回null即可)

// /app/interceptor/@modal/(.)user/[id]/page.tsx
export default function Page({ params }: { params: { id: string } }) {
  const { id } = params
  const router = useRouter()
  return (
    <div className="border-2 border-black p-6 w-96 rounded-lg">
      <p>{id}</p>
      <button className="mt-4 bg-slate-900 text-zinc-50 px-4 rounded-md" onClick={() => router.back()}>back</button>
    </div>
  )
}

最终效果如下

image-20240228204950723

接口处理

Next.js除了支持页面的渲染,还可以返回API,只需要将声明route.js即可,但是pageroute不能同时存在(同一层级下),导出以请求方法名命名的函数即可支持对应的HTTP请求。

export async function GET(request: NextRequest, {params}) {}
export async function HEAD(request: NextRequest, {params}) {}
export async function POST(request: NextRequest, {params}) {}
export async function PUT(request: NextRequest, {params}) {}
export async function DELETE(request: NextRequest, {params}) {}
export async function PATCH(request: NextRequest, {params}) {}
export async function OPTIONS(request: NextRequest, {params}) {}

方法接收两个参数,第一个是Request对象,被Next.js拓展为NextRequest,第二个是Context对象,目前只有params参数(和page的params规则一致)。

GET方法如果没有用到Request对象会被默认缓存,下次访问该请求时会直接返回缓存结果。避免缓存的方法有:

  • 使用GET以外的请求方法;
  • 使用Request获取参数;
  • 使用cookies()、headers()等动态方法;
  • 强制退出缓存,在路由文件中声明export const dynamic = 'force-dynamic'

缓存可以设置有效时间,超出时间后重新请求:

  • Next.js中的fetch请求是被重写过的,在fetch请求中添加有效期参数,fetch(url, { next: { revalidate: 60 }})
  • 路由文件中声明有效期,export const revalidate = 60

接口中一些常用的操作如下:

  • 获取query参数,使用request.nextUrl.searchParams获取searchParams对象,然后get对应字段;
  • 获取cookie,使用request.cookies获取使用next/headers导出的cookies()方法获取Cookie对象,然后通过get方法获取对应字段;
  • 写cookie,通过返回的Response对象设置headers: { 'Set-Cookie': 'key=value' }写入cookie;
  • 获取header,使用request.headers获取使用next/headers导出的headers()方法获取HeaderList对象,然后通过get方法获取对应字段;
  • 写header,通过返回的Response对象设置headers: { key: value }写入header;
  • 重定向,使用 next/navigation 提供的 redirect() 方法;
  • 获取请求体数据,使用request.json()或者request.formData()

中间件

在Next.js中,中间件是可以在请求完成之前运行代码。根据传入的请求,可以通过重写、重定向、修改请求或响应标头或直接响应来修改响应。

声明中间的件的方式有是:在app同级目录下创建middleware.ts(.js也行)。

例如,我想要对请求次数进行简单的计算,可以在添加以下中间件

import { NextRequest, NextResponse } from "next/server";

export async function middleware(request: NextRequest) {
  const hasCount = request.cookies.has('count')
  const count = hasCount ? Number(request.cookies.get('count')?.value) : 0
  
  const response = NextResponse.next()
  response.cookies.set('count', String(count + 1))

  return response
}

正如上面👆🏻代码看到的,中间件可以对请求进行前置和后置操作,如果使用过Koa等NodeJS服务就比较好理解这里了。当调用NextResponse.next()的时候会进入路由处理程序(接口),在这之前的部分称为前置操作,当命中的路由处理程序执行完成时将Response对象返回,然后执行后置操作。可以使用request参数获取客户端的header和cookie,在返回的response中设置header和cookie,这点和路由处理程序是一样的。

Next.js中路由处理过程执行的顺序为:

  1. headersnext.config.js
  2. redirectsnext.config.js
  3. 中间件 (rewrites, redirects 等)
  4. beforeFiles (next.config.js中的rewrites),在文件路由之前
  5. 基于文件系统的路由 (public/, _next/static/, pages/, app/ 等)
  6. afterFiles (next.config.js中的rewrites),在文件路由之后
  7. 动态路由 (/blog/[slug])
  8. fallback (next.config.js中的rewrites)

中间件生效控制可以通过middleware文件中导出config.matcher数组来控制匹配的路径

export const config = {
  matcher: ['/api/:path*']
}

或者在middleware函数中进行更精细地控制

import { NextRequest, NextResponse } from "next/server";

export async function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/api')) {
    return NextResponse();
  }
}

渲染

虽然Next.js是作为一款SSR框架出圈的,但是它本身不光支持这一种渲染方式,下面就看下Next.js中的各种渲染方式。

渲染方式

客户端渲染(Client-Side-Render),现在主流的前端框架的渲染方式就是CSR,像使用React和Vue这类框架直接开发的SPA应用,将所有的内容通过js bundle加载到浏览器进行DOM渲染。CSR的特点是一次请求吧所有的页面都拉取下来,后续只通过AJAX来刷新数据,DOM的变化都在浏览器控制,优势是页面跳转更流畅,劣势是浏览器SEO不友好,因为爬到的DOM结构只有一个根结点,不方便搜索引擎的内容解析。

在Next.js中,普通的使用React useEffect来控制异步数据,就是CSR形式;或者使用SWR等数据获取的库获取客户端数据也是CSR形式。

服务端渲染(Server-Side-Render),本文的主角Next.js和Vue生态的Nuxt.js都是主流的SSR框架。服务端渲染主要解决了CSR首屏加载时间过长和搜索引擎SEO优化的问题,但是这增加了服务器的压力,原本CSR的渲染方式只需要提供一个静态文件服务器(例如Nginx等),SSR则需要通过NodeJS服务提供渲染能力。

在page文件中导出getServerSideProps的异步方法,方法会在每次请求是执行,返回的数据通过props传给组件,这种形式是SSR。

静态站点生成(Server-Side-Generation),一种纯静态站点的渲染方式,老牌的博客站点生成工具hexo就算是SSG了。使用SSG的网站一般不需要后端服务,比如文档站点,博客网站等等。

如果页面中没有动态数据,只有静态的内容,Next.js默认会使用SSG生成。getStaticPaths可以定义哪些路径被预渲染,getStaticProps用于获取路径参数和请求参数传递给组件,这两个API用于动态的渲染静态文件(比如博客模板都是一样的,只是内容有区别,可以通过接口获取ID之后,根据ID生成html文件)。

增量静态再生(Incremental Static Regeneration),当用户访问了这个页面,第一次依然是老的 HTML 内容,但是 Next.js 同时静态编译成新的 HTML 文件,当第二次访问或者其他用户访问的时候,就会变成新的 HTML 内容了。

在SSG的基础上,在getStaticProps中返回revalidate即可。

服务端组件/客户端组件

Next.js渲染方式其实属于RSC(React Server Components)和SSR还是有区别的,SSR只有首屏会在服务端渲染,然后后续的所有内容都是类似CSR了。

服务端组件是在服务端完成渲染的组件,在Next.js中没有特殊声明客户端组件的都是服务端组件。通过ajax请求获取需要渲染的内容,在服务端请求到数据后将渲染结果返回客户端进行展示。但是!!服务端组件不能使用useState、useEffect等Hooks,不能绑定onClick之类的事件。

通过在文件头使用'use client'声明客户端组件,或者引入客户端组件的组件也被视为客户端组件。客户端组件和普通的React组件一样,可以使用useState等Hooks。

服务端组件的优势:

  1. 渲染速度更快,一般SPA渲染的数据的过程是从远端请求页面,从远端请求数据;服务端组件只需要从远端请求一次,然后远端在内网环境加载一次数据,这样的时长会更短。
  2. 数据更安全,像accessKey、secretKey这种东西以往为了安全是通过后端包一层接口转发的,使用服务端组件可以直接在Next.js请求第三方服务而不用担心密钥泄漏。
  3. 更快的打开速度,更小的客户端bundle和渲染完成的文档可以有效减少首屏加载时间。
  4. 流式处理,传统SPA的渲染一个列表需要所有的内容加载完成,但是使用服务端组件可以逐个组件渲染。

虽然服务端组件有这么多优势,但是客户端组件也有自己存在的必要。首先,服务端组件运行在服务端,是无法访问浏览器API的,如果需要使用浏览器提供的BOM API,则需要使用客户端组件处理;其次需要使用useState等Hooks、绑定事件、生命周期时,需要使用客户端组件。

如果使用第三方依赖时出现报错(服务端组件使用了服务端组件不支持的hooks等),可以使用'use client'声明一个文件对第三方组件包装一层。

'use client';
import {Component} from 'dep-name';

export {Component}

数据获取

之前说过Next.js对fetch做了增强,在服务端使用时(服务端组件、路由处理程序、server actions)会进行缓存,这一小节就来看下具体使用。

服务端fetch通过cache配置控制缓存策略,默认开启缓存,相当于

fetch('example.com', {cache: 'force-cache'})

页面中的GET方法和POST方法都会被缓存,路由处理程序中的POST方法不会被缓存。

缓存的验证(重新请求)策略有两种,基于时间验证按需验证

  • 基于时间验证很好理解,就是固定缓存的有效期时多长时间,超过这个时间之后重新进行请求,在请求中标注有效时间fetch('https://...', { next: { revalidate: 3600 } })或者在page文件中导出有效时间export const revalidate = 3600

  • 按需验证,通过给fetch绑定标签,然后指定某些标签过期。

    // /src/page.js
    export default async function Page() {
      const res = await fetch('url', { next: { tags: ['tag'] } })
    }
    
    // src/action.js
    'use server'
    import { revalidateTag } from 'next/cache'
     
    export default async function action() {
      revalidateTag('tag')
    }
    

如果不想使用缓存,可以通过以下方式进行禁用:

  • 请求标注了cache: 'no-store'
  • 请求过期时间设为0
  • 使用cookies或者headers之后的请求
  • 请求使用了 Authorization或者 Cookie请求头,并且在组件树中其上方还有一个未缓存的请求

如果使用第三方库进行请求,没有了fetch的增强,要想使用缓存可以通过React的cache函数。

import { cache } from 'react'
 
export const getItem = cache(async (id) => {
  const item = await axios.get('/api/...')
  return item
})

Next.js数据获取的最佳实践如下:

  • 尽量在服务端获取数据,可以加快响应时间、提高安全性
  • 在需要的地方就地获取数据,因为有缓存的存在,不需要担心行嫩问题
  • 适当的使用Streaming,可以让获取到数据的部分先可以交互
  • 根据场景串行或并行获取数据,比如先获取id列表,在根据id获取详情时进行串行处理,进行多个前后不相关请求时进行并行处理。正常的await fetch即为串行,多个请求不使用await标记,最后统一Promise.all处理即为并行

Server Actions

Server Actions之前有提到过,这一小节来展开讲讲。(Next.js v14默认开启,之前的版本需要在next.config.js的experimental.serverActions设置为true)

例如,传统SPA的表单提交需要通过form的onSubmit事件来获取数据,将数据通过接口发送至服务端处理

export default function page() {
  async function submit(event: FormDataEvent) {
    event.preventDefault()
    const data = new FormDate(event.currentTarget)
    const response = await fetch('/api/submit', {
      method: 'POST',
      body: data,
    })
  }
  
  return (
    <div>
      <form onSubmit={submit}>
        <label htmlFor="username">Username:</label>
        <input type="text" name="username" />
        <label htmlFor="password">Password:</label>
        <input type="password" name="password" />
        <input type="submit" value="submit" />
      </form>
    </div>
  )
}

在Nuxt.js通过Server Actions可以这样处理,将异步函数中声明use server,表示这是一个server actions,然后Next.js会自动将这个函数转为服务端处理程序。

export default function page() {
  async function submit(data: FormData) {
    'use server';
    console.log(data)
  }
  
  return (
    <div>
      <form action={submit}>
        <label htmlFor="username">Username:</label>
        <input type="text" name="username" />
        <label htmlFor="password">Password:</label>
        <input type="password" name="password" />
        <input type="submit" value="submit" />
      </form>
    </div>
  )
}

在服务端组件中,server actions可以直接使用,像上面的示例这样。如果在客户端组件中使用server actions,有两种方法:第一种,通过单独文件导出然后再客户端组件中引入

// app/actions.ts
'use server';

export async function submit(data: FormData) {
  console.log(data);
}

// app/page.tsx
'use client'

import { submit } from "@/actions/actions"

export default function page() {
  return (
    <div>
      <form action={submit}>
        <label htmlFor="username">Username:</label>
        <input type="text" name="username" />
        <label htmlFor="password">Password:</label>
        <input type="password" name="password" />
        <input type="submit" value="submit" />
      </form>
    </div>
  )
}

第二种是通过props传入

// app/page.tsx
export default function page() {
  async function submit(data: FormData) {
    'use server';
    console.log(data)
  }
  return (
    <div>
      <Form submit={submit} />
    </div>
  )
}

// app/Form.tsx
'use client';

interface IProps {
  submit(data: FormData): void;
}

export default function Form(props: IProps) {
  const { submit } = props;

  return (
    <form action={submit}>
      <label htmlFor="username">Username:</label>
      <input type="text" name="username" />
      <label htmlFor="password">Password:</label>
      <input type="password" name="password" />
      <input type="submit" value="submit" />
    </form>
  )
}

server actions的使用场景一种是像上面这种form的action,还可以通过事件绑定来处理。

server actions默认限制请求体大小为1mb,这样可以防止解析数据时消耗大量服务器资源,可以通过配置文件中experimental.serverActionsBodySizeLimit修改大小,比如:500kb2mb

懒加载

写Vue路由配置文件时,import 异步路由就是属于懒加载,只有当路由匹配到当前路由时才加载切割出来的bundle。

在React中,有一个lazy函数用于懒加载处理,这个函数在Next.js中同样适用,可以通过条件判断控制组件的加载。

import { useState, lazy, Suspense } from "react";

function delayForDemo(promise: Promise<any>) {
  return new Promise(resolve => {
    setTimeout(resolve, 2000);
  }).then(() => promise);
}

const DynamicComponent = lazy(() => delayForDemo(import('./dynamicComponent')));

export default function Page() {
  const [checked, setChecked] = useState(false)
  return (<>
    <label htmlFor="check">显示</label>
    <input name="check" type="checkbox" onChange={e => setChecked(e.target.checked)} />
    {
      checked &&
      <Suspense fallback="loading...">
        <DynamicComponent />
      </Suspense>
    }</>
  )
}

可以通过网络抓包看到,当checkbox选中时,发起了加载异步组件的请求

image-20240326195258852

在Next.js中,我们可以使用更简便的方法来代替lazySuspense,使用next/dynamic函数来代替。

import dynamic from 'next/dynamic'

const DynamicComponent = dynamic(
  () => import('./dynamicComponent'),
  {
    loading: () => <span>Loading...</span>,
  }
)

export default function Page() {
  return (
    <div>
      <DynamicComponent />
    </div>
  )
}

懒加载仅针对于客户端组件,如果对服务端组件进行懒加载,只有引用的客户端组件会被懒加载。

dynamic函数的第二个参数可以通过设置ssr: false来跳过首屏的ssr,即不在服务端渲染成html,这样会比没有跳过ssr的组件显示晚一些。

Runtime

在Next.js中有两个运行时类型,Nodejs RuntimeEdge Runtime。Nodejs Runtime是默认的运行时,可以使用Nodejs及其生态的包;Edge Runtime 是基于Web API的运行时,也就是浏览器中可以运行的API(但不是指在客户端运行)。

Edge Runtime 是 NodeJS Runtime 的子集。

Node.js Runtime 是指将 Next.js 应用程序部署到 Node.js 服务器上运行的方式。这意味着应用程序在服务器端使用 Node.js 来处理请求、渲染页面并返回响应。

而 Edge Runtime 则是指将 Next.js 应用程序使用 Vercel 平台提供的 Serverless 构建和托管服务来部署和运行。Edge Runtime 基于 Vercel 的边缘网络进行部署,能够在全球范围内提供快速、低延迟的用户体验。

Node.js Runtime 主要适合传统的服务器端应用程序部署方式,而 Edge Runtime 更适合于构建基于 Serverless 架构,并通过 CDN 全球分发服务来加速访问速度。

需要使用 Node.js 运行时的情况常见以下几种:

  1. 在服务器端渲染(SSR)页面中执行一些必须在服务端环境下进行的操作,比如读取文件、访问数据库等。

  2. 需要定制服务器配置或者对请求进行特殊处理时,例如使用自定义 API 路由、中间件等。

  3. 依赖一些只能在 Node.js 环境下运行的第三方库或模块。

需要使用 Edge Runtime 的情况则包括:

  1. 在客户端渲染(CSR)页面上执行一些前端代码,在浏览器中直接运行 JavaScript 代码时需要使用 Edge Runtime。

  2. 使用客户端路由、处理用户交互逻辑等与客户端相关的操作

可以为单个路由指定运行时,默认为nodejs,如果需要修改,在layout或者page中添加导出变量

export const runtime = 'edge'

配置

环境变量

与其他框架一样,Next.js的环境变量通过process.env读取,从 .env.local 文件中加载环境变量到 process.env

MINIO_HOST=100.125.188.135
NEXT_PUBLIC_MINIO_HOST=100.125.188.135
MINIO_PORT=9000

在组件中读取时需要注意组件类型,客户端组件只能读取NEXT_PUBLIC_前缀开头的变量,其他环境变量会被过滤(打包时会将值编译到bundle中,所以不要在客户端变量中使用敏感的变量如AccessKey等)。

'use client';

export default function page() {
  return (
    <div>
      {process.env.MINIO_HOST}
      {process.env.NEXT_PUBLIC_MINIO_HOST}
    </div>
  )
}

环境变量的配置文件支持多种类型,.env.local 是优先级最高的,除此之外还有:

  • .env:所有环境默认读取
  • .env.development:开发环境,在npm run dev的时候读取
  • .env.test:会在NODE_DEV设置成test时生效,用于单元测试或者E2E测试时使用的变量,不会读取.env.local中的变量
  • .env.production:生产环境,npm run start的时候读取

元数据(配置)

元数据指的是在 HTML 文档中用于描述文档内容的信息。这些元数据通常包括文档标题、作者、关键词和描述等信息,可以帮助搜索引擎理解页面内容并提高网站在搜索结果中的排名。

常见的 HTML 元数据标签包括:

  • <title>:定义网页标题,在浏览器标签栏或书签上显示。
  • <meta>:用于定义其他类型的元数据,如字符编码、关键词、作者、页面描述等,更多可见Open Graph
  • <link>:通常用于将外部资源链接到文档中,但也可用来设置 RSS 订阅源和其他元数据链接,网站图标也是通过link设置的。

元数据的丰富会改善 SEO 和 web 可共享性,为了方便定义和管理这些元数据,Next.js 提供了 Metadata API。

静态元数据,在layout或者page中导出metadata

export const metadata = {
  title: '页面标题',
  description: '页面描述',
}
 
export default function Page() {}

动态元数据,通过generateMetadata函数导出动态元数据,可以接收前路由参数、query参数、父级路由段metadata等信息作为参数。

type Props = {
  params: { id: string }
  // searchParams只在page中有
  searchParams: { [key: string]: string | string[] | undefined }
}

export async function generateMetadata(
  { params, searchParams }: Props,
  parent: ResolvingMetadata): Promise<Metadata> {
  // 读取路由参数
  const id = params.id

  // 获取数据, 可以使用fetch
  const user = await new Promise<any>(resolve => {
    setTimeout(() => {
      resolve({
        id: id,
        nickname: '枫',
      })
    }, 1000)
  })

  return {
    title: `欢迎——${user.nickname}`,
    description: `用户${user.nickname}的个人中心`
  }
}
 
export default function Page() {}

image-20240312212138157

有两个默认的元数据,编码集和视口宽度比例,可以被覆盖。

<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

metadata的解析顺序是从根路由开始的,从布局到页面,也就是具体页面的元数据可以覆盖父级的元数据。

元数据(文件)

有一些元数据是基于约定文件的,比如favicon.ico(网站图标)、robots.txt(爬虫声明)、sitemap.xml(用于搜索引擎介些的站点地图)、opengraph-image.jpg(分享链接显示网站图片)……

网站图标可用于在浏览器标签页,收藏夹显示,桌面图标显示,Next.js支持的文件类型也进行了拓展

文件名称 支持的图片格式 生效目录 对应 link标签 rel 属性
favicon .ico app/ <link rel="icon" />
icon .ico.jpg.jpeg.png.svg app/**/* <link rel="icon" />
apple-icon .jpg.jpeg.png app/**/* <link rel="apple-touch-icon" />

除了传统的内置图标静态文件之外,Next.js还支持动态生成。通过next/og模块的ImageResponse函数可以进行一个动态的网站logo,我们可以通过响应一个JSX元素来生成icon。

import { ImageResponse } from 'next/og'

// 路由段配置
export const runtime = 'edge'

// 图片size
export const size = {
  width: 32,
  height: 32,
}

// 图片类型
export const contentType = 'image/png'

// 默认导出方法需要返回一个 
// Blob | ArrayBuffer | TypedArray | DataView | ReadableStream | Response 值
export default function Icon() {
  return new ImageResponse(
    (
      <div
        style={{
          fontSize: 24,
          background: 'black',
          width: '100%',
          height: '100%',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          color: 'white',
        }}
      >
        A
      </div>
    ),
    {
      ...size,
    }
  )
}

内置组件

Image

<Image>是基于原生html中img元素进行封装的内置组件,在图片元素的基础上进行了一些列的增强。

  1. 尺寸优化:自动为每个设备提供正确尺寸的图片
  2. 视觉稳定性:防止图片加载时发生布局偏移
  3. 优化页面加载:图片只有在进入视口的时候才会加载,使用懒加载功能,并可选使用模糊占位符
  4. 灵活配置:按需进行图片调整,远程服务器上的图片也可以

常用的props有:

  • *src:图片地址,可以直接使用本地图片通过import导入,或者填入图片相对路径,也可以使用第三方路径的图片url(需要在配置文件中配置支持远程图片的信息:协议、主机、端口、路径等)。
  • *width:图片宽度。
  • *height:图片高度。
  • *alt:图片描述,鼠标悬浮到图片时会显示描述。
  • loader:自定义图片加载器,用于解析图片地址,会将图片的{src, width, quality}信息传到loader中,返回一个url路径(loader仅在客户端组件中使用)。loader可以在配置文件中统一配置。
  • fill:是否填充父元素,父元素必须指定position为relative、fixed、absolute,可以和CSS object-fit搭配使用。
  • sizes&srcset:HTML5中img的属性srcsetsizes
  • quality:图片质量,1-100,默认75。
  • priority:图片加载的优先级,boolean值,设置为true会优先加载,使用该属性会禁用懒加载。
  • placeholder:占位,可以设置empty(空白)blur(使用blurDataURL的值)base64字符串(显示base64图片)
  • blurDataURL:占位设置为blur时显示的占位图,必须是base64字符串。
  • style:图片样式。
  • onLoadingComplete:图片加载完毕时执行的回调函数。
  • onLoad:同样是在图片加载完执行,和onLoadingComplete区别在于:onLoadingComplete一定会在占位图删除之后执行,onLoad可能会在占位图删除和图片解码完成之前执行。
  • onError:图片加载失败时执行。
  • loading:图片的加载行为,lazy懒加载、eager立即加载(会影响性能)。
  • unoptimized:boolean值,true时取消优化使用原图片。

动图(GIF、APNG、WebP)会跳过优化使用原图。

用于应用内跳转路由的声明式组件,可以视为一个a标签元素(当然,就是对a标签的增强),主要的能力是预加载应用路由跳转

常用的props有:

  • href:要跳转的地址,支持字符串或者URL描述对象
  • replace:boolean值,控制跳转的行为是push还是replace。
  • scroll:跳转之后是否维持滚动位置。
  • prefetch:是否进行预加载,任何视口中的Link标签都会预加载资源,可以设置为false来关闭预加载。

如果还设置其他props会透传给a标签。

Script

基于HTML的script标签,对其功能进行了增强。

  • src:要加载的脚本地址。
  • strategy:加载的策略,可以在可交互前加载(beforeInteractive)、可交互后加载(afterInteractive)、浏览器空闲时加载(lazyOnload)、通过worker加载(worker)。
  • onLoad:脚本加载完成后调用。
  • onReady:脚本加载完成及每次重新挂载时执行。
  • onError:加载失败时进行处理。

测试

测试最近开始在前端圈火热起来了,TDD的开发方式越来越流行,下面将从单元测试和E2E两个方面介绍

单元测试

单元测试(Unit test)通过jsdom渲染组件元素,使用相关断言进行的测试。之前了解过单元测试的可以跳过,不熟悉的可以看一下《Vitest入门》,Nextjs也是推荐使用Vitest来进行单元测试。

首先安装相关依赖

$ pnpm install -D vitest @vitejs/plugin-react jsdom @testing-library/react

然后新建vitest.config.ts文件,编写测试环境的相关配置

import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
 
export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
  },
})

在package.json中新增一个脚本,"test:unit": "vitest",然后创建测试文件目录

import { fireEvent, render, screen } from '@testing-library/react'
import Page from '../../src/app/dynamic/page'

test('DynamicComponent', async () => {
  render(<Page />)

  expect(screen.getByRole('checkbox', { checked: false }))
    .toBeDefined()
  expect(screen.queryByText('dynamicComponent'))
    .toBeNull()

  fireEvent.click(screen.getByRole('checkbox', { checked: false }))

  expect(screen.getByRole('checkbox', { checked: true }))
    .toBeDefined()
  expect(
    await screen
      .findByText('dynamicComponent', undefined, { timeout: 2000 })
  ).toBeDefined()
})

命令行中执行测试命令pnpm run test:unit即可看到单元测试效果

image-20240331221601664

E2E

端到端(E2E)测试是通过无头浏览器模拟用户操作来进行的交互测试,如果没有接触过,可以看下《E2E —— 搞清楚端到端测试》,根据个人喜好选择playwright或者cypress即可。

以playwright为例,首先安装依赖,执行下面的命令,根据交互提示进行安装即可

$ pnpm create playwright

在package.json中添加脚本"test:e2e": "pnpm playwright test",然后创建测试文件进行编码

// __e2e__/dynamic/page.spec.ts
import { test, expect } from '@playwright/test';

test('dynamicComponent', async ({ page }) => {
  await page.goto('http://localhost:3000/dynamic');
  await expect(page.getByRole('checkbox', { name: 'check', checked: false })).toBeDefined()
  await expect(page.waitForSelector('[data-testid="dynamic"]', { state: 'detached' })).resolves.toBeNull();
  await page.click('input[name=check]')
  await expect(page.getByRole('checkbox', { name: 'check', checked: true })).toBeDefined()
  await expect(page.getByTestId('dynamic')).toBeDefined()
  await expect(page.getByTestId('dynamic')).toHaveText('dynamicComponent')
});

测试通过后会生成结果文件,如果失败则会立即打开浏览器显示出错的断言。

image-20240402201057555


前端小白