Hexo + Mathjax: 公式離線渲染

原文在我的博客:Hexo + Mathjax: 公式離線渲染

目前我的博客上的 Mathjax 公式是在客戶端渲染的。這種方式實現比較便利,主題 NexT 已經幫我們實現了,我們只需要打開配置開關就可以了。但是客戶端渲染的方式有如下兩個比較嚴重的問題:

  1. Mathjax 的前端腳本會產生爲數不少的資源請求
  2. 在公式比較多的頁面中(我的 Academic 版塊的公式就非常多),渲染效率會比較慢,這意味着公式需要好幾秒才能渲染,這在寫作的時候非常不利。因爲爲了確保公式格式正確,我在每編寫一個公式之後,都會刷新頁面查看渲染結果。如果每次刷新都要等待這麼長的時間會非常嚴重。另外,對於訪問我的博客的用戶來說,太長的渲染時間也是一個問題。

這篇文章旨在使用離線渲染的方式解決這個問題。

渲染流程介入

所謂離線渲染是指讓 Hexo 在生成靜態網站未見時就完成 Mathjax 的渲染。目前 NexT 是不支持這個功能的,需要我們自己寫腳本實現。我們可以通過 Hexo 的事件系統介入渲染流程。

首先我們在博客的根目錄下的 scripts 文件夾下面新建一個 Javascript 腳本。這個腳本的名字沒有限制,Hexo 會加載這個目錄下的所有 Javascript 腳本。例如可以命名爲 mathRender.js。我們在這個文件夾中監聽 Hexo 渲染過程中的事件。顯然,公式的渲染應該在所有其他的渲染完成以後進行。因此我們可以選擇註冊一個 Hexo 的過濾器([Filter]{.i})。

hexo.extend.filter.register('after_post_render', function (data) {
  // do something
})

我們的主體功能實現就放在這個函數裏面。

Mathjax in Node.js

mathjax-node-page

Mathjax 是一個非常龐雜的項目,因此我們需要依賴一些對 Mathjax 進行了良好封裝的包來處理 Mathjax 渲染的問題,不然光一個配置環節都會非常麻煩。我們這裏選擇 pkra/mathjax-node-page 這個項目。這個項目將 Mathjax 的渲染處理爲一個單一的函數 mjpage。這個函數接受四個參數:

mjpage(input, mjpageConfig, mjnodeConfig, callback)

其中第一個是渲染的輸入內容。第二項是頁面配置,你可以認爲這個配置是 Mathjax 的前端配置的一個包裝。第三項則是傳遞 mathjax-node 的參數。mathjax-node 是一個更加底層一些封裝,我們這裏不太需要關注這個封裝的細節。最後一個參數是完成渲染之後的回調。由於接口形式是異步的,因此我們在上一個章節中註冊的after_post_render的處理函數也應該是異步的,即代碼整體應該有如下的特點:

hexo.extend.filter.register('after_post_render', async function (data) {
  // do something
  return new Promose((resolve, reject) => {
    mjpage(input, mjpageConfig, mjnodeConfig, (output) => {
      resolve()
    })
  })
})

配置

這裏我們的配置信息的目的,是還原前端渲染場景中的配置,對於其他內容我們不用太在意。在mjpageConfig中,專門有一個字段Mathjax負責傳遞前端配置。這大大簡化了我們的配置操作。這裏我的配置內容如下:

const mjpageConfig = {
    format: ["TeX"],
    ouptut: "html",
    singleDollars: true,
    fragment: false,
    cssInline: true,
    fontURL: "https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.2/fonts/HTML-CSS",
    displayErrors: false,
    MathJax: {
      tex2jax: {
        inlineMath: [ ['$', '$'], ['\\(', '\\)'] ],
        processEscapes: true,
        skipTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code']
      },
      TeX: {
        extensions: data.mathjaxext,
        equationNumbers: {
          autoNumber: 'all'
        },
      }
      ,
      "HTML-CSS": {
        preferredFont: "TeX", 
        availableFonts: ["TeX"], 
      }
    }
  }

