嗨~那个少年,快来做一个跨vue2和vue3的组件库

Source

       前端时间,由于公司业务广度深度的拓展以及想要对之前网站的布局进行革新,洋洋洒洒的进行了多次的UI改版,致使很多前端同学是苦不堪言,但是总体来说最后的结果是好的,也得到了相应的好评。但是随着改版的范围越来越广,涉及的项目越来越多,面临的挑战也是越来越大。这个时候我们迫切的需要一个组件库来承接所有的设计规范UI物料库和支撑公司各种形形色色项目的后续的改版(ps: 其实就是手痒,想做会死,搞出点动静来,颇有心计)。
       于是罗列了公司目前项目的现状,还好技术栈还是比较统一用的vue,但是问题在于版本幅度有点大,我一听心中窃喜,活不就来了吗?不跨版本的活我还不干呢。小伙子,有点狂啊,好了,铺(废)垫(话)就先到这里了,直接开干。
       想到跨版本,一定就会想起antfu大神的vue-demi,没错后续就是基于这个包来开展一系列的动作,这个时候有朋友就会问道为什么不用vue-component去实现,且听我徐徐道来。

基于vue-demi的几种方案

  1. 直接上最原始的组件,依赖宿主项目的编译能力。(pass,这也太耍无赖了)
  2. 判断当前版本,利用jsx以及render等去动态替换逻辑。(我就是只想用个vue3而已,你给我捆绑vue2)
  3. 构建时走多份配置(vue 2、vue2.7、vue3),构建多版本产物。

显而易见,我采取了第三种,当然其实也是都可以,(ps:又不是不能用)。
确定了后续的方向,那么我们就需要去分析选择哪个版本做主版本的问题,由于我是革新派毫无疑问选择了vue3作为主版本,以及再次借助了antfu大神写的unplugin-vue2-script-setup,(ps:感谢祖师爷赏饭吃),直接上了setup语法,小孩子才做选择,而我全都要。

依赖冲突

       这个时候就会有朋友就会问了,这么版本这么多依赖杂糅在一个项目不会有冲突吗?不得不说这个朋友你真聪明,还真让你说对了,不过庆幸的是我们目前只有vue2vue3的项目,而2和3里面并没有太多的依赖交集,所以哈哈哈,完美避免。想知道vue2,vue2.7,vue3如何共存的同学请听后续我娓娓道来。
       说是没有依赖交集,但还是会存在一个冲突,那就是执着的我是以vue3作为主版本,那么vue2专用的vue-template-compiler在读取版本的时候可能会出错,我们来打开它的源码看下它如何来引入vue的。

try {
    
      
var vueVersion = require('vue').version
} catch (e) {
    
      }

var packageName = require('./package.json').name
var packageVersion = require('./package.json').version
if (vueVersion && vueVersion !== packageVersion) {
    
      
var vuePath = require.resolve('vue')
var packagePath = require.resolve('./package.json')
throw new Error(
  '\n\nVue packages version mismatch:\n\n' +
  '- vue@' + vueVersion + ' (' + vuePath + ')\n' +
  '- ' + packageName + '@' + packageVersion + ' (' + packagePath + ')\n\n' +
  'This may cause things to work incorrectly. Make sure to use the same version for both.\n' +
  'If you are using vue-loader@>=10.0, simply update vue-template-compiler.\n' +
  'If you are using vue-loader@<10.0 or vueify, re-installing vue-loader/vueify should bump ' + packageName + ' to the latest.\n'
)
}

module.exports = require('./build')

喔嚯,一看源码那么事情就大了肯定是会读错的,难道我们就这样放弃了吗?不,点上一首孤勇者,谁说污泥满身的不算英雄,就在情绪的在高点时,脑海中涌现了一个库patch-package,救星来了,我们可以在安装依赖的时候利用postinstall钩子,覆盖它的源码强制指定vue的版本。顿时一顿操作猛如虎,却发现库太老了,已经不兼容pnpm安装的依赖结构了(不过听小道消息最近作者又‘活’过来了,准备更新下去适配pnpm这些新兴势力),有兴趣的同学可以持续关注下。
       其实原理我们是知道的,那就自己写个脚本动态替换下吧,虽然做不到patch-package那么高级,好歹能用(ps:又不是不能用)。直接上代码,多的就不提了。

import {
    
       getPackageInfo, resolveModule } from "local-pkg";
import fs from "node:fs/promises";

