基于React+Redux的SSR实现方法

作者: 老王420 | 来源:发表于2018-12-10 22:04 被阅读17次

    为什么要实现服务端渲染(SSR)

    总结下来有以下几点:

    1. SEO,让搜索引擎更容易读取页面内容
    2. 首屏渲染速度更快(重点),无需等待js文件下载执行的过程
    3. 代码同构,服务端和客户端可以共享某些代码

    今天我们将构建一个使用 Redux 的简单的 React 应用程序,实现服务端渲染(SSR)。该示例包括异步数据抓取,这使得任务变得更有趣。

    如果您想使用本文中讨论的代码,请查看GitHub: answer518/react-redux-ssr

    安装环境

    在开始编写应用之前,需要我们先把环境编译/打包环境配置好,因为我们采用的是es6语法编写代码。我们需要将代码编译成es5代码在浏览器或node环境中执行。

    我们将用babelify转换来使用browserifywatchify来打包我们的客户端代码。对于我们的服务器端代码,我们将直接使用babel-cli。

    代码结构如下:

    `build`
    `src`
    `├── client`
    `│  └── client.js`
    `└── server`
    `└── server.js`
    

    我们在package.json里面加入以下两个命令脚本:

    `"scripts"``: {`
    `"build"``:` `"`
    `browserify ./src/client/client.js -o ./build/bundle.js -t babelify &&`
    `babel ./src/ --out-dir ./build/"``,`
    `"watch"``:` `"`
    `concurrently`
    `\"watchify ./src/client/client.js -o ./build/bundle.js -t babelify -v\"`
    `\"babel ./src/ --out-dir ./build/ --watch\"`
    `"`
    `}`
    

    concurrently库帮助并行运行多个进程,这正是我们在监控更改时需要的。

    最后一个有用的命令,用于运行我们的http服务器:

    `"scripts"``: {
    `"build"``:` `"..."``,`
    `"watch"``:` `"..."``,`
    `"start"``:` `"nodemon ./build/server/server.js"`
    `}`
    

    不使用 node ./build/server/server.js 而使用 Nodemon 的原因是,它可以监控我们代码中的任何更改,并自动重新启动服务器。这一点在开发过程会非常有用。

    开发React+Redux应用

    假设服务端返回以下的数据格式:

    `[`
    `{`
    `"id"``: 4,`
    `"first_name"``:` `"Gates"``,`
    `"last_name"``:` `"Bill"``,`
    `"avatar"``:` `"[https://s3.amazonaws.com/uifaces/faces/twitter/marcoramires/128.jpg](https://s3.amazonaws.com/uifaces/faces/twitter/marcoramires/128.jpg)"`
    `},`
    `{`
    `...`
    `}`
    `]`
    

    我们通过一个组件将数据渲染出来。在这个组件的 componentWillMount 生命周期方法中,我们将触发数据获取,一旦请求成功,我们将发送一个类型为 user_fetch 的操作。该操作将由一个 reducer 处理,我们将在 Redux 存储中获得更新。状态的改变将触发我们的组件重新呈现指定的数据。

    Redux具体实现

    reducer 处理过程如下:

    `// reducer.js`
    `import { USERS_FETCHED } from` `'./constants'``;`
    `function` `getInitialState() {`
    `return` `{ users:` `null` `};`
    `}`
    `const reducer =` `function` `(oldState = getInitialState(), action) {`
    `if` `(action.type === USERS_FETCHED) {`
    `return` `{ users: action.response.data };`
    `}
    `return` `oldState;`
    `};`
    

    为了能派发 action 请求去改变应用状态,我们需要编写 Action Creator :

    `// actions.js`
    `import { USERS_FETCHED } from` `'./constants'``;`
    `export const usersFetched = response => ({ type: USERS_FETCHED, response });`
    `// selectors.js`
    `export const getUsers = ({ users }) => users;`
    

    Redux 实现的最关键一步就是创建 Store :

    `// store.js`
    `import { USERS_FETCHED } from` `'./constants'``;`
    `import { createStore } from` `'redux'``;`
    `import reducer from` `'./reducer'``;`
    `export` `default` `() => createStore(reducer);`
    

    为什么直接返回的是工厂函数而不是 createStore(reducer) ?这是因为当我们在服务器端渲染时,我们需要一个全新的 Store 实例来处理每个请求。

    实现React组件

    在这里需要提的一个重点是,一旦我们想实现服务端渲染,那我们就需要改变之前的纯客户端编程模式。

    服务器端渲染,也叫代码同构,也就是同一份代码既能在客户端渲染,又能在服务端渲染。

    我们必须保证代码能在服务端正常的运行。例如,访问 Window 对象,Node不提供Window对象的访问。

    `// App.jsx`
    `import React from` `'react'``;`
    `import { connect } from` `'react-redux'``;`
    `import { getUsers } from` `'./redux/selectors'``;`
    `import { usersFetched } from` `'./redux/actions'``;`
    `const ENDPOINT =` `'[http://localhost:3000/users_fake_data.json](http://localhost:3000/users_fake_data.json)'``;`
    `class App extends React.Component {`
    `componentWillMount() {`
    `fetchUsers();`
    `}`
    `render() {`
    `const { users } =` `this``.props;`
    `return` `(`
    `<div>`
    `{`
    欢迎加入全栈开发交流划水交流圈:582735936
    面向划水1-3年前端人员
    帮助突破划水瓶颈,提升思维能力
    `users && users.length > 0 && users.map(`
    `// ... render the user here`
    `)`
    `}`
    `</div>`
    `);`
    `}`
    `}`
    `const ConnectedApp = connect(`
    `state => ({`
    `users: getUsers(state)`
    `}),`
    `dispatch => ({`
    `fetchUsers: async () => dispatch(`
    `usersFetched(await (await fetch(ENDPOINT)).json())`
    `)`
    `})`
    `)(App);`
    `export` `default` `ConnectedApp;`
    

    你看到,我们使用 componentWillMount 来发送 fetchUsers 请求, componentDidMount 为什么不能用呢? 主要原因是 componentDidMount 在服务端渲染过程中并不会执行。

    fetchUsers 是一个异步函数,它通过Fetch API请求数据。当数据返回时,会派发 users_fetch 动作,从而通过 reducer 重新计算状态,而我们的 <App /> 由于连接到 Redux 从而被重新渲染。

    `// client.js`
    `import React from` `'react'``;`
    `import ReactDOM from` `'react-dom'``;`
    `import { Provider } from` `'react-redux'``;`
    `import App from` `'./App.jsx'``;`
    `import createStore from` `'./redux/store'``;`
    `ReactDOM.render(`
    `<Provider store={ createStore() }><App /></Provider>,`
    `document.querySelector(``'#content'``)`
    `);`
    

    运行Node Server

    为了演示方便,我们首选Express作为http服务器。

    `// server.js`
    `import express from` `'express'``;`
    `const app = express();`
    `// Serving the content of the "build" folder. Remember that`
    `// after the transpiling and bundling we have:`
    `//`
    `// build`
    `//  ├── client`
    `//  ├── server`
    `//  │  └── server.js`
    `//  └── bundle.js`
    `app.use(express.static(__dirname +` `'/../'``));`
    `app.get(``'*'``, (req, res) => {`
    `res.set(``'Content-Type'``,` `'text/html'``);`
    `res.send(``
    `<html>`
    `<head>`
    `<title>App</title>`
    `</head>`
    `<body>`
    欢迎加入全栈开发交流划水交流圈:582735936
    面向划水1-3年前端人员
    帮助突破划水瓶颈,提升思维能力
    `<div id=``"content"``></div>`
    `<script src=``"/bundle.js"``></script>`
    `</body>`
    `</html>`
    ``);`
    `});`
    `app.listen(`
    `3000,`
    `() => console.log(``'Example app listening on port 3000!'``)`
    `);`
    

    有了这个文件,我们可以运行 npm run start 并访问 http://localhost:3000 。我们看到数据获取成功,并成功的显示了。

    服务端渲染

    目前为止,我们的服务端仅仅是返回了一个 html 骨架,而所有交互全在客户端完成。浏览器需要先下载 bundle.js 后执行。而服务端渲染的作用就是在服务器上执行所有操作并发送最终标记,而不是把所有工作交给浏览器执行。 React 足够的聪明,能够识别出这些标记。

    还记得我们在客户端做的以下事情吗?

    `import ReactDOM from` `'react-dom'``;`
    `ReactDOM.render(`
    `<Provider store={ createStore() }><App /></Provider>,`
    `document.querySelector(``'#content'``)`
    `);`
    

    服务端几乎相同:

    `import ReactDOMServer from` `'react-dom/server'``;`
    `const markupAsString = ReactDOMServer.renderToString(`
    `<Provider store={ store }><App /></Provider>`
    `);`
    

    我们使用了相同的组件 <App /> 和 store ,不同之处在于它返回的是一个字符串,而不是虚拟DOM。

    然后将这个字符串加入到 Express 的响应里面,所以服务端代码为:

    `const store = createStore();`
    `const content = ReactDOMServer.renderToString(`
    `<Provider store={ store }><App /></Provider>`
    `);`
    `app.get(``'*'``, (req, res) => {`
    `res.set(``'Content-Type'``,` `'text/html'``);`
    `res.send(``
    `<html>`
    `<head>`
    `<title>App</title>`
    `</head>`
    `<body>`
    `<div id=``"content"``>${ content }</div>`
    `<script src=``"/bundle.js"``></script>`
    `</body>`
    `</html>`
    ``);`
    `});`
    

    如果重新启动服务器并打开相同的 http://localhost:3000 ,我们将看到以下响应:

    `<``html``>`
    `<``head``>`
    `<``title``>App</``title``>`
    `</``head``>`
    `<``body``>`
    `<``div` `id``=``"content"``><``div` `data-reactroot``=``""``></``div``></``div``>`
    `<``script` `src``=``"/bundle.js"``></``script``>`
    `</``body``>`
    `</``html``>`
    

    我们的页面中确实有一些内容,但它只是 <div data-reactroot=""></div> 。这并不意味着程序出错了。这绝对是正确的。 React 确实呈现了我们的页面,但它只呈现静态内容。在我们的组件中,我们在获取数据之前什么都没有,数据的获取是一个异步过程,在服务器上呈现时,我们必须考虑到这一点。这就是我们的任务变得棘手的地方。这可以归结为我们的应用程序在做什么。在本例中,客户端代码依赖于一个特定的请求,但如果使用 redux-saga 库,则可能是多个请求,或者可能是一个完整的root saga。我意识到处理这个问题的两种方法:

    1、我们明确知道请求的页面需要什么样的数据。我们获取数据并使用该数据创建 Redux 存储。然后我们通过提供已完成的 Store 来呈现页面,理论上我们可以做到。

    2、我们完全依赖于运行在客户端上的代码,计算出最终的结果。

    第一种方法,需要我们在两端做好状态管理。第二种方法需要我们在服务端使用一些额外的库或工具,来确保同一套代码能在服务端和客户端做相同的事情,我个人比较推荐使用这种方法。

    例如,我们使用了 Fetch API 向后端发出异步请求,而服务端默认是不支持的。我们需要做的就是在 server.js 中将 Fetch 导入:

    `import` `'isomorphic-fetch'``;`
    

    我们使用客户端API接收异步数据,一旦 Store 获取到异步数据,我们将触发 ReactDOMServer.renderToString 。它会提供给我们想要的标记。我们的Express处理器是这样的:

    `app.get(``'*'``, (req, res) => {`
    `const store = createStore();`
    `const unsubscribe = store.subscribe(() => {`
    `const users = getUsers(store.getState());`
    `if` `(users !==` `null` `&& users.length > 0) {`
    `unsubscribe();`
    `const content = ReactDOMServer.renderToString(`
    `<Provider store={ store }><App /></Provider>`
    `);`
    `res.set(``'Content-Type'``,` `'text/html'``);`
    `res.send(``
    `<html>`
    `<head>`
    `<title>App</title>`
    `</head>`
    `<body>`
    `<div id=``"content"``>${ content }</div>`
    `<script src=``"/bundle.js"``></script>`
    `</body>`
    `</html>`
    ``);`
    `}`
    `});`
    `ReactDOMServer.renderToString(<Provider store={ store }><App /></Provider>);`
    `});`
    

    我们使用 Storesubscribe 方法来监听状态。当状态发生变化——是否有任何用户数据被获取。如果 users 存在,我们将 unsubscribe() ,这样我们就不会让相同的代码运行两次,并且我们使用相同的存储实例转换为string。最后,我们将标记输出到浏览器。

    store.subscribe方法返回一个函数,调用这个函数就可以解除监听

    有了上面的代码,我们的组件已经可以成功地在服务器端渲染。通过开发者工具,我们可以看到发送到浏览器的内容:

    `<``html``>`
    `<``head``>`
    `<``title``>App</``title``>`
    `<``style``>`
    `body {`
    `font-size: 18px;`
    `font-family: Verdana;`
    `}`
    `</``style``>`
    `</``head``>`
    `<``body``>`
    `<``div` `id``=``"content"``><``div` `data-reactroot``=``""``><``p``>Eve Holt</``p``><``p``>Charles Morris</``p``><``p``>Tracey Ramos</``p``></``div``></``div``>`
    `<``script``>`
    `window.__APP_STATE = {"users":[{"id":4,"first_name":"Eve","last_name":"Holt","avatar":"[https://s3.amazonaws.com/uifaces/faces/twitter/marcoramires/128.jpg](https://s3.amazonaws.com/uifaces/faces/twitter/marcoramires/128.jpg)"},{"id":5,"first_name":"Charles","last_name":"Morris","avatar":"[https://s3.amazonaws.com/uifaces/faces/twitter/stephenmoon/128.jpg](https://s3.amazonaws.com/uifaces/faces/twitter/stephenmoon/128.jpg)"},{"id":6,"first_name":"Tracey","last_name":"Ramos","avatar":"[https://s3.amazonaws.com/uifaces/faces/twitter/bigmancho/128.jpg](https://s3.amazonaws.com/uifaces/faces/twitter/bigmancho/128.jpg)"}]};`
    `</``script``>`
    `<``script` `src``=``"/bundle.js"``></``script``>`
    `</``body``>`
    `</``html``>`
    

    当然,现在并没有结束,客户端 JavaScript 不知道服务器上发生了什么,也不知道我们已经对API进行了请求。我们必须通过传递 Store 的状态来通知浏览器,以便它能够接收它。

    `const content = ReactDOMServer.renderToString(`
    `<Provider store={ store }><App /></Provider>`
    `);`
    `res.set(``'Content-Type'``,` `'text/html'``);`
    `res.send(``
    `<html>`
    `<head>`
    `<title>App</title>`
    `</head>`
    `<body>`
    `<div id=``"content"``>${ content }</div>`
    `<script>`
    `window.__APP_STATE = ${ JSON.stringify(store.getState()) };`
    `</script>`
    `<script src=``"/bundle.js"``></script>`
    `</body>`
    `</html>`
    ``);`
    

    我们将 Store 状态放到一个全局变量 __APP_STATE 中, reducer 也有一点变化:

    `function` `getInitialState() {`
    `if` `(``typeof` `window !==` `'undefined'` `&& window.__APP_STATE) {`
    `return` `window.__APP_STATE;`
    `}`
    `return` `{ users:` `null` `};`
    `}`
    

    注意 typeof window !== 'undefined' ,我们必须这样做,因为这段代码也会在服务端执行,这就是为什么说在做服务端渲染时要非常小心,尤其是全局使用的浏览器api的时候。

    最后一个需要优化的地方,就是当已经取到 users 时,必须阻止 fetch 。

    `componentWillMount() {`
    `const { users, fetchUsers } =` `this``.props;`
    `if` `(users ===` `null``) {`
    `fetchUsers();`
    `}`
    `}`
    

    总结

    服务器端呈现是一个有趣的话题。它有很多优势,并改善了整体用户体验。它还会提升你的单页应用程序的SEO。但这一切并不简单。在大多数情况下,需要额外的工具和精心选择的api。

    这只是一个简单的案例,实际开发场景往往比这个复杂的多,需要考虑的情况也会非常多,你们的服务端渲染是怎么做的?
    本次给大家推荐一个交流圈,里面概括移动应用网站开发,css,html,webpack,vue node angular以及面试资源等。
    对web开发技术感兴趣的同学,欢迎加入:582735936,不管你是小白还是大牛我都欢迎,还有大牛整理的一套高效率学习路线和教程与您免费分享,同时每天更新视频资料。
    最后,祝大家早日学有所成,拿到满意offer,快速升职加薪,走上人生巅峰。

    相关文章

      网友评论

        本文标题:基于React+Redux的SSR实现方法

        本文链接:https://www.haomeiwen.com/subject/mpzdhqtx.html