vue3 @功能的实现

1,177 阅读6分钟

前言

一大早,又是美好的一天!

src=http __inews.gtimg.com_newsapp_bt_0_13550775179_1000&refer=http __inews.gtimg.webp

产品需求

u=3964386407,507480335&fm=253&fmt=auto&app=138&f=GIF.gif

给我实现@功能

src=http __img.soogif.com_7MlMhGACtSeIJ6uWT2GcpDnMoyY6xODo.gif&refer=http __img.soogif.gif

简简单单,包在我身上!

几天之后

产品需求: 你好了没有?

src=http __tva4.sinaimg.cn_large_006mowZngy1fz16o4aco7j306o06oa9z.jpg&refer=http __tva4.sinaimg.webp

构思

初次思考

作为小白的我,初入职场,可谓是艺低人胆大,这活接手之后,我的想法就是:这不就是键盘监听输入@符号,跳出弹框,可以选择@的人员,之后再把数据渲染到页面上吗?@之后的文字再用颜色整一下不就好了吗?

u=4018987211,1755470004&fm=253&fmt=auto&app=138&f=JPEG.webp

二次构思

后来才发现,踩了一大把的坑,在写@功能的时候,还是需要注意一下需求滴

  • 输入@ 的时候,需要弹出人员选择器

  • 人员选择器需要跟随在光标的位置出现

  • 选择时 @的用户标签要插入当前的光标位置中

  • 生成@的用户标签的规则是:高亮、携带用户ID、一键删除信息、且不可以编辑。

  • 用户点击生成的标签或移动键盘方向键也不能聚焦进@的标签,光标需自定移到当前标签最后

  • 输入@后连续输入的非空内容作为搜索关键词

这些需求分析完后,我才发现,自己写的是个啥,所以日后还是需要先分析分析需求,之后下手再不迟。分析完之后,本菜鸟不会,连忙前往掘金社区,寻求大佬帮助,本文的vue3版本也是在此基础上建立的,有兴趣的朋友可以去看看这位大佬写的vue2思路

开始行动

大致效果

大体板块划分

此处是将弹框sandText.vue作为一个组件,将其分出去书写了,主体功能逻辑在sand.vue当中。

大致划分之后,需要构建思路,父组件sand.vue传三个参数给子组件sandText.vue

  • queryString 搜索的关键字
  • visible 控制子组件是否展示
  • posittion 所展示的位置坐标

返废话不多说直接上代码


//sandText.vue

<template>

    <div class="wrapper" :style="{ position: 'fixed', top: position.y + 'px', left: position.x + 'px' }">

        <div v-if="!mockList.length" class="empty">无搜索结果</div>

        <div v-for="(item, i) in mockList" :key="item.id" ref="usersRef" class="item" :class="{ 'active': i === index }"

            @click="clickAt($event, item)" @mouseenter="hoverAt(i)">

            <div class="name">{{ item.name }}</div>

        </div>

    </div>

</template>

 

<script lang="ts" setup>

import { onMounted, onUnmounted, ref, watch } from 'vue'

  


//选项数据源

const mockData = [

    { name: '文本语言', id: 'HTML' },

    { name: 'CSS', id: 'CSS' },

    { name: 'Java', id: 'Java' },

    { name: 'JavaScript', id: 'JavaScript' }

]

  


const props = defineProps({

    //是否展示

    visible: {

        type: Boolean,

        default: true

    },

    //搜索关键字

    queryString: {

        type: String,

        default: ''

    },

    //展示的位置

    position: {

        type: Object,

        default: () => {

            return {}

        }

    }

})

  


//监听父组件传入搜索值的变化

watch(

    () => props.queryString,

    (val) => {  //匹配这个搜索值  匹配不成功则为空,成功则返回值

        val ? mockList.value = mockData.filter(({ name }) => name.startsWith(val)) : mockList.value = mockData.slice(0)

    }

)

  


const users = ref([]);

const index = ref(-1); //控制文本的下标

const mockList: any = ref(mockData);

  
  


const emit = defineEmits([ 'onHide', 'onPickUser'])

  


//鼠标悬浮选择下标

const hoverAt = (i: any) => {

    index.value = i

}

//上下选择文本

