SOLID 原则是什么?开闭原则在前端里怎样体现?
SOLID 是五条面向对象设计原则:单一职责、开闭、里氏替换、接口隔离、依赖倒置。前端里最常用的是开闭原则,新增功能靠注册和组合,而不是修改已有代码,比如表单字段注册表、插件机制、组件插槽。文中用一个字段注册表演示,并讲什么时候不必套用。
简答
一个表单渲染函数里写着
if (type === 'text') ... else if (type === 'select') ...,每次产品要加一种字段,都得打开这个函数改一遍,改着改着就把旧字段改坏了。SOLID 讨论的就是怎样组织代码,让改动少、影响小。它是五条原则的首字母:单一职责、开闭、里氏替换、接口隔离、依赖倒置。其中开闭原则说的是:对扩展开放,对修改关闭。意思是加新功能时,尽量通过新增代码来完成,而不是改已经稳定的代码。上面的表单就可以改成一张注册表,每种字段一个渲染函数,新增字段只要注册一项,渲染函数本身不动。
前端里到处是这个思路。组件留出插槽或者
children,使用方往里放内容,不用改组件源码;webpack、Vite、Babel 都靠插件扩展;请求库用拦截器加功能。其他几条也有对应:一个组件只做一件事是单一职责,组件通过 props 接收依赖而不是直接引用全局单例,接近依赖倒置。要注意,这些原则是用来降低改动成本的,不是越抽象越好。只有两三种情况、以后也不太会变时,直接写 if-else 更清楚;为了“符合原则”提前加一层层抽象,反而让代码更难读。
用注册表实现开闭
下面的渲染函数不认识任何具体字段,它只负责从注册表里查出渲染函数并调用。
// 对扩展开放:新增字段类型只往表里注册,不改 renderField
const renderers = new Map();
function registerField(type, render) {
renderers.set(type, render);
}
function renderField(field) {
const render = renderers.get(field.type);
if (!render) return `[未知字段 ${field.type}]`;
return render(field);
}
registerField('text', (field) => `<input name="${field.name}">`);
registerField('select', (field) => `<select name="${field.name}">${field.options.length} 项</select>`);
console.log(renderField({ type: 'text', name: 'title' })); // <input name="title">
console.log(renderField({ type: 'date', name: 'deadline' })); // [未知字段 date]
// 后来需求要日期字段:新增一处注册,renderField 一行没动
registerField('date', (field) => `<input type="date" name="${field.name}">`);
console.log(renderField({ type: 'date', name: 'deadline' })); // <input type="date" name="deadline">
第一次渲染 date 时还没有注册,所以返回“未知字段”。注册以后再渲染就正常了,而 renderField 的代码完全没改。
这样做的好处是什么?已经测过、上线的 renderField 不用再动,新增字段的人只需要关心自己那一个渲染函数,不会误伤别的字段。低代码平台的物料注册、表单引擎的自定义控件,用的都是这个思路。
组件里的开闭原则
组件同样可以对扩展开放。比如一个卡片组件,如果标题、操作按钮的样式都靠 props 开关控制,每多一种需求就要多一个 props,组件越改越复杂。改成留出插槽,使用方自己决定往里放什么:
function Card({ title, actions, children }) {
return (
<section className="card">
<header>
<h2>{title}</h2>
{actions}
</header>
{children}
</section>
);
}
<Card title="订单" actions={<button>导出</button>}>
<OrderTable />
</Card>;
React 里通过 children 和接收 JSX 的 props 实现,Vue 里用 <slot> 和具名插槽。组件提供结构和行为,内容由外部填充,所以新需求通常不用改组件本身。
其他四条在前端怎样理解
单一职责是说一个模块只有一个改变的理由。比如一个组件既请求接口、又处理数据、又负责展示,那接口变了、展示变了都要改它。把请求和数据处理抽成 Hook 或组合式函数,组件只管展示,改动就分开了。
里氏替换是说子类型要能替换父类型,调用方不用知道换了。前端里更常见的形式是:几个实现遵守同一个约定,比如本地存储和远程存储都提供 get、set,调用方随便换哪个都能正常工作。如果某个实现偷偷改变了返回值的含义,调用方就会出错。
接口隔离是说不要强迫使用方依赖它用不到的东西。组件 props 也是一样,一个按钮组件要 30 个 props,大多数使用方只用两三个,可以拆成几个更小的组件。
依赖倒置是说高层逻辑依赖抽象,而不是依赖具体实现。比如业务代码接收一个 request 函数作为参数,而不是直接 import 某个请求实例,测试时就可以传入假的 request。
常见追问
开闭原则是说代码永远不能改吗?
不是。它针对的是“新增同类功能时”应该少改已有代码。发现 bug、需求本身变了,该改就改。而且要做到开闭,前提是先找准哪里会变化,抽象放错了地方,扩展起来照样要改代码。
策略模式和开闭原则有什么关系?
策略模式就是实现开闭原则的常见手段。把每种算法写成独立的策略,用一张表按名字选择,新增算法只加一项,上面的字段注册表本质上就是策略模式。具体例子见《策略模式和代理模式分别解决什么问题?前端怎样使用?》。