news 2026/9/30 5:00:08

前端性能优化核心:异步加载原理、落地方式与实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
前端性能优化核心:异步加载原理、落地方式与实战指南

1. 异步加载到底解决了什么问题

做过前端或者客户端性能优化的朋友,应该都有过这种体验:页面代码越堆越多,首屏打开越来越慢,白屏时间从原来的500毫秒变成一秒两秒,用户早跑了。刚开始我以为是网络问题,后来把Network面板打开一看,一个首屏加载了2MB的JS、1MB的CSS,还有一堆没在首屏出现的图片全挤在那条加载链里。问题很明确:我们让用户为根本看不到的内容付了费——这个"费"就是等待时间。

异步加载的核心思想其实特别朴素:现在不用的东西,就别现在加载。但就是这句朴素的话,落地到工程里却牵扯出一整套设计和权衡。从图片懒加载到路由级代码分割,从Web Worker到Android端的异步初始化,背后共享的是同一个底层逻辑——把关键路径上的任务砍短,把非关键任务踢出主流程。这也是为什么性能优化里,异步加载几乎是所有优化手段的地基。你去做LCP优化、做启动耗时优化、做TTI优化,最后大都会落到"某些资源是不是可以晚点再加载"这个决策上。

这篇文章我打算把异步加载从原理到实操完整拆一遍,包括它到底在优化什么、有哪几种落地方式、每种方式适合什么场景、踩过哪些坑,以及怎么用数据验证优化到底有没有效果。不管你是做Web前端、Android客户端,还是做跨端应用,这套思路都是通用的。我会把我在实际项目里验证过的东西直接写出来,能帮你少走不少弯路。

先说说异步加载之所以能提升性能的根本原因。浏览器和操作系统的渲染机制里,有一条绝对的主干道——主线程。DOM的解析、样式计算、布局、绘制、JavaScript的执行,全部挤在这条主干道上。同步加载的意思是,一个任务没走完,后面所有任务全部排队等着。你加载了一个同步脚本,HTML解析器就得停下,去拉脚本、编译脚本、执行脚本,全部搞完才继续解析后续标签。页面内容越靠后,用户看到首屏的时间就越晚。异步加载的本质,就是把不阻塞首屏渲染的任务挪到主干道之外,或者挪到不那么紧急的时间点。

这里有个非常重要的认知:异步加载不是"减少工作量",而是"调整工作的时间分布"。比如路由懒加载,该写的代码一行没少,但写代码的那2MB文件从"首屏必须下载并执行"变成了"用户进入某个路由时才下载执行"。对于首屏来说,它要处理的工作量就变少了,所以首屏变快了。但对于整个应用生命周期来说,总工作量并没有减少,甚至可能因为分包拆得不合理还增加了请求次数。理解了这一层,你就不会盲目地"凡事皆异步",而是会想清楚:哪条路径是用户最关心的,哪条路径是可以往后放的。

在做具体方案之前,还需要构建一个最基本的理论框架:事件循环机制。JS是单线程的,但通过事件循环把任务分成了同步任务、宏任务、微任务、渲染步骤等不同队列。setTimeout和setInterval把任务延迟到宏任务队列,Promise.then、MutationObserver把回调排到微任务队列,requestAnimationFrame把任务排在渲染之前,requestIdleCallback把任务排在浏览器空闲时段。这些API是异步加载的"调度器",你选择哪个API,实际上是在选择任务在事件循环的哪个阶段执行,这会直接影响用户的感知流畅度。后面讲具体场景时,我会再回来说它们之间的取舍。

2. 异步加载的五种主流落地方式

异步加载不是某一种单一技术,它是一族方案的统称。我按实际项目的使用频率,把它们的实现原理、适用场景和踩坑点都列一下。理解这些方案的差异,是做好选型的前提。

2.1 资源级的异步加载:defer与async

HTML里加载外部脚本,默认是同步阻塞的。你写了一个<script src="app.js">放在<head>里,浏览器的HTML解析器就会卡住,下载并执行完这个脚本才能继续渲染。这其实是最古老的性能杀手。后来有了defer和async两个属性,但它们的工作原理并不一样。

