互联网寒冬,面试八股牢记于心。话虽如此,巩固并吃透一个知识点才是正道。 技术飞速迭代的今天,当你学不动的时候,停下脚步歇歇,回头看看也未尝不可。 作为一个正在接受社会毒打的前端工程师,以下是个人收集并归纳总结的一些面试常见问题, 问题的相关细节会放在括号中,方便拓展和举一反三。

JavaScript 核心

javascript 有哪些原始类型

Undefined,Null,Boolean,Number,String,Symbol,Bigint (一项新的提案让这个答案可能增加 Record 和 Tuple 这两个不可变数据类型)

执行上下文和作用域链

变量或者函数的执行上下文决定了他们可以访问那些数据,每个函数都有自己的执行上下文,当函数被执行的时候, 它的上下文会被推入 执行栈,并创建一个作用域链,执行时沿着作用域链查找变量, 函数执行完毕后,执行上下文也会被弹出。

闭包

闭包是指那些引用了另一个函数作用域中的变量的函数,通常在嵌套函数中实现。内部的函数作为参数传递或结果返回, 仍能访问到其所在的外部函数的变量。闭包形成的原因是当一个函数执行完毕时,将会销毁其执行上下文以及附带的活 动对象, 但由于外部函数的活动对象已被添加到内部函数的作用域链中,故无法被销毁,仍然保留在内存中,供内部函 数使用。 闭包的使用场景包括防抖、节流、单次调用函数等。

尾调用优化

尾调用优化是 ES6 规范新增的内存管理优化机制(严格模式下开启),当一个函数的返回值是是它内部返回值的时候, 通过重用执行栈栈帧的方式优化内存管理(内部函数执行上下文执行完毕后不会直接弹出,而是先判断它的外部栈帧 是否有存在必要)。

构造函数

任何函数只要通过new操作符调用就是构造函数(一个函数也可以通过new.target来判断自己是否最为构造函数 被调用)。当使用new操作符执行构造函数时,js 解释器会在内存中新建一个对象,并将该对象的__proto__属性 赋值为构造函数的原型对象prototype,然后构造函数内部的this被赋值为这个新对象,执行完内部代码后返回 这个对象(如果构造函数直接返回了一个对象,除非手动操作否则原型链会断掉)。

原型和原型链

每个函数(包括 ES6 类)都会创建一个prototype属性指向它的原型对象,原型对象的constructor属性也会 指向这个函数(或类),当这个函数作为构造函数使用,实例化出一个对象时,这个实例对象的__proto__属性也会 指向构它的原型对象。当一个函数的原型对象是另一个构造函数的实例时,就会形成原型链。

为什么 0.1+0.2 不等用 0.3

任何使用了 IEEE754 浮点规范的语言都会存在这个问题,双精度浮点数的可靠位为 15 位,16 位之后的可能是对不上的。 0.1 和 0.2 储存值都比实际值要大一些,所以结果不等于 0.3,比较小数是否相当,应该使用两者的差值与 ES6 新增的 Number.EPSILON属性比较:

if (0.1 + 0.2 - 0.3 < Number.EPSILON) {
  console.log(`0.1+0.2=0.3`);
}

javascript 如何实现继承

原型链、借用构造函数、组合继承、原型式继承、寄生式继承、寄生式组合继承。详见这里

自己实现call或者apply

Function.prototype.myApplay = function (newThis, argArray) {
  const tempObj = newThis ?? window;
  const funcSymb = Symbol("tempFunc");
  // 当一个函数作为对象的属性调用时,函数内this指向这个对象
  // 利用这点来达到绑定传入的newThis的目的
  tempObj[funcSymb] = this;
  tempObj[funcSymb](...argArray);
};

自己实现bind

Function.prototype.myBind = function (newThis) {
  const self = this;
  // 利用闭包,绑定this
  return function () {
    self.apply(newthis, arguments);
  };
};

浏览器与 javaScript

