目的
这次主要目的是实现vue3
和react
框架中的主要的vnode
虚拟节点的初步简单实现和如何渲染挂在到真实的dom节点上面
虚拟dom
- 为什么vue和react框架中要用虚拟dom?
这些很多文章都讲过,减少没必要的dom渲染,提高性能,并且利用框架提供的数据双向绑定语法,开发者可以只关注代码实现逻辑之类的。这里贴篇博客,自己记录一下
vue核心之虚拟DOM(vdom)
- vnode
通过h函数或者createElement函数创建返回的对象。他并不是一个真实的dom对象,但是存储要渲染的真实dom节点的全部信息,可以告诉框架如何渲染真实dom节点及其子节点,我们把这样的节点描述为虚拟节点 (Virtual Node)
,也常简写它为VNode
。虚拟 DOM
是我们对由 Vue
组件树建立起来的整个 VNode
树的称呼。
VNode
这里我们简单只实现下面四种类型
划分VNode
种类
- Element
element
对应普通元素,使用document.createElement
创建。type
指签名,props
指元素属性,children
指子元素,可以为字符串或者数组。为字符串时代表只有一个文本子节点。
//类型定义
{
type: string,
props: Object,
children: string | Vnode[]
}
//举例
{
type: 'div',
props: {class: 'a'},
children: 'hello'
}
复制代码
- Text
Text
对应的是文本节点,使用document.createTextNode
创建。type
所以应定义为一个Symbol
,props
为空,children
为字符串,指具体的内容。
//类型定义
{
type: Symbol,
props: null,
children: string
}
复制代码
- Fragment
Fragment
是一个不会真实渲染的节点。相当于我们在vue中使用的template
和React中的Fragment
。type
应定为一个Symbol
,props
为空, children
子节点为数组。最后渲染时子节点会挂载到Fragment的父节点上面
//类型定义
{
type: Symbol,
props: null,
children: Vnode[]
}
复制代码
- Component
Component
是组件,组件有自己特殊的一套渲染方法,但是组件的最终参数,也是上面三种Vnode的集合。组件的type
,就是定义组件的对象,props
即是外部传入的props数据,children
即是组件的slot
(这里就不实现了)
{
type: Object,
props: Object,
children: null
}
//示例
{
type: {
template: `{{msg}} {{name}}`,
props: ['name'],
setup(){
return {
msg: 'hello'
}
}
},
props: {name: 'world'}
}
复制代码
ShapeFlags
给每一个Vnode
一个标记他的类型和children
类型。利用位运算
左移量不同来标识不同的Vnode
节点,利用|
运算来标识所拥有的子节点类型,利用 &
运算来Vnode
的类型和子节点类型,以下所示
//runtime/vnode.js
export const ShapeFlags = {
ELEMENT: 1, //00000001
TEXT: 1 << 1, //00000010
FRAGMENT: 1 << 2, //00000100
COMPONENT: 1 << 3, //00001000
TEXT_CHILDREN: 1 << 4, //00010000
ARRAY_CHILDREN: 1 << 5, //00100000
CHILDREN: (1 << 4) | (1 << 5), //00110000
};
复制代码
另外定义两个 Text
和 Fragment
全局 Symbol标识,用来代表VNode Text
和Fragment
类型
//runtime/vnode.js
export const Text = Symbol('Text');
export const Fragment = Symbol('Fragment');
复制代码
h()
实现
h
函数,或者叫createVNodeElement
是用来创建Vnode
的一个方法,接收type
, props
, children
三个属性,返回Vnode
对象
//runtime/vnode.js
/**
* @param {string | Object | Text | Fragment} type ;
* @param {Object | null} props
* @param {string |number | Array | null} children
* @returns VNode
*/
export function h(type, props, children) {
//判断类型
let shapeFlag = 0;
//是否是字符串,是的话标识为Element类型
if (isString(type)) {
shapeFlag = ShapeFlags.ELEMENT;
//是否是文本类型
} else if (type === Text) {
shapeFlag = ShapeFlags.TEXT;
} else if (type === Fragment) {
//是否是Fragment类型
shapeFlag = ShapeFlags.FRAGMENT;
} else {
//剩下就是Component类型
shapeFlag = ShapeFlags.COMPONENT;
}
//判断children 是否是文本类型还是数组类型
if (isString(children) || isNumber(children)) {
shapeFlag |= ShapeFlags.TEXT_CHILDREN;
children = children.toString();
} else if (isArray(children)) {
shapeFlag |= ShapeFlags.ARRAY_CHILDREN;
}
//返回VNode 对象
return {
type,
props,
children,
shapeFlag,
};
}
//utils/index.js
export function isString(target) {
return typeof target === 'string';
}
export function isNumber(target) {
return typeof target === 'number';
}
export function isBoolean(target) {
return typeof target === 'boolean';
}
复制代码
render()
实现
实现了构建VNode
之后,我们要解析VNode
渲染真实的dom
节点,并且挂载到真实的dom
节点上面
//runtime/render.js
/**
* 将虚拟dom节点挂载到真实的dom 节点上
* @param {Object} vnode
* @param {HTMLElement} container 父容器dom节点
*/
export function render(vnode, container) {
mount(vnode, container);
}
复制代码
实现mount()
将虚拟节点VNode
到container
上面,
利用shapeFlag
判断vnode
类型,然后根据类型渲染为不同节点
//runtime/render.js
/**
* 将虚拟dom节点挂载到真实的dom 节点上
* @param {Object} vnode
* @param {HTMLElement} container 父容器dom节点
*/
function mount(vnode, container) {
//取出节点类型
const { shapeFlag } = vnode;
//是否是Element类型
if (shapeFlag & ShapeFlags.ELEMENT) {
mountElement(vnode, container);
} else if (shapeFlag & ShapeFlags.TEXT) {
//是否是文本类型
mountTextNode(vnode, container);
//是否是Fragment类型
} else if (shapeFlag & ShapeFlags.FRAGMENT) {
mountFragment(vnode, container);
} else {
//剩下是Component 类型
mountComponent(vnode, container);
}
}
复制代码
接下来就是实现各个类型具体的挂载方法 首先实现简单的文本节点挂载
//runtime/render.js
function mountTextNode(vnode, container) {
//创建文本dom节点
const textNode = document.createTextNode(vnode.children);
//挂载到真实父容器上面
container.appendChild(textNode);
}
复制代码
然后是 mountFragment()
方法实现
//runtime/render.js
function mountFragment(vnode, container) {
//将子节点 挂载到fragment的父节点上面
mountChildren(vnode, container);
}
function mountChildren(vnode, container) {
const { shapeFlag, children } = vnode;
//判断子节点是否是文本节点
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
mountTextNode(vnode, container);
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
//子节点是数组的话 利用mount将依次数组元素挂载到 fragment父节点下面
children.forEach((child) => {
mount(child, container);
});
}
}
复制代码
接下来实现mountElement()
方法挂载
- 创建dom元素 挂载dom元素到父节点
- 将props属性挂载到dom节点上面去
- 挂载子节点
//runtime/render.js
function mountElement(vnode, container) {
//取出VNode 类型 和 属性对象
const { type, props } = vnode;
//创建对应 dom 元素
const el = document.createElement(type);
//挂载props 属性 到dom 对象上面
mountProps(props, el);
//挂载子节点
mountChildren(vnode, el);
//将自身挂载到父节点上
container.appendChild(el);
}
复制代码
首先我们发现这里多了一个mountProps()
方法 该方法是将我们给的属性对象挂载到 dom
节点上面去,props 对象类似下面这种
//runtime/render.js
{
class: 'a b',
style: {
color: 'red',
fontSize: '14px',
},
onClick: () => console.log('click'),
checked: '',
custom: false
}
复制代码
将上面的class
、style
、click
事件 都挂载到对应的dom节点上面,这里我们class
只取字符串类型style
只能是对象类型,vue
事件只能是on
开头,事件名第一个字母大写。
//runtime/render.js
function mountProps(props, el) {
for(const key in props) {
const value = props[value];
switch(key) {
case 'class':
el.className = value;
break;
case 'style':
for(const styleName in value){
el.style[styleName] = value[styleName];
}
break;
default:
if(/^on[^a-z]/.test(key)) {
const eventName = key.slice(-2).toLowerCase();
el.addEventListener(eventName, value);
}
break;
}
}
}
复制代码
但是像checked
这种dom元素自带的Dom properties
和 cutsom
这种自己设置的attributes
该怎么设置呢?
渲染器挂载之Attributes 和 DOM Properties处理
看完之后, 我们对这两个处理如下设置
//runtime/render.js
const domPropsRE = /[A-Z]|^(value | checked | selected | muted | disabled)$/;
function mountProps(props, el) {
for(const key in props) {
const value = props[value];
switch(key) {
case 'class':
el.className = value;
break;
case 'style':
for(const styleName in value){
el.style[styleName] = value[styleName];
}
break;
default:
if(/^on[^a-z]/.test(key)) {
const eventName = key.slice(-2).toLowerCase();
el.addEventListener(eventName, value);
} else if(domPropsRE.test(key)) {
//判断是否是dom 的特殊 attributes
el[key] = value;
} else {
//不是的话 直接用setAttribute()设置
el.setAttribute(key, value);
}
break;
}
}
}
复制代码
但是如果 碰到如下节点
<input type="checkbox" checked />
复制代码
他的props 对象是:
{ "checked": "" }
复制代码
它的值是空字符串,按照上面方法处理会直接给checked
设置为空字符串,空字符串它代表的值是false
但是checked
的值应该是true
所以还要加一个特殊判断
//满足正则的 做为 domProp 赋值
if(domPropsRE.test(key)) {
//例如 {ckecked: ""} 还有判断它的原来值是否是布尔类型
if(value === '' && typeof el[key] === 'boolean') {
value = true;
}
el[key] = value;
}
复制代码
还要考虑以下这种情况:
自定义的布尔类型custom属性, 如果我们传入下面这个props, 希望将custom 设置为false
{ "custom": false }
复制代码
这个时候我们利用setAttribute
会让false
变为"false"
, 其结果仍然为true
,所以我们仍需做以下判断,并且使用removeAttribute
移除custom
if (domPropsRE.test(key)) {
...
} else {
// 例如 自定义属性 {custom: ''} 应应用setAttribute设置为<input custom/>
// 而 {custom: null} 应用removeAttribute 设置为 <input />
if (value == null || value === false) {
el.removeAttribute(key);
} else {
el.setAttribute(key, value);
}
}
复制代码
以上就简单实现了虚拟dom的创建和挂载。接下来我们写代码测试一下结果
首先导出相关代码
//runtime/index.js
export { h, Text, Fragment } from './vnode';
export { render } from './render';
复制代码
//index.js
import { render, h, Text, Fragment } from './runtime';
const vnode = h(
'div',
{
class: 'a b',
style: {
border: '1px solid',
fontSize: '14px',
backgroundColor: 'white',
},
onClick: () => console.log('click'),
id: 'foo',
checked: '',
custom: false,
},
[
h('ul', null, [
h('li', { style: { color: 'red' } }, 1),
h('li', null, 2),
h('li', { style: { color: 'green' } }, 3),
h(Fragment, null, [h('li', null, '4'), h('li')]),
h('li', null, [h(Text, null, 'Hello Wrold')]),
]),
]
);
render(vnode, document.body);
复制代码
结果
OK !!