vue3复习-组件库实现/单元测试

158 阅读8分钟

1.组件库

组件库组成:

  • 基础组件
  • 输入表单组件
  • 数据组件
  • 通知组件

2.基础组件

布局/颜色/字体

container组件

  • <script setup >无法设置name,多定义一个 <script>,只设置option
  • defineProps定义porps类型
  • withDefaults 设置参数默认值
  • useSlots显示默认插槽

Container.vue


<template>
    <section
      class="el-container"
      :class="{ 'is-vertical': isVertical }"
    >
      <slot />
    </section> 
  </template>
  <script lang="ts">
  export default{
    name:'ElContainer'
  }
  </script>
  <script setup lang="ts">
  import {useSlots,computed,VNode,Component} from 'vue'
  interface Props {
    direction?:string
  }
  const props = defineProps<Props>()
  const slots = useSlots()
  const isVertical = computed(() => {
    if (slots && slots.default) {
      return slots.default().some((vn:VNode) => {
        const tag = (vn.type as Component).name
        return tag === 'ElHeader' || tag === 'ElFooter'
      })
    } else {
      if (props.direction === 'vertical') {
        return true
    } else  {
        return false
      }
    }
  })
  </script>
<style lang="scss">
@import '../styles/mixin';
@include b(container) {
  display: flex;
  flex-direction: row;
  flex: 1;
  flex-basis: auto;
  box-sizing: border-box;
  min-width: 0;
  @include when(vertical) {
    flex-direction: column;
  }
}
</style>

Header.vue

<template>
  <header
    class="el-header"
    :style="{ height }"
  >
    <slot />
  </header>
</template>
<script lang="ts">
export default{
  name:'ElHeader'
}
</script>
<script setup lang="ts">
import {withDefaults} from 'vue'
interface Props {
  height?:string
}
withDefaults(defineProps<Props>(),{
  height:"60px"
})
</script>
<style lang="scss">
@import '../styles/mixin';
@include b(header) {
  padding: $--header-padding;
  box-sizing: border-box;
  flex-shrink: 0;
}
</style>

index.ts

import {App} from 'vue'
import ElContainer from './Container.vue'
import ElHeader from './Header.vue'

export default {
    install(app:App){
        app.component(ElContainer.name,ElContainer)
        app.component(ElHeader.name,ElHeader)
    }
}

src\components\styles\mixin.scss

$namespace: 'el';
$state-prefix: 'is-';
$--header-padding: 10px;
@mixin b($block) {
  $B: $namespace + '-' + $block !global;
  .#{$B} {
    @content;
  }
}
// 添加ben后缀啥的
@mixin when($state) {
  @at-root {
    &.#{$state-prefix + $state} {
      @content;
    }
  }
}

3.单元测试

mini-jest

function add(x,y){
    return Number(x) + Number(y)
}

function expect(result){
    return {
        toBe(arg){ 
            if(arg !== result) {
                throw new Error(`输入与预期不符,预期:${arg},实际:${result}`)
            }
        }
    }
}

function test(title,fn){
    try {
        fn()
        console.log("测试通过")
    } catch (error) {
        console.log(error)
        console.log("测试不通过")
    }
}


test('单元测试1', ()=> {
    expect(add(1,'2')).toBe(3)
})

jest

安装

npm install -D jest@26 vue-jest@next @vue/test-utils@next 
npm install -D babel-jest@26 @babel/core @babel/preset-env 
npm install -D ts-jest@26 @babel/preset-typescript @types/jest

.babel.config.js

module.exports = {
  presets: [
    ['@babel/preset-env', { targets: { node: 'current' } }],
    '@babel/preset-typescript',
  ],
}

jest.config.js

module.exports = {
  transform: {
    // .vue文件用 vue-jest 处理
    '^.+\\.vue$': 'vue-jest',
    // .js或者.jsx用 babel-jest处理
    '^.+\\.jsx?$': 'babel-jest', 
    //.ts文件用ts-jest处理
    '^.+\\.ts$': 'ts-jest'
  },
  testMatch: ['**/?(*.)+(spec).[jt]s?(x)'],
    collectCoverage: true,
    coverageReporters: ["json", "html"],
}

package.json

"scripts": {
    "test": "jest"
}

第三方jest插件

安装

npm i @vue/test-utils -D

Button.vue

<template>
  <button
    class="el-button" 
    :class="[
      buttonSize ? `el-button--${buttonSize}` : '',
      type ? `el-button--${type}` : ''
    ]"
  >
    <slot />
  </button>
</template>
<script lang="ts">
export default{
  name:'ElButton'
}
</script>
<script setup lang="ts">
import {computed, withDefaults} from 'vue'
import { getCurrentInstance } from 'vue'

function useGlobalOptions() {
  const instance = getCurrentInstance()

  if (!instance) {
    console.warn('useGlobalOptions must be call in setup function')
    return
  }

  return instance.appContext.config.globalProperties.$ELEMENT || {}
}

 
interface Props {
  size?:""|'small'|'medium'|'large',
  type?:""|'primary'|'success'|'danger'
}
const props = withDefaults(defineProps<Props>(),{
  size:"",
  type:""
})
const globalConfig = useGlobalOptions()
const buttonSize = computed(()=>{
  return props.size||globalConfig.size
})
</script>

 

Button.spec.js


import Button from './Button.vue';
import { mount } from '@vue/test-utils';
describe('按钮测试', () => {
    it('测试能够显示文本', () => {
        const content = 'test';
        const wrapper = mount(Button, {
            slots: {
                default: content
            }
        });
        expect(wrapper.text()).toBe(content);
    });
    it('通过size属性控制大小', () => {
        const size = 'medium';
        const wrapper = mount(Button, {
            props: {
                size
            }
        });
        // size内部通过class控制
        console.log('classess',wrapper.classes());
        expect(wrapper.classes()).toContain(`el-button--${size}`);
    });  
});

4.表单

