JavaScript 信号响应式编程实战:从零实现信号库并深度解读核心原理

在过去的几年里,一种名为“信号”(Signals)的响应式编程模式悄然席卷了前端框架领域。从 Solid.js 的高效渲染,到 Preact 最近引入的 Signals 集成,再到 Angular 的深度采用,信号正在成为组件状态管理的新标准。但信号的魔力并不局限于框架内部——它本质上是一种纯粹的 JavaScript 设计模式,完全可以在无框架环境下运行。本文将带你从零开始,亲手实现一个功能完备的信号库,并借助多个实际案例展示其如何化简 UI 状态同步、副作用管理以及复杂数据流的处理。

一、理解信号:响应式编程的最小单元

在传统的前端开发中,状态变化通常需要通过事件回调、命令式赋值或不可变数据更新来触发界面重绘。而信号提供了一种声明式的自动追踪机制:当某个值(信号)发生变化时,所有依赖它的计算逻辑和副作用都会自动重新执行。这种模式消除了手动订阅、取消订阅以及状态同步带来的模板代码。

一个信号系统通常包含三个核心概念:

  • Signal(信号):可以被读取和写入的值容器,通常通过 getter/setter 函数暴露。
  • Computed(派生信号):基于其他信号计算得出的只读值,当依赖变化时自动重新计算。
  • Effect(副作用):根据信号变化自动执行的操作,例如更新 DOM、发送网络请求或输出日志。

与传统的发布/订阅模式不同,信号的追踪是自动的,且依赖关系在运行时通过读取操作动态收集,这大大减少了手动管理订阅的负担。下面我们将按照这个层次,从零构建一个轻量但完整的信号库。

二、从零搭建信号系统:核心算法实现

为了彻底理解信号的工作原理,我们直接编写一个名为 TinySignals 的迷你库。整个过程仅使用原生 JavaScript,不依赖任何第三方模块。

2.1 全局上下文与依赖收集栈

所有魔法都基于一个运行时的“当前执行上下文”。当某个副作用或派生信号正在计算时,我们将其推入一个全局栈,这样任何被访问的信号就知道谁在依赖它。

// 全局上下文栈:存储当前正在运行的 Effect 或 Computed
const contextStack = [];

// 获取当前正在执行的依赖上下文
function getCurrentContext() {
    return contextStack[contextStack.length - 1] ?? null;
}

// 临时将一个上下文推入栈中并执行函数
function executeInContext(context, fn) {
    contextStack.push(context);
    try {
        return fn();
    } finally {
        contextStack.pop();
    }
}

2.2 实现基础 Signal(信号)

信号是一个封装了值的函数,调用它不传参时获取当前值,传入参数时设置新值并通知所有订阅者。这里我们使用闭包来保持私有状态。

function createSignal(initialValue) {
    let value = initialValue;
    const subscribers = new Set(); // 存储所有依赖此信号的上下文

    function signal(newValue) {
        if (arguments.length === 0) {
            // 读取操作:将当前运行的上下文加入订阅者集合
            const currentCtx = getCurrentContext();
            if (currentCtx) {
                subscribers.add(currentCtx);
            }
            return value;
        } else {
            // 写入操作:更新值并通知所有订阅者重新执行
            if (value !== newValue) {
                value = newValue;
                // 使用副本遍历,防止订阅者在回调中修改集合
                const subs = [...subscribers];
                for (const ctx of subs) {
                    ctx.execute();
                }
            }
            return value;
        }
    }

    // 附加一个 peek 方法,用于不产生依赖的读取
    signal.peek = () => value;

    return signal;
}

这里的关键在于:当读取信号时,如果存在当前上下文,就自动将该上下文注册为信号的订阅者。这使得后续写入时可以准确通知所有依赖方更新。

2.3 实现 Computed(派生信号)

派生信号本身是一个只读信号,它的值由一个计算函数决定。该计算函数可以读取其他信号,当任一依赖信号变化时,派生信号会自动重新计算。

function createComputed(computeFn) {
    const signal = createSignal(); // 内部使用一个信号存储计算结果
    let isStale = true; // 标记是否需要重新计算

    // 创建派生上下文,当依赖变化时重新执行
    const context = {
        execute() {
            // 标记失效,但并不立即计算(惰性求值)
            isStale = true;
            // 通知所有依赖此派生信号的消费者更新
            // 这里通过内部信号的通知机制触发
        },
        dependencies: new Set(),
    };

    // 包装原有的 execute 以便收集依赖
    const originalExecute = context.execute;
    context.execute = () => {
        // 重新计算前,先清除旧的依赖关系(简化版未处理,实际需要)
        executeInContext(context, () => {
            const newValue = computeFn();
            // 直接调用内部信号的 setter(跳过通知,因为派生的通知由 context 负责)
            if (signal.peek() !== newValue) {
                signal(newValue);
            }
        });
        isStale = false;
    };

    // 初始计算
    context.execute();

    // 返回一个只读信号包装,实际读取内部信号的值
    const computed = () => {
        if (isStale) {
            context.execute();
        }
        // 读取内部信号以触发依赖收集
        return signal();
    };

    computed.peek = () => signal.peek();

    return computed;
}