HTTP、HTTPS 和 HTTP2

  • HTTPS = HTTP + SSL/TLS,(TLS 是 SSL 标准化后的产物)
  • SSL 使用非对称加密,对称指的是加密解密使用同一密钥,非对称使用不同密钥
  • HTTPS 证书中包含了公钥,发送数据时会使用该公钥加密,接受端使用私钥解密,加大了破解成本,提高安全性
  • HTTPS 加密和解密过程会有一定程度的性能损耗
  • 目前广泛使用的时 HTTP 协议版本为 1.1
  • HTTP2 建立在 HTTPS 协议的基础上,安全
  • HTTP2 通过二进制分帧来进行数据传输,高效
  • HTTP2 一个域建立一次 TCP 链接,使用多路复用和连接共享,没有 HTTP1 中同个域的并发限制 (得益于分帧机制,帧可以乱序发送,不再依赖多个 TCP 实现并行)

HTTP 缓存机制

浏览器每次发起请求,都会现在浏览器缓存中查找该请求的结果以及缓存标识,且每次拿到结果,都会将该结果 和缓存标识存入浏览器缓存中,而这个缓存过程又分为强制缓存和协商缓存。服务端控制缓存规则的字段包括 Expires(HTTP1.0 的字段,客户端对比时间存在缺陷,已被后者取代)和Cache-Control(优先级更高), Cache-Control的取值有:

  • public 所有内容都被缓存
  • private 默认值,所有内容只有客户端可以缓存
  • no-cache 客户端缓存内容,但是否使用缓存要经过协商缓存验证
  • no-store 所有内容都不会被缓存
  • max-age=time 缓存内容在 time 秒后失效

当发送请求时,不存在缓存结果或上述标志,则直接向服务器发起请求,如果有这是缓存标志,且存在缓存结果, 则直接返回缓存结果(强制缓存生效,取缓存先内存后硬盘),如果缓存结果失效,则携带缓存标志向服务器请求, 服务器可能会返回 304(协商缓存生效), 浏览器直接使用缓存,也可能返回 200(协商了决定给新的), 浏览器使用新的结果。

协商缓存的过程中,请求会携带一些信息帮助浏览器判断:

  • last-modified-since 上一次请求的返回体中的 last-modified 值
  • if-none-match 上一次请求的返回体中的 etag 标识

跨域

协议、域名、端口任一不同即为跨域,浏览器的同源策略,不限制跨域请求的发送,但会拦截请求响应, 常用的跨域解决办法:

  • CORS,服务端设置跨域原资源共享
  • JSONP,浏览器告诉服务一个返回函数的名称,服务在返回的 script 里调用这个回调函数,同时传进客户端需要的数据, 这样返回的代码就能在浏览器上执行了
  • 子域名跨父域名时,通过更改document.domain为父域名即可
  • 嵌套 iframe 跨父窗口,父域无法访问不同源的 iframe 内容,但 iframe 可以向上调用window.parent, 两者间也可以通过postMessageAPI 来通信

默认情况下跨域 ajax 请求不会携带cookie,除非设置withCredentials属性为true

返回报文的Set-Cookie可以设置浏览器cookie

  • name=value 普通键值对
  • Domain 指定 cookie 有效的域名,发送到这个域名的请求都会携带 cookie,可以指定是否包含子域名
  • Path 指定使用 cookie 的路径,请求 URL 包含这个路径才会携带 cookie
  • Expires 过期时间,即什么时间之后就不要携带 cookie 了
  • Secure 安全标志,设置之后只有使用了 SSL 安全连接的情况才会携带 cookie
  • SameSite 可设置三个值
    • Strict 完全禁止第三方 cookie
    • Lax 默认值,仅发送连接、预加载还有 GET 请求的 cookie
    • None 关闭该设置

事件循环

JavaScript 设计之初就是一门处理浏览器网页交互的脚本语言,这决定了它注定是一门单线程语言 (多个线程同时操作 ui 是很麻烦的事情)。浏览器式多线程的,当 js 主线程调用setTimeoutaddEventLisenter之类的方法时,会触发其他线程(定时器触发线程、事件触发线程),以此来实现 异步非阻塞。其他线程执行完毕后会将对应的额回调函数较为任务队列维护,当 js 主线程执行栈为空时, 从这个队列中依次去除任务(回调函数)执行。新一轮的执行若有异步任务则重复以上步骤,这个过程称之为 事件循环。

宏任务与微任务

  • 宏任务:I/O、setTimeout、setInterval、setImmediate、requestAnimationFrame、 requestIdleCallback(浏览器空闲时段调用)、ajax
  • 微任务:process.nextTick(node 中)、promise、MutationObserver、MessageChannel

