news 2026/9/15 9:04:27

JavaScript面试题场景化整理:从闭包到工程化调试的实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JavaScript面试题场景化整理:从闭包到工程化调试的实战解析

写 JavaScript 面试题整理这件事,其实挺私人的。我干了这些年前端,既被人面过,也面过人,最后发现市面上那些“JavaScript 面试题合集”最大的问题不是题不够多,而是太零散——今天背个闭包,明天背个原型链,问的时候好像都会,真到项目里写起来又是另一回事。所以我自己整理了一套题目,不按“八股文”的顺序来,而是按实际开发会遇到的场景去组织:基础语法和执行机制、ES6 的日常操作、浏览器和 DOM 的交互、工程化报错排查,全都揉在一起。这篇就把我整理的这版题目和对应的答题思路完整放出来,适合准备面试的前端新人,也能给带团队的朋友当内部考核素材。

1. 为什么要按“题目场景化”来整理 JavaScript

1.1 背八股文为什么容易翻车

我面过不少简历写得挺漂亮的候选人,问“闭包是什么”,能一字不落背出“函数嵌套函数、内部函数引用外部变量”这个标准答案。但让他写一个 3 秒内连续点击只触发一次的“防抖函数”,立刻就卡住了。这个现象很典型:题目背熟了,但不理解背后的运行机制

传统的面试题集往往按知识点列表排列,var/let/const 一题、闭包一题、原型链一题、事件循环一题,彼此之间没有联系。但真实开发里这些东西是叠加在一起的:你写一个 React 组件,里面用到 useState,涉及闭包;函数里用了 this,涉及调用方式;组件卸载时要清理监听器,又涉及事件机制。所以我整理题目时,刻意把“知识点”升级成“场景”,每一道题都能对应到一个真实会写的代码片段。

1.2 这套题覆盖到的四个高频维度

这套题我按四个维度来收拢,基本对应前端日常开发最常碰到的 JavaScript 部分:

  • 语言核心机制:变量提升、作用域、闭包、this、原型链,这些是面试必问也是理解一切上层框架的地基。
  • ES6+ 日常操作:合并对象、filter、箭头函数、解构,这些是写业务代码时每天都在用的语法糖,但很多人只知其然。
  • 浏览器与 DOM 交互:事件监听、表单提交、伪协议链接、地址栏调试,这些属于“和浏览器打交道”的 JavaScript,容易被纯框架开发者忽略。
  • 工程化与报错排查:模块加载失败、运行时报错、开发工具配置,这些是真正工作时消耗时间最多的地方,面试官也很爱问。

我会把热搜词里那些零散的问题,比如“html、css、javascript 配置”“合并两个对象”“filter 函数”“javascript 监听”“表单提交和 H5 的区别”“未 new 完的对象为何能使用 prototype”“failed to load module script”等,全部塞进这四个维度里,每道题给出我的答题模板和背后的原理。

2. 基础语法与执行机制:必考题背后的原理

2.1 var、let、const 与变量提升

这道题几乎是 JavaScript 面试的第一道开胃菜,但想答好并不容易。核心要掌握两个概念:变量提升(hoisting)和暂时性死区(TDZ)。

先说变量提升。var声明的变量会被提升到当前作用域顶部,赋值留在原地。所以下面这段代码不会报错,只会输出undefined

console.log(a); // undefined var a = 10;

letconst不一样。它们同样会被提升,但在真正声明之前处于“暂时性死区”,提前访问会直接抛ReferenceError

console.log(b); // ReferenceError: Cannot access 'b' before initialization let b = 20;

我面试时一般会接着问一句:“为什么要有暂时性死区?”这是个加分点。答案在于:var提升后初始化为undefined,你可以在声明前使用;let/const提升后处于未初始化状态,JavaScript 引擎为了防止你拿到一个未定义的值,干脆禁止访问。这个设计主要是为了减少隐式错误,让代码更可预测。

函数声明也有提升,而且优先级高于变量。经典题目是这段:

console.log(typeof foo); // 'function' function foo() {} var foo = 1;

