大家好,这里是大家的林语冰。
JS 的 with() 语句已被腰斩,且强烈建议直接禁用。虽然但是,这合理吗喵?
with 语句实际已被弃用,且在严格模式下无法奏效,但即使综合考虑其“18 禁”的原因,它仍然令人鸡冻。让我们花一些时间回首往昔,with() 的超能力是什么、其饱受争议又是为何,以及本人对这些差评的反对意见。
诉诸 with() 读写属性
当我们读写 JS 对象的属性时,我们几乎总是需要诉诸标识符来限定这些属性,这样引擎才知道哪里可以找到对应的值。唯一的例外是全局变量。如果该名称对应的变量在作用域链中不存在,那么会将其作为 window 或 globalThis 的属性进行检查。如下所示:
const name = '林语冰'
const cat = {
name: '薛定谔',
isSingle: true
}
// 搜索 cat 对象:
console.log(cat.name) // '薛定谔'
// 搜索作用域链,然后继续上溯搜索 window/globalThis:
console.log(name) // '林语冰'
在没有限定标识符的情况下,with 语句也能读写对象的属性。我们只需将它们作为独立的变量引用即可。
const cat = {
name: '薛定谔',
isSingle: true
}
with (cat) {
console.log(name) // '薛定谔'
console.log(isSingle) // `true`
}
这能奏效,因为 with() 把 cat 插入作用域链的开头,这意味着,在继续上溯搜索之前,这首先会搜索目标对象的值。顺便一提,您仍然可以从更宽泛的作用域读写变量 —— 您只需要依赖该显式标识符:
window.name = '林语冰'
with (cat) {
console.log(window.name) // "林语冰"
}
在某些方面,它提供了类似于解构赋值的人体工程学福利。这不需要重复标识符,语法脂肪会稍减。但另一个福利是,with() 内执行的代码包含在不同的区块作用域中。
为什么要弃用 with?
如果我们偷瞄 TC39 文档,我们会发现,with() 语句被标记为“历史包袱”,且不鼓励使用,但它并没有深入说明原因。虽然但是,如果我们偷瞄其他资料,就会发现若干主要原因。(顺便一提,我很可能遗漏了其他某些关于 with 的关键差评。如果您了解这些差评,请不吝赐教。)
1. 可读性感人
如果没有显式标识符,那么可能会写下某些难以阅读的“代妈屎山”。请瞄一下这个函数:
function doSomething(name, obj) {
with (obj) {
console.log(name)
}
console.log(name)
}
doSomething('林语冰', { name: '薛定谔' })
// '薛定谔'
// '林语冰'
乍一看,并不清楚 name 是什么鬼物 —— 是 obj 上的属性,还是传递给函数的参数。并且相同的变量名在整个函数体中引用了截然不同的值。这很令人懵逼,并且可能会让您搬起石头砸到自己的猫。毕竟,根据该变量的使用位置,解析其作用域的执行方式一龙一猪。
这是一个有意义的差评,但在我看来,这并非致命的批评。写下这样的代码是开发者(糟糕的)选择,并且很大程度上似乎可以通过教育来解决。
2. 作用域泄漏/意外属性读写
除此之外,由于其设计,我们可能会因为读写不打算处理的不同作用域内的属性,而无意中遭遇 bug。假设我们有一个处理包含了恋爱史的 cat 对象的函数。
const cat = {
history: ['girl1', 'lady2']
}
function processHistory(cat) {
with (cat) {
// 使用 history 属性搞事情
}
}
processHistory(cat)
直到您传递一个没有 history 属性的 cat 变量之前,这都能奏效。否则,history 将回退到 window.history(或作用域链上存在的其他某些 history 变量),导致意外 bug。
在这个简单的示例中,如果 history 是必需属性,那就问题不大(如果对象是通过对象字面量创建的,那么 TS 可以提供辅助),但我们可以看到在更复杂的场景中出现其他意外。我们正在修改作用域链。在某些时候,奇奇怪怪的事情肯定会发生。
3. 性能挑战
当与性能相关时,事情会变得更有趣。在我看来,这是我见过的最义愤填膺的差评。
当在 with 语句中读写属性时,不仅会在给定对象的顶层属性中搜索它的值,还会在其整个原型链中搜索。如果在原型链中搜索不到,它就会从一个作用域上溯另一个作用域继续搜索。每次属性读写都必然会遵循这种搜索顺序。根据具体需求的不同,这可能会导致某些缓慢的搜索和高性能雷区。
此处简述一下下,我们使用 with 读写 me 的属性,该属性位于原型链的底部:
const creature = { name: '碳基生物', planet: '地球' }
const mammal = Object.create(creature, { name: { value: '哺乳动物' } })
const human = Object.create(mammal, { name: { value: '人族' } })
const me = Object.create(human, { name: { value: '林语冰' } })
function outerFunction() {
const outer = 'outer'
function innerFunction() {
const inner = 'inner'
with (me) {
console.log(name, planet, inner, outer)
// '林语冰' '地球' 'inner' 'outer'
}
}
innerFunction()
}
outerFunction()
由于 name 直接存储在 me 对象上,因此搜索它没有太多开销。粉丝请记住 —— 目标对象的顶层属性优先被搜索。但 planet 不并非如此,它不在 me 对象上,因此原型链中的每个对象都会搜索一个值,直到找到为止。
普遍推荐的备胎方案
为了获得相同的“干净”变量规避此风险,通常建议使用解构赋值。我们重构之前的继承示例:
const creature = { name: '碳基生物', planet: '地球' }
const mammal = Object.create(creature, { name: { value: '哺乳动物' } })
const human = Object.create(mammal, { name: { value: '人族' } })
const me = Object.create(human, { name: { value: '林语冰' } })
const { name, planet } = me
console.log(name, planet)
// `林语冰` `地球`
我理解这为什么这会成为建议的替代方案:
- 关于变量的来源相对清晰。
- 编译器可以对读写属性的位置做出更好的假设(和优化),这有利于性能(由于仍需要搜索原型链,因此成本同样存在)。
- 您仍然可以使用没有标识符的变量。
但从可读性的角度来看,我并不完全相信它是一个有价值的选择。
为何 with() 有时优于解构赋值
使用 with() 的吸引力不仅仅在于“干净”的变量。它就在控制结构中。由于其周围的语法,很容易在 with() 语句中有意识地“存储”特定任务。该代码在词法作用域和动机上都与其余代码分开,更易于推理。
请想象一下,您正在处理一个 HTTP 请求,该请求在请求中传递某些信息,并且您以某种方式在 data 变量中读写它。您的目标是使用特定属性将记录保存到数据库中。以下是使用解构属性的方案:
const { imageUrl, width, height } = data
await saveToDb({
imageUrl,
width,
height
})
这很好,但是需要多一行代码来处理这些变量。另外,它们现在都是区块作用域,并且可能与方法中涉及的任何其他事情发生冲突。此时可能会有若干意见:
“只需将代码移动到它自己的方法中即可。”我不讨厌此意见。从 OOP(面向对象编程)设计的角度来看,这甚至可能是一件好事 —— 父方法会更精简和集中。但此建议感觉也像是针对使用解构赋值而引入的问题的解决方案,并且可以使用 with() 的语义来缓解。根据发生的其他情况,我可能不想创建一个不同的方法,但仍然希望有一个不同的作用域。
“将其全部包裹在一个临时区块中。”const 是区块作用域,这意味着,可以通过使用大括号创建新的块作用域来包含它:
const imageUrl = 'different-image.jpg'
{
const { imageUrl, width, height } = data
await saveToDb({
imageUrl,
width,
height
})
}
这里有某些值得褒奖的奇技淫巧,但您无法让我相信它更具可读性。这不是黑客攻击,但目测有点像黑客攻击。
诉诸 with() 重构
现在,针对同样的需求,这一次我们诉诸 with 语句来重构:
with (data) {
await saveToDb({
imageUrl,
width,
height
})
}
- 与 with 配对的控制结构一目了然,与 data 相关的特定事情会发生,并将某些内容保存到数据库中。
- 由于属性名称简写,我不需要先解构 data 的值,然后再将它们传递给我的方法。为我节省了一行代码。
- 任何变量都不能污染此方法的其他部分。
我依然认为,解构赋值是一个给力的功能(我经常使用它),但至少在可读性和语义方面,它并不能完全作为 with() 的替代方案。
性能考量
根据 with() 设计操作的本质,它绝对不是处理对象属性的最严格的最佳实践。但我有所质疑,鉴于代码中实际发生的情况,以及与人体工程学和易读性收益的权衡,这种担忧到底有多严重。
请考虑此栗子。在我见过的大多数 with() 案例中,大家使用的对象并不复杂。它们通常只是简单的键值对。因此,与大多数此类案例相比,我制作了一个相当大的案例。这是美国每个州的列表:
const states = {
alabama: 'AL',
alaska: 'AK',
arizona: 'AZ',
arkansas: 'AR',
california: 'CA'
//... 其余的州府
}
然后,我运行了一个快速基准测试来打印每个值。一项测试使用了 with():
with (states) {
console.log(alabama, alaska /* ...其余的属性 */)
}
另一项测试使用解构赋值:
const { alabama, alaska /* ...其余属性 */ } = states
console.log(alabama, alaska /* ...其余属性 */)
预料之内,with() 速度较慢,总体慢了约 23%:
图片
但如果您仔细观察这些数字,并将其置于现实世界中,就会发现此差异几乎是“没事找事”。毕竟,您在一秒钟内要处理数万个操作。
别误会我的意思。在对执行性能高度敏感的环境中,这可能会产生十分有意义的变化。但这些场景可能很少,而且它们可能不应该使用 JS。显然,它们是用 PHP 编写的。
最重要的是,值得指出的是,with() 远不是唯一一个在使用不当时,可能会导致性能适得其反的 JS 功能。仅举一个栗子:扩展运算符写起来确实很好,但如果在我们代码的其余部分中没有小心使用它,事情就会变得十分敏感。
with 可以更快吗?
我很乐意看到一个“更好”版本的 with() 卷土从来,并进行某些调整提高性能。
我不介意看到的一个十分具体的变化是不再搜索原型链。新版改进的 with() 只会考虑从 Object.getOwnPropertyNames() 返回的对象的顶层属性。此更改将减少解析变量所需的时间,尤其是当您完全访问 with() 上下文之外的变量时。
一个与此相关的有前途的基准测试。我制作了一个具有 100 个键/值对的对象,其原型链有 100 层深。每个层级都有相同的一组键/值对。这是用来制作它的零碎代码:
function makeComplicatedObject() {
const obj = Object.fromEntries(
Array.from({ length: 100 }).map((_, index) => [
`key_${index}`,
`value_${index}`
])
)
return obj
}
// 结果:100 个键值对,原型链 100 层深度
const deeplyNestedObject = Array.from({ length: 100 }).reduce(
(prevObj, _current, index) => {
let newObject = makeComplicatedObject()
Object.setPrototypeOf(newObject, prevObj)
return newObject
},
makeComplicatedObject()
)
然后,我在该深层嵌套对象和具有相同键/值对但没有巨大原型链的另一个对象之间运行了基准测试。每个代码片段都会简单地记录另一个局部变量。虽然但是,由于它位于 with() 内部,因此它必须首先等待这些对象被爬取。
结果应该不足为奇。该对象的“扁平”版本的搜索速度大约快 36%。
图片
这就说得通了。它并没有让 with() 遍历那个令人讨厌的原型链。我敢打赌,99.99% 的使用 with() 的现实代码也不需要这样做。
个人专属限量版 with
顺便一提,我在构建自己的更“限量版”的 with() 时度过了一段有趣的时光。它使用一个简单的 Proxy 来使 with() 认为该对象仅在它是顶级键之一时“具有”属性:
function limitedWith(obj, cb) {
const keys = Object.getOwnPropertyNames(obj)
const scopedObj = new Proxy(obj, {
has(_target, key) {
return keys.includes(key)
}
})
return eval(`
with(scopedObj) {
(${cb}.bind(this))();
}
`)
}
尽管使用 JS 来解决驱动引擎的原生代码无疑可以青出于蓝胜于蓝,但基准测试结果也不算太糟糕:
图片
当然,这只是桌面上的一项优化。我也不讨厌能够将目标作用域传递到 with() 的想法。默认情况下,它将在作用域链中一直搜索变量。但如果传递特定值,它将被限制在该作用域内:
with ((someObject, { scope: 'module' })) {
// 在 someObject 的外部,
// 当且仅当此模块作用域会被搜索
}
您并不了解所有用例
抛开这些问题不谈,当大家不鼓励使用某功能时,它们很可能会在有限范围的情况下思考,并在此过程中做出若干假设。它们可能包括但不限于:
- 执行速度兹事体大。
- 这是完成任务的低效方法。
- 该代码将始终在主线程上运行。
- 还有更多......
顺便一提,这样的假设通常是准确的,值得牢记在心。但它们并不总是准确的。它们经常忽视工程师被迫做出的特定权衡、它们正在构建的工具的目的或其他因素。
最好的证据是这样一个事实:由真正聪明、有洞察力的工程师编写的真正优秀、信誉良好的库,今天在它们的代码库中仍然有 with()。
有的库的大部分内容都不会在主线程中执行,因此它与大多数其他库相比,在一组根本不同的性能约束上运行。这可以说是一个特例,但至少与 with() 应该被撤销的主张相悖。毕竟,您必须有一个非常非常好的案例,才能移除某个功能,尤其是当它已经成为该语言的一部分这么久的情况下。
长话短说
- 使用 with() 存在某些独特的挑战和风险(尽管它们并不像 ES2015 之前那么糟糕)。
- 推荐的备胎方案还不够好。
- 无论如何,这些挑战和风险往往被夸大了。
- 尽管如此,我们或许可以构建更负责任的版本来取代它。
- 您可能没有理由普遍阻止一项已经存在很长时间的功能。