@wy
2020-04-23T14:44:37.000000Z
字数 9307
阅读 814
vue.js
使用过一段时间 class 来定义组件,要用 vue-property-decorator 提供定义好的装饰器,辅助完成所需功能,对这个过程好奇,就研究了源码。内部主要依靠 vue-class-component 实现,所以将重点放在对 vue-class-component 的解读上。
本文主要内容有:
没有使用 class 方式定义组件时,通常导出一个选项对象:
<script>
export default {
props: {
name: String
},
data() {
return {
message: '新消息'
}
},
watch: {
message(){
console.log('message改变触发')
}
},
computed:{
hello: {
get(){
return this.message + 'hello';
},
set(newValue){}
}
},
methods:{
clickHandler(){}
}
mounted(){
console.log('挂载完毕');
}
}
</script>
这个对象告诉 Vue 你要做什么事情,需要哪些功能。 根据字段的不同作用,把需要添加的属性和方法,写在指定的位置,例如,需要响应式数据写在 data 中、计算属性写在 computed 中、事件函数写在 methods中、直接写生命周期函数等 。Vue 内部会调用 Vue.extend() 创建组件的构造函数,以便在模板中使用时,通过构造函数初始化此组件。
如果使用了 class 来定义组件,上面的字段可省略,但要符合 Vue 内部使用数据的规则,就需要重组这些数据。
定义 class 组件:
<script lang="ts">
class Home extends Vue {
message = '新数据';
get hello(){
return this.message + 'hello';
}
set hello(newValue){}
clickHandler(){}
mounted(){}
}
Home.prototype.age = '年龄'
</script>
message 作为响应式的数据,应该放在 data 中,但问题是 message 写在类中,为初始化后实例上的属性,就要想办法在初始化后拿到 message,放在 data 中。
age 直接写在原型上,值不是函数,也应该放在 data 中。
hello 写了访问器,作为计算属性,写在 computed 中;clickHandler作为方法,写在 methods 中;mounted 是生命周期函数,挂载原型上就可以,不需要动。这三个都是方法,定义在原型上,需要拿到原型对象,找到这三类方法,按照特性放在指定位置。
这就引发一个问题,怎么把这些定义的属性放在 Vue 需要解析的数据中,“上帝的归上帝,凯撒的归凯撒”。
最终处理成这样:
{
data:{
message: '新数据',
age: '年龄'
},
methods:{
clickHandler(){}
},
computed:{
hello:{
get(){
return this.message + 'hello';
}
}
},
mounted(){}
}
最好是无入侵式的添加功能,开发者无感知,正常写业务代码,提供封装好功能来完成归类数据这件事。
装饰器模式,在不改变自身对象的基础上,动态增加额外的功能,这个模式的思路符合上述内容的要求。具体可参考一篇文章详细了解,装饰者模式和TypeScript装饰器
vue-class-component 的代码使用 ts 书写,如果对 ts 语法不熟悉,可以忽略定义的类型,直接看函数体内的逻辑,不影响阅读。或者直接看打包后,没有压缩的代码,也不多,大约200行左右。
本文分析的代码主要文件在:仓库地址
先来看大致结构和如何使用:
function Component(options) {
// options 是 function类型,是要装饰的类
if (typeof options === 'function') {
return componentFactory(options);
}
// 执行后,这个函数作为装饰器函数,接收要装饰的类
// options 为传入的选项数据。
return function (Component) {
return componentFactory(Component, options);
};
}
// 使用1
@Component
class Home Extend Vue {}
// 使用2
@Component({
components:{}
data:{newMessage: '增加的消息'},
methods:{
moveHandler(){}
},
computed:{
reveserMessage(){
return this.newMessage + '翻转'
}
}
// ... vue中选项对象其他值
})
class Home Extend Vue {}
Component 作为装饰器函数,接受的 options 就是要装饰的类 Home, js 中类不过是一种语法糖,typeof Home 得到为 function 类型。
Component 函数作为工厂函数,执行并传入参数 options(为了称呼方便,后面把这个参数叫做 装饰器选项数据),工厂函数执行后,返回装饰器函数,同样是接受要装饰的类 Home。
从代码中可以看出来,都调用了 componentFactory ,第一个参数为要装饰的类,第二参数可选,传入的话就是装饰器选项数据。
从名字上可以看出来,componentFactory 用来产生组件的工厂,经过一系列的执行后,返回新的组件函数。省略其他,先看关键代码 代码地址:
function componentFactory(Component) {
// 省略其他代码...
// 参数为两个,说明第二个是传入的部分选项数据;
var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
// 得到继承的父类,不出意外为 Vue
var superProto = Object.getPrototypeOf(Component.prototype);
// 如果原型链上确实有 Vue,则得到构造函数;不为 Vue,则直接使用 Vue;
// 目的是为了找到 extend 函数。
var Super = superProto instanceof Vue ? superProto.constructor : Vue;
// 根据选项对象,新建一个组件的构造函数
var Extended = Super.extend(options);
// 返回新的构造函数
return Extended;
}
验证了上面的猜测,调用了 Vue.extend 返回新的组件函数。但在返回之前,要处理原来组件上的属性,和原型上的方法。
首先对选项上的方法归类,方法归 methods;非方法归 data;有访问器归 computed。
// 需要忽略的属性
const $internalHooks = [
'data',
'beforeCreate',
'created',
'beforeMount',
'mounted',
'beforeDestroy',
'destroyed',
'beforeUpdate',
'updated',
'activated',
'deactivated',
'render',
'errorCaptured', // 2.5
'serverPrefetch' // 2.6
]
function componentFactory(Component) {
// 其他代码省略...
// 拿到原型对象
const proto = Component.prototype
// 返回对象上所有自身属性,包括不可枚举的属性
Object.getOwnPropertyNames(proto).forEach(function (key) {
// 构造函数,不做处理
if (key === 'constructor') {
return
}
// 钩子函数之类的属性,直接赋值到 options对象上,不需要归类
if ($internalHooks.indexOf(key) > -1) {
options[key] = proto[key]
return
}
// 拿到对应属性的描述对象,用这个方法能避免继续查找原型链上的属性
const descriptor = Object.getOwnPropertyDescriptor(proto, key);
// 如果此属性的值不为 undefined,说明有值
if (descriptor.value !== void 0) {
// methods
// 如果为函数,则直接归为 methods
if (typeof descriptor.value === 'function') {
(options.methods || (options.methods = {}))[key] = descriptor.value
} else {
// 如果值不为函数,则归为data,这里采用 mixins,混合数据的方式来做
(options.mixins || (options.mixins = [])).push({
data (this: Vue) {
return { [key]: descriptor.value }
}
})
}
} else if (descriptor.get || descriptor.set) {
// value 为空,但是有 get或set的访问器,则归为computed
(options.computed || (options.computed = {}))[key] = {
get: descriptor.get,
set: descriptor.set
}
}
})
}
从上述代码可以看出来,拿到属性对应的描述对象,根据属性对应的值,进行类型判断,来决定归为哪一类。
值得注意的是这段代码,目的是把非函数的属性,混合在 data 中:
if(typeof descriptor.value === 'function'){/*省略*/}
else{// 处理原型上不是函数的情况
(options.mixins || (options.mixins = [])).push({
data (this: Vue) {
return { [key]: descriptor.value }
}
})
}
一般写在类中的只有是函数才能放在原型上,但有别的方式可以把非函数的值添加到原型上:
// 第一种,直接给原型添加属性
Home.prototype.age = 18;
// 第二种,用属性装饰器
function ageDecorator(prototype, key){
return { // 装饰器返回描述对象,会在 prototype增加key这个属性
enumerable: false,
value: 18
}
}
class Home extends Vue {
@ageDecorator
age: number = 18;
}
如果用了 ts 的属性装饰器,并返回描述对象,就会在 prototype 增加这个属性,所以在上面 componentFactory 源码中要处理这种情况,一般在项目中比较少见。
写在类中的属性,不添加在原型上,只有通过得到实例后拿到这些值,可以沿着这个思路进行分析。
先看实例上属性的情况:
class Home {
message: '新消息',
clickHandler(){}
}
let home = new Home();
console.log(home);
// 打印实例,简化后:
{
message: "新消息"
__proto__:
constructor: class Home
clickHandler: ƒ clickHandler()
__proto__: Object
}
在 componentFactory 中做了单独的处理:
function componentFactory(Component){
// 省略其他代码
;(options.mixins || (options.mixins = [])).push({
data () {
return collectDataFromConstructor(this, Component)
}
})
}
这里依然使用混合 data 的方式,混合功能很强大,敲黑板记下来。mixins 会在初始化组件时,调用 data 对应的函数,得到要混合的数据,又调用了 collectDataFromConstructor,传入 this,为组件实例,跟平时写项目在 mounted 中使用的那个 this 一样,都为渲染组件的实例;第二参数为 Component,是原来装饰的类,上面例子中就是 Home 类。
这个函数的目的是把原来装饰的类,初始之后,拿到实例上的属性组成对象返回。代码地址
来看代码:
// 用来收集被装饰类中定义的属性
// vm 为要渲染的组件实例
// Component 为原来要装饰的组件类
function collectDataFromConstructor(vm, Component) {
// 先保存原有的 _init,目的是不执行 Vue上的 _init 做其他初始化动作
var originalInit = Component.prototype._init;
// 在被装饰的类的原型上手动增加 _init,在Vue实例化事内部会调用
Component.prototype._init = function () {
var _this = this;
// 拿到渲染组件对象上的属性,包括不可枚举的属性,包含组件内定义的 $开头属性 和 _开头属性,还有自定义的一些方法
var keys = Object.getOwnPropertyNames(vm);
// 如果渲染组件含有,props,但是并没有放在原组件实例上,则添加上
if (vm.$options.props) {
for (var key in vm.$options.props) {
if (!vm.hasOwnProperty(key)) {
keys.push(key);
}
}
}
// 把给原组件实例上 Vue 内置属性设置为不可遍历。
keys.forEach(function (key) {
if (key.charAt(0) !== '_') {
Object.defineProperty(_this, key, {
get: function get() {
return vm[key];
},
set: function set(value) {
vm[key] = value;
},
configurable: true
});
}
});
};
// 手动初始化要包装的类,目的是拿到初始化后实例
var data = new Component();
// 重新还原回原来的 _init,防止一直引用原有的实例,造成内存泄漏
Component.prototype._init = originalInit;
// 重新定义对象
var plainData = {};
// Object.keys 拿到可被枚举的属性,添加到对象中
Object.keys(data).forEach(function (key) {
if (data[key] !== undefined) {
plainData[key] = data[key];
}
});
return plainData;
}
具体要做的话,通过 new Component() 得到被装饰类的实例,但要注意,Component 继承了 Vue 类,初始化后实例上有很多 Vue 内部添加上的属性,比如 $options、$parent、$attrs、$listeners、$data 等等,还有以 _ 开头的属性,_watcher、_renderProxy 等等,还有我们需要的属性。这里只是简单举几个属性,你可以手动初始化,在控制台打印输出看一下。
以 _ 开头的属性,是内置方法,不可被枚举;以 $ 开头的属性,也是内置方法,但是可被枚举。如果直接循环实例,会拿到以 $ 开头的属性,这并不是我们需要的。
那怎么办呢?代码中给了答案,在初始化一系列组件内置的属性后,组件内部会调用 Component.prototype._init 方法,可通过改写这个方法,来处理属性为不可枚举。
最后通过 Object.keys() 得到能够被遍历的属性。
上面拐的弯比较多,难免看蒙了,根据核心意思,简化如下:
原来有个组件:
class Home {
message: '新消息'
}
现在有个需要渲染的组件,要把上面定义在 Home 中的 message 写在现有组件的 data 中:
const App = Vue.extend({
// 混合功能
mixins:[{
data(){
// 初始化后拿到实例,就能拿到 message 属性
let data = new Home();
let plainData = {};
Object.keys(data).forEach(function (key) {
if (data[key] !== undefined) {
plainData[key] = data[key];
}
});
return plainData;
}
}],
data(){
return {
other: '其他data'
}
}
})
new App().$mounted('#app');
简化后,是不是清晰很多,本质就是初始类得到实例,拿属性组成对象,混合到渲染的组件中。
小的优化点,简化代码:
// 保留原有的 _init 方法
var originalInit = Component.prototype._init;
Component.prototype._init = function(){
// 其他代码省略
};
Component.prototype._init = originalInit;
这段代码,在改写的 _init 内部使用了外面的引用 vm 和 Component,就会一直在内存中,为防止内存泄漏,重新赋回原来的函数。
vue-property-decorator 依赖 vue-class-component 实现,主要用了内部提供的 createDecorator 方法。
如果你想增加更多装饰器,也可以通过调用 createDecorator 方法,原理很简单,就是向选项对象上增加所需数据。
在 vue-class-component 中提供了工具函数 createDecorator 允许添加其他额外的装饰函数,统一挂载在 Component.__decorators__ 上,并把 options 传过去,对 options 增加需要的属性,实际上会调用这些装饰函数,让这些函数有机会处理 options。
function componentFactory(Component) {
// 省略其他代码....
var decorators = Component.__decorators__;
if (decorators) {
decorators.forEach(function (fn) {
return fn(options);
});
delete Component.__decorators__;
}
}
我们可以利用 createDecorator,扩展其他的装饰器,vue-property-decorator 内部就是利用这个函数扩展了 @Prop、@Watch 等装饰器。
function createDecorator(factory) {
return (target, key, index) => {
// 是函数类型,则为装饰的类;
// 否则,为原型,通过constructor拿到构造函数
const Ctor = typeof target === 'function'
? target
: target.constructor;
if (!Ctor.__decorators__) {
Ctor.__decorators__ = [];
}
// 当为参数装饰器时,index为number
if (typeof index !== 'number') {
index = undefined;
}
Ctor.__decorators__.push(options => factory(options, key, index));
};
}s
从源码中可以看出来,createDecorator 调用后会返回一个函数,这个函数可以作为装饰器函数,接收的 target 如果是函数类型,说明作为类装饰器,target 就是被装饰的类;否则,得到的是原型,通过 constructor 拿到构造函数。
向要装饰的类上添加静态属性 __decorators__,存入一个函数,获得 options。
现在来看 vue-property-decorator 中 watch 装饰器的源码,代码地址
function Watch(path, options) {
if (options === void 0) { options = {}; }
return createDecorator(function (componentOptions, handler) {
if (typeof componentOptions.watch !== 'object') {
componentOptions.watch = Object.create(null);
}
var watch = componentOptions.watch;
if (typeof watch[path] === 'object' && !Array.isArray(watch[path])) {
watch[path] = [watch[path]];
}
else if (typeof watch[path] === 'undefined') {
watch[path] = [];
}
watch[path].push({ handler: handler});
});
}
传入 createDecorator 的回调函数,会接受两个参数,componentOptions 为一个对象,就是在上面 componentFactory 中调用 Component.__decorators__,传入的对象,目的是向这个对象添加或增加 watch 属性,给要装饰的类使用;handler 是函数名字;
这样使用:
@Component
class Home extend Vue {
message='新消息'
@watch('message')
messageHandler(){
console.log('当message改变后,执行这里')
}
}
经过 @watch 装饰器处理后,选项对象上会增加一段数据:
{
watch: {
message: 'messageHandler'
},
methods:{
messageHandler(){
console.log('当message改变后,执行这里')
}
}
}
以上便是 vue-property-decorator 增加装饰器的实现方式,对其他装饰器感兴趣,可以看仓库源码,做进一步了解,思路都大同小异。