form设计思路

  • jform
    • 整体的from数据管理
    • 所有的规则rules,并触发所有el-form-item的校验事件
    • 提交方法
  • el-form-item
    • 每个输入框单独的校验规则
  • el-input
    • 实际录入的组件

常用使用格式

  <el-form :model="ruleForm" :rules="rules" ref="form">
    <el-form-item label="用户名" prop="username">
      <el-input v-model="ruleForm.username"></el-input>
      <!-- <el-input :model-value="" @update:model-value=""></el-input> -->
    </el-form-item>
    <el-form-item label="密码" prop="passwd">
      <el-input type="textarea" v-model="ruleForm.passwd"></el-input>
    </el-form-item>
    <el-form-item>
      <el-button type="primary" @click="submitForm()">登录</el-button>
    </el-form-item>
  </el-form>

相关技术点

  • async-validator 实现校验
  • defineExpose 父组件直接访问子组件内容

demo

JForm.vue

<template>
  <form>
    <slot></slot>
  </form>
</template>
<script lang="ts" setup>
import { provide, reactive, toRefs, getCurrentInstance } from "vue";
import {formKey} from './type'
interface Props {
  model: Object,
  rules: Object,
}
const props = defineProps<Props>()

const { proxy } = getCurrentInstance();
const itemList = [];

function validate(cb){
  const tasks = itemList.map((item) => { 
    return item.validate()
  });
  Promise.all(tasks)
    .then(() => cb(true))
    .catch(() => {
      console.log("catch-false");
      cb(false);
    });
};
 
proxy.$mitt.on("JForm.addField", (field) => { 
   
    itemList.push(field);
});
 
provide(formKey, props); 

//<script setup>如果想被父组件直接调用私有方法,需要 expose导出
defineExpose({
  validate, 
})
</script>

JFormItem.vue

<template>
  <div class="j-form-item">
    <label for="">
      {{ label }}
    </label>
    <slot></slot>
    <p class="errors">
      {{ error }}
    </p>
  </div>
</template>
<script setup lang="ts">
import Schema from "async-validator";
import {formKey} from './type'
import {
  reactive,
  onMounted,
  ref,
  toRefs,
  provide,
  inject,
  getCurrentInstance,
} from "vue";

interface Props {
  label:string,
  prop:string,
}
const props = defineProps<Props>() 
    const { proxy } = getCurrentInstance();
    let error = ref();
 
    const jForm = inject(formKey);//这里注入 form的总对象

    const validate = () => {  
      if (!props.prop) return; 
      const rules = jForm.rules[props.prop];
      const value = jForm.model[props.prop];
      const validator = new Schema({ [props.prop]: rules });
      // 返回promise,全局可以统一处理
      return validator.validate({ [props.prop]: value }, (errors) => {
        // errors存在则校验失败
        console.log("err", errors);
        if (errors) {
          error.value = errors[0].message;
        } else {
          // 校验通过
          error.value = "";
        }
      });
    }; 
    const jFormItem = reactive({
      ...toRefs(props),
      validate,
    });
    provide("jFormItem", props);

    onMounted(() => { 
      proxy.$mitt.on("JFormItem.validate", validate);//留给子组件调用 
      if (props.prop) {  
        proxy.$mitt.emit("JForm.addField", jFormItem);//每注册一个组件 ,都回传到总form里面统一管理
      }
    });
 
</script>
<style scoped>
.errors {
  color: red;
  font-size: 12px;
}
</style>

JInput.vue

<template>
  <input type="text" :value="modelValue" @input="handleChange" />
</template>

<script lang="ts">
export default {
  name: "j-input"
}
</script>

<script setup lang="ts">
import { getCurrentInstance } from "vue";

interface Props {
  modelValue: String
}

const props = defineProps<Props>()
const emits = defineEmits('update:modelValue')
const { proxy } = getCurrentInstance();
const handleChange = (e) => {
  emits("update:modelValue", e.target.value);
  proxy.$mitt.emit("JFormItem.validate");
};

const handleBlur = () => {
  console.log("blur");
}; 
</script>

FormTest.vue

<template>
  <div>
    <h2>表单form组件</h2>
    <j-form ref="form" :model="formData" :rules="rules">
      <j-form-item label="名称" prop="name">
        <j-input v-model="formData.name"></j-input>
      </j-form-item>
      <j-form-item label="邮箱" prop="email">
        <j-input v-model="formData.email"></j-input>
      </j-form-item>
      <j-form-item>
        <button @click="onSubmit">提交</button>
      </j-form-item>
    </j-form>
    <hr />
  </div>
</template>
<script>
import { reactive, getCurrentInstance } from "vue";

import JInput from "./jform/JInput.vue";
import JForm from "./jform/JForm.vue";
import JFormItem from "./jform/JFormItem.vue";
export default {
  components: {
    JForm,
    JFormItem,
    JInput,
  },
  setup() {
    const formData = reactive({ name: "" ,email: ""});
    const rules = {
      name: [{ required: "true", message: "请输入名称" }],
      email: [{ required: "true", message: "请输入邮箱" }],
    };
    const { proxy } = getCurrentInstance();
    const onSubmit = (e) => {
      e.preventDefault(); 
      proxy.$refs.form.validate((valid) => {
        console.log("valid", valid);
      });
    };
    return { formData, rules, onSubmit };
  },
};
</script>

type.ts 用于provide与inject类型推到

import {InjectionKey,ref} from 'vue'
export type FormData = {
    model:Object,
    rules:Object
}
export const formKey:InjectionKey<FormData> = Symbol('')

plugins/emitter.ts

import mitt from "mitt";
export default {
  install(Vue) {
    const _emitter = mitt();
    // 全局发布(在Vue全局方法中自定义$pub发布方法)
    // 这里做了$pub方法能够携带多个参数的处理,方便我们再业务中触发事件时带多个参数
    Vue.config.globalProperties.$mitt = _emitter
  },
};

//使用
app.use(emitter)
app.use(emitter)

5.弹框

一般用法

<el-dialog
  title="提示"
  :visible.sync="dialogVisible"
  width="30%"
  v-model:visible="dialogVisible"