注意,上述实现为了简洁,省略了部分依赖清理逻辑。在实际生产中,当一个派生信号重新计算时,需要取消对旧依赖的订阅,以避免内存泄漏和不必要的重新计算。不过对于理解核心机制,这段代码已经足够。

2.4 实现 Effect(副作用)

副作用是信号系统的最终消费者,它通常用于执行与外部世界交互的操作,例如更新 DOM、写入控制台或发起网络请求。副作用函数会在其依赖信号变化时自动重新运行。

function createEffect(effectFn) {
    let cleanupFn = null; // 存储上一次运行的清理函数

    const context = {
        execute() {
            // 先执行上一次的清理函数
            if (cleanupFn) {
                cleanupFn();
                cleanupFn = null;
            }
            // 在上下文中运行副作用函数
            executeInContext(context, () => {
                cleanupFn = effectFn();
            });
        },
        dependencies: new Set(),
    };

    // 立即执行一次以收集依赖
    context.execute();

    // 返回一个取消订阅的函数(用于手动停止副作用)
    return () => {
        if (cleanupFn) {
            cleanupFn();
            cleanupFn = null;
        }
        context.execute = () => {}; // 禁用后续更新
    };
}

这段代码实现了副作用的清理机制:每次重新运行副作用前,会调用上一次返回的清理函数,这在处理事件监听器或定时器时非常有用。

三、实战案例一:构建一个实时计数器与UI更新器

现在我们用刚刚实现的信号库来构建一个简单的计数器应用。这个例子将展示信号如何与 DOM 更新解耦,同时保持代码的简洁性。

// 创建两个基础信号
const count = createSignal(0);
const multiplier = createSignal(2);

// 创建派生信号:计算加倍后的值
const doubled = createComputed(() => count() * multiplier());

// 创建副作用:当 doubled 变化时自动更新页面
const disposeDOM = createEffect(() => {
    document.getElementById('count-display').textContent = count();
    document.getElementById('doubled-display').textContent = doubled();
    console.log(`UI已更新:count=${count()}, doubled=${doubled()}`);
});

// 按钮事件绑定
document.getElementById('increment-btn').addEventListener('click', () => {
    count(count() + 1);
});

document.getElementById('multiply-btn').addEventListener('click', () => {
    multiplier(multiplier() + 1);
});

// 停止更新(页面卸载时调用)
// disposeDOM();

对应的 HTML 结构可以是:

<div>
    <span id="count-display">0</span>
    <span id="doubled-display">0</span>
    <button id="increment-btn">增加数字</button>
    <button id="multiply-btn">增加倍数</button>
</div>

这个案例虽然简单,但已经体现了信号的核心优势:派生状态 doubled 会自动保持与 countmultiplier 的同步,而副作用则负责将最新值渲染到 DOM。整个过程没有出现任何手动调用更新函数或检查状态的逻辑。

四、实战案例二:基于信号的待办事项应用

接下来,我们构建一个更贴近实际应用的待办事项列表。信号系统不仅能管理单一数值,还能优雅地处理数组和过滤逻辑。

// 定义任务信号(数组)
const todos = createSignal([]);
// 定义过滤条件信号:'all' | 'active' | 'completed'
const filter = createSignal('all');

// 派生信号:根据过滤条件返回对应的任务列表
const filteredTodos = createComputed(() => {
    const all = todos();
    const currentFilter = filter();
    if (currentFilter === 'active') return all.filter(t => !t.completed);
    if (currentFilter === 'completed') return all.filter(t => t.completed);
    return all;
});

// 派生信号:统计未完成任务数量
const activeCount = createComputed(() => {
    return todos().filter(t => !t.completed).length;
});

// 副作用:将过滤后的任务列表渲染到DOM
const disposeList = createEffect(() => {
    const listEl = document.getElementById('todo-list');
    const tasks = filteredTodos();
    listEl.innerHTML = '';
    tasks.forEach((task, index) => {
        const li = document.createElement('li');
        li.textContent = task.text;
        li.style.textDecoration = task.completed ? 'line-through' : 'none';
        li.addEventListener('click', () => toggleTodo(index));
        listEl.appendChild(li);
    });
});

// 操作函数
function addTodo(text) {
    todos([...todos.peek(), { text, completed: false }]);
}

function toggleTodo(index) {
    const current = [...todos.peek()];
    current[index] = { ...current[index], completed: !current[index].completed };
    todos(current);
}

function setFilter(newFilter) {
    filter(newFilter);
}

// 初始数据
addTodo('学习信号模式');
addTodo('写一篇技术文章');
setFilter('all');

这个案例中,当任务列表或过滤条件发生变化时,DOM 更新会自动触发,无需手动编写任何渲染调度代码。派生信号 filteredTodosactiveCount 始终与基础信号保持一致,副作用则负责将变化反映到 UI 上。这种模式让业务逻辑与渲染逻辑彻底分离,极大地提高了代码的可测试性和可维护性。

