阅读 1626

React服务端渲染实践|手动实现

关于服务端渲染也就是我们说的SSR大多数人都听过这个概念,很多同学或许在公司中已经做过服务端渲染的项目了,主流的单页面应用比如说Vue或者React开发的项目采用的一般都是客户端渲染的模式也就是我们说的CSR。

但是这种模式会带来明显的两个问题,第一个就是TTFP时间比较长,TTFP指的就是首屏展示时间,同时不具备SEO排名的条件,搜索引擎上排名不是很好。所以我们可以借助一些工具来进行改良我们的项目,将单页面应用编程服务器端渲染项目,这样就可以解决掉这些问题了。

目前主流的服务器端渲染框架也就是SSR框架有针对于Vue的Nuxt.js和针对React的Next.js这两个。这里我们并不使用这些SSR框架,而是从零开始完整搭建一套SSR框架,来熟悉他的底层原理。

服务器端编写 React 组件

如果是客户端渲染,浏览器首先会向浏览器发送请求,服务器返回页面的html文件,然后html中再向服务器发送请求,服务器返回js文件,js文件在浏览器中执行绘制出页面结构渲染到浏览器完成页面渲染。

如果是服务器端渲染这个流程就不同了,浏览器发送请求,服务器端运行React代码生成页面,然后服务器将生成好的页面返回给浏览器,浏览器进行渲染。这种情况下React代码就是服务器的一部分而不是前端部分了。

这里我们进行代码的演示,首选需要npm init初始化项目,然后安装react,express,webpack,webpack-cli,webpack-node-externals。

我们首先编写一个React的组件。 .src/components/Home/index.js, 因为我们这个js是在node环境执行的所以我们要遵循CommonJS规范,使用require和module.exports进行导入导出。

const React = require('react');

const Home = () => {
    return <div>home</div>
}

module.exports = {
    default: Home
};
复制代码

我们这里开发的Home组件是不能直接在node中运行的,需要借助webpack工具将jsx语法打包编译成js语法,让nodejs可以争取的识别,我们需要创建一个webpack.server.js文件。

在服务器端使用webpack需要添加一个target为node的键值对。我们知道在服务器端如果使用path路径是不需要打包到js中的,如果在浏览器端使用了path是需要打包到js中的,所以在服务器端和在浏览器端需要编译出来的js是完全不同的。所以我们在打包的时候要告诉webpack打包的是服务器端的代码还是浏览器端的代码。

entry入口文件就是我们node的启动文件,这里我们写成./src/index.js,输出的output文件名称为bundle,目录在跟目录的build文件夹中。

const Path = require('path');
const NodeExternals = require('webpack-node-externals'); // 服务端运行webpack需要运行NodeExternals, 他的作用是将express这类node模块不被打包到js里。

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']
                        }
                    }]]
                }
            }
        ]
    }
}
复制代码

安装依赖模块

npm install babel-loader babel-core babel-preset-react babel-preset-stage-0 babel-preset-env --save
复制代码

接着我们这里基于express模块来编写一个简单的服务。./src/server/index.js

var express = require('express');
var app = express();
const Home = require('../Components/Home');
app.get('*', function(req, res) {
    res.send(`<h1>hello</h1>`);
})

var server = app.listen(3000);
复制代码

运行webpack使用webpack.server.js配置文件来执行。

webpack --config webpack.server.js
复制代码

打包之后在我们的目录下会出现一个bundle.js,这个js就是我们打包生成的最终可以运行的代码。我们可以使用node运行这个文件, 就启动了一个3000端口的服务器。我们访问127.0.0.1:3000可以访问这个服务,看到浏览器输出Hello。

node ./build/bundile.js
复制代码

上面的代码我们运行前会使用webpack进行编译,所以也就支持了ES Modules规范,不再强制使用CommonJS了。

src/components/Home/index.js

import React from 'react';

const Home = () => {
    return <div>home</div>
}

export default Home;
复制代码

/src/server/index.js中我们可以使用Home组件,这里我们首先需要安装react-dom,借助renderToString将Home组件转换为标签字符串,当然这里需要依赖React所以我们需要引入React。

