這是一個即時短課程的系列筆記。本筆記系列進度已更新到:https://github.com/dangjingtao/react-ssr
服務端資料的異步擷取
上節的代碼中,存在一個問題。在浏覽器右鍵審查網頁源代碼,看到的代碼是這樣的:

後端ssr隻是渲染了網頁模闆(ul),清單(li)的html都是異步請求加載出來的。再回看首頁清單的代碼:
// src/container/Index.js
import React,{useState,useEffect} from 'react';
import {connect} from 'react-redux';
import {getIndexList} from '../store/index';
function Index(props){
const [count,setCount]=useState(1);
useEffect(()=>{
props.getIndexList();
},[]);
return <div>
<h1>react ssr</h1>
<span>{count}</span><br/>
<button onClick={()=>{setCount(count+1)}}>+</button><hr/>
<ul>
{props.list.map((item,index)=>(
<li key={index}>{item.id}-{item.name}</li>
))}
</ul>
</div>
}
export default connect(
state=>({list:state.index.list}),
{getIndexList}
)(Index);
複制
這裡的過程是:Index作為一個純元件,在加載之後(componentDIdAmount),通過redux dispatch一個請求。拿到我們mock的資料,傳入到首頁到props中,再執行渲染。
問題來了:異步資料(useEffect)能否再後端執行渲染完了再傳給前端呢?
解決的思路在于store的初始值。
// 建立store
const store = createStore(reducer,初始值, applyMiddleware(thunk));
複制
createStore可以插入第二個參數,放入初始值,是以考慮把擷取初始值放到server端去做。此時服務端和用戶端的store已經分離。
思路既已确定,就衍生了兩個需要解決的問題:
1.在某個路由加載時,我們如何知道哪個store需要在服務端完成?2.多個資料如何加載到props中?
server層異步擷取
useEffect
既然需要在服務端擷取,是以在Index代碼中就可以注釋掉了。同時給Index寫一個loadData方法:
// src/container/Index.js
// ...
function Index(props){
const [count,setCount]=useState(1);
// useEffect(()=>{
// props.getIndexList();
// },[]);
return <div>
<h1>react ssr</h1>
<span>{count}</span><br/>
<button onClick={()=>{setCount(count+1)}}>+</button><hr/>
<ul>
{props.list.map((item,index)=>(
<li key={index}>{item.id}-{item.name}</li>
))}
</ul>
</div>
}
// 給元件傳遞一個方法
Index.loadData=(store)=>{
return store.dispatch(getIndexList());
}
// ...
複制
接下來看如何在server端擷取資料。
閱讀文檔:https://reacttraining.com/react-router/web/guides/server-rendering 的data loading部分:
There are so many different approaches to this, and there’s no clear best practice yet, so we seek to be composable with any approach, and not prescribe or lean toward one or the other. We’re confident the router can fit inside the constraints of your application.
The primary constraint is that you want to load data before you render. React Router exports the
matchPath
static function that it uses internally to match locations to routes. You can use this function on the server to help determine what your data dependencies will be before rendering.
The gist of this approach relies on a static route config used to both render your routes and match against before rendering to determine data dependencies.
關于資料在服務端加載,目前還沒有一個明确的最佳實踐。但思路都是通過配置路由來實作。你可以給路由傳遞一些元件的自定義的屬性(比如擷取資料的方法loadData)。這樣,你就可以在服務端拿到請求資料的方法了。
React Router提供了matchPath方法,可以在服務端内部用于将定向與路由比對。你可以在服務端上使用此方法來比對路由。此方法的要點在于:在請求拿到異步資料之前,基于靜态路由配置來實作路由比對。
接下來考慮路由擷取動态配置來實作路由,在這裡配置寫成像vue一樣:
// src/App.js
//...
// export default (
// <div>
// <Route exact path="/" component={Index} />
// <Route exact path="/about" component={About} />
// </div>
// );
// 改造成根據配置來實作路由
export default [
{
path:'/',
component:Index,
exact:true,
key:'index',
// 你甚至可以在這裡定義你的方法比如`loadData:Index.loadData`
// 但是這裡loadData已經是Index的屬性了。
},
{
path:'/about',
component:About,
exact:true,
key:'about'
}
]
複制
接下來在服務端應用
matchPath
方法:
// App實際上就是route
import { StaticRouter, matchPath,Route} from 'react-router-dom';
import routes from '../src/App';
// 監聽所有頁面
app.get('*', (req, res) => {
// 【總體思路】根據路由擷取到的元件,并且拿到loadData,擷取資料
// ------------
// 1.定義一個數組來存放所有網絡請求
const promises = [];
// 2.周遊來比對路由,
routes.forEach(route => {
// 3.通過 `matchPath` 判斷目前是否比對
const match = matchPath(req.path, route);
if (match) {
const { loadData } = route.component;
if (loadData) {
promises.push(loadData(store));
}
}
});
// 4.等待所有的請求結束後,再傳回渲染邏輯
Promise.all(promises).then(data => {
// do something w/ the data so the client
// react元件解析為html
const content = renderToString(
<Provider store={store}>
<StaticRouter location={req.url}>
{/*route此時是一個數組,是以需要map出來*/}
{routes.map(route => <Route {...route} />)}
</StaticRouter>
</Provider>
);
res.send(`
<html>
<head>
<meta charset="UTF-8">
<title>react ssr</title>
<body>
<div id="root">${content}</div>
<script src="bundle.js"></script>
</body>
</head>
</html>
`);
});
});
複制
此時需要注意的是,原來的App已經變成了一個數組,在用戶端也作如下修改:
// client/index.js
import store from '../src/store/store';
import routes from '../src/App';
const Page = (<Provider store={store}>
<BrowserRouter>
{routes.map(route => <Route {...route} />)}
</BrowserRouter>
</Provider>);
複制
自此,我們已經完成了在服務端擷取資料的工作。
store的區分
但是之前說過store也需要區分,分别供服務端和用戶端擷取使用。服務端如何告知前端,"我幫你把資料請求到了"呢?思路是在渲染模闆時,放到全局變量裡。
// 建立store
// const store = createStore(reducer, applyMiddleware(thunk));
// export default store;
// 服務端用
export const getServerStore=()=>{
return createStore(reducer, applyMiddleware(thunk));
}
export const getClientStore=()=>{
// 把初始狀态放到window.__context中,作為全局變量,以此來擷取資料。
const defaultState=window.__context?window.__context:{};
return createStore(reducer, defaultState,applyMiddleware(thunk));
}
複制
// server/index.js
import {getServerStore} from '../src/store/store';
const store=getServerStore();
//...
res.send(`<html>
<head>
<meta charset="UTF-8">
<title>react ssr</title>
<body>
<div id="root">${content}</div>
<script>window.__context=${JSON.stringify(store.getState())}</script>
<script src="bundle.js"></script>
</body>
</head>
</html>`);
複制
同理,用戶端也改造下:
// client/index.js
// ...
import {getClientStore} from '../src/store/store';
import {Route} from 'react-router-dom';
import routes from '../src/App';
const Page = (<Provider store={getClientStore()}>
<BrowserRouter>
{routes.map(route => <Route {...route} />)}
</BrowserRouter>
</Provider>);
// 用戶端
// 注水:不需render
ReacDom.hydrate(Page, document.querySelector('#root'));
複制
ok,再重新整理代碼:
發現内容都傳遞進來了。
引入公共元件
現在我們要在
src/component
下新增加一個Header,作為公用元件,它提供多個頁面下不同路由的導航跳轉功能。代碼如下:
import React from 'react';
import {Link} from 'react-router-dom';
function Header(){
return (<div>
<Link to='/'>首頁</Link>
<Link to='about'>關于</Link>
</div>)
}
export default Header;
複制
公共元件應當如何同構呢?
操作是幾乎一樣的:
// server/index.js
// ...
import Header from '../src/component/Header';
const content = renderToString(
<Provider store={store}>
<StaticRouter location={req.url}>
<Header/>
{routes.map(route => <Route {...route} />)}
</StaticRouter>
</Provider>
);
//...
複制
// client/index.js
// ...
import Header from '../src/component/Header';
const Page = (<Provider store={getClientStore()}>
<BrowserRouter>
<Header/>
{routes.map(route => <Route {...route} />)}
</BrowserRouter>
</Provider>);
複制
此時頁面是這樣的:
所有功能做好,就是新問題到來之時。
前後端統一資料請求
我們首次直接通路about路由,檢視源代碼,發現
__context
是空的。
這個很好了解,因為比對不到。這時再跳轉首頁。你發現清單加載不出來了。因為沒有用戶端并未執行網絡請求。
這個問題也很好解決,還記得最初注釋掉的
useEffect
嗎?再用戶端元件代碼中,當發現資料為空時,執行網絡請求即可。
function Index(props){
const [count,setCount]=useState(1);
// 增加用戶端請求判斷
useEffect(()=>{
if(!props.list.length){
props.getIndexList();
}
},[]);
return <div>
<h1>react ssr</h1>
<span>{count}</span><br/>
<button onClick={()=>{setCount(count+1)}}>+</button><hr/>
<ul>
{props.list.map((item,index)=>(
<li key={index}>{item.id}-{item.name}</li>
))}
</ul>
</div>
}
複制
問題就解決了。
新增User頁面
現在再快速把之前的邏輯重複操作一遍。
1.建立一個
User
元件,業務邏輯是:通過store展示使用者個人資訊。
import React ,{useState,useEffect} from 'react';
import {connect} from 'react-redux';
import {getUserInfo} from '../store/user';
function User(props){
useEffect(()=>{
if(!props.info.name){
props.getUserInfo();
}
},[]);
const {name,honor}=props.info;
return <div>
<h1>你好,{name},你目前的成就是:
<span style={{textDecoration:'underline'}}>{honor}</span>
</h1>
</div>
}
User.loadData=(store)=>{
return store.dispatch(getUserInfo());
}
export default connect(
state=>({info:state.user.info}),
{getUserInfo}
)(User);
複制
1.是以需要在store下建立一個
user.js
子產品:
import axios from 'axios';
// 定義actionType
const GET_INFO = 'INDEX/GET_USERINFO';
// actionCreator
const changeList = info => ({
type: GET_INFO,
info
});
// 異步的dispatchAction
export const getUserInfo = server => {
return (dispatch, getState, axiosInstance) => {
// 傳回promise
return axios.get('http://localhost:9001/user/info').then((res)=>{
const { info } = res.data;
dispatch(changeList(info));
});
}
}
// 初始狀态
const defaultState = {
info: {
name:'',
honor:''
}
}
export default (state = defaultState, action) => {
switch (action.type) {
case GET_INFO:
const newState = {
...state,
info: action.info
}
return newState;
default:
return state;
}
}
複制
1.然後我們在store.js中新增一個userReducer:
// store.js
// ...
import userReducer from './user';
const reducer = combineReducers({
index: indexReducer,
user:userReducer
});
複制
1.在路由中增加一個User路由:
// App.js
import User from './container/User';
export default [
// ...
{
path:'/user',
component:User,
exact:true,
key:'user'
}
]
複制
并在header更新:
function Header(){
return (<div>
<Link to='/'>首頁</Link>|
<Link to='/about'>關于</Link>|
<Link to='/user'>使用者</Link>
</div>)
}
複制
1.最後在mock.js新增一個接口:
// mock.js
app.get('/user/info',(req,res)=>{
// 支援跨域
res.header('Access-Control-Allow-Origin','*');
res.header('Access-Control-Methods','GET,POST,PUT,DELETE');
res.header('Content-Type','application/json;charset=utf-8');
res.json({
code:0,
info:{
name:'黨某某',
honor:'首席背鍋工程師'
}
});
});
複制
此時看到的頁面是
容錯處理
容錯處理的關鍵在于:找到報錯的地方。
先來看場景:
react-router可以精确比對,也可以非精确比對,在App.js中,如果注釋掉
exact:true
:
export default [
{
path:'/',
component:Index,
// exact:true,
key:'index'
},
{
path:'/user',
component:User,
exact:true,
key:'user'
}
]
複制
将會非精确比對,你會看到兩個頁面。
假設mockjs中,前端把擷取使用者資訊的接口誤寫為:
http://localhost:9001/user/info1
,這時應定位到server.js中的promise.all方法。是以設定一個catch即可。
Promise.all(promises).then(data=>{
//...
}).catch(e=>{
res.send(`錯誤:${e}`);
});
複制
那麼通路user路由:
然而,問題來了。
思考題:
既然index是非精确比對,接口也沒有寫錯。為什麼要全部渲染為err?理想的效果是:Index正常顯示,User報錯的内容單獨顯示。是否存在解決方法?
以下是我的解決方案:
留意到在store/user.js下
getUserInfo
,單獨捕獲axios錯誤後,頁面不再報錯。是以考慮在catch中傳回錯誤資訊:
// 異步的dispatchAction
export const getUserInfo = server => {
return (dispatch, getState, axiosInstance) => {
// 傳回promise
return axios.get('http://localhost:9001/user/info1').then((res) => {
const { info } = res.data;
console.log('info', info);
dispatch(getInfo(info));
}).catch(e=>{
// 容錯
return dispatch(getInfo({
errMsg:e.message
}));
})
}
}
複制
然後在元件中增加容錯選項,以user為例:
function User(props){
// 容錯處理
if(props.info.errMsg){
return <h1>{props.info.errMsg}</h1>
}
useEffect(()=>{
if(!props.info.name){
props.getUserInfo();
}
},[]);
const {name,honor}=props.info;
return <div>
<h1>你好,{name},你目前的成就是:
<span style={{textDecoration:'underline'}}>{honor}</span>
</h1>
</div>
}
複制
實作效果如下:
所有元件對loadData處理後,不再需要在PromiseAll中處理。
複用處理:
•考慮到catch中邏輯一緻,可以用一個通用方法統一封裝傳回的報錯内容使之健壯。
// ...
.catch(err=>{
handleErr(err);
})
複制
•留意到所有元件都在一開始前判斷,考慮用一個高階元件封裝原來的所有元件。j簡易代碼如下:
function Wrap(props,component){
if(props.errMsg){
return <Error errMsg={props.errMsg} />
}
return component
}
複制
實作從略。