首屏性能优化

  • 尽量减少请求次数
  • gzip 压缩静态资源
  • dns 预连接<link rel="dns-prefetch" href="url">
  • cdn 加速资源
  • 优化缓存机制(更新频率低的可以加大缓存时间)
  • 使用 http2
  • 图片懒加载
  • 优化 dom 结构,减少无用节点,避免页面重绘
  • 提高首屏加载代码的覆盖率,想办法剔除为使用的代码

浏览器渲染过程

  • HTML->DOM 树,CSS->CSSOM 规则树
  • DOM 树+CSSOM 规则树=渲染树
  • 遍历渲染树,先开始布局
  • 绘制节点

浏览器加载 js

  • 默认情况下同步加载,阻塞渲染
  • defer表示延迟执行脚本,解析到 script 标签时立即下载,等待渲染完成后再执行,并保证按出现顺序执行
  • async表示异步执行脚本,解析到 script 标签时立即下载,下载完就执行(不管浏览器是否渲染完成,可能造成阻塞) 不保证执行顺序
  • type="module"表示 ES6 模块,浏览器会异步加载,解析到 script 标签时立即下载,等待渲染完成后再执行, (它会受到async的影响,可能阻塞渲染)。

框架

MVC MVP MVVM…

  • MVC
    • Model 业务数据,在更新时会通知相应的观察者(View)
    • View 视图,负责 UI 交互,是 Model 的可视化表示,它会观察相应的 Model
    • Controller 控制器,时 Model 和 View 的中介,用户操作 View 时,它通常负责更新 Model
  • MVP
    • View 和 Model 不再练习,通过 Presenter 传递
    • View 转换为被动形态,没有任何业务逻辑
    • Presenter 表示器,几乎所有的逻辑都在这里
  • MVVM
    • 与 MVP 基本一致,Presenter 变成了 ViewModel
    • ViewModel 视图模型,采用了双向绑定,View 的变动自动更新到了 ViewModel,反之亦然
    • Model 更多是由 ViewModel 负责

Vue2

Vue 源码结构

自底向上分析,以下是 Vue 导出的全过程

  1. Vue 是一个函数类,构造函数内只有一个this._init(options) (开发模式下会判断是否作为构造函数调用,直接调用Vue方法会报错)
  2. 调用各种混入方法,传入Vue,增强(或者说组合)Vue.prototype能力:
    1. initMixin,该混入声明了Vue.prototype._init,该方法负责一系列的初始化操作 (以下过程发生在实例化中):
      1. initLifecycle(vm)
      2. initEvents(vm)
      3. initRender(vm)
      4. callHook(vm, 'beforeCreate')
      5. initInjections(vm) // resolve injections before data/props
      6. initState(vm)
      7. initProvide(vm) // resolve provide after data/props
      8. callHook(vm, 'created')
    2. stateMixin,加入了数据响应相关的能力:
      1. Vue.prototype上创建dataprops选项的代理访问$data$props
      2. Vue.prototype上增加$set$delete$watch
    3. eventsMixin,事件相关的能力:
      1. Vue.prototype.$on
      2. Vue.prototype.$once
      3. Vue.prototype.$off
      4. Vue.prototype.$emit
    4. lifecycleMixin,生命周期:
      1. Vue.prototype._update
      2. Vue.prototype.$forceUpdate
      3. Vue.prototype.$destroy
    5. renderMixin,渲染相关:
      1. Vue.prototype上安装渲染辅助工具,(挂载了原型上的_o_n_s之类的属性上)
      2. Vue.prototype.$nextTick
      3. Vue.prototype._render
  3. 调用initGlobalApi(Vue)加入 Vue 全局方法或属性(函数类的静态方法或者属性):
    1. Vue.config ,全局配置
    2. Vue.util,包含warnextendmergeOptionsdefineReactive方法
    3. Vue.setVue.deleteVue.nextTick,与原型上的是一样的
    4. Vue.observeable,2.6 版本的 API,时一个对象可响应
    5. Vue.options,初始化了componentsdirectivesfilters空值选项,以及_base=Vue, 并增加内置组件KeepAlive
    6. 调用initUse,增加插件拓展能力Vue.use(判断插件是否有install方法以及是否重复安装)
    7. 调用initMixin,增加混入能力Vue.mixin(实际上就是将混传入的options与当前options, 会影响所有vm实例,通常在插件中使用,Vue.useVue.mixin方法都返回了this,支持链式调用)
    8. 调用initExtend,增加继承能力Vue.extend(用于创建Vue的子类,会在内部缓存)
    9. 调用initAssetRegisters,增加Vue.componentVue.directivesVue.filters 这三个资源注册能力
  4. 增加Vue.protoype环境相关标记$isServer$ssrContext, 以及全局标记Vue.FunctionalRenderContextVue.version

