详解vue-class迁移vite的一次踩坑记录

what happen

最进项目从 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 的属性,可以多了解一下,也可以拓宽一些知识。

到此这篇关于详解vue-class迁移vite的一次踩坑记录的文章就介绍到这了,更多相关vue-class迁移vite内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:

  • 浅谈将three项目迁移至vue项目遇到的问题
  • 详解Vue2.5+迁移至Typescript指南
  • 详解vue-cli@2.x项目迁移日志
  • Vue如何从1.0迁移到2.0
页面下部广告
分类: