前端面试-JavaScript

2021/5/10 InterviewJavaScript

# JS为什么单线程

一个简单的原因就是,js在设计之初只是进行一些简单的表单校验,这完全不需要多线程,单线程完全可以胜任这项工作。即便后来前端发展迅速,承载的能力越来越多,也没有发展到非多线程不可的程度。

而且还有一个主要的原因,设想一下,如果js是多线程的,在运行时多个线程同时对DOM元素进行操作,那具体以哪个线程为主就是个问题了,线程的调度问题是一个比较复杂的问题。

HTML5新的标准中允许使用new Worker的方式来开启一个新的线程,去运行一段单独的js文件脚本,但是在这个新线程中严格的要求了可以使用的功能,比如说他只能使用ECMAScript,不能访问DOM和BOM。这也就限制死了多个线程同时操作DOM元素的可能。

# 说一下解构

  1. 数组

    let [a, b, c] = [1, 2, 3];
    let [a, [[b], c]] = [1, [[2], 3]];
    let [a, , b] = [1, 2, 3];
    let [a = 1, b] = []; // a = 1, b = undefined
    let [a, ...b] = [1, 2, 3]; // a = 1, b = [2, 3]
    let [a = 3, b = a] = [1];    // a = 1, b = 1
    
    1
    2
    3
    4
    5
    6
  2. 对象

    let { foo, bar } = { foo: 'aaa', bar: 'bbb' };
    let { baz : foo } = { baz : 'ddd' }; // 重命名 foo = 'ddd'
    let {a, b, ...rest} = {a: 10, b: 20, c: 30, d: 40}; // rest = {c: 30, d: 40}
    let {a: aa = 10, b: bb = 5} = {a: 3}; // aa = 3; bb = 5;
    
    1
    2
    3
    4
  • 说一下剩余参数,他是如何使用的?

    如果函数的最后一个命名参数以...为前缀,则它将成为一个数组,其中从当前位开始到结束的元素由传递给函数的实际参数提供。

    剩余参数和 arguments 对象之间的区别主要有三个:

    • 剩余参数只包含那些没有对应形参的实参,而 arguments 对象包含了传给函数的所有实参。
    • arguments对象不是一个真正的数组,而剩余参数是真正的 Array 实例,也就是说你能够在它上面直接使用所有的数组方法,比如 sortmapforEachpop
    • arguments对象还有一些附加的属性 (如callee属性)。

# JavaScript的原始值和引用值

  1. 原始值

    • number
    • string
    • boolean
    • undefined
    • null
    • symbol(ES6新增)
    • bigint(ES10新增)
  2. 引用值

    • object
    • array
    • function
    • date
    • regexp
  • null是对象吗?为什么?

    在js中,变量由类型标签和变量值组成。对象的类型标签为0。而null是一个空指针,在js最初版本使用32位系统,会使用低位存储变量的类型信息,而null也是以000开头,因此null的类型标签也为0,会被识别为对象。但null是全0,这是一个bug。

  • instanceof能否判断基本数据类型?

  • 首先typeof能够判断基本数据类型,但是除了nulltypeof null返回的是object

  • 但是对于对象来说typeof不能准确判断类型,typeof 函数会返回function,除此之外全部都是object,不能准确判断类型。

  • instanceof可以判断复杂数据类型,基本数据类型不可以。

  • instanceof是通过原型链来判断的,用于检测构造函数的prototype属性是否出现在某个实例对象的原型链上。A instanceof B,在实例对象A的原型链中层层查找,是否有原型等于构造函数B的prototypeB.prototype),如果一直找到A的原型链的顶端(null,即Object.prototype._proto_),仍然不等于B,那么返回false,否则返回true

  • 手动实现instanceof的功能

    instanceof的实现

  • 获取元素类型

    const dataType = obj => Object.prototype.toString.call(obj).replace(/^\[object (.+)\]$/, '$1').toLowerCase();

# 判断是否是移动端

// 移动端如果没有指定事件值为null,而PC端是undefined
const isMobile = () => 'ontouchstart' in window
1
2

# 禁止网页复制粘贴

const html = document.querySelector('html')
html.oncopy = () => false
html.onpaste = () => false
// 启用返回true
1
2
3
4

# addEventListener的第三个参数

target.addEventListener(type, listener, options);
target.addEventListener(type, listener, useCapture);
target.addEventListener(type, listener, useCapture, wantsUntrusted); // Gecko/Mozilla only
1
2
3
  • type:表示监听事件类型的字符串。

  • listener:事件处理函数。

  • 第三个参数可以是一个boolean值,也可以是一个对象。那些不支持参数options的浏览器,会把第三个参数默认为useCapture,即设置useCapturetrue

    在旧版本的DOM的规定中,addEventListener()的第三个参数是一个布尔值,表示是否在捕获阶段调用事件处理程序。随着时间的推移,很明显需要更多的选项。与其在方法之中添加更多参数(传递可选值将会变得异常复杂),倒不如把第三个参数改为一个包含了各种属性的对象,这些属性的值用来被配置删除事件侦听器的过程。

    options可用选项(默认都是false):

    • capture: boolean,表示listener会在该类型的事件捕获阶段传播到该EventTarget时触发。
    • once: boolean,表示listener在添加之后最多只调用一次。如果是truelistener会在其被调用之后自动移除。
    • passive: boolean,设置为true时,表示listener永远不会调用preventDefault()。如果listener仍然调用了这个函数,客户端将会忽略它并抛出一个控制台警告。
    • signal:AbortSignal,该AbortSignalabort()方法被调用时,监听器会被移除。
    • mozSystemGroup:只能在XBL或者是Firefox' chrome使用,这是个boolean,表示listener被添加到system group。

    useCapture(设为true)时,沿着DOM树向上冒泡的事件,不会触发listener。当一个元素嵌套了另一个元素,并且两个元素都对同一事件注册了一个处理函数时,所发生的事件冒泡和事件捕获是两种不同的事件传播方式。事件传播模式决定了元素以哪个顺序接收事件。如果没有指定,useCapture默认为false

    • true:事件句柄在捕获阶段执行。
    • false:默认。事件句柄在冒泡阶段执行。

    冒泡就是事件触发顺序从内到外,捕获就是事件触发顺序从外到内,如果有些DOM设置了捕获,有些没有,那么捕获的优先级更高,会先触发捕获再触发冒泡。

  • wantsUntrusted:如果为true,则事件处理程序会接收网页自定义的事件。此参数只适用于Gecko(chrome的默认值为true,其他常规网页的默认值为false),主要用于附加组件的代码和浏览器本身。

