Hexo 主題開發之自定義模板

遊仙好夢發表於2023-12-16

關於 Hexo 如何開發主題包的教程在已經是大把的存在了,這裡就不在贅述了。這邊文章主要講的是作為一個主題的開發者,如何讓你的主題具有更好的擴充套件性,在使用者自定義修改主題後,能夠更加平易升級主題。

問題所在

Hexo 提供兩種方式安裝主題包:

  • 直接在 themes 目錄下直接存放主題包檔案,這種方式方便使用者自己魔改主題,魔改後升級主題會比較困難
  • 透過 npm 安裝主題包,這種方式更加方便使用者升級主題,但是不易擴充套件

當使用者想要自定義修改主題時,基本上只能透過第一種方式安裝,然後透過修改 原始碼 形式去修改主題。這樣帶來的問題就是,當主題修復一些 bug 或者主題迭代 N 個版本後,使用者想升級主題時就會變的比較麻煩。

有沒有能讓使用者方便升級,又能提供一定個性化的能力的?答案是有的,那就是透過 npm 方式分發主題包,然後透過一些魔法,讓其有一定的擴充套件能力,這篇文章就來講解如何實現它。

模板

在 Hexo 中,主題的模板決定的網站頁面程式的方式,當你不同頁面結構很相似時候,可以透過佈局(Layout)去複用相同的結構,而相似的部分可以抽離成通用區域性模板,透過使用 Partial 去載入,以達到模板複用的效果。

這就是 Hexo 在開發主題處理模板複用的方式,可把一個個區域性模板理解為一個個獨立的元件,哪裡需要是就在哪裡載入它。如果說把使用者想替換某一個區域性模板,然後讓使用者提供一個新的模板,然後我們去載入這個新的模板,那是不是達到在使用者不修改原始碼情況下對主題進行個性話的擴充套件呢。

Partial

要想知道 Hexo 是如果載入區域性模板的,我們翻看下 Hexo 原始碼裡 Partial 的實現(/plugins/helper/partial.js),可以看到當透過呼叫 ctx.theme 獲取到對應的 view,然後呼叫 render 渲染的。

const { dirname, join } = require("path");

module.exports = (ctx) =>
    function partial(name, locals, options = {}) {
        const viewDir = this.view_dir;
        const currentView = this.filename.substring(viewDir.length);
        const path = join(dirname(currentView), name); // 根據當前路徑找到,區域性模板路徑
        const view = ctx.theme.getView(path) || ctx.theme.getView(name); // 根據路徑去匹配 view
        const viewLocals = { layout: false };
        // Partial don't need layout
        viewLocals.layout = false;
        return view.renderSync(viewLocals);
    };

Hexo 對檔案處理分為兩種,一種是 source 目錄檔案處理,一種是對主題包裡檔案處理。在輔助函式註冊裡可以看 ctx 其實就是 hexo 執行時的例項,上面的 ctx.theme 就是主題檔案處理的 Box。透過 Hexo 提供 api 可以看到,它不僅提供了 getView,還提供了 setViewremoveView 方法。

然後翻看 setView 程式碼,可以看到當你重新設定一個新的 view 時,它會覆蓋掉已有的 view。也就是說我們可以直接覆蓋主題裡的 區域性模板


  setView(path, data) {
    const ext = extname(path);
    const name = path.substring(0, path.length - ext.length);
    this.views[name] = this.views[name] || {};
    const views = this.views[name];

    views[ext] = new this.View(path, data);
  }

修改示例

我們以覆蓋 hexo-theme-async 為示例,在生成前鉤子 generateBefore 裡,覆蓋掉主題裡預設的側欄模板。

hexo.on("generateBefore", () => {
    hexo.theme.setView("_partial/sidebar/index.ejs", "<div>111</div>");
});

執行起來會發現側欄模板已經替換成我們寫的 111 了。

示例

主題實現

透過上面方式確實可以達到覆蓋主題預設模板能力,但是讓使用者直接修改會很不友好,需要自己去看主題中區域性模板的路徑資訊,並且還需要自己編寫載入檔案內容,覆蓋主題預設模板邏輯。

我們可以將這部分操作內建進入主題內,然後只需要讓使用者編寫自己的模板,以及告訴我們需要替換對應模板即可。大致流程如下:

demo

我們還可以提供預設配置,簡化透過路徑覆蓋

demo

可以透過在配置中配置好主題中使用的區域性模板,類似這樣,將主題中使用的區域性模板以配置形式展示。

layout:
    path: layout
    # layout
    main: _partial/main
    header: _partial/header
    banner: _partial/banner
    sidebar: _partial/sidebar/index
    footer: _partial/footer

然後在載入區域性模板時,直接讀取配置的資訊,當使用者覆蓋掉了 layout.header 時候,主題就會自動使用新的模板了。

<%- partial(theme.layout.header) %>

模板載入實現

根據上面配置,約定 layout.path 配置指向目錄為用存在模板目錄,以便可以自定義存放路徑。

