Skip to content

⚠️ Important Notice

This post was last updated on: which was . Please pay attention to its timelines.

Next入门篇

Next官方文档
Next中文文档

Next是什么?

是基于React的应用层框架,提供了很多内置功能,能快速的构建Web应用程序。 包括的功能有:

  • 内置路由
  • 静态和服务端渲染
  • TS
  • 完善的打包配置

简单使用

官方提供了一个create-next-app脚手架工具来使用,可以快速创建一个Next项目。

自动创建项目

IMPORTANT

node版本要求:18.17以上

bash
npx create-next-app@latest

其他各种包管理工具都提供了模板:

bash
yarn create next-app
bash
pnpm create next-app

然后按照提示进行选择,就可以创建一个Next项目。 create-next-app-tip

另外还有很多的实例模板,可以在github仓库中看到。通过这些实例模板,可以学习Next如何在各种环境中使用的。

可以通过--example参数直接使用这些模板。

bash
npx create-next-app --example [example-name] [project-name]

手动创建项目

通过手动创建一个Next项目,可以更好的学习Next,以及它到底依赖了哪些东西。

  1. 创建项目文件夹
bash
mkdir next-demo
  1. 进入文件夹安装相关依赖
bash
pnpm i next@latest react@latest react-dom@latest
  1. 修改package.json
json
"scripts": {
  "dev": "next dev",
  "build": "next build",
  "start": "next start",
  "lint": "next lint"
},
  1. 添加文件

app/page.js以及app/layout.js

在新的Next版本中,已经从page路由转成了app路由,所以需要有一个app文件夹

  • layout.js:提供布局文件,容器
  • page.js:提供页面文件

还有一些其他的文件,在后面路由章节会介绍

比如上面的访问/路径时,会先进入layout.js渲染,然后再渲染page.js

Next CLI常见命令

  • dev:启动一个本地服务,就可以看到页面了
  • build:打包服务,不过在next: 14.2.4版本发现,该命令和dev命令好像有冲突,需要停掉dev命令
  • start:查看打包后的文件
  • lint:执行lint

路由介绍(App Router)

从一开始page router到现在的app router。在next中路由并没有单独的配置文件,而是通过文件路径来配置路由的,另外也可以通过文件来处理常见的页面状态。

NOTE

从 v13.4 之后,App Router成了默认的路由方案。 当然,Page Router也是兼容的,App Router的优先级要高一些,并且如果两者路由一致,会有冲突,导致构建错误。

md
. src
├─ app
│	├─ page.js
│	├─ layout.js
│	├─ demo
│	│	├─ about
│	│	│	├─ page.js
│	│	├─ page.js
│	│	├─ loading.js
│	│	├─ error.js
│	│	├─ layout.js
│	│	├─ template.js

首先介绍一下这几个有着特殊名称的文件作用:

  • layout.xx:布局作用,相当于一个容器,会自动将同级的page.xx作为children传入。顶级的layout是必须要有的,而且必须要有html以及body标签
  • page.xx:页面主体,类比index.xx的作用,作为页面的主体内容
  • template.xxlayout.xx相同的作用,也会将同级page.xx作为children传入。但是路由切换时并不会保留页面状态,同时顶级不是必须的。
  • loading.xx:提供loading状态,配合Suspense实现的,一般是page.xx中导出的是async函数,而不是普通的函数。
  • error.xx:提供错误状态,配合ErrorBoundary实现的,但是测试时好像开发环境还是会抛出错误。顶级的话是global-error.xx,报错会替换掉layout,所以顶级的error也需要有html/body
  • not-found.xx:404的页面,有一个默认的。

NOTE

如果文件夹内没有page.xx的话,该文件夹不作为路由,而是用来存放其他文件

如果全都有的话,编译之后就是:

jsx
export default () => {
  return (
    <Layout>
      <Template>
        <ErrorBoundary fallback={<Error />}>
          <Suspense fallback={<Loading />}>
            <ErrorBoundary fallback={<NotFound />}>
              <Page />
            </ErrorBoundary>
          </Suspense>
        </ErrorBoundary>
      </Template>
    </Layout>
  )
}

