TypeScript + Webpack + Koa 搭建 React 服務端渲染

我可以發表於2019-04-18

原理:

  1. 利用 webpack 打包能在 node 執行的 React 程式碼,利用 react-dom/server 將 React 程式碼渲染成 html 字串返回給客戶端

  2. 利用 webpack 打包瀏覽器執行的 React 程式碼,在客戶端用 import { hydrate } from 'react-dom' hydrate 啟用(新增事件等)

也可以使用 babel-core/register 讓 React 程式碼能夠執行在服務端,具體參考:segmentfault.com/a/119000001…

新建專案

$ mkdir customize-server-side-render 
$ cd customize-server-side-render
# 初始化一個 package.json
$ yarn init -y
複製程式碼

基本專案目錄

|-- customize-server-side-render
    |-- config     webpack 打包配置檔案和路徑配置檔案
        |-- paths   路徑配置檔案
        |-- webpack.base.js     公用的 webpack 打包配置
        |-- webpack.client.js   打包給客戶端使用的指令碼
        |-- webpack.server.js   打包給 node 使用的指令碼
    |-- src        原始碼
        |-- App.tsx
        |-- index.tsx    客戶端啟動入口
        !-- server.tsx   服務端啟動入口
    |-- server      koa 啟動 http 服務程式碼
    |-- public      靜態資源
    |-- dist        webpack 打包後的檔案
    |-- package.json
    |-- tsconfig.json
    |-- tslint.json
    ...
複製程式碼

安裝依賴

$ yarn add react react-dom koa koa-router
$ yarn add webpack webpack-cli ts-loader typescipt -D
複製程式碼

首先在 config 下面建立一個 paths.js,宣告瞭有用到的 paths

const path = require('path');

function resolveResource(filename) {
  return path.resolve(__dirname, `../${filename}`);
}

module.exports = {
  clientEntry: resolveResource('src/index.tsx'),
  serverEntry: resolveResource('src/server.tsx'),
  sourceDir: resolveResource('src'),
  distDir: resolveResource('dist'),
};
複製程式碼
  • webpack.base.js
const paths = require('./paths');

module.exports = {
  mode: 'development',
  output: {
    path: paths.distDir,
    filename: '[name].js',
  },
  resolve: {
    extensions: ['.ts', '.tsx'],
  },
  module: {
    rules: [
      {
        test: /\.tsx?/,
        loader: 'ts-loader',
        exclude: /node_modules/,
      }
    ],
  },
};

複製程式碼

利用 webpack-merge 合併 webpack 配置

$ yarn add webpack-merge -D
複製程式碼
  • webpack.client.js
const merge = require('webpack-merge');
const baseConfig = require('./webpack.base');
const paths = require('./paths');

module.exports = merge(baseConfig, {
  target: 'web',
  entry: paths.clientEntry,
})

複製程式碼

執行 webpack --config config/webpack.client.js,打包出在客戶端執行的指令碼

打包客戶端程式碼出現問題:
ERROR in ./node_modules/react-dom/cjs/react-dom.development.js
Module not found: Error: Can't resolve 'object-assign' in '/Users/logan/Projects/backend/customize-server-side-render/node_modules/react-dom/cjs'
 @ ./node_modules/react-dom/cjs/react-dom.development.js 19:14-38
 @ ./node_modules/react-dom/index.js
 @ ./src/index.tsx
 ...
複製程式碼

打包時出現了一些依賴未安裝的問題,是開發版本的 react 引入的庫,這裡都給他安裝一下

$ yarn add object-assign prop-types scheduler -D
複製程式碼

依然出現上面的問題,猜測可能是沒有引入 babel 的原因

最終結果並不是,是由於 resolve.extensions 中我只配置了 ts 和 tsx 結尾的檔案型別,但是沒有 js 和 jsx 結尾的。修改 webpack.base.js

const paths = require('./paths');

module.exports = {
  output: {
    path: paths.distDir,
    filename: '[name].js',
  },
  resolve: {
    extensions: ['.ts', '.tsx', '.js', '.jsx'],
  },
  module: {
    rules: [
      {
        test: /\.tsx?/,
        include: paths.sourceDir,
        exclude: /node_modules/,
        loader: 'ts-loader',
      },
    ],
  },
};
複製程式碼

啟動 Node 服務

server 下面建立一個 index.js

const Koa = require('koa');
const Router = require('koa-router');

const app = new Koa();
const router = new Router();

router.get('/', (ctx) => {
  ctx.body = 'Hello world Koa';
});

