藏在 Vue2 中的 composition API
我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情
写在前面
由于上半年发生一些事情导致这期间断更好久,年初的文章近期才整理发出来🤣🤣🤣。这期间官方发布了 Vue2.7,所以文中部分内容和发布时间可能有所出入,感谢评论区小伙伴指正🌟🌟🌟
背景
熟悉 Vue2 的小伙伴们都清楚,Vue2 推崇 options API,这对新人来说上手很简单,但也伴随而来了一些小问题,比如逻辑服用很困难,this黑盒、mixins命名冲突等等。后来 Vue3 通过 composition API 解决了这些痛点。
但也因为采用了过于前卫的 Proxy ,让一些必须考虑兼容性的应用无法立刻升级。所以历史问题依旧存在。
所幸,今年年初笔者也遇到了类似的苦恼,最终发现了属于 Vue2 的 composition API。
嚯~这不仔细看还真以为是 Vue3 哈,但我们可是额外保留了 Vue2 良好的 兼容性 哦~
至于是如何实现的,让我们一起来看一下吧~
Vue2的祖训
首先大家都清楚,options API 推荐我们通过 data
创建 响应式对象 ;在 methods
中声明方法;而且还有一套完整的 生命周期函数 可供使用。
除此之外,官网 还贴心地告诉大家在各个 生命周期函数 中应该做那些事情,这样一来新手很快就能上手写代码了。
其中关于 beforeCreate
是这样讲的,
在实例初始化之后,进行数据侦听和事件/侦听器的配置之前同步调用
也就是说在 beforeCreate
阶段没有完成 数据响应式 ,因此很多教程会告诉大家不要在这个阶段操作 this.data
,大部分行为最好在 mounted
阶段编写。
久而久之这便形成了一个公认的规则:尽量不要在 beforeCreate
中操作 this.data
。
在规则的边缘试探
对 Vue 有一定研究的同学可能尝试过在 实例 之外的地方,用 Vue.util.defineReactive
或 Vue.observable
创建 响应式对象。
像这样:
于是乎现我们发现,好像 定义响应式变量 也可以不定义在 data
、方法 也可以不放在 methods
。
但是绑定在 原型链 上有点不妥,而且变量的作用域也不合适,
所以我们尝试在 beforeCreate
阶段做这些事情,顺便绑定到 this。
发现这种方式也不错,逻辑比较集中,不用像之前那样上下来回翻文件了。
于是我们做了一个更大胆的尝试~
我做了一个违背祖训的决定
很快我们发现这种写法像极了 Vue3 的 composition API ,只是没有我们重写 响应式原理 ,一切改造都是基于 Vue2 现有的 API,所以也不存在兼容性问题。
既然如此,何不借助 Vue2 的现有逻辑创造一个新的 composition API 呢,既不用担心兼容性问题,将来迁移 Vue3 时还无需做过多改动。
我们以常用的几个API为例,用 Vue2 来实现一下
defineComponent
在 Vue3 中,defineComponent 仅仅是在定义 组件 时提供类型推导的辅助函数,并没有额外其他功能
export default defineComponent({
name: 'App,
setup (props) {
// ...
}
})
但我们可以借助这个方法实现一个很重要的事情:让 options
支持 setup
。
setup 的核心作用便是将 返回值 挂载到 this
上。
所以我们先将 setup
函数执行,再把返回值绑定至 this
export const defineComponent = function (options) {
const { beforeCreate, setup, ...restOptions } = options
return {
beforeCreate: setup
? function () {
beforeCreate && beforeCreate()
const options = setup(this)
// 代理到this
proxyToThis.call(this, options)
} : undefined,
...restOptions
}
}
但问题在于如何绑定到 this
呢?
经过思考我们想到两个可行性方案:
-
Object.defineProperty
这方案老熟悉了哈~,在大部分场景下也确实好用,但
Object.defineProperty
不允许重复设置key
,否则报错Cannot redefine property: xxx
。为什么会出现重复设置呢?
大部分 Vue2 项目在 逻辑复用 时避免不了
mixins
或extends
,这种情况下允许覆盖相同属性,所以该方案难以兼容此场景。
-
this.$options
看过源码的小伙伴或许知道,Vue 在组件初始化阶段会将 组件配置 绑定到 this.$options 上。
所以 【修改
this.$options
=== 修改组件配置】那么问题又来了,我们怎么知道返回的内容是
data
,还是methods
,还是computed
呢?仅依靠 数据类型 是不严谨的,因为
computed
和methods
都是函数,而data
可以是任意值。所以我们需要对这几类数据打上特殊标识
const isComputed = Symbol('isComputed') const isRef = Symbol('isRef')
以帮助我们在将目标绑定至
this
时能放到正确位置,说白了就是做搬运🤣。
reactive、ref 等基本API
上文有介绍,可以用 Vue.util.defineReactive
或 Vue.observable
创建响应式对象,
Vue.observable
方法的返回值即是响应式对象,而且可以一次设置多个 key-value,所以在这里最适合。
export const reactive = Vue.observable
ref
也类似,这里就不赘述啦~
别急别急,这只是最基础的API,我们无需重复造轮子,所以直接复用即可。
toRef、toRefs
这两个 API 一直备受争议,因为方法返回的结构会被包装在 value
中,
像这样{ value: xxx }
,
但在 template
引用时却无需获取 value
,直接引用即可。
其实在 Vue3源码 中,绑定至 this
时会经过特殊处理:将 this.[key]
转发给 this.[key].value
,
像这样:
proxy(target, key, {
get: function getterHandler() {
var value = getter ? getter.call(target) : val;
// 如果是 ref ,则直接获取到 value
if (isRef(value)) {
return value.value;
} else {
return value;
}
},
});
所以通过 this
访问时不需要读取 value
,而直接访问则需要。
所以呢,我们要先在对象身上打上 isRef
标识,以便将来代理到 this
时直接获取到他们的 value
。
+ const isRef = Symbol('isRef')
+ export const toRef = function (obj, key) {
+ const ObjectRefImpl = {
+ get value () {
+ return obj[key]
+ },
+ set value (val) {
+ obj[key] = val
+ }
+ }
+ // 1. 打上特殊标识
+ ObjectRefImpl[isRef] = true
+ return ObjectRefImpl
+ }
const proxyToThis = function (obj) {
for (const key in obj) {
if (key in this) {
continue
}
const value = obj[key]
if (typeof value === 'function' && value[isComputed]) {
// ...
+ } else if (value[isRef]) {
+ // 2. 如果是 ref ,则直接获取到 value
+ Object.defineProperty(this, key, {
+ get () {
+ return value.value
+ },
+ set (val) {
+ value.value = val
+ }
+ })
+ } else {
// ...
}
}
}
computed
关于 computed ,有一种方案是直接代理给 this
,像这样:
const double = () => state.count * 2
Object.defineProperty(this, 'double', {
get () {
return double()
}
})
但 computed 在 Vue原理 中可不仅如此,而是一种特殊的 观察者,内部包含了 脏检查 等优化处理。
ps:本文对于 观察者 暂不展开细说,感兴趣的小伙伴可以查看 这篇文章
所以我们干脆把 computed 放入 this.$options
交给 Vue 处理好了。
+ const isComputed = Symbol('isComputed')
+ export const computed = function (getter) {
+ getter[isComputed] = true
+ return getter
+ }
const proxyToThis = function (obj) {
for (const key in obj) {
if (key in this) {
continue
}
const value = obj[key]
+ if (typeof value === 'function' && value[isComputed]) {
+ // 如果是getter,则放到 options 中交给 Vue 处理
+ this.$options.computed[key] = value
+ } else if (value[isRef]) {
// ...
} else {
// ...
}
}
}
onMounted 等生命周期函数
这里和前面会有所区别,因为 生命周期函数 不需要 return
,所以现有方案无法和 this
绑定
不怕不怕,看看 源码 是如何实现的。
源码 的思路是专门获取到当前组件的 this
,然后把 回调函数 添加到 this
上,由于考虑的情况比较全面,所以代码比较复杂。
但我们不需要考虑那么多,只需要保证在 setup
中使用 composition API 正常即可,毕竟太灵活了也不好嘛,还是要做一点限制的。
所以我们可以在 const options = setup(this)
之前 保存 组件实例,在其之后 取消绑定。
核心改动如下:
let currentInstance = null
export const defineComponent = function (options) {
const { beforeCreate, setup, ...restOptions } = options
return {
beforeCreate: setup
? function () {
beforeCreate && beforeCreate()
+ // 保存 this
+ currentInstance = this
+ // 使用 this
+ const options = setup(currentInstance)
proxyToThis(options)
+ // 取消 this 的绑定
+ currentInstance = null
} : undefined,
...restOptions
}
}
然后在 生命周期函数 中使用即可:
export const onMounted = function (cb) {
if (!currentInstance) throw new Error('onMounted只能在 setup 中使用哦')
if (currentInstance.$options.mounted) {
currentInstance.$options.mounted.push(cb)
} else {
currentInstance.$options.mounted = cb
}
}
其他 生命周期函数 同理~
小试锋芒
现在可以放心地在 历史债项目 中使用 composition API 啦!
import { defineComponent, reactive, toRefs, onMounted } from 'vue2-composition-api'
export default defineComponent({
name: 'App',
setup (props) {
const state = reactive({
id: '1',
count: 0,
})
const add = () => state.count++
const double = computed(() => state.count * 2)
onMounted(() => {
console.log('onMounted', state.count);
})
return {
double,
add,
...toRefs(state)
}
}
})
可以看到体验和 Vue3 一模一样!
思考与总结
本篇文章的意义有两点
-
为 因兼容性而无法升级到Vue3的项目 提供良好的过渡方案,以便将来可以顺滑地过渡。毕竟历史的车轮不会因为浏览器的版本问题而停滞不前,相信有朝一日我们一定可以全面拥抱 Vue3 !
但也仅局限于对 响应式API 的改造,诸如 setup script、自定义渲染器 这一类依赖 Vue3 编译器 的特性依然只能望而却步。
-
打破限制,寻求更多的可能,与其抱怨现阶段的不足,不如尝试着改变现状!
代码地址如下,欢迎小伙伴们拍砖~
感谢读到这里的小伙伴,希望这篇文章能够给你带来帮助,蟹蟹~
转载自:https://juejin.cn/post/7144651931187675149