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

相关文章
相关标签/搜索