资讯专栏INFORMATION COLUMN

vue2源码学习开胃菜——snabbdom源码学习(二)

BetaRabbit / 827人阅读

摘要:前言在上一章我们学习了,等模块,在这一篇我们将会学习到的核心功能和功能。如果父节点没变化,我们就比较所有同层的子节点,对这些子节点进行删除创建移位操作。只需要对两个进行判断是否相似,如果相似,则对他们进行操作,否则直接用替换。

前言

在上一章我们学习了,modules,vnode,h,htmldomapi,is等模块,在这一篇我们将会学习到
snabbdom的核心功能——patchVnode和updateChildren功能。

继续我们的snabbdom源码之旅 最终章 snabbdom!

首先我们先从简单的部分开始,比如一些工具函数,我将逐个来讲解他们的用处

sameNode

这个函数主要用于比较oldvnode与vnode同层次节点的比较,如果同层次节点的key和sel都相同
我们就可以保留这个节点,否则直接替换节点

function sameVnode(vnode1, vnode2) {
  return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;
}
createKeyToOldIdx

这个函数的功能十分简单,就是将oldvnode数组中位置对oldvnode.key的映射转换为oldvnode.key
对位置的映射

function createKeyToOldIdx(children, beginIdx, endIdx) {
  var i, map = {}, key;
  for (i = beginIdx; i <= endIdx; ++i) {
    key = children[i].key;
    if (isDef(key)) map[key] = i;
  }
  return map;
}
hook

snabbdom在全局下有6种类型的钩子,触发这些钩子时,会调用对应的函数对节点的状态进行更改
首先我们来看看有哪些钩子:

Name Triggered when Arguments to callback
pre the patch process begins (patch开始时触发) none
init a vnode has been added (vnode被创建时触发) vnode
create a DOM element has been created based on a vnode (vnode转换为真实DOM节点时触发 emptyVnode, vnode
insert an element has been inserted into the DOM (插入到DOM树时触发) vnode
prepatch an element is about to be patched (元素准备patch前触发) oldVnode, vnode
update an element is being updated (元素更新时触发) oldVnode, vnode
postpatch an element has been patched (元素patch完触发) oldVnode, vnode
destroy an element is directly or indirectly being removed (元素被删除时触发) vnode
remove an element is directly being removed from the DOM (元素从父节点删除时触发,和destory略有不同,remove只影响到被移除节点中最顶层的节点) vnode, removeCallback
post the patch process is done (patch完成后触发) none

然后,下面列出钩子对应的状态更新函数:

create => style,class,dataset,eventlistener,props,hero

update => style,class,dataset,eventlistener,props,hero

remove => style

destory => eventlistener,style,hero

pre => hero

post => hero

好了,简单的都看完了,接下来我们开始打大boss了,第一关就是init函数了

init

init函数有两个参数modules和api,其中modules是init依赖的模块,如attribute、props
、eventlistener这些模块,api则是对封装真实DOM操作的工具函数库,如果我们没有传入,则默认
使用snabbdom提供的htmldomapi。init还包含了许多vnode和真实DOM之间的操作和注册全局钩子,
还有patchVnode和updateChildren这两个重要功能,然后返回一个patch函数

注册全局钩子
     //注册钩子的回调,在发生状态变更时,触发对应属性变更
      for (i = 0; i < hooks.length; ++i) {
        cbs[hooks[i]] = [];
        for (j = 0; j < modules.length; ++j) {
          if (modules[j][hooks[i]] !== undefined) cbs[hooks[i]].push(modules[j][hooks[i]]);
        }
      }
emptyNodeAt

这个函数主要的功能是将一个真实DOM节点转化成vnode形式,

将转换为{sel:"div#a.b.c",data:{},children:[],text:undefined,elm:
}

     function emptyNodeAt(elm) {
        var id = elm.id ? "#" + elm.id : "";
        var c = elm.className ? "." + elm.className.split(" ").join(".") : "";
        return VNode(api.tagName(elm).toLowerCase() + id + c, {}, [], undefined, elm);
      }
createRmCb

我们知道当我们需要remove一个vnode时,会触发remove钩子作拦截器,只有在所有remove钩子
回调函数都触发完才会将节点从父节点删除,而这个函数提供的就是对remove钩子回调操作的计数功能

function createRmCb(childElm, listeners) {
    return function() {
      if (--listeners === 0) {
        var parent = api.parentNode(childElm);
        api.removeChild(parent, childElm);
      }
    };
  }
invokeDestoryHook

这个函数用于手动触发destory钩子回调,主要步骤如下:

先调用vnode上的destory

再调用全局下的destory

递归调用子vnode的destory

function invokeDestroyHook(vnode) {
  var i, j, data = vnode.data;
  if (isDef(data)) {
//先触发该节点上的destory回调
if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode);
//在触发全局下的destory回调
for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode);
//递归触发子节点的destory回调
if (isDef(i = vnode.children)) {
  for (j = 0; j < vnode.children.length; ++j) {
    invokeDestroyHook(vnode.children[j]);
  }
}
  }
}

