Skip to content

主管深挖细节一


第一批:JavaScript 语言特性(那些“看起来很简单”的细节)

1. ?? (空值合并运算符) 与 || (逻辑或) 的区别

基础认知:二者都用于提供默认值。 进阶细节

  • || 会忽略所有假值(Falsy),包括 0''falseNaN,在这些值时会使用默认值。
  • ?? 只忽略 nullundefined当变量的值为 0'' 时,?? 会保留原值。

面试场景

javascript
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()

面试场景

javascript
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(因为它们数值上相等)。
javascript
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))

javascript
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个元素且不提供初始值时,该元素会被直接返回,回调函数不会执行。
javascript
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}),而 [...] 不能。
javascript
// 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. deferasync 的区别(不仅仅是“一个等DOM,一个不等”)

基础认知:两者都是让脚本不阻塞HTML解析。 进阶细节(面试官最爱深挖)

  • defer(延迟执行)

    • HTML解析时并行下载脚本(不阻塞)。
    • 脚本在DOMContentLoaded事件之前,按照在HTML中出现的顺序依次执行。
    • 适用于需要操作完整DOM,且有依赖关系(如jQuery)的脚本。
  • async(异步执行)

    • HTML解析时并行下载脚本(不阻塞)。
    • 脚本下载完成后立即执行,执行时会阻塞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才能安全调用$。如果用asyncapp.js可能先执行,这时$还没加载,就会报错。”

额外细节

  • deferinline script 无效(只有外部脚本才生效)
  • 浏览器实际行为:async 脚本下载时并行,但执行时会阻塞渲染;defer 脚本下载时并行,但执行是等HTML解析完、在DOMContentLoaded之前批量执行。

2. DOMContentLoadedload 的区别

基础认知:都是页面加载时触发的事件,但时机不同。 进阶细节

事件触发时机常见用途
DOMContentLoadedHTML完全解析完成,DOM树构建完成。不等待样式表、图片、子框架加载主业务逻辑初始化(操作DOM)、首屏数据请求。
load所有资源(HTML、CSS、JS、图片、字体、iframe)全部加载完成。资源统计、图片尺寸计算、非关键操作初始化。

代码示例

javascript
// 页面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样式值。 进阶细节:读取某些几何属性(如offsetTopgetComputedStyle)会强制浏览器立即进行布局计算(回流),以确保返回的值是最新的。如果你在循环中频繁读取,会引发严重的性能问题。

javascript
// ❌ 性能杀手:每写一次就读一次,造成反复强制回流
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'; // 批量写
}

哪些操作会强制回流(面试常考)

操作类型具体属性/方法
几何尺寸offsetTopoffsetLeftoffsetWidthoffsetHeightscrollTopscrollWidthclientTopgetBoundingClientRect()
样式计算getComputedStyle()currentStyle(IE)
滚动scrollIntoView()scrollTo()

面试话术:“在WeLink消息列表的虚拟滚动中,我需要频繁计算每个消息的高度。但我不会在循环中直接用offsetHeight,而是先在requestAnimationFrame里一次性采集所有高度,再统一更新状态。”

4. requestAnimationFramesetTimeout 的深层次差异

基础认知rAF 是浏览器专用的动画API,setTimeout 是定时器。 进阶细节

维度requestAnimationFramesetTimeout(fn, 0)
执行时机浏览器下一次重绘之前(约16.67ms一次,与屏幕刷新率同步)主线程空闲时执行(可能被长任务延迟)
后台标签页自动暂停,节省CPU继续执行(浪费资源)
精准度高(与显示器刷新率同步)低(受事件循环和长任务影响)
适用场景动画、批量DOM更新、帧率控制延迟执行、防抖、定时任务

代码示例

javascript
// ❌ 错误:用 setTimeout 做动画(可能掉帧)
function animate() {
    updatePosition();
    setTimeout(animate, 16); // 16ms,但如果主线程卡顿,下次执行可能延迟到30ms
}

// ✅ 正确:用 requestAnimationFrame(帧率稳定60fps)
function animate() {
    updatePosition();
    requestAnimationFrame(animate); // 浏览器自动控制节奏
}

rAF 配合 setTimeout 做性能优化

javascript
// 批量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 用于监听元素是否进入视口,常用于图片懒加载。 进阶细节(业务场景才是重点)

