样式,或无样式?这是个问题
关于 Tiptap Editor 引发的一些思考
2023 年,春。
我终于遇上了那个心仪的那个“无设计组件”框架: radix-ui
它在 GitHub 上 7.6k 的点赞数量对于前端项目来说,不算多;但对于一个新项目而言绝对足够亮眼了。
除此之外,在它的基础上还诞生了另一个明星项目: shadcn
这个项目所使用的技术栈 (radix-ui + tailwindcss) 更是直接让 Vercel 的 DevRel 惊呼 —— 2023 年开发美观且易于访问的 web 应用的最佳技术栈。
果然,我的直觉是正确的!
自 React 掀起前端组件化浪潮以来已 9 年有余。组件化已成为了前端中绝对的政治正确。但组件化并不是万能的,前端组件化中存在着一个基本矛盾:组件与样式之间的矛盾。
当我们编写组件时,需要考虑以下特性:
- 独立性;组件间不应该拥有太多耦合
- 复用性;组件应该尽可能的被复用
而我们却希望样式拥有以下特性:
- 统一性;样式整体的设计风格需要统一
- 个性;设计需要体现出品牌的特色
这里就在逻辑上形成了一个不可能三角!独立性,统一性和个性最多只能存在两个,三者不可能同时存在!
过去与现在🔗
现在主流的解决方案是牺牲个性,而追求独立性和统一性,例如 Material UI、Chakra UI 等这类大而全的组件库。这类组件库确实能解决问题,但也带来了新的问题。因为使用了相同的组件库,导致现在的网站同质化严重。这也反过来限制住了程序员和设计师的发挥。假如某一天,设计师突然来出了一份相当炫酷 (但在现有组件库 Cover 之外) 的设计时,程序员大多是崩溃的 (修改别人的库比自己写一份还难受)。
而 (React 发明前的) 传统 Web 开发则更强调个性和统一性,毕竟也没有模块可以用。
而第三条路着更是最坏的结果!没有统一性,个性将毫无意义。
但其实我们还有第四条路可以走。那便是:将样式从组件中剥离开来,使用传统思路 (和现代方法),依靠设计师和程序员的水平完成个性又统一的设计。
逻辑组件🔗
我这一思考主要来自于 Tiptap Editor 给我带来的启示。
Tiptap Editor 是一个开源的 WYSIWYG (所见即所得) 编辑器。它本质上是对 ProseMirror 的封装,而后者被纽约时报等老牌媒体选择,在其 CMS 中使用。
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 Design、Human 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>
);
}