代码仓库地址:
https://gitee.com/wdw-code-cloude/SSR
项目整体目录结构:
├── build ————————————-> 打包后的 server端 js文件
│ └── bundle.js
├── package-lock.json
├── package.json
├── public ————————————> 打包后的 web端 js文件
│ └── index.js
├── src
│ ├── App
│ │ ├── components
│ │ │ └── header.js ————-> header 组件
│ │ └── index.js ———————> 项目 入口文件
│ ├── Routes.js ————————-> 路由配置文件,导出的为 数组
│ ├── WithStyle.js ———————> 服务端 注入css 高阶组件
│ ├── client ——————————-> 前端代码 文件夹
│ │ ├── index.js
│ │ └── request.js ——————-> 前端请求方法
│ ├── container ————————-> client & server共用组件
│ │ ├── Home
│ │ │ ├── index.js
│ │ │ ├── store ———————> home 组件 store
│ │ │ │ └── index.js
│ │ │ └── style.css
│ │ ├── Login
│ │ │ └── index.js
│ │ └── UnFound
│ │ └── index.js
│ ├── server ——————————-> server端配置文件夹
│ │ ├── index.js
│ │ ├── request.js ——————-> server请求方法
│ │ └── utils
│ │ └── index.js ——————-> server 端共用方法
│ └── store
│ └── index.js ————————-> 项目redux仓库
├── webpack.base.js ———————-> webpack 基础配置
├── webpack.client.js ———————> webpack client端配置
└── webpack.server.js ———————> webpack server端配置
实现步骤:
NO.1 构建项目 (使用webpack打包工具)
- 由于SSR项目的原理就是由服务端(中间层Node端)直接返回HTML文件,浏览器直接渲染出页面,省去了CSR首次渲染页面还需要再次请求 js 和 css 的时间;
- 另外,由于服务端没有DOM,页面的交互功能只能在页面首次渲染出来后再次请求js在客户端去操作DOM。
- 综上所述,webpack 需要有三分配置(公共配置,server端配置,client配置)
公共配置(webpack.base.js):
const path = require('path');module.exports = {module: {rules: [{test: /\.js$/,exclude: /node_modules/,use: [{loader: "babel-loader",options: {presets: ["@babel/preset-react",["@babel/preset-env",{targets: {browsers: ["last 2 versions"]}}]]}}]}]}};
client配置(webpack.client.js):
const merge = require("webpack-merge");const path = require("path");const baseConfig = require("./webpack.base.js");const clientOption = {mode: "production",entry: path.resolve(__dirname, "src/client/index.js"),output: {filename: "index.js",path: path.resolve(__dirname, "public")},module: {rules: [{test: /\.less$/,use: [{loader: "style-loader"},{loader: "css-loader",options: {modules: true}},{loader: "less-loader",}]}]},};module.exports = merge(baseConfig, clientOption);
server配置(webpack.server.js):
const merge = require("webpack-merge");const path = require("path");const baseConfig = require('./webpack.base.js');const nodeExternals = require('webpack-node-externals');const serverOption = {mode: "development",//为了不把nodejs内置模块打包进输出文件中,例如: fs net模块等;target: "node",// 为了不把node_modeuls目录下的第三方模块打包进输出文件中externals:[nodeExternals()],entry: path.resolve(__dirname, "src/server/index.js"),output: {filename: "bundle.js",path: path.resolve(__dirname, "build")},module: {rules: [{test: /\.less$/,use: ['isomorphic-style-loader',{loader: 'css-loader',options: {modules: true}},'less-loader'],}]}};module.exports = merge(baseConfig, serverOption );
与client的区别:
- webpack-node-externals作用:
这个插件的原理是利用了webapck中的externals配置项,来剔除node_modules文件的,因为默认webapck会把所有用到的js文件统统打包,而我们由于是在node端,因此不需要把用到的库也打包了。
因为 Node 环境下通过 NPM 已经安装了这些包,直接引用就可以,不需要额外再打包到代码里
webapck中的externals配置项作用:
防止将某些
import的包(package)打包到 bundle 中,而是在运行时(runtime)再去从外部获取这些扩展依赖(external dependencies)。
- target: “node”
告诉webpack 避免将 Node 内置模块进行打包
使用 nodemon 和 npm-run-all :
package.json文件部分配置:
"scripts": {"dev": "npm-run-all --parallel dev:**","dev:build:server": "webpack --config webpack.server.js --watch","dev:build:client": "webpack --config webpack.client.js --watch","dev:start": "nodemon --watch build --exec node \"./build/bundle.js\""},
npm-run-all 作用:
npm-run-all --parallel dev:**并行执行以dev开头的所有的命令
nodemon作用:
帮助node实现文件的监听
"dev:start": "nodemon --watch build --exec node \"./build/bundle.js\""
监听 build 文件夹是否有变化,如有变化 即重新运行 ./build/bundle.js
NO.2 同构
所谓同构,即为同一套代码(各个组件代码)在 服务端运行一次,在客户端运行一次。 服务器和客户端的路由代码是不一样的,服务器端是通过请求路径找到组件,客户端是通过浏览器网址,找到路由组件,是不用的两套机制。
事件绑定问题:
server端 js代码:
import express from 'express';import Home from './container/Home';import { renderToString } from 'react-dom/server';import React from 'react';const app = express();app.use(express.static('public'));const content = renderToString(<Home />);app.get('/', (req, res) => {res.send(`<html><head><title>ssr</title></head><body><div id='root'>${content}</div><script src='/index.js'></script></body></html>`)})app.listen(3333, () => console.log('listen 3333!'))
公共组件 Home 代码:
// src/container/Home/index.jsimport React from 'react';const Home = () => {return (<div><H3>Home组件s</H3><button onClick={() => {alert('home组件')}}>点击</button></div>)}export default Home
client端代码:
// src/client/index.jsimport React from 'react';import ReactDom from 'react-dom';import Home from '../container/Home';ReactDom.render(<Home />, document.getElementById('root'));
不出意外,点击页面上的 按钮 是没有点击事件的,原因是 服务端的js代码是通过 react-dom/server 组件中的
renderToString 渲染出来的字符串,是不会进行事件绑定。
解决方法: 就是用 client端的代码在 客户端去操作DOM, 具体实现是 将 client端打包的 js 放置在 服务端监听的公共资源文件夹里面( 具体实现 查看webpack.client.js配置 ),当 客户端首次渲染后,再去服务端拉去 client 打包的js,进行事件添加等操作。
// 服务器发现请求的是静态文件,就到根路径下的public路径中去找。app.use(express.static("public"));...app.get('/', (req, res) => {res.send(`<html><head><title>ssr</title></head><body><div id='root'>${content}</div><script src='/index.js'></script></body></html>`)})...
坑位:
🌰1:
react-dom.development.js:517 Warning: render(): Target node has markup rendered by React, but there are unrelated nodes as well. This is most commonly caused by white-space inserted around server-rendered markup.
- 这是由于同构引起的,可以将**
ReactDom.render换成ReactDom.hydrate**来解决
🌰2:
react-dom.development.js:523 Warning: Did not expect server HTML to contain the text node “ “ in
.
// 将server端的js 按如下改动,即 ${content} 和div之间最好不要留空格app.get('/', (req, res) => {res.send(`<html><head><title>ssr</title></head><body><div id='root'>${content}</div><script src='/index.js'></script></body></html>`)})
路由:
client端路由:
const App = () => {return (<Provider store={store}><BrowserRouter><div><Route path='/' component={Home}></div></BrowserRouter></Provider>)}ReactDom.hydrate(<App/>, document.getElementById('root'))
server端路由:
const App = () => {return<Provider store={store}><StaticRouter location={req.path} context={context}><div><Route path='/' component={Home}></div></StaticRouter></Provider>}Return ReactDom.renderToString(<App/>)
差异:
- 服务端路由使用的是 React-Router 针对服务器端渲染专门提供的一个路由组件 (StaticRouter)
把 location(当前请求路径)传递给 StaticRouter 组件,这样 StaticRouter 才能根据路径分析出当前所需要的组件是哪个
- 通过 BrowserRouter 我们能够匹配到浏览器即将显示的路由组件,对浏览器来说,我们需要把组件转化成 DOM,所以需要我们使用 ReactDom.render 方法来进行 DOM 的挂载。而 StaticRouter 能够在服务器端匹配到将要显示的组件,对服务器端来说,我们要把组件转化成字符串,这时我们只需要调用 ReactDom 提供的 renderToString 方法,就可以得到 App 组件对应的 HTML 字符串。
引入Redux:
客户端渲染中,异步数据结合 Redux 的使用方式遵循下面的流程:
- 创建 Store
- 根据路由显示组件
- 派发 Action 获取数据
- 更新 Store 中的数据
- 组件 Rerender
服务器端,页面一旦确定内容,就没有办法 Rerender 了,这就要求组件显示的时候,就要把 Store 的数据都准备好,所以服务器端异步数据结合 Redux 的使用方式,流程是下面的样子:
- 创建 Store
- 根据路由分析 Store 中需要的数据
- 派发 Action 获取数据
- 更新Store 中的数据
- 结合数据和组件生成 HTML,一次性返回
创建store:
- 创建 store 的坑:
const store = createStore(reducer, defaultState)export default store;
如果在客户端时,以上代码是没问题的,因为用户的浏览器中永远只存在一个Store; 但是在服务器端,如果这样创造store ,store就会变为一个单例( 即一个全局对象 ),所有用户共享store,这样显然是不行的
const getStore = (req) => {return createStore(reducer, defaultState);}export default getStore;
函数重新执行,为每个用户提供一个独立的 Store。
路由改造:
改造路由一方面是 提升开发效率 但主要是为了 服务端能 根据请求path 去获取数据,并将数据同步到 客户端。
在服务端,需要分析当前路由要加载的所有组件,可以通过 react-router-config 组件。 其中的 renderRoutes 就是根据url渲染一层路由的组件; 通过matchRoutes(routes, req.path) 可以 返回匹配路由的数组,
/src/Routes.js...const routes = [{path: "/",component: App,routes: [{path: "/",exact: true,component: Home,loadData: Home.loadData, // 服务端获取异步数据的函数key: "home"},{path: "/login",exact: true,component: Login,// loadData: Login.loadData, // 服务端获取异步数据的函数key: "login"}]}];...
client端 :
/ src/client/index.js...import { renderRoutes } from 'react-router-config';......const APP = () => {return (<Provider store={store}><BrowserRouter><div>{renderRoutes(routes)}</div></BrowserRouter></Provider>)}...
server端 :
/ src/server/utils/index.js...import { renderRoutes } from 'react-router-config';......const content = renderToString(<Provider store={store}><StaticRouter location={req.path} context={context}><div>{renderRoutes(routes)}</div></StaticRouter></Provider>);...
- 路由改造完成后,需要改造 公共组件:
// src/container/Home/index.js// 这样写 可以在matchRoutes 返回的数组中的每一项获取到此方法,以达到可以 通过路由判断是否请求异步数据Home.loadData = (store) => {return store.dispatch(getHomeList())}
这个方法需要你把服务器端渲染的 Store 传递进来,它的作用就是帮助服务器端的 Store 获取到这个组件所需的数据。 所以,组件上有了这样的方法,同时我们也有当前路由所需要的所有组件,依次调用各个组件上的 loadData 方法,就能够获取到路由所需的所有数据内容了。
- 在生成 HTML 之前,需要保证所有的数据都获取完毕
// matchedRoutes 是当前路由对应的所有需要显示的组件集合matchedRoutes.forEach(item => {if (item.route.loadData) {const promise = new Promise((resolve, reject) => {item.route.loadData(store).then(resolve).catch(resolve);})promises.push(promise);}})Promise.all(promises).then(() => {// 生成 HTML 逻辑})
服务器端渲染时,页面的数据是通过 loadData 函数来获取的。而在客户端,数据获取依然要做,因为如果这个页面是你访问的第一个页面,那么你看到的内容是服务器端渲染出来的,但是如果经过 react-router 路由跳转道第二个页面,那么这个页面就完全是客户端渲染出来的了,所以客户端也要去拿数据。
在客户端获取数据,使用的是我们最习惯的方式,通过 componentDidMount 进行数据的获取。这里要注意的是,componentDidMount 只在客户端才会执行,在服务器端这个生命周期函数是不会执行的。所以我们不必担心 componentDidMount 和 loadData 会有冲突,放心使用即可。这也是为什么数据的获取应该放到 componentDidMount 这个生命周期函数中而不是 componentWillMount 中的原因,可以避免服务器端获取数据和客户端获取数据的冲突
componentDidMount() {// 如果 list 有数据则说明服务端已经请求过了,客户端不需要请求了if(!this.props.list.length) {this.props.getOfHomeList()}}}
- 数据的注水和脱水
如果我们将上面 componentDidMount 中异步请求函数注释掉,页面中不会有任何数据,但是打开网页源代码,却会发现是由数据的。
这是因为 服务端返回的HTML中是有数据的,但是在客户端又执行一次js代码,创建的store是没有数据的,此时就会存在 服务端 store数据和 客户端不统一的问题。
解决办法:
**在服务端获取获取之后,在返回的html代码中加入这样一个script标签:
<script>window.context = {state: ${JSON.stringify(store.getState())}}</script>
这叫做数据的“注水”操作,即把服务端的store数据注入到window全局环境中。 接下来是“脱水”处理,换句话说也就是把window上绑定的数据给到客户端的store,可以在客户端store产生的源头进行,即在全局的store/index.js中进行。
//store/index.jsimport {createStore, applyMiddleware, combineReducers} from 'redux';import thunk from 'redux-thunk';import { reducer as homeReducer } from '../containers/Home/store';const reducer = combineReducers({home: homeReducer})//服务端的store创建函数export const getStore = () => {return createStore(reducer, applyMiddleware(thunk));}//客户端的store创建函数export const getClientStore = () => {const defaultState = window.context ? window.context.state : {};return createStore(reducer, defaultState, applyMiddleware(thunk));}
**至此,数据的脱水和注水操作完成!
处理CSS:
服务端是没有 DOM 的,因此解析后的css不能用 style-loader 挂载到DOM上的,需要用 isomorphic-style-loader 将解析后的css 转化为 字符串形式添加到 服务端返回的 HTML 中,然后在 客户端渲染。
当我们的 React 代码中引入了一些 CSS 样式代码时,服务器端打包的过程会处理一遍 CSS,而客户端也会处理一遍。查看配置,我们可以看到,服务器端打包时我们用了 isomorphic-style-loader,它处理 CSS 的时候,只在对应的 DOM 元素上生成 class 类名,然后返回生成的 CSS 样式代码。
而在客户端代码打包配置中,我们使用了 css-loader 和 style-loader,css-loader 不但会在 DOM 上生成 class 类名,解析好的 CSS 代码,还会通过 style-loader 把代码挂载到页面上。不过这么做,由于页面上的样式实际上最终是由客户端渲染时添加上的,所以页面可能会存在一开始没有样式的情况,为了解决这个问题, 我们可以在服务器端渲染时,拿到 isomorphic-style-loader 返回的样式代码,然后以字符串的形式添加到服务器端渲染的 HTML 之中
服务端webpack配置:
...{test: /\.less$/,use: ['isomorphic-style-loader',{loader: 'css-loader',options: {modules: true}},'less-loader'],}...
使用 isomorphic-style-loader 代替 style-loader,并且组件中引入 css 文件方式为
import styles from './style.less';
引入 css 文件,isomorphic-style-loader 会在 styles 中挂载三个函数:
{_getContent: [Function],_getCss: [Function],_insertCss: [Function]}
然后在服务端还可以利用 react-router-dom 中的 StaticRouter 中的钩子变量 context 重外接传入css
<StaticRouter location={req.path} context={context}><div>{renderRoutes(routes)}</div></StaticRouter>
这就意味着在路由配置对象routes中的组件都能在服务端渲染的过程中拿到这个context,而且这个context对于组件来说,就相当于组件中的props.staticContext。并且,这个props.staticContext只会在服务端渲染的过程中存在,而客户端渲染的时候不会被定义。这就让我们能够通过这个变量来区分两种渲染环境。
现在,我们需要在服务端的render函数执行之前,初始化context变量的值:
let context = { css: [] }
我们只需要在组件的componentWillMount生命周期中编写相应的逻辑即可:
componentWillMount() {//判断是否为服务端渲染环境if (this.props.staticContext) {this.props.staticContext.css.push(styles._getCss())}}
服务端的renderToString执行完成后,context的CSS现在已经是一个有内容的数组,让我们来获取其中的CSS代码:
//拼接代码const cssStr = context.css.length ? context.css.join('\n') : '';
最后挂载到页面:
//放到返回的html字符串里的header里面<style>${cssStr}</style>
