导读

使用过一段时间 class 来定义组件,要用 vue-property-decorator 提供定义好的装饰器,辅助完成所需功能,对这个过程好奇,就研究了源码。内部主要依靠 vue-class-component 实现,所以将重点放在对 vue-class-component 的解读上。

本文主要内容有:

  • 装饰器作用在 class 定义的组件,发生了什么
  • 解读 Component 装饰器实现过程
  • vue-property-decorator 中如何扩展装饰器

装饰器作用在 class 定义的组件,发生了什么

没有使用 class 方式定义组件时,通常导出一个选项对象:

  1. <script>
  2. export default {
  3. props: {
  4. name: String
  5. },
  6. data() {
  7. return {
  8. message: '新消息'
  9. }
  10. },
  11. watch: {
  12. message(){
  13. console.log('message改变触发')
  14. }
  15. },
  16. computed:{
  17. hello: {
  18. get(){
  19. return this.message + 'hello';
  20. },
  21. set(newValue){}
  22. }
  23. },
  24. methods:{
  25. clickHandler(){}
  26. }
  27. mounted(){
  28. console.log('挂载完毕');
  29. }
  30. }
  31. </script>

这个对象告诉 Vue 你要做什么事情,需要哪些功能。 根据字段的不同作用,把需要添加的属性和方法,写在指定的位置,例如,需要响应式数据写在 data 中、计算属性写在 computed 中、事件函数写在 methods中、直接写生命周期函数等 。Vue 内部会调用 Vue.extend() 创建组件的构造函数,以便在模板中使用时,通过构造函数初始化此组件。

如果使用了 class 来定义组件,上面的字段可省略,但要符合 Vue 内部使用数据的规则,就需要重组这些数据。

定义 class 组件:

  1. <script lang="ts">
  2. class Home extends Vue {
  3. message = '新数据';
  4. get hello(){
  5. return this.message + 'hello';
  6. }
  7. set hello(newValue){}
  8. clickHandler(){}
  9. mounted(){}
  10. }
  11. Home.prototype.age = '年龄'
  12. </script>

message 作为响应式的数据,应该放在 data 中,但问题是 message 写在类中,为初始化后实例上的属性,就要想办法在初始化后拿到 message,放在 data 中。

age 直接写在原型上,值不是函数,也应该放在 data 中。

hello 写了访问器,作为计算属性,写在 computed 中;clickHandler作为方法,写在 methods 中;mounted 是生命周期函数,挂载原型上就可以,不需要动。这三个都是方法,定义在原型上,需要拿到原型对象,找到这三类方法,按照特性放在指定位置。

这就引发一个问题,怎么把这些定义的属性放在 Vue 需要解析的数据中,“上帝的归上帝,凯撒的归凯撒”。

最终处理成这样:

  1. {
  2. data:{
  3. message: '新数据',
  4. age: '年龄'
  5. },
  6. methods:{
  7. clickHandler(){}
  8. },
  9. computed:{
  10. hello:{
  11. get(){
  12. return this.message + 'hello';
  13. }
  14. }
  15. },
  16. mounted(){}
  17. }

最好是无入侵式的添加功能,开发者无感知,正常写业务代码,提供封装好功能来完成归类数据这件事。

装饰器模式,在不改变自身对象的基础上,动态增加额外的功能,这个模式的思路符合上述内容的要求。具体可参考一篇文章详细了解,装饰者模式和TypeScript装饰器

vue-class-component 的代码使用 ts 书写,如果对 ts 语法不熟悉,可以忽略定义的类型,直接看函数体内的逻辑,不影响阅读。或者直接看打包后,没有压缩的代码,也不多,大约200行左右。

本文分析的代码主要文件在:仓库地址

解读 Component 装饰器

先来看大致结构和如何使用:

  1. function Component(options) {
  2. // options 是 function类型,是要装饰的类
  3. if (typeof options === 'function') {
  4. return componentFactory(options);
  5. }
  6. // 执行后,这个函数作为装饰器函数,接收要装饰的类
  7. // options 为传入的选项数据。
  8. return function (Component) {
  9. return componentFactory(Component, options);
  10. };
  11. }
  12. // 使用1
  13. @Component
  14. class Home Extend Vue {}
  15. // 使用2
  16. @Component({
  17. components:{}
  18. data:{newMessage: '增加的消息'},
  19. methods:{
  20. moveHandler(){}
  21. },
  22. computed:{
  23. reveserMessage(){
  24. return this.newMessage + '翻转'
  25. }
  26. }
  27. // ... vue中选项对象其他值
  28. })
  29. class Home Extend Vue {}

Component 作为装饰器函数,接受的 options 就是要装饰的类 Homejs 中类不过是一种语法糖,typeof Home 得到为 function 类型。

