2025-09-05 05:54:17
在今年八月的时候,我这边所在的一个 Github Organization 在私有项目开发阶段频繁触发 CI,耗尽了 Github 为免费计划 (Free Plan) 提供的每月 2000 分钟 Action 额度(所有私有仓库共享,公有仓库不计)。大致看了下,CI 流设置得是合理的,那么就要另寻他法看看有没有办法去提供更宽裕的资源,因此也就盯上了文章标题中所提到的 Github Action Self-hosted Runner。
对于这个 Self-hosted Runner,与 Github 官方提供的 runner 相比,主要有以下几个优势
由于不清楚需要的网络环境,我这次测试直接选用了一台闲置的香港 vps,4核4G + 80G 硬盘 + 1Gbps 大口子的配置,除了硬盘读写稍微拉胯一些,别的地方可以说是拉满了。
Self-hosted Runner 的配置本身是相当直接和清晰的,照着官方提供的方案基本没什么问题。
三个主流平台都有,如果好好加以利用,应该可以涵盖包括 iPhone 应用打包等一系列的需求。
在观察一下我这边拿到手的 2.328.0 版本的 runner 安装文件压缩包的体积在 220MB 左右,内置了 node20 和 node24 各两个版本的运行环境。
在执行完 config.sh 后,当前目录下就会多出一个 svc.sh,可以帮助利用这东西来调用 systemd 实现进程守护之类的需求。
再次刷新网页,就可以看到 Self-hosted Runner 处于已经上线的状态了
这一步很简单,只需在原 Action 的 yml 文件中改变 runs-on 字段即可
jobs: run:+ runs-on: self-hosted- runs-on: ubuntu-latest
当我满心欢喜地将 CI 流程从 Github 官方的 runner 切换到自托管的 runner 后,问题很快就浮现了,而这也正是我“爱不起来”的主要原因。问题集中体现在我习以为常的 setup-python
这一由 Github 官方维护的 Github Action Flow 中,提示 3.12 版本没找到。
在 Github 官方提供的虚拟环境中,这些 Action 会为我们准备好指定版本的开发环境。例如,uses: actions/setup-python
加上 with: python-version: '3.12'
就会自动在环境中安装并配置好 Python 3.12.x。我对此已经习以为常,认为这是一个“开箱即用”的功能。但在 Self-hosted Runner 上,情况略有些不同。setup-python 在文档中指出
Python distributions are only available for the same environments that GitHub Actions hosted environments are available for. If you are using an unsupported version of Ubuntu such as
19.04
or another Linux distribution such as Fedora,setup-python
may not work.
setup-python 这个 Action 只支持 Github Action 所采用的同款操作系统,而我 VPS 的 Debian 不受支持,因此有这个误报,同时也给我的 Debian 判了死刑。
我潜意识里认为,Self-hosted Runner 仅仅是将计算成本从 Github 服务器转移到了本地,而 actions/setup-python
这种官方标准动作,理应会像 Github-hosted Runner 中那样,优雅地为我下载、安装、并配置好我需要的一切。然而,Self-hosted Runner 的本质只是从 Github 接收任务,并在当前的操作系统环境中执行指令,并不保证和 Github 官方提供的 Runner 的运行环境一致。
Self-hosted Runner 不是一个开箱即用的“服务”,而是一个需要你亲自管理的“基础设施”。你需要负责服务器的安装、配置、安全更新、依赖管理、磁盘清理等一系列运维工作。它更适合那些对 CI/CD 有更高阶需求的团队或个人:比如 CI/CD 消费大户、需要特定硬件(如 ARM、GPU)进行构建的团队、或者 CI 流程深度依赖内部网络资源的企业。对于像我这样只是愿意拿出更多的本地计算资源来获取更多 Action 运行时长的普通开发者而言,它带来的运维心智负担,似乎是有一点重了。
2025-08-11 00:06:40
去年夏天,我兴致勃勃地写了好几篇博文,详细讲述了我如何搭建博客图床。核心目标很明确:分地区解析 DNS,让国内外的访客都能嗖嗖地加载图片,体验拉满。想法嘛,绝对是走在技术前沿的,堪称完美!然而……现实它总是喜欢给你来点小惊喜,对吧?
955 毫秒! 看到这个 DNS 解析时长的时候,我差点把刚喝下去的霸王茶姬喷在屏幕上。这简直就是一个隐形刺客,在我精心设计的图床网络架构背后,悄咪咪地给了致命一击。想象一下,访客满怀期待地点开你的博客,结果光是为了搞清楚图片服务器在哪,就要等上差不多一秒钟?这体验优化了个寂寞啊!
为啥之前没发现?这得“感谢”DNS 缓存这位老好人。它勤勤恳恳地帮后来的访客记住了答案,让我的本地测试和复访测试都一片祥和。直到最近,有群友向我反馈了首次访问时图片的加载速度过慢,我才如梦初醒。再结合最近为了秋招准备的八股文中里面关于 DNS 解析那套繁琐的流程(递归查询、权威查询、根域名、顶级域名……查个地址堪比查户口本),我瞬间锁定了罪魁祸首:首次访问时的 DNS 解析延迟。
来,复盘一下我那“曲折离奇”的 DNS 寻址之旅(访客视角):
static.031130.xyz
的图片。031130.xyz
的权威 DNS: 问了一圈,发现权威服务器原来在 Cloudflare (国外)。static.031130.xyz
啊?它是个马甲 (CNAME
),真身是 cdn-cname.zhul.in
,你去找它吧!”zhul.in
的权威 DNS: 这次权威服务器在 DNSPod (国内)。cdn-cname.zhul.in
也是个马甲 (CNAME
),它实际是 small-storage-cdn.b0.aicdn.com
,接着找!”small-storage-cdn.b0.aicdn.com
: 最终,它可能还会再 CNAME
到类似 nm.aicdn.com
这样的 CDN 节点主机名。发现问题没?关键的第一步和第二步,权威 DNS 查询指向了国外的 Cloudflare! 对于国内用户,虽然最终解析到的 CDN 节点 (small-storage-cdn.b0.aicdn.com/nm.aicdn.com
) 是国内的、速度飞快,但光是前两步跨越重洋的 DNS 查询,就足够让首次访问的用户体验跌入谷底。那个 955ms 的解析时长,基本就是花在跟国外 DNS 服务器“跨国聊天”上了。
优化方案:三管齐下,围剿 DNS 延迟
既然找到了病根,就得下猛药:
dns-prefetch
): 在博客的 HTML <head>
里,早早地加上 <link rel="dns-prefetch" href="//static.031130.xyz">
。这相当于浏览器在渲染页面时,就悄悄开始解析图床域名了,等真需要加载图片时,DNS 结果可能已经准备好了,神不知鬼不觉。当然也可以使用 preconnect 等等更激进的策略,但本文着重讲 DNS 解析,因此不做拓展。static.031130.xyz
这个 CNAME
记录的 TTL 值调大。以前都设置得较短,方便快速切换。现在为了缓存,适当延长(比如几小时甚至一天)。这样,一旦有用户解析过,本地 DNS 服务器就能记住更久,后续用户(包括同一用户再次访问)就能直接从缓存拿到结果,省掉跨国查询。031130.xyz
域名的权威 DNS 服务器,从 Cloudflare 搬回国内 DNSPod。这样一来:031130.xyz
的权威服务器时,直接找到的就是国内的 DNSPod,响应飞快。static.031130.xyz
-> small-storage-cdn.b0.aicdn.com
完全在国内完成,丝般顺滑,不需要 cdn-cname.zhul.in
当分区域解析的工具人效果如何?
受限于 DNS 缓存带来的测试困难,最终的效果确实很难在短时间内测试出来。但迁移权威 DNS 到 DNSPod + 调整 TTL + 加上预取之后,再测试首次访问的 DNS 解析时间总算是降到了可接受的程度,这才是 CDN 优化该有的样子!
教训总结:
dns-prefetch
和合理设置 TTL 能有效缓解。!!! 超级重要补充:警惕 CNAME 拉平 !!!
最后,必须给各位提个醒!如果你和我一样,需要依赖分地区解析来让访客访问到最近的 CDN 节点(比如让国内走国内CDN,国外走Cloudflare),那么千万要避开 CNAME Flattening
(CNAME 拉平) 这个“优化”方案!
CNAME
记录(比如 static.example.com
-> cdn.cname.target.com
),它主动帮你去找 cdn.cname.target.com
的最终 A
/AAAA
记录 (IP地址),然后把 最终的 IP 地址 直接返回给查询者,而不是返回 CNAME
。DNS View
或 GeoDNS
) 的功能是在权威 DNS 服务器层面实现的。当权威服务器执行 CNAME
拉平时,它是在它自己所在的位置去查询 cdn.cname.target.com
的 IP。比如你的权威 DNS 在 Cloudflare (美国节点),它拉平查询时,拿到的 cdn.cname.target.com
的 IP 大概率是给美国用户用的最优 IP。然后它把这个 IP 返回给了所有地区的查询者,包括中国用户!你精心配置的让中国用户解析到国内 CDN IP 的策略就完全失效了!GeoDNS
) 功能,绝对不要在你希望应用分地区解析的域名上启用 CNAME Flattening
(或 ALIAS
, ANAME
等实现类似拉平效果的功能)。老老实实用 CNAME
指向另一个支持 GeoDNS
的域名(就像我初始方案里 static.031130.xyz
-> cdn-cname.zhul.in
,而 zhul.in
在 DNSPod 上做分地区解析),才能保证你的分流策略正确执行。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 是真人真事(泪)。