JavaScript 闭包在高阶函数中的一个极其隐蔽的坑

今天拜读大牛 Micheal Fogus 的神作『Functional JavaScript』,发现 JavaScript 闭包中一个极其隐蔽的坑,特此梳理,也希望能帮到更多后来人。

言归正传。在第三章讨论闭包的问题时,大牛提到一个对判定条件取反的案例:

function complement(PRED) {
    return function() {
        return !PRED.apply(null, _.toArray(arguments));
    };
}

剔除 Underscore.js 的干扰,并简化为 ES6 的写法为:

const complement = PRED => (...args) => !PRED.apply(null, args);

如果有一个判定偶数的谓词函数 isEven,并需要结合 complement 得到一个判定奇数的新函数 isOdd,可以写为:

const complement = PRED => (...args) => !PRED.apply(null, args);
let isEven = n => (n % 2) === 0;
const isOdd = complement(isEven);
isOdd(2); // => false
isOdd(413); // => true

问题来了:如果将 isEven 篡改为其他函数,isOdd 会受影响吗?

isEven = () => false;
console.assert(isOdd(2) === true, 'isOdd 不受影响');
// => Assertion failed: isOdd 不受影响

也就是说,isOdd 的闭包,是函数声明时对初始 isEven 的引用;后续的赋值只是修改了 isEven 的引用,并不影响 isOdd,除非 isEven 又被用于生成新的谓词函数(如 isOddNew),那就会受影响了:

isEven = () => false;
const isOddNew = complement(isEven);
console.assert(isOddNew(2) === false, 'isOddNew 受影响');
// => Assertion failed: isOddNew 受影响

实际应用中,遇到的更常见的情况是,isEven 很可能来自一个通用工具模块:

const myUtils = { isEven: n => (n % 2) === 0 };
const complement2 = wrapper => 
	(...args) => !wrapper.isEven.apply(null, args);
const isOdd2 = complement2(myUtils);
console.log('before:', isOdd2(2)); // => before: false
myUtils.isEven = () => false;
console.log('after:', isOdd2(2)); // => after: true

可见,即便传入的对象引用不变,当新函数真正调用的是该引用下的某个属性时,修改这个属性的引用,也会影响到生成的函数。解决这个问题至少有两种思路:

  1. 尽量避免传入目标引用的包裹对象;
  2. 使用解构赋值直接锁定目标引用;

第一条不用多说,第二条示例如下:

// 使用解构赋值直接锁定目标引用
const myUtils = { isEven: n => (n % 2) === 0 };
const complement3 = ({isEven}) => 
	(...args) => !isEven.apply(null, args);
const isOdd3 = complement3(myUtils);
console.log('before:', isOdd3(2)); // => before: false
myUtils.isEven = () => false;
console.log('after:', isOdd3(2)); // => after: false

除了上述补救措施外,要从根本上杜绝闭包引用被篡改的情况,应该像书中说的那样,最大限度降低被捕获变量的暴露风险(如使用 Revealing Module Pattern 设计模式,或 class 语法糖):

// myUtils.mjs
export const isEven = n => (n % 2) === 0;
// index.mjs
import { isEven } from './myUtils.mjs';
const {isOdd: isOdd4} = (() => {
    const complement = PRED => 
    	(...args) => !PRED.apply(null, args);
    const isOdd = complement(isEven); // captured variable
    return { isOdd };
})();
console.log(isOdd4(2));
// => false

综上所述,利用 JavaScript 闭包特性及高阶函数创建新函数时,需要注意以下三点:

  1. 闭包的引用尽量直奔主题,不要传入含有目标引用的任何包裹对象;
  2. 如果实在避免不了传入包裹对象,最好使用解构赋值锁定目标引用;
  3. 最大限度降低被捕获变量的暴露风险。

你可能感兴趣的:(JS相关,日常小问题,javascript,闭包,高阶函数,函数式编程,closure)