Vue 中 MathJax 的使用與渲染的監聽 (下)

網易技術熱愛者發表於2020-10-30

在這裡插入圖片描述

本文作者:傅雲貴(網易有道技術團隊)


在上一篇文章 (見 Vue 中 MathJax 的使用與渲染的監聽 (上) ) 中講述了在 Vue 元件中如何使用 MathJax,卻應對不了產品的新需求:

待 MathJax 渲染(Typeset)數學公式後,使用者使用瀏覽器的列印功能列印網頁。

在此需求中,需要判斷所有元件例項的 MathJax Typeset 是否完成。

如何監聽所有元件例項中的 MathJax Typeset 是否完成呢?

元件 typeset 渲染的監聽初步實現

根據「分而治之」的思想,很容易想到:若要判斷多個元件例項是否 MathJax typeset 完成,只要判斷每一個元件例項是否 MathJax typeset 完成。

在元件中,我們可以使用以下方法監聽 MathJax Typeset 是否完成。

@Component({})
class SomeComponent extends Vue {
    private mathJax: typeof MathJax | null = null

    private needTypeset: boolean = false

    isTypesetFinished: boolean = false

    private callMathJaxTypeset(): void {
        const { mathJax } = this
        if (mathJax) {
            const { typesetElement } = this.$refs
            mathJax.Hub.Queue(['Typeset', MathJax.Hub, typesetElement])
            mathJax.Hub.Queue(() => {
                this.isTypesetFinished = true
            })
        } else {
            this.needTypeset = true
        }
    }

    created(): void {
        const mathJax = await loadMathJax()
        this.mathJax = mathJax

        if (this.needTypeset) {
            this.callMathJaxTypeset()
        }
    }

    mounted(): void {
        this.isTypesetFinished = false
        this.callMathJaxTypeset()
    }

    updated(): void {
        this.isTypesetFinished = false
        this.callMathJaxTypeset()
    }
}

MathJax.Hub.Queue 深入瞭解

在元件實現 MathJax Typeset 是否完成過程中,使用了MathJax.Hub.Queue, 那麼這個 Queue 究竟是什麼呢?

翻閱 MathJax 的原始碼,可以發現 MathJax.Hub.Queue 源於 MathJax.Callback.Queue

// ...

var QUEUE = BASE.Object.Subclass({
    //
    //  Create the queue and push any commands that are specified
    //
    Init: function() {
        // ...
    },
    //
    //  Add commands to the queue and run them. Adding a callback object
    //  (rather than a callback specification) queues a wait for that callback.
    //  Return the final callback for synchronization purposes.
    //
    Push: function() {
        //...
    },
    //
    //  Process the command queue if we aren't waiting on another command
    //
    Process: function(queue) {
        // ...
    },
    //
    //  Suspend/Resume command processing on this queue
    //
    Suspend: function() {
        // ...
    },
    Resume: function() {
        // ...
    },
    //
    //  Used by WAITFOR to restart the queue when an action completes
    //
    call: function() {
        // ...
    },
    wait: function(callback) {
        // ...
    },
})
// ...

BASE.Callback.Queue = QUEUE

// ...

var HUB = BASE.Hub

// ...

HUB.queue = BASE.Callback.Queue()

MathJax.Hub = {
    // ...
    Queue: function() {
        return this.queue.Push.apply(this.queue, arguments)
    },
    //...
}

MathJax.Callback.Queue

A “callback” is a function that MathJax calls when it completes an action that may occur asynchronously (like loading a file). Many of MathJax’s functions operate asynchronously, and MathJax uses callbacks to allow you to synchronize your code with the action of those functions. The MathJax.Callback structure manages these callbacks.

MathJax.Callback.Queue 是一個佇列,負責管理一系列 callback (即任務)的執行。MathJax.Hub.Queue 可以理解為 MathJax.Callback.Queue 的一個例項。

初步實現 typeset 渲染監聽可能存在的問題

由於 MathJax.Hub.Queuecallback 是儲存在佇列的,並不會立即執行;且在實際使用過程發現, typeset 渲染數學公式過程並不太快。那麼,元件 typeset 渲染的監聽初步實現 章節中的實現,在多元件例項、多次updated的情況下,MathJax.Hub.Queue 中等待任務可能會出現以下情況:

序號MathJax.Hub.Queue 中等待的任務
N+1someComponent1 typeset callback
N+2someComponent1 isTypesetFinished = true callback
N+3someComponent2 typeset callback
N+4someComponent2 isTypesetFinished = true callback
N+5someComponent1 typeset callback
N+6someComponent1 isTypesetFinished = true callback
N+…
  1. 從功能上說, someComponent1 能正確的顯示數學公式;typeset callback 會執行多遍,但有的執行是多餘的——序號為N+1,N+2 的任務執行後,還有同樣的 N+5, N+6 任務。理想的狀況是: 序號為N+1,N+2 的任務應該被取消,直接執行 N+5,N+6任務即可。
  2. 由於 typeset 渲染數學公式過程並不快,且 MathJax.Hub.Queue 中還有其他元件例項的 typeset callback 任務, 那麼 someComponent1 在 destroyed 生命週期後,其typeset callback 可能仍然存放在MathJax.Hub.Queue 佇列。因此,someComponent1 例項在 beforeDestroy 生命週期時,其新增到MathJax.Hub.Queue佇列中的任務應當被取消。但 MathJax.Hub 未提供取消任務的 api——這可能導致記憶體洩漏。

解決方案

如何解決以上問題呢?可能的方式有:

方案 1

僅在 web app 頂層元件中呼叫 MathJax 進行 typeset, 即只有一個元件例項呼叫 MathJax.Hub.Queue 去渲染數學公式

  • 頂層元件發生 mounted / updated時,呼叫 MathJax 進行 typeset
  • 頂層元件的子元件發生 mounted /updated, 需要手動通知頂層元件, 並由頂層元件呼叫 MathJax 進行 typeset

方案 2

自實現佇列,接管 MathJax.Hub.Queue中的佇列功能, MathJax.Hub.Queue 僅僅被用於 typeset

  • 集中式管理任務,可控
  • 自實現的佇列可提供取消任務等功能,亦可解決可能存在的記憶體洩漏問題

方案選擇

很明顯:

  • 方案 1 管理粒度比較粗放,每次某個子元件發生mouted 或者updated 時,需要呼叫 MathJax 渲染頂層元件的 HTML。
  • 方案 2 能夠更精細化的控制、靈活可控。

方案 2 的實現

決定採用方案 2 後,主要開發了以下幾個模組:

  1. TypesetQueue: 接管 MathJax.Hub.Queue 的佇列功能, 並提供全域性的唯一例項 globalTypesetQueue
  2. MathJaxMixin: 封裝 globalTypesetQueue 新增/取消任務的邏輯,以便元件呼叫 MathJax 渲染——元件 mixin MathJaxMixin 即可
  3. MathJaxProgressMixin: 封裝 globalTypesetQueue 進度的邏輯,以便元件顯示進度給使用者檢視——元件 mixin MathJaxProgressMixin 即可

1. TypesetQueue 實現

實現TypesetQueue 類,接管MathJax.Hub.Queue 的佇列功能, MathJax.Hub.Queue 僅僅被用於 typeset。

import { EventEmitter } from 'eventemitter3'

interface ExecutedItem {
    uid: string
    componentId: string
    refNames: string[]
    isTopPriority: boolean
    startTime: number
    endTime: number
}

interface WaitingItem {
    uid: string
    componentId: string
    refNames: string[]
    refs: Element[]
    afterRender?: (info: ExecutedItem) => void
    isTopPriority: boolean
}

const TypesetQueueEvent = {
    addTask: Symbol('add-task'),
    cancelTask: Symbol('cancel-task'),
    finishTask: Symbol('finish-task'),
    clearTasks: Symbol('clear-tasks'),
}

class TypesetQueue extends EventEmitter {
    private topPriorityQueue: WaitingItem[]

    private normalQueue: WaitingItem[]

    private executed: ExecutedItem[]

    private isRunning: boolean

    private mathJax: typeof MathJax | null

    constructor() {
        super()

        this.topPriorityQueue = []
        this.normalQueue = []
        this.executed = []
        this.isRunning = false
        this.mathJax = null
    }

