作者: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 }) => (
);
通过服务端渲染转换成dom element后将会丢失virtual dom结构信息:
因此,为了React能够在客户端正常运行。客户端也需要进行一次渲染,构建出virtualdom结构。
2. 为何需要验证?
既然在客户端和服务端都进行了渲染,那么就有可能存在前后端渲染出来的结构不同步的情况(之后会给出例子)。当出现这种情况时,为了保证单页应用能够正常工作,React总是会以客户端渲染为准。
当验证后,发现当前的页面元素相匹配。React便会跳过virtualdom -> real dom的过程,直接复用已有元素,从而加快了页面构建速度。反之,只能抛弃服务端的渲染内容。从新创建页面:
好了,在大部分的演讲中。同构似乎就这么简单,了解了基本流程,然后改造上线,同构完成了。其实不然,这仅仅是一个开始。你需要在开发过程中不断复现出以上的渲染流程,才能保证在服务端和客户端的控制台中不打印出讨厌的warning信息。那么,你会遇到什么问题呢?
1. 保持数据同步
在实际同构中,你需要保证服务端与客户端共享相同的数据集合才能生成出相同的virtual dom。在我的开发中,通过使用redux进行数据管理。在渲染完成后,将store的内容通过js传递给客户端:
const store = createStore(reducers, { ... });
const App = (
);
const componentHTML = renderToString(App);
res.end(
`
...
${componentHTML}
`);
如果你开发过大型单页应用,你可能已经发现了问题。在实际的项目中,我们不会一下子便初始化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:
当存在多个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();
}
}
好了,当完成这些内容后。开始享受你的同构之旅吧!