您的位置:  首页 > 技术 > 前端 > 正文

@vue/composition-api 解析

2021-08-12 12:00 https://my.oschina.net/u/5079097/blog/5178538 PHP开发工程师 次阅读 条评论
 

前言

组合式 API 是 vue3 提出的一个新的开发方式,而在 vue2 中我们可以使用新的组合式 API 进行组件开发。本篇通过一个例子,来分析这个插件是如何提供功能。

关于该插件的安装、使用,可以直接阅读文档。

 

安装

我们从最开始安装分析,一探究竟。

 

vue.use

按照文档所提到的,我们必须通过 Vue.use() 进行安装:

// vue.use 安装

import Vue from 'vue'

import VueCompositionAPI from '@vue/composition-api'

Vue.use(VueCompositionAPI)

复制代码
 

我们先看入口文件

// index.js

import type Vue from 'vue'

import { Data, SetupFunction } from './component'

import { Plugin } from './install'

 

export default Plugin

 

// auto install when using CDN

if (typeof window !== 'undefined' && window.Vue) {

  window.Vue.use(Plugin)

}

复制代码
 

可以知道我们 Vue.use 时,传入的就是 install 文件中的 Plugin 对象。

// install.ts 折叠源码

export function install(Vue: VueConstructor) {

  if (isVueRegistered(Vue)) {

    if (__DEV__) {

      warn(

        '[vue-composition-api] already installed. Vue.use(VueCompositionAPI) should be called only once.'

      )

    }

    return

  }

 

  if (__DEV__) {

    if (Vue.version) {

      if (Vue.version[0] !== '2' || Vue.version[1] !== '.') {

        warn(

          `[vue-composition-api] only works with Vue 2, v${Vue.version} found.`

        )

      }

    } else {

      warn('[vue-composition-api] no Vue version found')

    }

  }

 

  Vue.config.optionMergeStrategies.setup = function (

    parent: Function,

    child: Function

  ) {

    return function mergedSetupFn(props: any, context: any) {

      return mergeData(

        typeof parent === 'function' ? parent(props, context) || {} : undefined,

        typeof child === 'function' ? child(props, context) || {} : undefined

      )

    }

  }

 

  setVueConstructor(Vue)

  mixin(Vue)

}

 

export const Plugin = {

  install: (Vue: VueConstructor) => install(Vue),

}

复制代码
   

install

通过上面的代码和 Vue.use 可知,我们安装时其实就是调用了 install 方法,先分析一波 install。根据代码块及功能可以分成三个部分:

  1. 前两个大 if 的开发 check 部分
  2. 关于 setup 合并策略
  3. 通过 mixin 混入插件关于 组合式 API 的处理逻辑

第一部分中的第一个 if 是为了确保该 install 方法只被调用一次,避免浪费性能;第二个 if 则是确保vue版本为2.x。不过这里有个关于第一个if的小问题:多次注册插件时,Vue.use 自己本身会进行重复处理——安装过的插件再次注册时,不会调用 install 方法(Vue.use代码见下)。那么这个 if 的目的是啥?

// Vue.use 部分源码

Vue.use = function (plugin: Function | Object) {

  const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))

  if (installedPlugins.indexOf(plugin) > -1) {

    return this

  }

 

  // additional parameters

  const args = toArray(arguments, 1)

  args.unshift(this)

  if (typeof plugin.install === 'function') {

    plugin.install.apply(plugin, args)

  } else if (typeof plugin === 'function') {

    plugin.apply(null, args)

  }

  installedPlugins.push(plugin)

  return this

}

复制代码
 

根据上面代码可知 Vue.use 实际上还是传入 vue 并调用插件的 install 方法,那么如果有大神(或者是奇葩?)绕过 Vue.use 直接调用,那么这个 if 的判断就生效了。如下方代码,此时第二个 install 会判断重复后,抛出错误

// 直接调用 install

import Vue from 'vue'

import VueCompositionAPI from '@vue/composition-api'

import App from './App.vue'

 

Vue.config.productionTip = false

 

VueCompositionAPI.install(Vue)

VueCompositionAPI.install(Vue)

复制代码
 

报错:

image2021-6-23_16-38-4.png

第二部分的合并策略是“Vue.config.optionMergeStrategies”这个代码块。Vue 提供的这个能力很生僻,我们日常的开发中几乎不会主动接触到。先上文档

image2021-6-23_16-50-30.png

这是用来定义属性的合并行为。比如例子中的 extend 在调用时,会执行 mergeOptions。

// Vue.extend

Vue.extend = function (extendOptions) {

    const Super = this

    extendOptions = extendOptions || {}

    Sub.options = mergeOptions(

      Super.options,

      extendOptions

    )

}