>
  <span>这是一段信息</span>
  <template #footer>
    <span class="dialog-footer">
      <el-button @click="dialogVisible = false">取 消</el-button>
      <el-button type="primary" @click="dialogVisible = false">确 定</el-button>
    </span>
  </template>
</el-dialog>

动态创建组件

<template>
  <el-button plain @click="open1"> 成功 </el-button>
  <el-button plain @click="open2"> 警告 </el-button>
</template>
<script setup>
  import { Notification } from 'element3'
  function open1() {
    Notification.success({
      title: '成功',
      message: '这是一条成功的提示消息',
      type: 'success'
    })
  }
  function open2() {
    Notification.warning({
      title: '警告',
      message: '这是一条警告的提示消息',
      type: 'warning'
    })
  }
</script>

开始搭建

<teleport
    :disabled="!appendToBody"
    to="body"
  >
    <div class="el-dialog">
      <div class="el-dialog__content">
        <slot />
      </div>
    </div>
  </teleport>

添加用例


it('函数会创建组件', () => {
  const instanceProxy = Notification('foo')
  expect(instanceProxy.close).toBeTruthy()
})
it('默认配置 ', () => {
  const instanceProxy = Notification('foo')
  expect(instanceProxy.$props.position).toBe('top-right')
  expect(instanceProxy.$props.message).toBe('foo')
  expect(instanceProxy.$props.duration).toBe(4500)
  expect(instanceProxy.$props.verticalOffset).toBe(16)
})
test('字符串信息', () => {
  const instanceProxy = Notification.info('foo')
  expect(instanceProxy.$props.type).toBe('info')
  expect(instanceProxy.$props.message).toBe('foo')
})
test('成功信息', () => {
  const instanceProxy = Notification.success('foo')
  expect(instanceProxy.$props.type).toBe('success')
  expect(instanceProxy.$props.message).toBe('foo')
})

原理

  • install 方法 用于全局注册 $message('xxx') 和 组件 message.vue的注册
  • 调用直接使用 $message('xxx') 

demo

src\components\message\index.ts 用于全局注册

import { Plugin, App } from 'vue';
import Message from './message.vue';
import message from './message.js';

// 挂载组件方法
const install = (app: App): App => {
  // 
  app.config.globalProperties.$message = message;
  app.component(Message.name, Message);
  return app;
};

export default install as Plugin;

src\components\message\message.js

import { createApp, h ,render} from "vue";
import Message from "./message.vue";


const MOUNT_COMPONENT_REF = 'el_component'
const COMPONENT_CONTAINER_SYMBOL = Symbol('el_component_container')

/**
 * 创建组件实例对象
 * 返回的实例和调用 getCurrentComponent() 返回的一致
 * @param {*} Component
 */
export function createComponent(Component, props, children) {
  const vnode = h(Component, { ...props, ref: MOUNT_COMPONENT_REF }, children)
  const container = document.createElement('div')
  vnode[COMPONENT_CONTAINER_SYMBOL] = container
  render(vnode, container)
  return vnode.component
}

/**
 * 销毁组件实例对象
 * @param {*} ComponnetInstance 通过createComponent方法得到的组件实例对象
 */
export function unmountComponent(ComponnetInstance) {
  render(undefined, ComponnetInstance.vnode[COMPONENT_CONTAINER_SYMBOL])
}

function createNotificationByOpts(opts) { 
  return createComponent(Message, opts)
}

const message = (props) => { 
  const instance = createNotificationByOpts(props)
  document.body.append(instance.vnode.el)
  return instance.proxy
};

export default message

src\components\message\message.vue

<template>
  <div class="message" v-if="isShow">
    <p>
      {{ message }}
    </p>
    <button @click="close">关闭弹窗</button>
  </div>
</template>

<script lang="ts" setup>
import { ref, defineProps, onMounted, withDefaults } from "vue";
interface Props {
  message: string,
  duration: number
}
const props = withDefaults(defineProps<Props>(), {
  message: '',
  duration: 3,
})
let isShow = ref(false);

const show = () => {
  console.log("duration", props.duration);
  isShow.value = true;
  setTimeout(() => {
    isShow.value = false;
  }, props.duration * 1000 || 10000000);
};

const close = () => {
  isShow.value = false;
};
onMounted(() => {
  show();
})
</script>

main.ts


import message from './components/message';
createApp(App).use(message).mount('#app');

MessageTest.vue

<template>
  <div> 
    <button @click="handleOpen">测试显示</button> 
  </div>
</template>
<script lang="ts" setup>
import { getCurrentInstance } from "vue";
const { proxy } = getCurrentInstance();
const handleOpen = () => {
  const component = proxy.$message({
    message: "xxxxx",
  });
  setTimeout(() => {
    component.close();
  }, 2000);
}; 
</script>

创建一个空组件

<template>
  <div class="el-nofication">
    <slot />
  </div>
</template>
<script>
</script>
<style lang="scss">
@import '../styles/mixin';
</style>

测试用例驱动开发


import Notification from "./Notification.vue"
import { mount } from "@vue/test-utils"
describe("Notification", () => { 
  
  it('渲染标题title', () => {
    const title = 'this is a title'
    const wrapper = mount(Notification, {
      props: {
        title
      }
    })
    expect(wrapper.get('.el-notification__title').text()).toContain(title)
  })
  it('信息message渲染', () => {
    const message = 'this is a message'
    const wrapper = mount(Notification, {
      props: {
        message
      }
    })
    expect(wrapper.get('.el-notification__content').text()).toContain(message)
  })
  it('位置渲染', () => {
    const position = 'bottom-right'
    const wrapper = mount(Notification, {
      props: {
        position
      }
    })
    expect(wrapper.find('.el-notification').classes()).toContain('right')
    expect(wrapper.vm.verticalProperty).toBe('bottom')
    expect(wrapper.find('.el-notification').element.style.bottom).toBe('0px')
  })
  it('位置偏移', () => {
    const verticalOffset = 50
    const wrapper = mount(Notification, {
      props: {
        verticalOffset
      }
    })
    expect(wrapper.vm.verticalProperty).toBe('top')
    expect(wrapper.find('.el-notification').element.style.top).toBe(
      `${verticalOffset}px`
    )
  })
})

