代码仓库地址:

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):

  1. const path = require('path');
  2. module.exports = {
  3. module: {
  4. rules: [
  5. {
  6. test: /\.js$/,
  7. exclude: /node_modules/,
  8. use: [
  9. {
  10. loader: "babel-loader",
  11. options: {
  12. presets: [
  13. "@babel/preset-react",
  14. [
  15. "@babel/preset-env",
  16. {
  17. targets: {
  18. browsers: ["last 2 versions"]
  19. }
  20. }
  21. ]
  22. ]
  23. }
  24. }
  25. ]
  26. }
  27. ]
  28. }
  29. };

查看babel总结 —->

client配置(webpack.client.js):

  1. const merge = require("webpack-merge");
  2. const path = require("path");
  3. const baseConfig = require("./webpack.base.js");
  4. const clientOption = {
  5. mode: "production",
  6. entry: path.resolve(__dirname, "src/client/index.js"),
  7. output: {
  8. filename: "index.js",
  9. path: path.resolve(__dirname, "public")
  10. },
  11. module: {
  12. rules: [
  13. {
  14. test: /\.less$/,
  15. use: [
  16. {
  17. loader: "style-loader"
  18. },
  19. {
  20. loader: "css-loader",
  21. options: {
  22. modules: true
  23. }
  24. },
  25. {
  26. loader: "less-loader",
  27. }
  28. ]
  29. }
  30. ]
  31. },
  32. };
  33. module.exports = merge(baseConfig, clientOption);

server配置(webpack.server.js):

  1. const merge = require("webpack-merge");
  2. const path = require("path");
  3. const baseConfig = require('./webpack.base.js');
  4. const nodeExternals = require('webpack-node-externals');
  5. const serverOption = {
  6. mode: "development",
  7. //为了不把nodejs内置模块打包进输出文件中,例如: fs net模块等;
  8. target: "node",
  9. // 为了不把node_modeuls目录下的第三方模块打包进输出文件中
  10. externals:[nodeExternals()],
  11. entry: path.resolve(__dirname, "src/server/index.js"),
  12. output: {
  13. filename: "bundle.js",
  14. path: path.resolve(__dirname, "build")
  15. },
  16. module: {
  17. rules: [
  18. {
  19. test: /\.less$/,
  20. use: [
  21. 'isomorphic-style-loader',
  22. {
  23. loader: 'css-loader',
  24. options: {
  25. modules: true
  26. }
  27. },
  28. 'less-loader'
  29. ],
  30. }
  31. ]
  32. }
  33. };
  34. 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文件部分配置:

  1. "scripts": {
  2. "dev": "npm-run-all --parallel dev:**",
  3. "dev:build:server": "webpack --config webpack.server.js --watch",
  4. "dev:build:client": "webpack --config webpack.client.js --watch",
  5. "dev:start": "nodemon --watch build --exec node \"./build/bundle.js\""
  6. },

npm-run-all 作用:

npm-run-all --parallel dev:**并行执行以dev开头的所有的命令

nodemon作用:

帮助node实现文件的监听


  1. "dev:start": "nodemon --watch build --exec node \"./build/bundle.js\""

监听 build 文件夹是否有变化,如有变化 即重新运行 ./build/bundle.js

详细请参考—->


NO.2 同构

所谓同构,即为同一套代码(各个组件代码)在 服务端运行一次,在客户端运行一次。 服务器和客户端的路由代码是不一样的,服务器端是通过请求路径找到组件,客户端是通过浏览器网址,找到路由组件,是不用的两套机制。

事件绑定问题:

server端 js代码:

  1. import express from 'express';
  2. import Home from './container/Home';
  3. import { renderToString } from 'react-dom/server';
  4. import React from 'react';
  5. const app = express();
  6. app.use(express.static('public'));
  7. const content = renderToString(<Home />);
  8. app.get('/', (req, res) => {
  9. res.send(
  10. `<html>
  11. <head>
  12. <title>ssr</title>
  13. </head>
  14. <body>
  15. <div id='root'>
  16. ${content}
  17. </div>
  18. <script src='/index.js'></script>
  19. </body>
  20. </html>`
  21. )
  22. })
  23. app.listen(3333, () => console.log('listen 3333!'))

