Next.js部署web同构直出应用全指南(MobX + TypeScript)

前言

有关Next.js、同构直出、SEO、SPA等相关介绍将再也不赘述,本文主要针对Next.js配合TypeScript和MobX搭建一个完整的生产部署的前端工程进行核心代码的分析以及主要坑点的讲解,非Next.js入门课程,下面我将会列出本教程所须要的前置预备知识和能力:javascript

  • nodejs服务端编程基础
  • 已至少阅读一遍Next.js官方文档
  • 熟练使用React
  • 熟练使用webpack
  • 理解同构直出的概念和它解决了什么样的痛点
  • 有必定的前端工程化、自动化部署的经验

正文开始时,也就默认了有缘阅读到此文的同窗均具有上述能力css

原文地址:Echo Lynn's Bloghtml

做者将在原文上持续分享关于Next.js的高级拓展经验,有兴趣的朋友也能够在博客上留言你遇到的问题或者与做者交流前端

建立基于TypeScript的项目

Zeit在2019/07发布了Next.js 9 该版本最吸人眼球的两个Feature分别是 Built-in Zero-Config TypeScript SupportFile system-Based Dynamic Routing零配置内置TypeScript支持基于文件系统的动态路由支持,这里主要说起一下关于TypeScript的支持。在9.0以前的版本,Next.js从6.0开始经过一个名为 @zeit/next-typescript 提供了基础版本的TypeScript支持,但并无整合类型检查,Next.js核心代码自己也不提供types类型因此这个版本提供的TypeScript支持并不友好。Zeit本次发布的Next.js 9 核心代码使用TypeScript重构,所以给开发体验带来了极致的提高。如下将使用官方提供的Demo with-typescript 做为种子项目,后面内容将在这个项目上进行集成java

安装

npx create-next-app --example with-typescript with-typescript-app
# or
yarn create next-app --example with-typescript with-typescript-app
复制代码

启动

cd with-typescript-app
yarn dev
复制代码

获得如下目录结构:node

with-typescript-app
├─ .gitignore
├─ README.md
├─ components
│  ├─ Layout.tsx
│  ├─ List.tsx
│  ├─ ListDetail.tsx
│  └─ ListItem.tsx
├─ interfaces
│  └─ index.ts
├─ next-env.d.ts
├─ package.json
├─ pages
│  ├─ about.tsx
│  ├─ detail.tsx
│  ├─ index.tsx
│  └─ initial-props.tsx
├─ tsconfig.json
├─ utils
│  └─ sample-api.ts
└─ yarn.lock
复制代码

使用MobX做为app状态管理方案

有关MobX的介绍请自行官网查阅:[mobx.js.org/]react

安装依赖

安装mobx、mobx-react模块:webpack

yarn add mobx mobx-react
// or
npm install --save mobx mobx-react
复制代码

安装babel plugin对装饰器提供编译支持:git

yarn add -D @babel/plugin-proposal-class-properties @babel/plugin-proposal-decorators
// or
npm install --save-dev @babel/plugin-proposal-class-properties @babel/plugin-proposal-decorators
复制代码

配置

建立一个.babelrc的文件在工程的根目录github

touch .babelrc
vi .babelrc
复制代码

写入

{
  "presets": [
    "next/babel"
  ],
  "plugins": [
    ["@babel/plugin-proposal-decorators", { "legacy": true }],
    ["@babel/plugin-proposal-class-properties", { "loose": true }]
  ]
}
复制代码

并在tsconfig.json中加入一行配置来使ts支持装饰器语法:

{
  "compilerOptions": {
    "experimentalDecorators": true
  }
}
复制代码

store子模块代码实现

建立stores文件夹并建立user.ts:

mkdir stores
touch stores/user.ts
复制代码

写入:

// user.ts
import {action, observable} from 'mobx'

export default class UserStore {

  @observable name: string = 'Clint'
  
  constructor (initialState: any = {}) {
    this.name = initialState.name;
  }

  @action setName(name: string) {
    this.name = name
  }
}
复制代码

