聊一聊事件驱动编程模式在前端中的常见应用

事件驱动编程模式在前端开发中有广泛的应用,特别是在处理用户交互异步操作组件通信方面。

用户交互

DOM中对用户交互的处理

简介

在前端,用户与页面的交互通常通过DOM事件触发。开发者可以使用事件监听器(Event Listener)来注册特定事件的处理函数,以响应用户的操作,例如点击、键盘输入等。

javascript 复制代码
const button = document.getElementById('myButton');

button.addEventListener('click', function(event) {
  // 处理点击事件
});

浏览器的事件模型,就是通过监听函数(listener)对事件做出反应。事件发生后,浏览器监听到了这个事件,就会执行对应的监听函数。这是事件驱动编程模式(event-driven)的主要编程方式。

JavaScript 有三种方法,可以为事件绑定监听函数:

javascript 复制代码
// html 的 on 属性,只会在冒泡阶段触发。
<body onload="doSomething()">
<div onclick="console.log('触发事件')">

// 元素节点的事件属性,使用这个方法指定的监听函数,也是只会在冒泡阶段触发。
window.onload = doSomething;

div.onclick = function (event) {
  console.log('触发事件');
};
// 使用 EventTarget.addEventListener(),触发阶段取决于第三个参数,true:捕获阶段,fase: 冒泡阶段
window.addEventListener('load', doSomething, false);
  

事件的传播

一个事件发生后,会在子元素和父元素之间传播(propagation)。这种传播分成三个阶段。

阶段 描述
第一阶段 从 window 对象传导到目标节点(上层传到底层),称为"捕获阶段"(capture phase)。
第二阶段 在目标节点上触发,称为"目标阶段"(target phase)。
第三阶段 从目标节点传导回 window 对象(从底层传回上层),称为"冒泡阶段"(bubbling phase)。

这种三阶段的传播模型,使得同一个事件会在多个节点上触发。

html 复制代码
<div>
  <p>点击</p>
</div>
javascript 复制代码
var phases = {
  1: 'capture',
  2: 'target',
  3: 'bubble'
};

var div = document.querySelector('div');
var p = document.querySelector('p');

div.addEventListener('click', callback, true);
p.addEventListener('click', callback, true);
div.addEventListener('click', callback, false);
p.addEventListener('click', callback, false);

function callback(event) {
  var tag = event.currentTarget.tagName;
  var phase = phases[event.eventPhase];
  console.log("Tag: '" + tag + "'. EventPhase: '" + phase + "'");
}

// 点击以后的结果
// Tag: 'DIV'. EventPhase: 'capture'
// Tag: 'P'. EventPhase: 'target'
// Tag: 'P'. EventPhase: 'target'
// Tag: 'DIV'. EventPhase: 'bubble'

p 节点有两个监听函数( addEventListener 方法第三个参数的不同,会导致绑定两个监听函数),因此它们都会因为click事件触发一次。所以,p 会在 targe t阶段有两次输出。

浏览器总是假定click事件的目标节点,就是点击位置嵌套最深的那个节点(本例是 div 节点里面的 p 节点)。所以,p 节点的捕获阶段和冒泡阶段,都会显示为target阶段。

上例的事件传播顺序

事件的代理

由于事件会在冒泡阶段向上传播到父节点,因此可以把子节点的监听函数定义在父节点上,由父节点的监听函数统一处理多个子元素的事件。这种方法叫做事件的代理(delegation)。

如果希望事件到某个节点为止,不再传播,可以使用事件对象的 stopPropagation 方法。

javascript 复制代码
// 事件传播到 p 元素后,就不再向下传播了
p.addEventListener('click', function (event) {
  event.stopPropagation();
}, true);

// 事件冒泡到 p 元素后,就不再向上冒泡了
p.addEventListener('click', function (event) {
  event.stopPropagation();
}, false);

