写点什么

原生 JavaScript 灵魂拷问 (二),你能全部答对吗?

作者:战场小包
  • 2022 年 4 月 28 日
  • 本文字数:8577 字

    阅读完需:约 28 分钟

前言

当下的前端开发,三大框架三分天下,框架的简单、强大让我们欲罢不能,使用原生 JavaScript 越来越少。

但我认为 JavaScript 作为每一个前端工程师的立身之本,不止要学会,还要学好、学精,学再多遍都不为过。

另一方面,前端面试中,越来越重视原生 JavaScript 的考察,其所占比例也越来越高。

因此我决定整理JavaScript中容易忽视或者混淆的知识点,写一系列篇文章,以灵魂拷问的方式,系统且完整的带大家遨游原生 JavaScript 的世界,希望能给大家带来一些收获。


这是原生 JavaScript 灵魂拷问系列文章的第二部分,第一部分链接:

传送门: 原生 JavaScript 灵魂拷问,你能答上多少 (一)


JavaScript 之运算符篇

16. 你知道 ||&& 的返回值规则吗?

  • 短路效应:&&|| 都会发生短路

  • && 只有在两个操作数都为 true 时,条件判断的结果才为 true ,如果操作数一为 false ,不会判断操作数二。

  • || 两个操作数只要有一个为 true ,条件判断的结果就为 true ,因此操作数一为 true 时,不会判断操作数二。

  • 返回值规则

  • ||&& 首先会对操作数一执行条件判断,如果不是布尔值就先强制转换为布尔类型,然后再执行条件判断。

  • 对于 || 来说,如果条件判断结果为 true 就返回第一个操作数的值,如果为 false 就返回第二个操作数的值。

  • && 则相反,如果条件判断结果为 true 就返回第二个操作数的值,如果为 false 就返回第一个操作数的值。

  • ||&& 返回它们其中一个操作数的值,而非条件判断的结果

  • (2<3)||(3<2) 返回值是多少?

17. 1 + - + + + - + 1结果是多少?

+/- 号在 JavaScript 通常有三种用途:


  • 普通加减法: 二元运算符

  • ++/--: 自增自减,一元运算符

  • +/-: 正负,一元运算符


上面表达式中没有涉及自增与自减的情况,一元运算符的优先级大于二元运算符,上述表达式执行顺序为:1 + (- (+ (+ (+ (- (+ 1)))))) ----> 1 + 1 = 2

18. 你能准确的做出自增与自减的题目吗?

  • ++/-- 在前,先加/减后用

  • ++/-- 在后,先用后加/减


请回答:y 的值为?


var x = 1;var y = x + ++x + 3 * (x = ++x + x + x++ + 1)          + x++ + 3;console.log(y); 
复制代码


咱们来解剖一下这个长的要死的表达式:


第一个x值为1第二个自增先加后用,x = 2将 ++x + x + x++ + 1 的运算结果赋值给x    ++x 先加后用,x = 3    x = 3    x++ 先用后加,x = 3    ++x + x + x++ + 1 = 3 + 3 + 3 + 1 = 10        x++的后加,x = 4将表达式的值赋给x,x由4变为10
x++ 先用后加,x = 10此时所有的变量都已经求出y = 1 + 2 + 3*10 + 10 + 3 = 46x++,x最终值为11
复制代码

19. == 与 === 的区别,Object.is() 与 === 区别

== 与 === 区别

  • === 是严格相等,要求数据类型和值都要相等;== 只需要值相等。

  • == 会发生隐式类型转换,=== 不会发生隐式类型转换。

Object.is() 与 === 区别:

Object.is() 来进行相等判断时,一般情况下与 === 相同,它处理了一些特殊的情况,比如 +0-0 不再相等,两个 NaN 是相等的。

20. 你知道new运算符的有两种优先级吗?

MDNnew 操作符的描述中,语法是:


    new constructor[([arguments])]