const keyDownHandler = (e: any) => {

    if (e.code === 'Escape') {

        emit('onHide')

        return

    }

    // 键盘按下 => ↓

    if (e.code === 'ArrowDown') {

        if (index.value >= mockList.value.length - 1) {

            index.value = 0

        } else {

            index.value = index.value + 1

        }

    }

    // 键盘按下 => ↑

    if (e.code === 'ArrowUp') {

        if (index.value <= 0) {

            index.value = mockList.value.length - 1

        } else {

            index.value = index.value - 1

        }

    }

    // 键盘按下 => 回车

    if (e.code === 'Enter') {

        if (mockList.value.length) {

            const user = {

                name: mockList[index.value].value.name,

                id: mockList[index.value].value.id

            }

            emit('onPickUser', user)

            index.value = -1

        }

    }

}

  


//将选中的user抛出给父组件

const clickAt = (e: any, item: any) => {

    const user = {

        name: item.name,

        id: item.id

    }

    emit('onPickUser', user)

    index.value = -1

}

//页面挂载

onMounted(() => {

    document.addEventListener('keyup', keyDownHandler)

})

  


//页面销毁

onUnmounted(() => {

    document.removeEventListener('keyup', keyDownHandler)

})

  


</script>

 

<style scoped lang="less">

.wrapper {

    width: 238px;

    border: 1px solid #e4e7ed;

    border-radius: 4px;

    background-color: #fff;

    box-shadow: 0 2px 12px 0 rgb(0 0 0 / 10%);

    box-sizing: border-box;

    padding: 6px 0;

    z-index: 100;

}

  


.empty {

    font-size: 14px;

    padding: 0 20px;

    color: #999;

}

  


.item {

    font-size: 14px;

    padding: 0 20px;

    line-height: 34px;

    cursor: pointer;

    color: #606266;

  


    &.active {

        background: #f5f7fa;

        color: blue;

  


        .id {

            color: blue;

        }

    }

  


    &:first-child {

        border-radius: 5px 5px 0 0;

    }

  


    &:last-child {

        border-radius: 0 0 5px 5px;

    }

  


    .id {

        font-size: 12px;

        color: rgb(83, 81, 81);

    }

}

</style>

这块代码当中没有过多繁琐的步骤,就是一个弹窗的内容,没有过多的代码逻辑,需要监听父组件传的字段搜素值,从而改变页面选择框的数据.接下来才是重头戏

主要逻辑

html与样式

sand.vue
<template>

        <div class="content">

            <!--文本框-->

            <div ref="divRef" class="editor" contenteditable @keyup="handleKeyUp" @keydown="handleKeyDown" />

            <!--选项-->

            <sandText v-if="showDialog" :visible="showDialog" :position="position" :query-string="queryString"

                @onPickUser="handlePickUser" @onHide="handleHide" @onShow="handleShow" />

            <el-button type="primary" @click="logA">获取数据</el-button>

            <!-- <el-button ref="btn" type="text" @click="handleIn">@</el-button> -->

        </div>

</template>


<style scoped lang="less">

.content {

    width: 50%;

    font-family: sans-serif;

  


    h1 {

        text-align: center;

    }

}

  


.editor {

    margin: 0 auto;

    width: 100%;

    height: 200px;

    background: #fff;

    border: 1px solid #ccc;

    border-radius: 5px;

    text-align: left;

    padding: 10px;

    overflow: auto;

    line-height: 30px;

  


    &-focus {

        outline: none;

    }

}

</style>

逻辑实现

全局变量

const userList: any = ref([]) //用户数据列表

const node = ref('')    // 获取的节点

const user = ref('')     // 选中项的内容

const endIndex = ref('')     // 光标最后停留位置

const queryString = ref('')    // 搜索值

const showDialog = ref(false)     // 是否显示弹窗

const position: any = ref({

    x: 0,

    y: 0

})   // 弹窗显示位置

const divRef: any = ref(null)

键盘按下弹起事件

首先需要绑定键盘按下以及弹起事件,用于后续操作

// 键盘抬起事件

const handleKeyUp = async () => {

    //匹配@   出现@

    if (showAt()) {

        const node1 = getRangeNode()  //获取当前聚焦节点

        const endIndex1 = getCursorIndex() //获取光标位置

        node.value = node1

        endIndex.value = endIndex1

        position.value = await getRangeRect()  //获取坐标需要时间,需要异步获取    

        queryString.value = getAtUser() || ''

        showDialog.value = true

    } else {

        showDialog.value = false

    }

    // 限制长度 为1000  截取

    if (divRef.value.innerText.length > 1000) {

        divRef.value.innerText = divRef.value.innerText.substr(

            0,

            1000

        )

        // 光标移动到最后

        document.execCommand('selectAll', false, '')

         //将选种网页中的全部内容,也就是全部文本,第二个参数为true,会显示对话框

        let a: any = document.getSelection()

        a.collapseToEnd()

    }

}

