背景
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(this, this.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(this: any) {
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