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

相關文章
相關標籤/搜索