HTML / CSS
1. HTML 语义化
HTML 语义化就是用合适的标签表达内容含义,比如导航用 nav,主体用 main,文章用 article,而不是全部用 div。它的价值主要体现在 SEO、可访问性 和代码可维护性上,因为搜索引擎、读屏器和开发者都能更容易理解页面结构。
2. DOCTYPE 与标准模式
<!DOCTYPE html> 用来告诉浏览器按 HTML5 标准解析页面。如果不写或写错,浏览器可能进入怪异模式,导致盒模型、布局计算等行为和标准模式不一致,页面在不同浏览器中更容易出现兼容问题。
3. meta viewport
移动端常见的 meta viewport 是为了控制布局视口宽度,典型写法是 width=device-width, initial-scale=1.0。如果不设置,移动端浏览器可能按桌面宽度渲染页面,再整体缩小,导致文字和布局异常。
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
4. script 的 async 与 defer
普通 script 会阻塞 HTML 解析;async 是脚本下载完就立即执行,执行顺序不稳定;defer 是脚本下载不阻塞解析,并在 DOM 解析完成后按顺序执行。业务脚本通常更适合用 defer,第三方统计这类独立脚本更适合用 async。
5. preload / prefetch
preload 是提前加载当前页面马上要用的关键资源,比如首屏字体、首屏大图、关键 JS;prefetch 是浏览器空闲时预取未来可能用到的资源,比如下一页路由包。二者不要滥用,否则会抢占关键资源带宽,反而影响首屏。
6. 盒模型
CSS 盒模型分为 content、padding、border、margin。标准盒模型中 width 只表示 content 宽度,而 border-box 会把 padding 和 border 也算进 width,项目里常统一设置为 border-box 来降低布局计算成本。
* {
box-sizing: border-box;
}
7. BFC
BFC 是块级格式化上下文,可以理解为一块独立的布局区域,内部元素布局不会影响外部。常用它解决 margin 折叠、清除浮动、防止文字环绕浮动元素等问题,触发方式包括 overflow: hidden、display: flow-root、position: absolute、display: flex。
8. 层叠上下文与 z-index
层叠上下文决定元素在 z 轴上的绘制层级,position + z-index、transform、opacity < 1 等都会创建新的层叠上下文。很多时候 z-index 不生效,不是数值不够大,而是它被限制在父级层叠上下文内部。
9. 选择器优先级
CSS 优先级大致是:内联样式 > ID 选择器 > class / 属性 / 伪类 > 标签 / 伪元素。实际项目中应避免层级过深和滥用 !important,否则样式覆盖关系会变得很难维护。
内联样式 最高
ID 选择器 #app
类/属性/伪类 .box type="text"
10. Flex 布局
Flex 是一维布局方案,适合处理一行或一列内的排列、对齐和剩余空间分配。主轴用 justify-content 控制,交叉轴用 align-items 控制,flex: 1 常用于让元素占满剩余空间。
.row {
display: flex;
align-items: center;
justify-content: space-between;
}
11. Grid 布局
Grid 是二维布局方案,适合同时控制行和列,比如后台看板、图片墙、复杂表单。简单横向排列用 Flex 更轻,复杂区域划分用 Grid 更直观。
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
12. 水平垂直居中
水平垂直居中的核心是:让子元素在父容器的主轴和交叉轴上都处于中间位置。实际开发中最常用的是 Flex 和 Grid,因为它们不依赖子元素固定宽高,也不需要手动计算偏移量。绝对定位加 transform 也很常见,适合弹窗、浮层、提示框这类脱离文档流的元素。
Flex 居中适合一维布局,比如按钮内容、空状态、登录框、卡片内容居中。父元素设置为弹性容器后,justify-content: center 控制主轴居中,align-items: center 控制交叉轴居中。
.parent {
display: flex;
justify-content: center;
align-items: center;
}
Grid 居中写法更简洁,适合只有一个核心子元素需要居中的场景。place-items: center 是 align-items: center 和 justify-items: center 的简写。
.center {
display: grid;
place-items: center;
}
绝对定位 + transform 适合需要脱离普通文档流的元素。top: 50% 和 left: 50% 是把元素左上角移动到父容器中心,transform: translate(-50%, -50%) 再按元素自身宽高往回移动一半,从而实现真正居中。
.parent {
position: relative;
}
.child {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
如果元素宽高固定,也可以用绝对定位四边为 0 配合 margin: auto,但它依赖明确的尺寸,灵活性不如 transform。
.child {
position: absolute;
inset: 0;
width: 200px;
height: 120px;
margin: auto;
}
面试回答时可以按这个顺序说:优先使用 Flex 或 Grid;如果元素是弹窗、浮层等脱离文档流的场景,使用绝对定位加 transform;如果宽高固定,也可以用 inset: 0 加 margin: auto。其中 Flex/Grid 更现代、更通用,绝对定位方案更适合特殊定位场景。
13. 响应式布局
响应式布局的核心是让页面在不同屏幕宽度下保持可读、可用,常用手段包括媒体查询、弹性布局、百分比、rem、vw、clamp()。现代项目更推荐用布局容器、断点和弹性单位组合,而不是只按设计稿等比缩放。
面试里可以先从目标说起:响应式不是简单把页面“缩小”,而是让内容、布局、字号、间距和交互区域根据设备宽度重新组织。比如桌面端三栏展示,平板端两栏,手机端一栏;导航从横向菜单变成折叠菜单;图片和表格需要避免撑破容器。
常见实现方式可以分为四类:
- 弹性布局:使用 Flex 或 Grid,让容器自动分配空间,减少固定宽度。
- 媒体查询:在关键断点切换布局,比如手机、平板、桌面。
- 弹性单位:宽度优先用
%、fr、minmax(),字号和间距可以结合rem、vw、clamp()。 - 资源适配:图片使用
max-width: 100%、picture、srcset,避免小屏加载过大的图片。
.page {
width: min(100% - 32px, 1200px);
margin: 0 auto;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
媒体查询适合处理“结构变化”,比如列数、侧边栏、导航形态,而不是每个尺寸都写一套样式。实际项目中常用移动端优先:基础样式先照顾小屏,再用 min-width 逐步增强大屏布局。
.layout {
display: grid;
gap: 16px;
}
@media (min-width: 768px) {
.layout {
grid-template-columns: 240px 1fr;
}
}
@media (min-width: 1024px) {
.layout {
grid-template-columns: 280px 1fr 320px;
}
}
rem、vw 和媒体查询可以理解成三个工具,各自解决的问题不一样。
rem 适合做统一尺寸控制,比如字号、间距、按钮高度、卡片内边距。rem 依赖根元素 html 的 font-size,所以只要根字号确定,页面里的 1rem、2rem 都会按同一套基准计算。
html {
font-size: 16px;
}
.card {
padding: 1rem; /* 16px */
font-size: 1rem; /* 16px */
}
vw 适合做跟屏幕宽度强相关的尺寸,1vw 等于视口宽度的 1%,屏幕越宽,计算出来的值越大。它适合标题、横幅、某些需要随屏幕流式变化的尺寸,但纯 vw 容易在小屏上太小、大屏上太大。
.title {
font-size: 4vw;
}
媒体查询适合做布局结构切换,比如手机端一列、平板两列、桌面三列。这类变化不是简单缩放,而是布局结构变了,所以更适合用媒体查询。
.list {
display: grid;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.list {
grid-template-columns: repeat(3, 1fr);
}
}
clamp() 可以给流式尺寸设置上下限,常用来弥补纯 vw 失控的问题。它的三个参数分别是最小值、理想值、最大值。
.title {
font-size: clamp(20px, 4vw, 40px);
}
上面这句的意思是:字号最小不低于 20px,中间尽量按 4vw 跟随屏幕变化,最大不超过 40px。所以面试时可以总结为:rem 管统一尺寸,vw 管随屏幕流式变化,媒体查询管布局结构切换,clamp() 给变化范围兜底。
响应式布局还要注意几个常见坑:不要大量写死 width: 375px、height: 100vh;长文本、表格、图片要允许换行或滚动;按钮点击区域在移动端不能太小;断点应该根据内容何时放不下决定,而不是机械照搬设备型号。面试总结时可以说:我会先用流式布局保证基础伸缩,再在关键断点调整结构,最后用弹性单位和图片适配补齐细节。
14. 回流与重绘
回流是布局发生变化,比如宽高、位置、字体大小改变;重绘是视觉样式变化,比如颜色、背景、阴影改变。回流成本通常更高,优化时要减少频繁读写布局,动画尽量使用 transform 和 opacity。
做动画时要尽量少改会影响布局的属性,比如 width、height、top、right、bottom、left、margin、padding、border-width、font-size、line-height。这些属性会改变元素尺寸或位置,容易触发回流。
/* 不推荐:会影响布局 */
.box:hover {
width: 300px;
left: 100px;
}
更推荐把位置和缩放交给 transform,把显隐过渡交给 opacity。
/* 推荐:更适合动画 */
.box:hover {
transform: translateX(100px) scale(1.2);
opacity: 0.8;
}
另外,box-shadow、background-color、filter、border-radius 这类属性通常不会像宽高位置那样触发布局,但可能造成较重的重绘。尤其是大面积阴影、模糊滤镜、多个元素同时变化时,也要谨慎使用。面试时可以总结为:动画优先用 transform 和 opacity;少改宽高、位置、间距、字体这类布局属性;大面积阴影、模糊和背景变化也要注意重绘成本。
15. CSS 动画性能
transition 适合简单状态切换,animation + keyframes 适合复杂连续动画。性能上应优先改变 transform、opacity,因为它们通常可以走合成线程,避免频繁触发布局和绘制。
JavaScript
1. JS 数据类型
JavaScript 数据类型可以先分成两大类:原始类型和引用类型。原始类型保存的是值本身,引用类型保存的是对象的引用地址。
原始类型有 7 种:string、number、boolean、undefined、null、symbol、bigint。可以用口诀记:字数布,空未符大。
字:string 字符串 数:number 数字 布:boolean 布尔值 空:null 空值 未:undefined 未定义 符:symbol 唯一符号 大:bigint 大整数
引用类型主要是对象,包括普通对象、数组、函数、日期、正则等。它们赋值时复制的是引用地址,所以两个变量可能指向同一个对象。
const a = { count: 1 };
const b = a;
b.count = 2;
console.log(a.count); // 2
SNB U Null SB
面试时可以这样说:原始类型按值存储,引用类型按引用访问,所以引用类型修改内部属性时,其他指向同一对象的变量也能看到变化。
2. 类型判断
JS 里常见的类型判断方法有 typeof、instanceof、Array.isArray() 和 Object.prototype.toString.call()。面试时可以按“先判断基础类型,再判断复杂对象”的思路回答。
typeof 适合判断原始类型,比如 string、number、boolean、undefined、symbol、bigint,也可以判断函数。它的优点是写法简单,缺点是判断对象时不够细。
typeof "hello"; // "string"
typeof 123; // "number"
typeof true; // "boolean"
typeof undefined; // "undefined"
typeof Symbol("id"); // "symbol"
typeof 10n; // "bigint"
typeof (() => {}); // "function"
需要注意两个坑:typeof null 的结果是 "object",这是历史遗留问题;数组、普通对象、日期对象用 typeof 判断出来也都是 "object"。
typeof null; // "object"
typeof []; // "object"
typeof {}; // "object"
typeof new Date(); // "object"
Array.isArray() 专门判断数组,比 typeof 和 instanceof 更直接。
Array.isArray([]); // true
Array.isArray({}); // false
instanceof 用来判断对象是否在某个构造函数的原型链上。它适合判断某个对象是不是由某个类或构造函数创建出来的,比如 date instanceof Date。但它不适合判断原始类型,而且在 iframe、跨窗口场景下可能不稳定。
const date = new Date();
date instanceof Date; // true
[] instanceof Array; // true
{} instanceof Object; // true
"hello" instanceof String; // false
Object.prototype.toString.call() 更适合做精确类型判断,它可以区分数组、对象、日期、正则、null、undefined 等。
const getType = (value: unknown) =>
Object.prototype.toString.call(value).slice(8, -1).toLowerCase();
getType("hello"); // "string"
getType([]); // "array"
getType({}); // "object"
getType(new Date()); // "date"
getType(/abc/); // "regexp"
getType(null); // "null"
getType(undefined); // "undefined"
所以实际使用时可以这样记:普通原始类型用 typeof;数组用 Array.isArray();判断实例关系用 instanceof;如果要写一个通用的精确类型判断函数,就用 Object.prototype.toString.call()。
3. == 与 ===
== 和 === 的核心区别是:== 会先做隐式类型转换,=== 不会转换类型。
1 == "1"; // true 1 === "1"; // false
工程里更推荐 ===,因为它的判断规则更直接,不容易出现奇怪结果。== 的转换规则很多,比如字符串和数字比较时会尝试把字符串转成数字,null == undefined 是 true,但它们和其他值比较又不相等。
null == undefined; // true null === undefined; // false "" == 0; // true false == 0; // true
Object.is 和 === 大部分时候类似,但它能正确判断 NaN,也能区分 +0 和 -0。
NaN === NaN; // false Object.is(NaN, NaN); // true +0 === -0; // true Object.is(+0, -0); // false
面试总结:业务代码优先用 ===;需要处理 NaN 或区分正负零时,可以考虑 Object.is。
4. var / let / const
var、let、const 的区别主要看三个点:作用域、变量提升、能不能重新赋值。
var 是函数作用域,会变量提升,所以在声明前访问不会报错,而是得到 undefined。let 和 const 是块级作用域,只在 {} 内有效,并且存在暂时性死区,声明前访问会报错。
console.log(a); // undefined var a = 1; console.log(b); // ReferenceError let b = 2;
let 可以重新赋值,const 不能重新绑定。但 const 限制的是变量和地址的绑定,不代表对象内部属性不能改。
const user = { name: "A" };
user.name = "B"; // 可以
// user = { name: "C" }; // 不可以
面试总结:优先用 const,需要重新赋值再用 let,尽量不用 var。
5. 作用域与作用域链
作用域就是变量能被访问的范围。JS 使用的是词法作用域,也就是函数写在哪里,它能访问哪些外部变量就基本确定了,不是看函数在哪里被调用。
查找变量时,会先找当前作用域;当前找不到,就去外层作用域找;一直找到全局作用域。这条从内到外的查找路径就是作用域链。
const name = "global";
function outer() {
const name = "outer";
function inner() {
console.log(name);
}
inner();
}
outer(); // "outer"
上面 inner 里面没有 name,就向外找到 outer 里的 name。面试时可以一句话总结:作用域决定变量可见范围,作用域链决定变量查找顺序。
6. 闭包
闭包可以先理解成:函数“记住了”它定义时能访问的外部变量。即使外层函数已经执行结束,只要内部函数还被外部引用,它仍然能继续访问那些变量。
const createCounter = () => {
let count = 0;
return () => ++count;
};
const next = createCounter();
next(); // 1
next(); // 2
上面 createCounter 执行完以后,按理说 count 应该结束了。但返回的函数还在使用 count,所以这个变量没有被释放,这就是闭包。
闭包常见用途有三个:
- 保存私有变量,比如计数器。
- 缓存中间结果,比如记忆化函数。
- 做函数柯里化或延迟执行。
闭包的风险是:如果闭包长期引用大对象、DOM 节点、定时器数据,这些内容可能无法被垃圾回收,造成内存泄漏。面试总结:闭包的本质是函数保留了外层作用域的变量引用。
7. 原型与原型链
JS 里对象可以通过原型共享属性和方法。每个对象都有一个内部原型 [[Prototype]],平时常见的 __proto__ 可以理解成访问它的方式。
属性查找规则是:先找对象自身,找不到再沿着原型继续往上找,这条链就是原型链。
function Person(name) {
this.name = name; // 私人财产
}
// 在公共仓库(原型)中放入一个方法
Person.prototype.sayHello = function() {
console.log("你好,我是 " + this.name);
};
const user1 = new Person("张三");
const user2 = new Person("李四");
// user1 和 user2 都可以使用公共仓库里的方法
user1.sayHello(); // "你好,我是 张三"
user2.sayHello(); // "你好,我是 李四"
在这里,Person.prototype 就是 user1 和 user2 的原型。这样做的好处是:sayHello 方法在内存中只有一份,所有的实例共享它,极大地节省了内存。
当你试图访问一个对象的某个属性或方法时,JavaScript 引擎会执行一套“甩锅机制”,这条甩锅的路径就是原型链。
查找规则如下:
- 先找自己: 引擎首先在对象本身的属性中查找。如果找到了,就直接用。
- 问上级(原型): 如果自己身上没有,引擎就会通过隐藏的
__proto__属性,顺藤摸瓜去该对象的原型对象(公共仓库)里找。 - 继续向上: 如果原型对象里也没有,就会继续去原型的原型里找。
- 终点: 这样一层一层找下去,直到找到最顶层的
Object.prototype。如果连Object.prototype的原型(即null)都没有,就会返回undefined(或者报错说这不是一个函数)。
面试总结:原型用于对象之间共享能力,原型链用于属性查找;构造函数的 prototype 会成为实例的原型。
8. this 指向
this 不看函数在哪里定义,主要看函数怎么被调用。可以按四条规则记:
- 普通函数直接调用:严格模式下是
undefined,非严格模式下通常指向全局对象。 - 对象方法调用:谁点出来调用,
this就指向谁。 call、apply、bind:显式指定this。new调用:this指向新创建的对象。
const user = {
name: "Tom",
say() {
return this.name;
},
};
user.say(); // "Tom"
箭头函数没有自己的 this,它会捕获外层作用域的 this,所以不能用箭头函数当需要动态 this 的对象方法。
面试总结:普通函数的 this 看调用方式,箭头函数的 this 看定义时外层作用域。
9. call / apply / bind
call、apply、bind 都是用来改变函数执行时的 this,区别在于参数形式和是否立即执行。
function say(this: { name: string }, prefix: string) {
return `${prefix}${this.name}`;
}
say.call({ name: "Tom" }, "Hi "); // 立即执行,参数一个个传
say.apply({ name: "Tom" }, ["Hi "]); // 立即执行,参数用数组传
const boundSay = say.bind({ name: "Tom" }, "Hi ");
boundSay(); // bind 不立即执行,返回新函数
简单记:call 是参数列表,apply 是参数数组,bind 是先绑定、后执行。
function myBind<T extends (...args: any[]) => any>(fn: T, ctx: unknown) {
return (...args: Parameters<T>): ReturnType<T> => fn.apply(ctx, args);
}
10. new 做了什么
new 的作用是根据构造函数创建实例对象。内部可以拆成四步:
- 创建一个新对象。
- 把新对象的原型指向构造函数的
prototype。 - 用新对象作为
this执行构造函数。 - 如果构造函数返回对象,就返回这个对象;否则返回新创建的对象。
function myNew<T extends object>(Ctor: new (...args: any[]) => T, ...args: any[]) {
const obj = Object.create(Ctor.prototype);
const result = Ctor.apply(obj, args);
return result && typeof result === "object" ? result : obj;
}
面试时不用死背实现细节,重点说清:new 会建立实例和原型之间的关系,并让构造函数里的 this 指向这个实例。
11. Event Loop
Event Loop 是 JS 处理同步任务和异步回调的机制。因为 JS 主线程一次只能执行一段代码,所以异步任务完成后不能立刻插队执行,而是要排进任务队列,等主线程空了再执行。
浏览器里可以先按这个顺序记:
- 执行当前同步代码。
- 清空本轮产生的所有微任务。
- 浏览器根据情况进行渲染。
- 取出一个宏任务执行,进入下一轮循环。
console.log("sync");
setTimeout(() => console.log("macro"), 0);
Promise.resolve().then(() => console.log("micro"));
console.log("end");
// sync -> end -> micro -> macro
上面代码里,同步代码先输出 sync 和 end;Promise.then 是微任务,先于 setTimeout 执行;setTimeout 是宏任务,最后执行。
12. 宏任务与微任务
宏任务和微任务都是异步回调队列,但执行优先级不同:微任务会在当前同步代码结束后立刻清空,宏任务一轮只取一个执行。
常见微任务有 Promise.then、queueMicrotask、MutationObserver。常见宏任务有 setTimeout、setInterval、I/O、UI 事件。
setTimeout(() => console.log("timeout"));
Promise.resolve().then(() => {
console.log("promise 1");
Promise.resolve().then(() => console.log("promise 2"));
});
console.log("sync");
// sync -> promise 1 -> promise 2 -> timeout
注意:微任务优先级高,但不是越多越好。如果一直往微任务队列里塞任务,浏览器可能迟迟没有机会渲染页面。
13. Promise 原理
Promise 可以理解成一个异步结果的容器。它有三种状态:pending 表示进行中,fulfilled 表示成功,rejected 表示失败。
状态变化有两个特点:
- 只能从
pending变成fulfilled或rejected。 - 一旦状态改变,就不能再改。
then 的关键是:它会返回一个新的 Promise,所以才能链式调用。上一个 then 回调的返回值,会传给下一个 then。
Promise.resolve(1) .then((value) => value + 1) .then((value) => console.log(value)); // 2
如果回调里返回的是普通值,下一个 then 会拿到这个值;如果返回的是 Promise,下一个 then 会等待这个 Promise 完成。
以下是 Promise 运作的核心原理:
- 核心机制:三种状态 (Three States)
一个 Promise 实例在任何时候都只能处于以下三种状态之一:
- Pending(等待中):初始状态,也就是事情还在进行中,既没成功也没失败。
- Fulfilled(已成功):操作成功完成。此时会触发
resolve函数,并传递一个成功的值。 - Rejected(已失败):操作失败。此时会触发
reject函数,并传递一个失败的原因(通常是 Error 对象)。
核心铁律:状态不可逆 Promise 的状态流转是单向且不可逆的。只能从 Pending -> Fulfilled 或者从 Pending -> Rejected。一旦状态改变(也就是状态落锤定音,称为 settled),就永远不会再变了。
- 核心架构:发布-订阅模式
Promise 内部其实维护了两个队列(数组):
- 成功回调队列:存放通过
.then()注册的成功处理函数。 - 失败回调队列:存放通过
.catch()(或.then的第二个参数)注册的失败处理函数。
当你 new Promise 并执行异步操作时:
- 如果异步操作还在 Pending,你调用的
.then()或.catch()会把对应的回调函数存入对应的队列中(订阅)。 - 当异步操作完成,调用了
resolve()或reject()时,Promise 内部会遍历对应的队列,将里面的回调函数依次拿出来执行(发布)。 - 执行时机:微任务 (Microtask)
这是很多人容易忽略的一点。Promise 的回调函数(.then 和 .catch 里的代码)永远是异步执行的,即使 Promise 已经处于成功状态。 它们会被推入 JavaScript 事件循环(Event Loop)的微任务队列(Microtask Queue)中,在当前同步代码执行完毕后、下一个宏任务开始前立刻执行。
面试总结:Promise 解决的是异步结果的状态管理,then 返回新 Promise 是链式调用的基础。
14. async / await
async/await 是 Promise 的语法糖,目的是把异步流程写得更像同步代码。
async 函数一定会返回 Promise;await 会暂停当前 async 函数后面的代码,等待右侧 Promise 完成后再继续执行。注意它只是暂停当前 async 函数,不会阻塞整个 JS 线程。
const load = async () => {
try {
const [user, orders] = await Promise.all([fetchUser(), fetchOrders()]);
return { user, orders };
} catch (error) {
console.error(error);
}
};
await 后面的继续执行逻辑会进入微任务队列,所以它和 Promise 的执行顺序题经常一起考。
async function run() {
console.log("a");
await Promise.resolve();
console.log("b");
}
run();
console.log("c");
// a -> c -> b
面试总结:async/await 本质还是 Promise,只是让异步代码更容易读。
15. 深拷贝
浅拷贝只复制第一层,嵌套对象仍然共享引用;深拷贝会递归复制嵌套结构。面试手写时至少要处理对象、数组和循环引用,实际项目中可以优先考虑 structuredClone 或成熟工具库。
const deepClone = <T extends object>(source: T, cache = new WeakMap()): T => {
if (cache.has(source)) return cache.get(source);
const target: any = Array.isArray(source) ? [] : {};
cache.set(source, target);
Reflect.ownKeys(source).forEach((key) => {
const value = (source as any)[key];
target[key] = value && typeof value === "object" ? deepClone(value, cache) : value;
});
return target;
};
16. 防抖与节流
防抖是在连续触发停止一段时间后再执行,适合搜索输入、窗口 resize;节流是在固定时间内最多执行一次,适合滚动、拖拽、按钮连点限制。两者都是为了降低高频事件带来的性能压力。
const debounce = <T extends (...args: any[]) => void>(fn: T, delay: number) => {
let timer: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
};
const throttle = <T extends (...args: any[]) => void>(fn: T, delay: number) => {
let last = 0;
return (...args: Parameters<T>) => {
const now = Date.now();
if (now - last >= delay) {
last = now;
fn(...args);
}
};
};
17. 模块化
CommonJS 是运行时加载,主要用于 Node;ESM 是静态模块系统,支持编译期分析和 Tree Shaking。现代前端工程通常优先使用 ESM,库包会同时产出 ESM 和 CJS 来兼容不同环境。
18. 垃圾回收
JS 垃圾回收主要基于可达性分析,从根对象出发无法访问到的对象会被回收。常见内存泄漏包括未清理事件监听、定时器、闭包持有大对象、全局缓存无限增长、DOM 被移除但仍被 JS 引用。
TypeScript
1. TypeScript 的价值
TypeScript 可以理解成给 JavaScript 加了一层类型检查。它不会改变 JS 的运行时逻辑,最终还是会编译成 JS,但它能在代码运行前发现很多类型错误。
它的价值主要有三点:第一,用类型把业务数据结构表达清楚;第二,重构时更安全,改字段名或函数参数时 IDE 能帮你发现影响范围;第三,团队协作时,别人看类型就能知道这个函数需要什么、返回什么。
面试总结:TS 不是为了让代码变复杂,而是用类型提前暴露错误、描述约束、提升维护性。
2. interface 与 type
interface 和 type 都能描述类型,很多场景可以互换,但侧重点不一样。
interface 更适合描述对象结构,比如用户、订单、组件 props,也适合给 class 做 implements。它还能声明合并,也就是同名 interface 会自动合在一起。
type 更灵活,适合联合类型、交叉类型、条件类型、工具类型这类类型运算。
interface User {
id: number;
name: string;
}
type Status = "loading" | "success" | "error";
type UserWithRole = User & { role: string };
面试总结:对象模型优先用 interface,复杂类型组合优先用 type,项目里保持团队风格统一更重要。
3. any / unknown / never
any、unknown、never 可以按安全程度理解。
any 表示“随便是什么类型”,用了它以后 TS 基本不再检查,容易把错误传染到后续代码里。unknown 也表示未知类型,但使用前必须先判断类型,所以更安全。never 表示永远不会出现的值,常用于抛错函数、死循环函数、联合类型穷尽检查。
function handle(value: unknown) {
if (typeof value === "string") {
return value.toUpperCase();
}
}
面试总结:外部输入优先用 unknown,不要上来就用 any;never 常用于保证分支已经处理完整。
4. 泛型
泛型就是“类型参数”。普通函数的参数传的是值,泛型传的是类型。它解决的问题是:代码可以复用,但类型不能丢。
const pick = <T extends object, K extends keyof T>(obj: T, key: K): T[K] => obj[key];
上面 T 表示对象类型,K extends keyof T 表示 key 必须是这个对象真实存在的属性。返回值 T[K] 表示返回对应属性的类型。
const user = { id: 1, name: "Tom" };
const name = pick(user, "name"); // string
面试总结:泛型让函数、组件、工具类型在复用时仍然保留输入和输出之间的类型关系。
5. 联合类型与交叉类型
联合类型表示“可能是其中一种”,用 |;交叉类型表示“同时拥有这些能力”,用 &。
联合类型常用来描述状态,因为一个状态在同一时间只能属于一种情况。
type State =
| { type: "loading" }
| { type: "success"; data: string }
| { type: "error"; message: string };
交叉类型常用来组合多个对象能力。
type User = { id: number; name: string };
type Permission = { role: string };
type Admin = User & Permission;
面试总结:| 是“或”,适合多种可能;& 是“且”,适合能力叠加。
6. 类型收窄
类型收窄就是把一个宽泛类型缩小成更具体的类型。因为 TS 只有确认类型后,才允许你安全访问对应属性或方法。
常见收窄方式有 typeof、in、instanceof、字面量判断。
function print(value: string | number) {
if (typeof value === "string") {
return value.toUpperCase();
}
return value.toFixed(2);
}
面试总结:类型收窄是 TS 根据判断条件理解代码分支,从而给出更精确类型。
7. 工具类型
Partial、Required、Readonly、Pick、Omit、Record 是最常见的工具类型。它们的底层主要依赖映射类型、索引访问类型和条件类型。
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
8. infer
infer 用在条件类型中,用来临时推断某个位置上的类型。常见场景是提取函数返回值、Promise 内部值、数组元素类型等。
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never; type AwaitedValue<T> = T extends Promise<infer R> ? R : T;
浏览器原理
1. 从输入 URL 到页面展示
从输入 URL 到页面展示,可以拆成两个大阶段:网络请求阶段和浏览器渲染阶段。
网络阶段大致是:先查缓存;缓存没有命中就做 DNS 解析,把域名变成 IP;然后建立 TCP 连接;如果是 HTTPS,还要进行 TLS 握手;接着发送 HTTP 请求,服务端返回 HTML。
渲染阶段大致是:浏览器解析 HTML 生成 DOM,解析 CSS 生成 CSSOM,两者合成渲染树;然后计算每个节点的位置和大小,也就是布局;再绘制颜色、文字、图片、阴影;最后把不同图层合成到屏幕上。
面试总结:这道题不要一口气背流水账,可以按“缓存、DNS、连接、请求响应、解析、布局、绘制、合成”这条线讲,再结合性能优化补充关键 CSS、JS 阻塞和首屏资源。
2. 浏览器渲染流程
浏览器渲染流程可以记成:DOM、CSSOM、渲染树、布局、绘制、合成。
DOM 描述页面结构,CSSOM 描述样式规则。渲染树只包含需要显示的节点,比如 display: none 的元素不会进入渲染树。布局阶段计算元素的位置和大小,绘制阶段把文字、颜色、边框、阴影画出来,合成阶段把不同图层合成最终画面。
CSS 会阻塞渲染,因为浏览器必须知道样式才能正确绘制页面。JS 可能阻塞 HTML 解析,因为 JS 可能会修改 DOM 或读取样式。面试总结:首屏优化很多时候就是减少关键 CSS/JS 对这条渲染链路的阻塞。
3. DOM 事件流
DOM 事件流描述的是一次事件从哪里开始、经过哪里、最后到哪里。它分为三个阶段:捕获阶段、目标阶段、冒泡阶段。
捕获阶段是事件从 window、document 一层层往目标元素走;目标阶段是到达真正触发事件的元素;冒泡阶段是事件再从目标元素一层层往外冒。
事件委托就是利用冒泡,把事件监听放在父元素上,通过 event.target 判断真正点击的是哪个子元素。它适合列表、表格、动态新增节点,因为不需要给每个子元素单独绑定事件。
list.addEventListener("click", (event) => {
const target = event.target as HTMLElement;
const item = target.closest("[data-id]");
if (item) console.log(item.getAttribute("data-id"));
});
面试总结:事件流讲传播顺序,事件委托讲如何利用冒泡减少事件绑定。
4. 浏览器本地存储
Cookie 容量小,会随请求自动携带,适合服务端会话;localStorage 持久保存,适合非敏感配置;sessionStorage 页面会话结束后清除;IndexedDB 适合大量结构化数据。敏感 Token 不建议直接放 localStorage,因为 XSS 后容易被读取。
5. Web Worker
Web Worker 可以把耗时计算放到独立线程中执行,避免阻塞主线程渲染。它不能直接操作 DOM,只能通过 postMessage 和主线程通信,适合大数据计算、文件解析、图片处理等场景。
Vue
1. Vue2 与 Vue3 区别
Vue2 响应式基于 Object.defineProperty,对新增属性和数组部分操作需要额外处理;Vue3 基于 Proxy,拦截能力更完整。Vue3 还引入了组合式 API,更适合复杂逻辑复用和 TypeScript 类型推导。
2. Vue 响应式原理
Vue 响应式的核心是:读取数据时进行依赖收集,修改数据时进行派发更新。简单理解就是 effect 执行时记录“谁用到了这个数据”,数据变化时再通知这些 effect 重新执行。
可以用一个例子理解:模板里用了 user.name,渲染函数执行时会读取 user.name,Vue 就记录“这个渲染函数依赖了 name”。之后 user.name 被修改,Vue 就能找到对应渲染函数并重新更新页面。
Vue2 和 Vue3 的区别主要在拦截方式:Vue2 用 Object.defineProperty 拦截对象已有属性的读取和修改;Vue3 用 Proxy 代理整个对象,能拦截能力更完整,比如新增属性、删除属性、数组操作等。
type Effect = () => void;
const bucket = new WeakMap<object, Map<PropertyKey, Set<Effect>>>();
let activeEffect: Effect | null = null;
const effect = (fn: Effect) => {
activeEffect = fn;
fn();
activeEffect = null;
};
const reactive = <T extends object>(target: T): T =>
new Proxy(target, {
get(obj, key, receiver) {
if (activeEffect) {
let depsMap = bucket.get(obj);
if (!depsMap) bucket.set(obj, (depsMap = new Map()));
let deps = depsMap.get(key);
if (!deps) depsMap.set(key, (deps = new Set()));
deps.add(activeEffect);
}
return Reflect.get(obj, key, receiver);
},
set(obj, key, value, receiver) {
const ok = Reflect.set(obj, key, value, receiver);
bucket.get(obj)?.get(key)?.forEach((fn) => fn());
return ok;
},
});
3. ref 与 reactive
ref 和 reactive 都能创建响应式数据,区别主要在使用对象和访问方式。
ref 适合基本类型,也适合需要整体替换的值;在 JS 里要通过 .value 访问,在模板中会自动解包。reactive 适合对象或数组,访问属性时不需要 .value。
const count = ref(0);
count.value++;
const user = reactive({ name: "Tom" });
user.name = "Jerry";
注意:reactive 直接解构可能丢失响应式,因为解构出来的是普通值。需要保留响应式时,可以用 toRefs。
面试总结:基本类型和整体替换用 ref,复杂对象用 reactive,解构 reactive 要小心。
4. computed 与 watch
computed 和 watch 的区别可以记成:computed 负责算出一个值,watch 负责变化后做一件事。
computed 是带缓存的派生状态,依赖不变就不会重新计算,适合从已有状态计算新状态,比如总价、过滤列表、格式化展示。
watch 是监听数据变化后执行副作用,比如请求接口、写日志、操作本地缓存、调用第三方 API。
面试总结:能用计算属性表达的数据,就优先用 computed;需要在变化后执行动作,才用 watch。
5. nextTick
Vue 更新 DOM 是异步批量执行的,修改响应式数据后,DOM 不会立刻同步更新。nextTick 的作用是等本轮数据更新对应的 DOM patch 完成后再执行回调,常用于获取最新 DOM 尺寸或滚动位置。
const callbacks: Array<() => void> = [];
let pending = false;
const nextTick = (cb: () => void) => {
callbacks.push(cb);
if (!pending) {
pending = true;
Promise.resolve().then(() => {
pending = false;
callbacks.splice(0).forEach((fn) => fn());
});
}
};
6. Vue Diff 与 key
Vue Diff 会尽量复用同层级节点,减少真实 DOM 操作。key 的作用是标识节点身份,尤其在列表插入、删除、排序时,如果 key 不稳定,可能导致节点错误复用和组件状态错乱。
7. 组件通信
父子组件通信用 props 和 emit,跨层级通信用 provide/inject,复杂全局状态用 Pinia 或 Vuex。不要把所有通信都放到事件总线里,否则数据流会变得难追踪。
8. v-if 与 v-show
v-if 是真正创建或销毁 DOM,适合低频切换;v-show 是通过 display 控制显示隐藏,适合高频切换。判断标准就是切换频率和初始渲染成本。
9. keep-alive
keep-alive 用来缓存组件实例,避免组件来回切换时重复创建和销毁。常见场景是 tab 页、列表页返回详情页后保留滚动位置,它会触发 activated 和 deactivated 生命周期。
10. Pinia
Pinia 是 Vue3 推荐状态库,API 更简洁,TypeScript 推导也更好。相比 Vuex,它弱化了 mutation 概念,可以直接通过 action 或 store 实例修改状态,使用体验更接近组合式 API。
React
1. React 核心思想
React 的核心思想是:UI 是状态的结果。你不用手动告诉浏览器“把这个 DOM 改成什么”,而是描述在某个 state 下页面应该长什么样,状态变化后 React 重新计算 UI 并更新 DOM。
这就是声明式 UI。命令式写法关注“怎么一步步改 DOM”,声明式写法关注“当前状态应该显示什么”。
面试总结:React 用组件拆分 UI,用 state 驱动视图,用声明式方式降低复杂 UI 的维护成本。
2. 虚拟 DOM
虚拟 DOM本质是用 JS 对象描述真实 DOM。状态变化后,React 会生成新的虚拟 DOM,再和旧的虚拟 DOM 做 diff,找出需要更新的部分,最后再更新真实 DOM。
它的价值不是“永远比手写 DOM 快”,而是让复杂 UI 的更新过程更可预测,并且同一套描述可以适配不同平台,比如浏览器 DOM、React Native。
面试总结:虚拟 DOM 是 UI 的中间表示,核心价值是声明式更新、diff 优化和跨平台能力。
3. Fiber
Fiber 可以理解成 React 为了“可中断渲染”设计的一种任务单元和数据结构。以前一次更新如果组件树很大,React 可能长时间占用主线程,导致页面卡顿。Fiber 把大任务拆成很多小任务,让 React 有机会暂停、恢复、丢弃低优先级任务。
React 更新大致分为两个阶段:render 阶段负责计算变化,可以被中断;commit 阶段负责把变化真正提交到 DOM,必须同步完成,不能中断。
面试总结:Fiber 解决的是大组件树更新时主线程被长时间占用的问题,让 React 支持优先级调度和更流畅的交互。
4. React Diff
React Diff 基于两个假设:不同类型节点直接替换,同层列表通过 key 判断能否复用。稳定 key 很重要,如果用数组 index 当 key,在插入、删除、排序时可能导致组件状态错位。
5. Hooks 原理
Hooks 的状态和副作用是按调用顺序保存的,所以 Hook 不能写在条件、循环、嵌套函数里。只要每次渲染 Hook 调用顺序一致,React 就能正确找到每个 Hook 对应的状态。
可以把 Hooks 想成一个按顺序排列的列表:第一次 useState 对应第一个状态,第二次 useEffect 对应第二个副作用。如果某次渲染因为条件判断少调用了一个 Hook,后面的顺序就全乱了。
let hooks: unknown[] = [];
let index = 0;
function useState<T>(initial: T) {
const current = index;
hooks[current] ??= initial;
const setState = (value: T) => {
hooks[current] = value;
index = 0;
render();
};
return [hooks[index++] as T, setState] as const;
}
function render() {
// rerender component
}
6. useEffect
useEffect 用来处理副作用。副作用就是那些不只是计算 UI 的事情,比如请求数据、订阅事件、设置定时器、操作浏览器 API。
它会在浏览器绘制后异步执行,所以不会阻塞页面绘制。返回的函数用于清理副作用,避免组件卸载后仍然保留监听器、定时器或旧请求。
useEffect(() => {
const timer = setInterval(() => setCount((n) => n + 1), 1000);
return () => clearInterval(timer);
}, []);
依赖数组决定 effect 什么时候重新执行:不传依赖数组,每次渲染后都执行;传空数组,只在挂载后执行一次;传具体依赖,依赖变化后执行。
7. useLayoutEffect
useLayoutEffect 在 DOM 更新后、浏览器绘制前同步执行,适合读取布局并立刻修正页面,比如测量元素尺寸后调整位置。它会阻塞浏览器绘制,所以普通请求、订阅、日志不要用它。
8. useMemo / useCallback / React.memo
useMemo 缓存计算结果,useCallback 缓存函数引用,React.memo 缓存组件渲染结果。它们不是越多越好,因为缓存和比较也有成本,适合用于昂贵计算或减少子组件不必要重渲染。
9. setState
React 状态更新可能被批处理,所以依赖旧状态时应使用函数式更新。直接修改对象或数组不会产生新引用,可能导致 React 无法判断变化,应该返回新的对象或数组。
setCount((count) => count + 1); setList((list) => [...list, item]);
10. 受控与非受控组件
受控组件的表单值由 React state 控制,适合校验、联动和统一管理;非受控组件由 DOM 自己保存状态,适合文件上传或简单表单。复杂表单通常以受控为主,但也要注意性能和输入延迟。
11. Context
Context 适合跨层级传递低频变化的数据,比如主题、语言、登录用户信息。高频变化的大状态不适合全部塞进 Context,否则会导致大范围重渲染,通常需要拆分 Context 或引入状态库。
12. React 合成事件
React 的合成事件封装了浏览器原生事件,统一了不同浏览器的差异,并通过事件委托减少事件绑定数量。React 17 之后事件委托从 document 调整到根容器,方便多个 React 版本共存。
网络与安全
1. HTTP 状态码
HTTP 状态码用于表示请求结果,2xx 成功,3xx 重定向,4xx 客户端错误,5xx 服务端错误。高频状态码包括 200、204、301、302、304、400、401、403、404、500、502。
2. HTTP 缓存
HTTP 缓存可以分为强缓存和协商缓存。
强缓存的意思是:浏览器先看本地缓存是否还在有效期内,如果没过期,就直接用本地资源,不发请求。常见响应头是 Cache-Control 和 Expires,现代项目更常用 Cache-Control。
协商缓存的意思是:浏览器会带着缓存标识去问服务器“我这个资源还能不能用”。如果资源没变,服务器返回 304,浏览器继续用本地缓存;如果变了,就返回新资源。常见响应头是 ETag / If-None-Match 和 Last-Modified / If-Modified-Since。
面试总结:强缓存是不发请求直接用,协商缓存是发请求确认后再决定用不用。
3. HTTP/1.1 / HTTP/2 / HTTP/3
HTTP/1.1、HTTP/2、HTTP/3 可以按“怎么让传输更快、更稳”来理解。
HTTP/1.1 支持长连接,不用每个请求都重新建连接,但浏览器对同一个域名的连接数有限,而且同一连接上的请求容易互相等待。
HTTP/2 主要改进是多路复用和头部压缩。多路复用表示多个请求可以在同一个 TCP 连接里并发传输,不用严格一个等一个;头部压缩减少了重复请求头的体积。但 HTTP/2 底层还是 TCP,如果 TCP 丢包,同一连接里的数据仍然会受影响。
HTTP/3 基于 QUIC,QUIC 跑在 UDP 之上,改善了 TCP 层面的队头阻塞,也更适合弱网下的连接迁移。
面试总结:HTTP/1.1 解决长连接,HTTP/2 解决应用层多路复用,HTTP/3 用 QUIC 改善 TCP 队头阻塞和弱网体验。
4. TCP 与 UDP
TCP 和 UDP 的核心区别是:TCP 更重视可靠性,UDP 更重视低延迟。
TCP 是面向连接的,传输前要先建立连接;它保证数据可靠、有序到达,所以适合 HTTP/HTTPS、文件传输这类不能丢数据的场景。TCP 的代价是连接和可靠性机制会带来额外开销。
UDP 是无连接的,不保证可靠到达,也不保证顺序,但开销小、延迟低,适合实时音视频、游戏、直播、QUIC 这类更在意实时性的场景。丢一点数据可以由应用层自己处理。
面试总结:要可靠、有序选 TCP;要低延迟、能接受部分丢包或应用层兜底,可以选 UDP。
5. HTTPS / TLS
HTTPS 本质是 HTTP 加 TLS,解决 HTTP 明文传输不安全的问题。
它主要提供三件事:加密,防止内容被窃听;身份认证,确认你访问的确实是目标网站;完整性校验,防止数据传输过程中被篡改。
TLS 握手阶段会校验证书,并协商后续通信使用的会话密钥。非对称加密主要用于身份认证和密钥协商,真正传输大量数据时使用对称加密,因为它性能更好。
面试总结:HTTPS 不是新的应用层协议,而是 HTTP 套了一层 TLS,用证书和加密保证安全通信。
6. DNS
DNS 的作用是把域名解析成 IP 地址,查询会经过浏览器缓存、系统缓存、递归 DNS、权威 DNS 等环节。优化手段包括 DNS 缓存、dns-prefetch、减少不必要的跨域名资源。
7. 跨域与 CORS
跨域来自浏览器的同源策略。同源要求协议、域名、端口都相同,只要有一个不同,就算跨域。它限制的是浏览器里的脚本访问响应内容,目的是保护用户数据。
CORS 是服务端通过响应头告诉浏览器“这个来源可以访问我”。常见响应头有 Access-Control-Allow-Origin、Access-Control-Allow-Methods、Access-Control-Allow-Headers。
如果是复杂请求,比如使用了特殊请求头、非简单方法,浏览器会先发一个 OPTIONS 预检请求,确认服务端允许后再发真实请求。
面试总结:跨域不是请求一定发不出去,而是浏览器基于同源策略拦截了响应;CORS 的关键在服务端响应头。
8. Cookie / Session / Token / JWT
Cookie、Session、Token、JWT 都和登录态有关,但职责不同。
Cookie 是浏览器保存的一小段数据,会自动随同域请求发送。Session 通常保存在服务端,浏览器只保存一个 session id,服务端根据这个 id 找到用户状态。
Token 通常是服务端签发的一段凭证,前端保存后放到请求头里,比如 Authorization。它适合前后端分离、多端登录、跨域接口调用。
JWT 是一种自包含 Token,里面可以带用户信息和过期时间,并通过签名防篡改。优点是服务端可以无状态校验;缺点是签发后不容易主动吊销,续期和泄露处理更复杂。
面试总结:Cookie 是存储和自动携带机制,Session 是服务端登录态,Token 是访问凭证,JWT 是一种带签名的 Token 格式。
9. XSS
XSS 是攻击者把恶意脚本注入页面,让脚本在用户浏览器里执行。它可能窃取 Cookie、Token,伪造用户操作,或者篡改页面内容。
常见来源是把用户输入当成 HTML 直接渲染,比如评论、昵称、富文本内容没有过滤。防护重点是:输入要校验,输出到页面前要转义;富文本要用白名单过滤;设置 CSP 限制脚本来源;敏感 Cookie 设置 HttpOnly,避免被 JS 读取;尽量少用 innerHTML、v-html、dangerouslySetInnerHTML。
面试总结:XSS 防的是恶意脚本在页面里执行,核心是不要信任用户输入,输出时做好转义和白名单过滤。
10. CSRF
CSRF 是攻击者利用用户已经登录的状态,诱导浏览器向目标网站发起请求。因为 Cookie 会自动携带,所以服务端可能误以为这是用户本人操作。
它和 XSS 的区别是:XSS 是把脚本注入你的页面里执行;CSRF 不一定能读取响应内容,而是借用用户登录态发请求。
防护方式包括:设置 SameSite Cookie,减少跨站自动携带 Cookie;使用 CSRF Token,让攻击者拿不到合法 token;校验 Origin / Referer;转账、改密码这类关键接口增加二次验证。
面试总结:CSRF 防的是伪造用户请求,重点是不要只靠 Cookie 判断用户意图。
11. 点击劫持
点击劫持是把目标页面放进透明 iframe 中,诱导用户点击攻击者想让他点击的位置。防护方式是设置 X-Frame-Options 或 CSP 的 frame-ancestors,限制页面被其他站点嵌入。
12. WebSocket 与 SSE
WebSocket 是全双工长连接,客户端和服务端可以互相主动发送消息,适合 IM、协同编辑、实时游戏。SSE 是服务端到客户端的单向推送,基于 HTTP,适合通知、日志、AI 流式输出,并且天然支持断线重连。
13. 请求取消与竞态
搜索框、切页、组件卸载时,旧请求可能比新请求更晚返回,导致页面显示过期数据。常用 AbortController 取消请求,或者用请求序号判断只处理最后一次响应。
const controller = new AbortController();
fetch("/api/search", { signal: controller.signal });
controller.abort();
性能优化
1. 核心性能指标
常见性能指标有 FCP、LCP、CLS、INP,可以分别对应用户感受。
FCP 看“页面什么时候开始有内容”;LCP 看“首屏主要内容什么时候出来”;CLS 看“页面加载过程中有没有乱跳”;INP 看“用户点击、输入后页面响应快不快”。
优化时不要凭感觉乱改,要先看是加载慢、渲染慢、布局不稳定,还是交互卡顿,再选择对应手段。
面试总结:FCP/LCP 关注加载体验,CLS 关注视觉稳定性,INP 关注交互响应。
2. 首屏优化
首屏优化的核心是:让用户尽快看到可用的主要内容。它不是只优化某一个点,而是同时看资源、渲染、接口和图片。
常见手段包括:减少首屏 JS/CSS 体积,使用路由懒加载;关键 CSS 内联或优先加载;首屏大图压缩并设置合适尺寸;关键资源用 preload;接口慢时做缓存、并行请求或骨架屏;对 SEO 或首屏要求高的页面可以考虑 SSR/SSG。
实际项目中,LCP 往往受首屏大图、字体、主包体积和首屏接口影响。面试总结:首屏优化就是缩短关键渲染路径,让关键内容先出来,非关键内容延后加载。
3. 代码分割
代码分割就是把一个很大的 JS 包拆成多个小包,让用户先加载当前页面需要的代码,其他页面或低频功能等用到时再加载。
它主要解决首屏 JS 太大、解析执行时间太长的问题。常见拆分方式有按路由拆、按组件拆、按业务模块拆、把第三方依赖单独拆。
React 常用 lazy + Suspense,Vue 常用动态 import() 配合路由懒加载。
const Page = lazy(() => import("./Page"));
4. Tree Shaking
Tree Shaking 是构建时删除没有被使用的代码。它依赖 ESM 的静态结构,因为 ESM 的 import/export 在编译阶段就能分析出依赖关系。
要让 Tree Shaking 更容易生效,代码应尽量使用 ESM,避免模块顶层产生不可预测副作用,并正确配置 sideEffects。如果把有副作用的样式或初始化逻辑误标成无副作用,可能会被错误删除。
面试总结:代码分割是“按需加载代码”,Tree Shaking 是“删除没用代码”,两者都能减少最终加载成本,但解决的问题不同。
5. 图片优化
图片优化包括压缩体积、使用 WebP/AVIF、按屏幕尺寸加载合适图片、懒加载非首屏图片、给图片设置宽高避免 CLS。首屏关键图片不要盲目懒加载,反而可以配合 preload 提高加载优先级。
6. 长列表优化
长列表如果一次性渲染几千个 DOM,会导致渲染和滚动卡顿。虚拟列表只渲染可视区域附近的数据,用一个总高度容器撑开滚动条,从而显著减少 DOM 数量。
它的核心思路是:用户虽然有一万条数据,但屏幕上同一时间只能看到几十条,所以只渲染可视区附近的几十条。滚动时根据 scrollTop 计算当前应该显示哪一段数据,再用 transform 或占位高度把它放到正确位置。
const getRange = (scrollTop: number, itemHeight: number, viewHeight: number, total: number) => {
const start = Math.floor(scrollTop / itemHeight);
const size = Math.ceil(viewHeight / itemHeight);
return {
start,
end: Math.min(total, start + size + 2),
offset: start * itemHeight,
};
};
7. 交互性能优化
交互卡顿通常来自主线程太忙,比如长任务、频繁重渲染、大量 DOM、同步布局、复杂计算。用户点击或输入后,如果主线程还在忙,页面就会迟迟没有响应。
优化方式包括:把大任务拆成小块分批执行;把重计算放到 Web Worker;用缓存减少重复计算;用虚拟列表减少 DOM;在 React/Vue 中减少无效渲染;把非紧急更新延后。
面试总结:交互优化的核心是减少主线程被长时间占用,让用户操作能尽快得到响应。
8. 内存优化
内存泄漏常见来源包括未清理定时器、事件监听、闭包持有大对象、全局缓存无限增长、组件卸载后异步回调还在执行。排查时可以用 Chrome DevTools 的 Memory 面板对比 Heap Snapshot,看对象是否能被正常回收。
9. 前端监控
前端监控通常包括错误监控、性能监控、接口监控、资源加载监控和用户行为埋点。一个完整闭环应该包含采集、上报、聚合、告警、定位和修复验证,而不是只把日志打出来。
工程化
1. Webpack
Webpack 可以理解成一个模块打包器。它从入口文件开始,沿着 import / require 递归分析依赖,形成模块依赖图,然后经过 loader 转换不同类型文件,再通过 plugin 扩展构建流程,最终输出 bundle。
核心概念可以这样记:entry 是入口,output 是出口,loader 负责转换文件,plugin 负责扩展流程,chunk 是拆出来的代码块。
2. Vite
Vite 快主要快在开发阶段。它利用浏览器原生 ESM,启动时不需要先把整个项目打成一个包,而是按需加载当前页面用到的源码模块,所以冷启动快;文件修改后也只更新受影响的模块,所以 HMR 快。
生产环境下,Vite 通常还是基于 Rollup 打包,做代码压缩、分包和兼容处理。面试总结:Webpack 开发阶段更偏“先打包再运行”,Vite 开发阶段更偏“按需加载源码模块”。
3. Loader 与 Plugin
Loader 和 Plugin 的区别可以一句话记:Loader 处理文件内容,Plugin 介入构建流程。
Loader 像转换器,把 TS、CSS、图片等不同类型文件转换成 Webpack 能理解的模块。Plugin 像插件,可以在构建生命周期中做额外事情,比如生成 HTML、压缩资源、抽离 CSS、分析包体积。
面试总结:Loader 关注“某类文件怎么变成模块”,Plugin 关注“构建过程里额外做什么”。
4. Babel 与 SWC
Babel 主要用于 JS 语法转换和兼容性处理,生态成熟;SWC 用 Rust 实现,编译速度更快。preset-env 会根据目标浏览器决定需要转换哪些语法,以及是否注入 polyfill。
5. Source Map
Source Map 用来把压缩混淆后的线上代码映射回源码,方便定位报错行列。生产环境要控制访问权限,否则可能把源码直接暴露给外部用户。
6. npm / yarn / pnpm
npm 是默认包管理器,yarn 提供过更稳定的依赖安装体验,pnpm 通过内容寻址和硬链接节省磁盘,并能更好地避免幽灵依赖。现代 monorepo 项目里 pnpm workspace 使用非常多。
7. Monorepo
Monorepo 是把多个包放在同一个仓库中管理,适合组件库、工具库、多应用协作。它的优势是统一规范、统一依赖、方便跨包联调,难点是任务编排、依赖拓扑、版本发布和权限边界。
8. ESLint / Prettier / Stylelint
ESLint 管 JS/TS 代码质量,Prettier 管代码格式,Stylelint 管样式规范。团队规范最好落到编辑器、pre-commit 和 CI 中,避免只靠人工 code review。
9. CI/CD
CI 负责自动化校验,比如 lint、test、build;CD 负责自动化部署,比如发布、灰度、回滚。前端流水线要重点保证环境变量隔离、构建可复现、产物可追踪、失败可回滚。
10. 微前端
微前端是把大型前端拆成多个可独立开发、部署和运行的子应用,适合多团队维护的大型中后台。核心难点是路由隔离、样式隔离、状态通信、依赖共享、性能和部署治理。
状态管理与架构
1. 前端状态分类
前端状态不要一上来都放全局 store,可以先按来源和用途分类。
本地 UI 状态只影响当前组件,比如弹窗开关、输入框内容,适合放组件 state。跨组件共享状态会被多个页面或组件使用,比如用户信息、主题、权限,适合放全局 store。服务端状态来自接口,比如列表数据、详情数据,它还涉及缓存、过期、重试、分页,适合交给 React Query / SWR 这类工具。URL 状态适合放筛选条件、分页参数,因为它需要可分享、可回退。
面试总结:状态管理的关键不是工具,而是先判断状态属于哪一类,再选合适位置管理。
2. Redux / Zustand / Pinia
Redux、Zustand、Pinia 都是状态管理工具,但适合的场景不同。
Redux 强调单向数据流、不可变更新和可预测性,适合大型项目、多人协作、需要严格调试和中间件能力的场景。Zustand 更轻量,写法简单,适合 React 中小型项目或局部复杂状态。Pinia 是 Vue3 推荐状态库,API 简洁,类型推导友好。
面试总结:选型要看团队规模、状态复杂度、调试需求、类型体验和维护成本,不要只说“哪个更好”。
3. 服务端状态管理
服务端状态指的是从接口拿来的数据,比如用户列表、订单详情、搜索结果。它和普通 UI 状态不一样,因为它会过期、需要重新请求、可能失败、可能分页、还可能出现请求竞态。
React Query、SWR 这类库解决的是远程数据同步问题,比如缓存、重新验证、重试、预取、请求去重。Redux、Pinia 更偏客户端状态管理,两者不应该完全混为一谈。
面试总结:接口数据优先考虑服务端状态管理,本地交互状态再考虑组件 state 或全局 store。
4. 组件设计
好的组件不是 props 越多越好,而是职责清楚、输入输出稳定、默认行为合理、扩展点明确。
展示组件主要负责 UI 展示,尽量少关心数据从哪里来;容器组件负责请求数据、处理副作用、连接状态管理;基础组件要尽量通用,不要塞太多业务特例,否则后面复用和维护都会变难。
面试总结:组件封装要控制职责边界,业务变化放在外层组合,基础能力放在组件内部。
5. 权限设计
前端权限常分为路由权限、菜单权限、按钮权限和数据权限。前端权限主要是控制用户体验和入口展示,真正的安全必须由后端校验,否则用户仍然可以绕过前端直接请求接口。
6. 错误边界
React 的 Error Boundary 可以捕获渲染阶段错误,Vue 可以用 errorCaptured 或全局错误处理。它们不能覆盖所有错误,比如异步错误、事件回调错误、接口错误仍然需要单独捕获和上报。
测试
1. 单元测试
单元测试用于验证一个函数、组件或模块的最小行为。它适合工具函数、复杂计算逻辑、稳定的业务规则。
比如金额计算、权限判断、URL 解析、数据转换,这些逻辑一旦错了影响很大,而且输入输出明确,就很适合写单元测试。
面试总结:单元测试关注小范围逻辑是否正确,重点测行为结果,不要过度依赖内部实现细节。
2. 组件测试
组件测试关注组件在页面上的展示和交互是否符合预期。它不是测试组件内部调用了哪个函数,而是从用户视角看:文本有没有出现、按钮能不能点击、输入后页面有没有变化。
Testing Library 推荐通过文本、角色、label 查询元素,因为这更接近真实用户和辅助技术访问页面的方式。
面试总结:组件测试少测实现,多测用户能看到什么、能做什么。
3. E2E 测试
E2E 测试是从真实用户路径出发,验证一整条业务链路,比如登录、下单、支付、核心表单提交。
它覆盖面强,能发现前后端联动问题,但运行慢、维护成本高,所以不适合把所有细节都写成 E2E。通常只覆盖核心路径和高风险流程。
面试总结:单元测试保逻辑,组件测试保交互,E2E 测试保关键链路。
4. Mock
Mock 的作用是隔离外部依赖,让测试更稳定可控。接口 Mock 可以用 MSW,在浏览器和 Node 测试环境中拦截请求,比直接 mock fetch 更贴近真实网络行为。
Node.js 与 SSR
1. Node 事件循环
Node 也有事件循环,但它和浏览器不完全一样。Node 的事件循环分成多个阶段,比如 timers、poll、check 等,分别处理定时器、I/O 回调、setImmediate 等任务。
Node 里还要特别注意 process.nextTick,它的优先级高于普通 Promise 微任务,容易在执行顺序题里出现。
面试总结:浏览器事件循环重点看宏任务、微任务和渲染;Node 事件循环还要区分阶段,并注意 process.nextTick。
2. Express 与 Koa
Express 中间件模型直接,生态成熟;Koa 基于洋葱模型,更强调 async/await。中间件本质上就是按顺序处理请求、响应和错误,适合做鉴权、日志、异常处理、代理转发等。
3. SSR
SSR 是服务端渲染,意思是服务端先把页面 HTML 生成好,再返回给浏览器。浏览器拿到 HTML 后能更快看到内容,然后前端 JS 再接管交互,这个接管过程叫水合。
SSR 的优点是首屏更快、SEO 更好;缺点是服务端压力更大,工程复杂度更高。常见难点包括数据预取、状态注水、同构代码、水合不一致、缓存策略。
面试总结:SSR 解决的是首屏和 SEO 问题,但会增加服务端和工程复杂度。
4. CSR / SSR / SSG
CSR、SSR、SSG 的区别在于 HTML 什么时候生成。
CSR 是浏览器端渲染,服务端先返回一个空壳 HTML,再由 JS 拉数据、生成页面。它交互灵活,但首屏和 SEO 较弱。
SSR 是请求时在服务端生成 HTML,首屏和 SEO 更好,但服务端压力更大。
SSG 是构建时提前生成静态 HTML,访问时直接返回静态文件,性能好、部署简单,但适合内容变化不太频繁的页面。
面试总结:强交互后台常用 CSR,重 SEO 和首屏可考虑 SSR,内容稳定的营销页、文档、博客适合 SSG。
移动端与小程序
1. 移动端 1px 问题
移动端 1px 问题来自 CSS 像素和设备物理像素比例不同。比如 DPR 为 2 的屏幕上,1 个 CSS 像素可能对应 2 个物理像素,所以边框看起来会比设计稿更粗。
常见方案是用伪元素画边框,再通过 transform: scaleY(0.5) 或 scaleX(0.5) 压缩,或者使用 UI 组件库提供的 hairline 边框方案。
面试总结:1px 问题本质是 CSS 像素和物理像素不一致导致的视觉粗细问题。
2. 安全区域适配
全面屏和刘海屏需要处理 safe area,避免底部按钮、固定导航或顶部内容被系统手势区域、刘海区域遮挡。
常用 env(safe-area-inset-bottom) 给底部固定元素增加安全间距,也可以处理顶部、左侧、右侧安全区域。
.footer {
padding-bottom: env(safe-area-inset-bottom);
}
3. 移动端点击与滚动
早期移动端有 300ms 点击延迟,现代浏览器在正确设置 viewport 后基本已经解决。复杂手势场景要注意 touch 事件、滚动穿透、被动监听和手势冲突。
4. 小程序架构
小程序通常分为逻辑层和视图层,两者通过桥通信。频繁 setData 或一次传输大量数据会影响性能,所以应减少更新频率和数据体积,只更新真正变化的字段。
手写与算法高频
1. 并发控制
并发控制是限制同时执行的异步任务数量。比如有 100 个请求,不希望一次性全发出去,而是最多同时发 5 个,谁完成了就补下一个。
它常用于批量上传、批量请求、图片处理。核心思路是维护一个任务指针,启动固定数量 worker,每个 worker 执行完一个任务后继续取下一个。
async function limit<T>(tasks: Array<() => Promise<T>>, max: number): Promise<T[]> {
const result: T[] = [];
let next = 0;
async function worker() {
while (next < tasks.length) {
const current = next++;
result[current] = await tasks[current]();
}
}
await Promise.all(Array.from({ length: max }, worker));
return result;
}
2. 发布订阅
发布订阅通过一个事件中心解耦事件发送者和接收者。发送者只负责 emit 一个事件,不需要知道谁在监听;接收者只负责 on 订阅事件,不需要知道是谁触发的。
它常用于组件通信、插件机制、业务事件流。核心 API 一般是 on 订阅、emit 发布、off 取消订阅。
class EventBus {
private events = new Map<string, Set<(...args: unknown[]) => void>>();
on(type: string, fn: (...args: unknown[]) => void) {
const set = this.events.get(type) ?? new Set();
set.add(fn);
this.events.set(type, set);
}
emit(type: string, ...args: unknown[]) {
this.events.get(type)?.forEach((fn) => fn(...args));
}
off(type: string, fn: (...args: unknown[]) => void) {
this.events.get(type)?.delete(fn);
}
}
3. LRU 缓存
LRU 的意思是最近最少使用。它解决的是缓存空间有限时该淘汰谁的问题:缓存满了,就优先删除最久没有被访问的数据。
JS 的 Map 会保持插入顺序,所以可以用它实现 LRU:每次访问某个 key,就先删除再重新插入,让它变成“最新使用”;超过容量时删除 Map 里的第一个 key。
class LRU<K, V> {
private cache = new Map<K, V>();
constructor(private capacity: number) {}
get(key: K) {
const value = this.cache.get(key);
if (value === undefined) return undefined;
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
set(key: K, value: V) {
if (this.cache.has(key)) this.cache.delete(key);
this.cache.set(key, value);
if (this.cache.size > this.capacity) {
this.cache.delete(this.cache.keys().next().value);
}
}
}
4. 数组去重
基础类型数组去重可以直接用 Set,因为 Set 天然不允许重复值。对象数组去重一般要根据业务唯一键,比如 id,用 Map 保存最后一次或第一次出现的对象。
const unique = <T>(list: T[]) => [...new Set(list)];
5. 数组扁平化
数组扁平化就是把多层嵌套数组展开成一层。工程里可以用 flat(Infinity),手写时通常用递归或栈来实现。
const flatten = (arr: unknown[]): unknown[] =>
arr.reduce<unknown[]>((res, item) => {
return res.concat(Array.isArray(item) ? flatten(item) : item);
}, []);
6. 柯里化
柯里化是把一个接收多个参数的函数,变成多个连续接收参数的函数。比如 sum(1, 2, 3) 可以变成 sum(1)(2)(3)。
它常用于参数复用、延迟执行和函数组合,本质是利用闭包保存已经传入的参数。
const curry = (fn: (...args: any[]) => any, ...args: any[]): any => args.length >= fn.length ? fn(...args) : (...rest: any[]) => curry(fn, ...args, ...rest);
7. 常见算法方向
前端算法高频集中在数组、字符串、哈希表、栈、队列、链表、树、双指针、滑动窗口。面试时不仅要写出代码,还要说清时间复杂度和空间复杂度,比如哈希去重通常是 O(n) 时间和 O(n) 空间。
项目深挖
1. 项目介绍怎么讲
项目介绍不要只罗列技术栈,建议按业务背景、项目目标、个人职责、技术难点、解决方案、最终结果来讲。面试官真正关注的是你是否真的参与过核心问题,以及你做出的技术决策是否有依据。
2. 技术选型怎么讲
技术选型要围绕业务诉求、团队熟悉度、生态成熟度、性能、维护成本和迁移成本展开。不要只说“因为它快”或“因为它新”,要说明它具体解决了什么问题,以及有没有权衡过替代方案。
3. 复杂组件封装
复杂组件比如表格、表单、上传器、编辑器,要重点讲状态模型、扩展点、异常处理、性能优化和类型设计。好的封装不是参数越多越好,而是默认行为稳定、核心能力可组合、业务特例不污染基础组件。
4. 性能治理项目
性能治理要讲闭环:如何发现问题、用什么指标定位、做了哪些优化、上线后收益如何。最好准备具体数据,比如主包体积减少多少、LCP 降低多少、接口耗时减少多少、错误率下降多少。
5. 线上问题排查
线上问题排查可以按影响范围、复现路径、监控日志、接口链路、最近发布、回滚策略来讲。修复后还要补回归测试、监控告警或防御性代码,体现你不是只修一次 bug,而是在降低同类问题再次发生的概率。
复习优先级
- 第一优先级:JavaScript 执行机制、闭包、原型链、Promise、Event Loop、Vue/React 响应式与渲染、HTTP 缓存、跨域、安全。
- 第二优先级:TypeScript 泛型与高级类型、浏览器渲染、性能优化、Webpack/Vite、状态管理、组件设计。
- 第三优先级:SSR、微前端、监控体系、CI/CD、测试、小程序、Node、复杂项目复盘和算法手写。