本文參考了慕課網jokcy老師的React16.8+Next.js+Koa2開發Github全棧項目,也算是作個筆記吧。css
Next.js 是一個輕量級的 React 服務端渲染應用框架。前端
官網:nextjs.org
中文官網:nextjs.frontendx.cnnode
當使用 React 開發系統的時候,經常須要配置不少繁瑣的參數,如 Webpack 配置、Router 配置和服務器配置等。若是須要作 SEO,要考慮的事情就更多了,怎麼讓服務端渲染和客戶端渲染保持一致是一件很麻煩的事情,須要引入不少第三方庫。針對這些問題,Next.js提供了一個很好的解決方案,使開發人員能夠將精力放在業務上,從繁瑣的配置中解放出來。下面咱們一塊兒來從零開始搭建一個完善的next項目。react
首先安裝 create-next-app 腳手架webpack
npm i -g create-next-app
複製代碼
而後利用腳手架創建 next 項目git
create-next-app next-github
cd next-github
npm run dev
複製代碼
能夠看到 pages 文件夾下的 index.jsgithub
生成的目錄結構很簡單,咱們稍微加幾個內容web
├── README.md
├── components // 非頁面級共用組件
│ └── nav.js
├── package-lock.json
├── package.json
├── pages // 頁面級組件 會被解析成路由
│ └── index.js
├── lib // 一些通用的js
├── static // 靜態資源
│ └── favicon.ico
複製代碼
啓動項目以後,默認端口啓動在 3000 端口,打開 localhost:3000 後,默認訪問的就是 index.js 裏的內容npm
若是要集成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
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({})
複製代碼
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
複製代碼
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
是由於這種別名的方法只是在前端路由跳轉的時候加上的
刷新時請求走了服務端就認不得這個路由了
// 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}`)
})
})
複製代碼
在一次路由跳轉中,前後會觸發
routeChangeStart
beforeHistoryChange
routeChangeComplete
若是有錯誤的話,則會觸發
routeChangeError
監聽的方式是
Router.events.on(eventName, callback)
複製代碼
在 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>
)
}
}
複製代碼
next 中,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
這個靜態方法返回的值 都會被當作 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 裏獲取數據的邏輯
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> ) } } 複製代碼
咱們但願每一個頁面跳轉之後,均可以有共同的頭部導航欄,這就能夠利用_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> ) } } 複製代碼
例如在 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 複製代碼
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>
複製代碼
首先安裝依賴
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,切換到對應路由纔會去加載對應的 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 回去讀取根目錄下的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)
複製代碼
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>
複製代碼
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
}
複製代碼
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) 複製代碼
在上面 引入 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。
總結一下,咱們的目標有:
因此咱們決定利用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。