2万字 | 前端基础拾遗90问

2万字 | 前端基础拾遗90问大家好,我是练习时长一年半的前端练习生,喜欢唱、跳、rap、敲代码。本文是笔者一年多来对前端基础知识的总结和思考,这些题目对自己是总结,对大家也是一点微薄的资料,希望能给大家带来一些帮助和启发。成文过程中得到了许多大佬的帮助,在此感谢恺哥的小册、神三元同学的前端每日一问以及许多…

大家好,我是练习时长一年半的前端练习生,喜欢唱、跳、rap、敲代码。本文是笔者一年多来对前端基础知识的总结和思考,这些题目对自己是总结,对大家也是一点微薄的资料,希望能给大家带来一些帮助和启发。成文过程中得到了许多大佬的帮助,在此感谢恺哥的小册、神三元同学的前端每日一问以及许多素未谋面的朋友们,让我等萌新也有机会在前人的财富中拾人牙慧,班门弄斧Thanks♪(・ω・)ノ

本文将从以下十一个维度为读者总结前端基础知识

2万字 | 前端基础拾遗90问

JS基础

1. 如何在ES5环境下实现let

这个问题实质上是在回答letvar有什么区别,对于这个问题,我们可以直接查看babel转换前后的结果,看一下在循环中通过let定义的变量是如何解决变量提升的问题

2万字 | 前端基础拾遗90问

babel在let定义的变量前加了道下划线,避免在块级作用域外访问到该变量,除了对变量名的转换,我们也可以通过自执行函数来模拟块级作用域

(function(){
  for(var i = 0; i < 5; i ++){
    console.log(i)  // 0 1 2 3 4
  }
})();

console.log(i)      // Uncaught ReferenceError: i is not defined

不过这个问题并没有结束,我们回到varlet/const的区别上:

  • var声明的变量会挂到window上,而letconst不会
  • var声明的变量存在变量提升,而letconst不会
  • letconst声明形成块作用域,只能在块作用域里访问,不能跨块访问,也不能跨函数访问
  • 同一作用域下letconst不能声明同名变量,而var可以
  • 暂时性死区,letconst声明的变量不能在声明前被使用

babel的转化,其实只实现了第2、3、5点

2. 如何在ES5环境下实现const

实现const的关键在于Object.defineProperty()这个API,这个API用于在一个对象上增加或修改属性。通过配置属性描述符,可以精确地控制属性行为。Object.defineProperty() 接收三个参数:

Object.defineProperty(obj, prop, desc)

参数 说明
obj 要在其上定义属性的对象
prop 要定义或修改的属性的名称
descriptor 将被定义或修改的属性描述符

属性描述符 说明 默认值
value 该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。默认为 undefined undefined
get 一个给属性提供 getter 的方法,如果没有 getter 则为 undefined undefined
set 一个给属性提供 setter 的方法,如果没有 setter 则为 undefined。当属性值修改时,触发执行该方法 undefined
writable 当且仅当该属性的writable为true时,value才能被赋值运算符改变。默认为 false false
enumerable enumerable定义了对象的属性是否可以在 for…in 循环和 Object.keys() 中被枚举 false
Configurable configurable特性表示对象的属性是否可以被删除,以及除value和writable特性外的其他特性是否可以被修改 false

对于const不可修改的特性,我们通过设置writable属性来实现

function _const(key, value) {    
    const desc = {        
        value,        
        writable: false    
    }    
    Object.defineProperty(window, key, desc)
}
    
_const('obj', {a: 1})   //定义obj
obj.b = 2               //可以正常给obj的属性赋值
obj = {}                //无法赋值新对象

参考资料:如何在 ES5 环境下实现一个const ?

3. 手写call()

call() 方法使用一个指定的 this 值和单独给出的一个或多个参数来调用一个函数
语法:function.call(thisArg, arg1, arg2, ...)

call()的原理比较简单,由于函数的this指向它的直接调用者,我们变更调用者即完成this指向的变更:

//变更函数调用者示例
function foo() {
    console.log(this.name)
}

// 测试
const obj = {
    name: '写代码像蔡徐抻'
}
obj.foo = foo   // 变更foo的调用者
obj.foo()       // '写代码像蔡徐抻'

基于以上原理, 我们两句代码就能实现call()

Function.prototype.myCall = function(thisArg, ...args) {
    thisArg.fn = this              // this指向调用call的对象,即我们要改变this指向的函数
    return thisArg.fn(...args)     // 执行函数并return其执行结果
}

但是我们有一些细节需要处理:

Function.prototype.myCall = function(thisArg, ...args) {
    const fn = Symbol('fn')        // 声明一个独有的Symbol属性, 防止fn覆盖已有属性
    thisArg = thisArg || window    // 若没有传入this, 默认绑定window对象
    thisArg[fn] = this              // this指向调用call的对象,即我们要改变this指向的函数
    const result = thisArg[fn](...args)  // 执行当前函数
    delete thisArg[fn]              // 删除我们声明的fn属性
    return result                  // 返回函数执行结果
}

//测试
foo.myCall(obj)     // 输出'写代码像蔡徐抻'

4. 手写apply()

apply() 方法调用一个具有给定this值的函数,以及作为一个数组(或类似数组对象)提供的参数。
语法:func.apply(thisArg, [argsArray])

apply()call()类似,区别在于call()接收参数列表,而apply()接收一个参数数组,所以我们在call()的实现上简单改一下入参形式即可

Function.prototype.myApply = function(thisArg, args) {
    const fn = Symbol('fn')        // 声明一个独有的Symbol属性, 防止fn覆盖已有属性
    thisArg = thisArg || window    // 若没有传入this, 默认绑定window对象
    thisArg[fn] = this              // this指向调用call的对象,即我们要改变this指向的函数
    const result = thisArg[fn](...args)  // 执行当前函数(此处说明一下:虽然apply()接收的是一个数组,但在调用原函数时,依然要展开参数数组。可以对照原生apply(),原函数接收到展开的参数数组)
    delete thisArg[fn]              // 删除我们声明的fn属性
    return result                  // 返回函数执行结果
}

//测试
foo.myApply(obj, [])     // 输出'写代码像蔡徐抻'

5. 手写bind()

bind() 方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。
语法: function.bind(thisArg, arg1, arg2, …)

从用法上看,似乎给call/apply包一层function就实现了bind():

Function.prototype.myBind = function(thisArg, ...args) {
    return () => {
        this.apply(thisArg, args)
    }
}

但我们忽略了三点:

  1. bind()除了this还接收其他参数,bind()返回的函数也接收参数,这两部分的参数都要传给返回的函数
  2. new会改变this指向:如果bind绑定后的函数被new了,那么this指向会发生改变,指向当前函数的实例
  3. 没有保留原函数在原型链上的属性和方法
Function.prototype.myBind = function (thisArg, ...args) {
    var self = this
    // new优先级
    var fbound = function () {
        self.apply(this instanceof self ? this : thisArg, args.concat(Array.prototype.slice.call(arguments)))
    }
    // 继承原型上的属性和方法
    fbound.prototype = Object.create(self.prototype);

    return fbound;
}

//测试
const obj = { name: '写代码像蔡徐抻' }
function foo() {
    console.log(this.name)
    console.log(arguments)
}

foo.myBind(obj, 'a', 'b', 'c')()    //输出写代码像蔡徐抻 ['a', 'b', 'c']

6. 手写一个防抖函数

防抖和节流的概念都比较简单,所以我们就不在“防抖节流是什么”这个问题上浪费过多篇幅了,简单点一下:

防抖,即短时间内大量触发同一事件,只会执行一次函数,实现原理为设置一个定时器,约定在xx毫秒后再触发事件处理,每次触发事件都会重新设置计时器,直到xx毫秒内无第二次操作,防抖常用于搜索框/滚动条的监听事件处理,如果不做防抖,每输入一个字/滚动屏幕,都会触发事件处理,造成性能浪费。

function debounce(func, wait) {
    let timeout = null
    return function() {
        let context = this
        let args = arguments
        if (timeout) clearTimeout(timeout)
        timeout = setTimeout(() => {
            func.apply(context, args)
        }, wait)
    }
}

7. 手写一个节流函数

防抖是延迟执行,而节流是间隔执行,函数节流即每隔一段时间就执行一次,实现原理为设置一个定时器,约定xx毫秒后执行事件,如果时间到了,那么执行函数并重置定时器,和防抖的区别在于,防抖每次触发事件都重置定时器,而节流在定时器到时间后再清空定时器

function throttle(func, wait) {
    let timeout = null
    return function() {
        let context = this
        let args = arguments
        if (!timeout) {
            timeout = setTimeout(() => {
                timeout = null
                func.apply(context, args)
            }, wait)
        }

    }
}

实现方式2:使用两个时间戳prev旧时间戳now新时间戳,每次触发事件都判断二者的时间差,如果到达规定时间,执行函数并重置旧时间戳

function throttle(func, wait) {
    var prev = 0;
    return function() {
        let now = Date.now();
        let context = this;
        let args = arguments;
        if (now - prev > wait) {
            func.apply(context, args);
            prev = now;
        }
    }
}

