注意:本文不会马上教你如何编写代码,而是作为一个引路人,一步一步引导你去理解这个组件的设计思路。会以“假如我是一个初学者,如果我在学习这个知识的时候,别人能这样告诉我,那么我也可以很快的去理解”的角度去讲解 ,授人以鱼不如授人以渔。希望你在阅读本文的时候可以拓展思路,举一反三。
一. 文件准备
前期你需要准备三个文件,来完成这个全局搜索框
SearchBar.ts文件
SearchBar.vue文件
useSearch.ts文件
二. 搜索框的样式
样式问题不是本文的重点,你可以花费五分钟在SearchBar.vue文件内速写一个非常简易的正方形div包裹着一个input标签即可快速进行下面的学习。
但是首先我们需要理清思路,这个组件是会出现在我们页面的最顶部的,所以它组件内部需要用到绝对布局。我们去SearchBar.vue去设置一个样式给最外层的div,这里其它样式的写法使用的是Uno CSS,没用过的小伙伴也不需要担心,它只是单纯的样式,和本文中心内容不牵扯。(CSS写成计算属性在这个场景也毫无特殊意义,只是单纯设计时考虑多了)
三. 渲染函数 h 和 render 函数(重点)
打开之前准备的 SearchBar.ts 文件,从 vue 里引入这两个函数,并且把在上一步写好的简陋版搜索框(SearchBar.vue)引入到这个文件内。
首先我们从官网的介绍,先看一下这个函数的定义。
可以看出,这个函数第一个参数是必填的,可以是一个string和Component,这篇文章重点讨论参数为Component的情况。重点是这个函数的返回值,是一个VNode,这个你一定不陌生,Virtual Node,看本篇文章的读者可能对虚拟dom的原理可能不是那么清楚,但是我相信你们一定知道它的基本机制。Vue其实是先渲染虚拟 dom -->然后 转换成真实 dom。
先别急着写代码,我想你可能更清楚这样的写法,比如我们前面在SearchBar.vue文件内写的简单的弹出框。
整个组件的样式都是在Vue提供的<template>组件内写的,但是你要知道,Vue在底层还是通过调用h()来完成虚拟dom的构建。而<template>仅仅只是Vue为了让你用熟悉的原生html开发而为你提供的语法糖🍬而已。(嗯,你可以这样理解)
那么我们可以根据上面h()函数的介绍,它接收的第一参数可以是Component,那我们这个SearchBar.vue不就是组件吗?那如果我不想使用<template>去展示这个组件的话,我是否可以这样写呢?h(SearchBar.vue)。没错,是的,你就是可以这样写。别忘了h的返回值就是我们想拿到的Vnode,所以按照正确的写法是这样的。
三. 编写 SearchBarMaker 构造函数和 present 方法
让我们回到 SearchBar.ts 文件
首先思考,这个搜索框一定有一个出现的函数,和一个消失的函数🤔,ok,起名字,一个 present,一个 dismiss 。
接下来我需要创建出一个 VNode ,然后想办法处理成真实 dom。经过上面的学习,第一步马上就可以想到下面的写法。
下面这位更是重量级,render() 函数。虚拟 dom 有了,真实dom 该如何拿到呢? Vue 为我们提供了这样一个函数,这里我们需要重点去看这个函数的类型是值,是一个 RootRenderFunction 类型的。
这里我们转变一下思路,我们看一下 render 函数的第二个参数是 一个 container:HostElement ,然后让我们打开我们 main.ts 文件,我们跳进 mount的定义部分,
发现神奇的地方了吗,我们虽然不知道 HostElement 的类型是什么,但是你知道你 mount 函数内填的参数是什么了吗?(忘掉的转头自觉复习官网哈。)
没错,就是全局唯一的一个真实 dom,一个朴实无华的id叫 app 的 div 元素。
由于篇幅限制,在这里你可以先暂时简单的理解,render函数会将你的虚拟dom包装成一个真实dom元素,但是你需要给它一个真实的外壳dom来告诉它将虚拟dom渲染到哪个位置。
ok,拿到一个包装后的虚拟 dom ,接下来就是告诉浏览器在哪里渲染这个元素。这里我们需要思考🤔,既然是全局都可以弹出的,并且需要在所有组件之上弹出。
那么最简单的方法就是让它出现在body的第一个元素,那么它一定会和我们网页所有的组件同级别(tips:通常我们所有的页面构成都会写在body内的一个div内。什么?你问我为什么?请打开你的index.html看一下,你是否忘记了我们的App.vue是挂在这个真实的,id为 app的元素内的)
那其实我们的操作的思路就是非常简单的,当我按下全局搜索按钮,那么你就在 <div id="app"> 的元素之前插入我的组件即可。
ok,到这里我们已经可以看到基本效果了,我们来测试一下。让我们在App.vue组件内随便写一个按钮,然后调用SearchBarCreator实例身上的present方法。(maker感觉不是那么合理,之后我们将SearchBarMaker变更为SeachBarCreator的叫法,仅仅是名字变了而已,逻辑什么的根本没变哦)。🍦
效果如下:
https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/66a6f82060a2451da531beaa54506ff7~tplv-k3u1fbpfcp-zoom-in-crop-mark:1512:0:0:0.awebp?
到这里 searchBar 已经可以呈现在页面上了,但是我们还不知道怎样让它消失,其实也非常简单,我们只需要在合适的时机移除这个 dom 元素即可。
在这里我们需要知道一点,我们需要将 searchBar 提升到当前文件的全局,不能仅只在 open 中去 new 了。
ok,我们测试一下
https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d3ba620871964046aa5e941f314fb0ac~tplv-k3u1fbpfcp-zoom-in-crop-mark:1512:0:0:0.awebp?
四. 优化 SearchBarCreator 构造函数的代码逻辑
写到这里的时候,你可能发现了一个小问题,当我一直去按搜索按钮的时候,它会出现多个搜索框,但是我们希望的是它在全局只能出现一个搜索框。换个角度思考,也就是同一时间,这个被我们new出来的SeachBar实例只能出现一个。思考一下🤔,我加一个变量,isShowing 是否正在被展示,如果正在被展示的话,那么用户再次调用present的时候,我就去调用实例自身的dismiss方法让它消失,是否可行呢?
测试一下:
https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/065be5689d5341d8a6c4f96ecaac9287~tplv-k3u1fbpfcp-zoom-in-crop-mark:1512:0:0:0.awebp?
OK,看来完美解决当前的问题了。
五. 编写全局唯一的调用实例
在上面的这种情况下,我们已经可以在App.vue文件内去new一个实例来调用这个搜索框了。但是我们加入现在需要在XXX.vue文件内调用这个搜索框呢?我难道还需要重新去引入,然后重新new吗?nonono,某位大佬说过,程序员都是很懒的,不可能写这种低级的重复代码的。那么该如何实现呢
打开我们之前准备的useSearch.ts文件,我们把之前在App.vue的全局生成的这个 SearchBar 实例转换思路,使它在全局的一个ts文件内生成一个,然后把这个实例自身的一些方法封装成函数,暴露给外部。那么我就可以在全局任意一个地方去调用这个实例身上的这两个方法。
让我们在 App.vue 去试一下。
这是我们之前的 App.vue 文件的调用方法。
我们改造一下它。
我们再次测试一下功能有没有什么问题
https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/01afbc6ce609421dbec121eb1ffa7827~tplv-k3u1fbpfcp-zoom-in-crop-mark:1512:0:0:0.awebp?
如此一来就方便很多了,我们可以在任意位置去调用这个“唯一的搜索框”
六. 添加全局的快捷键 Command + K
再此之前,我们需要理解一个概念,注意我们的 main.ts 文件,我们是把谁挂在了全局的那一个 id='app'
没错,就是前面我们提到的App.vue组件。
那么假如我在这个App.vue组件挂载的时候,给全局window对象身上添加一个键盘事件,是不是就可以了呢?怎么添加呢?其实非常非常简单,要用到见组合按键,我们就需要使用到“keydown”,具体为什么不是“keypress”,读者可以自行查阅这两者的区别,不属于本文的主要探讨内容。
这时候,我们先来按一下 command 看看打印的内容是什么。这里重点的内容是该键盘事件身上的metaKey 属性。
在这里我们还可以推算出按下 “ctrl” 的事件为
keydown 事件支持多个按键同时按下。当我们同时按下 “command” 和 “K” 键,会发生什么呢?
但是我们发现好像并没有 K:true 这个属性呀,那我们怎么去判断呢?别着急接着往下看。
我们可以看到键盘事件 event 身上有个 key 属性,它的值恰好是字符串类型的 “k”,
这里我直接公布写法,js 允许我们这样判断是否同时按下两个按键。
我们测试一下,我们去吧 App.vue 文件内的这两个按钮给去掉
然后再打印一下我们按下 command 和 k 的时候。
测试一下:
https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/afe0ebc9c5e14c329b6aa96d20319186~tplv-k3u1fbpfcp-zoom-in-crop-mark:1512:0:0:0.awebp?
七. 添加出现的动画
在上面我们可以看到,这样突然的出现好像有一丝丝的突兀。我希望这个搜索框在出现的时候,可以有那么一丝丝的平移效果,(类似于下面的效果)该如何做呢?🤔
https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b57cccc75d3545f3b3dd551c1fda3676~tplv-k3u1fbpfcp-zoom-in-crop-mark:1512:0:0:0.awebp?
我这里介绍一种较为简单的思路,我们在 App.vue 文件的 style 内预设一个 Css 动画,并起好名字。叫做"searchInput"
然后回到我们 searBar.vue 的组件去,给我们这个组件最外层的起一个好听的名字,我这里就叫做 searchBarWrapper。
然后回到我们的SearchBar.ts文件内,也就是放我们SeachBarCreator构造函数的那个文件内。(tips:不是useSearch.ts哦)我这里解释一下思路,在调用render函数后,这个组件其实已经渲染成为一个真实的dom元素,只不过我们还没给它指定渲染的位置。既然是真实的dom,那么我们就可以通过document.getElementById这个方法(querySelector同理,一个意思)拿到这个SearchBar.vue组件 ,接下来我只需要在调用document.body.insertBefore方法前,给它添加上刚刚我们在App.vue里预设好的类名,searchInput,就完美达成我们想要的效果了。
注意:style ,这个点仅仅是类名选择器,不要忘记了基础知识
测试一下效果:
https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ea07aa7fad3b4d8693b441d6e8efd808~tplv-k3u1fbpfcp-zoom-in-crop-mark:1512:0:0:0.awebp?
八. 自动聚焦
在弹出框的 input 框实现自动聚焦相比于之前讲的就非常简单了,我在这里一笔带过了。只需要在 nextTick 中调用 input 本身的 focus 方法即可。
总结:
之所以不喜欢使用真代码去写文章而大量使用截图的原因是:我自己在搜索到自己想要的文章后,也会喜欢直接看有没有最后的成品代码,然后直接复制就拿过去用了,而往往忽略了自己动手去实现一遍才是真正理解了的过程。
所以我写代码的时候,尽量不写特别复杂的逻辑,而写一些很简单的几行代码去实现某一个功能。是因为我希望你们真正带入自己的思考,和一步步体会这个实现过程,从而举一反三。
如果你认真看了该文章,你也许会明白现在很多组件库的底层实现原理其实就是这样的,比如全局弹出的dialog,modal框等等。我们要去理解组件库组件实现的思路,而不是一味的复制粘贴。
这个搜索框有很多可以更加优化的地方,你们可以带入自己的思考去想一想。比如
1.如何保存搜索历史?
2.如何实现实时的给出搜索联想
与君共勉才是我的初衷...
源码
这里贴出核心代码SearchBar.ts文件的源码,希望读者可以仅作为参考使用,希望不要直接复制粘贴。
import { h, render } from "vue"
import SearchBar from "./SearchBar.vue"
class SearchBarCreator {
container: HTMLElement
appElement: HTMLElement | null
showing: boolean
_dismiss: () => void
constructor() {
this.container = document.createElement("div")
this.showing = false
this.appElement = document.body.querySelector("#app")
this.present.bind(this)
this.dismiss.bind(this)
this._dismiss = this.dismiss.bind(this)
}
present() {
if (this.showing) {
this.dismiss()
} else {
const SearchBar = h(h(SearchBar))
render(SearchBar, this.container)
const searchBarWrapperDOM =
this.container.querySelector("#searchBarWrapper")
searchBarWrapperDOM?.classList.add("animate-searchInputAnimation")
document.body.insertBefore(this.container, document.body.firstChild)
this.showing = true
this.appElement?.addEventListener("click", this._dismiss)
}
}
dismiss() {
if (this.showing && this.container) {
render(null, this.container)
document.body.removeChild(this.container)
this.showing = false
this.appElement?.removeEventListener("click", this._dismiss)
} else {
console.log("不需要关闭")
}
}
}