【vite】手把手搞一个ts组件包

2,933 阅读5分钟

前言

造了个轮子,还没想好如何给大家用?看下去吧 ~ 首先,我们假设已经费心打磨出了一个轮子 (比如一个 vue3 的通用组件),迫不及待地想用这个轮子为各位同仁『减负』。这个轮子将是什么形态的呢?

  • 可能需要是一个 npm 包 ,方便项目接入。
  • 可能需要有 typescript ,满足 ts 项目的需要,提升书写体验。
  • 可能需要脱离技术栈,比如能够给一个 react项目使用。
  • 可能需要是多个构建版本,方便 cjs 环境、script标签引入、es module。 然后就开始吧~

本文以 vue3+ts+less 的组件为例

0. 准备工作

我准备了一个用vue3实现的简单弹窗组件。

image.png

<template>
<teleport to="body">
  <div class="modal-mask" v-show="localVisible">
    <div class="modal">
      <div class="modal-header">
        <span>{{title}}</span>
        <button @click="localVisible = false">关闭</button>
      </div>
      <div class="modal-body">
        <slot></slot>
      </div>
    </div>
  </div>
</teleport>
</template>
<script lang="ts">
import { ref, defineComponent, watchEffect, watch } from 'vue'
export default defineComponent({
  name: 'Modal',
  props: {
    visible: {
      type: Boolean,
      required: false,
      default: false
    },
    title: {
      type: String,
      required: false,
      default: ''
    },
  },
  emits: ['update:visible'],
  setup(props, {emit}) {
    const localVisible = ref(false);
    watchEffect(() => {
      localVisible.value = props.visible;
    });
    watch(localVisible, value => {
      emit('update:visible', value);
    });
    return {localVisible};
  }
});
</script>

<style lang="less">
.modal-mask {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  background-color: rgba(0,0,0,.45);
  .modal {
    width: 520px;
    margin: 20% auto;
    background-color: #fff;
    position: relative;
    // modal主体的样式
  }
}
</style>

组件 Modal 我设计了 titlevisible 两个prop,以及一个配置弹窗主体内容的插槽,其中 visible 是具有双向绑定的。

1. 一个入口文件

可能需要脱离技术栈,比如能够给一个 react项目使用. 设想一个react的项目使用这个 Modal 组件,当然无法注册这个 vue 组件。如此可能需要一个JS界的『通用形态』,一个构造函数,或者说一个 Class。

1.1 完成实例化的过程

在 src 目录下新建一个 entry.ts ,暴露一个 Class ,当用户实例化时,生成这个 vue 实例 (做原来 main.ts 的工作)

import {createApp, App, h, ref} from 'vue';
import ModalComp from './components/Modal.vue';
export class Modal {
    vm: App<Element>
    constructor(el: Element, params: {
        title?: string;
        visible?: boolean;
        'onUpdate:visible'?: (value: boolean) => unknown;
    }) {
        const vm = createApp({
            setup() {
                const visible = ref(params.visible || false);
                return () => h(
                    ModalComp,
                    {   
                        title: params.title,
                        visible: visible.value,
                        'onUpdate:visible': params['onUpdate:visible'] || ((value: boolean) => {
                            visible.value = value;
                        })
                    },
                    {
                        // todo: 插槽
                    }
                )
            }
        });
        vm.mount(el);
        this.vm = vm;
    }
};

大部分的组件参数都作为 Class 的实例化参数,保留了和组件一致的默认行为,透传进了组件。但是,至关重要的插槽还没有。(还不熟悉渲染函数戳 这里 ) 就 Modal 而言,插槽没有传递参数,用户也无法触及组件内的响应式数据,可以提供一个 renderContent 的参数,传入字符串,也可以是一个函数。

