1.引言
最近参加了大量的招聘会,投递了大量的简历,整整体会了从“随便找个厂上一下”——“还是的找个大厂”——“没人要”——“急了急了,海投一波”——“工资有点尬”——“海投中…”。简单说一下自己的一些感受吧,现在的前端属实有点尴尬:
前端的基础教程特别多,最开始本来是觉得自己有这断断续续3年的编程经历还算有优势,可惜很多公司需要的是那种把面试玩明白的,知道后来我才发现原来前端是有着一个30w字的简历总结的,那里面涵盖了大量的前端面试题,甚至基本上我参加的面试或者笔试都有相关的题目。我面过多益网络(这厂背后总有一股不是很让人向往的知乎风评),多益网络的面试虽然可能繁杂一下,但是多益问的基础还是比较广泛的,更加适合那些从前端还没有炸就开始学的,面过一些类似“冲业绩的”(懂的都懂),还有一些直接拿着双飞本科说事的。
2.一点点体会
总结一下就是前端教程很多,都偏向于应用上,实际上公司需要的是那一种能够深挖原理的(比如可以看看《你不知道的JavaScript》,技术群别人推荐的),如果现在还不懂原理的话找到理想工资的前端工作还是很有难度的。
大概说一下我个人的情况吧,本科双飞,强调这个可能学历高的没有体会到这个的劣势,一谈工资面试官就会拿学历压薪资,然后大学生创新创业和同学组建过公司,这大概算得上自己最耀眼的经历吧,然后过了六级分不高,专业差不多前十(当然不保研这个实际作用应该不大),然后动手能力可能强一些,在公司的时候一起接过外包,自己也接过学妹学弟的单子(虽然钱不多),差不多几天一个全栈小demo的那种。实际上,面试官可能比较烦这种写demo,影响编码规范也是有可能的。主要是可能因为自己去年四月份选择了考研,然后在经验上或者是技术变化的跟随上有一些不足,这里我觉得更为核心的还是实习经验上,自己创新创业的一年好像不是很能够被认可,但是在我个人看来其实面试我的公司的开发部还没有我们当时的那个人数多。
我这里不是要批斗什么,也不是报一种就业消极的心态,面试其实也有它的好处。比如给我影响深刻的是,能够提高我们的技术基础,长期写代码的人可能更能体会到,学的东西很快会忘,导致问什么可能当时想不起来,但是给个机会,几分钟搜一下就能明白(比较经典的就是某个公司问我的axios拦截重复请求问题)。还有一些公司的面试官比较友好,他们会暗示性的让你回答他想要的答案,比如:真的吗?这个时候往往就是你错了,你得记下来回去试试、搜一搜、再积累积累。更好一下的面试官直接给你讲解少量的知识,里面很多都是你理解错了的或者理解不充分的,比如多益的JSON.parse()拷贝的浅拷贝的理解,一零跃动promise的then方法其实有着第二个参数err触发到catch。还有就是面试的碰壁能够激励自己去学历更多的理论知识,总不能一家不知道直接下一家也不知道吧,想想都觉得尬,面试官还能够提示一些关键词,比如快狗打车的ast抽查,diff,模板渲染等,在外界的刺激下确确实实能够学习更多理论。从想——搜——敲/复制——调试——经验积累(什么可以实现,需要借助什么),转变为想——涉及原理——封装——优化——编码决策。
3.建议
如果找工作的话,建议还是多看看理论,当然编码能够加强记忆和理解。如果你已经有很好的编码经验,建议还是多看看原理,那才是提高薪资的敲门砖。如果你觉得不能够很好的表达,多看看原理相关的文章,比如去大佬的公众号或者是掘金,csdn更偏向于代码编写和框架依赖的使用(至少热度上是这么表现的)。
4.面试难题总结
今天就花点时间把这些原理给学了,不再下次一定。主要包括如下几点:
(1)原型和原型链;
(2)koa原理;
(3)promise原理;
(4)nodejs模块化原理;
(5)node events;
如果有不对的欢迎在评论区指点指点带我一手,非常感谢。
4.1原型和原型链
可能你在layui的部分源码中看到过,知道它是用来挂载属性和函数的,但这过面试还不够。原型和原型链其实还牵扯了JS内部很多的概念问题:constructor、函数、对象、prototype、_proto_、原型对象、原型、原型链。
object.constructor也就是拿到一个对象的构造函数,函数和对象有一种说话是:“函数即对象”,Function 函数和Object函数都是JS内置对象(内部类),函数其实就是Function这个内置对象的实例对象,因此说函数即对象。函数与对象的区别:功能上,函数强调对一种操作的封装,对象则更强调实例,更偏向于是一个功能被丰富的变量。
prototype出现的原因:当我们使用一个对象构造出两个不同的实例对象,然后给实例对象分别挂载上相同的方法,由于被挂载的方法分属于不同的对象,那么对出现student1.getName() !=
student2.getName();同时带来的问题是直接占用了两块内存空间,这么玩的话也有点尬,明显封装度不是很够,还有比如我们利用Array引用对象构造了一个arr的实例对象,这个实例就可以使用到pop、push、splice等一系列方法,其实就是prototype的作用,也就是常理解的挂载属性和方法;
原型对象即创建自己的构造函数所在的类中的prototype,接下来分析一下constructor的属性的位置,既然constructor在每个实例对象都可以获取到,那刚好可以把这个constructor挂载到prototype里面,实际上,恰好也是这么处理的。然而问题来了,是否可以中途修改constructor达到修改所有的?(答案是不能)
这个时候__proto__就来了,那就能够指向原型对象的prototype(也就是原型对象):
接下来再来理解原型链就轻而易举啦,student1的 __proto__可以指向其构造函数内部的prototype属性(原型对象),这玩意本身它就是一个对象,并且是一个Function类构造的实例,那么Function本质又是Object类构造的,因此就能够一步步向上查找原型对象,这就是所谓的原型链,student1.__proto__可以找到Student.prototype,
student1.proto.__proto__可以找到new Student函数的原型,也就是Object.prototype,为了里面原型链的循环,取对象的原型为null:
4.2 koa原理
这个题目确实有些难,经过一些尝试,我把我的理解分享出来,官网上主要是介绍一些基本信息:
差不多说的是koa是一个开发express的团队设计的新的web框架,以更小更快更健壮为目标来搭建web应用或者是api,可以摆脱回调的使用增加错误处理,核心部分没有捆绑任何中间件,能够更优雅的写服务端应用。像没说一样,需要关注的还是原理,经过一些文章的学习,明白了koa的核心文件:application.js、context.js、request.js以及response.js,分别针对应用搭建、上下文(ctx),请求体和响应体。结构图如下:
aplication继承nodejs里面的events模块(用于事件统一管理的,对应第五个,先不细说),简单说一下程序执行流程,由application里面封装的listen方法结合调用new koa().listen 传入的参数来开启一个nodejs服务,接着处理middleware中间件,然后将洋葱模型串联起来并执行,返回响应。这里先挖一下洋葱模型的实现原理,为什么要是用洋葱模型?其实我并没有找到我能接受的答案,网上说的是能够保证中间件的顺序执行,为什么会顾及中间件的这个执行呢,因为中间件之间可能会存在相互引用,使用洋葱模型结合koa-compser里面的promise串联机制能够保证函数的顺序执行,可能也是为了抽离出思想或者是借鉴了某些非计算机领域的思想来这么称谓的吧,我觉得这个概念有点强制话主要是因为在koa里面的middleware只有一个next能够调用下一个中间件,并没有说能够自由调用内部中间件,因为自己经验还是比较有限,可能对于代码思想上的概念还缺乏相当程度的理解:
于是找来了一个项目看看(尝试理解):
是否觉得还可以挣扎一下?对,将middleWare强行用一个函数套在一起返回,当然实测发现不行,因为已经脱离了koa框架的执行机制和对外暴露的接口了。这样就能够举例证明middleware之间顺序执行的必要性,但诚然无法说明洋葱的重要性,不过相比于顺序执行而言,next的参与能够像nextTick()一眼获得到下一个middleware的数据,然后next的调用者内部能够进一步利用更新后的ctx进行后续处理。还是拿着这段经典代码来解释洋葱模型的顺序化处理吧(并没有在实际开发中真正使用到,说真的理解起来不是很顺畅):
const koa = require("koa");
const app = new koa();
app.use(async (ctx, next) => {
console.log(1);
next();
console.log(5);
});
app.use(async (ctx, next) => {
console.log(2);
next();
console.log(4);
})
app.use(async (ctx) => {
console.log(3);
ctx.body = "hello world";
})
app.listen(3000, () => {
console.log("服务启动:localhost:3000");
})
//输出12345
其实就是利用了promise的异步执行特点,将next封装成一个promise对象,然后利用递归来实现(还是看看源码吧):
现在将核心放在递归链上,上面这个递归的理解也需要花点时间,我们先易后难,将函数简化成:
const koa = require("koa");
const app = new koa();
app.use(async (ctx, next) => {
console.log(1);
});
app.listen(3000, () => {
console.log("服务启动:localhost:3000");
})
在只有一个middleware的情况下,函数步入,肯定会到达上图代码的42行
,但由于函数在next处没有执行过,因此直接退出,下面恢复next的使用,函数步入,由于这次next执行,函数递归,就形成了嵌套,相当于将下一个中间件塞到了前一个中间件内部,这里则不是由于i===middleware.length
跳出的,而是函数正常执行而结束的,所以这个函数其实算是一种伪递归。接下来分析,为什么函数能够检测出来两次next(),两次next的限制可能是为了防止嵌套深度过大,假设在上面最简单的代码中加上两次next(那个只打印1的代码块),那么还是一样,模仿函数执行,函数步入之后,也就是会重复调用两次depatch(1),也就是会出现第二次的时候i==index
的情况。
回到面试上来,如果再次遇到了这个问题该怎么回答呢?
答:koa主要是利用promise异步编程抛弃回调函数,不再内置中间件,其中主要包含四大模块,分别是application,context、request、response,在application中封装了一个构造函数,在继承了nodejs的events模块的同时,对外暴露了listen、use、toJSON等接口,listen内部又基于nodejs的原生http模块封装了nodeServer函数,利用use来装载外部自定义的中间件,在中间件机制上,koa使用洋葱模型的思想,先将多个middleware中间件顺序存到一个数组中,然后再利用koa-compose里面的compose方法来保证middleware能够顺序执行,在compose方法中其实利用的是递归的思想,对middleware函数进行遍历同时将函数的next参数修改为执行下一个middleware的函数,当然存在一定的爆栈风险,可以改用倒序遍历中间件的方式重新封装compose方法。对于request和response上采用的是类似于Vue的getter、setter来设置和修改request和response上面的属性,同时结合delegate将request和response代理到context上面,方便获取和函数调用。
4.3 promise原理
面试官特别喜欢问这个,但我总觉得的不是很好回答(甚至直接被误解为只是使用过,一般听到这儿那你的promise基本就算是没答上来),手写promise也搞过,但不怕被笑话我是背的源码,下面再好好总结总结。首先总结一下promise的特点:
(1)promise一共有三种状态:pending、Fulfilled、Rejected;
(2)promise接受一个函数作为参数,该函数又具有resolve和reject两个函数类型参数,resolve函数能够将pending状态转变成Fulfilled,reject函数能够将peding状态转化成Rejected,在状态转变之后立即触发相对应的then和catch函数(也就是说代码中同时有resolve和reject调用的话,谁在前面调用就谁说了算),最后如果有finally的话,只要转变了都触发;
(3)注意一点(某次被面试官刷新认知),promise的then方法里面是有两个参数的,包括成功的处理方法还有失败的处理方法,如果在成功中调用抛出异常那么函数可以进入catch,这个本质是promise内部做的异常处理,如果是reject并且在then里面写了第二个函数即错误处理函数,那么catch不会触发,并且如果是在then的错误处理函数抛出错误也不会触发catch,其实这是因为,catch本质是一个语法糖,还是通过调用then里面的方法来实现的,也就是(这样也就很好理解了,建议还是不要使用then的第二个参数,否则这个语法糖的意义也就不大了,代码清晰它不香吗):
Promise.prototype.catch = function(fn){
return this.then(null,fn);
}
(4)promise在执行的过程中支持链式调用,promise.then可以一直向下调用,就算是中间某处的then或者catch函数有问题也能将状态向下传递,就简单举个例子吧:
面试官问的话,把这些答出来基本就差不多了,友好一点的面试官可能会问你:“聊聊promise吧”,不友好的:“promise的原理是什么”(这么问有点尬,异步编程确实就是这样的,有点无从下口,但是你就把你的理解说出来应该问题不大,或者面试官没有听到想要的答案也会转入你喜欢的问答模式)。接下来再来说说promise的手写问题,了解以上就比较好理解了,特别是关于为什么进行回调函数的数组封装(我怕自己写的不够完美,借鉴了掘金的大佬的代码,然后微调了一下):
const isFunction = variable => typeof variable === 'function'
const PENDING = 'PENDING'
const FULFILLED = 'FULFILLED'
const REJECTED = 'REJECTED'
class MyPromise {
constructor(handle) {
if (!isFunction(handle)) {
throw new Error('MyPromise must accept a function as a parameter')
}
this._status = PENDING
this._value = undefined
this._fulfilledQueues = []
this._rejectedQueues = []
try {
handle(this._resolve.bind(this), this._reject.bind(this))
} catch (err) {
this._reject(err)
}
}
// 添加resovle时执行的函数
_resolve(val) {
const run = () => {
if (this._status !== PENDING) return
const runFulfilled = (value) => {
while (this._fulfilledQueues.length) {
this._fulfilledQueues.shift()(value);
}
}
const runRejected = (error) => {
while (this._rejectedQueues.length) {
this._rejectedQueues.shift()(error);
}
}
//判断是否是resolve的参数为Promise对象
if (val instanceof MyPromise) {
val.then(value => {
this._value = value
this._status = FULFILLED
runFulfilled(value)
}, err => {
this._value = err
this._status = REJECTED
runRejected(err)
})
} else {
this._value = val
this._status = FULFILLED
runFulfilled(val)
}
}
// 为了支持同步的Promise,这里采用异步调用
setTimeout(run, 0)
}
// 添加reject时执行的函数
_reject(err) {
if (this._status !== PENDING) return
const run = () => {
this._status = REJECTED
this._value = err
while (this._rejectedQueues.length) {
this._rejectedQueues.shift()(err)
}
}
// 为了支持同步的Promise,这里采用异步调用
setTimeout(run, 0)
}
then(onFulfilled, onRejected) {
const { _value, _status } = this
// 返回一个新的Promise对象
return new MyPromise((onFulfilledNext, onRejectedNext) => {
// 封装一个成功时执行的函数
let fulfilled = value => {
try {
if (!isFunction(onFulfilled)) {
onFulfilledNext(value)
} else {
let res = onFulfilled(value);
if (res instanceof MyPromise) {
// 如果当前回调函数返回MyPromise对象,必须等待其状态改变后再执行下一个回调
res.then(onFulfilledNext, onRejectedNext)
} else {
//否则会将返回结果直接作为参数,传入下一个then的回调函数,并立即执行下一个then的回调函数
onFulfilledNext(res)
}
}
} catch (err) {
// 如果函数执行出错,新的Promise对象的状态为失败
onRejectedNext(err)
}
}
// 封装一个失败时执行的函数
let rejected = error => {
try {
if (!isFunction(onRejected)) {
onRejectedNext(error)
} else {
let res = onRejected(error);
if (res instanceof MyPromise) {
// 如果当前回调函数返回MyPromise对象,必须等待其状态改变后再执行下一个回调
res.then(onFulfilledNext, onRejectedNext)
} else {
//否则会将返回结果直接作为参数,传入下一个then的回调函数,并立即执行下一个then的回调函数
onFulfilledNext(res)
}
}
} catch (err) {
// 如果函数执行出错,新的Promise对象的状态为失败
onRejectedNext(err)
}
}
switch (_status) {
// 当状态为pending时,将then方法回调函数加入执行队列等待执行
case PENDING:
this._fulfilledQueues.push(fulfilled)
this._rejectedQueues.push(rejected)
break
// 当状态已经改变时,立即执行对应的回调函数
case FULFILLED:
fulfilled(_value)
break
case REJECTED:
rejected(_value)
break
}
})
}
// 添加catch方法
catch(onRejected) {
return this.then(undefined, onRejected)
}
// 添加静态resolve方法
static resolve(value) {
// 如果参数是MyPromise实例,直接返回这个实例
if (value instanceof MyPromise) return value
return new MyPromise(resolve => resolve(value))
}
// 添加静态reject方法
static reject(value) {
return new MyPromise((resolve, reject) => reject(value))
}
// 添加静态all方法
static all(list) {
return new MyPromise((resolve, reject) => {
let values = []
let count = 0
for (let [i, p] of list.entries()) {
// 数组参数如果不是MyPromise实例,先调用MyPromise.resolve
this.resolve(p).then(res => {
values[i] = res
count++
// 所有状态都变成fulfilled时返回的MyPromise状态就变成fulfilled
if (count === list.length) resolve(values)
}, err => {
// 有一个被rejected时返回的MyPromise状态就变成rejected
reject(err)
})
}
})
}
// 添加静态race方法
static race(list) {
return new MyPromise((resolve, reject) => {
for (let p of list) {
// 只要有一个实例率先改变状态,新的MyPromise的状态就跟着改变
this.resolve(p).then(res => {
resolve(res)
}, err => {
reject(err)
})
}
})
}
finally(cb) {
return this.then(
value => MyPromise.resolve(cb()).then(() => value),
reason => MyPromise.resolve(cb()).then(() => { throw reason })
);
}
}
4.4 nodejs模块化原理
node的应用是模块组成的,Node遵循commonjs的模块规范,用来隔离每一个模块的做用域,使每个模块在自身的命名空间中执行。这也是面试遇到的一大原因,光会用一些内置模块和第三方依赖远远不够,提到模块,基本每个语言都有,在js中还有一种es6模块,了解二者的区别也是必要的:
(1)导入导出语法上不同,CommonJS 使用的是 module.exports = {} 导出一个模块对象,require(‘file_path’) 引入模块对象;ES6使用的是 export 导出指定数据, import 引入具体数据。由于前端一般是es6后端一般是CommonJS,仔细回想一下编程时候使用的导入导出不难理解,需要注意的是后端也是可以通过package.json文件配置不同的编规范的;开发规范其实有很多,不过这两种算是比较常用的,es6的模块化其实还依赖于babel,babel能够将还未被宿主环境兼容的es6模块编译成es5(CommonJS),webpack中就用到了babel-loader处理兼容性问题。
(2)模块输出上的区别:CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用,也就是CommonJS输出之后不再受到模块内部变化的干预,而ES6则会改变;
(3)模块加载上,CommonJS 模块是运行时加载,ES6 模块是编译时加载,CommonJS先加载整个模块并生成对象然后读取对象上的方法,而ES6则不需要加载整个模块,利用import加载指定值;
还是回到问题上来,如果面试官问什么是nodejs的模块化(其实是考察是否看过官方文档,是否了解框架底层工具的原理).
你可以这么回答:在nodejs中,每一个文件都被视为一个模块,然后利用nodejs的模块包装器函数,保证每一个模块的变量的作用域范围只限制在模块内部,在导出时,nodejs会利用module.exports函数类挂载函数或者变量等,模块除了exports属性外,还有模块id(通常就是模块解析后的文件名)、被调用的子模块children和调用者parent、模块搜索路径path、模块完全解析路径filename以及标识模块是否加载完成的loaded。nodejs中的模块一共被分为三类:系统内核模块、用户自定义模块以及第三方模块。在一个模块调用另一个模块中的函数时,会使用require函数来引入,具体流程是:首先查看文件模块中是否有缓存(模块在第一次加载之后进行缓存),如果有缓存直接使用,如果没有则判断是否是内核模块,如果是内核模块加载、缓存并使用,如果不是需要查找文件模块的位置,在这个过程中,需要分情况来加载,如果是有相对路劲标识,那么直接定位相对路径指向的位置加载,如果是第三方模块,那么会在package.json的main属性下指定文件(通常是app.js)的同级目录的node_modules下找,没有找到再一步一步再上一级的node_modules中找,没有找到则返回查找失败报错。相对路劲下如果没有文件后缀会利用.js、.json、.node依次补充查找,如果判定为第三方包,还是会找package.json中的main属性指定的文件名,不成功则找index.js,index.node,index.json(类似于php的默认查找)。
这个可能还是不够,面试官可能追问,如果模块之间相互依赖构成了依赖环怎么办?——其实nodejs自己处理了,出现循环依赖主要是模块之间的相互引入,nodejs会返回其中一个模块加载未完成的副本,当然还有可能问到一个问题,既然是缓存,那这个缓存怎么刷新,什么时候失效呢?有多种缓存策略可以了解一下,这里不再详细描述,强制刷新缓存的话可以利用这句delete require.cache[require.resolve("module")];
。
4.5 node events
感觉这个问的我挺惭愧的,基本用的都是常用模块,对框架工具的探索或者说是官方文档的阅读明显不足。其实这个也是让你讲讲events的用途。前面提到了koa中直接继承了events模块,从侧面也可以看出events的完善和强大。
其实,nodejs是事件驱动非阻塞io构建的,所谓的事件驱动指的是,通过有效的方法来监听状态,状态变化了触发响应事件,什么是非阻塞io,这里然后我想起了有一次面试题里面问到了阻塞io与非阻塞io的区别,阻塞io指的是需要内核IO操作彻底完成后,才返回到用户空间执行用户的操作。而非阻塞指的是用户空间的程序不需要等待内核IO操作彻底完成,可以立即返回用户空间执行用户操作,即处于非阻塞的状态,与此同时内核会立即返回给用户一个状态值,就比如nodejs一大优点为处理io密集型工作。
事件模型使用的是发布订阅模式,在vue的响应式原理中也经常被提到,在实时消息系统中也经常用到,另外websocket和mqtt协议在使用时也会经常被提到。先给出一个使用例子吧:
一下子就能想到第一个用途,增强系统的维护能力,在每次有错误的时候能够打印错误日志,甚至还能够借助邮件系统相关的第三方依赖直接发送邮件反馈系统的错误信息,及时对系统进行维护。另外,可以联想到vue里面的子组件像父组件传参,跨模块倒是不如回调函数来的直接(当然把监听器导出去也可以),但是一个模块内部进行传参监听还是可以的,但也需要注意另外一些问题,传来传去太多了系统容易像goto
一样,另外监听器还是容易产生一些性能和内存泄漏上的顾虑,不同的时候及时剔除,或者参考下面的常用api最贴切的使用:
emitter.on(eventName, listener)//添加指定事件的监听
emitter.emit(eventName[, ...args])//触发事件
emitter.once(eventName, listener)//绑定一次性监听,类似于vue的v-once
emitter.eventNames()//获取监听器监听的事件的名称
emitter.getMaxListeners()//获取能够设置的监听器上限
emitter.setMaxListeners(n)//设置监听器
emitter.listenerCount(eventName)//获取当前监听器的数量
emitter.listeners(eventName)//获取监听某事件的监听器副本
emitter.addListener(eventName, listener)//追加监听器
emitter.prependListener(eventName, listener)//头插监听器
emitter.prependOnceListener(eventName, listener)//头插一次性监听器
emitter.removeAllListeners([eventName])//不再监听某事件
emitter.removeListener(eventName, listener)//一处指定监听器对指定事件的监听
这个其实和dom事件比较类似,该有的方法都有,自定义能力强大,甚至有了数组的功能,在面试的时候如果没有使用过也要挣扎一下,比如我虽然没有在项目中真正使用过,但是我知道…可以怎么样,有哪些方法等,就和打游戏一样,“射手被人抓总不能一直逃吧,你说两句还能在薪资上回个血”。
5.求职反问阶段的问题
我很幸运,考完研之后,有很多好兄弟都入职了大厂,他们给我了很多指点,下面给出一些反问面试官的话语:
(1)表示出你很希望加入公司,问一些关于项目的团队情况,培训方式、入职大概的工作等;
(2)如果你很在意薪资,不要有什么顾虑(虽然有些面经觉得这个有些敏感了,因为背后会有人根据面试情况给你定薪资,比如B站的模拟面试),咱找工作这个都不问有点傻乎乎的,至少自己得有个薪资的线,你可能怕问了别人不给offer,不给就不给下一家;
(3)五险一金中公积金的比例,根据根据好兄弟介绍这里面大概有个1w左右的钱;
(4)住宿和就餐问题,毕竟这也关系到能够攒下多少钱;
注:每次别人给了面试机会,最好把相关的网络信息搜集一下,比如知乎风评、薪资、工作氛围等都初步了解一下,不要套路式的反问,反而可能让别人觉得你有点憨,等下给你来句我们校招简章里面有说明的,你可以去看看,嘿嘿,什么意思应该懂了吧。