由filter说开去

背景

filter 这个函数,相信大家都不会陌生,他是 es2015 新增的数组的方法,用以过滤。它接受一个函数,返回符合函数条件的数组,如果没有,则返回空数组。

语法如下

var newArray = arr.filter(callback(element[, index[, array]])[, thisArg])

各个参数的具体含义如下(来源于 MDN

callback
用来测试数组的每个元素的函数。返回 true 表示该元素通过测试,保留该元素,false 则不保留。它接受以下三个参数:
element
数组中当前正在处理的元素。
index 可选
正在处理的元素在数组中的索引。
array 可选
调用了 filter 的数组本身。
thisArg 可选
执行 callback 时,用于 this

用法

filter 是一个基础的数组操作用法,它的基本用法如下

//过滤普通数组
var arr = [1, 2, 3, 4, 5, 6, 7, 5, 3, 2, 4, 9, -1]

var res = arr.filter(row => row > 4) // [5, 6, 7, 5, 9]

//过滤对象数组
var data = [{ a: 1, b: 4 }, { a: 5, b: 8 }]

var v = data.filter(row => row.a === 1) // [{a: 1, b: 4}]

当然,它还可以和别的东西结合起来,比如取数组交集

/**
* 取交集
* @param {array} a
* @param {array} b
*/
export function __Union(a, b) {
return a.filter(v => b.includes(v))
}

一个 bug 引发的思考

接下来切入正题,之前 review 代码,然后发现了一个关于 filter 的 bug,bug 的原因很简单,写错了 filter传入的函数的判断体。大致代码如下

var data = [{ a: 1, b: 4 }, { a: 5, b: 8 }]

var v = data.filter(row => (row.a = 1))

相信各位同学一眼就能看出问题所在,的确就是判断的 ===写成了=

那么问题来了,这个res的结果是多少,各位同学知道吗?是空数组?还是报错(Uncaught SyntaxError: Unexpected token)?

不知道的同学可以尝试一下,结果其实是下面这样子的

var data = [{ a: 1, b: 4 }, { a: 5, b: 8 }]

var v = data.filter(row => (row.a = 1)) // [{a: 1, b: 4}, {a: 1, b: 8}]

????我们再来打印一下原始数组

var data = [{ a: 1, b: 4 }, { a: 5, b: 8 }]

var v = data.filter(row => (row.a = 1)) // [{a: 1, b: 4}, {a: 1, b: 8}]

console.log(data) // [{a: 1, b: 4}, {a: 1, b: 8}]

嗯?很奇怪是不是,难不成返回了原数组?我们接下来继续判断一下是不是原数组

var data = [{ a: 1, b: 4 }, { a: 5, b: 8 }]

var v = data.filter(row => (row.a = 1)) // [{a: 1, b: 4}, {a: 1, b: 8}]

console.log(data) // [{a: 1, b: 4}, {a: 1, b: 8}]

console.log(data === v) // false

看到这里,我们可以发现,这个问题不是filter返回了原始数组,所以并不是 bug,那么究竟是什么原因导致的问题呢?我们继续往下看。

Polyfill

分析此类问题,最好的办法还是查看源码,既然filter是 es2015 新增的特性,那么,必然会有 Polyfill 对其进行向下兼容。于是我们翻看MDN,找到如下代码

注:filter 被添加到 ECMA-262 标准第 5 版中,因此在某些实现环境中不被支持。可以把下面的代码插入到脚本的开头来解决此问题,该代码允许在那些没有原生支持 filter 的实现环境中使用它。该算法是 ECMA-262 第 5 版中指定的算法,假定 fn.call 等价于 Function.prototype.call的初始值,且 Array.prototype.push 拥有它的初始值。

if (!Array.prototype.filter) {
Array.prototype.filter = function(func, thisArg) {
'use strict'
if (!((typeof func === 'Function' || typeof func === 'function') && this))
throw new TypeError()

var len = this.length >>> 0, // 无符号右移0,详情见注[1]
res = new Array(len), // preallocate array
t = this,
c = 0,
i = -1
if (thisArg === undefined) {
while (++i !== len) {
// checks to see if the key was set
if (i in this) {
if (func(t[i], i, t)) {
res[c++] = t[i]
}
}
}
} else {
while (++i !== len) {
// checks to see if the key was set
if (i in this) {
if (func.call(thisArg, t[i], i, t)) {
res[c++] = t[i]
}
}
}
}

res.length = c // shrink down array to proper size
return res
}
}

具体分析

从上面的代码我们可以很明显的看出,过滤操作主要发生在while循环内,首先先判断func(t[i], i, t)是否为true,如果是,则将此项复制一份到res中,最后循环结束,返回res

整个 Polyfill 思路清晰,我们可以得出一点,就是之前的问题,原因产生在下面这段代码中

if (func(t[i], i, t)) {
res[c++] = t[i]
}

根据实际情况,我们可以得出以下假设

if函数体判断一直为true,然后函数体再对原始数组进行修改,导致,返回结果与原数组一样(注意,这里的原数组指的是修改之后的)

我们尝试在Array上挂载一个filters的方法(做一个对比,不覆盖原有的filter方法),代码就为上述 Polyfill 的代码(加上了两句console.log

if (!Array.prototype.filters) {
Array.prototype.filters = function(func, thisArg) {
'use strict'
if (!((typeof func === 'Function' || typeof func === 'function') && this))
throw new TypeError()

var len = this.length >>> 0, // 无符号右移0,详情见注[1]
res = new Array(len), // preallocate array
t = this,
c = 0,
i = -1
if (thisArg === undefined) {
while (++i !== len) {
// checks to see if the key was set
if (i in this) {
if (func(t[i], i, t)) {
console.log(func, func(t[i], i, t))
res[c++] = t[i]
}
}
}
} else {
while (++i !== len) {
// checks to see if the key was set
if (i in this) {
if (func.call(thisArg, t[i], i, t)) {
console.log(func, func(t[i], i, t))
res[c++] = t[i]
}
}
}
}

res.length = c // shrink down array to proper size
return res
}
}
var data = [{ a: 1, b: 4 }, { a: 5, b: 8 }]
var v = data.filters(row => (row.a = 1))
console.log(data, v)

上面这段代码和我们之前的 bug 结果一样,那我们再改一下,把最后函数体改为 row => (row.a = 0),然后你会发现,返回的是一个空数组。Why?

这里需要说明的就是 JavaScript 的类型转换规则了,我们翻看红宝书,可以得到下列对于Boolean转换规则

数据类型 转换为true 转换为false
Boolean true false
String 任何非空字符串 ''(空字符串)
Number 任何非零数值(包括无穷) 0NaN
Object 任何对象 null
Undefined n/a(N/A代表不适用) undefined

我们还是会有一个疑问,为什么 row => (row.a = 0) 这段函数体调用打印结果和最后赋值有关?

我们接着尝试

var test = row => {
return (row.a = -1)
}

console.log(test({ a: -1, b: 8 })) // -1

下面还有个例子

var obj = {}

console.log((obj.a = -1)) // -1

console.log(obj) // {a: -1}

这个问题,我们回到表达式上面来,JS 中表达式分为两种,单值表达式复合表达式

这里得感谢mutoe大佬的提点,我也算查漏补缺了…

JavaScript 表达式总有返回值,其中,单值表达式的结果是值本身,其他表达式结果是根据运算符进行运算的结果值

于是,上面的一切我们都可以解释的通了,因为我们传入的函数体 row => (row.a = 0)包含一个单值表达式,所以它相当于 row => return 0

还剩下最后一个问题,关于原始数组的修改。其实这个问题很好理解,因为传入的是对象类型,JS 的对象类型都是由引用地址进行传递,如果没有进行深拷贝,那么它所传递的就是一个堆地址的一个指针。所以,修改传递进来的对象,原始数组也会修改(因为说到底,内存的堆区域,就只有一个数组)

可以参考下面的例子

let objc = { a: 77 }

let test = param => {
param.a = 100
return param
}

let res = test(objc)

console.log(res) // {a: 100}

console.log(objc) // {a: 100}

console.log(res === objc) // true

接下来,我们做一个总结,为什么filter传入的函数体写法错误(比如传入row => row.a = 1),会导致原始结果改变

根本原因是由于 filter 的实现过程,决定了输出结果由传入函数决定,而函数的输出(return)结果,则由函数体内部运算决定,当内部是一个单值表达式的时候,由于 JS 表达式的返回值,它会返回值本身,然后函数也返回这个结果,进而进入if判断,接着由于 JS 的 Boolean 类型转换规则,输出 true/false,然后得到意外的结果。而这个过程中,如果传入的数组是一个对象数组,那么原始的结果会进行改变,如果不是,那么将会得到原数组的一份拷贝(见下面代码)

var arr = [1, 2, 3]

var res = arr.filter(row => (row = 1))

console.log(arr) // [1, 2, 3]

console.log(res) // [1, 2, 3]

console.log(arr === res) // false

总结

没想到一个小小的 filter,由一次 bug 引发了诸多思考,尽管个人认为filter的规范里面应该将上面 bug 操作进行Uncaught SyntaxError: Unexpected token报错,但是进而思考,它并不能检测函数体内部(或许可以,但是很麻烦,实现起来也并不现实),因而,我们实际使用中还是要多多注意细节问题。

参考文章

[1]无符号右移 0 操作

文章作者: Jdeseva
文章链接: https://jdeseva.github.io/2019/08/13/由filter说开去/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 沧海鲸歌