基于 Signal 的框架无关的组件库

动机
一直以来,程序员们都痴迷于编写一次,处处运行
,在组件开发领域也不外如是。组件开发者们一直以来希望能够编写一次核心逻辑,然后在各个框架上运行组件。目前常见的模式有:

常见的模式
基于Web Components
首先想到的模式是基于 Web Components 组件做开发,然后基于此做各大框架的分发。这种方式缺陷比较明显: Web Components是比较基础的技术体系,其渲染,状态,交互逻辑都是和其他框架无关的,无法很好地利用框架的特性。
分层设计
这是来自semi-design 的设计理念,其将交互逻辑和渲染逻辑分离形成 Fundation
层 和 Adapter
层。这种设计 Fundation
层是不定义状态的,只定义状态接口,所以其 Fundation
层十分薄的一层,内聚性有限。基于 Fundation
去完成某一框架的实现依然工作量巨大。
独立状态实现 zag
这是我目前见过最佳的框架无关实现,其核心是实现一个框架无关的有限状态机。同时不同框架提供不同的状态机对接实现。以 Dialog
为例,
import * as dialog from "@zag-js/dialog"
import { useMachine, normalizeProps, Portal } from "@zag-js/react"
export function Dialog() {
const [state, send] = useMachine(dialog.machine({ id: "1" }));
const api = dialog.connect(state, send, normalizeProps);
return (...)
}
<script setup>
import * as dialog from '@zag-js/dialog';
import { normalizeProps, useMachine } from '@zag-js/vue';
import { computed, Teleport } from 'vue';
const [state, send] = useMachine(dialog.machine({ id: '1' }));
const api = computed(() => dialog.connect(state.value, send, normalizeProps));
</script>
vue
和 react
均使用同一个状态机,但通过不同的API使用状态机。状态机承包了所有的逻辑,而各个框架只负责渲染。
但是像 vue
,solid
这类框架是有自己的状态核心的,vue
的 Reactive
, solid
的 Signals
,像 zag
这种方式无疑没有很好地利用好框架的状态特性。
那么有没有办法既能完美利用各框架的状态工具,又能为所有框架提供一个通用的逻辑实现呢?
基于 Signals
的框架无关组件库
答案是基于 Signals
的框架无关组件库。自2020年以来,越来越多的框架已经将 Signals
集成。

