面试合集

212 阅读53分钟

JS

JS 数据类型

JavaScript共有八种数据类型,分别是 String、Number、Boolean、Null、Undefined、Symbol、BigInt、Object

其中 SymbolBigInt 是ES6 中新增的数据类型:

  • Symbol: 代表创建后独一无二且不可变的数据类型,它主要是为了解决可能出现的全局变量冲突的问题。
  • BigInt: 是一种数字类型的数据,它可以表示任意精度格式的整数,使用 BigInt 可以安全地存储和操作大整数,即使这个数已经超出了 Number 能够表示的安全整数范围。

这些数据可以分为原始数据类型引用数据类型(复杂数据类型) ,他们在内存中的存储方式不同。

  • 堆: 存放引用数据类型,引用数据类型占据空间大、大小不固定。如果存储在栈中,将会影响程序运行的性能;引用数据类型在栈中存储了指针,该指针指向堆中该实体的起始地址,如ObjectArrayFunction
  • 栈: 存放原始数据类型,栈中的简单数据段,占据空间小,属于被频繁使用的数据,如StringNumberBooleanNull

Symbol 使用场景

  • 定义对象的私有属性:Symbol 值作为属性名是唯一的,可以防止属性名的冲突,防止属性被意外修改。
  • 定义常量
  • 定义枚举

原型

原型的作用

原型的作用是实现面向对象

一个能支持面向对象的语言必须做到一点:能判定一个实例的类型。在 JS 中,通过原型就可以知晓某个对象从属于哪个类型,换句话说,原型的存在避免了类型的丢失

原型链

每个实例对象都有一个__proto__属性指向它的构造函数的原型对象,而这个原型对象也会有自己的原型对象,一层一层向上,直到顶级原型对象null,这样就形成了一个原型链。

当访问对象的一个属性或方法时,当对象身上不存在该属性方法时,就会沿着原型链向上查找,直到查找到该属性方法位置。

原型链的顶层原型是Object.prototype,如果这里没有就只指向null

对作用域、作用域链的理解

作用域是一个变量或函数的可访问范围,作用域控制着变量或函数的可见性和生命周期。

  1. 全局作用域:可以全局访问

    • 最外层函数和最外层定义的变量拥有全局作用域
    • window上的对象属性方法拥有全局作用域
    • 为定义直接复制的变量自动申明拥有全局作用域
    • 过多的全局作用域变量会导致变量全局污染,命名冲突
  2. 函数作用域:只能在函数中访问使用哦

    • 在函数中定义的变量,都只能在内部使用,外部无法访问
    • 内层作用域可以访问外层,外层不能访问内存作用域
  3. ES6中的块级作用域:只在代码块中访问使用

    • 使用ES6中新增的letconst什么的变量,具备块级作用域,块级作用域可以在函数中创建(由{}包裹的代码都是块级作用域)
    • letconst申明的变量不会变量提升,const也不能重复申明
    • 块级作用域主要用来解决由变量提升导致的变量覆盖问题

作用域链: 变量在指定的作用域中没有找到,会依次向一层作用域进行查找,直到全局作用域。这个查找的过程被称为作用域链。

闭包

juejin.cn/post/728966…

闭包是指有权访问另一个函数作用域中变量的函数,创建闭包的最常见的方式就是在一个函数内创建另一个函数,创建的函数可以访问到当前函数的局部变量。

闭包优点:

  • 创建全局私有变量,避免变量全局污染
  • 可以实现封装、缓存等

闭包缺点:

  • 闭包对外部函数有引用时,若闭包被调用且未及时解绑,则会造成外部函数的变量无法被释放,导致内存泄露

  • 闭包涉及作用域链查找,性能相较直接访问局部、全局变量要低一些,在一些频繁调用或要求高性能的场景不适用

  • 闭包可以访问外部函数中的私有变量,这可能导致信息泄露和安全问题。如果闭包被滥用或不当使用,可能会导致数据被意外泄露给未授权的代码。

使用场景:

  • 用于创建全局私有变量
  • 封装类和模块
  • 实现函数柯里化

闭包一定会造成内存泄漏吗

闭包并不一定会造成内存泄漏,如果在使用闭包后变量没有及时销毁,可能会造成内存泄漏的风险。只要合理的使用闭包,就不会造成内存泄漏。

哪些情况会导致内存泄漏

  • 意外的全局变量:由于使用未声明的变量,而意外的创建了一个全局变量,而使这个变量一直留在内存中无法被回收。
  • 被遗忘的计时器或回调函数:设置了 setInterval 定时器,而忘记取消它,如果循环函数有对外部变量的引用的话,那么这个变量会被一直留在内存中,而无法被回收。
  • 脱离 DOM 的引用:获取一个 DOM 元素的引用,而后面这个元素被删除,由于一直保留了对这个元素的引用,所以它也无法被回收。
  • 闭包:不合理的使用闭包,从而导致某些变量一直被留在内存当中。

this 指向

默认情况下,this 指向全局对象,比如在浏览器就是指向 window,在 node 环境指向一个空对象

函数中的 this 指向,取决于函数是如何被调用的

调用方式示例函数中的 this 指向
通过 new 调用new method()新对象
直接调用method()全局对象
通过对象调用obj.method()前面的对象
call、apply、bindmethod.call(ctx)第一个参数

箭头函数 this 指向 juejin.cn/post/720100…

  • 箭头函数没有自己的 this 指向,它的 this 指向上一级作用域的 this
  • 箭头函数的 this 指向在创建时就确定了,而不是在函数被调用时确定

总结

  1. this 只有在函数调用的时候才会产生,就是函数的调用者
  2. 如果是在函数执行的时候创建箭头函数,当函数执行时会产生一个 this,箭头函数的 this 在创建的时候就指定,且不可更改,这时候就指向函数的 this
  3. 如果是在对象里面声明对象属性的时候创建的箭头函数,这时候对象里面没有 this 产生,就按照函数作用域向上寻找 this,找到就绑定并使用,找不到最后就指向 window

浅拷贝和深拷贝

浅拷贝

  • 扩展运算符
  • Object.assign()

深拷贝

  • 递归拷贝
  • JSON.stringify() 和 JSON.parse()

其它思路

  • 使用 Object.assign() 方法实现浅拷贝,然后对于每个属性值是引用类型的属性,再递归调用深拷贝函数。

  • 使用 ES6 的扩展运算符(…)实现浅拷贝,然后对于每个属性值是引用类型的属性,再递归调用深拷贝函数。

  • 使用第三方库,如 Lodash 的 _.cloneDeep() 方法,该方法能够递归地深拷贝一个对象。

function deepCopy(obj) {
  if (typeof obj !== 'object' || obj === null) {
    return obj;
  }
  const newObj = Array.isArray(obj) ? [] : {};
  for (let key in obj) {
    newObj[key] = deepCopy(obj[key]);
  }
  return newObj;
}

使用 JSON.stringify() 和 JSON.parse() 方法进行深拷贝是一种常见的错误做法。虽然这种方法能够将一个对象序列化为 JSON 字符串,再将 JSON 字符串解析为一个新对象,但是存在以下几个问题:

  • 该方法只能序列化对象中的可枚举属性,不能序列化对象的原型链和方法。
  • 如果对象中有循环引用(即一个对象引用了自身),则该方法会抛出错误。
  • 该方法不能序列化 RegExp、Date、Map、Set 等特殊类型的对象,会将其序列化为字符串或空对象。

因此,使用 JSON.stringify() 和 JSON.parse() 方法进行深拷贝并不可靠,建议使用其他方法实现深拷贝。

为什么用 void 0 代替 undefined

因为直接使用 undefined 不安全,代码中可以定义 undefined 变量并修改它的值,这样判断 undefined 会出错

// void 0 可以安全的获取 undefined
// void 后面跟上任何一个值,都是返回 undefined
function foo(arg1, agr2) {
  // 如果 arg1 !== undefined,返回 arg1,否则返回 arg2
  var res = arg1 !== void 0 ? agr1 : arg2
  return res
}

// 报错,null 关键字不能作为变量使用
var null

// 生命 undefined 关键字为变量不会报错,还可以修改它的值
var undefined = 'xx'

事件循环 Event Loop

👉👉 做一些动图,学习一下EventLoop

宏任务

浏览器Node
整体代码(script)
UI交互事件
I/O
setTimeout
setInterval
setImmediate
requestAnimationFrame

微任务

浏览器Node
process.nextTick
MutationObserver
Promise.then catch finally

事件循环流程

  1. 从宏任务队列中,按照入队顺序,找到第一个执行的宏任务,放入调用栈,开始执行;
  2. 执行完该宏任务下所有同步任务后,即调用栈清空后,该宏任务被推出宏任务队列,然后微任务队列开始按照入队顺序,依次执行其中的微任务,直至微任务队列清空为止
  3. 当微任务队列清空后,一个事件循环结束;
  4. 接着从宏任务队列中,找到下一个执行的宏任务,开始第二个事件循环,直至宏任务队列清空为止。

这里有几个重点:

  • 当我们第一次执行的时候,解释器会将整体代码script放入宏任务队列中,因此事件循环是从第一个宏任务开始的;
  • 如果在执行微任务的过程中,产生新的微任务添加到微任务队列中,也需要一起清空;微任务队列没清空之前,是不会执行下一个宏任务的。

new 的过程发生了什么


使用 new 运算符创建对象时,会经历以下步骤:

  • 创建一个空对象:创建一个新的空对象,该对象将成为实例化后的对象。
  • 将原型连接到对象:将新创建的对象的原型指向构造函数的原型对象,通过原型链实现继承。
  • 执行构造函数:将构造函数作为普通函数调用,传入新创建的对象作为执行上下文(this)。在构造函数内部,可以使用 this 关键字引用新创建的对象,并且可以添加实例属性和方法。
  • 返回对象:如果构造函数没有显式返回一个对象,则返回新创建的对象;否则,如果构造函数返回的是一个对象,则返回该对象。

图片懒加载

  • 最简单的实现方式是给 img 标签加上 loading="lazy"
  • 把图片的地址放入到 data-src 属性里,然后监听图片是否进入可视区域内,把 data-src 赋值给 src
  • 通过 Intersection Observer(交叉观察器) juejin.cn/post/720068…

上传文件时获取上传进度

juejin.cn/post/719512…

ES6

ES6 模块和 CommonJS 模块有什么区别

