封装(一):封装记录、封装集合、以对象取代基本类型

重构这件事,说白了就是让代码更好改。我做了这么多年架构,发现一个规律:代码腐烂的速度,往往取决于数据被暴露的程度。你想想看,一个字段到处被直接读写,改起来是不是心惊胆战?今天我们就聊三个最基础、也最实用的封装手法。

1. 封装记录(Encapsulate Record)

先看一个最常见的场景。项目中经常有这种代码:

// 重构前:直接操作散装数据
let userName = "张三";
let userAge = 28;
let userEmail = "zhangsan@example.com";

// 到处都在这样用
console.log(userName);
userAge = 29; // 改年龄,但没人知道改得对不对

这种散装变量,就像把螺丝刀、扳手、锤子全扔在地上。用起来是方便,但找起来、管起来就乱了。

第一步:用对象收拢

// 重构后:封装成记录
const user = {
  name: "张三",
  age: 28,
  email: "zhangsan@example.com"
};

console.log(user.name);
user.age = 29;

嗯,这步很简单。但问题来了——对象还是可以被随意修改。我在项目中遇到过,一个核心配置对象被十几个地方直接赋值,最后查bug查到崩溃。

核心原则:封装记录不只是把数据放一起,而是要控制对数据的访问。

第二步:加上访问控制

class User {
  #name;
  #age;
  #email;

  constructor(name, age, email) {
    this.#name = name;
    this.#age = age;
    this.#email = email;
  }

  getName() { return this.#name; }
  getAge() { return this.#age; }
  getEmail() { return this.#email; }

  setAge(age) {
    if (age < 0 || age > 150) {
      throw new Error("年龄不合法");
    }
    this.#age = age;
  }
}

const user = new User("张三", 28, "zhangsan@example.com");
user.setAge(29); // 合法
user.setAge(200); // 抛出错误

看到没?现在改年龄必须经过 setAge 方法。你可以在里面加校验、加日志、加通知。这就是封装的价值——把数据的使用权收回来

我的习惯:只要一个数据结构被三个以上地方使用,我就考虑封装成类。别等到代码写烂了再动手。

2. 封装集合(Encapsulate Collection)

集合的封装,是很多人容易忽略的坑。我见过太多这样的代码:

class Team {
  constructor() {
    this.members = [];
  }
}

const team = new Team();
team.members.push("张三"); // 直接操作数组
team.members[0] = "李四";  // 直接修改元素
team.members.pop();        // 直接删除

这跟没封装有什么区别?集合的引用一旦暴露出去,调用方就可以为所欲为

正确的做法:只暴露不可修改的视图

class Team {
  #members = [];

  addMember(name) {
    if (!name || this.#members.includes(name)) {
      return;
    }
    this.#members.push(name);
  }

  removeMember(name) {
    const index = this.#members.indexOf(name);
    if (index !== -1) {
      this.#members.splice(index, 1);
    }
  }

  getMembers() {
    return [...this.#members]; // 返回副本
  }

  get size() {
    return this.#members.length;
  }
}

const team = new Team();
team.addMember("张三");
team.addMember("李四");

// 外部无法直接修改内部数组
const members = team.getMembers();
members.push("王五"); // 只影响副本,不影响原数据
console.log(team.size); // 仍然是 2

我曾经踩过的坑:直接返回了内部数组的引用,结果某个模块偷偷清空了数组,导致整个系统的用户列表全没了。从那以后,我所有集合都返回副本或者只读视图。

这里有个细节:返回副本不是万能的。如果集合里存的是对象,副本只是复制了引用,对象本身还是可以被修改。所以更彻底的做法是返回不可变对象,或者用 Object.freeze 冻结。

3. 以对象取代基本类型(Replace Primitive with Object)

这个手法,说白了就是别把有业务含义的值当字符串或数字用。举个例子:

// 重构前:到处用字符串表示电话
function call(phoneNumber) {
  console.log(`正在拨打 ${phoneNumber}`);
}

call("13800138000"); // 看起来没问题
call("abc");         // 也能传进去,但明显不对

你想想看,电话号码是随便一个字符串吗?它有格式要求、有区号、有国家码。用基本类型表示,就等于放弃了所有约束。

重构后:创建专属类型

class PhoneNumber {
  #countryCode;
  #areaCode;
  #number;

  constructor(countryCode, areaCode, number) {
    if (!/^\d{1,3}$/.test(countryCode)) {
      throw new Error("国家码格式错误");
    }
    if (!/^\d{3,4}$/.test(areaCode)) {
      throw new Error("区号格式错误");
    }
    if (!/^\d{7,8}$/.test(number)) {
      throw new Error("号码格式错误");
    }
    this.#countryCode = countryCode;
    this.#areaCode = areaCode;
    this.#number = number;
  }

  getFullNumber() {
    return `+${this.#countryCode} ${this.#areaCode} ${this.#number}`;
  }

  toString() {
    return this.getFullNumber();
  }
}

// 使用
const phone = new PhoneNumber("86", "010", "12345678");
call(phone.toString()); // 输出:正在拨打 +86 010 12345678

现在,PhoneNumber 自己负责校验格式。你不可能再传一个 "abc" 进去。这就是用类型系统来约束业务规则

什么时候该用? 我个人的判断标准是:如果一个基本类型出现了校验逻辑、格式化逻辑、或者多个相关操作,就该封装成对象了。

知识体系总览

下面这张图,帮你理清这三个封装手法的关系和适用场景:

封装三大手法 封装记录 封装集合 以对象取代基本类型 散装变量 → 对象收拢 数组直接暴露 → 只读视图 字符串/数字 → 专属类型 核心价值:控制数据访问 + 内聚业务逻辑 + 增强类型安全 适用场景判断 数据被多处读写 → 封装记录 集合被外部修改 → 封装集合 基本类型有业务含义 → 创建对象 封装不是限制,而是给代码加上「安全护栏」

避坑指南

最后,分享几个我实战中总结的经验:

  • 不要过度封装:如果一个数据只在两个地方用,直接暴露也没问题。封装有成本,别为了封装而封装。
  • 注意性能:返回集合副本在数据量大时会有性能开销。如果集合有上千个元素,考虑用只读代理或者懒加载。
  • 保持一致性:团队里要么都用封装,要么都不封装。最怕一半封装一半不封装,新人来了直接懵。
  • 先写测试再重构:封装会改变调用方式。没有测试覆盖,你改完都不知道哪里炸了。

我的工作流:先找到最「痛」的地方——就是那个改一次要改十几个文件的数据。从它开始封装,效果立竿见影。

封装这件事,说白了就是给数据穿上铠甲。刚开始你可能觉得麻烦,但项目大了以后,你会发现这些铠甲救了你无数次。下一节我们继续聊封装的高级玩法——封装函数和封装条件逻辑。


公众号:蓝海资料掘金营,微信deep3321