Solid
Solid 应该是最早在前端框架领域引入 Signals
概念的框架了。其用法如下:
import { createSignal } from 'solid-js';
const [count, setCount] = createSignal(0);
count();
其中状态的获取(getter)是个函数。
Vue
Vue 一直实践着响应式编程的理念,Vue3 的 shallowRef/triggerRef
基本可以等同于 Signal:
import { shallowRef, triggerRef } from 'vue';
export function createSignal(value, options) {
const r = shallowRef(value);
const get = () => r.value;
const set = (v) => {
r.value = typeof v === 'function' ? v(r.value) : v;
if (options?.equals === false) triggerRef(r);
};
return [get, set];
}
看,实现 Vue 的 createSignal
相当简洁
Preact
Preact 则推出了单独的 @preact/signals
包:
import { signal } from '@preact/signals';
const count = signal(0);
count.value += 1;
count.value;
其用法几乎与 vue 的 shallowRef
一致。
Angular
Angular 也在 v23.0 引入了 Signals
, 其用法与前几个框架大同小异:
const count = signal(0);
count.set(3);
// or
count.update((value) => value + 1);
count();
React
React 是主流框架中唯一的状态理念和 Signal 有很大差别的框架。React 是 where you pretend the whole thing is recreated every time. 但是事实上,React 的生态中不乏实现 Reactive 理念的状态库,而且相比Angular/Vue这些框架,React 是一个更加low level的库,所以使用Signal
构建 React组件库我相信并无太大问题。
可以看出除了 React 之外,所有主流框架都支持类似Signal的概念,不过真正激发我灵感的实际上是 proposal-signals 的出现,一旦在JS标准层面得到实现,也许未来很多框架会直接使用 JS Signal API。那么,具体要怎么做呢?
各大框架的实现
提供统一的Signal API:@kernel-ui/signals
首先,我们得为组件开发提供一个统一的 Signal API
,组件开发者可以直接使用 @kernel-ui/signals
构建组件状态,例如实现 Switch 组件可能是这样:
// @kernel-ui/switch
import { signal, computed, watch } from '@kernel-ui/signals';
export interface SwitchCheckedChangeEvent {
checked: boolean;
}
export interface SwitchProps {
checked: boolean;
onCheckedChange?: (evt: SwitchCheckedChangeEvent) => void
}
export function useSwitch(props: SwitchProps) {
const [fieldsetDisabled, setFieldsetDisabled] = signal(false);
const [context, produce] = signal({
checked: false,
label: 'switch',
disabled: false,
...props,
});
const isDisabled = computed(() => context.disabled || fieldsetDisabled());
function syncInputElement() {
// 获取dom节点,不同的框架获取dom节点方式不同
input.checked = context.checked;
},
watch(context.disabled, syncInputElement);
function handleChecked(checked: boolean) {
produce((draft) => {
draft.checked = checked;
});
context?.onCheckedChange({ checked: context.checked });
}
return {
context,
isDisable,
syncInputElement,
setChecked: handleChecked,
toggle() {
handleChecked(!state.checked);
},
}
}
而 @kernel-ui/signals
对于不同框架则有不同的实现方式:
Vue 的可能实现
// @kernel-ui/signals/vue
import { shallowRef, shallowReactive, triggerRef } from 'vue';
import type { WritableSignal } from './interface';
export const signal: WritableSignal<T> = function (value: T) {
const isObject = typeof value === 'object';
const r = isObject ? shallowReactive(value) : shallowRef(value);
const getter = isObject ? r : () => r.value;
function setter(updator: (draft: any) => void) {
updator(r.value);
triggerRef(r);
}
return [getter, setter];
};
Solid 的可能实现
// @kernel-ui/signals/solid
import { createSignal } from 'solid-js';
import { createStore, unwrap } from 'solid-js/store';
import type { WritableSignal } from './interface';
export const signal: WritableSignal<T> = function (value: T) {
const isObject = typeof value === 'object';
const s = isObject ? createStore(value) : createSignal(value);
const getter = isObject ? s[0] : s[0];
function setter(updator: (draft: any) => void) {
return s[1](updator);
}
return [getter, setter];
};
Preact 的可能实现
// @kernel-ui/signals/preact
import { signal as PreactSignal } from '@preact/signals-core';
import type { WritableSignal } from './interface';
export const signal: WritableSignal<T> = function (value: T) {
const isObject = typeof value === 'object';
const s = PreactSignal(value);
const getter = isObject ? s.value : () => s.value;
function setter(updator: (draft: any) => void) {
const draft = isObject ? Object.assign({}, s.value) : s.value;
s.value = updator(draft) ?? draft;
}
return [getter, setter];
};
React 的可能实现
目前依赖 React 没有什么成熟的 useSignal
实现,可以考虑使用 preact 实现或是独立实现一个
Vanilla
如果不使用任何框架,则可以依赖原生JS Signal,当然现在还没有正式成为 JS 特性,不过现在我们可以使用 signal-polyfill 来做一个试验库,可能得实现如下:
import { Signal } from 'signal-polyfill';
import { SignalObject } from 'signal-utils/object';
import { SignalArray } from 'signal-utils/array';
import type { WritableSignal } from './interface';
export const signal: WritableSignal<T> = function (value: T) {
const isObject = typeof value === 'object';
const s = isObject
? Array.isArray(value)
? new SignalArray(value)
: new SignalObject(value)
: new Signal.State(value);
const getter = isObject ? s : () => s.get();
function setter(updator: (draft: any) => void) {
const draft = isObject ? Object.assign({}, s.value) : s.value;
s.value = updator(draft) ?? draft;
}
return [getter, setter];
};
如何使用?
而当我们特定框架中使用 @kernel-ui/switch
时,需一并安装 @kernel-ui/signals
注意须在组件引入之前 引入相关实现
import '@kernel-ui/signals/vue';
export * from './switch.vue';
@kernel-ui/signals/vue
会自动绑定到 @kernel-ui/signals
在框架中使用起来就相当简洁,以 vue
为例
<script setup>
import { type SwitchProps, useSwitch } from '@kernel-ui/switch';
const props = defineProps<SwitchProps>();
const { state, isDisable, toggle, setChecked } = useSwitch(props);
</script>
<template> </template>