菜鸡最近在面试,有些受打击。不过多面一个下一次成功的机率就大一些。在这里对自己的面试题做个回顾。总结错误的地方也请指正。
- 简单的问题或者开放性的问题可能会不作解释,大佬们可在评论区给出自己得理解,非常感谢。
- 较为复杂的或概念篇幅比较长的题将会附上我阅读过的优秀文章
正文
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 - 宏任务微任务交替进行
Javascript内核加载代码到执行栈。- 当遇到诸如
Ajax,setTimeout等异步任务时,交给浏览器WebApi去处理。然后继续执行后续代码。 - 当异步任务有结果时,将回调函数放入各自所属的任务队列中(宏任务到宏任务队列,微任务到微任务队列中)。
- 执行栈清空后,检查微任务队列并将回调函数放入执行栈中执行。
- 微任务清空后,入栈第一个宏任务回调并执行,执行中遇到微任务继续添加到微任务队列中。
- 本轮宏任务执行完毕后再次清空产生的微任务队列。
- 执行下一轮宏任务回调。
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实现响应式数据,其中有三个比较重要的方法和类。
defineReactive方法使用Object.defineProperty的get,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(); //通知所有依赖更新视图
}
}
});
}
Dep类每个对象都会有Dep类,在defineReactive监听对象的get方法中收集使用了当前对象的依赖,并在set方法中通知所有依赖进行更新。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如何解析模板的
鉴权
- 权限表生成
- 登录时后台返回用户角色,前端使用与角色匹配的权限表(前端生成的)。
- 或登录时后台直接返回权限表,前端存储在本地。
- 页面鉴权
- 根据前端权限表生成菜单栏或使用
addRoutes动态添加后台返回的页面路由。 - 路由守卫中,判断跳转路由合法性。非法页面跳转到其他指定页。
- 根据前端权限表生成菜单栏或使用
- 按钮鉴权
- 权限表中存储每个按钮的唯一标识,结合 Vue自定义指令, 仅当权限表中存在该标识时显示当前按钮。
- 若按钮种类固定,使用类似linux系统文件权限的方式简化权限表,可参考JavaScript 中的位运算和权限设计
项目优化
网络资源丰富,可参考Vue 项目性能优化 — 实践指南和其他优秀文章。
Vue模板解析
HTML给人的第一印象就是标签的集合和嵌套。Vue在解析模板时,就针对<判断HTML中的文本的语义,例如开始标签,结束标签,注释或普通文本等。

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

AST让用户书写的模板变得更为具体,更为常态化。
接下来Vue中对AST进行了优化,将静态标签打上了记号,确保更新时跳过静态标签。
最后解析AST并生成了可执行代码也就是render函数。
图片引用自Vue模板编译原理,更多细节可参考该优秀文章。
Question8
WebPack原理
- 从配置文件和
Shell语句读取配置参数 - 使用配置好的参数初始化
Compiler对象,加载插件,调用插件apply方法并传入Compiler对象。执行run方法开始编译。 - 根据
entry确定入口,调用配置好的Loader对文件进行翻译,并找到文件对应的依赖,直到所有入口依赖的文件都被处理。 - 编译完成后的文件会组装到
modules数组中,每个module子项都包含对应的依赖。将module组合成一个个chunk。 - 生成文件到
dist或用户指定输出目录
可参考
WebPack 多页面 打包公共文件
使用WebPack4的optimization.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
}
}
}
}
Question9
问:Object 和 Map 的区别 => Map的本质 => 散列表的原理
Object 和 Map 区别
- 都是键-值形式,但是
Map的键值可以是任意类型(或者说Map支持值-值的形式) Map有size属性,可得到添加的条目数量Map自身实现了迭代,可用for of遍历,Object不行Map有多种遍历方法,并且遍历顺序就是添加的顺序。(Object不保证顺序)
Map.prototype.keys():返回键名的遍历器。Map.prototype.values():返回键值的遍历器。Map.prototype.entries():返回所有成员的遍历器。Map.prototype.forEach():遍历Map的所有成员。
- 展开操作符能将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.21.3+2.3 返回true
- 输入1.2.3+.5.2 返回false
function judge(expression) {
return !expression.match(/\D\.|\.\D|\.$|^\./)
}
- 对象序列化(简化版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}]`
}
}
- 发布订阅
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))
}
}
}
- 组装树节点
- 输入一个树节点的集合(数组),
[{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
}
- 控制并发请求
- 排队请求数据,同一时间最多两个请求执行,其中一个请求结束后填充下一个请求
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()
})
}
}
}
致谢
感谢大佬们的优秀文章为我答疑解惑,文章链继均在问答处,就不在这里统一放置了。