一、JS 相关
1. 谈谈你对 this 的理解?
this
永远指向最后调用它的那个对象this
的指向有以下几种情况:- 默认绑定
- 隐式绑定
- 显示绑定
- new 绑定
- 箭头函数绑定
注意:箭头函数中没有 this
绑定,必须通过查找作用域链来决定其值。如果箭头函数被非箭头函数包含,则 this
绑定的是最近一层非箭头函数的 this
,否则,this
为 undefined
2. 说一说事件循环 Event-Loop?
JS 是一门单线程语言(注意:JS 的主线程是单线程的)。JS 代码在执行过程中,除了依靠函数调用栈来搞定函数的执行顺序外,还可以依靠任务队列(task queue)来搞定另外一些代码的执行。整个执行过程,我们称为事件循环过程。任务队列分为:macro-task(宏任务)与 micro-task(微任务)
Macro-task: script(整个)、setTimeout、setInterval、setImmediate、I/O 等
Micro-task: process.nextTick、Promise、Async/Await(实际上就是 promise)、MutationObserver(H5 新特性)
总结:执行宏任务,然后执行该宏任务产生的微任务。若微任务在执行过程中产生了新的微任务,则继续执行微任务。当微任务执行完毕后,再回到宏任务中进行下一轮循环
扩展:
NodeJs 中的事件循环
- timersj 阶段:这个阶段执行 timer(setTimeout、setInterval)的回调
- I/O callbacks:执行一些系统调用错误,比如网络通信的错误回调
- idle,prepare:仅 node 内部使用
- poll:获取新的 I/O 事件, 适当的条件下 node 将阻塞在这里
- check:执行 setImmediate() 的回调
- close callbacks:执行 socket 的 close 事件回调
node 和浏览器 event-loop 的主要区别?
两者最主要的区别在于:浏览器中的微任务是在每个相应的宏任务中执行的,而 node 中的微任务是在不同阶段之间执行的
进程:资源分配的最小单位;线程:程序执行的最小单位
H5 的新特性 Web Worker 可以创建多线程。就是在主线程开辟的一个额外的线程,这个线程与主线程不相互影响。通过 postMessage 和 onMessage 交互数据。它创建的子线程完全受控于主线程,且位于外部文件中,无法访问 DOM。所以它并没有改变 js 单线程的本质
考察:【promise 和 async/await 的执行顺序】
3. 你了解 JS 中原型 & 原型链吗?
JS 中一切皆是对象,对象分为普通对象和函数对象。Object、Function 是 JS 自带的函数对象。凡是通过 new Function()创建的对象都是函数对象,其他的都是普通对象
构造函数:实例的构造函数属性(constructor)指向构造函数
每个对象都有一个
__proto__
属性,但只有函数对象才有 prototype 属性__proto__
内置属性用于指向创建它的构造函数的原型对象。p.__proto__
=== Person.prototype原型对象:一个普通对象(除了 Function.prototype 外,它是函数对象,也没有 prototype 属性)。原型对象(Person.prototype)是构造函数(Person)的一个实例 -》原型对象主要用于 继承
所有函数对象的
__proto__
都指向 Function.prototype,它是一个空函数Person.prototype.constructor === Person person1.__proto__ === Person.prototype person1.constructor === Person Object.__proto__ === Function.prototype Function.__proto__ === Function.prototype Object.prototype.__proto__ === null
总结:原型链的形成真正是靠
__proto__
而非 prototype
4. 作用域 与 作用域链了解吗?
js 作为一种弱类型语言 ,变量声明没有那么严谨。于是就要有属于它独特 一套执行方式。作用域可以理解就是一块小的“地盘”一块代码段所在的地方
作用域(Scope)的分类:
- 全局作用域
- 函数作用域(局部作用域)
- 块级作用域(ES6 新增)
作用域链(Scope Chain)
内部函数访问外部函数的变量,采取的是链式查找的方法来决定取那个结构,这种结构称之为作用域链
扩展:作用域与执行上下文的区别?
区别 1:
- 全局作用域之外,每个函数都会创建自己的作用域,作用域在函数定义时就已经确定了。而不是函数调用
- 全局执行上下环境是在作用域确定之后,js 代码立马执行之前创建
- 函数执行上下环境是在调用函数时,执行函数体代码之前创建
区别 2:
- 作用域是静态的,只要函数定义好了就会一直存在,不会变化
- 上下文环境是动态的,调用函数时创建,函数调用结束时上下环境会立即释放
5. 闭包 知道吗?
闭包 是指有权访问另一个函数作用域中的变量的函数--《JavaScript 高级程序设计》
闭包的特性
- 函数嵌套函数
- 函数内部可以引用外部的参数和变量,封装私有的方法和变量,避免全局变量的污染
- 本质是将函数内部和外部连接起来。优点是可以读取函数内部的变量,让这些变量的值始终保存在内存中,不会在函数被调用之后自动清除
闭包的好处
- 可以读取函数内部的变量
- 让这些变量的值始终保持在内存中
闭包的优点
延长局部变量的生命周期
闭包的缺点
会导致函数的变量一直保存在内存中,过多的闭包可能会导致内存泄漏
闭包的应用场景
闭包的两个场景,闭包的两大作用:保存/保护。 在开发中, 其实我们随处可见闭包的身影, 大部分前端 JavaScript 代码都是“事件驱动”的,即一个事件绑定的回调方法; 发送 ajax 请求成功|失败的回调;setTimeout 的延时回调;或者一个函数内部返回另一个匿名函数,这些都是闭包的应用
6. 判断数据类型的方式?以及优缺点?
基本数据类型
null、undefined、string、boolean、number、(ES6 新增)Symbol、BigInt
引用数据类型
object
扩展:新增两个原始数据类型
Record & Tuple:就是一个只读的 Object 和 Array
- 基本用法
// Record
const myRecord = #{
name: 'tmc',
age: 27
}
// Tuple
const myTuple = #[1, 2, 3]
特点:其实就是在之前的对象和数组前面加了个#
特性
- 可读特性
const myRecord = #{ name: 'tmc', age: 27 } const myTuple = #[1, 2, 3] myRecord['sex'] = '男' // error myTuple.push(4) // error
- 非唯一性
const obj1 = {name: 'tmc'} const obj2 = {name: 'tmc'} console.log(obj1 === obj2) // false const arr1 = [1] const arr2 = [1] console.log(arr1 === arr2) // false 因为每个生成的对象在内存中的地址都不一样 const obj3 = #{name: 'tmc'} const obj4 = #{name: 'tmc'} console.log(obj1 === obj2) // true const arr3 = #[1] const arr4 = #[1] console.log(arr1 === arr2) // true 只要内部内容一致,就是相等的
- 普通对象和数组的转换
const myRecord = Record({ name: 'tmc' }) // #{name: 'tmc'} const myTuple = Tuple([1, 2, 3]) // #[1, 2, 3]
- 支持扩展运算符
const myTuple = #[1, 2, 3] const myRecord = #{name: 'tmc'} const newTuple = #[...myTuple, 4, 5] // #[1, 2, 3, 4, 5] const newRecord = #{...myRecord, age: 27} // #{name: 'tmc', age: 27}
如何使用
- 安装 babel 插件
# babel基本的库 yarn add @babel/cli @babel/core @babel/preset-env -D # Record 和 Tuple 的Babel polyfill yarn add @babel/plugin-proposal-record-and-tuple @bloomberg/record-tuple-polyfill - D
- 根目录创建
.babelrc
{ "presets": ["@babel/preset-env"], "plugins": [ [ "@babel/plugin-proposal-record-and-tuple", { "importPolyfill": true, "syntaxType": "hash" } ] ] }
- 直接使用
应用场景
- 用于保护一些数据,比如:函数的返回值、对象内部的静态属性...
- 既然具有只读的特性,即不可变对象,那应该也可以作为对象的 key 值
数据判断类型方法
方式一:typeof
- 在基本数据类型中:除了 null 以外,使用 typeof 均可得到正确的结果
- 在引用数据类型中:除了 function 以外,使用 typeof 都得到 object
方式二:instanceof
用来判断 xx 是否是 xx 的实例。是则返回 true,否则返回 false。语法:[对象] instanceof [构造函数]
instanceof 有以下几点需要注意:
- 左侧必须是对象 object, 如果不是,则返回 false
- instanceof 检查的 原型
方式三:constructor
- 原理是利用函数的原型对象的 constructor 属性指向其构造函数
- constructor 需要注意:null 和 undefined 是无效对象,没有 constructor
- 方式四:toString【最好】
- toString 是 object 原型上的方法,默认会返回[[Class]] -> [object String]
- toString 需要注意:对于其他对象,我们需要通过
call
、apply
、bind
来改变this
的指向后才能返回正确的结果
扩展:obj.toString()的结果和 Object.prototype.toString.call(obj)的结果不一样,这是为什么?
- 这是因为 toString 为 Object 的原型方法,而 Array 、Function 等类型作为 Object 的实例,都重写了 toString 方法。不同的对象类型调用 toString 方法时,根据原型链的知识,调用的是对应的重写之后的 toString 方法,而不会去调用 Object 上原型 toString 方法,所以采用 obj.toString()不能得到其对象类型,只能将 obj 转换为字符串类型;因此,在想要得到对象的具体类型时,应该调用 Object 上原型 toString 方法
7. JS 中继承的方式有哪些?
原型链继承:父类的实例作为子类的原型
缺点: 可以访问父类原型上的属性和方法,但是父构造函数上的属性和方法会被子类共享; 子类的实例都是访问的同一个原型对象。共享的是同一份数据-》导致数据可能被篡改
构造函数继承(借用 call):复制父类的实例属性给子类
缺点: 可以获取到父类的属性和方法。但是不能获取到父类原型上的方法
组合继承(原型链继承 + 钩子函数继承)
缺点: 子类不仅自己独享一份父构造函数上的属性,还能访问父类原型上的属性和方法。 -》 父构造函数 Parent 被调用的次数太多了(假如 Parent 的构造函数代码量很大,每一次的继承都是一笔不小的性能开销)
原型式继承(借用 Object.create)
就是生成了一个原型的 proto 指针指向传入对象的对象而已。然后生成的对象可以通过原型链来访问原型对象的属性和方法
寄生式继承(借用 Object.create 和工厂模式)
组合寄生式继承
8. JS 中数据的转换机制?
比如:let x;虽然 js 中有很多数据类型,但我们在申明的时候只有一种数据类型,只有到运行期间才会确定当前类型。虽然变量的数据类型是不确定的,但是各种运算符对数据类型是有要求的,如果运算子的类型与预期不符合,就会触发类型转换机制
常见的类型转换有:
强制转换(显示转换)
- Number():将任意类型的值转化为数值
undefined -> NaN; null -> 0; true -> 1; false -> 0; ....Object -> 先调用 toPrimitive,再调用 toNumber
注意:
- Number()转换字符串的时候,Number('')->0; Number('324abc') -> NaN; Number('324')-> 324
- Number()转换对象的时候,通常转换成 NaN(除了只包含单个数值的数组),Number({a: 1})->NaN;Number([1, 2, 3])->NaN;Number([5])->5
- parseInt():
parseInt
函数逐个解析字符,遇到不能转换的字符就停下来
parseInt('32a3') // 32
- String():可以将任意类型的值转化成字符串
String({a: 1})->"[object Object]"; String([1, 2, 3]) // "1,2,3";其余的都是”类型“
- Boolean():可以将任意类型的值转为布尔值
Undefined,null,0,NaN,'' -> false; {}, [] -> true
自动转换(隐式转换)
隐式转换的场景:(要求运算符两边的操作数不是同一类型)
- 比较运算(
==
、!=
、>
、<
)、if
、while
需要布尔值地方 - 算术运算(
+
、-
、*
、/
、%
)
- undefined、null、false、+0、-0、NaN、‘’都会被转成 false;其余的都是 true
- 自动转换成字符串,具体规则是:先将复合类型的值转为原始类型的值,再将原始类型的值转为字符串
- 自动转换成数值,除了
+
有可能把运算子转为字符串,其他运算符都会把运算子自动转成数值
- 比较运算(
扩展:== 和 === 有什么区别? === 叫做严格相等,是指:左右两边不仅值要相等,类型也要相等
== 不像 === 那样严格,对于一般情况,只要值相等,就返回 true,但 == 还涉及一些类型转换,它的转换规则如下:
- 两边的类型是否相同,相同的话就比较值的大小,例如 1==2,返回 false
- 判断的是否是 null 和 undefined,是的话就返回 true
- 判断的类型是否是 String 和 Number,是的话,把 String 类型转换成 Number,再进行比较
- 判断其中一方是否是 Boolean,是的话就把 Boolean 转换成 Number,再进行比较
- 如果其中一方为 Object,且另一方为 String、Number 或者 Symbol,会将 Object 转换成字符串,再进行比较
console.log({ a: 1 } == true) //false
console.log({ a: 1 } == '[object Object]') //true
注意:它们都有缺点,前者会自动转换数据类型,后者的 NaN 不等于自身,以及+0 等于-0。Object.is 修复了 +0 和-0,NaN 和 NaN 相等的问题
扩展:对象转原始类型是根据什么流程运行的?
对象转原始类型,会调用内置的[ToPrimitive]函数,对于该函数而言,其逻辑如下:
- 如果 Symbol.toPrimitive()方法,优先调用再返回
- 调用 valueOf(),如果转换为原始类型,则返回
- 调用 toString(),如果转换为原始类型,则返回
- 如果都没有返回原始类型,会报错
var obj = {
value: 3,
valueOf() {
return 4
},
toString() {
return '5'
},
[Symbol.toPrimitive]() {
return 6
},
}
console.log(obj + 1) // 输出7
应用:如何让 if(a == 1 && a == 2)条件成立?
var a = {
value: 0,
valueOf: function() {
this.value++
return this.value
},
}
console.log(a == 1 && a == 2) //true
9. 说说 函数式编程里的高阶函数、函数柯里化、组合函数等?
在 JavaScript 中,函数为一等公民(First Class),所谓的 “一等公民”,指的是函数与其他数据类型一样,处于平等地位,可以赋值给其他变量
,也可以作为参数,传入另一个函数
,或作为其它函数的返回值
。
由于函数式编程具有代码质量高且易于维护的特点,其应用也越来越广泛。许多不支持函数式编程的语言也加入了闭包,匿名函数等函数式编程特性。
- 纯函数:输出不受外部环境影响,同时也不影响外部环境,无副作用
- 高阶函数:至少满足两个条件:它接收一个或多个函数作为参数、将函数作为输出返回
debounce 和 throttle
- 高阶函数:(如果函数的参数的一个函数,如果一个函数返回了一个函数,两者都被称为高阶函数)
- 高阶函数的应用: (如果一个函数中前后想执行什么操作,就可以执行高阶函数;重写一些原生的方法;AOP 面向切面编程)
- 函数组合:就是将两个或两个以上的函数组合生成一个新函数的过程
- 柯里化:是函数式编程中的一种进阶技巧。直接表现形式就是,当我们有一个函数 f(a,b,c),通过柯里化转换,使得这个函数可以被这样调用 f(a)(b)(c)。 用途:参数可以复用,便于封装语法糖
10. 深拷贝和浅拷贝的区别?如何实现一个深拷贝?(考虑正则,Date 这种类型的数据)
JavaScript 中存在两大数据类型:基本类型、引用类型。 基本类型数据保存在在栈内存中;引用类型数据保存在堆内存中,引用数据类型的变量是一个指向堆内存中实际对象的引用,存在栈中
- 浅拷贝:对原始对象属性值的一份精确拷贝,若属性是基本类型,就拷贝其基本类型的值;若属性是引用类型,则拷贝的是内存地址。所以,若其中一个地址变了后就会影响到另一个对象
- 常见的浅拷贝方式有:
- Object.assign -> Object.assign({}, obj)
- Array.prototype.slice(), Array.prototype.concat() -> arr.slice(0); arr.concat()
- ...扩展运算符 -> [...obj]
- 常见的浅拷贝方式有:
function shallowClone(obj) {
let newObj = {}
for (let prop in obj) {
if (obj.hasOwnProperty(prop)) {
newObj[prop] = obj[prop]
}
}
return newObj
}
深拷贝:指的是开辟一个新的栈,两个对象属性完成相同,但是对应两个不同的地址,修改一个对象的属性,不会改变另一个对象的属性
常见的深拷贝方式有:
- _.cloneDeep()【lodash】 -> _.cloneDeep(obj)
- jQuery.extend()【jQuery】 -> $.extend(true, {}, obj)
- JSON.parse(JSON.stringify()) -> JSON.parse(JSON.stringify(obj))
这种方式最简单,但有以下弊端:
- 当被拷贝对象中有
Date
对象,则拷贝后时间将以字符串
的形式 - 当被拷贝对象中有
RegExp
、Error
对象,则拷贝的结果将得到一个空对象
- 当被拷贝对象中有
undefined
和函数
的时,则拷贝的结果将会把函数或 undefined丢失
- 当被拷贝对象中有
- 手写递归
写一个深拷贝,考虑正则,Date这种类型的数据 function deepClone(target, hash = new WeakMap()) { if (target === null) return target // 如果是null或者undefined,不进行拷贝操作 if (target instanceof Date) return new Date(target) if (target instanceof RegExp) return new RegExp(target) if (typeof target === 'object') { // 对象 if (hash.has(target)) return hash.get(target) // let cloneTarget = Array.isArray(target) ? [] : {} let cloneTarget = target.constructor // constructor没参数可以不写() hash.set(target, cloneTarget) for(let key in target) { if (target.hasOwnProperty(key)) { cloneTarget[key] = deepClone(target[key], hash) } } return cloneTarget } else { // 普通值 return target } }
注意:null == undefined 为 true null === undefined 为 false
两者的区别:
- 浅拷贝只复制属性指向某个对象的指针,而不复制对象本身,新旧对象还是共享同一块内存,修改对象属性会影响原对象
- 深拷贝会另外创造一个一模一样的对象,新对象跟原对象不共享内存,修改新对象不会改到原对象
11. JS 中执行上下文 和 执行栈是什么?
执行上下文就是当前 JavaScript 代码被解析和执行时所在环境
执行上下文总共有三种类型:
- 全局执行上下文: 默认的。不在任何函数中的代码都位于全局执行上下文中。它做了两件事:1. 创建一个全局对象,在浏览器中这个全局对象就是 window 对象。2. 将 this 指针指向这个全局对象。一个程序中只能存在一个全局执行上下文
- 函数执行上下文: 每次调用函数时,都会为该函数创建一个新的执行上下文。每个函数都拥有自己的执行上下文,但是只有在函数被调用的时候才会被创建
- Eval 函数执行上下文: 运行在 eval 函数中的代码也获得了自己的执行上下文
执行上下文的生命周期包括三个阶段:
创建阶段
→执行阶段
→回收阶段
- 创建阶段
扩展:当函数被调用,但未执行任何其内部代码之前,会做以下三件事:
- 创建变量对象:首先初始化函数的参数 arguments,提升函数声明和变量声明
- 创建作用域链(Scope Chain):在执行期上下文的创建阶段,作用域链是在变量对象之后创建的。作用域链本身包含变量对象。作用域链用于解析变量。当被要求解析变量时,JavaScript 始终从代码嵌套的最内层开始,如果最内层没有找到变量,就会跳转到上一层父作用域中查找,直到找到该变量。
- 确定 this 指向
- 执行阶段 扩展:执行变量赋值、代码执行
- 回收阶段 扩展:执行上下文出栈等待虚拟机回收执行上下文
- 创建阶段
扩展:当函数被调用,但未执行任何其内部代码之前,会做以下三件事:
变量提升 大部分编程语言都是先声明变量再使用,但在 JS 中,事情有些不一样:
- 变量声明提升
console.log(a); // undefined var a = 10; // 上述代码正常输出undefined而不是报错Uncaught ReferenceError: a is not defined,这是因为声明提升(hoisting) <!-- 等价于 --> var a; // 声明,默认值是undefined console.log(a); a = 10; // 赋值
- 函数声明提升 创建一个函数的方法有两种,一种是通过函数声明 function foo(){}, 另一种是通过函数表达式 var foo = function(){} ,那这两种在函数提升有什么区别呢?
console.log(f1) // function f1(){} function f1() {} // 函数声明 console.log(f2) // undefined var f2 = function() {} // 函数表达式
注意:当遇到函数和变量同名且都会被提升的情况,函数声明优先级比较高,因此变量声明会被函数声明所覆盖,但是可以重新赋值。
console.log(a) // 输出function a() {} function a() {} var a = 10 console.log(a) // 10 // function 声明的优先级比 var 声明高,也就意味着当两个同名变量同时被 function 和 var 声明时,function 声明会覆盖 var 声明 <!-- 等价于 --> function a() {} var a; // undefined -> hoisting console.log(a) // 输出function a() {} a = 10; console.log(a) // 10
总结:变量提升的规则:函数申明整体提升;变量声明提升值为 undefined
确认 this 的指向 很重要的概念 ——
this 的值是在执行的时候才能确认,定义的时候不能确认!
为什么呢 ? 因为 this 是执行上下文环境的一部分,而执行上下文需要在代码执行之前确定,而不是定义的时候。看如下例子:执行上下文栈 函数多了,就有多个函数执行上下文,每次调用函数创建一个新的执行上下文,那如何管理创建的那么多执行上下文呢?JavaScript 引擎创建了执行上下文栈来管理执行上下文。
可以把执行上下文栈认为是一个存储函数调用的栈结构,遵循先进后出的原则
扩展:需要记住几个关键点:- JavaScript 执行在单线程上,所有的代码都是排队执行。
- 一开始浏览器执行全局的代码时,首先创建全局的执行上下文,压入执行栈的顶部。
- 每当进入一个函数的执行就会创建函数的执行上下文,并且把它压入执行栈的顶部。当前函数执行完成后,当前函数的执行上下文出栈,并等待垃圾回收。
- 浏览器的 JS 执行引擎总是访问栈顶的执行上下文。
- 全局上下文只有唯一的一个,它在浏览器关闭时出栈。
12. 了解 预编译 吗?
- js 运行代码共分三步:
- 语法分析 顾名思义就是检查一遍 JS 代码内有没有出息语法错误(比如少分号,多写括号等);语法分析期间不会执行代码
- 预编译 这个阶段发送在代码执行的前一刻,这个过程说白了就是在内存里面创建一个空间,用来存你定义的变量和函数(放东西的)
- 解析执行 执行代码
JS 在执行前会产生一个 GO(Global Object)也就是我们说的全局作用域。当一个方法被调用时会形成一个局部作用域 AO(Activation Object)
- 预编译发生在函数执行的前一刻, 预编译阶段做了哪些事情?
- 创建 AO(Activation Object)对象(里面存储的是函数内部的局部变量)
- 找形参和变量声明,将变量和形参名做为 AO 属性名,值为 undefined
- 将实参和形参统一,即更改形参后的 undefined 为具体的形参值
- 找函数的申明 会覆盖相同变量的申明
在全局作用域里,预编译过程有些许不同
- GO 对象的过程如下:
- 创建 GO 对象
- 寻找变量声明,值设定为 undefined
- 寻找函数中的函数声明,将函数名作为 GO 属性名,值为函数体
13. 知道 事件代理吗?有什么应用场景?
事件委托,会把一个或者一组元素的事件委托到它的父层或者更外层元素上,真正绑定事件的是外层元素,而不是目标元素。当事件响应到目标元素上时,会通过事件冒泡机制从而触发它的外层元素的绑定事件上,然后在外层元素上去执行函数
事件代理,俗地来讲,就是把一个元素响应事件(click...)的函数委托到另一个元素
扩展:事件流的都会经过三个阶段: 捕获阶段
-> 目标阶段
-> 冒泡阶段
应用场景
- 如果我们有一个列表,列表之中有大量的列表项,我们需要在点击列表项的时候响应一个事件。如果给每个列表项一一都绑定一个函数,那对于内存消耗是非常大的。这时候就可以事件委托,把点击事件绑定在父级元素 ul 上面,然后执行事件的时候再去匹配目标元素
- 动态绑定事件,动态的增加或者去除列表项元素
总结 适合事件委托的事件有:
click
,mousedown
,mouseup
,keydown
,keyup
,keypress
事件委托存在两大优点:
- 减少整个页面所需的内存,提升整体性能
- 动态绑定,减少重复工作
使用事件委托也是存在局限性:
- focus、blur 这些事件没有事件冒泡机制,所以无法进行委托绑定事件
- mousemove、mouseout 这样的事件,虽然有事件冒泡,但是只能不断通过位置去计算定位,对性能消耗高,因此也是不适合于事件委托的
14. 说说 JS 中的垃圾回收机制 与 内存泄漏?
程序的运行需要内存,只要程序提出要求,操作系统或者运行是就必须供给内存。对于持续运行的服务进程,必须及时释放内存,否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。不再用到的内存,没有及时释放,就叫做内存泄漏
扩展:有些语言(比如 c 语言)必须手动释放内存。大多数语言提供自动内存管理,减轻程序员的负担,这被称为"垃圾回收机制"
- js 垃圾回收机制原理:解决内存的泄露,垃圾回收机制会定期(周期性)找出那些不再用到的内存(变量),然后释放其内存。
现在各大浏览器通常采用的垃圾回收机制有两种方法:标记清除
【常用】,引用计数
。
标记清除 js 中最常用的垃圾回收方式就是标记清除。当变量进入环境时,例如,在一个函数中声明一个变量,就将这个变量标记为"进入环境"。而当变量离开环境时,则将其标记为"离开环境"。垃圾回收机制在运行的时候会给存储再内存中的所有变量都加上标记(可以是任何标记方式),然后,它会去掉处在环境中的变量及被环境中的变量引用的变量标记(闭包)。(先所有都加上标记,再把环境中引用到的变量去除标记。剩下的就是没用的了)
引用计数 语言引擎有一张"引用表",保存了内存里面所有资源(通常是各种值)的引用次数。如果一个值的引用次数是 0,就表示这个值不再用到了,因此可以将这块内存释放。(跟踪记录每 个值被引用的次数。清除引用次数为 0 的变量 ⚠️ 会有循环引用问题 。循环引用如果大量存在就会导致内存泄露。)
内存泄漏的识别方法:
浏览器 开发者工具 -> 选择 Timeline 面板 -> 顶部的 Capture 字段里面勾选 Memory -> 点击左上角的录制按钮 -> 在页面上进行各种操作,模拟用户的使用情况 -> 一段时间后,点击对话框的 stop 按钮,面板上就会显示这段时间的内存占用情况
如果内存占用基本平稳,接近水平,就说明不存在内存泄漏
命令行 process.memoryUsage()返回一个对象,包含了 Node 进程的内存占用信息
ES6 推出了两种新的数据结构:WeakSet 和 WeakMap。它们对于值的引用都是不计入垃圾回收机制的,所以名字里面才会有一个"Weak",表示这是
弱引用
扩展:内存泄漏的场景
15. WebSocket 的原理是啥?
WebSocket 是 HTML5 出的东西(协议),是一个持久化的协议. 是基于 HTTP 协议的,或者说借用了 HTTP 的协议来完成一部分握手
问题:HTTP 的生命周期通过 Request 来界定,也就是一个 Request 一个 Response ,那么在 HTTP1.0 中,这次 HTTP 请求就结束了。在 HTTP1.1 中进行了改进,使得有一个 keep-alive,也就是说,在一个 HTTP 连接中,可以发送多个 Request,接收多个 Response。但是请记住 Request = Response, 在 HTTP 中永远是这样,也就是说一个 request 只能有一个 response。而且这个 response 也是被动的,不能主动发起。
// 典型的 Websocket 握手
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw== // 是一个 Base64 encode 的值,这个是浏览器随机生成的,告诉服务器:我要验证你是不是真的websocket协议
Sec-WebSocket-Protocol: chat, superchat // 是一个用户定义的字符串,用来区分同URL下,不同的服务所需要的协议;简单理解:今晚我要服务A,别搞错啦~
Sec-WebSocket-Version: 13 // 是告诉服务器所使用的 Websocket Draft (协议版本)
Origin: http://example.com
// 服务器会返回下列东西,表示已经接受到请求, 成功建立Websocket啦!
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk= // 这个则是经过服务器确认,并且加密过后的 Sec-WebSocket-Key
Sec-WebSocket-Protocol: chat // 则是表示最终使用的协议
扩展:
- ajax 轮询的原理非常简单,让浏览器隔个几秒就发送一次请求,询问服务器是否有新信息。
- long poll 其实原理跟 ajax 轮询 差不多,都是采用轮询的方式,不过采取的是阻塞模型(一直打电话,没收到就不挂电话),也就是说,客户端发起连接后,如果没消息,就一直不返回 Response 给客户端。直到有消息才返回,返回完之后,客户端再次建立连接,周而复始。
- Websocket 的作用
- 服务端就可以主动推送信息给客户端
- 可以以任何一种方式非常有效地发送数据。由于已经建立了连接并且非常有效地组织了 webSocket 数据帧,因此与通过 HTTP 请求(其中必须包含标头,Cookie 等)相比,可以更高效地发送数据。
16. require 和 import 的区别?/ ES6 Module 和 Commonjs 区别?
- 导入 require 导出 exports/module.exports 是 CommonJS 的标准,通常适用范围如 Node.js
- import/export 是 ES6 的标准
- commonjs 输出的,是一个值的拷贝,而 es6 输出的是值的引用
- commonjs 是运行时加载,es6 是编译时输出接口
注意:require 是浅拷贝,也就是说你可以修改对象第二层的属性并影响原数据;import 是引用,基本数据类型,修改不会影响原数据,但是对象修改属性会影响。import 具有置顶性, 它不是一定要放在文件的顶部
- ES6 Module 静态引入,编译时引入
- CommonJs 动态引入,执行(执行)时引入
- 只有 ES6 Module 才能静态分析,实现 Tree-Shaking
- CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用
优势: CommonJS 加载的是一个对象(即 module.exports 属性),该对象只有在脚本运行完才会生成。而 ES6 Modules 不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成
17. 为何 Proxy 不能被 Polyfill?
- 如 Class 可以用 function 模拟
- 如 Promise 可以用 callback 来模拟
- 但 Proxy 的功能用 Object.defineProperty 无法模拟
18. 编译器一般由哪几个阶段组成?
编译器一般由 4 个阶段工作完成:
- Parse 阶段:V8 引擎负责将 JS 代码转换成 AST(抽象语法树);
- Ignition 阶段:解释器将 AST 转换为字节码,解析执行字节码也会为下一个阶段优化编译提供需要的信息;
- TurboFan 阶段:编译器利用上个阶段收集的信息,将字节码优化为可以执行的机器码;
- Orinoco 阶段:垃圾回收阶段,将程序中不再使用的内存空间进行回收。
注意:数据类型检查 一般在 Parse 阶段之前 就进行了,因为在生成 AST 之前 就要进行语法分析,提取出句子的结构
二、ES6+相关
1. ES6 有哪些新特性?
let,const;解构赋值;箭头函数;数组/对象/函数字符串等的扩展;Proxy;Reflect;Iterator;Set/WeakSet;Map/WeakMap;Symbol;Promise;Async+await;Generator;类;Module 模块化
注意:...在函数参数中叫剩余运算符
(将所有的参数组合成一个数组,只能在最后一个参数);在函数中调用是展开运算符
2. 能手写一个完整的 Promise 吗?及其静态方法?
- 优点
- 可以解决异步嵌套问题
- 可以解决多个异步并发的问题
- 缺点
- promise 也是基于回调的
- promise 无法终止
扩展:
async+ await 如何处理错误?
Promise 可以通过 catch 捕获错误,async+await 通过await-to-js捕获错误
async+await 的原理?
async+await 就是 Generator+yield 的语法糖
实现简版的 generator?
function selfGenerator(arr) { let index = 0 let len = arr.length return { next: function() { let done = index >= len let value = !done ? arr[index++] : undefined return { done: done, value: value, } }, [Symbol.iterator]() { // yield返回iterator [...it] return this }, } }
3. 怎么理解 Set、Map 两种数据结构?
Set 是一种叫做集合的数据结构,类似于数组。但成员是唯一且无需的,没有重复的值
Map 是一种叫做 字典的数据结构
WeakSet 与 Set 的本质区别:
- WeakSet 只能存储对象引用,不能存放值;而 Set 对象都可以存放
- WeakSet 对象中存储的对象值都是被弱引用的,即垃圾回收机制不考虑 WeakSet
Object 里的 key 只能是字符串(或 Symbol),Map 的里的 key 可以放任何类型
WeakMap 与 Map 的本质区别:
- WeakMap 的键必须是对象,而值可以是任意的
- WeakMap 中的对象都是弱引用的
注意:Set 和 Map 注意的应用场景在于 数组去重 和 数据存储
4. Symbol 的应用场景
- Symbol 是基本数据类型,特点:独一无二【一般作为对象的 key】
- Symbol 属性默认是不能枚举的,可通过 Object.getOwnPropertySymbols(), Reflect.keys()获取
- Symbol 有两个静态方法:Symbol.for, Symbol.keyFor
- Symbol 具有元编程的能力,可以改写语法本身。(hasInstance, species, match,iterator, toPrimitive, toStringTag 等 11 种)
- Symbol 可以模拟类的私有方法
5. 普通函数与箭头函数的区别?
- 箭头函数是匿名函数,不能作为构造函数,不能使用 new。
- 箭头函数不绑定 arguments,取而代之用 rest 参数...解决
- 箭头函数不绑定 this,会捕获其所在的上下文的 this 值,作为自己的 this 值。
- 箭头函数没有原型属性,undefined
- 箭头函数不能当做 Generator 函数,不能使用 yield 关键字
总结:普通函数的 this 指向调用它的对象;箭头函数的 this 指向调用父级的对象,如果父级作用域还是箭头函数,就继续向上找,直到 window
var a = 20
var obj = {
a: 10,
b: () => {
console.log(this.a) // 浏览器是20;node中是undefined
console.log(this) // 浏览器是Window 对象;node中是{}
},
c: function() {
console.log(this.a) // 10
console.log(this) // {a: 10, b: ƒ, c: ƒ}
},
}
obj.b()
obj.c()
三、H5 & CSS3 相关
1. 元素水平垂直居中的方式有哪些?如果元素不定宽高呢?
第一种:绝对定位 + transform 通过设置父盒子相对定位,子盒子绝对定位并 top 和 left 距离 50%,实现了子盒子左上角点居中,再通过 transform: translate(-50%, -50%); 属性来实现子盒子的中心点居中
.parent { position: relative; } .children { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); -webkit-transform: translate(-50%, -50%); }
注意:需要添加浏览器前缀进行兼容
第二种:绝对定位 + margin
- 不用知道子盒子的宽高 通过设置父盒子相对定位,子盒子绝对定位,top、right、bottom、left 均为 0,再通过 margin: auto; 属性来实现子盒子的水平垂直居中
.parent { position: relative; } .children { position: absolute; top: 0; left: 0; right: 0; bottom: 0; margin: auto; }
- 需要知道子盒子的宽高 通过设置父盒子相对定位,子盒子绝对定位并 top 和 left 距离 50%,实现了子盒子右上角居中,然后利用 margin-top 和 margin-left 属性等于盒子的负宽高,来实现子盒子的中心点居中
.parent { position: relative; } .children { position: absolute; top: 50%; left: 50%; margin-top: -200px; margin-left: -200px; }
第三种:table-cell 属性 通过设置子盒子为 display: table-cell; 显示类型 ,并分别通过 text-align: center; 和 vertical-align: middle; 实现了子盒子内的文本水平垂直居中
.children { display: table-cell; text-align: center; vertical-align: middle; }
注意:水平垂直居中的是盒子中的文本段落
第四种:flex 布局 通过设置父盒子为 display: display; 显示类型 ,并分别通过 justify-content: center; 和 align-items: center; 实现父盒子中的主轴、交叉轴居中,从而让子盒子水平垂直居中
.parents { display: flex; -webkit-display: flex; justify-content: center; -webkit-justify-content: center; align-items: center; -webkit-align-items: center; }
注意:需要添加浏览器前缀进行兼容
第五种:calc()函数 calc() 函数是 css 中用于动态计算长度值,运算符 ”-“ 前后都需要保留一个空格
.parents { position: relative; } .children { position: absolute; top: calc((100vh - 300px) / 2); top: -webkit-calc((100vh - 300px) / 2); left: calc((100vw - 300px) / 2); left: -webkit-calc((100vw - 300px) / 2); }
注意:需要添加浏览器前缀进行兼容
元素不定宽高水平垂直有哪几种:
- 父元素设置 display:table;子元素设置 display:table-cell 缺点:IE7 不支持,而且子元素会填满父元素,不建议使用
- 使用 css3 transform:translate(-50%; -50%) 缺点:兼容性不好,IE9+
- 使用 flex 布局 缺点:兼容性不好,IE9+
- 利用伪类元素
2. 说说 em、rem、vh、vw、px 、%的区别?移动端适配解决方案?
px: 是 pixel 像素的缩写,相对长度单位,网页设计常用的基本单位。像素 px 是相对于显示器屏幕分辨率而言的
em: em 是相对长度单位。相对于当前对象内文本的字体尺寸(参考物是父元素的 font-size).如当前父元素的字体尺寸未设置,则相对于浏览器的默认字体尺寸 特点:
- em 的值并不是固定的
- em 会继承父级元素的字体大小
rem: 是 CSS3 新增的一个相对单位,rem 是相对于 HTML 根元素的字体大小(font-size)来计算的长度单位. 如果你没有设置 html 的字体大小,就会以浏览器默认字体大小,一般是 16px
em 与 rem 的区别:rem 是相对于根元素(html)的字体大小,而 em 是相对于其父元素的字体大小
vw、vh vw、vh、vmax、vmin 这四个单位都是基于视口
vw 是相对视口(viewport)的宽度而定的,长度等于视口宽度的 1/100.(假如浏览器的宽度为 200px,那么 1vw 就等于 2px(200px/100))
vh 是相对视口(viewport)的高度而定的,长度等于视口高度的 1/100.(假如浏览器的高度为 500px,那么 1vh 就等于 5px(500px/100))
vmin 和 vmax 是相对于视口的高度和宽度两者之间的最小值或最大值
%(百分比) 一般来说就是相对于父元素:
- 对于普通定位元素就是我们理解的父元素
- 对于 position: absolute;的元素是相对于已定位的父元素
- 对于 position: fixed;的元素是相对于 ViewPort(可视窗口)
实现移动端适配的核心思想就是: 使用 rem 作为样式单位,根据不同分辨率的移动设备设置根元素的 font-size 值
常见问题:
- 如何使 1rem=10px? 在设置 HTML{font-size:62.5%;}即可
- 如果父元素没有指定高度,那么子元素的百分比的高度是多少? 会按照子元素的实际高度,设置百分比则没有效果
- 如何合理地设置根元素的 font-size 值呢?
- 将 viewport 宽度设置为移动设备逻辑宽度;
- 使用 js 根据不同分辨率的移动设备来设置根元素的 font-size 值,注意移动端与 pc 端的临界值;
- 在样式中字体使用 px 单位,而其它元素使用 rem 单位;
- 使用 sass 中的 function 来设置一个 px 与 rem 之间的转换函数;
以 iphone 6 的设计稿为基准,即设计稿横向分辨率为 750,取 100 为参照数(即在使用 rem 时与使用 px 时相差 100 的倍数),则我们可以知道 html 的宽度为 7.5rem(750 / 100),而我们知道 iphone 6 的逻辑宽度是 375px,所以 html 的宽度也为 375px,那么此时 7.5rem * html(font-size) = 375px,所以可以得出:html(font-size) = 375 / 7.5,即 html(font-size) = deviceWidth / 7.5
通过 js 来设置根元素的 font-size
前提:就是设置 viewport 宽度为移动设备的逻辑宽度var deviceWidth = document.documentElement.clientWidth document.documentElement.style.fontSize = deviceWidth / 7.5 + 'px'
注意:而当 deviceWidth 大于 750px 时,我们应该去访问的是 pc 版的页面,所以当 deviceWidth 大于 750px 时我们不应该再改变根元素的 font-size 值<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no" />
通过 sass 的 function 来设置一个 px 与 rem 之间的转换函数var deviceWidth = document.documentElement.clientWidth if (deviceWidth > 750) deviceWidth = 750 document.documentElement.style.fontSize = deviceWidth / 7.5 + 'px'
@function pxToRem($num) { @return ($num/100) * 1rem; } div { width: pxToRem(50); height: pxToRem(50); }
3. 谈谈你对 BFC 的理解?
BFC 即块级格式化上下文,是一个完全独立的空间(布局环境),让空间里的子元素不会影响到外面的布局
BFC 有以下特性:
- 块级元素会在垂直方式一个接一个的排列,和文档流的排列方式一样
- 垂直方向的距离由
margin
决定。属于同一个 BFC 中相邻的两个容器的margin
会发生重叠 - 计算 BFC 高度时,浮动元素也参与计算
- BFC 是一个独立的容器,容器里的内容不会影响到外面的内容
触发 BFC 的方式:
overflow
的值不为visible
position
的值为absolute
、fixed
display
的值为inline-block
、table-cell
、flex
BFC 有哪些作用:
- 实现两栏布局(第一个 float 后覆盖在第二个上面)
- 解决高度塌陷(使用 float 脱离文档流后,高度塌陷)
- 解决 margin 边距重叠
4. 说一下 FlexBox 和 Grid 布局,以及适用场景?
FlexBox 在 flex 布局中,有两个概念需要谨记:容器与元素。在一个 html 标签中声明样式:display:flex or display:inline-flex 即声明了一个 flex 的容器,在这个容器里面的元素即为 flex 元素。而 flex 所有的样式属性分为两类:容器属性与元素属性,他们均作用于 flex 元素,只不过 flex 容器中声明的属性统领 flex 所有元素整体显示与排布方式,而 flex 元素的属性表示单一元素的排布方式。
容器属性
- flex-direction: 是控制 flex 元素的整体布局方向的,它包括四个属性: row //从左到右 默认 row-reverse column //从上到下 column-reverse
- flex-wrap: 是控制元素是换行显示还是单行显示,它共有三个属性: no-wrap //不换行 默认 wrap // 换行 wrap-reverse //换行反序
- flex-flow: 是 flex-direction 与 flex-wrap 的统写,语法是: flex-flow:<‘flex-direction’> || <‘flex-wrap’>
- justify-content: 控制 flex 元素水平方向的对齐方式,它共有 个属性: flex-start // 默认值 默认情况下左对齐 flex-end // 右对齐 center // 居中
下面以 space 开头的属性都是描述剩余空间的排布方式的
space-around //剩余空间围绕在每个子元素的周围 space-between //剩余空间只分布在子元素之间,两端元素左右没有剩余空间 space-evenly // 剩余空间均匀分布在元素两端、之间
- align-content: 代表元素垂直方向上的分布,而这种分布方式只有在多行的情况下才能够凸显出来,单行情况下设置此属性无效。 相对于 justify-content,它多出来一个 stretch 的属性,代表拉伸,默认属性
- align-items: 就是在单行情况下,控制元素排布方式的。共有 5 个属性: stretch 默认属性,会将元素的高度拉伸至父容器的高度 flex-start 顶部对齐 flex-end 底部对齐 baseline 基线对齐 center 居中
元素属性
- order 属性:可以控制 flex 元素的排布顺序,flex 元素会根据 order 从小到大依次排布,order 的默认值为 0,因此如果想要某个元素排在前面,只需要将他的 order 值设为比 0 小的值即可。
- flex-grow:控制元素所占宽度,默认值为 0,当容器内有剩余空间时,flex-grow 不为 0 的元素将会按照以下规则扩展:
- 容器中只有一个元素设置了 flex-grow
- flex-grow 值>=1 那么这个元素会填充容器的剩余空间
- flex-grow 在 0-1 之间,那么这个元素会多占用空间为剩余空间乘以这个 flex-grow 的值
- 容器中有多个元素设置了 flex-grow
- 所有元素的 flex-grow 的值之和>1。则占用全部的剩余空间,多占用的剩余空间比例即为各个元素所设置 flex-grow 的比例。
- 所有元素的 flex-grow 的值之和<1。 所占用的剩余空间的比例即为各个元素的 flex-grow 的值的比例。
- flex-shrink 属性与 flex-grow 相反,指的是当空间不足的时候,元素的收缩比例,默认值为 1;其核心思路与 grow 一致
- flex-basis:定义了在分配剩余空间之前,每个元素的默认宽度,默认为 auto 即元素的宽度;当 flex-basis 的值不为 auto 时,其显示的优先级是高于元素的 width 的
- flex 属性为以上三个属性的统称,语法为: flex: none | auto | [ <'flex-grow'> <'flex-shrink'>? || <'flex-basis'> ] flex 翻译为中文就是弹性的,所以这个属性就是说明当有空间过大 or 空间不足时,每个元素如何分布。
- align-self:就是确定单个元素垂直分布状态
- Grid
与 Flex 相同,Grid 也分为容器与元素两个概念;在一个 html 标签中添加样式:display:grid 或者 display:inline-grid,即构建了一个 Grid 的容器,里面的 dom 元素即为 Grid 元素。同样,Grid 也分为两类属性,分别装载在容器与元素上
5. CSS3 中新增了哪些新特性?
答:一些选择器、边框(border-radius, border-image)、box-shadow、背景(background-origin、
background-size 等)、文字(word-wrap, text-overflow, text-shadow)、颜色(rgba, hsla)、transition 过渡、transform 转换(scale 缩放,translate 平移,rotate 旋转,skew 切斜)、animation 动画、渐变(linear-gradient 线性渐变和 radial-gradient 径向渐变)、Flex 布局、Grid 栅格布局、多列布局、媒体查询等等
6. CSS3 画一个三角形,其原理是什么?
我们有时候会需要用到一个三角形的形状,比如地址选择或者播放器里面播放按钮,通常情况下,我们会使用图片或者 svg 去完成三角形效果图
实心三角形
.border: { width: 0; height: 0; border-style: solid; border-width: 0 50px 50px; border-color: transparent transparent red; }
这时候就已经能够看到 4 个不同颜色的三角形,如果需要下方三角形,只需要将上、左、右边框设置为透明就可以得到下方的红色三角形. 但这种方式,虽然视觉上是实现了三角形,但实际上,隐藏的部分任然占据部分高度,需要将上方的宽度去掉
空心三角形(利用伪类) 伪类元素定位参照对象的内容区域宽高都为 0,则内容区域即可以理解成中心一点,所以伪元素相对中心这点定位
.border { width: 0; height: 0; border-style: solid; border-width: 0 50px 50px; border-color: transparent transparent #d9534f; position: relative; } .border:after { content: ''; border-style: solid; border-width: 0 40px 40px; border-color: transparent transparent #96ceb4; position: absolute; top: 6px; left: -40px; }
原理分析 采用的是均分原理:非常有意思的是盒子都是一个矩形或正方形,从形状的中心,向 4 个角上下左右划分 4 个部分
.square { width: 0; height: 0; margin: 0 auto; border-width: 6px; border-color: red transparent transparent transparent; border-style: solid dashed dashed dashed; // 为了兼容IE6,把没有的边框都设置为虚线 }
边框是实现三角形的部分,边框实际上并不是一个直线,如果我们将四条边设置不同的颜色,将边框逐渐放大,可以得到每条边框都是一个梯形.当分别取消边框的时候,发现下面几种情况:
- 取消一条边的时候,与这条边相邻的两条边的接触部分会变成直的
- 当仅有邻边时, 两个边会变成对分的三角
- 当保留边没有其他接触时,极限情况所有东西都会消失
- 通过上图的变化规则,利用旋转、隐藏,以及设置内容宽高等属性,就能够实现其他类型的三角形
如设置直角三角形 .box { /* 内部大小 */ width: 0px; height: 0px; /* 边框大小 只设置两条边*/ border-top: #4285f4 solid; border-right: transparent solid; border-width: 85px; /* 其他设置 */ margin: 50px; }
7. 获取元素的宽高的方式有哪些?
dom.style.width/height
原理:这种方式只能取到 dom 元素内联样式所设置的宽高;(也就是说如果该节点的样式是在 style 标签中或外联的 CSS 文件中设置的话,通过这种方法是获取不到 dom 的宽高的)
dom.currentStyle.width/height
原理:获取渲染后的宽高。(但仅 IE 支持)
dom.getComputedStyle(dom).width/height
原理:与 2 原理类似。但是兼容性,通用性更好一些
dom.getBoundingClientRect().width/height
原理:计算元素的绝对位置,获取到四个属性,left, top, right, bottom
8. 说说你对 盒子模型 的理解?
CSS3 中的盒模型有以下两种:标准盒模型、IE(怪异替代)盒模型。
两种盒子模型都是由 content + padding + border + margin 构成,其大小都是由 content + padding + border 决定的,但是盒子内容宽/高度(即 width/height)的计算范围根据盒模型的不同会有所不同:
- 标准盒模型:只包含 content
- IE(替代)盒模型:content + padding + border
可以通过 box-sizing 来改变元素的盒模型:
- box-sizing: content-box :标准盒模型(默认值)
- box-sizing: border-box :IE(替代)盒模型
9. CSS 中选择器有哪些?优先级?哪些属性可以继承?
样式的优先级一般为 !important
> style
> id
> class
> 标签选择器
优先级:
- !important
- 内联样式(1000)
- ID 选择器(0100)
- 类选择器/属性选择器/伪类选择器(0010)
- 元素选择器/伪元素选择器(0001)
- 关系选择器/通配符选择器(0000)
总结:带!important 标记的样式属性优先级最高; 样式表的来源相同时:!important > 行内样式>ID 选择器 > 类选择器 > 标签 > 通配符 > 继承 > 浏览器默认属性
css 属性选择器常用的有:
- id 选择器(#box),选择 id 为 box 的元素
- 类选择器(.one),选择类名为 one 的所有元素
- 标签选择器(div),选择标签为 div 的所有元素
- 后代选择器(#box div),选择 id 为 box 元素内部所有的 div 元素
- 子选择器(.one>one_1),选择父元素为.one 的所有.one_1 的元素
- 相邻同胞选择器(.one+.two),选择紧接在.one 之后的所有.two 元素
- 群组选择器(div,p),选择 div、p 的所有元素
- 通配符选择器(*)
一些使用频率相对不是很多的选择器:
- 伪类选择器(:link, :visited, :active, :hover)
- 伪元素选择器(:after, :before)
- 属性选择器([attribute], [attribute=value])
CSS3 中新增的选择器:
- 伪类选择器(:nth-child(n), :not, :last-child)
- 属性选择器([attribute^=value])
优先级是由 A 、B、C、D 的值来决定的,其中它们的值计算规则如下:
- 如果存在内联样式,那么 A = 1,否则 A = 0
- B 的值等于 ID 选择器(#id) 出现的次数
- C 的值等于 类选择器(.class) 和 属性选择器(a[href="https://example.org"]) 和 伪类(:first-child) 出现的总次数
- D 的值等于 标签选择器(h1,a,div) 和 伪元素(::before,::after) 出现的总次数
知道了优先级是如何计算之后,就来看看比较规则:
- 从左往右依次进行比较 ,较大者优先级更高
- 如果相等,则继续往右移动一位进行比较
- 如果 4 位全部相等,则后面的会覆盖前面的
CSS 属性继承
在 css 中,继承是指的是给父元素设置一些属性,后代元素会自动拥有这些属性
关于继承属性,可以分成:
字体系列属性
文本系列属性
元素可见性
表格布局属性
注意:继承中比较特殊的几点:
- a 标签的字体颜色不能被继承
- h1-h6 标签字体的大小也是不能被继承的
无继承的属性:
- display
- 文本属性:vertical-align、text-decoration
- 盒子模型的属性:宽度、高度、内外边距、边框等
- 背景属性:背景图片、颜色、位置等
- 定位属性:浮动、清除浮动、定位 position 等
- 生成内容属性:content、counter-reset、counter-increment
- 页面样式属性:size、page-break-before、page-break-after
10. display 和 position 有哪些值?
- display 的值:none、inline、inline-block、block、table、flex
- position 的值:static、relative、absolute、fixed、inherit
11. 清除浮动的方式有哪些?
- 添加额外标签,使用 clear:both;清除浮动
<div class="parent"> <!-- 添加额外标签并且添加clear属性 --> <div style="clear:both"></div> <!-- 也可以加一个br标签 --> </div>
- 利用伪元素:after 来清除浮动
//在css中添加:after伪元素 .parent:after { /* 设置添加子元素的内容是空 */ content: ''; /* 设置添加子元素为块级元素 */ display: block; /* 设置添加的子元素的高度0 */ height: 0; /* 设置添加子元素看不见 */ visibility: hidden; /* 设置clear:both */ clear: both; }
- 使用 CSS 的 overflow 属性(BFC)
12. 说说重绘 & 重排?
简单地总结下两者的概念:
- 重排:无论通过什么方式影响了元素的几何信息(元素在视口内的位置和尺寸大小),浏览器需要重新计算元素在视口内的几何属性,这个过程叫做重排
- 重绘:通过构造渲染树和重排(回流)阶段,我们知道了哪些节点是可见的,以及可见节点的样式和具体的几何信息(元素在视口内的位置和尺寸大小),接下来就可以将渲染树的每个节点都转换为屏幕上的实际像素,这个阶段就叫做重绘
如何减少重排和重绘?
- 最小化重绘和重排,比如样式集中改变,使用添加新样式类名 .class 或 cssText
- 批量操作 DOM,比如读取某元素 offsetWidth 属性存到一个临时变量,再去使用,而不是频繁使用这个计算属性;又比如利用 document.createDocumentFragment() 来添加要被添加的节点,处理完之后再插入到实际 DOM 中
- 使用 absolute 或 fixed 使元素脱离文档流,这在制作复杂的动画时对性能的影响比较明显
- 开启 GPU 加速,利用 css 属性 transform 、will-change 等,比如改变元素位置,我们使用 translate 会比使用绝对定位改变其 left 、top 等来的高效,因为它不会触发重排或重绘,transform 使浏览器为元素创建⼀个 GPU 图层,这使得动画元素在一个独立的层中进行渲染。当元素的内容没有发生改变,就没有必要进行重绘
扩展:transform 会造成回流吗? 渲染流水线是这样的顺序:重排 -> 重绘 -> 合成;transform: translate 是直接合成,跳过了前面的重排重绘。
13. defer、async 的区别?
- script :会阻碍 HTML 解析,只有下载好并执行完脚本才会继续解析 HTML。
- async script :解析 HTML 过程中进行脚本的异步下载,下载成功立马执行,有可能会阻断 HTML 的解析。
- defer script:完全不会阻碍 HTML 的解析,解析完成之后再按照顺序执行脚本。
14. 说说 html、css、js 的加载顺序?
DOM 解析:把所写的各种
html
标签,生成一个DOM TREE
,相当于是生成了一个最原始的页面,一点样式都没有,毫无CSS
修饰DOM 渲染:浏览器会把本身默认的样式 + 用户自己写得样式整合到一起,形成一个
CSS TREE
,而DOM
渲染就是指DOM TREE
和CSS TREE
结合到一起,生成一个Render TREE
,呈现出一个带有样式的页面DOM 与 CSS
css
的加载不会阻塞DOM
的解析css
的加载会阻塞DOM
的渲染
- DOM 与 JS
JS
(加载和执行) 都会阻塞DOM
的解析JS
(加载和执行) 都会阻塞DOM
的渲染
注:html
中每遇到 script
标签,页面就会重新渲染一次,因为要保证标签中的 JS
代码拿到的都是最新的样式
- CSS 与 JS
CSS
的加载阻塞JS
的运行,不阻塞JS
的加载
注:CSS
的渲染 GUI 线程
和 JS 运行线程
互斥
总结:
- script 最好放底部,link 最好放头部
- 如果头部同时有
script
与link
的情况下,最好将script
放在link
上面
扩展:为了避免让用户看到长时间的白屏时间,我们应该尽可能的提高 css 加载速度,比如可以使用以下几种方法:
- 使用 CDN(因为 CDN 会根据你的网络状况,替你挑选最近的一个具有缓存内容的节点为你提供资源,因此可以减少加载时间)
- 对 css 进行压缩(可以用很多打包工具,比如 webpack,gulp 等,也可以通过开启 gzip 压缩)
- 合理的使用缓存(设置 cache-control,expires,以及 E-tag 都是不错的,不过要注意一个问题,就是文件更新后,你要避免缓存而带来的影响。其中一个解决防范是在文件名字后面加一个版本号)
- 减少 http 请求数,将多个 css 文件合并,或者是干脆直接写成内联样式(内联样式的一个缺点就是不能缓存)
- 不要在嵌入的 JS 中调用运行时间较长的函数,如果一定要用,可以用
setTimeout
来调用
15. 你知道哪几种方式可以隐藏一个元素?
答:opacity/filter: opacity()、clip-path、position、z-index、visibility、覆盖一个元素(伪类:after)、display、transform: scale(0)/translate(-999px, 0px)、color alpha、缩小尺寸等
16. 了解 H5 的 draggable 拖拽吗?
为了使元素可拖动,把 draggable 属性设置为 true。draggable 属性可用于任何元素节点,但是图片(img)和链接(a)不加这个属性,就可以拖拉
注意:一旦某个元素节点的 draggable 属性设为 true,就无法再用鼠标选中该节点内部的文字或子节点了
当元素节点或选中的文本被拖拉时,包括以下一些事件:
- drag:拖拉过程中
- dragstart:用户开始拖拉时
- dragend:拖拉结束时
- dragenter:拖拉进入当前节点时
- dragover:拖拉到当前节点上方时
- dragleave:拖拉操作离开当前节点范围时
- drop:被拖拉的节点或选中的文本,释放到目标节点时
四、Vue 相关
1. Vue3 和 Vue2 有哪些区别?
- 数据响应式原理变化,Vue2:Object.defineProperty();Vue3:Proxy
- Vue3 新增内置组件:Fragment(文档碎片)、Suspense(异步组件)、Teleport(瞬移组件)
- Vue3 提供 Composition API;Vue2 是 Options API;自定义函数(hooks)
- Vue3 中生命周期前面都加了 on,移除了 beforeCreate 和 created 钩子函数
- Vue3 源码采用 TS 开发,Vue2 采用 flow;-> Vue3 对 TS 支持更加友好
- Vue3 源码采用 monorepo 方式进行管理,将模块拆分到 packages 目录中
- Vue3 支持 tree-shaking,不使用就不会打包,提升性能
- Vue3 中对模块编译进行了优化,编译时生成 block tree,可以对子节点进行收集。可以减少比较,并且采用了 patchFlag 标记动态节点。
- Vue3 中对全局 API 的改变
2. 说一下 Vue2 和 Vue3 的响应式原理?
Vue2:当创建 Vue 实例时,Vue 会遍历 data 选项的属性,利用 Object.defineProperty 为属性添加 getter 和 setter(getter 用来依赖收集,setter 用来派发更新),并且在内部追踪依赖,在属性被访问和修改时通知变化。
Watcher、Dep、Observer 三者的区别?
- Observer:它的作用是给对象的属性添加 getter 和 setter,用于依赖收集和派发更新
- Dep:用于收集当前响应式对象的依赖关系,每个响应式对象包括子对象都拥有应 Dep 实例(里面 subs 是 watcher 实例数组),当数据变更时,会通过 dep.notify()通知各个 watcher
- Watcher:观察者对象(渲染 watcher、计算属性 watcher、用户传的 watcher) 扩展:Watcher 和 Dep 的关系? 一个属性可能有多个依赖,每个响应式数据都有一个 Dep 来管理它的依赖
Vue 实例挂载的过程发生了什么?(new Vue()发生了啥?)
- src/core/instance/index.js -> new Vue() -> this._init(options) -> initMixin(Vue)[原型上挂载_init 方法]
- scr/core/instance/init.js -> Vue.prototype._init -> 合并配置 mergeOptions() -> 初始化生命周期(initLifecycle) -> 初始化事件中心(initEvents) -> 初始化渲染(initRender) -> 挂载 beforeCreate 钩子 -> 初始化 Inject(initInject) -> 初始化 initState(data props computed watcher)等等 -> 初始化 Provide(initProvide) -> 初始化 created 钩子 -> 最后判断是否有 el 使用$mount 挂载
- src/core/instance/state.js -> initState: 会对 props、methods、data、computed、watcher 进行初始化=》数组的基本来源
- src/core/instance/state.js -> initData: 初始化数据 -> 判断 data 是否是函数 -> 获取 data 所有的 key 和 props、methods 判断是否有重名 -> 观测数据(observe(data))
- src/core/observer/index.js -> observe() -> 通过
__ob__
判断数据是否被观测 -> 如果观测就返回观测的数据,若没有观测就实例化(new Observer(value)) - src/core/observer/index.js -> class Observer 类 -> 1、给每个属性添加ob属性;2、判断数组还是对象;3、数组走 observeArray(), 对象走 walk()【注意:此时实例化 dep;this.dep = new Dep()】
- 对象情况:循环所有的 key, 调用defineReactive() -> Object.defineProperty()
get() [dep.depend()依赖收集【如果有孩子深度观测 childOb.dep.depend();若孩子是数组走 dependArray()(循环所有的孩子,e && e.
__ob__
&& e.__ob__
.dep.depend());如果孩子里还有数组,递归调用 dependArray()】]- src/observer/dep.js -> depend() :如果 Dep.target 存在,就添加依赖 Dep.target.addDep(this)
- src/observer/watcher.js -> addDep(dep) :将 dep 添加到 watcher 的 newDeps 中;通过 addSub()将 watcher 添加到 Dep 的 subs 数组中(判断是否重复,相同的只能添加一次),不存在的话就将 watcher 添加到 dep 中 dep.addSub(this)
- src/observer/dep.js -> addSub(sub: Watcher)将 watcher 添加到 dep 中 this.subs.push(sub)
set() [dep.notify()触发更新]【如果孩子是数组也要进行观测 childOb = observe(newVal)】-> dep.notify()
- src/observer/dep.js -> notify() : 遍历 Dep 中的 subs 里所有的 watcher,循环调用 watcher 的 update 方法;subs[i].update()
- src/observer/watcher.js -> update(): 如果是同步 this.sync 调用 this.run() 【调用 watcher 中的 get 方法 this.get()获取最新 value,将 this.value 赋值给 oldValue; 将最新的 value 赋值给 this.value;调用 this.cb.call(this.vm, value, oldValue)】-> 如果是异步调用 queueWatcher(this)
- src/observer/scheduler.js -> queueWatch(watcher: Watcher)方法: -> nextTick(flushSchedulerQueue)
- src/observer/scheduler.js -> flushSchedulerQueue() 就是将更新队列中的 watcher 拿出来并依次调用他们的 callback,但重点在于为什么在 for 循环之前先对这些 watcher 进行了升序排列
- src/platform/web/entry-runtime-with-compiler.js -> $mount()重写$mount 方法 -> 判断(是否 render 函数、是否有 template、都没有则 el.outerHTML )-> 将 template 转换成 render 方法【compileToFunction(template)】
线路图:new Vue => _init => $mount => mountComponent
src/instance/lifecycle.js:mountComponent() -> 挂载组件 -> callHook(vm, 'beforeMount') -> 定义 updateComponent -> new Watcher(vm, updateComponent, () => {}, true) // == updateComponent() ;默认 vue 是通过 watcher 来进行渲染 渲染 watcher;
- _render()函数返回的就是虚拟 dom
- _update()方法将虚拟节点转换成真实节点
- _render 函数会读取 data 中的数据从而触发 getter 方法进行依赖收集(会调用 watcher 中的 get 方法进行求值)
扩展:Vue2 中 runtime+compiler 和 runtime-only 的区别?
- runtime-only 相比 runtime-compiler 有两个优点:
- 运行效率高
- 源代码量更少
- runtime-compiler 模式,runtime-only 模式在 src 文件里面只在 main.js 里面有区别:
- runtime-compiler 模式里的 Vue 实例的模板,和注册的组件,都被一个 render 函数替换掉了
- h 函数是 createElement 的缩写-》用于创建虚拟 DOM 的
- **runtime-compiler 的步骤:**template -> ast -> render -> virtual dom -> 真实 dom
- **runtime-only 的步骤:**render -> virtual dom -> 真实 dom 问题:**runtime-only:**main.js 中的 template 被替换掉了,那组件中 template 还存在,他是怎么编译的呢? Vue-template-compiler,运行项目时,这个包会自动将组件的 template 转换成 render 函数
3. Vue2 和 Vue3 组件通信方式有哪些?
Vue2 组件通信方式:
- 父 -> 子:props; 子 -> 父:$emit【单向数据流:props 只能从上一级组件传递到下一级组件】
- 父子组件之间:$parents、$children 获取组件实例
- provide 和 inject :父组件中通过 provide 来提供变量,然后子组件中使用 inject 来注入变量【Vue2.2.0 新增】
- ref 和 refs:如果在普通的 DOM 元素上,引用指向的是 DOM 元素;如果用在子组件上,引用就指向组件是来,可以通过实例获取组件的方法和数据等。
- EventBus:事件总线($emit派发事件、$on 注册事件、$off 移除事件)
- $attrs 和 $listeners:新增了inheritAttrs 选项,默认值为 true【Vue2.4 新增】
- Vuex:专为 Vue.js 应用程序开发设计的状态管理
Vue3 组件通信方式:
- 父 -> 子:props; 子 -> 父:emit
- v-model 方式
- $refs 【defineExpose 暴露属性】
- provide 和 inject
- 事件总线(Vue3 中移除了 Event-Bus,但是可以借助第三方工具:Vue 官方推荐:mitt 和 tiny-emitter)
- Vuex4
- Pinia
4. 说一说 nextTick 的原理?
nextTick 作用:是在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。
再回答 nextTick 实现原理之前,首先要了解 JS 的执行机制
JS 是单线程的,一次只能干一件事,即同步。(就是说所有的任务都需要排队,后面的任务需要等前面的任务执行完才能执行,如果前面的任务耗时过长,后面的任务就需要一直等,这是非常影响用户体验的)。所以有了 异步 的概念
- 同步任务:指排队在主线程上依次执行的任务
- 异步任务:不进入主线程,而进入任务队列的任务,又分为宏任务和微任务
- 宏任务: 渲染事件、请求、script、setTimeout、setInterval、Node 中的 setImmediate 等
- 微任务: Promise.then、MutationObserver(监听 DOM)、Node 中的 Process.nextTick 等
当执行栈中的同步任务执行完后,就会去任务队列中拿一个宏任务放到执行栈中执行,执行完该宏任务中的所有微任务,再到任务队列中拿宏任务,即一个宏任务、所有微任务、渲染、一个宏任务、所有微任务、渲染...,如此形成循环,即事件循环(EventLoop)
Vue2 中 nextTick 源码解析:
源码地址:src/core/util/next-tick.js,源码版本:2.6.14
源码的主要的两个作用:
- 一是判断当前环境能使用的最合适的 API 并保存异步函数
主要是判断用哪个宏任务或微任务,因为宏任务耗费的时间是大于微任务的,所以成先使用微任务,判断顺序如下:
- Promise
- MutationObserver
- setImmediate
- setTimeout 环境判断结束就会得到一个延迟回调函数 timerFunc,然后进入核心的 nextTick
- 二是调用异步函数 执行回调队列
nextTick 方法主要逻辑就是:
- 把传入的回调函数放进回调队列 callbacks
- 执行保存的异步任务 timeFunc,就会遍历 callbacks 执行相应的回调函数了
注意:如果没有提供回调,并且支持 Promise,就返回一个 Promise。 -> this.$nextTick().then(()=>{ ... })
- 一是判断当前环境能使用的最合适的 API 并保存异步函数
主要是判断用哪个宏任务或微任务,因为宏任务耗费的时间是大于微任务的,所以成先使用微任务,判断顺序如下:
名词解析:
- isUsingMicroTask:是否启用微任务开关
- callbacks:回调队列
- pending:异步控制开关,同一时间只能执行一次
- flushCallbacks():该方法负责执行队列中的全部回调;执行之前先备份并清空回调队列,是为了防止 nextTick 里有 nextTick 出现的问题
- timerFunc:用来保存调用异步任务方法
Vue3 中 nextTick 源码解析:
- 源码地址:packages/runtime-core/src/scheduler.ts,源码版本:3.2.11
- vue3 中 nextTick 的队列由几个方法维护,基本执行顺序是这样的: queueJob -> queueFlush -> flushJobs -> nextTick 参数的 fn
- 【还需探索...】
5. 为什么 index 不能作为 key, 谈谈你对 key 的理解?
- 不能用随机数作为 key, 不然会全部重新创建
6. 有写过自定义指令吗?自定义指令的应用场景?
7. 前端路由的原理?
- 什么叫路由? 核心:根据不同的路径跳转不同的组件
vue-router 提供三种路由模式:
- hash 模式【默认】 通过路径中的 hash 值来控制路由跳转,不存在兼容问题。 原理:在正常路径后跟一个 # 号,匹配 # 后边的路径为前端路由,通过 window.onhashchange 方法来操控路由改变的时候切换内容
- history 模式 H5 新增的 history API,相对 hash 而言,不会显示#号,但是需要服务器端配置 原理:在 HTML5 中,新增了 pushState 和 replaceState,通过这两个 API 可以改变 url 地址且不会发送请求,同时还有 popstate 事件,实现原理与 hash 相似,只不过因为没有了 # 号,所以刷新页面还是会向服务器发送请求,而后端没有对应的处理措施的话,会返回 404,所以需要后端配合
- abstract 模式 支持 javascript 的所有运行环境,常指 Node.js 服务器环境 原理:abstract 模式是不依赖于浏览器环境的一种模式,它是 VueRouter 内部使用数组进行模拟了路由管理,在 node 环境,或者原生 App 环境下,都会默认使用 abstract 模式,VueRouter 内部会根据所处的环境自行判断,默认使用 hash 模式,如果检测到没有浏览器 API 的时候,就会使用 abstract 模式
提供了两个函数式组件: router-link 和 router-view
vue-router 使用方式
Vue.use(VueRouter); 注册插件(如果是在浏览器环境运行的,可以不写该方法) 安装 router-link 和 router-view 组件,并且给当前应用下所有组件都注入$outer $route 对象
- 定义/导入(路由)组件
- 定义路由 routes
- 创建 router 实例,并传
routes
配置 - 创建和挂载根实例
- VueRouter 构造函数
- constructor() -> this.matcher = createMatcher(options.routes || []);扁平化用户传入的数据,创建路由映射表
- createMatcher 方法返回:
- match 匹配根据传入的 location 路径在 pathMap 找到对应的记录,createRoute()中生成一个新的 route 路径
- addRoutes 作用是动态添加路由配置
- addRoutes 的实现
- 是调用 createRouteMap()目标是把用户的路由配置转成一张路由映射表。方法中遍历路由配置 routes, 返回值是一个包括 pathList 、pathMap 、nameMap 的对象
- pathList 是存储所有 path 值,pathMap 表示一个 path 到 RouteRecord 的映射关系,nameMap 表示 name 到 RouteRecord 的映射关系
- 判断 mode 模式,switch(mode)根据不同模式,来创建不同路由对象
- install(Vue) 方法
- Vue.mixin 在 beforeCreate();在所有的组件上新增了一个_routerRoot 属性,通过这个属性可以拿到根实例;
- this.$options.router 判断是否是根组件。是 this._routerRoot = this;不是 this._routerRoot = this.$parent && this.$parent._routerRoot(子组件)
- this._router.init(this)调用初始化路由系统
- Vue.util.defineReactive(this, '_route', this._router.history.current) // 将属性变成响应式,数据一变界面就更新;
- 使用 Object.defineProperty 在 Vue.property 原型上定义$route和$router 属性,get()通过代理拿到 this._routerRoot._route;this._routerRoot._router
- 注册全局组件 RouterView 和 RouterLink
- init(app)初始化路由系统
- 通过 instanceof 判断 history 是哪一种路由;HTML5History、HashHistory、AbstractHistory 都是继承 History.针对于 HTML5History 和 HashHistory 特殊处理,因为在这两种模式下才有可能存在进入时候的不是默认页,需要根据当前浏览器地址栏里的 path 或者 hash 来激活对应的路由
路由更新方式:
- 主动触发 router-link 绑定了 click 方法,触发 history.push 或者 history.replace,从而触发 history.transitionTo。transitionTo 用于处理路由转换,其中包含了 updateRoute 用于更新_route.当_route 变化后,触发 router-view 的变化
- 地址变化(如:在浏览器地址栏直接输入地址) HashHistory 和 HTML5History 会分别监控 hashchange 和 popstate 来对路由变化作对用的处理。HashHistory 和 HTML5History 捕获到变化后会对应执行 push 或 replace 方法,从而调用 transitionTo
路由变更到视图的过程: hash -> match router -> set _route -> router-view render() -> render matched component
实现过程技术点:
- Vue 插件机制
- 数据响应式机制
- Vue 渲染机制
- 地址变更监听
8. 你知道 Vue 页面不刷新的情况有哪些?
- Vue 无法检测实例被创建时不存在 data 中的属性
- Vue 无法检测对象属性的添加和移除
- Vue 通过数组下标(索引)修改一个数据项
- Vue 不能直接修改数组的 length
- 在异步执行之前操作 DOM
- 循环嵌套太深,视图不更新 -> 解决:$forceUpdate()
- 路由参数变化时,页面不刷新 -> 解决:watch 监听$route 和 router-view 添加 key
9. keep-alive 的原理?
keep-alive 是一个抽象组件:它自身不会渲染一个 DOM 元素,也不会出现在父组件链中;使用 keep-alive 包裹动态组件时,会缓存不活动的组件实例,而不是销毁它们。
总的来说:keep-alive 用于保存组件的渲染状态,防止组件多次渲染。
keep-alive 用法:
- 在动态组件中的应用
<keep-alive :include="whiteList" :exclude="blackList" :max="amount"> <component :is="currentComponent"></component> </keep-alive>
- 在 vue-router 中的应用
<keep-alive :include="whiteList" :exclude="blackList" :max="amount"> <router-view></router-view> </keep-alive>
- 在动态组件中的应用
其中:include 定义缓存白名单,keep-alive 会缓存命中的组件;exclude 定义缓存黑名单,被命中的组件将不会被缓存;max 定义缓存组件上限,超出上限使用 LRU 的策略置换缓存数据。
- 缓存淘汰策略 LRU(最近最少使用)
源码解析过程:
路径:src/core/components/keep-alive.js
abstract 属性:true,判断当前组件虚拟 dom 是否渲染成真实 dom 的关键
props 属性:
- include:缓存白名单
- exclude:缓存黑名单
- max:缓存的组件数量
keep-alive 在它生命周期内定义了三个钩子函数:
created:初始化两个对象分别缓存 VNode(虚拟 DOM)cache 和 VNode 对应的键集合 keys
destroyed:删除 this.cache 中缓存的 VNode 实例 扩展:为什么不直接将 this.cache 置为 null,而是遍历调用 pruneCacheEntry 函数删除? 删除缓存的 VNode 还要对应组件实例的 destroy 钩子函数
mounted:对 include 和 exclude 参数进行监听,然后实时地更新(删除)this.cache 对象数据
render:
- 第一步:获取 keep-alive 包裹着的第一个子组件对象及其组件名
- 第二步:根据设定的黑白名单(如果有)进行条件匹配,决定是否缓存。不匹配,直接返回组件实例(VNode),否则执行第三步
- 第三步:根据组件 ID 和 tag 生成缓存 Key,并在缓存对象中查找是否已缓存过该组件实例。如果存在,直接取出缓存值并更新该 key 在 this.keys 中的位置(更新 key 的位置是实现 LRU 置换策略的关键),否则执行第四步
- 第四步:在 this.cache 对象中存储该组件实例并保存 key 值,之后检查缓存的实例数量是否超过 max 设置值,超过则根据 LRU 置换策略删除最近最久未使用的实例(即是下标为 0 的那个 key)
- 第五步:最后并且很重要,将该组件实例的 keepAlive 属性值设置为 true
- 新增两个钩子函数:activated、deactivated
Vue 的渲染过程:
new Vue -> init -> $mount -> compile -> render -> VNode -> patch -> DOM
可得:Vue 的渲染是在 render 阶段开始的,但 keep-alive 的渲染是在 patch 阶段(将虚拟 DOM 转换成真实 DOM)
扩展:从 render 到 patch 的过程?
- Vue 在渲染的时候先调用原型上的_render 函数将组件对象转化成一个 VNode 实例。而_render 是通过调用 createElement 和 createEmptyVNode 两个函数进行转化
- createElement 的转化过程会根据不同的情形选择 new VNode 或者调用 createComponent 函数做 VNode 实例化
- 完成 VNode 实例化后,这时候 Vue 调用原型上的_update 函数把 VNode 渲染成真实 DOM,这个过程又是通过调用 patch 函数完成的(这就是 patch 阶段了)
常见问题:
keep-alive 不会生成真正的 DOM 节点,Vue 是如何做到的?
Vue 在初始化生命周期的时候,为组件实例建立父子关系会根据 abstract 属性决定是否忽略某个组件。在 keep-alive 中,设置了 abstract:true,那 Vue 就会跳过该组件实例。
// src/core/instance/lifecycle.js if (parent && !options.abstract) { while (parent.$options.abstract && parent.$parent) { parent = parent.$parent } parent.$children.push(vm) }
keep-alive 包裹的组件是如何使用缓存的?
在 patch 阶段,会执行 createComponent 函数:
// src/core/vdom/patch.js function createComponent() { let i = vnode.data if (isDef(i)) { if (isDef((i = i.hook)) && isDef((i = i.init))) { i(vnode, false) } if (isDef(vnode.componentInstance)) { initComponent(vnode, insertedVnodeQueue) insert(parentElem, vnode.elem, refElem) // 将缓存的DOM(vnode.elem) 插入父元素中 } } }
- 在首次加载被包裹组建时,由 keep-alive.js 中的 render 函数可知,vnode.componentInstance 的值是 undefined,keepAlive 的值是 true,因为 keep-alive 组件作为父组件,它的 render 函数会先于被包裹组件执行;那么只执行到 i(vnode,false),后面的逻辑不执行
- 再次访问被包裹组件时,vnode.componentInstance 的值就是已经缓存的组件实例,那么会执行 insert(parentElm, vnode.elm, refElm)逻辑,这样就直接把上一次的 DOM 插入到父元素中
10. 你知道 Vue2 的模板编译原理吗?和 Vue3 做了哪些改进?
11. Vue3 中的 Composition API 和 Vue2 中的 Options API 有什么不同?
通常使用 Vue2 开发的项目,普遍会存在以下问题:
- 代码的可读性随着组件变大而变差
- 每一种代码复用的方式,都存在缺点
- TypeScript 支持有限
mixins 的缺陷:
- 命名冲突
- 数据来源不清楚
两者的主要区别:
- 在逻辑组织和逻辑复用方面,Composition API 是优于 Options API
- 因为 Composition API 几乎是函数,会有更好的类型推断
- Composition API 对 tree-shaking 友好,代码也更容易压缩
- Composition API 中见不到 this 的使用,减少了 this 指向不明的情况
- 如果是小型组件,可以继续使用 Options API,也是十分友好的
12. 说一下 Vue2 中 Diff 算法?Vue3 做了哪些改进?
虚拟 DOM 是一个 JS 对象,虚拟 DOM 是一个对象. 虚拟 DOM 算法 = 虚拟 DOM + Diff 算法
使用虚拟 DOM 算法的损耗计算: 总损耗 = 虚拟 DOM 增删改+(与 Diff 算法效率有关)真实 DOM 差异增删改+(较少的节点)排版与重绘
直接操作真实 DOM 的损耗计算: 总损耗 = 真实 DOM 完全增删改+(可能较多的节点)排版与重绘
Diff 算法的目的是什么? 为了减少 DOM 操作的性能开销,我们要尽可能的复用 DOM 元素。所以我们需要判断出是否有节点需要移动,应该如何移动以及找出那些需要被添加或删除的节点。
diff 算法是一种通过同层的树节点进行比较的高效算法, diff 整体策略为:深度优先,同层比较
其有两个特点:
- 比较只会在同层级进行, 不会跨层级比较
- 在 diff 比较的过程中,循环从两边向中间比较
- 原理分析 源码位置:src/core/vdom/patch.js
当数据发生改变时,set 方法会调用 Dep.notify 通知所有订阅者 Watcher,订阅者就会调用 patch 给真实的 DOM 打补丁,更新相应的视图
对比流程:set -> Dep.notify -> patch(oldVnode, newVnode) -> isSameVnode() -> 不是直接替换,是走 patchVnode(1. oldVnode 有子节点, newVnode 没有;2. oldVnode 没有子节点, newVnode 有;3. 都是文本节点;4. 都有子节点)
sameVnode 方法判断是否为同一类型的节点 如何才算同一类型的节点,sameVnode(oldVnode, newVnode)几种情况:
- key 值是否一样
- tagName 标签名是否一样
- isComment 是否都是注释节点
- 是否都定义了 data(class, style)是否一样
- sameInputType() 当前节点为 input 时,type 必须相同
patchVnode 函数做了哪些事情 el: 真的的 DOM,oldVnode: 旧节点,newVnode: 新节点
- 获取对应的真实 DOM,el
- 判断 newVnode 和 oldVnode 是否指向同一个对象,如果是,直接返回
- 如果他们都有文本节点并且不相同,那么将 el 的文本节点设置为 newVnode 的文本节点
- 如果 oldVnode 有子节点而新节点没有,则删除 el 的子节点
- 如果 oldVnode 没有子节点而新节点有,则将 newVnode 的子节点真实化之后添加到 el
- 如果两者都有子节点,则执行 updateChildren 函数比较子节点
updateChildren 函数五种比较情况: 记住一点,最终的渲染结果都要以 newVDOM 为准,这也解释了为什么之后的节点移动需要移动到 newVDOM 所对应的位置
- 旧头 & 新头
- 旧头 & 新尾
- 旧尾 & 新头
- 旧尾 & 新尾
- 如果以上逻辑都不匹配,再把所有旧子节点的 key 做一个映射到旧节点下标的 key -> index 表,然后用新 vnode 的 key 去找出在旧节点中可以复用的位置
13. 描述一下组件的渲染和更新的过程?
渲染组件时,会通过 Vue.extend 方法构建子组件的构造函数,初始化组件的时候会进行实例化。然后手动调用$mount()进行组件挂载渲染。更新组件时会进行 patchVnode 流程,核心就是 diff 算法。
14. Vue 为什么要采用 异步组件?抽象组件是个啥?动态组件?函数式组件优势?
异步组件可以减少打包的结果。会将异步组件分开打包,会采用异步的方式加载组件,可以有效的解决一个组件过大的问题。不使用异步组件,如果组件功能比较多打包出来的结果就会变大
异步组件的核心可以给组件定义变成一个函数,函数里面可以用 import 语法,实现文件的分割加载,import 语法是 webpack 提供的,采用的就是 jsonp
components: {
A: () => import('xxxx.vue')
B(resolve) {
require(['xxx.vue'], resolve)
}
}
- 异步组件的原理? 在 createComponent()方法中,定义了 asyncFactory 变量。然后判断 isUndef(Ctor.cid),是否组件是一个函数,是则 asyncFactory = Ctor,调用 resolveAsyncComponent()方法;Ctor = resolveAsyncComponent(asyncFactory, baseCtor),将 asyncFactory 传入让 asyncFactory 立马执行,但不会马上返回结果,会返回一个 promise。此时的值为 undefined。然后调用 createAsyncPlaceholder()方法创建一个异步组件的占位,空虚拟节点。当加载此组件的时候会调用 factory 函数传入 resolve, reject 两个参数,执行后返回影成功的回调和失败的回调,成功就调 resolve,resolve 中会调用 forceRender 强制更新视图重新渲染,forceRender 中调用$forceUpdate()。同时把结果放到 factory.resolve 上。如果强制刷新的时候就会再次走 resolveAsyncComponent 方法,这时候有个判断,如果有成功的结果就把结果直接放回去,这时候 resolveAsyncComponent 返回的就不是 undefined 了,就会接的创建组件,初始化组件,渲染组件
15. Vue-Router 中导航守卫有哪些?
- 组件内导航守卫:
- beforeRouterEnter: 在页面还未进入的时候。这个时候 vue 实力还未创建。因此访问不到 this。
- beforeRouterUpdate:当前页面组件被复用的时候,页面的路径发生了改变,会触发此方法。在这个时候是可以访问到 this 的
- beforeRouterLeave:离开当前路由的时候,即要离开当前页面的时候,这个时候是可以访问到 this 的
- 全局导航守卫:
- router.beforeEach: 全局前置守卫进入路由之前触发
- router.beforeResolve: 全局解析守卫在 beforeRouteEnter 调用之后调用
- router.beforeAfter: 全局后置钩子进入路由后触发
- 路由独享守卫:
routes: [ { path: '/foo', component: Foo, beforeEnter: (to, from, next) => { // 参数用法什么的都一样,调用顺序在全局前置守卫后面,所以不会被全局守卫覆盖 // ... }, }, ]
16. 简述一下 Vuex 工作原理?
- Vuex 是什么?
Vuex 是专门为 Vue 服务,用于管理页面的数据状态.相当于数据库 mongoDB,MySQL 等. 相似有 Redux
Vuex 采用 MVC 模式中的 Model 层,规定所有的数据必须通过 action —> mutaion —> state 这个流程进行来改变状态的。再结合 Vue 的数据视图双向绑定实现页面的更新。统一页面状态管理,可以让复杂的组件交互变的简单清晰,同时在调试时也可以通过 DEVtools 去查看状态。
核心变量
- state: 状态,类似于 data
- getters: 类似于 computed
- mutations: 更改 Vuex 的 store 中的状态的唯一方法时提交 mutation,类似于 methods
- actions: Action 提交的是 mutation,而不是直接变更状态。Action 可以包含任何的异步操作, 但 mutation 必须是同步操作
- modules: 模块
核心原理:
Vuex 本质是一个对象,有两个属性,一个是 install 方法(作用是将 store 这个实例挂载到所有的组件上),一个是 Store 这个类(拥有 commit,dispatch 这些方法,Store 类里将用户传入的 state 包装成 data,作为 new Vue 的参数,从而实现了 state 值的响应式)
常见问题:
Vue 项目中是怎么引入 Vuex 的?
- 安装 Vuex,再通过 import Vuex from 'vuex'引入
- 先 const store = new Vuex.Store({…}),再把 store 作为参数的一个属性值,new Vue({store})
- 通过 Vue.use(Vuex) 使得每个组件都可以拥有 store 实例
- 我们是通过 new Vuex.store({})获得一个 store 实例,也就是说,我们引入的 Vuex 中有 Store 这个类作为 Vuex 对象的一个属性。因为通过 import 引入的,实质上就是一个导出一个对象的引用
- 我们还使用了 Vue.use(),而 Vue.use 的一个原则就是执行对象的 install 这个方法
扩展:Vue.use 的实现原理? Vue 是使用.use( plugins )方法将插件注入到 Vue 中。use 方法会检测注入插件 VueRouter 内的 install 方法,如果有,则执行 install 方法。
1. 参数:{ Object | Function } plugin 2. 安装Vue.js插件。如果插件是一个对象,必须提供install方法。如果插件是一个函数,它会被作为install方法。调用install方法时,会将Vue作为参数传入。install方法被同一个插件多次调用时,插件也只会被安装一次。 3. 注册插件,此时只需要调用install方法并将Vue作为参数传入即可。但在细节上有两部分逻辑要处理: 1. 插件的类型,可以是install方法,也可以是一个包含install方法的对象。 2. 插件只能被安装一次,保证插件列表中不能有重复的插件。
- 具体实现过程:
- 在 Vue.js 上新增了 use 方法,并接收一个参数 plugin
- 首先判断插件是不是已经别注册过,如果被注册过,则直接终止方法执行,此时只需要使用 indexOf 方法即可
- toArray 方法我们在就是将类数组转成真正的数组。使用 toArray 方法得到 arguments。除了第一个参数之外,剩余的所有参数将得到的列表赋值给 args,然后将 Vue 添加到 args 列表的最前面。这样做的目的是保证 install 方法被执行时第一个参数是 Vue,其余参数是注册插件时传入的参数
- 由于 plugin 参数支持对象和函数类型,所以通过判断 plugin.install 和 plugin 哪个是函数,即可知用户使用哪种方式祖册的插件,然后执行用户编写的插件并将 args 作为参数传入
- 最后,将插件添加到 installedPlugins 中,保证相同的插件不会反复被注册(可能会问为什么插件不会被重新加载)
- 执行 install 的时候,将 Vue 作为参数传进去.mixin 的作用是将 mixin 的内容混合到 Vue 的初始参数 options 中.如果判断当前组件是根组件的话,就将我们传入的 store 挂在到根组件实例上,属性名为$store. 如果判断当前组件是子组件的话,就将我们根组件的$store也复制给子组件。注意是引用的复制,因此每个组件都拥有了同一个$store 挂载在它身上
const applyMixin = (Vue) => { Vue.mixin({ beforeCreate: vuexInit, // Vuex初始化工作 }) } function vuexInit() { console.log('Vuex初始化工作') const options = this.$options if (options.store) { // 根组件 this.$store = options.store } else if (options.parent && options.parent.$store) { // 子组件 this.$store = options.parent.$store // 把父组件的$store传给子组件 } }
常见问题:
- 为什么是 beforeCreate 而不是 created 呢?: 因为如果是在 created 操作的话,$options 已经初始化好了
- 为什么子组件可以从父组件里获取$store: 在执行子组件的 beforeCreate 的时候,父组件已经执行完 beforeCreate 了,那理所当然父组件已经有$store 了(考察父组件和子组件的执行顺序?)
- Vuex 和全局变量比有什么区别?:Vuex 具有响应式
- this.$store.vm.state 如何使用 this.state.xxx 访问:类的属性访问器,get state() {}
- 为什么用 getter 的时候不用写括号: 利用了 Object.defineProperty 的 get 接口,其实写的是方法,但是取值的时候 是属性
- 为什么 mutation 和 action 的第一个参数不同?mutation(thi.state, xxx) 而 action(this, xxx)注意:此时要使用箭头函数
- 注意:Vue 中定义数据,属性名是有特点的,如果属性名是通过,$xxx命名的,是不会被代理的,使用$$state
- 发布订阅模式: 将用户定义的 mutation 和 action 先保存起来(this._mutations = {}, this._actions = {}),当调用 commit 时,就找订阅的 mutation,当调用 dispatch,就找对应的 action 执行
- vue-router: 是把属性定义到了根实例上,所有的组件都能拿到这个根,通过根实例获取这个属性;vuex:是给每个组件都定义一个$store 属性,指定的是同一个人
- new Store -> 收集模块转换成一棵树,this._modules = new ModuleCollection(options) -> this.register() 存在 modules 选项,递归注册
- actions mutation 和 action 的区别?action 执行后返回的是一个 promise。判断 res 是不是一个 promise。不是则 Promise.resolve(res)返回。当调用 dispatch 方法时,Promise.all 执行
- Vuex3 内部会创建一个 vue 实例,但是 vuex4 直接采用 vue3 提供的响应式方法(reactive);vuex4 中使用 provide(给根 app 增加一个_provides,子组件会向上查找),inject(injectKey.js)
- Vuex 区分 State 是外部直接修改,还是通过 commit 修改?
Vuex 中修改 state 的唯一渠道就是执行 commit(‘xx’, payload) 方法,其底层通过执行
this._withCommit(fn)
设置_committing
标志变量为 true,然后才能修改 state,修改完毕还需要还原_committing 变量。外部修改虽然能够直接修改 state,但是并没有修改_committing 标志位,所以只要 watch 一下 state,state change 时判断是否_committing 值为 true,即可判断修改的合法性
watch: { flag: { handler() { if(this.$store._committing) { // 通过commit修改 } else { // 直接修改 } } } }
- state 内部支持模块配置和模块嵌套,如何实现的 在 store 构造方法中有 makeLocalContext 方法,所有 module 都会有一个 local context,根据配置时的 path 进行匹配。所以执行如 dispatch('submitOrder', payload)这类 action 时,默认的拿到都是 module 的 local state,如果要访问最外层或者是其他 module 的 state,只能从 rootState 按照 path 路径逐步进行访问。
17. Vue 中父子组件的生命周期的顺序?
- 加载渲染过程: 父 beforeCreate->父 created->父 beforeMount->子 beforeCreate->子 created->子 beforeMount->子 mounted->父 mounted
- 挂载阶段: 父 created->子 created->子 mounted->父 mounted
- 父组件更新阶段: 父 beforeUpdate->父 updated
- 子组件更新阶段: 父 beforeUpdate->子 beforeUpdate->子 updated->父 updated
- 销毁阶段: 父 beforeDestroy->子 beforeDestroy->子 destroyed->父 destroyed
18. SPA 单页面的理解以及优缺点?SSR 了解吗?
SPA( single-page application )仅在 Web 页面初始化时加载相应的 HTML、JavaScript 和 CSS。一旦页面加载完成,SPA 不会因为用户的操作而进行页面的重新加载或跳转;取而代之的是利用路由机制实现 HTML 内容的变换,UI 与用户的交互,避免页面的重新加载。
优点:
- 用户体验好、快,内容的改变不需要重新加载整个页面,避免了不必要的跳转和重复渲染
- 基于上面一点,SPA 相对对服务器压力小
- 前后端职责分离,架构清晰,前端进行交互逻辑,后端负责数据处理
缺点:
- 初次加载耗时多:为实现单页 Web 应用功能及显示效果,需要在加载页面的时候将 JavaScript、CSS 统一加载,部分页面按需加载
- 前进后退路由管理:由于单页应用在一个页面中显示所有的内容,所以不能使用浏览器的前进后退功能,所有的页面切换需要自己建立堆栈管理
- SEO 难度较大:由于所有的内容都在一个页面中动态替换显示,所以在 SEO 上其有着天然的弱势
SSR 服务端渲染 Nuxt.js
- 优点:
- 更利于 SEO
- 更利于首屏渲染
- 缺点:
- 服务端压力较大
- 开发条件受限(created 和 beforeCreate 之外的生命周期钩子不可用)
- 学习成本相对较高(除了对 webpack、Vue 要熟悉,还需要掌握 node、Express 相关技术)
- 优点:
19. Vue 中事件绑定原理?
20. set、delete 的实现原理?
set 的实现原理?
- 路径:src/core/instance/observer/index.js -> set(target, key, val)
- 大致流程:核心方法:defineReactive(ob.value, key, val)
- 判断目标值是否为有效值,不是有效值直接停止
- 判断是否为数组,并且 key 值是否为有效的 key 值 如果是数组,就选择数组的长度和 key 值取较大值作为数组的新的 length 值,并且替换目标值 splice 方法,重写了,所以执行 splice,会双向数据绑定
- 如果目标值是对象,并且 key 值是目标值存在的有效 key 值,并且不是原型上的 key 值 判断目标值是否为响应式的ob 如果是 vue 实例,直接不行 如果不是响应式的数据,就是普通的修改对象操作 如果是响应式数据,那就通过 Object.defineProperty 进行数据劫持
- 通知 dom 更新 ob.dep.notify()
delete 的实现原理?
- 路径:src/core/instance/observer/index.js -> del(target, key)
- 大致流程:核心逻辑:delete target[key]
- 非生产环境下, 不允许删除一个原始数据类型, 或者 undefined, null
- 如果 target 是数组, 并且 key 是一个合法索引,通过数组的 splice 方法删除值, 并且还能触发数据的响应(数组拦截器截取到变化到元素, 通知依赖更新数据)
- target._isVue: 不允许删除 Vue 实例对象上的属性;(ob && ob.vmCount): 不允许删除根数据对象的属性,触发不了响应
- 如果属性压根不在对象上, 什么都不做处理
- 走到这一步说明, target 是对象, 并且 key 在 target 上, 直接使用 delete 删除
- 如果 ob 不存在, 说明 target 本身不是响应式数据 return; 存在 ob, 通过 ob 里面存储的 Dep 实例的 notify 方法通知依赖更新 ob.dep.notify()
扩展:$delete 和 delete 的区别?
- delete 是 js 原生方法,由于语言限制,此操作无法设置回调来响应
- $delete 是 vue 提供的实例方法,核心就是在删除后通知了依赖更新
21. Vue 源码设计用了哪些设计模式?
- 单例模式:new 多次,只有一个实例
- 工场模式:传入参数就可以创建实例(虚拟节点的创建)
- 发布订阅模式:eventBus
- 观察者模式:watch 和 dep
- 代理模式:_data 属性、proxy、防抖、节流
- 中介者模式:vuex
- 策略模式
- 外观模式
扩展:
- 发布 / 订阅模式和观察者模式的区别是什么?
在观察者模式中,被观察者通常会维护一个观察者列表。当被观察者的状态发生改变时,就会通知观察者
在发布订阅模式中,具体发布者会动态维护一个订阅者的列表:可在运行时根据程序需要开始或停止发布给对应订阅者的事件通知
区别在于发布者本身并不维护订阅列表(它不会像观察者一样主动维护一个列表),它会将工作委派给具体发布者(相当于秘书,任何人想知道我的事情,直接问我的秘书就可以了);订阅者在接收到发布者的消息后,会委派具体的订阅者来进行相关的处理
22. Vue2 和 Vue3 中 v-model 的实现原理?
v-model:即可以作用于表单元素,也可以作用于自定义组件。无论是哪一种情况(vue2, vue3),它都是一个 语法糖,最终生成一个属性和事件
当其作用于表单元素时,
vue
会根据作用的表单元素类型而生成合适的属性和事件- text 和 textarea 元素使用
value
property 和input
事件 - checkbox 和 radio 使用
checked
property 和change
事件 - select 字段将
value
作为 prop 并将change
作为事件
- text 和 textarea 元素使用
当其作用于自定义组件时,默认情况下,它会生成一个
value
属性和input
事件- vue2 中:
props: { value: Number, }, change(val) { this.$emit("update:value", val); }, <HelloWorld :value="inputVal" @update:value="inputVal = $event" /> <!-- 等效于 --> <HelloWorld v-model="inputVal" />
- 开发者可以通过组件的 model 配置来改变生成的属性和事件
props: { number: Number, }, model: { prop: "number", // 默认为 value event: "change", // 默认为 input }, change(val) { this.$emit("change", val); } <HelloWorld v-model="inputVal" /> <!-- 等效于 --> <HelloWorld :number="inputVal" @change="data=$event" />
- vue3 中:默认情况下,它会生成一个
modelValue
属性和onUpdate:modelValue
事件
const props = defineProps({ modelValue: Number }) const emits = defineEmits(['update:modelValue']); const change = (val) => { emits('update:modelValue', val) } <Comp v-model="msg"></Comp> <!-- 等效于 --> <Comp :modelValue="msg" @update:modelValue="msg = $event"></Comp>
vue2 和 vue3 都有 v-model,原理都是生成一个属性和一个事件,但是也存在些区别?
- vue3 中去掉了
.sync
修饰符是,他的功能可以由v-model
的参数替代
<!-- vue2 --> <Comp :title="inputVal" @update:title="inputVal = $event"></Comp> <!-- 简写为 --> <Comp :title.sync="inputVal"></Comp> <!-- Vue3 --> <Comp :title="inputVal" @update:title="input = $event"></Comp> <!-- 简写为 --> <Comp v-model:title="inputVal"></Comp>
- Vue3 中可以写多个 v-model, Vue2 只能一个
<ChildComponent v-model:title="pageTitle" v-model:content="pageContent" /> <!-- 是以下的简写: --> <ChildComponent :title="pageTitle" @update:title="pageTitle = $event" :content="pageContent" @update:content="pageContent = $event" />
v-model
修饰符: vue2.x 是自带的修饰符(.lazy、.number、.trim 等),但是在 3.x 的版本中,可以自定义修饰符哦 .在 3.x 中的修饰符会在当作属性传递给子组件,并且在属性中生成一个modelModifiers
的属性。存在这个修饰符就会有对应的修饰符,并且是 true,如果没有传递,那就是 undefined
const props = defineProps({ modelValue: Number, modelModifiers: { default: () => ({}) } }) const emits = defineEmits(['update:modelValue']); const change = (val) => { // 如果存在修饰符range2,那就多加1,减法就没有效果 if(props.modelModifiers.range2){ val++; } emits('update:modelValue', val) } <Comp v-model.range2="msg"></Comp>
- vue3 中去掉了
Vue2 中 v-model 源码分析? 在给页面上绑定了 v-model 之后,就会根据表单元素的 tag 标签以及 type 属性的值,去调用不同的 model 方法,这里我们调用 genDefaultModel
- 路径:src/platforms/web/compiler/directives/model.js -> genDefaultModel()
- genDefaultModel():先是去检测该表单元素是否同时有 v-model 绑定和 v-bind:value
- 获取到表单元素的修饰符 lazy(替换 input 的 change 时间),number(入字符串转为数字)及 trim.(去除两边空格),之后生成值得表达式
- 通过 genAssignmentCode()方法生成 v-model value 值的代码
<div id="test"> 请输入:<input type="text" v-model="message" /><br /> 你输入的是:<input type="text" v-model="message" disabled /> </div> 这一句代码经过vue的处理之后,就变成了 <input type="text" v-bind:value="message" v-on:input="message= $event.target.value" /><br /> 相当于将data里面的值通过响应式更新到视图上,然后给input绑定change事件,在改变值得时候对data里面message值做修改并更新视图
23. Vue computed 是如何实现的?
initComputed -> new Watcher -> defineComputed -> createComputedGetter -> 用户取值(dirty: false 返回上次计算的结果;dirty: true -> watcher.evaluate)
- 基本使用
- 函数
- get set
- 与 watch 比较
- computed 是计算一个新的属性,并将该属性挂载到 vm(Vue 实例)上,而 watch 是监听已经存在且已挂载到 vm 上的数据,所以用 watch 同样可以监听 computed 计算属性的变化(其它还有 data、props)
- computed 本质是一个惰性求值的观察者,具有缓存性,只有当依赖变化后,第一次访问 computed 属性,才会计算新的值,而 watch 则是当数据发生变化便会调用执行函数
- 从使用场景上说,computed 适用一个数据被多个数据影响,而 watch 适用一个数据影响多个数据
扩展:
Vue 响应系统,其核心有三点:observe、watcher、dep:
- observe:遍历 data 中的属性,使用 Object.defineProperty 的 get/set 方法对其进行数据劫持
- dep:每个属性拥有自己的消息订阅器 dep,用于存放所有订阅了该属性的观察者对象
- watcher:观察者(对象),通过 dep 实现对响应属性的监听,监听到结果后,主动触发自己的回调进行响应
初始化 initProps -> initMethods -> initData -> initComputed -> initWatch
Watcher 和 Dep 的关系,用一句话总结: watcher 中实例化了 dep 并向 dep.subs 中添加了订阅者,dep 通过 notify 遍历了 dep.subs 通知每个 watcher 更新
原理解析
- 当组件初始化的时候,computed 和 data 会分别建立各自的响应系统,Observer 遍历 data 中每个属性设置 get/set 数据拦截
- 初始化 computed 会调用 initComputed 函数
- 注册一个 watcher 实例,并在内实例化一个 Dep 消息订阅器用作后续收集依赖
- 调用计算属性时会触发其 Object.defineProperty 的 get 访问器函数
- 调用 watcher.depend() 方法向自身的消息订阅器 dep 的 subs 中添加其他属性的 watcher
- 调用 watcher 的 evaluate 方法(进而调用 watcher 的 get 方法)让自身成为其他 watcher 的消息订阅器的订阅者,首先将 watcher 赋给 Dep.target,然后执行 getter 求值函数,当访问求值函数里面的属性(比如来自 data、props 或其他 computed)时,会同样触发它们的 get 访问器函数从而将该计算属性的 watcher 添加到求值函数中属性的 watcher 的消息订阅器 dep 中,当这些操作完成,最后关闭 Dep.target 赋为 null 并返回求值函数结果
- 当某个属性发生变化,触发 set 拦截函数,然后调用自身消息订阅器 dep 的 notify 方法,遍历当前 dep 中保存着所有订阅者 watcher 的 subs 数组,并逐个调用 watcher 的 update 方法,完成响应更新。
// 1. 存放computed的观察者 var watchers = (vm._computedWatchers = Object.create(null)) // 2. 遍历computed for (var key in computed) { var userDef = computed[key]; var getter = typeof userDef === 'function' ? userDef : userDef.get; watchers[key] = new Watcher(// 生成观察者(Watcher实例) vm, getter || noop,// getter将在观察者get方法中执行 noop, computedWatcherOptions // { lazy: true }懒执行,暂不执行get方法,当读取computed属性值执行 ); defineComputed(vm, key, userDef); // 将 computed 属性添加到组件实例上,并通过 get、set 获取或者设置属性值,并且重定义 getter 函数 function defineComputed(target, key, userDef) { var shouldCache = !isServerRendering(); sharedPropertyDefinition.get = shouldCache // 重定义getter函数 ? createComputedGetter(key) : createGetterInvoker(userDef); Object.defineProperty(target, key, sharedPropertyDefinition); // 将computed属性添加到组件实例上 } function createComputedGetter(key) { return function computedGetter() { var watcher = this._computedWatchers && this._computedWatchers[key]; if (watcher) { if (watcher.dirty) { // true,懒执行 watcher.evaluate(); // 执行watcher方法后设置dirty为false } if (Dep.target) { watcher.depend(); } return watcher.value; //返回观察者的value值 } }; } } 如果 dirty 为 true(即依赖的值没有发生变化),就不会重新求值。相当于 computed 被缓存了
每个 computed 属性都会生成对应的观察者(Watcher 实例),观察者存在 values 属性和 get 方法。computed 属性的 getter 函数会在 get 方法中调用,并将返回值赋值给 value。初始设置 dirty 和 lazy 的值为 true,lazy 为 true 不会立即 get 方法(懒执行),而是会在读取 computed 值时执行 computedWatcherOptions={lazy: true}
24. 你知道插槽 Slot 是怎么“插”的吗?
- 单个插槽(匿名插槽)
// 子组件
<template>
<div>
<slot></slot>
</div>
</template>
// 父组件
<template>
<div>
<child>汤小梦</child>
</div>
</template>
编译作用域 slot 其实就是能够让我们在父组件中添加内容到子组件的‘空间’。我们可以添加父组件内任意的 data 值.
直接传入子组件内的数据是不可以的。因为:父级模板里的所有内容都是在父级作用域中编译的;子模板里的所有内容都是在子作用域中编译的。
设置默认值 有时我没有在父组件内添加内容,那么 slot 就会显示默认值
<template> <div class="child"> <slot>这就是默认值</slot> </div> </template>
具名插槽 有时候,也许子组件内的 slot 不止一个,那么我们如何在父组件中,精确的在想要的位置,插入对应的内容呢? 给插槽命一个名即可,即添加 name 属性。
<template> <div class="child"> <slot name="one"> 这就是默认值1</slot> <slot name="two"> 这就是默认值2 </slot> <slot name="three"> 这就是默认值3 </slot> </div> </template> // 父组件通过,或slot="name"(旧语法),v-slot:name或#name(新语法) 的方式添加内容: <div class= 'app'> <child> <template v-slot:"one"> 这是插入到one插槽的内容 </template> <template v-slot:"two"> 这是插入到two插槽的内容 </template> <template v-slot:"three"> 这是插入到three插槽的内容 </template> </child> </div>
作用域插槽 (父组件在子组件处使用子组件 data) 我们知道不能直接使用子组件内的数据,但是 我们也可以使用 slot-scope 的方式:让我们能够使用子组件的数据
<template> <div class="child"> <slot name="one" :value1="child1"> 这就是默认值1</slot> // 绑定child1的数据 <slot :value2="child2"> 这就是默认值2 </slot>// 绑定child2的数据,这里我没有命名slot </div> </template> <child> <template v-slot:one="slotone"> {{ slotone.value1 }} // 通过v-slot的语法 将子组件的value1值赋值给slotone </template> <template v-slot:default="slotde"> {{ slotde.value2 }} // 同上,由于子组件没有给slot命名,默认值就为default </template> </child>
Slot 插槽是怎么“插”的
- 普通插槽
- 父组件先解析,把 child 当做子元素处理,把 插槽当做 child 的子元素处理,并且在父组件作用域内得出了 parent 变量的值,生成对应的节点
- 子组件解析,slot 作为一个占位符,会被解析成一个函数 _t('default') _t('one')
- _t 函数需要传入插槽名称,默认是 default,具名插槽则传入 name,这个函数的作用,是把第一步解析得到的插槽节点拿到,然后返回解析后的节点,那么子组件的节点就完整了.插槽也找到了父亲
- 作用域插槽
- 父组件先解析,遇到作用域插槽,会将此插槽封装成一个函数保存到子元素 child 下
{ tag: "div", children: [{ tag: "child" scopeSlots:{ default (data) { // 记住这个data参数 return ['插入one slot 中插入默认 slot 中' + data.value1 + data.value2] }, one (data) { // 记住这个data参数 return ['插入one slot 中' + data.value1 + data.value2] } } }] }
- 轮到子组件解析了,这个时候_t 函数又登场了,并且子组件将对应的插槽数据包装成一个对象,传进_t 函数
{ tag: "div", children: [ '我在子组件里面', _t('default',{value1: '子数据1', value2: '子数据1'}), _t('one',{value1: '子数据2', value2: '子数据2'}) ] }
- 接下来就是_t 内部执行,包装后的对象被当做 data 参数传入了 scopeSlots 中的对应的各个函数,解析成:
{ tag: "div", children: [ '我在子组件里面', '插入默认 slot 中 子数据1 子数据1', '插入one slot 中 子数据2 子数据2' ] }
- $slots
存放解析后的节点 VNode 对象。$slots 是一个 Map,key 是各个插槽的名称(匿名插槽的 key 为 default),key 对应的 value 都是各个插槽下面的 VNode 节点
- 普通插槽
25. Vue 组件中的 data 为什么是个函数?
总结一下: 组件中的 data 是一个函数的原因在于:同一个组件被复用多次,会创建多个实例。这些实例用的是同一个构造函数,如果 data 是一个对象的话。那么所有组件都共享了同一个对象。为了保证组件的数据独立性要求每个组件必须通过 data 函数返回一个对象作为组件的状态。而 new Vue 的实例,是不会被复用的,因此不存在引用对象的问题
26. 谈谈你对 Vue 生命周期的理解?
Vue 实例有一个完整的生命周期,也就是从开始创建、初始化数据、编译模版、挂载 Dom -> 渲染、更新 -> 渲染、卸载等一系列过程,我们称这是 Vue 的生命周期。
- Vue 本质上是一个构造函数
- 构造函数的核心是调用了_init 方法
- _init 内调用了很多初始化函数,从函数名称可以看出分别是执行
- 初始化生命周期(initLifecycle)
- 初始化事件中心(initEvents)
- 初始化渲染(initRender)
- 执行 beforeCreate 钩子(callHook(vm, 'beforeCreate'))
- 解析 inject(initInjections)
- 初始化状态(initState)
- 解析 provide(initProvide)
- 执行 created 钩子(callHook(vm, 'created'))
- 在_init 函数的最后有判断如果有 el 就执行$mount 方法
- 重写了 Vue 函数的原型上的$mount 函数
- 判断是否有模板,并且将模板转化成 render 函数
- 最后调用了 runtime 的 mount 方法,用来挂载组件,也就是 mountComponent 方法
- mountComponent 内首先调用了 beforeMount 方法,然后在初次渲染和更新后会执行 vm._update(vm._render(), hydrating)方法。最后渲染完成后调用 mounted 钩子
- beforeUpdate 和 updated 钩子是在页面发生变化,触发更新后,被调用的,对应是在 src/core/observer/scheduler.js 的 flushSchedulerQueue 函数中
- beforeDestroy 和 destroyed 都在执行 destroy 函数时被调用
26. Vue 中常见的性能优化方式?
- 编码优化
- 尽量不要将所有的数据都放在 data 中,data 中的数据都会增加 getter 和 setter,会收集对应的 watcher
- vue 在 v-for 时给每项元素绑定事件尽量用事件代理
- 拆分组件( 提高复用性、增加代码的可维护性,减少不必要的渲染 )
- v-if 当值为 false 时内部指令不会执行,具有阻断功能,很多情况下使用 v-if 替代 v-show
- 合理使用路由懒加载、异步组件
- Object.freeze 冻结数据
- 用户体验
- app-skeleton 骨架屏
- pwa serviceworker
- 加载性能优化
- 第三方模块按需导入 ( babel-plugin-component )
- 滚动到可视区域动态加载 ( vue-virtual-scroll-list )
- 图片懒加载 (vue-lazyload)
- SEO 优化
- 预渲染插件 prerender-spa-plugin
- 服务端渲染 ssr
- 打包优化
- 使用 cdn 的方式加载第三方模块
- 多线程打包 happypack、parallel-webpack
- 控制包文件大小(tree shaking / splitChunksPlugin)
- 使用 DllPlugin 提高打包速度
- 缓存/压缩
- 客户端缓存/服务端缓存
- 服务端 gzip 压缩
27.
五、React 相关
1. setState 是同步还是异步?
- 异步情况 在 React 事件当中是异步操作
- 同步情况 如果是在 setTimeout 事件或者自定义的 dom 事件中,都是同步的
2. 说说 React 中的事件机制?
3. 对 Fiber 的理解?解决了什么问题?
4. 谈谈你对 React Hooks 的理解?
5. React 中性能优化有哪些手段?
6. 说一下你对 Diff 算法的理解?
7. React 中受控组件和非受控组件的区别?
8. 描述 Redux 单项数据流?
9. JSX 本质是什么?context 是什么?有何用途?
六、HTTP 相关
1. 说一下 GET 和 POST 的区别?
GET 在浏览器回退时是无害的,而 POST 会再次提交请求
GET 请求只能进行 url 编码,而 POST 支持多种编码方式
GET 请求参数会被完整保留在浏览器历史记录里,而 POST 中的参数不会保留
GET 请求参数通过 URL 传递且参数是有长度限制的(2083 字符,中文字符的话只有 2083/9=231 个字符),而 POST 参数放在 Request body 中是没有限制的
对参数的数据类型,GET 只接受 ASCII 字符(如果非 ASCII 字符会进行转码),而 POST 没有限制
GET 和 POST 本质上就是 TCP 链接, GET 产生一个 TCP 数据包;POST 产生两个 TCP 数据包
注意:并不是所有浏览器都会在 POST 中发送两次包,Firefox 就只发送一次
- 对于 GET 方式的请求,浏览器会把 http header 和 data 一并发送出去,服务器响应 200(返回数据)
- 对于 POST,浏览器先发送 header,服务器响应 100 continue,浏览器再发送 data,服务器响应 200 ok(返回数据)
2. HTTP1.0、HTTP1.1、HTTP2、HTTP3 的区别?
3. DNS 解析过程及原理?回源是什么?CDN 的原理和回源是什么?DNS 劫持听说过吗?
- DNS 解析过程
- 浏览器缓存:浏览器会按照一定的频率缓存 DNS 记录。
- 操作系统缓存:如果浏览器缓存中找不到需要的 DNS 记录,那就去操作系统中找。
- 路由缓存:路由器也有 DNS 缓存。
- ISP 的 DNS 服务器:ISP 是互联网服务提供商(Internet Service Provider)的简称,ISP 有专门的 DNS 服务器应对 DNS 查询请求。
- 根服务器:ISP 的 DNS 服务器还找不到的话,它就会向根服务器发出请求,进行递归查询(DNS 服务器先问根域名服务器.com 域名服务器的 IP 地址,然后再问.baidu 域名服务器,依次类推)
- CDN 原理 CDN 的全称是 Content Delivery Network,即内容分发网络。CDN 的基本原理是广泛采用各种缓存服务器,将这些缓存服务器分布到用户访问相对集中的地区或网络中,在用户访问网站时,利用全局负载技术将用户的访问指向距离最近的工作正常的缓存服务器上,由缓存服务器直接响应
4. TCP 和 UDP 区别是什么?
- UDP
- 无连接
- 面向报文,只是报文的搬运工
- 不可靠,没有拥塞控制
- 高效,头部开销只有 8 字节
- 支持一对一、一对多、多对多、多对一
- 适合直播、视频、语音、会议等实时性要求高的
- TCP
- 面向连接:传输前需要先连接
- 可靠的传输
- 流量控制:发送方不会发送速度过快,超过接收方的处理能力
- 拥塞控制:当网络负载过多时能限制发送方的发送速率
- 不提供时延保障
- 不提供最小带宽保障
5. 如何理解 TCP/IP 五层模型 OSI 七层模型?
- OSI 七层模型
- 应用层:文件传输,常用协议 HTTP,FTP
- 表示层:数据格式化,代码转换,数据加密,
- 会话层:建立,解除会话
- 传输层:提供端对端的接口,tcp,udp
- 网络层:为数据包选择路由,IP,icmp
- 数据链路层:传输有地址的帧
- 物理层:二进制的数据形式在物理媒体上传输数据
6. 为什么 HTTPS 比 HTTP 安全?HTTPS 是如何保证安全的?
7. HTTP 常见的状态码有哪些?适用的场景?
常见的状态码:
- 1xx 表示消息类
- 2xx 表示成功类
- 3xx 表示重定向类
- 4xx 表示请求错误
- 5xx 表示服务器错误
状态码的适用场景:
- 100:客户端在发送 POST 数据给服务器前,征询服务器情况,看服务器是否处理 POST 的数据
- 206:一般用来做断点续传,或者是视频文件等大文件的加载
- 301:永久重定向会缓存。新域名替换旧域名,旧的域名不再使用时,用户访问旧域名时用 301 就重定向到新的域名
- 302:临时重定向不会缓存,常用 于未登陆的用户访问用户中心重定向到登录页面
- 304:协商缓存
- 400:参数有误,请求无法被服务器识别
- 403:告诉客户端禁止访问该站点或者资源
- 404:服务器找不到资源时
- 503:服务器停机维护时,主动用 503 响应请求或 nginx 设置限速,超过限速,会返回 503
- 504:网关超时
8. 知道 HTTP 的缓存吗?(浏览器强缓存和协商缓存)
强缓存是利用 Expires(http1.0)和 Cache-Control(http1.1)这两个字段来控制的,控制资源缓存的时间,在有效期内不会去向服务器请求了,同时存在,优先 Cache-Control
- Expires 的值为一个绝对时间(由于 Expires 是依赖于客户端系统时间,当修改了本地时间后,缓存可能会失效)
- 给 Cache-Control 设置 max-age ,表示缓存的最长时间是多少秒,是一个相对时间。
- Catch-Control 的值:public 都缓存;private 客服端缓存,服务器不换车;no-cache:表示不进行强缓存,而是用协商缓存来验证;no-store 所有内容都不缓存
协商缓存是由服务器来确定缓存资源是否可用,是利用 Last-Modified(http1.0)(表示被请求资源在服务器端的最后一次修改时间)/ If-Modified-Since 和 ETag(每次文件修改后服务端那边会生成一个新的 ETag)/if-None-Match 来控制的,同时存在,优先 ETag
- Last-Modified/if-Modified-Since 的缺点:(文件有可能在 1s 内修改内容、文件内容修改后又复原)
- ETag 性能上的不足,只要文件发生改变,
ETag
就会发生改变.ETag
需要服务器通过算法来计算出一个 hash 值
9. 前端错误的分类有哪些?
前端错误类型:
- SyntaxError(语法错误)
- ReferenceError(引用错误)
- TypeError(类型错误)
- RangeError(范围越界错误)
10. 说说为什么前端会有跨域?如何解决跨域?知道 option 请求吗?
不同域之间相互请求资源,称为”跨域“;浏览器的 同源策略(同源:url 是由协议、域名、端口和路径等组成。如果两个路径的协议、域名、端口都相同则表示再同一个域上)
在浏览器上 script、img、link、iframe 等标签都可以加载跨域资源 且不受同源限制
非同源限制:
- 无法读取非同源网页的 Cookie、LocalStorage、IndexedDB
- 无法接触非同源网页的 DOM
- 无法向非同源地址发起 ajax 请求
解决跨域的方式:
设置 document.domain 解决无法读取非同源网页的 Cookie 问题 浏览器是通过 document.domain 属性来检查两个页面是否同源,因此只要通过设置相同的 document.domain,两个页面就可以共享 Cookie。(**缺点:**此方案仅限主域相同,子域不同的跨域应用场景)
JSONP 是服务器与客户端跨源通信的常用方法。最大特点就是简单适用,兼容性好(兼容低版本 IE),缺点:是只支持 get 请求, 需要后台配合,将返回结果包装成 callback(res)的形式。原理是利用 script 元素的跨域能力 **核心思想:**网页通过添加一个
script元素
,向服务器请求 JSON 数据,服务器收到请求后,将数据放在一个指定名字的回调函数的参数位置传回来- 原生 js 实现【请求地址后面添加?callback="xxx",然后在 script 中添加 xxx 方法其参数返回结果】
- jQuery 的 ajax【dataType: 'jsonp', 请求方式为 jsonp;jsonpCallback: 'handleCallback' 自定义回调函数】
扩展:
- script 的 src 和 img 的 src 跨域的区别? 原理上都是利用标签的 src 可绕过同源限制,跨域请求的特点;区别在于:img 只能单向发送 get 请求,不可访问响应内容(只是展现),而 script 可对其进行解析
- 如果黑客植入 script 脚本通过 jsonp 的方式对服务器进行攻击,怎么办? 可以通过页面设置的内容安全协议 csp 进行防范
H5 提供的 postMessage postMessage 事件发送消息,message 事件接受消息
跨域资源共享 CORS【常用】 浏览器将 CORS 请求分成两类:简单请求和非简单请求;当发出简单请求,只需要在头信息之中增加一个 Origin 字段。当发出 CORS 非简单请求,会在正式通信之前,增加一次 OPTIONS 查询请求,称为"预检"请求(preflight)。设置响应头的 Access-Control-Allow-Origin: * 扩展:简单请求同时满足的三个条件?
- 请求方式只能是:GET、POST、HEAD
- HTTP 请求头限制这几种字段:Accept、Accept-Language、Content-Language、Content-Type、Last-Event-ID
- Content-type 只能取:application/x-www-form-urlencoded(是 Jquery 的 Ajax 请求默认方式)、multipart/form-data、text/plain
预检请求(preflight):浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些 HTTP 动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的 XMLHttpRequest 请求,否则就报错。
重点:服务端如何避免每次都发出预检请求?(缓存)
- Access-Control-Max-Age 该字段可选,用来指定本次预检请求的有效期,单位为秒。在有效期间,不用发出另一条预检请求(全局和局部方式)常用
- @CrossOrigin 注解,可细粒度精确到单个请求级别
**window.name ** name 值在不同的页面(甚至不同域名)加载后依旧存在,并且可以支持非常长的 name 值(2MB)
vue 中 proxy 配置本地代理
H5 中的 websocket
Nginx 代理
扩展:二级域名和域名是否同源?非同源的话是如何传递 cookie 的?
11. TCP 三次握手 & 四次挥手(短连接 & 长连接)?
- TCP 三次握手
- 第一次握手:起初两端都处于 CLOSED 关闭状态,Client 将标志位 SYN 置为 1,随机产生一个值 seq=x,并将该数据包发送给 Server,Client 进入 SYN-SENT 状态,等待 Server 确认
- .第二次握手:Server 收到数据包后由标志位 SYN=1 得知 Client 请求建立连接,Server 将标志位 SYN 和 ACK 都置为 1,ack=x+1,随机产生一个值 seq=y,并将该数据包发送给 Client 以确认连接请求,Server 进入 SYN-RCVD 状态,此时操作系统为该 TCP 连接分配 TCP 缓存和变量
- 第三次握手:Client 收到确认后,检查 ack 是否为 x+1,ACK 是否为 1,如果正确则将标志位 ACK 置为 1,ack=y+1,并且此时操作系统为该 TCP 连接分配 TCP 缓存和变量,并将该数据包发送给 Server,Server 检查 ack 是否为 y+1,ACK 是否为 1,如果正确则连接建立成功,Client 和 Server 进入 ESTABLISHED 状态,完成三次握手,随后 Client 和 Server 就可以开始传输数据 三次握手:为了防止已失效的连接请求报文段突然又传送到了服务端,因而产生错误
12. V8 如何执行一段 JS 代码?
13. 多路复用与 keep-alive 的区别?
七、Webpack 相关
1. 说说你对 Webpack 的理解?它解决了什么问题?
Webpack 是一个现代的 JS 应用程序的静态模块打包器。它主要做的事情就是:分析你的项目结构,找到 JavaScript 模块以及其他的一些浏览器不能直接运行的扩展语言(Sass TS 等)。并将其打包为合适的格式以供浏览器使用。
Webpack 的主要功能:
- 代码转换
- 文件优化
- 代码分割
- 模块合并
- 自动刷新
- 代码校验
- 自动发布等等
早期模块化?
- 把单独的功能抽离到单独的 js 文件,通过 script 引入。
- **问题:**模块都在全局中,大量模块污染环境,并且模块与模块之间没有依赖关系,维护困难,没有私有空间等问题
- 解决:出现了
命名空间
方式,规定每个模块只暴露一个全局对象,模块的内容都挂载在这个对象中; ---》还是没有解决第一种方式的依赖
等问题- 再后来,使用
立即执行函数
模块提供私有空间,通过参数
的形式作为依赖声明; ---》这种方式还是存在一些问题。比如:通过 script 引入模块,这些模块的加载并不受代码的控制
- 再后来,使用
- 理想的解决方式:在页面中引入一个 JS 入口文件,其余用到的模块可以通过代码控制,按需加载进来
- 除了 模块加载的问题以外,还需要规定模块化的规范。如今流行的:CommonJS、ES Module
从前后端渲染的 JSP、PHP。到前端原生 JavaScript。再到 jQuery 开发。再到目前三大框架 Vue, React, Angular 开发。也从 JavaScript 到后面的 es5,6,7,8...。再到 TypeScript。有些编写的 CSS 预处理器 less、sass 等。如今的前端变得十分复杂,所以我们开发过程中会遇到以下问题:
- 项目需要通过模块化的方式来开发
- 使用一些高级的特性来加快我们的开发效率,如:ES6+、TypeScript 开发脚本逻辑,通过 Less、Sass 等方式来编写 css 样式代码
- 监听文件的变化并且反映到浏览器上,提高开发效率
- JS 代码需要模块化,HTML 和 CSS 这些资源文件有些也需要模块化
- 开发完后我们需要将代码压缩、合并以及一些优化等问题
综合:Webpack 恰巧可以解决以上问题!
2. Webpack 的构建流程?
- 初始化参数:解析 webpack 配置参数,合并 shell 传入和 webpack.config.js 文件配置的参数,形成最后的配置结果
- 开始编译:上一步得到的参数初始化 compiler 对象,注册所有配置的插件,插件 监听 webpack 构建生命周期的事件节点,做出相应的反应,执行对象的 run 方法开始执行编译
- 确认入口:根据配置的 entry 入口,开始解析文件构建 AST(抽象语法树),找出依赖,递归下去
- 编译模块:递归中根据文件类型和 loader 配置,调用所有配置的 loader 对文件进行转换,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
- 完成模块编译并输出资源:递归完事后,得到每个文件结果,包含每个模块以及他们之间的依赖关系,根据 entry 或分包配置生成代码块 chunk
- 输出完成:输出所有的 chunk 到文件系统
3. 常见的 Loader、Plugin 有哪些?能手写吗?
常见的 Plugin:
- html-webpack-plugin:可以根据模板自动生成 html 代码,并自动引用 css 和 js 文件
- extract-text-webpack-plugin:将 js 文件中引用的样式单独抽离成 css 文件(webpack4 推荐使用 mini-css-extract-plugin)
- 两者有啥区别?
- 后者:更容易使用、异步加载、而且只针对 CSS,并且不重复编译,性能更好
- 该插件一般在(生产环境)使用。代替 loaders 中的 style-loader,暂时不支持 HMR
- clean-webpack-plugin: 清理每次打包的文件
- speed-measure-webpack-plugin: 可以看每个 Loader 和 Plugin 执行耗时(webpack5 使用 speed-measure-webpack5-plugin)
- webpack-bundle-analyzer: 可视化 Webpack 输出文件的体积
- copy-webpack-plugin:拷贝插件
- friendly-errors-webpack-plugin: 识别某些类别的 webpack 错误,并清理,聚合和优先级,以提供更好的开发人员体验
- webpack 中提供了 stats 选项显示打包信息(只展示错误信息、都展示等等)
- optimize-css-assets-webpack-plugin:压缩 css
- purgecss-webpack-plugin: 去除无用的 css
- uglifyJs-webpack-plugin:压缩 js(压缩 es6 的代码不是很友好;并且是单线程压缩代码,打包时间慢,所以开发环境将其关闭,生产环境打开(parallelUglifyPlugin 开启多个子进程打包,每个子进程还是 UglifyJS 打包,但并行执行)-》webpack4 推荐用 terser-webpack-plugin(开启 parallel 参数【一般是电脑的 CPU 核数减 1】,使用进程压缩);此插件 webpack5 中内置了)
- compression-webpack-plugin:(生产环境可采用)gzip 压缩 JS 和 CSS【需要后台配置 nginx】
- HotModuleReplacementPlugin:热更新(自带的)
- happypack:开启多进程打包,提高打包速度
- ProvidePlugin:自动加载模块,代替 require 和 import(自带)
- DefinePlugin:定义全局变量(浏览器获取的值,需使用 JSON.stringify 包裹)
- IgnorePlugin:忽略或排除(moment 不用全部加载,只加载中文)
- DllPlugin:动态链接库,配合 DllReferencePlugin 一起使用(自带的)
Plugin
就是一个扩展器,它比Loader
更加灵活,因为它可以接触到Webpack
编译器。在Webpack
运行的生命周期中会广播出许多的事件,Plugin
可以监听这些事件,在合适的时机通过Webpack
提供的API
改变输出结果。这样Plugin
就可以通过一些 hook 函数来拦截Webpack
的执行,做一些Webpack
打包之外的事情。像:打包优化
、资源管理
、注入环境变量
等等。- 插件实例上都会有个 apply 方法,并将 compiler 作为其参数。(类似于:Vue 插件都有个 install 方法)
- 在开发
Plugin
时最常用的两个对象Compiler
和Compilation
,它们都继承自Tapable
,是Plugin
和Webpack
之间的桥梁。类似于react-redux
是连接React
和Redux
的桥梁- Tapable 有同步钩子和异步钩子(异步串行钩子和异步并行钩子)
- 注册钩子的方式:同步(tap 注册 -》 call 执行);异步(tap -》call、tapAsync -》callAsync、tapPromise -》promise)
- 通过 schema-utils 验证 options 的合法性
常见的 Loader:
- file-loader:
- url-loader:
- babel-loader:
- css-loader:
- style-loader:
- eslint-loader:
- cache-loader:
- less-loader、sass-loader、styles-loader:
- image-webpack-loader:压缩图片
- postcss-loader、autoprefixer-loader
就是一个代码转码器,对各种资源进行转换。它的特点:单一原则,每个 loader 只做对应的事情。它的执行顺序:从右到左,从下到上。有几种分类:pre、normal(默认)、inline、post。
Loader
就是一个函数,接受原始资源作为参数,输出进行转换后的内容。- loader 的执行分为两个阶段:Pitch 阶段和 Normal 阶段。
loader
会先执行pitch
,然后获取资源再执行normal loader
。如果pitch
有返回值时,就不会走之后的loader
,并将返回值返回给之前的loader
。这就是为什么pitch
有 熔断 的作用! - loader-utils 中 getOptions(this)方法用来获取 loader 中 options 的配置
- schema-utils 中 validate 方法用来验证 loader 中 options 的配置是否合法{type: 'object', properties: {}, additionalProperties: true}
- loader 分为同步(return 或 this.callback(null, source, map, meta)两种方式)和异步(this.async())
4. 如何提高 Webpack 的构建速度?
优化webpack
构建的方式有很多,主要可以从优化搜索时间、缩小文件搜索范围、减少不必要的编译等方面入手:
- 多线程/多实例构建:HappyPack(不维护了)、thread-loader
- 优化 loader 的配置
- include 和 exclude
- 配置 babel-loader 时,可以配置 cacheDirectory 开启缓存
- 合理的使用 resolve.extensions
- extensions: [".js",".json"]
- 优化 resolve.modules
- 用于配置
webpack
去哪些目录下寻找第三方模块。默认值为['node_modules']
,配置了可以减少查找路径
- 用于配置
- 优化 resolve.alias
- "@":path.resolve(__dirname,'./src')减少查找过程
- 使用 DllPlugin 插件
- 打包成一个 Dll 库,webpack.DllPlugin() -> 生成 mainfest.json 文件
- 引入 Dll 库,webpack.DllReferencePlugin()
- 使用 cache-loader
- 针对一些开销较大的 loader 前添加 cache-loader,将其结果缓存到磁盘里,显著提升二次构建速度(保存和读取这些缓存文件会有一些时间开销,只针对一些开销大的 loader)
- use: ['cache-loader', ...loaders]
- terser 开启多线程
- 使用多进程并行运行来提高构建速度
- optimization: { minimizer: [new TerserPlugin({ parallel: true })] }
- 合理使用 sourceMap
5. Webpack4 和 Webpack5 有哪些区别?
- 压缩代码
内部自带 terser-webpack-plugin 插件(生产环境自动开启压缩)【webpack4 需要独立安装】**扩展:**开发模块开启 Tree Shaking 的方式?【生产环境自动开启】
// webpack5开发环境启动压缩 const TerserPlugin = require('terser-webpack-plugini') module.exports = { optimization: { usedExports: true, // 只导出被使用的模块 minimize: true, // 启动压缩 minimizer: [new TerserPlugin()], }, }
- optimization.sideEffects: true; //开启 (package.json 里也要配置:'sideEffects: false | true | []')
- Tree-shaking 机制的原理?
- treeShaking 也叫
摇树优化
,是一种通过移除多于代码,来优化打包体积的,生产环境默认开启
- treeShaking 也叫
- 它可以在
代码不运行
的状态下,分析出不需要的代码
- 利用
es6模块
的规范- ES6 Module 引入进行
静态分析
,故而编译的时候正确判断到底加载了那些模块
- ES6 Module 引入进行
- Tree-shaking 的实现:
- 先标记出模块导出值中哪些没有被用过
- 使用 Terser 删掉这些没被用到的导出语句
- 标记过程大致可划分为三个步骤:
- Make 阶段,收集模块导出变量并记录到模块依赖关系图 ModuleGraph 变量中
- Seal 阶段,遍历 ModuleGraph 标记模块导出变量有没有被使用
- 生成产物时,若变量没有被其它模块使用则删除对应的导出语句
缓存配置
- webpack4 通过 hard-source-webpack-plugin 缓存
- webpack5 内置了 cache 缓存机制
module.exports = { // 使用持久化缓存 cache: { type: 'filesystem', cacheDirectory: path.join(__dirname, 'node_modules/.cache/webpack'), }, }
**注意:**cache 在开发模式默认设置成 type: memory; 【生产模块把 cache 给禁用掉了】
启动服务的差别
- webpack4 启动服务 -> webpack-dev-server
- webpack5 启动服务使用内置的 webpack serve 启动
输出代码
- webpack4 只能输出 es5 的代码
- webpack5 新增属性 output.ecmaVersion,可以生成 es5 和 es6 的代码
代码分割
- webpack4 将超过 30kb 文件单独提为一个 chunk(minSize: 30000)
- webpack5 可以区分是 js 还是 css,精准划分(minSize: {javascript: 30000, css: 50000})
模块联邦
devtool 差别
- webpack4 有 13 种
- webpack5 有 26 种 **扩展:**webpack4 一般开发环境配置 cheap-eval-module-source-map,在生产用 none;webpack5 使用 eval-cheap-module-source-map
6. Grunt、Gulp、Webpack、Rollup、Vite 的比较?
模块化管理工具 和 自动化构建工具 是不同的。两者主要的侧重点不一样。自动化构建工具 则侧重于 前端开发的整个过程 的控制管理(像流水线)。而模块化管理工具更侧重于 模块打包。可以把开发中的所有资源(图片、js、css 文件等)都当成 模块
Webpack: 是当前最流行的 模块化管理工具 和 打包工具。其通过 loader 的转换,可以将任何形式的资源都当成模块。它还可以将各个模块通过其 依赖关系 打包成符合生产环境部署的前端资源。它还可以将应用程序分解成可管理的代码块,可以按需加载
打包原理:解析各个模块的依赖关系,使用 loader 转换文件,使用 plugin 注入钩子,打包合并模块,最终生成 bundle 文件,使用 express 开启本地服务器,浏览器请求的是 bundle 文件
优点:
- 基本之前 gulp 可以操作的,webpack 都可以做
- 同时支持热更新、tree-shaking、Scope Hoisting、动态加载、代码拆分、文件指纹、代码压缩、静态资源处理等
- 支持多种打包方式
缺点:
- 各个模块之间的依赖关系过于复杂 会导致打包速度很慢
- 使用热更新时,改动一个模块,其他有依赖关系的模块也会重新打包
- 不支持打包出 esm 格式的代码(打包后的代码再次被引用时 tree shaking 困难),打包后亢余代码较多
Vite:和 webpack 差不多,vite 是当下新型的 模块化管理工具 和打包工具。它本地启动速度比 webpack 快了很多。但是 vite 还完成没有替换 webpack 的能力,不管是从社区还是从能力来说,vite 本身还是太过脆弱,它的产生和火热完成依赖于 vue 本身的热度
打包原理:使用 koa 开启本地服务器,没有 webpack 那样打包合并的过程,所以启动服务器快
缺点:
- 项目的开发浏览器要支持 esmmodule
- 不能识别 commonjs 语法
- 生态没有 webpack 丰富
- 生产环境 esbuild 构建对于 css 和代码分割不够友好
Rollup:是下一代 ES6 模块打包工具,可以将我们按照 ESM(ES2015 Module)规范编写的源码构建输出如下格式:
- IIFE:自执行函数,可通过 script 标签加载
- AMD:通过 requirejs 加载
- CommonJS:Node 默认的模块规范,可通过 webpack 加载
- UMD:兼容 IIFE、AMD、CJS 三种模块规范
- ESM:ES2015 Module 规范,可用 webpack,rollup 加载
优点:
- 支持动态导入
- 支持 tree-shaking。仅加载模块里用得到的函数以减少文件大小
- Scope Hoisting。rollup 可以将所有的小文件生成到一个大文件中,所有代码都在同一个函数作用域里;不会像 webpack 那样用很多函数来包装模块
- 没有其他冗余代码, 执行很快。除了必要的
cjs
,umd
头外,bundle 代码基本和源码差不多,也没有奇怪的__webpack_require__
,Object.defineProperty
之类的东西
缺点:
- 不支持热更新功能
- 对于 commonjs 模块,需要额外的插件将其转化为 es2015 供 rollup 处理
- 无法公共代码拆分
适用场景:开发第三方库、生成单一的 umd 文件的场景 比较 Webpack:
- Rollup 目前还不支持代码拆分(Code Splitting)和模块的热更新(HMR)
- 一般,对于应用开发使用 Webpack,对于类库开发使用 Rollup
- 需要代码拆分(Code Splitting),或者很多静态资源需要处理,再或者构建的项目需要引入很多 CommonJS 模块的依赖时,使用 webpack。代码库是基于 ES6 模块,而且希望代码能够被其他人直接使用,使用 Rollup
- React 已经将构建工具从 Webpack 换成了 Rollup
Gulp:是基于 流 的 前端自动化构建工具,采用代码优于配置的策略,更容易学习和使用,它让简单的任务简单,复杂的任务复杂
优点:
- gulp 文档简单,学习成本低,使用也比较简单
- 对大量源文件可以进行流式处理,借助插件,可以对文件类型进行多种操作处理
缺点:
- 不支持 tree-shaking、热更新、代码拆分等
- gulp 对 js 模块化方案无能为力,只是对静态资源做流式处理,处理之后并未做有效的优化整合
适用场景:静态资源密集操作型场景,主要用于 css、图片等静态资源的处理操作 比较 grunt:
- 易用,Gulp 相比于 Grunt 更简洁,而且遵循代码优于配置策略,维护 Gulp 更像是写代码
- 高效,Gulp 核心设计是基于 Unix 的流的概念,通过管道连接,不需要写中间文件
- 易学,Gulp 的核心 API 只有 5 个,之后可以通过管道流组合自己想要的任务
- 流:使用 Grunt 的 I/O 过程中会产生一些中间态的临时文件,一些任务生成临时文件,其它任务可能会基于临时文件再做处理并生成最终的构建后文件。而使用 Gulp 的优势就是利用流的方式进行文件的处理,通过管道将多个任务和操作连接起来,因此只有一次 I/O 的过程,流程更清晰,更纯粹
- 代码优于配置:维护 Gulp 更像是写代码,而且 Gulp 遵循 CommonJS 规范,因此跟写 Node 程序没有区别
Grunt:是一套 前端自动化工具,帮助处理反复重复的任务。一般用于:编译、压缩、合并文件,简单语法检查等
特点:
- Grunt 有一个完成的社区,插件丰富
- 它简单易学,你可以随便安装插件并配置它们
Webpack 的定位是模块打包器,而 Gulp/Grunt 属于构建工具
7. 了解热更新原理吗?它是如何做到的?说说其原理?
开启了 express 应用,添加了对 webpack 编译的监听,添加了和浏览器的 websocket 长连接,当文件变化触发 webpack 进行编译并完成后,会通过 socket 告诉浏览器准备刷新。而为了减少刷新的代价,就是不用刷新页面,而是刷新某个模块,webpack-dev-server 可以支持热更新,通过生成文件的 hash 来对比需要更新的模块,浏览器再进行热替换
扩展:hash、chunkhash、contenthash 三者的区别?
hash 一般是结合 CDN 缓存来使用的
- hash:是跟整个项目的构建相关,只要项目里有文件更改,整个项目构建的 hash 值都会更改,并且全部文件都公用相同的 hash 值(每一次构建都会生成新的 hash 值(不管文件是否有改动)-》导致没有办法实现缓存效果)
- chunkhash:和 hash 不一样,它根据不同的入口文件(entry)进行依赖文件解析,构建对于的 chunk,生成对应的哈希值。-》(同一个 chunk 的一个依赖改变了,其他的依赖哈希值也会变)
- contenthash:针对的是对应的内容是否改变
如何避免相同的随机值?
webpack 在
计算hash后分割chunk
。产生相同随机值可能是因为这些文件属于同一个chunk,可以将某个文件提到独立的chunk(如放入entry)
8. sourceMap 有哪些?对应的作用是什么?
9. Babel 的原理?
Babel 是 JS 语法转换器(将一些高级语法转换成浏览器可以识别的低级语法)
Babel 的功能很纯粹,它只是一个编译器。大多数编译器的工作过程可以分为三部分:
- 解析(Parse) :将源代码转换成更加抽象的表示方法(例如抽象语法树)。包括词法分析和语法分析。词法分析主要把字符流源代码(Char Stream)转换成令牌流( Token Stream),语法分析主要是将令牌流转换成抽象语法树(Abstract Syntax Tree,AST)。
- 转换(Transform) :通过 Babel 的插件能力,对(抽象语法树)做一些特殊处理,将高版本语法的 AST 转换成支持低版本语法的 AST。让它符合编译器的期望,当然在此过程中也可以对 AST 的 Node 节点进行优化操作,比如添加、更新以及移除节点等。
- 生成(Generate) :将 AST 转换成字符串形式的低版本代码,同时也能创建 Source Map 映射。
Babel 的原理:
- 使用 babylon 将源代码进行解析 -> 得到 AST
- 使用 babel-traverse 对 AST 树进行遍历转义 -> 得到新的 AST 树
- 使用 babel-generator 通过 AST 树生成 ES5 代码
Babel 的包构成:
- babel-core: babel 的核心库,提供一下 babel 转义 API,如 babel.transform 等,用于对代码进行转译。(webpack 的 babel-loader 是调用这些 API 来完成转译的)
- babylon:js 的词法解析器
- babel-traverse:用于对 AST 的遍历
- babel-generator: 根据 AST 生成代码
工具包和功能包:
- babel-cli:babel 的命令行工具,通过命令行对 js 代码进行转译
- babel-register:通过绑定 node.js 的 require 来自动转译 require 引用的 js 代码文件
- babel-types:用于检验、构建和改变 AST 树的节点
- babel-polyfill:JS 标准新增的原生对象和 API 的 shim,实现上仅仅是 core-js 和 regenerator-runtime 两个包的封装
- babel-runtime:功能类似 babel-polyfill,一般用于 library 或 plugin 中,因为它不会污染全局作用域
扩展:babel-runtime 和 babel-polyfill 的区别?
babel 默认只转译新的 JS 语法,而不转译新的 API(如:Iterator、Set、Generator、Proxy、Symbol 等全局对象),以及一些定义在全局对象上的方法(如:Object.assign)都不会转译。如果想使用这些新的对象和方法,则需要为当前环境提供一个 ployfill
- babel-ployfill,它会加载整个 polyfill 库,解决了 babel 不转译新 API 的问题。并且在代码中插入一些帮助函数 缺点:直接在代码中插入帮助函数,会导致污染了全局环境;并且全部引入,打包后会有很多重复的代码,导致编译后的代码体积变大
- babel-runtime:babel 为了解决以上问题,提供了单独的包,用以提供编译模块的工具函数。启用 babel-plugin-transform-runtime(它会帮我自动动态 require @babel/runtime 中的内容)后,babel 就会使用 babel-runtime 下的工具函数;这样可以避免自行引入 polyfill 时导致的污染全局命名空间的问题
- babel-runtime 适合在组件,类库项目中使用,而 babel-polyfill 适合在业务项目中使用
扩展:babel-runtime 为什么适合 JavaScript 库和工具包的实现?
- 避免 babel 编译的工具函数在每个模块里重复出现,减小库和工具包的体积
- 在没有使用 babel-runtime 之前,库和工具包一般不会直接引入 polyfill。否则像 Promise 这样的全局对象会污染全局命名空间。在使用 babel-runtime 后,库和工具只要在 package.json 中增加依赖 babel-runtime,交给 babel-runtime 去引入 polyfill 就行了
注意:具体项目还是需要使用 babel-polyfill,只使用 babel-runtime 的话,实例方法不能正常工作(例如 "foobar".includes("foo")
)
10. module、chunk、bundle 分别是什么意思,有何区别?
- 对于一份同逻辑的代码,当我们手写下一个一个的文件,它们无论是 ESM 还是 commonJS 或是 AMD,他们都是 module
- 当我们写的 module 源文件传到 webpack 进行打包时,webpack 会根据文件引用关系生成 chunk 文件,webpack 会对这个 chunk 文件进行一些操作
- webpack 处理好 chunk 文件后,最后会输出 bundle 文件,这个 bundle 文件包含了经过加载和编译的最终源文件,所以它可以直接在浏览器中运行
总结:我们直接写出来的是 module,webpack 处理时是 chunk,最后生成浏览器可以直接运行的 bundle
11. Webpack optimize 有配置过吗?可以简单说说吗?
八、经典面试题
1. 从输入 URL 到页面展示经历了什么?
总体流程如下:
URL 解析 完整的 URL:
协议 + 主机 + 端口 + 路径 + 参数 + 锚点
。如果为非 url 结构的字符串,交给浏览器默认引擎去搜索改字符串;若为 url 结构的字符串,浏览器主进程会交给 网络进程 ,开始干活。- encodeURI 和 encodeURIComponent 的区别?
encodeURI 是编码
整个URL
,而 encodeURIComponent 编码的是参数
部分
- encodeURI 和 encodeURIComponent 的区别?
encodeURI 是编码
检查资源缓存 在有效期内的缓存资源直接使用,称之为
强缓存
。返回 200,size 为 memory cache(资源从内存中取出)和 disk cache(资源从磁盘中取出)。当超过有效期的,则携带缓存的资源标识向服务器发起请求。返回 304,走协商缓存
;返回 200,向服务器发起请求,将结果缓存起来,为下一次使用 通常来说:刷新页面会使用内存缓存
; 关闭后重新打开会使用磁盘缓存
DNS 解析:将域名解析成 IP 地址 如果没有成功使用本地缓存,则需要发起网络请求,发起之前要做 DNS 解析,会依次搜索:
浏览器DNS缓存 -> 操作系统DNS缓存 -> 路由器DNS缓存 -> 服务商DNS服务器查询 -> 全球13台根域名服务器查询
为了节约时间,可以在 HTML 头部做 DNS 的预解析:
<meta http-equiv="x-dns-prefetch-control" content="on" /> <link rel="dns-prefetch" href="http://www.baidu.com" />
为了保证响应的及时,DNS 解析使用的是 UDP 协议
用户向本地 DNS 服务器发起请求属于
递归请求
,本地 DNS 服务器向各级域名服务器发起请求属于迭代请求
TCP 连接:TCP 三次握手
发送 HTTP 请求
服务器处理请求并返回 HTTP 报文
浏览器解析并渲染页面
断开 TCP 连接:TCP 四次挥手
2. 长列表优化?
3. 大文件上传的方案有哪些?
核心:是利用 Blob.prototype.slice 方法,和数组的 slice 方法相似,调用的 slice 方法可以返回原文件的某个切片
根据预先设置好的切片最大数量将文件切分为一个个切片,然后借助 http 的可并发性,同时上传多个切片,这样从原本传一个大文件,变成了同时传多个小的文件切片,可以大大减少上传时间
由于是并发,传输到服务端的顺序可能会发生变化 -》 所以我们还需要给每个切片
记录顺序
服务端需要负责: 接受这些切片,并在接收到所有切片后合并切片 两个问题:
- 何时合并切片,即切片什么时候传输完成?
- 需要前端进行配合,前端在每个切片中都携带切片最大数量的信息,当服务端接受到这个数量的切片时自动合并
- 也可以额外发一个请求主动通知服务端进行切片的合并
- 如何合并切片? 使用 nodejs 的 api fs.appendFileSync,它可以同步地将数据追加到指定文件,也就是说,当服务端接受到所有切片后,先创建一个最终的文件,然后将所有切片逐步合并到这个文件中
- 何时合并切片,即切片什么时候传输完成?
接着实现比较重要的上传功能,上传需要做两件事
对文件进行切片 当点击上传按钮时,调用 createFileChunk 将文件切片,切片数量通过一个常量 Length 控制,这里设置为 10,即将文件分成 10 个切片上传, createFileChunk 内使用 while 循环和 slice 方法将切片放入 fileChunkList 数组中返回. 在生成文件切片时,需要给每个切片一个标识作为 hash,这里暂时使用文件名 + 下标,这样后端可以知道当前切片是第几个切片,用于之后的合并切片
随后调用 uploadChunks 上传所有的文件切片,将
文件切片
,切片 hash
,以及文件名
放入FormData
中,再调用上一步的 request 函数返回一个 promise,最后调用Promise.all 并发
上传所有的切片
将切片传输给服务端 接受切片: 使用 multiparty 包处理前端传来的 FormData, 在 multiparty.parse 的回调中,files 参数保存了 FormData 中文件,fields 参数保存了 FormData 中非文件的字段
server.on('request', async (req, res) => { // ... const multipart = new multiparty.Form() multipart.parse(req, async (err, fields, files) => { if (err) { return } const [chunk] = files.chunk const [hash] = fields.hash const [filename] = fields.filename const chunkDir = `${UPLOAD_DIR}/${filename}` // 切片目录不存在,创建切片目录 if (!fse.existsSync(chunkDir)) { await fse.mkdirs(chunkDir) } // 重命名文件 await fse.rename(chunk.path, `${chunkDir}/${hash}`) res.end('received file chunk') }) })
查看 multiparty 处理后的 chunk 对象,path 是存储临时文件的路径,size 是临时文件大小,在 multiparty 文档中提到可以使用 fs.rename 重命名的方式移动临时文件,也就是文件切片
在接受文件切片时,需要先创建存储切片的文件夹,由于前端在发送每个切片时额外携带了唯一值 hash,所以以 hash 作为文件名,将切片从临时路径移动切片文件夹中
合并切片: 在接收到前端发送的合并请求后,服务端将文件夹下的所有切片进行合并 使用 fs.writeFileSync 先创建一个空文件,这个空文件的文件名就是切片文件夹名 + 后缀名组合而成,随后通过 fs.appendFileSync 从切片文件夹中不断将切片合并到空文件中,每次合并完成后删除这个切片,等所有切片都合并完毕后最后删除切片文件夹
显示上传进度条 上传进度分两种,一个是每个切片的上传进度,另一个是整个文件的上传进度,而整个文件的上传进度是基于每个切片上传进度计算而来,所以我们先实现切片的上传进度 切片进度条: XMLHttpRequest 原生支持上传进度的监听,只需要监听 upload.onprogress 即可,我们在原来的 request 基础上传入 onProgress 参数,给 XMLHttpRequest 注册监听事件
由于每个切片都需要触发独立的监听事件,所以还需要一个工厂函数,根据传入的切片返回不同的监听函数;每个切片在上传时都会通过监听函数更新 data 数组对应元素的 percentage 属性,之后把将 data 数组放到视图中展示即可
onProgress: this.createProgressHandler(this.data[index]) createProgressHandler(item) { return e => { item.percentage = parseInt(String((e.loaded / e.total) * 100)); }; } 每个切片在上传时都会通过监听函数更新 data 数组对应元素的 percentage 属性,之后把将 data 数组放到视图中展示即可
文件进度条: 将每个切片已上传的部分累加,除以整个文件的大小,就能得出当前文件的上传进度,所以这里使用 Vue 计算属性
computed: { uploadPercentage() { if (!this.container.file || !this.data.length) return 0; const loaded = this.data .map(item => item.size * item.percentage) .reduce((acc, cur) => acc + cur); return parseInt((loaded / this.container.file.size).toFixed(2)); } }
断点续传 断点续传的原理在于前端/服务端需要记住已上传的切片,这样下次上传就可以跳过之前已上传的部分,有两种方案实现记忆的功能:
- 前端使用 localStorage 记录已上传的切片 hash
- 服务端保存已上传的切片 hash,前端每次上传前向服务端获取已上传的切片 第一种是前端的解决方案,第二种是服务端,而前端方案有一个缺陷,如果换了个浏览器就失去了记忆的效果,所以选取后者
生成 hash 无论是前端还是服务端,都必须要生成文件和切片的 hash,之前我们使用文件名 + 切片下标作为切片 hash,这样做文件名一旦修改就失去了效果,而事实上只要文件内容不变,hash 就不应该变化,所以正确的做法是根据文件内容生成 hash,所以我们修改一下 hash 的生成规则.
spark-md5
,它可以根据文件内容计算出文件的 hash 值另外考虑到如果上传一个超大文件,读取文件内容计算 hash 是非常耗费时间的,并且会引起 UI 的阻塞,导致页面假死状态,所以我们使用 web-worker 在 worker 线程计算 hash,这样用户仍可以在主界面正常的交互
由于实例化 web-worker 时,参数是一个 js 文件路径且不能跨域,所以我们单独创建一个 hash.js 文件放在 public 目录下,另外在 worker 中也是不允许访问 dom 的,但它提供了 importScripts 函数用于导入外部脚本,通过它导入 spark-md5.在 worker 线程中,接受文件切片 fileChunkList,利用 FileReader 读取每个切片的 ArrayBuffer 并不断传入 spark-md5 中,每计算完一个切片通过 postMessage 向主线程发送一个进度事件,全部完成后将最终的 hash 发送给主线程
主线程使用 postMessage 给 worker 线程传入所有切片 fileChunkList,并监听 worker 线程发出的 onMessage 事件拿到文件 hash
至此前端需要将之前用文件名作为 hash 的地方改写为 worker 返回的这个 hash,服务端则使用 hash 作为切片文件夹名,hash + 下标作为切片名,hash + 扩展名作为文件名
文件秒传 所谓的文件秒传,即在服务端已经存在了上传的资源,所以当用户再次上传时会直接提示上传成功
文件秒传需要依赖上一步生成的 hash,即在上传前,先计算出文件 hash,并把 hash 发送给服务端进行验证,由于 hash 的唯一性,所以一旦服务端能找到 hash 相同的文件,则直接返回上传成功的信息即可
暂停上传 断点续传顾名思义即断点 + 续传,所以我们第一步先实现“断点”,也就是暂停上传.原理是使用 XMLHttpRequest 的 abort 方法,可以取消一个 xhr 请求的发送,为此我们需要将上传每个切片的 xhr 对象保存起来,我们再改造一下 request 方法
request({ onProgress = e => e, requestList }) { return new Promise(resolve => { const xhr = new XMLHttpRequest(); xhr.upload.onprogress = onProgress; xhr.open(method, url); Object.keys(headers).forEach(key => xhr.setRequestHeader(key, headers[key]) ); xhr.send(data); xhr.onload = e => { // 将请求成功的 xhr 从列表中删除 if (requestList) { const xhrIndex = requestList.findIndex(item => item === xhr); requestList.splice(xhrIndex, 1); } resolve({ data: e.target.response }); }; // 暴露当前 xhr 给外部 requestList?.push(xhr); }); },
这样在上传切片时传入 requestList 数组作为参数,request 方法就会将所有的 xhr 保存在数组中了
每当一个切片上传成功时,将对应的 xhr 从 requestList 中删除,所以 requestList 中只保存正在上传切片的 xhr,之后新建一个暂停按钮,当点击按钮时,调用保存在 requestList 中 xhr 的 abort 方法,即取消并清空所有正在上传的切片
恢复上传 由于当文件切片上传后,服务端会建立一个文件夹存储所有上传的切片,所以每次前端上传前可以调用一个接口,服务端将已上传的切片的切片名返回,前端再跳过这些已经上传切片,这样就实现了“续传”的效果
而这个接口可以和之前秒传的验证接口合并,前端每次上传前发送一个验证的请求,返回两种结果
- 服务端已存在该文件,不需要再次上传
- 服务端不存在该文件或者已上传部分文件切片,通知前端进行上传,并把已上传的文件切片返回给前端
回到前端,前端有两个地方需要调用验证的接口:
- 点击上传时,检查是否需要上传和已上传的切片
- 点击暂停后的恢复上传,返回已上传的切片
总结:
- 大文件上传
- 前端上传大文件时使用 Blob.prototype.slice 将文件切片,并发上传多个切片,最后发送一个合并的请求通知服务端合并切片
- 服务端接收切片并存储,收到合并请求后使用 fs.appendFileSync 对多个切片进行合并
- 原生 XMLHttpRequest 的 upload.onprogress 对切片上传进度的监听
- 使用 Vue 计算属性根据每个切片的进度算出整个文件的上传进度
- 断点续传
- 使用 spart-md5 根据文件内容算出文件 hash
- 通过 hash 可以判断服务端是否已经上传该文件,从而直接提示用户上传成功(秒传)
- 通过 XMLHttpRequest 的 abort 方法暂停切片的上传
- 上传前服务端返回已经上传的切片名,前端跳过这些切片的上传
4. 自己有开发过组件库吗?
5. 说说你对项目的优化有哪些?有量化吗?
6. 有开发过脚手架库吗?
- 基本功能:
- 通过 tmc create projectName 命令创建项目
- 询问用户需要选择下载的模板
- 远程拉取模板
- 工具库:
- inquirer: 命令行询问用户问题
- commander: 命令行自定义指令
- chalk: 控制台输出内容样式美化
- ora: 控制台 loading 效果
- download-git-repo: 下载远程模板
- cross-spawn: 跨平台 shell 工具(可以用来自动执行 shell 命令)
- figlet: logo 获取远程模板: axios.get('https://api.github.com/orgs/[仓库名]/repos') 获取远程版本: axios.get('https://api.github.com/repos/[仓库名]/${repo}/tags') gitlab 获取方式:https://docs.gitlab.com/ee/api/repositories.html#get-file-archive
- 实现思路:#! /usr/bin/env node
- 创建 bin/cli.js 启动文件,package.json 中添加"bin": {"tmc": "./bin/cli.js"}, 然后 npm link 链接到全局:控制台执行 tmc
- 使用 commander 创建启动命令,(command 定义命令和参数、description 描述、option 选项、action 动作),配置版本号信息,parse(process.argv)解析用户执行命令传入的参数
- 创建项目之前要考虑:目录是否存在?
- 若存在:
- 当{force: true}时,直接移除原来的目录,直接创建
- 当{force: false}时,询问用户是否覆盖
- 若不存在,直接创建
- 添加更多的 option 选项,logo
- 通过 inquirer 询问用户当{force: false}时,是否覆盖
- 通过 axios.get 获取项目列表和 tag 列表
- 通过 download-git-repo 来下载远程模板(在获取之前显示 loading 关闭 loading)
7. 封装过哪些组件?
UI 组件:
- 柱状图组件(BarChart.vue)
- 折线图组件(LineChart.vue)
- 饼图组件(PieChart.vue)
- 表格组件 (SaaSTable.vue)
- 导入组件(SaaSUpdate.vue)
- 放大弹框组件(ZoomCard.vue)
- 漏斗组件(FunnelChart.vue)
- 布局组件(SideMenu.vue/SideMenuItem.vue)
- 面积图组件
- 堆积图组件
业务组件:
- 盒子组件(SquareCard.vue)
- 暂无数据组件(NoData.vue)
- 动态表单搜索组件(DynamicSearch.vue)
- 卡片组件(BaseCard.vue)
- 卡片组合组件(Card.vue)
- 统计组件(Statistics.vue)
- 自动生成页面组件(TailerBillBoard.vue)
- 自定义颜色组件
8. 你搭建的项目是从哪些方面入手的?
9. 说说你对前端模块化的理解?
10. 说说浏览器渲染原理?
- 解析 HTML,生成 DOM 树。解析 CSS,生成 CSSOM 树
- 将 DOM 树和 CSS 树结合,生成渲染树(Render Tree)
- Layout(回流):根据生成的渲染树,进行回流(Layout),得到节点的几何信息(位置、大小)
- Painting(重绘):根据渲染树以及回流得到节点的几何信息,从而得到节点的绝对像素
- Display:将像素发送给 GPU。展示在页面上
注意:渲染树只包含可见的节点 扩展:为了构建渲染树,浏览器主要完成了以下工作:
- 从 DOM 树的根节点开始遍历每个可见节点
- 对于每个可见的节点,找到 CSSOM 树中对于的规则,并应用它们
- 根据每个可见节点以及其对应的样式,组合生成渲染树
不可见的节点包括:
- 一些不会渲染输出的节点:如:script、link、meta 等
- 一些通过 CSS 进行隐藏的节点:如:display:none;
注意:利用 visible 和 opacity 隐藏节点,还是会显示在渲染树上的
九、版本控制相关
1. git merge 和 git rebase 的区别?
git 的 merge 和 rebase 都是用来合并分支的
git merge:
- 记录下合并动作,很多时候这种合并动作是垃圾信息
- 不会修改原 commit ID
- 分支看着不大整洁(不是线性),但是能看出合并的先后顺序
- 记录了真实的 commit 情况,包括每个分支的详情
git merge 的优势是它保留了分支的结构与历史提交目录,但同时这也导致了提交历史会被大量的 merge 污染
git rebase:
- 得到更简洁的项目历史,分支线性
- 修改所有 commit ID
rebase 合并往往又被称为 「变基」,它是将把所有的提交压缩成一个 patch 。然后把 patch 添加到目标分支里。rebase 与 merge 不同的是:rebase 通过为原始分支中的每个提交创建全新的 commits 来重写项目历史记录
git rebase 的优势是可以获得更清晰的项目历史。首先,它消除了 git merge 所需的不必要的合并提交;rebase 会产生完美线性的项目历史记录,你可以在 feature 分支上没有任何分叉的情况下一直追寻到项目的初始提交。但是, rebase 会丢失合并提交的上下文, 使我们无法看到真实的更改是何时合并到目标分支上的
建议:
- 本地开发:应该使用 rebasing 而不是 merging ,这样历史记录会很清晰
- 你的代码准备好了被 review :它会让 pull request 的可塑性更强,也能避免历史突然丢失
- review 已经完成并且已经准备好了合并到目标分支:使用 merge
2. git pull 和 git fetch 的区别?
- git fetch 的意思是将远程主机的最新内容拉到本地,用户再检查无误后再决定是否合并到工作本地分支中
- git pull 是将远程主机中的最新内容拉取下来后直接合并,即:git pull = git fetch+git merge,这样可能会产生冲突,需要手动解决
3. 说一下 git 发生冲突后如何解决?
一般情况下,出现冲突的场景有如下:
- 多个分支代码合并到一个分支时
- 多个分支向同一个远端分支推送
git stash -> git checkout xxx -> git stash pop -> 解决冲突 -> commit
4. git flow 工作流的理解?
Git flow: 使用 git 要遵循的一套规范(称为分之管理模型),有助于项目开发和发布的有序、高效
分支分类:
- 主分支:master、develop
- 辅助分支:feature、release、hotfix
git flow 优缺点:
- 优点:各个分支明确,便于开发、并行、追溯
- 缺点:分支间切换次数过多,合并次数较多
十、安全相关
1. CSRF 和 XSS 、SSRF 的攻击原理 与防御措施?
CSRF(Cross-site request forgery):跨站请求伪造: 是一种劫持受信任用户向服务器发送非预期请求的攻击方式
- CSRF 的攻击原理
从上图可以看出,要完成一次 CSRF 攻击,受害者必须满足两个必要的条件:
- 登录受信任网站 A,并在本地生成 Cookie。(如果用户没有登录网站 A,那么网站 B 在诱导的时候,请求网站 A 的 api 接口时,会提示你登录)
- 在不登出 A 的情况下,访问危险网站 B(其实是利用了网站 A 的漏洞)
温馨提示一下:cookie 保证了用户可以处于登录状态,但网站 B 其实拿不到 cookie
CSRF 如何防御
- 方法一、Token 验证:(用的最多) 服务器发送给客户端一个 token;客户端提交的表单中带着这个 token。如果这个 token 不合法,那么服务器拒绝这个请求。
- 方法二、隐藏令牌: 把 token 隐藏在 http 的 head 头中。方法二和方法一有点像,本质上没有太大区别,只是使用方式上有区别。
- 方法三、Referer 验证: Referer 指的是页面请求来源。意思是,只接受本站的请求,服务器才做响应;如果不是,就拦截。
XSS(Cross Site Scripting):跨域脚本攻击: 是指攻击者在网站上注入恶意的客户端代码,通过恶意脚本对客户端网页进行篡改,从而在用户浏览网页时,对用户浏览器进行控制或者获取用户隐私数据的一种攻击方式
XSS 的攻击原理 XSS 攻击的核心原理是:不需要你做任何的登录认证,它会通过合法的操作(比如在 url 中输入、在评论框中输入),向你的页面注入脚本(可能是 js、html 代码块等)。导致:盗用 Cookie、破坏页面的正常结构,插入广告等恶意内容、D-doss 攻击
XSS 的攻击方式
- 反射型 发出请求时,XSS 代码出现在 url 中,作为输入提交到服务器端,服务器端解析后响应,XSS 代码随响应内容一起传回给浏览器,最后浏览器解析执行 XSS 代码。这个过程像一次反射,所以叫反射型 XSS
- 存储型 存储型 XSS 和反射型 XSS 的差别在于,提交的代码会存储在服务器端(数据库、内存、文件系统等),下次请求时目标页面时不用再提交 XSS 代码
XSS 的防范措施(encode + 过滤)
- 方法一、编码 对用户输入的数据进行 HTML Entity 编码;把字符转换成 转义字符
- 方法二、过滤 移除用户输入的和事件相关的属性。如 onerror 可以自动触发攻击,还有 onclick 等。(总而言之,过滤掉一些不安全的内容) 移除用户输入的 Style 节点、Script 节点、Iframe 节点。(尤其是 Script 节点,它可是支持跨域的呀,一定要移除)
- 方法三、校正 避免直接对 HTML Entity 进行解码;使用 DOM Parse 转换,校正不配对的 DOM 标签
CSRF 和 XSS 的区别:
- CSRF:需要用户先登录网站 A,获取 cookie;XSS:不需要登录
- CSRF:是利用网站 A 本身的漏洞,去请求网站 A 的 api;XSS:是向网站 A 注入 JS 代码,然后执行 JS 里的代码,篡改网站 A 的内容
SSRF
扩展:v-html 的弊端?(利用 innerHTML)
- 可能会导致 xss 攻击
- v-html 会替换掉标签内部的子元素
2. 知道第三方 Cookie 吗?
cookie 由哪些部分组成:
- Name: 这个属性就表示 cookie 的名字,每个 cookie 的名字都是唯一的
- Value: 这个属性表示 cookie 的值
- Domain: 就是 cookie 所在的域名,如果没有设置 domain 的话,那么 cookie 会自动绑定到执行语句的当前域
- Path: 这个属性的默认值是/,匹配的是路由,这里匹配的是路由的意思就是比如你的域名是 www.xxx.xyz,那么路由如果是 www.xxx.xyz/auth/,那么实际上 cookie 绑定的是这个/auth
- Max-age: 这个属性是 http1.1 新增的属性,用来替代 expires 的,单位是秒,用来表示 cookie 在多少秒之后会失效
- Secure: 因为 http 是无状态协议,而且 http 在传输数据的过程中是以明文传输的,因此很容易遭到第三方网站的窃取,如果我们使用 secure 的话,就能够确保 cookie 是在 https 协议下进行传输的,但是这不代表会将 cookie 加密
- HttpOnly: 这个属性表示不能够被 js 脚本访问,因为 js 能够通过 document.cookie 来获取 cookie,所以使用 HttpOnly 就能够阻止这种情况,在一定程度上防止 xss 攻击,也就是跨站脚本攻击
- SameSite: 用于限制第三方网站的 cookie 发送机制(cookie 每次随着请求会自动发送到服务器去的,这就给了其他站点发起 CSRF 攻击和用户追踪的机会)
具体如下:
- Strict: 最严格的模式,完全禁止跨站点请求时携带 cookie,设置为 strict 之后,跨站行为都不会再携带 cookie
- Lax: 相对 strict 模式会宽松一点儿,允许导航到三方网站时携带 cookie,即 a 标签跳转,form 表单的 get 提交,以及 link 标签的 prerender
- None: 使用 None 显示的关闭 SameSite 模式控制,但是需要注意的是还需要加上 secure,即 cookie 只会在 HTTPS 中发送,如果只是设置了 SameSite=None 是没有效果的
cookie 的分类: 第一方 Cookie 和第三方 Cookie,这两类 Cookie 都是网站保存在用户电脑上的一个文件,它们都由某个特定的域创建,并且只能被这个域访问
第一方 Cookie 是由地址栏中列出的网站域设置的 Cookie,而第三方 Cookie 来自在网页上嵌入广告或图片等项的其他域来源。都是网站在客户端上存放的一小块数据。他们都由某个域存放,只能被这个域访问。他们的区别其实并不是技术上的区别,而是使用方式上的区别。
十一、手写题
1. bind、call、apply 的实现原理?
与apply唯一不同的是,call()方法接受的是一个参数列表
Function.prototype.call = function(context = window, ...args) {
if (typeof this !== 'function') {
throw new TypeError('Type Error')
}
const fn = Symbol('fn')
context[fn] = this
const res = context[fn](...args)
delete context[fn]
return res
}
第一个参数是绑定的this,默认为window,第二个参数是数组或类数组
// Function.prototype.apply = function(context = window, args) {
// if(typeof this !== 'function') {
// throw new TypeError('Type Error')
// }
// const fn = Symbol('fn')
// context[fn] = this
// const res = context[fn](...args)
// delete context[fn]
// return res
// }
Function.prototype.apply = function(context = window) {
if (typeof this !== 'function') {
throw new TypeError('Type Error')
}
let res
const fn = Symbol('fn')
context[fn] = this
if(arguments[1]) {
res = context[fn](...arguments[1])
} else {
res = context[fn]()
}
delete context[fn]
return res
}
Function.prototype.bind = function(context = window, ...args) {
if (typeof this !== 'function') {
throw new TypeError('Type Error')
}
// 保存this
let self = this
return function F() {
// 考虑new的情况
if (this instanceof F) {
return new self(...args, ...arguments)
} else {
return self.apply(context, [...args, ...arguments])
}
}
}
2. 能否手写一个 new?
实现 new 操作符的步骤:
- 函数接受一个不定量的参数,第一个参数为构造函数,剩余的参数被构造函数使用
- 内部创建一个空对象 obj
- 因为 obj 对象需要访问到构造函数原型链上的属性,所有通过 setPrototype 将两者关联起来
- 将 obj 绑定到构造函数上,并传入剩余参数
- 判断构造函数返回值是否是对象,如果为对象就是要构造函数返回的值,否则返回 obj
function createNew(Con, ...args) { if (typeof Con !== 'function') { throw new TypeError('Type Error') } let obj = {} Object.setPrototype(obj, Con.prototype) // 等价于obj.__proto__ = Con.prototype let result = Con.apply(obj, ...args) return result instanceof Object ? result : obj }
注意:当 new Object()不传参数时,字面量{}和 new 关键字创建的对象是 Object 的实例一样的;Object.create()传两个参数,第一个为新创建对象的原型对象,第二个为自身定义的属性;为 null,新对象是空对象,没有原型,不继承任何对象;arg 为指定对象,新对象的原型指向指定对象,继承指定对象
3. 实现一个 instanceof?
instanceof 用来检测一个对象在其原型链中是否存在一个构造函数的 prototype 属性
function instanceof(left, right) {
let proto = left.__proto__;
let prototype = right.prototype;
while(true) {
if (proto === null) return false;
if (proto === prototype) return true;
proto = proto.__proto__; // 等价于proto = Object.getPrototypeOf(proto)
}
}
4. 实现一个 发布订阅 EventEmitter?
发布订阅模式中,包含发布者,事件调度中心,订阅者三个角色。EventEmitter 的一个实例就是一个事件调度中心,发布者和订阅者是松散耦合的,互不关心对方是否存在,他们关注的是事件本身。发布者借用事件调度中心提供的 emit 方法发布事件,而订阅者则通过 on 进行订阅。
class EventEmitter {
constructor() {
this.listeners = {} // 存储所有事件的监听器
}
/**
* 注册事件监听者
* @param {*} type 事件类型
* @param {*} cb 回调函数
*/
on(type, cb) {
if (!this.listeners[type]) {
this.listeners[type] = []
}
this.listeners[type].push(cb)
}
/**
* 发布事件
* @param {*} type 事件类型
* @param {...any} args 参数列表,把emit传递的参数赋给回调函数
*/
emit(type, ...args) {
if (this.listeners[type]) {
this.listeners[type].forEach((cb) => {
cb(...args)
})
}
}
/**
* 移除某个事件的一个监听者
* @param {*} type 事件类型
* @param {*} cb 回调函数
*/
off(type, cb) {
if (this.listeners[type]) {
const targetIndex = this.listeners[type].findIndex((item) => item === cb)
if (targetIndex !== -1) {
this.listeners[type].splice(targetIndex, 1)
}
if (this.listeners[type].length === 0) {
delete this.listeners[type]
}
}
}
offAll(type) {
if (this.listeners[type]) {
delete this.listeners[type]
}
}
}
特点: 发布订阅模式中,对于发布者 Publisher 和订阅者 Subscriber 没有特殊的约束,他们好似是匿名活动,借助事件调度中心提供的接口发布和订阅事件.松散耦合,灵活度高,常用作事件总线 缺点: 当事件类型越来越多时,难以维护,需要考虑事件命名的规范
- 观察者模式 角色很明确,没有事件调度中心作为中间者,目标对象 Subject 和观察者 Observer 都要实现约定的成员方法。双方联系更紧密,目标对象的主动性很强,自己收集和维护观察者,并在状态变化时主动通知观察者更新。
// 观察者
class Observer {
constructor(cb) {
if (typeof cb === 'function') {
this.cb = cb
} else {
throw new Error('Observer构造器必须传入函数类型!')
}
}
update() {
this.cb()
}
}
// 被观察者
class Subject {
constructor() {
this.observerList = [] // 维护观察者列表
}
addObserver(observer) {
this.observerList.push(observer)
}
notify() {
this.observerList.forEach((observer) => {
observer.update()
})
}
}
5. 数组去重的方式?
const arr = [1, 1, '1', 17, true, true, false, false, 'true', 'a', {}, {}]
// => [1, '1', 17, true, false, 'true', 'a', {}, {}]
- 方式一、利用 Set
const res = Array.from(new Set(arr))
- 方式二、利用 indexOf
const unique = (arr) => {
const res = []
for (let i = 0; i < arr.length; i++) {
if (res.indexOf(arr[i]) === -1) {
res.push(arr[i])
}
// if(!res.includes(arr[i])) {
// res.push(arr[i])
// }
}
return res
}
const unique = (arr) => {
return arr.filter((item, index) => {
return arr.indexOf(item) === index
})
}
当然也可以用 include、filter,思路大同小异
- 方式三、两层 for 循环 + splice
const unique = (arr) => {
let len = arr.length
for (let i = 0; i < len; i++) {
for (let j = i + 1; j < len; j++) {
if (arr[i] === arr[j]) {
arr.splice(j, 1)
// 每删除一个树,j--保证j的值经过自加后不变。同时,len--,减少循环次数提升性能
len--
j--
}
}
}
return arr
}
- 方式四、利用 Map
const unique = (arr) => {
const map = new Map()
const res = []
for (let i = 0; i < arr.length; i++) {
if (!map.has(arr[i])) {
map.set(arr[i], true)
res.push(arr[i])
}
para
}
return res
}
6. 解析 URL 参数?
function parseParam(url) {
const paramsStr = /.+\?(.+)$/.exec(url)[1] // 将?后面的字符串取出来
const paramsArr = paramsStr.split('&') // 将字符串以&分割后存到数组里
let paramsObj = {} // 将params存到对象中
paramsArr.forEach((param) => {
if (/=/.test(param)) {
// 处理有value的参数
let [key, val] = param.split('=') // 分割key和value
val = decodeURIComponent(val) // 解码
val = /^\d+$/.test(val) ? parseFloat(val) : val // 判断是否转为数字
if (paramsObj.hasOwnProperty(key)) {
// 如果对象有key,则添加一个值
paramsObj[key] = [].concat(paramsObj[key], val)
} else {
// 如果对象没有这个key,创建key并设置值
paramsObj[key] = val
}
} else {
// 处理没有value的参数
paramsObj[param] = true
}
})
return paramsObj
}
// 创建一个URLSearchParams实例
const urlSearchParams = new URLSearchParams(window.location.search)
// 把键值对列表转换为一个对象
const params = Object.fromEntries(urlSearchParams.entries())
7. 版本比较?
如果 version1 > version2 返回 1, 如果 version1 < version2 返回 -1, 除此之外返回 0
function compileVersion(version1, version2) {
// 将两个版本号切割成由修订号组成的数组
const arr1 = version1.split('.')
const arr2 = version2.split('.')
// 比较数组长度,得到最大的数组长度
const maxLength = Math.max(arr1.length, arr2.length)
// 遍历数组,分别比较同一个位置上的版本号
for (let i = 0; i < maxLength; i++) {
// 从左到右依次比较版本号
const a = arr1[i] || 0
const b = arr2[i] || 0
// 忽略前导0,使用Number()转为数字
if (Number(a) > Number(b)) {
return 1
} else if (Number(a) < Number(b)) {
return -1
}
// 对比结束的时候就返回0
if (i === maxLength - 1) {
return 0
}
}
}
console.log(compileVersion('0.1', '1.1'))
8. 手写 防抖 和 节流?
- debounce 防抖:触发高频时间后 n 秒内函数只会执行一次,如果 n 秒内高频时间再次触发,则重新计算时间(按最后一次算。比如说“停止输入 5s 后才发送请求”)
const debounce = (fn, time) => {
let timeout = null
return function() {
clearTimeout(timeout)
timeout = setTimeout(() => {
fn.apply(this, arguments)
}, time)
}
}
防抖常应用于用户进行搜索输入节约请求资源,window 触发 resize 事件时进行防抖只触发一次
- throttle 节流:高频时间触发,但 n 秒内只会执行一次,所以节流会稀释函数的执行频率(在 n 秒内只会执行一次,所以节流会稀释函数的执行频率)
const throttle = (fn, time) => {
let flag = true
return function() {
if (!flag) return
flag = false
setTimeout(() => {
fn.apply(this, arguments)
flag = true
}, time)
}
}
节流常应用于鼠标不断点击触发、监听滚动事件
9. 你知道判断数组的几种方式?
function isArray(arr) {
// 1. 通过原型链判断
// return arr.__proto__ === Array.prototype;
// 2. 通过 ES6 中 Array.isArray()判断
// return Array.isArray(arr);
// 3. 通过 instanceof 判断
// return arr instanceof Array;
// 4. 通过 Array.prototype.isPrototypeOf() 判断
// return Array.prototype.isPrototypeOf(arr);
// 5. 通过 Object.prototype.toString.call() 判断
return Object.prototype.toString.call(arr).slice(8, -1) === 'Array'
}
扩展:isPrototypeOf 和 instanceof 的区别?
- isPrototypeOf 表示对象是否在另一个对象的原型链上
- instanceof 运算符用来测试一个对象在其原型链中是否存在一个构造函数的 prototype 属性
10. 使用 reduce 实现 map?
用数组的 reduce 方法实现 map 方法
arr.reduce((previousValue, currentValue, currentIndex, array) => {},
initialValue)
reduce 接受两个参数,第一个参数,指定了每次迭代调用的函数,函数的返回值为下一次迭代的 previousValue。第二个参数为初始值,是可选的。
- 若没有指定初始值,那么第一次的 previousValue 为 arr[0], currentValue 为 arr[1], currentIndex 为 1
- 若指定初始值,那么第一次的 previousValue 为 initialValue, currentValue 为 arr[0], currentIndex 为 0
- 第二个参数为 thisArg,可选的,表示执行函数时的 this 注意:如果 callback 为箭头函数时,里面的 this 执行外层代码块(非严格模式下为 window),此时指定的 thisArg 无效
Array.prototype.MCmap = function(fn, thisArg) {
const result = []
this.reduce((prev, curr, index, array) => {
result[index] = fn.call(thisArg, array[index], index, array)
}, 0)
return result
}
let arr = [1, 2, 3]
let arr1 = arr.MCmap((item) => {
return item * 2
})
console.log(arr1)
11. 手写数据类型判断?
function typeOf(obj) {
let res = Object.prototype.toString.call(obj).split(' ')[1]
res = res.substring(0, res.length - 1).toLowerCase()
return res
// 更好的写法
// return Object.prototype.toString.call(obj).slice(8, -1).toLowerCase()
}
typeOf([]) // 'array'
typeOf({}) // 'object'
typeOf(new Date) // 'date'
12. 手写继承?
- 原型链继承
- 借用构造函数实现继承
- 组合继承
- 寄生式继承
- class 实现继承
13. 偏函数 & compose & 柯里化?
14. 能实现一个 JSONP 吗?
script 标签不遵循同源协议,可以用来进行跨域请求,优点就是兼容性好但仅限于 GET 请求
const jsonp = ({ url, params, callbackName }) => {
const generateUrl = () => {
let dataSrc = ''
for (let key in params) {
if (Object.prototype.hasOwnPrototype.call(params, key)) {
dataSrc += `${key}=${params[key]}&`
}
}
dataSrc += `callback=${callbackName}`
return `${url}?${dataSrc}`
}
return new Promise((resolve, reject) => {
const scriptEle = document.createElement('script')
scriptEle.src = generatorUrl()
document.body.appendChild(scriptEle)
window[callbackName] = (data) => {
resolve(data)
document.removeChild(scriptEle)
}
})
}
15. 实现一下原生的 AJAX?
const getJson = function(url) {
return new Promise((resolve, reject) => {
const xhr = XMLHttpRequest
? new XMLHttpRequest()
: new ActiveXObject('Microsoft.XMLHttp')
xhr.open('GET', url, false)
xhr.setRequestHeader('Accept', 'application/json')
xhr.onreadystatechange = function() {
if (xhr.readyState !== 4) return
if (xhr.status === 200 || xhr.status === 304) {
resolve(xhr.responseText)
} else {
reject(new Error(xhr.responseText))
}
}
xhr.send()
})
}
16. 实现数组原型方法?
forEach
map 用数组的 reduce 方法实现 map 方法
arr.reduce((previousValue, currentValue, currentIndex, array) => {}, initialValue)
reduce 接受两个参数,第一个参数,指定了每次迭代调用的函数,函数的返回值为下一次迭代的 previousValue。第二个参数为初始值,是可选的。
- 若没有指定初始值,那么第一次的 previousValue 为 arr[0], currentValue 为 arr[1], currentIndex 为 1
- 若指定初始值,那么第一次的 previousValue 为 initialValue, currentValue 为 arr[0], currentIndex 为 0
- 第二个参数为 thisArg,可选的,表示执行函数时的 this 注意:如果 callback 为箭头函数时,里面的 this 执行外层代码块(非严格模式下为 window),此时指定的 thisArg 无效
Array.prototype.MCmap = function(fn, thisArg) { const result = [] this.reduce((prev, curr, index, array) => { result[index] = fn.call(thisArg, array[index], index, array) }, 0) return result } let arr = [1, 2, 3] let arr1 = arr.MCmap((item) => { return item * 2 }) console.log(arr1)
filter
some
reduce
17. 实现 Object.create & Object.assign?
18. 手写 Promise?
isPromise
function isPromise(value) { if ( typeof value !== null && (typeof value === 'object' || typeof value === 'function') ) { if (typeof value.then === 'function') { return true } } else { return false } }
resolve
reject
all 返回一个 promise 对象,只有当所有 promise 都成功时返回的 promise 状态才成功
function all(promises) { return new Promise((resolve, reject) => { if (!Array.isArray(promises)) { throw new TypeError('promises must be a array') } let result = [] // 存放结果 let count = 0 // 记录有几个resolved function processData(key, value) { result[key] = value if (++count === promises.length) { resolve(result) } } for (let i = 0; i < promises.length; i++) { let current = promises[i] if (isPromise(current)) { current.then((data) => { processData(i, data) }, reject) } else { processData(i, current) } } }) }
缺陷:在并发请求中,只有有一个请求错误,promise 的状态就是 reject
race
allSettled Promise.all 需要所有 promise 都成功时才 resolve 或者有一个失败时即 reject,Promise.allSettled 只关心所有 promise 是不是都被 settle 了,不管其是 rejected 状态的 promise,还是非 rejected 状态(即 fulfilled)的 promise, 我都可以拿到它的最终状态并对其进行处理
- Promise.allSettled 的结果数组中可能包含以下两种格式的数据
- {status:"fulfilled", value:result} 对于成功的响应
- {status:"rejected", reason:error} 对于 error
function allSettled(promises) { return new Promise((resolve, reject) => { let result = [] // 存放运行的结果 let index = 0 // 调用的次数和传入的参数个数一直的时候,resolve function processData(key, obj) { result[key] = obj if (++index === promises.length) { resolve(result) } } for (let i = 0; i < promises.length; i++) { let current = promises[i] if (isPromise(current)) { current.then( (data) => { let obj = { status: 'fulfilled', value: data, } processData(i, obj) }, (err) => { let obj = { status: 'rejected', value: err, } processData(i, obj) } ) } else { let obj = { status: 'fulfilled', value: current, } processData(i, current, obj) } } }) }
- Promise.allSettled 的结果数组中可能包含以下两种格式的数据
any
https://juejin.cn/post/6946022649768181774#heading-38
19. 排序?
- 冒泡排序
function BubbleSort(arr) { for (let i = 0; i < arr.length; i++) { for (let j = 1; j < arr.length - i; j++) { const temp = arr[j] arr[j] = arr[j - 1] arr[j - 1] = temp } } return arr }
- 选择排序
- 插入排序
- 快速排序
- 归并排序
- 希尔排序
20. 类数组转化为数组的方法?
类数组是具有 length 属性,但不具有数组原型上的方法。常见的类数组有 arguments、DOM 操作方法返回的结果。
- 方式一、Array.from()
Array.from(document.querySelectorAll('div'))
- 方式二、Array.prototype.slice.call()
Array.prototype.slice.call(document.querySelectorAll('div'))
- 方式三、扩展运算符
;[...document.querySelectorAll('div')]
- 方式四、利用 concat()
Array.prototype.concat.apply([], document.querySelectorAll('div'))
21. 列表转成树形结构 & 树形结构转成列表?
22. 大数相加?
23. 数组扁平化?
数组扁平化是指将一个多维数组变为一个一维数组
const arr = [1, [2, [3, [4, 5]]], 6]
// => [1, 2, 3, 4, 5, 6]
- 方法一、使用 flat()
const res = arr.flat(Infinity)
- 方法二、使用正则
const res = JSON.stringify(arr)
.replace(/\[|\]/g, '')
.split(',')
数据类型都会变为字符串(3)[('1', '2', '3')]
正则改良版本(3)[(1, 2, 3)]
const res = JSON.parse('[' + JSON.stringify(arr).replace(/\[|\]/g, '') + ']')
- 方法三、使用 reduce
const flatten = (arr) => {
return arr.reduce((pre, cur) => {
return pre.concat(Array.isArray(cur) ? flatten(cur) : cur)
}, [])
}
const res = flatten(arr)
- 方式四、函数递归
const res = []
const flatten = (arr) => {
for (let i = 0; i < arr.length; i++) {
if (Array.isArray(arr[i])) {
flatten(arr[i])
} else {
res.push(arr[i])
}
}
}
flatten(arr)
- 方式五、toString
function flatten(arr) {
return arr.toString().split(',') // 有缺陷,toString 后无法保持之前的类型
}
- 方式六、rest 运算符
function flatten(arr) {
while (arr.some((item) => Array.isArray(item))) {
// concat方法本身就会把参数中的数组展开,相当于[].concat('1', 2, [3, 4])
arr = [].concat(...arr)
}
return arr
}
24. 算法 -> 全排列?
给定一个字符串,输出该字符串所有排列的可能。如输入“abc”,输出“abc,acb,bca,bac,cab,cba”。
- 思路:定义一个数组存放全排列的情况并返回。判断输入的字符串是否为单个字符串的情况。是,返回其本身。不是,则遍历字符串中的每一个元素,并将字符串中除了该元素的其他元素及你想全排列
function fullPermutation(str) {
var result = []
if (str.length > 1) {
for (let i = 0; i < str.length; i++) {
var left = str[i] // 当前元素
// 除了当前元素的其他元素组合
var rest = str.slice(0, i) + str.slice(i + 1, str.length)
// 上一次递归返回的全排列
var preResult = fullPermutation(rest)
// 结合在一起
for (var j = 0; j < preResult.length; j++) {
var temp = left + preResult[j]
result.push(temp)
}
}
} else if (str.length === 1) {
result.push(str)
}
return result
}
25. 算法 -> 二分查找?
二分搜索法,也称折半搜索,是一种在有序数组中查找特定元素的搜索算法。
- 实现步骤:
- 首先从数组中间开始查找对比,若相等则找到,直接返回中间元素的索引
- 若查找值小于中间值,则在小于中间值的那一部分执行步骤 1 的操作
- 若查找值大于中间值,则在大于中间值的那一部分执行步骤 1 的操作
- 否则,返回结果为查不到,返回-1
- 实现方法:
- 非递归方式,采用 while 方式,判断是否符合要求
/** * @param {*} arr 已排好的数组 * @param {*} key 想要查找的值 */ function binary_search1(arr, key) { var low = 0, high = arr.length - 1 while (low <= high) { // var mid = parseInt((low + high) / 2); // 得到中间的数 var mid = Math.floor((low + high) / 2) // 得到中间的数 if (key === arr[mid]) { return mid } else if (key > arr[mid]) { // 说明在右边 low = mid + 1 } else if (key < arr[mid]) { // 说明在左边 high = mid - 1 } else { return -1 } } }
- 递归方式,采用 if 方式,依次递归,找到相应的值
时间复杂度:O(log2n) => O(logn).优点:比较次数少,查找速度快,平均性能好。缺点:要求待查表为有序表,且插入删除困难。结论:适用于不经常变动而查找频繁的有序列表。function binary_search2(arr, key) { return search(arr, key, 0, arr.length - 1) /** * @param {*} arr 已排好的数组 * @param {*} key 想要查找的值 * @param {*} low 第一个值的索引 * @param {*} high 最后一个值的索引 * @returns */ function search(arr, key, low, high) { if (low > high) { return -1 } var mid = Math.floor((low + high) / 2) if (key === arr[mid]) { return mid } else if (key > arr[mid]) { // 说明在右边 return search(arr, key, mid + 1, high) } else if (key < arr[mid]) { // 说明在左边 return search(arr, key, low, high - 1) } } }
十二、性能优化
1. Vue 项目优化方式有哪些?
使用计算属性 特点:可以被缓存
使用函数式组件 例如:对于某些组件,如果我们只是用来显示一些数据,不需要管理状态,监听数据等,那么就可以用函数式组件。
函数式组件是无状态的,无实例的,在初始化时不需要初始化状态,不需要创建实例,也不需要去处理生命周期等,相比有状态组件,会更加轻量,同时性能也更好。
结合场景使用 v-show 和 v-if 两者的作用都是用来控制某些组件或 DOM 的显示/隐藏
v-if 适用于在运行时很少改变条件,不需要频繁切换条件的场景
v-show 适用于需要非常频繁切换条件的场景
- v-if 的部分被转换成了一个三元表达式,visible 为 true 时,创建组件的 vnode,否则创建一个空 vnode。在 patch 的时候,新旧节点不一样,就会移除旧的节点或创建新的节点,这样的话组件也会跟着创建/销毁。如果组件里有很多 DOM,或者要执行很多初始化/销毁逻辑,那么随着 visible 的切换,势必会浪费掉很多性能
- v-show 被编译成了 directives,它其实是通过切换元素的 display 属性来控制的,和 v-if 相比,不需要在 patch 阶段创建/移除节点,只是根据 v-show 上绑定的值来控制 DOM 元素的 style.display 属性,在频繁切换的场景下就可以节省很多性能
如果初始值是 false 时,v-if 并不会创建隐藏的节点,但是 v-show 会创建,并通过设置 style.display='none'来隐藏,虽然外表看上去这个 DOM 都是被隐藏的,但是 v-show 已经完整的走了一遍创建的流程,造成了性能的浪费 总结:v-if 的优势体现在初始化时,v-show 体现在更新时(初始化性能压力大) 扩展补充:display:none、visibility:hidden 和 opacity:0 之间的区别?
相同点:都是隐藏
不同点:
- 是否占据空间
- display: none, 隐藏之后不占据空间
- visibility: hidden、opacity: 0;隐藏后任然占据空间
- 子元素是否继承
- display: none, 不会被子元素继承,父元素都不存在了,紫云山也不会显示
- visibility: hidden, 会被子元素继承,通过设置子元素,visibility: visible 来显示子元素
- opacity: 0, 会被子元素继承,但是不能设置子元素 opacity 来重新显示
- 事件绑定
- display: none, 元素都不在了,所以无法触发它绑定的事件
- visibility: hidden, 不会触发它上面绑定的事件
- opacity: 0, 元素上面绑定的事件是可以触发的
- 过渡动画
- transition: 对于 display 是无效的
- transition: 对于 visibility 是无效的
- transition: 对于 opacity 是有效的
- 是否占据空间
使用 keep-alive keep-alive 的作用就是将它包裹的组件在第一次渲染后就缓存起来,下次需要时就直接从缓存里面取,避免了不必要的性能浪费
避免 v-for 和 v-if 同时使用 Vue2 中,v-for 的优先级比 v-if 高;Vue3 中,v-if 的优先级比 v-for 高 总结:使用计算属性代替
给 v-for 添加 key, 并且不要将 index 作为 key
延迟渲染 延迟渲染就是分批渲染,假设我们某个页面里有一些组件在初始化时需要执行复杂的逻辑,这将会占用很长时间,导致帧数下降、卡顿,其实可以使用分批渲染的方式来进行优化,就是先渲染一部分,再渲染另一部分
<template> <Heavy v-if="defer(1)" /> </template> <script> export default { data() { return { displayPriority: 0 } }, mounted() { this.runDisplayPriority() }, methods: { runDisplayPriority() { const step = () => { requestAnimationFrame(() => { this.displayPriority++ if (this.displayPriority < 10) { step() } }) } step() }, defer(priority) { return this.displayPriority >= priority } } } </script>
原理:主要是维护 displayPriority 变量,通过 requestAnimationFrame 在每一帧渲染时自增,然后我们就可以在组件上通过 v-if="defer(n)"使 displayPriority 增加到某一值时再渲染,这样就可以避免 js 执行时间过长导致的卡顿问题了
使用非响应式数据 在 Vue 组件初始化数据时,会递归遍历在 data 中定义的每一条数据,通过 Object.defineProperty 将数据改成响应式,这就意味着如果 data 中的数据量很大的话,在初始化时将会使用很长的时间去执行 Object.defineProperty,也就会带来性能问题,这个时候我们可以强制使数据变为非响应式,从而节省时间
解决:Object.freeze() 扩展:为什么 Object.freeze()会有这样的效果呢? 对某一对象使用 Object.freeze()后,将不能向这个对象添加新的属性,不能删除已有属性,不能修改该对象已有属性的可枚举性、可配置性、可写性,以及不能修改已有属性的值
而 Vue 在将数据改造成响应式之前有个判断:
export function observe(value, asRootData) { // ...省略其他逻辑 if ( shouldObserve && !isServerRendering() && (Array.isArray(value) || isPlainObject(value)) && Object.isExtensible(value) && !value._isVue ) { ob = new Observer(value) } // ...省略其他逻辑 }
这个判断条件中有一个 Object.isExtensible(value),这个方法是判断一个对象是否是可扩展的,由于我们使用了 Object.freeze(),这里肯定就返回了 false,所以就跳过了下面数据劫持的过程,没有了收集依赖的过程,自然也就节省了性能。
问题:数据都不是响应式的,可以只对这种数据的某一层使用 Object.freeze(),同时配合使用上文中的延迟渲染、函数式组件等,可以极大提升性能。
Vue2/3 Template Explorer
computed、watch、methods 区分使用场景
- computed: 一个数据受多个数据影响的。
- watch: 一个数据影响多个数据的。当数据变化时,需要执行异步或开销较大的操作时。如果数据变化时请求一个接口。
- methods: 希望数据是实时更新,不需要缓存。
防抖和节流
- 防抖:触发事件后规定时间内事件只会执行一次。简单来说就是防止手抖,短时间操作了好多次。
- 节流:事件在规定时间内只执行一次。
- 应用场景: 节流不管事件有没有触发还是频繁触发,在规定时间内一定会只执行一次事件,而防抖是在规定时间内事件被触发,且是最后一次被触发才执行一次事件。
图片大小优化和懒加载
- 图片大小的优化,可以用 image-webpack-loader 进行压缩图片
- 图片懒加载,可以用 vue-lazyload 插件实现
利用挂载节点会被替换的特性优化白屏问题 我们可以在
<div id="app"></div>
里添加首屏的静态页面。等真正的首屏加载出来后就会把<div id="app"></div>
这块结构都替换掉,给人一种视觉上的误差,就不会产生白屏组件库按需引入 如 element UI 库,用 babel-plugin-component 插件实现按需引入
在根目录下.babelrc.js 文件中按如下配置:
{ "presets": [["es2015", { "modules": false }]], "plugins": [ [ "component", { "libraryName": "element-ui", "styleLibraryName": "theme-chalk" } ] ] }
其中 libraryName 为组件库的名称,styleLibraryName 为组件库打包后样式存放的文件夹名称。
问题:babel-plugin-component 是如何做到按需加载的?
其实 babel-plugin-component 插件是 element 用 babel-plugin-import 插件改造后特定给 element UI 使用。一般的组件库还是 babel-plugin-import 插件实现按需引入。
打包优化:
webpack-bundle-analyzer 可以帮助你可视化的分析打包后的各个资源的大小
利用 import()异步引入组件实现按需引入 路由懒加载,所谓的懒加载就是用 import()异步引入组件
component: () =>import('views/home.vue'), component: resolve =>require(['views/home.vue'],resolve)
resolve 方式打包会把所有组件的代码都打包在一个 js 文件。预期应该是每个组件的代码都被打包成对应的 js 文件,加载组件时会对应加载 js 文件,这才是懒加载。
- 可以使用 webpackChunkName: chunk 文件的名称, [request]表示实际解析的文件名
function load(component) { return () => import(/* webpackChunkName: "[request]" */ `views/${component}`) }
- 使用懒加载后,浏览器的 network 就会出现 Purpose: prefetch,只是预取(prefetch)一下,没有返回内容的。目的是告诉浏览器,空闲的时候给我加载这个 js 文件。直到真正加载这个路由组件时,这个 js 文件再次被加载
扩展:prelink 和 prefetch 的区别 preload、prefetch 去提前加载,还可以使用 DNS Prefetch、Prerender、Preconnect
利用 externals 提取第三方依赖并用 CDN 引入 在 Webpack 中的 externals 配置选项,可避免将第三方依赖打包,而是在项目运行时从外部获取第三方依赖。
利用 SplitChunks 插件提取公共 js 代码和分割 js 代码
压缩图片、HTML、CSS、JS 等静态资源
项目部署的优化
识别 gzip 压缩是否开启 只要看响应头部(Response headers)中 有没有 Content-Encoding: gzip 这个属性即可,有代表有开启 gzip 压缩。
在 Nginx 上开启 gzip 压缩 在 nginx/conf/nginx.conf 中配置
http { // on | off ,默认为off,on为开启gzip,off为关闭gzip gzip on; // number,压缩起点,文件大于多少才进行压缩,单位默认为字节,也可用k表示千字节 gzip_min_length 1k; // 压缩级别,1-9,数字越大,压缩后的大小越小,也越占用CPU,花费时间越长 gzip_comp_level 5; // 需要进行压缩的文件类型。类型去Response headers中看Content-Type属性 gzip_types application/javascript image/png image/gif image/jpeg text/css text/plain; // number size,设置系统获取几个单位的缓存用于存储gzip的压缩结果数据流(例如 4 4k代表以4k为单位,按照原始数据大小以4k为单位的4倍申请内存。如原始数据大小为17K,则申请 (17/4)*4 = 17k内存) gzip_buffers 4 4k; // 设置gzip压缩针对的HTTP协议版本以上 gzip_http_version 1.1; // on | off,是否在http header中添加Vary:Accept-Encoding,on表示添加。Vary:Accept-Encoding告诉代理服务器缓存两种版本的资源:压缩和非压缩,避免一个浏览器不支持压缩资源,而先请求了服务器,服务器缓存了非压缩的资源,然后一个浏览器支持压缩资源,再去请求了服务器,结果得到非压缩资源,但是又去解压它,结果会出错。所以建议设置为on gzip_vary on; }
在 Webpack 上开启 gzip 压缩 利用 CompressionWebpack 插件来实现 gzip 压缩
- CompressionWebpack 参数详解:
- test:String|RegExp|Array<String|RegExp>,资源的名称符合条件的才会被压缩,默认为 undefined,即全部符合,例如只要压缩 js 文件
plugins: [ new CompressionPlugin({ test: /\.js(\?.*)?$/i, }) ],
- include:String|RegExp|Array<String|RegExp>,资源的名称符合条件的才会被压缩,默认为 undefined,是在 test 参数的范围内在进行筛选,满足 test 参数的条件,且满足 include 参数的条件的资源才会被压缩
- exclude:String|RegExp|Array<String|RegExp>,压缩时排除资源的名称符合条件的资源,默认为 undefined,是在 test 参数的范围内在进行排除,满足 test 参数的条件,不满足 exclude 参数的条件的资源才会被压缩
- algorithm:压缩算法/功能,默认 gzip,一般不做更改
- compressionOptions,对 algorithm 参数所选用的压缩功能的参数设置,一般用来设置压缩级别,1-9,数字越大,压缩后的大小越小,也越占用 CPU,花费时间也越长
plugins: [ new CompressionPlugin({ compressionOptions: { level: 1 }, }) ],
- threshold:Number,设置被压缩资源的最小大小,单位为字节。默认为 0
- minRatio:Number,设置压缩比率,压缩比率 = 压缩后的资源的大小/压缩后的资源,小于压缩比率的资源才会被压缩。和 threshold 参数是‘与’的关系
- filename:类型:String|Function,设置压缩资源后的名称,默认值:[path].gz[query],[file]被替换为原始资产文件名。 [path]替换为原始资产的路径。 [dir]替换为原始资产的目录。 [name]被替换为原始资产的文件名。 [ext]替换为原始资产的扩展名。 [query]被查询替换
new CompressionPlugin({ filename(info) { console.log(info) return `${info.path}.gz${info.query}` }, })
- CompressionWebpack 参数详解:
Nginx 和 Webpack 压缩的区别
- 不管 Nginx 还是 Webpack 压缩,在 Nginx 中都要开启 gzip 压缩,不然浏览器加载还是未压缩的资源。 还可以在 Nginx 加上 gzip_static on;的配置。gzip_static 启用后, 浏览器请求资源时,Nginx 会先检查是否存该资源名称且后缀为.gz 的文件,如果有则直接返回该 gz 文件内容,可以避免 Nginx 对该资源再进行 gzip 压缩,浪费服务器的 CPU。
- 用 Nginx 压缩会占用服务器的 CPU,浏览器每次请求资源,Nginx 会对该资源实时压缩,压缩完毕后才会返回该资源,如果资源很大的话,还是压缩级别设置很高,都会导致返回资源的时间过长,造成不好的用户体验。
- 用 Webpack 会使打包时间变长。但是用 CompressionPlugin 插件压缩,会有缓存,可以相对减少打包时间。
- 建议 Nginx 和 Webpack 压缩都开启压缩,且在 Nginx 加上 gzip_static on;的配置,减少服务器的 CPU 的使用,当然还是要根据项目的情况实际选择。
- nginx 配置 vue 项目缓存
- vue 的所有资源修改后打包出来的名称都会改变,所以可以使用强缓存,对 css、js、png、ttf、jpg 等
location ~* \.(css|js|png|jpg|jpeg|gif|gz|svg|mp4|ogg|ogv|webm|htc|xml|woff)$ { access_log off; add_header Cache-Control max-age=604800; }
- html 文件因为名称不会改变,所以使用协商缓存,html 文件有改动就会立即更新,max-age=no-cache 代表进入协商缓存,文件改动会自动更新,不改动会返回 304
location ~* \.(html)$ { access_log off; add_header Cache-Control max-age=no-cache; }
2. 常见的优化手段?
十三、项目重难点
1. 前端性能监控?
前端监控:它指的是通过一定的手段来获取用户行为以及跟踪产品在用户端的使用情况,并以监控数据为基础,为产品优化指明方向,为用户提供更加精确、完善的服务。
前端监控一般分为三大类:
- 数据监控(监控用户行为)
- PV/UV:PV 即页面浏览器或点击率;UV 即访问某个站点或点击某条新闻的不同 IP 地址的人数
- 用户在每一个页面的停留时间
- 用户通过什么入口来访问该网页
- 用户在相应的页面中触发的行为...等
- 性能监控(监控页面性能)
- 不同用户,不同机型和不同系统下的首屏加载时间
- 白屏时间
- http 等请求的响应时间
- 静态资源整体下载时间
- 页面渲染时间...等
- 异常监控(监控产品、系统异常)
- JavaScript 的异常监控
- 样式丢失的异常监控...等
2. 前端性能埋点?
埋点的三种方法:
- 手动埋点(代码埋点):在需要埋点的业务逻辑功能位置调用接口,上报埋点数据(像友盟、百度统计、神策) 缺点:项目工程量大,需要埋点的位置太多
- 可视化埋点(通过可视化交互的手段,代替上述的代码埋点。将业务代码和埋点代码分离) 缺点:埋点的控件有限,不能手动定制
- 无埋点(前端自动采集全部事件,上报埋点数据,由后端来过滤和计算出有用的数据) 优点:前端只要一次加载埋点脚本;缺点:流量和采集的数据过于庞大,服务器性能压力大
埋点上报的方式:
- 图片(优先考虑 GIF) 优点:防止跨域、防止阻塞页面加载,影响用户体验、相比 PNG/JPG,GIF 的体积最小
大多采用的是 1*1 像素的透明 GIF 来上报
- Beacon 用于将数据异步发送到服务器。navigator.sendBeacon(url, data);
十四、开放性相关
1. JSBridge 通信?
主要是给 JavaScript 提供调用 Native 功能的接口,让混合开发中的前端部分可以方便地使用 Native 的功能(例如:地址位置、摄像头)。是 Native 和非 Native 之间的桥梁,它的核心是构建 Native 和非 Native 间消息通信的通道,而且这个通信的通道是双向的。
JSBridge 的通信原理
JavaScript 调用 Native 的方式 主要有两种:
注入API
和拦截URL SCHEME
- 注入 API 方式的主要原理是,通过 WebView 提供的接口,向 JavaScript 的 Context(window)中注入对象或者方法,让 JavaScript 调用时,直接执行相应的 Native 代码逻辑,达到 JavaScript 调用 Native 的目的
- 拦截 URL SCHEME 的主要流程是:Web 端通过某种方式(例如 iframe.src)发送 URL Scheme 请求,之后 Native 拦截到请求并根据 URL SCHEME(包括所带的参数)进行相关操作 在时间过程中,这种方式有一定的缺陷:1. 使用 iframe.src 发送 URL SCHEME 会有 url 长度的隐患。2. 创建请求,需要一定的耗时,比注入 API 的方式调用同样的功能,耗时会较长
因此:JavaScript 调用 Native 推荐使用注入 API 的方式
Native 调用 JavaScript 的方式 相比于 JavaScript 调用 Native, Native 调用 JavaScript 较为简单,直接执行拼接好的 JavaScript 代码即可。从外部调用 JavaScript 中的方法,因此 JavaScript 的方法必须在全局的 window 上。
扩展:RN 与 webView 的通信
- webview 中点击事件代码 通过 postMessage()方法,传递两个参数。一个是 type 值,是我们做判断时候用的。判断是哪种类型的消息。另一个是我们想传递的其他参数。这里记得参数传递前做一次:JSON.stringify()方法,解析 json 字符串
- RN 中获取传过来的参数 Webview 组件的 onMessage 方法中接受:JSON.parse(event.nativeEvent.data)
2. 小程序 与 Vue 有什么区别?
生命周期:相比之下,小程序的钩子函数要简单得多。vue 的钩子函数在跳转新页面时,钩子函数都会触发,但是小程序的钩子函数,页面不同的跳转方式,触发的钩子并不一样
- onLoad:页面加载 一个页面只会调用一次,可以在 onLoad 中获取打开当前页面所调用的 query 参数
- onShow: 页面显示 每次打开页面都会调用一次
- onReady: 页面初次渲染完成 一个页面只会调用一次,代表页面已经准备妥当,可以和视图层进行交互。对界面的设置如 wx.setNavigationBarTitle 请在 onReady 之后设置
- onHide: 页面隐藏 当 navigateTo 或底部 tab 切换时调用
- onUnload: 页面卸载 当 redirectTo 或 navigateBack 的时候调用
数据请求 在页面加载请求数据时,两者钩子的使用有些类似,vue 一般会在 created 或者 mounted 中请求数据,而在小程序,会在 onLoad 或者 onShow 中请求数据
数据绑定
- Vue: vue 动态绑定一个变量的值为元素的某个属性的时候,会在变量前面加上冒号:
<img :src="imgSrc" />
- 小程序: 绑定某个变量的值为元素属性时,会用两个大括号括起来,如果不加括号,为被认为是字符串
<image src="{{imgSrc}}"></image>
列表渲染
- Vue:
<li v-for="item in items"></li>
- 小程序:
<text wx:for=""></text>
- Vue:
显示与隐藏元素
- Vue: 使用 v-if 和 v-show 控制元素的显示和隐藏
- 小程序: 使用 wx-if 和 hidden 控制元素的显示和隐藏
事件处理
- Vue: 使用 v-on:event 绑定事件,或者使用@event 绑定事件
<button v-on:click="counter += 1">Add 1</button> <button @click="counter += 1">Add 1</button>
- 小程序: 全用 bindtap(bind+event),或者 catchtap(catch+event)绑定事件
<button bindtap="noWork">明天不上班</button> <button catchtap="noWork">明天不上班</button>
数据双向绑定
- Vue: 表单元素上加 v-model
- 小程序: 通过监听输入框事件 bindinput,调用 setData({})方法进行模拟
取值
- Vue: 通过 this.xxx 取值
- 小程序: 通过 this.data.xxx 取值
绑定事件传参
- Vue: 绑定事件传参挺简单,只需要在触发事件的方法中,把需要传递的数据作为形参传入就可以了
<button @click="say('明天不上班')"></button>
- 小程序: 不能直接在绑定事件的方法中传入参数,需要将参数作为属性值,绑定到元素上的 data-属性上,然后在方法中,通过 e.currentTarget.dataset.*的方式获取
<view class="tr" bindtap="toApprove" data-id="{{item.id}}"></view> toApprove(e) { let id = e.currentTarget.dataset.id; }
小程序实现原理解析
小程序目录结构 一个完整的小程序主要由以下几部分组成:
- 一个入口文件:app.js
- 一个全局样式:app.wxss
- 一个全局配置:app.json
页面:pages 下,每个页面再按文件夹划分,每个页面 4 个文件
- 视图: wxml, wxss
- 逻辑: js, json(页面配置, 不是必须)
小程序架构 微信小程序的框架包含两部分:
View视图层
、App Service逻辑层
. View 层用来渲染页面结构,AppService 层用来逻辑处理、数据请求、接口调用,它们在两个进程(两个 Webview)里运行视图层和逻辑层通过系统层的 JSBridge 进行通信,逻辑层把数据变化通知到视图层,触发视图层页面更新,视图层把触发的事件通知到逻辑层进行业务处理
扩展:小程序不允许打开超过 5 个层级的页面? 小程序的视图和逻辑处理是用多个 webview 实现的,逻辑处理的 JS 代码全部加载到一个 Webview 里面,称之为 AppService,整个小程序只有一个,并且整个生命周期常驻内存。而所有的视图(wxml 和 wxss)都是单独的 Webview 来承载,称之为 AppView。所以一个小程序打开至少就会有 2 个 webview 进程,正式因为每个视图都是一个独立的 webview 进程,考虑到性能消耗
原理上,微信 App 里包含 javascript 运行引擎、WXML/WXSS 处理引擎,最终会把界面翻译成系统原生的界面,并展示出来。这样做的目的是为了提供和原生 App 性能相当的用户体验。
- 在 ios 中,小程序的 javascript 运行在 javascriptCore 中
- 在 Android 中,小程序的 javascript 是通过 X5 内核来解析的
小程序的优劣势
- 优势:
- 无需下载,通过搜索和扫一扫就可以打开
- 良好的用户体验:打开速度快
- 开发成本要比 App 要低
- 安卓上可以添加到桌面,与原生 App 差不多
- 为用户提供良好的安全保障。小程序的发布,微信拥有一套严格的审查流程, 不能通过审查的小程序是无法发布到线上的
- 劣势:
- 限制较多。页面大小不能超过 1M。不能打开超过 5 个层级的页面
- 样式单一。小程序的部分组件已经是成型的了,样式不可以修改。例如:幻灯片、导航
- 推广面窄,不能分享朋友圈,只能通过分享给朋友,附近小程序推广。其中附近小程序也受到微信的限制
- 依托于微信,无法开发后台管理功能
3. 团队的开发流程?
4. 如何防止首页白屏?
先说下 Spa 单页面的加载过程: 首先就是 html,也就是 FP 阶段;然后是静态资源 css,js,之后解析 js,生成 HTML,也就是 FCP 阶段,css,js 资源加载下来了,首次的内容绘制,有一个大结构了;到最后,就是 FMP,ajax 请求数据之后,首次有效绘制,就是页面加载差不多了,但是可能图片还没加载出来
总结: 从 FP 到 FMP 这个过程全是白屏,可能你的 header 如果有啥大背景色啊,这个背景色或许会出来,ajax 之后,才会真正去解析我们的数据,把数据放入我们的 html 标签中
- 解决办法:
- 预渲染
- SSR
- 路由懒加载
- 使用 Gzip 压缩
- webpack entry 多页应用
- 骨架屏(原理)
- loading 在首页 index.html 里加一个 loading css 效果,当页面加载完成消失
5. 怎么理解前端工程化、模块化、组件化?
- 工程化:是一种思想而不是某种技术,而模块化和组件化是为工程化思想下相对较具体的开发方式。可以简单的认为模块化和组件化是工程化的表现形式
- 模块化开发:一个模块就是一个实现特定功能的文件,有了模块我们就可以更方便的使用别人的代码,要用什么功能就加载什么模块
- 模块化开发的好处:
- 提高代码复用率和维护性
- 避免变量污染、命名冲突
- 模块化开发的好处:
- 组件化开发:就是将一个页面拆分成很多的小组件
6. 微前端中的渲染、JS 沙箱、样式隔离、数据通信?
7. 如何实现扫码登录功能?
访问 PC 端二维码生成页面,PC 端请求服务端获取
二维码ID
服务端生成相应的
二维码ID
,设置二维码的过期时间,状态等。PC 获取
二维码ID
,生成相应的二维码。手机端扫描二维码,获取
二维码ID
。手机端将
手机端token
和二维码ID
发送给服务端,确认登录。服务端校验
手机端token
,根据手机端token
和二维码ID
生成 PC 端token
PC 端通过
轮询方式
请求服务端,通过二维码 ID 获取二维码状态,如果已成功,返回 PC token,登录成功。扩展:轮询方式有哪些,以及它们各自的优缺点?
轮询 基本思路就是浏览器每隔一段时间向浏览器发送 http 请求,服务器端在收到请求后,不论是否有数据更新,都直接进行响应。这种方式实现的即时通信,本质上还是浏览器发送请求,服务器接受请求的一个过程,通过让客户端不断的进行请求,使得客户端能够模拟实时地收到服务器端的数据的变化。 优点: 是比较简单,易于理解,实现起来也没有什么技术难点。 缺点: 由于需要不断的建立 http 连接,严重浪费了服务器端和客户端的资源. 人数越多,服务器端压力越大,这是很不合理的。
长轮询 服务器收到客户端发来的请求后,服务器端不会直接进行响应,而是先将这个请求挂起,然后判断服务器端数据是否有更新。如果有更新,则进行响应,如果一直没有数据,则到达一定的时间限制(服务器端设置)才返回。客户端 JavaScript 响应处理函数会在处理完服务器返回的信息后,再次发出请求,重新建立连接。 优点: 减少了很多不必要的 http 请求次数,相比之下节约了资源 缺点: 接挂起也会导致资源的浪费
轮询与长轮询都是基于 HTTP 的
长连接(SSE) SSE 是 HTML5 新增的功能,全称为 Server-Sent Events。它可以允许服务推送数据到客户端。SSE 在本质上就与之前的长轮询、短轮询不同,虽然都是基于 http 协议的,但是轮询需要客户端先发送请求。而 SSE 最大的特点就是不需要客户端发送请求,可以实现只要服务器端数据有更新,就可以马上发送到客户端。 优点: 它不需要建立或保持大量的客户端发往服务器端的请求,节约了很多资源,提升应用性能
WebSocket WebSocket 是 Html5 定义的一个新协议,与传统的 http 协议不同,该协议可以实现服务器与客户端之间全双工通信
8. 如何防止重复发送请求?
function firstPromise(promiseFunction) {
let p = null
return function(...args) {
// 请求的实例,已存在意味着正在请求中,直接返回实例,不触发新的请求
return p
? p
: // 否则发送请求,且在finally时将p置空,那么下一次请求可以重新发起
(p = promiseFunction.apply(this, args).finally(() => (p = null)))
}
}
扩展:前端做后台管控系统,在某些接口请求时间过长的场景下,需要防止用户反复发起请求,实现方式也有好几种:
- 在按钮点击发起请求后,弹个蒙层,显示个 loading,等请求数据返回了将蒙层隐藏掉
- 在按钮点击发起请求后,将按钮禁用掉,同样等数据返回了将按钮禁用解除
以上两种方案优点仅仅是简单,但是每个需要处理的页面都要单独写一串重复的代码,哪怕利用 mixin 也要多不少冗余代码。利用指令的方式仅仅需要在合适的地方加上个一条 v-xxxx,其他都在指令的逻辑内统一处理。
let forbidClick = null
export default {
bind(e) {
const el = e
let timer = null
forbidClick = () => {
el.disabled = true
el.classList.add('is-disabled')
timer = setInterval(() => {
if (window.currentRes.done) {
clearInterval(timer)
el.disabled = false
el.classList.remove('is-disabled')
}
}, 500)
}
el.addEventListener('click', forbidClick)
},
unbind() {
document.removeEventListener('click', forbidClick)
},
}
再考虑请求,记录当前请求是否完成。请求拦截器中,接口请求时长超过3s,则视为完成,不管请求结果成功或失败
这样就实现了只要在按钮上加上了 v-clickForbidden。按钮点击后就会被禁用,仅当某个请求返回数据或者 3s 后将按钮的禁用解除