react框架是目前最为流行的前端框架之一,尤其在很多大厂,应用更为广泛。相对于一些mvvm框架,react上手需要一定的技术基础,但掌握后,编码体验和性能是很不错的。react整体思想是函数式编程,可以很大程度上减少代码重复,易于并发编程,react内部实现了一套调度机制,给不同执行任务划分优先级,从高到低循环渲染(Fiber),同时引入一套Fiber Diff算法,最大限度的使网页平滑,性能最优。
react的源码在不断的迭代后,相对不那么好理解,那么怎么才能快速理解并掌握react底层运行原理呢,近期给员工作培训,基于react源码思想,设计和实现了一个简版react,本文将逐层深入,从0实现一个简版react,包括Fiber结构,Fiber Diff算法,useState hooks实现。
更多内容关注公众号:前端361
概述
1,实现基础版react
2,fiber数据结构实现
3,fiber diff算法实现
4,useState实现
一,实现基础版react
先看一段react代码
import React from "react"
import ReactDOM from "reaect-dom"
const container = document.getElementById("root")
ReactDOM.render(<div className="test" onClick={() => {console.log(123)}}>
<p style={
{background: 'red'}}>123</p>
</div>, container)
思路分析:
1,上面代码引用了React, ReactDOM对象,所以自己实现的话需要自定义React, ReactDOM对象
const React = {}
const ReactDOM = {}
2,执行react jsx代码会被@babel/plugin-transform-react-jsx转化为如下格式:
jsx转化
从上面代码可以看到,jsx代码在经过babel转义后,调用了React.createElement,所以要给自定义React对象加上createElement方法,createElement接收参数是标签类型(type),元素属性(props),元素的所有子元素(...children)
React.createElement = function(type, props, ...children){
return {
type,
props: {
...props,
children: children.map(child => {
return child
})
}
}
}
由于会存在文本节点,标签类型都是TEXT,将文本节点单独拎出来,添加createTextElement方法
React.createElement = function(type, props, ...children){
// console.log(children)
return {
type,
props: {
...props,
children: children.map(child => {
return typeof child === 'object' ? child : React.createTextElement(child)
// 添加组件判断
})
}
}
}
React.createTextElement = function(text){
return {
type: 'TEXT',
props: {
nodeValue: text,
children: []
}
}
}
3, react是mvvm框架,基本原理是将Vdom转化为真实dom并添加到文档流中,所以实现ReactDOM.render方法,render内部将vDom转化为真实dom,并给dom添加属性,再添加到容器中;
ReactDOM.render = function(vDom, container){
container.appendChild(initElement(vDom))
}
function initElement(vDom){ //生成真实dom结构
if(!vDom){
return
}
let dom
if(vDom.type === 'TEXT'){
dom = document.createTextNode("")
} else {
dom = document.createElement(vDom.type)
setAtr(dom, vDom.props)
}
if(vDom.props.children.length > 0){
vDom.props.children.map((child) => {
dom.appendChild(initElement(child))
})
}
setAtr(dom)
return dom
}
function setAtr(dom, props){
if(!props){
return
}
for(let [key, value] of Object.entries(props)){
if(key !== 'children'){
// 添加样式
if(key === 'style'){
if(value && typeof value === 'object'){
for(let i in value){
dom.style[i] = value[i]
}
} else {
dom.removeAttribute(key) // 样式对象不存在或者样式格式不对
}
} else if(/on\w+/.test(key)){ // 绑定事件
//\W+:匹配一个或多个非字母进行切割,匹配到的非字母不缓存;
key = key.toLowerCase()
if(value){
dom[key] = value
} else {
dom.removeAttribute(key)
}
} else if(key === 'nodeValue'){
dom.nodeValue=value
} else { // 其他属性
if(value){
dom.setAttribute(key, value)
} else {
dom.removeAttribute(key)
}
}
}
}
}
到此就实现了一个简版react(不包括组件,组件类型在下面介绍)
二,Fiber数据结构实现
上面的递归子节点是不可打断的,由于js是单线程的,如果此时存在其他高级任务渲染,页面会出现卡顿,基于这个问题react设计了一个双向链表结构(Fiber),将递归转化为循环遍历,有高级任务介入时,暂停循环,待高级任务执行后再继续遍历。循环遍历主要利用了浏览器的特性,主要用到requestIdleCallback来判断每一帧是否有空闲时间,利用空闲时间遍历生成fiber树。
思路:
由于遍历fiber暂停后,可以再回到上次暂停的位置继续,所以需要定义一个全局变量nextUnitOfWork,用来存储当前遍历的节点,在任意fiber节点都可以继续遍历整棵树,所以通过fiber节点可以找到其父级节点,兄弟节点,子节点,设置fiber.return指向父级节点,fiber.child指向子节点,fiber.sibling执行兄弟节点,整颗fiber树是双向链表结构。
在遍历fiber的过程中,同步创建fiber节点对应的真实dom
1,将每次遍历看作是一个工作单元,执行完当前工作单元后(performUnitOfWork),返回下一个工作单元,
performUnitOfWork函数实现以下功能
- 创建fiber节点的dom
- 将fiber节点的子节点创建为Fiber结构
- 返回fiber节点的下一个工作单元(要执行的节点)
function createDom(fiber){
let dom = fiber.type === 'TEXT' ? document.createTextNode("") : document.createElement(fiber.type)
setAtr(dom, fiber.props)
return dom
}
let nextUnitOfWork = null
// performUnitOfWork执行工作单元时,将当前节点的子元素都创建为fiber结构
// 遍历vDom和创建fiber节点是同步的
// fiber.props.children可以访问到子节点,根据这个将vDom遍历完毕
function performUnitOfWork(fiber){
if(!fiber.dom){
fiber.dom = createDom(fiber)
}
// 为每个子节点创建dom
let elements = fiber.props.children
let index = 0
let prevSibling = null
// 将当前子节点虚拟dom对象创建为fiber节点
while(index < elements.length){
const element = elements[index]
const newFiber = {
type: element.type,
props: element.props,
return: fiber,
dom: null
}
if(index === 0){ //如果是第一个元素,就设置为子节点
fiber.child = newFiber
} else {
prevSibling.sibling = newFiber // 如果不是第一个元素,则指向上一个元素的兄弟节点
}
prevSibling = newFiber //每次循环,缓存当前节点
index++
}
// 上面创建fiber节点,下面遍历fiber节点,将其子节点再次传入performUnitOfWork
if(fiber.child){
return fiber.child
}
let nextFiber = fiber
while(nextFiber){
if(nextFiber.sibling){
return nextFiber.sibling
}
nextFiber = nextFiber.return
}
}
2,设置一个循环函数workLoop,浏览器每一帧有空闲时间时执行workLoop,workLoop内部执行performUnitOfWork,直到nextUnitOfWork为null,如果存在工作单元,将workLoop赋值给浏览器,在浏览器空余时间执行workLoop
function wookLoop(deadline){
let shouldYield = false //是否有剩余时间
// 有空余时间的情况下,继续渲染
while(nextUnitOfWork && !shouldYield) { // 链表结构没有渲染完毕并且有空余时间
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
shouldYield = deadline.timeRemaining < 1
}
// 没有空余时间,交给浏览器等待执行
if(nextUnitOfWork){
requestIdleCallback(wookLoop)
}
}
3,设置全局变量wipRoot存储链表根节点,ReactDOM.render时,初始化第一个fiber节点赋值给wipRoot,并将其赋值给nextUnitOfWork
let wipRoot = null
ReactDOM.render = function(vDom, container){
wipRoot = {
dom: container,
props: {
children: [vDom]
}
}
nextUnitOfWork = wipRoot
// 触发workLoop
requestIdleCallback(wookLoop)
}
4,当nextUnitOfWork不存在时,证明已经生成fiberTree,此时要提交整个渲染
function wookLoop(deadline){
let shouldYield = false //是否有剩余时间
// 有空余时间的情况下,继续渲染
while(nextUnitOfWork && !shouldYield) { // 链表结构没有渲染完毕并且有空余时间
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
shouldYield = deadline.timeRemaining < 1
}
// 没有空余时间,交给浏览器等待执行
if(nextUnitOfWork){
requestIdleCallback(wookLoop)
}
// nextUnitOfWork不存在,也就是fiber已经全部创建
if(!nextUnitOfWork && wipRoot){
// 执行完成,提交渲染
commitRoot()
}
}
5,commitRoot用来遍历fiber树,将dom添加到文档流
/**
* 转化为双向链表结构后,开始提交和渲染真实dom
* 提交整个fiber树(双向链表)
*/
function commitRoot(){
commitWork(wipRoot.child)
wipRoot = null
}
/**
* 节点真实dom提交到父级dom中
*/
function commitWork(fiber){
if(!fiber){
return
}
const parentDom = fiber.return.dom;
parentDom.appendChild(fiber.dom)
commitWork(fiber.child)
commitWork(fiber.sibling)
}
以上实现了fiber数据结构和fiber双向链表树
总结:fiber架构相当于把原来的递归执行权限让给了浏览器,让浏览器在空闲情况下执行递归,相当于循环遍历vDom,可中断操作。
三,fiber diff算法实现
要实现数据结构对比,首先要缓存上次的fiber树,对比后需要删除旧树上没用的节点
1,设置全局变量存储fiber树,收集要删除的节点,reactDOM.render执行时,fiber添加alternate属性指向上次的fiber树,重置deletions
let currentRoot = null //上一次提交的fiber树
let deletions = []
ReactDOM.render = function(vDom, container){
wipRoot = {
dom: container,
props: {
children: [vDom]
},
alternate: currentRoot, // 上一次提交的fiber
}
nextUnitOfWork = wipRoot
deletions = [] // 渲染开始前将deletions重置为空
// 触发workLoop
requestIdleCallback(wookLoop)
}
2,从根节点开始,采用深度优先遍历,和旧的fiber树对比并生成新的fiber树
function performUnitOfWork(fiber){
if(!fiber.dom){ //如果当前节点上没有真实节点,创建fiber对应的真实节点
fiber.dom = createDom(fiber)
}
elements = fiber.props.children
// 加入diff算法,对比新老节点
reconcileChildren(fiber, elements) //说明 在遍历当前节点时,会遍历其所有子节点,并给子节点和子节点的兄弟节点设置alternate, 设置过之后,在下次递归中,刚好可以对比新老节点
if(fiber.child){
return fiber.child
}
let nextFiber = fiber //缓存当前fiber
while(nextFiber){
if(nextFiber.sibling){ //返回当前节点的兄弟节点
return nextFiber.sibling
}
// 如果不存在兄弟节点,就返回父级节点,进而去查找父级节点的兄弟节点,如果没有也没有兄弟节点,则继续向上返回
nextFiber = nextFiber.return
}
}
3,执行fiber节点时,创建的fiber节点添加属性alternate指向对应的oldFiber,在对比新老节点时,可以做到相同层级相同位置进行比较
alternate对应
对比老节点同时生成新的fiber,在新的fiber上打上不同的effecTag,在commit阶段执行不同的操作
将要删除的fiber放入deletions
这里还有一个特别注意的点,当新老节点类型不同,需要将老节点删除,添加新节点时需要找到老节点所在的位置,这时需要记录新插入的节点对应子元素的序号
/**
* 对比新老节点,并为节点创建fiber结构
* @param {*} wipFiber 当前节点
* @param {*} elements 当前节点的子节点
* 遍历当前节点时,设置其子元素和子元素兄弟元素的alternate,当遍历到子元素和兄弟元素时,刚好可以访问到alternate
* 遍历同时,先设置alternate,后面再对比新老节点
*/
function reconcileChildren(wipFiber, elements){
let index = 0;
let prevSibling = null;
// 获取老节点
let oldFiber = wipFiber.alternate && wipFiber.alternate.child
while(index < elements.length || oldFiber != null){ // 子元素存在 或者 当前节点的旧节点存在
const element = elements[index]
let sameType = element && oldFiber && element.type === oldFiber.type
let newFiber
console.error('sameType', element.type, sameType)
if(sameType){ //节点类型相同,比如均为div
newFiber = {
return: wipFiber,
props: element.props,
type: element.type,
dom: oldFiber.dom,
alternate: oldFiber, //这里将和当前节点对应的旧的fiber赋值记录下来,当比对当前节点子节点时,用来获得对应的旧的节点对象
effectTag: 'UPDATE'
}
}
if(element && !sameType){// 节点类型不同,比如新节点是span, 老节点是div
newFiber = {
return: wipFiber,
props: element.props,
type: element.type,
dom: null, //重新创建dom
alternate: null,
effectTag: 'ADD',
childIndex: index,// 用来记录当前元素是第几个子元素,在新节点是新类型时,根据index找到dom插入位置
siblingNum: elements.length
}
}
if(oldFiber && !sameType){
newFiber.effectTag = "PLACEMENT" // 新老节点都存在时,类型不同,直接替换为新节点,删除老节点
oldFiber.effectTag="DELETION";
deletions.push(oldFiber) // 收集要删除的节点
}
// 每次while循环结束后, oldFiber指向其兄弟节点
if(oldFiber){
oldFiber = oldFiber.sibling
}
if(index === 0){
wipFiber.child = newFiber
} else {
prevSibling.sibling = newFiber
}
prevSibling = newFiber
index++
}
}
4,commitRoot改造,提交前删除没用的旧节点,提交后缓存currentRoot
/**
* 转化为双向链表结构后,开始提交和渲染真实dom
* 提交整个fiber树(双向链表)
*/
function commitRoot(){
//处理要删除的节点
deletions.forEach(commitWork)
commitWork(wipRoot.child)
currentRoot = wipRoot // 缓存上次提交的fiber树
wipRoot = null
}
/**
* 节点真实dom提交到父级dom中
*/
function commitWork(fiber){
if(!fiber){
return
}
const parentDom = fiber.return.dom;
if(fiber.effectTag === 'UPDATE' && fiber.dom != null){
updateDom(fiber.dom, fiber.alternate.props, fiber.props) //更新节点属性
} else if(fiber.effectTag === 'DELETION'){
domParent.removeChild(fiber.dom)
} else if(fiber.effectTag === 'PLACEMENT' && fiber.dom != null){
if(fiber.childIndex < fiber.siblingNum){ // 中间元素
parentDom.insertBefore(fiber.dom, fiber.sibling.dom)
} else {
parentDom.appendChild(fiber.dom)
}
} else if(fiber.effectTag === 'ADD' && fiber.dom != null){
parentDom.appendChild(fiber.dom)
}
// 处理子节点和兄弟节点
commitWork(fiber.child)
commitWork(fiber.sibling)
}
5,updateDom更新dom属性,由于该类型时复用dom,只需要对比props即可,简单实现如下,同时createDom也使用updateDom
function createDom(fiber){
let dom = fiber.type === 'TEXT' ? document.createTextNode('') : document.createElement(fiber.type)
// 添加属性,文本节点除外
updateDom(dom, {}, fiber.props)
return dom
}
// 前缀是 on 返回是 true
const isEvent = (key) => key.startsWith("on");
// 去掉 children 和 on 开头的
const isProperty = (key) => key !== "children" && !isEvent(key);
// 前一次 和 本次 不同
const isNew = (prev, next) => (key) => prev[key] !== next[key];
// 过滤 匹配 下一次 中没有的值
const isGone = (prev, next) => (key) => !(key in next);
function updateDom(dom, prevProps, nextProps){
// 清空 旧 事件
Object.keys(prevProps)
.filter(isEvent)
.filter((key) => !(key in nextProps) || isNew(prevProps, nextProps)(key))
.forEach((name) => {
const eventType = name.toLowerCase().substring(2);
dom.removeEventListener(eventType, prevProps[name]);
});
// 清空 旧 的值
Object.keys(prevProps)
.filter(isProperty)
.filter(isGone(prevProps, nextProps))
.forEach((name) => {
dom[name] = "";
});
// 设置 新 事件
Object.keys(nextProps)
.filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach((name) => {
const eventType = name.toLowerCase().substring(2);
dom.addEventListener(eventType, nextProps[name]);
});
// 设置 新 的值
Object.keys(nextProps)
.filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach((name) => {
if(dom instanceof Object && dom.setAttribute){
// 设置样式
if(name === 'style'){
const value = nextProps[name]
// console.log('style value', value)
if(value && typeof value === 'object'){
for(let i in value){
dom.style[i] = value[i]
}
} else {
dom.removeAttribute(name) // 样式对象不存在或者样式格式不对
}
} else if(name === 'nodeValue'){
dom.nodeValue=nextProps[name]
} else {
dom.setAttribute(name, nextProps[name]);
}
}else{
// console.log('设置样式', name)
dom[name] = nextProps[name];
}
});
}
以上实现了Fiber的diff算法
总结:由于创建新的fiber节点时,属性alternate指向了旧fiber,fiber新旧树在对比时,是同层级同位置进行比较,提高了计算效率,算法对比有三种情况:
1,添加新节点
2,替换为新节点,删除旧节点
3,修改节点属性
四,useState实现
1,useState主要应用在函数式组件中,实现useState之前,需要先兼容节点是组件的场景
分析:
组件经过babel转义后,得到的type类型是函数,因此要得到函数内部的dom,需要先执行该函数,将performUnitOfWork进行如下改造
function performUnitOfWork(fiber){
let elements;
// 判断fiber是不是组件
if(typeof fiber.type === 'function'){
// 判断是否是类组件
if(fiber.type.prototype.render){
elements = [fiber.type.prototype.render(fiber.props)] //兼容类组件
} else {
elements = [fiber.type(fiber.props)]
}
} else {
if(!fiber.dom){ //如果当前节点上没有真实节点,创建fiber对应的真实节点
fiber.dom = createDom(fiber)
}
elements = fiber.props.children
}
// 加入diff算法,对比新老节点
reconcileChildren(fiber, elements) //说明 在遍历当前节点时,会遍历其所有子节点,并给子节点和子节点的兄弟节点设置alternate, 设置过之后,在下次递归中,刚好可以对比新老节点
if(fiber.child){
return fiber.child
}
let nextFiber = fiber //缓存当前fiber
while(nextFiber){
if(nextFiber.sibling){ //返回当前节点的兄弟节点
return nextFiber.sibling
}
nextFiber = nextFiber.return
}
}
2,由于组件自身没有dom结构,所以在更新dom时,需要判断节点父级是否是组件,如果是组件则继续向上查找;
在删除无用dom元素时,要判断当前fiber节点是否是组件,如果是组件,则要删除其内部真实dom;
commitRoot, updateDom做以下修改
function commitRoot(){
//处理要删除的节点
deletions.forEach(commitWork)
commitWork(wipRoot.child)
currentRoot = wipRoot // 缓存上次提交的fiber树
wipRoot = null
}
function commitWork(fiber){
if(!fiber){
return
}
const parentDom = findParentDom(fiber) //函数式组件的dom为null, 这里要再向上查找,直到找到存在dom的父级
if(fiber.effectTag === 'UPDATE' && fiber.dom != null){
updateDom(fiber.dom, fiber.alternate.props, fiber.props) //更新节点属性
} else if(fiber.effectTag === 'DELETION'){
commitDeletion(fiber, parentDom) // 存在组件后,fiber.dom有可能为null
} else if(fiber.effectTag === 'PLACEMENT' && fiber.dom != null){
if(fiber.childIndex < fiber.siblingNum){ // 中间元素
parentDom.insertBefore(fiber.dom, fiber.sibling.dom)
} else {
parentDom.appendChild(fiber.dom)
}
} else if(fiber.effectTag === 'ADD' && fiber.dom != null){
parentDom.appendChild(fiber.dom)
}
// 处理子节点和兄弟节点
commitWork(fiber.child)
commitWork(fiber.sibling)
}
function commitDeletion(fiber, domParent){
if(fiber.dom){
domParent.removeChild(fiber.dom)
}else{
commitDeletion(fiber.child, domParent) // 移除真实dom
}
}
function findParentDom(fiber){
if(fiber.return.dom){
return fiber.return.dom
} else {
return findParentDom(fiber.return)
}
}
3,useState实现
分析:
在函数式组件中,每次执行都会调用useState,所以在调用useState时,需要判断是否已经有状态存在,所以每个组件需要有一个变量存储状态
useState可能被调用多次,生成多个状态,存储状态的变量可以看作一个数组队列,存储多个状态
设置状态变量hooks = [],为了保证hooks只有一个,将其绑定在对应的组件fiber节点上
实现如下
在调用useState时,需要访问当前组件fiber节点,可以设置全局变量wipFiber,在performUnitOfWork执行当前节点时,把当前fiber赋值给wipFiber,并设置wipFiber.hooks=[]
定义全局hookIndex对应状态索引,初始为0,每调用一次useState, hookIndex+1
performUnitOfWork执行节点时,将hookIndex重置为0
let wipFiber = null;//本次操作的节点,在组件中赋值
let hookIndex = 0; //状态索引初始值
function performUnitOfWork(fiber){
let elements;
// 判断fiber是不是组件
if(typeof fiber.type === 'function'){
wipFiber = fiber
hookIndex = 0 // 当前状态对应的是哪个hooks,状态索引
wipFiber.hooks = [] //在组件上定义一个队列,用于存储状态,一个组件会有多个状态
// 判断是否是类组件
if(fiber.type.prototype.render){
elements = [fiber.type.prototype.render(fiber.props)]
} else {
elements = [fiber.type(fiber.props)]
}
} else {
if(!fiber.dom){ //如果当前节点上没有真实节点,创建fiber对应的真实节点
fiber.dom = createDom(fiber)
}
elements = fiber.props.children
}
reconcileChildren(fiber, elements) //说明 在遍历当前节点时,会遍历其所有子节点,并给子节点和子节点的兄弟节点设置alternate, 设置过之后,在下次递归中,刚好可以对比新老节点
if(fiber.child){
return fiber.child
}
let nextFiber = fiber //缓存当前fiber
while(nextFiber){
if(nextFiber.sibling){ //返回当前节点的兄弟节点
return nextFiber.sibling
}
nextFiber = nextFiber.return
}
}
实现简版useState
function useState(initial){
// 获取老的状态对象
const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex]
//当前状态值创建
const hook = {
state: oldHook ? oldHook : initial,
}
wipFiber.hooks.push(hook)
hookIndex ++;
return [hook.state]
}
4,给useState添加修改状态函数
useState调用时,返回setState函数,用来修改对应状态
setState是和单个状态相对应的,所以可以给每个状态设置一个事件队列queue,存储操作状态的函数
setState执行后将操作放进对应queue中,并且发起workLoop,重新渲染组件
在重新渲染组件时,会再次调用useState,此时在返回state之前,先调用state对应的queue函数,返回修改后的函数
function useState(initial){
// 获取老的状态对象
const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex]
//当前状态值创建
const hook = {
state: oldHook ? oldHook : initial,
queue:[] //用来存储操作状态的动作
}
const actions = oldHook ? oldHook.queue : []
// 状态是在下一次渲染时,更新上次修改的状态
actions.forEach(action => {
if( typeof action === 'function'){
hook.state = action(hook.state)
} else {
hook.state = action
}
})
// 定义一个收集action的函数,setState
const setState = action => {
hook.queue.push(action)
// 触发更新
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
}
nextUnitOfWork = wipRoot
deletions = []
requestIdleCallback(wookLoop)
}
wipFiber.hooks.push(hook)
hookIndex ++;
return [hook.state, setState]
}
说明:
setState没有在执行后立即改变状态,而是先存储操作,在下次更新前,修改状态
state在组件上的存储是队列形式,每次是根据hookIndex获取,所以不能用在判断条件中
以上实现了useState
五,实例
function App(props){
const [count, setCount] = useState(0)
return <div className = "test-com" onClick={() => {console.log("test")}}>
<div>测试</div>
{
count == 0 ? <div>当前count是0</div> : <div style={
{color:'red'}}>当前大于0,count值为{count}</div>
}
<button onClick={() => {setCount(count+1)}}>点击增加</button>
<Page></Page> // 组件嵌套
</div>
}
function Page(){
return <div>页面demo</div>
}
ReactDOM.render(<App></App>, container)
总结:
1,fiber架构在遍历vDom树同时创建fiber节点,利用浏览器控制递归执行权限,fiber diff算法在生成新的fiber时,给每个fiber节点添加alternater属性指向旧节点,对比时实现同层级同位置比较
2,fiber架构将渲染分为两个阶段,生成带标签的的fiber树(reconlice),提交和渲染fiber树(commit)
3,useState采用函数式编程,引入函数副作用存储状态和操作
更多内容关注公众号:前端361