主管深挖细节一
第一批:JavaScript 语言特性(那些“看起来很简单”的细节)
1. ?? (空值合并运算符) 与 || (逻辑或) 的区别
基础认知:二者都用于提供默认值。 进阶细节:
||会忽略所有假值(Falsy),包括0、''、false、NaN,在这些值时会使用默认值。??只忽略null和undefined。当变量的值为0或''时,??会保留原值。
面试场景:
const a = 0;
const b = a || 100; // b = 100 (因为 0 是假值)
const c = a ?? 100; // c = 0 (因为 0 不是 null/undefined)面试话术:“如果我处理的是用户输入的数字或字符串,而
0或''是合法值,我会用??来避免被||错误地替换掉。这在处理表单默认值时非常重要。”
2. ?. (可选链) 的短路机制
基础认知:安全地访问深层嵌套属性。 进阶细节:?. 不仅会短路属性访问,还会短路函数调用(obj.method?.())和数组索引(arr?.[0])。它是一个“完整的短路运算符”,一旦链上的某个节点为 null/undefined,整个表达式立即停止并返回 undefined。
3. NaN 的诡异行为(面试高频陷阱)
基础认知:NaN 代表“不是数字”。 进阶细节:
typeof NaN === 'number'返回true(这是一个历史遗留问题)。NaN === NaN返回false(这是IEEE 754标准规定的)。- 唯一判断
NaN的可靠方法是Number.isNaN()。
面试场景:
console.log(typeof NaN); // 'number'
console.log(NaN === NaN); // false
console.log(isNaN('hello')); // true (因为 isNaN 会尝试类型转换)
console.log(Number.isNaN('hello')); // false (更严格)面试话术:“在项目中,我习惯使用
Number.isNaN(),而不是全局的isNaN(),因为后者会先进行类型转换,可能把字符串误判为NaN。”
4. Object.is() 与 === 的细微差别
基础认知:Object.is 可以比较两个值是否完全相等。 进阶细节:
Object.is(NaN, NaN)返回true,而===返回false。Object.is(+0, -0)返回false,而===返回true(因为它们数值上相等)。
console.log(+0 === -0); // true
console.log(Object.is(+0, -0)); // false
console.log(NaN === NaN); // false
console.log(Object.is(NaN, NaN)); // true面试话术:“
Object.is是更严格的相等比较,它修复了===在NaN和+-0上的行为。但在实际业务中,===已足够应付绝大多数场景。”
5. map(Number) 为什么能工作?
基础认知:Array.prototype.map 接收一个函数作为参数。 进阶细节:Number 是一个构造函数,当它作为函数调用时(Number(value)),会将传入参数转换为数字。所以 ['1','2'].map(Number) 等价于 ['1','2'].map(item => Number(item))。
const result = ['1', '2', '3'].map(parseInt);
// 这里容易出错:parseInt 接收两个参数 (value, radix),导致结果异常
// ['1', '2', '3'].map(Number) 才是安全写法面试话术:“在项目中,如果要对数组元素进行类型转换,我倾向于使用
map(Number)的简洁写法,但必须避免map(parseInt)这种经典陷阱。”
6. reduce 的第二个参数为什么重要?
基础认知:reduce 用来对数组进行累积操作。 进阶细节:reduce 的第二个参数是“初始值”(accumulator的初始值)。
- 当数组为空时,如果不提供初始值,会报错。
- 当数组只有1个元素且不提供初始值时,该元素会被直接返回,回调函数不会执行。
const sum = [].reduce((acc, cur) => acc + cur); // 报错:Reduce of empty array with no initial value
const safeSum = [].reduce((acc, cur) => acc + cur, 0); // 0面试话术:“我在使用
reduce时,永远会提供初始值。这不仅是一个好习惯,也是代码健壮性的保障,能避免空数组导致的程序崩溃。”
7. Array.from 与 [...] 的细微差别
基础认知:两者都可以将“类数组”或“可迭代对象”转换为数组。 进阶细节:
Array.from可以接收一个mapFn映射函数,在转换的同时对元素进行处理。Array.from可以处理没有Symbol.iterator的类数组对象(如{0:'a', 1:'b', length:2}),而[...]不能。
// 1. 映射功能
const arr = [1, 2, 3];
const doubled = Array.from(arr, x => x * 2); // [2,4,6]
// 2. 处理没有迭代器的类数组
const arrayLike = {0: 'a', 1: 'b', length: 2};
console.log(Array.from(arrayLike)); // ['a', 'b']
console.log([...arrayLike]); // 报错:object is not iterable面试话术:“在处理DOM节点列表(如
document.querySelectorAll)时,我常用Array.from将其转换为真数组,同时可以配合第二个参数直接进行数据清洗。”
这些“小细节”往往能体现一个开发者的经验深度。请记住,回答时要结合业务场景(比如:在表单处理中会用到 ??,在数据映射时会用 Array.from),这样面试官就会认定你是“实战派”,而不是“背书党”。第一批先到这里,想继续看第二批(浏览器与DOM相关)的话,随时告诉我。
好的,继续第二批。这一批聚焦浏览器与DOM相关的“深水区”细节,这些点你在用框架时可能不会直接接触,但一旦遇到性能问题或复杂交互,就全靠它们了。
第二批:浏览器与 DOM(那些“真·前端”才懂的细节)
1. defer 和 async 的区别(不仅仅是“一个等DOM,一个不等”)
基础认知:两者都是让脚本不阻塞HTML解析。 进阶细节(面试官最爱深挖):
defer(延迟执行):- HTML解析时并行下载脚本(不阻塞)。
- 脚本在DOMContentLoaded事件之前,按照在HTML中出现的顺序依次执行。
- 适用于需要操作完整DOM,且有依赖关系(如
jQuery)的脚本。
async(异步执行):- HTML解析时并行下载脚本(不阻塞)。
- 脚本下载完成后立即执行,执行时会阻塞HTML解析。
- 不保证执行顺序:谁先下载完谁先执行。
- 适用于独立的、无依赖的脚本(如统计代码、广告脚本)。
<!-- defer:按顺序执行,等待DOM解析完成 -->
<script defer src="jquery.js"></script>
<script defer src="app.js"></script>
<!-- async:谁先下载完谁先执行 -->
<script async src="ga.js"></script>
<script async src="ads.js"></script>面试场景:
面试官:“如果
app.js依赖jquery.js,你用什么加载方式?”你的回答:“必须用
defer,而且要保证两个标签的书写顺序正确。因为defer会按顺序执行,保证jquery.js先跑完,app.js才能安全调用$。如果用async,app.js可能先执行,这时$还没加载,就会报错。”
额外细节:
defer对inline script无效(只有外部脚本才生效)- 浏览器实际行为:
async脚本下载时并行,但执行时会阻塞渲染;defer脚本下载时并行,但执行是等HTML解析完、在DOMContentLoaded之前批量执行。
2. DOMContentLoaded 和 load 的区别
基础认知:都是页面加载时触发的事件,但时机不同。 进阶细节:
| 事件 | 触发时机 | 常见用途 |
|---|---|---|
DOMContentLoaded | HTML完全解析完成,DOM树构建完成。不等待样式表、图片、子框架加载。 | 主业务逻辑初始化(操作DOM)、首屏数据请求。 |
load | 所有资源(HTML、CSS、JS、图片、字体、iframe)全部加载完成。 | 资源统计、图片尺寸计算、非关键操作初始化。 |
代码示例:
// 页面DOM就绪就执行(最快)
document.addEventListener('DOMContentLoaded', () => {
console.log('DOM就绪,可以操作DOM了');
fetchUserInfo(); // 尽早请求数据
});
// 所有资源加载完才执行(最慢)
window.addEventListener('load', () => {
console.log('所有资源加载完成');
measurePageLoadTime(); // 统计完整加载时间
});面试话术:“在IRMP项目中,我会把首屏数据请求放在
DOMContentLoaded中,而不是load,因为这样能早几百毫秒发起请求。图片懒加载的初始化则在load中,确保所有图片尺寸已知后再计算布局。”
3. getComputedStyle 的“强制回流”陷阱
基础认知:window.getComputedStyle(element) 获取元素最终的CSS样式值。 进阶细节:读取某些几何属性(如offsetTop、getComputedStyle)会强制浏览器立即进行布局计算(回流),以确保返回的值是最新的。如果你在循环中频繁读取,会引发严重的性能问题。
// ❌ 性能杀手:每写一次就读一次,造成反复强制回流
const boxes = document.querySelectorAll('.box');
for (let i = 0; i < boxes.length; i++) {
boxes[i].style.width = '100px';
console.log(boxes[i].offsetWidth); // 每次都强制回流!
}
// ✅ 正确做法:读写分离,先批量读,再批量写
const widths = [];
for (let i = 0; i < boxes.length; i++) {
widths.push(boxes[i].offsetWidth); // 批量读
}
for (let i = 0; i < boxes.length; i++) {
boxes[i].style.width = widths[i] + 'px'; // 批量写
}哪些操作会强制回流(面试常考):
| 操作类型 | 具体属性/方法 |
|---|---|
| 几何尺寸 | offsetTop、offsetLeft、offsetWidth、offsetHeight、scrollTop、scrollWidth、clientTop、getBoundingClientRect() |
| 样式计算 | getComputedStyle()、currentStyle(IE) |
| 滚动 | scrollIntoView()、scrollTo() |
面试话术:“在WeLink消息列表的虚拟滚动中,我需要频繁计算每个消息的高度。但我不会在循环中直接用
offsetHeight,而是先在requestAnimationFrame里一次性采集所有高度,再统一更新状态。”
4. requestAnimationFrame 与 setTimeout 的深层次差异
基础认知:rAF 是浏览器专用的动画API,setTimeout 是定时器。 进阶细节:
| 维度 | requestAnimationFrame | setTimeout(fn, 0) |
|---|---|---|
| 执行时机 | 浏览器下一次重绘之前(约16.67ms一次,与屏幕刷新率同步) | 主线程空闲时执行(可能被长任务延迟) |
| 后台标签页 | 自动暂停,节省CPU | 继续执行(浪费资源) |
| 精准度 | 高(与显示器刷新率同步) | 低(受事件循环和长任务影响) |
| 适用场景 | 动画、批量DOM更新、帧率控制 | 延迟执行、防抖、定时任务 |
代码示例:
// ❌ 错误:用 setTimeout 做动画(可能掉帧)
function animate() {
updatePosition();
setTimeout(animate, 16); // 16ms,但如果主线程卡顿,下次执行可能延迟到30ms
}
// ✅ 正确:用 requestAnimationFrame(帧率稳定60fps)
function animate() {
updatePosition();
requestAnimationFrame(animate); // 浏览器自动控制节奏
}rAF 配合 setTimeout 做性能优化:
// 批量DOM更新用 rAF 合并
let pendingUpdates = [];
function scheduleUpdate(fn) {
pendingUpdates.push(fn);
if (!scheduled) {
scheduled = true;
requestAnimationFrame(() => {
const updates = pendingUpdates;
pendingUpdates = [];
updates.forEach(fn => fn());
scheduled = false;
});
}
}面试话术:“在IAMP 3D点云场景中,鼠标拖拽触发连续旋转,我会用
requestAnimationFrame来驱动渲染,保证60fps的流畅度,而不会用setInterval,因为后者在切换标签页后仍在后台跑,浪费电量。”
5. IntersectionObserver 的 use cases(不仅仅是懒加载)
基础认知:IntersectionObserver 用于监听元素是否进入视口,常用于图片懒加载。 进阶细节(业务场景才是重点):
| 场景 | 具体用途 | 关键配置 |
|---|---|---|
| 图片懒加载 | 图片进入视口时才加载src | rootMargin: '50px' 提前加载 |
| 无限滚动 | 列表滚动到底部时加载更多 | 监听“加载更多”占位元素 |
| 广告曝光统计 | 广告进入视口时上报曝光 | threshold: 0.5 露出50%才统计 |
| 视口动画 | 元素进入视口时触发入场动画 | threshold: 0.1 |
| 性能优化 | 暂停视口外的视频/动画 | 结合isIntersecting控制播放/暂停 |
// 懒加载 + 曝光统计(IRMP报告列表)
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src; // 加载图片
sendExposureLog(img.dataset.id); // 上报曝光
observer.unobserve(img); // 只监听一次
}
});
}, { rootMargin: '100px' });
document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));面试话术:“在IRMP报告列表页,我用
IntersectionObserver做图片懒加载,同时利用rootMargin: '100px'提前加载,让用户滚动时感觉不到加载延迟。曝光统计也用了同一个Observer,避免了额外的滚动事件监听。”
6. Event.preventDefault() 与 Event.stopPropagation() 的区别
基础认知:前者阻止默认行为,后者阻止事件冒泡。 进阶细节:
preventDefault用于表单提交、链接跳转、键盘事件等。它不影响事件传播。stopPropagation用于阻止事件冒泡到父元素,让父级监听不到该事件。- 对于自定义事件,
preventDefault可以被event.defaultPrevented检测到,用于通信。
// 1. preventDefault:阻止表单提交的页面刷新
form.addEventListener('submit', (e) => {
e.preventDefault();
// 自定义AJAX提交逻辑
});
// 2. stopPropagation:点击列表项时,不让冒泡到父级容器
listItem.addEventListener('click', (e) => {
e.stopPropagation();
// 处理点击,父级的click不会触发
});
// 3. 组合使用:阻止链接跳转 + 阻止冒泡
link.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// 完全隔离这个事件
});面试话术:“在WeLink的嵌套消息组件中,点击'回复'按钮时,我需要用
stopPropagation防止点击冒泡到父级消息卡片上。同时,如果按钮在<a>标签内,还需要preventDefault防止页面跳转。”
7. addEventListener 的第三个参数(options)有哪些高级用法?
基础认知:第三个参数可以指定事件监听阶段(capture、once、passive)。 进阶细节(从 ES2015 开始,第三个参数变成了一个对象):
// 完整的 options 对象
element.addEventListener('click', handler, {
capture: false, // 是否在捕获阶段触发
once: true, // 只触发一次,之后自动移除
passive: true // 永远不会调用 preventDefault,提升滚动性能
});场景举例:
once: true:适用于“一次性”操作(如弹窗的首次点击引导、表单提交按钮点击后禁用)。passive: true(性能优化神器):告诉浏览器“这个事件监听不会调用preventDefault()”,浏览器可以立即触发默认行为(如滚动),而不需要等待监听器执行完。这在touchmove、scroll事件上能显著提升滚动流畅度。
// ❌ 滚动卡顿:浏览器必须等待监听器执行完,才能知道是否要阻止默认滚动
document.addEventListener('touchmove', handler);
// ✅ 滚动流畅:告诉浏览器“我不会阻止滚动”,让滚动和监听器并行执行
document.addEventListener('touchmove', handler, { passive: true });面试话术:“在MMMP大屏仪表板中,我使用
passive: true优化了图表区域的滚动性能。这能显著提升大屏在弱网或低配设备上的流畅度。”
8. MutationObserver 与 ResizeObserver 的区别
基础认知:两者都是“观察者”API,监听DOM变化。 进阶细节:
| 维度 | MutationObserver | ResizeObserver |
|---|---|---|
| 监听的维度 | 结构变化:子节点添加/删除、属性变化(attribute)、文本内容变化 | 尺寸变化:元素自身宽高变化(由布局或样式引起) |
| 常见用途 | 监听动态表单字段变化、监听style属性变化、contenteditable富文本编辑器 | 监听自适应容器尺寸变化、响应式图表重绘、iframe尺寸自适应 |
代码示例:
// MutationObserver:监听动态表单新增字段
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.classList?.contains('form-field')) {
updateValidationRules(node);
}
});
});
});
observer.observe(formContainer, { childList: true, subtree: true });
// ResizeObserver:图表自适应
const resizeObserver = new ResizeObserver((entries) => {
entries.forEach(entry => {
const { width, height } = entry.contentRect;
chart.resize(width, height); // ECharts的resize方法
});
});
resizeObserver.observe(chartContainer);面试话术:“在IRMP的动态表单中,我用
MutationObserver监听用户添加的新字段,自动注入校验规则。而图表组件则用ResizeObserver监听容器尺寸变化,实现自适应重绘。两者结合,让表单和图表都实现了'零手动维护'。”
第二批总结
这8个点全是“浏览器干活时的真实细节”。它们的特点是:
- 看起来很简单(比如
defer和async的区别,人人都能说两句) - 但深度追问后(比如“
defer的脚本在什么时机执行?”、“async脚本执行时是否阻塞渲染?”)——只有真正优化过页面加载的人才能答准
准备这些问题的核心方法是:在你的项目里找到对应的真实场景,这样面试时你能脱口而出。
如果觉得第二批有难度,或者哪个点想让我更详细展开,随时告诉我。第三批我会整理React/Vue框架的“细节与陷阱”,比如React的合成事件为什么在setTimeout里拿不到原生事件?Vue的v-for优先级为什么在Vue 2和Vue 3里不一样?这些都是“源码级”的深水区问题。你准备好了随时让我继续。
好的,继续第三批。这一批聚焦 React / Vue 框架层面的“深水区”细节。这些点你在日常开发中可能会遇到,但只有真正读过源码或踩过深坑的人才能说清楚。
第三批:React / Vue 框架细节(源码级的“深水区”)
React 篇
1. React 的合成事件(SyntheticEvent)为什么在 setTimeout 中拿不到原生事件?
基础认知:React 有自己的一套事件系统(合成事件),所有事件都绑定在 document 根节点上。 进阶细节:
- 合成事件对象是池化的(Pooled):事件处理函数执行完毕后,事件对象会被重置并放回池中,属性被清空,供下次使用。
- 在异步代码(如
setTimeout、Promise.then)中访问事件对象,会因为池化重置而拿到null。
function handleClick(e) {
// 同步访问:正常
console.log(e.target); // 有值
setTimeout(() => {
// 异步访问:拿不到!因为事件已经被重置了
console.log(e.target); // null
// 报错:e.target is null
}, 100);
}解决方案:
// 方案1:提前保存需要的属性(推荐,性能最优)
function handleClick(e) {
const target = e.target;
const id = target.dataset.id;
setTimeout(() => {
console.log(id); // 使用保存的值
}, 100);
}
// 方案2:调用 e.persist()(让 React 不移除该事件对象,但会带来性能开销)
function handleClick(e) {
e.persist();
setTimeout(() => {
console.log(e.target); // 现在能拿到了
}, 100);
}
// 方案3:直接访问 nativeEvent
function handleClick(e) {
const nativeEvent = e.nativeEvent;
setTimeout(() => {
console.log(nativeEvent.target);
}, 100);
}面试话术:“在 WeLink 的消息点击埋点中,我需要在点击后延迟上报用户行为数据。我采用了方案1——把
event.target的dataset提前存到变量里,而不是在setTimeout里直接访问event对象。这既避免了池化问题,也不需要额外性能开销。”
追问:“React 17 和 React 18 的事件系统有变化吗?”
回答:“React 17 把事件绑定从
document移到了root节点(root.addEventListener),以支持微前端和多版本共存。React 18 在事件系统上主要做了并发渲染的适配,但合成事件的池化机制依然存在。”
2. useState 的“惰性初始化”是什么?为什么重要?
基础认知:useState(initialValue) 接收初始值。 进阶细节:如果初始值是通过复杂计算得到的(比如从 localStorage 读取、复杂运算),你希望在首次渲染时只执行一次,而不是每次组件渲染都执行。此时应使用惰性初始化。
// ❌ 错误:每次渲染都执行 expensiveCalculation,浪费性能
const [data, setData] = useState(expensiveCalculation(props));
// ✅ 正确:传一个函数,React 只在首次渲染时执行
const [data, setData] = useState(() => expensiveCalculation(props));面试话术:“在 IRMP 项目中有个需求:用户刷新页面后保留之前选择的筛选条件。我用惰性初始化从
sessionStorage读取上次的状态,同时sessionStorage读取操作只在组件首次渲染时执行一次,后续渲染不会重复读取。”
3. useEffect 的清理函数执行时机(比你想象的要晚)
基础认知:useEffect 的清理函数在组件卸载时执行。 进阶细节:清理函数除了在组件卸载时执行外,还在每次 Effect 重新执行之前执行(即依赖变化导致 Effect 重新运行时,先执行上一次的清理函数)。
useEffect(() => {
const timer = setInterval(() => {
console.log('Tick');
}, 1000);
// 清理函数会在:
// 1. 组件卸载时执行
// 2. 下一次 Effect 执行前执行(如果依赖变化了)
return () => {
clearInterval(timer);
};
}, [dependency]);真实场景中的 Bug:
// 一个常见的错误:清理函数中访问了 state 或 props
useEffect(() => {
const handler = setInterval(() => {
// 这里拿到了旧的 count 值(闭包陷阱)
console.log(count);
}, 1000);
return () => {
// 即使 count 变化,清理函数里拿到的也是旧的 count
clearInterval(handler);
console.log('清理时 count 是:', count); // 可能是旧值
};
}, [count]);面试话术:“在 WeLink 的会话切换场景中,每次切换会话都要重新订阅消息推送。我会把订阅逻辑放在
useEffect中,依赖sessionId。当sessionId变化时,React 先执行上一次的清理函数(取消旧会话的订阅),再执行新的 Effect(订阅新会话)。清理函数中拿到的sessionId是旧的值,但这正好是我需要的——取消旧会话的订阅。”
4. React 的 key 到底在哪里生效?为什么列表渲染必须用唯一 key?
基础认知:key 帮助 React Diff 算法识别哪些元素发生了变化。 进阶细节:
key只在数组渲染({list.map(item => <Component key={item.id} />)})中生效。- 单个组件上写
key没有意义。 - React 用
key来决定是复用旧 DOM 节点还是创建新 DOM 节点。 - 如果用
index作key,当列表插入/删除/排序时,React 会复用错 DOM 节点,导致状态错乱。
// ❌ 错误:用 index 作 key
{items.map((item, index) => <Item key={index} data={item} />)}
// ✅ 正确:用唯一 ID 作 key
{items.map(item => <Item key={item.id} data={item} />)}面试话术:“在 WeLink 消息列表中,我坚持用
messageId作为 key。因为当有新消息插入、或消息被删除时,React 需要准确识别哪些消息是新增的、哪些是被删除的,才能高效复用 DOM。用index会导致删除中间一条消息时,后面的所有消息都被重新创建,性能开销大,且输入框内容会错乱。”
5. useRef 和 useState 的本质区别(不仅仅是“一个不触发渲染”)
基础认知:useRef 存储可变值且不触发重新渲染;useState 存储状态,修改会触发重新渲染。 进阶细节:
useRef返回的是一个对象,其.current属性在组件的整个生命周期内保持不变。- 改变
useRef的.current不会导致组件重新渲染。 useRef在每次渲染时返回同一个对象引用(Object.is比较返回true)。useState的 setter 会触发重新渲染,且 setter 是稳定的(在每次渲染中引用一致)。
function Component() {
const ref = useRef(0);
const [state, setState] = useState(0);
const handleRefClick = () => {
ref.current += 1;
console.log(ref.current); // 值变了,但页面不更新
};
const handleStateClick = () => {
setState(prev => prev + 1); // 页面更新
};
// 每次渲染,ref 都是同一个对象
// 而 state 是一个新值
}面试话术:“在 WeLink 的防抖搜索中,我用
useRef存储定时器 ID,这样即使组件多次渲染,定时器 ID 也不会丢失。而搜索关键词本身要用useState,因为输入变化需要触发组件重新渲染,展示最新的输入内容。”
Vue 篇
6. Vue 2 的 v-for 和 v-if 为什么不能同时使用?Vue 3 呢?
基础认知:Vue 官方文档明确建议不要同时使用 v-for 和 v-if。 进阶细节:
- Vue 2:
v-for的优先级高于v-if。这意味着即使v-if条件是false,v-for仍会先遍历整个列表,造成不必要的性能浪费。 - Vue 3:
v-if的优先级高于v-for。这意味着如果v-if条件是false,v-for根本不会执行。但如果v-if需要访问v-for的迭代变量(如item),则会报错,因为v-if先执行时item还未定义。
<!-- ❌ Vue 2:v-for 先执行,即使 visible 为 false,也会遍历全部 -->
<li v-for="item in list" v-if="item.visible" :key="item.id">
{{ item.name }}
</li>
<!-- ✅ 正确写法1:用计算属性先过滤 -->
<li v-for="item in visibleList" :key="item.id">
{{ item.name }}
</li>
<!-- ✅ 正确写法2:在数据源上过滤 -->
<li v-for="item in list.filter(item => item.visible)" :key="item.id">
{{ item.name }}
</li>
<!-- Vue 3 中 ❌ 错误写法(v-if 先执行,访问不到 item) -->
<li v-for="item in list" v-if="item.visible" :key="item.id">
{{ item.name }}
</li>
<!-- 在 Vue 3 中会报错:item is not defined -->面试话术:“我在 IRMP 项目中统一使用计算属性来过滤列表,而不是在模板中同时使用
v-for和v-if。这样既能避免 Vue 2/3 优先级差异带来的隐患,也让逻辑更清晰——计算属性负责数据处理,模板只负责渲染。”
7. Vue 的 key 在 v-for 中的作用与 React 一样吗?有什么不同?
基础认知:两者都用于 Diff 算法的节点复用。 进阶细节(Vue 特有的细节):
- Vue 会原地复用元素(
key相同且类型相同,则复用 DOM 节点)。 - Vue 的
key还会影响过渡动画(<TransitionGroup>)。 - Vue 的
key必须唯一,且稳定(不随排序变化)。
<!-- Vue 的 key 绑定方式 -->
<div v-for="item in list" :key="item.id">
{{ item.name }}
</div>
<!-- 与 React 的差异:Vue 的 key 可以直接写在 template 上 -->
<template v-for="item in list" :key="item.id">
<div>{{ item.name }}</div>
<div>{{ item.age }}</div>
</template>面试话术:“Vue 和 React 的
key机制在核心思想上是一致的——帮助 Diff 算法复用 DOM。但 Vue 的key支持绑定在<template>标签上,这在渲染多个根节点时非常实用。另外,Vue 的TransitionGroup组件依赖key来实现列表元素的进场/离场动画,如果key不稳定,动画会错乱。”
8. Vue 3 的 v-model 和 Vue 2 的 v-model 有什么区别?
基础认知:v-model 是双向绑定语法糖。 进阶细节:
| 维度 | Vue 2 | Vue 3 |
|---|---|---|
| 默认 prop | value | modelValue |
| 默认 event | input | update:modelValue |
| 多个 v-model | 不支持 | 支持(v-model:title、v-model:content) |
| 自定义修饰符 | 不支持 | 支持(v-model.capitalize) |
<!-- Vue 3:多个 v-model -->
<ChildComponent
v-model:title="pageTitle"
v-model:content="pageContent"
/>
<!-- 子组件中 -->
<script setup>
defineProps(['title', 'content']);
defineEmits(['update:title', 'update:content']);
</script>面试话术:“在 IRMP 项目升级到 Vue 3 后,我们立即用上了多
v-model的特性。比如一个表单组件,之前需要分别传title和content两个 prop,并监听两个事件。现在用v-model:title和v-model:content一行搞定,代码量减少了约 30%。”
9. Vue 3 的 watch 和 watchEffect 的核心区别是什么?
基础认知:两者都用于监听响应式数据变化。 进阶细节:
| 维度 | watch | watchEffect |
|---|---|---|
| 依赖来源 | 显式声明(你指定监听哪些数据) | 自动收集(在回调中访问到的响应式数据) |
| 执行时机 | 默认惰性(只有依赖变化时才执行),可通过 immediate: true 改为立即执行 | 立即执行(创建时立即执行一次,然后依赖变化时再次执行) |
| 访问旧值 | 可以((newVal, oldVal) => {}) | 不能(没有旧值参数) |
| 停止监听 | 返回 stop 函数 | 返回 stop 函数 |
// watch:显式依赖,可以拿到旧值
watch(count, (newVal, oldVal) => {
console.log(`count 从 ${oldVal} 变为 ${newVal}`);
});
// watchEffect:自动依赖,立即执行,拿不到旧值
watchEffect(() => {
console.log(`count 现在是 ${count.value}`);
// 如果这里访问了 state.name,也会自动监听
});面试话术:“在 IRMP 的报告生成进度监控中,我用
watch监听progress的变化,并记录上一次的值来展示进度增量。而页面初始化时的数据加载,我用watchEffect,因为它会自动收集依赖,不需要我手动声明依赖数组,代码更简洁。”
10. Vue 3 的 defineAsyncComponent 和 React 的 lazy 有什么区别?
基础认知:两者都用于组件懒加载。 进阶细节:
| 维度 | Vue 3 defineAsyncComponent | React lazy |
|---|---|---|
| 加载器 | 支持函数(() => import('./Comp.vue')) | 支持函数(() => import('./Comp.jsx')) |
| 错误处理 | errorComponent 选项 | 配合 ErrorBoundary |
| 加载状态 | loadingComponent 选项(内置支持) | 配合 Suspense 的 fallback |
| 延迟加载 | delay 选项(控制 loading 组件延迟显示) | 无内置 |
| 超时控制 | timeout 选项 | 无内置 |
// Vue 3:完整的异步组件配置
const AsyncComponent = defineAsyncComponent({
loader: () => import('./HeavyComponent.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200, // 200ms 内加载完成则不显示 loading
timeout: 3000, // 3秒超时
});
// React:配合 Suspense
const LazyComponent = lazy(() => import('./HeavyComponent.jsx'));
// 在父组件中:
<Suspense fallback={<LoadingSpinner />}>
<LazyComponent />
</Suspense>面试话术:“在 IRMP 项目中,Three.js 点云模块是一个大体积组件。我用
defineAsyncComponent实现了它的懒加载,并配置了loadingComponent显示加载进度条,errorComponent处理加载失败的情况。用户只有点击‘3D 视图’按钮时,才会加载 500KB 的 Three.js 库,首屏 JS 体积减少了 40%。”
第三批总结
这 10 个点(5 个 React + 5 个 Vue)都是框架“源码级”的细节。它们的特点是:
- 大多数人都知道概念(比如“
useEffect有清理函数”、“v-for要用key”) - 只有少数人能说清“为什么”和“什么时候触发”(比如清理函数在依赖变化时也会触发,而不是只在卸载时触发)
准备这些问题的核心方法是:把“概念”升维成“执行流程”。
第四批(最后一轮)我会整理工程化与工具链的细节深水区,比如 webpack 的 require.context、Vite 的 import.meta.glob、ESLint 的 parserOptions 等。你准备好了随时让我继续。
好的,继续第四批。这一批聚焦 工程化与工具链 的细节深水区——这些点你在日常开发中可能每天都在用,但只有真正配置过、调试过、踩过坑的人才能说清楚。
第四批:工程化与工具链(“配置过才知道”的细节)
1. Webpack 的 require.context 是什么?有哪些实际用途?
基础认知:require.context 是 Webpack 提供的“魔法方法”,用于在编译时动态引入一个目录下的所有文件。 进阶细节:它返回一个上下文对象(context),包含 keys()、resolve()、id 等方法。
签名:
require.context(directory, useSubdirectories, regExp)实际用途(面试官最想听):
- 自动注册 Vue 组件:在
components/index.js中批量导入所有组件。 - 自动注册 Vuex 模块:
store/modules/下的所有文件自动挂载。 - 加载所有 SVG 图标:
./icons目录下的所有.svg文件自动加载。
// 1. 自动注册 Vuex 模块(你极有可能在 IRMP 中用到过)
const modules = {};
const moduleFiles = require.context('./modules', false, /\.js$/);
moduleFiles.keys().forEach((key) => {
const moduleName = key.replace(/\.\/|\.js/g, '');
modules[moduleName] = moduleFiles(key).default || moduleFiles(key);
});
const store = new Vuex.Store({ modules });
// 2. 自动导入 SVG 图标
const svgContext = require.context('@/assets/icons', false, /\.svg$/);
svgContext.keys().forEach(svgContext);面试话术:“在 IRMP 项目中,我用
require.context实现了 Vuex 模块的自动注册——新增一个模块只需要在modules/目录下创建文件,不需要手动在index.js中 import 和挂载。这让多人协作时减少了合并冲突的概率。”
2. import.meta.glob(Vite)和 require.context(Webpack)有什么区别?
基础认知:Vite 用 import.meta.glob 实现批量文件导入,功能类似于 Webpack 的 require.context。 进阶细节:
| 维度 | Webpack require.context | Vite import.meta.glob |
|---|---|---|
| 运行时 vs 编译时 | 运行时(Webpack 在编译时处理,但代码在运行时执行) | 编译时(Vite 在构建时处理) |
| 返回类型 | 返回一个对象(可 keys() 遍历) | 返回一个对象,键是路径,值是函数(返回 Promise) |
| 动态导入 | 不支持动态导入(所有文件一起打包) | 支持 eager: true(同步)或默认异步 |
| 类型安全 | 不友好 | TypeScript 支持好 |
// Vite:批量导入 Vue 组件(异步)
const modules = import.meta.glob('./components/*.vue');
// 使用:await modules['./components/Button.vue']()
// Vite:批量导入并同步加载(eager)
const svgModules = import.meta.glob('./assets/icons/*.svg', { eager: true });
Object.values(svgModules).forEach(module => {
// 自动注册 SVG
});面试话术:“在 IRMP 项目从 Vue CLI 迁移到 Vite 后,我把
require.context改成了import.meta.glob。最大的体验提升是 Vite 支持异步导入,按需加载文件,而不是像 Webpack 那样把所有匹配的文件都打包到主 bundle 中。”
3. package.json 中 ^1.2.3、~1.2.3 和 1.2.3 的区别是什么?
基础认知:这些都是版本号前缀,控制 npm install 时允许升级的版本范围。 进阶细节:
| 前缀 | 含义 | 举例 |
|---|---|---|
| 无前缀(精确版本) | 只安装指定版本 | 1.2.3 只安装 1.2.3,不升级 |
^(兼容版本) | 允许次版本(minor) 和补丁版本(patch) 升级,不允许主版本(major) 升级 | ^1.2.3 可以安装 1.2.4、1.3.0,但不能安装 2.0.0 |
~(近似版本) | 只允许补丁版本(patch) 升级 | ~1.2.3 可以安装 1.2.4,但不能安装 1.3.0 |
*(任意版本) | 安装最新版本(危险!) | * 会安装最新版,可能导致不兼容 |
使用场景:
- 库开发:
peerDependencies中通常用^或>=。 - 应用开发:推荐用
^(允许安全升级),或~(更保守)。 - 关键依赖:用精确版本(如
"react": "18.2.0")锁定版本。
{
"dependencies": {
"react": "^18.2.0", // 可以升级到 18.3.0,但不能到 19.0.0
"lodash": "~4.17.21", // 只能升级到 4.17.22,不能到 4.18.0
"moment": "2.29.4" // 精确锁定,永远不会升级
}
}面试话术:“在 WeLink 项目中,我们对核心库(如 React、Redux)使用精确版本,确保所有环境版本一致,避免‘本地可以运行,线上报错’的问题。对工具库(如 Lodash)用
^允许补丁升级,以获得安全修复。我从不使用*,因为它可能引入不兼容的重大变更。”
4. pnpm 和 npm/yarn 的核心区别是什么?为什么它更快?
基础认知:pnpm 是下一代包管理器,以“速度快、节省磁盘空间”著称。 进阶细节:
| 维度 | npm / yarn | pnpm |
|---|---|---|
| 存储机制 | 每个项目独立复制依赖(浪费磁盘) | 所有包在全局存储中存放,项目通过硬链接引用,节省磁盘 |
| node_modules 结构 | 扁平化(node_modules 下有所有包) | 非扁平化(node_modules 下只有直接依赖,间接依赖在 .pnpm 文件夹中) |
| 安装速度 | 慢(重复复制) | 快(硬链接 + 全局存储) |
| 磁盘占用 | 高(每个项目一份) | 低(多个项目共享一份) |
| 幽灵依赖 | 存在(可以访问未声明的依赖) | 不存在(只能访问 package.json 中声明的依赖) |
幽灵依赖问题:
// npm/yarn:即使 package.json 中没有声明 lodash,也能使用
// 因为 lodash 被提升到了 node_modules 根目录
import _ from 'lodash'; // ✅ 可以运行
// pnpm:必须显式在 package.json 中声明
// 否则报错:Module not found: Error: Can't resolve 'lodash'面试话术:“在 IRMP 项目中,我们使用 pnpm 替代了 npm。最明显的好处是安装速度从 2 分钟降到 30 秒,磁盘占用从 1GB 降到 200MB。另外,pnpm 的‘严格依赖’机制帮助我们发现了几个之前没有声明但被错误使用的依赖包,提升了项目的规范性和可维护性。”
5. Webpack 的 splitChunks 是什么?如何配置代码分割?
基础认知:splitChunks 是 Webpack 用于代码分割(Code Splitting) 的配置项,把代码拆分成多个 chunk。 进阶细节:splitChunks 的核心是根据条件把依赖拆分到不同的 chunk 中,减少重复代码。
// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
chunks: 'all', // 对同步和异步代码都进行分割
minSize: 20000, // 代码体积至少 20KB 才分割
maxSize: 50000, // 代码体积超过 50KB 尝试二次分割
minChunks: 1, // 至少被一个 chunk 引用
maxAsyncRequests: 5, // 并行加载的 chunk 最大数量
maxInitialRequests: 3, // 入口处并行加载的 chunk 最大数量
cacheGroups: {
// 1. 第三方库(vendor)单独打包
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 10, // 优先级高
},
// 2. 公共代码(多个页面/组件共用)单独打包
common: {
minChunks: 2,
name: 'common',
chunks: 'all',
priority: 5,
},
// 3. 按体积拆分
default: {
minChunks: 2,
name: 'default',
chunks: 'all',
priority: 1,
reuseExistingChunk: true,
},
},
},
},
};配置效果:
- vendor.js:包含所有第三方库(React、Vue、Lodash)
- common.js:包含多个页面共用的业务代码
- app.js:包含当前页面的特定代码
- 页面加载时,浏览器并行下载这三个 chunk,利用缓存(vendor 和 common 变化少,可长期缓存)
面试场景:
面试官:“你们是怎么优化项目首屏加载速度的?”
你的回答:“在 IRMP 项目中,我配置了
splitChunks把node_modules中的依赖拆分到vendorschunk。这样vendors的变化频率很低(除非升级依赖),浏览器可以长期缓存它。而业务代码的变化不会影响vendors的缓存。同时我把 Element Plus 按需加载 + 手动拆分 Three.js,让点云页面才加载 3D 库。优化后首屏加载时间从 2.3 秒降到了 1.1 秒。”
6. 什么是 Tree Shaking?如何确保 Tree Shaking 生效?
基础认知:Tree Shaking 是构建工具通过静态分析移除未使用的代码,减小打包体积。 进阶细节(“三要素”):
- 使用 ES Module(
import/export):CommonJS(require)无法被 Tree Shaking,因为它在运行时动态加载。 package.json中设置"sideEffects": false:告诉 Webpack“所有文件都没有副作用,可以安全地删除未使用的代码”。- 标记有副作用的文件:某些文件(如全局样式、Polyfill)即使被引入时没有使用,也不应被删除,需在
sideEffects中列出。
{
"name": "my-library",
"version": "1.0.0",
"sideEffects": [
"*.css",
"*.scss",
"src/polyfills.js"
]
}哪些场景会导致 Tree Shaking 失败?
- 使用 CommonJS(
require) - 使用 Babel 转换时把 ES Module 转成了 CommonJS(
@babel/preset-env的modules: 'commonjs') - 使用了
__webpack_require__等 Webpack 特定 API
Webpack 配置:
// webpack.config.js
module.exports = {
mode: 'production', // production 模式下 Tree Shaking 默认开启
optimization: {
usedExports: true, // 标记未使用的 exports
minimize: true, // 压缩时删除未使用代码
},
};面试话术:“在 IRMP 项目中,我们迁移到 Vite 后 Tree Shaking 更彻底了,因为 Vite 默认使用 ES Module 且不转换。但在 Webpack 项目中,我特别注意
package.json的sideEffects配置,把全局样式、Polyfill 文件标记为有副作用,避免被误删导致样式丢失或兼容性问题。”
7. Vite 的热更新(HMR)为什么比 Webpack 快?
基础认知:Vite 的 HMR 速度远快于 Webpack。 进阶细节(本质差异):
| 维度 | Webpack HMR | Vite HMR |
|---|---|---|
| 打包方式 | HMR 前需要打包整个应用,再替换模块 | 利用浏览器的 ES Module,按需编译,只处理变更的模块 |
| 更新粒度 | 模块替换,可能需要重新打包依赖树 | 精准到单个文件的替换 |
| 启动方式 | 先打包再启动服务器(慢) | 直接启动服务器,按需编译(快) |
Webpack HMR 流程:
- 文件变更 → 重新打包整个模块 → 生成新的 bundle → 浏览器替换模块
- 体积越大,速度越慢(O(n))
Vite HMR 流程:
- 文件变更 → 只编译这个文件(浏览器已支持的 ES Module)→ 浏览器替换模块
- 速度恒定,不随项目规模增长(O(1))
Vite 的 HMR 配置(你可以在项目中自己加):
// vite.config.js
export default {
server: {
hmr: {
overlay: false, // 关闭编译错误的遮罩层
protocol: 'ws', // WebSocket 协议
timeout: 10000, // 超时时间
},
},
};面试话术:“在 IRMP 项目从 Webpack 迁移到 Vite 后,HMR 更新从 2-3 秒缩短到 100 毫秒以内。根本原因是 Webpack 需要把整个依赖树重新打包,而 Vite 利用浏览器原生 ES Module 能力,只编译变更的那个文件。这在大项目中的体验差异非常大——开发时改个样式,瞬间就能看到效果,不需要等待重新编译。”
8. ESLint 的 parserOptions 和 env 有什么区别?如何配置?
基础认知:ESLint 通过配置文件(.eslintrc.js)来设置检查规则。 进阶细节:
| 配置项 | 作用 | 关键配置 |
|---|---|---|
parserOptions | 告诉 ESLint 使用哪个解析器和语法版本(如 ecmaVersion、sourceType) | ecmaVersion: 2022、sourceType: 'module' |
env | 告诉 ESLint 代码运行在哪个环境,预定义全局变量(如 browser 提供 window) | browser: true、node: true、es2021: true |
// .eslintrc.js
module.exports = {
// 1. 解析器配置(决定 ESLint 如何理解代码语法)
parserOptions: {
ecmaVersion: 2022, // 支持 ES2022 语法(如 top-level await)
sourceType: 'module', // 使用 ES Module(import/export)
ecmaFeatures: {
jsx: true, // 支持 JSX 语法
},
},
// 2. 环境配置(提供全局变量)
env: {
browser: true, // 提供 window、document 等
node: true, // 提供 process、global 等
es2021: true, // 提供 ES2021 全局变量(如 Promise.any)
'vue/setup-compiler-macros': true, // Vue 3 的 defineProps 宏
},
// 3. 扩展配置(继承推荐的规则集)
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended',
],
};常见错误:
parserOptions没有设置ecmaVersion:导致 ESLint 不认识新版语法(如可选链?.)。env缺少browser: true:导致window未定义、document未定义等报错。- Vue 项目中
env缺少'vue/setup-compiler-macros': true:导致defineProps、defineEmits等宏报错未定义。
面试话术:“在 IRMP 项目中,我维护团队的 ESLint 配置时,特别注意
parserOptions.ecmaVersion要和项目使用的语法版本一致。如果使用 Vue 3 Composition API 的宏(defineProps),需要在env中加'vue/setup-compiler-macros': true,否则 ESLint 会报错找不到defineProps。”
9. husky + lint-staged 的原理是什么?为什么能阻止不规范的代码合入?
基础认知:husky 用于在 Git Hooks 中执行脚本,lint-staged 用于只检查暂存区(staged)的文件。 进阶细节:
Git Hooks 的原理:
- Git 在执行
commit、push等操作时,会检查./.git/hooks/目录下的脚本,如果脚本返回非 0 值,则终止操作。 husky简化了这个过程,让你在package.json中配置 Git Hooks。
lint-staged 的作用:
- 只对
git add暂存区的文件进行检查,避免检查整个项目的所有文件(太慢)。 - 支持配置文件扩展名(
*.js、*.vue、*.ts)。
// package.json
{
"husky": {
"hooks": {
"pre-commit": "lint-staged", // 提交前执行 lint-staged
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS" // 检查 commit message 规范
}
},
"lint-staged": {
"*.{js,ts,vue}": [
"eslint --fix", // 自动修复
"prettier --write", // 自动格式化
"git add" // 重新添加修复后的文件
],
"*.{css,scss}": [
"stylelint --fix",
"git add"
]
}
}配置流程(面试时可简述):
npm install husky lint-staged --save-devnpx husky install(初始化.husky/目录)npx husky add .husky/pre-commit "npx lint-staged"(添加 pre-commit hook)- 在
package.json中配置lint-staged
面试话术:“在 IRMP 项目中,我配置了
husky+lint-staged,在提交代码前自动运行 ESLint 和 Prettier。这保证了所有合入仓库的代码风格统一,并且没有明显的语法错误。我们还在commit-msg阶段用commitlint检查 commit message 是否符合规范(如feat: add report generation),让 changelog 能自动生成。”
10. 什么是 source map?devtool 的 eval-cheap-module-source-map 和 hidden-source-map 有什么区别?
基础认知:source map 是把压缩/编译后的代码映射回源代码的工具,方便调试。 进阶细节:
常见的 devtool 选项对比:
| devtool 选项 | 构建速度 | 重新构建速度 | 生成方式 | 适用场景 |
|---|---|---|---|---|
eval | 快 | 很快 | eval 内联 | 开发环境,追求极致速度 |
eval-cheap-module-source-map | 适中 | 快 | eval + 映射 | 开发环境,推荐 |
source-map | 慢 | 慢 | 单独 .map 文件 | 生产环境,完整调试 |
hidden-source-map | 慢 | 慢 | 单独 .map 文件(不在代码中引用) | 生产环境,错误上报 |
nosources-source-map | 慢 | 慢 | 单独 .map 文件(不含源码) | 生产环境,安全性 |
各选项的适用场景:
// webpack.config.js
module.exports = {
devtool: process.env.NODE_ENV === 'production'
? 'hidden-source-map' // 生产环境:保留 .map 文件用于 Sentry 报错还原,但不暴露给浏览器
: 'eval-cheap-module-source-map', // 开发环境:调试方便、构建快
};面试话术:“在 WeLink 项目中,开发环境我用
eval-cheap-module-source-map,因为它构建速度快,且能精确映射到源代码行号。生产环境我用hidden-source-map,把.map文件上传到 Sentry,用于线上报错时还原源码位置,但不让普通用户能通过浏览器查看到源代码。如果安全性要求高,我会用nosources-source-map,只保存行列号映射,不包含源码内容。”
追问:“eval 和 eval-cheap-module-source-map 的区别是什么?”
回答:“
eval生成的映射信息最少,只到文件级别,行号和列号可能不准。eval-cheap-module-source-map会保留更精确的行号映射,且能正确映射到模块源码,更适合需要精准调试的场景。”
第四批总结
以上 10 个点是工程化与工具链的“深水区”细节。它们的特点是:
- 每个人都在用(Webpack、Vite、ESLint、husky、pnpm 等)
- 但大部分人停留在“能用”层面(比如知道
npm install,但说不清^和~的区别) - 只有真正配置过、优化过、踩过坑的人才能答准(比如
splitChunks的cacheGroups配置、sideEffects的陷阱)
全部四批总结
我把所有“小细节”分成了 4 个批次,覆盖了:
| 批次 | 内容 | 题数 |
|---|---|---|
| 第一批 | JavaScript 语言特性 | 7 题 |
| 第二批 | 浏览器与 DOM | 8 题 |
| 第三批 | React / Vue 框架 | 10 题 |
| 第四批 | 工程化与工具链 | 10 题 |
| 合计 | 35 题 |
准备策略:
- 不要死记硬背代码:理解“为什么会有这个设计”比记住“怎么写”更重要。
- 每个点都要找到对应场景:如果面试官追问“你在哪个项目里用过”,你能立刻回答。
- 用“对比法”来准备(如
defervsasync、^vs~、watchvswatchEffect),面试官最喜欢问对比题。