虽然什么也写 (很多东西都懒得写), 但是还是喜欢折腾这个博客......

最开始使用的是静态博客 Hexo, 主题使用的是 Fluid, 那时直接挂在 GitHub 进行托管. Github Pages 非常适合静态博客, 但问题是稍微慢了一点并且非常不方便使用, 页不好扩展

从创建博客以来没事就想去优化一下, 想让博客变得更快功能更多更方便使用, 最后来到了动态博客系统 Halo

整理了一些最近做过的优化. 由于对前端可以说是一无所知仅仅现学现卖, 所以很不专业

资源压缩

对于一些静态资源, 比如头像, logo 等等都可以进行压缩

图片压缩

SVG 压缩: https://github.com/svg/svgo

图片压缩: https://github.com/GoogleChromeLabs/squoosh

JS/CSS 压缩

关于 js 和 css 压缩可以使用 esbuild, 需要安装一个 Node.js

进入博客放 JS 的目录, 执行下面这条命令就可以完成压缩

npx esbuild main.js --bundle --minify --outfile=main.min.js

--minify 就是开启最小化

实际上 esbuild 是一个构建工具, 如果要纯粹的压缩可以使用 terser

npx terser main.js -o main.min.js -c -m

如果要压缩 CSS, 可以使用:

npx esbuild style.css --minify --outfile=style.min.css

一些在线工具:

  • https://babeljs.io/repl
  • https://try.terser.org/
  • https://jscompress.com/
  • https://cssminifier.com
  • https://www.toptal.com/developers/cssminifier

字体压缩

在电脑上大多都使用 TTF 和 OTF, 这两个对于 Web 网页来说都比较大, 可以都压缩成 WOFF 和 WOFF2

有一些字体的发布者提供 WOFF 的格式, 如果没有提供可以自己压缩, 使用工具: https://github.com/google/woff2

安装:

git clone https://github.com/google/woff2.git
cd woff2
mkdir out
cd out
cmake ..
make
make install

原本的字体大小:

❯ ls -lh ~/Downloads/fusion-pixel-font-12px-proportional-otf-v2025.07.30/fusion-pixel-12px-proportional-ja.otf
-rw-r--r--@ 1 erzbir  staff   4.1M Jul 29 22:27 /Users/erzbir/Downloads/fusion-pixel-font-12px-proportional-otf-v2025.07.30/fusion-pixel-12px-proportional-ja.otf

可以看到有 4.1M

使用 woff2 压缩:

woff2_compress ~/Downloads/fusion-pixel-font-12px-proportional-otf-v2025.07.30/fusion-pixel-12px-proportional-ja.otf

压缩之后就只有 622K 了:

❯ ls -lh ~/Downloads/fusion-pixel-font-12px-proportional-otf-v2025.07.30/fusion-pixel-12px-proportional-ja.woff2
-rw-r--r--  1 erzbir  staff   622K Aug  6 11:53 /Users/erzbir/Downloads/fusion-pixel-font-12px-proportional-otf-v2025.07.30/fusion-pixel-12px-proportional-ja.woff2

关于 WOFF 和 WOFF2

目前主流浏览器都支持 WOFF2, 老旧浏览器不支持

WOFF2 会比 WOFF 更小更好, 所以一般 WOFF 都用于兜底. 如果你的博客压根不考虑 IE 浏览器这种, 可以放弃 WOFF

直接使用系统字体

如果个性化需求不是特别大, 可以直接使用系统自带的字体, 比如下面这些:

font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"

有个性化需求的话, 可以在 CSS 中写:

@font-face {
  font-family: 'Fusion Pixel 12px P zh_hans';
  font-display: swap;
  src: url("../fonts/fusion-pixel-12px-proportional-zh_hans.woff2") format('woff2');
}

比较推荐将字体子集化, 比如: https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap

这是谷歌的字体服务, 可以将里面的字体保存到网站目录改一改 @font-face 的 src, 将 url 改成自己的网站

还可以用 local 来优先加载本地字体, 比如 JetBrains Mono 这种大多数开发者可能都会装的字体就可以优先加载本地的:

@font-face {
  font-family: 'JetBrains Mono';
  src: local('JetBrains Mono'),
  local('JetBrains Mono Regular'),
  url('../fonts/JetBrainsMono-Regular.woff2') format('woff2');
  font-display: swap;
  font-style: normal;
}