复制代码
 

而 mergeOptions 里关于 _my_option的相关如下:

const strats = config.optionMergeStrategies

function mergeOptions (parent, child, vm){

  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)

  }

}

复制代码
 

这里的 parent 就是 Super.options 也就是 Vue.options,而 child 就是 extendOptions 也就是我们传入的 { _my_option: 1 }。在这里使用了两个 for 循环,确保父子元素种所有的 key 都会执行到 mergeField,而第二个 for 循环中的 if 判断确保不会执行两次,保证了正确性及性能。而 mergeField 则是最终执行策略的地方。从 strats 中获取到我们定义的方法,把对应参数传入并执行,在这里就是:

// demo执行

strat(undefined, 1, vm, '_my_option') // return 2

复制代码
 

顺便一提,Vue.mixin 的实现就是 mergeOptions,也就是说当我们使用了 mixin 且里面具有 setup 属性时,会执行到上述合并策略。

Vue.mixin = function (mixin) {

  this.options = mergeOptions(this.options, mixin)

  return this

}

复制代码
 

而我们插件中相关的策略也很简单,获取好定义的父子 setup,然后合并成一个新的,在调用时会分别执行父子 setup,并通过 mergeData 方法合并返回:

// optionMergeStrategies.setup

Vue.config.optionMergeStrategies.setup = function (

  parent: Function,

  child: Function

) {

  return function mergedSetupFn(props: any, context: any) {

    return mergeData(

      typeof parent === 'function' ? parent(props, context) || {} : undefined,

      typeof child === 'function' ? child(props, context) || {} : undefined

    )

  }

}

复制代码
 

第三部分则是通过调用 mixin 方法向 vue 中混入一些事件,下面是 mixin 的定义:

function mixin(Vue) {

  Vue.mixin({

    beforeCreate: functionApiInit,

    mounted(this: ComponentInstance) {

      updateTemplateRef(this)

    },

    updated(this: ComponentInstance) {

      updateTemplateRef(this)

    }

  })

   

  function functionApiInit() {}

  function initSetup() {}

  // 省略...

}

复制代码
 

可以看到 mixin 内部调用了 Vue.mixin 来想 beforeCreate、mounted、updated 等生命周期混入事件。这样就完成 install 的执行, Vue.use(VueCompositionAPI) 也到此结束。

 

初始化 — functionApiInit

 

functionApiInit 执行

我们知道在new Vue 时,会执行组件的 beforeCreate 生命周期。此时刚才通过 Vue.mixin 注入的函数 “functionApiInit”开始执行。

function Vue (options) {

  this._init(options)

}

Vue.prototype._init = function (options) {

  initLifecycle(vm)

  initEvents(vm)

  initRender(vm)

  callHook(vm, 'beforeCreate') // 触发 beforeCreate 生命周期,执行 functionApiInit

  initInjections(vm) // resolve injections before data/props

  initState(vm)

  initProvide(vm) // resolve provide after data/props

  callHook(vm, 'created')

}

复制代码
 

该方法也很清晰,分别暂存了组件最开始的 render方法和 data方法(我们平常写的 data 是一个函数),然后在这基础上又扩展了一下这两个方法,达到类似钩子的目的。

function functionApiInit(this: ComponentInstance) {

  const vm = this

  const $options = vm.$options

  const { setup, render } = $options

 

  if (render) {

    // keep currentInstance accessible for createElement

    $options.render = function (...args: any): any {

      return activateCurrentInstance(vm, () => render.apply(this, args))

    }

  }

 

  if (!setup) {

    return

  }

  if (typeof setup !== 'function') {

    if (__DEV__) {

      warn(

        'The "setup" option should be a function that returns a object in component definitions.',

        vm

      )

    }

    return

  }

 

  const { data } = $options

  // wrapper the data option, so we can invoke setup before data get resolved

  $options.data = function wrappedData() {

    initSetup(vm, vm.$props)

    return typeof data === 'function'

      ? (data as (

          this: ComponentInstance,

          x: ComponentInstance

        ) => object).call(vm, vm)

      : data || {}

  }

}

复制代码
 

虽然是先扩展的 render,但在 new Vue 的实际执行中会优先执行下方扩展的方法 “wrappedData”。因为 data 的执行是在 new Vue 时发生,而 render 的执行在 $mount 中。所以我们这里就按照执行顺序来看看如何扩展我们的 wrappedData。

wrappedData 这里只是简单执行了 initSetup 方法,对原先的 data 做了判断。这里是因为 Vue 执行时拿到的 data 已经是 wrappedData 这个函数而不是用户编写的 data,所以关于原 data 的处理移交在了 wrappedData 中。可以说 99%的逻辑都在 initSetup 中。我们接下来看这个方法。

 

