Knockout.js DOM 操作模块详解
[utils.domManipulation.js]是 Knockout.js 框架中负责处理 HTML 片段解析和 DOM 操作的核心模块。它提供了将 HTML 字符串转换为 DOM 节点的功能,以及设置元素 HTML 内容的工具函数。
核心概念
HTML 解析的挑战
在 Web 开发中,将 HTML 字符串转换为 DOM 节点并不是一个简单的过程,特别是对于一些特殊的 HTML 元素:
- 表格相关元素 -
<tr>
,<td>
,<th>
等元素不能直接通过innerHTML
设置 - 表单相关元素 -
<option>
,<optgroup>
等元素有特殊要求 - 注释节点 - 在某些浏览器中处理不当可能会丢失
- 跨浏览器兼容性 - 不同浏览器对 HTML 解析的行为可能不同
解决方案
Knockout.js 通过包装和扩展浏览器原生的 HTML 解析功能,提供了一套统一的 API 来处理这些复杂情况。
核心实现
HTML 包装映射
var none = [0, "", ""],table = [1, "<table>", "</table>"],tbody = [2, "<table><tbody>", "</tbody></table>"],tr = [3, "<table><tbody><tr>", "</tr></tbody></table>"],select = [1, "<select multiple='multiple'>", "</select>"],lookup = {'thead': table,'tbody': table,'tfoot': table,'tr': tbody,'td': tr,'th': tr,'option': select,'optgroup': select};
这个映射表定义了如何包装特殊 HTML 元素以确保它们能被正确解析。例如,<td>
元素需要包装在 <table><tbody><tr>
中才能正确解析。
HTML 解析函数
simpleHtmlParse
function simpleHtmlParse(html, documentContext) {documentContext || (documentContext = document);var windowContext = documentContext['parentWindow'] || documentContext['defaultView'] || window;// Trim whitespace, otherwise indexOf won't work as expectedvar tags = ko.utils.stringTrim(html).toLowerCase(), div = documentContext.createElement("div"),wrap = getWrap(tags),depth = wrap[0];// Go to html and back, then peel off extra wrappers// Note that we always prefix with some dummy text, because otherwise, IE<9 will strip out leading comment nodes in descendants. Total madness.var markup = "ignored<div>" + wrap[1] + html + wrap[2] + "</div>";if (typeof windowContext['innerShiv'] == "function") {// Note that innerShiv is deprecated in favour of html5shiv. We should consider adding// support for html5shiv (except if no explicit support is needed, e.g., if html5shiv// somehow shims the native APIs so it just works anyway)div.appendChild(windowContext['innerShiv'](markup));} else {if (mayRequireCreateElementHack) {// The document.createElement('my-element') trick to enable custom elements in IE6-8// only works if we assign innerHTML on an element associated with that document.documentContext.body.appendChild(div);}div.innerHTML = markup;if (mayRequireCreateElementHack) {div.parentNode.removeChild(div);}}// Move to the right depthwhile (depth--)div = div.lastChild;return ko.utils.makeArray(div.lastChild.childNodes);
}
该函数通过将 HTML 片段包装在适当的标签中,然后使用 innerHTML
解析,最后提取出所需的节点。
jQueryHtmlParse
function jQueryHtmlParse(html, documentContext) {// jQuery's "parseHTML" function was introduced in jQuery 1.8.0 and is a documented public API.if (jQueryInstance['parseHTML']) {return jQueryInstance['parseHTML'](html, documentContext) || []; // Ensure we always return an array and never null} else {// For jQuery < 1.8.0, we fall back on the undocumented internal "clean" function.var elems = jQueryInstance['clean']([html], documentContext);// As of jQuery 1.7.1, jQuery parses the HTML by appending it to some dummy parent nodes held in an in-memory document fragment.// Unfortunately, it never clears the dummy parent nodes from the document fragment, so it leaks memory over time.// Fix this by finding the top-most dummy parent element, and detaching it from its owner fragment.if (elems && elems[0]) {// Find the top-most parent element that's a direct child of a document fragmentvar elem = elems[0];while (elem.parentNode && elem.parentNode.nodeType !== 11 /* i.e., DocumentFragment */)elem = elem.parentNode;// ... then detach itif (elem.parentNode)elem.parentNode.removeChild(elem);}return elems;}
}
当页面中引入了 jQuery 时,Knockout.js 会优先使用 jQuery 的 HTML 解析功能,因为它更成熟和强大。
核心 API
parseHtmlFragment
ko.utils.parseHtmlFragment = function(html, documentContext) {return jQueryInstance ?jQueryHtmlParse(html, documentContext) : // As below, benefit from jQuery's optimisations where possiblesimpleHtmlParse(html, documentContext); // ... otherwise, this simple logic will do in most common cases.
};
解析 HTML 片段并返回 DOM 节点数组。如果页面中引入了 jQuery,则使用 jQuery 的解析功能,否则使用内置的解析逻辑。
setHtml
ko.utils.setHtml = function(node, html) {ko.utils.emptyDomNode(node);// There's no legitimate reason to display a stringified observable without unwrapping it, so we'll unwrap ithtml = ko.utils.unwrapObservable(html);if ((html !== null) && (html !== undefined)) {if (typeof html != 'string')html = html.toString();// jQuery contains a lot of sophisticated code to parse arbitrary HTML fragments,// for example <tr> elements which are not normally allowed to exist on their own.// If you've referenced jQuery we'll use that rather than duplicating its code.if (jQueryInstance) {jQueryInstance(node)['html'](html);} else {// ... otherwise, use KO's own parsing logic.var parsedNodes = ko.utils.parseHtmlFragment(html, node.ownerDocument);for (var i = 0; i < parsedNodes.length; i++)node.appendChild(parsedNodes[i]);}}
};
设置节点的 HTML 内容。如果引入了 jQuery,则使用 jQuery 的 html()
方法,否则使用 Knockout.js 自己的解析逻辑。
优化方案(针对现代浏览器)
针对现代浏览器,我们可以大幅简化 DOM 操作模块的实现:
(function () {// 现代浏览器中的 HTML 包装映射const lookup = {'thead': [1, "<table>", "</table>"],'tbody': [1, "<table>", "</table>"],'tfoot': [1, "<table>", "</table>"],'tr': [2, "<table><tbody>", "</tbody></table>"],'td': [3, "<table><tbody><tr>", "</tr></tbody></table>"],'th': [3, "<table><tbody><tr>", "</tr></tbody></table>"],'option': [1, "<select multiple='multiple'>", "</select>"],'optgroup': [1, "<select multiple='multiple'>", "</select>"]};function getWrap(tagName) {// 提取标签名const match = tagName.match(/<([a-z]+)/i);const tag = match ? match[1].toLowerCase() : '';return lookup[tag] || [0, "", ""];}function parseHtmlFragment(html, documentContext) {documentContext = documentContext || document;// 使用现代浏览器的 DOMParser APIif (typeof DOMParser !== 'undefined') {const parser = new DOMParser();const doc = parser.parseFromString(`<div>${html}</div>`, 'text/html');return Array.from(doc.body.firstChild.childNodes);}// 回退到模板元素方法const template = documentContext.createElement('template');template.innerHTML = html;return Array.from(template.content.childNodes);}ko.utils.parseHtmlFragment = function(html, documentContext) {// 如果引入了 jQuery,仍然可以使用它if (jQueryInstance && jQueryInstance.parseHTML) {return jQueryInstance.parseHTML(html, documentContext) || [];}return parseHtmlFragment(html, documentContext);};ko.utils.parseHtmlForTemplateNodes = function(html, documentContext) {const nodes = ko.utils.parseHtmlFragment(html, documentContext);return (nodes.length && nodes[0].parentElement) || ko.utils.moveCleanedNodesToContainerElement(nodes);};ko.utils.setHtml = function(node, html) {ko.utils.emptyDomNode(node);// 解包 observablehtml = ko.utils.unwrapObservable(html);if ((html !== null) && (html !== undefined)) {if (typeof html != 'string')html = html.toString();// 如果引入了 jQuery,使用 jQuery 的 html 方法if (jQueryInstance) {jQueryInstance(node).html(html);} else {// 使用现代浏览器的实现const parsedNodes = ko.utils.parseHtmlFragment(html, node.ownerDocument);parsedNodes.forEach(parsedNode => node.appendChild(parsedNode));}}};
})();ko.exportSymbol('utils.parseHtmlFragment', ko.utils.parseHtmlFragment);
ko.exportSymbol('utils.setHtml', ko.utils.setHtml);
优化要点
- 使用现代 API - 利用
DOMParser
和<template>
元素 - 简化包装逻辑 - 移除 IE 兼容性代码
- 使用现代 JavaScript 语法 - 使用
const
/let
、箭头函数、Array.from
等 - 移除过时的兼容代码 - 删除针对 IE6-8 的特殊处理
使用示例
基本用法
// 解析 HTML 片段
const nodes = ko.utils.parseHtmlFragment('<p>Hello, world!</p><div>Content</div>');
console.log(nodes.length); // 输出节点数量// 设置元素的 HTML 内容
const element = document.getElementById('content');
ko.utils.setHtml(element, '<h1>Title</h1><p>Paragraph</p>');
在绑定处理器中使用
ko.bindingHandlers.html = {init: function() {// 阻止默认的后代绑定return { controlsDescendantBindings: true };},update: function(element, valueAccessor) {// 使用 Knockout.js 的 HTML 设置功能ko.utils.setHtml(element, valueAccessor());}
};
处理特殊元素
// 解析表格相关元素
const tableCells = ko.utils.parseHtmlFragment('<td>Cell 1</td><td>Cell 2</td>');
// 这些单元格会被正确包装在表格结构中// 解析表单选项
const options = ko.utils.parseHtmlFragment('<option value="1">Option 1</option><option value="2">Option 2</option>');
// 这些选项会被正确包装在 select 元素中
总结
[utils.domManipulation.js]是 Knockout.js 中一个重要的 DOM 操作模块,它解决了 HTML 解析和 DOM 操作中的复杂问题。通过提供统一的 API,它隐藏了浏览器差异,使得开发者可以安全地处理各种 HTML 片段。
对于现代浏览器,我们可以利用新的 Web API(如 DOMParser
和 <template>
元素)来简化实现,同时保持与 jQuery 等库的兼容性。这种渐进式增强的设计模式使得 Knockout.js 既能在旧环境中正常工作,又能在现代浏览器中发挥最佳性能。