WebFont 压缩

使用类似字蛛的工具, 分析页面使用到的文字然后将字体文件裁剪到只有那些文字

这个用在静态博客实在是太适合了, 动态博客稍微麻烦一点

Nginx 压缩

如果使用 Nginx 可以在其配置文件中启用 gzip 压缩

修改主题

我用的主题是 Terminal, 这个主题原本是由 wan92hen 从 Zola Terminal (这个又是从 Hugo Terminal 移植过来的) 移植过来的. 三年前就很喜欢这个主题, 奈何功能太少又更新太慢, 中途就换了其他主题, 但现在我还是用回 Terminal 想要的功能没有我就自己加

实际上不修改主题也是可以用的, 但是就是喜欢折腾, 看着 F12 每次点开切换页面都加载的相同内容, 带宽白白被消耗, 心里很是不爽, 于是才有了改主题的想法

PJAX 实现

刚开始是做了一些自己喜欢的调整, 主要是参照 Hugo Terminal, 基于它的 CSS 修改来替换当前主题的样式. 然后就是优化带宽使用, 需要一些局部刷新的技术来实现, 最容易嵌入的应该就是 PJAX 了

这里使用的是: https://github.com/MoOx/pjax

当然除了这个库以外, 还有其他的一些局部刷新实现, 比如 babar.js. babar.js 更强大, 且提供了动画的支持

我这个博客的目标是复古, 主题本身就删除了动画, 所以这里就选用更轻量的 PJAX

ref: Halo 主题适配 PJAX 的问题

整个 PJAX 的代码就是这样, 参考了 LIlGG 的实现: pjax.js

import Pjax from "pjax";

const pjax = new Pjax({
    elements: "a[data-pjax]",
    selectors: ["head title", ".content", ".ex-pjax"],
    switches: {
        ".content": Pjax.switches.innerHTML,
    },
    cacheBust: false,
    analytics: false,
    debug: false
});

let _responseText = '';

pjax._handleResponse = pjax.handleResponse;

pjax.handleResponse = function (responseText, request, href, options) {
    _responseText = responseText;
    pjax._handleResponse(responseText, request, href, options);
};

function djb2(str) {
    let hash = 5381;
    for (let i = 0; i < str.length; i++) {
        hash = ((hash << 5) + hash) + str.charCodeAt(i);
    }
    return (hash >>> 0)
}

function renewElement(element) {
    const newElem = document.createElement(element.tagName);
    for (let i = 0; i < element.attributes.length; i++) {
        const attr = element.attributes[i];
        newElem.setAttribute(attr.name, attr.value);
    }
    return newElem;
}

function insertStyle(dom, parentElement, selectors) {
    const existingStyles = new Set();

    document.querySelectorAll(selectors).forEach(link => {
        existingStyles.add(link.href);
    });

    dom.querySelectorAll(selectors).forEach(link => {
        const href = link.href;
        if (href && !existingStyles.has(href)) {
            const newLink = renewElement(link);
            parentElement.appendChild(newLink);
        }
    });
}

function insertScript(dom, parentElement, selectors) {
    const existingScripts = new Set();

    parentElement.querySelectorAll(selectors).forEach(script => {
        existingScripts.add(script.src);
    });

    dom.querySelectorAll(selectors).forEach(script => {
        const src = script.src;
        if (src && !existingScripts.has(src)) {
            const newScript = renewElement(script);
            parentElement.appendChild(newScript);
        }
    });
}

function insertInlineScript(dom, parentElement, selectors) {
    dom.querySelectorAll(selectors).forEach(script => {
        if (script.innerHTML.trim()) {
            let hash = djb2(script.innerHTML);
            let old = parentElement.querySelector(`script[data-hash="${hash}"]`);
            if (old) {
                parentElement.removeChild(old);
            } else {
                let scripts = parentElement.querySelectorAll('script:not([src]):not([no-pjax])');
                scripts.forEach((s) => {
                    if (s) {
                        if (djb2(s.innerHTML) === hash) {
                            parentElement.removeChild(s);
                        }
                    }
                });
            }
            const newScript = renewElement(script);
            newScript.innerHTML = script.innerHTML;
            newScript.setAttribute("data-hash", hash);
            parentElement.appendChild(newScript);
        }
    });

}

