React最佳實踐嘗試(二)
首先我的整體思路是:根據webpack.ssr.config.js配置檔案,將前端程式碼打包進node層供node做SSR使用,然後前端正常啟動webpack-dev-server伺服器即可。
package.json
"startfe": "run-p client ssr", "client": "BABEL_ENV=client NODE_ENV=development webpack-dev-server --config public/webpack.dev.config.js", "ssr": "BABEL_ENV=ssr NODE_ENV=development webpack --watch --config public/webpack.ssr.config.js", 複製程式碼
將前端程式碼打包進node之後,在正常啟動node伺服器即可:
package.json
"start": "BABEL_ENV=server NODE_ENV=development nodemon src/app.ts --exec babel-node --extensions '.ts,.tsx'", 複製程式碼
這樣基本上webpack整體的打包思路就清晰了。
最終生產模式中,我們只需要將整個前端程式碼通過webpack打包進src
目錄,然後將整個src
目錄經過babel轉義之後輸出到output
目錄,最終我們的生產模式只需要啟動output/app.js
即可。
package.json
"buildfe": "run-p client:prod ssr:prod", "build": "BABEL_ENV=server NODE_ENV=production babel src -D -d output/src --extensions '.ts,.tsx'", "ssr:prod": "BABEL_ENV=ssr NODE_ENV=production webpack --config public/webpack.ssr.config.js", "client:prod": "BABEL_ENV=client NODE_ENV=production webpack --progess --config public/webpack.prod.config.js", 複製程式碼
$ node output/app.js // 啟動生產模式 複製程式碼
webpack配置
在客戶端的打包中,我們需要使用webpack-manifest-plugin
外掛。這個外掛可以將webpack打包之後所有檔案的路徑寫入一個manifest.json
的檔案中,我們只需要讀取這個檔案就可以找到所有資源的正確路徑了。
部分webpack.client.config.js
const ManifestPlugin = require("webpack-manifest-plugin"); module.exports = merge(baseConfig, { // ... plugins: [ new ManifestPlugin(), // ... ] }); 複製程式碼
Mapping loaded modules to bundles
In order to make sure that the client loads all the modules that were rendered server-side, we'll need to map them to the bundles that Webpack created.
我們的客戶端渲染使用了react-loadable
,需要知道該模組是否提前經過了服務端渲染,否則會出現重複載入的問題。因此需要將webpack打包後的bundles
生成一個map檔案,然後在ssr的時候傳入react-loadable
。這裡我們使用react-loadable/webpack
外掛即可。
部分webpack.client.config.js
import { ReactLoadablePlugin } from 'react-loadable/webpack'; const outputDir = path.resolve(__dirname, "../src/public/buildPublic"); plugins: [ // ... new ReactLoadablePlugin({ filename: path.resolve(outputDir, "react-loadable.json") }) // ... ], 複製程式碼
接下來是webpack打包產物的資源路徑問題。
生產模式一般都是將輸出的檔案上傳到cdn上,因此我們只需要在pubicPath的地方使用cdn地址即可。
部分webpack.prod.config.js
mode: "production", output: { filename: "[name].[chunkhash].js", publicPath: "//cdn.address.com", chunkFilename: "chunk.[name].[chunkhash].js" }, 複製程式碼
開發環境中我們只需要讀取manifest.json
檔案中相對應模組的地址即可。
manifest.json
{ "home.js": "http://127.0.0.1:4999/static/home.js", "home.css": "http://127.0.0.1:4999/static/home.css", "home.js.map": "http://127.0.0.1:4999/static/home.js.map", "home.css.map": "http://127.0.0.1:4999/static/home.css.map" } 複製程式碼
SSR程式碼
解決了打包問題之後,我們需要考慮ssr的問題了。
其實整體思路比較簡單:我們通過打包,已經有了manifest.json
檔案儲存靜態資源路徑,有react-loadable.json
檔案儲存打包輸出的各個模組的資訊,只需要在ssr的地方讀出js、css路徑,然後將被<Loadable.Capture />
包裹的元件renderToString
一下,填入pug模板中即可。
src/utils/bundle.ts
function getScript(src) { return `<script type="text/javascript" src="${src}"></script>`; } function getStyle(src) { return `<link rel="stylesheet" href="${src}" />`; } export { getScript, getStyle }; 複製程式碼
src/utils/getPage.ts
import { getBundles } from "react-loadable/webpack"; import React from "react"; import { getScript, getStyle } from "./bundle"; import { renderToString } from "react-dom/server"; import Loadable from "react-loadable"; export default async function getPage({ store, url, Component, page }) { const manifest = require("../public/buildPublic/manifest.json"); const mainjs = getScript(manifest[`${page}.js`]); const maincss = getStyle(manifest[`${page}.css`]); let modules: string[] = []; const dom = ( <Loadable.Capture report={moduleName => { modules.push(moduleName); }} > <Component url={url} store={store} /> </Loadable.Capture> ); const html = renderToString(dom); const stats = require("../public/buildPublic/react-loadable.json"); let bundles: any[] = getBundles(stats, modules); const _styles = bundles .filter(bundle => bundle && bundle.file.endsWith(".css")) .map(bundle => getStyle(bundle.publicPath)) .concat(maincss); const styles = [...new Set(_styles)].join("\n"); const _scripts = bundles .filter(bundle => bundle && bundle.file.endsWith(".js")) .map(bundle => getScript(bundle.publicPath)) .concat(mainjs); const scripts = [...new Set(_scripts)].join("\n"); return { html, __INIT_STATES__: JSON.stringify(store.getState()), scripts, styles }; } 複製程式碼
路徑說明:src/public
目錄存放所有前端打包過來的檔案,src/public/buildPublic
存放webpack.client.config.js
打包的前端程式碼,src/public/buildServer
存放webpack.ssr.config.js
打包的服務端渲染的程式碼。
這樣服務端渲染的部分就基本完成了。
其他node層啟動程式碼可以直接檢視src/server.ts
檔案即可。
前後端同構
接下來就要編寫前端的業務程式碼來測試一下服務端渲染是否生效。
這裡我們要保證使用最少的程式碼完成前後端同構的功能。
首先我們需要在webpack中定義個變數IS_NODE
,在程式碼中根據這個變數就可以區分ssr部分的程式碼和客戶端部分的程式碼了。
webpack.client.config.js
plugins: [ // ... new webpack.DefinePlugin({ IS_NODE: false }) // ... ] 複製程式碼
接下來編寫前端頁面的入口檔案,入口這裡要對ssr和client做區別渲染:
public/js/decorators/entry.tsx
import React, { Component } from "react"; import { Provider } from "react-redux"; import ReactDOM from "react-dom"; import Loadable from "react-loadable"; import { BrowserRouter, StaticRouter } from "react-router-dom"; // server side render const SSR = App => class SSR extends Component<{ store: any; url: string; }> { render() { const context = {}; return ( <Provider store={this.props.store} context={context}> <StaticRouter location={this.props.url}> <App /> </StaticRouter> </Provider> ); } }; // client side render const CLIENT = configureState => Component => { const initStates = window.__INIT_STATES__; const store = configureState(initStates); Loadable.preloadReady().then(() => { ReactDOM.hydrate( <Provider store={store}> <BrowserRouter> <Component /> </BrowserRouter> </Provider>, document.getElementById("root") ); }); }; export default function entry(configureState) { return IS_NODE ? SSR : CLIENT(configureState); } 複製程式碼
這裡entry引數中的configureState
是我們store的宣告檔案。
public/js/models/configure.ts
import { init } from "@rematch/core"; import immerPlugin from "@rematch/immer"; import * as models from "./index"; const immer = immerPlugin(); export default function configure(initStates) { const store = init({ models, plugins: [immer] }); for (const model of Object.keys(models)) { store.dispatch({ type: `${model}/@init`, payload: initStates[model] }); } return store; } 複製程式碼
這樣就萬事俱備了,接下來只需要約定我們單頁的入口即可。
這裡我將單頁的入口都統一放到public/js/entry
目錄下面,每一個單頁都是一個目錄,比如我的專案中只有一個單頁,因此我只建立了一個home
目錄。
每一個目錄下面都有一個index.tsx
檔案和一個routes.tsx
檔案,分為是單頁的整體入口程式碼,已經路由定義程式碼。
例如:
/entry/home/routes.tsx
import Loadable from "react-loadable"; import * as Path from "constants/path"; import Loading from "components/loading"; export default [ { name: "demo", path: Path.Demo, component: Loadable({ loader: () => import("containers/demo"), loading: Loading }), exact: true }, { name: "todolist", path: Path.Todolist, component: Loadable({ loader: () => import("containers/todolist"), loading: Loading }), exact: true } ]; 複製程式碼
/entry/home.index.tsx
import React, { Component } from "react"; import configureStore from "models/configure"; import entry from "decorators/entry"; import { Route } from "react-router-dom"; import Layout from "components/layout"; import routes from "./routes"; class Home extends Component { render() { return ( <Layout> {routes.map(({ path, component: Component, exact = true }) => { return ( <Route path={path} component={Component} key={path} exact={exact} /> ); })} </Layout> ); } } const Entry = entry(configureStore)(Home); export { Entry as default, Entry, configureStore }; 複製程式碼
Layout
元件是存放所有頁面的公共部分,比如Nav導航條、Footer等。
這樣所有的準備工作就已經做完了,剩下的工作就只有編寫元件程式碼以及首屏資料載入了。