我写了一个插件,将你的 Vue3 Composition API 代码 转换为 <script setup> 模式。

lxf2023-03-16 09:22:01

初衷

自从 Vue3 <script setup> 发布距今也有一年多了。一年的时间,逐渐从怀疑到真香,开始在项目中大量使用这个特性,毕竟不用裹一层 defineComponent 和维护 return 对象是真的的爽,甚至可以媲美 React 的开发体验。但是项目中还有很多代码使用的是旧写法,普通的 Composition API 写法转成 setup 写法其实并不难,但架不住文件多,时间成本就上来了。于是借着这个机会,顺便学习如何写一个轮子,我开发了 vue3-script-to-setup,可以批量将旧的 Composition API 写法转为 <script setup>

思路

作为一个转换代码的插件,最简单的方式就是使用命令式。

npx tosetup /src/App.vue

但是希望批量转入或者自定义文件路径的话,更友好的方式还是需要一个配置文件,参考 vite.config,我们可以以葫芦画瓢让用户创建一个 tosetup.config.ts 文件,通过读取文件配置转换代码。

一般来说,对于自动转换的代码并不能百分百的保持和原功能一致,所以可以先生成一个 App.new.vue,让用户对比一下,看看哪里还需要手动优化。

开发

确定好思路就可以开始开发了,搭建一个初始的 node 项目模板可以参考 @antfu 大佬的 starter-ts,这个模板基本涵盖了一个库的构建测试。

想要让用户使用 npx [xxxx] 的方式就可以调用命令触发效果,需要在 package.json 文件的 bin 字段提供一个指令。

{
  // ... 省略
  "bin": {
    "tosetup": "bin/tosetup.mjs"
  }
  // ... 省略
}

这样就可一注入一个 tosetup 的命令,它将会调用 bin/tosetup.mjs 文件下的代码。

接下来就可以编写主题功能,首先需要接收命令行的参数,比如路径或者配置项。在 node 中我们可以直接使用

process.argv.slice(2).filter(Boolean)

获取参数,为了区分路径名,最好规范一下配置项的输入方式,比如加个 -- 的前缀区分。这样用户就可以这样使用。

npx tosetup /src/App.vue --propsNotOnlyTs

得到参数后,解析路径和命令项。

let { pathNames, commands } = argv.reduce<{
  pathNames: string[]
  commands: CommandsOption
}>(
  (p, c) => {
    // 配置项
    if (c.startsWith('--')) {
      switch (c.split('--')[1] as keyof typeof p.commands) {
        case 'propsNotOnlyTs':
          p.commands.propsNotOnlyTs = true
          break
        case 'notUseNewFile':
          p.commands.notUseNewFile = true
          break

        default:
          break
      }
      return p
    }

    // 文件路径
    const absolutePath = getTheFileAbsolutePath(c)
    if (absolutePath) {
      p.pathNames.push(absolutePath)
    }

    return p
  },
  { pathNames: [], commands: {} }
)

这样就可以得到要转换的代码路径和配置项,然后我们再去查找一下是否有 tosetup.config.ts/tosetup.config.js,解析文件仍然使用了 antfu 大佬的 unconfig 插件,可以快速读取文件配置。得到文件路径就可以开始转换了。

vue/compiler-sfc 提供了一个 parse API ,它可以将 vueSFC 转换成一个包含 script template style 的对象,这样我们就可以得到 script 的详细代码。

import { parse } from 'vue/compiler-sfc'
const {
  descriptor: { script, scriptSetup },
} = parse(sfc)

对于代码的转换不可避免的还是要利用 babel swc 等工具去将代码解析为 AST ,本着学习的心态选用了 swc 但着实踩了不少坑

  1. 在一次内存里多次使用 swcparseSync APIspan 会逐渐累加,比如当 parse abcspan{ start: 0, end: 3 },然后再 parse def span 应该还是 { start: 0, end: 3 },但 span 变为 { start: 3, end: 6 }

  2. jsrust 对特殊字符的长度表现不一致,比如在 js 中,中文 的长度是 2,而 rust 则是 6。这种不一致会造成代码截取错误,因此需要手动计算到正确的偏移量。

