05-如何提升代码可扩展性
什么是代码的可扩展性
代码的扩展性是指代码的设计和结构能够方便地进行修改和添加新功能,而不会引起大规模的重构或破坏原有的功能。一个具有良好的扩展性的代码可以轻松地适应变化的需求和新的功能需求。
我们通过一个示例来体会下什么是可扩展性。
假设我们有一个多语言网站,我们希望根据用户选择的语言动态显示不同语言的内容,比如在网站首页展示一个欢迎语,如果我们不使用配置的方式,可能实现方式如下:
<!--Vue实现示例-->
<template>
<div>
<h1>
{{language === 'en' ? 'Hello' : '你好' }}
</h1>
<div>
{{language === 'en' ? 'Welcome to our website!' : '欢迎访问我们网站' }}
</div>
</div>
</template>
如果后续需求发生变化,需要支持日语,如果我们采用上述代码实现这个功能,势必会要进行大量的修改,也就是为了扩展某个语种,需要对原有的代码逻辑进行大量的修改。
我们可以使用配置文件来存储不同语言的翻译,而不是将所有翻译硬编码到代码中。
首先,我们创建一个配置文件,比如translations.js,其中包含每种语言的翻译:
const translations = {
en: {
greeting: 'Hello',
message: 'Welcome to our website!'
},
fr: {
greeting: 'Bonjour',
message: 'Bienvenue sur notre site web !'
},
// other language translations...
};
export default translations;
之前欢迎语的实现方式可以改为如下形式:
<!--Vue实现示例-->
<template>
<div>
<h1>
{{translations[language].greeting}}
</h1>
<div>
{{translations[language].message}}
</div>
</div>
</template>
可以看到,如果我们扩展一个新的语言,UI相关的代码逻辑是完全不需要动的,只需要在配置文件中增加不同语言的翻译即可,即语言类型的扩展不会影响原有代码,这就是通过配置提升代码可扩展性的一个示例。
对比下上面两种实现方式,可以很容易看出,没有考虑扩展性的代码实现,在后续需求变化时,会对原有的代码进行大量的修改,而考虑了扩展性的代码则只需要集中在某个位置进行修改,不会影响原有的大部分逻辑。
开闭原则
说到扩展性,不得不谈一个重要的设计原则"开闭原则",开闭原则是面向对象设计中的一个重要原则,它强调软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。这意味着你可以在不影响其他部分的情况下添加新功能,并且可以轻松地更改现有功能以适应新的需求。
具体来说,开放封闭原则包含以下两个方面:
对扩展开放:当需要添加新功能时,应该尽可能地开放类、模块、函数等的扩展点。这意味着您应该尽可能地将类、模块、函数等的行为封装在内部,并提供一些公共的接口来访问它们。
对修改关闭:当需要修改现有功能时,应该尽可能地关闭类、模块、函数等的修改点。这意味着您应该尽可能地将类、模块、函数等的实现封装在内部,并提供一些公共的接口来访问它们。
在我们前端开发中,除了开发一些公共库之外,很少会用到类,我们先以Vue组件封装为例来体会下什么是开闭原则。
假设我们现在开发一个表单标题组件,需求就是在标题的左侧添加一个绿色的竖线,如下图所示。
Vue实现如下
<template>
<div class="form-title">
<div class="title">
{{ title }}
</div>
</div>
</template>
<script>
export default {
name: 'FormTitle',
props: {
title: {
type: String
}
}
};
</script>
<style scoped lang="scss">
.form-title {
.title {
height: 16px;
line-height: 16px;
padding-left: 8px;
border-left: green 4px solid;
font-size: 16px;
}
}
</style>
上述实现并没有考虑扩展性,现在开发中遇到以下两种场景,一个是标题右侧展示一个问号,鼠标移入后展示一段提示信息,另一种是标题右侧有个按钮,点击执行某个动作。
如何实现这两个场景呢,一种是我们继续修改上述FormTitle组件的实现,比如对外暴露一个icon属性,传递了icon则标题右侧展示icon;同时再暴露一个buttonLabel属性,传递button的文案。
虽然这样也可以实现,但是不满足开闭原则,首先这样实现没有扩展性,假如后续标题右侧展示两个button按钮,是无法实现的,并没有提供一个统一的扩展方案,即不满足对扩展开放;同时为了满足需求,对原有的FormTitle代码进行了大量的修改,即不满足对修改关闭。
假如我们在FormTitle组件的标题右侧引入了一个插槽,则可以完美应对各种各样的需求。
<template>
<div class="form-title">
<div class="title">
{{ title }}
<slot></slot>
</div>
</div>
</template>
引入插槽之后,上面两个新需求可以非常容易实现,而不需要修改原有的FormTitle组件。
<template>
<div>
<FormTitle title="表单标题">
<i class="icon-question"></i>
</FormTitle>
<FormTitle title="表单标题">
<button>按钮</button>
</FormTitle>
</div>
</template>
通过这个例子可以看出,通过使用插槽机制,让组件FormTitle扩展性大大增强,为以后的新需求提供了扩展机制,而不必修改FormTitle的实现,我们说这样的设计是符合开闭原则的。
再来看一个纯js的例子,假设我们实现一个表单校验的工具,可以校验某个值是否是合法的手机号、邮箱等。
const formValidate = new FormValidate();
formValidate.validate('mobile', '111111'); //false
formValidate.validate('mobile', '13156678909'); //true
formValidate.validate('email', 'aaa'); //false
formValidate.validate('email', 'test@qq.com'); //true
我们来试着实现这样一个FormValidate类,有个validate方法,根据参数type类型进行正则校验。
class FormValidate {
validate(type, value){
if(type === 'mobile'){
return /^1[3-9]\d{9}$/.test(value)
}else if(type === 'email'){
return /^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$/.test(value)
}else {
return false;
}
}
}
假如我们现在要增加对用户名的校验,如何实现呢?按照上面的实现方法,我们需要修改原有的FormValidate实现。
class FormValidate {
validate(type, value){
if(type === 'username'){
if(!value) {
return false;
}else if(value.length <=30){
return true;
} else{
return false;
}
}
//其他逻辑
}
}
这个实现方式就不符合开闭原则,没有对扩展开放,没有对修改关闭。我们重构下实现方式,增加一个addValidateMethod方法,通过该方法添加自定义的校验方法。
class FormValidate {
validateMethods = {};
validate(type, value){
if(this.validateMethods[type]){
return this.validateMethods[type](value);
}
return false;
}
addValidateMethod(type, validateMethods){
this.validateMethods[type] = validateMethods;
}
}
const formValidate = new FormValidate();
formValidate.addValidateMethod('mobile', (value) => /^1[3-9]\d{9}$/.test(value));
formValidate.addValidateMethod('email', (value) => /^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$/.test(value));
formValidate.addValidateMethod('username', (value) => {
if(!value) {
return false;
}else if(value.length <=30){
return true;
} else{
return false;
}
});
formValidate.validate('mobile', '111111'); //false
formValidate.validate('mobile', '13156678909'); //true
formValidate.validate('email', 'aaa'); //false
formValidate.validate('email', 'test@qq.com'); //true
后续假如想再增加对ip地址的校验,只需要调用addValidateMethod方法添加ip的校验函数即可,无需修改原有FormValidate类,这样的实现方式满足对扩展开放对修改关闭的原则,符合开闭原则。
前端开源库的扩展机制
对于一个开源库来说,扩展性的重要性要比普通业务代码的扩展性重要的多,因为我们在使用第三方库时,通常是不能修改其源码的,也就是修改通道已经关闭,如果扩展性不好,当遇到新需求无法实现时,只能放弃使用该三方库了,所以好的开源库通常都有很好的扩展性,接下来我们选择几个优秀的开源库,来看看他们是怎么提升扩展性的。
axios
axios是一个优秀的网络请求库,它使用简单,包尺寸小且提供了易于扩展的接口。其中拦截器功能让axios扩展性大大增强,axios的拦截器分为两种:请求拦截器和响应拦截器。
// 添加请求拦截器
axios.interceptors.request.use(function (config) {
// 在发送请求之前做些什么
return config;
}, function (error) {
// 对请求错误做些什么
return Promise.reject(error);
});
// 添加响应拦截器
axios.interceptors.response.use(function (response) {
// 2xx 范围内的状态码都会触发该函数。
// 对响应数据做点什么
return response;
}, function (error) {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
return Promise.reject(error);
});
请求拦截器会在发送请求之前执行,比如我们可以在请求拦截器中对接口url进行修改,而响应拦截器会在axios返回响应数据之前执行,比如可以通过响应拦截器进行统一的接口错误处理,通过拦截器,我们可以定制我们的网络请求处理逻辑,以实现业务需求。
多个请求拦截器的执行顺序是后添加执行靠前,多个响应拦截器的执行顺序是先添加先执行,实现代码也并不复杂,我们可以看一下源码。
Axios类有个interceptors属性,其中包含request和response两个属性。
class Axios {
constructor(instanceConfig) {
this.interceptors = {
request: new InterceptorManager(),
response: new InterceptorManager()
};
}
}
request和response两个属性值为InterceptorManager对象,在前面示例中我们看到axios.interceptors.request.use是个方法,这个方法肯定是来自InterceptorManager对象,接下来看下InterceptorManager对象的实现。
class InterceptorManager {
constructor() {
this.handlers = [];
}
use(fulfilled, rejected, options) {
this.handlers.push({
fulfilled,
rejected,
synchronous: options ? options.synchronous : false,
runWhen: options ? options.runWhen : null
});
return this.handlers.length - 1;
}
}
可以看到,当调用InterceptorManager实例的use方法时,会将传递过来的成功处理函数和失败处理函数,放到this.handlers数组中。
当调用axios的request方法时,我们猜想会先遍历axios.interceptors.request中的handlers数组,依次进行请求前拦截处理,然后发送请求,接着再遍历axios.interceptors.response中的handlers数组,依次进行响应前拦截,就像一个链条一下,依次执行。
我们看下axios的request处理方法,为了明白拦截器相关的执行逻辑,对无关代码进行删除和简化。
class Axios {
request(configOrUrl, config) {
// 去除了其他处理逻辑
const requestInterceptorChain = [];
//遍历请求拦截器
this.interceptors.request.forEach((interceptor) => {
//使用了unshift,所以请求拦截器的执行顺序是后添加先执行
requestInterceptorChain.unshift(interceptor.fulfilled, interceptor.rejected);
});
const responseInterceptorChain = [];
//遍历响应拦截器
this.interceptors.response.forEach( (interceptor) => {
responseInterceptorChain.push(interceptor.fulfilled, interceptor.rejected);
});
//创建一个执行链条,将请求任务加入链条
const chain = [dispatchRequest.bind(this), undefined];
//将请求拦截器中的任务加入链条之前
chain.unshift.apply(chain, requestInterceptorChain);
//将响应拦截器的任务加入链条之后
chain.push.apply(chain, responseInterceptorChain);
len = chain.length;
promise = Promise.resolve(config);
//将链条中的任务通过Promise的方式串到一串
while (i < len) {
promise = promise.then(chain[i++], chain[i++]);
}
return promise;
}
}
可以看出,axios中的request方法采用了设计模式中的责任链模式,将请求拦截、网络请求、响应拦截组成一个责任链,然后依次执行。
我们来总结一下axios扩展性的实现方法,首先通过请求和响应拦截器的use方法收集拦截器执行函数,将收集到的拦截器存放到数组中,然后在合适的时机(比如调用request方法时)遍历收集到的拦截器进行调用,通过这种简单的设计,大大增强了axios的扩展性。
koa
koa 是由 Express 原班人马打造的,致力于成为一个更小、更富有表现力、更健壮的 Web 框架。koa 不在内核方法中绑定任何中间件,它仅仅提供了一个轻量优雅的函数库,得益于它良好的扩展性,发展出了完善的中间件生态,为koa的发展提供了源源不断的动力。
koa内核很简单,一个简单的示例如下:
const Koa = require('koa');
const app = new Koa();
app.use(async ctx => {
ctx.body = 'Hello World';
});
app.listen(3000);
用koa实现一个后端项目时,内核并不提供登录校验、跨域配置、鉴权、路由控制、静态服务等功能,全部由中间件提供。
跨域配置:
const cors = require("koa2-cors");
app.use(cors());
静态服务:
const serve = require('koa-static')
app.use(serve(__dirname + '/public/'));
可以看出,koa大部分功能都通过中间件来实现,当要扩展一个功能时,不需要修改koa源码,只要创建一个中间件即可,然后通过use方法将中间件加入队列,当有访问到来时,koa通过遍历收集到的中间件队列,依次执行其中的回调函数。
我们来看下koa的源码,为了更好的理解它的中间件机制,删去了无关代码。
class Koa {
constructor (options) {
this.middleware = []
}
use (fn) {
if (typeof fn !== 'function') throw new TypeError('middleware must be a function!')
this.middleware.push(fn)
return this
}
}
可以看到use方法实现很简单,只是将收集到的回调函数fn放入this.middleware数组中,接下来看看当请求来到后,koa如何执行:
const compose = require('koa-compose')
class Koa {
//请求回调函数
callback () {
//通过compose方法将数组中的中间件转成串行执行的函数
const fn = compose(this.middleware)
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res)
return this.handleRequest(ctx, fn)
}
return handleRequest
}
handleRequest (ctx, fnMiddleware) {
const res = ctx.res
res.statusCode = 404
const onerror = err => ctx.onerror(err)
const handleResponse = () => respond(ctx)
onFinished(res, onerror)
//调用中间件
return fnMiddleware(ctx).then(handleResponse).catch(onerror)
}
}
可以看到,在请求回调中,首先通过koa-compose提供的方法将中间件数组中的方法修改为一个串行执行的结构,然后创建上下文ctx,调用中间件串行后返回的方法。
其中koa-compose的实现很简短,也很有意思,可以看一下其源码:
function compose (middleware) {
if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
for (const fn of middleware) {
if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
}
return function (context, next) {
// last called middleware #
let index = -1
return dispatch(0)
function dispatch (i) {
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
let fn = middleware[i]
if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
} catch (err) {
return Promise.reject(err)
}
}
}
}
通过以上这些实现,我们可以大致了解koa中间件机制的实现方式,即通过use收集中间件方法,放入一个数组中,在请求到来时,将数组中的方法修改为串行执行结构,然后进行调用。
Webpack
Webpack的扩展性主要体现在以下3个方面
- loader:
Webpack在import文件时,默认只能解析js文件,对于其他文件是不认识的,必须通过loader进行转换。通过不同的loader,webpack可以解析各种不同类型的文件,如json、ts、css、less、图片、文件等,即使以后你需要解析一种完全没有出现过的文件类型,也可以通过开发相应的loader来完成构建,而不需要Webpack进行任何更改。
loader就是一个函数,输入为import的文件内容或者前一个loader处理后的结果,经过特定的转换后,将结果返回给Webpack去构建。
//实现一个loader,将import的文件中的foo替换为bar
module.exports = function(source) {
const result = source.replace(/foo/g, 'bar');
return result
};
- plugins:
plugins 可以用于优化 Webpack 的构建过程,例如压缩代码、生成 HTML 文件、压缩图片等。plugins 可以在 Webpack 的构建生命周期中执行,包括编译、打包、优化等。
一个Webpack插件必须对外暴露一个apply方法,Webpack启动后调用插件的apply方法,在apply方法中向Webpack的不同生命周期的钩子函数中注入处理函数,供Webpack在不同的时机进行调用。
class MyPlugin {
constructor(options) {
this.options = options;
}
apply(compiler) {
compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
console.log('MyPlugin is running...');
// 在这里执行自定义的逻辑
callback();
});
}
}
module.exports = MyPlugin;
- 配置文件
通过Webpack配置文件,我们可以进行入口文件、输出文件、加载器、插件等配置,通过这种简单灵活的方式,我们可以轻松定制各式各样的构建需求,大大提升了Webpack的可扩展性。
提升扩展性方法
插件(plugin)和中间件(middleware)
插件和中间件都是用于扩展应用程序功能的技术,但它们的作用和使用方式有所不同。
中间件用于在应用程序的请求和响应过程中进行处理的技术。中间件通常用于处理身份验证、日志记录、缓存、压缩等功能。中间件可以帮助开发人员更好地管理应用程序的请求和响应过程。
插件通常用于扩展应用程序的功能,例如添加新的页面、按钮、表单等。插件通常是开发人员自己编写的,可以在应用程序中使用。插件可以帮助开发人员快速构建应用程序,提高开发效率。
有时这两个概念的区分度感觉并不是那么明显,无论是插件还是中间件目标都是用来扩展基座应用的能力,为了满足新增的业务需求,我们可以通过开发一个新的插件/中间件来实现,而不是去修改基座应用,通过这种机制,增强了基座应用的扩展性。就好比在一个台式机中,电脑主板我们可以称之为基座应用,在主板上提供了各种各样的插槽,有一天想增加系统内存,只需要再插入一个内存条(插件)即可,无需更换主板。
要实现一套支持插件的系统,我们只需要满足以下3个条件(中间件也一样):
- 制定插件API规范
由于后续基座应用要统一调度插件,所以必须要明确插件的API,插件应该提供什么方法,后续基座调用某个方法会传递哪些参数,都需要提前明确好。
就像前面提到的Webpack插件,就必须提供一个apply方法,参数为Webpack的compiler对象。
class MyPlugin {
apply(compiler) {
}
}
- 提供插件注册机制,可以将插件注入到基座应用
有了插件后还必须要把插件注入到基座应用中,常见的方式有类似koa的使用use方法注入,或者类似Webpack通过配置文件注入。
- 基座应用在某些时机下调用插件的API方法
要想插件发挥作用,基座必须在适当的时机去调用插件。
配置文件
通过配置文件驱动服务是增强系统扩展性的一个重要方法,当面对新需求时,只需要修改配置文件即可,而不用去修改基座应用代码,就像我们最开始的关于多语的示例,将翻译内容放入配置文件,后续增加或者删除某个语言,只需要修改配置文件即可,业务代码无需任何变更。
分层
分层可以提升系统的扩展性,因为它可以将系统分解成多个层次,每个层次都有自己的职责和功能。每个层次都可以单独地被修改或扩展而不会影响其他层次。
组件化/模块化
组件化可以提升系统的扩展性,因为它可以将系统分解成多个组件,每个组件都有自己的职责和功能。每个组件都可以单独地被修改或扩展而不会影响其他组件,使用组件化模块化进行前端开发后,再遇到新需求时,只需要针对性的添加、修改、删除部分小组件即可,而不会对整个系统进行大范围的修改,对于提升系统扩展性有很大帮助。
总结
提升系统扩展性能够让我们在面对新需求时,不用去大范围的重构之前的代码,从而提升开发效率、减低出错的可能,提升扩展性的核心是要遵循开闭原则,即对扩展开放,对修改关闭,可以通过插件/中间件机制、配置文件、分层、组件化模块化、设计模式等方式来提升系统扩展性。