blog.csdn.net/qq_45890970…

  • 语法不同:ES6 模块使用 importexport 关键字来导入和导出模块,而 CommonJS 模块使用 requiremodule.exportsexports 来导入和导出模块。

    js
    复制代码
    // ES6 模块
    import { foo } from './module';
    export const bar = 'bar';
    
    // CommonJS 模块
    const foo = require('./commonjs');
    exports.bar = 'bar';
    
  • CommonJS 模块输出的是一个值的拷贝,即一旦输出一个值,模块内部的变化就影响不到这个值,而 ES6 模块输出的是值的引用

  • 异步加载: ES6 模块支持动态导入(dynamic import),可以异步加载模块。这使得在需要时按需加载模块成为可能,从而提高了性能。CommonJS 模块在设计时没有考虑异步加载的需求,通常在模块的顶部进行同步加载。

  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口

CommonJS 规范加载模块是同步的,只有加载完成,才能执行后面的操作。由于 Node.js 主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑非同步加载的方式,所以 CommonJS 规范比较适用。

如果是浏览器环境,要从服务器端加载模块,这时就必须采用异步模式。浏览器加载 ES6 模块是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完,再执行模块脚本

CommonJS 加载的是一个对象(即 module.exports 属性),该对象只有在脚本运行完才会生成

ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成

因此 CommonJS 无法支持 tree shaking,而 ES6 模块支持

箭头函数和普通函数区别

  • 语法不同:箭头函数使用箭头符号(=>)来定义函数,普通函数使用关键字 function 来定义。
  • this 的指向不同:箭头函数没有自己的 this,它会继承父级作用域中的 this 值,而普通函数中的 this 则是函数被调用时动态确定的,它的值取决于调用函数的方式。
  • 无法使用 arguments 对象:箭头函数没有自己的 arguments 对象,因此在箭头函数中使用 arguments 会引用外部作用域的 arguments。
  • 不能用作构造函数:箭头函数不能使用 new 关键字来创建实例,因为它们没有自己的 this,也没有原型对象。
  • 没有原型:箭头函数没有 prototype 属性,因此不能通过它来定义方法。

Set、Map

Set

  • 创建: new Set([1, 1, 2, 3, 3, 4, 2])
  • add(value):添加某个值,返回Set结构本身。
  • delete(value):删除某个值,返回一个布尔值,表示删除是否成功。
  • has(value):返回一个布尔值,表示该值是否为Set的成员。
  • clear():清除所有成员,没有返回值。

Map

  • set(key, val):Map中添加新元素
  • get(key): 通过键值查找特定的数值并返回
  • has(key): 判断Map对象中是否有Key所对应的值,有返回true,否则返回false
  • delete(key): 通过键值从Map中移除对应的数据
  • clear(): 将这个Map中的所有元素删除

map 和 set 的区别

  • Map是一种键值对的集合,和对象不同的是,键可以是任意值
  • Map可以遍历,可以和各种数据格式转换
  • Set是类似数组的一种的数据结构,类似数组的一种集合,但在Set中没有重复的值

map 和 Object 的区别

mapObject都是用键值对来存储数据,区别如下:

  • 键的类型Map 的键可以是任意数据类型(包括对象、函数、NaN 等),而 Object 的键只能是字符串或者 Symbol 类型。
  • 键值对的顺序Map中的键值对是按照插入的顺序存储的,而对象中的键值对则没有顺序。
  • 键值对的遍例Map 的键值对可以使用 for...of 进行遍历,而 Object 的键值对需要手动遍历键值对。
  • 继承关系Map 没有继承关系,而 Object 是所有对象的基类。

map 和 weakMap 的区别

它们是 JavaScript 中的两种不同的键值对集合,主要区别如下:

  • map的键可以是任意类型,weakMap键只能是对象类型。
  • map 使用常规的引用来管理键和值之间的关系,因此即使键不再使用,map 仍然会保留该键的内存。weakMap 使用弱引用来管理键和值之间的关系,因此如果键不再有其他引用,垃圾回收机制可以自动回收键值对。

Promise

在 Promise A+ 规范定义,Promise 是一个带有 then 方法的对象/函数,只要能够满足规范,就是 Promise。ES6 中,Peomise 是一个构造函数,是对 Promise A+ 规范的实现。

Promise是异步编程的一种解决方案,将异步操作以同步操作的流程表达出来,避免了地狱回调。

Promise 实例三个状态:

  • Pending(初始状态)
  • Fulfilled(成功状态)
  • Rejected(失败状态)

Promise 实例两个过程:

  • pending -> fulfilled : Resolved(已完成)
  • pending -> rejectedRejected(已拒绝)

注意:一旦从进行状态变成为其他状态就永远不能更改状态了,其过程是不可逆的。

Promise构造函数接收一个带有resolvereject参数的回调函数。

  • resolve的作用是将Promise状态从pending变为fulfilled,在异步操作成功时调用,并将异步结果返回,作为参数传递出去
  • reject的作用是将Promise状态从pending变为rejected,在异步操作失败后,将异步操作错误的结果,作为参数传递出去

Promise 缺点:

  • 无法取消 Promise,一旦新建它就会立即执行,无法中途取消。
  • 如果不设置回调函数,Promise内部抛出的错误,不会反应到外部。
  • 当处于pending状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。

Promise 方法:

  • promise.then() 对应resolve成功的处理
  • promise.catch()对应reject失败的处理
  • promise.all()可以完成并行任务,将多个Promise实例数组,包装成一个新的Promise实例,返回的实例就是普通的Promise。有一个失败,代表该Primise失败。当所有的子Promise完成,返回值时全部值的数组
  • promise.race()类似promise.all(),区别在于有任意一个完成就算完成
  • promise.allSettled() 返回一个在所有给定的 promise 都已经 fulfilledrejected 后的 promise ,并带有一个对象数组,每个对象表示对应的promise 结果。

promise.all 和 promise.allsettled 区别

Promise.all()Promise.allSettled() 都是用来处理多个 Promise 实例的方法,它们的区别在于以下几点:

  • all: 只有当所有Promise实例都resolve后,才会resolve返回一个由所有Promise返回值组成的数组。如果有一个Promise实例reject,就会立即被拒绝,并返回拒绝原因。all是团队的成功才算,如果有一个人失败就算失败。
  • allSettled: 等所有Promise执行完毕后,不管成功或失败, 都会吧每个Promise状态信息放到一个数组里面返回。

对async/await 的理解

async/await其实是 Generator 的语法糖,它能实现的效果都能用then链来实现,它是为优化then链而开发出来的。通过async关键字声明一个异步函数, await 用于等待一个异步方法执行完成,并且会阻塞执行async 函数返回的是一个 Promise 对象,如果在函数中 return 一个变量,async 会把这个直接量通过 Promise.resolve() 封装成 Promise 对象。如果没有返回值,返回 Promise.resolve(undefined)

async/await对比Promise的优势

  • 代码可读性高,Promise虽然摆脱了回调地狱,但链式调用会影响可读性
  • 相对Promise更优雅,传值更方便
  • 对错误处理友好,可以通过try/catch捕获,Promise的错误捕获⾮常冗余

CSS

常用滤镜效果 filter(阴影、磨砂玻璃)

// 高斯模糊
filter: blur(10px)

// 黑白页面
filter: grayscale(1)

// 阴影(只针对像素点,而不是整张图片)
filter: drop-shadow(10px 10px 10px rgba(0, 0, 0, 0.5))

// 元素设置毛玻璃
background: rgba(255, 255, 255, 0.2) // 背景设置透明
backdrop-filter: blur(5px) // 不对本元素造成影响,转换的是元素背后

如何实现单行/多行文本溢出的省略样式

👉👉 如何实现单行/多行文本溢出的省略样式?

CSS3 中的伪类和伪元素的区别

CSS 中,伪类和伪元素都是用来选择 DOM 元素的特殊方式,但它们有一些区别。

伪类是用于选择文档树中的某些特定状态的元素,例如 hoveractivevisited等。它们是以冒号( : )表示的,并通过在选择器中添加伪类来使用。伪类的语法是在选择器后面使用冒号加上伪类的名称,比如a:hoverinput:checked等。

伪元素用于在文档中的某些特定位置插入内容,例如在元素的前面、后面、内部的某个位置。它们是以双冒号( :: )表示的,并通过在选择器中添加伪元素来使用。伪元素的语法是在选择器后面使用双冒号加上伪元素的名称,比如::before::after等。

总的来说,伪类用于选择元素的某种状态,而伪元素用于插入额外的内容或样式。使用伪类可以改变元素的样式,使用伪元素可以在元素的特定位置插入内容。

Vue

SPA 的理解,有什么优缺点

SPA(单页应用)是一种前端应用程序的架构模式,它通过在加载应用程序时只加载单个 HTML 页面,并通过使用 JavaScript 动态地更新页面内容,从而实现无刷新的用户体验。

优点

  • 用户体验
  • 响应式交互
  • 代码复用(组件化开发)
  • 减小服务器压力

缺点

  • 首次加载慢(打包体积大、网络请求多)
  • SEO 问题(服务端渲染、预渲染)
  • 内存占用

SPA 首屏为什么加载慢?

SPA首屏加载慢可能有以下原因:

  • JavaScript文件过大:SPA通常有很多 JavaScript 文件,如果这些文件的大小过大或加载速度慢,就会导致首屏加载缓慢。可以通过代码分割和打包、使用CDN等方式来优化加载速度。
  • 数据请求过多或数据请求太慢:SPA通过 AJAX 或 Fetch 等方式从后端获取数据,如果数据请求过多或数据请求太慢,也会导致首屏加载缓慢。可以通过减少数据请求、使用数据缓存、优化数据接口等方式来优化数据请求速度。
  • 大量图片加载慢:如果首屏需要加载大量图片,而这些图片大小过大或加载速度慢,也会导致首屏加载缓慢。可以通过图片压缩、使用图片懒加载等方式来优化图片加载速度。
  • 过多的渲染和重绘操作:如果在首屏加载时进行大量的渲染和重绘操作,也会导致首屏加载缓慢。可以通过尽可能少的DOM操作、使用CSS3动画代替JS动画等方式来优化渲染和重绘操作。
  • 网络问题:网络问题也会影响SPA首屏加载速度,比如网络延迟、丢包等。可以通过使用CDN、使用HTTP/2等方式来优化网络问题。

Vue3 和 Vue2 有什么区别

