Skip to content

商品参数选项-创建选项元素与商品参数选项-点击选项元素选中(Day21 1/2) ​

本页对应 Day21_PC项目,共 4 段视频,约 50 分钟。

学习目标 ​

  • 能依据商品参数数据遍历每组选项,动态创建并嵌套 DL、DT、DD 元素,正确渲染选项结构与文本。
  • 能使用事件委托监听选项区域点击,判断目标为 DD,并为当前项添加 active、同组其他项移除 active,实现组内排他选中。
  • 能使用固定长度数组按选项组索引保存被选中的 DD 引用,并通过自定义属性获取组索引,保证选中项按组顺序存放且同组互斥。
  • 能遍历选中项数组创建选中标签元素,包含选项文本和关闭按钮,并在每次渲染前清空标签容器以避免重复。
  • 能在初始化时默认选中每组第一个 DD,并验证点击不同组或同组选项后数组内容、标签顺序和页面选中状态保持一致。

知识点 ​

先把 DL 骨架创建出来 ​

为什么需要:商品参数区的结构是「一组一个 dl,dl 里一个 dt 标题 + 若干 dd 选项」。页面上的组数是动态的,数据里有几组就得建几个 dl,写死 HTML 不现实。

怎么想:转写稿里第一件事就是去 goodsDetail 里翻数据——这个数组有多长,页面上就该有几个 dl。所以直接对数组做遍历,数组元素就叫 dlItem,下标叫 dlIndex,用 dlItem 造 dl,用 dlIndex 记录这是第几组。

怎么做:

js
goodsDetail.forEach((dlItem, dlIndex) => {
  const dlEle = document.createElement('dl');
  optionsBox.appendChild(dlEle);   // 先挂到包裹元素上
  // 后面再往里塞 dt / dd
});

易错点:不少同学会纠结「必须先创建完 dl、再创建 dt、最后才 appendChild 到 optionsBox 吗」。讲师当场验证过:不需要。dlEle 这个变量永远指向那个 dl 元素,它有没有被挂进 optionsBox,都不影响你继续给它 appendChild。先挂后挂都能加进去,顺序不是硬性要求。

往 DL 里补 DT 和 DD ​

为什么需要:dl 只是壳子,用户看到的是标题和选项文字。

怎么想:dt 的内容就是 dlItem.title;dd 是「每组里的选项数据」,也就是 dlItem.data——注意 data 又是一个数组,每个成员是对象,选项文字在 type 属性上。所以 dd 要多遍历一层。

怎么做:

js
const dtEle = document.createElement('dt');
dtEle.innerHTML = dlItem.title;
dlEle.appendChild(dtEle);

dlItem.data.forEach((ddItem, ddIndex) => {
  const ddEle = document.createElement('dd');
  ddEle.innerHTML = ddItem.type;
  dlEle.appendChild(ddEle);
});

易错点:这里比「单纯创建+添加元素」多套了一层循环,容易把 dlItem.data 写成 dlItem,结果拿到的是 undefined;另外 innerHTML 取的是 ddItem.type,不是 ddItem 本身。

点击选中与同组排他 ​

为什么需要:选项要能点,点中后同组只能亮一个。

怎么想:dd 全是 JS 新建的,用传统「先获取集合再循环绑定」根本拿不到集合。有两条路:创建时顺手绑事件,或者事件委托。讲师选了委托——委托只挂一个监听,不用在循环里再套函数形成闭包,内存占用更小。

怎么做:委托给包裹元素 optionsBox,回调里先判断目标是不是 dd(tagName 是大写的 DD),再排他:

js
optionsBox.addEventListener('click', function (e) {
  if (e.target.tagName !== 'DD') return;

  const siblings = e.target.parentElement.children;
  for (let i = 1; i < siblings.length; i++) {  // 0 是 dt,跳过
    siblings[i].classList.remove('active');
  }
  e.target.classList.add('active');
});

易错点:parentElement.children 拿到的是 HTMLCollection,没有 forEach,只能写 for 循环;而且它包含 dt,所以从 1 开始;排他时对每个兄弟是 remove('active'),不是 add。

为什么要用一个固定长度的数组 ​

为什么需要:点一下就往页面 append 一个「选中标签」,点几次就有几个,重复点同组还会出现两个「银色」,顺序也乱。

