likes
comments
collection
share

从0实现React18系列七-事件系统

作者站长头像
站长
· 阅读数 45

本系列是讲述从0开始实现一个react18的基本版本。由于React源码通过Mono-repo 管理仓库,我们也是用pnpm提供的workspaces来管理我们的代码仓库,打包我们使用rollup进行打包。

仓库地址

具体章节代码commit

在使用React的时候,我们都知道React实现了一套自己的事件系统,今天我们以click事件为例,讲解React事件系统的内部实践。

思考一下如下代码,在React中, 当我们点击p标签的时候,是一个什么顺序输出。div -> div -> p分别绑定了onClickCaptureonClick的函数。

export default function App() {
  const [num, setNum] = useState(3);
  return (
      <div
          onClick={() => {
            console.log("container click");
          }}
          onClickCapture={() => {
            console.log("container onClickCapture");
          }}
      >
        <div
            onClick={() => {
              console.log("div click");
            }}
            onClickCapture={() => {
              console.log("div onClickCapture");
            }}
        >
          <p
              onClickCapture={() => {
                console.log("p onClickCapture");
              }}
              onClick={() => {
                console.log("p click");
                setNum(num + 1);
              }}
          >
            {num}
          </p>
        </div>
      </div>
  );
}

container onClickCapture -> div onClickCapture -> p onClickCapture -> p click -> div click -> container click

当我们使用e.stopPropagation在某一次点击的时候,又会发送什么呢?

这篇文章就从事件本质去解释执行的流程。

绑定事件参数-入口

我们知道在书写jsx的时候,事件绑定onClick会被babel转换到ReactElementprops上。事件系统又是和宿主环境相关,在浏览器中是使用react-dom的包进行处理。事件相关处理主要文件SyntheticEvent.ts

那如何将我们传递的事件参数onClick、onClickCapture等传递到react-dom中呢?

分2步:

  1. 初始化阶段
  2. 更新阶段

初始化阶段

从前面调和章节中,我们知道在初始化阶段,为了构建离屏DOM树,我们会在completeWork的时候去调用react-domhostConfigcreateInstance的创建dom。

在这里,我们就可以将ReactElement props传递给react-dom。 这样在createInstance的时候,我们就得到了props的数据了。

// react-reconciler - completeWork  1. 构建DOM
const instance = createInstance(wip.type, newProps);
// react-dom - hostConfig 1. 创建DOM
export const createInstance = (type: string, props: Props): Instance => {
  const element = document.createElement(type) as unknown;
  updateFiberProps(element as DOMElement, props);
  return element as DOMElement;
};

之后updateFiberProps,我们就可以将props保存在新建的dom的某一个属性(__props)中。方便之后事件处理

// react-dom - SyntheticEvent.ts
export function updateFiberProps(node: DOMElement, props: Props) {
  // dom.__props = reactElement props
  node[elementPropsKey] = props;
}

更新阶段

正常在更新阶段,我们需要进行如下步骤

  1. completeWork的时候,对于HostComponent,对比前后props属性的变化。
  2. 如果属性变动后,执行markUpdate标记更新
  3. 然后在commitMutationEffectsOnFibers的时候,触发commitUpdate
  4. commitUpdate中针对HostComponent执行updateFiberProps操作。

这样就可以得到了最新的属性,并赋值给对应的dom

注册事件

由于react兼容各个平台的事件机制,自己实现了一套事件系统,在React17之前,事件都是绑定在document上,React17后,事件被绑定到同一容器container统一管理。同时对于微前端项目,也可以隔离多个容器。

let container = document.getElementById("root") // 这个就是绑定事件的contaienr
ReactDOM.createRoot(container).render(<App />

由于不是绑定在真实的Dom上,所以React模拟出了一套事件流: 事件捕获 -> 事件源 -> 事件冒泡。也基于自身重写了事件源对象event

在初始化渲染的时候,我们就需要注册事件, 这里以click为例。

// react-dom -> root.ts
export function createRoot(container: Container) {
  const root = createContainer(container);

  return {
    render(element: ReactElementType) {
      initEvent(container, "click"); 
      return updateContainer(element, root);
    },
  };
}

调用initEvent,传递我们实际绑定的DOM节点。进行事件绑定。主要是通过addEventListener绑定事件。

// react-dom SyntheticEvent.ts
export function initEvent(container: Container, eventType: string) {
  container.addEventListener(eventType, (e: Event) => {
    dispatchEvent(container, eventType, e);
  });
}

所以我们平时的点击事件,实际监听触发的都在container上,并不是本身监听了事件。这样统一处理,也可以减少事件的监听。

事件分发dispatchEvent

注册事件后,我们知道,当我们点击一个元素的时候,实际上触发的是container元素,那目标元素是如何执行绑定的onClick或者onClickCapture函数的呢?

很容易想到的是,我们应该需要收集container目标元素的沿途过程中的事件绑定,然后依次去触发它们。

所以dispatchEvent的主要流程分为下面4步骤:

  1. 收集沿途的绑定事件(onClick或者onClickCapture冒泡或捕获)
  2. 基于原始事件参数event构造合成事件参数
  3. 遍历捕获capture,依次执行
  4. 遍历冒泡bubble,依次执行
function dispatchEvent(container: Container, eventType: string, e: Event) {
  const targetElement = e.target;

  if (targetElement === null) {
    console.warn("事件不存在target", e);
  }
  // 1. 收集沿途的事件
  const { bubble, capture } = collectPaths(
    targetElement as DOMElement,
    container,
    eventType
  );
  // 2. 构造合成事件
  const se = createSyntheticEvent(e);
  // 3. 遍历capture
  triggerEventFlow(capture, se);
  // 4. bubble
  if (!se.__stopPropagation) {
    triggerEventFlow(bubble, se);
  }
}

接下来我们依次分析:

1. 收集绑定事件collectPaths

collectPaths这个函数主要是收集从实际点击的Dom元素container的绑定事件。接收三个函数

  1. targetElement: 点击的目标元素e.target
  2. container: 容器Dom元素
  3. eventType: 事件类型,主要是用于映射,例如click 映射 onClickonClickCapture

从之前的绑定事件参数中我们得知,ReactElemenet 的参数绑定在对应的Dom元素的__props属性中。

所以向上遍历的过程中,要获取elementProps, 然后根据是否存在事件绑定进行对应的逻辑。通过collectPaths后 我们得到一个capturebubble的数组。

function collectPaths(
  targetElement: DOMElement,
  container: Container,
  eventType: string
) {
  const paths: Paths = {
    capture: [],
    bubble: [],
  };
  while (targetElement && targetElement !== container) {
    // 收集
    const elementProps = targetElement[elementPropsKey];
    if (elementProps) {
      const callbackNameList = getEventCallbackNameFromEventType(eventType);
      // callbackNameList = ["onClickCapture", "onClick"]
      if (callbackNameList) {
        callbackNameList.forEach((callbackName, i) => {
          const eventCallback = elementProps[callbackName];
          if (eventCallback) {
            if (i === 0) {
              //capture
              paths.capture.unshift(eventCallback);
            } else {
              paths.bubble.push(eventCallback);
            }
          }
        });
      }
    }
    targetElement = targetElement.parentNode as DOMElement;
  }
  return paths;
}

这里可能有一个疑惑点,就是为什么在catpure的时候我们要使用unshift,而在bubble中,我们使用push

捕获阶段是从上到下的,所以最上面的元素应该是最先执行的。使用unShift保证后遍历到的,先执行。 从0实现React18系列七-事件系统

2. 构造合成事件参数createSyntheticEvent

由于react自己实现的事件系统,所以在处理事件冒泡和捕获的时候时候,需要自定义一些实现来模拟阻止冒泡。 例如e.stopPropagation,除了执行本身之外,还需要额外的逻辑。

当用户调用e.stopPropagation的时候,我们将一个私有遍历__stopPropagation设置为true。在之后的遍历中使用。

function createSyntheticEvent(e: Event) {
  const syntheticEvent = e as SyntheticEvent;
  syntheticEvent.__stopPropagation = false;
  const originStopPropagation = e.stopPropagation;

  syntheticEvent.stopPropagation = () => {
    syntheticEvent.__stopPropagation = true;
    if (originStopPropagation) {
      originStopPropagation();
    }
  };
  return syntheticEvent;
}

3. 遍历捕获事件triggerEventFlow

这个阶段主要是依次执行在第一步中收集的函数,执行的时候传入第二步构造的事件对象。

当发现上层有调用e.stopPropagation()的时候,就停止循环。

// dispatchEvent 调用
// 3. capture
triggerEventFlow(capture, se);

function triggerEventFlow(paths: EventCallback[], se: SyntheticEvent) {
  for (let i = 0; i < paths.length; i++) {
    const callback = paths[i];
    callback.call(null, se);

    if (se.__stopPropagation) {
      break;
    }
  }
}

4. 遍历冒泡事件triggerEventFlow

// dispatchEvent 调用
// 4. bubble
if (!se.__stopPropagation) {
  triggerEventFlow(bubble, se);
}

总结

这样就实现了一个基于click的事件系统。 从0实现React18系列七-事件系统

思考如下例子,在react中的执行顺序是怎么样的呢?

function App() {
  const [num, setNum] = useState(3);
  return (
    <div
      onClick={() => {
        console.log("container click");
      }}
      onClickCapture={() => {
          console.log("container onClickCapture");
      }}
    >
      <div
        onClick={(e) => {
          e.stopPropagation()
          console.log("div click");
        }}
        onClickCapture={() => {
          console.log("div onClickCapture");
        }}
      >
        <p
          onClickCapture={() => {
            console.log("p onClickCapture");
          }}
          onClick={() => {
            console.log("p-click");
            setNum(num + 1);
          }}
        >
          {num}
        </p>
      </div>
    </div>
  );
}

container onClickCapture -> div onClickCapture -> p onClickCapture -> p-click -> div click

如果改成div的 onClickCapture执行e.stopPropagation()呢?打断继续向下遍历的流程,并也不会执行冒泡操作了。所以输出如下:

container onClickCapture -> div onClickCapture

下一节

下一节我们讲解多节点的diff的原理

转载自:https://juejin.cn/post/7189564391648395321
评论
请登录