前言
今天来模拟一下vue3的渲染系统的实现,不太了解这方面的可以认真看看,希望能对大家有帮助
渲染系统
众所周知vue框架是通过render函数将vue的template模板通过h函数解析成vnode(虚拟节点)慢慢变成了vdom(虚拟dom) 最后转换成真实元素,我们才能真正的在浏览器上看到。今天我们就来手动实现一个简单的渲染器,来探究探究其真正的原理。
手写一个虚拟节点 来帮助我们实验
//1.通过h函数创建vnode形成vdom
const vnode = h('div', {
class: 'home',
id: 'one'
}, [
h('h2', null, '哈哈,我是渲染器渲染出来的元素'),
h('button',{onClick(){}},'按钮点击')
])
我们现在要做的就是将虚拟节点中的内容转化成真正的元素
h函数 转化成vnode对象
传递参数
- tag 标签名 如div span
- props 属性名如class id
- children 字符串为直接填入内容 数组可嵌套多个子节点
//将vnode转换成 avascript对象 -> {}
const h = (tag, props, children) => {
return {
tag,
props,
children
}
}
它干的活很简单,直接转化成对象就行了 (实际上vue内部源码还增加了其它属性,来应对其它的情况)
我们来看看转换后的vnode 实际上现在是一个小的vdom了
实质上就对象里面套对象的数据结构,现在我们就是考虑将其转换为真实dom再进行挂载
接下来我们将其生成的vdom挂载到特定的容器上
// 2.通过mount函数, 将vnode挂载到div#app上
mount(vnode,document.querySelector('#app'))
实现mount挂载函数 将VNode挂载到DOM上
传递参数 vnode 虚拟节点 container 挂载到的容器上
//解析vnode节点形成真正的元素节点 vnode -> element
const mount = (vnode, container) => {
//1.创建元素节点
const el = vnode.el = document.createElement(vnode.tag)
//2.判断属性是否有值 有则遍历 再判断属性是否为函数 分别处理
if (vnode.props) {
for (key in vnode.props) {
const value = vnode.props[key] //取值
if (key.startsWith('on')) { //匹配是否以on开头的属性
//添加监听 删除'on'再小写 eg:onClick->click
el.addEventListener(key.slice(2).toLowerCase(), value)
} else {
el.setAttribute(key, value) //添加属性
}
}
}
//3.处理children
if (vnode.children) {
if (typeof vnode.children === 'string') { //字符串直接填入
el.textContent = vnode.children
} else {
vnode.children.forEach(item => { //遍历直接执行递归
mount(item, el)
});
}
}
container.appendChild(el)
}
这个实现起来稍微复杂了点,主要是需要考虑的情况比较多,这里也没有写完整,主要步骤实现就是这样来做的。
这样执行mount函数后页面上就能真正的显示我们的dom元素了 如下:
但是当我们更新了dom时它又是怎么进行及时更新呢?
patch函数,用于对两个VNode进行对比,决定如何处理新的VNode
- 情况一tag标签不同
//通过h函数创建vnode形成vdom
const vnode = h('div', {
class: 'home',
id: 'one'
}, [
h('h2', null, '哈哈,我是渲染器渲染出来的元素'),
h('button', {
onClick() {}
}, '按钮点击')
])
console.log(vnode);
// 2.通过mount函数, 将vnode挂载到div#app上
mount(vnode, document.querySelector('#app'))
//3.通过patch函数进行diff算法更新节点
setTimeout(() => {
const newVnode = h('p', {
class: 'home-new',
id: 'one-new'
}, [
h('h3', null, '哈哈,我是渲染器渲染出来的元素'),
])
patch(vnode,newVnode)
}, 2000)
patch 处理
//主要思路是比较出不同点,更新dom
const patch = (n1, n2) => {
//1.节点标签不一致 简单粗暴 将旧节点移除新节点挂载上去 做一个替换效果
if (n1.tag !== n2.tag) {
const n1Parent = n1.el.parentElement //获取父节点
n1Parent.removeChild(n1.el) //移除n1子节点
mount(n2, n1Parent)//将n2挂载到父节点
}else{
}
}
效果展示 成功替换
- 情况二 props中的属性发生了变化 增删改
//通过h函数创建vnode形成vdom
const vnode = h('div', {
class: 'home',
id: 'one'
}, [
h('h2', null, '哈哈,我是渲染器渲染出来的元素'),
h('button', {
onClick() {}
}, '按钮点击')
])
console.log(vnode);
// 2.通过mount函数, 将vnode挂载到div#app上
mount(vnode, document.querySelector('#app'))
//3.通过patch函数进行diff算法更新节点
setTimeout(() => {
//情况二:props中的属性发生了变化 增删改
const newVnode = h('div', {
class: 'home-new',
name: 'kzj',
onClick() {
console.log('hahha');
}
}, [
h('h3', null, '呵呵,我是渲染器渲染出来的元素'),
])
patch(vnode,newVnode)
}, 2000)
我们这里改变了class类名 增加了name属性 删除了id属性
patch 处理
//主要思路是比较出不同点,更新dom
const patch = (n1, n2) => {
//1.节点标签不一致 简单粗暴 将旧节点移除新节点挂载上去 做一个替换效果
if (n1.tag !== n2.tag) {
const n1Parent = n1.el.parentElement //获取父节点
n1Parent.removeChild(n1.el) //移除n1子节点
mount(n2, n1Parent)//将n2挂载到父节点
}else{
//取出element对象 并在n2中保存
const el = n2.el = n1.el
//2.属性新增 修改 删除
const oldProps = n1.props || {}
const newProps = n2.props || {}
// 修改和新增处理
for(key in newProps){
const oldValue = oldProps[key]
const newValue = newProps[key]
if(oldValue !== newValue){
if(key.startsWith('on')){//更新函数
el.addEventListener(key.slice(2).toLowerCase(),newValue)
}else{
el.setAttribute(key,newValue)//更新属性
}
}
}
//删除处理
for (const key in oldProps) {
//写在外面的原因是每次触发后的函数地址都会发生改变所以要清除旧的
if (key.startsWith('on')) {//移除方法
const value = oldProps[key]
el.removeEventListener(key.slice(2).toLowerCase(), value)
}
if (!(key in newProps)) {//如果该属性不在新对象中
el.removeAttribute(key)//移除该属性
}
}
}
}
效果展示 成功实现 增删改都能实现 点击也能触发函数
这里可能有细心的同学对下面子元素怎么没改变有点疑问了,那是因为我们现在还没有对children处理呢,所以上面肯定是不会改变的。那为啥第一种情况标签不同就能改变呢,这个不用多说了吧,虽然它没有去做props children处理,但是它简单粗暴呀,把根节点直接给替换了,你说能不改变吗?
下面开始对children开始处理
- 情况三 children 发生改变
//通过h函数创建vnode形成vdom
const vnode = h('div', {
class: 'home',
id: 'one'
}, [
h('h2', null, '哈哈,我是渲染器渲染出来的元素'),
h('button', {
onClick() {}
}, '按钮点击')
])
console.log(vnode);
// 2.通过mount函数, 将vnode挂载到div#app上
mount(vnode, document.querySelector('#app'))
//3.通过patch函数进行diff算法更新节点
setTimeout(() => {
//情况三:children 发生改变
const newVnode = h('div', {
class: 'home-new',
name: 'kzj',
onClick() {
console.log('hahha');
}
}, [
h('h3', null, '呵呵,我是渲染器渲染出来的元素'),
h('h4', null, '嘻嘻'),
h('h5', null, '嘿嘿'),
])
patch(vnode, newVnode)
}, 2000)
我们这里新增了两个h标签
patch处理
这里细分几种情况处理
- 判断是不是字符串 处理
- 数组处理 2.1旧节点大于新节点–移除多余旧节点 2.2旧节点小于新节点–增加多余新节点
//主要思路是比较出不同点,更新dom
const patch = (n1, n2) => {
//1.节点标签不一致 简单粗暴 将旧节点移除新节点挂载上去 做一个替换效果
if (n1.tag !== n2.tag) {
const n1Parent = n1.el.parentElement //获取父节点
n1Parent.removeChild(n1.el) //移除n1子节点
mount(n2, n1Parent)//将n2挂载到父节点
} else {
//取出element对象 并在n2中保存
const el = n2.el = n1.el
//2.属性新增 修改 删除
const oldProps = n1.props || {}
const newProps = n2.props || {}
// 修改和新增处理
for (key in newProps) {
const oldValue = oldProps[key]
const newValue = newProps[key]
if (oldValue !== newValue) {
if (key.startsWith('on')) {
el.addEventListener(key.slice(2).toLowerCase(), newValue)
} else {
el.setAttribute(key, newValue)
}
}
}
//删除处理
for (const key in oldProps) {
//写在外面的原因是每次触发后的函数地址都会发生改变所以要清除旧的
if (key.startsWith('on')) {//移除方法
const value = oldProps[key]
el.removeEventListener(key.slice(2).toLowerCase(), value)
}
if (!(key in newProps)) {//如果该属性不在新对象中
el.removeAttribute(key)//移除该属性
}
}
//3.children处理
const oldChildren = n1.children || [];
const newChildren = n2.children || [];
//3.1判断是不是字符串 处理
if (typeof newChildren === 'string') {
if (typeof oldChildren === 'string') {
if (oldChildren !== newChildren) { //都是且值不同
el.textContent = newChildren
}
} else {
el.innerHtml = newChildren //旧节点是数组或其它类型
}
} else {
//数组处理
//n1 [v1,v2,v3]
//n2 [v1,v5,v6]
//简单diff算法实现
const commonLength = Math.min(oldChildren.length, newChildren.length)//最小长度
for (let i = 0; i < commonLength; i++) {
patch(oldChildren[i],newChildren[i])//递归更新节点
}
//n1 [v1,v2,v3,v7,v8]
//n2 [v1,v5,v6]
//多余节点处理 旧节点多--移除节点 v7 v8
if(oldChildren.length > commonLength){
oldChildren.slice(commonLength).forEach(item =>{
el.removeChild(item.el)//移除
})
}
//n1 [v1,v2,v3]
//n2 [v1,v5,v6,v7,v8]
//新节点多--增加节点 v7 v8
if(newChildren.length > commonLength){
newChildren.slice(commonLength).forEach(item =>{
mount(item,el)//挂载
})
}
}
}
}
效果展示 成功实现
这样我们就封装一个完整的渲染系统了,理解了实现过程也能更好的帮助我们了解vue内部是如何来实现渲染系统的。
END
希望能得到大家支持,点个赞,关注下都可,感谢大家。
如有错误,望各位大佬指出。
另外如果不想敲代码的话或者遇到什么报错问题的话,可以私我发源码给你,但是建议最好自己来实现,这样感触更深,哈哈,下次见~