- 向 DOM 中插入 HTML
- 理解 DOM 的特性和属性
- 获取计算样式
- 处理频繁布局操作
如果用 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 结构注入到任意位置。
innerHTML
属性。转换的步骤如下所示:
- 确保 HTML 字符串是合法有效的。
- 将它包裹在任意符合浏览器规则要求的闭合标签内。
- 使用
innerHTML
将这串 HTML 插入到一个需求 DOM 中。 - 提取该 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片段扩展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方法和属性访问特性值
<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>
该样式方法有以下两个特点:
- 它使用正则表达式将名称参数转换为驼峰表示。
- 使用
setter
和getter
,通过检查参数列表可以实现不同的功能。例如,我们可以通过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>
获取隐藏元素的宽高:
- 将
display
属性设置为block
。 - 将
visibility
设置为hidden
。 - 将
position
设置为absolute
。 - 获取元素尺寸。
- 恢复先前更改的属性。
获取隐藏元素的尺寸
<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 元素属性和特性,尽管挂钩,但并不总是相同!我们可以通过使用
getAttribute
和setAttribute
方法读取和写入 DOM 属性,同时也可以使用对象属性符号方式写入 DOM 属性。 - 使用属性和特性时,也有必要了解自定义属性。我们在 DOM 元素上自定义的特性,仅用于自定义信息,不能与元素属性等同看待或使用。
- 元素 style 属性是一个对象,它含有与元素标记中指定的样式值相对应的属性。要获得计算后样式,需要同时考虑样式表中设置的样式,请使用内置的
getComputedStyle
方法。 - 要获取 HTML 元素的尺寸,请使用
offsetWidth
和offsetHeight
属性。 - 当代码对 DOM 进行一系列连续的读取和写入操作时,浏览器每次都会强制重新计算布局信息,这会引起布局抖动。这进而导致Web 应用程序运行和响应速度变慢。
- 请批量更新 DOM!