阅读 3284

我知道的 React 一些原理

本文用于记录最近学习的 React 原理,如果有错误或者不严谨的地方,烦请给予指正,十分感谢。如果喜欢或者有所启发,欢迎点赞,对作者也是一种鼓励。如果还有 React 相关的原理没有写进来,欢迎留言补充,我会持续更新。谢谢 🙏

合成事件

事件委托

更多 DOM 事件参见 juejin.cn/post/684790…

先讲一下前置知识:事件委托。事件委托利用的是事件冒泡,将子元素相同的事件处理合并到父元素上统一处理。例子 🌰:如下的 ul 中,想实现点击每个 li 标签,打印出 li 标签的内容。

<ul id="poem">
    <li>窗前明月光</li>
    <li>疑似地上霜</li>
    <li>举头望明月</li>
    <li>低头思故乡</li>
</ul>
<script>
    const liList = document.getElementsByTagName("li");
    for(let i=0;i<liList.length;i++){
        liList[i].addEventListener("click", function(e){
            console.log(e.target.innerHTML);
        })
    }
</script>
复制代码

上面是比较粗糙的一种实现方式。利用事件委托,其实我们可以将 click 的逻辑统一在父元素 ul 上处理,如下:

const ul = document.getElementById("poem");
ul.addEventListener("click", function(e){
    console.log(e.target.innerHTML);
})
复制代码

合成事件

如果你还认为自己写的 <button onClick={this.addChange}>累加</button> 会被绑定在原生 DOM 元素 button 上的话,就错了。

事实上,直接打印 addChange = (e)=>{ console.log(e); } 就知道 e 的原型对象是 SyntheticEvent 类而不是 MouseEvent  对象。

React 合成事件(SyntheticEvent)是 React 模拟原生 DOM 事件所有能力的一个自定义事件对象,可以理解为浏览器原生事件的跨浏览器包装器。它根据 W3C 规范 来定义合成事件,兼容所有浏览器,拥有与浏览器原生事件相同的接口。

合成事件的原理是利用事件冒泡,通过 事件委托 把事件统一在 document 这个 DOM 节点上。

合成事件的目的跨浏览器执行,实现更好的跨平台。同时,React 引入事件池,避免频繁绑定和解绑事件,方便事件统一管理和事务机制(下文会讲到事务机制)。

React 合成事件和 DOM 原生事件不同,不要混用。使用 React 合成事件 e.stopPropagation(); 并不能阻止原生 DOM 事件冒泡。

class setStateDemo extends React.Component{
    render(){
        return <div>
            <div onClick={this.clickDiv}>
                <button onClick={this.addChange}>累加</button>
            </div>
        </div>
    }

    clickDiv = ()=>{
        console.log("div clicked");
    }
    
    addChange = (e)=>{
        e.stopPropagation();
        console.log("btn clicked");
   }
    bodyClickHandler = (e)=>{
        console.log("bodyClickHandler");
    }
    componentDidMount(){
        document.body.addEventListener('click', this.bodyClickHandler);
    }
    componentWillUnmount(){
        document.body.removeEventListener('click', this.bodyClickHandler)
    }
}
复制代码

最后一个问题是:React 事件与原生事件执行顺序。直接说下结论,大家可以写代码测试下:

  • React 所有事件都挂载在 document 对象上;
  • 当真实 DOM 元素触发事件,会冒泡到 document 对象后,再处理 React 事件;
  • 所以会先执行原生事件,然后处理 React 事件;
  • 最后真正执行 document 上挂载的事件。

zhuanlan.zhihu.com/p/25883536
www.jianshu.com/p/fb3199e75… blog.csdn.net/qq_36380426…

setState 是同步还是异步

先来一道面试题压压惊,哈哈哈 🤣

class Test extends React.Component {
  state  = {
      count: 0
  };
  componentDidMount() {
    this.setState({count: this.state.count + 1});
    console.log(this.state.count);

    this.setState({count: this.state.count + 1});
    console.log(this.state.count);

    setTimeout(() => {
      this.setState({count: this.state.count + 1});
      console.log(this.state.count);

      this.setState({count: this.state.count + 1});
      console.log(this.state.count);
    }, 0);
}
  render() {
    return null;
  }
};
复制代码

上面的输出结果是什么呢?我们这里先卖个关子,继续向下看。

