包教包会 - 手把手为组件库支持volar
随着Typescript的发展,在Vue3
+ Volar
中,如果组件库存在global.d.ts,那么在使用组件的时候就会提示组件的属性。
输入的时候也会有相应的提示
但如果没有global.d.ts则会不存在代码提示,组件类型也是any

0x0 事情起因
事情起因是这样的,在github上和一位师傅在讨论Panel的beforeToggle
API,应该返回布尔值还是调用done函数
我在尝试两种方案的时候发现,非常的不方便,因为我经常会忘记API名称。本来想着把Panel的API搞好之后就去修复,结果就忘了这茬。
0x1 方案拟定
在rc-1版本的时候就已经发现问题并提交了issues,但到了1.0.0还是没有人关闭这个issues,可能是各位老师都忙着修改自己的组件。正好我的组件也没有正在开放的issues,便着手开始修复这个问题。
看着build.js
我其实没有什么头绪,于是我跑去问了万能的刀酱1。得到的答复是这样的

于是我又跑去看Ant design的scripts脚本,结果发现人家是直接硬写死的 73ebf4c。

我坐在凳子上如坐针毡,看着代码硬是一点思路没有,于是根据我看的为数不多的医疗文献2,我很轻松的就能明白,一定是我认知疲劳了,绝对不是我想摆烂。解决认知疲劳的最好办法就是直接开摆!

但是当我连输了两把五子棋之后,我就啪的一下很快啊,马上就把手机扔了,并表示“我怎么是这种人呢”。但是看着VSCODE里没有思路的代码,我还立刻捡起被扔出去的手机在群里问了问各位师傅。
各位师傅也非常的给力,啪的一下很快啊,马上甩出了一个解决方案。

好!既然各位师傅们都这么的热心给了我帮助,那么我必然要解决这个恶魔

我!巴别塔的恶灵!整合运动的毁灭者!3
誓要将没有类型提示这个恶魔永远的封印进CHANGLOG里,再不允许它踏入项目半步

本文将会以vue-devui项目为例,带领读者从零改造改造构建流程,使得构建产物可以支持volar插件。话不多说我们直接开始。
0x2 分析问题
运行build:components命令之后,所有的产物都会放在packages\devui-vue\build
下。我们发现,build文件夹下不存在任何的声明文件。代码中也没有和声明文件有关的代码
// build.js
const createPackageJson = (name) => {
const fileStr = `{
"name": "${name}",
"version": "0.0.0",
"main": "index.umd.js",
"module": "index.es.js",
"style": "style.css",
"types": "../types/${name}/index.d.ts"
}`;
fsExtra.outputFile(path.resolve(outputDir, `${name}/package.json`), fileStr, 'utf-8');
};
exports.build = async () => {
// 打包umd与es模块
await buildAll();
// 获取所有components
const components = fs.readdirSync(entryDir).filter((name) => {
const componentDir = path.resolve(entryDir, name);
const isDir = fs.lstatSync(componentDir).isDirectory();
return isDir && fs.readdirSync(componentDir).includes('index.ts');
});
// 构建所有准备好发布的组件.
for (const name of components) {
if (!isReadyToRelease(name)) {
continue;
}
// 打包单个组件
await buildSingle(name);
// 创建单个组件的package,支持按需导入
createPackageJson(name);
}
};
我们只需要使用vue-tsc在打包时声明类型文件,最后根据组件完成度来决定哪些组件要写进global.d.ts即可。

