【面试复盘】前端底层原理与 React 核心机制深度梳理

写在前面

相信很多前端同学都有过这种绝望的时刻:明明八股文背得滚瓜烂熟,源码也看了几套,结果一上战场,面试官顺着你的回答随便追问一个'为什么',瞬间就哑火了。

'为什么不能用 index 做 key?' '为什么箭头函数不能 new?' 'useEffect 空数组到底闭包了什么?'

这些问题看似基础,但考察的绝对不是记忆力,而是你对 JavaScript 引擎机制和框架设计哲学的'第一性原理'理解

最近经历了一次深度的技术面试,我尝试换一种思路去答题------不谈表象,只谈本质。从 RAG 业务场景到 React 协调机制,从 JS 词法作用域到现代构建工具的范式转变。整理出这份近 5000 字的复盘,希望能帮大家把零散的知识点,串成一张坚不可摧的底层网。

项目部分

Rag 如何减少模型的幻觉?

RAG(检索增强生成)减少模型幻觉的核心逻辑,可以简单概括为四个字: "开卷考试"

大模型产生幻觉,根本原因在于它是"闭卷考试"------它只能依靠训练时记忆在神经网络里的权重来"猜测"下一个词,当记忆模糊或知识不足时,它就会一本正经地胡说八道。

引入RAG后,机制发生了根本变化,具体是如何减少幻觉的:

1. 提供事实"锚点"

在RAG流程中,模型在回答问题前,会先去外部知识库检索出相关的真实文档片段。模型生成回答时,是被要求严格基于这些检索到的片段来进行的。这就把模型从"凭空捏造"变成了"阅读理解",大大降低了脱离事实乱编的概率。

2. 划定知识的边界

没有RAG时,模型很容易"越界",比如用A领域的知识错误地回答B领域的问题。有了RAG,检索到的文档片段就像是给模型划定了范围,模型只需要在这个小范围内做总结和归纳,减少了发散性幻觉。

3. 增加了"拒绝回答"的能力

纯大模型往往有一种"迎合用户"的倾向,即使不知道也硬编。而在优秀的RAG设计中,如果检索系统发现没有找到与问题相关度足够高的文档(比如相似度得分低于某个阈值),系统可以直接拦截,返回"我没有找到相关资料",从源头上掐断了幻觉。

4. 结果可追溯

RAG的输出通常可以附带信息来源(比如引用了哪篇文档的第几段)。这不仅让用户可以自己去核实真伪,这种"被监督"的机制在工程上也会倒逼模型更谨慎地对待检索到的内容。

前端部分

React组件信息传递

1. 父传子:直接通过 Props 传递数据

父组件在渲染子组件时,将数据作为属性传入,子组件通过 props 接收。

jsx 复制代码
// 父组件
function Parent() {
  const message = "Hello from Parent";
  return <Child text={message} />;
}

// 子组件
function Child({ text }) {
  return <div>{text}</div>;
}

2. 子传父:通过回调函数

父组件传递一个函数给子组件,子组件在适当的时候调用这个函数,将数据作为参数传回去。

js 复制代码
// 父组件
function Parent() {
  const [childData, setChildData] = useState("");

  const handleReceiveData = (data) => {
    setChildData(data);
  };

  return (
    <div>
      <p>子组件传来的数据: {childData}</p>
      <Child onSendData={handleReceiveData} />
    </div>
  );
}

// 子组件
function Child({ onSendData }) {
  const handleClick = () => {
    onSendData("Hello from Child!");
  };
  return <button onClick={handleClick}>发送数据给父组件</button>;
}

二、 兄弟组件通信

兄弟组件之间没有直接的连接,必须借助它们的共同父组件 作为中转。这种方式叫做状态提升

原理: 将共享的状态提升到最近的共同父组件中,然后通过"父传子"把状态传给需要显示的兄弟,通过"子传父(回调)"让另一个兄弟修改状态。

三、 跨层级组件通信(祖孙组件)

如果组件层级很深(比如 A -> B -> C -> D),使用 Props 逐层传递会非常繁琐,这就是所谓的 Props Drilling(逐层透传) 。解决方法有两种:

1. Context API(React 内置方案)

Context 提供了一种在组件树中共享数据的方式,无需手动传递 props。

步骤: 创建 Context -> 提供 Provider -> 消费 Context。

2. 使用第三方状态管理库(Redux / Zustand)

当跨层级的组件非常多,或者状态逻辑非常复杂时,Context 可能会导致不必要的重渲染。这时通常会引入状态管理库(如目前最流行的 Zustand 或传统的 Redux),它们将状态独立于组件树之外进行管理。

说一下什么是闭包

闭包就是一个'随身携带记忆的函数'

