mini-react 第四天:事件绑定,更新props

I. 实现事件绑定

1. JSX中的写法

App.jsxCounter子组件中添加一个button 和 onClick 函数。

js 复制代码
// App.jsx
function Counter(props) {
  function handleClick() {
    console.log("click");
  }
  
  return (
    <div>
      count: {props.num}
      <button onClick={handleClick}>click</button>
    </div>
  )
}

2. 在fiber节点中拿到key和value

processUnitOfWork 中打印每一个fiber 就可以看到button对应的fiber。其中props.onClick 就是我们写的函数。

3. 在为fiber节点创建dom之后的 updateProps 环节添加event listener

在处理props的function updateProps 中拿到这个function 并且用 element.addEventListener 来添加即可。

js 复制代码
function updateProps(dom, props) {
  Object.keys(props).forEach((key) => {
    if (key !== "children") {
      // 支持的事件命名都形如"on" + "Click", 这样就可以处理多种事件了
      if (key.startsWith("on")) {
        const eventType = key.slice(2).toLowerCase();
        dom.addEventListener(eventType, props[key]);
      }
      
      dom[key] = props[key];
    }
  });
}

II. 更新props

1. props保存在哪里?本身如何更新

在mini-react里为了简便,props是保存在函数组件外面的变量里,通过handleClick函数直接赋值来更新。先写一个React.update() 函数用来执行一轮props更新。

js 复制代码
let count = 10;
let props = {id :"1213131"};
function Counter() {
  // update props - 后续会用useState来触发props更新
  function handleClick() {
    console.log("click");
    count++;
    props = {};
    React.update();  // 手动改变props以及调用update
  }
  
  return (
    <div {...props}>
      count: {count}
      <button onClick={handleClick}>click</button>
    </div>
  )
}

由于我们还没有实现hook系统,这里就手动地更新props以及手动调用一个update函数来模拟props更新。render函数用来处理第一次的组件渲染,update函数来处理后续的props更新。

js 复制代码
function render(el, container) {
  // 将vdom的根节点作为children,创建到 root container 上
  // 这是第一个入队的任务
  wipRoot = {
    dom: container,  // root container dom是dom树的根节点
    props: {
      children: [el],
    },
  };
  nextWorkOfUnit = wipRoot;
}

function update() {
  wipRoot = {
    dom: currentRoot.dom,  // root container dom是dom树的根节点
    props: currentRoot.props,
    alternate: currentRoot,
  };
  console.log(currentRoot.props, "update");
  nextWorkOfUnit = wipRoot;
}

2. props的更新将会反映到DOM上呢?

props的更新,可能会导致两种更新:

  • 节点类型不变,但是属性改变。例如id111变成 222
  • 节点类型变化。例如从div变成TEXT_ELEMENT

回看render函数执行的第一次渲染,函数组件的渲染包含这些关键步骤:

  1. 执行函数组件函数得到节点的vdom:每个vdom节点包含props和children
  2. 根据vdom节点创建前后关联的fiber节点,逐级处理children
  3. 为children节点创建DOM,设置DOM props
  4. 执行commitWorkcommitRoot,将dom节点append到dom树上。

相对应地,update函数执行的第二次渲染,和第一次渲染也很相像。区别在于

  • 如果节点类型不变:
    • 第3步,第一次渲染生成的DOM还在,跳过这一步
    • 第4步,基于原有的fiber.dom,设置新的props
  • 如果节点类型变化:
    • 第3步:和第一次渲染一样,创建DOM和设置DOM props
    • 第4步,和第一次渲染一样,将dom节点append到dom树上

3. 具体实现

3.1. 如何保存旧的fiber树和DOM树

在commitRoot 中记录一下本轮已经render完的fiber树。每个fiber节点包含了其DOM和函数组件的函数。再次执行这些函数,就能得到新的fiber节点。进而可以通过创建或者更新

js 复制代码
let currentRoot = null;

function commitRoot() {
  commitWork(root.child);
  currentRoot = root; // 保存当前的fiber树, 给下次用来做diff
  root = null
}
自问自答

问:上面的代码中currentRoot是通过保存第一轮渲染的root得到的,里面保存的是旧的fiber树,props更新的时候会更新这个fiber树吗?如果不是,那props保存在哪,旧的fiber树如何使用新的props?

答:props更新的时候并不会直接更新保留的fiber树。currentRoot虽然是通过保存第一轮渲染的root得到的,但是里面存储的是App函数和Counter函数。当我们手动调用我们自己写的 React.update() 的时候拿到的仍然是这些函数,相关的props是存储在函数外面的变量,执行函数的时候自然会使用新的props 来构建更新后的节点。可想而知,如果Counter函数组件使用了props来进行条件render,那么自然会得到一个新的fiber结构和dom结构。

【自问自答小总结】:包含了函数组件的fiber树并不是一个静态的数据结构,而是会通过执行fiber树中存储的函数加上外部存储的props来得到数据。

3.2. 如何方便地为每个fiber节点找到上一轮的fiber节点以便对比?

在原来fiber树双向链表的基础上,再添加一个链接:每个节点对应的老节点

这个属性命名为alternate,意思为『备用』

