Read OSS

Chart.js 内部机制:架构概览与代码库导览

中级

前置知识

  • 具备基本的 JavaScript/TypeScript 知识
  • 了解图表的基本概念(坐标轴、数据集、系列)
  • 熟悉 npm/pnpm 包管理工具

Chart.js 内部机制:架构概览与代码库导览

Chart.js 是 Web 端使用最广泛的图表库之一,但大多数开发者只是通过配置 API 与它打交道——传入对象和回调函数,从未深入思考背后究竟发生了什么。本系列文章将带你改变这一局面。在接下来的六篇文章中,我们将逐一走过代码库中的每一条路径:从模块组织,到最终的 Canvas fillRect 调用。本文是第一篇,我们先来摸清地形——弄清楚源码是如何组织的、构建流程如何对其进行转换,以及三个精心设计的入口点如何同时支持 script 标签引用和可 tree-shaking 的 ESM 导入。

项目结构与模块组织

src/ 目录下有七个顶层子目录,每个目录代表一个独立的架构层:

目录 职责 主要导出
core/ 编排、生命周期、配置、布局 Chart, Scale, DatasetController, defaults, registry
controllers/ 图表类型逻辑(柱状图、折线图、环形图等) BarController, LineController, DoughnutController, …
elements/ 在 Canvas 上绘制的视觉基元 ArcElement, BarElement, LineElement, PointElement
scales/ 坐标轴类型与坐标映射 LinearScale, LogarithmicScale, TimeScale, CategoryScale
plugins/ 横切特性(图例、提示框、颜色) Legend, Tooltip, Filler, Decimation, Colors
platform/ 环境抽象(DOM、OffscreenCanvas) DomPlatform, BasicPlatform, BasePlatform
helpers/ 共享工具函数 数学、颜色、DOM、缓动、配置解析

这些目录的划分绝非随意,而是体现了一种有意为之的分层架构——依赖关系自上而下流动:controllers 依赖 core 和 elements,plugins 依赖 core,而 helpers 是叶节点工具,不向上依赖任何模块。

graph TD
    subgraph "Public API"
        CHART["Chart (core.controller)"]
    end
    subgraph "Subsystems"
        CTRL["Controllers"]
        SCALE["Scales"]
        PLUG["Plugins"]
        ELEM["Elements"]
        PLAT["Platform"]
    end
    subgraph "Foundation"
        CORE["Core (defaults, registry, layouts, config)"]
        HELP["Helpers"]
    end

    CHART --> CTRL
    CHART --> SCALE
    CHART --> PLUG
    CHART --> PLAT
    CHART --> CORE
    CTRL --> ELEM
    CTRL --> CORE
    SCALE --> CORE
    PLUG --> CORE
    CORE --> HELP
    ELEM --> HELP
    PLAT --> HELP

core 的桶文件(barrel file)重新导出了库内其他部分所需的一切,包括 animator、registry、defaults 和布局引擎的单例实例:

src/core/index.ts#L1-L16

注意,这里同时导出了类本身和单例实例。Chart 类是 core.controller.js 的默认导出,而 defaultsregistrylayoutsanimator 则是预先实例化的单例。这个区别对于 tree-shaking 至关重要,后文将详细说明。

入口点与 Exports Map

Chart.js 通过 package.json 的 exports map 对外暴露三个公共入口点:

package.json#L18-L34

flowchart LR
    subgraph "Consumer Code"
        A["import { Chart } from 'chart.js'"]
        B["import 'chart.js/auto'"]
        C["import { color } from 'chart.js/helpers'"]
    end

    subgraph "Entry Points"
        ESM["src/index.ts<br/>(tree-shakeable)"]
        AUTO["auto/auto.js<br/>(side-effectful)"]
        HELP["src/helpers/index.ts<br/>(utilities)"]
    end

    A --> ESM
    B --> AUTO
    C --> HELP
    AUTO -->|"imports & registers"| ESM

ESM 入口src/index.ts)重新导出各子系统的所有内容,并提供一个 registerables 数组。关键在于,它不会调用 Chart.register()。这样一来,打包工具就能静态分析哪些导入真正被使用,从而消除其余部分。

auto 入口auto/auto.js)从 dist 产物中导入 Chartregisterables,并立即调用 Chart.register(...registerables)。该文件在 package.json 中被标记为 sideEffects,告知打包工具:即使其返回值未被使用,这条导入也不能被移除。

UMD 入口src/index.umd.ts)更进一步:它自动注册所有组件,通过 Object.assign 将一切挂载到 Chart 命名空间,并设置 window.Chart 以支持 script 标签引用。这是出现在 CDN 上的"开箱即用"包。

提示: 如果你在构建现代应用,且只需要柱状图和折线图,请从 chart.js(而非 chart.js/auto)导入,并只注册你需要的组件。这样做可以将包体积减少 30–40%。

构建流程:从源码到发布产物

Chart.js 使用 Rollup 配合 SWC 进行 TypeScript 转译,从单一配置文件生成四种输出包:

rollup.config.js#L46-L116

