手摸手服务端渲染-react
共 37750字,需浏览 76分钟
·
2022-04-08 22:19
目录
服务端渲染基础
添加路由
添加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}>年龄+1button>
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('', `${appString} `)
res.send(html)
})
server.listen(1234)
创建html模版文件
ssr/public/index.html
html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Documenttitle>
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="/">HomeNavLink> |
<NavLink to="/about">AboutNavLink>
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(
'' ,
`${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="/">HomeNavLink> |
<NavLink to="/about">AboutNavLink>
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(
'',
`${appString} `
);
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(
'',
`${appString} `
)
.replace(
"Document ",
`Document `
);
res.send(html);
});
server.listen(1234);
})();
执行npm start
后打开页面http://localhost:1234/并查看源码
我们可以看到样式已经生效,并且服务端渲染也返回了标签。但这样的方式不利于页面的性能优化,所以我们需要将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(
'',
`${appString} `
)
res.send(html);
});
server.listen(1234);
})();
这里直接引入打包好的css文件即可/static/index.css
ssr/public/index.html
html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link type="text/css" rel="stylesheet" href="/static/index.css" />
<title>Documenttitle>
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
html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Documenttitle>
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(
'',
styleTags
)
.replace(
'',
`${appString} `
)
.replace(
'',
``
)
res.send(html);
});
server.listen(1234);
})();
ssr/public/index.html
html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Documenttitle>
head>
<body>
<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 name="viewport" content="width=device-width, initial-scale=1.0">meta>
<title>默认标题title>
Helmet>
<nav>
<NavLink to="/">HomeNavLink> |
<NavLink to="/about">AboutNavLink>
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(
'',
helmet.meta.toString()
)
.replace(
'',
helmet.link.toString()
)
.replace(
'',
styleTags
)
.replace(
'',
helmet.title.toString()
)
.replace(
'replace-body-attributes',
helmet.bodyAttributes.toString()
)
.replace(
'',
`${appString} `
)
.replace(
'',
``
)
res.send(html);
});
server.listen(1234);
})();
ssr/public/index.html
html>
<html replace-html-attributes>
<head>
head>
<body replace-body-attributes>
<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