setup 调用及处理

这块是通过 initSetup 函数实现的,代码很长且仅有几行是这里不用关心的(可自行研究),整体上可以跟着注释走一遍。

function initSetup(vm: ComponentInstance, props: Record<any, any> = {}) {

  // 获取定义好的 setup

  const setup = vm.$options.setup!

  // 创建 setup 方法接收的第二个参数 context,主流程中使用不上,先忽略

  const ctx = createSetupContext(vm)

 

  // fake reactive for `toRefs(props)`

  // porps 相关,主流成可先忽略(毕竟可以不写 props...)

  def(props, '__ob__', createObserver())

 

  // resolve scopedSlots and slots to functions

  // slots 相关,同 props 先忽略

  // @ts-expect-error

  resolveScopedSlots(vm, ctx.slots)

 

  let binding: ReturnType<SetupFunction<Data, Data>> | undefined | null

  // 执行 setup

  activateCurrentInstance(vm, () => {

    // make props to be fake reactive, this is for `toRefs(props)`

    binding = setup(props, ctx)

  })

 

  // 以下都是根据 setup 返回值,进行的一些处理

  if (!binding) return

  if (isFunction(binding)) {  // setup 可以返回一个渲染函数(render)

    // keep typescript happy with the binding type.

    const bindingFunc = binding

    // keep currentInstance accessible for createElement

    // 获取到渲染函数后,手动添加再 vue 实例上

    vm.$options.render = () => {

      // @ts-expect-error

      resolveScopedSlots(vm, ctx.slots)

      return activateCurrentInstance(vm, () => bindingFunc())

    }

    return

  } else if (isPlainObject(binding)) { // setup 返回的是一个普通对象

    if (isReactive(binding)) { // 如果返回的是通过 reactive 方法定义的对象,需要通过 toRefs 结构

      binding = toRefs(binding) as Data

    }

     

    // 用于 slots 及 $refs ,先忽略

    vmStateManager.set(vm, 'rawBindings', binding)

    const bindingObj = binding

 

    // 遍历返回值,做一些处理

    Object.keys(bindingObj).forEach((name) => {

      let bindingValue: any = bindingObj[name]

 

      if (!isRef(bindingValue)) {

        if (!isReactive(bindingValue)) {

          if (isFunction(bindingValue)) {

            bindingValue = bindingValue.bind(vm)

          } else if (!isObject(bindingValue)) {

            bindingValue = ref(bindingValue)

          } else if (hasReactiveArrayChild(bindingValue)) {

            // creates a custom reactive properties without make the object explicitly reactive

            // NOTE we should try to avoid this, better implementation needed

            customReactive(bindingValue)

          }

        } else if (isArray(bindingValue)) {

          bindingValue = ref(bindingValue)

        }

      }

      asVmProperty(vm, name, bindingValue)

    })

 

    return

  }

  // 不是对象和方法时,在开发环境下抛错

  if (__DEV__) {

    assert(

      false,

      `"setup" must return a "Object" or a "Function", got "${Object.prototype.toString

        .call(binding)

        .slice(8, -1)}"`

    )

  }

}

复制代码
 

我们先聚焦到 setup 的执行。setup 包裹在 activateCurrentInstance 方法中,activateCurrentInstance 目的是为了设置当前的实例。类似我们平常写的交换a、b变量的值。setup 在调用前,会先获取 currentInstance 变量并赋值给 preVm,最开始时currentInstance 为 null。接着再把 currentInstance 设置成当前的 vue 实例,于是我们变可以在 setup 通过 插件提供的 getCurrentInstance 方法获取到当前实例。在执行完毕后,又通过 setCurrentInstance(preVm) 把 currentInstance 重置为null。所以印证了文档中所说的,只能在 setup 及生命周期(不在本篇重点)中使用 getCurrentInstance 方法。

// setup执行

activateCurrentInstance(vm, () => {

  // make props to be fake reactive, this is for `toRefs(props)`

  binding = setup(props, ctx)

})

 

function activateCurrentInstance(vm, fn, onError) {

  let preVm = getCurrentVue2Instance()

  setCurrentInstance(vm)

  try {

    return fn(vm)

  } catch (err) {

    if (onError) {

      onError(err)

    } else {

      throw err

    }

  } finally {

    setCurrentInstance(preVm)

  }

}

 

 

let currentInstance = null

 

 

function setCurrentInstance(vm) {

  // currentInstance?.$scopedSlots

  currentInstance = vm

}

 

 

function getCurrentVue2Instance() {

  return currentInstance

}

 

 

