Next.js详细教程

一、前言
本文基于开源项目:

https://github1s.com/vercel/next.js

https://nextjs.org/

    广东靓仔之前也写过Next.js的相关文章,这篇文章来一个全面的介绍,希望对没使用过Next.js又感兴趣的小伙伴有一点点帮助。
温馨提示:看Nextjs的文档我们最好选择英文版本,中文文档好像很久不更新了
二、基础知识
系统环境需求
Node.js 12.22.0 或更高版本

MacOS、Windows (包括 WSL) 和 Linux 都被支持

安装
yarn global add create-next-app
# or
npm i -g create-next-app


or 官方推荐

npx create-next-app@latest
# or
yarn create next-app
TypeScript 项目

npx create-next-app@latest --typescript
# or
yarn create next-app --typescript


三、目录梳理
运行Demo
我们安装好项目,运行:http://localhost:3000/

效果如下:



目录结构说明































next.config.js // 是我们的配置文件,用来修改next以及webpack的配置

pages            // Next.js路由文件夹

|--index.js       // 入口文件

|--_app.js       // 用来定义一些页面共用的

Home.module.css // 带有.module后缀的样式文件一般是用来做样式隔离的

【温馨提示】

一般抽取组件的时候,我们可以在根目录创建components文件夹

(不能存储在pages目录,会导致路由错乱)

四、配置修改
便捷开发
一般在Next项目中,我们会结合antd搭配开发,常见的两种使用方式如下:

一、Next.js + Antd (with Less)

安装

yarn add next-plugin-antd-less
yarn add --dev babel-plugin-import
使用

// next.config.js
const withAntdLess = require('next-plugin-antd-less');

module.exports = withAntdLess({
  // 可选
  modifyVars: { '@primary-color': '#04f' },
  // 可选
  lessVarsFilePath: './src/styles/variables.less',
  // 可选
  lessVarsFilePathAppendToEndOfContent: false,
  // 可选 https://github.com/webpack-contrib/css-loader#object
  cssLoaderOptions: {},

  // 其他配置在这里...

  webpack(config) {
    return config;
  },

  //  仅适用于 Next.js 10,如果您使用 Next.js 11,请删除此块
  future: {
    webpack5: true,
  },
});
添加一个 .babelrc.js

// .babelrc.js
module.exports = {
  presets: [['next/babel']],
  plugins: [['import', { libraryName: 'antd', style: true }]],
};
详细前往:https://www.npmjs.com/package/next-plugin-antd-less

二、安装antd同时也开启css modules

安装支持next-css、babel-plugin-import

yarn add @zeit/next-css babel-plugin-import
# or
npm install @zeit/next-css babel-plugin-import --save-dev
修改babelrc

{
    "presets": [
        "next/babel"
    ],
    "plugins": [
        [
            "import",
            {
                "libraryName": "antd",
                "libraryDirectory":"lib",
                "style": true
            }
        ]
    ]
}
增加next-less.config.js

const cssLoaderConfig = require('@zeit/next-css/css-loader-config')
module.exports = (nextConfig = {}) => {
  return Object.assign({}, nextConfig, {
    webpack(config, options) {
      if (!options.defaultLoaders) {
        throw new Error(
          'This plugin is not compatible with Next.js versions below 5.0.0 https://err.sh/next-plugins/upgrade'
        )
      }
      const { dev, isServer } = options
      const {
        cssModules,
        cssLoaderOptions,
        postcssLoaderOptions,
        lessLoaderOptions = {}
      } = nextConfig
      options.defaultLoaders.less = cssLoaderConfig(config, {
        extensions: ['less'],
        cssModules,
        cssLoaderOptions,
        postcssLoaderOptions,
        dev,
        isServer,
        loaders: [
          {
            loader: 'less-loader',
            options: lessLoaderOptions
          }
        ]
      })
      config.module.rules.push({
        test: /\.less$/,
        exclude: /node_modules/,
        use: options.defaultLoaders.less
      })
      // 我们禁用了antd的cssModules
      config.module.rules.push({
        test: /\.less$/,
        include: /node_modules/,
        use: cssLoaderConfig(config, {
          extensions: ['less'],
          cssModules:false,
          cssLoaderOptions:{},
          dev,
          isServer,
          loaders: [
            {
              loader: 'less-loader',
              options: lessLoaderOptions
            }
          ]
        })
      })
      if (typeof nextConfig.webpack === 'function') {
        return nextConfig.webpack(config, options)
      }
      return config
    }
  })
}
修改next.config.js

