1.渲染器与响应式系统的结合
渲染器renderer用于执行渲染任务,在浏览器平台上用于将虚拟DOM渲染真实DOM,这个过程称为挂载。同时渲染器也是跨平台的关键,以下是一个简易的渲染器:
function renderer(domString,container){
container.innerHTML = domString;
}
const count = ref(1);
effect(()=>{
renderer(`<h1>${count.value}/<h1>`,document.getElementById('app'))
})
渲染器除了渲染外还可以激活已有的DOM元素,所以用createRenderer函数来创建渲染器:
function createRender(){
function render(vnode,container) {
//...
}
function hydrate(vnode,container) {
//...
}
return {render,hydrate}
}
渲染器接收两个参数domString是要挂载的内容,container是指定挂载位置的真实DOM,即容器。由于count是响应式数据,当修改count的值后,副作用函数重新执行,就会重新渲染。多次在同一个容器上进行渲染时,渲染器除了执行挂载动作,还会执行更新动作。
const renderer = createRender();
//首次渲染
renderer.render(oldNode,container);
//再次渲染
renderer.render(newNode,container);
再次渲染的过程被称为patch,即更新。挂载可以视为旧的渲染内容不存在的更新。
function createRender(){
function render(vnode,container) {
if(vnode){
//新节点存在,将其与旧节点一起传递给patch函数执行更新
patch(container._vnode,vnode,container)
}else{
if(container._vnode){
//旧节点不存在,新节点存在,说明是卸载操作,直接清空container的内容
container.innerHTML = "";
}
}
//把vnode存在container._vnode上,即后续渲染的旧vnode;
container._vnode = vnode;
}
return {render}
}
首次渲染的时候patch函数接收到的旧节点值为空,它会忽略旧节点,直接把新节点的内容渲染到容器中,所以patch函数不仅可以更新,也可以挂载。
2.自定义渲染器
使用vnode对象来描述一个h1标签。
const vnode = {
type:"h1",
children:"hello"
}
type用来描述vnode类型,当type值是字符串时,vnode是普通标签。而元素除了文本节点还可以包含其他类型的节点,子节点类型可以是很多个,所以我们将vnode.children定义为数组。
const vnode = {
type:"div",
props:{
id:"foo"
},
children:[
{
type:'p',
children:"hello"
}
]
}
以上描述的是:一个div标签具有一个p标签子节点。使用mountElement函数渲染。vnode.props字段代表元素的属性。
function mountElement(vnode,container){
const el = createElement(vnode.type);
if(typeof vnode.children === "string"){
setElementText(el,vnode.children);
}else if(Array.isArray(vnode.children)){
//如果children是数组,则遍历每一个子节点,并使用patch函数挂载
vnode.children.forEach(child => {
patch(null,child,el)
})
}
//如果vnode具有属性
if(vnode.props){
for(const key in vnode.props){
el.setAttribute(key,vnode.props[key])
}
}
insert(el,container)
}
3.HTML Attributes 与 DOM Properties
以如下代码为例:
<input id="my-input" type="text" value="foo"/>
HTML Attributes指的是定义在HTML标签上的属性,如id、type和value,可以通过js代码来读取DOM对象:
const el = document.getElementById("my-input");
这个DOM对象包含很多属性,这些属性就是DOM Properties。有很多HTML Attributes在DOM对象上有与之同名的DOM Properties,HTML Attributes的作用是设置与之对应的DOM Properties的初始值。对于普通的HTML文件而言,浏览器会自动分析HTML Attributes并设置DOM Properties。但在vue的template模板中需要由框架来解析和设置。
//HTML模板
<button disabled>Button</button>
//HTML模板编译得到的vnode
const button = {
type:"button",
props:{
disabled:''
}
}
这里的props.disabled是空字符串,在浏览器中设置属性el.setAttribute('disabled',''),按钮会被禁用,但如果props.disabled值为false,就会变成el.setAttribute('disabled','false'),原本的布尔值false会被当作字符串'false'执行。按钮依然被禁用。为了避免这个错误,我们不使用setAttribute,而是使用el.disabled = false。 当props.disabled为空字符串时,手动校正为true。
function mountElement(vnode,container){
const el = createElement(vnode.type);
if(vnode.props){
for(const key of vnode.props){
if(key in el){
const type = typeof el[key],value = vnode.props[key];
//如果是布尔类型且value是空字符串则校正为true。
if(type === "boolean" && value === ""){
el[key] = true;
}else{
el[key] = value;
}
}else{
el.setAttribute(key,vnode.props[key])
}
}
}
insert(el,container)
}
class属性需要特殊处理,因为vue中template设置class有三种情况,class可以是字符串、对象或数组。
<p class="foo bar"></p>
<p :class="{foo:true,bar:false}"></p>
<p :class="['baz',{foo:true,bar:false}]"></p>
由于class可以是多种类型,所以我们需要把它转换为字符串类型。el.className设置class性能最好。
4.卸载操作
在挂载完成后,后续渲染时如果传递null作为新的vnode,意味着什么都不渲染,要卸载之前的内容。卸载时要做三件事: 1.容器的内容可能是某个或多个组件渲染的,卸载时需要调用这些组件的生命周期函数; 2.有些元素存在自定义指令,应该在卸载时执行对应的钩子函数; 3.移除绑定在DOM元素上的事件处理函数。 因此我们需要在vnode和真实DOM元素之间建立联系,卸载时,通过vnode来获取真实DOM,再使用原生的方法将真实DOM移除。修改mountElement函数:
function mountElement(vnode,container){
//让vnode.el引用真实DOM
const el =vnode.el = createElement(vnode.type);
...
}
卸载时:
function render(vnode,container){
if(!node){
if(container._vnode){
unmount(container._vnode)
}
}
}
function unmount(vnode){
const parent = vnode.el.parentNode;
if(parent){
parent.removeChild(vnode.el)
}
}
如果新旧vnode.type值不同,则卸载旧节点,再挂载新节点。
5.事件的处理
先约定在vnode.props对象中凡是以on开头的属性都视为事件:
const vnode = {
type:"p",
props:{
onClick()=>{
alert('clicked')
}
},
children:"text"
}
使用patchProps函数调用addEventListener来绑定事件。
patchProps(el,key,prevValue,next){
//如果key以on开头,则视为事件。
if(key.startWith("on")){
const name = key.slice(2).toLowerCase();
//绑定事件
el.addEventListener(name,nextValue)
}
}
在绑定事件后,如果要更新事件,可以先将添加的事件移除,再添加新的事件。但是有一个技巧,定义伪造的事件处理函数invoker,然后将真正的事件处理函数设置为invoker.value的值,这样在更新事件时只需要修改invoker.value即可。同时为了能给同一个DOM对象绑定多个事件,invoker.value的值应该是一个对象,键是事件名称,值是绑定的函数。另外,一个事件可能具有多个函数,所以props对象中的事件不仅可以是方法,也可以是存放方法的数组。
patchProps(el,key,prevValue,nextValue){
//如果key以on开头,则视为事件。
if(key.startWith("on")){
const invokers = el._vei||(el._vei = {}), name = key.slice(2).toLowerCase();
let invoker = invokers[key];
if(nextValue){
if(!invoker){
invoker = el._vei[key] = (e) => {
//如果invoker.value是数组,则遍历它并逐个调用事件处理函数
if(Array.isArray(invoker.value)){
invoker.value.forEach(fn => fn(e))
}else{
invoker.value(e)
}
}
invoker.value = nextValue;
el.addEventListener(name,invoker)
}else{
invoker.value = nextValue;
}
}else if(invoker){
el.removeEventListener(name,invoker)
}
//绑定事件
el.addEventListener(name,nextValue)
}
}
6.事件冒泡和更新时机
我们创建一个响应式数据bol,它的初始值为false,一个div元素,它的props是一个三元表达式,当bol值为false时什么也不做,当bol为true时为div绑定点击事件。在div内部有个p元素,它的点击事件是将bol设置为true:
const {effect,ref} = VueReactivity,bol = ref(false);
effect(()=>{
const vnode={
type:"div",
props:bol.value ? {
onClick:()=>{
alert('父元素clicked')
}
} :{},
children:[
{
type:"p",
props:{
onClick:()=>{
bol.value = true;
}
},
children:'text'
}
]
}
})
当我们点击p元素时,预想的情况是,bol初始值为false,所以div没有点击事件,虽然点击p元素可以冒泡到div元素上,但由于div元素上没有点击事件,所以不会触发alert('父元素clicked')。但实际的情况alert('父元素clicked')竟然执行了。原因是bol变为true后为div绑定事件会发生在事件冒泡之前。为了避免这种情况,如果一个事件处理函数的绑定事件晚于事件触发时间,则我们屏蔽它:
patchProps(el,key,prevValue,nextValue){
//如果key以on开头,则视为事件。
if(key.startWith("on")){
const invokers = el._vei||(el._vei = {}), name = key.slice(2).toLowerCase();
let invoker = invokers[key];
if(nextValue){
if(!invoker){
invoker = el._vei[key] = (e) => {
//如果一个事件处理函数的绑定事件晚于事件触发时间,则我们屏蔽它
if(e.timeStamp < invoker.attached){
return
}
//如果invoker.value是数组,则遍历它并逐个调用事件处理函数
if(Array.isArray(invoker.value)){
invoker.value.forEach(fn => fn(e))
}else{
invoker.value(e)
}
}
invoker.value = nextValue;
//添加invoker.attached属性,储存事件处理函数被绑定的时间
invoker.attached = performance.now()
el.addEventListener(name,invoker)
}else{
invoker.value = nextValue;
}
}else if(invoker){
el.removeEventListener(name,invoker)
}
//绑定事件
el.addEventListener(name,nextValue)
}
}
7.Fragment与多根节点模板
vue3支持在template模板中有多个根节点,原理是使用Fragment作为vnode类型来描述多根节点,在渲染时只渲染Fragment的子节点,卸载时也只卸载它的子节点:
const Fragment = Symbol();
const vnode = {
type:Fragment,
children:[
{type:"li",children:"text1"},
{type:"li",children:"text2"}
{type:"li",children:"text3"}
]
}
function patch(n1,n2,container){
if(n2.type === Fragment){
//如果旧节点不存在,则将Fragment的子节点依次挂载即可
if(!n1){
n2.children.forEach(item => {patch(null,c,container)})
}else{
patchChildren(n1,n2,container)
}
}
}
function unmount(vnode){
if(vnode.type === Fragment){
vnode.children.forEach(item => unmount(item));
return
}
const parent = vnode.el.parentNode;
if(parent){
parent.removeChild(vnode.el);
}
}