封装(上):封装记录、封装集合、以对象取代基本类型
今天我们来聊聊封装。这个词你肯定听过无数遍了,但说实话,很多人对封装的理解就停留在「把字段设为 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
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中,访问方式不统一 | 用类/对象包裹,提供统一访问接口 | 这个数据被多少处代码引用?超过3处就封装 |
| 封装集合 | 外部可以直接修改内部集合 | 返回副本 + 提供明确的增删改方法 | 集合是否会被外部遍历并修改?是就封装 |
| 以对象取代基本类型 | 基本类型承载了过多行为逻辑 | 将基本类型提升为对象,行为内聚 | 这个值有没有「不属于基本类型」的行为?有就封装 |
好了,这一节的内容就到这里。封装这件事,说白了就是「管好你自己的事,别让别人乱动你的东西」。这三个手法看似简单,但用好了,代码的可维护性会提升一个档次。