完善后的代码


<template>
  <div class="el-notification" :style="positionStyle" @click="onClickHandler">
    <div class="el-notification__title">
      {{ title }}
    </div>
    <div class="el-notification__message">
      {{ message }}
    </div>
    <button
      v-if="showClose"
      class="el-notification__close-button"
      @click="onCloseHandler"
    ></button>
  </div>
</template>
<script setup>
const instance = getCurrentInstance()
const visible = ref(true)
const verticalOffsetVal = ref(props.verticalOffset)
const typeClass = computed(() => {
  return props.type ? `el-icon-${props.type}` : ''
})
const horizontalClass = computed(() => {
  return props.position.endsWith('right') ? 'right' : 'left'
})
const verticalProperty = computed(() => {
  return props.position.startsWith('top') ? 'top' : 'bottom'
})
const positionStyle = computed(() => {
  return {
    [verticalProperty.value]: `${verticalOffsetVal.value}px`
  }
})
</script>
<style lang="scss">
.el-notification {
  position: fixed;
  right: 10px;
  top: 50px;
  width: 330px;
  padding: 14px 26px 14px 13px;
  border-radius: 8px;
  border: 1px solid #ebeef5;
  background-color: #fff;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  overflow: hidden;
}
</style>

6.通知

demo2-jest

Notification.spec.ts


import Notification from './Notification.vue';
import { mount } from '@vue/test-utils';
  
describe("Notification", () => { 
  
  it('测试标题title', () => {
    const title = 'this is a title'
    const wrapper = mount(Notification, {
      props: {
        title
      }
    })
    expect(wrapper.get('.el-notification__title').text()).toContain(title)
  })
  it('测试内容', () => {
    const message = 'this is a message'
    const wrapper = mount(Notification, {
      props: {
        message
      }
    })
    expect(wrapper.get('.el-notification__content').text()).toContain(message)
  })
  it('测试渲染位置', () => {
    const position = 'bottom-right'
    const wrapper = mount(Notification, {
      props: {
        position
      }
    })
    expect(wrapper.find('.el-notification').classes()).toContain('right')
    expect(wrapper.vm.verticalProperty).toBe('bottom')
    const el = <HTMLElement>wrapper.find('.el-notification').element
    expect(el.style.bottom).toBe('0px')
  })
  it('测试位置偏移', () => {
    const verticalOffset = 50
    const wrapper = mount(Notification, {
      props: {
        verticalOffset
      }
    })
    expect(wrapper.vm.verticalProperty).toBe('top') 
    const el = <HTMLElement>wrapper.find('.el-notification').element
    expect(el.style.top).toBe( `${verticalOffset}px` )
  })


  it('测试按钮参数', () => {
    const showClose = false
    const wrapper = mount(Notification, {
      props: {
        showClose
      }
    })
    expect(wrapper.find('.el-notification__close-button').exists()).toBe(showClose)
    // expect(wrapper.find('.el-icon-close').exists()).toBe(true)
  })
  it('测试点击关闭按钮', async () => {
    const showClose = true
    const wrapper = mount(Notification, {
      props: {
        showClose
      }
    })
    const closeBtn = wrapper.get('.el-notification__close-button')
    await closeBtn.trigger('click')
    expect(wrapper.get('.el-notification').isVisible()).toBe(false)
  })
  it('测试一段时间之后自动关闭', async () => {
    jest.useFakeTimers()
    const wrapper = mount(Notification, {
      props: {
        duration: 8000
      }
    })
    const flushPromises = () => new Promise(resolve => setImmediate(resolve));
    jest.runOnlyPendingTimers() 
    await flushPromises()
    expect(wrapper.get('.el-notification').isVisible()).toBe(false)
     })

})

Notification.vue

<template>
  <div v-show="visible" :class="['el-notification',verticalProperty,horizontalClass]" :style="positionStyle"
    @click="onClickHandler">
    <div class="el-notification__title">
      {{ title }}
    </div>
    <div class="el-notification__content">
      {{ message }}
    </div>
    <button v-if="showClose" class="el-notification__close-button" @click="onCloseHandler"></button>
  </div>
</template>
<script setup lang="ts">
import { getCurrentInstance, ref, computed, withDefaults } from 'vue'
const instance = getCurrentInstance()
const visible = ref(true)

interface Props {
  title: string,
  message: string,
  verticalOffset: number,
  type: string,
  position: string,
  showClose: boolean,
  duration:number
}
const props = withDefaults(defineProps<Props>(), {
  title: '',
  message: '',
  verticalOffset: 0,
  type: '',
  position: 'top-right',
  showClose: true,
  duration:3
})
const verticalOffsetVal = ref(props.verticalOffset)
const typeClass = computed(() => {
  return props.type ? `el-icon-${props.type}` : ''
})
const horizontalClass = computed(() => {
  return props.position.endsWith('right') ? 'right' : 'left'
})
const verticalProperty = computed(() => {
  return props.position.startsWith('top') ? 'top' : 'bottom'
})
const positionStyle = computed(() => {
  return {
    [verticalProperty.value]: `${verticalOffsetVal.value}px`
  }
})
function onCloseHandler() {
  visible.value = false
}

let timer
function delayClose() {
  if (props.duration > 0) {
    timer = setTimeout(() => {
      onCloseHandler()
    }, props.duration)
  }
}
delayClose()
</script>
<style lang="scss">
.el-notification {
  position: fixed;
  right: 10px;
  top: 50px;
  width: 330px;
  padding: 14px 26px 14px 13px;
  border-radius: 8px;
  border: 1px solid #ebeef5;
  background-color: #fff;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  overflow: hidden;
}
</style>

添加关闭的逻辑