现在我要向大家抛出一个问题,问:下列代码执行后,会打印 2 吗?

javascript 复制代码
p.addEventListener('click', function (event) {
  event.stopPropagation();
  console.log(1);
});

p.addEventListener('click', function(event) {
  console.log(2);
});

答案揭晓:会打印 2。

因为 stopPropagation 只会阻止事件的 传播,不会阻止该事件触发

节点的其他 click 事件的监听函数。

如果希望彻底阻止

节点上其他的 click 事件监听函数被触发,要用到 stopImmediatePropagation。

javascript 复制代码
p.addEventListener('click', function (event) {
  event.stopImmediatePropagation();
  console.log(1);
});

p.addEventListener('click', function(event) {
  // 不会被触发
  console.log(2);
});

读者朋友可以在浏览器中实验一下。

React 中对用户交互的处理

在 React 中使用原生事件

React使用了事件驱动的模型来处理用户交互。组件可以监听并响应特定的事件,例如点击、输入等。事件处理函数通常通过React的props传递给子组件。

javascript 复制代码
class MyComponent extends React.Component {
  handleClick = () => {
    // 处理点击事件
  };

  render() {
    return <button onClick={this.handleClick}>Click me</button>;
  }
}

因为原生事件的机制比较特别,所以我在这里要特别强调一下它的原理:合成事件(Synthetic Events)

由于虚拟 DOM 的存在,在 React 中即使绑定一个事件到原生的 DOM 节点,事件也并不是绑定在对应的节点上,而是所有的事件都是绑定在根节点上。然后由 React 统一监听和管理,获取事件后再分发到具体的虚拟 DOM 节点上。

在 React 17 之前,所有的事件都是绑定在 document 上的,而从 React 17 开始,所有的事件都绑定在 整个 App 上的根节点 上,这主要是为了以后页面上可能存在 多版本 React 的考虑。

这一能力的实现正是基于前文介绍的 浏览器事件的冒泡模型 。无论事件在哪个节点被触发, React 都可以通过事件的 srcElement 这个属性,知道它是从哪个节点开始发出的,这样 React 就可以收集管理所有的事件,然后再以一致的 API 暴露出来。

具体来说,React 这么做的原因主要有两个:

第一,虚拟 DOM render 的时候, DOM 很可能还没有真实地 render 到页面上,所以无法绑定事件。

第二,对跨端能力的支持。React 可以屏蔽底层事件的细节,避免浏览器的兼容性问题。同时呢,对于 React Native 这种不是通过浏览器 render 的运行时,也能提供一致的 API。

用 hooks 封装键盘事件

首先要强调一个概念:Hooks 具备绑定任何数据源的能力

然后来看这样一个场景:

要让某个显示表格的页面,支持通过左右键进行翻页的功能。

没有 hooks 时,可以用的实现方案:在 useEffect 里去做 window.addEventListner,然后在返回的回调函数里去 window.removeEventListner。

有 hooks 之后,可以将这个逻辑封装为 hook,然后在项目中按需调用。

实现:

javascript 复制代码
import { useEffect, useState } from "react";

// 使用 document.body 作为默认的监听节点
const useKeyPress = (domNode = document.body) => {
  const [key, setKey] = useState(null);
  useEffect(() => {
    const handleKeyPress = (evt) => {
      setKey(evt.keyCode);
    };
    // 监听按键事件
    domNode.addEventListener("keypress", handleKeyPress);
    return () => {
      // 接触监听按键事件
      domNode.removeEventListener("keypress", handleKeyPress);
    };
  }, [domNode]);
  return key;
};

使用:

javascript 复制代码
import useKeyPress from './useKeyPress';

function UseKeyPressExample() => {
  const key = useKeyPress();
  return (
    <div>
      <h1>UseKeyPress</h1>
      <label>Key pressed: {key || "N/A"}</label>
    </div>
  );
};

hooks 的出现,可以帮助我们避免重复写这样通用性高的事件处理逻辑。