import express from 'express';
import Home from '../Components/Home';
import React from 'react';
import { renderToString } from 'react-dom/server';

const app = express();
const content = renderToString(<Home />);
app.get('*', function(req, res) {
    res.send(`
        <html>
            <body>${content}</body>
        </html>
    `);
})

var server = app.listen(3000);
复制代码
# 重新打包
webpack --config webpack.server.js
# 运行服务
node ./build/bundile.js
复制代码

这时候页面就显示出了我们React组件的代码。

React的服务端渲染是建立在虚拟DOM上的服务器端渲染,而且服务端渲染会让页面的首屏渲染速度大大加快。不过服务端渲染也有弊端,客户端渲染React代码在浏览器端执行,他消耗的是用户浏览器端的性能,但是服务器端渲染消耗的是服务器端的性能,因为React代码在服务器上运行。极大的消耗了服务器的性能,因为React代码是很消耗计算性能的。

如果你的项目完全没有必要使用SEO优化并且你的项目访问速度已经很快了的情况下,建议还是不要使用SSR的技术了,因为他的成本开销还是比较大的。

上面我们的代码每次修改之后都需要重新执行webpack打包和启动服务器,这样调试起来太过麻烦,为了解决这个问题我们需要做一下webpack的自动打包和node的重启。我们在package.json中加入build命令,并且通过--watch监听文件变化进行自动打包。

{
    ...
    "scripts": {
        "build": "webpack --config webpack.server.js --watch"
    }
    ...
}
复制代码

只是重新打包还不够,我们还需要重启node服务器,这里我们需要借助nodemon模块,这里我们使用全局安装nodemon, 在package.json文件中添加一个start命令来启动我们的node服务器。使用nodemon监听build文件并且发生改变之后重新exec运行"node ./build/bundile.js", 这里需要保留双引号,转译一下就好了。

{
    ...
    "scripts": {
        "start": "nodemon --watch build --exec node \"./build/bundile.js\"",
        "build": "webpack --config webpack.server.js --watch"
    }
    ...
}
复制代码

这时我们启动服务器,这里需要在两个窗口运行下面的命令,因为build后不允许再输入其他命令了。

npm run build
npm run start
复制代码

这个时候我们修改代码之后页面就会自动更新了。

但是上面的流程还是有些麻烦,我们需要两个窗口来执行命令,我们想要一个窗口将两个命令执行完毕,我们需要借助一个第三方模块npm-run-all,可以全局安装这个模块。然后再package.json中来修改一下。

我们在打包和调试应该是在开发环境,我们创建一个dev命令, 里面执行npm-run-all, --parallel表示并行执行, 执行dev:开头的所有命令。我们将start和build前面追加一个dev:,这个时候我想启动服务器同时监听文件改变运行npm run dev就可以了。

{
    ...
    "scripts": {
        "dev": "npm-run-all --parallel dev:**",
        "dev:start": "nodemon --watch build --exec node \"./build/bundile.js\"",
        "dev:build": "webpack --config webpack.server.js --watch"
    }
    ...
}
复制代码

什么叫做同构

比如下面的代码,我们给div绑定一个click事件,希望点击的时候可以弹出click提示。但是运行之后我们会发现这个事件并没有被绑定上,因为服务器端没办法绑定事件。

src/components/Home/index.js

import React from 'react';

const Home = () => {
    return <div onClick={() => { alert('click'); }}>home</div>
}

export default Home;
复制代码

一般我们的做法是先将页面渲染出来,然后将相同的代码在浏览器端像传统的React项目一样再去运行一遍,这样的话这个点击事件就有了。

这就衍生出一个同构的概念,我的理解是一套React代码在服务器端执行一次,在客户端再执行一次。

同构就可以解决点击事件无效的问题,首先服务器端执行一次能够正常的展示页面,客户端再执行一次就可以绑定上事件。

我们可以在页面渲染的时候加载一个index.js, 使用app.use创建静态文件的访问路径, 这样访问的index.js就会请求到/public/index.js文件中。


app.use(express.static('public'));

app.get('/', function(req, res) {
    res.send(`
        <html>
            <body>
                <div id="root">${content}</div>
                <script src="/index.js"></script>
            </body>
        </html>
    `);
})
复制代码

