背景

composition 作为一种抽象手段,对应的领域实现有 react hooks 和 vue3 composition API , Vue2 中可以通过 composition-api[1] 插件来支持 composition 抽象。但这并不意味着 composition 抽象是 react 或 vue3 独有的。

我们的业务虽然是一个新业务,但由于需求倒排的原因,要复用大部分的历史代码,在技术架构选型上必然就有很多限制,为了复用代码和 Typescript 友好,最终选用了 vue2 和 vue-class 来编写前端 UI 。

这个技术架构对于满足业务需求来说是够的,但俗话说,曾经沧海难为水,除却巫山不是云。对于用惯了 hooks 和 composition API 的开发者来说,开发体验其实是下降了的。

项目初期,为了快速完成需求,多人并行开发的过程中,大家并不能很好互相了解对方负责的部分,因此代码中充斥着各种重复的逻辑。后期项目稳定后,我们着手减少重复代码,面对 UI 上的复用,我们通过和设计进行约定,开发公共业务组件解决。然而,我们发现,组件级别的复用粒度还是太大,各个业务属性不同的组件内部依旧充满着重复代码。为此,我们又分别尝试了 decorator 和指令 ,最终效果还是不太好,只有在面对特定场景的时候,它们才能很好的解决问题。

有过 react hooks 珠玉在前, composition 这种抽象非常满足我们的诉求 ,调研了 composition-api[2] 后,经过种种考量,我们没有选择 composition API 插件, 而是决定自己适配一个。

又双叒叕自己造轮子?

为什么放着那么多大佬贡献的 composition API 插件不用,而选择自己实现一套 composition 的抽象 ?

vue2 的 composition API 插件是对齐 vue3 的,但是由于 vue2 的响应式系统限制,有很多行为其实不符合 vue3 规范[3]、反直觉[4],有的甚至就是 bug[5] 了。composition API 插件的问题其实有很多办法解决,并且 composition API 插件在未来会被合并到 vue2 仓库中,从而通过一些 vue2 的内部 API 来解决问题。但现在整个 roadmap[6] 还不清晰vue3插件,vue 项目组的重心还在 vue3 身上,所以只能修修补补,没法大刀阔斧的修改,防止可能带来的破坏性变化。

这种情况下,如果带着 vue3 的 composition 心智去使用 composition API 插件,是非常容易踩坑的,尽管这些坑在了解内部实现情况下,很容易避开,但我们无法要求每个业务开发人员都对 composition API 插件内部实现有所了解。

对于我们的项目来说,我们并不需要把所有机制的做的尽善尽美,也不需要对齐 vue3 的 spec ,比如说自动 unwrap ref 等机制,这些都是锦上添花的东西。我们的核心诉求是如何进行 composition 抽象吗,在不考虑对齐 spec 的的条件上,我们可以提前规避很多坑,也大大减少了实现 composition 的难度。其次,我们需要兼容 vue-class 的开发方式,从而方便历史代码进行渐进性的重构。

实现@babel/plugin-proposal-class-properties 的语法糖

class Counter {
  count = 0;
  inc() {
    this.count += 1;
  }
  constructor() {
    this.bar = 0;
  }
}

class Counter {
  inc() {
    this.count += 1;
  }
  constructor() {
    this.count = 0;
    this.bar = 0;
  }
}

你编写的 count = 0 只是个语法糖,它的执行时机从编译后代码可以看出来是在 constructor 函数执行的时候。

vue-class 的原理

现在我们讨论下, vue-class 是如何将你编写的类转换成一个 Vue 组件。

@Component
class Counter extends Vue {
  count = 0;
  inc() {
    this.count += 1;
  }
  constructor() {
    this.bar = 0;
  }
}

const Counter = Component(
  class Counter extends Vue {
    count = 0;
    inc() {
      this.count += 1;
    }
    constructor() {
      this.bar = 0;
    }
  }
);

你编写的代码在转译后可以近似的认为会变成右边的样子,因此可以断定,所有的魔法行为其实都发生在 Component 函数内。

