Maui 实践:JavaScript 动态生成集合属性的 get/set 代理
原创 夏群林 2025.11.6
一、背景
在我的数独项目的 SudokuFound 类中,需要管理 8 个集合属性,每个集合都需要:
- 「读」:返回副本(避免外部直接修改内部数据);
- 「写」:校验数组类型 + 检测变化 + 触发事件;
- 「统一逻辑」:所有集合的 get/set 行为完全一致。
如果手动为每个集合写 get/set,会出现 3 个问题:
- 代码冗余:8 个集合需要写 8 组重复代码,后续新增集合还要手动补;
- 维护成本高:修改逻辑(比如加校验、改事件)需要改 8 个地方,容易漏改;
- 易出错:手动写代码可能出现语法错误(比如副本创建遗漏、变化检测不一致)。
二、创意
利用 ES6 的 static 代码块(类加载时执行一次)+ Object.defineProperty,自动为所有「合法集合属性」生成统一逻辑的 get/set 代理,完美解决上述痛点。这是一个 静态代码块 + 动态代理 的思路:
- 「筛选合法属性」:自动识别数据载体(如
JsonFound)中所有数组类型的属性(即需要管理的集合); - 「动态生成代理」:为每个合法集合自动生成 get/set 方法,内置「副本返回」「类型校验」「变化检测」「事件触发」逻辑;
- 「自动化适配」:后续新增/删除集合属性,无需修改代理逻辑,自动适配。
三、代码
我把这部分代码抽出来,可作为模板直接复用。
- 数据载体类,保持纯数据结构,便于前后端通讯
// 纯数据载体:定义需要管理的集合属性(数组类型)
class DataCarrier {constructor() {this.basicProp1 = "默认值"; // 基础属性(非集合)this.basicProp2 = 0;// 待管理的集合属性(数组类型)this.collection1 = [];this.collection2 = [];this.collection3 = [];// 后续新增集合,只需在这里加一行}
}
- 带集合属性变化通知功能的轻量包装类,动态生成集合属性代理
class ObservableDataWrapper {constructor() {this.#data = new DataCarrier();// 筛选合法集合属性:仅保留数组类型的属性名this.#validCollectionNames = Object.keys(this.#data).filter(key => Array.isArray(this.#data[key]));}// 私有变量#data; // 内部数据载体实例#validCollectionNames; // 合法集合名称列表(自动筛选)#isEqual; // 深比较工具(用于变化检测)#triggerChange; // 事件触发方法(统一触发布局/UI更新)// 核心:静态代码块(类加载时动态生成 get/set)static {// 步骤1:筛选 DataCarrier 中所有数组类型的属性(合法集合)const validCollections = Object.keys(new DataCarrier()).filter(key => Array.isArray(new DataCarrier()[key]));// 步骤2:为每个合法集合动态生成 get/set 代理validCollections.forEach(collectionName => {Object.defineProperty(this.prototype, collectionName, {// get 方法:返回副本,避免外部直接修改内部数据get() {return [...this.#data[collectionName]];},// set 方法:统一逻辑(校验+变化检测+更新+事件)set(newValue) {// 1. 类型校验:必须是数组if (!Array.isArray(newValue)) return;// 2. 变化检测:深比较新旧数据,无变化则跳过if (this.#isEqual(newValue, this.#data[collectionName])) return;// 3. 安全更新:存储副本,避免外部引用篡改this.#data[collectionName] = [...newValue];// 4. 统一事件:触发变化通知(UI/其他模块监听)this.#triggerChange();},enumerable: true, // 支持 for...in 遍历configurable: true // 允许后续扩展(如重写)});});}// 辅助工具:深比较(简化版,可替换为 lodash.isEqual)#isEqual(a, b) {if (a === b) return true;if (a.length !== b.length) return false;return a.every((item, idx) => item === b[idx]); // 复杂对象需扩展}// 辅助工具:触发事件(统一对外通知)#triggerChange() {// 实际项目中可替换为事件总线。比如,在我的项目: GameUtils.publishEvent。console.log(`集合变化:${JSON.stringify(this.#data)}`);}
}
四、价值
-
极致精简代码。8 个集合只需写 1 套逻辑,代码量减少 87.5%。新增集合时,仅需在
DataCarrier中加一行数组定义,无需修改ObservableDataWrapper。 -
逻辑绝对一致。所有集合的 get/set 逻辑完全统一,避免手动写代码导致的差异化 bug。如某个集合漏写副本、某个集合未加校验。
-
内置数据安全。get 自动返回副本,外部无法通过
manager.collection1.push(xxx)直接修改内部数据。set 自动存储副本,外部传入的数组引用变化,不会影响内部数据。类型校验 + 变化检测,避免非法数据写入,减少无效事件触发。 -
低成本,高复用。外部使用方式,如
manager.collection1读、manager.collection1 = [1,2,3]写,和普通属性完全一致,无需学习新 API。「多集合属性管理」场景,直接复用本模板即可。
五、避坑
-
合法属性筛选要精准。确保
filter(key => Array.isArray(this.#data[key]))只筛选需要管理的集合,避免把基础属性(如字符串、数字)误判为集合。 -
深比较需适配复杂对象。若集合元素是复杂对象,需扩展
#isEqual方法,比较对象的核心属性(如x/y、first/last),避免“引用不同但内容相同”被误判为变化。 -
兼容外部操作逻辑。动态生成的 get/set 是“代理”,是在外部赋值时触发 set 逻辑,无需额外调用方法。如我的项目中,
GameStates.SudokuFound.InferenceChain = inference直接生效。 -
调试技巧:若集合操作异常,可在 static 代码块中加日志,确认合法集合是否正确筛选:
-
static {const validCollections = Object.keys(new DataCarrier()).filter(key => Array.isArray(new DataCarrier()[key]));console.log("合法集合:", validCollections); // 调试用// ... 后续生成代理 }
六、适用
这个技巧特别适合以下场景:
- 业务类需要管理 多个结构一致的集合属性;
- 要求所有集合的 读/写行为统一;
- 希望 减少冗余代码,降低后续维护成本,特别是频繁新增/修改集合的情形。
七、效果
在我的数独项目中,该技巧实现了:
- 8 个集合属性仅用 20 行代码完成 get/set 代理,后续新增集合无需修改;
- 外部操作逻辑不变,兼容旧代码;
- 数据安全问题(如外部篡改内部集合)被彻底解决;
- 事件触发统一,UI 模块只需监听
sudoku-found-changed即可感知所有集合变化。
一次编写,多处可用。这个实践,希望对同仁有所助益。