18.【SolidJS】 采用 template 内容模板元素创建 DOM 元素

前言

我们上篇中的例子中是像 React 和 Vue 那样通过 document.createElement 来进行创建 DOM 元素的,如果存在大量 HTML 元素的话,采用 document.createElement 创建是非常麻烦的,所以我们可以采用 <template> 内容模板元素来批量创建 DOM 元素,这种方法的性能更高,生成的代码更少。

Vue 技术栈的同学肯定对 <template> 标签非常熟悉,因为 Vue 的单文件组件中的 HTML 模板部分就是使用 template 标签来表示的。其实 template 标签还是一个原生的 DOM 标签,现在 template 标签一般在浏览器原生组件 Web Components 中使用得比较多,而在 SolidJS 中也是将 Web 组件作为一等公民,所以我们有必要学习原生 DOM 标签 template

复习将要使用到的 DOM API

那么在学习 template 标签之前,我们先复习一下将要使用到的 DOM API 的使用。

例如我们有以下一段 HTML 功能:

html 复制代码
<div id="app">
    <div id="left">
        <dl>
            <dt>类别</dt>
            <dd>学生</dd>
        </dl>
    </div>
    <div id="right">
        <dl>
            <dt>类别</dt>
            <dd>老师</dd>
        </dl>
    </div>
</div>

那么我们可以通过 document.getElementById 的方式获取第一个 div 元素:

javascript 复制代码
const appEl = document.getElementById('app')

还可以通过 document.querySelector 获取:

javascript 复制代码
const appEl = document.querySelector('#app')

这里值得一提的是 document.querySelector 是 JavaScript 官方参考社区的 Jquery 的标准实现的。

接下来我们要获取 idleftrightdiv 元素,我们处了可以使用上述方法以外,我们还可以通过已经获取了第一个 div 元素进行获取。

我们先打印 appEl 变量来看看。

javascript 复制代码
console.log(appEl)

打印结果如下:

我们看到通过 console.log 打印 HTML 对象是一个元素的展示模式,而不是数据对象的展示模式,我们可以通过 console.dir 来打印。

javascript 复制代码
console.dir(appEl)

打印结果:

这个时候,我们就可以看到打印的 HTML 对象是数据对象的展示模式了。同时我们还可以通过以下方式的来打印查看 HTML元素对象上的属性:

javascript 复制代码
console.log('%O', appEl)

打印结果:

我们可以看到 appEl 对象下的 firstChild 属性(第一个孩子节点)的内容是一个文本节点,这个文本节点 nextSibling 属性 (下一个兄弟节点) 则是上面 idleftdiv 元素。那么为什么 appEl 对象第一个孩子节点是一个文本节点呢?这是因为空白也算一个节点,也就是文本节点,空白的文本节点。那么我们想 appEl 的第一个孩子节点是 idleftdiv 元素节点的话,我们需要删除中间的空白。

修改如下:

html 复制代码
<div id="app"><div id="left">
        <dl>
            <dt>类别</dt>
            <dd>学生</dd>
        </dl>
    </div>
    <div id="right">
        <dl>
            <dt>类别</dt>
            <dd>老师</dd>
        </dl>
    </div>
</div>

这样 appEl 的第一个孩子节点就是 idleftdiv 元素节点了。

同时我们可以看到 idrightdiv 中的内容跟 idleftdiv 中的内容结构是一致的,所以我们可以通过获取第一个 dl 的内容然后进行克隆,再添加到 idright 的容器中。

html 复制代码
<div id="app"><div id="left">
        <dl>
            <dt>类别</dt>
            <dd>学生</dd>
        </dl>
    </div>
    <div id="right"></div>
</div>

接着我们可以进行以下操作

javascript 复制代码
const leftEl = document.getElementById('left')
const dlEl = leftEl.firstChild
const cloneEl = dlEl.cloneNode(true)
const ddEl = cloneEl.getElementsByTagName('dd')[0]
ddEl.textContent = '老师'
const rightEl = document.getElementById('right')
rightEl.appendChild(cloneEl)

我们获取到 dl 标签DOM元素,然后通过 cloneNode 进行克隆,克隆之后再修改对应的元素内容,再通过 appendChild 挂载到 id 为 right 的 div 元素上。