不支持冒泡的事件:

  • load
  • unload
  • focus
  • blur
  • mouseenter
  • mouseleave
  • resize

注意:事件流(Event flow)的执行顺序依次是事件捕获、事件触发、事件冒泡。事件触发是在冒泡阶段(默认)。

# 纯函数

纯函数是是指在相同的参数调用下,函数的返回值唯一不变。这跟数学中函数的映射关系类似,同样的x不可能映射多个不同的y。使用函数式编程会使得函数的调用非常稳定,从而降低Bug产生的机率。例如不能包含以下一些副作用:

  • 操作Http请求
  • 可变数据(包括在函数内部改变输入参数)
  • DOM 操作
  • 打印日志
  • 访问系统状态
  • 操作文件系统
  • 操作数据库

# 函数式编程

函数式编程是⼀种编程范式,它将程序看做是⼀系列函数的组合,函数是应⽤的基础单位。函数式编程主要有以下核⼼概念:

  1. 纯函数:函数的输出只取决于输⼊,没有任何副作⽤,不会修改外部变量或状态,所以对于同样的输⼊,永远返回同样的输出值。因此,纯函数可以有效地避免副作⽤和竞态条件等问题,使得代码更加可靠、易于调试和测试。
  2. 不可变性:在函数式编程中,数据通常是不可变的,即不允许在内部进⾏修改。这样可以避免副作⽤的发⽣,提⾼代码可靠性。
  3. 函数组合:函数可以组合成复杂的函数,从⽽减少重复代码的产⽣。
  4. ⾼阶函数:⾼阶函数是指可以接收其他函数作为参数,也可以返回函数的函数。例如,函数柯⾥化和函数的组合就是⾼阶函数的应⽤场景。
  5. 惰性计算:指在必要的时候才计算(执⾏)函数,⽽不是在每个可能的执⾏路径上都执⾏,从⽽提⾼性能。

函数式编程的核⼼概念是将函数作为基本构建块来组合构建程序,通过纯函数、不可变性、函数组合、⾼阶函数和惰性计算等概念来实现代码的简洁性、可读性和可维护性,以及⾼效的性能运⾏。

函数式编程有以下优势:

  1. 易于理解和维护:函数式编程强调数据不变性和纯函数概念,可以提⾼代码的可读性和可维护性,因为它避免了按照顺序对变量进⾏修改,并强调函数⾏为的确定性。
  2. 更少的bug:由于函数式编程强调纯函数的概念,它可以消除由于副作⽤引起的bug。因为纯函数不会修改外部状态或数据结构,只是将输⼊转换为输出。这么做有助于保持代码更加可靠。
  3. 更好的可测试性:由于纯函数不具有副作⽤,它更容易测试,因为测试数据是预测性的。
  4. 更少的重构:函数式编程使⽤函数组合和柯⾥化等⽅法来简化代码。它将⼤型问题分解为微⼩问题,从⽽减少了代码重构的需要。
  5. 避免并发问题:由于函数式编程强调不变性和纯函数的概念,这使得并发问题变得更简单。纯函数允许并⾏运⾏,因此,当程序在不同的线程上执⾏时,它更容易保持同步。
  6. 代码复⽤:由于函数是基本构建块,并且可以组合成更⾼级别的功能块,使⽤函数式编程可以更⼤程度上推崇代码复⽤,减少代码冗余。

函数式编程通过强调纯函数、不可变数据结构和函数组合等概念,可以提⾼代码可读性和可维护性,降低程序bug出现的⻛险,更容易测试,并且更容易将问题分解为更容易处理的⼩部分,更好地应对并发和可扩展性。

# 判断文本溢出,增加title或tooltip

<body>
  <div class="text">Lorem, ipsum dolor sit amet consectetur adipisicing elit. Dolores, soluta.</div>
  <div class="tooltip">Lorem, ipsum dolor sit amet consectetur adipisicing elit. Dolores, soluta.</div>

  <style>
    .text {
      width: 200px;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
      word-break: break-all;
    }

    .tooltip {
      font-size: 18px;
      line-height: 24px;
      font-weight: 600;
      opacity: 0;
      transition: opacity 1s ease-in-out;
    }
  </style>

  <script>
    const text = document.querySelector('.text')
    const tooltip = document.querySelector('.tooltip')
    text.addEventListener('mouseenter', showTooltip)
    text.addEventListener('mouseleave', hideTooltip)
    function showTooltip(e) {
      const range = document.createRange()
      range.setStart(text, 0)
      range.setEnd(text, text.childNodes.length)
      const width = range.getBoundingClientRect().width
      if (width > text.offsetWidth) {
        tooltip.style.opacity = 1
      }
    }
    function hideTooltip(e) {
      tooltip.style.opacity = 0
    }
  </script>
</body>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

document.createRange()创建一个Range (opens new window)对象,Range接口表示一个包含节点与文本节点的一部分的文档片段。

setStart setEnd 分别设置Range的起始位置,第二个参数表示偏移量,最终通过getBoundingClientRect获取Range的宽度,对比DOM的宽度就可以知道文字是否溢出(element-ui的table单元格溢出展示就是这样写的)

个人觉得也可以在鼠标事件里面去创建一个不可见且不会换行的DOM,把内容放进去,获取这个DOM的宽度,然后删除这个DOM

如果需要判断溢出的DOM容器里面的内容还有一层包裹,比如文字被span标签包含,父级的div标签设置了超出显示省略号,那么直接可以获取里面span标签的宽度进行判断

# for...in可以遍历数组吗

可以,但又不是完全可以