异步操作

JavaScript 中实现异步操作的基石:事件循环机制

JavaScript 代码是单线程执行的。

有这样两种很常见的情况:

  1. 线程已经开始执行,此时用户点击某个DOM元素,需要执行对应的操作。
  2. 如果是发送了网络请求,在获取到请求结果之前,线程会被阻塞。

要想在线程运行过程中,能接收并执行新的任务,就需要采用 事件循环机制

这一机制引入了两个改进:事件、循环。

当监听到对应 事件 时,会将相应操作推入执行队列。主线程会循环执行,不断将新的事件对应的操作入队。这个执行队列的官方命名为 消息队列

针对前文提出的两种情况:

  1. 线程已经开始执行,此时用户点击某个DOM元素,渲染引擎会将 DOM 对应的点击操作推入消息队列。
  2. 在 Chrome 中除了正常使用的消息队列 之外,还有另外一个消息队列 ,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。发送了网络请求,对请求结果不同状态的操作会被推入延迟队列,释放主线控制权,监听到请求结果之后再在主线程上执行对应操作。

原理模拟如下:

javascript 复制代码
//GetInput
//等待用户从键盘输入一个数字,并返回该输入的数字
int GetInput(){
    int input_number = 0;
    cout<<"请输入一个数:";
    cin>>input_number;
    return input_number;
}

//主线程(Main Thread)
void MainThread(){
     for(;;){
          int first_num = GetInput();
          int second_num = GetInput();
          result_num = first_num + second_num;
          print("最终计算的值为:%d",result_num);
      }
}

提问:当同时触发了两个事件,比如定时器到期时,用户点击了某个 DOM 元素,应当先响应哪个事件呢?

解决这个问题,需要区分事件的优先级。浏览器引入了 微任务 来解决这个问题。

通常我们把消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果 DOM 有变化,那么就会将该变化添加到微任务列表中,这样就不会影响到宏任务的继续执行。等宏任务中的主要功能都直接完成之后,渲染引擎会执行当前宏任务中的微任务。

因此,定时器到期时,用户点击了某个 DOM 元素,会先执行定时器的回调函数,后执行DOM对应的点击监听函数。

消息队列中的任务

宏任务

页面的渲染事件、各种 IO 的完成事件、执行 JavaScript 脚本的事件、用户交互的事件等都随时有可能被添加到消息队列中,而且添加事件是由系统操作的,JavaScript 代码不能准确掌控任务要添加到队列中的位置,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间。

宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的,对一些高实时性的需求就不太符合了。

微任务

微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。

当 JavaScript 执行一段脚本的时候,V8 会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8 引擎也会在内部创建一个 微任务队列

在当前宏任务执行的过程中,有时候会产生多个微任务,存放在这个微任务队列中。

在现代浏览器里面,产生微任务有两种方式:

第一种方式是使用 MutationObserver 监控某个 DOM 节点,然后再通过 JavaScript 来修改这个节点,或者为这个节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。

第二种方式是使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务。

通常情况下,在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候 ,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。WHATWG 把执行微任务的时间点称为检查点

除了在退出全局执行上下文式这个检查点之外,还有其他的检查点,不过不是太重要,这里就不做介绍了。

总结来说,在我们日常开发中要注意三点:

  1. 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。
  2. 微任务的执行时长会影响到当前宏任务的时长,在写代码的时候一定要注意控制微任务的执行时长。
  3. 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行。

另外一个消息队列:延迟队列

在 Chrome 中除了正常使用的消息队列 之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。

