(五)JS 面试题详解(2024)

209 阅读23分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第 5 天,点击查看活动详情

写过最多的是 JS 相关的文章,做过最完整的是 JS 的思维导图,敲过最多的依然是 JS 代码,我觉得自己的 JS 还算可以了。写到这里的时候,我已经离职一周,也参加了几次面试,大多数问题都能按自己的理解回答上来,同时,也让我意识到,那只是我以为的可以,只是没有遇到真正厉害的面试官罢了 ~ 写 JS 面试题之前,我纠结了好多次,我已经看过很多优秀的相关文章了,也写过很多各种各样的笔记了,还有没有必要再重复写。面完几次之后,有过正确解答,也有一知半解。有些问题让我意识到,这不是一次重复,而是一次重新认识,一次对原有认知的升华。同时,要时刻提醒自己,不能局限于你知道的原理,局限于我能说出这个概念那个定义,而是能够熟稔的把每个简单的知识点融会贯通于我们开发的实际过程中(快速上手也是如此),能够在贴合实际的情况下,从容应对变幻莫测的面试官。

内容可能比较多,也免不了一些错误,这也很让自己庆幸,这些认知错误,帮我不断刷新自己的知识上限。来吧,我是 huohuo ,一起加油!

​ 注:这里的内容是由 曾经的笔记 + 开发经验 + 新的理解 总结而成,持续更新 ing......

关于JS的历史发展,JS的基本语法,大家自行上W3C即可,这里不做赘述。

与 JS 的初遇

想必大家与 JS 牵手的次数早已不下千遍万遍,你熟悉了她春夏秋冬的手心温度,抑或是她平静或激动时的呼吸频率,但,你是否还记得,第一次与她相见的,那份心动 ~

一切开始于,为什么要接触 JS ,显然我们需要 JS 帮助我们完成什么

JS 的作用

  • 表单动态校验(密码强度检测) (JS产生最初的目的  )
  • 网页特效
  • 服务端开发(Node.js)
  • 桌面程序(Electron)
  • App(Cordova)
  • 控制硬件-物联网(Ruff)
  • 游戏开发(cocos2d-js)

我们都知道三大前端语言,HTML/CSS/JS,密不可分,你是如何理解他们之间的关系呢

HTML/CSS/JS 的关系

一句话概括,很好理解

HTML就是我们的身体结构(决定网页结构和内容),CSS就是我们穿的衣服和化的妆(页面如何呈现),而JS 就是我们的各种行为动作(页面控制和业务逻辑)

说到关系,当然少不了浏览器跟JS的关系啦!

浏览器执行 JS 简介

