跟着MDN学HTML_day_49:(ShadowRoot接口)

跟着 MDN 学 HTML day_49:深入理解 ShadowRoot 接口


📑 目录

左列章节 右列章节
[一、创建 Shadow Root 并理解其模式](#左列章节 右列章节 一、创建 Shadow Root 并理解其模式 二、认识 ShadowRoot 的宿主元素 三、innerHTML 操作 Shadow DOM 四、焦点元素与选区 五、坐标定位方法 六、delegatesFocus 代理焦点) [二、认识 ShadowRoot 的宿主元素](#左列章节 右列章节 一、创建 Shadow Root 并理解其模式 二、认识 ShadowRoot 的宿主元素 三、innerHTML 操作 Shadow DOM 四、焦点元素与选区 五、坐标定位方法 六、delegatesFocus 代理焦点)
[三、innerHTML 操作 Shadow DOM](#左列章节 右列章节 一、创建 Shadow Root 并理解其模式 二、认识 ShadowRoot 的宿主元素 三、innerHTML 操作 Shadow DOM 四、焦点元素与选区 五、坐标定位方法 六、delegatesFocus 代理焦点) [四、焦点元素与选区](#左列章节 右列章节 一、创建 Shadow Root 并理解其模式 二、认识 ShadowRoot 的宿主元素 三、innerHTML 操作 Shadow DOM 四、焦点元素与选区 五、坐标定位方法 六、delegatesFocus 代理焦点)
[五、坐标定位方法](#左列章节 右列章节 一、创建 Shadow Root 并理解其模式 二、认识 ShadowRoot 的宿主元素 三、innerHTML 操作 Shadow DOM 四、焦点元素与选区 五、坐标定位方法 六、delegatesFocus 代理焦点) [六、delegatesFocus 代理焦点](#左列章节 右列章节 一、创建 Shadow Root 并理解其模式 二、认识 ShadowRoot 的宿主元素 三、innerHTML 操作 Shadow DOM 四、焦点元素与选区 五、坐标定位方法 六、delegatesFocus 代理焦点)

一、创建 Shadow Root 并理解其模式

ShadowRoot 不能通过构造函数直接创建,它必须通过 ElementattachShadow 方法来生成。在创建时,需要指定 mode 参数,它决定了 Shadow DOM 的内部实现是否对外部 JavaScript 开放。

代码示例:open 与 closed 两种模式
javascript 复制代码
// 获取宿主元素
const openHost = document.getElementById('openComponent');
const closedHost = document.getElementById('closedComponent');

// 创建开放模式的 Shadow Root
const openShadow = openHost.attachShadow({ mode: 'open' });
openShadow.innerHTML = `
  <style>
    p {
      color: blue;
      font-weight: bold;
      padding: 10px;
      border: 1px solid blue;
    }
  </style>
  <p>这是 Shadow DOM 中的内容,模式为 open</p>
`;

// 创建封闭模式的 Shadow Root
const closedShadow = closedHost.attachShadow({ mode: 'closed' });
closedShadow.innerHTML = `
  <style>
    p {
      color: red;
      font-weight: bold;
      padding: 10px;
      border: 1px solid red;
    }
  </style>
  <p>这是 Shadow DOM 中的内容,模式为 closed</p>
`;
代码示例:两种模式的可访问性差异
javascript 复制代码
// 通过 host 元素的 shadowRoot 属性访问
console.log('开放模式的 shadowRoot:', openHost.shadowRoot);     // ShadowRoot 对象
console.log('开放模式的 mode:', openHost.shadowRoot.mode);       // "open"

// 封闭模式下 host.shadowRoot 返回 null
console.log('封闭模式的 shadowRoot:', closedHost.shadowRoot);   // null

// 如果在创建时保存了引用,仍然可以操作封闭模式的 ShadowRoot
console.log('封闭模式的 mode(通过保存的引用访问):', closedShadow.mode); // "closed"

核心结论mode 属性有两种可选值:openclosed 。当 modeopen 时,可以通过宿主元素的 shadowRoot 属性获取到 ShadowRoot 实例,允许外部 JavaScript 访问和修改内部结构。当 modeclosed 时,host.shadowRoot 返回 null,外部脚本无法直接访问内部实现。


⚠️ 【重点 / 面试考点 / 易错点】

模式 外部可访问性 host.shadowRoot 适用场景
open ✅ 外部可访问 返回 ShadowRoot 实例 需要外部脚本交互的组件
closed ❌ 外部不可访问 返回 null 封装要求严格的组件
  • closed 并非绝对安全 ------如果创建时保存了 ShadowRoot 引用,仍可通过该引用操作
  • closed 的主要作用是防止外部意外访问,而非提供安全隔离
  • 大多数场景推荐使用 open 模式,便于调试和外部样式穿透

二、认识 ShadowRoot 的宿主元素

每一个 ShadowRoot 都会附加到一个宿主元素 上,通过 host 属性可以获取到这个宿主 DOM 元素的引用。这一属性在编写可复用的自定义组件时非常重要。

代码示例:通过 host 属性访问宿主元素
javascript 复制代码
const container = document.getElementById('cardContainer');
const shadowRoot = container.attachShadow({ mode: 'open' });

// 在 Shadow DOM 中构建卡片结构
shadowRoot.innerHTML = `
  <style>
    .card {
      background-color: #f8f9fa;
      border-radius: 8px;
      padding: 20px;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    }
    .card h3 { margin-top: 0; color: #333; }
    .card p { color: #666; line-height: 1.6; }
    button {
      background-color: #007bff;
      color: white;
      border: none;
      padding: 8px 16px;
      border-radius: 4px;
      cursor: pointer;
    }
  </style>
  <div class="card">
    <h3>卡片标题</h3>
    <p>这是卡片内部的描述文字,样式不受外部 CSS 影响。</p>
    <button id="actionBtn">点击我</button>
  </div>
`;

// 使用 host 属性从 Shadow DOM 内部获取宿主元素
const hostElement = shadowRoot.host;
console.log('ShadowRoot 的宿主元素:', hostElement);
console.log('宿主元素的 ID:', hostElement.id);
console.log('宿主元素是否与容器相同:', hostElement === container); // true
代码示例:通过 host 与外部交互
javascript 复制代码
// 在 Shadow DOM 中给按钮添加事件监听
const button = shadowRoot.querySelector('#actionBtn');
button.addEventListener('click', function() {
  // 通过 host 属性获取宿主元素,进而修改其属性
  shadowRoot.host.setAttribute('data-clicked', 'true');
  shadowRoot.host.style.border = '2px solid #007bff';

  // 在 Shadow DOM 内部显示响应
  const messageDiv = document.createElement('p');
  messageDiv.textContent = '宿主元素的 data-clicked 已设置为: ' +
    shadowRoot.host.getAttribute('data-clicked');
  messageDiv.style.color = 'green';
  shadowRoot.querySelector('.card').appendChild(messageDiv);
});

核心结论host 属性建立了 Shadow DOM 内部与外部宿主元素之间的桥梁。通过 host 属性,组件内部可以读取宿主的属性、数据属性,甚至修改宿主的样式类来反映组件状态。host 属性是只读的,无法修改。


三、使用 innerHTML 操作 Shadow DOM 内容

ShadowRoot 接口提供了 innerHTML 属性,允许开发者以字符串形式读写 Shadow DOM 内部的 HTML 结构。这与普通 DOM 元素上的 innerHTML 用法完全一致。

代码示例:使用 innerHTML 设置初始内容
javascript 复制代码
const host = document.getElementById('dynamicComponent');
const shadowRoot = host.attachShadow({ mode: 'open' });

// 使用 innerHTML 设置初始内容
shadowRoot.innerHTML = `
  <style>
    .content {
      font-family: 'Arial', sans-serif;
      padding: 16px;
      background-color: #e9ecef;
    }
    .item {
      background-color: white;
      margin: 8px 0;
      padding: 12px;
      border-radius: 4px;
      border-left: 4px solid #28a745;
    }
  </style>
  <div class="content">
    <h4>动态内容区域</h4>
    <div class="item">项目一:初始内容</div>
    <div class="item">项目二:初始内容</div>
  </div>
`;
代码示例:读取和更新 innerHTML
javascript 复制代码
// 读取 Shadow DOM 的 HTML 结构
console.log('当前 Shadow DOM 结构:', shadowRoot.innerHTML);

// 替换整个 Shadow DOM 内容(完全重写)
shadowRoot.innerHTML = `
  <style>
    .content {
      padding: 16px;
      background-color: #d4edda;
    }
    .item {
      background-color: white;
      margin: 8px 0;
      padding: 12px;
      border-left: 4px solid #dc3545;
    }
  </style>
  <div class="content">
    <h4>内容已完全更新</h4>
    <div class="item">全新内容 A</div>
    <div class="item">全新内容 B</div>
    <div class="item">全新内容 C</div>
  </div>
`;
代码示例:追加内容(避免全部重写)
javascript 复制代码
// 获取已有容器并追加内容,比重写 innerHTML 性能更好
const contentDiv = shadowRoot.querySelector('.content');
if (contentDiv) {
  const newItem = document.createElement('div');
  newItem.className = 'item highlight';
  newItem.textContent = '新追加的项目 ' + new Date().toLocaleTimeString();
  contentDiv.appendChild(newItem);
}

核心结论 :设置 innerHTML完全替换 Shadow DOM 中的所有内容。如果只需要追加内容,应使用 appendChildinsertAdjacentHTML 等方法。频繁使用 innerHTML 进行大量内容替换可能影响性能,处理复杂组件时应考虑更细粒度的 DOM 操作方法。


⚠️ 【重点 / 面试考点】

  • innerHTML 设置时会完全替换 Shadow DOM 内所有内容,包括样式和结构
  • 频繁重写 innerHTML 会导致性能下降事件监听器丢失
  • 追加内容优先使用 appendChild / insertAdjacentHTML,避免不必要的全部重写
  • Shadow DOM 内的 <style> 标签仅作用于 Shadow DOM 内部,不会影响外部页面
  • 这是 Shadow DOM 样式封装的核心机制

四、获取 Shadow DOM 中的焦点元素与选区

ShadowRoot 接口通过 DocumentOrShadowRoot 混入,获得了 activeElement 属性和 getSelection 方法,使得可以在 Shadow DOM 的上下文中追踪焦点文本选择状态。

代码示例:activeElement 追踪焦点元素
javascript 复制代码
const formHost = document.getElementById('formComponent');
const shadow = formHost.attachShadow({ mode: 'open' });

shadow.innerHTML = `
  <style>
    .form-group { margin-bottom: 16px; padding: 20px; background: #f5f5f5; }
    input, textarea {
      width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;
    }
    input:focus, textarea:focus {
      outline: none; border-color: #007bff;
      box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
    }
  </style>
  <div class="form-group">
    <label for="nameInput">姓名:</label>
    <input type="text" id="nameInput" placeholder="请输入姓名">
  </div>
  <div class="form-group">
    <label for="emailInput">邮箱:</label>
    <input type="email" id="emailInput" placeholder="请输入邮箱">
  </div>
`;

// 检查 Shadow DOM 内的焦点元素
const activeElement = shadow.activeElement;
if (activeElement) {
  console.log('Shadow DOM 中当前焦点元素:');
  console.log('  标签名:', activeElement.tagName);
  console.log('  ID:', activeElement.id);
  console.log('  值:', activeElement.value);
}
代码示例:getSelection 获取文本选区
javascript 复制代码
// 获取用户在 Shadow DOM 中的文本选择状态
const selection = shadow.getSelection();
if (selection && selection.toString().length > 0) {
  console.log('选中的文本:', selection.toString());
  console.log('选区起始节点:', selection.anchorNode);
  console.log('选区起始偏移量:', selection.anchorOffset);
  console.log('选区结束节点:', selection.focusNode);
  console.log('选区结束偏移量:', selection.focusOffset);
} else {
  console.log('当前没有选中的文本');
}

核心结论shadow.activeElement 返回 Shadow DOM 树中当前拥有焦点的元素(焦点不在 Shadow DOM 内部时返回 null)。document.activeElement 在 Shadow DOM 场景下返回的是宿主元素 ,而 shadow.activeElement 能够穿透封装获取内部具体的焦点元素。


五、使用坐标定位方法查找元素

ShadowRoot 接口提供了 elementFromPointelementsFromPoint 方法,允许开发者根据视口坐标在 Shadow DOM 内部查找对应的元素。这些方法在处理鼠标交互和拖拽功能时非常有用。

代码示例:elementFromPoint 与 elementsFromPoint
javascript 复制代码
const host = document.getElementById('interactiveHost');
const shadow = host.attachShadow({ mode: 'open' });

shadow.innerHTML = `
  <style>
    .container {
      width: 100%; height: 100%; position: relative;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    }
    .box {
      position: absolute; width: 80px; height: 80px;
      border-radius: 8px; display: flex;
      align-items: center; justify-content: center;
      color: white; font-weight: bold; font-size: 12px;
    }
    .box-a { top: 30px; left: 30px; background: rgba(255,255,255,0.3); }
    .box-b { top: 30px; right: 30px; background: rgba(255,255,255,0.4); }
  </style>
  <div class="container">
    <div class="box box-a" data-name="A区">区域 A</div>
    <div class="box box-b" data-name="B区">区域 B</div>
  </div>
`;

// 监听宿主元素上的鼠标移动事件
host.addEventListener('mousemove', function(event) {
  const rect = host.getBoundingClientRect();
  const x = event.clientX - rect.left;
  const y = event.clientY - rect.top;

  // 获取指定坐标的最顶层元素
  const topElement = shadow.elementFromPoint(x, y);

  // 获取指定坐标的所有元素(从顶到底)
  const allElements = shadow.elementsFromPoint(x, y);

  console.log('坐标: (' + x + ', ' + y + ')');
  console.log('最顶层元素:', topElement?.getAttribute('data-name') || '无');
  console.log('该坐标下的元素层级:');
  allElements.forEach(function(el, index) {
    console.log('  ' + (index + 1) + '. ' + (el.getAttribute('data-name') || el.tagName));
  });
});
代码示例:点击拾取元素
javascript 复制代码
host.addEventListener('click', function(event) {
  const rect = host.getBoundingClientRect();
  const x = event.clientX - rect.left;
  const y = event.clientY - rect.top;

  const clickedElement = shadow.elementFromPoint(x, y);
  if (clickedElement) {
    const name = clickedElement.getAttribute('data-name');
    if (name) {
      console.log('点击了 Shadow DOM 中的:', name);
    }
  }
});

核心结论elementFromPoint 返回指定坐标点处最顶层 的元素,elementsFromPoint 返回该坐标点处从顶到底的所有元素数组 。坐标是相对于视口的,计算 Shadow DOM 内部坐标时需减去宿主元素的偏移量document.elementFromPoint 返回宿主元素 ,而 shadow.elementFromPoint穿透封装返回实际内部元素。


六、代理焦点行为与 delegatesFocus 属性

ShadowRoot 接口的 delegatesFocus 属性是一个只读布尔值 ,在 Shadow Root 创建时通过配置项设定。当该属性为 true 时,Shadow DOM 内部的焦点行为会以特殊方式代理到宿主元素上。

代码示例:创建带 delegatesFocus 的 Shadow Root
javascript 复制代码
// 创建启用了 delegatesFocus 的 Shadow Root
const delegatingHost = document.getElementById('delegatingHost');
const delegatingShadow = delegatingHost.attachShadow({
  mode: 'open',
  delegatesFocus: true   // 启用焦点委托
});

delegatingShadow.innerHTML = `
  <style>
    input {
      padding: 10px; border: 2px solid #ccc;
      border-radius: 4px; width: 200px; font-size: 14px;
    }
    input:focus { border-color: #007bff; outline: none; }
  </style>
  <input type="text" placeholder="启用了焦点委托的输入框">
  <button>内部按钮</button>
`;

// 创建未启用 delegatesFocus 的 Shadow Root(对比)
const normalHost = document.getElementById('normalHost');
const normalShadow = normalHost.attachShadow({
  mode: 'open',
  delegatesFocus: false  // 不启用焦点委托(默认)
});

normalShadow.innerHTML = `
  <style>
    input { padding: 10px; border: 2px solid #ccc; border-radius: 4px; width: 200px; }
    input:focus { border-color: #007bff; outline: none; }
  </style>
  <input type="text" placeholder="未启用焦点委托的输入框">
`;
代码示例:delegatesFocus 的行为差异
javascript 复制代码
// 检查 delegatesFocus 属性
console.log('第一个 ShadowRoot 的 delegatesFocus:', delegatingShadow.delegatesFocus); // true
console.log('第二个 ShadowRoot 的 delegatesFocus:', normalShadow.delegatesFocus);     // false

// 当 delegatesFocus 为 true 时:
// - Shadow DOM 内部元素获得焦点时,document.activeElement 指向宿主元素
// - 宿主元素匹配 :focus 和 :focus-within CSS 伪类
// - 外部页面可感知组件内部的焦点状态

// 监听焦点事件
delegatingHost.addEventListener('focus', function() {
  console.log('delegatingHost 获得焦点(来自内部元素的委托)');
  console.log('document.activeElement:', document.activeElement);       // delegatingHost
  console.log('Shadow 内 activeElement:', delegatingShadow.activeElement); // input 元素
});

核心结论delegatesFocus: true 解决了 Shadow DOM 组件的焦点感知问题------当内部可聚焦元素获得焦点时,宿主元素在文档层面表现为获得焦点状态。这使得基于 Shadow DOM 的组件能够更好地与页面的无障碍功能:focus / :focus-within CSS 伪类集成。


⚠️ 【重点 / 面试考点】

属性/方法 功能 关键特性
mode 访问控制 open 可外部访问,closed 不可访问
host 获取宿主元素 只读,建立内外桥梁
innerHTML 读写 HTML 结构 完全替换,注意性能
activeElement 焦点元素 穿透封装获取内部焦点
getSelection() 文本选区 获取 Shadow DOM 内选区
elementFromPoint 坐标定位 单元素,坐标相对视口
elementsFromPoint 坐标定位 元素数组,从顶到底
delegatesFocus 焦点代理 内部焦点委托给宿主
  • delegatesFocus 必须在 attachShadow() 时设置,创建后不可修改
  • document.activeElement 遇到 Shadow DOM 只返回宿主元素 ,要获取内部焦点用 shadow.activeElement
  • closed 模式不能阻挡开发者工具检查,仅阻止 JavaScript 访问

七、ShadowRoot 核心方法与属性速查

Shadow DOM 架构示意图

host 属性
shadowRoot 属性
Light DOM

普通页面
Host Element

宿主元素
Shadow Root

影子根
Shadow DOM

内部 DOM 树
内部样式 style
内部元素 slot/content
内部脚本/事件

核心属性
属性 类型 说明
mode string "open""closed",创建时设定
host Element 宿主元素引用,只读
innerHTML string Shadow DOM 内部的 HTML 字符串
delegatesFocus boolean 是否启用焦点代理,创建时设定
activeElement Element / null 当前获得焦点的内部元素
核心方法
方法 返回值 说明
getSelection() Selection / null 获取文本选区
elementFromPoint(x, y) Element / null 坐标点最顶层元素
elementsFromPoint(x, y) Element[] 坐标点所有元素
创建方式
javascript 复制代码
// 标准创建方式
const shadow = element.attachShadow({
  mode: 'open',           // 或 'closed'
  delegatesFocus: true    // 可选,默认 false
});

✅ 文档总结

  • ShadowRoot 通过 element.attachShadow({ mode, delegatesFocus }) 创建,不能通过构造函数直接实例化
  • mode: 'open' 允许外部通过 host.shadowRoot 访问内部;mode: 'closed' 返回 null,但保存的引用仍可操作
  • host 属性只读,是 Shadow DOM 内部与外部宿主元素的唯一桥梁
  • innerHTML 可读写 Shadow DOM 结构,但完全替换性能较差,追加内容优先用 appendChild
  • Shadow DOM 内 <style> 仅作用于内部,是样式封装的核心机制
  • activeElement 获取内部焦点元素;getSelection() 获取内部文本选区
  • elementFromPoint / elementsFromPoint 实现坐标定位,坐标相对视口需减去宿主偏移
  • delegatesFocus: true 将内部焦点代理给宿主,使 :focus / :focus-within 伪类正常工作

完整实践示例
javascript 复制代码
// 综合应用:封装一个带焦点代理和动态内容的 Shadow DOM 组件

class ShadowCard extends HTMLElement {
  constructor() {
    super();

    // 创建 Shadow Root,启用焦点代理
    this.shadow = this.attachShadow({
      mode: 'open',
      delegatesFocus: true
    });

    // 初始化结构
    this.shadow.innerHTML = `
      <style>
        :host {
          display: block;
          border: 2px solid #e0e0e0;
          border-radius: 12px;
          padding: 20px;
          background: white;
          transition: border-color 0.3s, box-shadow 0.3s;
        }
        :host(:focus-within) {
          border-color: #007bff;
          box-shadow: 0 4px 12px rgba(0, 123, 255, 0.15);
        }
        .card-header {
          font-size: 18px;
          font-weight: bold;
          margin-bottom: 12px;
          color: #333;
        }
        .card-body {
          color: #666;
          line-height: 1.6;
          margin-bottom: 16px;
        }
        input {
          width: 100%;
          padding: 10px;
          border: 1px solid #ccc;
          border-radius: 6px;
          box-sizing: border-box;
          font-size: 14px;
        }
        input:focus {
          border-color: #007bff;
          outline: none;
        }
        .status {
          margin-top: 12px;
          font-size: 12px;
          color: #999;
        }
      </style>
      <div class="card-header">Shadow Card 组件</div>
      <div class="card-body">
        <slot>默认内容</slot>
      </div>
      <input type="text" placeholder="在此输入...">
      <div class="status">等待输入...</div>
    `;

    // 绑定内部事件
    this.bindEvents();
  }

  bindEvents() {
    const input = this.shadow.querySelector('input');
    const status = this.shadow.querySelector('.status');

    // 监听输入事件
    input.addEventListener('input', () => {
      status.textContent = '当前输入: ' + input.value;
      status.style.color = '#28a745';
    });

    // 监听焦点事件
    input.addEventListener('focus', () => {
      console.log('输入框获得焦点');
      console.log('shadow.activeElement:', this.shadow.activeElement?.tagName);
      console.log('document.activeElement:', document.activeElement?.tagName); // shadow-card
    });

    // 监听选区变化
    input.addEventListener('select', () => {
      const selection = this.shadow.getSelection();
      console.log('选中文本:', selection?.toString());
    });
  }

  // 公共 API:动态更新内容
  updateContent(html) {
    const body = this.shadow.querySelector('.card-body');
    body.innerHTML = html;
  }

  // 公共 API:获取当前输入值
  getValue() {
    return this.shadow.querySelector('input').value;
  }
}

// 注册自定义元素
customElements.define('shadow-card', ShadowCard);

// 使用示例
document.addEventListener('DOMContentLoaded', () => {
  const card = document.querySelector('shadow-card');

  // 通过公共 API 更新内容
  card.updateContent('<p>这是动态更新的内容!</p>');

  // delegatesFocus 使得宿主元素能感知内部焦点
  card.addEventListener('focus', () => {
    console.log('卡片组件获得焦点(由内部 input 委托)');
  });
});

通过动手实践,可以更直观地体会到 ShadowRoot 接口如何为 Web 组件提供真正的封装能力,以及 delegatesFocusactiveElement 等特性在构建无障碍、可交互组件时的重要作用。


想要解锁更多HTML 核心标签实战、前端零基础入门干货、开发避坑全指南吗?
持续关注,后续将更新CSS 布局实战、JavaScript 交互基础、全站导航开发等硬核内容,带你从新手快速进阶,轻松搞定前端开发!

相关推荐
小则又沐风a1 小时前
初步了解进程的概念
java·linux·服务器·前端
审判长烧鸡1 小时前
【前端】npm audit fix 修复漏洞时的具体逻辑
前端·npm
之歆1 小时前
DAY_25 JavaScript 原型、原型链与值类型/引用类型 ── 深度全解(上)
开发语言·javascript·原型模式
幽络源小助理1 小时前
IP定位系统源码二开版 新增分销功能 PHP地理位置查询系统
前端·开源·源码·php源码
JianZhen✓1 小时前
前端面试“八股文” - 核心、高频知识体系整理
前端·ai编程
sheeta19981 小时前
Pinia核心笔记
前端·vue.js·笔记
淑子啦1 小时前
TS 和组件绑定深耕(泛型表格)
前端·javascript·react.js
道清茗2 小时前
【shell编程知识点汇总】第九章 HTML 清洗、多行合并与条件替换
前端·html
噢,我明白了3 小时前
表单的完整 CRUD 练习【极简个人记账本】(含前端后端链接mySQL)
java·前端·数据库·mysql