用 TypeScript 編寫一個 React 服務端渲染庫(1)

我可以發表於2019-04-18

前言

程式碼都甩在 Github 上面了,歡迎隨手 star ?

踩坑的過程大概都在 TypeScript + Webpack + Koa 搭建 React 服務端渲染 這篇文章裡面

踩坑的 DEMO 放在 customize-server-side-render

我對服務端渲染有著深深的執念,後來再次基礎上寫了第一個版本的服務端渲染庫 it-ssr,但是為了區分是否服務端,是否客戶端,是否生產環境,抽取了太多 config 檔案,在服務端渲染上也加了很多不合理的邏輯,it-ssr 重寫了五六次

思路

  • 在開發時需要開啟兩個 HTTP 服務:

    1、類似 webpack-dev-server 的靜態資原始檔服務,用來提供客戶端使用的靜態資原始檔

    2、開發時主要訪問這個服務,接受客戶端的 HTTP 請求,並將 jsx 程式碼渲染成 HTML 字串的服務。

  • 在渲染 HTML 的時候,動態加入打包生成的靜態 js 檔案

然後最簡單渲染大概就能跑得起來,但是,要做一個 library 的話,其他開發者怎麼使用這個庫,入口在哪裡?怎麼區分 serverclient?這個問題當時踩了很多坑

  • clientserver 都提供一個同名的 render 方法,接受一樣的引數

  • webpack 配置下面的 resolve -> alias 區分不同環境匯出不同的檔案

 const config = {
     resolve: {
       alias: {
          'server-renderer': isServer
            ? 'server-renderer/lib/server.js' 
            : 'server-renderer/lib/client.js',
       }
     }
 }
複製程式碼

開發

配置檔案和開發等核心程式碼都會利用 TypeScript 開編寫

1、配置檔案、開發服務等 ts 程式碼會利用 taskr 將 ts 轉 js

2、庫的核心程式碼會利用 rollup 進行打包

3、使用這個庫的業務程式碼程式碼,使用 webpack 進行打包

配置檔案和開發服務的程式碼同樣可以利用 rollup

目錄結構

  • core 下面放置核心的程式碼檔案
  1. sevrer.tsx 匯出使用的服務端渲染邏輯

  2. client.tsc 匯出使用的客戶端渲染邏輯

  • config 下面放置打包 library 程式碼的 rollup 配置檔案
  • script 放置 webpack 配置檔案和打包業務程式碼開啟的開發服務等
?server-renderer
 ┣ ?config
 ┃ ┣ ?rollup.client.js
 ┃ ┗ ?rollup.server.js
 ┣ ?core
 ┃ ┣ ?client.tsx
 ┃ ┗ ?server.tsx
 ┣ ?scripts 
 ┃ ┣ ?dev.ts
 ┃ ┣ ?build.ts
 ┃ ┗ ?start.ts
複製程式碼

核心程式碼編寫

在編寫庫的時候,將 react 和 react-dom 作為 peerDependencies 安裝

(本來覺得可以寫完的,後面發現太多了,路由同構、切換和資料注水脫水等只能下次再寫一篇了...)

我們的目標是希望使用者只傳入一個 routes 配置就可以跑得起來,形如下面

import { render } from 'server-renderer'

const routes = [
  {
      path: '/',
      component: YourComponent,
  }
]

render(routes)
複製程式碼

但是使用者可能希望,外層包裹一層自己的元件

class App extends React.Component {
  public render() {
    return (
      <App>{this.props.children}</App>      
    )
  }
}
複製程式碼

但是直接把匹配到的路由元件傳給 App 並不太方便,踩了很多坑以後採用 next 的設計方式

export interface AppProps {
  Component: React.ComponentType<any>
}

class App extends React.Component<AppProps> {
  public render() {
    const { Component } = this.props
    return (
      <App>
        <Component />
      </App>      
    )
  }
}
複製程式碼

然後因為入口在庫這邊,所以 ReactDOM.hydrate(<App />, container) 這一步是由我們去完成的,因此還需要一個 container

ReactDOM.hydrate(<App />, document.querySelector(container))
複製程式碼

所以可傳入的配置項預設為

export interface Route {
  name: string
  path: string
  component: React.ComponentType<any>
}

export type AppComponentType = React.ComponentType<AppProps>