removeVnodes

这个函数主要功能是批量删除DOM节点,需要配合invokeDestoryHook和createRmCb服用,效果更佳
主要步骤如下:

调用invokeDestoryHook以触发destory回调

调用createRmCb来开始对remove回调进行计数

删除DOM节点

  /**
   *
   * @param parentElm 父节点
   * @param vnodes  删除节点数组
   * @param startIdx  删除起始坐标
   * @param endIdx  删除结束坐标
   */
function removeVnodes(parentElm, vnodes, startIdx, endIdx) {
  for (; startIdx <= endIdx; ++startIdx) {
var i, listeners, rm, ch = vnodes[startIdx];
if (isDef(ch)) {
  if (isDef(ch.sel)) {
    //调用destroy钩子
    invokeDestroyHook(ch);
    //对全局remove钩子进行计数
    listeners = cbs.remove.length + 1;
    rm = createRmCb(ch.elm, listeners);
    //调用全局remove回调函数,并每次减少一个remove钩子计数
    for (i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm);
    //调用内部vnode.data.hook中的remove钩子(只有一个)
    if (isDef(i = ch.data) && isDef(i = i.hook) && isDef(i = i.remove)) {
      i(ch, rm);
    } else {
      //如果没有内部remove钩子,需要调用rm,确保能够remove节点
      rm();
    }
  } else { // Text node
    api.removeChild(parentElm, ch.elm);
  }
}
  }
}

createElm

就如太极有阴就有阳一样,既然我们有remove操作,肯定也有createelm的操作,这个函数主要功能
如下:

初始化vnode,调用init钩子

创建对应tagname的DOM element节点,并将vnode.sel中的id名和class名挂载上去

如果有子vnode,递归创建DOM element节点,并添加到父vnode对应的element节点上去,

否则如果有text属性,则创建text节点,并添加到父vnode对应的element节点上去

vnode转换成dom节点操作完成后,调用create钩子

如果vnode上有insert钩子,那么就将这个vnode放入insertedVnodeQueue中作记录,到时

再在全局批量调用insert钩子回调
function createElm(vnode, insertedVnodeQueue) {
   var i, data = vnode.data;
   if (isDef(data)) {
 //当节点上存在hook而且hook中有init钩子时,先调用init回调,对刚创建的vnode进行处理
 if (isDef(i = data.hook) && isDef(i = i.init)) {
   i(vnode);
   //获取init钩子修改后的数据
   data = vnode.data;
 }
   }
   var elm, children = vnode.children, sel = vnode.sel;
   if (isDef(sel)) {
 // Parse selector
 var hashIdx = sel.indexOf("#");
 //先id后class
 var dotIdx = sel.indexOf(".", hashIdx);
 var hash = hashIdx > 0 ? hashIdx : sel.length;
 var dot = dotIdx > 0 ? dotIdx : sel.length;
 var tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel;
 //创建一个DOM节点引用,并对其属性实例化
 elm = vnode.elm = isDef(data) && isDef(i = data.ns) ? api.createElementNS(i, tag): api.createElement(tag);
  //获取id名 #a --> a
 if (hash < dot) elm.id = sel.slice(hash + 1, dot);
 //获取类名,并格式化  .a.b --> a b
 if (dotIdx > 0) elm.className = sel.slice(dot + 1).replace(/./g, " ");
 //如果存在子元素Vnode节点,则递归将子元素节点插入到当前Vnode节点中,并将已插入的子元素节点在insertedVnodeQueue中作记录
 if (is.array(children)) {
   for (i = 0; i < children.length; ++i) {
     api.appendChild(elm, createElm(children[i], insertedVnodeQueue));
   }
   //如果存在子文本节点,则直接将其插入到当前Vnode节点
 } else if (is.primitive(vnode.text)) {
   api.appendChild(elm, api.createTextNode(vnode.text));
 }
 //当创建完毕后,触发全局create钩子回调
 for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
 i = vnode.data.hook; // Reuse variable
 if (isDef(i)) {
   if (i.create) i.create(emptyNode, vnode);
   //如果有insert钩子,则推进insertedVnodeQueue中作记录,从而实现批量插入触发insert回调
   if (i.insert) insertedVnodeQueue.push(vnode);
 }
   }
   //如果没声明选择器,则说明这个是一个text节点
   else {
 elm = vnode.elm = api.createTextNode(vnode.text);
   }
   return vnode.elm;
 }