场景具体用途关键配置
图片懒加载图片进入视口时才加载srcrootMargin: '50px' 提前加载
无限滚动列表滚动到底部时加载更多监听“加载更多”占位元素
广告曝光统计广告进入视口时上报曝光threshold: 0.5 露出50%才统计
视口动画元素进入视口时触发入场动画threshold: 0.1
性能优化暂停视口外的视频/动画结合isIntersecting控制播放/暂停
javascript
// 懒加载 + 曝光统计(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 检测到,用于通信。
javascript
// 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)有哪些高级用法?

基础认知:第三个参数可以指定事件监听阶段(captureoncepassive)。 进阶细节(从 ES2015 开始,第三个参数变成了一个对象):

javascript
// 完整的 options 对象
element.addEventListener('click', handler, {
    capture: false, // 是否在捕获阶段触发
    once: true,     // 只触发一次,之后自动移除
    passive: true   // 永远不会调用 preventDefault,提升滚动性能
});

场景举例

  • once: true:适用于“一次性”操作(如弹窗的首次点击引导、表单提交按钮点击后禁用)。
  • passive: true(性能优化神器):告诉浏览器“这个事件监听不会调用preventDefault()”,浏览器可以立即触发默认行为(如滚动),而不需要等待监听器执行完。这在touchmovescroll事件上能显著提升滚动流畅度。
javascript
// ❌ 滚动卡顿:浏览器必须等待监听器执行完,才能知道是否要阻止默认滚动
document.addEventListener('touchmove', handler);

// ✅ 滚动流畅:告诉浏览器“我不会阻止滚动”,让滚动和监听器并行执行
document.addEventListener('touchmove', handler, { passive: true });

面试话术:“在MMMP大屏仪表板中,我使用passive: true优化了图表区域的滚动性能。这能显著提升大屏在弱网或低配设备上的流畅度。”

8. MutationObserverResizeObserver 的区别

基础认知:两者都是“观察者”API,监听DOM变化。 进阶细节

维度MutationObserverResizeObserver
监听的维度结构变化:子节点添加/删除、属性变化(attribute)、文本内容变化尺寸变化:元素自身宽高变化(由布局或样式引起)
常见用途监听动态表单字段变化、监听style属性变化、contenteditable富文本编辑器监听自适应容器尺寸变化、响应式图表重绘、iframe尺寸自适应

代码示例

javascript
// 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个点全是“浏览器干活时的真实细节”。它们的特点是:

  1. 看起来很简单(比如deferasync的区别,人人都能说两句)
  2. 但深度追问后(比如“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):事件处理函数执行完毕后,事件对象会被重置并放回池中,属性被清空,供下次使用。
  • 异步代码(如 setTimeoutPromise.then)中访问事件对象,会因为池化重置而拿到 null
javascript
function handleClick(e) {
    // 同步访问:正常
    console.log(e.target); // 有值

    setTimeout(() => {
        // 异步访问:拿不到!因为事件已经被重置了
        console.log(e.target); // null
        // 报错:e.target is null
    }, 100);
}

解决方案

javascript
// 方案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.targetdataset 提前存到变量里,而不是在 setTimeout 里直接访问 event 对象。这既避免了池化问题,也不需要额外性能开销。”

追问:“React 17 和 React 18 的事件系统有变化吗?”

回答:“React 17 把事件绑定从 document 移到了 root 节点(root.addEventListener),以支持微前端和多版本共存。React 18 在事件系统上主要做了并发渲染的适配,但合成事件的池化机制依然存在。”

2. useState 的“惰性初始化”是什么?为什么重要?

基础认知useState(initialValue) 接收初始值。 进阶细节:如果初始值是通过复杂计算得到的(比如从 localStorage 读取、复杂运算),你希望在首次渲染时只执行一次,而不是每次组件渲染都执行。此时应使用惰性初始化

javascript
// ❌ 错误:每次渲染都执行 expensiveCalculation,浪费性能
const [data, setData] = useState(expensiveCalculation(props));

// ✅ 正确:传一个函数,React 只在首次渲染时执行
const [data, setData] = useState(() => expensiveCalculation(props));

面试话术:“在 IRMP 项目中有个需求:用户刷新页面后保留之前选择的筛选条件。我用惰性初始化从 sessionStorage 读取上次的状态,同时 sessionStorage 读取操作只在组件首次渲染时执行一次,后续渲染不会重复读取。”

3. useEffect 的清理函数执行时机(比你想象的要晚)

基础认知useEffect 的清理函数在组件卸载时执行。 进阶细节:清理函数除了在组件卸载时执行外,还在每次 Effect 重新执行之前执行(即依赖变化导致 Effect 重新运行时,先执行上一次的清理函数)。