export type AppProps<T = {}> = T &{
  Component: React.ComponentType<any>
}
export interface RenderOptions {
  container: string
  routes: Route[]
  App?: AppComponentType
}
複製程式碼

客戶端

確定了引數,就可以寫個大概了,客戶端是最簡單的,所以從 client.tsx 開始

import * as React from 'react'
import { hydrate } from 'react-dom'
import path2regexp from 'path-to-regexp'

export function render(opts: RenderOptions) {
  const App = opts.App || React.Fragment
  const { pathname } = window.location
  // 假設一定匹配到,沒有 404
  const matchedRoute = opts.routes.find(({ path }) => path2regexp(path).test(pathname))
  const app = (
    <App Component={matchedRoute.component} />
  )
  hydrate(app, document.querySelector(opts.container))
}
複製程式碼

這樣子的話,一個粗糙的 client.tsx 就差不多了

在這裡並沒有判斷 App 是否為 Fragment 和 matchedRoute 為 null 的情況

服務端

服務端做的事就會比客戶端多一些,在開發的時候大概需要以後流程

  • 接受頁面的請求,根據請求的地址匹配路由

  • 利用 ReactDOM/serverjsx 渲染成 HTML 字串

  • 讀取 HTML 模板(指的是:src/index.html),將上一步生成的字串追加到模板中

  • 取得客戶端靜態資源的路徑,動態新增 script 指令碼

  • 返回給瀏覽器

所以可以大概確定這個結構

class Server {
  private readonly clientChunkPath: URL // 開發時客戶端的指令碼地址
  private readonly container: string // container
  private readonly originalHTML: string // src/index.html 讀取的原始 HTML
  private readonly App: ServerRenderer.AppComponentType
  private readonly routes: ServerRenderer.Route[]
  
  constructor(opts: ServerRenderer.RenderOptions) {
  }
  
  // 啟動開發服務
  public start() {}
  
  // 處理請求
  private handleRequest() {}
  
  // 渲染成 HTML
  private renderHTML() {}
}

export function render(opts: ServerRenderer.RenderOptions) {
  const server = new Server(opts)
  server.start()
}
複製程式碼

在建構函式裡面將 App 和 routes 等引數儲存下來,然後確定一下指令碼路徑,HTML 模板字串等

import { readFileSync } from 'fs'

const config = getConfig()
const isDev = process.env.NODE_ENV === 'development'

class Server {
  constructor(opts: ServerRenderer.RenderOptions) {
    // 根據配置拼接
    this.clientChunkPath = new URL(
      config.clientChunkName,
      `http://localhost:${config.webpackServerPort}${config.clientPublicPath}`
    )
    this.container = opts.container
    this.App = opts.App || React.Fragment
    this.routes = opts.routes
    // 這裡要區分是否開發環境,
    // 開發環境取模板來拼接 HTML
    // 生產環境直接去編譯後的 HTML 檔案,因為生產環境的檔名可能會有 hash 值等會導致 clientChunkPath 錯誤
    // 而且生產環境沒有 webpack-dev-server,拼接的 clientChunkPath 會錯誤
    const htmlPath = isDev ? config.htmlTemplatePath : config.htmlPath
    this.originalHTML = readFileSync(htmlPath, 'utf-8')
  }
}
複製程式碼

然後 start 方法比較簡單,就是啟動 koa 服務,並讓所有的請求讓 handleRequest 處理

import * as Koa from 'koa'
import * as KoaRouter from 'koa-router'

class Server {
    public start() {
      const app = new Koa()
      const router = new KoaRouter()
      const port = config.serverPort
      router.get('*', this.handleRequest.bind(this))
      app.use(router.routes())
      app.listen(port, () => {
        console.log('Server listen on: http://localhost:' + port)
      })
    }
}
複製程式碼

接著就是核心的 handleRequest 了,不過我們還是先寫個簡陋版本的

import { renderToString } from 'react-dom/server'

class Server {
    private handleRequest(ctx: Koa.ParameterizedContext) {
        const App = this.App
        const routes = this.routes
        const matchedRoute = // find matched route
        const content = renderToString(
          <App Component={matchedRoute.component} />
        )
        // 拼接指令碼等讓 renderHTML 去做
        ctx.body = this.renderHTML(content)
    }
}
複製程式碼

renderHTML 因為需要找到 container 節點,並在開發時動態新增 script

這時我們安裝 cheerio 這個庫,他提供了 jQuery 那樣的方法操作 HTML 字串

