最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • vue 响应式学习笔记

    正文概述 掘金(Reborn酱)   2021-08-17   497

    一、响应式原理初体验

    vue 响应式的核心是数据驱动视图,那怎么做到数据发生变更,从而使视图发生变化那? 如果有能够监听数据变化的 API 即可实现,ES5 提供了 Object.defineProperty 与 ES6 提供了 Proxy 可以实现对数据的监听。

    接下来模拟下 vue2.x 与 vue3.x 响应式的实现

    1. vue2.x

    响应式原理 = Object.defineProperty + 观察者模式 (后续有介绍) vue 响应式学习笔记

    <body>
    	<h1 id="elm">姓名为:{{name}}</h1>
    	<button id="btn">点击进行初始化渲染</button>
    </body>
    
    // vue 2.x 将 data 数据转换为响应式
        const elm = document.querySelector('#elm')
        // 1. 响应式初始化
        const data = {
          name: 'reborn',
          age: 18,
          hobbies: {
            a: 'play game',
            b: 'play ball'
          }
        }
    
        // 假设为 vm 为 vue 实例
        const vm = {}
    
        // 将 data 中属性注入到 vue 实例上。(这也是为什么我们可以通过 this  访问 data 中数据的原因)
        Reflect.ownKeys(data).forEach(key => {
          Object.defineProperty(vm, key, {
            enumerable: true,
            configurable: true,
            // 获取操作的劫持
            get() {
              return data[key]
            },
            // 设置新值操作的劫持
            set(newVal) {
              data[key] = newVal
            }
          })
        })
    
        // 将 data 数据也转换为响应式, 简单 demo  暂不考虑 对象嵌套对象
        Reflect.ownKeys(data).forEach(key => {
          let value = data[key]
          Object.defineProperty(data, key, {
            enumerable: true,
            configurable: true,
    
            // 这里之所以返回value的原因如下: 1. 如果继续返回 data[key] 会一直触发 get 劫持,从而导致 调用栈溢出。解决办法利用闭包缓存 value 变量,get set 都变更 vlaue。
            get() {
              return value
            },
    
            // 暂不考虑变更数据的是新对象。
            set(newVal) {
              if (newVal === value) return
              value = newVal
              // 3. 模拟当值发生改变之后更新视图
              elm.textContent = value
            }
          })
        })
    
        // 2. 页面初始化,不考虑插值表达式解析
        document.querySelector('#btn').addEventListener('click', () => {
          elm.textContent = vm.name
        })
    
    

    2. vue3.x

    所得到的效果与使用

    <body>
    	<h1 id="elm">姓名为:{{name}}</h1>
    	<button id="btn">点击进行初始化渲染</button>
    </body>
    
       // vue 3.x 将 data 数据转换为响应式
        const elm = document.querySelector('#elm')
        // 1. 响应式初始化
        const data = {
          name: 'reborn',
          age: 18,
          hobbies: {
            a: 'play game',
            b: 'play ball'
          }
        }
    
        // 假设为 vm 为 vue 实例
        const vm = {}
    
        const proxyData = new Proxy(data, {
          get(target, key) {
            return target[key]
          },
    
          set(target, key, val) {
            if (val === target[key]) return
            Reflect.set(...arguments)
            // 模拟当值发生改变之后更新视图
            elm.textContent = val
            return true
          }
        })
    
        Reflect.ownKeys(proxyData).forEach(key => {
          Object.defineProperty(vm, key, {
            enumerable: true,
            configurable: true,
            // 获取操作的劫持
            get() {
              return proxyData[key]
            },
            // 设置新值操作的劫持
            set(newVal) {
              proxyData[key] = newVal
            }
          })
        })
    
        // 页面初始化,不考虑插值表达式解析
        document.querySelector('#btn').addEventListener('click', () => {
          elm.textContent = vm.name
        })
    
    

    细心的你经过测试一定能够发现,上面的 demo 会出现我们无论更改 data 中哪一个属性的值都会导致 elm dom 元素的文本内容变成新改变的值。虽然说我们现在已经能够劫持每一个属性的变化,那该如何做到当 data 中的某个属性发生变化之后,页面上所有依赖该属性的 dom 元素的值都能够发生变化? 稍后会揭晓,请您继续看下去~

    3. Object.defineProperty 与 Proxy 之间的区别

    definePropertyProxy
    监听对象仅能够监听对象 {}可以是任何类型的对象,包括原生数组,函数,甚至另一个代理监听个体监听对象的某个属性监听整个对象

    4. vue 3.x 从 Object.defineProperty 过渡到 Proxy 带来那些变化

    挖个坑。。。

    二、响应式系统设计模式

    发布订阅 && 观察者是软件的一种设计的模式

    1. 观察者模式

    举一个观察者模式应用与 vue 响应式系统的例子:

    <!-- 页面布局 -->
     <div id="app">
        <!-- 头部 -->
        <div class="header">{{subject}}</div>
        
        <!-- 主体 -->
        <div class="main">{{subject}}</div>
    
        <!-- 底部 -->
        <div class="bottom">{{subject}}</div>
      </div>
    
    const vm = new Vue({
        el: '#app',
        data: {
          subject: "someContent",
          other: "otherContent"
        }
      })
    

    假设有一个 html 页面,其布局分别由 头部 ,主体,底部构成并依赖与 data 对象中 subject 属性所对应的值进行展示。了解响应式的朋友们都知道,响应式系统是数据驱动视图,当 data 中 subject 属性的值发生改变之后,页面中所依赖与该属性值的头部,主体,底部的内容也会发生变化。可以思考下,目标对象,观察者对象,状态改变分别对应观察者模式那些主体?

    在这一段 demo 中,目标对象就是 data 对象的 subject 属性,而页面中所有依赖于该 subject 属性的主体为观察者对象,状态改变则对应数据发生变化,如: data.subject = "reborn"。参考下图:

    vue 响应式学习笔记 vue 响应式学习笔记 用代码实现观察者模式

    模拟场景: 国庆假期,你与你的4个好朋友约定好了一起去球场打篮球,但是那由于你的粗心大意忘记提前预约了。 当你们到球场的时候已经没场了,此时球场老板跟你说,不好意思啊帅哥,假期球场都已经约满了,要是有人取消预约的时候会通知您。

    // 观察者模式
    
    // 目标对象
    class Subject {
      constructor() {
        this.observes = []
      }
    
      // 添加观察者
      addObserve(observe) {
        if (!observe.update) return
        this.observes.push(observe)
      }
    
      // 事件触发通知函数
      notify(val) {
        if (!this.observes.length) return
        this.observes.forEach(observes => {
          observes.update?.(val)
        })
      }
    
      // 移除单个观察者
      removeObserve(observe) {
        const targetIdx = this.observes.findIndex(obsv => obsv === observe)
        this.observes.splice(targetIdx, 1)
      }
    
      // 移除所有观察者
      removeAllObserves() {
        this.observes = []
      }
    }
    
    
    // 观察者对象
    class Observe {
      constructor(cb) {
        this.cb = cb
      }
    
      update(val) {
        this.cb(val)
      }
    }
    
    // 分析:
    // 观察者 => 帅哥
    // 目标对象 => 球场
    // 状态变更(事件) => 球场为空
    
    // 创建目标对象
    const ballPark = new Subject()
    
    // 创建观察者
    const handsomeMan = new Observe((date) => {
      console.log('XDM 有场了,'+ date + '跟我一起杀过去')
    })
    
    // 订阅球场
    ballPark.addObserve(handsomeMan)
    
    // 有人取消预约,老板通知你有场拉
    ballPark.notify('本周三')
    

    到现在你应该已经能够手写一个观察者模式了,接下来我们将结合响应式初体验中 demo ( vue2.x ) 来实现 vue 响应式系统。

    <!DOCTYPE html>
    <html lang="en">
    
    <head>
      <meta charset="UTF-8">
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
    </head>
    
    <body>
      <h1 id="elm">姓名为:{{name}}</h1>
      <!-- 1. 将刚刚写的观察者模式的代码引进来 -->
      <script src="./observe.js"></script>
      <script>
        // 2. 数据劫持相关操作
        const data = {
          name: 'reborn',
          age: 18,
          hobbies: {
            a: 'play game',
            b: 'play ball'
          }
        }
    
        // 假设为 vue 实例
        const vm = {}
    
        Reflect.ownKeys(data).forEach(key => {
          Object.defineProperty(vm, key, {
            enumerable: true,
            configurable: true,
            get() {
              return data[key]
            },
            set(newVal) {
              data[key] = newVal
            }
          })
        })
    
        Reflect.ownKeys(data).forEach(key => {
          let value = data[key]
          // 创建目标对象
          const dep = new Subject()
          Object.defineProperty(data, key, {
            enumerable: true,
            configurable: true,
    
            get() {
              // 当访问该数据的时候,添加 观察者
              Subject.isAddObsv && dep.addObserve(Subject.isAddObsv)
              return value
            },
    
            set(newVal) {
              if (newVal === value) return
              value = newVal
              // 当数据发生变化之后通知所有的观察者
              dep.notify(value)
            }
          })
        })
    
    
        // 3. 页面首次渲染
        function initRender() {
          const elm = document.querySelector("#elm")
          // 创建观察者对象
          Subject.isAddObsv = new Observe((val) => {
            // 当劫持数据发生变化之后,在回调中更新数据。
            elm.textContent = val
          })
          elm.textContent = vm.name
          Subject.isAddObsv = null
        } 
    
        // 触发首屏渲染
        initRender()
    
      </script>
    
    </body>
    
    </html>
    

    vue 响应式学习笔记

    2. 发布订阅模式

    发布订阅模式与观察者模式很相似,但又有所不同,好像 西红柿与圣女果的关系一般。

    根据上述定义我们可能会有些疑问,既然发布者与订阅者都无需关心对方的存在,那订阅者和发布者如如何实现关联的?是需要存在一个第三者消息中心来作为中间人来协调订阅者与发布者,他的职责就是来维护发布与订阅的消息,将发布订阅消息进行筛选匹配,从而间接实现发布者订阅者对应关系。(个人理解)

    从定义就可以得出发布订阅模式与观察者的差异:

    • 发布订阅模式松耦合,发布者订阅者无需知道对方的存在,仅关注消息的本身。
    • 观察者模式为强耦合,观察者需要依赖与目标对象。

    代码实现发布订阅模式

    // 发布订阅模式
    
    // 事件调度中心(消息中心)
    class EventEmitter {
      constructor() {
        this.subs = {}
      }
    
      // 订阅消息
      $on(type, handler) {
        this.subs[type] = this.subs[type] || []
        this.subs[type].push(handler)
      }
    
    
      // 发布消息
      $emit(type, ...args) {
        if (!this.subs[type]) return
        this.subs[type].forEach(handler => {
          handler(...args)
        })
      }
    
    
      // 移除单个消息
      removeMsg(type, cbFn) {
        if (!type || !cbFn) return 
    
        const fnInSubsIdx = this.subs[type].findIndex(cb => cbFn === cb)
    
        if (fnInSubsIdx === -1) return
    
        this.subs[type].splice(fnInSubsIdx, 1 )
      }
    
    
      // 移除所有消息
      removeAllMsg() {
        this.subs = {}
      }
    }
    
    const e = new EventEmitter()
    
    e.$on('playBall', (val) => {
      // finished homework
      console.log('come on , go to play ball!', {val})
    })
    
    
    e.$on('playBall', (val) => {
      // no finished homework
      console.log('nonono ', {val})
    })
    
    
    e.$emit('playBall' , 'hhhh')
    

    目前 vue 内部自定义事件就是用到了发布订阅模式,webpack 依赖的核心库 tapable 也同样是发布订阅模式。这种设计模式我们有必要了解~

    三、实现一个简版的 Vue

    到现在,我们已经知道了如何对 data 中的数据进行劫持来监听到数据发生改变,也知道了响应式系统的设计模式了。现在根据已经实现的 demo 现在我们来思考下要实现一个 vue 响应式系统需要做那些事。

    实现思路

    • 对数据进行劫持,监听数据的变化。
    • 响应式系统设计模式——观察者模式,订阅数据变化,更新视图。
    • 页面初始化渲染,解析插值表达式,指令,创建 观察者 订阅 目标数据的变化。

    vue 响应式学习笔记 接下来就让我们将思路转换为代码吧~

    代码实现 先了解下目录结构 vue 响应式学习笔记

    // index.html
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
    </head>
    <body>
    
      <div id="app">
        <h1>插值表达式:name {{name}}</h1>
        <h1>插值表达式:age {{age}}</h1>
        <h1 v-text="hobby">v-text</h1>
        <div v-html="htmlContent">v-html</div>
        <input type="text" v-model="tall" placeholder="请输入身高">
        <button v-on:click.native="handleClick">
          点击更改name
        </button>
      </div>
      
      <script src="./js/Watcher.js"></script>
      <script src="./js/Compiler.js"></script>
      <script src="./js/Dep.js"></script>
      <script src="./js/Observe.js"></script>
      <script src="./js/Vue.js"></script>
    
      <script>
        const vm = new Vue({
          el: '#app',
          data: {
            name: 'reborn',
            age: 18,
            tall: '',
            obj: {
              a : 1,
              b: 2
            },
            hobby: 'game',
            htmlContent: '<strong>hello Reborn~~</strong>'
          },
          methods: {
            handleClick(e) {
              console.log('事件触发了', {e})
            }
          }
        })
      </script>
    
    </body>
    </html>
    
    // vue.js
    // 职责
    // create vue
    class Vue {
      constructor(options) {
        // 解析参数
        this.$options = options
        this.$data = options.data
        this.$el =
          typeof options.el === 'string' ? document.querySelector('#app') : options.el
    
        // 将 data 数据添加到 this 实例上
        this._proxyData(this.$data)
    
        // 将 data 中的所有数据转为响应式
        new Observe(this.$data)
    
        // 解析vue语法规则
        new Compiler(this.$el, this)
    
      }
    
      _proxyData(data) {
        Object.keys(data).forEach((key) => {
          Object.defineProperty(this, key, {
            enumerable: true,
            configurable: true,
    
            get() {
              return data[key]
            },
    
            set(newVal) {
              if (newVal === data[key]) return
              data[key] = newVal
            }
          })
        })
      }
    }
    
    // Observe.js
    // 职责:
    // 将 data 中属性转换为响应式数据
    // 在 getter 劫持的时候,添加 观察者 watcher
    // 在 setter 劫持 通知所有观察者更新视图
    class Observe {
      constructor(data) {
        this.walk(data)
      }
      walk (data) {
    
        Object.keys(data).forEach(key => {
          this.defineReactive(data, key, data[key])
        })
      }
    
      // 给属性添加数据劫持
      defineReactive(data, key, value) {
        const dep = new Dep()
        let that = this
        // 递归将 树形结构的所有数据转为响应式
        if (typeof value === 'object') this.walk(value)
        Object.defineProperty(data, key, {
          enumerable: true,
          configurable: true,
          get() {
            Dep.haveWatcher && dep.addSubs(Dep.haveWatcher)
            return value
          },
          set(newVal) {
            if (value === newVal) return
            value = newVal
            // 改变数据的时候如果是 {} ,则将其内部的属性也同样转换为响应式
            if (typeof value === 'object') that.walk(value)
            dep.notify(newVal)
          }
        })
      }
      
    }
    
    // Dep.js 
    // 职责:
    // create Subject
    class Dep {
      constructor() {
        this.subs = []
      }
    
      addSubs(watcher) {
        if (!watcher.update) return
        this.subs.push(watcher)
      }
    
      notify(newVal) {
        this.subs.forEach(item => {
          item.update(newVal)
        })
      }
    }
    
    // wather.js
    // 职责:
    // create 观察者
    class Watcher {
      constructor(vm, prop, cb) {
        this.cb = cb
        Dep.haveWatcher = this
        this.oldValue = vm[prop]
        Dep.haveWatcher = null
      }
    
      update(newVal) {
        if (newVal === this.oldValue) return
        this.cb && this.cb(newVal)
      }
    }
    
    // compile.js
    // Compiler 职责:
    // 1. 解析 vue 语法
    // 2. 初次渲染时 create watcher
    class Compiler {
      constructor(el, vm) {
        this.vm = vm
        this.compile(el)
      }
    
      // 编辑
      compile(el) {
        const childrenNode = el.childNodes || []
        childrenNode.forEach((item) => {
          this.handleNode(item)
          if (childrenNode.length) this.compile(item)
        })
      }
    
      handleNode(node) {
        if (this.isTextNode(node) && this.isHaveHuaKuohao(node)) {
          this.handleTextNode(node)
        } else if (this.isElmNode(node)) {
          this.handleElmNode(node)
        }
      }
    
      // 是否文本节点
      isTextNode(node) {
        return node.nodeType === 3
      }
    
      // 是否元素节点
      isElmNode(node) {
        return node.nodeType === 1
      }
    
      // 处理文件节点,解析插值表达式 (不考虑插值表达式中有表达式的情况)
      handleTextNode(node) { 
        let {textContent} = node
        const getVariableRegexp = /\{{2}(.+)\}{2}/
        const variable = textContent.match(getVariableRegexp)?.[1]?.trim()
        const watcher = new Watcher(this.vm, variable, (newValue) => {
          node.textContent = newValue
        })
        const value = watcher.oldValue
        node.textContent = value
        
        // 在页面使用的地方进行进行依赖收集
        
        
      }
      // 有花括号的为需要进行解析操作
      isHaveHuaKuohao(node) {
        const isHaveHuaKuohaoRegexp = /\{{2}(.+)\}{2}/
        return isHaveHuaKuohaoRegexp.test(node.textContent)
      }
    
      // 处理元素节点,解析指令
      handleElmNode(node) {
        const attrubuts = Array.from(node.attributes) || []
        attrubuts.forEach((attr) => {
          if (this.isDirectives(attr)) {
            // 当前元素节点有指令
            const name = attr.name
            const value = attr.value
            // 不同指令交给不同的函数来处理
            const drtFn = this.createFnName(name)
            this[drtFn]?.(node, value, name)
          }
        })
      }
    
      // 判断元素节点上是否有指令
      isDirectives(attr) {
        const attrText = attr?.nodeName
        return attrText.startsWith('v-') || attrText.startsWith('@')
      }
    
      // 一下师处理不同类型的指令
      createFnName(name) {
        // 这里需要考虑注册事件 @click v-on:click 的情况
        if (name.includes('@') || name.includes(':')) {
          name = name.slice(2, 4)
        } else {
          name = name.slice(2)
        }
        return name + 'DrtFn'
      }
    
      // v-text
      textDrtFn(node, prop) {
        prop = prop.trim()
        const watcher = new Watcher(this.vm, prop, (newVal) => {
          node.textContent = newVal
        })
        node.textContent = watcher.oldValue
        // 将页面上依赖数据的,将其添加到依赖上
      }
    
      // v-model
      modelDrtFn(node, prop) {
        prop = prop.trim()
        const watcher = new Watcher(this.vm, prop, (newVal) => {
          node.value = newVal
        })
    
        node.value = watcher.oldValue
    
        // v-model 实现双向数据绑定,监听input 时间
    
        node.addEventListener('input', (e) => {
          this.vm[prop] = e.target.value
        })
      }
    
      // v-html
      htmlDrtFn(node, prop) {
        prop = prop.trim()
        const watcher = new Watcher(this.vm, prop, (newVal) => {
          node.innerHTML = newVal
        })
        node.innerHTML = watcher.oldValue
      }
    
      // v-on
      onDrtFn(node, prop, attrName) {
        let eventFn, eventName, options
    
        const { eName, sign } = this.handleDirectName(attrName)
        eventFn = this.handleEventFn(prop.trim())
        // 正常事件绑定
        // 内联处理器中的方法, 可以往事件中传值
        // 处理事件修饰符
        // 按键修饰符 (太复杂了,要重新更改代码,不处理了)
        options = {}
    
        node.addEventListener(eName, eventFn, options)
      }
    
      handleDirectName(name) {
        let eName, sign
        const startIdx = name.indexOf(':') > 0 ? name.indexOf(':') : name.indexOf('@')
        const eventNameEndIdx = name.indexOf(".") > 0 ? name.indexOf(".") : name.length
        eName = name.slice(startIdx + 1, eventNameEndIdx)
        const signStartIdx = eventNameEndIdx > 0 && eventNameEndIdx < name.length ? eventNameEndIdx : null
    
        if (signStartIdx) sign = name.slice(signStartIdx + 1, name.length)
        // 处理带修饰符情况
    
        return { eName, sign }
      }
    
      // 处理事件修饰符
      // handleEvent
    
      // 内联处理器中的方法, 可以往事件中传值
      handleEventFn(fnName) {
        let fn
        const { methods } = this.vm.$options
        const callFnRegxp = /(\w+)(\(.+\))/
        const isCallFn = callFnRegxp.test(fnName)
        if (isCallFn) {
          let paramsArr = []
          // 函数调用的情况
          const res = fnName.match(callFnRegxp)
          const eventName = res?.[1]
          const params = res?.[2]
    
          if (!eventName) return new Function()
    
          // 去掉小括号
          const str = params.replace(/\(?\)?/g, '').trim()
    
    
          // 处理参数,将变量转换位真实的值
          str.split(',')?.forEach(item => {
            const isVariable = this.isVariable(item)
            if (isVariable) {
              // $event 需要传入对象
              if (item !== '$event') {
                item = this.vm[item]
              }
              paramsArr.push(item)
            } else {
              paramsArr.push(item)
            }
          })
    
          // 生成函数
          fn = (e) => {
            // 将 '$event' 替换位 事件对象 e
            const arr = paramsArr.map( item  =>  {
              if (item === '$event') {
                return e
              }
    
              return item
            })
            return methods[eventName](...arr)
          }
    
        } else {
          // 无函数调用, 
          fn = methods[fnName]
        }
        return fn
    
      }
    
      // 判断是否全实
      isVariable(str) {
        // 不是 23423 , 没有 ' ' 字符串
        str = str.trim()
        const isNumRegexp = /^\d+$/
        const isStrRegexp = /^'{1}.?'{1}$/
        const isStrOrNum = isNumRegexp.test(str) || isStrRegexp.test(str)
    
        return !isStrOrNum
      }
    }
    
    

    四、谈谈你对 vue 响应式系统的理解

    看完上面文章,你收获了多少? 假如面试官问你:谈谈你对 vue 响应式的理解?你会怎么答复?

    从 设计思想 和 具体实现方式这两个点来回答这个问题 设计思想:

    首先响应式系统的核心是数据驱动视图,当数据发生变化的时候视图也会跟着变化,所以需要有能够监听数据变化的 API, Object.definedProperty Or Proxy 来实现对数据的监听。最后利用观察者模式以一种"优雅"的设计方式在数据发生变化之后,更新页面上所有依赖该数据的视图的内容。

    具体实现方式:

    1. Vue 会将 data 中的所有属性通过 Object.defineProperty 将其进行数据劫持,并给每一个属性创建一个 目标对象(发布者)。
    2. 在 getter 中其主要的职责有两件事,收集依赖(添加观察者),返回其访问的目标值。
    3. 在 setter 中主要的职责有两件事, 变更目标值,通知所有的观察者更新视图。
    4. 在页面初次渲染的时候, 为视图所依赖的劫持数据创建 观察者,并将 观察者添加到 发布者,此时创建回调函数,在回调函数中修改 dom,等待数据更新之后发布者调用 所有watcher 的 回调来更新视图。

    起源地下载网 » vue 响应式学习笔记

    常见问题FAQ

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

    发表评论

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

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

    联系作者

    请选择支付方式

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