Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

跨版本兼容:构建支持 Vue 2 和 Vue 3 的通用组件 #279

Open
yanyue404 opened this issue Aug 30, 2024 · 0 comments
Open

跨版本兼容:构建支持 Vue 2 和 Vue 3 的通用组件 #279

yanyue404 opened this issue Aug 30, 2024 · 0 comments

Comments

@yanyue404
Copy link
Owner

yanyue404 commented Aug 30, 2024

跨版本兼容:构建支持 Vue 2 和 Vue 3 的通用组件

在现代前端开发中,随着框架版本的升级,如何有效管理和维护组件库的兼容性已成为高级前端工程师的必备技能。本文将深入探讨如何构建一个同时兼容 Vue 2 和 Vue 3 的通用组件,并讨论在此过程中需要注意的关键点。

Vue 3 渲染函数的重大变化

Vue 3 引入了多个针对渲染函数 API 的改动,旨在提高性能和一致性。这些更改虽然提升了框架的灵活性和易用性,但也给兼容性带来了挑战。以下是 Vue 3 渲染函数的主要变化概述:

渲染函数参数的变化

在 Vue 2 中,render 函数会自动接收 h 函数 (即 createElement) 作为参数,而在 Vue 3 中,h 函数已被移至全局导入。这种变化要求开发者在编写跨版本兼容的代码时格外注意。

Vue 2 示例:

// Vue 2 渲染函数示例
export default {
  render(h) {
    return h('div')
  }
}

Vue 3 示例:

// Vue 3 渲染函数示例
import { h } from 'vue'

export default {
  render() {
    return h('div')
  }
}

VNode Prop 的结构扁平化

Vue 3 对 VNode Prop 的结构进行了扁平化处理,从而简化了数据结构。这种改动减少了开发者在处理 VNode 时的认知负担,但同样需要对旧代码进行改造。

Vue 2 示例:

// 2.x VNode prop 示例
{
    staticClass: 'button',
    class: { 'is-outlined': isOutlined },
    staticStyle: { color: '#34495E' },
    style: { backgroundColor: buttonColor },
    attrs: { id: 'submit' },
    domProps: { innerHTML: '' },
    on: { click: submitForm },
    key: 'submit-button'
}

Vue 3 示例:

// 3.x VNode prop 示例
{
    class: ['button', { 'is-outlined': isOutlined }],
    style: [{ color: '#34495E' }, { backgroundColor: buttonColor }],
    id: 'submit',
    innerHTML: '',
    onClick: submitForm,
    key: 'submit-button'
}

组件注册方式的调整

Vue 3 在组件注册方面引入了 resolveComponent,要求显式导入组件并进行注册,这与 Vue 2 中可以通过字符串直接引用组件的方式形成了鲜明对比。

Vue 2 示例:

// Vue 2 组件注册示例
Vue.component('button-counter', {
  data() {
    return {
      count: 0
    }
  },
  template: `<button @click="count++"> Clicked {{ count }} times. </button>`
})

export default {
  render(h) {
    return h('button-counter')
  }
}

Vue 3 示例:

// Vue 3 组件注册示例, resolveComponent 用于按名称手动解析已注册的组件。
// 如果你可以直接引入组件就不需使用此方法。比如用 defineComponent、defineAsyncComponent
import { h, resolveComponent } from 'vue'

export default {
  setup() {
    const ButtonCounter = resolveComponent('button-counter')
    return () => h(ButtonCounter)
  }
}

创建跨 Vue 版本兼容的渲染方法

在构建通用组件时,最关键的步骤之一是根据不同的 Vue 环境来初始化渲染组件。下面的代码展示了如何为 Vue 2 和 Vue 3 创建一个基础的渲染方法,并提供了一个进阶版本,利用 vue-demi 库进一步提升兼容性和代码的可维护性。

基础实现

基础实现的目标是通过一个统一的接口,确保在 Vue 2 和 Vue 3 环境中都能正常渲染组件。

let createVmFn
export function setCreateVmFn(fn) {
  createVmFn = fn
}
export function mount(component, opt, el) {
  if (!component) {
    console.warn('请传入正确的组件')
  }
  if (!el) {
    el = document.createElement('div')
    document.body.appendChild(el)
  }
  if (!createVmFn) {
    console.warn('使用 mount 方法前,请确保加载入口文件')
    return
  }
  return createVmFn(component, opt, el)
}

入口文件示例

针对 Vue 2 和 Vue 3 分别实现了不同的入口函数,用以初始化组件的挂载过程。

// Vue 2 入口文件
import Vue from 'vue'
setCreateVmFn(
  (component, opt, el) =>
    new Vue({
      el,
      render(h) {
        return h(component, opt)
      }
    })
)

// Vue 3 入口文件
import { createApp, h } from 'vue'
setCreateVmFn((component, opt, el) => {
  const app = createApp({
    render() {
      return h(component, opt)
    }
  })
  app.mount(el)
  return app
})

异步对话框示例

下面的代码展示了一个在 Vue 2 和 Vue 3 中兼容的异步对话框组件的实现。

<template>
  <van-popup
    v-model="show"
    v-model:value="show"
    :lock-scroll="false"
    :close-on-click-overlay="false"
    :style="{ borderRadius: '0.3rem' }"
    class="async-dialog"
  >
    <div class="async-dialog-box">
      <div class="tip-content">内容</div>
      <div class="btn-confirm" @click="confirm">确定</div>
    </div>
  </van-popup>
</template>

