前端八股文

从输入一个 URL 地址到浏览器完成渲染的整个过程

这个问题属于老生常谈的经典问题了 下面给出面试详细作答

1、DNS解析:浏览器首先将输入的URL地址发送给DNS服务器,该服务器负责将URL转换为IP地址。这个过程称为DNS解析,它将URL中的域名解析为对应的IP地址。

2、建立TCP连接:浏览器使用解析到的IP地址和服务器进行TCP三次握手,建立与服务器之间的连接。TCP握手包括客户端发送SYN请求,服务器回复SYN-ACK,最后客户端发送ACK确认。

3、发送HTTP请求:建立TCP连接后,浏览器向服务器发送HTTP请求。请求中包含请求的方法(GET、POST等)、URL路径、请求头和请求体等信息。

4、服务器处理请求:服务器接收到浏览器发送的HTTP请求后,根据请求的内容进行处理。这可能包括从数据库中检索数据、处理业务逻辑等。

5、服务器发送响应:服务器根据请求的处理结果生成HTTP响应,包括状态码、响应头和响应体等。响应体中通常包含了请求的目标资源的HTML代码。

6、接收响应:浏览器接收到服务器发送的HTTP响应后,开始接收响应内容。这个过程通常涉及分段传输和数据压缩等。

7、解析HTML:浏览器解析接收到的HTML代码,构建DOM(文档对象模型)树。在解析过程中,如果遇到外部资源(例如CSS、JavaScript文件),浏览器会继续发送请求获取这些资源。

8、加载外部资源:浏览器根据解析HTML时遇到的外部资源的URL,发送额外的请求获取这些资源。例如,浏览器会发送CSS请求来获取样式表,并将其应用于DOM树上的元素。

9、渲染页面:浏览器根据DOM树和样式表对页面进行渲染,确定每个元素在屏幕上的位置和外观。这个过程涉及布局(计算元素的几何属性)和绘制(绘制元素的外观)。

10、JavaScript执行:如果HTML中包含JavaScript代码,浏览器会执行这些脚本。执行过程可能会修改DOM树、触发样式重新计算和页面重绘等。

11、完成渲染:当页面的所有资源都加载完成并且JavaScript执行完毕后,浏览器完成页面的渲染。此时,用户可以与页面进行交互。

什么是事件代理(事件委托) 有什么好处

事件委托的原理:不给每个子节点单独设置事件监听器,而是设置在其父节点上,然后利用冒泡原理设置每个子节点。

  • 优点:
    • 减少内存消耗和 dom 操作,提高性能 在 JavaScript 中,添加到页面上的事件处理程序数量将直接关系到页面的整体运行性能,因为需要不断的操作 dom,那么引起浏览器重绘和回流的可能也就越多,页面交互的事件也就变的越长,这也就是为什么要减少 dom 操作的原因。每一个事件处理函数,都是一个对象,多一个事件处理函数,内存中就会被多占用一部分空间。如果要用事件委托,就会将所有的操作放到 js 程序里面,只对它的父级进行操作,与 dom 的操作就只需要交互一次,这样就能大大的减少与 dom 的交互次数,提高性能;
    • 动态绑定事件 因为事件绑定在父级元素 所以新增的元素也能触发同样的事件

addEventListener 默认是捕获还是冒泡

默认是冒泡

addEventListener第三个参数默认为 false 代表执行事件冒泡行为。

当为 true 时执行事件捕获行为。

css 的渲染层合成是什么 浏览器如何创建新的渲染层

在 DOM 树中每个节点都会对应一个渲染对象(RenderObject),当它们的渲染对象处于相同的坐标空间(z 轴空间)时,就会形成一个 RenderLayers,也就是渲染层。渲染层将保证页面元素以正确的顺序堆叠,这时候就会出现层合成(composite),从而正确处理透明元素和重叠元素的显示。对于有位置重叠的元素的页面,这个过程尤其重要,因为一旦图层的合并顺序出错,将会导致元素显示异常。

  • 浏览器如何创建新的渲染层
    • 根元素 document
    • 有明确的定位属性(relative、fixed、sticky、absolute)
    • opacity < 1
    • 有 CSS fliter 属性
    • 有 CSS mask 属性
    • 有 CSS mix-blend-mode 属性且值不为 normal
    • 有 CSS transform 属性且值不为 none
    • backface-visibility 属性为 hidden
    • 有 CSS reflection 属性
    • 有 CSS column-count 属性且值不为 auto 或者有 CSS column-width 属性且值不为 auto
    • 当前有对于 opacity、transform、fliter、backdrop-filter 应用动画
    • overflow 不为 visible

合成层和渲染层的区别

合成层和渲染层是浏览器中用于提高性能和渲染效果的关键概念。

合成层的条件:

1、3D或透视变换:元素应用了3D转换(如transform: translate3d)或透视变换(如perspective),这会创建一个合成层。

2、使用will-change属性:开发者可以使用CSS的will-change属性明确指示某个元素将要发生变化,浏览器可以将其放置在合成层中,以提前准备进行合成和优化。

3、使用视频或canvas元素:视频和canvas元素通常会被浏览器自动放置在一个独立的合成层中,以便更高效地处理。

4、使用Web动画:通过使用CSS动画或JavaScript控制的动画,可以将元素放置在合成层中以实现平滑的动画效果。

5、元素的层叠上下文:某些情况下,元素的层叠上下文(通过设置position: fixed、position: sticky、z-index等属性)可能会触发元素成为合成层。

渲染层产生的条件:

1、元素可见性:只有可见的元素才会被创建为渲染层。如果一个元素被设置为display: none、visibility: hidden、或者在不可滚动的容器中,它将不会成为渲染层。

2、CSS属性:某些CSS属性会触发浏览器将元素放置在单独的渲染层中,例如position: fixed、position: sticky、opacity等。

3、分层树:渲染层是基于浏览器的分层树(也称为图层树)来创建的。分层树是由元素的渲染顺序、层叠上下文等因素决定的,不同的分层树层次会产生不同的渲染层。