复制代码


([arguments]) 意味着可以缺省,会存在 new constructor(...args)new constructor 两种模式,并且前者的优先级高于后者。更详细的优先级见下图:



这个知识点非常重要,只有区分开了 new 带参列表和不带参列表,才能准确并且透彻的理解下面这道经典面试题。


function Foo(){    getName = function(){ console.log(1); };    return this;}Foo.getName = function(){ console.log(2); };Foo.prototype.getName = function(){ console.log(3); };var getName = function(){ console.log(4); };function getName(){ console.log(5) };
Foo.getName(); getName(); Foo().getName();getName(); new Foo.getName();new Foo().getName();
复制代码

21.(ES6+) 可选链接运算符(?.)和空值合并运算符(??)

说到运算符,提一下这两个最新的扩展符,开阔一下眼界。

可选链接运算符(?.)

当使用 ?. 时,若当前值为假值,会停止执行,返回 undefined


在日常编程中,如果要读取对象内部的属性,往往需要判断该对象是否存在,例如读取 message.body.user.firstName,安全的写法


const firstName = (message                 && message.body                && message.body.user                && message.body.user.firstName)
复制代码


但如果有了 (?.) ,上面代码就可以简化为


const firstName = message?.body?.user?.firstName
复制代码

空值合并运算符(??)

空值合并运算符 (??) 是一个逻辑操作符,当左侧的操作数为 nullundefined 时,才会返回右侧操作数结果,否则返回左侧结果


?? 和 || 有点像,但是 || 判断的是假值,?? 是判断 null 和 undefined


//真值const a = 1 ?? '默认值'console.log(a)    // 1
//假值const a = '' ?? '默认值'console.log(a) // ''
//nullconst a = null ?? '默认值'console.log(a) // '默认值'
//undefinedconst a = undefined ?? '默认值'console.log(a) // '默认值'
复制代码


JavaScript 之字符串篇

22.String('1'), new String('1'),'1' 区别是什么?

JavaScript 中,我们有下面三种定义字符串的方式。


var str = '1';var str1 = String('1');var str2 = new String('1');
复制代码


上面三种方式定义出的 '1' ,是否存在区别?我们来通过 === 运算符和 typeof 运算符测试一下。


console.log(str === str1)  // trueconsole.log(str1 === str2) // falseconsole.log(str === str2)  // false
console.log(typeof(str)) // stringconsole.log(typeof(str1)) // stringconsole.log(typeof(str2)) // object
复制代码


打印一下, new String('1') 的结构见下图:



可见 new String() 生成的字符串为对象类型, [[PrimitiveValue]] 存储字符串的原始值。

23.字符串是基本类型,那为什么可以调用字符串方法那?

这是 JavaScript 中的设计。JavaScript 为了便于基本类型操作,提供了三个特殊的引用类型(包装类),即 Number,String,Boolean ,它们的 [[PrimitiveValue]] 属性存储它们的本身值。基本类型的方法与属性是"借"来的,去向对应包装类"借"来的。


光说这些有可能有些难理解,咱们来举个例子:


var str = 'zcxiaobao'str2 = str.toUpperCase()
复制代码


其实 js 引擎内部会这样处理:


var str = 'zcxiaobao'// 调用方法,创建String的一个实例new String(str)// 调用实例上的方法,并将值返回str2 = new String(str).toUpperCase()// 销毁实例
复制代码

24.修改 string.length 大小能改变字符串长度吗?为什么?

先看一个示例:


var str = '123456';str.length = 0;console.log(str, str.length); // 123456 6
复制代码


很明显,修改 str.length 是无法做到修改字符串的长度的。


str 为原始值,调用 length 相当借用 new String(str).length,修改的是 new String(str)length ,跟原始值 str 无关。

扩展:修改 new String()生成字符串的 length 会生效吗?为什么?