五、实战案例三:异步数据获取与信号集成

信号本身是同步的,但现代应用离不开异步操作。我们可以通过将 Promise 状态封装为信号来优雅地处理数据加载、成功、失败等状态。

// 创建异步请求状态管理器
function createAsyncSignal(fetchFn) {
    const data = createSignal(null);
    const loading = createSignal(false);
    const error = createSignal(null);

    async function execute() {
        loading(true);
        error(null);
        try {
            const result = await fetchFn();
            data(result);
        } catch (err) {
            error(err);
        } finally {
            loading(false);
        }
    }

    // 立即执行
    execute();

    return {
        data: data,
        loading: loading,
        error: error,
        refetch: execute,
    };
}

// 使用:获取用户列表
const userResource = createAsyncSignal(async () => {
    const response = await fetch('https://jsonplaceholder.typicode.com/users');
    if (!response.ok) throw new Error('请求失败');
    return response.json();
});

// 派生信号:判断是否加载完成且无错误
const isReady = createComputed(() => !userResource.loading() && !userResource.error());

// 副作用:根据状态更新UI
createEffect(() => {
    const statusEl = document.getElementById('status');
    const dataEl = document.getElementById('data-display');
    
    if (userResource.loading()) {
        statusEl.textContent = '加载中...';
    } else if (userResource.error()) {
        statusEl.textContent = `错误:${userResource.error().message}`;
    } else {
        statusEl.textContent = '加载完成';
        dataEl.textContent = JSON.stringify(userResource.data(), null, 2);
    }
});

异步信号封装模式让数据获取的状态管理变得声明化。开发者只需关注数据本身,而 loadingerrordata 三个信号之间的联动由系统自动处理,避免了常见的“忘记设置 loading 为 false”或“未清除 error”等 bug。

六、进阶探索:批处理更新与性能优化

在上面的实现中,每次信号写入都会立即触发所有订阅者的重新计算。当短时间内发生多个信号变更时,这可能导致不必要的重复计算和 DOM 更新。生产级信号库(如 @preact/signals-core)通常会引入批处理(batching)机制:将多个同步更新合并,仅在当前微任务或下一个事件循环中执行一次副作用。

我们可以通过一个简单的批处理包装器来实现这一点:

let pending = false;
const effectQueue = new Set();

function batch(fn) {
    const prevPending = pending;
    pending = true;
    try {
        fn();
    } finally {
        if (!prevPending) {
            pending = false;
            flushEffects();
        }
    }
}

function scheduleEffect(context) {
    if (pending) {
        effectQueue.add(context);
    } else {
        context.execute();
    }
}

function flushEffects() {
    const effects = [...effectQueue];
    effectQueue.clear();
    for (const ctx of effects) {
        ctx.execute();
    }
}

然后,修改信号写入时的通知逻辑,改为调用 scheduleEffect 而不是直接 execute。这样,在 batch 回调中连续修改多个信号时,副作用只会执行一次,显著提升复杂场景下的性能。

七、信号与主流框架的关系及选型建议

理解了原生信号实现后,再去看框架中的信号 API 就会豁然开朗。例如:

  • Solid.js:createSignalcreateMemocreateEffect 与我们的实现几乎一致,只是在内部与 JSX 渲染深度集成。
  • Preact:通过 @preact/signals 提供信号支持,可以在不升级到函数式组件重写的情况下渐进式引入。
  • Angular:从 Angular 17 开始内置信号原语,并计划逐步替代基于 Zone.js 的变更检测。
  • Vue 3:refcomputed 本质上也是信号模式,只是与响应式系统和虚拟 DOM 调度深度绑定。

如果你的项目尚未迁移到这些框架,或者正在构建一个轻量的自定义前端工具集,那么基于原生 JavaScript 实现信号库是一种极具性价比的解决方案。它不依赖任何特定框架,可以运行在任何 JavaScript 环境中,甚至可以在 Node.js 服务端用于状态管理。

八、总结

本文通过从零实现一个完整的信号库,展示了信号响应式编程背后的核心概念:自动依赖收集、惰性求值派生以及声明式副作用。这三个模式的组合提供了一种极简而强大的状态管理方式,彻底消除了手动订阅和更新协调的复杂性。

信号模式并非要完全取代 Redux 或 Zustand 等成熟状态管理库,而是提供了一种更细粒度、更贴近组件更新粒度的选择。在实际项目中,你可以根据场景将信号用于局部组件状态,或结合其他库管理全局应用状态。

建议读者将本文的迷你信号库代码复制到本地运行,并尝试为其增加更多特性(如资源清理、相等性检查跳过更新、异步派生信号等),这不仅能加深对响应式系统的理解,还可能催生出你对前端架构的新思路。

JavaScript 信号响应式编程实战:从零实现信号库并深度解读核心原理
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 javascript JavaScript 信号响应式编程实战:从零实现信号库并深度解读核心原理 https://www.taomawang.com/web/javascript/2074.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务