您不知道的 JS with 语句,让我来告诉您!

开发 前端
最重要的是,值得指出的是,with() 远不是唯一一个在使用不当时,可能会导致性能适得其反的 JS 功能。仅举一个栗子:扩展运算符写起来确实很好,但如果在我们代码的其余部分中没有小心使用它,事情就会变得十分敏感。

大家好,这里是大家的林语冰。

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 之前那么糟糕)。
  • 推荐的备胎方案还不够好。
  • 无论如何,这些挑战和风险往往被夸大了。
  • 尽管如此,我们或许可以构建更负责任的版本来取代它。
  • 您可能没有理由普遍阻止一项已经存在很长时间的功能。
责任编辑:武晓燕 来源: 人猫神话
相关推荐

2010-07-12 10:03:50

ibmdwjava

2011-04-02 13:11:35

JARJava

2011-04-02 13:35:21

多线程编程多线程java

2021-07-14 11:25:12

CSSPosition定位

2010-02-26 13:35:04

Python IDE

2015-06-16 10:25:22

2011-04-02 14:00:45

命令行JVMJava

2020-10-16 12:10:10

云架构云计算云端

2021-12-29 11:38:59

JS前端沙箱

2011-06-01 15:34:23

2020-07-16 08:32:16

JavaScript语言语句

2010-05-06 09:16:47

2011-11-01 12:22:11

京东商城小i机器人

2021-11-16 08:51:29

Node JavaScript变量类型

2024-02-05 11:55:41

Next.js开发URL

2011-04-28 13:00:15

投影幕投影

2019-01-01 05:33:53

物联网IOT连网设备

2020-08-20 14:50:43

谷歌工具开发者

2020-12-14 07:51:16

JS 技巧虚值

2020-06-12 09:20:33

前端Blob字符串
点赞
收藏

51CTO技术栈公众号