前端知库

SOLID 原则是什么?开闭原则在前端里怎样体现?

SOLID 是五条面向对象设计原则:单一职责、开闭、里氏替换、接口隔离、依赖倒置。前端里最常用的是开闭原则,新增功能靠注册和组合,而不是修改已有代码,比如表单字段注册表、插件机制、组件插槽。文中用一个字段注册表演示,并讲什么时候不必套用。

参考答案

简答

一个表单渲染函数里写着 if (type === 'text') ... else if (type === 'select') ...,每次产品要加一种字段,都得打开这个函数改一遍,改着改着就把旧字段改坏了。SOLID 讨论的就是怎样组织代码,让改动少、影响小。它是五条原则的首字母:单一职责、开闭、里氏替换、接口隔离、依赖倒置。

其中开闭原则说的是:对扩展开放,对修改关闭。意思是加新功能时,尽量通过新增代码来完成,而不是改已经稳定的代码。上面的表单就可以改成一张注册表,每种字段一个渲染函数,新增字段只要注册一项,渲染函数本身不动。

前端里到处是这个思路。组件留出插槽或者 children,使用方往里放内容,不用改组件源码;webpack、Vite、Babel 都靠插件扩展;请求库用拦截器加功能。其他几条也有对应:一个组件只做一件事是单一职责,组件通过 props 接收依赖而不是直接引用全局单例,接近依赖倒置。

要注意,这些原则是用来降低改动成本的,不是越抽象越好。只有两三种情况、以后也不太会变时,直接写 if-else 更清楚;为了“符合原则”提前加一层层抽象,反而让代码更难读。

用注册表实现开闭

下面的渲染函数不认识任何具体字段,它只负责从注册表里查出渲染函数并调用。

JavaScript
// 对扩展开放:新增字段类型只往表里注册,不改 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,组件越改越复杂。改成留出插槽,使用方自己决定往里放什么:

JSX
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、需求本身变了,该改就改。而且要做到开闭,前提是先找准哪里会变化,抽象放错了地方,扩展起来照样要改代码。

策略模式和开闭原则有什么关系?

策略模式就是实现开闭原则的常见手段。把每种算法写成独立的策略,用一张表按名字选择,新增算法只加一项,上面的字段注册表本质上就是策略模式。具体例子见《策略模式和代理模式分别解决什么问题?前端怎样使用?》。