在浏览器中,虽然 setTimeout 函数用于将任务放入延迟队列,但并不能完全保证任务会在指定的时间执行。具体而言,以下是一些影响因素:

  1. 最小延迟时间: 浏览器对 setTimeout 的最小延迟时间有限制,通常是4毫秒。这意味着如果你设置的延迟小于4毫秒,浏览器会自动调整为4毫秒。
  2. 事件循环机制: 浏览器是基于事件驱动的,任务的执行依赖于事件循环机制。在当前任务执行完成后,浏览器会检查延迟队列,如果有任务的延迟时间已到,则将其推入执行队列中。
  3. 页面状态: 如果页面处于非激活状态(例如,当前标签页不在前台),浏览器可能会减缓或停止延迟队列的执行,以提高性能和减少资源消耗。

总体而言,浏览器的执行时间是受多种因素影响的,因此 setTimeout 的延迟时间并不能百分之百地精确。如果需要更精确的定时器,可以考虑使用 requestAnimationFrame。

例如,用 requestAnimationFrame 实现一个每隔300ms执行一次的定时器:

javascript 复制代码
function myTimer(timestamp) {
  if (!myTimer.lastTimestamp) {
    myTimer.lastTimestamp = timestamp;
  }

  // 计算时间间隔
  const deltaTime = timestamp - myTimer.lastTimestamp;

  // 判断是否达到间隔时间(300ms)
  if (deltaTime >= 300) {
    // 执行定时任务的逻辑
    console.log('Timer task executed');

    // 更新上一次执行的时间戳
    myTimer.lastTimestamp = timestamp;
  }

  // 继续注册下一帧的定时任务
  requestAnimationFrame(myTimer);
}

// 启动定时器
requestAnimationFrame(myTimer);

异步操作引入的问题及解决方案

使用 Promise 解决回调地狱问题

产生回调地狱的原因:

  1. 多层嵌套的问题;
  2. 每种任务的处理结果存在两种可能性(成功或失败),需要在每种任务执行结束后分别处理这两种可能性。

Promise 通过一系列技术来解决回调地狱问题,包括回调函数延迟绑定、回调函数返回值穿透和错误冒泡技术。

Promise 设计了三种状态:fulfilled, pending, rejected,一旦从 pending 转变为其他两个状态则不可逆。

延迟绑定

Promise 可以使用 then、catch API 延迟绑定回调函数。

Promise 的回调函数延迟绑定是指即使 Promise 对象已经处于 settled(fulfilled 或 rejected)状态,仍然可以通过 这些回调函数会在下一个微任务时执行。

回调函数返回值穿透

当在 Promise 的 then 方法中的回调函数返回一个值时,这个值会被 自动包装为一个新的 Promise 对象。这样,可以在链式调用中传递值,而不需要手动创建新的 Promise。

javascript 复制代码
const promise = new Promise((resolve, reject) => {
  resolve("Initial data");
});

promise
  .then((data) => {
    console.log(data); // 输出:"Initial data"
    return "Processed data";
  })
  .then((processedData) => {
    console.log(processedData); // 输出:"Processed data"
  });

在上面的例子中,第一个 then 回调函数返回的字符串 "Processed data" 被自动包装为新的 Promise,并在下一个 then 中作为参数传递。

错误冒泡技术

在 Promise 链中,如果任何一个 then 方法或 catch 方法抛出了异常,这个异常会向后传递到链中的下一个 catch 处理程序。

javascript 复制代码
const promise = new Promise((resolve, reject) => {
  reject(new Error("Something went wrong"));
});

promise
  .then((data) => {
    console.log(data); // 不会执行
  })
  .catch((error) => {
    console.error(error.message); // 输出:"Something went wrong"
  });

在这个例子中,由于 Promise 的状态被设置为 rejected,错误被传递到了链中的 catch 处理程序。

这两种技术结合在一起,使得 Promise 链中的异常可以被灵活地处理,而不需要在每个 then 方法中都进行错误处理。同时,回调函数返回值穿透也简化了数据在 Promise 链中的传递。

竞态条件

竞态问题,又叫竞态条件(race condition),它旨在描述一个系统或者进程的输出依赖于不受控制的事件出现顺序或者出现时机。在前端中常见于用户一连串的交互,如反复切换 tab 页行为,连续触发请求。

解决竞态条件问题可以用取消请求忽略请求两种方案。

取消请求

XMLHttpRequest
javascript 复制代码
const xhr= new XMLHttpRequest();

xhr.open('GET', 'https://xxx');
xhr.send();
    
xhr.abort(); // 取消请求
fetch API

用 abortController

javascript 复制代码
const controller = new AbortController();
const signal = controller.signal;

fetch('/xxx', {
  signal,
}).then(function(response) {
  //...
});

controller.abort(); // 取消请求
axios

同样用 abortController

javascript 复制代码
const controller = new AbortController();

axios.get('/xxx', {
  signal: controller.signal
}).then(function(response) {
   //...
});

controller.abort() // 取消请求
可取消的 promise
javascript 复制代码
import { createImperativePromise } from 'awesome-imperative-promise';

const { resolve, reject, cancel } = createImperativePromise(promise);

resolve("some value");
// or
reject(new Error());
// or
cancel(); // 实际上是将 resolve 与 cancel 都设置为 null

忽略请求

封装指令式 promise

在每次发送新请求前,cancel 掉上一次的请求,忽略它的回调。可参考 awesome-imperative-promise 中的实现:

javascript 复制代码
function onlyResolvesLast(fn) {
  // 保存上一个请求的 cancel 方法
  let cancelPrevious = null; 

  const wrappedFn = (...args) => {
    // 当前请求执行前,先 cancel 上一个请求
    cancelPrevious && cancelPrevious();
    // 执行当前请求
    const result = fn.apply(this, args); 
    
    // 创建指令式的 promise,暴露 cancel 方法并保存
    const { promise, cancel } = createImperativePromise(result);
    cancelPrevious = cancel;
    
    return promise;
  };

  return wrappedFn;
}

略加改造,即可实现自动忽略:

javascript 复制代码
const fn = (duration) => 
  new Promise(r => {    
    setTimeout(r, duration);  
  });

const wrappedFn = onlyResolvesLast(fn);

wrappedFn(500).then(() => console.log(1));
wrappedFn(1000).then(() => console.log(2));
wrappedFn(100).then(() => console.log(3));

// 输出 3
使用唯一 id 标识每次请求
  1. 利用全局变量记录最新一次的请求 id
  2. 在发请求前,生成唯一 id 标识该次请求
  3. 在请求回调中,判断 id 是否是最新的 id,如果不是,则忽略该请求的回调

伪代码如下:

javascript 复制代码
let fetchId = 0; // 保存最新的请求 id

const getUsers = () => {
  // 发起请求前,生成新的 id 并保存
  const id = fetchId + 1;
  fetchId = id;
  
  await 请求
  
  // 判断是最新的请求 id 再处理回调
  if (id === fetchId) {
    // 请求处理
  }
}

具体实现如下:

javascript 复制代码
function onlyResolvesLast(fn) {
  // 利用闭包保存最新的请求 id
  let id = 0;
  
  const wrappedFn = (...args) => {
    // 发起请求前,生成新的 id 并保存
    const fetchId = id + 1;
    id = fetchId;
    
    // 执行请求
    const result = fn.apply(this, args);
    
    return new Promise((resolve, reject) => {
      // result 可能不是 promise,需要包装成 promise
      Promise.resolve(result).then((value) => {
        // 只处理最新一次请求
        if (fetchId === id) { 
          resolve(value);
        }
      }, (error) => {
        // 只处理最新一次请求
        if (fetchId === id) {
          reject(error);
        }
      });
    })
  };
  
  return wrappedFn;
}

组件通信

原生自定义事件

在某些场景下,开发者可能需要自定义事件来进行组件间的通信。通过创建和触发自定义事件,可以实现更灵活的组件通信。

javascript 复制代码
// 创建自定义事件
const customEvent = new Event('customEvent');