public/index.js

console.log('public');
复制代码

基于这种情况我们就可以将React代码在浏览器中执行一次,我们这里新建一个/src/client/index.js。将客户端执行的代码帖进去。这里我们同构代码使用hydrate代替render。

import React from 'react';
import ReactDOM from 'react-dom';

import Home from '../Components/Home';

ReactDOM.hydrate(<Home />, document.getElementById('root'));
复制代码

然后我们还需要在根目录创建一个webpack.client.js文件。入口文件为./src/client/index.js,出口文件到public/index.js

const Path = require('path');

module.exports = {
    mode: 'development',
    entry: './src/client/index.js',
    output: {
        filename: 'index.js',
        path: Path.resolve(__dirname, 'public')
    },
    module: {
        rules: [
            {
                test: /.js?$/,
                loader: 'babel-loader',
                exclude: /node_modules/,
                options: {
                    presets: ['react', 'stage-0', ['env', {
                        targets: {
                            browsers: ['last 2 versions']
                        }
                    }]]
                }
            }
        ]
    }
}
复制代码

package.json文件中添加一条打包client目录的命令

{
    ...
    "scripts": {
        "dev": "npm-run-all --parallel dev:**",
        "dev:start": "nodemon --watch build --exec node \"./build/bundile.js\"",
        "dev:build": "webpack --config webpack.server.js --watch",
        "dev:build": "webpack --config webpack.client.js --watch",
    }
    ...
}
复制代码

这样我们启动的时候会编译client运行的文件。再去访问页面的时候就可以绑定好事件了。

下面我们对上面工程的代码进行整理,上面webpack.server.js和webpack.client.js文件有很多重复的地方,我们可以使用webpack-merge插件对内容进行合并。

webpack.base.js

module.exports = {
    module: {
        rules: [
            {
                test: /.js?$/,
                loader: 'babel-loader',
                exclude: /node_modules/,
                options: {
                    presets: ['react', 'stage-0', ['env', {
                        targets: {
                            browsers: ['last 2 versions']
                        }
                    }]]
                }
            }
        ]
    }
}
复制代码

webpack.server.js

const Path = require('path');
const NodeExternals = require('webpack-node-externals'); // 服务端运行webpack需要运行NodeExternals, 他的作用是将express这类node模块不被打包到js里。

const merge = require('webpack-merge');
const config = require('./webpack.base.js');

const serverConfig = {
    target: 'node',
    mode: 'development',
    entry: './src/server/index.js',
    output: {
        filename: 'bundle.js',
        path: Path.resolve(__dirname, 'build')
    },
    externals: [NodeExternals()],
}

module.exports = merge(config, serverConfig);
复制代码

webpack.client.js

const Path = require('path');
const merge = require('webpack-merge');
const config = require('./webpack.base.js');

const clientConfig = {
    mode: 'development',
    entry: './src/client/index.js',
    output: {
        filename: 'index.js',
        path: Path.resolve(__dirname, 'public')
    }
};

module.exports = merge(config, clientConfig);
复制代码

src/server中放置的是服务端运行的代码,src/client放置的是浏览器端运行的js。

服务器端渲染中的路由

首先浏览器向服务器发送请求,服务器返回一个空的html,浏览器再请求js,加载到js后会执行react代码,react代码接管页面执行流程,这个时候可以根据浏览器的地址展示页面内容。

当我们做重构的时候我们需要让路由代码在浏览器和服务端分别执行一次,浏览器执行的流程和原本一模一样没有任何区别但是服务器端有一些区别,这里要使用StaticRouter组件替代浏览器的browserRouter。

npm install react-router-dom --save
复制代码

创建src/Routes.js配置路由。

import React from 'react';
import { Route } from 'react-router-dom';
import Home from './components/Home';

export default (
    <div>
        <Route path="/" exact component={Home}></Route>
    </div>
);
复制代码

在做同构的时候我们要让路由在服务器跑一遍也要在客户端跑一遍。

src/client/index.js, 这里使用BrowserRouter包裹住之前定义的Routes,Home删掉就可以了,因为Routes中已经引入了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'));
复制代码