// 键盘按下事件

const handleKeyDown = (e: any) => {

    //当前@选择被打开

    if (showDialog.value) {

        if (

            e.code === 'ArrowUp' ||

            e.code === 'ArrowDown' ||

            e.code === 'Enter'

        ) {

            // 阻止浏览器默认动作 (页面跳转)

            e.preventDefault()

        }

    }

}

// 隐藏选择框

const handleHide = () => {

    showDialog.value = false

}

// 显示选择框

const handleShow = () => {

    showDialog.value = true

}

键盘输入监听@符号

当用户输入字符之后,需要记录当前被聚焦的节点,以及当前的光标,获取文本内容,匹配是否存在@符号,进行弹窗位置获取操作,以及获取后续输入的内容操作,

// 获取光标位置

const getCursorIndex = () => {

    const selection: any = window.getSelection()  //窗口的selection对象,当前用户选择的文本

    return selection.focusOffset // 选择开始处 focusNode 的偏移量

}

// 获取节点

const getRangeNode = () => {

    const selection: any = window.getSelection()

    return selection.focusNode // 选择的结束节点,正在聚焦的节点

}


//  匹配@

const showAt = () => {

    const node = getRangeNode()  //获取当前被聚焦的节点

    if (!node || node.nodeType !== Node.TEXT_NODE) return false

    const content = node.textContent || ''  //获取节点文本

    const regX = /@([^@\s]*)$/

    const match = regX.exec(content.slice(0, getCursorIndex()))  //输入到 当前光标位置 匹配@

    return match && match.length === 2  //返回@符号 一个@长度为2

}

关于这个selection对象,我得多几句嘴,可以理解为当前用户所选择的文本对象,其中包含多种属性,用于记录光标、当前所选择的节点的信息,具体想要了解的可以看看这篇文章,关于window.getSelction()

弹窗位置以及用户信息

利用selection对象当中的getRangeAt(0) 。管理选择范围的通用对象,来获取光标的位置信息。

而在获取@后输入的内容当中,也是需要获取输入的文本内容,匹配到@值,以后后续的内容,如果匹配成功,则会获取到后续输入的内容,之后传值到子组件queryString,使用该关键字去查询。查询结果通过鼠标点击事件传回父组件,父组件获取该值后,用于创建节点标签。

// 弹窗出现的位置

const getRangeRect = async () => {

    const selection: any = (window).getSelection()

    const range = selection.getRangeAt(0) // 是用于管理选择范围的通用对象

    const rect: any = await range.getClientRects()[0] // 择一些文本并将获得所选文本的范围

    // const rect = range.getBoundingClientRect()

    const LINE_HEIGHT = 30

    if (rect === undefined) { //光标在行首,是undefined

        divRef.value.innerHTML = '\u200b'  //零字符

        const rect = range.getClientRects()[0]

        // console.log(rect);

        let obj:any = { x: 0, y: 0 }

        return obj

    } else {

        return {

            x: rect.x,

            y: rect.y + LINE_HEIGHT

        }

    }

}

// 获取 @ 用户 @后的数据

const getAtUser = () => {

    const content = getRangeNode().textContent || ''

    const regX = /@([^@\s]*)$/

    const match = regX.exec(content.slice(0, getCursorIndex()))

    if (match && match.length === 2) {        

        return match[1]   //@ 后面跟着的字符  光标停留

    }

    return undefined

}

插入标签

父组件获取到子组件抛出的相关用户信息之后,开始创建节点,其实就是一个span标签,且需要设置为不可编辑属性,这样删除的时候可以一块删除,之后将创建后的标签,替换掉原来的@符号,并将光标移动到最后。

// 插入标签后 隐藏选择框   获取子组件抛出的user

const handlePickUser = (user1: any) => {

    userList.value.push(user1)

    replaceAtUser(user1)

    user.value = user1

    showDialog.value = false

}