其中我们需要对 Node.cloneNode() 方法进行一定的了解。

  • Node:将要被克隆的节点。
  • 参数 deep:是否采用深度克隆,如果为 true,则该节点的所有后代节点也都会被克隆,如果为 false,则只克隆该节点本身。

初试原生 template 元素

我们可以看到上述的 HTML 功能中有两个模块结构是一样的,一般在 Vue 或者 React 应用中,我们可能会把这部分进行封装成一个组件以方便复用。那么在原生 HTML 中我们可以怎么做呢?

html 复制代码
<div id="app">
    <div id="left"></div>
    <div id="right"></div>
</div>
<template id="dl"><dl><dt></dt><dd></dd></dl></template>

tepmlate 中的内容会被解析,但并不会渲染在页面上,因为 template 被解析了,所以我们可以通过 ID 获取 template 的内容:

javascript 复制代码
const temp = document.getElementById('dl')

获取到的 template 的对象上有个 content 属性, 这个属性是 DocumentFragment 包含了模板所表示的 DOM 树。关于 DocumentFragment,如果有了解过 Vue1 实现原理的话会比较熟悉,因为 Vue1 中就使用到了 DocumentFragment 来进行 DOM 操作的性能优化。为什么使用 DocumentFragment 可以进行 DOM 操作的性能优化呢?这是因为 DocumentFragment 中的内容虽然像标准的 document 一样,但它却不是真实 DOM,它的变化不会触发 DOM 树的重新渲染,所以不会产生性能问题。

接下来我们通过 console.dir 来打印一下这个 content 属性,看看它上面都有些什么。

javascript 复制代码
console.dir(temp.content)

打印结果如下:

我们可以看到这个 content 属性中的内容跟普通 DOM 对象结构是差不多的,同时我们可以 firstChild 属性值就是 dl 标签,这样我们就又可以通过 dl 标签获取到 dt 标签了,同理我们又可以通过 dt 标签获取到 dd 标签了。

代码如下:

javascript 复制代码
// 获取 template 标签内容
const tempEl = temp.content
// 获取 dl 标签
const dlEl = tempEl.firstChild
// 获取 dt 标签
const dtEl = dlEl.firstChild
// 获取 dd 标签
const ddEl = dtEl.nextSibling

这样我们就可以去修改 dt 标签 和 dd 标签中的文本内容了。

javascript 复制代码
dtEl.textContent = '类别'
ddEl.textContent = '学生'

修改完之后我们就可以将标签中的 DOM 内容插入到对应的地方。

javascript 复制代码
const leftEl = document.getElementById('left')
leftEl.appendChild(tempEl)

渲染结果如下:

这样我们就通过 template 实现了渲染。这样我们是不是又可以将 dd 标签的内容修改成 老师 再将 dl 的标签的内容插入到 id 为 right 的 DOM 中呢?也就是下面的方式,这样我们就实现代码结构的复用。

javascript 复制代码
ddEl.textContent = '老师'
const rightEl = document.getElementById('right')
rightEl.appendChild(tempEl)

渲染结果如下:

但我们发现并没有实现我们的需求,我们对 template 中的 dd 标签的内容修改影响到已经添加到真实 DOM 上了,这是因为是修改到了同一个引用对象的缘故。

其次我们往 id 为 right 的 div 中添加 template 标签中的内容时,并没有成功。这是因为 template 对象中的 content 属性是一个 DocumentFragment,通过 DocumentFragment 创建的 DOM 树,当使用 appendChild() 或 insertBefore() 将其插入到真实 DOM 中时会清空原来通过 DocumentFragment 创建的 DOM 树。

我们在把模板内容添加到 id 为 left 的 div 中之后打印 tempEl 对象看看。

我们发现 firstChild 属性为空了。

那么根据我们上面所学的知识,我们就很容易知道只要通过克隆就可以解决这个问题了。

javascript 复制代码
const temp = document.getElementById('dl')
const tempEl = temp.content
const dlEl = tempEl.firstChild
const dtEl = dlEl.firstChild
const ddEl = dtEl.nextSibling
dtEl.textContent = '类别'
ddEl.textContent = '学生'

const leftEl = document.getElementById('left')
// 通过克隆
leftEl.appendChild(tempEl.cloneNode(true))

