Loading...
Loading...
- 最后编辑:2025/10/21 17:51:19 -
Loading...
是的,这次事情的起源仍然在我想要完善博客文本的显示模式时,我这几天已经因为这堆问题连发了三篇博客了。(笑
起初我是觉得博客代码块内的缩进空间太小了,看起来结构不明显,有点难以阅读,并且我还不能自由的调整缩进的距离,所以我就想要(立!刻!)解决这个问题。
首先,我先问了一下AI这是怎么回事,有没有方法解决,AI给出了肯定的回答,并告诉了我一条有效的路:将缩进单位从空格替换为制表符。
也就是说,原本程序是使用两个空格为一个单位来对代码添加缩进的,现在我直接将每两个空格整体替换成一个制表符\t,以此来达到规范化的效果,同时我还能用CSS的tab-size属性来动态调整缩进距离,听起来似乎是个完美的解决方案。
但现在有个问题就来了——我该如何介入next-mdx-remote模块(Next.js中一种能将MDX文本转换成HTML标签的npm包)的编译过程,对文本进行替换呢?
我的第一想法是直接在next-mdx-remote编译MDX文本时,通过在components处对应的节点类型中添加判断替换的逻辑。
为了确保没有相关开发经验的读者也能够理解这篇文章,所以这里先将一些有关next-mdx-remote的基本常识和用法简单讲解一下:
在next-mdx-remote这个包中,有一种名为服务端组件(RSC)的原生 MDX 渲染方案,它对MDX文本的解析与编译环节发生在服务端,而不是客户端。
在代码中,我们可以通过形如<MDXRemote source components? options? > /* code */ </MDXRemote>的形式来调用next-mdx-remote处理指定的文本,其中:
而next-mdx-remote在编译时主要会经历以下几个环节:
remark插件链)mdx 编译器)你不需要掌握太多更细节的内容(因为我也说不明白),只需要知道一个大概的过程即可。
接着,我就想通过在components中自定义<code>标签的内容来实现我的最终效果——但是失败了。
值得一提的是,在实现这一步骤的过程中,我发现next-mdx-remote中一个用于实现语法高亮的插件会将文本切的很碎,碎到连空格都能独占一个对象的程度,因此想要获取到被层层包在最深处的纯文本格式的内容是非常难的(它有点类似于一个高维数组),以至于我写出来的代码的可读性也极差,套了两层for循环,然后还要再往下找才能找到,可谓是又臭又长啊。。。
并且令我失望的是,我花了这么大力气才找到的文本,修改完后居然没效果!
我在实现修改文本的算法后,回到浏览器中,准备美美的给<code>标签加上tab-size样式后结束今天的工作,结果难以置信的发现这个样式没有任何效果lol……
我大为震惊,只能去求助AI,询问原因,而AI给出的答复是:
你对代码内容的修改没有真正生效到 DOM 渲染中,并且
tab-size对<span>包裹的文本无效。
简单来说,当程序执行到需要调用components的内容的时候,整个页面基本已经渲染完毕了,相当于上文中提到的第四环节(HAST → React 元素),我只能对标签进行修改,而无法修改其内部已经被解析完成的标签类型、文本内容等,这些修改都无法实际的影响到DOM中的,所以这个改动实际在浏览器中并没有生效。
那么,这个问题该如何处理呢?
网络上有句话说得好:遇事不决中间件,如果一个不够,那就再加一个。
具体的解法,实际上我是让AI帮忙完成的,我事后向它仔细地了解了整个解决方案的原理和细节,它给出的解决方案大致如下:
首先创建一个新的rehype插件:rehypeIndentToTab.js:
export default function rehypeIndentToTab({ spacesPerTab = 2 } = {}) {
return (tree) => {
// 遍历所有 <pre><code> 节点
const visit = (node) => {
if (node.type === 'element' && node.tagName === 'pre') {
const codeNode = node.children.find(
(child) => child.type === 'element' && child.tagName === 'code'
);
if (codeNode && codeNode.children?.[0]?.type === 'text') {
let text = codeNode.children[0].value;
if (
还记得rehype插件是做什么的吗?没错,它是专门用来操作HTML的一种插件!
rehypeIndentToTab插件能够精确的寻找到每一个<code>元素,并替换掉标签内文本中的空格缩进。
最后将这个插件注册到options中,并确保它位于所有rehype插件的最上方,这能让它直接处理未经任何插件影响的DOM,然后就大功告成!
这时候我打开浏览器,发现tab-size确实生效了,这代表插件已经正常运行,且达到了它的目的!
解决方案说完了,接下来我们就重点讲一下这个神奇的插件的原理。
逐行解释这个插件:
export default function rehypeIndentToTab({ spacesPerTab = 2 } = {}) {
return (tree) => {
// 遍历所有 <pre><code> 节点
const visit = (node) => {
if (node.type === 'element' && node.tagName === 'pre') {
// code...
}
};
visit(tree);
};
}首先,当插件被调用时,它默认导出的rehypeIndentToTab函数将接受一个spacesPerTab参数,并返回一个visit函数,这个函数会在插件的一开始就被调用,同时传入一个tree变量,它是 HAST 的根节点,也就是整个文档的根节点,类似于<html></html>这种标签,这是典型的高阶函数写法,比较容易理解。
接着,visit函数会首先对tree进行处理,可以看到,在第五行,它判断了一次节点的类型,但是tree本身不属于pre类型,所以整个if就直接会被跳过,来到下面的if (node.children)语句中。
if (node.children) { // 如果当前节点中还有其他子节点
node.children.forEach(visit); // 遍历子节点,并依次调用visit函数
}这个if语句会判断当前处理的节点是否含有其他子节点,那对于tree这个文档根节点来说,下面肯定还有其他节点,所以它会通过forEach依次的遍历每一个子节点,并对它们都调用一次visit函数。
关键点来了,forEach依次遍历根节点的子节点,相当于遍历每一个处于文档最外围的HTML标签,并判断他们是不是<pre>标签,如果不是,就直接进入到节点中,判断里面还有没有子节点,如果有,那再往下遍历一次,如果没有,那就说明当前遍历已经到了尽头了,已经走到了最深或最后的节点了,那么程序就会原路依次返回,每返回一层,forEach就会判断遍历有没有完成,如果完成了就再往上走,没有就继续遍历,以此类推,形成一种树状的路径,或者我们可以将这种遍历算法称之为深度优先算法(DSP)——先进入一个分支,走到最深,再回溯,也就是俗话中常说的:“不撞南墙不回头”。
接着,我们再讨论另一种情况,就是最上面那个if判断为真时,说明辛苦劳作大半天的visit函数终于找到一个它心心念念的<pre>标签了,那么这个if中的代码将不会像之前一样被跳过,而是会被执行,来到下一行:
const codeNode = node.children.find(
(child) => child.type === 'element' && child.tagName === 'code'
);这一行的作用总结起来就一句话:找到<pre>标签里面唯一的那个<code>标签。
因为在 <pre> 标签内部,HTML 和 Markdown 规范都保证只会有一个 <code> 标签,也就是一个<pre>标签中有且只会有一个<code>标签,不会出现第二个(这也是为什么下面的代码中codeNode.children的偏移量能直接取0的原因,因为这个数组中只可能有一个值),所以find的目的就是为了在<pre>标签的所有子元素中精准的找到它想要的那个<code>标签(因为<pre>标签在某些情况下可能不只有<code>标签一个子元素,也可能含有注释、换行符号、空白文本等独立存在的节点,所以需要进一步排除),并赋值给codeNode。由于find函数会返回数组中第一个满足条件的值,所以一旦找到<code>标签,函数就会立即返回,避免了后续多次遍历造成不必要的性能浪费的可能。
需要注意的是,此时find函数的第一个参数不能直接传入一个bool数据或者条件判断语句,因为在上文中,child变量并未定义,我们无法在外部获取到children里的每一个每一个child,因此,我们需要利用find函数的特性(它会默认将遍历的每个子元素作为实参传给第一个参数中的函数),使用箭头函数的形式获取到子元素,再去判断它的类型是不是code。
当找到<code>标签后,那一切都好说了,我就可以直接拿到标签中的文本,然后对它进行全文正则替换了:
// 将每行开头的 spacesPerTab 个空格组替换为制表符 \t
const regex = new RegExp(`^([ ]{${spacesPerTab}})+`, 'gm');
text = text.replace(regex, (match) => {
return '\t'.repeat(match.length / spacesPerTab);
});
// 关键的一步:将替换后的文本作用到DOM上,完成最终的修改
codeNode.children[0].value = text;完成这一步后,visit就会逐层返回并依次排查有没有其他<pre>标签的存在,如果没有,那么这个插件的生命周期就到此结束了。
总结下来,整个插件的大致原理可以被概括为:
编译器首先调用
visit(tree),传入 HAST 根节点tree。此次调用中,由于根节点不是<pre>,所以直接跳过替换逻辑,但因其包含子节点,进入forEach开始深度优先遍历。在遍历过程中,每当遇到
<pre>元素,就通过find精准定位其内部唯一的<code>子节点,提取其中的原始文本,将前导空格替换为制表符\t。替换完成后,继续递归遍历
<code>的子节点(如高亮生成的<span>),直到叶子节点(即最末端的节点,如文本节点)——此时因无children,递归自然返回。控制权逐层回退至父级的
forEach,继续处理下一个兄弟节点,最终完成整棵树的遍历,确保所有代码块都被正确转换。
至此,这个名为rehypeIndentToTab的插件就完成了它伟大的事业,完美的达到了我所希望的效果。
如果非说我能从这个案例中学到了什么的话,我的评价是它让我对于js的各种高级玩法的认识更深了一步,比如对高阶函数的概念、AST 遍历机制、高级的数组函数等内容有了全新的认知,它毫无疑问的能帮助我更加完全的掌握现代化的JavaScript开发,写出更加优雅的代码(笑
感谢AI,感谢通义,感谢阿里,为我解答了不少疑惑,也帮助我学到了很多东西🙏
PS:本文中提到的所有代码片段默认视为开源,你可以在MIT协议允许的范围内自由的使用它。
1个月前
技术 · Postgresql · JS · 全文搜索
1个月前