// 监听自定义事件
document.addEventListener('customEvent', function(event) {
  // 处理自定义事件
});

// 触发自定义事件
document.dispatchEvent(customEvent);

React 中的自定义事件

对于一个自定义组件,除了可以从 props 接收参数并用于渲染之外,还很可能需要和父组件进行交互,从而反馈信息。这个时候,我们就需要为组件创建自定义事件。

习惯上我们都会将这样的回调函数命名为 onSomething 这种以" on "开头的名字,方便在使用的时候理解。

javascript 复制代码
import { useState } from "react";

// 创建一个无状态的受控组件
function ToggleButton({ value, onChange }) {
  const handleClick = () => {
    onChange(!value);
  };
  return (
    <button style={{ width: "60px" }} onClick={handleClick}>
      <span>{value ? "On" : "Off"}</span>
    </button>
  );
}

所谓的自定义事件,其实就是利用了属性传递回调函数给子组件,实现事件的触发。

本质上,它和原生事件的机制是完全不一样的,原生事件是浏览器层面的事件,而自定义事件则是纯组件实现的一种机制。

redux 通信方式

redux 简介

在一些状态管理库(如Redux、Vuex等)中,事件驱动的思想被用于管理应用状态。通过派发(dispatch)特定的动作(action),应用状态可以被更新,触发对应的事件来影响页面的渲染。

Redux 引入的概念其实并不多,主要就是三个:State、Action 和 Reducer。

  • 其中 State 即 Store,一般就是一个纯 JavaScript Object。
  • Action 也是一个 Object,用于描述发生的动作。
  • 而 Reducer 则是一个函数,接收 Action 和 State 并作为参数,通过计算得到新的 Store。

state 是当前状态,每当改变state,需要使用一些操作,将这些操作封装起来,按 action 类型来执行。对 state 的操作通常不是单一的,这些操作在使用的时候需要能够查询到,因此有reducer。

可以类比为 大富翁游戏,state 是当前棋子位置,棋子上下左右移动就是不同的 4 个 action,你走棋的时候其实就是在执行一个 reducer。

这个reducer 是什么形式呢?

可简略描述为如下形式:

javascript 复制代码
const counterReducer = (state = 0, action) => {
  switch (action.type) {
    case 'UP':
      // 棋子向上移动
      break;
    case 'DOWN':
      // 棋子向下移动
      break;
    case 'LEFT':
      // 棋子向左移动
      break;
    case 'RIGHT':
      // 棋子向右移动
      break;
    default:
      return state;
  }
};

在函数组件中使用 redux

那么如何建立 Redux 和 React 的联系呢?

主要是两点:

React 组件能够在依赖的 Store 的数据发生变化时,重新 Render。

在 React 组件中,能够在某些时机去 dispatch 一个 action,从而触发 Store 的更新。

使用 react-redux 工具库:

首先配置如下:

javascript 复制代码
import React from 'react'
import ReactDOM from 'react-dom'

import { Provider } from 'react-redux'
import store from './store'

import App from './App'

const rootElement = document.getElementById('root')
ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  rootElement
)

以官方给的计数器例子为例:

javascript 复制代码
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'

export function Counter() {
  // 从 state 中获取当前的计数值
  // useSelector 则让一个组件能够在 Store 的某些数据发生变化时重新 render。
  const count = useSelector(state => state.value)

  // 获得当前 store 的 dispatch 方法
  const dispatch = useDispatch()

  // 在按钮的 click 时间中去分发 action 来修改 store
  return (
    <div>
      <button
        onClick={() => dispatch({ type: 'counter/incremented' })}
      >+</button>
      <span>{count}</span>
      <button
        onClick={() => dispatch({ type: 'counter/decremented' })}
      >-</button>
    </div>
  )
}

如何使用 redux 处理异步逻辑

异步 action,其实就是将这个action分三个状态处理,请求开始、请求成功、请求失败。