setState 设计是合并异步更新的。为什么是合并异步呢?如上的前后 this.setState({count: this.state.count + 1}); 执行了两次,如果没有这种合并异步机制,页面岂不是要立即刷新两遍。两次还好,来一个 for 循环做上一万次、十万次赋值操作那页面不是要卡死崩溃了。所以 setState 不是立即触发更新数据的,而是会先将这些 setState 合并起来,等到 “时机成熟”,执行更新。什么时候是 “时机成熟” 呢?有一个变量 isBatchingUpdates 来控制是否更新 state 的值。具体逻辑如下:

setState 的时候会根据 isBatchingUpdates 的值来判断接下来的操作,如果 isBatchingUpdates 是 true,则先将 setState 缓存起来,不进行更新;当 isBatchingUpdates 是 false 的时候,触发 state 值的更新,循环更新 dirtyComponents 所有组件。

在 React 中的合成事件和生命周期钩子函数执行前,React 已经悄悄将 isBatchingUpdates 置为 true,这样可以保证去合并 setState 操作,避免频繁触发 state 的修改,导致页面频繁回流和重绘。

而对于 setTimeout、setInterval 中执行 setStatesetTimeout、setInterval 属于宏任务,执行相关操作的时候,React 已经完成一轮等待,isBatchingUpdates 已经被修改为 false 了,这个时候 setState 会触发 state 的直接修改。

另一方面是对于原生的事件,并没有 React 外层的封装与拦截,无法更新 isBatchingUpdates 的状态为 true。这就造成 isBatchingUpdates 的状态只会为 false,且立即执行。所以在 addEventListener 、setTimeout、setInterval 这些原生事件中都会同步更新。

对于 setState 的合并逻辑是怎样的呢? 回到上面的面试题,控制台会依次输出
0 0 2 3

为什么呢?

this.setState({count: this.state.count + 1});
console.log(this.state.count);

this.setState({count: this.state.count + 1});
console.log(this.state.count);
复制代码

componentDidMount 执行前,isBatchingUpdates 已经被置为 true 了,那么第一遍和第二遍执行 this.setState({count: this.state.count + 1}); 时访问到的 this.state.count 都是 0, 所以相当于执行:

this.setState({count: 1});  
this.setState({count: 1});
复制代码

所以会输出 0 0
之后,对于 setTimeout 函数中,我们知道 isBatchingUpdates 已经是 false 了,所以 this.state.count 的值会立即被修改。所以会依次输出 2 3

不想被合并

setState 怎么能不被 “合并” 呢?执行了几次就是几次,其实我们可以给 setState 传入一个函数,每次都是在之前数据的基础上操作(保证获取的都是最新的数据),而不是在 this.state.count 的基础上,看代码:

addFn = ()=>{
    this.setState((prevState, props)=>{
        return{
            count: prevState.count + 1
        }
    });
    this.setState((prevState, props)=>{
        return{
            count: prevState.count + 1
        }
    });
}
复制代码

JSX 是什么

JSX 是什么?JSX 是什么?JSX 是什么啊? 😂 灵魂三连问。

JSX 是 JavaScript 的一种语法扩展,它和模板语言很接近,但是它充分具备 JavaScript 的能力。

上面是 React 官网给出的定义。我们可以白话白话:JSX 是 JavaScript 的一种语法扩展,它起到了模板的作用,类似于 Vue 的模板。

JSX 离不开 babel 的转义,我们可以在 Babel 官网对一段 JSX 代码进行转义,如下:

Babel 会把 JSX 转译成一个名为 React.createElement() 函数调用。我们可以理解 JSX 是 React.createElement() 函数的 语法糖。避免我们使用 React.createElement() 吭哧吭哧写 React.createElement() 函数需要的各种参数, JSX 语法以一种我们普遍能接受、学习成本低的、父子元素结构清晰的方式展示了我们想要表达的 “模板”。

React.createElement() 函数返回的是什么? 是 “React 元素”,即 虚拟 DOM。

  • React.createElement(); 函数 负责生成 “React 元素” 虚拟 DOM。
  • ReactDOM.render(); 函数 负责将虚拟 DOM 转为真实的 DOM。

为什么 class 组件要绑定 this

在 class 组件中,如果我们要给元素绑定事件的时候如果没有使用箭头函数的形式,需要在构造函数中添加 this.add = this.add.bind(this); ,对 add 函数的 this 进行绑定。为什么呢?

class Counter extends React.Component{
    constructor(props){
      super(props);
      this.state = { count: 0};
      this.add = this.add.bind(this);
    }
    add(){
        this.setState({
            count: this.state.count + 1
        });
    }
    render(){
        return (
            <div>
                <button onClick={this.add}>普通 +1</button>
            </div>
        )
    }
}
复制代码

