PART 1:理解防御性
定义
“防御性编程(Defensive programming)是防御式设计的一种具体体现,它是为了保证,对程序的不可预见的使用,不会造成程序功能上的损坏。它可以被看作是为了减少或消除墨菲定律效力的想法。”
“防御式设计是考虑使用者可能会错误使用的所有情形,用设计手法避免错误使用,或是降低错误使用的机会。”(来自wiki)
简而言之,前端开发中的防御性就是防出错。这里的“错”不只是代码报错,而是影响用户使用和用户体验的全部问题。“防”不仅是预防,进一步追求弹复性。弹复性的定义:“系统能从故障中恢复并在面对故障时保持服务可靠性的持久性的能力”(参考阅读:https://www.bmc.com/blogs/resilience-engineering/)。前端开发不仅是简单还原产品设计,而实现更好的产品使用体验。否则就成了“中看不中用”。前端开发的工作和影响必须从实现层(中看)深入到体验层(中用)。
我们不能假设用户会按照产品预设的方式使用产品。在产品设计之初,我们也难以预见到用户所有使用场景和用法。只要在页面上放一个输入框,用户就可能输入任何值。正如此时此刻我在写这篇文章,如果断网,或者不小心cmd+w了,或者不小心F5......能不能保住我的文章,这就是一种防御性功能。严格说这不算是产品核心功能,但会直接影响产品的使用体验,甚至会导致用户弃用一款产品。所有这些伤害用户体验的事情不断积累,最后得到用户的反馈就是“难用”。用法越复杂的产品类似的体验问题越多。开发上的防御性是指保证程序正确执行。我们同样不能假设数据是干净的,入参是符合预期的,调用的方法是不会出错的等等。我们要尽量避免错误发生(这是预防),错误发生后不影响产品继续使用(这是纠正),错误发生后提示用户该怎么做(这是指引)。因此,防御不光是“防”,防御性体现在预防、纠正、指引三个方面。
下图是一种良好的防御思路。通过防御性设计和开发最大程度的阻止“危机”的发生(后面会分析前端有哪些危机点)。当危机发生,如果没有任何防御性手段只能有两种可能的结果:一是用户自己理解问题是什么,反复尝试或通过求助解决问题,即便最终完成任务,体验也是相当糟糕的。二是直接放弃。通过“纠正(Correction)”和“指引(Direction)”能绕过危机。
(来自Udit Khandelwal的文章《Defensive Design Framework》)
预防 | (后面会详细展开) |
纠正 |
|
指引 |
|
防御点
实际情况远比想象的复杂。下图中列举了一些用户在使用链路上可能存在的“危机点”,这也是防御性设计和开发中着重要考虑的“防御点”。归纳为两大类问题:
- UI的防御性
- 防白屏 -- 白屏时间小于1秒(小于200ms最佳)
- 防布局错乱 -- 布局被动态内容撑垮
- 防极端内容 -- 缺失 / 超长 / 连续字符 / 未转义
- 防慢 -- 网络慢 / 响应慢 / 渲染慢 / 执行慢
- 防卡 -- 卡顿 / 假死
- 防一致性问题 -- 不一致的交互方式、图标、 标准组件等
- 防UI状态不全 -- 五层UI状态栈(加载状态/空状态/部分缺失状态/出错状态/理想状态)
- 防样式污染 -- 样式冲突,局部模块的样式影响全局
- 防Chartjunk -- 可读性差的图表用法
- 防误操作 / 危险操作 -- 对不可逆的操作二次确认+强提示
2.代码的防御性
- 防报错 -- 语法错误 / 逻辑错误
- 防兼容性问题
- 防安全性问题
- 防意外输入和交互
- 防数据 -- 防极端数据 / 无效数据 / 接囗变更
- 防代码坏味道 / 防工程腐化 -- 代码复杂度 / 重复率 / 反模式 / 死代码等
- 防语法风格不一致
- 防代码冲突
- 防代码冗余
尤其对于专业用户的B端产品来说,我们要考虑什么样的人,在什么样的网络环境,乃至自然环境下,用着什么样的OS、CPU/GPU/内存、浏览器和显示器的使用我们的产品,还要考虑服务慢了挂了失效了怎么办(做好真不容易啊)。
PART 2:实现防御性
代码的防御性
防御的目的是确保程序能够正确的运行。前端开发面对的是一个大型的异步模型,一方面跟用户互动,用户会随时触发各种操作,有些是预期外的交互行为。另一方面跟服务端互动,受网络环境和服务端稳定性等各种不确定性因素影响。要保证在各种情况下不能阻断用户的使用,同时还能实现更好的交互体验。
1、前端防御性开发中的常见问题
分类 | 类型 | 具体问题 |
需求 | 功能实现问题 | 1. 错误的实现:不符合PRD 2. 不完整的实现:遗漏一些版本 / 应用场景下的不同实现 3. 流程缺失,如权限校验、授权、灰度等 |
UI和交互问题 | 1. 一致性问题:不符合设计稿 / 不符合设计规范 2. UI适配性问题 |
|
开发 | 逻辑问题 | 1. 判断条件有误 / 忽略了必要条件 2. 循环 / 递归的退出条件 3. 显隐逻辑和跳转逻辑控制 4. 缺少校验或错判参数类型 / 空值 / 边界条件 5. 缺少对默认值 / 缺省状态的校验 / 判断 / 处理 6. 接囗调用逻辑和组合关系 7. 忽略一些组件之间的联动关系 |
全局副作用 | 1. 变更公共代码,对其他部分产生影响 2. 变更配置文件 / 全局变量 3. 代码的冲突和污染 4. 基础库版本升级 |
|
容错问题 | 1. 错误输入 / 特殊字符 / 数据类型的容错 2. 接囗返回值的不确定性 3. 接囗请求失败的容错 4. 缺少error boundary,避免导致白屏 5. 错误要上报 |
|
表单校验问题 | 前端校验条件不全 | |
编译 & 依赖问题 | 1. JS编译漏掉对一些语法的处理 2. 本地和发布构建有差异 3. 本地和线上依赖版本有差异 |
|
兼容性问题 | 1. Polyfill不全 2. CSS兼容性问题 |
|
文案问题 | 1. 文案错误 / 不准确 / 折行 2. 国际化不完整 |
|
“灵异”问题 | 难以解释,工程腐化的结果 | |
数据 | 请求失败 | 1. 缺少或错误的入参 2. 参数结构不符合接囗文档 3. 请求失败缺少catch 4. 请求失败信息不全 / 不友好 |
字段问题 | 1. 返回的不确定:缺字段 / 类型不统一 / 空值 / 默认值 2. 接囗各种场景考虑不全 3. 用户数据差异 |
|
接囗变更 | 未及时同步变更和接囗版本变化 | |
状态不全 | 加载态、空状态、错误提示等UI反馈不完整 | |
系统问题 | 浏览器问题 | 1. GET / POST请求参数超出限制 2. Cookie / LocalStorage超出限制 3. 不符合同源策略 4. 触发浏览器Bug的一些写法 |
资源加载问题 | CDN服务异常 | |
请求失败 / 响应慢 | Web服务异常 | |
性能 | 显示性能 | 加载慢 / 渲染慢 |
能耗 | 内存泄漏 / 重循环缺少优化 / CPU占用过高 | |
交互性能 | 卡顿 / 闪烁 / 假死 | |
接囗性能 | 1. 接囗延时 / 超时 2. 接囗的重复调用 / 接囗的冗余调用 |
|
安全缺陷 | 三方库/开源库 | NPM包被恶意篡改、挂马屡有发生 |
敏感信息风险 | (建议购买阿里云数据安全中心服务) | |
Web安全风险 | XSS/CSRF/SSRF/SQL注入等 |
人是代码的创作者,提高代码防御性,写出高质量的代码,最终靠人。人需要通过工具增强能力,需要从代码评审中学习经验。
- 规范:建立前端编码规范和前端安全规范
- 工具:让问题可见。
1. 代码规约检查 - ESLint / Prettier
2. 自动化测试工具 - 开源的太多了
3. 兼容器测试工具
4. 代码质量发现和分析工具 - Plato / SonarJS等
5. 错误监控 - 代码评审:CR创造了一个交流代码的机会,传达规范,互换经验,了解别人的代码,参与双方都受益。
1. 强制性:CR的流程和工具。
2. 可行性:有简单清晰的客观标准,即checklist。
3. 需要有一个“较真”的reviewer。
2、前端代码审查项
类型 | 审查项 |
通用 | import的包是否符合要求 |
变量名是否可理解 | |
用const / let 声明变量 |
|
是否对方法的参数、组件的属性进行必要的检查 | |
避免hardcode值,用常量替代 | |
复杂的判断条件需要先赋值再判断 | |
是否进行必要的数据类型转换 | |
是否引入不需要的状态 | |
链式调用要检查成员属性是否存在,或用?. |
|
优化嵌套循环和多层判断 | |
注掉代码不清除需加说明 | |
是否存在未引用的方法 | |
try{...} catch(err){} catch要有处理 |
|
异步请求处理“三态”:加载、空状态、错误处理 | |
批量请求的接囗是否包含未使用的接囗 | |
避免使用dva的subscriptions |
|
避免引入多余的全局状态 | |
API错误码有对应的具体文案 | |
方法参数不大于5个 | |
单个文件小于400行 | |
是否遗漏国际化处理 | |
消除ESLint报错 | |
清除console.log |
|
React | 禁止写内联样式 |
禁止直接操作DOM | |
使用的是否是标准组件 | |
Form表单的校验是否使用Field组件 | |
Form提交是否对处理中进行处理 | |
Form提交成功无论是关闭浮层还是跳转,都需要显示Message.success('...') |
|
用useRef 替代全局变量 |
|
组件内有循环要用useMemo |
|
组件属性值是方法要用useCallback |
|
绑定数据的组件是否有加载和空的状态 | |
CSS | class命名是否容易重名 |
禁用float 和absolute 布局 |
|
禁止固定宽/高,如需用min-width``min-height 替代 |
|
禁止直接修改通用样式,采取覆写的方式 | |
禁止直接修改标签样式,同上 | |
业务代码禁止在全局定义CSS变量 | |
UI自测 | 用UI Lint自测 |
支持放大 / 缩小两级 | |
Git | 分支用法是否规范 |
提交信息是否清晰 | |
素材 | 是否使用无版权的图片或icon |
《代码大全》第8章防御式编程对前端开发的启发:
方式 | 说明 | 对前端开发的启发 |
1.保护程序免遭非法数据的破坏 | 1. 检查所有来源于外部的数据 当从外部接口中获取数据时,应检查所获得的数据值,以确保它在允许的范围内。 2. 检查子程序所有输入参数的值 3. 决定如何处理错误的输入数据 一旦检测到非法参数,选择适合的错误处理方处理(见第3项)。 |
1. 检查接囗数据字段(是否存在 / 数据类型 / 取值范围 / 缺省值) 2. 检查方法的属性参数(是否存在 / 数据类型 / 取值范围 / 缺省值)做必要的转换 |
2.断言 | 1. 建立自己的断言机制 2. 用错误处理代码处理预期发生的状况,用断言去处理那些不该发生的错误! 3. 利用断言来注解前条件和后条件 前条件(先验条件):调用方在调用子程序前,保证入参的合法性。 后条件(后验条件):子程序的返回结果保证合法性。 4. 避免将需要执行的子程序放到断言中 |
1. 对于得到的入参 / 外部数据 / 返回结果 进行检查。是否符合业务逻辑 2. 通过写断言,不仅可以提高防御性,还能提高可读性 |
3.错误处理 | 程序的健壮性:健壮性具体指的是应用在不正常的输入或不正常的外部环境下仍能表现出正常的程度。 健壮性的原则: 1、不断尝试采取措施来包容错误的输入以此让程序正常运转(对自己的代码要保守,对用户的行为要开放) 2、考虑各种各样的极端情况,“没有什么是不可能的” 3、即使终止执行,也要准确/无歧义的向用户展示全面的错误信息 4、抛出有助于debug的错误信息 |
1. 主动防御处理是有降级 / 容错处理,尽量不要走到error boundary。 2. 考虑到各种可能的输入修士,兼容全面。 3. mock各种极端数据进行测试。 4. 丰富捕获到的错误信息,包含更多上下文信息。 |
程序的正确性:和健壮性是有一定冲突的。健壮性尽可能不出错。正确性是宁可出错也不返回不准确的值。 1. 返回中立值 / 默认值:处理错误的最佳做法就是继续执行操作并简单的返回一个没有危害的值。 2. 换用下一个正确的数据:在轮询中,如返回数据有误就丢掉,进行下一轮查询。 3. 返回上一次正确的数据:同上,不跳过的也可以返回上一次正确的数据。 4. 选择最接近的合法值 5. 上报错误日志 6. 返回一个错误状态码 7. 启动错误处理子程序或对象 8. 显示对用户友好的出错消息 9. 正确性要求高的话,就直接退出程序 |
|
|
4.异常处理 (出错后,调用方利用try/catch/finally捕获子程序异常,并进行善后处理) |
1. 用异常通知程序的其他部分,发生了不可忽略的错误(无感...) 2. 只在真正例外的情况下才抛出异常(无感...) 3. 不能用异常来推卸责任:能在局部处理掉就在局部解决掉,不要简单抛出去。 4. 避免在构造函数和析构函数中抛出异常,除非你在同一个地方把它们捕获 (无感...) 5. 在恰当的抽象层次抛出异常:不要把底层的异常抛给高层的调用方,暴露具体实现的细节。 6. 异常消息中加入关于导致异常发生的全部信息 7. 避免使用空catch语句 8. 考虑创建一个集中的异常上报机制 9. 考虑异常的替换机制 |
当前有错误就直接抛出去,导致线上监控的错误信息质量不高,这个环节值得改进 |
5.建立隔栏 | 左侧外部接口数据假定是脏数据、不可信,通过中间这些类(子程序)构成隔栏,负责清理、验证数据,并返回可信的数据,最右侧的类(子程序)全部在假定数据干净(安全)的基础上工作,这样可以让大部分的代码无须再担负检查错误数据的职责 |
类似的适配器模式和门面模式用来隔离或适配变化,都是对不可控变化的防御。 |
6.辅助调试代码(Debugging Aids) | 1. 在早期的引入辅助调试代码 2. 采用进攻式编程 “尽量让异常的情况在开发期间暴露出来,而在产品上线时自我恢复。”在开发阶段考虑到最坏的情况。 3. 发布时移除调试辅助的代码 |
1. console.log算是一种辅助调试代码,发布时清除。在复杂的调试场景下,有必要专门写一些辅助调试代码。 2. 利用chrome插件追踪变量和状态变化辅助调试。 3. 写单测是一进攻式编程。 |
UI 的防御性
防御点 | 措施 |
防白屏 | 1. 白屏监控 2. 资源加载失败重试 3. Service Worker的资源fallback机制 4. 模块都包装了error boundary 5. 兼容性探测和提示 6. 白屏提示信息 |
防慢 -- 网络慢 / 响应慢 / 渲染慢 / 执行慢 | 前端性能优化 |
防卡 -- 卡顿 / 假死 | |
防布局错乱 | 前端响应式开发 |
防极端内容 -- 缺失 / 超长 / 连续字符 / 未转义 | |
防一致性问题 | 1. 《设计规范》 2. UI走查工具、视觉回归测试 |
防UI状态不全 | |
防样式污染 | 代码审核 |
防Chartjunk | |
防误操作 / 危险操作 |
1、B端产品响应式设计和开发的必要性
以阿里云云安全中心屏幕物理分辨率占比情况(如下图)为例,从结果看台式机或外接显示器的使用比较普遍。我平时开发所用的是1440(15寸本)仅占约9%。同时,用外接显示器会有各种用法,横着用,坚着用,分屏用等等(如图)。必须注意到:屏幕物理分辨率≠浏览器窗囗大小。不能简单的依据屏幕分辨率进行设计和开发。
(图片来自网上)
响应式网页设计的定义:“响应式网页设计(Responsive Web Design,缩写RWD),或称自适应网页设计、响应式网页设计、对应式网页设计。是一种网页设计的技术,这种设计可使网站在不同的设备(从桌面电脑显示器到手机或其他移动设备)上浏览时对应不同分辨率皆有适合的呈现,减少用户进行缩放、平移和滚动等操作行为。”
响应式开发不仅是布局的自适应,最终目的是让产品UI能自适应窗囗大小的变化,自适应内容的变化,均能有良好的呈现。用程序员的话讲就是UI的健壮性。而且尽量不用或少用media query,它只能定死一些breakpoint值,实现效果比较僵硬,应当充分利用CSS技术本身的灵活性。大貘老师近期有一篇详细介绍CSS防御式开发的文章值得仔细看看。
2、CSS开发中的防御规则
防御点 | 说明 |
1. 避免用“布局组件” |
这里指的是用JavaScript实现的布局组件不要用。它会多出很多层没用的嵌套,同时把布局定义的很死,难以再用CSS控制。 |
2. 避免用JavaScript控制布局 |
永远没有原生的流畅,同时增加代码的复杂,容易用问题。除非解决一些必要的兼容性问题。 |
3. 避免用float / position: absolute / display: table等过时的布局技术 |
优先用Flexbox/Grids布局。你会说绝对定位还是有用的。你要强迫自己不用,经过反复尝试过发现绝对定位是最优的选择那就用。重要的是有这个“强迫自己”的过程。 |
4. 避免定高/定宽 |
固定宽/高最容易出现的问题是内容溢出。没必要通过定宽高对齐,可以利用Flexbox的位伸/收缩特性。一般情况下用最小宽/高、calc()、相对单位替代。同上要“强迫自己”不用。 |
5. 避免侵入性的写法 |
|
6. 避免CSS代码的误改 / 漏改 |
|
7. 避免CSS样式冲突 |
|
8. 防止内容不对齐 |
应该说Flexbox侧重“对齐”,Grids是专为布局设计的。受字体、行高等因素影响(如图),用Flexbox实现对齐最可靠:1、height / line-height 不可靠。2、display:inline-block / vertical-align:middle 不可靠。 |
9. 防止内容溢出 |
包括文字 / 图表等内容在宽度变化时或是英文版下容易出现溢出(如图)。 1、图表要支持自动 resize。 2、图片要限制大小范围,如: max-width 、max-height ``min(100px, 100%) 、max(100px, 100%) (注意:min() / max() 兼容性:chrome 79+ / safari 11 / firefox 75) 3、不要固定宽/高。(见规则3) 4、不要在容器元素定义 overflow:hidden 防止内容不可见。宁可难看关键信息也要显示全。5、用 min-width:0 防止Flexbox项被内容撑开。例如:html如下,.canvas 的style是JS写死的,不可避免的溢出了(如图)。<div class="wrapper"> <div class="item"> <div class="canvas" style="width:300px;height:200px;">canvas</div> </div> </div> 这种情况下 .item 可以定义为:display: flex; min-width: 0; |
10. 防止内容过度拥挤 |
|
11. 防止内容被遮挡 |
定义负值时(负margin / top / left),小心内容被遮挡,避免这么定义。定义margin 统一朝一个方向,向下和向右定义,再重置一下:last-child 。position: relative 平时很常用,发生遮挡时会造成链接无法点击。 |
12. 防止可点击区域过小 |
小于32x32像素的可点击元素,通过下面的方式扩大可点击区域: .btn-text { position: relative; } /* 比 padding 副作用小 */ .btn-text::before { content: ''; position: absolute; top: -6px; left: -8px; right: -8px; bottom: -6px; } |
13. 防止内容显示不全 / 被截断 |
|
14. 防止该折行不折行 / 不该折行的折行 |
首先必须理解UI,折行有3种情况:哪些需要折行,哪些不能折行,哪些不能从中间断行。 1. 大部分情况需要折行,不能为了保持UI美观而损失内容的完整性。一般用 overflow-wrap ,尽量不要用~~word-wrap~~ (不符CSS标准):overflow-wrap: break-word 配合overflow-wrap ,可再加上hyphens: auto (目前兼容性不够)限定多行: -webkit-line-clamp: 3 2. 不能折行,如标题 / 列头 / 按钮等。开发中要理解内容,哪些元素不应该折行。 3. 避免表头折行。表格列数过多(>5列)时,会要求锁列,此时, th 定义white-space: nowrap 强制不折行。4. 不能从中间断行的情况(如图): |
15. 防止滚动链问题 |
浮层的场景下需要避免滚动链问题:子元素可滚动,如果父元素也有滚动区域,在子元素上滚动时,触顶/触底后,会影响父元素滚动。关掉浮层后,用户会发现页面滚到了其它位置。 overscroll-behavior: contain; overflow-y: auto; overflow-x: hidden; 注意:避免出现同时出现水平/垂直滚动条 兼容性:chrome 63+ / firefox 59+ / safari和edge不支持 |
16. 防止图片变形 |
|
17. 防止图片加载失败 |
需要考虑图片加载慢或加载失败的情景。在图片的容器上加边或加底色。 |
18. 防止CSS变量未引入 |
在标准化开发中,我们提倡使用全局的CSS变量。业务代码中,利用CSS变量也可以方便的进行全局的控制。在使用CSS变量时要加上缺省值。 font-size: var(--tab-item-text-size-s, 12px); |
19. 防止CSS兼容性问题 |
|
20. Flexbox常见防御性写法 |
Flexbox的默认表现比较多,不能简单的定义display:flex ,或是flex:1 。1. Flexbox容器元素通常要做如下定义:要支持多行(默认是单行),交叉轴上垂直居中(默认是 stretch ),主轴上采用space-between ,将自由空间分配到相邻元素之间。一般都要写上:display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; 2. Flexbox的盒子元素要定义间距。 |
21. Grid常见防御性写法 |
|
写在最后
最后习惯性的想有没有通用解决方案,难点在于太紧帖具体业务实现。要实现通用:一种思路提供原子化的库,类似TailwindCSS,不需要太精通CSS。另外就是内置于标准组件中。默认具备自适应能力。接下来可以继续探索。
(图片来自网上)