分享 (番外篇) 同构化的 React + Redux 服务端渲染

nightire · 2016年04月23日 · 最后由 nightire 回复于 2016年07月16日 · 10892 次阅读
本帖已被管理员设置为精华贴

前一篇写于一个月前,那时候我开始准备同构化的应用程序架构也就一两个星期的时间,经验并不足,所以尽管也写了挺长一篇但是留下不少坑,很多细节也没有讲得很清楚。

当时我承诺以后会补充,并且把完整的架构开源分享出来(上一篇公开的那个 repo 是实验性质的,有很多瑕疵),经过一个多月的实际产品开发,我修补和完善了许多细节并重新整理一个 repo,那么这一篇就是来兑现承诺的。当然我不会把冷饭再炒一遍,上次讲过的代码细节就不再重复了,这次主要是总结一些遗漏的细节以及针对实际场景的一些考量与决策分析,会把重点放在 Redux 以及 Data <-> UI 的交互上面。相比于上一篇纯粹是理论性质的阐述,这一篇的内容应该对现实开发会更有意义。

先把 repo 地址放在前面,需要参考代码的地方都在这上面:https://github.com/very-geek/univera 克隆下来,运行 npm install 然后 npm start 就行;如果需要针对服务端调试就 npm run debug;如果要看发行版本效果就 npm run stage……具体的请看 package.json

值得一提的是这个 repo 的提交历史我整理的非常小心仔细,如果你关注所有细节的演进过程,不妨顺着历史记录看。在过程中我针对一些技术选型写了一些 demo,然而为了保持这个 repo 的“干净”,在继续演进之后我把它们都删掉了(其实应该把分支都留下来的,以后会注意这一点)。

也有人问我:“你不是鼓吹前后分离 SPA+API 的吗,怎么又玩儿回去了?”其实问出这种问题恰恰说明他既不了解“分”也不了解“合”,不解释,直接看:http://tomdale.net/2015/02/youre-missing-the-point-of-server-side-rendered-javascript-apps/

从 Express 到 Koa

上一篇的实验是用 Express 做 server 的,这一次则改用了 Koa 并且直接走了 v2 的路子。对我来说唯一的原因就是更喜欢 Koa v2 对于 middleware 的设计,详细的阐述我在不久前发过一篇文章:https://segmentfault.com/a/1190000004883199 ,对 Koa 感兴趣的不妨先转道一观。

不过这么一转也给我带来了一些麻烦,主要是在 Express 下非常通用的 middleware 放在 Koa 下就需要针对 async/await 式的写法重新封装了,难度倒是不大,在 server/modules 有许多例子可以参考。

那么用下来的感受呢?优势在于 Koa 的中间件设计更加灵活,async/await 的语法也会让处理异步的过程变得更“干净”,虽说相应的又要适应一种异步模型,但这也是迟早的事情啦。说到异步,以前我也是对 Promise/Generator/Async Function 这些花样感到无比头大,后来看了这个视频(N 遍)之后才算是打通了这条经脉:https://www.youtube.com/watch?v=lil4YCCXRYc

我推荐这个视频不只是因为 JavaScript 或者 Async Function,最重要的是 Jafar Husain 讲得是真好,把异步的概念解释的既生动又透彻,值得一看。

Koa 的缺点就是周边的支持了,尽管常用的中间件也不少,但是稍微偏门点的轮子要么只有 Express 牌的,要么就是型号不兼容 v2。所以真要在生产中使用 Koa,那得先把它的 Middleware 机制玩儿熟了,这样就算找不到合适的也可以拿别家的过来改造一下,基本指导思想很简单:把 callback 改成 promise,然后在 async function 里 await 它了。相比 Generator 来说还是 Async Function 更具实用价值,因为 Generator 并非就是为了“异步”而生的(尽管它能做到),Generator 需要对应的 runner 配合才适合处理异步场景,而 Async Function 就直接了当多了(这俩的对比建议还是看上面那个视频,图文并茂附带动效比我这干嚼文字更直观)。

关于 Webpack 的二三事

