单页面开发思路, 阶段小结

去年年底在公司内部发过一篇梳理, 回顾了下两年面对的各种前端问题
单页面由于正在发展之中, 加上 React 社区的发展, 细节不少
虽然不是很难的东西, 但一边摸索一边推进不得不想了挺多
考虑到后面不是做大型单页面应用, 中间很多问题大概不会继续深入了
我觉得相关的技能梳理一下也比较有用.. 当做记录也好
本来是两个月前打算写出来的, 中间没写忘了挺多的, 争取收尾掉
Backbone 比较早, 不想多提了, 分章节写现在我的理解, 粗略写下

Webpack 脚手架

打包

原本打包方案不是首要考虑, 但项目打算上线, 问题就来了
打包分发是项目开发的第一步, 脚本配置少不了
我早期用把公司的纯 Grunt 拼接改成 Browserify, 再到 RequireJS
现在的话 Webpack 就好了不用多想了, RequireJS 太弱了
现在 Browserify 也许跟上, 当初不够用, 而且不如 Webpack 地方多
另外 JSPM 似乎到了 ES6 时代要起来, 还可以观望一会

revision 处理

试过 Gulp 一些 Rev 插件, 感觉相当吃力, 而 Webpack 瞬间解决了
当初也是吃惊, 但想想能省我那么多精力就算被同事说也无所谓了
revision 的前因是 CDN 存储资源有强制缓存, 需要通过文件名刷新
而这样面临的问题是, 比如 a.css 依赖 b.css 再依赖 c.jpg
这时 c.jpg 更新导致 3 个文件名都要改变, 分析依赖不好做
分析完依赖还要替换内部引用的文件名, 整个就是大工程了
说真的小公司真会为了打包搞个大新闻么, 当然是用现成方案了

webpack-dev-server 自动编译

另外, 单个文件更新, 增量编译的问题, 思路大家都知道
最初 Grunt, 局限就是会导致整个编译, Gulp 能解决
但 Gulp 当时解决主要是 requirejs 环境中文件相互分离的
比如 coffee 单个编译出 js, 然后通过 requirejs 调试
如果用 requirejs 编译到一个包, 项目大的话还是会很慢
这时候 webpack 自动增量编译好处就明显了

热替换

重新启动单页面, 一方面是加载资源, 一方面是启动应用
开发当中为了改一行代码做整个事情效率非常低
早先我先对 CSS 做了, 毕竟 CSS 基于 Gulp 方案仍十分成熟
也用过一段时间 workspace 直接替换, 但由于 LESS 推不下去
js 热替换随着 React 这边推动, 我最终搞起来了, 但没能说服同事
现在回头看热替换前端代码再平常不过的事情了
而我现在无论前后端, 无论 js 还是 cljs, 都会尽量开启热替换

前期用的 react-hot-loader, 后期直接用 module.hot API
其实全局性替换会方便一点, 只是 js 可变数据太多太复杂
热替换工作在 cljs 中由于数据不可变, 直接在编译器内置完成了
最终在入口文件指定替换代码后如何更新即可
然而副作用多的话, 只能手动处理每个副作用在替换前后的行为

嵌入 gulp task

这是为了简化启动命令, 虽然简化后对于大家来说脚本更难读懂一点
webpack 推荐是命令行直接启动, 但这对于发布等等并不方便
我发现将其作为一个 gulp task 处理, 组合能省很多事情
包括 webpack 拿到 assets.json 以后生成 HTML 引用页面的步骤

分包和异步加载代码

这是为了减少资源的重复加载或者冗余加载
分包在 requirejs 也有, 但 webpack 更强大, 能自动分析复用模块
从配置上说也清晰很多, 比起 requirejs. 但更复杂的场景也会变麻烦

异步加载代码是对于一些不常用的大块代码来说的, 比如编辑器
webpack 中倒是很容易就实现, 特别是还保证了 revision 正常
但是注意异步打包的代码可能破坏热替换的冒泡机制
我当时也到 Issue 上问了作者, 才找到修正冒泡的方案的

