当前位置:   article > 正文

vite + vue3 + storybook + ts 搭建vue组件库记录_storybook vue3

storybook vue3

目标

  • 只按需引入,不依赖babel-import-plugin 插件。
  • 第三方依赖都不打包。
  • 用原生fetch请求数据。
  • 仅支持esmodule。配置package.json type:"module"

搭建

根据storybook 官网文档,需要在已有的项目中运行 

npx storybook@latest init

 也就是事先需要通过vite创建一个项目。

npm create vite@latest

之后再运行storybook 的命令,storybook会自动分析使用的构建工具,安装好需要的依赖,并自动新增文件。

storybook 增加了/src/stories目录,用于存放组件。

默认情况下,/src/stories 中放着 .vue, .stories, .css 文件,由于我希望把组件源代码单独放在一个文件夹

目录预设

因此在项目根目录 增加 /packages 目录,下面存放以组件名称命名的文件夹。以每个文件夹为单位表示为一个组件。

/src/stories 中.stories 文件引用/packages 目录下的.vue 用于生成storybook演示文档。

对于vite 的普通打包模式下,入口必须为index.html,因此需要使用vite 支持的lib模式打包。

lib模式下,对于图片等静态资源,处理为base64内联到代码中。且不支持配置

base64的问题在于增大js体积,且不利于相同图片的复用。这块待研究。

构建vue组件

直接使用vite build 命令,会使用项目根目录下的vite.config.js 对指定的入口进行构建。构建产物默认输出到dist目录下。

lib模式下,构建产物有

  • index.js
  • style.css

对于一个vue组件来说,这种格式正式我想要的。

而/package 目录下面会有好多组件,因此需要借助vite 的JavsScript API 进行循环

vite JS API 构建 /packages 目录

根据官网描述,使用下面js代码, 可以通过js启动vite 打包。

  1. import { build } from 'vite';
  2. build({
  3. //... vite config
  4. })

这里的 build() 方法传入的config对象,会和项目下的 vite.config.js 合并

所以将一些公共的配置可以写在 vite.config.js下

vite.config.js

  1. import { defineConfig } from 'vite';
  2. import vue from '@vitejs/plugin-vue';
  3. import vueJsx from '@vitejs/plugin-vue-jsx';
  4. import dts from 'vite-plugin-dts';
  5. // https://vitejs.dev/config/
  6. export default defineConfig({
  7. build: {
  8. rollupOptions: {
  9. external: ['vue'], // 排除三方包
  10. },
  11. },
  12. plugins: [
  13. vue(),
  14. vueJsx(),
  15. dts({
  16. outputDir: './lib',
  17. entryRoot: './packages',
  18. }),
  19. ],
  20. });

 build.js 主要的构建方法

  1. /**
  2. * 构建一个组件
  3. * @param {string} compName 组件名称,对应packages目录下的文件夹名称
  4. */
  5. async function buildAComponent(compName) {
  6. const entry = path.join(packagesDir, compName, 'index.ts');
  7. const out = path.join(outDir, compName);
  8. await build({
  9. build: {
  10. outDir: out,
  11. lib: {
  12. entry: [entry],
  13. formats: ['es'],
  14. },
  15. },
  16. });
  17. }

 这个是构建一个组件的方法,因此,我只要通过node 的 fs 模块读取并分析/packages 路径下的文件夹,遍历出来构建即可。

build.js代码      

  1. import { readdirSync, writeFileSync } from 'fs';
  2. import path, { dirname } from 'path';
  3. import { fileURLToPath } from 'url';
  4. import { build } from 'vite';
  5. const __dirname = dirname(fileURLToPath(import.meta.url));
  6. const packagesDir = path.join(__dirname, './packages');
  7. const outDir = path.join(__dirname, './lib');
  8. async function main() {
  9. const packages = readdirSync(packagesDir);
  10. let indexFileContent = '';
  11. const exportNames = [];
  12. const promise = [];
  13. for (const folderName of packages) {
  14. // const stats = lstatSync(path.join(packagesDir, compName));
  15. // if (stats.isDirectory()) {
  16. // // 如果是目录,则构建
  17. // }
  18. const firstLetter = folderName[0];
  19. const isFirstLetterUpperCase = /[A-Z]/.test(firstLetter);
  20. if (isFirstLetterUpperCase && folderName.indexOf('.ts') === -1) {
  21. promise.push(buildAComponent(folderName));
  22. indexFileContent += `import { ${folderName} } from './${folderName}/index.js';\n`;
  23. exportNames.push(folderName);
  24. }
  25. }
  26. await Promise.all(promise);
  27. indexFileContent += `export { ${exportNames.join(', ')} };`;
  28. writeFileSync(path.join(outDir, 'index.js'), indexFileContent);
  29. console.log('created: lib/index.js');
  30. }
  31. main();
  32. /**
  33. * 构建一个组件
  34. * @param {string} compName 组件名称,对应packages目录下的文件夹名称
  35. */
  36. async function buildAComponent(compName) {
  37. const entry = path.join(packagesDir, compName, 'index.ts');
  38. const out = path.join(outDir, compName);
  39. await build({
  40. build: {
  41. outDir: out,
  42. lib: {
  43. entry: [entry],
  44. formats: ['es'],
  45. },
  46. },
  47. });
  48. }

 main 函数中判断文件夹名称前面是大写字母开头的才当作组件来处理。