浏览器分成两部分:渲染引擎和JS引擎

  • 渲染引擎:用来解析HTMLCSS,俗称内核(如chrome浏览器的blinkwebkit
  • JS引擎:也称为JS解释器。 用来读取网页中的JS代码,对其处理后运行(如chrome浏览器的V8

浏览器本身并不会执行JS代码,而是通过内置JavaScript引擎来解析 (逐行解析)JS代码 ,然后由计算机去执行,所以JavaScript语言归为脚本语言,会逐行解释执行。

JS 的组成

image-20220414194911377

(1)ECMAScript

ECMAScriptECMAScript规定了JS的编程语法和基础核心知识,是所有浏览器厂商共同遵守的一套JS语法工业标准。(参考链接

(2)DOM——文档对象模型

文档对象模型(Document Object Model,简称DOM),是W3C组织推荐的处理可扩展标记语言的标准编程接口。

通过DOM提供的接口可以对页面上的各种元素进行操作(大小、位置、颜色等)。

(3)BOM——浏览器对象模型

BOM (Browser Object Model,简称BOM) 是指浏览器对象模型,它提供了独立于内容的、可以与浏览器窗口进行互动的对象结构。通过BOM可以操作浏览器窗口,比如弹出框、控制浏览器跳转、获取分辨率等。

输入输出

  • alert(msg):浏览器弹出消息框给用户
  • console.log(msg):开发时控制台打印运行信息
  • prompt(info):浏览器弹出的输入框,可供用户做输入操作

我们的初遇看起来是如此的简单,而她的美妙也让我们心里一眼定终身的感觉焕然升起。在彼此甜蜜无间的时候,请不要忘记最初的衷心。让我们一起慢慢回味......

数据类型

说到数据类型,想必大家脑海中马上会涌现出这类常见面试题:JS有哪些数据类型?怎么判断这些数据类型?假如,我起手问你的是这样,你会如何回答呢。

为什么需要数据类型

在计算机中,不同的数据所需占用的存储空间是不同的,为了便于把数据分成所需内存大小不同的数据,充分利用存储空间,于是定义了不同的数据类型

有同学听到存储空间,可能会马上想到——变量,跟存储的关系

JS 数据类型分类

JS 的数据类型到底有哪些?我们都知道有两种,有人说是简单数据类型和复杂数据类型,有人说是基本类型和引用类型,但是我更认同:原始类型对象类型,这些名词你都可以在红宝书中找到他们的身影,但是我们只需要牢记这两类就可以啦!

6 种原始类型:

NullUndefinedBooleanNumberStringSymbol

对象(Object )类型

  • ObjectArray、RegExpDateFunction

note:Null(空),代表此处不该有值得存在。Undefined(不存在),运行期才知道是否存在

JS 中的变量与数据类型

变量是用来存储值的所在处,它们有名字和数据类型。变量的数据类型决定了如何将代表这些值的位存储到计算机的内存中。JavaScript是一种弱类型或者说动态语言。这意味着不用提前声明变量的类型,在程序运行过程中,类型会被自动确定。

var age = 10; // 这是一个数字型
var name = "huohuoit"; // 这是一个字符串

在代码运行时,变量的数据类型是由JS引擎 根据 = 右边变量值的数据类型来判断 的,运行完毕之后, 变量就确定了数据类型。也意味着相同的变量可用作不同的数据类型。

如果有人问你,你是如何理解 JS 是一门动态类型语言或者 JS 变量可以用作不同类型的,上面的解释或许可以帮助到你。

另外,我们把变量的数据分为 原始值 和 引用值

原始值

  • 原始类型
  • 按值访问
  • 操作变量实际值

引用值

  • 对象类型
  • 按引用访问
  • 操作对象的引用(指针)

不同数据类型的坑

1、原始类型存储的都是值,是没有函数可以调用的

这个问题最开始出现在我操作数据的时候发生的,如a.toString(),我对数据a调用了toString()方法,但是有时候会报如下错误,我想你可能也在哪见过

image-20220414195424720

这个报错是不是很眼熟呢?a 虽然被定义了,但是没有初始化,所以值为UndefinedUndefined是原始类型哦,只是个值,它可没有函数可调用。

2、类型强制转换

我们再来看一个例子

image-20220414195451287

为什么这里不会报错呢?因为发生了类型强制转换,'1' 在这里已经不是原始类型了,而是被转换成了String类型(对象类型),所以可以调用toString()函数。

Why ?因为JS是一门动态类型语言,JS引擎会在运算时根据运算情景为变量设定类型。

我们把类型转换分为显式和隐式。

  • 显式就是光明正大的转换啦,比如toString()String()parseInt()parseFloat()Number()
  • 隐式就可能发生在if语句、逻辑语句、数学运算逻辑、== 等情况下。

了解就好,切记不要钻牛角尖哇,毕竟JS还有有些坑的!比如说下面这个

3、0.1 + 0.2 != 0.3

简单来说,就是JSNumber类型的精度问题。 计算机都是通过二进制来存储的,而 0.1 在二进制中是无限循环小数,而JS采用 IEEE 754 双精度版本(64 位),且JS的浮点数标准会截断数字(如截断后为 0.100000000000000002),所以 0.1 + 0.2 经过转换是不等于 0.3 的。

那怎么解决呢?这里采用原生方式做处理

浮点数转整数的思路:0.1+0.2 => (0.110+0.210)/10

parseFloat((0.1 + 0.2).toFixed(10)) === 0.3; // true

永远不要测试某个特定的浮点值!

如何判断 JS 数据类型

typeof 操作符

  • 可判断原始类型、ObjectFunctionSymbol(主要用来判断原始类型)
  • 注:这个方法判断对象类型和Null返回的都是Object

image-20220414195636268-

instanceof 操作符

  • 可以比较方便地判断具体的对象类型(也即判断一个引用类型是否属于某构造函数)
  • 在继承关系中判断一个实例是否属于它的父类

image-20220414195705335

如果你对它的底层原理感兴趣:

Lproto顺着原型链查找,看是否能找到对应Rprototype,找到了就返回true

function instance_of(L, R) { // L (a),R (Array)
  var O = R.prototype; // 取 R 的显示原型
  L = L.**proto**; // 取 L 的隐式原型
  while (true) {
    if (L === null)
      return false;
    if (O === L) // 当 O 显式原型 严格等于 L 隐式原型 时,返回 true
      return true;
    L = L.**proto**;
  }
}

Object.prototype.toString.call(数据)

  • 原始类型和各种对象类型都能判断

image-20220414195852380

我们常为此做一个方法封装

function getType(obj) {
  const type = typeof obj;
  // 判断是否是基础数据类型
  if (type !== "object") {
    // 是的话直接返回
    return type;
  }
  // 是复杂数据类型:通过 Object.prototype.toString.call(obj) 得到 [object obj],
  // 使用正则拿到 obj 的值即为该数据的类型
  return Object.prototype.toString
    .call(obj)
    .replace(/^\[object (\S+)\]$/, "$1"); // 注意正则中间有个空格
}

经典面试题 1:如何判断一个数据是数组

  • 如上,instanceof
  • 如上,Object.prototype.toString.call()
  • ES6Array.isArray()

经典面试题 2:如何判断一个对象是否为空对象

首先要区分一个概念,空对象空引用

  • 空对象:简单理解就是不含任何属性的对象,{ }
  • 空引用:变量值指向null(obj = null)

JSON 字符串判断(一般用在接收处理后台数据)

let data = {}; // 拿到的数据
let b = (JSON.stringify(data) == "{}"); 转字符串判断是否为 {}
console.log(b); //true

for in 循环判断 (遍历原型及自身上的可枚举属性,需要结合hasOwnProperty去除原型上的可枚举属性)

let data = {};
function isEmptyObj(obj) {
  for (let key in obj) {
    if ({}.hasOwnProperty.call(obj, key)) return false;
  }
  return true;
}
console.log(isEmptyObj(data));

③ Object.getOwnPropertyNames() (获取对象属性名,来判断是否有属性)

let data = {};
let arr = Object.getOwnPropertyNames(data); // 拿到数据的属性名并以数组对象的形式返回
console.log(arr.length == 0); // true (根据数组长度判断是否存在属性)

④ ES6 的 Object.keys() (同上,返回属性名数组)

let data = {};
let arr = Object.keys(data);
console.log(arr.length == 0); // true

一个特殊的 Number

NaN(Not a Number),不是数值,意思是本来要返回数值的操作失败了(非抛错)

特性:

  • 任何涉及NaN 的操作始终返回NaN
  • NaN不等于NaN在内的任何值

isNaN()函数:任何不能转换为数值的值都被导致这个函数返回true

一个有趣的题目

唔,是不是看累了,来点好玩的,放松下哈! ~~

[] == false; //true 对象 => 字符串 => 数值 0 false 转换为数字 0,
![] == false; //true

第二个运算前边多了个 !,我们要先将 [] 转换为布尔值再取反,转换为布尔值时,空字符串('')、NaN0nullundefined 这几个外返回的都是true, 所以[] => true, 取反为false,故![] == falsetrue

一个 JS 运算符优先级问题

有一次在群里遇到群友问的一个问题,有不少同志都不明所以,我们来看看

let num = 10;
const sub = ++num + --num;
console.log(num); // ?
console.log(sub); // ?

打印出来是多少呢?先想一想

这里我再先放出JS运算符优先级的一张图

image-20220414200215282

可以明显看到,优先级最高的是 (),所以你可以很快的想到这样计算(以下计算按顺序进行)

        (--num) : num = 10 - 1 = 9, return 9
        ++num : num = 9 + 1, return 10
        last return 9 + 10 = 19,
        // 打印 num = 10, sub = 19

又或者你这样计算

        ++num : num = 10 + 1, return 11
        (--num) : num = 10 - 1 = 9, return 9
        last return 9 + 11 = 20,
        // 打印 num = 9, sub = 20

很不幸,都错了。纳尼 (ÒωÓױ)!

这里大家很容易被运算符的优先级误导(你可能没有真正理解优先级运算)

  1. JS运算是从左向右的
  2. 产生优先级高低比较的前提是值两边都有运算符 3.++val ==》val + 1val = val + 1

现在我们按着这个规则再试试

        ++num : num = 11, return 11 // ++ 优先级高于 +
        (--num) : num = 11 - 1 = 10, return 10 // () 优先级高于 +
        last return 11 + 10, // 最后再 +
        // 打印 num = 10, sub = 21

作用域

这一块的经典面试题还真不少,在这我主要想讲的是这三大块:执行上下文、作用域链、闭包

JS 执行上下文

JS 可执行代码包含了全局代码(全局上下文)、函数代码(函数上下文)、eval 代码(eval上下文),而执行上下文(简称上下文)就是代码执行过程中非常重要的概念。

概念:当JS代码执行一段可执行代码时,就会进行准备工作,这里的“准备工作”,就叫做执行上下文,它决定了我们的变量或函数可以访问哪些数据以及他们的行为

上下文的重要属性有三个:变量对象、作用域链、this

这里我们先关注变量对象(Variable object,VO)

  1. 变量对象是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明。
  2. 全局上下文中的变量对象就是全局对象(AO)。全局上下文的变量对象初始化是全局对象。
  3. 函数上下文的变量对象(VO)初始化只包括Arguments对象。
  4. 在浏览器中,全局对象就是window对象,是由Object构造函数实例化的一个对象,预定义了一堆函数和属性,且有window属性指向自身。
  5. AO = VO + function parameters + arguments

代码的执行必定有开始与结束,所以执行上下文也有个生命周期

  1. 创建阶段:创建变量对象,建立作用域链,以及确定this的指向(注意未进入执行阶段之前,变量对象中的属性都不能访问!)
  2. 代码执行阶段:完成变量赋值(会再次修改变量对象的属性值),函数引用,以及执行其他代码
  3. 执行完后出栈,等待被回收

上下文中的代码在执行的时候,会创建变量对象的一个作用域链,这个作用域链决定了各级上下文中的代码在访问变量和函数时的顺序。

作用域链

我们先来看看作用域(变量和函数才有)

  • 概念:指程序源代码中定义变量的区域
  • 作用:规定了如何查找变量,也就是确定当前执行代码对变量的访问权限
  • JS采用词法作用域,也就是静态作用域。函数的作用域在函数定义的时候就决定了

作用域链

当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。

梳理一下:JS 如何查找变量?

当前上下文的VO ——>父级上下文的VO——>全局对象AO,这个查找链就是作用域链

this (这一块我们放到后面专门讲)

闭包

很多人都知道闭包,但是对它的概念有点模糊不清,其实就是:

闭包 = 函数 + 函数能够访问的自由变量

注:自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。

闭包是可以访问外部作用域的内部函数(延伸变量的作用范围),即使这个外部作用域已经执行结束。闭包就是内部函数,我们可以通过在一个函数内部或者 {} 块里面定义一个函数来创建闭包。

其中我们需要注意以下几点:

  • 闭包的外部作用域是在其定义的时候已决定,而不是执行的时候。
  • 变量的生命周期取决于闭包的生命周期
  • 闭包只存储外部变量的引用,而不会拷贝这些外部变量的值

应用场景(这些我会在后面分别展开详细介绍):

  • 在异步任务例如timer定时器,事件处理,Ajax请求中被作为回调,HTML5 GeolocationWebSockets, requestAnimationFrame()
  • 被外部函数作为返回结果返回,或者返回结果对象中引用该内部函数
  • 封装性
  • 在函数式编程中的应用
  • 装饰器函数
  • 垃圾回收

面试官发难:请用闭包实现说明一个私有变量

实现私有变量,我们可以靠约定(变量名前加_)、Proxy代理、Symbol数据结构,而最流行的当然是闭包啦,我们来举例看看吧 ~

function fun() {
  let id = "huohuo";
  this.getId = function () {
    return id;
  };
  this.setId = function (val) {
    id = val;
  };
}
const wg = new fun();
console.log(wg.getId()); // huohuo
wg.setId("huohuoNB");
console.log(wg.getId()); // huohuoNB
console.log(id); // id is not defined

通过打印我们可以看到,**函数 fun(闭包)**内的变量id只有getId函数 和setId函数 能访问到,外部无法访问

上面我们说了作用域链,JS中还有一条有名的链——原型链,它们有什么区别呢?

注意到了吗,这条作用域链的形成源于对变量的查找

温馨提示:JS变量是存储任意数据值的命名占位符(一个存储空间的名字!)

所以作用域链就是查找变量或者说标识符的,而原型链是用来查找对象的属性的

在着手原型链之前,我们先来做一些准备工作,方便更清晰地了解它

构造函数和原型

JS中,万物皆对象,而对象,皆出自构造(构造函数),而所有的函数,都有一个特殊的属性prototype(原型)

ES6之前 ,对象不是基于类创建的,而是用一种称为构建函数的特殊函数来定义的

构造函数

开发中,我们会把一个对象中的可共用的属性或方法提取出来,并为这些成员赋初始值。通常我们还要保证在使用他们的时候,不会对原有的成员产生修改。这就产生了构造函数(用来创建新对象的函数)。

先来看看开发时我们编写的构造函数是个什么样子

// 函数声明形式
function Person() {}
// 函数表达式形式
let Person = function () {};

关于它的使用:

  • 用于创建某一类对象,其首字母要大写
  • 要和new一起使用才有意义(new + 构造函数)

构造函数与函数的区别?

唯一区别:调用方式不同,一个是通过new调用,一个直接调用

这时候,残忍的面试官又蹦出来了,哈哈哈哈 hiahiahiahia!

new 操作符在执行时做了哪些事情,你可以手写一个 简单的 new 吗?

new操作符会创建一个被定义的对象类型的实例或具有构造函数的内置对象类型

new 在执行时会做四件事情

  1. 在内存中创建一个新的空对象
  2. 设置原型链,将构造函数的原型对象设为新对象的原型
  3. 让构造函数中的 this 指向新对象,并执行构造函数(给新对象添加属性)
  4. 返回这个新对象(这就是构造函数里面不需要 return 的原因)
function myNew (constrc, ...args) {
  let obj = {}; // 1. 创建一个空对象
  obj.**proto** = constrc.prototype; // 2. 将 obj 的**proto**属性指向构造函数的原型对象
  let res = constrc.apply(obj, args); // 3. 将构造函数 constrc 执行的上下文 this 指向 obj,并执行
  return res instanceof Object ? res : obj; // 4. 确保返回一个对象(res 为空则返回新对象)
}

上面是为了我们充分理解原理,其实第二步的写法有问题(proto 本质上是个getter/setter

我们也可以将第一跟第二步代码简化

function myNew(constrc, ...args) {
  let obj = Object.create(constrc.prototype); // 以构造函数的 prototype 属性为原型,创建新对象
  let res = constrc.apply(obj, args); // 将构造函数 constrc 执行的上下文 this 指向 obj,并执行
  return res instanceof Object ? res : obj; // 确保返回一个对象(res 为空则返回新对象)
}

上面我们使用了一个Object.create()方法,常用来创建一个新对象,或者做对象的浅拷贝

// 模拟实现一下
function create(prototype) {
  function F() {}
  F.prototype = prototype;
  return new F();
}

note:有一种设计模式叫工厂模式(一个可添加属性和方法的函数,返回一个对象),用于抽象创建多个类似对象。通过上面的分析,我们可以知道,构造函数一样可以创建多个类似对象,而且,还可以(通过consturctor)确保实例对象的类型

好了,我们new完一个女朋友(对象)后,就跟看看我们这个女朋友到底是咋样的,好不好看,有没有才,家庭背景又是如何?那就得问问第二步中这个关键的牵线媒婆(prototype)了。

prototype(原型)

prototype属性其实是一个对象(原型对象),它的方法和属性都可以被函数的实例所共享。所谓的函数实例是指以函数作为构造函数创建的对象(就是new出来的女朋友没错啦),这些对象实例都可以共享构造函数的原型的方法和属性(比如女娲捏的女朋友们,都有固定的女性特点)。

想想看,我们辛苦的女娲娘娘,给我们捏那么多女朋友,多累啊(还很费内存哇)。聪明的你肯定会想到,捏一个模板,然后基于这个模板一直复制共享的东西过去就方便多啦。呐,你要的prototype就出来啦!通过它,我们找到模板所在(原型对象),就可以知道女朋友的身体组织器官(属性)和她们特有的女性性格魅力(方法)

另外我们还需要知道三个关键概念:

  • prototype:让函数实例化的对象都可以找到共享的属性和方法
  • proto:指向构造函数的prototype指向的原型对象
  • constructor:原型对象的属性,指回构造函数

接下来,我们再通过一张图来理清构造函数、实例对象、原型对象三者之间的关系

image-20220414201041689

原型链

原型链就是一条实例与原型之间的关系路线。具体查找机制如下:

  1. 当访问一个对象的属性或方法时,首先查找这个实例对象自身(ldh)有没有该属性。
  2. 如果没有就查找它的原型(也就是它的  proto指向的Star的原型对象prototype)。
  3. 如果还没有就查找原型对象的原型( proto指向的Object的原型对象prototype)。
  4. 如果还是找不到,就会返回nullproto指向的Object的原型对象的原型是null

proto 的意义就在于为对象成员查找机制提供一个方向,看图

image-20220414201157616

做一个面试时的简要回答

原型链的原型搜索机制:对象自身,到该对象的原型,到Object的原型,再到null

当然,如果你能结合prototypeprotoconstructor来做更细致的回答,那一定是个加分项!(图已经给你了,我相信你一定没问题的!)

more(了解):

  • protoconstructor属性是对象所独有的;
  • prototype属性是函数所独有的,因为函数也是一种对象,所以函数也拥有 protoconstructor属性。
  • 我们可以从图中发现,查找路线都是通过 proto 这个属性,它的作用就是为这条查找路线提供方向。

修改原生对象原型

在实际的开发过程中,我们有一个封装过的JS文件,里面写了一个构造函数(作为一个对象原型),为了给它定义新的方法,我们可以修改它的原生对象原型。如下:

// 构造函数
function Huohuo(opt) {
  const opt = opt || {};
  this.name = opt.name || "";
  this.age = opt.age || 3;
}
// 修改对象原型,添加新方法
Huohuo.prototype.huoFun = function () {
  const that = this;
  console.log("I am " + that.name);
};

开发环境下我们做封装可以这么写,但是在生产环境下修改原生对象原型,可能会造成误会喔!(怎么写着写着把我原型都给改了?)还可能会引发命名冲突(你这个名字我取过了,你还取?),同样可能会意外重写原生的方法(啥意思啊,农民把我地主给干了?)。

那可怎么办呢:创建一个自定义的类,继承原生类型

继承

继承的方式不少,比如原型链继承、构造函数继承、组合继承、寄生式继承、寄生式组合继承以及上个问题最后提到的类实现继承。这部分内容其实在一年前已经写过,由于篇幅原因,这里就不重复了。所以在原有文章基础上,做了一些修改。(请阅读:JS-继承

这一块我们能够理解原理最好了,实际上可能不会用到。因为我们有一个众所周知的软件设计原则:“Composition over Inheritance(复合胜过继承)”,复合模式的代码实际能给我们提供极大的灵活性。