从学术角度讲,它是一个函数以及其捆绑的周围环境(词法环境)的引用的组合。简单来说,就是一个内部函数,记住了并能够访问它外部函数的变量,即使外部函数已经执行完毕了。

产生闭包的根本原因在于 JavaScript 的词法作用域

词法作用域意味着,一个函数在定义的时候,就已经决定了它能访问哪些变量,而不是在调用的时候决定的。

正常情况下,函数执行完毕后,它内部的局部变量会被垃圾回收机制(GC)销毁,释放内存。但是,如果内部函数被返回到了外部,并且在外部被调用,由于内部函数还保持着对外部变量的引用,垃圾回收机制就不会销毁这些变量。这就形成了闭包。

闭包常见的场景?

1. 防抖和 节流

这是闭包最经典的应用。它们的目的是限制函数的执行频率,核心逻辑就是利用闭包缓存一个定时器(timer)变量

  • 场景: 搜索框输入联想、滚动条事件监听、窗口 resize。
  • 闭包体现: 外部函数接收你要执行的函数和等待时间,返回一个内部函数。内部函数每次触发时,都会去闭包里检查那个唯一的 timer 存不存在,以此决定是清除重新计时,还是直接跳过。

2. 函数柯里化

柯里化是把一个多参数函数,转换成多个单参数函数的过程。

  • 场景: 比如有一个通用的日志打印函数 log(level, date, message),你可以柯里化成 logError = log('error'),以后直接调用 logError('出错了')
  • 闭包体现: 内层函数记住了外层函数传入的 level 这个参数,形成了一个定制化的新函数。

React Hooks 的基石

如果你面 React,这条必说。Hooks 能在函数组件里"保存状态",完全依赖闭包。

  • useStateconst [count, setCount] = useState(0)。React 底层通过链表或者数组存了一个真实的 count 值。你每次调用的 setCount 和渲染出来的 UI,其实都是闭包,它们通过引用关联到了那个被 React 托管的内存地址。
  • useEffect / useCallback:它们的依赖数组机制,本质上就是在控制"我这个闭包要捕获哪一次渲染时的变量"。
  • 场景: 解决 React 中的 Stale Closure(闭包陷阱)问题,是高级前端必备技能。

箭头函数和普通函数的对比 ?哪个能用作构造函数?

  • 普通函数的 this 是动态的 :它取决于函数是怎么被调用的 。谁调用它,this 就指向谁(默认绑定、隐式绑定、显式绑定、new 绑定)。如果在严格模式下没调用者,this 就是 undefined

  • 箭头函数的 this 是静态的(词法作用域) :它没有自己的 this,它里面的 this 继承自它定义时所在的外层作用域 。而且一旦定义,就永远不会变,你用 callapplybind 去强行修改也没用。

"只有普通函数可以作为构造函数,箭头函数不能。 如果你尝试用 new 关键字去调用一个箭头函数,JavaScript 引擎会直接抛出 TypeError 报错。"

【核心:解释为什么不能?(展现底层原理)】

"要理解为什么不能,我们需要拆解一下 new 操作符在底层到底做了哪些事情。当执行 new Foo() 时,引擎会做四步:

  1. 创建一个空的内存对象。
  2. 将这个对象的 __proto__ 指向构造函数的 prototype
  3. 将构造函数内部的 this 绑定到这个新对象上。
  4. 如果构造函数没有显式返回其他对象,则返回这个新对象。

而箭头函数的设计初衷,恰恰与第 3 步水火不容 。箭头函数最大的特点就是没有自己的 this ,它的 this 是静态的,继承自外层词法作用域。

既然箭头函数连自己的 this 都没有,new 操作符就找不到目标去绑定这个新对象,所以 JS 规范在底层就直接禁止了这种行为,连尝试的机会都不给。"

说一下 React Key

关于 React 的 key,的本质并不是为了提升性能,而是为了身份标识。它是 React 在虚拟 DOM 树中进行节点比对时,用来判断'这个节点还是不是上次那个节点'的唯一凭证。

【第一层:底层运行机制(展现原理深度)】

"当组件状态更新触发重新渲染时,React 会生成新的虚拟 DOM 树,然后拿着新树和旧树进行 Diff 算法比对。

在没有 key 的情况下,React 只能采用'按顺序盲目对比'顺序对比。

一旦我们给列表项加上了 key,React 的比对策略就会变成'按 key 查找' 。React 会发现拥有某个 key 的元素在前后两次渲染中都存在,它就会认为这是同一个组件实例**,进而去复用这个实例,只更新它发生变化的属性。这就避免了组件的销毁和重建。"

什么是虚拟DOM?

不能用简单的一句'JS 对象'来概括虚拟 DOM。从本质上讲,虚拟 DOM 是前端在状态(数据)和真实 DOM 之间,建立的一层'缓冲层'或'抽象层'。它是 React 等现代框架实现'状态驱动视图'的核心基石。"

