虚拟dom是将dom结构表示成一个对象,当组件的状态发生更改时,新的虚拟dom会与dom进行比较,只将修改的部分替换掉,而不需要重新渲染整个dom。
下面我们将分为四步逐步实现我们的虚拟dom:
1、创建hyperscriptfunction来实现dom渲染
2、创建一个简单的应用程序,使用hyperscript来渲染
3、使用虚拟dom实现动态渲染
4、实现diffing算法
hyperscript
在这一步中,先看一下如何用它构建简单的 DOM,在进一步的步骤中,我们将开发它来制作虚拟 DOM。
function hyperscript(nodeName, rootNode, attr, ...children) {
const $el = document.createElement(nodeName);
for (let key in attr) {
$el.setAttribute(key, attr[key]);
}
// $el.innerHTML=children.join()
children.forEach(child => {
if (typeof child === 'string') {
$el.appendChild(document.createTextNode(child));
} else {
$el.appendChild(child);
}
});
rootNode && rootNode.appendChild($el)
return $el;
}
hyperscript首先是创建了一个简单的dom节点并带有nodeName、attributes、children,渲染children可以使用递归的方式,这里只是简单实现看效果,难点通通扔到后面。下面来调用这个hyperscript
hyperscript(
'div',
document.body,
null,
'Hello, vDOM'
);
结果:
创建一个简单的应用
const App = (props) => {
return hyperscript('div', document.body, { class: 'app'},
hyperscript('h1', null, null, 'Simple vDOM'),
hyperscript(
'ul', null, null,
...props.map(item => hyperscript('li', null, null, item))
));
}
let currentApp;
const render= (state) => {
const newApp = App(state);
currentApp ? document.body.replaceChild(newApp, currentApp) : document.body.appendChild(newApp);
currentApp = newApp;
};
let state = [
'🌑', '🌒', '🌓','🌔', '🌕', '🌖','🌗', '🌘', '🌙'
];
setInterval(() => {
state = [...state, state[Math.floor(Math.random()*state.length)]];
render(state);
},1000);
App创建了一个div节点,并带有一个h1标题和一个ul列表,每隔一秒钟向props中添加一个表情,并重新执行App,效果懒得录屏了,贴张图算了。简单描述一下就是没过一秒整个div都会重新被渲染
实现 vDOM 渲染
之前已经提到过了,所谓的vDOM本质上是一个结构类似dom的object,所以我们先重构一下hyperscript,非常简单的重构完成了。
function hyperscript(nodeName, attr, ...children) {
return {nodeName, attr, children}
}
可以再console里面打印一下结果,垃圾网络毁我青春不传了
下面实现以下渲染函数,将object转为dom
const renderNode = vnode => {
let $el;
const {nodeName, attr, children} = vnode;
if (vnode.split) return document.createTextNode(vnode);
$el = document.createElement(nodeName);
for (let key in attr) {
$el.setAttribute(key, attr[key]);
}
children.forEach(child => {
if (typeof child === 'string') {
$el.appendChild(document.createTextNode(child));
} else {
$el.appendChild(child);
}
});
return $el;
}
让我一步一步解释它的作用:
1、使用解构我们检索虚拟节点的节点名称、属性和子节点
2、如果 vnode 是文本(我们可以通过 vnode.split 检查)然后我们返回文本节点
3、否则,我们使用 nodeName 创建一个元素并从属性对象设置其属性
4、如果有的话,为child节点做同样的事情
下面把整体的代码贴一下
function hyperscript(nodeName, attr, ...children) {
return {nodeName, attr, children}
}
const App = (props) => {
return renderNode(hyperscript('div', { class: 'app'},
renderNode(hyperscript('h1', null, 'Simple vDOM')),
renderNode(hyperscript(
'ul', null,
...props.map(item => renderNode(hyperscript('li', null, item)))
))));
}
let state = [
'🌑', '🌒', '🌓','🌔', '🌕', '🌖','🌗', '🌘', '🌙'
];
const renderNode = vnode => {
let $el;
const {nodeName, attr, children} = vnode;
if (vnode.split) return document.createTextNode(vnode);
$el = document.createElement(nodeName);
for (let key in attr) {
$el.setAttribute(key, attr[key]);
}
children.forEach(child => {
if (typeof child === 'string') {
$el.appendChild(document.createTextNode(child));
} else {
$el.appendChild(child);
}
});
return $el;
}
let currentApp;
const render= (state) => {
const newApp = App(state);
currentApp ? document.body.replaceChild(newApp, currentApp) : document.body.appendChild(newApp);
currentApp = newApp;
};
setInterval(() => {
state = [...state, state[Math.floor(Math.random()*state.length)]];
render(state);
},1000);
到这里我们就实现了将虚拟dom(一个类似dom的object)转化为dom并渲染的功能。下面剩下的也就是细化和diff算法了。
前面几步结束后,我们会发现我们都是直接替换了整个div,H1标题并没有发生变化,但还是被我们重新渲染了,接下来我们将ul标签拆出来,创建一个单独的people component 来单独存放 emoji list,这里引入了render方法 —— 它实际上创建了我们用来渲染真实DOM的虚拟DOM。
class People extends Component{
constructor(props) {
super(props);
this.state = {
list: [
'🌑', '🌒', '🌓','🌔', '🌕', '🌖','🌗', '🌘', '🌙'
]
};
}
render(props, state) {
return hyperscript(
'ul', null,
...state.list.map(item => hyperscript('li', null, item))
)
}
}
现在需要一个函数用来设置emoji list的值,为其列表添加值,并且这也是每一个组件都需要的,所以直接写一个父类,让其他类都集成于他。
class Component {
constructor(props) {
this.props = props
this.state = {}
}
setState(state) {
this.state = Object.assign({}, state);
renderComponent(this)
}
}
基本上,我们只是使用 props 和 state 字段启动我们的组件并实现 setState 方法,该方法基本上重写我们的state并调用其自身的 renderComponent(重新渲染) ,将app、people都加上对component的继承,接下来实现这个renderComponent,他的用处就是重新渲染传入的组件。需要做三件事
1、获取更改前的dom(存储在component.base)
2、调用组件的render方法渲染出虚拟dom
3、获取父节点 并使用新的去替换掉旧的孩子节点
const renderComponent = (component, parent) => {
const oldBase = component.base;
component.base = renderNode(
component.render(component.props, component.state)
)
if (parent) {
parent.appendChild(component.base);
} else {
oldBase.parentNode.replaceChild(component.base, oldBase);
}
}
接下来需要rendernode做一定的适配性的改动,作用还是原来的作用,现在nodeName 不仅可以采用字符串值,还可以采用另一个组件/函数进行渲染,在我的示例中,这是 People 组件, 我们需要确保我们正确处理它们 (正确处理:能够渲染该组件何其子组件)。 这是我们更新的 renderNode 实现:
const renderNode = vnode => {
let $el;
const {nodeName, attr, children} = vnode;
if (vnode.split) return document.createTextNode(vnode);
if (typeof nodeName === 'string') {
$el = document.createElement(nodeName);
for (let key in attr) {
$el.setAttribute(key, attr[key]);
}
} else if (typeof nodeName === 'function') {
const component = new nodeName(attr);
$el = renderNode(component.render(component.props, component.state));
component.base = $el;
}
(children || []).forEach(child => $el.appendChild(renderNode(child)))
return $el;
}
现在App就不再像之前那么复杂了,就只处理dom元素就好,
class App extends Component {
render() {
return hyperscript('div', { class: 'app' },
hyperscript('h1', null, 'Simple vDOM'),
hyperscript(People)
)
}
};
将之前的setInterval插入到people中,现在的效果就是,每次添加一个emoji就只有ul变了二h1并不会被重新渲染,传个图片太难了我放弃了。
差分算法
在我们本次项目中,只需要实现一下几个功能
1、如果是第一次被渲染,也就是说没有真实的dom,那么就直接插入到父节点下面
2、如果存在dom,使用 component.render 方法创建虚拟 DOM,然后将其与当前 DOM 状态(我们保存在 component.base 字段中)进行比较,并将 diff 的结果再次保存在其中。diff 函数应该检查是否有任何新的孩子,如果是,我们就append它。
const diff = (dom, vnode, parent) => {
if (dom) {
if (typeof vnode === 'string') {
dom.nodeValue = vnode
return dom
}
if (typeof vnode.nodeName === 'function') {
const component = new vnode.nodeName(vnode.attributes)
const rendered = component.render(component.props, component.state)
diff(dom, rendered)
return dom
}
if (vnode.children.length !== dom.childNodes.length) {
console.log(dom.childNodes.length);
console.log(dom.childNodes);
dom.appendChild(
renderNode(vnode.children[vnode.children.length - 1])
)
}
dom.childNodes.forEach((child, i) => diff(child, vnode.children[i]))
return dom
} else {
const newDom = renderNode(vnode)
parent.appendChild(newDom)
return newDom
}
}
更新一张效果图片
总结
真实的react还需要对比类型、属性、children。。这是一个简单到不能再简单的例子,与真实的react还相差甚远,实现一个虚拟dom不是说直接把虚拟dom完整的写出来,而是为了更好的理解其中的概念和思想。下面是最后的一个完整的代码:
function hyperscript(nodeName, attr, ...children) {
return {nodeName, attr, children}
}
class Component {
constructor(props) {
this.props = props
this.state = {}
}
setState(state) {
this.state = Object.assign({}, state);
renderComponent(this)
}
}
class People extends Component{
constructor(props) {
super(props);
this.state = {
list: [
'🌑', '🌒', '🌓','🌔', '🌕', '🌖','🌗', '🌘', '🌙'
]
};
this.timer = setInterval(_ => {
this.setState({
list: [...this.state.list, this.state.list[Math.floor(Math.random()*this.state.list.length)]]
});
}, 1000);
}
render(props, state) {
return hyperscript(
'ul', null,
...state.list.map(item => hyperscript('li', null, item))
)
}
}
class App extends Component {
render() {
return hyperscript('div', { class: 'app' },
hyperscript('h1', null, 'Simple vDOM'),
hyperscript(People)
)
}
};
const renderComponent = (component) => {
let rendered = component.render(component.props, component.state);
component.base = diff(component.base, rendered)
}
const renderNode = vnode => {
let $el;
const {nodeName, attr, children} = vnode;
if (vnode.split) return document.createTextNode(vnode);
if (typeof nodeName === 'string') {
$el = document.createElement(nodeName);
for (let key in attr) {
$el.setAttribute(key, attr[key]);
}
} else if (typeof nodeName === 'function') {
const component = new nodeName(attr);
$el = renderNode(component.render(component.props, component.state));
component.base = $el;
}
(children || []).forEach(child => $el.appendChild(renderNode(child)))
return $el;
}
const diff = (dom, vnode, parent) => {
if (dom) {
if (typeof vnode === 'string') {
dom.nodeValue = vnode
return dom
}
if (typeof vnode.nodeName === 'function') {
const component = new vnode.nodeName(vnode.attributes)
const rendered = component.render(component.props, component.state)
diff(dom, rendered)
return dom
}
if (vnode.children.length !== dom.childNodes.length) {
console.log(dom.childNodes.length);
console.log(dom.childNodes);
dom.appendChild(
renderNode(vnode.children[vnode.children.length - 1])
)
}
dom.childNodes.forEach((child, i) => diff(child, vnode.children[i]))
return dom
} else {
const newDom = renderNode(vnode)
parent.appendChild(newDom)
return newDom
}
}
const render = (vnode, parent) => {
diff(undefined, vnode, parent)
}
render(hyperscript(App), document.body);