如何按需加载 Markdown 渲染资源

我总结了 Typecho 主题里常见的 Markdown 资源按需加载方案,重点是 KaTeX highlight.js

主题会在所有页面加载:

  • katex.min.css / katex.min.js
  • highlight.min.js

这样会导致:

  • 首页、分类页、非渲染的文章也发起请求
  • 首屏重
  • CDN 请求数增加

于是我采用按需加载:

  1. 没有公式就不加载 KaTeX
  2. 没有代码块就不加载 highlight
  3. 检测时排除 pre/code,避免误判
  4. 保证主题切换(明/暗)下高亮样式正确

首页结果:

状态域名文件耗时
200www.hansjack.com/59 ms
200www.hansjack.comresult.css66 ms
200www.hansjack.comstyle.css53 ms
xwww.blogsclub.orgwww.hansjack.com109 ms

KaTeX 预判(含 pre/code 排除)

<?php
use Widget\Archive;

function hjArchiveContentContainsKatexSyntax(string $content): bool
{
    if ($content === '') {
        return false;
    }

    // 关键:先排除 pre/code,避免代码示例里的 $...$ 触发误判
    $scanContent = $content;
    if (strpos($scanContent, '<') !== false) {
        $withoutPre = preg_replace('/<pre\b[^>]*>[\s\S]*?<\/pre>/iu', ' ', $scanContent);
        if (is_string($withoutPre)) {
            $scanContent = $withoutPre;
        }

        $withoutCode = preg_replace('/<code\b[^>]*>[\s\S]*?<\/code>/iu', ' ', $scanContent);
        if (is_string($withoutCode)) {
            $scanContent = $withoutCode;
        }
    }

    if ($scanContent === '') {
        return false;
    }

    if (strpos($scanContent, '$') !== false) {
        if (preg_match('/\$\$[\s\S]+?\$\$/u', $scanContent) === 1) {
            return true;
        }
        if (preg_match('/(?<!\\\\)\$(?!\s)(?:[^$\\\\\r\n]|\\\\.)+?(?<!\s)(?<!\\\\)\$/u', $scanContent) === 1) {
            return true;
        }
    }

    if (strpos($scanContent, '\\') !== false) {
        if (preg_match('/\\\\\([\s\S]+?\\\\\)|\\\\\[[\s\S]+?\\\\\]/u', $scanContent) === 1) {
            return true;
        }
        if (preg_match('/\\\\begin\{(?:equation|align|alignat|gather|CD)\}/u', $scanContent) === 1) {
            return true;
        }
    }

    return false;
}

function hjShouldLoadKatexAssets(Archive $archive): bool
{
    $isRenderable = false;
    try {
        $isRenderable = $archive->is('post') || $archive->is('page');
    } catch (\Throwable $e) {
        $isRenderable = false;
    }
    if (!$isRenderable) {
        return false;
    }

    try {
        $content = trim((string) ($archive->content ?? ''));
    } catch (\Throwable $e) {
        $content = '';
    }

    return hjArchiveContentContainsKatexSyntax($content);
}

highlight 预判

<?php
use Widget\Archive;

function hjShouldLoadHighlightAssets(Archive $archive): bool
{
    $isRenderable = false;
    try {
        $isRenderable = $archive->is('post') || $archive->is('page');
    } catch (\Throwable $e) {
        $isRenderable = false;
    }
    if (!$isRenderable) {
        return false;
    }

    try {
        $content = (string) ($archive->content ?? '');
    } catch (\Throwable $e) {
        $content = '';
    }
    if ($content === '') {
        return false;
    }

    // Markdown 渲染后一般为 <pre><code>...
    return preg_match('/<pre\b[^>]*>\s*<code\b/i', $content) === 1;
}

前端按需加载

function loadScriptOnce(src, key, done) {
  if (!src) return done();
  var selector = 'script[data-res-key="' + key + '"]';
  var existing = document.querySelector(selector);
  if (existing) {
    if (existing.getAttribute("data-res-loaded") === "1") return done();
    existing.addEventListener("load", done);
    existing.addEventListener("error", done);
    return;
  }
  var s = document.createElement("script");
  s.src = src;
  s.async = false;
  s.setAttribute("data-res-key", key);
  s.onload = function () {
    s.setAttribute("data-res-loaded", "1");
    done();
  };
  s.onerror = done;
  document.head.appendChild(s);
}

function appendCssOnce(href, key) {
  if (!href) return;
  if (document.querySelector('link[data-css-key="' + key + '"]')) return;
  var link = document.createElement("link");
  link.rel = "stylesheet";
  link.href = href;
  link.setAttribute("data-css-key", key);
  document.head.appendChild(link);
}
(function () {
  var containers = Array.prototype.slice.call(
    document.querySelectorAll(".hj-article-content, .hj-comment-content")
  );
  if (!containers.length) return;

  var codeBlocks = [];
  containers.forEach(function (content) {
    if (!content || !content.querySelectorAll) return;
    var blocks = content.querySelectorAll("pre code");
    for (var i = 0; i < blocks.length; i++) codeBlocks.push(blocks[i]);
  });
  if (!codeBlocks.length) return;

  function applyHighlightTheme() {
    var root = document.documentElement;
    var isDark = root.classList.contains("hj-theme-dark") && !root.classList.contains("hj-theme-light");
    var links = document.querySelectorAll("link[data-hljs-theme]");
    for (var i = 0; i < links.length; i++) {
      var theme = links[i].getAttribute("data-hljs-theme") || "";
      links[i].disabled = isDark ? theme !== "dark" : theme !== "light";
    }
  }

  function appendThemeLink(theme, href) {
    if (!href) return;
    if (document.querySelector('link[data-hljs-theme="' + theme + '"]')) return;
    var link = document.createElement("link");
    link.rel = "stylesheet";
    link.href = href;
    link.disabled = true;
    link.setAttribute("data-hljs-theme", theme);
    document.head.appendChild(link);
  }

  appendThemeLink("light", "/usr/themes/YourTheme/assets/vendor/highlight/github.min.css");
  appendThemeLink("dark", "/usr/themes/YourTheme/assets/vendor/highlight/github-dark.min.css");
  applyHighlightTheme();

  loadScriptOnce("/usr/themes/YourTheme/assets/vendor/highlight/highlight.min.js", "hljs", function () {
    if (!window.hljs) return;
    try { window.hljs.configure({ ignoreUnescapedHTML: true }); } catch (e) {}
    codeBlocks.forEach(function (code) {
      if (!code) return;
      if (code.dataset && code.dataset.highlighted) return;
      try { window.hljs.highlightElement(code); } catch (e) {}
    });
  });
})();

KaTeX 前端检测(排除 pre/code

function hasKatexSyntax(text) {
  if (!text) return false;
  if (text.indexOf("$$") !== -1 && /\$\$[\s\S]+?\$\$/.test(text)) return true;
  if (text.indexOf("\\(") !== -1 && /\\\([\s\S]+?\\\)/.test(text)) return true;
  if (text.indexOf("\\[") !== -1 && /\\\[[\s\S]+?\\\]/.test(text)) return true;
  if (text.indexOf("\\begin{") !== -1 && /\\begin\{(?:equation|align|alignat|gather|CD)\}/.test(text)) return true;
  if (text.indexOf("$") !== -1 && /(^|[^\\])\$(?![\s$])(?:[^$\\\r\n]|\\.)+?\$(?!\$)/.test(text)) return true;
  return false;
}

function readTextWithoutCode(node) {
  if (!node) return "";
  var sourceNode = node.cloneNode ? node.cloneNode(true) : node;
  if (sourceNode.querySelectorAll) {
    var ignored = sourceNode.querySelectorAll("pre, code");
    for (var i = 0; i < ignored.length; i++) {
      if (ignored[i] && ignored[i].parentNode) ignored[i].parentNode.removeChild(ignored[i]);
    }
  }
  return String((sourceNode && sourceNode.textContent) || "");
}

注意事项

  1. 检测误判最常见来源是代码块中的数学符号,务必排除 pre/code
  2. 正则不要过宽,特别是 $...$,容易误伤金额、Shell 变量等文本。
  3. 动态加载脚本必须防重复,否则会出现多次初始化。
  4. highlight 明暗主题要跟随 html class 切换。
  5. 若站点启用严格 CSP,需要给动态注入资源放行域名。
  6. 压缩和缓存策略要和按需加载配合,避免重复下载。
  7. 评论区若支持 Markdown,同样应纳入检测范围。
评论 2条
  1. xingwangzhe

    好巧,我也写过类似的

    Astro: 优化katex,mermaid和灯箱使用

    HansJack厉害,好文! 以前用Astro感觉{构建时间太长:几分钟}就放弃了,果然还是==不太会玩静态博客==
    共1条回复 收起回复
    1. HansJack

      厉害,好文!
      以前用Astro感觉构建时间太长几分钟就放弃了,果然还是不太会玩静态博客