如果你认为 Webpack 是一个类似 Rake 的 task runner 或者类似于 Assets Pipeline 的 assets building tool 那你就错了,这些功能其实都是 Webpack 的“副作用”,它的真正核心 feature 是一个模块解析器,而且它可以是一个“万能的”模块解析器。通俗地讲就是可以让你用各种可能的机制来加载各种可能的资源:

  • 各种可能的机制,即在特定的 context 下用合法的引用方式来导入资源。比如说在 JavaScript 文件里使用 require() 或是 import ... from ... 就是可以的;或者在 CSS 文件里用 @import ... 或者 composes: selector from ... 或者 url(...) 等等也是可以的;

  • 各种可能的资源,HTML、CSS/Less/Sass/PostCSS、JavaScript、TXT/JSON/YAML/XML、字体、图片、音频/视频……反正你做 web 产品能用上的资源它都能给你加载了,加载的方法是上一条中的某个/些或全部——这取决于你声明使用的 loader 如何;

以上两点配合使用可以带来无穷的可能性,你可能因此而改变很多传统的开发手段,但另一方面也对同构与服务端渲染带来的许多棘手的挑战。做这个架构的过程中其实我花在 Webpack 身上的时间非常多,至少三成吧。倒不是因为 Webpack 不好(当然这货的文档是需要大力吐槽的 🙄),反而是很多时候 Webpack 能为客户端开发做到的事情太多了,一旦同构就会导致一些机制没法在服务端渲染的过程中“即插即用”,因此你得找出在服务端的解决方案。在这方面我至今做得都不够完美,这里面也有一些原因是受到客观的现实因素所制约的,下面我就说一说这些细节:

JavaScript 模块的解析

99% 的 JavaScript 模块解析在这个架构里都没什么好讲的,该怎么写怎么写。经验分享如下:

完全的 ES2015 模块语法支持

我属于“极端派”,要么不用要么全用。在客户端,只要 Webpack 用 babel-loader 就可以做到,配置文件写成 webpack.config.babel.js 就可以直接用 ES2015 模块语法(重点是 .babel 部分,其他随意)。针对 babel-loader 的配置建议写成 .babelrc 方便前后通用(或者写进 package.json)。

在服务端,你需要一个入口文件(见:server/index.js)预先导入 babel-register;如果要使用更“前卫”的语法,如 async/await 还需要导入 babel-polyfill,然后才能导入真正的服务端代码,此后就可以放心写 ES2015 了。

极个别情况下还需要 require 语法,主要是因为 Webpack 的 require 扩展了 CommonJS 的同名函数,它有一些特别的用处(比如说用来按需加载的 require.ensure);另外就是 require 是每次执行都会重新导入模块而 import 则是会 cache 的,所以在特殊场合下——比如代码热加载/替换时还得用。Webpack v2 会原生支持 import 并提供相应的异步加载机制,这样会使得写法趋近统一,但由于目前还在 RC 阶段所以我并没有用(实在是怕了它的文档大坑 😰)。

小坑一个:Babel v6 之后为了完全靠拢 ES 标准,去除了 export default ... 语法转换时针对 CommonJS 的兼容性处理,因此不得不用 require(...).default 的方式来导入模块。解决办法见:Line 4 @ .babelrc

一些特殊的模块解析场景

不是所有的模块都需要/值得 importrequire,以下是几种很常见的情形:

  1. 全局通用/替代原生同名对象:比如说我倾向用 Bluebird 来代替原生的 Promise(绝对值得),那么在客户端可以使用 webpack.ProvidePlugin 来解决;服务端的话可以 global.Promise = require('bluebird'),不过我觉得没必要,善用 Bluebird 的 promisify 方法就够了。

  2. 适合无需显式导入的模块:比如说我们做 motion 用的 Velocity,这东西到处用,如果每次都 import 很烦人呐(而且它还有一个额外的 ui-pack)。大多数 repo 都手动把它绑定在 window 下面全局调用,那其实 Webpack 也可以让你这样来导入。那么只要是那种加载时自动会往特定对象身上绑定的(比如 windowjQuery)都可以这么做的。

  3. 为便于调试暴露至全局的模块(可以直接在 console 里访问到):和 2. 类似的,除了人工绑定这种笨办法之外,还有一个 expose-loader 更适合这项任务。和 webpack.ProvidePlugin 的区别就是它是暴露在浏览器全局的,便于调试用。