const patch = async () => {
    
      
 const {
    
       name, version, rootPath } = await getPackageInfo("vue-template-compiler");
 console.log(`检测到当前${
      
        name}版本为${
      
        version}`);
 const packagePath = await resolveModule("vue-template-compiler");

 const content = await fs.readFile(packagePath, "utf8");

 try {
    
      
   await fs.writeFile(
     packagePath,
     content.replace(
       "require('vue').version",
       `require('vue@${
      
        version}').version`
     )
   );
   console.log(`vue@${
      
        version}版本写入成功`);
   console.log('写入路径:', rootPath);
 } catch (e) {
    
      
   console.log(`vue@${
      
        version}版本写入失败,请检测包脚本是否需要更新`);
 }
};

patch();

完活,前期的冲突问题到这里就完结了。

构建工具

       接下来就是选构建工具了,鉴于小本生意,没啥研发经费,那就选vite吧,开箱即用。整体的思路就是借助vue-demi提供的vue-demi-switch命令,在构建的时候动态的切换vue的版本,并根据当前vue的版本按需去注册相关构建依赖。核心代码大概就如下部分:

// package.json
scripts": {
    
      
  "switch:v3": "vue-demi-switch 3 vue3",
  "switch:v2": "vue-demi-switch 2 vue2",
 }
// build.ts
import {
    
       build } from "vite";
import glob from "glob";
import {
    
       isVue3, version } from "vue-demi";
import dts from "vite-plugin-dts";
import {
    
       resolve, workRoot, getVuePlugins } from "./utils";

export const start = async () => {
    
      
console.log("当前vue版本", version);
const name = isVue3 ? "vue3" : "vue2";

const vuePlugins: any[] = await getVuePlugins();

glob("src/components/**/**.{vue,ts,js}", {
    
      
  cwd: process.cwd(),
  absolute: true,
  onlyFiles: true,
}, async (err, files) => {
    
      
  if (err) return;
  files.forEach(async (file) => {
    
      
    const plugins = [...vuePlugins,];

    plugins.push(
      dts({
    
      
        entryRoot: `${
      
        workRoot}/src/components`,
        outputDir: [resolve(`./dist/${
      
        name}/es`), resolve(`./dist/${
      
        name}/cjs`)],
        exclude: ['src/vite-env.d.ts'],
        cleanVueFileName: true,
        staticImport: true,
        compilerOptions: isVue3
          ? {
    
      }
          : {
    
      
            baseUrl: ".",
            paths: {
    
      
              vue: ["node_modules/vue2"],
              "vue/*": ["node_modules/vue2/*"],
              "@vue/composition-api": ["node_modules/@vue/composition-api"],
              "@vue/runtime-dom": ["node_modules/@vue/runtime-dom"],
            },
          },
      })
    );
    await build({
    
      
      plugins,
      resolve: {
    
      
        alias: isVue3
          ? {
    
      }
          : {
    
      
            vue: resolve("./node_modules/vue2"),
            "@vue/composition-api": resolve(
              "./node_modules/@vue/composition-api"
            ),
          },
      },
      build: {
    
      
        assetsDir: resolve(`./dist/${
      
        name}/es/`),
        emptyOutDir: false,
        minify: 'esbuild',
        sourcemap: true,
        lib: {
    
      
          entry: resolve(file),
          name: "vue-ui"
        },
        rollupOptions: {
    
      
          external: ["vue", "vue-demi"],
          output: [
            {
    
      
              format: "es",
              dir: resolve(`./dist/${
      
        name}/es`),
              preserveModules: true,
              preserveModulesRoot: `${
      
        workRoot}/src/components`,
              entryFileNames: `[name].mjs`,
            },
            {
    
      
              format: "cjs",
              dir: resolve(`./dist/${
      
        name}/cjs`),
              preserveModules: true,
              preserveModulesRoot: `${
      
        workRoot}/src/components`,
              exports: "named",
              entryFileNames: `[name].js`
            },
          ],
        },
      },
    });
  });
});
};

start();

到这里我们的组件构建过程应该是没啥太大的问题了,这个时候又有聪明的同学会问:你这组件生成了,能不能再生成相关的ts类型文件。好的,老板没问题,这个时候得请出vite-plugin-dts,重新构建一遍发现还好,能用。想更加细致的去定制化ts类型文件生成的同学可以去了解下ts-morph

