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
1
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' }
                ]
            ]
        }
    }
}
1
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')
        })
    ]
}
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

# 构建入口

最内引用文档构建:

"scripts": {
    "doc": "webpack serve --config ./website/webpack.config.js"
}
1
2
3

配置运行命令:后续可以采用 "doc" 运行文档来预览组件效果

import {createApp} from 'vue';
import RUI from 'rat-ui';
import App from './App.vue'
createApp(App).use(RUI).mount('#app'); // 入口文件中使用组件即可
1
2
3
4

# 组件库初始化

# monorepo项目初始化

$ yarn global add lerna
$ lerna init
1
2

lerna.json

{
    "packages": [
        "packages/*"
    ],
    "version": "0.0.0",
    "npmClient": "yarn", // 使用yarn管理
    "useWorkspaces": true // 使用workspace,需要配置package.json
}
1
2
3
4
5
6
7
8

package.json

{
    "name": "root",
    "private": true,
    "workspaces": [
        "packages/*"
    ],
    "devDependencies": {
        "lerna": "^3.22.1"
    }
}
1
2
3
4
5
6
7
8
9
10

# 初始化组件

$ lerna create button
$ lerna create icon
1
2
├─button
│  │  package.json
│  │  README.md
│  ├─src
|  ├─  button.vue
│  ├─index.ts # 组件入口
│  └─__tests__ # 测试相关
└─icon
    │  package.json
    │  README.md
    ├─src
    ├─  icon.vue
    ├─index.ts # 组件入口
    └─__tests__ 
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# tsconfig生成

yarn add typescript -W
npx tsc --init
1
2
{
  "compilerOptions": {
    "target": "ESNext", // 打包的目标语法
    "module": "ESNext", // 模块转化后的格式
    "esModuleInterop": true, // 支持模块转化
    "skipLibCheck": true, // 跳过类库检测
    "forceConsistentCasingInFileNames": true, // 强制区分大小写
    "moduleResolution": "node", // 模块解析方式
    "jsx": "preserve", // 不转化jsx
    "declaration": true, // 生成声明文件
    "sourceMap": true // 生成映射文件
  }
}
1
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属性,所引引用时会出问题
1
2
3

# 组件初始化

$ yarn add vue@next -W
1

# 编写组件入口及出口

<template>
  <button> 按钮 </button>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
  name: "RButton",
});
</script>
1
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>
1
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;
1
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;
}
1
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方法
}
1
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"
1
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(),
    ]
}
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

通过组件库入口进行打包

"build": "webpack --config ./build/webpack.config.js"
1

# 打包esModule格式组件库

使用rollup进行打包,安装所需依赖

yarn add rollup rollup-plugin-typescript2 @rollup/plugin-node-resolve rollup-plugin-vue -D
1

# 全量打包

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)
        },
    }
})
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

# 按组件打包

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)
    },
}
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

组件为了能单独使用,增加声明

type IWithInstall<T> = T & { install(app: App): void; } // 携带install方法
const _Button:IWithInstall<typeof Button> = Button;
export default _Button;
1
2
3

# 组件样式处理

theme-chalk项目

# 使用gulp打包scss文件

安装gulp, 打包样式

yarn add gulp gulp-autoprefixer gulp-cssmin gulp-dart-sass gulp-rename -D
1
│ button.scss
│ icon.scss
├─common
│    var.scss # 提供scss变量
├─fonts # 字体
└─mixins
     config.scss # 提供名字
     mixins.scss # 提供mixin方法
  index.scss  # 整合所有scss
1
2
3
4
5
6
7
8
9

mixins/config.scss

$namespace:'r'; // 修饰命名空间
$state-prefix: 'is-';// 修饰状态
$modifier-separator:'--'; // 修饰类型的
$element-separator: '__'; // 划分空间分隔符
1
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;
1
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;
        }
    }
}
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

# 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)
1
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');
1
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;
}
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

# 实现对应组件编写

<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>
1
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'
1
<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>
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
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)
    }
}
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

提供scss的辅助方法,方便后续使用

@mixin button-variant($color, $background-color, $border-color) {
    color: $color;
    background: $background-color;
    border-color: $border-color;
}
1
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>
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