怎么想:选中标签最多 4 个,且位置和组一一对应——第 0 位永远放颜色、第 1 位放内存、第 2 位放版本、第 3 位放购买方式。既然「位置固定、按组对应」,那天然就该用数组:数组下标 = 组下标,同组再点就是覆盖同一个下标,自动实现「互斥」;不同组各占各的格子,自动实现「顺序」。

怎么做:

js
const selectedArr = new Array(goodsDetail.length); // 长度固定,元素为 undefined

易错点:把 DD 元素放进数组,只是多了一个引用,不会像 appendChild 那样把元素从原位置挪走,两者有本质区别。

用自定义属性把组索引带出来 ​

为什么需要:点击时只能拿到 e.target,它身上没有任何「我是第几组」的信息。而组索引只在创建时(dlIndex)才有,创建和点击不是同一个时机。

怎么想:既然创建时能拿到,就把它存到元素身上,用的时候再读。用 dataset 加自定义属性最合适。

怎么做:在创建 dd 时加 ddEle.dataset.groupIndex = dlIndex;,点击时读 e.target.dataset.groupIndex,再 selectedArr[groupIndex] = e.target。也可以加在 dl 上,点击时用 e.target.parentElement.dataset.index 取,但加在 dd 上就不用再找父元素。

易错点:属性名带连字符,dataset 读取要转驼峰(group-index → groupIndex)。

根据数组渲染选中标签 ​

为什么需要:数组只是数据,页面上得真的出现标签。

怎么想:封装一个 createSelectedTag(),遍历数组生成标签。数组可能是稀疏的(比如只选了第 1、3 组),forEach 会自动跳过空位,正好合适;for 循环反而不方便。

怎么做:渲染前先 selectedBox.innerHTML = '' 清空,再逐项创建:

js
function createSelectedTag() {
  selectedBox.innerHTML = '';
  selectedArr.forEach(ddEle => {
    const selectedEle = document.createElement('div');
    selectedEle.className = 'selected-tag';
    selectedEle.innerHTML = ddEle.innerHTML + '<span class="close"></span>';
    selectedBox.appendChild(selectedEle);
  });
}

易错点:不清空就会越点越多(讲师演示时 256G 出现了两次);forEach 遍历出来的元素是 dd,文字要从 dd 的 innerHTML 取。最后补一个默认选中:创建 dd 时判断 ddIndex === 0 就 classList.add('active')——注意这只加了类名,选中标签还没同步,是留给下一步的事。

先明确目标:动态创建参数选项元素 ​

为什么需要:商品参数选项不能写死在页面里,数据来自 goodsDetail,有几组就应生成几个 dl,每组里再生成一个 dt 和若干 dd。
怎么想:先遍历 goodsDetail 创建 dl,把 dl 放进 optionsBox;再在每组里创建 dt,内容取 dlItem.title;最后遍历 dlItem.data 创建 dd,内容取 ddItem.type。
怎么做:

js
goodsDetail.forEach((dlItem, dlIndex) => {
  const dlEl = document.createElement('dl');
  optionsBox.appendChild(dlEl);

  const dtEl = document.createElement('dt');
  dtEl.innerHTML = dlItem.title;
  dlEl.appendChild(dtEl);

  dlItem.data.forEach((ddItem, ddIndex) => {
    const ddEl = document.createElement('dd');
    ddEl.innerHTML = ddItem.type;
    dlEl.appendChild(ddEl);
  });
});

易错点:转写里“B包”应理解为闭包,“ATM collection”应理解为 HTMLCollection。先 appendChild(dlEl) 再加 dt、dd 也可以,因为 dlEl 变量始终指向同一个 dl;但不要把 dlEl 和 dtEl 混用。HTMLCollection 没有 forEach,遍历它要用普通 for 循环。

点击选中:用事件委托监听 DD ​

为什么需要:dd 是动态创建的,逐个绑定事件既麻烦又容易形成闭包;委托给父容器 optionsBox 更稳。
怎么想:点击 optionsBox 后,先判断 event.target 是不是 dd,只有点中选项才继续。
怎么做:

js
optionsBox.addEventListener('click', e => {
  if (e.target.tagName !== 'DD') return;

  const siblings = e.target.parentElement.children;
  for (let i = 1; i < siblings.length; i++) {
    siblings[i].classList.remove('active');
  }
  e.target.classList.add('active');
});

易错点:tagName 得到的是大写 DD;parentElement.children 包含 dt,所以循环从索引 1 开始;先移除同组其他 dd 的 active,再给当前 dd 加 active。调试时点选项之间的缝隙或 dt,页面不应有反应,说明目标判断生效。

用数组保存被选中选项:解决顺序和同组互斥 ​

为什么需要:直接点一次就创建一个选中标签,会重复、乱序;而选中标签要求同组互斥、不同组按顺序。
怎么想:固定 4 个位置,索引对应组:颜色 0、内存 1、版本 2、购买方式 3。创建 dd 时用自定义属性记录“所在组索引”,点击时把当前 dd 放进 selectedArr[groupIndex]。
怎么做:

js
const selectedArr = new Array(goodsDetail.length);

// 创建 dd 时
ddEl.dataset.groupIndex = dlIndex;

// 点击 dd 后
const groupIndex = e.target.dataset.groupIndex;
selectedArr[groupIndex] = e.target;

易错点:这里要的是 dl 的索引,不是 dd 在组内的索引;同组三个 dd 的 groupIndex 应相同。selectedArr 可能是稀疏数组,但 forEach 会自动跳过空位,正好可用。调试时输出 selectedArr:先点黑色只有一项,再点银色仍在同一位置替换,再点内存则出现在下一个位置。

根据数组创建选中标签并处理默认选中 ​

为什么需要:数组只保存了被选中的 dd,页面还要把选中标签渲染出来。
怎么想:每次渲染前先清空 selectedBox,再遍历 selectedArr,为每个 dd 创建 selected-tag,文字取 ddEl.innerHTML,并拼上关闭按钮。
怎么做:

js
function createSelectedTag() {
  selectedBox.innerHTML = '';
  selectedArr.forEach(ddEl => {
    if (!ddEl) return;
    const tagEl = document.createElement('div');
    tagEl.className = 'selected-tag';
    tagEl.innerHTML = ddEl.innerHTML + '<span class="close">×</span>';
    selectedBox.appendChild(tagEl);
  });
}

点击 dd 并更新 selectedArr 后调用 createSelectedTag()。默认选中可在创建 dd 时判断 ddIndex === 0,给第一个 dd 加 active;同时要把该 dd 放入 selectedArr[dlIndex] 并渲染,否则页面上只有选中样式,没有选中标签。
易错点:不先清空 selectedBox 会不断追加,出现重复标签;转写里的“风一尺”即 forEach,“稀数数组”即稀疏数组。渲染函数只负责根据数组显示,不要在这里再次乱改选中状态。

为什么不能“点一下就直接追加标签” ​

先看直接实现的问题:点击 DD 后创建选中标签,并 selectedBox.appendChild(tag)。第一次点“银色”能出现标签;再点“黑色”会继续追加;再点同组“金色”也不会替换“银色”。结果是标签越来越多、顺序也无法保证。
所以本节的核心不是“创建标签”,而是先解决选中状态如何被有序保存。转写里的思路是:创建一个固定长度数组,数组索引对应参数组顺序,数组元素保存被选中的 DD。这样同组点击会覆盖同一索引,不同组会按索引顺序排列。

还原选项结构:用 goodsDetail 创建 DL、DT、DD ​

先根据 goodsDetail 遍历创建每组 DL,每组里创建 DT 标题,再遍历该组 data 创建多个 DD。创建 DD 时同时写入组索引,供点击时使用。

js
goodsDetail.forEach((dlItem, dlIndex) => {
  const dlEle = document.createElement('dl')
  optionsBox.appendChild(dlEle)

  const dtEle = document.createElement('dt')
  dtEle.innerHTML = dlItem.title
  dlEle.appendChild(dtEle)

  dlItem.data.forEach((ddItem, ddIndex) => {
    const ddEle = document.createElement('dd')
    ddEle.innerHTML = ddItem.type
    ddEle.dataset.groupIndex = dlIndex
    if (ddIndex === 0) ddEle.classList.add('active')
    dlEle.appendChild(ddEle)
  })
})