对于 ES6 是在严格模式下运行的,那么在严格模式下我们先看下简单的逻辑的 this 指向:

"use strict";
function a(){
  console.log(this);
}
a();
console.log(this);
复制代码

在严格模式下,全局 this 指向 window;在函数中 this 指向 undefined。

"use strict";
class Counter{
    constructor(){
        this.name = "Tom";
    }
    say(){
        console.log(this);
    }
}
const c = new Counter();
c.say();
const sayTmp = c.say;
sayTmp();
复制代码

对于 Counter 的实例 c 调用 say 方法,可以看到 this 指向了实例对象 c.( new 操作符可以改变 this 的指向 juejin.cn/post/692309… 有详细介绍。)

但是我们以变量的形式赋值给 sayTmp, 执行,会发现 this 是 undefined 的。这也是符合预期的。 sayTmp 函数没有指向任何变量,所以是 undefined 的。

上面我们已经知道 JSX 最终会被转义为 React.createElement() 函数调用,生成虚拟 DOM,然后通过 ReactDOM.render(); 函数渲染到页面。也就是 Counter 组件会被转义如下:

"use strict";
class Counter{
    constructor(){
        this.name = "Tom";
    }
    say(){
        console.log(this);
    }
    render(){
        createElement(this.say);
    }
}
// 模拟 ReactDOM.render(); 
const createElement = function(cur){
    const p = document.createElement("p");
    p.innerHTML = "react dom";
    p.addEventListener("click", function(){
        cur();
    });
    document.body.append(p);
}

new Counter().render();
复制代码

当我们点击页面上的 p 元素的时候,执行绑定的 click 事件,cur 被穿进去执行,相当于 const cur = this.say;。之后调用 cur 会发现 this 是 undefined。正因为此,需要在 Counter 的构造函数中对 this.say 绑定 this。

生命周期

先上来一个比较全的 React 生命周期图地址: projects.wojtekmaj.pl/react-lifec…

我们这里介绍 16.4 之后的 React 生命周期过程。常用的有 componentDidMount、shouldComponentUpdate、componentDidUpdate、 componentWillUnmount。
相关的函数一共有 8 个,分别是 constructor、 getDerivedStateFromProps、 render、 componentDidMount、shouldCompopnentUpdate、 getSnapshotBeforeUpdate、 componentDidUpdate、componentWillUnmount

挂载阶段

执行顺序是: constructor getDerivedStateFromProps render componentDidMount

介绍主要的函数:componentDidMount
componentDidMount: 组件装载之后调用,此时我们可以获取 DOM 节点并操作,比如对 canvas,svg 的操作,服务器请求,订阅都可以写在这里。记得在 componentWillUnmount 中取消订阅。

更新阶段

执行顺序是: getDerivedStateFromProps shouldCompopnentUpdate render getSnapshotBeforeUpdate componentDidUpdate

介绍主要的函数:shouldComponentUpdate、componentDidUpdate

shouldComponentUpdate(nextProps, nextState): 默认值返回 true。也就是会触发 render 以及之后的生命周期函数的执行。所以默认情况下父组件的 state 的修改会导致子组件走更新流程,即使父组件的 state 没有传递给子组件。子组件进行了多余的重复渲染,我们可以利用 PureComponent 组件进行优化,或者在 shouldComponentUpdate(nextProps, nextState) 函数中增加判断逻辑,确定子组件是否值得重新渲染。

componentDidUpdate(newProps, newState, Snapshot): 组件数据更新完成时触发的函数,参数如下:
newProps: 新的 props
newState: 新的 state
Snapshot:由 getSnapshotBeforeUpdate 返回的

卸载阶段
componentWillUnmount: 当我们的组件被卸载或者销毁了就会调用,我们可以在这个函数里去清除一些定时器,取消网络请求,清理无效的DOM元素等垃圾清理工作

为什么要废弃有些生命周期

React 16 之后废弃的生命周期函数是:

  • componentWillMount
  • componentWillReceviesProps
  • componentWillUpdate

这些生命周期函数在 render 之前,处于 “Render 阶段”,Fiber 机制的出现,这个阶段可能会被 React 暂停,中止或重新启动。同时,开发者有时会在上述函数中做一些操作如发送请求,这就导致了这些请求可能会被执行多次。为了避免这些危险的操作,干脆把这些冗余的生命周期函数去掉。

函数组件

React 中函数组件和 class 组件都是我们经常使用的组件实现方式。函数组件内部没有 state,常作为 UI组件,只做渲染使用。函数组件又被称为无状态组件。

