百度前端面试:JSX 与 Babel、SSE 流式输出与 RAG 多路召回
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
- JSX 是什么?为什么要用这样的写法?原理是什么?浏览器是如何识别并解析的?
- 在低版本浏览器上支持高版本语法,会用什么工具?Babel 解析的原理是什么?和 polyfill 有什么区别?
- 浏览器从加载页面到渲染的流程是什么?
- 常见 React Hooks 有哪些?
- useEffect 的依赖项如果是空数组代表什么意思?
- useMemo、useCallback、React.memo 分别缓存什么?什么时候真的有用?
- 什么是 Virtual DOM?React 为什么需要它?介绍一下 React 的虚拟 DOM 和 diff 算法。
- JSBridge 是什么?H5 怎么调用 Native?Native 又怎么把结果回调给 JS?底层原理了解吗?
- WebView 是什么?H5 跑在 App 里时到底发生了什么?
- React Native 这类跨端框架是怎么工作的?
- HTTP 短轮询、SSE、WebSocket 分别是什么?各自适合什么场景?
- SSE 为什么适合 AI 流式输出?怎么做断线补发?
- 介绍一下 Webpack 的原理,完整构建流程是什么?
- Loader 和 Plugin 有什么区别?分别解决什么问题?
- Skills、MCP、A2A 有什么区别?
- Harness 工程:如果想让 AI 按指定工作流执行,应该怎么做?
- 智能体的上下文你是怎么给的?如果一次性给太多信息怎么处理?Agent 上下文太大怎么办?
- Skill 按需加载怎么组合?
- SSE 流式事件的作用是什么?还了解其他通信方式吗?
- 介绍 RAG 多路召回。
- 为什么 RAG 要建索引?为什么不能每次直接扫全部知识库?
- 你觉得除了提问式的对话,Agent 还有什么其他形态?
- 二叉树前序、中序、后序、层序遍历分别是什么?
- DFS 为什么常用栈或递归,BFS 为什么常用队列?
- 手撕:二叉树的层序遍历。
- 反问环节:你有什么想问面试官的?
《参考解析》
从 JSX 到浏览器渲染:先把「语法糖—编译—渲染」这条线串起来
JSX 本质是语法糖,浏览器完全不认,它在构建期被转换成函数调用(经典运行时是 React.createElement,新的自动运行时是 jsx/jsxs 并用 jsxImportSource 自动引入),产出的仍是普通 JavaScript,所以才需要构建工具。Babel 是源码到源码的编译器,工作分三个阶段:@babel/parser 把源码解析成 AST,插件在 transform 阶段遍历并改写 AST,@babel/generator 再把 AST 还原成代码并生成 sourcemap;插件按访问者模式匹配节点类型,这也是写自定义 Babel 插件的入口。和 polyfill 的区别要落在判据上:Babel 的 preset-env 做的是「语法降级」——箭头函数、可选链、类字段这类新语法能改写成旧语法;而 Promise、Array.prototype.includes、Object.fromEntries 这类是运行时 API,旧环境根本没有对应实现,改语法改不出来,必须靠 core-js 之类的垫片注入。判断标准就是「这是语法还是 API」。
浏览器的渲染流程从导航开始:URL 解析后走 DNS、TCP、TLS,收到 HTML 后边下载边解析构建 DOM(解析中遇到同步 script 会阻塞,defer 是解析完按序执行、async 是下载完就执行),同时构建 CSSOM,两者合成渲染树后进入样式计算、布局(计算几何位置)、绘制(生成绘制指令)、合成(分层后交给 GPU 合成)。性能优化的常见落点都挂在这条线上:改几何属性触发重排,改颜色等触发重绘,transform 与 opacity 可以只走合成层;频繁读取布局属性再写入会造成强制同步布局(布局抖动),要用读写分离或 requestAnimationFrame 批处理;长任务会阻塞主线程,所以配合 will-change、虚拟列表、代码分割等手段控制单帧工作量。
React 的更新机制:Hooks 规则、三种缓存与虚拟 DOM 的价值
Hooks 有几个容易考到却容易被忽略的点:Hooks 是按调用顺序存在链表上的,所以不能在条件判断或循环里调用,否则顺序错位、状态全乱;useEffect 依赖数组为空表示只在挂载后执行一次,返回的函数在卸载时执行,而开发环境的严格模式会故意执行两遍以暴露副作用没清理的问题;依赖数组写漏会导致闭包捕获旧值,这是实际项目里最隐蔽的一类 bug。
三种「缓存」要讲清各自缓存的对象:useMemo 缓存计算结果,避免每次渲染都重算昂贵逻辑;useCallback 缓存函数引用,好处体现在把函数传给用 React.memo 包裹的子组件或作为其它 Hook 的依赖时;React.memo 缓存组件本身的渲染结果,靠浅比较 props 决定是否跳过渲染。三者只有在「引用相等性真的被下游用来做判断」时才有效,否则只是多了一层比较和内存占用——判断方法很简单:看这个值有没有进依赖数组、下游组件有没有 memo。
Virtual DOM 的价值不是「比直接操作 DOM 快」,而是把 UI 描述成数据、让声明式写法与批量更新成为可能,再用 diff 求出最小更新集。React 的 diff 用的是几条启发式:只做同层比较(跨层移动会被当成删除加新建)、类型不同直接重建子树、列表靠 key 判断节点身份——用数组下标当 key 在列表顺序变化时会导致状态错位,这是常被追问的一个点。要主动补一句:手写精细的 DOM 操作在单点上可以比 diff 快,虚拟 DOM 换来的是一致性、可组合和维护成本。
JSBridge、WebView 与 React Native:跨端的三条路
WebView 是宿主 App 提供的浏览器内核容器,H5 在里面跑的是和浏览器一致的那套渲染流程,区别在于多了与 Native 的通信边界:能力受限(拿不到全部系统 API)、生命周期受宿主控制、还要处理与原生页面的滚动和返回手势协调。JSBridge 就是把这个边界打通的一层协议。JS 调 Native 的常见实现有几条:拦截 URL scheme(iframe.src 或 location.href,实现简单但参数长度与性能受限)、拦截 prompt/console(H5 里比较古老的做法)、注入对象(Android 的 addJavascriptInterface)、以及 iOS 上 WKWebView 的 WKScriptMessageHandler(现在是主流)。Native 回调 JS 则是通过 evaluateJavascript 执行一段调用回调的脚本,工程上要给每次调用分配 callbackId,在 JS 侧维护回调注册表,把异步的桥接调用包装成 Promise,并配超时与重试;注入时机也要注意——桥对象通常要等页面加载到特定阶段才可用,所以调用前要探测或等待 ready 事件。安全上必须校验来源域名、限制可调用的方法白名单,避免暴露任意系统能力。
React Native 走的是另一条路:不用 WebView,JS 线程通过 Bridge 与原生通信(新架构换成 JSI 直连加 Fabric 渲染与 TurboModules),布局由 Yoga 按 flex 规则算出,再映射成真实原生视图,所以渲染结果是原生控件、性能与体验比 WebView 方案好,代价是生态与一致性成本、以及跨线程通信仍有开销。和小程序、Flutter 这类方案对比时可以概括成三种思路:WebView 套壳(Web 一套代码,体验上限最低)、自绘引擎(Flutter 在 Skia 上画,一致性最好但要自带控件体系)、原生映射(RN 把组件映射成原生视图,介于两者之间)。
短轮询、SSE 与 WebSocket:AI 流式输出为什么选 SSE
三者的差别用「谁先说话、要不要双向、连接维持成本」三个问题就能分清。短轮询是客户端定时问,实现最简单、服务端无状态,但延迟等于轮询间隔、大量请求是空转,只适合状态更新不频繁且对延迟不敏感的场景;长轮询是服务端 hold 住请求直到有数据或超时,实时性比短轮询好,但每来一次数据就要重建连接,服务端也占着连接资源;SSE 是服务端在一条 HTTP 连接上单向推送,响应类型是 text/event-stream,浏览器自带重连与 Last-Event-ID 续传语义,纯文本、易调试、穿过代理无压力;WebSocket 是握手后升级成独立协议的全双工连接,双方随时可发,适合聊天、协同编辑、需要低延迟双向心跳的场景,代价是要自己做心跳、重连、鉴权和连接治理。
SSE 适合 AI 流式输出的原因很直接:模型的输出是单向、逐 token 增量产生的,正好匹配单向推送;文本帧省去二进制协议的编解码;在网关后面比 WebSocket 更容易透传。断线补发的做法是给每个事件带递增 id(例如消息分片序号)和事件类型,客户端重连时浏览器自动在请求头带上 Last-Event-ID,服务端据此从该位置继续推;服务端还要处理「生成已经结束但连接断了」的情况——客户端重连时先按消息 id 回查已落库的完整内容,避免只拿到后半段。工程坑要一起说:Nginx 等反向代理必须关闭响应缓冲(否则会攒一批再吐,失去流式效果)、要放宽读超时、HTTP/1.1 同域名连接数有限会导致多个会话排队(HTTP/2 多路复用可缓解)、以及前后端要约定结束事件与错误事件,让前端能区分「正常结束」和「中途失败」。
Webpack 的构建流程与 Loader、Plugin 的分工
Webpack 的核心是「一切皆模块」加依赖图。流程大致是:从 entry 出发,用 resolver 定位模块路径,按 module.rules 匹配 loader 链(从右到左、从下到上依次执行)把非 JS 资源(CSS、图片、TS)转换成可被打包的模块,用解析器把源码转成 AST 找出 import/require 形成依赖图,递归处理直到没有新依赖;随后进入优化阶段——tree shaking(依赖 ESM 的静态分析与 sideEffects 标记)、splitChunks 分包、scope hoisting 把模块合并进同一作用域——再由生成器产出 chunk 与 asset,把最终的 bundle 与运行时(模块注册表、__webpack_require__)写入输出目录。开发模式下的 HMR 是 devServer 通过 WebSocket 通知客户端,客户端拉取变更模块并替换,同时把更新冒泡到边界模块。
Loader 与 Plugin 的区别可以用一句话概括:Loader 面向「文件内容」,是一个把源码字符串(或 buffer)转成模块的函数,在模块构建阶段被调用,职责单一、可以链式组合,例如 babel-loader、css-loader、ts-loader;Plugin 面向「构建流程」,是一个带 apply(compiler) 的类,通过 tapable 的钩子挂到编译生命周期的各个节点上,能做的事远超模块转换——产出额外文件、改配置、优化 chunk、注入变量,例如 HtmlWebpackPlugin、MiniCssExtractPlugin、DefinePlugin。回答时可以主动补一句实践:Loader 解决不了「构建产物级别」的事,所以像「把 CSS 抽成独立文件」这种需求必须用 Plugin。
Agent 工程:Skills 与 MCP 与 A2A、Harness、上下文与 RAG
这三个缩写是不同层次的协议/机制,别并列比较。MCP 解决「能连什么」——模型与外部工具、数据源之间的标准化协议,规定服务端如何暴露工具与资源、客户端如何发现与调用,目的是可插拔,换客户端或换模型不用重写集成。Skill 解决「该怎么做」——把某类任务的方法、步骤、约束组织成可被加载的知识文件,本质是程序性记忆,按需渐进式加载以省上下文,与 MCP 是互补关系:工具能力来自 MCP,使用策略来自 Skill。A2A 解决「谁来干、怎么交接」——Agent 与 Agent 之间的协作协议,关注任务委派、状态同步与跨进程/跨团队的互操作,比前两者更靠上层。它们可以同时存在:一个 Agent 通过 A2A 把子任务交给另一个 Agent,对方用 MCP 调工具、按 Skill 描述的流程执行。
Harness 想让 AI 按指定工作流执行,关键是把流程从提示词里拿出来交给代码:用状态机或图编排定义节点与条件边,每个节点只注入该步需要的上下文和工具白名单,输入输出用 schema 约束并校验,失败按策略重试或转人工,全程留痕可回放。上下文管理的思路是分层装配——把「系统约束与当前状态」设为必留、「本轮任务相关内容」设为按需、「历史与检索结果」设为可裁剪;长历史做摘要或滚动窗口,工具返回只保留结论与引用而不是原始大段文本,检索结果先 rerank 去重再截断,能复用前缀的稳定内容放在前面以命中提示缓存。超出预算时优先丢弃旧对话与已完成步骤的中间过程,保留约束、最新状态和未完成的计划;即使用长上下文模型也要做这些,因为成本、延迟和注意力稀释三个问题不会因为窗口变大而消失。
RAG 多路召回是把不同检索通道的结果融合后统一精排:向量召回负责语义相近(同义改写、口语化问题),关键词或 BM25 负责精确实体与编号(型号、错误码、专有名词,这正是向量容易漏的),元数据过滤负责时间、部门、权限这类结构化约束,再加父文档或摘要召回解决「片段太碎、缺少上下文」的问题;多路结果按倒数排名融合或加权合并,交给 cross-encoder 精排后取 top-k 送入生成。为什么要建索引:全量扫描是 O(N) 且每次都要算相似度,延迟与成本随库增长不可接受,索引把检索变成亚线性的近似最近邻查找(HNSW、IVF 这类),同时支持过滤与增量更新;代价是召回率不再百分之百,所以要用关键词路和精排把漏召回兜回来。关于 Agent 的其他形态,可以从交互方式上答:固定工作流型(把确定流程编排成图,稳定可审计)、事件驱动或后台常驻型(监听消息、定时巡检、异常主动介入)、多智能体协作型(分工加评审,用互相检查换质量)、以及操作型(浏览器与桌面自动化,代替人点界面),再补一句形态的选择取决于任务的可预测性与错误的代价。
手撕层序遍历与 DFS/BFS 的取舍
遍历本身要能分清「根的位置」:前序是根左右、中序是左根右、后序是左右根,递归写法三行;层序不用递归,用队列做 BFS。手撕层序遍历的模板是:根入队,每轮先记录当前队列长度 size,循环 size 次出队并把左右子节点入队,这一轮出队的节点就是同一层,收集成数组。要主动提几个变体:之字形遍历(按层号反转偶数层)、每层取最大值、从下往上输出(最后反转结果);用 DFS 也能做层序,代价是要带深度参数并按深度往结果里填,空间从「最大宽度」变成「树高」,这正好引出 DFS 与 BFS 的取舍。
为什么 DFS 用栈或递归、BFS 用队列,要答到数据结构与访问顺序的对应关系上:DFS 需要「后进先出」才能回到最近的岔路继续深入,递归本质上就是函数调用栈;BFS 需要「先进先出」才能保证同层的节点先于下一层被处理,所以用队列。空间复杂度上,DFS 与树高(或图的深度)相关,BFS 与最大宽度相关,所以宽而浅的树用 DFS 省内存、深而窄的树用 BFS 更稳(DFS 还可能爆栈,可以改写成显式栈或加深度上限);图上的遍历两者都要 visited 去重,BFS 的另一个用途是在无权图上求最短路径,因为它是按层扩展的。