这里路由的层级就是文件的层级:访问/就是src/app/page.js;访问/demo就是src/app/demo/page.js;访问/demo/about就是src/app/demo/about/page.js

Layout的使用

起到一个容器作用,需要注意如果在根目录的话,该文件是必须要有的,并且必须要要包含html、body标签。 page.xx文件会作为children传入到该文件中。

Template的使用

也是起到一个容器作用,但是路由切换时状态会重置,以下例子:

layout.xx中有一些路由跳转的链接,跳转到子集路由,template.xx中有自己的状态,可以发现路由切换后,template的状态重置了,而layout.xx中的状态没有重置。每次路由变化时,template都会重新创建,所以一般用在某些和路由切换相关联的场景,比如切换路由时来执行一些上报记录之类的操作。

tsx
// layout.tsx
'use client'
import Link from 'next/link'
import { FC, PropsWithChildren, memo, useState } from 'react'

const Layout: FC<PropsWithChildren> = ({ children }) => {
  const [count, setCount] = useState(0)
  return (
    <div className='w-[200px] border-[1px] border-[#ccc] rounded-[4px]'>
      <div>
        <Link href='/about' className="w-[80px] h-[30px] p-[4px] border-[1px] border-[#ccc]">about</Link>
        <br />
        <Link href='/about/detail' className="w-[80px] h-[30px] p-[4px] border-[1px] border-[#ccc]">detail</Link>
      </div>
      {children}
      <div>layout count: {count}</div>
      <button className='border-[1px] border-[red] p-[4px]' onClick={() => setCount(d => d += 1)}>layout count + 1</button>
    </div>
  )
}

export default memo(Layout)
tsx
// template.tsx
'use client'
import { FC, PropsWithChildren, memo, useState } from 'react'

const Template: FC<PropsWithChildren> = ({ children }) => {
  const [count, setCount] = useState(0)
  return (
    <>
      {children}
      <div>tempalte count: {count}</div>
      <button className='border-[1px] border-[red] p-[4px]' onClick={() => setCount(d => d += 1)}>template count + 1</button>
    </>
  )
}

export default memo(Template)

具体的代码看该仓库next/demo1分支

其他状态页面的使用

这个状态页面包括loading.xxerror.xxnot-found.xx,这几个都是向上查找的,本级没有就用父级的。

Loading的使用

是使用Suspence组件包裹的,所以page.xx需要是异步函数,如下代码:

tsx
import { FC, memo } from 'react'

const getData: () => Promise<string> = async() => {
  return await new Promise((resolve) => setTimeout(() => {
    resolve('ji')
  }, 3000))
}

const User: FC = async () => {
  const userName = await getData()
  return (
    <>
      <h1>User page.</h1>
      <div className=' font-bold'>userName: {userName}</div>
    </>
  )
}

export default memo(User)

当访问该路由时,就会从本路由中找loading.xx作为Suspencefallback,如果没有就会用父级的loading.xx

Error的使用

是使用ErrorBoundary组件包裹的,这里的是在Layout以及Template之下的,所以本级的error.xx是无法捕获到layout.xx以及template.xx中的错误,需要在父级路由的error.xx中捕获。

NOTE

这里个ErrorBoundary组件并不是React内置的,应该是Next内置的,具体源码没有看。

所以还提供了一个global-error.xx文件,用来捕获根路由的layou.xx以及template.xx错误,这里作为根layout的fallback,所以也必须要有html以及body标签。

NotFound的使用

not-found.xx文件有一个默认的,触发时机如下:

  • 执行内置的notFound函数
  • 没有找到对应的路由

所以开发时可以通过手动执行notFound函数来触发not-found.xx。如下代码:

tsx
import { notFound } from 'next/navigation'
import { FC, memo } from 'react'

const getData: () => Promise<string> = async() => {
  return await new Promise((resolve) => setTimeout(() => {
    resolve(Math.random() > 0.5 ? 'ji' : '')
  }, 1000))
}

const User: FC = async () => {
  const userName = await getData()
  if(!userName) notFound()
  return (
    <>
      <h1>User page.</h1>
      <div className='font-bold'>userName: {userName}</div>
      <div className='h-[30px] p-[4px] border-[1px] border-[#ccc] rounded-[6px]'>refresh userName</div>
    </>
  )
}

export default memo(User)

路由切换

SPA中的跳转到页面其他地方的链接,不会渲染整个页面,只加载需要的部分。Next中有4种方式来导航:

  1. 提供的<Link>组件
  2. 客户端组件中的useRouterhook
  3. 服务端组件中的redirect函数
  4. 浏览器原生的History API

Link组件

基本用法如下:

jsx
import Link from 'next/link'

export default () => <Link href='/test'>跳转到test</Link>

其中的href支持动态的。

还可以通过usePathname获取当前的pathname:

jsx
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'

export default () => {
  const pathname = usePathname()
  return (
  	<>
    	<h1>当前页面的pathname:{pathname}</h1>
    	<Link href='/test'>跳转到test</Link>
    </>
  )
}

另外路由跳转有一些滚动条位置问题,默认是滚动到顶部的,可以通过配置scroll来解决:

jsx
// 滚动条不会改变,即保持当前页面的滚动位置
<Link href="/dashboard" scroll={false}>
  Dashboard
</Link>

useRouter hook

客户端组件use client中使用

jsx
'use client'
import { useRoute } from 'next/navigation'
export default () => {
  const route = useRoute()
  
  return <div onClick={() => route.push('/test')}>
  	跳转
  </div>
}

redirect函数

服务端组件中使用

原生的History API

history.pushState/replaceState

路由文件的写法

动态路由Dynamic Route

因为next中采用的是文件名称来进行路由配置的,动态路由需要使用[xxx]将文件名称包裹起来,包裹起来的部分会作为params参数传递给layout.xx/page.xx文件。

IMPORTANT

注意文件名不要是特殊的名称,比如react、route等

同级下多个动态路由,只会有一个生效,一般是先创建的那个。

[folderName]

这种写法只能访问下一级

比如文件目录:

bash
app
- route
	- page.js
	- [id]
		- page.js

访问/route/123会进入到[id]/page.js中,但是访问/route/123/abc就会404

传入组件(对应的page.js文件内容)的params.id是字符串

[...folderName]

访问多级路由

以下文件目录:

bash
app
- route
	- page.js
	- [...id]
	  - page.js

访问/route/123会进入到[...id]/page.js

访问/route/123/abc也会进入到[...id]/page.js

此时组件中的params.id是字符串数组

[[...folderName]]

会匹配所有的子级路由,包括自己

bash
app
- route
	- [[...id]]
	  - page.js

[...folderName]的区别在于,访问/route时也会进入[[...id]]/page.js中。

IMPORTANT

特别注意这种写法,不能有默认的page.js,否则会404

路由组Route Groups (folderName)

Next中一般的文件名称都会被映射到URL中,可以通过路由组阻止这种行为。

一般用这个按照逻辑对文件分组

使用时只需要用(folderName)即可。

比如以下文件:

bash
- app
	- (group1)
		- layout.js
		- detail
			- page.js # /detail
    - detail1
    	- page.js # /detail1
  - (group2)
  	- layout.js
  	- about
  		- page.js # /about

访问/detail和/about即可,进行了逻辑上的划分。

路由组只是阻止了将文件名映射到URL,其他的如layout.xx/error.xx/loading.xx还是可以用的,比如上面的/detail/detail1用的都是layout.js

需要注意的时,如果替代的是根布局,那么就需要包括html和body元素。

  • 路由分组仅在逻辑上对文件内容划分,没有实际意义。
  • 不要有相同的路径,比如(group1)/about/page.js(group2)/about/page.js,会报错。
  • layout的导航会导致页面重新加载,上述文件中的/about跳转到/detail会让页面重新加载

