bootstrap-component-js

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Bootstrap 6 component JavaScript

Bootstrap 6 组件 JavaScript 开发

Every Bootstrap plugin is a TypeScript module in
js/src/
that extends
BaseComponent
, which handles instance registration, config merging, and teardown. Follow the shared structure and a component gets
getInstance()
,
getOrCreateInstance()
,
data-bs-*
config, namespaced events, and
dispose()
without writing any of it.
The sources are TypeScript, but the runtime patterns are the same ones v5 used with types layered on top. Bootstrap compiles them to JavaScript and emits its own declarations, so consumers need no
@types/bootstrap
.
For the component's styles, see the
bootstrap-component
skill.
每个 Bootstrap 插件都是
js/src/
目录下的 TypeScript 模块,继承自
BaseComponent
,后者负责实例注册、配置合并与销毁逻辑。遵循这套共享结构,组件无需额外编写代码即可获得
getInstance()
、
getOrCreateInstance()
、
data-bs-*
配置、命名空间事件以及
dispose()
等能力。
源码使用 TypeScript 编写,但运行时模式与 v5 保持一致,只是在其之上添加了类型层。Bootstrap 会将其编译为 JavaScript 并生成自带的类型声明文件,因此使用者无需安装
@types/bootstrap
。
关于组件样式,请参考
bootstrap-component
技能。

Workflow

工作流程

  • Step 1: Scaffold the module
  • Step 2: Define config options
  • Step 3: Implement behavior and events
  • Step 4: Wire the data API
  • Step 5: Position with Floating UI (optional)
  • Step 6: Wrap a third-party library (optional)
  • Step 7: Clean up in dispose()
  • Step 8: Register and test
  • Step 9: Verify

  • 步骤 1:搭建模块骨架
  • 步骤 2:定义配置选项
  • 步骤 3:实现交互行为与事件
  • 步骤 4:连接 data API
  • 步骤 5:使用 Floating UI 实现定位(可选)
  • 步骤 6:封装第三方库(可选)
  • 步骤 7:在 dispose() 中清理资源
  • 步骤 8:注册与测试
  • 步骤 9:验证

Step 1: Scaffold the module

步骤 1:搭建模块骨架

Create
js/src/<name>.ts
. Read
js/src/alert.ts
first — it's the smallest complete example.
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
    is the only required static.
    BaseComponent
    derives
    DATA_KEY
    (
    bs.<name>
    ) and
    EVENT_KEY
    (
    .bs.<name>
    ) from it, stores the instance on the element with
    Data.set()
    , and disposes any previous instance bound to the same element.
  • Keep the section comments (
    Constants
    ,
    Class definition
    ,
    Data API implementation
    ) and member grouping (
    constructor
    ,
    // Getters
    ,
    // Public
    ,
    // Private
    ,
    // Static
    ) — every module in
    js/src/
    looks the same.
  • Repo style: ES modules only, no semicolons, 2-space indent, single quotes, and
    override
    on every member that overrides
    BaseComponent
    or
    Config
    .
  • Relative imports keep the ESM
    .js
    extension even though the file on disk is
    .ts
    , because
    tsconfig.json
    sets
    moduleResolution: nodenext
    . Rolldown, Vite, and Vitest all map the specifier back to the
    .ts
    source.
  • The compiler is strict in ways worth knowing before you write:
    verbatimModuleSyntax
    wants type imports marked (
    import EventHandler, { type BootstrapEvent } from './dom/event-handler.js'
    ), and
    erasableSyntaxOnly
    rules out enums, namespaces, and constructor parameter properties.
  • Declare instance state as
    protected declare _foo: Type
    and assign it in the constructor, the way
    toast.ts
    does.
    declare
    is type-only; a real field without an initializer would emit
    _foo = undefined
    under
    useDefineForClassFields
    and wipe whatever the base constructor set.
  • v6 has no
    defineJQueryPlugin
    and no
    js/src/util.ts
    . Helpers live in
    js/src/util/
    ; DOM wrappers in
    js/src/dom/
    (
    event-handler
    ,
    selector-engine
    ,
    manipulator
    ,
    data
    ).