src/server/index.js,同样使用StaticRouter来渲染Routes。Home在Routes中已经引入了这里就不需要引入了。context是StaticRouter做数据传递的,这里先写一个空对象。

StaticRouter是不知道请求路径是什么的,因为他运行在服务器端,所以这是他不如BrowserRouter的地方,他需要在请求体重获取到路径传递给他, 这里我们就需要将content写在请求里面。将location的值赋为req.path。

import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';

import { StaticRouter } from 'react-router-dom';
import Routes from '../Routes';

const app = express();

app.use(express.static('public'));

app.get('*', function(req, res) {
    const content = renderToString((
        <StaticRouter location={req.path} context={{}}>
            <Routes />
        </StaticRouter>
    ));
    res.send(`
        <html>
            <body>
                <div id="root">${content}</div>
                <script src="/index.js"></script>
            </body>
        </html>
    `);
})

var server = app.listen(3000);
复制代码

我们这里增加一个页面,实现多页面的路由跳转。当用户访问login路径的时候我们返回一个Login组件。

src/Routes.js

import React from 'react';
import { Route } from 'react-router-dom';
import Home from './components/Home';
import Login from './components/Login';

export default (
    <div>
        <Route path="/" exact component={Home}></Route>
        <Route path="/login" exact component={Login}></Route>
    </div>
);
复制代码

src/components/Login/index.js

import React from 'react';

const Login = () => {
    return <div>Login</div>
}

export default Login;
复制代码

这个时候页面就可以打开了,并且login路由可以加载出来。我们来整理一下代码,新建一个utils文件将通用方法抽离出来。

src/server/utils.js

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>
            <body>
                <div id="root">${content}</div>
                <script src="/index.js"></script>
            </body>
        </html>
    `;
}
复制代码

整理一下src/server/index.js,使用render函数。

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));
})
var server = app.listen(3000);
复制代码

这样代码就整理好了,我们接着使用Link标签串联起整个路由的工作流程。

我们创建一个公共组件src/components/Header/index.js

import React from 'react';

const Header = () => {
    return <div>header</div>
}

export default Header;
复制代码

我们再src/components/Home/index.js组件中引入Header组件。

import React from 'react';
import Header from '../Header';

const Home = () => {
    return <div>
        <Header>
        Home
        <button onClick={() => { alert('click1'); }>按钮</button>
    </div>
}

export default Home;
复制代码

src/components/Login/index.js

import React from 'react';
import Header from '../Header';

const Login = () => {
    return <div><Header />Login</div>
}

export default Login;
复制代码

页面可以正常执行,接着我们在Header中引入Link, 并且使用他跳转至Home和Login。

src/components/Header/index.js

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;
复制代码

当我们在做页面同构的时候,服务器端渲染只放生在我们第一次进入页面的时候,后面使用Link的跳转都是浏览器端的跳转,不会再去加载页面的资源文件。

所以服务器端渲染不是每个页面都做服务器端渲染,而是只访问的第一个页面具有服务端渲染的特性,其他的页面仍旧是React的路由机制, 这是我们要注意的。

中间层

当我们做服务端渲染的时候经常能听到中间层这个名词。当我们做服务端渲染的时候浏览器请求服务器也就是node-server,服务器会将页面数据返回给浏览器。但是当我们在做大型项目的时候,获取页面内容的时候会涉及到数据库查询或者说数据的计算,一般在做服务端渲染的时候架构层会将数据库的查询或者复杂的计算放在java,c++等服务器去做,而不会放在node中。

因为相对于Node来说,Java或者C++计算性能要更高一些。

这种架构有一个好处,java服务只需要专注数据的获取数据的计算就可以了,node服务器专注生成页面的内容,负责将从java服务器中获取的数据生成页面结构。所以node-server只是一个中间层,负责页面的拼装。

我们知道react计算还是很消耗性能的,当访问量过多的时候node可能会承受不住,这个时候我们就可以单独去增加node服务器的数量来提高负载瓶颈,这种架构在线上还是具备很大的便捷性的。

不过他的缺点也是比较明显的,增加了前端的复杂度,当我们在关心页面渲染的同时还要维护服务器,关心项目架构。

同构项目引入Redux

如果我想在项目中使用redux我需要在server端和client端都使用redux,我们首先需要安装redux。react-redux可以方便我们在react中去使用redux。redux-thunk是redux的一个中间件我们也安装一下。

npm install redux react-redux redux-thunk --save
复制代码

接着我们打开src/client/index.js。开始使用redux,首先引入createStore来创建store。不了解redux的同学可以参考我之前写的《redux设计模式》一文。

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import Routes from '../Routes';
import { createStore } from 'redux';
import { Provider } from 'react-redux';

const reducer = (state = { name: 'yd'}, action) => {
    return state;
}
const store = createStore(reducer);

const App = () => {
    return (
        <Provider store={store}>
            <BrowserRouter>
                {Routes}
            </BrowserRouter>
        </Provider>
    )
}

ReactDOM.hydrate(<App />, document.getElementById('root'));
复制代码

这样我们的redux就创建完了,我们要可以再src/components/Home/index.js中使用redux。

import React from 'react';
import Header from '../Header';
import { connect } from 'react-redux';

const Home = (props) => {
    return <div>
        <Header>
        <div>{props.name}</div>
        <div>Home</div>
        <button onClick={() => { alert('click1'); }>按钮</button>
    </div>
}

const mapStatetoProps = state => ({
    name: state.name
});

export default connect(mapStatetoProps, null)(Home);
复制代码

我们这里还要打开src/server/utils.js,来写一下store。

src/server/utils.js

import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import Routes from '../Routes';
import { createStore } from 'redux';
import { Provider } from 'react-redux';

export const render = (req) => {

    const reducer = (state = { name: 'yd'}, action) => {
        return state;
    }
    const store = createStore(reducer);

    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>
    `;
}
复制代码

