原生JS知识点&&面试高频算法(一)

1,391 阅读9分钟

JavaScript作为前端工程师的技术基础,学再多遍都不为过。很多JS的基础知识点也可以引申出很多的旁支体系。

有些开发者或者同学可能会觉得有的平时基本用不到的东西,为什么要掌握这些东西?

真实业务场景中,可能诸如手写splice,深拷贝,手撕promise的场景并不多见,甚至可以说并不存在,但面试问这些问题的初衷,并不是为了让你拿到平时的开发中去用,而是检验候选人对于JS语言的理解深度有没有达到一定的水准。例如有一些边界情况是否考虑的到,有没有基本的数据结构思想和计算机素养,未来是否有潜力去设计出优秀的产品或者框架。

本文不针对面试,只是基础知识的梳理,但如果面试中有遇到类似问题,可以借鉴这篇blog的话和面试官聊聊。不多BB,以下为正文。

第一章:JS的数据类型

1.JS的原始数据类型&&引用数据类型

在JS中,一共分为7种数据类型,分别为以下几种:

  • Boolean
  • null
  • undefined
  • string
  • number
  • symobol
  • bigInt

而引用数据类型分为:

  • 对象Object ( 包含普通对象Object,数组对象Array,正则对象RegExp,日期对象Date,数学函数Math以及函数对象Function)。

2.读程说出结果

拿本人举例,之前在面试字节跳动的时候,遇到过这样的一道题。

function test(person) {
  person.age = 18;
  person = {
    name: 'bytedance',
    age: 16
  }
  return person;
}
const p1 = {
  name: 'bytedancer',
  age: 19
}
const p2 = test(p1)
console.log(p1) // -> ?
console.log(p2) // -> ?

以上程序结果为

{ name: "bytedancer", age: 18 };
{ name: "bytedance", age: 16 };

原因是因为在函数传参的时候传递的是对象在堆中的内存地址值,test函数中的实参person是p1对象的内存地址,通过调用person.age = 18确实改变了p1的值,但随后person变成了另一块内存空间的地址,并且在最后将这另外一份内存空间的地址返回,赋给了p2。

3. typeof null为什么结果为object

可以简单理解为在 JS 的最初版本中使用的是 32 位系统,为了性能考虑使用低位存储变量的类型信息,000 开头代表是对象,然而null 表示为全零,所以将它错误的判断为 object 。

4. 0.1 + 0.2 !== 0.3

0.1和0.2在转换成二进制后会无限循环,由于标准位数的限制后面多余的位数会被截掉,此时就已经出现了精度的损失,相加后因浮点数小数位的限制而截断的二进制数字在转换为十进制就会变成0.30000000000000004。

5. BigInt

在最开始提到了基本数据类型有一种类型为“BigInt”,而什么是BigInt呢?

我之前在学习该类型的时候看过某知乎大V的博客,上面对于BigInt的解释如下:

BigInt数据类型是为了让JavaScript程序能表示超出Number 类型支持的数值范围。

在对大整数进行数学运算时,以任意精度表示整数的能力尤为重要。

有了BigInt,整数溢出将不再是一个问题。

从以上几句话不难看出,BigInt的引入就是为了解决JS语言中大数的精度问题。

如何创建并使用BigInt?

要创建BigInt,只需要在数字末尾追加n即可。

console.log( 9007199254740995n );    // → 9007199254740995n	
console.log( 9007199254740995 );     // → 9007199254740996

或者使用BigInt构造函数

BigInt("9007199254740995");    // → 9007199254740995n

第二章:JS数据类型检测

1. typeof能否正确判断数据类型?

对于原始类型来说,除了 null 都可以调用typeof显示正确的类型。但对于引用数据类型,除了函数之外,都会显示"object"。例如:

typeof [] // 'object'
typeof {} // 'object'
typeof console.log // 'function'

如果要解决以上问题,可以使用instanceof,其原理是基于原型链的查询,只要处于原型链中,判断永远为true。

const Person = function() {}
const p1 = new Person()
p1 instanceof Person // true

var str1 = 'hello world'
str1 instanceof String // false

var str2 = new String('hello world')
str2 instanceof String // true

2. 手写instanceof

手写instanceof并不难,主要是理解他的核心原理,即:原型链的向上查找。

代码如下:

function myInstanceof(left, right) {    //先做判断,如果是基本数据类型那就直接返回false
    if (typeof left !== 'object' || left == null) return false;
    let proto = Object.getPrototypeOf(left);
    //先写一个死循环,将两种情况作为终止条件
    while (true) {
        if (proto === null) return false;
        //查找到尽头,如若还没找到,返回false
        if (proto == right.prototype) return true;
        // 找到相同的原型对象,返回true
        proto = Object.getPrototypeOf(proto);
    }
}

4. Object.is和===的区别?

Object.is在严格等于的基础上修复了一些特殊情况下的失误,具体来说就是+0和-0,NaN和NaN。

第三章:JS数据类型之问——转换篇

1. [] == ![]的结果

