写点什么

前端经典面试题(有答案)

作者:coder2028
  • 2023-03-15
    浙江
  • 本文字数:24800 字

    阅读完需:约 81 分钟

作用域

  • 作用域: 作用域是定义变量的区域,它有一套访问变量的规则,这套规则来管理浏览器引擎如何在当前作用域以及嵌套的作用域中根据变量(标识符)进行变量查找

  • 作用域链: 作用域链的作用是保证对执行环境有权访问的所有变量和函数的有序访问,通过作用域链,我们可以访问到外层环境的变量和 函数。


作用域链的本质上是一个指向变量对象的指针列表。变量对象是一个包含了执行环境中所有变量和函数的对象。作用域链的前 端始终都是当前执行上下文的变量对象。全局执行上下文的变量对象(也就是全局对象)始终是作用域链的最后一个对象。


  • 当我们查找一个变量时,如果当前执行环境中没有找到,我们可以沿着作用域链向后查找

  • 作用域链的创建过程跟执行上下文的建立有关....


作用域可以理解为变量的可访问性,总共分为三种类型,分别为:


  • 全局作用域

  • 函数作用域

  • 块级作用域,ES6 中的 letconst 就可以产生该作用域


其实看完前面的闭包、this 这部分内部的话,应该基本能了解作用域的一些应用。


一旦我们将这些作用域嵌套起来,就变成了另外一个重要的知识点「作用域链」,也就是 JS 到底是如何访问需要的变量或者函数的。


  • 首先作用域链是在定义时就被确定下来的,和箭头函数里的 this 一样,后续不会改变,JS 会一层层往上寻找需要的内容。

  • 其实作用域链这个东西我们在闭包小结中已经看到过它的实体了:[[Scopes]]



图中的 [[Scopes]] 是个数组,作用域的一层层往上寻找就等同于遍历 [[Scopes]]


1. 全局作用域


全局变量是挂载在 window 对象下的变量,所以在网页中的任何位置你都可以使用并且访问到这个全局变量