import * as cheerio from 'cheerio'

class Server {
  private renderHTML(content: string) {
    // decodeEntities 會轉譯漢字,還有文字的 <script> 等關鍵詞,對防止 XSS 有一定作用
    const $ = cheerio.load(this.originalHTML, { decodeEntities: true })
    $(this.container).html(content)
    if (isDev) {
      $('body').append(`
        <script type='text/javascript' src='${this.clientChunkPath}'></script>
      `)
    }
    return $.html()
  }
}
複製程式碼

然後服務端方面也寫的差不多

但是不管在客戶端或者服務端,都沒有路由切換的邏輯

開發時的邏輯

在開發時需要在改變時自動打包,這個可以利用 webpack(config).watch 來完成,也可以直接利用 webpack-dev-middleware

Webpack 配置

scripts 下面新建一個 webpack-config.ts 檔案,用來匯出 Webpack 配置

  • webpack 打包時會有輸出路徑,檔名等一些配置,為了方便維護,或者後期開放出給使用者自定義,這裡在新建一個 config.ts 檔案,可以預設這個配置匯出的資料
export interface Configuration {
  webpackServerPort: number // 開發服務監聽的埠
  serverPort: number // 渲染服務監聽的埠
  clientPublicPath: string // 客戶端靜態檔案 public path
  serverPublicPath: string // 服務端靜態檔案 public path
  clientChunkName: string // 客戶端打包生成的檔名
  serverChunkName: string // 服務端打包生成的檔名
  htmlTemplatePath: string // HTML 模板路徑
  buildDirectory: string // 服務端打包輸出路徑
  staticDirectory: string // 客戶端打包輸出路徑
  htmlPath: string // HTML 打包後的路徑
  srcDirectory: string // 業務程式碼資料夾
  customConfigFile: string // 自定義配置的檔名(專案根目錄)
}
複製程式碼

在這裡匯出一個或者上述配置的方法

import { join } from 'path'

// 專案根目錄
const rootDirectory = process.cwd()

export function getConfig(): Configuration {
  const staticDirName = 'static'
  const buildDirName = 'build'
  const srcDirectory = join(rootDirectory, 'src')
  return {
    clientChunkName: 'app.js',
    serverChunkName: 'server.js',
    webpackServerPort: 8080,
    serverPort: 3030,
    clientPublicPath: '/static/',
    serverPublicPath: '/',
    htmlTemplatePath: join(srcDirectory, 'index.html'),
    htmlPath: join(rootDirectory, staticDirName, 'client.html'),
    buildDirectory: join(rootDirectory, buildDirName),
    staticDirectory: join(rootDirectory, staticDirName),
    srcDirectory,
    customConfigFile: join(rootDirectory, 'server-renderer.config.js'),
  }
}
複製程式碼
  • 匯出 webpack 配置

webpack 配置需要區分是否服務端和是否生產環境,所以定義一個方法,接受以下引數

export interface GenerateWebpackOpts {
  isDev?: boolean
  isServer?: boolean
}
複製程式碼

然後利用傳入的引數匯出不同的 webpack 配置

import * as path from 'path'
import * as webpack from 'webpack'
import { getConfig } from './config'

export interface GenerateWebpackOpts {
  rootDirectory: string
  isDev?: boolean
  isServer?: boolean
}

export function genWebpackConfig(opts: GenerateWebpackOpts) {
  const { isDev = false, isServer = false } = opts
  const config = getConfig()

  // 區分不同環境匯出不同的配置
  const webpackConfig: webpack.Configuration = {
    mode: isDev ? 'development' : 'production',
    target: isServer ? 'node' : 'web',
    entry: path.resolve(config.srcDirectory, 'index.tsx'),
    output: {
      path: isServer ? config.buildDirectory : config.staticDirectory,
      publicPath: isServer 
      ? config.serverPublicPath 
      : config.clientPublicPath,
      filename: isServer 
        ? config.serverChunkName 
        : config.clientChunkName,
      libraryTarget: isServer ? 'commonjs2' : 'umd',
    },
  }
  
  if (!isServer) {
    webpackConfig.node = {
      dgram: 'empty',
      fs: 'empty',
      net: 'empty',
      tls: 'empty',
      child_process: 'empty',
    }
  }

  return webpackConfig
}

複製程式碼