it('set the showClose ', () => {
    const showClose = true
    const wrapper = mount(Notification, {
      props: {
        showClose
      }
    })
    expect(wrapper.find('.el-notification__closeBtn').exists()).toBe(true)
    expect(wrapper.find('.el-icon-close').exists()).toBe(true)
  })
  it('点击关闭按钮', async () => {
    const showClose = true
    const wrapper = mount(Notification, {
      props: {
        showClose
      }
    })
    const closeBtn = wrapper.get('.el-notification__closeBtn')
    await closeBtn.trigger('click')
    expect(wrapper.get('.el-notification').isVisible()).toBe(false)
  })
  it('持续时间之后自动管理', async () => {
    jest.useFakeTimers()
    const wrapper = mount(Notification, {
      props: {
        duration: 1000
      }
    })
    jest.runTimersToTime(1000)
    await flushPromises()
    expect(wrapper.get('.el-notification').isVisible()).toBe(false)
     })

7.树

  • expaned 显示是否展开
  • checked 用来决定复选框选中列表
<el-tree
  :data="data"
  show-checkbox
  v-model:expanded="expandedList"
  v-model:checked="checkedList"
  :defaultNodeKey="defaultNodeKey"
>
</el-tree>
<script>
  export default {
    data() {
      return {
        expandedList: [4, 5],
        checkedList: [5],
        data: [
          {
            id: 1,
            label: '一级 1',
            children: [
              {
                id: 4,
                label: '二级 1-1',
                children: [
                  {
                    id: 9,
                    label: '三级 1-1-1'
                  },
                  {
                    id: 10,
                    label: '三级 1-1-2'
                  }
                ]
              }
            ]
          },
          {
            id: 2,
            label: '一级 2',
            children: [
              {
                id: 5,
                label: '二级 2-1'
              },
              {
                id: 6,
                label: '二级 2-2'
              }
            ]
          }
        ],
        defaultNodeKey: {
          childNodes: 'children',
          label: 'label'
        }
      }
    }
  }
  
</script>

初始化树节点

//输入     
     4
   /   \
  2     7
 / \   / \
1   3 6   9
//输出
     4
   /   \
  7     2
 / \   / \
9   6 3   1
//节点的构造函数
/**
 * Definition for a binary tree node.
 */
 function TreeNode(val, left, right) {
    this.val = (val===undefined ? 0 : val)
    this.left = (left===undefined ? null : left)
    this.right = (right===undefined ? null : right)
}

递归替换树

var invertTree = function(root) {
  // 递归 终止条件
  if(root==null) {
    return root
  }
  // 递归的逻辑
  [root.left, root.right] = [invertTree(root.right), invertTree(root.left)]
  return root
}

tree.vue


<template>
  <div class="el-tree">
    <el-tree-node v-for="child in tree.root.childNodes" :node="child" :key="child.id"></el-tree-node>
  </div>
</template>
<script>
import ElTreeNode from './TreeNode.vue'
const instance = getCurrentInstance()
const tree = new Tree(props.data, props.defaultNodeKey, {
  asyncLoadFn: props.asyncLoadFn,
  isAsync: props.async
})
const state = reactive({
  tree
})
provide('elTree', instance)
useTab()
useExpand(props, state)
function useExpand(props, state) {
  const instance = getCurrentInstance()
  const { emit } = instance
  if (props.defaultExpandAll) {
    state.tree.expandAll()
  }
  watchEffect(() => {
    emit('update:expanded', state.tree.expanded)
  })
  watchEffect(() => {
    state.tree.setExpandedByIdList(props.expanded, true)
  })
  onMounted(() => {
    state.tree.root.expand(true)
  })
}
  
</script>

TreeNode.vue

<div
    v-show="node.isVisable"
    class="el-tree-node"
    :class="{
      'is-expanded': node.isExpanded,
      'is-current': elTree.proxy.dragState.current === node,
      'is-checked': node.isChecked,
    }"
    role="TreeNode"
    ref="TreeNode"
    :id="'TreeNode' + node.id"
    @click.stop="onClickNode"
  >
    <div class="el-tree-node__content"> 
      <span
        :class="[
          { expanded: node.isExpanded, 'is-leaf': node.isLeaf },
          'el-tree-node__expand-icon',
          elTree.props.iconClass
        ]"
        @click.stop="
          node.isLeaf ||
            (elTree.props.accordion ? node.collapse() : node.expand())
        ">
      </span>
      <el-checkbox
        v-if="elTree.props.showCheckbox"
        :modelValue="node.isChecked"
        @update:modelValue="onChangeCheckbox"
        @click="elTree.emit('check', node, node.isChecked, $event)"
      >
      </el-checkbox
         <!-- 这里针对动态节点输出处理-->
      <el-node-content
        class="el-tree-node__label"
        :node="node"
      ></el-node-content>
   
    </div>
      <div
        class="el-tree-node__children"
        v-show="node.isExpanded"
        v-if="!elTree.props.renderAfterExpand || node.isRendered"
        role="group"
        :aria-expanded="node.isExpanded"
      >
        <el-tree-node
          v-for="child in node.childNodes"
          :key="child.id"
          :node="child"
        >
        </el-tree-node>
      </div>
  </div>

//操作事件

<script>
 
import ElCheckbox from '../checkbox'
import ElNodeContent from './NodeContent'
import { TreeNode } from './entity/TreeNode'
import { inject } from 'vue'

  const elTree = inject('elTree')
    const onClickNode = (e) => {
    !elTree.props.expandOnClickNode ||
        props.node.isLeaf ||
        (elTree.props.accordion ? props.node.collapse() : props.node.expand())
    !elTree.props.checkOnClickNode ||
        props.node.setChecked(undefined, elTree.props.checkStrictly)
    elTree.emit('node-click', props.node, e)
    elTree.emit('current-change', props.node, e)
    props.node.isExpanded
        ? elTree.emit('node-expand', props.node, e)
        : elTree.emit('node-collapse', props.node, e)
    }
    const onChangeCheckbox = (e) => {
    props.node.setChecked(undefined, elTree.props.checkStrictly)
    elTree.emit('check-change', props.node, e)
    } 
    
    return {
      elTree,
      onClickNode, 
      onChangeCheckbox
    }