这个时候页面就可以显示出来了。我们页面也引入了redux。

我们希望使用redux的时候使用一些中间件,我们可以在src/server/utils.js演示。

import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import Routes from '../Routes';
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';

export const render = (req) => {

    const reducer = (state = { name: 'yd'}, action) => {
        return state;
    }
    const store = createStore(reducer, applyMiddleware(thunk));

    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>
    `;
}
复制代码

src/client/index.js也要加一下。

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import Routes from '../Routes';
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';

const reducer = (state = { name: 'yd'}, action) => {
    return state;
}
const store = createStore(reducer, applyMiddleware(thunk));

const App = () => {
    return (
        <Provider store={store}>
            <BrowserRouter>
                {Routes}
            </BrowserRouter>
        </Provider>
    )
}

ReactDOM.hydrate(<App />, document.getElementById('root'));
复制代码

这里我们发现client和server中都会用到store,我们可以将他们抽离出来不要每个位置都写一遍。

这里我们要注意一下,在render方法中每个用户访问都会使用store,但是在服务器上我们这个store只定义了一次,并不是每次调用render都会创建,所以共享了store,这样是不对的,每个用户都应该有自己的store。所以我们这里导出一个创建Store的方法,在使用的位置创建store。

src/store/index.js

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
const reducer = (state = { name: 'yd'}, action) => {
    return state;
}

const getStore = () => {
    return createStore(reducer, applyMiddleware(thunk));
}
export default getStore;
复制代码

src/client/index.js也要加一下。

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import Routes from '../Routes';
import getStore from '../store'; // 使用store
import { Provider } from 'react-redux';

const App = () => {
    return (
        <Provider store={getStore()}>
            <BrowserRouter>
                {Routes}
            </BrowserRouter>
        </Provider>
    )
}

ReactDOM.hydrate(<App />, document.getElementById('root'));
复制代码

src/server/utils.js

import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import Routes from '../Routes';
import getStore from '../store'; // 使用store
import { Provider } from 'react-redux';

export const render = (req) => {

    const content = renderToString((
        <Provider store={getStore()}>
            <StaticRouter location={req.path} context={{}}>
                <Routes />
            </StaticRouter>
        </Provider>
    ));
    return `
        <html>
            <body>
                <div id="root">${content}</div>
                <script src="/index.js"></script>
            </body>
        </html>
    `;
}
复制代码

这里我们来规范一下redux项目代码的结构。我们这里希望访问根目录的时候页面显示一个列表。

首先我们修改首页src/components/Home/store/reducer.js初始化创建一些数据并且处理actions变化数据的变化。

import { CHANGE_LIST } from './constants';
const defaultState = {
    newsList: []
}
export default (state = defaultState, action) => {
    switch (action.type) {
        case CHANGE_LIST:
            return {
                ...state,
                newsList: action.list
            };
        default:
            return state;
    }
}
复制代码

接着我们来到全局的store中,这里面应该对所有的reducer做一个组合,我们来修改一下。我们引入home中的reducer

src/store/index.js

import { createStore, applyMiddleware, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import { reducer as homeReducer} from '../components/Home/store';

const reducer = combineReducers({
    home: homeReducer
});

const getStore = () => {
    return createStore(reducer, applyMiddleware(thunk));
}
export default getStore;
复制代码

src/components/Home/store/index.js

import reducer from './reducer';

export { reducer };
复制代码

src/components/Home/index.js, 我们希望在这个文件中发一个请求去展示列表,这里我们改造成一个类式组件。

借助dispatch的能力,我们在getHomeList中发送一个异步请求。这里dispatch需要使用action

import React, { Component } from 'react';
import Header from '../Header';
import { connect } from 'react-redux';
import { getHomeList } from './store/actions';

class Home extends Component {

    getList() {
        const { list } = this.props;
        return this.props.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.newsList
});

const mapDispatchToProps = dispatch => ({
    getHomeList() {
        dispatch(getHomeList());
    }
})

export default connect(mapStatetoProps, mapDispatchToProps)(Home);
复制代码

src/components/Home/store/actions.js这里我们定义一个函数,这个函数可以返回一个对象作为action也可以返回一个函数来做异步的操作,这是redux-thunk带来的能力。

这里我们做异步的请求, 这里我们使用axios,别忘记安装。

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://127.0.0.1:3000/getlist').then(res => {
            const list = res.data.data;
            dispatch(changeList(list));
        })
    }
}
复制代码

src/components/Home/store/constants.js 存储常量。

export const CHANGE_LIST = 'HOME/CHANGE_LIST';
复制代码

这样我们redux的结构就创建好了,但是我们发现我们项目可以正常运行但是页面渲染的结构中并没有这段列表的结构。这是因为我们服务器端运行的时候componentDidMount并不会执行,所以列表是空的,所以列表的内容并没有生成,我们看到的列表是客户端客户端运行的时候执行的,所以列表是客户端渲染出来的。

我们需要做的是在服务器端的时候也要执行componentDidMount获取到数据。将页面结构渲染出来。

src/components/Home/index.js, 我们再Home组件中添加一个静态方法Home.loadData, 这个函数负责在服务端渲染之前把这个路由需要的数据提前加载好。

import React, { Component } from 'react';
import Header from '../Header';
import { connect } from 'react-redux';
import { getHomeList } from './store/actions';

class Home extends Component {

    getList() {
        const { list } = this.props;
        return this.props.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();
    }
}

Home.loadData = (store) => {
    // 执行action,扩充store。
    return store.dispatch(getHomeList());
}

const mapStatetoProps = state => ({
    list: state.home.newsList
});

const mapDispatchToProps = dispatch => ({
    getHomeList() {
        dispatch(getHomeList());
    }
})

export default connect(mapStatetoProps, mapDispatchToProps)(Home);
复制代码

其实componentDidMount在服务端是不会执行的,

src/server/utils.js中我们拿到的store是空的,我们可以让store在渲染时拿到真实的数据就可以了。这里我们需要知道当前路由加载哪些组件的数据。所以我们这里还需要改造一下路由配置,根据路由来判断加载的数据。

loadData是加载组件之前执行的方法,我们这里写成Home.loadData, Login组件不需要加载任何数据,所以我们不定义就可以了。

src/Routes.js

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的地方也要修改。Router已经不是组件而是数组了,我们需要修改一下。

src/client/index.js注意需要使用div包裹一下所有Route,否则会报错。因为react-route-dom要求route成组出现。

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Route } from 'react-router-dom';
import routes from '../Routes';
import getStore from '../store'; // 使用store
import { Provider } from 'react-redux';

const App = () => {
    return (
        <Provider store={getStore()}>
            <BrowserRouter>
                <div>
                    {
                        routes.map(route => (
                            <Route {...route} />
                        ))
                    }
                </div>
            </BrowserRouter>
        </Provider>
    )
}

ReactDOM.hydrate(<App />, document.getElementById('root'));
复制代码

src/server/utils.js这里我们除了修改routes以外还要判断当前访问的路径是什么,然后将对应需要加载的数据提前放在store里面,需要借助matchRoute方法。他是react-router-config插件提供的。matchRoute可以匹配二级路由,react-router-dom自带的matchPath只能匹配一级路由。

这里我们的render方法需要多接收一个res,用于返回给浏览器的send调用。因为请求函数是异步的,所以需要在回调结束后返回。

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'; // 使用store
import { Provider } from 'react-redux';
import { matchRoute } from 'react-router-config';

export const render = (req, res) => {
    const store = getStore();
    // 可以拿到store填充到store中。
    // 根据路由的路径向store里面添加数据,需要借助matchRoute,返回值是一个数组,里面是匹配到的每级路由。
    const matchedRoutes = matchRoute(routes, req,path);
    // 让matchRoutes里面所有的组件对应的loadData方法都执行一次
    // item.route.loadData返回的是一个promise我们等待promise执行完毕再向下,所以使用Promise.all,请求响应后返回给浏览器数据。
    const 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>
                    <script src="/index.js"></script>
                </body>
            </html>
        `);
    })
}
复制代码