這裏要注意這麼幾個配置:

  1. singleDollars: 決定了是否支持行內公式,確保這一項爲true
  2. fragment: 這決定了渲染的輸出是一個完整的html文件內容,還是隻是渲染內容(即document.body.innerHTML)。【~不過後面我們沒有采用API中的回調函數接口來獲取渲染結果,原因後面會說明。】
  3. cssInline: 確保帶上css樣式信息。

至於mjnodeConfig,使用默認的配置就可以了。

渲染的輸入與輸出

現在我們來找到渲染的輸入輸出內容。輸入的問題很好解決,使用 data.content 即可,data 是過濾器函數提供的參數。data.content 是對源文件進行渲染的直接結果,即將要插入div.post-body中的內容。我們可以將這個字符串內容直接交給mjpage來處理。

不過怎麼處理輸出是一個問題。當input的輸出內容是字符串時,輸出,即callback的輸入參數也會是字符串。若mjpageConfig.fragment=false,輸出的會是一個具有html, body的完整 html 內容,這不符合我們的要求。渲染過程的輸出,應該永遠只是針對源文件的直接渲染結果。例如將**text**變成<strong>text</strong>,而不能變成<html><body><strong></strong></body></html>。如
果令mjpageConfig.fragment=true,會輸出正確的html的內容,但是css樣式信息會丟失(css樣式位於document.body.head)。

爲了兼顧這兩個問題,我們不使用mjpagecallback參數,而是使用MjPageJob提供的beforeSerialization事件。這個事件發生在渲染完成之後,調用callback回調之前。而事件的響應函數的兩個參數分別爲完成的DOM(JSDOM對象)和css樣式(字符串)。故渲染如下:

return new Promise((resolve, reject) => {
    mjpage(data.content, mjpageConfig, mjnodeConfig, function(output) {
    }).on("beforeSerialization", function(document, css) {
      data.content = document.body.innerHTML
      data.head = `<style type="text/css">${css}</style>`
      resolve()
    })
  })

模板渲染

最後的問題是模板渲染。所謂模板渲染是指將博客源文件的內容嵌入到swig模板中。這裏我們除了html的內容以外,還需要將css樣式也渲染進模板。爲了解決這個問題,我們將css信息單獨放到data.head中,然後在NexT的模板文件layout/_layout.swig中,做如下修改:

<html class="{{ html_class | lower }}" lang="{{ config.language }}">
<head>
  ...

  {{ page.head }}

</head>

...
</html>

完整腳本

const mjpage = require("mathjax-node-page").mjpage

hexo.extend.filter.register('after_post_render', async function (data) {
  if (!data.offlineMath) {
    return
  }

  const mjpageConfig = {
    format: ["TeX"],
    ouptut: "html",
    singleDollars: true,
    fragment: false,
    cssInline: true,
    fontURL: "https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.2/fonts/HTML-CSS",
    displayErrors: false,
    MathJax: {
      tex2jax: {
        inlineMath: [ ['$', '$'], ['\\(', '\\)'] ],
        processEscapes: true,
        skipTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code']
      },
      TeX: {
        extensions: data.mathjaxext,
        equationNumbers: {
          autoNumber: 'all'
        },
      }
      ,
      "HTML-CSS": {
        preferredFont: "TeX", 
        availableFonts: ["TeX"], 
      }
    }
  }
  return new Promose((resolve, reject) => {
    mjpage(input, mjpageConfig, {}, (output) => {})
    .on("beforeSerialization", function(document, css) {
      data.content = document.body.innerHTML
      data.head = `<style type="text/css">${css}</style>`
      resolve()
    })
  })
})

style標籤的處理

使用過程中發現一個問題。如果我們在博客的的正文中使用了style標籤定義樣式,那麼mjpage在處理後,會將這部分內容移動到head部分,故回調函數中document.body.innerHTML中就不會再包含這些內聯樣式,導致樣式丟失。爲了繼續支持內聯樣式,我們需要將docuemnt.head中的內容插入到輸出中。因此,上一個章節的代碼中最後的return需要做如下修改:

return new Promose((resolve, reject) => {
    mjpage(input, mjpageConfig, {}, (output) => {})
    .on("beforeSerialization", function(document, css) {
      data.content = document.body.innerHTML
      data.head = document.head.innerHTML
      resolve()
    })
  })
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章