创建
js/src/<name>.ts
文件。建议先阅读
js/src/alert.ts
—— 这是最小的完整示例。
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
    关键字。
  • 相对导入需要保留 ESM 的
    .js
    后缀,尽管磁盘上的实际文件是
    .ts
    ,这是因为
    tsconfig.json
    中设置了
    moduleResolution: nodenext
    。Rolldown、Vite 和 Vitest 都会将导入路径映射回
    .ts
    源文件。
  • 编译器的严格规则值得提前了解:
    verbatimModuleSyntax
    要求类型导入必须显式标记(例如
    import EventHandler, { type BootstrapEvent } from './dom/event-handler.js'
    ),而
    erasableSyntaxOnly
    禁止使用枚举、命名空间和构造函数参数属性。
  • 实例状态需要用
    protected declare _foo: Type
    声明,并在构造函数中赋值,参考
    toast.ts
    的写法。
    declare
    仅用于类型标注;如果是没有初始值的真实字段,在
    useDefineForClassFields
    规则下会被编译为
    _foo = undefined
    ,从而覆盖基类构造函数设置的值。
  • v6 版本不再提供
    defineJQueryPlugin
    ,也没有
    js/src/util.ts
    。工具函数存放在
    js/src/util/
    目录下;DOM 包装器存放在
    js/src/dom/
    目录(包括
    event-handler
    、
    selector-engine
    、
    manipulator
    、
    data
    )。

Step 2: Define config options

步骤 2:定义配置选项

Declare the config shape, the defaults, and the runtime types as module constants, then expose them as statics:
ts
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 }
  • The config type does double duty:
    Default
    is typed with it, the constructor accepts a
    Partial<>
    of it since callers pass a subset, and redeclaring
    protected declare _config: WidgetConfig
    narrows
    BaseComponent
    's loose config so
    this._config.delay
    is a
    number
    .
    DefaultType
    stays a plain string map — it drives the runtime check, not the compiler. Export the type; the type tests import it.
  • Merge order, applied by
    Config._mergeConfigObj()
    (last wins):
    Default
    → the JSON in
    data-bs-config
    → individual
    data-bs-*
    attributes → the object passed to the constructor. You don't write any of this.
  • data-bs-*
    names are camelCased when read, so
    data-bs-auto-hide="false"
    becomes
    autoHide: false
    .
  • Every key in
    DefaultType
    is validated and throws a
    TypeError
    on mismatch. Values are matched as regex, so unions work:
    'boolean'
    ,
    '(number|string)'
    ,
    '(string|element|function)'
    .
  • Normalize a value before validation by overriding
    _configAfterMerge(config)
    — the usual place to resolve a selector string into an element or coerce a shorthand.

将配置结构、默认值和运行时类型声明为模块常量,然后作为静态属性暴露出去:
ts
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)
    在校验前对配置值进行标准化——通常用于将选择器字符串解析为元素,或是处理简写形式。

Step 3: Implement behavior and events

步骤 3:实现交互行为与事件

ts
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)
  }
}
  • Events come in pairs: an infinitive fired before the action (
    show.bs.widget
    , cancelable) and a past participle after it (
    shown.bs.widget
    ). Always bail when the "before" event is
    defaultPrevented
    .
  • _queueCallback(callback, element, isAnimated)
    runs the callback after the CSS transition on
    element
    finishes, and skips it if the instance was disposed mid-transition. Pass the animation flag from config so non-animated components fire immediately.
  • Add a payload when listeners need context:
    EventHandler.trigger(this._element, EVENT_SHOWN, { relatedTarget })
    .
    trigger()
    returns a
    BootstrapEvent
    , an
    Event
    widened with the payload keys, so reading them back in a handler needs no cast.
  • Query with
    SelectorEngine
    (
    findOne
    ,
    find
    ,
    next
    ,
    prev
    ,
    getElementFromSelector
    ) instead of raw
    querySelector
    , and read or write attributes with
    Manipulator
    .
  • Bind internal listeners with
    EventHandler.on(this._element, EVENT_MOUSEOVER, …)
    using the namespaced constants, so
    dispose()
    removes them in one call.

