你用过一个越写越慢的编辑器
么?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缓存类>之类的😀, 敬请期待~