8. 数组扁平化

对于[1, [1,2], [1,2,3]]这样多层嵌套的数组,我们如何将其扁平化为[1, 1, 2, 1, 2, 3]这样的一维数组呢:

1.ES6的flat()

const arr = [1, [1,2], [1,2,3]]
arr.flat(Infinity)  // [1, 1, 2, 1, 2, 3]

2.序列化后正则

const arr = [1, [1,2], [1,2,3]]
const str = `[${JSON.stringify(arr).replace(/(\[|\])/g, '')}]`
JSON.parse(str)   // [1, 1, 2, 1, 2, 3]

3.递归
对于树状结构的数据,最直接的处理方式就是递归

const arr = [1, [1,2], [1,2,3]]
function flat(arr) {
  let result = []
  for (const item of arr) {
    item instanceof Array ? result = result.concat(flat(item)) : result.push(item)
  }
  return result
}

flat(arr) // [1, 1, 2, 1, 2, 3]

4.reduce()递归

const arr = [1, [1,2], [1,2,3]]
function flat(arr) {
  return arr.reduce((prev, cur) => {
    return prev.concat(cur instanceof Array ? flat(cur) : cur)
  }, [])
}

flat(arr)  // [1, 1, 2, 1, 2, 3]

5.迭代+展开运算符

// 每次while都会合并一层的元素,这里第一次合并结果为[1, 1, 2, 1, 2, 3, [4,4,4]]
// 然后arr.some判定数组中是否存在数组,因为存在[4,4,4],继续进入第二次循环进行合并
let arr = [1, [1,2], [1,2,3,[4,4,4]]]
while (arr.some(Array.isArray)) {
  arr = [].concat(...arr);
}

console.log(arr)  // [1, 1, 2, 1, 2, 3, 4, 4, 4]

9. 手写一个Promise

实现一个符合规范的Promise篇幅比较长,建议阅读笔者上一篇文章:异步编程二三事 | Promise/async/Generator实现原理解析 | 9k字

JS面向对象

在JS中一切皆对象,但JS并不是一种真正的面向对象(OOP)的语言,因为它缺少类(class)的概念。虽然ES6引入了classextends,使我们能够轻易地实现类和继承。但JS并不存在真实的类,JS的类是通过函数以及原型链机制模拟的,本小节的就来探究如何在ES5环境下利用函数和原型链实现JS面向对象的特性

在开始之前,我们先回顾一下原型链的知识,后续new继承等实现都是基于原型链机制。很多介绍原型链的资料都能写上洋洋洒洒几千字,但我觉得读者们不需要把原型链想太复杂,容易把自己绕进去,其实在我看来,原型链的核心只需要记住三点:

  1. 每个对象都有__proto__属性,该属性指向其原型对象,在调用实例的方法和属性时,如果在实例对象上找不到,就会往原型对象上找
  2. 构造函数的prototype属性也指向实例的原型对象
  3. 原型对象的constructor属性指向构造函数
2万字 | 前端基础拾遗90问

1. 模拟实现new

首先我们要知道new做了什么

  1. 创建一个新对象,并继承其构造函数的prototype,这一步是为了继承构造函数原型上的属性和方法
  2. 执行构造函数,方法内的this被指定为该新实例,这一步是为了执行构造函数内的赋值操作
  3. 返回新实例(规范规定,如果构造方法返回了一个对象,那么返回该对象,否则返回第一步创建的新对象)
// new是关键字,这里我们用函数来模拟,new Foo(args) <=> myNew(Foo, args)
function myNew(foo, ...args) {
  // 创建新对象,并继承构造方法的prototype属性, 这一步是为了把obj挂原型链上, 相当于obj.__proto__ = Foo.prototype
  let obj = Object.create(foo.prototype)  
  
  // 执行构造方法, 并为其绑定新this, 这一步是为了让构造方法能进行this.name = name之类的操作, args是构造方法的入参, 因为这里用myNew模拟, 所以入参从myNew传入
  let result = foo.apply(obj, args)

  // 如果构造方法已经return了一个对象,那么就返回该对象,否则返回myNew创建的新对象(一般情况下,构造方法不会返回新实例,但使用者可以选择返回新实例来覆盖new创建的对象)
  return Object.prototype.toString.call(result) === '[object Object]' ? result : obj
}

// 测试:
function Foo(name) {
  this.name = name
}
const newObj = myNew(Foo, 'zhangsan')
console.log(newObj)                 // Foo {name: "zhangsan"}
console.log(newObj instanceof Foo)  // true

2. ES5如何实现继承

说到继承,最容易想到的是ES6的extends,当然如果只回答这个肯定不合格,我们要从函数和原型链的角度上实现继承,下面我们一步步地、递进地实现一个合格的继承

一. 原型链继承

原型链继承的原理很简单,直接让子类的原型对象指向父类实例,当子类实例找不到对应的属性和方法时,就会往它的原型对象,也就是父类实例上找,从而实现对父类的属性和方法的继承

// 父类
function Parent() {
    this.name = '写代码像蔡徐抻'
}
// 父类的原型方法
Parent.prototype.getName = function() {
    return this.name
}
// 子类
function Child() {}

// 让子类的原型对象指向父类实例, 这样一来在Child实例中找不到的属性和方法就会到原型对象(父类实例)上寻找
Child.prototype = new Parent()
Child.prototype.constructor = Child // 根据原型链的规则,顺便绑定一下constructor, 这一步不影响继承, 只是在用到constructor时会需要

// 然后Child实例就能访问到父类及其原型上的name属性和getName()方法
const child = new Child()
child.name          // '写代码像蔡徐抻'
child.getName()     // '写代码像蔡徐抻'

原型继承的缺点:

  1. 由于所有Child实例原型都指向同一个Parent实例, 因此对某个Child实例的父类引用类型变量修改会影响所有的Child实例
  2. 在创建子类实例时无法向父类构造传参, 即没有实现super()的功能
// 示例:
function Parent() {
    this.name = ['写代码像蔡徐抻'] 
}
Parent.prototype.getName = function() {
    return this.name
}
function Child() {}

Child.prototype = new Parent()
Child.prototype.constructor = Child 

// 测试
const child1 = new Child()
const child2 = new Child()
child1.name[0] = 'foo'
console.log(child1.name)          // ['foo']
console.log(child2.name)          // ['foo'] (预期是['写代码像蔡徐抻'], 对child1.name的修改引起了所有child实例的变化)

二. 构造函数继承

构造函数继承,即在子类的构造函数中执行父类的构造函数,并为其绑定子类的this,让父类的构造函数把成员属性和方法都挂到子类的this上去,这样既能避免实例之间共享一个原型实例,又能向父类构造方法传参

function Parent(name) {
    this.name = [name]
}
Parent.prototype.getName = function() {
    return this.name
}
function Child() {
    Parent.call(this, 'zhangsan')   // 执行父类构造方法并绑定子类的this, 使得父类中的属性能够赋到子类的this上
}

//测试
const child1 = new Child()
const child2 = new Child()
child1.name[0] = 'foo'
console.log(child1.name)          // ['foo']
console.log(child2.name)          // ['zhangsan']
child2.getName()                  // 报错,找不到getName(), 构造函数继承的方式继承不到父类原型上的属性和方法

构造函数继承的缺点:

  1. 继承不到父类原型上的属性和方法

三. 组合式继承

既然原型链继承和构造函数继承各有互补的优缺点, 那么我们为什么不组合起来使用呢, 所以就有了综合二者的组合式继承

function Parent(name) {
    this.name = [name]
}
Parent.prototype.getName = function() {
    return this.name
}
function Child() {
    // 构造函数继承
    Parent.call(this, 'zhangsan') 
}
//原型链继承
Child.prototype = new Parent()
Child.prototype.constructor = Child

//测试
const child1 = new Child()
const child2 = new Child()
child1.name[0] = 'foo'
console.log(child1.name)          // ['foo']
console.log(child2.name)          // ['zhangsan']
child2.getName()                  // ['zhangsan']

组合式继承的缺点:

  1. 每次创建子类实例都执行了两次构造函数(Parent.call()new Parent()),虽然这并不影响对父类的继承,但子类创建实例时,原型中会存在两份相同的属性和方法,这并不优雅

四. 寄生式组合继承

为了解决构造函数被执行两次的问题, 我们将指向父类实例改为指向父类原型, 减去一次构造函数的执行

function Parent(name) {
    this.name = [name]
}
Parent.prototype.getName = function() {
    return this.name
}
function Child() {
    // 构造函数继承
    Parent.call(this, 'zhangsan') 
}
//原型链继承
// Child.prototype = new Parent()
Child.prototype = Parent.prototype  //将`指向父类实例`改为`指向父类原型`
Child.prototype.constructor = Child

//测试
const child1 = new Child()
const child2 = new Child()
child1.name[0] = 'foo'
console.log(child1.name)          // ['foo']
console.log(child2.name)          // ['zhangsan']
child2.getName()                  // ['zhangsan']

