目标
- 分享 DI 是如何在 Javascript 中体现的。
- 分享 DI 如何在定制化场景中应用。
什么是DI?
依赖注入是一种软件设计模式,它的核心思想是从外部提供我们的代码所需的依赖项,而不是让我们的代码直接构造和解析依赖项,其目的就是实现关注点分离,使得代码更加模块化、可重用、可扩展和可测试。
看下面这个例子:
const getData = async (url) => {
const response = await fetch(url);
const data = await response.json();
return data;
};
在函数中使用 Fetch 请求数据并返回,虽然从功能上讲是可行的,但是从可维护的代码角度来看,还是有很多不完善的地方:
- 如果之后项目基建发生变化决定使用另一个 HTTP 请求库(Axios)替换 Fetch,我们必须修改整个函数才能使用 Axios。
- Fetch 是浏览器提供的原生 API,假如我们需要在 Node.js 等环境进行功能测试就不可用了。
此时就是依赖注入发挥作用的时候了,我们可以将代码所需的依赖项作为参数传给 getData 函数:
const getData = async (fetch, url) => {
const response = await fetch(url);
const data = await response.json();
return data;
};
(async => {
const resourceData = await getData(window.fetch, "xxxx");
})()
DI的表现形式有哪些?
Javascript 的核心思想是对象和原型,因此我们可以通过函数式或者面向对象的方式进行依赖注入。
函数式
JavaScript 的函数式编程特性(如高阶函数和闭包)使我们能够优雅地实现依赖注入,例如 Array.prototype.map,Array.prototype.filter 和 Array.prototype.reduce 是 Javascript 中内置的一些高阶函数,这些函数将最终结果的控制权交给外部依赖,从而保证自己的可维护性和拓展性。
现在对上面的例子进行改写:
const fetchResource = (httpClient) => (url) =>
httpClient(url)
.then((data) => data.json)
.catch((error) => console.log(error))
fetchResource 函数获取 HTTP 请求实例,并返回一个接受 URL 参数并发起实际请求的函数。
const httpClient = axios.create({
baseURL: "https://mybasepath",
method: "POST",
headers: { "Access-Control-Allow-Origin": "*"}
});
const getData = fetchResource(httpClient);
getData("/resourcepath").then((response) => console.log(response.data));
然后这里使用了 Axios 替换了原生的 Fetch,且无需干预 fetchResoure 内部具体实现。
面向对象
面向对象编程会将一个系统抽象为多个对象的集合,每一个对象代表了这个系统特定的方面,随着应用的复杂度越来越高,对象和对象之间不免会出现依赖关系,比如下面这个例子:
我们有个忍者类,在类内部会实例化一种武器:
// 武士刀类
class Katana {
// 攻击方法
attack() {
return "cut!";
}
}
// 忍者类
class Ninja {
// 实例化一个武士刀武器
weapon: Katana = new Katana();
attack() {
this.weapon.attack();
}
}
在忍者类中实例化了一个武士刀作为武器,即 Ninja 依赖了 Katana ,但是武士的武器肯定不止一种,假如现在想换成一把剑,只能这么改写:
// 剑类
class Shuriken {
attack() {
return "hit!";
}
}
// 忍者类
class Ninja {
// 实例化一把剑作为武器
weapon: Shuriken = new Shuriken();
attack() {
this.weapon.attack();
}
}
理想的状态应该是:**武士不依赖具体的武器,而是武器库里面有什么武器就能用什么武器,**此时武器就应该从外部注入,这也就诞生了两个开发准则:
-
面向接口开发
忍者不依赖具体的实例,而是依赖一个武器接口,所有的武器类都需要 implements 这个接口。
-
依赖注入
忍者实际依赖的武器需要外部注入,不能在类中实例化。
// 武器接口
interface IWeapon {
attack(): string;
}
// 武士刀类基于武器接口实现
class Katana implements IWeapon {
// 攻击方法
attack() {
return "cut!";
}
}
// 剑类基于武器接口实现
class Shuriken implements IWeapon {
attack() {
return "hit!";
}
}
// 忍者类
class Ninja {
// 武器依赖武器接口
weapon: IWeapon;
constructor(weapon: IWeapon) {
this.weapon = weapon;
}
attack() {
this.weapon.attack();
}
}
// 实例化武士刀注入到武士中
const ninja1 = new Ninja(new Katana())
console.log(ninja1.attack()) // cut!
// 实例化剑追到武士中
const ninja2 = new Ninja(new Shuriken())
console.log(ninja2.attack()) // hit!
在更复杂的用例中,手动进行依赖项注入通常无法扩展,并且会带来全新的复杂度,此时可以利用依赖注入容器的强大功能来解决这个问题,比如 TypeDI 和 InversifyJS,但是这两个库都依赖试验性装饰器和元数据,而最新的标准装饰器规范与实验性的装饰器还不兼容,所以暂不推荐使用!!。
如何在定制化场景中应用DI?
目前在 B 端产品中都涉及到定制化开发的功能,比较常见的形式就是在标品中增加耦合性功能,比如下面这个定制化场景:
标品:
定制化功能:
定制化需求有:
- 文件类型增加文档中心文件类型,并支持切换。
- 文件上传新增树形下拉组件,用于支持客户选择系统内部文件。
传统的做法是拉分支改源码,这种方式虽然最快最直接,但是存在以下几个不足:
1、维护成本大,主要成本在于分支的维护、定制化代码与原有逻辑的耦合。
2、DRD 开发成本大,主要成本在于需要理解开发模式、阅读源码。
针对定制化的场景理想的状态是尽量减少定制化代码与有逻辑的耦合,而 DI 就能很好的帮我们解决这个问题——将需要定制化的功能拆解出来,最终以注入的方式接入。
拆分出的文件上传组件:
// 文件类型option初始数据
const originFileTypeOptions = [
{key: 'localFile', label: '本地文件'}
]
interface IExtralFileUploadType {
type: 'documentCenter',
label: '文档中心文件',
uploadFormItem: <DocumentCenter />
}
// 拆出文件上传组件,用于注入到数据集创建表单
const FileUploadComponent = () => {
// 从配置文件中读取定制化内容
const {extralFileUploadTypes} = config;
const fileTypeOptions = extralFileUploadTypes.map(item => ({key: item.type, label: item.label})).concat(originFileTypeOptions);
const fileTypeToUploadComponentMap = useMemo(() => {
// 文件类型对应文件上传组件映射初始数据
const map = new Map([
['localFile', <LocalFileUpload />]
])
extralFileUploadTypes.forEach(item => map.set(item.type, item.uploadFormItem));
return map;
}, []);
return (
<>
{
extralFileUploadTypes.length > 0 ? (
<Form.Item label="文件类型" key="fileType">
<Radio.Group>
{fileTypeOptions.map(item => return <Radio value={ite,.key}>{item.label}</Radio>)}
</Radio.Group>
</Form.Item>
<Form.Item label="文件上传">
{({getFieldValue}) => {
const publishType = getFieldValue('fileType');
return fileTypeToUploadComponentMap.get(publishType);
}}
</Form.Item>
) ? (
<Form.Item label="文件上传">
<LocalFileUpload />
</Form.Item>
)
}
</>
)
}
该组件从外部接受 DRD 定制化的代码,自动进行组装,最终通过 props 的方式注入到表单组件中渲染。
创建表单组件:
interface IProps {
formateFormData: (value: any) => any;
FileUpload: () => React.ReactNode;
}
const DatasetCreateForm = ({formateFormData, FileUpload}) => {
const onSubmit = (value: IFormData) > {
const formdata = formateFormData(value)
// ...
}
return (
<Form>
<Form.Item name="name" label="数据集名称"></Form.Item>
<FileUpload />
</Form>
)
}
整体流程如下图所示:
这样就能做到标品代码和定开代码解藕。
总结
本篇文章主要介绍了 DI 的设计思想以及其在定制化场景中的应用,当然这里只介绍了一种场景,具体的组件拆分、注入还需要根据不同的场景去思考,但是整体设计思想要保持一致——定制化逻辑与标品功能解藕,保证代码的可复用性。