基本回答点

  • 响应式原理
  • Composition API 和 Options Api
  • diff 算法
  • 生命周期钩子名称(setup 等同于 create,卸载改成 unmount)
  • 自定义指令构子名称
  • 新的内置组件(Teleprot)
  • 对于 TS 的支持
  • Vue3 核心库的依赖更少,减少打包体积(组合式api,函数式编程)
  • Vue3 全局 API 名称发生了变化,同时新增了watchEffectHooks等功能
  • 支持按需加载,可以更好的Tree Shanking

进阶回答点

  • Vue3 不推荐使用 mixin 进行逻辑复用,而是推荐写成 hook
  • v-model 作用于组件时,监听的事件和传递的值改变(允许多个 v-model) juejin.cn/post/722513…
  • 性能优化,增加静态节点标记,会标记静态节点,不对静态节点进行对比,从而增加效率(文本内容为变量会标记为1,属性为动态会标记2,如果静态则不标记跳过对比)

Vue2 自定义组件双向绑定(只能单个 v-model)

www.cnblogs.com/Im-Victor/p…

Vue 的响应式原理

什么是 Vue 的响应式

vue 数据响应式设计的初衷是为了实现数据和函数的联动,当数据变化后,用到该数据的联动函数会自动重新运行。

具体在 vue 的开发中,数据和组件的 render 函数关联在一起,从而实现数据变化自动运行 render,在感官上就看到了组件的重新渲染。

除了 vue 自动关联的 render 函数,其它还有使用到 vue 响应式的场景,比如 computed、watch 等等,不能仅把 vue 的数据响应式想象成和 render 的关联。

// 当数据变化自动运行 render(渲染函数),实现组件的重新渲染
function render ---> obj.a obj.b

// 像 cpmputed、 watch 会自己提供一个函数,当关联数据变化则运行函数
computed(() => obj.a + obj.b)
watch(() => {})

关联问题:vue 响应式依赖收集的是什么?

收集的是跟数据关联的函数(如渲染函数)

Vue2 的响应式原理

Vue.js 是采用 数据劫持 结合 发布者-订阅者模式 的方式,通过 Object.defineProperty() 来劫持各个属性的 settergetter,在数据变动时发布消息给订阅者,触发相应的监听回调。主要分为以下几个步骤:

  1. 使用 observe 对需要响应式的数据进行递归,将对像的所有属性及其子属性,都加上 settergetter 这样的话,给这个对象的某个属性赋值的时候,就会触发 setter,那么就能监听到了数据变化。
  2. compile 解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图。
  3. Watcher 订阅者是 ObserverCompile 之间通信的桥梁,主要做的事情是:
  • 在自身实例化时往属性订阅器(dep)里面添加自己
  • 自身必须有一个 update() 方法
  • 待属性变动触发 dep.notice() 通知时,能调用自身的 update() 方法,并触发 Compile 中绑定的回调,完成视图更新。 总结:通过 Observer 来监听自己的 model 数据变化,通过 Compile 来解析编译模板指令,最终利用 Watcher 搭起 ObserverCompile 之间的通信桥梁,达到一个数据响应式的效果。

Vue3 的响应式原理

Vue2 的响应式是通过Object.defineProperty对对象属性重写getset来实现的,而这个 API 是有一些缺陷的:

  1. 深度递归,性能消耗大
  2. 无法拦截新增和删除属性
  3. 无法拦截原生数组索引操作

所以 Vue3 换成了Proxy,它是从对象层面进行拦截的,所以它能解决Object.defineProperty这三个缺陷,但它也有缺点,就是兼容性很差,而且不能被 polyfill,正因如此vue3.0不支持IE

不变的是:还是要劫持对象的属性

变化的是:舍弃了 defineProperty 劫持对象属性,而使用 Proxy 劫持对象

Vue2 的劫持是通过遍历的方式,对每个属性调用 defineProperty 逐个劫持,不包括后添加的属性

Vue3 的劫持是通过 Proxy,对所有属性全部劫持,包括后添加的属性

image.png

Vue3 的响应式,像是事件委托,不必给每个子元素全部添加事件。通过事件冒泡,所有子元素的事件都能被监听到,包括动态创建的子元素。

在 Vue3 的响应式中,不必再去遍历对象的所有属性,逐个劫持。因此 Vue3 的性能要远远高于 Vue2,并且 Vue3 中移除掉了 $set 方法。

除了getset,Proxy 还有deletePropery可以劫持对象删除属性的操作,因此$delete在Vue3中也移除掉了。

区别

Vue2 是通过 Object.defineProperty() 劫持各个属性的 getter 和 setter,在数据变化时发布消息给订阅者,触发相应的监听回调,存在以下问题

  1. 初始化时需要遍历对象所有 key,如果对象层次较深,性能不好
  2. 通知更新过程需要维护大量 dep 实例和 watcher 实例,额外占用内存较多
  3. 无法监听到数组元素的变化,只能通过劫持重写数组方法
  4. 动态新增,删除对象属性无法拦截,只能用特定 set/delete API 代替
  5. 不支持 Map、Set 等数据结构

Vue3 为了解决这些问题,使用原生的 proxy 代替,支持监听对象和数组的变化,多达 13 种拦截方法

  1. 动态属性增删都可以拦截
  2. 新增数据结构全部支持
  3. 对象嵌套属性只代理第一层,运行时递归,用到才代理,不需要维护特别多的依赖关系,性能取得很大进步

总结

面试官问:说下 Vue2 的响应式原理?

  1. Vue2 中在创建阶段,会遍历 data 函数声明的对象
  2. 调用 defineProperty,为每个属性添加getset方法
  3. 其中,get 用来收集依赖,追踪使用数据的dom,确保将来去更新dom
  4. 其中,set 用来通知依赖(dom)更新界面内容
  5. 从而实现了,响应式效果

面试官问:说下 Vue3 的响应式原理?

  1. 与 Vue2 大致相同,还是get收集依赖,set通知依赖更新
  2. 区别在于,Vue3 使用 Proxy 替代了 defineProperty 实现getset

面试官问;为什么要用 Proxy?

Proxy 类似事件委托:

  1. 不必遍历对象,逐个添加劫持
  2. 动态添加的属性,依然会自动劫持,依然有响应式效果

Vue 的模板解析过程

在 Vue 中,模板是由 HTML 代码和 Vue 特定的模板语法组成的。Vue 的模板编译器会将模板编译成渲染函数,然后再生成 Virtual DOM,最终进行渲染。

下面是 Vue 的模板解析过程:

  1. 解析模板,生成 AST 抽象语法树:Vue 的编译器会将模板转换为 AST 抽象语法树,这是一个树形结构,代表了模板的结构,包括元素节点、文本节点、指令等。
  2. 优化 AST:在生成 AST 之后,Vue 的编译器会对其进行优化,例如静态节点提取、静态根节点提取等优化操作,以提高渲染性能。
  3. 生成代码:最后,Vue 的编译器会将 AST 转换为渲染函数的代码。这个渲染函数是一个 JavaScript 函数,接收一个参数 h,返回一个 Virtual DOM 节点。
  4. 生成 Virtual DOM:渲染函数生成后,Vue 会使用它来生成 Virtual DOM,然后对比新旧 Virtual DOM,计算出需要更新的部分,最终只更新需要更新的部分。
  5. 渲染:最后,Vue 将更新后的 Virtual DOM 渲染到真实的 DOM 中。

这个过程是 Vue 的模板编译过程的核心,也是 Vue 能够高效渲染页面的重要原因.

computed 和 watch 有什么异同

juejin.cn/post/706557…

