eastfair-plus v0.1.2
一、框架初始化
技术选型: Vite + Vue3 + Typescript。
在空白目录执行下列命令:
yarn create vite
依次填写项目名称和选择框架为 vue-ts 后,将会自动完成项目的初始化,代码结构如下:
.
├── README.md
├── index.html
├── package.json
├── public
├── src
├── tsconfig.json
├── vite.config.ts
└── yarn.lock
在根目录下新建一个 ./packages/components
目录,后续组件的开发都会在该目录进行。以一个 <ef-button />
组件为例,看看 ./packages/components
目录内部是什么样的:
packages
├── Button
│ ├── docs
│ │ ├── README.md // 组件文档
│ │ └── demo.vue // 交互式预览实例
│ ├── index.ts // 模块导出文件
│ └── src
│ └── index.vue // 组件本体
├── index.ts // 组件库导出文件
└── list.json // 组件列表
下面分别看看这些文件都是些什么内容。
packages/components/Button/src/index.vue
该文件是组件的本体,代码如下:
<template>
<button class="ef-button" @click="$emit('click', $event)">
<slot></slot>
</button>
</template>
<script lang="ts" setup>
defineEmits(['click']);
</script>
<style scoped>
.ef-button {
// 样式部分省略
}
</style>
packages/components/Button/index.ts
为了让组件库既允许全局调用:
import { createApp } from 'vue'
import App from './app.vue'
import eastfairUi from 'eastfair-ui'
createApp(App).use(eastfairUi)
也允许局部调用:
import { Button } from 'eastfair-ui'
Vue.component('ef-button', Button)
因此需要为每一个组件定义一个 VuePlugin
的引用方式。package/Button/index.ts
的内容如下:
import { App, Plugin } from 'vue';
import Button from './src/index.vue';
export const ButtonPlugin: Plugin = {
install(app: App) {
app.component('ef-button', Button);
},
};
export { Button };
packages/components/index.ts
该文件是作为组件库本身的导出文件,它默认导出了一个 VuePlugin
,同时也导出了不同的组件:
import { App, Plugin } from 'vue';
import { ButtonPlugin } from './Button';
const EfPlugin: Plugin = {
install(app: App) {
ButtonPlugin.install?.(app);
},
};
export default EfPlugin;
export * from './Button';
./packages/components/list.json
最后就是组件库的一个记述文件,用来记录了它里面组件的各种说明,这个我们后面会用到:
[
{
"compName": "Button",
"compZhName": "按钮",
"compDesc": "这是一个按钮",
"compClassName": "button"
}
]
完成了上述组件库目录的初始化以后,此时我们的 eastfair-ui
是已经可以被业务侧直接使用了。
回到根目录下找到 src/main.ts
文件,我们把整个 eastfair-ui
引入:
import { createApp } from 'vue'
import App from './App.vue'
import eastfairUi from '../packages/components';
createApp(App).use(eastfairUi).mount('#app')
改写 src/App.vue
,引入 <ef-button></ef-button>
试一下:
<template>
<ef-button>我是自定义按钮</ef-button>
</template>
运行 yarn dev
开启 Vite 的服务器以后,就可以直接在浏览器上看到效果了:
二、实时可交互式文档
一个组件库肯定不止有 Button 一种组件,每个组件都应该有它独立的文档。这个文档不仅有对组件各项功能的描述,更应该具有组件预览、组件代码查看等功能,我们可以把这种文档称之为“可交互式文档”。同时为了良好的组件开发体验,我们希望这个文档是实时的,这边修改代码,那边就可以在文档里实时地看到最新的效果。接下来我们就来实现这么一个功能。
组件的文档一般是用 Markdown 来写,在这里也不例外。我们希望一个 Markdown 一个页面,因此需要使用 vue-router@next
来实现路由控制。
在根目录的 /src
底下新建 router.ts
,写入如下代码:
import { createRouter, createWebHashHistory, RouterOptions } from 'vue-router'
const routes = [{
title: '按钮',
name: 'Button',
path: '/components/Button',
component: () => import(`packages/components/Button/docs/README.md`),
}];
const routerConfig = {
history: createWebHashHistory(),
routes,
scrollBehavior(to: any, from: any) {
if (to.path !== from.path) {
return { top: 0 };
}
},
};
const router = createRouter(routerConfig as RouterOptions);
export default router;
可以看到这是一个典型的 vue-router@next
配置,path 为 /components/Button
的路由引入了一个 Markdown 文件,这个在默认的 Vite 配置里是无效的,需要引入 vite-plugin-md
插件来解析 Markdown 文件并把它变成 Vue 文件。回到根目录下找到 vite.config.ts
,添加该插件:
import Markdown from 'vite-plugin-md'
export default defineConfig({
// 默认的配置
plugins: [
vue({ include: [/\.vue$/, /\.md$/] }),
Markdown(),
],
})
这样配置以后,任意的 Markdown 文件都能像一个 Vue 文件一样被使用了。
三、代码预览功能
只要把组件放进一个 <Preview />
标签内就能直接展示组件的代码,同时还具有代码高亮的功能,这才是可交互式文档真正具备的样子!接下来我们就来研究一下应该如何实现这个功能。
在 Vite 的开发文档里有记载到,它支持在资源的末尾加上一个后缀来控制所引入资源的类型。比如可以通过 import xx from 'xx?raw'
以字符串形式引入 xx 文件。基于这个能力,我们可以在 <Preview />
组件中获取所需要展示的文件源码。
首先来新建一个 Preview.vue
文件,其核心内容是通过 Props 拿到源码的路径,然后通过动态 import 的方式把源码拿到。以下展示核心代码(模板部分暂时略过)
export default {
props: {
/** 组件名称 */
compName: {
type: String,
default: '',
require: true,
},
/** 要显示代码的组件 */
demoName: {
type: String,
default: '',
require: true,
},
},
data() {
return {
sourceCode: '',
};
},
mounted() {
this.sourceCode = (
await import(/* @vite-ignore */ `../../packages/components/${this.compName}/docs/${this.demoName}.vue?raw`)
).default;
}
}
这里需要加 @vite-ignore
的注释是因为 Vite 基于 Rollup,在 Rollup 当中动态 import 是被要求传入确定的路径,不能是这种动态拼接的路径。具体原因和其静态分析有关,感兴趣的同学可以自行搜索了解。此处加上该注释则会忽略 Rollup 的要求而直接支持该写法。
但是这样的写法在 dev 模式下可用,待真正执行 build 构建了以后再运行会发现报错。其原因也是同样的,由于 Rollup 无法进行静态分析,因此它无法在构建阶段处理需要动态 import 的文件,导致会出现找不到对应资源的情况。这个问题截止到目前(2021.12.11)暂时没有好的办法,只好判断环境变量,在 build 模式下通过 fetch
请求文件的源码来绕过。改写后如下:
const isDev = import.meta.env.MODE === 'development';
if (isDev) {
this.sourceCode = (
await import(/* @vite-ignore */ `../../packages/components/${this.compName}/docs/${this.demoName}.vue?raw`)
).default;
} else {
this.sourceCode = await fetch(`./packages/components/${this.compName}/docs/${this.demoName}.vue`).then((res) => res.text());
}
假设构建后的输出目录为
/docs
,记得在构建后也要把./packages/components
目录复制过去,否则在 build 模式下运行会出现 404 的情况。
可能又有同学会问,为什么要这么麻烦,直接在 dev 模式下也走 fetch
请求的方式不行么?答案是不行,因为在 Vite 的 dev 模式下,它本来就是通过 http 请求去拉取文件资源并处理完了才给到了业务的那一层。因此在 dev 模式下通过 fetch
拿到的 Vue 文件源码是已经被 Vite 给处理过的。
拿到了源码以后,只需要展示出来即可:
<template>
<pre>{{ sourceCode }}</pre>
</template>
但是这样的源码展示非常丑,只有干巴巴的字符,我们有必要给它们加个高亮。高亮的方案我选择了 PrismJS,它非常小巧又灵活,只需要引入一个相关的 CSS 主题文件,然后执行 Prism.highlightAll()
即可。本例所使用的 CSS 主题文件已经放置在仓库,可以自行取用。
回到项目,执行 yarn add prismjs -D
安装 PrismJS,然后在 <Preview />
组件中引入:
import Prism from 'prismjs';
import '../assets/prism.css'; // 主题 CSS
export default {
// ...省略...
async mounted() {
// ...省略...
await this.$nextTick(); // 确保在源码都渲染好了以后再执行高亮
Prism.highlightAll();
},
}
由于 PrismJS 没有支持 Vue 文件的声明,因此 Vue 的源码高亮是通过将其设置为 HTML 类型来实现的。在 <Preview />
组件的模板中我们直接指定源码的类型为 HTML:
<pre class="language-html"><code class="language-html">{{ sourceCode }}</code></pre>
这样调整了以后,PrismJS 就会自动高亮源码了。
四、命令式新建组件
通过 node
运行该文件时,会在终端内依次提出三个组件信息相关的问题,并把答案 compName
(组件英文名),compZhName
(组件中文名)和 compDesc
(组件描述)保存在 meta
对象中并导出。
收集到了组件相关信息后,就要通过 handlebars
替换模板中的内容,生成或修改文件了。
在 /script/genNewComp
中新建一个 .template
目录,然后根据需要去建立新组件所需的所有文件的模板。在我们的框架中,一个组件的目录是这样的:
Foo
├── docs
│ ├── README.md
│ └── demo.vue
├── index.ts
└── src
└── index.vue
一共是4个文件,因此需要新建 index.ts.tpl
,index.vue.tpl
,README.md.tpl
和 demo.vue.tpl
。同时由于新组件需要一个新的路由,因此router.ts
也是需要一个对应的模板。由于篇幅关系就不全展示了,只挑最核心的 index.ts.tpl
来看看:
import { App, Plugin } from 'vue';
import {{ compName }} from './src/index.vue';
export const {{ compName }}Plugin: Plugin = {
install(app: App) {
app.component('ef-{{ compClassName }}', {{ compName }});
},
};
export {
{{ compName }},
};
位于双括号{{}}
中的内容最终会被 handlebars
所替换,比如我们已经得知一个新组件的信息如下:
{
"compName": "Button",
"compZhName": "按钮",
"compDesc": "这是一个按钮",
"compClassName": "button"
}
那么模板 index.ts.tpl
最终会被替换成这样:
import { App, Plugin } from 'vue';
import Button from './src/index.vue';
export const ButtonPlugin: Plugin = {
install(app: App) {
app.component('ef-button', Button);
},
};
export { Button };
模板替换的核心代码如下:
const fs = require('fs-extra')
const handlebars = require('handlebars')
const { resolve } = require('path')
const installTsTplReplacer = (listFileContent) => {
// 设置输入输出路径
const installFileFrom = './.template/install.ts.tpl'
const installFileTo = '../../packages/components/index.ts'
// 读取模板内容
const installFileTpl = fs.readFileSync(resolve(__dirname, installFileFrom), 'utf-8')
// 根据传入的信息构造数据
const installMeta = {
importPlugins: listFileContent.map(({ compName }) => `import { ${compName}Plugin } from './${compName}';`).join('\n'),
installPlugins: listFileContent.map(({ compName }) => `${compName}Plugin.install?.(app);`).join('\n '),
exportPlugins: listFileContent.map(({ compName }) => `export * from './${compName}'`).join('\n'),
}
// 使用 handlebars 替换模板内容
const installFileContent = handlebars.compile(installFileTpl, { noEscape: true })(installMeta)
// 渲染模板并输出至指定目录
fs.outputFile(resolve(__dirname, installFileTo), installFileContent, err => {
if (err) console.log(err)
})
}
上述代码中的 listFileContent
即为 ./packages/components/list.json
中的内容,这个 JSON 文件也是需要根据新组件而动态更新。
在完成了模板替换的相关逻辑后,就可以把它们都收归到一个可执行文件中了:
const infoCollector = require('./infoCollector')
const tplReplacer = require('./tplReplacer')
async function run() {
const meta = await infoCollector()
tplReplacer(meta)
}
run()
新增一个 npm script 到 package.json
:
{
"scripts": {
"gen": "node ./script/genNewComp/index.js"
},
}
接下来只要执行 yarn gen
就可以进入交互式终端,回答问题自动完成新建组件文件、修改配置的功能,并能够在可交互式文档中实时预览效果。
五、分开文档和库的构建逻辑
在默认的 Vite 配置中,执行 yarn build
所构建出来的产物是“可交互式文档网站”,并非“组件库”本身。为了构建一个 eastfair-ui
组件库并发布到 npm,我们需要将构建的逻辑分开。
在根目录下添加一个 /build
目录,依次写入 base.js
,lib.js
和 doc.js
,分别为基础配置、库配置和文档配置。
base.js
基础配置,需要确定路径别名、配置 Vue 插件和 Markdown 插件用于对应文件的解析。
import { resolve } from 'path';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import Markdown from 'vite-plugin-md';
// 文档: https://vitejs.dev/config/
export default defineConfig({
resolve: {
alias: {
'@': resolve(__dirname, './src'),
packages: resolve(__dirname, '../packages/components'),
},
},
plugins: [
vue({ include: [/\.vue$/, /\.md$/] }),
Markdown(),
],
});
lib.js
库构建,用于构建位于 ./packages/components
目录的组件库,同时需要 vite-plugin-dts
来帮助把一些 TS 声明文件给打包出来。
import baseConfig from './base.config';
import { defineConfig } from 'vite';
import { resolve } from 'path';
import dts from 'vite-plugin-dts';
export default defineConfig({
...baseConfig,
build: {
outDir: 'dist',
lib: {
entry: resolve(__dirname, '../packages/components/index.ts'),
name: 'eastfair-ui',
fileName: (format) => `ef-.${format}.js`,
},
rollupOptions: {
// 确保外部化处理那些你不想打包进库的依赖
external: ['vue'],
output: {
// 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量
globals: {
vue: 'Vue'
}
}
}
},
plugins: [
...baseConfig.plugins,
dts(),
]
});
doc.js
交互式文档构建配置,跟 base 是几乎一样的,只需要修改输出目录为 docs
即可。
import baseConfig from './vite.base.config';
import { defineConfig } from 'vite';
export default defineConfig({
...baseConfig,
build: {
outDir: 'docs',
},
});
还记得前文有提到的构建文档时需要把 ./packages/components
目录也一并复制到输出目录吗?亲测了好几个 Vite 的复制插件都不好使,干脆自己写一个:
const child_process = require('child_process');
const copyDir = (src, dist) => {
child_process.spawn('cp', ['-r', , src, dist]);
};
copyDir('../packages/components', './docs');
完成了上面这些构建配置以后,修改一下 npm script 即可:
"dev": "vite --config ./build/base.config.ts",
"build:lib": "vue-tsc --noEmit && vite build --config ./build/lib.config.ts",
"build:doc": "vue-tsc --noEmit && vite build --config ./build/doc.config.ts && node script/copyDir.js",
build:lib
的产物:
dist
├── ef-.es.js
├── ef-.umd.js
├── packages
│ ├── Button
│ │ ├── index.d.ts
│ │ └── src
│ │ └── index.vue.d.ts
│ ├── Foo
│ │ └── index.d.ts
│ └── index.d.ts
├── src
│ └── env.d.ts
└── style.css
build:doc
的产物:
docs
├── assets
│ ├── README.04f9b87a.js
│ ├── README.e8face78.js
│ ├── index.917a75eb.js
│ ├── index.f005ac77.css
│ └── vendor.234e3e3c.js
├── index.html
└── packages
完!
六、尾声
至此我们的组件开发框架已经基本完成了,它具备了相对完整的代码开发、实时交互式文档、命令式新建组件等能力,在它上面开发组件已经拥有了超级丝滑的体验。当然它距离完美还有很长的距离,比如说单元测试、E2E测试等也还没集成进去,组件库的版本管理和 CHANGELOG 还需要接入,这些不完美的部分都很值得补充进去。
七、异常处理
首次clone项目后 拉取依赖可能会出现以下异常
$ npm install
> npm ERR! notsup Unsupported platform for esbuild-android-arm64@0.13.15: wanted {"os":"android","arch":"arm64"} (current: {"os":"win32","arch":"x64"})
> npm ERR! notsup Valid OS: android
> npm ERR! notsup Valid Arch: arm64
> npm ERR! notsup Actual OS: win32
> npm ERR! notsup Actual Arch: x64
解决方案
$ npm cache --force clean # 清理缓存
$ npm install --force # 强制安装