Angular进阶之十五:使用 mat-button 替换 Bootstrap button 一:实战迁移与落地

一:摘要:

本文聚焦于Button 组件的迁移:从 Bootstrap 的 btn 系列切换到 Angular Material(M3)体系下的 mat-raised-button 与 mat-icon-button。涵盖风险清单、语义映射、M3 主题策略、自动化替换脚本、验证方案与问题处理(如禁用按钮的 Tooltip)。

二:背景与目标:

• 现状:项目已并存 @angular/material@18 和 ngx-bootstrap@8,且已有全局 M3 主题。

• 迁移范围:仅替换 <button>,不修改 <a>。

• Material 规则:只使用 mat-raised-button 与 mat-icon-button;M3 下不使用 color 属性(该属性仅在 M2 生效),用 类名+Scoped 主题实现色彩与状态。

三:风险清单(迁移前应知):

1. 颜色来源变化:

Bootstrap 的 btn-primary/secondary/danger 是静态色;Material M3 的色彩来自主题(palettes/roles),不再靠 color="..."。
2. 尺寸与密度:

Bootstrap 的 btn-lg/btn-sm/btn-block 需迁移为布局 CSS 或主题密度(M3 的 density),无 1:1 类映射。
3. Ripple & Overlay:

Material 默认有涟漪(ripple)与 overlay 行为;如需关闭或局部调整,需使用 [disableRipple] 或覆盖 CSS 变量。
4. 组件搭配规则:

避免无效或冲突组合(例如 mat-menu-item 只能用于菜单项,不可与 mat-raised-button 嵌套)。
5. 交互事件差异:

事件绑定语法保持不变,但禁用态、焦点行为、Keyboard 支持等可能不同。

四:语义映射(btn → mat)

原则:语义优先(primary/success/danger 等表达语义),通过类名绑定到局部主题作用域。

基本按钮

Bootstrap:<button class="btn btn-primary">提交

Material(M3):<button mat-raised-button class="mat-btn mat-btn-primary">提交

仅图标按钮(无文字或以图标为主)

Bootstrap:<button class="btn btn-light">

Material(M3):<button mat-icon-button aria-label="编辑" class="mat-btn mat-btn-icon">

说明:保留 <i class="fa ...">;加上 aria-label 以提升可访问性。

块级宽度(btn-block)

Bootstrap:btn-block

Material:通过容器 CSS(如 .w-100 或 display: block; width:100%),不强制修改按钮本身。

五:M3 主题策略(局部主题作用域)

为不同语义(如 success/danger/warn/info)提供局部主题类,并在模板中用类名选择:

处于将来升级的考虑,我们使用M3主题,更多请参阅:主题

全局主题(已存在)示例

css 复制代码
@use 'sass:map';
@use '@angular/material' as mat;
@include mat.core();

$theme: mat.define-theme((
  color: (
    theme-type: light,
    primary: mat.$azure-palette,
  ),
  typography: (
    brand-family: 'Comic Sans',
    bold-weight: 900
  ),
  density: (
    scale: -1
  )
));

html {
  @include mat.all-component-themes($theme);
}

局部主题(成功样式)示例:

新建主题及效果,颜色选值 文档,当然也可以自定义。

css 复制代码
@use '@angular/material' as mat;
@use 'global' as var;
@use 'sass:map';

// 从你的自定义调色板集合里取出 success(假设 var.$palettes 结构与 M3 tones 一致)
$_success: map.get(var.$palettes, success);

// ------ 关键:构造"辅助调色板集合"并合并到 primary ------
// 如果你在 var.$palettes 中也维护了 secondary / neutral / neutral-variant / error,按需取出;
// 若没有,可以先用默认或从设计稿里补齐。
$_rest: (
  secondary: map.get(var.$palettes, secondary),
  neutral: map.get(var.$palettes, neutral),
  neutral-variant: map.get(var.$palettes, neutral-variant),
  error: map.get(var.$palettes, error),
);

