Next.js 是一个 React 应用框架,使用它可以快速上手开发 React 应用,而不是先花很多时间和精力去折腾各种开发工具。AntD 是蚂蚁金服开源的一个 React 组件库,提供了许多功能强大的 UI 组件,特别适合业务复杂的企业级后台产品。两者相结合,可以快速开发出交互复杂和体验良好的 Web 应用。

本文对 NextJS 和 AntD 框架做了入门介绍,如想全面和深入学习,可报名学习课程 叽歪课堂 - NextJS + AntD React 应用开发实战

NextJS 介绍

熟悉 React 的开发人员都知道,它只是一个用来构建 UI 的库,这对于开发一个完整的 React 应用是远远不够的。我们还需要构建、打包和运行应用等开发工具,比如使用 Babel 转换使用了新特性的 JS 代码、处理 SASS 和 LESS 样式文件等。开发应用时,除了构建界面,还需要处理页面路由、获取服务端数据、管理应用状态等。为了让应用对搜索引擎友好,最好支持服务端渲染。如果自己从零去安装配置各种开发工具,需要花费许多时间和精力,因此 React 官方提供了 Create React App(简称 CRA)工具来降低上手开发 React 应用的门槛。NextJS 可以看作是 CRA 的升级版,它具有以下重要特性。

服务端渲染(SSR)

CRA 默认只支持客户端渲染(CSR),首屏需要先加载应用基础代码然后在客户端生成页面并渲染,打开速度较慢。此外客户端渲染对搜索引擎不友好,它抓取到的是空白页面,除非搜索引擎加载并执行页面中的 JS 代码。NextJS 通过服务端渲染解决了这两个问题。首屏在服务端生成 HTML 并返回给浏览器,浏览器直接渲染就行,服务端性能一般比客户端强,因此首屏打开非常快。后续打开的页面将在客户端渲染,因此不会造成页面整体刷新,体验上跟单页面应用一致。搜索引擎来抓取页面的时候,服务端会返回生成好的 HTML 内容,因此网站内容能够被收录。

基于文件系统的页面路由

一般编写 React 应用都需要使用第三方的路由组件,比如 react-router ,并且需要建立 URL 路径跟页面组件的对应关系,比较麻烦。而 NextJS 则采用了类似传统服务端页面生成网站(比如 PHP)的方式,页面 URL 路径跟磁盘文件路径一一对应,这样就不用去为每个页面创建路由了。并且 NextJS 还支持指定路径别名,也就是显示在浏览器地址栏里的路径可以跟磁盘文件路径不一样。

自动按页面拆分代码

NextJS 将每个页面单独打包,打开首页时会加载应用基础代码和首页代码,其它页面代码只会在打开时才去加载,这对于大型应用来说非常有用。并且 NextJS 还支持页面预取,在链接页面时可以选择是否在打开本页时就预先获取被链接页面的代码,这样打开链接时就不需要再发送网络请求,直接渲染!

静态页面导出

如果网站页面数据都可以预先确定,不需要在运行时再从其它地方获取,那么甚至可以将整个 NextJS 应用导出为一个静态网站。这样就没有任何运行时的开销了,使用 Nginx 这样的 Web 服务器对外提供静态网页服务就可以,适合流量非常大的网站。

CSS-in-JS

目前已经有很多 CSS-in-JS 的方案,包括 React 使用的 JSX 就支持通过 style 属性指定组件样式。但 React 使用的 CSS 属性名称跟标准不一样,它用的是驼峰方式,而标准是小写+中划线方式,另外 React 不是百分百支持所有 CSS 属性。NextJS 内置了 styled-jsx 方案,样式写法遵循标准,并且样式作用域局限于组件内部而不是全局,避免了组件之间样式互相影响。

比如下面的代码,对 p 标签设置的样式只会作用于本组件内的 p 标签,并且不会作用于本组件内嵌入的其它组件里的 p 标签。

export default () => (
  <div>
    <p>only this paragraph will get the style :)</p>

    { /* you can include <Component />s here that include
         other <p>s that don't get unexpected styles! */ }

    <style jsx>{`
      p {
        color: red;
      }
    `}</style>
  </div>
)

AntD 介绍

AntD 是一套用于构建 Web 界面的 React 组件库,是蚂蚁金服前端团队的技术结晶。

设计语言

