mcpField
B
bootstrap-component-js

bootstrap-component-js

github.com/twbs开发175.0k351 次安装今天更新
代码仓库

关于 bootstrap-component-js

面向 Bootstrap 6 开发者的 JavaScript 组件技能,说明如何按项目约定实现 TypeScript 插件,并覆盖配置、事件、数据 API、清理和测试流程。

主要功能

  1. 01组件模块结构与配置
  2. 02命名空间事件和数据 API
  3. 03浮动定位及第三方库封装
  4. 04实例清理、注册与测试

适用场景

  1. 01为 Bootstrap 6 新增 JavaScript 组件
  2. 02为组件接入配置属性与事件
  3. 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,可作为此模式的参考:

  1. 保留 Bootstrap 的配置接口。用户设置 data-bs-* 选项;私有构建器(_buildCalendarOptions())会将其转换为该库所需的选项格式。
  2. 在构造函数中调用私有初始化器来创建实例,并将实例保存在私有字段中;字段类型使用该库自身的类型(protected declare _calendar: Calendar | null)。
  3. 将该库的回调重新触发为带命名空间的 Bootstrap 事件,让使用者只需了解一种 API。
  4. 在 super.dispose() 之前于 dispose() 中调用该库的清理方法,并将字段置为 null。
  5. 将该软件包添加到 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:注册并测试

  1. 在 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。

  2. 扩展两项类型测试。js/tests/types/api.ts 会根据源码断言公共接口;js/tests/types/consumer.ts 会根据发布的 js/dist/*.d.ts 断言相同接口,并像下游项目那样通过包名导入。将该类、其配置类型和方法添加到两项测试中——声明文件生成错误会通过第一项测试,却无法通过第二项。

  3. 在 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%),导致构建失败。
  4. 在组件的文档页面中添加说明:在 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:验证

  1. npm run js-lint
  2. npm run js-typecheck——对 js/src 和类型测试运行 tsc --noEmit。
  3. 运行 npm run js-test-unit,或运行 npm run js-test,以额外执行两个 Rolldown 集成 bundle,验证模块支持 tree-shaking。每台机器都需要安装一次 Playwright 浏览器(npx playwright install chromium);运行 npm run js-debug 可在可见浏览器中运行测试。
  4. npm run js——Rolldown 将源码编译到 dist/js 和 js/dist,terser 负责压缩,tsc 则生成 .d.ts 文件。检查 bundle 的增长幅度是否大致符合预期;npm run bundlewatch 会强制执行体积预算。
  5. 运行 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,否则你的实例会替换共享元素上的插件实例。

评论

支持 Markdown