ts
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
    )。当“before”事件的
    defaultPrevented
    为 true 时,必须终止后续逻辑。
  • _queueCallback(callback, element, isAnimated)
    会在
    element
    的 CSS 过渡结束后执行回调,如果实例在过渡过程中被销毁则会跳过执行。需要传入配置中的动画开关,这样无动画的组件会立即触发回调。
  • 当监听器需要上下文信息时,可以添加 payload:
    EventHandler.trigger(this._element, EVENT_SHOWN, { relatedTarget })
    。
    trigger()
    返回
    BootstrapEvent
    类型,是扩展了 payload 键的
    Event
    对象,因此在事件处理函数中读取这些属性无需类型转换。
  • 使用
    SelectorEngine
    (
    findOne
    、
    find
    、
    next
    、
    prev
    、
    getElementFromSelector
    )进行元素查询,不要直接用
    querySelector
    ;属性读写则使用
    Manipulator
    。
  • 内部监听器使用
    EventHandler.on(this._element, EVENT_MOUSEOVER, …)
    绑定,并使用带命名空间的常量,这样
    dispose()
    可以一次性移除所有监听器。

Step 4: Wire the data API

步骤 4:连接 data API

The data API goes at the bottom of the module, after the class, as an import side effect:
ts
/**
 * Data API implementation
 */

EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {
  event.preventDefault()
  Widget.getOrCreateInstance(this).toggle()
})
  • Delegate from
    document
    and namespace the event with
    .data-api
    so it stays distinguishable from user listeners.
  • Keep
    function
    rather than an arrow when you need the delegated element:
    EventHandler
    types the callback's
    this
    , so
    this
    is the match for
    SELECTOR_DATA_TOGGLE
    . Use an arrow and reach for
    event.target
    only when you need the actual click target.
  • Prefer the shared helpers in
    js/src/util/component-functions.ts
    over hand-rolled handlers:
    • enableDismissTrigger(Widget)
      wires
      [data-bs-dismiss="widget"]
      to
      hide()
      . Pass a method name for anything else:
      enableDismissTrigger(Alert, 'close')
      .
    • eventActionOnPlugin(Widget, 'click', SELECTOR_DATA_TOGGLE, 'toggle')
      handles the "trigger points at one or more targets via
      data-bs-target
      " case and skips disabled elements.
  • Because these are side effects, the data API only exists once the module is imported. Anything importing a single component file gets that component's data API and nothing else.