constructor(el: Element, params: {
    title?: string;
    visible?: boolean;
    renderContent?: string|(() => string);
    'onUpdate:visible'?: (value: boolean) => unknown;
}) {

    const renderCardFun = typeof params.renderContent === 'function'
        ? params.renderContent
        : new Function('', `return \`${params.renderContent || ''}\`;`);
    const vm = createApp({
        setup() {
            const visible = ref(params.visible || false);
            return () => h(
                ModalComp,
                { // ... },
                {
                    default() {
                        return h('div', {innerHTML: renderCardFun(), class: 'modal-content'});
                    }
                }
            )
        }
    });
    vm.mount(el);
    this.vm = vm;
}
  • 如果传入的renderContent是字符串,还有必要转化成函数吗?在插槽有传参时有必要。 这样用户可以通过模板字符串语法书写插槽内容,而且在每次渲染插槽都会更新。
const renderCardFun = typeof params.renderContent === 'function'
    ? params.renderContent
    : new Function('data', `return \`${params.renderContent || ''}\`;`);
// ...
default(data: unkown) {
    return h('div', {innerHTML: renderCardFun(unkown), class: 'modal-content'});
}
// test
// renderCardFun = '用户名:${data.name}';
  • 为什么是innerHTML?vue 的 slot 函数期望用户 return 一个用过 vue 渲染函数生成的虚拟 DOM(比如JSX),将这样的渲染函数给一个react的用户,恐怕还需要很多 vue 的背景。另外JSX在 vue 和 react 只是书写体验一直,虚拟 DOM 并不相容,即使写一个白名单转换,也无法满足用户自定义组件等复杂的需求,意义不大。所以选择了通用结构innerHTML,只是需要一层 DOM 作为容器。

到此,可能你已经发现了,外界还无法在实例化后干预 visible 的状态,一个弹窗有它需要出现的契机。

1.2 向外暴露组件的行为

用户在实例化时传入的visible无法显式的完成双向绑定 (要求用户包装成一个 Proxy 很反常识,而且用户还要理解『双向绑定』) 。因此,想到暴露一对 show hidden 方法。

visible ref变量将被 show hidden 修改,show hidden 方法所在的作用域使得 setup 已不再合适。

constructor(el: Element, params: {
    title?: string;
    visible?: boolean;
    renderContent?: string|(() => string);
    'onUpdate:visible'?: (value: boolean) => unknown;
}) {

    const renderCardFun = typeof params.renderContent === 'function'
        ? params.renderContent
        : new Function('', `return \`${params.renderContent || ''}\`;`);
    const visible = ref(params.visible || false); // 向上提了作用域,便于 show 方法使用
    const vm = createApp({
        render() { // setup 中不包含任何初始化,改用 render
            return h(ModalComp, { /* ... */ }, { /* ... */ });
        }
    });
    vm.mount(el);
    this.vm = Object.assign(vm, { // 向实例混入 show 和 hidden 方法
        show() {
            visible.value = true;
        },
        hidden() {
            visible.value = false;
        }
    });
}

由于vue3的实例还未在生产环境暴露 methods,我们采用了手动混入自定义方法

1.3 export default

为满足不同使用习惯,还应该提供 export default 形式。

export class Modal {
    vm: App<Element>&{
        show(): void;
        hidden(): void
    }
    constructor(el: Element, params: {
        title?: string;
        visible?: boolean;
        renderContent?: string|(() => string);
        'onUpdate:visible'?: (value: boolean) => unknown;
    }) { /* ... */ }
}

export default Modal;

到此 Modal 组件的入口文件就完成了,设想用户引入我们的模块,应该可以这样使用:

// 容器节点渲染完成
const modal = new Modal(el, {/* ... */});
// 需要弹窗出现时
modal.vm.show();
// 需要弹窗隐藏时
modal.vm.hidden();

不过,如果我们把这样的源文件给用户,用户要自己安装 vue less ts,进行很多构配置设法解析这个 vue 项目。

2. 构建

终于该vite出场了,将构建产物提供给用户,他就无需费心解析这些不属于自己维护的源代码。

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  build: {
    lib: { // 库模式构建相关配置
      entry: './src/entry.ts',
      name: 'Modal',
      fileName: 'modal-vue'
    }
  },
  plugins: [vue()]
})
  • entry:提供入口文件,相对路径绝对路径都可。
  • name:script 引入时全局对象绑定的变量名,在 formats 包含 'umd''iife' 时是必须的。
  • fileName:输出的包文件名,默认 fileName 是 package.json 的 name 选项。
  • formats:模块化构建,默认是 ['es', 'umd'],还支持 'cjs''iife'。 执行npm run build,在dist目录就得到了构建产物。 image.png
style.css ---样式文件
modal-vue.umd.js ---umd构建版本,用于commonJS环境、script引入以及其他模块化
modal-vue.es.js ---es module构建版本,用于支持es module的项目引入。

了解vite-库模式戳这里

注意

  • 库模式构建目前无法使用@vitejs/plugin-legacy,因此兼容性取依赖和源代码的子集
  • 库模式提及可以不构建指定依赖,本次由于不想用户额外安装载入嵌套的依赖,选择全部构建

3. 一个类型声明文件

可能需要有 typescript ,满足 ts 项目的需要,提升书写体验。

设想用户在 ts 项目中使用了这个 js 包,用户需要很不情愿地写一个 modal-vue.d.ts 文件,或者粗暴地断言一个 any 。内置一个类型声明文件可以避免这种糟糕的问题发生。

在根目录新建一个 index.d.ts 用于声明这个组件包向外暴露的值(对应源代码中的export)以及一些想提供的类型声明(方便用户定义一些关键结构)。

// index.d.ts
export declare class Modal {
    vm: {
        show(): void;
        hidden(): void
    } // vue3-App
    constructor(el: Element, params: {
        title?: string;
        visible?: boolean;
        renderContent?: string|(() => string);
        'onUpdate:visible'?: (value: boolean) => unknown;
    })
}
export default Modal;

Modal的声明看起来比入口文件短小很多,因为类型声明文件不需要包含实现细节。个人对于声明文件内容的原则:

  • 用户可仅凭声明文件正确调用暴露的结构。
  • 符合声明文件的入参都应该被源代码处理。 想让这个声明文件生效还需要在 package.json 中用 types 指引到这里。
{
    // ....
    "types": "./index.d.ts",
    // ....
}

4. package.json

可能需要是一个 npm 包 ,方便项目接入。

除了 types ,一个 npm 包还需要在不同模块化指定对应入口文件。

{
    // ....
    "main": "dist/modal-vue.umd.js",
    "module": "./dist/modal-vue.es.js",
    // ....
}

最后,移除 dependencies、devDependencies 的选项,因为 dist 中的产物已包含相关依赖。

以上,这个组件包已经合成完成,尽管还未发布。你可以将项目目录(其实只需要 dist 、package.json 以及 index.d.ts)放入其他项目的node_modules中。

传送门