在== 中,左右两边都需要转换为数字然后进行比较。[]转换为数字为0。![] 首先是转换为布尔值,由于[]作为一个引用类型转换为布尔值为true,因此![]为false,进而在转换成数字,变为0。0 == 0 , 结果为true。

2. JS中类型转换有哪几种?

JS中,类型转换只有三种:

  • 转换成数字
  • 转换成布尔值
  • 转换成字符串

3. == 和 ===有什么区别?

===叫做严格相等,是指:左右两边不仅值要相等,类型也要相等,例如'1'===1的结果是false,因为一边是string,另一边是number。

而==不像===那样严格,对于一般情况,只要值相等,就返回true,但==还涉及一些类型转换,它的转换规则如下:

  • 两边的类型是否相同,相同的话就比较值的大小
  • 判断的是否是null和undefined,是的话就返回true
  • 判断的类型是否是String和Number,是的话,把String类型转换成Number,再进行比较
  • 判断其中一方是否是Boolean,是的话就把Boolean转换成Number,再进行比较
  • 如果其中一方为Object,且另一方为String、Number或者Symbol,会将Object转换成字符串,再进行比较

第四章: 谈谈你对闭包的理解

解释一下什么是闭包?

《JavaScript高级程序设计》上对于闭包的定义:闭包是指有权访问另外一个函数作用域中的变量的函数。

MDN 对闭包的定义为:闭包是指那些能够访问自由变量的函数。

《你不知道的JavaScript》对闭包的定义为:当函数可以记住并访问所在词法的作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

闭包产生的原因?

在ES5中只存在两种作用域————全局作用域和函数作用域,当访问一个变量时,解释器会首先在当前作用域查找标示符,如果没有找到,就去父作用域找,直到找到该变量的标示符或者不在父作用域中,这就是作用域链,值得注意的是,每一个子函数都会拷贝上级的作用域,形成一个作用域的链条。 比如:

var a = 1;
function fn1() {
  var a = 2
  function fn2() {
    var a = 3;
    console.log(a);//3
  }
}

在这段代码中,f1的作用域指向有全局作用域(window)和它本身,而f2的作用域指向全局作用域(window)、f1和它本身。而且作用域是从最底层向上找,直到找到全局作用域window为止,如果全局还没有的话就会报错。

闭包产生的本质就是,当前环境中存在指向父级作用域的引用。

闭包有哪些表现形式?

1.返回一个函数。刚刚已经举例。

2.作为函数参数传递

var a = 1;
function foo(){
  var a = 2;
  function fn(){
    console.log(a);
  }
  bar(fn);
}
function bar(fn){
  // 这就是闭包
  fn();
}
// 输出2,而不是1
foo();

3. 在定时器、事件监听、Ajax请求、跨窗口通信、Web Workers或者任何异步中,只要使用了回调函数,实际上就是在使用闭包。

4. IIFE(立即执行函数表达式)创建闭包, 保存了全局作用域window当前函数的作用域,因此可以全局的变量。例如:

var a = 2;
(function IIFE(){
  // 输出2
  console.log(a);
})();

如何解决下面的循环输出问题?

for(var i = 1; i <= 10; i++){
  setTimeout(function fn(){
    console.log(i)
  }, 0)
}
//输出10个11

如何改进让其输出为 1 ~ 10?

因为setTimeout为宏任务,由于JS中单线程eventLoop机制,在主线程同步任务执行完后才去执行宏任务,因此循环结束后setTimeout中的回调才依次执行,但输出i的时候当前作用域没有,往上一级再找,发现了i,此时循环已经结束,i变成了11。因此会全部输出11。

解决方法:

1. 利用IIFE(立即执行函数表达式)当每次for循环时,把此时的i变量传递到定时器中

for(var i = 1;i <= 10;i++){
  (function(j){
    setTimeout(function fn(){
      console.log(j)
    }, 0)
  })(i)
}

2. 给定时器传入第三个参数,作为timer函数的第一个函数参数

for(var i = 1;i <= 10; i++){
  setTimeout(function fn(j){
    console.log(j)
  }, 0, i)
}

3. 使用ES6的let

for(let i = 1; i <= 10; i++){
  setTimeout(function fn(){
    console.log(i)
  },0)
}

let的出现让JS有函数作用域变为了块级作用域,用let后作用域链不复存在。

第五章: 谈谈你对原型链的理解

1.原型对象和构造函数有何关系?

在JavaScript中,每当定义一个函数数据类型(普通函数、类)时候,都会天生自带一个prototype属性,这个属性指向函数的原型对象。

当函数经过new调用时,这个函数就成为了构造函数,返回一个全新的实例对象,这个实例对象有一个__proto__属性,指向构造函数的原型对象。

2.能不能描述一下原型链?

JavaScript对象通过__proto__ 指向父类对象,直到指向Object对象为止,这样就形成了一个原型指向的链条, 即原型链。

第六章: 常见算法以及解题思路(一)

如果你忍着性子看到这里,先谢谢你能看我写这么多。

下面就让我们一期来看一下那些面试中常见的算法题以及解题技巧吧。