构建结果

  • /package/Button
    • index.ts
    • style.less
    • Button.vue

这一个目录入口为index.ts 构建完成后,输出到lib

  • /lib/Button
    • index.js
    • style.css

至此,主要构建完成了

库入口

由于一个npm库需要有一个默认的入口js,因此需要在/lib 目录下面增加一个index.js 用于导入lib 下面所有组件,并导出。这部分代码在上面 build.js 中已经体现。 

/lib/index.js

export { Button } from './Button/index.ts';

package.json 增加配置

  1. {
  2. "main": "lib/index.js",
  3. "module": "lib/index.js",
  4. "files": [
  5. "lib",
  6. "packages"
  7. ],
  8. //...
  9. }

d.ts生成

使用vite-plugin-dts 插件

  1. import dts from 'vite-plugin-dts';
  2. // ...
  3. plugins:[
  4. // vue(),
  5. // vueJsx(),
  6. dts({
  7. outputDir: './lib',
  8. entryRoot: './packages',
  9. }),
  10. ]

构建dts速度有点慢 。

这样配置,会在lib/Button/ 下面增加d.ts 文件了。

与库的默认入口文件一样,需要指定默认declare 文件,package.json types 指定为 /packages/main.ts。(这里暂时指定.ts 文件,可能在一些构建工具中不识别,因为那些工具只识别d.ts 文件)

实现自动引入

一个组件默认的构建结果为index.js, style.css

鉴于使用的情况下引入 import { Button } from 'xxx'; 之外还要 import 'xxx/lib/Button/style.css';

使用babel-import-plugin 可以解决按需引入样式问题。

由于我的目标是不使用babel-import-pluing 因此,完成这个效果,只需要在/lib/Button/index.js 文件的最上方引入/lib/Button/style.css即可。

方案

  • 使用node fs读写文件。
  • 借助vite plugin

使用vite plugin 实现

因为不想用node

由于vite基于rollup,根据rollup 官网文档,我们可以在插件的 generateBundle 钩子中在生成产物前操作文件内容。

代码

  1. /**
  2. * 在产物js上导入css
  3. */
  4. function () {
  5. return {
  6. name: 'auto-import-style',
  7. generateBundle(options, bundle) {
  8. bundle['index.js'].code = 'import "./style.css";\n' + bundle['index.js'].code;
  9. },
  10. };
  11. },

npm上也有类似的插件 vite-plugin-libcss

构建vue组件要点

不能将vue依赖打包进代码中,否则会导致组件无法使用。

打包组件的产物js中,最上方应该为 import {} from 'vue'; 这样的代码。

通过配置vite.config.js 下的rollupOptions.external即可:

  1. build: {
  2. rollupOptions: {
  3. external: ['vue'],
  4. // output: {
  5. // globals: {
  6. // vue: 'Vue', // umd需要
  7. // },
  8. // },
  9. },
  10. },

 这里注释掉rollupOptions.output.globals 原因是默认构建lib会输入umd 和 es 模块的文件。我构建时会指定 build.lib.formats: ['es'] 就不需要了。

增量构建方案

由于现在我看使用vite-plugin-dts 生成d.ts 文件使用的时间过长。先制定一下增量构建的方案。

鉴于在正常开发过程中,始终用master分支作为正式分支。

因此在开发分支中,可以借助git 来对比当前开发分支与master分支的区别

使用node 的child_process 执行git命令

git diff origin/master --name-only

可检查当前分支,对比master,哪些文件有变化。

比如我更改了Button.vue文件,此git控制台输出:

packages/Button/Button.vue