src/server/index.js也需要修改一下。将req和res都传递进去,然后在render方法里面去返回响应。

import express from 'express';
import { render } from './utils';

const app = express();
app.use(express.static('public'));

app.get('*', function(req, res) {
    render(req, res)
})
var server = app.listen(3000);
复制代码

注意浏览器会自动请求一个favicon文件,造成代码重复执行,我们可以在public文件夹中加入这个图片解决该问题。

这样我们访问浏览器就可以发现页面结构已经渲染出来了,并且是服务端渲染的而不是浏览器渲染的。

这里我们整理一下这些代码。

src/server/index.js当接收到用户请求的时候将store的创建移动到这里,保持render函数干净。

import express from 'express';
import { matchRoute } from 'react-router-config';
import { render } from './utils';
import getStore from '../store'; // 使用store
import routes from '../Routes';

const app = express();
app.use(express.static('public'));

app.get('*', function(req, res) {
    const store = getStore();
    const matchedRoutes = matchRoute(routes, req,path);
    const promises = [];
    matchedRoutes.forEach(item => {
        if (item.route.loadData) {
            promises.push(item.route.loadData(store));
        }
    });
    Promise.all(promises).then(() => {
        res.send(render(store, routes, req)); 
    })
})
var server = app.listen(3000);
复制代码

