ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

TypeScript访问权限修饰符:public、private、protected实战与区别

TypeScript访问权限修饰符:public、private、protected实战与区别 用 TypeScript 写类的时候public、private、protected这三个访问权限修饰符是最基础、也最容易被忽视的一块。很多同学背概念时挺清楚——public 谁都行、private 只有自己能碰、protected 自己和子类能碰——但一落到真实项目里遇到类型比较报错、对象字面量赋值失败、编译成 JavaScript 后私有属性还能被外部直接读出来就开始懵了。这篇内容我就围绕这三个修饰符把背后的设计意图、编译期行为、继承场景下的细节、常见的坑和面试考点一次讲透。适合正在学 TypeScript 的前端开发、准备面试的人也适合用 TS 写了几年但没认真抠过这块的“熟练工”。读完你至少能明白这几个修饰符到底保护的是什么它们和 JavaScript 的私有字段有什么区别以及实际编码时该怎么选。1. 访问权限背后封装到底在保护什么1.1 信息隐藏不是摆设是给代码“上保险”先想一个问题为什么语言要提供访问权限控制直接所有成员都公开不就行了反正程序跑起来都能访问。如果只从“能不能访问”来看public 当然最省事。但访问权限解决的核心问题不是“能不能用”而是“应不应该由外部来用”。一个类的内部状态、内部实现细节一旦被外部直接读写问题就来了你没法保证赋值进来的数据是合法的也没法在状态变化时统一处理副作用更糟糕的是你一旦重构内部实现所有依赖这些细节的外部代码都会跟着炸。我自己写过一次非常惨的教训一个订单类里有个status字段当时图省事直接 public结果到处都在直接order.status PAID。后来要在状态切换时做校验、记录操作日志只能全局搜改。如果是 private 加 setter 的写法只需要改一个方法。这就是封装的价值——把可变性收拢到明确的、可控的入口里给未来留余地。TypeScript 在类上提供的这套访问权限语法就是在编译期帮你强制“哪些地方不能碰”把这种约定变成编译错误而不是靠自觉。1.2 三个修饰符的边界一张表说清楚public不写也是它。类内部、子类内部、类外部实例全部可以访问。private只能在声明它的那个类的内部访问。子类不能碰外部实例更不能碰。protected只能在声明它的类和子类内部访问外部实例不能碰。修饰符本类内部子类内部外部实例典型用途public可以可以可以对外 API、组件接口private可以不可以不可以内部状态、辅助方法protected可以可以不可以给子类使用的钩子、公共基础逻辑这里有个容易踩的直觉误区很多新手以为private表示“子类也能用但外部不能用”实际上子类也不能用private成员。如果你希望“子类能用但外部实例不能用”必须用protected。我在面试别人时经常拿这个问题区分对方是不是真的写过继承相关代码。2. 三种修饰符的实操细节与继承场景2.1 public显式声明不如约定统一严格来说TypeScript 里不写修饰符的类成员默认就是publicclass User { name: string; // 等价于 public name: string; public age: number; // 显式 public constructor(name: string, age: number) { this.name name; this.age age; } } const u new User(张三, 30); console.log(u.name); // 正常 u.age 31; // 外部随意修改关于public的实战建议没必要每个成员都写public但团队要统一约定——要么全部省略要么对公开 API 显式标注。最怕的是代码里一半写一半不写后面维护的人根本分不清哪些是刻意公开、哪些是忘了写修饰符。我在带团队时定的约定是公开的属性和方法显式写public私有的一律private等于给阅读者一个明确信号。2.2 private本类专属子类也别想碰private的语义是“这个成员只属于声明它的类”。一个典型的应用场景是内部状态字段比如缓存、累计器、临时标记这些数据不应该被外部直接读写class Counter { private count 0; increment(): number { this.count; return this.count; } reset(): void { this.count 0; } } const c new Counter(); c.increment(); c.reset(); // c.count; // 报错属性“count”为私有属性只能在类“Counter”中访问注意一个关键细节子类也不能访问父类的private成员。class BaseCounter extends Counter { log() { // this.count; // 报错属性“count”为私有属性只能在类“Counter”中访问 } }这就是private和protected最大的分界线。如果你写了一个基类希望子类可以用某个内部工具方法那就得用protected。如果我们把需求反过来——希望外面的代码不能改字段但子类里可以读——private就完全不够用必须切到protected。private还有一个比较隐蔽的地方strictPropertyInitialization开启时没有被初始化的私有属性必须在构造函数里赋值否则编译直接报“属性没有初始化器”class Service { private config: string; constructor(config: string) { this.config config; // 不写这一行就报错 } }这在写依赖注入、异步初始化场景时特别容易遇到很多报错其实是“你忘了在构造函数里赋值”不是访问权限本身的问题。2.3 protected给继承体系留的“内部通道”protected是三种修饰符里最容易被低估的。它存在的意义在于一个成员既不应该暴露给外部调用方又需要让子类复用时可以访问。比如一个基类里的公共逻辑拆分出的辅助方法class BaseService { protected log(message: string): void { console.log([${new Date().toISOString()}] ${message}); } protected validateId(id: number): boolean { return Number.isInteger(id) id 0; } } class OrderService extends BaseService { getOrder(id: number) { if (!this.validateId(id)) { // 子类可以访问 protected 方法 this.log(无效的订单ID); // 可以 throw new Error(invalid id); } // 业务逻辑 } } const service new OrderService(); // service.log(x); // 报错外部实例不能访问 protected // service.validateId(1); // 报错protected一个容易忽略的细节外部实例不只是直接访问会报错连“绕一个弯”访问也不行。这个我放到后面“类型系统视角”那一节单独讲因为它涉及比较隐蔽的编译错误。先说一个实际建议protected不要滥用。很多项目里protected成员最后变成了“半公开”子类随便覆盖基类逻辑被各种子类改得面目全非。如果你只是想“给子类提供默认实现”优先考虑设计模式里的模板方法或者组合让子类通过构造函数参数或者策略函数来定制行为。protected适合的是那些“确实需要子类共享的辅助逻辑”而不是默认的交互通道。2.4 参数属性用构造器简化声明TypeScript 给类提供了一个很实用的简化语法叫“参数属性”。直接在构造函数参数上写修饰符TypeScript 会自动帮你声明同名属性并赋值class Point { constructor( private x: number, protected y: number, public label: string ) {} get coordinates(): [number, number] { return [this.x, this.y]; } } const p new Point(3, 4, origin); // p.x; // 报错 // p.y; // 报错 console.log(p.label); // OK这等效于手动写一遍class PointManual { private x: number; protected y: number; public label: string; constructor(x: number, y: number, label: string) { this.x x; this.y y; this.label label; } }参数属性省掉了大量重复的声明和赋值代码但有几个注意点参数属性只在构造函数的“实现签名”里可以用。如果构造函数有重载重载签名里写了修饰符会直接报错。参数属性仍然遵守访问权限语义比如private readonly是非常常见的组合用来注入不可变依赖class EmailService { constructor( private readonly smtpHost: string, private readonly apiKey: string ) {} }如果你只想声明一个普通参数不想要属性那就别写任何修饰符。很多新手在参数上顺手写了个public结果类里多了一堆没意义的公开字段外部代码对类内部结构的侵入性一下子就变强了。3. 类型系统视角几个隐蔽但常见的“报错现场”3.1 结构类型系统下private 是“类型锁”TypeScript 的类型系统是结构化structural的意思是你只要形状匹配类型就兼容。但类成员带private时这个规则会破例——两个类即使结构完全一样只要私有成员不是“来自同一个声明”它们的类型就不兼容class User { private id: number; constructor(id: number) { this.id id; } } class Admin { private id: number; constructor(id: number) { this.id id; } } const u: User new Admin(1); // 报错类型“Admin”不兼容为什么这么设计因为如果只有形状一样就算兼容一个User类型实例的内部私有字段就可能被一个Admin实例“带偏”。TypeScript 用“私有成员必须声明于同一处”来保证两个类型只有当它们在同一个类中声明了私有成员时才被认为互相兼容。这个行为面试经常考直接背答案有时候会翻车理解“结构类型遇上封装信息就必须让路”就好记了。这种“锁”也是有用的它让private变成了类型层面的“品牌标记”可以用来做名义类型模拟。比如你想区分两个都是字符串的 ID把字符串包进带私有字段的类里就无法互相赋错了。3.2 对象字面量为什么不能“冒充”类实例接着上面的特性看一个常见报错class Person { private ssn: string; constructor(ssn: string) { this.ssn ssn; } } const p: Person { ssn: 123 }; // 报错原因是对象字面量类型不能声明private成员它和Person类型在私有成员上永远对不上。所以哪怕对象里有一个同名属性TypeScript 也判定为不可兼容。这个限制倒过来看是个保护一个类对外承诺“我有不可见的内部状态”外部就不可能伪造一个一模一样的东西塞进来。实际项目中DTO、实体类经常有这种需求——只允许通过构造函数或工厂函数创建实例禁止外部拼一个对象蒙混过关。private在这里就起了“防伪”作用。如果你确实需要把一个普通对象转成类实例通常用 Object.assign 或者类里的静态工厂方法而不是直接类型断言。3.3 受保护成员访问还有一层“实例身份”限制protected有一个特别容易踩的编译坑。看这个例子class Base { protected x 1; } class Derived extends Base { f(other: Base) { // other.x; // 报错属性“x”受保护只能通过类“Derived”的实例访问 } }按理说other是Base类型Derived是Base的子类子类内部应该可以访问protected成员吧不行。TypeScript 有一条规则通过“其他实例”访问protected成员时那个实例必须是当前类或当前类的子类类型。也就是说在Derived的方法里你可以访问this.x可以访问某个Derived类型参数上的x但不能访问一个静态类型是Base的参数上的x。这么做是为了防止“间接访问”——比如某个兄弟类Sibling extends Base它内部也能访问自己的x但如果允许通过Base类型互相访问两个分支的私有逻辑就被打通了。遇到这种报错解决办法通常是缩小参数类型比如把other: Base改成other: Derived或者在基类里提供受保护的访问方法。4. 编译之后的世界TypeScript 修饰符与 JavaScript 原生私有字段4.1 编译产物里private 和 protected 去哪了TypeScript 的访问权限是“编译期约束”不是“运行时机制”。你把代码编译成 JavaScript 之后private和protected完全消失属性就是一个普普通通的 JS 属性class Counter { private count 0; increment(): number { this.count; return this.count; } }编译目标为 ES2015 或更高时大致变成class Counter { constructor() { this.count 0; } increment() { this.count; return this.count; } }编译目标为 ES5 时也会类似只是 class 语法变成函数和原型链。你完全可以在运行时通过(counter as any).count读到私有值甚至直接改掉。TypeScript 编译器和 IDE 会报错但不会阻止你在运行时绕过去。这意味着什么如果你的代码要作为库发布给他人使用光靠private是挡不住“故意使坏”的调用方的。访问权限是一个工程约定不是一个安全边界。真正的安全边界还是得靠运行时机制比如闭包、WeakMap、或者 ES2022 的原生私有字段。4.2 ES2022 原生#私有字段真正的运行时私有从 ES2022 开始JavaScript 自己有了私有字段语法用#声明class Counter { #count 0; increment(): number { this.#count; return this.#count; } }它的特点是真正的运行时私有——外部任何方式都访问不到包括(counter as any).#count因为这个名字在语法层面就不存在。还有一件很实用的事#私有字段不会与继承类、甚至与其他类的同名#字段冲突因为每个名字都会在运行时被“作用域化”处理。那 TypeScript 项目里应该选哪种我的经验是如果你写的是内部业务代码类实例不会被第三方消费者使用优先用private。它更简单、兼容所有编译目标IDE 提示友好而且不会在运行时产生额外开销。如果你写的是开源库、npm 包类的内部状态不希望被别人的代码意外触碰优先用#。它是语言层面的强制私有别人拿你的库做二次开发时想动手脚也碰不到内部字段。如果你编译目标比较老ES2021 之前又想用#需要确认转译器是否支持TypeScript 3.8 会做降级处理但降级方案可能要引入 WeakMap性能上有轻微影响。顺便说一个容易混淆的点private和#是可以混用的。private处理编译期访问控制#处理运行时真正私有。Class 内部可以同时声明两者。不过实践中建议一个类里保持一致风格别一半private一半#否则维护时还要去猜每个字段的真实暴露水平。4.3 项目里怎么定规范我参与的 TS 项目规范大致是这样的类成员默认不写修饰符但对外 API 必须显式public。所有内部可变状态一律private子类需要用的辅助方法统一protected。对外发布的库核心私有状态用#避免消费者绕路。用 readonly 标记不需要重新赋值的字段尤其是注入的依赖。不用下划线_name的命名约定替代访问权限。下划线只是“视觉提示”没有任何编译期保障一旦团队成员不遵守代码就慢慢变成“哪里都能访问”。这样的好处是看到public就知道是对外契约看到private就知道是内部实现看到protected就知道牵扯继承。代码的可维护性会在几个月后真实反馈回来。5. 面试高频题与编码实操速查5.1 常见报错与排查思路我把平时在项目和面试中高频出现的访问权限相关报错整理成了表方便对照排查报错现象原因解决办法属性为私有属性只能在类中访问子类或外部访问了private成员改protected或public或提供公开方法属性受保护只能通过类实例访问外部实例访问protected成员改成public或用公开方法中转属性受保护只能通过类“X”的实例访问在子类中访问基类类型引用上的protected成员缩小引用的静态类型对象字面量与类类型不兼容类有private或protected成员对象字面量无法声明用类的构造函数/工厂方法创建类型不兼容结构相同但私有成员来自不同类两个类各有同名private字段统一继承一个基类或改用#私有字段参数属性需要在实现签名中书写构造函数重载签名里写了修饰符只在实现签名里写参数属性5.2 面试里会问到的延伸点除了基本语义下面这几条是面试官比较爱追问的private和protected的区别通常结合具体代码场景问比如“子类能不能访问父类的 private 字段”答案是“不能”很多人栽在这里。TypeScript 的访问权限编译后是否还存在答案是“不存在”。紧接着会追问“那怎么实现运行时私有”就把#私有字段、WeakMap、闭包都答上来。为什么有private还需要#把“编译期约束 vs 运行时强制”的区别说清楚。protected的跨实例访问限制就是我上面讲的Derived方法里访问Base引用上的protected成员会报错能答出来的人很少答出来基本就是加分项。参数属性也是面试高频考察对构造函数语法的熟悉度顺带还会遇到readonly 修饰符的组合写法。还有一个容易被忽略的细节protected构造函数。你可以在构造函数上写protected这样类不能被外部直接new只能由子类super()调用。这是实现“抽象类不让实例化”的一种简单手段比abstract的限制更轻量。class BaseModel { protected constructor(public id: number) {} } class UserModel extends BaseModel { constructor(id: number, public name: string) { super(id); } } // new BaseModel(1); // 报错构造函数受保护 new UserModel(1, 张三); // OK这种写法在写基础模型、基类控制器时很实用外部只能创建子类实例不能直接实例化基类。5.3 编码层面再补两个真实场景场景一实现“只能通过方法修改”的属性。很多人一上来就给字段加private然后用 setter其实更简洁的是配合 getterclass Account { private _balance 0; get balance(): number { return this._balance; } deposit(amount: number) { if (amount 0) { throw new Error(金额必须大于0); } this._balance amount; } }外部只能读balance要改钱只能走deposit校验逻辑锁在一个入口。这就是访问权限和业务约束的结合。场景二混入Mixin场景下的访问权限。做一个功能混入时字段权限一不小心就会牵连到多个类interface Named { name: string; } class Person implements Named { private name ; setName(name: string) { this.name name; } getName(): string { return this.name; } } class Employee extends Person { // 这里没法读 this.name只能通过 setName/getName }如果混入的类里需要读写宿主类的私有成员私有成员会给你设置一堆障碍。通用的做法是混入基类把字段声明成protected让所有混合进来的类都能访问或者干脆内部暴露受保护的方法避免直接把private字段传给外部混入逻辑。6. 个人经验谈写类时我的一些习惯写了几年 TypeScript我对访问权限的态度从“尽量严格”慢慢变成了“有章法的严格”。private不是越多越好把本该公开的方法设为private会让测试和扩展变得很别扭真正要守住的是“可变的状态”和“实现细节”。对外稳定的方法、组件接口该public就public不用不好意思内部状态能私有就私有能只读就加readonly。protected是继承体系的桥梁要从“子类需不需要”出发去设计而不是随手给所有方法都开了受保护的豁口。还有一个我自己常做的小动作写完一个类之后问自己一句“如果三个月后有人接手只看类上暴露的成员他能猜出这个类怎么用吗”如果答案是不能说明我的公开接口太糊涂了。好的类设计公开的方法就是说明书私有的字段就是保险柜受保护的方法就是留给子类的辅助工具。这个视角比死记修饰符定义有用得多。最后再分享一个细节如果你在用 TypeScript 写一个被很多地方引用的类别把访问权限改来改去。从private改成public通常不会报错但反向从public改成private或者protected会瞬间让所有调用方编译失败。这是最“便宜”的破坏性变更但也是最容易被忽略的。改动前先搜一下外部用法能把团队很多不必要的加班省下来。
返回列表