const createAtButton = (user: any) => {

    const btn = document.createElement('span')

    btn.style.display = 'inline-block'

    btn.dataset.user = JSON.stringify(user)

    btn.className = 'at-button'

    btn.contentEditable = 'false'

    btn.textContent = `@${user.name}`

    btn.style.color = 'blue'

    const wrapper = document.createElement('span')

    wrapper.style.display = 'inline-block'

    wrapper.contentEditable = 'false'

    const spaceElem = document.createElement('span')

    spaceElem.style.whiteSpace = 'pre'

    spaceElem.textContent = '\u200b'

    spaceElem.contentEditable = 'false'

    const clonedSpaceElem = spaceElem.cloneNode(true)

    wrapper.appendChild(spaceElem)

    wrapper.appendChild(btn)

    wrapper.appendChild(clonedSpaceElem)

    return wrapper

}

// 插入@标签

const replaceAtUser = (user: any) => {

    const node1: any = node.value

    if (node1 && user) {

        const content = node1.textContent || ''

        const endIndex1 = endIndex.value

        const preSlice = replaceString(content.slice(0, endIndex1), '')

        const restSlice = content.slice(endIndex1)

        const parentNode = node1.parentNode

        const nextNode = node1.nextSibling

        const previousTextNode = new Text(preSlice)

        const nextTextNode = new Text('\u200b' + restSlice) // 添加 0 宽字符

        const atButton = createAtButton(user)  //创建@user标签

        parentNode.removeChild(node1)

        // 插在文本框中

        if (nextNode) {

            parentNode.insertBefore(previousTextNode, nextNode)

            parentNode.insertBefore(atButton, nextNode)

            parentNode.insertBefore(nextTextNode, nextNode)

        } else {

            parentNode.appendChild(previousTextNode)

            parentNode.appendChild(atButton)

            parentNode.appendChild(nextTextNode)

        }

        // 重置光标的位置

        const range = new Range()

        const selection: any = window.getSelection()

        range.setStart(nextTextNode, 0)

        range.setEnd(nextTextNode, 0)

        selection.removeAllRanges()

        selection.addRange(range)

    }

}

获取信息

我们将用户信息存入到了span标签的data-set当中。我们想要获取到用户的信息,就需要使用字符匹配机制,在选入用户信息的时候,需要将该用户信息存入到我们的userLIst当中,如果输入完全部文本之后,其中的文本信息包含了我们的userList当中的任意一项就需要将其结果保存到一个数组中,这个数组包含的就是用户信息,但是免不了会多@几个人,设置多次@一个人,这个时候需要对数组做去重工作,利用id唯一性,进行去重操作,获取新的数组,即为@的用户对象。

// 数组去重

const distinct = (arr: any, key: any) => {

    var newObj: any = {}

    var newArr: any = []

    for (var i = 0; i < arr.length; i++) {

        var item = arr[i]

        if (!newObj[item[key]]) {

            newObj[item[key]] = newArr.push(item)

        }

    }

    return newArr

}

//格式化

const escape2Html = (str: any) => {

    // 格式化

    var arrEntities: any = { lt: '<', gt: '>', nbsp: ' ', amp: '&', quot: '"' }

    return str.replace(/&(lt|gt|nbsp|amp|quot);/gi, function (all: any, t: any) {

        return arrEntities[t]

    })

}

//获取输入的@用户的信息

const logA = () => {

    const text = divRef.value.innerHTML

    const text2 = escape2Html(text)    

    let list: any = []

    for (let index = 0; index < userList.value.length; index++) {

        if (text2.includes(`"id":"${userList.value[index].id}"`)) {

            list.push(userList.value[index])

        }

    }

    list = distinct(list, 'id')  //获取到@的id和name

    console.log(divRef.value.innerText, list)

}

主体全部代码

sand.vue
<template>

        <div class="content">

            <!--文本框-->

            <div ref="divRef" class="editor" contenteditable @keyup="handleKeyUp" @keydown="handleKeyDown" />

            <!--选项-->

            <sandText v-if="showDialog" :visible="showDialog" :position="position" :query-string="queryString"

                @onPickUser="handlePickUser" @onHide="handleHide" @onShow="handleShow" />

            <el-button type="primary" @click="logA">获取数据</el-button>

            <!-- <el-button ref="btn" type="text" @click="handleIn">@</el-button> -->

        </div>

</template>

<script lang="ts" setup>

import { onMounted, ref } from 'vue';

