全面拥抱MDX, 让我的静态博客“动起来”

471 阅读5分钟

前言

本程序员用 Astro 搭好了自己的小窝(博客),键盘敲得飞起,代码写得贼溜。写博客嘛,自然少不了秀一秀咱的“艺术品”——代码片段!用 Markdown 一渲染,哎哟,语法高亮整整齐齐,看着就倍儿舒坦,强迫症都治好了!

但是!  问题来了... 当读者想“偷师”我的代码(或者单纯想复制粘贴去debug),事情就尴尬了。指望原生 Markdown 给个“一键复制”按钮?它只会眨巴着无辜的大眼睛说:“臣妾做不到啊!🤷‍♂️” 这功能得跟 JS 老哥勾肩搭背才能整出来。

怎么办?  身为合格的程序员,第一反应当然是——封装它!  造个轮子,啊不,造个组件!让它替我们干这脏活累活。

噔噔噔噔!  主角光环闪瞎眼!MDX 大佬踩着七彩祥云(划掉)... 带着 JSX 的超能力,闪亮登场!✨ 它拍了拍 Markdown 的肩膀:“老弟,复制按钮这种小事,包在我身上!”

MDX介绍

MDX(Markdown + JSX)是一种结合 Markdown 的简洁语法与 JSX 动态能力的混合格式。它允许在 Markdown 文档中直接嵌入 React 组件(或其他框架组件),解决了传统 Markdown 无法实现交互性和动态内容的问题。

核心特性:

  1. Markdown 基础:支持标准 Markdown 语法(标题、列表、代码块等)。
  2. JSX 嵌入:可在 Markdown 中插入交互式组件(如按钮、图表、动态内容)。
  3. 组件复用:导入并复用自定义或第三方 UI 组件。
  4. Frontmatter 支持:通过 YAML 元数据管理页面属性(标题、日期等)。
  5. ESM 兼容:支持 JavaScript 模块导入(import)。

说了这些,反正一句话MDX牛,强烈推荐在自己的静态博客中使用,替换之前的MD。

Astro中MDX的实践

作为目前当之无愧的最牛选手静态博客之王Astro,对MDX的支持度非常好,我们只需要安装一个插件@astrojs/mdx,集成进来就可以了,具体代码如下所示:

// astro.config.mjs文件
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import preact from "@astrojs/preact";
import { remarkReadingTime } from './remark-reading-time.mjs';
import tailwind from "@astrojs/tailwind";
import icon from "astro-icon";

// https://astro.build/config
export default defineConfig({
  site: "http://localhost:4321",
  prefetch: true,
  markdown: {
    shikiConfig: {
      // 选择 Shiki 内置的主题(或添加你自己的主题)
      // https://shiki.style/themes
      theme: 'dracula',
      // 另外,也提供了多种主题
      // 查看下面关于使用亮/暗双主题的的说明
      // themes: {
      //   light: 'github-light',
      //   dark: 'github-dark',
      // },
      // 添加自定义语言
      // 注意:Shiki 内置了无数语言,包括 .astro!
      // https://shiki.style/languages
      langs: [],
      // 启用自动换行,以防止水平滚动
      wrap: true,
      // 添加自定义转换器:https://shiki.style/guide/transformers
      // 查找常用转换器:https://shiki.style/packages/transformers
      transformers: []
    },
    gfm: true,
    remarkPlugins: [remarkReadingTime]
  },
  integrations: [preact({
    compat: true
  }), mdx(), tailwind(), icon({
    iconDir: "src/icons"
  })], // 集成的相关插件
  experimental: {
  
  }
});

集成后,在MDX里面导入动态组件并使用就可以了,操作也是非常的简单。

实战例子

前言 中我们有一个代码块可一键复制的需求,现在我们就来实现它,然后在自己的Astro博客中导入使用。

设计一个这样的组件,我们就只需要考虑两点,展现代码块和复制代码两部分。

  • 展现代码:将代码块转换为HTML代码,然后通过dangerouslySetInnerHTML属性去渲染HTML代码
  • 复制代码:navigator.clipboard可以实现

既然是博客,自然是耶做了主题的切换,再添加了一个功能,用于检测主题的变化,然后切换代码块的主题。

获取主题

function getCurrentTheme() {
  if (typeof window !== 'undefined') {
    if (window.localStorage.getItem('theme')) {
      return window.localStorage.getItem('theme');
    }
    if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
      return 'dark';
    }
  }
  return 'light';
}

将代码块转为HTML格式

 const fetchHtml = async () => {
      const html = await codeToHtml(code, {
        lang: language,
        theme: theme === 'dark' ? 'dracula' : 'github-light',
      });
      setHtml(html);
    };

复制代码