但这种方式存在一个问题,由于子类原型和父类原型指向同一个对象,我们对子类原型的操作会影响到父类原型,例如给Child.prototype增加一个getName()方法,那么会导致Parent.prototype也增加或被覆盖一个getName()方法,为了解决这个问题,我们给Parent.prototype做一个浅拷贝

function Parent(name) {
    this.name = [name]
}
Parent.prototype.getName = function() {
    return this.name
}
function Child() {
    // 构造函数继承
    Parent.call(this, 'zhangsan') 
}
//原型链继承
// Child.prototype = new Parent()
Child.prototype = Object.create(Parent.prototype)  //将`指向父类实例`改为`指向父类原型`
Child.prototype.constructor = Child

//测试
const child = new Child()
const parent = new Parent()
child.getName()                  // ['zhangsan']
parent.getName()                 // 报错, 找不到getName()

到这里我们就完成了ES5环境下的继承的实现,这种继承方式称为寄生组合式继承,是目前最成熟的继承方式,babel对ES6继承的转化也是使用了寄生组合式继承

我们回顾一下实现过程:
一开始最容易想到的是原型链继承,通过把子类实例的原型指向父类实例来继承父类的属性和方法,但原型链继承的缺陷在于对子类实例继承的引用类型的修改会影响到所有的实例对象以及无法向父类的构造方法传参
因此我们引入了构造函数继承, 通过在子类构造函数中调用父类构造函数并传入子类this来获取父类的属性和方法,但构造函数继承也存在缺陷,构造函数继承不能继承到父类原型链上的属性和方法
所以我们综合了两种继承的优点,提出了组合式继承,但组合式继承也引入了新的问题,它每次创建子类实例都执行了两次父类构造方法,我们通过将子类原型指向父类实例改为子类原型指向父类原型的浅拷贝来解决这一问题,也就是最终实现 —— 寄生组合式继承

2万字 | 前端基础拾遗90问

V8引擎机制

1. V8如何执行一段JS代码

2万字 | 前端基础拾遗90问

  1. 预解析:检查语法错误但不生成AST
  2. 生成AST:经过词法/语法分析,生成抽象语法树
  3. 生成字节码:基线编译器(Ignition)将AST转换成字节码
  4. 生成机器码:优化编译器(Turbofan)将字节码转换成优化过的机器码,此外在逐行执行字节码的过程中,如果一段代码经常被执行,那么V8会将这段代码直接转换成机器码保存起来,下一次执行就不必经过字节码,优化了执行速度

上面几点只是V8执行机制的极简总结,建议阅读参考资料:

1.V8 是怎么跑起来的 —— V8 的 JavaScript 执行管道
2.JavaScript 引擎 V8 执行流程概述

2. 介绍一下引用计数和标记清除

  • 引用计数:给一个变量赋值引用类型,则该对象的引用次数+1,如果这个变量变成了其他值,那么该对象的引用次数-1,垃圾回收器会回收引用次数为0的对象。但是当对象循环引用时,会导致引用次数永远无法归零,造成内存无法释放。
  • 标记清除:垃圾收集器先给内存中所有对象加上标记,然后从根节点开始遍历,去掉被引用的对象和运行环境中对象的标记,剩下的被标记的对象就是无法访问的等待回收的对象。

3. V8如何进行垃圾回收

JS引擎中对变量的存储主要有两种位置,栈内存和堆内存,栈内存存储基本类型数据以及引用类型数据的内存地址,堆内存储存引用类型的数据

2万字 | 前端基础拾遗90问

栈内存的回收:

栈内存调用栈上下文切换后就被回收,比较简单

堆内存的回收:

V8的堆内存分为新生代内存和老生代内存,新生代内存是临时分配的内存,存在时间短,老生代内存存在时间长

2万字 | 前端基础拾遗90问

  • 新生代内存回收机制:
    • 新生代内存容量小,64位系统下仅有32M。新生代内存分为From、To两部分,进行垃圾回收时,先扫描From,将非存活对象回收,将存活对象顺序复制到To中,之后调换From/To,等待下一次回收
  • 老生代内存回收机制
    • 晋升:如果新生代的变量经过多次回收依然存在,那么就会被放入老生代内存中
    • 标记清除:老生代内存会先遍历所有对象并打上标记,然后对正在使用或被强引用的对象取消标记,回收被标记的对象
    • 整理内存碎片:把对象挪到内存的一端

参考资料:聊聊V8引擎的垃圾回收

4. JS相较于C++等语言为什么慢,V8做了哪些优化

  1. JS的问题:
    • 动态类型:导致每次存取属性/寻求方法时候,都需要先检查类型;此外动态类型也很难在编译阶段进行优化
    • 属性存取:C++/Java等语言中方法、属性是存储在数组中的,仅需数组位移就可以获取,而JS存储在对象中,每次获取都要进行哈希查询
  2. V8的优化:
    • 优化JIT(即时编译):相较于C++/Java这类编译型语言,JS一边解释一边执行,效率低。V8对这个过程进行了优化:如果一段代码被执行多次,那么V8会把这段代码转化为机器码缓存下来,下次运行时直接使用机器码。
    • 隐藏类:对于C++这类语言来说,仅需几个指令就能通过偏移量获取变量信息,而JS需要进行字符串匹配,效率低,V8借用了类和偏移位置的思想,将对象划分成不同的组,即隐藏类
    • 内嵌缓存:即缓存对象查询的结果。常规查询过程是:获取隐藏类地址 -> 根据属性名查找偏移值 -> 计算该属性地址,内嵌缓存就是对这一过程结果的缓存
    • 垃圾回收管理:上文已介绍
2万字 | 前端基础拾遗90问

参考资料:为什么V8引擎这么快?

浏览器渲染机制

1. 浏览器的渲染过程是怎样的

2万字 | 前端基础拾遗90问

大体流程如下:

  1. HTML和CSS经过各自解析,生成DOM树和CSSOM树
  2. 合并成为渲染树
  3. 根据渲染树进行布局
  4. 最后调用GPU进行绘制,显示在屏幕上

2. 如何根据浏览器渲染机制加快首屏速度

  1. 优化文件大小:HTML和CSS的加载和解析都会阻塞渲染树的生成,从而影响首屏展示速度,因此我们可以通过优化文件大小、减少CSS文件层级的方法来加快首屏速度
  2. 避免资源下载阻塞文档解析:浏览器解析到<script>标签时,会阻塞文档解析,直到脚本执行完成,因此我们通常把<script>标签放在底部,或者加上defer、async来进行异步下载

3. 什么是回流(重排),什么情况下会触发回流

  • 当元素的尺寸或者位置发生了变化,就需要重新计算渲染树,这就是回流
  • DOM元素的几何属性(width/height/padding/margin/border)发生变化时会触发回流
  • DOM元素移动或增加会触发回流
  • 读写offset/scroll/client等属性时会触发回流
  • 调用window.getComputedStyle会触发回流

4. 什么是重绘,什么情况下会触发重绘

  • DOM样式发生了变化,但没有影响DOM的几何属性时,会触发重绘,而不会触发回流。重绘由于DOM位置信息不需要更新,省去了布局过程,因而性能上优于回流

5. 什么是GPU加速,如何使用GPU加速,GPU加速的缺点

  • 优点:使用transform、opacity、filters等属性时,会直接在GPU中完成处理,这些属性的变化不会引起回流重绘
  • 缺点:GPU渲染字体会导致字体模糊,过多的GPU处理会导致内存问题

6. 如何减少回流

  • 使用class替代style,减少style的使用
  • 使用resize、scroll时进行防抖和节流处理,这两者会直接导致回流
  • 使用visibility替换display: none,因为前者只会引起重绘,后者会引发回流
  • 批量修改元素时,可以先让元素脱离文档流,等修改完毕后,再放入文档流
  • 避免触发同步布局事件,我们在获取offsetWidth这类属性的值时,可以使用变量将查询结果存起来,避免多次查询,每次对offset/scroll/client等属性进行查询时都会触发回流
  • 对于复杂动画效果,使用绝对定位让其脱离文档流,复杂的动画效果会频繁地触发回流重绘,我们可以将动画元素设置绝对定位从而脱离文档流避免反复回流重绘。
2万字 | 前端基础拾遗90问

参考资料:必须明白的浏览器渲染机制

浏览器缓存策略

1. 介绍一下浏览器缓存位置和优先级

  1. Service Worker
  2. Memory Cache(内存缓存)
  3. Disk Cache(硬盘缓存)
  4. Push Cache(推送缓存)
  5. 以上缓存都没命中就会进行网络请求

2. 说说不同缓存间的差别

  1. Service Worker

和Web Worker类似,是独立的线程,我们可以在这个线程中缓存文件,在主线程需要的时候读取这里的文件,Service Worker使我们可以自由选择缓存哪些文件以及文件的匹配、读取规则,并且缓存是持续性的

  1. Memory Cache

即内存缓存,内存缓存不是持续性的,缓存会随着进程释放而释放

  1. Disk Cache

即硬盘缓存,相较于内存缓存,硬盘缓存的持续性和容量更优,它会根据HTTP header的字段判断哪些资源需要缓存

  1. Push Cache

即推送缓存,是HTTP/2的内容,目前应用较少

3. 介绍一下浏览器缓存策略

强缓存(不要向服务器询问的缓存)

设置Expires

  • 即过期时间,例如「Expires: Thu, 26 Dec 2019 10:30:42 GMT」表示缓存会在这个时间后失效,这个过期日期是绝对日期,如果修改了本地日期,或者本地日期与服务器日期不一致,那么将导致缓存过期时间错误。

设置Cache-Control

  • HTTP/1.1新增字段,Cache-Control可以通过max-age字段来设置过期时间,例如「Cache-Control:max-age=3600」除此之外Cache-Control还能设置private/no-cache等多种字段

协商缓存(需要向服务器询问缓存是否已经过期)

Last-Modified

  • 即最后修改时间,浏览器第一次请求资源时,服务器会在响应头上加上Last-Modified ,当浏览器再次请求该资源时,浏览器会在请求头中带上If-Modified-Since 字段,字段的值就是之前服务器返回的最后修改时间,服务器对比这两个时间,若相同则返回304,否则返回新资源,并更新Last-Modified

ETag

  • HTTP/1.1新增字段,表示文件唯一标识,只要文件内容改动,ETag就会重新计算。缓存流程和 Last-Modified 一样:服务器发送 ETag 字段 -> 浏览器再次请求时发送 If-None-Match -> 如果ETag值不匹配,说明文件已经改变,返回新资源并更新ETag,若匹配则返回304

两者对比

  • ETag 比 Last-Modified 更准确:如果我们打开文件但并没有修改,Last-Modified 也会改变,并且 Last-Modified 的单位时间为一秒,如果一秒内修改完了文件,那么还是会命中缓存
  • 如果什么缓存策略都没有设置,那么浏览器会取响应头中的 Date 减去 Last-Modified 值的 10% 作为缓存时间
2万字 | 前端基础拾遗90问

参考资料:浏览器缓存机制剖析

网络相关

1. 讲讲网络OSI七层模型,TCP/IP和HTTP分别位于哪一层

alt

模型 概述 单位
物理层 网络连接介质,如网线、光缆,数据在其中以比特为单位传输 bit
数据链路层 数据链路层将比特封装成数据帧并传递
网络层 定义IP地址,定义路由功能,建立主机到主机的通信 数据包
传输层 负责将数据进行可靠或者不可靠传递,建立端口到端口的通信 数据段
会话层 控制应用程序之间会话能力,区分不同的进程
表示层 数据格式标识,基本压缩加密功能
应用层 各种应用软件

2. 常见HTTP状态码有哪些

2xx 开头(请求成功)

200 OK:客户端发送给服务器的请求被正常处理并返回

3xx 开头(重定向)

301 Moved Permanently:永久重定向,请求的网页已永久移动到新位置。 服务器返回此响应时,会自动将请求者转到新位置

302 Moved Permanently:临时重定向,请求的网页已临时移动到新位置。服务器目前从不同位置的网页响应请求,但请求者应继续使用原有位置来进行以后的请求

304 Not Modified:未修改,自从上次请求后,请求的网页未修改过。服务器返回此响应时,不会返回网页内容

4xx 开头(客户端错误)

400 Bad Request:错误请求,服务器不理解请求的语法,常见于客户端传参错误

401 Unauthorized:未授权,表示发送的请求需要有通过 HTTP 认证的认证信息,常见于客户端未登录

403 Forbidden:禁止,服务器拒绝请求,常见于客户端权限不足

404 Not Found:未找到,服务器找不到对应资源

5xx 开头(服务端错误)

500 Inter Server Error:服务器内部错误,服务器遇到错误,无法完成请求

501 Not Implemented:尚未实施,服务器不具备完成请求的功能

502 Bad Gateway:作为网关或者代理工作的服务器尝试执行请求时,从上游服务器接收到无效的响应。

503 service unavailable:服务不可用,服务器目前无法使用(处于超载或停机维护状态)。通常是暂时状态。

3. GET请求和POST请求有何区别

标准答案:

  • GET请求参数放在URL上,POST请求参数放在请求体里
  • GET请求参数长度有限制,POST请求参数长度可以非常大
  • POST请求相较于GET请求安全一点点,因为GET请求的参数在URL上,且有历史记录
  • GET请求能缓存,POST不能

更进一步:

其实HTTP协议并没有要求GET/POST请求参数必须放在URL上或请求体里,也没有规定GET请求的长度,目前对URL的长度限制,是各家浏览器设置的限制。GET和POST的根本区别在于:GET请求是幂等性的,而POST请求不是

幂等性,指的是对某一资源进行一次或多次请求都具有相同的副作用。例如搜索就是一个幂等的操作,而删除、新增则不是一个幂等操作。

由于GET请求是幂等的,在网络不好的环境中,GET请求可能会重复尝试,造成重复操作数据的风险,因此,GET请求用于无副作用的操作(如搜索),新增/删除等操作适合用POST

参考资料:HTTP|GET 和 POST 区别?网上多数答案都是错的

4. HTTP的请求报文由哪几部分组成

一个HTTP请求报文由请求行(request line)、请求头(header)、空行和请求数据4个部分组成

2万字 | 前端基础拾遗90问

响应报文和请求报文结构类似,不再赘述

5. HTTP常见请求/响应头及其含义

通用头(请求头和响应头都有的首部)

字段 作用
Cache-Control 控制缓存 public:表示响应可以被任何对象缓存(包括客户端/代理服务器)
private(默认值):响应只能被单个客户缓存,不能被代理服务器缓存
no-cache:缓存要经过服务器验证,在浏览器使用缓存前,会对比ETag,若没变则返回304,使用缓存
no-store:禁止任何缓存
Connection 是否需要持久连接(HTTP 1.1默认持久连接) keep-alive / close
Transfer-Encoding 报文主体的传输编码格式 chunked(分块) / identity(未压缩和修改) / gzip(LZ77压缩) / compress(LZW压缩,弃用) / deflate(zlib结构压缩)

请求头

字段 作用 语法
Accept 告知(服务器)客户端可以处理的内容类型 text/html、image/*、*/*
If-Modified-Since Last-Modified的值发送给服务器,询问资源是否已经过期(被修改),过期则返回新资源,否则返回304 示例:If-Modified-Since: Wed, 21 Oct 2015 07:28:00 GMT
If-Unmodified-Since Last-Modified的值发送给服务器,询问文件是否被修改,若没有则返回200,否则返回412预处理错误,可用于断点续传。通俗点说If-Unmodified-Since是文件没有修改时下载,If-Modified-Since是文件修改时下载 示例:If-Unmodified-Since: Wed, 21 Oct 2015 07:28:00 GMT
If-None-Match ETag的值发送给服务器,询问资源是否已经过期(被修改),过期则返回新资源,否则返回304 示例:If-None-Match: “bfc13a6472992d82d”
If-Match ETag的值发送给服务器,询问文件是否被修改,若没有则返回200,否则返回412预处理错误,可用于断点续传 示例:If-Match: “bfc129c88ca92d82d”
Range 告知服务器返回文件的哪一部分, 用于断点续传 示例:Range: bytes=200-1000, 2000-6576, 19000-
Host 指明了服务器的域名(对于虚拟主机来说),以及(可选的)服务器监听的TCP端口号 示例:Host:www.baidu.com
User-Agent 告诉HTTP服务器, 客户端使用的操作系统和浏览器的名称和版本 User-Agent: Mozilla/<version> (<system-information>) <platform> (<platform-details>) <extensions>

响应头

字段 作用 语法
Location 需要将页面重新定向至的地址。一般在响应码为3xx的响应中才会有意义 Location: <url>
ETag 资源的特定版本的标识符,如果内容没有改变,Web服务器不需要发送完整的响应 ETag: “<etag_value>”
Server 处理请求的源头服务器所用到的软件相关信息 Server: <product>

实体头(针对请求报文和响应报文的实体部分使用首部)

字段 作用 语法
Allow 资源可支持http请求的方法 Allow: <http-methods>,示例:Allow: GET, POST, HEAD
Last-Modified 资源最后的修改时间,用作一个验证器来判断接收到的或者存储的资源是否彼此一致,精度不如ETag 示例:Last-Modified: Wed, 21 Oct 2020 07:28:00 GMT
Expires 响应过期时间 Expires: <http-date>,示例:Expires: Wed, 21 Oct 2020 07:28:00 GMT

HTTP首部当然不止这么几个,但为了避免写太多大家记不住(主要是别的我也没去看),这里只介绍了一些常用的,详细的可以看MDN的文档

6. HTTP/1.0和HTTP/1.1有什么区别

  • 长连接: HTTP/1.1支持长连接和请求的流水线,在一个TCP连接上可以传送多个HTTP请求,避免了因为多次建立TCP连接的时间消耗和延时
  • 缓存处理: HTTP/1.1引入Entity tag,If-Unmodified-Since, If-Match, If-None-Match等新的请求头来控制缓存,详见浏览器缓存小节
  • 带宽优化及网络连接的使用: HTTP1.1则在请求头引入了range头域,支持断点续传功能
  • Host头处理: 在HTTP/1.0中认为每台服务器都有唯一的IP地址,但随着虚拟主机技术的发展,多个主机共享一个IP地址愈发普遍,HTTP1.1的请求消息和响应消息都应支持Host头域,且请求消息中如果没有Host头域会400错误

7. 介绍一下HTTP/2.0新特性

  • 多路复用: 即多个请求都通过一个TCP连接并发地完成
  • 服务端推送: 服务端能够主动把资源推送给客户端
  • 新的二进制格式: HTTP/2采用二进制格式传输数据,相比于HTTP/1.1的文本格式,二进制格式具有更好的解析性和拓展性
  • header压缩: HTTP/2压缩消息头,减少了传输数据的大小

8. 说说HTTP/2.0多路复用基本原理以及解决的问题

HTTP/2解决的问题,就是HTTP/1.1存在的问题:

  • TCP慢启动: TCP连接建立后,会经历一个先慢后快的发送过程,就像汽车启动一般,如果我们的网页文件(HTML/JS/CSS/icon)都经过一次慢启动,对性能是不小的损耗。另外慢启动是TCP为了减少网络拥塞的一种策略,我们是没有办法改变的。
  • 多条TCP连接竞争带宽: 如果同时建立多条TCP连接,当带宽不足时就会竞争带宽,影响关键资源的下载。
  • HTTP/1.1队头阻塞: 尽管HTTP/1.1长链接可以通过一个TCP连接传输多个请求,但同一时刻只能处理一个请求,当前请求未结束前,其他请求只能处于阻塞状态。

为了解决以上几个问题,HTTP/2一个域名只使用一个TCP⻓连接来传输数据,而且请求直接是并行的、非阻塞的,这就是多路复用

实现原理: HTTP/2引入了一个二进制分帧层,客户端和服务端进行传输时,数据会先经过二进制分帧层处理,转化为一个个带有请求ID的帧,这些帧在传输完成后根据ID组合成对应的数据。

9. 说说HTTP/3.0

尽管HTTP/2解决了很多1.1的问题,但HTTP/2仍然存在一些缺陷,这些缺陷并不是来自于HTTP/2协议本身,而是来源于底层的TCP协议,我们知道TCP链接是可靠的连接,如果出现了丢包,那么整个连接都要等待重传,HTTP/1.1可以同时使用6个TCP连接,一个阻塞另外五个还能工作,但HTTP/2只有一个TCP连接,阻塞的问题便被放大了。

由于TCP协议已经被广泛使用,我们很难直接修改TCP协议,基于此,HTTP/3选择了一个折衷的方法——UDP协议,HTTP/2在UDP的基础上实现多路复用、0-RTT、TLS加密、流量控制、丢包重传等功能。

参考资料:http发展史(http0.9、http1.0、http1.1、http2、http3)梳理笔记 (推荐阅读)

10. HTTP和HTTPS有何区别

  • HTTPS使用443端口,而HTTP使用80
  • HTTPS需要申请证书
  • HTTP是超文本传输协议,是明文传输;HTTPS是经过SSL加密的协议,传输更安全
  • HTTPS比HTTP慢,因为HTTPS除了TCP握手的三个包,还要加上SSL握手的九个包

11. HTTPS是如何进行加密的

我们通过分析几种加密方式,层层递进,理解HTTPS的加密方式以及为什么使用这种加密方式:

对称加密

客户端和服务器公用一个密匙用来对消息加解密,这种方式称为对称加密。客户端和服务器约定好一个加密的密匙。客户端在发消息前用该密匙对消息加密,发送给服务器后,服务器再用该密匙进行解密拿到消息。

2万字 | 前端基础拾遗90问

这种方式一定程度上保证了数据的安全性,但密钥一旦泄露(密钥在传输过程中被截获),传输内容就会暴露,因此我们要寻找一种安全传递密钥的方法。

非对称加密

采用非对称加密时,客户端和服务端均拥有一个公钥和私钥,公钥加密的内容只有对应的私钥能解密。私钥自己留着,公钥发给对方。这样在发送消息前,先用对方的公钥对消息进行加密,收到后再用自己的私钥进行解密。这样攻击者只拿到传输过程中的公钥也无法激活成功教程传输的内容

2万字 | 前端基础拾遗90问

尽管非对称加密解决了由于密钥被获取而导致传输内容泄露的问题,但中间人仍然可以用
篡改公钥的方式来获取或篡改传输内容,而且非对称加密的性能比对称加密的性能差了不少

2万字 | 前端基础拾遗90问

第三方认证

上面这种方法的弱点在于,客户端不知道公钥是由服务端返回,还是中间人返回的,因此我们再引入一个第三方认证的环节:即第三方使用私钥加密我们自己的公钥,浏览器已经内置一些权威第三方认证机构的公钥,浏览器会使用第三方的公钥来解开第三方私钥加密过的我们自己的公钥,从而获取公钥,如果能成功解密,就说明获取到的自己的公钥是正确的

但第三方认证也未能完全解决问题,第三方认证是面向所有人的,中间人也能申请证书,如果中间人使用自己的证书掉包原证书,客户端还是无法确认公钥的真伪

2万字 | 前端基础拾遗90问

数字签名

为了让客户端能够验证公钥的来源,我们给公钥加上一个数字签名,这个数字签名是由企业、网站等各种信息和公钥经过单向hash而来,一旦构成数字签名的信息发生变化,hash值就会改变,这就构成了公钥来源的唯一标识。

具体来说,服务端本地生成一对密钥,然后拿着公钥以及企业、网站等各种信息到CA(第三方认证中心)去申请数字证书,CA会通过一种单向hash算法(比如MD5),生成一串摘要,这串摘要就是这堆信息的唯一标识,然后CA还会使用自己的私钥对摘要进行加密,连同我们自己服务器的公钥一同发送给我我们。

浏览器拿到数字签名后,会使用浏览器本地内置的CA公钥解开数字证书并验证,从而拿到正确的公钥。由于非对称加密性能低下,拿到公钥以后,客户端会随机生成一个对称密钥,使用这个公钥加密并发送给服务端,服务端用自己的私钥解开对称密钥,此后的加密连接就通过这个对称密钥进行对称加密。

综上所述,HTTPS在验证阶段使用非对称加密+第三方认证+数字签名获取正确的公钥,获取到正确的公钥后以对称加密的方式通信

2万字 | 前端基础拾遗90问

参考资料:看图学HTTPS

前端安全

什么是CSRF攻击

CSRF即Cross-site request forgery(跨站请求伪造),是一种挟制用户在当前已登录的Web应用程序上执行非本意的操作的攻击方法。

假如黑客在自己的站点上放置了其他网站的外链,例如"www.weibo.com/api,默认情况下,浏览器会带着weibo.com的cookie访问这个网址,如果用户已登录过该网站且网站没有对CSRF攻击进行防御,那么服务器就会认为是用户本人在调用此接口并执行相关操作,致使账号被劫持。

如何防御CSRF攻击

  • 验证Token:浏览器请求服务器时,服务器返回一个token,每个请求都需要同时带上token和cookie才会被认为是合法请求
  • 验证Referer:通过验证请求头的Referer来验证来源站点,但请求头很容易伪造
  • 设置SameSite:设置cookie的SameSite,可以让cookie不随跨域请求发出,但浏览器兼容不一

什么是XSS攻击

XSS即Cross Site Scripting(跨站脚本),指的是通过利用网页开发时留下的漏洞,注入恶意指令代码到网页,使用户加载并执行攻击者恶意制造的网页程序。常见的例如在评论区植入JS代码,用户进入评论页时代码被执行,造成页面被植入广告、账号信息被窃取

XSS攻击有哪些类型

  • 存储型:即攻击被存储在服务端,常见的是在评论区插入攻击脚本,如果脚本被储存到服务端,那么所有看见对应评论的用户都会受到攻击。
  • 反射型:攻击者将脚本混在URL里,服务端接收到URL将恶意代码当做参数取出并拼接在HTML里返回,浏览器解析此HTML后即执行恶意代码
  • DOM型:将攻击脚本写在URL中,诱导用户点击该URL,如果URL被解析,那么攻击脚本就会被运行。和前两者的差别主要在于DOM型攻击不经过服务端

如何防御XSS攻击

  • 输入检查:对输入内容中的<script><iframe>等标签进行转义或者过滤
  • 设置httpOnly:很多XSS攻击目标都是窃取用户cookie伪造身份认证,设置此属性可防止JS获取cookie
  • 开启CSP,即开启白名单,可阻止白名单以外的资源加载和运行
2万字 | 前端基础拾遗90问

排序算法

1. 手写冒泡排序

冒泡排序应该是很多人第一个接触的排序,比较简单,不展开讲解了

function bubbleSort(arr){
  for(let i = 0; i < arr.length; i++) {
    for(let j = 0; j < arr.length - i - 1; j++) {
      if(arr[j] > arr[j+1]) {
        let temp = arr[j]
        arr[j] = arr[j+1]
        arr[j+1] = temp
      }
    }
  }
  return arr
}

2. 如何优化一个冒泡排序

冒泡排序总会执行(N-1)+(N-2)+(N-3)+..+2+1趟,但如果运行到当中某一趟时排序已经完成,或者输入的是一个有序数组,那么后边的比较就都是多余的,为了避免这种情况,我们增加一个flag,判断排序是否在中途就已经完成(也就是判断有无发生元素交换)

function bubbleSort(arr){
  for(let i = 0; i < arr.length; i++) {
  let flag = true
    for(let j = 0; j < arr.length - i - 1; j++) {
      if(arr[j] > arr[j+1]) {
        flag = false
        let temp = arr[j]
        arr[j] = arr[j+1]
        arr[j+1] = temp
      }
    }
    // 这个flag的含义是:如果`某次循环`中没有交换过元素,那么意味着排序已经完成
    if(flag)break;
  }
  return arr
}

3. 手写快速排序

快排基本步骤:

  1. 选取基准元素
  2. 比基准元素小的元素放到左边,大的放右边
  3. 在左右子数组中重复步骤一二,直到数组只剩下一个元素
  4. 向上逐级合并数组
function quickSort(arr) {
    if(arr.length <= 1) return arr          //递归终止条件
    const pivot = arr.length / 2 | 0        //基准点
    const pivotValue = arr.splice(pivot, 1)[0]
    const leftArr = []
    const rightArr = []
    arr.forEach(val => {
        val > pivotValue ? rightArr.push(val) : leftArr.push(val)
    })
    return [ ...quickSort(leftArr), pivotValue, ...quickSort(rightArr)]
}

4. 如何优化一个快速排序

原地排序

上边这个快排只是让读者找找感觉,我们不能这样写快排,如果每次都开两个数组,会消耗很多内存空间,数据量大时可能造成内存溢出,我们要避免开新的内存空间,即原地完成排序

我们可以用元素交换来取代开新数组,在每一次分区的时候直接在原数组上交换元素,将小于基准数的元素挪到数组开头,以[5,1,4,2,3]为例:

2万字 | 前端基础拾遗90问

我们定义一个pos指针, 标识等待置换的元素的位置, 然后逐一遍历数组元素, 遇到比基准数小的就和arr[pos]交换位置, 然后pos++

代码实现:

function quickSort(arr, left, right) {          //这个left和right代表分区后“新数组”的区间下标,因为这里没有新开数组,所以需要left/right来确认新数组的位置
    if (left < right) {
        let pos = left - 1                      //pos即“被置换的位置”,第一趟为-1
        for(let i = left; i <= right; i++) {    //循环遍历数组,置换元素
            let pivot = arr[right]              //选取数组最后一位作为基准数,
            if(arr[i] <= pivot) {               //若小于等于基准数,pos++,并置换元素, 这里使用小于等于而不是小于, 其实是为了避免因为重复数据而进入死循环
                pos++
                let temp = arr[pos]
                arr[pos] = arr[i]
                arr[i] = temp
            }
        }
        //一趟排序完成后,pos位置即基准数的位置,以pos的位置分割数组
        quickSort(arr, left, pos - 1)        
        quickSort(arr, pos + 1, right)
    }
    return arr      //数组只包含1或0个元素时(即left>=right),递归终止
}

//使用
var arr = [5,1,4,2,3]
var start = 0;
var end = arr.length - 1;
quickSort(arr, start, end)

这个交换的过程还是需要一些时间理解消化的,详细分析可以看这篇:js算法-快速排序(Quicksort)

三路快排

上边这个快排还谈不上优化,应当说是快排的纠正写法,其实有两个问题我们还能优化一下:

  1. 有序数组的情况:如果输入的数组是有序的,而取基准点时也顺序取,就可能导致基准点一侧的子数组一直为空, 使时间复杂度退化到O(n2)
  2. 大量重复数据的情况:例如输入的数据是[1,2,2,2,2,3], 无论基准点取1、2还是3, 都会导致基准点两侧数组大小不平衡, 影响快排效率

对于第一个问题, 我们可以通过在取基准点的时候随机化来解决,对于第二个问题,我们可以使用三路快排的方式来优化,比方说对于上面的[1,2,2,2,2,3],我们基准点取2,在分区的时候,将数组元素分为小于2|等于2|大于2三个区域,其中等于基准点的部分不再进入下一次排序, 这样就大大提高了快排效率

2万字 | 前端基础拾遗90问

5. 手写归并排序

归并排序和快排的思路类似,都是递归分治,区别在于快排边分区边排序,而归并在分区完成后才会排序

2万字 | 前端基础拾遗90问

function mergeSort(arr) {
    if(arr.length <= 1) return arr		//数组元素被划分到剩1个时,递归终止
    const midIndex = arr.length/2 | 0
    const leftArr = arr.slice(0, midIndex)
    const rightArr = arr.slice(midIndex, arr.length)
    return merge(mergeSort(leftArr), mergeSort(rightArr))	//先划分,后合并
}

//合并
function merge(leftArr, rightArr) {
    const result = []
    while(leftArr.length && rightArr.length) {
    	leftArr[0] <= rightArr[0] ? result.push(leftArr.shift()) : result.push(rightArr.shift())
    }
    while(leftArr.length) result.push(leftArr.shift())
    while(rightArr.length) result.push(rightArr.shift())
    return result
}

6. 手写堆排序

堆是一棵特殊的树, 只要满足这棵树是完全二叉树堆中每一个节点的值都大于或小于其左右孩子节点这两个条件, 那么就是一个堆, 根据堆中每一个节点的值都大于或小于其左右孩子节点, 又分为大根堆和小根堆

堆排序的流程:

  1. 初始化大(小)根堆,此时根节点为最大(小)值,将根节点与最后一个节点(数组最后一个元素)交换
  2. 除开最后一个节点,重新调整大(小)根堆,使根节点为最大(小)值
  3. 重复步骤二,直到堆中元素剩一个,排序完成

[1,5,4,2,3]为例构筑大根堆:

2万字 | 前端基础拾遗90问

代码实现:

// 堆排序
const heapSort = array => {
        // 我们用数组来储存这个大根堆,数组就是堆本身
	// 初始化大顶堆,从第一个非叶子结点开始
	for (let i = Math.floor(array.length / 2 - 1); i >= 0; i--) {
		heapify(array, i, array.length);
	}
	// 排序,每一次 for 循环找出一个当前最大值,数组长度减一
	for (let i = Math.floor(array.length - 1); i > 0; i--) {
		// 根节点与最后一个节点交换
		swap(array, 0, i);
		// 从根节点开始调整,并且最后一个结点已经为当前最大值,不需要再参与比较,所以第三个参数为 i,即比较到最后一个结点前一个即可
		heapify(array, 0, i);
	}
	return array;
};

// 交换两个节点
const swap = (array, i, j) => {
	let temp = array[i];
	array[i] = array[j];
	array[j] = temp;
};

// 将 i 结点以下的堆整理为大顶堆,注意这一步实现的基础实际上是:
// 假设结点 i 以下的子堆已经是一个大顶堆,heapify 函数实现的
// 功能是实际上是:找到 结点 i 在包括结点 i 的堆中的正确位置。
// 后面将写一个 for 循环,从第一个非叶子结点开始,对每一个非叶子结点
// 都执行 heapify 操作,所以就满足了结点 i 以下的子堆已经是一大顶堆
const heapify = (array, i, length) => {
	let temp = array[i]; // 当前父节点
	// j < length 的目的是对结点 i 以下的结点全部做顺序调整
	for (let j = 2 * i + 1; j < length; j = 2 * j + 1) {
		temp = array[i]; // 将 array[i] 取出,整个过程相当于找到 array[i] 应处于的位置
		if (j + 1 < length && array[j] < array[j + 1]) {
			j++; // 找到两个孩子中较大的一个,再与父节点比较
		}
		if (temp < array[j]) {
			swap(array, i, j); // 如果父节点小于子节点:交换;否则跳出
			i = j; // 交换后,temp 的下标变为 j
		} else {
			break;
		}
	}
}

参考资料: JS实现堆排序

7. 归并、快排、堆排有何区别

排序 时间复杂度(最好情况) 时间复杂度(最坏情况) 空间复杂度 稳定性
快速排序 O(nlogn) O(n^2) O(logn)~O(n) 不稳定
归并排序 O(nlogn) O(nlogn) O(n) 稳定
堆排序 O(nlogn) O(nlogn) O(1) 不稳定

其实从表格中我们可以看到,就时间复杂度而言,快排并没有很大优势,然而为什么快排会成为最常用的排序手段,这是因为时间复杂度只能说明随着数据量的增加,算法时间代价增长的趋势,并不直接代表实际执行时间,实际运行时间还包括了很多常数参数的差别,此外在面对不同类型数据(比如有序数据、大量重复数据)时,表现也不同,综合来说,快排的时间效率是最高的