0x3 global.d.ts大致结构
现在逻辑大致通顺了,只需要用vue-tsc生成各组件的声明文件,然后稍做处理就可以写入global.d.ts了,不过目前我们还不知道global.d.ts文件大致是什么样子,文件结构也不复杂,我们来熟悉一下
// global.d.ts
export {}
// vue3 support
declare module '@vue/runtime-core'{
interface GlobalComponents {
// 组件声明
}
interface ComponentCustomProps{
// 指令声明
}
interface ComponentCustomProperties{
// service声明
}
}
了解了global文件的大致结构,我们就可以直接开始改造了
0x4 目录结构
先让我们大致看一眼改造前的目录结构是什么样的.下图只列出了重要的目录.
packages
...
build
vue-devui.umd.js
vue-devui.es.js
devui-vue
__mocks__
devui // 组件源码
component
index.ts // 组件入口
...
vue-devui.ts // 库入口
devui-cli // cli工具
commands
build.js // 打包逻辑
docs // 组件文档
0x5 依赖安装
本次使用了vue-tsc
来生成声明文件
pnpm install vue-tsc ---save
在文章发布时vue-tsc已经迭代到1.0.0
0x6 改造packages.json
因为vue-tsc不是全局安装,直接使用命令会报错。所以我们需要在packages.json中添加一条命令.
"scripts":{
+ "build:components:dts": "vue-tsc --declaration --emitDeclarationOnly"
}
读者可以根据自己需求来添加参数,这里只需要生成类型文件,故只添加了--declaration --emitDeclarationOnly
0x7 改造tsconfig.json
我在这里将declarationDir
设置为build/types
做统一管理。
因为vue-tsc会自动读取最近的tsconfig,如果不对 declarationDir
选项做设置,最终的声明文件将会生成在原目录下,而不是 build
目录下
0x8 改造build.js
for (const name of components) {
if (!isReadyToRelease(name)) {
continue;
}
readyToReleaseComponentName.push(name);
await buildSingle(name);
createPackageJson(name);
nuxtBuild.createAutoImportedComponent(name);
}
- nuxtBuild.createNuxtPlugin();
+ try {
+ execSync(`pnpm run build:components:dts`);
+ } catch {}
+ nuxtBuild.createNuxtPlugin();
注意,这里要使用try...catch
包裹。除非组件库非常的标准,没有一点类型错误,否则遇到类型错误之后会报错,后续代码便不会运行。
再次运行build:components
命令,等待任务结束后我们可以发现build
目录下就已经多出了types
文件夹。内部包含了所有组件及库入口的声明文件。

0x9 生成global.d.ts
既然每个组件都有了声明文件,我们就可以开始生成global.d.ts。每一个组件的入口文件格式都是固定的,我们随便选择一个组件看一下。
// devui/alert/src/alert.tsx
export default defineComponent({
name: 'DAlert',
props: alertProps,
/** setup 略 */
})
// devui/alert/index.ts
import type { App } from 'vue';
import Alert from './src/alert';
export * from './src/alert-types';
export { Alert };
export default {
title: 'Alert 警告', // 声明组件名称(用于文档)
category: '反馈', // 组件类型(用于文档)
status: '100%', // 组件完成状态(status≠100%时为未完成)
install(app: App): void {
// 注册组件
app.component(Alert.name, Alert); // Alert.name 返回 DAlert
},
};
0x9.1 遍历install函数
组件的默认导出中又有一段install函数。当开发者全局使用库时,库入口会use各个组件入口文件的install函数。
install函数通常只做注册处理,但部分组件会做一些简单的锚点处理。例如overlay组件会在body中插入一个元素。
我们可以根据这个特性,使用ts-compiler对install函数进行处理。大致的处理流程如下图示
项目中还使用了一颗关系树,目的是为了存储组件之间的关系,大致的抽象结构如下
多叉树的实现代码如下
// use-relation-tree.js
class componentNode {
/**
*
* @param {String} name componentName
*/
constructor(name){
this.name = name;
/** @type {componentNode} */
this.children = [];
this.type = '';
this.isComponet = false;
}
/**
*
* @param {(node: componentNode) => void} callback
*/
forEachChild(callback){
for (const child of this.children){
callback(child);
}
}
}
class componentRelationTree{
constructor(){
/**
* @type {componentNode}
*/
this.root = new componentNode('root');
}
/**
*
* @param {componentNode} node component relation Node. Used to describe the relationship between components
*/
insert(node){
// 避免进行误操作
if (!this.#_hasSameNode(node)){
this.root.children.push(node);
}
}
/**
*
* @param {componentNode} node
* @return {Boolean}
*/
#_hasSameNode(node){
let idx=0;
let hasSame = false;
while (this.root.children.length !== idx){
/** @type {componentNode} */
const child = this.root.children[idx++];
hasSame = child.name === node.name;
}
return hasSame;
}
}
ts-compiler处理流程如下, 为了逻辑清晰,我们封装到use-relation-tree.js
里
// use-relation-tree.js
exports.useRelationTree = function (componentPaths){
const tsPrograms = [];
const tree = new componentRelationTree();
tree.root.type = 'root';
for (const path of componentPaths){
tsPrograms.push(ts.createSourceFile('', readIndexFile(path)));
}
for (const program of tsPrograms){
/**
* @type {ts.ExportDeclaration[]}
*/
const sourceFile = program.getSourceFile();
program.forEachChild((node) => {
if (ts.isExportAssignment(node)){
/**
* @type {ts.ObjectLiteralElement}
*/
const exportObject = node.getChildAt(0, sourceFile);
/** @type {ts.Node[]} */
const properties = exportObject.parent.expression.properties;
/** @type {componentNode} */
let componentTreeNode;
properties.forEach((property) => {
if (ts.isPropertyAssignment(property)){
const Identifier = property.getChildAt(0, sourceFile).getText(sourceFile);
const value = property.getChildAt(2, sourceFile).getText(sourceFile);
if (Identifier === 'title'){
// title: 'Card 卡片'
componentTreeNode = new componentNode(value.split(' ')[0]);
}
} else {
/** @type {ts.MethodDeclaration} */
const method = property;
/** @type {ts.Block} */
const block = method.body.getChildAt(1, sourceFile);
const blockChildren = block.getChildren(sourceFile);
for (const child of blockChildren){
const childCode = child.getFullText(sourceFile);
const nodeName = extra(childCode);
const nodeType = extraType(childCode);
const childNode = new componentNode(nodeName);
childNode.type = nodeType;
childNode.isComponet = nodeType === 'component';
if (nodeName){
componentTreeNode.children.push(childNode);
}
}
}
});
tree.insert(componentTreeNode);
}
});
}
}
我们将提取类型放入另一个文件use-extra.js
中
// use-extra.js
/**
*
* @param {string} code node full text.
* @returns {RegExpMatchArray | undefined}
*/
function extraComponentName(code){
const regexp = /app\.component\(((?<components>.*)\.name), (?<fileName>.*)\)/;
const groups = regexp.exec(code)?.groups;
if (groups?.components){
return groups.components;
}
}
/**
*
* app.directive('file-drop', fileDropDirective);
* @param {string} code
* @returns {RegExpMatchArray | undefined}
*/
function extraDirective(code){
const regexp = /app\.directive\('(?<directiveName>.*), ?(?<fileName>.*)\);/;
const groups = regexp.exec(code)?.groups;
if (groups?.fileName){
return groups.fileName;
}
}
/**
*
* app.config.globalProperties.$loading = loading;
* app.provide(ModalService.token, new ModalService(anchorsContainer));
* @param {string} code
* @returns {RegExpMatchArray | undefined}
*/
function extraGlobalProperties(code) {
const globalPropertiesReg = /app\.config\.globalProperties\.(?<serviceName>\$.*) = (?<serviceFileName>.*);/;
const provideReg = /app\.provide\((?<serviceName>.*)\..*, ?new? ?(?<instanceName>.*)\((?<param>.*)\);/gm;
const groups = globalPropertiesReg.exec(code)?.groups || provideReg.exec(code);
if (groups?.serviceName){
return groups.serviceName;
}
}
/**
*
* @param {string} code
* @returns {String | undefined}
*/
function extraValue(code){
return extraComponentName(code) ?? extraDirective(code) ?? extraGlobalProperties(code);
}
/**
* @param {string} code
* @returns {String | undefined}
*/
function extraType(code){
const isDirective = /app\.directive/.test(code);
const isComponent = /app\.component/.test(code);
const isGlobalProperties = /app\.config\.globalProperties/.test(code);
const isProvide = /app\.provide/.test(code);
if (isDirective) {return 'directive';}
if (isComponent) {return 'component';}
if (isGlobalProperties || isProvide) {return 'service';}
}
exports.extra = extraValue;
exports.extraType = extraType;
之后我们只需要两层的forEachChild拼接字符串即可。
const tree = useRelationTree(componentPath);
tree.forEachChild((foldNode) => {
foldNode.forEachChild((node) => {
let nodeName = node.name.replace(/\$/gim, '').replace(/directive/gim, '');
let reference = nodeName;
// Modal中的Body与Layout中的Body重复,在这里做读取replaceIdentifer.json做一次转换
const needToTransform = replaceIdentifier?.[foldNode.name]?.[node.name] !== undefined;
if (!node.isComponet){
const hasType = new RegExp(node.type, 'gim');
if (!hasType.test(reference)){
reference += `-${node.type}`;
}
reference = bigCamelCase(reference);
}
if (needToTransform){
reference = replaceIdentifier[foldNode.name][node.name]?.['reference'];
nodeName = replaceIdentifier[foldNode.name][node.name]?.['exportKey'];
}
if (node.type === 'component'){
componentDTSItem.push(buildComponentItem(bigCamelCase(nodeName), reference));
}
if (node.type === 'directive'){
directiveDTSItem.push(buildDirectiveItem(nodeName, reference));
}
if (node.type === 'service'){
serviceDTSItem.push(buildServiceItem(nodeName, reference));
}
});
});
我们将上述代码打包成一个函数,这里取名为了volarSupport
// build.js
exports.build = async () => {
nuxtBuild.createNuxtPlugin();
+ logger.success('准备生成global.d.ts');
+ const volarSupportbuildState = volarSupport(replaceIdentifier, readyToReleaseComponentName);
+ fs.writeFileSync('./build/index.d.ts', `
+export * from './types/vue-devui';
+import _default from './types/vue-devui';
+export default _default;
+`);
+ if (volarSupportbuildState){
+ logger.success('global.d.ts生成成功');
+ } else {
+ logger.error('global.d.ts生成失败, 因为发生错误');
+ }
此时我们还需要修改一下build.js
中的createPackageJson
函数。目的是为了解决局部导入时的报错
const createPackageJson = (name) => {
const fileStr = `{
"name": "${name}",
"version": "0.0.0",
"main": "index.umd.js",
"module": "index.es.js",
"style": "style.css",
+ "types": "../types/${name}/index.d.ts"
}`;
fsExtra.outputFile(path.resolve(outputDir, `${name}/package.json`), fileStr, 'utf-8');
};
同理我们需要在build
函数中写入一个index.d.ts
,为了解决全局引入的报错
exports.build = async () => {
const volarSupportbuildState = volarSupport(replaceIdentifier, readyToReleaseComponentName);
+ fs.writeFileSync('./build/index.d.ts', `
+ export * from './types/vue-devui';
+ import _default from './types/vue-devui';
+ export default _default;
+ `);
if (volarSupportbuildState){
logger.success('global.d.ts生成成功');
} else {
logger.error('global.d.ts生成失败, 因为发生错误');
}
}
之后我们运行pnpm build:release
就可以成功的生成好带有volar支持的项目啦~
0x10 总结
整体流程图如下
整体代码已经在devui的仓库中了。鄙人才疏学浅,欢迎各位师傅在issues中指出错误。
Footnotes
转载自:https://juejin.cn/post/7152330397689315341