ddEl.textContent = '老师'
const rightEl = document.getElementById('right')
// 通过克隆
rightEl.appendChild(tempEl.cloneNode(true))

渲染结果:

我们除了可以通过 HTML 的方式创建 template 标签内容,我们还可以通过 JavaScript 方式创建。

javascript 复制代码
const t = document.createElement("template")
t.innerHTML = '<dl><dt></dt><dd></dd></dl>'
const tempEl = t.content.firstChild

我们还可以将它封装成一个函数:

javascript 复制代码
function template(html) {
    const t = document.createElement("template")
    t.innerHTML = html
    return t.content.firstChild.cloneNode(true)
}
const tempEl = template('<dl><dt></dt><dd></dd></dl>')

采用 template 内容模板元素创建 DOM 元素

我们上篇文章中的例子中是像 React 和 Vue 那样通过 document.createElement 来进行创建 DOM 元素的,如果存在大量 HTML 元素的话,采用 document.createElement 创建是非常麻烦的,需要通过 document.createElement 每一个节点,所以我们可以采用 <template> 内容模板元素来批量创建 DOM 元素,这种方法的性能更高,生成的代码更少。

javascript 复制代码
const App = () => {
  const [count, setCount] = createSignal(0)
  const el = document.createElement('button')
  el.addEventListener('click', () => {
    setCount(520)
  })
  createEffect(() => {
    el.textContent = count()
  })
  insert(el, count)
  return el
}

这是我们上一篇文章中实现的组件代码,下面我们采用 template 标签来重新实现一遍。我们在上一节中将通过 JavaScript 创建 template 内容的功能封装成了一个函数,我们这里也可以拿来使用。

diff 复制代码
+ function template(html) {
+    const t = document.createElement("template")
+    t.innerHTML = html
+    return t.content.firstChild.cloneNode(true)
+ }
const App = () => {
  const [count, setCount] = createSignal(0)
-  const el = document.createElement('button')
+  const el = template('<button></button>')
  el.addEventListener('click', () => {
    setCount(520)
  })
  createEffect(() => {
    el.textContent = count()
  })
  insert(el, count)
  return el
}

这一小节我们通过 template 原生 DOM 标签创建 DOM 节点,但这只是初步实现,我们还可以继续优化一下,同时学习一些开发技巧,惰性函数的使用。

使用惰性函数提高程序性能

我们上述生成模板内容的函数 template 是每次执行就直接生成结果的,有些结果可能永远使用不到的,也生成了,这就会造成性能浪费。比如请求列表的结果,如果有结果就显示列表,如果没有就显示没有的页面,那么大部分情况可能都是存在数据的,所以没有的页面就很少使用到,那么没有的页面就不应该每次都生成了,所以我们希望需要使用到的时候,template 函数才去执行生成结果。

那么要实现我们上面的效果,我们就需要将我们的 template 函数改成惰性函数惰性函数是一种特殊的函数设计模式,它只在需要时才计算结果,而不是在函数被调用时立即计算。

javascript 复制代码
 function template(html) {
    const create = () => {
        const t = document.createElement("template")
        t.innerHTML = html
        return t.content.firstChild
    }
    const fn = () => create().cloneNode(true)
    return fn
 }

那么改造后的 template 函数就像一个工厂函数了,它会根据你传递的参数创建不同的内容模板生成函数,所以我们需要将其他创建的位置提升到组件函数的外面。

javascript 复制代码
// 生成创建 button 标签的函数
const _tmpl$ = template('<button></button>') 
const App = () => {
  const [count, setCount] = createSignal(0)
  // 真正进行创建模板内容的地方
  const el = _tmpl$()
  el.addEventListener('click', () => {
    setCount(520)
  })
  createEffect(() => {
    el.textContent = count()
  })
  insert(el, count)
  return el
}

同时我们希望一旦计算出结果,就会被缓存起来,这样后续需要时直接使用,而不是重新计算,所以我们重新迭代我们的 template 函数。迭代如下:

diff 复制代码
 function template(html) {
+    let node
    const create = () => {
        const t = document.createElement("template")
        t.innerHTML = html
        return t.content.firstChild
    }
-    const fn = () => create().cloneNode(true)
+    const fn = () => (node || (node = create())).cloneNode(true)
    return fn
 }

