你用過一個越寫越慢的編輯器
麼?html
我曾在項目中實現了一個MD編輯器, 用來解析簡單的MD文本, 不過它的性能令我捉急. 初期基本沒有作任何性能優化相關的內容, 致使每當我正在寫的文章變長以後, 編輯器會變得很是~很是~卡, 因此說是越寫越慢的編輯器( ╯□╰ ) 這期文章主要針對這個編輯器聊聊我實踐以及思考總結的一些性能優化方法, 確定還有文中沒有總結到的一些方法, 歡迎各位看官不捨賜教, 留言評論.前端
文中MD編輯器能夠在左側窗口輸入MD格式文本, 而後經過調用解析函數將文本解析轉換爲HTML代碼, 放到右側v-html窗口中直接渲染.node
通常來講MD解析不須要通過詞法語法分析, 並且標點符號幾乎沒有二義性, 解析起來比較簡單. 對於一段簡單的MD文本, 咱們大可從一個正則表達式的角度入手. 思考從如下4點開始匹配:正則表達式
咱們以如下文本爲例進行解析:算法
### 一個*斜體*標題
複製代碼
首先命中文本元素標題, 內容爲一個*斜體*標題
數組
緊接着, 繼續解析比文本元素優先級更低的行內元素, 此次命中行內元素斜體, 內容爲斜體
瀏覽器
至此, 咱們將解析完的內容推入結果數組, 結果形如:緩存
parsedContent = [
`<h3>一個<i>斜體</i>標題</h3>`
]
複製代碼
若是文本不是一行, 再繼續以前的思路繼續解析, 直到原始內容爲空, 獲得最終的解析結果.性能優化
函數節流是老生常談的話題了, 固然不能當左側內容一有變更就當即更新. 在一些極端的場合, 好比長按刪除或是長按空格回車等狀況下, 連續執行解析函數硬件會形成沉重的負擔. 因此咱們優化思路首先要求儘可能在不太影響視覺效果的狀況下, 儘量少地執行解析函數.markdown
目標有了, 那麼對應的解決方案手到擒來:
緩存解析結果方案, 相似於算法題中常見的緩存對象. 好比咱們要實現一個斐波那契數列遞歸函數, 計算fabi(5)
時須要用到fabi(3)
和fabi(4)
的結果, 若是咱們有緩存, 咱們能夠直接從緩存中獲取fabi(3)
的結果. 將這一律念推導到解析器, 咱們能夠建立一個對象去緩存解析結果.
一開始寫解析結果緩存的時候, 筆者犯了一個很嚴重的錯誤, 那就是想嘗試將全部內容以及其解析值緩存到備忘錄對象, 代碼形如:
data: {
// 緩存對象
memo: {}
}
watch: {
// 當編輯器的value變更時將嘗試直接獲取緩存, 若是沒有緩存才解析內容
value (n, o) {
if (this.memo[n]) {
this.parsedValue = this.memo[n]
} else {
this.memo[n] = this.parsedValue = parseMDToHTML(n)
}
}
}
複製代碼
代碼看起來沒什麼問題, 由於問題不在代碼.
問題在內存容量上.
代碼運行在瀏覽器中, 通常狀況下, 內存相對於代碼執行速度而言是比較廉價的, 因此筆者常用到用對象進行緩存這種以空間換時間
的代碼模式. 通常狀況下它很是好用, 但它可能帶來一個問題. 這種代碼模式進一步限制了前端對內存的感知——筆者將整個編輯區域的原始值做爲對象的鍵, 將其解析結果做爲值緩存下來——一旦文章長度開始增加, 緩存對象佔用的內存容量將急劇增大.
假設咱們有某文章字符長度總量爲n, 那麼備忘錄模型將生長成這個樣子:
value = [1, 2, 3, ..., n-1, n].join('')
memo == {
'1': '1',
'12': '12',
'123': '123',
// ...
'12345...n-1': '12345...n-1',
'12345...n': '12345...n',
}
複製代碼
那麼能夠輕易得出, 文章字符長度(N)和內存消耗量(O)的關係, 形如:
和你想的同樣, 筆者瀏覽器內存爆了😅
不只如此, 文章不斷地增加, 不只帶來內存壓力, 解析函數每次要處理地內容也變多, 瀏覽器響應速度也愈來愈慢.
咱們亟需更好的緩存方案.
在解析過程簡述小節, 咱們提到解析器在解析時, 會將MD文本分爲塊狀內容進行解析. 由此咱們能夠嘗試緩存塊狀內容的解析結果, 而不是去緩存全文. 爲了在此次優化不爆內存, 咱們引入有限空間概念
——設想編輯器內含一個數組, 用來存放MD文本中塊狀內容以及其解析結果, 同時數組有最大長度限制, 限制爲1000, 假設咱們的每個元素佔5kb的內存, 那麼這個數組將只佔瀏覽器約5MB的內存, 不管咱們怎麼折騰, 至少不至於爆內存了~
不過咱們須要先考慮這樣一種狀況, 假使咱們的文章有超過1001個塊狀內容, 那麼多出的這一個塊狀內容進行解析後獲得的結果很顯然不能直接存入長度限制爲1000數組中. 因此咱們須要一種算法去計算應該捨棄數組中哪個元素, 將該元素捨棄後, 再把咱們手中結果存入數組.
用過Redis的朋友應該瞭解, Redis做爲一種使用內存做緩存的緩存系統, 它有多種緩存策略:
下文將仿照Redis的緩存淘汰策略手動造一個使用LFU策略進行緩存淘汰的緩存類.
實際的代碼並未採用數組充當緩存元素, 實際選擇了雙向鏈表, 使用雙向列表能夠抹除使用出租移除元素添加元素帶來的性能成本.
咱們須要提早定義好節點類Node
:
function Node (config) {
this.key = config.key
this.prev = null
this.next = null
this.data = config.data || {
val: null,
weight: 1
}
}
// 將當前節點的next指向另外一節點
Node.prototype.linkNextTo = function (nextNode) {
this.next = nextNode
nextNode.prev = this
}
// 將當前節點插入某一結點後
Node.prototype.insertAfterNode = function (prevNode) {
const prevNextNode = prevNode.next
prevNode.linkNext(this)
this.linkNext(prevNextNode)
}
// 刪除當前節點, 除非節點是頭節點/尾節點
Node.prototype.unLink = function () {
const prev = this.prev
const next = this.next
if (!prev || !next) {
console.log(`Node : ${this.key} cant unlink`)
return false
}
prev.linkNext(next)
}
複製代碼
緩存類將內含一個雙向鏈表, 同時還包含最大鏈表節點數, 當前鏈表長度這些屬性:
數組能夠直接經過下標去獲取某個特定的元素, 而鏈表不行, 在緩存類中筆者使用一個備忘錄對象去記錄每個節點的訪問地址, 充當數組下標的做用, 詳見下代碼中`nodeMemo`的使用
function LFU (limit) {
this.headNode = new Node({ key: '__head__', data: { val: null, weight: Number.MAX_VALUE } })
this.tailNode = new Node({ key: '__tail__', data: { val: null, weight: Number.MIN_VALUE } })
this.headNode.linkNext(this.tailNode)
this.nodeMemo = {}
this.nodeLength = 0
this.nodeLengthLimit = limit || 999
}
// 經過key判斷緩存中是否有某元素
LFU.prototype.has = function (key) {
return !!this.nodeMemo[key]
}
// 經過key獲取緩存中某一元素值
LFU.prototype.get = function (key) {
let handle = this.nodeMemo[key]
if (handle) {
this.addNodeWeight(handle)
return handle.data.val
} else {
throw new Error(`Key : ${key} is not fount in LFU Nodes`)
}
}
// 經過key獲取緩存中某一元素權重
LFU.prototype.getNodeWeight = function (key) {
let handle = this.nodeMemo[key]
if (handle) {
return handle.data.weight
} else {
throw new Error(`Key : ${key} is not fount in LFU Nodes`)
}
}
// 添加新的緩存元素
LFU.prototype.set = function (key, val) {
const handleNode = this.nodeMemo[key]
if (handleNode) {
this.addNodeWeight(handleNode, 10)
handleNode.data.val = val
} else {
if (this.nodeLength < this.nodeLengthLimit) {
this.nodeLength++
} else {
const deleteNode = this.tailNode.prev
deleteNode.unLink()
delete this.nodeMemo[deleteNode.key]
}
const newNode = new Node({ key, data: { val, weight: 1 } })
this.nodeMemo[key] = newNode
newNode.insertAfter(this.tailNode.prev)
}
}
// 打印緩存中所有節點
LFU.prototype.showAllNodes = function () {
let next = this.headNode.next
while (next && next.next) {
console.log(`Node : ${next.key} has data ${next.data.val} and weight ${next.data.weight}`)
next = next.next
}
}
// 對某一元素進行加權操做
LFU.prototype.addNodeWeight = function (node, w = 1) {
const handle = node
let prev = handle.prev
handle.unLink()
handle.data.weight += w
while (prev) {
if (prev.data.weight <= handle.data.weight) {
prev = prev.prev
} else {
handle.insertAfter(prev)
prev = null
}
}
}
複製代碼
import LFU from '@/utils/suites/teditor/LFU'
describe('LFU測試', () => {
const LFU = new LFU(4)
it('可以正確維護鏈表長度', () => {
LFU.set('1', 1)
LFU.set('2', 2)
LFU.set('3', 3)
LFU.set('4', 4)
LFU.set('5', 5)
expect(LFU.has('4')).to.equal(false)
})
it('節點的數據應該正確', () => {
expect(LFU.get('1')).to.equal(1)
expect(LFU.get('2')).to.equal(2)
expect(LFU.get('3')).to.equal(3)
expect(LFU.get('5')).to.equal(5)
LFU.get('5')
LFU.get('3')
LFU.get('3')
LFU.get('3')
LFU.get('3')
LFU.set('5', 6)
expect(LFU.get('5')).to.equal(6)
})
it('節點的權重應該正確', () => {
expect(LFU.getNodeWeight('5')).to.equal(14)
expect(LFU.getNodeWeight('3')).to.equal(6)
})
})
複製代碼
拆分渲染內容和經過節流解析函數想要達到的目的相似——經過限制瀏覽器的重繪迴流次數以減輕硬件負擔.
筆者的解析函數會將傳入的MD文本解析爲HTML片斷, 而後經過v-html將片斷放到瀏覽器右側窗口進行渲染, 雖然咱們在解析函數中作了緩存, 使得解析速度增長, 可是每一次的解析都會使瀏覽器從新繪製整一個右側窗口, 這裏有一個優化點.
拆分渲染內容就是要解決這樣一個問題. 咱們把右側窗口一整塊v-html區域以MD塊狀元素拆分爲多個小的v-html區域, 當編輯器某一行的文本數據有變更時, 只通知右側窗口更新對應區域的內容, 這樣一來, 瀏覽器性能能夠獲得進一步提高.
前端作頁面性能優化時, 除了網絡層面的優化, 剩下很大一塊內容都落在JS和瀏覽器的頭上, 考慮JS, 主要是如何減小重複計算, 至於瀏覽器, 則主要會想到重繪迴流這塊. 依靠這兩大山頭, 相信你也能寫出運行速度飛快的代碼!
本文只對代碼作了歸納性說明, 具體的代碼細節還須要待我使勁整理再發一篇新文章, 好比<動手擼一個簡單的LFU緩存類>之類的😀, 敬請期待~