Sean

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

Edited

image.png

动机

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

动机 - visual selection.png

常见的模式

基于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>

vuereact 均使用同一个状态机,但通过不同的API使用状态机。状态机承包了所有的逻辑,而各个框架只负责渲染。

但是像 vuesolid 这类框架是有自己的状态核心的,vueReactive, solidSignals,像 zag 这种方式无疑没有很好地利用好框架的状态特性。

那么有没有办法既能完美利用各框架的状态工具,又能为所有框架提供一个通用的逻辑实现呢?

基于 Signals 的框架无关组件库

答案是基于 Signals 的框架无关组件库。自2020年以来,越来越多的框架已经将 Signals 集成。

signal-framework.png

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>