如代码所示:

javascript 复制代码
function DataList() {
  const dispatch = useDispatch();
  // 在组件初次加载时发起请求
  useEffect(() => {
    // 请求发送时
    dispatch({ type: 'FETCH_DATA_BEGIN' });
    fetch('/some-url').then(res => {
      // 请求成功时
      dispatch({ type: 'FETCH_DATA_SUCCESS', data: res });
    }).catch(err => {
      // 请求失败时
      dispatch({ type: 'FETCH_DATA_FAILURE', error: err });
    })
  }, []);
  
  // 绑定到 state 的变化
  const data = useSelector(state => state.data);
  const pending = useSelector(state => state.pending);
  const error = useSelector(state => state.error);
  
  // 根据 state 显示不同的状态
  if (error) return 'Error.';
  if (pending) return 'Loading...';
  return <Table data={data} />;
}

如果出现新的异步操作,这样的三个成组的action还要再写一遍,如何解决这种代码重复问题呢?

Redux 中提供了 middleware 这样一个机制,简单来说,middleware 可以让你提供一个拦截器在 reducer 处理 action 之前被调用。

Redux 提供了 redux-thunk 这样一个中间件,它如果发现接受到的 action 是一个函数,那么就不会传递给 Reducer,而是执行这个函数,并把 dispatch 作为参数传给这个函数,从而在这个函数中你可以自由决定何时,如何发送 Action。

javascript 复制代码
import { createStore, applyMiddleware } from 'redux'
import thunkMiddleware from 'redux-thunk'
import rootReducer from './reducer'

const composedEnhancer = applyMiddleware(thunkMiddleware)
const store = createStore(rootReducer, composedEnhancer)

封装代码如下:

javascript 复制代码
function fetchData() {
  return dispatch => {
    dispatch({ type: 'FETCH_DATA_BEGIN' });
    fetch('/some-url').then(res => {
      dispatch({ type: 'FETCH_DATA_SUCCESS', data: res });
    }).catch(err => {
      dispatch({ type: 'FETCH_DATA_FAILURE', error: err });
    })
  }
}

通过这种方式,我们就实现了异步请求逻辑的重用:

javascript 复制代码
import fetchData from './fetchData';

function DataList() {
  const dispatch = useDispatch();
  // dispatch 了一个函数由 redux-thunk 中间件去执行
  dispatch(fetchData());
}

使用 hooks 模拟 redux

使用 Hooks 模拟 Redux 的核心思想是利用 React 的状态管理机制和 useEffect Hook 实现状态的订阅和更新。下面是一个简单的例子,演示如何使用 Hooks 模拟 Redux:

javascript 复制代码
import React, { useState, useEffect } from 'react';

// 创建一个简单的模拟 Redux 的 store
const createStore = (reducer, initialState) => {
  let currentState = initialState;
  const listeners = [];

  const getState = () => currentState;

  const dispatch = action => {
    currentState = reducer(currentState, action);
    listeners.forEach(listener => listener());
  };

  const subscribe = listener => {
    listeners.push(listener);

    return () => {
      listeners.splice(listeners.indexOf(listener), 1);
    };
  };

  return { getState, dispatch, subscribe };
};

// Reducer 函数
const counterReducer = (state = 0, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1;
    case 'DECREMENT':
      return state - 1;
    default:
      return state;
  }
};

// 初始化一个简单的 Redux store
const store = createStore(counterReducer, 0);

// 使用自定义 Hook 来连接 React 组件和模拟的 Redux store
const useRedux = (mapState, mapDispatch) => {
  const [state, setState] = useState(mapState(store.getState()));

  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      setState(mapState(store.getState()));
    });

    return () => {
      unsubscribe();
    };
  }, [mapState]);

  const dispatchProps = mapDispatch(store.dispatch);

  return { ...state, ...dispatchProps };
};