app.use(router.routes());

app.listen(3000);

console.log('Application is running on http://127.0.0.1:3000');
複製程式碼

執行 node server/index.js,看見服務啟動正常,但是修改了 server 下面的 index.js 無法自己重啟 node 服務,所以準備利用 nodemon 執行

$ yarn add nodemon -D
複製程式碼

修改啟動指令碼為

$ nodemon server/index.js
複製程式碼

OK, node 服務能在修改後自己重啟。

編譯 React 在服務端

const merge = require('webpack-merge');
const baseConfig = require('./webpack.base');
const paths = require('./paths');

module.exports = merge(baseConfig, {
  target: 'node',
  entry: {
    'server-entry': paths.serverEntry,
  },
})
複製程式碼

將打包後的 server-entry.js 在 server/index.js 中引入, 利用 react-dom/server 模組中的 renderToString 方法渲染成 html

const ReactDOMServer = require('react-dom/server');
const serverEntry = require('../dist/server-entry');

const str = ReactDOMServer.renderToString(serverEntry);
複製程式碼

但是發現 require 進來的 serverEntry 只是一個空物件。

  • 利用 webpack-node-externals 外掛,webpack 將不打包 path, fs 等原生 node 模組下面的模組
  • output 中設定 libraryTarget 為 commonjs,webpack.server.js 如下:
const merge = require('webpack-merge');
const nodeExternals = require('webpack-node-externals');
const baseConfig = require('./webpack.base');
const paths = require('./paths');

module.exports = merge(baseConfig, {
  target: 'node',
  entry: {
    'server-entry': paths.serverEntry,
  },
  output: {
    libraryTarget: 'commonjs',
  },
  externals: [nodeExternals()],
})

複製程式碼

然後 require server-entry 的方式變為:

const serverEntry = require('../dist/server-entry').default;
複製程式碼

然後就可以看見瀏覽器上顯示出了 的內容,但是每次執行都要 yarn dev:clientyarn dev:serveryarn dev,而且還不能用 && 連線,因為 yarn dev:clientwebpack --watch 會卡在當前程式,所以可以用 npm-run-all 一次執行三個指令碼

$ yarn add npm-run-all -D
複製程式碼

最終啟動指令碼變為:

"start": "npm-run-all --parallel \"dev\" \"dev:client\" \"dev:server\""
複製程式碼

利用 HTML 模板檔案

public 下面新建一個 index.html

<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <title>Customize Server side render</title>
  </head>
  <body>
    <!-- 服務端會替換 <slot />,也可以使用 koa-views 等外掛實現 -->
    <div class="app-container"><slot /></div>
  </body>
</html>
複製程式碼

修改 server/index.js 內容:

const Koa = require('koa');
const Router = require('koa-router');
// 新增
const fs = require('fs');
const path = require('path');
const ReactDOMServer = require('react-dom/server');
const serverEntry = require('../dist/server-entry').default;

const app = new Koa();
const router = new Router();

// 新增
const template = fs.readFileSync(path.resolve(__dirname, '../public/index.html'), 'utf8');

router.get('*', (ctx) => {
  // 新增
  const str = ReactDOMServer.renderToString(serverEntry);
  ctx.body = template.replace('<slot />', str);
  ctx.type = 'html';
});

app.use(router.routes());

app.listen(3000);

console.log('Application is running on http://127.0.0.1:3000');

複製程式碼

但是 react-dom/server 模組只是將 jsx 渲染成 html,但是他沒有 document 等 html 元素,所以他並沒有繫結點選事件等,所以需要將程式碼在瀏覽器端再執行一遍(瀏覽器啟用)

將瀏覽器再執行一次的原理就是,將 webpack.client.js 的 output 中 path 設定為 public 目錄,然後將 public 目錄設定為 koa 中的靜態資源目錄。

  • public 設定為靜態資源目錄
const koaStatic = require('koa-static');

const app = new Koa();

// 這句一定要在 router.get('*') 之前,不然請求到 router.get('*') 中直接返回了,不會再找 public 中的靜態資源
app.use(koaStatic(path.resolve(__dirname, '../public')));
複製程式碼
  • index.html 中引入即可
<script type="text/javascript" src='/app.js'></script>
複製程式碼

這樣子,客戶端執行的時候就回去載入 public/app.js,從而達到客戶端啟用的目的

  • 修改 webpack.client.js
const merge = require('webpack-merge');
const baseConfig = require('./webpack.base');
const paths = require('./paths');

