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

    正文概述 掘金(hcysunyang)   2021-02-10   467

    原本文章的名字叫做《源码解析》,不过后来想想,还是用 “源码学习” 来的合适一点,在没有彻底掌握源码中的每一个字母之前,“解析”就有点标题党了。建议在看这篇文章之前,最好打开 2.1.7 的源码对照着看,这样可能更容易理解。另外本人水平有限,文中有错误或不妥的地方望大家多多指正共同成长。

    补充:Vue 2.2 刚刚发布,作为一个系列文章的第一篇,本篇文章主要从 Vue 代码的组织,Vue 构造函数的还原,原型的设计,以及参数选项的处理和已经被写烂了的数据绑定与如何使用 Virtual DOM 更新视图入手。从整体的大方向观察框架,这么看来 V2.1.7 对于理解 V2.2 的代码不会有太大的影响。该系列文章的后续文章,都会从最新的源码入手,并对改动的地方做相应的提示。

    很久之前写过一篇文章:JavaScript 实现 MVVM 之我就是想监测一个普通对象的变化,文章开头提到了我写博客的风格,还是那句话,只写努力让小白,甚至是小学生都能看明白的文章。这不免会导致对于某些同学来说这篇文章有些墨迹,所以大家根据自己的喜好,可以详细的看,也可以跳跃着看。

    一、从了解一个开源项目入手

    要看一个项目的源码,不要一上来就看,先去了解一下项目本身的元数据和依赖,除此之外最好也了解一下 PR 规则,Issue Reporting 规则等等。特别是 “前端” 开源项目,我们在看源码之前第一个想到的应该是:package.json文件。

    package.json 文件中,我们最应该关注的就是 scripts 字段和 devDependencies 以及 dependencies 字段,通过 scripts 字段我们可以知道项目中定义的脚本命令,通过 devDependenciesdependencies 字段我们可以了解项目的依赖情况。

    了解了这些之后,如果有依赖我们就 npm install 安装依赖就 ok 了。

    除了 package.json 之外,我们还要阅读项目的贡献规则文档,了解如何开始,一个好的开源项目肯定会包含这部分内容的,Vue 也不例外:github.com/vuejs/vue/b…,在这个文档里说明了一些行为准则,PR 指南,Issue Reporting 指南,Development Setup 以及 项目结构。通过阅读这些内容,我们可以了解项目如何开始,如何开发以及目录的说明,下面是对重要目录和文件的简单介绍,这些内容你都可以去自己阅读获取:

    ├── build 
    ├── dist 
    ├── examples 
    ├── flow 
    ├── package.json 
    ├── test 
    ├── src 
    │   ├── entries 
    │   │   ├── web-runtime.js 
    │   │   ├── web-runtime-with-compiler.js 
    │   │   ├── web-compiler.js 
    │   │   ├── web-server-renderer.js 
    │   ├── compiler 
    │   │   ├── parser 
    │   │   ├── codegen 
    │   │   ├── optimizer.js 
    │   ├── core 
    │   │   ├── observer 
    │   │   ├── vdom 
    │   │   ├── instance 
    │   │   ├── global-api 
    │   │   ├── components 
    │   ├── server 
    │   ├── platforms 
    │   ├── sfc 
    │   ├── shared 
    
    
    

    大概了解了重要目录和文件之后,我们就可以查看 Development Setup 中的常用命令部分,来了解如何开始这个项目了,我们可以看到这样的介绍:

    # watch and auto re-build dist/vue.js
    $ npm run dev
    
    # watch and auto re-run unit tests in Chrome
    $ npm run dev:test
    
    
    

    现在,我们只需要运行 npm run dev 即可监测文件变化并自动重新构建输出 dist/vue.js,然后运行 npm run dev:test 来测试。不过为了方便,我会在 examples 目录新建一个例子,然后引用 dist/vue.js 这样,我们可以直接拿这个例子一边改 Vue 源码一边看自己写的代码想怎么玩怎么玩。

    二、看源码的小提示

    在真正步入源码世界之前,我想简单说一说看源码的技巧:

    注重大体框架,从宏观到微观

    当你看一个项目代码的时候,最好是能找到一条主线,先把大体流程结构摸清楚,再深入到细节,逐项击破,拿 Vue 举个栗子:假如你已经知道 Vue 中数据状态改变后会采用 virtual DOM 的方式更新 DOM,这个时候,如果你不了解 virtual DOM,那么听我一句 “暂且不要去研究内部具体实现,因为这会是你丧失主线”,而你仅仅需要知道 virtual DOM 分为三个步骤:

    有的时候 第二步 可能与 第三步 合并成一步 (Vue 中的 patch 就是这样),除此之外,还比如 src/compiler/codegen 内的代码,可能你不知道他写了什么,直接去看它会让你很痛苦,但是你只需要知道 codegen 是用来将抽象语法树 (AST) 生成 render 函数的就 OK 了,也就是生成类似下面这样的代码:

    function anonymous() {
        with(this){return _c('p',{attrs:{"id":"app"}},[_v("\n      "+_s(a)+"\n      "),_c('my-com')])}
    }
    

    当我们知道了一个东西存在,且知道它存在的目的,那么我们就很容易抓住这条主线,这个系列的第一篇文章就是围绕大体主线展开的。了解大体之后,我们就知道了每部分内容都是做什么的,比如 codegen 是生成类似上面贴出的代码所示的函数的,那么再去看 codegen 下的代码时,目的性就会更强,就更容易理解。

    三、Vue 的构造函数是什么样的

    balabala 一大堆,开始来干货吧。我们要做的第一件事就是搞清楚 Vue 构造函数到底是什么样子的。

    我们知道,我们要使用 new 操作符来调用 Vue,那么也就是说 Vue 应该是一个构造函数,所以我们第一件要做的事儿就是把构造函数先扒的一清二楚,如何寻找 Vue 构造函数呢?当然是从 entry 开始啦,还记的我们运行 npm run dev 命令后,会输出 dist/vue.js 吗,那么我们就去看看 npm run dev 干了什么:

    "dev": "TARGET=web-full-dev rollup -w -c build/config.js",
    

    首先将 TARGET 得值设置为 ‘web-full-dev’,然后,然后,然后如果你不了解 rollup 就应该简单去看一下啦……,简单的说就是一个 JavaScript 模块打包器,你可以把它简单的理解为和 webpack 一样,只不过它有他的优势,比如 Tree-shaking (webpack2 也有),但同样,在某些场景它也有他的劣势。。。废话不多说,其中 -w 就是 watch,-c 就是指定配置文件为 build/config.js ,我们打开这个配置文件看一看:

    ...
    
    
    const builds = {
        ...
        
        'web-full-dev': {
            entry: path.resolve(__dirname, '../src/entries/web-runtime-with-compiler.js'),
            dest: path.resolve(__dirname, '../dist/vue.js'),
            format: 'umd',
            env: 'development',
            alias: { he: './entity-decoder' },
            banner
        },
        ...
    }
    
    
    function genConfig(opts){
        ...
    }
    
    if (process.env.TARGET) {
      module.exports = genConfig(builds[process.env.TARGET])
    } else {
      exports.getBuild = name => genConfig(builds[name])
      exports.getAllBuilds = () => Object.keys(builds).map(name => genConfig(builds[name]))
    }
    

    上面的代码是简化过的,当我们运行 npm run dev 的时候 process.env.TARGET 的值等于 ‘web-full-dev’,所以

    module.exports = genConfig(builds[process.env.TARGET])
    

    这句代码相当于:

    module.exports = genConfig({
        entry: path.resolve(__dirname, '../src/entries/web-runtime-with-compiler.js'),
        dest: path.resolve(__dirname, '../dist/vue.js'),
        format: 'umd',
        env: 'development',
        alias: { he: './entity-decoder' },
        banner
    })
    

    最终,genConfig 函数返回一个 config 对象,这个 config 对象就是 Rollup 的配置对象。那么我们就不难看到,入口文件是:

    src/entries/web-runtime-with-compiler.js
    

    我们打开这个文件,不要忘了我们的主题,我们在寻找 Vue 构造函数,所以当我们看到这个文件的第一行代码是:

    import Vue from './web-runtime'
    

    这个时候,你就应该知道,这个文件暂时与你无缘,你应该打开 web-runtime.js 文件,不过当你打开这个文件时,你发现第一行是这样的:

    import Vue from 'core/index'
    

    依照此思路,最终我们寻找到 Vue 构造函数的位置应该是在 src/core/instance/index.js 文件中,其实我们猜也猜得到,上面介绍目录的时候说过:instance 是存放 Vue 构造函数设计相关代码的目录。总结一下,我们寻找的过程是这样的:

    7xlolm.com1.z0.glb.clouddn.com/vueimg2BD0D…

    我们回头看一看 src/core/instance/index.js 文件,很简单:

    import { initMixin } from './init'
    import { stateMixin } from './state'
    import { renderMixin } from './render'
    import { eventsMixin } from './events'
    import { lifecycleMixin } from './lifecycle'
    import { warn } from '../util/index'
    
    function Vue (options) {
      if (process.env.NODE_ENV !== 'production' &&
        !(this instanceof Vue)) {
        warn('Vue is a constructor and should be called with the `new` keyword')
      }
      this._init(options)
    }
    
    initMixin(Vue)
    stateMixin(Vue)
    eventsMixin(Vue)
    lifecycleMixin(Vue)
    renderMixin(Vue)
    
    export default Vue
    

    引入依赖,定义 Vue 构造函数,然后以 Vue 构造函数为参数,调用了五个方法,最后导出 Vue。这五个方法分别来自五个文件:init.js state.js render.js events.js 以及 lifecycle.js

    打开这五个文件,找到相应的方法,你会发现,这些方法的作用,就是在 Vue 的原型 prototype 上挂载方法或属性,经历了这五个方法后的 Vue 会变成这样:

    Vue.prototype._init = function (options?: Object) {}
    
    
    Vue.prototype.$data
    Vue.prototype.$set = set
    Vue.prototype.$delete = del
    Vue.prototype.$watch = function(){}
    
    
    Vue.prototype.$nextTick = function (fn: Function) {}
    Vue.prototype._render = function (): VNode {}
    Vue.prototype._s = _toString
    Vue.prototype._v = createTextVNode
    Vue.prototype._n = toNumber
    Vue.prototype._e = createEmptyVNode
    Vue.prototype._q = looseEqual
    Vue.prototype._i = looseIndexOf
    Vue.prototype._m = function(){}
    Vue.prototype._o = function(){}
    Vue.prototype._f = function resolveFilter (id) {}
    Vue.prototype._l = function(){}
    Vue.prototype._t = function(){}
    Vue.prototype._b = function(){}
    Vue.prototype._k = function(){}
    
    
    Vue.prototype.$on = function (event: string, fn: Function): Component {}
    Vue.prototype.$once = function (event: string, fn: Function): Component {}
    Vue.prototype.$off = function (event?: string, fn?: Function): Component {}
    Vue.prototype.$emit = function (event: string): Component {}
    
    
    Vue.prototype._mount = function(){}
    Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {}
    Vue.prototype._updateFromParent = function(){}
    Vue.prototype.$forceUpdate = function () {}
    Vue.prototype.$destroy = function () {}
    

    这样就结束了吗?并没有,根据我们之前寻找 Vue 的路线,这只是刚刚开始,我们追溯路线往回走,那么下一个处理 Vue 构造函数的应该是 src/core/index.js 文件,我们打开它:

    import Vue from './instance/index'
    import { initGlobalAPI } from './global-api/index'
    import { isServerRendering } from 'core/util/env'
    
    initGlobalAPI(Vue)
    
    Object.defineProperty(Vue.prototype, '$isServer', {
      get: isServerRendering
    })
    
    Vue.version = '__VERSION__'
    
    export default Vue
    

    这个文件也很简单,从 instance/index 中导入已经在原型上挂载了方法和属性后的 Vue,然后导入 initGlobalAPIisServerRendering,之后将 Vue 作为参数传给 initGlobalAPI ,最后又在 Vue.prototype 上挂载了 $isServer ,在 Vue 上挂载了 version 属性。

    initGlobalAPI 的作用是在 Vue 构造函数上挂载静态属性和方法,Vue 在经过 initGlobalAPI 之后,会变成这样:

    Vue.config
    Vue.util = util
    Vue.set = set
    Vue.delete = del
    Vue.nextTick = util.nextTick
    Vue.options = {
        components: {
            KeepAlive
        },
        directives: {},
        filters: {},
        _base: Vue
    }
    Vue.use
    Vue.mixin
    Vue.cid = 0
    Vue.extend
    Vue.component = function(){}
    Vue.directive = function(){}
    Vue.filter = function(){}
    
    Vue.prototype.$isServer
    Vue.version = '__VERSION__'
    

    其中,稍微复杂一点的就是 Vue.options,大家稍微分析分析就会知道他的确长成那个样子。下一个就是 web-runtime.js 文件了,web-runtime.js 文件主要做了三件事儿:

    经过 web-runtime.js 文件之后,Vue 变成下面这个样子:

    Vue.config.isUnknownElement = isUnknownElement
    Vue.config.isReservedTag = isReservedTag
    Vue.config.getTagNamespace = getTagNamespace
    Vue.config.mustUseProp = mustUseProp
    
    Vue.options = {
        components: {
            KeepAlive,
            Transition,
            TransitionGroup
        },
        directives: {
            model,
            show
        },
        filters: {},
        _base: Vue
    }
    Vue.prototype.__patch__
    Vue.prototype.$mount
    

    这里大家要注意的是 Vue.options 的变化。另外这里的 $mount 方法很简单:

    Vue.prototype.$mount = function (
      el?: string | Element,
      hydrating?: boolean
    ): Component {
      el = el && inBrowser ? query(el) : undefined
      return this._mount(el, hydrating)
    }
    

    首先根据是否是浏览器环境决定要不要 query(el) 获取元素,然后将 el 作为参数传递给 this._mount()

    最后一个处理 Vue 的文件就是入口文件 web-runtime-with-compiler.js 了,该文件做了两件事:

    1、缓存来自 web-runtime.js 文件的 $mount 函数

    const mount = Vue.prototype.$mount
    

    然后覆盖覆盖了 Vue.prototype.$mount

    2、在 Vue 上挂载 compile

    Vue.compile = compileToFunctions
    

    compileToFunctions 函数的作用,就是将模板 template 编译为 render 函数。

    至此,我们算是还原了 Vue 构造函数,总结一下:

    四、一个贯穿始终的例子

    在了解了 Vue 构造函数的设计之后,接下来,我们一个贯穿始终的例子就要登场了,掌声有请:

    let v = new Vue({
        el: '#app',
        data: {
            a: 1,
            b: [1, 2, 3]
        }
    })
    

    好吧,我承认这段代码你家没满月的孩子都会写了。这段代码就是我们贯穿始终的例子,它就是这篇文章的主线,在后续的讲解中,都会以这段代码为例,当讲到必要的地方,会为其添加选项,比如讲计算属性的时候当然要加上一个 computed 属性了。不过在最开始,我只传递了两个选项 el 以及 data,“我们看看接下来会发生什么,让我们拭目以待 “ —- NBA 球星在接受采访时最喜欢说这句话。

    当我们按照例子那样编码使用 Vue 的时候,Vue 都做了什么?

    想要知道 Vue 都干了什么,我们就要找到 Vue 初始化程序,查看 Vue 构造函数:

    function Vue (options) {
      if (process.env.NODE_ENV !== 'production' &&
        !(this instanceof Vue)) {
        warn('Vue is a constructor and should be called with the `new` keyword')
      }
      this._init(options)
    }
    
    
    

    我们发现,_init() 方法就是 Vue 调用的第一个方法,然后将我们的参数 options 透传了过去。在调用 _init() 之前,还做了一个安全模式的处理,告诉开发者必须使用 new 操作符调用 Vue。根据之前我们的整理,_init() 方法应该是在 src/core/instance/init.js 文件中定义的,我们打开这个文件查看 _init() 方法:

      Vue.prototype._init = function (options?: Object) {
        const vm: Component = this
        
        vm._uid = uid++
        
        vm._isVue = true
        
        if (options && options._isComponent) {
          
          
          
          initInternalComponent(vm, options)
        } else {
          vm.$options = mergeOptions(
            resolveConstructorOptions(vm.constructor),
            options || {},
            vm
          )
        }
        
        if (process.env.NODE_ENV !== 'production') {
          initProxy(vm)
        } else {
          vm._renderProxy = vm
        }
    
        
        vm._self = vm
        initLifecycle(vm)
        initEvents(vm)
        callHook(vm, 'beforeCreate')
        initState(vm)
        callHook(vm, 'created')
        initRender(vm)
      }
    

    _init() 方法在一开始的时候,在 this 对象上定义了两个属性:_uid_isVue,然后判断有没有定义 options._isComponent,在使用 Vue 开发项目的时候,我们是不会使用 _isComponent 选项的,这个选项是 Vue 内部使用的,按照本节开头的例子,这里会走 else 分支,也就是这段代码:

      vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    
    
    

    这样 Vue 第一步所做的事情就来了:使用策略对象合并参数选项

    可以发现,Vue 使用 mergeOptions 来处理我们调用 Vue 时传入的参数选项 (options),然后将返回值赋值给 this.$options (vm === this),传给 mergeOptions 方法三个参数,我们分别来看一看,首先是:resolveConstructorOptions(vm.constructor),我们查看一下这个方法:

    export function resolveConstructorOptions (Ctor: Class<Component>) {
      let options = Ctor.options
      if (Ctor.super) {
        const superOptions = Ctor.super.options
        const cachedSuperOptions = Ctor.superOptions
        const extendOptions = Ctor.extendOptions
        if (superOptions !== cachedSuperOptions) {
          
          Ctor.superOptions = superOptions
          extendOptions.render = options.render
          extendOptions.staticRenderFns = options.staticRenderFns
          extendOptions._scopeId = options._scopeId
          options = Ctor.options = mergeOptions(superOptions, extendOptions)
          if (options.name) {
            options.components[options.name] = Ctor
          }
        }
      }
      return options
    }
    

    这个方法接收一个参数 Ctor,通过传入的 vm.constructor 我们可以知道,其实就是 Vue 构造函数本身。所以下面这句代码:

    let options = Ctor.options
    
    
    

    相当于:

    let options = Vue.options
    
    
    

    大家还记得 Vue.options 吗?在寻找 Vue 构造函数一节里,我们整理了 Vue.options 应该长成下面这个样子:

    Vue.options = {
        components: {
            KeepAlive,
            Transition,
            TransitionGroup
        },
        directives: {
            model,
            show
        },
        filters: {},
        _base: Vue
    }
    

    之后判断是否定义了 Vue.super ,这个是用来处理继承的,我们后续再讲,在本例中,resolveConstructorOptions 方法直接返回了 Vue.options。也就是说,传递给 mergeOptions 方法的第一个参数就是 Vue.options

    传给 mergeOptions 方法的第二个参数是我们调用 Vue 构造函数时的参数选项,第三个参数是 vm 也就是 this 对象,按照本节开头的例子那样使用 Vue,最终运行的代码应该如下:

      vm.$options = mergeOptions(
          
        {
            components: {
                KeepAlive,
                Transition,
                TransitionGroup
            },
            directives: {
                model,
                show
            },
            filters: {},
            _base: Vue
        },
        
        {
            el: '#app',
            data: {
                a: 1,
                b: [1, 2, 3]
            }
        },
        
        vm
      )
    

    了解了这些,我们就可以看看 mergeOptions 到底做了些什么了,根据引用寻找到 mergeOptions 应该是在 src/core/util/options.js 文件中定义的。这个文件第一次看可能会头大,下面是我处理后的简略展示,大家看上去应该更容易理解了:

    import Vue from '../instance/index'
    其他引用...
    
    
    const strats = config.optionMergeStrategies
    
    strats.el = 
    strats.propsData = function (parent, child, vm, key){}
    strats.data = function (parentVal, childVal, vm)
    
    config._lifecycleHooks.forEach(hook => {
      strats[hook] = mergeHook
    })
    
    config._assetTypes.forEach(function (type) {
      strats[type + 's'] = mergeAssets
    })
    
    strats.watch = function (parentVal, childVal)
    
    strats.props =
    strats.methods =
    strats.computed = function (parentVal: ?Object, childVal: ?Object)
    // 默认的合并策略,如果有 `childVal` 则返回 `childVal` 没有则返回 `parentVal`
    const defaultStrat = function (parentVal: any, childVal: any): any {
      return childVal === undefined
        ? parentVal
        : childVal
    }
    
    
    export function mergeOptions (
      parent: Object,
      child: Object,
      vm?: Component
    ): Object {
    
      
      ...
    
      const options = {}
      let key
      for (key in parent) {
        mergeField(key)
      }
      for (key in child) {
        if (!hasOwn(parent, key)) {
          mergeField(key)
        }
      }
      function mergeField (key) {
        const strat = strats[key] || defaultStrat
        options[key] = strat(parent[key], child[key], vm, key)
      }
      return options
    
    }
    

    上面的代码中,我省略了一些工具函数,例如 mergeHookmergeAssets 等等,唯一需要注意的是这段代码:

    config._lifecycleHooks.forEach(hook => {
      strats[hook] = mergeHook
    })
    
    config._assetTypes.forEach(function (type) {
      strats[type + 's'] = mergeAssets
    })
    

    config 对象引用自 src/core/config.js 文件,最终的结果就是在 strats 下添加了相应的生命周期选项的合并策略函数为 mergeHook,添加指令 (directives)、组件(components)、过滤器(filters) 等选项的合并策略函数为 mergeAssets

    这样看来就清晰多了,拿我们贯穿本文的例子来说:

    let v = new Vue({
        el: '#app',
        data: {
            a: 1,
            b: [1, 2, 3]
        }
    })
    
    
    

    其中 el 选项会使用 defaultStrat 默认策略函数处理,data 选项则会使用 strats.data 策略函数处理,并且根据 strats.data 中的逻辑,strats.data 方法最终会返回一个函数:mergedInstanceDataFn

    这里就不详细的讲解每一个策略函数的内容了,后续都会讲到,这里我们还是抓住主线理清思路为主,只需要知道 Vue 在处理选项的时候,使用了一个策略对象对父子选项进行合并。并将最终的值赋值给实例下的 $options 属性即:this.$options,那么我们继续查看 _init() 方法在合并完选项之后,又做了什么:

    合并完选项之后,Vue 第二部做的事情就来了:初始化工作与 Vue 实例对象的设计

    前面讲了 Vue 构造函数的设计,并且整理了 Vue 原型属性与方法Vue 静态属性与方法,而 Vue 实例对象就是通过构造函数创造出来的,让我们来看一看 Vue 实例对象是如何设计的,下面的代码是 _init() 方法合并完选项之后的代码:

        
        if (process.env.NODE_ENV !== 'production') {
          initProxy(vm)
        } else {
          vm._renderProxy = vm
        }
    
        
        vm._self = vm
        initLifecycle(vm)
        initEvents(vm)
        callHook(vm, 'beforeCreate')
        initState(vm)
        callHook(vm, 'created')
        initRender(vm)
    

    根据上面的代码,在生产环境下会为实例添加两个属性,并且属性值都为实例本身:

    vm._renderProxy = vm
    vm._self = vm
    

    然后,调用了四个 init* 方法分别为:initLifecycleinitEventsinitStateinitRender,且在 initState 前后分别回调了生命周期钩子 beforeCreatecreated,而 initRender 是在 created 钩子执行之后执行的,看到这里,也就明白了为什么 created 的时候不能操作 DOM 了。因为这个时候还没有渲染真正的 DOM 元素到文档中。created 仅仅代表数据状态的初始化完成。

    根据四个 init* 方法的引用关系打开对应的文件查看对应的方法,我们发现,这些方法是在处理 Vue 实例对象,以及做一些初始化的工作,类似整理 Vue 构造函数一样,我同样针对 Vue 实例做了属性和方法的整理,如下:

    this._uid = uid++
    this._isVue = true
    this.$options = {
        components,
        directives,
        filters,
        _base,
        el,
        data: mergedInstanceDataFn()
    }
    this._renderProxy = this
    this._self = this
    
    
    this.$parent = parent
    this.$root = parent ? parent.$root : this
    
    this.$children = []
    this.$refs = {}
    
    this._watcher = null
    this._inactive = false
    this._isMounted = false
    this._isDestroyed = false
    this._isBeingDestroyed = false
    
    
    this._events = {}
    this._updateListeners = function(){}
    
    
    this._watchers = []
        
        this._data
    
    
    this.$vnode = null 
    this._vnode = null 
    this._staticTrees = null
    this.$slots
    this.$scopedSlots
    this._c
    this.$createElement
    

    以上就是一个 Vue 实例所包含的属性和方法,除此之外要注意的是,在 initEvents 中除了添加属性之外,如果有 vm.$options._parentListeners 还要调用 vm._updateListeners() 方法,在 initState 中又调用了一些其他 init 方法,如下:

    export function initState (vm: Component) {
      vm._watchers = []
      initProps(vm)
      initMethods(vm)
      initData(vm)
      initComputed(vm)
      initWatch(vm)
    }
    

    最后在 initRender 中如果有 vm.$options.el 还要调用 vm.$mount(vm.$options.el),如下:

      if (vm.$options.el) {
        vm.$mount(vm.$options.el)
      }
    

    这就是为什么如果不传递 el 选项就需要手动 mount 的原因了。

    那么我们依照我们本节开头的的例子,以及初始化的先后顺序来逐一看一看都发生了什么。我们将 initState 中的 init* 方法展开来看,执行顺序应该是这样的(从上到下的顺序执行):

    initLifecycle(vm)
    initEvents(vm)
    callHook(vm, 'beforeCreate')
    initProps(vm)
    initMethods(vm)
    initData(vm)
    initComputed(vm)
    initWatch(vm)
    callHook(vm, 'created')
    initRender(vm)
    

    首先是 initLifecycle,这个函数的作用就是在实例上添加一些属性,然后是 initEvents,由于 vm.$options._parentListeners 的值为 undefined 所以也仅仅是在实例上添加属性, vm._updateListeners(listeners) 并不会执行,由于我们只传递了 eldata,所以 initPropsinitMethodsinitComputedinitWatch 这四个方法什么都不会做,只有 initData 会执行。最后是 initRender,除了在实例上添加一些属性外,由于我们传递了 el 选项,所以会执行 vm.$mount(vm.$options.el)

    综上所述:按照我们的例子那样写,初始化工作只包含两个主要内容即:initDatainitRender

    五、通过 initData 看 Vue 的数据响应系统

    Vue 的数据响应系统包含三个部分:ObserverDepWatcher。关于数据响应系统的内容真的已经被文章讲烂了,所以我就简单的说一下,力求大家能理解就 ok,我们还是先看一下 initData 中的代码:

    function initData (vm: Component) {
      let data = vm.$options.data
      data = vm._data = typeof data === 'function'
        ? data.call(vm)
        : data || {}
      if (!isPlainObject(data)) {
        data = {}
        process.env.NODE_ENV !== 'production' && warn(
          'data functions should return an object:\n' +
          'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
          vm
        )
      }
      
      const keys = Object.keys(data)
      const props = vm.$options.props
      let i = keys.length
      while (i--) {
        if (props && hasOwn(props, keys[i])) {
          process.env.NODE_ENV !== 'production' && warn(
            `The data property "${keys[i]}" is already declared as a prop. ` +
            `Use prop default value instead.`,
            vm
          )
        } else {
          proxy(vm, keys[i])
        }
      }
      
      observe(data)
      data.__ob__ && data.__ob__.vmCount++
    }
    
    
    

    首先,先拿到 data 数据:let data = vm.$options.data,大家还记得此时 vm.$options.data 的值应该是通过 mergeOptions 合并处理后的 mergedInstanceDataFn 函数吗?所以在得到 data 后,它又判断了 data 的数据类型是不是 ‘function’,最终的结果是:data 还是我们传入的数据选项的 data,即:

    data: {
        a: 1,
        b: [1, 2, 3]
    }
    

    然后在实例对象上定义 _data 属性,该属性与 data 是相同的引用。

    然后是一个 while 循环,循环的目的是在实例对象上对数据进行代理,这样我们就能通过 this.a 来访问 data.a 了,代码的处理是在 proxy 函数中,该函数非常简单,仅仅是在实例对象上设置与 data 属性同名的访问器属性,然后使用 _data 做数据劫持,如下:

    function proxy (vm: Component, key: string) {
      if (!isReserved(key)) {
        Object.defineProperty(vm, key, {
          configurable: true,
          enumerable: true,
          get: function proxyGetter () {
            return vm._data[key]
          },
          set: function proxySetter (val) {
            vm._data[key] = val
          }
        })
      }
    }
    

    做完数据的代理,就正式进入响应系统,

    observe(data)
    

    我们说过,数据响应系统主要包含三部分:ObserverDepWatcher,代码分别存放在:observer/index.jsobserver/dep.js 以及 observer/watcher.js 文件中,这回我们换一种方式,我们先不看其源码,大家先跟着我的思路来思考,最后回头再去看代码,你会有一种:” 奥,不过如此 “的感觉。

    假如,我们有如下代码:

    var data = {
        a: 1,
        b: {
            c: 2
        }
    }
    
    observer(data)
    
    new Watch('a', () => {
        alert(9)
    })
    new Watch('a', () => {
        alert(90)
    })
    new Watch('b.c', () => {
        alert(80)
    })
    

    这段代码目的是,首先定义一个数据对象 data,然后通过 observer 对其进行观测,之后定义了三个观察者,当数据有变化时,执行相应的方法,这个功能使用 Vue 的实现原来要如何去实现?其实就是在问 observer 怎么写?Watch 构造函数又怎么写?接下来我们逐一实现。

    首先,observer 的作用是:将数据对象 data 的属性转换为访问器属性:

    class Observer {
        constructor (data) {
            this.walk(data)
        }
        walk (data) {
            
            let keys = Object.keys(data)
            for(let i = 0; i < keys.length; i++){
                defineReactive(data, keys[i], data[keys[i]])
            }
        }
    }
    
    
    function defineReactive (data, key, val) {
        
        observer(val)
    
        Object.defineProperty(data, key, {
            enumerable: true,
            configurable: true,
            get: function () {
                return val
            },
            set: function (newVal) {
                if(val === newVal){
                    return
                }
                
                observer(newVal)
            }
        })
    }
    
    
    function observer (data) {
        if(Object.prototype.toString.call(data) !== '[object Object]') {
            return
        }
        new Observer(data)
    }
    

    上面的代码中,我们定义了 observer 方法,该方法检测了数据 data 是不是纯 JavaScript 对象,如果是就调用 Observer 类,并将 data 作为参数透传。在 Observer 类中,我们使用 walk 方法对数据 data 的属性循环调用 defineReactive 方法,defineReactive 方法很简单,仅仅是将数据 data 的属性转为访问器属性,并对数据进行递归观测,否则只能观测数据 data 的直属子属性。这样我们的第一步工作就完成了,当我们修改或者获取 data 属性值的时候,通过 getset 即能获取到通知。

    我们继续往下看,来看一下 Watch

    new Watch('a', () => {
        alert(9)
    })
    

    现在的问题是,Watch 要怎么和 observer 关联???????我们看看 Watch 它知道些什么,通过上面调用 Watch 的方式,传递给 Watch 两个参数,一个是 ‘a’ 我们可以称其为表达式,另外一个是回调函数。所以我们目前只能写出这样的代码:

    class Watch {
        constructor (exp, fn) {
            this.exp = exp
            this.fn = fn
        }
    }
    

    那么要怎么关联呢,大家看下面的代码会发生什么:

    class Watch {
        constructor (exp, fn) {
            this.exp = exp
            this.fn = fn
            data[exp]
        }
    }
    

    多了一句 data[exp],这句话是在干什么?是不是在获取 data 下某个属性的值,比如 exp 为 ‘a’ 的话,那么 data[exp] 就相当于在获取 data.a 的值,那这会放生什么?大家不要忘了,此时数据 data 下的属性已经是访问器属性了,所以这么做的结果会直接触发对应属性的 get 函数,这样我们就成功的和 observer 产生了关联,但这样还不够,我们还是没有达到目的,不过我们已经无限接近了,我们继续思考看一下可不可以这样:

    答案是可以的,不过这个时候我们就需要 Dep 出场了,它是一个依赖收集器。我们的思路是:data 下的每一个属性都有一个唯一的 Dep 对象,在 get 中收集仅针对该属性的依赖,然后在 set 方法中触发所有收集的依赖,这样就搞定了,看如下代码:

    class Dep {
        constructor () {
            this.subs = []
        }
        addSub () {
            this.subs.push(Dep.target)
        }
        notify () {
            for(let i = 0; i < this.subs.length; i++){
                this.subs[i].fn()
            }
        }
    }
    Dep.target = null
    function pushTarget(watch){
        Dep.target = watch
    }
    
    class Watch {
        constructor (exp, fn) {
            this.exp = exp
            this.fn = fn
            pushTarget(this)
            data[exp]
        }
    }
    
    
    

    上面的代码中,我们在 Watch 中增加了 pushTarget(this),可以发现,这句代码的作用是将 Dep.target 的值设置为该 Watch 对象。在 pushTarget 之后我们才对表达式进行求值,接着,我们修改 defineReactive 代码如下

    function defineReactive (data, key, val) {
        observer(val)
        let dep = new Dep()        
        Object.defineProperty(data, key, {
            enumerable: true,
            configurable: true,
            get: function () {
                dep.addSub()    
                return val
            },
            set: function (newVal) {
                if(val === newVal){
                    return
                }
                observer(newVal)
                dep.notify()    
            }
        })
    }
    

    如标注,新增了三句代码,我们知道,Watch 中对表达式求值会触发 get 方法,我们在 get 方法中调用了 dep.addSub,也就执行了这句代码:this.subs.push(Dep.target),由于在这句代码执行之前,Dep.target 的值已经被设置为一个 Watch 对象了,所以最终结果就是收集了一个 Watch 对象,然后在 set 方法中我们调用了 dep.notify,所以当 data 属性值变化的时候,就会通过 dep.notify 循环调用所有收集的 Watch 对象中的回调函数:

    notify () {
        for(let i = 0; i < this.subs.length; i++){
            this.subs[i].fn()
        }
    }
    

    这样 observerDepWatch 三者就联系成为一个有机的整体,实现了我们最初的目标,完整的代码可以戳这里:observer-dep-watch。这里还给大家挖了个坑,因为我们没有处理对数组的观测,由于比较复杂并且这又不是我们讨论的重点,如果大家想了解可以戳我的这篇文章:JavaScript 实现 MVVM 之我就是想监测一个普通对象的变化,另外,在 Watch 中对表达式求值的时候也只做了直接子属性的求值,所以如果 exp 的值为 ‘a.b’ 的时候,就不可以用了,Vue 的做法是使用 . 分割表达式字符串为数组,然后遍历一下对其进行求值,大家可以查看其源码。如下:

    const bailRE = /[^\w.$]/
    export function parsePath (path: string): any {
      if (bailRE.test(path)) {
        return
      } else {
        const segments = path.split('.')
        return function (obj) {
          for (let i = 0; i < segments.length; i++) {
            if (!obj) return
            obj = obj[segments[i]]
          }
          return obj
        }
      }
    }
    

    Vue 的求值代码是在 src/core/util/lang.js 文件中 parsePath 函数中实现的。总结一下 Vue 的依赖收集过程应该是这样的:

    实际上,Vue 并没有直接在 get 中调用 addSub,而是调用的 dep.depend,目的是将当前的 dep 对象收集到 watch 对象中,如果要完整的流程,应该是这样的:(大家注意数据的每一个字段都拥有自己的 dep 对象和 get 方法。)

    这样 Vue 就建立了一套数据响应系统,之前我们说过,按照我们的例子那样写,初始化工作只包含两个主要内容即:initDatainitRender。现在 initData 我们分析完了,接下来看一看 initRender

    六、通过 initRender 看 Vue 的 render(渲染) 与 re-render(重新渲染)

    initRender 方法中,因为我们的例子中传递了 el 选项,所以下面的代码会执行:

      if (vm.$options.el) {
        vm.$mount(vm.$options.el)
      }
    

    这里,调用了 $mount 方法,在还原 Vue 构造函数的时候,我们整理过所有的方法,其中 $mount 方法在两个地方出现过:

    1、在 web-runtime.js 文件中:

    Vue.prototype.$mount = function (
      el?: string | Element,
      hydrating?: boolean
    ): Component {
      el = el && inBrowser ? query(el) : undefined
      return this._mount(el, hydrating)
    }
    

    它的作用是通过 el 获取相应的 DOM 元素,然后调用 lifecycle.js 文件中的 _mount 方法。

    2、在 web-runtime-with-compiler.js 文件中:

    const mount = Vue.prototype.$mount
    
    Vue.prototype.$mount = function (
      el?: string | Element,
      hydrating?: boolean
    ): Component {
      
      el = el && query(el)
      
      if (el === document.body || el === document.documentElement) {
        process.env.NODE_ENV !== 'production' && warn(
          `Do not mount Vue to <html> or <body> - mount to normal elements instead.`
        )
        return this
      }
    
      const options = this.$options
      
      if (!options.render) {
        let template = options.template
        if (template) {
          if (typeof template === 'string') {
            if (template.charAt(0) === '#') {
              template = idToTemplate(template)
              
              if (process.env.NODE_ENV !== 'production' && !template) {
                warn(
                  `Template element not found or is empty: ${options.template}`,
                  this
                )
              }
            }
          } else if (template.nodeType) {
            template = template.innerHTML
          } else {
            if (process.env.NODE_ENV !== 'production') {
              warn('invalid template option:' + template, this)
            }
            return this
          }
        } else if (el) {
          template = getOuterHTML(el)
        }
        if (template) {
          const { render, staticRenderFns } = compileToFunctions(template, {
            warn,
            shouldDecodeNewlines,
            delimiters: options.delimiters
          }, this)
          options.render = render
          options.staticRenderFns = staticRenderFns
        }
      }
      
      return mount.call(this, el, hydrating)
    }
    

    分析一下可知 web-runtime-with-compiler.js 的逻辑如下:

    1、缓存来自 web-runtime.js 文件的 $mount 方法

    2、判断有没有传递 render 选项,如果有直接调用来自 web-runtime.js 文件的 $mount 方法

    3、如果没有传递 render 选项,那么查看有没有 template 选项,如果有就使用 compileToFunctions 函数根据其内容编译成 render 函数

    4、如果没有 template 选项,那么查看有没有 el 选项,如果有就使用 compileToFunctions 函数将其内容 (template = getOuterHTML(el)) 编译成 render 函数

    5、将编译成的 render 函数挂载到 this.$options 属性下,并调用缓存下来的 web-runtime.js 文件中的 $mount 方法

    简单的用一张图表示 mount 方法的调用关系,从上至下调用:

    Vue2.1.7 源码学习 | HcySunYang

    不过不管怎样,我们发现这些步骤的最终目的是生成 render 函数,然后再调用 lifecycle.js 文件中的 _mount 方法,我们看看这个方法做了什么事情,查看 _mount 方法的代码,这是简化过得:

      Vue.prototype._mount = function (
        el?: Element | void,
        hydrating?: boolean
      ): Component {
        const vm: Component = this
    
        
        vm.$el = el
    
        
        callHook(vm, 'beforeMount')
    
        vm._watcher = new Watcher(vm, () => {
          vm._update(vm._render(), hydrating)
        }, noop)
    
        
        if (vm.$vnode == null) {
          vm._isMounted = true
          callHook(vm, 'mounted')
        }
        return vm
      }
    

    上面的代码很简单,该注释的都注释了,唯一需要看的就是这段代码:

    vm._watcher = new Watcher(vm, () => {
      vm._update(vm._render(), hydrating)
    }, noop)
    

    看上去很眼熟有没有?我们平时使用 Vue 都是这样使用 watch 的:

    this.$watch('a', (newVal, oldVal) => {
    
    })
    // 或者
    this.$watch(function(){
        return this.a + this.b
    }, (newVal, oldVal) => {
    
    })
    

    第一个参数是 表达式或者函数,第二个参数是回调函数,第三个参数是可选的选项。原理是 Watch 内部对表达式求值或者对函数求值从而触发数据的 get 方法收集依赖。可是 _mount 方法中使用 Watcher 的时候第一个参数 vm 是什么鬼。我们不妨去看看源码中 $watch 函数是如何实现的,根据之前还原 Vue 构造函数中所整理的内容可知:$warch 方法是在 src/core/instance/state.js 文件中的 stateMixin 方法中定义的,源码如下:

      Vue.prototype.$watch = function (
        expOrFn: string | Function,
        cb: Function,
        options?: Object
      ): Function {
        const vm: Component = this
        options = options || {}
        options.user = true
        const watcher = new Watcher(vm, expOrFn, cb, options)
        if (options.immediate) {
          cb.call(vm, watcher.value)
        }
        return function unwatchFn () {
          watcher.teardown()
        }
      }
    

    我们可以发现,$warch 其实是对 Watcher 的一个封装,内部的 Watcher 的第一个参数实际上也是 vm 即:Vue 实例对象,这一点我们可以在 Watcher 的源码中得到验证,代开 observer/watcher.js 文件查看:

    export default class Watcher {
    
      constructor (
        vm: Component,
        expOrFn: string | Function,
        cb: Function,
        options?: Object = {}
      ) {
    
      }
    }
    
    
    

    可以发现真正的 Watcher 第一个参数实际上就是 vm。第二个参数是表达式或者函数,然后以此类推,所以现在再来看 _mount 中的这段代码:

    vm._watcher = new Watcher(vm, () => {
      vm._update(vm._render(), hydrating)
    }, noop)
    

    忽略第一个参数 vm,也就说,Watcher 内部应该对第二个参数求值,也就是运行这个函数:

    () => {
      vm._update(vm._render(), hydrating)
    }
    

    所以 vm._render() 函数被第一个执行,该函数在 src/core/instance/render.js 中,该方法中的代码很多,下面是简化过的:

      Vue.prototype._render = function (): VNode {
        const vm: Component = this
        
        const {
          render,
          staticRenderFns,
          _parentVnode
        } = vm.$options
        ...
    
        let vnode
        try {
          
          vnode = render.call(vm._renderProxy, vm.$createElement)
        } catch (e) {
          ...
        }
    
        
        vnode.parent = _parentVnode
        return vnode
      }
    

    _render 方法首先从 vm.$options 中解构出 render 函数,大家应该记得:vm.$options.render 方法是在 web-runtime-with-compiler.js 文件中通过 compileToFunctions 方法将 templateel 编译而来的。解构出 render 函数后,接下来便执行了该方法:

    vnode = render.call(vm._renderProxy, vm.$createElement)
    

    其中使用 call 指定了 render 函数的作用域环境为 vm._renderProxy,这个属性在我们整理实例对象的时候知道,他是在 Vue.prototype._init 方法中被添加的,即:vm._renderProxy = vm,其实就是 Vue 实例对象本身,然后传递了一个参数:vm.$createElement。那么 render 函数到底是干什么的呢?让我们根据上面那句代码猜一猜,我们已经知道 render 函数是从 templateel 编译而来的,如果没错的话应该是返回一个虚拟 DOM 对象。我们不妨使用 console.log 打印一下 render 函数,当我们的模板这样编写时:

    <ul>
      <li>{{a}}</li>
    </ul>
    

    打印的 render 函数如下:

    7xlolm.com1.z0.glb.clouddn.com/vueimgr2.pn…

    我们修改模板为:

    <ul>
      <li v-for="i in b">{{a}}</li>
    </ul>
    
    
    

    打印出来的 render 函数如下:

    7xlolm.com1.z0.glb.clouddn.com/vueimgr3.pn…

    其实了解 Vue2.x 版本的同学都知道,Vue 提供了 render 选项,作为 template 的代替方案,同时为 JavaScript 提供了完全编程的能力,下面两种编写模板的方式实际是等价的:

    new Vue({
        el: '#app',
        data: {
            a: 1
        },
        template: '<ul><li>{{a}}</li><li>{{a}}</li></ul>'
    })
    
    
    new Vue({
        el: '#app',
        render: function (createElement) {
            createElement('ul', [
                createElement('li', this.a),
                createElement('li', this.a)
            ])
        }
    })
    
    
    

    现在我们再来看我们打印的 render 函数:

    function anonymous() {
        with(this){
            return _c('ul', { 
                attrs: {"id": "app"}
            },[
                _c('li', [_v(_s(a))])
            ])
        }
    }
    

    是不是与我们自己写 render 函数很像?因为 render 函数的作用域被绑定到了 Vue 实例,即:render.call(vm._renderProxy, vm.$createElement),所以上面代码中 _c_v_s 以及变量 a相当于 Vue 实例下的方法和变量。大家还记得诸如 _c_v_s 这样的方法在哪里定义的吗?我们在整理 Vue 构造函数的时候知道,他们在 src/core/instance/render.js 文件中的 renderMixin 方法中定义,除了这些之外还有诸如:_l_m_o 等等。其中 _l 就在我们使用 v-for 指令的时候出现了。所以现在大家知道为什么这些方法都被定义在 render.js 文件中了吧,因为他们就是为了构造出 render 函数而存在的。

    现在我们已经知道了 render 函数的长相,也知道了 render 函数的作用域是 Vue 实例本身即:this(或vm)。那么当我们执行 render 函数时,其中的变量如:a,就相当于:this.a,我们知道这是在求值,所以 _mount 中的这段代码:

    vm._watcher = new Watcher(vm, () => {
      vm._update(vm._render(), hydrating)
    }, noop)
    

    vm._render 执行的时候,所依赖的变量就会被求值,并被收集为依赖。按照 Vue 中 watcher.js 的逻辑,当依赖的变量有变化时不仅仅回调函数被执行,实际上还要重新求值,即还要执行一遍:

    () => {
      vm._update(vm._render(), hydrating)
    }
    

    这实际上就做到了 re-render,因为 vm._update 就是文章开头所说的虚拟 DOM 中的最后一步:patch

    vm_render 方法最终返回一个 vnode 对象,即虚拟 DOM,然后作为 vm_update 的第一个参数传递了过去,我们看一下 vm_update 的逻辑,在 src/core/instance/lifecycle.js 文件中有这么一段代码:

        if (!prevVnode) {
          
          vm.$el = vm.__patch__(
            vm.$el, vnode, hydrating, false ,
            vm.$options._parentElm,
            vm.$options._refElm
          )
        } else {
          
          vm.$el = vm.__patch__(prevVnode, vnode)
        }
    

    如果还没有 prevVnode 说明是首次渲染,直接创建真实 DOM。如果已经有了 prevVnode 说明不是首次渲染,那么就采用 patch 算法进行必要的 DOM 操作。这就是 Vue 更新 DOM 的逻辑。只不过我们没有将 virtual DOM 内部的实现。

    现在我们来好好理理思路,当我们写如下代码时:

    new Vue({
        el: '#app',
        data: {
            a: 1,
            b: [1, 2, 3]
        }
    })
    

    Vue 所做的事:

    用一张详细一点的图表示就是这样的:

    7xlolm.com1.z0.glb.clouddn.com/vueimgdetai…

    到此,我们从大体流程,挑着重点的走了一遍 Vue,但是还有很多细节我们没有提及,比如:

    1、将模板转为 render 函数的时候,实际是先生成的抽象语法树(AST),再将抽象语法树转成的 render 函数,而且这一整套的代码我们也没有提及,因为他在复杂了,其实这部分内容就是在完正则。

    2、我们也没有详细的讲 Virtual DOM 的实现原理,网上已经有文章讲了,大家可以搜一搜

    3、我们的例子中仅仅传递了 eldata 选项,大家知道 Vue 支持的选项很多,比如我们都没有讲到,但都是触类旁通的,比如你搞清楚了 data 选项再去看 computed 选项或者 props 选项就会很容易,比如你知道了 Watcher 的工作机制再去看 watch 选项就会很容易。

    本篇文章作为 Vue 源码的启蒙文章,也许还有很多缺陷,全当抛砖引玉了。


    起源地下载网 » Vue2.1.7 源码学习 | HcySunYang

    常见问题FAQ

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

    发表评论

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

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

    联系作者

    请选择支付方式

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