如何使用原生 JavaScript 实现 Reactivity 响应性

文章来源:vanilla-javascript-reactivity

什么是 Reactivity?各种你所熟悉的前端库、框架基本都已实现了 Reactivity(响应性), Reactivity 广泛接受的定义是在应用中,UI 对数据变化的自动更新。作为一个开发者,你只需要关心数据状态,而不需要操心 UI 如何响应状态的变化。反应性有很多种类型,但对于本文来说,反应性是指当数据发生变化时,您是怎么执行操作的。本文用原生 JS 讲解各种响应性的是如何实现的。

Reactivity 是前端开发的核心

由于浏览器是一个完全异步的环境,我们在网站和 Web 应用程序中使用 JavaScript 处理很多事情。我们必须响应用户输入、与服务器通信、记录、执行等。所有这些任务都涉及 UI 更新、Ajax 请求、浏览器URL 和导航更改,这些层层嵌套的变化成为 Web 开发的核心。

如果你在一家公司,做一个性能稳定的产品,我们通常选择一个响应性的成熟框架您也可以通过在纯 JavaScript 中实现反应性来学到很多东西。我们可以混合重组这些设计模式,来看看不同的设计模式是如何更改响应数据的更改的。

无论您使用什么工具或框架,使用纯 JavaScript 学习核心设计模式都将减少 Web 应用程序的代码量并提高性能。

我喜欢学习设计模式,因为它们适用于任何语言和系统。可以组合设计模式来解决应用程序的确切需求,通常会产生性能更高、更易于维护的代码。

希望无论您使用什么框架和库,您都会学到新的设计模式并添加到您的工具箱中!


PubSub 发布订阅者模式

PubSub 是反应性最基本的模式之一。使用 publish() 触发事件允许任何人监听该事件 subscribe() 并在与触发该事件的任何事情分离的情况下执行他们想做的任何事情。

kotlin 复制代码
const pubSub = {
  events: {},
  subscribe(event, callback) {
    if (!this.events[event]) this.events[event] = [];
    this.events[event].push(callback);
  },
  publish(event, data) {
    if (this.events[event]) this.events[event].forEach(callback => callback(data));
  }
};
​
pubSub.subscribe('update', data => console.log(data));
pubSub.publish('update', 'Some update'); // Some update

请注意,发布者不知道正在收听的内容,因此无法取消订阅或者通过这样简单的代码去实现清理。

基于浏览器原生接口的PubSub: CustomEvent

浏览器有这样一个 JavaScript API,用于触发和订阅自定义事件。它允许您使用 dispatchEvent 发送数据以及自定义事件。

javascript 复制代码
const pizzaEvent = new CustomEvent("pizzaDelivery", {
  detail: {
    name: "supreme",
  },
});
​
window.addEventListener("pizzaDelivery", (e) => console.log(e.detail.name));
window.dispatchEvent(pizzaEvent);

您可以将这些自定义事件的绑定到 DOM 节点。在代码示例中,我们使用全局 window 对象,也称为全局事件总线,因此应用程序中的任何内容都可以侦听事件数据并对其执行操作。

bash 复制代码
<div id="pizza-store"></div
javascript 复制代码
const pizzaEvent = new CustomEvent("pizzaDelivery", {
  detail: {
    name: "supreme",
  },
});
​
const pizzaStore = document.querySelector('#pizza-store');
pizzaStore.addEventListener("pizzaDelivery", (e) => console.log(e.detail.name));
pizzaStore.dispatchEvent(pizzaEvent);

继承 EventTarget,并绑定 CustomEvent

我们可以继承 EventTarget,当在实例上发送事件时,便可在应用中监听该事件:

scala 复制代码
class PizzaStore extends EventTarget {
  constructor() {
    super();
  }
  addPizza(flavor) {
    // fire event directly on the class
    this.dispatchEvent(new CustomEvent("pizzaAdded", {
      detail: {
        pizza: flavor,
      },
    }));
  }
}
​
const Pizzas = new PizzaStore();
Pizzas.addEventListener("pizzaAdded", (e) => console.log('Added Pizza:', e.detail.pizza));
Pizzas.addPizza("supreme");

最爽的一点是您的事件不会在窗口上全局触发。您可以直接在类上触发事件;然后任何地方都可以监听到该事件。

