21.同构应用中你所忽略的细节
不管是服务端渲染还是服务端渲染衍生出的同构应用,现在来看已经并不新鲜了,实现起来也并不困难。可是有的开发者认为:同构应用不就是调用一个 renderToString(React 中)类似的 API 吗?
讲道理确实是这样的,但是讲道理你也许并没有真正在实战中领会同构应用的精髓。
同构应用能够完成的本质条件是虚拟 DOM,基于虚拟 DOM 我们可以生成真实的 DOM,并由浏览器渲染;也可以调用不同框架的不同 APIs,将虚拟 DOM 生成字符串,由服务端传输给客户端。
但是同构应用也不只是这么简单。拿面试来说,同构应用的考察点不是「纸上谈兵」的理论,而是实际实施时的细节。这一讲我们就来聊一聊「同构应用中往往被忽略的细节」,需要读者提前了解服务端渲染和同构应用的概念。
相关知识点如下:
打包环境区分
第一个细节:我们知道同构应用实现了客户端代码和服务端代码的基本统一,我们只需要编写一种组件,就能生成适用于服务端和客户端的组件案例。可是你是否知道,服务端代码和客户端代码大多数情况下还是需要单独处理?比如:
- 路由代码差别:服务端需要根据请求路径,匹配页面组件;客户端需要通过浏览器中的地址,匹配页面组件。
客户端代码:
const App = () => {
return (
<Provider store={store}>
<BrowserRouter>
<Route path="/" component={Home} />
<Route path="/product" component={Product} />
</BrowserRouter>
</Provider>
);
};
ReactDom.render(<App />, document.querySelector('#root'));
BrowserRouter 组件根据 window.location 以及 history API 实现页面切换,而服务端肯定是无法获取 window.location 的,服务端代码如下:
const App = () => {
return <Provider store={store}>
<StaticRoute location={req.path} context={context}>
<Route path="/" component={Home}/>
</StaticRoute>
</Provider>
}
return ReactDom.renderToString()
需要使用 StaticRouter 组件,并将请求地址和上下文信息作为 location 和 context 这两个 props 传入 StaticRouter 中。
- 打包差别:服务端运行的代码如果需要依赖 Node 核心模块或者第三方模块,就不再需要把这些模块代码打包到最终代码中了。因为环境已经安装这些依赖,可以直接引用。这样一来,就需要我们在 webpack 中配置:target:node,并借助 webpack-node-externals 插件,解决第三方依赖打包的问题。
- 对于图片等静态资源,url-loader 会在服务端代码和客户端代码打包过程中分别被引用,因此会在资源目录中生成了重复的文件。当然后打包出来的因为重名,会覆盖前一次打包出来的结果,并不影响使用,但是整个构建过程并不优雅。
由于路由在服务端和客户端的差别,因此 webpack 配置文件的 entry 会不相同:
{
entry: './src/client/index.js',
}
{
entry: './src/server/index.js',
}
注水和脱水
什么叫做注水和脱水呢?这个和同构应用中数据的获取有关:在服务器端渲染时,首先服务端请求接口拿到数据,并处理准备好数据状态(如果使用 Redux,就是进行 store 的更新),为了减少客户端的请求,我们需要保留住这个状态。一般做法是在服务器端返回 HTML 字符串的时候,将数据 JSON.stringify 一并返回,这个过程,叫做脱水(dehydrate);在客户端,就不再需要进行数据的请求了,可以直接使用服务端下发下来的数据,这个过程叫注水(hydrate)。用代码来表示:
ctx.body = `
<meta charset="UTF-8">
<script>
window.context = {
initialState: ${JSON.stringify(store.getState())}
}
</script>
<div id="app">
//...
</div>
`;
客户端:
export const getClientStore = () => {
const defaultState = JSON.parse(window.context.state)
return createStore(reducer, defaultState, applyMiddleware(thunk))
}
这一系列过程非常典型,但是也会有几个细节值得探讨:在服务端渲染时,服务端如何能够请求所有的 APIs,保障数据全部已经请求呢?
一般有两种方法:
- react-router 的解决方案是配置路由 route-config,结合 matchRoutes,找到页面上相关组件所需的请求接口的方法并执行请求。这就要求开发者通过路由配置信息,显式地告知服务端请求内容。
在服务端代码中:
const routes = [
{
path: '/',
component: Root,
loadData: () => getSomeData(),
},
// etc.
];
import { routes } from './routes';
function App() {
return (
<Switch>
{routes.map((route) => (
<Route {...route} />
))}
</Switch>
);
}
在服务端代码中:
import { matchPath } from "react-router-dom"
const promises = []
routes.some(route => {
const match = matchPath(req.path, route)
if (match) promises.push(route.loadData(match))
return match
})
Promise.all(promises).then(data => {
putTheDataSomewhereTheClientCanFindIt(data)
})
- 另外一种思路类似 Next.js,我们需要在 React 组件上定义静态方法。 比如定义静态 loadData 方法,在服务端渲染时,我们可以遍历所有组件的 loadData,获取需要请求的接口。这样的方式借鉴了早期 React-apollo 的解决方案,我个人很喜欢这种设计。这里贴出我为 Facebook 团队 react-apollo 开源项目贡献的改动代码,其目的就是遍历组件,获取请求接口:
注水和脱水,是同构应用最为核心和关键的细节点。