公共组件 Home 代码:

  1. // src/container/Home/index.js
  2. import React from 'react';
  3. const Home = () => {
  4. return (
  5. <div>
  6. <H3>Home组件s</H3>
  7. <button onClick={() => {alert('home组件')}}>点击</button>
  8. </div>
  9. )
  10. }
  11. export default Home

client端代码:

  1. // src/client/index.js
  2. import React from 'react';
  3. import ReactDom from 'react-dom';
  4. import Home from '../container/Home';
  5. ReactDom.render(<Home />, document.getElementById('root'));

不出意外,点击页面上的 按钮 是没有点击事件的,原因是 服务端的js代码是通过 react-dom/server 组件中的
renderToString 渲染出来的字符串,是不会进行事件绑定。

解决方法: 就是用 client端的代码在 客户端去操作DOM, 具体实现是 将 client端打包的 js 放置在 服务端监听的公共资源文件夹里面( 具体实现 查看webpack.client.js配置 ),当 客户端首次渲染后,再去服务端拉去 client 打包的js,进行事件添加等操作。

  1. // 服务器发现请求的是静态文件,就到根路径下的public路径中去找。
  2. app.use(express.static("public"));
  3. ...
  4. app.get('/', (req, res) => {
  5. res.send(
  6. `<html>
  7. <head>
  8. <title>ssr</title>
  9. </head>
  10. <body>
  11. <div id='root'>
  12. ${content}
  13. </div>
  14. <script src='/index.js'></script>
  15. </body>
  16. </html>`
  17. )
  18. })
  19. ...

坑位:

🌰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

.

  1. // 将server端的js 按如下改动,即 ${content} 和div之间最好不要留空格
  2. app.get('/', (req, res) => {
  3. res.send(
  4. `<html>
  5. <head>
  6. <title>ssr</title>
  7. </head>
  8. <body>
  9. <div id='root'>${content}</div>
  10. <script src='/index.js'></script>
  11. </body>
  12. </html>`
  13. )
  14. })

路由:

client端路由:

  1. const App = () => {
  2. return (
  3. <Provider store={store}>
  4. <BrowserRouter>
  5. <div>
  6. <Route path='/' component={Home}>
  7. </div>
  8. </BrowserRouter>
  9. </Provider>
  10. )
  11. }
  12. ReactDom.hydrate(<App/>, document.getElementById('root'))

server端路由:

  1. const App = () => {
  2. return
  3. <Provider store={store}>
  4. <StaticRouter location={req.path} context={context}>
  5. <div>
  6. <Route path='/' component={Home}>
  7. </div>
  8. </StaticRouter>
  9. </Provider>
  10. }
  11. Return ReactDom.renderToString(<App/>)

差异:

  • 服务端路由使用的是 React-Router 针对服务器端渲染专门提供的一个路由组件 (StaticRouter)

    把 location(当前请求路径)传递给 StaticRouter 组件,这样 StaticRouter 才能根据路径分析出当前所需要的组件是哪个

  • 通过 BrowserRouter 我们能够匹配到浏览器即将显示的路由组件,对浏览器来说,我们需要把组件转化成 DOM,所以需要我们使用 ReactDom.render 方法来进行 DOM 的挂载。而 StaticRouter 能够在服务器端匹配到将要显示的组件,对服务器端来说,我们要把组件转化成字符串,这时我们只需要调用 ReactDom 提供的 renderToString 方法,就可以得到 App 组件对应的 HTML 字符串。

引入Redux:

客户端渲染中,异步数据结合 Redux 的使用方式遵循下面的流程:

  1. 创建 Store
  2. 根据路由显示组件
  3. 派发 Action 获取数据
  4. 更新 Store 中的数据
  5. 组件 Rerender

服务器端,页面一旦确定内容,就没有办法 Rerender 了,这就要求组件显示的时候,就要把 Store 的数据都准备好,所以服务器端异步数据结合 Redux 的使用方式,流程是下面的样子:

  1. 创建 Store
  2. 根据路由分析 Store 中需要的数据
  3. 派发 Action 获取数据
  4. 更新Store 中的数据
  5. 结合数据和组件生成 HTML,一次性返回

