结构型模式:适配器 / 外观 / 组合 / 享元
结构型模式关心如何把类和对象组合成更大的结构。除了已经单独成篇的代理和装饰器,这里是另外四个常用的。
一、适配器模式(Adapter)
把一个接口转换成使用方期望的另一个接口,让原本不兼容的东西能协同工作。
识别信号:你想用的库/接口,形状和你的代码要求的不一样,而你不能(或不想)改任何一方。
// 现有的图表库要求的数据格式
// [{ name: '北京', value: 100 }, ...]
// 后端返回的格式
const apiData = { 北京: 100, 上海: 200, 广州: 150 };
// 适配器:只负责形状转换,不含业务逻辑
const toChartData = obj =>
Object.entries(obj).map(([name, value]) => ({ name, value }));
renderChart(toChartData(apiData));
更典型的是统一多个第三方 SDK 的差异:
// 三家地图 SDK 的 API 各不相同,业务代码不该关心这些
const googleMapAdapter = {
show: () => googleMap.display(),
setCenter: (lng, lat) => googleMap.setCenter({ lng, lat }),
};
const baiduMapAdapter = {
show: () => baiduMap.render(),
setCenter: (lng, lat) => baiduMap.centerAndZoom(new BMap.Point(lng, lat)),
};
function renderMap(map) { // 业务代码只依赖 { show, setCenter } 这个抽象
map.show();
map.setCenter(116.4, 39.9);
}
适配器的常见落地位置
- API 层的数据转换(后端字段名与前端模型不一致时,在请求层统一转换,别让
user_name这种蛇形命名渗透到组件里); - 旧接口兼容:接口升级后用适配器包住老代码,避免一次性改遍全项目;
- 跨端差异抹平:小程序 / H5 / RN 的存储、路由、请求 API 封装成同一套。
与代理、装饰器的区别:适配器改变接口,代理和装饰器保持接口不变。
二、外观模式(Facade)
为一组复杂的子系统调用提供一个统一的、简化的高层接口。
识别信号:调用方要按固定顺序调一长串方法,且必须知道很多内部细节。
// ❌ 每个页面都要重复这一套流程
player.load(url);
player.setVolume(0.8);
player.setSubtitle(subUrl);
player.play();
tracker.report('play_start');
// ✅ 外观:把流程封装成一个方法
class VideoPlayerFacade {
constructor(player, tracker) {
this.player = player;
this.tracker = tracker;
}
playVideo(url, { volume = 0.8, subtitle } = {}) {
this.player.load(url);
this.player.setVolume(volume);
if (subtitle) this.player.setSubtitle(subtitle);
this.player.play();
this.tracker.report('play_start');
}
}
前端里最常见的外观就是封装的 request 函数:
// 内部做了:baseURL 拼接、token 注入、超时控制、错误码统一处理、loading、重试
export const request = (url, options) => { /* ... */ };
调用方只需要 request('/api/user'),完全不必知道里面有多少层拦截器。
外观不应该"吞掉"底层能力
好的外观是可穿透的:提供简化入口的同时,仍允许高级用户直接访问底层对象(axios 既提供 axios.get() 也暴露 axios.request() 和拦截器)。只留一个"傻瓜接口"会让特殊场景无路可走,最终逼出一堆参数开关。
外观 vs 适配器:外观是简化(多变一),适配器是转换(形状不一致)。
三、组合模式(Composite)
把对象组合成树形结构,使得使用方可以用一致的方式处理"单个对象"和"对象组合"。
核心是叶子节点和容器节点实现同一个接口,因此可以递归处理,调用方不需要判断当前处理的是哪一种。
// 统一接口:都有 execute()
class File {
constructor(name) { this.name = name; }
execute() { console.log('扫描文件:', this.name); }
}
class Folder {
constructor(name) { this.name = name; this.children = []; }
add(child) { this.children.push(child); return this; }
execute() {
console.log('进入目录:', this.name);
this.children.forEach(child => child.execute()); // 递归,不关心子节点类型
}
}
const root = new Folder('root')
.add(new File('a.txt'))
.add(new Folder('sub').add(new File('b.txt')));
root.execute();
前端中的组合结构无处不在:DOM 树、虚拟 DOM、菜单/权限树、表单的嵌套校验、组件树本身。
// 递归渲染菜单:叶子和分组用同一个组件处理
function renderMenu(items) {
return items.map(item =>
item.children?.length
? `<li>${item.title}<ul>${renderMenu(item.children)}</ul></li>`
: `<li>${item.title}</li>`
).join('');
}
组合模式的两个注意点
- 必须真正统一接口。如果调用方还要写
if (node instanceof Folder),就说明抽象没做对,模式的价值也就没了。 - 深树要防栈溢出。递归深度过大时改用显式栈的迭代写法:
const stack = [root]; while (stack.length) { const node = stack.pop(); node.visit(); if (node.children) stack.push(...node.children); }
四、享元模式(Flyweight)
通过共享技术,大量复用细粒度对象,减少内存占用。
做法是把对象的状态一分为二:
- 内部状态:可共享、与场景无关(如"男装模特"这个对象本身);
- 外部状态:随场景变化,从外部传入(如具体穿哪件衣服)。
// ❌ 50 件男装 + 50 件女装 = 100 个模特对象
for (let i = 0; i < 50; i++) { const model = new Model('male', `img-m-${i}`); }
// ✅ 只造 2 个模特(内部状态),衣服作为外部状态传入
class Model {
constructor(sex) { this.sex = sex; } // 内部状态:共享
takePhoto(clothes) { // 外部状态:调用时传入
console.log(`${this.sex} 穿 ${clothes}`);
}
}
const modelFactory = (() => {
const cache = new Map();
return sex => {
if (!cache.has(sex)) cache.set(sex, new Model(sex));
return cache.get(sex); // 共享同一个实例
};
})();
modelFactory('male').takePhoto('img-m-1');
modelFactory('male') === modelFactory('male'); // true
前端里的实际应用:
- 对象池:大列表滚动时复用有限的 DOM 节点(虚拟列表的核心思路),Canvas 粒子系统复用粒子对象;
- 字符串/图标常量共享:同一个 SVG symbol 被
<use>引用多次; - 不可变数据的结构共享:Immutable.js 只复制变化路径上的节点,其余部分共享。
不要滥用享元
享元把简单对象拆成了"共享部分 + 外部状态",代码可读性明显下降。只有当对象数量真的达到成千上万、内存成为瓶颈时才值得。先测量,再优化。
下一步 👉 行为型模式