样式构建

       上面只能构建了组件,但是样式应该咋办呢?脑海中模拟了无数场景,最终我们还是借鉴下element-plus,直接用gulp基于文件流去简单的构建一下。大致思路如下:

// gulpfile.ts
import path from 'path'
import chalk from 'chalk'
import {
    
       dest, parallel, series, src } from 'gulp'
import gulpSass from 'gulp-sass'
import dartSass from 'sass'
import autoprefixer from 'gulp-autoprefixer'
import cleanCSS from 'gulp-clean-css'
import rename from 'gulp-rename'
import consola from 'consola'

const distFolder = path.resolve(__dirname, 'style')

function buildThemeChalk() {
    
      
 const sass = gulpSass(dartSass)
 const noElPrefixFile = /(index|base|display)/
 return src(path.resolve(__dirname, 'src/components/**/*.scss'))
   .pipe(sass.sync())
   .pipe(autoprefixer({
    
       cascade: false }))
   .pipe(
     cleanCSS({
    
      }, (details) => {
    
      
       consola.success(
         `${
      
        chalk.cyan(details.name)}: ${
      
        chalk.yellow(
           details.stats.originalSize / 1000
         )} KB -> ${
      
        chalk.green(details.stats.minifiedSize / 1000)} KB`
       )
     })
   )
   .pipe(
     rename((path) => {
    
      
       if (!noElPrefixFile.test(path.basename)) {
    
      
         path.basename = `vue-${
      
        path.basename}`
       }
     })
   )
   .pipe(dest(distFolder))
}

export const build: any = parallel(buildThemeChalk)

export default build

本地开发

由于基于vite,那么本地开就很舒服了,拉一个vite模板文件,配置一下vite.config.ts就行了。不得不说这个经费一下子省到位了,舒服。

// vite.config.ts
import {
    
       defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import {
    
       resolve } from './build/utils';
import {
    
       isVue3 } from 'vue-demi';
import {
    
       createVuePlugin } from 'vite-plugin-vue2';
import setUp from 'unplugin-vue2-script-setup/vite';

// https://vitejs.dev/config/
export default defineConfig(process.env.NODE_ENV === 'development' ? {
    
      
  plugins: isVue3 ? [vue()] : [createVuePlugin(), setUp()],
  resolve: {
    
      
    alias: {
    
      
      '@': resolve("./src"),
      '@component': resolve("./src/components"),
      vue: resolve(`./node_modules/vue${
      
         isVue3 ? 3 : 2}`)
    }
  },
} : {
    
      });

我们在写组件的时候可以先切vue3的跑起来,再切vue2的看下兼容性问题,具体main.ts可以这样写:

import {
    
       createApp, version, Vue2, isVue3 } from 'vue-demi'
import App from './App.vue'

/** 自动搜索全局样式进行引入 **/
const styles = import.meta.glob('./**/*.scss');

Object.keys(styles).forEach((k) => {
    
      
    console.log('注入css:', k);
    styles[k]()
})

console.log('当前vue版本:', version);

if (isVue3) {
    
      
    createApp(App).mount('#app')
} else {
    
      
    new Vue2({
    
      
        render: h => h(App as any)
    }).$mount("#app")
}

自动引入

有不少同学会说,人家都已经开上宝马了,你搁这骑自行车。好的,收到,问题不大,那就让我们来扒一下unplugin-vue-components的源码,多的地方不用细看,只需要找到相关的resolver就行,照着写就行。当然你可以向这个库提交你的resolver,不过一般情况下demo是不会通过的,你可以自己重新构建一个自用的npm包就行,列如:unplugin-component-resolvers
另外一些pnpm,postinstall,还有用到一些包就不在这里赘述了。

搞定完事,这个季度的kpi有了。

最后献上demo地址,没错就是它,给我狠狠的点它。

哦哦,忘了填上面挖的两个坑了。对于web-component可以阅读下:
https://web.dev/declarative-shadow-dom/#styling
https://css-tricks.com/using-web-components-with-next-or-any-ssr-framework/
目前在ssr方面还比较薄弱,可以去国外的论坛或者推特上面看看后续对这块的规划,很多优化的案例还在起草中,就先不作死,让大佬们先走。

对于想要做到兼容vue2,vue2.7和vue3,就需要做相应的依赖隔离,因为vue2和vue2.7,vue2.7和vue3都有共同相交的依赖只是版本不同,比如可以分两个仓库去构建等去做隔离。

好了,最后让我们一起加油,去做一个天选打工人吧!!!