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

在這裏插入圖片描述

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


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

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

在此需求中,須要判斷全部組件實例的 MathJax Typeset 是否完成。api

如何監聽全部組件實例中的 MathJax Typeset 是否完成呢?
瀏覽器

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

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

在組件中,咱們可使用如下方法監聽 MathJax Typeset 是否完成。app

@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 到底是什麼呢?async

翻閱 MathJax 的源碼,能夠發現 MathJax.Hub.Queue 源於 MathJax.Callback.Queueide

// ...

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.oop

MathJax.Callback.Queue 是一個隊列,負責管理一系列 callback (即任務)的執行。MathJax.Hub.Queue 能夠理解爲 MathJax.Callback.Queue 的一個實例。

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

因爲 MathJax.Hub.Queuecallback 是存儲在隊列的,並不會當即執行;且在實際使用過程發現, typeset 渲染數學公式過程並不太快。那麼,組件 typeset 渲染的監聽初步實現 章節中的實現,在多組件實例、屢次updated的狀況下,MathJax.Hub.Queue 中等待任務可能會出現如下狀況:

序號 MathJax.Hub.Queue 中等待的任務
... ...
N+1 someComponent1 typeset callback
N+2 someComponent1 isTypesetFinished = true callback
N+3 someComponent2 typeset callback
N+4 someComponent2 isTypesetFinished = true callback
N+5 someComponent1 typeset callback
N+6 someComponent1 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

相關文章
相關標籤/搜索