React同构漫谈

作者:Jiang, Jilin


同构指的是相同代码可以同时在客户端与服务端同时渲染的技术,利用服务器资源对用户请求进行预渲染,而客户端仍然保持SPA特性。本文将从实际项目出发,谈谈开发过程中遇到的问题以及解决方案。

 

在开始阅读本文之前,你需要有一定的react同构基本概念。如果尚未接触过同构,建议先参考一些相关的同构项目:

·        https://github.com/RickWong/react-isomorphic-starterkit

·        https://github.com/kriasoft/react-starter-kit

 

React同构主要分成以下几个步骤:

·        服务端将请求交由React Router解析

·        React Router生成页面布局

·        服务端将生成结果文本化返回给客户端

·        客户端由React Router生成页面布局

·        React将其与服务端布局进行对比

·        对比成功,复用当前页面;反之则重新渲染


利用local apicall响应更快的特点,可以减少总体的页面可用性等待时间。




或许你会疑惑,为什么React在客户端还需要再次进行渲染并验证。这是需要分成两个问题来分别讨论。

 

1. 为何需要再次渲染?

React中,组件通过Props和State来决定组件表现。例如,我们现在有一个Checkbox组件:


const CheckBox = ({ title, checked }) => (
   <label>
      <input type="checkbox" checked={checked} />
      {title}
   </label>
);


通过服务端渲染转换成dom element后将会丢失virtual dom结构信息:

<label><input type="checkbox" checked="checked" />Hello World</label>

因此,为了React能够在客户端正常运行。客户端也需要进行一次渲染,构建出virtualdom结构。

 

2. 为何需要验证?

既然在客户端和服务端都进行了渲染,那么就有可能存在前后端渲染出来的结构不同步的情况(之后会给出例子)。当出现这种情况时,为了保证单页应用能够正常工作,React总是会以客户端渲染为准。

当验证后,发现当前的页面元素相匹配。React便会跳过virtualdom -> real dom的过程,直接复用已有元素,从而加快了页面构建速度。反之,只能抛弃服务端的渲染内容。从新创建页面:


好了,在大部分的演讲中。同构似乎就这么简单,了解了基本流程,然后改造上线,同构完成了。其实不然,这仅仅是一个开始。你需要在开发过程中不断复现出以上的渲染流程,才能保证在服务端和客户端的控制台中不打印出讨厌的warning信息。那么,你会遇到什么问题呢?

 

1. 保持数据同步

在实际同构中,你需要保证服务端与客户端共享相同的数据集合才能生成出相同的virtual dom。在我的开发中,通过使用redux进行数据管理。在渲染完成后,将store的内容通过js传递给客户端:


const store = createStore(reducers, { ... });

const App = (
   <StaticRouter location={req.url} context={context}>
      <Provider store={store}>
         <Main />
      </Provider>
   </StaticRouter>
);

const componentHTML = renderToString(App);

res.end(
`<!DOCTYPE html>
<html>
<head>
   ...
</head>
<body>
   <div id="root">${componentHTML}</div>
   <script>
      window.__INITIAL_STATE__ = ${store.getState()}; 
   </script>
</body>
</html>`);

如果你开发过大型单页应用,你可能已经发现了问题。在实际的项目中,我们不会一下子便初始化store的所有内容。例如购物车页面不会需要管理你的好友信息,优惠券页面不需要你的支付信息等等。我们会将store进行部分初始化,将大部分页面通用的内容进行填充。但是对于剩余内容,在页面打开后才进行数据请求:

class UserInfo extends React.Component {
   componentWillMount() {
      const { user, userInfo } = this.props;

      if (!userInfo) {
         dispatch(loadUser(user));
      }
   }

   ...
}


当页面存在异步请求的时候,你会发现同构变成了一团乱麻。用户访问的页面在渲染给客户端的时候,需要state还为填充,以至于客户端需要再次发起api请求。同时,服务端的这次请求白白浪费了。

更甚者是,当数据存在依赖关系。页面在渲染时需要多次有序api请求时,你自然而然会想到一种解决方案:路由表

 

1.1 路由表

思路非常简单,在数据初始化之前。我们让用户访问的url进行一次路由表匹配。从而填充需要的state信息:

const ROUTER_TABLE = {
   '/user': [loadUser],
   '/user/info': [loadUser, loadUserInfo],
   '/shoppingCart': [loadUser, loadShoppingCart],
   ... 
};

然而问题在于,随着页面的增多,以及相关的页面逻辑更改。你总是需要同时维护两份数据依赖逻辑(服务端和客户端),同构并没有解放你的双手。

接着,你会开始尝试寻找可以前后端通用的解决方案:Promise队列

 

1.2 Promise队列

对于服务端渲染,我们需要解决的是在返回用户web content之前,等待所有的异步api完成。因而我们需要监视当前的渲染的api状态。同时,由于存在数据依赖。我们需要循环监听api请求,直到队列中没有额外的请求:


这里,我们就不得不提到React的context属性。Context允许你在组件之间传递共享数据和方法,而不需要经过props传递。因而当你在Top component中注册了promiseListener后,所有子组件都可以将异步promise置于其中。


class Main extends React.Component {
   getChildContext() {
      const { promiseList } = this.props;
      return {
         addIsomorphicPromise: promise => {
            if (promiseList) promiseList.push(promise);
         }, 
      };
   }

   ...
}
...

根组件Main接受一个promiseList属性,并提供全局的addIsomorphicPromise方法。当子组件/页面发起请求时。我们将promise放入list之中。由于仅有服务端渲染会用到promise队列,当props中没有promiseList(客户端)则不添加。