// 这个函数将请求的页面 head 中 script 和 link 都插入进来合成新页面
function insertResourcesToHead(dom, parentElement) {
    insertStyle(dom, parentElement, 'head link[rel="stylesheet"]:not([no-pjax])');
    insertScript(dom, parentElement, 'head script[src]:not([no-pjax])');
    insertInlineScript(dom, parentElement, 'head script:not([src]):not([no-pjax])');
}

// 这个函数将请求的页面 footer 中 script 和 link 都插入进来合成新页面
function insertResourcesToFooter(dom, parentElement) {
    insertStyle(dom, parentElement, 'footer link[rel="stylesheet"][data-pjax]:not([no-pjax])');
    insertScript(dom, parentElement, 'footer script[src][data-pjax]:not([no-pjax])');
    insertInlineScript(dom, parentElement, 'footer script:not([src])[data-pjax]:not([no-pjax])');
}

// 处理请求的页面新增样式和脚本
function processNewScripts(responseText) {
    const parser = new DOMParser();
    const dom = parser.parseFromString(responseText, 'text/html');
    insertResourcesToHead(dom, document.head);
    let footer = document.querySelector('footer');
    if (!footer) {
        return;
    }
    insertResourcesToFooter(dom, footer);
}

const originalAddEventListener = EventTarget.prototype.addEventListener;

EventTarget.prototype.addEventListener = function (type, listener, options) {
    if (type === "DOMContentLoaded") {
        if (listener) {
            window.addEventListener(
                "pjax:success",
                () => {
                    retry(() => listener(), 10, 100);
                },
                {
                    once: true,
                }
            );
            return;
        }
    }
    originalAddEventListener.call(this, type, listener, options);
};

// 刷新 script, 以及重新刷新主题注册的函数
document.addEventListener("pjax:complete", function () {
    // 处理头和尾有新增的情况
    processNewScripts(_responseText);
    // 处理替换的内容中存在脚本的情况
    let pjaxDoms = document.querySelectorAll(".content script[data-pjax]:not([no-pjax])");
    pjaxDoms.forEach((element) => {
        let code = element.text || element.textContent || element.innerHTML || "";
        let parent = element.parentNode;
        if (parent === null) {
            return;
        }
        parent.removeChild(element);
        let script = renewElement(element);
        if (code !== "") {
            script.appendChild(document.createTextNode(code));
        }
        parent.appendChild(script);
    });
    // 刷新注册的函数
    window.terminal.refresh();
});

window.addEventListener("pjax:error", (event) => {
    const request = event.request
    if (request.status === 404 || request.status === 500) {
        window.location.href = request.responseURL;
    }
});

pjax.doRequest = function (location, options, callback) {
    options = options || {};
    let queryString;
    const requestOptions = options.requestOptions || {};
    const requestMethod = (requestOptions.requestMethod || "GET").toUpperCase();
    const requestParams = requestOptions.requestParams || null;
    const formData = requestOptions.formData || null;
    let requestPayload = null;
    const request = new XMLHttpRequest();
    const timeout = options.timeout || 0;

    request.onreadystatechange = function () {
        if (request.readyState === 4) {
            if (request.status === 200) {
                callback(request.responseText, request, location, options);
            } else if (request.status !== 0) {
                callback(null, request, location, options);
            }
        }
    };

    request.onerror = function (e) {
        console.log(e);
        callback(null, request, location, options);
    };

    request.ontimeout = function () {
        callback(null, request, location, options);
    };

    if (requestParams && requestParams.length) {
        queryString = requestParams
            .map(function (param) {
                return param.name + "=" + param.value;
            })
            .join("&");

        switch (requestMethod) {
            case "GET":
                location = location.split("?")[0];

                location += "?" + queryString;
                break;

            case "POST":
                requestPayload = queryString;
                break;
        }
    } else if (formData) {
        requestPayload = formData;
    }

    if (options.cacheBust) {
        location = updateQueryString(location, "t", Date.now());
    }

    request.open(requestMethod, location, true);
    request.timeout = timeout;
    request.setRequestHeader("X-Requested-With", "XMLHttpRequest");
    request.setRequestHeader("X-PJAX", "true");
    request.setRequestHeader("X-PJAX-Selectors", JSON.stringify(options.selectors));
    request.setRequestHeader("accept", "text/html, application/json, text/plain, */*");
    request.withCredentials = true;

    // 发送 POST 表单
    if (requestPayload && requestMethod === "POST" && !formData) {
        request.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
    }

    request.send(requestPayload);

    return request;
};

