0 准备

0.1 创建项目

  • 利用yarn或npm创建
1
2
3
yarn create next-app
// 或
npx create-next-app@latest
  • 使用typescript:
1
yarn create next-app --typescript
  • 根据提示输入文件名后,输入yarn dev启动项目。

0.2 Link快速导航

  • 用法:用Link标签包裹a标签
1
2
import Link from 'next/link'
<Link ref='/'><a>回到首页</a></Link>
  • 优点:
  1. ​ 页面不会刷新,而是利用AJAX请求新页面内容
  2. ​ 不会请求重复的HTML、CSS、JS(可以打开控制台/network)
  3. ​ 自动在页面插入新内容、删除旧内容
  4. ​ 因为省了很多请求和解析过程,所以速度极快
  5. ​ 借鉴了Rails Turbolinks、pjax等技术

0.3 同构代码

  • 同一份代码运行在Node控制台和Chrome两端
  • 不是所有的代码都会运行,有些需要用户触发,有些API(window)不能两端共用

1 全局配置

1.1 自定义<head>

  • Head标签可以改变各页面的titlemeta:viewport

    1
    2
    3
    4
    5
    6
    
    import Head from 'next/head'
    ...
    <Head>
    	<title>我的next页面</title>
        <meta name="description" content="Generated by create next app" />
    </Head>
    

1.2 _app.js

  • page/_app.js中统一编写各页面的全局配置,包括全局css以及全局<head>等。

  • 页面切换时app组件不会销毁,只是其中的组件会销毁,所以可以在这里保存一些全局状态

  • 全局css只能在这里引入,其他组件只能编写局部css

  • 示例:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    
    // 全局css
    import '../styles/globals.css'
    import Head from 'next/head'
    
    function MyApp({ Component, pageProps }) {
      return (
        <>
          <Head>
            <title>
              我的博客
            </title>
            <meta name="description" content="Generated by create next app" />
          </Head>
          <Component {...pageProps} />
        </>
      )
    }
    
    export default MyApp
    
  • 引入css的路径是基于本文件的相对路径,因此写起来有些麻烦。可以通过配置jsconfig.json | tsconfig.json来重新规定项目引用根目录

    1
    2
    3
    4
    5
    
    {
      "compilerOptions": {
        "baseUrl": "."
      }
    }
    

    引用时,可以简写为import 'styles/global.css'

1.3 局部css

  • styled-jsx

    • 写法为:

    • 1
      2
      3
      4
      5
      6
      7
      8
      
      <div className='example'>
      	First Post
      </div>
      <style jsx>{`
      	.example {
      		color: blue
      	}
      `}</style>
      
  • CSS Modules

    • 在styles文件夹下新建Example.module.css,在文件中引入及使用方式为:

    • 1
      2
      3
      
      import style from 'styles/Example.module.css'
      ...
      <div className={ style.example }>First Post</div>
      
  • 配置scss

    • 直接安装依赖即可

    • 1
      
      yarn add sass
      

1.4 静态资源

  • 配置webpack

    • 需要在next.config.js中配置file-loader:

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      
      module.exports = {
      	reactStrictMode: true,
      	webpack: (config, options) => {
      		config.module.rules.push({
      			test: /\.(png|jpg|jpeg|gif|svg)$/,
      			use: [
      				{
                          loader: 'file/loader',
                          options: {
                              // 指定输出名称:‘文件名.哈希值.后缀’
      						name: '[name].[contenthash].[ext]',
                              // 硬盘中的输出路径
      						outputPath: 'static',
                              // 网站中的加载路径
      						publicPath: '/_next/static'
      					}
      				},
      			],
      		})
      		return config
      	},
      }
      
    • 在根目录下新建assets/image,用于存放图片类型的静态资源

    • yarn add --dev file-loader安装依赖,重启项目后,即可引用图片格式的静态资源了

  • 新版next中无需配置webpack,如需加载静态图片资源,使用<Image>插件即可

    1
    2
    3
    4
    
    import png from 'assets/image/1.png'
    import Image from 'next/image'
    ...
    <Image alt='' src={ png } />
    

