For AI agents: the complete documentation index is available at /tc39-atlas/llms.txt, the full documentation bundle is available at /tc39-atlas/llms-full.txt, and this page is available as Markdown at /tc39-atlas/proposals/year/pending/proposal-call-this.md.
  • 简体中文
  • Call-this operator S1

    中文标题:调用此操作符

    提案概览
    提案速览

    该提案引入了一种新的 ~> 操作符,以简化函数调用的 this 绑定更改,将诸如 fn.call(rec, arg0) 这样的冗长模式替换为 rec~>fn(arg0)。这是旧绑定操作符提案的重生,并直接与扩展提案竞争。

    Note

    以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。

    JavaScript 的 Call-this 操作符

    ECMAScript Stage-1 提案。J. S. Choi,2021 年。

    本提案是旧 Stage-0 绑定操作符提案复兴。它也是 Stage-1 扩展提案 的一个替代、 竞争性 提案。更多信息,请参阅 § 相关提案

    语法

    语法正在 issue #10 中讨论。

    暂定语法
    receiver~>fn(arg0, arg1)
    receiver~>ns.fn(arg0, arg1)
    receiver~>(expr)(arg0, arg1)
    receiver

    一个成员表达式、调用表达式、可选表达式、带参数的 new 表达式、另一个 call-this 表达式或括号表达式。该表达式解析到的值将作为右侧函数对象的 this 接收者绑定到该函数对象。

    fn

    一个必须解析为函数对象的变量。

    ns

    右侧可以是命名空间对象的变量,后跟一系列属性标识符,而不是单个变量。该链必须解析为函数对象。

    expr

    括号内的任意表达式,必须解析为函数对象。

    arg0, arg1

    一系列参数表达式,可以包含展开 ... 语法。

    描述

    语法正在 issue #10 中讨论。

    暂定描述

    (有 正式规范 可用。)

    call-this 操作符 ~> 是一个 左结合 的二元操作符。它以与 Function.prototype.call 相同的方式,将其右侧(一个 函数)的 this 值绑定到其左侧(一个 接收者 值),以及任何给定的参数。

    例如,receiver~>fn(arg0, arg1) 将等同于 fn.call(receiver, arg0, arg1)(只不过即使其他代码重新赋值全局方法 Function.prototype.call,其行为也不会改变)。

    同样,receiver~>(createFn())(arg0, arg1) 将大致等同于 createFn().call(receiver, arg0, arg1)

    如果操作符的右侧在运行时没有计算为函数,则程序会抛出 TypeError 异常。

    操作符的 左侧 与成员表达式、调用表达式、带参数的 new 表达式和可选表达式具有相同的 优先级。与这些操作符一样,call-this 操作符也可以被左侧的可选表达式短路。

    左侧示例组合方式
    成员表达式obj.prop~>fn(a)(obj.prop)~>fn(a)
    调用表达式obj()~>fn(a)(obj())~>fn(a)
    可选表达式obj?.prop~>fn(a)(obj?.prop)~>fn(a)
    带参数的 new 表达式new C()~>fn(a)(new C())~>fn(a)

    操作符的 右侧,与装饰器一样,可以是以下之一:

    • 单个 标识符私有字段(如 fn#field)。
    • 标识符和/或私有字段的 (如 ns.fnthis.ns.#field)。
    • 括号内的 表达式(如 (createFn()))。

    例如,receiver~>ns.ns.ns.fn 组合为 receiver~>(ns.ns.ns.fn)

    .?. 操作符类似,call-this 操作符可以有或没有空白填充。
    例如,receiver~>fn
    等同于 receiver~>fn
    并且 receiver~>(createFn())
    等同于 receiver~>(createFn())

    call-this 操作符可以可选地与 ?. 链式使用(即 ?.~>):
    receiver~>fn 总是会产生一个绑定的函数,
    无论 receiver 是否为 nullish。
    receiver ?.~>fn 如果 receivernullundefined,其结果将为 nullundefined
    receiver ?.~>fn(arg) 如果 receiver 是 nullish,也会照常短路,在 arg 求值之前。

    new 表达式 不能 包含不带括号的 call-this 表达式。new x~>fn() 是 SyntaxError。
    否则,new x~>fn() 会在视觉上产生歧义,既可能是
    (new x)~>fn() 也可能是 new (x~>fn())

    为什么需要 call-this 操作符

    简而言之:

    1. .call 在 JavaScript 代码库中非常有用且非常常见。
    2. .call 笨拙且不符合人体工程学。

    .call 非常常见

    动态的 this 绑定是当今 JavaScript 设计和实践的基本部分。因此,开发人员经常需要更改 this 绑定。.call 可以说是整个 JavaScript 中最常用的函数之一。

    我们可以使用 Node Gzemnid 来估计 .call 的普遍性。虽然 Gzemnid 可能具有欺骗性,但我们只是寻求粗略的估计。

    以下结果来自下载量排名前 1000 的 NPM 包的已检入 Git 的源代码。

    出现次数方法
    1,016,503.map
    701,169.push
    315,922.call
    271,915console.log
    182,292.slice
    170,248.bind
    168,872.set

    这些结果表明,.call 的使用频率与其他常用标准函数相当。在此数据集中,其使用频率甚至超过了 console.log

    显然,此方法存在许多陷阱,但我们只是相对于其他基线函数寻找粗略的数量级估计。Gzemnid 只计算每个库的代码库一次;它不会重复计算依赖项。

    这些结果是使用什么方法获得的?

    首先,我们下载 2019-06-04 的 预构建 Gzemnid 数据集,用于下载量排名前 1000 的 NPM 包。我们还需要位于同一活动目录中的 Gzemnid 的 search.topcode.sh 脚本,它需要 lz4 命令套件。search.topcode.sh 将输出前 1000 个包中符合给定正则表达式的代码行。

    ./search.topcode.sh '\.call\b' | head
    grep -aE  "\.call\b"
    177726827	debug@4.1.1/src/common.js:101:					match = formatter.call(self, val);
    177726827	debug@4.1.1/src/common.js:111:			createDebug.formatArgs.call(self, args);
    154772106	kind-of@6.0.2/index.js:54:  type = toString.call(val);
    139612972	readable-stream@3.4.0/errors-browser.js:26:      return _Base.call(this, getMessage(arg1, arg2, arg3)) || this;
    139612972	readable-stream@3.4.0/lib/_stream_duplex.js:60:  Readable.call(this, options);
    139612972	readable-stream@3.4.0/lib/_stream_duplex.js:61:  Writable.call(this, options);
    139612972	readable-stream@3.4.0/lib/_stream_passthrough.js:34:  Transform.call(this, options);
    139612972	readable-stream@3.4.0/lib/_stream_readable.js:183:  Stream.call(this);
    139612972	readable-stream@3.4.0/lib/_stream_readable.js:786:  var res = Stream.prototype.on.call(this, ev, fn);

    我们使用 awk 计算这些匹配的代码行数,并将它们与 call 和其他几个常用函数的数量进行比较。

    > ls
    search.topcode.sh
    slim.topcode.1000.txt.lz4
    > ./search.topcode.sh '\.call\b' | grep -E --invert-match '//.*\.call|/\*.+\.call|[^a-zA-Z][A-Z][a-zA-Z0-9_$]*\.call\( *this|_super\.call|_super\.prototype\.|_getPrototypeOf|_possibleConstructorReturn|__super__|WEBPACK VAR INJECTION|_objectWithoutProperties|\.hasOwnProperty\.call' | awk 'END { print NR }'
    315922
    > ./search.topcode.sh '\.bind\b' | awk 'END { print NR }'
    170248
    > ./search.topcode.sh '\b.map\b' | awk 'END { print NR }'
    1016503
    > ./search.topcode.sh '\bconsole.log\b' | awk 'END { print NR }'
    271915
    > ./search.topcode.sh '\.slice\b' | awk 'END { print NR }'
    182292
    > ./search.topcode.sh '\.set\b' | awk 'END { print NR }'
    168872
    > ./search.topcode.sh '\.push\b' | awk 'END { print NR }'
    701169

    请注意,对于 .call,我们使用 grep 排除 .call 在注释中或由转译代码产生的一些不相关的出现。我们倾向于过度排除。

    排除的模式原因
    //.*\.call代码注释。
    /\*.+\.call代码注释。
    [^a-zA-Z][A-Z][a-zA-Z0-9_$]*\.call\( *this已被 super 取代的构造函数调用。见说明。
    _super\.callBabel 转译的 super() 产物。
    _super\.prototype\.Babel 转译的 super.fn() 产物。
    _objectWithoutPropertiesBabel 转译的 ... 产物。
    _getPrototypeOfBabel 产物。
    _possibleConstructorReturnBabel 产物。
    __super__CoffeeScript 产物。
    WEBPACK VAR INJECTIONWebpack 产物。
    \.hasOwnProperty\.call已被 Object.has 取代。

    这些排除的模式由一位独立调查员(Scott Jamison)在 手动审查数据集中前 10,000 个 .call 出现 用于确定每个出现的原因后确定。

    排除的 [^a-zA-Z][A-Z][a-zA-Z0-9_$]*\.call\( *this 模式值得特别注意。此模式匹配任何大写标识符后跟 .call(this。我们排除任何此类出现,因为任何大写标识符可能指的是构造函数,并且对构造函数使用 .call 已经在很大程度上被 classsuper 语法所取代。此模式可能会错误地从我们的计数中排除许多合法使用 .call 的情况,但这种对 .call 的偏见对于粗略比较的目的是可以接受的。


    开发人员使用 .call 的原因有很多。这些包括:

    包装接收者的方法,然后调用它:

    // 包装接收者的方法,然后调用它:
    assertFunction(obj.f).call(obj, f);
    
    // 从 bluebird@3.5.5。
    tryCatch(item).call(boundTo, e);

    在两种方法之间条件切换

    const method = obj.f ?? obj.g;
    method.call(obj, arg0, arg1);
    
    // 从 debug@4.1.1。
    // createDebug 是用于 Node 或 web 浏览器的对象。
    createDebug.formatArgs.call(self, args);

    在猴子补丁的对象上重用原始方法

    // 从 graceful-fs@4.1.15。
    return fs$read.call(fs, fd, /*…*/)

    保护方法调用免受 原型污染

    // 从 lodash@4.17.11。
    // Object.prototype.toString 被缓存为 nativeObjectToString。
    nativeObjectToString.call(value);

    …以及其他原因。开发人员使用 .call 完成所有这些操作,这些用途的总和使 .call 成为整个语言中最常用的操作之一。

    .call 很笨拙

    尽管它很常见,但 .call 既笨拙又难以阅读。它用样板代码将函数与其接收者和参数分开,并且颠倒了“自然”的词序,导致动词 .call-主语-宾语词序:

    fn.call(rec, arg0).

    JavaScript 开发人员习惯于使用类似于英语和其他 SVO 人类语言主语-动词-宾语词序 的方法。这种模式在 JavaScript 中作为点方法调用无处不在:

    rec.method(arg0).

    考虑以下使用 .call 的真实代码,并将它们与使用 call-this 操作符的版本进行比较。当你大声朗读时,差异尤其明显。

    // kind-of@6.0.2/index.js
    type = toString.call(val);
    type = val~>toString();
    
    // debug@4.1.1/src/common.js
    match = formatter.call(self, val);
    match = self~>formatter(val);
    
    createDebug.formatArgs.call(self, args);
    self~>createDebug.formatArgs(args);
    
    // rxjs@6.5.2/src/internal/operators/every.ts
    result = this.predicate.call(this.thisArg, value, this.index++, this.source);
    result = this.thisArg~>this.predicate(value, this.index++, this.source);
    
    // bluebird@3.5.5/js/release/synchronous_inspection.js
    return isPending.call(this._target());
    return this._target()~>isPending();
    
    var matchesPredicate = tryCatch(item).call(boundTo, e);
    var matchesPredicate = boundTo~>(tryCatch(item))(e);
    
    // async@3.0.1/internal/initialParams.js
    var callback = args.pop(); return fn.call(this, args, callback);
    var callback = args.pop(); return this~>fn(args, callback);
    
    // ajv@6.10.0/lib/ajv.js
    validate = macro.call(self, schema, parentSchema, it);
    validate = self~>macro(schema, parentSchema, it);
    
    // graceful-fs@4.1.15/polyfills.js
    return fs$read.call(fs, fd, buffer, offset, length, position, callback)
    return fs~>fs$read(fd, buffer, offset, length, position, callback)

    简而言之:

    非常常见 × 非常笨拙 = 值得用语法改进

    关于生态系统分裂的担忧

    多种方式或语法做事是否有害,关键取决于重复对 API 的影响及其病毒式传播的程度。

    假设我们正在考虑使用两种语法 𝘟 和 𝘠 来使用 API。如果模块或个人 𝘈 使用语法 𝘟,它比语法 𝘠 与语法 𝘟 的互操作性更好,并且这迫使模块或个人 𝘉 在其新 API 中使用语法 𝘟,以与个人 𝘈 的 API 互操作,这种病毒式传播会鼓励生态系统分裂和 API 战争。在语言中引入多种这样的方式是糟糕的。

    “另一方面,如果个人 𝘈 的语法选择 [即 𝘟] 对个人 𝘉 [的语法选择,𝘠] 没有影响,并且他们可以轻松地互操作,那么这通常是良性的。”

    摘自 2022-01-27 数据流会议

    • 𝘟:某些 API(如“函数式”API)使用不基于 this 的 ƒ。
    • 𝘠:某些 API(如“面向对象”API)使用基于 this 的 ƒ。

    𝘟 API 和 𝘠 API 之间的这种分裂已经内置于语言中。这种分裂非常明显,以至于像 Firebase JS SDK 已经切换 这样的知名 API 从 𝘠 切换到了 𝘟(例如,为了模块拆分)。

    但是 call-this 操作符,连同 管道操作符 |>,将使 𝘟 和 𝘠 之间的互操作性更加流畅 – 并且会使 𝘟 和 𝘠 之间的选择变得更不具有病毒式传播性 – 弥合分裂:

    import { x0, x1 } from '𝘟';
    import { y0, y1 } from '𝘠';
    input |> x0(@)~>y0() |> x1(@)~>y1();

    非目标

    本提案的目标是 简单。因此,本提案有意 处理以下用例:

    函数绑定方法提取不是本提案的目标。

    更改函数的 this 接收者比函数绑定更常见,如前面的统计所示。一些 TC39 代表表示担心函数绑定可能与诸如 PFA(部分函数应用)语法 之类的提案重复。因此,我们将这两个功能推迟到未来的提案中。

    提取属性访问器(即 getter 和 setter)也不是本提案的目标。Get/set 访问器 不像 方法。方法是 属性(恰好是函数)。访问器本身 不是 属性;它们是在获取或设置属性时激活的函数。

    可以使用 Object.getOwnPropertyDescriptor 提取 getter/setter;它们没有特殊处理。这种冗长可能被认为是可取的 语法盐:它使开发人员的意图(提取 getter/setter - 而不是方法)更加明确。

    const { get: $getSize } =
      Object.getOwnPropertyDescriptor(
        Set.prototype, 'size');
    
    // 对手的代码。
    delete Set; delete Function;
    
    // 我们自己的受信任代码,稍后运行。
    new Set([0, 1, 2])~>$getSize();

    函数/表达式应用,即将深度嵌套的函数调用和其他表达式展开为线性管道,非常重要,但本提案未涉及。相反,它由 管道操作符 处理,本提案的语法可以很好地与其配合使用。

    相关提案

    旧绑定操作符

    本提案是旧 Stage-0 绑定操作符提案复兴。(旧提案的拥护者 建议用新提案重新开始,而不是使用旧提案。)

    新提案与旧提案基本相同。唯一大的区别是,在方法提取期间,没有用于隐式绑定接收者的一元形式。(另请参阅 非目标。)

    扩展

    扩展系统是 Stage-1 扩展提案 的一个替代、 竞争性 提案。

    还提供了 深度比较。具体差异简要如下:

    1. Call-this 没有特殊的变量命名空间。
    2. Call-this 没有属性访问器的隐式语法处理。
    3. Call-this 没有多态的 const ::{ … } from …; 语法。
    4. Call-this 没有多态的 …::…:… 语法。
    5. Call-this 没有 Symbol.extension 元编程系统。

    管道操作符

    管道操作符 是一个 互补 的提案,可用于将深度嵌套的表达式(如 f(0, g([h()], 1), 2))线性化为 h() |> g(^, 1) |> f(0, ^, 2)

    这与 call-this 操作符的目的根本不同,后者更接近属性访问 .

    确实,属性访问 .、call-this 和管道操作符都可以用于线性化代码。但这对于前两个操作符来说只是一个令人愉快的副作用:

    • 属性访问与对象成员关系紧密耦合。
    • Call-this 只是改变函数调用的 this 绑定。

    相比之下,管道操作符旨在普遍线性化所有其他类型的表达式。

    |> 不能改善 .call 的笨拙性。这是笨拙(且频繁)的现状:

    fn.call(rec, arg0)

    引入 管道操作符 |> 解决了 词序 问题,但结果 更不 可读。过多的样板代码将函数与其接收者和参数分开:

    rec |> fn.call(@, arg0) // 更不可读。

    只有单独的操作符才能在保持可读性的同时改善词序:

    rec~>fn(arg0)

    管道冠军组一直在研究是否可以修改管道操作符来解决 .call 的笨拙性,同时仍然解决管道的其他用例(例如,不基于 this 的 n-ary 函数调用;异步函数调用)。它仍然没有找到除了单独操作符之外的任何解决方案。

    就像管道操作符与属性访问共存一样:

    // 改编自 react@17.0.2/scripts/jest/jest-cli.js
    Object.keys(envars)
      .map(envar => `${envar}=${envars[envar]}`)
      .join(' ')
      |> `$ ${^}`
      |> chalk.dim(^, 'node', args.join(' '))
      |> console.log(^);

    …它也可以与 call-this 一起工作:

    // 改编自 chalk@2.4.2/index.js
    return this._styles
      |> (^ ? ^.concat(codes) : [codes])
      |> this~>build(^, this._empty, key);

    PFA 语法

    PFA(部分函数应用)语法 ~() 将简洁地创建部分应用的函数。

    PFA 语法 ~() 和 call-this ~> 也是互补的,处理不同的用例。

    例如,obj.method~() 将处理带隐式绑定的方法提取,这是 call-this 未解决的。换句话说,当接收者对象本身包含我们想要绑定的函数时,我们需要使用 call-this 重复一次接收者。PFA 语法将允许我们避免重复接收者。

    n.on("click", v.reset.bind(v))
    n.on("click", v.reset~())

    相比之下,call-this 改变函数调用的接收者。receiver~>fn()。(这个未绑定的函数可能已经从另一个对象中提取出来。)PFA 语法不能解决这个用例。

    // bluebird@3.5.5/js/release/synchronous_inspection.js
    isPending.call(this._target())
    this._target()~>isPending()