JavaScript 函数命名参数,甚至是另一种方法





5.00/5 (3投票s)
如果您认为在 JavaScript 项目中需要命名函数参数,您可以考虑扩展内置的 JavaScript Function 对象来自动添加它们。
引言
阅读本文标题及其简短描述,许多 JavaScript 专家会认为 ECMAScript 规范并未提及命名参数,而且此功能已经以多种方式实现。 无论如何,忽略这一点,我并不是想重新发明轮子,我只想提出一些有趣的观点,这些观点在您日常的 JavaScript 编码实践中也常常被低估。
背景
正如上述专家可能会补充说,更改 JS 内置对象可能会给您带来一些惊喜,您的工作可能会被其他扩展意外覆盖,例如,如果您出于充分的理由添加外部库或更改其中一个库的版本。虽然这些观点是真实有效的,但我相信,在激励开发人员提高专业知识的驱动因素中,拥抱那些用于解决特定问题的令人畏惧的功能的意愿,通常是新的探索之旅的一个被低估的良好起点。此外,对我而言,给我提示的不仅仅是一个提示的是 Sergey Alexandrovich Kryukov 最近在此发表的文章(及其后续文章),我建议您阅读它,从不同的角度看待这个问题。
Using the Code
现在,我准备向您展示主要源代码的外观,并通过代码示例来说明其用法,这些示例旨在解释该功能的实现。下面,您将找到 argumentify
扩展的源代码,该扩展放弃了函数包装器方法——这可能是处理 Object.seal
不可逆行为所必需的——而是使用 ECMAScript 5 引入的 Object.defineProperty
功能,在运行时直接将命名参数附加到函数上,以修改 JavaScript 对象。
为防止参数名称冲突,使用 hasOwnProperty
原生方法来检查属性是否存在。
无论如何,在 JavaScript 中,函数是第一类公民,因此它们可以像任何其他对象一样拥有属性。我们需要这些属性来表示原始目标函数的命名形式参数。编写调用代码时,开发人员可以选择定义一个或多个命名参数的默认值,或者稍后为其属性赋值。
这是 argumentify 的完整实现:
"use strict";
Function.prototype.argumentify = function argumentify() {
var self = this,
argumentValues = Array.prototype.slice.call(arguments),
argumentNames = (self + "").match(/function[^(]*\(([^)]*)\)/)[1].split(/,\s*/);
if (!(!argumentNames || !argumentNames.length)) {
for (var i = 0, l = self.length; i < argumentNames.length; ++i) {
if (!self.hasOwnProperty(argumentNames[i]) || (self.$$arguments &&
self.$$arguments.names.indexOf(argumentNames[i]) > -1)) {
Object.defineProperty(self, argumentNames[i], {
get: function(index, length) {
return function() {
return argumentValues[index + length];
};
}.call(null, i, l),
set: function(index, length) {
return function(value) {
argumentValues[index + length] = value;
}
}.call(null, i, l),
configurable: true,
enumerable: false
});
self[argumentNames[i]] = argumentValues[i];
}
}
}
Object.defineProperty(self, "$$arguments", {
configurable: true,
enumerable: false,
value: {
names: argumentNames,
values: argumentValues
}
});
return self;
};
在函数实例化并通过 argumentify
进行增强后,开发人员可以调用该实例上的 invoke
方法来执行函数逻辑,可以调用任意次数。 invoke
方法的行为模仿 apply
原生方法,分别接受两个参数:this
上下文对象和一个包含命名函数参数的对象。因此,如果正确提供,invoke
方法确实期望第二个参数是字面对象类型,它包含表示函数参数值的某些属性,在调用时,它可以包含所有必需的参数,或其中一部分。并且,如果属性名称拼写错误,错误不会被忽略,因为该方法会尽早失败并引发 TypeError
异常。
这是 invoke
的源代码
Function.prototype.invoke = function invoke(context) {
var i = 0, args, invokeArgs, $$arguments = this.$$arguments;
for (; $$arguments && i < $$arguments.names.length; i++)
(args || (args = [])).push(this[$$arguments.names[i]]);
invokeArgs = Array.prototype.slice.call(arguments, 1);
if (invokeArgs.length === 1 && invokeArgs[0].constructor === Object) {
var $args = invokeArgs[0];
for (var prop in $args) {
if ($args.hasOwnProperty(prop)) {
if ((i = $$arguments.names.indexOf(prop)) === -1) {
throw new TypeError("\"" + prop + "\" argument name is invalid");
} else {
args[i] = $args[prop];
}
}
}
}
return this.apply(context, args || invokeArgs);
};
当然,可以通过调用 cleanUpNamedArguments
将所有参数(属性)设置为 undefined
。
Function.prototype.cleanUpNamedArguments = function cleanUpNamedArguments(undefined) {
var $$arguments = this.$$arguments;
for (var i = 0; $$arguments && i < $$arguments.names.length; i++)
this[$$arguments.names[i]] = undefined;
};
实际上,为了添加辅助功能,例如观察一个或多个参数的更改,已经实现了 watchNamedArguments
和 unWatchNamedArguments
作为机制,允许触发指定对象实例/属性的监视块。
watch
/unwatch
方法和依赖项看起来是这样的
if (!Function.prototype.watch) {
Object.defineProperty(Function.prototype, "watch", {
enumerable: false,
configurable: true,
writable: false,
value: function(prop, handler) {
var oldval = this[prop],
newval = oldval,
getter = function() {
return newval;
},
setter = function(val) {
oldval = newval;
return newval = handler.call(this, prop, oldval, val);
};
if (delete this[prop]) {
Object.defineProperty(this, prop, {
get: getter,
set: setter,
enumerable: true,
configurable: true
});
}
}
});
}
if (!Function.prototype.unwatch) {
Object.defineProperty(Function.prototype, "unwatch", {
enumerable: false,
configurable: true,
writable: false,
value: function(prop) {
var val = this[prop];
delete this[prop];
this[prop] = val;
}
});
}
Function.prototype.watchNamedArguments = function watchNamedArguments(callback) {
var $$arguments = this.$$arguments;
for (var i = 0; $$arguments && i < $$arguments.names.length; i++)
this.watch([$$arguments.names[i]], function (id, oldval, newval) {
callback.call(null, id, oldval, newval);
return newval;
});
};
Function.prototype.unwatchNamedArguments = function watchNamedArguments() {
var $$arguments = this.$$arguments;
for (var i = 0; $$arguments && i < $$arguments.names.length; i++)
this.unwatch([$$arguments.names[i]]);
};
如前所述,argumentify
方法被设计为一种更 JavaScript 函数友好的命名参数方法,这意味着它总是可以根据开发人员的需要被调用任意次数来增强函数实例,而无需任何额外的保护。
要查看完整示例,让我们考虑 Sergey 文章中所示的、使用 3 个参数的“普通”函数
var fn, fn0;
function f(first, medium, last) {
return first + medium + last;
}
try {
fn = f.argumentify(5, 10);
console.log("fn: " + (fn.last = 1, fn.invoke(null)));
console.log(fn.first);
console.log(fn.medium);
fn0 = f.argumentify(15, 20);
console.log("fn0: " + fn0.invoke(null, {last: 1}));
console.log(fn.first);
console.log(fn.medium);
} catch (ex) {
console.log(ex);
} finally {
fn.cleanUpNamedArguments();
}
嗯,正如您所见,调用代码没有显式检查任何内容,尽管在两个 invoke
示例中参数名称很容易拼错,但值得注意的是,后一个 invoke
调用可能会引发 TypeError
异常来通知编码错误。在示例中,参数的顺序也可以改变,这要归功于您可以在 argumentify
方法调用中为省略的参数定义默认参数值。在每次函数调用时,我们可以使用 invoke
方法并传入不同的参数值,将最前面的参数作为 this
上下文或将其值设为 null。这为函数调用编码增加了一点微不足道的额外工作,但是,尽管如此,JavaScript 程序员仍然可以选择坚持使用逗号运算符语法或更常见、经过验证的、针对拼写错误的形式参数列表的字面对象语法。在这种情况下,两个示例都倾向于将最后一个参数设置为默认值 undefined
。无论如何,我们在这里真正关心的问题是可读性,当然还有更好的代码库维护性,尤其是在您有很多任务要处理,并且在很长一段时间后回到同一个代码库变得很困难的情况下。
由 fn
和 fn0
变量引用的原始函数与附加到函数对象的属性定义相同。这是 finally
块中 cleanUpNamedArguments
只调用一次的主要原因。
同时,提供的拼写错误防护机制,加上监视/取消监视机制,为开发人员提供了一个近乎完整的可用于工作的基础。事实上,当用以下监视回调丰富时,本文中显示的编程风格会变得更有趣。
fn.watchNamedArguments(function(id, oldval, newval) {
console.log('o.' + id + ' changed from ' + oldval + ' to ' + newval);
});
在这里,我使用了常见的想法,即使用一个回调函数来记录命名参数的活动。请注意,对于当前提出的实现,只有第一个监视回调会被触发。
ECMAScript 6 来救场
ECMAScript 5 是目前所有主要浏览器中最流行的 ECMAScript 标准,然而 ECMAScript 6 引入了定义默认参数值的功能:https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters。即使这个语法糖的添加听起来无关紧要,但如果您是那些渴望 JavaScript 中命名参数的开发者之一,它可能是一个很好的 ECMAScript 语言改进。
关注点
很可能,所介绍的方法中有两个可能令人困惑的地方。其中之一是参数初始化的风格,因为我建议使用逗号运算符,而另一个则是因为实际参数显然与常规函数调用语句中传递的参数无关。
让我们先说逗号运算符并非 JavaScript 特有,在 C 和 C++ 等其他语言中也可用。作为从 C 语言继承而来的二元运算符,当左侧的操作数(通常是表达式)具有第二个操作数所需的期望的副作用时,它很有用。但是,为了全面起见,逗号在 var
语句中具有特殊含义。实际上,像 var a, b
这样的变量声明是指您将在代码中初始化和使用的两个或多个变量,而表达式则不是这种情况,其结果由最后一个用逗号运算符分隔的变量或指令给出。一个混淆的例子可能是这样
var b = (1,2) > (4,8) ? 'bar' : (4,2) > (1,0) ? '' : 'baz'; // b == ''
这段代码异味示例导致变量初始化为 ''
的结果,通过一系列起初看似复杂的步骤,这些步骤启动了 (1,2)
表达式到 2
和 (4,8)
表达式到 8
的解析,然后是 (4,2)
表达式到 2
和 (1,0)
表达式到 1
的解析。然后,这些瞬时值分别用作大于运算符的左侧和右侧,为两个 ?:
三元运算符提供输入,从而导致上述 ''
的最终结果。部分解混淆效果可以这样举例
var b = 2 > 8 ? 'bar' : 2 > 0 ? '' : 'baz';
所以,转到另一个潜在的令人困惑的点,忽略调用语句圆括号中传递的参数列表中的参数可能看起来很奇怪,如果您来自不同的语言。考虑到编写函数调用的开发人员需要对其结果有具体的了解,使用本文所述方法时需要考虑的一个重要问题是,参数集可以作为属性赋值集传递,从而将参数列表留给我们用于其他潜在用途。
让我们来看一对等效的函数调用语句
console.log(fn(1, -2, 3)); // => 2
console.log((fn.first = 1, fn.medium = -2, fn.last = 3, fn.invoke(null))); // => 2
这两个语句在预期的最终结果上是等效的,并且,尽管关于调用 invoke 方法时带有零参数编写 fn.invoke()
有任何相反的说法,但同样重要的是要记住,f.first
仍然是引用第一个参数值的合法方式,因为在我们主要示例中声明了函数名称。如果您想重用函数对象以避免贪婪的 GC 操作,并且您的编码方式是创建单个对象一次并在 argumentify 调用期间更新其命名参数属性值,那么这是一种合理的方法。
另一个可能 concerns 是关于 Object.defineProperty
的参数化;因此,我希望 ECMAScript 规范能够帮助理清事情,尤其是在将 configurable
设置为 true 以允许命名参数默认值进一步更改方面。
- 8.6.1 节:“[[Configurable]]:如果为 false,则尝试删除属性、将属性更改为访问器属性或更改其属性([[Value]] 除外)将失败。”
在相同的代码设计决策区域,出于个人偏好,我决定将 enumerable
设置为 false
;这样,一个 for-in
循环遍历对象的所有可枚举属性(包括原型链上的属性)时,将跳过命名参数属性。
结论
我花了一些时间才得出结论,通过几次重写周期成熟的,我在这篇文章中描述的简单技术甚至可以部分用于其他开发人员的项目。它不仅提供了一种控制函数参数传递流程的方式,还可以成为思考如何以更易于维护的方式设计 JavaScript 解决方案的基础。