function Component(options: ComponentOptions | VueClass): any {
  if (typeof options === "function") {
    return componentFactory(options);
  }
  return function (Component: VueClass{
    return componentFactory(Component, options);
  };
}

对于我们的 Counter 组件来说,我们会进入第一个判断分支,调用 componentFactory 方法。

export function componentFactory(
  Component: VueClass,
  options: ComponentOptions = {}
): VueClass<Vue
{
  options.name =
    options.name || (Component as any)._componentTag || (Component as any).name;
  // prototype props.
  const proto = Component.prototype;
  Object.getOwnPropertyNames(proto).forEach(function (key{
    if (key === "constructor"return;

    const descriptor = Object.getOwnPropertyDescriptor(proto, key)!;
    if (descriptor.value !== void 0) {
      // methods
      if (typeof descriptor.value === "function") {
        // copy 方法
        (options.methods || (options.methods = {}))[key] = descriptor.value;
      } else {
        // typescript decorated data
        (options.mixins || (options.mixins = [])).push({
          // copy static 变量
          data(this: Vue) {
            return { [key]: descriptor.value };
          },
        });
      }
    } else if (descriptor.get || descriptor.set) {
      // 把 getter 和 setter 处理成
      // computed properties
      (options.computed || (options.computed = {}))[key] = {
        get: descriptor.get,
        set: descriptor.set,
      };
    }
  });

  // add data hook to collect class properties as Vue instance's data
  (options.mixins || (options.mixins = [])).push({
    // 注意这里,收集实例变量是一个 thunk 函数,并没有立刻收集
    data(this: Vue) {
      return collectDataFromConstructor(this, Component);
    },
  });

  // decorate options
  const decorators = (Component as DecoratedClass).__decorators__;
  if (decorators) {
    decorators.forEach((fn) => fn(options));
    delete (Component as DecoratedClass).__decorators__;
  }

  const Extended = Vue.extend(options);

  return Extended;
}

所以你编写的 Count 类只是个描述对象,它唯一的作用就是在运行时后提供足够的信息给 vue-class 的 @Component 装饰器 ,让 @Component 构造出一个真正的 Vue 组件。

@Component
class Counter extends Vue {
  count = 0;
  inc() {
    this.count += 1;
  }
  constructor() {
    this.bar = 0;
  }
}

Vue.extends({
  mixins: [
    {
      data() {
        return collectDataFromConstructor(this, Component); // Counter
      },
    },
  ],
  methods: {
    inc() {
      this.count += 1;
    },
  },
});

export function collectDataFromConstructor(vm: Vue, Component: VueClass{
  // should be acquired class property values
  const data = new Component();

  // create plain data object
  const plainData = {};
  Object.keys(data).forEach((key) => {
    if (data[key] !== undefined) {
      // 注意这里
      plainData[key] = data[key];
    }
  });

  return plainData;
}

如果我们把 data 也看成 Vue 组件的一个生命周期的话,他的执行时机是在 beforeCreate 之后, created 之前。从代码中我们可以看到 collectDataFromConstructor 这个函数会在 data 函数内部被执行。这个时候,我们的 Counter 实例会被真正的 new 出来,然而把这个实例上的属性浅复制到一个 object 后,就把这个实例像隔夜果酱一样扔掉了!

从这里我们可以看到,尽管我们编写代码时的心智模型是在编写一个类,但实际运行时的模型却并不是一个类。vue-class 做了很多的操作,来使得运行时的行为和我们的心智模型相符合,不得不说能做到这点真的很佩服,不过有些情况下也会出现例外,比如这段代码中的 inc 函数就不会正常工作,因为 inc 中的 this 指向 Counter 实例,但是从上文我们知道,实例被浅拷贝后就直接被丢弃了,所以执行 inc 函数,并不会导致 count 属性变化。

@Component
class Counter extends Vue {
  count = 0;
  inc = () => {
    this.count += 1;
  };
  constructor() {
    this.bar = 0;
  }
}

vue-class-composition

主要的核心点在于我们如何拿到 Vue 的组件实例,拿到实例后,我们就可以通过 Vue 提供的 $watch $on 等 API 来封装逻辑,比如说 useCreated 的实现可以是这样

第一版的 API 形式

export function useCreated(this: Vue, callback: CallbackFn{
  this.$on("hook:created", callback);
  return () => {
    this.$off(evtName, callback);
  };
}

@Component
class Counter extends Vue {
  count = 0;
  foo = useXXX.call(this);
  inc() {
    this.count += 1;
  }
  constructor() {
    useCreated.call(thisthis.inc);
    useXXX.call(this);
    useXXX.call(this);
  }
}

先忍着血压别上升,这个 API 形式已经被废弃了。并且在了解过 vue-class 的原理后,你会很容易看这种实现的问题所在,手动传递的 this 是类组件的实例而不是 @Component 装饰器生成的 Vue 组件实例!但对于当时的我来说,我弃用这种 API 形式的主要原因是太不友好(太丑了) ,使用起来也很繁琐,容易造成开发人员高血压。

在废弃掉第一版 API 之后,我发现 Vue3 提供了 getCurrentInstance 函数来让 useXXX 内部可以拿到当前正在初始化的 Vue 组件实例,并去观察了下 vue2 composition-api 插件是怎么做的。调研过后,我发现自己可以参考 vue2 composition-api 插件的方法来实现相似的效果。

首先我们需要确定 useXXX 函数的执行时机是什么时候?

@Component
class Counter extends Vue {
  foo = useXXX();
  constructor() {
    useCreated(this.inc);
  }
}

@Component
class Counter extends Vue {
  constructor() {
    useCreated(this.inc);
    this.foo = useXXX();
  }
}

可以看到,所有的 useXXX 执行时机其实就是 constructor 的执行时机,还记得上文讲的 constructor 的执行时机吗?对,就是 data 函数执行的时候。那现在问题就变成了,我们该怎么感知到 data 函数的执行?答案是通过全局 mixin ,在 beforeCreate 生命周期里对原 data 函数进行包裹(拦截) 。

let currentInstance
export function getCurrentInstance({
 return currentInstance
}
function vueClassCompositionApiInit(thisany{
  const vm = this
  const $options = vm.$options

  const { data } = $options
  $options.data = function wrappedData({
  const prev = currentInstance
  try {
   currentInstance = vm
   return typeof data === 'function'
      ? (data as any).call(vm, vm)
      : data || {})
  } finally {
   currentInstance = prev
  }
  }
}

Vue.mixin({
  beforeCreate: vueClassCompositionApiInit,
})

第二版的 API 形式就看起来好多了

export function useCreated(callback: CallbackFn{
  const self = getCurrentInstance();
  self.$on("hook:created", callback);
  return () => {
    self.$off(evtName, callback);
  };
}

@Component
class Counter extends Vue {
  count = 0;
  foo = useXXX();
  inc() {
    this.count += 1;
  }
  constructor() {
    useCreated(this.inc);
    useXXX();
    useXXX();
  }
}

抽象泄露[7]

“抽象泄漏”是软件开发时,本应隐藏实现细节的抽象化不可避免地暴露出底层细节与局限性。抽象泄露是棘手的问题,因为抽象化本来目的就是向用户隐藏不必要公开的细节。

刚才我们提到对于 vue-class 来说,我们不能使用类似 xx = () => {} 这种语法,因为这会导致 this 无法指向正确的 Vue 组件实例。这是一种抽象泄露,我们必须了解抽象之下的工作原理才能正确的使用某种抽象。对于 vue-class-composition 来说,我们会遇到同样的问题。

比如说,即使是作为实现者的我vue3插件,也会一开始尝试写出类似这样的代码。等到运行的时候才发现,左侧代码的箭头函数中的 this 不会正确的指向 Vue 组件实例,从而导致响应式更新失败。

@Component
class Counter extends Vue {
  count = 0;
  inc() {
    this.count += 1;
  }
  constructor() {
    useCreated(() => {
      this.inc();
    });
  }
}

@Component
class Counter extends Vue {
  count = 0;
  inc() {
    this.count += 1;
  }
  constructor() {
    useCreated(function ({
      this.inc();
    });
  }
}

抽象泄漏法则指出“可靠”软件的开发者必须了解抽象之下的底层细节。否则一旦出了任何问题,根本不会知道是怎么回事,也不知道如何除错或回复。所以抽象机制虽然节省了工作的时间,不过学习的时间是省不掉的。

好处

你问我好处都有啥?我只能建议你去看下这几个库。

后记

编写代码的过程中一直在想一个灵魂问题:“我写的这些到底有什么用?”,不搞 composition 抽象不是照样写业务代码。想了各种原因,不设边界、追求极致、优化开发体验什么的,归根结底,没那么多玄乎的,只是山在那里。

欢迎长按图片加 ssh 为好友,我会第一时间和你分享前端行业趋势,学习途径等等。2021 陪你一起度过!

参考资料

[1]

composition-api:

[2]

composition-api:

[3]

不符合 vue3 规范:

[4]

反直觉:

[5]

bug:

[6]

roadmap:

[7]

抽象泄露: %E6%8A%BD%E8%B1%A1%E6%B3%84%E6%BC%8F

[8]

react-use:

[9]

vueuse:

[10]

ahooks:

[11]

react-query:

限 时 特 惠: 本站每日持续更新海量各大内部创业教程,一年会员只需98元,全站资源免费下载 点击查看详情
站 长 微 信: muyang-0410