ByteDance——jy真题
一面(1h 技术面)解答## 1. 问项目(通用答题框架)
项目回答需遵循「STAR 法则」,突出技术深度和个人贡献,避免泛泛而谈。核心逻辑:
- 背景(Situation):项目目标是什么?解决什么问题?面向哪些用户?
例:“为学校社团开发的「社团活动管理系统」,解决线下活动报名混乱、数据统计低效的问题,面向全校100+社团和5000+学生”。 - 技术栈(Technology):前端用了哪些技术?为什么选这些技术?
例:“技术栈:Vue3+Pinia+Vant+Axios+Vite,选Vue3是因为Composition API适合复杂逻辑拆分,Pinia解决跨组件状态管理,Vite提升开发热更新速度(比Webpack快30%+)”。 - 任务(Task):你负责哪些模块?核心挑战是什么?
例:“负责「活动报名表单」「活动数据看板」模块,核心挑战是:1. 表单需支持多类型字段(单选/多选/日期/文件上传);2. 看板需实时展示报名数据,避免频繁请求”。 - 行动(Action):针对挑战做了什么技术方案?如何落地?
例:“1. 表单模块:封装「动态表单组件」,通过JSON配置生成不同字段,减少重复代码;2. 数据看板:用WebSocket实现实时数据推送,替代轮询,请求次数减少90%”。 - 结果(Result):最终效果如何?有量化指标更佳?
例:“项目上线后,社团活动报名效率提升60%,数据统计时间从2小时缩短到5分钟,无兼容性问题(覆盖Chrome/Safari/Edge)”。
关键加分点:主动提「优化点」(如性能、兼容性)或「踩坑复盘」(如解决跨域、表单校验逻辑漏洞),体现复盘能力。
2. Token 被窃取了怎么办?
Token 窃取的核心场景是 XSS(跨站脚本攻击)(如注入脚本窃取 localStorage
中的 Token)和 CSRF(跨站请求伪造)(伪造用户请求携带 Token),防护需从「窃取路径阻断」和「Token 本身加固」入手:
窃取场景 | 防护方案 |
---|---|
XSS 窃取 | 1. 存储层:不用 localStorage/sessionStorage (易被脚本读取),改用 HttpOnly Cookie(脚本无法访问);2. 前端过滤:输入内容做 XSS 过滤(如用 DOMPurify 清洗 HTML 标签);3. CSP 策略:配置 Content-Security-Policy (如禁止加载外部脚本 script-src 'self' ),阻断恶意脚本执行。 |
CSRF 伪造请求 | 1. SameSite Cookie:设置 SameSite=Strict/Lax ,限制 Cookie 仅在同源请求中携带;2. CSRF Token:后端生成随机 Token 存入 Cookie,前端请求时携带该 Token(如放在 Header),后端校验 Token 一致性; 3. Referer 校验:后端校验请求的 Referer 字段,仅允许同源域名请求。 |
Token 本身加固 | 1. 短期有效:Access Token 有效期设为 15-30 分钟,减少泄露后的风险窗口; 2. Refresh Token 机制:用长期 Refresh Token(存 HttpOnly Cookie)刷新 Access Token,Refresh Token 支持「一次性有效」或「设备绑定」; 3. Token 签名:后端用密钥对 Token 签名(如 JWT 的 HS256/RS256),防止 Token 被篡改。 |
3. 设计一个系统,从哪些方面解决 Token 安全问题?
需从「全链路防护」角度设计,覆盖「传输层→存储层→Token 机制→前后端校验→异常处理」:
-
传输层:加密传输
- 强制用 HTTPS(TLS 1.2+),防止中间人攻击(MITM)窃取 Token(HTTPS 会对请求内容加密,包括 Cookie/Header 中的 Token);
- 禁用 HTTP 降级,后端配置
Strict-Transport-Security (HSTS)
,强制浏览器用 HTTPS 访问。
-
存储层:安全存储
- 核心原则:不存储敏感信息到可被脚本访问的位置;
- 方案:Access Token 存
HttpOnly + SameSite + Secure Cookie
(Secure 确保仅 HTTPS 传输),Refresh Token 存另一个独立的 HttpOnly Cookie(与 Access Token 隔离,降低同时泄露风险)。
-
Token 机制:动态化+可控
- 「双 Token 模型」:Access Token(短期)用于接口请求,Refresh Token(长期)用于刷新 Access Token;
- 「Token 吊销」:后端维护「黑名单」,用户登出/Token 泄露时,将 Token 加入黑名单(有效期内拦截请求);
- 「设备绑定」:Refresh Token 绑定设备指纹(如浏览器 UA + IP 段),异设备使用时触发二次验证(如短信验证码)。
-
前端防护:阻断攻击入口
- XSS 防护:输入过滤(
DOMPurify
)、CSP 策略、避免eval()
/innerHTML
等危险 API; - 避免跨域请求泄露:第三方接口请求用后端代理,不直接在前端暴露 Token;
- 防误操作:登出时清空所有 Token(前端调用接口让后端吊销 Token,同时清除 Cookie)。
- XSS 防护:输入过滤(
-
后端校验:多重校验
- Token 合法性:校验签名、有效期、是否在黑名单;
- 请求合法性:校验 CSRF Token、Referer、设备指纹;
- 限流防护:对 Token 刷新接口做限流(如 1 小时内最多 5 次),防止恶意刷 Token。
-
异常处理:快速响应
- 前端:拦截 401/403 响应,判断 Token 过期还是无效,过期则用 Refresh Token 刷新,无效则跳转登录页;
- 后端:日志记录 Token 异常请求(如异地 IP、多次校验失败),触发告警(如通知用户“账号异常登录”)。
4. IntersectionObserver 实现无限滚动,和 scroll 事件有什么区别?
(1)IntersectionObserver 实现无限滚动的原理+步骤
核心逻辑:监听列表底部的「触发点元素」(如“加载更多”提示),当该元素进入视口(Intersection)时,加载下一页数据。
实现步骤:
- 页面结构:列表容器 + 列表项 + 底部触发点(如
<div class="load-more">加载中...</div>
); - 创建 IntersectionObserver 实例:
- 配置
root
:监听的根元素(默认是视口null
); - 配置
threshold
:交叉比例(0 表示触发点刚进入视口就触发回调); - 回调函数:当触发点与视口交叉时,执行加载逻辑;
- 配置
- 监听触发点:调用
observer.observe(loadMoreElement)
开始监听; - 加载数据:回调中判断是否已加载(避免重复请求),调用接口获取下一页数据,追加到列表;
- 停止监听:当数据加载完毕(如后端返回
hasMore: false
),调用observer.unobserve(loadMoreElement)
停止监听。
代码示例:
// 1. 获取元素
const list = document.getElementById('list');
const loadMore = document.getElementById('load-more');
let page = 1;
let isLoading = false;// 2. 创建观察者
const observer = new IntersectionObserver((entries) => {// entries 是所有被监听元素的交叉状态数组const entry = entries[0];// 当触发点进入视口,且未加载时,加载数据if (entry.isIntersecting && !isLoading) {loadNextPage();}},{ root: null, threshold: 0 } // root: 视口,threshold: 0% 交叉即触发
);// 3. 开始监听触发点
observer.observe(loadMore);// 4. 加载下一页数据
async function loadNextPage() {isLoading = true;loadMore.textContent = '加载中...';try {const res = await fetch(`/api/list?page=${page}&size=10`);const data = await res.json();if (data.list.length) {// 追加列表项data.list.forEach(item => {const li = document.createElement('li');li.textContent = item.title;list.appendChild(li);});page++;} else {// 无更多数据,停止监听loadMore.textContent = '没有更多数据了';observer.unobserve(loadMore);}} catch (err) {loadMore.textContent = '加载失败,点击重试';} finally {isLoading = false;}
}
(2)与 scroll 事件的核心区别
对比维度 | scroll 事件 | IntersectionObserver |
---|---|---|
触发频率 | 高频触发(滚动过程中持续触发),需手动加「防抖/节流」(如 200ms 延迟),否则占用主线程,导致卡顿。 | 异步触发(浏览器空闲时执行回调),仅在「交叉状态变化」时触发(如触发点进入/离开视口),无需防抖节流,性能更优。 |
实现复杂度 | 需手动计算「滚动高度、视口高度、元素偏移量」(scrollTop + clientHeight >= scrollHeight - offsetTop ),逻辑繁琐,易出错(如兼容性问题)。 | 浏览器原生 API 封装了交叉判断逻辑,无需手动计算,代码简洁,可读性高。 |
兼容性 | 所有浏览器支持(包括 IE),兼容性好。 | IE 不支持(Edge 支持),现代浏览器(Chrome 51+/Safari 12.1+)支持,需兼容 IE 时需降级用 scroll。 |
适用场景 | 需精细控制滚动过程(如滚动进度条、滚动到指定位置)。 | 无限滚动、懒加载(图片/组件)、视口内元素动画触发等「交叉状态判断」场景。 |
5. 了解虚拟列表吗?用 IntersectionObserver 怎么实现?
(1)虚拟列表的核心原理
虚拟列表是「只渲染可视区域内的列表项」的优化方案,解决「长列表(如 10000+ 项)DOM 过多导致的滚动卡顿」问题。核心逻辑:
- 计算「可视区域高度」和「单列表项高度」,得出「可视区域可容纳的项数」(如可视区 500px,每项 50px → 10 项);
- 监听滚动事件,计算「当前滚动偏移量」,得出「可视区域第一项的索引」(如滚动 200px → 索引 4);
- 只渲染「可视区域索引范围」内的项(如索引 4~13),并通过「定位偏移」让渲染的项对齐到可视区(避免空白);
- 不渲染「非可视区域」的项,减少 DOM 数量(从 10000+ 减少到 20 项左右,性能提升显著)。
(2)用 IntersectionObserver 实现虚拟列表
核心思路:通过监听「可视区域的上下边界元素」,动态调整「渲染的列表项范围」,避免手动计算滚动偏移量。
实现步骤:
-
页面结构:
- 外层容器(
virtual-list
):固定高度(可视区域高度),开启滚动(overflow-y: auto
); - 占位容器(
placeholder
):高度 = 总列表项数 × 单 item 高度,用于撑起容器滚动条(模拟长列表高度); - 渲染容器(
render-container
):绝对定位,用于承载「可视区域内的列表项」(避免占位容器遮挡); - 边界触发点(
top-trigger
/bottom-trigger
):分别放在渲染容器的顶部和底部,用于监听滚动时的上下边界。
<div class="virtual-list" style="height: 500px; overflow-y: auto; position: relative;"><div class="placeholder" id="placeholder"></div> <!-- 占位用 --><div class="render-container" id="render-container" style="position: absolute; top: 0; left: 0; width: 100%;"></div><div class="top-trigger" id="top-trigger"></div> <!-- 上边界触发点 --><div class="bottom-trigger" id="bottom-trigger"></div> <!-- 下边界触发点 --> </div>
- 外层容器(
-
初始化配置:
定义总数据、单 item 高度、可视区可容纳项数(visibleCount
)、当前渲染的起始索引(startIndex
)。const totalData = 10000; // 总列表数据 const itemHeight = 50; // 单 item 高度 const visibleHeight = 500; // 可视区高度 const visibleCount = Math.ceil(visibleHeight / itemHeight) + 2; // 多渲染2项,避免滚动空白 let startIndex = 0; // 当前渲染起始索引 const placeholder = document.getElementById('placeholder'); const renderContainer = document.getElementById('render-container'); // 设置占位容器高度(撑起滚动条) placeholder.style.height = `${totalData * itemHeight}px`;
-
创建 IntersectionObserver 监听边界:
- 监听
top-trigger
(上边界):当触发点进入视口,说明向上滚动,需减小startIndex
,重新渲染上方项; - 监听
bottom-trigger
(下边界):当触发点进入视口,说明向下滚动,需增大startIndex
,重新渲染下方项。
const observer = new IntersectionObserver((entries) => {entries.forEach(entry => {if (entry.isIntersecting) {if (entry.target.id === 'bottom-trigger') {// 向下滚动:更新起始索引(不超过总数据范围)startIndex = Math.min(startIndex + visibleCount, totalData - visibleCount);} else if (entry.target.id === 'top-trigger') {// 向上滚动:更新起始索引(不小于0)startIndex = Math.max(startIndex - visibleCount, 0);}// 重新渲染可视区域项renderItems();}});},{ root: document.querySelector('.virtual-list'), threshold: 0.1 } );// 开始监听上下边界 observer.observe(document.getElementById('top-trigger')); observer.observe(document.getElementById('bottom-trigger'));
- 监听
-
渲染可视区域项:
根据startIndex
和visibleCount
,截取数据片段,生成 DOM 并插入渲染容器,同时调整渲染容器的top
定位(对齐可视区)。function renderItems() {// 1. 截取可视区域数据(startIndex ~ startIndex + visibleCount)const visibleData = Array.from({ length: visibleCount }, (_, i) => {const index = startIndex + i;return index < totalData ? `列表项 ${index + 1}` : '';}).filter(Boolean);// 2. 生成 DOM 并更新渲染容器renderContainer.innerHTML = visibleData.map(item => `<div style="height: ${itemHeight}px; border-bottom: 1px solid #eee;">${item}</div>`).join('');// 3. 调整渲染容器的 top 定位(对齐可视区)renderContainer.style.top = `${startIndex * itemHeight}px`; }// 初始渲染 renderItems();
优势:
无需手动计算 scrollTop
和偏移量,依赖浏览器原生 API 判断边界,代码更简洁,性能更优;适合「不定高 item」场景(需动态计算 item 高度,调整占位容器和渲染范围)。
6. HTTP/2 核心特性(解决 HTTP/1.x 痛点)
HTTP/2 是 HTTP 协议的重大升级,核心目标是「提升性能」,解决 HTTP/1.x 的「队头阻塞」「头部冗余」等问题,核心特性如下:
特性 | 原理 | 解决的 HTTP/1.x 痛点 |
---|---|---|
二进制帧层 | 将请求/响应数据拆分为「二进制帧」(Frame),每个帧包含「类型、长度、流ID」,取代 HTTP/1.x 的文本格式。 | 文本格式易出错(如换行符解析),二进制更高效、易解析,为后续特性奠定基础。 |
多路复用 | 同一 TCP 连接中,多个请求/响应通过「不同流ID」的帧并行传输,互不阻塞;流支持优先级(如给 CSS/JS 设高优先级)。 | HTTP/1.x 同一连接中,请求需排队(队头阻塞),多请求需建立多 TCP 连接(握手耗时)。 |
头部压缩 | 用「HPACK 算法」压缩请求头: 1. 维护「静态字典」(常见头部如 Host/Method,用索引表示); 2. 维护「动态字典」(当前连接中重复的头部,如 Cookie,用索引表示)。 | HTTP/1.x 头部(如 Cookie/User-Agent)重复传输,占请求体积 30%+,浪费带宽。 |
服务器推送 | 服务器可主动向客户端推送「相关资源」(如请求 HTML 时,主动推送 CSS/JS),无需客户端额外请求。 | HTTP/1.x 客户端需先加载 HTML,再解析并请求 CSS/JS,多一次网络往返。 |
流优先级 | 每个流(请求)可设置「优先级权重」(1-256),服务器按优先级处理帧,确保关键资源(如首屏 JS)优先加载。 | HTTP/1.x 无法设置请求优先级,关键资源可能被非关键资源阻塞(如图片阻塞 JS)。 |
首部字段扩展 | 支持「伪头部字段」(如 :method /:path /:status ),明确请求/响应的核心信息,便于解析。 | HTTP/1.x 头部字段无统一结构,解析逻辑复杂。 |
注意:HTTP/2 仍基于 TCP 协议,无法解决「TCP 队头阻塞」(如某个帧丢失,需重传,影响同一连接中所有流);HTTP/3 用 QUIC 协议(基于 UDP)解决了此问题。
7. Node.js 了解 Koa 吗?讲讲 Express 中间件,有什么好处?
(1)Koa 核心特点
Koa 是 Express 原团队开发的轻量级 Node.js Web 框架,核心目标是「更优雅的中间件机制」,特点如下:
- 洋葱模型中间件:中间件通过
async/await
实现「先入后出」的执行顺序(如中间件 A → 中间件 B → 中间件 B 回调 → 中间件 A 回调),支持异步逻辑的优雅处理(Express 中间件不支持 async/await,需手动处理回调); - 轻量无内置:Koa 仅保留核心功能(如上下文
ctx
、中间件栈),无内置路由、静态文件服务等,需通过第三方插件(如koa-router
/koa-static
)扩展,灵活性更高; - 上下文封装:将
req
(请求)和res
(响应)封装为ctx
对象(如ctx.request
/ctx.response
),提供便捷 API(如ctx.body
替代res.end()
,ctx.query
获取查询参数)。
示例(Koa 洋葱模型):
const Koa = require('koa');
const app = new Koa();// 中间件 1
app.use(async (ctx, next) => {console.log('中间件 1 开始');await next(); // 执行下一个中间件console.log('中间件 1 结束');
});// 中间件 2
app.use(async (ctx, next) => {console.log('中间件 2 开始');await next();console.log('中间件 2 结束');
});// 路由处理
app.use(async (ctx) => {ctx.body = 'Hello Koa';console.log('路由处理');
});app.listen(3000);
// 执行顺序:中间件1开始 → 中间件2开始 → 路由处理 → 中间件2结束 → 中间件1结束
(2)Express 中间件
Express 是 Node.js 最早的主流 Web 框架,中间件是其核心机制,定义为「访问请求对象(req)、响应对象(res)和下一个中间件函数(next)的函数」。
1. 中间件类型(按功能划分)
类型 | 作用 | 示例代码 |
---|---|---|
应用级中间件 | 对所有请求生效,用于全局处理(如日志、跨域)。 | app.use((req, res, next) => { console.log('请求时间:', Date.now()); next(); }) |
路由级中间件 | 仅对指定路由生效,用于路由专属处理(如权限校验)。 | app.get('/user', (req, res, next) => { if (!req.query.token) return res.send('无权限'); next(); }, (req, res) => { res.send('用户信息'); }) |
错误处理中间件 | 捕获全局错误(需 4 个参数:err, req, res, next),用于统一错误响应。 | app.use((err, req, res, next) => { console.error(err); res.status(500).send('服务器错误'); }) |
内置中间件 | Express 内置的中间件(如静态文件服务、URL 编码)。 | app.use(express.static('public')) (静态文件服务)、app.use(express.json()) (解析 JSON 请求体) |
第三方中间件 | 社区提供的中间件(如 cors 处理跨域、morgan 打印日志)。 | const cors = require('cors'); app.use(cors()); |
2. 中间件执行顺序
- 按「注册顺序」执行,遇到
next()
则跳转到下一个中间件; - 若未调用
next()
,则请求会被挂起(不会响应客户端); - 错误处理中间件需放在所有中间件最后,否则无法捕获前面的错误。
3. Express 中间件的好处
- 解耦业务逻辑:将通用功能(如日志、权限、跨域)抽离为中间件,不与业务代码混杂(如日志中间件可在所有请求中复用,无需在每个路由中重复写);
- 灵活组合:可按需加载中间件(如仅对
/admin
路由加载权限中间件),支持动态添加/移除; - 统一流程:通过
next()
控制请求流转,错误处理中间件统一捕获异常,避免每个路由单独处理错误; - 生态丰富:第三方中间件成熟(如
passport
处理认证、multer
处理文件上传),降低开发成本。
(对比 Koa):Express 中间件不支持 async/await
,异步逻辑需用回调(如 next(err)
传递错误),而 Koa 用 async/await
让异步中间件更优雅。
8. This 指向题(核心绑定规则+示例)
This 指向取决于「函数的调用方式」,而非定义位置,核心绑定规则优先级:new 绑定 > 显式绑定(call/apply/bind)> 隐式绑定(对象调用)> 默认绑定(独立调用),箭头函数无自己的 this,继承外层作用域的 this。
核心规则示例
绑定规则 | 场景 | 示例代码 | this 指向 |
---|---|---|---|
默认绑定 | 函数独立调用(非对象调用、非 new/显式绑定)。 | function foo() { console.log(this); } foo(); // 非严格模式 | 非严格模式:window (浏览器)/global (Node);严格模式:undefined |
隐式绑定 | 函数作为对象的方法调用(obj.foo() )。 | const obj = { name: 'A', foo() { console.log(this.name); } }; obj.foo(); | obj (调用函数的对象) |
显式绑定 | 用 call/apply/bind 强制指定 this。 | function foo() { console.log(this.name); } const obj = { name: 'A' }; foo.call(obj); // call 传参列表 foo.apply(obj); // apply 传数组 foo.bind(obj)(); // bind 返回新函数 | obj (显式指定的对象) |
new 绑定 | 用 new 调用构造函数(创建实例)。 | function Foo(name) { this.name = name; } const instance = new Foo('A'); console.log(instance.name); | 新创建的实例 instance |
箭头函数 | 箭头函数(无自己的 this,继承外层作用域的 this)。 | const obj = { foo: () => { console.log(this); } }; obj.foo(); // 外层作用域是 window | 外层作用域的 this(如 window ) |
高频面试题示例
// 题 1:隐式绑定 vs 默认绑定
const obj = {name: 'obj',foo() {console.log(this.name);function bar() { console.log(this.name); }bar(); // 独立调用 → 默认绑定(window.name 通常为空)}
};
obj.foo(); // 输出:obj → (空字符串)// 题 2:箭头函数继承 this
const obj = {name: 'obj',foo() {const bar = () => { console.log(this.name); };bar(); // 继承 foo 的 this(obj)}
};
obj.foo(); // 输出:obj// 题 3:bind 绑定(永久绑定,无法被覆盖)
function foo() { console.log(this.name); }
const obj1 = { name: 'obj1' };
const obj2 = { name: 'obj2' };
const fooBind = foo.bind(obj1);
fooBind.call(obj2); // 输出:obj1(bind 绑定优先级高于 call)
9. 手写:封装一个函数,只在第一次点击按钮时执行,之后不再执行(once 函数)
核心思路:用「闭包保存状态」(标记函数是否已执行),第一次执行后更新状态,后续调用不执行。
实现方案(3种)
方案 1:闭包(推荐,兼容性好)
/*** 生成只执行一次的函数* @param {Function} fn - 要执行的函数* @returns {Function} 只执行一次的函数*/
function once(fn) {let isExecuted = false; // 闭包保存状态:标记是否已执行// 返回新函数,接收参数并绑定 thisreturn function (...args) {if (!isExecuted) {isExecuted = true; // 第一次执行后,标记为已执行return fn.apply(this, args); // 绑定 this 和参数,执行原函数}// 后续调用不执行,返回 undefinedreturn undefined;};
}// 使用示例
const handleClick = () => {console.log('按钮只点击一次');
};
// 生成只执行一次的点击处理函数
const onceClick = once(handleClick);
// 绑定按钮点击事件
document.getElementById('btn').addEventListener('click', onceClick);
方案 2:ES6 Symbol(避免状态被外部修改)
用 Symbol 作为对象的私有属性,存储执行状态,避免外部意外修改 isExecuted
。
function once(fn) {const isExecutedKey = Symbol('isExecuted'); // 私有 Symbol 键return function (...args) {if (!this[isExecutedKey]) {this[isExecutedKey] = true;return fn.apply(this, args);}};
}
方案 3:Vue 内置指令(Vue 项目专用)
若在 Vue 项目中,可直接用 @click.once
指令(Vue 内置实现了 once 逻辑):
<template><button @click.once="handleClick">只点击一次</button>
</template>
<script setup>
const handleClick = () => {console.log('Vue 内置 once 指令');
};
</script>
核心要点
- 需支持
this
绑定(如按钮点击时,this
指向按钮元素),所以用fn.apply(this, args)
; - 需支持参数传递(如点击事件的
event
对象),所以用...args
接收参数; - 状态需隔离(每个 once 函数的状态独立),闭包或 Symbol 可实现隔离。
10. 讲讲闭包
(1)闭包的定义
闭包是「函数嵌套时,内部函数引用了外部函数的变量/参数,且外部函数执行后,内部函数仍能访问这些变量/参数」的现象。核心是「作用域链的保留」—— 外部函数执行上下文的变量对象(VO)不会被垃圾回收(GC),因为内部函数仍在引用它。
示例(经典闭包):
function outer() {let count = 0; // 外部函数的变量// 内部函数引用了 countfunction inner() {count++;console.log(count);}return inner; // 外部函数返回内部函数
}const fn = outer(); // 执行外部函数,返回内部函数
fn(); // 1(内部函数仍能访问 count)
fn(); // 2(count 状态被保留)
(2)闭包的原理(作用域链+垃圾回收)
- 作用域链:函数执行时会创建「执行上下文」,包含「变量对象(VO)」和「作用域链」(由当前 VO 和外层 VO 组成)。内部函数的作用域链会包含外部函数的 VO;
- 垃圾回收(GC):JS 引擎会回收「无引用的对象」。外部函数执行后,其执行上下文本应被回收,但因内部函数仍引用其 VO(变量
count
),所以 VO 被保留,形成闭包。
(3)闭包的用途
- 模块化(隐藏私有变量):通过闭包实现「私有变量+公有方法」,避免全局变量污染。
例:实现一个计数器模块,仅暴露add
/get
方法,不暴露count
:const counter = (function () {let count = 0; // 私有变量(外部无法访问)return {add() { count++; }, // 公有方法(通过闭包访问 count)get() { return count; }}; })(); counter.add(); console.log(counter.get()); // 1 console.log(counter.count); // undefined(私有变量不可访问)
- 保存状态:如
once
函数(保存isExecuted
状态)、防抖/节流函数(保存定时器 ID)、循环中保存索引(解决var
变量提升导致的问题)。
例:循环绑定点击事件,保存每个按钮的索引:// 用闭包保存 i 的值(避免 var 提升导致的所有按钮输出 3) for (var i = 0; i < 3; i++) {(function (index) {document.getElementById(`btn${i}`).addEventListener('click', () => {console.log(index); // 点击 btn0 输出 0,btn1 输出 1...});})(i); }
- 柯里化(函数复用参数):将多参数函数拆分为单参数函数,复用前几个参数。
例:实现add(1)(2) = 3
:function add(a) {return function (b) { // 闭包引用 areturn a + b;}; } console.log(add(1)(2)); // 3
(4)闭包的注意点(内存泄漏)
- 风险:若闭包长期持有大量内存(如 DOM 元素、大对象),且未及时释放引用,会导致内存泄漏(如页面卡顿、崩溃);
- 解决方案:
- 不需要闭包时,手动解除引用(如
fn = null
,让内部函数失去引用,GC 回收外部 VO); - 避免在闭包中引用过大的对象(如整个 DOM 树),只引用必要的属性;
- Vue/React 项目中,组件卸载时清除闭包相关的监听(如
removeEventListener
、清除定时器)。
- 不需要闭包时,手动解除引用(如
11. 讲讲发布订阅模式
(1)发布订阅模式的定义
发布订阅模式(Publish-Subscribe Pattern)是「一种事件通信机制」,核心是「解耦发布者和订阅者」—— 发布者(发布事件的对象)不直接依赖订阅者(接收事件的对象),而是通过「事件中心(Event Bus)」传递消息。
核心角色:
- 发布者(Publisher):触发事件,向事件中心发布消息(如
bus.emit('login', user)
); - 订阅者(Subscriber):向事件中心订阅事件,定义事件触发时的回调(如
bus.on('login', (user) => { ... })
); - 事件中心(Event Bus):维护「事件-回调」映射表,提供「订阅(on)、发布(emit)、取消订阅(off)」等 API。
(2)工作流程
- 订阅者向事件中心订阅「特定事件」,并注册回调函数;
- 发布者在合适时机,向事件中心发布「该事件」,并传递参数;
- 事件中心找到该事件对应的所有回调函数,依次执行(传递参数);
- 订阅者若不再需要接收事件,可向事件中心取消订阅。
(3)发布订阅模式的好处
- 解耦发布者和订阅者:发布者无需知道订阅者的存在(如登录事件的发布者,不用知道有多少个组件需要监听登录状态),订阅者也无需知道发布者是谁,两者通过事件中心通信,降低代码耦合度;
- 支持一对多通信:一个事件可被多个订阅者订阅(如登录事件触发后,用户中心、消息通知、日志系统同时更新),无需发布者多次调用;
- 灵活性高:可动态添加/移除订阅者(如组件挂载时订阅事件,卸载时取消订阅),适应复杂场景(如跨组件通信、模块间通信);
- 可扩展性强:新增订阅者时,无需修改发布者代码(符合开闭原则),如新增“登录后统计”功能,只需新增一个订阅者,不用改登录逻辑。
(4)与观察者模式的区别
很多人会混淆两者,核心区别是「是否有事件中心」:
- 观察者模式(Observer Pattern):观察者直接依赖被观察者,被观察者维护观察者列表,事件触发时直接通知观察者(无中间层);
- 发布订阅模式:有事件中心作为中间层,发布者和订阅者完全解耦(更常用,如 Vue 的
$emit
/$on
、Node 的events
模块)。
12. 手写:发布订阅模式(Event Bus 实现)
需实现核心 API:on
(订阅)、emit
(发布)、off
(取消订阅)、once
(只订阅一次),确保功能完整且鲁棒(如取消订阅时避免数组塌陷、支持多参数传递)。
完整实现代码
class EventBus {constructor() {// 维护事件-回调映射表:key=事件名,value=回调函数数组this.events = Object.create(null); // 用 Object.create(null) 避免原型链污染}/*** 订阅事件* @param {string} eventName - 事件名* @param {Function} callback - 回调函数*/on(eventName, callback) {// 校验参数:事件名必传,回调必须是函数if (!eventName || typeof callback !== 'function') {throw new Error('参数错误:eventName 必传,callback 必须是函数');}// 若事件不存在,初始化回调数组if (!this.events[eventName]) {this.events[eventName] = [];}// 将回调加入数组(支持同一事件多个回调)this.events[eventName].push(callback);}/*** 发布事件* @param {string} eventName - 事件名* @param {...any} args - 传递给回调的参数*/emit(eventName, ...args) {// 若事件不存在,直接返回if (!this.events[eventName]) {return;}// 复制回调数组(避免执行回调时修改原数组导致的问题,如 off 操作)const callbacks = [...this.events[eventName]];// 依次执行所有回调,传递参数callbacks.forEach(callback => {callback.apply(this, args); // 绑定 this 为 EventBus 实例(可选,根据需求调整)});}/*** 取消订阅* @param {string} eventName - 事件名* @param {Function} callback - 要取消的回调函数(若不传,取消该事件所有回调)*/off(eventName, callback) {// 若事件不存在,直接返回if (!this.events[eventName]) {return;}// 情况 1:未传 callback → 取消该事件所有回调if (typeof callback !== 'function') {this.events[eventName] = [];return;}// 情况 2:传了 callback → 过滤掉该回调(避免数组塌陷,用 filter)this.events[eventName] = this.events[eventName].filter(cb => cb !== callback);// 若回调数组为空,删除该事件(优化内存)if (this.events[eventName].length === 0) {delete this.events[eventName];}}/*** 订阅一次事件(触发后自动取消订阅)* @param {string} eventName - 事件名* @param {Function} callback - 回调函数*/once(eventName, callback) {// 包装回调:执行后立即取消订阅const onceCallback = (...args) => {callback.apply(this, args); // 执行原回调this.off(eventName, onceCallback); // 取消订阅当前包装后的回调};// 订阅包装后的回调this.on(eventName, onceCallback);// 返回包装后的回调(方便外部手动取消订阅)return onceCallback;}
}
使用示例
// 1. 创建 EventBus 实例
const bus = new EventBus();// 2. 订阅事件(login)
const handleLogin = (user) => {console.log('登录成功,用户:', user);
};
bus.on('login', handleLogin);// 3. 订阅一次事件(logout)
bus.once('logout', () => {console.log('只执行一次的登出事件');
});// 4. 发布事件
bus.emit('login', { name: '张三', age: 20 }); // 输出:登录成功,用户:{ name: '张三', ... }
bus.emit('logout'); // 输出:只执行一次的登出事件
bus.emit('logout'); // 无输出(已取消订阅)// 5. 取消订阅(login 事件的 handleLogin 回调)
bus.off('login', handleLogin);
bus.emit('login', { name: '李四' }); // 无输出(已取消订阅)
核心优化点
- 避免原型链污染:用
Object.create(null)
创建events
,避免继承Object
原型上的方法(如toString
); - 防止数组塌陷:取消订阅时用
filter
生成新数组,而非splice
(splice
会修改原数组,导致循环时索引错乱); - 参数校验:避免传入非法参数(如非函数的回调)导致报错;
- once 实现:通过包装回调,执行后自动取消订阅,确保只触发一次。
13. 点了一下直接 delete 事件会有安全问题
这里的「delete 事件」指「前端直接触发删除操作(如删除数据、文件)」,安全问题核心是「未做权限校验和防误操作」,具体风险和解决方案如下:
(1)核心安全风险
-
未授权删除:用户可能删除不属于自己的数据(如普通用户删除管理员的数据)。
例:前端直接通过userId=123
调用删除接口,若后端未校验「当前用户是否有权删除 userId=123 的数据」,攻击者可篡改userId
删除他人数据。 -
CSRF 攻击:攻击者伪造删除请求(如通过 iframe/图片标签),诱导用户点击,触发删除操作。
例:攻击者发送邮件给用户,邮件中包含<img src="https://xxx.com/api/delete?itemId=456" />
,用户打开邮件时,浏览器会自动发送请求(携带用户 Cookie 中的 Token),若后端未做 CSRF 防护,会执行删除。 -
误操作删除:用户不小心点击删除按钮(如手滑),未确认直接删除,导致数据无法恢复。
-
前端逻辑可绕过:攻击者通过控制台修改前端代码,跳过删除前的校验(如前端判断「用户等级≥3 才能删除」,攻击者修改代码为「等级≥0」)。
(2)解决方案
-
后端强制权限校验(核心):
- 无论前端如何处理,后端必须校验「当前用户是否有权删除该资源」(如删除商品时,校验「商品所属商家 ID」与「当前用户 ID」是否一致);
- 资源 ID 避免用明文传输(如用加密的
itemKey
替代自增itemId
),防止攻击者猜测 ID。
-
防止 CSRF 攻击:
- 后端配置
SameSite=Strict/Lax
的 Cookie,限制 Cookie 仅在同源请求中携带; - 前端请求时携带「CSRF Token」(如放在 Header 的
X-CSRF-Token
字段),后端校验 Token 一致性(Token 由后端生成,存在 HttpOnly Cookie 中,前端通过接口获取); - 优先用「POST 请求」执行删除操作(而非 GET/DELETE),GET 请求易被图片/iframe 自动触发,POST 需显式提交。
- 后端配置
-
增加删除确认流程:
- 点击删除按钮后,弹出确认弹窗(如“确定要删除该数据吗?删除后无法恢复”),用户点击「确认」后才发送请求;
- 敏感操作(如删除账号、删除订单)可增加「二次验证」(如输入密码、短信验证码)。
-
前端防篡改+日志追溯:
- 前端关键逻辑(如权限判断)可通过「签名校验」(如后端返回用户权限签名,前端校验签名有效性),避免攻击者修改代码;
- 后端记录所有删除操作日志(包含「操作人 ID、操作时间、资源 ID、IP 地址、设备信息」),便于后续追溯(如用户投诉误删,可查询日志确认操作人)。
-
支持数据恢复:
- 重要数据删除时,采用「软删除」(如在数据库中标记
is_deleted=1
,而非直接删除),保留数据一段时间(如 30 天),用户误删后可申请恢复。
- 重要数据删除时,采用「软删除」(如在数据库中标记
二、二面(45min 综合+技术面)解答
二面侧重「综合素养」(学习能力、时间管理、团队协作)、「业务理解」(项目动机、问题解决)、「职业规划」,回答需真诚、有逻辑,体现与岗位的匹配度。
1. 怎么想着大二就出来实习?
核心是体现「主动性、学习规划、对前端的热情」,避免说“为了赚零花钱”等浅层理由,示例:
“我从大一下开始自学前端,跟着做了 2 个小项目(如个人博客、社团管理系统),但发现「课本知识」和「工业界实践」有差距——比如课本讲了 Vue 的基础语法,但实际项目中的工程化(如 Vite 配置、ESLint 规范)、性能优化(如虚拟列表)是课本没覆盖的。
大二实习的核心目标是:1. 把课堂学到的前端基础(JS/HTML/CSS)应用到实际业务中,比如剪映的视频编辑相关界面,提升实战能力;2. 了解大厂的开发流程(如需求评审、代码评审、测试部署),学习前辈的编码规范和问题解决思路;3. 提前感受前端岗位的工作节奏,确定自己的职业方向(比如是否适合做视频类前端开发)。
另外,我已经提前规划了课程,大二下的课不多,每周能保证 4 天以上的实习时间,不会影响学业。”
2. 可实习时间
回答需「明确、具体」,体现灵活性,同时说明学业安排,让面试官放心,示例:
“我目前大二下,课程集中在周一和周三上午,所以实习时间可以是:周一/周三下午到晚上,周二/周四/周五全天,周末可根据项目需求配合加班(提前沟通即可)。
实习能持续到 9 月份(暑假不回家,全程在岗),如果后续课程冲突,我会提前 2 周和团队沟通,调整实习时间(比如用晚上/周末补课程,确保不影响工作进度)。”
3. 学校的课
一面技术问题详解 (1h)
1. Token被窃取了怎么办?如果要你设计一个系统可以从哪些方面解决这个问题?
这是一个非常经典的Web安全问题,考察你对身份认证和安全的理解。
-
应急措施(怎么办):
- 立即吊销被盗Token: 这是最直接的措施。服务端需要维护一个黑名单(Token Revocation List),将窃取的Token加入其中。当该Token再次发起请求时,即使它未过期,服务器也会拒绝访问。但这要求服务端有状态,增加了开销。
- 强制用户重新登录: 清除客户端的Token(如 localStorage 的 Token),将用户跳转至登录页,生成新的 Token。同时,使该用户之前颁发的所有 Token(或对应 Session)失效。
- 调查与警报: 调查窃取发生的原因(是否XSS攻击、网络嗅探等),并通知用户账号存在安全风险。
-
系统设计(如何预防和缓解):
- 缩短Token有效期: 使用短效的Access Token(如15-30分钟)和专用的长效Refresh Token。Access Token即使被盗,攻击者也只能在很短的时间窗口内使用。Refresh Token用于获取新的Access Token,它应该被安全地存储(如HttpOnly Cookie),并且可以在服务端被单独吊销。
- 安全地存储Token:
- 不要存储在LocalStorage/SessionStorage: 它们易受XSS攻击。
- 推荐使用HttpOnly Cookie: 可以防止JavaScript读取,有效防范XSS。但需注意防范CSRF攻击(通过SameSite、CSRF Tokens等手段)。
- 增加Token的使用上下文信息:
- 绑定用户IP/User-Agent: Token与签发时的客户端IP或User-Agent绑定。如果检测到不一致,则要求重新认证。但这可能对移动端或切换网络的用户不友好。
- 使用更安全的传输通道:
- 全程HTTPS: 防止网络传输过程中被窃听。
- 实施监控和异常检测:
- 监控Token的使用频率、来源IP是否异常(例如突然从国外IP访问),发现可疑行为立即告警并吊销Token。
2. IntersectionObserver怎么实现的无限滚动,和用scroll有什么区别?
-
实现方式:
- 在列表末尾放置一个“哨兵”元素(如一个div)。
- 使用
IntersectionObserver
监听这个哨兵元素。 - 当哨兵元素进入视口(
isIntersecting
为true)时,回调函数被触发。 - 在回调函数中加载下一页数据,并将新内容追加到列表尾部。
- 完成后,哨兵元素会再次被推到视口之外,等待下一次进入。
-
与scroll事件的区别:
特性 IntersectionObserver 传统scroll事件 + 节流 原理 异步观察,由浏览器在空闲时回调 同步监听,需要主动绑定事件 性能 极高。不依赖连续滚动触发,无回调压力。 较差。即使使用节流,仍需要频繁计算元素位置( offsetTop
,scrollTop
等),导致布局抖动(Layout Thrashing),影响性能。复杂度 简单。API清晰,只需关注目标元素与视口的交叉状态。 复杂。需要手动计算元素位置、视口高度、滚动距离,代码冗长。 精确度 高。直接告诉你是否“进入视口”。 依赖实现。计算可能有误差。
3. 了解虚拟列表吗?用IntersectionObserver怎么实现?
虚拟列表是用于优化长列表性能的技术,只渲染可视区域(Viewport)内的元素,非可视区域的元素不渲染或用空白占位。
-
核心思想: 通过计算滚动位置,动态地切换可视区域内的列表项数据,并调整一个空白容器(滚动容器)的Padding或Transform来模拟整个列表的高度,从而维持正确的滚动条。
-
用IntersectionObserver的实现思路:
- 计算总高度:
totalHeight = itemCount * itemHeight
。 - 创建可见区域: 一个固定高度的容器(Viewport)。
- 创建滚动容器: 一个高度为
totalHeight
的容器,内部初始为空。 - 创建两个“哨兵”元素: 一个位于可视区域顶部上方,一个位于底部下方(或使用一个动态列表,观察每个ListItem)。
- 观察哨兵: 使用
IntersectionObserver
观察这两个哨兵(或列表项)。 - 动态渲染: 当滚动发生时,哨兵的交叉状态变化触发回调。根据哪个哨兵进入了视口,计算出新的起始索引(startIndex)和结束索引(endIndex)。
- 更新DOM: 根据
startIndex
和endIndex
,渲染对应的列表项数据到滚动容器中。同时,通过设置滚动容器的padding-top
或内容的transform: translateY()
为startIndex * itemHeight
,来模拟已经滚动过去的内容,保持滚动条正确。 - 回收和复用DOM节点 以进一步提升性能。
- 计算总高度:
4. Nodejs: 了解Koa吗?讲讲Express中间件,有什么好处?
- Koa: 是由Express原班人马打造的下一代Web框架,更轻量、更优雅。它的核心是利用Async/Await彻底解决了回调地狱问题。Koa的中间件采用洋葱模型(Onion model),通过
await next()
来控制执行流程。 - Express中间件:
- 是什么: 是一个函数,接收三个参数:
(req, res, next)
。它可以访问请求对象(req)、响应对象(res)和应用程序的请求-响应循环中的下一个中间件函数(next)。 - 执行流程: 中间件按顺序执行。每个中间件可以执行任何代码、修改req和res、结束请求-响应循环、调用下一个中间件
next()
。 - 好处:
- 高可扩展性: 通过组合不同的中间件(如日志、 body解析、 会话管理、 路由)来构建应用,功能解耦,灵活性强。
- 职责单一: 每个中间件只负责一个功能,代码清晰,易于维护和测试。
- 简化流程: 将复杂的处理流程拆分成多个连续的步骤,降低了单个函数的复杂度。
- 是什么: 是一个函数,接收三个参数:
5. This指向题 & 手写:封装一个函数,只在第一次点击按钮时执行
-
This指向: 这是一个大话题,但核心记住:this的值取决于函数的调用方式,而非定义方式。
- 直接调用:
func()
->this
指向全局(严格模式下为undefined) - 方法调用:
obj.func()
->this
指向obj
- 构造函数:
new Func()
->this
指向新创建的实例 - call/apply/bind:
func.call(ctx)
->this
指向ctx
- 箭头函数:
() => {}
->this
继承自父执行上下文
- 直接调用:
-
手写代码(单次执行函数):
function once(fn) {let executed = false;let result; // 用于存储第一次执行的结果,满足函数可能有返回值的情况return function(...args) {if (!executed) {executed = true;result = fn.apply(this, args); // 使用apply保证正确的this指向和参数return result;}// 后续调用可以返回undefined,或者选择返回第一次的结果 `return result;`return undefined;}; }// 使用示例 const button = document.querySelector('button'); button.addEventListener('click', once(function(e) {console.log('只能执行一次!', this);alert('Clicked!'); }));
关键点: 使用闭包(
executed
变量)来保持状态。
6. 讲讲闭包 & 讲讲发布订阅,有什么好处?手写发布订阅
-
闭包: 一个函数和对其周围状态(词法环境)的引用捆绑在一起。简单说,就是一个内部函数可以访问其外部函数作用域中的变量,即使外部函数已经执行完毕。上面的
once
函数就是闭包的经典应用。 -
发布订阅模式(Pub/Sub): 一种消息模式,发送者(发布者)不直接将消息发送给接收者(订阅者),而是通过一个消息通道(中间人)来广播消息。
- 好处:
- 解耦: 发布者和订阅者完全解耦,互不知晓对方的存在,只需关注消息本身。提高了代码的灵活性和可维护性。
- 可扩展性: 可以方便地增加新的发布者或订阅者,而不影响现有系统。
- 异步处理: 非常适合处理异步操作和事件驱动的系统。
- 好处:
-
手写发布订阅:
class EventEmitter {constructor() {this.events = new Map(); // { eventName: [callback1, callback2, ...] }}on(eventName, callback) {if (!this.events.has(eventName)) {this.events.set(eventName, []);}this.events.get(eventName).push(callback);return this; // 支持链式调用}off(eventName, callback) {const callbacks = this.events.get(eventName);if (callbacks) {const index = callbacks.indexOf(callback);if (index > -1) {callbacks.splice(index, 1);}// 如果该事件没有回调了,可以删除键以节省内存if (callbacks.length === 0) {this.events.delete(eventName);}}return this;}emit(eventName, ...args) {const callbacks = this.events.get(eventName);if (callbacks) {// 复制一份数组,防止在回调函数中注册或取消注册当前事件导致循环出错callbacks.slice().forEach(cb => cb.apply(this, args));}return this;}once(eventName, callback) {const wrapper = (...args) => {callback.apply(this, args);this.off(eventName, wrapper);};this.on(eventName, wrapper);return this;} }// 使用示例 const emitter = new EventEmitter(); const helloCallback = (name) => console.log(`Hello ${name}`); emitter.on('greet', helloCallback); emitter.emit('greet', 'World'); // Hello World emitter.off('greet', helloCallback); emitter.emit('greet', 'World'); // Nothing happens
7. 点了一下直接delete事件会有安全问题
这个问题可能是指手写发布订阅中的off
方法。如果实现不当,在事件回调函数内部off
掉自身,同时使用forEach
循环,会因为数组元素的实时变化导致循环错乱(比如删除了一个,后面的元素会前移,索引会出错)。上面的实现通过callbacks.slice()
创建了一个副本进行遍历,从而避免了这个问题。
二面问题分析与回答思路
这部分问题没有标准答案,主要考察你的软实力、动机和思考方式。
-
怎么想着大二就出来实习?
- 思路: 体现你的主动性、求知欲和对行业的热情。
- 参考: “希望尽早将理论知识应用于实践,了解业界的开发流程和真实项目的复杂性。希望通过实习快速提升自己的工程能力,并明确自己未来的学习方向。”
-
为什么选前端?学前端多久了?
- 思路: 表达对前端的真诚热爱,可以提到视觉创造力、即时反馈、与用户直接交互等特点。
- 参考: “喜欢它能直接创造可视化的产品,给用户带来直接的体验。同时也对Web技术生态的快速发展和挑战感到兴奋。系统学习了大约 [X] 年/月。”
-
项目背景、为什么选择写线上商城、遇到的难点?
- 思路: STAR原则(Situation, Task, Action, Result)。商城项目很经典,覆盖知识点广(UI、状态管理、路由、性能、安全)。
- 难点举例: 购物车状态同步、大量商品列表的性能优化(虚拟滚动/懒加载)、前端路由设计、表单验证和提交、与后端API的联调调试等。
- 解决: 具体说明你用了什么技术(如Redux管理购物车、IntersectionObserver做懒加载、Webpack分包优化等)。
-
最大的两个优点和缺点?
- 优点: 结合技术岗位需求。例如:快速学习能力(能迅速掌握新框架)、解决问题能力(擅长Debug和排查问题)、责任心强(保证代码质量)。
- 缺点: 切忌说致命缺点(如懒、不细心)。要说一个可改善的、甚至能反过来体现优点的缺点。
- 参考: “有时会过于追求技术的完美解决方案,可能会在细节上花较多时间(体现技术热情)。后来学会了在项目 deadline 和代码质量之间做更好的权衡。” 或者 “初期不太敢问问题,怕打扰别人。后来意识到及时沟通效率更高,现在会先自己研究一段时间,如果卡住会主动寻求帮助。”
-
DDL任务,何时寻求帮助?
- 思路: 体现你既独立又善于协作。
- 参考: “我会先花一小部分时间(例如30分钟-1小时)快速尝试和评估,明确问题的难点和卡点。如果发现自己完全没思路,或者预估无法在ddl前独立解决,我会立即带着当前的研究结果和思考去向同事或导师求助,这样能高效地解决问题,不耽误整体进度。”
-
怎么看AI写代码?最近用哪个大模型?作用大吗?
- 思路: 承认AI的强大辅助作用,但强调人的主导地位。
- 参考: “AI(如GPT-4、Claude、DeepSeek)目前是一个强大的辅助工具。它非常适合生成样板代码、提供代码建议、解释复杂概念、协助Debug(贴错误信息问它)。但它缺乏对业务上下文和整体架构的深度理解,生成的代码可能需要修改和优化。我是它的‘驾驶员’,而不是乘客。 它极大提升了我的学习效率和开发效率,但最终的决策、设计和代码质量仍然需要我自己负责。”
-
手写:合并有序数组
- 题目: 给你两个按非递减顺序排列的整数数组
nums1
和nums2
,另有两个整数m
和n
,分别表示nums1
和nums2
中的元素数目。请将nums2
合并到nums1
中,使合并后的数组同样按非递减顺序排列。 - 思路: 双指针,从后往前遍历,避免覆盖
nums1
中的元素。
function merge(nums1, m, nums2, n) {let p1 = m - 1; // nums1有效元素的末尾let p2 = n - 1; // nums2的末尾let p = m + n - 1; // nums1整个数组的末尾while (p1 >= 0 && p2 >= 0) {if (nums1[p1] > nums2[p2]) {nums1[p] = nums1[p1];p1--;} else {nums1[p] = nums2[p2];p2--;}p--;}// 如果nums2还有剩余元素(意味着这些元素都比nums1剩下的最小元素还小)while (p2 >= 0) {nums1[p] = nums2[p2];p2--;p--;}// nums1有剩余元素不需要处理,因为它们已经在正确的位置上了。 }
- 题目: 给你两个按非递减顺序排列的整数数组
-
写一个登录页面,可以用AI
- 考察点: 不仅仅是代码,更是工程化思维和安全意识。
- 实现要点:
- UI: 表单包含用户名/邮箱输入框、密码输入框(类型为
password
)、提交按钮。 - 基础功能: 表单提交事件处理,阻止默认提交行为,获取输入值。
- 用户体验: 加载状态(点击后按钮禁用、显示loading动画),错误信息提示。
- 安全:
- 密码字段使用
type="password"
。 - 提交请求使用HTTPS。
- 前端需要做基础校验(如非空、邮箱格式),但绝不能替代后端校验。
- 考虑添加CSRF Token(如果后端需要)。
- 密码字段使用
- 密码管理工具兼容: 使用正确的
input
name
和type
属性,方便浏览器自动填充密码。
- UI: 表单包含用户名/邮箱输入框、密码输入框(类型为
- 代码结构:
<form id="loginForm"><div><label for="email">Email:</label><input type="email" id="email" name="email" required></div><div><label for="password">Password:</label><input type="password" id="password" name="password" required></div><button type="submit" id="submitBtn">Login</button><div id="errorMsg" style="color: red; display: none;"></div> </form> <script>const form = document.getElementById('loginForm');const submitBtn = document.getElementById('submitBtn');const errorMsg = document.getElementById('errorMsg');form.addEventListener('submit', async (e) => {e.preventDefault();// 重置状态errorMsg.style.display = 'none';submitBtn.disabled = true;const formData = new FormData(form);const credentials = Object.fromEntries(formData);try {const response = await fetch('/api/login', {method: 'POST',headers: {'Content-Type': 'application/json',},body: JSON.stringify(credentials),});if (response.ok) {const data = await response.json();// 存储token,跳转页面localStorage.setItem('authToken', data.token);window.location.href = '/dashboard';} else {const err = await response.json();throw new Error(err.message || 'Login failed');}} catch (error) {errorMsg.textContent = error.message;errorMsg.style.display = 'block';} finally {submitBtn.disabled = false;}}); </script>
希望这份超详细的解答和思路分析能帮助你更好地理解和准备面试!祝你未来面试顺利!
一、理论题(“八股”)
1. 原型链(prototype chain)
要点:JavaScript 的对象继承是基于原型(prototype)的——每个对象有一个内部指针 [[Prototype]]
(常见访问器 __proto__
),属性查找会沿着这个链向上查找直到 null
。
function F(){}
->F.prototype
是构造函数实例的原型(可被实例访问)。obj.__proto__ === Constructor.prototype
;Object.getPrototypeOf(obj)
推荐用法。- 属性查找:先在对象自身查找(own property),找不到就沿
[[Prototype]]
向上查找直到null
。 instanceof
:检查prototype
是否在对象的原型链上。Object.create(proto)
:创建一个以proto
为原型的新对象。- ES6
class
:只是语法糖,底层仍用原型链实现。 - 常见面试题点:
constructor
、修改prototype
(会影响所有实例)、当心在原型上放可变对象(会被实例共享)、性能问题(频繁查找原型链不如 own property 快)。
示例:
function Parent(){ this.a = 1; }
Parent.prototype.say = function(){ console.log('hi'); }function Child(){ Parent.call(this); }
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;const c = new Child();
c.say(); // 'hi' —— 在原型链上找到
console.log(c instanceof Parent); // true
2. 怎么理解普通函数 this
指向
要点:this
指向取决于调用方式,而不是函数定义位置(除了箭头函数)。常见四大规则(再加一个特殊的箭头行为):
- 默认绑定(Global/default):直接调用
fn()
,非严格模式下this
指向全局对象(浏览器window
);严格模式下为undefined
。 - 隐式绑定(Implicit):
obj.method()
,this
指向调用方法的对象obj
(最常见)。 - 显式绑定(Explicit):
fn.call(obj, ...)
/fn.apply(obj, ...)
,this
被强制设为第一个参数(bind 返回的新函数可永久绑定)。 - 构造调用(new):
new Fn()
,this
指向新创建的实例(并返回该实例,除非函数显式返回对象)。 - 箭头函数:没有自己的
this
,this
词法绑定(取决于定义时的外层作用域的this
)。
面试常考陷阱/话题:
- 方法“丢失”绑定(把
obj.method
赋给变量再调用,隐式绑定丢失)。 - 用
bind
、call
、apply
改变this
。 - 类组件/React 中
this
的常见处理(bind 或 class field)。 strict mode
的区别。
示例:
const obj = {x: 10,getX() { return this.x; }
};
const fn = obj.getX;
console.log(obj.getX()); // 10 (隐式绑定)
console.log(fn()); // undefined or global.x (默认绑定)
3. call
/apply
/bind
作用:显式设置函数执行时的 this
。
fn.call(thisArg, arg1, arg2, ...)
fn.apply(thisArg, [arg1, arg2, ...])
fn.bind(thisArg, arg1, ...)
返回一个新函数,绑定this
和部分参数(可用于延迟调用或确保this
不变)。
实现思路(面试常写 polyfill):
Function.prototype.myCall = function(context, ...args) {context = context ?? globalThis;const key = Symbol('fn');context[key] = this;const res = context[key](...args);delete context[key];return res;
};Function.prototype.myApply = function(context, args) {context = context ?? globalThis;const key = Symbol('fn');context[key] = this;const res = context[key](...(args || []));delete context[key];return res;
};Function.prototype.myBind = function(context, ...bindArgs) {const self = this;function bound(...args) {// 支持 new 调用const isNew = this instanceof bound;return self.apply(isNew ? this : context, bindArgs.concat(args));}// 继承原函数 prototype,支持被 newbound.prototype = Object.create(self.prototype);return bound;
};
注意:bind
的实现要考虑 new
场景(构造器模式),以及尽量维持 length
、name
等属性(高级要求)。
4. HTTP(基础知识)
要点概览:
- 定位:应用层协议(基于 TCP)——请求/响应模型。
- 常用方法:
GET
(安全、幂等)、POST
(非幂等)、PUT
、DELETE
、PATCH
、HEAD
、OPTIONS
等。面试会考“幂等”“安全”概念。 - 状态码:1xx/2xx/3xx/4xx/5xx(例如 200 OK,301/302 重定向,401/403/404/500)。
- 请求/响应头:
Host
、Content-Type
、Accept
、Cache-Control
、Set-Cookie
、Cookie
、Authorization
、Location
、ETag
等。 - 无状态:HTTP 本身是无状态(每次请求独立),状态通过 Cookie/Session/Token 存储在客户端或服务端配合实现。
- 缓存:
Cache-Control
、ETag
、Last-Modified
、Expires
。 - 连接:HTTP/1.1 默认长连接(Keep-Alive);HTTP/2 使用二进制帧、multiplex、头压缩;HTTP/3 基于 QUIC(UDP)。
- 跨域:Same-origin policy + CORS(
Access-Control-*
)策略。 - 常见问题:CSRF(用 SameSite、CSRF token防护)、XSS(输出编码)、身份认证(Cookie session / JWT / OAuth)。
5. HTTPS 有状态吗?
回答(精确):在应用层(HTTP)看,HTTPS 与 HTTP 一样仍是无状态的(每次请求/响应独立);但在传输层(TLS/SSL)存在会话/握手的状态(比如握手期间协商参数、建立会话密钥,以及会话重用/会话票据等机制)。
解释:
- HTTPS = HTTP over TLS。HTTP 请求是无状态的。
- TLS 建立连接需要握手(短暂有状态),客户端和服务器记录必要的密钥信息来保护后续数据传输。TLS 会话可以被重用以避免重复全握手(会话ID、Session Tickets)。
- 因此区分“协议的无状态性(应用语义)”与“传输层是否存在会话状态”。
6. 什么叫无状态(stateless)
定义:无状态协议意味着服务器不会自动保留来自同一客户端的过往请求的状态信息。每个请求都应包含完成该请求所需的全部信息(或者与服务端以某种已知方式关联,比如通过 token/cookie)。
- 优点:可伸缩性好,简单。
- 缺点:若需要连续会话体验,必须借助外部机制(cookie/session,或 token、数据库、缓存)。
- 常见实现:Cookie + Session(服务器端有状态)、JWT(客户端携带状态)、Redis 存 session(分布式保持状态)。
7. HTTPS 加密(整体流程/要点)
总体:TLS 协议保证——机密性(confidentiality)、完整性(integrity)、认证(authentication)。
主要步骤与构件:
-
证书(公钥):服务器有 X.509 证书,包含服务器公钥,受 CA 签名。用于证明服务器身份。
-
握手(Key Exchange):
- 客户端发起并验证证书(域名、链、有效期、撤销)。
- 使用非对称加密或密钥交换(例如 ECDHE)协商出对称密钥(session key)。
-
对称加密(bulk encryption):数据传输使用对称算法(AES-GCM、ChaCha20-Poly1305 等),因为对称算法在大数据量下更快。
-
完整性验证:AEAD(如 GCM)同时提供加密+完整性,或使用 MAC(HMAC)+加密。
-
会话重用:Session ticket/Session ID 来减少重新握手成本。
-
现代 TLS(1.3):简化握手,默认使用 (EC)DHE,移除部分不安全选项,优先 AEAD。
8. 为什么握手阶段不用对称加密?
核心原因:对称加密要求通信双方事先共享一个秘密密钥(shared secret)。握手阶段的目标正是在不安全的网络上安全地协商出一个对称密钥,因此必须使用能在公开信道上安全交换密钥的机制——即非对称加密(公/私钥)或密钥交换协议(如 Diffie–Hellman / ECDH)。
- 非对称:可以用来加密“预主密钥”(pre-master secret)或验证密钥交换双方的身份(证书、签名)。
- Diffie–Hellman(尤其 ECDHE):双方通过数学方法各自派生出相同的共享密钥,但不直接传输该密钥,能提供前向保密。
- 一旦共享密钥被安全协商出来,后续数据采用对称加密(高效)。
9. 中间人攻击(MITM)是怎么实现的?
基本思路:攻击者位于客户端和服务器之间,能够拦截、篡改或伪造通信数据。常见方式有:
- 网络层劫持:ARP 欺骗、DNS 欺骗、路由器或 Wi-Fi 热点劫持,直接截获和修改流量。
- SSL/TLS 降级攻击 / SSL stripping:把 HTTPS 链接降为 HTTP(如果站点不强制重定向或没有 HSTS)。
- 伪造证书:如果攻击者能让受信任的 CA 颁发证书,或用户不校验证书,就可做完全代理(解密并重新加密)。
- 代理/中间代理:用户或企业安装了自签名根证书,代理可以解密 HTTPS(合法或恶意)。
- 浏览器不校验/用户忽视警告:用户忽视证书错误提示也会遭受 MITM。
防御措施:
- 始终使用 HTTPS(并开启 HSTS)。
- 正确的证书验证(检查域名/链/过期/撤销)。
- Certificate pinning(移动端或特定场景)、OCSP stapling、Certificate Transparency。
- 避免不安全的 Wi-Fi,使用 VPN。
10. 证书是干什么的
X.509 证书的作用:
- 绑定身份与公钥:证明某个域名/实体对应一个公钥(由 CA 签名)。
- 保证身份真实性:浏览器通过信任根 CA,并验证证书签名,来保证服务器就是其声称的服务器(如
example.com
)。 - 支撑 TLS 的认证环节:握手时服务器(和可选客户端)出示证书,客户端验证后继续密钥协商。
证书包含的主要字段:主体(Subject/DNS names)、公钥、颁发者(Issuer)、有效期、扩展(SAN、用途)、CA 签名等。
验证步骤(简化):
- 验证签名链 -> 查到受信任的根证书。
- 校验域名是否匹配证书(CN/SAN)。
- 校验有效期是否过期。
- 校验是否撤销(CRL/OCSP)。
11. TS(TypeScript)解决什么问题
核心:为 JavaScript 提供静态类型系统与类型检查,改善可维护性、可读性、工具支持(编辑器自动补全、重构、安全重构、早期错误发现)。
具体好处:
- 防止常见错误(类型不匹配、拼写、访问不存在属性)。
- 更好 IDE 支持(跳转、补全、重命名、重构)。
- 可表达复杂类型(泛型、联合/交叉、映射类型、条件类型)。
- 逐步采用(gradual typing):可以混合
.ts
/.js
,慢慢迁移。 - 编译期检查:编译器发现潜在 bug(不会改变运行时代码,构建时会转成 JS)。
注意:TypeScript 是静态类型系统,在运行时类型信息被擦除(erased)。
12. JS 有类型检测吗?
答:JS 是动态、弱类型语言,运行时有一些基本类型判断工具:
typeof x
:返回"undefined" | "object" | "boolean" | "number" | "string" | "function" | "symbol" | "bigint"
(注意typeof null === 'object'
)。instanceof
:检查对象原型链是否包含某个构造函数的prototype
。Array.isArray(x)
:检查数组。Object.prototype.toString.call(x)
:更精准的类型判断([object Date]
、[object RegExp]
等)。- 运行时检查依赖值(
if (x && typeof x === 'object' && 'prop' in x)
)。
总结:JS 有运行时的类型检测手段(动态检查),但没有静态的编译期类型检查。
13. JS 的类型检测和 TS 的区别
-
时机:
- JS:运行时检查(值在运行时被判断)。
- TS:编译时检查(在开发/构建阶段,类型检查器报错,运行时没有类型信息)。
-
目标:
- JS:判断一个值的实际类型(
typeof
/instanceof
)。 - TS:约束代码的类型契约,帮助开发者在写代码时避免类型错误。
- JS:判断一个值的实际类型(
-
类型系统:
- JS:无静态类型;可以随时把任何类型赋给变量。
- TS:静态类型(可选),支持联合、交叉、泛型、类型保护(type guards)、映射类型等高级特性。
-
运行结果:TS 在编译后产出纯 JavaScript(类型信息全部被擦除),所以运行时表现仍然是 JS 的动态行为。
二、手撕 / 编码题
手撕 1:给一个泛型的定义让我说明它的作用(你给的例子)
你给的接口:
export interface Serviceidentifier<T> {(...args: any[]): void;type: T;
}
解析与常见面试考点:
- 这个接口定义了一个可调用的类型(call signature)同时还具有一个
type
属性。也就是说实现这个接口的值既可以像函数那样被调用,又有一个type
字段,其静态类型是T
。 - 用途:常见于依赖注入(DI)或“标识符 token”模式,用于在类型系统中携带泛型信息:通过
Serviceidentifier<MyType>
将T
信息传给容器的get<T>(id: Serviceidentifier<T>): T
,从而让容器的取出与类型保持一致。 - 注意:TypeScript 的类型参数在运行时被擦除(type erasure),
type: T
只是类型上的帮助,实际运行时并不会有T
的“真实值”。所以这种模式多用于在编译期把类型信息传递给 API,运行时通常用Symbol
、类构造函数或字符串作为 token。
如何更稳健/常见地实现“带类型的 token”:
- 用
symbol
做 token,并用类型断言“携带”类型:
type Token<T> = symbol & { __type?: T };function createToken<T>(desc: string) {return Symbol(desc) as Token<T>;
}const MyServiceToken = createToken<MyService>('MyService');interface Container {register<T>(token: Token<T>, factory: () => T): void;get<T>(token: Token<T>): T;
}
这样 MyServiceToken
运行时只是 Symbol
,但在类型系统里它携带了 MyService
。
- 如果你坚持用可调用接口,示例用法:
export interface ServiceIdentifier<T> {(...args: any[]): void;type?: T; // 可选
}// 用法示意(真实运行时不会有 type 信息)
const token = (function(){}) as ServiceIdentifier<MyType>;
function register<T>(id: ServiceIdentifier<T>, factory: () => T) { /* ... */ }
function get<T>(id: ServiceIdentifier<T>): T { /* ... */ return {} as T; }
面试时如何回答(要点):
- 说明接口是“callable + 带 type 属性”的混合。
- 解释为何要这么做(在 TS 的类型系统里传递泛型信息,方便 DI 等 API)。
- 提示运行时类型擦除,展示更实用的
symbol
token 或直接用类构造函数newable
作为 token 的替代方案。
手撕 2:关于 this
的指向问题(具体例子/深究)
已经在理论部分详细解释了调用规则;面试中常见题型与答案骨架如下:
常见题目:给出一段代码,问 this
指向是什么并说明原因。例如:
const obj = {x: 1,getX() { return this.x; }
};const f = obj.getX;
console.log(f()); // ?
console.log(obj.getX()); // ?
回答要点:
f()
:默认绑定 -> 严格模式undefined
,非严格模式window
。obj.getX()
:隐式绑定 ->this
是obj
。
复杂场景:obj.method().another()
、函数作为回调丢失上下文、箭头函数绑定外层 this
、bind
强制绑定、new
创建对象覆盖绑定等。演示 bind
与 new
的优先级。
TypeScript 补充:TS 支持 this
参数类型(仅编译时检查):
function foo(this: HTMLElement, ev: Event) {// 此处 this 被限定为 HTMLElement
}
手撕 3:实现节流函数(throttle)
说明:节流(throttle)保证在固定间隔内至多执行一次函数。与防抖(debounce)不同,防抖是持续触发时延后执行。
我给出一个常见且功能完整(支持 leading
、trailing
、cancel
、flush
)的实现(TypeScript):
type Procedure = (...args: any[]) => any;
interface ThrottleOptions {leading?: boolean; // 是否在开始立即调用trailing?: boolean; // 是否在结束后再调用一次
}function throttle<F extends Procedure>(func: F, wait = 200, options: ThrottleOptions = {}) {let timer: ReturnType<typeof setTimeout> | null = null;let lastExec = 0;let lastArgs: any[] | null = null;let lastThis: any = null;const leading = options.leading ?? true;const trailing = options.trailing ?? true;function invoke() {lastExec = Date.now();if (lastArgs) {func.apply(lastThis, lastArgs);lastArgs = null;lastThis = null;}}const throttled = function(this: any, ...args: any[]) {const now = Date.now();if (!lastExec && !leading) {lastExec = now;}const remaining = wait - (now - lastExec);lastArgs = args;lastThis = this;if (remaining <= 0) {if (timer) {clearTimeout(timer);timer = null;}invoke();} else if (!timer && trailing) {timer = setTimeout(() => {timer = null;invoke();}, remaining);}};throttled.cancel = () => {if (timer) {clearTimeout(timer);timer = null;}lastArgs = null;lastThis = null;lastExec = 0;};throttled.flush = () => {if (timer) {clearTimeout(timer);timer = null;}invoke();};return throttled as F & { cancel: () => void; flush: () => void; };
}/* 使用示例:
const fn = throttle((...a)=>console.log('call', a), 300);
window.addEventListener('resize', fn);
*/
补充:
- 简单实现可以用时间戳方式(立即执行 + 时间差判断)。
- 也可以用定时器方式(第一次延后执行),或混合(上面实现)。
- 注意清理定时器(cancel)以避免内存泄漏。
手撕 4:青蛙跳台阶问题
题目常见形式:青蛙一次可以跳 1 级或 2 级台阶,问爬上 n
级台阶有多少种跳法?(要求返回总数)
分析:
- 这是斐波那契数列问题:
f(1)=1, f(2)=2, f(n)=f(n-1)+f(n-2)
。 - 证明:最后一步可能是跳 1 级(前面有
f(n-1)
种)或跳 2 级(前面有f(n-2)
种)。
实现(多种解法):
- 递归(简单但会重复计算):
function waysRec(n) {if (n <= 1) return 1;if (n === 2) return 2;return waysRec(n-1) + waysRec(n-2);
}
- 迭代 DP(推荐,O(n) 时间 O(1) 空间):
function waysIter(n) {if (n <= 1) return 1;let a = 1; // f(1)let b = 2; // f(2)for (let i = 3; i <= n; i++) {const c = a + b;a = b;b = c;}return b;
}
- 矩阵快速幂 / 闭式(Binet)解法(当 n 很大时用来降复杂度到 O(log n))——面试中若被问可以提及,但实现较长。对于一般面试,迭代版就足够。
扩展题:如果青蛙可以跳 1…k 步,解法是 f(n) = sum_{i=1..k} f(n-i)
,可以用滑动窗口优化。
三、补充:常见面试问答小结与答题技巧
- 回答概念题时,先一句总结(核心观点),再分点详细阐述(实现/例子/面试常见陷阱)。
- 编码题:写出核心正确实现,讲清楚时间/空间复杂度,指出边界条件,若时间允许给更完善的版本(带取消、兼容
new
、异常处理等)。 - 当被问到“为什么”或“为什么不用××”,回答要从安全性、效率、数学性质或历史原因入手(例如握手不用对称加密 = 因为不能安全地先共享对称密钥)。