这一小节我们通过惰性函数对我们的 template 函数进行迭代,从而实现性能提升。我们这里主要使用了惰性函数的其中两个特点,延迟计算计算结果缓存,也就是函数它只在需要时才计算结果,而不是在函数被调用时立即计算,同时一旦计算出结果,就会被缓存起来,这样后续需要时直接使用,而不是重新计算。

惰性函数还有其他一些表现形式,比如重写函数,我们这里就不作深入探讨了,我们只探讨我们涉及到的部分。

原生 template 标签为什么会提高性能

我们上面提到了使用原生 template 标签可以提高性能,这其实也是 SolidJS 官网提到的,那么到底 template 是如何提高性能的呢?接下来我们就进行一番探索。

首先通过 template 标签创建的对象中有一个 content 属性,也就是 HTMLTemplateElement.content,它是一个 DocumentFragment,重点就在于这个 DocumentFragment,是它让原生 template 标签可以提高性能,同时 Vue1 也使用到了这个功能,因为它可以提高性能。

以下是 MDN 对 DocumentFragment 的介绍:

DocumentFragment ,文档片段接口,表示一个没有父对象的最小文档对象。 它被作为一个轻量版的 Document 使用,就像标准的 document 一样,存储由节点(nodes)组成的文档结构。与 document 相比,最大的区别是它不是真实 DOM 树的一部分,它的变化不会触发 DOM 树的重新渲染,且不会对性能产生影响。

MDN 官方虽然这样说,但它具体怎么样的,我们还是需要手动亲自验证一番。这个就像掘金上看到很多文章的观点一样,我们都需要去验证了,才是自己学到的知识。包括我这篇文章所说的观点,也只是我一家之言,技术文章还是需要手动进行验证结果。

我们知道通过 appendChild()insertBefore() 操作 DOM 的时候会引起浏览器的回流操作。那么我们下面来测试以下:

html 复制代码
<div id="app"></div>
<script>
  const root = document.getElementById('app')
  const ul = document.createElement('ul')

  function watchRender() {
    const li1 = document.createElement('li')
    li1.textContent = 'Cobyte'
    ul.appendChild(li1) // 引起回流?

    const li2 = document.createElement('li')
    li2.textContent = 'Coboy'
    ul.appendChild(li2) // 引起回流?

    const li3 = document.createElement('li')
    li3.textContent = '掘金签约作者'
    ul.appendChild(li3) // 引起回流?
  }

  setTimeout(watchRender)
  root.appendChild(ul)
</script>

我们使用 Google 浏览器执行上述代码然后在开发者工具的性能栏进行录制,我们可以得到下面的结果:

我们发现并没有什么异常,也体现不出使用 appendChild API 就引起浏览器回流操作的现象。其实上述操作最终只引起了浏览器的一次回流,回流重绘是昂贵的操作,如果每次 appendChild 都引起浏览器回流的话,这样就显得浏览器很呆板,所以浏览器就会把多次回流的操作合并成一次已达到提高性能的操作,同时这样也体现出浏览器很智能。这跟我们 Vue 多次修改一个响应式变量最终只进行一次重新渲染是同一个道理的。

但有时候我们需要浏览器实时给我们一些样式数据的时候,浏览器为了计算样式数据就会强制回流。例如下面的例子:

diff 复制代码
<div id="app"></div>
<script>
  const root = document.getElementById('app')
  const ul = document.createElement('ul')

  function watchRender() {
    const li1 = document.createElement('li')
    li1.textContent = 'Cobyte'
    ul.appendChild(li1) 
+    const width1 = li1.clientWidth // 真的引起了回流
+    console.log(width1)
    const li2 = document.createElement('li')
    li2.textContent = 'Coboy'
    ul.appendChild(li2) 
+    const width2 = li2.clientWidth // 真的引起了回流
+    console.log(width2)
    const li3 = document.createElement('li')
    li3.textContent = '掘金签约作者'
    ul.appendChild(li3) 
+    const width3 = li3.clientWidth // 真的引起了回流
+    console.log(widht3)
  }

  setTimeout(watchRender)
  root.appendChild(ul)
</script>

我们重新运行上述代码,并重新录制性能报告,我们就可以看到不一样的地方了。