<script>
import { Popup } from 'vant'
export default {
  props: {
    handleAction: Function
  },
  components: { [Popup.name]: Popup },
  data() {
    return {
      show: true
    }
  },
  mounted() {},
  methods: {
    close(e) {
      this.show = false
    },
    cancel() {
      this.handleAction('cancel')
      this.close()
    },
    confirm() {
      this.handleAction('confirm')
      this.close()
    }
  }
}
</script>
import { mount } from '../util'
import * as Vue from 'vue'
import dialog from './index.vue'

export default function asyncDialog() {
  return new Promise(resolve => {
    const isVue3 = (Vue.version || Vue.default.version)[0] === '3'
    const props = {
      handleAction: action => {
        resolve(action)
      }
    }
    dialog ? mount(dialog, isVue3 ? props : { props: props }) : resolve()
  })
}

进阶实现:利用 vue-demi 进行兼容性处理

https://github.com/yanyue404/vue-demi/blob/main/examples/

为了进一步提升跨版本的兼容性,我们可以借助 vue-demi 库,它提供了 Vue 2 和 Vue 3 之间的一层适配层,使得同一份代码在两个版本中均可运行。下面的代码展示了如何使用 vue-demi 实现这一目标。

import { isVue2, Vue, createApp, h as hDemi } from 'vue-demi'

function adaptOnsV3(ons) {
  if (!ons) return null
  return Object.entries(ons).reduce((ret, [key, handler]) => {
    key = key.charAt(0).toUpperCase() + key.slice(1)
    key = `on${key}`
    return { ...ret, [key]: handler }
  }, {})
}

// 使用 Vue2 语法编写
export function h(type, options, children) {
  if (isVue2) return hDemi(type, options, children)

  const { props, domProps, on, ...extraOptions } = options

  const ons = adaptOnsV3(on)
  const params = { ...extraOptions, ...props, ...domProps, ...ons }
  return hDemi(type, params, children)
}

export function mount(component, opt, el) {
  if (!component) {
    console.warn('请传入正确的组件')
  }
  if (!el) {
    el = document.createElement('div')
    document.body.appendChild(el)
  }

  if (isVue2) {
    return new Vue({
      el,
      render(h) {
        return h(component, opt)
      }
    })
  } else {
    const app = createApp({
      render() {
        return h(component, opt)
      }
    })
    app.mount(el)
  }
}
<template>
  <van-button plain type="primary" @click="openDialog"> 打开 vant dialog 弹窗</van-button>
</template>

<script>
import { ref } from 'vue-demi'
import { Button, Notify } from 'vant'
import 'vant/lib/index.css'
import { mount } from './utils'

export default {
  name: 'App',
  components: {
    [Button.name]: Button
  },
  setup(ctx, context) {
    return {
      openDialog: async () => {
        let selectDialog = () => {
          return new Promise((resolve, reject) => {
            mount(Dialog, {
              on: { confirm: () => resolve('confirm'), cancel: () => resolve('cancel') }
            })
          })
        }

        let ret = await selectDialog()

        setTimeout(() => {
          Notify({ type: { confirm: 'success', cancel: 'danger' }[ret], message: '通知内容: ' + ret })
        }, 300)
      }
    }
  },
  mounted() {
    console.log('App Mounted!')
  }
}
</script>

另一种采用 Vue3 的 props 写法兼容适配方案

import { isVue2 } from 'vue-demi'
const attrsNames = ['src']

export function transformVNodeProps(props) {
  if (!isVue2) {
    return props
  }

  // 创建需转移的结构
  const on = {}
  const attrs = {}
  const domProps = {}

  // 处理 class
  let staticClass = '',
    classNames = {}
  if (props.class) {
    staticClass = props.class.filter(v => typeof v === 'string').join(' ') || ''

    props.class
      .filter(v => typeof v === 'object')
      .forEach(o => {
        classNames = Object.assign(classNames, o)
      })
  }
  // 处理 style
  const staticStyle = props.style ? Object.assign({}, ...props.style) : {}

  // 处理 onClick 事件
  Object.keys(props).forEach(key => {
    if (key.startsWith('on') && key.length > 2) {
      const eventName = key[2].toLowerCase() + key.substring(3)
      on[eventName] = props[key]
      delete props[key]
    }
  })

  // 处理 attrs 和 domProps
  Object.keys(props).forEach(key => {
    if (key === 'innerHTML') {
      domProps.innerHTML = props[key]
      delete props[key]
    } else if (['id', 'src'].includes(key)) {
      attrs[key] = props[key]
      delete props[key]
    }
  })

  // 组装最终结构
  return {
    staticClass,
    class: classNames,
    staticStyle,
    attrs,
    domProps,
    on,
    key: props.key
  }
}
transformVNodeProps({
  class: ['button', { 'is-outlined': 'isOutlined' }],
  style: [{ color: '#34495E' }, { backgroundColor: 'buttonColor' }],
  src: 'xxx',
  id: 'submit',
  innerHTML: '',
  onClick: () => {},
  key: 'submit-button'
})

// to Vue2:
/*    {
      staticClass: 'button',
      class: { 'is-outlined': 'isOutlined' },
      staticStyle: { color: '#34495E',backgroundColor: 'buttonColor' },
      attrs: { id: 'submit',src: 'xxx' },
      domProps: { innerHTML: '' },
      on: { click: submitForm },
      key: 'submit-button'
  } */

总结

在现代前端开发中,构建兼容多个框架版本的组件库是一个具有挑战性但也充满价值的任务。通过深入了解框架的变化、使用合适的工具如 vue-demi,以及采用模块化的设计,我们可以创建一个高效且易于维护的组件库,确保其在不同版本的 Vue 中都能稳定运行。这不仅提升了项目的可维护性,也为团队的技术积累打下了坚实的基础。

更多探索

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant