手写100行JS Signals:搞懂前端框架的响应式原理

前阵子接了个老项目,技术栈还停在 jQuery + 模板字符串。为了在几个页面之间同步一个全局弹窗状态,我在原生 JS 里写了个很小的响应式模块。写完发现,这个模块和现在前端圈子里常说的 Signals 是一路货色。后来我把这玩意儿不断简化,最后只剩一百多行。

这篇文章就把这个过程完整拆给你看,不借助任何库,用原生 JS 实现一个能用的 Signals 系统。

先把 API 定下来

我需要的不是完整框架,而是三个函数:

  • signal(value):创建信号,返回一个读函数和一个写函数。
  • computed(fn):根据已有信号生成派生值,自动更新。
  • effect(fn):声明副作用,当函数里读过的信号变化时,自动重跑。

期望的使用方式长这样:

const count = signal(1);
const double = computed(() => count() * 2);

effect(() => console.log(count(), double()));

count(2);
// 2 4

signal 的核心逻辑

每个信号内部只维护一个订阅集合 subs。读信号时把当前正在运行的 effect 加进集合,写信号时把集合里的 effect 挨个跑一遍。

function signal(value) {
  const subs = new Set();

  function read() {
    if (activeEffect) {
      if (!subs.has(activeEffect)) {
        subs.add(activeEffect);
        activeEffect.deps.push(subs);
      }
    }
    return value;
  }

  function write(next) {
    if (Object.is(value, next)) return;
    value = next;
    for (const sub of [...subs]) {
      sub();
    }
  }

  return [read, write];
}

这里有个细节:同一个 effect 读同一个信号很多次,不应该在 deps 里重复记录,所以我加了 subs.has(activeEffect) 的判断。

effect 要处理依赖清理

上文里的 activeEffect 是“当前正在执行的 effect”。effect 函数接收一个回调,先把它立即跑一次,在执行之前把全局变量指向自己:

let activeEffect = null;

function effect(fn) {
  const run = () => {
    if (run.deps) {
      for (const dep of run.deps) {
        dep.delete(run);
      }
      run.deps.length = 0;
    }

    const prev = activeEffect;
    activeEffect = run;
    try {
      fn();
    } finally {
      activeEffect = prev;
    }
  };

  run.deps = [];
  run();
}

为什么要清理依赖?看这个例子:

const isAdmin = signal(false);
const name = signal('小张');

effect(() => {
  if (isAdmin()) {
    console.log(name());
  }
});

isAdmin(true);
name('老李');

isAdminfalse 时,effect 根本没读 name。等 isAdmin 变成 true 后才开始读 name。如果不清理旧依赖,反过来的场景会出问题:第一次读了 name,第二次因为条件变化不再读 name,那 name 变化时这个 effect 还是会白白跑一次。所以每次重跑前清空上一次的订阅关系最稳妥。

顺手提一句:prev 保存了外层 effect,而不是直接重置为 null,是为了支持嵌套 effect。computed 内部会再次调用 effect,这时候内层跑完必须恢复外层的上下文。

computed 最省事的写法

直接拿 signal 和 effect 拼出来:

function computed(fn) {
  const [read, set] = signal();

  effect(() => {
    set(fn());
  });

  return read;
}

这段代码初看有点绕,因为它在 effect 里调 set。实际上,computed 内部挂了一个 effect,effect 读到的所有信号都会成为它的依赖;依赖变化时,effect 重跑并调用 set(fn()),用新值更新内部信号。由于 signal 的 write 里有 Object.is 判断,计算结果和上次一样时,不会触发外层的订阅者。

这是一种“积极求值”的 computed,跟 Solid 那种“惰性计算”不太一样。真实框架里为了性能普遍会做惰性求值,这里为了教学先用最简单的实现。

让多次触发合并成一次

目前写信号是同步触发所有 effect,连续改三个信号,effect 就重跑三次。多数时候没必要,所以加一个微任务批处理。

let queued = false;
const queue = new Set();

function flush() {
  const runs = [...queue];
  queue.clear();
  queued = false;
  for (const run of runs) run();
}

function schedule(run) {
  queue.add(run);
  if (queued) return;
  queued = true;
  Promise.resolve().then(flush);
}

然后把 signal.write 里调用订阅者的那句换成:

for (const sub of [...subs]) {
  schedule(sub);
}

