JavaScript 和 TypeScript 的封装性 —— 私有成员

JavaScript 使用了基于原型模式的 OOP 实现,一直以来,其封装性都不太友好。为此,TypeScript 在对 JavaScript 类型进行增强的同时,特别关注了“类”定义。TS 的类定义起来更接近于 Java 和 C# 的语法,还允许使用 privateprotectedpublic 访问修饰符声明成员访问限制,并在编译期进行检查。

显然 ECMAScript 受到启发,在 ES2015 中引入了新的类定义语法,并开始思考成员访问限制的问题,提出了基于 Symbol 和闭包私有成员定义方案,当然这个方案使用起来并不太能被接受。又经过长达 4 年思考、设计和讨论,最终在 ES2019 中发布了使用 # 号来定义私有成员的规范。Chrome 74+ 和 Node 12+ 已经实现了该私有成员定义的规范。

JavaScript 和 ECMAScript 有什么关系?

ECMAScript 由 ECMA-262 标准定义,是一个语言规范(Specification);JavaScript 是该规范的一个实现。拿上面的问题去搜索引擎上搜索一下,可以查阅到更详尽的答案。

1. ES 规范中的私有成员定义

1.1. 正确示例

先来看一个示例:

class Test {
    static #greeting = "Hello";
    #name = "James";

    test() {
        console.log(`${Test.#greeting} ${this.#name}`);
    }
}

// 用一个 IIFE 来代替定义并执行 main()
(() => {
    const t = new Test();
    t.test();               // OUTPUT: Hello James
})();

这个示例在 Chrome 74+ 和最新版的 Edge 等浏览览器的开发者工具控制台中运行都没有问题。

📝 <u>小技巧</u>

试验代码时往往需要在开发者工具控制台中多次粘贴类似的代码,像 const t = ... 这样的代码在第二次运行的时候会报 “Identifier 't' has already been declared”这样的错误。

为了避免这种错误,可以将需要直接运行的代码封装在 IIFE 中,即 (() => { ... })()

同理,在不支持顶层 await 的环境中,也可以用 (async () => { ... })() 来封装需要直接执行的异步代码。

1.2. 错误调用示例

私有成员的访问限制决定了,这个成员可以在定义它的类的内部访问,不管它是静态 (static) 成员还是实例成员。稍稍改一下代码可以很容易验证这一点:

// 前端的类定义不变,只改一下 IIFE 中的测试代码

(() => {
    // SyntaxError: Private field '#greeting' must be declared in an enclosing class
    console.log(Test.#greeting);
                
    // SyntaxError: Private field '#name' must be declared in an enclosing class
    console.log(new Test().#name);
})();

1.3. 私有方法

虽然 MDN 上一直描述的是私有字段 (private fields),但它给的语法中包含了私有方法的定义

来自 MDN: Private class fields 的 Syntax 部分:

class ClassWithPrivateMethod {   
  #privateMethod() {     
    return 'hello world'
  }
}

(这部分代码风格和其他代码风格不同,它是原样从 MDN 抄下来的,非“边城”风格)

很不幸,即使在最新的 Chrome 83 中尝试上面的代码,也只能得到语法错误。Nodejs 和 Edge 都是基于 Chrome 的,所以会得到相同的结果。而 Firefox 压根儿不支持私有成员特性。

不过 JS 很灵活,有非常神奇的 this 指向规则。我们可以用定义字段的方式来定义方法:

class Test {
    #name;

    constructor(name) {
        this.#name = name;
    }

    #greet = () => {
        console.log(`hello ${this.#name}`);
    }

    test() {
        this.#greet();
    }
}

(() => {
    new Test("James").test();       // OUTPUT: hello James
})();

2. TypeScript 中的私有成员

都已经 2020 了,讲到 JavaScript 而不提 TypeScript 有点说不过去。但是如果你确实一点不会 TypeScript,也暂时不想去了解它,这部分可以跳过。

🖊 作者“边城”会在近期推出与 TypeScript 有关的视频教程,即使不免费,也会非常超值。请关注“边城客栈”订阅号跟踪此视频教程的最新消息。

2.1. 访问限定修饰符

TypeScript 发明之初就提供了私有成员解决方案,跟 Java 和 C# 类似,通过添加访问限定修饰符来声明成员的可访问级别:

  • public,公共可访问,不加修饰符默认此级别;
  • protected,子类可访问;
  • private,仅内部可访问

还是拿实例来说话:

class Test {
    private name: string;
    constructor(name: string) {
        this.name = name;
    }

    private greet() {
        console.log(`hello ${this.name}`);
    }

    test() {
        this.greet();
    }
}

(() => {
    const test = new Test("James");
    console.log(test.name);
    test.greet();
    test.test();
})();

这段代码可以拿到 TypeScript Playground 去运行,打开控制台来查看结果。不过我更推荐使用 Playgroud v3 beta ,从 Playground 页面右上角的“Try Playground v3 beta”可进入。

在 JS 区,我们可以看到转义后的 Test 类定义,已经去掉了访问限定修饰符:

class Test {
    constructor(name) {
        this.name = name;
    }
    greet() {
        console.log(`hello ${this.name}`);
    }
    test() {
        this.greet();
    }
}

