ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

TypeScript访问修饰符详解:public、private、protected与#字段的封装边界

TypeScript访问修饰符详解:public、private、protected与#字段的封装边界 public、private、protected 这三个访问权限修饰符在 TypeScript 的面试和代码评审里属于见面率极高的知识点。我自己带过几个前端团队每次让新同学上手写类都要先花几分钟确认访问权限用得对不对。项目代码一多内部状态到处被外部改写的痛苦是写过大型项目的人最清楚的。这篇文章会从三种修饰符的语义拆开讲再到实际编码里的封装习惯、常见坑位最后补充几条面试题应答思路。如果你刚开始学 TypeScript 里的类或者已经在项目里写了但从来没细想过这三个词背后的分工这篇内容能帮你把体系补齐如果你已经在写类了后半部分的问题清单和避坑记录多半也能派上用场。先说一句概括的话TypeScript 类的访问权限本质上是一套“编译期规则”它约束的是类型层面能不能访问而不是运行时能不能拿到。这个认知如果没建立起来后面的一切理解都容易跑偏。接下来我们一步步拆。1. 访问权限不是语法装饰是封装意愿的正式声明1.1 为什么类设计绕不开封装封装这个词听起来很教科书但落到代码里特别现实。假设你有个订单类里面存着原始金额、折扣比例外部如果到处都能直接改order.discount 20、order.rawAmount 500那订单总价的规则就等于被每个调用方各写了一遍。某天你发现折扣比例必须按用户等级异步计算或者金额要统一走数据库里的快照你会被迫去翻所有直接赋值的地方。写代码的人都很容易高估自己对项目的掌控力等代码量上来这个高估会迅速变成重构成本。访问权限解决的就是这个问题把内部实现藏起来只对外暴露稳定的行为接口。public 成员是类对外给出的承诺private 是“外人别碰”的内部细节protected 是“允许子类参与”的扩展口。这个隔离层建得越好后面改内部逻辑的时候需要动的外部代码就越少。这不是洁癖是大型项目能连续迭代的前提。1.2 TS 的访问修饰符是编译期的“协议”不是运行时的“锁”这一点是 TypeScript 里最容易产生认知误差的地方。Java 或者 C# 里的private有运行时的可达性检查而 TypeScript 的public、private、protected只是类型系统在编译阶段做的可见性校验。编译成 JavaScript 之后这些修饰符不会留下任何痕迹字段该在的还在方法该通的还能通。举个例子class Account { private password 123456; getPassword() { return this.password; } } const account new Account(); // 类型层面会报错Property password is private // 但实际 JS 运行仍然可以拿到 console.log((account as any).password);所以在讨论 TS 访问权限时请先建立这样一个意识它是一份给编译器、给团队协作看的“协议”不是用来做运行时安全防护的锁。安全检查需要靠#私有字段或者闭包这点我会在后面的实操部分专门讲。先理解这层差异后面所有坑都源于此。2. 三种访问级别public、private、protected 到底管什么2.1 public默认可见也是最容易失控的一种在 TypeScript 类中如果不写任何修饰符成员默认就是public。也就是说类外部可以自由读取和修改它。对于一些真正的对外接口这没有问题问题在于很多人会忘记“默认即公开”这一点把本该私有的内部字段也顺手公开了。public的正确使用场景是那些你有意暴露、并愿意长期维护稳定的成员。比如一个播放器类的play()、pause()一个用户类的getName()这些是类的对外契约。一旦公开外部代码就会依赖它后续改名、换实现都要考虑影响面。建议是显式写出public不要全靠默认。显式写出来不是为了运行效果而是让阅读代码的人清楚地知道“这里就是公开接口”。很多 ESLint 配置里也会开启explicit-member-accessibility强制每个成员都写明访问修饰符就是为了让封装边界可视化。2.2 private只在类内部可见的真正“内部”private修饰的成员只能在声明它的类内部访问。类外部不行子类也不行。它适合存放那些纯粹属于内部实现的东西比如临时缓存、加密用的密钥、内部状态标记、辅助函数。class UserService { private token ; private encryptKey do-not-touch; constructor(private password: string) {} login() { this.token this.encrypt(this.password); return this.token; } private encrypt(input: string) { // 这里只是演示真实场景会用更安全的算法 return ${this.encryptKey}:${input}; } } const service new UserService(my-password); // service.token TS 报错 // service.encrypt TS 报错注意子类也不能访问父类的private成员。也就是说private对“继承关系”也是封闭的。这个特性经常被混淆很多人以为子类能访问父类的私有字段实际上不能能访问的是protected。把哪些字段设成private本质上是给团队画了一条红线这里是内部细节外部只关心你能不能把一件事做成不关心你怎么做。2.3 protected给继承关系里“自己人”留的口子protected的可见范围是类本身和它的子类类外部不能访问。这个修饰符典型的用法是基类定义一套流程骨架子类需要访问某些内部方法或属性来完成定制但又不想把这些口子暴露给整个外部世界。class BaseNotifier { protected buildMessage(content: string) { return [系统通知] ${content}; } protected send(route: string, payload: string) { // 发送逻辑 } notify(content: string) { const message this.buildMessage(content); this.send(/api/notify, message); } } class EmailNotifier extends BaseNotifier { sendByEmail(content: string) { const message this.buildMessage(content); // 子类可以访问 protected this.send(/api/email, message); } } const notifier new EmailNotifier(); // notifier.buildMessage(x) // TS 报错外部不可访问protected是一把双刃剑。它确实方便了继承体系的扩展但也意味着只要有人继承这个类这些成员就会进入子类的可见范围。滥用protected会导致类层次越深隐式依赖越复杂。我的建议是只在明确存在“模板方法”或“扩展点”需求时使用不要因为“万一子类要用”就把所有成员设成 protected。2.4 组合 readonly把“不能访问”和“不能改”分开访问权限解决的是“谁能访问”readonly解决的是“能不能改”。两者经常搭配使用尤其是对外暴露的配置项、标识符、初始化后就不再变化的字段。class Article { public readonly id: string; private readonly createTime: number; public title: string; constructor(id: string, title: string) { this.id id; this.title title; this.createTime Date.now(); } }id外部能读但不能重新赋值createTime连读都不让外部读只能在类内部使用。组合之后意图表达会非常明确。为了帮你快速对照我把三种修饰符和 ES 私有字段的差异整理成一张表修饰符同类内访问子类内访问外部访问编译后是否保留字段运行时是否强制public可可可是普通字段否private可不可不可类型层面是普通字段否protected可可不可类型层面是普通字段否# 私有字段可不可不可是按目标编译为 WeakMap 等是这张表基本覆盖了日常开发需要的判断依据。3. 实操写出靠谱的 TypeScript 类封装3.1 构造器参数快捷方式一行代码完成声明与赋值TypeScript 有一个非常省事的语法在构造函数参数前面直接加访问修饰符它会自动声明同名字段并完成赋值。class Student { constructor( public readonly id: string, public name: string, private courseCount 0 ) {} }这段代码等价于class Student { public readonly id: string; public name: string; private courseCount: number; constructor(id: string, name: string, courseCount 0) { this.id id; this.name name; this.courseCount courseCount; } }参数属性是 TypeScript 的独有语法编译成 JS 后就是你熟悉的赋值语句。实际开发中我很少再手写“属性声明 构造器里 this.x x”这种重复代码了constructor(private readonly xxx: string)一行搞定。需要提醒的是参数属性不要和其他同名属性声明同时写否则等于重复声明另外参数属性仍受 strict 模式下面的初始化要求约束但它本身就是在构造器里赋值的所以没有问题。这个语法对封装很友好看到private readonly就知道这个字段只属于类内部且创建后不会变。3.2 getter / setter给私有状态留一个“可控出口”有时候你不想让外部直接操作私有字段但确实需要提供一个读写的通道。直接开一个public字段太放任完全不给出口又不现实。这时候就该用 getter 和 setter。class Order { private _amount 0; get amount(): number { return this._amount; } set amount(money: number) { if (money 0) { throw new Error(金额不能为负数); } this._amount money; } } const order new Order(); order.amount 100; // 走 setter console.log(order.amount); // 走 getter返回 100 // order.amount -10; // 运行时会抛出异常这种写法的价值在于字段的读写规则集中在类内部外部只能看到amount这个属性并不知道内部还存了一个_amount。如果你发现某个字段在赋值前需要进行格式校验、类型转换、联动更新优先考虑用 getter/setter 包住它而不是直接开放public。不过要注意TypeScript 编译器对private _amount只是类型层面拦截如果外部通过(order as any)._amount强行访问运行时仍能拿到。真需要铁桶一样的私有还得看后面的#字段。3.3 真正的私有化ES # 字段在 TS 里的玩法从 TypeScript 3.8 开始ES 标准的私有字段语法在 TS 中也有完整支持。用#声明的字段是运行时的真私有外部无论如何都访问不到。class BankAccount { #balance 0; constructor(initial: number) { this.#balance initial; } deposit(money: number) { if (money 0) { this.#balance money; } } get balance() { return this.#balance; } } const account new BankAccount(100); account.deposit(50); console.log(account.balance); // 150 // account.#balance // 语法层面就不允许运行层也拿不到编译时如果目标环境不支持原生私有字段TS 会把它转成 WeakMap 或者闭包形式同样起到运行时保护作用。但对于现代浏览器或 Node 环境直接输出原生#实现的效率更好。那是不是所有私有成员都应该用#也不是。#字段目前在一些依赖装饰器、依赖注入框架的生态里会遇到额外限制而且调试工具对它的展示不如普通属性直观。我的取舍是普通业务类优先用#保护关键敏感数据需要和 Angular 等重装饰器生态深度配合或者字段名需要被外部库反射读取时再用private因为它在编译产物里仍然是常规属性。4. 最容易踩的几个坑从编译到运行的落差4.1 private 并不会让字段从 JS 对象上消失这是整个访问权限话题里最容易被误解的一点。private在编译后会原样保留字段比如private password 123编译成 JS 后仍然有this.password。你用普通方式访问会触发类型错误但只要用as any、索引访问或者干脆不看类型运行时还是能拿到。所以请记住TypeScript 的private是“守君子不守小人”的。它真正保护的是团队协作中的误用而不是恶意绕过。如果你的需求是“这个字段绝对不能外泄”比如密钥、敏感状态要么用#字段要么放到类外部的闭包作用域里。我在安全相关代码上从不把希望寄托在private上。4.2 继承结构里的同名私有字段冲突在继承体系里父类和子类很容易无意中声明同名字段。如果两边都是privateTypeScript 会认为它们应当是同一个字段如果类型不一致编译直接报错。即使类型恰好一致这种同名声明也会让继承关系变得非常混乱。class Base { private id: string base; } class Derived extends Base { private id: number 1; // 报错示例类型不兼容 }这类错误在代码量大的项目里很隐蔽因为问题不是“语法不对”而是类型系统觉得你在试图遮蔽父类的私有成员。解决方式很简单子类的私有字段别和父类重名或者改用protected并提供明确的重写语义。如果基类成员本身就是protected那子类可以考虑将它提升为public这是允许的但反过来把private改成protected或public是不行的因为封装边界只能收不能放宽。4.3 strict 模式下初始化问题会找上门开了strictPropertyInitialization之后类里的每个字段都必须在构造函数里明确赋值或者在声明时给初始值。访问修饰符不直接导致这个问题但组合场景很常见一个private readonly字段忘了初始化立刻会被编译器吐槽“has no initializer”。class Config { private readonly apiUrl: string; // Property apiUrl has no initializer and is not definitely assigned }解决办法是直接在构造器里赋值或者声明时给默认值再或者用!非空断言告诉编译器“我会在别处初始化”。但!等于绕过了类型系统的检查我一般只在少数外部注入场景才用业务字段一律老老实实初始化。配合构造器参数属性这个问题会消失得无影无踪constructor(private readonly apiUrl: string) {}既完成了声明又完成了赋值还能把private、readonly的语义一次锁死。5. 团队规范和面试场里访问权限怎么聊才加分5.1 团队 code review 里的通用约定结合我自己的带团队经验给大家一套可以直接抄的约定新写的类成员默认从private开始考虑只有当外部确实需要访问时才提升为public或protected。public成员是类的对外契约命名要稳定语义要清晰加注释说明用途。把protected当作“扩展点”管理每次使用都问一下这个成员真的需要被子类访问吗有没有可能用构造函数传参或策略模式替代getter/setter 优先于直接公开字段特别是有校验、联动需求的时候。对readonly抱着“能用就加”的心态它和访问权限不冲突能显著降低误改概率。在 ESLint 里开启typescript-eslint/explicit-member-accessibility让“每个成员都写明访问修饰符”成为代码风格的一部分。团队层面的价值不只是规范本身而是让每个人在写类的时候都多花五秒钟想想这个字段的外部可见性我是否真的想清楚了。5.2 面试准备与三种访问权限相关的题型TypeScript 面试中访问权限几乎必被问到。常见的问题和回答思路整理如下Q1public、private、protected 有什么区别先给最简答案public 任何地方都能访问private 只在类内部访问protected 在类内部和子类能访问。然后补一句关键信息它们都是编译期类型层面的约束编译成 JS 后并不做运行时强制。Q2private 和 protected 的区别是什么private 连子类都访问不了protected 允许子类访问。如果想让子类参与扩展用 protected如果只是类内部实现细节用 private。这句话很简短但已经覆盖核心。Q3private 真的访问不到吗注意这个问题考的就是编译期认知。你可以回答类型层面访问不到但运行时通过as any或索引访问仍然可以拿到真正的运行时私有要用#字段。能答出这一点面试官通常会觉得你是真写过、真踩过坑的。Q4什么时候会把构造函数设为 private最常见的场景是单例模式比如class Singleton { private static instance: Singleton; private constructor() {} static getInstance(): Singleton { if (!Singleton.instance) { Singleton.instance new Singleton(); } return Singleton.instance; } }构造函数private之后外部无法直接new Singleton()只能通过getInstance()获取实例。同理构造函数的访问权限也能用protected用来限制外部实例化但允许子类super()调用这在某些类工厂或继承基类的场景里很好用。能讲到这一层说明你对访问权限不只是背过语法还知道它如何参与设计模式。6. 两个实际项目里沉淀下来的使用习惯6.1 把类的“对外名单”写在最前面我在项目里有个习惯每个类的public方法或字段优先放在类顶部让人一眼看全这个类的对外能力。紧接着是protected区域最后才是private的实现细节。如果类成员比较多还会用注释区块把三块分开。这样做的原因很简单别人接手你的类时先看成员列表就能判断自己该不该碰这文件不用把所有代码读完。代码是给人读的这种排版属于低成本高性价比的沟通。6.2 判断访问权限时先问“谁会用它”每次新增一个类成员我都会先问自己三个问题外部真的需要读它吗真的需要改它吗真的需要继承它吗如果答案都是否定或者不确定那它就该是 private。如果未来某个需求浮现再放开为 public 或者 protected 也不迟。代码里“先收紧后放开”比“先放开后收紧”安全得多因为收紧是破坏性变更放开只是加权限。另外还有一个实际建议不要在代码里把private字段命名成带下划线开头的_foo然后用注释解释“这是私有的”。有了private修饰符之后前缀下划线属于多余信息如果使用了#字段那更不需要画蛇添足。让修饰符本身去表达语义注释留给业务逻辑代码会更干净。自己在项目里踩过这个弯后来统一了规范评审时少了很多无意义的争论。
返回列表