Skip to content

Latest commit

 

History

History
446 lines (380 loc) · 18.1 KB

12.DOM操作.md

File metadata and controls

446 lines (380 loc) · 18.1 KB

12.DOM 操作

  • 向 DOM 中插入 HTML
  • 理解 DOM 的特性和属性
  • 获取计算样式
  • 处理频繁布局操作

向 DOM 中注入 HTML

如果用 jQuery,代码 可以这样实现:

$(document.body).append('<div><h1>Greetings</h1><p>Yoshi here</p></div>');

原生 DOM API 进行对比:

const h1 = document.createElement('h1');
h1.textContent = 'Greetings';
const p = document.createElement('p');
p.textContent = 'Yoshi here';
const div = document.createElement('div');
div.appendChild(h1);
div.appendChild(p);
document.body.appendChild(div);

我们要从头实现一套简洁的 DOM 操作方式。具体步骤如下。

  • 将任意有效的 HTML 字符串转换为 DOM 结构。
  • 尽可能高效地将 DOM 结构注入到任意位置。

将 HTML 字符串转换成 DOM

innerHTML 属性。转换的步骤如下所示:

  1. 确保 HTML 字符串是合法有效的。
  2. 将它包裹在任意符合浏览器规则要求的闭合标签内。
  3. 使用 innerHTML 将这串 HTML 插入到一个需求 DOM 中。
  4. 提取该 DOM 节点。

这一系列步骤看上去并不复杂,不过在实际插入的时候还是存在着一些陷阱。

预处理 HTML 源字符串

<option>Yoshi</option>
<option>Kuma</option>
<table />

确保自闭合元素被正确解释

const tags = /^(area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$/i;
function convert(html) {
  return html.replace(/(<(\w+)[^>]*?)\/>/g, (all, front, tag) => {
    return tags.test(tag) ? all : front + '></' + tag + '>';
  });
}
assert(convert('<a/>') === '<a></a>', 'Check anchor conversion.');
assert(convert('<hr/>') === '<hr/>', 'Check hr conversion.');

当我们将 convert 函数应用于此示例的 HTML 字符串时,我们最终得到以下结果 (<table />展开了):

<option>Yoshi</option>
<option>Kuma</option>
<table></table>

执行完上面的转换后,我们还需要解决选项元素没有包含在 select 元素中的问题。让我们看看如何确定一个元素是否需要包装。

包装 HTML

<option>元素必须包含在<select>中。我们可以通过两种方式解决这个问题,这两种方式都需要构建问题元素和它们的容器之间的映射:

  • 通过 innerHTML 将该字符串直接注入到它的特定父元素中,该父元素提前使用内置的 document.createElement 创建好。尽管大多数情况下的大部分的浏览器都支持这种方式,但仍然不能保证完全通用。
  • HTML 字符串可以在使用对应父元素包装后,直接注入到任意容器元素中(比方<div>),这样更保险,但相对麻烦。

这里更推荐第二种方法,相比第一种,它只需很少的浏览器兼容代码。

需要包含在其他元素中的元素

元素名称 父级元素
<option>,<optgroup> <select multiple>...</select>
<legend> <select multiple>...</select>
<thead>,<tbody>,<tfoot>,<colgroup>,<caption> <table>...</table>
<tr> <table><thead>...</thead></table>,<table><tbody>...</tbody></table>,<table><tfoot>...</tfoot></table>
<td>,<th> <table><tbody><tr>...</tr></tbody></table>
<col> <table><tbody></tbody><colgroup>...</colgroup></table>

这里大部分元素的包装都很直接,除了以下几点。

  • 使用具有multiple属性的<select>元素(而不是单选),因为它不会自动检查任何包含在其中的选项(而单选则会自动检查第一个选项)。
  • 对 clo 的兼容处理需要一个额外的<tbody>,否则<clogroup>不能正确生成。

将元素标签转为一系列DOM节点

function getNodes(htmlString, doc) {
    const map = { // 需要特殊父级容器的元素映射表。每个条目都包含新节点的深度,以及父元素的HTML头尾片断
      "<td":[3,"<table><tbody><tr>","</tr></tbody></table>"],
      "<th":[3,"<table><tbody><tr>","</tr></tbody></table>"],
      "<tr":[2,"<table><thead>","</thead></table>"],
      "<option":[1,"<select multiple>","</select>"],
      "<optgroup":[1,"<select multiple>","</select>"],
      "<legend":[1,"<fieldset>","</fieldset>"],
      "<thead":[1,"<table>","</table>"],
      "<tbody":[1,"<table>","</table>"],
      "<tfoot":[1,"<table>","</table>"],
      "<colgroup":[1,"<table>","</table>"],
      "<caption":[1,"<table>","</table>"],
      "<col":[2,"<table><tbody></tbody><colgroup>","</colgroup></table>"],
  };
  const tagName = htmlString.match(/<\w+/);
  let mapEntry = tagName ? map[tagName[0]] : null;
  if (!mapEntry) { mapEntry = [0, " "," " ];}
  let div = (doc || document).createElement("div");
  div.innerHTML = mapEntry[1] + htmlString + mapEntry[2];
  while (mapEntry[0]--) { div = div.lastChild;}
  return div.childNodes;
}
assert(getNodes("<td>test</td><td>test2</td>").length === 2, "Get two nodes back from the method.");
assert(getNodes("<td>test</td>")[0].nodeName === "TD", "Verify that we're getting the right node.");

将 DOM 元素插入到文档中

使用DOM片段扩展getNodes函数

function getNodes(htmlString, doc, fragment){ 
  const map = {
    "<td":[3,"<table><tbody><tr>","</tr></tbody></table>"],
    "<th":[3,"<table><tbody><tr>","</tr></tbody></table>"],
    "<tr":[2,"<table><thead>","</thead></table>"],
    "<option":[1,"<select multiple>","</select>"],
    "<optgroup":[1,"<select multiple>","</select>"],
    "<legend":[1,"<fieldset>","</fieldset>"],
    "<thead":[1,"<table>","</table>"],
    "<tbody":[1,"<table>","</table>"],
    "<tfoot":[1,"<table>","</table>"],
    "<colgroup":[1,"<table>","</table>"],
    "<caption":[1,"<table>","</table>"],
    "<col":[2,"<table><tbody></tbody><colgroup>","</colgroup></table>"],
  };
  const tagName = htmlString.match(/<\w+/);
  let mapEntry = tagName ? map[tagName[0]] : null;
  if (!mapEntry) { mapEntry = [0, " "," " ];}
  let div = (doc || document).createElement("div"); 
  div.innerHTML = mapEntry[1] + htmlString + mapEntry[2]; 
  while (mapEntry[0]--) { div = div.lastChild;}
  if (fragment) {
      while (div.firstChild) {
        fragment.appendChild(div.firstChild);
      }
  }
  return div.childNodes;
}

在DOM的多个位置插入DOM片段

<div id="test"><b>Hello</b>, I'm a ninja!</div>
<div id="test2"></div>
<script>
  document.addEventListener("DOMContentLoaded", () => {
    function insert(elems, args, callback) {
      if (elems.length) {
        const doc = elems[0].ownerDocument || elems[0],
              fragment = doc.createDocumentFragment(),
              scripts = getNodes(args, doc, fragment),
              first = fragment.firstChild;
        if (first) {
          for (let i = 0; elems[i]; i++) {
            callback.call(root(elems[i], first),
            i > 0 ? fragment.cloneNode(true) : fragment);
          }
        }
      }
    }
    const divs = document.querySelectorAll("div");
    insert(divs, "<b>Name:</b>", function (fragment) {
      this.appendChild(fragment);
    });
    insert(divs, "<span>First</span> <span>Last</span>", function (fragment) {
      this.parentNode.insertBefore(fragment, this);
    });
  });
</script>

DOM 的特性和属性

通过DOM方法和属性访问特性值

<div></div>
<script>
  document.addEventListener("DOMContentLoaded", () => {
    const div = document.querySelector("div");
    div.setAttribute("id","ninja-1");
    assert(div.getAttribute('id') === "ninja-1", "Attribute successfully changed");
    div.id = "ninja-2";
    assert(div.id === "ninja-2", "Property successfully changed");
    assert(div.getAttribute('id') === "ninja-2", "Attribute successfully changed via property");
    div.setAttribute("id","ninja-3");
    assert(div.id === "ninja-3", "Property successfully changed via attribute");
    assert(div.getAttribute('id') === "ninja-3", "Attribute successfully changed");
  });
</script>

令人头疼的样式特性

样式在何处

检测Style属性

<style>
  div { font-size: 1.8em; border: 0 solid gold; }
</style>
<div style="color:#000;" title="Ninja power!">忍者パワー </div>
<script>
  document.addEventListener("DOMContentLoaded", () => {
    const div = document.querySelector("div");
    assert(div.style.color === 'rgb(0, 0, 0)' || div.style.color === '#000','color was recorded');
    assert(div.style.fontSize === '1.8em', 'fontSize was recorded');
    assert(div.style.borderWidth === '0', 'borderWidth was recorded');
    div.style.borderWidth = "4px";
    assert(div.style.borderWidth === '4px', 'borderWidth was replaced');
  });
</script>

样式属性命名

<div style="color:red;font-size:10px;background-color:#eee;"></div>
<script>
  function style(element,name,value) {
    name = name.replace(/-([a-z])/ig, (all,letter) => {
      return letter.toUpperCase();
    });
    if (typeof value !== 'undefined') {
      element.style[name] = value;
    }
    return element.style[name];
  }
  document.addEventListener("DOMContentLoaded", () => {
    const div = document.querySelector("div");
    assert(style(div,'color') === "red", style(div,'color'));
    assert(style(div,'font-size') === "10px", style(div,'font-size'));
    assert(style(div,'background-color') === "rgb(238, 238, 238)",style(div,'background-color'));
  });
</script>

该样式方法有以下两个特点:

  • 它使用正则表达式将名称参数转换为驼峰表示。
  • 使用 settergetter,通过检查参数列表可以实现不同的功能。例如,我们可以通过 style(div, 'font-size') 获取 font-size 属性的值,我们可以使用 style(div, 'font-size', '5px') 设置一个新值。
function style(element,name,value){
  ...
  if (typeof value !== 'undefined') {
    element.style[name] = value;
  }
  return element.style[name];
}

获取计算后样式

<style> 
  div {
    background-color: #ffc; display: inline; font-size: 1.8em;
    border: 1px solid crimson; color: green;
  }
</style>
<div style="color:crimson;" id="testSubject" title="Ninja power!">忍者ハワ゚ー</div>
<script>
  function fetchComputedStyle(element,property) {
    const computedStyles = getComputedStyle(element);
    if (computedStyles) {
      property = property.replace(/([A-Z])/g,'-$1').toLowerCase();
      return computedStyles.getPropertyValue(property);
    }
  }
  document.addEventListener("DOMContentLoaded", () => {
    const div = document.querySelector("div");
    report("background-color: " + fetchComputedStyle(div,'background-color'));
    report("display: " + fetchComputedStyle(div,'display'));
    report("font-size: " + fetchComputedStyle(div,'fontSize'));
    report("color: " + fetchComputedStyle(div,'color'));
    report("border-top-color: " + fetchComputedStyle(div,'borderTopColor'));
    report("border-top-width: " + fetchComputedStyle(div,'border-top-width'));
  });
</script>

转换像素值

测量元素的高度和宽度

获取隐藏元素的宽高:

  1. display 属性设置为 block
  2. visibility 设置为 hidden
  3. position 设置为 absolute
  4. 获取元素尺寸。
  5. 恢复先前更改的属性。

获取隐藏元素的尺寸

<div>
  Lorem ipsum dolor sit amet, consectetur adipiscing elit.
  Suspendisse congue facilisis dignissim. Fusce sodales,
  odio commodo accumsan commodo, lacus odio aliquet purus,
  <img src="../images/ninja-with-pole.png" id="withPole" alt="ninja pole"/>
  <img src="../images/ninja-with-shuriken.png" id="withShuriken" style="display:none" alt="ninja shuriken" />
  vel rhoncus elit sem quis libero. Cum sociis natoque
  penatibus et magnis dis parturient montes, nascetur
  ridiculus mus. In hac habitasse platea dictumst. Donec
  adipiscing urna ut nibh vestibulum vitae mattis leo
  rutrum. Etiam a lectus ut nunc mattis laoreet at
  placerat nulla. Aenean tincidunt lorem eu dolor commodo
  ornare.
</div>
<script>
  (function(){
    const PROPERTIES = {
      position: "absolute",
      visibility: "hidden",
      display: "block"
    };
    window.getDimensions = element => {
      const previous = {};
      for (let key in PROPERTIES) {
        previous[key] = element.style[key];
        element.style[key] = PROPERTIES[key];
      }
      const result = {
        width: element.offsetWidth,
        height: element.offsetHeight
      };
      for (let in PROPERTIES) {
        element.style[key] = previous[key];
      }
      return result;
    };
  })();
  document.addEventListener("DOMContentLoaded", () => {
    setTimeout(() => {
      const withPole = document.getElementById('withPole'),
      withShuriken = document.getElementById('withShuriken');
      assert(withPole.offsetWidth === 41, "Pole image width fetched; actual: " + withPole.offsetWidth + ", expected: 41");
      assert(withPole.offsetHeight === 48, "Pole image height fetched: actual: " + withPole.offsetHeight + ", expected 48");
      assert(withShuriken.offsetWidth === 36, "Shuriken image width fetched; actual: " + withShuriken.offsetWidth + ", expected: 36");
      assert(withShuriken.offsetHeight === 48, "Shuriken image height fetched: actual: " + withShuriken.offsetHeight + ", expected 48");
      const dimensions = getDimensions(withShuriken);
      assert(dimensions.width === 36, "Shuriken image width fetched; actual: " + dimensions.width + ", expected: 36");
      assert(dimensions.height === 48, "Shuriken image height fetched: actual: " + dimensions.height + ", expected 48");
    },3000);
  });
</script>

避免布局抖动

连续一系列的读取和写入导致布局抖动

<div id="ninja">I’m a ninja</div>
<div id="samurai">I’m a samurai</div>
<div id="ronin">I’m a ronin</div>
<script>
  const ninja = document.getElementById("ninja");
  const samurai = document.getElementById("samurai");
  const ronin = document.getElementById("ronin");
  const ninjaWidth = ninja.clientWidth;
  ninja.style.width = ninjaWidth/2 + "px";
  const samuraiWidth = samurai.clientWidth;
  samurai.style.width = samuraiWidth/2 + "px";
  const roninWidth = ronin.clientWidth;
  ronin.style.width = roninWidth/2 + "px";
</script>

批量DOM读取和写入以避免布局抖动

<div id="ninja">I’m a ninja</div>
<div id="samurai">I’m a samurai</div>
<div id="ronin">I’m a ronin</div>
<script>
  const ninja = document.getElementById("ninja");
  const samurai = document.getElementById("samurai");
  const ronin = document.getElementById("ronin");
  const ninjaWidth = ninja.clientWidth;
  const samuraiWidth = samurai.clientWidth;
  const roninWidth = ronin.clientWidth;
  ninja.style.width = ninjaWidth/2 + "px";
  samurai.style.width = samuraiWidth/2 + "px";
  ronin.style.width = roninWidth/2 + "px";
</script>

引起布局抖动的API和属性

接口对象 属性名
Element clientHeight, clientLeft, clientTop, clientWidth, focus, getBoundingClientRect,getClientRects, innerText, offsetHeight, offsetLeft, offsetParent, offsetTop, offsetWidth, outerText, scrollByLines, scrollByPages, scrollHeight, scrollIntoView, scrollIntoViewIfNeeded, scrollLeft, scrollTop, scrollWidth
MouseEvent layerX, layerY, offsetX, offsetY
Window getComputedStyle, scrollBy, scrollTo, scroll, scrollY
Frame, Document, Image height, width

已经有许多第三方库会尽量减少布局抖动。其中最受欢迎的是 FastDom。 FastDom 的仓库里含有示例,可以清楚地看到, 通过分批 DOM 读/写操作来实现性能的提升。

React 的虚拟 DOM

React的虚拟DOM中最流行的客户端库是Facebook的 React。React 使用虚拟 DOM 和一组 JavaScript 对象,通过模拟实际 DOM 来实现极佳的性能。当我们在 React 中开发应用程序时,我们可以对虚拟 DOM 执行所有修改,而不考虑布局抖动。然后,在恰当的时候,React 会使用虚拟 DOM 来判断对实际 DOM 需要做什么改变,以保证 UI 同步。这种创新的批处理方式,进一步提高了应用程序的性能。

小结

  • 将 HTML 字符串转换为 DOM 元素包括以下步骤。
    • 确保 HTML 字符串是有效的 HTML 代码。
    • 将其包装成封闭的标记,符合浏览器规则要求。
    • 通过 DOM 元素的 innerHTML 属性将 HTML 插入虚拟 DOM 元素。
    • 将创建的 DOM 节点提取出来。
  • 为了快速插入 DOM 节点,请使用 DOM 片段,因为可以在单个操作中注入片段,从而大大减少了操作次数。
  • DOM 元素属性和特性,尽管挂钩,但并不总是相同!我们可以通过使用 getAttributesetAttribute 方法读取和写入 DOM 属性,同时也可以使用对象属性符号方式写入 DOM 属性。
  • 使用属性和特性时,也有必要了解自定义属性。我们在 DOM 元素上自定义的特性,仅用于自定义信息,不能与元素属性等同看待或使用。
  • 元素 style 属性是一个对象,它含有与元素标记中指定的样式值相对应的属性。要获得计算后样式,需要同时考虑样式表中设置的样式,请使用内置的 getComputedStyle 方法。
  • 要获取 HTML 元素的尺寸,请使用 offsetWidthoffsetHeight 属性。
  • 当代码对 DOM 进行一系列连续的读取和写入操作时,浏览器每次都会强制重新计算布局信息,这会引起布局抖动。这进而导致Web 应用程序运行和响应速度变慢。
  • 请批量更新 DOM!