Component 函数作为工厂函数,执行并传入参数 options(为了称呼方便,后面把这个参数叫做 装饰器选项数据),工厂函数执行后,返回装饰器函数,同样是接受要装饰的类 Home

从代码中可以看出来,都调用了 componentFactory ,第一个参数为要装饰的类,第二参数可选,传入的话就是装饰器选项数据。

解读 componentFactory 函数

从名字上可以看出来,componentFactory 用来产生组件的工厂,经过一系列的执行后,返回新的组件函数。省略其他,先看关键代码 代码地址

  1. function componentFactory(Component) {
  2. // 省略其他代码...
  3. // 参数为两个,说明第二个是传入的部分选项数据;
  4. var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
  5. // 得到继承的父类,不出意外为 Vue
  6. var superProto = Object.getPrototypeOf(Component.prototype);
  7. // 如果原型链上确实有 Vue,则得到构造函数;不为 Vue,则直接使用 Vue;
  8. // 目的是为了找到 extend 函数。
  9. var Super = superProto instanceof Vue ? superProto.constructor : Vue;
  10. // 根据选项对象,新建一个组件的构造函数
  11. var Extended = Super.extend(options);
  12. // 返回新的构造函数
  13. return Extended;
  14. }

验证了上面的猜测,调用了 Vue.extend 返回新的组件函数。但在返回之前,要处理原来组件上的属性,和原型上的方法。

归类原型上方法

首先对选项上的方法归类,方法归 methods;非方法归 data;有访问器归 computed

  1. // 需要忽略的属性
  2. const $internalHooks = [
  3. 'data',
  4. 'beforeCreate',
  5. 'created',
  6. 'beforeMount',
  7. 'mounted',
  8. 'beforeDestroy',
  9. 'destroyed',
  10. 'beforeUpdate',
  11. 'updated',
  12. 'activated',
  13. 'deactivated',
  14. 'render',
  15. 'errorCaptured', // 2.5
  16. 'serverPrefetch' // 2.6
  17. ]
  18. function componentFactory(Component) {
  19. // 其他代码省略...
  20. // 拿到原型对象
  21. const proto = Component.prototype
  22. // 返回对象上所有自身属性,包括不可枚举的属性
  23. Object.getOwnPropertyNames(proto).forEach(function (key) {
  24. // 构造函数,不做处理
  25. if (key === 'constructor') {
  26. return
  27. }
  28. // 钩子函数之类的属性,直接赋值到 options对象上,不需要归类
  29. if ($internalHooks.indexOf(key) > -1) {
  30. options[key] = proto[key]
  31. return
  32. }
  33. // 拿到对应属性的描述对象,用这个方法能避免继续查找原型链上的属性
  34. const descriptor = Object.getOwnPropertyDescriptor(proto, key);
  35. // 如果此属性的值不为 undefined,说明有值
  36. if (descriptor.value !== void 0) {
  37. // methods
  38. // 如果为函数,则直接归为 methods
  39. if (typeof descriptor.value === 'function') {
  40. (options.methods || (options.methods = {}))[key] = descriptor.value
  41. } else {
  42. // 如果值不为函数,则归为data,这里采用 mixins,混合数据的方式来做
  43. (options.mixins || (options.mixins = [])).push({
  44. data (this: Vue) {
  45. return { [key]: descriptor.value }
  46. }
  47. })
  48. }
  49. } else if (descriptor.get || descriptor.set) {
  50. // value 为空,但是有 get或set的访问器,则归为computed
  51. (options.computed || (options.computed = {}))[key] = {
  52. get: descriptor.get,
  53. set: descriptor.set
  54. }
  55. }
  56. })
  57. }

从上述代码可以看出来,拿到属性对应的描述对象,根据属性对应的值,进行类型判断,来决定归为哪一类。

值得注意的是这段代码,目的是把非函数的属性,混合在 data 中:

  1. if(typeof descriptor.value === 'function'){/*省略*/}
  2. else{// 处理原型上不是函数的情况
  3. (options.mixins || (options.mixins = [])).push({
  4. data (this: Vue) {
  5. return { [key]: descriptor.value }
  6. }
  7. })
  8. }

一般写在类中的只有是函数才能放在原型上,但有别的方式可以把非函数的值添加到原型上:

  1. // 第一种,直接给原型添加属性
  2. Home.prototype.age = 18;
  3. // 第二种,用属性装饰器
  4. function ageDecorator(prototype, key){
  5. return { // 装饰器返回描述对象,会在 prototype增加key这个属性
  6. enumerable: false,
  7. value: 18
  8. }
  9. }
  10. class Home extends Vue {
  11. @ageDecorator
  12. age: number = 18;
  13. }

如果用了 ts 的属性装饰器,并返回描述对象,就会在 prototype 增加这个属性,所以在上面 componentFactory 源码中要处理这种情况,一般在项目中比较少见。

