2020.8月 前端面试小结

373 阅读8分钟

菜鸡最近在面试,有些受打击。不过多面一个下一次成功的机率就大一些。在这里对自己的面试题做个回顾。总结错误的地方也请指正。

  1. 简单的问题或者开放性的问题可能会不作解释,大佬们可在评论区给出自己得理解,非常感谢。
  2. 较为复杂的或概念篇幅比较长的题将会附上我阅读过的优秀文章

正文

Question1

问:闭包的概念 => 闭包的作用或使用场景 => 闭包的问题(引用值类型会有问题吗?) => 如何解决

概念

闭包就是能够读取其他函数内部变量的函数。

使用场景

使用场景 - 包装函数。例如防抖,节流,自定义实现bind方法等 - 模拟私有变量/方法。例如:

  var makeCounter = function() {
  var privateCounter = 0; // 私有变量
  //	私有方法
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment: function() {
      changeBy(1);
    },
    value: function() {
      return privateCounter;
    }
  }  
};
var Counter1 = makeCounter();
console.log(Counter1.value()); /* logs 0 */

闭包的问题

- var + 循环 + 闭包
```js
function log () {
  var msgs = ['Hello', 'My', 'Friends']
  var logFns = []
  for (var i = 0; i < msgs.length; i++) {
    var msg = msgs[i]
    logFns.push(function () {
      console.log(msg)
    })
  }
  return logFns
}
log().forEach(fn => fn()) // log Friends * 3
```
`var`变量提升,导致闭包引用的`msg`始终都是最后一个字符串`Friends`

解决:立即执行表达式传参 / `let`,`const` 替换 `var`

- 内存泄漏/溢出

`内存泄漏是程序无法释放已申请的空间。内存溢出则可能是内存泄漏累计造成内存空间不足。`

我认为闭包引用值类型是不会引起内存泄漏的。值类型存储在栈中,大小固定,属于被频繁使用的数据,每次存取应该都是同一片内存。引用类型的地址存储在栈中,但实体存储在堆中,实体大小不固定。
- 解决方法 —— 在退出函数之前,手动释放无关变量。

Question2

问: 介绍原型/原型链 => 原型链的缺点 => 如何解决 => 寄生组合继承的实现特点

原型和原型链

Java等语言不同,JavaScript没有类的概念。但是它可以通过原型来实现继承。

原型:JavaScript只有一种数据,对象。每个对象都有一个私有属性__proto__指向另一个对象,这个对象就是原型。构造函数也有一个属性prototype指向一个原型。通过同一个构造函数创建的对象共享同一个原型。{}.__proto__ === Object.prototype

原型链:实例对象的__proto__指向了一个原型对象,该原型仍旧是对象,所以也有自己的__proto__,这样一层一层向上连接,就是原型链。

const instance = new String('Hello World!')
instance.__proto__ === String.prototype	// true
instance.__proto__.__proto__ === Object.prototype  //true
instance.__proto__.__proto__.__proto__ === null  // true

属性读取时,会从当前对象开始,沿着原型链自底向上查找。

原型缺点

  • 继承时,普通的原型链继承无法向父类构造函数传参
  • 实例会共享原型链引用。

解决

  • 使用借用构造函数继承理念,在子类构造函数中调用父类构造函数
function Parent (name) {    |     function Child (name) {
	this.name = name        |       Parent.apply(this, name)
}                           |     }
  • 让原型变为只读,使用Object.freeze冻结原型对象(可使用Object.defineProperty兼容)
  • 非继承场景下,可用Object.create(null)创建对象,这样的对象没有任何属性,包括原型。

寄生组合式继承

  • 结合借用构造函数,实现在子类构造函数中向父类构造传参并继承父类实例的相关属性。
  • 创建一个新对象连接父类原型,而非直接让子类原型指向父类的实例,避免了重复继承父类相关属性(构造函数中已经执行过父类构造方法)。
function create (prototype) {
    function F () {}
    F.prototype = prototype  // 通过新的对象和原型链继承方式,避免了父类构造函数的二次调用
    return new F ()
}

function inheritPrototype (ChildCons, ParentCons) {
  var prototype = create(ParentCons.prototype); //  创建父类原型副本
  prototype.constructor = ChildCons;            //  修复原型constructor指向
  ChildCons.prototype = prototype;              //  继承副本原型
}

function Parent (name) {
  this.name = name;
}

Parent.prototype.sayName = function(){
  console.log(this.name);
}

function Child(name, age){
  Parent.call(this, name); // 通过call方法,执行父类构造函数并将属性绑定到子类上下文中
  this.age = age;          // 子类实例属性
}

inheritPrototype(Child, Parent);

Child.prototype.sayAge = function(){
  console.log(this.age);
};