首先我们可以看到我们上面所写的 watchRender 函数底下多了三个重新计算样式和布局的紫色方块,说明每一次 appendChild 之后在读取 clientWidth 属性的时候,都引起了浏览器的回流,因为浏览器需要实时计算样式提供给我们。同时我们也看到第一次 watchRender 函数执行的时间是 59 微秒,第二次 watchRender 函数执行的时间是 0.55 毫秒,转换成微秒则是 550 微秒,第二次 watchRender 函数执行的时间差不多比第一次执行的时间多了 10 倍。

所以我们为了减少浏览器回流的次数,我们不要经常访问会引起浏览器强制回流的属性,如果你确实要访问,可以利用缓存,除此之外我们还可以使用 DocumentFragment。那么使用 DocumentFragment 又是怎么样的呢?

diff 复制代码
    <div id="app"></div>
<script>
  const root = document.getElementById('app')
  const ul = document.createElement('ul')

  function watchRender() {
+    // 使用 DocumentFragment 
+    const fragment = document.createDocumentFragment()
    const li1 = document.createElement('li')
    li1.textContent = 'Cobyte'
+    fragment.appendChild(li1) 
+    const width1 = li1.clientWidth // 会引起回流吗?
+    console.log(width1)

    const li2 = document.createElement('li')
    li2.textContent = 'Coboy'
+    fragment.appendChild(li2) 
+    const width2 = li2.clientWidth // 会引起回流吗?
+    console.log(width2)
    
    const li3 = document.createElement('li')
    li3.textContent = '掘金签约作者'
+    fragment.appendChild(li3) 
+    const width3 = li3.clientWidth // 会引起回流吗?
+    console.log(width3)
+    ul.appendChild(fragment) // 这里会引起一次回流
  }

  setTimeout(watchRender)
  root.appendChild(ul)
</script>

我们重新运行上述代码,并重新录制性能报告:

我们从性能报告中可以看到使用 DocumentFragment 后,watchRender 函数的执行时间大大减少了,只有 79 微秒,也就是 49 微秒,也比上面第一次 watchRender 的执行时间 59 微秒要少,并且也没有紫色的回流方块。

值得注意的是,这些测试得到的时间会在不同电脑会受到电脑配置的影响而不尽相同,但时间差是可以看得出的。

所以经过上述测试,我们就深刻地知道为什么 SolidJS 官方强调使用 template 标签可以提高性能了。本质是 DocumentFragment 可以提高我们操作 DOM 的性能, 因为 template 标签的 content 内容是一个 DocumentFragment

总结

本文深入探讨了原生 <template> 标签在 DOM 操作中的优势与实践。通过对比 document.createElement 逐节点创建的方式,<template> 配合 DocumentFragment 能够批量生成 DOM 结构,显著减少页面回流与重绘次数,从而提升性能。我们不仅实现了基于 template 的组件复用,还借助惰性函数 实现了延迟计算与结果缓存,进一步优化了代码执行效率。性能测试表明,DocumentFragment 可将多次 DOM 操作合并为一次提交,有效避免强制回流的性能损耗。这些技巧正是 SolidJS 等现代框架底层优化的关键所在,掌握它们有助于开发者写出更高性能的原生 JavaScript 应用。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

相关推荐
ldmd2842 小时前
Typescript 入门篇-3
javascript·typescript·notepad++
山峰哥2 小时前
VB事件驱动编程实战:从零到一搭建完整管理系统
前端·数据库·性能优化·深度优先·vb
LucianaiB2 小时前
Swarm管理面板的多项目配置策略与模型别名机制的效率分析
java·服务器·前端
IT_陈寒2 小时前
Redis缓存雪崩,原来我一直在用错误的方式设置过期时间
前端·人工智能·后端
怕浪猫3 小时前
Electron 开发实战(十二):安全性最佳实践|彻底杜绝漏洞、代码执行与数据泄露
前端·javascript·electron
wgc2k3 小时前
NestJS基础-7: 官方 CLI 完整使用指南
前端
AI_零食3 小时前
HarmonyOS-鸿蒙原生 ArkTS 布局系统:width(‘100%‘) 的本质与 padding 陷阱
前端·学习·华为·harmonyos·鸿蒙
英俊潇洒美少年3 小时前
React18 flushSync 完整深度解析
前端·react
小鱼程序员3 小时前
Reqable关于路径定位
前端