前端业务开发的通用经验 - JS 常见问题

2,658 阅读11分钟

日期时间的格式问题

前端经常需要处理日期时间的格式,自己调 Date api 做转化大概率会遇到兼容性的坑,原因是不同浏览器的 js 引擎对标准的实现不同。

业务中最好的选择是用 date-fns、moment、day.js 这类成熟的方法库。

如果发现业务代码里存在大坨大坨日期字符串解析、转换、拼装的逻辑,大概率是因为前后端数据格式约定有问题。个人推荐的一般原则是,接口参数和返回数据,日期时间类的字段都统一用时间戳,至于具体呈现格式,由前端处理,后端不用关心格式化操作。从实践上看有两个好处:

  • 规避 element-ui 日期选择器由 toISOString 时区问题导致的默认时间格式“bug”
  • 达成一致的协定可以提高协作效率

Native App 由于涉及发版与用户更新周期问题,由接口返回格式化后的日期会更好,不过参数仍然建议用时间戳。

延伸阅读:贺师俊对 Date api 的吐槽


当前时间的问题

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


浮点数运算精度问题

这是一个计算机运算的基本常识,各类编程语言浮点数四则运算都会遇到精度问题(参考 Why 0.1 + 0.2 ≠ 0.3 in JavaScript)。

业务开发中,尤其是涉及金额运算时,如果不得不由前端计算,那么最好转化成整数,单位用分,并且前后端达成一致约定(阿里《Java 开发手册》就明确规定:任何货币金额,均以最小货币单位且整型类型来进行存储)。

也可以采用支持无误差精度运算的库,如 currency.jsbig.js

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


不要用 toFixed 做四舍五入

Math.round 只能精确到整数,要舍入到小数位,可能有人会用 toFixed,但 toFixed 设计上就不是用于做四舍五入的,并且不同浏览器实现有差异,参考 javaScript 中 toFixed() 精度问题

浮点数四舍五入,一种容易想到的变通方案是:

const round = (n, d) => Math.round(n * Math.pow(10, d)) / Math.pow(10, d) // d 表示精确到第几位

不过这种方案会遇到如前所述的浮点数精度问题,比如 round(1.005, 2) 会返回 1 而不是 1.01,原因是 1.005 * 100 = 100.49999999999999。

解决方案可以用 mathjs 中的 round 方法,该实现考虑了大量的情形,比较完备。


整数范围溢出问题

js 的 Number,采用的规格是 64 位双精度浮点数(IEEE 754),有限位的编码方案只能表示连续数轴上的一部分数,IEEE 754 能准确且连续表示的整数范围是 Number.MIN_SAFE_INTEGER ~ Number.MAX_SAFE_INTEGER,超出这个范围,就只有某些整数能被准确表示,其他整数就只能近似表示(在程序上会出现问题)。所以能看到 Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2,原因是这两个数背后的二进制编码是一样的。

所以订单 ID 一类的大数必须用字符串表示。ECMAScript 2021 提供了一种新的原始数据类型 BigInt,也可以解决这个问题。


localStorage 存满问题

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

解决的基本思路是“互相伤害”:当存满的时候,就把别人的数据清掉,具体参考:localStorage 存满了怎么办


Url 过长问题

通常是毫无节制的用 url 跨页面传参导致的,参数一多,有可能突破容器(浏览器、webview、微信)的限制,甚至超过服务器限制,导致报 414 Request-URI Too Long。

甚至听说还有用 cookie 做跨页面传参的,不仅影响接口性能,还容易导致 413 异常

非单页应用,如果有大量数据需要跨页面传递,最好还是把数据做到接口里,localStorage 可能存满所以不适合用来传数据,sessionStorage 无法跨 tab/webview 存在,也不适合。


必须加 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 用法问题

case 1:Promise 忘了 catch,报一堆 uncaught exception

case 2:catch 后面又跟了 then,如果 promise 执行后 rejected,除非 catch 里显式的 throw Error 或 return rejection,catch 执行后这个 then 也会跟着执行,而且 rejected 的 Error 会作为参数传给这个 then

// 经典误用示例
function fetchData () {
  return request('/api/path').then(d => d.data).catch(e => alert(e.msg))
}
fetchData().then(d => handleData(d))

case 3: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,也确实费了老大劲儿