最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 虚拟DOM(Virtual DOM)与Snabbdom

    正文概述 掘金(bluesky108)   2020-12-27   408

    在研究Vue的源码的时候,了解了一下Virtual DOM,以及Virtual DOM的作用。下面分享一下什么是Virtual DOM,以及它的作用。和一个比较常见的Virtual DOM库的使用与源码分析。

    什么是Virtual DOM

    • Virtual DOM(虚拟DOM),是由js对象来描述DOM对象,因为不是真是的DOM,所以叫作虚拟DOM。
    • Virtual DOM的格式一般如下:
    {
      sel: "div",
      data: {},
      children: undefined,
      text: "Hello Virtual DOM",
      elm: undefined,
      key: undefined
    }
    

    为什么使用Virtual DOM

    • 手动操作DOM比较麻烦,虽然有JQuery等库简化DOM操作,但是随着项目的复杂性提升,DOM操作越来越难
    • 为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题
    • Virtual DOM的好处是可以不用立即更新DOM,当数据改变时,只需创建一个虚拟树来描述DOM,可以跟踪记录上一次的状态,通过比较前后两次的状态差异来更新真实的DOM,在复杂项目中可以提升渲染的性能
    • 除了渲染DOM外,还可以实现SSR渲染(nuxt.js/next.js),原生应用(weex/React Native),小程序等

    Snabbdom(Virtual DOM库)

    • snabbdom文档
    • Vue 2.x 内部使用的 Virtual DOM 就是改造的 Snabbdom
    • 大约 200 SLOC(single line of code)
    • 通过模块可扩展,源码使用 TypeScript 开发,最快的 Virtual DOM 之一

    Snabbdom的基本使用

    • 我使用的打包工具是parcel,不用webpack,选这个的原因,是可以零配置运行一个简单的demo
    # 创建项目目录
    md snabbdom-demo
    # 进入项目目录
    cd snabbdom-demo
    # 创建 package.json
    npm init -y
    # 本地安装 parcel
    npm install parcel-bundler -D
    # 本地安装 snabbdom   安装snabbdom要注意版本问题,最新的版本有问题,建议安装0.7.4版本
    npm install snabbdom@0.7.4 -S
    
    • 创建对应的目录结构
    - index.html
    - package.json
    └─ src
       index.js
    
    • 配置 package.json 的 scripts
    "scripts": {
     "dev": "parcel index.html --open",
     "build": "parcel build index.html"
    }
    
    • Snabbdom的导入
    // 因为snabbdom是commonJs语法,在项目中要是想用ES6模块的语法,要注意导入的方式
    
    // commonJs
    let snabbdom = require('snabbdom')
    
    // ES6
    import * as snabbdom from 'snabbdom'  或者
    import { h, init, thunk} from 'snabbdom'
    

    Snabbdom的使用

    先简单的介绍一下常用的几个方法的功能

    • 使用 h() 函数创建 JavaScript 对象(VNode)描述真实 DOM
    • init() 设置模块,创建 patch()
    • patch() 比较新旧两个 VNode,它有两个参数,第一个参数可以是真实的DOM或者VNode,第二个参数是新的VNode
    • 把变化的内容更新到真实 DOM 树上

    开始上代码

    • 先简单的写一个Hello World的例子。
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>snabbdom-demo</title>
    </head>
    <body>
      <div id="app"></div>
      <script src="./src/index.js"></script>
    </body>
    </html>
    
    import * as snabbdom from 'snabbdom'
    
    let patch = snabbdom.init([])
    
    let vnode = snabbdom.h('div#app.app', 'Hello World')
    let app = document.querySelector('#app')
    
    let oldVnode = patch(app, vnode) // 保留上一次的vnode
    
    setTimeout(() => { // 过两秒更新一下DOM
      vnode = snabbdom.h('div#app.app', 'Hello World1')
      oldVnode = patch(oldVnode, vnode)
    }, 2000);
    
    setTimeout(() => {
      vnode = snabbdom.h('div#app.app', 'Hello World2')
      oldVnode = patch(oldVnode, vnode)
    }, 4000);
    
    • 来一个包含子节点的例子
    import * as snabbdom from 'snabbdom'
    // 下面是常用模块,还有一些在官网
    import { classModule } from 'snabbdom/modules/class' // 可以根据变量动态切换的class
    import { propsModule } from 'snabbdom/modules/props' // 设置不为布尔值的属性
    import { attributesModule } from 'snabbdom/modules/attributes' // 设置不为布尔值的属性
    import { datasetModule } from 'snabbdom/modules/dataset' // 设置data-*的属性
    import { styleModule } from 'snabbdom/modules/style' // 设置内联样式
    import { eventListenersModule } from 'snabbdom/modules/eventlisteners' // 注册和移除事件
    
    let patch = snabbdom.init([
      classModule,
      propsModule,
      attributesModule,
      datasetModule,
      styleModule,
      eventListenersModule
    ])
    
    function handleClick(num) {
      console.log('点击了' + num)
    }
    
    let app = document.querySelector('#app')
    
    let vnode = snabbdom.h('div#app', {
      style: {
        color: 'pink'
      },
      dataset: {
        id: 1
      },
      props: {
        title: 'hello world'
      },
      on: {
        click: [handleClick, 1] // 如果不想传参数可以写成  handleClick
      }
    },[
      snabbdom.h('p', '你好啊')
    ])
    
    let oldVnode = patch(app, vnode)
    
    • 上一个可以排序,添加,删除的列表
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>snabbdom-demo</title>
      <style>
        *{
          margin: 0;
          padding: 0;
        }
        html,body{
          height: 100%;
          background-color: #000000;
          color: #ffffff;
          font-size: 14px;
        }
        #app{
          width: 600px;
          padding-top: 50px;
          margin-left: 100px;
        }
        .h1{
          margin-bottom: 15px;
        }
        .options_box{
          display: flex;
          align-items: center;
          justify-content: space-between;
          margin-bottom: 15px;
        }
        .options_box .left_btn {
          margin-right: 10px;
        }
        .options_box .left_btn,
        .options_box .right_btn{
          cursor: pointer;
          padding: 5px 10px;
          background-color: #1d1717;
        }
        .options_box .left_btn.active,
        .options_box .right_btn.active{
          background-color: #524e4e;
        }
    
        .list_box .list{
          background-color: #524e4e;
          margin-bottom: 10px;
        }
        .list_box .list .btn_box{
          overflow: hidden;
        }
        .list_box .list .btn_box .btn{
          cursor: pointer;
          float: right;
          padding: 5px 10px;
        }
        .list_box .list .row{
          display: flex;
          align-items: center;
          padding: 0 15px 15px;
        }
        .list_box .list .row .rank{
          width: 30px;
        }
        .list_box .list .row .title{
          flex: 1;
        }
        .list_box .list .row .desc{
          margin-left: 20px;
          flex: 3;
        }
      </style>
    </head>
    <body>
      <div id="app"></div>
      <script src="./src/index.js"></script>
    </body>
    </html>
    
    import {h, init} from 'snabbdom'
    // 下面是常用模块,还有一些在官网
    import { classModule } from 'snabbdom/modules/class' // 可以根据变量动态切换的class
    import { propsModule } from 'snabbdom/modules/props' // 设置不为布尔值的属性
    import { styleModule } from 'snabbdom/modules/style' // 设置内联样式
    import { eventListenersModule } from 'snabbdom/modules/eventlisteners' // 注册和移除事件
    
    let patch = init([
      classModule,
      propsModule,
      styleModule,
      eventListenersModule
    ])
    
    let oldVnode = null // 旧节点
    let types = '' // 当前排序的条件
    
    let tableData = [
      { rank: 3, title: 'The Godfather: Part II', desc: 'The early life and career of Vito Lake Tahoe, Nevada to pre-revolution 1958 Cuba.', elmHeight: 0 },
      { rank: 1, title: 'The Shawshank Redemption', desc: 'Two imprisoned men bond over a ', elmHeight: 0 },
      { rank: 7, title: '12 Angry Men', desc: 'A dissenting juror in a murder trial slowly ma as it seemed in court.', elmHeight: 0 },
      { rank: 4, title: 'The Dark Knight', desc: 'When the menace known as the Joker wreaks h ability to fight injustice.', elmHeight: 0 },
      { rank: 9, title: 'The Lord of the Rings: The Return of the King', desc: 'Gandalf and A from Frodo and Sam aDoom with the One Ring.', elmHeight: 0 },
      { rank: 6, title: 'Schindler\'s List', desc: 'In Poland during World War II, Oskar Schinessing their persecution by the Nazis.', elmHeight: 0 },
      { rank: 2, title: 'The Godfather', desc: 'The aging patriarch of an organized crime  son.', elmHeight: 0 },
      { rank: 8, title: 'The Good, the Bad and the Ugly', desc: 'A bounty hunting scam joins rtune in gold buried in a remote cemetery.', elmHeight: 0 },
      { rank: 5, title: 'Pulp Fiction', desc: 'The lives of two mob hit men, a boxer, a gangs violence and redemption.', elmHeight: 0 },
      { rank: 10, title: 'Fight Club', desc: 'An insomniac office worker looking for a way t they form an into something much, much more...', elmHeight: 0 },
    ]
    let rankId = tableData.length // 最后一个rank的值
    
    let app = document.querySelector('#app')
    
    // 删除某一行
    function handleRemove(row, index) {
      tableData.splice(index, 1)
      oldVnode = patch(oldVnode, returnVnode(tableData))
    }
    // 添加一行
    function addRow() {
      let data = {
        rank: ++rankId,
        title: `我是添加的第条${rankId - 10}数据`,
        desc: `我是添加的第条${rankId - 10}数据`,
        elmHeight: 0
      }
      tableData.push(data)
      oldVnode = patch(oldVnode, returnVnode(tableData))
    }
    // 创建表的每一行
    function tableRow(row, index) {
      return h('div.list', [
        h('p.btn_box', [
          h('span.btn', {
            on: {
              click: [handleRemove, row, index]
            }
          }, 'x')
        ]),
        h('div.row', [
          h('div.rank', row.rank),
          h('div.title', row.title),
          h('div.desc', row.desc),
        ])
      ])
    }
    
    // 排序
    function changeSort(type) {
      types = type
      tableData.sort((a, b) => {
        if(typeof a[type] === 'number') {
          return a[type] - b[type]
        }else {
          return a[type].localeCompare(b[type])
        }
      })
      oldVnode = patch(oldVnode, returnVnode(tableData))
    }
    
    // 返回vnode
    function returnVnode(data) {
      return h('div#app', [
        h('h1.h1', 'Top 10 movies'),
        h('div.options_box', [
          h('div.left', [
            h('span.left_btn', {
              class: {active: types === 'rank'},
              on: {
                click: [changeSort, 'rank']
              }
            }, 'rank'),
            h('span.left_btn', {
              class: {active: types === 'title'},
              on: {
                click: [changeSort, 'title']
              }
            }, 'title'),
          ]),
          h('span.right_btn',{
            on: {
              click: addRow
            }
          }, 'add')
        ]),
        h('div.list_box', data.map(tableRow))
      ])
    }
    
    oldVnode = patch(app, returnVnode(tableData))
    

    Snabbdom的源码分析

    Snabbdom的核心

    • 使用 h() 函数创建 JavaScript 对象(VNode)描述真实 DOM
    • init() 设置模块,创建 patch()
    • patch() 比较新旧两个 VNode,它有两个参数,第一个参数可以是真实的DOM或者VNode,第二个参数是新的VNode
    • 把变化的内容更新到真实 DOM 树上

    Snabbdom的源码

    • 源码地址 snabbdom
    • src目录结构
    │ h.ts h() 函数,用来创建 VNode
    │ hooks.ts 所有钩子函数的定义
    │ htmldomapi.ts 对 DOM API 的包装
    │ is.ts 判断数组和原始值的函数
    │ jsx-global.d.ts jsx 的类型声明文件
    │ jsx.ts 处理 jsx
    │ snabbdom.bundle.ts 入口,已经注册了模块
    │ snabbdom.ts 初始化,返回 init/h/thunk
    │ thunk.ts 优化处理,对复杂视图不可变值得优化
    │ tovnode.ts DOM 转换成 VNode
    │ vnode.ts 虚拟节点定义
    │
    ├─helpers
    │   attachto.ts 定义了 vnode.ts 中 AttachData 的数据结构
    │
    └─modules 所有模块定义
       attributes.ts
       class.ts
       dataset.ts
       eventlisteners.ts
       hero.ts example 中使用到的自定义钩子
       module.ts 定义了模块中用到的钩子函数
       props.ts
       style.ts
    

    h 函数

    • h函数在使用vue的时候是经常见到的
    new Vue({
     router,
     store,
     render: h => h(App)
    }).$mount('#app')
    
    • snabbdom的h函数是用来创建vnode的,它利用了函数重载的思想,根据传入的参数个数或类型的不同,执行不同函数。
    // h 函数的重载
    export function h(sel: string): VNode;
    export function h(sel: string, data: VNodeData | null): VNode;
    export function h(sel: string, children: VNodeChildren): VNode;
    export function h(sel: string, data: VNodeData | null, children:
    VNodeChildren): VNode;
    export function h(sel: any, b?: any, c?: any): VNode {
     var data: VNodeData = {}, children: any, text: any, i: number;
     // 处理参数,实现重载的机制
     if (c !== undefined) {
      // 处理三个参数的情况
      // sel、data、children/text
      if (b !== null) { data = b; }
      if (is.array(c)) { children = c; }
      // 如果 c 是字符串或者数字
      else if (is.primitive(c)) { text = c; }
      // 如果 c 是 VNode
      else if (c && c.sel) { children = [c]; }
    } else if (b !== undefined && b !== null) {
      // 处理两个参数的情况
      // 如果 b 是数组
      if (is.array(b)) { children = b; }
      // 如果 b 是字符串或者数字
      else if (is.primitive(b)) { text = b; }
      // 如果 b 是 VNode
      else if (b && b.sel) { children = [b]; }
      else { data = b; }
    }
     if (children !== undefined) {
      // 处理 children 中的原始值(string/number)
      for (i = 0; i < children.length; ++i) {
       // 如果 child 是 string/number,创建文本节点
       if (is.primitive(children[i])) children[i] = vnode(undefined,
    undefined, undefined, children[i], undefined);
     }
    }
     if (
      sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
     (sel.length === 3 || sel[3] === '.' || sel[3] === '#')
    ) {
      // 如果是 svg,添加命名空间
      addNS(data, children, sel);
    }
     // 返回 VNode
     return vnode(sel, data, children, text, undefined);
    };
    // 导出模块
    export default h;
    

    VNode

    一个VNode就是一个虚拟节点,用来描述一个DOM元素,下面分析一下snabbdom的vnode.ts

    export interface VNode {
     // 选择器
     sel: string | undefined;
     // 节点数据:属性/样式/事件等
     data: VNodeData | undefined;
     // 子节点,和 text 只能互斥
     children: Array<VNode | string> | undefined;
     // 记录 vnode 对应的真实 DOM
     elm: Node | undefined;
     // 节点中的内容,和 children 只能互斥
     text: string | undefined;
     // 优化用
     key: Key | undefined;
    }
    export function vnode(sel: string | undefined,
               data: any | undefined,
               children: Array<VNode | string> | undefined,
               text: string | undefined,
               elm: Element | Text | undefined): VNode {
     let key = data === undefined ? undefined : data.key;
     return {sel, data, children, text, elm, key};
    }
    export default vnode;
    

    Snabbdom.ts

    分析一下snabbdom.ts文件

    • patch(oldVnode, newVnode)
    • 打补丁,把新节点中变化的内容渲染到真实 DOM,最后返回新节点作为下一次处理的旧节点
    • 对比新旧 VNode 是否相同节点(节点的 key 和 sel 相同)
    • 如果不是相同节点,删除之前的内容,重新渲染
    • 如果是相同节点,再判断新的 VNode 是否有 text,如果有并且和 oldVnode 的 text 不同,直接更新文本内容
    • 如果新的 VNode 有 children,判断子节点是否有变化,判断子节点的过程使用的就是 diff 算法
    • diff 过程只进行同层级比较

    init 函数

    • init(modules, domApi),返回 patch() 函数(高阶函数)。modules是一个数组,数组里面是各种扩展插件,domApi是一个包含dom操作的对象

    patch 函数

    • 功能
      • 传入新旧 VNode,对比差异,把差异渲染到 DOM
      • 返回新的 VNode,作为下一次 patch() 的 oldVnode
    • 执行过程
      • 首先执行模块中的钩子函数 pre
      • 如果 oldVnode 和 vnode 相同(key 和 sel 相同),调用 patchVnode(),找节点的差异并更新 DOM
      • 如果 oldVnode 是 DOM 元素。① 把 DOM 元素转换成 oldVnode ② 调用 createElm() 把 vnode 转换为真实 DOM,记录到 vnode.elm ③ 把刚创建的 DOM 元素插入到 parent 中 ④ 移除老节点,触发用户设置的 create 钩子函数
    return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
     let i: number, elm: Node, parent: Node;
     // 保存新插入节点的队列,为了触发钩子函数
     const insertedVnodeQueue: VNodeQueue = [];
     // 执行模块的 pre 钩子函数
     for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
     // 如果 oldVnode 不是 VNode,创建 VNode 并设置 elm
     if (!isVnode(oldVnode)) {
      // 把 DOM 元素转换成空的 VNode
      oldVnode = emptyNodeAt(oldVnode);
    }
     // 如果新旧节点是相同节点(key 和 sel 相同)
     if (sameVnode(oldVnode, vnode)) {
      // 找节点的差异并更新 DOM
      patchVnode(oldVnode, vnode, insertedVnodeQueue);
    } else {
      // 如果新旧节点不同,vnode 创建对应的 DOM
      // 获取当前的 DOM 元素
      elm = oldVnode.elm!;
      parent = api.parentNode(elm);
      // 触发 init/create 钩子函数,创建 DOM
      createElm(vnode, insertedVnodeQueue);
      if (parent !== null) {
       // 如果父节点不为空,把 vnode 对应的 DOM 插入到文档中
       api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
       // 移除老节点
       removeVnodes(parent, [oldVnode], 0, 0);
     }
    }
     // 执行用户设置的 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
     return vnode;
    };
    

    createElm 函数

    • 功能:createElm(vnode, insertedVnodeQueue),返回创建 vnode 对应的 DOM 元素
    • 执行过程
      • 首先触发用户设置的 init 钩子函数
      • 如果选择器是!,创建注释节点;如果选择器为空,创建文本节点
      • 如果选择器不为空。① 解析选择器,设置标签的 id 和 class 属性 ② 执行模块的 create 钩子函数 ③ 如果 vnode 有 children,创建子 vnode 对应的 DOM,追加到 DOM 树 ④ 如果 vnode 的 text 值是 string/number,创建文本节点并追击到 DOM 树 ⑤ 执行用户设置的 create 钩子函数 ⑥ 如果有用户设置的 insert 钩子函数,把 vnode 添加到队列中

    patchVnode 函数

    • 功能:patchVnode(oldVnode, vnode, insertedVnodeQueue),对比 oldVnode 和 vnode 的差异,把差异渲染到 DOM
    • 执行过程
      • 首先执行用户设置的 prepatch 钩子函数
      • 执行模块的 create 钩子函数,再执行用户设置的 create 钩子函数
      • 如果设置了 vnode.text 并且和 oldVnode.text 不相等。如果老节点有子节点,全部移除,设置 DOM 元素的 textContent 为 vnode.text
      • 如果 vnode.text 未定义
        • 如果 oldVnode.children 和 vnode.children 都有值。调用 updateChildren(),使用 diff 算法对比子节点,更新子节点
        • 如果 vnode.children 有值。 oldVnode.children 无值。清空 DOM 元素,调用 addVnodes() ,批量添加子节点
        • 如果 oldVnode.children 有值, vnode.children 无值。调用 removeVnodes() ,批量移除子节点
        • 如果 oldVnode.text 有值。清空 DOM 元素的内容
    function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue:VNodeQueue) {
     const hook = vnode.data?.hook;
     // 首先执行用户设置的 prepatch 钩子函数
     hook?.prepatch?.(oldVnode, vnode);
     const elm = vnode.elm = oldVnode.elm!;
     let oldCh = oldVnode.children as VNode[];
     let ch = vnode.children as VNode[];
     // 如果新老 vnode 相同返回
     if (oldVnode === vnode) return;
     if (vnode.data !== undefined) {
      // 执行模块的 update 钩子函数
      for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode,
    vnode);
      // 执行用户设置的 update 钩子函数
      vnode.data.hook?.update?.(oldVnode, vnode);
     }
     // 如果 vnode.text 未定义
     if (isUndef(vnode.text)) {
      // 如果新老节点都有 children
      if (isDef(oldCh) && isDef(ch)) {
       // 使用 diff 算法对比子节点,更新子节点
       if (oldCh !== ch) updateChildren(elm, oldCh, ch,
    insertedVnodeQueue);
     } else if (isDef(ch)) {
       // 如果新节点有 children,老节点没有 children
       // 如果老节点有text,清空dom 元素的内容
       if (isDef(oldVnode.text)) api.setTextContent(elm, '');
       // 批量添加子节点
       addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
     } else if (isDef(oldCh)) {
       // 如果老节点有children,新节点没有children
       // 批量移除子节点
       removeVnodes(elm, oldCh, 0, oldCh.length - 1);
     } else if (isDef(oldVnode.text)) {
       // 如果老节点有 text,清空 DOM 元素
       api.setTextContent(elm, '');
     }
    } else if (oldVnode.text !== vnode.text) {
      // 如果没有设置 vnode.text
      if (isDef(oldCh)) {
       // 如果老节点有 children,移除
       removeVnodes(elm, oldCh, 0, oldCh.length - 1);
     }
      // 设置 DOM 元素的 textContent 为 vnode.text
      api.setTextContent(elm, vnode.text!);
               }
     // 最后执行用户设置的 postpatch 钩子函数
     hook?.postpatch?.(oldVnode, vnode);
    }
    

    updateChildren 函数

    • diff 算法的核心,对比新旧节点的 children,更新 DOM
    • 通过调试 updateChildren,我们发现不带 key 的情况需要进行两次 DOM 操作,带 key 的情况只需要更新一次 DOM 操作(移动 DOM 项),所以带 key 的情况可以减少 DOM 的操作,如果子项比较多,更能体现出带 key 的优势。

    起源地下载网 » 虚拟DOM(Virtual DOM)与Snabbdom

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元