以上为核心 Vue 的导出过程,接下来是导出完整的运行时版本 Vue,从这一步开始,就会区分 web 和 weex 平台了 (各平台的视图相关操作不尽相同),以下以 web 为例:

  1. Vue.config上继续添加一些相关标记
  2. 添加平台内置的指令v-modelv-show和组件TransitionTransitionGroup
  3. 判断是否浏览器环境,绑定patch方法到Vue.prototype.__patch__
  4. 增加Vue.prototype.$mount方法(其实就是调用mountCommponent方法)

如果是待编译器的版本,会在上述基础上继续:

  1. 将运行时 Vue.prototype.$mount 方法取出暂存,重写$mount
    1. 检查挂载的根元素,不能是html或者body
    2. 检查渲染函数,没有渲染函数时检查template并转换为渲染函数,没有template则取挂载元素的outerHTML作为template
    3. 继续调用原本的$mount
  2. 增加Vue.compile = compileToFunctions

Vue 的实例化过程

Vue 的构造函数中只调用了this._initnew Vue()这个过程详细展开为:

  1. 合并配置项,将默认配置与构造函数传入配置合并,赋值给vm.$options
  2. initLifecycle(vm) 初始化生命周期:
    1. 挂载父节点和跟节点
    2. 初始化vm.$children=[]vm.$refs={}
    3. 初始化vm上生命周期的相关标记, _watcher_inactive_directInactive_isMounted_isDestroyed_isBeingDestroyed
  3. initEvents(vm) 初始化事件:
    1. 初始化vm._events vm._hasHookEvent
    2. 判断父组件是否有事件监听,有的话进行相应的更新
  4. initRender(vm) 初始化渲染:
    1. 初始化vm._vnodevm._staticTrees标记
    2. 初始化vm.$slotvm.$scopedSlots
    3. createElementvm._cvm.$createElement(alwaysNormalize参数不同)
    4. 调用defineReactive注册vm实例响应对象vm.$attrsvm.$listeners
  5. callHook(vm, 'beforeCreate') 触发 beforeCreate 钩子
  6. initInjections(vm) // resolve injections before data/props
    1. 读取vm.$options上的injection配置
    2. 关闭全局shouldObserve开关
    3. 遍历injection,对每个key使用defineReactive
    4. 打开全局shouldObserve开关
  7. initState(vm) 初始化状态:
    1. 初始化vm._watchers=[]
    2. initProps
      1. 判断是否根节点,不是的话关闭全局shouldObserve开关
      2. 遍历propsOptions
        1. 判断响应的是否propsData是否满足条件(类型,验证等)
        2. defineReactive注册响应对象
        3. 创建vmprops访问代理vm._props.xxx
      3. 打开全局shouldObserve开关
    3. initMethods,遍历metheds里的方法,对每个方法使用bind,绑定vm执行上下文 (这也是方法中this能访问到vm的原因)
    4. initData
      1. 创建vmdata访问代理vm._data.xxx
      2. 观察数据,observe(data,true)
    5. initComputed
      1. 初始化vm._computedWatcher
      2. 遍历计算属性,调用defineComputed,非 ssr 情况会生成新的watcher赋值给vm._computedWatcher.xxx
    6. initWatch,遍历watch创建相应的watcher
  8. initProvide(vm) // resolve provide after data/props
  9. callHook(vm, 'created') 触发 created 钩子
  10. 判断vm.$options.el是否存在,是则调用vm.$mount进行挂载

