ES6 class通关手册:从基础语法到继承,从私有字段到实战组件

发布时间:2026/10/2 20:07:57
ES6 class通关手册:从基础语法到继承,从私有字段到实战组件 如果你是从 ES5 时代摸爬滚打过来的老前端第一次见到class关键字时心里多半会冒出三个字这不就是语法糖吗 如果你是从 Java 或者 Python 转过来的新人看到class又会陷入另一种困惑JS 的class怎么总感觉和别的语言不太一样继承、私有属性、this绑定全都暗藏玄机。ES6 class 是现代 JavaScript 面向对象写法的核心语法也是我在带新人时最重视的一课。这篇通关手册我就把class从基础语法到继承机制、从私有字段到实战组件连着底层原理和踩坑经验一起讲清楚。无论你是刚摸到 JS 门框的新手还是写了好几年function想系统化重构的老手这一篇都值得认真过一遍。1. 从构造函数到 classJS 面向对象语法是怎么演进的1.1 ES5 时代用构造函数和原型模拟面向对象在 ES6 之前JavaScript 没有class关键字但面向对象的三大特征——封装、继承、多态却一样都不少。它靠的是构造函数加原型链硬生生模拟出了一套面向对象的玩法。先看一段典型的 ES5 代码function Animal(name) { this.name name; } Animal.prototype.speak function () { console.log(this.name 发出声音); }; Animal.prototype.type 动物; var dog new Animal(旺财); console.log(dog.type); // 动物 dog.speak(); // 旺财 发出声音这段代码的逻辑并不复杂Animal是个普通函数用new调用时它会变成一个构造函数实例属性挂到this上共享方法挂到Animal.prototype上。凡是new Animal()创建出来的对象都能通过原型链访问到speak和type。这套机制在当时已经足够强大但实际写起来有几个很别扭的地方。第一个别扭之处是语义模糊Animal本身就是一个函数新手很容易直接var a Animal(旺财)把它当成普通函数调用一调用就是undefined而且this指向了全局对象连带污染全局作用域。第二个别扭之处是方法归属感差方法都挂在.prototype上写多了就像在拆零件维护一个几十个方法的类代码会变得非常散。第三个别扭之处是继承链条极其繁琐需要一系列Object.create、call、Object.setPrototypeOf之类的操作组合我早期的项目里见过一段六百行的继承封装代码最后没人敢动它。1.2 class 不是普通语法糖而是规范化的升级很多教程喜欢把class定义成语法糖这个说法大方向上没错但容易让人低估它带来的变化。严谨一点说class是一层建立在原型之上的、有明显语义差异的语法升级并非简单换个写法。看一个同样功能的 ES6 版本class Animal { constructor(name) { this.name name; } speak() { console.log(${this.name} 发出声音); } } const dog new Animal(旺财); dog.speak(); // 旺财 发出声音表面上constructor对应构造函数speak对应原型的实例方法好像就是把prototype.缩进了花括号里。但class和普通构造函数之间有几个非常关键的差异类体默认处于严格模式所有类方法全都不可枚举而 ES5 手动挂在prototype上的方法是可枚举的。class声明没有变量提升不存在先调用后声明这种用法而普通函数声明天然有提升。类只能通过new调用直接当成函数执行会立刻抛出TypeError。类内部的function方法没有prototype属性因此也不会被误当成构造函数去new。下面这段代码能直观看出差异class Animal { constructor(name) { this.name name; } speak() {} } console.log(typeof Animal); // function console.log(Object.keys(Animal.prototype)); // [] console.log(Animal Animal.prototype.constructor); // true它底层仍然是一个构造器仍然走原型链但在语法层面把面向对象这件事变得规范和收敛了。加了new.target检测、严格模式保护、方法不可枚举约束之后ES5 时代常见的忘写 new就能在源头被挡住。所以我在项目里更愿意把class当作新的范式来用而不是把它当成原型链的一个简短别名。2. class 基础语法constructor、实例字段与方法的正确打开方式2.1 constructor初始化实例的入口constructor是类的默认入口它的作用是在new执行时完成实例的初始组装。new Foo()发生的时候JS 引擎会先创建出一个新对象然后把Foo.prototype挂到它的原型上最后把constructor里的逻辑作为初始化流程执行让this指向这个新对象。写constructor的时候有两点值得注意。第一类只能显式声明一个constructor写两个会直接语法报错。第二如果不写constructor引擎会偷偷补一个空构造器对普通类来说等价于constructor() {}对继承类来说等价于constructor(...args) { super(...args); }。class Person { constructor(name, age) { this.name name; this.age age; } } const p new Person(小明, 18); console.log(p.name); // 小明实例上特有的属性比如name、age按惯例都在constructor里初始化。不过 ES2022 开始支持类字段class field语法可以直接在类体顶部声明实例字段让初始值一目了然class Person { name 未命名; age 0; tags []; constructor(name, age) { if (name ! undefined) this.name name; if (age ! undefined) this.age age; } }类字段会在constructor执行之前先完成初始化。换句话说你走进constructor的第一行代码时this.name已经是未命名this.tags已经是[]不会再出现某个属性忘了初始化访问时得到undefined的裸奔状态。这是老代码里最常见的bug来源之一尤其是多人协作时有人往类里加属性却忘在constructor里赋值。用类字段语法等于把这个坑提前填上了。2.2 实例方法、原型方法与静态方法三者的定位差异class内部写的方法会默认进入原型链成为原型方法但也有一种特殊情况如果你把方法声明为static它就会被挂到类本身和具体实例无关。三种方法在语义上有明确分工。原型方法是我们平时最常用的方法。每个实例不会单独复制一份而是通过原型链查找内存占用低方法更新只改原型对象一处所有实例都能拿到新行为class Counter { count 0; increment() { this.count 1; } }实例方法则通过类字段声明每个实例都拥有自己的函数副本。它最大的价值在于配合箭头函数固定this这个后面单独展开。静态方法属于类自身的方法适合放一些不需要实例状态、只与类本身相关的逻辑。比如axios.create、Promise.all、Array.isArray都是静态方法思维的经典例子class StringUtils { static isEmpty(str) { return str || str null; } static toKebabCase(str) { return str.replace(/([a-z])([A-Z])/g, $1-$2).toLowerCase(); } } console.log(StringUtils.isEmpty()); // true console.log(StringUtils.toKebabCase(helloWorld)); // hello-world静态方法里的this指向的是类本身不指向任何实例。子类通过继承可以访问父类的静态方法但调用时的this会指向子类这在实现多态工厂时可以派上用场。2.3 类字段与箭头函数方法this 绑定问题的最简解法前面提到类字段不仅能保存数据还能保存箭头函数。这个能力直接解决了一个困扰 JS 开发者多年的问题方法被当作回调传给setTimeout、事件监听器或者第三方库时this会静默丢失。class Button { label 点击; handleClick() { console.log(this.label); } } const btn new Button(); setTimeout(btn.handleClick, 100); // undefined或者直接报错原因很简单btn.handleClick被作为独立函数传入时真正执行它的上下文已经和btn实例无关。在严格模式下函数里的this会变成undefined于是访问this.label就报错非严格模式下则可能指向全局对象造成更隐蔽的污染。用箭头函数类字段可以直接把this锁死在实例上因为箭头函数在创建时就捕获了外层词法作用域的thisclass Button { label 点击; handleClick () { console.log(this.label); }; } const btn new Button(); setTimeout(btn.handleClick, 100); // 点击代价是每个实例都会创建一份handleClick函数副本占用更多内存。如果应用里这种实例成千上万更稳妥的做法是在constructor里手动bindclass Button { constructor() { this.label 点击; this.handleClick this.handleClick.bind(this); } handleClick() { console.log(this.label); } }这两种方式各有取舍。我的经验是组件类比如 Vue 组件、React 组件、弹窗类优先用箭头函数字段因为它们实例数量有限代码直观最重要工具类或会被高频实例化的类优先用原型方法加bind把内存占用压下去。3. 继承的深层机制extends、super 与原型链的联动3.1 extends 让两条原型链同时建立class的继承只有一个关键字extends。它做了很多工作但核心可以在两条原型链上理解清楚。class Animal { constructor(name) { this.name name; } eat() { console.log(${this.name} 正在进食); } } class Dog extends Animal { constructor(name, breed) { super(name); this.breed breed; } bark() { console.log(${this.name} 在汪汪叫); } } const dog new Dog(旺财, 柯基); dog.eat(); // 旺财 正在进食 dog.bark(); // 旺财 在汪汪叫extends帮我们建立了两条链路。第一条是实例层原型链dog.__proto__ Dog.prototype而Dog.prototype.__proto__ Animal.prototype。所以dog访问bark时先找到Dog.prototype访问eat时顺着链往上找到Animal.prototype。第二条是构造器层原型链Dog.__proto__ Animal这意味着子类可以通过super.xxx()调用父类的静态方法也让静态属性在子类上可以继续继承。ES5 时代要实现同样效果得写密密麻麻的Object.create和setPrototypeOf还经常有人漏了其中一条链导致实例方法能继承但静态方法继承不了。extends直接规避了这种低级失误。3.2 super 的两副面孔构造器里和普通方法里super是 JS 里少有的一个根据位置改变语义的关键字。在constructor里super()是一个函数调用用来执行父类的构造器在普通方法里super.xxx用来访问父类原型上的方法或静态属性。先看构造器里的super()规则。ES6 的继承实现和 ES5 有本质区别ES5 是先创建子类的this再让父类函数去加工它ES6 则是先由父类构造出this子类拿到之后再接着加工。所以派生类的constructor里必须在任何this操作之前先调用super()class Dog extends Animal { constructor(name, breed) { // 这里如果写 this.breed breed; 会直接抛 ReferenceError super(name); this.breed breed; } }之所以这样规定是因为子类的this初始化依赖于父类构造结果。如果你还没调用super()就想访问this引擎根本无法保证这个对象已经完成了最基础的构造。再看普通方法里的super。它指向父类的原型对象但调用方法时this会原样绑定到当前实例这样才能保证在处理子类数据时不会串到父类上去。比如class Dog extends Animal { eat() { console.log(开始吃饭); super.eat(); console.log(吃饭结束); } }这里super.eat()执行的是Animal.prototype.eat但方法是定义在Animal.prototype上不假执行时函数内部的this.name依然指向dog实例。理解这一条你才能看懂为什么继承中的super调用不会弄丢子类的数据。3.3 多级继承与方法重写中的注意点继承链条是可以继续往下延伸的class Puppy extends Dog没有任何问题。但不要陷入无限多层继承的思维惯性。前端组件领域有一条经验法则继承层级控制在三层以内超过三层代码的阅读成本和改动风险会快速上升。方法重写也是继承里躲不开的事。子类定义同名方法会覆盖父类方法这是一种常见的设计。但在重写前一定要想清楚你真的需要完全替换父类行为还是想在父类行为前后加点逻辑如果是后者务必调用super.xxx()否则你会不知不觉丢掉父类原有的关键处理。class Dog extends Animal { eat() { super.eat(); console.log(${this.name} 吃得很开心); } }还有一个隐藏很深的多态陷阱父类constructor中调用了某个方法而子类重写了这个方法此时会发生什么这个问题会导致非常诡异的现象我在第 6 章专门用一个案例展开。4. class 进阶能力私有字段、getter/setter 与静态块4.1 私有字段#语言级别的访问控制在 JS 早期大家都用下划线前缀约定_private表示私有属性但这种约定只防君子不防小人。ES2022 正式纳入了私有字段语法用#前缀声明真正的私有成员。class Account { #balance 0; constructor(balance) { this.#balance balance; } deposit(amount) { if (amount 0) throw new Error(充值金额必须大于0); this.#balance amount; } getBalance() { return this.#balance; } } const acc new Account(100); acc.deposit(50); console.log(acc.getBalance()); // 150 console.log(acc.#balance); // SyntaxError: Private field #balance must be declared in an enclosing class注意私有字段的访问报错发生在编译阶段而不是运行阶段。只要你在类外面写了acc.#balance代码加载时就会语法错误。用Object.keys(acc)枚举属性不会看到#balanceJSON.stringify(acc)也不会输出它。这对于网络传输、日志打印这些场景来说比传统下划线约定安全得多。私有字段也可以用于方法比如#checkAmount这类私有方法只能在类内部调用。不过有一个容易踩的坑私有字段在不同实例之间是可以互相访问的。同一个类创建的两个实例之间a的方法可以访问b的私有字段。这在实现某些数据结构比如链表的合并、树的遍历时反而是一种优势但新手初看代码时可能会觉得奇怪。4.2 getter/setter拦截属性读写的现实价值get和set语法可以让你像访问普通属性一样使用方法同时保留校验和计算的逻辑class User { #password ; constructor(username, password) { this.username username; this.password password; } get password() { return ********; } set password(value) { if (value.length 8) { throw new Error(密码长度至少需要8位); } this.#password value; } checkPassword(value) { return value this.#password; } } const u new User(admin, 12345678); console.log(u.password); // ******** console.log(u.checkPassword(12345678)); // true这里set password在构造时就被触发了等于把校验逻辑收敛到了唯一的入口以后任何位置对password赋值都必须经过校验想绕都绕不过去。get则可以用来做计算属性。比如圆类class Circle { constructor(radius) { this.radius radius; } get area() { return Math.PI * this.radius * this.radius; } } const c new Circle(5); console.log(c.area); // 78.53981633974483每次访问area都会实时计算值不需要预先存储也省去了手动同步的麻烦。需要注意get和set要成对使用才有最大价值只写get不写set在严格模式下对属性赋值会直接抛错。很多新手在只读属性上踩过这个坑解决方式是写一个空set或者在set里抛出明确错误。4.3 静态初始化块静态数据也需要生命周期static修饰的成员挂载在类本身它们同样可能需要一段初始化逻辑。ES2022 新增了静态初始化块static initialization block专门解决这个问题class Config { static data {}; static env; static { try { Config.env document.querySelector(html).dataset.env || production; Config.data JSON.parse(localStorage.getItem(app_config)) || {}; } catch (err) { Config.env development; Config.data {}; } } }静态初始化块在类定义阶段执行适合做环境探测、配置读取、第三方 SDK 初始化等一次性的准备工作。它和静态字段配合让类的静态成员在声明时就能完成初始赋值而不是等到第一次使用时才在方法里懒加载。我见过团队争论静态初始化块有没有必要结论是对于配置类、常量类、工具类这种以静态成员为主的类静态初始化块能把分散在多个静态方法里的初始化逻辑集中起来而对实例为主的类通常用不上它不必强行引入。5. 实战用 class 封装一个带队列的 Toast 组件5.1 组件需求与设计思路理论讲了这么多如果不实战一次很难真正体会class在真实代码里的优势。这里用前端日常最常用的Toast轻提示组件作为例子。需求非常简单组件可以显示一条消息并在规定时间后自动消失。如果多个提示连续触发上一个消失之后再显示下一个不能出现多条消息叠在屏幕中间的现象。调用方希望永远不要手动管理 DOM最好一行代码就能弹出提示。如果不用class这个组件可能会写成全局函数加全局状态状态完全裸奔多实例管理全靠手写。用class后我们可以把提示实例的状态和全局队列的逻辑清晰分离代码可读性直接上一个台阶。5.2 完整实现与关键代码拆解先看完整代码再逐段解释class Toast { static queue []; static isShowing false; constructor(options {}) { this.message options.message || ; this.duration options.duration || 2500; this.type options.type || info; this.el null; this.timer null; } show() { if (Toast.isShowing) { Toast.queue.push(this); return this; } this._mount(); Toast.isShowing true; return this; } _mount() { const toast document.createElement(div); toast.className toast toast-${this.type}; toast.textContent this.message; document.body.appendChild(toast); this.el toast; this.timer setTimeout(() { this.hide(); }, this.duration); } hide() { if (this.timer) { clearTimeout(this.timer); this.timer null; } if (this.el) { this.el.remove(); this.el null; } Toast.isShowing false; const next Toast.queue.shift(); if (next) { next.show(); } } static show(message, options {}) { const toast new Toast({ message, ...options }); return toast.show(); } }这段代码把class的几个能力都用上了。static queue和static isShowing是全局共享的队列状态用来管理当前是否有提示正在展示。实例属性message、duration、type、el、timer则负责记录单个提示的本地状态。show是公开入口_mount用下划线前缀表明它是内部方法hide则同时承担了销毁实例和驱动队列下一步的任务。看核心的队列逻辑当Toast.show(网络错误)被连续调用三次时第一次调用直接创建实例并挂到 DOM 上随后isShowing变为true第二次第三次进来时发现正在展示就把实例压入队列返回当前实例但不做其他操作。第一个提示定时结束后hide把isShowing置回false从队列头部取下一个实例重新调用show于是第二个提示才会展示。这就完美实现了串行弹窗不会出现多个提示叠在一起的情况。5.3 组件使用与扩展思路调用方式非常简单Toast.show(保存成功, { type: success, duration: 3000 }); Toast.show(网络连接失败请稍后重试, { type: error });如果想做单例版本只需要把new Toast()的逻辑收进getInstance方法保证全局只有一个实例。想扩展成支持按钮操作、支持手动关闭、支持多个提示并排展示改动也都在Toast内部完成调用方完全无感知。这种改内部不动外部的封装能力正是面向对象在工程里的核心价值让变化限定在一个可控的边界内。6. 使用 class 时最常见的几个坑与排查思路6.1 class 没有变量提升先使用后声明会直接报错普通函数声明是有变量提升的所以你可以先把函数写在后面再在前面调用foo(); // 正常工作 function foo() { console.log(foo); }但class声明没有这种待遇。下面的代码会直接抛出ReferenceErrorconst p new Person(); // ReferenceError: Cannot access Person before initialization class Person { constructor() { this.name test; } }底层原因是class声明被放进了一个暂时性死区TDZ引擎在类声明执行之前就读取了Person变量会直接拒绝访问。这个问题在模块化项目里特别容易触发A 模块导出一个类B 模块在使用时因为循环依赖或加载顺序问题拿到的是一个尚未初始化的类。排查思路是优先检查模块循环依赖其次把类的使用逻辑放到异步回调或者生命周期函数里保证类声明先于调用执行。6.2 方法作为回调传入时 this 不翼而飞这个坑我在 2.3 节已经抛出来过这里从排查角度再深挖一层。当你在调试时发现一个类方法里访问this.xxx报错第一反应不是去看对象结构而是先检查这个方法是不是被拆出来单独传给了别人class Counter { count 0; increment() { this.count 1; } } const counter new Counter(); const callback counter.increment; // 把方法拆出来当成普通函数 callback(); // TypeError: Cannot read properties of undefined (reading count)最直接的排查方法是打断点看函数执行时this的值到底是什么。如果显示undefined基本就是调用方式改变了上下文。修复方式有三条路调用时包一层箭头函数() counter.increment()传入前手动绑定counter.increment.bind(counter)或者直接用箭头函数类字段定义方法。第三种在现代前端框架中越来越普遍因为它从源头锁定了this。6.3 instanceof 不是万能类型判断工具很多新手遇到类型判断一律用instanceof但它在几个场景下会给出误导结果。第一个场景是跨iframeA 页面里拿到的数组arr在 B 页面里用arr instanceof BWindow.Array判断可能返回false因为数组的原型链分属两个不同的全局环境。第二个场景是继承链子类实例对父类构造函数使用instanceof会返回true这在某些场景下不是你想表达的意思。第三个场景有些库会通过Symbol.hasInstance定制instanceof的行为让判断逻辑变得不可控。更稳妥的通用类型判断方式是配合Object.prototype.toStringfunction getType(value) { return Object.prototype.toString.call(value).slice(8, -1); } console.log(getType([])); // Array console.log(getType(new Date())); // Date console.log(getType(null)); // Null如果要判断数组直接用 ES6 提供的Array.isArray。判断普通对象、Set、Map这些内置类型时Object.prototype.toString都不会被伪装成[object Object]的普通对象干扰。说到判断顺手提一句和字符串判断相关的迭代更新很多老代码判断字符串是否包含子串还在用indexOf其实 ES6 后有更直观的includes方法直接返回布尔值。语法层面类似的现代化改造往往比纠缠在class的细枝末节里提升效率更快。最后再说一个我实际写代码时的习惯class非常适合那些同时包含状态和行为、需要被复用的业务模型比如用户模型、订单模型、组件类但对于纯函数集合、单函数工具完全没有必要套上class的外壳。过度面向对象会让代码变得笨重适当的函数式思维和class各司其职才能写出既清晰又灵活的前端代码。这个度需要在真实项目里摔几次跟头才能拿捏准希望这一篇能帮你少摔几次。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询