这边文章将给这个系列做一个结尾,我们将通过art-js为例,来分析重运行时框架的性能优化。
Art-js框架简单介绍
简介
一个基于虚拟dom的高性能框架,用于构建丰富的应用程序。
特性
- 没有编译预处理: 纯js写法,jsx外的任何代码都不会被转化,保证最大的灵活性。
- 基于虚拟DOM: 保证复杂场景下也能拥有不错的性能。
- 通用的任务调度: 基于key+优先级的任务调度不止可以用来渲染,也可以用在业务代码中。
- 快速响应: raf+时间分片保证交互事件能够及时响应,对齐渲染帧。
- 记忆虚拟树: 跳过无变更的虚拟dom创建,并且无需没有额外的内存占用。
- 三层节点复用机制: 保证最少的dom创建与更新。
js-framework-benchmark
截图来源于官方最新快照
主要从两个角度对比一下
- 运行时、编译时
- 是否存在虚拟dom
重运行框架拥有更好的灵活性,缺点是难以在编译时进行优化。重编译时框架,通过预处理优化性能,缺点语法受限,大型项目中可以编译后文件过大。
虚拟dom提供灵活的操作,最低性能保障,复杂组件编写简单。真实dom性能更好,灵活性低,复杂组件编写复杂。
art采用虚拟dom、重运行时、组件级别更新,保证开发人员最大程度而灵活。但是art在此基础上保证了不错的性能。
组件更新
三层缓存
三层缓存用于后续diff的节点复用,减少真实dom的更新
记忆虚拟树
作用: 记录虚拟树中的虚拟dom节点。跳过没有变更的虚拟dom创建。
- 第一层缓存,可以跳过虚拟dom创建、跳过render函数调用、跳过dom更新
- 只记录存在key值的节点。没有key值无法快速查找虚拟树中的节点。
- 只记录自定义组件节点,其他类型节点不需要调用render函数,所有没有记录。
- 当节点移除虚拟树时,删除记录。
// 创建虚拟dom节点
export function h(type, props, ..._children) {
// 处理子节点
let children = [];
for (let child of _children) {
if (Array.isArray(child)) {
children = children.concat(child);
} else if (vNode.isVNode(child)) {
children.push(child)
} else {
children.push(new vTextNode(child))
}
}
// 自定义组件类型虚拟dom
if (type.prototype instanceof Component) {
// 记忆虚拟树中读取缓存
let cacheNode = props && props.key &&
type.$cacheMap && type.$cacheMap[props.key];
// 节点没有更新时,直接返回缓存
if (cacheNode && cacheNode.$instance &&
(!cacheNode.$instance.shouldComponentUpdate ||
!cacheNode.$instance.shouldComponentUpdate(props))) {
return cacheNode;
}
// 没有缓存或者缓存有更新时创建创新的虚拟dom节点
let node = new vComponentNode(type, props);
// 如果新生成的节点存在key值,则写入缓存
if (props && props.key) type.$cacheMap[props.key] = node;
return node;
} else {
return new vNode(type, props, children);
}
}
key值复用与类型复用
作用: diff前对旧虚拟树进行处理,根据key与类型存入map结构,方便后续查找与更新。
- 适用于所有类型的虚拟dom节点
- 拥有key值的节点要求key值与类型完全符合才能复用
- 原生组件会复用之前的dom
- 自定义组件复用之前组件实例,更改属性后重新调用render生成内部结构 (自定义组件对应的真实dom就是render后生成的虚拟树所对应的真实dom)
// 所有旧node
const preMap = {}
for (let i = 0; i < preNodes.length; i++) {
let node = preNodes[i];
// 记录旧虚拟树中的前后关系
node.beforeNodeIndex = i - 1;
// 根据key值缓存
if (node.$props.key) {
preMap[node.$props.key] = node;
continue;
}
// 根据类型缓存
if (!preMap[node.$type]) preMap[node.$type] = [];
if (node.$dom) preMap[node.$type].push(node);
}
diff算法
diff的原则是最小更新,但是最小更新并不一定最快,常见的dom操作,创建、删除dom最耗时,修改属性、移动位置次之,cloneNode省时。有些框架会提前创建dom,实例化时clone一份,这种做法性能好但是占用内存。
art通过多层复用机制减少dom创建与删除操作。更新属性时,通过前后虚拟dom对比最小程度更新dom。
有些框架可能会更新时解除父子关系,全部更新完根据虚拟树重新绑定。art更新dom时并不去改变dom的父子关系,在diff过程中只使用一次移动来移动dom的位置。
art没有使用细粒度更新而是采用组件级别更新,合并diff、patch一次遍历完成组件更新。
比较完整的流程图,删除了一些步骤
任务调度
通过上面diff算法我们已经在跑分中拥有很好的性能,但是快不代表流畅。
通过任务调度有序执行任务,防止一帧占用时间过长,造成掉帧。
渲染一帧需要完成哪些工作?
详细请看前一篇文章
时间分片
如果我们任务队列中的任务很多,要在一帧中全部执行完虽然可以保证速度最快,但是这一帧的时长必然会很长,造成卡顿,并且这段时间内都无法响应用户的操作。
我们可以通过时间分片在每一帧中只执行一段时间后便释放资源,到了下一帧中继续执行。通过这种方式就可以在任务队列执行期间不阻塞用户事件与页面渲染。
import { TaskList } from '../modal/Scheduler';
const taskList = new TaskList();
let isWorking = false;
function workLoop(timestamp) {
// 存在任务、并且当前帧占用时间少于5ms时或者首个任务已超时则执行
while (taskList.getFirstTimeOut() &&
(performance.now() - timestamp < 5 ||
taskList.getFirstTimeOut() <= performance.now())) {
let task = taskList.shift();
// 执行任务
task.val();
// 执行回调
for (const callback of task.callbackList) callback();
}
if (taskList.getFirstTimeOut()) {
// 如果队列中存在任务,下一帧raf阶段执行
requestAnimationFrame(workLoop);
} else {
// 不存在任务,停止调度
isWorking = false;
}
}
export function pushTask(task) {
// 插入任务
taskList.put(task);
// 如果任务调度未启动,启动调度,并在下一帧raf阶段执行。
if (!isWorking) {
isWorking = true;
requestAnimationFrame(workLoop);
}
}
任务队列
art的任务队列除了用于渲染以外,还希望能够提供给开发者直接调用,帮助有序执行一些不需要同步执行的任务,例如埋点上报、日志、资源回收,甚至可以直接用于动画,art的任务队列需要更好的性能。
任务队列功能
1. 允许通过key值更新任务,防止单个组件的重复更新
2. 允许传入一组回调函数在任务完成时调用
3.可以通过优先级指定任务的执行顺序
4.任务更新时如果优先级提高需要调整任务的执行顺序
任务队列结构
一般来说优先级队列我们会最先想到使用堆这种数据结构来存储,但是如果想要通过key值来快速定位并修改任务的话就需要维护一组频繁变更的下标,实现上会比较繁琐,性能也不会特别好。
此处我使用一个map+双向跳表的数据结构来实现我们的任务队列,不同优先级有对应的超时时间。
// 任务列表
export class TaskList {
constructor() {
// 使用map可以通过key值直接定位到目标任务
this.map = new Map();
// 跳表最大层级
this.maxLevel = 0;
// 跳表头
this.head = new Node({ key: HEAD });
// 跳表尾
this.tail = new Node({ key: TAIL });
// 空跳表链接收尾
for (let i = 0; i < MAX_LEVEL; i++) {
this.head.next[i] = this.tail;
this.tail.pre[i] = this.head;
}
}
...
}
// 单个任务
export class Task {
constructor(key, val, callbackList, priority) {
this.key = key;
// 任务的方法
this.val = val;
// 任务的所有回调函数
this.callbackList = callbackList;
// 任务最后超时时间
this.timeout = performance.now() + priority;
}
}
// 任务节点包装一层标记前后任务
class Node {
constructor({ key, val = () => { }, callbackList = [], priority = PRIORITY_TYPES.NORMAL_PRIORITY_TIMEOUT } = {}) {
this.task = new Task(key, val, callbackList, priority);
// 跳表各层级后一个节点
this.next = new Array(MAX_LEVEL).fill(null);
// 跳表各层级前一个节点
this.pre = new Array(MAX_LEVEL).fill(null);
}
}
跳表
对于一个单链表来讲,即便链表中存储的数据是有序的,如果我们要想在其中查找某个数据,也只能从头到尾遍历链表。这样查找效率就会很低,时间复杂度会很高,是 O(n)。
跳表通过建立多级索引实现O(logN)的时间复杂度。保证性能需要数据平衡
如果你了解红黑树、AVL 树这样平衡二叉树,你就知道它们是通过左右旋的方式保持左右子树的大小平衡,而跳表是通过随机函数来维护前面提到的“平衡性”。
任务队列操作
取出任务:取出任务时,我们的操作是从跳表中取出首个任务,然后根据key值从map中删除,这个操作我们需要的时间复杂度只有O(1)。
// 取出首个任务
shift() {
// 任务为空
if (this.head.next[0] === this.tail) return null;
// 首个任务
let currentNode = this.head.next[0];
// 从跳表中删除首个任务
for (let i = this.maxLevel - 1; i >= 0; i--) {
if (currentNode.next[i] && currentNode.pre[i]) {
currentNode.pre[i].next[i] = currentNode.next[i];
currentNode.next[i].pre[i] = currentNode.pre[i];
}
// 删除后判断当前层级是否为空,空则最大高度-1
if (this.head.next[i] === this.tail) this.maxLevel--;
}
// 删除map中的任务
this.map.delete(currentNode.task.key);
return currentNode.task;
}
添加任务(不存在相同key值任务时):map中不存在相同key值的任务,此时我们需要将新任务存入map,并且根据优先级插入到跳表中,时间复杂度为O(logN)
// 不存在key值相同的任务
// 生成当前任务跳表层级
let level = TaskList.getLevel();
// 创建任务
let node = new Node({ key, val, callbackList, priority });
// 将新任务插入map
this.map.set(key, node);
// 将任务根据超时时间插入跳表,超时时间相同插入到最后
let preNode = this.head;
for (let i = level - 1; i >= 0; i--) {
while (preNode.next[i] !== this.tail && preNode.next[i].task.timeout <= node.task.timeout) {
preNode = preNode.next[i];
}
node.pre[i] = preNode;
node.next[i] = preNode.next[i];
preNode.next[i] = node;
node.next[i].pre[i] = node;
}
// 重新赋值跳表最大层级
if (level > this.maxLevel) this.maxLevel = level;
添加任务(存在相同key值任务):map中存在相同key值任务并且新任务优先级更低,此时我们只需要通过map查找出任务,然后更新任务、合并回调函数,时间复杂度为O(1)
put({ key = Symbol('default'), val = () => { }, callbackList = [], priority = PRIORITY_TYPES.NORMAL_PRIORITY_TIMEOUT }) {
if (this.has(key)) {
// 已经存在key值相同的任务
// 获取相同key值任务
let node = this.get(key);
// 计算新任务的超时时间
let timeout = performance.now() + priority;
// 旧任务重新赋值
node.task.val = val;
// 合并新旧任务回调函数
node.task.callbackList = node.task.callbackList.concat(callbackList);
...
}
}
添加任务(存在相同key值任务并且新任务优先级更低):map中存在相同key值任务并且新任务优先级更高,此时我们除了要通过map查找出任务,然后更新任务、合并回调函数外,还需要向前移动任务在跳表中的位置,时间复杂度为O(logN)。
// 如果新任务更紧急,则修改过期时间,并移动位置
if (timeout < node.task.timeout) {
// 赋值超时时间
node.timeout = timeout;
let level = this.maxLevel;
let nextNode = node.next[level - 1];
// 计算当前任务的层级与最高层级的下一个任务
while (!nextNode) {
level--;
nextNode = node.next[level - 1];
}
// 从跳表中删除该任务
for (let i = level - 1; i >= 0; i--) {
node.pre[i].next[i] = node.next[i];
node.next[i].pre[i] = node.pre[i];
}
// 各层级插入新的位置
for (let i = level - 1; i >= 0; i--) {
while (nextNode.pre[i] !== this.head && nextNode.pre[i].timeout > node.task.timeout) {
nextNode = nextNode.pre[i];
}
node.next[i] = nextNode;
node.pre[i] = nextNode.pre[i];
nextNode.pre[i] = node;
node.pre[i].next[i] = node;
}
简单介绍到这里,框架还有很多需要完善的地方,下面贴个链接,欢迎大家指出问题。
npm: npm i js-art