【写在前面】
上一篇《UNDERSCORE.js 源码解析(一)》中,我们分享了框架的主要结构,并扩展了一些周边内容。今天,我们来看一些框架实现中有意思的片段。
【看这里】
1、根对象定义
// Establish the root object, `window` (`self`) in the browser, `global`
// on the server, or `this` in some virtual machines. We use `self`
// instead of `window` for `WebWorker` support.
var root = typeof self == 'object' && self.self === self && self ||
typeof global == 'object' && global.global === global && global ||
Function('return this')() ||
{};
上面的代码片段中,定义了一个root的根对象。其思路就是适配各种运行环境,将真实运行环境中的根对象赋值给root。&&运算的优先级要高于||运算,所以上面的结构,其实就是多个条件语句的||运算。root最后的值,也就是按照从左到右的顺序,取第一个||语句成立的值。
typeof self == 'object' && self.self === self && self;
在浏览器中,self = window.self = window,这个是BOM对象上的一个特殊设定。所以这句话是在判断当前是否运行在Browser中。&&语句中,左边成立,再进行右边的运算。该语句最后返回window对象。
typeof global == 'object' && global.global === global && global
在服务端环境中,一般指代node环境,global是全局根对象,global.global = global, 该语句返回的是global对象。
Function('return this')()
声明一个函数有多种方式,一种是平时使用的function(){}, 另一种就是通过Function构造函数来声明。可能这种方式平时大家用的少一点。两种方式是有一些区别的,后者声明的方法不会产生闭包,即它访问到的是全局对象。'return this'是构造函数的参数,其参数定义列表参照下方的链接[1]。这种写法,在浏览器和server端都被支持,该语句返回了this对象。
2、定义简称引用
// Save bytes in the minified (but not gzipped) version:
var ArrayProto = Array.prototype, ObjProto = Object.prototype;
var SymbolProto = typeof Symbol !== 'undefined' ? Symbol.prototype : null;
// Create quick reference variables for speed access to core prototypes.
var push = ArrayProto.push,
slice = ArrayProto.slice,
toString = ObjProto.toString,
hasOwnProperty = ObjProto.hasOwnProperty;
定义简称,是工具库实现中常用的方式。一方面为了多处复用,减少字节数,缩小文件体积。另一方面是为了提升访问速度。这也是为什么很多工具库中的别名较多、变量命名比较简单,甚至不太易读的原因。
3、参数处理
// Some functions take a variable number of arguments, or a few expected
// arguments at the beginning and then a variable number of values to operate
// on. This helper accumulates all remaining arguments past the function’s
// argument length (or an explicit `startIndex`), into an array that becomes
// the last argument. Similar to ES6’s "rest parameter".
function restArguments(func, startIndex) {
startIndex = startIndex == null ? func.length - 1 : +startIndex;
return function() {
var length = Math.max(arguments.length - startIndex, 0),
rest = Array(length),
index = 0;
for (; index < length; index++) {
rest[index] = arguments[index + startIndex];
}
switch (startIndex) {
case 0:
return func.call(this, rest);
case 1:
return func.call(this, arguments[0], rest);
case 2:
return func.call(this, arguments[0], arguments[1], rest);
}
var args = Array(startIndex + 1);
for (index = 0; index < startIndex; index++) {
args[index] = arguments[index];
}
args[startIndex] = rest;
return func.apply(this, args);
};
}
restArguments定义了处理动态参数长度场景的方法。这里统一将多余的参数放到一个数组中,作为最后一个参数,类似ES6中的rest[2]。
fun.length可以用来获取函数定义中,形式参数的个数。arguments.length获取的是实参的个数。枚举了0/1/2这三种情况,也是出于执行效率的考虑。超出这三种情况的其它场景,一律走下面的遍历赋值。
4、类型判断
// Is a given variable undefined?
function isUndefined(obj) {
return obj === void 0;
}
isUndefined中使用void 0来判断是否是undefined。void是js中的一个函数,接受任意参数,恒返回undefined。之所以不直接使用undefined来判断,原因有两个:一是undefined不是js的保留字,可以作为变量名被赋值;二是节省字节数。
// Is a given value a DOM element?
function isElement(obj) {
return !!(obj && obj.nodeType === 1);
}
isElement中判断当前对象是否为dom节点,nodeType是node接口的只读属性,nodeType = 1,表示为dom节点,具体见[3]。
// Is the given value `NaN`?
function isNaN$1(obj) {
return isNumber(obj) && _isNaN(obj);
}
注意typeof NaN === 'number' 为true。
5、缓存
// Memoize an expensive function by storing its results.
function memoize(func, hasher) {
var memoize = function(key) {
var cache = memoize.cache;
var address = '' + (hasher ? hasher.apply(this, arguments) : key);
if (!has(cache, address)) cache[address] = func.apply(this, arguments);
return cache[address];
};
memoize.cache = {};
return memoize;
}
将运算结果缓存到cache中,cache以k:v的形式组织。hasher是key的生成器,如果没有指定hasher,则默认使用参数值作为属性名。看下面的例子:
var fibonacci = _.memoize(function(n) {
return n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2);
});
fibonacci(5);
console.log(fibonacci.cache);
// {0: 0, 1: 1, 2: 1, 3: 2, 4: 3, 5: 5}
每一次对原始函数的调用,都将结果缓存了起来。
6、防抖和节流
两个使用非常高频的方法,应用场景非常广泛。大家也应该不止一次的封装过自己的防抖和节流方法。我们来看下UnderScore是怎样做的。
首先,我们再来澄清一下它们的概念。防抖(debounce)的概念比较好理解,即在一段时间内只执行最后一次触发。多次触发会重新计算时间。节流(throttle)的概念相对抽象,是指在wait秒内只执行一次回调。多次的触发,只有满足条件才会被回调。这个条件一般是判断距离上次回调是否已经过去wait秒。
// Delays a function for the given number of milliseconds, and then calls
// it with the arguments supplied.
var delay = restArguments(function(func, wait, args) {
return setTimeout(function() {
return func.apply(null, args);
}, wait);
});
// When a sequence of calls of the returned function ends, the argument
// function is triggered. The end of a sequence is defined by the `wait`
// parameter. If `immediate` is passed, the argument function will be
// triggered at the beginning of the sequence instead of at the end.
function debounce(func, wait, immediate) {
var timeout, result;
var later = function(context, args) {
timeout = null;
if (args) result = func.apply(context, args);
};
var debounced = restArguments(function(args) {
if (timeout) clearTimeout(timeout);
if (immediate) {
var callNow = !timeout;
timeout = setTimeout(later, wait);
if (callNow) result = func.apply(this, args);
} else {
timeout = delay(later, wait, this, args);
}
return result;
});
debounced.cancel = function() {
clearTimeout(timeout);
timeout = null;
};
return debounced;
}
debounce方法接收三个参数,回调函数、等待时间和一个是否立即触发的标志字段。首次触发,设置一个wait时长的定时器,在等待回调的时间里,若出现了二次触发,则重新设置定时器,原有定时器被清除,由此达到重新计时的目的。只有在等待时长内没有再次触发防抖逻辑时,真正的回调逻辑才会被执行。所以防抖是在一段时间内,只执行最后一次。
// Returns a function, that, when invoked, will only be triggered at most once
// during a given window of time. Normally, the throttled function will run
// as much as it can, without ever going more than once per `wait` duration;
// but if you'd like to disable the execution on the leading edge, pass
// `{leading: false}`. To disable execution on the trailing edge, ditto.
function throttle(func, wait, options) {
var timeout, context, args, result;
var previous = 0;
if (!options) options = {};
var later = function() {
previous = options.leading === false ? 0 : now();
timeout = null;
result = func.apply(context, args);
if (!timeout) context = args = null;
};
var throttled = function() {
var _now = now();
if (!previous && options.leading === false) previous = _now;
var remaining = wait - (_now - previous);
context = this;
args = arguments;
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = _now;
result = func.apply(context, args);
if (!timeout) context = args = null;
} else if (!timeout && options.trailing !== false) {
timeout = setTimeout(later, remaining);
}
return result;
};
throttled.cancel = function() {
clearTimeout(timeout);
previous = 0;
timeout = context = args = null;
};
return throttled;
}
throttle也接收三个参数,前两个参数与防抖是一样的,第三个参数可以传入额外的配置项。在注释中有提到options.leading可以禁止在时间段wait的开始执行回调,options.trailing禁止在wait结束的时候执行回调。
相对于debounce,throttle更强调稀释执行频率,保证两次回调逻辑执行的间隔大于执行的时长wait。实现这一策略的具体方法,就是设置定时器的时候给一个合理的回调时长remaining。
var _now = now();
if (!previous && options.leading === false) previous = _now;
var remaining = wait - (_now - previous);
remaining表示在上次触发后,本次触发距离真正可以执行回调的时长是多少,也可以理解为最短还要等多久。一般情况下,_now-previous一定是大于等于0的,所以,remaining一定是小于等于wait的。当上次执行和本次触发之间的间隔没有超过一个wait周期时,只需要将剩余等待时间remaining设置给定时器就可以了。若时间间隔超过了一个wait周期,此时remaining是小于等于0的,应该立即执行回调逻辑。之所以会有remaining > wait这种条件,是为了容错用户更改系统时间的场景。
【扩展阅读】
1、链式调用
在上一节中,我们曾经提到_.chain方法对一个对象进行了包装,恒返回一个_的实例对象。_的原型对象上映射了各种导出方法,因此chain之后返回的对象可以调用任何_的方法。
_.chain([1, 2]).first()
又因为_原型对象上的各种导出方法也是经过包装的,所以这些方法的调用结果,可以继续调用_的导出方法。因此,就支持了一个链式调用的使用方式。
_.chain([1, 2]).first().value()
我们发现这种链式调用的编程方式,语义连贯性和思维逻辑性特别好。它的展示方式,合乎对这件事情的自然语言描述:即取数组的第一个元素的值。所以,我们可以借鉴这种方式,在实际工作中写更易于理解和维护的Code。
2、函数式编程
函数式编程是一种编程范式。它强调关注解决问题的方法。与此对应的是命令式编程,它更多关注解决问题的步骤。体现到Coding的实现上,前者通过变换组合来定义数据间的映射关系,后者通过操作数据获取结果。
// (1 + 2) * 3 - 4
var result = subtract(multiply(add(1,2), 3), 4);
// 链式调用的写法,更直白易懂
var result = add(1, 2).multiply(3).substract(4);
UnderScore中出现了大量的高阶函数,其作为函数式编程的一种实践,很多时候并不容易理解,因为其抽象的程度更高。上层抽象的程度越高,下层的并发和复用也会更高,所以尝试通过抽象和描述映射关系来解决问题,是一件有价值的事情。
更多的,还有响应式编程RxJS[4]。通过管理事件的控制流和操作序列,来达成对数据的处理。这里对于抽象的要求就更高,但好在RxJS提供了很多Operator可以帮助达成对问题解决方法的描述。
// 统计按钮点击次数
var button = document.querySelector('button');
Rx.Observable.fromEvent(button, 'click')
.scan(count => count + 1, 0)
.subscribe(count => console.log(`Clicked ${count} times`));
3、线性和流式
当使用命令式编程的时候,每个操作的步骤是以点分布的,每个命令都独立存在,它依赖下一条命令主动处理上一条命令的结果产生关联关系。此时,过程是零维的。当我们尝试将过程抽象描述成一个组合和序列的时候,处理过程就变成了线性和一维的。相当于搭建了一条流水线,数据进入到这个管道中,流入到下一个管道。你只需要灌入数据,就会得到结果,然后不再关心其中的具体步骤。
【相关链接】
[1] Function MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function
[2] ES6 Rest Parameter: https://es6.ruanyifeng.com/#docs/function#rest-%E5%8F%82%E6%95%B0
[3] NodeType MDN: https://developer.mozilla.org/zh-CN/docs/Web/API/Node/nodeType
[4] RxJS: https://cn.rx.js.org/