包阅导读总结
1. 关键词:JavaScript、装饰器、特性、应用场景、依赖注入
2. 总结:本文探讨了 JavaScript 原生装饰器特性,包括其与设计模式的区别、官方 API 及应用,如防抖节流、记忆化、依赖注入等,并指出该特性虽有深度但掌握核心即可受益。
3. 主要内容:
– 探索原生 JavaScript 装饰器可能性
– 介绍即将引入的本地装饰器特性
– 对比装饰器设计模式与语言特性
– 展示官方 API 及实际案例应用
– 装饰器的历史模式与特性
– 装饰器设计模式
– 作为语言特性的装饰器
– 略有不同的官方 API
– 探索使用场景
– 防抖与节流
– 记忆化
– 简单实现
– 基于参数的缓存
– 记忆化 Getter
– 依赖注入
– 无装饰器时的依赖处理
– 使用装饰器创建依赖注入机制
– 控制反转与依赖注入
– 总结
– 装饰器规范有深度,掌握核心即可受益
思维导图:
文章地址:https://mp.weixin.qq.com/s/UwtV2U2aVrDUXR_f8wSIkg
文章来源:mp.weixin.qq.com
作者:Alex??MacArthur
发布时间:2024/8/16 0:01
语言:中文
总字数:4012字
预计阅读时间:17分钟
评分:86分
标签:JavaScript,装饰器,设计模式,性能优化,依赖注入
以下为原文内容
本内容来源于用户推荐转载,旨在分享知识与观点,如有侵权请联系删除 联系邮箱 media@ilingban.com
前言
介绍了 JavaScript 即将引入的本地装饰器特性,探讨了其设计模式与语言特性的区别,展示了官方 API 的使用,并通过实际案例展示了装饰器在防抖、节流、记忆化和依赖注入等方面的应用。今日前端早读课文章由 @飘飘翻译分享。
正文从这开始~~
我们已经知道这一点很久了,但 JavaScript 最终将获得原生装饰器支持。目前,这个提案已进入 第三阶段 —— 这是不可避免的!我刚刚开始探索这一特性,后悔没有早些尝试,因为我发现它非常有用。让我们一起来探索这个特性吧。
【第3131期】装饰器的10年历史
模式与特性
首先,有必要澄清一下 “装饰器” 到底指什么。大多数情况下,人们在谈论以下两种情况之一:
装饰器设计模式
这是一个更高层次的概念,即通过 “装饰” 来增强或扩展函数的行为。日志记录是一个常见的例子。你可能想知道函数何时被调用以及使用了哪些参数,因此你可以用另一个函数对其进行包装:
function add(a, b) {
return a + b;
}
function log(func) {
return function (...args) {
console.log(
`method: ${func.name} | `,
`arguments: ${[...args].join(", ")}`
);
return func.call(this, ...args);
};
}
const addWithLogging = log(add);
addWithLogging(1, 2);
// adding 1 2
这里没有任何新的语言特性。只是一个函数接受另一个函数作为参数并返回一个新的、增强版的函数。原函数已被 “装饰”。
作为语言特性的装饰器
装饰器特性是模式的一种更直观的体现。你可能以前见过一些非官方的旧版本。我们将继续使用上面的日志记录例子,但首先需要进行一些重构,因为语言级别的装饰器只能用于类方法、字段和类本身。
// 旧的装饰器 API:
function log(target, key, descriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args) {
console.log(
`method: ${originalMethod.name} | `,
`arguments: ${[...args].join(", ")}`
);
return originalMethod.apply(this, args);
};
return descriptor;
}
class Calculator {
@log // <-- 在此处应用装饰器。
add(a, b) {
return a + b;
}
}
new Calculator().add(1, 2); // method: add | arguments: 1, 2
尽管这种实现方式并不标准,但市面上仍有许多流行且成熟的库采用了这种实现方式。比如 TypeORM、Angular 和 NestJS。我很高兴它们采用了这种实现方式。使用这些库构建应用程序感觉更加整洁、表达力更强,并且更容易维护。
但因为它不是标准的,可能会引发一些问题。例如,Babel 和 TypeScript 的实现之间存在一些细微差别,这可能会让工程师在不同构建工具之间切换时感到沮丧。标准化将对它们大有裨益。
略有不同的官方 API
幸运的是,从 TypeScript v5 开始,以及通过使用插件的 Babel,现在都支持 TC39 的 API 版本,这个版本更为简洁:
function log(func, context) {
return function (...args) {
console.log(
`method: ${func.name} | `,
`arguments: ${[...args].join(", ")}`
);
func.call(this, ...args);
};
}
class Calculator {
@log
add(a, b) {
return a + b;
}
}
new Calculator().add(1, 2); // method: add | arguments: 1, 2
如你所见,学习曲线大大缩短,而且它可以完全替代以前用作装饰器的许多功能。唯一的区别是它采用了新的语法实现。
探索使用场景
这个特性有很多有用的场景,但让我们尝试一下脑海中浮现的几个例子。
防抖与节流
在一定时间内限制某个操作的执行次数是一个很常见的需求。通常,这意味着使用 Lodash 库中的某个实用工具,或者自己编写实现代码。
想象一下一个实时搜索框。为了防止用户体验问题和网络负载,你可能希望对这些搜索进行防抖,只在用户停止输入一段时间后才触发请求:
function debounce(func) {
let timeout = null;
return function (...args) {
clearTimeout(timeout);
timeout = setTimeout(() => {
func.apply(this, args);
}, 500);
};
}
const debouncedSearch = debounce(search);
document.addEventListener('keyup', function(e) {
// 只会在停止输入 500ms 后触发。
debouncedSearch(e.target.value);
});
但装饰器只能用于类或其成员,所以我们来举一个更好的例子。你有一个处理 keyup 事件的 ViewController 类,并且你想要对这个类进行一些自定义操作:
class ViewController {
async handleSearch(query) {
const results = await search(query);
console.log(`Update UI with:`, results);
}
}
const controller = new ViewController();
input.addEventListener('keyup', function (e) {
controller.handleSearch(e.target.value);
});
使用我们上面写的debounce()
方法,代码的实现会显得笨拙。现在让我们聚焦在 ViewController 类本身:
class ViewController {
handleSearch = debounce(async function (query) {
const results = await search(query);
console.log(`Got results!`, results);
});
}
你不仅需要将整个方法封装起来,还需要将类方法更改为实例属性,并将其设置为延时版本的该方法。这有点侵入性。
更新为原生装饰器
将debounce()
函数转换为官方装饰器不会花太多时间。实际上,该函数的编写方式已经完全符合 API 要求:它接受原始函数并返回经过扩展的版本。因此,我们所需要做的就是使用以下语法对其进行应用:
class ViewController {
@debounce
async handleSearch(query) {
const results = await search(query);
console.log(`Got results!`, results);
}
}
这就是所需的一切 —— 只需一行代码即可获得完全相同的结果。
我们还可以通过让debounce()
接受一个delay
值并返回一个装饰器本身,使防抖延迟变得可配置:
// 接受延迟值:
function debounce(delay) {
let timeout = null;
// 返回可配置的装饰器:
return function (value) {
return function (...args) {
clearTimeout(timeout);
timeout = setTimeout(() => {
value.call(this, ...args);
}, delay);
};
};
}
使用它只需将我们的装饰器包装器作为一个函数调用并传递延迟值:
class ViewController {
@debounce(500)
async handleSearch(query) {
const results = await search(query);
console.log(`Got results!`, results);
}
}
这对于需要编写的代码量非常少来说,是一个非常有价值的解决方案,尤其是有 TypeScript 和 Babel 的支持 —— 这些工具已经很好地融入了我们的构建流程中。
记忆化
每当我想起那些语法优美的高效缓存时,第一个想到的是 Ruby。我以前写过关于它优雅之处的文章;你真正需要的只是||=
运算符:
def results
@results ||= calculate_results
end
但是有了装饰器之后,JavaScript 的性能得到了显著提升。这里有一个简单的实现,它可以缓存方法的返回值,并在任何后续调用中使用该值:
function memoize(func) {
let cachedValue;
return function (...args) {
// 如果之前已经运行过,从缓存中返回。
if (cachedValue) {
return cachedValue;
}
cachedValue = func.call(this, ...args);
return cachedValue;
};
}
这种实现的好处是,因为每个装饰器的调用都会声明自己的作用域,这意味着你可以重复使用它而不必担心 cachedValue 被意外赋值覆盖。
class Student {
@memoize
calculateGPA() {
// 耗时计算...
return 3.9;
}
@memoize
calculateACT() {
// 耗时计算...
return 34;
}
}
const bart = new Student();
bart.calculateGPA();
console.log(bart.calculateGPA()); // 来自缓存: 3.9
bart.calculateACT();
console.log(bart.calculateACT()); // 来自缓存: 34
进一步说,我们还可以基于传递给方法的参数来实现缓存:
function memoize(func) {
// 为每组不同的参数保留一个位置。
let cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
// 这组参数有缓存值。
if (cache.has(key)) {
return cache.get(key);
}
const value = func.call(this, ...args);
cache.set(key, value);
return value;
};
}
现在,无论参数使用如何,都可以让缓存更加灵活:
class Student {
@memoize
calculateRank(otherGPAs) {
const sorted = [...otherGPAs].sort().reverse();
for (let i = 0; i <= sorted.length; i++) {
if (this.calculateGPA() > sorted[i]) {
return i + 1;
}
}
return 1;
}
@memoize
calculateGPA() {
// 耗时计算...
return 3.4;
}
}
const bart = new Student();
bart.calculateRank([3.5, 3.7, 3.1]); // 全新计算
bart.calculateRank([3.5, 3.7, 3.1]); // 来自缓存
bart.calculateRank([3.5]); // 全新计算
这很酷,但值得注意的是,如果你处理的是无法序列化的参数(如 undefined、包含循环引用的对象等),可能会遇到问题。因此,使用时要谨慎。
记忆化 Getter
由于装饰器不仅可以用于方法,对 getter 进行记忆化也只需稍加调整。我们只需使用context.name
(getter 的名称)作为缓存键:
function memoize(func, context) {
let cache = new Map();
return function () {
if (cache.has(context.name)) {
return cache.get(context.name);
}
const value = func.call(this);
cache.set(context.name, value);
return value;
};
}
实现方式与之前相同:
class Student {
@memoize
get gpa() {
// 耗时计算...
return 4.0;
}
}
const milton = new Student();
milton.gpa // 全新计算
milton.gpa // 来自缓存
顺便说一下,那个 context 对象包含了一些有用的信息。其中之一是正在装饰的字段的 “类型”。这意味着我们甚至可以通过同一个装饰器对 getter 和方法进行缓存:
function memoize(func, context) {
const cache = new Map();
return function (...args) {
const { kind, name } = context;
// 根据“类型”使用不同的缓存键。
const cacheKey = kind === 'getter' ? name : JSON.stringify(args);
if (cache.has(cacheKey)) {
return cache.get(cacheKey);
}
const value = func.call(this, ...args);
cache.set(cacheKey, value);
return value;
};
}
你可以更深入地探讨这个问题,但我们现在先就此打住,转而探讨一些更复杂的内容。
依赖注入
如果你使用过 Laravel 或 Spring Boot 等框架,你应该熟悉依赖注入和应用程序的 “控制反转(IoC)容器”。这是一个非常有用的功能,使你可以编写更加松耦合且易于测试的组件。通过原生装饰器,甚至可以将这一核心概念带入原生 JavaScript 中。无需使用任何框架。
【第2630期】javascript中的依赖注入
假设我们正在构建一个需要向各种第三方发送消息的应用程序。触发电子邮件、发送分析事件、推送通知等。每个操作都被抽象为自己的服务类:
class EmailService {
constructor() {
this.emailKey = process.env.EMAIL_KEY;
}
}
class AnalyticsService {
constructor(analyticsKey) {
this.analyticsKey = analyticsKey;
}
}
class PushNotificationService {
constructor() {
this.pushNotificationKey = process.env.PUSH_NOTIFICATION_KEY;
}
}
没有装饰器的情况下,自己实例化这些服务类并不困难。代码可能看起来像这样:
class MyApp {
constructor(
emailService = new EmailService(),
analyticsService = new AnalyticsService(),
pushNotificationService = new PushNotificationService()
) {
this.emailService = emailService;
this.analyticsService = analyticsService;
this.pushNotificationService = pushNotificationService;
// 做些事...
}
}
const app = new MyApp();
但现在你的构造函数充斥着在运行时永远不会被使用的参数,并且你要完全负责实例化这些类。虽然有一些可行的解决方案(例如依赖于单独的模块来创建单例),但这种方法并不十分舒适。随着复杂性的增加,这种方法会变得更加繁琐,特别是当你试图保持可测试性并坚持良好的控制反转原则时。
使用装饰器进行依赖注入
现在,让我们使用装饰器创建一个基本的依赖注入机制。它将负责注册依赖项,在必要时实例化它们,并将它们的引用存储在一个集中容器中。
浅谈控制反转与依赖注入
在一个单独的文件(container.js)中,我们将构建一个简单的装饰器,用于注册我们希望容器提供的任何类。
const registry = new Map();
export function register(args = []) {
return function (clazz) {
registry.set(clazz, args);
};
}
内容很简洁。我们接受类本身和实例化时可能需要的构造函数参数。接下来,我们将创建一个容器来保存我们创建的实例,以及一个inject()
装饰器。
const container = new Map();
export function inject(clazz) {
return function (_value, context) {
context.addInitializer(function () {
let instance = container.get(clazz);
if (!instance) {
instance = Reflect.construct(clazz, registry.get(clazz));
container.set(clazz, instance);
}
this[context.name] = instance;
});
};
}
你会注意到我们使用了装饰器规范中的另一个方法。addInitializer()
方法会在被装饰的属性定义后才触发回调。这意味着我们可以在实例化注入的依赖项时实现懒加载,而不是一次性启动所有已注册的类。这在性能上略有提升。如果一个类使用了 EmailService,但从未实际实例化,那么我们也不会不必要地启动 EmailService 的实例。
也就是说,当装饰器被调用时,会发生以下情况:
现在,我们的应用程序可以更优雅地处理依赖项。
import { register, inject } from "./container";
@register()
class EmailService {
constructor() {
this.emailKey = process.env.EMAIL_KEY;
}
}
@register()
class AnalyticsService {
constructor(analyticsKey) {
this.analyticsKey = analyticsKey;
}
}
@register()
class PushNotificationService {
constructor() {
this.pushNotificationKey = process.env.PUSH_NOTIFICATION_KEY;
}
}
class MyApp {
@inject(EmailService)
emailService;
@inject(AnalyticsService)
analyticsService;
@inject(PushNotificationService)
pushNotificationService;
constructor() {
// 做些事。
}
}
const app = new MyApp();
另外一个好处是,也可以轻松替换这些类的 Mock 版本。我们可以通过在被测试的类实例化之前将我们自己的模拟类注入到容器中,而不是覆盖类属性,从而更不具侵入性地实现注入:
import { vi, it } from 'vitest';
import { container } from './container';
import { MyApp, EmailService } from './main';
it('does something', () => {
const mockInstance = vi.fn();
container.set(EmailService, mockInstance);
const instance = new MyApp();
// 测试代码。
});
这使得我们的责任更小,控制权得到了清晰的反转,测试也变得更加简单。这一切都得益于一种原生功能。
这只是浅尝辄止而已
如果你仔细阅读了这个提案,你会发现装饰器规范的深度远不止于此,并且未来会开放一些新的应用场景,尤其是在更多运行时支持它之后。但你不需要完全掌握这个特性的深层次内容才能从中受益。其核心仍然牢牢地基于装饰器模式。只要记住这一点,你就能很好地从中受益于自己的代码中。
关于本文
译者:@飘飘
作者:@Alex MacArthur
原文:https://frontendmasters.com/blog/exploring-the-possibilities-of-native-javascript-decorators/
这期前端早读课
对你有帮助,帮”赞“一下,
期待下一期,帮”在看” 一下 。