UserStore类中的构造函数的意义是:接受初始化数据来对该store下的状态进行初始化或者将在服务端渲染首屏时已经产生的状态同步到客户端(这里是同构直出中状态同步一个很是关键的环节,只有理解得足够透彻,Next.js才能用得驾轻就熟 因为每次建立一个这样的store子模块都须要实现同样的构造函数来对模块中的状态初始化或同步,咱们能够经过编写一个基类,让全部store子模块继承这个基类来优化一下代码: 建立stores/base.ts,写入:

// base.ts
export default class Base {
  [key: string]: any

  constructor(initState: { [key: string]: any } = {}) {
    for (const k in initState) {
      if (initState.hasOwnProperty(k)) {
        this[k] = initState[k]
      }
    }
  }
}
复制代码

修改user.ts:

// user.ts
import {action, observable} from 'mobx'
import Base from './base'

export default class UserStore extends Base {

  @observable name: string = 'Clint'

  @action setName(name: string) {
    this.name = name
  }
}
复制代码

建立stores/config.ts,当有新的store子模块须要建立时候,只要经过这个配置文件引入子模块便可自动集成到根store中:

touch stores/config.ts
复制代码

写入:

import userStore from './user'
import Base from './base'

const config: { [key: string]: typeof Base } = {
  userStore
}

export default config
复制代码

MobX主体逻辑

优化了store子模块的代码之后,接下来实现store的主体逻辑,建立stores/index.ts:

touch stores/index.ts
复制代码

写入:

import {useStaticRendering} from 'mobx-react'
import config from './config'

const isServer = typeof window === 'undefined'
// Comment 1
useStaticRendering(isServer)

export class Store {
  [key: string]: any
  // Comment 2
  constructor(initialState: any = {}) {
    for (const k in config) {
      if (config.hasOwnProperty(k)) {
        this[k] = new config[k](initialState[k])
      }
    }
  }
}

let store: any = null
// Comment 3
export function initializeStore(initialState = {}) {
  if (isServer) {
    return new Store(initialState)
  }
  if (store === null) {
    store = new Store(initialState)
  }

  return store
}

复制代码

代码注释:

  1. 因为Next.js首屏渲染是在服务端执行的,MobX所建立的状态是可观察的对象,使用MobX建立的可观察对象会在内存中使用listener来监听对象的变化,但实际上在服务端是没有必要监听变化的,由于首屏渲染完成获得html文件后,后续的工做都由客户端接手,因此若是在服务端的对象是可观察的,将有可能形成内存泄漏,因此咱们使用useStaticRendering方法,当该文件在服务端执行时,让MobX建立静态的普通js对象便可
  2. 构造函数将在MobX的根store下挂载上文建立的子模块,并将接收到的初始状态/服务端透传的状态一一赋值给子模块,当赋值过程是服务端状态同步时,因为执行环境是客户端,子模块中的状态将从新得到可观察的属性,可以让使用了该状态值的react组件响应变化
  3. initializeStore 方法,服务端渲染时,每一个独立的请求都将建立一个新的store,以此来隔离请求之间的状态混淆,当客户端渲染时,只须要引用以前已经建立过的store便可,由于同一个应用程序(SPA)应该共享一颗状态树 以上即MobX状态管理的主逻辑实现,接下来将讲述MobX如何配合Next.js和react实现状态管理

mobx-react

MobX配合react实现状态管理能够引用mobx-react来实现,写代码以前咱们先来分析一下需求,即但愿MobX具有什么样能力。

前文咱们设计MobX代码结构的时候,实现了一个store的子模块概念,那么第一个问题来了,能经过注入的方式,给页面按需加载咱们所须要的store子模块吗?

另外,咱们都已经知道,Next.js是经过一个实现一个名为getInitialProps的静态方法来作到当页面被首屏请求的时候,在服务端执行getInitialProps从而获取页面渲染所需的数据来作服务端渲染的,那么第二个问题:如何在 getInitialProps 中获取store对象?

第三,上文一样提到了,咱们服务端首屏渲染的时候会产生一些初始状态存在store的某个或者某些子模块中,那么Next.js是经过什么手段将这些状态带给客户端的 而 **咱们又怎样才能让这些状态同步到客户端的store对象里来保持服务端客户端状态一致呢?**这是第三和第四个问题。概括一下须要解决的事务:

  1. 向react组件注入store子模块
  2. getInitialProps方法中使用store对象填充数据
  3. 分析Next.js数据从服务端向客户端同步的机制
  4. 同步服务端和客户端的store状态

解决第一个问题咱们须要重写Next.js的*_app.tsx*文件:

touch pages/_app.tsx
复制代码

写入:

// pages/_app.tsx
import App, {AppContext} from 'next/app'
import React from 'react'
import {initializeStore, Store} from '../stores'
import {Provider} from 'mobx-react'

class MyMobxApp extends App {

  mobxStore: Store

  // Fetching serialized(JSON) store state
  static async getInitialProps(appContext: AppContext): Promise<any> {
    const ctx: any = appContext.ctx
    // Comment 1
    ctx.mobxStore = initializeStore()
    const appProps = await App.getInitialProps(appContext)
    
    return {
      ...appProps,
      initialMobxState: ctx.mobxStore
    }
  }

  constructor(props: any) {
    super(props)
    // Comment 2
    const isServer = typeof window === 'undefined'
    this.mobxStore = isServer ? props.initialMobxState : initializeStore(props.initialMobxState)
  }

  render() {
    const {Component, pageProps}: any = this.props
    return (
      // Comment 3
      <Provider {...this.mobxStore}>
        <Component {...pageProps} />
      </Provider>
    )
  }
}

export default MyMobxApp
复制代码

代码注释:

  1. 建立(服务端)或获取(客户端)store对象命名为mobxStore,将mobxStore挂载到appContext.ctx对象上,这个对象会在页面的getInitialProps方法中做为入参传入,这就解决了上述的第二个问题

  2. 这里其实须要先解释一下Next.js同构直出的原理:当首屏被请求时,Next.js在服务端利用react渲染页面的机制(服务端渲染生命周期只会执行到render)渲染出html文件后,来知足SEO的需求和首屏页面的展现,而后返回给客户端(一般是浏览器),到了浏览器,Next.js则会跑一遍完整React的生命周期渲染,因此只要渲染结果一致,react内置的diff算法结果没有任何差别,你将不会看到页面有任何可察觉的变化 Next.js经过什么方式来保证第二点提到的渲染结果一致呢?这就是咱们要解决的第三个事务。Next.js服务端渲染html文件的同时,将本次请求产生的有关数据经过写入script 标签的方式插在html文件一并返回。起一下本地服务,咱们使用Chrome控制台看一下实际数据

yarn dev
复制代码
<script id="__NEXT_DATA__" type="application/json">
  {"dataManager":"[]","props":{"pageProps":{},"initialMobxState":{"userStore":{}}},"page":"/","query":{},"buildId":"development"}
</script>
复制代码

就是以这种方式,Next.js运行在客户端时会依据服务端带回的NEXT_DATA构建React SPA,这就是同构直出的核心原理。

从上面获得的数据,咱们不难发现initialMobxState被带回,这时,回过头来看下pages/_app.tsx中的一段代码:

constructor(props: any) {
    super(props)
    const isServer = typeof window === 'undefined'
    this.mobxStore = isServer ? props.initialMobxState : initializeStore(props.initialMobxState)
  }
复制代码

在构造函数的执行环境为客户端时,store对象会依据*NEXT_DATA中的props.initialMobxState*被建立,这就完成了服务端store的状态向客户端同步,这就解决了事务4

  1. 将store使用拓展运算符将子模块经过props注入到provider组件,配合mobx-react提供的inject方法来达到按需获取store模块的功能,下面给出一种用法代码示例,更多使用方式请移步mobx-react[github.com/mobxjs/mobx…] 了解更多

    // pages/detail.tsx
    import * as React from 'react'
    import Layout from '../components/Layout'
    import {User} from '../interfaces'
    import {findData} from '../utils/sample-api'
    import ListDetail from '../components/ListDetail'
    import {inject, observer} from 'mobx-react'
    import UserStore from '../stores/user'
    
    type Props = {
      item?: User
      userStore: UserStore
      errors?: string
    }
    
    @inject('userStore')
    @observer
    class InitialPropsDetail extends React.Component<Props> {
      static getInitialProps = async ({query, mobxStore}: any) => {
        mobxStore.userStore.setName('set by server')
        try {
          const {id} = query
          const item = await findData(Array.isArray(id) ? id[0] : id)
          return {item}
        } catch (err) {
          return {errors: err.message}
        }
      }
    
      render() {
        const {item, errors} = this.props
    
        if (errors) {
          return (
            <Layout title={`Error | Next.js + TypeScript Example`}>
              <p>
                <span style={{color: 'red'}}>Error:</span> {errors}
              </p>
            </Layout>
          )
        }
    
        return (
          <Layout
            title={`${item ? item.name : 'Detail'} | Next.js + TypeScript Example`}
          >
            {item && <ListDetail item={item}/>}
            <p>
              Name: {this.props.userStore.name}
            </p>
            <button onClick={() => {
              this.props.userStore.setName('set by client')
            }}>click to set name
            </button>
          </Layout>
        )
      }
    }
    
    export default InitialPropsDetail
    
    复制代码

    访问: [http://localhost:3000/detail?id=101] 查看效果

以上,就是基于Next.js开发的几个比较核心的思想和库的使用,下面开始介绍在构建和部署方面的内容

构建编译

Next.js使用webpack来构建打包项目,当项目不须要特殊的定制化构建的时候,执行如下命令便可构建项目包

next build
复制代码

在前言里也提到,本文着重讲部署Next.js的完整实例,那么只以默认方式构建项目显然是知足不了咱们的实际的生产诉求了,我会在这里讲一些日常咱们构建项目所须要的几个比较通用的需求点,固然覆盖不了全部,不过也能够提供一些思路。

在这里,也顺便一提,当咱们使用一个框架来搭建应用的时候,能使用框架自己提供的API实现功能请尽可能使用,这样作的好处有哪些:

  1. 避免重复造轮子
  2. 天然造成一套规范和标准,团队开发减小学习成本
  3. 文档现成,使用起来水到渠成
  4. 项目里越少带有主观偏好的代码越好

环境分割

一个生产项目避免不了环境这个问题,比较常见的项目环境分为dev test production,即开发、测试、生产,下面咱们以这类环境划分为例,多几种或者少几种同理可推

一般咱们将项目内引用到的环境变量抽离出来,用配置文件把变量存起来,根据程序运行的环境来索引对应的配置文件,取出变量使用

在根目录下建立*/config目录,分别建立dev.js,test.js,prod.js*(提一下,这里为何不是.ts文件呢,由于这个配置文件,构建时候被引用的文件,是不通过ts编译的)index.js项目根目录下执行:

mkdir config
touch config/dev.js config/test.js config/prod.js config/index.js
复制代码

分别写入:

// config/dev.js
module.exports = {
  env: 'dev'
}
复制代码
// config/test.js
module.exports = {
  env: 'test'
}
复制代码
// config/prod.js
module.exports = {
  env: 'prod'
}
复制代码
// config/index.js
const dev = require('./dev')
const test = require('./test')
const prod = require('./prod')

module.exports = {
  dev,
  test,
  prod
}
复制代码

Next.js构建(next build)和启动应用(nextnext start)经过在根目录下next.config.js文件读取定制化的配置选项,当文件不存在时,使用默认配置构建

建立next.config.js

touch next.config.js
复制代码
// next.config.js
const config = require('./config')
// Get process DEPLOY_ENV value
const DEPLOY_ENV = process.env.DEPLOY_ENV || 'dev'

module.exports = {
  serverRuntimeConfig: {
    // Will only be available on the server side
    secret: 'secret',
  },
  // Use which config file according to DEPLOY_ENV
  publicRuntimeConfig: config[DEPLOY_ENV]
}

复制代码

修改pages/index.tsx文件:

// pages/index.tsx
import * as React from 'react'
import Link from 'next/link'
import Layout from '../components/Layout'
import { NextPage } from 'next'
import getConfig from 'next/config'

const {publicRuntimeConfig, serverRuntimeConfig} = getConfig()

const IndexPage: NextPage = () => {
  return (
    <Layout title="Home | Next.js + TypeScript Example">
      <h1>Hello Next.js 👋</h1>
      <p>Public config JSON string: {JSON.stringify(publicRuntimeConfig)}</p>
      <p>Server side config JSON string: {JSON.stringify(serverRuntimeConfig)}</p>
      <p>
        <Link href="/about">
          <a>About</a>
        </Link>
      </p>
    </Layout>
  )
}

export default IndexPage
复制代码

Next.js配置文件中,有两个配置选项serverRuntimeConfigpublicRuntimeConfigserverRuntimeConfig只容许程序运行在服务端时使用,publicRuntimeConfig选项同时容许服务端和客户端获取,我用publicRuntimeConfig讲解思路

完成以上代码编写后,执行命令

next
复制代码

使用浏览器打开 [http://localhost:3000]查看效果

能够注意到浏览器显示了publicRuntimeConfigconfig/dev.js的内容,而serverRuntimeConfig为空对象,细心的朋友会注意到,当你快速不断刷新页面的时候,是能够看到serverRuntimeConfig是由{"secret": "secret"}变为{}的,为何会这样,结合上文提到的Next.js同构直出的核心思想和关于serverRuntimeConfig的特性就能够理解该现象了。

那么,如今咱们要解决的问题就是,让程序构建后跑在test/prod环境时候,页面显示config/test.js或者config/prod.js的内容了

以test为例,Next.js的构建命令为next build,启动命令为next start,运行和构建都会根据next.config.js来决定应用构建和启动的定制化配置,从代码里能够看到,咱们是根据一个叫DEPLOY_ENV的环境变量来索引配置文件的,那么咱们只须要在运行next buildnext start的时候给DEPLOY_ENV赋值便可

DEPLOY_ENV=test next build
DEPLOY_ENV=test next start
复制代码

执行完上述命令,打开[http://localhost:3000]查看页面是否已显示config/test.js的内容,有关环境分割的内容就讲到这里,更多有关环境的拓展能够依据这样的思路来实现

CSS预编译

这个就更加简单了,官方提供插件的,我就不费口舌讲一遍了,直接上连接

值得一提是的,Next.js在CSS方面有一点不足:全部的样式文件最终会被打包为一个style.chunk.css文件随着首屏加载一并返回。这会带来一点小小的缺陷就是当你的app工程庞大时,这个文件的体积会对首屏的加载带来一点影响,虽然在gzip压缩后这种影响微乎其微,不过终归是须要优化,另一个问题就是,类名冲突了,你可能须要利用像Less、Sass这样的嵌套样式写法把不相关的页面样式包裹在一个命名空间里,或者是经过配置{cssModules: true}来为你的类名打上hash后缀。

关于CSS文件切割的问题笔者已经给Next.js做者提了issue了,期待后续版本的解决方案。

动手能力强webpack原理够硬的同窗也能够尝试本身实现一下这个功能。笔者后面空下来有幸实现了的话,会再分享出来。

服务部署

Next.js不一样于普通的静态web项目,固然,Next.js也能够搭建一个普通的静态项目,不过同构直出才是它的最大亮点,因此本文全部篇幅都是基于这个点出发的,不讨论其余小众方式运行Next.js

那么想部署同构直出,就须要有web服务器,前端领域目前比较热门的仍是Node.js,Next.js的服务端也正是运行在Node.js上,下面介绍一下Next.js简单的部署方案,而后继续针对一些我认为出现频繁的一些场景讲解一下部署思路。

部署项目能够有两种方式:

一是把整个项目目录除了node_modules(固然你也能够把这个目录带上去,若是你链接服务端传输网速够快的话)之外的源文件一并上传到服务器,安装项目依赖

yarn
// or
npm install
复制代码

构建

DEPLOY_ENV=$YOUR_SERVER_ENV_TYPE next build
复制代码

启动服务

DEPLOY_ENV=$YOUR_SERVER_ENV_TYPE next start
复制代码

二是你在本地或者使用Docs Gitlab Com Runner (推荐使用,具体操做自行查阅文档)

构建后把所须要的资源上传到服务器,列一下所须要的目录清单

app
├─ .next // required
├─ pages // just empty dir, for safe
├─ next.config.js // if have
├─ server.js // if have
├─ static // if have
├─ config // Mentioned above, if have
├─ package.json // required
├─ package-lock.json // optional
└─ yarn.lock // optional
复制代码
  • .nextnext build执行后编译完成的文件目录
  • pages:建议传一个空目录。按理来讲不须要,由于里面的源文件已经被打包到*.next*目录去了,但因为最近在部署的时候遇到一个报错提示说找不到pages,弄了一个空目录就正常运行了。emm...晚点去提个issue
  • next.config.js:若是你有定制化配置的话
  • server.js:若是你有定制化node服务的话
  • static: 静态资源目录,由本身建立,Next.js编译会忽略这个目录,若是你app有引用这个目录的静态资源,须要带上
  • config:前文提到的,若是你按照本文作的环境分割的话
  • package.json:在服务器须要Next.js等的npm模块来启动服务,因此须要这个文件来安装依赖
  • package-lock.json:不解释了
  • yarn.lock:不解释了

完成传输后,运行

yarn
// or
npm install
// no build command needed
DEPLOY_ENV=$YOUR_SERVER_ENV_TYPE next start
复制代码

部署路径

众所周知,Next.js默认是经过文件系统路由的(file-system routing)。假设你项目部署的域名是www.myapp.com,你要访问*/pages目录下的home.tsx*,则访问的url为http://www.myapp.com/home,一般这样是可以知足大部分的业务场景的,这一章我想要讲的,就是比较可能出现的另一种业务场景,即单个域名下部署多个项目,不只仅是Next.js项目,也有多是Vue、React、Angular、JQuery等其余类型的web项目

......

原文连接持续更新:Echo Lynn's Blog

做者

Echo-Lynn
Ken

相关文章
相关标签/搜索