【第一层:为什么要发明虚拟 DOM?(讲透痛点)】

"在以前用 jQuery 时代,我们是'命令式'开发,状态一变,就要手动去操作 DOM(比如 document.createElementappendChild)。但操作真实 DOM 的代价是非常昂贵的,因为它会触发浏览器的重排和重绘,甚至牵一发而动全身。

现代框架是'声明式'开发,我们只关心状态 state 变成什么样,不关心 DOM 怎么变。虚拟 DOM 就是为了填补这中间的鸿沟。当状态改变时,框架生成一棵新的虚拟 DOM 树,然后跟旧的树进行比对,计算出最小差异,最后再一次性批量去操作真实 DOM。"

【第二层:它到底长什么样?(具象化展示)】

"从代码层面看,它确实是用普通 JS 对象来描述 DOM 节点的。比如一段 JSX:<div class="app"><h1>Hello</h1></div>,经过 Babel 转换后,在底层其实调用了 React.createElement,最终生成的大概是这样的一个对象树:

js 复制代码
{
  type: "div",
  props: { className: "app", children: [
    { type: "h1", props: { children: "Hello" } }
  ]}
}

它把原本极其复杂的真实 DOM 节点上的几百个属性和 API,精简成了我们真正关心的 type(类型)、keyprops(属性和子节点)。因为是纯 JS 对象,所以操作它的速度比操作真实 DOM 快几个数量级。"

【第三层:核心价值大反转(展现高级认知,极其加分!!!)】

"很多人(包括以前的我)以为虚拟 DOM 的最大优势是'比直接操作 DOM 快',其实这是一个常见的误区

JS 操作虚拟 DOM 的确快,但最终你还是要调用浏览器 API 去更新真实 DOM。如果你手动优化的足够好,原生 JS 操作 DOM 肯定是最快的。

虚拟 DOM 真正不可替代的价值在于:

  1. 为我们提供了批量更新和异步更新的能力:有了这层缓冲,React 就可以把多次状态更新合并成一次虚拟 DOM 计算,最后只打一次补丁,极大优化了性能。
  2. 抹平了环境差异,实现了跨平台 :这是最牛逼的一点。既然 divspan 只是 JS 对象里的一个 type: 'div' 字符串,那只要我写不同的"渲染器",告诉它遇到 'div' 在浏览器里怎么画,在移动端遇到 'View' 怎么画,不就能跨平台了吗?(这就是 React Native 和 React DOM 的底层原理)。如果没有虚拟 DOM 这个中间层,React 根本做不到一套代码多端运行。 "

【第四层:最新技术视野(防坑)】

"当然,虚拟 DOM 也不是万能的,它也有劣势,比如内存占用大(要维护两棵树),Diff 计算也有时间开销。所以现在像 Vue 3 引入了 Compiler-informed(编译时提示) ,SolidJS 甚至直接放弃了虚拟 DOM 走编译时,都是为了绕开虚拟 DOM 的运行时开销。但在 React 当前基于运行时的架构下,虚拟 DOM 依然是最优解。"

16. 为什么使用vite?

17. 为什么vite更快一些?

18. vite在开发的时候是基于什么构建的?

【回答 Q16 & Q17:为什么用 Vite?为什么这么快?------核心在于"范式转变"】

"Vite 之所以快,并不是因为它用了什么黑魔法,而是因为它改变了开发阶段的构建范式

传统工具是 '先打包,再服务'

而 Vite 在开发阶段是 '先服务,按需编译'

具体快在两个维度:

  1. 极速冷启动 :Vite 启动时,绝对不会去打包你的业务代码 。它直接启动一个静态服务器,利用浏览器原生的 ES Module(ESM)支持。当浏览器请求某个组件时,Vite 才去编译那个组件并返回。启动时间从跟项目体积成正比,变成了常数级(通常几百毫秒)。
  2. 极速热更新(HMR) :当你修改了一个 Vue/React 组件,Vite 只会精确地去重新编译这个模块,然后通过 ESM 的热替换机制让浏览器更新。它不需要像 Webpack 那样去重新构建整条依赖链,所以无论项目多大,HMR 都能保持在毫秒级。"

【回答 Q18:开发时基于什么构建?------亮出底层武器】

"为了支撑上面说的'按需编译',Vite 在开发阶段主要基于两个核心东西:

第一个,就是刚才说的浏览器原生 ESM,这是 Vite 快的机制基础。

第二个,就是预构建工具 Esbuild (基于 Go 语言编写)。

这里有个细节,Vite 的源码业务代码是按需编译的,但是对于 node_modules 里的第三方依赖(比如 React、Lodash),Vite 会在启动时用 Esbuild 把它们预先打包成 ESM 格式