flowchart TD
    SRC_UMD["src/index.umd.ts"] --> ROLLUP_UMD["Rollup + SWC + Terser"]
    SRC_ESM["src/index.ts"] --> ROLLUP_ESM["Rollup + SWC + Cleanup"]
    SRC_HELP["src/helpers/index.ts"] --> ROLLUP_ESM

    ROLLUP_UMD --> UMD_MIN["dist/chart.umd.min.js<br/>(UMD, minified)"]
    ROLLUP_UMD --> UMD["dist/chart.umd.js<br/>(UMD, minified)"]
    ROLLUP_ESM --> ESM_OUT["dist/chart.js<br/>(ESM)"]
    ROLLUP_ESM --> CJS_OUT["dist/chart.cjs<br/>(CommonJS)"]
    ROLLUP_ESM --> HELP_OUT["dist/helpers.js<br/>(ESM)"]

构建流程在插件配置上有一个细节值得关注:

rollup.config.js#L17-L44

UMD 构建使用 Terser 进行压缩;ESM/CJS 构建则改用 rollup-plugin-cleanup 插件,并配置了 comments: ['some', /__PURE__/]。这样做是为了在产物中保留 #__PURE__ 注释,这对于下游打包工具识别哪些表达式没有副作用至关重要。

SWC 的编译目标为 es2022,因此产物会使用类字段、可选链等现代语法。这是一个有意为之的选择——Chart.js 期望使用者在需要兼容旧版浏览器时自行处理进一步的转译。

单例模式与 Tree-Shaking

Chart.js 依赖三个核心单例:Defaults 配置存储、Registry 注册表和 Animator 动画器。每个单例都在模块作用域内实例化,并带有 /* #__PURE__ */ 注释:

src/core/core.defaults.js#L165-L175

src/core/core.registry.js#L185-L186

src/core/core.animator.js#L213-L214

classDiagram
    class Defaults {
        +backgroundColor: string
        +borderColor: string
        +color: string
        +font: object
        +set(scope, values)
        +get(scope)
        +route(scope, name, targetScope, targetName)
        +describe(scope, values)
        +override(scope, values)
    }

    class Registry {
        +controllers: TypedRegistry
        +elements: TypedRegistry
        +plugins: TypedRegistry
        +scales: TypedRegistry
        +add(...args)
        +remove(...args)
    }

    class Animator {
        -_charts: Map
        -_running: boolean
        +listen(chart, event, cb)
        +add(chart, items)
        +start(chart)
        +stop(chart)
    }

    Defaults <.. Registry : uses for merging
    Animator <.. Chart : drives render loop
    Registry <.. Chart : resolves components

#__PURE__ 注释的作用是告诉 webpack、Rollup 等打包工具:"这个表达式没有副作用——如果没有人使用它的返回值,可以安全地将其移除。"如果没有这个注释,打包工具就不得不假设 new Defaults(...) 可能会修改全局状态,从而强制保留它。

这也正是 ESM 入口支持 tree-shaking 的原因:如果你只导入了 ChartBarController,打包工具就能确定 PolarAreaControllerRadarController 及其关联的 elements 从未被引用,并将它们全部删除。

模块依赖图

ESM 入口(src/index.ts)采用星号导出(star-export)模式,从各子系统的桶文件组合出完整的库。它还对每个子系统进行了命名空间处理,并导出了一个 registerables 数组:

graph TD
    INDEX["src/index.ts"]
    INDEX -->|"export *"| CTRL_IDX["controllers/index.js"]
    INDEX -->|"export *"| CORE_IDX["core/index.ts"]
    INDEX -->|"export *"| ELEM_IDX["elements/index.js"]
    INDEX -->|"export *"| PLAT_IDX["platform/index.js"]
    INDEX -->|"export *"| PLUG_IDX["plugins/index.js"]
    INDEX -->|"export *"| SCALE_IDX["scales/index.js"]

    INDEX -->|"import * as controllers"| CTRL_IDX
    INDEX -->|"import * as elements"| ELEM_IDX
    INDEX -->|"import * as plugins"| PLUG_IDX
    INDEX -->|"import * as scales"| SCALE_IDX

    subgraph "registerables array"
        REG["[controllers, elements, plugins, scales]"]
    end
    INDEX --> REG

UMD 入口则采用了完全不同的方式。它不是重新导出,而是导入所有内容,然后手动将其挂载到 Chart 对象上:

src/index.umd.ts#L27-L51

Object.assign(Chart, controllers, scales, elements, plugins, platforms) 这行代码将所有导出合并到同一个 Chart 命名空间下——也就是 ESM 之前的用户所熟悉的 Chart.LineControllerChart.LinearScale 访问方式。第 50 行设置 window.Chart,则完成了供 <script> 标签使用的全局注册。

这种设计的精妙之处在于:同一份源码无需重复任何逻辑,便能同时服务于三种使用场景——可 tree-shaking 的 ESM、自动注册的 ESM,以及全局 UMD。唯一的区别,在于各入口点组合和暴露子系统的方式。

衔接下一篇

有了这份代码库地图,我们就可以开始追踪其中最重要的一条路径了:当开发者写下 new Chart(ctx, config) 时,究竟发生了什么。下一篇文章将跟随 Chart 构造函数,深入了解平台检测、配置解析、多阶段 update() 流程,以及将数据转化为 Canvas 像素的分层 draw() 系统。