通过组件,可以将一个大的页面拆分为多个部分,每个部分都可以作为单独的组件,这些组件共同组成完整的页面。组件的实现需要渲染器的支持
从渲染器的内部实现来看,一个组件就是一个特殊类型的虚拟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 对象的引用。