手把手带你用next搭建一个完善的react服务端渲染项目(集成antd、redux、样式解决方案)
- 2020 年 4 月 10 日
- 笔记
前言
本文参考了慕课网jokcy老师的React16.8+Next.js+Koa2开发Github全栈项目,也算是做个笔记吧。
源码地址
介绍
Next.js 是一个轻量级的 React 服务端渲染应用框架。
官网:nextjs.org 中文官网:nextjs.frontendx.cn
当使用 React 开发系统的时候,常常需要配置很多繁琐的参数,如 Webpack 配置、Router 配置和服务器配置等。如果需要做 SEO,要考虑的事情就更多了,怎么让服务端渲染和客户端渲染保持一致是一件很麻烦的事情,需要引入很多第三方库。针对这些问题,Next.js提供了一个很好的解决方案,使开发人员可以将精力放在业务上,从繁琐的配置中解放出来。下面我们一起来从零开始搭建一个完善的next项目。
项目的初始化
首先安装 create-next-app 脚手架
npm i -g create-next-app
然后利用脚手架建立 next 项目
create-next-app next-github cd next-github npm run dev
可以看到 pages 文件夹下的 index.js
生成的目录结构很简单,我们稍微加几个内容
├── README.md ├── components // 非页面级共用组件 │ └── nav.js ├── package-lock.json ├── package.json ├── pages // 页面级组件 会被解析成路由 │ └── index.js ├── lib // 一些通用的js ├── static // 静态资源 │ └── favicon.ico
启动项目之后,默认端口启动在 3000 端口,打开 localhost:3000 后,默认访问的就是 index.js 里的内容
把 next 作为 Koa 的中间件使用。(可选)
如果要集成koa的话,可以参考这一段。 在根目录新建 server.js 文件
// server.js const Koa = require('koa') const Router = require('koa-router') const next = require('next') const dev = process.env.NODE_ENV !== 'production' const app = next({ dev }) const handle = app.getRequestHandler() const PORT = 3001 // 等到pages目录编译完成后启动服务响应请求 app.prepare().then(() => { const server = new Koa() const router = new Router() server.use(async (ctx, next) => { await handle(ctx.req, ctx.res) ctx.respond = false }) server.listen(PORT, () => { console.log(`koa server listening on ${PORT}`) }) }) 复制代码
然后把package.json
中的dev
命令改掉
scripts": { "dev": "node server.js", "build": "next build", "start": "next start" }
ctx.req
和ctx.res
是 node 原生提供的
之所以要传递 ctx.req
和ctx.res
,是因为 next 并不只是兼容 koa 这个框架,所以需要传递 node 原生提供的 req
和 res
集成 css
next 中默认不支持直接 import css 文件,它默认为我们提供了一种 css in js 的方案,所以我们要自己加入 next 的插件包进行 css 支持
yarn add @zeit/next-css
如果项目根目录下没有的话 我们新建一个next.config.js
然后加入如下代码
const withCss = require('@zeit/next-css') if (typeof require !== 'undefined') { require.extensions['.css'] = file => {} } // withCss得到的是一个next的config配置 module.exports = withCss({}) 复制代码
集成 ant-design
yarn add antd yarn add babel-plugin-import // 按需加载插件
在根目录下新建.babelrc
文件
{ "presets": ["next/babel"], "plugins": [ [ "import", { "libraryName": "antd" } ] ] }
这个 babel 插件的作用是把
import { Button } from 'antd'
解析成
import Button from 'antd/lib/button'
这样就完成了按需引入组件
在 pages 文件夹下新建_app.js
,这是 next 提供的让你重写 App 组件的方式,在这里我们可以引入 antd 的样式
pages/_app.js
import App from 'next/app' import 'antd/dist/antd.css' export default App
next 中的路由
利用Link
组件进行跳转
import Link from 'next/link' import { Button } from 'antd' const LinkTest = () => ( <div> <Link href="/a"> <Button>跳转到a页面</Button> </Link> </div> ) export default LinkTest 复制代码
利用Router
模块进行跳转
import Link from 'next/link' import Router from 'next/router' import { Button } from 'antd' export default () => { const goB = () => { Router.push('/b') } return ( <> <Link href="/a"> <Button>跳转到a页面</Button> </Link> <Button onClick={goB}>跳转到b页面</Button> </> ) } 复制代码
动态路由
在 next 中,只能通过query
来实现动态路由,不支持/b/:id
这样的定义方法
首页
import Link from 'next/link' import Router from 'next/router' import { Button } from 'antd' export default () => { const goB = () => { Router.push('/b?id=2') // 或 Router.push({ pathname: '/b', query: { id: 2, }, }) } return <Button onClick={goB}>跳转到b页面</Button> } 复制代码
B 页面
import { withRouter } from 'next/router' const B = ({ router }) => <span>这是B页面, 参数是{router.query.id}</span> export default withRouter(B)
此时跳转到 b 页面的路径是/b?id=2
如果真的想显示成/b/2
这种形式的话, 也可以通过Link
上的as
属性来实现
<Link href="/a?id=1" as="/a/1"> <Button>跳转到a页面</Button> </Link>
或在使用Router
时
Router.push( { pathname: '/b', query: { id: 2, }, }, '/b/2' )
但是使用这种方法,在页面刷新的时候会 404 是因为这种别名的方法只是在前端路由跳转的时候加上的 刷新时请求走了服务端就认不得这个路由了
使用 koa 可以解决这个问题
// server.js const Koa = require('koa') const Router = require('koa-router') const next = require('next') const dev = process.env.NODE_ENV !== 'production' const app = next({ dev }) const handle = app.getRequestHandler() const PORT = 3001 // 等到pages目录编译完成后启动服务响应请求 app.prepare().then(() => { const server = new Koa() const router = new Router() // start // 利用koa-router去把/a/1这种格式的路由 // 代理到/a?id=1去,这样就不会404了 router.get('/a/:id', async ctx => { const id = ctx.params.id await handle(ctx.req, ctx.res, { pathname: '/a', query: { id, }, }) ctx.respond = false }) server.use(router.routes()) // end server.use(async (ctx, next) => { await handle(ctx.req, ctx.res) ctx.respond = false }) server.listen(PORT, () => { console.log(`koa server listening on ${PORT}`) }) }) 复制代码
Router 的钩子
在一次路由跳转中,先后会触发 routeChangeStart
beforeHistoryChange
routeChangeComplete
如果有错误的话,则会触发 routeChangeError
监听的方式是
Router.events.on(eventName, callback)
自定义 document
- 只有在服务端渲染的时候才会被调用
- 用来修改服务端渲染的文档内容
- 一般用来配合第三方 css in js 方案使用
在 pages 下新建_document.js,我们可以根据需求去重写。
import Document, { Html, Head, Main, NextScript } from 'next/document' export default class MyDocument extends Document { // 如果要重写render 就必须按照这个结构来写 render() { return ( <Html> <Head> <title>ssh-next-github</title> </Head> <body> <Main /> <NextScript /> </body> </Html> ) } } 复制代码
自定义 app
next 中,pages/_app.js 这个文件中暴露出的组件会作为一个全局的包裹组件,会被包在每一个页面组件的外层,我们可以用它来
- 固定 Layout
- 保持一些共用的状态
- 给页面传入一些自定义数据 pages/_app.js
给个简单的例子,先别改_app.js 里的代码,否则接下来 getInitialProps 就获取不到数据了,这个后面再处理。
import App, { Container } from 'next/app' import 'antd/dist/antd.css' import React from 'react' export default class MyApp extends App { render() { // Component就是我们要包裹的页面组件 const { Component } = this.props return ( <Container> <Component /> </Container> ) } } 复制代码
封装 getInitialProps
getInitialProps
的作用非常强大,它可以帮助我们同步服务端和客户端的数据,我们应该尽量把数据获取的逻辑放在 getInitialProps
里,它可以:
- 在页面中获取数据
- 在 App 中获取全局数据
基本使用
通过 getInitialProps
这个静态方法返回的值 都会被当做 props 传入组件
const A = ({ name }) => ( <span>这是A页面, 通过getInitialProps获得的name是{name}</span> ) A.getInitialProps = () => { return { name: 'ssh', } } export default A 复制代码
但是需要注意的是,只有 pages 文件夹下的组件(页面级组件)才会调用这个方法。next 会在路由切换前去帮你调用这个方法,这个方法在服务端渲染和客户端渲染都会执行。(刷新
或 前端跳转
) 并且如果服务端渲染已经执行过了,在进行客户端渲染时就不会再帮你执行了。
异步场景
异步场景可以通过 async await 来解决,next 会等到异步处理完毕 返回了结果后以后再去渲染页面
const A = ({ name }) => ( <span>这是A页面, 通过getInitialProps获得的name是{name}</span> ) A.getInitialProps = async () => { const result = Promise.resolve({ name: 'ssh' }) await new Promise(resolve => setTimeout(resolve, 1000)) return result } export default A 复制代码
在_app.js 里获取数据
我们重写一些_app.js 里获取数据的逻辑
import App, { Container } from 'next/app' import 'antd/dist/antd.css' import React from 'react' export default class MyApp extends App { // App组件的getInitialProps比较特殊 // 能拿到一些额外的参数 // Component: 被包裹的组件 static async getInitialProps(ctx) { const { Component } = ctx let pageProps = {} // 拿到Component上定义的getInitialProps if (Component.getInitialProps) { // 执行拿到返回结果 pageProps = await Component.getInitialProps(ctx) } // 返回给组件 return { pageProps, } } render() { const { Component, pageProps } = this.props return ( <Container> {/* 把pageProps解构后传递给组件 */} <Component {...pageProps} /> </Container> ) } } 复制代码
封装通用 Layout
我们希望每个页面跳转以后,都可以有共同的头部导航栏,这就可以利用_app.js 来做了。
在 components 文件夹下新建 Layout.jsx:
import Link from 'next/link' import { Button } from 'antd' export default ({ children }) => ( <header> <Link href="/a"> <Button>跳转到a页面</Button> </Link> <Link href="/b"> <Button>跳转到b页面</Button> </Link> <section className="container">{children}</section> </header> ) 复制代码
在_app.js 里
// 省略 import Layout from '../components/Layout' export default class MyApp extends App { // 省略 render() { const { Component, pageProps } = this.props return ( <Container> {/* Layout包在外面 */} <Layout> {/* 把pageProps解构后传递给组件 */} <Component {...pageProps} /> </Layout> </Container> ) } } 复制代码
document title 的解决方案
例如在 pages/a.js 这个页面中,我希望网页的 title 是 a,在 b 页面中我希望 title 是 b,这个功能 next 也给我们提供了方案
pages/a.js
import Head from 'next/head' const A = ({ name }) => ( <> <Head> <title>A</title> </Head> <span>这是A页面, 通过getInitialProps获得的name是{name}</span> </> ) export default A 复制代码
样式的解决方案(css in js)
next 默认采用的是 styled-jsx 这个库 github.com/zeit/styled…
需要注意的点是:组件内部的 style 标签,只有在组件渲染后才会被加到 head 里生效,组件销毁后样式就失效。
组件内部样式
next 默认提供了样式的解决方案,在组件内部写的话默认的作用域就是该组件,写法如下:
const A = ({ name }) => ( <> <span className="link">这是A页面</span> <style jsx> {` .link { color: red; } `} </style> </> ) export default A ) 复制代码
我们可以看到生成的 span 标签变成了
<span class="jsx-3081729934 link">这是A页面</span>
生效的 css 样式变成了
.link.jsx-3081729934 { color: red; }
通过这种方式做到了组件级别的样式隔离,并且 link 这个 class 假如在全局有定义样式的话,也一样可以得到样式。
全局样式
<style jsx global> {` .link { color: red; } `} </style>
样式的解决方案(styled-component)
首先安装依赖
yarn add styled-components babel-plugin-styled-components
然后我们在.babelrc 中加入 plugin
{ "presets": ["next/babel"], "plugins": [ [ "import", { "libraryName": "antd" } ], ["styled-components", { "ssr": true }] ] }
在 pages/_document.js 里加入 jsx 的支持,这里用到了 next 给我们提供的一个覆写 app 的方法,其实就是利用高阶组件。
import Document, { Html, Head, Main, NextScript } from 'next/document' import { ServerStyleSheet } from 'styled-components' export default class MyDocument extends Document { static async getInitialProps(ctx) { const sheet = new ServerStyleSheet() // 劫持原本的renderPage函数并重写 const originalRenderPage = ctx.renderPage try { ctx.renderPage = () => originalRenderPage({ // 根App组件 enhanceApp: App => props => sheet.collectStyles(<App {...props} />), }) // 如果重写了getInitialProps 就要把这段逻辑重新实现 const props = await Document.getInitialProps(ctx) return { ...props, styles: ( <> {props.styles} {sheet.getStyleElement()} </> ), } } finally { sheet.seal() } } // 如果要重写render 就必须按照这个结构来写 render() { return ( <Html> <Head /> <body> <Main /> <NextScript /> </body> </Html> ) } } 复制代码
然后在 pages/a.js 中
import styled from 'styled-components' const Title = styled.h1` color: yellow; font-size: 40px; ` const A = ({ name }) => ( <> <Title>这是A页面</Title> </> ) export default A 复制代码
next 中的 LazyLoading
next 中默认帮我们开启了 LazyLoading,切换到对应路由才会去加载对应的 js 模块。
LazyLoading 一般分为两类
- 异步加载模块
- 异步加载组件
首先我们利用 moment 这个库演示一下异步加载模块的展示。
异步加载模块
我们在 a 页面中引入 moment 模块 // pages/a.js
import styled from 'styled-components' import moment from 'moment' const Title = styled.h1` color: yellow; font-size: 40px; ` const A = ({ name }) => { const time = moment(Date.now() - 60 * 1000).fromNow() return ( <> <Title>这是A页面, 时间差是{time}</Title> </> ) } export default A 复制代码
这会带来一个问题,如果我们在多个页面中都引入了 moment,这个模块默认会被提取到打包后的公共的 vendor.js 里。
我们可以利用 webpack 的动态 import 语法
A.getInitialProps = async ctx => { const moment = await import('moment') const timeDiff = moment.default(Date.now() - 60 * 1000).fromNow() return { timeDiff } }
这样只有在进入了 A 页面以后,才会下载 moment 的代码。
异步加载组件
next 官方为我们提供了一个dynamic
方法,使用示例:
import dynamic from 'next/dynamic' const Comp = dynamic(import('../components/Comp')) const A = ({ name, timeDiff }) => { return ( <> <Comp /> </> ) } export default A
使用这种方式引入普通的 react 组件,这个组件的代码就只会在 A 页面进入后才会被下载。
next.config.js 完整配置
next 回去读取根目录下的next.config.js
文件,每一项都用注释标明了,可以根据自己的需求来使用。
const withCss = require('@zeit/next-css') const configs = { // 输出目录 distDir: 'dest', // 是否每个路由生成Etag generateEtags: true, // 本地开发时对页面内容的缓存 onDemandEntries: { // 内容在内存中缓存的时长(ms) maxInactiveAge: 25 * 1000, // 同时缓存的页面数 pagesBufferLength: 2, }, // 在pages目录下会被当做页面解析的后缀 pageExtensions: ['jsx', 'js'], // 配置buildId generateBuildId: async () => { if (process.env.YOUR_BUILD_ID) { return process.env.YOUR_BUILD_ID } // 返回null默认的 unique id return null }, // 手动修改webpack配置 webpack(config, options) { return config }, // 手动修改webpackDevMiddleware配置 webpackDevMiddleware(config) { return config }, // 可以在页面上通过process.env.customkey 获取 value env: { customkey: 'value', }, // 下面两个要通过 'next/config' 来读取 // 可以在页面上通过引入 import getConfig from 'next/config'来读取 // 只有在服务端渲染时才会获取的配置 serverRuntimeConfig: { mySecret: 'secret', secondSecret: process.env.SECOND_SECRET, }, // 在服务端渲染和客户端渲染都可获取的配置 publicRuntimeConfig: { staticFolder: '/static', }, } if (typeof require !== 'undefined') { require.extensions['.css'] = file => {} } // withCss得到的是一个nextjs的config配置 module.exports = withCss(configs) 复制代码
ssr 流程
next 帮我们解决了 getInitialProps 在客户端和服务端同步的问题,

next 会把服务端渲染时候得到的数据通过NEXT_DATA这个 key 注入到 html 页面中去。
比如我们之前举例的 a 页面中,大概是这样的格式
script id="__NEXT_DATA__" type="application/json"> { "dataManager":"[]", "props": { "pageProps":{"timeDiff":"a minute ago"} }, "page":"/a", "query":{}, "buildId":"development", "dynamicBuildId":false, "dynamicIds":["./components/Comp.jsx"] } </script>
引入 redux (客户端普通写法)
yarn add redux
在根目录下新建 store/store.js 文件
// store.js
import { createStore, applyMiddleware } from 'redux' import ReduxThunk from 'redux-thunk' const initialState = { count: 0, } function reducer(state = initialState, action) { switch (action.type) { case 'add': return { count: state.count + 1, } break default: return state } } // 这里暴露出的是创建store的工厂方法 // 每次渲染都需要重新创建一个store实例 // 防止服务端一直复用旧实例 无法和客户端状态同步 export default function initializeStore() { const store = createStore(reducer, initialState, applyMiddleware(ReduxThunk)) return store } 复制代码
引入 react-redux
yarn add react-redux
然后在_app.js 中用这个库提供的 Provider 包裹在组件的外层 并且传入你定义的 store
import { Provider } from 'react-redux' import initializeStore from '../store/store' ... render() { const { Component, pageProps } = this.props return ( <Container> <Layout> <Provider store={initializeStore()}> {/* 把pageProps解构后传递给组件 */} <Component {...pageProps} /> </Provider> </Layout> </Container> ) } 复制代码
在组件内部
import { connect } from 'react-redux' const Index = ({ count, add }) => { return ( <> <span>首页 state的count是{count}</span> <button onClick={add}>增加</button> </> ) } function mapStateToProps(state) { const { count } = state return { count, } } function mapDispatchToProps(dispatch) { return { add() { dispatch({ type: 'add' }) }, } } export default connect( mapStateToProps, mapDispatchToProps )(Index) 复制代码
利用 hoc 集成 redux 和 next
在上面 引入 redux (客户端普通写法)
介绍中,我们简单的和平常一样去引入了 store,但是这种方式在我们使用 next 做服务端渲染的时候有个很严重的问题,假如我们在 Index 组件的 getInitialProps 中这样写
Index.getInitialProps = async ({ reduxStore }) => { store.dispatch({ type: 'add' }) return {} }
进入 index 页面以后就会报一个错误
Text content did not match. Server: "1" Client: "0"
并且你每次刷新 这个 Server 后面的值都会加 1,这意味着如果多个浏览器同时访问,store
里的count
就会一直递增,这是很严重的 bug。
这段报错的意思就是服务端的状态和客户端的状态不一致了,服务端拿到的count
是 1,但是客户端的count
却是 0,其实根本原因就是服务端解析了 store.js
文件以后拿到的 store
和客户端拿到的 store
状态不一致,其实在同构项目中,服务端和客户端会持有各自不同的 store
,并且在服务端启动了的生命周期中 store
是保持同一份引用的,所以我们必须想办法让两者状态统一,并且和单页应用中每次刷新以后store
重新初始化这个行为要一致。在服务端解析过拿到 store
以后,直接让客户端用服务端解析的值来初始化 store。
总结一下,我们的目标有:
- 每次请求服务端的时候(页面初次进入,页面刷新),store 重新创建。
- 前端路由跳转的时候,store 复用之前创建好的。
- 这种判断不能写在每个组件的 getInitialProps 里,想办法抽象出来。
所以我们决定利用hoc
来实现这个逻辑复用。
首先我们改造一下 store/store.js,不再直接暴露出 store 对象,而是暴露一个创建 store 的方法,并且允许传入初始状态来进行初始化。
import { createStore, applyMiddleware } from 'redux' import ReduxThunk from 'redux-thunk' const initialState = { count: 0, } function reducer(state = initialState, action) { switch (action.type) { case 'add': return { count: state.count + 1, } break default: return state } } export default function initializeStore(state) { const store = createStore( reducer, Object.assign({}, initialState, state), applyMiddleware(ReduxThunk) ) return store } 复制代码
在 lib 目录下新建 with-redux-app.js,我们决定用这个 hoc 来包裹_app.js 里导出的组件,每次加载 app 都要通过我们这个 hoc。
import React from 'react' import initializeStore from '../store/store' const isServer = typeof window === 'undefined' const __NEXT_REDUX_STORE__ = '__NEXT_REDUX_STORE__' function getOrCreateStore(initialState) { if (isServer) { // 服务端每次执行都重新创建一个store return initializeStore(initialState) } // 在客户端执行这个方法的时候 优先返回window上已有的store // 而不能每次执行都重新创建一个store 否则状态就无限重置了 if (!window[__NEXT_REDUX_STORE__]) { window[__NEXT_REDUX_STORE__] = initializeStore(initialState) } return window[__NEXT_REDUX_STORE__] } export default Comp => { class withReduxApp extends React.Component { constructor(props) { super(props) // getInitialProps创建了store 这里为什么又重新创建一次? // 因为服务端执行了getInitialProps之后 返回给客户端的是序列化后的字符串 // redux里有很多方法 不适合序列化存储 // 所以选择在getInitialProps返回initialReduxState初始的状态 // 再在这里通过initialReduxState去创建一个完整的store this.reduxStore = getOrCreateStore(props.initialReduxState) } render() { const { Component, pageProps, ...rest } = this.props return ( <Comp {...rest} Component={Component} pageProps={pageProps} reduxStore={this.reduxStore} /> ) } } // 这个其实是_app.js的getInitialProps // 在服务端渲染和客户端路由跳转时会被执行 // 所以非常适合做redux-store的初始化 withReduxApp.getInitialProps = async ctx => { const reduxStore = getOrCreateStore() ctx.reduxStore = reduxStore let appProps = {} if (typeof Comp.getInitialProps === 'function') { appProps = await Comp.getInitialProps(ctx) } return { ...appProps, initialReduxState: reduxStore.getState(), } } return withReduxApp } 复制代码
在_app.js 中引入 hoc
import App, { Container } from 'next/app' import 'antd/dist/antd.css' import React from 'react' import { Provider } from 'react-redux' import Layout from '../components/Layout' import initializeStore from '../store/store' import withRedux from '../lib/with-redux-app' class MyApp extends App { // App组件的getInitialProps比较特殊 // 能拿到一些额外的参数 // Component: 被包裹的组件 static async getInitialProps(ctx) { const { Component } = ctx let pageProps = {} // 拿到Component上定义的getInitialProps if (Component.getInitialProps) { // 执行拿到返回结果` pageProps = await Component.getInitialProps(ctx) } // 返回给组件 return { pageProps, } } render() { const { Component, pageProps, reduxStore } = this.props return ( <Container> <Layout> <Provider store={reduxStore}> {/* 把pageProps解构后传递给组件 */} <Component {...pageProps} /> </Provider> </Layout> </Container> ) } } export default withRedux(MyApp) 复制代码
这样,我们就实现了在 next 中集成 redux。