src/server/utils.js

import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter, Route } from 'react-router-dom';
import { Provider } from 'react-redux';

export const render = (store, routes, req) => {
    const content = renderToString((
        <Provider store={store}>
            <StaticRouter location={req.path} context={{}}>
                <div>
                    {
                        routes.map(route => (
                            <Route {...route} />
                        ))
                    }
                </div>
            </StaticRouter>
        </Provider>
    ));
    return `
        <html>
            <body>
                <div id="root">${content}</div>
                <script src="/index.js"></script>
            </body>
        </html>
    `;
}
复制代码

这里我们来总结一下服务器端渲染的流程。

当用户请求我们网页的时候首先我们创建了一个空的store,然后根据请求路径和路由项做匹配,来判断用户当前访问的路径对应的加载项有哪些,所以matchedRoutes中放置的就是要展示的组件。循环matchedRoutes判断组件里面是否存在loadData,如果有就说明他需要加载一些数据,所以把loadData执行以下,将请求放在Promise的数组里面,等所有组件对应的Promose都执行完成之后就说明这一个路径要展示的组件依赖数据已经准备好了,那么结合展示好了的数据和路由和请求数据最终生成一段html内容返回给用户。这段html就包含了用户需要的所有信息。

不过我们这里还有一个问题,当我们加载页面之后页面会闪动一下,因为这是js执行的时候会清空页面再展示页面。因为客户端一开始store也是空的,是请求结束之后才有数据的。