const updateQueryString = (uri, key, value) => {
    const re = new RegExp(`([?&])${key}=.*?(&|$)`, "i");
    const separator = uri.includes("?") ? "&" : "?";
    return uri.match(re)
        ? uri.replace(re, `$1${key}=${value}$2`)
        : uri + separator + key + "=" + value;
};


async function retry(promiseFn, maxRetries = 3, interval = 1000) {
    try {
        return await promiseFn();
    } catch (error) {
        if (maxRetries > 0) {
            await new Promise((resolve) => setTimeout(resolve, interval));
            return await retry(promiseFn, maxRetries - 1, interval);
        } else {
            throw error;
        }
    }
}

我对其的修改主要是增加新页面有新增 <script> 以及 <link> 的情况. 这个实现或许你也能稍作修改直接嵌入到自己的主题, 但需要遵循一些规则:

  • 给需要 PJAX 的 <a> 加上 data-pjax 属性
  • 给头部不需要 PJAX 的元素加上 no-pjax
  • 给 footer 需要 PJAX 的元素加上 data-pjax
  • 给替换区内需要重新执行的的 <script> 加上 data-pjax
  • 不要把脚本以及样式放到非刷新区且非 <head> 且非 <footer> 的地方
  • 注册监听事件类的脚本加上 no-pjax
  • 即使执行的脚本加上 data-pjax
  • 动态注入的新增脚本只注入到 <head>, 替换区, <footer>

上面有一行: window.terminal.refresh();, 这行代码是用来执行我主题中需要刷新的函数的, 可以根据自己的主题来决定

响应式

原主题并没有太多针对小屏幕设备的布局, 并且放大字体或者整体页面 zoom 变化都无法很好的响应, 这里主要做了几个调整:

  • 增加一些对小屏的优化
  • 全局的单位统一为根据字体大小动态计算
  • 将博客内容部分的宽度设置为 60% 而不是具体像素, 以此来适应缩放以及高分屏

自定义

这里主要是让主题更自由一些, 增加了一些可自定义的功能:

  • 增加配色选择, 可以选择深色浅色模式并且执行对应模式下的样式
  • 增加 CSS 覆盖的功能

CSS 覆盖的功能实际上就可以实现高度自定义了, 但是这种功能只有开发者能使用, 所以下一步计划是增加更多普通用户优化的选项来控制样式

对于原主题文章目录的功能我也进行了扩展, 使其根据不同宽度和高度来显示:

  • 在宽度足够时直接显示, 并且没有边框
  • 在宽度不够时默认隐藏, 需要手动点击按扭开启, 开启之后在右侧展开

插件

写注入插件

上面的主题修改, 样式和功能可能都是兴趣上来的才改的, 最终目的还是想要一个: 按需加载, 局部刷新, 带宽消耗小的博客

主题的东西按需加载很轻松就可以控制, 但 Halo 作为一个动态博客, 是可以扩展很多内容的, 可以加上各种插件, 比如评论, 搜索等插件

但这些插件的 JS 很多都是全局注入, 比如说评论插件. 主页通常来说是不需要评论功能的, 但是由于全局注入, 主页仍会加载评论插件的 JS, 这样就消耗了资源, 所以需要实现按需注入

目前 Halo 并没有这样的插件, 设置中的注入也无法针对某一个页面来进行注入, 比如说我要在主页插入一个模拟终端但其他页面就不需要

于是就写了这个插件: Injector, 可以把 JS 代码插入到你想要的位置

这样我就可以把我这一大坨 JS 只插入到主页了:

Screenshot2025-08-07at22.25.45

(由于这个模拟终端做了一个猜谜游戏, 所以混淆了)

注入插件的原理就是通过 Halo 提供的扩展点: TemplateHeadProcessorTemplateFooterProcessor 在渲染 HTML 的时候加入一些自定义的逻辑