import sandText from './sandText.vue'

  


const userList: any = ref([]) //用户数据列表

  


const node = ref('')    // 获取到节点

const user = ref('')     // 选中项的内容

const endIndex = ref('')     // 光标最后停留位置

const queryString = ref('')    // 搜索值

const showDialog = ref(false)     // 是否显示弹窗

const position: any = ref({

    x: 0,

    y: 0

})   // 弹窗显示位置

  


const divRef: any = ref(null)

  


// 数组去重

const distinct = (arr: any, key: any) => {

    var newObj: any = {}

    var newArr: any = []

    for (var i = 0; i < arr.length; i++) {

        var item = arr[i]

        if (!newObj[item[key]]) {

            newObj[item[key]] = newArr.push(item)

        }

    }

    return newArr

}

//格式化

const escape2Html = (str: any) => {

    // 格式化

    var arrEntities: any = { lt: '<', gt: '>', nbsp: ' ', amp: '&', quot: '"' }

    return str.replace(/&(lt|gt|nbsp|amp|quot);/gi, function (all: any, t: any) {

        return arrEntities[t]

    })

}

//获取输入的@用户的信息

const logA = () => {

    const text = divRef.value.innerHTML

    const text2 = escape2Html(text)    

    let list: any = []

    for (let index = 0; index < userList.value.length; index++) {

        if (text2.includes(`"id":"${userList.value[index].id}"`)) {

            list.push(userList.value[index])

        }

    }

    list = distinct(list, 'id')  //获取到@的id和name

    console.log(divRef.value.innerText, list)

}

  


//初始化

onMounted(() => {

    handleIn()

})

//初始化各项数据

const handleIn = async () => {

    // divRef.value.focus()

    document.execCommand('selectAll', false, '')

    let a: any = document.getSelection()

    a.collapseToEnd()

    const node1 = getRangeNode()

    const endIndex1 = getCursorIndex()

    node.value = node1

    endIndex.value = endIndex1

    position.value = await getRangeRect() //需要异步获取

    queryString.value = getAtUser() || ''

    showDialog.value = false

}

// 获取光标位置

const getCursorIndex = () => {

    const selection: any = window.getSelection()  //窗口的selection对象,当前用户选择的文本

    return selection.focusOffset // 选择开始处 focusNode 的偏移量

}

// 获取节点

const getRangeNode = () => {

    const selection: any = window.getSelection()

    return selection.focusNode // 选择的结束节点,正在聚焦的节点

}

// 弹窗出现的位置

const getRangeRect = async () => {

    const selection: any = (window).getSelection()

    const range = selection.getRangeAt(0) // 是用于管理选择范围的通用对象

    const rect: any = await range.getClientRects()[0] // 择一些文本并将获得所选文本的范围

    // const rect = range.getBoundingClientRect()

    const LINE_HEIGHT = 30

    if (rect === undefined) { //光标在行首,是undefined

        divRef.value.innerHTML = '\u200b'  //零字符

        const rect = range.getClientRects()[0]

        // console.log(rect);

        let obj:any = { x: 0, y: 0 }

        return obj

    } else {

        return {

            x: rect.x,

            y: rect.y + LINE_HEIGHT

        }

    }

}

// 是否展示 @    匹配@

const showAt = () => {

    const node = getRangeNode()  //获取当前被聚焦的节点

    if (!node || node.nodeType !== Node.TEXT_NODE) return false

    const content = node.textContent || ''  //获取节点文本

    const regX = /@([^@\s]*)$/

    const match = regX.exec(content.slice(0, getCursorIndex()))  //输入到 当前光标位置 匹配@

    return match && match.length === 2  //返回@符号 一个@长度为2

}

// 获取 @ 用户 @后的数据

const getAtUser = () => {

    const content = getRangeNode().textContent || ''

    const regX = /@([^@\s]*)$/

    const match = regX.exec(content.slice(0, getCursorIndex()))

    if (match && match.length === 2) {        

        return match[1]   //@ 后面跟着的字符  光标停留

    }

    return undefined

}

// 创建整个@标签

