导读
今年的“金三银四”蒙上了一层厚厚的灰色。虽然大环境确实不好,面试也变得越来越卷,背的八股文突然也不够用了,我一直觉得光去背这些,等到面试的时候,稍微修改下问的方式,就还是无法应对,显得有些捉襟见肘。
为此,把那些八股文改成图片去记忆,效果会更好一些,所以整理了一些高频面试的知识点的导图形式,助力今年不那么好的“铜三铁四”。
更全面的知识体系,可以点击查看这里
HTML
语义化的问题更多是考察对一些新的 HTML5 标签的运用,了解一些常见标签的用法,知道标签是用于什么语义情况,为什么使用语义化的标签,有什么好处等等问题。可以经常看到一些招聘需求上面写的“符合W3C规范的语义化的页面”这种要求。
上图列举了一些常用的语义化标签,也是参考了 MDN 文档中的语义化的解释,了解这些标签,不止说只有面试中用到,平时开发也可以用到这些,更有利于我们的开发
CSS
CSS 中经常遇到的问题就是性能优化,也是整个前端的着重点之一,谈到性能优化,无非就是分为加载时和运行时的优化。上图很多优化,大部分都是日常开发中都已经做过了,只是可能是框架层面已经帮你做过了,或者写 CSS 的时候,你已经有了这么一个概念了。做到了上面这些优化,CSS 差不多已经优化了 80% 了,所以平时的开发中不断总结是非常重要的。
JavaScript
关于 HTML 和 CSS,更多还是记忆层面的东西,只要你用过,都会留有印象。而在 JS 部分,更多则是看个人的理解,拓展以及深度了。当然对于面试来说,重点还是在于基础,如果你能说出更深层次的东西,那自然是一个加分项,加分项就是拉开差距的重要条件。
原型&原型链
首先原型已经是被问烂的一个问题,但是如何回答才能变成一个加分项呢?
先解释下原型的概念,原型是什么。原型相关的一些属性,prototype、constructor以及非标准属性__proto__。有非标准获取原型的属性,肯定也有标准获取原型的属性,Object.getPrototypeOf 获取原型,Object.setPrototypeOf 设置原型。
原型怎么使用。原型用于如何查找属性,当前对象上如果没有该属性,则在其原型上查找,引出原型链的概念。一般一个对象的原型链的顶层就是 Object.prototype,再往上则是 null 了。利用这个特性,可以为构造函数生成的多个对象设置共享属性,共享其实就体现了面向对象中的继承特性,这也是 ES6 实现 extends 继承的原理之一。
再去拓展一下原型的知识,就上面说到的继承展开说说,JS 中的继承有几种方法,例如原型链继承、构造函数继承、组合继承、寄生继承等等。如果你看过“你不知道的JavaScript”一书,还可以谈谈 JS 中原型的本质,实质上就是委托,所有的对象都是由委托来生成属性的。类比原型的这种委托机制,可以想到相似的一个概念——作用域以及作用域链。
作用域&作用域链
JS 的作用域是静态作用域,所谓静态,就是决定作用域的是代码书写的地方,而不是代码生成的地方,一个经典的面试题:
var global = 'global';
function foo() {
var global = 'local';
bar();
}
function bar() {
console.log(global); // global
}
这道题就证明了上面说到的代码书写的地方这个短语的意思。由此可以引申出作用域最直观的解释:
作用域就是代码的作用范围
代码的作用范围就是代码定义的地方,那么,作用范围的“作用”又该如何解释呢?思考一下为什么会有作用域这个概念,就是为了规定了代码如何查找变量,也就是代码对于变量的访问权限。这就是作用域的作用。在 JS 中作用域分为了全局作用域和函数作用域,这是在let和const出现之前的划分,在这之后,又出现了块级作用域,为什么会出现块级作用域呢,一个新事物的出现,必然是为了弥补旧事物的缺陷。
执行上下文
这又说到 JS 中的代码是如何执行的,如何去保存这些作用域信息的。此时出现了一个概念,叫做执行上下文。
上图就是执行右边的代码以后,生成的执行上下文。先不考虑let声明的变量,从这张图中,可以得到以下几个知识点。
首先就是代码执行时的相关信息是保存在栈结构中,栈底是全局上下文,包括var声明的变量,function定义的函数声明,还有词法环境以及 this。而 var 和 function 定义的变量就构成了变量对象,变量对象中声明的变量会被提升到代码的顶层,也就是第一行的位置,这样导致的结果就是,在定义这些变量之前访问这些对象,也是可以访问到的。这种现象就是变量提升。
变量提升的好处就是提前把所有声明的变量放入变量对象中,等到用到的时候,再去变量对象中查找,非常简单明了,也易于实现。但同样,变量提升也存在问题:就是不符合人类的线形思维,对于初学者来说,很难理解为什么变量还没有定义就能访问了呢。另外则是容易出错,变量提升对于 var 来说,会赋为一个初值 undefined,这就会导致一些意想不到的问题出现。基于这些,才有了块级作用域的出现。
块级作用域更好理解,大括号之间的就是块级作用域,使用let或者const就会绑定词法环境,无法在声明之前使用变量:
{
console.log(name);
let name = 'Jack';
}
这种情况下就会报错,也就是临时性死区(TDZ)。
当代码执行时访问一个变量,如果当前作用域中不存在该变量,就会去其父级作用域中查找,父级作用域中也不存在,则去父级作用域的父级去查找,以此类推,直到全局作用域。这种查找可以理解为一种链式的查找,也就是上图中的scope chain的指向构成了作用域链。
至此,上图中上下文环境中的三个就都已经说到了,就剩下一个 this。this 的指向你可能很清楚,但是为什么会出现 this,就很少有人能说清楚了。上面说了这么多,其实都在说一个概念,执行上下文,可以理解为代码的执行环境,那如何拿到当前执行环境中的变量呢?直接拿对应的变量名?如果是在全局中变量 OR 嵌套函数中的变量呢?
所以就出现了 this 的概念,用来指代这个上下文,这跟传统的静态语言中的 this 是完全不一样的。所以我们经常看到某些文章中提到 this 的指向的时候,会说“this 的指向根据其执行上下文来决定的”。OK,现在好像就豁然开朗了,不是吗。
内存模型&闭包
关于上图其实就说的差不多了,但是提到了作用域,又不得不提 JS 中著名的闭包。
闭包就是指那些能够访问自由变量的函数,自由变量就是那些既不是函数参数也不是函数局部变量的变量。
这里强烈推荐冴羽大佬的博客,值得一看。
所以这么一看,闭包就是函数,而闭包形成的原因就是作用域链,或者也能理解闭包就是一种特殊的上下文环境。这里我们结合下面的内存模型图分析一下。
我们都知道 JS 代码执行时,会把基础类型的变量存放在栈空间,而引用类型的变量存放在堆空间。上一小节提到的执行上下文栈也存放在栈空间。为什么要这么区分呢?首先从变量本身占据的内存大小来说,引用类型通常占用内存大,而基础类型占用的内存小。再从空间的用途来说,栈用来存放代码执行时的环境,当一个函数执行完毕以后,需要把当前函数出栈,如何表示当前函数出栈了呢?JS 使用了一个 ESP 的指针,来表示当前正在执行的上下文,执行完毕以后,指针下移,就完成了上下文的切换。如果换成堆结构,指针的移动则太过复杂,反而得不偿失。
回到闭包上来,看上图左边的代码。foo 返回了一个 bar 函数,执行 bar 函数,可以把这段代码放到 vscode 中打个断点一步一步调试一下,你会发现,当进入 bar 函数的时候,其上下文中已经包含了一个闭包对象closure(foo),此时 foo 已经执行完毕,其上下文已经被销毁了,但是在 bar 中还是可以访问到 foo 中的 a 变量。这种情况就好像,当 JS 引擎扫描到 foo 时,就已在堆内存中开辟了一块空间,复制了 foo 的上下文,并把这块空间关联到 bar 上,属于 bar 的一个专属的背包。
JavaScript 编译原理
当然实际 JS 引擎是否真的复制了 foo 的上下文,什么时候复制的,还有待去研究。具体的引擎实现细节我们很难去深入,但是关于 JS 引擎是如何执行一段代码的,却是我们需要去了解的。
上图展示了 JS 从源码到被执行的过程。学过编译原理的同学应该都知道,JS 的编译流程也差不多,从源代码进行词法分析,语法分析,这个阶段会创建执行上下文,并且生成 AST,也就是抽象语法树。关键点来了,JS 的解释器会把 AST 转为字节码,众所周知,字节码的执行效率是比较低的,所以又提出了JIT即时编译的技术,其实就是把字节码中经常用到的代码,称为热点代码编译成机器码,也就是01的机器码。这么做其实就是解释与编译相结合,JS 也不再是纯解释执行的代码了,执行效率相比以前也更高了,类似 Java 等语言都是用了 JIT 技术。
那么 JS 代码执行的时候,同步代码都是按顺序一步一步执行,遇到异步的代码,诸如 setTimeout、Promise这样的代码,又按照什么顺序执行呢?其实这就是事件循环所做的事。
事件循环
事件循环分为浏览器环境和 Nodejs 环境,浏览器环境大部分前端接触的可能比较多一点,而 Nodejs 环境则相对较少,关于下面两张图就不再赘述,感兴趣可以去看看这篇文章。
无论是在浏览器环境,还是在 Nodejs 中,JS 还是一个单线程的语言,这就意味着 JS 的异步并不是开启了一个新线程,当然这里没有涉及 worker 线程的概念,worker 线程的使用还是有很多限制,所以没有办法作为一个真正的线程来使用。JS 中的异步可以理解为消息队列的机制,代码执行时遇到异步任务,把其添加到队列中,等到合适的时机,再从消息队列中取出队首的任务执行。这种消息队列就是上图的任务队列的概念。
那任务队列为什么又分为宏任务和微任务呢?宏任务就是上图列出的一些 API,用来表示未来某个时刻会执行的任务,微任务队列会在当前的宏任务之前执行,为什么会有这么一个机制呢?其实就是优先级的原因,需要优先于宏任务之前执行任务的机制。
JavaScript 垃圾回收机制
至此,整个 JS 从上下文,到执行机制,到编译原理都有了大概的理解。那么 JS 的代码执行完成后,如何去回收那些已经用不到的对象呢,这在 JS 中是由引擎自动为我们处理的,但是我们也还是需要去理解 JS 的垃圾回收机制的。
从内存模型那张图,我们可以知道 JS 的代码产生的变量被存储在栈空间或者堆空间,垃圾回收的对象主要也就是这两者。对于栈空间,上文已经说过,就是通过 ESP 的指针,指向当前执行代码,而栈顶的上下文则会被系统给清除。
堆空间的垃圾回收则如上图所示,分为老生代和新生代的区域,老生代空间用来存放那些占据内存大,使用时间久的对象,新生代则存放那些使用时间短,占据内存小的对象。大部分的垃圾回收都发生在新生代区域。
新生代区域分为了两个部分,对象区域和空间区域,对象区域存储的对象占满空间时,会与空闲区域进行反转,对象区域中还存活的对象会被复制到空闲区域中,然后清空整个对象区域,这样对象区域就变成了空闲区域,而空闲区域则会变成对象区域,进行下一轮的垃圾回收。对象区域中存活的对象,经历过一次垃圾回收以后,会发生对象晋升,即把该对象放入老生代区域中。这种垃圾回收的机制就是Scavenge算法。
老生代区域采用了耳熟能详的标记清除和标记整理算法。由于垃圾回收会占用线程执行,所以一旦执行垃圾回收,JS 线程便会阻塞,这就是全停顿。为了应对这种情况,引擎采用了增量标记的方法,把垃圾回收任务拆分成一个个小任务,插入到 JS 的执行线程中,这样就不会产生长时间的阻塞卡顿,其实和 React 的时间切片有异曲同工之妙。
浏览器
浏览器部分是串联整个前端知识体系的重要部分。
如上图,浏览器更多是掌握其渲染原理,也是面试会被频繁问到的知识点。由于每个部分深入又是一个很大的点,所以不再赘述,只是对照这张图,罗列一下浏览器涉及到的知识点:
工程化
工程化这块,更多其实就在平时的工作中的积累。工程化就是一整套的高效的开发流程,任何提升我们开发效率的工具、框架等都是工程化的一部分。
工程化就是整个开发流程的高效运作
上图是整个前端开发过程中,都会用到的一些工具框架等,都是与我们息息相关。其中贯穿整个前端开发流程的工具就是 Babel、Webpack,这也是前端开发中必须要掌握的知识点,工程化的主要面试点就在于此。
Babel
如上所示是一个 Babel 的工作原理图,Babel 非常强大的地方就在于它的插件系统,Babel 本身,也就是 babel-core 是 Babel 的核心包,主要提供 AST、转换 AST 节点以及生成新的 AST 的功能,而插件系统则是根据 AST 节点解析规则,生成不同的 JS 语法。
而 Webpack 内部也是使用了 babel-core 的核心包来处理 AST。
Webpack
Webpack 的运行流程如上图所示,就不再赘述,其中有几个流程中的概念,需要理解一下。
Compiler 对象代表了完整的 webpack 环境配置。这个对象在启动 webpack 时被一次性建立,并配置好所有可操作的设置,包括 options,loader 和 plugin。当在 webpack 环境中应用一个插件时,插件将收到此 compiler 对象的引用。可以使用它来访问 webpack 的主环境。
Compilation 对象代表了一次资源版本构建。当运行 webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。一个 compilation 对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。compilation 对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用。
而插件其实就是在这个运行流程的节点中触发注册的回调函数,类似于发布订阅模式的实现,依赖Tapable这个库来实现。
说完了 Plugin,继续来说说 Loader。
很多同学都知道 Loader 是从右至左,从下到上的顺序运行的,但很少知道 Loader 的运行顺序取决于 Loader 的类型,Webpack 的文档中告诉我们 Loader 有以下几种类型:
- pre loader
- post loader
- inline loader
- normal loader
- pitch loader
具体每种 Loader 如何使用,可以去查看官方文档。Loader 的运行分为了两个阶段,一个是 Pitching 阶段,一个是 Normal 阶段。Pitching 阶段,Loader 的运行顺序是 post、inline、normal、pre。而 Normal 阶段则是按照 pre、normal、inline、post 的顺序运行。
注册 Pitching 阶段的 Loader 方法和普通 Loader 是一样的,都是作为一个普通的方法暴露出去,但是 Pitching Loader 是作为 Normal Loader 的 pitch 属性,如下所示。
// normal loader
module.exports = function loader(source) {
return source;
};
// pitching loader
module.exports.pitch = function loader(source) {
console.log('Pitching Loader');
return;
}
Pitch Loader 的执行顺序如上图所示,和 Normal Loader 的执行顺序是相反的。并且当 Pitch Loader 存在返回值的时候,会发生熔断,即会中断后续所有的 Normal Loader 和 Pitch Loader,返回执行其前一个 Normal Loader。
最后说说 Webpack 的 HMR 的机制,也是面试中经常会被问到的。
上图展示了 HMR 的运行流程 abcd。
- 使用 webpack-dev-server 托管静态资源,往浏览器注入 HMR Runtime 代码
- 浏览器加载页面,与 webpack-dev-server 建立 websocket 连接
- webpack 监听到文件变化后,重新编译更新的模块,通过 websocket 发送 hash 事件
- 浏览器收到 hash 事件后,请求 manifest 资源文件,确认更新范围
- 浏览器加载发生更新的模块
- webpack 运行时触发更新模块的 module.hot.accept 的回调方法
框架 React 为例
框架方面,因为我是 React 为主,所以这里就谈到了 React 框架的一些问题,首先就是关于 React 的 diff 算法的实现。大家可以参考卡颂大佬的React技术揭秘,这里我是画了几张图,试图把 React 的 diff 过程展示出来。
Diff 算法
diff 主要分为两类,一类就是单节点 diff,一类就是多节点的 diff。另外就是 diff 中一个非常重要的属性Key,主要是用来暗示哪些元素在更新前后没有发生变化,可以复用,所以 key 属性用的好,确实可以提升 diff 的效率,否则就会起到反作用。
Hooks 的实现
我们知道 class 组件需要实例化,而组件的 state 和 props 都存储在组件的实例上了。但是函数式组件没有实例,执行完以后就被销毁了,所以 hooks 应该如何存储呢。
如果看过一些 React 的源码分析就会知道 Fiber 的概念,可以简单理解为虚拟DOM,hook 其实就是挂载在了 Fiber 节点上了。hook 本身是一个对象,这个对象上有一个 next 指针,指向了下一个 hook,形成一个单向链表结构。其次 hook 中还包含了一个更新队列,里面存储了当前的状态,以及更新以后的下一个状态,它们被链接起来形成一个单向循环链表结构。
性能优化
关于前端的性能优化,内容其实非常多,所以说自己熟悉的一些优化就可以了。优化和上面的 CSS 的优化是类似的,分为运行时的优化和加载时的优化,每一个点都需要你切实的去做过,或者深入研究过,才能说的透彻,否则反而变成了暴露了自己的劣势。
排序算法
算法这块更多的是考察解决实际题目的能力,但有时也会问到一些基础排序算法的复杂度,是否稳定等等。这样的问题,就需要你对排序算法了然于心,可以借助上面这张图,比较一下各个排序算法之间的区别,从而更好的理解排序算法。
结语
聪明的你应该看出来了,每个模块,我尽量是从一个点切入,然后去关联到其他知识点上去,通俗来说,就是非常能“吹牛X”吗,但是内里其实就是前端的整个知识体系在起作用,它最直观的作用就是能让你面对面试官的时候,不至于冷场或者不知道说什么,这样的问题。但是如果你说,面试官打断了你,不想听你说这么多“没用”的,别怀疑,那就是面试官格局小了(狗头保命)。
最后,说了这么多,都是自己近些年的一些个人总结,可能说的不好,可能有错误,或者还是不完善,很多遗漏的地方,那么就欢迎你们来补充啦。