    setupMathJax(mathJax: typeof MathJax): void {
        if (this.mathJax) {
            return
        }
        this.mathJax = mathJax
        this.runTask()
    }

    private buildUniqueId(componentId: string, refNames: string[]): string {
        const names = [...refNames]
        names.sort()
        const joinded = names.join('-_')
        return `${componentId}-${joinded}`
    }

    private removeTask(uid: string): boolean {
        const { normalQueue, topPriorityQueue } = this
        let index = normalQueue.findIndex((item) => {
            return item.uid === uid
        })
        if (index > -1) {
            normalQueue.splice(index, 1)
            return true
        }
        index = topPriorityQueue.findIndex((item) => {
            return item.uid === uid
        })
        if (index > -1) {
            topPriorityQueue.splice(index, 1)
            return true
        }
        return false
    }

    addTask(
        componentId: string,
        refNames: string[],
        refs: Element[],
        afterRender?: (info: ExecutedItem) => void,
        isTopPriority = false,
    ): string {
        const uid = this.buildUniqueId(componentId, refNames)
        this.removeTask(uid)
        const { normalQueue, topPriorityQueue } = this

        const queueItem: WaitingItem = {
            uid,
            componentId,
            refNames,
            refs,
            afterRender,
            isTopPriority,
        }

        if (isTopPriority) {
            topPriorityQueue.unshift(queueItem)
        } else {
            normalQueue.push(queueItem)
        }
        this.emit(TypesetQueueEvent.addTask, queueItem)
        this.runTask()
        return uid
    }

    cancelTask(uid: string): void {
        const isRemoved = this.removeTask(uid)
        if (isRemoved) {
            this.emit(TypesetQueueEvent.cancelTask)
        }
    }

    private runTask(): void {
        const { isRunning, mathJax } = this
        if (isRunning || !mathJax) {
            return
        }
        this.isRunning = true
        const { topPriorityQueue, normalQueue } = this
        let item: WaitingItem | undefined
        if (topPriorityQueue.length) {
            item = topPriorityQueue.shift()
        } else if (normalQueue.length) {
            item = normalQueue.shift()
        }
        if (!item) {
            this.isRunning = false
            const { executed } = this
            const { length } = executed
            if (length) {
                const total = executed.reduce((count, executedItem) => {
                    return (count += executedItem.endTime - executedItem.startTime)
                }, 0)
                const average = total / length

                const firstRun = executed[0]
                const lastRun = executed[length - 1]
                const duration = lastRun.endTime - firstRun.startTime
                // tslint:disable-next-line
                console.log(
                    `finished ... time( duration / total / average / times):  ${duration} /${total} / ${average} / ${length}`,
                )
            }
            return
        }
        const { refs, afterRender, uid, refNames, componentId, isTopPriority } = item

        const startTime = Date.now()
        const queueArgs: any = []
        refs.forEach((ref) => {
            queueArgs.push(['Typeset', MathJax.Hub, ref])
        })
        queueArgs.push(() => {
            this.isRunning = false
            const info: ExecutedItem = {
                uid,
                refNames,
                componentId,
                isTopPriority,
                startTime,
                endTime: Date.now(),
            }
            this.executed.push(info)
            if (afterRender) {
                afterRender(info)
            }
            this.emit(TypesetQueueEvent.finishTask)
            this.runTask()
        })
        MathJax.Hub.Queue.apply(MathJax.Hub, queueArgs)
    }

    clearTasks(): void {
        this.normalQueue = []
        this.topPriorityQueue = []
        this.executed = []
        this.emit(TypesetQueueEvent.clearTasks)
    }

    reset(): void {
        this.normalQueue = []
        this.topPriorityQueue = []
        this.executed = []
        this.mathJax = null
        this.removeAllListeners()
    }

    getProgress(): { total: number; finished: number } {
        const { normalQueue, topPriorityQueue, executed } = this
        const total = normalQueue.length + topPriorityQueue.length + executed.length
        const finished = executed.length
        return {
            total,
            finished,
        }
    }
}

export { WaitingItem, ExecutedItem, TypesetQueue, TypesetQueueEvent }

實現說明

  • addTask(): 新增任務並自動執行,返回任務的uid
  • cancelTask(uid): 根據 uid 取消任務
  • getProgress(): 獲取任務執行進度
  • 當任務佇列變化時,觸發 TypesetQueueEvent,以方便其他元件監控進度

2. MathJaxMixin 實現

import config from '@/common/config'
import { loadMathJax } from '@/common/mathjax/mathJaxLoader2'
import { TypesetQueue } from '@/common/mathjax/TypesetQueue'
import shortid from '@/common/utils/shortid'
import Vue from 'vue'
import Component /*, { mixins } */ from 'vue-class-component'

const globalTypesetQueue = new TypesetQueue()

@Component({})
class MathJaxMixin extends Vue /*mixins(ComponentNameMixin) */ {
    /**************************************************************************
     * data
     **************************************************************************/

    private componentId!: string
    private typesetUidList!: string[]
    private mathJaxRenderTime: number = 0

    /**************************************************************************
     * computed
     **************************************************************************/

    get isMathJaxRendered(): boolean {
        const { mathJaxRenderTime } = this
        return mathJaxRenderTime > 0
    }

    /**************************************************************************
     * methods
     **************************************************************************/

    private async loadMathJax(): Promise<void> {
        const result = await loadMathJax()
        const { mathJax } = result
        globalTypesetQueue.setupMathJax(mathJax)
        this.onLoadMathJax()
    }

    private pushRefIntoTypesetQueue(refNames: string[], afterRender?: () => void, isTopPriority = false): void {
        if (!refNames || !refNames.length) {
            throw new Error('refNames can not be nil')
        }
        const { $refs, componentId } = this
        if (!componentId) {
            throw new Error(`Component mixin MathJaxMixin has no componentId`)
        }
        const refElements: Array<{ name: string; el: Element }> = []

        refNames.forEach((refName) => {
            const ref = $refs[refName]
            if (ref) {
                refElements.push({
                    name: refName,
                    el: ref as Element,
                })
            }
        })

        if (refElements && refElements.length) {
            const names = refElements.map((item) => item.name)
            const elements = refElements.map((item) => item.el)
            const uid = globalTypesetQueue.addTask(componentId, names, elements, afterRender, isTopPriority)
            const { typesetUidList } = this
            if (!typesetUidList.includes(uid)) {
                typesetUidList.push(uid)
            }
        } else {
            if (config.isDev) {
                const msg = `[refNames] is not valid`
                // tslint:disable-next-line
                console.warn(`Failed push ref into MathJax Queue: ${msg}`, refNames)
            }
        }
    }

    onLoadMathJax(): void {
        //  onLoadMathJax() method can be overrided
    }

    renderMathJaxAtNextTick(refNames: string[] | string, afterRender?: () => void, isTopPriority = false): void {
        this.cancelMathJaxRender()
        this.$nextTick(() => {
            const names: string[] = typeof refNames === 'string' ? [refNames] : refNames
            this.pushRefIntoTypesetQueue(
                names,
                () => {
                    this.mathJaxRenderTime = Date.now()
                    if (afterRender) {
                        afterRender()
                    }
                },
                isTopPriority,
            )
        })
    }

    cancelMathJaxRender(): void {
        const { typesetUidList } = this
        typesetUidList.forEach((uid) => {
            globalTypesetQueue.cancelTask(uid)
        })
        this.typesetUidList = []
    }

    /**************************************************************************
     * life cycle
     **************************************************************************/

    created(): void {
        this.loadMathJax()
        this.typesetUidList = []
        this.componentId = shortid.generate()
    }
    beforeDestroy(): void {
        this.cancelMathJaxRender()
    }
}

export { MathJaxMixin, globalTypesetQueue }

實現說明

  1. typesetUidList 會收集新增到 globalTypesetQueue 中的任務;每次新增任務到 globalTypesetQueue之前,typesetUidList記錄的任務會被取消
  • 使用時,注意將使用 MathJax 渲染的 DOMreference name 一次性地提交給 renderMathJaxAtNextTick() 方法
  1. mixin MathJaxMixin 的元件需要在mounted、updated時呼叫 renderMathJaxWithRefAtNextTick() 方法
  2. mixin MathJaxMixin 的元件在 beforeDestroy 時,需要呼叫 cancelMathJaxRender() 方法
  • MathJaxMixin 中已在 beforeDestroy 鉤子中呼叫cancelMathJaxRender() 方法, mixin 時注意不要被沖掉

