- 在前面的文章中,我已经对服务端渲染有了充分介绍,并且实现了最简单的服务端渲染。
- 在这篇文章中,就基于React,一步一步来搭建一个服务端渲染的项目。
这里是github地址 react-ssr,欢迎start
第一步:React组件渲染
1. 目标
首先,我们将下面这个简单的React组件渲染出来。
在前面的文章中已经渲染出简单的HTML结构,现在需要在页面上渲染出React组件。
// 在页面上渲染出该home组件
const Home = () => {
return (
<div>
<div>Hello World</div>
</div>
)
}
- 你可能会想,直接把React组件引入不就可以了吗?就像这样:
import express from 'express';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import Home from '../containers/Home';
const app = express();
const content = ReactDOMServer.renderToString(<Home />);
app.get('/', function(req, res) {
res.send(
`
<html>
<head>
<title>ssr</title>
</head>
<body>
<div id="root">${content}</div>
</body>
</html>
`
)
})
app.listen(8000, () => {
console.log('listen:8000')
})
- 但是,上面的代码直接执行会失败
- 首先是
es6 语法,如果比较高的版本的node 在package.json 中配置就可以了。如果是比较低版本的就不行了。
// 报错信息,无法识别 es mudule
SyntaxError: Cannot use import statement outside a module
- 还有
JSX 需要结合babel 使用 @babel/preset-react 进行转换
// 报错信息,无法识别JSX语法
SyntaxError: Unexpected token '<'
所以在正式开始之前,需要将项目进行配置一下
2. 下载依赖
首先需要安装项目所需依赖
express
yarn add express // 用于启动服务
webpack
yarn add -D webpack webpack-cli webpack-dev-server webpack-merge webpack-node-externals
bable
yarn add -D @babel/cli @babel/core @babel/preset-env @babel/preset-react @babel/preset-stage-0 babel-loader @babel/runtime
react
yarn add react react-dom
命令
yarn add -D npm-run-all // 简化命令
yarn add -D nodemon // 监听变化,自动执行JS文件
注意:可以参考我项目中的依赖版本。
3.项目配置
- 我们这里开发的Home组件是不能直接在node中运行的,需要借助
webpack 工具将jsx 语法打包编译成js语法,让nodejs 可以争取的识别,我们需要创建一个webpack.server.js 文件。
const Path = require('path');
const NodeExternals = require('webpack-node-externals');
module.exports = {
target: 'node',
mode: 'development',
entry: './src/server/index.js',
output: {
filename: 'bundle.js',
path: Path.resolve(__dirname, 'build')
},
externals: [NodeExternals()],
module: {
rules: [
{
test: /.js?$/,
loader: 'babel-loader',
exclude: /node_modules/,
options: {
presets: ['react', 'stage-0', ['env', {
targets: {
browsers: ['last 2 versions']
}
}]]
}
}
]
}
}
4. 编写基于express服务
需要安装react-dom ,借助renderToString 将Home组件转换为标签字符串
import express from 'express';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import Home from '../containers/Home';
const app = express();
const content = ReactDOMServer.renderToString(<Home />);
app.get('/', function(req, res) {
res.send(
`
<html>
<head>
<title>ssr</title>
</head>
<body>
<div id="root">${content}</div>
</body>
</html>
`
)
})
app.listen(8000, () => {
console.log('listen:8000')
})
// containers/Home/index.js
import React from 'react';
const Home = () => {
return (
<div>
<div>Hello World</div>
</div>
)
}
export default Home
5. 启动服务
想要访问到页面,我们需要打包产生bundle.js 文件,执行该文件就能启动express 服务,我们就能通过浏览器窗口的地址栏输入localhost:8000 访问到了。
但是在项目进行的过程中,可能需要多次修改,这里我们就需要相应配置从而简化一下。
- 自动打包:通过
--watch 监听文件变化进行自动打包 。 - 运行JS文件:借助
nodemon 模块,监听build 文件并且发生改变之后重新exec运行node ./build/bundile.js - 执行所有命令:创建一个dev命令, 里面执行
npm-run-all , --parallel表示并行执行, 执行dev:开头的所有命令。
配置如下:
"scripts": {
"dev": "npm-run-all --parallel dev:**",
"dev:build:server": "webpack --config webpack.server.js --watch",
"dev:start": "nodemon --watch build --exec node \"./build/bundle.js\""
},
这个时候我想启动服务器同时监听文件改变运行yarn dev 就可以了。出现下面内容说明启动成功,在浏览器中可以看到页面内容。
6. 小结
- React组件服务端渲染需要使用
renderToString 方法,将React组件渲染为字符串。并注入到需要发送到客户端的HTML中。
第二步:同构
1. 事件绑定
- 前面我们实现了最基本的SSR服务端渲染的流程,但是通过
renderToString 方法将React组件渲染为字符串。 - 因为只是字符串,所以并不能进行交互。也就是说一系列的事件绑定都没有应用上去。
接下来我们就来学习事件的绑定。
(1)做法
SSR 有两套代码,一套在服务端运行一次,一套在客户端运行一次。- 首先浏览器向服务器发送请求,服务器返回一个空的html。
- 浏览器再请求并加载JS。
- 执行JS代码接管页面执行流程,这个时候就可以触发点击事件了。
客户端获取到的页面结构如下:
(2)同构代码
浏览器后续请求的JS就是同构代码。这里我们同构代码使用hydrate 代替render 。
import React from 'react';
import ReactDom from 'react-dom';
import Home from '../containers/Home';
ReactDom.hydrate( < Home / > , document.getElementById('root'))
原组件中增加点击事件
import React from 'react';
const Home = () => {
return <div onClick={() => { alert('click'); }}>home</div>
}
export default Home
在服务端生成的html中引入JS,
app.get('/', function(req, res) {
res.send(
`
<html>
<head>
<title>Palate-ssr</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`
)
})
(3)配置webpack
同构代码也需要先对React 语法进行编译
const path = require('path')
const { merge } = require('webpack-merge')
const config = require('./webpack.base')
const clientConfig = {
mode: 'development',
entry: './src/client/index.js',
module: {
rules: [{
test: /\.css?$/,
use: ['style-loader', {
loader: 'css-loader',
options: {
modules: true
}
}]
}]
},
output: {
filename: 'index.js',
path: path.resolve(__dirname, 'public')
},
}
module.exports = merge(config, clientConfig)
抽离和webpack.server.js 文件的公共部分,使用webpack-merge 插件对内容进行合并。
module.exports = {
module: {
rules: [{
test: /\.jsx?$/,
exclude: '/node_modules/',
loader: 'babel-loader',
options: {
presets: ["@babel/react", ['@babel/env', {
targets: {
browsers: ['last 2 versions']
}
}]]
}
}]
}
};
2. 前端路由
路由和上面的事件一样也是同构,因为路由本身也是通过修改URL ,触发监听URL 变化的事件来切换页面内容的。
所以,类似前面的逻辑:
- 首先浏览器向服务器发送请求,服务器返回一个空的html。
- 浏览器再请求JS,加载到js后会执行react代码。
- react代码接管页面执行流程,这个时候可以根据浏览器的地址展示页面内容。
也就是说,首页是服务端拼接好的,后面是基于JS代码进行内容切换,即后续页面内容由JS生成。
(1)下载依赖
yarn add react-router-dom
注意:
- 这里的
react-router-dom 不能下载6.x版本的。因为最新版已经弃用了staticRouter ,而我们需要用到。下载react-router-dom@5.3.0
(2)定义路由规则
import React from 'react';
import {Route} from 'react-router-dom'
import Home from './containers/Home';
import Login from './containers/Login';
export default (
<div>
<Route path='/' exact component={Home}></Route>
<Route path='/login' exact component={Login}></Route>
</div>
);
(3)路由导航组件
在Header 中引入Link , 并且使用他跳转至Home和Login。
import React from 'react';
import { Link } from 'react-router-dom';
const Header = () => {
return (
<div>
<Link to="/">Home</Link>
<br />
<Link to="/login">Login</Link>
</div>
)
}
export default Header;
(4)组件中引入导航组件
新建一个Login组件,用于测试路由跳转
import React from 'react';
import Header from '../../components/Header';
const Login = () => {
return (
<div>
<Header />
<div> Login </div>
</div>
)
};
export default Login;
Home组件中也需要引入Header组件
import React from 'react';
import Header from '../../components/Header';
const Home = () => {
return (
<div>
<Header />
home
<button onClick={() => { alert('click'); }}>按钮</button>
</div>
)
}
export default Home
同构代码
路由规则需要在客户端执行
import React from 'react';
import ReactDom from 'react-dom';
import { BrowserRouter } from 'react-router-dom'
import Routes from '../Routes'
const App = () => {
return (
<BrowserRouter>
{Routes}
</BrowserRouter>
)
}
ReactDom.hydrate(<App />, document.getElementById('root'));
服务端代码
路由规则也需要在服务端执行
- 服务端要使用
StaticRouter 组件替代浏览器的browserRouter 。 StaticRouter 是不知道请求路径是什么的,因为他运行在服务器端,这是他不如BrowserRouter 的地方,他需要在请求体中获取到路径传递给他,。- 这里我们就需要将
content 写在请求里面。将location 的值赋为req.path 。
import express from 'express';
import { render } from './utils';
const app = express();
app.use(express.static('public'));
app.get('*', function(req, res) {
res.send(render(req))
});
app.listen(8000, () => {
console.log('listen:8000')
})
提取出render模块:
import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import Routes from '../Routes';
export const render = (req) => {
const content = renderToString((
<StaticRouter location={req.path} context={{}}>
{Routes}
</StaticRouter>
));
return `
<html>
<head>
<title>这里是Palate的博客</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`;
}
启动项目,可以看到
页面源码
<html>
<head>
<title>这里是Palate的博客</title>
</head>
<body>
<div id="root">
<div>
<div>
<div>
<a href="/">Home</a>
<br/>
<a href="/login">Login</a>
</div>
home<button>按钮</button>
</div>
</div>
</div>
<script src="/index.js"></script>
</body>
</html>
注意
- 当我们在做页面同构的时候,服务器端渲染只放生在我们第一次进入页面的时候,后面使用Link的跳转都是浏览器端的跳转。
- 所以服务器端渲染不是每个页面都做服务器端渲染,而是只访问的第一个页面具有服务端渲染的特性,其他的页面仍旧是React的路由机制, 这是我们要注意的。
3. 小结
这一步讲同构代码,主要是事件绑定和前端路由的实现。
基本思路就是:
- 两套代码,一套在服务端运行一次,一套在客户端运行一次。服务端完成html元素的渲染,客户端完成元素事件的绑定。
- 把React组件和路由规则编译打包成JS文件交给服务端。
- 服务端先发送HTML给客户端。客户端解析HTML模板时,通过script标签请求并加载JS文件激活页面。
路由:服务端需要将路由逻辑执行一遍,服务端的路由使用的是 StaticRouter 。
第三步:引入Redux
1. 安装依赖
yarn add redux react-redux redux-thunk
2. 创建全局store
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
const reducer = combineReducers({
home: homeReducer
})
const getStore = () => {
return createStore(reducer, applyMiddleware(thunk));
}
export default getStore;
3. 项目引入store
对于store的连接操作,在同构项目中分两个部分,一个是与客户端store的连接,另一部分是与服务端store的连接。通过react-redux 中的Provider来传递store
import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import Routes from '../Routes';
import getStore from '../store';
import { Provider } from 'react-redux';
const store = getStore()
export const render = (req) => {
const content = renderToString((
<Provider store={store}>
<StaticRouter location={req.path} context={{}}>
<Routes />
</StaticRouter>
</Provider>
));
return `
<html>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`;
}
import React from 'react';
import ReactDom from 'react-dom';
import { BrowserRouter, Route } from 'react-router-dom'
import Routes from '../Routes'
import { Provider } from 'react-redux';
import getstore from '../store';
const store = getStore()
const App = () => {
return (
<Provider store={store}>
<BrowserRouter >
{Routes}
</BrowserRouter>
</Provider>
)
}
ReactDom.hydrate(<App />, document.getElementById('root'));
到这里,就完成了Redux 的引入
4. 创建服务端资源
在根目录的public文件夹下
{
"data": [
{
"id": 1,
"title": "1111111"
},
{
"id": 2,
"title": "2222222"
},
{
"id": 3,
"title": "3333333"
},
{
"id": 4,
"title": "4444444"
},
{
"id": 5,
"title": "5555555"
}
]
}
- 可以通过
localhost:8000/api/news.json 访问到该数据,可以在浏览器中尝试。 - 我们下面要做的做的就是让Home组件请求到这个数据。
注意:浏览器会自动请求一个favicon 文件,造成代码重复执行,我们可以在public 文件夹中加入这个图片解决该问题。
5. 组件内action和reducer的构建
下载axios
yarn add axios
四个JS文件
在Home文件夹下创建store文件夹,创建以下文件
export const CHANGE_LIST = 'HOME/CHANGE_LIST';
import axios from 'axios';
import { CHANGE_LIST } from "./constants";
const changeList = list => ({
type: CHANGE_LIST,
list
});
export const getHomeList = () => {
return dispatch => {
return axios.get('http://localhost:8000/api/news.json')
.then((res) => {
const list = res.data.data;
dispatch(changeList(list))
})
}
}
import { CHANGE_LIST } from './constants';
const defaultState = {
name: 'palate',
list: []
}
export default (state = defaultState, action) => {
switch (action.type) {
case CHANGE_LIST:
const newState = {
...state,
list: action.list
}
return newState
default:
return state;
}
}
import reducer from "./reducer";
export { reducer }
6. 组件连接全局store
import React, { Component } from 'react';
import Header from '../../components/Header';
import { connect } from 'react-redux';
import { getHomeList } from './store/actions';
class Home extends Component {
getList() {
const { list } = this.props;
return list.map(item => <div key={item.id}>{item.title}</div>)
}
render() {
return (
<div>
<Header/>
<div>Home</div>
{this.getList()}
<button onClick={() => { alert('click1'); }}>按钮</button>
</div>
)
}
componentDidMount() {
this.props.getHomeList()
}
}
const mapStatetoProps = state => ({
list: state.home.list
});
const mapDispatchToProps = dispatch => ({
getHomeList() {
dispatch(getHomeList());
}
})
export default connect(mapStatetoProps, mapDispatchToProps)(Home);
componentDidMount() 在服务端无法执行,需要添加一个静态方法Home.loadData 加载数据,这个方法只在服务端有效。
Home.loadData = (store) => {
return store.dispatch(getHomeList());
}
7. 改造路由
这里需要改造一下路由配置,根据路由来判断是否需要通过loadData 加载数据。
import React from 'react';
import Home from './components/Home';
import Login from './components/Login';
export default [
{
path: '/',
component: Home,
exact: true,
key: 'home',
loadData: Home.loadData
},
{
path: '/login',
component: Login,
key: 'login',
exact: true
}
]
使用Router.js 的地方也要修改
<Provider store={store}>
<BrowserRouter>
<div>
{
routers.map(route => {
<Route {...route} />
})
}
</div>
</BrowserRouter>
</Provider>
<Provider store={store}>
<StaticRouter>
<div>
{
routers.map(route => {
<Route {...route} />
})
}
</div>
</StaticRouter>
</Provider>
6. 渲染list数据
先下载react-router-config
在server/utils.js 中加入以下逻辑
import { matchRoutes as matchRoute } from 'react-router-config';
const matchedRoutes = matchRoute(routes, req.path)
let promises = [];
matchedRoutes.forEach(item => {
if (item.route.loadData) {
promises.push(item.route.loadData(store));
}
});
Promise.all(promises).then(() => {
})
将对server的内容整理一下,提取出render函数
import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter, Route } from 'react-router-dom';
import Routes from '../Routes';
import getStore from '../store';
import { Provider } from 'react-redux';
import { matchRoutes as matchRoute } from 'react-router-config';
export const render = (req, res) => {
const store = getStore();
const matchedRoutes = matchRoute(Routes, req.path);
let promises = [];
matchedRoutes.forEach(item => {
if (item.route.loadData) {
promises.push(item.route.loadData(store));
}
});
Promise.all(promises).then(() => {
const content = renderToString((
<Provider store = { store } >
<StaticRouter location={req.path} context={{}}>
<div>
{
Routes.map(route => (
<Route {...route} />
))
}
</div>
</StaticRouter >
</Provider>
));
res.send(`
<html>
<body>
<div id="root">${content}</div>
</body>
<script src="/index.js"></script>
</html>
`);
})
}
server/index.js
import express from 'express';
import { render } from './utils';
const app = express();
app.use(express.static('public'));
app.get('*', function(req, res) {
render(req, res)
});
app.listen(8000, () => {
console.log('listen:8000')
})
7. 数据注水和脱水
目的:解决客户端和服务端的store可能不同步的问题。
- 因为服务端和客户端的store是分别创建的,如果中间有代码不一致,就有可能导致store不同步。
- 以服务端的store为准,客户端获取服务端的store。
这就需要分两步进行:
(1)注水
数据的“注水”操作,即把服务端的store数据注入到window全局环境中。
做法:在返回的html代码中加入这样一个script标签:
<script>
window.context = {
state: ${JSON.stringify(store.getState())}
}
</script>
(2)脱水
接下来是“脱水”处理,换句话说也就是把window上绑定的数据给到客户端的store,可以在客户端store产生的源头进行,即在全局的store/index.js中进行。
import { legacy_createStore as createStore, applyMiddleware, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import { reducer as homeReducer } from '../containers/Home/store';
const reducer = combineReducers({
home: homeReducer
})
export const getStore = () => {
return createStore(reducer, applyMiddleware(thunk));
}
export const getClientStore = () => {
const defaultState = window.context ? window.context.state : {};
return createStore(reducer, defaultState, applyMiddleware(thunk));
}
然后对引入getStore 的地方进行修改,这里省略了。
这样我们访问浏览器就可以发现页面结构已经渲染出来了。
8. 注意问题
1.渲染出页面后,可能会遇到报错情况
需要在script标签添加 defer属性,避免阻塞HTML解析
2.注意需要使用div包裹一下所有Route,否则会报错。因为react-route-dom 要求route 成组出现。
3.避免组件重复获取数据
- 因为数据已经在服务端获取并拼接在HTML结构中了,所以判断服务端已经获取到数据,就不重复获取。
componentDidMount() {
if (!this.props.list.length) { //判断当前的数据是否已经从服务端获取
this.props.getHomeList() // 请求数据
}
}
- 那为什么不直接删掉?
- 因为该页不一定是作为首页展现,而是跳转到该页面的,这时候还是需要发送请求。
9. 小结
在这一步完成的redux的引入和异步请求数据。
- 对于store的连接操作,在同构项目中分两个部分,一个是与客户端store的连接,另一部分是与服务端store的连接。
- 客户端和服务端都需要异步获取数据和创建store。
- 客户端需要获取服务端的store,保持数据同步。(注水、脱水)
第四步:node作中间层
1. 为什么引入中间层
- 前端每次发送请求都是去请求
node层 的接口,然后node层 对于相应的前端请求做转发,用node层 去请求真正的后端接口获取数据,获取后再由node层 做对应的数据计算等处理操作,然后返回给前端。 node层 替前端接管了对数据的操作,减轻对服务器端的性能消耗。- 之前搭建的SSR框架中,服务端和客户端请求利用的是同一套请求后端接口的代码,但这是不合理的。
2. 组件请求判断
- 我们先对组件的请求做个修改,判断请求时哪里发出的。
- 如果是在客户端,那么是发送给该中间层。中间层请求是发送到真正的服务端。
const getUrl = (server) => {
return server ? 'xxxx(后端接口地址)' : '/api/sanyuan.json(node接口)';
}
export const getHomeList = (server) => {
return dispatch => {
return axios.get(getUrl(server))
.then((res) => {
const list = res.data.data;
dispatch(changeList(list))
})
}
}
Home组件调用getHome() 时需要传入参数
在componentDidMount 中调用这个action时传入false,因为这个是客户端发送的请求
在loadData 函数中调用时传入true,因为这个是中间层发送的请求
3. 中间层转发请求
import proxy from 'express-http-proxy';
app.use('/api', proxy('http://xxxxxx(服务端地址)', {
proxyReqPathResolver: function(req) {
return '/api'+req.url;
}
}));
4. 代码优化
请求在组件中判断并不合理。其实,每个组件中都需要进行一样的判断。
(1)封装axios
我们把这部分判断提取出来,对axios做一个封装。
import axios from 'axios'
const instance = axios.create({
baseURL: 'http://xxxxxx(服务端地址)'
})
export default instance
import axios from 'axios'
const instance = axios.create({
baseURL: '/'
})
export default instance
(2)通过store传递
import { legacy_createStore as createStore, applyMiddleware, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import { reducer as homeReducer } from '../containers/Home/store';
import clientAxios from '../client/request';
import serverAxios from '../server/request';
const reducer = combineReducers({
home: homeReducer
})
export const getStore = () => {
return createStore(reducer, applyMiddleware(thunk.withExtraArgument(serverAxios)));
}
export const getClientStore = () => {
const defaultState = window.context ? window.context.state : {};
return createStore(reducer, defaultState, applyMiddleware(thunk.withExtraArgument(clientAxios)));
}
(3)组件内获取axios实例
export const getHomeList = () => {
return (dispatch, getState, axiosInstance) => {
return axiosInstance.get('资源地址')
.then((res) => {
const list = res.data.data;
dispatch(changeList(list))
})
}
}
5. 另外启动一个服务
这个服务是用来接收中间层发送过来的请求的,将前面放在public/api/news.json 放置在该项目中
const express = require("express")
const app = express()
app.use(express.static('public'))
app.listen(4000, () => { console.log('running 4000') })
- 将这个是目标服务器,先启动该服务,再启动原来的项目,可以看到数据成功展示。(因为请求方式没有变化)
- 这里可以先将中间层获取数据的逻辑去掉尝试一下,因为中间层获取数据并拼接到HTML中了,客户端不会重新获取。
- 或者在login页面测试一下,因为这里是经过跳转的,获取数据就需要客户端发送请求。
6. 小结
- 在这里将启动了另外一个服务器,作为真正的目标服务器。
- 而之前搭建的作为中间层,只负责页面渲染和请求转发。
- 这一步实现了请求转发,也就是客户端发送请求到中间层,中间层转发请求给目标服务器,从目标服务器获取数据。
- 使用
thunk.withExtraArgument 传递封装的axios请求。
这里需要注意,中间层渲染出页面的时候会向后端发送请求
第五步:多级路由渲染
1. 修改路由规则
现在的需求是,页面中有一个App组件包含所有组件
import Home from './containers/Home';
import Login from './containers/Login';
import App from './App'
export default [{
path: '/',
component: App,
routes: [
{
path: "/",
component: Home,
exact: true,
loadData: Home.loadData,
key: 'home',
},
{
path: '/login',
component: Login,
exact: true,
key: 'login',
}
]
}]
2. App组件
前面我们再Home组件和Login组件中分别引用了Header组件,这里实现共用一个
import React from 'react';
import Header from './components/Header';
const App = (props) => {
console.log(props.route)
return (
<div>
<Header></Header>
</div>
)
}
export default App;
记得将Header组件从其它两个组件中去掉。
3. 修改路由渲染形式
将服务端和客户端路由渲染的形式由原来的
{
Routes.map(route => (
<Route {...Route} />
))
}
改为:
import { renderRoutes } from 'react-router-config';
{renderRoutes(Routes)}
这里用到的renderRoutes方法,就是根据url渲染一层路由的组件(这里渲染的是App组件),然后将下一层的路由通过props传给目前的App组件,依次循环。
4. 小结
这一步比较简单,新建一个App组件包含所有组件。
第六步:CSS服务端渲染
1. 客户端
下载依赖
yarn add -D style-loader css-loader
对应配置
在webpack文件中进行配置
const clientConfig = {
mode: 'development',
entry: './src/client/index.js',
module: {
rules: [{
test: /\.css?$/,
use: ['style-loader', {
loader: 'css-loader',
options: {
modules: true
}
}]
}]
},
引入CSS文件
import styles from './style.css';
- 这个时候打开启动项目,就可以看到页面有样式了。
- 但是打开源码,发现并没有样式代码。
2. 服务端
下载依赖
服务端使用的是isomorphic-style-loader ,对应配置:
module: {
rules: [{
test: /\.css?$/,
use: ['isomorphic-style-loader', {
loader: 'css-loader',
options: {
modules: true
}
}]
}]
}
获取css代码
引入css文件 时,styles 中挂了三个函数。通过styles._getCss 即可获得CSS代码 。react-router-dom 中的StaticRouter 中已经帮我们准备了一个钩子变量context 。CSS代码可以从这里传入。- 在路由配置对象routes中的组件都能在服务端渲染的过程中拿到这个context,而且这个context对于组件来说,就相当于组件中的props.staticContext。
注意:这个props.staticContext只会在服务端渲染的过程中存在,而客户端渲染的时候不会被定义。
//context从外界传入
<StaticRouter location={req.path} context={context}>
<div>
{renderRoutes(routes)}
</div>
</StaticRouter>
我们需要在服务端的render函数执行之前,初始化context变量的值:
let context = { css: [] }
在组件中获取到CSS代码
//context从外界传入
<StaticRouter location={req.path} context={context}>
<div>
{renderRoutes(routes)}
</div>
</StaticRouter>
服务端的renderToString执行完成后,拼接css代码
//拼接代码
const cssStr = context.css.length ? context.css.join('\n') : '';
挂载到页面
//放到返回的html字符串里的header里面
<style>${cssStr}</style>
接下来就可以查看结果
这是依赖版本引起的问题,修改webpack配置,将esModule改为false
options: {
modules: true,
esModule: false
}
3. 小结
- 在CSS引入组件时获取到CSS的代码,放入routes提供的context中。
- 在输出HTML前,将CSS代码进行拼接,然后注入到HTML代码中。
到这里,一个服务端渲染项目的就基本搭建好了。
参考:
《React服务器渲染原理解析与实践》
|