# 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
1
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;
        }
    }
}
1
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>
1
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?.())
    }
});
1
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?.())
    }
})
1
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?.())
    }
})
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

# 设置列宽

row.scss

@import "common/var";
@import "mixins/mixins";
@include b(row){
    display: flex;
    flex-wrap: wrap;
}
1
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%;
    }
}
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
})
1
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?.())
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

保证padding不影响宽度

[class*="#{$namespace}-col-"] {
    box-sizing: border-box;
}
1
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)
);
1
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;
        }
    }
}
1
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%;
    }
  }
}
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]}`)
    }
})
1
2
3
4
5
6

# 对其方式

row.ts

const classs = computed(() => [
    'r-row',
    props.justify !== 'start' ? `is-justify-${props.justify}` : '',
])
1
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;
    }
}
1
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 // 是否选中
}
1
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>
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

# 实现checkbox双向绑定

<input
       type="checkbox"
       :value="label"
       :disabled="disabled"
       v-model="model"  // 双向绑定的字段
       :checked="isChecked" // input的状态
       @change="handleChange" // 用于将checked字段传递给用户
/>
1
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
    }
}
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

# 实现半选功能[TODO]

const inputRef = ref<HTMLInputElement>(null)
function indeterminate(val) {
     inputRef.value.indeterminate = val;
}
watch(() => props.indeterminate, indeterminate)
onMounted(()=>{ // 默认加载完毕后
    indeterminate(props.indeterminate)
});
1
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>
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

注入实例时编写实例类型

export interface ICheckboxGroupInstance {
    modelValue?: ComputedRef, // 绑定的值
    name?: string
    changeEvent?: (...args: any[]) => any // 修改事件
}
1
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
}
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

# 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属性
  },
1
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>
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

# 表单样式

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,
    },
]);
1
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位置
});
1
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;
  }
}
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

# 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();
};
1
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);
};
1
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();
    }
);
1
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;
});
1
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>
1
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>
1
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);
});
1
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(); // 添加验证事件,子组件会触发这些事件
});
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

# 监控输入框中的内容

得到输入框中的内容进行校验

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");
};
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

# 暴露校验方法

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;
};
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

# 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       // 可改名
}
1
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> 
      &nbsp;
      <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>
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

panel面板主要有:面板头部和面板体

<template>
  <div class="r-transfer-panel">
    <p class="r-transfer-panel__header">
        列表
    </p>
    <div class="r-transfer-panel__body">
       ----
    </div>
  </div>
</template>
1
2
3
4
5
6
7
8
9
10

# 实现穿梭功能

# 左右面板数据进行拆分

<!-- 左边穿梭框 -->
<TransferPanel :data="sourceData" :props="props"></TransferPanel>
<!-- 左边穿梭框 -->
<TransferPanel :data="targetData" :props="props"></TransferPanel>
1
2
3
4
setup(props) {
    // 1.计算 左右数据
    const { propsKey, sourceData, targetData } = useComputedData(props);
    return {
      sourceData,
      targetData,
    };
},
1
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
    }
}
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

# 面板渲染内容【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>
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

获取数据信息

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
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 获取当前选中的值

<r-checkbox v-model="allChecked"  @change="handleAllCheckedChange"></r-checkbox>
1
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]) : []
}
1
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;
};
1
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);
};
1
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;
});
1
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]) : []
}
1
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>
1
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>
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

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;
    }
}
1
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);
1
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>
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

# 手风琴效果

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);
1
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>
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
<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>
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

# 结合Collapse组件使用

# 通过js控制动画
<RTransition>
    <div v-show="isActive">
        <div class="r-collapse-item__content">
            <slot></slot>
        </div>
    </div>
</RTransition>
1
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
1
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;
1
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);
1

获取无限滚动信息

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)
}
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

# 获取滚动容器

const container = getScrollContainer(el);
1

获取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
}
1
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);
1
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);
}
1
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
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

# 滚动检测

container.addEventListener('scroll', onScroll)
1
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();
    }
}
1
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);
1
2
上次更新: 2022/04/15, 05:41:28
×