2025-07-13 00:01:35
morphdom
遇上 Vue在上一篇文章中,我们经历了一场 Markdown 渲染的性能优化之旅。从最原始的 v-html
全量刷新,到按块更新,最终我们请出了 morphdom
这个“终极武器”。它通过直接比对和操作真实 DOM,以最小的代价更新视图,完美解决了实时渲染中的性能瓶颈和交互状态丢失问题。
然而,一个根本性问题始终存在:在 Vue 的地盘里,绕过 Vue 的虚拟 DOM (Virtual DOM) 和 Diff 算法,直接用一个第三方库去“动刀”真实 DOM,总感觉有些“旁门左道”。这就像在一个精密的自动化工厂里,引入了一个老师傅拿着锤子和扳手进行手动修补。虽然活干得漂亮,但总觉得破坏了原有的工作流,不够“Vue”。
那么,有没有一种更优雅、更“原生”的方式,让我们既能享受精准更新的快感,又能完全融入 Vue 的生态体系呢?
带着这个问题,我询问了前端群里的伙伴们。
如果就要做一个渲染器,你这个思路不是最佳实践。每次更新时,你都生成全量的虚拟 HTML,然后再对 HTML 做减法来优化性能。然而,每次更新的增量部分是明确的,为什么不直接用这部分增量去做加法?增量部分通过 markdown-it 的库无法直接获取,但更好的做法是在这一步进行改造:先解析 Markdown 的结构,再利用 Vue 的动态渲染能力生成 DOM。这样,DOM 的复用就可以借助 Vue 自身的能力来实现。—— j10c
可以用 unified 结合 remark-parse 插件,将 markdown 字符串解析为 ast,然后根据 ast 使用 render func 进行渲染即可。—— bii & nekomeowww
我们之前的方案,无论是 v-html
还是 morphdom
,其核心思路都是:
Markdown 字符串
-> markdown-it
-> HTML 字符串
-> 浏览器/morphdom
-> DOM
这条链路的问题在于,从 HTML 字符串
这一步开始,我们就丢失了 Markdown 的原始结构信息。我们得到的是一堆非结构化的文本,Vue 无法理解其内在逻辑,只能将其囫囵吞下。
而新的思路则是将流程改造为:
Markdown 字符串
-> AST (抽象语法树)
-> Vue VNodes (虚拟节点)
-> Vue
-> DOM
AST (Abstract Syntax Tree) ,即抽象语法树,是源代码或标记语言的结构化表示。它将一长串的文本,解析成一个层级分明的树状对象。对于 Markdown 来说,一个一级标题会变成一个 type: 'heading', depth: 1
的节点,一个段落会变成一个 type: 'paragraph'
的节点,而段落里的文字,则是 paragraph
节点的 children
。
一旦我们将 Markdown 转换成 AST,就相当于拥有了整个文档的“结构图纸”。我们不再是面对一堆模糊的 HTML 字符串,而是面对一个清晰、可编程的 JavaScript 对象。
为了实现 Markdown -> AST
的转换,我们引入 unified
生态。
unified
插件,专门负责将 Markdown 文本解析成 AST(具体来说是 mdast
格式)。首先,我们需要安装相关依赖:
npm install unified remark-parse
然后,我们可以轻松地将 Markdown 字符串转换为 AST:
import { unified } from 'unified'import remarkParse from 'remark-parse'const markdownContent = '# Hello, AST!\n\nThis is a paragraph.'// 创建一个处理器实例const processor = unified().use(remarkParse)// 解析 Markdown 内容const ast = processor.parse(markdownContent)console.log(JSON.stringify(ast, null, 2))
运行以上代码,我们将得到一个如下所示的 JSON 对象,这就是我们梦寐以求的 AST:
{ "type": "root", "children": [ { "type": "heading", "depth": 1, "children": [ { "type": "text", "value": "Hello, AST!", "position": { ... } } ], "position": { ... } }, { "type": "paragraph", "children": [ { "type": "text", "value": "This is a paragraph.", "position": { ... } } ], "position": { ... } } ], "position": { ... }}
拿到了 AST,下一步就是将这个“结构图纸”真正地“施工”成用户可见的界面。在 Vue 的世界里,描述 UI 的蓝图就是**虚拟节点 (VNode)**,而 h()
函数(即 hyperscript)就是创建 VNode 的画笔。
我们的任务是编写一个渲染函数,它能够递归地遍历 AST,并为每一种节点类型(heading
, paragraph
, text
等)生成对应的 VNode。
下面是一个简单的渲染函数实现:
function renderAst(node) { if (!node) return null switch (node.type) { case 'root': return h('div', {}, node.children.map(renderAst)) case 'paragraph': return h('p', {}, node.children.map(renderAst)) case 'text': return node.value case 'emphasis': return h('em', {}, node.children.map(renderAst)) case 'strong': return h('strong', {}, node.children.map(renderAst)) case 'inlineCode': return h('code', {}, node.value) case 'heading': return h('h' + node.depth, {}, node.children.map(renderAst)) case 'code': return h('pre', {}, [h('code', {}, node.value)]) case 'list': return h(node.ordered ? 'ol' : 'ul', {}, node.children.map(renderAst)) case 'listItem': return h('li', {}, node.children.map(renderAst)) case 'thematicBreak': return h('hr') case 'blockquote': return h('blockquote', {}, node.children.map(renderAst)) case 'link': return h('a', { href: node.url, target: '_blank' }, node.children.map(renderAst)) default: // 其它未实现类型 return h('span', { }, `[${node.type}]`) }}
整合上述逻辑,我们可以构建一个 Vue 组件。鉴于直接生成 VNode 的特性,采用函数式组件或显式 render
函数最为适宜。
<template> <component :is="VNodeTree" /></template><script setup>import { computed, h, shallowRef, watchEffect } from 'vue'import { unified } from 'unified'import remarkParse from 'remark-parse'const props = defineProps({ mdText: { type: String, default: '' }})const ast = shallowRef(null)const parser = unified().use(remarkParse)watchEffect(() => { ast.value = parser.parse(props.mdText)})// AST 渲染函数 (同上文 renderAst 函数)function renderAst(node) { ... }const VNodeTree = computed(() => renderAst(ast.value))</script>
现在就可以像使用普通组件一样使用它了:
<template> <MarkdownRenderer :mdText="markdownContent" /></template><script setup>import { ref } from 'vue'import MarkdownRenderer from './MarkdownRenderer.vue'const markdownContent = ref('# Hello Vue\n\nThis is rendered via AST!')</script>
切换到 AST 赛道后,我们获得了前所未有的超能力:
v-html
的暴力刷新,也不再需要 morphdom
这样的“外援”。所有更新都交由 Vue 自己的 Diff 算法处理,这不仅性能极高,而且完全符合 Vue 的设计哲学,是真正的“自己人”。<h2>
)无缝替换为自定义 Vue 组件(如 <FancyHeading>
),仅在 renderAst
函数中调整对应 case
逻辑即可。<a>
添加 target="_blank"
与 rel="noopener noreferrer"
属性,或为图片 <img>
包裹懒加载组件,此类操作在 AST 层面易于实现。unified
丰富的插件生态(如 remark-gfm
支持 GFM 语法,remark-prism
实现代码高亮),仅需在处理器链中引入相应插件(.use(pluginName)
)。remark
)、渲染逻辑(renderAst
)和业务逻辑(Vue 组件)被清晰地分离开来,代码结构更清晰,维护性更强。回顾优化历程:
通过采用 AST,我们不仅解决了具体的技术挑战,更重要的是实现了思维范式的转变——从面向结果(HTML 字符串)的编程,转向面向过程与结构(AST)的编程。这使我们能够深入内容本质,从而实现对渲染流程的精确控制。
本次从“全量刷新”到“结构化渲染”的优化实践,不仅是一次性能提升的技术过程,更是一次深入理解现代前端工程化思想的系统性探索。最终实现的 Markdown 渲染方案,在性能、功能性与架构优雅性上均达到了较高水准。
2025-07-12 20:48:56
在最近接手的 AI 需求中,需要实现一个类似 ChatGPT 的对话交互界面。其核心流程是:后端通过 SSE(Server-Sent Events)协议,持续地将 AI 生成的 Markdown 格式文本片段推送到前端。前端负责动态接收并拼接这些 Markdown 片段,最终将拼接完成的 Markdown 文本实时渲染并显示在用户界面上。
Markdown 渲染并不是什么罕见的需求,尤其是在 LLM 相关落地产品满天飞的当下。不同于 React 生态拥有一个 14k+ star 的著名第三方库——react-markdown,Vue 这边似乎暂时还没有一个仍在活跃维护的、star 数量不低(起码得 2k+ 吧?)的 markdown 渲染库。cloudacy/vue-markdown-render 最后一次发版在一年前,但截止本文写作时间只有 103 个 star;miaolz123/vue-markdown 有 2k star,但最后一次 commit 已经是 7 年前了;zhaoxuhui1122/vue-markdown 更是 archived 状态。
简单调研了一圈,发现 Vue 生态里确实缺少一个能打的 Markdown 渲染库。既然没有现成的轮子,那咱就自己造一个!
根据大部分文章以及 LLM 的推荐,我们首先采用 markdown-it 这个第三方库将 markdown 转换为 html 字符串,再通过 v-html 传入。
PS: 我们这里假设 Markdown 内容是可信的(比如由我们自己的 AI 生成)。如果内容来自用户输入,一定要使用 DOMPurify
这类库来防止 XSS 攻击,避免给网站“开天窗”哦!
示例代码如下:
<template> <div v-html="renderedHtml"></div></template><script setup>import { computed, onMounted, ref } from 'vue';import MarkdownIt from 'markdown-it';const markdownContent = ref('');const md = new MarkdownIt();const renderedHtml = computed(() => md.render(markdownContent.value))onMounted(() => { // markdownContent.value = await fetch() ...})</script>
上述方案虽然能实现基础渲染,但在实时更新场景下存在明显缺陷:每次接收到新的 Markdown 片段,整个文档都会触发全量重渲染。即使只有最后一行是新增内容,整个文档的 DOM 也会被完全替换。这导致两个核心问题:
markdown-it
解析和 DOM 重建的开销呈线性上升。为了解决这两个问题,我们在网上找到了分块渲染的方案 —— 把 Markdown 按两个连续的换行符 (\n\n
) 切成一块一块的。这样每次更新,只重新渲染最后一块新的,前面的老块直接复用缓存。好处很明显:
代码调整后像这样:
<template> <div> <div v-for="(block, idx) in renderedBlocks" :key="idx" v-html="block" class="markdown-block" ></div> </div></template><script setup>import { ref, computed, watch } from 'vue'import MarkdownIt from 'markdown-it'const markdownContent = ref('')const md = new MarkdownIt()const renderedBlocks = ref([])const blockCache = ref([])watch( markdownContent, (newContent, oldContent) => { const blocks = newContent.split(/\n{2,}/) // 只重新渲染最后一个块,其余用缓存 // 处理块减少、块增多的场景 blockCache.value.length = blocks.length for (let i = 0; i < blocks.length; i++) { // 只渲染最后一个,或新块 if (i === blocks.length - 1 || !blockCache.value[i]) { blockCache.value[i] = md.render(blocks[i] || '') } // 其余块直接复用 } renderedBlocks.value = blockCache.value.slice() }, { immediate: true })onMounted(() => { // markdownContent.value = await fetch() ...})</script>
分块渲染虽然解决了大部分问题,但遇到 Markdown 列表就有点力不从心了。因为 Markdown 语法里,列表项之间通常只有一个换行符,整个列表会被当成一个大块。想象一下一个几百项的列表,哪怕只更新最后一项,整个列表块也要全部重来,前面的问题又回来了。
morphdom
是一个仅 5KB(gzip 后)的 JavaScript 库,核心功能是:接收两个 DOM 节点(或 HTML 字符串),计算出最小化的 DOM 操作,将第一个节点 “变形” 为第二个节点,而非直接替换。
其工作原理类似虚拟 DOM 的 Diff 算法,但直接操作真实 DOM:
Markdown 把列表当整体,但生成的 HTML 里,每个列表项 (<li>
) 都是独立的!morphdom
在更新后面的列表项时,能保证前面的列表项纹丝不动,状态自然就保住了。
这不就是我们梦寐以求的效果吗?在 Markdown 实时更新的同时,最大程度留住用户的操作状态,还能省掉一堆不必要的 DOM 操作!
<template> <div ref="markdownContainer" class="markdown-container"> <div id="md-root"></div> </div></template><script setup>import { nextTick, ref, watch } from 'vue';import MarkdownIt from 'markdown-it';import morphdom from 'morphdom';const markdownContent = ref('');const markdownContainer = ref(null);const md = new MarkdownIt(); const render = () => { if (!markdownContainer.value.querySelector('#md-root')) return; const newHtml = `<div id="md-root">` + md.render(markdownContent.value) + `</div>` morphdom(markdownContainer.value, newHtml, { childrenOnly: true });}watch(markdownContent, () => { render()}); onMounted(async () => { // 等待 Dom 被挂载上 await nextTick() render()})</script>
下面这个 iframe 里放了个对比 Demo,展示了不同方案的效果差异。
小技巧: 如果你用的是 Chrome、Edge 这类 Chromium 内核的浏览器,打开开发者工具 (DevTools),找到“渲染”(Rendering) 标签页,勾选「突出显示重绘区域(Paint flashing)」。这样你就能直观看到每次更新时,哪些部分被重新绘制了——重绘区域越少,性能越好!
从最开始的“暴力全量刷新”,到“聪明点的分块更新”,再到如今“精准手术刀般的 morphdom
更新”,我们一步步把那些不必要的渲染开销给砍掉了,最终搞出了一个既快又能留住用户状态的 Markdown 实时渲染方案。
不过,用 morphdom
这个第三方库来直接操作 Vue 组件里的 DOM,总觉得有点…不够“Vue”?它虽然解决了核心的性能和状态问题,但在 Vue 的世界里这么玩,多少有点旁门左道的意思。
下篇预告: 在下一篇文章里,咱们就来聊聊,在 Vue 的世界里,有没有更优雅、更“原生”的方案来搞定 Markdown 的精准更新?敬请期待!
2025-07-05 17:57:02
node@^12
vue@^2
webpack@^3
vue-loader@^14
sass-loader@^7.0.3
node-sass@^4
webpack 官方并没有提供 webpack 3 支持的最高 node 版本,且即使 webpack 官方支持,webpack 的相关插件也未必支持。因此 node 版本能否更新就只能自己试。好在尽管这个项目的 CI/CD 跑在 node 12,但我日常都在用 node 14 开发,因此顺势将 node 版本提升至 14。
webpack 的版本目前处于非必要不更新的定时炸弹状态,基于现有的 webpack 3 限制,所支持的最高 sass-loader 版本就是 ^7 ( sass-loader 在 8.0.0 版本的更新日志中明确指出 8.0.0 版本需要 webpack 4.36.0)。
如果项目中 sass-loader@^7 支持使用 dart-sass 就可以不更新 sass-loader,也就不必更新 webpack 版本;反之,就需要同步更新 webpack 至 4,再视情况定下 sass-loader 的版本。
那么到底支不支持呢?我在 webpack 官方文档介绍 sass-loader 的页面找到了这样一段 package.json 片段
{ "devDependencies": { "sass-loader": "^7.2.0", "sass": "^1.22.10" }}
这证明起码在 [email protected] 这一版本就已经支持 dart-sass 了,因此 webpack 版本可以停留在 ^3,而 sass-loader 暂时停留在 7.0.3 版本,如果后续有问题可以更新到 ^7 版本中最新的 7.3.1 版本。
sass-loader@^7 所支持的最高 sass 我并没有查到,Github Copilot 信誓旦旦地告诉我
官方文档引用:
sass-loader@^7.0.0 requires node-sass >=4.0.0 or sass >=1.3.0, <=1.26.5.
建议:
- 如果需要使用更高版本的
sass
,请升级到sass-loader
8 或更高版本。
但事实上,我并没有在互联网上找到这段文本的蛛丝马迹。并且在 sass 的 ~1.26 版本中最后一个版本是 1.26.11 而非 1.26.5,根据常见的 npm 版本号原则,major version 和 minor version 不变,只改变了 patch version 的发版一般只有 bugfix 而没有 breaking change,不至于从 1.26.5 更新到 1.26.11 就突然不支持 sass-loader 7 了,因此更可能是 AI 幻觉或者是训练数据受限。
出于谨慎考虑,最终决定采用 webpack 官方文档中提到的 sass 1.22 的最后一个版本,也就是 1.22.12。
npm uninstall node-sassnpm install sass@^1.22.12
module.exports = { // ... module: { rules: [ { test: /\.(scss|sass)$/, use: [ 'style-loader', 'css-loader', { loader: 'sass-loader',+ options: {+ // 事实上,这一行在大部分 sass-loader 版本中不用加,sass-loader 能自动检测本地是 sass 还是 node-sass+ implementation: require('sass')+ }, }, }, ], }, ], },};
因为 /deep/ 写法在 2017 年被弃用 ,/deep/ 变成了不受支持的深度作用选择器,node-sass 凭借其出色的容错性能够继续提供兼容,但 dart-sass 则不支持这种写法。于是需要将 /deep/ 语法批量替换成 ::v-deep 写法,这种写法虽然在 vue 的后续 rfc 被放弃了,但直至今日依然在事实上被支持。
# 大概就是这么个意思,用 vscode 的批量替换其实也行sed -i 's#\s*/deep/\s*# ::v-deep #g' $(grep -rl '/deep/' .)
在迁移的过程中,我发现项目中有一些不规范的写法,node-sass 凭借出色的鲁棒性不吭一声强行解析,而 dart-sass 则干不了这粗活。因此需要根据编译时的报错手动修复一下这些语法错误,我这里一共遇到两种。
// 多打了一个冒号.foo {- color:: #fff;+ color: #fff;}// :nth-last-child 没指定数字.bar {- &:nth-last-child() {+ &:nth-last-child(1) { margin-bottom: 0; }}
依赖更新完后看了两眼好像是没问题,就推测试环境了。结果一天没到就被同事 call 了,::v-deep 这种深度作用选择器居然没有生效?
抱着试一试的态度,GPT 给了如下回答
在 Vue 2 + vue-loader + Sass 的组合下,这种写法是正确的,前提是你的构建工具链支持
::v-deep
语法(如vue-loader@15
及以上版本 +sass-loader
)。
虽说我依然没有查证到为什么更新 vue-loader@15 才能使用 ::v-deep 语法,但对 vue-loader 进行更新后,::v-deep 语法确实生效了。在撰写本文时,我找到了些许蛛丝马迹,可能能解释这一问题。
vue-loader 在 14 版本的官方文档就是没有 ::v-deep 写法的示例,这一示例一直在 vue-loader 15.7.0 版本发布后才被加入。
vue-cli 的 Github Issue 评论区中有人提到
::v-deep
implemented in @vue/component-compiler-utils v2.6.0, should work after you reinstall the deps.
而 vue-loader 在 15.0.0-beta.1 版本才将 @vue/component-compiler-utils 加入到自己的 dependencies 中,并直到 vue-loader 15.7.1 中才将其 @vue/component-compiler-utils 的版本号更新到满足要求的 ^3.0.0
那能否升级到 vue-loader 16 甚至 17 版本呢?不行,在 vue-loader v16.1.2 的更新日志中明确写道
Note: vue-loader v16 is for Vue 3 only.
vue-loader 从 14 往上迁移时,不修改 webpack 配置直接跑会遇到 vue 语法不识别的问题。具体表现为 .vue 文件命名都是正确有效的语法,但构建开发时编译器就是不认,报语法错误。vue-loader 官方有一份迁移文档,需要注意一下。
ERROR in ./src/......Module parse failed: Unexpected token(1:0)You may need an appropriate loader to handle this file type.
// ...import path from 'path'+const VueLoaderPlugin = require('vue-loader/lib/plugin')// ... plugins: [+ new VueLoaderPlugin() // ... ]
除此之外,在我这个项目中需要额外移除 webpack 配置中针对 .vue 文件的 babel-loader
{ test: /\.vue$/, use: [- {- loader: 'babel-loader'- }, { loader: 'vue-loader', } ]}
node@^12
-> node@^14
vue-loader@^14
-> vue-loader@^15
node-sass@^4
-> sass@^1.22.12
其余依赖版本维持不变
dart-sass
和 node-sass
都是用来将sass
编译成 - 掘金2025-06-08 01:22:13
这事说来也邪乎,半个月前吃着火锅唱着歌,在工位上嘎嘎写码,发现一个诡异的 bug。作为如假包换的人类程序员,写出 bug 是再正常不过的事情了,但这 bug 邪门就邪门在我一打开 F12 的 DevTools 观察相关的 dom 结构,这 bug 就自动消失了;再把 DevTools 一关,Ctrl + F5 一刷新页面,Bug 又出现了。
下面是使用 iframe 引入的 demo
这 Bug 给我整得脑瓜子嗡嗡的,我又不是物理学家,写个前端怎么量子力学的观察者效应都给我整出来了(?
观测者效应(Observer effect),是指“观测”这种行为对被观测对象造成一定影响的效应。
在量子力学实验中,如果要测算一个电子所处的速度,就要用两个光子隔一段时间去撞击这个电子,但第一个光子就已经把这个电子撞飞了,便改变了电子的原有速度,我们便无法测出真正准确的速度(不确定原理)。时间流逝的快慢也会受到观测者的影响,用很高的频率去观测粒子的衰变,反而使得粒子长时间不衰变。
——wikipedia
这里先稍微解释一下 demo 中的代码片段:
if (scrollIndex >= groupLength) { setTimeout(() => { wrapper.style.transition = "none"; scrollIndex = 0; wrapper.style.transform = `translateY(-${crollIndex * itemHeight}px)`; requestAnimationFrame(() => { wrapper.style.transition = "transform 0.5s cubic-bezier(0.25, 0.1, 0.25, 1)"; }); }, 500);}
我这边拿到的需求是需要写一个无限滚动的轮播标题列表,每次展示三个,2 秒后标题列表整体上移,原本的第一个标题就移出可视范围了,下面会新补充一个新的标题列表。(可能解释的不清楚,但各位应该都看过上面的 demo 了)
当列表滚动到最底部的时候,我先取消 transition 过渡动画效果,趁机将整体列表平移到上一次可视范围内出现相同的三个标题的位置,再把 transition 过渡动画的效果加回来,这样就能在视觉上造成无限滚动的效果。
但问题就出在明明把 transition 属性取消了,但这一次平移仍然触发了过渡动画效果。
说实话,这是我短暂的码农生涯当中最绝望的一次,一方面是遇到的 bug 过于逆天以至于说出去都可能没人信,一方面是遇上这种问题是完全不能指望搜索引擎能给出什么解决方案的——毕竟我自己都不知道该怎么组织关键词进行搜索。
于是抱着试一试的心态,把相关代码喂给 ChatGPT-4o 看看能不能问出个所以然来。
你描述的现象——“滚动第九次时列表出现突兀的自下而上跳动,而打开 F12 时不会出现问题”——几乎可以确定是由于浏览器在某些渲染状态下跳过了某些帧(帧率波动)或者定时器精度的问题导致动画突变。
这种问题多半发生在“使用
setInterval
控制动画”和“切换样式(transition)时机不当”所引起的 过渡跳帧问题,而打开 DevTools 会 强制刷新帧或提高定时器精度,从而掩盖了这个问题。
window.requestAnimationFrame()
方法会告诉浏览器你希望执行一个动画。它要求浏览器在下一次重绘之前,调用用户提供的回调函数。——MDN
这是 GPT 给出的方案,非常有效
if (scrollIndex >= groupLength) { setTimeout(() => { wrapper.style.transition = "none"; scrollIndex = 0; wrapper.style.transform = `translateY(-${crollIndex * itemHeight}px)`; requestAnimationFrame(() => {+ requestAnimationFrame(() => { wrapper.style.transition = "transform 0.5s cubic-bezier(0.25, 0.1, 0.25, 1)";+ }); }); }, 500);}
如果觉得嵌套两层 requestAnimationFrame 比较难理解,那下面的代码是等效的
if (scrollIndex >= groupLength) { setTimeout(() => { scrollIndex = 0; requestAnimationFrame(() => { // 第一帧 wrapper.style.transition = "none"; wrapper.style.transform = `translateY(-${crollIndex * itemHeight}px)`; // 第二帧 requestAnimationFrame(() => { wrapper.style.transition = "transform 0.5s cubic-bezier(0.25, 0.1, 0.25, 1)"; }); }); }, 500);}
总之,我们需要杜绝浏览器将设置 transform 偏移值(瞬移列表位置)与恢复 transition 动画两件事合并到同一帧里去,而两层嵌套的 requestAnimationFrame 方法能很好的解决这个问题
就这样,通过使用两层requestAnimationFrame
,我们成功驯服了这个”量子态”的bug。现在无论是否打开F12,它都会乖乖地按照我们的预期滚动,不再玩消失的把戏。
看来,在前端的世界里,我们不仅要懂JavaScript,还得懂点量子力学。下次再遇到这种”一观测就消失”的bug,不妨试试这个”量子纠缠解决方案”——双重requestAnimationFrame
,没准就能让bug从”量子态”坍缩成”稳定态”呢!
当然,如果你有更神奇的 debug 经历,欢迎分享你的经历——毕竟,在代码的宇宙里,我们永远不知道下一个bug会以怎样的形态出现。也许,这就是编程的乐趣(?)所在吧!
本文由 ChatGPT 与 DeepSeek 协助撰写,但 bug 是真人真事(泪)。
2025-06-02 20:59:10
事情的起因是需要在网页上展示一个时长约为 5 分钟的产品展示视频,拿到的 H264 编码的原文件有 60MB 大。高达 1646 Kbps 码率的视频文件通过网络传输,烧 cdn 流量费用不说,对于弱网环境下的用户体验也绝对不会好。因此必须在兼顾浏览器兼容性(太好了不用管 IE)的情况下,使用更现代的视频压缩算法进行压缩。
AV1 作为目前压缩效率最高的主流视频编码格式,在 2025 年的今天已经在 YouTube、Netflix、Bilibili 等视频网站全面铺开,毫无疑问是最值得优先考虑的选择;除了优异的压缩效率以外,AV1 免版税的优势使得各硬件厂商和浏览器内核开发者可以无所顾忌的将 AV1 编码的支持添加到自己的产品中。
可惜的是,Safari 并没有对 AV1 编码添加软解支持,只有在搭载 Apple M3 及后续生产的 Mac 和 iPhone 15 Pro 后续的机型才拥有硬解 AV1 的能力,在此之前生产的产品均无法使用 Safari 播放 AV1 编码的视频。我宣布 Safari 已经成为当代 IE,妥妥阻碍 Web 发展的绊脚石
除此之外,AV1 在压制视频时对设备的要求较高。在桌面端的消费级显卡中,目前只有 NVIDIA RTX 40 系、AMD Radeon RX 7000 系、IntelArc A380 及后续的产品拥有 AV1 的编码(encode)支持。而 Apple M 系列芯片至今没有任何一款产品拥有对 AV1 编码的硬件支持。这也导致我在我搭载 Intel Core i7-1165G7 的 ThinkPad 上使用 AV1 编码压缩视频时被迫使用 libaom-av1 进行软件编码,1080p 的视频压缩效率为 0.0025x 的速率,五分钟的视频要压一天多的时间。
作为 H.264 / AVC 的下一代继任者,H.265(又称 HEVC)的表现可谓是一手好牌打得稀巴烂。HEVC 由多个专利池(如 MPEG LA、HEVC Advance 和 Velos Media)管理,授权费用高且分散,昂贵的专利授权费用严重限制了它的普及速度和范围,尤其是在开放生态和网页端应用中。
Chromium / Firefox 不愿意当承担专利授权费的冤大头,拒绝在当今世界最大的两个开源浏览器内核中添加默认的 H.265 软解支持,目前主流浏览器普遍采用能硬解就硬解,硬解不了就摆烂的支持策略。Firefox on Linux 倒是另辟蹊径,不仅会尝试使用硬解,还会尝试使用用户在电脑上装的 ffmpeg 软解曲线救国。不过好在毕竟是 2013 年就确定的标准,现在大部分硬件厂商都集体被摁着脖子交了专利授权费以保证产品竞争力,Apple 更是 HEVC 的一等公民,保证了全系产品的 HEVC 解码能力。
目前未覆盖到的场景主要是 Chromium / Firefox on Windows 7 和 Chromium on Linux(包括 UOS、麒麟等一众国产 Linux 发行版)。
VP9 是 Google 于 2013 年推出的视频编码格式,作为 H.264 的继任者之一,在压缩效率上接近 H.265(HEVC),但最大的杀手锏是——彻底免专利费。这也让 VP9 成为 Google 对 HEVC 高额授权费用的掀桌式回应:你们慢慢吃,我开一桌免费的。
借着免专利的东风和 Google 自家产品矩阵的强推,VP9 在 YouTube、WebRTC 乃至 Chrome 浏览器中迅速站稳了脚跟。特别是在 AV1 普及之前,VP9 几乎是网页视频播放领域的事实标准,甚至逼得苹果这个“编解码俱乐部元老”在 macOS 11 Big Sur 和 iOS 14 上的 Safari 破天荒地加入了 VP9 支持(尽管 VP9 in webm 的支持稍晚一些,具体见上表)。
VP9 的软解码支持基本无死角:Chromium、Firefox、Edge 都原生支持,Safari 也一反常态地“从了”。硬件解码方面,从 Intel Skylake(第六代酷睿)开始,NVIDIA GTX 950 及以上、AMD Vega 和 RDNA 系显卡基本都具备完整的 VP9 解码能力——总之,只要不是博物馆级别的老电脑,就能愉快播放 VP9 视频。
当然,编码仍是 VP9 的短板。Google 官方提供的开源实现 libvpx,速度比不上 x264/x265 等老牌选手,在缺乏硬件加速的场景下,仍然属于“关机前压一宿”的那种体验。不过相比 AV1 的 libaom-av1,VP9 至少还能算“可用”,适合轻量化应用、实时通信或是对压制速度敏感的用户,而早在 7 代 Intel 的 Kaby Lake 系列产品就已经引入了 VP9 的硬件编码支持,各家硬件厂商对 VP9 硬件编码的支持发展到今天还算不错。
作为“老将出马一个顶俩”的代表,H.264 / AVC 无疑是过去二十年视频编码领域的霸主。自 2003 年标准确定以来,凭借良好的压缩效率、广泛的硬件支持和相对合理的专利授权策略,H.264 迅速成为从网络视频、蓝光光盘到直播、监控乃至手机录像的默认选择。如果你打开一个视频网站的视频流、下载一个在线视频、剪辑一个 vlog,大概率都绕不开 H.264 的身影。
H.264 的最大优势在于——兼容性无敌。不夸张地说,只要是带屏幕的设备,就能播放 H.264 视频。软解?早在十几年前的浏览器和媒体播放器中就已普及;硬解?从 Intel Sandy Bridge、NVIDIA Fermi、AMD VLIW4 这些“史前”架构开始就已加入对 H.264 的完整支持——你甚至可以在树莓派、智能冰箱上流畅播放 H.264 视频。
虽然 H.264 同样存在和 H.265 相同的专利问题,但其授权策略明显更温和——MPEG LA 提供的专利池授权门槛较低,且不向免费网络视频收取费用,使得包括 Chromium、Firefox 在内的浏览器都默认集成了 H.264 的软解功能。Apple 和 Microsoft 更是早早将其作为视频编码和解码的第一公民,Safari 和 Edge 天生支持 H.264,不存在任何兼容性烦恼。
当然,作为一项 20 多年前的技术,H.264 在压缩效率上已经明显落后于 VP9、HEVC 和 AV1。相同画质下,H.264 的码率要比 AV1 高出 30~50%,在追求极致带宽利用或存储节省的应用场景中就显得有些力不从心。然而在今天这个“能播比好看更重要”的现实环境中,H.264 依然是默认方案,是“稳健老哥”的代名词。
所以,即便 AV1、HEVC、VP9 各有亮点,H.264 依旧凭借“老、稳、全”三大核心竞争力,在 2025 年依然牢牢占据着视频生态链的中枢地位——只要这个世界还有浏览器不支持 AV1(可恶的 Safari 不支持软解),服务器不想烧钱转码视频,或用户设备太老,H.264 就不会退场。
在视频编码方面,浏览器不再是那个能靠一己之力抹平硬件和系统差异的超人,所以总有一些特殊情况是表格中无法涵盖的。
编解码器 | 压缩效率 | 浏览器 | 桌面端支持 | 移动端支持 | 备注 |
---|---|---|---|---|---|
AV1 | ★★★ | Chrome / Chromium | 是 (v70+,发布于 2018 年 10 月) | 是 (v70+,发布于 2018 年 10 月) | 硬解优先,软解后备 |
Firefox | 是 (v67+,发布于 2019 年 5 月) | 是 (v113+,发布于 2023 年 5 月) | 硬解优先,软解后备 | ||
Safari | 不完全支持 (仅近两年的产品支持) | 不完全支持 (仅近两年的产品支持) | 仅支持硬解 (M3, A17 Pro 系芯片后开始支持),无软解支持 | ||
HEVC (H.265) | ★★☆ | Chrome / Chromium | 不完全支持 | 不完全支持 | 仅支持硬解,无软解支持(Windows 可从微软商店安装付费的软解插件) |
Firefox | 不完全支持 | 不完全支持 | 仅支持硬解,无软解支持(Linux 可依赖系统 ffmpeg 实现软解) | ||
Safari | 近期设备全部支持 (macOS High Sierra+,发布于 2017 年 6 月) | 近期设备全部支持 (iOS 11+,发布于 2017 年 10 月) | 苹果是 H.265 一等公民 | ||
VP9 | ★★☆ | Chrome / Chromium | 是 | 是 | 支持良好 |
Firefox | 是 | 是 | 支持良好 | ||
Safari | 是 (v14.1+,发布于 2021 年 4 月) | 是 (iOS 17.4+,发布于 2024 年 3 月) | 支持稍晚(此处指兼容 vp9 的 webm 时间,vp9 in WebRTC 的兼容时间更早) | ||
H.264 (AVC) | ★☆☆ | Chrome / Chromium | 是 | 是 | 通用 |
Firefox | 是 | 是 | 通用 | ||
Safari | 是 | 是 | 通用 |
我们不是专业的视频托管平台,不像 YouTube、Bilibili 那样专业到可以向用户提供多种分辨率、压缩算法的选择。
最终的选择策略,必须在压缩效率、播放兼容性、编码耗时等维度之间做出权衡。
现代浏览器支持在 <video>
标签中使用 <source>
标签和 MIME type 让浏览器按需播放
<video controls poster="preview.jpg"> <source src="video.av1.webm" type='video/webm; codecs="av01"' /> <source src="video.h264.mp4" type='video/mp4' /> 当前浏览器不支持视频播放</video>
通过这样的写法,浏览器会自动选择最先能解码的 source
,无需写复杂的判断逻辑或使用 JavaScript 动态切换。默认的 AV1 编码在最大程度上减少了传输流量降低成本,享受现代浏览器与设备的压缩红利;而 H.264 则作为兜底方案,保证了在不支持 AV1 的 Safari 等老旧设备上的回放兼容性。
然而这个选择可能并不是太合适,一方面我手上最先进的处理器 Apple M4 并不支持硬件编码 AV1 视频,5 分钟的视频压完需要整整 3 个小时,如果还需要视压缩质量来回调整压缩参数重新压上几次,那可真是遭老罪了;另一方面,即使 Chromium / Firefox 等主流浏览器内核现在都支持 AV1 的软解,但在一些硬件较老的设备上播放 AV1 编码的视频可能让用户的电脑风扇原地起飞,这一点在 YouTube 大力推广 AV1 的时候就曾遭到不少用户的诟病。
考虑到 AV1 编码的高昂成本和用户电脑风扇原地起飞的风险,VP9 也是一个非常具有竞争力的选择。VP9 在主流浏览器中得到了非常好的兼容,因此可以考虑放弃 H.264 的 fallback 方案独挑大梁。而 VP9 硬件编码在近几年的硬件设备上的普遍支持也给足了我勇气,让我可以多次调整压缩质量重新压缩,找一个在文件体积和画面清晰度之间的 sweet point。
由于是 VP9 独挑大梁,因此大多数人可能会考虑使用与 VP9 最为适配的 webm 格式封装视频。但目前在 webm 中最广泛使用的音频编码 opus 在 Safari 上的兼容性并不是太好(在 2024 年 3 月发布的 Safari 17.4 才开始支持),建议斟酌一下是不是继续用回 AAC 编码,并将视频封装在 mp4 中。
上面说了那么多的视频压缩算法,其实只是局限于视频画面的压缩,音频这一块其实还能再压一点出来。
Stream #0:1[0x2](und): Audio: aac (LC) (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 128 kb/s (default)
一个介绍产品的视频,在音频部分采用了 48000 Hz 双声道采样,码率高达 128 kbps,说实话有点奢侈。我直接砍成 64 kbps 单声道,又省下 2MB 的文件大小。
对于前端开发者来说,视频压缩算法的选择早已不是单纯的“压得小不小”问题,而是一场在设备能力、浏览器兼容性、用户体验与开发成本之间的博弈。我们既要跟上技术演进的节奏,拥抱 AV1、VP9 等更高效的编解码器,也要在实际项目中照顾到现实中的设备分布和播放环境。
在理想与落地之间,我们所能做的,就是充分利用 HTML5 提供的容错机制,搭配好合适的编码策略和封装格式,让网页上的每一段视频都能在合适的设备上、以合理的代价播放出来。
毕竟,Web 从来不缺“能不能做”,缺的是“做得优雅”。如果说编码器是硬件工程师和视频平台的战场,那 <video>
标签下的这几行 <source>
,才是属于我们前端工程师的战壕。
2025-05-31 00:29:40
这是精弘内部的图床开发时遇到的事情,大一的小朋友反馈说 el-image 和 el-table 打架了。
demo 的 iframe 引入
看到后面的表格透出 el-image 的预览层,我的第一反应是叫小朋友去检查 z-index 是否正确,el-image 的 mask 遮罩的 z-index 是否大于表格。
经过我本地调试,发现 z-index 的设置确实没问题,但后面的元素为什么会透出来?谷歌搜索一番,找到了这篇文章
给 el-table 加一行如下代码即可
.el-table__cell { position: static !important;}
经本地调试确认,这一方案确实能解决问题,但为什么呢?这就涉及到 Stacking Context (层叠上下文)了。
简单来说,Stacking Context 可以被类比成画布。在同一块画布上,z-index 值越高的元素就处于越上方,会覆盖掉 z-index 较低的元素,这也是为什么我最开始让检查 z-index 的设置是否有问题。但问题出在 Stacking Context 也是有上下顺序之分的。
现在假设我们有 A、B 两块画布,在 A 上有一个设置了 z-index 为 1145141919810 的元素。那这个元素具备非常高的优先级,理应出现在浏览器窗口的最上方。但如果 B 画布的优先级高于 A 画布,那么 B 元素上的所有元素都会优先显示(当了躺赢狗)。那么画布靠什么来决定优先级呢?
第二条规则也能解释为什么在上面的 demo 中,只有在表格中位置排在图片元素后面的元素出现了透出来的情况。
这次的冲突主要是下面两个因素引起的
el-table 给每个 cell 都设置了 position: relative
的 css 属性,而 position 被设为 relative 时,当前元素就会生成一个 Stacking Context。
所以我们这么一个有十个格子的表格,其实就生成了十个画布。而这其中每个画布 z-index 都为 1。根据刚才的规则,在图片格子后面的那些格子对应的 html 代码片段在整体的 html 文档中更靠后,所以他们的优先级都高于图片格子。
el-image 的预览功能所展开的遮罩层处于 el-image 标签内部
上图中橙色部分是 el-image 在预览时提供的遮罩,可以看到 element-plus 组件的 image 预览的默认行为是将预览时所需要的遮罩层直接放在 <el-image> </el-image> 标签内部,这导致 el-image 的遮罩层被困在一个低优先级的 Stacking Context 中,后面的格子里的内容就是能凭借高优先级透过来。
上面我谷歌搜到的将 el-table 中 cell 的 position 值强制设为 static 确实是有效的,因为 static 不会创建新的 Stacking Context,这样就不会有现在的问题。
但别的组件库在处理这个需求时,一般会将预览时提供的遮罩的 html 代码片段直接插入到 body 标签内部的最尾部,并设置一个相对比较大的 z-index 值,以确保这个遮罩层能够获得最高的优先级,以此能出现在屏幕的最上方。(像一些 dialog 对话框、popover 悬浮框也都是这个原理)。
事实上,element-plus 组件库也提供了这个功能
preview-teleported: image-viewer 是否插入至 body 元素上。嵌套的父元素属性会发生修改时应该将此属性设置为
true
所以在使用 el-image 时传入一个 :preview-teleported="true"
是一个更普适的方案,因为我们并不能确保 el-image 的父元素除了 el-table 的 cell 以外还有什么其他的父元素会创建新的 Stacking Context。