// 把辅助调色板合入 primary,得到一个"完整的 M3 primary palette"
$_primary: map.merge($_success, $_rest);

// 如果你也需要自定义 tertiary,可同样合并(这里演示保留默认)
// 用你自己的 tertiary,或用 Angular Material 预置的某个 M3 palette
$_tertiary: map.merge(
  map.get(var.$palettes, tertiary),
  $_rest
);

$success-theme: mat.define-theme((
  color: (
    theme-type: light,
    primary: $_primary,
    tertiary: $_tertiary,
  ),
  typography: (
    plain-family: 'Open Sans, sans-serif',
    brand-family: 'Open Sans, sans-serif',
    bold-weight: 700,
    medium-weight: 500,
    regular-weight: 300,
  ),
  density: (
    scale: -1,
  ),
));

// 把 "success 主题" 仅应用到 .mat-btn-success 作用域
.mat-btn-success {
  @include mat.button-theme($success-theme);
}

不要忘了把这个按钮主题文件应用到全局文件中。

css 复制代码
  @use 'mat-btn' as btn;

其它主题以此类推

六:迁移脚本

js 复制代码
// scripts/migrate-buttons.mjs
import { promises as fs } from 'node:fs';
import path from 'node:path';

/**
 * Usage examples:
 *   node scripts/migrate-buttons.mjs --dir=client/app/components --ext=.html --backup
 *   node scripts/migrate-buttons.mjs --dry-run
 */

const args = new Map(
  process.argv.slice(2).flatMap(a => {
    const m = a.match(/^--([^=]+)(?:=(.+))?$/);
    return m ? [[m[1], m[2] ?? true]] : [];
  }),
);

const ROOT_DIR = path.resolve(process.cwd(), args.get('dir') || 'client/app/components');
const EXT      = String(args.get('ext') || '.html');
const DRY_RUN  = Boolean(args.get('dry-run'));
const BACKUP   = Boolean(args.get('backup'));

// Bootstrap -> mat-btn-*(后缀保持一致)
const SAME_SUFFIX = [
  'primary', 'secondary', 'success', 'info', 'danger', 'warning', 'light', 'dark', 'link',
];
const CLASS_MAP = new Map([
  // 实心
  ...SAME_SUFFIX.map(sfx => [`btn-${sfx}`, `mat-btn-${sfx}`]),
  // outline 同样保持后缀一致(最终仍映射到同一类,外观统一为 raised)
  ...SAME_SUFFIX.map(sfx => [`btn-outline-${sfx}`, `mat-btn-${sfx}`]),
]);

// 移除的 Bootstrap 基类
const BOOTSTRAP_BASE_CLASSES = new Set(['btn', 'btn-sm', 'btn-lg']);

// Material 冲突/跳过的组件型指令(遵守组件规则)
const MATERIAL_SKIP_ATTRS = [
  'mat-menu-item', // 菜单项:不能再挂按钮指令
  'mat-fab',       // 浮动按钮:独占元素
  'mat-mini-fab',
];

// 已有目标外观 → 整个按钮跳过
const MATERIAL_TARGET_ATTRS = [
  'mat-raised-button',
  'mat-icon-button',
];

// 递归列出指定扩展名文件
async function listFiles(dir, ext, acc = []) {
  const entries = await fs.readdir(dir, { withFileTypes: true });
  for (const e of entries) {
    const full = path.join(dir, e.name);
    if (e.isDirectory()) {
      await listFiles(full, ext, acc);
    } else if (e.isFile() && full.endsWith(ext)) {
      acc.push(full);
    }
  }
  return acc;
}