Vue 的挂载过程

  1. 检查vm.$options是否有render,没有的话调用createEmptyVNode生成空节点
  2. 触发beforeMount钩子
  3. 声明updateComponent回调函数updateComponent = () => { vm._update(vm._render(), hydrating)}
    1. vm._render()->vm.$createdElement->createElement
  4. 实例化Watcher,传入updateComponent回调,并注入beforeUpdate钩子(初始化时,默认会执行`updateComponent 钩子)
  5. 标记vm._isMounted,触发mounted钩子

响应式原理

再实例挂载的过程中,创建了一个watcher,他的回调updateComponent会立即执行一次,调用vm._render() 的过程中会触发响应式数据的getter,进行一次依赖收集,当有更新时则会派发更新。

响应式的核心是WatcherObserverDep(经典的观察者模式),大致的流程为

Vue 的实例化过程中,对propsdata等都进行了defineReactive操作,该操作就是给定义一个响应式对象, 给对象动态添加gettersetter

defineReactive创建响应式对象的过程中,会实例化一个DepDep类实际上是对Watcher的一种管理, 同时Dep类上有一个静态属性target,指向一个全局唯一的WatcherWatcher类也会维护需要更新的 Dep队列,当响应式对象的getter触发时,会把自己的dep实例加入到目标watcherdep实例队列, 同时也会把watcher实例加入到depsubs队列,这个过程则是依赖收集。

实际上Watcher类维护了两个dep实例数组,一个表示新添加的,另一个表示上一次添加的

当修改响应式对象时,则会触发相应的setter,它会调用depnotify方法,然后依次调用def实例中的 subs也就是watcher实例数组的update方法,将需要更新的回调加到一个队列里,在nextTick时后执行

diff 算法

vue 和 react 类似,在对比新旧节点树时都不会整棵树比较,而是同级比较。同层的比较采用的双端比较的算法… // TODO

Vue 是怎么实现组件化的

从底层代码来看,我觉得组件化的核心是Vue.extend

自定义指令

配置钩子 bind -> inserted -> updated -> componentUpdated -> unbind

v-model

v-model本质上是语法糖:传入value属性,并在事件(具体事件因空间类型而异)中触发是更新相应的值。 如果要给自定义的组件添加v-model,配置组件的model: {prop: PropName, event: EventName}对象即可

插槽原理

普通插槽是在父组件和渲染阶段生成 vnodes,故数据的作用域是父组件实例,子组件渲染的时候直接拿到这些渲染好的vnodes; 作用域插槽在父组件的编译和渲染阶段都不会直接生成vnodes,而是在父节点中保留scopedSlots对象,该对象储存者不同 名称的插槽以及对应的渲染函数,在编译和渲染子组件阶段执行渲染函数生成vnodes,由于是在子组件环境中执行的,所以对应的 数据作用域是子组件。来自 vue 技术揭秘

keep-alive

<keep-alive>是内置的抽象组件,该组件是通过插槽和自定义渲染函数来实现的,并且该组件会缓存vnode,在patch的过程中 已缓存的组件不会再次触发mounted钩子,而是activateddeactivated,可以通过inclueexclude来控制缓存行为

vue-router 原理

vuex 原理

Vue3

diff 算法

编译模板时,会打上pathFlag,标记静态节点以及各种不同情况,优化 diff 效率

React

服务端渲染

Node

打包&构建&工程化

聊一聊前端工程化

很开放的问题,不太属于八股文范畴了,以下是网上冲浪加上一些自己理解的总结:

  • git 版本控制
  • 规范约束
    • 分支规范
    • 版本规范
    • CSS 规范(BEM、ATOM)
  • lint 校验、prettier 风格统一、husky 钩子流程做预检
  • 文档
  • 单元测试
  • 模块化
  • 自动化流程:打包、构建、发布、部署、性能测试
  • 环境区分:开发、测试、预发、线上
  • 线上监控报警
  • bug 修复流程

webpack

文件指纹

文件指纹主要用于版本迭代管控以及浏览器的缓存访问行为,文件指纹分为:

  • Hash 与整个项目有关,compilation实例变化就会改变
  • ChunkHash 和 entry 相关,不同的模块会有不同的ChunkHash
  • ContentHash 和文件内容有关,文件保持自己的独立更新

esbuild

rollup

vite

其他

低代码平台

微前端框架