手把手帶你用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。