值得一试:这几行的 babel plugins 以及这几行的 Webpack 配置都有助于优化导入模块时的写法,或缩减相对路径,或简化模块命名;协调团队代码约定时很有用处。

样式(及相关资源)模块的解析

样式是同构 SSR 里的一个重头戏。在过去数年中,我们已经接受了外联样式文件的主流处理方式,然而若想获得极速的初始加载体验,内联样式其实是更佳的方案。过去我们讨厌内联样式的主要原因是不好写也不好维护,但假如这方面有技术手段可以解决还会难以接受吗?Google 的首页到今天还是以内联样式为主的,减少请求数固然是最直接的原因,同时 SSR 处理的响应也是方便做 cache 的。

当然了,不是说所有的样式都内联最好。SSR 的应用场景是:如果有页面请求(如浏览器刷新)-> 判断当前 path -> 生成需要的内容 -> 返回响应;一旦客户端接手,由于客户端路由的存在(假设你是有客户端路由的,否则就纯粹是传统的 SSR 了),接下来的 URLs 变化就不会产生页面请求了(只有可能的 APIs 请求或按需加载的静态资源)。因此,只有直接的页面请求需要内联样式(为了渲染时避免 FOUC),而且只需要那些必不可缺的(这个概念叫做 Critical Rendering Path,CSS 是其中的重点处理部分)为最佳。

直到大约一周以前我们在产品开发中一直使用一个叫做 Radium 的工具,这个东西允许你用 JavaScript 来编写 CSS(写法极其类似,但最终导出的是 JavaScript Object),因为 Radium 的样式终究是由 JavaScript 对象转换而来的,所以内联化非常简单,维护也不是问题,同时对 SSR 的支持非常好。我们一直很满意……直到我们发现它对动画性能有非常大的影响。

动画的触发和流向依赖状态,状态变更会导致组件重新渲染,而应用了 Radium 的组件刷新会重新计算样式,新的样式(即使规则未改)应用会操作 DOM——这是连锁反应。

所以我们推倒了样式方案(我必须承认现在做前端的确很折腾,有人会问:值得吗?其实这个答案也是连锁反应,有需求才会有折腾,后面我会谈到这些方面),转而采用更贴近未来趋势的 cssnext,也就是下一代的 css4。cssnext 现在是 PostCSS 的组成部分,PostCSS 你可以把它看作是样式圈里的 Babel,也就是一个语法转换器。和 Radium 即时计算样式不同,PostCSS 是集成在 Webpack 环境中的,它只在 building 时转换并生成最终样式,并且不会因为组件渲染或 DOM 操作而改变(除非你改的就是样式)。缺点在于由于是标准的样式体系,所以它不能解决内联样式不好写和不好维护的问题。我注意到目前有一些工具可以用于在 SSR 过程中抽取必要的样式规则进行内联化,但看起来都不够成熟,暂时没敢碰……看来内联部分说不得还得靠自己写。目前诸位看到的这个 repo 没有处理内联样式的机制,因为我还在踌躇之中,也因此,我还没有使用 Webpack 进行样式的抽取打包并最终转成外联样式表文件,因为这是解决内联样式之后的事情(而且也很简单)。

关于 PostCSS 的所有配置都在 Webpack 配置文件里,这里唯一要说的就是 CSS Modules 的部分。CSS Modules 使得样式可以针对具体的组件编写而不用考虑命名冲突或复杂的选择符设计问题,本质上会由 css-loader 内置的组件化支持把样式命名转化为随机名称,然后生成一个 map 供你引用。于是你可以在 JavaScript 中 import 样式表然后动态使用。为了在 SSR 过程中也能得到一模一样的名称映射,需要在服务端加载 css_module_require_hook 模块,这是因为 CommonJS 并不懂如何处理 .css 文件的模块。