module.exports = merge(baseConfig, {
  target: 'web',
  entry: {
    app: paths.clientEntry,
  },
  output: {
    // 指向 public 目錄
    path: paths.publicDir,
  },
});

複製程式碼

但是,這樣子訪問 http://localhost: 3000 時,他走的不是 router.get('/'), 而是 public/index.html,這個有很多種方式解決,比如修改 public/index.html -> public/template.html等。

載入樣式

安裝依賴

$ yarn add style-loader css-loader scss-loader node-sass -D
複製程式碼

客戶端打包沒問題,但是 style-loader 需要 window 物件,但是 webpack.server.js 是打包給 node 用的,沒有 window ,會報錯

webpack:///./node_modules/style-loader/lib/addStyles.js?:23
	return window && document && document.all && !window.atob;
	^

ReferenceError: window is not defined
    at eval (webpack:///./node_modules/style-loader/lib/addStyles.js?:23:2)
    at eval (webpack:///./node_modules/style-loader/lib/addStyles.js?:12:46)
    at module.exports (webpack:///./node_modules/style-loader/lib/addStyles.js?:80:88)
    at eval (webpack:///./src/components/Container/style.scss?:16:140)
    at Object../src/components/Container/style.scss (/Users/logan/Projects/backend/customize-server-side-render/dist/server-entry.js:165:1)
    at __webpack_require__ (/Users/logan/Projects/backend/customize-server-side-render/dist/server-entry.js:20:30)
    at eval (webpack:///./src/components/Container/index.tsx?:4:69)
    at Module../src/components/Container/index.tsx (/Users/logan/Projects/backend/customize-server-side-render/dist/server-entry.js:154:1)
    at __webpack_require__ (/Users/logan/Projects/backend/customize-server-side-render/dist/server-entry.js:20:30)
    at eval (webpack:///./src/App.tsx?:6:79)
複製程式碼

所以將樣式 loader 拆開,在 webpack.server.js 中用 isomorphic-style-loader 代替 style-loader

路由同構

服務端渲染時,不能使用 BrowswerRouter 或者 HashRouter,而是 StaticRouter,參考地址:

可以看到,StaticRouter 需要用到請求引數中的 path 甚至 context,因此需要對結構做一些改變,讓 node 啟動的入口直接引入 <App /> ,而不是通過 require 載入 webpack 打包過的

  • src 下面新建 server 目錄,新建 index.tsx,這樣服務端的內容也能夠使用 typescript

  • server/index.js 內容轉入 src/server/index.tsx,安裝 @types/node

  • 原本用 require 引入的方式都改為 import

  • 修改 paths 下面的 serverEntry,修改 src/server/index.tsx 下面引用的檔案路徑,利用 typescript 以後,路勁引用就不用 path.resolve(__dirname, 'path/to/file'),直接專案目錄下資料夾開始就行,如果引用 project/public 下面的 public 目錄,直接 public 即可。

  • 修改後的 src/server/index.tsx 為:

import * as React from 'react';
import * as fs from 'fs';
import Koa from 'koa';
import Router from 'koa-router';
import koaStatic from 'koa-static';
import * as ReactDOMServer from 'react-dom/server';
import App from '../App';

const app = new Koa();
const router = new Router();

const template = fs.readFileSync('public/template.html', 'utf8');

app.use(koaStatic('public', {
  gzip: true,
  maxage: 10,
}));

router.get('*', (ctx) => {
  const str = ReactDOMServer.renderToString(<App />);
  ctx.body = template.replace('<slot />', str);
  ctx.type = 'html';
});

app.use(router.routes());


app.listen(3000);

console.log('Application is running on http://127.0.0.1:3000');

複製程式碼

修改 renderToString 的過程

const str = ReactDOMServer.renderToString(
    <StaticRouter location={ctx.req.url} context={{}}>
      <App />
    </StaticRouter>
  );
複製程式碼

這是服務端新增了 Router,但是這樣子直接執行的話,瀏覽器會報錯:

 You should not use <Route> or withRouter() outside a <Router>
複製程式碼

這是因為服務端新增了 StaticRouter,但是客戶端外層卻並沒有新增一個 Router

修改 src/index.tsx

import * as React from 'react';
import { hydrate } from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import App from './App';

hydrate(
  (
    <BrowserRouter>
      <App />
    </BrowserRouter>
  ),
  document.querySelector('.app-container') as HTMLElement,
);
複製程式碼

新增路由成功!

相關文章