如果将上面代码修改一下,str 是由 new String 产生,修改 length 属性会生效吗?


var str = new String('123456');str.length = 0;console.log(str, str.length); // String {"123456"} 6
复制代码


答案告诉我们,还是失败了。


new String() 生成的字符串是对象类型,为啥还不能使用 length 属性。那说明 length 属性,很有可能配置不可写,测试一下上述猜想:


Object.getOwnPropertyDescriptor(str, 'length')/*    {        value: 6,         writable: false,         enumerable: false,         configurable: false    }*/
复制代码


由控制台的打印可知:new String() 生成的字符串的 length 属性不止是不可写,而且是不可枚举、不可配置的。

25.字符串截取函数 slice(),substring(),substr()的区别是什么?

基本使用

  • substring: 方法返回一个字符串在开始索引到结束索引之间的一个子集,或从开始索引直到字符串的末尾的一个子集。


str.substring(indexStart, [indexEnd])
复制代码


注意事项:


  1. substring 截取字符串中 [indexStart, indexEnd) 处字符串

  2. 如果任一参数大于 stringName.length ,则被当作 stringName.length

  3. 如果 indexStart 大于 indexEnd,则 substring 的执行效果就像两个参数调换了一样。见下面的例子。


const str = "123456"console.log(str.substring(0,1) === str.substring(1,0)) // true
复制代码


  • substr: 方法返回一个字符串中从指定位置开始到指定字符数的字符。


str.substr(start, [length])
复制代码


注意事项


  1. substr() 会从 start 获取长度为 length 字符(如果截取到字符串的末尾,则会停止截取)。

  2. 如果 start 是正的并且大于或等于字符串的长度,则 substr() 返回一个空字符串。

  3. start 为负数,则将该值加上字符串长度后再进行计算(如果加上字符串的长度后还是负数,则从 0 开始截取)。

  4. 如果 length0 或为负数,substr() 返回一个空字符串。如果 length 省略,则将 substr() 字符提取到字符串的末尾。


  • slice: 方法提取某个字符串的一部分,并返回一个新的字符串,且不会改动原字符串。


str.slice(beginIndex[, endIndex])
复制代码


注意事项


  1. beginIndex 为负数,则将该值加上字符串长度后再进行计算(如果加上字符串的长度后还是负数,则从 0 开始截取)。

  2. 如果 beginIndex 大于或等于字符串的长度,则 slice() 返回一个空字符串。

  3. 如果 endIndex 省略,则将 slice() 字符提取到字符串的末尾。如果为负,它被视为 strLength + endIndex 其中 strLength 是字符串的长度。

三者区别


  1. slicesubstring 参数都是起始索引和结束索引,但 slice 参数可以是负数,substring 参数如果小于 0 ,则会被视为 0

  2. substr 与其他两个不同,两个参数为起始索引和要包含在生成的字符串中的字符的长度。

26.你真的理解透 JSON.stringify 了吗?

基本使用

JSON.stringify 可以将数组或者对象转化成 JSON 字符串。JSON.parse 可以将 JSON 字符串转化为数组或对象。基于这两个方法,可以产生很多用处,例如:


  • 对象的深拷贝(存在缺点: 循环引用等)

  • localStorage 只能存取字符串格式的内容,因此存之前转换成 JSON 字符串,取出来用时,在转化成数组或对象。

学透 JSON.stringfy

语法


JSON.stringify(value[, replacer [, space]])
复制代码


参数


  • value: 将要序列化成 一个 JSON 字符串的值。

  • replacer(可选):

  • 如果该参数是一个函数,则在序列化过程中,被序列化的值的每个属性都会经过该函数的转换和处理;

  • 如果该参数是一个数组,则只有包含在这个数组中的属性名才会被序列化到最终的 JSON 字符串中;

  • 如果该参数为 null 或者未提供,则对象所有的属性都会被序列化。

  • space:指定缩进用的空白字符串,用于美化输出