传统的 SSR 并不能直接用原生的模块导入来引用样式表文件,你只能读取文件内容然后解析再生成最终的文本内容拼接到 response body 中。所以本质上 css_module_require_hook 就像是 Webpack css-loader 那样,理解 require('*.css'),解析它然后把它当作 JavaScript 代码来处理——这就是同构得以实现的基本要求。

因此,抽取影响 Critical Rendering Path 的样式部分然后内联它们也是完全可能的,但是只有选择符名称映射的对象还不够,还得要解析所有的规则声明然后匹配 CRP 里对应的 HTML 结构再插入……这个过程相当繁琐,如果硬生生的塞进 SSR 的处理过程中我不确定值不值得或者是否正确。

CSS 内部引用的其它资源,诸如字体、图片等都由 Webpack 负责解析了。在开发过程中,这些解析的内容会由 webpack_dev_middleware 处理成内存式的文件系统映射,拦截实际的路径请求然后把对应的内容返回给你;最终构建时 Webpack 会把它们按照指定的规则写入硬盘的路径,或者把对应的路径替换为指定的规则(如:CDN),因此你不必担心如何处理它们的问题。

留存的一个问题是,由于 Webpack 也允许你在 JavaScript 模块中引用各种静态资源,所以 SSR 在渲染这些组件时遇到这些引入会无能为力(除了样式我们已经解决了)。好比说你想在 SSR 时就预先计算一个 <img/> 的宽高尺寸就会遇到麻烦了。不过解决的思路其实也和 CSS Modules 那个部分是类似的,拦截 require,解析,然后当成 JavaScript 来使用。比如说图片,拦截之后可以进行某种编码,或者用 Headless Browser“伪加载”了,获得需要的信息后拼装成 Object 传递给组件,组件内部自然有动态修改 <img/> 的逻辑(别忘了它们是要同构的),于是服务端和客户端的行为就一致了。

我们的产品没有这方面的诉求所以我没有做相应的实现,不过未来我们有计划要做对中文字体的子集抽取,大概也会经历类似上面的一个过程吧。这一段的内容略微有些生涩,大概在现实中不会有很多团队需要自己去做这些事情吧,毕竟太繁琐。面向现实考虑,我推荐一个轮子吧:webpack-isomorphic-tools,这个东西的作用就是帮你把 Webpack 为客户端处理的各种模块解析对应在服务端的实现都做好了,你按照它的说明写点代码整合到自己的架构中即可,比较快捷实用,不过也是蛮新的一个项目里面有坑几何我就没法儿说了。


(Redux 的坑先留着,对一些场景的解决方案我有了一些新的想法需要实践检验一下,有了结果我再来填)

先收藏了。感谢。

nightire 出品必属精品,必须赞一个先

习惯性加精 😄

收藏,在学 React

赞,学习下

:plus1: 好赞的文章,学习下。

前几天用 Radium 写了个东西,然后发现居然不能在 ie10 下运行,虽然不确定是不是它的锅。。

内联样式的时候对 css 原来的继承是怎么使用的?比如 font family 这些是继续放父组件还是做成一个 mixin 塞在每一个用到的组件上?

#7 楼 @mizuhashi Medium 是 JS,引用和复用的手段远超 CSS,像你举的 fontFamily 很显然放全局。

#8 楼 @nightire 放全局意思是放在 body 上,还是作为一个模块,让每个组件都去引用?前者会让组件隐性地依赖 body 的样式,后者解耦很好不过可能空间占用很大吧?

#9 楼 @mizuhashi 我没看错的话,你说的是 font-family 吧?一般的应用在字体上都是全局统一的,自然是直接写入 html 标签了。至于你说的隐形依赖全局,这不是理所当然的吗?难道每一个出现字体的地方你都要去覆盖一样的 font-family 吗?

如果出现了某个组件一定要用特别的字体来显示,那可以把该字体的定义单独抽取变成一个对象,由于 Radium 是直接用 JS 写的,复用这个对象来引用该字体规则就好了。