平行路由Parallel Route @folderName

从名称中可以知道这个是用来干嘛的,即同一个layout中渲染多个页面的。

如下文件:

bash
- app
 - route
  - page.js
  - layout.js
  - @some1
    - page.js
  - @some2
  	- page.js

会自动将@some1/page.js@some2/page.js作为参数传递到route/layout.js中:

jsx
export default ({ children, frontend, backend }) => {
  return (
    <>
      route平行路由测试
      {children}
      {frontend}
      {backend}
    </>
  )
}

需要注意的是,要有一个page.js,否则会404。page.js相当于是@children/page.js

可以用于条件渲染,比如根据某个条件才显示出some1,否则显示some2

这样写也能单独使用loading.xx,让每一个路由都有自己的状态,这样就更像是子模块而非路由了。

平行路由让复杂页面拆分的更加简单了,将这些平行路由当做子模块来理解可能会更好。

default.js

平行路由虽然使用子路由使得复杂页面更加的方便拆分,但是也带来了一些问题。

  1. 热更新可能会卡死,需要重启服务
  2. 如果平行路由有子路由,直接访问子路由可能会导致404
bash
- app
 - route
  - page.js
  - layout.js
  - @frontend
  	- show
  		- page.js
    - page.js
  - @backend
  	- page.js

上面的文件,直接访问/route可以看到页面,如果在layout中有个Link,跳转到/route/show,直接点击跳转frontend区域会显示出show/page.js的内容。但是如果直接在导航栏输入/route/show进行跳转,就会404

这是因为Next中导航的跳转(软导航)和直接刷新的跳转(硬导航)行为是不太一样的,Link导航时,执行部分渲染,如果其他的没有匹配上,就不会变化。但直接输入URL进行跳转,就无法确定其他部分和当前URL不匹配的该如何渲染,所以就渲染了404

实际上,直接输入URL跳转,比如/route/show,会去查找app/route/show/page.jsapp/route/@frontend/show/page.jsapp/route/@backend/show/page.js

为此,Next提供了default.js,当出现上述的无法匹配问题,就会渲染default.js,如果没有default.js,就会渲染404

将上述文件目录改为:

bash
- app
 - route
  - page.js
  - default.js
  - layout.js
  - @frontend
  	- show
  		- page.js
    - page.js
  - @backend
    - default.js
  	- page.js

app/routeapp/route/@backend下新增了个default.js,这样直接通过URL访问/route/show时,就会渲染出对应的default.js内容。

拦截路由Intercepting Route (.)

即通过Link跳转时,可以进行拦截,展示A页面。如果是直接通过URL来访问,则展示B页面。

NOTE

参考交互https://dribbble.com/

直接点击图片是一个弹窗,但是此时导航栏的URL发生了变化。如果此时刷新,发现进入了具体的页面。

拦截路由可以让用户即预览了详情内容,又没有打断用户的浏览体验 留在了当前页面。

使用

使用拦截路由时,需要文件夹以(..)开头:

  • (.)同级
  • (..)上一级
  • (..)(..)上上一级
  • (...)根目录

IMPORTANT

匹配的是路由的层级 而非文件夹层级,比如那些路由组/平行路由 就不会匹配。

如以下文件:

bash
- app
  - stop
    - @modal
      - (.)photo
        - page.js
    - photo
      - page.js
    - page.js
    - layout.js

app/stop/page.js中通过Link跳转到/stop/photo,会先进入app/stop/@modal/(.)photo/page.js页面,刷新之后才展示app/stop/photo/page.js

路由写法总结

上述有好几种路由的方式,这里总结一下:

  • 通过[]包裹的文件夹名称,作为动态路由
  • 通过()包裹的文件夹名称,作为路由组,仅作逻辑上的划分
  • 通过@前缀的文件夹名称,作为平行路由,也不会被映射到URL上
  • 通过(.)等前缀的文件夹名称,作为拦截路由,是以路由层级而非文件层级。

上一次更新: