背景
- 这个写起来有点麻烦,难度还行,一步步总结一下。
流程
一、 准备工作
- 先建个html和一个js文件。让html引入这个js文件。
二、渲染vnode到页面
const root = document.querySelector('#root');
const vnode = h('ul', { id: 'container' },
h('li', { style: { backgroundColor: '#110000' }, key: 'A' }, 'A'),
h('li', { style: { backgroundColor: '#440000' }, key: 'B' }, 'B'),
h('li', { style: { backgroundColor: '#770000' }, key: 'C' }, 'C'),
h('li', { style: { backgroundColor: '#AA0000' }, key: 'D' }, 'D'),
)
mount(vnode,root)
- 我们拿到一个vnode后需要通过h函数做成虚拟dom对象,然后通过amount函数挂在到真实dom即可。
- 所以需要写一下h函数和mount函数。
h函数
const VNODE_TYPE = 'VNODE_TYPE';
const hasOwnProperty = Object.prototype.hasOwnProperty
function convertVnode(type,props={},key,children=[],text,domElement) {
return {
_type:VNODE_TYPE,
type,props,key,children,text,domElement
}
}
function h(type,attr,...children) {
let key;
let props={}
if(attr.key){
key = attr.key;
}
for(let attName in attr){
if(hasOwnProperty.call(attr,attName)&&attName!='key'){
props[attName]=attr[attName]
}
}
return convertVnode(type,props,key,children.map((child)=>{
return typeof child =='string'||typeof child =='number'?
convertVnode(undefined,undefined,undefined,undefined,child):child
}))
}
- 功能很简单,就是把传来的vnode的属性分解一下,做成一个对象,这个对象有个_type标记,然后上面各种属性。
- 这里用hasOwnProperty是可以就看它自己的属性,并且能防止有重名属性导致hasOwnProperty不工作。
mount函数
function updateAttr(vnode,oldprops=[]) {
let newprops = vnode.props
for(let attr in newprops){
if(attr === 'style'){
let styleObj = newprops[attr]
for(let styleName in styleObj){//styleObj样式对象
vnode.domElement.style[styleName]=styleObj[styleName]
}
}else{//一般样式直接弄
vnode.domElement[attr]=newprops[attr]
}
}
}
function creatRealDom(vnode) {
let {type,children}=vnode
if(type){
let dom = vnode.domElement=document.createElement(type)
updateAttr(vnode)
if(Array.isArray(children)){
children.forEach((child)=>dom.appendChild(creatRealDom(child)))
}
}else{
vnode.domElement = document.createTextNode(vnode.text)
}
return vnode.domElement
}
function mount(vnode,container) {
let realDom = creatRealDom(vnode)
container.appendChild(realDom)
}
- 通过creatRealDom函数把真实dom节点放在vnode的domElement属性上,通过updateAttr更新属性,如果还有子元素,那么把子元素创建真实dom,同时加入其真实dom的子元素。
- 这段也没太难的地方,主要就是靠细心和有思路即可写出来。
- 然后打开浏览器可以看一下是否是个不同底色的列表,如果是那就成功了。
三、用新vnode替换老vnode
- 首先,把数据改成一个新的vnode一个老的vnode。先做成如果节点不一样,整个全换掉。
const oldVnode = h('ul', { id: 'container' },
h('li', { style: { backgroundColor: '#110000' }, key: 'A' }, 'A'),
h('li', { style: { backgroundColor: '#440000' }, key: 'B' }, 'B'),
h('li', { style: { backgroundColor: '#770000' }, key: 'C' }, 'C'),
h('li', { style: { backgroundColor: '#AA0000' }, key: 'D' }, 'D'),
)
const newVnode = h('div',{},'xx')
- 然后改一下挂载逻辑,挂在在dom上后进行用patch打补丁修改以前dom
- 为了看出区别,用settimeout延时这个函数。
mount(oldVnode,root)
setTimeout(() => {
patch(oldVnode,newVnode)
}, 3000);
patch函数
function patch(oldVnode,newVnode) {
//如果类型不一样,直接替换
if(newVnode.type!==oldVnode.type){//
return oldVnode.domElement.parentNode.replaceChild(creatRealDom(newVnode),oldVnode.domElement)
}
}
- 这里替换操作需要注意,是拿到它父亲的真实dom节点,然后替换儿子。
- 一开始我这有个地方没缓过来,以为还需要修改oldVnode上属性。其实是不需要修改的,因为你需要生成的虚拟节点已经就是newVnode传来了,再把oldVnode上修改毫无意义。就是如果这2类型不一样,替换就完了,替换后老虚拟节点相当于独立于真实dom外。如果类型一样,这2个虚拟节点实际指的是一个dom,无论更改哪个都会影响视图。
- 所以这时候可以打开浏览器看看是不是成功替换了。
- 然后需要做下一个节点类型一样,文本不一样,更新属性,并能去查找儿子不一样的地方。
- 测试例子改成如下:
const newVnode = h('ul', { id: 'container' },
h('li', { style: { backgroundColor: '#AA0000' }, key: 'E' }, 'E1'),
h('li', { style: { backgroundColor: '#440000' }, key: 'B' }, 'B1'),
h('li', { style: { backgroundColor: '#110000' }, key: 'f' }, 's1'),
h('li', { style: { backgroundColor: '#AA0000' }, key: 'D' }, 'D1'),
h('li', { style: { backgroundColor: '#770000' }, key: 'A' }, 'A1'),
);
const oldVnode = h('ul', { id: 'container' },
h('li', { style: { backgroundColor: '#110000' }, key: 'A' }, 'A'),
h('li', { style: { backgroundColor: '#440000' }, key: 'B' }, 'B'),
h('li', { style: { backgroundColor: '#770000' }, key: 'C' }, 'C'),
h('li', { style: { backgroundColor: '#AA0000' }, key: 'D' }, 'D'),
)
- 父节点和子节点一样,但是属性和text不一样。
- 我们需要对patch函数增加逻辑和更新属性函数增加逻辑。
function patch(oldVnode,newVnode) {
//如果类型不一样,直接替换
if(newVnode.type!==oldVnode.type){
return oldVnode.domElement.parentNode.replaceChild(creatRealDom(newVnode),oldVnode.domElement)
}
//节点类型一样,文本赋值,第三个属性不是儿子就是文本,是文本返回
if(newVnode.text!==undefined){
return oldVnode.domElement.textContent=newVnode.text
}
//节点类型一样,更新属性
let domElement = newVnode.domElement = oldVnode.domElement//先让newvnode上能操作dom
updateAttr(newVnode,oldVnode.props)
//节点类型一样,查找儿子
let oldChildren = oldVnode.children
let newChildren = newVnode.children
//分三种情况考虑
//老的有儿子,新的没儿子,删除老的
if(oldChildren.length>0 && newChildren.length>0){
updateChildren(domElement,newChildren,oldChildren)
}else if(oldChildren.length>0){//如果不是2个都大于0 那么就是老的有儿子新的没儿子
domElement.innerHTML=''//改内部html即可删除儿子
}else if(newChildren.length>0){//新的有老的没有
for(let i=0;i<newChildren.length;i++){
domElement.appendChild(creatRealDom(newChildren[i]))
}
}
}
function updateAttr(vnode,oldprops=[]) {
let oldStyle = oldprops.style ||{}
let newStyle = vnode.props.style ||{}
for(let attr in oldStyle){//循环老的,如果老的有新的没有,删除
if(!newStyle[attr]){
vnode.domElement.style[attr]=''
}
}
for(let attrName in oldprops){//除style属性 老的有新的没有删除
if(!vnode.props[attrName]){
delete vnode.domElement[attrName]
}
}
let newprops = vnode.props
for(let attr in newprops){
if(attr === 'style'){
let styleObj = newprops[attr]
for(let styleName in styleObj){
vnode.domElement.style[styleName]=styleObj[styleName]
}
}else{//一般样式直接弄
vnode.domElement[attr]=newprops[attr]
}
}
}
- 更新属性套用前面的方法,先把老的有新的没有的属性全删掉,再统一添加。
- 最重要的就是两个孩子都大于0的updateChildren方法了。
updateChildren
function isSameNode(oldNode,newNode) {//key和type都一样
return oldNode.key === newNode.key && oldNode.type === newNode.type;
}
function createKeyToIndexMap(children) {
let map = {};
for (let i = 0; i < children.length; i++) {
let key = children[i].key;
if (key) {
map[key] = i;
}
}//遍历一遍老节点获取索引和key
return map;
}
function updateChildren(parentDomElement,newChildren,oldChildren) {
//如果都有,先对新旧的前后进行相互比较。
//做开始结束指针
let oldStartIndex = 0
let oldStartNode = oldChildren[0]
let newStartIndex = 0
let newStartNode = newChildren[0]
let oldEndIndex = oldChildren.length-1
let oldEndNode = oldChildren[oldChildren.length-1]
let newEndIndex = newChildren.length-1
let newEndNode = newChildren[newChildren.length-1]
//这是个递归,所以停止条件就是2个指针都完全重合。
//循环逻辑下,一方走完就出while,不然没法比。
while(oldStartIndex<=oldEndIndex&&newStartIndex<=newEndIndex){
if(!oldStartNode){//如果取不到,说明这个节点被移动了,获取下一个
oldStartNode=oldChildren[++oldStartIndex]
}else if(!oldEndNode){
oldEndNode = oldChildren[--oldEndIndex]
}
//头比头,如果相同,那么递归Patch,加到dom上,更新索引和节点
else if(isSameNode(oldStartNode,newStartNode)){
patch(oldStartNode,newStartNode)
oldStartNode = oldChildren[++oldStartIndex];
newStartNode = newChildren[++newStartIndex];
}else if(isSameNode(oldEndNode,newEndNode)){
patch(oldEndNode,newEndNode)
oldEndNode = oldChildren[--oldEndIndex]
newEndNode = newChildren[--newEndIndex]
}else if(isSameNode(oldStartNode,newEndNode)){//头和尾
patch(oldStartNode,newEndNode)
//移动位置,把老的头移到老的尾,然后end索引少一
parentDomElement.insertBefore(oldStartNode.domElement,oldEndNode.domElement.nextSibiling)
oldStartNode = oldChildren[++oldStartIndex]
newEndNode = newChildren[--newEndIndex]
}else if(isSameNode(oldEndNode,newStartNode)){
patch(oldEndNode,newStartNode)
//把老尾移动到老头
parentDomElement.insertBefore(oldEndNode.domElement,oldStartNode.domElement)
newStartNode = newChildren[++newStartIndex]
oldEndNode = oldChildren[--oldEndIndex]
}else{//全都对不上,使用key寻找
let oldIndexByKey = createKeyToIndexMap(oldChildren)[newStartNode.key]
if(oldIndexByKey==null){//找不到
parentDomElement.insertBefore(creatRealDom(newStartNode),oldStartNode.domElement)
}else{//如果找到了,那么就把老的索引那个移动到最前面。
let oldMoveNode = oldChildren[oldIndexByKey]
if(oldMoveNode.type!==newStartNode.type){//类型不一样按找不到处理
parentDomElement.insertBefore(creatRealDom(newStartNode),oldStartNode.domElement)
}else{
patch(oldMoveNode,newStartNode)
oldChildren[oldIndexByKey]=undefined//移动后让节点变成空
parentDomElement.insertBefore(oldMoveNode,oldStartNode.domElement)
}
}
newStartNode = newChildren[++newStartIndex]
}
}
if(newStartIndex<=newEndIndex){//老走完 新没走完
let beforeDOMElement = newChildren[newEndIndex + 1] == null ? null : newChildren[newEndIndex + 1].domElement;
for(let i = newStartIndex;i<=newEndIndex;i++){
parentDomElement.insertBefore(creatRealDom(newChildren[i]),beforeDOMElement)
}
}
if(oldStartIndex<=oldEndIndex){//新走完,老没走完
for(let i = oldStartIndex;i<=oldEndIndex;i++){
parentDomElement.removeChild(oldChildren[i].domElement)
}
}
}
- 这里更新孩子走一个while循环,如果其中一对指针走完那么判断有没有多的或者少的节点。
- 在while循环里主要就是判断头和头,尾和尾,以及头和尾,尾和头四种情况是否相同,判断是根据type和key是否一样。
- 如果相同,那么把新的vdom更新老的vdom,移动索引,这里需要注意头和尾或者尾和头相等的需要移动老节点,这样才能保证顺序不会错。而insertbefore算纯粹移动,会干掉以前元素,最适合干这事。
- 如果四种情况都不符合,那就根据键值索引去比较老的Key和新的key相同元素,如果type也相同,那么就patch,不同,那么就创建真实dom插入前头。
- 另外还有几个容易逻辑遗漏的地方:
- 因为前面四种情况都是移动头或者尾,如果不符合四种情况就会移动中间,这样导致指针为空,所以while循环里要进行个判断指针为空的情况。
- 还有就是老的走完,新的没走完时的插入位置不是固定的,通过查找走完前索引来找到插入位置。
- 最后打开浏览器试验下,列表按顺序排列即ok。