AntD 有自己的设计语言,定义了交互、视觉、模式、可视化、动效等界面体验相关的原则和方案。遵循这套设计语言,不懂设计的开发人员也可以编写出界面美观、用户体验良好的 Web 页面。

组件库

AntD 组件库里提供了大量功能强大、可定制性强的组件,涵盖布局、导航、数据录入、数据展示、反馈等各种前端交互场景。熟练使用这些组件,能够快速实现复杂的前端交互,大大提升开发效率。

开发实战

本项目要开发的是 及未支付 的精简版,本文仅摘取部分示例,完整代码可从 GitHub 仓库 React in Practice 获取。

目录结构

为了便于理解代码,首先来看一下完整的项目目录结构。

.
├── Dockerfile # Docker 镜像构建文件
├── README.md
├── actions # Redux actions,包括 api 调用
├── assets # CSS 等需要打包的静态资源
├── components # 公用组件
├── lib # API 调用、反馈、工具函数等公共库
├── next.config.js # Next.js 应用配置
├── node_modules
├── package-lock.json
├── package.json
├── pages # 页面
├── reducers # Redux reducers
├── server.js # 自定义 Next.js server
├── static # 对外公开的静态资源
└── store.js # Redux store 创建

项目采用了典型的 Redux 应用结构,但组件并没有区分容器组件和展示组件。所有页面都放在 pages 目录下,这也是 Next.js 框架的要求。

运行应用

  1. 克隆代码到本地并安装依赖包
git clone https://github.com/jaggerwang/react-in-practice && cd react-in-practice
npm install
  1. 开发模式运行
npm run dev
  1. 生产模式运行
npm run build
npm run start

在浏览器里打开网址 http://localhost:3000,即可访问应用。由于缺乏后端 API 服务,默认会使用内置的模拟 API 服务。下面我们选取部分核心代码和配置来进行讲解。

核心代码和配置

创建 Redux Store

由于 NextJS 应用的首屏在服务端渲染,因此集成 Redux 比纯客户端渲染的 React 应用会麻烦一些。不过可以使用 next-redux-wrapper 这个 Next.js 扩展来简化集成工作。下面是创建 store 对象的代码。

store.js

import getConfig from 'next/config'
import { createStore, applyMiddleware } from 'redux'
import { composeWithDevTools } from 'redux-devtools-extension'
import logger from 'redux-logger'
import thunk from 'redux-thunk'

import { compareVersion } from './lib'
import reducer from './reducers'

const { publicRuntimeConfig } = getConfig()

export function makeStore(initialState, { isServer }) {
  initialState = initialState || reducer()

  const middlewares = [thunk]
  if (publicRuntimeConfig.logReduxAction) {
    middlewares.push(logger)
  }
  const enhancer = composeWithDevTools(applyMiddleware(...middlewares))

  if (isServer) {
    return createStore(reducer, initialState, enhancer)
  } else {
    const { persistReducer, persistStore } = require('redux-persist')
    const storage = require('redux-persist/lib/storage').default

    const persistedReducer = persistReducer({
      key: 'jwpay',
      whitelist: ['common', 'form', 'account'],
      storage,
      migrate: state => {
        if (state && compareVersion(state.common.version, process.env.version, 2) !== 0) {
          state = initialState
        }
        return Promise.resolve(state)
      },
    }, reducer)
    const store = createStore(persistedReducer, initialState, enhancer)

    store.__persistor = persistStore(store)

    return store
  }
}

为了支持 API 调用,给 store 添加了 thunk 中间件。如果开启了 action 日志打印,还会添加 logger 中间件。

通过 isServer 变量可以判断当前环境是服务端还是客户端,该变量在 next-redux-wrapper 调用 makeStore 时会传递进来。如果是客户端环境,将使用 redux-persist 来持久化存放在 store 里的应用状态。创建持久化 reducer 时,通过 whitelist 选项来指明哪些子模块的状态才需要持久化。为了防止应用版本更新后,缓存在本地的应用状态的数据结构跟代码不匹配,通过 migrate 选项来在必要的时候执行数据迁移。也就是如果发现本地状态的版本跟代码版本不匹配(只比较 main 和 minor 版本号),则重置本地状态为最新版本的初始状态。

自定义应用入口

因为要集成 Redux,Next.js 框架自带的应用入口已经满足不了要求,需要自定义应用入口,这可以通过在 pages 目录下添加 _app.js 文件来实现。

