手摸手服務端渲染 - react
-
服務端渲染基礎
-
添加路由
-
添加 ajax 異步請求
-
添加樣式
-
代碼拆分
-
引入 react-helmet
服務端渲染基礎
類似vue ssr
思路,在 react ssr 我們也需要創建兩個入口文件,entry-client.js
、entry-server.js
,這兩個文件都引入 react 的主入口App.jsx
文件,entry-server 返回一個渲染 react 應用的渲染函數,在 node server 中拿到 entry-server 端返回的渲染函數並獲取到 html 字符串,最後將其處理好並返回給客戶端,client 端則負責激活 html 字符串,react 管這個步驟叫做 hydrate(水合)
mkdir ssr
cd ssr
npm init -y
npm install --save-dev webpack webpack-cli webpack-node-externals babel-loader @babel/core @babel/preset-env @babel/preset-react
npm install --save express react react-dom
我們先簡單創建一個 React 應用,新建App.jsx
ssr/src/App.jsx
import React, { useState } from 'react'
export default function App () {
const [name, setName] = useState('初始姓名')
const [age, setAge] = useState(0)
function onClick () {
setAge(age + 1)
}
return (
<article>
<p>姓名: { name }</p>
<p>年齡: { age }</p>
<button onClick={onClick}>年齡+1</button>
</article>
)
}
創建雙端入口
ssr/src/entry-client.jsx
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App.jsx'
ReactDOM.hydrate(<App />, document.querySelector('#root'))
ssr/src/entry-server.jsx
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import App from './App.jsx'
export default function createAppString () {
return ReactDOMServer.renderToString(<App />)
}
我們需要將連個入口打包變異成 node.js 可解析的 es5 版本語法,我們需要配置 webpack
ssr/webpack.client.js
const path = require('path')
module.exports = {
mode: 'development',
entry: './src/entry-client.jsx',
output: {
path: path.join(__dirname, 'dist', 'client'),
filename: 'index.js'
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
use: 'babel-loader',
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react']
}
}
}
]
}
}
ssr/webpack.server.js
const path = require('path')
const nodeExternals = require('webpack-node-externals');
module.exports = {
mode: 'development',
entry: './src/entry-server.jsx',
output: {
path: path.join(__dirname, 'dist', 'server'),
filename: 'index.js',
libraryTarget: 'umd',
umdNamedDefine: true,
globalObject: 'this',
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
use: 'babel-loader',
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react']
}
}
}
]
},
externals: [nodeExternals()],
target: 'node',
}
修改 ssr/package.json 的 scripts 如下
{
"name": "init",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build:client": "webpack --config webpack.client.js",
"build:server": "webpack --config webpack.server.js",
"build": "npm run build:client && npm run build:server",
"start": "nodemon server.js"
},
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.17.8",
"@babel/preset-env": "^7.16.11",
"@babel/preset-react": "^7.16.7",
"babel-loader": "^8.2.4",
"webpack": "^5.70.0",
"webpack-cli": "^4.9.2",
"webpack-node-externals": "^3.0.0"
},
"dependencies": {
"express": "^4.17.3",
"react": "^17.0.2",
"react-dom": "^17.0.2"
}
}
執行npm run build
此時目錄結構如下
ssr
├── dist
│ ├── client
│ │ └── index.js
│ └── server
│ └── index.js
├── package-lock.json
├── package.json
├── src
│ ├── App.jsx
│ ├── entry-client.jsx
│ └── entry-server.jsx
├── webpack.client.js
└── webpack.server.js
ssr/dist 爲編譯產物,其中 node server 渲染靜態 html 時需要用 ssr/dist/server/index.js,客戶端激活時需要使用 ssr/dist/client/index.js
我們搭建一個 node server 服務,來將 SSR 服務整體跑起來
ssr/server.js
const express = require('express')
const path = require('path')
const fs = require('fs/promises')
const server = express()
const createAppString = require('./dist/server/index').default
server.use('/js', express.static(path.join(__dirname, 'dist', 'client')))
server.get('/', async (req, res) => {
const htmlTemplate = await fs.readFile('./public/index.html', 'utf-8')
const appString = createAppString()
const html = htmlTemplate.replace('<article id="root"></article>', `<article id="root">${appString}</article>`)
res.send(html)
})
server.listen(1234)
創建 html 模版文件
ssr/public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta >
<title>Document</title>
</head>
<body>
<article id="root"></article>
<script src="/js/index.js"></script>
</body>
</html>
運行npm start
我們打開http://localhost:1234/
並查看源碼如下
通過點擊按鈕我們可以知道現在頁面已經是一個可交互的 react 應用了,我們查看源碼發現源碼已經正確的渲染出來 react 應用。
添加路由
npm install --save react-router-dom
創建路由映射表
ssr/src/routes.js
import Home from './pages/Home.jsx'
import About from './pages/About.jsx'
const routes = [
{
path: '/',
element: Home
},
{
path: '/about',
element: About
}
]
export default routes
ssr/src/pages/Home.jsx
import React from 'react'
export default function Home () {
return (
<article>我是首頁</article>
)
}
ssr/src/pages/About.jsx
import React, { useState } from 'react'
export default function About () {
const [name, setName] = useState('姓名默認值')
const [age, setAge] = useState(0)
function onClick () {
setAge(age + 1)
}
return (
<article>
<p>name: { name }</p>
<p>age: { age }</p>
<button onClick={onClick}>過年</button>
</article>
)
}
修改 ssr/src/App.jsx
import React from 'react'
import { Route, NavLink, Routes } from 'react-router-dom'
import routes from './routes'
export default function App () {
return (
<article>
<nav>
<NavLink to="/">Home</NavLink> |
<NavLink to="/about">About</NavLink>
</nav>
<main>
<Routes>
{
routes.map(item =>
<Route key={item.path} exact path={item.path} element={<item.element />} />
)
}
</Routes>
</main>
</article>
)
}
修改 ssr/src/entry-client.jsx
import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
ReactDOM.hydrate(
<BrowserRouter>
<App />
</BrowserRouter>
, document.querySelector('#root'))
修改 ssr/src/entry-server.jsx
import React from "react";
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import App from './App.jsx'
export default function createAppString({url}) {
console.log('url', url)
return renderToString(
<StaticRouter location={url}>
<App />
</StaticRouter>
);
}
運行npm run build
此時目錄結構如下
ssr
├── dist
│ ├── client
│ │ └── index.js
│ └── server
│ └── index.js
├── package-lock.json
├── package.json
├── public
│ └── index.html
├── server.js
├── src
│ ├── App.jsx
│ ├── entry-client.jsx
│ ├── entry-server.jsx
│ ├── pages
│ │ ├── About.jsx
│ │ └── Home.jsx
│ └── routes.js
├── webpack.client.js
└── webpack.server.js
修改 ssr/server.js
const express = require("express");
const path = require("path");
const fs = require("fs/promises");
(async () => {
const indexTemplate = await fs.readFile(path.join(__dirname, "public", "index.html"),"utf-8");
const server = express();
server.use('/js', express.static(path.join(__dirname, 'dist/client')))
server.get("*", async (req, res) => {
const createAppString = require('./dist/server/index').default
const appString = createAppString({ url: req.url })
const html = indexTemplate.replace(
'<article id="root">',
`<article id="root">${appString}`
);
res.send(html);
});
server.listen(1234);
})();
運行npm start
打開頁面 http://localhost:1234 / 並查看源碼
如上圖所示,服務端渲染正確
添加 ajax 異步請求
npm install --save axios
npm install --save-dev @babel/plugin-transform-runtime
修改 webpack 配置,使其支持async function
語法
ssr/webpack.server.js
const path = require('path')
const nodeExternals = require('webpack-node-externals');
module.exports = {
mode: 'development',
entry: './src/entry-server.jsx',
output: {
path: path.join(__dirname, 'dist', 'server'),
filename: 'index.js',
libraryTarget: 'umd',
umdNamedDefine: true,
globalObject: 'this',
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
use: 'babel-loader',
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
plugins: ['@babel/plugin-transform-runtime']
}
}
}
]
},
externals: [nodeExternals()],
target: 'node',
}
ssr/webpack.client.js
const path = require('path')
module.exports = {
mode: 'development',
entry: './src/entry-client.jsx',
output: {
path: path.join(__dirname, 'dist', 'client'),
filename: 'index.js'
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
use: 'babel-loader',
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
plugins: ['@babel/plugin-transform-runtime']
}
}
}
]
}
}
創建文件 ssr/src/api.js
import axios from 'axios'
export function getInfo () {
return axios.get('http://localhost:1234/info')
}
export function getText () {
return axios.get('http://localhost:1234/text')
}
我們在 SSR 階段可以通過瀏覽器返回的 url 與路由表信息來獲取與之匹配的頁面組件,我們假設匹配到的頁面組件中有getInitData
方法,我們通過該方法拿到頁面初始數據後再渲染react app
,然後我們將初始數據傳入 App
中
ssr/src/entry-server.jsx
import React from "react";
import { renderToString } from "react-dom/server";
import { StaticRouter, matchPath } from "react-router-dom/server";
import routes from './routes'
import App from './App.jsx'
export default async function createAppString({url}) {
const match = routes.filter(item => item.path === url)
let __INIT_DATA__ = {}
if (match.length > 0) {
await Promise.all(match.map(async item => {
const { getInitData, initDataId } = item.element
const { data } = await getInitData()
__INIT_DATA__[initDataId] = data
}))
}
const appString = renderToString(
<StaticRouter location={url}>
<App initData={__INIT_DATA__} />
</StaticRouter>
)
return { appString, __INIT_DATA__ };
}
App 在服務端渲染時傳入了initData
初始數據,我們拿到初始數據並將其傳入匹配到的頁面組件中
ssr/src/App.jsx
import React, { useState } from 'react'
import { Route, NavLink, Routes } from 'react-router-dom'
import routes from './routes'
export default function App (props) {
const [initData, setInitData] = useState((props.initData ? props.initData : window.__INIT_DATA__) || {})
return (
<article>
<nav>
<NavLink to="/">Home</NavLink> |
<NavLink to="/about">About</NavLink>
</nav>
<main>
<Routes>
{
routes.map(item =>
<Route key={item.path} exact path={item.path} element={<item.element initData={initData} setInitData={setInitData} />} />
)
}
</Routes>
</main>
</article>
)
}
接下來我們需要在頁面組件定義getInitData
方法,以使其在服務端渲染時能夠獲取到初始化數據。
因爲每個頁面都有一部分相同的處理初始數據的邏輯,所以需要我們將這部分邏輯抽離出來做成一個公共組件。
ssr/src/components/Layout.jsx
import React from 'react'
export default function Layout (Component, { getInitData, initDataId }) {
const PageComponent = (props) => {
const initData = props.initData[initDataId]
if (!initData) {
(async () => {
const { data } = await getInitData()
props.setInitData({ ...props.initData, [initDataId]: data })
})()
}
return <Component initData={initData || {}} />
}
PageComponent.getInitData = getInitData
PageComponent.initDataId = initDataId
return PageComponent
}
如上代碼所示,我們定義了一個 Layout 方法,專門用來處理初始數據,執行 Layout 會返回一個 PageComponent 組件,該組件包含當前頁面的getInitData
方法與initDataId
屬性。這兩個對象會被用於 SSR 階段獲取和保存數據,PageComponent 組件渲染並返回了我們傳入 Layout 方法的頁面組件(Home、About),頁面組件在渲染時傳入了已經處理好的當前頁面的初始數據initData
,所以我們在頁面組件 Home、About 組件中可以直接通過props.initData
獲取初始數據。
initDataId 的含義:我們在每個頁面會定義一個唯一的 initDataId
變量,我們儲存數據時會使用該變量作爲 key 值。用於在不同的頁面獲取與其對應的初始數據。
接下來我們修改頁面組件 Home 與 About,我們在頁面組件中引入 Layout,並傳入getInitData
方法與initDataId
屬性。
ssr/src/pages/Home.jsx
import React, { useState, useEffect } from "react";
import Layout from "../components/Layout.jsx";
import { getText } from "./../api";
function Home({ initData }) {
const [text, setText] = useState(initData && initData.text || "首頁");
useEffect(() => {
initData.text && setText(initData.text);
}, [initData]);
return <article>{text}</article>;
}
export default Layout(Home, { getInitData: getText, initDataId: 'home' })
ssr/src/pages/About.jsx
import React, { useEffect, useState } from "react";
import Layout from "../components/Layout.jsx";
import { getInfo } from "./../api";
function About({ initData }) {
const [name, setName] = useState(initData.name || "姓名默認值");
const [age, setAge] = useState(initData.age || 0);
useEffect(() => {
initData.name && setName(initData.name)
initData.age && setAge(initData.age)
}, [initData])
function onClick() {
setAge(age + 1);
}
return (
<article>
<p>name: {name}</p>
<p>age: {age}</p>
<button onClick={onClick}>過年</button>
</article>
);
}
export default Layout(About, { getInitData: getInfo, initDataId: 'about' })
我們在 ssr/server.js 中添加幾個接口。
調用createAppString
後我們能得到頁面的初始數據,我們將其放入 html 的全局變量window.__INIT_DATA__
中,用於客戶端激活與數據的初始化。
const express = require("express");
const path = require("path");
const fs = require("fs/promises");
(async () => {
const indexTemplate = await fs.readFile(path.join(__dirname, "public", "index.html"),"utf-8");
const server = express();
server.get('/info', async (req, res) => {
setTimeout(() => {
res.send({
name: 'hagan',
age: 22
})
}, 1000)
})
server.get('/text', async (req, res) => {
setTimeout(() => {
res.send({
text: '我是服務端渲染出來的首頁文案'
})
}, 1000)
})
server.use('/js', express.static(path.join(__dirname, 'dist/client')))
server.get("*", async (req, res) => {
const createAppString = require('./dist/server/index').default
const { appString, __INIT_DATA__ } = await createAppString({ url: req.url })
const html = indexTemplate.replace(
'<article id="root"></article>',
`<article id="root">${appString}</article><script>window.__INIT_DATA__ = ${JSON.stringify(__INIT_DATA__)}</script>`
);
res.send(html);
});
server.listen(1234);
})();
npm run build
此時目錄結構如下
.
├── dist
│ ├── client
│ │ └── index.js
│ └── server
│ └── index.js
├── package-lock.json
├── package.json
├── public
│ └── index.html
├── server.js
├── src
│ ├── App.jsx
│ ├── api.js
│ ├── components
│ │ └── Layout.jsx
│ ├── entry-client.jsx
│ ├── entry-server.jsx
│ ├── pages
│ │ ├── About.jsx
│ │ └── Home.jsx
│ └── routes.js
├── webpack.client.js
└── webpack.server.js
我們運行npm start
後打開頁面 http://localhost:1234 / 並查看源碼
此時服務端渲染已正確運行。
添加樣式
我們使用 css-loader 來處理 css,在客戶端渲染階段我們使用 style-loader 來將樣式插入到 html,在服務端渲染階段我們使用 isomorphic-style-loader 來獲取 css 字符串並手動將其插入到 html 模版中。
npm install --save-dev css-loader style-loader isomorphic-style-loader
修改 webpack 配置
ssr/webpack.client.js
const path = require('path')
module.exports = {
mode: 'development',
entry: './src/entry-client.jsx',
output: {
path: path.join(__dirname, 'dist', 'client'),
filename: 'index.js'
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
use: 'babel-loader',
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
plugins: ['@babel/plugin-transform-runtime']
}
}
},
{
test: /\.css?$/,
use: [
// "isomorphic-style-loader",
"style-loader",
{
loader: "css-loader",
options: {
modules: {
localIdentName: '[name]__[local]--[hash:base64:5]'
},
},
},
],
},
]
}
}
ssr/webpack.server.js
const path = require('path')
const nodeExternals = require('webpack-node-externals');
module.exports = {
mode: 'development',
entry: './src/entry-server.jsx',
output: {
path: path.join(__dirname, 'dist', 'server'),
filename: 'index.js',
libraryTarget: 'umd',
umdNamedDefine: true,
globalObject: 'this',
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
use: 'babel-loader',
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
plugins: ['@babel/plugin-transform-runtime']
}
}
},
{
test: /\.css?$/,
use: [
"isomorphic-style-loader",
{
loader: "css-loader",
options: {
modules: {
localIdentName: '[name]__[local]--[hash:base64:5]'
},
esModule: false, // 不加這個CSS內容會顯示爲[Object Module],且styles['name']方式拿不到樣式名
},
},
],
},
]
},
externals: [nodeExternals()],
target: 'node',
}
ssr/src/entry-server.jsx
import React from "react";
import { renderToString } from "react-dom/server";
import { StaticRouter, matchPath } from "react-router-dom/server";
import StyleContext from'isomorphic-style-loader/StyleContext'
import routes from './routes'
import App from './App.jsx'
export default async function createAppString({url}) {
const css = new Set();
const insertCss = (...styles) => styles.forEach(style => css.add(style._getCss()))
const match = routes.filter(item => item.path === url)
let __INIT_DATA__ = {}
if (match.length > 0) {
await Promise.all(match.map(async item => {
const { getInitData, initDataId } = item.element
const { data } = await getInitData()
__INIT_DATA__[initDataId] = data
}))
}
const appString = renderToString(
<StyleContext.Provider value={{ insertCss }}>
<StaticRouter location={url}>
<App initData={__INIT_DATA__} />
</StaticRouter>
</StyleContext.Provider>
)
return { appString, __INIT_DATA__, styles: [...css].join(' ') };
}
如上代碼所示,我們定義了一個css
變量和一個insertCss
方法,用來收集匹配到的 css。我們將 insertCss 傳入到StyleContext.Provider
中,然後我們在頁面組件中調用useStyles
就能收集到匹配頁面的 css 了。最後我們將收集到的 css 轉換成字符串返回給server.js
創建 ssr/src/pages/home.css
.home {
width: 80vw;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
background-color: azure;
border: 1px solid blue;
}
ssr/src/pages/Home.jsx
import React, { useState, useEffect } from "react";
import useStyles from "isomorphic-style-loader/useStyles";
import Layout from "../components/Layout.jsx";
import { getText } from "./../api";
import styles from './home.css'
function Home({ initData }) {
if (styles._insertCss) {
useStyles(styles);
}
const [text, setText] = useState(initData && initData.text || "首頁");
useEffect(() => {
initData.text && setText(initData.text);
}, [initData]);
return <article className={styles['home']}>{text}</article>;
}
export default Layout(Home, { getInitData: getText, initDataId: 'home' })
創建 ssr/src/pages/about.css
.about {
width: 80vw;
height: 300px;
background-color: cornsilk;
border: 1px solid rgb(183, 116, 255);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.name {
color: red;
margin: 0;
}
.age {
color: blue;
margin: 0;
padding: 5px;
}
ssr/src/pages/About.jsx
import React, { useEffect, useState } from "react";
import useStyles from "isomorphic-style-loader/useStyles";
import Layout from "../components/Layout.jsx";
import { getInfo } from "./../api";
import styles from './about.css'
function About({ initData }) {
if (styles._insertCss) {
useStyles(styles);
}
const [name, setName] = useState(initData.name || "姓名默認值");
const [age, setAge] = useState(initData.age || 0);
useEffect(() => {
initData.name && setName(initData.name)
initData.age && setAge(initData.age)
}, [initData])
function onClick() {
setAge(age + 1);
}
return (
<article className={styles["about"]}>
<p className={styles["name"]}>name: {name}</p>
<p className={styles["age"]}>age: {age}</p>
<button onClick={onClick}>過年</button>
</article>
);
}
export default Layout(About, { getInitData: getInfo, initDataId: 'about' })
執行npm run build
後目錄結構如下
ssr
├── dist
│ ├── client
│ │ └── index.js
│ └── server
│ └── index.js
├── package-lock.json
├── package.json
├── public
│ └── index.html
├── server.js
├── src
│ ├── App.jsx
│ ├── api.js
│ ├── components
│ │ └── Layout.jsx
│ ├── entry-client.jsx
│ ├── entry-server.jsx
│ ├── pages
│ │ ├── About.jsx
│ │ ├── Home.jsx
│ │ ├── about.css
│ │ └── home.css
│ └── routes.js
├── webpack.client.js
└── webpack.server.js
我們接收createAppString
返回的 css 字符串,並添加到 html 中。
ssr/server.js
const express = require("express");
const path = require("path");
const fs = require("fs/promises");
(async () => {
const indexTemplate = await fs.readFile(path.join(__dirname, "public", "index.html"),"utf-8");
const server = express();
server.get('/info', async (req, res) => {
setTimeout(() => {
res.send({
name: 'hagan',
age: 22
})
}, 1000)
})
server.get('/text', async (req, res) => {
setTimeout(() => {
res.send({
text: '我是服務端渲染出來的首頁文案'
})
}, 1000)
})
server.use('/js', express.static(path.join(__dirname, 'dist/client')))
server.get("*", async (req, res) => {
const createAppString = require('./dist/server/index').default
const { appString, __INIT_DATA__, styles } = await createAppString({ url: req.url })
const html = indexTemplate
.replace(
'<article id="root"></article>',
`<article id="root">${appString}</article><script>window.__INIT_DATA__ = ${JSON.stringify(
__INIT_DATA__
)}</script>`
)
.replace(
"<title>Document</title>",
`<title>Document</title><style ssr>${styles}</style>`
);
res.send(html);
});
server.listen(1234);
})();
執行npm start
後打開頁面 http://localhost:1234 / 並查看源碼
我們可以看到樣式已經生效,並且服務端渲染也返回了<style>
標籤。但這樣的方式不利於頁面的性能優化,所以我們需要將 css 抽離出來單獨的文件來進行引入。
抽離 css 需要mini-css-extract-plugin
npm install --save-dev mini-css-extract-plugin
這裏我們將兩個頁面的 css 抽離成一個 css 文件,然後我們直接在服務端渲染時引用這個 css 文件,這樣服務端渲染階段就不需要做 css 的收集了,我們需要把前面 css 收集相關的代碼都去掉。
修改 webpack 配置
ssr/webpack.client.js
const path = require('path')
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
mode: 'development',
entry: './src/entry-client.jsx',
output: {
path: path.join(__dirname, 'dist', 'client'),
filename: 'index.js'
},
plugins:[
new MiniCssExtractPlugin({
filename: "index.css",
chunkFilename: "[id].css"
})
],
module: {
rules: [
{
test: /\.(js|jsx)$/,
use: 'babel-loader',
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
plugins: ['@babel/plugin-transform-runtime']
}
}
},
{
test: /\.css?$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
options: {
publicPath: path.join(__dirname, 'dist', 'client')
}
},
{
loader: "css-loader",
options: {
modules: {
localIdentName: '[name]__[local]--[hash:base64:5]'
},
},
},
],
},
]
}
}
去掉收集 css 並插入到 html 的邏輯
ssr/server.js
const express = require("express");
const path = require("path");
const fs = require("fs/promises");
(async () => {
const indexTemplate = await fs.readFile(path.join(__dirname, "public", "index.html"),"utf-8");
const server = express();
server.get('/info', async (req, res) => {
setTimeout(() => {
res.send({
name: 'hagan',
age: 22
})
}, 1000)
})
server.get('/text', async (req, res) => {
setTimeout(() => {
res.send({
text: '我是服務端渲染出來的首頁文案'
})
}, 1000)
})
server.use("/static", express.static(path.join(__dirname, "dist/client")));
server.get("*", async (req, res) => {
const createAppString = require('./dist/server/index').default
const { appString, __INIT_DATA__ } = await createAppString({ url: req.url })
const html = indexTemplate
.replace(
'<article id="root"></article>',
`<article id="root">${appString}</article><script>window.__INIT_DATA__ = ${JSON.stringify(
__INIT_DATA__
)}</script>`
)
res.send(html);
});
server.listen(1234);
})();
這裏直接引入打包好的 css 文件即可/static/index.css
ssr/public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta >
<link type="text/css" rel="stylesheet" href="/static/index.css" />
<title>Document</title>
</head>
<body>
<article id="root"></article>
<script src="/static/index.js"></script>
</body>
</html>
去掉收集 css 的邏輯
ssr/src/entry-server.jsx
import React from "react";
import { renderToString } from "react-dom/server";
import { StaticRouter, matchPath } from "react-router-dom/server";
import routes from './routes'
import App from './App.jsx'
export default async function createAppString({url}) {
const match = routes.filter(item => item.path === url)
let __INIT_DATA__ = {}
if (match.length > 0) {
await Promise.all(match.map(async item => {
const { getInitData, initDataId } = item.element
const { data } = await getInitData()
__INIT_DATA__[initDataId] = data
}))
}
const appString = renderToString(
<StaticRouter location={url}>
<App initData={__INIT_DATA__} />
</StaticRouter>
)
return { appString, __INIT_DATA__ };
}
去掉收集 css 的邏輯
ssr/src/pages/Home.jsx
import React, { useState, useEffect } from "react";
import Layout from "../components/Layout.jsx";
import { getText } from "./../api";
import styles from './home.css'
function Home({ initData }) {
const [text, setText] = useState(initData && initData.text || "首頁");
useEffect(() => {
initData.text && setText(initData.text);
}, [initData]);
return <article className={styles['home']}>{text}</article>;
}
export default Layout(Home, { getInitData: getText, initDataId: 'home' })
去掉收集 css 的邏輯
ssr/src/pages/About.jsx
import React, { useEffect, useState } from "react";
import Layout from "../components/Layout.jsx";
import { getInfo } from "./../api";
import styles from './about.css'
function About({ initData }) {
const [name, setName] = useState(initData.name || "姓名默認值");
const [age, setAge] = useState(initData.age || 0);
useEffect(() => {
initData.name && setName(initData.name)
initData.age && setAge(initData.age)
}, [initData])
function onClick() {
setAge(age + 1);
}
return (
<article className={styles["about"]}>
<p className={styles["name"]}>name: {name}</p>
<p className={styles["age"]}>age: {age}</p>
<button onClick={onClick}>過年</button>
</article>
);
}
export default Layout(About, { getInitData: getInfo, initDataId: 'about' })
執行npm run build
後目錄結構如下
ssr
├── dist
│ ├── client
│ │ ├── index.css
│ │ └── index.js
│ └── server
│ └── index.js
├── package-lock.json
├── package.json
├── public
│ └── index.html
├── server.js
├── src
│ ├── App.jsx
│ ├── api.js
│ ├── components
│ │ └── Layout.jsx
│ ├── entry-client.jsx
│ ├── entry-server.jsx
│ ├── pages
│ │ ├── About.jsx
│ │ ├── Home.jsx
│ │ ├── about.css
│ │ └── home.css
│ └── routes.js
├── webpack.client.js
└── webpack.server.js
我們可以看到dist/client/index.css
中已經包含了兩個頁面的 css。
然後我們運行npm start
後打開頁面 http://localhost:1234 / 並查看源碼
此時添加外鏈樣式已完成
代碼拆分
在項目開發時,爲了性能考慮,我們通常會使用React.lazy
的方式加載異步組件,但React.lazy
並不適用於服務端渲染,此時我們可以使用loadable
代替React.lazy
來進行異步組件的加載。
npm install --save @loadable/component @loadable/server
npm install --save-dev @loadable/babel-plugin @loadable/webpack-plugin
我們先修改 webpack 配置
ssr/webpack.client.js
const path = require('path')
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const LoadablePlugin = require('@loadable/webpack-plugin')
module.exports = {
mode: 'development',
entry: './src/entry-client.jsx',
output: {
path: path.join(__dirname, 'dist', 'client'),
filename: 'index.js'
},
plugins:[
new MiniCssExtractPlugin({
filename: "index.css",
chunkFilename: "[id].css"
}),
new LoadablePlugin()
],
module: {
rules: [
{
test: /\.(js|jsx)$/,
use: 'babel-loader',
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
plugins: ["@babel/plugin-transform-runtime", "@loadable/babel-plugin"],
}
}
},
{
test: /\.css?$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
options: {
publicPath: path.join(__dirname, 'dist', 'client')
}
},
{
loader: "css-loader",
options: {
modules: {
localIdentName: '[name]__[local]--[hash:base64:5]'
},
},
},
],
},
]
}
}
ssr/webpack.server.js
const path = require('path')
const nodeExternals = require('webpack-node-externals');
const LoadablePlugin = require('@loadable/webpack-plugin')
module.exports = {
mode: 'development',
entry: './src/entry-server.jsx',
output: {
path: path.join(__dirname, 'dist', 'server'),
filename: 'index.js',
libraryTarget: 'umd',
umdNamedDefine: true,
globalObject: 'this',
},
plugins:[
new LoadablePlugin()
],
module: {
rules: [
{
test: /\.(js|jsx)$/,
use: 'babel-loader',
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
plugins: ["@babel/plugin-transform-runtime", "@loadable/babel-plugin"],
}
}
},
{
test: /\.css?$/,
use: [
"isomorphic-style-loader",
{
loader: "css-loader",
options: {
modules: {
localIdentName: '[name]__[local]--[hash:base64:5]'
},
esModule: false, // 不加這個CSS內容會顯示爲[Object Module],且styles['name']方式拿不到樣式名
},
},
],
},
]
},
externals: [nodeExternals()],
target: 'node',
}
然後我們修改路由表,使其異步加載頁面組件
ssr/src/routes.js
import loadable from '@loadable/component'
const routes = [
{
path: '/',
element: loadable(() => import('./pages/Home.jsx'))
},
{
path: '/about',
element: loadable(() => import('./pages/About.jsx'))
}
]
export default routes
我們需要修改客戶端入口文件,使loadable
能夠正確加載組件
ssr/src/entry-client.jsx
import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import { loadableReady } from '@loadable/component'
import App from './App.jsx'
loadableReady(() => {
ReactDOM.hydrate(
<BrowserRouter>
<App />
</BrowserRouter>
, document.querySelector('#root'))
})
ssr/src/entry-server.jsx
import React from "react";
import { renderToString } from "react-dom/server";
import { StaticRouter, matchPath } from "react-router-dom/server";
import routes from './routes'
import App from './App.jsx'
export default async function createAppString({url}) {
const match = routes.filter(item => item.path === url)
let __INIT_DATA__ = {}
if (match.length > 0) {
await Promise.all(match.map(async item => {
const Component = (await item.element.load()).default
const { getInitData, initDataId } = Component
const res = await getInitData()
__INIT_DATA__[initDataId] = res.data
}))
}
const appString = renderToString(
<StaticRouter location={url}>
<App initData={__INIT_DATA__} />
</StaticRouter>
)
return { appString, __INIT_DATA__ };
}
我們需要在 html 模版中刪掉static/index.css
的引入,因爲loadable
會自動加載匹配的 css
ssr/public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta >
<title>Document</title>
</head>
<body>
<article id="root"></article>
<script src="/static/index.js"></script>
</body>
</html>
然後我們運行npm run build
此時目錄結構如下
ssr
├── dist
│ ├── client
│ │ ├── index.js
│ │ ├── loadable-stats.json
│ │ ├── src_pages_About_jsx.css
│ │ ├── src_pages_About_jsx.index.js
│ │ ├── src_pages_Home_jsx.css
│ │ ├── src_pages_Home_jsx.index.js
│ │ └── vendors-node_modules_babel_runtime_regenerator_index_js-node_modules_axios_index_js-node_modu-8131bb.index.js
│ └── server
│ ├── index.js
│ ├── loadable-stats.json
│ ├── src_pages_About_jsx.index.js
│ └── src_pages_Home_jsx.index.js
├── package-lock.json
├── package.json
├── public
│ └── index.html
├── server.js
├── src
│ ├── App.jsx
│ ├── api.js
│ ├── components
│ │ └── Layout.jsx
│ ├── entry-client.jsx
│ ├── entry-server.jsx
│ ├── pages
│ │ ├── About.jsx
│ │ ├── Home.jsx
│ │ ├── about.css
│ │ └── home.css
│ └── routes.js
├── webpack.client.js
└── webpack.server.js
運行npm start
後打開頁面 http://localhost:1234/
我們可以發現頁面在加載的一瞬間會閃爍一下,我們查看源碼可以發現在服務端渲染階段並沒有返回給我們 css 樣式,而客戶端渲染時加載了樣式,但在客戶端加載完樣式之前是有一定的時間差的,所以纔會有一瞬間的閃爍,爲了解決此問題,我們需要在服務端渲染時把樣式插入進來。我們可以通過loadable
提供的ChunkExtractor
構造函數來獲取樣式,具體代碼如下
詳細的 loadable 文檔請參考鏈接 https://loadable-components.com/docs/server-side-rendering/
ssr/src/entry-server.jsx
import React from "react";
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import { ChunkExtractor } from '@loadable/server'
import { join } from 'path'
import routes from './routes'
import App from './App.jsx'
export default async function createAppString({url}) {
const match = routes.filter(item => item.path === url)
let __INIT_DATA__ = {}
if (match.length > 0) {
await Promise.all(match.map(async item => {
const Component = (await item.element.load()).default
const { getInitData, initDataId } = Component
const res = await getInitData()
__INIT_DATA__[initDataId] = res.data
}))
}
const extractor = new ChunkExtractor({
statsFile: join(__dirname, '../', 'client', 'loadable-stats.json'),
publicPath: '/static'
})
const appString = renderToString(
extractor.collectChunks(
<StaticRouter location={url}>
<App initData={__INIT_DATA__} />
</StaticRouter>
)
)
const styleTags = extractor.getStyleTags()
console.log('styleTags', styleTags)
return { appString, __INIT_DATA__, styleTags };
}
如上createAppString
返回了styleTags
樣式鏈接,我們在 node server 中接收並添加到 html 返回值中就可以了
ssr/server.js
const express = require("express");
const path = require("path");
const fs = require("fs/promises");
(async () => {
const indexTemplate = await fs.readFile(path.join(__dirname, "public", "index.html"),"utf-8");
const server = express();
server.get('/info', async (req, res) => {
setTimeout(() => {
res.send({
name: 'hagan',
age: 22
})
}, 1000)
})
server.get('/text', async (req, res) => {
setTimeout(() => {
res.send({
text: '我是服務端渲染出來的首頁文案'
})
}, 1000)
})
server.use("/static", express.static(path.join(__dirname, "dist/client")));
server.get("*", async (req, res) => {
const createAppString = require('./dist/server/index').default
const { appString, __INIT_DATA__, styleTags } = await createAppString({ url: req.url })
const html = indexTemplate
.replace(
'<!-- replace-css -->',
styleTags
)
.replace(
'<!-- replace-root -->',
`<article id="root">${appString}</article>`
)
.replace(
'<!-- replace-data -->',
`<script>window.__INIT_DATA__ = ${JSON.stringify(
__INIT_DATA__
)}</script>`
)
res.send(html);
});
server.listen(1234);
})();
ssr/public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta >
<!-- replace-css -->
<title>Document</title>
</head>
<body>
<!-- replace-root -->
<!-- replace-data -->
<script src="/static/index.js"></script>
</body>
</html>
npm run build
npm start
打開頁面 http://localhost:1234 / 並查看源碼
此時樣式已經在服務端渲染階段插入進來。
引入 react-helmet
使用 react-helmet 可以提升搜索引擎對於頁面的解析抓取效果,可以讓我們的頁面更好的被搜索引擎收錄。這裏我們直接引入,並將 Home、About 頁面分別修改爲兩個標題來做測試。
npm install --save react-helmet
ssr/src/App.jsx
import React, { useState } from 'react'
import { Route, NavLink, Routes } from 'react-router-dom'
import Helmet from 'react-helmet'
import routes from './routes'
export default function App (props) {
const [initData, setInitData] = useState((props.initData ? props.initData : window.__INIT_DATA__) || {})
return (
<article>
<Helmet>
<html lang="en" />
<meta charset="UTF-8"></meta>
<meta http-equiv="X-UA-Compatible" content="IE=edge"></meta>
<meta ></meta>
<title>默認標題</title>
</Helmet>
<nav>
<NavLink to="/">Home</NavLink> |
<NavLink to="/about">About</NavLink>
</nav>
<main>
<Routes>
{
routes.map(item =>
<Route key={item.path} exact path={item.path} element={<item.element initData={initData} setInitData={setInitData} />} />
)
}
</Routes>
</main>
</article>
)
}
Home 的頁面標題爲首頁
ssr/src/pages/Home.jsx
import React, { useState, useEffect } from "react";
import Helmet from 'react-helmet'
import Layout from "../components/Layout.jsx";
import { getText } from "./../api";
import styles from './home.css'
function Home({ initData }) {
const [text, setText] = useState(initData && initData.text || "首頁");
useEffect(() => {
initData.text && setText(initData.text);
}, [initData]);
return (
<article className={styles['home']}>
<Helmet>
<title>首頁</title>
</Helmet>
{text}
</article>
);
}
export default Layout(Home, { getInitData: getText, initDataId: 'home' })
About 的頁面標題爲關於頁
ssr/src/pages/About.jsx
import React, { useEffect, useState } from "react";
import Helmet from 'react-helmet'
import Layout from "../components/Layout.jsx";
import { getInfo } from "./../api";
import styles from './about.css'
function About({ initData }) {
const [name, setName] = useState(initData.name || "姓名默認值");
const [age, setAge] = useState(initData.age || 0);
useEffect(() => {
initData.name && setName(initData.name)
initData.age && setAge(initData.age)
}, [initData])
function onClick() {
setAge(age + 1);
}
return (
<article className={styles["about"]}>
<Helmet>
<title>關於頁</title>
</Helmet>
<p className={styles["name"]}>name: {name}</p>
<p className={styles["age"]}>age: {age}</p>
<button onClick={onClick}>過年</button>
</article>
);
}
export default Layout(About, { getInitData: getInfo, initDataId: 'about' })
ssr/src/entry-server.jsx
import React from "react";
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import { ChunkExtractor } from '@loadable/server'
import { join } from 'path'
import Helmet from 'react-helmet'
import routes from './routes'
import App from './App.jsx'
export default async function createAppString({url}) {
const match = routes.filter(item => item.path === url)
let __INIT_DATA__ = {}
if (match.length > 0) {
await Promise.all(match.map(async item => {
const Component = (await item.element.load()).default
const { getInitData, initDataId } = Component
const res = await getInitData()
__INIT_DATA__[initDataId] = res.data
}))
}
const extractor = new ChunkExtractor({
statsFile: join(__dirname, '../', 'client', 'loadable-stats.json'),
publicPath: '/static'
})
const appString = renderToString(
extractor.collectChunks(
<StaticRouter location={url}>
<App initData={__INIT_DATA__} />
</StaticRouter>
)
)
const styleTags = extractor.getStyleTags()
const helmet = Helmet.renderStatic()
console.log('styleTags', styleTags)
return { appString, __INIT_DATA__, styleTags, helmet };
}
ssr/server.js
const express = require("express");
const path = require("path");
const fs = require("fs/promises");
(async () => {
const indexTemplate = await fs.readFile(path.join(__dirname, "public", "index.html"),"utf-8");
const server = express();
server.get('/info', async (req, res) => {
setTimeout(() => {
res.send({
name: 'hagan',
age: 22
})
}, 1000)
})
server.get('/text', async (req, res) => {
setTimeout(() => {
res.send({
text: '我是服務端渲染出來的首頁文案'
})
}, 1000)
})
server.use("/static", express.static(path.join(__dirname, "dist/client")));
server.get("*", async (req, res) => {
const createAppString = require('./dist/server/index').default
const { appString, __INIT_DATA__, styleTags, helmet } = await createAppString({ url: req.url })
const html = indexTemplate
.replace(
'replace-html-attributes',
helmet.htmlAttributes.toString()
)
.replace(
'<!-- replace-meta -->',
helmet.meta.toString()
)
.replace(
'<!-- replace-link -->',
helmet.link.toString()
)
.replace(
'<!-- replace-css -->',
styleTags
)
.replace(
'<!-- replace-title -->',
helmet.title.toString()
)
.replace(
'replace-body-attributes',
helmet.bodyAttributes.toString()
)
.replace(
'<!-- replace-root -->',
`<article id="root">${appString}</article>`
)
.replace(
'<!-- replace-data -->',
`<script>window.__INIT_DATA__ = ${JSON.stringify(
__INIT_DATA__
)}</script>`
)
res.send(html);
});
server.listen(1234);
})();
ssr/public/index.html
<!DOCTYPE html>
<html replace-html-attributes>
<head>
<!-- replace-meta -->
<!-- replace-link -->
<!-- replace-css -->
<!-- replace-title -->
</head>
<body replace-body-attributes>
<!-- replace-root -->
<!-- replace-data -->
<script src="/static/index.js"></script>
</body>
</html>
運行npm run build
此時目錄結構如下
ssr
├── dist
│ ├── client
│ │ ├── index.js
│ │ ├── loadable-stats.json
│ │ ├── pages-About-jsx.css
│ │ ├── pages-About-jsx.index.js
│ │ ├── pages-Home-jsx.css
│ │ ├── pages-Home-jsx.index.js
│ │ ├── src_pages_About_jsx.css
│ │ ├── src_pages_About_jsx.index.js
│ │ ├── src_pages_Home_jsx.css
│ │ ├── src_pages_Home_jsx.index.js
│ │ └── vendors-node_modules_babel_runtime_regenerator_index_js-node_modules_axios_index_js-node_modu-8131bb.index.js
│ └── server
│ ├── index.js
│ ├── loadable-stats.json
│ ├── pages-About-jsx.index.js
│ ├── pages-Home-jsx.index.js
│ ├── src_pages_About_jsx.index.js
│ └── src_pages_Home_jsx.index.js
├── package-lock.json
├── package.json
├── public
│ └── index.html
├── server.js
├── src
│ ├── App.jsx
│ ├── api.js
│ ├── components
│ │ └── Layout.jsx
│ ├── entry-client.jsx
│ ├── entry-server.jsx
│ ├── pages
│ │ ├── About.jsx
│ │ ├── Home.jsx
│ │ ├── about.css
│ │ └── home.css
│ └── routes.js
├── webpack.client.js
└── webpack.server.js
我們運行npm start
後打開頁面 http://localhost:1234/about 並查看源碼
我們可以看到 SSR 階段頁面 title 就已經正確的被解析成我們想要的 title 了,helmet 還有很多功能,詳細 API 請參考官方文檔 https://www.npmjs.com/package/react-helmet
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/2PpU_JGNTftk4nOYbIs58A