不论是服务端渲染仍是服务端渲染衍生出的同构应用,如今来看已经并不新鲜了,实现起来也并不困难。可是社区上相关文章质量参差不齐,不少只是“纸上谈兵”,甚至有的开发者认为:同构应用不就是调用一个 renderToString(React 中)相似的 API 吗?javascript
讲道理确实是这样的,可是讲道理你也许并无真正在实战中领会同构应用的精髓。css
同构应用可以实现的本质条件是虚拟 DOM,基于虚拟 DOM 咱们能够生成真实的 DOM,并由浏览器渲染;也能够调用不一样框架的不一样 APIs,将虚拟 DOM 生成字符串,由服务端传输给客户端。html
可是同构应用也不仅是这么简单,它涉及到 NodeJS 层构建应用的方方面面。拿面试来讲,同构应用的考察点不是“纸上谈兵”的理论,而是实际实施时的细节。今天咱们就来聊一聊“同构应用工程中每每被忽略的细节”,须要读者提早了解服务端渲染和同构应用的概念。前端
相关知识点以下:java
第一个细节:咱们知道同构应用实现了客户端代码和服务端代码的基本统一,咱们只须要编写一种组件,就能生成适用于服务端和客户端的组件案例。但是你是否知道,服务端代码和客户端代码大多数状况下仍是须要单独处理?好比:node
来看一个例子,客户端代码:react
const App = () => { return ( <Provider store={store}> <BrowserRouter> <div> <Route path='/' component={Home}> <Route path='/product' component={Product}> </div> </BrowserRouter> </Provider> ) } ReactDom.render(<App/>, document.querySelector('#root'))
BrowserRouter 组件根据 window.location 以及 history API 实现页面切换,而服务端确定是没法获取 window.location 的,服务端代码以下:webpack
const App = () => { return <Provider store={store}> <StaticRouter location={req.path} context={context}> <div> <Route path='/' component={Home}> </div> </StaticRouter> </Provider> } Return ReactDom.renderToString(<App/>)
须要使用 StaticRouter 组件,并将请求地址和上下文信息做为 location 和 context 这两个 props 传入 StaticRouter 中。git
{ entry: './src/client/index.js', } { entry: './src/server/index.js', }
第二个细节很是重要,涉及到数据的预获取。也是服务端渲染的真正意义。web
什么叫作注水和脱水呢?这个和同构应用中数据的获取有关:在服务器端渲染时,首先服务端请求接口拿到数据,并处理准备好数据状态(若是使用 Redux,就是进行 store 的更新),为了减小客户端的请求,咱们须要保留住这个状态。通常作法是在服务器端返回 HTML 字符串的时候,将数据 JSON.stringify 一并返回,这个过程,叫作脱水(dehydrate);在客户端,就再也不须要进行数据的请求了,能够直接使用服务端下发下来的数据,这个过程叫注水(hydrate)。用代码来表示:
服务端:
ctx.body = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> </head> <body> <script> window.context = { initialState: ${JSON.stringify(store.getState())} } </script> <div id="app"> // ... </div> </body> </html> `
客户端:
export const getClientStore = () => { const defaultState = JSON.parse(window.context.state) return createStore(reducer, defaultState, applyMiddleware(thunk)) }
这一系列过程很是典型,可是也会有几个细节值得探讨:在服务端渲染时,服务端如何可以请求全部的数据请求 APIs,保障数据所有已经预先加载了呢?
通常有两种方法:
咱们首先配置路由:
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) })
好比定义静态 loadData 方法,在服务端渲染时,咱们能够遍历全部组件的 loadData,获取须要请求的接口。这样的方式借鉴了早期 React-apollo 的解决方案,我我的很喜欢这种设计。这里贴出我为 Facebook 团队著名的 react-graphQl-apollo 开源项目贡献的改动代码,其目的就是遍历组件,获取请求接口:
function getPromisesFromTree({ rootElement, rootContext = {}, }: PromiseTreeArgument): PromiseTreeResult[] { const promises: PromiseTreeResult[] = []; walkTree(rootElement, rootContext, (_, instance, context, childContext) => { if (instance && hasFetchDataFunction(instance)) { const promise = instance.fetchData(); if (isPromise<Object>(promise)) { promises.push({ promise, context: childContext || context, instance }); return false; } } }); return promises; } // Recurse a React Element tree, running visitor on each element. // If visitor returns `false`, don't call the element's render function // or recurse into its child elements. export function walkTree( element: React.ReactNode, context: Context, visitor: ( element: React.ReactNode, instance: React.Component<any> | null, context: Context, childContext?: Context, ) => boolean | void, ) { if (Array.isArray(element)) { element.forEach(item => walkTree(item, context, visitor)); return; } if (!element) { return; } // A stateless functional component or a class if (isReactElement(element)) { if (typeof element.type === 'function') { const Comp = element.type; const props = Object.assign({}, Comp.defaultProps, getProps(element)); let childContext = context; let child; // Are we are a react class? if (isComponentClass(Comp)) { const instance = new Comp(props, context); // In case the user doesn't pass these to super in the constructor. // Note: `Component.props` are now readonly in `@types/react`, so // we're using `defineProperty` as a workaround (for now). Object.defineProperty(instance, 'props', { value: instance.props || props, }); instance.context = instance.context || context; // Set the instance state to null (not undefined) if not set, to match React behaviour instance.state = instance.state || null; // Override setState to just change the state, not queue up an update // (we can't do the default React thing as we aren't mounted // "properly", however we don't need to re-render as we only support // setState in componentWillMount, which happens *before* render). instance.setState = newState => { if (typeof newState === 'function') { // React's TS type definitions don't contain context as a third parameter for // setState's updater function. // Remove this cast to `any` when that is fixed. newState = (newState as any)(instance.state, instance.props, instance.context); } instance.state = Object.assign({}, instance.state, newState); }; if (Comp.getDerivedStateFromProps) { const result = Comp.getDerivedStateFromProps(instance.props, instance.state); if (result !== null) { instance.state = Object.assign({}, instance.state, result); } } else if (instance.UNSAFE_componentWillMount) { instance.UNSAFE_componentWillMount(); } else if (instance.componentWillMount) { instance.componentWillMount(); } if (providesChildContext(instance)) { childContext = Object.assign({}, context, instance.getChildContext()); } if (visitor(element, instance, context, childContext) === false) { return; } child = instance.render(); } else { // Just a stateless functional if (visitor(element, null, context) === false) { return; } child = Comp(props, context); } if (child) { if (Array.isArray(child)) { child.forEach(item => walkTree(item, childContext, visitor)); } else { walkTree(child, childContext, visitor); } } } else if ((element.type as any)._context || (element.type as any).Consumer) { // A React context provider or consumer if (visitor(element, null, context) === false) { return; } let child; if ((element.type as any)._context) { // A provider - sets the context value before rendering children ((element.type as any)._context as any)._currentValue = element.props.value; child = element.props.children; } else { // A consumer child = element.props.children((element.type as any)._currentValue); } if (child) { if (Array.isArray(child)) { child.forEach(item => walkTree(item, context, visitor)); } else { walkTree(child, context, visitor); } } } else { // A basic string or dom element, just get children if (visitor(element, null, context) === false) { return; } if (element.props && element.props.children) { React.Children.forEach(element.props.children, (child: any) => { if (child) { walkTree(child, context, visitor); } }); } } } else if (typeof element === 'string' || typeof element === 'number') { // Just visit these, they are leaves so we don't keep traversing. visitor(element, null, context); } }
可是一个重要细节是:以 Next.js 为例,getInitialData 的方法必需要注册在根组件 App 当中。这样作的目的在于减小子孙组件的渲染。由于若是子孙组件也注入了 getInitialData 方法,那么若是不进行渲染,天然也就没法收集到该子孙组件 getInitialData 方法。
也就是说,基于 walkTree 的方案或者其余非配置化方案,咱们都须要在服务端渲染两次。第一次的目的在于收集请求,第二次才是 renderToString 获得真正的渲染结果。
咱们项目中的整个 isomorphic 过程能够简化为:
更多内容因为敏感性,再也不展开。
使人期待的 React.suspense 能够解决 double rendering 的问题,但你知道原理是什么吗?后续我会写文章分析,欢迎关注~
注水和脱水,是同构应用最为核心和关键的细节点。
上面讲到服务端预先请求数据,那么思考这样的场景:某个请求依赖 cookie 代表的用户信息,好比请求“个人学习计划列表”。这种状况下服务端请求是不一样于客户端的,不会有浏览器添加 cookie 以及不含邮其余相关的 header 信息。这个请求在服务端发送时,必定不会拿到预期的结果。
为了解决这个问题,咱们来看看 React-apollo 的解决方法:
import { ApolloProvider } from 'react-apollo' import { ApolloClient } from 'apollo-client' import { createHttpLink } from 'apollo-link-http' import Express from 'express' import { StaticRouter } from 'react-router' import { InMemoryCache } from "apollo-cache-inmemory" import Layout from './routes/Layout' // Note you don't have to use any particular http server, but // we're using Express in this example const app = new Express(); app.use((req, res) => { const client = new ApolloClient({ ssrMode: true, // Remember that this is the interface the SSR server will use to connect to the // API server, so we need to ensure it isn't firewalled, etc link: createHttpLink({ uri: 'http://localhost:3010', credentials: 'same-origin', headers: { cookie: req.header('Cookie'), }, }), cache: new InMemoryCache(), }); const context = {} // The client-side App will instead use <BrowserRouter> const App = ( <ApolloProvider client={client}> <StaticRouter location={req.url} context={context}> <Layout /> </StaticRouter> </ApolloProvider> ); // rendering code (see below) })
这个作法也很是简单,原理是:服务端请求时须要保留客户端页面请求的信息,并在 API 请求时携带并透传这个信息。上述代码中,createHttpLink 方法调用时:
headers: { cookie: req.header('Cookie'), },
这个配置项就是关键,它使得服务端的请求完整地还原了客户端信息,所以验证类接口也再也不会有问题。
事实上,不少早期 React 完成服务端渲染的轮子好比 React-universally 都借鉴了 React-apollo 众多优秀思想,对这个话题感兴趣的读者能够抽空去了解 React-apollo。
同构应用的样式处理容易被开发者所忽视,而一旦忽略,就会掉到坑里。好比,正常的服务端渲染只是返回了 HTML 字符串,样式须要浏览器加载完 CSS 后才会加上,这个样式添加的过程就会形成页面的闪动。
再好比,咱们不能再使用 style-loader 了,由于这个 webpack loader 会在编译时将样式模块载入到 HTML header 中。可是在服务端渲染环境下,没有 window 对象,style-loader 进而会报错。通常咱们换用 isomorphic-style-loader 来实现:
{ test: /\.css$/, use: [ 'isomorphic-style-loader', 'css-loader', 'postcss-loader' ], }
同时 isomorphic-style-loader 也会解决页面样式闪动的问题。它的原理也不难理解:在服务器端输出 html 字符串的同时,也将样式插入到 html 字符串当中,将结果一同传送到客户端。
isomorphic-style-loader 具体作了什么呢,他是如何实现的?
咱们知道对于 webpack 来讲,全部的资源都是模块,webpack loader 在编译过程当中能够将导入的 CSS 文件转换成对象,拿到样式信息。所以 isomorphic-style-loader 能够获取页面中全部组件样式。为了实现的更加通用化,isomorphic-style-loader 利用 context API,在渲染页面组件时获取全部 React 组件的样式信息,最终插入到 HTML 字符串中。
在服务端渲染时,咱们须要加入这样的逻辑:
import express from 'express' import React from 'react' import ReactDOM from 'react-dom' import StyleContext from 'isomorphic-style-loader/StyleContext' import App from './App.js' const server = express() const port = process.env.PORT || 3000 // Server-side rendering of the React app server.get('*', (req, res, next) => { const css = new Set() // CSS for all rendered React components const insertCss = (...styles) => styles.forEach(style => css.add(style._getCss())) const body = ReactDOM.renderToString( <StyleContext.Provider value={{ insertCss }}> <App /> </StyleContext.Provider> ) const html = `<!doctype html> <html> <head> <script src="client.js" defer></script> <style>${[...css].join('')}</style> </head> <body> <div id="root">${body}</div> </body> </html>` res.status(200).send(html) }) server.listen(port, () => { console.log(`Node.js app is running at http://localhost:${port}/`) })
咱们定义了 css Set 类型来存储页面全部的样式,并定义了 insertCss 方法,该方法经过 context 传给每一个 React 组件,这样每一个组件在服务端渲染阶段就能够调用 insertCss 方法。该方法调用时,会将组件样式加入到 css Set 当中。
最后咱们用 [...css].join('') 就能够获取页面的全部样式字符串。
强调一下,isomorphic-style-loader 的源码目前已经更新,采用了最新的 React hooks API,我推荐给 React 开发者阅读,相信必定收获不少!
React 应用中,骨架每每相似:
const App = () => { return ( <div> <Component1 /> <Component2 /> </div> ) } ReactDom.render(<App/>, document.querySelector('#root'))
App 组件嵌入到 document.querySelector('#root') 节点当中,通常是不包含 head 标签的。 可是单页应用在切换路由时,可能也会须要动态修改 head 标签信息,好比 title 内容。也就是说:在单页面应用切换页面,不会通过服务端渲染,可是咱们仍然须要更改 document 的 title 内容。
那么服务端如何渲染 meta tags head 标签就是一个常被忽略可是相当重要的话题,咱们每每使用 React-helmet 库来解决问题。
Home 组件:
import Helmet from "react-helmet"; <div> <Helmet> <title>Home page</title> <meta name="description" content="Home page description" /> </Helmet> <h1>Home component</h1>
Users 组件:
<Helmet> <title>Users page</title> <meta name="description" content="Users page description" /> </Helmet>
React-helmet 这个库会在 Home 组件和 Users 组件渲染时,检测到 Helmet,并自动执行反作用逻辑。执行反作用的过程:React-helmet 依赖了 react-side-effect 库,该库做者就是大名鼎鼎的 Dan abramov,也推荐给你们学习。
当服务端渲染时,咱们还须要留心对 404 的状况进行处理,有 layout.js 文件以下:
<Switch> <Route path="/" exact component={Home} /> <Route path="/users" exact component={Users} /> </Switch>
当访问:/home
时,会获得一个空白页面,浏览器也没有获得 404 的状态码。为了处理这种状况,咱们加入:
<Switch> <Route path="/" exact component={Home} /> <Route path="/users" exact component={Users} /> <Route component={NotFound} /> </Switch>
并建立 NotFound.js 文件:
import React from 'react' export default function NotFound({ staticContext }) { if (staticContext) { staticContext.notFound = true } return ( <div>Not found</div> ) }
注意,在访问一个不存在的地址时,咱们要返回 404 状态码。通常 React router 类库已经帮咱们进行了较好的封装,Static Router 会注入一个 context prop,并将 context.notFound 赋值为 true,在 server/index.js 加入:
const context = {} const html = renderer(data, req.path, context); if (context.notFound) { res.status(404) } res.send(html)
便可。这一系列处理过程没有什么难点,可是这种处理意识,仍是须要具有的。
安全问题很是关键,尤为是涉及到服务端渲染,开发者要格外当心。这里提出一个点:咱们前面提到了注水和脱水过程,其中的代码:
ctx.body = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> </head> <body> <script> window.context = { initialState: ${JSON.stringify(store.getState())} } </script> <div id="app"> // ... </div> </body> </html> `
很是容易遭受 XSS 攻击,JSON.stringify 可能会形成 script 注入。所以,咱们须要严格清洗 JSON 字符串中的 HTML 标签和其余危险的字符。我习惯使用 serialize-javascript 库进行处理,这也是同构应用中最容易被忽视的细节。
另外一个规避这种 XSS 风险的作法是:将数据传递个页面中一个隐藏的 textarea 的 value 中,textarea 的 value 天然就不怕 XSS 风险了。
这里给你们留一个思考题,React dangerouslySetInnerHTML API 也有相似风险,React 是怎么处理这个安全隐患的呢?
咱们将数据请求移到了服务端,可是依然要格外重视性能优化。目前针对于此,业界广泛作法包括如下几点。
如图所示,React 16 在服务端渲染上的性能对比提高:
短短篇幅其实仍然没法说清楚同构应用的方方面面,如何优雅地设计一个 isomorphic 应用,将是开发者设计功力的体现。
在普通的 renderToString 调用之上,更“强大”、更“牛”的设计,好比咱们须要关心如下问题:
最后一点我稍微提一下,我设计的理想同构应用的轮子启动时,获取一个 timeout 参数。服务端渲染真正在于服务端请求数据。在实际应用中好比,当前应用须要在服务端请求 6 组 RPC,在请求过程当中超时(这个 timeout 由业务方设置),只拉取了 4 个接口,注水 4 组数据源。为了缩短 TTFB 的时间,服务端优先返回,剩下的未请求到的 2 个接口数据经过 script 标签注入页面,并进行返回,这样客户端超时前便可渲染页面。
开源的 react-server.io 也实现了相似功能,同时它经过指令化的组件,来作到服务端渲染时,数据的顺序可控性:
getElements() { return <RootContainer> <RootElement when={headerPromise}> <Header /> </RootElement> <RootContainer listen={bodyEmitter}> <MainContent /> <RootElement when={sidebarPromise}> <Sidebar /> </RootElement> </RootContainer> <TheFold /> <Footer /> </RootContainer> }
注意 RootElement 的 when props,以及 RootContainer 的 listen props,顾名思义,这些都实现渐进式渲染和服务端控制。
与此相关的其余概念以及上述技术细节的实现,因为篇幅缘由,这里再也不展开,将来我讲针对更高阶的同构应用设计产出更多文章。
最后,服务端渲染和目前革命性趋势 serverless 的结合也很值得期待,前一段在和狼叔聊天时得知阿里在积极尝试同构应用在 serverless 环境下的架构设计,我我的将来长期看好,也会在这个主题上分享更多内容。
本讲没有“手把手”教你实现服务端渲染的同构应用,由于这些知识并不困难,社区上资料也不少。咱们从更高的角度出发,剖析同构应用中那些关键的细节点和疑难问题的解决方案,这些经验来源于真刀真枪的线上案例,若是读者没有开发过同构应用,也能从中全方位地了解关键信息,一旦掌握了这些细节,同构应用的实现就会更稳、更可靠。
同构应用其实远比理论复杂,绝对不是几个 APIs 和几台服务器就能完成的,但愿你们多思考、多动手,必定会更有体会。
另外,同构应用各类细节也不止于此,坑也不止于此,还有更多 NodeJS 层面的设计也没有设计,欢迎你们和我讨论,保持联系,我也会贡献更多内容和资源。
本篇文章主要内容出自个人课程:前端开发核心知识进阶
感兴趣的读者能够:
移动端点击了解更多:
大纲内容:
Happy coding!