易错点:DL 先加入 optionsBox 后,仍然可以通过变量继续给 DL 添加子元素;变量始终引用同一个 DL。另外,tagName 得到的是大写 DD,判断时不要写成小写。

点击选中与同组排他:事件委托到 optionsBox ​

DD 是动态创建的,直接获取集合再绑定不方便,因此用事件委托:监听 optionsBox 的点击,判断 e.target.tagName === 'DD'。同组排他时取 dd.parentElement.children,从索引 1 开始跳过 DT,先移除同组所有 active,再给当前 DD 加 active。

js
optionsBox.addEventListener('click', e => {
  if (e.target.tagName !== 'DD') return
  const dd = e.target
  const groupIndex = dd.dataset.groupIndex

  const children = dd.parentElement.children
  for (let i = 1; i < children.length; i++) {
    children[i].classList.remove('active')
  }
  dd.classList.add('active')

  selectedArr[groupIndex] = dd
  createSelectedTag()
})

易错点:children 是 HTMLCollection,没有 forEach,这里用 for 循环;不要从 0 开始,因为第 0 个是 DT。groupIndex 来自 data-group-index,同一组 DD 的组索引相同,所以同组会覆盖同一个数组位置。

用固定长度数组保存选中的 DD ​

数组长度就是参数组数量,可直接用 new Array(goodsDetail.length)。点击某组 DD 时执行 selectedArr[groupIndex] = dd。这不是把 DOM 元素“移动”到数组里,只是多了一个引用;原来页面中的 DD 仍然存在。
易错点:不要用 push,否则同组会不断追加;也不要误以为放进数组会改变 DOM 结构。数组可能是稀疏的,未点击的组对应位置为空。

根据数组重建选中标签,并先清空 ​

每次点击后都根据 selectedArr 重新生成选中标签。关键顺序是:先清空 selectedBox.innerHTML = '',再遍历数组创建标签。forEach 遍历稀疏数组时会跳过空位,比手写 for 更省判断。

js
function createSelectedTag() {
  selectedBox.innerHTML = ''
  selectedArr.forEach(dd => {
    if (!dd) return
    const tag = document.createElement('div')
    tag.className = 'selected-tag'
    tag.innerHTML = dd.innerHTML + '<span class="close">×</span>'
    selectedBox.appendChild(tag)
  })
}

易错点:如果每次只 appendChild 不清空,标签会重复累积;清空后重建,才能让界面与数组实时对应。默认第一个 DD 可在创建时判断 ddIndex === 0 加 active,转写本节先做到默认选中样式;选中标签的默认同步未在本节展开。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

代码示例 ​