目前并没有整个 HTML 预处理的扩展点, 所以根据 ID 和 CSS 选择器的注入都是简单的一个实现

修改评论插件

评论插件足足有 60 KB, 比我整个首页资源加起来还要大, 所以必须优化掉

评论插件的逻辑是在头部插入一个即时执行的 JS, 定义 CommentWidget, 然后在通过插入到 <comment-widget> 中的脚本来执行 CommentWidget.init() 来生初始化评论

所以我首先去掉了它全局插入的行为, 改为由上面的 Injector 来插入, 再将他插入到 <comment-widget> 中的脚本修改了一下

从:

<script>
  CommentWidget.init(
    "#${domId}",
    {
      group: "${group}",
      kind: "${kind}",
      name: "${name}",
      size: ${size},
      replySize: ${replySize},
      withReplies: ${withReplies},
      withReplySize: ${withReplySize},
      useAvatarProvider: ${useAvatarProvider},
      avatarProvider: "${avatarProvider}",
      avatarProviderMirror: "${avatarProviderMirror}",
      avatarPolicy: "${avatarPolicy}",
      captchaEnabled: ${captchaEnabled},
    }
  );
</script>

修改成了:

<script>
    document.addEventListener("DOMContentLoaded", function() {
    	CommentWidget.init(
          "#${domId}",
          {
            group: "${group}",
            kind: "${kind}",
            name: "${name}",
            size: ${size},
        	replySize: ${replySize},                  
			withReplies: ${withReplies},
     		withReplySize: ${withReplySize},
			useAvatarProvider: ${useAvatarProvider},
      		avatarProvider: "${avatarProvider}",
      		avatarProviderMirror: "${avatarProviderMirror}",
      		avatarPolicy: "${avatarPolicy}",
      		captchaEnabled: ${captchaEnabled},
	}
  );
});
</script>

改成了监听 DOM 事件来初始化的方式

这样是因为在 PJAX 下我将定义 CommentWiget 的脚本动态插入到尾部之后, 执行到上面的内联脚本时 CommentWiget 还没有被定义会报错无法从而无法正常初始化评论

还有一些插件比如 HighlightJS 本身就支持在指定页面生效, 这些只要 PJAX 做好兼容就可以愉快使用了

缓存

我的博客服务器前面实际上还有一层 Nginx 做转发, 于是在这个 Nginx 上可以做缓存

如果不喜欢写配置, 可以使用 NginxProxyManager, 但这个项目存在一些 BUG 尚未修复

对字体, 图片, JS, CSS 这些不需要频繁更新的资源做缓存:

location ~ ^(?!.*\b(console)\b).*\.(gif|jpg|jpeg|png|js|css|html|cab|bmp|woff|woff2|ttf|otf|)$ {
    proxy_cache public-cache; 
    proxy_no_cache 0;
    proxy_cache_key  $host$request_uri;
    proxy_cache_valid  200 304 1d;
    proxy_next_upstream http_502 http_504 error timeout invalid_header;
    add_header  C-Cache "$upstream_cache_status";
    proxy_buffering   on;
    expires 1d;
    
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Scheme $scheme;
    proxy_set_header X-Forwarded-Proto  $scheme;
    proxy_set_header X-Forwarded-For    $remote_addr;
    proxy_set_header X-Real-IP          $remote_addr;

    proxy_pass       http://x.x.x.x:x;
    
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $http_connection;
    proxy_http_version 1.1;
    
  }

我对这些资源缓存了 1 天, 根据需要来调整

Screenshot2025-08-08at00.02.47

C-Cache == HIT 就是命中缓存了, 这样可以加快请求响应的速度

完工

上面写得非常潦草, 但实际上由于对前端的一无所知, 先学现卖花了半个月的时间来做这些

最后得到的效果就是极少的首屏加载带宽消耗:

Screenshot2025-08-07at22.50.37

(这里 erzbir.com 这个 document 因为我插入的高度混淆 js 的缘故多了 30 KB)

在切换页面时才加载所需的 js, 并且不会加载旧内容:

Screenshot2025-08-07at22.52.55

(主页 切换到 关于页)

Screenshot2025-08-07at22.54.24

(关于页 切换到 归档页)