published: true date: 2022-2-3 tags: '前端框架 Vue'
虚拟DOM
本章将从零介绍Vue中的虚拟DOM,从渲染函数带到mount函数以及最后的patch函数也都有具体的代码实现。
致谢Vue Mastery非常好的课程,可以转载,但请声明源链接:文章源链接justin3go.com(有些latex公式某些平台不能渲染可查看这个网站)
虚拟DOM层的一些好处
- 它让组件的渲染逻辑完全从真实DOM中解耦
- 更直接地去重用框架的运行在其他环境中
- Vue运行第三方开发人员创建自定义渲染解决方案,目标不仅仅是浏览器,也包括IOS和Android等原生环境
- 也可以使用API创建自定义渲染器直接渲染到WebGL,而不是DOM节点
- 提供了以编程方式构造、检查、克隆以及操作所需的DOM操作的能力
渲染函数
模板会完成你要做的事,在99%的情况下你只需写出HTML就好了,有时候需要做一些更可控的事情,这种情况下,需要编写一个渲染函数,所以渲染函数是什么样子的呢?
Vue2 API
// 这是组件定义中的一个选项,相比于提供一个template选项,你可以为组件提供一个渲染函数,在Vue2中,你会得到h参数直接作为渲染函数的参数,可以用它创造vnode render(h) { // vnode接收的第一个参数是type return h('div', { // 第二个参数是一个对象,包含vnode上的所有数据或属性; // Vue2中的API比较冗长,必须指明传递给节点的绑定类型,如果要绑定属性,你必须把它嵌套在attrs对象下,如果要绑定时事件侦听器,你得把它绑定在on下面 attrs: { id: 'foo' }, on: { click: this.onClick } // 第三个参数是这个vnode的子节点,直接传递一个字符串是一个方便的API去表明此节点只包含文本子节点,但它也可以是数组,包含跟多的子节点,这个数组可以嵌套跟多的嵌套h调用 }, 'hello') }
Vue3 API
- Flat props structure(扁平的props结构)
- Globally imported
h
helper(全局导入h)
- 因为Vue2的h需要连续传递,所以设置为全局变量
import {h} from 'vue' render(){ // 当你调用h时,第二个参数现在总是一个扁平的对象,你可以直接给它传递一个属性; // 任何带on的都会自动绑定为一个监听器,所以不必考虑太多嵌套的问题 // 大多数时候你也不必考虑是应将其作为attribute绑定,还是DOM属性绑定,因为Vue将智能地找到最好方法 // 实际上,检查这个key是否存在,在原生DOM中作为属性,如果存在,我们会将其设置为property,如果不存在,我们将其设置为一个attribute return h('div', { id: 'foo' onClick: this.onClick }, 'hello') }
什么时候去使用渲染函数
静态结构的写法
import {h} from 'vue' const App = { render(){ return h('div', { id: 'hello' }) } }
上述代码最终会得到类似于以下的代码: <div id=hello></div> 在最终的dom里面
然后,你可以给它嵌套更多的嵌套子元素
const App = { render(){ return h('div', { id: 'hello' }, [ h('span', 'world') ]) } }
上述代码最终会得到类似于以下的代码: <div id=hello><span>world</span></div> 在最终的dom里面
使用v-if
// 使用是三目表达式或者普通的if-else,是一样的 const App = { render(){ // v-if="ok" return this.ok ? h('div', {id: 'hello'}, [h('span', 'world')]) :this.otherCondition ?h('p', 'other branch') :h('span') } }
使用v-for
import {h} from 'vue' const App = this: { render() // v-for return this.list.map(item => { return h('div', {key: item.id}, item.text) }) }
处理插槽
import {h} from 'vue' const App = { render(){ const slot = this.$slot.default ?this.$slots.default() :[] } }
例子
假设我们有一个堆栈组件,一些用户界面库可能会有这种情况吗,堆栈组件时布局组件
<Stack size="4"> <div>hello</div> <Stack size="4"> <div>hello</div> <div>hello</div> </Stack> </Stack> <div class="stack"> <div class="mt-4"> <div>hello</div> </div> <div class="mt-4"> <div class="stack"> <div class="mt-4"> <div>hello</div> </div> </div> </div> </div> <script> import {h} from 'vue' const Stack = { render(){ const slot = this.$slots.default ?this.$slots.default() :[] // 所有内容放进stack类中 return h('div', { class: 'stack'}, slot.map(child =>{ return h('div', {class: `mt-${this.$props.size}`},[ child ]) })) } } </script>
实际使用:
<script src="https://unpkg.com/vue@next"></script> <style> .mt-4{ margin:10px; } </style> <div id="app"> </div> <script> const {h,createApp} = Vue // 使用渲染函数生成的Stack组件 const Stack = { render(){ const slot = this.$slots.default ?this.$slots.default() :[] // 所有内容放进stack类中 return h('div', { class: 'stack'}, slot.map(child =>{ return h('div', {class: `mt-${this.$props.size}`},[ child ]) })) } } // 使用Stack组件 const App = { components: { Stack }, template: ` <Stack size="4"> <div>hello</div> <Stack size="4"> <div>hello</div> <div>hello</div> </Stack> </Stack>` } createApp(App).mount('#app') </script>
效果:
经验:什么时候使用render
- 当你意识到你想表达的逻辑使用JavaScript更容易表达,而不是模板语法
- 当你创作可重用的功能组件时更常见,要跨多个应用程序共享或者组织内部共享
- 你主要在编写特性组件,模板通常是有效的方式
- 模板的好处是更简单,优化通过编译器优化,尤其当你有很多标记的时候
- 它更容易让设计师接管组件并用CSS设置样式
创造一个mount函数
一些假设让例子更简单:
- 一切都是一个元素
- 调用参数总是一样的顺序(tag, props, children),所以下面有如果你没有任何的属性,你需要在那里传入null参数
<div id="app"> </div> <script> function h(tag, props, children){ return { tag, props, children } } // mount会接收我们所说的vnode,contianer是DOM元素 function mount(vnode, container){ // 中间的vnode.el是为了后续实现patchh const el = vnode.el= document.createElement(vnode.tag) // 这给了我们实际的节点对应于虚拟节点 // props: 如果有,我们需要迭代这些属性把它们分别放在元素上作为DOM的property或attribute if(vnode.props){ // 这里为了简单,就假设一切都是attribute for (const key in vnode.props){ const value= vnode.props[key] el.setAttribute(key, value) } } // children: 假设这个参数是一个虚拟节点数组或者是一个字符串 if(vnode.children){ if(typeof vnode.children === 'string'){ el.textContent = vnode.children }else{ vnode.children.forEach(child => { mount(child, el) }) } } // 把它插入容器 container.appendChild(el) } const vdom = h('div', {class: 'red'},[ h('span', null, ['hello']) ]) mount(vdom, document.getElementById()) // n1是旧的虚拟DOM,之前的快照,n2是新的虚拟DOM,是我们现在想要展示在界面的部分 // patch需要找出最小数量它需要执行的DOM操作 function patch(n1, n2){ ... } const vdom2 = h('div', {class: 'green'},[ h('span', null, ['changed']) ]) patch(vdom, vdom2) </script>
我们渲染了原始组件,把它变成了虚拟DOM,当一个响应式属性被更新的时候,触发了重新渲染,重新生成了另一个表示形式的虚拟DOM,然后新旧比较。
创建patch函数
<div id="app"> </div> <script> function h(tag, props, children){ return { tag, props, children } } function mount(vnode, container){ const el = vnode.el= document.createElement(vnode.tag) if(vnode.props){ for (const key in vnode.props){ const value= vnode.props[key] el.setAttribute(key, value) } } if(vnode.children){ if(typeof vnode.children === 'string'){ el.textContent = vnode.children }else{ vnode.children.forEach(child => { mount(child, el) }) } } container.appendChild(el) } const vdom = h('div', {class: 'red'},[ h('span', null, ['hello']) ]) mount(vdom, document.getElementById()) // n1是旧的虚拟DOM,之前的快照,n2是新的虚拟DOM,是我们现在想要展示在界面的部分 // patch需要找出最小数量它需要执行的DOM操作 function patch(n1, n2){ // 这里仅讨论相同类型需要做的工作 if(n1.tag === n2.tag){ // 中间这部是为了在以后的更新中成为未来的快照 const el = n2.el = n1.el // props // 这里不讨论n1,n2的props是否为空的四种分支情况 const oldProps = n1.props || {} const newProps = n2.props || {} for(const key in newProps){ const oldValue = oldProps[key] const newValue = newProps[key] // 只有在实际变化后才会调用,以最小化实际DOM API的调用 if(newValue !== oldValue){ // 旧的没有,set会添加,旧的有key,set会替换 el.setAttribute(key, newValue) } } // 接下来讨论key不在newProps中的时候 for (const key in oldProps){ if(!(key in oldProps)){ el.removeAttribute(key) } } // children const oldChildren= n1.children const newChildren = n2.children if(typeof newChildren === 'string'){ if(typeof oldChildren === 'string'){ if(newChildren !== oldChildren){ el.textContent = newChildren } }else{ // 使用文本直接覆盖现有的DOM节点并丢弃它们 el.textContent = newChildren } }else{ // newC是arr的情况 if(typeof oldChildren === 'string'){ el.innreHTML = '' // 清理,然后这个元素变为空元素 // 加入 newChildren,forEach(child => { mount(child, el) }) }else{ // 都是数组的情况 const commonLength = Math.min(oldChildren.length, newChildren.length) for (let i = 0; i < commonLength; i++){ patch(oldChildren[i], newChildren[i]) } if(newChildren.length > oldChildren.length){ mount(child, el) }else if(newwChildren.length < oldChildren.length){ oldChildren.slice(newChildren.length).forEach(child => { el.removeChild(child.) }) } } } }else{ // repalce } } const vdom2 = h('div', {class: 'green'},[ h('span', null, ['changed']) ]) patch(vdom, vdom2) </script>
props:
可以看到,patch函数做了相当大的工作,遍历了两个对象,但是有了编译器,给了我们很多的提示,完全跳过这一部分是可能的;
children:
Vue内部比较数组的一种模式
- 键模式:当你使用v-for并提供一个key,key作为节点位置的提示