这就意味着,下面的测试代码在 JS 环境中完全可以正确执行,不会受限。在控制台,或者 Playground v3 的 Logs 部分,可以看到正常的输出

[LOG]: James 
[LOG]: hello James 
[LOG]: hello James 

不过在编辑器内,我们可以看到 test.nametest.greet() 被标记为有错。鼠标移上去可以看到具体的错误信息。这些错误信息在 Playground v3 的 Errors 部分也可以看到:

Property 'name' is private and only accessible within class 'Test'.
Property 'greet' is private and only accessible within class 'Test'.

TypeScript 扩展了更为严格的语法,并借助 LSP 和编译器来帮助开发者在开发环境中尽早发现并解决存在或替在的问题。这就是 TS 为开发者带来的最大好处,也是 TS 发展如此迅速的原因之一。然而,正如上面的示例所示,TS 编译出来的 JS 库并不能限制最终用户如何使用。所以即使 TS 有了 private#privateField 在仍然在 TS 中具有存在的意义。

2.2. TypeScript 和 #privateField

上面提到,如果使用 TypeScript 写一个库,使用 privateprotected 来限定成员访问,在其用户同样使用 TypeScript 的时候不会有问题。但其用户使用 JavaScript 的时候,却并不能受到期望的限制。因此 TypeScript 引入 #privateField 是意义的。

不过 TypeScript 并没有直接把 private 修饰符和 #privateField 关联起来,它在 v3.8 的发行公告 中解释了二者的主要区别在于运行时访问权限。

在 TypeScript 中使用 #privateField,从语法检查上来说和 private 区别不大,都限制为仅在内部可访问,所以在声明 #privateField 的时候,不允许添加访问限制修饰符:

  • 如果添加 publicprotected,语义相悖
  • 如果添加 private,没有必要

上面的示例,如果把 private name 改为 #name,我们不仅会得到编译期错误,还会得到运行时错误:

[ERR]: Private field '#name' must be declared in an enclosing class 

或者

[ERR]: Unexpected token ')' 

得到哪个错误取决于 tsconfig.json 中的 target 配置,它决定了 console.log(test.#name) 这句话的转译结果。

  • 如果配置为 ESNEXT,转译结果不变,仍然是 test.#name。由于外部不可访问私有成员,这样调用会引起语法错误;
  • 如果配置为 ES2020 或以前版本,转译结果会直接丢掉对私有字段的访问:console.log(test.);,直接引发的语法错误。

private 和 #privateField 的选择问题上,我个人建议现阶段(现阶段 TS 的最高稳定 Target 版本是 TS2020)仍然使用 private。TS 会把 #privateField 转义成闭包环境下的 privateMap,虽然实现了功能,但看起来别扭。当然如果你不在意这个问题,或者使用 ESNext 作为 Target,那不妨早一点尝试新的语法。

3. 其他私有成员解决方案

3.1. 使用闭包环境下的 Symbol

ES2015 引入了 Symbol 这一特殊的数据类型。说它特殊,因为它可以做到每次产生的 Symbol 绝不相同,比如

const a = Symbol("key");
const b = Symbol("key");
console.log(a === b);   // false

此外,Symbol 可以作为对象的 key 使用:

const o = {};
const key = Symbol("key");
o[key] = "something";
console.log(o[key]);    // OUTPUT: something

如果在闭包环境下使用 Symbol,让外界拿不到这个 Symbol,就可以实现私有属性。下面是使用 JS 写的示例,TS 类似:

// @file test.mjs

const NAME = Symbol("name");

export class Test {
    constructor(name) {
        this[NAME] = name;
    }

    test() {
        console.log(`hello ${this[NAME]}`);
    }
}
// @file index.mjs

import { Test } from "./test.mjs";

const t = new Test("James");

// OUTPUT: hello James
t.test();

// OUTPUT: undefined
console.log(t[Symbol("name")]);

模块 —— 不管是 ESM 还是 CommonJS Module —— 都是闭包环境。所以在模块化框架中使用 Symbol 还是很方便的。

3.2. 用随机属性名代替 Symbol

对于没有 Symbol 的环境,可以使用随机属性名代替。不过既然是不支持 Symbol 的环境,显然也不支持 class, let/const, ESM 等特性,所以示例代码看起来比较古老:

var Test = (function () {
    const NAME = ("name__" + Math.random());

    function Test(name) {
        this[NAME] = name;
    }

    Test.prototype.test = function () {
        console.log("hello " + this[NAME]);
    };

    return Test;
})();

var t = new Test("James");
t.test();

由于每次运行时创建 Test 构造函数的时候,NAME 的值会随机生成,所以用户并不知道它到底是什么,也就不能通过它来访问成员,以此达到私有化的目的。

3.3. 抬个杠

不管是 Symbol 还是随机属性名实现的私有成员,都有漏洞可钻,所以防君子不防小人。提示一下,细节就不说了:

  • Object.getOwnPropertySymbols()
  • Object.getOwnPropertyNames()

边城客栈

请关注公众号边城客栈

看完了先别走,点个赞 ⇓ 啊,赞赏 ⇘ 就更好啦!

阅读 438

推荐阅读
边城客栈
用户专栏

全栈技术专栏

3148 人关注
77 篇文章
专栏主页