// 是否仅图标内容(<i class="fa ..."> 或 <mat-icon>,且无文本)
function isIconOnly(innerHtml) {
  const cleaned = innerHtml
    .replace(/<!--[\s\S]*?-->/g, '')
    .replace(/\s+/g, '')
    .replace(/&nbsp;/g, '');

  const onlyFA   = /^<i[^>]*class=["'][^"']*?\bfa\b[^"']*["'][^>]*><\/i>$/.test(cleaned)
                || /^<i[^>]*class=["'][^"']*?\bfa\b[^"']*["'][^>]*\/>$/.test(cleaned);
  const onlyMatI = /^<mat-icon\b[^>]*>[\s\S]*<\/mat-icon>$/.test(cleaned);

  return onlyFA || onlyMatI;
}

// 迁移 class 属性:映射 Bootstrap 类、移除基类、保留其他自定义类
function migrateClassAttr(openTag) {
  const m = openTag.match(/\bclass\s*=\s*(['"])(.*?)\1/i);
  if (!m) return openTag;

  const quote = m[1];
  const classes = m[2].trim().split(/\s+/);

  const keep = [];
  const add  = new Set();

  for (const c of classes) {
    if (BOOTSTRAP_BASE_CLASSES.has(c)) continue; // 去掉 btn/btn-sm/btn-lg

    const mapped = CLASS_MAP.get(c);
    if (mapped) { add.add(mapped); continue; }

    keep.push(c); // 其他类保留(如 pull-right/view-chart 等)
  }

  const next = Array.from(new Set([...keep, ...add])).join(' ').trim();
  const newAttr = next ? `class=${quote}${next}${quote}` : '';
  return openTag.replace(m[0], newAttr || '').replace(/\s+>/, '>');
}

// 清理与按钮外观相关的 Material 属性(为统一外观做准备)& 移除 M2 的 color 属性(M3 无效)
function stripMaterialButtonAttrs(openTag) {
  // 仅移除会与我们"统一外观"冲突的按钮属性:mat-button/mat-flat/stroked
  let s = openTag.replace(/\s+mat-(?:button|flat-button|stroked-button)\b/g, '');

  // 移除 color="..."(M3 不使用 color)
  s = s.replace(/\s+color\s*=\s*(['"]).*?\1/g, '');
  return s;
}

// 检查是否包含任意属性
function hasAnyAttr(openTag, attrList) {
  return attrList.some(a => new RegExp(`\\b${a}\\b`).test(openTag));
}

// 注入唯一允许的外观属性:mat-icon-button 或 mat-raised-button
function injectMaterialAttr(openTag, iconOnly) {
  const attr = iconOnly ? 'mat-icon-button' : 'mat-raised-button';
  return openTag.replace(/^<\s*button\b/, match => `${match} ${attr}`);
}

// 迁移一个完整的 <button>...</button> 片段
function transformButtonTag(full) {
  const openTagMatch = full.match(/^<\s*button\b[^>]*>/i);
  if (!openTagMatch) return full;

  const openTag = openTagMatch[0];
  const inner   = full.slice(openTag.length, full.lastIndexOf('</')).trim();

  // 1) 已有目标外观 → 完整跳过(不改任何内容)
  if (hasAnyAttr(openTag, MATERIAL_TARGET_ATTRS)) {
    return full;
  }

  // 2) Material 冲突组件(菜单项/浮动按钮)→ 不注入按钮外观,仅做类迁移 & 移除 color
  if (hasAnyAttr(openTag, MATERIAL_SKIP_ATTRS)) {
    let nextOpen = migrateClassAttr(openTag);
    nextOpen = nextOpen.replace(/\s+color\s*=\s*(['"]).*?\1/g, ''); // 移除 M2 color
    return nextOpen + full.slice(openTag.length);
  }

  // 3) 普通按钮路径:
  //    a. 类迁移
  //    b. 清理其它按钮外观(mat-button/mat-flat/mat-stroked)
  //    c. 注入两种允许外观之一
  const iconOnly = isIconOnly(inner);

  let nextOpen = migrateClassAttr(openTag);
  nextOpen = stripMaterialButtonAttrs(nextOpen);
  nextOpen = injectMaterialAttr(nextOpen, iconOnly);

  return nextOpen + full.slice(openTag.length);
}

// 对整份 HTML 做替换(只处理 <button>,不动 <a>)
function transformHtml(html) {
  return html.replace(/<button\b[^>]*>[\s\S]*?<\/button>/gi, tag => transformButtonTag(tag));
}

async function main() {
  console.log(`Scanning: ${ROOT_DIR} (ext: ${EXT})`);
  const files = await listFiles(ROOT_DIR, EXT);
  let changed = 0;

  for (const f of files) {
    const src = await fs.readFile(f, 'utf8');
    const out = transformHtml(src);
    if (out !== src) {
      if (DRY_RUN) {
        console.log(`~ would migrate: ${path.relative(process.cwd(), f)}`);
        continue;
      }
      if (BACKUP) {
        await fs.writeFile(`${f}.bak`, src, 'utf8');
      }
      await fs.writeFile(f, out, 'utf8');
      changed++;
      console.log(`✓ migrated: ${path.relative(process.cwd(), f)}`);
    }
  }
  console.log(`\nDone. Updated files: ${changed}/${files.length}`);
}

main().catch(err => {
  console.error(err);
  process.exit(1);
});

七:验证与回归(批量与视觉)
1:正则批量扫描

用 grep 或脚本检查关键点:

js 复制代码
// 冲突检查,应无输出
grep -R --line-number "<button[^>]*mat-menu-item[^>]*mat-raised-button" client/app/components
grep -R --line-number "<button[^>]*mat-menu-item[^>]*mat-icon-button" client/app/components

// 非 <button> 修改检查,应无输出
grep -R --line-number "<a[^>]*mat-raised-button" client/app/components

// 类名映射检查,应无输出
grep -R --line-number "btn-" client/app/components

2:使用脚本辅助验证

validate-buttons.mjs是通过验收目标的规则生成的,运行之后很容易发现哪些文件存在问题:

3:视觉回归和人工抽样检查

matTooltip不显示了:

这个问题GitHub中也有记录 50407953

因为大多数浏览器对于禁用的元素不会触发mouseenter事件。一种解决方法是将 matTooltip 添加到父元素:

html 复制代码
<div matTooltip="当前记录不可编辑" matTooltipClass="body">
    <button mat-raised-button type="button" class="mat-btn-success" [disabled]="true">编辑</button>
</div>

Material 样式未生效:检查模块注册与主题引入顺序;确保 @include mat.all-component-themes(...) 已应用到 html 或全局作用域;局部主题文件通过 @use 引入。

总结:

• 明确语义(primary/success...)→ 用类名+局部主题映射到 M3。

• 自动化脚本只处理 <button>,避免跨组件误改。

• 验证脚本 + 视觉回归,快速发现 Tooltip 、模块注册等细节问题。

相关推荐
KenkoTech8 天前
Angular由一个bug说起之二十三:记一次“时好时坏”的CI测试的debug过程
angular
添加shujuqudong1如果未回复1 个月前
Comsol多场耦合:解锁地质能源开采新视野
angular
询问QQ688238862 个月前
Transformer-LSTM 多变量回归预测:Matlab 实现与探索
angular
Q688238862 个月前
8位40M采样频率异步SAR ADC设计与仿真全集(SMIC18mmrf工艺)
angular
KenkoTech2 个月前
Angular由一个bug说起之二十:Table lazy load:防止重复渲染
angular
CodeCraft Studio3 个月前
前端表格工具AG Grid 34.3 发布:重磅引入AI工具包,全面支持 React 19.2!
前端·人工智能·react.js·angular·ag grid·前端表格工具·透视分析
黄毛火烧雪下3 个月前
Angular 入门项目
前端·angular
患得患失9493 个月前
【Angular 】Angular 中的依赖注入
angular