如果你所有的出现文字的地方都要这样单独定义字体,那肯定会大量冗余(内联样式都是独立的),可是你为什么要这样做呢?而且不要忘了,写样式还有一种东西叫做 class ,这是可复用的东东哦。虽说 Radium 非常便于写内联样式,但并不是说它不能写 class 样式,而且写内联的前提是 Critical Rendering Path 的样式才需要内联,一味的内联只能说你不懂 CSS。

#10 楼 @nightire 加速渲染是一回事,但是 css in js 就是为了更模块化啊,你的组件不依赖任何外部的东西样式也不会乱,而且 js 还可以用计算生成 style,对比以前只能去改 class,这样更纯

我觉得完全用 js 来实现样式也是很不错的,当然可能不是你的主题

#11 楼 @mizuhashi

  1. CSS Modules 意味着可以以模块为单位 isolate stylesheets,但并不意味着 all stylesheets should be isolated,而且模块化 CSS 也还是以 class 为主的,并不是 css in js 为主
  2. 你可以去看现在很多很多开源的模块套件,特别是以 polymer 为主的(因为比较接近原汁原味的 scoped css),独立的模块也不至于要把所有的样式都隔离来写,特别是像 fontFamily 这种明显全局应用更合理的样式
  3. 如果你做一套组件发行出去或在项目里应用,也不代表这套组件没有全局性的样式部分,如果真的没有那这套也太“傻”了

我建议别走“技术极端化主义”路线,特别是 web 开发这块,有些想法再理想也要看清楚 web 的历史和现实。

@nightire 求救。

universal react app中,其中一个主要的问题:在服务器渲染html模板之前,先要去数据接口拿到datagoogle 搜索出来的好多 tutorail, samples 都是满嘴说着这个问题,但是整篇文章看完,整个代码翻过来就压根不给个🌰的,我擦! 当然我也看到了几个例子,基本最关键的地方就是以下的代码块内

server/index.js

...
else if  (renderProps) {  // 找到匹配的 `component`

   //  ↓  
   doSomethingAsync().then(() => {
   //  可是这仅仅是一个`async`动作。
   //  正常的业务,每个`路由`都可能有一个`async`和他对应,类似:
   //   {
   //       "/foobar": getBothFooAndBarAsync, 
   //       "/foo": getFooAsync,
   //       "/bar": getBarAsync 
   //   } 
   // 
   //   问: 这种情况怎么才是优雅的解决办法?

       const markup = renderToString(
             <Provider store={store}>
                    <RouterContext {...renderProps} />
             </Provider>
       )
       re.status(200).send(renderHTMLPage(markup, store.getState()))
   })
}
...

option 1

当然,我也看到了有些都在component里面加一个fetchData的方法 (包括你的 univera),然后在else if (renderProps)代码块内检查该component是否有fetchData方法。

option 2

不过我也找到universal app 概念最原始的例子isomorphic-tutorial,它是直接把 获取数据的接口 写在服务器端的每个路由内部。

option 3

以及还有一种办法,是第三方库Rezonans/redux-async-connect,不过我个人不太喜欢使用这类库。


现在,我比较倾向 option 1 和 option 2。@nightire 有什么经验可以分享的?

@1272729223

SSR 其实仅仅指的是 Render 的机制,而像我的 univera 以及其他类似的方案在你问的这个问题上涉及到的层面是 isomorphic app,也就是同构化(现在倾向于叫 universal 了)。

同构追求的是什么?同样的代码无分前后只写一次(且不需要逻辑判断当前是哪一个端)。

option 3,你不喜欢,我也不喜欢,so pass

option 2,它和 univera 的架构其实不同,它的 API 和 APP 是完全分离的,也就是你本地开发的时候需要开至少两个 server。在这个 repo 里,API 是 :3031, SSR 是 :3030。

这样子的架构就会产生一个问题,对于 Client 来说,它是不知道 API Endpoint 的具体位置的,当它发请求的时候就是直接请求 :3030/api/...,然后 SSR 里有一个 proxy 代理到了 :3031 去;而对于 SSR Server 来说,如果是它发请求(比如首次请求的时候),它是需要直接访问 :3031/... 的。

