样式,或无样式?这是个问题

关于 Tiptap Editor 引发的一些思考

11 min read

2023 年,春。

我终于遇上了那个心仪的那个“无设计组件”框架: radix-ui

它在 GitHub 上 7.6k 的点赞数量对于前端项目来说,不算多;但对于一个新项目而言绝对足够亮眼了。

除此之外,在它的基础上还诞生了另一个明星项目: shadcn

og

这个项目所使用的技术栈 (radix-ui + tailwindcss) 更是直接让 Vercel 的 DevRel 惊呼 —— 2023 年开发美观且易于访问的 web 应用的最佳技术栈

果然,我的直觉是正确的!

自 React 掀起前端组件化浪潮以来已 9 年有余。组件化已成为了前端中绝对的政治正确。但组件化并不是万能的,前端组件化中存在着一个基本矛盾:组件样式之间的矛盾。

当我们编写组件时,需要考虑以下特性:

  • 独立性;组件间不应该拥有太多耦合
  • 复用性;组件应该尽可能的被复用

而我们却希望样式拥有以下特性:

  • 统一性;样式整体的设计风格需要统一
  • 个性;设计需要体现出品牌的特色

这里就在逻辑上形成了一个不可能三角!独立性,统一性和个性最多只能存在两个,三者不可能同时存在!

过去与现在

现在主流的解决方案是牺牲个性,而追求独立性统一性,例如 Material UIChakra UI 等这类大而全的组件库。这类组件库确实能解决问题,但也带来了新的问题。因为使用了相同的组件库,导致现在的网站同质化严重。这也反过来限制住了程序员和设计师的发挥。假如某一天,设计师突然来出了一份相当炫酷 (但在现有组件库 Cover 之外) 的设计时,程序员大多是崩溃的 (修改别人的库比自己写一份还难受)。

而 (React 发明前的) 传统 Web 开发则更强调个性统一性,毕竟也没有模块可以用。

而第三条路着更是最坏的结果!没有统一性个性将毫无意义。

但其实我们还有第四条路可以走。那便是:将样式从组件中剥离开来,使用传统思路 (和现代方法),依靠设计师和程序员的水平完成个性又统一的设计。

逻辑组件

我这一思考主要来自于 Tiptap Editor 给我带来的启示。

Tiptap Editor 是一个开源的 WYSIWYG (所见即所得) 编辑器。它本质上是对 ProseMirror 的封装,而后者被纽约时报等老牌媒体选择,在其 CMS 中使用。

Tiptap Editor

Tiptap 的主要卖点之一便是 Handless:它默认不提供任何 CSS 样式。你应当根据你的需求自行设计它!

It’s headless and comes without any CSS. You are in full control over markup, styling and behaviour.

它成功的避免了组件化对设计的割裂:只要我不提供样式,就不会割裂样式。

本人才疏学浅,我不清楚业界是否对这样的组件或模块有统一的共识。我这里就大言不惭的将其命名为逻辑组件

我这里对逻辑组件的定义为:只提供核心功能,并不包含任何样式的组件或模块。

设计逻辑组件

如何将样式添加到逻辑组件内部?这可能是逻辑组件的设计中最大的难点了。

其主要解决思路在于,如何将组件内部的 DOM 节点的 class 属性暴露出来?无论你用何种样式系统 (无论是传统 CSS、CSS Modules、tailwind CSS 或 CSS in JS),只要能编辑 class,就一定能将样式注入进去。

Tiptap 对这个问题的解决方法是:提供一个设置参数 attributes (对于扩展,这个参数是 HTMLAttributes),根据这个参数将 class 注入到目标节点上。

new Editor({
  extensions: [
    Document,
    Paragraph.configure({
      HTMLAttributes: {
        class: "my-custom-paragraph",
      },
    }),
    Heading.configure({
      HTMLAttributes: {
        class: "my-custom-heading",
      },
    }),
    Text,
  ],
  editorProps: {
    attributes: {
      class:
        "prose prose-sm sm:prose lg:prose-lg xl:prose-2xl mx-auto focus:outline-none",
    },
  },
});

它渲染后的效果类似于这样:

<div class="prose prose-sm sm:prose lg:prose-lg xl:prose-2xl mx-auto focus:outline-none">
    <h1 class="my-custom-heading">Example Text</p>
    <p class="my-custom-paragraph">Wow, that’s really custom.</p>
</div>

逻辑组件的好处

仔细想想逻辑组件的好处其实挺多的。

不会割裂设计

这点前面已经论证过了。

节约时间

这似乎是反直觉的;使用预制的样式可以节省编写样式的时间。正常情况下确实是这样的,但就像我前面提到的;如果实际需求超出了预制的范围,相比于在别人的体系上修修补补,自己出零开始设计一套体系反而花费的时间和精力更少。因此在需要定制设计的场景中,逻辑组件更有优势。