其他的 typescript 配置和 css 樣式打包的配置在踩坑裡面寫過了(customize-server-side-render

或者檢視具體檔案 server-renderer/scripts/webpack-config.ts

開發的 HTTP 服務

開發的邏輯放在 scripts/dev.ts

有了 webpack 配置就可以編寫一個靜態資源的開發伺服器了

  • 生成 webpack 配置
import { genWebpackConfig } from './webpack-config'

const rootDirectory = process.cwd()
const clientDevConfig = genWebpackConfig({ 
  rootDirectory, isDev: true, isServer: false,
})
複製程式碼
  • 安裝 webpack-dev-middleware,然後生成一個 HTTP 服務的中介軟體
$ yarn add webpack-dev-middleware
複製程式碼
const clientCompiler = webpack(clientDevConfig)

const clientDevMiddleware = WebpackDevMiddleware(clientCompiler, {
  publicPath: clientDevConfig.output.publicPath,
  writeToDisk: false,
  logLevel: 'silent',
})
複製程式碼
  • 啟動 HTTP 服務
const app = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => {
  clientDevMiddleware(req, res, () => {
    res.end()
  })
})

app.listen(getConfig().webpackServerPort, () => {
  console.clear()
  console.log(
    chalk.green(`正在啟動開發服務...`)
  )
})
複製程式碼

上面做的事基本就是一個 webpack-dev-server

渲染開發服務的開發

渲染開發服務同樣需要監聽檔案的變化,然後進行重新打包並重啟

重新打包利用 webpack-dev-middleware 或者 webpack(config).watch 都可以

用同樣的方式生成一個服務端的中介軟體

const rootDirectory = process.cwd()
const serverDevConfig = genWebpackConfig({ 
  rootDirectory, isDev: true, isServer: true, 
})
const serverCompiler = webpack(serverDevConfig)
const serverDevMiddleware = WebpackDevMiddleware(serverCompiler, {
  publicPath: serverDevConfig.output.publicPath,
  writeToDisk: true, // 和客戶端不同,這裡需要寫到硬碟,因為我們需要用到它
  logLevel: 'silent',
})
複製程式碼

不過這裡生成的 serverDevMiddleware 並沒有什麼用,然後就是服務的重啟了

我們需要在每次打包成功後重啟服務,正好 webpack 提供了這些鉤子 webpack.docschina.org/api/compile…

然後就是打包後如何執行打包後的檔案,重啟如何殺死上一個服務,重新開啟新的服務

這裡我用的是 node 的 child_process/fork,當然還有很多其他的方法

import * as webpack from 'webpack'
import { fork } from 'child_process'
import { join } from 'path'
import chalk from 'chalk'

let childProcess

serverCompiler.hooks.done.tap('server-compile-done', (stats: webpack.Stats) => {
  if (childProcess) {
    childProcess.kill()
    console.clear()
    console.log(
      chalk.green('正在重啟開發服務...')
    )
  }
  // webpack 打包後的資源資訊
  const assets = stats.toJson().assetsByChunkName
  // 拼接成完整的路徑
  const chunkPath = join(serverDevConfig.output.path, assets.main)
  // @ts-ignore
  childProcess = fork(chunkPath, {}, { stdio: 'inherit' })
})
複製程式碼

開發和核心的程式碼大概寫了差不多了,然後就是怎麼除錯,讓我們這個庫跑起來

打包 scripts 下面的指令碼

利用 taskrscripts 下面的指令碼,都打包到 lib/scripts 下面

打包 typescript 需要 @taskr/typescript

$ yarn add taskr @taskr/typescript -D
複製程式碼

在專案根目錄建立 taskfile.js 檔案

// 引入 tsconfig 檔案
const config = require('./tsconfig.json')

exports.scripts = function* (task) {
  yield task.source('scripts/**.ts')
    .typescript(config)
    .target('lib/scripts')
}

exports.default = function* (task) {
  yield task.start('scripts')
}
複製程式碼

然後執行 taskr 即可

除錯

新建資料夾,編寫程式碼,利用 yarn link server-renderer 在本地除錯

server-renderer
$ yarn link
$ cd demo
$
demo
$ yarn link server-renderer
$ node ./node_modules/server-renderer/lib/scripts/dev.js
複製程式碼

寫了一個運用 server-renderer 的 DEMO,具體可以參考 github.com/wokeyi/musi…

問題

如果有錯誤或者可以優化的地方,請指正

相關文章