addVnodes

这个函数十分简单,就是将vnode转换后的dom节点插入到dom树的指定位置中去

function addVnodes(parentElm, before, vnodes, startIdx, endIdx, insertedVnodeQueue) {
    for (; startIdx <= endIdx; ++startIdx) {
      api.insertBefore(parentElm, createElm(vnodes[startIdx], insertedVnodeQueue), before);
    }
  }

说完上面的节点工具函数之后,我们就开始看如何进行patch操作了,首先我们从patch,也就是init
返回的函数开始

patch

首先我们需要明确的一个是,如果按照传统的diff算法,那么为了找到最小变化,需要逐层逐层的去
搜索比较,这样时间复杂度将会达到 O(n^3)的级别,代价十分高,考虑到节点变化很少是跨层次的,
vdom采取的是一种简化的思路,只比较同层节点,如果不同,那么即使该节点的子节点没变化,我们
也不复用,直接将从父节点开始的子树全部删除,然后再重新创建节点添加到新的位置。如果父节点
没变化,我们就比较所有同层的子节点,对这些子节点进行删除、创建、移位操作。有了这个思想,
理解patch也十分简单了。patch只需要对两个vnode进行判断是否相似,如果相似,则对他们进行
patchVnode操作,否则直接用vnode替换oldvnode。

return function(oldVnode, vnode) {
    var i, elm, parent;
    //记录被插入的vnode队列,用于批触发insert
    var insertedVnodeQueue = [];
    //调用全局pre钩子
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
    //如果oldvnode是dom节点,转化为oldvnode
    if (isUndef(oldVnode.sel)) {
      oldVnode = emptyNodeAt(oldVnode);
    }
    //如果oldvnode与vnode相似,进行更新
    if (sameVnode(oldVnode, vnode)) {
      patchVnode(oldVnode, vnode, insertedVnodeQueue);
    } else {
      //否则,将vnode插入,并将oldvnode从其父节点上直接删除
      elm = oldVnode.elm;
      parent = api.parentNode(elm);

      createElm(vnode, insertedVnodeQueue);

      if (parent !== null) {
        api.insertBefore(parent, vnode.elm, api.nextSibling(elm));
        removeVnodes(parent, [oldVnode], 0, 0);
      }
    }
    //插入完后,调用被插入的vnode的insert钩子
    for (i = 0; i < insertedVnodeQueue.length; ++i) {
      insertedVnodeQueue[i].data.hook.insert(insertedVnodeQueue[i]);
    }
    //然后调用全局下的post钩子
    for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
    //返回vnode用作下次patch的oldvnode
    return vnode;
  };
patchVnode

真正对vnode内部patch的还是得靠patchVnode。让我们看看他到底做了什么?

function patchVnode(oldVnode, vnode, insertedVnodeQueue) {
    var i, hook;
    //在patch之前,先调用vnode.data的prepatch钩子
    if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) {
      i(oldVnode, vnode);
    }
    var elm = vnode.elm = oldVnode.elm, oldCh = oldVnode.children, ch = vnode.children;
    //如果oldvnode和vnode的引用相同,说明没发生任何变化直接返回,避免性能浪费
    if (oldVnode === vnode) return;
    //如果oldvnode和vnode不同,说明vnode有更新
    //如果vnode和oldvnode不相似则直接用vnode引用的DOM节点去替代oldvnode引用的旧节点
    if (!sameVnode(oldVnode, vnode)) {
      var parentElm = api.parentNode(oldVnode.elm);
      elm = createElm(vnode, insertedVnodeQueue);
      api.insertBefore(parentElm, elm, oldVnode.elm);
      removeVnodes(parentElm, [oldVnode], 0, 0);
      return;
    }
    //如果vnode和oldvnode相似,那么我们要对oldvnode本身进行更新
    if (isDef(vnode.data)) {
      //首先调用全局的update钩子,对vnode.elm本身属性进行更新
      for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
      //然后调用vnode.data里面的update钩子,再次对vnode.elm更新
      i = vnode.data.hook;
      if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode);
    }
    //如果vnode不是text节点
    if (isUndef(vnode.text)) {
      //如果vnode和oldVnode都有子节点
      if (isDef(oldCh) && isDef(ch)) {
        //当Vnode和oldvnode的子节点不同时,调用updatechilren函数,diff子节点
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
      }
      //如果vnode有子节点,oldvnode没子节点
      else if (isDef(ch)) {
        //oldvnode是text节点,则将elm的text清除
        if (isDef(oldVnode.text)) api.setTextContent(elm, "");
        //并添加vnode的children
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
      }
      //如果oldvnode有children,而vnode没children,则移除elm的children
      else if (isDef(oldCh)) {
        removeVnodes(elm, oldCh, 0, oldCh.length - 1);
      }
      //如果vnode和oldvnode都没chidlren,且vnode没text,则删除oldvnode的text
      else if (isDef(oldVnode.text)) {
        api.setTextContent(elm, "");
      }
    }

    //如果oldvnode的text和vnode的text不同,则更新为vnode的text
    else if (oldVnode.text !== vnode.text) {
      api.setTextContent(elm, vnode.text);
    }
    //patch完,触发postpatch钩子
    if (isDef(hook) && isDef(i = hook.postpatch)) {
      i(oldVnode, vnode);
    }
  }
updateChildren

对于同层的子节点,snabbdom主要有删除、创建的操作,同时通过移位的方法,达到最大复用存在
节点的目的,其中需要维护四个索引,分别是:

oldStartIdx => 旧头索引

oldEndIdx => 旧尾索引

newStartIdx => 新头索引

newEndIdx => 新尾索引

然后开始将旧子节点组和新子节点组进行逐一比对,直到遍历完任一子节点组,比对策略有5种:

oldStartVnode和newStartVnode进行比对,如果相似,则进行patch,然后新旧头索引都后移

oldEndVnode和newEndVnode进行比对,如果相似,则进行patch,然后新旧尾索引前移

oldStartVnode和newEndVnode进行比对,如果相似,则进行patch,将旧节点移位到最后

然后旧头索引后移,尾索引前移,为什么要这样做呢?我们思考一种情况,如旧节点为【5,1,2,3,4】
,新节点为【1,2,3,4,5】,如果缺乏这种判断,意味着需要先将5->1,1->2,2->3,3->4,4->5五
次删除插入操作,即使是有了key-index来复用,也会出现也会出现【5,1,2,3,4】->
【1,5,2,3,4】->【1,2,5,3,4】->【1,2,3,5,4】->【1,2,3,4,5】共4次操作,如果
有了这种判断,我们只需要将5插入到旧尾索引后面即可,从而实现右移

oldEndVnode和newStartVnode进行比对,处理和上面类似,只不过改为左移

如果以上情况都失败了,我们就只能复用key相同的节点了。首先我们要通过createKeyToOldIdx

创建key-index的映射,如果新节点在旧节点中不存在,我们将它插入到旧头索引节点前,
然后新头索引向后;如果新节点在就旧节点组中存在,先找到对应的旧节点,然后patch,并将
旧节点组中对应节点设置为undefined,代表已经遍历过了,不再遍历,否则可能存在重复
插入的问题,最后将节点移位到旧头索引节点之前,新头索引向后

遍历完之后,将剩余的新Vnode添加到最后一个新节点的位置后或者删除多余的旧节点