</script>

动态渲染自定义内容

NodeContent.vue

<script>
import { TreeNode } from './entity/TreeNode'
import { inject, h } from 'vue'
export default {
  name: 'ElNodeContent',

  props: {
    node: {
      required: true,
      type: TreeNode
    }
  },

  render(ctx) {
    const elTree = inject('elTree')
    if (typeof elTree.slots.default === 'function') {
      return elTree.slots.default({ node: ctx.node, data: ctx.node.data.raw })
    } else if (typeof elTree.props.renderContent === 'function') {
      return elTree.props.renderContent({
        node: ctx.node,
        data: ctx.node.data.raw
      })
    }

    return h('span', ctx.node.label)
  }
}
</script>

test2.vue 传入自定义插槽内容

<div class="custom-tree-container">
  <div class="block">
    <p>使用 render-content</p>
    <el-tree
      :data="data1"
      show-checkbox
      default-expand-all
      :expand-on-click-node="false"
      :render-content="renderContent"
    >
    </el-tree>
  </div>
</div>
<script>
function renderContent({ node, data }) {
  return (
    <span class="custom-tree-node">
      <span>{data.label}</span>
      <span>
        <el-button
          size="mini"
          type="text"
          onClick={() => this.append(node, data)}
        >
          Append
        </el-button>
        <el-button
          size="mini"
          type="text"
          onClick={() => this.remove(node, data)}
        >
          Delete
        </el-button>
      </span>
    </span>
  )
}
</script>

8.表格

html表格格式

<table>
  <thead>
    <tr>
      <th>111</th>
      <th>222</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>333</td>
      <td>444</td>
    </tr>
    <tr>
      <td>555</td>
      <td>666</td>
    </tr>
  </tbody>
</table>

columns + data 版本

<el-table-column />标签 + data版本


<el-table :data="tableData" border style="width: 100%">
  <el-table-column fixed prop="date" label="日期" width="150">
  </el-table-column>
  <el-table-column prop="name" label="姓名" width="120"> </el-table-column>
  <el-table-column prop="province" label="省份" width="120"> </el-table-column>
  <el-table-column prop="city" label="市区" width="120"> </el-table-column>
  <el-table-column prop="address" label="地址" width="300"> </el-table-column>
  <el-table-column prop="zip" label="邮编" width="120"> </el-table-column>
  <el-table-column fixed="right" label="操作" width="100">
    <template v-slot="scope">
      <el-button @click="handleClick(scope.row)" type="text" size="small"
        >查看</el-button
      >
      <el-button type="text" size="small">编辑</el-button>
    </template>
  </el-table-column>
</el-table>
<script>
  export default {
    methods: {
      handleClick(row) {
        console.log(row)
      }
    },
    data() {
      return {
        tableData: [
          {
            date: '2016-05-02',
            name: '王小虎',
            province: '上海',
            city: '普陀区',
            address: '上海市普陀区金沙江路 1518 弄',
            zip: 200333
          },
          {
            date: '2016-05-04',
            name: '王小虎',
            province: '上海',
            city: '普陀区',
            address: '上海市普陀区金沙江路 1517 弄',
            zip: 200333
          },
          {
            date: '2016-05-01',
            name: '王小虎',
            province: '上海',
            city: '普陀区',
            address: '上海市普陀区金沙江路 1519 弄',
            zip: 200333
          },
          {
            date: '2016-05-03',
            name: '王小虎',
            province: '上海',
            city: '普陀区',
            address: '上海市普陀区金沙江路 1516 弄',
            zip: 200333
          }
        ]
      }
    }
  }
</script>

结构

el-table

  • el-table-column
  • el-table-body
  • el-table-header

特殊处理

  • hidden-columns 负责隐藏列的显示,并且通过 table-store 进行表格内部的状态管理。
  • 每当 table 中的 table-store 被修改后,table-header、table-body 都需要重新渲染。

el-table


<template>
  <div class="el-table">
    <div class="hidden-columns" ref="hiddenColumns">
      <slot></slot>
    </div>
    <div class="el-table__header-wrapper"
         ref="headerWrapper">
      <table-header ref="tableHeader"
                    :store="store">
      </table-header>
    </div>
    <div class="el-table__body-wrapper"
         ref="bodyWrapper">
      <table-body :context="context"
                  :store="store">                  
      </table-body>
    </div>
  </div>
</template>
setup(props) {
let table = getCurrentInstance()
    const store = createStore(table, {
      rowKey: props.rowKey,
      defaultExpandAll: props.defaultExpandAll,
      selectOnIndeterminate: props.selectOnIndeterminate,
      // TreeTable 的相关配置
      indent: props.indent,
      lazy: props.lazy,
      lazyColumnIdentifier: props.treeProps.hasChildren || 'hasChildren',
      childrenColumnName: props.treeProps.children || 'children',
      data: props.data
    })
    table.store = store
    const layout = new TableLayout({
      store: table.store,
      table,
      fit: props.fit,
      showHeader: props.showHeader
    })
    table.layout = layout
    
  const tableId = 'el-table_' + tableIdSeed++
    table.tableId = tableId
    table.state = {
      isGroup,
      resizeState,
      doLayout,
      debouncedUpdateLayout
    }
    return {
      layout,
      store,
      handleHeaderFooterMousewheel,
      handleMouseLeave,
      tableId,
      tableSize,
      clearSort,
      doLayout,
      sort,
      setDragVisible,
      context: table
    }
 
 }

table-header