得到控制台输出后,可以确定package/Button 组件有变动,只需要构建package/Button/index 即可。

以此仅构建变更过的组件。

方案存在问题

在本组件库中,存在组件间相互引用的情况。

这种情况导致下面的问题

1.增量构建

Button组件修改,增量构建不会重新构建依赖的组件A.vue,B.vue

2.重复构建

Button.vue 的代码会加入到其他组件的产物中,导致重复代码,体积增大。

解决方案目标

组件间的依赖不构建。A.vue 中import 的 Button.vue 转换成成../lib/Button/index.js,也就是Button的构建产物。

方案如何实现

vite/rollup 是否支持配置

有rollupOptions.external 可以配置 '../Button' 不打包。

存在问题
  • 缺少灵活性。比如 ‘../../Button’又得重新配置。
  • 如果有一个同名组件被依赖,会被误处理。
解决方向1

如果用别名的方式是否可行。类似一般vue项目中 '@' 表示'src' 一样,将内部依赖的组件都用别名如 '@pkg/Button' 引入。

虽然可行,但是增加心智负担,且一旦有一个没加路径别名,则功亏一篑。

解决方向2

rollupOptions.external 支持传入函数。是否可以在函数内判断导入的组件属于package/ 一级目录下的组件。待续

通过plugin接口

是否存在钩子使扫描到的依赖不处理。并且支持替换导入语句。


增量构建方案(2023.11.22 更新)

通过rollupOptions.external,该字段支持传入回调函数。

  1. rollupOptions: {
  2. external(source,importer,isResolved){
  3. if(!isResolved){
  4. return ['vue'].includes(source);
  5. }else if(importer){
  6. return /[\\/]packages[\\/][A-Z]\w+[\\/]index.[jt]s/;
  7. }
  8. }
  9. }

isResolved 表示导入source 是否被转化为绝对路径。

这样通过正则表达式可以确定一些import语句不需要转换。

二次处理import语句

由于使用external讲import '' from './xxx.ts',语句不处理,会将语句原封不动输出到产物中。而产物中不能有 .ts 后缀的引入。因此需要将ts 后缀转换为js后缀。

同自动导入css一样。我们在rollupPlugin 的generateBundle 钩子中,在产物文件生成前,读取编译后的代码字符串,正则匹配出 import 语句后面的 .ts 并转换为 .js 即可。

  1. /**
  2. * 在产物js上导入css
  3. */
  4. function () {
  5. return {
  6. name: 'auto-import-style',
  7. generateBundle(options, bundle) {
  8. bundle['index.js'].code = 'import "./style.css";\n' + bundle['index.js'].code;
  9. // new
  10. bundle['index.js'].code = bundle['index'].code.replace(\.\.[\\/]\w+[\\/]index.ts/g, str => str.replace('.ts','.js'));
  11. },
  12. };
  13. },

防止自引用

比如 package/Button/A 引入了 package/Button/index 

此时会报错:

"xxx" is imported as an external by "xxx", but is already an existing non-external module id

说明引入的文件已经配置external了。此时应该调整rollupOptions.external 的计算规则,保证同组件间引入文件不被external

规定组件间引用

/packages 目录下如果组件之间相互引用,则必须要引入其组件的 index.ts 文件。

否则在rolluptOption.external 中判断不是引的index 则直接抛错误,中断构建。

因为构建完的产物只有一个index.js,引入其他的会报错找不到。

  1. const packagesIndexReg = /[\\/]packages[\\/][A-Z]\w+[\\/]index.[jt]s/;
  2. const packagesReg = /[\\/]packages[\\/][A-Z]\w+[\\/]/
  3. rollupOptions: {
  4. external(source,importer,isResolved){
  5. if(!isResolved){
  6. return ['vue'].includes(source);
  7. }else if(importer){
  8. const sourceName = source.match(packagesReg);
  9. const isPackageIndex = packagesIndexReg.test(source);
  10. if(sourceName){
  11. const importerName = importer.match(packagesReg)
  12. const isSamePackage = sourceName[0] === importerName[0];
  13. const isSourceImporterIndex = packagesIndexReg.test(source);
  14. if(!isSourceImporterIndex && !isSamePackage){
  15. throw new Error('报错组件间引用请引入index.ts');// 直接中断构建
  16. }
  17. // 防止组件自引用index导致的问题
  18. return !isSamePackage;
  19. }
  20. return isPackageIndex;
  21. }
  22. }
  23. }

 通过正则来确定是否组件间引用,和组件内引用。