3. MathJaxProgressMixin 實現

import { globalTypesetQueue } from '@/common/components/MathJaxMixin'
import { TypesetQueueEvent } from '@/common/mathjax/TypesetQueue'

import Vue from 'vue'
import Component /*, { mixins } */ from 'vue-class-component'

@Component({})
class MathJaxProgressMixin extends Vue /*mixins(ComponentNameMixin) */ {
    /**************************************************************************
     * data
     **************************************************************************/

    mathJaxTotal: number = 0
    mathJaxFinished: number = 0

    /**************************************************************************
     * computed
     **************************************************************************/

    get isMathJaxRendered(): boolean {
        const { mathJaxTotal, mathJaxFinished } = this
        const value = mathJaxTotal <= mathJaxFinished
        return value
    }

    /**************************************************************************
     * methods
     **************************************************************************/

    private handleMathJaxProgress(): void {
        window.setTimeout(() => {
            const result = globalTypesetQueue.getProgress()
            const { total, finished } = result
            this.mathJaxTotal = total
            this.mathJaxFinished = finished
        }, 0)
    }

    private addMathJaxListener(): void {
        this.removeMathJaxListener()
        globalTypesetQueue.on(TypesetQueueEvent.addTask, this.handleMathJaxProgress)
        globalTypesetQueue.on(TypesetQueueEvent.cancelTask, this.handleMathJaxProgress)
        globalTypesetQueue.on(TypesetQueueEvent.finishTask, this.handleMathJaxProgress)
        globalTypesetQueue.on(TypesetQueueEvent.clearTasks, this.handleMathJaxProgress)
    }

    private removeMathJaxListener(): void {
        globalTypesetQueue.off(TypesetQueueEvent.addTask, this.handleMathJaxProgress)
        globalTypesetQueue.off(TypesetQueueEvent.cancelTask, this.handleMathJaxProgress)
        globalTypesetQueue.off(TypesetQueueEvent.finishTask, this.handleMathJaxProgress)
        globalTypesetQueue.off(TypesetQueueEvent.clearTasks, this.handleMathJaxProgress)
    }

    progressAsText(): string {
        const { mathJaxTotal, mathJaxFinished } = this
        return `${mathJaxFinished} / ${mathJaxTotal}`
    }

    /**************************************************************************
     * life cycle
     **************************************************************************/

    created(): void {
        this.addMathJaxListener()
    }
    beforeDestroy(): void {
        this.removeMathJaxListener()
    }
}

export default MathJaxProgressMixin

總結

方案 2 實現了

  • 在 Vue 元件中呼叫 MathJax 進行數學公式渲染
  • 監聽 App 中所有元件的 MathJax 的渲染進度

基本上可以滿足了產品需求

待 MathJax 渲染(Typeset)數學公式後,使用者使用瀏覽器的列印功能列印網頁。

存在的問題

由於整個 App 只有一個TypesetQueue 例項(globalTypesetQueue),該方案只能滿足當前 app 介面中只有一個 MathJax 渲染管理需求的情況。

參考

The MathJax Startup Sequence — MathJax 1.1 documentation

2020-01-17 update

以上的思路及實現,是在開發過程的邏輯。

今天整理成文,發現以上的思路及實現存在一個邏輯上的漏洞:

  • 調研實現思路時,直接跳到了「分而治之」的思想
  • 為什麼不考慮使用 MathJax.HubMathJax.Hub.Queue 來管理呢?這樣的話就不必自開發 TypesetQueue

帶著這樣的疑問, 翻看了 MathJax 的文件及原始碼,發現:

  • MathJax.HubMathJax.Hub.Queue 未有 TypesetQueue提供的取消任務的功能, 除非直接操作 MathJax.Hub.queue
  • 故仍然需要自開發 TypesetQueue

p.s.個人水平有限,以上內容僅供參考,歡迎交流。

網易技術熱愛者隊伍持續招募隊友中!網易有道,與你同道,因為熱愛所以選擇, 期待志同道合的你加入我們,簡歷可傳送至郵箱:bjfanyudan@corp.netease.com

相關文章