setup(props, { emit }) {
    const instance = getCurrentInstance()
    const parent = instance.parent
    const storeData = parent.store.states
    const filterPanels = ref({})
    const {
      tableLayout,
      onColumnsChange,
      onScrollableChange
    } = useLayoutObserver(parent)
    const hasGutter = computed(() => {
      return !props.fixed && tableLayout.gutterWidth
    })
    onMounted(() => {
      nextTick(() => {
        const { prop, order } = props.defaultSort
        const init = true
        parent.store.commit('sort', { prop, order, init })
      })
    })
    const {
      handleHeaderClick,
      handleHeaderContextMenu,
      handleMouseDown,
      handleMouseMove,
      handleMouseOut,
      handleSortClick,
      handleFilterClick
    } = useEvent(props, emit)
    const {
      getHeaderRowStyle,
      getHeaderRowClass,
      getHeaderCellStyle,
      getHeaderCellClass
    } = useStyle(props)
    const { isGroup, toggleAllSelection, columnRows } = useUtils(props)
    instance.state = {
      onColumnsChange,
      onScrollableChange
    }
    // eslint-disable-next-line
    instance.filterPanels = filterPanels

    return {
      columns: storeData.columns,
      filterPanels,
      hasGutter,
      onColumnsChange,
      onScrollableChange,
      columnRows,
      getHeaderRowClass,
      getHeaderRowStyle,
      getHeaderCellClass,
      getHeaderCellStyle,
      handleHeaderClick,
      handleHeaderContextMenu,
      handleMouseDown,
      handleMouseMove,
      handleMouseOut,
      handleSortClick,
      handleFilterClick,
      isGroup,
      toggleAllSelection
    }
 }
 
   render() {
    return h(
      'table',
      {
        border: '0',
        cellpadding: '0',
        cellspacing: '0',
        class: 'el-table__header'
      },
      [
        hColgroup(this.columns, this.hasGutter),
        h(
          'thead',
          {
            class: { 'is-group': this.isGroup, 'has-gutter': this.hasGutter }
          },
          this.columnRows.map((subColumns, rowIndex) =>{
          ..
          }
       }]
  );

table-body渲染

render() {
    return h(
      'table',
      {
        class: 'el-table__body',
        cellspacing: '0',
        cellpadding: '0',
        border: '0'
      },
      [
        hColgroup(this.store.states.columns.value),
        h('tbody', {}, [
          data.reduce((acc, row) => {
            return acc.concat(this.wrappedRowRender(row, acc.length))
          }, []),
          h( 
            ElTooltip,
            {
              modelValue: this.tooltipVisible,
              content: this.tooltipContent,
              manual: true,
              effect: this.$parent.tooltipEffect,
              placement: 'top'
            },   
            {
              default: () => this.tooltipTrigger
            }
          )
        ])
      ]
    )
  }

9.文档

yarn add -D vuepress@next

docs/README.md 配置首页


---
home: true
heroImage: /theme.png
title: 网站快速成型工具
tagline: 一套为开发者、设计师和产品经理准备的基于 Vue 3 的桌面端组件库
heroText: 网站快速成型工具
actions:
  - text: 快速上手
    link: /install
    type: primary
  - text: 项目简介
    link: /button
    type: secondary
features:
  - title: 简洁至上
    details: 以 Markdown 为中心的项目结构,以最少的配置帮助你专注于写作。
  - title: Vue 驱动
    details: 享受 Vue 的开发体验,可以在 Markdown 中使用 Vue 组件,又可以使用 Vue 来开发自定义主题。
  - title: 高性能
    details: VuePress 会为每个页面预渲染生成静态的 HTML,同时,每个页面被加载的时候,将作为 SPA 运行。
footer: powdered by vuepress and me
---
# 额外的信息

项目配置信息

module.exports = {
  themeConfig:{
    title:"title",
    description:"vuepress搭建的文档",
    logo:"/logo.svg",
    navbar:[
      {
        link:"/",
        text:"首页"
      },{
        link:"/install",
        text:"安装"
      },
    ]
  }
}

docs/install.md

## 安装
### npm 安装
推荐使用 npm 的方式安装,它能更好地和 [webpack](https://webpack.js.org/) 打包工具配合使用。``shell
npm i element3 -S
``
### CDN
目前可以通过 xxxxxx
``html
<!-- 引入样式 -->
<link
  rel="stylesheet"
  href="https://xxxxx/index.css"
/>
<!-- 引入组件库 -->
<script src="xxxx"></script>
`` 

演示UI渲染的代码与下面具体代码共用一份

通过自定义 Markdown-loader 处理一份md生成ui和源代码

例子

:::demo

- ```html
<el-button type="text">文字按钮</el-button>
<el-button type="text" disabled>文字按钮</el-button>
- ```

:::

实现逻辑

  1. md.render解析代码,如果遇到:::demo + ::: ,进入特殊解析阶段
  2. 通过genInlineComponentText模拟vue动态解析组件成html
  3. 把源代码通过<script>标签包裹输出

代码

md-loader/src/index.js

const { stripScript, stripTemplate, genInlineComponentText } = require('./util')
const md = require('./config')

module.exports = function (source) {
  const content = md.render(source)

  const startTag = '<!--element-demo:'
  const startTagLen = startTag.length
  const endTag = ':element-demo-->'
  const endTagLen = endTag.length

  let componenetsString = ''
  let id = 0 // demo 的 id
  const output = [] // 输出的内容
  let start = 0 // 字符串开始位置

  let commentStart = content.indexOf(startTag)
  let commentEnd = content.indexOf(endTag, commentStart + startTagLen)
  while (commentStart !== -1 && commentEnd !== -1) {
    output.push(content.slice(start, commentStart))

    const commentContent = content.slice(commentStart + startTagLen, commentEnd)
    const html = stripTemplate(commentContent)
    const script = stripScript(commentContent)

    const demoComponentContent = genInlineComponentText(html, script)

    const demoComponentName = `element-demo${id}`
    output.push(`<template #source><${demoComponentName} /></template>`)
    componenetsString += `${JSON.stringify(
      demoComponentName
    )}: ${demoComponentContent},`

    // 重新计算下一次的位置
    id++
    start = commentEnd + endTagLen
    commentStart = content.indexOf(startTag, start)
    commentEnd = content.indexOf(endTag, commentStart + startTagLen)
  }

  // 仅允许在 demo 不存在时,才可以在 Markdown 中写 script 标签
  // todo: 优化这段逻辑
  let pageScript = ''
  if (componenetsString) {
    pageScript = `<script>
      import hljs from 'highlight.js'
      import * as Vue from "vue"
      export default {
        name: 'component-doc',
        components: {
          ${componenetsString}
        }
      }
    </script>`
  } else if (content.indexOf('<script>') === 0) {
    // 硬编码,有待改善
    start = content.indexOf('</script>') + '</script>'.length
    pageScript = content.slice(0, start)
  }

  output.push(content.slice(start))
  return `
    <template>
      <section class="content element-doc">
        ${output.join('')}
      </section>
    </template>
    ${pageScript}
  `
}

md-loader/src/util.js

// const { compileTemplate } = require('@vue/component-compiler-utils');
// const compiler = require('vue-template-compiler');
const compiler = require('@vue/compiler-dom') // 模板

function stripScript(content) {
  const result = content.match(/<(script)>([\s\S]+)<\/\1>/)
  return result && result[2] ? result[2].trim() : ''
}

function stripStyle(content) {
  const result = content.match(/<(style)\s*>([\s\S]+)<\/\1>/)
  return result && result[2] ? result[2].trim() : ''
}

// 编写例子时不一定有 template。所以采取的方案是剔除其他的内容
function stripTemplate(content) {
  content = content.trim()
  if (!content) {
    return content
  }
  content = content.replace(/<(script|style)[\s\S]+<\/\1>/g, '').trim()
  // 过滤<template>
  const ret = content.match(/<(template)\s*>([\s\S]+)<\/\1>/)
  return ret ? ret[2].trim() : content
}

function genInlineComponentText(template, script) {
  const compiled = compiler.compile(template, { prefixIdentifiers: true })

  const code = compiled.code.replace(/return\s+?function\s+?render/, () => {
    return 'function render '
  })

  let demoComponentContent = `
    ${code}
  `

  // todo: 这里采用了硬编码有待改进
  script = script.trim()
  if (script) {
    script = script
      .replace(/export\s+default/, 'const democomponentExport =')
      .replace(/import ([,{}\w\s]+) from (['"\w]+)/g, function (s0, s1, s2) {
        if (s2 === `'vue'`) {
          return `
        const ${s1} = Vue
        `
        } else if (s2 === `'element3'`) {
          return `
            const ${s1} = Element3
          `
        }
      })
  } else {
    script = 'const democomponentExport = {}'
  }
  demoComponentContent = `(function() {
    ${demoComponentContent}
    ${script}
    return {
      mounted(){
        this.$nextTick(()=>{
          const blocks = document.querySelectorAll('pre code:not(.hljs)')
          Array.prototype.forEach.call(blocks, hljs.highlightBlock)
        })
      },
      render,
      ...democomponentExport
    }
  })()`
  return demoComponentContent
}

module.exports = {
  stripScript,
  stripStyle,
  stripTemplate,
  genInlineComponentText
}

md-loader/src/containers.js

下面把模拟vue渲染的html 渲染到demo-block结构里面

const mdContainer = require('markdown-it-container')

module.exports = (md) => {
  md.use(mdContainer, 'demo', {
    validate(params) {
      return params.trim().match(/^demo\s*(.*)$/)
    },
    render(tokens, idx) {
      const m = tokens[idx].info.trim().match(/^demo\s*(.*)$/)
      if (tokens[idx].nesting === 1) {
        const description = m && m.length > 1 ? m[1] : ''
        const content =
          tokens[idx + 1].type === 'fence' ? tokens[idx + 1].content : ''
        return `<demo-block>
        ${description ? `<div>${md.render(description)}</div>` : ''}
        <!--element-demo: ${content}:element-demo-->
        `
      }
      return '</demo-block>'
    }
  })

  md.use(mdContainer, 'tip')
  md.use(mdContainer, 'warning')
}

自定义demo-block.vue ,支持查看/隐藏源码

<!-- DemoBlock.vue -->
<template>
  <div class="demo-block">
    <div class="source">
      <slot name="source"></slot>
    </div>
    <div class="meta" ref="meta">
      <div class="description" v-if="$slots.default">
        <slot></slot>
      </div>
      <div class="highlight">
        <slot name="highlight"></slot>
      </div>
    </div>
    <div
      class="demo-block-control"
      ref="control"
      @click="isExpanded = !isExpanded"
    >
      <span>{{ controlText }}</span>
    </div>
  </div>
</template>
<script>
import { ref, computed, watchEffect, onMounted } from 'vue'
export default {
  setup() {
    const meta = ref(null)
    const isExpanded = ref(false)
    const controlText = computed(() =>
      isExpanded.value ? '隐藏代码' : '显示代码'
    )
    const codeAreaHeight = computed(() =>
      [...meta.value.children].reduce((t, i) => i.offsetHeight + t, 56)
    )
    onMounted(() => {
      watchEffect(() => {
        meta.value.style.height = isExpanded.value
          ? `${codeAreaHeight.value}px`
          : '0'
      })
    })
    return {
      meta,
      isExpanded,
      controlText
    }
  }
}
</script>

md-loader/src/fence.js 匹配策略

// 覆盖默认的 fence 渲染策略
module.exports = (md) => {
  const defaultRender = md.renderer.rules.fence
  md.renderer.rules.fence = (tokens, idx, options, env, self) => {
    const token = tokens[idx]
    // 判断该 fence 是否在 :::demo 内
    const prevToken = tokens[idx - 1]
    const isInDemoContainer =
      prevToken &&
      prevToken.nesting === 1 &&
      prevToken.info.trim().match(/^demo\s*(.*)$/)
    if (token.info === 'html' && isInDemoContainer) {
      return `<template #highlight><pre v-pre><code class="html">${md.utils.escapeHtml(
        token.content
      )}</code></pre></template>`
    }
    return defaultRender(tokens, idx, options, env, self)
  }
}

参考

玩转Vue 3