阅读 1165

前端业务开发的通用经验 - JavaScript 篇

日期时间的格式问题

前端经常需要处理日期时间的格式,自己调 Date api 计算大概率会遇到兼容性的坑。贺师俊还专门吐槽过 Date。

业务中最好的选择是用 date-fns 一类成熟的方法库。

有时候业务代码里会出现一大坨日期字符串解析、转换、拼装的逻辑,大概率是因为前后端数据格式约定有问题。个人推荐的一般原则是,前后端交互,日期时间类字段统一用时间戳,至于怎么展示,让前端决定(native 页面除外)。


当前时间的问题

前端能取到准确的当前的北京时间吗?代码获得的时间是操作系统提供的,如果用户只是修改了时区,尚可自行校正时区,但如果用户修改了系统时间,那就没办法了。所以前端取当前时间,是有风险的,最好由后端提供(尤其涉及跨时区的业务,比如国际机票)。


浮点数运算精度问题

这是一个计算机运算的基本常识,浮点数除了四则运算会遇到精度问题,四舍五入也会遇到。业务开发中,尤其是涉及金额运算时,如果不得不由前端计算,那么最好转化成整数,单位用分,并且前后端达成一致约定(阿里《Java 开发手册》就明确规定:任何货币金额,均以最小货币单位且整型类型来进行存储)。也可以采用支持无误差精度运算的库,如 currency.jsbig.js

出个题感受下浮点数运算的闹心之处:如何把 0.01 ~ 0.99 的百分小数转化为符合常识的以折为单位的表示形式,比如 0.2 折、7.5 折、8 折。


整数范围溢出问题

这是一个计算机信息表示的基础常识。有些值(比如某种 id)用整数,等长度超过一定范围,就无法被计算机正确表示(比如你可以将 9007199254740993 这个数贴到 console 看看会发生啥)。所以订单 ID 一类的大数得用字符串表示。


localStorage 存满问题

通常在大公司,同一个域名下可能存在几十上百条业务线,每条业务线都可能因为各种理由往 localStorage 里塞东西,跨页面传数据啦、缓存啦、离线化啦、性能优化啦...5M 看起来很多,其实很快就用完了。凡是依赖 localStorage 而未考虑存满问题的业务流程都存在风险。

解决的基本思路是“互相伤害”:当存满的时候,就把别人的数据清掉(不会出问题吗?不会)。

业务实践中,应该考虑禁止直接调用 localStorage,强制使用经过封装的方法,否则肯定会有新手忘记 try catch,忽略存满可能导致业务逻辑失败。


Url 过长问题

通常是毫无节制的用 url 跨页面传参导致的,可能突破容器(浏览器、webview、微信)的限制。

甚至听说还有用 cookie 做跨页面传参的,不怕 413 吗

如果有大量数据需要跨页面传递,最好还是用接口,如果后端真不愿意/没资源加接口,那么建议考虑单页应用,毕竟跨页面传参的场景一般发生在紧密关联的流程型页面间(比如交易 => 结果页)。如果自己有办法写接口,那也不错。


必须加 try catch 的场景

一般来说,JSON.parse 和 encodeURIComponent 肯定是需要加的,除非你特别信任(最好不要那么信任)传入的数据源。

你大概率遇到过/会遇到 SyntaxError: JSON Parse error: Unexpected identifier “object”。或者看看这个列表,感受下 JSON.parse 有多么容易出错。

async 函数里的 await 也需要加 try catch,最好每个 await 包一个 try catch,不要多个 await 包在一个大的 try catch 里,这样很难知道异常是从哪个异步逻辑里抛出的。


Promise 用法问题

  • 错误一:Promise 忘了 catch,报一堆 uncaught exception
  • 错误二:catch 后面又跟了 then,前面异常,这个 then 也会执行,而且抛出的 Error 会作为参数传给这个 then
  • 错误三:Promise 里套 Promise,变成了新的“callback hell”

js 兼容问题

用了过高版本的 JS 特性或浏览器 api,babel 无法编译,或者遗漏了没被编译,然后又没加 polyfill,那就免不了要报 SyntaxError 啦。

要知道大多数项目的配置下,node_modules 里的 js 是不会被 babel 编译的,现代脚手架通常会提供配置用于解决这个问题。

polyfill 总是很占体积,毕竟大部分用户设备可能都用不着,因此有必要考虑按需引入 polyfill

// 一种简单的按需引用思路
window.fetch || document.write('<script src="fetch-polyfill.js"></script>')
复制代码

build 后用 es-check 检测下有没有 es6 代码没被编译,拦截一下,不失为上线清单必备项目。不过现在还有个更好的选择:modern build 模式


模块写法混用的问题

Uncaught TypeError: Cannot assign to read only property 'exports' of object '#<Object>'
复制代码

这是一个自 webpack 2 就出现的非常普遍的坑,能搜出一大堆,大意是 module.exports 变成了 read-only,因此给其赋值就会报错。你可以在这里找到全面的讨论

简单的说,webpack 2 调整了模块化处理方式,强制用了 import 的文件(会被视为 es6 module)也只能用 export 导出,而不能用 commonjs 语法,目的是解决模块语法混用的历史问题。