(重要)九大特性:


特别要注意 1、3、5 条特性


  1. 布尔值、数字、字符串的包装对象在序列化过程中会自动转换成对应的原始值。

  2. 所有以 symbol 为属性键的属性都会被完全忽略掉,即便 replacer 参数中强制指定包含了它们。

  3. 对包含循环引用的对象(对象之间相互引用,形成无限循环)执行此方法,会抛出错误。

  4. NaNInfinity 格式的数值及 null 都会被当做 null

  5. undefined、任意的函数以及 symbol 值:


  • 出现在非数组对象的属性值中时在序列化过程中会被忽略

  • 出现在数组中时会被转换成 null

  • 单独转换时,会返回 undefined


  1. 转换值如果有 toJSON() 方法,该方法定义什么值将被序列化。

  2. Date 日期调用了 toJSON() 将其转换为了 string 字符串(同 Date.toISOString() ),因此会被当做字符串处理。

  3. 其他类型的对象,包括 Map/Set/WeakMap/WeakSet,仅会序列化可枚举的属性

  4. 非数组对象的属性不能保证以特定的顺序出现在序列化后的字符串中。

JavaScript 之数组篇

27.那些数组方法会修改原数组吗?那些不会?

这是频繁出现的考点,牛客的前端面试题中,该问题反复出现。


  1. 改变自身值的方法


pop push shift unshift splice sort reverse// ES6新增copyWithinfill
复制代码


  1. 不改变自身值的方法


join // 返回字符串forEach // 无返回值concat map filter slice // 返回新数组every some // 返回布尔值reduce // 不一定返回什么indexOf lastIndexOf // 返回数值(索引值)// ES6新增find findIndex
复制代码

28.shiftunshift 的返回值分别是什么?

  1. unshift() 在数组开始处插入元素,shift() 删除数组第一个元素

  2. 关于两者返回值,直接上测试代码:


const arr = [1,2,3,4,5]undefinedconsole.log(arr.unshift(0)) // 6console.log(arr.shift()) // 0
复制代码


由上述代码可知:unshift 返回插入元素后的新数组长度,shift 返回删除的元素值


那么可以类比推理一下,push 返回的是插入元素的新数组长度,pop 返回删除的元素值

29.new Array()Array.of() 的区别是什么?

  1. Array.of() 方法创建一个具有可变数量参数的新数组实例,而不考虑参数的数量或类型。这好像跟 new Array 的功能极度接近,为什么 ES6 添加了新方法那?


原因就在于 new Array(n) ,只有一个参数时,构造的并非只含 n 的数组,而是 1 * n 的数组,值全为 undefined。具体看测试:


Array.of(7);       // [7]Array.of(1, 2, 3); // [1, 2, 3]
Array(7); // [ , , , , , , ]Array(1, 2, 3); // [1, 2, 3]
复制代码

30.includes 与 indexOf 的区别是什么?

基本使用

  1. indexOf: 返回元素在 array 中的位置,不存在就返回 -1

  2. includes: 用来判断元素是否存在

区别

  1. includes 可以检测 NaN


var arr = [NaN]console.log(arr.indexOf(NaN))   // -1console.log(arr.includes(NaN))  // true
复制代码


  1. includes 返回值为 true/false ,在判断数组中是否存在某元素时,includes 的可读性更好

31.splice() 删除元素的注意事项

我来假设一个场景,大家就理解为什么会有此问题。


现在有这样一个数组 [1,-1,2,-1,-5],我想删除掉所有的负数,于是我就写出了下面的代码:


const arr = [1,-1,-2,1,-5]for(let i = 0; i<arr.length; i++) {    if (arr[i] < 0) {        arr.splice(i, 1)    }}console.log(arr) // [1, -2, 1]
复制代码


上面的代码感觉没什么错误,为啥 -2 没被删除掉?