总结

  • computed 是计算一个新的属性,并将该属性挂载到 vm(Vue 实例)上,而 watch 是监听已经存在且已挂载到 vm 上的数据,所以用 watch 同样可以监听 computed 计算属性的变化(其它还有 dataprops

  • computed 本质是一个惰性求值的观察者,具有缓存性,只有当依赖变化后,第一次访问 computed 属性,才会计算新的值,而 watch 则是当数据发生变化便会调用执行函数

  • 从使用场景上说,computed 适用一个数据被多个数据影响,而 watch 适用一个数据影响多个数据

常见的事件修饰符及其作用

  • .stop阻止冒泡
  • .prevent阻止默认事件
  • .capture :与事件冒泡的方向相反,事件捕获由外到内;
  • .self :只会触发自己范围内的事件,不包含子元素;
  • .once:只会触发一次。

defineProperty 和 Proxy 的区别

  1. defineProperty 不能监听到数组下标变化和对象新增属性,而 Proxy 可以
  2. defineProperty 是劫持对象属性,Proxy 是代理整个对象
  3. defineProperty 会污染原对象,修改时是修改原对象,Proxy 是对原对象进行代理并会返回一个新的代理对象,修改的是代理对象
  4. Object.defineProperty 是 ES5 的方法,Proxy 是 ES6 的方法
  5. defineProperty 不兼容 IE8,Proxy 不兼容 IE11

defineProperty 局限性大,只能针对单属性监听,所以在一开始就要全部递归监听;Proxy 对象嵌套属性运行时递归,用到才代理,也不需要维护特别多的依赖关系,性能提升很大,且首次渲染更快

vue 的 nextTick 原理是什么

VuenextTick 方法用于在下次 DOM 更新循环结束之后执行延迟回调。

原理如下:

  • 当调用 Vue 实例的 nextTick 方法时,Vue 会将传入的回调函数放入一个待执行的回调队列中。
  • 在同一个 tick 内,如果多次调用了 nextTickVue 会将这些回调函数都放入同一个队列中。
  • tick 循环开始时,Vue 会先执行同步任务,然后进行异步任务的处理,其中就包括执行 nextTick 队列中的回调函数。
  • Vue 会根据浏览器支持情况使用不同的异步延迟策略,如微任务(PromiseMutationObserver)或宏任务(setImmediatesetTimeout)来处理异步任务。
  • 在下次 tick 循环结束之后,DOM 更新完成,Vue 会执行 nextTick 队列中的所有回调函数。

这样,你可以通过 nextTick 方法来确保在 Vue 完成 DOM 更新之后再执行一些操作,例如访问更新后的 DOM、执行某些操作依赖于更新后的数据等。

DOM 更新是异步执行,vue 进行 DOM 更新时通过 nextTick 来做异步队列控制,调用 nextTick 时在 DOM 更新的 microtask 后追加我们的回调函数,从而确保我们的代码在 DOM 更新后执行(相当于两个微任务,nextTick 这个微任务在后面,所以能够获取到前面 DOM 更新的结果),同时避免了 setTimeout 可能存在的多次执行问题。

什么时候需要使用 nextTick()

Vue 是异步执行 DOM 更新的,一旦观察到数据变化,Vue 就会开启一个队列把在同一个事件循环 (Event Loop) 当中观察到数据变化的 watcher 推送进这个队列。如果这个 watcher 被触发多次,只会被推送到队列一次(这种缓冲行为可以有效的去掉重复数据造成的不必要的计算和 DOM 操作)。在下一个事件循环时,Vue 会清空队列,并进行必要的 DOM 更新。

当设置 vm.someData = 'new value',DOM 并不会马上更新,而是在异步队列被清除,即下一个事件循环开始执行前才会进行必要的 DOM 更新。如果此时想要根据更新的 DOM 状态去做某些事情,就会出现问题。为了在数据变化后等待 Vue 完成更新 DOM ,可以在数据变化之后立即使用 Vue.nextTick(callback) ,这样回调函数在 DOM 更新完成后就会调用。

调用 nextTick 是在 DOM 更新的 microtask 后追加我们的回调函数,从而确保我们的代码在 DOM 更新后执行(相当于两个微任务,nextTick 这个微任务在后面,所以能够获取到前面 DOM 更新的结果)

  • Vue 生命周期的 created() 钩子函数进行的 DOM 操作

    这个时候 DOM 还未渲染(此时执行 DOM 操作无效),所以要将 DOM 操作的代码放进 Vue.nextTick() 的回调函数中;与之对应的就是 mounted 钩子函数,因为该钩子函数执行时所有的 DOM 挂载已完成

  • 改变 DOM 元素的数据后基于新的 DOM 做操作

    对新 DOM 的操作都需要放进 Vue.nextTick() 的回调函数中,即更改数据后想立即使用新视图时需要使用它

  • 第三方插件

    使用第三方插件时,希望在 vue 生成的某些 DOM 动态发生变化时重新应用该插件,这时也会用到该方法(在 $nextTick 的回调函数中执行重新应用插件的方法)

组件之间的通讯方式有哪些

  • props / $emit 适用 父子组件通信

  • ref 适用 父子组件通信

ref 如果在普通的 DOM 元素上使用,引用指向的就是 DOM 元素;如果用在子组件上,引用就指向组件实例

  • $parent / $children$root:访问父 / 子 / 根实例

  • EventBus ($emit / $on) 适用于 父子、隔代、兄弟组件通信

这种方法通过一个空的 Vue 实例作为中央事件总线(事件中心),用它来触发事件和监听事件,从而实现任何组件间的通信,包括父子、隔代、兄弟组件。

  • $attrs/$listeners 适用于 隔代组件通信
  1. $attrs 包含了父作用域中不被 prop 所识别 (且获取) 的特性绑定 ( classstyle 除外 )。当一个组件没有声明任何 prop 时,这里会包含所有父作用域的绑定 ( classstyle 除外 ),并且可以通过 v-bind="$attrs" 传入内部组件。通常配合 inheritAttrs 选项一起使用。

  2. $listeners 包含了父作用域中的 (不含 .native 修饰器的) v-on 事件监听器。它可以通过 v-on="$listeners" 传入内部组件

  • provide / inject 适用于 隔代组件通信

祖先组件中通过 provide 来提供变量,然后在子孙组件中通过 inject 来注入变量。provide / inject API 主要解决了跨级组件间的通信问题,不过它的使用场景,主要是子组件获取上级组件的状态,跨级组件间建立了一种主动提供与依赖注入的关系。

  • Vuex 适用于 父子、隔代、兄弟组件通信

Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。每一个 Vuex 应用的核心就是 store(仓库)。store 基本上就是一个容器,它包含着你的应用中大部分的状态 ( state )。

  1. Vuex 的状态存储是响应式的。当 Vue 组件从 store 中读取状态的时候,若 store 中的状态发生变化,那么相应的组件也会相应地得到高效更新。

  2. 改变 store 中的状态的唯一途径就是显式地提交 (commit) mutation。这样使得我们可以方便地跟踪每一个状态的变化。

  • 插槽

Vue3 可以通过 usesolt 获取插槽数据。

  • mitt.js 适用于任意组件通信

Vue3 中移除了 $on$off等方法,所以 EventBus 不再使用,相应的替换方案就是 mitt.js

vue 中切换页面如何保存状态

使用 keep-alive

keep-aliveVue 内置的一个组件,可以使被包含的组件保留状态,避免重新渲染 ,其有以下特性:

  • 一般结合路由和动态组件一起使用,用于缓存组件;

  • 提供 includeexclude 属性,两者都支持字符串或正则表达式, include 表示只有名称匹配的组件会被缓存,exclude 表示任何名称匹配的组件都不会被缓存 ,其中 exclude 的优先级比 include 高;

  • 对应两个钩子函数 activateddeactivated ,当组件被激活时,触发钩子函数 activated,当组件被移除时,触发钩子函数 deactivated

vue 如何实现一个自定义指令

自定义指令是 vueHTML 元素的扩展,给 HTML 元素增加自定义功能。vue 编译 DOM 时,会找到指令对象,执行指令的相关方法。

自定义指令有五个生命周期

  • bind:只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。
  • inserted:被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)。
  • update:被绑定于元素所在的模板更新时调用,而无论绑定值是否变化。通过比较更新前后的绑定值,可以忽略不必要的模板更新。
  • componentUpdated:被绑定元素所在模板完成一次更新周期时调用。
  • unbind:只调用一次,指令与元素解绑时调用。

vue 当中使用了哪些设计模式

  • 观察者模式(Observer Pattern)Vue 使用观察者模式来实现数据绑定和响应式更新。Vue 中的数据和视图是通过观察者模式进行绑定,当数据发生变化时,会通知视图进行更新。
  • 发布订阅模式(Publish-Subscribe Pattern)Vue 也使用了发布订阅模式来实现组件间的通信。Vue 实例通过 $emit 方法发布事件,其他组件通过 $on 方法订阅事件,从而实现了解耦和灵活的组件通信。
  • 工厂模式(Factory Pattern) :在 Vue 中,组件的创建使用了工厂模式。Vue 组件是通过 Vue.extend 方法创建的构造函数,然后使用 new 关键字实例化组件对象。
  • 装饰器模式(Decorator Pattern)Vue 中的指令、计算属性、过滤器等功能都使用了装饰器模式来扩展组件的功能。通过在组件上添加不同的装饰器,可以实现不同的功能。
  • 单向数据流模式(One-Way Data Flow Pattern)Vue 推崇单向数据流,即数据从父组件向子组件传递,子组件通过 props 接收父组件的数据,子组件不能直接修改父组件数据,通过触发事件的方式向父组件传递数据变化。

Vue.use 作用及原理

Vue 中引入使用第三方库通常会采用 import 的形式引入,但是有的组件在引入之后又做了 Vue.use() 操作,有的组件引入进来又进行了 Vue.prototype.$something = something,那么它们之间有什么联系呢?

javascript
复制代码
// 先说一下 Vue.prototype,这是在 Vue 原型上增加了一个 axios
// 会在全局注册这个方法,之后文件中都可以通过 $axios 直接来使用 axios
import axios from "axios"
Vue.prototype.$axios = axios

Vue.use是什么?

官方对 Vue.use() 方法的说明:通过全局方法 Vue.use() 使用插件,Vue.use 会自动阻止多次注册相同插件,它需要在你调用 new Vue() 启动应用之前完成。

Vue.use() 方法至少传入一个参数,该参数类型必须是 Object 或 Function,如果是 Object 那么这个对象需要定义一个 install 方法,如果是 Function 那么这个函数就被当做 install 方法。

在 Vue.use() 执行时 install 会默认执行,install 执行时第一个参数就是 Vue,其他参数是 Vue.use() 执行时传入的其他参数。

就是说使用它之后调用的是该组件的 install 方法。

Vue.use 的源码中的逻辑

javascript
复制代码
export function initUse (Vue: GlobalAPI) {
 Vue.use = function (plugin: Function | Object) {
  const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))
  if (installedPlugins.indexOf(plugin) > -1) {
   return this
  }
  const args = toArray(arguments, 1)
  args.unshift(this)
  if (typeof plugin.install === 'function') {
   plugin.install.apply(plugin, args)
  } else if (typeof plugin === 'function') {
   plugin.apply(null, args)
  }
  installedPlugins.push(plugin)
  return this
 }
}

在源码中首先限制了它传入的值的类型只能是 Function 或 Object,然后判断了该插件是不是已经注册过,防止重复注册,然后调用该插件的 install 方法,源码中也有介绍到 Vue.use() 可以接受多个参数的,除第一个参数之后的参数我们都是以参数的形式传入到当前组件中。

Vue.use 什么时候使用

它在使用时实际是调用了该插件的 install 方法,所以引入的当前插件如果含有 install 方法我们就需要使用 Vue.use(),例如在 Vue 中引用 Element 如下:

javascript
复制代码
import Vue from 'vue'
import Element from 'element-ui'

// 在 Element 源码中会暴露除install方法,所以才需要用Vue.use()引入
Vue.use(Element)

我们也可以在自己的 vue 项目中自己定义一个 install 方法,然后通过 Vue.use() 方法来引入测试一下:

javascript
复制代码
const plugin = {
  install(Vue) {
    // 可以注册全局函数或组件到这个 vue 实例上
    Vue.prototype.xx = xx
    Vue.component('component-name', xxx)
    alert("我是install内的代码")
  },
}
import Vue from "vue

// 打开页面时弹窗显示"我是install内的代码"
Vue.use(plugin)

Vue 组件库如何实现按需加载

利用上面 Vue.use 的原理,给组件库中的每个组件添加一个 install 方法,通过单独引入组件,调用 Vue.use(组件) 注册,实现按需加载。

实现一个前端组件库

juejin.cn/post/703738…

先搭建一个简单的组件库,从ElementUIcopy了两个组件:AlertTag,并将组件库命名为XUI,当前目录结构如下:

image.png

组件都放在packages目录下,每个组件都是一个单独的文件夹,最基本的结构是一个js文件和一个vue文件,组件支持使用Vue.component方式注册,也支持插件方式Vue.use注册,js文件就是用来支持插件方式使用的,比如Alertjs文件内容如下:

