JavaScript 信号:如何将响应式功能带到普通 Web 开发中

在现代前端框架中,信号(Signals)正变得越来越流行。从 Angular 到 Solid、Preact,几乎所有主流框架都在使用信号,甚至有提案将其作为语言的核心功能。如果这个提案通过,那么框架中内置信号将是时间问题,而对于普通的 Web 开发者来说,信号也不再是遥不可及的技术。

信号是什么?

信号本质上是一个可以包装值并在值发生变化时发出事件的机制。在更现代的框架中,信号通过捕捉数据的变化并以响应的方式进行操作,避免了像传统 DOM 更新那样的重绘操作,从而提高了性能和开发效率。它的最大特点是能够简洁、高效地处理响应式状态,而不需要像 React 那样频繁地重新渲染整个组件。

举个例子

如果你对前端不是很熟悉,或者与世隔绝很久了,我们先通过下面的例子来简单了解一下信号的作用

javascript 复制代码
import { h } from 'preact';
import { signal, computed } from '@preact/signals';
import style from './style.css';

const username = signal('');
const password = signal('');
const message = computed(() => `用户名:${username.value} / 密码:${password.value}`);
const Test = ({ user }) => {
	const submitForm = () => {

	};

	return (
		<div>
			<h2>用户注册</h2>
			<input
				type="text"
				placeholder="用户名"
				onInput={(e) => username.value = e.target.value}
			/><br/>
			<input
				type="password"
				placeholder="密码"
				onInput={(e) => password.value = e.target.value}
			/><br/>
			<button onClick={submitForm}>提交</button>
			<p>{message}</p>
		</div>
	);
};

export default Test;

运行上面preact的例子,我们可以看到如下效果

从零开始实现信号

虽然信号在大多数现代框架中已经成为标准,但对于普通的 Web 开发者来说,我们可以通过一些简单的技巧在普通的 JavaScript 环境中实现类似的功能。我们通过 EventTarget 基类来封装信号,简单的包装便能实现信号机制。

javascript 复制代码
class Signal extends EventTarget {
    #value;

    constructor(value) {
        super();
        this.#value = value;
    }

    get value() {
        return this.#value;
    }

    set value(newValue) {
        if (this.#value !== newValue) {
            this.#value = newValue;
            this.dispatchEvent(new CustomEvent('change', { detail: newValue }));
        }
    }
}


const signal = new Signal('Initial Value');
console.log(signal.value);
signal.addEventListener('change', (event) => {
    console.log(`信号的新值是:${event.detail}`);
});

signal.value = 'Updated Value'; // 输出:信号的新值是:Updated Value
signal.value = 'Another Value'; // 输出:信号的新值是:Another Value

这段代码使用了 EventTarget 来监听和分发变化事件。通过 value 属性,我们可以轻松地读取和更新信号值,而当信号值改变时,会触发一个 change 事件。我们可以通过 addEventListener 来订阅信号变化:

这只是信号的基本实现,接下来,我们可以通过添加一些语法糖来简化其使用体验。

增强功能:更简洁的 API

我们可以为 Signal 添加一些额外的方法,简化订阅和取消订阅的操作。例如,effect 方法可以直接订阅信号的变化并执行相应的回调。

javascript 复制代码
class Signal extends EventTarget {
    #value;

    constructor(value) {
        super();
        this.#value = value;
    }

    get value() {
        return this.#value;
    }

    set value(newValue) {
        if (this.#value !== newValue) {
            this.#value = newValue;
            this.dispatchEvent(new CustomEvent('change', { detail: newValue }));
        }
    }

    effect(fn) {
        fn();
        this.addEventListener('change', fn);
        return () => this.removeEventListener('change', fn);
    }

    valueOf () { return this.#value; }
    toString () { return String(this.#value); }
}

现在,我们可以通过 effect 来简化代码:

javascript 复制代码
const signal = new Signal('Initial Value');
signal.effect(() => console.log(`信号的新值是:${signal.value}`)); 
signal.value = 'Updated Value'; 

在这个例子中,effect 方法会立即执行一次回调,并订阅 change 事件。返回的取消函数可以让我们在不需要时取消对信号的订阅。

计算信号:依赖多个信号的计算

有时我们需要基于多个信号来计算一个新值,这时候我们就需要计算信号。计算信号会基于其他信号的变化,自动重新计算自己的值。

javascript 复制代码
class Signal extends EventTarget {
    #value;

    constructor(value) {
        super();
        this.#value = value;
    }

    get value() {
        return this.#value;
    }

    set value(newValue) {
        if (this.#value !== newValue) {
            this.#value = newValue;
            this.dispatchEvent(new CustomEvent('change', { detail: newValue }));
        }
    }

    valueOf () { return this.#value; }
    toString () { return String(this.#value); }
}


class ComputedSignal extends Signal {
    constructor(calculateFn, deps) {
        super(calculateFn(...deps));
        this.deps = deps;

        deps.forEach(dep => {
            dep.addEventListener('change', () => {
                this.value = calculateFn(...deps);
            });
        });
    }
}

const name = new Signal('Thor');
const surname = new Signal('Odinson');

const fullName = new ComputedSignal((first, last) => `${first} ${last}`, [name, surname]);

fullName.addEventListener('change', () => {
    console.log(`计算后的全名是:${fullName.value}`);
});

name.value = 'Bruce'; // 输出:计算后的全名是:Bruce Odinson
surname.value = 'Banner'; // 输出:计算后的全名是:Bruce Banner

在这个示例中,fullName 会根据 name 和 surname 信号的变化自动更新。当 name 改变时,fullName 也会重新计算。

将信号与 Web 组件结合使用

信号的一个非常有趣的应用是在 Web 组件中。我们可以通过信号将状态和 UI 更新绑定在一起,从而避免直接操作 DOM。

javascript 复制代码
class Signal extends EventTarget {
    #value;
    constructor(value) {
        super();
        this.#value = value;
    }
    get value() {
        return this.#value;
    }

    set value(newValue) {
        if (this.#value !== newValue) {
            this.#value = newValue;
            this.dispatchEvent(new CustomEvent('change', { detail: newValue }));
        }
    }

    effect(fn) {
        fn();
        this.addEventListener('change', fn);
        return () => this.removeEventListener('change', fn);
    }
}

customElements.define('theme-switcher', class extends HTMLElement {
    constructor() {
        super();
        this.darkThemeSignal = new Signal(false); // 默认为亮色主题
    }

    connectedCallback() {
        // 创建主题切换按钮
        this.innerHTML = `
            <button id="light">亮色主题</button>
            <button id="dark">暗色主题</button>
            <p>当前主题:${this.darkThemeSignal.value ? '暗色' : '亮色'}</p>
        `;

        // 获取按钮元素
        const lightButton = this.querySelector('#light');
        const darkButton = this.querySelector('#dark');
        const statusText = this.querySelector('p');

        // 监听按钮点击事件,切换主题
        lightButton.addEventListener('click', () => {
            this.darkThemeSignal.value = false; // 切换到亮色主题
        });

        darkButton.addEventListener('click', () => {
            this.darkThemeSignal.value = true; // 切换到暗色主题
        });

        // 监听信号变化,更新主题
        this.darkThemeSignal.effect(() => {
            document.body.style.backgroundColor = this.darkThemeSignal.value ? 'black' : 'white';
            document.body.style.color = this.darkThemeSignal.value ? 'white' : 'black';
            statusText.textContent = `当前主题:${this.darkThemeSignal.value ? '暗色' : '亮色'}`;
        });
    }
});

这个例子中,实现一个简单的主题切换器,允许用户在"暗色"和"亮色"主题之间切换。信号将用于存储当前主题,Web 组件将用于渲染和切换主题。

vue?

通过上面实现的效果很容易让我们联想到双向绑定的vue。但是需要注意的是,vue3的响应式系统是基于Proxy实现的(vue2是Object.defineProperty),而我们实现的信号是基于EventTarget实现的。因此,虽然它们都能实现响应式,但它们的实现原理和使用方式是不同的。虽然 Vue 使用了类似于事件的机制来通知视图更新(当数据发生变化时,通知组件重新渲染),但 Vue 的核心机制并不是基于 EventTarget。Vue 更侧重于数据响应和依赖跟踪。EventTarget 通常用于事件处理(如 DOM 事件),而 Vue 更专注于数据绑定和自动更新,并通过依赖收集和更新机制来触发视图的变化。

结语

信号提供了一种简洁且高效的方式来响应数据变化,在现代 Web 开发中大有可为。通过简单的 JavaScript,我们可以将信号机制引入到应用中,不再依赖大型框架即可享受响应式的编程体验。如果你还没尝试过信号,赶快开始吧!

更多一手讯息,可关注公众号:ITProHub

相关推荐
小华同学ai17 分钟前
ShowDoc:Star12.3k,福利项目,个人小团队的在线文档“简单、易用、轻量化”还专门针对API文档、技术文档做了优化
前端·程序员·github
一雨方知深秋18 分钟前
智慧商城:封装getters实现动态统计 + 全选反选功能
开发语言·javascript·vue2·foreach·find·every
海威的技术博客20 分钟前
关于JS中的this指向问题
开发语言·javascript·ecmascript
王解35 分钟前
Vue CLI 脚手架创建项目流程详解 (2)
前端·javascript·vue.js
刘大浪38 分钟前
vue.js滑动到顶便锁定位置
前端·javascript·vue.js
小金刚®44 分钟前
构建简洁之美:我的第一个前端页面
前端
ordinary901 小时前
指令-v-for的key
前端·javascript·vue.js
rpa_top1 小时前
RPA 助力电商:自动化商品信息上传,节省人力资源 —— 以影刀 RPA 为例【rpa.top】
大数据·前端·人工智能·自动化·rpa
新时代农民工--小明1 小时前
前端自动化部署更新,自动化打包部署
运维·前端·自动化
前端Hardy2 小时前
HTML&CSS:酷炫的3D开关控件
前端·javascript·css·3d·html