第9章 重新组织数据(Organizing Data)
一、导读
1.1 本章学习目标
通过本章的学习,你将能够:
理解数据结构对代码质量的影响,认识到良好的数据组织是清晰代码的基础
掌握拆分变量(Split Variable)的技术,将承担多重责任的变量拆分为独立的变量
学会字段改名(Rename Field),使数据字段的名称更清晰地表达其含义
掌握以查询取代派生变量(Replace Derived Variable with Query),消除冗余状态
学会将引用对象改为值对象(Change Reference to Value),简化数据管理
掌握将值对象改为引用对象(Change Value to Reference)的场景和方法
理解可变数据的问题,掌握不可变数据的优势和实现方式
能够在实际项目中识别数据组织的問題,并应用合适的重构手法
1.2 为什么本章如此重要
数据是程序的核心。无论代码逻辑多么精妙,如果数据结构混乱,整个系统都会变得难以理解和维护。
本章的重要性体现在以下几个方面:
首先,数据结构决定了代码的结构。良好的数据结构可以使代码自然流畅,而糟糕的数据结构会导致代码扭曲变形。正如Fred Brooks所说:"展示你的流程图,隐藏你的数据结构,我将继续困惑。展示你的数据结构,我的流程图通常就不言自明了。"
其次,现代应用越来越依赖复杂的数据处理。从API响应到状态管理,数据的组织和变换是开发工作的核心。良好的数据组织可以大大简化这些任务。
第三,数据的一致性是bug的主要来源之一。当同一数据有多个副本或派生版本时,保持它们同步是一个常见的问题。本章介绍的技术可以帮助消除这些问题。
第四,不可变数据正在成为主流。React、Redux等流行库都推崇不可变状态管理。理解不可变数据的优势和实现方式是现代开发者的必备技能。
第五,在分布式系统和微服务架构中,数据的序列化和传输变得至关重要。理解引用对象和值对象的区别对于设计可靠的分布式系统非常重要。
最后,随着TypeScript的普及,类型系统与数据结构的结合变得更加紧密。良好的数据组织可以更好地利用类型系统的优势。
对于任何希望编写高质量代码的开发者来说,本章都是必读的。它不仅告诉你如何组织数据,更告诉你为什么要这样组织。
1.3 核心问题引导
在阅读本章之前,请思考以下问题:
你是否曾经因为一个变量在不同时刻代表不同含义而感到困惑?
当同一个数据有多个来源或副本时,你如何确保它们的一致性?
什么是值对象?什么是引用对象?它们在JavaScript中有什么区别?
不可变数据真的有必要吗?它带来的好处是否值得额外的复杂度?
当你发现一个对象的字段可以通过其他字段计算得出时,应该保留这个字段吗?
在React等框架中,状态管理的数据组织有什么特殊要求?
如何处理来自外部API的数据结构与你内部模型的不匹配?
数据类型(如字符串、数字)应该在什么时候被封装为专门的类?
带着这些问题阅读本章,你会对数据组织有更深刻的理解。
二、核心概念详解
2.1 数据与代码的关系
数据和代码是软件的两个基本要素。它们之间的关系决定了系统的整体结构。
数据结构驱动代码结构:
// 糟糕的数据结构导致复杂的代码
const data = {
a: 'John',
b: 'Doe',
c: 'john@example.com',
d: '123-456-7890'
};
// 好的数据结构使代码自然
const user = {
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
phone: '123-456-7890'
};数据的演变:
随着需求的变化,数据结构也需要演变。重构数据结构往往比重构代码更重要,但也更困难,因为数据可能被多处使用。
数据组织的原则:
单一事实来源: 每个数据项应该只有一个权威来源
清晰的命名: 数据名称应该准确反映其含义
适当的抽象: 相关数据应该组合在一起
不可变性优先: 除非必要,否则数据应该是不可变的
2.2 拆分变量(Split Variable)
拆分变量是将承担多重责任的变量拆分为多个独立的变量。
动机:
- 一个变量在不同时刻被赋予不同的含义
- 变量先作为临时值使用,后来又被用作其他目的
- 变量的多次赋值使其含义模糊
识别机会:
变量被多次赋值用于不同目的:
// Before - temp既是临时计算又是结果
let temp = 2 * (height + width);
console.log(temp);
temp = height * width;
console.log(temp);
// After - 拆分为两个变量
const perimeter = 2 * (height + width);
console.log(perimeter);
const area = height * width;
console.log(area);循环变量复用:
// Before - i用于两个不同的循环
for (let i = 0; i < items.length; i++) {
// ...
}
for (let i = 0; i < orders.length; i++) {
// ...
}
// After - 使用独立的变量或forEach
items.forEach((item, index) => {
// ...
});
orders.forEach((order, index) => {
// ...
});机制:
识别变量的不同用途: 确定变量在哪些地方有不同的含义。
为每个用途创建新变量: 给每个新变量一个清晰的名称。
替换引用: 将原变量的引用替换为对应的新变量。
删除原变量: 确认所有引用都更新后,删除原变量。
测试: 确保行为不变。
注意事项:
- 只拆分真正有不同含义的变量
- 简单的累加器或计数器不需要拆分
- 考虑使用
const防止意外重新赋值
2.3 字段改名(Rename Field)
字段改名是给数据字段一个更清晰、更准确的名称。
动机:
- 字段名不能清晰表达其含义
- 字段名与实际内容不符
- 团队约定了新的命名规范
识别机会:
模糊的名称: data、info、temp等通用名称。
缩写或不一致的命名: custNm vs customerName。
误导性的名称: 名称暗示的内容与实际存储的不符。
机制:
确定新名称: 选择一个能清晰表达字段含义的名称。
查找所有引用: 找出所有使用该字段的地方。
替换引用: 将所有引用改为新名称。
测试: 确保行为不变。
示例:
// Before
const org = {
nm: 'Acme Corp',
loc: 'Springfield'
};
// After
const organization = {
name: 'Acme Corp',
location: 'Springfield'
};工具支持:
- IDE的重命名功能可以安全地重命名字段
- TypeScript的类型检查可以帮助发现遗漏的引用
- ESLint可以强制执行命名规范
2.4 以查询取代派生变量(Replace Derived Variable with Query)
以查询取代派生变量是将可以通过其他数据计算得出的变量替换为查询函数。
动机:
- 派生变量可能与源数据不同步
- 多个派生变量增加了维护复杂度
- 计算逻辑可能变化,集中在一处更容易维护
识别机会:
变量可以通过其他变量计算:
// Before
class Order {
constructor(items) {
this._items = items;
this._total = items.reduce((sum, item) => sum + item.price, 0); // 派生
}
addItem(item) {
this._items.push(item);
this._total += item.price; // 需要同步更新
}
}
// After
class Order {
constructor(items) {
this._items = items;
}
get total() {
return this._items.reduce((sum, item) => sum + item.price, 0);
}
addItem(item) {
this._items.push(item);
// 不需要更新total,查询会自动计算
}
}变量需要手动同步: 每次源数据变化都需要更新派生变量。
机制:
识别派生关系: 确定哪些变量可以从其他变量计算得出。
创建查询函数: 将计算逻辑提取为getter或函数。
替换引用: 将所有对派生变量的引用替换为查询调用。
删除派生变量: 确认所有引用都更新后,删除派生变量。
测试: 确保行为不变。
性能考虑:
- 如果计算昂贵,可以考虑缓存(memoization)
- 在现代JS引擎中,简单的计算开销很小
- 权衡计算的频率和成本
优点:
- 消除了同步问题
- 源数据变化时自动更新
- 计算逻辑集中在一处
2.5 将引用对象改为值对象(Change Reference to Value)
将引用对象改为值对象是将通过引用来共享的对象改为通过值来比较和复制的对象。
动机:
- 引用对象的状态变化可能导致意外的副作用
- 值对象更容易推理和测试
- 值对象天然线程安全(在并发环境中)
引用对象 vs 值对象:
| 特性 | 引用对象 | 值对象 |
|---|---|---|
| 身份 | 有唯一身份 | 无身份,由值定义 |
| 相等性 | 引用相等 | 值相等 |
| 可变性 | 通常可变 | 通常不可变 |
| 共享 | 单实例共享 | 每个使用者有自己的副本 |
| 示例 | User、Account | Money、Date、Color |
机制:
确认对象适合作为值对象: 对象应该没有独立的生命周期。
实现值相等: 实现equals方法或自定义比较逻辑。
确保不可变性: 移除setter,使对象不可变。
更新使用方式: 从共享引用改为创建副本。
测试: 确保行为不变。
示例:
// Before - 引用对象
class Money {
constructor(amount, currency) {
this.amount = amount;
this.currency = currency;
}
}
const price1 = new Money(100, 'USD');
const price2 = price1; // 共享引用
price1.amount = 200; // price2也被影响
// After - 值对象
class Money {
constructor(amount, currency) {
Object.freeze(this);
this.amount = amount;
this.currency = currency;
}
equals(other) {
return other instanceof Money &&
this.amount === other.amount &&
this.currency === other.currency;
}
add(other) {
if (this.currency !== other.currency) {
throw new Error('Cannot add different currencies');
}
return new Money(this.amount + other.amount, this.currency);
}
}
const price1 = new Money(100, 'USD');
const price2 = new Money(100, 'USD');
console.log(price1.equals(price2)); // true适用场景:
- 表示度量值(金钱、距离、温度等)
- 日期和时间
- 颜色、坐标等简单概念
- DTO(数据传输对象)
2.6 将值对象改为引用对象(Change Value to Reference)
将值对象改为引用对象是将每个使用者有自己的副本的对象改为共享单实例的对象。
动机:
- 多个副本浪费内存
- 需要同步多个副本的状态
- 对象有独立的生命周期和身份
机制:
创建仓库/注册表: 为对象创建一个中央存储。
实现获取逻辑: 从仓库获取对象而非创建新实例。
确保一致性: 所有使用者获取的是同一个实例。
测试: 确保行为不变。
示例:
// Before - 值对象,每个客户有自己的副本
class Customer {
constructor(id, name) {
this._id = id;
this._name = name;
}
}
// 订单中创建客户副本
const order1 = { customer: new Customer('cust1', 'John') };
const order2 = { customer: new Customer('cust1', 'John') }; // 重复
// After - 引用对象,共享单实例
class CustomerRegistry {
#customers = new Map();
register(customer) {
this.#customers.set(customer.id, customer);
}
get(id) {
return this.#customers.get(id);
}
}
const registry = new CustomerRegistry();
registry.register(new Customer('cust1', 'John'));
const order1 = { customer: registry.get('cust1') };
const order2 = { customer: registry.get('cust1') }; // 同一实例适用场景:
- 有明确身份的实体(用户、账户等)
- 需要全局一致性的数据
- 大型对象,复制成本高
2.7 可变数据的问题
可变数据是导致bug的主要来源之一,特别是在并发和异步环境中。
问题:
意外的副作用: 函数修改了传入的参数,影响调用者。
竞态条件: 多个代码路径同时修改同一数据。
难以推理: 数据可能在任何时候被改变。
测试困难: 测试之间可能相互影响。
解决方案:
使用const: 防止变量重新赋值。
返回副本: 函数返回新对象而非修改原对象。
使用不可变库: Immutable.js、immer等。
冻结对象: Object.freeze()防止修改。
示例:
// 可变数据的问题
const config = { theme: 'light', lang: 'en' };
function updateTheme(newConfig) {
newConfig.theme = 'dark'; // 修改了传入的对象
return newConfig;
}
const updated = updateTheme(config);
console.log(config.theme); // 'dark' - 意外被修改!
// 不可变数据的解决方案
function updateThemeImmutable(config) {
return { ...config, theme: 'dark' }; // 返回新对象
}
const config2 = { theme: 'light', lang: 'en' };
const updated2 = updateThemeImmutable(config2);
console.log(config2.theme); // 'light' - 未被修改
console.log(updated2.theme); // 'dark'React中的不可变状态:
// React要求不可变状态更新
// 错误 - 直接修改state
state.items.push(newItem);
setState(state);
// 正确 - 创建新状态
setState({
...state,
items: [...state.items, newItem]
});三、程序员的视角
3.1 在实际项目中的应用
场景一:电商系统的价格计算
在一个电商系统中,订单总价最初是存储在数据库中的派生变量。每当添加或删除商品时,都需要更新总价。这导致了多次不一致的问题。
我们将其改为查询:
class Order {
get total() {
return this.items.reduce((sum, item) => sum + item.subtotal, 0);
}
}问题解决,总价永远与商品列表保持一致。
场景二:用户配置的拆分
一个settings变量既存储UI配置又存储业务规则,导致混淆。我们将其拆分:
const uiSettings = { theme: 'dark', fontSize: 14 };
const businessRules = { maxItems: 100, discountRate: 0.1 };场景三:货币的值对象化
将Money从引用对象改为值对象后,货币计算变得安全可靠:
const price1 = new Money(100, 'USD');
const price2 = new Money(50, 'EUR');
const total = convertAndAdd(price1, price2);3.2 常见陷阱与误区
陷阱一:过度不可变
将所有数据都设为不可变,导致性能问题和代码复杂化。
教训: 只对共享数据和状态使用不可变性。
陷阱二:忽视深拷贝
使用展开运算符只进行浅拷贝,嵌套对象仍然共享引用。
教训: 需要时使用深拷贝库或结构化克隆。
陷阱三:派生变量的性能担忧
担心查询的性能而保留派生变量,但实际上计算开销很小。
教训: 先测量再优化,不要过早优化。
3.3 与其他层级的联系
与状态管理的联系:
- Redux要求不可变状态更新
- React的useState基于不可变性
- Vue的响应式系统处理可变性
与数据库的联系:
- 规范化数据库设计类似以查询取代派生变量
- 物化视图是派生变量的数据库版本
与API设计的联系:
- REST API中的资源是引用对象
- GraphQL中的值类型是值对象
四、代码实战
4.1 示例一:完整的数据重组
// Before - 混乱的数据组织
const userData = {
n: 'John',
e: 'john@example.com',
o: [
{ p: 10, q: 2 },
{ p: 20, q: 1 }
],
t: 40 // 派生的总价
};
function addToOrder(user, price, quantity) {
user.o.push({ p: price, q: quantity });
user.t += price * quantity; // 需要手动同步
}
// After - 清晰的数据组织
class OrderItem {
constructor(price, quantity) {
Object.freeze(this);
this.price = price;
this.quantity = quantity;
}
get subtotal() {
return this.price * this.quantity;
}
}
class User {
constructor(name, email) {
Object.freeze(this);
this.name = name;
this.email = email;
this.orders = [];
}
addItem(price, quantity) {
const newItem = new OrderItem(price, quantity);
return new UserWithOrders(this, [...this.orders, newItem]);
}
get total() {
return this.orders.reduce((sum, item) => sum + item.subtotal, 0);
}
}
class UserWithOrders extends User {
constructor(baseUser, orders) {
super(baseUser.name, baseUser.email);
Object.freeze(this);
this.orders = orders;
}
}4.2 示例二:不可变状态管理
// 使用immer简化不可变更新
import produce from 'immer';
const initialState = {
users: [],
loading: false,
error: null
};
const reducer = (state, action) => produce(state, draft => {
switch (action.type) {
case 'FETCH_START':
draft.loading = true;
draft.error = null;
break;
case 'FETCH_SUCCESS':
draft.users = action.payload;
draft.loading = false;
break;
case 'FETCH_ERROR':
draft.error = action.payload;
draft.loading = false;
break;
}
});五、深入思考
5.1 历史背景
值对象的概念源于领域驱动设计(DDD)。Eric Evans在《Domain-Driven Design》中系统地阐述了值对象和实体对象的区别。
不可变数据的历史可以追溯到函数式编程语言(如Haskell、Erlang)。近年来,随着React和Redux的流行,不可变数据在主流开发中得到广泛应用。
5.2 前沿方向
结构化共享:
- Immutable.js使用持久化数据结构
- 减少不可变更新的内存开销
语言支持:
- TypeScript的readonly修饰符
- JavaScript的Record Tuple提案
- Rust的所有权模型
响应式编程:
- RxJS处理数据流
- Signals和细粒度响应性
5.3 延伸阅读
- 《Domain-Driven Design》by Eric Evans - 值对象和实体的详细讨论
- 《Immutable Data Structures for JavaScript》- 不可变数据的实践指南
- Redux文档中的不可变更新模式
六、实验笔记
实验 1:识别和拆分多用途变量
目标: 在项目中找到承担多重责任的变量并拆分。
步骤:
扫描代码寻找多次赋值的变量
分析变量的不同用途
拆分为独立变量
验证功能正常
记录:
- 发现的变量:
- 拆分方案:
- 改善效果:
实验 2:将派生变量改为查询
目标: 识别派生变量并改为查询函数。
步骤:
找出可以通过其他数据计算的变量
创建查询函数
替换引用
删除派生变量
记录:
- 派生变量列表:
- 查询函数实现:
- 同步问题的解决:
七、本章小结
本章介绍了重新组织数据的各种技术。
拆分变量将承担多重责任的变量拆分为独立的变量,使每个变量的含义更清晰。字段改名给数据更准确的名称,提高可读性。
以查询取代派生变量消除了冗余状态和同步问题,确保数据的一致性。这是处理计算数据的首选方式。
引用对象和值对象各有适用场景。值对象适合表示没有独立身份的概念(如金钱、日期),引用对象适合有身份的实体(如用户、账户)。
不可变数据是现代开发的重要趋势。它可以防止意外的副作用,使代码更容易推理和测试。在React等框架中,不可变更新是必须的。
良好的数据组织是高质量代码的基础。通过应用本章的技术,你可以使数据更清晰、更可靠、更易维护。
八、习题
理解题: 解释值对象和引用对象的区别。各适用于什么场景?
实践题: 给定以下代码,指出需要拆分变量的地方:
let result = calculateArea(shape);
displayResult(result);
result = calculatePerimeter(shape);
displayResult(result);设计题: 为一个购物车系统设计数据结构,要求正确处理派生数据(总价、总数量)。
分析题: 为什么不可变数据可以减少bug?它有什么代价?
综合题: 重构以下代码,应用本章学到的技术:
const state = {
items: [],
total: 0,
count: 0
};
function addItem(state, item) {
state.items.push(item);
state.total += item.price;
state.count += 1;
return state;
}讨论题: "所有数据都应该是不可变的",你同意这个观点吗?为什么?
挑战题: 实现一个不可变的Map数据结构,支持set、get、delete操作,每次返回新实例。