观察者(Observer)模式

观察者模式与订阅发布模式具有相同的基本前提。它允许您将行为"订阅"到一个 Subject 中。当 Subject触发 notify 方法时,它会通知所有订阅的接受者。

javascript 复制代码
class Subject {
  constructor() {
    this.observers = [];
  }
  addObserver(observer) {
    this.observers.push(observer);
  }
  removeObserver(observer) {
    const index = this.observers.indexOf(observer);
    if (index > -1) {
      this.observers.splice(index, 1);
    }
  }
  notify(data) {
    this.observers.forEach(observer => observer.update(data));
  }
}
​
class Observer {
  update(data) {
    console.log(data);
  }
}
​
const subject = new Subject();
const observer = new Observer();
​
subject.addObserver(observer);
subject.notify('Everyone gets pizzas!');

这与订阅发布模式之间的主要区别在于,Subject 在其内部可以添加 observers, 也可以删除它们。它们并不像订阅发布模式那样完全解耦。

使用 Proxy 的响应性

在JavaScript 中的使用 Proxy 可以比较方便的对 js 对象获得响应性。

javascript 复制代码
const handler = {
  get: function(target, property) {
    console.log(`Getting property ${property}`);
    return target[property];
  },
  set: function(target, property, value) {
    console.log(`Setting property ${property} to ${value}`);
    target[property] = value;
    return true; // indicates that the setting has been done successfully
  }
};
​
const pizza = { name: 'Margherita', toppings: ['tomato sauce', 'mozzarella'] };
const proxiedPizza = new Proxy(pizza, handler);
​
console.log(proxiedPizza.name); // Outputs "Getting property name" and "Margherita"
proxiedPizza.name = 'Pepperoni'; // Outputs "Setting property name to Pepperoni"

当您访问或修改 proxiedPizza 上的属性时,它会向控制台记录一条消息。因此你可以将任何功能订阅到对象上的属性上。

单个属性的响应性:Object.defineProperty

您可以使用 Object.defineProperty 对特定属性执行相同的操作。您可以为属性定义 gettersetter,并在访问或修改属性时运行代码。

javascript 复制代码
const pizza = {
  _name: 'Margherita', // Internal property
};
​
Object.defineProperty(pizza, 'name', {
  get: function() {
    console.log(`Getting property name`);
    return this._name;
  },
  set: function(value) {
    console.log(`Setting property name to ${value}`);
    this._name = value;
  }
});

在这里,我们使用 Object.defineProperty 为披萨对象的 name 属性定义 gettersetter。实际值存储在私有 _name 属性中,并且 gettersetter 在将消息记录到控制台时提供对该值的访问

Object.defineProperty 比使用 Proxy 更为细致,特别是如果您想对许多属性应用相同的行为。但这是为单个属性定义自定义行为的一种强大而灵活的方法。

使用 Promise 处理响应性异步数据

让我们异步使用观察者!这样我们就可以更新数据并让多个观察者异步运行。

javascript 复制代码
class AsyncData {
  constructor(initialData) {
    this.data = initialData;
    this.subscribers = [];
  }
​
  // Subscribe to changes in the data
  subscribe(callback) {
    if (typeof callback !== 'function') {
      throw new Error('Callback must be a function');
    }
    this.subscribers.push(callback);
  }
​
  // Update the data and wait for all updates to complete
  async set(key, value) {
    this.data[key] = value;
​
    // Call the subscribed function and wait for it to resolve
    const updates = this.subscribers.map(async (callback) => {
      await callback(key, value);
    });
​
    await Promise.allSettled(updates);
  }
}

这是一个包装数据对象并在数据更改时触发更新的类。

Await 我们的 Async Observers

假设我们要等到异步反应数据的所有订阅都被处理完毕:

javascript 复制代码
const data = new AsyncData({ pizza: 'Pepperoni' });
​
data.subscribe(async (key, value) => {
  await new Promise(resolve => setTimeout(resolve, 500));
  console.log(`Updated UI for ${key}: ${value}`);
});
​
data.subscribe(async (key, value) => {
  await new Promise(resolve => setTimeout(resolve, 1000));
  console.log(`Logged change for ${key}: ${value}`);
});
​
// function to update data and wait for all updates to complete
async function updateData() {
  await data.set('pizza', 'Supreme'); // This will call the subscribed functions and wait for their promises to resolve
  console.log('All updates complete.');
}
​
updateData();

