前言
造了个轮子,还没想好如何给大家用?看下去吧 ~ 首先,我们假设已经费心打磨出了一个轮子 (比如一个 vue3 的通用组件),迫不及待地想用这个轮子为各位同仁『减负』。这个轮子将是什么形态的呢?
- 可能需要是一个 npm 包 ,方便项目接入。
- 可能需要有 typescript ,满足 ts 项目的需要,提升书写体验。
- 可能需要脱离技术栈,比如能够给一个 react项目使用。
- 可能需要是多个构建版本,方便 cjs 环境、script标签引入、es module。 然后就开始吧~
本文以 vue3+ts+less 的组件为例
0. 准备工作
我准备了一个用vue3实现的简单弹窗组件。
<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 我设计了 title
和 visible
两个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目录就得到了构建产物。
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中。