Question3

问: 0.1 + 0.2 !== 0.3 原因 => 解决方法

原因

JavaScript采用IEEE 754规范,使用64位固定长度来表示,第一位表示正负,后11位表示指数,最后52位表示小数 其中0.1表示为0.00011001100110011001100110011001100110011001100110011010 , 0.2 表示为0.0011001100110011001100110011001100110011001100110011010 ,相加之后得到0.0100110011001100110011001100110011001100110011001100111,转为10进制就是0.30000000000000004

解决方法

- 新原始数据类型:BigInt('123')
- 大数相加的三方库 / 用字符串记录大数,并实现相加的逻辑。

可参考文章——0.1 + 0.2 !== 0.3

Question4

问: event loop概念 => 是否每次event loop 都会伴随渲染

概念

  • 常见的宏任务:script(整体代码)、setTimeout、setInterval、I/O、setImmedidate
  • 常见的微任务:process.nextTick、MutationObserver、Promise.then catch finally
  • 宏任务微任务交替进行
  1. Javascript内核加载代码到执行栈。
  2. 当遇到诸如Ajax,setTimeout等异步任务时,交给浏览器WebApi去处理。然后继续执行后续代码。
  3. 当异步任务有结果时,将回调函数放入各自所属的任务队列中(宏任务到宏任务队列,微任务到微任务队列中)。
  4. 执行栈清空后,检查微任务队列并将回调函数放入执行栈中执行。
  5. 微任务清空后,入栈第一个宏任务回调并执行,执行中遇到微任务继续添加到微任务队列中。
  6. 本轮宏任务执行完毕后再次清空产生的微任务队列。
  7. 执行下一轮宏任务回调。

Event Loop 和渲染

Event Loop 并不一定伴随渲染,需根据屏幕刷新率,页面性能,页面可见性等因素进行分析。参考 深入解析你不知道的 EventLoop 和浏览器渲染、帧动画、空闲回调

可参考文章

Question5

问1:Vue父子生命周期 => Vue复用组件时共享同一个实例吗?那为什么data是函数?

父子生命周期调用顺序

父beforeCreate->父created->父beforeMount->子beforeCreate->子created->子beforeMount->子mounted->父mounted 其实这里比较容易发现,父组件渲染完成的前提就是所有子组件已经渲染完毕。所以父BeforeMount之后,开始渲染子组件,子组件完全渲染之后,父组件才调用Mounted

更新和销毁过程其实也是同样的。

更新:
父beforeUpdate->子beforeUpdate->子updated->父updated
销毁:
父beforeDestroy->子beforeDestroy->子destroyed->父destroyed

组件复用和data

关于组件复用,Vue官网说明如下 组件data使用函数返回一个完全独立的副本是因为JavaScript本身的原因。因为对象是引用类型,多个变量引用同一个对象实际只是拿到对象的地址,其中一个变量修改了对象后,其他变量拿到的都是修改后的对象。

若不返回副本而是引用了同一个对象,则会导致所有组件的状态均会同步,无法得到我们想要的结果。

Question6

问2:Vue双向绑定原理

数据Model => 视图View

Vue使用Object.defineProperty实现响应式数据,其中有三个比较重要的方法和类。

  1. defineReactive方法 使用Object.definePropertyget,set方法监听数据的读取和修改
function defineReactive(data, key, value) {
  var dep = new Dep();
  Object.defineProperty(data, key, {
    get: function () {
      if (Dep.target) {
        dep.addSub(Dep.target); // 在这里收集依赖
      }
      return value;
    },
    set: function (newVal) {
      if (value !== newVal) {
        value = newVal;
        dep.notify(); //通知所有依赖更新视图
      }
    }
  });
}
  1. Dep类 每个对象都会有Dep类,在defineReactive监听对象的get方法中收集使用了当前对象的依赖,并在set方法中通知所有依赖进行更新。
  2. Watcher类,Dep中手机的实际上就是一个个Watcher,修改对象的值时会通知Dep中的Watcher进行视图更新。

通过Object.defineProperty定义每个属性时,会在读取该属性时,通过Get方法收集使用当前对象的依赖,并在更改该属性时,通知所有依赖更新视图。

set: function (newVal) {
  if (value !== newVal) {
    value = newVal;
    dep.notify(); //通知所有依赖更新视图
  }
}

详细可参考文章:Vue双向绑定原理,教你一步一步实现双向绑定

视图View => 数据Model

一般是表单控件,诸如input, select之类的控件值改变后,会同步到Vue的数据源中。也就是v-model的实现。 这里我们只需要监听表单控件的input事件,在事件对象中拿到控件绑定的value值,手动更新Vue数据源即可。

< input v-model="data" />
// 等同于
<input :value="data" @input="data = $event.target.value">