在实际运用中, 并不只使用一种排序手段, 例如V8的Array.sort()就采取了当 n<=10 时, 采用插入排序, 当 n>10 时,采用三路快排的排序策略

2万字 | 前端基础拾遗90问

设计模式

设计模式有许多种,这里挑出几个常用的:

设计模式 描述 例子
单例模式 一个类只能构造出唯一实例 Redux/Vuex的store
工厂模式 对创建对象逻辑的封装 jQuery的$(selector)
观察者模式 当一个对象被修改时,会自动通知它的依赖对象 Redux的subscribe、Vue的双向绑定
装饰器模式 对类的包装,动态地拓展类的功能 React高阶组件、ES7 装饰器
适配器模式 兼容新旧接口,对类的包装 封装旧API
代理模式 控制对象的访问 事件代理、ES6的Proxy

1. 介绍一下单一职责原则和开放封闭原则

  • 单一职责原则:一个类只负责一个功能领域中的相应职责,或者可以定义为:就一个类而言,应该只有一个引起它变化的原因。

  • 开放封闭原则:核心的思想是软件实体(类、模块、函数等)是可扩展的、但不可修改的。也就是说,对扩展是开放的,而对修改是封闭的。

2. 单例模式

单例模式即一个类只能构造出唯一实例,单例模式的意义在于共享、唯一Redux/Vuex中的store、JQ的$或者业务场景中的购物车、登录框都是单例模式的应用

class SingletonLogin {
  constructor(name,password){
    this.name = name
    this.password = password
  }
  static getInstance(name,password){
    //判断对象是否已经被创建,若创建则返回旧对象
    if(!this.instance)this.instance = new SingletonLogin(name,password)
    return this.instance
  }
}
 
let obj1 = SingletonLogin.getInstance('CXK','123')
let obj2 = SingletonLogin.getInstance('CXK','321')
 
console.log(obj1===obj2)    // true
console.log(obj1)           // {name:CXK,password:123}
console.log(obj2)           // 输出的依然是{name:CXK,password:123}

3. 工厂模式

工厂模式即对创建对象逻辑的封装,或者可以简单理解为对new的封装,这种封装就像创建对象的工厂,故名工厂模式。工厂模式常见于大型项目,比如JQ的$对象,我们创建选择器对象时之所以没有new selector就是因为$()已经是一个工厂方法,其他例子例如React.createElement()Vue.component()都是工厂模式的实现。工厂模式有多种:简单工厂模式工厂方法模式抽象工厂模式,这里只以简单工厂模式为例:

class User {
  constructor(name, auth) {
    this.name = name
    this.auth = auth
  }
}

class UserFactory {
  static createUser(name, auth) {
    //工厂内部封装了创建对象的逻辑:
    //权限为admin时,auth=1, 权限为user时, auth为2
    //使用者在外部创建对象时,不需要知道各个权限对应哪个字段, 不需要知道赋权的逻辑,只需要知道创建了一个管理员和用户
    if(auth === 'admin')  new User(name, 1)
    if(auth === 'user')  new User(name, 2)
  }
}

const admin = UserFactory.createUser('cxk', 'admin');
const user = UserFactory.createUser('cxk', 'user');

4. 观察者模式

观察者模式算是前端最常用的设计模式了,观察者模式概念很简单:观察者监听被观察者的变化,被观察者发生改变时,通知所有的观察者。观察者模式被广泛用于监听事件的实现,有关观察者模式的详细应用,可以看我另一篇讲解Redux实现的文章

//观察者
class Observer {    
  constructor (fn) {      
    this.update = fn    
  }
}
//被观察者
class Subject {    
    constructor() {        
        this.observers = []          //观察者队列 
    }    
    addObserver(observer) {          
        this.observers.push(observer)//往观察者队列添加观察者 
    }    
    notify() {                       //通知所有观察者,实际上是把观察者的update()都执行了一遍 
        this.observers.forEach(observer => {        
            observer.update()            //依次取出观察者,并执行观察者的update方法 
        })    
    }
}

var subject = new Subject()       //被观察者
const update = () => {console.log('被观察者发出通知')}  //收到广播时要执行的方法
var ob1 = new Observer(update)    //观察者1
var ob2 = new Observer(update)    //观察者2
subject.addObserver(ob1)          //观察者1订阅subject的通知
subject.addObserver(ob2)          //观察者2订阅subject的通知
subject.notify()                  //发出广播,执行所有观察者的update方法

有些文章也把观察者模式称为发布订阅模式,其实二者是有所区别的,发布订阅相较于观察者模式多一个调度中心。

5. 装饰器模式

装饰器模式,可以理解为对类的一个包装,动态地拓展类的功能,ES7的装饰器语法以及React中的高阶组件(HoC)都是这一模式的实现。react-redux的connect()也运用了装饰器模式,这里以ES7的装饰器为例:

function info(target) {
  target.prototype.name = '张三'
  target.prototype.age = 10
}

@info
class Man {}

let man = new Man()
man.name // 张三

6. 适配器模式

适配器模式,将一个接口转换成客户希望的另一个接口,使接口不兼容的那些类可以一起工作。我们在生活中就常常有使用适配器的场景,例如出境旅游插头插座不匹配,这时我们就需要使用转换插头,也就是适配器来帮我们解决问题。

class Adaptee {
  test() {
      return '旧接口'
  }
}
 
class Target {
  constructor() {
      this.adaptee = new Adaptee()
  }
  test() {
      let info = this.adaptee.test()
      return `适配${info}`
  }
}
 
let target = new Target()
console.log(target.test())

7. 代理模式

代理模式,为一个对象找一个替代对象,以便对原对象进行访问。即在访问者与目标对象之间加一层代理,通过代理做授权和控制。最常见的例子是经纪人代理明星业务,假设你作为一个投资者,想联系明星打广告,那么你就需要先经过代理经纪人,经纪人对你的资质进行考察,并通知你明星排期,替明星本人过滤不必要的信息。事件代理、JQuery的$.proxy、ES6的proxy都是这一模式的实现,下面以ES6的proxy为例:

const idol = {
  name: '蔡x抻',
  phone: 10086,
  price: 1000000  //报价
}

const agent = new Proxy(idol, {
  get: function(target) {
    //拦截明星电话的请求,只提供经纪人电话
    return '经纪人电话:10010'
  },
  set: function(target, key, value) {
    if(key === 'price' ) {
      //经纪人过滤资质
      if(value < target.price) throw new Error('报价过低')
      target.price = value
    }
  }
})


agent.phone        //经纪人电话:10010
agent.price = 100  //Uncaught Error: 报价过低

2万字 | 前端基础拾遗90问

HTML相关

1. 说说HTML5在标签、属性、存储、API上的新特性

  • 标签:新增语义化标签(aside / figure / section / header / footer / nav等),增加多媒体标签videoaudio,使得样式和结构更加分离
  • 属性:增强表单,主要是增强了input的type属性;meta增加charset以设置字符集;script增加async以异步加载脚本
  • 存储:增加localStoragesessionStorageindexedDB,引入了application cache对web和应用进行缓存
  • API:增加拖放API地理定位SVG绘图canvas绘图Web WorkerWebSocket

2. doctype的作用是什么?

声明文档类型,告知浏览器用什么文档标准解析这个文档:

  • 怪异模式:浏览器使用自己的模式解析文档,不加doctype时默认为怪异模式
  • 标准模式:浏览器以W3C的标准解析文档

3. 几种前端储存以及它们之间的区别

  • cookies: HTML5之前本地储存的主要方式,大小只有4k,HTTP请求头会自动带上cookie,兼容性好
  • localStorage:HTML5新特性,持久性存储,即使页面关闭也不会被清除,以键值对的方式存储,大小为5M
  • sessionStorage:HTML5新特性,操作及大小同localStorage,和localStorage的区别在于sessionStorage在选项卡(页面)被关闭时即清除,且不同选项卡之间的sessionStorage不互通
  • IndexedDB: NoSQL型数据库,类比MongoDB,使用键值对进行储存,异步操作数据库,支持事务,储存空间可以在250MB以上,但是IndexedDB受同源策略限制
  • Web SQL:是在浏览器上模拟的关系型数据库,开发者可以通过SQL语句来操作Web SQL,是HTML5以外一套独立的规范,兼容性差

4. href和src有什么区别

href(hyperReference)即超文本引用:当浏览器遇到href时,会并行的地下载资源,不会阻塞页面解析,例如我们使用<link>引入CSS,浏览器会并行地下载CSS而不阻塞页面解析. 因此我们在引入CSS时建议使用<link>而不是@import

<link href="style.css" rel="stylesheet" />

src(resource)即资源,当浏览器遇到src时,会暂停页面解析,直到该资源下载或执行完毕,这也是script标签之所以放底部的原因

<script src="script.js"></script>

5. meta有哪些属性,作用是什么

meta标签用于描述网页的元信息,如网站作者、描述、关键词,meta通过name=xxxcontent=xxx的形式来定义信息,常用设置如下:

  • charset:定义HTML文档的字符集
 <meta charset="UTF-8" >
  • http-equiv:可用于模拟http请求头,可设置过期时间、缓存、刷新