这里我们需要用到一个概念叫做脱水和注水,我们首先找到src/server/utils.js, 在渲染页面的时候我们可以在页面底部加一个script标签, 在这个标签里面写上我们渲染的数据。

src/server/utils.js

import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter, Route } from 'react-router-dom';
import { Provider } from 'react-redux';

export const render = (store, routes, req) => {
    const content = renderToString((
        <Provider store={store}>
            <StaticRouter location={req.path} context={{}}>
                <div>
                    {
                        routes.map(route => (
                            <Route {...route} />
                        ))
                    }
                </div>
            </StaticRouter>
        </Provider>
    ));
    return `
        <html>
            <body>
                <div id="root">${content}</div>
                <script>
                    window.context = {
                        state: ${JSON.stringfiy(store.getState())}
                    }
                </script>
                <script src="/index.js"></script>
            </body>
        </html>
    `;
}
复制代码

我们打开src/store/index.js, 在里面新增一个方法getClientStore。

import { createStore, applyMiddleware, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import { reducer as homeReducer} from '../components/Home/store';

const reducer = combineReducers({
    home: homeReducer
});

export const getStore = () => {
    return createStore(reducer, applyMiddleware(thunk));
}

export const getClientStore = () => {
    const defaultState = window.context.state;
    // defaultState作为默认值
    return createStore(reducer, defaultState, applyMiddleware(thunk));
}
复制代码

src/server/index.js修改store获取方式

import express from 'express';
import { matchRoute } from 'react-router-config';
import { render } from './utils';
import { getStore } from '../store'; // 使用store
import routes from '../Routes';

const app = express();
app.use(express.static('public'));

app.get('*', function(req, res) {
    const store = getStore();
    const matchedRoutes = matchRoute(routes, req,path);
    const promises = [];
    matchedRoutes.forEach(item => {
        if (item.route.loadData) {
            promises.push(item.route.loadData(store));
        }
    });
    Promise.all(promises).then(() => {
        res.send(render(store, routes, req)); 
    })
})
var server = app.listen(3000);
复制代码

然后打开src/client/index.js这里的getStore换成getClientStore, 客户端的store我们要使用服务端给我的store。

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Route } from 'react-router-dom';
import routes from '../Routes';
import { getClientStore } from '../store'; // 使用store
import { Provider } from 'react-redux';

const store = getClientStore();
const App = () => {
    return (
        <Provider store={store}>
            <BrowserRouter>
                <div>
                    {
                        routes.map(route => (
                            <Route {...route} />
                        ))
                    }
                </div>
            </BrowserRouter>
        </Provider>
    )
}

ReactDOM.hydrate(<App />, document.getElementById('root'));
复制代码

但是这样我们componentDidMount中获取数据的方法是不是可以删掉了?其实不可以,因为我们路由跳转的时候被加载的组件仍旧需要执行改方法。前面我们说过服务端渲染只会加载第一个页面的内容,后面路由加载的内容并不会全部展示出来。

我们可以判断一下数据是否存在,如果不存在就请求,存在就不请求。

src/components/Home/index.js

import React, { Component } from 'react';
import Header from '../Header';
import { connect } from 'react-redux';
import { getHomeList } from './store/actions';

class Home extends Component {

    getList() {
        const { list } = this.props;
        return this.props.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() {
        if (!this.props.list.length) {
            this.props.getHomeList();
        }
    }
}

Home.loadData = (store) => {
    // 执行action,扩充store。
    return store.dispatch(getHomeList());
}

const mapStatetoProps = state => ({
    list: state.home.newsList
});

const mapDispatchToProps = dispatch => ({
    getHomeList() {
        dispatch(getHomeList());
    }
})

export default connect(mapStatetoProps, mapDispatchToProps)(Home);
复制代码
文章分类
前端
文章标签