您的任务——如果您决定接受——是在四个框架中构建一个 Button 组件,但只使用一个 button.css
文件!
这个想法对我来说非常重要。 我一直在开发一个名为 AgnosticUI 的组件库,其目的是构建不与任何特定 JavaScript 框架绑定的 UI 组件。 AgnosticUI 可在 React、Vue 3、Angular 和 Svelte 中使用。 所以我们今天在这篇文章中将要做的就是:**构建一个跨所有这些框架的按钮组件。**
本文的源代码可在 GitHub 上获取,位于 the-little-button-that-could-series
分支。
目录
为什么要使用单一代码库?
我们将设置一个小型基于 Yarn 工作区的单一代码库。 为什么? Chris 实际上在 另一篇文章中很好地概述了其优势。 但以下是我自己偏见地列出的我认为与我们的小按钮项目相关的优势
耦合
我们正在尝试构建一个使用单个 button.css
文件跨多个框架的按钮组件。 因此,从本质上讲,各种框架实现和单一事实来源 CSS 文件之间存在一些有目的的 耦合。 单一代码库设置提供了一个方便的结构,便于将我们的单个 button.css
组件复制到各种基于框架的项目中。
工作流
假设按钮需要调整——比如“焦点环”实现,或者我们在组件模板中搞砸了 aria
的使用。 理想情况下,我们希望在一个地方进行更正,而不是在单独的存储库中进行单独的修复。
测试
我们希望能够方便地同时启动所有四个按钮实现进行测试。 随着这类项目的发展,可以肯定的是,将会有更多更专业的测试。 例如,在 AgnosticUI 中,我目前使用 Storybook,并且经常启动所有框架 Storybook,或者在整个单一代码库中运行快照测试。
我喜欢 Leonardo Losoviz 对 单一代码库方法的看法。 (恰好与我们迄今为止讨论的所有内容相符。)
我相信,当所有包都用相同的编程语言编写、紧密耦合并依赖于相同的工具时,单一代码库特别有用。
设置
是时候深入代码了——首先在命令行中创建一个顶级目录来容纳项目,然后 cd
到该目录中。 (想不到名字? mkdir buttons && cd buttons
可以正常工作。)
首先,让我们初始化项目
$ yarn init
yarn init v1.22.15
question name (articles): littlebutton
question version (1.0.0):
question description: my little button project
question entry point (index.js):
question repository url:
question author (Rob Levin):
question license (MIT):
question private:
success Saved package.json
这将为我们提供一个 package.json
文件,其中包含类似以下内容
{
"name": "littlebutton",
"version": "1.0.0",
"description": "my little button project",
"main": "index.js",
"author": "Rob Levin",
"license": "MIT"
}
创建基础工作区
我们可以使用以下命令设置第一个工作区
mkdir -p ./littlebutton-css
接下来,我们需要在单一代码库的顶级 package.json
文件中添加以下两行,以便我们将单一代码库本身设为私有。 它还声明了我们的工作区
// ...
"private": true,
"workspaces": ["littlebutton-react", "littlebutton-vue", "littlebutton-svelte", "littlebutton-angular", "littlebutton-css"]
现在进入 littlebutton-css
目录。 我们还需要使用 yarn init
生成一个 package.json
。 由于我们已将目录命名为 littlebutton-css
(与我们在 package.json
中指定的 workspaces
相同),因此我们只需按 Return
键并接受所有提示即可
$ cd ./littlebutton-css && yarn init
yarn init v1.22.15
question name (littlebutton-css):
question version (1.0.0):
question description:
question entry point (index.js):
question repository url:
question author (Rob Levin):
question license (MIT):
question private:
success Saved package.json
此时,目录结构应如下所示
├── littlebutton-css
│ └── package.json
└── package.json
我们目前只创建了 CSS 包工作区,因为我们将在使用诸如 vite
之类的工具生成框架实现时生成一个 package.json
和项目目录。 我们必须记住,我们为这些生成的项目选择的名字必须与我们在 package.json
中为 workspaces
指定的名字匹配,这样才能使我们的 workspaces
起作用。
基础 HTML 和 CSS
让我们留在 ./littlebutton-css
工作区中,并使用纯 HTML 和 CSS 文件创建简单的按钮组件。
touch index.html ./css/button.css
现在我们的项目目录应如下所示
littlebutton-css
├── css
│ └── button.css
├── index.html
└── package.json
让我们通过在 ./index.html
中添加一些基本 HTML 代码来连接一些点
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>The Little Button That Could</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="css/button.css">
</head>
<body>
<main>
<button class="btn">Go</button>
</main>
</body>
</html>
为了让测试时有一些视觉效果,我们可以在 ./css/button.css
中添加一点颜色
.btn {
color: hotpink;
}