处理实例上的属性

写在类中的属性,不添加在原型上,只有通过得到实例后拿到这些值,可以沿着这个思路进行分析。

先看实例上属性的情况:

  1. class Home {
  2. message: '新消息',
  3. clickHandler(){}
  4. }
  5. let home = new Home();
  6. console.log(home);
  7. // 打印实例,简化后:
  8. {
  9. message: "新消息"
  10. __proto__:
  11. constructor: class Home
  12. clickHandler: ƒ clickHandler()
  13. __proto__: Object
  14. }

componentFactory 中做了单独的处理:

  1. function componentFactory(Component){
  2. // 省略其他代码
  3. ;(options.mixins || (options.mixins = [])).push({
  4. data () {
  5. return collectDataFromConstructor(this, Component)
  6. }
  7. })
  8. }

这里依然使用混合 data 的方式,混合功能很强大,敲黑板记下来。mixins 会在初始化组件时,调用 data 对应的函数,得到要混合的数据,又调用了 collectDataFromConstructor,传入 this,为组件实例,跟平时写项目在 mounted 中使用的那个 this 一样,都为渲染组件的实例;第二参数为 Component,是原来装饰的类,上面例子中就是 Home 类。

分析 collectDataFromConstructor 函数

这个函数的目的是把原来装饰的类,初始之后,拿到实例上的属性组成对象返回。代码地址

来看代码:

  1. // 用来收集被装饰类中定义的属性
  2. // vm 为要渲染的组件实例
  3. // Component 为原来要装饰的组件类
  4. function collectDataFromConstructor(vm, Component) {
  5. // 先保存原有的 _init,目的是不执行 Vue上的 _init 做其他初始化动作
  6. var originalInit = Component.prototype._init;
  7. // 在被装饰的类的原型上手动增加 _init,在Vue实例化事内部会调用
  8. Component.prototype._init = function () {
  9. var _this = this;
  10. // 拿到渲染组件对象上的属性,包括不可枚举的属性,包含组件内定义的 $开头属性 和 _开头属性,还有自定义的一些方法
  11. var keys = Object.getOwnPropertyNames(vm);
  12. // 如果渲染组件含有,props,但是并没有放在原组件实例上,则添加上
  13. if (vm.$options.props) {
  14. for (var key in vm.$options.props) {
  15. if (!vm.hasOwnProperty(key)) {
  16. keys.push(key);
  17. }
  18. }
  19. }
  20. // 把给原组件实例上 Vue 内置属性设置为不可遍历。
  21. keys.forEach(function (key) {
  22. if (key.charAt(0) !== '_') {
  23. Object.defineProperty(_this, key, {
  24. get: function get() {
  25. return vm[key];
  26. },
  27. set: function set(value) {
  28. vm[key] = value;
  29. },
  30. configurable: true
  31. });
  32. }
  33. });
  34. };
  35. // 手动初始化要包装的类,目的是拿到初始化后实例
  36. var data = new Component();
  37. // 重新还原回原来的 _init,防止一直引用原有的实例,造成内存泄漏
  38. Component.prototype._init = originalInit;
  39. // 重新定义对象
  40. var plainData = {};
  41. // Object.keys 拿到可被枚举的属性,添加到对象中
  42. Object.keys(data).forEach(function (key) {
  43. if (data[key] !== undefined) {
  44. plainData[key] = data[key];
  45. }
  46. });
  47. return plainData;
  48. }

具体要做的话,通过 new Component() 得到被装饰类的实例,但要注意,Component 继承了 Vue 类,初始化后实例上有很多 Vue 内部添加上的属性,比如 $options$parent$attrs$listeners$data 等等,还有以 _ 开头的属性,_watcher_renderProxy 等等,还有我们需要的属性。这里只是简单举几个属性,你可以手动初始化,在控制台打印输出看一下。

_ 开头的属性,是内置方法,不可被枚举;以 $ 开头的属性,也是内置方法,但是可被枚举。如果直接循环实例,会拿到以 $ 开头的属性,这并不是我们需要的。

那怎么办呢?代码中给了答案,在初始化一系列组件内置的属性后,组件内部会调用 Component.prototype._init 方法,可通过改写这个方法,来处理属性为不可枚举。

最后通过 Object.keys() 得到能够被遍历的属性。

上面拐的弯比较多,难免看蒙了,根据核心意思,简化如下:

原来有个组件:

  1. class Home {
  2. message: '新消息'
  3. }