界面渲染方案

渲染应当是纯渲染

早期在使用 Backbone 就被这个问题困扰了, 或者说犯了错误
因为渲染的需求是很频繁的, 会经常去调用, 很不自觉地
当渲染中存在副作用, 这种福作用就会被一次次触发, 难以控制
所以就是, 渲染过程只能渲染, 什么都不用做, 保证安全

数据驱动的界面

时间长了就明确了, 界面和数据应该是一致地对应的
比如说数据库里有 A, 如果前端显示 B, 那数据库当然是对的
抽象来说就是界面就应该按照数据渲染, 被数据驱动
实际开发, 早期 DOM 问题, 数据不一致经常发生, 好多地方改
慢慢学乖了, 界面应该从数据渲染, 而不是用 API 去改
回头看的话, 界面作为数据的展示层, 这个当然是尝试的

MVC 分离

分离组件局部状态

这是 React 带来的思路, 在 Backbone 里很难想到把状态抽象出来
比如说有个菜单, 有打开关闭两个状态, jQuery 思路就写个 class
而 React 提出的方案是抽象出组件的 State, class 根据 State 渲染
这种抽象导致我们开发多出了一个步骤, 或者说步骤是原来的两倍
但关键时刻这种思路是能省掉很多麻烦的, 特别对于不好熟悉的场景
ViewModel 的说法大概也是类似, 显然这个思路并非 React 独有

数据一致性

数据界面绑定, 比如一个 Backbone 组件有 View, 有 Model
早期经常遇到多个组件 Model 不一致的问题, 因为 Model 多嘛
但 M 和 V 分离并不仅仅是经验, 更深层应该是 Single Source of Truth
比如在数据库当中, 所有数据只存储一份, 那么这一份永远是对的
View 是用户端的东西, 植入一个用户显示 N 种名片很普通的需求
所以 View 存储数据是不可靠的, 因为你只要忘掉更新一个就出现了数据不一致
Model 数量有多个时也有这样的问题. 所以, Model 应该是单个

React 方案的重要性

MVC 的方案放在服务端很自然, 毕竟 HTML 都是整体渲染的
前端的问题在于无法整体渲染, DOM 不能说破坏就破坏
Backbone 当然知道, 只是技术受限, 后面有 Ractive 之类框架也能补补
当然我是钟情 React 的. 整个后端渲染 HTML 的体验搬过来了
所以尽管我讨厌 JSX 我千方百计想到办法在 CoffeeScript 里搞起来
单向数据流的事情就明显了, 不重复

数据层设计

模仿数据库

Single Store 在 Flux 的实现当中没有做到, 但思路上类似
后来去掉 Flux 过程当中怎样设计前端的数据层也就是要自己思考
我主要是从 MongoDB 的经验去调整这个全局数据的,
也就是定义多个 collection, 对应的消息话题用户按 id 存进去
Redux 应该说也是类似的, 但我猜想数据量不如我当初写的复杂
此外还有些全局状态数据, 我也粗暴地塞进全局 Store 去了

不可变数据实用特性

对于不可变数据的理解, 我也渐渐在改变, 其实是出于不同语言的立场
js 里有 freeze 的数据, 但并不等同于不可变, 在 cljs 中才是不可变
另外”不可变”有 immutable 和 persistent 两个不同的英文术语, 中文混用了
persistent 强调修改的原数据是保留的, 不是被更改的, 实现上可以是可变
另外 FP 还有”不可变的数据, 可变的引用”这类说法, js 就是太浅薄

实际情况导致我冒险重构到不可变数据, 是因为 React 性能优化
当初上百条数据性能差, 我用 shouldComponentUpdate 做性能优化
写过 React 就明白发生什么了, Bug, 界面不更新, 别人就 blabla 了
大应用重构是危险的事情, 我也算摔过了. 这种两难的选择只能认栽
想要保证整体架构上单向数据流的可行性和可靠性, 算是必要的一步

全局数据和全局状态

