封装(一):封装记录、封装集合、以对象取代基本类型
重构这件事,说白了就是让代码更好改。我做了这么多年架构,发现一个规律:代码腐烂的速度,往往取决于数据被暴露的程度。你想想看,一个字段到处被直接读写,改起来是不是心惊胆战?今天我们就聊三个最基础、也最实用的封装手法。
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" 进去。这就是用类型系统来约束业务规则。
什么时候该用? 我个人的判断标准是:如果一个基本类型出现了校验逻辑、格式化逻辑、或者多个相关操作,就该封装成对象了。
知识体系总览
下面这张图,帮你理清这三个封装手法的关系和适用场景:
避坑指南
最后,分享几个我实战中总结的经验:
- 不要过度封装:如果一个数据只在两个地方用,直接暴露也没问题。封装有成本,别为了封装而封装。
- 注意性能:返回集合副本在数据量大时会有性能开销。如果集合有上千个元素,考虑用只读代理或者懒加载。
- 保持一致性:团队里要么都用封装,要么都不封装。最怕一半封装一半不封装,新人来了直接懵。
- 先写测试再重构:封装会改变调用方式。没有测试覆盖,你改完都不知道哪里炸了。
我的工作流:先找到最「痛」的地方——就是那个改一次要改十几个文件的数据。从它开始封装,效果立竿见影。
封装这件事,说白了就是给数据穿上铠甲。刚开始你可能觉得麻烦,但项目大了以后,你会发现这些铠甲救了你无数次。下一节我们继续聊封装的高级玩法——封装函数和封装条件逻辑。