因此,它的架构里需要一个 isServer 的判断来区分请求发生的来源:https://github.com/spikebrehm/isomorphic-tutorial/blob/master/app/api_client.js ,有了这个 wrapper 实际调用的时候才能做到“形似同构”。

option1,也就是我的 univera 则是 API 与 SSR 同在一起的,所以不管对 Client 还是对 SSR Server,'/api/...' 所代表的意义是完全一样的,不存在中转代理这么一说。因此只要你的 request library 支持 node/browser 同构调用,那么对于每个 endpoint 只需要唯一的一次请求就好了。所以你会看到每一个 container layer 都会一个 api.js 的唯一调用,那里是我们真正去向 API 发起请求的地方。

至于组件内部的 fetchData 方法,它的用处就是处理你最上面问的问题,多个 async request 怎么处理。在 SSR 那边,它应该完全“不知道”要处理的 API 请求是什么(具体的 endpoint,参数等等),它只需要在 request hit 到自己的时候替客户端调用一次请求方法就好了,也就是 component.fetchData() 这个静态方法,这个方法仅仅就是给 SSR 用的。

为什么要写 fetchData() 而不直接利用 componentDidMount() 里相同的 API 请求呢?这是因为当 SSR 走到准备数据的逻辑时,真正的 UI 组件还没有实例化,componentDidMount 还没被调用,而我们要先行一步给 UI rendering 提供 data。这是 React 的机制决定的。

另外还有一个原因和 Redux 有关,see,请求 API 不是终点,这只是一个开始,最终的目的是要 payload -> reducer -> store。这个过程,在 SSR 会为首次渲染提供必要的数据,在 Client 则是变更 state 通知相应的 UI 组件 update。

在 client 那边,有 react-redux 可以直接把 raw action 变成 dispatched action,所以我们在组件里可以直接 this.props.someAction() 去写,这样会让写组件变得很直观方便。

而在 ssr 那边,由于 fetchData 发生在组件渲染之间,我们只有 raw action,我必须手动用 store.dispatch 去 call actions,所以尽管 API 的调用是一样的,可是在推进 store 这件事情上,由于发生先后顺序的不同,不得不把这个区分开来。这其实和 isomorphic 无关,这是完全不同的渲染逻辑:

  1. 服务端是先有数据然后渲染完整的 HTML 给 response
  2. 客户端是先有了 response 然后路由变更时发请求拿数据接着动态更改 HTML(DOM)

这两件事情的 life cycle 不同,且由于中间有一个 redux 的存在,才造成了 fetchData()componentDidMount() 看起来非常相似的结果,但其实它们各自用在不同的 life cycle 里,并不冲突。

那么 option 1 和 option 2 殊途同归吧,最终都是为了 isomorphic,但是一个是 API/SSR 分离的,需要做请求代理,一个则不需要。这件事情本身并无高下,还是要看实际需求的。比如说如果你已经有了一个不是 node.js 写的 API service 怎么办?你没有机会把 API 和 SSR 整合在一起,那么你一样还是要有一个 api proxy,只不过写得好的话也不需要判断 isServer 或者 isBrowser 这样啰嗦的东东。

类似的问题其实需要你静下心来把完整的流程梳理的清清楚楚,然后明确什么环节应该处理什么任务,这个东西一旦确立了,也就找到解决问题的办法了,具体的实现过程一定会遇到些困难,那也是没办法的事情……至少我还没有做到我心目中的完美。

#14 楼 @nightire 太感谢了,昨晚睡觉前已经看到,只是太困了就没回。有很多概念我还没理清楚,不过也确实是没静下心来整理下思路。谢谢,我再继续研究下这几个概念怎么回事。

@nightire 再问一下,所谓 SSR 仅仅是在 root route 的时候,把模板渲染完先给客户端, 然后再由客户端发起资源请求 (比如是bundle.js) 来启动应用,然后接下去就是传统的SPA应用(bundle.js 托管一切,包括路由),其他都是通过ajax 或 fetch来请求。是这样的吧?

