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

YYDev發表於2019-11-08

前言

有關Next.js、同構直出、SEO、SPA等相關介紹將不再贅述,本文主要針對Next.js配合TypeScript和MobX搭建一個完整的生產部署的前端工程進行核心程式碼的分析以及主要坑點的講解,非Next.js入門課程,下面我將會列出本教程所需要的前置預備知識和能力:

  • nodejs服務端程式設計基礎
  • 已至少閱讀一遍Next.js官方文件
  • 熟練使用React
  • 熟練使用webpack
  • 理解同構直出的概念和它解決了什麼樣的痛點
  • 有一定的前端工程化、自動化部署的經驗

正文開始時,也就預設了有緣閱讀到此文的同學均具備上述能力

原文地址:Echo Lynn's Blog

作者將在原文上持續分享關於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 作為種子專案,後面內容將在這個專案上進行整合

安裝

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
複製程式碼

得到以下目錄結構:

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/]

安裝依賴

安裝mobx、mobx-react模組:

yarn add mobx mobx-react
// or
npm install --save mobx mobx-react
複製程式碼

安裝babel plugin對裝飾器提供編譯支援:

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的檔案在工程的根目錄

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

相關文章