时间:2022-06-27 08:50:35 | 栏目:vue | 点击:次
最进项目从 vue-cli 迁移到了 vite,因为是 vue2 的项目,使用了 vue-class-component 类组件做 ts 支持。
当然迁移过程并没有那么一帆风顺,浏览器控制台报了一堆错,大致意思是某某方法为 undefined,无法调用。打印了下当前 this,为 undefined 的方法都来自于 vuex-class 装饰器下的方法。这就是一件很神奇的事,为什么只有 vuex-class 装饰器下的方法才会为 undefined ?
在网上搜了下并没有类似的问题,只能自己在 node_modules 中一步一步打断点看是哪里出了问题。最先觉得有问题的是 vuex-class ,调试了下 /node_modules/vuex-class/lib/bindings.js 下的代码,发现 vuex-class 只是做了一层方法替换,通过 createDecorator 方法存到 vue-class-component 下的 __decorators__ 数组中。
import { createDecorator } from "vue-class-component"; function createBindingHelper(bindTo, mapFn) { function makeDecorator(map, namespace) { // 存入到 vue-class-component 的 __decorators__ 数组中 return createDecorator(function (componentOptions, key) { if (!componentOptions[bindTo]) { componentOptions[bindTo] = {}; } var mapObject = ((_a = {}), (_a[key] = map), _a); componentOptions[bindTo][key] = namespace !== undefined ? mapFn(namespace, mapObject)[key] : mapFn(mapObject)[key]; var _a; }); } function helper(a, b) { if (typeof b === "string") { var key = b; var proto = a; return makeDecorator(key, undefined)(proto, key); } var namespace = extractNamespace(b); var type = a; return makeDecorator(type, namespace); } return helper; }
那就只能来看看 vue-class-component 了。
vue-class-component 的 @Component 装饰器会返回一个 vue对象的构造函数。
// vue-class-component/lib/component.js function Component (options: ComponentOptions<Vue> | VueClass<Vue>): any { if (typeof options === 'function') { return componentFactory(options) } return function (Component: VueClass<Vue>) { return componentFactory(Component, options) } } // 类组件 @Component export default class HelloWorld extends Vue { ... }
Component 方法会把 class HelloWorld 传入 componentFactory , 在其内部将 name 生命周期 methods computed 等注册到 options 中,然后传入 Vue.extend, 返回一个 vue对象的构造函数 。
export function componentFactory( Component: VueClass<Vue>, options: ComponentOptions<Vue> = {} ): VueClass<Vue> { // 。。。无关代码 options.name = options.name || (Component as any)._componentTag || (Component as any).name; const proto = Component.prototype; (options.methods || (options.methods = {}))[key] = descriptor.value; // typescript decorated data (options.mixins || (options.mixins = [])).push({ data(this: Vue) { return { [key]: descriptor.value }; }, }); // 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({ data(this: Vue) { return collectDataFromConstructor(this, Component); }, }); // vuex-class 包装的方法会在此处注入 const decorators = (Component as DecoratedClass).__decorators__; if (decorators) { decorators.forEach((fn) => fn(options)); delete (Component as DecoratedClass).__decorators__; } const Super = superProto instanceof Vue ? (superProto.constructor as VueClass<Vue>) : Vue; const Extended = Super.extend(options); // 。。。无关代码 return Extended; }
至此基本没有什么问题,那么压力就来到 vue 这里。返回的 Extended 是 Vue.extend 生成的 vue对象构造函数。
Vue.extend = function (extendOptions) { // 。。。无关代码 var Sub = function VueComponent(options) { this._init(options); }; // 。。。无关代码 return Sub; };
在 new Extended 的时候会调用 _init 初始化 vm 对象。
Vue.prototype._init = function (options) { // 。。。无关代码 initLifecycle(vm); initEvents(vm); initRender(vm); callHook(vm, "beforeCreate"); initInjections(vm); // resolve injections before data/props initState(vm); initProvide(vm); // resolve provide after data/props callHook(vm, "created"); // 。。。无关代码 };
接下来就是无聊的打断点调试了,最终找到在执行完 initState 方法后 vm 内的有些方法变为了 undefined ,initState 的作用是将 data methods 等注册到 vm 上。
function initState(vm) { vm._watchers = []; var opts = vm.$options; if (opts.props) { initProps(vm, opts.props); } if (opts.methods) { initMethods(vm, opts.methods); } if (opts.data) { initData(vm); } else { observe((vm._data = {}), true /* asRootData */); } if (opts.computed) { initComputed(vm, opts.computed); } if (opts.watch && opts.watch !== nativeWatch) { initWatch(vm, opts.watch); } }
再打断点找到 initData 方法后产生的问题,initData 方法的作用是将 data 对象注册到 vm 上,如果 data 是一个函数,则会调用该函数,那么问题就出现在 getData 中的 data.call(vm, vm) 这一句了。
function initData(vm) { var data = vm.$options.data; data = vm._data = typeof data === "function" ? getData(data, vm) : data || {}; // 。。。无关代码 } function getData(data, vm) { // #7573 disable dep collection when invoking data getters pushTarget(); try { const a = data.call(vm, vm); return a; } catch (e) { handleError(e, vm, "data()"); return {}; } finally { popTarget(); } }
调用的 data.call(vm, vm) 是 vue-class-component 注册的方法。好吧,又回到了 vue-class-component,我们来看看 vue-class-component 的代码。
export function componentFactory( Component: VueClass<Vue>, options: ComponentOptions<Vue> = {} ): VueClass<Vue> { // 。。。无关代码 (options.mixins || (options.mixins = [])).push({ data(this: Vue) { return collectDataFromConstructor(this, Component); }, }); // 。。。无关代码 }
在上面的 componentFactory 方法中,data 返回一个 collectDataFromConstructor 方法。在 collectDataFromConstructor 我们应该就可以解开谜题了。
function collectDataFromConstructor(vm, Component) { Component.prototype._init = function () { var _this = this; // proxy to actual vm var keys = Object.getOwnPropertyNames(vm); // 2.2.0 compat (props are no longer exposed as self properties) if (vm.$options.props) { for (var key in vm.$options.props) { if (!vm.hasOwnProperty(key)) { keys.push(key); } } } keys.forEach(function (key) { Object.defineProperty(_this, key, { get: function get() { return vm[key]; }, set: function set(value) { vm[key] = value; }, configurable: true, }); }); }; // should be acquired class property values var data = new Component(); // restore original _init to avoid memory leak (#209) // 。。。无关代码 return data; }
function Vue(options) { this._init(options); }
传下来的 Component 参数即 export default class HelloWorld extends Vue { ... }, new Component() 会获取到 HelloWorld 内的所有参数。 Component 继承于 Vue ,因此在 new Component() 时,会像 Vue 一样先调用一遍 _init 方法,collectDataFromConstructor 置换了 Component 的 _init。
在置换的 _init 方法中,会遍历 vm 上的所有属性,并且将这些属性通过 Object.defineProperty 再指回 vm 上。原因在于 initData 前会先 initProps initMethods 意味着,那么在 new Component() 时,探测到属于 props methods 的值时就会指向 vm,而剩下的就是 data 值。
整个流程跑下来好像没什么问题。不过既然使用了 Object.defineProperty 做 get set ,那会不会和 set 方法有关系呢?在 set 方法里打了一层断点,果然触发了,触发的条件有些奇特。
@Component export default class HelloWorld extends Vue { // vuex @model.State count: number; @model.Mutation("increment") increment: () => void; @model.Mutation("setCount") setCount: () => void = () => { this.count = this.count + 1; }; // data msg: string = "Hello Vue 3 + TypeScript + Vite"; // methods incrementEvent() { console.log(this); this.increment(); this.msg = this.msg + " + " + this.count; } // 生命周期 beforeCreate() {} created() { console.log(this); this.msg = this.msg + " + " + this.count; } }
上面是一个很基础的类组件,increment setCount 的 set 触发,一个被传入了 undefined 一个被传入 () => { this.count = this.count + 1 },两个都属于 methods 但都是不是以 fn(){} 的方式赋予初始值,所以 incrementEvent 的 set 没有触发,increment 被传入了 undefined,setCount 被传入了一个函数
class A { increment; setCount = () => {}; incrementEvent() {} }
increment 和 setCount 为一个变量,而 incrementEvent 会被看做一个方法
奇怪的是在 vue-cli 中没什么问题,set 方法不会触发,为什么切换到 vite 之后 会触发 set 重置掉一些变量的初始值。我想到是不是二者的编译又问题。我对比了下二者编译后的文件,果然。
vue-cli
export default class HelloWorld { constructor() { this.setCount = () => { this.count = this.count + 1; }; // data this.msg = "Hello Vue 3 + TypeScript + Vite"; } // methods incrementEvent() { console.log(this); this.increment(); this.msg = this.msg + " + " + this.count; } // 生命周期 beforeCreate() {} created() { console.log(this); this.msg = this.msg + " + " + this.count; } }
vite
export default class HelloWorld { // vuex count; increment; setCount = () => { this.count = this.count + 1; }; // data msg = "Hello Vue 3 + TypeScript + Vite"; // methods incrementEvent() { console.log(this); this.increment(); this.msg = this.msg + " + " + this.count; } // 生命周期 beforeCreate() {} created() { console.log(this); this.msg = this.msg + " + " + this.count; } }
可以看到 vue-cli vite 的编译结果并不一致,vite 比 vue-cli 多出了 count increment 两个默认值,这两个值默认值是 undefined,在 vue-cli 并没有编译进去。下面只能去翻 vite 文档了,一个属性吸引了我。
查了下这个 useDefineForClassFields 属性,简单来讲,useDefineForClassFields 为 false 的情况下 ts 会 跳过为 undefined 的变量,为 true 就会将默认值为 undefined 的变量属性依然编译进去。正常情况下不会有什么问题,但是 vue-class-component 会对 props methods 的属性做一层劫持,那 new 初始化 的时候探测到这些值就会触发 set,如果没有默认值就会被赋值为 undefined。
想要解决很简单,只要在 tsconfig 中加入 useDefineForClassFields 属性,并设置为 false 就可以了。
{ "compilerOptions": { "target": "ESNext", "useDefineForClassFields": false, "module": "ESNext", "lib": ["ESNext", "DOM"], "moduleResolution": "Node", "strict": true, "sourceMap": false, "resolveJsonModule": true, "esModuleInterop": true, "noEmit": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true }, "include": ["./src"] }
在转到 vite 的过程中,还是有许多坑要踩的,有时候并不是 vite 的问题,而是来自多方的问题,useDefineForClassFields 带来的变化也不仅仅是会编译为 undefined 的属性,可以多了解一下,也可以拓宽一些知识。