比 React 快 70% ?million.js 是如何做到的

前言

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 更新算法:

million.dev/blog/virtua...

而 Block virtual DOM 采用不同的 diffing 算法,可以分为两步进行:

  1. 静态分析:分析虚拟 DOM 以将树的动态部分提取到 "Edit Map" 中。

  2. 脏检查:对前后动态数据(不是 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 的主要流程分为几步:

  1. 借助编译手段,将动态数据分离,并抽离到一个组件中,将动态数据作为 props 传入

  2. 抽离的组件使用 block 函数包裹,生成一个新的高阶组件,使其可以进行一些额外的操作

  3. 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: 如何判断是可变数据?

astexplorer.net/

看下这个简单例子:

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...

相关推荐
zqx_77 小时前
随记 前端框架React的初步认识
前端·react.js·前端框架
笑非不退19 小时前
前端框架对比和选择
前端框架
TonyH20021 天前
webpack 4 的 30 个步骤构建 react 开发环境
前端·css·react.js·webpack·postcss·打包
掘金泥石流1 天前
React v19 的 React Complier 是如何优化 React 组件的,看 AI 是如何回答的
javascript·人工智能·react.js
lucifer3111 天前
深入解析 React 组件封装 —— 从业务需求到性能优化
前端·react.js
老章学编程i1 天前
Vue工程化开发
开发语言·前端·javascript·vue.js·前端框架
秃头女孩y1 天前
React基础-快速梳理
前端·react.js·前端框架
Small-K1 天前
前端框架中@路径别名原理和配置
前端·webpack·typescript·前端框架·vite
sophie旭2 天前
我要拿捏 react 系列二: React 架构设计
javascript·react.js·前端框架