前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >react 同构初步(3)

react 同构初步(3)

作者头像
一粒小麦
发布2019-12-19 14:44:18
1.5K0
发布2019-12-19 14:44:18
举报
文章被收录于专栏:一Li小麦
这是一个即时短课程的系列笔记。本笔记系列进度已更新到:https://github.com/dangjingtao/react-ssr

服务端数据的异步获取

上节的代码中,存在一个问题。在浏览器右键审查网页源代码,看到的代码是这样的:

后端ssr只是渲染了网页模板(ul),列表(li)的html都是异步请求加载出来的。再回看首页列表的代码:

代码语言:javascript
复制
// 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的初始值

代码语言:javascript
复制
// 创建store
const store = createStore(reducer,初始值, applyMiddleware(thunk));

createStore可以插入第二个参数,放入初始值,因此考虑把获取初始值放到server端去做。此时服务端和客户端的store已经分离

思路既已确定,就衍生了两个需要解决的问题:

1.在某个路由加载时,我们如何知道哪个store需要在服务端完成?2.多个数据如何加载到props中?

server层异步获取

useEffect既然需要在服务端获取,所以在Index代码中就可以注释掉了。同时给Index写一个loadData方法:

代码语言:javascript
复制
// 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一样:

代码语言:javascript
复制
// 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方法:

代码语言:javascript
复制
// 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已经变成了一个数组,在客户端也作如下修改:

代码语言:javascript
复制
// 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也需要区分,分别供服务端和客户端获取使用。服务端如何告知前端,"我帮你把数据请求到了"呢?思路是在渲染模板时,放到全局变量里。

代码语言:javascript
复制
// 创建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));
}
代码语言:javascript
复制
// 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>`);

同理,客户端也改造下:

代码语言:javascript
复制
// 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,作为公用组件,它提供多个页面下不同路由的导航跳转功能。代码如下:

代码语言:javascript
复制
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;

公共组件应当如何同构呢?

操作是几乎一样的:

代码语言:javascript
复制
// 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>
);
//...
代码语言:javascript
复制
// 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吗?再客户端组件代码中,当发现数据为空时,执行网络请求即可。

代码语言:javascript
复制
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展示用户个人信息。

代码语言:javascript
复制
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模块:

代码语言:javascript
复制
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:

代码语言:javascript
复制
// store.js
// ...
import userReducer from './user';

const reducer = combineReducers({
    index: indexReducer,
    user:userReducer
});

1.在路由中增加一个User路由:

代码语言:javascript
复制
// App.js
import User from './container/User';

export default [
    // ...
    {
        path:'/user',
        component:User,
        exact:true,
        key:'user'
    }
]

并在header更新:

代码语言:javascript
复制
function Header(){
    return (<div>
        <Link to='/'>首页</Link>|
        <Link to='/about'>关于</Link>|
        <Link to='/user'>用户</Link>
    </div>)
}

1.最后在mock.js新增一个接口:

代码语言:javascript
复制
// 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

代码语言:javascript
复制
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即可。

代码语言:javascript
复制
Promise.all(promises).then(data=>{
  //...
}).catch(e=>{
   res.send(`错误:${e}`);
});

那么访问user路由:

然而,问题来了。

思考题:

既然index是非精确匹配,接口也没有写错。为什么要全部渲染为err?理想的效果是:Index正常显示,User报错的内容单独显示。是否存在解决方法?

以下是我的解决方案:

留意到在store/user.js下getUserInfo,单独捕获axios错误后,页面不再报错。因此考虑在catch中返回错误信息:

代码语言:javascript
复制
// 异步的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为例:

代码语言:javascript
复制
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中逻辑一致,可以用一个通用方法统一封装返回的报错内容使之健壮。

代码语言:javascript
复制
// ...
.catch(err=>{
  handleErr(err);
})

•留意到所有组件都在一开始前判断,考虑用一个高阶组件封装原来的所有组件。j简易代码如下:

代码语言:javascript
复制
function Wrap(props,component){
  if(props.errMsg){
    return <Error errMsg={props.errMsg} />
  }
  return component
}

实现从略。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2019-12-15,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 一Li小麦 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 服务端数据的异步获取
    • server层异步获取
      • store的区分
      • 引入公共组件
      • 前后端统一数据请求
      • 新增User页面
      • 容错处理
      • 思考题:
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档