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

原理:

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

  2. 利用 webpack 打包瀏覽器運行的 React 代碼,在客戶端用 import { hydrate } from 'react-dom' hydrate 激活(添加事件等)css

也可使用 babel-core/register 讓 React 代碼可以運行在服務端,具體參考:segmentfault.com/a/119000001…html

新建項目

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

基本項目目錄java

|-- 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
    ...
複製代碼

安裝依賴node

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

首先在 config 下面建立一個 paths.js,聲明瞭有用到的 pathsreact

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 配置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,打包出在客戶端運行的腳本git

打包客戶端代碼出現問題:
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 引入的庫,這裏都給他安裝一下github

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

依然出現上面的問題,猜想多是沒有引入 babel 的緣由web

最終結果並非,是因爲 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,
);
複製代碼

添加路由成功!

相關文章
相關標籤/搜索