接着,我们简单改造一下dispatch的过程:


class UserInfo extends React.Component {
   componentWillMount() {
      const { user, userInfo } = this.props;
      const { addIsomorphicPromise } = this.context;

      if (!userInfo) {
         addIsomorphicPromise(dispatch(loadUser(user)));
      }
   }

   ...
}
...

注:这里使用了redux-thunk对action进行封装,返回值为fetch promise。

 

最后,在server端编写递归方法:

function loopRender($app, promiseList) {
   promiseList.splice(0);

   return new Promise((resolve, reject) => {
      const componentHTML = renderToString($app);

      if (promiseList.length === 0) {
         resolve(renderToString(componentHTML)); 
      } else {
         Promise.all(promiseList).then(() => {
            resolve(loopRender($app, promiseList));
         }).catch((err) => {
            reject(err);
         });
      }
   });
}

此外,在实际开发过程中,还需要做递归次数限制以防止逻辑错误导致遗漏添加promise导致store未更新产生的死循环。同时,如果你的页面存在通过store动态构建的子组件/页面嵌套dispatch,那么在promiseList为空时还需要额外的一次rendercheck以防止页面渲染未是最终态。

 

经过以上改造,你的页面代码已经实现了数据加载的复用。但是,并非所有情况下。你都需要让服务端完全渲染完毕页面再返回给用户。你需要适当地对数据请求进行拆分以到达速度响应与可用性的平衡:

                                                    (部分依赖数据后置)




将页面的基本组成进行服务端渲染后,部分内容提供载入动画以达到用户体验的平衡。我们通过使用React组件的2个生命周期方法组合可以实现这个效果:

 

componentWillMount

上文已经提到过。使用该方法实现同构的数据请求。

 

 

componentDidMount

该方法仅会在客户端触发,因而在该方法中进行数据请求不会在服务端触发。从而达到数据拆分的效果。


class Sample extends React.Component {
   componentWillMount() {
      const { data1 } = this.props;

      if (!data1) {
         dispatch(loadData1());
      }
   }

   componentDidMount() {
      const { data2 } = this.props;

      if (!data2) {
         dispatch(loadData2());
      }
   }

   ...
}

当搞定这些,你的同构代码离work更近了一步。记得在上文,我们提到过。如果服务端和客户端的virtual dom tree不同步时,总会以客户端为准。然而当准备完这些内容,我们仍然会在console中看到warning信息。为什么呢?我们需要再从redux说起。

 

按需加载的得与失

我们将应用内的数据拆分在多个reduxstate中,当用户访问不同页面的时候,通过componentWillMount和componentDidMount异步加载。当我们的component state数据有部分来自于redux state的延伸数据。我们需要额外做一次处理。

 

1. 页面初次载入

代码非常好理解,数据fetch完毕后setState更新组件:

componentWillMount() {
   const { dispatch, data1 } = this.props;
   const { addIsomorphicPromise } = this.context;

   if (!data1) {
      addIsomorphicPromise.push(dispatch(loadData1()).then(() => {
         this.setState({
              ...
           });
      }));
   }
}

2. 页面再次载入

当第二次打开页面时,由于不会再请求数据,从而我们需要额外调用setState。不过好在,简单做一下封装变可以省去在constructor和componentWillMount重复出现setState:

componentWillMount() {
   const { dispatch, data1 } = this.props;
   const { addIsomorphicPromise } = this.context;

   if (!data1) {
      addIsomorphicPromise.push(dispatch(loadData1()).then(() => {
         this.doSomeUpdate();
      }));
   } else {
      this.doSomeUpdate();
   }
}

doSomeUpdate = () => {
   const { data1 } = this.props;
   this.setState({
      ...
   });
};

3. componentWillReciveProps?

好吧,这不是一个推荐的做法,但是我也同样把它列在这里。如果你对React组件的生命周期方法很熟悉的话。你会很容易想起有一个componentWillReceiveProps方法。该方法在props更新时会调用。所以,如果我们想偷懒,可以直接监听Propsupdate然后再setState来更新组件的state。

但是大部分情况下,我们的组件不会只接收一个prop:


<MyComponent prop1={prop1} prop2={prop2} prop3={prop3} />

当存在多个props时,我们需要对props进行检查。省略没有必要的更新:

componentWillReceiveProps(nextProps) {
   if (nextProps.prop1 !== this.props.prop1) {
      this.setState({
         ...
      });
   }
}

(什么?你无所谓性能?当我什么都没有……)

 

来自服务端的warning

好了,当你完成这份代码。你会发现在控制台会打印出警告信息。


Warning: setState(...): Can only update a mounting component. This usually means you called setState() outside componentWillMount() on the server. This is a no-op. Please check the code for the Configuration component.

为何会发生这种情况呢?原因在于,当你在服务端渲染renderToString时,virtual dom tree已经完成渲染。这时当异步请求完成,调用setState已经无法生效。

 

对此,我们需要对环境进行检查:

export const isClient = !!(
   typeof window !== 'undefined' &&
   window.document &&
   window.document.createElement
);

如果是异步更新,那么只有处于client端才进行更新:

componentWillMount() {
   const { dispatch, data1 } = this.props;
   const { addIsomorphicPromise } = this.context;

   if (!data1) {
      addIsomorphicPromise.push(dispatch(loadData1()).then(() => {
         if (isClient) this.doSomeUpdate();
      }));
   } else {
      this.doSomeUpdate();
   }
}


好了,当完成这些内容后。开始享受你的同构之旅吧!



  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值