V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
iqoo
V2EX  ›  程序员

使用函数的风格调用 JS 方法

  •  
  •   iqoo · 2021-10-11 23:41:26 +08:00 · 1930 次点击
    这是一个创建于 1146 天前的主题,其中的信息可能已经有所发展或是发生改变。

    前言

    JS 调用方法的风格为 obj.method(...),例如 str.indexOf(...)arr.slice(...)。但有时出于某些目的,我们不希望这种风格。例如 Node.js 的源码中有很多 类似这样的代码

    const {
      ArrayPrototypeSlice,
      StringPrototypeToLowerCase,
    } = primordials
    
    // ...
    ArrayPrototypeSlice(arr, i)
    

    为什么不直接使用 arr.slice() 而要多此一举?

    因为 arr.slice() 实际调用的是 Array.prototype.slice,假如用户重写了这个方法,就会出现无法预期的结果。所以出于慎重,通常先备份原生函数,运行时只用备份的函数,而不用暴露在外的函数。

    调用

    备份原生函数很简单,但调用它时却有很多值得注意的细节。例如:

    // 备份
    var rawFn = String.prototype.indexOf
    // ...
    
    // 调用
    rawFn.call('hello', 'e')    // 1
    

    这种调用方式看起来没什么问题,但实际上并不严谨,因为 rawFn.call() 仍使用了 obj.method(...) 风格 —— 假如用户修改了 Function.prototype.call,那么仍会出现无法预期的结果。

    最简单的解决办法,就是用 ES6 中的 Reflect API:

    Reflect.apply(rawFn, 'hello', ['e'])    // 1
    

    不过同样值得注意,Reflect.apply 也未必是原生的,也有被用户重写的可能。因此该接口也需提前备份:

    // 备份
    var rawFn = String.prototype.indexOf
    var rawApply = Reflect.apply
    // ...
    
    // 调用
    rawApply(rawFn, 'hello', ['e'])    // 1
    

    只有这样,才能做到完全无副作用。

    简化

    有没有更简单的方案,无需用到 Reflect API 呢?

    我们先实现一个包装函数,可将 obj.method(...) 变成 method(obj, ...) 的风格:

    function wrap(fn) {
      return function(obj, ...args) {
        return fn.call(obj, ...args)
      }
    }
    const StringPrototypeIndexOf = wrap(String.prototype.indexOf)
    StringPrototypeIndexOf('hello', 'e')  // 1
    

    运行没问题,下面进入消消乐环节。

    v1

    即使没有包装函数,我们也可直接调用,只是稍显累赘:

    String.prototype.indexOf.call('hello', 'e')   // 1
    

    既然参数都相同,这样是否可行:

    const StringPrototypeIndexOf = String.prototype.indexOf.call
    StringPrototypeIndexOf('hello', 'e')  // ???
    

    显然不行!这相当于引用 Function.prototype.call,丢失了 String.prototype.indexOf 这个上下文。

    如果给 call 绑定上下文,这样就正常了:

    const call = Function.prototype.call
    const StringPrototypeIndexOf = call.bind(String.prototype.indexOf)
    StringPrototypeIndexOf('hello', 'e')   // 1
    

    整理可得:

    const call = Function.prototype.call
    
    function wrap(fn) {
      return call.bind(fn)
    }
    
    const StringPrototypeIndexOf = wrap(String.prototype.indexOf)
    StringPrototypeIndexOf('hello', 'e')  // 1
    

    v2

    既然 wrap(fn)call.bind(fn) 参数都相同,那么是否可继续简化,直接消除 wrap 函数?

    和之前一样,直接引用显然不行,而是要预先绑定上下文。由于会出现两个 bind 容易搞晕,因此我们拆开分析。

    回顾绑定公式:

    • 绑定前 obj.method(...)

    • 绑定后 method.bind(obj)

    call.bind(fn) 中,obj 为 call,method 为 bind。套入公式可得:

    bind.bind(call)
    

    其中第一个 bind 为 Function.prototype.bind

    整理可得:

    const call = Function.prototype.call
    const wrap = Function.prototype.bind.bind(call)
    
    const StringPrototypeIndexOf = wrap(String.prototype.indexOf)
    StringPrototypeIndexOf('hello', 'e')  // 1
    

    v3

    到此已没有可消除的了,但我们可以用更短的函数名代替 Function.prototype,例如 Map 、Set 、URL 或者自定义的函数名。

    出于兼容性,这里选择 Date 函数:

    const wrap = Date.bind.bind(Date.call)
    const StringPrototypeIndexOf = wrap(String.prototype.indexOf)
    StringPrototypeIndexOf('hello', 'e')  // 1
    

    结尾

    现在我们可用更简单、兼容性更好的方式,将方法函数化,并且无副作用:

    const wrap = Date.bind.bind(Date.call)
    
    const StringPrototypeIndexOf = wrap(String.prototype.indexOf)
    const StringPrototypeSubstr = wrap(String.prototype.substr)
    
    StringPrototypeIndexOf('hello', 'e')  // 1
    StringPrototypeSubstr('hello', 2, 3)  // "llo"
    
    第 1 条附言  ·  2021-10-13 15:33:01 +08:00

    防重写只是其中一个用法,并不是主目的。还有其他应用案例,比如用于函数式编程,一切都用函数实现。例如:

    const wrap = Date.bind.bind(Date.call)
    
    const find = wrap(String.prototype.indexOf)
    const mid = wrap(String.prototype.substr)
    
    find('hello', 'e')  // 1
    mid('hello', 2, 3)  // "llo"
    
    9 条回复    2021-10-13 17:03:52 +08:00
    learningman
        1
    learningman  
       2021-10-12 10:53:43 +08:00
    总觉得没必要管这个,正常用户不会动这个,非法用户你在 js 做啥防护都是透明的还是白折腾
    galikeoy
        2
    galikeoy  
       2021-10-12 12:56:04 +08:00
    都无法确定代码执行环境的正确性了,你这些还有意义吗
    markgor
        3
    markgor  
       2021-10-12 15:18:10 +08:00
    虽然说每件大事都是由一堆看不起眼的小事组合而成的,
    但是我觉得没这个必要...
    就如 1# 2# 所说到的,
    如果这种做法成为常态,那应该是编译器上干的事,把所有原生方法都克隆一份出来,再给出个文档介绍如何使用,而不是应用级别需要去考虑的。
    ---我不是专业前端,只是出于流程上进行考虑。
    CPoet
        4
    CPoet  
       2021-10-13 09:48:09 +08:00   ❤️ 1
    过度考虑了哈,不如直接约定。
    JerryCha
        5
    JerryCha  
       2021-10-13 11:27:37 +08:00
    写一个 proto-method.js ,今年的 KPI 就有了
    lisongeee
        6
    lisongeee  
       2021-10-13 11:43:26 +08:00
    >不过同样值得注意,Reflect.apply 也未必是原生的,也有被用户重写的可能。因此该接口也需提前备份

    既然它会被重写,那么如果用户先是重写了 Reflect.apply 再调用 你提供的方法,此时你拿到的就已经是修改过的 Reflect.apply,这种情况你怎么办?
    iqoo
        7
    iqoo  
    OP
       2021-10-13 15:29:15 +08:00
    @lisongeee 这样就没办法了,前提是自己的代码最先运行,就像 node.js 里的案例一样。
    iqoo
        8
    iqoo  
    OP
       2021-10-13 15:34:39 +08:00
    @learningman
    @galikeoy
    @markgor
    @llzero54 防重写只是其中一个用法,并不是主目的。还有其他应用案例,比如用于函数式编程,一切都用函数实现,包括方法调用。
    markgor
        9
    markgor  
       2021-10-13 17:03:52 +08:00
    @iqoo
    函数式 和 方法式 我觉得没必要要求统一...
    当然仅仅是个人习惯而已,
    针对前端 1 年以上经验的,哪种使用方法调用和哪种使用函数调用基本都了解了。
    但是突如其来都变成函数式,他还要去追代码看......

    我知道不能以我的个人习惯去否决其他人的个人习惯,
    只能说对我而言意义不大,毕竟我只是个后端,前端太多东西让人眼花缭乱....
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1410 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 24ms · UTC 17:34 · PVG 01:34 · LAX 09:34 · JFK 12:34
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.