示例一:创建选项元素(dl / dt / dd) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>商品参数选项 - 创建选项元素</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  .options-box { width: 620px; margin: 30px auto; font-size: 14px; }
  .options-box dl { display: flex; align-items: center; margin-bottom: 10px; }
  .options-box dt { width: 80px; color: #999; }
  .options-box dd {
    padding: 4px 10px; margin-right: 10px; cursor: pointer;
    border: 1px solid #ccc; border-radius: 3px;
  }
</style>
</head>
<body>
  <div class="options-box" id="optionsBox"></div>

  <script>
    // 演示数据:外层每组一个对象(title + data),data 里每个成员还有 type
    var goodsDetail = [
      { title: '颜色', data: [{ type: '金色' }, { type: '银色' }, { type: '黑色' }] },
      { title: '内存', data: [{ type: '16G' }, { type: '64G' }, { type: '128G' }, { type: '256G' }] },
      { title: '版本', data: [{ type: '移动版' }, { type: '电信优惠版' }, { type: '公开版' }] },
      { title: '购买方式', data: [{ type: '官方标配' }, { type: '套餐一' }, { type: '套餐二' }] }
    ];

    var optionsBox = document.getElementById('optionsBox');

    // 一组数据 对应 一个 dl
    goodsDetail.forEach(function (dlItem, dlIndex) {
      var dlEle = document.createElement('dl');
      dlEle.dataset.index = dlIndex;          // 记录所在组的索引,后面点击时要用
      optionsBox.appendChild(dlEle);          // 先挂到容器上(先挂后加子元素也可以)

      // 组标题 dt
      var dtEle = document.createElement('dt');
      dtEle.innerHTML = dlItem.title;         // 注意是 dlItem.title
      dlEle.appendChild(dtEle);

      // 组内选项 dd —— 要再遍历一层 data
      dlItem.data.forEach(function (ddItem, ddIndex) {
        var ddEle = document.createElement('dd');
        ddEle.innerHTML = ddItem.type;        // 注意是 ddItem.type
        ddEle.dataset.groupIndex = dlIndex;   // 把组索引挂到 dd 上,点击时直接用
        dlEle.appendChild(ddEle);
      });
    });
  </script>
</body>
</html>

关键行与易错点

  • 数据结构是“两层”:goodsDetail 每一项是一个组(有 title 和 data),data 的成员才带 type,所以必须遍历两层才能建出多个 dd。
  • dlEle 这个变量始终指向同一个 dl 元素,所以先 appendChild 到 optionsBox 再往里加 dt/dd 完全没问题;变量指向的是元素本身,跟元素有没有被挂到文档里无关。
  • 别写混:dt 取 dlItem.title,dd 取 ddItem.type。
  • 本节先埋两个伏笔:dl 上的 dataset.index、dd 上的 dataset.groupIndex,是给后面“点击时拿组索引”用的,因为点击事件里根本拿不到 forEach 的 index。

示例二:点击选项选中 + 同组排他(事件委托) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>商品参数选项 - 点击选中</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  .options-box { width: 620px; margin: 30px auto; font-size: 14px; }
  .options-box dl { display: flex; align-items: center; margin-bottom: 10px; }
  .options-box dt { width: 80px; color: #999; }
  .options-box dd {
    padding: 4px 10px; margin-right: 10px; cursor: pointer;
    border: 1px solid #ccc; border-radius: 3px;
  }
  .options-box dd.active { border-color: #e4393c; color: #e4393c; }
</style>
</head>
<body>
  <div class="options-box" id="optionsBox"></div>

  <script>
    var goodsDetail = [
      { title: '颜色', data: [{ type: '金色' }, { type: '银色' }, { type: '黑色' }] },
      { title: '内存', data: [{ type: '16G' }, { type: '64G' }, { type: '128G' }, { type: '256G' }] },
      { title: '版本', data: [{ type: '移动版' }, { type: '电信优惠版' }, { type: '公开版' }] },
      { title: '购买方式', data: [{ type: '官方标配' }, { type: '套餐一' }, { type: '套餐二' }] }
    ];

    var optionsBox = document.getElementById('optionsBox');

    goodsDetail.forEach(function (dlItem, dlIndex) {
      var dlEle = document.createElement('dl');
      dlEle.dataset.index = dlIndex;
      optionsBox.appendChild(dlEle);

      var dtEle = document.createElement('dt');
      dtEle.innerHTML = dlItem.title;
      dlEle.appendChild(dtEle);

      dlItem.data.forEach(function (ddItem) {
        var ddEle = document.createElement('dd');
        ddEle.innerHTML = ddItem.type;
        ddEle.dataset.groupIndex = dlIndex;
        dlEle.appendChild(ddEle);
      });
    });

    // 元素是新创建的,取不到集合再逐个绑事件 —— 直接委托给外层容器
    optionsBox.addEventListener('click', function (e) {
      // nodeName 是大写 DD
      if (e.target.nodeName !== 'DD') return;

      // 同组排他:父元素的所有子元素(含 dt,是 HTMLCollection)
      var siblings = e.target.parentElement.children;
      for (var i = 1; i < siblings.length; i++) {   // 从 1 开始,跳过第 0 个 dt
        siblings[i].classList.remove('active');
      }

      // 当前点击的 dd 选中
      e.target.classList.add('active');
    });
  </script>
</body>
</html>

关键行与易错点

  • 选项元素是 JS 动态创建的,querySelectorAll 拿不到“未来”的元素,所以用事件委托绑在 optionsBox 上,既省循环也避免大量闭包。
  • 判断目标是 dd 还是容器/dt:e.target.nodeName !== 'DD'。nodeName 返回大写,写成 'dd' 永远不成立。
  • 排他范围是“同组”:e.target.parentElement.children 拿到的是 dl 的所有子元素,第 0 个是 dt,循环必须从 i = 1 开始,否则会把 dt 也当选项处理。
  • children 是 HTMLCollection,没有 forEach,只能用 for 循环;要清掉别人的 active 用 classList.remove,最后再给当前项 add。

示例三:用数组存储被选中的选项(固定长度 + 索引即组序) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>商品参数选项 - 数组存储选中项</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  .options-box { width: 620px; margin: 30px auto 10px; font-size: 14px; }
  .options-box dl { display: flex; align-items: center; margin-bottom: 10px; }
  .options-box dt { width: 80px; color: #999; }
  .options-box dd {
    padding: 4px 10px; margin-right: 10px; cursor: pointer;
    border: 1px solid #ccc; border-radius: 3px;
  }
  .options-box dd.active { border-color: #e4393c; color: #e4393c; }

  .selected-box { width: 620px; margin: 0 auto; font-size: 14px; display: flex; flex-wrap: wrap; }
  .selected-tag {
    padding: 2px 8px; margin-right: 8px; border-radius: 3px;
    border: 1px solid #e4393c; color: #e4393c;
  }
  .selected-tag .close { margin-left: 6px; cursor: pointer; }
</style>
</head>
<body>
  <div class="options-box" id="optionsBox"></div>
  <div class="selected-box" id="selectedBox"></div>

  <script>
    var goodsDetail = [
      { title: '颜色', data: [{ type: '金色' }, { type: '银色' }, { type: '黑色' }] },
      { title: '内存', data: [{ type: '16G' }, { type: '64G' }, { type: '128G' }, { type: '256G' }] },
      { title: '版本', data: [{ type: '移动版' }, { type: '电信优惠版' }, { type: '公开版' }] },
      { title: '购买方式', data: [{ type: '官方标配' }, { type: '套餐一' }, { type: '套餐二' }] }
    ];

    var optionsBox = document.getElementById('optionsBox');
    var selectedBox = document.getElementById('selectedBox');

    // 1. 创建选项元素
    goodsDetail.forEach(function (dlItem, dlIndex) {
      var dlEle = document.createElement('dl');
      dlEle.dataset.index = dlIndex;
      optionsBox.appendChild(dlEle);

      var dtEle = document.createElement('dt');
      dtEle.innerHTML = dlItem.title;
      dlEle.appendChild(dtEle);

      dlItem.data.forEach(function (ddItem, ddIndex) {
        var ddEle = document.createElement('dd');
        ddEle.innerHTML = ddItem.type;
        ddEle.dataset.groupIndex = dlIndex;          // 组索引挂到 dd 上
        if (ddIndex === 0) ddEle.classList.add('active');  // 默认选中每组第一个
        dlEle.appendChild(ddEle);
      });
    });

    // 2. 固定长度数组:索引 = 组索引,长度 = 组数
    var selectedArr = new Array(goodsDetail.length);

    // 3. 根据数组创建选中标签(渲染前先清空)
    function createSelectedTag() {
      selectedBox.innerHTML = '';                    // 不清空会一直追加,越点越多
      selectedArr.forEach(function (ddEle) {         // 稀疏数组,forEach 会自动跳过空位
        var tagEle = document.createElement('div');
        tagEle.className = 'selected-tag';
        tagEle.innerHTML = ddEle.innerHTML + '<span class="close">×</span>';
        selectedBox.appendChild(tagEle);
      });
    }

    // 4. 点击:选中 + 排他 + 存数组 + 重新渲染
    optionsBox.addEventListener('click', function (e) {
      if (e.target.nodeName !== 'DD') return;

      var siblings = e.target.parentElement.children;
      for (var i = 1; i < siblings.length; i++) {
        siblings[i].classList.remove('active');
      }
      e.target.classList.add('active');

      // 用组索引作为下标:同组的 dd 互相覆盖 => 同组互斥 + 永远按组顺序
      selectedArr[e.target.dataset.groupIndex] = e.target;

      createSelectedTag();
    });

    // 5. 初始化:把默认选中的第一个 dd 也放进数组并渲染
    optionsBox.querySelectorAll('dl').forEach(function (dl) {
      selectedArr[dl.dataset.index] = dl.querySelector('dd');
    });
    createSelectedTag();
  </script>
</body>
</html>

关键行与易错点

  • 数组用 new Array(goodsDetail.length) 定长,下标就是组索引:颜色组永远落在 [0]、内存组落在 [1]……所以同组选项互相覆盖(互斥),不同组各占一格(顺序固定),先点后面的组也不会乱序。
  • 点击时拿不到 forEach 的 index,索引必须在创建 dd 时用 ddEle.dataset.groupIndex = dlIndex 存下来,点击时再 e.target.dataset.groupIndex 取(data-group-index 会自动转小驼峰)。
  • selectedArr[i] = ddEle 只是给元素多了一个引用,不会把元素从 DOM 里挪走(和 appendChild 移动元素是两回事)。
  • selectedArr 是稀疏数组(跳着填的),只能用 forEach 遍历,它会自动跳过空位;用普通 for 会拿到 undefined 而报错。
  • createSelectedTag 开头必须 selectedBox.innerHTML = '' 先清空,因为每次都是按数组“整体重画”,不清空就会重复追加。
  • 排他循环仍要从 i = 1 开始跳过 dt;children 是 HTMLCollection,不能用 forEach。

常见错误 ​

  1. 现象:点空白、DT 或分隔区域也触发选中逻辑。→ 原因:事件委托后未判断实际点击目标。→ 修法:在委托回调中先判断 e.target.tagName 是否为 DD,不是则直接返回。
  2. 现象:同组排他时把 DT 也处理了,或 active 清理不干净。→ 原因:直接遍历 DL 的全部子元素,未区分 DT/DD。→ 修法:从 DD 起始位置遍历,或筛选出 DD 后再清理同组 active,最后给当前 e.target 加 active。
  3. 现象:连续点击后选中标签重复、顺序错乱、同组不互斥。→ 原因:每次点击直接向容器追加标签,没有统一状态源。→ 修法:用固定长度数组按组索引保存被选中的 DD,渲染前先清空标签容器,再遍历数组重建。
  4. 现象:读取组索引得到 undefined。→ 原因:索引属性加在 DL 上,却在 DD 上直接读 dataset。→ 修法:统一属性挂载位置;若加在 DL 上则读 e.target.parentElement.dataset.index,若加在 DD 上则直接读 e.target.dataset.groupIndex。
  5. 现象:用 forEach 遍历 DOM 集合报错,或用普通 for 遍历稀疏数组出现空位异常。→ 原因:HTMLCollection 没有 forEach;稀疏数组索引不连续。→ 修法:DOM 集合转数组或用普通循环;稀疏数组用 forEach,空位会自动跳过。
  6. 现象:默认选中第一个 DD 后,只有 active 样式,没有对应选中标签。→ 原因:只改了视图状态,没有同步数组和渲染函数。→ 修法:初始化默认选中时同时写入 selectedArr,再调用统一渲染函数生成标签。

练习 ​

  1. 事件委托为什么绑定到 optionsBox 而不是新创建的 DD 或 DL?点击 DT 时如何保证不进入后续逻辑?
  2. selectedArr 的长度为什么取组数?点击同组不同 DD 时,数组对应索引会发生什么变化?
  3. 将组索引从 DL 的 data-index 改为每个 DD 的 data-group-index,需要修改哪些读写代码?
  4. 默认选中每组第一个 DD 时,如何保证 active 样式、selectedArr、选中标签三者一致?
  5. 如果要求再次点击已选中的 DD 可以取消选中,数组和渲染逻辑应如何调整?

原视频对照 ​

#视频时长
1商品参数选项-创建选项元素08:10
2商品参数选项-点击选项元素选中10:55
3商品参数选项-使用数组存储被选中的选项22:12
4商品参数选项-根据数组创建选中标签元素08:55

小结 ​

  1. 选中状态应集中保存在数组中,DOM 只作为状态的渲染结果。
  2. 固定组索引决定选中标签的顺序,同组覆盖实现互斥。
  3. 事件委托要过滤 e.target,只处理真正的 DD。
  4. 同组排他应先清理同组 active,再设置当前项。
  5. 选中标签每次应按数组全量重建,重建前先清空容器。
  6. 组索引属性挂载位置必须与读取位置一致。
  7. 默认选中必须同步状态、样式和标签渲染。
  8. 数组保存的是 DOM 引用,不是把元素从原位置移动。