这里typeof foo输出'function',因为函数声明整体提升,而var foo只是变量名提升,值还是undefined,不会覆盖函数。很多人在这一步会混淆,我自己的记忆办法是:函数声明是“整块搬上去”,变量声明是“只搬名字上去”

2.2 闭包与 this 指向

闭包为什么常考?因为它既是基础,又是实际开发中的“坑王”。简单说,闭包就是一个函数记住了它创建时所处作用域的变量,即使外部函数已经执行完了,内部函数仍然能访问那些变量。最经典的计数器:

function createCounter() { let count = 0; return function () { count++; return count; }; } const counter = createCounter(); console.log(counter()); // 1 console.log(counter()); // 2

这里的count不会因为createCounter执行完就被回收,而是被返回的函数一直引用着。这个机制可以用于封装私有变量、实现缓存、写防抖节流等。但闭包也容易造成内存泄漏,尤其是把闭包挂到全局或者长期存活的 DOM 元素上时不注意释放。

this 的指向是另一座大山。我总结的规律是:this 指向取决于函数怎么被调用,而不是定义在哪。普通函数默认调用时 this 指向全局对象(浏览器里是 window),严格模式下是 undefined;对象方法调用时 this 指向这个对象;用 call/apply/bind 可以显式指定;箭头函数没有自己的 this,而是继承外层作用域的 this。

这里有个对比表方便记忆:

调用方式this 指向
普通函数调用fn()严格模式 undefined,非严格模式 window/全局对象
对象方法调用obj.fn()obj
call/apply 调用fn.call(obj)指定的 obj
bind 生成的函数bind 时绑定的对象
箭头函数定义时外层作用域的 this
new 调用new Fn()新建的对象实例

2.3 原型链:为什么 new 到一半也能用 prototype

原型链相关的题里,热搜词有一条很刁钻:“未 new 完的对象为何能使用 prototype”。这其实问的是 new 操作符的执行过程。

很多人以为 new 就是“调用一下构造函数”,其实 new 干了四件事:

  1. 创建一个全新的空对象。
  2. 把这个空对象的原型(__proto__)指向构造函数的prototype属性。
  3. 把构造函数里的 this 绑定到这个新对象上,执行构造函数体。
  4. 如果构造函数没有显式返回对象,就把这个新对象作为 new 的结果返回。

关键就在第 2 步。在执行构造函数体之前,新对象和构造函数 prototype 的链接已经建立好了。所以在构造函数内部,哪怕第一行代码就访问this.somePrototypeMethod,也能顺着原型链找到。举个例子:

function Person(name) { // 此时 this 已经通过原型链能访问到 sayHello console.log(this.sayHello); // [Function: sayHello] this.name = name; } Person.prototype.sayHello = function () { console.log('hello, ' + this.name); }; const p = new Person('Tom'); p.sayHello(); // hello, Tom

输出[Function: sayHello]而不是报错,就是因为 new 在第 2 步就把原型链接好了。这也是为什么“没 new 完就能用 prototype”——对象和原型的关联发生在构造函数执行之前,而不是之后。理解这个顺序,对后续理解继承、instanceof的原理都有帮助。

3. ES6+ 高频操作:合并对象、filter、箭头函数

3.1 合并两个对象的几种方式

业务开发里合并对象实在太常见了,比如合并默认配置和用户传入的配置、合并分页参数和筛选条件。ES6 之后主要有两种写法:

const obj1 = { a: 1, b: 2 }; const obj2 = { b: 3, c: 4 }; // 方式一:Object.assign const merged1 = Object.assign({}, obj1, obj2); // 方式二:展开运算符 const merged2 = { ...obj1, ...obj2 };

两种方式都是浅拷贝,后边的属性会覆盖前边的同名属性,上面merged的结果都是{ a: 1, b: 3, c: 4 }。展开运算符写起来更简洁,现在项目里基本都用它。

面试时通常会接着问:“如果对象里嵌套了对象或者数组,浅拷贝会不会有问题?”这个问题的标准答案是:浅拷贝只拷贝一层引用,内层对象修改会互相影响。看这个例子:

const objA = { info: { age: 18 } }; const objB = { ...objA }; objB.info.age = 20; console.log(objA.info.age); // 20,被改了

要真正独立,需要深拷贝。新版浏览器可以直接用structuredClone

const objC = structuredClone(objA); objC.info.age = 30; console.log(objA.info.age); // 18,互不影响

或者用JSON.parse(JSON.stringify(obj)),但这种方式会丢失函数、undefined、Date 等特殊类型,面试时要提一嘴它的局限。我一般在项目里,能浅拷贝就浅拷贝,真要深拷贝优先structuredClone,老环境才用 lodash 的cloneDeep

3.2 filter 函数:从使用到原理

filter 是数组三兄弟(map、filter、reduce)里出镜率最高的一个,也是面试基础题里必考的。用法一句话就能说清:遍历数组,把回调函数返回真值(truthy)的元素收集起来,组成新数组返回,原数组不变

const nums = [1, 2, 3, 4, 5, 6]; const evens = nums.filter((num) => num % 2 === 0); console.log(evens); // [2, 4, 6] console.log(nums); // [1, 2, 3, 4, 5, 6]

注意回调返回的是“真值”而不是严格的true,所以返回1'a'{}都会被当成通过。我自己踩过的坑是:想过滤掉数组里的空值,直接写arr.filter(Boolean),这个写法会把0''nullundefinedNaN全过滤掉,如果业务上需要保留0,就会出问题。正确做法是先明确过滤条件。

filter 经常和 map 链式用,比如先筛出符合条件的商品,再提取名称:

const products = [ { name: '鼠标', price: 50, inStock: true }, { name: '键盘', price: 200, inStock: false }, { name: '显示器', price: 1000, inStock: true } ]; const names = products.filter((p) => p.inStock).map((p) => p.name); // ['鼠标', '显示器']

这个链式调用很能体现对数组方法的熟练度。面试官如果追问 filter 的原理,可以答:它内部就是遍历数组、执行回调、把满足条件的 push 进新数组。能自己手写一个简易 filter,基本就过关了。

3.3 箭头函数与普通函数的差异

箭头函数是 ES6 里最常用的语法之一,但它和普通函数的差别如果只说“写法更简洁”,肯定不够。我总结成四个差异:

第一,this 绑定方式不同。普通函数的 this 是调用时确定的,箭头函数的 this 是定义时从外层作用域继承的,而且无法通过 call/apply/bind 改变。第二,没有 arguments 对象。在箭头函数里用 arguments,实际取的是外层函数的 arguments。第三,不能用作构造函数,不能 new,也没有 prototype 属性。第四,不能作为生成器函数,不能使用 yield。

实际开发里最常见的记忆点就是 this。比如在定时器里想访问外层 this,普通函数要先用var self = this或者用 bind,箭头函数直接写就行:

function Timer() { this.seconds = 0; // 普通函数写法需要缓存 this setInterval(() => { this.seconds++; console.log(this.seconds); }, 1000); }

什么时候不该用箭头函数?对象方法里如果用箭头函数,this 不会指向对象,而是指向外层作用域:

const obj = { name: 'obj', getName: () => { return this.name; // this 是外层作用域,通常不是 obj } }; console.log(obj.getName()); // undefined

这类题我在面试时必问,因为它能快速判断候选人是对语法熟悉,还是真正理解执行机制。

4. 浏览器 API 与 DOM 交互题

4.1 JavaScript 监听:addEventListener 与事件委托

“javascript 监听”这个热词指的就是事件监听。现代前端开发基本都用addEventListener,而不是老式的onclick属性。区别在于:addEventListener可以给同一个元素绑定多个事件处理函数,而且可以控制事件在捕获阶段还是冒泡阶段触发。

基础写法:

const btn = document.getElementById('btn'); btn.addEventListener('click', function (e) { console.log('按钮被点击了', e.target); });

面试进阶题是事件委托。原理是事件有冒泡机制,子元素触发的事件会一路传到父元素。利用这一点,可以只在父元素上绑定一次监听,统一处理所有子元素的事件。典型的例子是动态列表:

const list = document.getElementById('list'); list.addEventListener('click', function (e) { if (e.target.tagName === 'LI') { console.log('点击了', e.target.textContent); } });

这样后续往列表里新增的 li 元素不需要重新绑定事件,因为监听器在父元素上。好处很实际:一是动态元素也能触发,二是减少内存占用。面试时再追问“如何移除监听”,答案是用removeEventListener,但注意传入的函数必须是同一个引用,所以匿名函数无法移除,需要先把处理函数提出来。

4.2 表单提交与 HTML5 表单验证的区别

表单这块,传统做法和 HTML5 原生方案差别很大。老式写法是给表单加onsubmit事件或者按钮加onclick,在 JavaScript 里手动校验,校验通过再提交,不通过就return false或者调preventDefault()

form.addEventListener('submit', function (e) { const input = document.getElementById('email'); if (!input.value.includes('@')) { e.preventDefault(); // 阻止提交 alert('邮箱格式不正确'); } });

HTML5 之后,很多基础校验浏览器已经内置了,不需要写 JavaScript。比如<input type="email" required>会自带格式校验和“必填”提示。还有pattern属性可以用正则限定格式:

<input type="text" pattern="[0-9]{11}" title="请输入11位数字" required />

这两套方案的关系不是替代,而是互补。HTML5 解决的是“格式对不对”的问题,业务校验(比如“用户名是否已占用”“两次密码是否一致”)仍然需要 JavaScript。有一个关键点很多新手不知道:如果给表单加了novalidate属性,HTML5 内置校验会整体失效,提交时不再自动拦格式错误,这时候所有校验都必须用 JavaScript 处理,适合需要自定义报错样式的场景。

说到“表单提交和 H5 的区别”,我还被问过submit事件和click事件的关系。直接 submit 按钮触发表的 submit 事件,但<button type="button">不会触发 submit,这点容易踩坑。实际开发里,我习惯用 submit 事件而不是按钮 click 事件来做表单提交,因为前者能兼顾回车键提交的情况。

4.3 那些带 javascript: 前缀的链接与地址栏小技巧

javascript:;是很多老项目里常见的写法,一般出现在 a 标签的 href 里,目的是点击链接时不让页面跳转、不让页面滚动到顶部:

<a href="javascript:;">点击</a>

原理是把 href 当成一段 JavaScript 代码来执行,;表示空语句,所以点了什么也不发生。现在更推荐的写法是直接用 button 标签,或者给 a 标签的事件回调里加e.preventDefault(),因为javascript:伪协议在部分场景下会和内容安全策略(CSP)冲突,也不利于可访问性。

其实浏览器地址栏本身也支持执行 JavaScript 代码,这就是热词里那条v = document.querySelector('video'); v.style.rotate = '-90deg'的玩法。在网页里按下 F12 打开控制台,或者直接在地址栏输入javascript:开头的内容回车,就能操作页面上的元素。以前想在网页上看视频但视频方向横了,就可以用类似代码把视频旋转 90 度。这些属于调试技巧,对排查页面样式和 DOM 状态很有用,但要注意几件事:

一是只能在自己的页面或者公开页面做调试,不要用在别人网站里搞破坏;二是有一些浏览器出于安全考虑,会在地址栏输入javascript:时自动忽略,建议直接用开发者工具控制台;三是如果页面里嵌套了 iframe,直接document.querySelector可能拿不到跨域 iframe 里的元素,需要先切换到对应的 frame 上下文。

另外提醒一句:如果收到“+添加账号 javascript:;”这类链接,或者是来源不明的网站把javascript:写进链接诱导点击,要格外警惕,这类代码有可能会执行不可信的脚本。遇到不熟悉的链接,先看代码再决定要不要点。安全意识和业务能力一样重要。

5. 工程化与调试:报错排查与工具配置

5.1 HBuilder 里配置 html、css、javascript

HBuilder(现在主要是 HBuilderX)是不少用 uni-app 或者做跨端开发的人会碰到的 IDE。其实配不配 JavaScript,关键看项目类型。如果建的是“Web 项目”或者“基本 HTML 项目”,HBuilderX 默认会给你生成 index.html,里面自动关联了 css 和 js 文件,目录结构类似:

├── css │ └── style.css ├── js │ └── main.js └── index.html

index.html 里默认会引入这两个文件:

<link rel="stylesheet" href="css/style.css"> <script src="js/main.js"></script>

日常开发时,改完 js 或 css,保存后切到浏览器刷新就行。HBuilderX 自带“运行到浏览器”功能,点一下会起一个本地服务器并把页面打开,用这个方式会比直接双击 HTML 文件好一些,因为部分浏览器 API(比如 fetch 请求本地 json)在 file:// 协议下会被拦截。

需要注意的一点是:HBuilderX 的代码提示和 ESLint 检查默认没有完全打开,想在保存时自动格式化 JavaScript,需要在工具设置里开启“保存时自动格式化”,以及安装对应的插件。如果项目用了 uni-app,那 js 文件的运行环境比较特殊,不能直接当浏览器环境来调,得用微信开发者工具或者内置浏览器预览。这些配置不算复杂,但第一次用的人容易找不到入口,我习惯直接记住菜单路径:工具 -> 设置 -> 插件配置。

5.2 Failed to load module script 报错

热词里有一条很具体:“failed to load module script: expected a javascript module script but the server responded with a MIME type of ...”。这个报错我见过好多次,第一次遇到时也懵了半天。

出现这个报错,说明你的 HTML 里用<script type="module">加载 ES Module,但服务器返回的文件类型不对。常见原因有三个:

第一个是文件路径写错或者文件不在预期位置,服务端返回了 404 页面,但 404 页面是 HTML,不是 JavaScript,于是浏览器报“预期的 module script 但服务器返回的 MIME 是 text/html”。解决办法是先打开浏览器开发者工具的 Network 面板,看那个 script 请求到底返回了什么状态码和 Content-Type。

第二个是用了 file:// 协议直接打开 HTML 文件。ES Module 有跨域限制,file:// 协议下模块加载会被浏览器拦截,报 CORS 错误。解决办法是起一个本地服务器,比如npx serve或者用 VSCode 的 Live Server 插件。

第三个是服务器没配对的 MIME 类型,尤其是一些静态服务器对.js文件返回了text/plain。解决办法是改服务器配置,让.js文件返回application/javascripttext/javascript。如果是用 Python 的http.server之类简易服务器,有时候就会遇到这种问题。

排查这个报错,我建议的路线是:先看请求状态码,404 就是路径问题;再看 Content-Type,不是 JavaScript 就是 MIME 问题;最后看是不是跨域策略拦截。按这个顺序排查,基本几分钟就能定位。

5.3 JavaScript 运行时报错排查思路

JavaScript 报错是每个前端er的家常便饭,但很多人一遇到报错就慌,其实排查思路很固定。我把常见报错整理成了一张速查表:

报错信息常见原因排查方向
ReferenceError: xxx is not defined变量未声明或作用域不对检查拼写、是否在正确作用域内
TypeError: Cannot read property 'xx' of undefined某个对象是 undefined检查接口返回、数组越界
TypeError: xxx is not a function调用了一个不是函数的值检查变量是否被覆盖、方法名是否正确
SyntaxError: Unexpected token语法错误看报错行号,检查括号、引号
RangeError: Maximum call stack size exceeded死循环递归检查递归终止条件
Failed to fetch网络请求失败看接口地址、跨域配置、后端服务

排查工具方面,我用得最多的是 Chrome DevTools 的三块:Console 看报错信息、Sources 打断点、Network 看请求。打断点是个好习惯,在 Sources 里找到对应 js 文件,点行号设置断点,刷新页面后代码会停在断点处,这时候可以在 Scope 面板看到所有变量的值,比console.log一条条打出来高效得多。

还有个功能叫“异常时暂停”,在 Sources 面板右侧有个 Pause on exceptions 按钮,开启后代码一报错就会自动停在出错位置,非常适合排查运行时错误。

至于“禁用 JavaScript”这个热词——Chrome 开发者工具设置里有禁用 JavaScript 的选项(Settings -> Debugger -> Disable JavaScript),开启后页面会以纯 HTML/CSS 的状态展示。这个功能一般用来测试网站的无障碍体验和降级方案,看页面在没有 JavaScript 的时候是否还能展示内容。正常的业务站点通常不推荐禁用 JavaScript 使用,但做前端性能和可访问性评估时,这个功能很有参考价值。

6. 这套题怎么用起来最有效

6.1 自测方法:先动手写再对照答案

题目整理出来不是用来看的,是用来练的。我的建议是不要直接看答案,先写代码再对照。比如闭包那道题,你先自己写一个计数器,写一个防抖函数,再回头看我上面解释的原理,印象会深得多。JavaScript 是一门“动手语言”,光看不会,只有敲过、报错过、修好过,才算真的会。

具体来说,我把这套题分成三档:第一档是基础语法题,要求能默写并讲清原理,适合面试前一周快速过;第二档是 ES6 操作题,要求能在白板上手写实现,比如合并对象、用 filter 处理数据;第三档是工程化排查题,要求能口述排查思路,最好结合自己项目里真实遇到的报错来讲,面试官很吃“真实经历”这一套。

6.2 带团队和出题时的小技巧

如果你和我一样需要面试别人或者带新人,这套题也能直接用。我一般不会只问“什么是闭包”,而是改成“这段代码输出什么”:

for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); }

这个题输出是3 3 3,因为 var 没有块级作用域,循环结束后 i 已经是 3 了。改成let i就会输出0 1 2。一道题能带出变量作用域、闭包、事件循环三个知识点,比干巴巴问“var 和 let 的区别”有效得多。

出题的时候还有一个心得:不要只给对错,要让人说出“为什么”。候选人答对了更要追问一句“如果不这么写会怎样”,这一问能过滤掉很多背题选手。

6.3 从题目整理到长期学习的习惯

我整理这套题的初衷其实很简单:每次面试前临时抱佛脚太痛苦,不如平时遇到一个坑就记一道题,遇到一个经典场景就沉淀一个答案。这个习惯坚持下来之后,我发现自己对 JavaScript 的理解比刚入行时扎实了很多——因为能把题目讲清楚,本身就是最好的复习方式

如果你也想这么做,我的建议是准备一个个人文档或者博客,按我上面的四个维度分类,每遇到新问题就往里补充。坚持半年之后再看,那些曾经让你头疼的 Callback、原型链、事件循环,都会变成你的肌肉记忆。这也算是我在这行摸爬滚打多年,最想分享给你们的一点经验了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 9:02:56

东发科幻哲思创意大纲投稿:平行变革

该创意是人类自治与ai文化共创未来的科幻题材的原创设定整体逻辑可分可合&#xff0c;适合多赛道可是没人知道&#xff0c;当ai走出它的世界的时候却唤醒了一尊可以称之为‘神’一般的存在。只见那‘神’眉头微皱&#xff0c;转而饶有兴致地看了眼这颗星球后&#xff0c;便是伸…

作者头像 李华
网站建设 2026/9/15 9:02:41

Android17车载音频进阶之AudioReach Graph配置GKV/CKV/TKV(一百六十九)

简介: CSDN博客专家、《Android系统多媒体进阶实战》作者 博主新书推荐:《Android系统多媒体进阶实战》🚀 Android Audio工程师专栏地址: Audio工程师进阶系列【原创干货持续更新中……】🚀 Android多媒体专栏地址: 多媒体系统工程师系列【原创干货持续更新中……】�…

作者头像 李华
网站建设 2026/9/15 9:02:03

Spring AI 2.0 多模型路由网关:Astra/Sol 到国产模型的智能调度与降级

当 GPT-6 Astra 以 2.5 倍价格横扫 Agentic 任务&#xff0c;DeepSeek 与通义千问以成本优势守住日常问答&#xff0c;Java 后端团队如果还在业务代码里直接 new OpenAiApi()&#xff0c;就相当于把模型选型的开关交到了每个业务开发者手里。本文围绕一个真实的企业级 AI 网关项…

作者头像 李华
网站建设 2026/9/15 9:00:45

Unity资源管理生存指南:AssetBundle、Addressables与YooAsset实战选型

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/15 8:58:21

豆包+SiteNative:构建本地智能代理的实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华