国际化配置

使用 vue-i18n@9.6.5 做国际化,由于我不想在组件库内部打包进vue-i18n 因此这里需要定义一下方按。

方案

使用项目的vue-i18n。

组件库提供方法在项目中通过app.use进行安装,并将项目的i18n传进来。

中文作为key基本不用翻译。因为$t("中文") 在获取不到翻译时,会输出$t 的入参字符串("中文")。

locale.js

  1. import * as cn from './cn/index.js'
  2. import * as en from './en/index.js'
  3. const langs= {en,cn}
  4. /**
  5. * @param {import('vue').App} app
  6. * @param {object} options
  7. * @param {import('vue-i18n').I18n} options.i18n 项目重传入的i18n
  8. */
  9. export default function (app, options) {
  10. options.i18n.global.mergeLocaleMessage('en',en);
  11. options.i18n.global.mergeLocaleMessage('cn',cn);
  12. }

项目中使用

  1. import locale from 'my-component/locale';
  2. import { createI18n } from 'vue-i18n';
  3. import { createApp } from 'vue';
  4. const app = createApp();
  5. const i18n = createI18n();
  6. app.use(i18n).use(locale, {i18n});

通过项目中的use 将项目的i18n实例传入组件库。

通过i18n.global.mergeLocaleMessage 方法,将组件库内的翻译合并到项目的i18n 中。

这里可能回污染项目中i18n 的命名空间。比如项目中 $(‘a’) 的翻译是’b', 但是组件库中的翻译是'c',这样回导致覆盖项目中的国际化。因此需要做隔离。

命名空间隔离

这里使用最简单的隔离,就是组件库中的翻译统一带一个固定的前缀,比如"myComp"。

正式弄的话前缀需要设置奇怪点。比如 “__comp__” 以确保不与项目命名空间冲突。

  1. import * as cn from './cn/index.js'
  2. import * as en from './en/index.js'
  3. /**
  4. * @param {import('vue').App} app
  5. * @param {object} options
  6. * @param {import('vue-i18n').I18n} options.i18n 项目重传入的i18n
  7. */
  8. export default function (app, options) {
  9. options.i18n.global.mergeLocaleMessage('cn', { myComp: { cn } });
  10. options.i18n.global.mergeLocaleMessage('en', { myComp: { en } });
  11. }

此时组件库中使用$t 需要加上前缀。$t('myComp.xxx');

中文key问题

将所有$t 加上前缀会导致中文输出会带上前缀。页面上会展示 “myComp.中文”

因此需要将前缀去除。

i18n 提供了找不到翻译时的回调。i18n.global.setMIssingHandler

  1. //locale
  2. export default function(app, options){
  3. //...
  4. if (options.global.setMissingHandler){
  5. options.global.setMissingHandler((lang, key) => {
  6. return key.replace('myComp', '');
  7. });
  8. } else if(options.i18n.global.hasOwnProperty('missing')){
  9. // i18n 替换api兼容,作者在某些版本中变成了这个
  10. options.i18n.global.missing = (lang, key) => {
  11. return key.replace('myComp', '');
  12. }
  13. }
  14. }

此时就可以去除前缀了。

未注册i18n时的处理

实际使用中,项目中并不使用vue-i18n 来翻译。因此app.use 中不会传递i18n。

这种情况需要自行处理。

重写app.config.globalProperties.$t

  1. /**
  2. * @param {import('vue').App} app
  3. */
  4. function fallbackToCN(app) {
  5. const cnMessage = cn.myComp;
  6. /** 保留旧t */
  7. const t = app.config.globalProperties.$t || (v => v)
  8. app.config.globalProperties.$t = str => {
  9. const keyChain = str.split('.');
  10. if (keyChain[0] !== 'myComp') return t(str);
  11. let value = cnMessage;
  12. for (let k of keyChain.slice(1)) {
  13. const next = value[k];
  14. if (!next) return k;
  15. value = next;
  16. }
  17. return value;
  18. }
  19. }

这样简单支持了拿不到翻译时,去取$t 入参的最后一个key;

存在的问题

  • 不支持i18n的模板语法。$t("hi {msg}", {msg: 'haha'});
  • 也没有兼容除 $t 外 的其他函数如 $e,等。

国际化小结

考虑优秀的组件不应当在代码中写死翻译,且翻译使用量较少。因此强制只使用 $t 一个函数也无可厚非。

。。。待续。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/小小林熬夜学编程/article/detail/379286
推荐阅读