本文参考了慕课网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。