为什么要多此一举?因为第三方依赖可能有几百上千个细碎的文件,如果让浏览器去发几千个 ESM 请求会直接卡死。而且,很多老一点的 npm 包还是 CommonJS 格式,浏览器不认识。用 Esbuild 预构建,既能把 CJS 转成 ESM,又能把几百个文件合并成几个大文件,极大地减少了网络请求。

Esbuild 为什么快?因为它用 Go 写的,去掉了 AST(抽象语法树)的解析过程,直接把代码转成机器码,速度比用 JS 写的 Webpack/Babel 快 10 到 100 倍。"

React

【回答 Q22:useRef 在哪些操作时会用到?】

"useRef 的核心特征就一句话:它的改变不会触发组件重新渲染。基于这个特性,我主要在以下三个场景使用它:

  1. 获取 DOM 元素的引用 :这是最基础的用法。比如页面加载后,需要让一个输入框自动聚焦,或者获取一个 canvas 节点来绘制图表,这时候就用 const inputRef = useRef(null) 绑定上去,然后通过 inputRef.current.focus() 命令式地操作 DOM。
  2. 存储不参与视图渲染的'可变值' :这是很多初学者容易忽略的。比如我在用定时器(setInterval)或者发请求时,需要保存一个 timerID 以便在组件卸载时清除;或者我想记录上一次的某个状态值用来做对比。如果用 useStatetimerID,每次存都会导致组件无意义的重渲染,而用 useRef 就完美解决了这个问题,它相当于一个贯穿组件整个生命周期的'全局变量'。
  3. 跨组件命令式通信 :结合 forwardRefuseImperativeHandle,父组件可以通过 ref 直接调用子组件内部暴露出来的方法(比如让子组件弹窗强制打开),打破常规的 props 数据流。"

【回答 Q23:useEffect 什么时候执行?】

"关于 useEffect 的执行时机,很多新手会把它和类组件的 componentDidMount 完全等同起来,其实不完全准确。它的精确执行时机是:在浏览器完成布局与绘制(即 DOM 更新完毕)之后,异步执行的

具体来说分三种情况:

  1. 不传依赖数组:组件每一次渲染(无论是初始化还是状态更新导致的重渲染),DOM 更新完之后,它都会执行。
  2. 传入依赖数组(比如 [count] :组件初次渲染会执行一次;之后,只有当依赖数组里的变量发生改变,导致重渲染完毕后,它才会再次执行。
  3. 清理函数的执行时机useEffect 里面 return 的函数,会在组件卸载前 执行,或者在下一次 Effect 执行前执行(用来清除上一次的定时器或解绑事件)。"

【回答 Q24:useEffect 依赖数组为空时,什么时候执行?(核心考点)】

"这里需要纠正一个小概念, '渲染'和 'Effect 执行'是两回事 。当依赖数组为空 [] 时:

  1. 执行时机 :它仅仅在组件初次挂载、完成第一次真实的 DOM 渲染之后,执行一次 。之后无论组件因为什么原因(父组件传值变了、自己的其他 state 变了)重渲染多少次,这个 Effect 都绝不会再执行。
  2. 最大的坑:闭包陷阱
    正因为空数组让 Effect 只执行一次,这就意味着它内部形成了一个永远闭包住初次渲染状态 的闭包。
    比如,如果我在 useEffect([]) 里写了一个 setInterval,里面去读取外部的某个 state,那么这个定时器读到的 state 永远是初始值,永远不会更新,这就是 React 中臭名昭著的'闭包陷阱'。
  3. 如何解决 :如果你在空数组的 Effect 里要用到最新的值,要么把该值加入依赖数组(但要注意可能会引发多次执行和清理),要么使用 useRef 把最新值存起来(因为 ref 的修改不依赖渲染,Effect 里面读 ref.current 总能拿到最新值)。"
相关推荐
浮午17 小时前
Agentic RAG:从检索增强生成到智能体驱动的问答系统
面试
excel17 小时前
为什么需要构建工具(Webpack / Vite 的本质)
前端
lang2015092817 小时前
Java SAX 流式解析全解:从原理到 EasyExcel 实战
java·前端·javascript
Rain50917 小时前
2.4. PostgreSQL 数据库连接与实战指南
前端·数据库·人工智能·后端·postgresql·数据分析
console.log('npc')17 小时前
Codex 桌面端接入 Headroom 压缩代理完整教程
前端·vscode
独泪了无痕17 小时前
Vue集成uuid生成唯一标识实践指南
前端·vue.js
yuanyxh1 天前
Mac 软件推荐
前端·javascript·程序员
万少1 天前
AtomCode开发微信小程序《谁去呀》 全流程
前端·javascript·后端
某人辛木1 天前
Web自动化测试
前端·python·pycharm·pytest
Kagol1 天前
Superpowers GSD gstack AgentSkills深度测评
前端·人工智能