function getCurrentInstance() {

  if (currentInstance) {

    return toVue3ComponentInstance(currentInstance)

  }

  return null

}

复制代码
 

这里有个思考,为什么需要在最后把 currentInstance 设置为 null?我们写了一个点击事件,并在相关的事件代码里调用了getCurrentInstance 。如果在 setup 调用重置为 null ,那么在该事件里就可能导致获取到错误的 currentInstance。于是就置为null 用来避免这个问题。(个人想法,期待指正)。

setup 内部可能会执行的东西有很多,比如通过 ref 定义一个响应式变量,这块放在后续单独说。

当获取完 setup 的返回值 binding 后,会根据其类型来做处理。如果返回函数,则说明这个 setup 返回的是一个渲染函数,便把放回值赋值给 vm.$options.render 供挂载时调用。如果返回的是一个对象,则会做一些相应式处理,这块内容和响应式相关,我们后续和响应式一块看。

// setup 返回对象



if (isReactive(binding)) {

  binding = toRefs(binding) as Data

}

 

vmStateManager.set(vm, 'rawBindings', binding)

const bindingObj = binding

 

Object.keys(bindingObj).forEach((name) => {

  let bindingValue: any = bindingObj[name]

 

  if (!isRef(bindingValue)) {

    if (!isReactive(bindingValue)) {

      if (isFunction(bindingValue)) {

        bindingValue = bindingValue.bind(vm)

      } else if (!isObject(bindingValue)) {

        bindingValue = ref(bindingValue)

      } else if (hasReactiveArrayChild(bindingValue)) {

        // creates a custom reactive properties without make the object explicitly reactive

        // NOTE we should try to avoid this, better implementation needed

        customReactive(bindingValue)

      }

    } else if (isArray(bindingValue)) {

      bindingValue = ref(bindingValue)

    }

  }

  asVmProperty(vm, name, bindingValue)

})

复制代码
 

我们这里只看重点函数 “asVmProperty”。我们知道 setup 返回的是一个对象 (赋值给了 binding / bindingObj),且里面的所有属性都能在 vue 的其他选项中使用。那么这块是如何实现的呢?

 

访问 setup 返回值 — asVmProperty 实现

这个函数执行后,我们就可以在 template 模版及 vue 选项中访问到 setup 的返回值,的下面是“asVmProperty” 这个函数的实现:

function asVmProperty(vm, propName, propValue) {

  const props = vm.$options.props

  if (!(propName in vm) && !(props && hasOwn(props, propName))) {

    if (isRef(propValue)) {

      proxy(vm, propName, {

        get: () => propValue.value,

        set: (val: unknown) => {

          propValue.value = val

        },

      })

    } else {

      proxy(vm, propName, {

        get: () => {

          if (isReactive(propValue)) {

            ;(propValue as any).__ob__.dep.depend()

          }

          return propValue

        },

        set: (val: any) => {

          propValue = val

        },

      })

    }

  }

}

function proxy(target, key, { get, set }) {

  Object.defineProperty(target, key, {

    enumerable: true,

    configurable: true,

    get: get || noopFn,

    set: set || noopFn,

  })

}

复制代码
 

函数很短,这里有3个处理逻辑:

  1. 普通属性的 get 和 set 正常返回
  2. 如果是 ref 类型的属性(通过 ref 创建),通过 vm.xxx 访问/修改时,访问/修改 ref 的 value 属性
  3. 代理 reactive 类型的属性 (通过 reactive 创建),reactive 返回的是一个响应式对象。当访问这个对象时, 需要调用 响应式对象种的 depend 收集watcher(观察者),以便数据更新时通知 watcher 进行更新。

总之 asVmProperty 是拿到 setup 返回值中的一个键值对后,再通过 Object.defineProperty 劫持了 this(是vm,也就是组件实例)中访问改键值对的 get 和 set,这样我们便可以通过 this.xxx 访问到 setup 中return 出去的属性。

而模版访问也同理,因为 template 编译成 render 后,上面的变量都实际会编译成 _vm.xxx,而 _vm 就是 this ,也就是组件实例。

结语

创作不易,如果对大家有所帮助,希望大家点赞支持,有什么问题也可以在评论区里讨论😄~

如果你觉得这篇文章对你有点用的话,麻烦请给我们的开源项目点点star:    http://github.crmeb.net/u/defu         不胜感激 !
 
来自 “开源独尊 ” ,链接:    https://ym.baisou.ltd/post/847.html  

 
  • 0
    感动
  • 0
    路过
  • 0
    高兴
  • 0
    难过
  • 0
    搞笑
  • 0
    无聊
  • 0
    愤怒
  • 0
    同情
热度排行
友情链接