How to Build a Cross‑Version Vue Component Library for Vue 2 and Vue 3
This article explains the challenges of upgrading large B2B Vue 2 projects to Vue 3, evaluates three technical approaches—Vue SFC templates, render functions, and standard JSX—and details the chosen JSX‑based strategy, multi‑layer architecture, TypeScript support, and open‑source implementation for a seamless cross‑version component library.
Enterprise projects often remain on Vue 2 while Vue 3 has been stable for years. To avoid costly, risky rewrites, a progressive upgrade strategy based on micro‑frontend architecture was adopted: existing Vue 2 applications stay unchanged, and new modules are built with the latest stack.
Decision Options
Vue SFC / Template
Advantages
Templates are the primary Vue syntax; learning curve is low.
Static compilation can produce version‑specific bundles, keeping bundle size small and preserving Vue’s template optimizations.
Relies on standard syntax, no need for framework hacks.
Disadvantages
Build pipeline must emit two separate bundles.
Subtle syntax differences (e.g., v-model changes, removed .sync) become hard to handle.
Template is locked to Vue 3 syntax, preventing use of new Vue 3 features such as defineModel or defineOptions.
Render Functions Both Vue 2 and Vue 3 support render functions, but their APIs diverge significantly. The team decided not to pursue this path.
Standard JSX
Advantages
No extra Babel plugins; mainstream compilers (tsc, swc, esbuild) can compile directly.
Runtime transforms JSX into version‑specific render functions.
TypeScript‑friendly; works without Vue‑specific plugins.
Development experience similar to React JSX.
Disadvantages
Cannot leverage Vue template compilation optimizations (static hoisting, caching, etc.).
Runtime abstraction adds a small performance overhead.
Implementation requires deep knowledge of Vue internals and may produce less readable code compared with React‑style APIs.
The JSX approach was selected because it is simple to implement and offers better control for future version updates.
Architecture Strategy
Cross‑version component libraries must handle more than syntax differences: API changes, rendering functions, routing, and i18n also vary. The solution is organized into five layers:
API Layer – Uses vue-demi to expose a unified Composition API and defineComponent that work in both Vue 2 and Vue 3.
View‑Syntax Layer – Provides a jsx-runtime that normalizes render‑function differences between versions.
Adapter Layer – Implements adapters for third‑party libraries (e.g., Element UI vs. Element Plus) to expose a consistent interface.
Component‑Library Layer – Builds UI components on top of the abstractions above.
Application Layer – Consuming Vue 2 or Vue 3 apps dynamically select the appropriate adapters at runtime.
Implementation policies:
New‑Version‑First – Prefer Vue 3 features (Composition API, defineComponent, JSX aligned with Vue 3 render functions).
Shortcoming‑First – Accept that some Vue 3 features (Fragment, Teleport, Suspense, await setup) cannot be back‑ported to Vue 2.
Fallback – Use vue-demi 's isVue2 flag to branch code where differences cannot be abstracted.
Implementation Details
API Compatibility with vue-demi
vue-demiruns a post‑install hook that detects the installed Vue version and exports the appropriate symbols: <=2.6: exports vue + @vue/composition-api. 2.7: exports the built‑in Composition API from Vue 2.7. >=3.0: exports the native Vue 3 API and shims Vue 2 set / del APIs.
The team forked vue-demi to add extra shims required by their library.
JSX Runtime
The JSX runtime translates JSX syntax into version‑specific render functions. The core idea is a thin wrapper that, at build time, generates two bundles (Vue 2 and Vue 3) and, at runtime, selects the appropriate render function based on the detected Vue version.
TypeScript Support and declareComponent
To simplify type definitions, a wrapper declareComponent was created around defineComponent. It drops the Options API, keeping only the Composition API, and provides:
Strongly typed props, emit, expose, and slots via generic parameters.
Conversion of Vue event names to on* props for JSX ergonomics.
Explicit handling of inheritAttrs and runtime v‑slots definitions.
Example:
const Counter = declareComponent({
name: 'Counter',
props: declareProps<{ initialValue: number }>(['initialValue']),
emits: declareEmits<{ change: (value: number) => void }>(),
setup(props, { emit }) {
const count = ref(props.initialValue)
const handleClick = () => {
count.value++
emit('change', count.value)
}
return () => (
<div title="count" onClick={handleClick}>count: {count.value}</div>
)
}
})Generic component support is achieved by asserting the component’s constructor type; current Volar support is limited.
Element Adapter
The adapter mirrors vue-demi 's post‑install logic: it selects element-ui for Vue 2 and element-plus for Vue 3. Most components are re‑exported directly; components with changed prop names are wrapped to normalize the API.
export { Button } from 'element-ui'
import { TimePicker as ElTimePicker } from 'element-ui'
import { h } from '@wakeadmin/h'
import { normalizeDateFormat } from '../../shared/date-format'
export const TimePicker = {
functional: true,
render(_, context) {
const { format, selectableRange, valueFormat, ...other } = context.props
other.pickerOptions = {
...other.pickerOptions,
format: format && normalizeDateFormat(format),
selectableRange,
}
if (valueFormat) {
other.valueFormat = normalizeDateFormat(valueFormat)
}
return h(ElTimePicker, Object.assign({}, context.data, { props: other, attrs: undefined }), context.slots())
}
}Router Adapter
Vue Router’s API is similar across versions, so the adapter simply retrieves the router instance from the component context, handling the slight shape differences.
export interface RouteLike {
query: Record<string, any>
params: Record<string, any>
hash: string
path: string
}
export type RouteLocation = string | {
query?: Record<string, any>
hash?: string
path?: string
name?: string
params?: Record<string, any>
replace?: boolean
}
export interface RouterLike {
push(to: RouteLocation): Promise<any>
replace(to: RouteLocation): Promise<any>
back(): void
forward(): void
go(delta: number): void
}
export function useRouter() {
const instance = getCurrentInstance()
if (isVue2) {
return (instance?.proxy?.$root as { $router: RouterLike } | undefined)?.$router
} else {
return (instance?.root?.proxy as unknown as { $router: RouterLike })?.$router
}
}Open‑Source Release
The full implementation—including the JSX runtime, component library, element‑adapter, and router‑adapter—has been open‑sourced. Developers can clone the repository, submit pull requests, and star the project.
Summary
A JSX‑based conversion layer abstracts Vue 2 and Vue 3 rendering differences, allowing a single component library to serve both versions. Most adapters follow the vue-demi pattern: provide a unified API and switch the concrete implementation at install time.
Further Reading
Vue 2/3 render‑function differences and compatibility solutions
Feature: generic support for defineComponent vite-plugin-vue: https://github.com/vitejs/vite-plugin-vue
vite-plugin-vue2: https://github.com/vitejs/vite-plugin-vue2
babel-plugin-jsx (Vue JSX Babel plugin): https://github.com/vuejs/babel-plugin-jsx
jsx-vue2 (JSX for Vue 2): https://github.com/vuejs/jsx-vue2
Vue language tools: https://github.com/vuejs/language-tools
vue-loader: https://github.com/vuejs/vue-loader
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
