◆

JavaScript 最小特权原则详解与应用场景

2023-02-02 01:370

我们写 JavaScript 时很容易顺手把变量和函数都摆在最外层,因为这样谁都能拿到、调试也方便。但一个模块暴露出去的每一个名字都是一份对外承诺:别人可能读它、改它,我们以后就不能随便重命名或者删掉它。最小特权原则说的就是反过来做,默认全部隐藏,只把调用方真正需要的那一小部分接口露出来。

这条原则在 JavaScript 里落地的成本很低,因为函数天然就是一个作用域,把变量挪进函数体内部它就变成了私有变量;再配合闭包、ES Module 和类的私有字段,我们几乎不需要额外的语言特性就能把暴露面控制住。下面先看一个典型的过度暴露的例子,再看几种把它收紧的写法。

最小特权原则 (也叫最小授权或最小暴露原则)

指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的 API 设计。

关于最小特权应用的场景

如下代码,subFn 以及变量 b 其实是在 parFn 中使用的,但现在这两个变量处 parFn 的外部作用域, 如果外部作用域不需要这两个变量,依据最小特权原则,应该要将这两个变量设计在 parFn 中,作为 parFn 的私有变量。

function parFn(a) {
  b = a + subFn(a * 2);
  console.log(b * 3);
}

function subFn(a) {
  return a - 1;
}

var b;
parFn(2); // 15

修改后

function parFn(a) {
  var b;

  b = a + subFn(a * 2);
  console.log(b * 3);

  function subFn(a) {
    return a - 1;
  }
}

parFn(2); // 15

为什么把东西放在全局的代价这么高

上面这个改动看起来只是挪了几行位置,省掉的麻烦却不少:

  1. 避免命名冲突。全局作用域是所有脚本共享的,b 这种短名字一旦被另一个文件也用上,后写入的那个会静默覆盖前一个,排查起来非常费时间。
  2. 避免被外部篡改。全局变量任何一行代码都能改,parFn 没办法保证自己算到一半时 b 还是它写进去的值。
  3. 让垃圾回收有机会工作。函数内部的 b 在调用结束后就没有引用了,可以被回收;挂在全局上的变量会一直活到页面关闭。
  4. 缩小重构半径。私有变量改名、改类型只影响函数内部这几行,不需要全局搜索谁在用它。

顺带提一个容易踩的点:非严格模式下给一个没声明过的变量赋值会隐式创建全局变量,也就是说即便把原始代码里的 var b 删掉,b = ... 依然能跑通,只是悄悄污染了全局。开启严格模式('use strict')后这种写法会直接抛 ReferenceError,这也是我们默认应该开严格模式的原因之一。

用闭包只暴露必要的接口

上面的例子里所有东西都藏起来了,实际开发中我们更常遇到的情况是「大部分要藏,少数几个要露」。这时可以用一个立即执行函数包住实现细节,只把接口对象返回出去。counter 内部的 count 没有任何办法从外部直接改到,只能走 inc 和 value:

const counter = (function () {
  let count = 0; // 私有状态,外部拿不到

  function clamp(n) {
    // 私有工具函数,不对外暴露
    return Math.max(0, n);
  }

  return {
    inc(step = 1) {
      count = clamp(count + step);
      return count;
    },
    get value() {
      return count;
    },
  };
})();

counter.inc(2);
console.log(counter.value); // 2
console.log(counter.count); // undefined

模块与类里的等价做法

在 ES Module 中,默认就是最小特权的:一个文件里没有 export 的绑定外部完全访问不到,我们只需要克制住「顺手都 export 一遍」的冲动。

// counter.js
let count = 0; // 模块私有

export function inc(step = 1) {
  count += step;
  return count;
}

写类的时候可以用 # 私有字段,它是语法层面的硬约束,在外部访问会直接抛 SyntaxError,比靠下划线前缀的命名约定可靠得多:

class Counter {
  #count = 0;

  inc() {
    return ++this.#count;
  }
}

const c = new Counter();
c.inc();
// c.#count  // 语法错误,无法从类外部访问

应用这条原则时的边界

最小特权不等于什么都藏。判断标准是「这个名字会不会被调用方合理使用」:会被使用的就设计成正式接口并写清语义,不会被使用的一律收进作用域内。有两种常见的过度收紧值得警惕:一是为了隐藏而把函数嵌套得很深,导致每次外层调用都重新创建内层函数、丢掉了复用;二是把测试真正需要的接缝也藏掉,逼着测试去测私有实现。遇到后者,更好的做法是把那部分逻辑拆成独立模块,让它自己成为一个有明确接口的单元,而不是在原模块上开后门。

💬留言板0

还没有留言,来发表第一条吧~

PAGE LOAD / LIVEJavaScript 最小特权原则详解与应用场景加载中