我在 splice 执行后,打印一下当前 i 位置的元素值,大家应该就可以理解。


for(let i = 0; i<arr.length; i++) {    if (arr[i] < 0) {        arr.splice(i, 1)        console.log(a[i]) // -2 undefined    }}
复制代码


数组删除 -1 之后,当前的 i 值为 -2,此次遍历结束,i++,正好空过了 -2 元素。因此如果 splice 删除元素发生在数组中,一定要注意回调 i 的位置

32. 你会使用数组高阶函数吗

传送门: 半小时,阿包带你学会手撕高阶函数

33. 你会手撕数组高阶函数吗?

传送门: 半小时,阿包带你学会手撕高阶函数

34.你知道 forEach 如何跳出循环吗?

break return

break return 都无法中断 forEach 循环,我们来挨着验证一下:


  • break


var arr = [1,2,3,4]arr.forEach((val, key) => {    // Uncaught SyntaxError: Illegal break statement    if (key > 1) {        break    }    console.log(val)})
复制代码


上述代码直接发生报错,可见 break 无法在 forEach 中使用。


  • return


var arr = [1,2,3,4]arr.forEach((val, key) => {    console.log('key = ', key)    if (key > 1) {        return    }    console.log(val)})
复制代码


输出结果


key =  01key =  12key =  2key =  3
复制代码


虽然有两个 val 未被打印,但循环还是进行了 4 次,return 无法中断 forEach ,只是类似于 continue 的功能。

try catch

try catch 可以中断 forEach 的,而且是唯一可以中断 forEach 的方式。


使用 try 监视代码块,在需要中断的地方抛出异常。具体见下面案例:


var arr = [1,2,3,4]try {    arr.forEach(function (val, key) {        console.log('key = ', key)
if (key > 1) { throw Error(); } })} catch (e) {}
复制代码


输出结果


key =  0key =  1key =  2
复制代码

官方推荐: every/some

传送门: Array.prototype.forEach


除了抛出异常以外,没有办法中止或跳出 forEach() 循环。如果你需要中止或跳出循环,forEach() 方法不是应当使用的工具。


可以使用 someevery 来替代 forEach 函数: every 在碰到 return false 的时候,中止循环。some 在碰到 return true 的时候,中止循环。具体使用见下面案例:


var arr = [1,2,3,4]arr.some((val, key) => {    console.log('key = ', key);    if (key > 1) {        return true    }})
复制代码

35. 如何实现数组乱序

传送门: JavaScript专题之乱序

36. 如何实现数组去重

你知道多少种数组去重的方法吗?

使用双重 forsplice

function unique(arr){                for(var i=0; i<arr.length; i++){        for(var j=i+1; j<arr.length; j++){            if(arr[i]==arr[j]){                     //第一个等同于第二个,splice方法删除第二个                arr.splice(j,1);                // 删除后注意回调j                j--;            }        }    }return arr;}
复制代码

使用 indexOfincludes 加新数组

//使用indexoffunction unique(arr) {    var uniqueArr = []; // 新数组    for (let i = 0; i < arr.length; i++) {        if (uniqueArr.indexOf(arr[i]) === -1) {            //indexof返回-1表示在新数组中不存在该元素            uniqueArr.push(arr[i])//是新数组里没有的元素就push入        }    }    return uniqueArr;}// 使用includesfunction unique(arr) {    var uniqueArr = [];     for (let i = 0; i < arr.length; i++) {        //includes 检测数组是否有某个值        if (!uniqueArr.includes(arr[i])) {            uniqueArr.push(arr[i])//        }    }    return uniqueArr;}
复制代码

sort 排序后,使用快慢指针的思想

function unique(arr) {    arr.sort((a, b) => a - b);    var slow = 1,        fast = 1;    while (fast < arr.length) {        if (arr[fast] != arr[fast - 1]) {            arr[slow ++] = arr[fast];        }        ++ fast;    }    arr.length = slow;    return arr;}
复制代码


sort 方法用于从小到大排序(返回一个新数组),其参数中不带以上回调函数就会在两位数及以上时出现排序错误(如果省略,元素按照转换为的字符串的各个字符的 Unicode 位点进行排序。两位数会变为长度为二的字符串来计算)。

ES6 提供的 Set 去重

function unique(arr) {    const result = new Set(arr);    return [...result];    //使用扩展运算符将Set数据结构转为数组}
复制代码


Set 中的元素只会出现一次,即 Set 中的元素是唯一的。

使用哈希表存储元素是否出现(ES6 提供的 map)

function unique(arr) {    let map = new Map();    let uniqueArr = new Array();  // 数组用于返回结果    for (let i = 0; i < arr.length; i++) {      if(map.has(arr[i])) {  // 如果有该key值        map.set(arr[i], true);       } else {         map.set(arr[i], false);   // 如果没有该key值        uniqueArr.push(arr[i]);      }    }     return uniqueArr ;}
复制代码


map 对象保存键值对,与对象类似。但 map 的键可以是任意类型,对象的键只能是字符串类型。


如果数组中只有数字也可以使用普通对象作为哈希表。

filter 配合 indexOf

function unique(arr) {    return arr.filter(function (item, index, arr) {        //当前元素,在原始数组中的第一个索引==当前索引值,否则返回当前元素        //不是那么就证明是重复项,就舍弃        return arr.indexOf(item) === index;    })}
复制代码


这里有可能存在疑问,我来举个例子:


const arr = [1,1,2,1,3]arr.indexOf(arr[0]) === 0 // 1 的第一次出现arr.indexOf(arr[1]) !== 1 // 说明前面曾经出现过1
复制代码

reduce 配合 includes

function unique(arr){    let uniqueArr = arr.reduce((acc,cur)=>{        if(!acc.includes(cur)){            acc.push(cur);        }        return acc;    },[]) // []作为回调函数的第一个参数的初始值    return uniqueArr}
复制代码

37. 如何实现数组扁平化

传送门: 面试官连环追问:数组拍平(扁平化) flat 方法实现

38. 类数组如何转化为数组?

Array.prototype.slice.call()

const arrayLike = {    0: '111',    1: '222',    length: 2}console.log(Array.prototype.slice.call(arrayLike)) // ["1", "2"]
复制代码

Array.from()

Array.fromES6 新增的方法,它可以将**类数组对象和可遍历(iterable)**转变为真正的数组。


const arrayLike = {    0: '1',    1: '2',    length: 2}console.log(Array.from(arrayLike)) // ["1", "2"]
复制代码

(...)展开运算符

扩展运算符调用的是遍历器接口,如果一个对象没有部署此接口就无法完成转换。


上面咱们自己写的普通类数组就无法使用...运算符。


const arrayLike = {    0: '1',    1: '2',    length: 2}// Uncaught TypeError: arrayLike is not iterableconsole.log([...arrayLike]) // ["1", "2"]
复制代码


如果部署了遍历器接口,例如 arguments 类数组,便可以使用扩展运算符。


function fn() {    console.log([...arguments])}fn(1,2,3) // [1, 2, 3]
复制代码

后语

我是 战场小包 ,一个快速成长中的小前端,希望可以和大家一起进步。


如果喜欢小包,可以在 InfoQ 关注我,同样也可以关注我的小小公众号——小包学前端


一路加油,冲向未来!!!

疫情早日结束 人间恢复太平

发布于: 21 小时前阅读数: 17
用户头像

战场小包

关注

成长中的小前端,一起努力,一起进步 2021.09.23 加入

掘金年度优秀创作者第20名。前端的小学生,快速进步的小前端。公众号: 小包学前端

评论

发布
暂无评论
原生JavaScript灵魂拷问(二),你能全部答对吗?_JavaScript_战场小包_InfoQ写作社区