<template>
<div class="validate-input-container pb-3">
<input
v-if="tag !== 'textarea'"
class="form-control"
:class="{'is-invalid': inputRef.error}"
@blur="validateInput"
v-model="inputRef.val"
v-bind="$attrs"
>
<textarea
v-else
class="form-control"
:class="{'is-invalid': inputRef.error}"
@blur="validateInput"
v-model="inputRef.val"
v-bind="$attrs"
>
</textarea>
<span v-if="inputRef.error" class="invalid-feedback">{{inputRef.message}}</span>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, PropType, onMounted, computed } from 'vue'
import { emitter } from './ValidateForm.vue'
const emailReg = /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:.[a-zA-Z0-9-]+)*$/
interface RuleProp {
type: 'required' | 'email' | 'custom';
message: string;
validator?: () => boolean;
}
export type RulesProp = RuleProp[]
export type TagType = 'input' | 'textarea'
export default defineComponent({
props: {
rules: Array as PropType<RulesProp>,
modelValue: String,
tag: {
type: String as PropType<TagType>,
default: 'input'
}
},
inheritAttrs: false,
setup(props, context) {
const inputRef = reactive({
val: computed({
get: () => props.modelValue || '',
set: val => {
context.emit('update:modelValue', val)
}
}),
error: false,
message: ''
})
const validateInput = () => {
if (props.rules) {
const allPassed = props.rules.every(rule => {
let passed = true
inputRef.message = rule.message
switch (rule.type) {
case 'required':
passed = (inputRef.val.trim() !== '')
break
case 'email':
passed = emailReg.test(inputRef.val)
break
case 'custom':
passed = rule.validator ? rule.validator() : true
break
default:
break
}
return passed
})
inputRef.error = !allPassed
return allPassed
}
return true
}
onMounted(() => {
emitter.emit('form-item-created', validateInput)
})
return {
inputRef,
validateInput
}
}
})
</script>
<template>
<form class="validate-form-container">
<slot name="default"></slot>
<div class="submit-area" @click.prevent="submitForm">
<slot name="submit">
<button type="submit" class="btn btn-primary">提交</button>
</slot>
</div>
</form>
</template>
<script lang="ts">
import { defineComponent, onUnmounted } from 'vue'
import mitt from 'mitt'
type ValidateFunc = () => boolean
export const emitter = mitt()
export default defineComponent({
emits: ['form-submit'],
setup(props, context) {
let funcArr: ValidateFunc[] = []
const submitForm = () => {
const result = funcArr.map(func => func()).every(result => result)
context.emit('form-submit', result)
}
const callback = (func?: ValidateFunc) => {
if (func) {
funcArr.push(func)
}
}
emitter.on('form-item-created', callback)
onUnmounted(() => {
emitter.off('form-item-created', callback)
funcArr = []
})
return {
submitForm
}
}
})
</script>
<template>
<div class="signup-page mx-auto p-3 w-330">
<h5 class="my-4 text-center">注册者也账户</h5>
<validate-form @form-submit="onFormSubmit">
<div class="mb-3">
<label class="form-label">邮箱地址</label>
<validate-input
:rules="emailRules" v-model="formData.email"
placeholder="请输入邮箱地址"
type="text"
/>
</div>
<div class="mb-3">
<label class="form-label">昵称</label>
<validate-input
:rules="nameRules" v-model="formData.nickName"
placeholder="请输入昵称"
type="text"
/>
</div>
<div class="mb-3">
<label class="form-label">密码</label>
<validate-input
type="password"
placeholder="请输入密码"
:rules="passwordRules"
v-model="formData.password"
/>
</div>
<div class="mb-3">
<label class="form-label">重复密码</label>
<validate-input
type="password"
placeholder="请再次密码"
:rules="repeatPasswordRules"
v-model="formData.repeatPassword"
/>
</div>
<template #submit>
<button type="submit" class="btn btn-primary btn-block btn-large">注册新用户</button>
</template>
</validate-form>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import ValidateInput, { RulesProp } from '../components/ValidateInput.vue'
import ValidateForm from '../components/ValidateForm.vue'
import createMessage from '../components/createMessage'
export default defineComponent({
name: 'Signup',
components: {
ValidateInput,
ValidateForm
},
setup() {
const formData = reactive({
email: '',
nickName: '',
password: '',
repeatPassword: ''
})
const router = useRouter()
const emailRules: RulesProp = [
{ type: 'required', message: '电子邮箱地址不能为空' },
{ type: 'email', message: '请输入正确的电子邮箱格式' }
]
const nameRules: RulesProp = [
{ type: 'required', message: '昵称不能为空' }
]
const passwordRules: RulesProp = [
{ type: 'required', message: '密码不能为空' }
]
const repeatPasswordRules: RulesProp = [
{ type: 'required', message: '重复密码不能为空' },
{
type: 'custom',
validator: () => {
return formData.password === formData.repeatPassword
},
message: '密码不相同'
}
]
const onFormSubmit = (result: boolean) => {
if (result) {
const payload = {
email: formData.email,
password: formData.password,
nickName: formData.nickName
}
axios.post('/users/', payload).then(data => {
createMessage('注册成功 正在跳转登录页面', 'success', 2000)
setTimeout(() => {
router.push('/login')
}, 2000)
}).catch(e => {
console.log(e)
})
}
}
return {
emailRules,
nameRules,
passwordRules,
repeatPasswordRules,
onFormSubmit,
formData
}
}
})
</script>
<style>
.w-330 {
max-width: 330px;
}
</style>
(5.1)--5-11使用新版mitt时报出类型错误的解决方案
📎(5.1)--5-11使用新版mitt时报出类型错误的解决方案.pdf
第五章 表单的世界 - 完成自定义 Form 组件
#5-1 web 世界的经典元素 - 表单
需求分析
#5-2 ValidateInput 编码第一部分 - 简单的实现
Bootstrap Form文档地址: ****v5.getbootstrap.com/docs/5.0/fo…
<form action="">
<div class="mb-3">
<label for="exampleInputEmail1" class="form-label">邮箱地址</label>
<input
type="text" class="form-control" id="exampleInputEmail1"
v-model="emailRef.val"
@blur="validateEmail"
>
<div class="form-text" v-if="emailRef.error">{{emailRef.message}}</div>
</div>
<div class="mb-3">
<label for="exampleInputPassword1" class="form-label">密码</label>
<input type="password" class="form-control" id="exampleInputPassword1">
</div>
</form>
验证表单的逻辑处理, 现在有两个规则,不能为空,和需要是邮件地址
const emailRef = reactive({
val: '',
error: false,
message: ''
})
const validateEmail = () => {
if (emailRef.val.trim() === '') {
emailRef.error = true
emailRef.message = 'can not be empty'
} else if (!emailReg.test(emailRef.val)) {
emailRef.error = true
emailRef.message = 'should be valid email'
}
}
return {
emailRef,
validateEmail
}
#5-3 ValidateInput 编码第二部分 - 抽象验证规则
ValidateInput 编码
<template>
<div class="validate-input-container pb-3">
<input type="text"
class="form-control"
:class="{'is-invalid': inputRef.error}"
v-model="inputRef.val"
@blur="validateInput"
>
<span v-if="inputRef.error" class="invalid-feedback">{{inputRef.message}}</span>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, PropType } from 'vue'
const emailReg = /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:.[a-zA-Z0-9-]+)*$/
interface RuleProp {
type: 'required' | 'email';
message: string;
}
export type RulesProp = RuleProp[]
export default defineComponent({
props: {
rules: Array as PropType<RulesProp>
},
setup(props) {
const inputRef = reactive({
val: '',
error: false,
message: ''
})
const validateInput = () => {
if (props.rules) {
const allPassed = props.rules.every(rule => {
let passed = true
inputRef.message = rule.message
switch (rule.type) {
case 'required':
passed = (inputRef.val.trim() !== '')
break
case 'email':
passed = emailReg.test(inputRef.val)
break
default:
break
}
return passed
})
inputRef.error = !allPassed
}
}
return {
inputRef,
validateInput
}
}
})
</script>
使用
<div class="mb-3">
<label class="form-label">邮箱地址</label>
<validate-input :rules="emailRules"></validate-input>
</div>
const emailRules: RulesProp = [
{ type: 'required', message: '电子邮箱地址不能为空' },
{ type: 'email', message: '请输入正确的电子邮箱格式' }
]
#5-4 ValidateInput 编码第三部分 - 支持 v-model
WARNING
这是一个 breaking change! Vue3 v-model 文档地址: ****v3.vuejs.org/guide/migra…
<template>
<div class="validate-input-container pb-3">
<input type="text"
class="form-control"
:class="{'is-invalid': inputRef.error}"
:value="inputRef.val"
@blur="validateInput"
@input="updateValue"
>
<span v-if="inputRef.error" class="invalid-feedback">{{inputRef.message}}</span>
</div>
</template>
<script lang="ts">
props: {
rules: Array as PropType<RulesProp>,
modelValue: String
},
const inputRef = reactive({
val: props.modelValue || '',
error: false,
message: ''
})
const updateValue = (e: KeyboardEvent) => {
const targetValue = (e.target as HTMLInputElement).value
inputRef.val = targetValue
context.emit('update:modelValue', targetValue)
}
</script>
#5-5 ValidateInput 编码第四部分 - 使用 $attrs 支持默认属性
Vue3 $attrs 文档地址: ****v3.vuejs.org/api/instanc…
#5-6 ValidateForm 组件需求分析
需求分析
#5-7 ValidateForm 编码第一部分 - 使用插槽 slot
Vue3 具名插槽 Named Slots 文档地址: ****v3.vuejs.org/guide/compo…
ValidateForm.vue
<template>
<form class="validate-form-container">
<slot name="default"></slot>
<div class="submit-area" @click.prevent="submitForm">
<slot name="submit">
<button type="submit" class="btn btn-primary">提交</button>
</slot>
</div>
</form>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
emits: ['form-submit'],
setup(props, context) {
const submitForm = () => {
context.emit('form-submit', true)
}
return {
submitForm
}
}
})
</script>
#5-9 ValidateForm 编码第三部分 - 寻找外援 mitt 和 5-10 ValidateForm 编码第四部分 - 大功告成
事件监听器 mitt 文档地址: ****github.com/developit/m…
安装 mitt
npm install mitt --save
ValidateForm.vue
import { defineComponent, onUnmounted } from 'vue'
import mitt from 'mitt'
type ValidateFunc = () => boolean
// 实例化 mitt
export const emitter = mitt()
export default defineComponent({
emits: ['form-submit'],
setup(props, context) {
let funcArr: ValidateFunc[] = []
const submitForm = () => {
// 循环执行数组 得到最后的验证结果
const result = funcArr.map(func => func()).every(result => result)
context.emit('form-submit', result)
}
// 将监听得到的验证函数都存到一个数组中
const callback = (func: ValidateFunc) => {
funcArr.push(func)
}
// 添加监听
emitter.on('form-item-created', callback)
onUnmounted(() => {
// 删除监听
emitter.off('form-item-created', callback)
funcArr = []
})
return {
submitForm
}
}
})
ValidateInput.vue
// 将事件发射出去,其实就是把验证函数发射出去
onMounted(() => {
emitter.emit('form-item-created', validateInput)
})
第四章 项目起航 - 准备工作和第一个页面
#4-1 项目起航 需求分析
一个复杂的 SPA 项目都要包括哪些知识点?
- 第一,要有数据的展示,这个是所有网站共有的特性,而且最好是有多级复杂数据的展示
- 第二,要有数据的创建,这就是表单的作用,有展示自然要有创建。在创建中,我们会发散很多问题,比如数据的验证怎样做,文件的上传如何处理,创建和编辑怎样共享单个页面等等。
- 第三,要有组件的抽象,vue 是组件的世界,组件是最重要的一环,编写组件是最基本的能力,对于一些常用的功能,我们需要高可用性和可定制性的组件,也就是说我们在整个项目中一般不会用到第三方组件,比如 element,都是从零开始,而且会循序渐进,不断抽象。甚至行成自己的一套小组件库。
- 第四,整体状态数据结构的设计和实现,SPA 一般使用状态工具管理整理状态,并且给多个路由使用,在 vue 中,我们使用 vuex,一个项目的整体数据结构的复杂程度就代表了这个能力的高低,最好是要有多层次的数据结构,相互依赖的关系,还要将数据的获取,结构设计,缓存进行一系列的考量。
- 第五,权限管理和控制,一个项目需要有用户权限的实现,不仅仅是后端,前端作为一个整体的 SPA 的项目,权限控制也尤为重要,我们需要有权限的获取,权限的持久化,权限的更新,那个路由可访问,哪个需要权限才可以访问。发送异步请求的全局 token 注入,全局拦截,全局信息提示等等和权限相关的内容。
- 第六,真实的后端API,和后端的交互是整个项目的最重要一环。一些同学在开发项目的时候会使用 mock server,但是由于后端的数据结构常常和最初的文档设计背道而驰,造成最后项目需要再次回炉修改。
页面所有原型图地址: whimsical.com/Djb2TcWsLTP…
#文件结构和代码规范
创建项目的过程和之前 vue3 基础知识的过程完全一致
唯一区别就是在步骤
Pick a linter / formatter config - 我们选择了 ESLint + Standard config
区别就是我们额外添加了 Standard 代码规范。standardjs.com/readme-zhcn…
我们初步确定的项目文件结构
/assets
image.png
logo.png
/components
ColumnList.vue
Dropdown.vue
...
/hooks
useURLloader.ts
...
/views
Home.vue
...
App.vue
main.ts
store.ts
router.ts
...
#从好用的样式库开始
安装最新版的 Bootstrap
npm install bootstrap@next --save
TIP
注意安装完毕应该至少是 v5.0.0-alpha1 以上版本
Bootstrap V5 文档地址: v5.getbootstrap.com/
#ColumnList 组件编码
ColumnList 组件源代码
<template>
<ul>
<li v-for="column in list" :key="column.id">
<img :src="column.avatar" :alt="column.title">
<h5>{{column.title}}</h5>
<p>{{column.description}}</p>
<a href="#">进入专栏</a>
</li>
</ul>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue'
export interface ColumnProps {
id: number;
title: string;
avatar: string;
description: string;
}
export default defineComponent({
name: 'ColumnList',
props: {
list: {
//这里特别有一点,我们现在的 Array 是没有类型的,只是一个数组,我们希望它是一个 ColomnProps 的数组,那么我们是否可以使用了类型断言直接写成 ColomnProps[],显然是不行的 ,因为 Array 是一个数组的构造函数不是类型,我们可以使用 PropType 这个方法,它接受一个泛型,讲 Array 构造函数返回传入的泛型类型。
type: Array as PropType<ColumnProps[]>,
required: true
}
}
})
</script>
引入 bootstrap
import 'bootstrap/dist/css/bootstrap.min.css'
测试数据
const testData: ColumnProps[] = [
{
id: 1,
title: 'test1的专栏',
description: '这是的test1专栏,有一段非常有意思的简介,可以更新一下欧',
avatar: 'http://vue-maker.oss-cn-hangzhou.aliyuncs.com/vue-marker/5ee22dd58b3c4520912b9470.jpg?x-oss-process=image/resize,m_pad,h_100,w_100'
},
{
id: 2,
title: 'test2的专栏',
description: '这是的test2专栏,有一段非常有意思的简介,可以更新一下欧',
avatar: 'http://vue-maker.oss-cn-hangzhou.aliyuncs.com/vue-marker/5ee22dd58b3c4520912b9470.jpg?x-oss-process=image/resize,m_pad,h_100,w_100'
}
]
#4-5 ColumnList 组件使用 Bootstrap 美化
Bootstrap 栅格系统文档地址: ****v5.getbootstrap.com/docs/5.0/la…
Bootstrap card 样式文档地址: ****v5.getbootstrap.com/docs/5.0/co…
设置默认的 avatar 图片
setup(props) {
const columnList = computed(() => {
return props.list.map(column => {
if (!column.avatar) {
column.avatar = require('@/assets/column.jpg')
}
return column
})
})
return {
columnList
}
}
修改后的vue template 模版
<div class="row">
<div v-for="column in columnList" :key="column.id" class="col-4 mb-4">
<div class="card h-100 shadow-sm">
<div class="card-body text-center">
<img :src="column.avatar" :alt="column.title" class="rounded-circle border border-light w-25 my-3" >
<h5 class="card-title">{{column.title}}</h5>
<p class="card-text text-left">{{column.description}}</p>
<a href="#" class="btn btn-outline-primary">进入专栏</a>
</div>
</div>
</div>
</div>
#4-6 GlobalHeader 组件编码
Bootstrap nav 样式文档地址: ****v5.getbootstrap.com/docs/5.0/co…
GlobalHeader 源代码
<template>
<nav class="navbar navbar-dark bg-primary justify-content-between mb-4 px-4">
<a class="navbar-brand" href="#">者也专栏</a>
<ul v-if="!user.isLogin" class="list-inline mb-0">
<li class="list-inline-item"><a href="#" class="btn btn-outline-light my-2">登陆</a></li>
<li class="list-inline-item"><a href="#" class="btn btn-outline-light my-2">注册</a></li>
</ul>
<ul v-else class="list-inline mb-0">
<li class="list-inline-item"><a href="#" class="btn btn-outline-light my-2">你好 {{user.name}}</a></li>
</ul>
</nav>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue'
export interface UserProps {
isLogin: boolean;
name?: string;
id?: number;
}
export default defineComponent({
name: 'GlobalHeader',
props: {
user: {
type: Object as PropType<UserProps>,
required: true
}
}
})
</script>
#4-7 Dropdown 组件编码第一部分 - 基本功能
Bootstrap dropdown 样式文档地址: ****v5.getbootstrap.com/docs/5.0/co…
Dropdown 组件编码
<template>
<div class="dropdown">
<a href="#" class="btn btn-outline-light my-2 dropdown-toggle" @click.prevent="toggleOpen">
{{title}}
</a>
<ul class="dropdown-menu" :style="{display: 'block'}" v-if="isOpen">
<li class="dropdown-item">
<a href="#">新建文章</a>
</li>
<li class="dropdown-item">
<a href="#">编辑资料</a>
</li>
</ul>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
export default defineComponent({
name: 'Dropdown',
props: {
title: {
type: String,
required: true
}
},
setup() {
const isOpen = ref(false)
const toggleOpen = () => {
isOpen.value = !isOpen.value
}
return {
isOpen,
toggleOpen
}
}
})
</script>
#4-8 Dropdown 组件编码第二部分 - 添加 DropdownItem
Vue3 slot 文档地址: ****v3.vuejs.org/guide/compo…
分离出来的 DropdownItem 组件编码
<template>
<li
class="dropdown-option"
:class="{'is-disabled': disabled}"
>
<slot></slot>
</li>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
props: {
disabled: {
type: Boolean,
default: false
}
}
})
</script>
<style>
.dropdown-option.is-disabled * {
color: #6c757d;
pointer-events: none;
background-color: transparent;
}
</style>
#4-9 Dropdown 组件编码第三部分 - 点击外部区域自动隐藏
composition API 使用 template ref: ****v3.vuejs.org/guide/compo…
给模版添加 ref 属性
<div class="dropdown" ref="dropdownRef">
const dropdownRef = ref<null | HTMLElement>(null)
const handler = (e: MouseEvent) => {
if (dropdownRef.value) {
if (!dropdownRef.value.contains(e.target as HTMLElement) && isOpen.value) {
isOpen.value = false
}
}
}
onMounted(() => {
document.addEventListener('click', handler)
})
onUnmounted(() => {
document.removeEventListener('click', handler)
})
return {
isOpen,
toggleOpen,
// 返回和 ref 同名的响应式对象,就可以拿到对应的 dom 节点
dropdownRef
}
#4-10 useClickOutside 第一个自定义函数
import { ref, onMounted, onUnmounted, Ref } from 'vue'
const useClickOutside = (elementRef: Ref<null | HTMLElement>) => {
const isClickOutside = ref(false)
const handler = (e: MouseEvent) => {
if (elementRef.value) {
if (elementRef.value.contains(e.target as HTMLElement)) {
isClickOutside.value = false
} else {
isClickOutside.value = true
}
}
}
onMounted(() => {
document.addEventListener('click', handler)
})
onUnmounted(() => {
document.removeEventListener('click', handler)
})
return isClickOutside
}
export default useClickOutside