为什么要实现服务端渲染(SSR) 总结下来有以下几点:
SEO,让搜索引擎更容易读取页面内容
首屏渲染速度更快(重点),无需等待js文件下载执行的过程
代码同构,服务端和客户端可以共享某些代码
今天我们将构建一个使用Redux
的简单的React
应用程序,实现服务端渲染(SSR)。该示例包括异步数据抓取,这使得任务变得更有趣。
如果您想使用本文中讨论的代码,请查看GitHub: answer518/react-redux-ssr
安装环境 在开始编写应用之前,需要我们先把环境编译/打包环境配置好,因为我们采用的是es6语法编写代码。我们需要将代码编译成es5代码在浏览器或node环境中执行。
我们将用babelify 转换来使用browserify 和watchify 来打包我们的客户端代码。对于我们的服务器端代码,我们将直接使用babel-cli 。
代码结构如下:
1 2 3 4 5 6 build src ├── client │ └── client.js └── server └── server.js
我们在package.json里面加入以下两个命令脚本:
1 2 3 4 5 6 7 8 9 10 "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服务器:
1 2 3 4 5 "scripts": { "build": "...", "watch": "...", "start": "nodemon ./build/server/server.js" }
不使用node ./build/server/server.js
而使用Nodemon
的原因是,它可以监控我们代码中的任何更改,并自动重新启动服务器。这一点在开发过程会非常有用。
开发React+Redux应用 假设服务端返回以下的数据格式:
1 2 3 4 5 6 7 8 9 10 11 [ { "id" : 4 , "first_name" : "Gates" , "last_name" : "Bill" , "avatar" : "https://s3.amazonaws.com/uifaces/faces/twitter/marcoramires/128.jpg" }, { ... } ]
我们通过一个组件将数据渲染出来。在这个组件的componentWillMount
生命周期方法中,我们将触发数据获取,一旦请求成功,我们将发送一个类型为user_fetch
的操作。该操作将由一个reducer
处理,我们将在Redux
存储中获得更新。状态的改变将触发我们的组件重新呈现指定的数据。
Redux具体实现 reducer
处理过程如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 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
:
1 2 3 4 5 6 import { USERS_FETCHED } from './constants' ;export const usersFetched = response => ({ type : USERS_FETCHED , response });export const getUsers = ({ users } ) => users;
Redux
实现的最关键一步就是创建Store
:
1 2 3 4 5 6 import { USERS_FETCHED } from './constants' ;import { createStore } from 'redux' ;import reducer from './reducer' ;export default () => createStore (reducer);
为什么直接返回的是工厂函数而不是createStore(reducer)
?这是因为当我们在服务器端渲染时,我们需要一个全新的Store
实例来处理每个请求。
实现React组件 在这里需要提的一个重点是,一旦我们想实现服务端渲染,那我们就需要改变之前的纯客户端编程模式。
服务器端渲染,也叫代码同构,也就是同一份代码既能在客户端渲染,又能在服务端渲染。
我们必须保证代码能在服务端正常的运行。例如,访问Window
对象,Node不提供Window对象的访问。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 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' ;class App extends React.Component { componentWillMount ( ) { fetchUsers (); } render ( ) { const { users } = this .props ; return ( <div > { 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
从而被重新渲染。
1 2 3 4 5 6 7 8 9 10 11 12 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服务器。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 import express from 'express' ;const app = express ();app.use (express.static (__dirname + '/../' )); app.get ('*' , (req, res ) => { res.set ('Content-Type' , 'text/html' ); res.send (` <html> <head> <title>App</title> </head> <body> <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
足够的聪明,能够识别出这些标记。
还记得我们在客户端做的以下事情吗?
1 2 3 4 5 6 import ReactDOM from 'react-dom' ;ReactDOM .render ( <Provider store ={ createStore () }> <App /> </Provider > , document .querySelector ('#content' ) );
服务端几乎相同:
1 2 3 4 5 import ReactDOMServer from 'react-dom/server' ;const markupAsString = ReactDOMServer .renderToString ( <Provider store ={ store }> <App /> </Provider > );
我们使用了相同的组件<App />
和 store
,不同之处在于它返回的是一个字符串,而不是虚拟DOM。
然后将这个字符串加入到Express
的响应里面,所以服务端代码为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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
,我们将看到以下响应:
1 2 3 4 5 6 7 8 9 <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
导入:
1 import 'isomorphic-fetch' ;
我们使用客户端API接收异步数据,一旦Store
获取到异步数据,我们将触发ReactDOMServer.renderToString
。它会提供给我们想要的标记。我们的Express处理器是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 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 > ); });
我们使用Store
的subscribe
方法来监听状态。当状态发生变化——是否有任何用户数据被获取。如果users
存在,我们将unsubscribe()
,这样我们就不会让相同的代码运行两次,并且我们使用相同的存储实例转换为string。最后,我们将标记输出到浏览器。
store.subscribe方法返回一个函数,调用这个函数就可以解除监听
有了上面的代码,我们的组件已经可以成功地在服务器端渲染。通过开发者工具,我们可以看到发送到浏览器的内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <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" },{"id" :5 ,"first_name" :"Charles" ,"last_name" :"Morris" ,"avatar" :"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" }]}; </script > <script src ="/bundle.js" > </script > </body > </html >
当然,现在并没有结束,客户端JavaScript
不知道服务器上发生了什么,也不知道我们已经对API进行了请求。我们必须通过传递Store
的状态来通知浏览器,以便它能够接收它。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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
也有一点变化:
1 2 3 4 5 6 function getInitialState ( ) { if (typeof window !== 'undefined' && window .__APP_STATE ) { return window .__APP_STATE ; } return { users : null }; }
注意typeof window !== 'undefined'
,我们必须这样做,因为这段代码也会在服务端执行,这就是为什么说在做服务端渲染时要非常小心,尤其是全局使用的浏览器api的时候。
最后一个需要优化的地方,就是当已经取到users
时,必须阻止fetch
。
1 2 3 4 5 6 7 componentWillMount ( ) { const { users, fetchUsers } = this .props ; if (users === null ) { fetchUsers (); } }
总结 服务器端呈现是一个有趣的话题。它有很多优势,并改善了整体用户体验。它还会提升你的单页应用程序的SEO。但这一切并不简单。在大多数情况下,需要额外的工具和精心选择的api。
这只是一个简单的案例,实际开发场景往往比这个复杂的多,需要考虑的情况也会非常多,你们的服务端渲染是怎么做的?