pages/_app.js

import React from 'react'
import App from 'next/app'
import getConfig from 'next/config'
import { Provider } from 'react-redux'
import withRedux from 'next-redux-wrapper'
import { ConfigProvider } from 'antd'
import zhCN from 'antd/lib/locale-provider/zh_CN'
import moment from 'moment'
import 'moment/locale/zh-cn'

import { makeStore } from '../store'
import { pageview, JWPApiResponse } from '../lib'
import ErrorPage from './error'

import '../assets/main.less'

moment.locale('zh-cn')

const { publicRuntimeConfig } = getConfig()

if (publicRuntimeConfig.enableGoogleAnalytics === 'true') {
  Router.events.on('routeChangeComplete', url => pageview(url))
}

class JWPApp extends App {
  static async getInitialProps({ Component, ctx }) {
    const { req, res, pathname, query, store } = ctx

    let pageError, pageProps
    try {
      pageProps = Component.getInitialProps ? await Component.getInitialProps(ctx) : {};
    } catch (error) {
      if (error instanceof JWPApiResponse) {
        pageError = { status: error.status, title: error.message }
      } else {
        throw error
      }
    }

    return { pathname, query, store, pageError, pageProps }
  }

  componentDidMount() {
    const { pageError } = this.props
    if (pageError) {
      return
    }
  }

  render() {
    const { Component, pathname, query, store, pageError, pageProps } = this.props

    return (
      <ConfigProvider locale={zhCN}>
        <Provider store={store}>
          {pageError ?
            <ErrorPage {...{ pathname, query, store }} {...pageError} /> :
            <Component {...{ pathname, query, store }} {...pageProps} />
          }
        </Provider>
      </ConfigProvider>
    )
  }
}

export default withRedux(makeStore)(JWPApp)

应用入口里最重要的就是给下层子组件提供 store 对象。withRedux(makeStore)(JWPApp) 这句代码会先调用我们前面定义的 makeStore 函数来创建 store,然后 withRedux(makeStore) 会创建一个高阶组件,该组件会给内层 JWPApp 组件注入创建好的 store 对象。这样我们在 JWPApp 的静态方法 getInitialProps 里才能够从上下文对象 ctx 里获取到 store 对象。

除了集成 Redux,应用入口还有一个重要的工作就是处理页面初始化异常。这里 Component 组件即为当前页面组件,在 JWPAppgetInitialProps 方法里会调用页面组件的 getInitialProps 方法来生成页面组件的初始化属性。在页面组件的 getInitialProps 方法里通常会调用 API 来获取页面渲染需要的数据,由于执行网络请求容易出错,因此需要进行异常处理。为了避免在每个页面重复处理,最好放在外层 JWPAppgetInitialProps 方法里来完成。如果 JWPApp 自身也需要请求 API,那么同样需要放在 try catch 语句块里。JWPApp 在渲染的时候会判断初始化时是否出现了异常,如果是则改为渲染错误页,否则正常渲染页面。

另外在应用入口里还完成了其它一些初始化任务,包括引入全局样式文件、AntD 和 moment 本地化配置等。

AntD 配置

Next.js 默认的 webpack 配置就已支持 AntD 组件的 JS 代码按需加载,但组件样式代码的按需加载需要自定义 babel 配置,比较麻烦。好在 AntD 整个组件库的样式代码并不大,只有几百 KB,在网络传输时如果启用 gzip 压缩就更小了。因此本项目不对样式代码使用按需加载,在前面的应用入口里,直接引入了所有组件的样式。

AntD 样式使用的是 LESS 语法,提供了许多 LESS 变量来允许自定义主题,包括颜色、字号、背景、边框等。如果不需要自定义样式,可以直接引入已经转换好的 CSS 文件。如果需要自定义主题,则需要加载LESS 文件并覆盖相关 LESS 变量。Next.js 默认不支持 LESS,可以按如下步骤来配置支持并修改 AntD 样式变量。

  1. 安装 less 和 Next.js 插件
npm install --save @zeit/next-less less
  1. 修改 Next.js 配置(该配置文件默认没有放出)

next.config.js

const withLess = require('@zeit/next-less')

module.exports = withLess({
  lessLoaderOptions: {
    javascriptEnabled: true,
    modifyVars: {
        'primary-color': '#d4380d',
    },
  },
})