js
复制代码
import Alert from './src/main';

// 给组件添加了一个`install`方法,这样就可以使用`Vue.use(Alert)`来注册
Alert.install = function(Vue) {
  Vue.component(Alert.name, Alert);
};

export default Alert;

组件的主题文件统一放在/theme-chalk目录下,也是每个组件一个样式文件,index.css包含了所有组件的样式,ElementUI的源码内是scss文件,本文为了简单,直接复制了其npm包内已经编译后的css文件。

最外层还有一个index.js文件,作为入口文件导出所有组件

js
复制代码
import Alert from './packages/alert/index.js';
import Tag from './packages/tag/index.js';

const components = [
    Alert,
    Tag
]

const install = function (Vue) {
    components.forEach(component => {
        Vue.component(component.name, component);
    });
};

if (typeof window !== 'undefined' && window.Vue) {
    install(window.Vue);
}

export default {
    install,
    Alert,
    Tag
}

首先依次引入组件库的所有组件,然后提供一个install方法,遍历所有组件,依次使用Vue.component方法注册,接下来判断是否存在全局的Vue对象,是的话代表是CDN方式使用,那么自动进行注册,最后导出install方法和所有组件。

js
复制代码
import XUI from 'xui'
import 'xui/theme-chalk/index.css'

// XUI 是一个带有`install`方法的对象,所以可以直接引入所有组件
Vue.use(XUI)

// 也可以单独注册某个组件
Vue.use(XUI.Alert)

实现按需加载

通过上面方法引入组件库后,无论是注册所有组件,还是只注册Alert组件,最后打包后的js里都存在Tag组件的内容,要做到按需加载,需要解决这个问题。

最简单的按需引入

因为每个组件都可以单独作为一个插件,所以可以只引入某个组件,比如:

js
复制代码
import Alert from 'xui/packages/alert'
import 'xui/theme-chalk/alert.css'

Vue.use(Alert)

这样只引入了alert相关的文件,当然最后只会包含alert组件的内容。但是比较麻烦,使用上成本比较高。最理想的方式还是下面这种:

import { Alert } from 'xui'

通过babel插件

使用babel插件是目前大多数组件库实现按需引入的方式,ElementUI使用的是babel-plugin-component

juejin.cn/post/696844…

image.png

可以看到能直接使用import { Alert } form 'xui'方式来引入Alert组件,也不需要手动引入样式,接下来我们来实现一个极简版的。

js
复制代码

// 我们想要的是下面这种方式
import { Alert } from 'xui'

// 实际按需使用需要这样
// 因此我们需要把第一种方式转换成第二种,通过 babel 插件来转换
import Alert from 'xui/packages/alert'

首先在babel.config.js同级新增一个babel-plugin-component.js文件,作为我们的插件文件,然后修改一下babel.config.js文件:

js
复制代码
module.exports = {
  // ...
  // 使用相对路径引用我们的插件
  plugins: ['./babel-plugin-component.js']
}

先来看一下import { Alert } from 'xui'对应的AST

image.png

整体是一个ImportDeclaration,通过souce.value可以判断导入的来源,specifiers数组里可以找到导入的变量,每个变量是一个ImportSpecifier,可以看到里面有两个对象:ImportSpecifier.importedImportSpecifier.local,这两个的区别在于是否使用了别名导入。

js
复制代码
import { Alert } from 'xui'

这种情况importedlocal是一样的,但是如果使用了别名:

js
复制代码
import { Alert as a } from 'xui'

那么是这样的:

image-20211202152548991.png

我们这里简单起见就不考虑别名情况,只使用imported

接下来的任务就是进行转换,看一下import Alert from 'xui/packages/alert'AST结构:

image-20211202154442551.png

目标AST结构也清楚了接下来的事情就简单了,遍历specifiers数组创建新的importDeclaration节点,然后替换掉原来的节点即可:

js
复制代码
// babel-plugin-component.js
module.exports = ({
    types
}) => {
    return {
        visitor: {
            ImportDeclaration(path) {
                const {
                    node
                } = path
                const {
                    value
                } = node.source
                if (value === 'xui') {
                    // 找出引入的组件名称列表
                    let specifiersList = []
                    node.specifiers.forEach(spec => {
                        if (types.isImportSpecifier(spec)) {
                            specifiersList.push(spec.imported.name)
                        }
                    })
                    // 给每个组件创建一条导入语句
                    const importDeclarationList = specifiersList.map((name) => {
                        // 文件夹的名称首字母为小写
                        let lowerCaseName = name.toLowerCase()
                        // 构造importDeclaration节点
                        return types.importDeclaration([
                            types.importDefaultSpecifier(types.identifier(name))
                        ], types.stringLiteral('xui/packages/' + lowerCaseName))
                    })
                    // 用多节点替换单节点
                    path.replaceWithMultiple(importDeclarationList)
                }
            }
        },
    }
}

接下来打包测试结果如下:

image-20211202171657728.png

image-20211202165120365.png

可以看到Tag组件的内容已经没有了。

当然,以上实现只是一个最简单的demo,实际上还需要考虑样式的引入、别名、去重、在组件中引入、引入了某个组件但是实际并没有使用等各种问题,有兴趣的可以直接阅读babel-plugin-component源码。

Vantantd也都是采用这种方式,只是使用的插件不一样,这两个使用的都是babel-plugin-importbabel-plugin-component其实也是forkbabel-plugin-import

Tree Shaking 方式

使用 unplugin-vue-components 插件

Vue 的性能优化有哪些

编码阶段

  • v-ifv-for不一起使用
  • v-for保证key的唯一性
  • v-ifv-show酌情使用
  • 合理使用 computed 和 watch
  • 使用keep-alive缓存组件
  • 路由懒加载、异步组件
  • 图片懒加载
  • 节流防抖
  • 第三方模块按需引入

打包优化

  • 压缩代码
  • 使用 CDN 加载第三方模块
  • 抽离公共文件

用户体验

  • 骨架屏
  • 客户端缓存

SEO优化

  • 预渲染
  • 服务端渲染
  • 合理使用 meta 标签

Vue3

reactive 对象重新赋值丢失响应式

blog.csdn.net/weixin_4668…

let foo = ref({ a: 1, b: 2, c: 3 }) 
let bar = reactive({ a: 4, b: 5, c: 6 })

foo.value = { a: 2} // 未丢失响应式
bar = {a: 2} // 丢失响应式

总结

  • ref 定义数据(包括对象)时,都会变成 RefImpl(Ref 引用对象) 类的实例,无论是修改还是重新赋值都会调用 setter,都会经过 reactive 方法处理为响应式对象。
  • 而 reactive 定义数据(必须是对象),是直接调用 reactive 方法处理成响应式对象。如果重新赋值,就会丢失原来响应式对象的引用地址,变成一个新的引用地址,这个新的引用地址指向的对象是没有经过 reactive 方法处理的,所以是一个普通对象,而不是响应式对象。

ref 创建的响应式引用,为什么要用 .value 的方式来获取值

响应式原理是通过 proxy 进行的,所以要伪装成一个对象

虚拟 DOM

虚拟 dom 是什么,为什么要使用虚拟 dom

Virtual DOMDOM 节点在 JavaScript 中的一种抽象数据结构,之所以需要虚拟 DOM,是因为浏览器中操作 DOM 的代价比较昂贵,频繁操作 DOM 会产生性能问题。

虚拟 DOM 的作用是在每一次响应式数据发生变化引起页面重渲染时,Vue 对比更新前后的虚拟 DOM,匹配找出尽可能少的需要更新的真实 DOM,从而达到提升性能的目的。

虚拟 DOM 的实现原理主要包括以下 3 部分:

  • JavaScript 对象模拟真实 DOM 树,对真实 DOM 进行抽象;
  • diff 算法 — 比较两棵虚拟 DOM 树的差异;
  • pach 算法 — 将两个虚拟 DOM 对象的差异应用到真正的 DOM 树。

虚拟 DOM 的解析过程

  • 首先对将要插入到文档中的 DOM 树结构进行分析,使用 js 对象将其表示出来,比如一个元素对象,包含 TagNamepropsChildren 这些属性。然后将这个 js 对象树给保存下来,最后再将 DOM 片段插入到文档中。
  • 当页面的状态发生改变,需要对页面的 DOM 的结构进行调整的时候,首先根据变更的状态,重新构建起一棵对象树,然后将这棵新的对象树和旧的对象树进行比较,记录下两棵树的的差异。
  • 最后将记录的有差异的地方应用到真正的 DOM 树中去,这样视图就更新了。

DIFF 算法原理

diff的目的是找出差异,最小化的更新视图。diff算法发生在视图更新阶段,当数据发生变化的时候,diff会对新旧虚拟DOM进行对比,只渲染有变化的部分。

  1. 对比是不是同类型标签,不是同类型直接替换

  2. 如果是同类型标签,执行patchVnode方法,判断新旧vnode是否相等。如果相等,直接返回。

  3. 新旧vnode不相等,需要比对新旧节点,比对原则是以新节点为主,主要分为以下几种。

    1. newVnodeoldVnode都有文本节点,用新节点替换旧节点。
    2. newVnode有子节点,oldVnode没有,新增newVnode的子节点。
    3. newVnode没有子节点,oldVnode有子节点,删除oldVnode中的子节点。
    4. newVnodeoldVnode都有子节点,通过updateChildren对比子节点。

Vue2 双端 diff 算法

updateChildren方法用来对比子节点是否相同,将新旧节点同级进行比对,减少比对次数。会创建4个指针,分别指向新旧两个节点的首尾,首和尾指针向中间移动。

每次对比下两个头指针指向的节点、两个尾指针指向的节点,头和尾指向的节点,是不是 key是一样的,也就是可复用的。如果是重复的,直接patch更新一下,如果是头尾节点,需要进行移动位置,结果以新节点的为主。

如果都没有可以复用的节点,就从旧的vnode中查找,然后进行移动,没有找到就插入一个新节点。

当比对结束后,此时新节点还有剩余,就批量增加,如果旧节点有剩余就批量删除。

Vue3 快速 diff 算法

juejin.cn/post/709206… juejin.cn/post/711942…

diff 优化

我们知道在数据变更触发页面重新渲染,会生成虚拟 DOM 并进行 patch 过程,这一过程在 Vue3 中的优化有如下

