解读 vue-class-component 源码实现原理
2020/4/24 11:22:08
本文主要是介绍解读 vue-class-component 源码实现原理,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
导读
使用过一段时间 class 来定义组件,要用 vue-property-decorator 提供定义好的装饰器,辅助完成所需功能,对这个过程好奇,就研究了源码。内部主要依靠 vue-class-component 实现,所以将重点放在对 vue-class-component 的解读上。
本文主要内容有:
- 装饰器作用在 class 定义的组件,发生了什么
- 解读 Component 装饰器实现过程
- vue-property-decorator 中如何扩展装饰器
装饰器作用在 class 定义的组件,发生了什么
没有使用 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行左右。
本文分析的代码主要文件在:仓库地址
解读 Component 装饰器
先来看大致结构和如何使用:
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 函数
从名字上可以看出来,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 类。
分析 collectDataFromConstructor 函数
这个函数的目的是把原来装饰的类,初始之后,拿到实例上的属性组成对象返回。代码地址
来看代码:
// 用来收集被装饰类中定义的属性 // 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-property-decorator 依赖 vue-class-component 实现,主要用了内部提供的 createDecorator 方法。
如果你想增加更多装饰器,也可以通过调用 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 增加装饰器的实现方式,对其他装饰器感兴趣,可以看仓库源码,做进一步了解,思路都大同小异。
以上如有偏差欢迎指正学习,谢谢。~~~~
github博客地址:github.com/WYseven/blo…,欢迎star。
如果对你有帮助,请关注【前端技能解锁】:
这篇关于解读 vue-class-component 源码实现原理的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-10-04package.json 文件位置在哪?-icode9专业技术文章分享
- 2024-10-01Craco.js学习:从入门到实践指南
- 2024-10-01Create-React-App学习:入门与实践指南
- 2024-10-01CSS-in-JS学习:从入门到实践指南
- 2024-09-30JSX语法学习:从入门到初步掌握
- 2024-09-30Mock.js学习:入门教程与实战演练
- 2024-09-30React Hooks学习:从入门到实践
- 2024-09-30受控组件学习:React中的基础入门教程
- 2024-09-29JS定时器教程:初学者必看指南
- 2024-09-29JS对象教程:初学者的全面指南