const createAtButton = (user: any) => {

    const btn = document.createElement('span')

    btn.style.display = 'inline-block'

    btn.dataset.user = JSON.stringify(user)

    btn.className = 'at-button'

    btn.contentEditable = 'false'

    btn.textContent = `@${user.name}`

    btn.style.color = 'blue'

    const wrapper = document.createElement('span')

    wrapper.style.display = 'inline-block'

    wrapper.contentEditable = 'false'

    const spaceElem = document.createElement('span')

    spaceElem.style.whiteSpace = 'pre'

    spaceElem.textContent = '\u200b'

    spaceElem.contentEditable = 'false'

    const clonedSpaceElem = spaceElem.cloneNode(true)

    wrapper.appendChild(spaceElem)

    wrapper.appendChild(btn)

    wrapper.appendChild(clonedSpaceElem)

    return wrapper

}

const replaceString = (raw: any, replacer: any) => {

    return raw.replace(/@([^@\s]*)$/, replacer)

}

// 插入@标签

const replaceAtUser = (user: any) => {

    const node1: any = node.value

    if (node1 && user) {

        const content = node1.textContent || ''

        const endIndex1 = endIndex.value

        const preSlice = replaceString(content.slice(0, endIndex1), '')

        const restSlice = content.slice(endIndex1)

        const parentNode = node1.parentNode

        const nextNode = node1.nextSibling

        const previousTextNode = new Text(preSlice)

        const nextTextNode = new Text('\u200b' + restSlice) // 添加 0 宽字符

        const atButton = createAtButton(user)  //创建@user标签

        parentNode.removeChild(node1)

        // 插在文本框中

        if (nextNode) {

            parentNode.insertBefore(previousTextNode, nextNode)

            parentNode.insertBefore(atButton, nextNode)

            parentNode.insertBefore(nextTextNode, nextNode)

        } else {

            parentNode.appendChild(previousTextNode)

            parentNode.appendChild(atButton)

            parentNode.appendChild(nextTextNode)

        }

        // 重置光标的位置

        const range = new Range()

        const selection: any = window.getSelection()

        range.setStart(nextTextNode, 0)

        range.setEnd(nextTextNode, 0)

        selection.removeAllRanges()

        selection.addRange(range)

    }

}

// 键盘抬起事件

const handleKeyUp = async () => {

    //匹配@   出现@

    if (showAt()) {

        const node1 = getRangeNode()

        const endIndex1 = getCursorIndex() //获取光标位置

        node.value = node1

        endIndex.value = endIndex1

        position.value = await getRangeRect()  //获取坐标需要时间,需要异步获取    

        queryString.value = getAtUser() || ''

        showDialog.value = true

    } else {

        showDialog.value = false

    }

    // 限制长度 为1000  截取

    if (divRef.value.innerText.length > 1000) {

        divRef.value.innerText = divRef.value.innerText.substr(

            0,

            1000

        )

        // 光标移动到最后

        document.execCommand('selectAll', false, '')

         //将选种网页中的全部内容,也就是全部文本,第二个参数为true,会显示对话框

        let a: any = document.getSelection()

        a.collapseToEnd()

    }

}

// 键盘按下事件

const handleKeyDown = (e: any) => {

    //当前@选择被打开

    if (showDialog.value) {

        if (

            e.code === 'ArrowUp' ||

            e.code === 'ArrowDown' ||

            e.code === 'Enter'

        ) {

            // 阻止浏览器默认动作 (页面跳转)

            e.preventDefault()

        }

    }

}

// 插入标签后 隐藏选择框   获取子组件抛出的user

const handlePickUser = (user1: any) => {

    userList.value.push(user1)

    replaceAtUser(user1)

    user.value = user1

    showDialog.value = false

}

// 隐藏选择框

const handleHide = () => {

    showDialog.value = false

}

// 显示选择框

const handleShow = () => {

    showDialog.value = true

}

</script>

 

<style scoped lang="less">

.content {

    width: 50%;

    font-family: sans-serif;

  


    h1 {

        text-align: center;

    }

}

  


.editor {

    margin: 0 auto;

    width: 100%;

    height: 200px;

    background: #fff;

    border: 1px solid #ccc;

    border-radius: 5px;

    text-align: left;

    padding: 10px;

    overflow: auto;

    line-height: 30px;

  


    &-focus {

        outline: none;

    }

}

</style>

总结

@功能对于我这种小白来说也是一个小小的挑战,也是技术上的拓展,同时让我感触到前端的博大精深,自己所学仍然是沧海一粟,不管怎么说,还是需要跟大佬们多多学习,有时间多逛逛技术社区才是!

我是小白,分享自我学习心得,咱们一起学习!