上面的 lessLoaderOptions 配置使得可以使用 JS 变量去修改 LESS 变量,按需加载组件样式代码时需要这个配置。不过本项目并没有使用按需加载,因此这个配置可以去掉,使用默认配置即可。如果 LESS 变量定义在外部 LESS 文件里,可以读取其内容出来并使用 less-vars-to-js 这个库来将里面的 LESS 变量提取为 JS 变量。

  1. 创建一个 LESS 文件来保存需要修改的 AntD 样式变量

assets/antd-custom.less

@primary-color: #d4380d;
@link-color: #610b00;

该文件会在 main.less 文件(已在前面应用入口里引入)里引入,并且位于 antd.less 引入之后,以便覆盖对应变量值。

assets/main.less

@import "~antd/dist/antd.less";
@import 'antd-custom.less';

...

使用 REST URL

Next.js 的 Router 除了支持传统的 query 参数格式的 URL,也支持 REST 那样的把参数放在路径里的 URL 格式。这是通过地址别名来实现的,只需在使用 Link 组件的时候同时指定 hrefas 属性就可以。href 是真实的页面地址,as 为显示在浏览器地址栏里的地址,如果不指定则跟 href 一致。下面的例子里任务页的真实地址为 /task/detail?id=1,但显示在浏览器地址栏里的为 /tasks/1

<Link
  href={{ pathname: '/task/detail', query: { id: task.id } }}
  as={`/tasks/${task.id}`}
>
  <a>{task.title}</a>
</Link>

上面的实现只支持客户端渲染,如果刷新页面则会报 404 错误。这是因为刷新时页面在服务端渲染,Next.js 默认会去磁盘里寻找 ./pages/tasks/1.js 这个文件,但这个文件并不存在。为了解决这个问题,需要自定义 Next.js 的 server 来处理虚拟路径。

server.js

const { parse } = require('url')
const express = require('express')
const next = require('next')

const devProxy = {
  '/api': {
    target: 'http://localhost:4000',
    pathRewrite: { '^/api': '/' },
    changeOrigin: true,
  },
}

const port = 3000
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()

app.prepare()
  .then(() => {
    server = express()

    if (dev && devProxy) {
      const proxyMiddleware = require('http-proxy-middleware')
      Object.keys(devProxy).forEach(function (context) {
        server.use(proxyMiddleware(context, devProxy[context]))
      })
    }

    server.all('*', (req, res) => {
      const parsedUrl = parse(req.url, true)

      const { pathname } = parsedUrl
      let m = pathname.match(/^\/tasks\/(\d+)$/)
      if (m) {
        parsedUrl.pathname = '/task/detail'
        parsedUrl.query.id = m[1]
      }
      m = pathname.match(/^\/users\/(\d+)$/)
      if (m) {
        parsedUrl.pathname = '/user/detail'
        parsedUrl.query.id = m[1]
      }

      handle(req, res, parsedUrl)
    })

    server.listen(port, (err) => {
      if (err) throw err
      console.log(`> Ready on http://localhost:${port}`)
    })
  })
  .catch(err => {
    console.log('An error occurred, unable to start the server')
    console.log(err)
  })

handle 处理请求之前,使用正则表达式去匹配路径是否为美化后的路径,并且提取出路径里的参数,然后转换为真实的页面路径和 query 参数。这样就解决了虚拟路径页面在服务端的渲染问题。

另外在自定义 server 里还使用了 proxyMiddleware 中间件来转发 /api 打头的请求到 API 服务,这样可以避免页面服务域名和 API 服务域名不是同一个而带来的跨域问题。

Docker 镜像构建

Docker 镜像可以大大简化部署流程,同时还能避免环境不一致引起的问题。构建镜像需要一个构建文件,其内容如下。

Dockerfile

FROM node:10.15

WORKDIR /app

COPY package.json .
COPY package-lock.json .
RUN npm install

COPY . .
RUN npm run build

EXPOSE 3000

CMD ["npm", "run", "start"]

为了尽量利用构建缓存,避免每次都重新安装 npm 包,这里先拷贝了 package.jsonpackage-lock.json 文件,再拷贝其余文件。每次构建时,只要这两个文件没有发生变化,那么就会使用上次的构建缓存,省去了下载和安装的时间。

参考资料

  1. React in Practice
  2. Next.js
  3. AntD
  4. React
  5. Styled-jsx
  6. Next-redux-wrapper
  7. Redux-persist