前面讲到 Store 中存的数据, 有应用内容, 也有单个用户的配置
于是就有了全局数据和全局状态的区分
全局数据是存储在服务器的, 前端只是一个副本, 要做同步
而全局状态是用户配置, 只是存在浏览器的配置, 在服务器没有记录
体现出来就是本地 Store 中要设定机制每次打开应用恢复这种状态
React 应该说是没把问题解决好, 它的组件有区分, 但全局却没有

数据版本迁移方案

前端有个大的 Store 之后, 就会有在前端做数据持久化的考虑
为了省事, 我用的是 localStorage 存储 JSON, 有大小限制
那个数据量其实已经能在存储时感觉到明显的延时的, 总之就很大
所以, 版本迁移问题, 比如旧的用户配置是应用的, 但消息数据要替换
我的思路是加个版本号, 然后根据版本号写相互的更新规则
当时没完成, 跟同事商量了下结果分歧了就没法写了…

Actions recorder 时期

准确说我模仿的是 Elm, 而不是 Redux, 只是很多人只知道 Redux
Redux 的实现细节让我看不惯, 复杂度太高了, 黑盒也很多
理解了 MVC 运行的套路, 我对 Redux 引入的大量的概念觉得反感
中间件抽象方便业务不假, 但强制做进方案当中就让人难受了
我们那么大的单个应用, 跟着 Redux 做一轮一轮迁移, 不靠谱
Elm 架构中介绍的 Model 和 View 的抽象方案, 看上去简单一些

当时思考这个问题给了我不小的思维的转变, 就是究竟什么是 Model
比如一个界面的状态, 能表示它当前的界面的到底是什么
首先界面是由 Store 和 State 确定的, 但这就结束了吗?
我想说 Store 并不是稳定的, 前端的 Store 可能和数据库有延时需要更新
在 Redux 的例子当中, Store 是 Reducer 计算得到的, 那计算还会出错呢

实际上我认为, Model 是多个 Operations 的总和, 的简化版本
Store 是可以通过 reduce(Fn, initialStore, operations) 计算的
所以数据也可能是 View, 或者说 DataView, 就是说这份数据是算出来的
举一个例子, 存款是一个数字, 但实际上是一笔笔收入支出的叠加
只是记录数字没用, 出错怎么办, 作假怎么办, 多个人操作怎么办?
解决办法就是保留所有账单, 有疑问查每笔账, 数额加到一起找结果
在 React 应用当中, 每个操作就叫做 Action, 以后也许叫 Msg

服务端数据同步方案

分布式的数据

有了前面 Action 的说法, 再看看前端的 Store, 理解上就不一样了
Store 不是简单地对应 Model, Store 是所有操作的叠加, 的简单版本
Store 要和数据库保持一致, 而数据库是和用户的每个操作保持一致
最终面临的是大量的 Actions 怎么在很多设备之间同步的问题
对分布式没啥研究, 不能继续挖下去

数据依赖分析, 合并请求

GraphQL 当中就讲到, 通过组件上的声明, 判断出组件依赖的数据
很容易理解, 渲染下一帧界面需要什么数据, 缺什么数据, 请求什么数据
而我们是没有 GraphQL 这种不成熟技术的, 怎么分析依赖?
我想到的就是通过路由, 因为路由基本能确定渲染的界面怎样
于是我当时做了简单的根据路由查找 Store 缺失数据, 然后合并请求

比如新打开的界面是个频道, 那么我要查看团队, 成员, 频道, 消息内容
也有查看一遍 Store 当中是否之前打开过已经缓存了一遍数据
再取差集根据路由当中的 id 还原到资源的 URL 地址
这样就能合并多个请求了

Loading 状态

一般 loading 的做法就是比如话题界面, 组件挂载时会请求数据
请求数据后当然就渲染了, 但请求之前将 undefined 数据渲染为 loading
我认为这个做法有两个错误, 1) 这是渲染过程, 不应该发起请求
2) 渲染组件了说明路由已经更新, 但数据抓取失败怎么办, 又往回跳?
正确的步骤应当是先抓取数据, 成功, 切换渲染, 失败, 提示失败