javascript
useEffect(() => {
    const timer = setInterval(() => {
        console.log('Tick');
    }, 1000);

    // 清理函数会在:
    // 1. 组件卸载时执行
    // 2. 下一次 Effect 执行前执行(如果依赖变化了)
    return () => {
        clearInterval(timer);
    };
}, [dependency]);

真实场景中的 Bug

javascript
// 一个常见的错误:清理函数中访问了 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 节点
  • 如果用 indexkey,当列表插入/删除/排序时,React 会复用错 DOM 节点,导致状态错乱。
javascript
// ❌ 错误:用 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. useRefuseState 的本质区别(不仅仅是“一个不触发渲染”)

基础认知useRef 存储可变值且不触发重新渲染;useState 存储状态,修改会触发重新渲染。 进阶细节

  • useRef 返回的是一个对象,其 .current 属性在组件的整个生命周期内保持不变
  • 改变 useRef.current 不会导致组件重新渲染。
  • useRef 在每次渲染时返回同一个对象引用Object.is 比较返回 true)。
  • useState 的 setter 会触发重新渲染,且 setter 是稳定的(在每次渲染中引用一致)。
javascript
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-forv-if 为什么不能同时使用?Vue 3 呢?

基础认知:Vue 官方文档明确建议不要同时使用 v-forv-if进阶细节

  • Vue 2v-for 的优先级高于 v-if。这意味着即使 v-if 条件是 falsev-for 仍会先遍历整个列表,造成不必要的性能浪费。
  • Vue 3v-if 的优先级高于 v-for。这意味着如果 v-if 条件是 falsev-for 根本不会执行。但如果 v-if 需要访问 v-for 的迭代变量(如 item),则会报错,因为 v-if 先执行时 item 还未定义。
html
<!-- ❌ 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-forv-if。这样既能避免 Vue 2/3 优先级差异带来的隐患,也让逻辑更清晰——计算属性负责数据处理,模板只负责渲染。”

7. Vue 的 keyv-for 中的作用与 React 一样吗?有什么不同?

基础认知:两者都用于 Diff 算法的节点复用。 进阶细节(Vue 特有的细节)

  • Vue 会原地复用元素(key 相同且类型相同,则复用 DOM 节点)。
  • Vue 的 key 还会影响过渡动画<TransitionGroup>)。
  • Vue 的 key 必须唯一,且稳定(不随排序变化)。