创建store:

  1. 创建 store 的坑:
  1. const store = createStore(reducer, defaultState)
  2. export default store;

如果在客户端时,以上代码是没问题的,因为用户的浏览器中永远只存在一个Store; 但是在服务器端,如果这样创造store ,store就会变为一个单例( 即一个全局对象 ),所有用户共享store,这样显然是不行的

  1. const getStore = (req) => {
  2. return createStore(reducer, defaultState);
  3. }
  4. export default getStore;

函数重新执行,为每个用户提供一个独立的 Store。

路由改造:

改造路由一方面是 提升开发效率 但主要是为了 服务端能 根据请求path 去获取数据,并将数据同步到 客户端。

在服务端,需要分析当前路由要加载的所有组件,可以通过 react-router-config 组件。 其中的 renderRoutes 就是根据url渲染一层路由的组件; 通过matchRoutes(routes, req.path) 可以 返回匹配路由的数组,

  1. /src/Routes.js
  2. ...
  3. const routes = [
  4. {
  5. path: "/",
  6. component: App,
  7. routes: [
  8. {
  9. path: "/",
  10. exact: true,
  11. component: Home,
  12. loadData: Home.loadData, // 服务端获取异步数据的函数
  13. key: "home"
  14. },
  15. {
  16. path: "/login",
  17. exact: true,
  18. component: Login,
  19. // loadData: Login.loadData, // 服务端获取异步数据的函数
  20. key: "login"
  21. }
  22. ]
  23. }
  24. ];
  25. ...

client端 :

  1. / src/client/index.js
  2. ...
  3. import { renderRoutes } from 'react-router-config';
  4. ...
  5. ...
  6. const APP = () => {
  7. return (
  8. <Provider store={store}>
  9. <BrowserRouter>
  10. <div>
  11. {renderRoutes(routes)}
  12. </div>
  13. </BrowserRouter>
  14. </Provider>
  15. )
  16. }
  17. ...

server端 :

  1. / src/server/utils/index.js
  2. ...
  3. import { renderRoutes } from 'react-router-config';
  4. ...
  5. ...
  6. const content = renderToString(
  7. <Provider store={store}>
  8. <StaticRouter location={req.path} context={context}>
  9. <div>{renderRoutes(routes)}</div>
  10. </StaticRouter>
  11. </Provider>
  12. );
  13. ...
  • 路由改造完成后,需要改造 公共组件:
  1. // src/container/Home/index.js
  2. // 这样写 可以在matchRoutes 返回的数组中的每一项获取到此方法,以达到可以 通过路由判断是否请求异步数据
  3. Home.loadData = (store) => {
  4. return store.dispatch(getHomeList())
  5. }

这个方法需要你把服务器端渲染的 Store 传递进来,它的作用就是帮助服务器端的 Store 获取到这个组件所需的数据。 所以,组件上有了这样的方法,同时我们也有当前路由所需要的所有组件,依次调用各个组件上的 loadData 方法,就能够获取到路由所需的所有数据内容了。

  • 在生成 HTML 之前,需要保证所有的数据都获取完毕
  1. // matchedRoutes 是当前路由对应的所有需要显示的组件集合
  2. matchedRoutes.forEach(item => {
  3. if (item.route.loadData) {
  4. const promise = new Promise((resolve, reject) => {
  5. item.route.loadData(store).then(resolve).catch(resolve);
  6. })
  7. promises.push(promise);
  8. }
  9. })
  10. Promise.all(promises).then(() => {
  11. // 生成 HTML 逻辑
  12. })

服务器端渲染时,页面的数据是通过 loadData 函数来获取的。而在客户端,数据获取依然要做,因为如果这个页面是你访问的第一个页面,那么你看到的内容是服务器端渲染出来的,但是如果经过 react-router 路由跳转道第二个页面,那么这个页面就完全是客户端渲染出来的了,所以客户端也要去拿数据。