Question7

问:项目中路由鉴权,按钮鉴权的实现 => 做过Vue项目优化吗?=> Vue如何解析模板的

鉴权

  1. 权限表生成
    • 登录时后台返回用户角色,前端使用与角色匹配的权限表(前端生成的)。
    • 或登录时后台直接返回权限表,前端存储在本地。
  2. 页面鉴权
    • 根据前端权限表生成菜单栏或使用addRoutes动态添加后台返回的页面路由。
    • 路由守卫中,判断跳转路由合法性。非法页面跳转到其他指定页。
  3. 按钮鉴权
    • 权限表中存储每个按钮的唯一标识,结合 Vue自定义指令, 仅当权限表中存在该标识时显示当前按钮。
    • 若按钮种类固定,使用类似linux系统文件权限的方式简化权限表,可参考JavaScript 中的位运算和权限设计

项目优化

网络资源丰富,可参考Vue 项目性能优化 — 实践指南和其他优秀文章。

Vue模板解析

HTML给人的第一印象就是标签的集合和嵌套。Vue在解析模板时,就针对<判断HTML中的文本的语义,例如开始标签,结束标签,注释或普通文本等。

解析到标签后即可解析标签上附属的属性,指令等。并将标签逐一转化为AST

AST让用户书写的模板变得更为具体,更为常态化。

接下来Vue中对AST进行了优化,将静态标签打上了记号,确保更新时跳过静态标签。

最后解析AST并生成了可执行代码也就是render函数。

图片引用自Vue模板编译原理,更多细节可参考该优秀文章。

Question8

WebPack原理

  1. 从配置文件和Shell语句读取配置参数
  2. 使用配置好的参数初始化Compiler对象,加载插件,调用插件apply方法并传入Compiler对象。执行run方法开始编译。
  3. 根据entry确定入口,调用配置好的Loader对文件进行翻译,并找到文件对应的依赖,直到所有入口依赖的文件都被处理。
  4. 编译完成后的文件会组装到modules数组中,每个module子项都包含对应的依赖。将module组合成一个个chunk
  5. 生成文件到dist或用户指定输出目录

可参考

面试官:webpack原理都不会?

webpack 原理浅析

WebPack 多页面 打包公共文件

使用WebPack4optimization.splitChunks, 依据定义的规则和优先级将不同目录下文件进行分包。可参考:

// 摘自 多页面解决方案--提取公共代码 
 optimization: {
    splitChunks: {
      cacheGroups: {
        // 注意: priority属性
        // 其次: 打包业务中公共代码
        common: {
          name: "common",
          chunks: "all",
          minSize: 1,
          priority: 0
        },
        // 首先: 打包node_modules中的文件
        vendor: {
          name: "vendor",
          test: /[\\/]node_modules[\\/]/,
          chunks: "all",
          priority: 10
        }
      }
    }
  }

webpack4的splitChunks分包

多页面解决方案--提取公共代码

Question9

问:Object 和 Map 的区别 => Map的本质 => 散列表的原理

Object 和 Map 区别

  1. 都是键-值形式,但是Map的键值可以是任意类型(或者说Map支持值-值的形式)
  2. Mapsize属性,可得到添加的条目数量
  3. Map自身实现了迭代,可用for of 遍历,Object不行
  4. Map有多种遍历方法,并且遍历顺序就是添加的顺序。(Object不保证顺序)
  • Map.prototype.keys():返回键名的遍历器。
  • Map.prototype.values():返回键值的遍历器。
  • Map.prototype.entries():返回所有成员的遍历器。
  • Map.prototype.forEach():遍历 Map 的所有成员。
  1. 展开操作符能将Map转为数组,而Object不行(能对Object内部的Object进行展开,类似浅拷贝)
const map = new Map([
    ['tom', 1], ['amy', 2]
])
console.log([...map]) // [ ['tom', 1], ['amy', 2] ]

console.log({a: 1, ...{b: 2}}) // {a: 1, b: 2}

Map原理

Map本质就是散列表(哈希表)。

数组是一段连续的内存空间,便于查找但是删除和插入操作都非常的消耗性能。 链表是通过指针首尾相连的数据结构,易于插入删除,查找困难。 散列表结合了两者的优点,但是它是如何做到的?

散列表将元素特征通过一定的规则转换为数组的下标,然后用该下标把内容物存储在对应的数组特定位置。元素特征通常就是我们使用的key。

/** 数组查找非常方便,但是它也有个极大的缺点就是,只能通过索引进行取值。
 *  如果我们能给一组数据每一项起一个名字,并把名字通过固定的规则转换为数字(索引)呢?
 *  比如有一组数据:[['老大', 1], ['老二', 2]]
 */ 