const arr = [1, 2, 3, 'a', 'b', 'c'];
Array.prototype.text = function () {};

arr.forEach((el, index) => {
  console.log(el, index, typeof el, typeof index);
});

for (const key in arr) {
  if (Object.hasOwnProperty.call(arr, key)) {
    console.log(key, typeof key);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

WARNING

  • 数组原型上的遍历方法拿到的索引类型是numberfor...in里面的keystring,因为对象的属性名都是字符串。
  • 如果没有Object.hasOwnProperty判断还会遍历到拓展的可枚举属性。
  • 遍历顺序是对象属性的枚举顺序,并不一定按数组的下标顺序遍历,可能造成乱序。

这也就是不要用for...in去遍历数组的原因,尽量选择数组自带的遍历方法,如果需要for...in退出循环的场景,可以选择for或者for...of

# 垃圾回收机制(GC)

垃圾回收机制

# Symbol()、Symbol.for()和Symbol.keyFor()

  • Symbol()是调用构造函数(不需要new),Symbol.for()Symbol.keyFor()Symbol的静态方法。
  • Symbol.for(key)方法会根据给定的键key,来从运行时的symbol注册表中找到对应的symbol,如果找到了,则返回它,否则,新建一个与该键关联的symbol,并放入全局symbol注册表中。
  • Symbol.keyFor(sym)方法用来获取全局symbol注册表中与某个symbol关联的键。如果全局注册表中查找到该symbol,则返回该symbol的key值,返回值为字符串类型。否则返回undefined
Symbol.for('foo'); // 创建一个symbol并放入symbol注册表中,键为"foo"
Symbol.for('foo'); // 从symbol注册表中读取键为"foo"的symbol

Symbol.for('bar') === Symbol.for('bar'); // true,证明了上面说的
Symbol('bar') === Symbol('bar'); // false,Symbol()函数每次都会返回新的一个symbol

var globalSym = Symbol.for("foo");
Symbol.keyFor(globalSym); // "foo"

var localSym = Symbol();
Symbol.keyFor(localSym); // undefined,
1
2
3
4
5
6
7
8
9
10
11

全局symbol注册表中的一个记录结构

字段名 字段值
[[key]] 一个字符串,用来标识每个symbol
[[symbol]] 存储的symbol

# window的onload事件和DOMContentLoaded谁先谁后

window.onload = function () {
  console.log('window load');
};
document.addEventListener('DOMContentLoaded', loaded, false);

function loaded() {
  console.log('document load');
}
1
2
3
4
5
6
7
8

一般情况下,DOMContentLoaded事件要在window.onload之前执行,当DOM树构建完成的时候就会执行DOMContentLoaded事件,而window.onload是在页面载入完成的时候才执行

jQuery的(document).ready()就是使用了DOMContentLoaded事件

# 打开新窗口监听其关闭然后刷新当前页面

let windowObjectReference = window.open(strUrl, strWindowName, [strWindowFeatures]);
1

参数

  • strUrl:要在新打开的窗口中加载的URL。
  • strWindowName:指定target属性或窗口的名称。_blank_parent_self_top
  • strWindowFeatures:一个可选参数,列出新窗口的特征(大小,位置,滚动条等)作为一个DOMString。

返回值:打开的新窗口对象的引用。如果调用失败,返回值会是null。如果父子窗口满足“同源策略”,你可以通过这个引用访问新窗口的属性或方法。

首先,将window.open打开的新窗口存到一个变量里,该方法会返回一个对象里面包含closed属性代表打开页面是否关闭。之后我们再利用定时器监听该属性是否变化,然后刷新当前页面并销毁定时器。

# 监听storage内的数据

参考地址:监听storage内的数据 (opens new window)

监听localstorageseesionstorage内的数据

当前页面使用的storage被其他页面修改时会触发StorageEvent事件。事件在同一个域下的不同页面之间触发,即在A页面注册了storge的监听处理,只有在跟A同域名下的B页面操作storage对象,A页面才会被触发storage事件。

function setStorage(key, val) {
  let storageEvent;
  if (key === 'watch') {
    // 创建一个事件
    storageEvent = document.createEvent('storageEvent');
  }
  const storage = {
    setItem: (itKey, itVal) => {
      sessionStorage.setItem(itKey, itVal);
      if (storageEvent) {
        // 初始化事件
        storageEvent.initStorageEvent('setItem', false, false, itKey, null, itVal, null, null);
        // 派发对象
        window.dispatchEvent(storageEvent);
      }
    },
  };
  return storage.setItem(key, val);
}

// 在A页面:
setStorage('watch', val);

// 在B页面即可获取:
window.addEventListener('setItem', () => {
  newVal = sessionStorage.getItem('watch');
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

# 获取多个值中真值的个数

/**
 * @description 获取真值的个数
 * @author fengchunqi
 * @date 2021-09-22 15:14:30
 * @param {array} bools
 * @return {number}
 */
function getTrueth(bools) {
  return bools.reduce((prev, cur) => prev + Boolean(cur), 0);
}
1
2
3
4
5
6
7
8
9
10

# 移动端click点击事件延时与穿透

事件触发顺序:touchstarttouchmovetouchendclick

为什么会产生click延时?

iOS中的safari,为了实现双击缩放操作,在单击300ms之后,如果未进行第二次点击,则执行click单击操作。也就是说来判断用户行为是否为双击产生的。但是,在App中,无论是否需要双击缩放这种行为,click单击都会产生300ms延迟。

为什么会产生click点击穿透?

双层元素叠加时,在上层元素上绑定touch事件,下层元素绑定click事件。由于click发生在touch之后,点击上层元素,元素消失,下层元素会触发click事件,由此产生了点击穿透的效果。

  • 解决方案一:使用touchstart替换click,在具有滚动的情况下,还是建议使用click处理,因为滚动还是会先触发touchstart
  • 解决方案二:使用fastclick库

# 哪些东西会阻塞页面加载

参考文章:

  1. 外部的js脚本

    解决办法

    • 放在底部(</body>前),虽然放在底部照样会阻塞渲染,但不会阻塞资源下载。
    • 根据需求使用async或者defer

    js的加载要在dom解析之前完成(DOMContentLoaded)

  2. 外部的css

    • css加载不会阻塞DOM树的解析
    • css加载会阻塞DOM树的渲染
    • css加载会阻塞后面js语句的执行

    因此,为了避免让用户看到长时间的白屏时间,我们应该尽可能的提高css加载速度,比如可以使用以下几种方法:

    • 使用CDN(因为CDN会根据你的网络状况,替你挑选最近的一个具有缓存内容的节点为你提供资源,因此可以减少加载时间)

    • 对css进行压缩(可以用很多打包工具,比如webpack、gulp等,也可以通过开启gzip压缩)

    • 合理的使用缓存(设置cache-control,expires,以及E-tag都是不错的,不过要注意一个问题,就是文件更新后,你要避免缓存而带来的影响。其中一个解决防范是在文件名字后面加一个版本号)

    • 减少http请求数,将多个css文件合并,或者是干脆直接写成内联样式,内联样式(把css代码直接写在现有的HTML标签中)的一个缺点就是不能缓存。

    • 当页面只存在css,或者js都在css前面,那么DomContentLoaded不需要等到css加载完毕。

    • 如果页面中同时存在css和js,并且存在js在css后面,则DOMContentLoaded事件会在css和js加载完后才执行。

    • 其他情况下,DOMContentLoaded都不会等待css加载,并且DOMContentLoaded事件也不会等待图片、视频等其他资源加载。

# getElementsByClassName的返回值能否遍历

答案是可以遍历

Element.getElementsByClassName()方法返回一个即时更新的(live)HTMLCollection (opens new window),包含了所有拥有指定class的子元素。

const nodes = document.getElementsByClassName('test');

for (let i = 0; i < nodes.length; i++) {
  console.log(nodes[i]);
}

for (const node of nodes) {
  console.log(node);
}

for (const key in nodes) {
  if (Object.hasOwnProperty.call(nodes, key)) {
    console.log(key, nodes[key]);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

但是无法使用数组方法进行迭代

nodes.forEach((node) => { // Uncaught TypeError: nodes.forEach is not a function
  console.log(node);
});
1
2
3

可以在任何HTMLCollection上面使用Array.prototype的方法,但是需要把HTMLCollection作为该方法的上下文对象(this)。

Array.prototype.forEach.call(nodes, (node, index) => {
  console.log(node, index);
});
// 或者直接转成数组
Array.from(nodes).forEach((node, index) => {
  console.log(node, index);
});
1
2
3
4
5
6
7

document.querySelectorAll的返回值又有何区别?

document.querySelectorAll()方法返回一个non-live的NodeList,它包含所有元素的非活动节点,该元素来自与其匹配指定的CSS选择器组的元素(基础元素本身不包括,即使它匹配)。

参考文章:NodeList 和 HTMLCollection之间的关系? (opens new window)

主要不同在于HTMLCollection是元素集合而NodeList是节点集合(即可以包含元素,也可以包含文本节点)。所以node.childNodes返回NodeList,而node.childrennode.getElementsByXXX返回HTMLCollection。唯一要注意的是querySelectorAll返回的虽然是NodeList,但是实际上是元素集合,并且是静态的(其他接口返回的HTMLCollection和NodeList都是live的)。 事实上,将来浏览器将增加queryAll接口取代现在的querySelectorAll,返回Elements是Array的子类(因而可以使用Array上的forEach、map等方法)。

const nodeList = document.querySelectorAll('.test');
console.log(Array.isArray(nodeList)); // false

nodeList.forEach((node, index) => {
  console.log(node, index);
});
// Chome v96.0.4664.110 直接用forEach方法是可以的 其他数组方法不行

nodeList.entries(); // Array Iterator {}
1
2
3
4
5
6
7
8
9

# 命名属性,数组索引属性与对象内属性

function Foo() {
  this[100] = 'test-100'
  this[1] = 'test-1'
  this["B"] = 'foo-B'
  this[50] = 'test-50'
  this[9] = 'test-9'
  this[8] = 'test-8'
  this[3] = 'test-3'
  this[5] = 'test-5'
  this["A"] = 'foo-A'
  this["C"] = 'foo-C'
}
1
2
3
4
5
6
7
8
9
10
11
12

我们创建一个Foo实例foofoo中有10个属性,我们遍历该对象并依次打印,可以发现打印的顺序与设置的顺序并不一致。对于整数型的key值,会从小到大遍历,对于非整数型的key值,会按照设置的先后顺序遍历。在V8中,前后者分别被称为数组索引属性(Array-indexedProperties)命名属性(NamedProperties),遍历时一般会先遍历前者。前后两者在底层存储在两个单独的数据结构中,分别用elementsproperties两个指针指向它们。

V8有一种策略:如果命名属性少于等于10个时,命名属性会直接存储到对象本身,而无需先通过properties指针查询,再获取对应key的值,省去中间的一步,从而提升了查找属性的效率。直接存储到对象本身的属性被称为对象内属性(In-objectProperties)对象内属性propertieselements处于同一层级。

# JS中数组是如何存储的

深入V8 - js数组的内存是如何分配的 (opens new window)

js分为基本类型和引用类型:

  • 基本类型是保存在栈内存中的简单数据段,它们的值都有固定的大小,保存在栈空间,通过按值访问。
  • 引用类型是保存在堆内存中的对象,值大小不固定,栈内存中存放的该对象的访问地址指向堆内存中的对象,JavaScript不允许直接访问堆内存中的位置,因此操作对象时,实际操作对象的引用。

通过在V8数组的定义可以了解到,数组可以处于两种模式:

  • Fast模式的存储结构是FixedArray并且长度小于等于elements.length,可以通过pushpop增加和缩小数组。

    • 快数组是一种线性的存储方式,内部存储是连续的内存(新创建的空数组,默认的存储方式是快数组)。
    • 快数组长度是可变的,可以根据元素的增加和删除来动态调整存储空间大小,内部是通过扩容和收缩机制实现。
  • slow模式的存储结构是一个以数字为键的HashTable。

总结:

  • 数组分为快数组和慢数组,快数组索引线性连续存储,慢数组使用HashTable存储。
  • 数组的elements被分为许多类型,其每一个元素占用的内存大小取决于elements类型和元素中是否有浮点数。如果有浮点数或者elements类型为PACKED_ELEMETNS,那么每个占8字节,否则4字节。
  • 除数字以外的其他类型数据都会以指针的方式存储再数组中,所以除了一些Smi不能表示的数字,其他类型和Smi能表示的值都可以由32位(4字节)表示。
  • 数组的扩容和收缩机制:如果给数组赋值超出容量的下标会触发扩容机制(不能相差太大,不然会转换位慢数组);如果容量大于等于length * 2 + 16,会触发收缩机制。

V8中的SmiHeapNumberSmi代表的是小整数,而HeapNumber则代表了一些浮点数以及无法用32位表示的数,比如NaNInfinity-0

# try...catch能否捕获到异步代码的错误

不能。

因为try catch语句主要用于同步代码的异常处理,‌而异步代码的执行时机和执行环境与try catch语句的执行环境和时机不同,‌因此无法直接捕获异步代码中的错误。‌具体来说,‌try catch无法捕获异步任务中的错误的原因包括:‌

  • 异步任务执行环境的变化:‌当try catch语句包裹的函数是异步任务时,‌如setTimeoutPromise,‌这些函数的执行是延迟的,‌而try catch语句的执行环境(‌上下文)‌在函数执行时已经改变,‌因此无法捕获到异步任务中的错误。‌
  • Promise的错误处理机制:‌Promise对象的错误处理需要通过Promise链中的.catch()方法进行,‌而不是通过try catch语句。‌Promise内部的错误不会直接抛出,‌而是被Promise对象内部捕获和处理,‌因此无法通过try catch直接捕获Promise中的错误。‌
  • 异步任务的执行时机:‌异步任务可能在try catch语句执行之后才完成,‌这时try catch已经结束,‌无法再捕获到异步任务中的错误。‌
  1. 全局错误监听器:window.onerror = function() {}
  2. Promise错误监听:window.addEventListener('unhandledrejection', function() {})
  3. 资源加载错误监听:imgElement.addEventListener('error', function() {})
  4. CDN资源加载失败:scriptElement.onerror = function () {}
// try..catch 无法捕获无效的JS代码
try {
  ~!$%^&*
} catch(err) {
  console.log("这里不会被执行");
}
1
2
3
4
5
6

# 交换两个数字

function swap(x, y) {
  const temp = x;
  x = y;
  y = temp;
  console.log(x, y);
}

function swap(x, y) {
  [x, y] = [y, x];
  console.log(x, y);
}

// 以下两个只能交换纯数字
function swap(x, y) {
  x = x + y; // x = x + y;
  y = x - y; // (x + y) - y => x
  x = x - y; // (x + y) - x => y
  console.log(x, y);
}

function swap(x, y) {
  x ^= y; // x = x ^ y
  y ^= x; // y = y ^ x => y ^ (x ^ y) => x
  x ^= y; // x = x ^ y => (x ^ y) ^ x => y
  console.log(x, y);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

# 文件上传如何做断点续传

前端上传大文件时使用Blob.prototype.slice将文件切片,并发上传多个切片,最后发送一个合并的请求通知服务端合并切片,服务端接收切片并存储,收到合并请求后使用流将切片合并到最终文件。原生XMLHttpRequestupload.onprogress对切片上传进度的监听。

断点续传

使用spark-md5根据文件内容算出文件hash,通过hash可以判断服务端是否已经上传该文件,从而直接提示用户上传成功(秒传)。通过XMLHttpRequestabort方法暂停切片的上传,上传前服务端返回已经上传的切片名,前端跳过这些切片的上传。

# 表单可以跨域吗

因为原页面用form提交到另一个域名之后,原页面的脚本无法获取新页面中的内容。所以浏览器认为这是安全的。而AJAX是可以读取响应内容的,因此浏览器不能允许这样做。其实请求已经发送出去了,只是拿不到响应而已。所以浏览器这个策略的本质是,一个域名的JS,在未经允许的情况下,不得读取另一个域名的内容。但浏览器并不阻止你向另一个域名发送请求。

# 事件委托以及优缺点

事件委托原理:事件冒泡机制。

优点:

  • 可以大量节省内存占用,减少事件注册。比如ul上委托所有li的click事件。
  • 可以实现当新增子对象时,无需再对其进行事件绑定,对于动态内容部分尤为合适。

缺点:

  • 如果把所有事件都用事件委托,可能会出现事件误判。即本不该被触发的事件被绑定上了事件。

react组件事件代理的原理:

区别于浏览器事件处理方式,React并未将事件处理函数与对应的DOM节点直接关联,而是在顶层使用了一个全局事件监听器监听所有的事件;

React会在内部维护一个映射表记录事件与组件事件处理函数的对应关系;当某个事件触发时,React根据这个内部映射表将事件分派给指定的事件处理函数;当映射表中没有事件处理函数时,React不做任何操作;当一个组件安装或者卸载时,相应的事件处理函数会自动被添加到事件监听器的内部映射表中或从表中删除。

在react内通过onClick绑定的事件,实际上并没有把点击事件绑定到对应的元素上,而是统一放到了document上处理。点击一个元素,首先触发这个元素的原生绑定事件(这里不讨论捕获),接着事件冒泡,到了document上,先触发dispatch,即分发react的合成事件,这个触发过程也是和冒泡类似,从里向外的。dispatch结束后,触发document上剩余的原生事件,也就是说可以认为dispatchdocument上的第一个原生绑定事件,这个事件内进行react合成事件的分发。

原生绑定的回调事件中获取到的是原生事件。通过react在jsx内onClick绑定的回调事件中获取到的是合成事件。

React中的事件分为3类。分别是DiscreteEvent(离散事件),UserBlockingEvent(用户阻塞事件),ContinuousEvent(连续事件)。不同类型的事件代表了不同的优先级。

事件委托需要区分捕获和冒泡,有些事件由于没有冒泡过程,只能在捕获阶段进行事件委托。没有进行委托的事件是Form事件和Media事件,原因是这些事件针对特性类型元素,委托意义不大,React将其直接注册到了目标对象。

# BFF和Serverless

BFF(Backend-for-Frontend,后端服务前端)

  • 用户体验适配层和API聚合层:主要负责快速跟进UI迭代,对后端接口服务进行组合、处理,对数据进行:裁剪、格式化、聚合等。在BFF层下面是各种后端微服务,在BFF上层则是各种前端应用(多端应用),向下调用后端为服务,向上给客户端提供接口服务,后端为BFF层的前端提供的的RPC(远程过程调用协议,RPC可以分为两部分:用户调用接口+具体网络协议。前者为开发者需要关心的,后者由框架来实现)接口,BFF层则直接调用服务端RPC接口拿到数据,按需加工数据,来完成整个BFF的闭环(以Node+GraphQL技术栈为主)。
  • 可以降低沟通成本:后端同学追求解耦,希望客户端应用和内部微服务不耦合,通过引入BFF这中间层,使得两边可以独立变化。
  • 多端应用适配:展示不同的(或更少量的)数据,比如PC端页面设计的API需要支持移动端,发现现有接口从设计到实现都与桌面UI展示需求强相关,无法简单适应移动端的展示需求,就好比PC端一个新闻推荐接口,接口字段PC端都需要,而移动端呢H5不需要,这个时候根据不同终端在BFF层做调整,同时也可以进行不同的(或更少的)API调用(聚合)来减少http请求。

缺点:

  • 重复开发:每个设备开发一个BFF应用,也会面临一些重复开发的问题展示,增加开发成本。
  • 维护问题:需要维护各种BFF应用。以往前端也不需要关心并发,现在并发压力却集中到了BFF上。
  • 链路复杂:流程变得繁琐,BFF引入后,要同时走前端、服务端的研发流程,多端发布、互相依赖,导致流程繁琐。
  • 浪费资源:BFF层多了,资源占用就成了问题,会浪费资源,除非有弹性伸缩扩容。

Serverless = Faas (Function as a service) + Baas (Backend as a service)

  • FaaS(Function-as-a-Service)函数即服务,是服务商提供一个平台、提供给用户开发、运行管理这些函数的功能,而无需搭建和维护基础框架,是一种事件驱动由消息触发的函数服务。

  • BaaS(Backend-as-a-Service)后端即服务,包含了后端服务组件,它是基于API的第三方服务,用于实现应用程序中的核心功能,包含常用的数据库、对象存储、消息队列、日志服务等等。

  • 环境统一:不需要搭建服务端环境,保持各个机器环境一致Serverless的机制天然可复制。

  • 按需计费:我们只在代码运行的时候付费,没有未使用资源浪费的问题。

  • 丰富的SDK:有完善的配套服务,如云数据库,云存储,云消息队列,云音视频和云AI服务等。

  • 弹性伸缩:不需要预估流量,关心资源利用率,备份容灾,扩容机器,可以根据流量动态提前峰值流量。

缺点:

  • 云厂商强绑定:它们常常会和厂商的其他云产品相绑定,如对象存储、消息队列等,意味你需要同时开通其他的服务,迁移成本高,如果想迁移基本原有的逻辑不可复用则需要重构。
  • 不适合长时间任务:云函数平台会限制函数执行时间,如阿里云Function Compute最大执行时长为10min。
  • 冷启动时间:函数运行时,执行容器和环境需要一定的时间,对HTTP请求来讲,可能会带来响应时延的增加。
  • 调试与测试:开发者需要不断调整代码,打印日志,并提交到函数平台运行测试,会带来开发成本和产生费用。

# 循环里的await

  • for:要使用在async异步方法里,循环会等await执行而停留,await是有效的,有break
  • forEach:没有break,循环不会等await执行而停留,await是无效的。
  • for...of:要使用在async异步方法里,执行期间,await之前的代码会执行,到了await会等待await执行完才继续往下执行,有break
  • for...await...of:也要用在async异步方法里,有break,但是它一般是使用在item是个异步方法的情况下,并不常见,例如要遍历的数组元素是个异步请求,异步没回来之前整个循环代码不执行。

forfor...offor...await...of是生效的,forEachawait是不生效的。forfor...ofawait这一行代码在等待,for...await...of是整个for在等待。

实际开发中我们可以发现其实forwhilefor...infor...offor...await...of使用await都是生效的。而几乎有回调的遍历方法:forEachmapfilterreducesomeeveryfind等,使用await都是不生效的。

参考文章:几个for循环里await关键字的用法 (opens new window)

# 浏览器跨标签通信

  1. localStorage

    // tab1
    window.addEventListener('storage', (e) => console.log(e));
    
    // tab2
    localStorage.setItem('key', 'value');
    
    1
    2
    3
    4
    5

    storage事件只能监听localStorage的变化,sessionStorage不行,当前修改localStorage的页面不会触发事件,其他同源页面会触发,如果设置相同的值其他页面也不会触发。

    若使用本地存储的方式除此之外还有indexedDBcookie

  2. SharedWorker

    const myWorker = new SharedWorker('worker.js');
    myWorker.port.start();
    myWorker.port.postMessage('message');
    myWorker.port.onmessage = function (e) {
      console.log(e);
    };
    
    1
    2
    3
    4
    5
    6

    使SharedWorker连接到多个不同的页面,这些页面必须是同源的。

  3. window.open与postMessage

    // tab1
    window.addEventListener('message', (e) => {
      console.log(e);
    });
    const targetWindow = window.open('target-url');
    targetWindow.postMessage('message');
    
    // tab2(targetWindow)
    window.addEventListener('message', (e) => {
      console.log(e);
      e.source.postMessage('');
    });
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12

    缺点:新开的tab不能主动发送消息。

  4. BroadcastChannel

    // 每个页面下都需要注册
    const channel = new BroadcastChannel('channel_name');
    channel.postMessage('message');
    channel.onmessage = (e) => {
      console.log(e);
    };
    
    1
    2
    3
    4
    5
    6

    表示给定源的任何浏览上下文都可以订阅的命名频道。它允许同源的不同浏览器窗口、标签页、frame或者iframe下的不同文档之间相互通信。

  5. Websocket、SSE

    借助服务端能力,通过第三方Websocket广播消息,或者SSE,客服端使用EventSource监听。

  6. service worker

    // service worker中监听页面发送的消息
    this.addEventListener('message', (e) => {
      console.log(e);
      const senderID = e.source.id;
      // 匹配所有注册了service worker的客户端
      this.clients.matchAll().then((clientList) => {
        clientList.forEach((client) => {
          if (client.id !== senderID) {
            client.postMessage('message');
          }
        });
      });
    });
    
    // 页面中
    // 接受消息
    navigator.serviceWorker.addEventListener('message', (e) => console.log(e));
    // 发送消息
    navigator.serviceWorker.controller.postMessage('message');
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19

# service worker和web worker

  • Service Worker可以拦截请求并将其替换为自己缓存中的项目,因此它们的行为就像是代理服务器。他们为Web应用提供了“离线功能”。它们可以在多个标签中使用,甚至在所有标签关闭后仍然可以使用。
  • Web worker有不同的用途。它们为单线程JavaScript语言提供了多线程功能,并用于执行计算繁重的任务,这些任务不应干扰UI的响应能力。它们仅限于一个标签。
  • 两者的共同点是它们无权访问DOM,无法使用postMessage API进行通信。你可以将它们看作是具有扩展功能的Web Worker。

# 普通模式和严格模式的区别

在js中默认是普通模式(Sloppy Mode),要想启用严格模式必须在脚本或函数开头添加use strict;指令。模块和ES6类默认启用严格模式。

  1. 变量声明

    • 普通模式:未声明的变量会自动成为全局变量,如:x = 10;
    • 严格模式:未声明的变量会抛出ReferenceError。
  2. 删除操作

    • 普通模式:允许删除变量、函数或函数参数,但无效,如:var x = 10; delete x;
    • 严格模式:删除变量、函数或函数参数会抛出SyntaxError。
  3. 重复属性名

    • 普通模式:允许对象字面量中有重复属性名。
    • 严格模式:重复属性名会抛出SyntaxError。
  4. 函数参数重复

    • 普通模式:允许函数有重复的参数名。
    • 严格模式:重复的参数名会抛出SyntaxError。
  5. this的值

    • 普通模式:全局函数中的this指向 window(浏览器)或 global(Node.js)。
    • 严格模式:全局函数中的thisundefined
  6. evalarguments

    • 普通模式:eval可以修改外部作用域,arguments与形参绑定。
    • 严格模式:eval不能修改外部作用域,arguments与形参不绑定。
  7. with语句

    • 普通模式:允许使用with语句。
    • 严格模式:使用with语句会抛出SyntaxError。
  8. arguments.calleearguments.caller

    • 普通模式:允许使用arguments.calleearguments.caller
    • 严格模式:使用arguments.calleearguments.caller会抛出TypeError。

# 跨域与同源策略

跨域是指浏览器出于安全考虑,限制了从一个源(Origin)向另一个源发起的请求。这种限制被称为同源策略(Same-Origin Policy)。如果两个URL的协议(Protocol)、域名(Domain)和端口(Port)不完全相同,则它们属于不同的源,浏览器会阻止跨域请求。(跨域只是浏览器的限制)

同源策略的限制:

  1. 限制跨域请求

    • 默认情况下,浏览器会阻止跨域的AJAX请求(如:XMLHttpRequestFetch API)。
    • 跨域加载字体文件时,浏览器会阻止请求,除非服务器正确设置CORS头。
    • 跨域加载的图像无法通过canvasgetImageData方法读取像素数据(可通过img标签的crossOrigin: use-credentials发送带凭据的跨源请求)。
  2. 限制跨域访问DOM

    • 对于iframe,如果两个页面的源不同,父页面无法通过JavaScript访问子页面的DOM。
    • 如果通过window.open打开的窗口与当前页面的源不同,父页面无法访问子窗口的DOM。
  3. 限制跨域Cookie和存储

    • 浏览器会根据同源策略限制跨域访问Cookie。
    • localStoragesessionStorage是基于源的,不同源的页面无法共享存储数据。
  4. 限制跨域脚本

    • 跨域加载的JavaScript文件可以执行,但无法访问父页面的DOM或数据。
  5. 限制跨域表单提交

    • 浏览器允许跨域的表单提交(如<form>标签的action属性),但无法读取跨域请求的响应。

解决办法:

  • JSONP

    JSONP

  • CORS

    跨域资源共享:Cross-Origin Resource Sharing

    Access-Control-Allow-Origin: https://example.com
    Access-Control-Allow-Methods: GET, POST, PUT
    Access-Control-Allow-Headers: Content-Type, Authorization
    
    1
    2
    3
  • 代理

    • 本地代理

      module.exports = {
        devServer: {
          host: '127.0.0.1',
          port: 8084,
          open: true, // vue项目启动时自动打开浏览器
          proxy: {
            '/api': { // '/api'是代理标识,用于告诉node,url前面是/api的就是使用代理的
              target: 'http://xxx.xxx.xx.xx:8080', // 目标地址,一般是指后台服务器地址
              changeOrigin: true, // 是否跨域
              pathRewrite: { // pathRewrite 的作用是把实际Request Url中的'/api'用""代替
                '^/api': '',
              },
            },
          },
        },
      };
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
    • Nginx代理

      server {
        listen 80;
        server_name yourdomain.com;
      
        location /api/ {
          proxy_pass http://192.168.1.1:8080;
          proxy_set_header Host $host;
          proxy_set_header X-Real-IP $remote_addr;
          proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
          proxy_set_header X-Forwarded-Proto $scheme;
        }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12

# 作用域和作用域链

JavaScript变量有三种作用域:

  • 全局作用域:任何不在函数中或是大括号中声明的变量,都是在全局作用域下,全局作用域下声明的变量可以在程序的任意位置访问。
  • 函数作用域:函数作用域也叫局部作用域,如果一个变量是在函数内部声明的它就在一个函数作用域下面。这些变量只能在函数内部访问,不能在函数以外去访问。
  • 块级作用域:ES6引入了letconst关键字,和var关键字不同,在大括号中使用letconst声明的变量存在于块级作用域中。在大括号之外不能访问这些变量。

另:

  • 模块作用域:ES6模块的作用域,变量和函数默认是私有的。使用exportimport共享模块内容。
  • 词法作用域:变量的作用域在代码编写时确定,函数的作用域由定义位置决定。闭包是词法作用域的典型应用。
  • 动态作用域:变量的作用域在运行时确定,JavaScript本身不支持动态作用域。可以通过thiseval模拟动态作用域。

作用域链

当在Javascript中使用一个变量的时候,首先Javascript引擎会尝试在当前作用域下去寻找该变量,如果没找到,再到它的上层作用域寻找,以此类推直到找到该变量或是已经到了全局作用域。如果在全局作用域里仍然找不到该变量,它就会在全局范围内隐式声明该变量(非严格模式下)或是直接报错。

# 什么是闭包

闭包是由捆绑起来(封闭的)的函数和函数周围状态(词法环境)的引用组合而成。换言之,闭包让函数能访问它的外部作用域。在JavaScript中,闭包会随着函数的创建而同时创建。(闭包是由函数以及函数声明所在的词法环境组合而成)

闭包的形成

  • 当一个函数内部定义了另一个函数,并且内部函数引用了外部函数的变量时,闭包就形成了。
  • 即使外部函数执行完毕,内部函数仍然可以访问外部函数的变量。

闭包的应用场景

  • 数据封装:闭包可以用于创建私有变量,避免变量被外部直接访问。
  • 回调函数:闭包可以用于保存回调函数的状态。
  • 模块化:闭包可以用于实现模块化,将相关的变量和函数封装在一个作用域中。

闭包是否一定会造成内存泄漏?

  • 持有本该被销毁的函数,造成其关联的词法环境无法销毁。例如定时器的回调函数或者事件监听函数。
  • 多个函数共享词法环境时,可能导致词法环境膨胀,从而发生无法访问但无法销毁的数据。

不一定。如果是想利用闭包缓存某些数据,则不算做内存泄漏。内存泄漏的其他情况:

情况 原因 解决方法
意外的全局变量 未使用varletconst声明的变量成为全局变量。 使用varletconst声明变量,启用严格模式。
未清理的定时器或回调 定时器或回调函数未及时清除。 在不需要时清除定时器或回调函数。
闭包 闭包保留对外部函数作用域的引用。 手动解除闭包引用。
DOM引用 JavaScript中保留了DOM元素的引用。 在移除DOM元素后,手动解除引用。
未清理的MapSet MapSet中存储了对象的引用。 使用WeakMapWeakSet
未释放的事件监听器 事件监听器未及时移除。 在不需要时移除事件监听器。
循环引用 两个对象相互引用,且它们都不再被其他对象引用。 手动解除循环引用。
未清理的缓存 缓存中的数据未被及时清理。 使用LRU缓存策略,定期清理不常用的数据。
未释放的WebSocket WebSocket连接或AJAX请求未关闭。 在不需要时关闭WebSocket连接或取消AJAX请求。

# 如何监听网络状态

要监听查看网络状态的变化,监听事件onlineofflinenavigator.onLine返回一个布尔值,表示浏览器的在线状态。

window.onoffline = (event) => {};
window.ononline = (event) => {};

// addEventListener 版本
window.addEventListener("offline", (event) => {});
window.addEventListener("online", (event) => {});
1
2
3
4
5
6

监听页面是否展示(切后台):

Document.visibilityState(只读属性),返回document的可见性,即当前可见元素的上下文环境。由此可以知道当前文档(即为页面)是在背后,或是不可见的隐藏的标签页,或者(正在)预渲染。可用的值如下:

  • visible:此时页面内容至少是部分可见。即此页面在前景标签页中,并且窗口没有最小化。
  • hidden:此时页面对用户不可见。即文档处于背景标签页或者窗口处于最小化状态,或者操作系统正处于'锁屏状态'。
  • prerender:页面此时正在渲染中,因此是不可见的(considered hidden for purposes of document.hidden),文档只能从此状态开始,永远不能从其他值变为此状态。注意:浏览器支持是可选的。
document.addEventListener("visibilitychange", function () {
  console.log(document.visibilityState);
});
1
2
3

# 解释高内聚和低耦合

高内聚是指一个模块内部的各个元素(如函数、类、方法)之间紧密相关,共同完成一个明确的任务或功能。内聚性越高,模块的功能越单一,职责越明确。

  • 功能集中:模块只负责一个明确的功能。
  • 代码清晰:模块内部的代码逻辑紧密相关,易于理解。
  • 易于维护:修改一个功能时,只需关注一个模块。

低耦合是指模块之间的依赖关系尽可能少,模块之间的交互通过清晰的接口进行。耦合度越低,模块的独立性越强,修改一个模块对其他模块的影响越小。

  • 独立性:模块之间相互独立,修改一个模块不会影响其他模块。
  • 接口清晰:模块之间通过定义良好的接口进行交互。
  • 易于复用:低耦合的模块可以更容易地复用到其他项目中。

好处

  • 提高可维护性:模块功能单一,代码逻辑清晰,易于理解和修改。
  • 提高可复用性:低耦合的模块可以更容易地复用到其他项目中。
  • 提高可扩展性:新增功能时,只需添加新的模块,无需修改现有模块。
  • 降低风险:修改一个模块不会影响其他模块,降低了引入错误的风险。

实现方法

  • 单一职责原则(SRP):每个模块只负责一个明确的功能。
  • 依赖倒置原则(DIP):高层模块不应该依赖低层模块,二者都应该依赖抽象。
  • 接口隔离原则(ISP):模块之间通过定义良好的接口进行交互,避免不必要的依赖。
  • 依赖注入(DI):通过依赖注入降低模块之间的耦合度。
最近更新: 2025年03月19日 13:31:15