需要注意的是,合成层和渲染层的创建是由浏览器自动完成的,并且浏览器在处理和优化渲染层和合成层方面具有一定的策略和算法。开发者可以通过合理地设置CSS属性、使用适当的技术手段来优化渲染层和合成层的生成和使用,从而提升页面的性能和渲染效果。

webpack Plugin 和 Loader 的区别

  • Loader:

用于对模块源码的转换,loader 描述了 webpack 如何处理非 javascript 模块,并且在 buld 中引入这些依赖。loader 可以将文件从不同的语言(如 TypeScript)转换为 JavaScript,或者将内联图像转换为 data URL。比如说:CSS-Loader,Style-Loader 等。

  • Plugin

目的在于解决 loader 无法实现的其他事,它直接作用于 webpack,扩展了它的功能。在 webpack 运行的生命周期中会广播出许多事件,plugin 可以监听这些事件,在合适的时机通过 webpack 提供的 API 改变输出结果。插件的范围包括,从打包优化和压缩,一直到重新定义环境中的变量。插件接口功能极其强大,可以用来处理各种各样的任务。

apply call bind 区别

  • 三者都可以改变函数的 this 对象指向。

  • 三者第一个参数都是 this 要指向的对象,如果如果没有这个参数或参数为 undefined 或 null,则默认指向全局 window。

三者都可以传参,但是 「apply 是数组」,而 「call 是参数列表」,且 apply 和 call 是一次性传入参数,而 「bind 可以分为多次传入」。

  • bind 是返回「绑定 this 之后的函数」,便于稍后调用;apply 、call 则是「立即执行」 。

  • bind()会返回一个新的函数,如果这个返回的新的函数作为「构造函数」创建一个新的对象,那么此时 this 「不再指向」传入给 bind 的第一个参数,而是指向用 new 创建的实例

注意!很多同学可能会忽略 bind 绑定的函数作为构造函数进行 new 实例化的情况

举出闭包实际场景运用的例子

比如常见的防抖节流

