1. 这个名字背后的东西,比想象中要大
第一次看到“Madeira”这个标题时,别急着把它归类成某个冷门软件、某张风光摄影作品或者某款酒的名字——如果你在技术社区或项目文档里碰到它,大概率会遇到两种完全不同的走向:一个是地理意义上的马德拉群岛,另一个是前端圈子里那套火了很久的组件化方案。这篇博文只聊后者,聊透它到底是什么、解决什么问题、适合谁去用,以及我在实际项目里踩过的坑和攒下来的经验。
马德拉(Madeira)是一套基于 Web Components 标准的 UI 组件库。说人话就是:它给你一堆封装好的按钮、弹窗、表单、导航、图表之类的界面零件,你直接拿来拼装页面,不用每次从零手写样式和交互逻辑。它跟 React 生态里的 Ant Design、Vue 里的 Element Plus 干的是同一类活,但底层思路完全不同——它不依赖任何框架,原生就能跑。
适合谁看?两类人最该停下来。一类是正在做多框架共存项目的前端工程师,比如公司里既有 React 老系统又有 Vue 新项目,想统一视觉规范又不想维护两套组件代码;另一类是刚接触 Web Components、想知道这套标准到底能不能用于生产的人。如果你只是写写个人小页面,它可能有点杀鸡用牛刀,但理解它的设计思路,对提升你对前端工程化、组件边界、样式隔离的认知非常有帮助。
我最早接触它是因为一个内部管理系统改造:老代码用 jQuery 写了五年,加新功能像在积木塔上抽木条。后来我们决定逐步替换技术栈,但全量重写成本太高,于是选中了组件化渐进式改造的路子。当时调研了四五套方案,最终留下来并跑完整个生产周期的,就是这套基于 Web Components 的方案。它不是最热门的选择,但确实是在复杂现实约束下,很能打的一套工具。
这篇文章不是官方文档的翻译,也不打算把每个 API 罗列一遍。我会把我们团队从技术选型、环境搭建、组件改造,到遇到诡异兼容性问题、一步一步定位解决的完整过程写下来。中间会穿插很多只有实际跑过生产环境才能发现的细节,比如样式隔离的坑、事件绑定的陷阱、Shadow DOM 对表单的干扰等等。
如果你正准备在项目里引入相关方案,或者正在纠结“要不要上 Web Components”,这篇文章值得你花二十分钟读完。它不会给你一个放之四海皆准的答案,但会告诉你:这套东西在什么条件下很香,在什么条件下会让你想摔键盘。
2. 技术方案选型:为什么我们放弃了 React 组件库
2.1 项目当时的痛点
先交代一下项目背景。那是一个政企类的数据管理系统,前端最早是服务端渲染的模板加 jQuery 拼出来的,后来陆陆续续引入过 React,但只在几个独立页面用,没有统一架构。问题非常典型:公共组件散落在各项目里,有的用 Bootstrap 改的,有的是手写 CSS,同一个“日期选择器”在不同页面长着三张脸。
更难受的是,业务方提出要统一视觉风格,并且接下来会有两个新项目同时启动,一个用 Vue,另一个用 React。也就是说,未来前端会长期处于多技术栈并存的局面。我们不可能为每个技术栈单独维护一套 UI 组件库——那意味着同样的设计规范要写两遍甚至三遍,后续任何一个视觉调整都要同步改多处,维护成本直接爆炸。
当时摆在面前的选择大概有这么几条:
- 继续用纯 CSS 加少量 JS 写公共样式,每个框架各自封装组件——最省钱,但团队协作成本高,样式和逻辑很难彻底复用。
- 选定一个主流框架,把未来新项目全部统一到它上面——听上去美好,但老系统的渐进式改造怎么办?不可能一蹴而就,至少有一到两年时间需要新旧共存。
- 引入一套基于 Web Components 的组件库,让组件本身与技术栈无关——所有项目都能直接用,样式隔离也在标准层面解决,一套代码到处跑。
第三条路在理论上最优雅,但当时我们团队没人正经用过 Web Components 写生产项目,大家心里都打鼓。我个人的态度是:如果方案在理论上正确,而且能解决真实约束下的核心矛盾,那就值得先做一个小范围技术验证。于是我们花了两天时间搭了个试验工程,把登录页、表格、弹窗几个常见场景用这套方案重写了一遍,然后让几个核心开发分别从 React 和 Vue 里调用这些组件,确认双向数据通信、事件派发、表单取值都能正常工作,才最终拍板。
2.2 跟主流组件库的横向对比
为了选型,我做了一张很朴素的对比表。不追求全面,只看我们在意的维度:
| 对比维度 | React 组件库(如 Ant Design) | Vue 组件库(如 Element Plus) | Web Components 方案(Madeira) |
|---|---|---|---|
| 框架依赖 | 强依赖 React | 强依赖 Vue | 无框架依赖,原生标准 |
| 跨框架复用 | 差,仅限 React 生态 | 差,仅限 Vue 生态 | 好,React/Vue/原生可通用 |
| 样式隔离 | CSS-in-JS 或约定命名 | scoped 样式或约定命名 | Shadow DOM,真正隔离 |
| 学习成本 | 低(如果熟悉 React) | 低(如果熟悉 Vue) | 中等,理解 Web Components 标准 |
| 生态成熟度 | 极高 | 高 | 相对小众,组件数量有限 |
| 对老系统友好度 | 需引入构建链 | 需引入构建链 | 可直接 script 标签引入 |
看这张表就知道,前两个方案在跨框架复用这一栏直接出局。我们不是没有考虑过维护两套组件库的思路,但说实话,设计规范从源头统一这件事,靠人肉对齐是永远做不到彻底的。视觉风格还能靠设计令牌勉强统一,但交互细节、状态管理逻辑、异常处理这些藏在组件内部的东西,迟早会分叉。
选择这套 Web Components 方案还有个隐性好处:它是浏览器原生标准,不是某个框架的私有实现。也就是说,哪怕未来下一代前端框架出现了,只要浏览器还认 Web Components,这些组件就依然能跑。对我们这种要维护五年十年的政企系统来说,这一点特别重要——技术栈可以换代,但业务资产不能推翻重来。
2.3 为什么不是所有项目都适合它
如果只夸不骂,那是耍流氓。这套方案也有很明显的硬伤,我在选型时必须跟团队说清楚。
第一,生态里的现成组件数量,跟主流框架组件库完全没法比。你可能想用的“复杂树表格”“富文本编辑器”“看板拖拽”,在主流框架里随便装个包就行,在这套生态里就得自己封装或者找社区替代品。我们的解决思路是:核心基础组件尽量用现成的,复杂业务组件自己在它基础上二次封装,因为 Web Components 也支持继承和组合,封装起来并不算别扭。
第二,Shadow DOM 的样式隔离是把双刃剑。隔离确实干净,但有些情况下你想要全局样式渗透进去,比如主题切换时给组件内部某个颜色变量重新赋值,就得费一番周折。官方提供了一套 CSS 自定义属性的桥接机制,但你得提前规划好变量名,否则后期改主题会想哭。
第三,表单场景有个很隐蔽的坑,后面我会专门拿出一节讲。原生表单跟自定义组件的集成,在 Shadow DOM 下面并不像想象中那么顺滑,处理不好会出现表单数据拿不到、校验不触发的问题。
所以,如果你只是一个小团队做一个短期项目、没有多框架共存需求、前端人员对主流框架很熟但对浏览器原生标准不熟,那用主流框架组件库是更省力的选择。但如果你是我们这种场景——多技术栈共存、系统要维护很多年、对样式隔离有硬性需求,那它值得认真考虑。
3. 环境搭建与核心机制拆解
3.1 引入方式和构建工具配置
这套方案的一大优点就是上手门槛低。传统组件库通常要求项目里必须有对应的框架运行时,而这里你只需要拿到编译后的 JavaScript 文件,用<script>标签就能引入,之后在 HTML 里写自定义标签即可。官方推荐两种引入方式:
- 直接引用 CDN 上打包好的单文件,适合快速体验和简单页面。
- 通过 npm 安装到本地,在自己的构建流程里按需引用和打包。
我们实际用的是 npm 安装。因为内网部署环境不允许直接依赖外网 CDN,而且我们要做二次封装和定制主题,必须把源码纳入自己的构建体系。安装命令很简单:
npm install @madeira/core然后在入口文件里引入:
import '@madeira/core';这一行会把所有基础组件注册到浏览器里。如果你担心包体积太大,它同样支持按需引入,只加载你真正用到的组件文件。我建议一开始还是全量引入,先把应用跑起来,再借助构建工具的代码分割能力验证整体性能。我们当时用 Vite 做构建,打包后按需加载的效果还挺理想的。
需要注意的一点是,这套方案底层依赖 Custom Elements 和 Shadow DOM,如果你的目标用户还在用比较古老的浏览器,比如 IE11,那直接劝退。必须上 polyfill 或者干脆放弃兼容。我们这边用户都用现代浏览器,所以没这块负担。如果你的项目受众还有大量老浏览器,选型前先确认一下兼容策略。
3.2 自定义元素的生命周期钩子
如果你想真正用好组件库而不仅仅是套模板,理解自定义元素的生命周期是必须过关的一关。它不像 Vue 或 React 那样有创建、挂载、更新、销毁一套完整的钩子体系,但也有自己独特的生命周期,主要包括四个阶段:
- connectedCallback:元素被挂载到文档时触发,适合做初始化工作,比如绑定事件、加载数据、设置默认状态。
- disconnectedCallback:元素从文档移除时触发,适合做清理工作,比如解绑事件监听、清除定时器。
- attributeChangedCallback:元素的属性被添加、移除、修改时触发,适合响应外部传参变化。
- adoptedCallback:元素被移动到新文档时触发,这个场景比较少见,但在多 iframe 或多窗口场景下会遇到。
我记得第一次用的时候,习惯性地想在 attributes 变化时直接改内部状态,结果忽略了一个细节:attributeChangedCallback触发的前提是你在类里声明了observedAttributes静态属性。不声明的话,属性变了它压根不会通知你。这个小细节当时让我排查了半天,翻文档才找到原因。
举一个实际例子。我要封装一个异步加载数据的下拉选择组件,外部传入一个>class AsyncSelect extends HTMLElement { static get observedAttributes() { return ['data-src']; } attributeChangedCallback(name, oldValue, newValue) { if (name === 'data-src' && oldValue !== newValue) { this.loadData(newValue); } } async loadData(url) { const data = await fetch(url).then(res => res.json()); this.renderOptions(data); } } customElements.define('async-select', AsyncSelect);
逻辑不复杂,但如果不清楚生命周期机制,很容易把请求写在构造函数里。构造函数里访问 DOM 是无效的,因为此时元素尚未插入文档,很多 API 用不了。踩过一次就记住了:初始化能干的事,放在connectedCallback里干。
3.3 Shadow DOM 下的样式规则
聊完了生命周期,再展开讲样式隔离。用 Shadow DOM 后,组件内部的样式默认不对外部生效,外部样式也渗透不进去。这套方案提供了一套设计令牌(Design Tokens)机制,让你通过 CSS 自定义属性统一管理主题变量。
举个例子,组件库内置了颜色变量:
:host { --madeira-color-primary: #4f6ef7; --madeira-color-success: #23b26d; --madeira-color-danger: #e5484d; --madeira-radius-md: 6px; --madeira-font-size-base: 14px; }你在自己的样式表里覆盖这些变量,组件内部的按钮、输入框、标签就都会跟着变。这比去改每个组件内部样式优雅得多,也符合设计系统一贯的思路——先定令牌,再出组件。
但这里有个经验教训:CSS 变量作用域很容易踩坑。Shadow DOM 内部访问外部变量的路径是有限的,如果你把变量定义在某个深层级的容器上,而组件被挂载在另一个层级,可能就获取不到。我建议把主题变量统一挂在根节点上,这样所有组件都能继承到。后期一旦发现某处主题不生效,第一反应该去查变量定义的位置,而不是怀疑组件有问题。
另外,当你需要穿透 Shadow DOM 去强制覆盖内部样式时,可以使用::part()伪元素。组件作者会在组件内部关键节点上暴露part属性,外部就可以精准定位到这些节点加样式。这个能力很实用,比如你在深色模式下想单独调整某个组件内部滚动条的样式。找不到对应part时,大部分情况下调整主题变量就够了,实在不够再考虑全局样式加!important——但那是最后手段,会影响组件自身的可维护性。
4. 实操过程:我改造成一个表格页面的全部细节
4.1 目标与拆解
理论聊得够多了,我们进实操。当时我挑了一个最典型的业务页面做试点:一个带筛选条件、数据表格、分页和行操作按钮的员工管理页。这个页面在原系统里用的是 jQuery 加前后端不分离的模板渲染,每次切换筛选条件都整页刷新,体验很糟。
我把改造目标拆成四个子任务:
- 用组件库搭出筛选表单。
- 用组件库的数据表格展示列表。
- 实现前端的分页、排序、行选中。
- 把筛选条件和表格状态联动起来。
这样拆的好处是每步可验证,出问题容易定位。我不会一上来就追求把所有功能一次性塞进一个巨型组件。
4.2 筛选表单的实现
筛选表单包含关键字输入、部门下拉、状态单选、查询与重置按钮。用组件库写出来大概是这样:
<madeira-form id="filter-form"> <madeira-input name="keyword" label="关键字"></madeira-input> <madeira-select name="department" label="部门"> <madeira-option value="tech">技术部</madeira-option> <madeira-option value="sales">销售部</madeira-option> </madeira-select> <madeira-radio-group name="status" label="状态"> <madeira-radio value="active">在职</madeira-radio> <madeira-radio value="inactive">离职</madeira-radio> </madeira-radio-group> <madeira-button type="submit">查询</madeira-button> <madeira-button type="reset">重置</madeira-button> </madeira-form>注意,这套组件库的表单控件虽然外观自定义了,但内部实现上会尽量模拟原生表单的行为。不过有一个关键点:如果你把它们放在 Shadow DOM 内部,它们并不会自动成为外部<form>的一部分。这是 Web Components 一个经典问题,下面详细说。
拿表单值举例。原生表单里,浏览器会自动收集带有name属性的表单控件值。但自定义元素内部的输入框在 Shadow DOM 里,外部表单无法直接读取它的值。组件库的解决方案是提供一个getFormData()方法,或者触发change事件让你自己取值。我这边更习惯用事件监听的方式,比如:
const form = document.getElementById('filter-form'); form.addEventListener('submit', (e) => { e.preventDefault(); const data = e.detail; // 组件内部按 name 收集好的数据 fetchData({ page: 1, ...data }); });这个事件是组件库帮我们在内部封装好的,使用起来跟普通表单的 submit 很像,但底层是走了自定义事件。只要你在文档里找到它是如何派发生命周期事件的,基本就能猜出 API 设计思路。我后来在封装业务组件时,也沿用类似的事件命名规范,让团队其他成员接手时能快速理解。
4.3 数据表格渲染与分页
表格组件是页面重头戏。我们需要的列包括姓名、部门、入职时间、状态、操作。声明方式可以直接写在标签属性里,也可以传 JavaScript 对象。我个人更喜欢用对象,因为列配置往往包含格式化函数等逻辑,写在标签里又长又难维护:
const columns = [ { key: 'name', title: '姓名' }, { key: 'department', title: '部门' }, { key: 'joinDate', title: '入职时间', format: (val) => formatDate(val) }, { key: 'status', title: '状态', render: (row) => renderStatusTag(row.status) } ]; table.columns = columns; table.data = rows;这里有个细节:render函数返回的如果是 HTML 字符串,要检查组件库是否信任字符串并直接插入。有的组件出于安全考虑只支持纯文本或特定节点,你需要提前读一下渲染逻辑,否则很容易写出 XSS 漏洞。我们在代码审查时专门强调过这个问题,团队约定:凡是插入用户可控内容,一律走转义函数,不允许拼 HTML。
分页这块,组件本身自带分页器。我一开始直接在组件内部维护分页状态,结果发现数据请求和表格展示混在一起特别乱。后来改成把分页状态放在页面控制器里,表格组件只负责展示和派发翻页事件,数据加载完全由业务代码控制。这个解耦方式后来成为了团队内部组件设计的一条原则:展示型组件尽量不要自己发起数据请求,把数据入口交给业务层。
4.4 与路由和业务数据联动
表格和筛选做好后,最后一步是数据联动。当时我们前端的状态管理主要依靠一个很轻量的发布订阅工具,没有接重型状态管理库,因为页面范围不大,引入 Redux 或者 Pinia 有点浪费。
具体思路是这样的:页面上有一个状态对象,保存筛选条件、当前页码、排序字段等;任何操作产生变化后,先更新状态对象,再触发一次统一的loadData()方法。这个方法的内部逻辑是把状态对象序列化成接口参数,调用后端接口,然后把返回值填进表格。
事件链大概是:筛选表单 submit -> 更新状态(页码重置为 1)-> loadData -> 表格 data 更新 -> 表格内部自动重新渲染。翻页时,页码变化 -> 更新状态 -> loadData,链路完全一致。
这个模式谈不上多高级,但胜在直观可靠。后来团队里新同事接手页面时,看一遍事件流就能改需求,维护成本降低了很多。相比之下,之前在 jQuery 项目里那种“先在某个回调里改了变量,再去手动触发另一个回调”的写法,简直是在给自己挖坑。
4.5 打包体积与加载性能实测
改造完成之后,我顺手测了一下打包体积。整个表格页面最终 JavaScript 产物大约 120KB,gzip 后不到 45KB。因为组件是按需加载的,筛选用到的表单组件、表格组件、基础样式都打包进了同一个 chunk。
对比原来项目引用的 jQuery 加 Bootstrap,整体资源体积没有明显增加,反而因为去掉了大量冗余的全局脚本,首屏渲染时间有了改善。数据接口返回后,表格从填充数据到完成首帧渲染,在开发环境模拟测了一下,大概 30 毫秒以内。这个数据在当时的业务场景里完全够用。
如果你对性能有更极致的要求,还可以开启组件库提供的自定义元素懒注册模式:等组件真正进入视口附近时再注册定义。我们当时没有做这层优化,因为页面数量还不多,后续如果表格页面继续膨胀,会考虑按路由动态加载组件定义,而不是把所有定义一次性打进主包。
5. 遇到过的坑:从表单值丢失到事件绑定失效
5.1 表单值获取不到,问题出在 Shadow DOM 和 form 的集成
第一个大坑就是前面多次提到的表单值问题。我当时改造完页面,高高兴兴点查询按钮,发现后端收到的一直是空对象。排查了很久,才发现自定义元素内部的输入框,并不会被外部<form>收集值。
原生表单收集值的机制,本质上是浏览器在表单提交时遍历表单控件元素,读取它们的name和value。但 Shadow DOM 对普通表单来说是个黑盒,外部 JavaScript 无法直接获取内部节点。所以组件库提供了自定义的事件和取值接口来桥接这个差异。
解决方案很简单:使用组件提供的getFormData()方法,或者监听它的自定义提交事件。但如果团队里有人不理解这套机制,把原生form提交逻辑直接搬过来用,就会踩中同一个坑。我后来专门在团队代码规范里加了一条:凡是用自定义表单组件的地方,一律禁止使用原生 form 的默认提交行为,统一走组件事件。
5.2 事件绑定失效:动态渲染的组件没有监听上
另一个高频坑是事件绑定失效。我们页面里有一个操作列,每行有两个按钮:编辑和删除。一开始直接在connectedCallback里为这些按钮绑定了事件,结果发现表格重新渲染后,事件不生效了。
原因是表格重新渲染时,内部会重建列表节点,旧节点被替换,绑定的事件自然跟着销毁。如果你不想每次渲染都重新绑定,可以在组件内部使用事件委托,把监听器挂到表格容器上,只用判断触发目标是不是对应按钮。具体代码如下:
this.shadowRoot.addEventListener('click', (e) => { const btn = e.target.closest('madeira-button'); if (!btn) return; const action = btn.dataset.action; const rowId = btn.dataset.id; if (action === 'edit') { this.dispatchEvent(new CustomEvent('edit', { detail: { id: rowId } })); } else if (action === 'delete') { this.dispatchEvent(new CustomEvent('delete', { detail: { id: rowId } })); } });用事件委托以后,不管表格怎么重新渲染,只要容器还在,监听就始终有效。这个习惯我还带到了后续 Vue 和 React 项目里:凡是动态生成的列表,优先用委托,而不是逐个绑定。
5.3 样式隔离导致 UI 风格不统一的尴尬
还有一个比较微妙的坑是全局样式渗透问题。组件库的 Shadow DOM 把内部样式挡得严严实实,这本来是优点,但当时我们设计系统里有不少全局辅助类,比如.text-danger、.mt-16,这些类在组件内部完全无效,因为组件内部看不到外部定义的类名。
这就导致了现象:外部页面用了全局辅助类一切正常,但只要进了组件内部,类名就失效了。解决办法我前面提过:优先用 CSS 自定义属性定义设计令牌,其次用::part()精准定制,实在不行的再去沟通组件作者开放样式接口。不要图省事直接写全局!important去覆盖,那样会破坏组件内部的样式隔离,之后维护起来处处都是雷。
5.4 内容安全与安全渲染的经验
最后分享一下安全层面的经验。Web Components 组件渲染内容时,如果用户输入被当成 HTML 插入了,就可能产生跨站脚本风险。组件库本身提供了默认安全转义,但如果你用render函数自定义列内容,并且自行拼接 HTML,那就等于把安全的门打开了。
我在团队内部定了一条硬性规矩:任何来自接口或用户输入的内容,拼入 HTML 前一律走统一的转义函数,比如把<、>、&、"、'全部转成实体字符。如果确实需要渲染富文本,也要用经过安全过滤的 HTML 清洗工具,绝不直接信任字符串。
这个坑在业务组件二次封装时尤其常见。大家为了图方便,直接在模板里写${userInput},本地测试没问题,一上线就被安全扫描盯上。花五分钟做一次安全过滤,比事后被通报整改划算得多。
6. 常见问题排查速查表
整理一下实际运维和开发中经常遇到的问题,方便你直接对号入座。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 组件不渲染,控制台没有报错 | 自定义元素未注册;引入顺序错误 | 检查customElements.define是否执行;确认 JS 文件在组件标签之前加载 |
| 表单提交后拿不到数据 | Shadow DOM 表单隔离导致原生收集失效 | 改用组件库的getFormData()或监听自定义 submit 事件 |
| 属性改变了但界面没更新 | 未配置observedAttributes或属性名大小写不一致 | 在类上声明static get observedAttributes();HTML 属性统一小写,JS 属性用驼峰 |
| 全局样式对组件内部不生效 | Shadow DOM 样式隔离 | 使用设计令牌变量、::part()或组件提供的样式接口 |
| 动态渲染列表事件失效 | 元素重建导致监听丢失 | 改用事件委托,监听容器级 click |
| 组件内部的中文文本被转义显示异常 | 转义逻辑误伤正常字符 | 检查转义函数是否只处理<>&"',不要整体做 HTML 编码 |
| 打包后组件重复注册报错 | 多入口重复引用了同一份组件定义 | 检查依赖配置,把组件库提取到公共 chunk 或只在一处引入 |
| 组件在低版本浏览器崩溃 | 缺少 Web Components polyfill | 引入必要的 polyfill,或放弃对老浏览器支持 |
看到这里,你应该对“Madeira”这类基于 Web Components 的组件方案有了比较完整的认知。它不是银弹,但确实在特定条件下能解决其他方案很难绕开的架构问题。我个人的体会是,技术选型没有绝对的好坏,关键看约束条件。如果你的项目也面临多框架共存、长期维护、样式隔离这些要求,不妨先搭个小试验工程,拿真实业务页面验证一遍,再决定是否全面铺开。
最后再分享一个小技巧:如果你打算在团队内部推广这类方案,别上来就开大会讲概念。找一个最痛的业务页面,花一两天时间把它改造成可对比的新版本,然后让团队成员分别用新老版本操作一遍。真实的加载速度、交互体验、代码可维护性差异,比任何 PPT 都有说服力。这套路径,比直接拍板上一套全家桶要稳妥得多。