最近我体验了审查一个项目并评估其可扩展性和可维护性的过程。这里有一些不好的实践,一些奇怪的代码片段缺少有意义的注释。对于一个相对较大的(遗留)代码库来说,这并不罕见,对吧?
但是,我一直在发现一些东西。一种在整个代码库和其他一些我查看过的项目中重复出现的模式。它们都可以概括为 **缺乏抽象**。最终,这是导致维护困难的原因。
在面向对象编程中,**抽象**是四个核心原则之一(连同 **封装**、**继承** 和 **多态**)。抽象之所以有价值,有两个主要原因
- 抽象隐藏某些细节,只显示对象的必要特征。它试图减少和提取细节,以便开发人员可以一次专注于几个概念。这种方法提高了代码的可理解性和可维护性。
- 抽象有助于我们减少代码重复。抽象提供了处理横切关注点的方法,并使我们能够避免紧耦合代码。
缺乏抽象不可避免地会导致可维护性问题。
我经常看到同事想要更进一步,编写更易维护的代码,但他们难以确定和实现基本的抽象。因此,在本文中,我将分享一些我用于 Web 世界中最常见的事情的实用抽象:处理远程数据。
需要提及的是,就像 JavaScript 世界中的所有事物一样,有无数种方法和不同的方法来实现类似的概念。我将分享我的方法,但您可以根据自己的需求对其进行升级或调整。或者更好的是 - 改进它并在下面的评论中分享!❤️
API 抽象
我已经有一段时间没有遇到不使用外部 API 来接收和发送数据的项目了。这通常是我定义的第一个和最基本的抽象之一。我尝试在那里存储尽可能多的与 API 相关的配置和设置,例如
- API 基本 URL
- 请求头
- 全局错误处理逻辑
const API = { /** * Simple service for generating different HTTP codes. Useful for * testing how your own scripts deal with varying responses. */ url: 'http://httpstat.us/', /** * fetch() will only reject a promise if the user is offline, * or some unlikely networking error occurs, such a DNS lookup failure. * However, there is a simple `ok` flag that indicates * whether an HTTP response's status code is in the successful range. */ _handleError(_res) { return _res.ok ? _res : Promise.reject(_res.statusText); }, /** * Get abstraction. * @return {Promise} */ get(_endpoint) { return window.fetch(this.url + _endpoint, { method: 'GET', headers: new Headers({ 'Accept': 'application/json' }) }) .then(this._handleError) .catch( error => { throw new Error(error) }); }, /** * Post abstraction. * @return {Promise} */ post(_endpoint, _body) { return window.fetch(this.url + _endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: _body, }) .then(this._handleError) .catch( error => { throw new Error(error) }); } };
在这个模块中,我们有两个公共方法,get()
和 post()
,它们都返回一个 Promise。在所有需要处理远程数据的地方,我们使用我们的 API 模块抽象 - API.get()
或 API.post()
,而不是直接通过 window.fetch()
调用 Fetch API。
因此,Fetch API **不与我们的代码紧密耦合**。
假设在以后我们阅读了 Zell Liew 关于使用 Fetch 的全面 总结,并且我们意识到我们的错误处理并不是真正的高级,比如它可以是。在进一步处理我们的逻辑之前,我们希望检查内容类型。没问题。我们只修改我们的 API
模块,我们到处使用的公共方法 API.get()
和 API.post()
工作得很好。
const API = {
/* ... */
/**
* Check whether the content type is correct before you process it further.
*/
_handleContentType(_response) {
const contentType = _response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return _response.json();
}
return Promise.reject('Oops, we haven\'t got JSON!');
},
get(_endpoint) {
return window.fetch(this.url + _endpoint, {
method: 'GET',
headers: new Headers({
'Accept': 'application/json'
})
})
.then(this._handleError)
.then(this._handleContentType)
.catch( error => { throw new Error(error) })
},
post(_endpoint, _body) {
return window.fetch(this.url + _endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: _body
})
.then(this._handleError)
.then(this._handleContentType)
.catch( error => { throw new Error(error) })
}
};
假设我们决定切换到 zlFetch,Zell 引入的抽象化响应处理的库(这样你就可以跳过并处理你的数据和错误,而不用担心响应)。**只要我们的公共方法返回一个 Promise,就没有问题:**
import zlFetch from 'zl-fetch';
const API = {
/* ... */
/**
* Get abstraction.
* @return {Promise}
*/
get(_endpoint) {
return zlFetch(this.url + _endpoint, {
method: 'GET'
})
.catch( error => { throw new Error(error) })
},
/**
* Post abstraction.
* @return {Promise}
*/
post(_endpoint, _body) {
return zlFetch(this.url + _endpoint, {
method: 'post',
body: _body
})
.catch( error => { throw new Error(error) });
}
};
假设由于任何原因,我们决定在将来切换到 jQuery Ajax 来处理远程数据。再次不是什么大问题,只要我们的公共方法返回一个 Promise。从 jQuery 1.5 开始,$.ajax()
返回的 jqXHR 对象实现了 Promise 接口,赋予它们 Promise 的所有属性、方法和行为。
const API = {
/* ... */
/**
* Get abstraction.
* @return {Promise}
*/
get(_endpoint) {
return $.ajax({
method: 'GET',
url: this.url + _endpoint
});
},
/**
* Post abstraction.
* @return {Promise}
*/
post(_endpoint, _body) {
return $.ajax({
method: 'POST',
url: this.url + _endpoint,
data: _body
});
}
};
但即使 jQuery 的 $.ajax()
没有返回 Promise,你也可以始终 将任何内容包装在一个新的 Promise() 中。一切顺利。可维护性++!
现在让我们抽象化接收和本地存储数据。
数据仓库
假设我们需要获取当前天气。API 返回给我们温度、体感温度、风速(m/s)、气压(hPa)和湿度(%)。一个常见的模式是,为了使 JSON 响应尽可能精简,属性被压缩到第一个字母。所以我们从服务器接收到的内容如下
{
"t": 30,
"f": 32,
"w": 6.7,
"p": 1012,
"h": 38
}
我们可以继续在需要的地方使用 API.get('weather').t
和 API.get('weather').w
,但这在语义上看起来不太好。我不喜欢这种一个字母且上下文不明显的命名方式。
此外,假设我们不使用湿度(h
)和体感温度(f
)。我们不需要它们。实际上,服务器可能会返回很多其他信息,但我们可能只想使用几个参数。不限制我们的天气模块实际需要(存储)的内容可能会导致很大的开销。
引入仓库式模式抽象!
import API from './api.js'; // Import it into your code however you like
const WeatherRepository = {
_normalizeData(currentWeather) {
// Take only what our app needs and nothing more.
const { t, w, p } = currentWeather;
return {
temperature: t,
windspeed: w,
pressure: p
};
},
/**
* Get current weather.
* @return {Promise}
*/
get(){
return API.get('/weather')
.then(this._normalizeData);
}
}
现在在整个代码库中使用 WeatherRepository.get()
并访问有意义的属性,如 .temperature
和 .windspeed
。更好!
此外,通过 _normalizeData()
,我们只公开我们需要的参数。
还有一个很大的好处。想象一下,我们需要将我们的应用程序与另一个天气 API 连接起来。令人惊讶的是,这个 API 的响应属性名称不同
{
"temp": 30,
"feels": 32,
"wind": 6.7,
"press": 1012,
"hum": 38
}
不用担心!拥有我们的 WeatherRepository
抽象,我们只需要调整 _normalizeData()
方法即可!不是单个其他模块(或文件)。
const WeatherRepository = {
_normalizeData(currentWeather) {
// Take only what our app needs and nothing more.
const { temp, wind, press } = currentWeather;
return {
temperature: temp,
windspeed: wind,
pressure: press
};
},
/* ... */
};
API 响应对象的属性名称 **不与我们的代码库紧密耦合**。可维护性++!
在以后,假设我们希望在当前获取的数据不超过 15 分钟时显示缓存的天气信息。因此,我们选择使用 localStorage
存储天气信息,而不是每次引用 WeatherRepository.get()
时都进行实际的网络请求并调用 API。
只要 WeatherRepository.get()
返回一个 Promise,我们就无需更改任何其他模块中的实现。所有想要访问当前天气的其他模块都不(也不应该)关心数据是如何检索的 - 是否来自本地存储、来自 API 请求、通过 Fetch API 或通过 jQuery 的 $.ajax()
。这无关紧要。它们只关心以它们实现的“约定”格式接收它 - 一个包装实际天气数据的 Promise。
因此,我们引入了两个“私有”方法 _isDataUpToDate()
- 用于检查我们的数据是否超过 15 分钟 - 和 _storeData()
用于简单地将我们的数据存储在浏览器存储中。
const WeatherRepository = {
/* ... */
/**
* Checks weather the data is up to date or not.
* @return {Boolean}
*/
_isDataUpToDate(_localStore) {
const isDataMissing =
_localStore === null || Object.keys(_localStore.data).length === 0;
if (isDataMissing) {
return false;
}
const { lastFetched } = _localStore;
const outOfDateAfter = 15 * 1000; // 15 minutes
const isDataUpToDate =
(new Date().valueOf() - lastFetched) < outOfDateAfter;
return isDataUpToDate;
},
_storeData(_weather) {
window.localStorage.setItem('weather', JSON.stringify({
lastFetched: new Date().valueOf(),
data: _weather
}));
},
/**
* Get current weather.
* @return {Promise}
*/
get(){
const localData = JSON.parse( window.localStorage.getItem('weather') );
if (this._isDataUpToDate(localData)) {
return new Promise(_resolve => _resolve(localData));
}
return API.get('/weather')
.then(this._normalizeData)
.then(this._storeData);
}
};
最后,我们调整 get()
方法:如果天气数据是最新的,我们将其包装在一个 Promise 中并返回它。否则 - 我们发出 API 调用。太棒了!
可能还有其他用例,但我希望你理解了这个想法。如果更改只需要调整一个模块 - 那太棒了!您以一种可维护的方式设计了实现!
如果您决定使用这种仓库式模式,您可能会注意到它会导致一些代码和逻辑重复,因为您在项目中定义的所有数据仓库(实体)可能都会有像 _isDataUpToDate()
、_normalizeData()
、_storeData()
等等这样的方法……
由于我在我的项目中大量使用它,我决定围绕这种模式创建一个库,它完全按照我在本文中描述的那样,甚至更多!
介绍 SuperRepo
SuperRepo 是一个帮助您实现客户端数据处理和存储最佳实践的库。
/**
* 1. Define where you want to store the data,
* in this example, in the LocalStorage.
*
* 2. Then - define a name of your data repository,
* it's used for the LocalStorage key.
*
* 3. Define when the data will get out of date.
*
* 4. Finally, define your data model, set custom attribute name
* for each response item, like we did above with `_normalizeData()`.
* In the example, server returns the params 't', 'w', 'p',
* we map them to 'temperature', 'windspeed', and 'pressure' instead.
*/
const WeatherRepository = new SuperRepo({
storage: 'LOCAL_STORAGE', // [1]
name: 'weather', // [2]
outOfDateAfter: 5 * 60 * 1000, // 5 min // [3]
request: () => API.get('weather'), // Function that returns a Promise
dataModel: { // [4]
temperature: 't',
windspeed: 'w',
pressure: 'p'
}
});
/**
* From here on, you can use the `.getData()` method to access your data.
* It will first check if out data outdated (based on the `outOfDateAfter`).
* If so - it will do a server request to get fresh data,
* otherwise - it will get it from the cache (Local Storage).
*/
WeatherRepository.getData().then( data => {
// Do something awesome.
console.log(`It is ${data.temperature} degrees`);
});
该库执行我们之前实现的操作
- 从服务器获取数据(如果我们的数据丢失或已过期)或从缓存中获取数据。
- 就像我们使用
_normalizeData()
一样,dataModel
选项将映射应用于我们的原始数据。这意味着- 在整个代码库中,我们将访问有意义和语义化的属性,例如
.temperature
和.windspeed
而不是.t
和.s
。- 仅公开您需要的参数,并且简单地不包含任何其他参数。
- 如果响应属性名称更改(或您需要连接具有不同响应结构的其他 API),您只需要在此处调整 - 在代码库的唯一 1 个位置。
此外,还有一些额外的改进
- 性能:如果从应用程序的不同部分多次调用
WeatherRepository.getData()
,则只会触发 1 个服务器请求。 - 可扩展性
- 您可以将数据存储在
localStorage
中、浏览器存储中(如果您正在构建浏览器扩展)或本地变量中(如果您不想跨浏览器会话存储数据)。请参阅storage
设置 的选项。 - 您可以使用
WeatherRepository.initSyncer()
启动自动数据同步。这将启动一个 setInterval,它将倒计时到数据过期的时间点(基于outOfDateAfter
值),并触发服务器请求以获取最新数据。太棒了。
- 您可以将数据存储在
要使用 SuperRepo,请使用 NPM 或 Bower 安装(或简单下载)它。
npm install --save super-repo
然后,通过三种可用方法之一将其导入您的代码。
- 静态 HTML
<script src="/node_modules/super-repo/src/index.js"></script>
- 使用 ES6 导入
// If transpiler is configured (Traceur Compiler, Babel, Rollup, Webpack) import SuperRepo from 'super-repo';
- …或使用 CommonJS 导入
// If module loader is configured (RequireJS, Browserify, Neuter) const SuperRepo = require('super-repo');
最后,定义您的 SuperRepo 存储库 :)
有关高级用法,请阅读我编写的文档。包含示例!
摘要
我上面描述的抽象可以成为应用程序架构和软件设计的基石之一。随着您经验的增长,尝试思考并在处理远程数据时以及在其他有意义的情况下应用类似的概念。
在实现功能时,始终尝试与您的团队讨论更改弹性、可维护性和可扩展性。未来的您会感谢您的!
你不用 Angular 来做这个吗?
不。至少不是内置的。当然,无论您使用什么框架,如果您以这种方式组织代码,都可以实现类似的功能。
我通常不会这样发帖,但这确实让我感到恼火…
OOP 有四个基础:封装、抽象、继承和多态。多态是将对象声明为其他事物并将其用作该事物的能力。许多人为此使用继承,即使它是一个不同的概念——甚至不是一个很好的概念,因为它在其定义中包含了多态。
是的,你说得对!我进行了编辑并添加了 多态。谢谢!
精彩的文章。我对浏览器支持有一些疑虑——例如:IE 的 fetch?
谢谢,Pedro!
在我的代码示例中使用 fetch 的目的是为了说明 API 抽象如何处理切换到用于处理远程数据的不同机制的用例。
选择底层机制是另一个话题。如果您想使用 fetch,但需要支持 IE,请查看Fetch Polyfill,这是一个实现了标准 Fetch 规范子集的项目,足以使 fetch 成为传统 Web 应用程序中大多数 XMLHttpRequest 用法的可行替代方案。它们提供从 IE 10+ 开始的支持,因此您应该安全无虞。