defer的意思是:脚本的下载和HTML解析并行,但执行被推迟到HTML解析完毕之后。多个defer脚本会按照文档顺序依次执行。async的意思是:下载是异步的,但一旦下载完成,立刻中断HTML解析去执行脚本。多个async脚本谁先下载完谁先执行,跟文档顺序无关。从行为上看,defer更适合那些依赖DOM结构已经准备好的脚本,async更适合那些独立性强的脚本,比如统计脚本、广告脚本。

这里有一个常见误区:很多人觉得在标签上加了async或者defer就万事大吉,其实不是。如果你有一个2MB的脚本,加defer只是让它"执行得晚一点",下载仍然占了带宽、也会在解析完毕后占用主线程执行。所以资源级的异步加载只解决了"不被阻塞解析"的问题,没有解决"加载了不该加载的东西"的问题。代码分割要配合路由懒加载去做,资源级异步只是其中一块拼图。

2.2 按需加载:图片懒加载与组件懒加载

图片懒加载是最常见也最容易上手的异步加载实践。核心逻辑是:图片的真实地址不直接写在src里,而先放在>/** * 图片懒加载工具类 * 用法:new LazyLoad({ selector: '.lazy-img' }) */ class LazyLoad { constructor(options = {}) { const defaultOptions = { selector: '.lazy-img', root: null, // 默认使用浏览器视口作为观察根元素 rootMargin: '0px 0px 200px 0px', // 提前200px开始加载,提高加载感知流畅度 threshold: 0.01, // 元素进入视口1%时触发,避免元素还在边缘就加载 loadedClass: 'is-loaded' }; this.options = { ...defaultOptions, ...options }; // 保存所有未加载的图片引用 this.images = Array.from(document.querySelectorAll(this.options.selector)); this.init(); } init() { if (!('IntersectionObserver' in window)) { // 降级方案:直接加载所有图片 this.images.forEach(img => this.loadImage(img)); return; } this.observer = new IntersectionObserver((entries) => { entries.forEach(entry => { // 只有isIntersecting为true时,才真正触发加载 if (entry.isIntersecting) { const img = entry.target; this.loadImage(img); // 图片一旦开始加载,就不需要再被观察了,及时解除观察 this.observer.unobserve(img); } }); }, { root: this.options.root, rootMargin: this.options.rootMargin, threshold: this.options.threshold }); this.images.forEach(img => this.observer.observe(img)); } loadImage(img) { // 把data-src的真实地址还原到src,同时兼容data-srcset响应式图片 const src = img.getAttribute('data-src'); if (src) { img.src = src; img.removeAttribute('data-src'); } const srcset = img.getAttribute('data-srcset'); if (srcset) { img.srcset = srcset; img.removeAttribute('data-srcset'); } // 加载完成后标记状态,用于后续样式控制 img.addEventListener('load', () => { img.classList.add(this.options.loadedClass); }); // 加载失败时,尝试用默认占位图兜底 img.addEventListener('error', () => { img.src = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; img.classList.add('load-error'); }); } } export default LazyLoad;

几个关键参数值得细说:

rootMargin我这里设置了0px 0px 200px 0px,意思是在视口底部往下扩展200px作为触发区域。这样用户滚动到某张图片前的200px时,浏览器就已经开始加载它了。等到图片真正进入视口,大概率已经加载完成,用户感知不到"图片突然出现"的卡顿。这个200px不是一个固定值,在网速较慢且图片较多的场景可以调整为300px,但也不能设太大——设置太大等于提前加载了大量还没看到的图片,性能优化就失真了。

threshold我设为0.01,元素哪怕只有1%进入视口就触发。如果设为0,在某些浏览器里会有边界情况——元素刚好在视口边缘但没有实际露出时可能不触发,所以用0.01来规避。

unobserve一定要调用。图片一旦开始加载,就不需要继续监听它的位置变化了,不加这行的话,观察器会一直持有这些元素引用,滚动过程中还会反复计算交叉状态,白消耗性能。

4.3 避免布局抖动的占位方案与图片渲染细节

图片懒加载最大的隐藏坑,是布局抖动导致的CLS指标恶化。普通图片渲染有几个阶段:HTML解析到<img>标签时只知道宽高属性,图片资源加载完成后浏览器需要用实际尺寸重新布局。如果你没有预先为图片占位置,加载完成后页面高度突然增加,下面的内容全部往下跳,用户正在阅读的位置就会被顶走——这个体验非常糟糕。

解决方案有两个:CSS预设宽高比和占位背景色。CSS预设宽高比现在最优雅的实现是aspect-ratio属性,它让元素在图片加载前就占据和最终渲染尺寸接近的空间:

.lazy-img { width: 100%; aspect-ratio: 16 / 9; /* 图片宽高比,从设计稿或接口数据中获取 */ object-fit: cover; /* 裁剪而不是拉伸,保证视觉不扭曲 */ background-color: #f0f0f0; /* 加载前的占位底色,弱化空白感 */ }

用aspect-ratio的好处是高度在CSS计算阶段就已经确定,浏览器不需要等图片加载完成再做二次布局。不过前提是你得知道图片的宽高比。如果是CMS后台随便上传的图片,建议在服务端做一次图片信息解析,把宽高比下发给前端;如果是固定尺寸的封面图,直接用固定比例就行。实在拿不到比例的情况,就给一个min-height的保底值,配合背景色兜底。

还有一个细节是不要给懒加载图片加过渡动画。很多人喜欢给图片加一个淡入效果(opacity从0到1),但opacity动画本身会触发合成层创建,如果页面上同时有几十张图片在滚动中淡入,性能开销反而大。content-visibility属性其实是一个更好用的方案——它可以让浏览器跳过屏幕外元素的渲染工作,但兼容性目前还有限,在团队技术栈允许的情况下可以作为补充手段。

4.4 加载失败兜底与网络切换场景处理

真实网络环境比开发环境复杂得多。3G弱网、4G信号切换、Wi-Fi断连,这些都会导致图片加载失败。如果不做兜底,页面上会出现一片破图,用户感知尤其差。

我的兜底方案分了三层:

第一层是loading属性,给所有图片加上loading="lazy"作为浏览器的原生兜底。现代浏览器已经原生支持懒加载,即便没有引入任何JavaScript代码,loading="lazy"也能让浏览器自动推迟屏幕外图片的加载。但注意,原生loading="lazy"的触发时机由浏览器决定,开发者无法精细控制提前量,也没有失败回调,所以它适合做兜底,不适合做主方案。

第二层是上面代码里的error回调,图片加载失败时替换为一张极小体积的1x1透明GIF占位图,避免破图图标显示。同时给图片加一个load-error类,方便后续通过样式表现"加载失败"状态。

第三层是网络状态变化后的手动重试机制。监听navigator.onLine和online事件,网络恢复后重新扫描页面上还有哪些>const LoginPage = React.lazy(() => import('./pages/Login')); const DashboardPage = React.lazy(() => import('./pages/Dashboard')); const UserManagePage = React.lazy(() => import('./pages/UserManage')); function RouterConfig() { return ( <Suspense fallback={<PageSkeleton />}> <Routes> <Route path="/login" element={<LoginPage />} /> <Route path="/dashboard" element={<DashboardPage />} /> <Route path="/users" element={<UserManagePage />} /> </Routes> </Suspense> ); }

Vue Router则更简单,直接在路由配置里用动态import返回组件即可:

const routes = [ { path: '/dashboard', component: () => import('./views/Dashboard.vue'), meta: { title: '工作台' } } ];

代码分割的核心收益不只是首屏体积变小,还有一个隐藏收益是缓存命中率变高。业务代码和公共库代码分开打包后,公共库的hash几乎不变,用户第二次访问其他页面时,公共库直接命中缓存,只需要下载业务chunk即可,体验提升非常明显。

5.2 移动端启动性能优化中的异步加载应用

异步加载在移动端的重要性比Web更突出,因为移动端设备的主线程资源更紧张,而且用户对启动速度的要求更高——从点击图标到看到首帧,超过两秒用户就会开始不耐烦。

Android端启动优化的核心矛盾是:Application的onCreate里有大量初始化任务(SDK初始化、数据库创建、缓存预加载、埋点模块启动),这些任务如果全部同步执行在onCreate里,启动耗时会被拉得很长。主流方案包括两类:

第一类是启动器框架(如AndroidX StartUp)。它的设计思想是把初始化任务抽象成一个一个有依赖关系的Task,框架根据依赖关系构建一个有向无环图,在没有依赖关系的Task之间并行执行,有依赖关系的Task保持顺序执行。这本质上就是一种异步加载——把原本串行、阻塞、全部挤在主线程的初始化流程,改成了并行、按需、可延后的调度流程。

第二类是更细力度的任务延后。那些非首屏必需的初始化(比如推送SDK初始化、地理位置获取、日志上传),可以放到首帧渲染完成之后再启动。启动耗时被"首帧之前"这个窗口严格约束,首帧之前的任务越少,启动速度越快。任务可以细分为必须在主线程、可以在子线程、必须首帧前、可以首帧后四类,逐一打标签后重新编排。

这里有一个常见的坑:子线程初始化看起来是异步了,但如果多个子线程都去访问同一个共享资源(比如同一个SQLite数据库),或者都在做大量CPU计算,线程之间的锁竞争和CPU争抢反而会让主线程更卡。所以异步不意味着无脑开线程,合理的做法是控制线程数,尽量合并同类任务,避免线程颠簸。

5.3 前端框架中的异步组件与副作用处理

在React和Vue之外,跨端框架和小程序框架里也能做异步加载,只是方式要跟着框架走。Taro/uni-app这类跨端框架,页面本身是按路由拆分的,但页面内部仍可以通过动态import把模块级的大依赖拆出去。比如页面里有一个图表组件用了ECharts,这个图表组件体积很大,但只出现在页面底部,那就可以把它单独拆出来,等用户滚动到底部时再加载。

异步组件的加载过程里还有一个容易被忽视的副作用——状态丢失。如果你在组件加载完成前,用户就已经开始了某个交互操作,等组件加载完成后,这个交互状态可能和组件的初始化状态冲突。比如用户在Modal打开前就点击了确认按钮,而承载确认逻辑的异步组件还没加载完,点击事件就丢了。所以异步组件场景下,要做的事不只是"加载组件",还要处理加载期间的交互事件缓冲和重放。如果这个组件承载了关键业务流程,我会倾向于把它的加载时机提前,而不是极限延后——性能优化不能以牺牲体验一致性为代价。

5.4 长列表的虚拟滚动与异步渲染结合

长列表场景(比如信息流、聊天记录、商城商品列表)一次性渲染几百上千个DOM节点,即使所有资源都本地已有,渲染本身也会卡顿。虚拟滚动是长列表性能问题的标配解法:只渲染可视区域内的元素,其他区域留空占位。这和懒加载的逻辑一脉相承——都是"不渲染、不加载当前看不到的东西"。

虚拟滚动的实现逻辑比图片懒加载复杂一些:需要计算可视区域内显示哪些行,根据滚动位置实时更新。成熟的库有react-window、vue-virtual-scroller等,也可以自己实现。自己实现的关键参数是预估行高——如果每条内容的行高是固定的,计算很简单;如果高度不固定(比如文本长短不一),就需要预估行高加上渲染后的实际高度校准。校准过程中,上滑或下滑时的滚动条跳动是高频问题,一般通过给未渲染区域预设一个"估算高度"来规避。

和异步渲染结合的方式是:每条列表项内部如果有图片或按钮等资源,仍然可以配合懒加载或按需加载策略。列表项滚动进入视口时才加载其内部资源,列表项滚出视口就销毁或回收其DOM结构。这样叠加之后,长列表才能做到"万条数据也不卡"的流畅度。

6. 异步加载的常见问题与性能排查实录

这部分是我在实际项目里踩过、也在团队里手把手带人排查过的典型问题。按问题现象、排查思路、最终解决三列整理成速查表。

6.1 高频问题速查表

问题现象可能原因解决方案
懒加载图片加载前出现布局跳动图片容器未设置宽高比使用aspect-ratio或固定宽高预留占位
懒加载后LCP指标反而变差首屏LCP元素也被加了懒加载对LCP元素禁用懒加载,改为立即加载
异步组件加载后页面白屏/报错动态import的模块加载失败或路径错误检查打包输出的chunk路径,配置webpackChunkName,加ErrorBoundary
滚动页面卡顿、掉帧scroll事件监听回调里做了getBoundingClientRect或大量DOM操作改为IntersectionObserver;如必须监听scroll,用requestAnimationFrame节流
大量异步请求同时发起导致接口排队懒加载组件一次性全部触发控制并发数,重要资源优先加载,其他进入延迟队列
使用原生loading="lazy"后图片始终不加载图片在首屏或根元素设置了0高度检查图片父容器高度是否为0,或改用IntersectionObserver方案
Android启动时子线程初始化导致主线程卡顿子线程任务过多且争用共享资源统一用StartUp框架管理任务依赖与调度;控制线程池数量
异步加载了公共库导致重复加载多个chunk都打包了同一份公共代码配置依赖自动拆分(splitChunks或manualChunks),抽取公共依赖为单独chunk

6.2 疑难问题排查方法论与案例复盘

这里挑一个最典型的案例复盘。我负责过的一个H5活动页,优化前加载很流畅,做完异步加载改造之后反倒变卡了。排查时先用Performance面板做了录制,发现主线程被一处占用了很久的任务给堵住了。追下去才发现,异步加载的组件包里不小心把ECharts也打了进去,而ECharts初始化时会去解析大量主题配置和series配置,这一执行就是300ms的长任务。组件是异步了,但组件内部的初始化逻辑却扔在了主线程上执行——异步加载只解决了"下载时机",没解决"执行开销"。

这个案例给了两个非常重要的结论:

第一,异步加载之后,一定要复查异步组件内部的初始化逻辑。组件的JavaScript下载和执行本身就是两件事,下载是网络层面的事,执行是主线程层面的事。下载异步了,但执行时如果做了大量同步计算,照样会卡住主线程。遇到这类情况,应该把ECharts这类重组件的初始化也改为异步渲染或者Worker内计算,至少也要把初始化逻辑放在空闲时间执行。

第二,异步加载的效果要通过性能数据和用户反馈双重验证。我后来在优化前后分别采集了FCP、LCP、TBT三个指标,用数据确认了优化方向是对的。只凭"感觉变快了"来评估性能优化,很容易被错觉误导。还有一个辅助手段是录制用户操作轨迹,回放时注意观察滚动过程有没有卡顿掉帧,这种主观感受配合客观数据,才能准确评估优化是否成功。

6.3 异步加载的边界:不要为了异步而异步

聊了这么多,还是要泼一盆冷水。异步加载不是越彻底越好,它有一个合理边界。

判断依据很简单:用户在这个时间点会不会用到这个东西?如果用户登录后第一屏就是数据看板,而看板要依赖的核心数据请求被你"异步"到了空闲期才发,那首屏就是一片空白等待——这就是异步用过头了。同理,如果某个异步组件承载的是用户最核心的操作入口,把它延后加载就需要权衡:是缩短首屏加载时间重要,还是保证用户立刻能用核心功能重要?

另外,不是所有东西都适合拆分。有些模块虽然体积大,但被多个路由共享(比如公共的UI组件库、请求库、工具函数),这样的模块强制拆分成异步chunk,反而会导致每个页面都要重新请求一份拷贝。这种情况下应该把它打进公共依赖chunk,利用浏览器缓存长期复用。代码分割的正确粒度应该是"模块访问频率低且独立",而不是纯粹按文件大小切。

我做性能优化这么长时间,体会最深的一点是:性能优化的本质是理解用户行为,把资源花在用户最需要的地方。异步加载只是一个手段,不是目的。真正合理的架构不会为了懒加载把所有图片全部加loading=lazy,也不会为了让首屏更快就把所有代码全部拆成异步。它是在理解用户的访问路径、理解资源的依赖关系之后,做出的一个全局最优解。

7. 我沉淀的一套异步加载自检清单

按我以前带团队的做法,每次做完全站性能优化Review之后,会逐个页面跑一遍自检清单。现在把它也分享出来,可以作为你项目里异步加载优化的验收标准。

  • [ ] 首屏LCP元素是否被误加了懒加载?LCP图片必须显式设置fetchpriority="high"且立即加载。
  • [ ] 所有懒加载图片是否预留了宽高比?能否在没有网络的情况下复现布局跳动?
  • [ ] 路由级代码分割是否执行了?首屏JS体积相对优化前下降了多少?
  • [ ] 异步组件是否有合理的加载失败兜底?是否有ErrorBoundary包裹?
  • [ ] 公共依赖是否被正确抽取,而不是被打进多个chunk?
  • [ ] 长列表场景是否使用了虚拟滚动,而不是一次性渲染全部DOM?
  • [ ] 非关键脚本是否使用了defer/async?统计脚本是否放到了页面底部?
  • [ ] 有没有做过优化前后的Lighthouse对比,FCP/LCP/TBT是否都有改善?
  • [ ] 移动端启动阶段,Application.onCreate里是否还有可延后或可切换子线程的初始化任务?
  • [ ] 异步任务之间是否存在共享资源竞争?线程或Worker数量是否合理?

这套清单我每次性能复盘都会过一遍,能过滤掉大部分低级失误。它不复杂,但每一条都来自实际生产环境里的教训。

异步加载这个主题看起来是单个技术点,但做深了会发现它其实横跨网络加载、渲染机制、线程调度、框架设计多个层面,是一个值得长期深耕的方向。我建议你把今天聊到的几个方案(资源异步、代码分割、任务调度、移动端启动优化)在自己的项目里各找一个场景试着落地,然后拿数据说话,再对比优化前后的指标变化。这个过程走完一遍,你对异步加载和性能优化的理解,会比你看十篇文章都深。

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

Playwright + Pytest 实战:从元素定位到CI集成的Web UI自动化测试方案

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

作者头像 李华
网站建设 2026/9/30 4:59:40

无人机风速风向仪:从运动平台解算真实大气的原理与工程实践

这几年做无人机气象载荷测试&#xff0c;被问得最多的一个问题是&#xff1a;无人机风速风向仪到底是什么&#xff1f;是不是就是把气象站上那个风速杯拆下来&#xff0c;绑到机身上就行&#xff1f;每次听到这个说法我都得解释半天——绑上去用很简单&#xff0c;测出来能用是…

作者头像 李华
网站建设 2026/9/30 4:59:36

长任务Agent断点续跑实战:检查点、状态管理与工作流设计

1. 为什么长任务 Agent 必须做断点续跑做过 Agent 项目的人大概都经历过这种崩溃时刻&#xff1a;一个跑了四十多分钟的多步工作流&#xff0c;前面三十几步都顺顺利利&#xff0c;结果在最后一步调用某个外部接口时超时了&#xff0c;整个流程直接挂掉。更让人抓狂的是&#x…

作者头像 李华
网站建设 2026/9/30 4:59:30

企业服务器搭建:从需求规划到服务配置与日常运维全指南

简介&#xff1a;这是一份面向中小企业管理者与运维人员的企业服务器选型及搭建方案文档&#xff0c;专门解决公司到底需不需要买服务器、如何设置公司服务器、自建机房与云服务器哪种更合适等常见困惑。文档先分析小型网站、企业官网、图片视频流媒体等典型业务场景&#xff0…

作者头像 李华
网站建设 2026/9/30 4:58:56

企业级LLM平台落地实战:从Demo到生产的六层架构与工程避坑指南

1. 企业级 LLM 落地&#xff0c;为什么“能跑通 Demo”和“能上生产”之间隔着一整条鸿沟做过 LLM 项目的人大概都有这种体验&#xff1a;本地拿个开源模型&#xff0c;接上 LangChain&#xff0c;写个 RAG 问答&#xff0c;半天就能跑出一个看起来像模像样的 Demo。可一旦要把…

作者头像 李华
网站建设 2026/9/30 4:58:51

前端选文件夹为什么比选文件难?三种方案深度对比

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

作者头像 李华