前言
React 凭借着丰富的生态和出色的开发体验吸引了众多开发者,但令人诟病的是,简单使用 react 开发的产品性能不佳。因为每一次更新时,react 都需要对比更新前后的 Vdom Tree,依次遍历 Vdom Tree 的所有 Vdom 节点。为此, React 提供了 React.memo 和 useMemo 等 api,帮助开发者优化性能问题。而 million.js 可以绕过以上 api,帮助开发者自动收集动态依赖,减少开发心智负担。以下是 react 和 million.js 的性能对比:krausest.github.io/js-framewor...
在涉及节点交换操作,即更新相关操作时,差距更为明显。那么 million.js 是如何实现的呢?
附:million.js 官网 million.dev/
Block Virtual Dom
我们先来看下传统的 Virtual Dom 更新算法:
而 Block virtual DOM 采用不同的 diffing 算法,可以分为两步进行:
-
静态分析:分析虚拟 DOM 以将树的动态部分提取到 "Edit Map" 中。
-
脏检查:对前后动态数据(不是 Vdom 节点)进行比较,以确定哪部分发生了变化。如果前后数据发生了变化,则直接通过 Edit Map 更新 DOM。
million.js 正是采用了类似的思想,下面我们来看下 miillon.js 实际是怎么运行的:
原代码:
JavaScript
import { useState } from 'react';
import { block } from 'million/react';
export const Counter = block(() => {
const [count, setCount] = useState(0);
return (
<div>
count:{count}
<button onClick={() => setCount(v => v + 1)}>+</button>
</div>
);
});
转换后(精简部分无关代码):
JavaScript
import { block as _block$ } from "million/react";
import { useState } from 'react';
const _anonymous$2 = () => {
const [count, setCount] = useState(0);
const _2 = () => setA(v => v + 1);
return <_puppet$ count={count} _2={_2} />;
};
const _puppet$ = /*million:transform*/_block$(({
count,
_2
}) => {
return <div>
count:{count}
<button onClick={_2}>+</button>
</div>;
}, {
svg: false,
shouldUpdate: (a, b) => a?.a !== b?.a || a?._2 !== b?._2
});
export const Counter = _anonymous$2;
_block$ 部分核心源码如下:
JavaScript
const MillionBlock = <P extends MillionProps>(
props: P,
forwardedRef: Ref<any>,
) => {
const ref = useRef<HTMLElement>(null);
const patch = useRef<((props: P) => void) | null>(null);
const portalRef = useRef<MillionPortal[]>([]);
props = processProps(props, forwardedRef, portalRef.current);
// 每次更新时都会调用 patch 方法,首次 mount 时,该方法为空
patch.current?.(props);
const effect = useCallback(() => {
if (!ref.current) return;
const currentBlock = blockTarget!(props, props.key);
if (patch.current === null) {
// 调用 mounted 方法
mount$.call(currentBlock, ref.current, null);
// 为 patch 方法赋值
patch.current = (props: P) => {
patchBlock(
currentBlock,
blockTarget!(
props,
props.key,
options?.shouldUpdate as Parameters<typeof createBlock>[2],
),
);
};
}
}, []);
...
// 借助 ref 绑定,跳出 react 的 render 流程
// 使用自身实现的 mount 和 patch 函数来实现元素的挂载和更新
const marker = useMemo(() => {
return createElement(options?.as ?? defaultType, { ref });
}, []);
// 处理 props 是 React 组件的情况
const childrenSize = portalRef.current.length;
const children = new Array(childrenSize);
for (let i = 0; i < childrenSize; ++i) {
children[i] = portalRef.current[i]?.portal;
}
const vnode = createElement(Fragment, {}, marker, createElement(Effect, {
effect,
deps: hmrTimestamp ? [hmrTimestamp] : [],
}), children);
return vnode;
};
主要流程
通过上面的分析,我们知道 million.js 的主要流程分为几步:
-
借助编译手段,将动态数据分离,并抽离到一个组件中,将动态数据作为 props 传入
-
抽离的组件使用 block 函数包裹,生成一个新的高阶组件,使其可以进行一些额外的操作
-
block 函数注入 useEffect hook,在首次执行时,执行自身实现的 mount 函数,在之后的更新中执行自定义的 patch 函数,以跳出 react 的 render 流程,达到性能优化的目的。
下面手动实现一下简易版的 million.js ,以帮助大家更好的理解 block、mount、patch 之间是如何工作的。完整代码见 github.com/aidenybai/h...
基本使用
JavaScript
const h = (
type: string,
props: Props | null = {},
...children: VNode[]
): VElement => ({
type,
props: props || {},
children,
});
const Button = block(({ number }) => {
return h('button', { type: 'button' }, number);
});
// 调用,相当于 <Button number={0} />
const button = Button({ number: 0 });
button.mount(document.getElementById('root')!);
setInterval(() => {
button.patch(Button({ number: Math.random() }));
}, 1000);
简单实现
block 函数
收集动态 props,将其保存在内部变量 edits 中。
JavaScript
class Hole {
key: string;
constructor(key: string) {
this.key = key;
}
}
const block = <TProps extends Props = Props>(fn: (props: TProps) => VNode) => {
// e.g. props.any_prop => new Hole('any_prop')
const proxy = new Proxy({} as TProps, {
get(_, prop: string) {
return new Hole(prop);
},
});
const vnode = fn(proxy);
const edits: Edit[] = [];
// 将 vnode 渲染成真实 dom
// 到这一步,root 是剥离了动态值的半成品,需要我们调用 mount 方法生成完整的节点,详情后面介绍
const root = render(vnode, edits);
...
// 返回一个高阶组件
return (props:TProps):Block => {
....
// 组件内部封装有必要的 api
return { mount, patch, props, edits };
}
};
JavaScript
const render = (
vnode: VNode,
edits: Edit[] = [],
// Path is used to keep track of where we are in the tree
// as we traverse it.
// e.g. [0, 1, 2] would mean:
// el1 = 1st child of el
// el2 = 2nd child of el1
// el3 = 3rd child of el2
path: number[] = [],
): HTMLElement | Text => {
if (typeof vnode === 'string') return document.createTextNode(vnode);
const el = document.createElement(vnode.type) as any;
for (const name in vnode.props) {
const value = vnode.props[name];
if (value instanceof Hole) {
edits.push({
type: 'attribute',
path, // the path we need to traverse to get to the element
attribute: name, // to set the value during mount/patch
hole: value.key, // to get the value from props during mount/patch
});
continue;
}
// value 是 Hole 的实例,说明 value 是动态的,将其保持在 edits 中后,不做其他操作;
// 否则说明 value 是静态的属性值,可以直接赋值。
// e.g. {{number}} 为动态节点,访问 number 会生成 Hole 实例,而 type:'button' 则是一个静态属性
// const Button = block(({ number }) => {
// return h('button', { type: 'button' }, number);
// });
el[name] = value;
}
for (let i = 0; i < vnode.children.length; i++) {
const child = vnode.children[i];
if (child instanceof Hole) {
edits.push({
type: 'child',
path, // the path we need to traverse to get to the parent element
index: i, // index represents the position of the child in the parent used to insert/update the child during mount/patch
hole: child.key, // to get the value from props during mount/patch
});
continue;
}
// child 是 Hole 的实例,说明 child 是动态的,将其保持在 edits 中后,不做其他操作;
// 否则说明 child 是静态的节点,可以直接追加进去。
el.appendChild(render(child, edits, [...path, i]));
}
return el;
};
mount 函数
JavaScript
const block = <TProps extends Props = Props>(fn: (props: TProps) => VNode) => {
// e.g. props.any_prop => new Hole('any_prop')
const proxy = new Proxy({} as TProps, {
get(_, prop: string) {
return new Hole(prop);
},
});
const vnode = fn(proxy);
const edits: Edit[] = [];
const root = render(vnode, edits);
// 返回一个高阶组件
return (props:TProps):Block => {
const elements = new Array(edits.length);
const mount = (parent: HTMLElement) => {
parent.textContent = '';
// 先把半成品 root 追加到要绑定的父节点中
parent.appendChild(root);
// 将动态数据补全
for (let i = 0; i < edits.length; i++) {
const edit = edits[i];
let thisEl = root as any;
// 从根节点遍历,如果 edit 是 attribute 类型,则是找到该动态节点
// 如果是 child 类型,path 非空,则是找到该动态节点的父节点
// If path = [1, 2, 3]
// thisEl = el.childNodes[1].childNodes[2].childNodes[3]
for (let i = 0; i < edit.path.length; i++) {
thisEl = thisEl.childNodes[edit.path[i]];
}
// 保存该动态节点/父节点,后面 patch 函数会用到
elements[i] = thisEl;
// 获取动态数据的值
const value = props[edit.hole];
if (edit.type === 'attribute') {
thisEl[edit.attribute] = value;
} else if (edit.type === 'child') {
// handle nested blocks if the value is a block
if (value.mount && typeof value.mount === 'function') {
value.mount(thisEl);
continue;
}
// 字符串类型
const textNode = document.createTextNode(value);
// thisEL 代表父节点,插入到父节点下对应的位置上
thisEl.insertBefore(textNode, thisEl.childNodes[edit.index]);
}
}
};
....
// 组件内部封装有必要的 api
return { mount, patch, props, edits };
}
};
patch 函数
JavaScript
const block = <TProps extends Props = Props>(fn: (props: TProps) => VNode) => {
// e.g. props.any_prop => new Hole('any_prop')
const proxy = new Proxy({} as TProps, {
get(_, prop: string) {
return new Hole(prop);
},
});
const vnode = fn(proxy);
const edits: Edit[] = [];
const root = render(vnode, edits);
// 返回一个高阶组件
return (props:TProps):Block => {
const elements = new Array(edits.length);
...
const patch = (newBlock: Block) => {
for (let i = 0; i < edits.length; i++) {
const edit = edits[i];
const value = props[edit.hole];
const newValue = newBlock.props[edit.hole];
// dirty check
if (value === newValue) continue;
// 依次更新动态节点
const thisEl = elements[i];
if (edit.type === 'attribute') {
thisEl[edit.attribute] = newValue;
} else if (edit.type === 'child') {
// handle nested blocks if the value is a block
if (value.patch && typeof value.patch === 'function') {
// patch cooresponding child blocks
value.patch(newBlock.edits[i].hole);
continue;
}
// 找到对应的位置,直接更新赋值
thisEl.childNodes[edit.index].textContent = newValue;
}
}
};
// 组件内部封装有必要的 api
return { mount, patch, props, edits };
}
};
总结
JavaScript
// 生成高阶函数组件 Button
const Button = block(({ number }) => {
return h('button', { type: 'button' }, number);
});
// 生成静态 Dom 节点,并保存动态数据
const button = Button({ number: 0 });
// 遍历动态数据 edits,将动态数据填充
button.mount(document.getElementById('root')!);
setInterval(() => {
// 遍历动态数据 edits,将更新前后数据比对,只更改变化后的节点数据
button.patch(Button({ number: Math.random() }));
}, 1000);
// 静态节点仅在调用 Button 时生成,mount 和 patch 方法只操作静态数据,
// 由之前的 Dom Diff 优化成 Data Diff,性能更佳!
拓展:编译实现原理
Q: 如何判断是可变数据?
看下这个简单例子:
JavaScript
import { useState } from 'react';
import { block } from 'million/react';
export const Counter = block(() => {
const [count, setCount] = useState(0);
return (
<div>
count:{count}
<button onClick={() => setCount(v => v + 1)}>+</button>
</div>
);
});
我们可以发现可变数据都是用大括号包裹的表达式,对应 AST 中的 JsxExpressionContainer 节点。
因此,我们可以识别并收集这些节点,在用 babel 插入一个自定义组件时,将这些节点当成 props 传入。
但要注意,如果是 {'text'}、 {0}等含有静态数据的节点,需要判断再做一层过滤。编译这部分源码比较复杂,这里就不展开了,感兴趣的同学可以直接去阅读源码 github.com/aidenybai/m...