// React 组件
const Counter = () => {
  // 使用自定义 Hook 来连接组件和 Redux store
  const { count, increment, decrement } = useRedux(
    state => ({ count: state }),
    dispatch => ({
      increment: () => dispatch({ type: 'INCREMENT' }),
      decrement: () => dispatch({ type: 'DECREMENT' }),
    })
  );

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
    </div>
  );
};

export default Counter;

发布订阅模式

发布订阅模式是一种设计模式,它允许对象之间松散耦合,使得一个对象(发布者/发布者)可以通知多个其他对象(订阅者/观察者)关于自己的状态变化,而不需要直接知道这些对象的存在。

以一个简单 EventEmitter 实现为例,讲解一下发布订阅模式在前端的应用:

javascript 复制代码
class EventEmitter {
  constructor() {
    // handlers是一个map,用于存储事件与回调之间的对应关系
    this.handlers = {}
  }

  // on方法用于安装事件监听器,它接受目标事件名和回调函数作为参数
  on(eventName, cb) {
    // 先检查一下目标事件名有没有对应的监听函数队列
    if (!this.handlers[eventName]) {
      // 如果没有,那么首先初始化一个监听函数队列
      this.handlers[eventName] = []
    }

    // 把回调函数推入目标事件的监听函数队列里去
    this.handlers[eventName].push(cb)
  }

  // emit 方法用于触发目标事件,它接受事件名和监听函数入参作为参数
  emit(eventName, ...args) {
    // 检查目标事件是否有监听函数队列
    if (this.handlers[eventName]) {
      // 这里需要对 this.handlers[eventName] 做一次浅拷贝,主要目的是为了避免通过 once 安装的监听器在移除的过程中出现顺序问题
      const handlers = this.handlers[eventName].slice()
      // 如果有,则逐个调用队列里的回调函数
      handlers.forEach((callback) => {
        callback(...args)
      })
    }
  }

  // 移除某个事件回调队列里的指定回调函数
  off(eventName, cb) {
    const callbacks = this.handlers[eventName]
    const index = callbacks.indexOf(cb)
    if (index !== -1) {
      callbacks.splice(index, 1)
    }
  }

  // 为事件注册单次监听器
  once(eventName, cb) {
    // 对回调函数进行包装,使其执行完毕自动被移除
    const wrapper = (...args) => {
      cb(...args)
      this.off(eventName, wrapper)
    }
    this.on(eventName, wrapper)
  }
}

参考资料

浏览器工作原理与实践:01 | Chrome架构:仅仅打开了1个页面,为什么有4个进程?-极客时间

React Hooks 核心原理与实战:开篇词 | 全面拥抱 Hooks,掌握最新 React 开发方式-极客时间

JavaScript 教程: JavaScript 教程

ES6标准入门:ES6 教程

如何解决前端常见的竞态问题 - 掘金

Promise实现原理 - 掘金

JavaScript 设计模式核心原理与应用实践 :掘金小册

相关推荐
everyStudy22 分钟前
前端五种排序
前端·算法·排序算法
甜兒.1 小时前
鸿蒙小技巧
前端·华为·typescript·harmonyos
Jiaberrr5 小时前
前端实战:使用JS和Canvas实现运算图形验证码(uniapp、微信小程序同样可用)
前端·javascript·vue.js·微信小程序·uni-app
everyStudy5 小时前
JS中判断字符串中是否包含指定字符
开发语言·前端·javascript
城南云小白5 小时前
web基础+http协议+httpd详细配置
前端·网络协议·http
前端小趴菜、5 小时前
Web Worker 简单使用
前端
web_learning_3215 小时前
信息收集常用指令
前端·搜索引擎
tabzzz5 小时前
Webpack 概念速通:从入门到掌握构建工具的精髓
前端·webpack
200不是二百6 小时前
Vuex详解
前端·javascript·vue.js
滔滔不绝tao6 小时前
自动化测试常用函数
前端·css·html5