又有些不开心的回忆. 总之这里会有个问题, 就是请求完成前界面没反应
这当然是违反了交互原则的. 即便我前面指出了问题, 还是违反交互
所以点击到请求完成, 这中间是特别的, 其实有个 loading 的状态
在 loading 状态中, 界面会显示 loading, 这是单独加入的一个页面
我知道这不如在组件里判断 undefined 方便, 代码也会更长
然而一边是代码变复杂, 一边是私有状态妨碍高级抽象和性能优化, 不好选

Action 设计

Flux 的写法主要是用常量定义的各种 Action 然后引用
我写法是 category/name 比如 message/update
我的思路是模拟请求, POST /api/message/1234 text=new
这样即便有几十个 action 我也比较方便调试. 受个人习惯影响

路由方案

路由的 View 特性(对比 Controller)

前端独立路由一般就那样, 一段匹配, 一个回调函数, 就这样了
在 Flux 而 MVVM 方案里做了声明式写发的封装, 好用一些
但是把这东西放在 React 的 Single Store 套路中很有问题
Redux Router 出来之前, 使用全局 Store 和 react-router 形成了干扰
我自己实现了新的路由方案, 主要解决手头一些问题:
1) router 中私有状态不受控制, time travel debugging 失效
2) 内部状态不可知, API 不稳定, 配置出错时查错没有头绪

细想一下数据流, 路由的数据流在哪? 在浏览器内部
一般 React 组件, 有用户事件时先回调到父节组件, 处理后数据发下来
或者有个 this.state, 去更新 State 私有的数据, 后面再渲染
但 react-router 呢, 首先是操作私有数据, 其次对外部产生了影响
这就不是一个受 Flux 方案控制的前端 MVC 方案了. 它就不受控制
那么换种思路, 路由不就是一个输入框加上前进后退按钮吗?
只要把事件全部截获, 然后封装成普通的 React 组件就好了
所以, 路由是个 Component, 而不是 react-router 中的特殊组件

popState 无法撤销问题

addressbar 的事件那就是 popstate 事件了, 前进, 后退, 还有切换
Single Store, 加上路由是影响全局数据的, 所以放在全局的 Store 中
但和普通的事件不同, popstate 无法 cancel, 这种有点棘手了
我的实现最终做了特殊处理, 避免渲染地址时意外触发事件的 bug

路由嵌套问题

当路由状态是存储在 Store 中的数据, 那么嵌套路由问题很好理解了
数据 A 和 B, 在上一次父组件是一直的, 子组件是不同的, 做判断即可
在 React 中会自动查找变更的组件, 所以直接跳过了原先的问题
这个方案和 react router 区别在于路由状态需要显式传递

服务端渲染首屏

服务端渲染

React 服务端渲染性能不佳, 有人在今年 2 月份做了演讲, 可以搜一下
大概是 react-dom-stream 作者, 他给出了一些方案
比如高阶组件的 component level caching, 比如 Stream
找 Youtube 上演讲视频

本地渲染加速

另一个加快速度的办法是保存上次关闭应用时的界面, 直接替换
比如保存上次打开时的 HTML 在 localStorage 里, 瞬间渲染…
我的话为了 React 组件容易更新, 存储的是 Single Store
Store 连路由和配置等等数据都存了, 所以是可以生成整个页面 HTML 的
当然在交互层面上要加上个”开始更新数据”的提示
如果某个组件是有局部状态影响 HTML 生成的, 方案可能就失败了

CSS 常用方案

Inline CSS

用过一段时间 SMACSS, 但现在个人项目都用 inline CSS
因为小应用要求不高, 所以 hover 之类的特殊样式是放弃了的
inline css 能解决的问题很明显, CSS 设计上就不方便写逻辑
强制改进 CSS 增加更多功能直接会引入过多的复杂概念
其实用 hash-map 的 merge 已经能干掉很多问题了
当然, 要求高的场景另说了. 两难

