React SSR 技術摘要
單頁面應用(SPA)模式被越來越多的站點所採用,這種模式勢必面臨著首次有效繪製(FMP)耗時較長和不利於搜尋引擎優化(SEO)的問題。“同構應用” 就像是精靈,可以遊刃有餘的穿梭在服務端與客戶端之間各盡其能。
但是想駕馭 “同構應用” 往往會面臨一系列的問題,下面針對一個示例進行一些細節介紹。
“同構” 是指一套程式碼可以在服務端和客戶端兩種環境下執行,通過用這種靈活性,可以在服務端渲染初始內容輸出到頁面,後續工作交給客戶端來完成,最終來解決SEO的問題並提升效能。
構建配置
選擇一個靈活的腳手架為專案後續的自定義功能及配置是十分有利的,Neutrino 提供了一些常用的webpack 預設配置 ,使初始化和構建專案的過程更加簡單。
這裡基於@neutrinojs/react 預設做一些定義用於開發
.neutrinorc.js
const isDev = process.env.NODE_ENV !== 'production'; const isSSR = process.argv.includes('--ssr'); module.exports = { use: [ ['@neutrinojs/react', { devServer: { port: isSSR ? 3000 : 5000, host: '0.0.0.0', disableHostCheck: true, contentBase: `${__dirname}/src`, before(app) { if(isSSR) { require('./src/server')(app); } }, }, manifest: true, html: isSSR ? false: {}, clean: { paths: ['./node_modules/.cache']}, }], ({ config }) => { if (isDev) { return; } config .output .filename('assets/[name].[chunkhash].js') .chunkFilename('assets/chunk.[chunkhash].js') .end() .optimization .minimize(false) .end(); }, ], };
為了達到開發環境下可以選擇 SSR(服務端渲染)、CSR(客戶端渲染) 任意一種渲染模式,在開始先定義一個變數isSSR
用以做差異配置:
devServer.before SSR 模式 manifest
構建用於服務端執行的配置項稍有不同
由於SSR 模式
最終程式碼要執行在 node 環境,這裡需要對配置再做一些調整:
-
target
調整為node
,編譯為類 Node 環境可用 -
libraryTarget
調整為commonjs2
,使用 Node 風格匯出模組 -
preset-env
執行環境調整為node
-
排除元件中
css/sass
資源的引用
在打包的時候通過webpack-node-externals
排除node_modules
依賴模組,可以使伺服器構建速度更快,並生成較小的 bundle 檔案。
webpack.server.config.js
const Neutrino = require('neutrino/Neutrino'); const nodeExternals = require('webpack-node-externals'); const NormalPlugin = require('webpack/lib/NormalModuleReplacementPlugin'); const babelMerge = require('babel-merge'); const config = require('./.neutrinorc'); const neutrino = new Neutrino(); neutrino.use(config); neutrino.config .target('node') .entryPoints .delete('index') .end() .entry('server') .add(`${__dirname}/src/server`) .end() .output .path(`${__dirname}/build`) .filename('server.js') .libraryTarget('commonjs2') .end() .externals([nodeExternals()]) .plugins .delete('clean') .delete('manifest') .end() .plugin('normal') .use(NormalPlugin, [/\.css$/, 'lodash/noop']) .end() .optimization .minimize(false) .runtimeChunk(false) .end() .module .rule('compile') .use('babel') .tap(options => babelMerge(options, { presets: [ ['@babel/preset-env', { targets: { node: true }, }], ], })); module.exports = neutrino.config.toConfig();
環境差異
由於執行環境和平臺 API 的差異,當執行在不同環境中時,我們的程式碼將不會完全相同。
Webpack 全域性物件中定義了process.browser
,可以在開發環境中來判斷當前是客戶端還是服務端。
自定義中介軟體
開發環境SSR 模式
下,如果我們在元件中引入了圖片或樣式資源,不經過 webpack-loader 進行編譯,Node 環境下是無法直接執行的。在 Node 環境下,通過ignore-styles
可以把這些資源進行忽略。
此外,為了讓 Node 環境下能夠執行 ES6 模組的元件,需要引入@babel/register
來做一些轉換:
src/server/register.js
require('ignore-styles'); require('@babel/register')({ presets: [ ['@babel/preset-env', { targets: { node: true }, }], '@babel/preset-react', ], plugins: [ '@babel/plugin-proposal-class-properties', ], });
如果 webpack 中配置了resolve.alias
,與之對應的還需要增加babel-plugin-module-resolver
外掛來做解析。
由於require()
引入方式模組將會被快取, 為了使元件內的修改實時生效,通過decache
模組從require()
快取中刪除模組:
src/server/dev.js
require('./register'); const decache = require('decache'); const routes = require('./routes'); let render = require('./render'); const handler = async (req, res, next) => { decache('./render'); render = require('./render'); res.send(await render({ req, res })); next(); }; module.exports = (app) => { app.get(routes, handler); };
服務端渲染
在服務端通過ReactDOMServer.renderToString()
方法將元件渲染為初始 HTML 字串。
獲取資料往往需要從query
、cookie
中取一些內容作為介面引數,
Node 環境下沒有window
、document
這樣的瀏覽器物件,可以藉助 Express 的 req 物件來拿到一些資訊:
${req.protocol}://${req.headers.host}${req.url} req.headers.cookie req.headers['user-agent']
src/server/render.js
const React = require('react'); const { renderToString } = require('react-dom/server'); ... module.exports = async ({ req, res }) => { const locals = { data: await fetchData({ req, res }), href: `${req.protocol}://${req.headers.host}${req.url}`, url: req.url, }; const markup = renderToString(<App locals={locals} />); const helmet = Helmet.renderStatic(); return template({ markup, helmet, assets, locals }); };
入口檔案
前端呼叫ReactDOM.hydrate()
方法與服務端返回的靜態標記相繫結事件。
src/index.jsx
import React from 'react'; import ReactDOM from 'react-dom'; import App from './App'; const renderMethod = ReactDOM[module.hot ? 'render' : 'hydrate']; renderMethod(<App />, document.getElementById('root'));
根元件
在服務端使用StaticRouter
元件,通過location
屬性設定伺服器收到的URL,並在context
屬性中存入渲染期間所需要的資料。
src/App.jsx
import React from 'react'; import { BrowserRouter, StaticRouter, Route } from 'react-router-dom'; import { hot } from 'react-hot-loader/root'; ... const Router = process.browser ? BrowserRouter : StaticRouter; const App = ({ locals = {} }) => ( <Router location={locals.url} context={locals}> <Layout> <Route exact path="/" component={Home}/> <Route path="/about" component={About}/> <Route path="/contact" component={Contact}/> <Route path="/character/:key" component={Character}/> </Layout> </Router> ); export default hot(App);
內容資料
通過constructor
接收StaticRouter
元件傳入的資料,客戶端 URL 與服務端請求地址相一致時直接使用傳入的資料,否則再進行客戶端資料請求。
src/comps/Content.jsx
import React from 'react'; import { withRouter } from 'react-router-dom'; import fetchData from '../utils/fetchData'; function isCurUrl() { if (!window.__INITIAL_DATA__) { return false; } return document.location.href === window.__INITIAL_DATA__.href; } class Content extends React.Component { constructor(props) { super(props); const { staticContext = {} } = props; let { data = {} } = staticContext; if (process.browser && isCurUrl()) { data = window.__INITIAL_DATA__.data; } this.state = { data }; } async componentDidMount() { if (isCurUrl()) { return; } const { match } = this.props; const data = await fetchData({ match }); this.setState({ data }); } render() { return this.props.render(this.state); } } export default withRouter(Content);
自定義標記
通常在不同頁面中需要輸出不同的頁面標題、頁面描述,HTML 屬性等,可以藉助react-helmet 來處理此類問題:
模板設定
const markup = ReactDOMServer.renderToString(<Handler />); const helmet = Helmet.renderStatic(); const template = ` <!DOCTYPE html> <html ${helmet.htmlAttributes.toString()}> <head> <meta charset="UTF-8"> ${helmet.title.toString()} ${helmet.meta.toString()} ${helmet.link.toString()} </head> <body ${helmet.bodyAttributes.toString()}> <div id="root">${markup}</div> </body> </html> `;
元件中的使用
import React from 'react'; import Helmet from 'react-helmet'; const Contact = () => ( <> <h2>This is the contact page</h2> <Helmet> <title>Contact Page</title> <meta name="description" content="This is a proof of concept for React SSR" /> </Helmet> </> );
總結
想要做好 “同構應用” 並不簡單,需要了解非常多的概念。好訊息是目前 React 社群有一些比較著名的同構方案Next.js
、Razzle
等,如果你想快速入手React SSR
這些或許是不錯的選擇。如果面對複雜應用,自定義完整的體系將會更加靈活。