关于 bootstrap-component-js
面向 Bootstrap 6 开发者的 JavaScript 组件技能,说明如何按项目约定实现 TypeScript 插件,并覆盖配置、事件、数据 API、清理和测试流程。
主要功能
- 01组件模块结构与配置
- 02命名空间事件和数据 API
- 03浮动定位及第三方库封装
- 04实例清理、注册与测试
适用场景
- 01为 Bootstrap 6 新增 JavaScript 组件
- 02为组件接入配置属性与事件
- 03为组件编写测试并验证类型
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ twbs/ bootstrap --skill bootstrap-component-js安装后在 Claude Code 中输入/bootstrap-component-js确认已加载。
详细介绍
Bootstrap 6 组件 JavaScript
每个 Bootstrap 插件都是 js/src/ 中的 TypeScript 模块,并扩展 BaseComponent。它负责处理实例注册、配置合并和清理。遵循统一结构后,组件会自动获得 getInstance()、getOrCreateInstance()、data-bs-* 配置、带命名空间的事件以及 dispose(),无需手动编写这些内容。
源码使用 TypeScript,但运行时模式与 v5 相同,只是在其基础上添加了类型。Bootstrap 会将其编译为 JavaScript 并生成自己的声明文件,因此使用者不需要 @types/bootstrap。
关于组件样式,请参阅 bootstrap-component 技能。
工作流
- 步骤 1:搭建模块
- 步骤 2:定义配置选项
- 步骤 3:实现行为和事件
- 步骤 4:接入数据 API
- 步骤 5:使用 Floating UI 定位(可选)
- 步骤 6:封装第三方库(可选)
- 步骤 7:在 dispose() 中清理
- 步骤 8:注册并测试
- 步骤 9:验证
步骤 1:搭建模块
创建 js/src/<name>.ts。先阅读 js/src/alert.ts——这是最精简的完整示例。
/**
* --------------------------------------------------------------------------
* Bootstrap widget.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component.js'
import EventHandler from './dom/event-handler.js'
/**
* Constants
*/
const NAME = 'widget'
const DATA_KEY = 'bs.widget'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const EVENT_SHOW = `show${EVENT_KEY}`
const EVENT_SHOWN = `shown${EVENT_KEY}`
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
const CLASS_NAME_SHOW = 'show'
const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="widget"]'
/**
* Class definition
*/
class Widget extends BaseComponent {
// Getters
static override get NAME(): string {
return NAME
}
}
export default Widget
NAME是唯一必需的静态属性。BaseComponent会根据它派生出DATA_KEY(bs.<name>)和EVENT_KEY(.bs.<name>),使用Data.set()将实例存储在元素上,并释放绑定到同一元素的任何先前实例。- 保留各部分注释(
Constants、Class definition、Data API implementation)和成员分组(constructor、// Getters、// Public、// Private、// Static)——js/src/中的每个模块都采用相同结构。 - 仓库风格:仅使用 ES 模块,不加分号,缩进 2 个空格,使用单引号,并在每个覆盖
BaseComponent或Config成员的地方添加override。 - 尽管磁盘上的文件是
.ts,相对导入仍使用 ESM 的.js扩展名,因为tsconfig.json将moduleResolution设为nodenext。Rolldown、Vite 和 Vitest 都会将该说明符映射回.ts源文件。 - 编译器有一些严格规则,编写代码前值得了解:
verbatimModuleSyntax要求标记类型导入(import EventHandler, { type BootstrapEvent } from './dom/event-handler.js'),而erasableSyntaxOnly不允许使用枚举、命名空间和构造函数参数属性。 - 按照
toast.ts的做法,将实例状态声明为protected declare _foo: Type,并在构造函数中赋值。declare仅用于类型;如果真实字段没有初始值,在useDefineForClassFields下会生成_foo = undefined,从而清除基类构造函数设置的值。 - v6 没有
defineJQueryPlugin,也没有js/src/util.ts。辅助函数位于js/src/util/;DOM 封装位于js/src/dom/(event-handler、selector-engine、manipulator、data)。
步骤 2:定义配置选项
将配置形状、默认值和运行时类型声明为模块常量,然后将它们公开为静态属性:
type WidgetConfig = {
animation: boolean
delay: number
}
const Default: WidgetConfig = {
animation: true,
delay: 5000
}
const DefaultType = {
animation: 'boolean',
delay: 'number'
}
class Widget extends BaseComponent {
protected declare _config: WidgetConfig
protected declare _timeout: number | null
constructor(element?: string | Element | null, config?: Partial<WidgetConfig> | null) {
super(element, config)
this._timeout = null
}
// Getters
static override get Default(): WidgetConfig {
return Default
}
static override get DefaultType(): Record<string, string> {
return DefaultType
}
static override get NAME(): string {
return NAME
}
}
export default Widget
export type { WidgetConfig }
- 配置类型有多重用途:
Default使用该类型进行类型标注;构造函数接受其Partial<>,因为调用方只会传入部分配置;重新声明protected declare _config: WidgetConfig则会收紧BaseComponent的宽泛配置类型,使this._config.delay的类型为number。DefaultType保持为普通字符串映射——它用于运行时检查,而非编译器。请导出该类型;类型测试会导入它。 Config._mergeConfigObj()会按以下顺序合并配置(后者优先):Default→data-bs-config中的 JSON → 单独的data-bs-*属性 → 传给构造函数的对象。这些逻辑无需手动编写。- 读取时,
data-bs-*名称会转换为驼峰式,因此data-bs-auto-hide="false"会变成autoHide: false。 DefaultType中的每个键都会经过验证,不匹配时会抛出TypeError。值会按正则表达式匹配,因此支持联合类型:'boolean'、'(number|string)'、'(string|element|function)'。- 在验证前,可通过覆盖
_configAfterMerge(config)来规范化值——通常在此处将选择器字符串解析为元素,或转换简写形式。
步骤 3:实现行为和事件
class Widget extends BaseComponent {
// Public
show(): void {
const showEvent = EventHandler.trigger(this._element, EVENT_SHOW)
if (showEvent.defaultPrevented) {
return
}
this._element.classList.add(CLASS_NAME_SHOW)
this._queueCallback(() => {
EventHandler.trigger(this._element, EVENT_SHOWN)
}, this._element, this._config.animation)
}
}
- 事件成对出现:动作执行前触发一个不定式事件(
show.bs.widget,可取消),动作执行后触发一个过去分词事件(shown.bs.widget)。如果“执行前”事件的defaultPrevented为true,请立即退出。 _queueCallback(callback, element, isAnimated)会在element上的 CSS 过渡结束后运行回调;如果实例在过渡期间被销毁,则跳过回调。根据配置传入动画标志,这样未启用动画的组件会立即触发回调。- 如果监听器需要上下文,请添加载荷:
EventHandler.trigger(this._element, EVENT_SHOWN, { relatedTarget })。trigger()会返回BootstrapEvent,即扩展了载荷键的Event,因此在处理函数中读取这些键无需类型断言。 - 使用
SelectorEngine(findOne、find、next、prev、getElementFromSelector)查询元素,而不是直接使用querySelector;使用Manipulator读取或写入属性。 - 使用带命名空间的常量,通过
EventHandler.on(this._element, EVENT_MOUSEOVER, …)绑定内部监听器,这样dispose()一次调用即可移除它们。
步骤 4:接入 data API
data API 应作为导入时副作用,放在模块底部、类定义之后:
/**
* Data API implementation
*/
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {
event.preventDefault()
Widget.getOrCreateInstance(this).toggle()
})
- 从
document委托事件,并为事件添加.data-api命名空间,以便与用户监听器区分。 - 需要获取委托元素时,使用
function而不是箭头函数:EventHandler会为回调中的this指定类型,因此this就是匹配SELECTOR_DATA_TOGGLE的元素。只有在需要获取实际点击目标时,才使用箭头函数并通过event.target访问。 - 优先使用
js/src/util/component-functions.ts中的共享辅助函数,而不是手动编写处理程序:enableDismissTrigger(Widget)会将[data-bs-dismiss="widget"]接入hide()。其他情况请传入方法名:enableDismissTrigger(Alert, 'close')。eventActionOnPlugin(Widget, 'click', SELECTOR_DATA_TOGGLE, 'toggle')用于处理“触发元素通过data-bs-target指向一个或多个目标”的情况,并会跳过已禁用的元素。
- 由于这些是副作用,只有导入模块后,data API 才会生效。任何只导入单个组件文件的代码,只会获得该组件的 data API,不会获得其他组件的。
步骤 5:使用 Floating UI 定位(可选)
菜单、工具提示和弹出框使用 @floating-ui/dom 定位,该依赖为可选的 peer dependency。在构造函数中调用 super() 之前检查它是否存在——由于该类声明字段而非初始化字段,因此这样做是合法的:
import { autoUpdate, computePosition } from '@floating-ui/dom'
class Widget extends BaseComponent {
protected declare _floatingCleanup: (() => void) | null
constructor(element?: string | Element | null, config?: Partial<WidgetConfig> | null) {
if (typeof computePosition === 'undefined') {
throw new TypeError('Bootstrap\'s widgets require Floating UI (https://floating-ui.com)')
}
super(element, config)
this._floatingCleanup = null
}
}
在元素显示时创建定位器,并保留清理函数,以便取消定位:
class Widget extends BaseComponent {
// Private
protected _createFloating(): void {
this._updateFloatingPosition()
this._floatingCleanup = autoUpdate(this._element, this._floatingEl, () => this._updateFloatingPosition())
}
protected _disposeFloating(): void {
if (this._floatingCleanup) {
this._floatingCleanup()
this._floatingCleanup = null
}
}
}
js/src/util/floating-ui.ts 会处理定位逻辑,无需重新实现:getDefaultPlacement() 会为 RTL 翻转 -start / -end,parseResponsivePlacement() 和 getResponsivePlacement() 支持 data-bs-placement="bottom-start md:top-end",而 createBreakpointListeners() / disposeBreakpointListeners() 会在断点变化时重新定位。
步骤 6:封装第三方库(可选)
js/src/datepicker.ts 封装了 vanilla-calendar-pro,可作为此模式的参考:
- 保留 Bootstrap 的配置接口。用户设置
data-bs-*选项;私有构建器(_buildCalendarOptions())会将其转换为该库所需的选项格式。 - 在构造函数中调用私有初始化器来创建实例,并将实例保存在私有字段中;字段类型使用该库自身的类型(
protected declare _calendar: Calendar | null)。 - 将该库的回调重新触发为带命名空间的 Bootstrap 事件,让使用者只需了解一种 API。
- 在
super.dispose()之前于dispose()中调用该库的清理方法,并将字段置为null。 - 将该软件包添加到
package.json的peerDependencies中,检查其是否存在,并在组件的文档页面中说明额外的安装步骤。
不要分叉或内置该库,也不要将其实例作为公共 API 暴露——用户所需的一切都应通过 Bootstrap 类的方法或事件提供。
步骤 7:在 dispose() 中清理
class Widget extends BaseComponent {
// Public
override dispose(): void {
clearTimeout(this._timeout!)
this._observer?.disconnect()
this._disposeFloating()
super.dispose()
}
}
super.dispose() 会移除 Data 条目,移除在 this._element 上通过 EVENT_KEY 绑定的所有监听器,并将实例自身的属性置空。其他所有内容都需要你先自行撤销:定时器、MutationObserver / ResizeObserver 实例、绑定在 document 或 window 上的监听器、Floating UI 清理逻辑,以及第三方实例。这里的内存泄漏会导致“should dispose”测试失败。
步骤 8:注册并测试
-
在
js/src/index.ts中添加导出:export { default as Widget } from './widget.js'该 barrel 文件是包的入口,并为
dist/js/bootstrap.js和 bundle 提供内容。无需更新其他内容:Rolldown 从js/src/index.ts构建,build/build-plugins.mjs会用 glob 匹配js/src/**/*.ts,以生成js/dist/中的各插件文件,而 Vitest 会发现js/tests/unit/**/*.spec.js。 -
扩展两项类型测试。
js/tests/types/api.ts会根据源码断言公共接口;js/tests/types/consumer.ts会根据发布的js/dist/*.d.ts断言相同接口,并像下游项目那样通过包名导入。将该类、其配置类型和方法添加到两项测试中——声明文件生成错误会通过第一项测试,却无法通过第二项。 -
在
js/tests/unit/<name>.spec.js中添加测试。测试使用纯 JavaScript,并通过 Vitest 浏览器模式在真实的 Chromium 中运行:import Widget from '../../src/widget.js' import { clearFixture, getFixture } from '../helpers/fixture.js' describe('Widget', () => { let fixtureEl beforeAll(() => { fixtureEl = getFixture() }) afterEach(() => { clearFixture() }) it('should expose the plugin name and data key', () => { expect(Widget.NAME).toEqual('widget') expect(Widget.DATA_KEY).toEqual('bs.widget') }) })describe、it、expect和钩子都是全局变量(js/tests/vitest.config.mts中设置了globals: true),因此不要导入它们。需要使用 spy 或假定时器时,从vitest导入vi——这是新测试的首选 API。js/tests/vitest-setup.js会将旧测试使用的 Jasmine API 映射到 Vitest,因此spyOn、jasmine.clock()、toHaveClass、toBeTrue、toBeFalse和toHaveSize仍可使用。注意两点:通过 shim 提供的spyOn会替换原方法(要调用真实方法,请添加.and.callThrough());Vitest 4 移除了done回调,因此异步测试应返回一个 promise,并在监听器中将其解析。- 覆盖使用选择器和元素的构造方式、来自
data-bs-*的配置、每个公共方法、两个事件(包括可取消事件)、无需手动实例化的数据 API,以及dispose()。覆盖率会统计js/src中的每个文件,因此没有测试的组件会将覆盖率拉低到js/tests/vitest.config.mts中的阈值以下(语句、函数和行覆盖率为 90%;分支覆盖率为 88%),导致构建失败。
-
在组件的文档页面中添加说明:在 frontmatter 中添加
js: optional(或js: required),在### Options部分中添加<JsDataAttributes />,并使用<BsTable>表格列出选项、方法和事件。演示代码片段位于site/src/assets/partials/snippets.js,并通过<JsDocs name="live-widget" file="site/src/assets/partials/snippets.js" />渲染。
步骤 9:验证
npm run js-lintnpm run js-typecheck——对js/src和类型测试运行tsc --noEmit。- 运行
npm run js-test-unit,或运行npm run js-test,以额外执行两个 Rolldown 集成 bundle,验证模块支持 tree-shaking。每台机器都需要安装一次 Playwright 浏览器(npx playwright install chromium);运行npm run js-debug可在可见浏览器中运行测试。 npm run js——Rolldown 将源码编译到dist/js和js/dist,terser 负责压缩,tsc则生成.d.ts文件。检查 bundle 的增长幅度是否大致符合预期;npm run bundlewatch会强制执行体积预算。- 运行
npm start,并在组件的文档页面中使用键盘和鼠标测试组件。
在你自己的项目中
package.json 将 bootstrap/js/src/*.js 映射到编译后的 js/dist/*.js 及其声明文件,因此 JavaScript 和 TypeScript 中都可以使用相同的说明符,自定义组件也可以基于同一个基类构建:
import BaseComponent from 'bootstrap/js/src/base-component.js'
import EventHandler from 'bootstrap/js/src/dom/event-handler.js'
class Widget extends BaseComponent {
static override get NAME(): string {
return 'widget'
}
}
请记住两点。导入 Bootstrap 组件模块会将其数据 API 作为副作用运行,这在应用入口文件中正合适,但不适合在库中这样做。此外,DATA_KEY 会派生为 bs.<name>,因此请选一个不会与 Bootstrap 插件冲突的 NAME,否则你的实例会替换共享元素上的插件实例。