// 防抖
function debounce(fn, delay = 300) {
  let timer; //闭包引用的外界变量
  return function () {
    const args = arguments;
    if (timer) {
      clearTimeout(timer);
    }
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

使用闭包可以在 JavaScript 中模拟块级作用域

function outputNumbers(count) {
  (function () {
    for (var i = 0; i < count; i++) {
      alert(i);
    }
  })();
  alert(i); //导致一个错误!
}

闭包可以用于在对象中创建私有变量

var aaa = (function () {
  var a = 1;
  function bbb() {
    a++;
    console.log(a);
  }
  function ccc() {
    a++;
    console.log(a);
  }
  return {
    b: bbb, //json结构
    c: ccc,
  };
})();
console.log(aaa.a); //undefined
aaa.b(); //2
aaa.c(); //3

css 优先级是怎么计算的

  • 第一优先级:!important 会覆盖页面内任何位置的元素样式

  • 内联样式,如 style="color: green",权值为 1000

  • ID 选择器,如#app,权值为 0100

  • 类、伪类、属性选择器,如.foo, :first-child, div[class="foo"],权值为 0010

  • 标签、伪元素选择器,如 div::first-line,权值为 0001

  • 通配符、子类选择器、兄弟选择器,如*, >, +,权值为 0000

  • 继承的样式没有权值

事件循环相关题目--必考(一般是代码输出顺序判断)

setTimeout(function () {
  console.log("1");
}, 0);
async function async1() {
  console.log("2");
  const data = await async2();
  console.log("3");
  return data;
}
async function async2() {
  return new Promise((resolve) => {
    console.log("4");
    resolve("async2的结果");
  }).then((data) => {
    console.log("5");
    return data;
  });
}
async1().then((data) => {
  console.log("6");
  console.log(data);
});
new Promise(function (resolve) {
  console.log("7");
  //   resolve()
}).then(function () {
  console.log("8");
});

输出结果:247536 async2 的结果 1

http 状态码 204 301 302 304 400 401 403 404 含义

  • http 状态码 204 (无内容) 服务器成功处理了请求,但没有返回任何内容

  • http 状态码 301 (永久移动) 请求的网页已永久移动到新位置。 服务器返回此响应(对 GET 或 HEAD 请求的响应)时,会自动将请求者转到新位置。

  • http 状态码 302 (临时移动) 服务器目前从不同位置的网页响应请求,但请求者应继续使用原有位置来进行以后的请求。

  • http 状态码 304 (未修改) 自从上次请求后,请求的网页未修改过。 服务器返回此响应时,不会返回网页内容。

  • http 状态码 400 (错误请求) 服务器不理解请求的语法(一般为参数错误)。

  • http 状态码 401 (未授权) 请求要求身份验证。 对于需要登录的网页,服务器可能返回此响应。

  • http 状态码 403 (禁止) 服务器拒绝请求。(一般为客户端的用户权限不够)

  • http 状态码 404 (未找到) 服务器找不到请求的网页。

http2.0 做了哪些改进 3.0 呢

  • 「http2.0 特性如下」

    • 二进制分帧传输
    • 多路复用
    • 头部压缩
    • 服务器推送
  • 「Http3.0」 相对于 Http2.0 是一种脱胎换骨的改变!

http 协议是应用层协议,都是建立在传输层之上的。我们也都知道传输层上面不只有 TCP 协议,还有另外一个强大的协议 「UDP 协议」,2.0 和 1.0 都是基于 TCP 的,因此都会有 TCP 带来的硬伤以及局限性。而 Http3.0 则是建立在 UDP 的基础上。所以其与 Http2.0 之间有质的不同。

  • 「http3.0 特性如下」
    • 连接迁移
    • 无队头阻塞
    • 自定义的拥塞控制
    • 前向安全和前向纠错

position 有哪些值,作用分别是什么

  • static

static(没有定位)是 position 的默认值,元素处于正常的文档流中,会忽略 left、top、right、bottom 和 z-index 属性。

  • relative

relative(相对定位)是指给元素设置相对于原本位置的定位,元素并不脱离文档流,因此元素原本的位置会被保留,其他的元素位置不会受到影响。

「使用场景」:子元素相对于父元素进行定位

  • absolute absolute(绝对定位)是指给元素设置绝对的定位,相对定位的对象可以分为两种情况:

设置了 absolute 的元素如果存在有祖先元素设置了 position 属性为 relative 或者 absolute,则这时元素的定位对象为此已设置 position 属性的祖先元素。

如果并没有设置了 position 属性的祖先元素,则此时相对于 body 进行定位。 「使用场景」:跟随图标 图标使用不依赖定位父级的 absolute 和 margin 属性进行定位,这样,当文本的字符个数改变时,图标的位置可以自适应

  • fixed 可以简单说 fixed 是特殊版的 absolute,fixed 元素总是相对于 body 定位的。 「使用场景」:侧边栏或者广告图

  • inherit 继承父元素的 position 属性,但需要注意的是 IE8 以及往前的版本都不支持 inherit 属性。

  • sticky 设置了 sticky 的元素,在屏幕范围(viewport)时该元素的位置并不受到定位影响(设置是 top、left 等属性无效),当该元素的位置将要移出偏移范围时,定位又会变成 fixed,根据设置的 left、top 等属性成固定位置的效果。 当元素在容器中被滚动超过指定的偏移值时,元素在容器内固定在指定位置。亦即如果你设置了 top: 50px,那么在 sticky 元素到达距离相对定位的元素顶部 50px 的位置时固定,不再向上移动(相当于此时 fixed 定位)。

「使用场景」:跟随窗口

vue 组件通讯方式有哪些方法

  • props 和emit父组件向子组件传递数据是通过prop传递的,子组件传递数据给父组件是通过emit 触发事件来做到的

  • parant children 获取当前组件的父组件和当前组件的子组件

  • attrs和listeners A->B->C。Vue 2.4 开始提供了attrs和listeners 来解决这个问题

  • 父组件中通过 provide 来提供变量,然后在子组件中通过 inject 来注入变量。(官方不推荐在实际业务中使用,但是写组件库时很常用)

  • $refs 获取组件实例

+envetBus 兄弟组件数据传递 这种情况下可以使用事件总线的方式

  • vuex 状态管理

Vue 响应式原理

整体思路是数据劫持+观察者模式

对象内部通过 defineReactive 方法,使用 Object.defineProperty 将属性进行劫持(只会劫持已经存在的属性),数组则是通过重写数组方法来实现。当页面使用对应属性时,每个属性都拥有自己的 dep 属性,存放他所依赖的 watcher(依赖收集),当属性变化后会通知自己对应的 watcher 去更新(派发更新)。

相关代码如下

class Observer {
  // 观测值
  constructor(value) {
    this.walk(value);
  }
  walk(data) {
    // 对象上的所有属性依次进行观测
    let keys = Object.keys(data);
    for (let i = 0; i < keys.length; i++) {
      let key = keys[i];
      let value = data[key];
      defineReactive(data, key, value);
    }
  }
}
// Object.defineProperty数据劫持核心 兼容性在ie9以及以上
function defineReactive(data, key, value) {
  observe(value); // 递归关键
  // --如果value还是一个对象会继续走一遍odefineReactive 层层遍历一直到value不是对象才停止
  //   思考?如果Vue数据嵌套层级过深 >>性能会受影响
  Object.defineProperty(data, key, {
    get() {
      console.log("获取值");

      //需要做依赖收集过程 这里代码没写出来
      return value;
    },
    set(newValue) {
      if (newValue === value) return;
      console.log("设置值");
      //需要做派发更新过程 这里代码没写出来
      value = newValue;
    },
  });
}
export function observe(value) {
  // 如果传过来的是对象或者数组 进行属性劫持
  if (
    Object.prototype.toString.call(value) === "[object Object]" ||
    Array.isArray(value)
  ) {
    return new Observer(value);
  }
}

Vue nextTick 原理

nextTick 中的回调是在下次 DOM 更新循环结束之后执行的延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。主要思路就是采用微任务优先的方式调用异步方法去执行 nextTick 包装的方法

相关代码如下

let callbacks = [];
let pending = false;
function flushCallbacks() {
  pending = false; //把标志还原为false
  // 依次执行回调
  for (let i = 0; i < callbacks.length; i++) {
    callbacks[i]();
  }
}
let timerFunc; //定义异步方法  采用优雅降级
if (typeof Promise !== "undefined") {
  // 如果支持promise
  const p = Promise.resolve();
  timerFunc = () => {
    p.then(flushCallbacks);
  };
} else if (typeof MutationObserver !== "undefined") {
  // MutationObserver 主要是监听dom变化 也是一个异步方法
  let counter = 1;
  const observer = new MutationObserver(flushCallbacks);
  const textNode = document.createTextNode(String(counter));
  observer.observe(textNode, {
    characterData: true,
  });
  timerFunc = () => {
    counter = (counter + 1) % 2;
    textNode.data = String(counter);
  };
} else if (typeof setImmediate !== "undefined") {
  // 如果前面都不支持 判断setImmediate
  timerFunc = () => {
    setImmediate(flushCallbacks);
  };
} else {
  // 最后降级采用setTimeout
  timerFunc = () => {
    setTimeout(flushCallbacks, 0);
  };
}

export function nextTick(cb) {
  // 除了渲染watcher  还有用户自己手动调用的nextTick 一起被收集到数组
  callbacks.push(cb);
  if (!pending) {
    // 如果多次调用nextTick  只会执行一次异步 等异步队列清空之后再把标志变为false
    pending = true;
    timerFunc();
  }
}

路由原理 history 和 hash 两种路由方式的特点

  • hash 模式

location.hash 的值实际就是 URL 中#后面的东西 它的特点在于:hash 虽然出现 URL 中,但不会被包含在 HTTP 请求中,对后端完全没有影响,因此改变 hash 不会重新加载页面。

可以为 hash 的改变添加监听事件

window.addEventListener("hashchange", funcRef, false);

每一次改变 hash(window.location.hash),都会在浏览器的访问历史中增加一个记录利用 hash 的以上特点,就可以来实现前端路由“更新视图但不重新请求页面”的功能了

特点:兼容性好但是不美观

  • history 模式

利用了 HTML5 History Interface 中新增的 pushState() 和 replaceState() 方法。

这两个方法应用于浏览器的历史记录站,在当前已有的 back、forward、go 的基础之上,它们提供了对历史记录进行修改的功能。这两个方法有个共同的特点:当调用他们修改浏览器历史记录栈后,虽然当前 URL 改变了,但浏览器不会刷新页面,这就为单页应用前端路由“更新视图但不重新请求页面”提供了基础。

特点:虽然美观,但是刷新会出现 404 需要后端进行配置

原型和原型链

在js中,原型和原型链open in new window是一个很重要的知识点,只有理解了它,我们才能更深刻的理解js,在这里,我们将分成几个部分来逐步讲解。

构造函数

构造函数和普通函数本质上没什么区别,只不过使用了new关键字创建对象的函数,被叫做了构造函数。构造函数的首字母一般是大写,用以区分普通函数,当然不大写也不会有什么错误。

function Person(name, age) {
  this.name = name;
  this.age = age;
  this.species = '人类';
  this.say = function () {
    console.log("Hello");
  }
}

let per1 = new Person('xiaoming', 20);

原型对象

在js中,每一个函数类型的数据,都有一个叫做prototype的属性,这个属性指向的是一个对象,就是所谓的原型对象。

对于原型对象来说,它有个constructor属性,指向它的构造函数。

那么这个原型对象有什么用呢?最主要的作用就是用来存放实例对象的公有属性和公有方法。

在上面那个例子里species属性和say方法对于所有实例来说都一样,放在构造函数里,那每创建一个实例,就会重复创建一次相同的属性和方法,显得有些浪费。这时候,如果把这些公有的属性和方法放在原型对象里共享,就会好很多。

function Person(name, age) {
    this.name = name;
    this.age = age;
}

Person.prototype.species = '人类';
Person.prototype.say = function () {
    console.log("Hello");
}

let per1 = new Person('xiaoming', 20);
let per2 = new Person('xiaohong', 19);

console.log(per1.species); // 人类
console.log(per2.species); // 人类

per1.say(); // Hello
per2.say(); // Hello

可是这里的species属性和say方法不是实例对象自己的,为什么可以直接用点运算符访问?这是因为在js中,对象如果在自己的这里找不到对应的属性或者方法,就会查看构造函数的原型对象,如果上面有这个属性或方法,就会返回属性值或调用方法。所以有时候,我们会用per1.constructor查看对象的构造函数:

console.log(per1.constructor); // Person()

这个constructor是原型对象的属性,在这里能被实例对象使用,原因就是上面所说的。那如果原型对象上也没有找到想要的属性呢?这就要说到原型链了。

原型链

说原型链之前,先来了解两个概念:

1. 显式原型

显示原型就是利用prototype属性查找原型,只是这个是函数类型数据的属性。

2. 隐式原型

隐式原型是利用__proto__属性查找原型,这个属性指向当前对象的构造函数的原型对象,这个属性是对象类型数据的属性,所以可以在实例对象上面使用:

console.log(per1.__proto__ === Person.prototype); // true
console.log(per2.__proto__ === Person.prototype); // true

根据上面,就可以得出constructor、prototype和__proto__之间的关系了

3. 原型链

既然这个是对象类型的属性,而原型对象也是对象,那么原型对象就也有这个属性,但是原型对象的__proto__又是指向哪呢?

我们来分析一下,既然原型对象也是对象,那我们只要找到对象的构造函数就能知道__proto__的指向了。而js中,对象的构造函数就是Object(),所以对象的原型对象,就是Object.prototype。既然原型对象也是对象,那原型对象的原型对象,就也是Object.prototype。不过Object.prototype这个比较特殊,它没有上一层的原型对象,或者说是它的__proto__指向的是null。

到这里,就可以回答前面那个问题了,如果某个对象查找属性,自己和原型对象上都没有,那就会继续往原型对象的原型对象上去找,这个例子里就是Object.prototype,这里就是查找的终点站了,在这里找不到,就没有更上一层了(null里面啥也没有),直接返回undefined。

可以看出,整个查找过程都是顺着__proto__属性,一步一步往上查找,形成了像链条一样的结构,这个结构,就是原型链。所以,原型链也叫作隐式原型链。

正是因为这个原因,我们在创建对象、数组、函数等等数据的时候,都自带一些属性和方法,这些属性和方法是在它们的原型上面保存着,所以它们自创建起就可以直接使用那些属性和方法。

函数也是一种对象

函数在js中,也算是一种特殊的对象,所以,可以想到的是,函数是不是也有一个__proto__属性?答案是肯定的,既然如此,那就按上面的思路,先来找找函数对象的构造函数。

在js中,所有函数都可以看做是Function()的实例,而Person()和Object()都是函数,所以它们的构造函数就是Function()。Function()本身也是函数,所以Function()也是自己的实例,听起来既怪异又合理,但是就是这么回事。

console.log(Person.constructor === Function); // true
console.log(Object.constructor === Function); // true
console.log(Function.constructor === Function); // true

总结

  1. 构造函数是使用了new关键字的函数,用来创建对象,所有函数都是Function()的实例

  2. 原型对象是用来存放实例对象的公有属性和公有方法的一个公共对象,所有原型对象都是Object()的实例

  3. 原型链又叫隐式原型链,是由__proto__属性串联起来,原型链的尽头是Object.prototype

HTTPS的加密原理

HTTPS(SSL/TLS)的加密机制虽然是大家都应了解的基本知识,但想必很多人都一知半解。

对称与非对称加密、数字签名、数字证书等,在学习过程中,除了了解“它是什么”,你是否有想过“为什么是它”?我认为有必要搞清楚后者,否则你可能只是单纯地记住了被灌输的知识,而未真正理解它。

为什么需要加密?

因为http的内容是明文传输的,明文数据会经过中间代理服务器、路由器、wifi热点、通信服务运营商等多个物理节点,如果信息在传输过程中被劫持,传输的内容就完全暴露了。劫持者还可以篡改传输的信息且不被双方察觉,这就是中间人攻击。所以我们才需要对信息进行加密。最容易理解的就是对称加密 。

在js中我们可以使用crypto-js这个库来进行对称加密数据,简单用法如下

import CryptoJS from 'crypto-js'

const aecConfig = {
  mode: CryptoJS.mode.CBC,
  padding: CryptoJS.pad.Pkcs7
}
const utils = {
  /**
   * 加密方法
   * @param {*} text 要加密的明文
   * @param {*} key 加密秘钥
   * @param {*} key 偏移量
   * @returns 密文
   */
  Encrypt(text, key, iv = '') {
    key = CryptoJS.enc.Latin1.parse(key)
    iv = CryptoJS.enc.Latin1.parse(iv)
    const encrypted = CryptoJS.AES.encrypt(text, key, { iv, ...aecConfig })
    return encrypted.toString()
  },
  /**
   * 解密方法
   * @param {*} text 要解密的密文
   * @returns
   */
  Decrypt(text, key, iv = '') {
    key = CryptoJS.enc.Latin1.parse(key)
    iv = CryptoJS.enc.Latin1.parse(iv)
    const decrypt = CryptoJS.AES.decrypt(text, key, { iv, ...aecConfig })
    return decrypt.toString(CryptoJS.enc.Utf8)
  }
}

export default utils

什么是对称加密?

简单说就是有一个密钥,它可以加密一段信息,也可以对加密后的信息进行解密,和我们日常生活中用的钥匙作用差不多。

用对称加密可行吗?

如果通信双方都各自持有同一个密钥,且没有别人知道,这两方的通信安全当然是可以被保证的(除非密钥被破解)。

然而最大的问题就是这个密钥怎么让传输的双方知晓,同时不被别人知道。如果由服务器生成一个密钥并传输给浏览器,那在这个传输过程中密钥被别人劫持到手了怎么办?之后他就能用密钥解开双方传输的任何内容了,所以这么做当然不行。

换种思路?试想一下,如果浏览器内部就预存了网站A的密钥,且可以确保除了浏览器和网站A,不会有任何外人知道该密钥,那理论上用对称加密是可以的,这样浏览器只要预存好世界上所有HTTPS网站的密钥就行了!这么做显然不现实。

怎么办?所以我们就需要非对称加密 。

什么是非对称加密?

简单说就是有两把密钥,通常一把叫做公钥、一把叫私钥,用公钥加密的内容必须用私钥才能解开,同样,私钥加密的内容只有公钥能解开。

用非对称加密可行吗?

鉴于非对称加密的机制,我们可能会有这种思路:服务器先把公钥以明文方式传输给浏览器,之后浏览器向服务器传数据前都先用这个公钥加密好再传,这条数据的安全似乎可以保障了!因为只有服务器有相应的私钥能解开公钥加密的数据。

然而反过来由服务器到浏览器的这条路怎么保障安全?如果服务器用它的私钥加密数据传给浏览器,那么浏览器用公钥可以解密它,而这个公钥是一开始通过明文传输给浏览器的,若这个公钥被中间人劫持到了,那他也能用该公钥解密服务器传来的信息了。所以目前似乎只能保证由浏览器向服务器传输数据的安全性(其实仍有漏洞,下文会说),那利用这点你能想到什么解决方案吗?

改良的非对称加密方案,似乎可以?

我们已经理解通过一组公钥私钥,可以保证单个方向传输的安全性,那用两组公钥私钥,是否就能保证双向传输都安全了?请看下面的过程:

  1. 某网站服务器拥有公钥A与对应的私钥A;浏览器拥有公钥B与对应的私钥B。

  2. 浏览器把公钥B明文传输给服务器。

  3. 服务器把公钥A明文给传输浏览器。

  4. 之后浏览器向服务器传输的内容都用公钥A加密,服务器收到后用私钥A解密。由于只有服务器拥有私钥A,所以能保证这条数据的安全。

  5. 同理,服务器向浏览器传输的内容都用公钥B加密,浏览器收到后用私钥B解密。同时也可以保证这条数据的安全。

的确可以!抛开这里面仍有的漏洞不谈(下文会讲),HTTPS的加密却没使用这种方案,为什么?很重要的原因是非对称加密算法非常耗时,而对称加密快很多。那我们能不能运用非对称加密的特性解决前面提到的对称加密的漏洞?

非对称加密+对称加密?

既然非对称加密耗时,那非对称加密+对称加密结合可以吗?而且得尽量减少非对称加密的次数。当然是可以的,且非对称加密、解密各只需用一次即可。请看一下这个过程:

  1. 某网站拥有用于非对称加密的公钥A、私钥A。

  2. 浏览器向网站服务器请求,服务器把公钥A明文给传输浏览器。

  3. 浏览器随机生成一个用于对称加密的密钥X,用公钥A加密后传给服务器。

  4. 服务器拿到后用私钥A解密得到密钥X。

  5. 这样双方就都拥有密钥X了,且别人无法知道它。之后双方所有数据都通过密钥X加密解密即可。

完美!HTTPS基本就是采用了这种方案。完美?还是有漏洞的。

中间人攻击

如果在数据传输过程中,中间人劫持到了数据,此时他的确无法得到浏览器生成的密钥X,这个密钥本身被公钥A加密了,只有服务器才有私钥A解开它,然而中间人却完全不需要拿到私钥A就能干坏事了。请看:

  1. 某网站有用于非对称加密的公钥A、私钥A。

  2. 浏览器向网站服务器请求,服务器把公钥A明文给传输浏览器。

  3. 中间人劫持到公钥A,保存下来,把数据包中的公钥A替换成自己伪造的公钥B(它当然也拥有公钥B对应的私钥B)。

  4. 浏览器生成一个用于对称加密的密钥X,用公钥B(浏览器无法得知公钥被替换了)加密后传给服务器。

  5. 中间人劫持后用私钥B解密得到密钥X,再用公钥A加密后传给服务器。

  6. 服务器拿到后用私钥A解密得到密钥X。

这样在双方都不会发现异常的情况下,中间人通过一套“狸猫换太子”的操作,掉包了服务器传来的公钥,进而得到了密钥X。根本原因是浏览器无法确认收到的公钥是不是网站自己的,因为公钥本身是明文传输的,难道还得对公钥的传输进行加密?这似乎变成鸡生蛋、蛋生鸡的问题了。解法是什么?

如何证明浏览器收到的公钥一定是该网站的公钥?

其实所有证明的源头都是一条或多条不证自明的“公理”(可以回想一下数学上公理),由它推导出一切。比如现实生活中,若想证明某身份证号一定是小明的,可以看他身份证,而身份证是由政府作证的,这里的“公理”就是“政府机构可信”,这也是社会正常运作的前提。

那能不能类似地有个机构充当互联网世界的“公理”呢?让它作为一切证明的源头,给网站颁发一个“身份证”?

它就是CA机构,它是如今互联网世界正常运作的前提,而CA机构颁发的“身份证”就是数字证书。

数字证书

网站在使用HTTPS前,需要向CA机构申领一份数字证书,数字证书里含有证书持有者信息、公钥信息等。服务器把证书传输给浏览器,浏览器从证书里获取公钥就行了,证书就如同身份证,证明“该公钥对应该网站”。而这里又有一个显而易见的问题,“证书本身的传输过程中,如何防止被篡改”?即如何证明证书本身的真实性?身份证运用了一些防伪技术,而数字证书怎么防伪呢?解决这个问题我们就接近胜利了!

如何防止数字证书被篡改?

我们把证书原本的内容生成一份“签名”,比对证书内容和签名是否一致就能判别是否被篡改。这就是数字证书的“防伪技术”,这里的“签名”就叫数字签名:

  • 数字签名的制作过程:
  1. CA机构拥有非对称加密的私钥和公钥。

  2. CA机构对证书明文数据T进行hash。

  3. 对hash后的值用私钥加密,得到数字签名S。

明文和数字签名共同组成了数字证书,这样一份数字证书就可以颁发给网站了。

那浏览器拿到服务器传来的数字证书后,如何验证它是不是真的?(有没有被篡改、掉包)

  • 浏览器验证过程:
  1. 拿到证书,得到明文T,签名S。

  2. 用CA机构的公钥对S解密(由于是浏览器信任的机构,所以浏览器保有它的公钥。详情见下文),得到S。

  3. 用证书里指明的hash算法对明文T进行hash得到T。

  4. 显然通过以上步骤,T应当等于S,除非明文或签名被篡改。所以此时比较S是否等于T,等于则表明证书可信。

为何么这样可以保证证书可信呢?我们来仔细想一下。

中间人有可能篡改该证书吗?

假设中间人篡改了证书的原文,由于他没有CA机构的私钥,所以无法得到此时加密后签名,无法相应地篡改签名。浏览器收到该证书后会发现原文和签名解密后的值不一致,则说明证书已被篡改,证书不可信,从而终止向服务器传输信息,防止信息泄露给中间人。

既然不可能篡改,那整个证书被掉包呢?

中间人有可能把证书掉包吗?

假设有另一个网站B也拿到了CA机构认证的证书,它想劫持网站A的信息。于是它成为中间人拦截到了A传给浏览器的证书,然后替换成自己的证书,传给浏览器,之后浏览器就会错误地拿到B的证书里的公钥了,这确实会导致上文“中间人攻击”那里提到的漏洞?

其实这并不会发生,因为证书里包含了网站A的信息,包括域名,浏览器把证书里的域名与自己请求的域名比对一下就知道有没有被掉包了。

为什么制作数字签名时需要hash一次?

我初识HTTPS的时候就有这个疑问,因为似乎那里的hash有点多余,把hash过程去掉也能保证证书没有被篡改。

最显然的是性能问题,前面我们已经说了非对称加密效率较差,证书信息一般较长,比较耗时。而hash后得到的是固定长度的信息(比如用md5算法hash后可以得到固定的128位的值),这样加解密就快很多。

当然也有安全上的原因,这部分内容相对深一些,感兴趣的可以看这篇解答open in new window

怎么证明CA机构的公钥是可信的?

你们可能会发现上文中说到CA机构的公钥,我几乎一笔带过,“浏览器保有它的公钥”,这是个什么保有法?怎么证明这个公钥是否可信?

让我们回想一下数字证书到底是干啥的?没错,为了证明某公钥是可信的,即“该公钥是否对应该网站”,那CA机构的公钥是否也可以用数字证书来证明?没错,操作系统、浏览器本身会预装一些它们信任的根证书,如果其中会有CA机构的根证书,这样就可以拿到它对应的可信公钥了。

实际上证书之间的认证也可以不止一层,可以A信任B,B信任C,以此类推,我们把它叫做信任链或数字证书链。也就是一连串的数字证书,由根证书为起点,透过层层信任,使终端实体证书的持有者可以获得转授的信任,以证明身份。

另外,不知你们是否遇到过网站访问不了、提示需安装证书的情况?这里安装的就是根证书。说明浏览器不认给这个网站颁发证书的机构,那么你就得手动下载安装该机构的根证书(风险自己承担XD)。安装后,你就有了它的公钥,就可以用它验证服务器发来的证书是否可信了。

总结

至此,我们已自上而下地打通了HTTPS加密的整体脉络以及核心知识点,不知你是否真正搞懂了HTTPS呢?找几个时间,多看、多想、多理解几次就会越来越清晰的!那么,下面的问题你是否已经可以解答了呢?

• 为什么要用对称加密+非对称加密?

• 为什么不能只用非对称加密?

• 为什么需要数字证书?

• 为什么需要数字签名?

• …

当然,由于篇幅和能力有限,一些更深入的内容没有覆盖到。但我认为面对一般的面试官来说,了解到这步就能轻松拿捏了,有兴趣的可以再深入研究~如有疏漏之处,欢迎指出。

new操作符

JavaScript 对象的创建

在 JavaScript 中,创建对象的方式有两种:对象字面量和使用 new 表达式。

对象字面量是一种灵活方便的书写方式,如:

let obj = {
  a: 1,
  b: 2,
}

不过对象字面量写法的缺点是,每创建一个新的对象都需要写出完整的定义语句,不便于创建大量相同类型的对象,不利于使用继承等高级特性。

而 new 表达式是配合构造函数使用的,通过 new 一个构造函数去继承构造函数的属性。

new 做了哪些事情?

new 运算符创建一个用户定义的对象数据类型的实例或者具有构造函数内置对象的实例。

它进行的操作:

  1. 首先创建一个新的空对象

  2. 然后将空对象的 __proto __ 指向构造函数的原型 它将新生成的对象的 __proto __ 属性赋值为构造函数的 prototype 属性,使得通过构造函数创建的所有对象可以共享相同的原型。 这意味着同一个构造函数创建的所有对象都继承自一个相同的对象,因此它们都是同一个类的对象。

  3. 改变 this 的指向,指向空对象

  4. 对构造函数的返回值做判断,然后返回对应的值 一般是返回第一步创建的空对象; 但是当 构造函数有返回值时 则需要做判断再返回对应的值,是 对象类型则返回该对象,是 原始类型则返回第一步创建的空对象。

new 的实现

new 的实现很简单,就是一步一步把它要做的操作给实现出来:

function myNew(Con, ...args) {
  // 创建一个新的空对象
  let obj = {};
  // 将这个空对象的__proto__指向构造函数的原型
  // obj.__proto__ = Con.prototype;
  Object.setPrototypeOf(obj, Con.prototype);
  // 将this指向空对象
  let res = Con.apply(obj, args);
  // 对构造函数返回值做判断,然后返回对应的值
  return res instanceof Object ? res : obj;
}

实现的验证

// 构造函数Person
function Person(name) {
  this.name = name;
}
let per = myNew(Person, '你好,new');
console.log(per); // {name: "你好,new"}
console.log(per.constructor === Person); // true
console.log(per.__proto__ === Person.prototype); // true

一般情况下构造函数是没有返回值的,但是作为函数,是可以有返回值的。

function Person(name) {
  this.name = name;
  return {
    age: 22
  }
}
let per = myNew(Person, '你好,new');
// 当构造函数返回对象类型的数据时,会直接返回这个数据, new 操作符无效
console.log(per); // {age: 22}
function Person(name) {
  this.name = name;
  return '十二点的程序员'
}
let per = myNew(Person, '你好,new');
// 而当构造函数返回基础类型的数据,则会被忽略
console.log(per); // {name: "你好,new"}

this指向

this是 JavaScript 语言的一个关键字。

它是代码运行时,在当前作用域内部自动生成的一个对象。

function test() {
 this.x = 1;
}

上面代码中,函数test运行时,内部会自动有一个this对象可以使用。

那么,this的值是什么呢?

函数的不同使用场合,this有不同的值。总的来说,this就是函数运行时所在的环境对象。下面分四种情况,详细讨论this的用法。

情况一:纯粹的函数调用

这是函数的最通常用法,属于全局性调用,因此this就代表全局对象。

function test() {
   console.log(this);
}
test(); // Window

情况二:作为对象方法的调用

函数还可以作为某个对象的方法调用,这时this就指这个上级对象。

var obj = {
    m: function() {
        console.log(this)
    }
}
obj.m(); // obj {m: ƒ}

情况三 作为构造函数调用

所谓构造函数,就是通过new这个函数,可以生成一个新对象。这时,this就指这个新对象

function test() {
  this.x = 1;
}

var obj = new test();
obj.x // 1

运行结果为1。为了表明这时this不是全局对象,我们对代码做一些改变:

var x = 2;
function test() {
  this.x = 1;
}

var obj = new test();
x  // 2

运行结果为2,表明全局变量 x 的值根本没变。

这里为了跟第一种情况作对比,故意没有首字母大写

情况四 apply 调用

apply()是函数的一个方法,作用是改变函数的调用对象。它的第一个参数就表示改变后的调用这个函数的对象。因此,这时this指的就是这第一个参数。

var obj = {
  m: function() {
    console.log(this)
  }
}
obj.m(); // obj {m: ƒ}
obj.m.apply() // Window

注意:apply()的参数为空时,默认调用全局对象。因此,这时的运行结果为Window,证明this指的是全局对象。

如果把最后一行代码修改为

obj.m.apply(obj); // obj {m: ƒ}

依然指向了obj对象。

作用域和闭包

作用域是什么

几乎所有编程语言最基本的功能之一,就是能够储存变量当中的值,并且能在之后对这个值进行访问或修改。事实上,正是这种储存和访问变量的值的能力将状态带给了程序。

若没有了状态这个概念,程序虽然也能够执行一些简单的任务,但它会受到高度限制,做不到非常有趣。

但是将变量引入程序会引起几个很有意思的问题,也正是我们将要讨论的:这些变量住在哪里?换句话说,它们储存在哪里?最重要的是,程序在需要时如何找到它们?

这些问题说明需要一套设计良好的规则来存储变量,并且之后可以方便地找到这些变量。这套规则被称为作用域。

理解作用域

全局作用域

  1. 最外层函数和最外层函数外面定义的变量拥有全局作用域。
var aaa = '前端帮'
function bbb() {}

在这个例子中,变量aaa和函数bbb在全局作用域中,在浏览器环境中是window,在node环境中是global

  1. 所有未定义直接赋值的变量自动声明为全局作用域
aaa = 111

function bbb() {
    ccc = 222
}

bbb()
console.log(ccc) // 222

即使是在函数内部定义的变量,不加声明,自动会变为全局变量

  1. 所有 window 对象的属性拥有全局作用域
window.aaa = 111
window.bbb = function() {}
console.log(aaa) // 111

在这个例子中,变量aaa和函数bbb在全局作用域中

注意:全局作用域有很大的弊端,过多的全局作用域变量会污染全局命名空间,容易引起命名冲突。

基于这个弊端,很多第三方库会在全局作用域中声明一个特定的变量,通常是一个对象,所有需要暴露给外界的功能都作为这个对象的属性。

函数作用域

在js每中声明一个函数,都会创建一个作用域,在函数中声明的所有变量,都无法在函数外部访问(有一种情况例外,后面再说)

function foo(a) {
  var b = 2;
  // 一些代码
  function bar() {
    // ...
  }
  // 更多的代码
  var c = 3;
}

在foo外部访问变量a、b、c、函数bar,会报ReferenceError错误。

bar(); // 失败 Uncaught ReferenceError: bar is not defined
console.log( a, b, c ); // 三个全都失败

但是在foo函数内部访问是可以被访问的,并且即使你修改它的值,也不会影响外部的同名变量。

var b = 1
function foo() { 
  var b = 2;
  console.log(b) // 2
}
foo()
console.log(b) // 1

可以看到,函数foo创建了一个“私有”的空间。

  • 引申一点:

这个foo函数虽然实现了隔离作用域的效果,但同时也导致了另一个问题。首先,必须声明一个具名函数 foo(),意味着 foo 这个名称本身“污染”了所在作用域(在这个 例子中是全局作用域)。其次,必须显式地通过函数名(foo())调用这个函数才能运行其中的代码。

如果函数不需要函数名(或者至少函数名可以不污染所在作用域),并且能够自动运行, 这将会更加理想。

幸好,JavaScript 提供了能够同时解决这两个问题的方案:

var a = 2;
(function foo(){
  var a = 3;
  console.log( a ); // 3
})();
console.log( a ); // 2

这里有一个很重要的细节:这个函数会被当作函数表达式而不是一 个标准的函数声明来处理。

区别:如果 function 是声明中 的第一个词,那么就是一个函数声明,否则就是一个函数表达式。

换句话说:(function foo(){ .. })作为函数表达式意味着foo只能在..所代表的位置中 被访问,外部作用域则不行。此时,foo不会污染外部(全局)作用域。

块级作用域

  • 为什么

先来两个反面例子,说明为什么需要块级作用域

console.log(i)
for (var i = 0; i < 10; i++) {
  console.log(i);
}
console.log(i)

例一:在这个for循环前后都能访问到for循环中定义的变量 i,如果不小心在for循环之前修改了 i 的值,例如var i = 100;那么永远都无法走进这个for循环,结果可想而知。

var foo = true;
if (foo) {
  var bar = !foo;
  console.log(bar);
}
console.log(bar)

例二:写这段代码的人可能是想让bar变量仅仅在if块内部生效,但事实上它最终都属于外部作用域。要确保没在作用域其他地方意外地使用 bar ,只能依靠自觉性。

这种奇怪的行为可能只会存在于javascript中吧,多年来都只能依靠开发者自觉检查代码来规避。

  • 是什么

幸好,ES6改变了现状,引入了新的 let 关键字,提供了除 var 以外的另一种变量声明方式。

用let改写以上代码

console.log(i) // Uncaught ReferenceError: i is not defined
for (let i = 0; i < 10; i++) {
  console.log(i);
}
console.log(i) // Uncaught ReferenceError: i is not defined

var foo = true;
if (foo) {
  let bar = !foo;
  console.log(bar);
}
console.log(bar) // Uncaught ReferenceError: bar is not defined

除了 let 以外,ES6 还引入了 const,同样可以用来创建块作用域变量,但其值是固定的 (常量)。之后任何修改值的操作都会报错。

闭包

说作用域,就不得不说说闭包

我们经常纠结于闭包的概念,到底什么是闭包,其实不用纠结,我们不必为了利用和了解它而刻意创建闭包。闭包的创建和使用在你的代码中随处可见。

来一段简单的代码

function foo() {
  var a = 2;
  function bar() {
    console.log( a ); // 2
  }
  bar();
}
foo();

这段代码看起来和嵌套作用域中的示例代码很相似。基于作用域的查找规则,函数 bar() 可以访问外部作用域中的变量 a

这是闭包吗?

只能说bar()涵盖了foo()的作用域(事实上,涵盖了它能访问的所有作用域,比如全局作用域),因为 bar() 嵌套在 foo() 内部。

这段代码能帮助我们更好地了解作用域,这就是前面提到的例外的情况。

  • 直接来看一段代码,展示什么是闭包
function foo() {
  var a = 2;
  function bar() {
    console.log( a );
  }
  return bar;
}
var baz = foo();
baz(); // 这里可以输出2,这就是闭包的效果。

函数 bar() 能够访问 foo() 的内部作用域。然后我们将 bar() 函数本身当作 一个值类型进行传递。在这个例子中,我们将 bar 所引用的函数对象本身当作返回值。

在 foo() 执行后,其返回值(也就是内部的 bar() 函数)赋值给变量 baz 并调用 baz(),实际上只是通过不同的标识符引用调用了内部的函数 bar()。

bar() 显然可以被正常执行。但是在这个例子中,它在自己定义的作用域以外的地方执行。

在 foo() 执行后,垃圾回收机制并不会销毁foo作用域,由于bar函数声明在foo函数内部,它拥有涵盖 foo() 内部作用域的闭包,使得该作用域能够一直存活,以供 bar() 在之后任何时间进行引用。

bar() 依然持有对该作用域的引用,而这个引用就叫作闭包。

  • 除了以上代码片段,以下代码均会产生闭包
  1. 把foo函数中的baz传到另一个函数中执行
function foo() {
  var a = 2;
  function baz() {
    console.log( a ); // 2
  }
  bar( baz );
}
function bar(fn) {
  fn(); // 妈妈快看呀,这就是闭包!
}
  1. 把函数内的baz函数赋值给全局变量,然后在另一个函数中执行
var fn;
function foo() {
  var a = 2;
  function baz() {
    console.log( a );
  }
  fn = baz; // 将 baz 分配给全局变量
}
function bar() {
  fn(); // 妈妈快看呀,这就是闭包!
}
foo();
bar(); // 2

无论通过何种手段将内部函数传递到所在的作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

贡献者: mankueng