updateData 函数现在是异步的,因此可以等待所有订阅的函数完成解析。这种模式可以比较简单地处理异步响应性。

响应性系统

更为复杂的响应性系统是流行库和框架的基础,例如 React 中的 hooks、Solid 中的 Signals、Rx.js 中的 Observables 等等。它们的处理方式都是类似的,即当数据更改时,重新渲染组件或关联的 DOM 元素。

Observables (Rx.js 中的模式)

和观察者模式(Observer)看起来很像,但实际上并不相同。

Observables 允许您定义一种随时间生成一系列值的方法。这是一个简单的 Observable 代码,它提供了一种向订阅者发出一系列值的方法,允许他们在生成这些值时做出反应。

javascript 复制代码
class Observable {
  constructor(producer) {
    this.producer = producer;
  }
​
  // Method to allow a subscriber to subscribe to the observable
  subscribe(observer) {
    // Ensure the observer has the necessary functions
    if (typeof observer !== 'object' || observer === null) {
      throw new Error('Observer must be an object with next, error, and complete methods');
    }
​
    if (typeof observer.next !== 'function') {
      throw new Error('Observer must have a next method');
    }
​
    if (typeof observer.error !== 'function') {
      throw new Error('Observer must have an error method');
    }
​
    if (typeof observer.complete !== 'function') {
      throw new Error('Observer must have a complete method');
    }
​
    const unsubscribe = this.producer(observer);
​
    // Return an object with an unsubscribe method
    return {
      unsubscribe: () => {
        if (unsubscribe && typeof unsubscribe === 'function') {
          unsubscribe();
        }
      },
    };
  }
}

以下是如何使用它们:

javascript 复制代码
// Create a new observable that emits three values and then completes
const observable = new Observable(observer => {
  observer.next(1);
  observer.next(2);
  observer.next(3);
  observer.complete();
​
  // Optional: Return a function to handle any cleanup if the observer unsubscribes
  return () => {
    console.log('Observer unsubscribed');
  };
});
​
// Define an observer with next, error, and complete methods
const observer = {
  next: value => console.log('Received value:', value),
  error: err => console.log('Error:', err),
  complete: () => console.log('Completed'),
};
​
// Subscribe to the observable
const subscription = observable.subscribe(observer);
​
// Optionally, you can later unsubscribe to stop receiving values
subscription.unsubscribe();

Observable 的关键组件是 next() 方法,它将数据发送给观察者。Observable 流关闭时的 complete() 方法。当出现问题时,还有一个 error() 方法。此外,还必须有一种方法 subscribe() 侦听更改并 unsubscribe() 停止从流接收数据。

使用此模式的最流行的库是 Rx.js 和 MobX。

Signals(SolidJS 中的模式)

ini 复制代码
let context = [];

export function untrack(fn) {
    const prevContext = context;
    context = [];
    const res = fn();
    context = prevContext;
    return res;
}

function cleanup(observer) {
    for (const dep of observer.dependencies) {
        dep.delete(observer);
    }
    observer.dependencies.clear();
}

function subscribe(observer, subscriptions) {
    subscriptions.add(observer);
    observer.dependencies.add(subscriptions);
}

export function createSignal(value) {
    const subscriptions = new Set();

    const read = () => {
        const observer = context[context.length - 1]
        if (observer) subscribe(observer, subscriptions);
        return value;
    }
    const write = (newValue) => {
        value = newValue;
        for (const observer of [...subscriptions]) {
            observer.execute();
        }
    }

    return [read, write];
}

export function createEffect(fn) {
    const effect = {
        execute() {
            cleanup(effect);
            context.push(effect);
            fn();
            context.pop();
        },
        dependencies: new Set()
    }

    effect.execute();
}

export function createMemo(fn) {
    const [signal, setSignal] = createSignal();
    createEffect(() => setSignal(fn()));
    return signal;
}

使用反应式系统:

scss 复制代码
import { createSignal, createEffect, createMemo, untrack } from "./reactive";

const [count, setCount] = createSignal(0);
const [count2, setCount2] = createSignal(2);
const [show, setShow] = createSignal(true);

const sum = createMemo(() => count() + count2());

createEffect(() => {
  console.log(count(), count2(), sum());
  console.log(untrack(() => count()));
}); // 0

setShow(false);
setCount(10);

响应性的 UI 渲染

以下是用于写入和读取 DOM 和 CSS 的一些模式。

这是一个基于数据 UI 渲染的简单示例。

javascript 复制代码
function PizzaRecipe(pizza) {
  return `<div class="pizza-recipe">
    <h1>${pizza.name}</h1>
    <h3>Toppings: ${pizza.toppings.join(', ')}</h3>
    <p>${pizza.description}</p>
  </div>`;
}
​
function PizzaRecipeList(pizzas) {
  return `<div class="pizza-recipe-list">
    ${pizzas.map(PizzaRecipe).join('')}
  </div>`;
}
​
var allPizzas = [
  {
    name: 'Margherita',
    toppings: ['tomato sauce', 'mozzarella'],
    description: 'A classic pizza with fresh ingredients.'
  },
  {
    name: 'Pepperoni',
    toppings: ['tomato sauce', 'mozzarella', 'pepperoni'],
    description: 'A favorite among many, topped with delicious pepperoni.'
  },
  {
    name: 'Veggie Supreme',
    toppings: ['tomato sauce', 'mozzarella', 'bell peppers', 'onions', 'mushrooms'],
    description: 'A delightful vegetable-packed pizza.'
  }
];
​
// Render the list of pizzas
function renderPizzas() {
  document.querySelector('body').innerHTML = PizzaRecipeList(allPizzas);
}
​
renderPizzas(); // Initial render
​
// Example of changing data and re-rendering
function addPizza() {
  allPizzas.push({
    name: 'Hawaiian',
    toppings: ['tomato sauce', 'mozzarella', 'ham', 'pineapple'],
    description: 'A tropical twist with ham and pineapple.'
  });
​
  renderPizzas(); // Re-render the updated list
}
​
// Call this function to add a new pizza and re-render the list
addPizza();

addPizza 演示如何通过向列表添加新的披萨食谱,然后重新呈现列表以反映更改来更改数据。

这种方法的主要缺点是每次渲染时都会破坏整个 DOM。您可以使用 lit-html 等库更智能地仅更新发生更改的 DOM 位。

Reactive DOM 属性:MutationObserver

使 DOM 响应式的一种方法是添加和删除属性。我们可以使用 MutationObserver API 监听属性的变化。

javascript 复制代码
const mutationCallback = (mutationsList) => {
  for (const mutation of mutationsList) {
    if (
      mutation.type !== "attributes" ||
      mutation.attributeName !== "pizza-type"
    ) return;
​
    console.log('old:', mutation.oldValue)
    console.log('new:', mutation.target.getAttribute("pizza-type"))
  }
}
const observer = new MutationObserver(mutationCallback);
observer.observe(document.getElementById('pizza-store'), { attributes: true });

现在我们可以从程序中的任何位置更新 Pizza-type 属性,并且元素本身可以具有附加到更新该属性的行为!

Web Components 中的响应性属性

使用 Web 组件,可以通过本机方式来侦听属性更新并对其做出响应。

javascript 复制代码
class PizzaStoreComponent extends HTMLElement {
  static get observedAttributes() {
    return ['pizza-type'];
  }
​
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'open' });
    shadowRoot.innerHTML = `<p>${this.getAttribute('pizza-type') || 'Default Content'}</p>`;
  }
​
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'my-attribute') {
      this.shadowRoot.querySelector('div').textContent = newValue;
      console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
    }
  }
}
​
customElements.define('pizza-store', PizzaStoreComponent);
ini 复制代码
<pizza-store pizza-type="Supreme"></pizza-store>
dart 复制代码
document.querySelector('pizza-store').setAttribute('pizza-type', 'BBQ Chicken!');

这个比较简单,但是我们必须通过 Web Components来使用这个API。

响应性滚动:IntersectionObserver

我们可以将反应性连接到滚动到视图中的 DOM 元素。我在我们的页面上使用了它来制作流畅的动画。

javascript 复制代码
var pizzaStoreElement = document.getElementById('pizza-store');

var observer = new IntersectionObserver(function(entries, observer) {
  entries.forEach(function(entry) {
    if (entry.isIntersecting) {
      entry.target.classList.add('animate-in');
    } else {
      entry.target.classList.remove('animate-in');
    }
  });
});

observer.observe(pizzaStoreElement);

动画和游戏渲染: requestAnimationFrame

在使用游戏开发、Canvas、WebGL 或那些疯狂的营销网站时,动画通常需要写入缓冲区,然后在渲染线程可用时将结果写入给定循环。我们用 requestAnimationFrame 来做到这一点。

scss 复制代码
function drawStuff() {
  // This is where you'd do game or animation rendering logic
}

// function to handle the animation
function animate() {
  drawStuff();
  requestAnimationFrame(animate); // Continually calls animate when the next render frame is available
}

// Start the animation
animate();

这是游戏和任何涉及实时渲染的东西在帧可用时用来渲染场景的方法。

响应性动画:Web Animations API

您还可以使用 Web Animations API 创建反应式动画。在这里,我们将使用动画 API 对元素的比例、位置和颜色进行动画处理。

php 复制代码
const el = document.getElementById('animatedElement');
​
// Define the animation properties
const animation = el.animate([
  // Keyframes
  { transform: 'scale(1)', backgroundColor: 'blue', left: '50px', top: '50px' },
  { transform: 'scale(1.5)', backgroundColor: 'red', left: '200px', top: '200px' }
], {
  // Timing options
  duration: 1000,
  fill: 'forwards'
});
​
// Set the animation's playback rate to 0 to pause it
animation.playbackRate = 0;
​
// Add a click event listener to the element
el.addEventListener('click', () => {
  // If the animation is paused, play it
  if (animation.playbackRate === 0) {
    animation.playbackRate = 1;
  } else {
    // If the animation is playing, reverse it
    animation.reverse();
  }
});

反应性在于,当交互发生时,动画可以相对于它所在的位置播放(在本例中,反转其方向)。标准 CSS 动画和过渡与其当前位置无关。

响应性 CSS:自定义属性和 calc

最后,我们可以通过组合自定义属性和 calc 来编写响应式 CSS。

arduino 复制代码
barElement.style.setProperty('--percentage', newPercentage);

在 JavaScript 中,您可以设置自定义属性值。

css 复制代码
.bar {
  width: calc(100% / 4 - 10px);
  height: calc(var(--percentage) * 1%);
  background-color: blue;
  margin-right: 10px;
  position: relative;
}

在 CSS 中,我们现在可以根据该百分比进行计算。我们可以将计算直接添加到 CSS 中,并让 CSS 完成其样式工作,而无需将所有渲染逻辑保留在 JavaScript 中,这非常酷。

仅供参考:如果您想创建相对于当前值的更改,您也可以读取这些属性。

scss 复制代码
getComputedStyle(barElement).getPropertyValue('--percentage');

关于实现反应性的多种方法

令人难以置信的是,在现代原生 JavaScript 中,我们可以使用很少的代码来实现反应性。我们可以以任何我们认为适合我们的应用程序的方式组合这些模式,以响应式渲染、记录、动画、处理用户事件以及浏览器中可能发生的所有事情。

相关推荐
WeiShuai9 分钟前
vue-cli3使用DllPlugin优化webpack打包性能
前端·javascript
ice___Cpu15 分钟前
Linux 基本使用和 web 程序部署 ( 8000 字 Linux 入门 )
linux·运维·前端
JYbill17 分钟前
nestjs使用ESM模块化
前端
加油吧x青年36 分钟前
Web端开启直播技术方案分享
前端·webrtc·直播
吕彬-前端1 小时前
使用vite+react+ts+Ant Design开发后台管理项目(二)
前端·react.js·前端框架
小白小白从不日白1 小时前
react hooks--useCallback
前端·react.js·前端框架
恩婧2 小时前
React项目中使用发布订阅模式
前端·react.js·前端框架·发布订阅模式
mez_Blog2 小时前
个人小结(2.0)
前端·javascript·vue.js·学习·typescript
珊珊而川2 小时前
【浏览器面试真题】sessionStorage和localStorage
前端·javascript·面试
森叶2 小时前
Electron 安装包 asar 解压定位问题实战
前端·javascript·electron