【vue设计与实现】组件的实现原理 1-渲染组件&组件状态与自更新

通过组件,可以将一个大的页面拆分为多个部分,每个部分都可以作为单独的组件,这些组件共同组成完整的页面。组件的实现需要渲染器的支持

从渲染器的内部实现来看,一个组件就是一个特殊类型的虚拟DOM节点

而渲染器会使用虚拟节点的type属性来区分类型,对于不同类型的节点,采用不同的处理方法来完成挂载和更新。
实际上对于组件来说也是一样,为了使用虚拟节点来描述组件,可以用虚拟节点的vnode.type属性来存储组件的选项对象.

为了让渲染器能够处理组件类型的虚拟节点,还需要在patch函数中堆组件类型的虚拟节点进行处理,如下面代码:

function patch(n1,n2,container,anchor){
	if(n1 && n1.type!==n2.type){
		unmount(n1)
		n1 = null
	}
	
	const {type} = n2
	if(typeof type === 'string'){
		// 作为普通元素处理
		
	}else if(typeof type === Text){
		// 作为文本节点处理
		
	}else if(typeof type === Fragment){
		// 作为片段处理
		
	}else if(typeof type === 'object'){
		// vnode.type 的值是选项对象,作为组件来处理
		if(!n1){
			// 挂载组件
			mountComponent(n2,container,anchor)
		}else{
			// 更新组件
			patchComponent(n2,container,anchor)
		}
		
	}
} 

上面的代码中增加了一个分支用来处理虚拟节点的vnode.type属性值为对象的情况,即将该虚拟节点作为组件的描述来看待。

接下来就是设计组件在用户层面的接口,也就是用户怎么使用接口。
实际上,组件本身是对页面内容的封装,它用来描述页面内容的一部分。因此一个组件必须包含一个渲染函数,即render函数,并且渲染函数返回值应该是虚拟DOM,也就是说,组件的渲染函数就是用来描述组件所渲染内容的接口,如下面代码:

const MyComponent = {
	name: 'MyComponent',
	// 组件的渲染函数,其返回值必须为虚拟DOM
	render(){
		// 返回虚拟DOM
		return {
			type: 'div',
			children: `我是文本内容`
		}
	}
}

有了基本的组件结构后,渲染器就可以完成组件的渲染,如下面代码所示:

// 用来描述组件的VNode对象,type属性值为组件的选项对象
const CompVNode = {
	type: MyComponent
}
// 调用渲染器来渲染组件
renderer.render(CompVNode,document.querySelector('#app'))

渲染器中真正完成组件渲染任务的是mountComponent 函数,具体实现如下:

function mountComponent(vnode, container, anchor){
	// 通过 vnode获取组件的选项对象,即vnode.type
	const componentOptions = vnode.type
	// 获取组件的渲染函数 render
	const {render} = componentOptions
	// 执行渲染函数,获取组件要渲染的内容,即render函数返回的虚拟DOM
	const subTree = render()
	// 最后调用patch函数来挂载组件所描述的内容,即subTree
	patch(null, subTree, container, anchor)
	
}

这样就实现了最基本的组件化方案

在完成了组件的初始渲染后,我们尝试为组件设计自身的状态,如下代码:

const MyComponent = {
	name: 'MyComponent',
	// 用data函数来定义组件自身的状态
	data(){
		return {
			foo: 'hello world'
		}
	},
	render(){
		return {
			type: 'div',
			children: `foo 的值是:${this.foo}` // 在渲染函数内使用组件状态
		}
	}
}

在上面的代码里,约定用户必须使用data函数来定义组件自身的状态,同时可以在渲染函数中通过this访问有data函数返回的状态数据
下面代码实现了组件自身状态的初始化:

function mountComponent(vnode, container, anchor){
	const componentOptions = vnode.type
	const {render,data} = componentOptions

	// 调用data函数得到原始数据,并调用reactive函数将其包装为响应式数据
	const state = reactive(data())
	// 调用render函数时,将其this设置为state
	// 从而render函数内部可以通过this访问组件自身状态数据
	const subTree = render.call(state, state)
	// 第一个state是render里的this,第二个state是上面生成的state参数
	// call方法看下面补充知识
	patch(null, subTree, container, anchor)
}

