前言
上一篇我们提到了响应式是一种思路,我们不止可以用响应式处理数据,还可以用响应式处理一些视图我们的更新。这次我们要做的就是给我们的 min-vue 结合响应式数据添加上视图更新的功能。
PS:上一篇传送门
render 和 setup
我们这里,模仿 Vue3 的render和setup来做个简易的视图更新。
render 函数负责我们视图的渲染,setup 负责返回我们的响应式数据。
const App={
//render函数用来渲染视图,他接收一个参数,这个参数就是我们setup函数的返回值
render(context){
//这里我们创建一个简易的div
const div = document.createElement('div')
div.innerText='小明:'+context.user.age
//添加到根容器内
document.body.append(div)
},
//setup 的作用是返回我们的响应式数据
setup(){
const user =reactive({
age:12
})
return {
user
}
}
}
//最后调用render函数去渲染视图
App.render(App.setup())
上面的代码很简单,调用 App 中的 render 函数在界面上插入一段文本。但是只是实现了简单的渲染,当我们修改 user 的数据的时候不能同步更新我们的视图,所以我们下面要修改一下 render 函数,当我们修改数据的时候能同步更新视图。 这里就用到了我们之前的 effectWatch 函数了
const App={
...
render(context){
const div = document.createElement('div')
effectWatch(()=>{
div.innerText='小明:'+context.user.age
document.body.append(div)
})
}
...
}
引入 effectWatch 后,我们在修改响应式数据后会同步更新我们的视图了。
但是这里还有个问题,render 函数会全部覆盖更新视图,这个时候如果我们想采用 diff 方法去精确更新我们的是视图是不可能的。所以为了我们的 diff 能够实现,我们需要抽离我们的代码。
抽离代码
这里我们就仿照 Vue3 的代码,进行我们代码的抽离和整合。
实现抽离出来的是 App 模块。这个模块其实就是我们 Vue 中的组件部分,不过这里的 App 是作为根组件存在的。它提供两个方法:render 和 setup。这两个上面都有介绍,这里就不过多赘述了。
//App.js
export default {
render(context){
const div = document.createElement('div')
div.innerText=context.user.age
return div
},
setup(){
const user =reactive({
age:12
})
return {
user
}
}
}
下面要抽离的是我们的挂载方法,即我们之前的“document.body.append(div)”。这里不仅要实现视图的挂载,还需要完成响应式数据的依赖收集,目的就是当我们的响应式数据修改时能够同步更新我们的视图。
//core/reactive/index.js
/**
*
* @param {Object} rootComponent 根组件
* @returns 返回我们的App对象
*/
export function createApp(rootComponent) {
return {
/**
* 挂载函数
* @param {Object} rootContainer 外部容器
*/
mount(rootContainer) {
//我们的响应式数据
const context = rootComponent.setup()
//用effect处理我们的更新视图的逻辑
effectWatch(() => {
//这里的render方法返回一个渲染后的视图对象,最终挂载到根容器上面
const element = rootComponent.render(context)
//挂载到我们的容器中
rootContainer.append(element)
})
}
}
}
剩下的比较简单,就是将两者结合起来:
//index.js
import { createApp } from './core/index.js'
import App from './App.js'
//这个地方执行的是挂载操作,引入我们的APP根组件
createApp(App).mount(document.querySelector('#app'))
这样,我们的代码基本上就和 Vue3 的代码类似了。还是之前提到的问题,我们这样做的目的就是为了响应式数据更新的时候能够准确的命中我们更新的地方,然后以最小的代价更新我们的视图。 上面只是达到了我们目的的第一步,下面我们需要做的就是引入虚拟 DOM 和 Diff 算法以达到高效率更新视图的目的。
虚拟 DOM
首先,我们需要了解虚拟 DOM 的概念,可以参考我之前写的手写虚拟 DOM。
我们在这里简单说一下为什么要引入虚拟 DOM:
随着我们的项目增大,界面变得更加复杂的时候。我们直接使用真实的 DOM 整体覆盖更新我们的视图是不太现实的,因为这会极大的增加我们的性能开销,尤其是当数据频繁变动的时候会使我们的界面变得卡顿,降低了用户的体验。因此我们就需要判断我们需要更新的内容然后精确定点更新视图,这个过程我们称之为 diff 过程。在 diff 过程中我们需要判断新老界面变更的地方,为了方便比较我们引入了中间层,这个中间层就是虚拟 DOM。
众所周知,我们的虚拟 DOM 是一个 JS 对象。然后为了创建这个 JS 对象,我们需要一个转换函数。在 Vue 中这个函数就是大名鼎鼎的h 函数
h 函数
h 函数用来生成我们的虚拟 DOM,这里的 h 函数是初始版本,因此很简单只是返回接收的参数。
JS 对象对 DOM 的描述,大概分为三个部分:
- 标签名称:tag,,在转换为真实 DOM 的过程中方便调用document.createElement方法
- 参数:props,这里的参数就是我们标签中的一些参数,例如:id,class 等。参数类型比较多,所以这里的 props 是一个对象的形式
- 子节点:children,children 的类型很多,可以是 html 标签、纯文本、function 组件、class 组件等。其中还要判断单个或者多个元素的情况,这里不做过多的延伸,只判断最简单的情况。
//core/reactive/h.js
/**
*
* @param {*} tag 标签名称
* @param {*} props 参数
* @param {*} children 子节点
* @returns 虚拟DOM对象
*/
export default function(tag,props,children){
return {
tag,
props,
children
}
}
mountElement
有了 h 函数生成虚拟 DOM,但是当我们挂载视图的时候仍是需要挂载真实的 DOM,所以我们还需要一个方法将虚拟 DOM 转换成真实的 DOM。这就是mountElement
// core/renderer/index.js
/**
* 将虚拟DOM转换为真实DOM
* @param {Object} vNode 虚拟DOM
* @param {Object} container 外部容器
*/
export function mountElement(vNode, container) {
//参考h函数的tag,props,children
const { tag, props, children } = vNode
//tag
const element = document.createElement(tag)
//当props
if (props) {
for (const key in props) {
const val = props[key]
element.setAttribute(key, val)
}
}
//如果children是一个字符串,即我们的子节点是一个纯文本节点
if (typeof children == 'string') {
element.append(document.createTextNode(children))
} else if (Array.isArray(children)) {
//如果children是一个数组的形式
children.forEach(item => {
//递归挂载子节点
mountElement(item, element)
})
}
//挂载到外层容器
container.append(element)
}
mountElement 的作用就是将我们传入的虚拟 DOM 转换成真实的 DOM 然后挂载到外部容器上。这里我们需要注意的就是对不同类型的 children 进行区别的处理的时候是一个递归的过程,因为我们的 children 下面可能也还有子节点。
修改 render 和 mount
我们还要稍微修改一下我们的 render 方法,引入我们的 h 函数
const App={
...
render(context){
return h(
'div',
{
class:'className',
id:'demo'
},
'小明:'+context.user.age
)
}
...
}
然后修改一下 mount 函数:
...
mount(rootContainer) {
const context = rootComponent.setup()
effectWatch(() => {
//这里的render方法返回一个渲染后的视图对象,最终挂载到根容器上面
const vNode = rootComponent.render(context)
//引入mountElement,挂载我们的虚拟DOM
mountElement(vNode, rootContainer)
})
}
...
以上,就是一个简易的虚拟 DOM。我们 DOM 的转换过程可谓是能精简就精简了。这里只是介绍一下思路,后面解析源码的过程中会慢慢的将内容补全的。
Diff 算法
之前我们提到了为了精确更新视图,采用了 diff 算法。diff 的主要作用就是比较新老节点,这个原理其实比较简单,但是里面的内容比较多。我们这里只是为了捋清我们的流程,因此不做复杂的延展只考虑一些简单的情况。后续文章会详解 Vue3 的 Diff 算法。
diff 算法是比较的我们的虚拟 Dom,而我们之前的 h 函数主要分三个部分:tag、props、children。因此,我们的 diff 算法也可以分成三个部分。
tag
这块比较简单,因为当我们新老节点的标签名不相同的时候我们只要用新的 Vnode 替换掉老的 Vnode 就好了。当标签相同的时候才可以继续后面的比较。
PS:这里还有个细节,就是我们需要将我们真实的 dom(即 Element)挂载到我们的 Vnode 上,这样会方便我们后续对 DOM 的操作。
//core/renderer/index.js
export function diff(oldVnode,newVnode){
//如果标签不同,我们直接替换掉当前的节点
if(oldVnode.tag!==newVnode.tag){
oldVnode.el.replaceWidth(document.createElement(newVnode.tag))
}
}
props
props 的逻辑稍微复杂一点。首先要考虑节点 props 是否为空,这时我们可以视情况直接进行删除或者新增的操作。另外一种情况就是新老节点的 props 都不为空,则需要修改操作。
export function diff(oldVnode,newVnode){
...
if(oldVnode.tag===newVnode.tag){
//这个地方将oldVnode.el,挂载到newVnode上,将我们的element保存下来
newVnode.el = oldVnode.el
//新增props、删除props、修改props
const { props: newProps } = newVnode
const { props: oldProps } = oldVnode
//当新老props都存在的时候
if (oldProps && newProps) {
Object.keys(newProps).forEach(key => {
const newVal = newProps[key]
const oldVal = oldProps[key]
//如果新老props不同,则修改
if (newVal !== oldVal) {
oldVnode.el.setAttribute(key, newVal)
}
})
}else if (oldProps) {
//若新props不存在则删除props
Object.keys(oldProps).forEach(key => {
const newVal = newProps[key]
const oldVal = oldProps[key]
//如果没有新props,则删除
if (!newVal) {
oldVnode.el.removeAttribute(key, oldVal)
}
})
}else{
//如果老props不存在,则直接新增新的props
Object.keys(newProps).forEach(key=>{
const newVal = newProps[key]
oldVnode.el.addAttribute(key,newVal)
})
}
}
...
}
children
children 的逻辑是这里面最复杂的了。之前提到过 children 的类型有两种,一是字符串,另一个是数组类型。新老节点的两两比较会是四种情况。其中最复杂的就是新老节点的 children 都是数组类型的情况,这里我们为了简化逻辑,假设新老节点的修改只存在新增删除或原地修改子节点的情况,不存在变换位置等其他复杂情况。
...
//修改children
const { children: newChildren } = newVnode
const { children: oldChildren } = oldVnode
//如果新的children是字符串
if (typeof newChildren === 'string') {
//如果老children是字符串
if (typeof oldChildren === 'string') {
//两者不相等,直接替换
if (newChildren !== oldChildren) {
newVnode.el.innerText(newChildren)
}
} else if (Array.isArray(oldChildren)) {
//老的children是数组,直接替换
newVnode.el.innerText(newChildren)
}
} else if (Array.isArray(newChildren)) {//新的节点是数组
if (typeof oldChildren === 'string') {
//如果老节点的children字符串,先清空节点
newVnode.el.innerText = ''
//挂载新children
mountElement(newVnode, newVnode.el)
} else if (Array.isArray(oldChildren)) {
//如果老节点children是字符串类型
const length = Math.min(newChildren.length, oldChildren.length)
//先diff公共长度的children
for (let index = 0; index < length; index++) {
const newVnode = newChildren[index]
const oldVnode = newChildren[index]
diff(oldVnode, newVnode)
}
//如果新节点children长度比较长,说明我们要新增子节点
if (newChildren.length > length) {
//创建节点
for (let index = length; index < newChildren.length; index++) {
const newVnode = newChildren[index]
mountElement(newVnode)
}
}
//如果老节点children长度比较长,说明我们要删除子节点
if (oldChildren.length > length) {
for (let index = length; index < oldChildren.length; index++) {
const oldVnode = oldChildren[index]
newVnode.el.parent.removeChild(oldVnode.el)
}
}
}
}
以上就是我们简单的 diff 过程了。当然,在 Vue3 中这块的代码十分复杂,因为要考虑到各种情况和效率问题。后面有机会了在详谈 Vue3 中的 diff 算法。
总结
上面就是我们 min-vue 中的虚拟 Dom 和 diff 部分。因为我们的目的是为了捋清大概的一个工作流程,所以其中的细节逻辑方便还是十分简单的,后面会慢慢丰富起来。 这也是学习源码的一种方法,在这个过程中我们对 Vue 的源码也有了一个更深的认知。越深入学习越觉得大神的深不可测,膜拜了
PS:附上源码地址
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!