export function getRealSpan({ start, end }: Omit<Span, 'ctxt'>, offset: number) {
  if (!unicodeMap.size) {
    return { start: start - offset, end: end - offset }
  } else {
    let realStart = start
    let realEnd = end
    unicodeMap.forEach((value, key) => {
      if (start > key) {
        realStart = start - value
      }
      if (end > key) {
        realEnd = end - value
      }
    })

    return { start: realStart - offset, end: realEnd - offset }
  }
}

得到 parse 的代码后就可以使用 @swc/core/Visitor 进行遍历,配合 magic-string 截取想要的代码,详细的转换代码就不写了,感兴趣的可以去这里看看。

在使用 @swc/core/Visitor 时需要注意, 导出方式需要这样才能正常导出

//                                文件尾要加.js
import { Visitor } from '@swc/core/Visitor.js'

export class GetCallExpressionFirstArg extends Visitor {
  constructor() {
    super()
  }

  /** visitTsType 还没有实现,需要手动写一个覆盖,否则会报错 */
  visitTsType(n: TsType) {
    return n
  }
}

然后将转会后的代码,生成就可以了。

import { writeFileSync } from "fs";
import { CommandsOption } from "./constants";

function writeFile(
  code: string,
  path: string,
  { notUseNewFile }: CommandsOption,
) {
  const index = path.indexOf(".vue");
  const file = notUseNewFile ? path : `${path.slice(0, index)}.new.vue`;

  writeFileSync(file, code);

  return file;
}

const file = writeFile(code, path, commands);

效果

最终效果还是很不错的,大部分场景都能覆盖到

原代码

<script lang="ts">
import type { PropType } from 'vue';
import { defineComponent, onMounted, onUnmounted, ref } from 'vue';
// @ts-ignore
import BTween from 'b-tween';
import { getPrefixCls } from '../_utils/global-config';
import { on, off } from '../_utils/dom';
import { throttleByRaf } from '../_utils/throttle-by-raf';
import IconToTop from '../icon/icon-to-top';
import { isString } from '../_utils/is';

export default defineComponent({
  name: 'BackTop',
  components: {
    IconToTop,
  },
  props: {
    /**
     * @zh 显示回到顶部按钮的触发滚动高度
     * @en Display the trigger scroll height of the back to top button
     */
    visibleHeight: {
      type: Number as PropType<number>,
      default: 200,
    },
    /**
     * @zh 滚动事件的监听容器
     * @en Scroll event listener container
     */
    targetContainer: {
      type: [String, Object] as PropType<string | HTMLElement>,
    },
    /**
     * @zh 滚动动画的缓动方式,可选值参考 [BTween](https://github.com/PengJiyuan/b-tween)
     * @en Easing mode of scrolling animation, refer to [BTween](https://github.com/PengJiyuan/b-tween) for optional values
     */
    easing: {
      type: String,
      default: 'quartOut',
    },
    /**
     * @zh 滚动动画的持续时间
     * @en Duration of scroll animation
     */
    duration: {
      type: Number,
      default: 200,
    },
  },
  setup(props) {
    const prefixCls = getPrefixCls('back-top');
    const visible = ref(false);
    const target = ref<HTMLElement>();
    const isWindow = !props.targetContainer;

    const scrollHandler = throttleByRaf(() => {
      if (target.value) {
        const { visibleHeight } = props;
        const { scrollTop } = target.value;
        visible.value = scrollTop >= visibleHeight;
      }
    });

    const getContainer = (container: string | HTMLElement) => {
      if (isString(container)) {
        return document.querySelector(container) as HTMLElement;
      }
      return container;
    };

    onMounted(() => {
      target.value = isWindow
        ? document?.documentElement
        : getContainer(props.targetContainer);
      if (target.value) {
        on(isWindow ? window : target.value, 'scroll', scrollHandler);
        scrollHandler();
      }
    });

    onUnmounted(() => {
      scrollHandler.cancel();
      if (target.value) {
        off(isWindow ? window : target.value, 'scroll', scrollHandler);
      }
    });

    const scrollToTop = () => {
      if (target.value) {
        const { scrollTop } = target.value;
        const tween = new BTween({
          from: { scrollTop },
          to: { scrollTop: 0 },
          easing: props.easing,
          duration: props.duration,
          onUpdate: (keys: any) => {
            if (target.value) {
              target.value.scrollTop = keys.scrollTop;
            }
          },
        });
        tween.start();
        // props.onClick && props.onClick();
      }
    };

    return {
      prefixCls,
      visible,
      scrollToTop,
    };
  },
});
</script>