编译阶段的优化:

  • 事件缓存:将事件缓存(如: @click),可以理解为变成静态的了
  • 静态提升:第一次创建静态节点时保存,后续直接复用
  • 添加静态标记:给节点添加静态标记,以优化 Diff 过程

由于编译阶段的优化,除了能更快的生成虚拟 DOM 以外,还使得 Diff 时可以跳过"永远不会变化的节点",

Diff 优化如下

  • Vue2 是全量 Diff,Vue3 是静态标记 + 非全量 Diff
  • 使用最长递增子序列优化了对比流程

diff 算法中 key 是做什么用的

diff算法中,key是用来标识组件或元素的唯一性的。它的作用是在Virtual DOM对比更新过程中帮助确定哪些组件或元素需要被更新、删除或重新渲染。 当key被添加到组件或元素上时,Vue会使用key来追踪它们的标识。在进行diff算法的过程中,Vue会比较新旧Virtual DOM树中的组件或元素的key,如果某个组件或元素在新旧树中的key相同,Vue会认为它是同一个组件或元素,然后根据需要进行更新、移动或删除操作。 使用key可以有效提高diff算法的性能,避免重复渲染和更新无关的组件或元素。它可以帮助Vue更精确地判断哪些部分需要更新,提高组件的复用性和渲染效率。

需要注意的是,key应该是稳定、唯一且可预测的,最好使用具有唯一标识的属性作为key值,如id或具有唯一性的字段。避免使用index作为key,并且同一级别下的兄弟元素或组件的key值应该是唯一的,以确保diff算法的准确性。

所以 Vuekey 的作用是:key 作为 Vuevnode 的唯一标记,通过这个 key,我们的 diff 操作可以更准确、更快速。

为什么不推荐使用 index 做 key

使用 indexkey 会有以下问题:

  • 不稳定性:组件或元素的索引可能会发生变化,当列表中的组件或元素被重新排序、添加或删除时,其对应的 index 也会发生变化。这会导致 key 不稳定,可能会引发一些错误的更新或重新渲染。
  • 渲染性能:当列表项中的组件或元素重新排序时,使用 index 作为 key 可能会触发不必要的重新渲染。因为 Vue 在进行 diff 算法时,会认为同一个 index 的组件或元素是同一个,不会重新创建和更新。这样就可能导致原本是不同的组件或元素被错误地复用,从而引发意料之外的问题。
  • 面临删除与插入时的性能问题:当列表中的组件或元素发生删除、插入操作时,使用 index 作为 key 无法准确识别被删除或插入的内容,可能会导致不必要的重新渲染。这是因为 Vue 会认为被删除的组件或元素与新添加的组件或元素具有相同的 index,从而复用之前的组件或元素,而不是根据具体的变化进行创建或删除。

使用 index 作为 key 和没写基本上没区别,因为不管数组的顺序怎么颠倒,index 都是 0, 1, 2... 这样排列,导致 Vue 会复用错误的旧子节点,做很多额外的工作。

用 index 拼接一个随机字符串做 key 可以解决上述的问题吗

不可以,同样会产生以下问题。

  • 不稳定性:随机字符串虽然可以确保 key 的唯一性,但在重新渲染组件或元素时,生成的随机字符串会发生变化。当组件或元素重新排序、添加或删除时,index 值会被重新分配,从而导致随机生成的字符串也会发生变化,使得 key 不是稳定的。

  • 性能问题:由于随机生成的字符串是随机的,diff 算法无法利用它们的稳定性进行优化。每次更新时,Vue 都会认为组件或元素是不同的,导致不必要的重新渲染和更新

Vuex

Vuex 由哪几部分组成

  • State:定义了应用状态的数据结构,可以在这里设置默认的初始状态。

  • Getter:允许组件从 Store 中获取数据,mapGetters 辅助函数仅仅是将 store 中的 getter 映射到局部计算属性。

  • Mutation:是唯一更改 store 中状态的方法,且必须是同步函数。

  • Action:用于提交 mutation,而不是直接变更状态,可以包含任意异步操作。

  • Module:允许将单一的 Store 拆分为多个 store 且同时保存在单一的状态树中。

webpack

「吐血整理」再来一打Webpack面试题

webpack 的作用

  • 模块打包

    可以将不同模块的文件打包整合在一起,并且保证它们之间的引用正确,执行有序。利用打包我们就可以在开发的时候根据我们自己的业务自由划分文件模块,保证项目结构的清晰和可读性。

  • 编译兼容

    在前端的“上古时期”,手写一堆浏览器兼容代码一直是令前端工程师头皮发麻的事情,而在今天这个问题被大大的弱化了,通过webpackLoader机制,不仅仅可以帮助我们对代码做polyfill,还可以编译转换诸如.less, .vue, .jsx这类在浏览器无法识别的格式文件,让我们在开发的时候可以使用新特性和新语法做开发,提高开发效率。

  • 能力扩展

    通过webpackPlugin机制,我们在实现模块化打包和编译兼容的基础上,可以进一步实现诸如按需加载,代码压缩等一系列功能,帮助我们进一步提高自动化程度,工程效率以及打包输出的质量。

webpack的构建过程

Webpack 是一个模块打包工具,它将应用程序的代码及其依赖项打包到一个或多个静态资源(bundle)中,以便在浏览器中加载。

Webpack 的构建过程主要包括以下几个步骤:

  • Entry(入口) :指定一个或多个入口文件作为构建的起点,Webpack 会从这些入口文件开始递归地解析和构建依赖关系。
  • Module(模块) :解析入口文件及其依赖的模块。Webpack 会根据配置中的不同模块规则(rules)来处理不同类型的模块,如 JS 文件、CSS 文件、图片等。
  • Chunk(代码块) :根据模块之间的依赖关系,Webpack 将模块分组成不同的块(Chunk)。一个块可以是一个文件或多个文件组成的一个逻辑单元。
  • Loaders(加载器)Webpack 使用加载器来处理非 JavaScript 文件。加载器允许在打包过程中对模块进行预处理。例如,Babel loader 可以将 ES6/ES7 代码转换为浏览器可以理解的 ES5 代码。
  • Plugins(插件) :插件用于执行更广泛的任务和自定义构建过程。它们可以用于优化输出、资源管理、注入环境变量等。
  • Output(输出) :指定打包后的文件输出的位置和命名规则。Webpack 会将打包后的文件输出为一个或多个静态资源(bundle)。
  • DevServer(开发服务器)Webpack 还提供了一个开发服务器(Webpack Dev Server),可以在开发过程中实时重新加载文件,使开发更加高效。

在以上步骤完成后,就可以生成一个或多个静态资源(bundle),准备用于在浏览器中运行应用程序。

Webpack 打包、优化和部署

👉👉 当面试官问Webpack的时候他想知道什么

👉👉 玩转 webpack,使你的打包速度提升 90%

webpack 打包流程

webpack的整个打包流程:

  • 读取webpack的配置参数;
  • 启动webpack,创建Compiler对象并开始解析项目;
  • 从入口文件(entry)开始解析,并且找到其导入的依赖模块,递归遍历分析,形成依赖关系树;
  • 对不同文件类型的依赖模块文件使用对应的Loader进行编译,最终转为Javascript文件;
  • 整个过程中webpack会通过发布订阅模式,向外抛出一些hooks,而webpack的插件即可通过监听这些关键的事件节点,执行插件任务进而达到干预输出结果的目的。

其中文件的解析与构建是一个比较复杂的过程,在webpack源码中主要依赖于compilercompilation两个核心对象实现。

compiler对象是一个全局单例,他负责把控整个webpack打包的构建流程。 compilation对象是每一次构建的上下文对象,它包含了当次构建所需要的所有信息,每次热更新和重新构建,compiler都会重新生成一个新的compilation对象,负责此次更新的构建过程。

而每个模块间的依赖关系,则依赖于AST语法树。每个模块文件在通过Loader解析完成之后,会通过acorn库生成模块代码的AST语法树,通过语法树就可以分析这个模块是否还有依赖的模块,进而继续循环执行下一个模块的编译解析。

最终Webpack打包出来的bundle文件是一个IIFE的执行函数。

如何提高webpack的打包速度

  • 利用缓存:利用Webpack的持久缓存功能,避免重复构建没有变化的代码。可以使用cache: true选项启用缓存。
  • 使用多进程/多线程构建 :使用thread-loaderhappypack等插件可以将构建过程分解为多个进程或线程,从而利用多核处理器加速构建。
  • 使用DllPlugin和HardSourceWebpackPluginDllPlugin可以将第三方库预先打包成单独的文件,减少构建时间。HardSourceWebpackPlugin可以缓存中间文件,加速后续构建过程。
  • 使用Tree Shaking: 配置WebpackTree Shaking机制,去除未使用的代码,减小生成的文件体积
  • 移除不必要的插件: 移除不必要的插件和配置,避免不必要的复杂性和性能开销。

如何减少打包后的代码体积

  • 代码分割(Code Splitting) :将应用程序的代码划分为多个代码块,按需加载。这可以减小初始加载的体积,使页面更快加载。
  • Tree Shaking:配置WebpackTree Shaking机制,去除未使用的代码。这可以从模块中移除那些在项目中没有被引用到的部分。
  • 压缩代码:使用工具如UglifyJSTerser来压缩JavaScript代码。这会删除空格、注释和不必要的代码,减小文件体积。
  • 使用生产模式:在Webpack中使用生产模式,通过设置mode: 'production'来启用优化。这会自动应用一系列性能优化策略,包括代码压缩和Tree Shaking
  • 使用压缩工具:使用现代的压缩工具,如BrotliGzip,来对静态资源进行压缩,从而减小传输体积。
  • 利用CDN加速:将项目中引用的静态资源路径修改为CDN上的路径,减少图片、字体等静态资源等打包。

常见的 Loader 和 Plugin

Loader

  • image-loader : 加载并且压缩图片文件
  • css-loader : 加载 CSS,支持模块化、压缩、文件导入等特性
  • style-loader : 把 CSS 代码注入到 JavaScript 中,通过 DOM 操作去加载 CSS
  • eslint-loader : 通过 ESLint 检查 JavaScript 代码
  • tslint-loader : 通过 TSLint检查 TypeScript 代码
  • babel-loader : 把 ES6 转换成 ES5

Plugin

  • define-plugin : 定义环境变量
  • html-webpack-plugin : 简化 HTML 文件创建
  • webpack-parallel-uglify-plugin : 多进程执行代码压缩,提升构建速度
  • webpack-bundle-analyzer : 可视化 Webpack 输出文件的体积
  • speed-measure-webpack-plugin : 可以看到每个 LoaderPlugin 执行耗时 (整个打包耗时、每个 PluginLoader 耗时)
  • mini-css-extract-plugin : 分离样式文件,CSS 提取为独立文件,支持按需加载

是否写过Loader?简单描述一下编写loader的思路?

juejin.cn/post/707894…

Webpack最后打包出来的成果是一份Javascript代码,实际上在Webpack内部默认也只能够处理JS模块代码,在打包过程中,会默认把所有遇到的文件都当作 JavaScript代码进行解析,因此当项目存在非JS类型文件时,我们需要先对其进行必要的转换,才能继续执行打包任务,这也是Loader机制存在的意义。

Loader的配置使用我们应该已经非常的熟悉:

js
复制代码
// webpack.config.js
module.exports = **{**
  // ...other config
  module: {
    rules: [
      {
        test: /^your-regExp$/,
        use: [
          {
             loader: 'loader-name-A',
          }, 
          {
             loader: 'loader-name-B',
          }
        ]
      },
    ]
  }
}

通过配置可以看出,针对每个文件类型,loader是支持以数组的形式配置多个的,因此当Webpack在转换该文件类型的时候,会按顺序链式调用每一个loader,前一个loader返回的内容会作为下一个loader的入参。因此loader的开发需要遵循一些规范,比如返回值必须是标准的JS代码字符串,以保证下一个loader能够正常工作,同时在开发上需要严格遵循“单一职责”,只关心loader的输出以及对应的输出。

loader函数中的this上下文由webpack提供,可以通过this对象提供的相关属性,获取当前loader需要的各种信息数据,事实上,这个this指向了一个叫loaderContextloader-runner特有对象。有兴趣的小伙伴可以自行阅读源码。

js
复制代码
module.exports = function(source) {
    const content = doSomeThing2JsString(source);
    
    // 如果 loader 配置了 options 对象,那么this.query将指向 options
    const options = this.query;
    
    // 可以用作解析其他模块路径的上下文
    console.log('this.context');
    
    /*
     * this.callback 参数:
     * error:Error | null,当 loader 出错时向外抛出一个 error
     * content:String | Buffer,经过 loader 编译后需要导出的内容
     * sourceMap:为方便调试生成的编译后内容的 source map
     * ast:本次编译生成的 AST 静态语法树,之后执行的 loader 可以直接使用这个 AST,进而省去重复生成 AST 的过程
     */
    this.callback(null, content);
    // or return content;
}

是否写过Plugin?简单描述一下编写plugin的思路?

如果说Loader负责文件转换,那么Plugin便是负责功能扩展。LoaderPlugin作为Webpack的两个重要组成部分,承担着两部分不同的职责。

上文已经说过,webpack基于发布订阅模式,在运行的生命周期中会广播出许多事件,插件通过监听这些事件,就可以在特定的阶段执行自己的插件任务,从而实现自己想要的功能。

既然基于发布订阅模式,那么知道Webpack到底提供了哪些事件钩子供插件开发者使用是非常重要的,上文提到过compilercompilationWebpack两个非常核心的对象,其中compiler暴露了和 Webpack整个生命周期相关的钩子(compiler-hooks),而compilation则暴露了与模块和依赖有关的粒度更小的事件钩子(Compilation Hooks)。