在客户端获取数据,使用的是我们最习惯的方式,通过 componentDidMount 进行数据的获取。这里要注意的是,componentDidMount 只在客户端才会执行,在服务器端这个生命周期函数是不会执行的。所以我们不必担心 componentDidMount 和 loadData 会有冲突,放心使用即可。这也是为什么数据的获取应该放到 componentDidMount 这个生命周期函数中而不是 componentWillMount 中的原因,可以避免服务器端获取数据和客户端获取数据的冲突

  1. componentDidMount() {
  2. // 如果 list 有数据则说明服务端已经请求过了,客户端不需要请求了
  3. if(!this.props.list.length) {
  4. this.props.getOfHomeList()
  5. }
  6. }
  7. }
  • 数据的注水和脱水

如果我们将上面 componentDidMount 中异步请求函数注释掉,页面中不会有任何数据,但是打开网页源代码,却会发现是由数据的。
这是因为 服务端返回的HTML中是有数据的,但是在客户端又执行一次js代码,创建的store是没有数据的,此时就会存在 服务端 store数据和 客户端不统一的问题。

解决办法:
**在服务端获取获取之后,在返回的html代码中加入这样一个script标签:

  1. <script>
  2. window.context = {
  3. state: ${JSON.stringify(store.getState())}
  4. }
  5. </script>

这叫做数据的“注水”操作,即把服务端的store数据注入到window全局环境中。 接下来是“脱水”处理,换句话说也就是把window上绑定的数据给到客户端的store,可以在客户端store产生的源头进行,即在全局的store/index.js中进行。

  1. //store/index.js
  2. import {createStore, applyMiddleware, combineReducers} from 'redux';
  3. import thunk from 'redux-thunk';
  4. import { reducer as homeReducer } from '../containers/Home/store';
  5. const reducer = combineReducers({
  6. home: homeReducer
  7. })
  8. //服务端的store创建函数
  9. export const getStore = () => {
  10. return createStore(reducer, applyMiddleware(thunk));
  11. }
  12. //客户端的store创建函数
  13. export const getClientStore = () => {
  14. const defaultState = window.context ? window.context.state : {};
  15. return createStore(reducer, defaultState, applyMiddleware(thunk));
  16. }

**至此,数据的脱水和注水操作完成!

处理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配置:

  1. ...
  2. {
  3. test: /\.less$/,
  4. use: [
  5. 'isomorphic-style-loader',
  6. {
  7. loader: 'css-loader',
  8. options: {
  9. modules: true
  10. }
  11. },
  12. 'less-loader'
  13. ],
  14. }
  15. ...

使用 isomorphic-style-loader 代替 style-loader,并且组件中引入 css 文件方式为

  1. import styles from './style.less';

引入 css 文件,isomorphic-style-loader 会在 styles 中挂载三个函数:

  1. {
  2. _getContent: [Function],
  3. _getCss: [Function],
  4. _insertCss: [Function]
  5. }

然后在服务端还可以利用 react-router-dom 中的 StaticRouter 中的钩子变量 context 重外接传入css

  1. <StaticRouter location={req.path} context={context}>
  2. <div>
  3. {renderRoutes(routes)}
  4. </div>
  5. </StaticRouter>

这就意味着在路由配置对象routes中的组件都能在服务端渲染的过程中拿到这个context,而且这个context对于组件来说,就相当于组件中的props.staticContext。并且,这个props.staticContext只会在服务端渲染的过程中存在,而客户端渲染的时候不会被定义。这就让我们能够通过这个变量来区分两种渲染环境。

现在,我们需要在服务端的render函数执行之前,初始化context变量的值:

  1. let context = { css: [] }

我们只需要在组件的componentWillMount生命周期中编写相应的逻辑即可:

  1. componentWillMount() {
  2. //判断是否为服务端渲染环境
  3. if (this.props.staticContext) {
  4. this.props.staticContext.css.push(styles._getCss())
  5. }
  6. }

服务端的renderToString执行完成后,context的CSS现在已经是一个有内容的数组,让我们来获取其中的CSS代码:

  1. //拼接代码
  2. const cssStr = context.css.length ? context.css.join('\n') : '';

最后挂载到页面:

  1. //放到返回的html字符串里的header里面
  2. <style>${cssStr}</style>