/**
   *
     * @param parentElm 父节点
     * @param oldCh 旧节点数组
     * @param newCh 新节点数组
     * @param insertedVnodeQueue
     */
  function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue) {

    var oldStartIdx = 0, newStartIdx = 0;
    var oldEndIdx = oldCh.length - 1;
    var oldStartVnode = oldCh[0];
    var oldEndVnode = oldCh[oldEndIdx];
    var newEndIdx = newCh.length - 1;
    var newStartVnode = newCh[0];
    var newEndVnode = newCh[newEndIdx];
    var oldKeyToIdx, idxInOld, elmToMove, before;

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx];
      }
      //如果旧头索引节点和新头索引节点相同,
      else if (sameVnode(oldStartVnode, newStartVnode)) {
        //对旧头索引节点和新头索引节点进行diff更新, 从而达到复用节点效果
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
        //旧头索引向后
        oldStartVnode = oldCh[++oldStartIdx];
        //新头索引向后
        newStartVnode = newCh[++newStartIdx];
      }
      //如果旧尾索引节点和新尾索引节点相似,可以复用
      else if (sameVnode(oldEndVnode, newEndVnode)) {
        //旧尾索引节点和新尾索引节点进行更新
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
        //旧尾索引向前
        oldEndVnode = oldCh[--oldEndIdx];
        //新尾索引向前
        newEndVnode = newCh[--newEndIdx];
      }
        //如果旧头索引节点和新头索引节点相似,可以通过移动来复用
        //如旧节点为【5,1,2,3,4】,新节点为【1,2,3,4,5】,如果缺乏这种判断,意味着
        //那样需要先将5->1,1->2,2->3,3->4,4->5五次删除插入操作,即使是有了key-index来复用,
        // 也会出现【5,1,2,3,4】->【1,5,2,3,4】->【1,2,5,3,4】->【1,2,3,5,4】->【1,2,3,4,5】
        // 共4次操作,如果有了这种判断,我们只需要将5插入到最后一次操作即可
      else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
        api.insertBefore(parentElm, oldStartVnode.elm, api.nextSibling(oldEndVnode.elm));
        oldStartVnode = oldCh[++oldStartIdx];
        newEndVnode = newCh[--newEndIdx];
      }
      //原理与上面相同
      else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
        api.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
        oldEndVnode = oldCh[--oldEndIdx];
        newStartVnode = newCh[++newStartIdx];
      }
      //如果上面的判断都不通过,我们就需要key-index表来达到最大程度复用了
      else {
        //如果不存在旧节点的key-index表,则创建
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
        //找到新节点在旧节点组中对应节点的位置
        idxInOld = oldKeyToIdx[newStartVnode.key];
        //如果新节点在旧节点中不存在,我们将它插入到旧头索引节点前,然后新头索引向后
        if (isUndef(idxInOld)) { // New element
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm);
          newStartVnode = newCh[++newStartIdx];
        } else {
          //如果新节点在就旧节点组中存在,先找到对应的旧节点
          elmToMove = oldCh[idxInOld];
          //先将新节点和对应旧节点作更新
          patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
          //然后将旧节点组中对应节点设置为undefined,代表已经遍历过了,不在遍历,否则可能存在重复插入的问题

          oldCh[idxInOld] = undefined;
          //插入到旧头索引节点之前
          api.insertBefore(parentElm, elmToMove.elm, oldStartVnode.elm);
          //新头索引向后
          newStartVnode = newCh[++newStartIdx];
        }
      }
    }
    //当旧头索引大于旧尾索引时,代表旧节点组已经遍历完,将剩余的新Vnode添加到最后一个新节点的位置后
    if (oldStartIdx > oldEndIdx) {
      before = isUndef(newCh[newEndIdx+1]) ? null : newCh[newEndIdx+1].elm;
      addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
    }
    //如果新节点组先遍历完,那么代表旧节点组中剩余节点都不需要,所以直接删除
    else if (newStartIdx > newEndIdx) {
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
  }

至此,snabbdom的主要功能就分析完了

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/88233.html

相关文章

  • Snabbdom.js(一)

    摘要:闲聊在学的过程中,虚拟应该是听的最多的概念之一,得知其是借鉴进行开发,故习之。以我的观点来看,多个相同元素渲染时,则需要为每个元素添加值。 闲聊:在学vue的过程中,虚拟dom应该是听的最多的概念之一,得知其是借鉴snabbdom.js进行开发,故习之。由于我工作处于IE8的环境,对ES6,TS这些知识的练习也只是浅尝辄止,而snabbdom.js从v.0.5.4这个版本后开始使用TS...

    mating 评论0 收藏0
  • vue2源码学习开胃——snabbdom源码学习(一)

    摘要:前言最近在学习的源码,刚开始看其源码,着实找不到方向,因为其在的实现上还加入了很多本身的钩子,加大了阅读难度。 前言 最近在学习vue2.0的源码,刚开始看其vdom源码,着实找不到方向,因为其在vdom的实现上还加入了很多vue2.0本身的钩子,加大了阅读难度。于是看到第一行尤大说vue2.0的vdom是在snabbdom的基础上改过来的,而snabbdom只有不到300sloc,那...

    betacat 评论0 收藏0
  • Luy 1.0 :一个React-like轮子的诞生

    摘要:司徒正美的一款了不起的化方案,支持到。行代码内实现一个胡子大哈实现的作品其实就是的了源码学习个人文章源码学习个人文章源码学习个人文章源码学习个人文章这几片文章的作者都是司徒正美,全面的解析和官方的对比。 前言 在过去的一个多月中,为了能够更深入的学习,使用React,了解React内部算法,数据结构,我自己,从零开始写了一个玩具框架。 截止今日,终于可以发布第一个版本,因为就在昨天,我...

    codecook 评论0 收藏0
  • javascript高级学习总结(

    摘要:那个率先改变的实例的返回值,就会传递给的回调函数。函数对函数的改进,体现在以下四点内置执行器。进一步说,函数完全可以看作多个异步操作,包装成的一个对象,而命令就是内部命令的语法糖。中的本质就是没有的隐藏的组件。 1、原型 - jquery使用showImg(https://segmentfault.com/img/bVbwNcY?w=692&h=442);注释 : 实例虽然不同,但是构...

    Songlcy 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<