queue 是 Set,同一个 effect 被触发几十次也只会在队列里出现一次。Promise 的微任务机制保证当前同步代码跑完后统一执行。

注意:批处理只影响副作用的重跑时机,不影响读值。同步写完信号后立刻读,拿到的就是新值。

一个能跑的 todo 案例

下面这个文件可以直接保存成 html 打开。它包含一个待办列表和完成数量统计,全部基于前面实现的 Signals:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Signals 待办演示</title>
</head>
<body>
  <input id="input" placeholder="随手记下要做的事" />
  <button id="add">添加</button>
  <ul id="list"></ul>
  <p id="summary"></p>

  <script>
    function signal(value) {
      const subs = new Set();

      function read() {
        if (activeEffect) {
          if (!subs.has(activeEffect)) {
            subs.add(activeEffect);
            activeEffect.deps.push(subs);
          }
        }
        return value;
      }

      function write(next) {
        if (Object.is(value, next)) return;
        value = next;
        for (const sub of [...subs]) schedule(sub);
      }

      return [read, write];
    }

    let activeEffect = null;

    function effect(fn) {
      const run = () => {
        if (run.deps) {
          for (const dep of run.deps) dep.delete(run);
          run.deps.length = 0;
        }

        const prev = activeEffect;
        activeEffect = run;
        try {
          fn();
        } finally {
          activeEffect = prev;
        }
      };

      run.deps = [];
      run();
    }

    function computed(fn) {
      const [read, set] = signal();
      effect(() => set(fn()));
      return read;
    }

    let queued = false;
    const queue = new Set();

    function flush() {
      const runs = [...queue];
      queue.clear();
      queued = false;
      for (const run of runs) run();
    }

    function schedule(run) {
      queue.add(run);
      if (queued) return;
      queued = true;
      Promise.resolve().then(flush);
    }

    const [todos, setTodos] = signal([]);
    const doneCount = computed(() => todos().filter((t) => t.done).length);

    const input = document.getElementById('input');
    const addBtn = document.getElementById('add');

    addBtn.addEventListener('click', () => {
      const text = input.value.trim();
      if (!text) return;
      setTodos([
        ...todos(),
        { id: Date.now(), text, done: false }
      ]);
      input.value = '';
    });

    effect(() => {
      const list = document.getElementById('list');
      list.innerHTML = '';
      for (const todo of todos()) {
        const li = document.createElement('li');
        li.textContent = todo.text;

        const toggle = document.createElement('input');
        toggle.type = 'checkbox';
        toggle.checked = todo.done;
        toggle.onchange = () => {
          setTodos(
            todos().map((t) =>
              t.id === todo.id ? { ...t, done: toggle.checked } : t
            )
          );
        };

        li.prepend(toggle);
        list.appendChild(li);
      }
    });

    effect(() => {
      const total = todos().length;
      document.getElementById('summary').textContent =
        '共 ' + total + ' 条,已完成 ' + doneCount() + ' 条';
    });
  </script>
</body>
</html>

上面代码里用了 innerHTML = '',很多人会担心事件丢失。这里不会,因为每个待办项的事件是在每次重跑时绑到新建的 DOM 节点上的。

它离生产级还差什么

这个迷你系统能用来做小页面实验,但跟工程级框架比,至少差三件事:

  • computed 不是惰性求值。真实实现会用脏标记和版本号,只有在读取时才重新计算。
  • effect 没有销毁接口。组件卸载后应该停止订阅,否则容易内存泄漏。
  • 缺少异常处理和异步取消机制。真实框架对 effect 出错后的恢复有更精细的逻辑。

写在最后

把这个迷你系统写完,你会发现 Vue 的 ref、Solid 的 createSignal、甚至 Angular 的新型响应式,内里都是同一个模型:读时记录依赖,写时触发依赖。框架真正复杂的是缓存、调度、类型推导、边界回收这些“收尾”工作。

如果你也想动手验证一下自己的理解,我给一个小任务:给这个 todo 加上一个“清除已完成”按钮。改完你基本就掌握这套体系了。

手写100行JS Signals:搞懂前端框架的响应式原理
收藏 (0) 打赏

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

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

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

淘吗网 javascript 手写100行JS Signals:搞懂前端框架的响应式原理 https://www.taomawang.com/web/javascript/2635.html

常见问题

相关文章

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

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