data API 作为导入副作用放在模块底部、类定义之后:
ts
/**
 * 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,不会引入其他内容。

Step 5: Position with Floating UI (optional)

步骤 5:使用 Floating UI 实现定位(可选)

Menus, tooltips, and popovers position with
@floating-ui/dom
, an optional peer dependency. Guard for it in the constructor before calling
super()
— legal here because the class declares its fields instead of initializing them:
ts
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
  }
}
Create the positioner when the element is shown and keep the cleanup function so it can be cancelled:
ts
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
handles placement so you don't reimplement it:
getDefaultPlacement()
flips
-start
/
-end
for RTL,
parseResponsivePlacement()
and
getResponsivePlacement()
support
data-bs-placement="bottom-start md:top-end"
, and
createBreakpointListeners()
/
disposeBreakpointListeners()
re-position on breakpoint changes.

菜单、tooltip 和 popover 等组件使用
@floating-ui/dom
实现定位,这是一个可选的 peer 依赖。在构造函数中调用
super()
之前先做存在性检查——在这里是合法的,因为类只是声明了字段,没有初始化:
ts
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
  }
}
当元素显示时创建定位器,并保存清理函数以便后续取消:
ts
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()
会在断点变化时重新定位。

Step 6: Wrap a third-party library (optional)

步骤 6:封装第三方库(可选)

js/src/datepicker.ts
wraps
vanilla-calendar-pro
and is the reference for this pattern:
  1. Keep Bootstrap's config surface. Users set
    data-bs-*
    options; a private builder (
    _buildCalendarOptions()
    ) translates them into the library's option shape.
  2. Instantiate in a private initializer called from the constructor and hold the instance on a private field typed with the library's own type (
    protected declare _calendar: Calendar | null
    ).
  3. Re-emit the library's callbacks as namespaced Bootstrap events so consumers only learn one API.
  4. Call the library's teardown in
    dispose()
    before
    super.dispose()
    , and null the field.
  5. Add the package to
    peerDependencies
    in
    package.json
    , guard for its absence, and document the extra install step on the component's docs page.
Don't fork or vendor the library, and don't expose its instance as public API — anything users need should be a method or event on the Bootstrap class.

js/src/datepicker.ts
封装了
vanilla-calendar-pro
,是这种模式的参考实现:
  1. 保持 Bootstrap 的配置接口不变。用户通过
    data-bs-*
    设置选项,由私有构建函数(
    _buildCalendarOptions()
    )将其转换为第三方库的配置格式。
  2. 在构造函数调用的私有初始化方法中实例化第三方库,并将实例保存在私有字段中,字段类型使用库自身的类型(
    protected declare _calendar: Calendar | null
    )。
  3. 将库的回调重新包装为带 Bootstrap 命名空间的事件,这样使用者只需要学习一套 API。
  4. 在
    dispose()
    中先调用库的销毁方法,再执行
    super.dispose()
    ,并将字段置为 null。
  5. 将该包添加到
    package.json
    的
    peerDependencies
    中,做缺失检查,并在组件的文档页面说明额外的安装步骤。
不要 fork 或把库的代码放到自己的仓库中,也不要将库的实例作为公共 API 暴露——用户需要的所有功能都应该以 Bootstrap 类的方法或事件形式提供。

Step 7: Clean up in dispose()

步骤 7:在 dispose() 中清理资源

ts
class Widget extends BaseComponent {
  // Public
  override dispose(): void {
    clearTimeout(this._timeout!)
    this._observer?.disconnect()
    this._disposeFloating()

    super.dispose()
  }
}
super.dispose()
removes the
Data
entry, removes every listener bound on
this._element
under
EVENT_KEY
, and nulls the instance's own properties. Everything else is yours to undo first: timers,
MutationObserver
/
ResizeObserver
instances, listeners bound on
document
or
window
, Floating UI cleanups, and third-party instances. Leaks here show up as failing "should dispose" specs.

ts
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
命名空间的监听器,并将实例自身的属性置为 null。其他资源都需要你先清理:定时器、
MutationObserver
/
ResizeObserver
实例、绑定在
document
或
window
上的监听器、Floating UI 的清理函数以及第三方库实例。这里的内存泄漏会导致“should dispose”测试用例失败。

Step 8: Register and test

步骤 8:注册与测试

  1. Add the export to
    js/src/index.ts
    :
    ts
    export { default as Widget } from './widget.js'
    That barrel is the package entry point and feeds both
    dist/js/bootstrap.js
    and the bundle. Nothing else needs updating: Rolldown builds from
    js/src/index.ts
    ,
    build/build-plugins.mjs
    globs
    js/src/**/*.ts
    for the per-plugin files in
    js/dist/
    , and Vitest picks up
    js/tests/unit/**/*.spec.js
    .
  2. Extend both type tests.
    js/tests/types/api.ts
    asserts the public surface against the sources;
    js/tests/types/consumer.ts
    asserts the same surface against the shipped
    js/dist/*.d.ts
    , imported through the package name the way a downstream project does. Add the class, its config type, and its methods to both — a declaration-emit bug passes the first and fails the second.
  3. Add a spec at
    js/tests/unit/<name>.spec.js
    . Specs are plain JavaScript and run in a real Chromium through Vitest browser mode:
    js
    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
      , and the hooks are globals (
      globals: true
      in
      js/tests/vitest.config.mts
      ), so don't import them. Import
      vi
      from
      vitest
      when you need spies or fake timers — that's the preferred API for new specs.
    • js/tests/vitest-setup.js
      maps the Jasmine API the older specs are written against onto Vitest, so
      spyOn
      ,
      jasmine.clock()
      ,
      toHaveClass
      ,
      toBeTrue
      ,
      toBeFalse
      , and
      toHaveSize
      still work. Two gotchas: the shimmed
      spyOn
      stubs the method (add
      .and.callThrough()
      for the real one), and Vitest 4 dropped the
      done
      callback, so an asynchronous test returns a promise and resolves it from the listener.
    • Cover the constructor with both a selector and an element, config coming from
      data-bs-*
      , each public method, both events including the cancelable one, the data API without manual instantiation, and
      dispose()
      . Coverage counts every file in
      js/src
      , so a component with no spec drags the run below the thresholds in
      js/tests/vitest.config.mts
      (90% of statements, functions, and lines; 88% of branches) and fails the build.
  4. Document it on the component's docs page: add
    js: optional
    (or
    js: required
    ) to the frontmatter,
    <JsDataAttributes />
    in an
    ### Options
    section, and
    <BsTable>
    tables for options, methods, and events. Demo snippets live in
    site/src/assets/partials/snippets.js
    and render with
    <JsDocs name="live-widget" file="site/src/assets/partials/snippets.js" />
    .

  1. 在
    js/src/index.ts
    中添加导出:
    ts
    export { default as Widget } from './widget.js'
    这个 barrel 文件是包的入口点,同时为
    dist/js/bootstrap.js
    和打包产物提供内容。不需要修改其他文件: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
    文件,模拟下游项目的使用方式来校验同一套接口。需要把类、它的配置类型和方法都添加到这两个测试文件中——如果声明文件生成有 bug,第一个测试会通过,但第二个会失败。
  3. 在
    js/tests/unit/<name>.spec.js
    添加测试用例。测试用例使用普通 JavaScript 编写,通过 Vitest 浏览器模式在真实的 Chromium 中运行:
    js
    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
      ),因此不需要导入。需要间谍函数或假定时器时,从
      vitest
      导入
      vi
      ——这是新测试用例推荐使用的 API。
    • js/tests/vitest-setup.js
      将旧测试用例使用的 Jasmine API 映射到 Vitest 上,因此
      spyOn
      、
      jasmine.clock()
      、
      toHaveClass
      、
      toBeTrue
      、
      toBeFalse
      和
      toHaveSize
      仍然可用。有两个注意点:垫片提供的
      spyOn
      会默认存根方法(如果要调用真实方法,需要添加
      .and.callThrough()
      );Vitest 4 移除了
      done
      回调,因此异步测试需要返回 Promise,并在监听器中 resolve 它。
    • 测试覆盖需要包括:构造函数同时支持选择器和元素传入、
      data-bs-*
      配置、每个公共方法、两类事件(包括可取消的那个)、无需手动实例化的 data 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" />
    渲染。

Step 9: Verify

步骤 9:验证

  1. npm run js-lint
  2. npm run js-typecheck
    —
    tsc --noEmit
    over
    js/src
    and the type tests.
  3. npm run js-test-unit
    , or
    npm run js-test
    to add the two Rolldown integration bundles that prove the module is tree-shakeable. Playwright needs its browser once per machine (
    npx playwright install chromium
    ), and
    npm run js-debug
    runs the specs in a visible browser.
  4. npm run js
    — Rolldown compiles the sources to
    dist/js
    and
    js/dist
    , terser minifies, and
    tsc
    emits the
    .d.ts
    files. Check the bundle grew by roughly what you'd expect;
    npm run bundlewatch
    enforces size budgets.
  5. npm start
    and exercise the component on its docs page, with the keyboard as well as the mouse.

  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 集成打包测试,验证模块支持 tree-shaking。每台机器需要安装一次 Playwright 浏览器(
    npx playwright install chromium
    ),运行
    npm run js-debug
    可以在可见的浏览器中运行测试用例。
  4. npm run js
    — Rolldown 将源码编译到
    dist/js
    和
    js/dist
    目录,terser 进行压缩,
    tsc
    生成
    .d.ts
    文件。检查包体积的增长是否符合预期;
    npm run bundlewatch
    会强制执行体积限制。
  5. 运行
    npm start
    ,在组件的文档页面测试功能,同时验证键盘和鼠标操作。

In your own project

在你自己的项目中使用

package.json
maps
bootstrap/js/src/*.js
to the compiled
js/dist/*.js
and its declarations, so the same specifier works from JavaScript and TypeScript, and custom components can build on the same base class:
ts
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'
  }
}
Two things to keep in mind. Importing a Bootstrap component module runs its data API as a side effect, which is what you want in an app entry point but not inside a library. And
DATA_KEY
is derived as
bs.<name>
, so pick a
NAME
that doesn't collide with a Bootstrap plugin or your instance will replace theirs on shared elements.
package.json
中已经将
bootstrap/js/src/*.js
映射到编译后的
js/dist/*.js
及其类型声明,因此在 JavaScript 和 TypeScript 中都可以使用相同的导入路径,自定义组件也可以基于同一个基类构建:
ts
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 组件模块时会作为副作用执行其 data API,这在应用入口点是符合预期的,但在库中则不适合。另外
DATA_KEY
是基于
bs.<name>
派生的,因此选择的
NAME
不要与 Bootstrap 内置插件冲突,否则你的实例会覆盖共享元素上的原有实例。