vue3自定义组件库
# 简介
vue3终极lerna搭建; vue3自定义组件库
# 搭建库文档
# 文档环境
安装webpack构建工具
yarn add webpack webpack-cli webpack-dev-server vue-loader@next @vue/compiler-sfc -D
yarn add babel-loader @babel/core @babel/preset-env @babel/preset-typescript babel-plugin-module-resolver url-loader file-loader html-webpack-plugin css-loader sass-loader style-loader sass -D
2
babel.config.js
module.exports = {
presets: [
'@babel/preset-env',
"@babel/preset-typescript" // 解析ts语法,在采用preset-env
],
overrides: [{
test: /\.vue$/,
plugins: [ // ?
'@babel/transform-typescript',
],
}],
env: {
utils: {
plugins: [ // ?
[
'babel-plugin-module-resolver', // 为了能正确找到rat-ui模块
{ root: 'rat-ui' }
]
]
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
使用webpack进行文档构建工作
const { VueLoaderPlugin } = require("vue-loader");
const HtmlWebpackPlugin = require('html-webpack-plugin')
const path = require('path')
module.exports = {
mode: 'development',
devtool:'source-map',
entry: path.resolve(__dirname, 'main.ts'), // 打包入口
output: {
path: path.resolve(__dirname, '../website-dist'), // 出口
filename: 'bundle.js'
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.vue', '.json'] // 解析文件顺序
},
module: {
rules: [{ // 识别vue
test: /\.vue$/,
use: 'vue-loader',
},
{ // 识别tsx
test: /\.(ts|js)x?$/,
exclude: /node_modules/,
loader: 'babel-loader'
},
{ // 识别图标...
test: /\.(svg|otf|ttf|woff|eot|gif|png)$/,
loader: 'url-loader',
},
{ // 识别样式
test: /\.(scss|css)$/,
use: [
'style-loader',
'css-loader',
'sass-loader'
]
}
],
},
plugins: [
new VueLoaderPlugin(),
new HtmlWebpackPlugin({ // html插件
template: path.resolve(__dirname, 'template.html')
})
]
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# 构建入口
最内引用文档构建:
"scripts": {
"doc": "webpack serve --config ./website/webpack.config.js"
}
2
3
配置运行命令:后续可以采用 "doc" 运行文档来预览组件效果
import {createApp} from 'vue';
import RUI from 'rat-ui';
import App from './App.vue'
createApp(App).use(RUI).mount('#app'); // 入口文件中使用组件即可
2
3
4
# 组件库初始化
# monorepo项目初始化
$ yarn global add lerna
$ lerna init
2
lerna.json
{
"packages": [
"packages/*"
],
"version": "0.0.0",
"npmClient": "yarn", // 使用yarn管理
"useWorkspaces": true // 使用workspace,需要配置package.json
}
2
3
4
5
6
7
8
package.json
{
"name": "root",
"private": true,
"workspaces": [
"packages/*"
],
"devDependencies": {
"lerna": "^3.22.1"
}
}
2
3
4
5
6
7
8
9
10
# 初始化组件
$ lerna create button
$ lerna create icon
2
├─button
│ │ package.json
│ │ README.md
│ ├─src
| ├─ button.vue
│ ├─index.ts # 组件入口
│ └─__tests__ # 测试相关
└─icon
│ package.json
│ README.md
├─src
├─ icon.vue
├─index.ts # 组件入口
└─__tests__
2
3
4
5
6
7
8
9
10
11
12
13
14
# tsconfig生成
yarn add typescript -W
npx tsc --init
2
{
"compilerOptions": {
"target": "ESNext", // 打包的目标语法
"module": "ESNext", // 模块转化后的格式
"esModuleInterop": true, // 支持模块转化
"skipLibCheck": true, // 跳过类库检测
"forceConsistentCasingInFileNames": true, // 强制区分大小写
"moduleResolution": "node", // 模块解析方式
"jsx": "preserve", // 不转化jsx
"declaration": true, // 生成声明文件
"sourceMap": true // 生成映射文件
}
}
2
3
4
5
6
7
8
9
10
11
12
13
解析
esModuleInterop
属性
import fs from 'fs'; // 编译前
let fs = require('fs');
fs.default // 编译后 fs无default属性,所引引用时会出问题
2
3
# 组件初始化
$ yarn add vue@next -W
# 编写组件入口及出口
<template>
<button> 按钮 </button>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name: "RButton",
});
</script>
2
3
4
5
6
7
8
9
<template>
<div> icon </div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name:'RIcon'
})
</script>
2
3
4
5
6
7
8
9
10
入口声明对应的install方法
import { App } from 'vue'
import Button from './src/button.vue'
Button.install = (app: App): void => {
app.component(Button.name, Button);
}
export default Button;
2
3
4
5
6
7
默认无法解析.vue文件后缀的文件,增加typings
typings/vue-shim.d.ts
// 定义所有以.vue文件结尾的类型
declare module "*.vue" {
import { defineComponent } from "vue";
const component: ReturnType<typeof defineComponent> & {
install(app: App): void;
};
export default component;
}
2
3
4
5
6
7
8
# 整合所有组件rat-ui
rat-ui/index.ts
import Button from "@rat-ui/button";
import Icon from "@rat-ui/icon";
import { App } from "vue";
const components = [ // 引入所有组件
Button,
Icon
];
const install = (app: App): void => {
components.forEach(component => {
app.component(component.name, component);
})
}
export default {
install // 导出install方法
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 组件库打包[TODO]
三种打包类型;
"build": "webpack --config builds/webpack.config.js",
"build:esm": "rollup -c ./builds/rollup.config.js",
"build:esm-bundle": "rollup -c ./builds/rollup.config.bundle.js"
2
3
# 打包Umd格式组件库
使用webpack打包成umd格式;
builds/webpack.config.js
const path = require('path');
const { VueLoaderPlugin } = require('vue-loader')
module.exports = {
mode: 'production',
entry: path.resolve(__dirname, '../packages/rat-ui/index.ts'),
output: {
libraryTarget: 'umd',
library: 'rat-ui',
},
externals: { // 排除vue打包
vue: {
root: 'Vue',
commonjs: 'vue',
commonjs2: 'vue',
},
},
module: {
rules: [{
test: /\.vue$/,
use: 'vue-loader',
},
{
test: /\.(ts|js)x?$/,
exclude: /node_modules/,
loader: 'babel-loader',
},
]
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.json'],
},
plugins: [
new VueLoaderPlugin(),
]
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
通过组件库入口进行打包
"build": "webpack --config ./build/webpack.config.js"
# 打包esModule格式组件库
使用rollup进行打包,安装所需依赖
yarn add rollup rollup-plugin-typescript2 @rollup/plugin-node-resolve rollup-plugin-vue -D
# 全量打包
builds\rollup.config.js
import typescript from 'rollup-plugin-typescript2';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import path from 'path';
import { getPackagesSync } from '@lerna/project';
import vue from 'rollup-plugin-vue'
// 获取package.json 找到名字 以@rat-ui 开头的
const inputs = getPackagesSync().map(pkg => pkg.name).filter(name => name.includes('@rat-ui'));
export default inputs.map(name => {
const pkgName = name.split('@rat-ui')[1] // button icon
return {
input: path.resolve(__dirname, `../packages/${pkgName}/index.ts`),
output: {
format: 'es',
// file: `lib/index.js`,
file: `lib/${pkgName}/index.js`,
},
plugins: [
nodeResolve(),
vue({
target: 'browser'
}),
typescript({
tsconfigOverride: {
compilerOptions: { // 打包单个组件的时候不生成ts声明文件
declaration: false,
},
exclude: [
'node_modules',
'website'
],
}
})
],
external(id) { // 对vue本身 和 自己写的包 都排除掉不打包
return /^vue/.test(id) || /^@rat-ui/.test(id)
},
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# 按组件打包
builds/rollup.config.bundle.js
import typescript from 'rollup-plugin-typescript2';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import path from 'path';
import vue from 'rollup-plugin-vue'
export default {
input: path.resolve(__dirname, `../packages/rat-ui/index.ts`),
output: {
format: 'es',
file: `lib/index.esm.js`,
},
plugins: [
nodeResolve(),
vue({
target: 'browser'
}),
typescript({ // 默认调用tsconfig.json 帮我们生成声明文件
tsconfigOverride: {
exclude: [
'node_modules',
'website'
]
}
})
],
external(id) { // 排除vue本身
return /^vue/.test(id)
},
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
组件为了能单独使用,增加声明
type IWithInstall<T> = T & { install(app: App): void; } // 携带install方法
const _Button:IWithInstall<typeof Button> = Button;
export default _Button;
2
3
# 组件样式处理
theme-chalk
项目
# 使用gulp打包scss文件
安装gulp, 打包样式
yarn add gulp gulp-autoprefixer gulp-cssmin gulp-dart-sass gulp-rename -D
│ button.scss
│ icon.scss
├─common
│ var.scss # 提供scss变量
├─fonts # 字体
└─mixins
config.scss # 提供名字
mixins.scss # 提供mixin方法
index.scss # 整合所有scss
2
3
4
5
6
7
8
9
mixins/config.scss
$namespace:'r'; // 修饰命名空间
$state-prefix: 'is-';// 修饰状态
$modifier-separator:'--'; // 修饰类型的
$element-separator: '__'; // 划分空间分隔符
2
3
4
common/var.scss
@import "../mixins/config.scss";
$--color-primary: #409EFF;
$--color-white: #FFFFFF;
$--color-black: #000000;
$--color-success: #67C23A;
$--color-warning: #E6A23C;
$--color-danger: #F56C6C;
$--color-info: #909399;
2
3
4
5
6
7
8
mixin.scss
@import "../common/var.scss";
// .r-button{}
@mixin b($block) {
$B: $namespace+'-'+$block;
.#{$B}{
@content;
}
}
// .r-button.is-xxx
@mixin when($state) {
@at-root {
&.#{$state-prefix + $state} {
@content;
}
}
}
// &--primary => .r-button--primary
@mixin m($modifier) {
@at-root {
#{&+$modifier-separator+$modifier} {
@content;
}
}
}
// &__header => .r-button__header
@mixin e($element) {
@at-root {
#{&+$element-separator+$element} {
@content;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# gulp
构建
const { series, src, dest } = require('gulp')
const sass = require('gulp-dart-sass')
const autoprefixer = require('gulp-autoprefixer')
const cssmin = require('gulp-cssmin')
function compile() { // 处理scss文件
return src('./src/*.scss')
.pipe(sass.sync())
.pipe(autoprefixer({}))
.pipe(cssmin())
.pipe(dest('./lib'))
}
function copyfont() { // 拷贝字体样式
return src('./src/fonts/**').pipe(cssmin()).pipe(dest('./lib/fonts'))
}
exports.build = series(compile, copyfont)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 预览环境中使用SCSS
这里时在website项目中使用,引入;
website/main.ts
import {createApp} from 'vue';
import RUI from 'rat-ui';
import App from './App.vue'
import "theme-chalk/src/index.scss"
createApp(App).use(RUI).mount('#app');
2
3
4
5
6
最终使用打包后的css引入即可,这里为了方便调试,不需要每次进行重新打包
# Icon组件实现
# 字体处理
这里我们使用iconfont实现字体图标 r-ui (opens new window)
项目名称 | rat-ui |
---|---|
项目描述 | 组件库图标 |
FontClass/Symbol 前缀 | r-icon- |
Font Family | r-ui-icons |
theme-chalk/icon.scss
@import "common/var.scss";
@font-face {
font-family: "r-ui-icons"; // 不考虑兼容性
src:url('./fonts/iconfont.woff') format('woff'),
url('./fonts/iconfont.ttf') format('truetype');
}
[class^="#{$namespace}-icon-"] {
font-family: "r-ui-icons" !important;
font-size: 14px;
display: inline-block;
font-style: normal;
-webkit-font-smoothing: antialiased;
-mor-osx-font-smoothing: grayscale;
}
@keyframes rotating {
0% {
transform: rotateZ(0deg);
}
100% {
transform: rotateZ(360deg);
}
}
.#{$namespace}-icon-loading {
animation: rotating 1.5s linear infinite;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 实现对应组件编写
<template>
<i :class="`r-icon-${name}`"></i>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name: "RIcon",
props: {
name: {
type: String,
default: "",
}
},
});
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Button组件实现
# button组件结构
typings/vue-shim.ts 定义组件大小状态
declare type ComponentSize = 'large' | 'medium' | 'small' | 'mini'
<template>
<button
:class="classs"
@click="handleClick"
:disabled="disabled"
>
<i v-if="loading" class="r-icon-loading"></i>
<i v-if="icon && !loading" :class="icon"></i>
<span v-if="$slots.default"><slot></slot></span>
</button>
</template>
<script lang="ts">
import { defineComponent, PropType, computed } from "vue";
export default defineComponent({
name: "RButton",
props: {
type: {
type: String as PropType<
"primary" | "success" | "warning" | "danger" | "info" | "default"
>,
default: "primary",
validator: (val: string) => {
return [
"default",
"primary",
"success",
"warning",
"danger",
"info",
].includes(val);
},
},
size: {
type: String as PropType<ComponentSize>,
},
icon: {
type: String,
default: "",
},
loading: Boolean,
disabled: Boolean,
round: Boolean,
},
emits: ["click"],
setup(props, ctx) {
const classs = computed(() => [
"r-button",
"r-button--" + props.type,
props.size ? "r-button--" + props.size : "",
{
"is-disabled": props.disabled, // 状态全部以 is-开头
"is-loading": props.loading,
"is-round": props.round,
},
]);
const handleClick = (e) => {
ctx.emit("click", e);
};
return {
classs,
handleClick,
};
},
});
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# button样式处理
@include b(button) {
// BEM规范
display: inline-block;
cursor: pointer;
outline: none;
border: #fafafa;
border-radius: 5px;
user-select: none;
min-height: 40px;
line-height: 1;
vertical-align: middle;
& [class*="#{$namespace}-icon-"] { // 处理icon 和文字间距
&+span {
margin-left: 5px;
}
}
@include when(disabled) { // 针对不同类型处理
&,
&:hover,
&:focus {
cursor: not-allowed
}
}
@include when(round) {
border-radius: 20px;
padding: 12px 23px;
}
@include when(loading) {
pointer-events: none;
}
@include m(primary) { //渲染不同类型的button
@include button-variant($--color-white, $--color-primary, $--color-primary)
}
@include m(success) {
@include button-variant($--color-white, $--color-success, $--color-success)
}
@include m(warning) {
@include button-variant($--color-white, $--color-warning, $--color-warning)
}
@include m(danger) {
@include button-variant($--color-white, $--color-danger, $--color-danger)
}
@include m(info) {
@include button-variant($--color-white, $--color-info, $--color-info)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
提供scss的辅助方法,方便后续使用
@mixin button-variant($color, $background-color, $border-color) {
color: $color;
background: $background-color;
border-color: $border-color;
}
2
3
4
5
# Button组件试用
<template>
<div>
<r-button :loading="buttonLoading" @click="buttonClick">架构</r-button>
<r-icon name="loading"></r-icon>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref } from "vue";
function useButton(){
const buttonLoading = ref(true);
onMounted(()=>{
setTimeout(() => {
buttonLoading.value = false;
}, 2000);
});
const buttonClick = () =>{
alert('点击按钮')
}
return {
buttonLoading,
buttonClick
}
}
export default defineComponent({
setup() {
return {
...useButton()
}
},
});
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# ButtonGroup按钮组组件
# 导出
packages/button-group/index.ts
import { App } from 'vue'
import ButtonGroup from '../button/src/button-group.vue'
ButtonGroup.install = (app: App): void => {
app.component(ButtonGroup.name, ButtonGroup)
}
type IWithInstall<T> = T & { install(app: App): void; }
const _ButtonGroup:IWithInstall<typeof ButtonGroup> = ButtonGroup
export default _ButtonGroup
2
3
4
5
6
7
8
9
10
# 样式及使用
@include b(button-group) {
// BEM规范
&>.#{$namespace}-button {
&:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
&:last-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
<r-button-group>
<r-button type="primary" icon="r-icon-arrow-left-bold">上一页</r-button>
<r-button type="primary" >下一页<i class="r-icon-arrow-right-bold"></i></r-button>
</r-button-group>
2
3
4
# Row/Col布局组件
# 初始化
row.ts
import { computed, defineComponent, h } from "vue";
export default defineComponent({
name: 'RRow',
props: {
tag: {
type: String,
default: 'div'
}
},
setup(props, { slots }) {
const classs = computed(() => [
'r-row'
])
return () => h(props.tag, {
class: classs.value
}, slots.default?.())
}
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
col.ts
简版
import { computed, defineComponent, h } from "vue"
export default defineComponent({
name: 'RCol',
props: {
tag: {
type: String,
default: 'div',
}
},
setup(props, { slots }) {
const classs = computed(() => [
'r-col'
])
return () => h(props.tag, {
class: classs.value
}, slots.default?.())
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
优化
import { computed, defineComponent, h } from "vue"
export default defineComponent({
name: 'RCol',
props: {
tag: {
type: String,
default: 'div'
},
span: {
type: Number,
default: 24
},
offset: {
type: Number,
default: 0,
}
},
setup(props, { slots }) {
const classs = computed(() => {
const ret: string[] = [];
const pos = ['span', 'offset'] as const;
pos.forEach(item => {
const size = props[item];
if (typeof size == 'number' && size > 0) {
ret.push(`r-col-${item}-${props[item]}`);
}
})
return [
'r-col',
...ret
]
})
return () => h(props.tag, {
class: classs.value
}, slots.default?.())
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 设置列宽
row.scss
@import "common/var";
@import "mixins/mixins";
@include b(row){
display: flex;
flex-wrap: wrap;
}
2
3
4
5
6
col.scss
@import './common/var.scss';
@import './mixins/mixins.scss';
@for $i from 0 through 24 {
.#{$namespace}-col-span-#{$i} {
max-width: (1 / 24 * $i * 100) * 1%;
flex: (1/24 * $i * 100) * 1%;
}
.#{$namespace}-col-offset-#{$i} {
margin-left: (1 / 24 * $i * 100) * 1%;
}
}
2
3
4
5
6
7
8
9
10
11
12
# 设置行信息
将Row组件中的gutter属性暴露出去, 在Col组件中注入属性;
provide('ZRow',props.gutter);
const style = computed(() => {
const ret = {
marginLeft: '',
marginRight: '',
}
if (props.gutter) { // 放大宽度
ret.marginLeft = `-${props.gutter / 2}px`
ret.marginRight = ret.marginLeft
}
return ret
})
2
3
4
5
6
7
8
9
10
11
12
setup(props, { slots }) {
const gutter = inject('RRow', 0)
const style = computed(() => { // 设置间距
if (gutter) {
return {
paddingLeft: gutter / 2 + 'px',
paddingRight: gutter / 2 + 'px'
}
}
return {}
})
return () => h(props.tag, {
class: classs.value,
style: style.value
}, slots.default?.())
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
保证padding不影响宽度
[class*="#{$namespace}-col-"] {
box-sizing: border-box;
}
2
3
# 响应式
var.scss
$--sm: 768px !default;
$--md: 992px !default;
$--lg: 1200px !default;
$--xl: 1920px !default;
$--breakpoints: (
'xs' : (max-width: $--sm - 1),
'sm' : (min-width: $--sm),
'md' : (min-width: $--md),
'lg' : (min-width: $--lg),
'xl' : (min-width: $--xl)
);
2
3
4
5
6
7
8
9
10
11
12
mixin.scss
@mixin res($key, $map: $--breakpoints) {
// 循环断点Map,如果存在则返回
@if map-has-key($map, $key) {
@media only screen and #{inspect(map-get($map, $key))} {
@content;
}
}
}
2
3
4
5
6
7
8
针对不同尺寸生成对应样式结构
@include res(xs) {
@for $i from 0 through 24 {
.#{$namespace}-col-xs-#{$i} {
max-width: (1 / 24 * $i * 100) * 1%;
flex: 0 0 (1 / 24 * $i * 100) * 1%;
}
.#{$namespace}-col-xs-offset-#{$i} {
margin-left: (1 / 24 * $i * 100) * 1%;
}
}
}
@include res(sm) {
@for $i from 0 through 24 {
.#{$namespace}-col-sm-#{$i} {
max-width: (1 / 24 * $i * 100) * 1%;
flex: 0 0 (1 / 24 * $i * 100) * 1%;
}
.#{$namespace}-col-sm-offset-#{$i} {
margin-left: (1 / 24 * $i * 100) * 1%;
}
}
}
@include res(md) {
@for $i from 0 through 24 {
.#{$namespace}-col-md-#{$i} {
max-width: (1 / 24 * $i * 100) * 1%;
flex: 0 0 (1 / 24 * $i * 100) * 1%;
}
.#{$namespace}-col-md-offset-#{$i} {
margin-left: (1 / 24 * $i * 100) * 1%;
}
}
}
@include res(lg) {
@for $i from 0 through 24 {
.#{$namespace}-col-lg-#{$i} {
max-width: (1 / 24 * $i * 100) * 1%;
flex: 0 0 (1 / 24 * $i * 100) * 1%;
}
.#{$namespace}-col-lg-offset-#{$i} {
margin-left: (1 / 24 * $i * 100) * 1%;
}
}
}
@include res(xl) {
@for $i from 0 through 24 {
.#{$namespace}-col-xl-#{$i} {
max-width: (1 / 24 * $i * 100) * 1%;
flex: 0 0 (1 / 24 * $i * 100) * 1%;
}
.#{$namespace}-col-xl-offset-#{$i} {
margin-left: (1 / 24 * $i * 100) * 1%;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
col.ts
根据用户设置的属性
const sizes = ['xs', 'sm', 'md', 'lg', 'xl'] as const
sizes.forEach(size => {
if (typeof props[size] === 'number') {
ret.push(`r-col-${size}-${props[size]}`)
}
})
2
3
4
5
6
# 对其方式
row.ts
const classs = computed(() => [
'r-row',
props.justify !== 'start' ? `is-justify-${props.justify}` : '',
])
2
3
4
设置flex布局对其方式即可
@include b(row) {
display: flex;
flex-wrap: wrap;
@include when(justify-center) {
justify-content: center;
}
@include when(justify-end) {
justify-content: flex-end;
}
@include when(justify-space-between) {
justify-content: space-between;
}
@include when(justify-space-around) {
justify-content: space-around;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Checkbox选择组件
# 设计组件属性
ckeckbox.types.ts
export interface ICheckboxProps {
name?: string, // input中name属性
label?: string | boolean | number, // v-model为array时使用
modelValue: string | boolean | number, // 绑定的值
indeterminate?: boolean // 是否半选
disabled?: boolean // 禁用
checked?: boolean // 是否选中
}
2
3
4
5
6
7
8
# 编写checkbox组件
<template>
<label class="r-checkbox">
<span class="r-checkbox__input">
<input
type="checkbox"
:value="label"
:disabled="disabled"
/>
</span>
<!-- 没有默认 有label -->
<span v-if="$slots.default || label" class="r-checkbox__label">
<slot></slot>
<template v-if="!$slots.default">{{ label }}</template>
</span>
</label>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { useCheckbox } from "./useCheckbox";
export default defineComponent({
name: "RCheckbox",
props: {
name: {
// checkbox name属性
type: String,
},
modelValue: {
// input绑定的值
type: [Boolean, Number, String],
},
label: {
// 选中状态的值
type: [Boolean, Number, String],
},
indeterminate: Boolean, // 半选
disabled: Boolean, // 禁用
checked: Boolean, // 是否选中
}
});
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# 实现checkbox双向绑定
<input
type="checkbox"
:value="label"
:disabled="disabled"
v-model="model" // 双向绑定的字段
:checked="isChecked" // input的状态
@change="handleChange" // 用于将checked字段传递给用户
/>
2
3
4
5
6
7
8
const useModel = (props: ICheckboxProps) => {
const { emit } = getCurrentInstance();
const model = computed({
get() {
return props.modelValue
},
set(val: unknown) {
emit('update:modelValue', val);
}
});
return model;
}
const useCheckboxStatus = (props: ICheckboxProps, model) => {
const isChecked = computed(() => {
const value = model.value;
return value;
});
return isChecked
}
const useEvent = () => {
const { emit } = getCurrentInstance()
// checkbox修改事件
function handleChange(e: InputEvent) {
const target = e.target as HTMLInputElement;
const value = target.checked ? true : false; // 获取checked属性,触发修改逻辑
emit('change', value)
}
return handleChange
}
export function useCheckbox(props: ICheckboxProps) {
const model = useModel(props);
const isChecked = useCheckboxStatus(props, model);
const handleChange = useEvent();
return {
model,
isChecked,
handleChange
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# 实现半选功能[TODO]
const inputRef = ref<HTMLInputElement>(null)
function indeterminate(val) {
inputRef.value.indeterminate = val;
}
watch(() => props.indeterminate, indeterminate)
onMounted(()=>{ // 默认加载完毕后
indeterminate(props.indeterminate)
});
2
3
4
5
6
7
8
# 编写Checkbox-Group组件
checkbox-group组件的功能主要是将数据同步给checkbox组件中;
<template>
<div class="r-checkbox-group">
<slot></slot>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, provide, toRefs } from "vue";
export default defineComponent({
name: "RCheckboxGroup",
props: {
modelValue: {
type: Array,
},
disabled: Boolean,
},
emits:['change','update:modelValue'],
setup(props, ctx) {
const modelValue = computed(()=>props.modelValue);
const changeEvent = (val) => { // 此事件用于子同步数据到checkbox-group中
ctx.emit("update:modelValue", val);
ctx.emit('change',val)
};
provide("CheckBoxGroup", { // 注入数据
name: "RCheckBoxGroup",
modelValue,
changeEvent,
});
},
});
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
注入实例时编写实例类型
export interface ICheckboxGroupInstance {
modelValue?: ComputedRef, // 绑定的值
name?: string
changeEvent?: (...args: any[]) => any // 修改事件
}
2
3
4
5
const useCheckboxGroup = () => {
const checkboxGroup = inject<ICheckboxGroupInstance>('CheckBoxGroup', {});
const isGroup = computed(() => checkboxGroup.name == 'RCheckBoxGroup')
return {
isGroup,
checkboxGroup
}
}
// 1.针对checkbox-group处理数据
const useModel = (props: ICheckboxProps) => {
const { emit } = getCurrentInstance();
const { isGroup, checkboxGroup } = useCheckboxGroup(); // 针对checkbox组特殊处理
const store = computed(() => checkboxGroup ? checkboxGroup.modelValue?.value : props.modelValue); // 将父组件v-model数据获取到
const model = computed({
get() {
return isGroup.value ? store.value : props.modelValue
},
set(val: unknown) {
if (isGroup.value) { // 如果是checkbox 组让父级处理
checkboxGroup.changeEvent(val)
} else {
emit('update:modelValue', val);
}
}
});
return model;
}
// 2.针对label来处理选中逻辑
const useCheckboxStatus = (props: ICheckboxProps, model) => {
const isChecked = computed(() => {
const value = model.value;
if(typeof value == 'boolean'){
return value;
}else if(Array.isArray(value)){ // 如果是数组 看是否包含这一项,来确定checked的值
return value.includes(props.label)
}
return value;
});
return isChecked
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# Input输入框组件
# 设计输入框属性
props: {
modelValue: { type: [String, Number], default: "" }, // v-model绑定的值
type: { type: String, default: "text" }, // 当前input类型 password text
placeholder: { type: String }, // 输入提示
disabled: { type: Boolean, default: false }, // 是否禁用
readonly: { type: Boolean, default: false }, // 是否仅读
clearable: { type: BIoolean }, // 是否带有清空按钮
showPassword: { type: Boolean, default: false }, // 密码框是否显示密码
suffixIcon: { type: String, default: "" }, // 前icon
prefixIcon: { type: String, default: "" }, // 后icon
label: { type: String }, // input配合的label属性
},
2
3
4
5
6
7
8
9
10
11
12
# 设计输入框结构
<div :class="classs">
<!-- 前元素 -->
<div v-if="$slots.prepend" class="r-input-group__prepend">
<slot name="prepend"></slot>
</div>
<!-- 核心input -->
<input
:type="showPassword ? (passwordVisible ? 'text' : 'password') : type"
ref="input"
class="r-input__inner"
:disabled="disabled"
:readonly="readonly"
v-bind="attrs"
:placeholder="placeholder"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@change="handleChange"
@keydown="handleKeydown"
/>
<span v-if="prefixIcon" class="r-input__prefix">
<i :class="prefixIcon"></i>
</span>
<span v-if="suffixIcon" class="r-input__suffix">
<!-- 第一种清空 不是清空 同时没有显示密码的 -->
<i :class="suffixIcon" v-if="!showClear && !showPwdVisible"></i>
<i
v-if="showClear"
class="r-icon-delete"
@click="clear"
@mousedown.prevent
></i>
<i
v-if="showPwdVisible"
class="r-icon-eye-close"
@click="handlePasswordVisible"
></i>
</span>
<!-- 后元素 -->
<div v-if="$slots.append" class="r-input-group__append">
<slot name="append"></slot>
</div>
</div>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# 表单样式
const classs = computed(() => [
"r-input",
{
"r-input-group": ctx.slots.prepend || ctx.slots.append,
"r-input--prefix": props.prefixIcon,
"r-input--suffix":
props.suffixIcon || props.clearable || props.showPassword,
},
]);
2
3
4
5
6
7
8
9
# 设置输入框值及更新icon位置
const input = ref<HTMLInputElement>(null);
const setNativeInputValue = () => {
const ele = input.value;
ele.value = String(props.modelValue);
};
const calcIconOffset = (place) => {
const { el } = instance.vnode;
let ele = el.querySelector(".r-input__" + place);
if (!ele) return;
const pendent = PENDANT_MAP[place];
if (ctx.slots[pendent]) {
ele.style.transform = `translateX(${place === "suffix" ? "-" : ""}${
el.querySelector(`.r-input-group__${pendent}`).offsetWidth
}px)`;
}
// 将前后icon 移动到 前位置或者后位置
};
onMounted(() => {
setNativeInputValue(); // 2.设置输入框的值
updateIconOffset(); // 3.更新icon位置
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# input
组件样式设定
@import "mixins/mixins";
@import "common/var";
// 正常r-input
@include b(input) {
// r-input
position: relative;
display: inline-block;
width: 100%;
@include e(suffix) {
position: absolute;
top: 0;
right: 5px;
}
@include m(suffix) { // 有icon 加padding
.r-input__inner {
padding-right: 30px;
}
}
@include m(prefix) {
.r-input__inner {
padding-left: 30px;
}
}
@include e(inner) { // 输入框 无样式 并且border-box
box-sizing: border-box;
-webkit-appearance: none;
outline: none;
width: 100%;
}
@include e(prefix) {
position: absolute;
left: 5px;
top: 0;
}
} // r-input__suffix
// 增加前后元素 变成网格 让前后元素 跑到一行去
@include b(input-group) {
display: inline-table;
.r-input__inner {
vertical-align: middle;
display: table-cell;
}
@include e(append) {
width: 1px;
display: table-cell;
white-space: nowrap;
padding: 0 15px;
}
@include e(prepend) {
width: 1px;
display: table-cell;
white-space: nowrap;
padding: 0 15px;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# icon显示设置
const showClear = computed( // 显示清除按钮
() =>
props.clearable &&
!props.readonly &&
!props.disabled &&
props.modelValue
);
const showPwdVisible = computed( // 显示密码按钮
() =>
props.showPassword &&
!props.disabled &&
!props.readonly &&
props.modelValue
);
const passwordVisible = ref(false);
const focus = () => {
nextTick(() => { // 切换后再次获取焦点
input.value.focus();
});
};
const handlePasswordVisible = () => { // 切换输入框显示内容
passwordVisible.value = !passwordVisible.value;
focus();
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 事件处理
const clear = () => { // 清除实现
ctx.emit("update:modelValue", "");
ctx.emit("change", "");
ctx.emit("clear");
};
const handleInput = (e) => { // 输入事件
let v = e.target.value;
ctx.emit("update:modelValue", v);
ctx.emit("input", v);
};
const handleFocus = (event) => { // 获取焦点
ctx.emit("focus", event);
};
const handleBlur = (event) => { // 处理失去焦点
ctx.emit("blur", event);
rformItem.formItemMitt?.emit("r.form.blur", [props.modelValue]);
};
const handleChange = (event) => { // 处理事件变化
ctx.emit("change", event.target.value);
};
const handleKeydown = (e) => { // 处理键盘
ctx.emit("keydown", e);
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 监控值变化更新输入框中内容
watch(
() => props.modelValue,
(val) => {
setNativeInputValue();
}
);
2
3
4
5
6
# 响应式attr实现
const instance = getCurrentInstance();
const attrs = ref({});
instance.attrs = reactive(instance.attrs);
watchEffect(() => {
// 监控attrs的变化 重新做赋值操作
const rest = {};
Object.entries(instance.attrs).forEach(([key, value]) => {
rest[key] = value;
});
attrs.value = rest;
});
2
3
4
5
6
7
8
9
10
11
12
# Form表单组件
# 表单组件
# Form
组件
表单组件用于接收表单数据和表单验证规则,没有任何实际功能
<template>
<form class="r-form">
<slot></slot>
</form>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
name:'RForm',
props:{
model:Object, // 表单数据
rules:Object // 校验规则
},
emits:['validate'] // 表单验证
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# FormItem
组件
用于实现表单验证逻辑
<template>
<div class="r-form-item">
<label v-if="label">{{ label }}</label>
<slot></slot>
<div class="r-form-item__error">
显示错误信息
</div>
</div>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
name:'RFormItem',
props: {
label: String,
prop: String,
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 数据交互实现
父组件提供数据给子组件,并收集
formItem
组件实例
const formMitt = mitt(); // 用于子组件触发事件
const rForm = reactive({
// 用于提供给子组件
...toRefs(props),
formMitt,
});
provide("RForm", rForm); // 暴露数据
const fields = []; // 用于收集子组件实例
formMitt.on("r.form.add", (field) => {
fields.push(field);
});
2
3
4
5
6
7
8
9
10
11
12
子组件将自己的validate方法传递给父组件。并监听输入框事件
const formItemMitt = mitt();
const rFormItem = reactive({
...toRefs(props),
formItemMitt,
validate, // 提供一个最核心的方法validate,好让父级可以调用到此方法
});
provide("RFormItem", rFormItem); // 提供给其他组件使用 checkbox,input
const rForm = inject("RForm", {} as RFormContext);
const getRules = (): any[] => {
// 获取规则 ,并且绑定事件
return rForm.rules[props.prop] as any[];
};
const addValidateEvents = () => {
// 绑定事件
const rules: any[] = getRules();
if (rules && rules.length > 0) {
formItemMitt.on("r.form.blur", onFieldBlur);
formItemMitt.on("r.form.change", onFieldChange);
}
};
onMounted(() => {
rForm.formMitt?.emit("r.form.add", rFormItem); // 将自己传递给父组件
addValidateEvents(); // 添加验证事件,子组件会触发这些事件
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 监控输入框中的内容
得到输入框中的内容进行校验
const validateState = ref("");
const validateMessage = ref("");
const getFilteredRule = (trigger) => {
// 根据触发方式获得对应的规则
const rules: any[] = getRules();
return rules
.filter((rule) => {
if (!rule.trigger || trigger == "") return true;
if (Array.isArray(rule.trigger)) {
return rule.trigger.indexOf(trigger) > -1; // 说明有
} else {
return rule.trigger === trigger;
}
}).map((rule) => ({ ...rule }));
};
const validate = (trigger, callback?) => {
const rules = getFilteredRule(trigger); // 获取过滤后的规则
if (!rules || rules.length == 0) return;
const descriptor = {};
rules.forEach((rule) => {
delete rule.trigger;
});
descriptor[props.prop] = rules;
// 校验器
const validator = new AsyncValidator(descriptor);
// 数据值
const model = {};
model[props.prop] = zForm.model[props.prop];
// 使用async-validate校验内容
validator.validate(model, {}, (errors: any, invalidFields: any) => {
validateState.value = !errors ? "success" : "error";
validateMessage.value = errors ? errors[0].message : "";
callback?.(validateMessage.value, invalidFields);
});
};
// 提供给儿子用
const onFieldBlur = () => {
validate("blur");
};
const onFieldChange = () => {
validate("change");
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# 暴露校验方法
const validate = (callback?) => {
// 这里需要触发所有子组件的校验方法
let valid = true;
let invalidFields = {};
let count = 0;
let promise: Promise<boolean> | undefined;
if (typeof callback !== "function") {
promise = new Promise((resolve, reject) => {
callback = function (valid, invalidFields) {
if (valid) resolve(true);
reject(invalidFields);
};
});
}
for (const field of fields) {
field.validate("", (message, field) => {
if (message) {
valid = false;
}
invalidFields = { ...invalidFields, ...field };
console.log(count);
if (++count === fields.length) {
callback(valid, invalidFields);
}
});
}
return promise;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# Transfer穿梭组件[要点]
# 定义组件属性
export type Key = string | number
export type DataItem = {
key: Key
label: string
disabled: boolean
}
export type Props = { // 别名
key: string, // key => id;
label: string, // label => desc
disabled: string // disabled => dis
}
// 穿梭框需要的数据
export interface TransferProps {
data: DataItem[], // 默认类型
modelValue: Key[], // 当前选中的是
props: Props // 可改名
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 定义组件结构
功能:左右panel和中间button结构
<template>
<div class="r-transfer">
<!-- 左边穿梭框 -->
<TransferPanel></TransferPanel>
<div class="r-transfer__buttons">
<r-button type="primary" icon="r-icon-arrow-left-bold"> </r-button>
<r-button type="primary" icon="r-icon-arrow-right-bold"> </r-button>
</div>
<!-- 左边穿梭框 -->
<TransferPanel></TransferPanel>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import TransferPanel from "./transfer-panel";
import RButton from "@r-ui/button";
export default defineComponent({
name: "RTransfer",
components: {
TransferPanel,
RButton,
},
props: {
data: {
type: Array as PropType<DataItem[]>,
default: () => [],
},
modelValue: {
type: Array as PropType<Key[]>,
default: () => [],
},
props: {
type: Object as PropType<Props>,
default: () => ({
label: "label",
key: "key",
disabled: "disabled",
}),
},
},
});
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
panel面板主要有:面板头部和面板体
<template>
<div class="r-transfer-panel">
<p class="r-transfer-panel__header">
列表
</p>
<div class="r-transfer-panel__body">
----
</div>
</div>
</template>
2
3
4
5
6
7
8
9
10
# 实现穿梭功能
# 左右面板数据进行拆分
<!-- 左边穿梭框 -->
<TransferPanel :data="sourceData" :props="props"></TransferPanel>
<!-- 左边穿梭框 -->
<TransferPanel :data="targetData" :props="props"></TransferPanel>
2
3
4
setup(props) {
// 1.计算 左右数据
const { propsKey, sourceData, targetData } = useComputedData(props);
return {
sourceData,
targetData,
};
},
2
3
4
5
6
7
8
根据所有数据和key,进行数据的筛查 useComputedData.ts
import { computed } from "@vue/runtime-core";
import { TransferProps } from "./transfer.type";
export const useComputedData = (props: TransferProps) => {
const propsKey = computed(() => props.props.key);
const dataObj = computed(() => {
return props.data.reduce((memo, cur) => {
memo[cur[propsKey.value]] = cur;
return memo
}, {}); // 根据key 映射原来的对象
});
// 通过key 进行数据筛选
const sourceData = computed(() => {
return props.data.filter(item => !props.modelValue.includes(item[propsKey.value]))
});
// 目标数据
const targetData = computed(() => {
return props.modelValue.reduce((arr, cur) => {
const val = dataObj.value[cur]; // 根据key 映射值,存放到数组中
if (val) {
arr.push(val)
}
return arr
}, []);
});
return {
propsKey,
sourceData,
targetData
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 面板渲染内容【TODO】
<template>
<div class="r-transfer-panel">
<p class="r-transfer-panel__header">
<r-checkbox></r-checkbox>
</p>
<div class="r-transfer-panel__body">
<r-checkbox-group class="r-transfer-panel__list">
<r-checkbox
class="r-transfer-panel__item"
v-for="item in data"
:key="item[keyProp]"
:label="item[keyProp]"
:disabled="item[disabledProp]"
> {{item[labelProp]}}
</r-checkbox>
</r-checkbox-group>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import ZCheckbox from "@r-ui/checkbox";
import ZCheckboxGroup from "@r-ui/checkbox-group";
import {useCheck} from './useCheck'
export default defineComponent({
components: {
ZCheckbox,
ZCheckboxGroup,
},
props: {
data: {
type: Array,
default: () => [],
},
props:{
type: Object as PropType<Props>
}
},
setup(props) {
const { labelProp, keyProp, disabledProp } = useCheck(props);
return {
labelProp,
keyProp,
disabledProp
}
},
});
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
获取数据信息
import { computed } from "@vue/runtime-core";
export interface TransferPanelProps {
data: any[]
props: Props
}
export const useCheck = (props: TransferPanelProps) => {
const labelProp = computed(() => props.props.label);
const keyProp = computed(() => props.props.key);
const disabledProp = computed(() => props.props.disabled);
return {
labelProp,
keyProp,
disabledProp
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 获取当前选中的值
<r-checkbox v-model="allChecked" @change="handleAllCheckedChange"></r-checkbox>
const panelState = reactive({
checked: [], // 选中的值
allChecked: false, // 是否全选
});
const updateAllChecked = () => {
const checkableDataKeys = props.data.map(item => item[keyProp.value]);
panelState.allChecked = checkableDataKeys.length > 0 && checkableDataKeys.every(item => panelState.checked.includes(item))
}
watch(() => panelState.checked, (val) => {
updateAllChecked(); // 更新全选状态
emit('checked-change',val)
});
const handleAllCheckedChange = (value: Key[]) => { // 更新checked
panelState.checked = value ? props.data.map(item => item[keyProp.value]) : []
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
每次选中的时候,将选中的结果传递给父组件
<TransferPanel
:data="sourceData"
:props="props"
@checked-change="onSourceCheckedChange"
></TransferPanel>
<TransferPanel
:data="targetData"
:props="props"
@checked-change="onTargetCheckedChange"
></TransferPanel>
const checkedState = reactive({
leftChecked: [],
rightChecked: [],
});
const onSourceCheckedChange = (val) => {
checkedState.leftChecked = val;
};
const onTargetCheckedChange = (val) => {
checkedState.rightChecked = val;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
将左右选中的内容分别存储到对应的数组中
# 穿梭实现
const addToLeft = () => { // 减少modelValue中的值
const currentValue = props.modelValue.slice();
checkedState.rightChecked.forEach((item) => {
const index = currentValue.indexOf(item);
if (index > -1) {
currentValue.splice(index, 1);
}
});
emit("update:modelValue", currentValue);
};
const addToRight = () => {
let currentValue = props.modelValue.slice(); // 给modelValue添加值
const itemsToBeMoved = props.data // 在所有数据中晒出选中的
.filter((item) =>
checkedState.leftChecked.includes(item[propsKey.value])
)
.map((item) => item[propsKey.value]);
currentValue = currentValue.concat(itemsToBeMoved);
console.log(currentValue)
emit("update:modelValue", currentValue);
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 修复Bug
# 穿梭后清空选中列表
watch(() => props.data, () => {
const checked = [];
panelState.checked = checked;
});
2
3
4
# 被禁用元素不支持穿梭
const checkableData = computed(() => {// 过滤禁用的数据
return props.data.filter(item => !item[disabledProp.value])
});
const updateAllChecked = () => { // 更新checkall
const checkableDataKeys = checkableData.value.map(item => item[keyProp.value]);
panelState.allChecked = checkableDataKeys.length > 0 && checkableDataKeys.every(item => panelState.checked.includes(item))
}
const handleAllCheckedChange = (value: Key[]) => { // 更新checked
panelState.checked = value ? checkableData.value.map(item => item[keyProp.value]) : []
}
2
3
4
5
6
7
8
9
10
# Collapse折叠菜单组件
# Collapse组件
<template>
<div class="r-collapse">
<slot></slot>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from "vue";
export default defineComponent({
name: "RCollapse",
props: {
accordion: Boolean, // 是否是手风琴模式
modelValue: { // 当前展开的值
type: [Array, String] as PropType<string | Array<string>>,
default:()=>[]
},
},
setup(props,{emit}){
return {}
}
});
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# CollapseItem组件
<template>
<div class="r-collapse-item">
<div class="r-collapse-item__header">
<slot name="title">{{title}}</slot>
</div>
<div class="r-collapse-item__content">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name: "RCollapseItem",
props: {
title: {
type: String,
default: "",
},
name: {
type: [String, Number],
default: () => Math.floor(Math.random() * 10000),
},
disabled: Boolean,
}
});
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
CollapseItem为折叠菜单中的每一项
@import "mixins/mixins";
@import "common/var";
@include b(collapse) {
border-top: 1px solid #ccc;
border-bottom: 1px solid #ccc;
font-size: 13px;
}
@include b(collapse-item) {
@include e(header) {
display: flex;
cursor: pointer;
line-height: 48px;
border-bottom: 1px solid #ccc;
}
@include e(content) {
padding-bottom: 25px;
border-bottom: 1px solid #ccc;
}
&:last-child {
margin-bottom: -1px;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 组件间跨级通信
父组件用于收集子组件的状态。 collapse.vue
import mitt from "mitt";
const activeNames = ref([].concat(props.modelValue)); // 当前激活的列表
const collapseMitt = mitt(); // 用于子类触发事件
watch(()=>props.modelValue,()=>{ // 值变化后,更新当前激活的activeNames
activeNames.value = [].concat(props.modelValue)
})
provide("collapse", { // 提供给子组件
activeNames,
collapseMitt
});
const handleItemClick = (name) => {}; // 监听子组件的点击事件
collapseMitt.on("item-click", handleItemClick);
2
3
4
5
6
7
8
9
10
11
12
13
14
子组件注入父组件提供的数据
<template>
<div class="r-collapse-item">
<div class="r-collapse-item__header" @click="handleHeaderClick">
<slot name="title">{{ title }}</slot>
</div>
<div class="r-collapse-item__content" v-show="isActive">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
import mitt, { Emitter } from "mitt";
import { defineComponent, inject, Ref } from "vue";
export default defineComponent({
name: "RCollapseItem",
props: {
title: {
type: String,
default: "",
},
name: {
type: [String, Number],
default: () => Math.floor(Math.random() * 10000),
},
disabled: Boolean,
},
setup(props) {
const collapse = inject<{ activeNames: Ref; collapseMitt: Emitter }>(
"collapse"
);
const handleHeaderClick = () => { // 点击时触发
if (props.disabled) return;
collapse.collapseMitt.emit("item-click", props.name);
};
const isActive = computed(() => { // 当前是否展开
return collapse.activeNames.value.includes(props.name);
});
return {
handleHeaderClick,
isActive,
};
},
});
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# 手风琴效果
const setValue = (_activeNames) => {
activeNames.value = [].concat(_activeNames);
const value = props.accordion ? activeNames.value[0] : activeNames.value;
emit("update:modelValue", value);
};
const handleItemClick = (name) => {
if (props.accordion) { // 手风琴效果
let v = activeNames.value[0] === name ? "" : name;
setValue(v);
} else { // 默认展开效果
let _activeNames = activeNames.value.slice(0);
const index = _activeNames.indexOf(name);
if (index > -1) {
_activeNames.splice(index, 1);
} else {
_activeNames.push(name);
}
setValue(_activeNames);
}
};
collapseMitt.on("item-click", handleItemClick);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Transition过渡动画组件
# 组件监听
<template>
<transition v-on="on">
<slot></slot>
</transition>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name: "RTransition",
setup() {
return {
on: {
beforeEnter() {
console.log("beforeEnter");
},
enter() {
console.log("enter");
},
afterEnter() {
console.log("afterEnter");
},
beforeLeave() {
console.log("beforeLeave");
},
leave() {
console.log("leave");
},
afterLeave() {
console.log("afterLeave");
},
},
};
},
});
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<template>
<transition v-on="on">
<slot></slot>
</transition>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
setup() {
return {
on: {
beforeEnter(el) { // 进入前 添加过度 设为初始状态
el.classList.add("collapse-transition");
el.style.height = 0;
},
enter(el) { // 设置结束状态
el.style.height = el.scrollHeight + "px";
el.style.overflow = "hidden";
},
afterEnter(el) { // 完成后删除样式
el.classList.remove("collapse-transition");
},
leave(el) { // 离开时 设置离开的目标
el.classList.add("collapse-transition");
el.style.height = 0;
},
afterLeave(el) { // 离开后删除样式即可
el.classList.remove("collapse-transition");
}
}
};
}
});
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 结合Collapse组件使用
# 通过js控制动画
<RTransition>
<div v-show="isActive">
<div class="r-collapse-item__content">
<slot></slot>
</div>
</div>
</RTransition>
2
3
4
5
6
7
# InfiniteScroll无限滚动组件
# 无限滚动组件注册方式
import { App } from 'vue'
import InfiniteScroll from './src/index'
(InfiniteScroll as any).install = (app: App): void => {
app.directive('InfiniteScroll', InfiniteScroll)
}
export default InfiniteScroll
2
3
4
5
6
# 滚动指令实现
# 指令定义
import { ComponentPublicInstance, ObjectDirective } from "vue";
type InfiniteScrollCallback = () => void;
type InfiniteScroll = HTMLElement & {
'infinite': {
container: HTMLElement // 滚动容器
delay: number // 延迟时间
cb: InfiniteScrollCallback // 触发的滚动方法
onScroll: () => void // 滚动时触发的回调
observer?: MutationObserver, // 用于监控高度不够时增加数据
instance:ComponentPublicInstance // 绑定到哪个组件实例
}
}
const InfiniteScroll: ObjectDirective<InfiniteScroll, InfiniteScrollCallback> = {
mounted(el,bindings){
const { value: cb, instance } = bindings;
await nextTick(); // 保证父元素加载完成
},
unmounted(el){}
}
export default InfiniteScroll;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 获取指令参数
let { delay, immediate } = getScrollOptions(el, instance);
获取无限滚动信息
const attributes = {
delay: { // 节流延迟
type: Number,
default: 200
},
distance: { // 触底距离
type: Number,
default: 0
},
disabled: { // 是否禁用
type: Boolean,
default: false
},
immediate: { // 立即撑满内容
type: Boolean,
default: true
}
}
type Attrs = typeof attributes;
type ScrollOtions = { [K in keyof Attrs]: Attrs[K]['default'] }
const getScrollOptions = (el:HTMLElement, instance:ComponentPublicInstance):ScrollOtions => {
return Object.entries(attributes).reduce((memo, [name, option]) => {
const { type, default: defaultValue } = option;
const attrVal = el.getAttribute(`infinite-scroll-${name}`);
let value = instance[attrVal] ?? attrVal ?? defaultValue;
value = value == 'false' ? false : value;
value = type(value);
memo[name] = value;
return memo;
}, {} as ScrollOtions)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 获取滚动容器
const container = getScrollContainer(el);
获取overflow 具备 scroll | auto的属性
const isScroll = (el: HTMLElement): RegExpMatchArray => {
let scrollY = getComputedStyle(el, '');
let overflow: string = scrollY['overflow-y'] || '';
return overflow.match(/(scroll|auto)/);
}
const getScrollContainer = (el: HTMLElement) => {
let parent = el;
while (parent) {
if (parent == document.documentElement) {
return document.documentElement
}
if (isScroll(parent)) {
return parent;
}
parent = parent.parentNode as HTMLElement;
}
return parent
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 定义scroll事件
const handleScroll = (el: InfiniteScroll, cb: InfiniteScrollCallback) => {
// 稍后实现滚动逻辑
}
const onScroll = throttle(() => handleScroll(el, cb), delay);
2
3
4
# 自动填充容器
if(immediate){
const observer = new MutationObserver(throttle(() => checkFull(el, cb), 100))
observer.observe(el, { childList: true, subtree: true });
el.infinite.observer = observer
checkFull(el, cb);
}
2
3
4
5
6
const checkFull = (el: InfiniteScroll, cb: InfiniteScrollCallback) => {
const { container ,instance} = el.infinite;
const { disabled } = getScrollOptions(el,instance);
if (disabled) return;
if (container.scrollHeight <= container.clientHeight) {
cb();
} else {
let ob = el.infinite.observer;
ob && ob.disconnect();
delete el.infinite.observer
}
}
2
3
4
5
6
7
8
9
10
11
12
# 滚动检测
container.addEventListener('scroll', onScroll)
const handleScroll = (el: InfiniteScroll, cb: InfiniteScrollCallback) => {
const {
container,
observer,
instance
} = el.infinite
const { disabled, distance } = getScrollOptions(el, instance);
const { clientHeight, scrollHeight, scrollTop } = container
let shouldTrigger = false;
if (observer || disabled) return;
if (container == el) {
shouldTrigger = scrollHeight - (clientHeight + scrollTop) <= distance
} else {
const { clientTop, scrollHeight: height } = el;
// 卷去的高度 + 可视区域 自己距离父亲的高度 + 自己的所有高度 - 距离
// 有可能定位 父距离顶部距离 - 自己离顶部的距离
shouldTrigger = scrollTop + clientHeight >= 0 + clientTop + height - distance
}
if (shouldTrigger) {
cb();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 指令卸载
移除监听事件
const { container, onScroll } = el.infinite;
container.removeEventListener('scroll', onScroll);
2