现在在浏览器中打开 index.html
页面。 如果您看到一个带有 hotpink
文本的丑陋的通用按钮……那么您就成功了!
特定于框架的工作区
因此,我们刚刚完成的是按钮组件的基础。 我们现在要做的是对其进行抽象,使其可扩展到其他框架等。 例如,如果我们想在 React 项目中使用该按钮怎么办? 我们需要在单一代码库中为每个框架创建工作区。 我们将从 React 开始,然后依次创建 Vue 3、Angular 和 Svelte 的工作区。
React
我们将使用 vite(一个非常轻量级且速度极快的构建器)来生成我们的 React 项目。 请注意,如果您尝试使用 create-react-app
执行此操作,很有可能您以后会遇到 react-scripts
与其他框架(如 Angular)中来自 webpack 或 Babel 的冲突配置冲突。
要启动我们的 React 工作区,让我们回到终端并使用 `cd` 返回到顶层目录。从那里,我们将使用 `vite` 初始化一个新项目 - 我们称之为 `littlebutton-react` - 当然,我们会在提示中选择 `react` 作为框架和变体。
$ yarn create vite
yarn create v1.22.15
[1/4] 🔍 Resolving packages...
[2/4] 🚚 Fetching packages...
[3/4] 🔗 Linking dependencies...
[4/4] 🔨 Building fresh packages...
success Installed "[email protected]" with binaries:
- create-vite
- cva
✔ Project name: … littlebutton-react
✔ Select a framework: › react
✔ Select a variant: › react
Scaffolding project in /Users/roblevin/workspace/opensource/guest-posts/articles/littlebutton-react...
Done. Now run:
cd littlebutton-react
yarn
yarn dev
✨ Done in 17.90s.
接下来,我们用以下命令初始化 React 应用程序。
cd littlebutton-react
yarn
yarn dev
安装并验证 React 后,让我们用以下代码替换 `src/App.jsx` 的内容,以容纳我们的按钮。
import "./App.css";
const Button = () => {
return <button>Go</button>;
};
function App() {
return (
<div className="App">
<Button />
</div>
);
}
export default App;
现在,我们将编写一个小的 Node 脚本,将我们的 `littlebutton-css/css/button.css` 复制到我们的 React 应用程序中。这一步可能对我来说是最有趣的,因为它既神奇又丑陋。它很神奇,因为这意味着我们的 React 按钮组件确实是从基础项目中编写的相同 CSS 派生其样式的。它很丑陋,因为,好吧,我们正在从一个工作区向上伸出手,从另一个工作区获取文件。¯\_(ツ)_/¯
将以下小 Node 脚本添加到 `littlebutton-react/copystyles.js` 中。
const fs = require("fs");
let css = fs.readFileSync("../littlebutton-css/css/button.css", "utf8");
fs.writeFileSync("./src/button.css", css, "utf8");
让我们在 `littlebutton-react/package.json` 中的 `dev` 脚本之前,放置一个 `node` 命令来运行该脚本。我们将添加一个 `syncStyles` 并更新 `dev` 以在 `vite` 之前调用 `syncStyles`。
"syncStyles": "node copystyles.js",
"dev": "yarn syncStyles && vite",
现在,每当我们使用 `yarn dev` 启动 React 应用程序时,我们首先会将 CSS 文件复制过去。本质上,我们“强迫”自己不偏离 CSS 包中的 `button.css` 到我们的 React 按钮。
但是,我们还想利用 CSS Modules 来防止命名冲突和全局 CSS 泄漏,因此我们还需要做一步来将其连接起来(来自同一个 `littlebutton-react` 目录)。
touch src/button.module.css
接下来,将以下内容添加到新的 `src/button.module.css` 文件中。
.btn {
composes: btn from './button.css';
}
我发现 `composes`(也称为 composition)是 CSS Modules 最酷的功能之一。简而言之,我们正在将我们的 HTML/CSS 版本的 `button.css` 完整复制过来,然后从我们唯一的 `.btn` 样式规则中进行组合。
有了它,我们可以回到我们的 `src/App.jsx` 并使用以下代码将 CSS Modules 的 `styles` 导入到我们的 React 组件中。
import "./App.css";
import styles from "./button.module.css";
const Button = () => {
return <button className={styles.btn}>Go</button>;
};
function App() {
return (
<div className="App">
<Button />
</div>
);
}
export default App;
呼!让我们暂停一下,尝试再次运行我们的 React 应用程序。
yarn dev
如果一切顺利,你应该看到同一个通用按钮,但文本为 `hotpink`。在我们继续下一个框架之前,让我们回到顶层单体仓库目录,并更新其 `package.json`。
{
"name": "littlebutton",
"version": "1.0.0",
"description": "toy project",
"main": "index.js",
"author": "Rob Levin",
"license": "MIT",
"private": true,
"workspaces": ["littlebutton-react", "littlebutton-vue", "littlebutton-svelte", "littlebutton-angular"],
"scripts": {
"start:react": "yarn workspace littlebutton-react dev"
}
}
从顶层目录运行 `yarn` 命令以安装单体仓库提升的依赖项。
我们对这个 `package.json` 做出的唯一更改是在 `scripts` 部分中添加了一个新的脚本,用于启动 React 应用程序。通过添加 `start:react`,我们现在可以从我们的顶层目录运行 `yarn start:react`,它将启动我们在 `./littlebutton-react` 中构建的项目,而无需使用 `cd` - 非常方便!
接下来我们将处理 Vue 和 Svelte。事实证明,我们可以对它们采用非常类似的方法,因为它们都使用 单文件组件 (SFC)。基本上,我们可以将 HTML、CSS 和 JavaScript 混合到一个文件中。无论你是否喜欢 SFC 方法,它对于构建演示性或原始 UI 组件来说都足够了。
Vue
遵循 vite 脚手架文档 中的步骤,我们将从单体仓库的顶层目录运行以下命令来初始化 Vue 应用程序。
yarn create vite littlebutton-vue --template vue
这将生成脚手架,其中包含一些提供的说明来运行入门 Vue 应用程序。
cd littlebutton-vue
yarn
yarn dev
这应该在浏览器中启动一个入门页面,其中包含一些标题,例如“Hello Vue 3 + Vite”。从这里,我们可以更新 `src/App.vue` 为
<template>
<div id="app">
<Button class="btn">Go</Button>
</div>
</template>
<script>
import Button from './components/Button.vue'
export default {
name: 'App',
components: {
Button
}
}
</script>
我们将替换所有 `src/components/*` 为 `src/components/Button.vue`。
<template>
<button :class="classes"><slot /></button>
</template>
<script>
export default {
name: 'Button',
computed: {
classes() {
return {
[this.$style.btn]: true,
}
}
}
}
</script>
<style module>
.btn {
color: slateblue;
}
</style>
让我们稍微分解一下。
- `:class="classes"` 使用 Vue 的绑定来调用计算的 `classes` 方法。
- 反过来,`classes` 方法利用了 Vue 中的 CSS Modules,使用 `this.$style.btn` 语法,它将使用 `<style module>` 标签中包含的样式。
现在,我们只是硬编码 `color: slateblue`,只是为了测试组件内部是否正常工作。尝试再次使用 `yarn dev` 启动应用程序。如果你看到带有我们声明的测试颜色的按钮,那么它就可以正常工作了!
现在,我们将编写一个 Node 脚本,将我们的 `littlebutton-css/css/button.css` 复制到我们的 `Button.vue` 文件中,类似于我们为 React 实现所做的操作。如前所述,这个组件是 SFC,因此我们将不得不使用一个简单的 正则表达式 来做一些不同的事情。
将以下小 Node.js 脚本添加到 `littlebutton-vue/copystyles.js` 中。
const fs = require("fs");
let css = fs.readFileSync("../littlebutton-css/css/button.css", "utf8");
const vue = fs.readFileSync("./src/components/Button.vue", "utf8");
// Take everything between the starting and closing style tag and replace
const styleRegex = /<style module>([\s\S]*?)<\/style>/;
let withSynchronizedStyles = vue.replace(styleRegex, `<style module>\n${css}\n</style>`);
fs.writeFileSync("./src/components/Button.vue", withSynchronizedStyles, "utf8");
这个脚本稍微复杂一些,但是使用 `replace` 通过正则表达式在开始和结束的 `style` 标签之间复制文本并不算太糟。
现在,让我们在 `littlebutton-vue/package.json` 文件的 `scripts` 子句中添加以下两个脚本。
"syncStyles": "node copystyles.js",
"dev": "yarn syncStyles && vite",
现在运行 `yarn syncStyles` 并再次查看 `./src/components/Button.vue`。你应该看到我们的样式模块被替换为以下内容。
<style module>
.btn {
color: hotpink;
}
</style>
再次使用 `yarn dev` 运行 Vue 应用程序,并验证你是否获得了预期的结果 - 是的,一个带有 `hotpink` 文本的按钮。如果是这样,我们就可以继续下一个框架工作区了!
Svelte
根据 Svelte 文档,我们应该使用以下命令从单体仓库的顶层目录启动我们的 `littlebutton-svelte` 工作区。
npx degit sveltejs/template littlebutton-svelte
cd littlebutton-svelte
yarn && yarn dev
确认你可以在 `http://localhost:5000` 上访问“Hello World”启动页面。然后,更新 `littlebutton-svelte/src/App.svelte`。
<script>
import Button from './Button.svelte';
</script>
<main>
<Button>Go</Button>
</main>
另外,在 `littlebutton-svelte/src/main.js` 中,我们要删除 `name` 属性,使其看起来像这样。
import App from './App.svelte';
const app = new App({
target: document.body
});
export default app;
最后,添加 `littlebutton-svelte/src/Button.svelte`,内容如下。
<button class="btn">
<slot></slot>
</button>
<script>
</script>
<style>
.btn {
color: saddlebrown;
}
</style>
还有一件事:Svelte 似乎将我们的应用程序命名为:`package.json` 中的 `"name": "svelte-app"`。将其更改为 `"name": "littlebutton-svelte"`,使其与我们顶层 `package.json` 文件中的 `workspaces` 名称一致。
再次,我们可以将我们的基础 `littlebutton-css/css/button.css` 复制到我们的 `Button.svelte` 中。如前所述,这个组件是 SFC,因此我们将不得不使用 正则表达式 来做这件事。将以下 Node 脚本添加到 `littlebutton-svelte/copystyles.js` 中。
const fs = require("fs");
let css = fs.readFileSync("../littlebutton-css/css/button.css", "utf8");
const svelte = fs.readFileSync("./src/Button.svelte", "utf8");
const styleRegex = /<style>([\s\S]*?)<\/style>/;
let withSynchronizedStyles = svelte.replace(styleRegex, `<style>\n${css}\n</style>`);
fs.writeFileSync("./src/Button.svelte", withSynchronizedStyles, "utf8");
这与我们与 Vue 一起使用的复制脚本非常相似,不是吗?我们将添加类似的脚本到我们的 `package.json` 脚本中。
"dev": "yarn syncStyles && rollup -c -w",
"syncStyles": "node copystyles.js",
现在运行 `yarn syncStyles && yarn dev`。如果一切顺利,我们应该再次看到一个带有 `hotpink` 文本的按钮。
如果这开始感觉很重复,我只能说,欢迎来到我的世界。我在这里展示给你的本质上是我一直在使用的构建我的 AgnosticUI 项目的相同流程!
Angular
你现在可能知道该怎么做了吧。从单体仓库的顶层目录,安装 Angular 并 创建 Angular 应用程序。如果我们要创建一个完整的 UI 库,我们很可能会使用 `ng generate library` 甚至 `nx`。但为了尽可能简单,我们将设置一个 Angular 应用程序样板,如下所示。
npm install -g @angular/cli ### unless you already have installed
ng new littlebutton-angular ### choose no for routing and CSS
? Would you like to add Angular routing? (y/N) N
❯ CSS
SCSS [ https://sass-lang.com.cn/documentation/syntax#scss ]
Sass [ https://sass-lang.com.cn/documentation/syntax#the-indented-syntax ]
Less [ https://lesscss.org.cn ]
cd littlebutton-angular && ng serve --open
确认 Angular 设置后,让我们更新一些文件。`cd littlebutton-angular`,删除 `src/app/app.component.spec.ts` 文件,并在 `src/components/button.component.ts` 中添加一个按钮组件,如下所示。
import { Component } from '@angular/core';
@Component({
selector: 'little-button',
templateUrl: './button.component.html',
styleUrls: ['./button.component.css'],
})
export class ButtonComponent {}
将以下内容添加到 `src/components/button.component.html` 中。
<button class="btn">Go</button>
并将此内容放入 `src/components/button.component.css` 文件中以进行测试。
.btn {
color: fuchsia;
}
在 `src/app/app.module.ts` 中。
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { ButtonComponent } from '../components/button.component';
@NgModule({
declarations: [AppComponent, ButtonComponent],
imports: [BrowserModule],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule {}
接下来,用以下内容替换 `src/app/app.component.ts`。
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {}
然后,用以下内容替换 `src/app/app.component.html`。
<main>
<little-button>Go</little-button>
</main>
有了它,让我们运行 `yarn start` 并验证我们的按钮是否按预期显示了 `fuchsia` 文本。
同样,我们要将基础工作区中的 CSS 复制过来。我们可以通过在 `littlebutton-angular/copystyles.js` 中添加以下内容来做到这一点。
const fs = require("fs");
let css = fs.readFileSync("../littlebutton-css/css/button.css", "utf8");
fs.writeFileSync("./src/components/button.component.css", css, "utf8");
Angular 很好的一点是它使用 ViewEncapsulation,默认值为 `emulate`,它模拟了,根据文档,
[…] 通过预处理(并重命名)CSS 代码来模拟 Shadow DOM 的行为,从而有效地将 CSS 范围限定到组件的视图。
这基本上意味着我们可以直接复制 `button.css` 并按原样使用它。
最后,更新 `package.json` 文件,在 `scripts` 部分添加以下两行。
"start": "yarn syncStyles && ng serve",
"syncStyles": "node copystyles.js",
有了它,我们现在可以再次运行 `yarn start` 并验证我们的按钮文本颜色(之前是 `fuchsia`)现在是 `hotpink`。
我们做了什么?
让我们休息一下编码,想想更大的画面以及我们刚刚做了什么。基本上,我们建立了一个系统,在这个系统中,对 CSS 包的 `button.css` 的任何更改都会被复制到所有框架实现中,这是我们 `copystyles.js` Node 脚本的结果。此外,我们已经整合了每个框架的习惯用法。
SFC
用于 Vue 和 SvelteCSS Modules
用于 React(以及 Vue 中 SFC 的 `<style module>` 设置)ViewEncapsulation
用于 Angular
当然,我声明显而易见的是,这些并不是在每个上述框架中进行 CSS 的唯一方法(例如,CSS-in-JS 是一个流行的选择),但它们肯定是可以接受的做法,并且非常适合我们的更大目标 - 拥有一个单一的 CSS 真实来源来驱动所有框架实现。
例如,如果我们的按钮正在使用中,我们的设计团队决定要将 `4px` 的 `border-radius` 更改为 `3px`,我们可以更新一个文件,任何单独的实现都会保持同步。
如果您有一支精通多种框架的开发人员组成的多语言团队,或者,比方说,有一个(在 Angular 中生产力提高 3 倍的)外包团队,他们被要求构建一个后台应用程序,但您的旗舰产品是用 React 构建的。或者,您正在构建一个临时的管理控制台,并且希望尝试使用 Vue 或 Svelte。您明白了我的意思。
收尾工作
好的,我们现在已经把 monorepo 架构放在了一个很好的位置。但我们还可以做一些事情,以提高开发人员的体验,使其更加有用。
更好的启动脚本
让我们回到我们的顶层 monorepo 目录,并更新其 package.json
scripts
部分,添加以下内容,以便我们可以启动任何框架实现,而无需使用 cd
// ...
"scripts": {
"start:react": "yarn workspace littlebutton-react dev",
"start:vue": "yarn workspace littlebutton-vue dev ",
"start:svelte": "yarn workspace littlebutton-svelte dev",
"start:angular": "yarn workspace littlebutton-angular start"
},
更好的基线样式
我们还可以为按钮提供更好的基线样式,使其从一个漂亮的中性位置开始。以下是我在 littlebutton-css/css/button.css
文件中所做的操作。
查看完整代码片段
.btn {
--button-dark: #333;
--button-line-height: 1.25rem;
--button-font-size: 1rem;
--button-light: #e9e9e9;
--button-transition-duration: 200ms;
--button-font-stack:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Ubuntu,
"Helvetica Neue",
sans-serif;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
user-select: none;
appearance: none;
cursor: pointer;
box-sizing: border-box;
transition-property: all;
transition-duration: var(--button-transition-duration);
color: var(--button-dark);
background-color: var(--button-light);
border-color: var(--button-light);
border-style: solid;
border-width: 1px;
font-family: var(--button-font-stack);
font-weight: 400;
font-size: var(--button-font-size);
line-height: var(--button-line-height);
padding-block-start: 0.5rem;
padding-block-end: 0.5rem;
padding-inline-start: 0.75rem;
padding-inline-end: 0.75rem;
text-decoration: none;
text-align: center;
}
/* Respect users reduced motion preferences */
@media (prefers-reduced-motion) {
.btn {
transition-duration: 0.001ms !important;
}
}
让我们测试一下!使用新的和改进的启动脚本启动所有四个框架实现,并确认样式更改已生效。

一个 CSS 文件更新传播到四个框架 - 太酷了,对吧?
设置主模式
我们将为每个按钮添加一个 mode
属性,并接下来实现 primary
模式。主按钮可以是任何颜色,但我们将使用绿色阴影作为背景,白色作为文字。同样,在基线样式表中
.btn {
--button-primary: #14775d;
--button-primary-color: #fff;
/* ... */
}
然后,就在 @media (prefers-reduced-motion)
查询之前,将以下 btn-primary
添加到相同的基线样式表中
.btn-primary {
background-color: var(--button-primary);
border-color: var(--button-primary);
color: var(--button-primary-color);
}
好了!一些开发者便利性和更好的基线样式!
mode
属性
更新每个组件以接受 现在我们已经添加了新的 primary
模式,它由 .btn-primary
类表示,我们希望同步所有四个框架实现的样式。因此,让我们在顶层 scripts
中添加更多 package.json
脚本
"sync:react": "yarn workspace littlebutton-react syncStyles",
"sync:vue": "yarn workspace littlebutton-vue syncStyles",
"sync:svelte": "yarn workspace littlebutton-svelte syncStyles",
"sync:angular": "yarn workspace littlebutton-angular syncStyles"
请务必遵守 JSON 的逗号规则!根据您在 scripts: {...}
中放置这些行的位置,您需要确保没有缺失或尾随逗号。
继续执行以下操作以完全同步样式
yarn sync:angular && yarn sync:react && yarn sync:vue && yarn sync:svelte
运行此操作不会改变任何内容,因为我们还没有应用 primary 类,但如果您查看框架的按钮组件 CSS,您至少应该看到 CSS 已经被复制过去了。
React
如果您还没有,请再次检查更新后的 CSS 是否已复制到 littlebutton-react/src/button.css
中。如果没有,您可以运行 yarn syncStyles
。请注意,如果您忘记运行 yarn syncStyles
,我们的 dev
脚本将在我们下次启动应用程序时为我们执行此操作。
"dev": "yarn syncStyles && vite",
对于我们的 React 实现,我们还需要在 littlebutton-react/src/button.module.css
中添加一个组合的 CSS 模块类,该类由新的 .btn-primary
组成
.btnPrimary {
composes: btn-primary from './button.css';
}
我们还将更新 littlebutton-react/src/App.jsx
import "./App.css";
import styles from "./button.module.css";
const Button = ({ mode }) => {
const primaryClass = mode ? styles[`btn${mode.charAt(0).toUpperCase()}${mode.slice(1)}`] : '';
const classes = primaryClass ? `${styles.btn} ${primaryClass}` : styles.btn;
return <button className={classes}>Go</button>;
};
function App() {
return (
<div className="App">
<Button mode="primary" />
</div>
);
}
export default App;
从顶层目录使用 yarn start:react
启动 React 应用程序。如果一切顺利,您现在应该会看到绿色的主按钮。

需要注意的是,为了简洁起见,我将 Button 组件保留在 App.jsx
中。如果您不喜欢这样做,可以将 Button 组件分解到它自己的文件中。
Vue
再次检查按钮样式是否已复制过去,如果没有,请运行 yarn syncStyles
。
接下来,对 littlebutton-vue/src/components/Button.vue
的 <script>
部分进行以下更改
<script>
export default {
name: 'Button',
props: {
mode: {
type: String,
required: false,
default: '',
validator: (value) => {
const isValid = ['primary'].includes(value);
if (!isValid) {
console.warn(`Allowed types for Button are primary`);
}
return isValid;
},
}
},
computed: {
classes() {
return {
[this.$style.btn]: true,
[this.$style['btn-primary']]: this.mode === 'primary',
}
}
}
}
</script>
现在我们可以更新 littlebutton-vue/src/App.vue
中的标记以使用新的 mode
属性
<Button mode="primary">Go</Button>
现在您可以从顶层目录执行 yarn start:vue
并检查相同的绿色按钮。
Svelte
让我们 cd
到 littlebutton-svelte
并验证 littlebutton-svelte/src/Button.svelte
中的样式是否已复制新的 .btn-primary
类,并根据需要运行 yarn syncStyles
。同样,如果您忘记了,dev
脚本将在下次启动时为我们执行此操作。
接下来,更新 Svelte 模板以传递 mode
为 primary
的值。在 src/App.svelte
中
<script>
import Button from './Button.svelte';
</script>
<main>
<Button mode="primary">Go</Button>
</main>
我们还需要更新 src/Button.svelte
组件本身的顶部,以接受 mode
属性并应用 CSS 模块类
<button class="{classes}">
<slot></slot>
</button>
<script>
export let mode = "";
const classes = [
"btn",
mode ? `btn-${mode}` : "",
].filter(cls => cls.length).join(" ");
</script>
请注意,此步骤中不应触及 Svelte 组件的 <styles>
部分。
现在,您可以从 littlebutton-svelte
(或从更高级目录执行 yarn start:svelte
) 执行 yarn dev
来确认绿色按钮已经生成了!
Angular
相同的事情,不同的框架:检查样式是否已复制过去,并根据需要运行 yarn syncStyles
。
让我们将 mode
属性添加到 littlebutton-angular/src/app/app.component.html
文件中
<main>
<little-button mode="primary">Go</little-button>
</main>
现在我们需要设置一个绑定到 classes
getter 的绑定,以根据是否已将 mode
传递给组件来计算
正确的类。将此添加到 littlebutton-angular/src/components/button.component.html
(并注意绑定是使用方括号进行的)
<button [class]="classes">Go</button>
接下来,我们需要在 littlebutton-angular/src/components/button.component.ts
中的组件中实际创建 classes
绑定
import { Component, Input } from '@angular/core';
@Component({
selector: 'little-button',
templateUrl: './button.component.html',
styleUrls: ['./button.component.css'],
})
export class ButtonComponent {
@Input() mode: 'primary' | undefined = undefined;
public get classes(): string {
const modeClass = this.mode ? `btn-${this.mode}` : '';
return [
'btn',
modeClass,
].filter(cl => cl.length).join(' ');
}
}
我们使用 Input
指令来接收 mode
属性,然后我们创建一个 classes
访问器,如果已传递 mode
,则添加 mode
类。
启动它并查看绿色按钮!
代码完成
如果您已经完成了这一步,恭喜您 - 您已经完成了代码!如果出现问题,我建议您交叉引用GitHub 上的源代码,该代码位于 the-little-button-that-could-series
分支中。由于打包程序和包往往会发生突然的变化,如果您遇到任何依赖性问题,您可能需要将您的包版本锁定到此分支中的版本。
花点时间回顾一下我们刚刚构建的四个基于框架的按钮组件实现。它们仍然足够小,可以快速发现一些有趣的差异,例如props 的传递方式、绑定到 props 的方式以及 CSS 名称冲突 的避免方式,以及其他一些细微的差异。随着我继续将组件添加到 AgnosticUI (它支持这四个完全相同的框架),我一直在思考哪一个提供了最好的开发者体验。您怎么看?
作业
如果您喜欢自己解决问题或更深入地挖掘,以下是一些想法。
按钮状态
当前按钮样式没有考虑各种状态,例如 :hover
。我认为这是一个很好的第一个练习。
/* You should really implement the following states
but I will leave it as an exercise for you to
decide how to and what values to use.
*/
.btn:focus {
/* If you elect to remove the outline, replace it
with another proper affordance and research how
to use transparent outlines to support windows
high contrast
*/
}
.btn:hover { }
.btn:visited { }
.btn:active { }
.btn:disabled { }
变体
大多数按钮库都支持许多按钮变体,用于大小、形状和颜色等内容。尝试创建比我们已经存在的 primary
模式更多的模式。也许是 secondary
变体?warning
或 success
?也许 filled
和 outline
?同样,您可以查看 AgnosticUI 的 按钮页面 获取想法。
CSS 自定义属性
如果您还没有开始使用 CSS 自定义属性,我强烈建议您使用。您可以从查看 AgnosticUI 的 通用样式 开始。我在那里大量使用了自定义属性。以下是一些关于自定义属性是什么以及如何利用它们的优秀文章
类型
不……不是类型,而是 <button>
元素的 type
属性。我们在组件中没有涵盖它,但有机会将组件扩展到其他使用案例,包括有效的类型,如 button
、submit
和 reset
。这很容易实现,并且将极大地改善按钮的 API。
更多想法
天哪,你可以做很多事情——添加 linting、将其转换为 Typescript、审核可访问性等等。
当前的 Svelte 实现存在一些相当松散的假设,因为如果未传递有效的 primary
模式,我们没有任何防御措施——这将产生一个错误的 CSS 类
mode ? `btn-${mode}` : "",
你可能会说,“嗯,.btn-garbage
作为类并不完全有害。”但是,最好在可能的情况下 防御性地编写样式。
潜在的陷阱
在进一步采用这种方法之前,您应该了解一些事项
- 基于标记结构的定位 CSS 对于此处使用的基于 CSS 模块的技术效果不佳。
- Angular 使定位技术更加困难,因为它生成
:host
元素 来表示每个组件视图。这意味着您在模板或标记结构之间有这些额外的元素。您需要解决这个问题。 - 跨工作区包复制样式对某些人来说是一种反模式。我之所以证明它,是因为我认为好处超过了成本;此外,当我想到单体仓库如何使用符号链接和(不太可靠的)提升时,我对这种方法的感觉并不那么糟糕。
- 您将不得不使用此处使用的解耦技术,因此没有 CSS-in-JS。
我相信所有软件开发方法都有其优缺点,您最终必须决定跨框架共享单个 CSS 文件是否适合您或您的特定项目。如果需要,当然还有其他方法可以做到这一点(例如,使用 littlebuttons-css
作为 npm 包依赖项)。
结论
希望我已经激发了您的兴趣,现在您真的对创建不绑定到特定框架的 UI 组件库和/或设计系统很感兴趣。也许您对如何实现这一点有更好的想法——我很乐意在评论中听到您的想法!
我相信您已经看到了久负盛名的 TodoMVC 项目,以及为其创建了多少框架实现。同样,拥有一个可用于许多框架的 UI 组件库的基元不是很好吗? Open UI 正在为正确标准化原生 UI 组件默认值做出巨大努力,但我相信我们总是需要在某种程度上介入。当然,花一整年时间来构建一个自定义设计系统正在迅速失宠,公司正在认真质疑其 ROI。需要某种脚手架才能使这项工作变得实用。
AgnosticUI 的愿景是拥有一个相对通用的方法来快速构建不绑定到特定前端框架的设计系统。如果您有兴趣参与,该项目仍处于早期阶段,并且易于上手,我非常需要帮助!另外,您已经非常熟悉项目的运作方式,因为您已经完成了本教程!
Web 组件,又名自定义元素
感谢 Cintron,好主意!如果可能,优先考虑 Web 组件可能是可行的。
但是如何将它们放回框架中?特别是 React?
大约一年前,我研究了 Stencil,您不幸地必须做些什么才能使 Web 组件与 React 正常工作。大量的
ref
操纵等等。这让我很沮丧。但 Web 组件的理念无疑是高尚的,我一定会密切关注它的发展。除了 Stencil 之外,您是否发现了另一种跨多个框架使用 Web 组件的方法?实际情况是,您在这些框架中拥有现有的或遗留项目,或者您没有发言权,因为您的团队已经做出了决定,您的团队只知道框架-X,等等。因此,如果有一种更好的方法将这些 Web 组件移植到这些流行的框架中,我想学习一下。
顺便说一句,您刚刚提醒我,我们不得不处理 React 中的
ref
以及 Svelte 中的bind:this
等,才能正确地实现包容性的键盘导航和焦点管理,这也很不幸。我的意思是,这是核心包容性功能,对吧?为什么“受控组件”或任何帮助我提供 API 的抽象让我可以调用focus()
?我必须撸起袖子,写一些非常棘手的代码!我真的很希望我们以更友好的开发体验和更现实的方式解决可访问的导航。我刚刚读了您对其他评论的回复,基本上和我说的内容一样。您只需将道具传递给元素属性。
我有点难以理解,你认为最好的方法是手动编写,并无限期地更新大型组件库中每个组件的每个框架版本。然后,如果一年后出现了一个新的流行 JS 框架,上帝保佑你——现在你必须重新编写整个库。
在几行 CSS 上做到 DRY,然后在更易出错且耗时的部分直接采用中世纪的方法,这实在太可笑了。
现在是 2023 年,兄弟,请研究抽象语法树和代码生成工具。这些工具将选定的源语言中的有效代码作为输入,从中提取语法树,然后将其输出为一种或多种不同的目标语言——它们是代码编译器的幕后功臣。当然,在您的情况下,主要是将框架转换为语言,但快速搜索 Google 显示有一个 Vue 到 React 组件转换器可用,因此显然已经解决了这个问题。
作为 UI 库创建者,您的时间不应该浪费在追逐错误上,因为您忘记更新某个组件的 Svelte 版本或其他什么东西。