【代码实现】

  • 为初次render之后的节点设置alternate属性,指向oldFiber
  • 对于fiber节点的children们
    • 如果是第一个child,oldFiber = fiber.alternate?.child
    • 对于其它的child,与新的fiber节点遍历一样,通过sibling的链接来获得 oldFiber = oldFiber.sibling
  • 为每个节点标注更新类型是DOM属性更新------update------例如div的id发生变化;还是节点类型更新------placement------需要创建DOM。
js 复制代码
function reconcileChildren(fiber, children) {  // reconcile 包含了init和update
  let prevChild = null;

  let oldFiber = fiber.alternate?.child;
  let newFiber;
  children.forEach((child, index) => {

    const isSameType = child && oldFiber && child.type === oldFiber.type;;

    if (isSameType) {
      // update
      newFiber = {
        type: child.type,
        props: child.props,
        child: null,
        parent: fiber,
        sibling: null,
        dom: oldFiber.dom,
        effectTag: "update",  // 标记一下,这个fiber节点是更新老节点得来的
        alternate: oldFiber,
      };
    } else {
      //create
      newFiber = {
        type: child.type,
        props: child.props,
        child: null,
        parent: fiber,
        sibling: null,
        dom: null,
        effectTag: "placement",  // 标记一下,这个fiber节点是新建的
      };
    }

    // 本次的fiber树会移动到sibling节点,oldFiber 也移动到兄弟节点。保证diff的时候能找到对应的节点
    if (oldFiber) {
      oldFiber = oldFiber.sibling;
    }

    if (index === 0) {
      fiber.child = newFiber;
    } else {
      prevChild.sibling = newFiber;
    }
    prevChild = newFiber;
  });
}

3.3 将fiber节点的更新应用到DOM上

根据props导致的更新是节点类型还是属性更新来做不同的处理:

  • 节点类型变化,则append dom
    • DOM是通过在reconcileChildren之前的 fiber.dom === null 来判断需要执行createDom
  • 节点属性变化,则执行updateProps更新DOM props
js 复制代码
function commitWork(fiber) {
  if (!fiber) return
  let fiberParent = fiber.parent;
  while (!fiberParent.dom) {
    fiberParent = fiberParent.parent
  }
  if (fiber.dom) {
    fiberParent.dom.append(fiber.dom)
  }

  // 根据props导致的更新是节点类型还是属性更新来做不同的处理 --Start
  if (fiber.effectTag === "update" && fiber.dom) {
    // 提供新的和旧的props用来做diff
    updateProps(fiber.dom, fiber.props, fiber.alternate?.props)
  } else if (fiber.effectTag === "placement" && fiber.dom) {
    fiberParent.dom.append(fiber.dom)
  }
  // 根据props导致的更新是节点类型还是属性更新来做不同的处理 --End

  commitWork(fiber.child)
  commitWork(fiber.sibling)
}
DOM属性更新

对于节点类型不变,仅更新属性值的不同情况进行处理:

  • old有,new没有的属性就删除
  • new有的,不管old有没有,都通过赋值来处理
js 复制代码
function updateProps(dom, nextProps, prevProps) {
  // 1. old 有,new 没有,删除
  Object.keys(prevProps).forEach((key) => {
    if (key !== "children") {
      if (!(key in nextProps)) {
        dom.removeAttribute(key);
      }
    }
  });
  // 2. old 有,new 有,更新
  // 3. old 没有,new 有,新增
  Object.keys(nextProps).forEach((key) => {
    if (key !== "children") {
      if (prevProps[key] !== nextProps[key]) {
        if (key.startsWith("on")) {
          const eventType = key.slice(2).toLowerCase();
          dom.removeEventListener(eventType, prevProps[key]);
          dom.addEventListener(eventType, nextProps[key]);
        } else {
          dom[key] = nextProps[key];
        }
      }
    }
  });
}

小结

至此,我们完成了props更新导致的两种更新的处理。测试一下:

  • 一开始,Counter组件的id有值

1. 节点的id属性更新

  • 点击后,更新id属性消失了

2. 节点类型从div变成p

  • 可以看到dom中append了新的p元素。

【问题】第一次渲染的div还保留着,这是因为在reconcileChildren中只针对新的fiber节点做了更新和新建标记。没有对oldFiber做删除。这点可以再优化,但是基本不影响我们理解本节课的内容。

相关推荐
三原24 分钟前
五年使用vue2、vue3经验,我直接上手react
前端·javascript·react.js
嘉琪coder29 分钟前
React的两种状态哲学:受控与非受控模式
前端·react.js
潜龙在渊灬6 小时前
前端 UI 框架发展史
javascript·vue.js·react.js
鸿是江边鸟,曾是心上人7 小时前
react+ts+eslint+prettier 配置教程
前端·javascript·react.js
hyyyyy!8 小时前
《从事件冒泡到处理:前端事件系统的“隐形逻辑”》
前端·javascript·react.js
FG.8 小时前
React
前端·react.js·前端框架
青红光硫化黑8 小时前
React基础之ReactRouter
前端·react.js·前端框架
程序员小续9 小时前
React 组件库:跨版本兼容的解决方案!
前端·react.js·面试
bbb16911 小时前
react源码分析 setStatae究竟是同步任务还是异步任务
前端·javascript·react.js