然而,我现在的需求是 (我所理解的 isomorphic application):

  • 第一步:同 SSR 类似,不同之处仅仅 (可能) 在root route响应客户端之前,先去api那边拿到初始化的data。但第二步可能跟 SSR 不同。
  • 第二步:我的bundle.js(可能) 并不是一个完整的 SPA 应用。或者说路由这一层是应该由服务器端来掌管的 (我目前的理解),因为每一个路由对应的页面,我都希望是在服务器端渲染完了之后给客户端的。而如果路由还是bundle.js来控制的话,后续的渲染都是在SPA内完成的,服务器端也根本不会接收到第二个路由

@nightire 细细想了一下,这样所谓的isomorphic application跟完全后端 MVC 应用又有何区别呢?不觉得多此一举吗?

当然,也许isomorphic和后端 MVC 的不同之处仅仅在于:前者是把 V 这一层用 (redux,react) 整个包装起来当做 V(这个 V 其实涵盖了 SPA 的 M 和 C)。因为纯后端 V 的话仅仅包含数据和模板,但是对于客户端的业务则需要另外再写一套。

@nightire 最后帮我验证一下吧,貌似跑通了,https://github.com/8itcoin/isomorphic-react-sample 😄 真是要人命的了!对了,你觉得 horizon.js, rethinkdb这种东西来构建应用稳定吗?

@1272729223

路由的问题:

路由有两个,前一个,后一个。你可以完全靠前置路由,后置路由则只负责唯一的 entry point(这是 SPA 架构);你也可以全靠后置路由,于是每一个 entry point 都是预先渲染好的模版交给 client(这是传统的后端负责 view 的架构);当然,你也可以对半掺,此处并无定式。

所谓 SSR,就是服务端负责渲染首屏所需要的 HTML。至于说 fetchData 发生在哪里 / 何时,或者路由是主要前置还是后置……等等等等,这些都是可选择的,根据你的需要来决定,本质上它们和 SSR 根本就没有直接关系,只不过你去尝试把前端的 view 放在后端来 SSR 的时候必然会遇到这些问题罢了。因果关系不要搞错,不是我写一篇 SSR 的文章就代表其他相关的技术选择和实现都只有我这一套,明白?

Isomorphic 的问题:

同构要解决的问题是同样的 module 在任何环境下都可以执行,拿 JavaScript 来说就是 node.js 和 browser 都可以运行。至于你是整个 framework 都能同构还是一个 library 可以同构,那也是你自己的事情。当你试图让整个应用程序都能同构的时候,最终的结果就好像你完成了一个后端 MVC 的应用,因为你整个前端的 MVC 都可以在后端直接运行,然而你不需要写两遍。

然而这只是一个结果,是因为我们践行 isomorphic 之后说带来的一个 side effect,但不是目的!不是说我为了造一个等价于后端 MVC 的框架才去搞 Isomorphic ……请不要因果倒置来理解这个问题,我发现这是你的特长。

没有 Isomorphic 的时候,举例:前端发 HTTP 请求可以用 $.ajax,但是 jQuery 是不能用在 node.js 环境里的,所以如果我要把用了 $.ajax 的视图用于 SSR 的时候我就懵逼了;node.js 有内置的 request lib,但这个又不能丢在浏览器里运行(你能直接把 rails 整个打包丢在浏览器里运行吗?),这个时候怎么办?isomorphic-fetch 可以。

所以请不要想多了,原始的动机仅仅是 react -> SSR -> isomorphic 而已,最终的结果就是我只需要按照前端习惯的方式去写 view,但是我得到了后端渲染的好处:首屏渲染快,而且不限于 root route,因为 react-router 可以直接在服务端解析,所以任何一个路由节点作为首屏访问的时候都是 SSR 的,为了这个好处才需要解决 isomorphic 的问题,也就是一切路由节点连带的 view 里的代码都能够在 B/S 两端执行,并且一俟 SSR 完成,路由又可以直接在浏览器里工作,于是其后的导航都是直接发生在浏览器里的,所以我们又得到了 SPA 的好处。

需要 登录 后方可回复, 如果你还没有账号请 注册新账号