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

今天我们来聊聊封装。这个词你肯定听过无数遍了,但说实话,很多人对封装的理解就停留在「把字段设为 private,然后写一堆 getter/setter」。嗯,这确实算封装,但只是最基础的一层。

我个人习惯把封装看作一种「契约管理」。你对外暴露什么,隐藏什么,本质上是在定义你的模块和外部世界之间的边界。边界清晰了,代码才好维护。

这一节我们重点讲三个重构手法:封装记录封装集合以对象取代基本类型。它们都属于「封装」这个大主题下的实用技巧。

1. 封装记录(Encapsulate Record)

先说说封装记录。什么是记录?说白了就是键值对结构,比如 JSON 对象、字典、或者某些语言里的 struct。很多新手喜欢直接拿字典到处传,比如这样:

// 原始代码:直接使用字典
let user = {
  name: "张三",
  age: 28,
  email: "zhangsan@example.com"
};

function printUserInfo(user) {
  console.log(`${user.name},${user.age}岁,邮箱:${user.email}`);
}

这段代码有什么问题?问题大了。你想想看,如果哪天需求变了,email 字段要改成 emailAddress,你得把所有用到 user.email 的地方全改一遍。我在项目中遇到过这种场景,一个字段改名,全局搜索替换搞了半小时,还漏了两个地方,线上出了 bug。

更好的做法是把它封装成一个类:

// 重构后:封装成类
class User {
  constructor(data) {
    this._name = data.name;
    this._age = data.age;
    this._email = data.email;
  }

  get name() { return this._name; }
  get age() { return this._age; }
  get email() { return this._email; }

  // 只暴露需要的方法
  printInfo() {
    console.log(`${this.name},${this.age}岁,邮箱:${this.email}`);
  }
}

let user = new User({
  name: "张三",
  age: 28,
  email: "zhangsan@example.com"
});
user.printInfo();
核心要点:封装记录的核心不是加一层类,而是把数据的访问方式统一到一个地方。以后字段改名,你只需要改这一个类内部,外部调用者完全不受影响。

2. 封装集合(Encapsulate Collection)

集合的封装比普通字段更微妙。很多人觉得「我返回一个 List 的副本不就完了?」——嗯,方向对了,但还不够。

来看一个典型反例:

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

  getMembers() {
    return this._members;  // 直接返回内部数组
  }
}

let team = new Team();
let members = team.getMembers();
members.push("李四");  // 外部直接修改了内部数据!

为什么会这样?因为 getMembers() 返回的是同一个数组引用。外部拿到后可以随意增删改,你的封装形同虚设。

我曾经在一个遗留系统里见过这种写法,一个团队模块的成员列表被三个不同的地方直接修改,最后数据全乱了,查了两天才定位到问题。

正确的做法是:

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

  // 返回副本,防止外部修改
  getMembers() {
    return [...this._members];
  }

  // 提供明确的修改方法
  addMember(name) {
    if (!this._members.includes(name)) {
      this._members.push(name);
    }
  }

  removeMember(name) {
    this._members = this._members.filter(m => m !== name);
  }

  get memberCount() {
    return this._members.length;
  }
}

let team = new Team();
team.addMember("张三");
team.addMember("李四");
console.log(team.memberCount); // 2

// 外部只能读,不能改
let snapshot = team.getMembers();
snapshot.push("王五"); // 只影响副本,不影响内部数据
console.log(team.memberCount); // 仍然是 2
小技巧:如果你用的是 TypeScript,可以用 ReadonlyArray<T> 作为返回类型,从类型层面就禁止修改。但运行时还是要返回副本才保险。

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

这个手法我特别喜欢。很多代码写着写着,你会发现一个字符串或数字承载了太多含义。

举个例子:

// 原始代码:用字符串表示电话号码
let phone = "010-12345678";

function validatePhone(phone) {
  return /^\d{3,4}-\d{7,8}$/.test(phone);
}

function formatPhone(phone) {
  let parts = phone.split("-");
  return `区号:${parts[0]},号码:${parts[1]}`;
}

你看,电话号码的验证逻辑和格式化逻辑散落在各处。如果哪天电话号码格式变了(比如加上了分机号),你得把所有相关函数都找出来改一遍。

更好的做法是把电话号码封装成一个对象:

class PhoneNumber {
  constructor(raw) {
    if (!this._isValid(raw)) {
      throw new Error("无效的电话号码格式");
    }
    let parts = raw.split("-");
    this._areaCode = parts[0];
    this._number = parts[1];
  }

  _isValid(raw) {
    return /^\d{3,4}-\d{7,8}$/.test(raw);
  }

  get areaCode() { return this._areaCode; }
  get number() { return this._number; }

  toString() {
    return `${this._areaCode}-${this._number}`;
  }

  format() {
    return `区号:${this._areaCode},号码:${this._number}`;
  }
}

// 使用
let phone = new PhoneNumber("010-12345678");
console.log(phone.format()); // 区号:010,号码:12345678
注意:不要过度封装。如果一个基本类型只是单纯地存值,没有任何行为逻辑,那封装成对象反而增加了复杂度。判断标准很简单:这个值有没有相关的验证、格式化、转换等行为?有,就封装;没有,就保持原样。

知识体系总览

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

封装(上)知识体系 封装记录 数据访问统一管理 封装集合 防止外部直接修改 以对象取代基本类型 行为与数据绑定 适用场景 JSON/字典数据 外部接口返回的数据 适用场景 数组/列表/Set等 需要控制修改权限 适用场景 电话号码/金额/状态码 有验证/格式化逻辑 核心原则:封装不是隐藏,而是管理边界 让数据的使用方式可控,让变化的影响范围最小

三个手法的对比

手法 核心问题 解决方式 我常用的判断标准
封装记录 数据散落在字典/JSON中,访问方式不统一 用类/对象包裹,提供统一访问接口 这个数据被多少处代码引用?超过3处就封装
封装集合 外部可以直接修改内部集合 返回副本 + 提供明确的增删改方法 集合是否会被外部遍历并修改?是就封装
以对象取代基本类型 基本类型承载了过多行为逻辑 将基本类型提升为对象,行为内聚 这个值有没有「不属于基本类型」的行为?有就封装
我的个人习惯:每次写代码时,如果发现一个基本类型变量被传了三个以上的函数,我就会停下来想想——是不是该把它封装成一个对象了?这个习惯帮我避免了很多后期改动的痛苦。

好了,这一节的内容就到这里。封装这件事,说白了就是「管好你自己的事,别让别人乱动你的东西」。这三个手法看似简单,但用好了,代码的可维护性会提升一个档次。

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