设计规范

CSS 受到设计规范的影响很大, 吃过亏, 没有设计没有组件
我之前试着和设计师去交流过, 算是有进展吧,
但更多发现设计师的思考方式和我们不同, 连工具数据也有不同
而且更麻烦的是, 移动端和前端的术语也是不同的, 难以统一
当我们考虑对组件制定规范方便代码上复用的时候, 设计师难做了
站在这个角度看 React Native 是个可能的好办法, 有待深入

组件相关

模板引擎的问题

模板引擎本身没问题, 但是在需要自动更新的场景下引起麻烦
React 其实就像个强制做进 React 的模板引擎, 同时能自动更新
前端如果模拟引擎不支持自动更新, 我真的建议放弃掉

组件复用问题

高阶组件的事情, 最好几次提起, 说真的, 这是我看不懂的问题
原则上说我认为有问题, 绕过了框架限制, 可能会引起优化方面的问题
但同时业务上我也遇到了需要进一步抽象的组件, 业务总是变化的
我能做的只是提醒人提防一下性能优化和 time travel 中潜在的问题…

Modal 组件设计

Modal 是个麻烦的组件, 又是动画又是数量还有嵌套, 细节做不好
另外在 Time Travel 中也是个麻烦, 通常 Modal 都有局部状态控制
而且由于组件做了隔离, 嵌套 Modal 对 ESC 的响应不容易做
还有呢, Popover 和 Modal 在点击事件方面相互影响, 同时存在如何控制
我前后很多个版本, 最后一个 stacked modal 是把状态放进 Store 控制的
又是不开心的回忆… 总之只是勉强解决掉了问题

功能测试

我其实是反对在界面上引入大量测试的人, 可能让人感觉不靠谱
但我真的只认同对数据内容进行测试的做法, 而不是界面或者交互
细想一下能整理出若干个我比较坚持的原因:

  • 首先强制 pure render 就是为了去掉不确定因素, 而且比测试重要

  • js 的 bug 在于它是动态语言, 引入 flow 或者 ts 是更迫切的

  • 界面的 HTML 状态组合较多, 修改也很快, 比 JSON API 混乱太多

  • 界面大量出现 CSS 不可靠引起的问题, CSS 难以写测试

之前有被后端工程师要求写前端测试的经历, 事情我真的反感了
反感归反感, 前端代码的可靠性赶上后端代码之前, 没有什么办法

题外话

我相信我们在单页面开发中的探索 , 我对 React 的探索, 对很多人有用
但问题也不小, React 使劲探索, 结果是奇怪的写法和拔高的技术栈
我前面的数据方案, 组件方案, 也加载了个人习惯在内的奇怪的东西
这些方案有闪光点也有难用的地方, 不过那边是官方, 我只是是个人开发
我相信多年后官方代码会被延续但大幅修改, 我的将被了解然后丢弃
技术探索跟推广使用的方案毕竟是不一样的

我转向 cljs 并不因为虚荣之类的, 而是因为看到了问题的所在
别人看到 js 有问题推出 Babel 慢慢改进, 而我转学 cljs, 就这区别
个人技术选择本来没什么对和错, 但是涉及到公司决策问题就不同了
是发生过也是将来可能重演的, 是我要提防别人也要自己注意的
我现在做很多希望更多人了解 cljs 优劣, 从而对 js 有更清醒的认识
cljs 也是有问题的, 随着项目要求越高容忍的限度越低, 正常

这篇文章主要在之前提纲基础上补的对上一个做过的大项目的回忆
我接触到的范围有限, 虽然自我感觉单页面开发大部分问题都涉及了
而且在 React 方面其实很多地方还研究得不够深入
React 也是个有很多局限的框架, 以前说了, 再对比下 Native 应用还会有
中间很多的知识点还要感谢寸志还有 React 社区前面一起探索和领路的人
前面那个项目到此为止了. 希望这份小结对人有用

    原文作者:题叶
    原文地址: https://segmentfault.com/a/1190000005176934
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