TLDR:
- 访问深层嵌套数据时,如果数据预期可能会缺失或者为空,使用可选链a?.b配合空值合并运算符??指定默认值进行访问。如果数据预期必须存在,使用try…catch的方式捕获错误并上报。
- 如果浏览器兼容要求比较严格无法使用可选链语法,考虑使用babel转译或者使用lodash提供的get方法。
- 滥用可选链语法会导致错误被掩盖,用户侧能感知到问题但是监控不到错误,debug困难。
- 如果发现频繁需要写出类似a.b.c.d这种访问多层嵌套数据的代码,请考虑函数是否符合单一职责原则。在有必要时增加抽象层次来降低函数复杂度。
问题描述
前端从服务端的接口获取数据时,不时就会遇到一些结构层次比较深的数据类型。比如说
{
"entities": {
"media": [
{
"url": "xxx"
}
],
"url": []
}
}
在访问这种数据的字段时,很容易就会写出类似a.b.c.d这种代码。而这种代码往往容易在运行时出现一个经典错误cannot read properties of undefined 。如果没有写对应的try … catch逻辑,那么很可能导致整个页面白屏。
Section 1:如何避免页面崩溃
那么如何能够避免这个错误的出现呢?最显而易见的方法就是只有确认某个变量自身有值时才访问下级的字段。
const d = a && a.b && a.b.c && a.b.c.d
这里利用了在javascript中&&运算符在所有值全为真值时会返回表达式中最后一个值的特性。不过这么写比较麻烦,另外在某些情况下可能返回不符合预期的结果。比如说。
const obj = { p: false }
const booleanConstructor = obj.p.constructor // f Boolean()
const booleanConstructorF = obj && obj.p && obj.p.constructor // false
更简单也更加安全的方法是使用javascript的可选链语法
const d = a?.b?.c?.d
使用可选链语法后, 如果某个字段为undefined或者null,就会直接返回undefined或者null,而不会抛出错误。通过可选链已经可以很好的避免出现javascript错误的问题了。
不过往往d这个变量在服务端是会有一个明确的类型定义的,如果说d本身是数组类型的变量,那么给d一个空数组的默认值会有利于降低后续数据处理的心智负担。
const d = a?.b?.c?.d ?? []
这里利用了空值合并运算符来处理这种情况,在表达式a ?? b 中如果a为undefined或者null,就返回b的值,否则返回a的值。
值得注意的是,不管是可选链(?.)还是空值合并运算符(??)都是ES2020的语法,如果实际应用中有很高的浏览器兼容需求,可能需要babel转译或者使用一些库提供的类似的功能函数来实现对应功能。比如说使用lodash-es提供的get函数。
var object = { 'a': [{ 'b': { 'c': 3 } }] };
_.get(object, 'a[0].b.c');
// => 3
_.get(object, ['a', '0', 'b', 'c']);
// => 3
_.get(object, 'a.b.c', 'default');
// => 'default'
Section 2:不要滥用可选链
至此,看上去这个问题已经得到了解决。但是事实上以上解法仅仅是阻止了报错,但是并没有消除真正的错误。更糟糕的是滥用可选链很可能会掩盖一些关键的错误同时增加代码的理解成本。极端情况下服务端所有接口都返回一个空对象前端都不会有任何错误提示,这显然是不合理的。
以a.b.c.d这个访问模式为例,如果d本身在与服务端沟通过程中就明确是一个可能没有的字段,那么就无需上报。如果c字段是确定会有的,但是实际上没有返回c字段,这个时候就有必要上报这个错误信息。不过如果代码全部使用可选链的写法,是无法区分这两种情况的。
// 无法区分到底是哪个变量为undefined或者null导致了返回值为undefined或者null
const d = a?.b?.c?.d
针对以上情况不应该在所有地方都通过可选链来进行数据访问,而是只有明确某个字段可能为空的情况下才使用可选链的方式来访问。如果某个字段约定是必须有的,但是没有返回,应该明确地抛出对应的错误并进行相应的处理。
try {
const c = a.b.c
const d = c?.d
} catch (e) {
// 根据项目需求写错误处理逻辑
console.error(e)
}
Section 3:不要总写a.b.c.d
当我们能够阻止js错误引起的程序崩溃,并且合理地上报有用的错误信息后。从项目质量的角度而言已经合格了,但是如果从代码质量的观点来看也许还有可以探索的空间。当代码里面出现a.b.c.d这种结构的时候,需要思考这个函数是否承担了太多的职责。
这里举一个简单的例子来说明(这个例子里面的重构其实是没有必要的,仅仅是为了说明对应的想法与动机),假设现在有一个简单的todo app,返回的数据包括一些用户信息和用户对应的todo列表信息。现在有一个页面需要展示用户的头像和用户对于这些todo所做的评论信息,那么就需要进行一些数据转化让UI可以获取直接可用于渲染的数据。
{
// 用户ID,必须存在
userId: 'xxx',
// 头像,可能不存在,不存在时展示默认图片
avatar: {
'url_1x': 'xxx',
'url_2x': 'xxx',
},
// TODO事项列表,各项都必须存在
todos: [
{
id: 'xxx',
description: 'xxx',
comments: [
{
create_time: 'xxx',
create_user: 'xxx',
content: 'xxx'
}
]
}
],
}
第一种方式是直接写一个converter函数来完成所有的工作,这个时候所有的数据转化逻辑都放在同一个函数里。这个写法在当前的场景下看来还是很容易理解的,如果能够确定这个结构之后变化频率很低那么就无需改动。而如果说data的结构可能比较高频发生变化的话,那么几乎每次修改都一定会改到dataConverter这个函数,这个函数的复杂度会持续增加。同时修改的影响范围也变得不太可控,需要花费更多的时间判断修改是否会影响到这个函数里面的其他逻辑。
function dataConverter(data) {
return {
userId: data.userId
avatarURL: data.avatar?.url_2x ?? data.avatar?.url_1x ?? 'default_url',
comments: data.todos.map(todo => {id: todo.id, ...todo.comments })
}
}
如果出现这种情况,就说明是时候重构了。在重构之后,为头像和评论创建专门的数据转化函数。这个时候如果头像或者评论相关的字段发生变化,就可以安全地修改AvatarConverter或者CommentsConverter而不必担心潜在的相互影响了。同时进行了多一层的函数抽象之后,会发现访问数据的层次也变得更浅了,这样就更容易在一个更具体的语境下思考如何处理特定的数据缺失,访问深层嵌套数据的出错概率也降低了。
function dataConverter(data) {
return {
userId: data.userId
avatarURL: AvatarConverter(data.avatar),
comments: CommentsConverter(data.todos),
};
}
function AvatarConverter(avatar) {
return avatar?.url_2x ?? avatar?.url_1x ?? 'default_url';
}
function CommentsConverter(todos) {
return todos.map(todo => {id: todo.id, ...todo.comments });
}