Vue 中 MathJax 的使用与渲染的监听 (下)

在这里插入图片描述

本文作者:傅云贵(网易有道技术团队)


在上一篇文章 (见 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