1.5 TypeScript支持

  • 安装相关依赖

    1
    
    yarn add --dev typescript @types/node @types/react
    
  • 新版本可以直接在创建时就配置typescript支持

2 Next.js API示例

  • Next.js的api位于pages/api中,下面以路径/api/v1/post、响应体json为例,开发获取全部博客列表的接口

    1. 在api下新建v1文件夹(表示第一版接口),创建posts.tsx

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      
      import { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
      // 将getPosts方法放在lib目录下,表示自己封装的工具函数
      import { getPosts } from 'lib/posts'
      
      // 在指定类型时也可以给整个方法指定为NextApiHandler类型
      // const Posts:NextApiHandler = (req, res) => {
      const Posts = async (req: NextApiRequest, res: NextApiResponse) => {
        const postsList = await getPosts()
        res.statusCode = 200
        res.setHeader('Content-Type', 'application/json')
        res.write(JSON.stringify(postsList))
        res.end()
      }
      
      export default Posts
      
    2. lib/posts.tsx:

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      
      import fs, { promises as fsPromise } from 'fs'
      import path from 'path'
      import matter from 'gray-matter'
      
      // 获取博客列表
      const getPosts = async () => {
        // 获取当前工作目录(current working dir),即项目的绝对路径
        // 不同操作系统,路径的连接方式不同,所以使用path.join自动拼接路径
        const markdownDir = path.join(process.cwd(), 'markdown')
        const fileNameList = await fsPromise.readdir(markdownDir)
        // 分别读取每个文件
        const posts = fileNameList.map(fileName => {
          const fullPath = path.join(markdownDir, fileName)
          // 处理文件名的后缀
          const id = fileName.replace('.md', '')
          // 每个文件的文本数据
          const text = fs.readFileSync(fullPath, 'utf-8')
          // 交给matter的库去处理,matter用来解析md格式的头部文件
          const { data: { title }, content } = matter(text)
          return {
            id, title
          }
        })
        return posts
      }
      export { getPosts }
      

      image-20211021144858713

3 Next.js页面渲染方式

  • Next.js渲染页面主要有三种方式:客户端渲染(BSR)、静态页面生成(SSG)和服务端渲染(SSR)
  • 分别对应:
    • 客户端渲染——用JS、Vue、 React创建HTML
    • SSG——页面静态化,把PHP提前渲染成HTML
    • SSR——PHP、 Python、 Ruby、 Java后台的基本功能
  • 区别:不过Next.js的预渲染可以与前端React无缝对接(SSG)

3.1 客户端渲染

  • 只在浏览器上执行的渲染,一般前端开发都是以这种形式,即由浏览器将前端请求到的数据渲染到页面中。

  • 以上文posts接口为例,在页面中加载请求的posts数据。新建pages/posts/index.tsx:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    
    // 安装axios并引入:yarn add axios @types/axios
    import axios from "axios";
    import { NextPage } from "next";
    import { useEffect, useState } from "react";
    // 声明Post数据类型
    type Post = {
      id: string
      title: string
    }
    const PostsIndex: NextPage = () => {
      // post数据
      const [posts, setPosts] = useState<Post[]>([])
      // 是否加载中
      const [isLoading, setIsLoading] = useState(true)
      // 数据是否为空
      const [isEmpty, setIsEmpty] = useState(false)
      useEffect(() => {
        // 请求数据
        axios.get('/api/v1/post').then((res: any) => {
          setIsLoading(false)
          setPosts(res.data)
          if (res.data.length === 0) {
            setIsEmpty(true)
          }
        }, () => {
          setIsLoading(false)
        })
      }, [])
      return (
        <div>
          <h1>文章列表</h1>
          {isLoading ? <div>正在加载。。。</div> :
            isEmpty ? <div>无数据</div> :
              posts.map(post => {
                return (
                  <div key={post.id}>
                    {post.id}
                  </div>)
              })}
        </div>
      )
    }
    
    export default PostsIndex
    

    访问localhost:3000/posts:

    image-20211021160911001

  • 封装hooks技巧

    1. lib目录下新建hooks文件夹,创建usePosts.tsx
    2. 复制全部hooks有关的代码及类型声明到新文件
    3. 将usePosts中的hooks全部return出来
    4. 源文件就可以通过usePosts函数获取到全部需要的参数
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    
    // usePosts.tsx
    import axios from "axios"
    import { useEffect, useState } from "react"
    type Post = {
      id: string
      title: string
    }
    export const usePosts = () => {
      const [posts, setPosts] = useState<Post[]>([])
      const [isLoading, setIsLoading] = useState(true)
      const [isEmpty, setIsEmpty] = useState(false)
      useEffect(() => {
        axios.get('/api/v1/post').then((res: any) => {
          setIsLoading(false)
          setPosts(res.data)
          if (res.data.length === 0) {
            setIsEmpty(true)
          }
        }, () => {
          setIsLoading(false)
        })
      }, [])
      return { posts, setPosts, isLoading, setIsLoading, isEmpty, setIsEmpty }
    }
    
    // pages/posts/index.tsx
    import { NextPage } from "next";
    import { usePosts } from 'lib/hooks/usePosts'
    type post = {
      id: string
      title: string
    }
    const PostsIndex: NextPage = () => {
      const { isLoading, isEmpty, posts } = usePosts()
      return (
        <div>
          <h1>文章列表</h1>
          {isLoading ? <div>正在加载。。。</div> :
            isEmpty ? <div>无数据</div> :
              posts.map((post: post) => {
                return (
                  <div key={post.id}>
                    {post.id}
                  </div>)
              })}
        </div>
      )
    }
    export default PostsIndex
    
  • 文章列表完全是由前端渲染的,我们称之为客户端渲染

  • 客户端渲染缺点:

    1. 白屏:在AJAX得到响应之前,页面中只有白屏或Loading;
    2. SEO(SearchEngineOptimization)不友好:搜索引擎访问页面,看不到posts数据,因为搜索引擎默认不会执行JS,只能看到HTML。对于本页面来说,只能看到“文章列表”四个字。
  • 静态内容和动态内容(选看)

    • 一般来说,静态内容(<div>文章列表</div>)是写在代码中的,动态内容(posts)是来自数据库的
    • 静态内容是由服务端与客户端分别渲染一次
    • Next.js分别调用了react的renderToString()方法,由后端将前端代码转为字符串再发送给前端;之后调用hydrate()方法在前端将后端传过来的代码与前端React实例进行整合,会保留HTML且只进行事件绑定,从而让用户有一个非常高性能的首次加载体验。
    • 也就是说后端渲染HTML,前端添加事件绑定
    • 前端也会渲染一次,但不是放在页面上,而是用来确保前后端渲染结果一致

3.2 静态页面生成

  • 背景:

    • 当某个页面(例如首页)与用户数据无关时,即每位用户看到的页面是一样的,这种情况下仍需要每位用户的浏览器来渲染
    • 所以可以在后端渲染好,然后发给每个用户
    • 这样之前的N次渲染就变成了1次渲染,即N次客户端渲染变成了1次静态页面生成
    • 这个过程叫做动态内容静态化
  • Next.js中后端获取数据的方式——getStaticProps

    • 将getStaticProps声明在每个page导出的函数旁边即可

    • 写法:

      1
      2
      3
      4
      
      export const getStaticProps = async () => {
      	const posts = await getPosts()
      	return {props: {posts: posts}}
      }
      
    • 示例:修改posts页面为SSG渲染

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      
      import { GetStaticProps, NextPage } from "next";
      import { getPosts } from "lib/posts";
      
      type Props = {
        posts: Posts[]
      }
      const PostsIndex: NextPage<Props> = (props) => {
        const { posts } = props
        return (
          <div>
            <h1>文章列表</h1>
            {posts.map(post => {
              return (<div key={post.id}>{post.id}</div>)
            })}
          </div>
        )
      }
      export default PostsIndex
      
      export const getStaticProps: GetStaticProps = async () => {
        const posts = await getPosts()
        return { props: { posts: posts } }
      }
      

      打开页面源码,可以发现后端拿到数据后,将数据直接放入了<script type="application/json">标签内部

      image-20211022095946388

  • 通过同构,前端也可以不用AJAX就能拿到数据了,这就是同构的好处:后端数据可以直接传给前端,然后前端JSON.parse一下就能得到了数据(next框架已经帮我们做了parse)。

  • PHP/java/Python 能不能做?

    • 思路是一样的,他们也能做,但是他们不支持jsx,不好与React无缝对接,而且这些语言的对象不能直接提供给JS用,需要类型转换。
  • SSG静态化的时机:

    • 开发环境:在开发环境每次请求都会运行一次getStaticProps,这是为了方便修改代码时重新运行。
    • 生产环境:getStaticProps只在build时运行一次,这样可以提供一份HTML给所有用户下载。
  • 总结

    • 动态内容静态化
      • 如果动态内容与用户无关,那么可以提前静态化
      • 通过getStaticProps可以获取数据
      • 静态内容+数据(本地获取)就得到了完整页面,代替了之前的静态内容+动态内容(AJAX获取)
    • 时机
      • 静态化是在yarn build的时候实现的
    • 优点
      • 生产环境中直接给出完整页面
      • 首屏不会白屏
      • 搜索引擎能看到页面内容,方便SEO(注意SEO只识别有意义的标签,例如h1a等,不要使用div

3.3 服务端渲染

  • 背景

    • 在页面内容与用户相关时,较难提前静态化。因为我们需要在用户请求时才可以获取到用户信息,然后通过用户信息再去数据库拿数据
    • 比如微博首页的信息流
    • 因此,由服务端拿到用户信息后渲染页面发送给前端,这种方案,解决了传统的客户端渲染白屏的问题,而且访问速度也大大提升
  • getStaticProps()只是在项目build阶段执行的,无法获取用户的请求数据

  • Next.js服务端获取数据的方式——getServerSideProps

  • 用法:

    1
    2
    3
    4
    
    export const getServerSideProps: GetServerSideProps = async (context) => {
    	...// context对象包含用户请求与响应等一切信息,类型为NextPageContext
    	return {props: {result: result}}
    }
    
  • 运行时机:每次请求到来之后运行

  • 示例:获取用户浏览器数据并展示

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    
    import type { GetServerSideProps, NextPage } from 'next'
    // 解析浏览器与系统信息的js库
    import { UAParser } from 'ua-parser-js'
    
    type Props = {
      browser: {
        name: string
      }
    }
    
    const Home: NextPage<Props> = (props) => {
      const { browser } = props
      return (
        <h1>你的浏览器是:{browser.name}</h1>
      )
    }
    export default Home
    
    export const getServerSideProps: GetServerSideProps = async (context) => {
      const ua = context.req.headers['user-agent']
      // 利用ua-parser-js库解析出客户端浏览器信息
      const browser = new UAParser(ua).getBrowser()
      return {
        props: {
          browser
        }
      }
    }
    

    运行结果:

    image-20211022154410121

3.4 Next.js渲染页面方案总结

  • 静态内容
    • 直接输出HTML
  • 动态内容(Brower Side Render)
    • 通过AJAX请求,渲染成HTML
  • 动态内容静态化(Static Site Generation)
    • 通过getStaticProps获取用户无关内容
  • 用户相关动态内容静态化(Server Side Render)
    • 通过getServerSideProps获取请求数据

3.5 三种渲染方式的选择

image-20211022160139139

附 博客主页跳转

实现点击文章名称,跳转至文章页面的功能。

  • 添加Link实现跳转
1
2
3
<Link href={`/post/${post.id}`}>
	<a>{post.id}</a>
</Link>
  • 基于约定式路由命名规范,在pages/page/posts目录下创建[id].tsx文件

    • 既声明了路由/post/:id,又是post/:id的页面实现程序
  • [id].tsx中:

    • 实现PostShow,从props接收post数据

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      
      type Props = {
        post: Posts
      }
      const postShow: NextPage<Props> = (props) => {
        const { post } = props
        return (
          <>
            <h1>{post.title}</h1>
            <div>{post.content}</div>
          </>
        )
      }
      export default postShow
      
    • 实现getStaticPaths,返回静态id列表

      1
      2
      3
      4
      5
      6
      7
      
      export const getStaticPaths: GetStaticPaths = async () => {
        const idList = await getPostIdList()
        return {
          paths: idList.map(i => { return { params: { id: i } } }),
          fallback: false
        }
      }
      

      fallback:false表示是否兜底,如果是false,表示如果请求的id不在getStaticPaths的结果中,则直接返回404页面;如果是true,表示id找不到的情况下,仍然尝试渲染页面(例如SSR的形式)。注意,id找不到不代表该id不存在,因为大型项目无法将全部页面静态化,只静态化了一部分id对应的页面。

    • 实现getStaticProps,从第一个参数接收params.id,返回静态post数据,并在build阶段生成各自的html文件

      1
      2
      3
      4
      5
      6
      7
      8
      9
      
      export const getStaticProps: GetStaticProps = async (staticContext) => {
        const id = staticContext.params?.id
        const post = await getPost(id)
        return {
          props: {
            post: post
          }
        }
      }
      
    • 源码如下

      • pages/posts/index.tsx
       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      
      import { GetStaticProps, NextPage } from "next";
      import { getPosts } from "lib/posts"
      import Link from "next/link";
      
      type Props = {
        posts: Posts[]
      }
      const PostsIndex: NextPage<Props> = (props) => {
        const { posts } = props
        return (
          <div>
            <h1>文章列表</h1>
            {posts.map(post => {
              return (
                <>
                <Link href={`/posts/${post.id}`} key={post.id}>
                  <a>{post.id}</a>
                </Link>
                <hr />
                </>)
            })}
          </div>
        )
      }
      export default PostsIndex
      
      export const getStaticProps: GetStaticProps = async () => {
        const posts = await getPosts()
        return { props: { posts: posts } }
      }
      
      • pages/posts/[id].tsx
       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      
      import { getPost, getPostIdList } from 'lib/posts';
      import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
      
      type Props = {
        post: Posts
      }
      const postShow: NextPage<Props> = (props) => {
        const { post } = props
        return (
          <>
            <h1>{post.title}</h1>
            <div>{post.content}</div>
          </>
        )
      }
      export default postShow
      
      export const getStaticPaths: GetStaticPaths = async () => {
        const idList = await getPostIdList()
        return {
          paths: idList.map(i => { return { params: { id: i } } }),
          // 
          fallback: false
        }
      }
      export const getStaticProps: GetStaticProps = async (staticContext) => {
        const id = staticContext.params?.id
        const post = await getPost(id)
        return {
          props: {
            post: post
          }
        }
      }
      
      • lib/posts.tsx
       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      
      import fs, { promises as fsPromise } from 'fs'
      import path from 'path'
      import matter from 'gray-matter'
      
      const markdownDir = path.join(process.cwd(), 'markdown')
      // 获取博客列表
      const getPosts = async () => {
        const fileNameList = await fsPromise.readdir(markdownDir)
        // 分别读取每个文件
        const posts = fileNameList.map(fileName => {
          const fullPath = path.join(markdownDir, fileName)
          // 处理文件名的后缀
          const id = fileName.replace('.md', '')
          // 每个文件的文本数据
          const text = fs.readFileSync(fullPath, 'utf-8')
          // 交给matter的库去处理
          const { data: { title }, content } = matter(text)
          return {
            id, title
          }
        })
        return posts
      }
      export { getPosts }
      
      // 获取博客数据
      const getPost = async (id: string[] | string | undefined) => {
        const fullPath = path.join(markdownDir, id + '.md')
        const text = fs.readFileSync(fullPath, 'utf-8')
        const { data: { title }, content } = matter(text)
        return {
          id, title, content
        }
      }
      export { getPost }
      
      // 获取博客id数组
      const getPostIdList = async () => {
        const fileNameList = await fsPromise.readdir(markdownDir)
        return fileNameList.map(i => i.replace('.md', ''))
      }
      export { getPostIdList }