基于vue-demi,支持sfc方式的vue2/3通用库开发指南
背景
随着vue3的逐渐成熟,公司项目逐渐会存在vue2和vue3项目共存的情况,兼容vue2和vue3的公共组件开发能让老项目较好地过渡到vue3。研究了vue-demi的源码和demo,发现vue-demi只是简单地根据vue版本生成对应的类似中间件的东西,而且render函数也只是做了简单的中转处理;
国外大佬写了一个h-demi解决了vue2/vue3的render函数attrs属性的问题,这里我就直接贴issue链接,不做过多说明了: github.com/vueuse/vue-…
虽然vue-demi没有提供sfc的兼容方案,但是其实仔细想一下,sfc的解析处理也不应该是由vue-demi来解决,应该是交给打包工具将template转成render,而vue-demi只需要关注composition-api就行;于是往着这个思路,花了几天时间研究一下vue2.6、vue2.7和vue3的sfc-compiler,得到以下开发方案。
技术要点
vue-demi
查看源码可以发现,vue-demi的工作是通过postinstall和 npx vue-demi-fix指令,判断当前项目安装的vue版本,然后将对应版本的插件复制到lib的根目录,其插件的功能就是抹平vue2和vue3版本使用composition-api时的差异;
<=2.6
: exports fromvue
+@vue/composition-api
with plugin auto installing.2.7
: exports fromvue
(Composition API is built-in in Vue 2.7).>=3.0
: exports fromvue
, with polyfill of Vue 2'sset
anddel
API.
sfc compiler
在日常开发中写的vue template,实际上最后是通过sfc-compiler转成render函数输出的,而vue2和vue3的sfc-compiler是互不兼容的。尤大大已经提供了vue2.6.x,vue2.7和vue3的compiler,其实我们只需要在打包工具写判断不同的vue版本使用不同的compiler逻辑即可,本文是基于vite开发,以下对应的打包插件:
- vue2.6:
vite-plugin-vue2@2.6.14
+vue-template-compiler@2.6.14
- vue2.7:
vite-plugin-vue2@2.7.9
+vue-template-compiler@2.7.9
; 或者@vitejs/plugin-vue2
+@vue/compiler-sfc
- vue3:
@vitejs/plugin-vue
+@vue/compiler-sfc
实现方式
以下实现方式均是基于vite开发,换成webpack和rollup原理上也是替换对应的插件即可。
vue2.6 + vue3 + vite + vue-demi
以vue2.6为主包,开发vue2/vue3组件,该方式能做到通过一个package.json的scripts同时调试和打包vue2、vue3环境,以下讲一下重点;
package.json
package.json中的vue包是固定了2.6.14版本,这里要注意vue-template-compiler要和vue的版本对齐;
//package.json部分片段
"main": "./lib/vue-demi-sfc-component.umd.cjs",
"exports": {
".": {
"import": "./lib/vue-demi-sfc-component.js",
"require": "./lib/vue-demi-sfc-component.umd.cjs"
}
},
"scripts": {
"postinstall": "node ./scripts/postinstall.mjs",
"dev": "vite",
"dev:3": "npm run switch:3 && vite --force",
"dev:2": "npm run switch:2 && vite",
"switch:2": "npx vue-demi-switch 2 vue2",
"switch:3": "npx vue-demi-switch 3 vue3",
"build:3": "npm run switch:3 && vue-tsc --noEmit && vite build",
"build:2": "npm run switch:2 && vue-tsc --noEmit && vite build",
"build": "rimraf lib && npm run build:2 && npm run build:3",
"preview": "vite preview",
"lint:fix": "eslint . --ext .js,.ts,.vue --fix",
"prepare": "husky install",
"pub": "npm publish --access=public"
},
"dependencies": {
"@vue/composition-api": "^1.7.0",
"vue-demi": "^0.13.8"
},
"peerDependencies": {
"@vue/composition-api": "^1.7.0",
"vue": "^2.0.0 || >=3.0.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
},
"peerDependencies": {
"@vue/composition-api": "^1.7.0",
"vue": "^2.0.0 || >=3.0.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
},
"devDependencies": {
// ...其他依赖,这里就不复制了
"@vitejs/plugin-vue": "^3.0.3",
"vite": "^3.0.7",
"vite-plugin-vue2": "^2.0.2",
"vue": "2.6.14",
"vue-eslint-parser": "^9.0.3",
"vue-template-compiler": "2.6.14",
"vue-tsc": "^0.39.5",
"vue2": "npm:vue@2.6.14",
"vue3": "npm:vue@^3.2.36"
}
vite.config.ts
import { defineConfig } from 'vite'
import { createVuePlugin } from 'vite-plugin-vue2'
import * as compiler from '@vue/compiler-sfc'
import vue3 from '@vitejs/plugin-vue'
import path from 'path'
import { getLibDir } from './scripts/utils.mjs'
import { isVue2, version } from 'vue-demi'
console.log({ version })
const resolve = (str: string) => {
return path.resolve(__dirname, str)
}
// https://vitejs.dev/config/
export default defineConfig({
resolve: {
alias: {
'@': resolve('src'),
vue: isVue2 ? resolve('./node_modules/vue2') : resolve('./node_modules/vue3'),
'@vue/composition-api': resolve('./node_modules/@vue/composition-api')
}
},
build: {
lib: {
entry: resolve('./src/components/index.ts'),
name: 'vueDemiSfcComponent',
fileName: 'vue-demi-sfc-component'
},
cssTarget: 'chrome61',
rollupOptions: {
external: ['vue-demi', 'vue'],
output: {
dir: getLibDir(version),
globals: {
vue: 'Vue',
'vue-demi': 'VueDemi'
}
}
}
},
optimizeDeps: {
exclude: ['vue-demi']
},
plugins: [
isVue2
? createVuePlugin()
: vue3({
compiler: compiler
})
]
})
这个文件有几个关键逻辑:
1、使用vue-demi的isVue2来判断当前打包环境
import { isVue2, version } from 'vue-demi'
2、alias要根据环境切换地址
alias: {
'@': resolve('src'),
vue: isVue2 ? resolve('/node_modules/vue2') : resolve('/node_modules/vue3')
}
3、在以vue2.6为主包的时候,如果直接使用@vitejs/plugin-vue
, 打包时会报错
error when starting dev server:
Error: Failed to resolve vue/compiler-sfc.
@vitejs/plugin-vue requires vue (>=3.2.25) to be present in the dependency tree.
这是因为@vitejs/plugin-vue
源码中是直接找vue/compiler-sfc目录的,如果以vue2为主包,这个时候nod_modules/vue是vue2的目录结构,并没有vue/compiler-sfc;
function resolveCompiler(root) {
const compiler = tryRequire("vue/compiler-sfc", root) || tryRequire("vue/compiler-sfc");
if (!compiler) {
throw new Error(
`Failed to resolve vue/compiler-sfc.
@vitejs/plugin-vue requires vue (>=3.2.25) to be present in the dependency tree.`
);
}
return compiler;
}
所以就去寻找一下@vitejs/plugin-vue
的options
interface Options {
include?: string | RegExp | (string | RegExp)[];
exclude?: string | RegExp | (string | RegExp)[];
isProduction?: boolean;
script?: Partial<Pick<SFCScriptCompileOptions, 'babelParserPlugins'>>;
template?: Partial<Pick<SFCTemplateCompileOptions, 'compiler' | 'compilerOptions' | 'preprocessOptions' | 'preprocessCustomRequire' | 'transformAssetUrls'>>;
style?: Partial<Pick<SFCStyleCompileOptions, 'trim'>>;
/**
* Transform Vue SFCs into custom elements.
* - `true`: all `*.vue` imports are converted into custom elements
* - `string | RegExp`: matched files are converted into custom elements
*
* @default /\.ce\.vue$/
*/
customElement?: boolean | string | RegExp | (string | RegExp)[];
/**
* Enable Vue reactivity transform (experimental).
* https://github.com/vuejs/core/tree/master/packages/reactivity-transform
* - `true`: transform will be enabled for all vue,js(x),ts(x) files except
* those inside node_modules
* - `string | RegExp`: apply to vue + only matched files (will include
* node_modules, so specify directories in necessary)
* - `false`: disable in all cases
*
* @default false
*/
reactivityTransform?: boolean | string | RegExp | (string | RegExp)[];
/**
* Use custom compiler-sfc instance. Can be used to force a specific version.
*/
compiler?: typeof _compiler;
}
发现option中是有自定义compiler-sfc的参数,于是就得到以下方案:
// vite.config.ts
import * as compiler from '@vue/compiler-sfc'
export default defineConfig({
// ...
plugins: [
isVue2
? createVuePlugin()
: vue3({
compiler: compiler
})
]
})
main.ts
main.ts需要判断isVue2后,区分vue2和vue3的依赖
import { isVue2 } from 'vue-demi'
import { createApp } from 'vue3'
import { createApp as Vue2 } from '@vue/composition-api'
import './style.css'
import App from './App.vue'
if (isVue2) {
const app = Vue2(App)
app.mount('#app')
} else {
const app = createApp(App)
app.mount('#app')
}
postinstall
这里是模仿vue-demi的原理,在安装时利用postinstall钩子执行node脚本,复制lib中的v2/v3目录,具体可直接看文章最后的项目链接;这里有一个地方要注意,由于我是使用vite + ts 构建的项目,package.json中的"type": "module"
需要我把所有js改成mjs文件,这个时候,其他项目安装这个项目时,会找不到 __dirname
,因此utils.mjs加了以下逻辑。
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
vue2.7 + vue3 + vite + vue-demi + yarn workspaces
以vue2.7为主包开发时,没办法像vue2.6可以在一个package.json项目下调试和打包,主要是因为vue2.7的代码方式已经是monorepo项目,因此在安装vue2.7的时候,会重新下载@vue/compuler-sfc
的2.7.x版本。
所以没办法直接使用@vue/compiler-sfc
包作为vue3的compiler;
那么我们就要换一个思路,做node_modules隔离,而node_modules隔离的方案现在主流的就是yarn workspaces、lerna和pnpm,这里我就以yarn workspaces来简单讲一下思路;(ps: 该方式我并没有上传到github)
开启yarn workspaces之后,新建packages文件夹
然后再packages下分别新建v2和v3目录,这两个目录存放对应vue2和vue3的package.json和vite.config.ts
// v2/package.json
"scripts": {
"dev": "vite",
"build": "rimraf ../../lib/v2 && vue-tsc --noEmit && vite build",
"preview": "vite preview",
"lint:fix": "eslint . --ext .js,.ts,.vue --fix",
"prepare": "husky install",
"pub": "npm publish --access=public"
},
"devDependencies": {
"@vitejs/plugin-vue2": "^2.7.9",
"vite": "^3.0.7",
"vite-plugin-vue2": "^2.0.2",
"vue": "2.7.9",
"vue-eslint-parser": "^9.0.3",
"vue-template-compiler": "2.7.9",
"vue-tsc": "^0.39.5",
"vue2": "npm:vue@2.7.9",
"vue3": "npm:vue@^3.2.36"
}
// v3/package.json
"scripts": {
"dev": "vite",
"build": "rimraf ../../lib/v3 && vue-tsc --noEmit && vite build",
"preview": "vite preview",
"lint:fix": "eslint . --ext .js,.ts,.vue --fix",
"prepare": "husky install",
"pub": "npm publish --access=public"
},
"devDependencies": {
"@vitejs/plugin-vue": "^3.0.3",
"vite": "^3.0.7",
"vite-plugin-vue2": "^2.0.2",
"vue": "3.2.26",
"vue-eslint-parser": "^9.0.3",
"vue-template-compiler": "2.6.14",
"vue-tsc": "^0.39.5",
"vue2": "npm:vue@2.6.14",
"vue3": "npm:vue@^3.2.26"
}
vite.config.ts的区别主要是 rollupOptions.output.dir,和对应的plugin,然后alias不需要再指定vue路径,main.ts也不需要区分vue2和vue3的依赖;
// v2/vite.config.ts
import { defineConfig } from 'vite'
import { createVuePlugin } from 'vite-plugin-vue2'
// or import vue2 from '@vitejs/plugin-vue2'
import path from 'path'
const resolve = (str: string) => {
return path.resolve(__dirname, str)
}
// https://vitejs.dev/config/
export default defineConfig({
// ...
resolve: {
alias: {
'@': resolve('src'),
}
},
build: {
// ...
rollupOptions: {
external: ['vue-demi', 'vue'],
output: {
dir: resolve('../../lib/v2'), // 区别在这
globals: {
vue: 'Vue',
'vue-demi': 'VueDemi'
}
}
}
},
optimizeDeps: {
exclude: ['vue-demi']
},
plugins: [createVuePlugin()] // or vue2()
})
// v3/vite.config.ts
import { defineConfig } from 'vite'
import vue3 from '@vitejs/plugin-vue'
import path from 'path'
const resolve = (str: string) => {
return path.resolve(__dirname, str)
}
// https://vitejs.dev/config/
export default defineConfig({
// ...
resolve: {
alias: {
'@': resolve('src'),
}
},
build: {
rollupOptions: {
external: ['vue-demi', 'vue'],
output: {
dir: resolve('../../lib/v3'), // 区别在这
globals: {
vue: 'Vue',
'vue-demi': 'VueDemi'
}
}
}
},
optimizeDeps: {
exclude: ['vue-demi']
},
plugins: [vue3()]
})
main.ts
// main.ts
import { createApp } from 'vue-demi'
import './style.css'
const app = createApp(App)
app.mount('#app')
整体目录结构如下,最后通过node脚本去同时构建v2和v3即可。
目前没找到vue3为主包的开发方式
文章看到这里,大概能知道整个方案其实是基于vue-demi处理composition-api和使用vue3的自定义compiler处理分别打包vue2、vue3;而vite-plugin-vue2
是没有对应自定义compiler的options,并且在vue3为主包的情况下,会报vue-template-compiler
与vue版本不一致的错误;而@vitejs/plugin-vue2
存在跟vue3冲突的情况;
目前如果要基于vue3为主包的方式开发,我想到如下2个思路,待后续有时间再去验证:
vite-plugin-vue2
增加自定义compiler选项- 开发rollup插件,支持修改
vue-template-compiler
在读取require(vue)
时,重定向到"vue2": "npm:vue@2.6.14"
对应的路径
注意点
1、@vue/composition-api重复引用问题
由于vue-demi在v2.6的场景下,会自动install @vue/composition-api
,,如果项目自身也在需要在入口时注册@vue/composition-api
,会出现多次注册@vue/composition-api
实例的情况,导致出setup相关的报错,这时需要在项目的alias加上以下代码:
alias: {
'@vue/compostion-api': resolve('./node_modules/@vue/composition-api')
},
2、由于要兼容vue2,vue3的 setup sfc语法糖不兼容
这一点无法解决,写组件template的时候,还是只能用vue2的template写法,包括template还是需要有唯一的跟节点;
最后
写到最后,其实我发现去写兼容vue2和vue3的template代码,并不能完全解决vue2到vue3过渡的问题。希望vue3社区以后越来越完善~
贴上项目地址(vue2.6 + vue3 + vite + vue-demi):vue-demi-sfc-component
转载自:https://juejin.cn/post/7134628175854141447