layout:
    path: layout

首先就是根據配置獲取模板存在的絕對路徑,可以根據 hexo 例項,獲取到根目錄,拼接出完整路徑位置。

const { resolve } = require("path");
const layoutDir = resolve(hexo.base_dir, hexo.theme.config.layout.path);

然後是對檔案目錄的監聽,這個可以直接使用 hexo-fs ,避免安裝額外的依賴包,提供了新增、刪除、修改、資料夾變動的監聽,可以針對不同事件做出不同操作。

const { watch } = require("hexo-fs");

watch(layoutDir, {
    persistent: true,
    awaitWriteFinish: {
        stabilityThreshold: 200,
    },
}).then((watcher) => {
    watcher.on("add", (path) => /** 設定模板 */);
    watcher.on("change", (path) => /** 設定模板 */);
    watcher.on("unlink", (path) => /** 移除模板 */);
    watcher.on("addDir", (path) => /** 新增資料夾,遞迴遍歷設定模板 */);
});

因為我們上面是透過配置去載入模板的,所有為了避免使用者自定義的模板名稱會與主題的模板名稱衝突,導致覆蓋了主題的模板,我們可以在使用時增加一個約定的字首,避免重名。我們對設定模板進行簡單封裝

const setView = (fullpath) => {
    const path = "async" + fullpath.replace(layoutDir, ""); // 約定固定字首為 async
    hexo.theme.setView(path, readFileSync(fullpath, { encoding: "utf8" }));
};

上面處理方式,使用者自定義模板,可以正常載入使用的,但是當自定義的模板又引入了其他模板時會存在一個問題,在有的模板引擎中會出現路徑不正常。透過檢視 view 例項資訊,可以看到其指向目錄是在 node_modules,而實際上是存在根目錄的。

view

翻看 view 原始碼可以看到 source 是獲取的 this._theme.base ,而 this._theme.base 往上找就 theme_dir,也就是主題存放的目錄,最後又透過 renderer.compile 設定模板渲染到,導致傳入 path 不正確。

view-code

知道了原因我對上面程式碼進行修正,設定後重新獲取到 view,然後手動根據路徑資訊。

const setView = (fullpath) => {
    const path = "async" + fullpath.replace(layoutDir, ""); // 約定固定字首為 async
    hexo.theme.setView(path, readFileSync(fullpath, { encoding: "utf8" }));

    const view = hexo.theme.getView(path);
    view.source = fullpath; // 修正原檔案路徑
    view._precompile(); // 重新呼叫渲染器的初始化
};

然後將上面操作,放置在在 Hexo 的 generateBefore 中:

const { resolve } = require("path");
const { watch, readdirSync, statSync } = require("hexo-fs");

hexo.on("generateBefore", () => {
    const layoutDir = resolve(hexo.base_dir, hexo.theme.config.layout.path);

    const setView = (fullpath) => {
        const path = "async" + fullpath.replace(layoutDir, ""); // 約定固定字首為 async
        hexo.theme.setView(path, readFileSync(fullpath, { encoding: "utf8" }));
        const view = hexo.theme.getView(path);
        view.source = fullpath; // 修正原檔案路徑
        view._precompile(); // 重新呼叫渲染器的初始化
    };

    watch(layoutDir, {
        persistent: true,
        awaitWriteFinish: {
            stabilityThreshold: 200,
        },
    }).then((watcher) => {
        watcher.on("add", (path) => setView(path));
        watcher.on("change", (path) => setView(path));
        watcher.on("unlink", (path) => {
            const path = "async" + path.replace(layoutDir, "");
            hexo.theme.removeView(path);
        });
        watcher.on("addDir", (path) => loadDir(path));
    });

    const loadDir = (base) => {
        let dirs = readdirSync(base);
        dirs.forEach((path) => {
            const fullpath = resolve(base, path);
            const stats = statSync(fullpath);
            if (stats.isDirectory()) {
                loadDir(fullpath);
            } else if (stats.isFile()) {
                setView(fullpath);
            }
        });
    };

    loadDir(layoutDir);
});

到此主要功能以及實現了,其他待最佳化項這裡就不描述了,可以看看完整實現原始碼。

使用示例

以為 hexo-theme-async 為例,在根目錄新建 layout 目錄,然後新增 sidebar.ejs 檔案,結構如下:

┌── blog
│   └── layout
│          └── sidebar.ejs
│   └── scaffolds
│   └── source
│   └── themes

sidebar.ejs 新增一點內容

<div>111</div>

然後在 _config.async.yml 中修改 layout 配置,替換掉預設 sidebar 模板。

layout:
    sidebar: async/sidebar

執行起來後,可以看到效果和 修改示例 中的效果一樣,但是簡化了使用者使用。

結語

透過上面方式,可以在使用 npm 安裝主題時,也支援自定義替換部分割槽域,來個性化的目的,當主題版本迭代升級後,也更方便使用者更新升級。

完整實現原始碼可以參考 hexo-theme-async 中原始碼。

相關文章