var globalName = 'global';function getName() {   console.log(globalName) // global  var name = 'inner'  console.log(name) // inner} getName();console.log(name); // console.log(globalName); //globalfunction setName(){   vName = 'setName';}setName();console.log(vName); // setName
复制代码


  • 从这段代码中我们可以看到,globalName 这个变量无论在什么地方都是可以被访问到的,所以它就是全局变量。而在 getName 函数中作为局部变量的 name 变量是不具备这种能力的

  • 当然全局作用域有相应的缺点,我们定义很多全局变量的时候,会容易引起变量命名的冲突,所以在定义变量的时候应该注意作用域的问题。


2. 函数作用域


函数中定义的变量叫作函数变量,这个时候只能在函数内部才能访问到它,所以它的作用域也就是函数的内部,称为函数作用域


function getName () {  var name = 'inner';  console.log(name); //inner}getName();console.log(name);
复制代码


除了这个函数内部,其他地方都是不能访问到它的。同时,当这个函数被执行完之后,这个局部变量也相应会被销毁。所以你会看到在 getName 函数外面的 name 是访问不到的


3. 块级作用域


ES6 中新增了块级作用域,最直接的表现就是新增的 let 关键词,使用 let 关键词定义的变量只能在块级作用域中被访问,有“暂时性死区”的特点,也就是说这个变量在定义之前是不能被使用的。


在 JS 编码过程中 if 语句for 语句后面 {...} 这里面所包括的,就是块级作用域


console.log(a) //a is not definedif(true){  let a = '123';  console.log(a); // 123}console.log(a) //a is not defined
复制代码


从这段代码可以看出,变量 a 是在 if 语句{...} 中由 let 关键词进行定义的变量,所以它的作用域是 if 语句括号中的那部分,而在外面进行访问 a 变量是会报错的,因为这里不是它的作用域。所以在 if 代码块的前后输出 a 这个变量的结果,控制台会显示 a 并没有定义

什么是物理像素,逻辑像素和像素密度,为什么在移动端开发时需要用到 @3x, @2x 这种图片?

以 iPhone XS 为例,当写 CSS 代码时,针对于单位 px,其宽度为 414px & 896px,也就是说当赋予一个 DIV 元素宽度为 414px,这个 DIV 就会填满手机的宽度;


而如果有一把尺子来实际测量这部手机的物理像素,实际为 1242*2688 物理像素;经过计算可知,1242/414=3,也就是说,在单边上,一个逻辑像素=3 个物理像素,就说这个屏幕的像素密度为 3,也就是常说的 3 倍屏。


对于图片来说,为了保证其不失真,1 个图片像素至少要对应一个物理像素,假如原始图片是 500300 像素,那么在 3 倍屏上就要放一个 1500900 像素的图片才能保证 1 个物理像素至少对应一个图片像素,才能不失真。 当然,也可以针对所有屏幕,都只提供最高清图片。虽然低密度屏幕用不到那么多图片像素,而且会因为下载多余的像素造成带宽浪费和下载延迟,但从结果上说能保证图片在所有屏幕上都不会失真。


还可以使用 CSS 媒体查询来判断不同的像素密度,从而选择不同的图片:


my-image { background: (low.png); }@media only screen and (min-device-pixel-ratio: 1.5) {  #my-image { background: (high.png); }}
复制代码

Canvas 和 SVG 的区别

(1)SVG: SVG 可缩放矢量图形(Scalable Vector Graphics)是基于可扩展标记语言 XML 描述的 2D 图形的语言,SVG 基于 XML 就意味着 SVG DOM 中的每个元素都是可用的,可以为某个元素附加 Javascript 事件处理器。在 SVG 中,每个被绘制的图形均被视为对象。如果 SVG 对象的属性发生变化,那么浏览器能够自动重现图形。


其特点如下:


  • 不依赖分辨率

  • 支持事件处理器

  • 最适合带有大型渲染区域的应用程序(比如谷歌地图)

  • 复杂度高会减慢渲染速度(任何过度使用 DOM 的应用都不快)

  • 不适合游戏应用


(2)Canvas: Canvas 是画布,通过 Javascript 来绘制 2D 图形,是逐像素进行渲染的。其位置发生改变,就会重新进行绘制。


其特点如下:


  • 依赖分辨率

  • 不支持事件处理器

  • 弱的文本渲染能力

  • 能够以 .png 或 .jpg 格式保存结果图像

  • 最适合图像密集型的游戏,其中的许多对象会被频繁重绘


注:矢量图,也称为面向对象的图像或绘图图像,在数学上定义为一系列由线连接的点。矢量文件中的图形元素称为对象。每个对象都是一个自成一体的实体,它具有颜色、形状、轮廓、大小和屏幕位置等属性。

对 this 对象的理解

this 是执行上下文中的一个属性,它指向最后一次调用这个方法的对象。在实际开发中,this 的指向可以通过四种调用模式来判断。


  • 第一种是函数调用模式,当一个函数不是一个对象的属性时,直接作为函数来调用时,this 指向全局对象。

  • 第二种是方法调用模式,如果一个函数作为一个对象的方法来调用时,this 指向这个对象。

  • 第三种是构造器调用模式,如果一个函数用 new 调用时,函数执行前会新创建一个对象,this 指向这个新创建的对象。

  • 第四种是 apply 、 call 和 bind 调用模式,这三个方法都可以显示的指定调用函数的 this 指向。其中 apply 方法接收两个参数:一个是 this 绑定的对象,一个是参数数组。call 方法接收的参数,第一个是 this 绑定的对象,后面的其余参数是传入函数执行的参数。也就是说,在使用 call() 方法时,传递给函数的参数必须逐个列举出来。bind 方法通过传入一个对象,返回一个 this 绑定了传入对象的新函数。这个函数的 this 指向除了使用 new 时会被改变,其他情况下都不会改变。


这四种方式,使用构造器调用模式的优先级最高,然后是 apply、call 和 bind 调用模式,然后是方法调用模式,然后是函数调用模式。

CSS 预处理器/后处理器是什么?为什么要使用它们?

预处理器, 如:lesssassstylus,用来预编译sass或者less,增加了css代码的复用性。层级,mixin, 变量,循环, 函数等对编写以及开发 UI 组件都极为方便。


后处理器, 如: postCss,通常是在完成的样式表中根据css规范处理css,让其更加有效。目前最常做的是给css属性添加浏览器私有前缀,实现跨浏览器兼容性的问题。


css预处理器为css增加一些编程特性,无需考虑浏览器的兼容问题,可以在CSS中使用变量,简单的逻辑程序,函数等在编程语言中的一些基本的性能,可以让css更加的简洁,增加适应性以及可读性,可维护性等。


其它css预处理器语言:Sass(Scss), Less, Stylus, Turbine, Swithch css, CSS Cacheer, DT Css


使用原因:


  • 结构清晰, 便于扩展

  • 可以很方便的屏蔽浏览器私有语法的差异

  • 可以轻松实现多重继承

  • 完美的兼容了CSS代码,可以应用到老项目中

常见的位运算符有哪些?其计算规则是什么?

现代计算机中数据都是以二进制的形式存储的,即 0、1 两种状态,计算机对二进制数据进行的运算加减乘除等都是叫位运算,即将符号位共同参与运算的运算。


常见的位运算有以下几种:


| 运算符 | 描述 | 运算规则 || --- | --- | --- | --- || & | 与 | 两个位都为 1 时,结果才为 1 || | | 或 | 两个位都为 0 时,结果才为 0 || ^ | 异或 | 两个位相同为 0,相异为 1 || ~ | 取反 | 0 变 1,1 变 0 || << | 左移 | 各二进制位全部左移若干位,高位丢弃,低位补 0 || >> | 右移 | 各二进制位全部右移若干位,正数左补 0,负数左补 1,右边丢弃 |

1. 按位与运算符(&)

定义: 参加运算的两个数据按二进制位进行“与”运算。 运算规则:


0 & 0 = 0  0 & 1 = 0  1 & 0 = 0  1 & 1 = 1
复制代码


总结:两位同时为 1,结果才为 1,否则结果为 0。例如:3&5 即:


0000 0011    0000 0101  = 0000 0001
复制代码


因此 3&5 的值为 1。注意:负数按补码形式参加按位与运算。


用途:


(1)判断奇偶


只要根据最未位是 0 还是 1 来决定,为 0 就是偶数,为 1 就是奇数。因此可以用if ((i & 1) == 0)代替if (i % 2 == 0)来判断 a 是不是偶数。


(2)清零


如果想将一个单元清零,即使其全部二进制位为 0,只要与一个各位都为零的数值相与,结果为零。

2. 按位或运算符(|)

定义: 参加运算的两个对象按二进制位进行“或”运算。


运算规则:


0 | 0 = 00 | 1 = 1  1 | 0 = 1  1 | 1 = 1
复制代码


总结:参加运算的两个对象只要有一个为 1,其值为 1。例如:3|5 即:


0000 0011  0000 0101 = 0000 0111
复制代码


因此,3|5 的值为 7。注意:负数按补码形式参加按位或运算。

3. 异或运算符(^)

定义: 参加运算的两个数据按二进制位进行“异或”运算。


运算规则:


0 ^ 0 = 0  0 ^ 1 = 1  1 ^ 0 = 1  1 ^ 1 = 0
复制代码


总结:参加运算的两个对象,如果两个相应位相同为 0,相异为 1。例如:3|5 即:


0000 0011  0000 0101 = 0000 0110
复制代码


因此,3^5 的值为 6。异或运算的性质:


  • 交换律:(a^b)^c == a^(b^c)

  • 结合律:(a + b)^c == a^b + b^c

  • 对于任何数 x,都有 x^x=0,x^0=x

  • 自反性: a^b^b=a^0=a;

4. 取反运算符 (~)

定义: 参加运算的一个数据按二进制进行“取反”运算。


运算规则:


~ 1 = 0~ 0 = 1
复制代码


总结:对一个二进制数按位取反,即将 0 变 1,1 变 0。例如:~6 即:


0000 0110= 1111 1001
复制代码


在计算机中,正数用原码表示,负数使用补码存储,首先看最高位,最高位 1 表示负数,0 表示正数。此计算机二进制码为负数,最高位为符号位。当发现按位取反为负数时,就直接取其补码,变为十进制:


0000 0110   = 1111 1001反码:1000 0110补码:1000 0111
复制代码


因此,~6 的值为-7。

5. 左移运算符(<<)

定义: 将一个运算对象的各二进制位全部左移若干位,左边的二进制位丢弃,右边补 0。设 a=1010 1110,a = a<< 2 将 a 的二进制位左移 2 位、右补 0,即得 a=1011 1000。若左移时舍弃的高位不包含 1,则每左移一位,相当于该数乘以 2。

6. 右移运算符(>>)

定义: 将一个数的各二进制位全部右移若干位,正数左补 0,负数左补 1,右边丢弃。例如:a=a>>2 将 a 的二进制位右移 2 位,左补 0 或者 左补 1 得看被移数是正还是负。操作数每右移一位,相当于该数除以 2。

7. 原码、补码、反码

上面提到了补码、反码等知识,这里就补充一下。计算机中的有符号数有三种表示方法,即原码、反码和补码。三种表示方法均有符号位和数值位两部分,符号位都是用 0 表示“正”,用 1 表示“负”,而数值位,三种表示方法各不相同。


(1)原码


原码就是一个数的二进制数。例如:10 的原码为 0000 1010


(2)反码


  • 正数的反码与原码相同,如:10 反码为 0000 1010

  • 负数的反码为除符号位,按位取反,即 0 变 1,1 变 0。


例如:-10


原码:1000 1010反码:1111 0101
复制代码


(3)补码


  • 正数的补码与原码相同,如:10 补码为 0000 1010

  • 负数的补码是原码除符号位外的所有位取反即 0 变 1,1 变 0,然后加 1,也就是反码加 1。


例如:-10


原码:1000 1010反码:1111 0101补码:1111 0110
复制代码


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

JavaScript 有哪些内置对象

全局的对象( global objects )或称标准内置对象,不要和 "全局对象(global object)" 混淆。这里说的全局的对象是说在全局作用域里的对象。全局作用域中的其他对象可以由用户的脚本创建或由宿主程序提供。


标准内置对象的分类:


(1)值属性,这些全局属性返回一个简单值,这些值没有自己的属性和方法。例如 Infinity、NaN、undefined、null 字面量


(2)函数属性,全局函数可以直接调用,不需要在调用时指定所属对象,执行结束后会将结果直接返回给调用者。例如 eval()、parseFloat()、parseInt() 等


(3)基本对象,基本对象是定义或使用其他对象的基础。基本对象包括一般对象、函数对象和错误对象。例如 Object、Function、Boolean、Symbol、Error 等


(4)数字和日期对象,用来表示数字、日期和执行数学计算的对象。例如 Number、Math、Date


(5)字符串,用来表示和操作字符串的对象。例如 String、RegExp


(6)可索引的集合对象,这些对象表示按照索引值来排序的数据集合,包括数组和类型数组,以及类数组结构的对象。例如 Array


(7)使用键的集合对象,这些集合对象在存储数据时会使用到键,支持按照插入顺序来迭代元素。例如 Map、Set、WeakMap、WeakSet


(8)矢量集合,SIMD 矢量集合中的数据会被组织为一个数据序列。例如 SIMD 等


(9)结构化数据,这些对象用来表示和操作结构化的缓冲区数据,或使用 JSON 编码的数据。例如 JSON 等


(10)控制抽象对象例如 Promise、Generator 等


(11)反射。例如 Reflect、Proxy


(12)国际化,为了支持多语言处理而加入 ECMAScript 的对象。例如 Intl、Intl.Collator 等


(13)WebAssembly


(14)其他。例如 arguments


总结: js 中的内置对象主要指的是在程序执行前存在全局作用域里的由 js 定义的一些全局值属性、函数和用来实例化其他对象的构造函数对象。一般经常用到的如全局变量值 NaN、undefined,全局函数如 parseInt()、parseFloat() 用来实例化对象的构造函数如 Date、Object 等,还有提供数学计算的单体内置对象如 Math 对象。

viewport

<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no" />    // width    设置viewport宽度,为一个正整数,或字符串‘device-width’    // device-width  设备宽度    // height   设置viewport高度,一般设置了宽度,会自动解析出高度,可以不用设置    // initial-scale    默认缩放比例(初始缩放比例),为一个数字,可以带小数    // minimum-scale    允许用户最小缩放比例,为一个数字,可以带小数    // maximum-scale    允许用户最大缩放比例,为一个数字,可以带小数    // user-scalable    是否允许手动缩放
复制代码


  • 延伸提问

  • 怎样处理 移动端 1px 被 渲染成 2px问题


局部处理


  • meta标签中的 viewport属性 ,initial-scale 设置为 1

  • rem按照设计稿标准走,外加利用transfromescale(0.5) 缩小一倍即可;


全局处理


  • mate标签中的 viewport属性 ,initial-scale 设置为 0.5

  • rem 按照设计稿标准走即可

connect 组件原理分析

1. connect 用法


作用:连接React组件与 Redux store


connect([mapStateToProps], [mapDispatchToProps], [mergeProps],[options])// 这个函数允许我们将 store 中的数据作为 props 绑定到组件上const mapStateToProps = (state) => {  return {    count: state.count  }}
复制代码


  • 这个函数的第一个参数就是 Reduxstore,我们从中摘取了 count 属性。你不必将 state 中的数据原封不动地传入组件,可以根据 state 中的数据,动态地输出组件需要的(最小)属性

  • 函数的第二个参数 ownProps,是组件自己的 props


state 变化,或者 ownProps 变化的时候,mapStateToProps 都会被调用,计算出一个新的 stateProps,(在与 ownProps merge 后)更新给组件


mapDispatchToProps(dispatch, ownProps): dispatchProps
复制代码


connect 的第二个参数是 mapDispatchToProps,它的功能是,将 action 作为 props绑定到组件上,也会成为 MyComp 的 `props


2. 原理解析


首先connect之所以会成功,是因为Provider组件


  • 在原应用组件上包裹一层,使原来整个应用成为Provider的子组件

  • 接收Reduxstore作为props,通过context对象传递给子孙组件上的connect


connect 做了些什么


它真正连接 ReduxReact,它包在我们的容器组件的外一层,它接收上面 Provider提供的 store 里面的 statedispatch,传给一个构造函数,返回一个对象,以属性形式传给我们的容器组件


3. 源码


connect是一个高阶函数,首先传入mapStateToPropsmapDispatchToProps,然后返回一个生产Component的函数(wrapWithConnect),然后再将真正的Component作为参数传入wrapWithConnect,这样就生产出一个经过包裹的Connect组件,该组件具有如下特点


  • 通过props.store获取祖先Componentstore props包括statePropsdispatchPropsparentProps,合并在一起得到nextState,作为props传给真正的Component

  • componentDidMount时,添加事件this.store.subscribe(this.handleChange),实现页面交互

  • shouldComponentUpdate时判断是否有避免进行渲染,提升页面性能,并得到nextState

  • componentWillUnmount时移除注册的事件this.handleChange


// 主要逻辑
export default function connect(mapStateToProps, mapDispatchToProps, mergeProps, options = {}) { return function wrapWithConnect(WrappedComponent) { class Connect extends Component { constructor(props, context) { // 从祖先Component处获得store this.store = props.store || context.store this.stateProps = computeStateProps(this.store, props) this.dispatchProps = computeDispatchProps(this.store, props) this.state = { storeState: null } // 对stateProps、dispatchProps、parentProps进行合并 this.updateState() } shouldComponentUpdate(nextProps, nextState) { // 进行判断,当数据发生改变时,Component重新渲染 if (propsChanged || mapStateProducedChange || dispatchPropsChanged) { this.updateState(nextProps) return true } } componentDidMount() { // 改变Component的state this.store.subscribe(() = { this.setState({ storeState: this.store.getState() }) }) } render() { // 生成包裹组件Connect return ( <WrappedComponent {...this.nextState} /> ) } } Connect.contextTypes = { store: storeShape } return Connect; }}
复制代码

判断数组的方式有哪些

  • 通过 Object.prototype.toString.call()做判断


Object.prototype.toString.call(obj).slice(8,-1) === 'Array';
复制代码


  • 通过原型链做判断


obj.__proto__ === Array.prototype;
复制代码


  • 通过 ES6 的 Array.isArray()做判断


Array.isArrray(obj);
复制代码


  • 通过 instanceof 做判断


obj instanceof Array
复制代码


  • 通过 Array.prototype.isPrototypeOf


Array.prototype.isPrototypeOf(obj)
复制代码

文档声明(Doctype)和<!Doctype html>有何作用? 严格模式与混杂模式如何区分?它们有何意义?

文档声明的作用: 文档声明是为了告诉浏览器,当前HTML文档使用什么版本的HTML来写的,这样浏览器才能按照声明的版本来正确的解析。


的作用:<!doctype html> 的作用就是让浏览器进入标准模式,使用最新的 HTML5 标准来解析渲染页面;如果不写,浏览器就会进入混杂模式,我们需要避免此类情况发生。


严格模式与混杂模式的区分:


  • 严格模式: 又称为标准模式,指浏览器按照W3C标准解析代码;

  • 混杂模式: 又称怪异模式、兼容模式,是指浏览器用自己的方式解析代码。混杂模式通常模拟老式浏览器的行为,以防止老站点无法工作;


区分:网页中的DTD,直接影响到使用的是严格模式还是浏览模式,可以说DTD的使用与这两种方式的区别息息相关。


  • 如果文档包含严格的DOCTYPE ,那么它一般以严格模式呈现(严格 DTD ——严格模式);

  • 包含过渡 DTDURIDOCTYPE ,也以严格模式呈现,但有过渡 DTD 而没有 URI (统一资源标识符,就是声明最后的地址)会导致页面以混杂模式呈现(有 URI 的过渡 DTD ——严格模式;没有 URI 的过渡 DTD ——混杂模式);

  • DOCTYPE 不存在或形式不正确会导致文档以混杂模式呈现(DTD 不存在或者格式不正确——混杂模式);

  • HTML5 没有 DTD ,因此也就没有严格模式与混杂模式的区别,HTML5 有相对宽松的 法,实现时,已经尽可能大的实现了向后兼容(HTML5 没有严格和混杂之分)。


总之,严格模式让各个浏览器统一执行一套规范兼容模式保证了旧网站的正常运行。

事件循环


  • 默认代码从上到下执行,执行环境通过script来执行(宏任务)

  • 在代码执行过程中,调用定时器 promise click事件...不会立即执行,需要等待当前代码全部执行完毕

  • 给异步方法划分队列,分别存放到微任务(立即存放)和宏任务(时间到了或事情发生了才存放)到队列中

  • script执行完毕后,会清空所有的微任务

  • 微任务执行完毕后,会渲染页面(不是每次都调用)

  • 再去宏任务队列中看有没有到达时间的,拿出来其中一个执行

  • 执行完毕后,按照上述步骤不停的循环


例子




自动执行的情况 会输出 listener1 listener2 task1 task2




如果手动点击 click 会一个宏任务取出来一个个执行,先执行 click 的宏任务,取出微任务去执行。会输出 listener1 task1 listener2 task2



console.log(1)
async function asyncFunc(){ console.log(2) // await xx ==> promise.resolve(()=>{console.log(3)}).then() // console.log(3) 放到promise.resolve或立即执行 await console.log(3) // 相当于把console.log(4)放到了then promise.resolve(()=>{console.log(3)}).then(()=>{ // console.log(4) // }) // 微任务谁先注册谁先执行 console.log(4)}
setTimeout(()=>{console.log(5)})
const promise = new Promise((resolve,reject)=>{ console.log(6) resolve(7)})
promise.then(d=>{console.log(d)})
asyncFunc()
console.log(8)
// 输出 1 6 2 3 8 7 4 5
复制代码


1. 浏览器事件循环


涉及面试题:异步代码执行顺序?解释一下什么是 Event Loop


JavaScript 的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?所以,为了避免复杂性,从一诞生,JavaScript 就是单线程,这已经成了这门语言的核心特征,将来也不会改变


js 代码执行过程中会有很多任务,这些任务总的分成两类:


  • 同步任务

  • 异步任务


当我们打开网站时,网页的渲染过程就是一大堆同步任务,比如页面骨架和页面元素的渲染。而像加载图片音乐之类占用资源大耗时久的任务,就是异步任务。,我们用导图来说明:



我们解释一下这张图:


  • 同步和异步任务分别进入不同的执行"场所",同步的进入主线程,异步的进入 Event Table 并注册函数。

  • 当指定的事情完成时,Event Table 会将这个函数移入 Event Queue。

  • 主线程内的任务执行完毕为空,会去 Event Queue 读取对应的函数,进入主线程执行。

  • 上述过程会不断重复,也就是常说的 Event Loop(事件循环)。


那主线程执行栈何时为空呢?js 引擎存在 monitoring process 进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去 Event Queue 那里检查是否有等待被调用的函数


以上就是 js 运行的整体流程


面试中该如何回答呢? 下面是我个人推荐的回答:


  • 首先 js 是单线程运行的,在代码执行的时候,通过将不同函数的执行上下文压入执行栈中来保证代码的有序执行

  • 在执行同步代码的时候,如果遇到了异步事件,js 引擎并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务

  • 当同步事件执行完毕后,再将异步事件对应的回调加入到与当前执行栈中不同的另一个任务队列中等待执行

  • 任务队列可以分为宏任务对列和微任务对列,当当前执行栈中的事件执行完毕后,js 引擎首先会判断微任务对列中是否有任务可以执行,如果有就将微任务队首的事件压入栈中执行

  • 当微任务对列中的任务都执行完成后再去判断宏任务对列中的任务。


setTimeout(function() {  console.log(1)}, 0);new Promise(function(resolve, reject) {  console.log(2);  resolve()}).then(function() {  console.log(3)});process.nextTick(function () {  console.log(4)})console.log(5)
复制代码


  • 第一轮:主线程开始执行,遇到setTimeout,将 setTimeout 的回调函数丢到宏任务队列中,在往下执行new Promise立即执行,输出 2,then 的回调函数丢到微任务队列中,再继续执行,遇到process.nextTick,同样将回调函数扔到微任务队列,再继续执行,输出 5,当所有同步任务执行完成后看有没有可以执行的微任务,发现有 then 函数和nextTick两个微任务,先执行哪个呢?process.nextTick指定的异步任务总是发生在所有异步任务之前,因此先执行 process.nextTick 输出 4 然后执行 then 函数输出 3,第一轮执行结束。

  • 第二轮:从宏任务队列开始,发现 setTimeout 回调,输出 1 执行完毕,因此结果是 25431


JS 在执行的过程中会产生执行环境,这些执行环境会被顺序的加入到执行栈中。如果遇到异步的代码,会被挂起并加入到 Task(有多种 task) 队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码并放入执行栈中执行,所以本质上来说 JS 中的异步还是同步行为



console.log('script start');
setTimeout(function() { console.log('setTimeout');}, 0);
console.log('script end');
复制代码


不同的任务源会被分配到不同的 Task 队列中,任务源可以分为 微任务(microtask) 和 宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobsmacrotask 称为 task


console.log('script start');
setTimeout(function() { console.log('setTimeout');}, 0);
new Promise((resolve) => { console.log('Promise') resolve()}).then(function() { console.log('promise1');}).then(function() { console.log('promise2');});
console.log('script end');// script start => Promise => script end => promise1 => promise2 => setTimeout
复制代码


以上代码虽然 setTimeout 写在 Promise 之前,但是因为 Promise 属于微任务而 setTimeout 属于宏任务


微任务


  • process.nextTick

  • promise

  • Object.observe

  • MutationObserver


宏任务


  • script

  • setTimeout

  • setInterval

  • setImmediate

  • I/O 网络请求完成、文件读写完成事件

  • UI rendering

  • 用户交互事件(比如鼠标点击、滚动页面、放大缩小等)


宏任务中包括了 script ,浏览器会先执行一个宏任务,接下来有异步代码的话就先执行微任务



所以正确的一次 Event loop 顺序是这样的


  • 执行同步代码,这属于宏任务

  • 执行栈为空,查询是否有微任务需要执行

  • 执行所有微任务

  • 必要的话渲染 UI

  • 然后开始下一轮 Event loop,执行宏任务中的异步代码


通过上述的 Event loop 顺序可知,如果宏任务中的异步代码有大量的计算并且需要操作 DOM 的话,为了更快的响应界面响应,我们可以把操作 DOM 放入微任务中


  • JavaScript 引擎首先从宏任务队列(macrotask queue)中取出第一个任务

  • 执行完毕后,再将微任务(microtask queue)中的所有任务取出,按照顺序分别全部执行(这里包括不仅指开始执行时队列里的微任务),如果在这一步过程中产生新的微任务,也需要执行;

  • 然后再从宏任务队列中取下一个,执行完毕后,再次将 microtask queue 中的全部取出,循环往复,直到两个 queue 中的任务都取完。



总结起来就是:一次 Eventloop 循环会处理一个宏任务和所有这次循环中产生的微任务


2. Node 中的 Event loop


当 Node.js 开始启动时,会初始化一个 Eventloop,处理输入的代码脚本,这些脚本会进行 API 异步调用,process.nextTick() 方法会开始处理事件循环。下面就是 Node.js 官网提供的 Eventloop 事件循环参考流程


  • Node 中的 Event loop 和浏览器中的不相同。

  • NodeEvent loop 分为6个阶段,它们会按照顺序反复运行





  • 每次执行执行一个宏任务后会清空微任务(执行顺序和浏览器一致,在 node11 版本以上)

  • process.nextTick node 中的微任务,当前执行栈的底部,优先级比promise要高


整个流程分为六个阶段,当这六个阶段执行完一次之后,才可以算得上执行了一次 Eventloop 的循环过程。我们来分别看下这六个阶段都做了哪些事情。


  • Timers 阶段 :这个阶段执行 setTimeoutsetInterval的回调函数,简单理解就是由这两个函数启动的回调函数。

  • I/O callbacks 阶段 :这个阶段主要执行系统级别的回调函数,比如 TCP 连接失败的回调。

  • idle,prepare 阶段 :仅系统内部使用,你只需要知道有这 2 个阶段就可以。

  • poll 阶段poll 阶段是一个重要且复杂的阶段,几乎所有 I/O 相关的回调,都在这个阶段执行(除了setTimeoutsetIntervalsetImmediate 以及一些因为 exception 意外关闭产生的回调)。检索新的 I/O 事件,执行与 I/O 相关的回调,其他情况 Node.js 将在适当的时候在此阻塞。这也是最复杂的一个阶段,所有的事件循环以及回调处理都在这个阶段执行。这个阶段的主要流程如下图所示。



  • check 阶段setImmediate() 回调函数在这里执行,setImmediate 并不是立马执行,而是当事件循环 poll 中没有新的事件处理时就执行该部分,如下代码所示。


const fs = require('fs');setTimeout(() => { // 新的事件循环的起点    console.log('1'); }, 0);setImmediate( () => {    console.log('setImmediate 1');});/// fs.readFile 将会在 poll 阶段执行fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => {    if (err) throw err;    console.log('read file success');});/// 该部分将会在首次事件循环中执行Promise.resolve().then(()=>{    console.log('poll callback');});// 首次事件循环执行console.log('2');
复制代码


在这一代码中有一个非常奇特的地方,就是 setImmediate 会在 setTimeout 之后输出。有以下几点原因:


  • setTimeout 如果不设置时间或者设置时间为 0,则会默认为 1ms

  • 主流程执行完成后,超过 1ms 时,会将 setTimeout 回调函数逻辑插入到待执行回调函数 poll 队列中;

  • 由于当前 poll 队列中存在可执行回调函数,因此需要先执行完,待完全执行完成后,才会执行check:setImmediate


因此这也验证了这句话,先执行回调函数,再执行 setImmediate


  • close callbacks 阶段 :执行一些关闭的回调函数,如 socket.on('close', ...)


除了把 Eventloop 的宏任务细分到不同阶段外。node 还引入了一个新的任务队列 Process.nextTick()


可以认为,Process.nextTick() 会在上述各个阶段结束时,在进入下一个阶段之前立即执行(优先级甚至超过 microtask 队列)


事件循环的主要包含微任务和宏任务。具体是怎么进行循环的呢



  • 微任务 :在 Node.js 中微任务包含 2 种——process.nextTickPromise微任务在事件循环中优先级是最高的,因此在同一个事件循环中有其他任务存在时,优先执行微任务队列。并且process.nextTick 和 Promise也存在优先级,process.nextTick 高于 Promise

  • 宏任务 :在 Node.js 中宏任务包含 4 种——setTimeoutsetIntervalsetImmediateI/O。宏任务在微任务执行之后执行,因此在同一个事件循环周期内,如果既存在微任务队列又存在宏任务队列,那么优先将微任务队列清空,再执行宏任务队列


我们可以看到有一个核心的主线程,它的执行阶段主要处理三个核心逻辑。


  • 同步代码。

  • 将异步任务插入到微任务队列或者宏任务队列中。

  • 执行微任务或者宏任务的回调函数。在主线程处理回调函数的同时,也需要判断是否插入微任务和宏任务。根据优先级,先判断微任务队列是否存在任务,存在则先执行微任务,不存在则判断在宏任务队列是否有任务,有则执行。


const fs = require('fs');// 首次事件循环执行console.log('start');/// 将会在新的事件循环中的阶段执行fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => {    if (err) throw err;    console.log('read file success');});setTimeout(() => { // 新的事件循环的起点    console.log('setTimeout'); }, 0);/// 该部分将会在首次事件循环中执行Promise.resolve().then(()=>{    console.log('Promise callback');});/// 执行 process.nextTickprocess.nextTick(() => {    console.log('nextTick callback');});// 首次事件循环执行console.log('end');
复制代码


分析下上面代码的执行过程


  • 第一个事件循环主线程发起,因此先执行同步代码,所以先输出 start,然后输出 end

  • 第一个事件循环主线程发起,因此先执行同步代码,所以先输出 start,然后输出 end;

  • 再从上往下分析,遇到微任务,插入微任务队列,遇到宏任务,插入宏任务队列,分析完成后,微任务队列包含:Promise.resolve 和 process.nextTick,宏任务队列包含:fs.readFile 和 setTimeout

  • 先执行微任务队列,但是根据优先级,先执行 process.nextTick 再执行 Promise.resolve,所以先输出 nextTick callback 再输出 Promise callback

  • 再执行宏任务队列,根据宏任务插入先后顺序执行 setTimeout 再执行 fs.readFile,这里需要注意,先执行 setTimeout 由于其回调时间较短,因此回调也先执行,并非是 setTimeout 先执行所以才先执行回调函数,但是它执行需要时间肯定大于 1ms,所以虽然 fs.readFile 先于setTimeout 执行,但是 setTimeout 执行更快,所以先输出 setTimeout ,最后输出 read file success


// 输出结果startendnextTick callbackPromise callbacksetTimeoutread file success
复制代码



当微任务和宏任务又产生新的微任务和宏任务时,又应该如何处理呢?如下代码所示:


const fs = require('fs');setTimeout(() => { // 新的事件循环的起点    console.log('1');     fs.readFile('./config/test.conf', {encoding: 'utf-8'}, (err, data) => {        if (err) throw err;        console.log('read file sync success');    });}, 0);/// 回调将会在新的事件循环之前fs.readFile('./config/test.conf', {encoding: 'utf-8'}, (err, data) => {    if (err) throw err;    console.log('read file success');});/// 该部分将会在首次事件循环中执行Promise.resolve().then(()=>{    console.log('poll callback');});// 首次事件循环执行console.log('2');
复制代码


在上面代码中,有 2 个宏任务和 1 个微任务,宏任务是 setTimeout 和 fs.readFile,微任务是 Promise.resolve


  • 整个过程优先执行主线程的第一个事件循环过程,所以先执行同步逻辑,先输出 2。

  • 接下来执行微任务,输出 poll callback

  • 再执行宏任务中的 fs.readFile 和 setTimeout,由于 fs.readFile 优先级高,先执行 fs.readFile。但是处理时间长于 1ms,因此会先执行 setTimeout 的回调函数,输出 1。这个阶段在执行过程中又会产生新的宏任务 fs.readFile,因此又将该 fs.readFile 插入宏任务队列

  • 最后由于只剩下宏任务了 fs.readFile,因此执行该宏任务,并等待处理完成后的回调,输出 read file sync success


// 结果2poll callback1read file successread file sync success
复制代码


Process.nextick() 和 Vue 的 nextick



Node.js 和浏览器端宏任务队列的另一个很重要的不同点是,浏览器端任务队列每轮事件循环仅出队一个回调函数接着去执行微任务队列;而 Node.js 端只要轮到执行某个宏任务队列,则会执行完队列中所有的当前任务,但是当前轮次新添加到队尾的任务则会等到下一轮次才会执行。


setTimeout(() => {    console.log('setTimeout');}, 0);setImmediate(() => {    console.log('setImmediate');})// 这里可能会输出 setTimeout,setImmediate// 可能也会相反的输出,这取决于性能// 因为可能进入 event loop 用了不到 1 毫秒,这时候会执行 setImmediate// 否则会执行 setTimeout
复制代码


上面介绍的都是 macrotask 的执行情况,microtask 会在以上每个阶段完成后立即执行


setTimeout(()=>{    console.log('timer1')
Promise.resolve().then(function() { console.log('promise1') })}, 0)
setTimeout(()=>{ console.log('timer2')
Promise.resolve().then(function() { console.log('promise2') })}, 0)
// 以上代码在浏览器和 node 中打印情况是不同的// 浏览器中一定打印 timer1, promise1, timer2, promise2// node 中可能打印 timer1, timer2, promise1, promise2// 也可能打印 timer1, promise1, timer2, promise2
复制代码


Node 中的 process.nextTick 会先于其他 microtask 执行



setTimeout(() => { console.log("timer1");
Promise.resolve().then(function() { console.log("promise1"); });}, 0);
// poll阶段执行fs.readFile('./test',()=>{ // 在poll阶段里面 如果有setImmediate优先执行,setTimeout处于事件循环顶端 poll下面就是setImmediate setTimeout(()=>console.log('setTimeout'),0) setImmediate(()=>console.log('setImmediate'),0)})
process.nextTick(() => { console.log("nextTick");});// nextTick, timer1, promise1,setImmediate,setTimeout
复制代码


对于 microtask 来说,它会在以上每个阶段完成前清空 microtask 队列,下图中的 Tick 就代表了 microtask



谁来启动这个循环过程,循环条件是什么?


当 Node.js 启动后,会初始化事件循环,处理已提供的输入脚本,它可能会先调用一些异步的 API、调度定时器,或者 process.nextTick(),然后再开始处理事件循环。因此可以这样理解,Node.js 进程启动后,就发起了一个新的事件循环,也就是事件循环的起点。


总结来说,Node.js 事件循环的发起点有 4 个:


  • Node.js 启动后;

  • setTimeout 回调函数;

  • setInterval 回调函数;

  • 也可能是一次 I/O 后的回调函数。


无限循环有没有终点


当所有的微任务和宏任务都清空的时候,虽然当前没有任务可执行了,但是也并不能代表循环结束了。因为可能存在当前还未回调的异步 I/O,所以这个循环是没有终点的,只要进程在,并且有新的任务存在,就会去执行


Node.js 是单线程的还是多线程的?


主线程是单线程执行的,但是 Node.js 存在多线程执行,多线程包括 setTimeout 和异步 I/O 事件。其实 Node.js 还存在其他的线程,包括垃圾回收、内存优化


EventLoop 对渲染的影响


  • 想必你之前在业务开发中也遇到过 requestIdlecallback 和 requestAnimationFrame,这两个函数在我们之前的内容中没有讲过,但是当你开始考虑它们在 Eventloop 的生命周期的哪一步触发,或者这两个方法的回调会在微任务队列还是宏任务队列执行的时候,才发现好像没有想象中那么简单。这两个方法其实也并不属于 JS 的原生方法,而是浏览器宿主环境提供的方法,因为它们牵扯到另一个问题:渲染。

  • 我们知道浏览器作为一个复杂的应用是多线程工作的,除了运行 JS 的线程外,还有渲染线程、定时器触发线程、HTTP 请求线程,等等。JS 线程可以读取并且修改 DOM,而渲染线程也需要读取 DOM,这是一个典型的多线程竞争临界资源的问题。所以浏览器就把这两个线程设计成互斥的,即同时只能有一个线程在执行

  • 渲染原本就不应该出现在 Eventloop 相关的知识体系里,但是因为 Eventloop 显然是在讨论 JS 如何运行的问题,而渲染则是浏览器另外一个线程的工作。但是 requestAnimationFrame的出现却把这两件事情给关联起来

  • 通过调用 requestAnimationFrame 我们可以在下次渲染之前执行回调函数。那下次渲染具体是哪个时间点呢?渲染和 Eventloop 有什么关系呢?

  • 简单来说,就是在每一次 Eventloop 的末尾,判断当前页面是否处于渲染时机,就是重新渲染

  • 有屏幕的硬件限制,比如 60Hz 刷新率,简而言之就是 1 秒刷新了 60 次,16.6ms 刷新一次。这个时候浏览器的渲染间隔时间就没必要小于 16.6ms,因为就算渲染了屏幕上也看不到。当然浏览器也不能保证一定会每 16.6ms 会渲染一次,因为还会受到处理器的性能、JavaScript 执行效率等其他因素影响。

  • 回到 requestAnimationFrame,这个 API 保证在下次浏览器渲染之前一定会被调用,实际上我们完全可以把它看成是一个高级版的 setInterval。它们都是在一段时间后执行回调,但是前者的间隔时间是由浏览器自己不断调整的,而后者只能由用户指定。这样的特性也决定了 requestAnimationFrame 更适合用来做针对每一帧来修改的动画效果

  • 当然 requestAnimationFrame 不是 Eventloop 里的宏任务,或者说它并不在 Eventloop 的生命周期里,只是浏览器又开放的一个在渲染之前发生的新的 hook。另外需要注意的是微任务的认知概念也需要更新,在执行 animation callback 时也有可能产生微任务(比如 promise 的 callback),会放到 animation queue 处理完后再执行。所以微任务并不是像之前说的那样在每一轮 Eventloop 后处理,而是在 JS 的函数调用栈清空后处理


但是 requestIdlecallback 却是一个更好理解的概念。当宏任务队列中没有任务可以处理时,浏览器可能存在“空闲状态”。这段空闲时间可以被 requestIdlecallback 利用起来执行一些优先级不高、不必立即执行的任务,如下图所示:


常用的正则表达式有哪些?

// (1)匹配 16 进制颜色值var regex = /#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})/g;
// (2)匹配日期,如 yyyy-mm-dd 格式var regex = /^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/;
// (3)匹配 qq 号var regex = /^[1-9][0-9]{4,10}$/g;
// (4)手机号码正则var regex = /^1[34578]\d{9}$/g;
// (5)用户名正则var regex = /^[a-zA-Z\$][a-zA-Z0-9_\$]{4,16}$/;
复制代码

DNS 记录和报文

DNS 服务器中以资源记录的形式存储信息,每一个 DNS 响应报文一般包含多条资源记录。一条资源记录的具体的格式为


(Name,Value,Type,TTL)
复制代码


其中 TTL 是资源记录的生存时间,它定义了资源记录能够被其他的 DNS 服务器缓存多长时间。


常用的一共有四种 Type 的值,分别是 A、NS、CNAME 和 MX ,不同 Type 的值,对应资源记录代表的意义不同:


  • 如果 Type = A,则 Name 是主机名,Value 是主机名对应的 IP 地址。因此一条记录为 A 的资源记录,提供了标 准的主机名到 IP 地址的映射。

  • 如果 Type = NS,则 Name 是个域名,Value 是负责该域名的 DNS 服务器的主机名。这个记录主要用于 DNS 链式 查询时,返回下一级需要查询的 DNS 服务器的信息。

  • 如果 Type = CNAME,则 Name 为别名,Value 为该主机的规范主机名。该条记录用于向查询的主机返回一个主机名 对应的规范主机名,从而告诉查询主机去查询这个主机名的 IP 地址。主机别名主要是为了通过给一些复杂的主机名提供 一个便于记忆的简单的别名。

  • 如果 Type = MX,则 Name 为一个邮件服务器的别名,Value 为邮件服务器的规范主机名。它的作用和 CNAME 是一 样的,都是为了解决规范主机名不利于记忆的缺点。

TCP 和 UDP 的概念及特点

TCP 和 UDP 都是传输层协议,他们都属于 TCP/IP 协议族:


(1)UDP


UDP 的全称是用户数据报协议,在网络中它与 TCP 协议一样用于处理数据包,是一种无连接的协议。在 OSI 模型中,在传输层,处于 IP 协议的上一层。UDP 有不提供数据包分组、组装和不能对数据包进行排序的缺点,也就是说,当报文发送之后,是无法得知其是否安全完整到达的。


它的特点如下:


1)面向无连接


首先 UDP 是不需要和 TCP 一样在发送数据前进行三次握手建立连接的,想发数据就可以开始发送了。并且也只是数据报文的搬运工,不会对数据报文进行任何拆分和拼接操作。


具体来说就是:


  • 在发送端,应用层将数据传递给传输层的 UDP 协议,UDP 只会给数据增加一个 UDP 头标识下是 UDP 协议,然后就传递给网络层了

  • 在接收端,网络层将数据传递给传输层,UDP 只去除 IP 报文头就传递给应用层,不会任何拼接操作


2)有单播,多播,广播的功能


UDP 不止支持一对一的传输方式,同样支持一对多,多对多,多对一的方式,也就是说 UDP 提供了单播,多播,广播的功能。


3)面向报文


发送方的 UDP 对应用程序交下来的报文,在添加首部后就向下交付 IP 层。UDP 对应用层交下来的报文,既不合并,也不拆分,而是保留这些报文的边界。因此,应用程序必须选择合适大小的报文


4)不可靠性


首先不可靠性体现在无连接上,通信都不需要建立连接,想发就发,这样的情况肯定不可靠。


并且收到什么数据就传递什么数据,并且也不会备份数据,发送数据也不会关心对方是否已经正确接收到数据了。


再者网络环境时好时坏,但是 UDP 因为没有拥塞控制,一直会以恒定的速度发送数据。即使网络条件不好,也不会对发送速率进行调整。这样实现的弊端就是在网络条件不好的情况下可能会导致丢包,但是优点也很明显,在某些实时性要求高的场景(比如电话会议)就需要使用 UDP 而不是 TCP。


5)头部开销小,传输数据报文时是很高效的。


UDP 头部包含了以下几个数据:


  • 两个十六位的端口号,分别为源端口(可选字段)和目标端口

  • 整个数据报文的长度

  • 整个数据报文的检验和(IPv4 可选字段),该字段用于发现头部信息和数据中的错误


因此 UDP 的头部开销小,只有 8 字节,相比 TCP 的至少 20 字节要少得多,在传输数据报文时是很高效的。


(2)TCP TCP 的全称是传输控制协议是一种面向连接的、可靠的、基于字节流的传输层通信协议。TCP 是面向连接的、可靠的流协议(流就是指不间断的数据结构)。


它有以下几个特点:


1)面向连接


面向连接,是指发送数据之前必须在两端建立连接。建立连接的方法是“三次握手”,这样能建立可靠的连接。建立连接,是为数据的可靠传输打下了基础。


2)仅支持单播传输


每条 TCP 传输连接只能有两个端点,只能进行点对点的数据传输,不支持多播和广播传输方式。


3)面向字节流


TCP 不像 UDP 一样那样一个个报文独立地传输,而是在不保留报文边界的情况下以字节流方式进行传输。


4)可靠传输


对于可靠传输,判断丢包、误码靠的是 TCP 的段编号以及确认号。TCP 为了保证报文传输的可靠,就给每个包一个序号,同时序号也保证了传送到接收端实体的包的按序接收。然后接收端实体对已成功收到的字节发回一个相应的确认(ACK);如果发送端实体在合理的往返时延(RTT)内未收到确认,那么对应的数据(假设丢失了)将会被重传。


5)提供拥塞控制


当网络出现拥塞的时候,TCP 能够减小向网络注入数据的速率和数量,缓解拥塞。


6)提供全双工通信


TCP 允许通信双方的应用程序在任何时候都能发送数据,因为 TCP 连接的两端都设有缓存,用来临时存放双向通信的数据。当然,TCP 可以立即发送一个数据段,也可以缓存一段时间以便一次发送更多的数据段(最大的数据段大小取决于 MSS)

This

不同情况的调用,this指向分别如何。顺带可以提一下 es6 中箭头函数没有 this, arguments, super 等,这些只依赖包含箭头函数最接近的函数


我们先来看几个函数调用的场景


function foo() {  console.log(this.a)}var a = 1foo()
const obj = { a: 2, foo: foo}obj.foo()
const c = new foo()
复制代码


  • 对于直接调用 foo 来说,不管 foo 函数被放在了什么地方,this 一定是window

  • 对于 obj.foo() 来说,我们只需要记住,谁调用了函数,谁就是 this,所以在这个场景下 foo 函数中的 this 就是 obj 对象

  • 对于 new 的方式来说,this 被永远绑定在了 c 上面,不会被任何方式改变 this


说完了以上几种情况,其实很多代码中的 this 应该就没什么问题了,下面让我们看看箭头函数中的 this


function a() {  return () => {    return () => {      console.log(this)    }  }}console.log(a()()())
复制代码


  • 首先箭头函数其实是没有 this 的,箭头函数中的 this 只取决包裹箭头函数的第一个普通函数的 this。在这个例子中,因为包裹箭头函数的第一个普通函数是 a,所以此时的 thiswindow。另外对箭头函数使用 bind这类函数是无效的。

  • 最后种情况也就是 bind 这些改变上下文的 API 了,对于这些函数来说,this 取决于第一个参数,如果第一个参数为空,那么就是 window

  • 那么说到 bind,不知道大家是否考虑过,如果对一个函数进行多次 bind,那么上下文会是什么呢?


let a = {}let fn = function () { console.log(this) }fn.bind().bind(a)() // => ?
复制代码


如果你认为输出结果是 a,那么你就错了,其实我们可以把上述代码转换成另一种形式


// fn.bind().bind(a) 等于let fn2 = function fn1() {  return function() {    return fn.apply()  }.apply(a)}fn2()
复制代码


可以从上述代码中发现,不管我们给函数 bind 几次,fn 中的 this 永远由第一次 bind 决定,所以结果永远是 window


let a = { name: 'poetries' }function foo() {  console.log(this.name)}foo.bind(a)() // => 'poetries'
复制代码


以上就是 this 的规则了,但是可能会发生多个规则同时出现的情况,这时候不同的规则之间会根据优先级最高的来决定 this 最终指向哪里。


首先,new 的方式优先级最高,接下来是 bind 这些函数,然后是 obj.foo() 这种调用方式,最后是 foo 这种调用方式,同时,箭头函数的 this 一旦被绑定,就不会再被任何方式所改变。



函数执行改变 this


  • 由于 JS 的设计原理: 在函数中,可以引用运行环境中的变量。因此就需要一个机制来让我们可以在函数体内部获取当前的运行环境,这便是this


因此要明白 this 指向,其实就是要搞清楚 函数的运行环境,说人话就是,谁调用了函数。例如


  • obj.fn(),便是 obj 调用了函数,既函数中的 this === obj

  • fn(),这里可以看成 window.fn(),因此 this === window


但这种机制并不完全能满足我们的业务需求,因此提供了三种方式可以手动修改 this 的指向:


  • call: fn.call(target, 1, 2)

  • apply: fn.apply(target, [1, 2])

  • bind: fn.bind(target)(1,2)

从输入 URL 到页面展示过程


1. DNS 域名解析



  • 根 DNS 服务器 :返回顶级域 DNS 服务器的 IP 地址

  • 顶级域 DNS 服务器:返回权威 DNS 服务器的 IP 地址

  • 权威 DNS 服务器 :返回相应主机的 IP 地址


DNS 的域名查找,在客户端和浏览器,本地 DNS 之间的查询方式是递归查询;在本地 DNS 服务器与根域及其子域之间的查询方式是迭代查询;



在客户端输入 URL 后,会有一个递归查找的过程,从浏览器缓存中查找->本地的hosts文件查找->找本地DNS解析器缓存查找->本地DNS服务器查找,这个过程中任何一步找到了都会结束查找流程。


如果本地 DNS 服务器无法查询到,则根据本地 DNS 服务器设置的转发器进行查询。若未用转发模式,则迭代查找过程如下图:



结合起来的过程,可以用一个图表示:



在查找过程中,有以下优化点:


  • DNS 存在着多级缓存,从离浏览器的距离排序的话,有以下几种: 浏览器缓存,系统缓存,路由器缓存,IPS服务器缓存,根域名服务器缓存,顶级域名服务器缓存,主域名服务器缓存

  • 在域名和 IP 的映射过程中,给了应用基于域名做负载均衡的机会,可以是简单的负载均衡,也可以根据地址和运营商做全局的负载均衡。


2. 建立 TCP 连接


首先,判断是不是 https 的,如果是,则 HTTPS 其实是 HTTP + SSL / TLS 两部分组成,也就是在 HTTP 上又加了一层处理加密信息的模块。服务端和客户端的信息传输都会通过 TLS 进行加密,所以传输的数据都是加密后的数据


进行三次握手,建立 TCP 连接。


  • 第一次握手:建立连接。客户端发送连接请求报文段

  • 第二次握手:服务器收到 SYN 报文段。服务器收到客户端的 SYN 报文段,需要对这个 SYN 报文段进行确认

  • 第三次握手:客户端收到服务器的 SYN+ACK 报文段,向服务器发送 ACK 报文段


SSL 握手过程


  • 第一阶段 建立安全能力 包括协议版本 会话 Id 密码构件 压缩方法和初始随机数

  • 第二阶段 服务器发送证书 密钥交换数据和证书请求,最后发送请求-相应阶段的结束信号

  • 第三阶段 如果有证书请求客户端发送此证书 之后客户端发送密钥交换数据 也可以发送证书验证消息

  • 第四阶段 变更密码构件和结束握手协议


完成了之后,客户端和服务器端就可以开始传送数据


发送 HTTP 请求,服务器处理请求,返回响应结果


TCP 连接建立后,浏览器就可以利用 HTTP/HTTPS 协议向服务器发送请求了。服务器接受到请求,就解析请求头,如果头部有缓存相关信息如if-none-match与if-modified-since,则验证缓存是否有效,若有效则返回状态码为304,若无效则重新返回资源,状态码为200


这里有发生的一个过程是 HTTP 缓存,是一个常考的考点,大致过程如图:



3. 关闭 TCP 连接


4. 浏览器渲染


按照渲染的时间顺序,流水线可分为如下几个子阶段:构建 DOM 树、样式计算、布局阶段、分层、栅格化和显示。如图:



  • 渲染进程将 HTML 内容转换为能够读懂 DOM 树结构。

  • 渲染引擎将 CSS 样式表转化为浏览器可以理解的 styleSheets,计算出 DOM 节点的样式。

  • 创建布局树,并计算元素的布局信息。

  • 对布局树进行分层,并生成分层树。

  • 为每个图层生成绘制列表,并将其提交到合成线程。合成线程将图层分图块,并栅格化将图块转换成位图。

  • 合成线程发送绘制图块命令给浏览器进程。浏览器进程根据指令生成页面,并显示到显示器上。


构建 DOM 树



  • 转码(Bytes -> Characters)—— 读取接收到的 HTML 二进制数据,按指定编码格式将字节转换为 HTML 字符串

  • Tokens 化(Characters -> Tokens)—— 解析 HTML,将 HTML 字符串转换为结构清晰的 Tokens,每个 Token 都有特殊的含义同时有自己的一套规则

  • 构建 Nodes(Tokens -> Nodes)—— 每个 Node 都添加特定的属性(或属性访问器),通过指针能够确定 Node 的父、子、兄弟关系和所属 treeScope(例如:iframe 的 treeScope 与外层页面的 treeScope 不同)

  • 构建 DOM 树(Nodes -> DOM Tree)—— 最重要的工作是建立起每个结点的父子兄弟关系


样式计算


渲染引擎将 CSS 样式表转化为浏览器可以理解的 styleSheets,计算出 DOM 节点的样式。


CSS 样式来源主要有 3 种,分别是通过 link 引用的外部 CSS 文件、style 标签内的 CSS、元素的 style 属性内嵌的 CSS。


页面布局


布局过程,即排除 script、meta 等功能化、非视觉节点,排除 display: none 的节点,计算元素的位置信息,确定元素的位置,构建一棵只包含可见元素布局树。如图:



其中,这个过程需要注意的是回流和重绘


生成分层树


页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-indexing 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)


栅格化


合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图



通常一个页面可能很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个部分叫做视口(viewport)。在有些情况下,有的图层可以很大,比如有的页面你使用滚动条要滚动好久才能滚动到底部,但是通过视口,用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。


显示


最后,合成线程发送绘制图块命令给浏览器进程。浏览器进程根据指令生成页面,并显示到显示器上,渲染过程完成。

垃圾回收

  • 对于在 JavaScript 中的字符串,对象,数组是没有固定大小的,只有当对他们进行动态分配存储时,解释器就会分配内存来存储这些数据,当 JavaScript 的解释器消耗完系统中所有可用的内存时,就会造成系统崩溃。

  • 内存泄漏,在某些情况下,不再使用到的变量所占用内存没有及时释放,导致程序运行中,内存越占越大,极端情况下可以导致系统崩溃,服务器宕机。

  • JavaScript 有自己的一套垃圾回收机制,JavaScript 的解释器可以检测到什么时候程序不再使用这个对象了(数据),就会把它所占用的内存释放掉。

  • 针对 JavaScript 的来及回收机制有以下两种方法(常用):标记清除,引用计数

  • 标记清除


v8 的垃圾回收机制基于分代回收机制,这个机制又基于世代假说,这个假说有两个特点,一是新生的对象容易早死,另一个是不死的对象会活得更久。基于这个假说,v8 引擎将内存分为了新生代和老生代。


  • 新创建的对象或者只经历过一次的垃圾回收的对象被称为新生代。经历过多次垃圾回收的对象被称为老生代。

  • 新生代被分为 From 和 To 两个空间,To 一般是闲置的。当 From 空间满了的时候会执行 Scavenge 算法进行垃圾回收。当我们执行垃圾回收算法的时候应用逻辑将会停止,等垃圾回收结束后再继续执行。


这个算法分为三步:


  • 首先检查 From 空间的存活对象,如果对象存活则判断对象是否满足晋升到老生代的条件,如果满足条件则晋升到老生代。如果不满足条件则移动 To 空间。

  • 如果对象不存活,则释放对象的空间。

  • 最后将 From 空间和 To 空间角色进行交换。


新生代对象晋升到老生代有两个条件:


  • 第一个是判断是对象否已经经过一次 Scavenge 回收。若经历过,则将对象从 From 空间复制到老生代中;若没有经历,则复制到 To 空间。

  • 第二个是 To 空间的内存使用占比是否超过限制。当对象从 From 空间复制到 To 空间时,若 To 空间使用超过 25%,则对象直接晋升到老生代中。设置 25% 的原因主要是因为算法结束后,两个空间结束后会交换位置,如果 To 空间的内存太小,会影响后续的内存分配。


老生代采用了标记清除法和标记压缩法。标记清除法首先会对内存中存活的对象进行标记,标记结束后清除掉那些没有标记的对象。由于标记清除后会造成很多的内存碎片,不便于后面的内存分配。所以了解决内存碎片的问题引入了标记压缩法。


由于在进行垃圾回收的时候会暂停应用的逻辑,对于新生代方法由于内存小,每次停顿的时间不会太长,但对于老生代来说每次垃圾回收的时间长,停顿会造成很大的影响。 为了解决这个问题 V8 引入了增量标记的方法,将一次停顿进行的过程分为了多步,每次执行完一小步就让运行逻辑执行一会,就这样交替运行

HTTP 世界全览


  • 互联网上绝大部分资源都使用 HTTP 协议传输;

  • 浏览器是 HTTP 协议里的请求方,即 User Agent

  • 服务器是 HTTP 协议里的应答方,常用的有 ApacheNginx

  • CDN 位于浏览器和服务器之间,主要起到缓存加速的作用;

  • 爬虫是另一类 User Agent,是自动访问网络资源的程序。

  • TCP/IP 是网络世界最常用的协议,HTTP 通常运行在 TCP/IP 提供的可靠传输基础上

  • DNS 域名是 IP 地址的等价替代,需要用域名解析实现到 IP 地址的映射;

  • URI 是用来标记互联网上资源的一个名字,由“协议名 + 主机名 + 路径”构成,俗称 URL;

  • HTTPS 相当于“HTTP+SSL/TLS+TCP/IP”,为 HTTP 套了一个安全的外壳;

  • 代理是 HTTP 传输过程中的“中转站”,可以实现缓存加速、负载均衡等功能

vue-router

mode


  • hash

  • history


跳转


  • this.$router.push()

  • <router-link to=""></router-link>


占位


<router-view></router-view>
复制代码


vue-router 源码实现


  • 作为一个插件存在:实现VueRouter类和install方法

  • 实现两个全局组件:router-view用于显示匹配组件内容,router-link用于跳转

  • 监控url变化:监听hashchangepopstate事件

  • 响应最新url:创建一个响应式的属性current,当它改变时获取对应组件并显示


// 我们的插件:// 1.实现一个Router类并挂载期实例// 2.实现两个全局组件router-link和router-viewlet Vue;
class VueRouter { // 核心任务: // 1.监听url变化 constructor(options) { this.$options = options;
// 缓存path和route映射关系 // 这样找组件更快 this.routeMap = {} this.$options.routes.forEach(route => { this.routeMap[route.path] = route })
// 数据响应式 // 定义一个响应式的current,则如果他变了,那么使用它的组件会rerender Vue.util.defineReactive(this, 'current', '')
// 请确保onHashChange中this指向当前实例 window.addEventListener('hashchange', this.onHashChange.bind(this)) window.addEventListener('load', this.onHashChange.bind(this)) }
onHashChange() { // console.log(window.location.hash); this.current = window.location.hash.slice(1) || '/' }}
// 插件需要实现install方法// 接收一个参数,Vue构造函数,主要用于数据响应式VueRouter.install = function (_Vue) { // 保存Vue构造函数在VueRouter中使用 Vue = _Vue
// 任务1:使用混入来做router挂载这件事情 Vue.mixin({ beforeCreate() { // 只有根实例才有router选项 if (this.$options.router) { Vue.prototype.$router = this.$options.router }
} })
// 任务2:实现两个全局组件 // router-link: 生成一个a标签,在url后面添 // <router-link to="/about">aaa</router-link> Vue.component('router-link', { props: { to: { type: String, required: true }, }, render(h) { // h(tag, props, children) return h('a', { attrs: { href: '#' + this.to } }, this.$slots.default ) // 使用jsx // return <a href={'#'+this.to}>{this.$slots.default}</a> } }) Vue.component('router-view', { render(h) { // 根据current获取组件并render // current怎么获取? // console.log('render',this.$router.current); // 获取要渲染的组件 let component = null const { routeMap, current } = this.$router if (routeMap[current]) { component = routeMap[current].component } return h(component) } })}
export default VueRouter
复制代码


用户头像

coder2028

关注

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

还未添加个人简介

评论

发布
暂无评论
前端经典面试题(有答案)_JavaScript_coder2028_InfoQ写作社区