经过上面的代码,就实现了对组件自身状态的支持,以及在渲染函数内访问组件自身状态的能力

当组件自身状态发生变化时,需要有能力触发自检更新,即组件的自更新。为此,需要将整个渲染任务包装到一个effect中,如下面代码:

function mountComponent(vnode, container, anchor){
	const componentOptions = vnode.type
	const { render, data } = componentOptions
	const state = reactive(data())

	// 将组件的render 函数调用包装到effect内
	effect(()=>{
		const subTree = render.call(state, state)
		patch(null, subTree, container, anchor)
	})
}

这样,一旦组件自身的响应式数据发生变化,组件就会自动重新执行渲染函数,从而完成更新。
但是这样多次修改响应式数据的值,就会导致渲染函数执行多次,这实际上是没有必要的,所以需要一个调度器,将改动缓冲到一个微任务队列中,等到执行栈清空后,再将它从微任务队列中取出并执行。具体实现如下:

// 任务缓存队列,用set数据结构
const queue = new Set()
let isFlushing = false
const p = Promise.resolve()

// 调取器
function queueJob(job){
	// 将job添加到任务队列中
	queue.add(job)
	if(!isFlushing){
		isFlushing = true
		p.then(()=>{
			try{
				// 执行任务队列中的任务
				queue.forEch(job=>job())
			} finally{
				// 重置状态
				isFlushing = false
				queue.length = 0
			}
		})
	}
}

有了调度器后,就可以在创建渲染副作用时使用

function mountComponent(vnode, container, anchor){
	const componentOptions = vnode.type
	const { render, data } = componentOptions
	const state = reactive(data())

	// 将组件的render 函数调用包装到effect内
	effect(()=>{
		const subTree = render.call(state, state)
		patch(null, subTree, container, anchor)
	}{
		// 指定该副作用函数的调度器为queueJob即可
		scheduler: queueJob
	})
}

这样,当响应式数据发生变化时,副作用函数不会立即同步执行,而是会被queueJob函数调度,最后在一个微任务中执行

但是上面的代码还存在缺陷,注意看effect函数内调用patch函数完成渲染时,第一个参数总是null,也就是,每次更新发生时都会进行全新的挂载,而不会打补丁,这样是不对的,正确的做法是:每次更新时,都那新的subTree和上一组件所渲染的subTree进行打补丁。要解决这个问题,就需要实现组件实例,用它来维护组件整个生命周期的状态,这样渲染器才能在正确的时机执行合适的操作。

补充知识:
对象的解构赋值
例如:

let { foo, bar } = { foo: 'aaa', bar: 'bbb' };
foo // "aaa"
bar // "bbb"

对象的解构赋值,可以很方便地将现有对象的方法,赋值到某个变量。

// 例一
let { log, sin, cos } = Math;

// 例二
const { log } = console;
log('hello') // hello

上面代码的例一将Math对象的对数、正弦、余弦三个方法,赋值到对应的变量上,使用起来就会方便很多。例二将console.log赋值到log变量。

js中call()方法的用法
语法:call(thisobj,[argq,arg2])
定义:调用一个对象的一个方法,以另一个对象替换当前对象
说明:call方法可以用来代替一个对象调用一个方法call方法可以将一个函数的对象上下文从初始化改为新的对象,也就是括号里面的原本的对象改为call()前面的对象、即用thisobj代替call前面的东西,最终用thisobj这个对象去执行call前面的方法。
如果没有提供 thisObj 参数,那么 Global 对象被用作 thisObj。
看下面的例子:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>js中call方法的使用</title>
</head>
<body>
	<p id="id1">新年</p>
</body>
</html>
<script>
    function add(a,b){
        alert(a+b);
    }

    function sub(a,b){
        alert(a-b);
    }

    document.getElementById("id1").onclick = function(){
        add.call(sub,3,1);
    }
</script>

结果是 4

这个例子中的意思就是用 add 来替换 sub,add.call(sub,3,1) == add(3,1) ,所以运行结果为:alert(4);
注意:js 中的函数其实是对象,函数名是对 Function 对象的引用。

你可能感兴趣的:(vue设计与实现,笔记,vue.js,javascript,前端)