const getIndex = function {
	// 假如这个函数返回汉字的笔画数目 老大 = 9 老二 = 8
}
const hashTable = [] // 我们的散列表
hashTable[getIndex('老大')] = 1  // 相当于hashTable[9] = 1
hashTable[getIndex('老二')] = 2  // 相当于hashTable[8] = 2
// 不用遍历即可查到元素
console.log(hashTable[getIndex('老二')]) // 2 

上面只是简单的提了一下转换的规则,大家有好的文章可以分享出来

哈希表hashtable(key,value) 就是把Key通过一个固定的算法函数既所谓的哈希函数转换成一个整型数字,然后就将该数字对数组长度进行取余,取余结果就当作数组的下标,将value存储在以该数字为下标的数组空间里。参考哈希表原理详解

手写代码

  1. 判断版本号相加合法性
  • 输入1.21.3+2.3 返回true
  • 输入1.2.3+.5.2 返回false
function  judge(expression) {
    return !expression.match(/\D\.|\.\D|\.$|^\./)
}
  1. 对象序列化(简化版JSON.stringify)
  • 输入[{a: 8, b: '9', c: {d: true}},[{2: 5}]]
  • 输出[{"a":8,"b":"9","c":{"d":true}},[{"2":5}]]
const getType = target => Object.prototype.toString.call(target).slice(8, -1)
function stringify (target) {
    const type = getType(target)
    let content = ''
    switch (type) {
        case 'Number':
        case 'Boolean':
            return target;
        case 'String':
            return `"${target}"`
        case 'Object':
            content = Object.keys(target).reduce((list, key) => {
                const value = stringify(target[key])
                value !== undefined && list.push(`"${key}":${value}`)
                return list
            }, []).join(",")
            return `{${content}}`
        case 'Array':
            content = target.map(item => {
                const value = stringify(item)
                return value === undefined ? "null" : value
            }).join(",")
            return `[${content}]`
    }
}
  1. 发布订阅
class Event {
    subs = {}
    on (event, cb) {
        if (!cb) return
    	const self = this
        if (!self.subs[event]) self.subs[event] = []
        self.subs[event].push(cb)
        return function () {
        	self.off(event, cb)
        }
    }
    
    off (event, cb) {
        if (cb && this.subs[event]) {
            this.subs[event] = this.subs[event].filter(t => t !== cb)
        }
    }
    
    emit (event, ...args) {
        if (this.subs[event]) {
            this.subs[event].forEach(cb => cb(...args))
        }
    }
}
  1. 组装树节点
  • 输入一个树节点的集合(数组),[{val: 1, parentId: 1, nodeId: 2}, {val: 2, parentId: null, nodeId: 1}]
  • parentId === null 时为根节点
  • 返回重组后的树的根节点
function transform (nodeList) {
    let head = null
    if (!Array.isArray(nodeList)) {
        return head
    }
    const map = new Map()
    nodeList.forEach(node => map.set(node.nodeId, node))

    nodeList.forEach(node => {
        if (node.parentId === null) {
            head = node
        } else {
            const parentNode = map.get(node.parentId)
            if (!parentNode.children) {
                parentNode.children = []
            }
            parentNode.children.push(node)
        }
    })
    return head
}
  1. 控制并发请求
  • 排队请求数据,同一时间最多两个请求执行,其中一个请求结束后填充下一个请求
class Scheduler {
    add () {
       
    }
}

const timeout = time => new Promise(resolve => {
    setTimeout(resolve, time)
})

const scheduler = new Scheduler()
const addTask = (time, order) => {
    scheduler.add(() => timeout(time))
    .then(() => console.log(order))
}

addTask(1000, '1')
addTask(500, '2')  
addTask(300, '3')  
addTask(400, '4')
/*
500毫秒 < 1000毫秒 , 500毫秒后请求结束,打印2
500毫秒请求结束后填充该请求,然后立即执行 500 + 300 < 1000 所以300也提前会返回结果
300 结束后 填充400, 但是此时1000的请求只有200毫秒等待时间 < 400毫秒, 1000先返回结果,打印1
最后打印4
输出 2 3 1 4
*/

菜鸡代码:

class Scheduler {
    constructor () {
        this.list = []
        this.count = 0
    }

    add (promiseFn) {
        return new Promise((resolve, reject) => {
            this.list.push({promiseFn, resolve})
            this.doNext()
        })
    }

    doNext () {
        if (this.count < 2 && this.list.length) {
            const target = this.list.shift()
            this.count++
            target.promiseFn().then(() => {
                this.count--
                target.resolve()
                this.doNext()
            })
        }
    }
}

致谢

感谢大佬们的优秀文章为我答疑解惑,文章链继均在问答处,就不在这里统一放置了。