PS:涉及到的算法思想以及题量太过庞大,而且内容过于繁多,一篇文章不可能讲完,所以单独拆分,找几个典型,然后写出解题思路,毕竟授人以鱼不如授人以渔。以下正文。

爬楼梯:

假设你正在爬楼梯。需要n阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。
你有多少种不同的方法可以爬到楼顶呢?
注意:给定n是一个正整数。

示例1:
输入: 2
输出: 2
解释: 有两种方法可以爬到楼顶。
1.  1 阶 + 1 阶
2.  2 阶

示例2:
输入: 3
输出: 3
解释: 有三种方法可以爬到楼顶。
1.  1 阶 + 1 阶 + 1 阶
2.  1 阶 + 2 阶
3.  2 阶 + 1 阶

这是一道超级高频而且经典的考题,典型的动态规划类型题。

本问题其实常规解法可以分成多个子问题,爬第n阶楼梯的方法数量,等于 2 部分之和 

  • 爬上 n - 1 阶楼梯的方法数量。因为再爬1阶就能到第n阶 
  • 爬上 n - 2 阶楼梯的方法数量,因为再爬2阶就能到第n阶 

所以我们可以得到公式 dp[n] = dp[n-1] + dp[n-2]。

同时需要初始化 dp[0] = 1和 dp[1] = 1。

时间复杂度:O(n)。

//解题思路代码如下:
function numWays(n) {
    const dp = [];
    dp[0] = 1;
    dp[1] = 1;
    for (let i = 2; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    return dp[n];
}

这么写是完全没有问题的,当然如果你想给面试官秀一波操作,你还可以这样写:

function numWays(n) {
    // 尾调用
    // i 初始值为0级台阶跳法
    // j 初始值为1级台阶跳法 
    const helper = (x, i = 1, j = 1) => {
        if (x <= 1) return j;
        return helper(x-1, j, (i+j)%1000000007);
    }
    return helper(n);
};

---------------------------------------------分割线------------------------------------------

无重复字符的最长子串:

给定一个字符串,
请你找出其中不含有重复字符的最长子串的长度。

示例 1:输入:s = "abcabcbb"
输出:3
解释:因为无重复字符的最长子串是 "abc",
     所以其长度为 3

示例 2:输入:s = "pwwkew"
输出:3
解释:因为无重复字符的最长子串是 "wke",所以其长度为 3。
     请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。
示例 3:输入:s = ""
输出:0
提示:0 <= s.length <= 5 * 104s 由英文字母、数字、符号和空格组成

解题思路:

  • 核心思路是:创建一个map的数据结构存储字符串,其中i指针标记无重复的字符串,j指针为遍历字符串;如果遇到相同的字符,就将i指针移动到相同字符串的后一位,同时更新i的值

  • 时间复杂度为O(n) 空间复杂度为O(n)

代码如下:

function strLen(s) {
    let map = new Map();
    let res = 0;
    for (let i = 0, j = 0; j < s.length; j++) {
        if (map.has(s[j])) i = Math.max(map.get(s[j]) + 1, i);
        map.set(s[j], j);
        res = Math.max(res, j - i + 1);
    }
    return res;
}

 ---------------------------------------------分割线------------------------------------------

K个一组翻转链表:

给你一个链表,每 k 个节点一组进行翻转,请你返回翻转后的链表。

k 是一个正整数,它的值小于或等于链表的长度。

如果节点总数不是 k 的整数倍,那么请将最后剩余的节点保持原有顺序。

示例:给你这个链表:1->2->3->4->5

当 k = 2 时,应当返回: 2->1->4->3->5

当 k = 3 时,应当返回: 3->2->1->4->5

说明:你的算法只能使用常数的额外空间。
     你不能只是单纯的改变节点内部的值,
     而是需要实际进行节点交换。

解题思路:

  • 首先判断传入的链表的长度是否大于等于k,如果不是,则返回原始链表
  • 满足第一个条件的时候,通过三个指针pre、cur、next,分别代表上一个指针、当前指针、下一个指针
  1. 第一步、将cur指向的下一个节点保存到next;
  2. 第二步、将cur的下一个节点指向pre;
  3. 第三步、将cur与pre的指针往后移动一步;
  4. 第四步、重复前面三个步骤直到k循环次数结束;
  5. 最后可以发现pre始终都是传入链表的表头,cur指向下一个要处理的部分,传入的head就是链表的表尾;
  • cur就是新一组需要处理的链表的表头,再将传入的head指向递归处理的结果,将处理的链表连接起来即可。

代码如下:

var reverseKGroup = function(head, k) {
    let pre = null, cur = head;
    let p = head;
    // 下面的循环用来检查后面的元素是否能组成一组
    for(let i = 0; i < k; i++) {
        if(p == null) return head;
        p = p.next;
    }
    for(let i = 0; i < k; i++){
        let next = cur.next;
        cur.next = pre;
        pre = cur;
        cur = next;
    }
    // pre为本组最后一个节点,cur为下一组的起点
    head.next = reverseKGroup(cur, k);
    return pre;
};

先写这么多吧,后续的算法和知识体系留到下次再更,码字真的累,能看完本文就是对我最大的支持了。感谢!