<meta http-equiv="expires" content="Wed, 20 Jun 2019 22:33:00 GMT"
  • viewport:视口,用于控制页面宽高及缩放比例
<meta 
    name="viewport" 
    content="width=device-width, initial-scale=1, maximum-scale=1"
>

6. viewport有哪些参数,作用是什么

  • width/height,宽高,默认宽度980px
  • initial-scale,初始缩放比例,1~10
  • maximum-scale/minimum-scale,允许用户缩放的最大/小比例
  • user-scalable,用户是否可以缩放 (yes/no)

7. http-equive属性的作用和参数

  • expires,指定过期时间
  • progma,设置no-cache可以禁止缓存
  • refresh,定时刷新
  • set-cookie,可以设置cookie
  • X-UA-Compatible,使用浏览器版本
  • apple-mobile-web-app-status-bar-style,针对WebApp全屏模式,隐藏状态栏/设置状态栏颜色
2万字 | 前端基础拾遗90问

CSS相关

清除浮动的方法

为什么要清除浮动:清除浮动是为了解决子元素浮动而导致父元素高度塌陷的问题

2万字 | 前端基础拾遗90问

1.添加新元素

<div class="parent">
  <div class="child"></div>
  <!-- 添加一个空元素,利用css提供的clear:both清除浮动 -->
  <div style="clear: both"></div>
</div>  

2.使用伪元素

/* 对父元素添加伪元素 */
.parent::after{
  content: "";
  display: block;
  height: 0;
  clear:both;
}

3.触发父元素BFC

/* 触发父元素BFC */
.parent {
  overflow: hidden;
  /* float: left; */
  /* position: absolute; */
  /* display: inline-block */
  /* 以上属性均可触发BFC */
}

介绍一下flex布局

其实我本来还写了一节水平/垂直居中相关的,不过感觉内容过于基础还占长篇幅,所以删去了,作为一篇总结性的文章,这一小节也不应该从“flex是什么”开始讲,主轴、侧轴这些概念相信用过flex布局都知道,所以我们直接flex的几个属性讲起:

容器属性(使用在flex布局容器上的属性)

  • justify-content
    定义了子元素在主轴(横轴)上的对齐方式
.container {
    justify-content: center | flex-start | flex-end | space-between | space-around;
    /* 主轴对齐方式:居中 | 左对齐(默认值) | 右对齐 | 两端对齐(子元素间边距相等) | 周围对齐(每个子元素两侧margin相等) */
}
  • align-items
    定义了定义项目在交叉轴(竖轴)上对齐方式
.container {
    align-items: center | flex-start | flex-end | baseline | stretch;
    /* 侧轴对齐方式:居中 | 上对齐 | 下对齐 | 项目的第一行文字的基线对齐 | 如果子元素未设置高度,将占满整个容器的高度(默认值) */
}
  • flex-direction
    主轴(横轴)方向
.container {
    flex-direction: row | row-reverse | column | column-reverse;
    /* 主轴方向:水平由左至右排列(默认值) | 水平由右向左 | 垂直由上至下 | 垂直由下至上 */
}
  • flex-wrap
    换行方式
.container {
    flex-wrap: nowrap | wrap | wrap-reverse;
    /* 换行方式:不换行(默认值) | 换行 | 反向换行 */
}
  • flex-flow
    flex-flow属性是flex-direction属性和flex-wrap的简写
.container {
    flex-flow: <flex-direction> || <flex-wrap>;
    /* 默认值:row nowrap */
}
  • align-content
    定义多根轴线的对齐方式
.container {
    align-content: center | flex-start | flex-end | space-between | space-around | stretch;
    /* 默认值:与交叉轴的中点对齐 | 与交叉轴的起点对齐 | 与交叉轴的终点对齐 | 与交叉轴两端对齐 | 每根轴线两侧的间隔都相等 | (默认值):轴线占满整个交叉轴 */
}

项目属性(使用在容器内子元素上的属性)

  • flex-grow
    定义项目的放大比例,默认为0,即使有剩余空间也不放大。如果所有子元素flex-grow为1,那么将等分剩余空间,如果某个子元素flex-grow为2,那么这个子元素将占据2倍的剩余空间
.item {
  flex-grow: <number>; /* default 0 */
}
  • flex-shrink
    定义项目的缩小比例,默认为1,即如果空间不足,子元素将缩小。如果所有子元素flex-shrink都为1,某个子元素flex-shrink为0,那么该子元素将不缩小
.item {
  flex-shrink: <number>; /* default 1 */
}
  • flex-basis
    定义在分配多余空间之前,项目占据的主轴空间,默认auto,即子元素本来的大小,如果设定为一个固定的值,那么子元素将占据固定空间
.item {
  flex-basis: <length> | auto; /* default auto */
}
  • flex
    flex属性是flex-grow, flex-shrinkflex-basis的简写,默认值为0 1 auto,即有剩余空间不放大,剩余空间不够将缩小,子元素占据自身大小
.item {
  flex: none | [ <'flex-grow'> <'flex-shrink'>? || <'flex-basis'> ]
}

flex有两个快捷值:autonone,分别代表1 1 auto(有剩余空间则平均分配,空间不够将等比缩小,子元素占据空间等于自身大小)和0 0 auto(有剩余空间也不分配,空间不够也不缩小,子元素占据空间等于自身大小)

  • order
    定义项目的排列顺序。数值越小,排列越靠前,默认为0
.item {
  order: <integer>;
}
  • align-self
    定义单个子元素的排列方式,例如align-items设置了center,使得所有子元素居中对齐,那么可以通过给某个子元素设置align-self来单独设置子元素的排序方式
.item {
  align-self: auto | flex-start | flex-end | center | baseline | stretch;
}

参考资料:阮一峰Flex布局

常见布局

编辑中,请稍等-_-||

什么是BFC

BFC全称 Block Formatting Context 即块级格式上下文,简单的说,BFC是页面上的一个隔离的独立容器,不受外界干扰或干扰外界

如何触发BFC

  • float不为 none
  • overflow的值不为 visible
  • position 为 absolute 或 fixed
  • display的值为 inline-block 或 table-cell 或 table-caption 或 grid

BFC的渲染规则是什么

  • BFC是页面上的一个隔离的独立容器,不受外界干扰或干扰外界
  • 计算BFC的高度时,浮动子元素也参与计算(即内部有浮动元素时也不会发生高度塌陷)
  • BFC的区域不会与float的元素区域重叠
  • BFC内部的元素会在垂直方向上放置
  • BFC内部两个相邻元素的margin会发生重叠

BFC的应用场景

  • 清除浮动:BFC内部的浮动元素会参与高度计算,因此可用于清除浮动,防止高度塌陷
  • 避免某元素被浮动元素覆盖:BFC的区域不会与浮动元素的区域重叠
  • 阻止外边距重叠:属于同一个BFC的两个相邻Box的margin会发生折叠,不同BFC不会发生折叠
2万字 | 前端基础拾遗90问

总结

对于前端基础知识的讲解,到这里就告一小段落。前端的世界纷繁复杂,远非笔者寥寥几笔所能勾画,笔者就像在沙滩上拾取贝壳的孩童,有时侥幸拾取收集一二,就为之欢欣鼓舞,迫不及待与伙伴们分享。

最后还想可耻地抒(自)发(夸)一下(•‾̑⌣‾̑•)✧˖°:
不知不觉,在掘金已经水了半年有余,这半年来我写下了近6万字,不过其实一共只有5篇文章,这是因为我并不想写水文,不想把基础的东西水上几千字几十篇来混赞升级。写下的文章,首先要能说服自己。要对自己写下的东西负责任,即使是一张图、一个标点。例如第一张图,我调整了不下十次,第一次我直接截取babel的转化结果,觉得不好看,换成了代码块,还是不好看,又换成了carbon的代码图,第一次下载,发现两张图宽度不一样,填充宽度重新下载,又发现自己的代码少了一个空格,重新下载,为了实现两张图并排效果,写了一个HTML来调整两张图的样式,为了保证每张图的内容和边距一致,我一边截图,一边记录下每次截图的尺寸和边距,每次截图都根据上一次的数据调整边距。

其实我并非提倡把时间花在这些细枝末节上,只是单纯觉得,文章没写好,就不能发出来,就像小野二郎先生说的那样:“菜做的不好,就不能拿给客人吃”,世间的大道理,往往都这样通俗简单。

2万字 | 前端基础拾遗90问

往期文章:

1. 异步编程二三事 | Promise/async/Generator实现原理解析 | 9k字
2. 10行代码看尽redux实现 —— redux & react-redux & redux中间件设计实现 | 8k字
3.红黑树上红黑果,红黑树下你和我 —— 红黑树入门 | 6k字
4. 深入React服务端渲染原理 | 1W字

今天的文章2万字 | 前端基础拾遗90问分享到此就结束了,感谢您的阅读。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/18795.html

(0)
编程小号编程小号

相关推荐

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注