原理:
-
利用 webpack 打包能在 node 執行的 React 程式碼,利用
react-dom/server
將 React 程式碼渲染成 html 字串返回給客戶端 -
利用 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:client
、yarn dev:server
、yarn dev
,而且還不能用 &&
連線,因為 yarn dev:client
中 webpack --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,
);
複製程式碼
新增路由成功!