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.Queue 中 callback 是儲存在佇列的,並不會立即執行;且在實際使用過程發現, 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+… | … |
- 從功能上說, someComponent1 能正確的顯示數學公式;typeset callback 會執行多遍,但有的執行是多餘的——序號為N+1,N+2 的任務執行後,還有同樣的 N+5, N+6 任務。理想的狀況是: 序號為N+1,N+2 的任務應該被取消,直接執行 N+5,N+6任務即可。
- 由於 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 後,主要開發了以下幾個模組:
- TypesetQueue: 接管 MathJax.Hub.Queue 的佇列功能, 並提供全域性的唯一例項 globalTypesetQueue
- MathJaxMixin: 封裝 globalTypesetQueue 新增/取消任務的邏輯,以便元件呼叫 MathJax 渲染——元件 mixin MathJaxMixin 即可
- 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 }
實現說明
- typesetUidList 會收集新增到 globalTypesetQueue 中的任務;每次新增任務到 globalTypesetQueue之前,typesetUidList記錄的任務會被取消
- 使用時,注意將使用 MathJax 渲染的 DOM 的 reference name 一次性地提交給 renderMathJaxAtNextTick() 方法
- mixin MathJaxMixin 的元件需要在mounted、updated時呼叫 renderMathJaxWithRefAtNextTick() 方法
- 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.Hub 或 MathJax.Hub.Queue 來管理呢?這樣的話就不必自開發 TypesetQueue 了
帶著這樣的疑問, 翻看了 MathJax 的文件及原始碼,發現:
- MathJax.Hub 或 MathJax.Hub.Queue 未有 TypesetQueue提供的取消任務的功能, 除非直接操作 MathJax.Hub.queue
- 故仍然需要自開發 TypesetQueue
p.s.個人水平有限,以上內容僅供參考,歡迎交流。
網易技術熱愛者隊伍持續招募隊友中!網易有道,與你同道,因為熱愛所以選擇, 期待志同道合的你加入我們,簡歷可傳送至郵箱:bjfanyudan@corp.netease.com
相關文章
- 使用 vue 例項更好的監聽事件Vue事件
- MathJax的基本使用
- vue中如何監聽vuex中的資料變化Vue
- vue2-使用watch監聽路由的變化Vue路由
- MathJax動態渲染數學公式公式
- Vue3.0的遞迴監聽和非遞迴監聽Vue遞迴
- javaWeb中的監聽器JavaWeb
- vue的監聽鍵盤事件的快捷方法Vue事件
- React和Vue中,是如何監聽變數變化的ReactVue變數
- vue2的監聽watch小爆料Vue
- vue 中 watch如何監聽陣列或物件中的某個值?Vue陣列物件
- Spring中如何優雅的使用監聽器模式Spring模式
- vue之監聽事件Vue事件
- vue中渲染值的方法Vue
- 用這招監聽 Vue 的插槽變化Vue
- vue是如何監聽陣列變化的Vue陣列
- 記錄下學習筆記(Laravel 中的事件監聽)筆記Laravel事件
- vue 監聽路由變化Vue路由
- (譯文)swift中的監聽者Swift
- vue計算屬性 監聽 方法的區別Vue
- Vue:watch 監聽多個屬性值的方法Vue
- 淺析Vue原始碼(八)——依賴收集與監聽Vue原始碼
- 對於Unity回撥、監聽與廣播的使用總結Unity
- vue 如何在迴圈中 "監聽" 的繫結v-model資料Vue
- OkHttp優雅的實現下載監聽HTTP
- web app 中物理返回鍵的監聽WebAPP
- Vue響應式原理-如何監聽Array的變化?Vue
- JS的平凡之路–模仿Vue寫個陣列監聽JSVue陣列
- Vue_watch深度監聽的正確開啟方式Vue
- Nacos - 事件的註冊、取消與監聽(EventDispatcher)事件
- vue中axios的使用與封裝VueiOS封裝
- Vue3 為何使用 Proxy 實現資料監聽Vue
- vue監聽input是否為空(監聽值為物件某個屬性)Vue物件
- VUE-UNI事件轉發監聽Vue事件
- vue 監聽頁面滾動事件Vue事件
- vue 的列表渲染Vue
- 分享一下 Vue 中 nextTick 的使用Vue
- vue中style下scope的使用和坑Vue