const withLessExcludeAntd = require("./next-less.config.js")
// choose your own modifyVars
const modifyVars = require("./utils/modifyVars")
if (typeof require !== 'undefined') {
  require.extensions['.less'] = (file) => {}
}
module.exports = withLessExcludeAntd({
    cssModules: true,
    cssLoaderOptions: {
      importLoaders: 1,
      localIdentName: "[local]___[hash:base64:5]",
    },
    lessLoaderOptions: {
      javascriptEnabled: true,
      modifyVars: modifyVars
    }
详细前往:https://www.yuque.com/steven-kkr5g/aza/ig3x9w

三、组件级css

Next.js 通过 [name].module.css 文件命名约定来支持 CSS 模块 。

五、SSG和SSR
SSG-静态生成
最简单、性能也最优的预渲染方式就是静态生成(SSG),把组件渲染工作完全前移到编译时:

(编译时)获取数据
(编译时)渲染组件,生成 HTML
   Demo:

// pages/demo.js
export default function Home(props) { ... }
 
// 获取静态数据
export async function getStaticProps() {
  const data = ...
  // The value of the `props` key will be
  //  passed to the `Home` component
  return {
    props: ...
  }
}
getStaticProps只在服务端执行(根本不会进入客户端 bundle),返回的静态数据会传递给页面组件(上例中的Home)。也就是说,要求通过getStaticProps提前备好页面所依赖的全部数据,数据 ready 之后组件才开始渲染,并生成 HTML。
Tips:  只有页面能通过getStaticProps声明其数据依赖,普通组件不允许,所以要求将整页依赖的所有数据都组织到一处。
SSR-服务端渲染
Next.js 提供了 SSR 专用的getServerSideProps(context):

// pages/demo.js
export async function getServerSideProps(context) {
  const res = await fetch(`https://...`)
  const data = await res.json()
 
  if (!data) {
    return {
      notFound: true,
    }
  }
 
  return {
    props: {}, // will be passed to the page component as props
  }
}
每个请求过来时都执行,所以能够拿到请求上下文参数(context)
除了这个外,编码过程跟写React项目差不多。
六、路由系统
    Nextjs默认匹配pages目录的index.js作为根路径/,其他的路径也是这样按文件名匹配的。
路由跳转
   Nextjs官方推荐了两种跳转方式,一种是Link组件包裹,一种使用Router。Link的原理也是用Router实现的,Link用起来总感觉很冗余,个人推荐使用Router。

   Nextjs提供了一个'next/router'的包,专门用来处理路由。Router便是其中一个对象,Router.push('url')进行跳转。
简单Demo:

import React from 'react'     
import Router from 'next/router'

export default () => {
 return(
    <>
      <button onClick={()=>Router.push('/demo')} >前往demo页</button>
      <div>这里是首页</div>
    </>
  )
}
路由传参
  Nextjs使用query传参数!



官方例子:

import { useRouter } from 'next/router'

export default function ReadMore({ post }) {
  const router = useRouter()

  return (
    <button
      type="button"
      onClick={() => {
        router.push({
          pathname: '/post/[pid]',
          query: { pid: post.id },
        })
      }}
    >
      Click here to read more
    </button>
  )
}
接收参数的时候使用props.router.query.pid
6个路由钩子
// routeChangeStart     history模式路由改变刚开始  
// routeChangeComplete  history模式路由改变结束
// routeChangeError     路由改变失败
// hashChangeStart      hash模式路由改变刚开始
// beforeHistoryChange  在routerChangeComplete之前执行
// hashChangeComplete   hash模式路由改变结束
来个Demo看看:

import React from 'react'
import Link from 'next/link'
import Router from 'next/router'  
 
const Home = () => {
    /**6个钩子事件
    routeChangeStart
    routerChangeComplete
    beforeHistoryChange
    routeChangeError
    hashChangeStart
    hashChangeComplete*/
    //路由开始变化
    Router.events.on('routeChangeStart',(...args)=>{
        console.log('1.routeChangeStart->路由开始变化,参数为:',...args)
    })
    //路由变化结束
    Router.events.on('routeChangeComplete',(...args)=>{
        console.log('2.routeChangeComplete->路由变化结束,参数为:',...args)
    })
    //Next.js全部都用History模式
    Router.events.on('beforeHistoryChange',(...args)=>{
        console.log('3.beforeHistoryChange,参数为:',...args)
    })
    //路由发生错误时,404不算
    Router.events.on('routeChangeError',(...args)=>{
        console.log('4.routeChangeError->路由发生错误,参数为:',...args)
    })
    //Hash路由切换之前
    Router.events.on('hashChangeStart',(...args)=>{
        console.log('5.hashChangeStart,参数为:',...args)
    })
    //Hash路由切换完成
    Router.events.on('hashChangeComplete',(...args)=>{
        console.log('6.hashChangeComplete,参数为:',...args)
    })
 
    function gotoSport(){
        Router.push({
            pathname:'/sport',
            query:{name:'前端早茶'}
        })
        // 同以下:
        // Router.push('/sport?前端早茶')
    }
 
    return (
        <>
            <div>调试下6个钩子</div>
            <div>
                <Link href={{pathname:'/sport',query:{name:'前端早茶'}}}><a>选择前端早茶</a></Link>
                <br/>
                <Link href="/sport?name=广东靓仔"><a>选择广东靓仔</a></Link>
            </div>
            <div>
                <button onClick={gotoSport}>选前端早茶</button>
            </div>
            <!-- 这里没有设置锚点,因此不会有跳转效果 -->
            <div>
                <Link href='/#juan'><a>选Juan</a></Link>
            </div>
        </>
    )
}

七、状态管理
Token存储
SSR之间只能通过cookie才能在Client和Server之间通信,以往我们在SPA项目中是使用localStorage或者sessionStorage来存储,但是在SSR项目中Server端是拿不到的,因为它是浏览器的属性,要想客户端和服务端同时都能拿到我们可以使用Cookie,所以token信息只能存储到Cookie中。
集成状态管理器
大型项目推荐使用Redux,方便我们维护以及二次开发。


四个步骤
创建store/axios.js文件
修改pages/_app.js文件
创建store/index.js文件
创建store/slice/auth.js文件
核心梳理:
pages/_app.js文件
使用next-redux-wrapper插件将redux store数据注入到next.js。
import {Provider} from 'react-redux'
import {store, wrapper} from '@/store'
 
const MyApp = ({Component, pageProps}) => {
  return <Component {...pageProps} />
}
 
export default wrapper.withRedux(MyApp)
store/index.js文件
使用@reduxjs/toolkit集成reducer并创建store,

使用next-redux-wrapper连接next.js和redux,

使用next-redux-cookie-wrapper注册要共享到cookie的slice信息。


import {configureStore, combineReducers} from '@reduxjs/toolkit';
import {createWrapper} from 'next-redux-wrapper';
import {nextReduxCookieMiddleware, wrapMakeStore} from "next-redux-cookie-wrapper";
import {authSlice} from './slices/auth';
import logger from "redux-logger";
 
const combinedReducers = combineReducers({
  [authSlice.name]: authSlice.reducer
});
export const store = wrapMakeStore(() => configureStore({
  reducer: combinedReducers,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().prepend(
      nextReduxCookieMiddleware({
        // 在这里设置在客户端和服务器端共享的cookie数据
        subtrees: ["auth.accessToken", "auth.isLogin", "auth.me"],
        })
    ).concat(logger)
}));
const makeStore = () => store;
export const wrapper = createWrapper(store, {storeKey: 'key', debug: true});
store/slice/auth.js
import {createAsyncThunk, createSlice} from '@reduxjs/toolkit';
import axios from '../axios';
import qs from "qs";
import {HYDRATE} from 'next-redux-wrapper';
 
// 获取用户信息
export const fetchUser = createAsyncThunk('auth/me', async (_, thunkAPI) => {
  try {
    const response = await axios.get('/account/me');
    return response.data.name;
  } catch (error) {
    return thunkAPI.rejectWithValue({errorMsg: error.message});
  }
});
 
// 登录
export const login = createAsyncThunk('auth/login', async (credentials, thunkAPI) => {
  try {
 
    // 获取token信息
    const response = await axios.post('/auth/oauth/token', qs.stringify(credentials));
    const resdata = response.data;
    if (resdata.access_token) {
      // 获取用户信息
      const refetch = await axios.get('/account/me', {
        headers: {Authorization: `Bearer ${resdata.access_token}`},
      });
      
      return {
        accessToken: resdata.access_token,
        isLogin: true,
        me: {name: refetch.data.name}
      };
    } else {
      return thunkAPI.rejectWithValue({errorMsg: response.data.message});
    }
 
  } catch (error) {
    return thunkAPI.rejectWithValue({errorMsg: error.message});
  }
});
 
// 初始化数据
const internalInitialState = {
  accessToken: null,
  me: null,
  errorMsg: null,
  isLogin: false
};
 
// reducer
export const authSlice = createSlice({
  name: 'auth',
  initialState: internalInitialState,
  reducers: {
    updateAuth(state, action) {
      state.accessToken = action.payload.accessToken;
      state.me = action.payload.me;
    },
    reset: () => internalInitialState,
  },
  extraReducers: {
    // 水合,拿到服务器端的reducer注入到客户端的reducer,达到数据统一的目的
    [HYDRATE]: (state, action) => {
      console.log('HYDRATE', state, action.payload);
      return Object.assign({}, state, {...action.payload.auth});
    },
    [login.fulfilled]: (state, action) => {
      state.accessToken = action.payload.accessToken;
      state.isLogin = action.payload.isLogin;
      state.me = action.payload.me;
    },
    [login.rejected]: (state, action) => {
      console.log('action=>', action)
      state = Object.assign(Object.assign({}, internalInitialState), {errorMsg: action.payload.errorMsg});
      console.log('state=>', state)
      // throw new Error(action.error.message);
    },
    [fetchUser.rejected]: (state, action) => {
      state = Object.assign(Object.assign({}, internalInitialState), {errorMsg: action.errorMsg});
    },
    [fetchUser.fulfilled]: (state, action) => {
      state.me = action.payload;
    }
  }
});
 
export const {updateAuth, reset} = authSlice.actions;

Tips:
1、使用了next-redux-wrapper一定要加HYDRATE,目的是同步服务端和客户端reducer数据,否则两个端数据不一致造成冲突
2、注意next-redux-wrapper和next-redux-cookie-wrapper版本
八、旧项目升级Next12
温馨提示:看Nextjs的文档我们最好选择英文版本,中文文档好像很久不更新了
React Server Components
允许我们在服务器上渲染所有内容,包括组件本身。
开启配置:
// next.config.js
module.exports = {
  experimental: {
    concurrentFeatures: true,
    serverComponents: true
  }
}
现在我们可以在组件级别进行数据获取,通过使用 React Server 组件,我们可以简化事情。不再需要getServerSideProps或getStaticProps。

我们可以将任何 Next.js 页面重命名为.server.js以创建服务器组件并直接在我们的服务器组件中导入客户端组件。

【温馨提示】广东靓仔从官网截了个图:


我们需要安装React18才能使用哦~
React 18添加了新功能,包括 Suspense、自动批处理更新、API 等startTransition,以及支持React.lazy.

【广东靓仔试用了下,确实方便,不建议在生产项目上使用】
详细内容
官方出了一个 demo :https://github1s.com/vercel/next-rsc-demo/blob/HEAD/pages/ssr.js

demo在线预览地址:https://next-news-rsc.vercel.sh/

目录如下所示:



以往的SSR:
import Page from '../components/page.client'
import Story from '../components/story.client'
import Footer from '../components/footer.client'

// Utils
import fetchData from '../lib/fetch-data'
import { transform } from '../lib/get-item'

export async function getServerSideProps() {
  const storyIds = await fetchData('topstories', 500)
  const data = await Promise.all(
    storyIds
      .slice(0, 30)
      .map((id) => fetchData(`item/${id}`).then(transform))
  )

  return {
    props: {
      data,
    },
  }
}

export default function News({ data }) {
  return (
    <Page>
      {data.map((item, i) => {
        return <Story key={i} {...item} />
      })}
      <Footer />
    </Page>
  )
}

页面添加 getServerSideProps 函数用于 服务端获取数据,每个页面都需要这样编写。

更新后rsc.server.js :
import { Suspense } from 'react'

// Shared Components
import Spinner from '../components/spinner'

// Server Components
import SystemInfo from '../components/server-info.server'

// Client Components
import Page from '../components/page.client'
import Story from '../components/story.client'
import Footer from '../components/footer.client'

// Utils
import fetchData from '../lib/fetch-data'
import { transform } from '../lib/get-item'
import useData from '../lib/use-data'

function StoryWithData({ id }) {
  const data = useData(`s-${id}`, () => fetchData(`item/${id}`).then(transform))
  return <Story {...data} />
}

function NewsWithData() {
  const storyIds = useData('top', () => fetchData('topstories'))
  return (
    <>
      {storyIds.slice(0, 30).map((id) => {
        return (
          <Suspense fallback={<Spinner />} key={id}>
            <StoryWithData id={id} />
          </Suspense>
        )
      })}
    </>
  )
}

export default function News() {
  return (
    <Page>
      <Suspense fallback={<Spinner />}>
        <NewsWithData />
      </Suspense>
      <Footer />
      <SystemInfo />
    </Page>
  )
}
可以看到,我们还是按平时React项目来开发就可以实现SSR了。
最重要的一点,支持 HTTP Streaming,文档还没加载完,页面已经开始渲染了。
详情前往:https://nextjs.org/blog/next-12
九、总结
在我们阅读完官方文档后,我们一定会进行更深层次的学习,比如看下框架底层是如何运行的,以及源码的阅读。
    这里广东靓仔给下一些小建议:
在看源码前,我们先去官方文档复习下框架设计理念、源码分层设计
阅读下框架官方开发人员写的相关文章
借助框架的调用栈来进行源码的阅读,通过这个执行流程,我们就完整的对源码进行了一个初步的了解
接下来再对源码执行过程中涉及的所有函数逻辑梳理一遍

作者:广东靓仔


欢迎关注:前端早茶