杂项

Proxy 与 Reflect

Proxy 是 JavaScript 的元编程利器:它包装目标对象,拦截并自定义底层操作(属性读写、函数调用、in 运算符等)。
Reflect 是与 Proxy 配套的 API,提供与 Proxy 捕捉器一一对应的方法,用于安全转发操作到目标对象。

核心概念

概念说明
目标对象 (target)被包装的原对象,可以是任何类型(对象、数组、函数、类实例)。
处理器 (handler)包含捕捉器 (traps) 的对象,定义拦截逻辑。
捕捉器 (trap)对应内部方法(如 [[Get]][[Set]])的拦截函数。
透明转发若 handler 为空或某捕捉器缺失,操作直接透传给 target。
不变量 (Invariant)必须遵守的强制规则(如 set 成功必须返回 true)。

基础语法与透明代理

let target = { name: 'John' };
let proxy = new Proxy(target, {}); // 空 handler = 完全透明

proxy.age = 30;      // 写入 proxy → 实际写入 target
console.log(target); // { name: 'John', age: 30 }
console.log(proxy.name); // 'John'(读取也透传)

// Proxy 自身无属性,只是 target 的透明包装
console.log(proxy === target); // false(不同对象)

关键原则:代理后应彻底替换对 target 的引用,避免混用导致混乱。

帇用捕捉器速查表

内部方法捕捉器触发场景典型用途
[[Get]]get读取属性 obj.prop默认值、访问控制、计算属性
[[Set]]set写入属性 obj.prop = val验证、观察变化、私有保护
[[HasProperty]]has'prop' in obj虚拟属性存在性检查
[[Delete]]deletePropertydelete obj.prop保护属性不被删除
[[Call]]apply函数调用 fn()装饰器、延迟、参数预处理
[[Construct]]constructnew Class()单例模式、参数验证
[[OwnPropertyKeys]]ownKeysObject.keys, for...in隐藏特定属性
[[GetOwnProperty]]getOwnPropertyDescriptor获取属性描述符配合 ownKeys 使虚拟属性可枚举

实战模式

1. 默认值模式(get 捕捉器)

// 数组越界返回 0,而非 undefined
let numbers = [0, 1, 2];
numbers = new Proxy(numbers, {
  get(target, prop) {
    return prop in target ? target[prop] : 0;
  }
});

console.log(numbers[1]);   // 1
console.log(numbers[99]);  // 0(优雅降级)
// 词典:未翻译短语原样返回
let dictionary = { 'Hello': 'Hola' };
dictionary = new Proxy(dictionary, {
  get(target, phrase) {
    return phrase in target ? target[phrase] : phrase;
  }
});

console.log(dictionary['Hello']);    // Hola
console.log(dictionary['Welcome']);  // Welcome(未翻译原样返回)

2. 验证模式(set 捕捉器)

// 纯数字数组:写入非数字抛出 TypeError
let numbers = [];
numbers = new Proxy(numbers, {
  set(target, prop, val) {
    if (typeof val !== 'number') {
      return false; // 触发 TypeError
    }
    target[prop] = val;
    return true; // 必须返回 true 表示成功
  }
});

numbers.push(1);      // OK
numbers.push('oops'); // TypeError: 'set' on proxy returned false

注意:数组方法(pushpop 等)内部使用 [[Set]],因此自动被拦截,无需重写。

3. 私有属性保护(多捕捉器组合)

let user = {
  name: 'John',
  _password: 'secret123'
};

user = new Proxy(user, {
  // 拦截读取
  get(target, prop) {
    if (prop.startsWith('_')) throw new Error('Access denied');
    const val = target[prop];
    // 方法需绑定 target,否则内部访问 _password 会触发代理
    return typeof val === 'function' ? val.bind(target) : val;
  },
  
  // 拦截写入
  set(target, prop, val) {
    if (prop.startsWith('_')) throw new Error('Access denied');
    target[prop] = val;
    return true;
  },
  
  // 拦截删除
  deleteProperty(target, prop) {
    if (prop.startsWith('_')) throw new Error('Access denied');
    delete target[prop];
    return true;
  },
  
  // 拦截遍历:隐藏 _ 开头属性
  ownKeys(target) {
    return Object.keys(target).filter(k => !k.startsWith('_'));
  }
});

console.log(Object.keys(user)); // ['name']
// console.log(user._password); // Error: Access denied

⚠️ 局限性bind 方案会暴露原始 target,可能破坏代理链。现代 JS 推荐直接使用 #private 私有字段(但 #private 与 Proxy 也有兼容问题,见下文)。

4. 范围检查(has 捕捉器)

let range = { start: 1, end: 10 };
range = new Proxy(range, {
  has(target, prop) {
    return prop >= target.start && prop <= target.end;
  }
});

console.log(5 in range);  // true
console.log(50 in range); // false

5. 函数装饰器(apply 捕捉器)

// 延迟执行装饰器,保留函数元属性(length、name)
function delay(fn, ms) {
  return new Proxy(fn, {
    apply(target, thisArg, args) {
      setTimeout(() => target.apply(thisArg, args), ms);
    }
  });
}

function sayHi(user) {
  console.log(`Hi, ${user}`);
}

const delayedSayHi = delay(sayHi, 3000);
console.log(delayedSayHi.length); // 1(传统包装函数会丢失)
delayedSayHi('John'); // 3秒后输出

Reflect:安全转发操作

为什么需要 Reflect?

场景直接访问的问题Reflect 的优势
Getter 继承target[prop] 会丢失 receiver(正确的 thisReflect.get 传递 receiver,确保 getter 中 this 指向正确
统一 API操作符(newdelete)无法作为函数调用Reflect.constructReflect.deleteProperty 等提供函数式调用
参数一致性手动转发易遗漏参数与 Proxy 捕捉器同名同参,可用 Reflect[trap](...arguments) 一键转发

典型用法

let user = {
  _name: 'Guest',
  get name() { return this._name; }
};

let userProxy = new Proxy(user, {
  // ❌ 错误:getter 中的 this 指向 user,而非继承者
  // get(target, prop) { return target[prop]; }
  
  // ✅ 正确:传递 receiver,确保 this 指向实际调用者
  get(target, prop, receiver) {
    return Reflect.get(target, prop, receiver);
    // 或简写:return Reflect.get(...arguments);
  }
});

let admin = {
  __proto__: userProxy,
  _name: 'Admin'
};

console.log(admin.name); // 'Admin'(正确)

Proxy 的局限性与解决方案

局限性原因解决方案
内置对象失效(Map、Set、Date 等)内置方法使用内部槽 [[MapData]],代理对象没有get 捕捉器中将方法绑定到 target:value.bind(target)
私有字段 #private 失效私有字段通过内部槽实现,不经过 [[Get]]同上,方法绑定到 target(但会暴露 target)
严格相等 === 无法拦截语言规范限制无法解决,避免用 Proxy 作为 Map/Set 的键
性能开销拦截增加调用层数仅对需要元编程的对象使用,避免热路径

内置对象兼容示例

let map = new Map();
let proxy = new Proxy(map, {
  get(target, prop, receiver) {
    const val = Reflect.get(...arguments);
    // 将方法绑定到原始 target,确保内部槽可访问
    return typeof val === 'function' ? val.bind(target) : val;
  }
});

proxy.set('key', 'value');
console.log(proxy.get('key')); // 'value'

可撤销代理(Revocable Proxy)

适用于临时访问控制(如租赁资源、安全沙箱):

let { proxy, revoke } = Proxy.revocable({ data: 'Sensitive' }, {});

console.log(proxy.data); // 'Sensitive'

revoke(); // 永久断开 proxy 与 target 的连接

console.log(proxy.data); // TypeError: Cannot perform 'get' on a proxy that has been revoked

可用 WeakMap<proxy, revoke> 管理多个可撤销代理,避免内存泄漏。

任务实现参考

1. 读取不存在属性报错

function wrap(target) {
  return new Proxy(target, {
    get(target, prop) {
      if (!(prop in target)) {
        throw new ReferenceError(`Property doesn't exist: "${prop}"`);
      }
      return target[prop];
    }
  });
}

let user = wrap({ name: 'John' });
console.log(user.name); // John
// console.log(user.age); // ReferenceError: Property doesn't exist: "age"

2. 负索引数组

let array = [1, 2, 3];
array = new Proxy(array, {
  get(target, prop) {
    // 仅处理负整数索引
    if (typeof prop === 'string' && /^-\d+$/.test(prop)) {
      prop = target.length + parseInt(prop);
    }
    return target[prop];
  }
});

console.log(array[-1]); // 3
console.log(array[-2]); // 2
array.push(4);
console.log(array[-1]); // 4(动态计算,始终指向末尾)

3. 可观察对象(Observable)

function makeObservable(target) {
  const handlers = new Set();
  
  return new Proxy(target, {
    set(target, prop, val) {
      const result = Reflect.set(...arguments);
      // 触发所有观察者
      handlers.forEach(handler => handler(prop, val));
      return result;
    },
    
    // 通过方法添加观察者(利用 proxy 可扩展性)
    get(target, prop) {
      if (prop === 'observe') {
        return (handler) => handlers.add(handler);
      }
      return target[prop];
    }
  });
}

let user = makeObservable({});
user.observe((key, value) => {
  console.log(`SET ${key}=${value}`);
});

user.name = 'John'; // SET name=John
user.age = 30;      // SET age=30

总结

Proxy = 对象的"拦截层"
在语言最底层劫持操作,实现验证、观察、虚拟化、访问控制等元编程能力;
Reflect = 可靠的"转发器"
确保在拦截后正确恢复默认行为,特别是处理继承和方法调用时不可或缺。

Eval:字符串执行

允许直接运行字符串:

  • eval 可以访问外部变量,因为运行在当前词法环境,因此可以更改外部变量的值。
  • 严格模式下外部环境无法访问 eval 内部定义的变量。
  • 不启用严格模式,eval 没有属于自己的词法环境,因此可以从外部访问变量 x 和函数 f
let code = 'console.info("Hello")';
eval(code); // Hello

// 允许包含换行符、函数声明和变量等
let value = eval('let i = 0; ++i');
console.info(value); // 1

危害与建议

降低代码压缩率

  • 压缩工具会将变量名称缩短,以减小代码包体积。
  • 通过 eval 直接访问外部变量,压缩工具对所有可能被 eval 访问的变量不进行压缩处理,导致压缩率降低。

使用建议

  • 未使用外部变量情况下,使用 window.eval(...) 的形式调用 eval
  • 使用局部变量时,采用 new Function 进行调用:
let f = new Function('a', 'console.info(a)');

f(5); // 5

其他字符串执行代码方案

纯 JavaScript 环境(浏览器 + Node.js 通用,不依赖特定 API)中,执行字符串代码的方式:

方式说明示例
eval(code)直接执行字符串代码,当前作用域eval("console.log('hello')")
new Function(code)创建函数执行,全局作用域new Function("console.log('hello')")()
Function.constructor绕过 new Function 检测Function("console.log('hello')")()
setTimeout(code, delay)延迟执行字符串(浏览器/Node)setTimeout("console.log('hello')", 0)
setInterval(code, delay)定时执行字符串(浏览器/Node)setInterval("console.log('hello')", 1000)

注意setImmediatequeueMicrotaskNode.js 特有requestAnimationFrameimport()浏览器特有,均不属于"纯 JS 环境"通用方案。

防范策略

对这些方法进行覆写:

"use strict";
eval = undefined;        // 可覆写
Function = undefined;    // 可覆写

但以下方式难以完全禁用:

  • 间接调用 eval(如 (0, eval)(code)
  • 通过原型链获取 Function 构造函数

柯里化(Currying)

柯里化只是一种高阶技术规范,一种概念,并非局限某一个语言。

  • 一种函数转换,从可调用的 f(a, b, c) 转换为可调用的 f(a)(b)(c)
  • 不会调用函数,只对函数进行转换

高级通用实现:lodash 库的 _.curry 函数

为什么要柯里化

更好的创建部分应用函数或者部分函数,对于多参数函数,可以更加方便地创建易调用的版本。

假设一个日志打印方法:

function log(date, importance, message) {
  console.info(`[${date.getHours()}:${date.getMinutes()}] [${importance}] ${message}`);
}

现在进行柯里化:

log = _.curry(log);

无论是柯里化调用还是正常调用都支持:

// 正常传参调用
log(new Date(), "DEBUG", "some debug"); // log(a, b, c)
// 柯里化调用
log(new Date())("DEBUG")("some debug"); // log(a)(b)(c)

创建部分应用函数,可以快速地调用对应场景的函数分支:

// logNow 会是带有固定第一个参数的日志的部分应用函数
let logNow = log(new Date());
logNow("INFO", "message"); // [HH:mm] INFO message

let debugNow = logNow("DEBUG");
debugNow("message"); // [HH:mm] DEBUG message

高级柯里化实现

function curry(func) {
  return function curried(...args) {
    if (args.length >= func.length) {
      return func.apply(this, args);
    } else {
      return function(...args2) {
        return curried.apply(this, args.concat(args2));
      }
    }
  };
}
  • 传入参数 args 长度与原始函数 func.length 参数长度相同或者更长时,使用 apply 转发到 func 上即可。
  • 少于原始函数长度时,需要创建一个部分函数版本(目前未调用原始函数),返回的是另一个包装器,对原始函数进行重新包装。

柯里化仅支持转换固定长度参数的函数,使用 rest 参数(...args)的方法不能以此进行柯里化。


Reference Type

主要是对 this 动态丢失的详细讲解

错误示例

let user = {
  name: "John",
  hi() { console.info(this.name); },
  bye() { console.info("Bye"); }
};

user.hi(); // 正常运行

// 现在让我们基于 name 来选择调用 user.hi 或 user.bye
(user.name == "John" ? user.hi : user.bye)(); // Error!

此处调用导致了一个错误,因为在该调用中 "this" 的值变成了 undefined

调用对象方法分为两个操作:

  1. '.' 取了属性 obj.method 的值
  2. () 执行了它

这就涉及到 this 从第一步传递到第二步,和以前一样分开调用就会丢失 this

apply/call 章节提到过

let user = {
  name: "John",
  hi() { console.info(this.name); }
};

// 把获取方法和调用方法拆成两行
let hi = user.hi;
hi(); // 报错了,因为 this 的值是 undefined

因为:'.' 返回的不是一个函数,而是一个特殊的 Reference Type 的值

Reference Type

  • ECMA 中的一个“规范类型”,无法使用,存在于语言内部
  • 三个值的组合 (base, name, strict)
    • base 是对象
    • name 是属性名
    • strictuse strict 模式下为 true

因此,user.hi 的结果不是一个函数,而是一个 Reference Type 的值,严格模式下为:

// Reference Type 的值
(user, "hi", true)
  1. () 被在 Reference Type 上调用时,会接收到关于对象和对象的方法的完整信息,然后可以设置正确的 this(在此处 =user
  2. Reference Type 充当“中间人”内部类型,目的是从 . 传递信息给 () 调用
  3. hi = user.hi 等其他的操作,都会将 Reference Type 作为一个整体丢弃掉,而会取 user.hi(一个函数)的值并继续传递,因此 this 发生了丢失

BigInt

特殊的数字类型,提供了对任意长度整数的支持。

创建方式

const bigint = 1234567890123456789012345678901234567890n;

const sameBigint = BigInt("1234567890123456789012345678901234567890");

const bigintFromNumber = BigInt(10); // 与 10n 相同

常规数字支持的运算,BigInt 全都支持,但不能将 BigInt 与常规数字进行混用

注意

BigInt 不支持一元加法,无法使用 +bigint

在非严格相等时:console.info(1 == 1n); // true

严格相等时:console.info(1 === 1n); // false

console.info(1n + 2n); // 3

// BigInt 运算返回也是 BigInt,
// 结果向零进行舍入,舍入后得到的结果没有了小数部分
console.info(5n / 2n); // 2

混用前必须手动进行显式转换:

let bigint = 1n;
let number = 2;

// 将 number 转换为 bigint
console.info(bigint + BigInt(number)); // 3

// 将 bigint 转换为 number
console.info(Number(bigint) + number); // 3

BigInt 替代方案(三方库实现):JSBI


Unicode 字符串内幕

JavaScript 的字符串是基于 Unicode 的:每个字符由 1-4 个字节的字节序列表示。

十六进制 Unicode 编码

  • \xXX:XX 必须是介于 00FF 之间的两位十六进制数,\xXX 表示 Unicode 编码为 XX 的字符。

    由于只能表示两位,只能用于前 256 个 Unicode 字符

    console.info("\x7A"); // z
    console.info("\xA9"); // © (版权符号)
    
  • \uXXXX:XXXX 必须是 4 位十六进制数,值介于 0000FFFF 之间。此时,\uXXXX 便表示 Unicode 编码为 XXXX 的字符。

    console.info("\u00A9"); 
    // ©, 等同于 \xA9,只是使用了四位十六进制数表示而已
    console.info("\u044F"); // я(西里尔字母)
    console.info("\u2191"); // ↑(上箭头符号)
    
  • \u{X…XXXXXX}:X…XXXXXX 必须是介于 010FFFF(Unicode 定义的最高码位)之间的 1 到 6 个字节的十六进制值。

    能够轻松地表示所有现有的 Unicode 字符

    console.info("\u{20331}"); // 佫, 不常见的中文字符(长 Unicode)
    console.info("\u{1F60D}"); // 😍
    

代理对

  • 所有常用字符都有对应的 2 字节长度的编码(4 位十六进制数)
  • 大多数欧洲语言的字母、数字、以及基本统一的 CJK 表意文字集(CJK —— 来自中文、日文和韩文书写系统)中的字母,均有对应的 2 字节长度的 Unicode 编码

JavaScript 是基于 UTF-16 编码的,只允许每个字符占 2 个字节长度。但 2 个字节只允许 65536 种组合,这对于表示 Unicode 里每个可能符号来说,是不够的。

对于超过 2 个字符的稀有字符则使用一对 2 字节长度的字符编码,它被称为“代理对”。(但这些符号的长度依旧为 2)

console.info('𝒳'.length); // 2, 大写的数学符号 X
console.info('😂'.length); // 2, 笑哭的表情
console.info('𩷶'.length); // 2, 一个少见的中文字符

无法通过简单的下标获取代理对的部分内容,会显示毫无意义的内容,代理对单个部分内容无意义,必须成对出现

console.info('𝒳'[0]); // 显示出了一个奇怪的符号...
console.info('𝒳'[1]); // ...代理对的片段

正确处理代理对

本质上与 String.fromCharCodestr.charCodeAt 相同,但它们可以正确地处理代理对

// charCodeAt 不会考虑代理对,所以返回了 𝒳 前半部分的编码:
console.info('𝒳'.charCodeAt(0).toString(16)); // d835

// codePointAt 可以正确处理代理对
console.info('𝒳'.codePointAt(0).toString(16)); // 1d4b3,读取到了完整的代理对

变音符号

很多语言都有由基础字符及其上方/下方的标记所组成的符号。

Unicode 标准允许使用多个 Unicode 字符:基础字符后跟着一个或多个“装饰”它的“标记”字符。

比如 字母 a 就是这些字符 àáâäãåā 的基础字符。

console.info('S\u0307'); // Ṡ
console.info('S\u0307\u0323'); // Ṩ

let s1 = 'S\u0307\u0323'; // Ṩ, S + 上方点符号 + 下方点符号
let s2 = 'S\u0323\u0307'; // Ṩ, S + 下方点符号 + 上方点符号

console.info(s1 == s2); // 视觉上是相同的,但结果却是 false

规范化处理

normalize() 将 3 个字符的序列合并为了一个字符

console.info(
  "S\u0307\u0323".normalize() == "S\u0323\u0307".normalize()
); // true

console.info("S\u0307\u0323".normalize().length); // 1

console.info("S\u0307\u0323".normalize() == "\u1e68"); // true

Unicode 标准附录:Unicode 规范化形式

无需特意学习,很少使用