跨框架

因为你只需要关注功能,不需要 Care 样式。这时,编写跨 UI 框架的组件就变得异常容易。

想象一下,你通过 Vanilla JS 编写组件,然后根据具体框架编写一个很轻的适配层,例如用 React Hook 将状态封装起来。这时你就拥有了一个跨原生 Web 开发和 React 开发的组件了!Vue、SolidJS、Svelte 等等也都是一样的道理。

易于优化

不绑定 UI 框架的另一个好处是拥有更大更灵活的优化空间!对于计算密集型的场景,你甚至可以引入 WebAssembly!如果此时你的模块与 UI 框架强耦合,事情就要变得复杂的多了。

逻辑组件的缺点

优点很多,缺点也同样很明显。

使用繁琐

显而易见的,使用逻辑组件意味着你需要自己定制样式。如果 Material UI 或 Chakra UI 能够满足我的业务需求,我为何还要自己造轮子呢?

如果组件设计者能根据现在成熟流行的设计语言 (例如 Material DesignHuman Interface Guidelines 等等) 提供一些预制的样式,可以在一定程度上解决这一问题;其使用体验将会和 Material UI 等重型组件库一致。(换句话说,去样式化其实也可以是现有组件库的改进方向之一?)

实现难度大

不要说什么最佳实践了,如今连个成熟的实现案例都很难找。

现有实现

除了 Tiptap Editor,以下组件 / 模块也或多或少的有逻辑组件的影子:

Headless UI

你可能也发现了,逻辑组件与 Tailwind CSS 的契合度很高!

这个库就是 Tailwind CSS 官方开源的组件库,其设计思想与我的逻辑组件概念高度重合,这可能也是目前为止最成熟最完整的逻辑组件实现了。

import { useState } from "react";
import { Switch } from "@headlessui/react";

export default function Example() {
  const [enabled, setEnabled] = useState(false);

  return (
    <div className="py-16">
      <Switch
        checked={enabled}
        onChange={setEnabled}
        className={`${enabled ? "bg-teal-900" : "bg-teal-700"} relative inline-flex h-[38px] w-[74px] shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75`}
      >
        <span className="sr-only">Use setting</span>
        <span
          aria-hidden="true"
          className={`${enabled ? "translate-x-9" : "translate-x-0"} pointer-events-none inline-block h-[34px] w-[34px] transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out`}
        />
      </Switch>
    </div>
  );
}

Zag

另一个以框架无关为主要卖点的组件库。目前还处于 BETA 阶段。

import * as numberInput from "@zag-js/number-input";
import { useMachine, normalizeProps } from "@zag-js/react";

export function NumberInput() {
  const [state, send] = useMachine(numberInput.machine({ id: "1" }));

  const api = numberInput.connect(state, send, normalizeProps);

  return (
    <div {...api.rootProps}>
      <label {...api.labelProps}>Enter number:</label>
      <div>
        <button {...api.decrementButtonProps}>DEC</button>
        <input {...api.inputProps} />
        <button {...api.incrementButtonProps}>INC</button>
      </div>
    </div>
  );
}

React Hook Form

与刚刚那些框架无关的组件库不同,这是一个针对 React 高度特化的模块。但它的设计思路正好契合了逻辑组件只提供核心功能,并不包含任何样式的理念。

import React from "react";
import { useForm } from "react-hook-form";

export default function App() {
  const {
    register,
    handleSubmit,
    watch,
    formState: { errors },
  } = useForm();
  const onSubmit = (data) => console.log(data);

  console.log(watch("example"));

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input defaultValue="test" {...register("example")} />

      <input {...register("exampleRequired", { required: true })} />
      {errors.exampleRequired && <span>This field is required</span>}

      <input type="submit" />
    </form>
  );
}

并且其文档中关于智能表单组件 (Smart Form Component) 的实现也非常具有参考价值。

import React from "react";
import { useForm } from "react-hook-form";

// Form
export default function Form({ defaultValues, children, onSubmit }) {
  const methods = useForm({ defaultValues });
  const { handleSubmit } = methods;

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {React.Children.map(children, (child) => {
        return child.props.name
          ? React.createElement(child.type, {
              ...{
                ...child.props,
                register: methods.register,
                key: child.props.name,
              },
            })
          : child;
      })}
    </form>
  );
}

// Input
export function Input({ register, name, ...rest }) {
  return <input {...register(name)} {...rest} />;
}

// Select
export function Select({ register, options, name, ...rest }) {
  return (
    <select {...register(name)} {...rest}>
      {options.map((value) => (
        <option key={value} value={value}>
          {value}
        </option>
      ))}
    </select>
  );
}