const copyToClipboard = (text: string) => {
    navigator.clipboard.writeText(text).then(() => {
      setIsTip(true);
      setTimeout(() => setIsTip(false), 2000);
    });
  };

以上就是主要功能的实现,现在只需要将其聚合在一起就可以封装成为我们想要的组件了,具体代码如下:

import type React from "preact/compat";
import { useEffect, useState } from "preact/compat";
import { codeToHtml } from 'shiki';

// 获取当前主题
function getCurrentTheme() {
  if (typeof window !== 'undefined') {
    if (window.localStorage.getItem('theme')) {
      return window.localStorage.getItem('theme');
    }
    if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
      return 'dark';
    }
  }
  return 'light';
}

type Props = {
  code: string;
  language: string;
  children?: React.ReactNode;
};

const CodeBlock = (props: Props) => {
  const { code, language } = props;
  const [html, setHtml] = useState('');
  const [isTip, setIsTip] = useState(false);
  const [theme, setTheme] = useState('light');

  // 监听主题变化
  useEffect(() => {
    const updateTheme = () => setTheme(getCurrentTheme() || 'light');
    updateTheme();
    window.addEventListener('storage', updateTheme);
    const mql = window.matchMedia('(prefers-color-scheme: dark)');
    mql.addEventListener('change', updateTheme);
    // 监听 class 变化(如 ThemeToggle 切换时)
    const observer = new MutationObserver(() => {
      updateTheme();
    });
    observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
    return () => {
      window.removeEventListener('storage', updateTheme);
      mql.removeEventListener('change', updateTheme);
      observer.disconnect();
    };
  }, []);

  // 根据主题渲染高亮
  useEffect(() => {
    const fetchHtml = async () => {
      const html = await codeToHtml(code, {
        lang: language,
        theme: theme === 'dark' ? 'dracula' : 'github-light',
      });
      setHtml(html);
    };
    fetchHtml();
  }, [code, language, theme]);

  const copyToClipboard = (text: string) => {
    navigator.clipboard.writeText(text).then(() => {
      setIsTip(true);
      setTimeout(() => setIsTip(false), 2000);
    });
  };

  return (
    <div
      className={`relative group rounded-xl overflow-auto border border-gray-200 dark:border-gray-700 bg-gradient-to-br from-white/90 to-gray-100/80 dark:from-zinc-900/90 dark:to-zinc-800/80 shadow-lg transition-all duration-500 my-6 hover:scale-[1.01]`}
      style={{ fontSize: '1em', boxShadow: '0 4px 24px 0 rgba(0,0,0,0.08)' }}
      onClick={() => copyToClipboard(code)}
    >
      <button
        className="absolute right-5 top-2 text-xs opacity-90 group-hover:opacity-100 bg-white/90 dark:bg-zinc-800 px-3 py-1 rounded-full shadow border border-gray-300 dark:border-zinc-700 transition-all hover:bg-blue-100 hover:text-blue-600 dark:hover:bg-zinc-700 dark:hover:text-yellow-300 text-gray-700 dark:text-gray-200 font-semibold"
        style={{ zIndex: 2 }}
        type="button"
        tabIndex={-1}
        onClick={e => { e.stopPropagation(); copyToClipboard(code); }}
      >
        <span className="inline-block align-middle mr-1">📋</span>复制
      </button>
      {isTip && (
        <span className="absolute right-5 top-10 text-xs text-green-600 dark:text-green-400 opacity-90 bg-white/90 dark:bg-zinc-900/90 px-3 py-1 rounded-full shadow border border-green-200 dark:border-green-700 transition-all" style={{ zIndex: 2 }}>已复制!</span>
      )}
      <div
        className="whitespace-pre-wrap break-words px-5 py-4 overflow-x-auto font-mono text-[0.98em] leading-relaxed selection:bg-blue-200 dark:selection:bg-blue-900/60"
        style={{ minHeight: 40 }}
        dangerouslySetInnerHTML={{ __html: html }}
      />
    </div>
  );
};

export default CodeBlock;

然后将其组件导入MDX中

import CodeBlock from "@components/CodeBlock";

### 内网穿透 - 端口映射


**局部安装**:

<CodeBlock
  code={ 
  `npm install -g localtunnel\nlt --port 端口号`
  }
  language="javascript"
  client:load
/>

**全局安装**:
<CodeBlock
  code={ `npx localtunnel --port 端口号`}
  language="javascript"
  client:load
/>

### NPM 包更新
<CodeBlock
  code={ `npm install -g npm-check-updates\nncu -u\nnpm install`}
  language="javascript"
  client:load
/>

展现格式如下:

image.png

总结

回顾本文的探讨,通过一个代码块一键复制的需要,我们了解到了MDX,并感受到了MDX的强大,让我们的静态博客也能动起来,创建出属于我们程序员的优美博客。