源本科技 | 码上会

JavaScript 中的封装

2026/01/13
11
0

学习目标

  • 理解封装在面向对象编程中的核心作用

  • 掌握使用闭包实现真正私有成员的方法

  • 了解基于 ES6 类的封装实践及其局限性

  • 能够根据项目需求选择合适的封装策略


什么是封装

封装是面向对象编程的三大基本特性之一。它的核心思想是:

隐藏对象的内部实现细节,仅暴露必要的接口供外部使用。

通过封装,我们可以:

  • 保护数据不被非法修改

  • 控制对内部状态的访问方式

  • 降低模块间的耦合度

  • 提高代码的可维护性和安全性

在 JavaScript 中,由于语言本身的动态特性,实现封装需要借助特定技巧。


使用闭包实现强封装

JavaScript 的函数作用域和闭包机制可以创建真正的私有变量和方法,这是最可靠的封装方式。

示例:银行账户系统

闭包版

function BankAccount(accountNumber, accountHolderName, balance) {
    // 私有变量(外部无法直接访问)
    let _accountNumber = accountNumber;
    let _accountHolderName = accountHolderName;
    let _balance = balance;

    // 私有方法
    function showAccountDetails() {
        console.log(`Account Number: ${_accountNumber}`);
        console.log(`Account Holder Name: ${_accountHolderName}`);
        console.log(`Balance: ${_balance}`);
    }

    // 公共方法(通过返回对象暴露)
    function deposit(amount) {
        if (amount <= 0) {
            console.log("Deposit amount must be positive");
            return;
        }
        _balance += amount;
        showAccountDetails();
    }

    function withdraw(amount) {
        if (_balance >= amount && amount > 0) {
            _balance -= amount;
            showAccountDetails();
        } else {
            console.log("Insufficient Balance or invalid amount");
        }
    }

    // 返回公共接口
    return {
        deposit: deposit,
        withdraw: withdraw
    };
}

// 使用示例
const myAccount = BankAccount("123456", "John Doe", 1000);
myAccount.deposit(500);        // 成功存款
myAccount.withdraw(2000);      // 余额不足

// 尝试直接访问私有变量(失败)
console.log(myAccount._balance); // undefined

闭包封装的特点

  • 真正的私有性:外部无法通过任何方式直接访问 _balance 等变量

  • 数据完整性:所有状态变更都经过验证逻辑

  • 内存开销:每个实例都会创建独立的函数副本(无法共享方法)

  • 无法扩展:返回的对象是普通对象,不能通过原型链扩展


使用 ES6 实现约定式封装

ES6 的 class 语法提供了更结构化的面向对象编程方式,但默认不支持真正的私有成员

示例:银行账户系统

class BankAccount {
    constructor(accountNumber, accountHolderName, balance) {
        // 使用下划线前缀表示“私有”(约定俗成)
        this._accountNumber = accountNumber;
        this._accountHolderName = accountHolderName;
        this._balance = balance;
    }

    // 公共方法
    showAccountDetails() {
        console.log(`Account Number: ${this._accountNumber}`);
        console.log(`Account Holder Name: ${this._accountHolderName}`);
        console.log(`Balance: ${this._balance}`);
    }

    deposit(amount) {
        if (amount <= 0) {
            console.log("Deposit amount must be positive");
            return;
        }
        this._balance += amount;
        this.showAccountDetails();
    }

    withdraw(amount) {
        if (this._balance >= amount && amount > 0) {
            this._balance -= amount;
            this.showAccountDetails();
        } else {
            console.log("Insufficient Balance or invalid amount");
        }
    }
}

// 使用示例
const account = new BankAccount("123456", "John Doe", 1000);
account.deposit(500);

// 技术上仍可访问“私有”属性(不推荐)
console.log(account._balance); // 1500(但违反了封装原则)

类封装的局限性

  • 只是约定:下划线前缀 _ 仅是开发者的约定,技术上仍可被外部访问和修改

  • 方法共享:所有实例共享原型上的方法,内存效率高

  • 可扩展性强:支持继承、原型链扩展等高级特性


真正的私有字段

现代化 JavaScript ES2022+

从 ECMAScript 2022 开始,JavaScript 原生支持私有字段,使用 # 前缀声明。

class BankAccount {
    // 私有字段(真正的私有)
    #accountNumber;
    #accountHolderName;
    #balance;

    constructor(accountNumber, accountHolderName, balance) {
        this.#accountNumber = accountNumber;
        this.#accountHolderName = accountHolderName;
        this.#balance = balance;
    }

    // 私有方法
    #showAccountDetails() {
        console.log(`Account Number: ${this.#accountNumber}`);
        console.log(`Account Holder Name: ${this.#accountHolderName}`);
        console.log(`Balance: ${this.#balance}`);
    }

    deposit(amount) {
        if (amount <= 0) {
            console.log("Deposit amount must be positive");
            return;
        }
        this.#balance += amount;
        this.#showAccountDetails();
    }

    withdraw(amount) {
        if (this.#balance >= amount && amount > 0) {
            this.#balance -= amount;
            this.#showAccountDetails();
        } else {
            console.log("Insufficient Balance or invalid amount");
        }
    }

    // 提供受控的只读访问(可选)
    getBalance() {
        return this.#balance;
    }
}

const account = new BankAccount("123456", "John Doe", 1000);
account.deposit(500);

// 尝试访问私有字段(报错!)
// console.log(account.#balance); // SyntaxError: Private field '#balance' must be declared in an enclosing class

私有字段的优势

  • 真正的私有性:语法层面禁止外部访问

  • 运行时错误:试图访问会抛出明确的语法错误

  • 与类系统完美集成:支持私有方法、私有 getter/setter 等

注意:需要现代浏览器或构建工具(如 Babel)支持 ES2022+。


封装方式对比

方式

私有性强度

内存效率

可扩展性

浏览器兼容性

适用场景

闭包

⭐⭐⭐⭐⭐
(完全私有)


(每个实例独立函数)

所有环境

简单模块、安全敏感场景

类 + 下划线


(仅约定)


(方法共享)

优秀

所有 ES6+ 环境

一般业务开发

私有字段(#)

⭐⭐⭐⭐⭐
(语法级私有)

优秀

现代环境
(ES2022+)

新项目首选


最佳实践

  1. 新项目优先使用私有字段(#:提供最强的封装保证

  2. 兼容性要求高时使用闭包:当需要支持旧浏览器且必须保证私有性

  3. 团队协作项目可使用下划线约定:配合 ESLint 规则(如 no-underscore-dangle)约束访问

  4. 始终提供清晰的公共接口:无论采用哪种方式,都要设计合理的 API


重点总结

  • 封装的核心目标是控制访问权限,保护对象内部状态

  • 闭包提供最可靠的私有性,但牺牲了内存效率和可扩展性

  • ES6 类提供了结构化语法,但传统写法仅能实现“伪私有”

  • 私有字段(# 是现代 JavaScript 的标准解决方案,兼顾安全性与性能

  • 选择封装方式时需权衡:安全性、性能、兼容性、可维护性


思考题

  1. 在闭包实现的 BankAccount 中,为什么每次创建账户实例都会产生新的 depositwithdraw 函数?这会带来什么性能影响?

  2. 如果使用类的下划线约定方式,如何通过工具(如 ESLint)防止开发者意外访问私有属性?

  3. 私有字段(#balance)和 Symbol 实现的“伪私有”属性有何本质区别?为什么 Symbol 不能真正实现封装?