写点什么

一年前端面试打怪升级之路

作者:loveX001
  • 2022-11-15
    浙江
  • 本文字数:35347 字

    阅读完需:约 116 分钟

节流与防抖

  • 函数防抖 是指在事件被触发 n 秒后再执行回调,如果在这 n 秒内事件又被触发,则重新计时。这可以使用在一些点击请求的事件上,避免因为用户的多次点击向后端发送多次请求。

  • 函数节流 是指规定一个单位时间,在这个单位时间内,只能有一次触发事件的回调函数执行,如果在同一个单位时间内某事件被触发多次,只有一次能生效。节流可以使用在 scroll 函数的事件监听上,通过事件节流来降低事件调用的频率。


// 函数防抖的实现function debounce(fn, wait) {  var timer = null;
return function() { var context = this, args = arguments;
// 如果此时存在定时器的话,则取消之前的定时器重新记时 if (timer) { clearTimeout(timer); timer = null; }
// 设置定时器,使事件间隔指定事件后执行 timer = setTimeout(() => { fn.apply(context, args); }, wait); };}
// 函数节流的实现;function throttle(fn, delay) { var preTime = Date.now();
return function() { var context = this, args = arguments, nowTime = Date.now();
// 如果两次时间间隔超过了指定时间,则执行函数。 if (nowTime - preTime >= delay) { preTime = Date.now(); return fn.apply(context, args); } };}
复制代码

vue3 带来的新特性/亮点

1. 压缩包体积更小


当前最小化并被压缩的 Vue 运行时大小约为 20kB(2.6.10 版为 22.8kB)。Vue 3.0 捆绑包的大小大约会减少一半,即只有 10kB!


2. Object.defineProperty -> Proxy


  • Object.defineProperty是一个相对比较昂贵的操作,因为它直接操作对象的属性,颗粒度比较小。将它替换为 es6 的Proxy,在目标对象之上架了一层拦截,代理的是对象而不是对象的属性。这样可以将原本对对象属性的操作变为对整个对象的操作,颗粒度变大。

  • javascript 引擎在解析的时候希望对象的结构越稳定越好,如果对象一直在变,可优化性降低,proxy 不需要对原始对象做太多操作。


3. Virtual DOM 重构


vdom 的本质是一个抽象层,用 javascript 描述界面渲染成什么样子。react 用 jsx,没办法检测出可以优化的动态代码,所以做时间分片,vue 中足够快的话可以不用时间分片


  • 传统 vdom 的性能瓶颈:

  • 虽然 Vue 能够保证触发更新的组件最小化,但在单个组件内部依然需要遍历该组件的整个 vdom 树。

  • 传统 vdom 的性能跟模版大小正相关,跟动态节点的数量无关。在一些组件整个模版内只有少量动态节点的情况下,这些遍历都是性能的浪费。

  • JSX 和手写的 render function 是完全动态的,过度的灵活性导致运行时可以用于优化的信息不足

  • 那为什么不直接抛弃 vdom 呢?

  • 高级场景下手写 render function 获得更强的表达力

  • 生成的代码更简洁

  • 兼容 2.x


vue 的特点是底层为 Virtual DOM,上层包含有大量静态信息的模版。为了兼容手写 render function,最大化利用模版静态信息,vue3.0采用了动静结合的解决方案,将 vdom 的操作颗粒度变小,每次触发更新不再以组件为单位进行遍历,主要更改如下


  • 将模版基于动态节点指令切割为嵌套的区块

  • 每个区块内部的节点结构是固定的

  • 每个区块只需要以一个 Array 追踪自身包含的动态节点


vue3.0 将 vdom 更新性能由与模版整体大小相关提升为与动态内容的数量相关


Vue 3.0 动静结合的 Dom diff


  • Vue3.0 提出动静结合的 DOM diff 思想,动静结合的 DOM diff 其实是在预编译阶段进行了优化。之所以能够做到预编译优化,是因为 Vue core 可以静态分析 template,在解析模版时,整个 parse 的过程是利用正则表达式顺序解析模板,当解析到开始标签、闭合标签和文本的时候都会分别执行对应的回调函数,来达到构造 AST 树的目的。

  • 借助预编译过程,Vue 可以做到的预编译优化就很强大了。比如在预编译时标记出模版中可能变化的组件节点,再次进行渲染前 diff 时就可以跳过“永远不会变化的节点”,而只需要对比“可能会变化的动态节点”。这也就是动静结合的 DOM diff 将 diff 成本与模版大小正相关优化到与动态节点正相关的理论依据。


4. Performance


vue3 在性能方面比 vue2 快了 2 倍。


  • 重写了虚拟 DOM 的实现

  • 运行时编译

  • update 性能提高

  • SSR 速度提高


5. Tree-shaking support


vue3 中的核心 api 都支持了 tree-shaking,这些 api 都是通过包引入的方式而不是直接在实例化时就注入,只会对使用到的功能或特性进行打包(按需打包),这意味着更多的功能和更小的体积。


6. Composition API


vue2 中,我们一般会采用 mixin 来复用逻辑代码,用倒是挺好用的,不过也存在一些问题:例如代码来源不清晰、方法属性等冲突。基于此在 vue3 中引入了 Composition API(组合 API),使用纯函数分隔复用代码。和 React 中的hooks的概念很相似


  • 更好的逻辑复用和代码组织

  • 更好的类型推导


<template>    <div>X: {{ x }}</div>    <div>Y: {{ y }}</div></template>
<script>import { defineComponent, onMounted, onUnmounted, ref } from "vue";
const useMouseMove = () => { const x = ref(0); const y = ref(0);
function move(e) { x.value = e.clientX; y.value = e.clientY; }
onMounted(() => { window.addEventListener("mousemove", move); });
onUnmounted(() => { window.removeEventListener("mousemove", move); });
return { x, y };};
export default defineComponent({ setup() { const { x, y } = useMouseMove();
return { x, y }; }});</script>
复制代码


7. 新增的三个组件 Fragment、Teleport、Suspense


Fragment


在书写 vue2 时,由于组件必须只有一个根节点,很多时候会添加一些没有意义的节点用于包裹。Fragment 组件就是用于解决这个问题的(这和 React 中的 Fragment 组件是一样的)。


这意味着现在可以这样写组件了。


/* App.vue */<template>  <header>...</header>  <main v-bind="$attrs">...</main>  <footer>...</footer></template>
<script>export default {};</script>
复制代码


或者这样


// app.jsimport { defineComponent, h, Fragment } from 'vue';
export default defineComponent({ render() { return h(Fragment, {}, [ h('header', {}, ['...']), h('main', {}, ['...']), h('footer', {}, ['...']), ]); }});
复制代码


Teleport


Teleport 其实就是 React 中的 Portal。Portal 提供了一种将子节点渲染到存在于父组件以外的 DOM 节点的优秀的方案。


一个 portal 的典型用例是当父组件有 overflow: hidden 或 z-index 样式时,但你需要子组件能够在视觉上“跳出”其容器。例如,对话框、悬浮卡以及提示框。


/* App.vue */<template>    <div>123</div>    <Teleport to="#container">        Teleport    </Teleport></template>
<script>import { defineComponent } from "vue";
export default defineComponent({ setup() {}});</script>
/* index.html */<div id="app"></div><div id="container"></div>
复制代码



Suspense


同样的,这和 React 中的 Supense 是一样的。


Suspense 让你的组件在渲染之前进行“等待”,并在等待时显示 fallback 的内容


// App.vue<template>    <Suspense>        <template #default>            <AsyncComponent />        </template>        <template #fallback>            Loading...        </template>    </Suspense></template>
<script lang="ts">import { defineComponent } from "vue";import AsyncComponent from './AsyncComponent.vue';
export default defineComponent({ name: "App",
components: { AsyncComponent }});</script>
// AsyncComponent.vue<template> <div>Async Component</div></template>
<script lang="ts">import { defineComponent } from "vue";
const sleep = () => { return new Promise(resolve => setTimeout(resolve, 1000));};
export default defineComponent({ async setup() { await sleep(); }});</script>
复制代码


8. Better TypeScript support


在 vue2 中使用过 TypesScript 的童鞋应该有过体会,写起来实在是有点难受。vue3 则是使用 ts 进行了重写,开发者使用 vue3 时拥有更好的类型支持和更好的编写体验。

数字证书是什么?

现在的方法也不一定是安全的,因为没有办法确定得到的公钥就一定是安全的公钥。可能存在一个中间人,截取了对方发给我们的公钥,然后将他自己的公钥发送给我们,当我们使用他的公钥加密后发送的信息,就可以被他用自己的私钥解密。然后他伪装成我们以同样的方法向对方发送信息,这样我们的信息就被窃取了,然而自己还不知道。为了解决这样的问题,可以使用数字证书。


首先使用一种 Hash 算法来对公钥和其他信息进行加密,生成一个信息摘要,然后让有公信力的认证中心(简称 CA )用它的私钥对消息摘要加密,形成签名。最后将原始的信息和签名合在一起,称为数字证书。当接收方收到数字证书的时候,先根据原始信息使用同样的 Hash 算法生成一个摘要,然后使用公证处的公钥来对数字证书中的摘要进行解密,最后将解密的摘要和生成的摘要进行对比,就能发现得到的信息是否被更改了。


这个方法最要的是认证中心的可靠性,一般浏览器里会内置一些顶层的认证中心的证书,相当于我们自动信任了他们,只有这样才能保证数据的安全。

跨域方案

很多种方法,但万变不离其宗,都是为了搞定同源策略。重用的有 jsonpiframecorsimgHTML5 postMessage等等。其中用到 html 标签进行跨域的原理就是 html 不受同源策略影响。但只是接受 Get 的请求方式,这个得清楚。


延伸 1:img iframe script 来发送跨域请求有什么优缺点?


1. iframe


  • 优点:跨域完毕之后DOM操作和互相之间的JavaScript调用都是没有问题的

  • 缺点:1.若结果要以URL参数传递,这就意味着在结果数据量很大的时候需要分割传递,巨烦。2.还有一个是iframe本身带来的,母页面和iframe本身的交互本身就有安全性限制。


2. script


  • 优点:可以直接返回json格式的数据,方便处理

  • 缺点:只接受GET请求方式


3. 图片 ping


  • 优点:可以访问任何url,一般用来进行点击追踪,做页面分析常用的方法

  • 缺点:不能访问响应文本,只能监听是否响应


延伸 2:配合 webpack 进行反向代理?


webpackdevServer 选项里面提供了一个 proxy 的参数供开发人员进行反向代理


'/api': {  target: 'http://www.example.com', // your target host  changeOrigin: true, // needed for virtual hosted sites  pathRewrite: {    '^/api': ''  // rewrite path  }},
复制代码


然后再配合 http-proxy-middleware 插件对 api 请求地址进行代理


const express = require('express');const proxy = require('http-proxy-middleware');// proxy api requestsconst exampleProxy = proxy(options); // 这里的 options 就是 webpack 里面的 proxy 选项对应的每个选项
// mount `exampleProxy` in web serverconst app = express();app.use('/api', exampleProxy);app.listen(3000);
复制代码


然后再用 nginx 把允许跨域的源地址添加到报头里面即可


说到 nginx ,可以再谈谈 CORS 配置,大致如下


location / {  if ($request_method = 'OPTIONS') {    add_header 'Access-Control-Allow-Origin' '*';      add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';     add_header 'Access-Control-Allow-Credentials' 'true';    add_header 'Access-Control-Allow-Headers' 'DNT, X-Mx-ReqToken, Keep-Alive, User-Agent, X-Requested-With, If-Modified-Since, Cache-Control, Content-Type';      add_header 'Access-Control-Max-Age' 86400;      add_header 'Content-Type' 'text/plain charset=UTF-8';      add_header 'Content-Length' 0;      return 200;    }}
复制代码


参考:前端进阶面试题详细解答

左右居中方案

  • 行内元素: text-align: center

  • 定宽块状元素: 左右 margin 值为 auto

  • 不定宽块状元素: table布局,position + transform


/* 方案1 */.wrap {  text-align: center}.center {  display: inline;  /* or */  /* display: inline-block; */}/* 方案2 */.center {  width: 100px;  margin: 0 auto;}/* 方案2 */.wrap {  position: relative;}.center {  position: absulote;  left: 50%;  transform: translateX(-50%);}
复制代码

闭包

闭包其实就是一个可以访问其他函数内部变量的函数。创建闭包的最常见的方式就是在一个函数内创建另一个函数,创建的函数可以 访问到当前函数的局部变量。



因为通常情况下,函数内部变量是无法在外部访问的(即全局变量和局部变量的区别),因此使用闭包的作用,就具备实现了能在外部访问某个函数内部变量的功能,让这些内部变量的值始终可以保存在内存中。下面我们通过代码先来看一个简单的例子


function fun1() {    var a = 1;    return function(){        console.log(a);    };}fun1();var result = fun1();result();  // 1
// 结合闭包的概念,我们把这段代码放到控制台执行一下,就可以发现最后输出的结果是 1(即 a 变量的值)。那么可以很清楚地发现,a 变量作为一个 fun1 函数的内部变量,正常情况下作为函数内的局部变量,是无法被外部访问到的。但是通过闭包,我们最后还是可以拿到 a 变量的值
复制代码


闭包有两个常用的用途


  • 闭包的第一个用途是使我们在函数外部能够访问到函数内部的变量。通过使用闭包,我们可以通过在外部调用闭包函数,从而在外部访问到函数内部的变量,可以使用这种方法来创建私有变量。

  • 函数的另一个用途是使已经运行结束的函数上下文中的变量对象继续留在内存中,因为闭包函数保留了这个变量对象的引用,所以这个变量对象不会被回收。


其实闭包的本质就是作用域链的一个特殊的应用,只要了解了作用域链的创建过程,就能够理解闭包的实现原理。


let a = 1// fn 是闭包function fn() {  console.log(a);}
function fn1() { let a = 1 // 这里也是闭包 return () => { console.log(a); }}const fn2 = fn1()fn2()
复制代码


  • 大家都知道闭包其中一个作用是访问私有变量,就比如上述代码中的 fn2 访问到了 fn1 函数中的变量 a。但是此时 fn1 早已销毁,我们是如何访问到变量 a 的呢?不是都说原始类型是存放在栈上的么,为什么此时却没有被销毁掉?

  • 接下来笔者会根据浏览器的表现来重新理解关于原始类型存放位置的说法。

  • 先来说下数据存放的正确规则是:局部、占用空间确定的数据,一般会存放在栈中,否则就在堆中(也有例外)。 那么接下来我们可以通过 Chrome 来帮助我们验证这个说法说法。



上图中画红框的位置我们能看到一个内部的对象 [[Scopes]],其中存放着变量 a,该对象是被存放在堆上的,其中包含了闭包、全局对象等等内容,因此我们能通过闭包访问到本该销毁的变量。


另外最开始我们对于闭包的定位是:假如一个函数能访问外部的变量,那么这个函数它就是一个闭包,因此接下来我们看看在全局下的表现是怎么样的。


let a = 1var b = 2// fn 是闭包function fn() {  console.log(a, b);}
复制代码



从上图我们能发现全局下声明的变量,如果是 var 的话就直接被挂到 globe 上,如果是其他关键字声明的话就被挂到 Script 上。虽然这些内容同样还是存在 [[Scopes]],但是全局变量应该是存放在静态区域的,因为全局变量无需进行垃圾回收,等需要回收的时候整个应用都没了。


只有在下图的场景中,原始类型才可能是被存储在栈上。


这里为什么要说可能,是因为 JS 是门动态类型语言,一个变量声明时可以是原始类型,马上又可以赋值为对象类型,然后又回到原始类型。这样频繁的在堆栈上切换存储位置,内部引擎是不是也会有什么优化手段,或者干脆全部都丢堆上?只有 const 声明的原始类型才一定存在栈上?当然这只是笔者的一个推测,暂时没有深究,读者可以忽略这段瞎想



因此笔者对于原始类型存储位置的理解为:局部变量才是被存储在栈上,全局变量存在静态区域上,其它都存储在堆上。


当然这个理解是建立的 Chrome 的表现之上的,在不同的浏览器上因为引擎的不同,可能存储的方式还是有所变化的。


闭包产生的原因


我们在前面介绍了作用域的概念,那么你还需要明白作用域链的基本概念。其实很简单,当访问一个变量时,代码解释器会首先在当前的作用域查找,如果没找到,就去父级作用域去查找,直到找到该变量或者不存在父级作用域中,这样的链路就是作用域链


需要注意的是,每一个子函数都会拷贝上级的作用域,形成一个作用域的链条。那么我们还是通过下面的代码来详细说明一下作用域链


var a = 1;function fun1() {  var a = 2  function fun2() {    var a = 3;    console.log(a);//3  }}
复制代码


  • 从中可以看出,fun1 函数的作用域指向全局作用域(window)和它自己本身;fun2 函数的作用域指向全局作用域 (window)、fun1 和它本身;而作用域是从最底层向上找,直到找到全局作用域 window 为止,如果全局还没有的话就会报错。

  • 那么这就很形象地说明了什么是作用域链,即当前函数一般都会存在上层函数的作用域的引用,那么他们就形成了一条作用域链。

  • 由此可见,闭包产生的本质就是:当前环境中存在指向父级作用域的引用。那么还是拿上的代码举例。


function fun1() {  var a = 2  function fun2() {    console.log(a);  //2  }  return fun2;}var result = fun1();result();
复制代码


  • 从上面这段代码可以看出,这里 result 会拿到父级作用域中的变量,输出 2。因为在当前环境中,含有对 fun2 函数的引用,fun2 函数恰恰引用了 window、fun1 和 fun2 的作用域。因此 fun2 函数是可以访问到 fun1 函数的作用域的变量。

  • 那是不是只有返回函数才算是产生了闭包呢?其实也不是,回到闭包的本质,我们只需要让父级作用域的引用存在即可,因此还可以这么改代码,如下所示


var fun3;function fun1() {  var a = 2  fun3 = function() {    console.log(a);  }}fun1();fun3();
复制代码


可以看出,其中实现的结果和前一段代码的效果其实是一样的,就是在给 fun3 函数赋值后,fun3 函数就拥有了 window、fun1 和 fun3 本身这几个作用域的访问权限;然后还是从下往上查找,直到找到 fun1 的作用域中存在 a 这个变量;因此输出的结果还是 2,最后产生了闭包,形式变了,本质没有改变。


因此最后返回的不管是不是函数,也都不能说明没有产生闭包


闭包的表现形式


  1. 返回一个函数

  2. 在定时器、事件监听、Ajax 请求、Web Workers 或者任何异步中,只要使用了回调函数,实际上就是在使用闭包。请看下面这段代码,这些都是平常开发中用到的形式


// 定时器setTimeout(function handler(){  console.log('1');},1000);// 事件监听$('#app').click(function(){  console.log('Event Listener');});
复制代码


  1. 作为函数参数传递的形式,比如下面的例子。


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


  1. IIFE(立即执行函数),创建了闭包,保存了全局作用域(window)和当前函数的作用域,因此可以输出全局的变量,如下所示


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


IIFE 这个函数会稍微有些特殊,算是一种自执行匿名函数,这个匿名函数拥有独立的作用域。这不仅可以避免了外界访问此 IIFE 中的变量,而且又不会污染全局作用域,我们经常能在高级的 JavaScript 编程中看见此类函数。


如何解决循环输出问题?


在互联网大厂的面试中,解决循环输出问题是比较高频的面试题,一般都会给一段这样的代码让你来解释


for(var i = 1; i <= 5; i ++){  setTimeout(function() {    console.log(i)  }, 0)}
复制代码


上面这段代码执行之后,从控制台执行的结果可以看出来,结果输出的是 5 个 6,那么一般面试官都会先问为什么都是 6?我想让你实现输出 1、2、3、4、5 的话怎么办呢?


因此结合本讲所学的知识我们来思考一下,应该怎么给面试官一个满意的解释。你可以围绕这两点来回答。


  • setTimeout 为宏任务,由于 JS 中单线程 eventLoop 机制,在主线程同步任务执行完后才去执行宏任务,因此循环结束后 setTimeout 中的回调才依次执行

  • 因为 setTimeout 函数也是一种闭包,往上找它的父级作用域链就是 window变量 i 为 window 上的全局变量,开始执行 setTimeout 之前变量 i 已经就是 6 了,因此最后输出的连续就都是 6。


那么我们再来看看如何按顺序依次输出 1、2、3、4、5 呢?


  1. 利用 IIFE


可以利用 IIFE(立即执行函数),当每次 for 循环时,把此时的变量 i 传递到定时器中,然后执行,改造之后的代码如下。


for(var i = 1;i <= 5;i++){  (function(j){    setTimeout(function timer(){      console.log(j)    }, 0)  })(i)}
复制代码


  1. 使用 ES6 中的 let


ES6 中新增的 let 定义变量的方式,使得 ES6 之后 JS 发生革命性的变化,让 JS 有了块级作用域,代码的作用域以块级为单位进行执行。通过改造后的代码,可以实现上面想要的结果。


for(let i = 1; i <= 5; i++){  setTimeout(function() {    console.log(i);  },0)}
复制代码


  1. 定时器传入第三个参数


setTimeout 作为经常使用的定时器,它是存在第三个参数的,日常工作中我们经常使用的一般是前两个,一个是回调函数,另外一个是时间,而第三个参数用得比较少。那么结合第三个参数,调整完之后的代码如下。


for(var i=1;i<=5;i++){  setTimeout(function(j) {    console.log(j)  }, 0, i)}
复制代码


从中可以看到,第三个参数的传递,可以改变 setTimeout 的执行逻辑,从而实现我们想要的结果,这也是一种解决循环输出问题的途径


常见考点


  • 闭包能考的很多,概念和笔试题都会考。

  • 概念题就是考考闭包是什么了。

  • 笔试题的话基本都会结合上异步,比如最常见的:


for (var i = 0; i < 6; i++) {  setTimeout(() => {    console.log(i)  })}
复制代码


这道题会问输出什么,有哪几种方式可以得到想要的答案?

深入数组

一、梳理数组 API


1. Array.of


Array.of 用于将参数依次转化为数组中的一项,然后返回这个新数组,而不管这个参数是数字还是其他。它基本上与 Array 构造器功能一致,唯一的区别就在单个数字参数的处理上


Array.of(8.0); // [8]Array(8.0); // [empty × 8]Array.of(8.0, 5); // [8, 5]Array(8.0, 5); // [8, 5]Array.of('8'); // ["8"]Array('8'); // ["8"]
复制代码


2. Array.from


从语法上看,Array.from 拥有 3 个参数:


  • 类似数组的对象,必选;

  • 加工函数,新生成的数组会经过该函数的加工再返回;

  • this 作用域,表示加工函数执行时 this 的值。


这三个参数里面第一个参数是必选的,后两个参数都是可选的。我们通过一段代码来看看它的用法。


var obj = {0: 'a', 1: 'b', 2:'c', length: 3};Array.from(obj, function(value, index){  console.log(value, index, this, arguments.length);  return value.repeat(3);   //必须指定返回值,否则返回 undefined}, obj);
// return 的 value 重复了三遍,最后返回的数组为 ["aaa","bbb","ccc"]

// 如果这里不指定 this 的话,加工函数完全可以是一个箭头函数。上述代码可以简写为如下形式。Array.from(obj, (value) => value.repeat(3));// 控制台返回 (3) ["aaa", "bbb", "ccc"]
复制代码


除了上述 obj 对象以外,拥有迭代器的对象还包括 String、Set、Map 等,Array.from 统统可以处理,请看下面的代码。


// StringArray.from('abc');         // ["a", "b", "c"]// SetArray.from(new Set(['abc', 'def'])); // ["abc", "def"]// MapArray.from(new Map([[1, 'ab'], [2, 'de']])); // [[1, 'ab'], [2, 'de']]
复制代码


3. Array 的判断


在 ES5 提供该方法之前,我们至少有如下 5 种方式去判断一个变量是否为数组。


var a = [];// 1.基于instanceofa instanceof Array;// 2.基于constructora.constructor === Array;// 3.基于Object.prototype.isPrototypeOfArray.prototype.isPrototypeOf(a);// 4.基于getPrototypeOfObject.getPrototypeOf(a) === Array.prototype;// 5.基于Object.prototype.toStringObject.prototype.toString.apply(a) === '[object Array]';
复制代码


ES6 之后新增了一个 Array.isArray 方法,能直接判断数据类型是否为数组,但是如果 isArray 不存在,那么 Array.isArray 的 polyfill 通常可以这样写:


if (!Array.isArray){  Array.isArray = function(arg){    return Object.prototype.toString.call(arg) === '[object Array]';  };}
复制代码


4. 改变自身的方法


基于 ES6,会改变自身值的方法一共有 9 个,分别为 pop、push、reverse、shift、sort、splice、unshift,以及两个 ES6 新增的方法 copyWithin 和 fill


// pop方法var array = ["cat", "dog", "cow", "chicken", "mouse"];var item = array.pop();console.log(array); // ["cat", "dog", "cow", "chicken"]console.log(item); // mouse// push方法var array = ["football", "basketball",  "badminton"];var i = array.push("golfball");console.log(array); // ["football", "basketball", "badminton", "golfball"]console.log(i); // 4// reverse方法var array = [1,2,3,4,5];var array2 = array.reverse();console.log(array); // [5,4,3,2,1]console.log(array2===array); // true// shift方法var array = [1,2,3,4,5];var item = array.shift();console.log(array); // [2,3,4,5]console.log(item); // 1// unshift方法var array = ["red", "green", "blue"];var length = array.unshift("yellow");console.log(array); // ["yellow", "red", "green", "blue"]console.log(length); // 4// sort方法var array = ["apple","Boy","Cat","dog"];var array2 = array.sort();console.log(array); // ["Boy", "Cat", "apple", "dog"]console.log(array2 == array); // true// splice方法var array = ["apple","boy"];var splices = array.splice(1,1);console.log(array); // ["apple"]console.log(splices); // ["boy"]// copyWithin方法var array = [1,2,3,4,5]; var array2 = array.copyWithin(0,3);console.log(array===array2,array2);  // true [4, 5, 3, 4, 5]// fill方法var array = [1,2,3,4,5];var array2 = array.fill(10,0,3);console.log(array===array2,array2); // true [10, 10, 10, 4, 5], 可见数组区间[0,3]的元素全部替换为10
复制代码


5. 不改变自身的方法


基于 ES7,不会改变自身的方法也有 9 个,分别为 concat、join、slice、toString、toLocaleString、indexOf、lastIndexOf、未形成标准的 toSource,以及 ES7 新增的方法 includes


// concat方法var array = [1, 2, 3];var array2 = array.concat(4,[5,6],[7,8,9]);console.log(array2); // [1, 2, 3, 4, 5, 6, 7, 8, 9]console.log(array); // [1, 2, 3], 可见原数组并未被修改// join方法var array = ['We', 'are', 'Chinese'];console.log(array.join()); // "We,are,Chinese"console.log(array.join('+')); // "We+are+Chinese"// slice方法var array = ["one", "two", "three","four", "five"];console.log(array.slice()); // ["one", "two", "three","four", "five"]console.log(array.slice(2,3)); // ["three"]// toString方法var array = ['Jan', 'Feb', 'Mar', 'Apr'];var str = array.toString();console.log(str); // Jan,Feb,Mar,Apr// tolocalString方法var array= [{name:'zz'}, 123, "abc", new Date()];var str = array.toLocaleString();console.log(str); // [object Object],123,abc,2016/1/5 下午1:06:23// indexOf方法var array = ['abc', 'def', 'ghi','123'];console.log(array.indexOf('def')); // 1// includes方法var array = [-0, 1, 2];console.log(array.includes(+0)); // trueconsole.log(array.includes(1)); // truevar array = [NaN];console.log(array.includes(NaN)); // true
复制代码


其中 includes 方法需要注意的是,如果元素中有 0,那么在判断过程中不论是 +0 还是 -0 都会判断为 True,这里的 includes 忽略了 +0 和 -0


6. 数组遍历的方法


基于 ES6,不会改变自身的遍历方法一共有 12 个,分别为 forEach、every、some、filter、map、reduce、reduceRight,以及 ES6 新增的方法 entries、find、findIndex、keys、values


// forEach方法var array = [1, 3, 5];var obj = {name:'cc'};var sReturn = array.forEach(function(value, index, array){  array[index] = value;  console.log(this.name); // cc被打印了三次, this指向obj},obj);console.log(array); // [1, 3, 5]console.log(sReturn); // undefined, 可见返回值为undefined// every方法var o = {0:10, 1:8, 2:25, length:3};var bool = Array.prototype.every.call(o,function(value, index, obj){  return value >= 8;},o);console.log(bool); // true// some方法var array = [18, 9, 10, 35, 80];var isExist = array.some(function(value, index, array){  return value > 20;});console.log(isExist); // true // map 方法var array = [18, 9, 10, 35, 80];array.map(item => item + 1);console.log(array);  // [19, 10, 11, 36, 81]// filter 方法var array = [18, 9, 10, 35, 80];var array2 = array.filter(function(value, index, array){  return value > 20;});console.log(array2); // [35, 80]// reduce方法var array = [1, 2, 3, 4];var s = array.reduce(function(previousValue, value, index, array){  return previousValue * value;},1);console.log(s); // 24// ES6写法更加简洁array.reduce((p, v) => p * v); // 24// reduceRight方法 (和reduce的区别就是从后往前累计)var array = [1, 2, 3, 4];array.reduceRight((p, v) => p * v); // 24// entries方法var array = ["a", "b", "c"];var iterator = array.entries();console.log(iterator.next().value); // [0, "a"]console.log(iterator.next().value); // [1, "b"]console.log(iterator.next().value); // [2, "c"]console.log(iterator.next().value); // undefined, 迭代器处于数组末尾时, 再迭代就会返回undefined// find & findIndex方法var array = [1, 3, 5, 7, 8, 9, 10];function f(value, index, array){  return value%2==0;     // 返回偶数}function f2(value, index, array){  return value > 20;     // 返回大于20的数}console.log(array.find(f)); // 8console.log(array.find(f2)); // undefinedconsole.log(array.findIndex(f)); // 4console.log(array.findIndex(f2)); // -1// keys方法[...Array(10).keys()];     // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9][...new Array(10).keys()]; // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]// values方法var array = ["abc", "xyz"];var iterator = array.values();console.log(iterator.next().value);//abcconsole.log(iterator.next().value);//xyz
复制代码


7. 总结



这些方法之间存在很多共性,如下:


  • 所有插入元素的方法,比如 push、unshift 一律返回数组新的长度;

  • 所有删除元素的方法,比如 pop、shift、splice 一律返回删除的元素,或者返回删除的多个元素组成的数组;

  • 部分遍历方法,比如 forEach、every、some、filter、map、find、findIndex,它们都包含 function(value,index,array){}thisArg 这样两个形参。


数组和字符串方法




二、理解 JS 的类数组


在 JavaScript 中有哪些情况下的对象是类数组呢?主要有以下几种


  • 函数里面的参数对象 arguments

  • getElementsByTagName/ClassName/Name 获得的 HTMLCollection

  • querySelector 获得的 NodeList


1. arguments 对象


arguments 对象是函数中传递的参数值的集合。它是一个类似数组的对象,因为它有一个length属性,我们可以使用数组索引表示法arguments[1]来访问单个值,但它没有数组中的内置方法,如:forEach、reduce、filter 和 map。


function foo(name, age, sex) {    console.log(arguments);    console.log(typeof arguments);    console.log(Object.prototype.toString.call(arguments));}foo('jack', '18', 'male');
复制代码


这段代码比较容易,就是直接将这个函数的 arguments 在函数内部打印出来,那么我们看下这个 arguments 打印出来的结果,请看控制台的这张截图。



从结果中可以看到,typeof 这个 arguments 返回的是 object,通过 Object.prototype.toString.call 返回的结果是 '[object arguments]',可以看出来返回的不是 '[object array]',说明 arguments 和数组还是有区别的。


我们可以使用Array.prototype.slicearguments对象转换成一个数组。


function one() {  return Array.prototype.slice.call(arguments);}
复制代码


注意:箭头函数中没有 arguments 对象。


function one() {  return arguments;}const two = function () {  return arguments;}const three = function three() {  return arguments;}
const four = () => arguments;
four(); // Throws an error - arguments is not defined
复制代码


当我们调用函数 four 时,它会抛出一个 ReferenceError: arguments is not defined error。使用 rest 语法,可以解决这个问题。


const four = (...args) => args;
复制代码


这会自动将所有参数值放入数组中。


arguments 不仅仅有一个 length 属性,还有一个 callee 属性,我们接下来看看这个 callee 是干什么的,代码如下所示


function foo(name, age, sex) {    console.log(arguments.callee);}foo('jack', '18', 'male');
复制代码



从控制台可以看到,输出的就是函数自身,如果在函数内部直接执行调用 callee 的话,那它就会不停地执行当前函数,直到执行到内存溢出


2. HTMLCollection


HTMLCollection 简单来说是 HTML DOM 对象的一个接口,这个接口包含了获取到的 DOM 元素集合,返回的类型是类数组对象,如果用 typeof 来判断的话,它返回的是 'object'。它是及时更新的,当文档中的 DOM 变化时,它也会随之变化。


描述起来比较抽象,还是通过一段代码来看下 HTMLCollection 最后返回的是什么,我们先随便找一个页面中有 form 表单的页面,在控制台中执行下述代码


var elem1, elem2;// document.forms 是一个 HTMLCollectionelem1 = document.forms[0];elem2 = document.forms.item(0);console.log(elem1);console.log(elem2);console.log(typeof elem1);console.log(Object.prototype.toString.call(elem1));
复制代码


在这个有 form 表单的页面执行上面的代码,得到的结果如下。



可以看到,这里打印出来了页面第一个 form 表单元素,同时也打印出来了判断类型的结果,说明打印的判断的类型和 arguments 返回的也比较类似,typeof 返回的都是 'object',和上面的类似。


另外需要注意的一点就是 HTML DOM 中的 HTMLCollection 是即时更新的,当其所包含的文档结构发生改变时,它会自动更新。下面我们再看最后一个 NodeList 类数组。


3. NodeList


NodeList 对象是节点的集合,通常是由 querySlector 返回的。NodeList 不是一个数组,也是一种类数组。虽然 NodeList 不是一个数组,但是可以使用 for...of 来迭代。在一些情况下,NodeList 是一个实时集合,也就是说,如果文档中的节点树发生变化,NodeList 也会随之变化。我们还是利用代码来理解一下 Nodelist 这种类数组。


var list = document.querySelectorAll('input[type=checkbox]');for (var checkbox of list) {  checkbox.checked = true;}console.log(list);console.log(typeof list);console.log(Object.prototype.toString.call(list));
复制代码


从上面的代码执行的结果中可以发现,我们是通过有 CheckBox 的页面执行的代码,在结果可中输出了一个 NodeList 类数组,里面有一个 CheckBox 元素,并且我们判断了它的类型,和上面的 arguments 与 HTMLCollection 其实是类似的,执行结果如下图所示。



4. 类数组应用场景


  1. 遍历参数操作


我们在函数内部可以直接获取 arguments 这个类数组的值,那么也可以对于参数进行一些操作,比如下面这段代码,我们可以将函数的参数默认进行求和操作。


function add() {    var sum =0,        len = arguments.length;    for(var i = 0; i < len; i++){        sum += arguments[i];    }    return sum;}add()                           // 0add(1)                          // 1add(1,2)                       // 3add(1,2,3,4);                   // 10
复制代码


  1. 定义链接字符串函数


我们可以通过 arguments 这个例子定义一个函数来连接字符串。这个函数唯一正式声明了的参数是一个字符串,该参数指定一个字符作为衔接点来连接字符串。该函数定义如下。


// 这段代码说明了,你可以传递任意数量的参数到该函数,并使用每个参数作为列表中的项创建列表进行拼接。从这个例子中也可以看出,我们可以在日常编码中采用这样的代码抽象方式,把需要解决的这一类问题,都抽象成通用的方法,来提升代码的可复用性function myConcat(separa) {  var args = Array.prototype.slice.call(arguments, 1);  return args.join(separa);}myConcat(", ", "red", "orange", "blue");// "red, orange, blue"myConcat("; ", "elephant", "lion", "snake");// "elephant; lion; snake"myConcat(". ", "one", "two", "three", "four", "five");// "one. two. three. four. five"
复制代码


  1. 传递参数使用


// 使用 apply 将 foo 的参数传递给 barfunction foo() {    bar.apply(this, arguments);}function bar(a, b, c) {   console.log(a, b, c);}foo(1, 2, 3)   //1 2 3
复制代码


5. 如何将类数组转换成数组


  1. 类数组借用数组方法转数组


function sum(a, b) {  let args = Array.prototype.slice.call(arguments); // let args = [].slice.call(arguments); // 这样写也是一样效果  console.log(args.reduce((sum, cur) => sum + cur));}sum(1, 2);  // 3function sum(a, b) {  let args = Array.prototype.concat.apply([], arguments);  console.log(args.reduce((sum, cur) => sum + cur));}sum(1, 2);  // 3
复制代码


  1. ES6 的方法转数组


function sum(a, b) {  let args = Array.from(arguments);  console.log(args.reduce((sum, cur) => sum + cur));}sum(1, 2);    // 3function sum(a, b) {  let args = [...arguments];  console.log(args.reduce((sum, cur) => sum + cur));}sum(1, 2);    // 3function sum(...args) {  console.log(args.reduce((sum, cur) => sum + cur));}sum(1, 2);    // 3
复制代码


Array.fromES6 的展开运算符,都可以把 arguments这个类数组转换成数组 args


类数组和数组的异同点



在前端工作中,开发者往往会忽视对类数组的学习,其实在高级 JavaScript 编程中经常需要将类数组向数组转化,尤其是一些比较复杂的开源项目,经常会看到函数中处理参数的写法,例如:[].slice.call(arguments) 这行代码。


三、实现数组扁平化的 6 种方式


1. 方法一:普通的递归实


普通的递归思路很容易理解,就是通过循环递归的方式,一项一项地去遍历,如果每一项还是一个数组,那么就继续往下遍历,利用递归程序的方法,来实现数组的每一项的连接。我们来看下这个方法是如何实现的,如下所示


// 方法1var a = [1, [2, [3, 4, 5]]];function flatten(arr) {  let result = [];
for(let i = 0; i < arr.length; i++) { if(Array.isArray(arr[i])) { result = result.concat(flatten(arr[i])); } else { result.push(arr[i]); } } return result;}flatten(a); // [1, 2, 3, 4,5]
复制代码


从上面这段代码可以看出,最后返回的结果是扁平化的结果,这段代码核心就是循环遍历过程中的递归操作,就是在遍历过程中发现数组元素还是数组的时候进行递归操作,把数组的结果通过数组的 concat 方法拼接到最后要返回的 result 数组上,那么最后输出的结果就是扁平化后的数组


2. 方法二:利用 reduce 函数迭代


从上面普通的递归函数中可以看出,其实就是对数组的每一项进行处理,那么我们其实也可以用 reduce 来实现数组的拼接,从而简化第一种方法的代码,改造后的代码如下所示。


// 方法2var arr = [1, [2, [3, 4]]];function flatten(arr) {    return arr.reduce(function(prev, next){        return prev.concat(Array.isArray(next) ? flatten(next) : next)    }, [])}console.log(flatten(arr));//  [1, 2, 3, 4,5]
复制代码


3. 方法三:扩展运算符实现


这个方法的实现,采用了扩展运算符和 some 的方法,两者共同使用,达到数组扁平化的目的,还是来看一下代码


// 方法3var arr = [1, [2, [3, 4]]];function flatten(arr) {    while (arr.some(item => Array.isArray(item))) {        arr = [].concat(...arr);    }    return arr;}console.log(flatten(arr)); //  [1, 2, 3, 4,5]
复制代码


从执行的结果中可以发现,我们先用数组的 some 方法把数组中仍然是组数的项过滤出来,然后执行 concat 操作,利用 ES6 的展开运算符,将其拼接到原数组中,最后返回原数组,达到了预期的效果。


前三种实现数组扁平化的方式其实是最基本的思路,都是通过最普通递归思路衍生的方法,尤其是前两种实现方法比较类似。值得注意的是 reduce 方法,它可以在很多应用场景中实现,由于 reduce 这个方法提供的几个参数比较灵活,能解决很多问题,所以是值得熟练使用并且精通的


4. 方法四:split 和 toString 共同处理


我们也可以通过 split 和 toString 两个方法,来共同实现数组扁平化,由于数组会默认带一个 toString 的方法,所以可以把数组直接转换成逗号分隔的字符串,然后再用 split 方法把字符串重新转换为数组,如下面的代码所示。


// 方法4var arr = [1, [2, [3, 4]]];function flatten(arr) {    return arr.toString().split(',');}console.log(flatten(arr)); //  [1, 2, 3, 4]
复制代码


通过这两个方法可以将多维数组直接转换成逗号连接的字符串,然后再重新分隔成数组,你可以在控制台执行一下查看结果。


5. 方法五:调用 ES6 中的 flat


我们还可以直接调用 ES6 中的 flat 方法,可以直接实现数组扁平化。先来看下 flat 方法的语法:


arr.flat([depth])
复制代码


其中 depth 是 flat 的参数,depth 是可以传递数组的展开深度(默认不填、数值是 1),即展开一层数组。那么如果多层的该怎么处理呢?参数也可以传进 Infinity,代表不论多少层都要展开。那么我们来看下,用 flat 方法怎么实现,请看下面的代码。


// 方法5var arr = [1, [2, [3, 4]]];function flatten(arr) {  return arr.flat(Infinity);}console.log(flatten(arr)); //  [1, 2, 3, 4,5]
复制代码


  • 可以看出,一个嵌套了两层的数组,通过将 flat 方法的参数设置为 Infinity,达到了我们预期的效果。其实同样也可以设置成 2,也能实现这样的效果。

  • 因此,你在编程过程中,发现对数组的嵌套层数不确定的时候,最好直接使用 Infinity,可以达到扁平化。下面我们再来看最后一种场景


6. 方法六:正则和 JSON 方法共同处理


我们在第四种方法中已经尝试了用 toString 方法,其中仍然采用了将 JSON.stringify 的方法先转换为字符串,然后通过正则表达式过滤掉字符串中的数组的方括号,最后再利用 JSON.parse 把它转换成数组。请看下面的代码


// 方法 6let arr = [1, [2, [3, [4, 5]]], 6];function flatten(arr) {  let str = JSON.stringify(arr);  str = str.replace(/(\[|\])/g, '');  str = '[' + str + ']';  return JSON.parse(str); }console.log(flatten(arr)); //  [1, 2, 3, 4,5]
复制代码


可以看到,其中先把传入的数组转换成字符串,然后通过正则表达式的方式把括号过滤掉,这部分正则的表达式你不太理解的话,可以看看下面的图片



通过这个在线网站 https://regexper.com/ 可以把正则分析成容易理解的可视化的逻辑脑图。其中我们可以看到,匹配规则是:全局匹配(g)左括号或者右括号,将它们替换成空格,最后返回处理后的结果。之后拿着正则处理好的结果重新在外层包裹括号,最后通过 JSON.parse 转换成数组返回。



四、如何用 JS 实现各种数组排序


数据结构算法中排序有很多种,常见的、不常见的,至少包含十种以上。根据它们的特性,可以大致分为两种类型:比较类排序和非比较类排序。


  • 比较类排序 :通过比较来决定元素间的相对次序,其时间复杂度不能突破 O(nlogn),因此也称为非线性时间比较类排序。

  • 非比较类排序 :不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此也称为线性时间非比较类排序。


我们通过一张图片来看看这两种分类方式分别包括哪些排序方法。



非比较类的排序在实际情况中用的比较少


1. 冒泡排序


冒泡排序是最基础的排序,一般在最开始学习数据结构的时候就会接触它。冒泡排序是一次比较两个元素,如果顺序是错误的就把它们交换过来。走访数列的工作会重复地进行,直到不需要再交换,也就是说该数列已经排序完成。请看下面的代码。


var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];function bubbleSort(array) {  const len = array.length  if (len < 2) return array  for (let i = 0; i < len; i++) {    for (let j = 0; j < i; j++) {      if (array[j] > array[i]) {        const temp = array[j]        array[j] = array[i]        array[i] = temp      }    }  }  return array}bubbleSort(a);  // [1, 1, 3, 3, 6, 6, 23, 34, 76, 221, 222, 456]
复制代码


从上面这段代码可以看出,最后返回的是排好序的结果。因为冒泡排序实在太基础和简单,这里就不过多赘述了。下面我们来看看快速排序法


2. 快速排序


快速排序的基本思想是通过一趟排序,将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可以分别对这两部分记录继续进行排序,以达到整个序列有序。


var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];function quickSort(array) {  var quick = function(arr) {    if (arr.length <= 1) return arr    const len = arr.length    const index = Math.floor(len >> 1)    const pivot = arr.splice(index, 1)[0]    const left = []    const right = []    for (let i = 0; i < len; i++) {      if (arr[i] > pivot) {        right.push(arr[i])      } else if (arr[i] <= pivot) {        left.push(arr[i])      }    }    return quick(left).concat([pivot], quick(right))  }  const result = quick(array)  return result}quickSort(a);//  [1, 1, 3, 3, 6, 6, 23, 34, 76, 221, 222, 456]
复制代码


上面的代码在控制台执行之后,也可以得到预期的结果。最主要的思路是从数列中挑出一个元素,称为 “基准”(pivot);然后重新排序数列,所有元素比基准值小的摆放在基准前面、比基准值大的摆在基准的后面;在这个区分搞定之后,该基准就处于数列的中间位置;然后把小于基准值元素的子数列(left)和大于基准值元素的子数列(right)递归地调用 quick 方法排序完成,这就是快排的思路。


3. 插入排序


插入排序算法描述的是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入,从而达到排序的效果。来看一下代码


var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];function insertSort(array) {  const len = array.length  let current  let prev  for (let i = 1; i < len; i++) {    current = array[i]    prev = i - 1    while (prev >= 0 && array[prev] > current) {      array[prev + 1] = array[prev]      prev--    }    array[prev + 1] = current  }  return array}insertSort(a); // [1, 1, 3, 3, 6, 6, 23, 34, 76, 221, 222, 456]
复制代码


从执行的结果中可以发现,通过插入排序这种方式实现了排序效果。插入排序的思路是基于数组本身进行调整的,首先循环遍历从 i 等于 1 开始,拿到当前的 current 的值,去和前面的值比较,如果前面的大于当前的值,就把前面的值和当前的那个值进行交换,通过这样不断循环达到了排序的目的


4. 选择排序


选择排序是一种简单直观的排序算法。它的工作原理是,首先将最小的元素存放在序列的起始位置,再从剩余未排序元素中继续寻找最小元素,然后放到已排序的序列后面……以此类推,直到所有元素均排序完毕。请看下面的代码。


var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];function selectSort(array) {  const len = array.length  let temp  let minIndex  for (let i = 0; i < len - 1; i++) {    minIndex = i    for (let j = i + 1; j < len; j++) {      if (array[j] <= array[minIndex]) {        minIndex = j      }    }    temp = array[i]    array[i] = array[minIndex]    array[minIndex] = temp  }  return array}selectSort(a); // [1, 1, 3, 3, 6, 6, 23, 34, 76, 221, 222, 456]
复制代码


这样,通过选择排序的方法同样也可以实现数组的排序,从上面的代码中可以看出该排序是表现最稳定的排序算法之一,因为无论什么数据进去都是 O(n 平方) 的时间复杂度,所以用到它的时候,数据规模越小越好


5. 堆排序


堆排序是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质,即子结点的键值或索引总是小于(或者大于)它的父节点。堆的底层实际上就是一棵完全二叉树,可以用数组实现。


根节点最大的堆叫作大根堆,根节点最小的堆叫作小根堆,你可以根据从大到小排序或者从小到大来排序,分别建立对应的堆就可以。请看下面的代码


var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];function heap_sort(arr) {  var len = arr.length  var k = 0  function swap(i, j) {    var temp = arr[i]    arr[i] = arr[j]    arr[j] = temp  }  function max_heapify(start, end) {    var dad = start    var son = dad * 2 + 1    if (son >= end) return    if (son + 1 < end && arr[son] < arr[son + 1]) {      son++    }    if (arr[dad] <= arr[son]) {      swap(dad, son)      max_heapify(son, end)    }  }  for (var i = Math.floor(len / 2) - 1; i >= 0; i--) {    max_heapify(i, len)  }
for (var j = len - 1; j > k; j--) { swap(0, j) max_heapify(0, j) }
return arr}heap_sort(a); // [1, 1, 3, 3, 6, 6, 23, 34, 76, 221, 222, 456]
复制代码


从代码来看,堆排序相比上面几种排序整体上会复杂一些,不太容易理解。不过你应该知道两点:


  • 一是堆排序最核心的点就在于排序前先建堆;

  • 二是由于堆其实就是完全二叉树,如果父节点的序号为 n,那么叶子节点的序号就分别是 2n2n+1


你理解了这两点,再看代码就比较好理解了。堆排序最后有两个循环:第一个是处理父节点的顺序;第二个循环则是根据父节点和叶子节点的大小对比,进行堆的调整。通过这两轮循环的调整,最后堆排序完成。


6. 归并排序


归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。我们先看一下代码。


var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];function mergeSort(array) {  const merge = (right, left) => {    const result = []    let il = 0    let ir = 0    while (il < left.length && ir < right.length) {      if (left[il] < right[ir]) {        result.push(left[il++])      } else {        result.push(right[ir++])      }    }    while (il < left.length) {      result.push(left[il++])    }    while (ir < right.length) {      result.push(right[ir++])    }    return result  }  const mergeSort = array => {    if (array.length === 1) { return array }    const mid = Math.floor(array.length / 2)    const left = array.slice(0, mid)    const right = array.slice(mid, array.length)    return merge(mergeSort(left), mergeSort(right))  }  return mergeSort(array)}mergeSort(a); // [1, 1, 3, 3, 6, 6, 23, 34, 76, 221, 222, 456]
复制代码


从上面这段代码中可以看到,通过归并排序可以得到想要的结果。上面提到了分治的思路,你可以从 mergeSort 方法中看到,通过 mid 可以把该数组分成左右两个数组,分别对这两个进行递归调用排序方法,最后将两个数组按照顺序归并起来。


归并排序是一种稳定的排序方法,和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好得多,因为始终都是 O(nlogn) 的时间复杂度。而代价是需要额外的内存空间。



其中你可以看到排序相关的时间复杂度和空间复杂度以及稳定性的情况,如果遇到需要自己实现排序的时候,可以根据它们的空间和时间复杂度综合考量,选择最适合的排序方法

继承


涉及面试题:原型如何实现继承?Class 如何实现继承?Class 本质是什么?


首先先来讲下 class,其实在 JS中并不存在类,class 只是语法糖,本质还是函数


class Person {}Person instanceof Function // true
复制代码


组合继承


组合继承是最常用的继承方式


function Parent(value) {  this.val = value}Parent.prototype.getValue = function() {  console.log(this.val)}function Child(value) {  Parent.call(this, value)}Child.prototype = new Parent()
const child = new Child(1)
child.getValue() // 1child instanceof Parent // true
复制代码


  • 以上继承的方式核心是在子类的构造函数中通过 Parent.call(this) 继承父类的属性,然后改变子类的原型为 new Parent() 来继承父类的函数。

  • 这种继承方式优点在于构造函数可以传参,不会与父类引用属性共享,可以复用父类的函数,但是也存在一个缺点就是在继承父类函数的时候调用了父类构造函数,导致子类的原型上多了不需要的父类属性,存在内存上的浪费


寄生组合继承


这种继承方式对组合继承进行了优化,组合继承缺点在于继承父类函数时调用了构造函数,我们只需要优化掉这点就行了


function Parent(value) {  this.val = value}Parent.prototype.getValue = function() {  console.log(this.val)}
function Child(value) { Parent.call(this, value)}Child.prototype = Object.create(Parent.prototype, { constructor: { value: Child, enumerable: false, writable: true, configurable: true }})
const child = new Child(1)
child.getValue() // 1child instanceof Parent // true
复制代码


以上继承实现的核心就是将父类的原型赋值给了子类,并且将构造函数设置为子类,这样既解决了无用的父类属性问题,还能正确的找到子类的构造函数。


Class 继承


以上两种继承方式都是通过原型去解决的,在 ES6 中,我们可以使用 class 去实现继承,并且实现起来很简单


class Parent {  constructor(value) {    this.val = value  }  getValue() {    console.log(this.val)  }}class Child extends Parent {  constructor(value) {    super(value)    this.val = value  }}let child = new Child(1)child.getValue() // 1child instanceof Parent // true
复制代码


class 实现继承的核心在于使用 extends 表明继承自哪个父类,并且在子类构造函数中必须调用 super,因为这段代码可以看成 Parent.call(this, value)


ES5 和 ES6 继承的区别:


  • ES6 继承的子类需要调用 super() 才能拿到子类,ES5 的话是通过 apply 这种绑定的方式

  • 类声明不会提升,和 let 这些一致


function Super() {}Super.prototype.getNumber = function() {  return 1}
function Sub() {}Sub.prototype = Object.create(Super.prototype, { constructor: { value: Sub, enumerable: false, writable: true, configurable: true }})let s = new Sub()s.getNumber()
复制代码


以下详细讲解几种常见的继承方式


1. 方式 1: 借助 call


function Parent1(){    this.name = 'parent1';  }  function Child1(){    Parent1.call(this);    this.type = 'child1'  }  console.log(new Child1);
复制代码


这样写的时候子类虽然能够拿到父类的属性值,但是问题是父类原型对象中一旦存在方法那么子类无法继承。那么引出下面的方法。


2. 方式 2: 借助原型链


function Parent2() {    this.name = 'parent2';    this.play = [1, 2, 3]  }  function Child2() {    this.type = 'child2';  }  Child2.prototype = new Parent2();
console.log(new Child2());
复制代码


看似没有问题,父类的方法和属性都能够访问,但实际上有一个潜在的不足。举个例子:


var s1 = new Child2();var s2 = new Child2();s1.play.push(4);console.log(s1.play, s2.play);
复制代码


可以看到控制台:



明明我只改变了 s1 的 play 属性,为什么 s2 也跟着变了呢?很简单,因为两个实例使用的是同一个原型对象。


那么还有更好的方式么?


3. 方式 3:将前两种组合


function Parent3 () {    this.name = 'parent3';    this.play = [1, 2, 3];  }  function Child3() {    Parent3.call(this);    this.type = 'child3';  }  Child3.prototype = new Parent3();  var s3 = new Child3();  var s4 = new Child3();  s3.play.push(4);  console.log(s3.play, s4.play);
复制代码


可以看到控制台:



之前的问题都得以解决。但是这里又徒增了一个新问题,那就是Parent3的构造函数会多执行了一次(Child3.prototype = new Parent3();)。这是我们不愿看到的。那么如何解决这个问题?


4. 方式 4: 组合继承的优化 1


function Parent4 () {    this.name = 'parent4';    this.play = [1, 2, 3];  }  function Child4() {    Parent4.call(this);    this.type = 'child4';  }  Child4.prototype = Parent4.prototype;
复制代码


这里让将父类原型对象直接给到子类,父类构造函数只执行一次,而且父类属性和方法均能访问,但是我们来测试一下:


var s3 = new Child4();var s4 = new Child4();console.log(s3)
复制代码



子类实例的构造函数是 Parent4,显然这是不对的,应该是 Child4。


5. 方式 5(最推荐使用): 组合继承的优化 2


function Parent5 () {    this.name = 'parent5';    this.play = [1, 2, 3];  }  function Child5() {    Parent5.call(this);    this.type = 'child5';  }  Child5.prototype = Object.create(Parent5.prototype);  Child5.prototype.constructor = Child5;
复制代码


这是最推荐的一种方式,接近完美的继承,它的名字也叫做寄生组合继承。


6. ES6 的 extends 被编译后的 JavaScript 代码


ES6 的代码最后都是要在浏览器上能够跑起来的,这中间就利用了 babel 这个编译工具,将 ES6 的代码编译成 ES5 让一些不支持新语法的浏览器也能运行。


那最后编译成了什么样子呢?


function _possibleConstructorReturn(self, call) {    // ...    return call && (typeof call === 'object' || typeof call === 'function') ? call : self;}
function _inherits(subClass, superClass) { // ... //看到没有 subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass;}

var Parent = function Parent() { // 验证是否是 Parent 构造出来的 this _classCallCheck(this, Parent);};
var Child = (function (_Parent) { _inherits(Child, _Parent);
function Child() { _classCallCheck(this, Child);
return _possibleConstructorReturn(this, (Child.__proto__ || Object.getPrototypeOf(Child)).apply(this, arguments)); }
return Child;}(Parent));
复制代码


核心是_inherits函数,可以看到它采用的依然也是第五种方式————寄生组合继承方式,同时证明了这种方式的成功。不过这里加了一个Object.setPrototypeOf(subClass, superClass),这是用来干啥的呢?


答案是用来继承父类的静态方法。这也是原来的继承方式疏忽掉的地方。


追问: 面向对象的设计一定是好的设计吗?


不一定。从继承的角度说,这一设计是存在巨大隐患的。

定时器与 requestAnimationFrame、requestIdleCallback

1. setTimeout


setTimeout 的运行机制:执行该语句时,是立即把当前定时器代码推入事件队列,当定时器在事件列表中满足设置的时间值时将传入的函数加入任务队列,之后的执行就交给任务队列负责。但是如果此时任务队列不为空,则需等待,所以执行定时器内代码的时间可能会大于设置的时间


setTimeout(() => {    console.log(1);}, 0)console.log(2);
复制代码


输出 2, 1;


setTimeout的第二个参数表示在执行代码前等待的毫秒数。上面代码中,设置为 0,表面意思为 执行代码前等待的毫秒数为 0,即立即执行。但实际上的运行结果我们也看到了,并不是表面上看起来的样子,千万不要被欺骗了。


实际上,上面的代码并不是立即执行的,这是因为setTimeout有一个最小执行时间,HTML5 标准规定了setTimeout()的第二个参数的最小值(最短间隔)不得低于4毫秒。 当指定的时间低于该时间时,浏览器会用最小允许的时间作为setTimeout的时间间隔,也就是说即使我们把setTimeout的延迟时间设置为 0,实际上可能为 4毫秒后才事件推入任务队列


定时器代码在被推送到任务队列前,会先被推入到事件列表中,当定时器在事件列表中满足设置的时间值时会被推到任务队列,但是如果此时任务队列不为空,则需等待,所以执行定时器内代码的时间可能会大于设置的时间


setTimeout(() => {    console.log(111);}, 100);
复制代码


上面代码表示100ms后执行console.log(111),但实际上实行的时间肯定是大于 100ms 后的, 100ms 只是表示 100ms 后将任务加入到"任务队列"中,必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以并没有办法保证,回调函数一定会在setTimeout()指定的时间执行。


2. setTimeout 和 setInterval 区别


  • setTimeout: 指定延期后调用函数,每次setTimeout计时到后就会去执行,然后执行一段时间后才继续setTimeout,中间就多了误差,(误差多少与代码的执行时间有关)。

  • setInterval:以指定周期调用函数,而setInterval则是每次都精确的隔一段时间推入一个事件(但是,事件的执行时间不一定就不准确,还有可能是这个事件还没执行完毕,下一个事件就来了).


btn.onclick = function(){    setTimeout(function(){        console.log(1);    },250);}
复制代码


击该按钮后,首先将onclick事件处理程序加入队列。该程序执行后才设置定时器,再有250ms后,指定的代码才被添加到队列中等待执行。 如果上面代码中的onclick事件处理程序执行了300ms,那么定时器的代码至少要在定时器设置之后的300ms后才会被执行。队列中所有的代码都要等到 javascript 进程空闲之后才能执行,而不管它们是如何添加到队列中的。



如图所示,尽管在255ms处添加了定时器代码,但这时候还不能执行,因为onclick事件处理程序仍在运行。定时器代码最早能执行的时机是在300ms处,即onclick事件处理程序结束之后。


3. setInterval 存在的一些问题:


JavaScript 中使用 setInterval 开启轮询。定时器代码可能在代码再次被添加到队列之前还没有完成执行,结果导致定时器代码连续运行好几次,而之间没有任何停顿。而 javascript 引擎对这个问题的解决是:当使用setInterval()时,仅当没有该定时器的任何其他代码实例时,才将定时器代码添加到队列中。这确保了定时器代码加入到队列中的最小时间间隔为指定间隔。


但是,这样会导致两个问题:


  • 某些间隔被跳过;

  • 多个定时器的代码执行之间的间隔可能比预期的小


假设,某个onclick事件处理程序使用setInterval()设置了200ms间隔的定时器。如果事件处理程序花了300ms多一点时间完成,同时定时器代码也花了差不多的时间,就会同时出现跳过某间隔的情况



例子中的第一个定时器是在205ms处添加到队列中的,但是直到过了300ms处才能执行。当执行这个定时器代码时,在 405ms 处又给队列添加了另一个副本。在下一个间隔,即 605ms 处,第一个定时器代码仍在运行,同时在队列中已经有了一个定时器代码的实例。结果是,在这个时间点上的定时器代码不会被添加到队列中


使用setTimeout构造轮询能保证每次轮询的间隔。


setTimeout(function () { console.log('我被调用了'); setTimeout(arguments.callee, 100);}, 100);
复制代码


calleearguments 对象的一个属性。它可以用于引用该函数的函数体内当前正在执行的函数。在严格模式下,第 5 版 ECMAScript (ES5) 禁止使用arguments.callee()。当一个函数必须调用自身的时候, 避免使用 arguments.callee(), 通过要么给函数表达式一个名字,要么使用一个函数声明.


setTimeout(function fn(){    console.log('我被调用了');    setTimeout(fn, 100);},100);
复制代码


这个模式链式调用了setTimeout(),每次函数执行的时候都会创建一个新的定时器。第二个setTimeout()调用当前执行的函数,并为其设置另外一个定时器。这样做的好处是,在前一个定时器代码执行完之前,不会向队列插入新的定时器代码,确保不会有任何缺失的间隔。而且,它可以保证在下一次定时器代码执行之前,至少要等待指定的间隔,避免了连续的运行。


4. requestAnimationFrame


4.1 60fps与设备刷新率


目前大多数设备的屏幕刷新率为60次/秒,如果在页面中有一个动画或者渐变效果,或者用户正在滚动页面,那么浏览器渲染动画或页面的每一帧的速率也需要跟设备屏幕的刷新率保持一致。


卡顿:其中每个帧的预算时间仅比16毫秒多一点(1秒/ 60 = 16.6毫秒)。但实际上,浏览器有整理工作要做,因此您的所有工作是需要在10毫秒内完成。如果无法符合此预算,帧率将下降,并且内容会在屏幕上抖动。此现象通常称为卡顿,会对用户体验产生负面影响。


跳帧: 假如动画切换在 16ms, 32ms, 48ms 时分别切换,跳帧就是假如到了 32ms,其他任务还未执行完成,没有去执行动画切帧,等到开始进行动画的切帧,已经到了该执行 48ms 的切帧。就好比你玩游戏的时候卡了,过了一会,你再看画面,它不会停留你卡的地方,或者这时你的角色已经挂掉了。必须在下一帧开始之前就已经绘制完毕;


Chrome devtool 查看实时 FPS, 打开 More tools => Rendering, 勾选 FPS meter


4.2 requestAnimationFrame实现动画


requestAnimationFrame是浏览器用于定时循环操作的一个接口,类似于 setTimeout,主要用途是按帧对网页进行重绘。


requestAnimationFrame 之前,主要借助 setTimeout/ setInterval 来编写 JS 动画,而动画的关键在于动画帧之间的时间间隔设置,这个时间间隔的设置有讲究,一方面要足够小,这样动画帧之间才有连贯性,动画效果才显得平滑流畅;另一方面要足够大,确保浏览器有足够的时间及时完成渲染。


显示器有固定的刷新频率(60Hz 或 75Hz),也就是说,每秒最多只能重绘 60 次或 75 次,requestAnimationFrame的基本思想就是与这个刷新频率保持同步,利用这个刷新频率进行页面重绘。此外,使用这个 API,一旦页面不处于浏览器的当前标签,就会自动停止刷新。这就节省了 CPU、GPU 和电力。


requestAnimationFrame 是在主线程上完成。这意味着,如果主线程非常繁忙,requestAnimationFrame的动画效果会大打折扣。


requestAnimationFrame 使用一个回调函数作为参数。这个回调函数会在浏览器重绘之前调用。


requestID = window.requestAnimationFrame(callback); 
window.requestAnimFrame = (function(){ return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.oRequestAnimationFrame || window.msRequestAnimationFrame || function( callback ){ window.setTimeout(callback, 1000 / 60); };})();
复制代码


上面的代码按照 1 秒钟 60 次(大约每 16.7 毫秒一次),来模拟requestAnimationFrame


5. requestIdleCallback()


MDN 上的解释:requestIdleCallback()方法将在浏览器的空闲时段内调用的函数排队。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间 timeout,则有可能为了在超时前执行函数而打乱执行顺序。


requestAnimationFrame会在每次屏幕刷新的时候被调用,而requestIdleCallback则会在每次屏幕刷新时,判断当前帧是否还有多余的时间,如果有,则会调用requestAnimationFrame的回调函数,



图片中是两个连续的执行帧,大致可以理解为两个帧的持续时间大概为 16.67,图中黄色部分就是空闲时间。所以,requestIdleCallback 中的回调函数仅会在每次屏幕刷新并且有空闲时间时才会被调用.


利用这个特性,我们可以在动画执行的期间,利用每帧的空闲时间来进行数据发送的操作,或者一些优先级比较低的操作,此时不会使影响到动画的性能,或者和requestAnimationFrame搭配,可以实现一些页面性能方面的的优化,


react 的 fiber 架构也是基于 requestIdleCallback 实现的, 并且在不支持的浏览器中提供了 polyfill


总结


  • 单线程模型和任务队列出发理解 setTimeout(fn, 0),并不是立即执行。

  • JS 动画, 用requestAnimationFrame 会比 setInterval 效果更好

  • requestIdleCallback()常用来切割长任务,利用空闲时间执行,避免主线程长时间阻塞

浏览器存储

我们经常需要对业务中的一些数据进行存储,通常可以分为 短暂性存储 和 持久性储存。


  • 短暂性的时候,我们只需要将数据存在内存中,只在运行时可用

  • 持久性存储,可以分为 浏览器端 与 服务器端

  • 浏览器:

  • cookie: 通常用于存储用户身份,登录状态等

  • http 中自动携带, 体积上限为 4K, 可自行设置过期时间

  • localStorage / sessionStorage: 长久储存/窗口关闭删除, 体积限制为 4~5M

  • indexDB

  • 服务器:

  • 分布式缓存 redis

  • 数据库


cookie 和 localSrorage、session、indexDB 的区别



从上表可以看到,cookie 已经不建议用于存储。如果没有大量数据存储需求的话,可以使用 localStoragesessionStorage 。对于不怎么改变的数据尽量使用 localStorage 存储,否则可以用 sessionStorage 存储。


对于 cookie,我们还需要注意安全性




  • Name,即该 Cookie 的名称。Cookie 一旦创建,名称便不可更改。

  • Value,即该 Cookie 的值。如果值为 Unicode 字符,需要为字符编码。如果值为二进制数据,则需要使用 BASE64 编码。

  • Max Age,即该 Cookie 失效的时间,单位秒,也常和 Expires 一起使用,通过它可以计算出其有效时间。Max Age如果为正数,则该 CookieMax Age 秒之后失效。如果为负数,则关闭浏览器时 Cookie 即失效,浏览器也不会以任何形式保存该 Cookie

  • Path,即该 Cookie 的使用路径。如果设置为 /path/,则只有路径为 /path/ 的页面可以访问该 Cookie。如果设置为 /,则本域名下的所有页面都可以访问该 Cookie

  • Domain,即可以访问该 Cookie 的域名。例如如果设置为 .zhihu.com,则所有以 zhihu.com,结尾的域名都可以访问该 CookieSize 字段,即此 Cookie 的大小。

  • Http 字段,即 Cookiehttponly 属性。若此属性为 true,则只有在 HTTP Headers 中会带有此 Cookie 的信息,而不能通过 document.cookie 来访问此 Cookie。

  • Secure,即该 Cookie 是否仅被使用安全协议传输。安全协议。安全协议有 HTTPS、SSL 等,在网络上传输数据之前先将数据加密。默认为 false

左右两边定宽,中间自适应

float,float + calc, 圣杯布局(设置 BFC,margin 负值法),flex


.wrap {  width: 100%;  height: 200px;}.wrap > div {  height: 100%;}/* 方案1 */.left {  width: 120px;  float: left;}.right {  float: right;  width: 120px;}.center {  margin: 0 120px; }/* 方案2 */.left {  width: 120px;  float: left;}.right {  float: right;  width: 120px;}.center {  width: calc(100% - 240px);  margin-left: 120px;}/* 方案3 */.wrap {  display: flex;}.left {  width: 120px;}.right {  width: 120px;}.center {  flex: 1;}
复制代码

清除浮动

  1. 在浮动元素后面添加 clear:both的空 div 元素


<div class="container">    <div class="left"></div>    <div class="right"></div>    <div style="clear:both"></div></div>
复制代码


  1. 给父元素添加 overflow:hidden 或者 auto 样式,触发BFC


<div class="container">    <div class="left"></div>    <div class="right"></div></div>
复制代码


.container{    width: 300px;    background-color: #aaa;    overflow:hidden;    zoom:1;   /*IE6*/}
复制代码


  1. 使用伪元素,也是在元素末尾添加一个点并带有 clear: both 属性的元素实现的。


<div class="container clearfix">    <div class="left"></div>    <div class="right"></div></div>
复制代码


.clearfix{    zoom: 1; /*IE6*/}.clearfix:after{    content: ".";    height: 0;    clear: both;    display: block;    visibility: hidden;}
复制代码


推荐使用第三种方法,不会在页面新增 div,文档结构更加清晰

HTTP/1.0 HTTP1.1 HTTP2.0 版本之间的差异

  • HTTP 0.9 :1991 年,原型版本,功能简陋,只有一个命令 GET,只支持纯文本内容,该版本已过时。

  • HTTP 1.0

  • 任何格式的内容都可以发送,这使得互联网不仅可以传输文字,还能传输图像、视频、二进制等文件。

  • 除了 GET 命令,还引入了 POST 命令和 HEAD 命令。

  • http 请求和回应的格式改变,除了数据部分,每次通信都必须包括头信息(HTTP header),用来描述一些元数据。

  • 只使用 header 中的 If-Modified-Since 和 Expires 作为缓存失效的标准。

  • 不支持断点续传,也就是说,每次都会传送全部的页面和数据。

  • 通常每台计算机只能绑定一个 IP,所以请求消息中的 URL 并没有传递主机名(hostname)

  • HTTP 1.1 http1.1 是目前最为主流的 http 协议版本,从 1999 年发布至今,仍是主流的 http 协议版本。

  • 引入了持久连接( persistent connection),即 TCP 连接默认不关闭,可以被多个请求复用,不用声明 Connection: keep-alive。长连接的连接时长可以通过请求头中的 keep-alive 来设置

  • 引入了管道机制( pipelining),即在同一个 TCP 连接里,客户端可以同时发送多个 请求,进一步改进了 HTTP 协议的效率。

  • HTTP 1.1 中新增加了 E-tag,If-Unmodified-Since, If-Match, If-None-Match 等缓存控制标头来控制缓存失效。

  • 支持断点续传,通过使用请求头中的 Range 来实现。

  • 使用了虚拟网络,在一台物理服务器上可以存在多个虚拟主机(Multi-homed Web Servers),并且它们共享一个 IP 地址。

  • 新增方法:PUT、 PATCH、 OPTIONS、 DELETE。

  • http1.x 版本问题

  • 在传输数据过程中,所有内容都是明文,客户端和服务器端都无法验证对方的身份,无法保证数据的安全性。

  • HTTP/1.1 版本默认允许复用 TCP 连接,但是在同一个 TCP 连接里,所有数据通信是按次序进行的,服务器通常在处理完一个回应后,才会继续去处理下一个,这样子就会造成队头阻塞。

  • http/1.x 版本支持 Keep-alive,用此方案来弥补创建多次连接产生的延迟,但是同样会给服务器带来压力,并且的话,对于单文件被不断请求的服务,Keep-alive 会极大影响性能,因为它在文件被请求之后还保持了不必要的连接很长时间。

  • HTTP 2.0

  • 二进制分帧 这是一次彻底的二进制协议,头信息和数据体都是二进制,并且统称为"帧":头信息帧和数据帧。

  • 头部压缩 HTTP 1.1 版本会出现 User-Agent、Cookie、Accept、Server、Range 等字段可能会占用几百甚至几千字节,而 Body 却经常只有几十字节,所以导致头部偏重。HTTP 2.0 使用 HPACK 算法进行压缩。

  • 多路复用 复用 TCP 连接,在一个连接里,客户端和浏览器都可以同时发送多个请求或回应,且不用按顺序一一对应,这样子解决了队头阻塞的问题。

  • 服务器推送 允许服务器未经请求,主动向客户端发送资源,即服务器推送。

  • 请求优先级 可以设置数据帧的优先级,让服务端先处理重要资源,优化用户体验。

TLS/SSL 的工作原理

TLS/SSL 全称安全传输层协议(Transport Layer Security), 是介于 TCP 和 HTTP 之间的一层安全协议,不影响原有的 TCP 协议和 HTTP 协议,所以使用 HTTPS 基本上不需要对 HTTP 页面进行太多的改造。


TLS/SSL 的功能实现主要依赖三类基本算法:散列函数 hash对称加密非对称加密。这三类算法的作用如下:


  • 基于散列函数验证信息的完整性

  • 对称加密算法采用协商的秘钥对数据加密

  • 非对称加密实现身份认证和秘钥协商

(1)散列函数 hash

常见的散列函数有 MD5、SHA1、SHA256。该函数的特点是单向不可逆,对输入数据非常敏感,输出的长度固定,任何数据的修改都会改变散列函数的结果,可以用于防止信息篡改并验证数据的完整性。


特点: 在信息传输过程中,散列函数不能三都实现信息防篡改,由于传输是明文传输,中间人可以修改信息后重新计算信息的摘要,所以需要对传输的信息和信息摘要进行加密。

(2)对称加密

对称加密的方法是,双方使用同一个秘钥对数据进行加密和解密。但是对称加密的存在一个问题,就是如何保证秘钥传输的安全性,因为秘钥还是会通过网络传输的,一旦秘钥被其他人获取到,那么整个加密过程就毫无作用了。 这就要用到非对称加密的方法。


常见的对称加密算法有 AES-CBC、DES、3DES、AES-GCM 等。相同的秘钥可以用于信息的加密和解密。掌握秘钥才能获取信息,防止信息窃听,其通讯方式是一对一。


特点: 对称加密的优势就是信息传输使用一对一,需要共享相同的密码,密码的安全是保证信息安全的基础,服务器和 N 个客户端通信,需要维持 N 个密码记录且不能修改密码。

(3)非对称加密

非对称加密的方法是,我们拥有两个秘钥,一个是公钥,一个是私钥。公钥是公开的,私钥是保密的。用私钥加密的数据,只有对应的公钥才能解密,用公钥加密的数据,只有对应的私钥才能解密。我们可以将公钥公布出去,任何想和我们通信的客户, 都可以使用我们提供的公钥对数据进行加密,这样我们就可以使用私钥进行解密,这样就能保证数据的安全了。但是非对称加密有一个缺点就是加密的过程很慢,因此如果每次通信都使用非对称加密的方式的话,反而会造成等待时间过长的问题。


常见的非对称加密算法有 RSA、ECC、DH 等。秘钥成对出现,一般称为公钥(公开)和私钥(保密)。公钥加密的信息只有私钥可以解开,私钥加密的信息只能公钥解开,因此掌握公钥的不同客户端之间不能相互解密信息,只能和服务器进行加密通信,服务器可以实现一对多的的通信,客户端也可以用来验证掌握私钥的服务器的身份。


特点: 非对称加密的特点就是信息一对多,服务器只需要维持一个私钥就可以和多个客户端进行通信,但服务器发出的信息能够被所有的客户端解密,且该算法的计算复杂,加密的速度慢。


综合上述算法特点,TLS/SSL 的工作方式就是客户端使用非对称加密与服务器进行通信,实现身份的验证并协商对称加密使用的秘钥。对称加密算法采用协商秘钥对信息以及信息摘要进行加密通信,不同节点之间采用的对称秘钥不同,从而保证信息只能通信双方获取。这样就解决了两个方法各自存在的问题。

箭头函数的 this 指向哪⾥?

箭头函数不同于传统 JavaScript 中的函数,箭头函数并没有属于⾃⼰的 this,它所谓的 this 是捕获其所在上下⽂的 this 值,作为⾃⼰的 this 值,并且由于没有属于⾃⼰的 this,所以是不会被 new 调⽤的,这个所谓的 this 也不会被改变。


可以⽤Babel 理解⼀下箭头函数:


// ES6 const obj = {   getArrow() {     return () => {       console.log(this === obj);     };   } }
复制代码


转化后:


// ES5,由 Babel 转译var obj = {    getArrow: function getArrow() {      var _this = this;      return function () {         console.log(_this === obj);      };    } };
复制代码

常见的 HTTP 请求方法

  • GET: 向服务器获取数据;

  • POST:将实体提交到指定的资源,通常会造成服务器资源的修改;

  • PUT:上传文件,更新数据;

  • DELETE:删除服务器上的对象;

  • HEAD:获取报文首部,与 GET 相比,不返回报文主体部分;

  • OPTIONS:询问支持的请求方法,用来跨域请求;

  • CONNECT:要求在与代理服务器通信时建立隧道,使用隧道进行 TCP 通信;

  • TRACE: 回显服务器收到的请求,主要⽤于测试或诊断。

Virtual DOM 的工作原理是什么


  • 虚拟 DOM 的工作原理是通过 JS 对象模拟 DOM 的节点。在 Facebook 构建 React 初期时,考虑到要提升代码抽象能力、避免人为的 DOM 操作、降低代码整体风险等因素,所以引入了虚拟 DOM

  • 虚拟 DOM 在实现上通常是 Plain Object,以 React 为例,在 render 函数中写的 JSX 会在 Babel 插件的作用下,编译为 React.createElement 执行 JSX 中的属性参数

  • React.createElement 执行后会返回一个 Plain Object,它会描述自己的 tag 类型、props 属性以及 children 情况等。这些 Plain Object 通过树形结构组成一棵虚拟 DOM 树。当状态发生变更时,将变更前后的虚拟 DOM 树进行差异比较,这个过程称为 diff,生成的结果称为 patch。计算之后,会渲染 Patch 完成对真实 DOM 的操作。

  • 虚拟 DOM 的优点主要有三点:改善大规模DOM操作的性能规避 XSS 风险能以较低的成本实现跨平台开发

  • 虚拟 DOM 的缺点在社区中主要有两点

  • 内存占用较高,因为需要模拟整个网页的真实 DOM

  • 高性能应用场景存在难以优化的情况,类似像 Google Earth 一类的高性能前端应用在技术选型上往往不会选择 React


除了渲染页面,虚拟 DOM 还有哪些应用场景?


这个问题考验面试者的想象力。通常而言,我们只是将虚拟 DOM 与渲染绑定在一起,但实际上虚拟 DOM 的应用更为广阔。比如,只要你记录了真实 DOM 变更,它甚至可以应用于埋点统计与数据记录等。


SSR 原理


借助虚拟 dom,服务器中没有 dom 概念的,react 巧妙的借助虚拟 dom,然后可以在服务器中 nodejs 可以运行起来 react 代码。

盒模型

content(元素内容) + padding(内边距) + border(边框) + margin(外边距)


延伸:box-sizing


  • content-box:默认值,总宽度 = margin + border + padding + width

  • border-box:盒子宽度包含 paddingborder总宽度 = margin + width

  • inherit:从父元素继承 box-sizing 属性

React 17 带来了哪些改变

最重要的是以下三点:


  • 新的 JSX 转换逻辑

  • 事件系统重构

  • Lane 模型的引入


1. 重构 JSX 转换逻辑


在过去,如果我们在 React 项目中写入下面这样的代码:


function MyComponent() {  return <p>这是我的组件</p>}
复制代码


React 是会报错的,原因是 React 中对 JSX 代码的转换依赖的是 React.createElement 这个函数。因此但凡我们在代码中包含了 JSX,那么就必须在文件中引入 React,像下面这样:


import React from 'react';function MyComponent() {  return <p>这是我的组件</p>}
复制代码


React 17 则允许我们在不引入 React 的情况下直接使用 JSX。这是因为在 React 17 中,编译器会自动帮我们引入 JSX 的解析器,也就是说像下面这样一段逻辑:


function MyComponent() {  return <p>这是我的组件</p>}
复制代码


会被编译器转换成这个样子:


import {jsx as _jsx} from 'react/jsx-runtime';function MyComponent() {  return _jsx('p', { children: '这是我的组件' });}
复制代码


react/jsx-runtime 中的 JSX 解析器将取代 React.createElement 完成 JSX 的编译工作,这个过程对开发者而言是自动化、无感知的。因此,新的 JSX 转换逻辑带来的最显著的改变就是降低了开发者的学习成本。


react/jsx-runtime 中的 JSX 解析器看上去似乎在调用姿势上和 React.createElement 区别不大,那么它是否只是 React.createElement 换了个马甲呢?当然不是,它在内部实现了 React.createElement 无法做到的性能优化和简化。在一定情况下,它可能会略微改善编译输出内容的大小


2. 事件系统重构


事件系统在 React 17 中的重构要从以下两个方面来看:


  • 卸掉历史包袱

  • 拥抱新的潮流


2.1 卸掉历史包袱:放弃利用 document 来做事件的中心化管控


React 16.13.x 版本中的事件系统会通过将所有事件冒泡到 document 来实现对事件的中心化管控


这样的做法虽然看上去已经足够巧妙,但仍然有它不聪明的地方——document 是整个文档树的根节点,操作 document 带来的影响范围实在是太大了,这将会使事情变得更加不可控


在 React 17 中,React 团队终于正面解决了这个问题:事件的中心化管控不会再全部依赖 document,管控相关的逻辑被转移到了每个 React 组件自己的容器 DOM 节点中。比如说我们在 ID 为 root 的 DOM 节点下挂载了一个 React 组件,像下面代码这样:


const rootElement = document.getElementById("root");ReactDOM.render(<App />, rootElement);
复制代码


那么事件管控相关的逻辑就会被安装到 root 节点上去。这样一来, React 组件就能够自己玩自己的,再也无法对全局的事件流构成威胁了


2.2 拥抱新的潮流:放弃事件池


在 React 17 之前,合成事件对象会被放进一个叫作“事件池”的地方统一管理。这样做的目的是能够实现事件对象的复用,进而提高性能:每当事件处理函数执行完毕后,其对应的合成事件对象内部的所有属性都会被置空,意在为下一次被复用做准备。这也就意味着事件逻辑一旦执行完毕,我们就拿不到事件对象了,React 官方给出的这个例子就很能说明问题,请看下面这个代码


function handleChange(e) {  // This won't work because the event object gets reused.  setTimeout(() => {    console.log(e.target.value); // Too late!  }, 100);}
复制代码


异步执行的 setTimeout 回调会在 handleChange 这个事件处理函数执行完毕后执行,因此它拿不到想要的那个事件对象 e


要想拿到目标事件对象,必须显式地告诉 React——我永远需要它,也就是调用 e.persist() 函数,像下面这样:


function handleChange(e) {  // Prevents React from resetting its properties:  e.persist();  setTimeout(() => {    console.log(e.target.value); // Works  }, 100);}
复制代码


在 React 17 中,我们不需要 e.persist(),也可以随时随地访问我们想要的事件对象。


3. Lane 模型的引入


初学 React 源码的同学由此可能会很自然地认为:优先级就应该是用 Lane 来处理的。但事实上,React 16 中处理优先级采用的是 expirationTime 模型


expirationTime 模型使用 expirationTime(一个时间长度) 来描述任务的优先级;而 Lane 模型则使用二进制数来表示任务的优先级


lane 模型通过将不同优先级赋值给一个位,通过 31 位的位运算来操作优先级。


Lane 模型提供了一个新的优先级排序的思路,相对于 expirationTime 来说,它对优先级的处理会更细腻,能够覆盖更多的边界条件。

HTTP 和 HTTPS 协议的区别

HTTP 和 HTTPS 协议的主要区别如下:


  • HTTPS 协议需要 CA 证书,费用较高;而 HTTP 协议不需要;

  • HTTP 协议是超文本传输协议,信息是明文传输的,HTTPS 则是具有安全性的 SSL 加密传输协议;

  • 使用不同的连接方式,端口也不同,HTTP 协议端口是 80,HTTPS 协议端口是 443;

  • HTTP 协议连接很简单,是无状态的;HTTPS 协议是有 SSL 和 HTTP 协议构建的可进行加密传输、身份认证的网络协议,比 HTTP 更加安全。


用户头像

loveX001

关注

还未添加个人签名 2022-09-01 加入

还未添加个人简介

评论

发布
暂无评论
一年前端面试打怪升级之路_JavaScript_loveX001_InfoQ写作社区