Webpack的事件机制基于webpack自己实现的一套Tapable事件流方案(github

js
复制代码
// Tapable的简单使用
const { SyncHook } = require("tapable");

class Car {
    constructor() {
        // 在this.hooks中定义所有的钩子事件
        this.hooks = {
            accelerate: new SyncHook(["newSpeed"]),
            brake: new SyncHook(),
            calculateRoutes: new AsyncParallelHook(["source", "target", "routesList"])
        };
    }

    /* ... */
}


const myCar = new Car();
// 通过调用tap方法即可增加一个消费者,订阅对应的钩子事件了
myCar.hooks.brake.tap("WarningLampPlugin", () => warningLamp.on());

Plugin的开发和开发Loader一样,需要遵循一些开发上的规范和原则:

  • 插件必须是一个函数或者是一个包含 apply 方法的对象,这样才能访问compiler实例;
  • 传给每个插件的 compilercompilation 对象都是同一个引用,若在一个插件中修改了它们身上的属性,会影响后面的插件;
  • 异步的事件需要在插件处理完任务时调用回调函数通知 Webpack 进入下一个流程,不然会卡住;

了解了以上这些内容,想要开发一个 Webpack Plugin,其实也并不困难。

js
复制代码
class MyPlugin {
  apply (compiler) {
    // 找到合适的事件钩子,实现自己的插件功能
    compiler.hooks.emit.tap('MyPlugin', compilation => {
        // compilation: 当前打包构建流程的上下文
        console.log(compilation);
        
        // do something...
    })
  }
}

splitChunks

segmentfault.com/a/119000004…

Code Splitting代码分割,是一种优化技术。它允许将一个大的chunk拆分成多个小的chunk,从而实现按需加载,减少初始加载时间,并提高应用程序的性能。

通常Webopack会将所有代码打包到一个单独的bundle中,然后在页面加载时一次性加载整个bundle。这样的做法可能导致初始加载时间过长,尤其是在大型应用程序中,因为用户需要等待所有代码加载完成才能访问应用程序。

Code Splitting 解决了这个问题,它将应用程序的代码划分为多个代码块,每个代码块代表不同的功能或路由。这些代码块可以在需要时被动态加载,使得页面只加载当前所需的功能,而不必等待整个应用程序的所有代码加载完毕。

Webpack中通过optimization.splitChunks配置项来开启代码分割。

Webpack 的 Tree Shaking 原理

Tree Shaking 也叫摇树优化,是一种通过移除多于代码,从而减小最终生成的代码体积,生产环境默认开启

原理:

  • ES6 模块系统Tree Shaking的基础是ES6模块系统,它具有静态特性,意味着模块的导入和导出关系在编译时就已经确定,不会受到程序运行时的影响。
  • 静态分析:在Webpack构建过程中,Webpack会通过静态分析依赖图,从入口文件开始,逐级追踪每个模块的依赖关系,以及模块之间的导入和导出关系。
  • 标记未使用代码: 在分析模块依赖时,Webpack会标记每个变量、函数、类和导入,以确定它们是否被实际使用。如果一个导入的模块只是被导入而没有被使用,或者某个模块的部分代码没有被使用,Webpack会将这些未使用的部分标记为"unused"
  • 删除未使用代码: 在代码标记为未使用后,Webpack会在最终的代码生成阶段,通过工具(如UglifyJS等)删除这些未使用的代码。这包括未使用的模块、函数、变量和导入。

Source Map

Source Map是一种文件,它建立了构建后的代码与原始源代码之间的映射关系。通常在开发阶段开启,用来调试代码,帮助找到代码问题所在。

可以在Webpack配置文件中的devtool选项中指定devtool: 'source-map'来开启。

vite

webpack 和 vite 打包原理

juejin.cn/post/726779…

webpack 有一个缺点,如果在这个文件中需要改动一点点再保存,webpack 的热重载又会重新自动打包一次,这对于大型项目是极不友好的,那么 vite 出现了!

vite 打包原理

当声明一个 script 标签类型为 module 时,浏览器会对其内部的 import 引用发起 HTTP 请求获取模块内容。那么,vite 会劫持这些请求并进行相应处理。因为浏览器只会对用到的模块发送 http 请求,所以 vite 不用对项目中所有文件都打包,而是按需加载,大大减少了AST树的生成和代码转换,降低服务启动的时间和项目复杂度的耦合,提升了开发者的体验。

vite 为什么快

mp.weixin.qq.com/s/CBhuEntaV…

  1. 提升 vite 服务器启动速度

类似于 webpack 这种打包器方式的构建工具,会首先打包所有资源为 bundle,然后才启动服务,而 vite 将该过程后置并做了一些优化来提升服务器的启动速度,主要如下所示:

(1)通过 esbuild 预构建依赖

依赖指的是在开发时不会变动的纯 JavaScript,主要包含 node_modules 下的文件,该内容通常包含多种模块化格式(CommonJS、UMD、ESM等),所以需要进行转换为原生的 ES 模块,为了完成该转换过程,引入 esbuild 进行预构建依赖,由于 esbuild 使用 go 编写,其构建速度相比于 JavaScript 编写的打包器构建速度更快。

(2)现代浏览器支持 ESM 格式的代码

由于浏览器支持 ESM 格式的代码,所以浏览器可接管打包程序的部分工作,vite 只需要在浏览器请求源码时进行转换并按需提供源码。

图片

  1. 加快更新速度

为了加快更新速度,vite 做了很多优化,主要体现在以下几点:

(1)减少网络请求

esbuild 预构建依赖时,将许多内部模块的 ESM 依赖关系转换为单个模块,从而减少网络请求个数,提高页面加载性能

(2)缓存

源码模块的请求会根据 304 Not Modified 进行协商缓存,而依赖模块请求则会通过 Cache-Control: max-age=31536000,immutable 进行强缓存,因此一旦被缓存它们将不需要再次请求

vite 缺点

vite 项目首次打开页面加载慢,因为它要进行一系列的动态分析/动态资源引入/动态编译

blog.csdn.net/m0_67265464…

vite 之所以启动快,是因为 vite 启动时并不会像 webpack 一样对所有代码进行编译/打包/压缩, 它只会对一小部分代码进行一些简单的处理,剩余的工作都交给浏览器,以及运行时进行依赖分析,动态打包,动态引入。

开发和生产环境体验不统一

  • 在开发模式下采用 esbulid 进行依赖编译,实现优秀的开发体验

  • 在生产模式下采用 Rollup 进行代码打包,虽然 Rollup 本身并不支持代码分割,但是 vite 已经通过插件的方式实现了该功能

移动端

如何进行移动端适配

👉👉 移动端适配的5种方案

👉👉 我要怎么才能实现:移动端的适配操作?

移动端 1px 问题如何解决

👉👉 为什么会存在1px问题?怎么解决?

blog.csdn.net/qq_45846359…

blog.csdn.net/lyh6665/art…

什么是 1px 问题?

UI 设计稿标注边框是 1px,前端直接在代码上写 border:1px,结果发现在有的手机中,1px 会比实际效果粗,这就是典型的1px问题。

为什么会有 1px 问题?

首先需要明确,1px 不是一定会出现的,比如 pc 基本不会出现,移动端有的手机也可能不会出现,为什么会这样?先打个问号,带着问题往下看。

(设备独立像素,CSS像素,逻辑像素) VS (设备像素,物理像素)

iPhone7 为例,打开 Chrome 发者工具,可以看到 iPhone7 是375*667,这就是 css像素(逻辑像素、设备独立像素),而 iPhone7 的说明书上写着屏幕是 750*1334,这就是物理像素。

设备像素比 = 物理像素/css像素

css像素和物理像素是不一样的,两者没什么关系,只是概念不同而已,但是物理像素比css像素会得出另一个概念设备像素比,即设备像素比

  • 为什么 pc 端没有 1px 问题,是因为在 pc 端中 css像素 == 物理像素(即设备像素比为1),所以在页面上写 1px 的时候页面上就呈现出来的就是 1px

  • 但在移动端,如上面提到的 iPhone7 它的设备像素比是 750/375 = 2,所以当 UI 给出的设计稿中设计的边框是1px(物理像素)时,需要转化为css像素,即 css像素 = 物理像素/设备像素比 = 1px/2 = 0.5px

  1. 为什么出现 1px 问题(浏览器解析问题)

问题就出在 0.5px 上,因为有的浏览器在解析 0.5px 的时候会将其解析成 1px,用公式换算出来,物理像素 = css像素 * 设备像素比 = 1px * 2 = 2px,因此原本应该是 1px 到最后呈现出来的变成了 2px,也就是我们说的变粗了

设备像素比越大,意味着手机屏幕越高清,也意味着这个 bug 在手机上越明显

  1. 为什么有的手机不会出现 1px 问题呢
  • 老式的手机,设备像素比为1,和 pc 端一样自然不会出现 1px 问题
  • 有的手机浏览器在解析 0.5px 的时候正常,自然也不会出现 1px 的问题,如某些苹果手机
  1. 为什么 2px 没有问题呢(浏览器能正常解析)

1px 问题只是这一类问题的总称,现在大部分手机设备像素比为 2,这意味着 2px 物理像素转化为css像素为 1px,而 1px 浏览器都可以正常解析,所以正常

解决 1px 问题

图片、伪类、缩放等等

性能优化

HTML&CSS

  • 减少DOM数量,减轻浏览器渲染计算负担。
  • 使用异步和延迟加载js文件,避免js文件阻塞页面渲染
  • 压缩HTML、CSS代码体积,删除不要的代码,合并CSS文件,减少HTTP请求次数和请求大小。
  • 减少CSS选择器的复杂程度,复杂度与阿高浏览器解析时间越长。
  • 避免使用CSS表达式在javascript代码中
  • 使用css渲染合成层如transformopacitywill-change等,提高页面相应速度减少卡顿现象。
  • 动画使用CSS3过渡,减少动画复杂度,还可以使用硬件加速。

JS

  • 减少DOM操作数量
  • 避免使用with语句、eval函数,避免引擎难以优化。
  • 尽量使用原生方法,执行效率高。
  • js文件放到文件页面底部,避免阻塞页面渲染
  • 使用事件委托,减少事件绑定次数。
  • 合理使用缓存,避免重复请求数据。

Vue

  • 合理使用watchcomputed,数据变化就会执行,避免使用太多,减少不必要的开销
  • 合理使用组件,提高代码可维护性的同事也会降低代码组件的耦合性
  • 使用路由懒加载,在需要的时候才会进行加载,避免一次性加载太多路由,导致页面阻塞
  • 使用Vuex缓存数据
  • 合理使用mixins,抽离公共代码封装成模块,避免重复代码。
  • 合理使用v-if v-show
  • v-for 不要和v-if一起使用,v-for的优先级会比v-if
  • v-for中不要用indexkey,要保证key的唯一性
  • 使用异步组件,避免一次性加载太多组件
  • 避免使用v-html,存在安全问风险和性能问题,可以使用v-text
  • 使用keep-alive缓存组件,避免组件重复加载

Webpack优化

  • 代码切割,使用code splitting将代码进行分割,避免将所有代码打包到一个文件,减少响应体积。
  • 按需加载代码,在使用使用的时候加载代码。
  • 压缩代码体积,可以减小代码体积
  • 优化静态资源,使用字体图标、雪碧图、webp格式的图片、svg图标等
  • 使用Tree Shaking 删除未被引用的代码
  • 开启gzip压缩
  • 静态资源使用CDN加载,减少服务器压力

网络优化

  • 使用HTTP/2
  • 减少、合并HTTP请求,通过合并CSS、JS文件、精灵图等方式减少请求数量。
  • 压缩文件, 开启nginxGzip对静态资源压缩
  • 使用HTTP缓存,如强缓存、协商缓存
  • 使用CDN,将网站资源分布到各地服务器上,减少访问延迟

手写题

手写 Promise.all

js
复制代码

// proms 不一定是 Array,也可能是 Set
Promise.myALL = function(proms) {
  // all 方法最后返回的是一个 promise
  // 具体是返回 resolve 还是 reject
  return new Promise((resolve, reject) => {
    let count = 0 // 当前下标
    const result = [] // 成功返回数组
    let fullfilledCount = 0 // 完成的 Promise 的数量
    
    // 不能用 proms.length 或 proms.size 来判断 Promise 的数量
    // Array 和 Set 都是可迭代对象,用 for-of 判断
    for(const prom of proms) {
      const index = count
      count++
    
      // 将每个参数转为 Promise,并监控它是成功还是失败
      Promise.resolve(prom).then(data => {
        // 1. data -> result,且保证返回数据顺序跟传入顺序是一致的
        result[index] = data
        // 2. 完成所有的 Promise
        fullfilledCount++
        if (fullfilledCount === count) {
          resolve(result)
        }
      }, reject)
    }
  
    // proms 长度为0,直接返回 resolve
    if (count === 0)  {
      resolve(result)
    }
    
  })  
}

Promise.myAll([]).then(datas => {
  console.log(datas) // []
})

Promise.myAll([1, 2, 3, 4]).then(datas => {
  console.log(datas) // [1, 2, 3, 4]
})

// then 的第二个参数用于处理 reject
// catch 其实就是 then 的第二个参数
Promise.myAll([1, 2, 3, Promise.reject(1)]).then(datas => {
  console.log(datas)
}, err => {
  console.log(err) // 1
})

防抖节流

防抖的原理是在一段连续触发的时间内,只执行最后一次操作。

js
复制代码
function debounce(func, delay) {
  let timer;

  return function () {
    const context = this;
    const args = arguments;

    // 再次调用时,清除time,重新计时
    clearTimeout(timer);
    timer = setTimeout(() => {
      // 通过apply执行传入的函数
      func.apply(context, args);
    }, delay);
  };
}

节流的原理是在一段时间内,固定执行操作的频率。

js
复制代码
function throttle(func, interval) {
  let timer;

  return function () {
    const context = this;
    const args = arguments;

    if (!timer) {
      timer = setTimeout(function () {
        func.apply(context, args);
        // 触发完成后清除timer,进入下一周期
        timer = null;
      }, interval);
    }
  };
}

curry 函数

柯里化(Currying)是一种将多个参数的函数转换为接受单个参数的函数序列的技术。

js
复制代码
function add() {
  // 创建空数组来维护所有要 add 的值
  const args = []
  // curry 函数,存入每次调用传入的参数
  function curried(...nums) {
    if (nums.length === 0) {
      // 长度为0,说明调用结束,返回 args 的 sum
      return args.reduce((pre, cur) => pre + cur, 0);
    } else {
      // 长度不为0,将传入的参数存入 args,返回 curried函数给下一次调用
      args.push(...nums);
      return curried;
    }
  }

  // 一开始给 curried 传递 add 接收到的参数 arguments
  return curried(...Array.from(arguments));
}

console.log(add(1, 2)(1)()); // 输出:4
console.log(add(1)(2)(3)(4)()); // 输出:10
console.log(add(5)()); // 输出:5

封装 resize 指令

// resize.js
const map = new WeakMap() // 涉及弱引用问题

// entries 返回发生变化的元素
const ob = new ResizeObserve((entries) => {
  for (const entry of entries) {
    // 运行 entry.target 对应的回调函数
    const handler = map.get(entry.target)
    if (handler) {
      console.log(entry.borderBoxSize[0])
      handler({
        width: entry.borderBoxSize[0].inlineSize,
        height: entry.borderBoxSize[0].blockSize
      })
    }
  }
})

export default {
  mounted(el, binding) {
    // 监听 el 元素尺寸的变化
    map.set(el, binding.value)
    ob.observe(el)
  },
  
  unmounted(el) {
    // 取消监听
    ob.unobserve(el)
  }
}


// index.vue

<template>
  <div class="container">
    <div v-size-ob="handleSizeChange" ref="chartRef" class="chart"></div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
impirt { useCharts } from './useCharts';

const chartRef = ref(null);
const width = ref(500);

useCharts(width, chartRef);

function handleSizeChange(size) {
  width.value = size.width
}
</script>


链式调用和延迟执行

// 链接调用,说明每个函数调用完后要把对象返回
function arrange(name) {
  const tasks = []
  tasks.push(() => {
    console.log(`${name} is notified`)
  })
  
  function doSomething(action){
    tasks.push(() => {
      console.log(`Start to ${action}`)
    })
    return this
  }
  
  // 等待函数
  function wait(sec){
    tasks.push(() => new Promise(resolve => {
     setTimeout(resolve, sec * 1000)
    }))
    return this
  }
  
  // 执行函数
  async function execute(){
    for(const t of tasks) {
      await t()
    }
    return this
  }
  
  // 等待函数,最先执行
  function waitFirst(sec){
    tasks.unshift(() => new Promise(resolve => {
     setTimeout(resolve, sec * 1000)
    }))
   return this
  }
  
  return {
    do: doSomething,
    wait,
    execute,
    waitFirst
  }
}

arrange('William').execute()
// William is notified

arrange('William').do('commit').execute()
// William is notified
// Start to commit

arrange('William').wait(5).do('commit').execute()
// William is notified
// 等待5s
// Start to commit

arrange('William').waitFirst(5).do('commit').execute()
// 等待5s
// William is notified
// Start to commit

网络和浏览器

👉👉 juejin.cn/post/729708…