现在有个需要渲染的组件,要把上面定义在 Home 中的 message 写在现有组件的 data 中:

  1. const App = Vue.extend({
  2. // 混合功能
  3. mixins:[{
  4. data(){
  5. // 初始化后拿到实例,就能拿到 message 属性
  6. let data = new Home();
  7. let plainData = {};
  8. Object.keys(data).forEach(function (key) {
  9. if (data[key] !== undefined) {
  10. plainData[key] = data[key];
  11. }
  12. });
  13. return plainData;
  14. }
  15. }],
  16. data(){
  17. return {
  18. other: '其他data'
  19. }
  20. }
  21. })
  22. new App().$mounted('#app');

简化后,是不是清晰很多,本质就是初始类得到实例,拿属性组成对象,混合到渲染的组件中。

小的优化点,简化代码:

  1. // 保留原有的 _init 方法
  2. var originalInit = Component.prototype._init;
  3. Component.prototype._init = function(){
  4. // 其他代码省略
  5. };
  6. Component.prototype._init = originalInit;

这段代码,在改写的 _init 内部使用了外面的引用 vmComponent,就会一直在内存中,为防止内存泄漏,重新赋回原来的函数。

vue-property-decorator 中如何扩展装饰器

vue-property-decorator 依赖 vue-class-component 实现,主要用了内部提供的 createDecorator 方法。

如果你想增加更多装饰器,也可以通过调用 createDecorator 方法,原理很简单,就是向选项对象上增加所需数据。

执行 createDecorator 添加的装饰函数

vue-class-component 中提供了工具函数 createDecorator 允许添加其他额外的装饰函数,统一挂载在 Component.decorators 上,并把 options 传过去,对 options 增加需要的属性,实际上会调用这些装饰函数,让这些函数有机会处理 options

  1. function componentFactory(Component) {
  2. // 省略其他代码....
  3. var decorators = Component.__decorators__;
  4. if (decorators) {
  5. decorators.forEach(function (fn) {
  6. return fn(options);
  7. });
  8. delete Component.__decorators__;
  9. }
  10. }

我们可以利用 createDecorator,扩展其他的装饰器,vue-property-decorator 内部就是利用这个函数扩展了 @Prop @Watch 等装饰器。

  1. function createDecorator(factory) {
  2. return (target, key, index) => {
  3. // 是函数类型,则为装饰的类;
  4. // 否则,为原型,通过constructor拿到构造函数
  5. const Ctor = typeof target === 'function'
  6. ? target
  7. : target.constructor;
  8. if (!Ctor.__decorators__) {
  9. Ctor.__decorators__ = [];
  10. }
  11. // 当为参数装饰器时,index为number
  12. if (typeof index !== 'number') {
  13. index = undefined;
  14. }
  15. Ctor.__decorators__.push(options => factory(options, key, index));
  16. };
  17. }s

从源码中可以看出来,createDecorator 调用后会返回一个函数,这个函数可以作为装饰器函数,接收的 target 如果是函数类型,说明作为类装饰器,target 就是被装饰的类;否则,得到的是原型,通过 constructor 拿到构造函数。

向要装饰的类上添加静态属性 decorators,存入一个函数,获得 options

现在来看 vue-property-decoratorwatch 装饰器的源码,代码地址

  1. function Watch(path, options) {
  2. if (options === void 0) { options = {}; }
  3. return createDecorator(function (componentOptions, handler) {
  4. if (typeof componentOptions.watch !== 'object') {
  5. componentOptions.watch = Object.create(null);
  6. }
  7. var watch = componentOptions.watch;
  8. if (typeof watch[path] === 'object' && !Array.isArray(watch[path])) {
  9. watch[path] = [watch[path]];
  10. }
  11. else if (typeof watch[path] === 'undefined') {
  12. watch[path] = [];
  13. }
  14. watch[path].push({ handler: handler});
  15. });
  16. }

传入 createDecorator 的回调函数,会接受两个参数,componentOptions 为一个对象,就是在上面 componentFactory 中调用 Component.decorators,传入的对象,目的是向这个对象添加或增加 watch 属性,给要装饰的类使用;handler 是函数名字;

这样使用:

  1. @Component
  2. class Home extend Vue {
  3. message='新消息'
  4. @watch('message')
  5. messageHandler(){
  6. console.log('当message改变后,执行这里')
  7. }
  8. }

经过 @watch 装饰器处理后,选项对象上会增加一段数据:

  1. {
  2. watch: {
  3. message: 'messageHandler'
  4. },
  5. methods:{
  6. messageHandler(){
  7. console.log('当message改变后,执行这里')
  8. }
  9. }
  10. }

以上便是 vue-property-decorator 增加装饰器的实现方式,对其他装饰器感兴趣,可以看仓库源码,做进一步了解,思路都大同小异。

以上如有偏差欢迎指正学习,谢谢。~~~~

github博客地址:https://github.com/WYseven/blog,欢迎star。

如果对你有帮助,请关注【前端技能解锁】:
解读 vue-class-component 源码实现原理 - 图1