初衷
自从 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 ,它可以将 vue
的 SFC
转换成一个包含 script template style
的对象,这样我们就可以得到 script
的详细代码。
import { parse } from 'vue/compiler-sfc'
const {
descriptor: { script, scriptSetup },
} = parse(sfc)
对于代码的转换不可避免的还是要利用 babel
swc
等工具去将代码解析为 AST
,本着学习的心态选用了 swc
但着实踩了不少坑
-
在一次内存里多次使用
swc
的parseSync API
,span
会逐渐累加,比如当parse
abc
,span
是 { start: 0, end: 3 },然后再parse
def
span
应该还是 { start: 0, end: 3 },但span
变为 { start: 3, end: 6 }。 -
js
和rust
对特殊字符的长度表现不一致,比如在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>
限制
当然这个插件对代码的转换还是有一些限制。
-
无法解析展开语法,即
{ ...obj }
,所以当props
emits
等对象内部包含展开语法是无法转换的,但是在大部分代码也很少在这些字段内使用展开语法。 -
因为
emits
太过灵活,所以无法智能的生成defineEmits
的TypeScript-only Features
,只能生成数组形式 -
如果
script
代码下子组件没有通过expose
暴露内部状态,转换为setu
p 代码后父组件将引用失败。默认情况下只要要是在setup
函数return
出去的变量,我们都可以在外部通过ref
获取到,但是无法智能分析哪些是需要的,哪些不需要,因此需要手动将需要的变量或函数expose
出去
结尾
希望这个插件能够对你有所帮助,如果觉得不错的话,求个 star
哦!
vue3-script-to-setup github