不过问题并不止于此,假设有个 npm 包用的 commonjs 语法,由于该模块没有预先编译,因此你将它添加到 babel 编译范围。然而被 babel 处理后的 commonjs 模块,会被插入 import,因此被 webpack 视为 es6 module,从而将 module.exports 设置为 read-only,最终就会导致上述问题。。。commonjs 模块没有被 babel 处理前,webpack 视之为 commonjs 模块,能正常处理,不会报错。

所以 babel 才会有一个 sourceType 的配置。

总之,谨慎对待你项目里那些还在用 commonjs 语法的模块。


模块导出方式

要写出对 tree-shaking 友好的模块,需要注意许多问题,比如用 commonjs 模块肯定是没法 tree-shaking 的(只是一种约定,而不是语法,无法做静态分析)。export 一整个大对象,也是没法 tree-shaking 的。

// don't
export default {
    functionA () {},
    functionB () {}
    ...
}

// do
export function A () {}
export function B () {}
复制代码

模块内部的私有方法,不要随便加 export。export 相当于 java 里的 public 修饰符,一旦 export,因为不知道哪个地方会 import 这个方法,导致重构时无法随便修改或删除。不加 export 的方法相当于 private,可以在模块内部随便改。


模块引入方式

一大堆文件,你 import 我,我 import 你,有人担心过循环引用的问题吗。并非所有循环引用都会出问题,一旦出问题,那就是 Max Callstack Size exceeded。如果你不知道会不会出问题,那么可以考虑用 circular-dependency-plugin 帮忙发现问题。

模块引用关系混乱的一个原因,是模块组织结构设计不合理。于是有人提供了一个对模块引用结构做约束和优化的工具


多版本重复依赖导致冲突

js 的包管理机制 npm,允许项目依赖同一个包的不同版本,这有可能导致某些问题。最简单的场景,假设某个包是通过全局变量提供 api 的,项目中用到了新版本才有的 api。后来因为引入了旧版本的包,而旧版本的包加载顺序靠后,如果直接覆盖掉了新版本包的全局变量,调用新版本 api 时就会报错。

当业务抽出多个公共业务包时,就很容易产生多版本问题。理想情况是:

上层业务项目
    | - 公共包 A v1.0
    | - 公共包 B v2.0
    | - 公共包 C v3.0
复制代码

不过现实中通常会出现公共包 A 需要依赖 B 的情况,如果直接引入到 dependencies,肯定会导致包版本不同步的情况:

上层业务项目
    | - 公共包 A v1.0
            | - 公共包 B v2.1
    | - 公共包 B v2.0
    | - 公共包 C v3.0
复制代码

这时候就会导致同一个页面里调用 B 的方法(import xxx from B),有些方法来自版本 v2.0,而有些方法来自 v2.1。遵循“凡可能出问题的地方一定会出问题”的原则,只要出现多版本并且分别调用了其中的方法,那肯定会出问题。就算没出问题,也会因重复打包影响包大小。

解决办法就是设法保证项目中 import xxx from B,这个 B 只能有一个版本,可以通过 peerDependencies、yarn resolutions 来约束版本,也可以通过 npm dedupe 一键清除其他版本。


TypeScript

TypeScript 完全值得作为项目的基础建设之一。ts 有许多明确的好处:

  • 质量保障,发现某些问题(类型错误、参数缺失、忘记判空、拼写错误等)
  • 天然的文档
  • 代码自动补全提示
  • 提供 api 类型描述,约束他人用法

多一种方案帮你发现问题,不好吗


可能有人觉得定义类型很麻烦,但事实上,类型校验在后续维护中一定会为你省下更多的时间。你可能经常遇到改了某处代码,发现有问题,搞了半天才发现另一个地方也需要改一下,或者仅仅是因为某个名称写错一个字母,如果有 ts,说不定早就报错了。


Vue & TypeScript

Vue 用户如何用 ts,这是个问题。因为历史原因,Vue 对 ts 的支持不如 React 完善,于是有人仿效 React 搞出了个 vue-class-component。

尤雨溪在 2018.9.30 宣布 vue3.0 开发计划时,就提出对 class api 的原生支持,并且在 2019.2.26 发出了 RFC。基本动机是:既然已经有很多人在用 vue-class-component + ts 做开发,不如提供原生的支持,既方便用户,又避免多维护一个 vue-class-component。

不过在 2019.5.21 尤雨溪否定了上述提案,基本理由是:实现太复杂,问题太多,同时发现了更好的替代方案,也就是 composition function。新的方案,和 class api 相比,具有更好的复用性、更好的 ts 支持、更好的向前兼容、更小的打包体积。

decorator/class 只是一种偏好,并没有解决额外的问题,甚至会带来额外的问题,已知的有:

  • 导致 eslint-plugin-vue 无法侦测出某些问题,比如注册了但未使用的组件
  • props 传参无法校验类型
  • class 中生命周期和 methods 容易混淆在一起

事实上用 Vue.extend,在 ts 校验上基本足够了。Vue 为了支持 ts,也确实费了老大劲儿