class 组件我们是 extends React.Component 实现,内置了生命周期等函数。类组件的能力边界明显强于函数组件。

两者还有一个重要区别是:函数组件会捕获 render 内部的状态,类组件不会;函数组件真正地把数据和渲染绑定到了一起。

React 作者 Dan 早期特意为类组件和函数组件写过的一篇非常棒的对比文章 overreacted.io/how-are-fun… 文章中经典的对比在线例子: codesandbox.io/s/pjqnl16lm…

React Hooks 原理

React Hooks 快速入门文章: juejin.cn/post/692715…

Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。Hook 在 class 内部是不起作用的。但你可以使用它们来取代 class 。

Hook 可以做什么?

  • 在函数组件中使用 state (useState)
  • 将组件的状态逻辑进行抽离(自定义 hook)

我们从 React Hooks 的使用规则说起:

  • 只在最顶层使用 Hook 不要在循环、条件或嵌套函数中调用 Hook。确保总是在 React 函数的最顶层调用他们,确保 Hook 在每一次渲染中都按照同样的顺序被调用。

  • 只在 React 函数中调用 Hook,不要在普通的 JavaScript 函数中调用 Hook。

对于第二条很好理解, React-Hooks 本身就是 React 组件的“钩子”,在普通函数里引入意义不大。对于第一条为什么要遵循 Hook 在每一次渲染中按照同样的顺序被调用呢?

说白了, React Hooks 本质上是一个函数,这个函数在每次 state 修改的时候会从头到尾(从第一行代码到最后一行代码)执行一遍。Hooks 的正常运作,在底层依赖于顺序链表。 第一次函数组件 React Hooks 执行,多个useStateuseEffect 函数执行的时候会按顺序存入到链表中。第二次或者第 n 次函数组件 React Hooks 执行,useStateuseEffect 函数执行,会依次从缓存的链表中索引查找对应的信息。如果某一次调用useStateuseEffect 的顺序(少了或者多了)发生了变化会导致链表查找的信息错误,出现 bug。

如果我们想要有条件地执行一个 effect,可以将判断放到 Hook 的内部:

useEffect(function persistForm() {
    // 👍 将条件判断放置在 effect 中
    if (name !== '') {
      localStorage.setItem('formData', name);
    }
});
复制代码

推荐 React Hooks 原理: github.com/brickspert/…

Fiber Reconciler

背景

React 的工作过程可以分为两个阶段:

  • 调和阶段(Reconciler): React 会自顶向下通过递归,遍历新数据生成新的 Virtual DOM,然后通过 Diff 算法,找到需要变更的元素(Patch),放到更新队列里面去。
  • 渲染阶段(Renderer): 遍历更新队列,通过调用宿主环境的API,实际更新渲染对应元素。宿主环境,比如 DOM、Native、WebGL 等。

JavaScript 是单线程的,但是浏览器是多线程的。JavaScript 线程和渲染线程是互斥的,这两个线程不能够穿插执行,必须串行。当其中一个线程执行,另一个线程只能挂起等待。

React 16 以前,在协调阶段阶段,由于是采用的递归的遍历方式,这种也被成为 Stack Reconciler。这种方式有一个特点:一旦任务开始进行,就无法中断,那么 js 将一直占用主线程, 一直要等到整棵 Virtual DOM 树计算完成之后,才能把执行权交给渲染引擎,那么这就会导致一些用户交互、动画等任务无法立即得到处理,就会有卡顿,非常的影响用户体验。

破题:

React 16 提出的 Fiber Reconciler 主要为了解决上面的问题。Fiber 实现了自己的组件调用栈,它以链表的形式遍历组件树,将一个庞大的任务分解为一个个的小任务,可以灵活的暂停、继续和丢弃执行的任务。

React Fiber 一个更新过程被分为两个阶段(Phase):

  • 阶段一,生成 Fiber 树,得出需要更新的节点信息。这一步是一个渐进的过程,可以被打断。
  • 阶段二,将需要更新的节点一次过批量更新,这个过程不能被打断。

阶段一存在被打断、被重启的可能,这一阶段的生命周期函数也就有可能被执行多次。恰巧开发者在 componentWillMount、componentWillReceviesProps、getDerivedStateFromProps(nextProps,prevState) 做了一些危险操作,如发送异步请求,这就导致了 bug。因为此,React 16 将上面的 3 个生命周期函数废弃掉了。Fiber 实现原理呼应上文的 “为什么要废弃有些生命周期 ” 章节。

segmentfault.com/a/119000001…

文章分类
前端
文章标签