html
<!-- 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 2Vue 3
默认 propvaluemodelValue
默认 eventinputupdate:modelValue
多个 v-model不支持支持(v-model:titlev-model:content
自定义修饰符不支持支持(v-model.capitalize
vue
<!-- 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 的特性。比如一个表单组件,之前需要分别传 titlecontent 两个 prop,并监听两个事件。现在用 v-model:titlev-model:content 一行搞定,代码量减少了约 30%。”

9. Vue 3 的 watchwatchEffect 的核心区别是什么?

基础认知:两者都用于监听响应式数据变化。 进阶细节

维度watchwatchEffect
依赖来源显式声明(你指定监听哪些数据)自动收集(在回调中访问到的响应式数据)
执行时机默认惰性(只有依赖变化时才执行),可通过 immediate: true 改为立即执行立即执行(创建时立即执行一次,然后依赖变化时再次执行)
访问旧值可以((newVal, oldVal) => {}不能(没有旧值参数)
停止监听返回 stop 函数返回 stop 函数
javascript
// 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 defineAsyncComponentReact lazy
加载器支持函数() => import('./Comp.vue')支持函数() => import('./Comp.jsx')
错误处理errorComponent 选项配合 ErrorBoundary
加载状态loadingComponent 选项(内置支持)配合 Suspensefallback
延迟加载delay 选项(控制 loading 组件延迟显示)无内置
超时控制timeout 选项无内置
javascript
// 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)都是框架“源码级”的细节。它们的特点是:

  1. 大多数人都知道概念(比如“useEffect 有清理函数”、“v-for 要用 key”)
  2. 只有少数人能说清“为什么”和“什么时候触发”(比如清理函数在依赖变化时也会触发,而不是只在卸载时触发)

准备这些问题的核心方法是:把“概念”升维成“执行流程”


第四批(最后一轮)我会整理工程化与工具链的细节深水区,比如 webpackrequire.contextViteimport.meta.globESLintparserOptions 等。你准备好了随时让我继续。

好的,继续第四批。这一批聚焦 工程化与工具链 的细节深水区——这些点你在日常开发中可能每天都在用,但只有真正配置过、调试过、踩过坑的人才能说清楚。

第四批:工程化与工具链(“配置过才知道”的细节)

1. Webpack 的 require.context 是什么?有哪些实际用途?

基础认知require.context 是 Webpack 提供的“魔法方法”,用于在编译时动态引入一个目录下的所有文件进阶细节:它返回一个上下文对象(context),包含 keys()resolve()id 等方法。

签名

javascript
require.context(directory, useSubdirectories, regExp)

实际用途(面试官最想听)

  • 自动注册 Vue 组件:在 components/index.js 中批量导入所有组件。
  • 自动注册 Vuex 模块store/modules/ 下的所有文件自动挂载。
  • 加载所有 SVG 图标./icons 目录下的所有 .svg 文件自动加载。
javascript
// 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.contextVite import.meta.glob
运行时 vs 编译时运行时(Webpack 在编译时处理,但代码在运行时执行)编译时(Vite 在构建时处理)
返回类型返回一个对象(可 keys() 遍历)返回一个对象,键是路径,值是函数(返回 Promise)
动态导入不支持动态导入(所有文件一起打包)支持 eager: true(同步)或默认异步
类型安全不友好TypeScript 支持好
javascript
// 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.31.2.3 的区别是什么?

基础认知:这些都是版本号前缀,控制 npm install 时允许升级的版本范围。 进阶细节

前缀含义举例
无前缀(精确版本)只安装指定版本1.2.3 只安装 1.2.3,不升级
^(兼容版本)允许次版本(minor)补丁版本(patch) 升级,不允许主版本(major) 升级^1.2.3 可以安装 1.2.41.3.0,但不能安装 2.0.0
~(近似版本)只允许补丁版本(patch) 升级~1.2.3 可以安装 1.2.4,但不能安装 1.3.0
*(任意版本)安装最新版本(危险!)* 会安装最新版,可能导致不兼容

使用场景

  • 库开发peerDependencies 中通常用 ^>=
  • 应用开发:推荐用 ^(允许安全升级),或 ~(更保守)。
  • 关键依赖:用精确版本(如 "react": "18.2.0")锁定版本。
json
{
  "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. pnpmnpm/yarn 的核心区别是什么?为什么它更快?

基础认知pnpm 是下一代包管理器,以“速度快、节省磁盘空间”著称。 进阶细节

维度npm / yarnpnpm
存储机制每个项目独立复制依赖(浪费磁盘)所有包在全局存储中存放,项目通过硬链接引用,节省磁盘
node_modules 结构扁平化(node_modules 下有所有包)非扁平化(node_modules 下只有直接依赖,间接依赖在 .pnpm 文件夹中)
安装速度慢(重复复制)快(硬链接 + 全局存储)
磁盘占用高(每个项目一份)低(多个项目共享一份)
幽灵依赖存在(可以访问未声明的依赖)不存在(只能访问 package.json 中声明的依赖)

幽灵依赖问题

javascript
// 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 中,减少重复代码。

javascript
// 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 项目中,我配置了 splitChunksnode_modules 中的依赖拆分到 vendors chunk。这样 vendors 的变化频率很低(除非升级依赖),浏览器可以长期缓存它。而业务代码的变化不会影响 vendors 的缓存。同时我把 Element Plus 按需加载 + 手动拆分 Three.js,让点云页面才加载 3D 库。优化后首屏加载时间从 2.3 秒降到了 1.1 秒。”

6. 什么是 Tree Shaking?如何确保 Tree Shaking 生效?

基础认知:Tree Shaking 是构建工具通过静态分析移除未使用的代码,减小打包体积。 进阶细节(“三要素”)

  1. 使用 ES Module(import/export:CommonJS(require)无法被 Tree Shaking,因为它在运行时动态加载。
  2. package.json 中设置 "sideEffects": false:告诉 Webpack“所有文件都没有副作用,可以安全地删除未使用的代码”。
  3. 标记有副作用的文件:某些文件(如全局样式、Polyfill)即使被引入时没有使用,也不应被删除,需在 sideEffects 中列出。
json
{
  "name": "my-library",
  "version": "1.0.0",
  "sideEffects": [
    "*.css",
    "*.scss",
    "src/polyfills.js"
  ]
}

哪些场景会导致 Tree Shaking 失败?

  • 使用 CommonJSrequire
  • 使用 Babel 转换时把 ES Module 转成了 CommonJS@babel/preset-envmodules: 'commonjs'
  • 使用了 __webpack_require__ 等 Webpack 特定 API

Webpack 配置

javascript
// 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.jsonsideEffects 配置,把全局样式、Polyfill 文件标记为有副作用,避免被误删导致样式丢失或兼容性问题。”

7. Vite 的热更新(HMR)为什么比 Webpack 快?

基础认知:Vite 的 HMR 速度远快于 Webpack。 进阶细节(本质差异)

维度Webpack HMRVite HMR
打包方式HMR 前需要打包整个应用,再替换模块利用浏览器的 ES Module,按需编译,只处理变更的模块
更新粒度模块替换,可能需要重新打包依赖树精准到单个文件的替换
启动方式先打包再启动服务器(慢)直接启动服务器,按需编译(快)

Webpack HMR 流程

  1. 文件变更 → 重新打包整个模块 → 生成新的 bundle → 浏览器替换模块
  2. 体积越大,速度越慢(O(n))

Vite HMR 流程

  1. 文件变更 → 只编译这个文件(浏览器已支持的 ES Module)→ 浏览器替换模块
  2. 速度恒定,不随项目规模增长(O(1))

Vite 的 HMR 配置(你可以在项目中自己加):

javascript
// 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 的 parserOptionsenv 有什么区别?如何配置?

基础认知:ESLint 通过配置文件(.eslintrc.js)来设置检查规则。 进阶细节

配置项作用关键配置
parserOptions告诉 ESLint 使用哪个解析器语法版本(如 ecmaVersionsourceTypeecmaVersion: 2022sourceType: 'module'
env告诉 ESLint 代码运行在哪个环境,预定义全局变量(如 browser 提供 windowbrowser: truenode: truees2021: true
javascript
// .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:导致 definePropsdefineEmits 等宏报错未定义。

面试话术:“在 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 在执行 commitpush 等操作时,会检查 ./.git/hooks/ 目录下的脚本,如果脚本返回非 0 值,则终止操作。
  • husky 简化了这个过程,让你在 package.json 中配置 Git Hooks。

lint-staged 的作用

  • 只对 git add 暂存区的文件进行检查,避免检查整个项目的所有文件(太慢)。
  • 支持配置文件扩展名(*.js*.vue*.ts)。
json
// 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"
    ]
  }
}

配置流程(面试时可简述):

  1. npm install husky lint-staged --save-dev
  2. npx husky install(初始化 .husky/ 目录)
  3. npx husky add .husky/pre-commit "npx lint-staged"(添加 pre-commit hook)
  4. package.json 中配置 lint-staged

面试话术:“在 IRMP 项目中,我配置了 husky + lint-staged,在提交代码前自动运行 ESLint 和 Prettier。这保证了所有合入仓库的代码风格统一,并且没有明显的语法错误。我们还在 commit-msg 阶段用 commitlint 检查 commit message 是否符合规范(如 feat: add report generation),让 changelog 能自动生成。”

10. 什么是 source mapdevtooleval-cheap-module-source-maphidden-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 文件(不含源码)生产环境,安全性

各选项的适用场景

javascript
// 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,只保存行列号映射,不包含源码内容。”

追问:“evaleval-cheap-module-source-map 的区别是什么?”

回答:“eval 生成的映射信息最少,只到文件级别,行号和列号可能不准。eval-cheap-module-source-map 会保留更精确的行号映射,且能正确映射到模块源码,更适合需要精准调试的场景。”

第四批总结

以上 10 个点是工程化与工具链的“深水区”细节。它们的特点是:

  1. 每个人都在用(Webpack、Vite、ESLint、husky、pnpm 等)
  2. 但大部分人停留在“能用”层面(比如知道 npm install,但说不清 ^~ 的区别)
  3. 只有真正配置过、优化过、踩过坑的人才能答准(比如 splitChunkscacheGroups 配置、sideEffects 的陷阱)

全部四批总结

我把所有“小细节”分成了 4 个批次,覆盖了:

批次内容题数
第一批JavaScript 语言特性7 题
第二批浏览器与 DOM8 题
第三批React / Vue 框架10 题
第四批工程化与工具链10 题
合计35 题

准备策略

  1. 不要死记硬背代码:理解“为什么会有这个设计”比记住“怎么写”更重要。
  2. 每个点都要找到对应场景:如果面试官追问“你在哪个项目里用过”,你能立刻回答。
  3. 用“对比法”来准备(如 defer vs async^ vs ~watch vs watchEffect),面试官最喜欢问对比题。
最近更新