前一篇写于一个月前,那时候我开始准备同构化的应用程序架构也就一两个星期的时间,经验并不足,所以尽管也写了挺长一篇但是留下不少坑,很多细节也没有讲得很清楚。
当时我承诺以后会补充,并且把完整的架构开源分享出来(上一篇公开的那个 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 做 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 是一个类似 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 能为客户端开发做到的事情太多了,一旦同构就会导致一些机制没法在服务端渲染的过程中“即插即用”,因此你得找出在服务端的解决方案。在这方面我至今做得都不够完美,这里面也有一些原因是受到客观的现实因素所制约的,下面我就说一说这些细节:
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。
一些特殊的模块解析场景
不是所有的模块都需要/值得 import
或 require
,以下是几种很常见的情形:
全局通用/替代原生同名对象:比如说我倾向用 Bluebird 来代替原生的 Promise
(绝对值得),那么在客户端可以使用 webpack.ProvidePlugin 来解决;服务端的话可以 global.Promise = require('bluebird')
,不过我觉得没必要,善用 Bluebird 的 promisify
方法就够了。
适合无需显式导入的模块:比如说我们做 motion 用的 Velocity,这东西到处用,如果每次都 import
很烦人呐(而且它还有一个额外的 ui-pack)。大多数 repo 都手动把它绑定在 window
下面全局调用,那其实 Webpack 也可以让你这样来导入。那么只要是那种加载时自动会往特定对象身上绑定的(比如 window
或 jQuery
)都可以这么做的。
为便于调试暴露至全局的模块(可以直接在 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 的坑先留着,对一些场景的解决方案我有了一些新的想法需要实践检验一下,有了结果我再来填)