转换后

<script lang="ts" setup>

import { onMounted, onUnmounted, ref } from 'vue';
// @ts-ignore
import BTween from 'b-tween';
import { getPrefixCls } from '../_utils/global-config';
import { on, off } from '../_utils/dom';
import { throttleByRaf } from '../_utils/throttle-by-raf';
import IconToTop from '../icon/icon-to-top';
import { isString } from '../_utils/is';

const props = withDefaults(defineProps<{ visibleHeight?: number; targetContainer?: string | HTMLElement; easing?: string; duration?: number;  }>(), { visibleHeight: 200, easing: 'quartOut', duration: 200,  });
const prefixCls = getPrefixCls('back-top');
    const visible = ref(false);
    const target = ref<HTMLElement>();
    const isWindow = !props.targetContainer;

    const scrollHandler = throttleByRaf(() => {
      if (target.value) {
        const { visibleHeight } = props;
        const { scrollTop } = target.value;
        visible.value = scrollTop >= visibleHeight;
      }
    });

    const getContainer = (container: string | HTMLElement) => {
      if (isString(container)) {
        return document.querySelector(container) as HTMLElement;
      }
      return container;
    };

    onMounted(() => {
      target.value = isWindow
        ? document?.documentElement
        : getContainer(props.targetContainer);
      if (target.value) {
        on(isWindow ? window : target.value, 'scroll', scrollHandler);
        scrollHandler();
      }
    });

    onUnmounted(() => {
      scrollHandler.cancel();
      if (target.value) {
        off(isWindow ? window : target.value, 'scroll', scrollHandler);
      }
    });

    const scrollToTop = () => {
      if (target.value) {
        const { scrollTop } = target.value;
        const tween = new BTween({
          from: { scrollTop },
          to: { scrollTop: 0 },
          easing: props.easing,
          duration: props.duration,
          onUpdate: (keys: any) => {
            if (target.value) {
              target.value.scrollTop = keys.scrollTop;
            }
          },
        });
        tween.start();
        // props.onClick && props.onClick();
      }
    };

    
</script>

限制

当然这个插件对代码的转换还是有一些限制。

  1. 无法解析展开语法,即 { ...obj },所以当 props emits 等对象内部包含展开语法是无法转换的,但是在大部分代码也很少在这些字段内使用展开语法。

  2. 因为 emits 太过灵活,所以无法智能的生成 defineEmitsTypeScript-only Features,只能生成数组形式

  3. 如果 script 代码下子组件没有通过 expose 暴露内部状态,转换为 setup 代码后父组件将引用失败。默认情况下只要要是在 setup 函数 return 出去的变量,我们都可以在外部通过 ref 获取到,但是无法智能分析哪些是需要的,哪些不需要,因此需要手动将需要的变量或函数 expose 出去

结尾

希望这个插件能够对你有所帮助,如果觉得不错的话,求个 star 哦!

vue3-script-to-setup github