作者:夕水
查看效果
作为一个前端开发者,应该基本都会用VSCode来做开发,所以也应该见过如下这张图的效果:
以上悬浮面板分为2个部分展示内容。
<element class="hljs-attr">
: 代表元素只有一个类名叫hljs-attr的类选择器,如果有id,比如#app .test
那么这里的展示将变成<element id="app" class="test">
。- 选择器特定性和(0,1,0): 前者是一个链接,可以跳转到mdn,后者分为3个部分,第一个部分代表id选择器的数量,第二个部分代表类选择器的数量,第三个部分代表标签选择器的数量。因此(0,1,0)就代表只有一个类选择器,如果是(1,1,1)代表id,类,标签选择器都有1个,即类似
#app .test div
这样的选择器。
介绍完了以上的功能,接下来,我们就来实现这样一个小组件,不过与原版有所区别的是,我们的实现没有考虑到:hover等伪类或者:first-letter之类的伪元素选择器,我们只做了id选择器,类选择器以及标签选择器,属性选择器的功能也与原版有所差异,想要实现完整的功能,还需要在此基础上进行扩展。
还有一点就是我们增加了总权重的展示。
接下来,我们来看一下我们的最终效果,如下图所示:
创建项目
第一步,先前往豆包MarsCode在线IDE编辑器地址 www.marscode.cn/dashboard 。
ps: 这里需要登陆,自行用各自掘金账号登陆即可。
第二步,选择创建一个项目,如下图所示:
在弹出的面板中选择从模板创建,并选择react。如下图所示:
创建好之后,会为我们生成一个代码地址,并直接跳转,现在你可能会看到如下图所示的目录结构:
它也会为我们自动安装依赖并运行代码。
根据效果图,我们可以知道我们需要用到代码高亮插件,这里的代码高亮插件我选择的是prismjs,读者也可以自行选择使用代码高亮插件,例如: highlight.js等。
尽管我们也可以自行实现一个代码高亮插件,不过这可以当作另一篇文章的主题了,这里就不自行实现了。
因此,我们需要新开一个终端,或者停止当前终端,来安装代码高亮插件。如下图所示:
使用如下命令安装依赖:
pnpm add prismjs @types/prismjs
然后我们需要调整一下项目目录结构,最终的目录应该如下图所示:
下面一一说明目录及文件结构:
- utils.ts: 存放工具函数。
- hooks.tsx: 存放钩子函数。
- test.ts: 默认测试的样式代码字符串。
- const.ts: 一些常量。
- components: 存放一些封装好的组件。
代码实现
前期项目准备工作已完成,接下来就进入我们的编码时刻。
代码高亮组件
让我们来分析一下,首先我们需要基于prism.js封装一下代码高亮插件,在components目录下新建一个HighLightCode.tsx。
根据prism.js的文档描述,我们应该知道它是如下这样使用的:
TypeScript
const hignlightCode = Prism.highligh(code, lang, lang);
其中第一个参数就是要高亮的代码字符串,第二个参数是导入的语言包,从Prism.languages下取,第三个参数就是我们定义的语言字符串,如: 'html'和'css',然后它的返回值就是经过处理的高亮代码字符串。
当然这里我们只需要用到这2种语言。现在这个代码高亮插件我们就只需要定义2个props即可,如下所示:
TypeScript
export interface HighLightCodeProps extends React.HTMLAttributes<HTMLPreElement>{
code?: string;
language?: keyof Prism.Languages & string;
}
也许有人好奇React.HTMLAttributes<HTMLPreElement>
,这里,我们会使用pre和code标签来展示代码,最外层是一个父组件,因此我们需要继承pre标签本身有的一些属性,例如: onClick事件,又或者是其它的一些html属性,所以这里才会继承这个接口。
现在这个组件的结构应该是这样的:
TypeScript
import Prism from 'prismjs';
import 'prismjs/themes/prism.css';
export interface HighLightCodeProps extends React.HTMLAttributes<HTMLPreElement>{
code?: string;
language?: keyof Prism.Languages & string;
}
const HighLightCode = ({ code, language = 'css',...rest }: HighLightCodeProps) => {
// ...
return (
<>
<pre className='pre' {...rest }>
<code
{/*...*/}
/>
</pre>
</>
)
}
export default HighLightCode
接下来,我们主要是用useMemo来缓存获取高亮后的代码,并使用dangerouslySetInnerHTML属性绑定到code标签中即可,最后我们在父组件使用的时候,还需要访问pre DOM元素,因此我们需要使用ref属性配合ForwardedRef方法使用来完善这个组件。
说明: 如果对ref语法不熟悉,可以查看这篇文章深入浅出React中的refs。
也许有人会好奇这里为什么要访问DOM元素,这个我们可以放在后面来说明,接下来,我们还是来看看我们完善后的组件。
TypeScript
// ...
import { ForwardedRef, forwardRef, useMemo } from 'react';
// ...
const HighLightCode = forwardRef(({ code, language = 'css',...rest }: HighLightCodeProps, ref: ForwardedRef<HTMLPreElement>) => {
// 这里相当于监听code是否变化,如果未变化,将采取缓存值
const hightLightCode = useMemo(() => {
if (code) {
return Prism.highlight(code, Prism.languages[language], language);
}
return '';
}, [code])
return (
<>
<pre className='pre' ref={ref} {...rest }>
<code dangerouslySetInnerHTML={{ __html: hightLightCode }} />
</pre>
</>
)
})
export default HighLightCode
超链接组件
由于用到了超链接,因此我们稍微基于a标签改造一个Link组件,当然也可以不改造,这取决于你自己。在const.ts中,我们也定义了mdn的跳转链接。如下:
TypeScript
// const.ts中
export const MDN_LINK = "https://developer.mozilla.org/docs/Web/CSS/Specificity"
接下来我们来看Link组件。如下所示:
TypeScript
import { AnchorHTMLAttributes } from "react";
export interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
children?: React.ReactNode;
}
const Link = ({ children,rel = 'noopener noreferrer',target = '_blank',...rest }: LinkProps) => {
const attrMap = {
...rest,
rel,
target
}
return (
<a {...attrMap}>{children}</a>
)
}
export default Link;
其实说白了,我们主要是增加了rel属性和target属性的默认值吗,其余都是由使用者来自行定义。
工具提示组件
接下来,我们需要实现我们的工具提示组件,这里我们需要访问到pre标签里面的具体的选择器元素。由于代码高亮插件为我们进行了选择器匹配,如下图所示:
因此,我们只需要把类名为selector的元素收集起来,然后监听每个元素的悬浮事件即可,这里由于要收集子元素,因此我们就需要通过ref来访问父元素pre标签元素,这也是前面提到要用ForwardedRef包裹代码高亮的原因。
我们可以获取到这个元素的偏移量,并基于这个偏移量来设置工具提示的偏移量,从而达到悬浮到选择器上就可以在对应的位置出现工具提示的功能。
ps: 当然这里我们的实现还是不完善的,更完善的有现成的插件来实现,例如popper.js
现在,我们先来看看我们的悬浮提示组件,我们是将悬浮提示的元素添加到body元素中的,因此我们需要使用createPortal api。
现在这个工具提示组件,我们只需要3个属性,如下:
- visible: 控制工具提示是否渲染。
- children: 渲染子节点,应该是一个react node。
- style: 样式设置,主要用来设置偏移量。
基于以上的分析,我们的tootip组件结构如下:
TypeScript
import { CSSProperties, useId, useMemo } from "react"
import { createPortal } from "react-dom";
export interface TooltipProps extends React.HTMLAttributes<HTMLDivElement>{
visible?: boolean;
children?: React.ReactNode;
style?: CSSProperties;
}
const Tooltip = ({ visible,children,style,...rest }: TooltipProps) => {
const toolTipId = useId();
return (
<>
{
visible && createPortal(
<div id={toolTipId} className="tooltip" style={style} {...rest}>
{children}
</div>,
document.body
)
}
</>
)
}
export default Tooltip;
可以看到这个组件代码结构很简单,然后就是我们的样式代码:
TypeScript
.tooltip {
position: fixed;
padding: 8px 12px;
border: 1px solid #dcdcdc;
background-color: #f5f4f4;
color: #979698;
border-radius: 8px;
box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.1);
min-width: 150px;
min-height: 60px;
}
.tooltip::before {
content: "";
width: 0;
height: 0;
border-style: solid;
border-width: 10px;
border-color: transparent transparent #f5f4f4 transparent;
position: absolute;
left: -20px;
top: 10px;
transform: rotate(-90deg);
}
.tooltip a {
margin-right: 5px;
}
稍微加点对话框的背景和边框色,也加了一个三角形,形成了如前面图中所看到的那样的一个对话框提示。
接下来,我们还要在这个组件的基础上去完善css权重工具提示的组件,但在这之前,我们有必要做一件事,让我们继续往下看。
封装一个解析css选择器字符串的hooks
接下来的这个 Hook 可以用于解析 CSS 选择器字符串并生成对应的 HTML 结构,例如将 #id.class [attr]
转换为 <element id="id" class="class" attr="attr">
。
我们应该如何实现这个解析器呢?首先我们就需要分析css选择器的特性了。我们以一个示例来说明,如下所示:
TypeScript
#root .app > span + .hover ~ .text, .active {
// 样式代码
}
以上的css选择器,#root是一个id选择器,.app是一个类选择器,以此类推,我们可以看到多个css选择器都是由固定的符号来区分的,如果存在空白,或者","又或者是">",那么前后一定是拆分成2个css选择器的,我们需要根据这些符号将选择器拆分出来,组成一个css选择器数组,不过这里为了统一拆分,我们需要去匹配字符串,转换成统一的分隔符,我这里取的是"s-"。
根据以上分析,我们可以写出如下代码:
TypeScript
export const useCssTypeCode = (str: string) => {
const splitSymbol = [' ', '>', '+', '~', ',']
splitSymbol.forEach((symbol) => {
// 将匹配到的符号转换成统一的s-分隔符
str = str.replace(symbol, 's-');
});
// ...
}
还没有结束,假如我们碰到的是这样的css选择器呢?
TypeScript
#app,
.app,
.text {
// 样式代码
}
因此,我们在转换后还需要过滤一下空白,再根据','来分隔一次。代码如下:
TypeScript
export const useCssTypeCode = (str: string) => {
const splitSymbol = [' ', '>', '+', '~', ',']
splitSymbol.forEach((symbol) => {
// 将匹配到的符号转换成统一的s-分隔符,然后过滤掉空白,并根据,来继续做拆分
str = str.replace(symbol, 's-').replace(/\s/g, '').replace(/,/g, 's-');
});
// ...
}
接下来我们就根据"s-"来拆分成字符串选择器数组,然后我们依次遍历数组元素,对每一个选择器字符串做解析处理。
对于我们的id选择器,它的第一个字符一定是"#",依次类推,类选择器是".",属性选择器是"[",不过别忘了我们的通配符选择器"*",当然这里为了简便化,暂时不考虑":"也就是伪类选择器和伪元素选择器的情况。
接下来我们就依据第一个字符串来拆分判断,就可以解析出结果来了,不过别忘了效果里面的(0,0,0)的展示,因此这里我们最终的结果需要返回一个对象,它的结构应该是如下这样:
TypeScript
{ res: '', id: 0, className: 0, tag: 0 }
其中id代表统计的id选择器的数量,用作括号里的第一个值展示,依次类推。
有了如上的分析,我们就可以完善我们的解析钩子函数了,如下所示:
TypeScript
export const useCssTypeCode = (str: string) => {
const splitSymbol = [' ', '>', '+', '~', ',']
splitSymbol.forEach((symbol) => {
str = str.replace(symbol, 's-').replace(/\s/g, '').replace(/,/g, 's-');
});
return str.split('s-').reduce((res, item) => {
if (item[0] === '#') {
res.res += `<element id="${item.slice(1)}">`;
res.id += 1;
} else if (item[0] === '.') {
res.className += 1;
res.res += `<element className="${item.slice(1)}">`;
} else if (item[0] === '[') {
res.className += 1;
res.res += `<element attr="${item.slice(1, -1)}">`
} else if (item === '*') {
res.res += `<element>`
} else {
res.tag += 1;
res.res += `<${item}>`
}
return res;
}, { res: '', id: 0, className: 0, tag: 0 })
}
这里虽然考虑了属性选择器,但是属性选择器的解析展示是还要继续进行完善的,不过这里就暂时这样,然后再次说明,我们没有考虑伪类选择器和伪元素选择器的情况,如果要考虑,还要再增加一个判断分支,而且每个选择器里面也需要去进行判断,这种情况是比较复杂的。例如考虑一下如下的选择器:
TypeScript
.text:hover {}
#app:hover,.test:hover {}
// ...
这些场景是很多的,我们要考虑完善的话,那就要增加很多逻辑。好了废话不多说,让我们继续往下看。
最后的对话框提示组件
有了前面所说的钩子函数,我们的代码对话框展示组件实现起来就简单多了。
接下来的代码对话框提示组件,我们所需要做的无非就是将前面的所有代码合并起来使用,代码如下:
TypeScript
import { CSSProperties } from "react";
import Tooltip from "./Tooltip";
import HighLightCode from "./HighLightCode";
import Link from "./Link";
import { MDN_LINK } from "../const";
import { useCssTypeCode } from "../hooks";
export interface CodeTooltipProps extends React.HTMLAttributes<HTMLDivElement>{
visible?: boolean;
style?: CSSProperties;
code: string;
}
const CodeTooltip = ({ visible, style,code,...rest }: CodeTooltipProps) => {
const { res,id,tag,className } = useCssTypeCode(code);
return (
<Tooltip visible={visible} style={style} {...rest}>
// 对话框里的html代码展示
<div className="line-code">
<HighLightCode code={res} language="html" />
</div>
// 链接展示
<Link href={MDN_LINK}>选择器特性:</Link>
// 权重展示
<span>({ id },{ className },{ tag })</span>
// 计算总权重
<div>总权重为:{ id * 100 + className * 10 + tag }</div>
</Tooltip>
)
}
export default CodeTooltip;
App组件
在App组件,我们还需要做一些工作,我们需要一个HighLightCode(高亮代码)组件,用于展示高亮的代码,一个CodeTooltip组件,用于展示悬浮的对话框。
我们给高亮代码组件绑定一个ref,然后收集选择器子元素,并具体给每一个子元素监听悬浮事件,这里为了防止频繁监听,我们还需要使用防抖函数。
然后我们监听悬浮事件,存储当前这个选择器子元素所占据的偏移量,我们需要依据这个偏移量去计算对话框的偏移位置。
然后,对话框的显示与隐藏条件呢?这很容易,我们只要根据这个偏移量来判断即可,怎么判断呢?
我们的偏移量应该是如下值:
TypeScript
const [position, setPosition] = useState<{ left?: number, top?: number }>({});
根据以上分析,我们可以写出如下代码:
TypeScript
// ...
const App = () => {
const codeRef = useRef<HTMLPreElement>(null);
const [position, setPosition] = useState<{ left?: number, top?: number }>({});
const [content, setContent] = useState('');
useEffect(() => {
if (codeRef.current) {
// ....
}
}, [])
const visible = useMemo(() => !isEmptyObject(position), [position])
return (
<div className="app">
<HighLightCode
// 测试的样式代码
code={testStyle}
ref={codeRef}
/>
<CodeTooltip
code={content}
visible={visible}
style={position}
/>
</div>
);
};
export default App;
以下是我写的一个简单的样式代码:
TypeScript
export const testStyle = `
* {
margin: 0;
padding: 0;
font-family: JetBrainsMono-Regular, "微软雅黑", sans-serif;
box-sizing: border-box;
}
#app {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
#root>div {
width: 100%;
}
.flex {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
body {
margin: 0;
}
接下来,我们需要根据codeRef来收集每一个选择器元素,然后添加悬浮事件的监听,如果鼠标悬浮上去,我们就将当前选择器元素的偏移量和内容存到状态中,注意这里的偏移量,我们是控制了边界值的。理论上我们的左偏移值left应该是当前元素的左偏移值加上它的宽度,再给一个固定的间距值,而顶部偏移量top则直接用当前元素的top减去高度即可。如下:
TypeScript
useEffect(() => {
if (codeRef.current) {
const selectorElements = codeRef.current.querySelectorAll('span.selector');
selectorElements.forEach((el) => {
el.addEventListener('mouseenter', debounce(() => {
const { left, top, width, height } = el.getBoundingClientRect();
const leftValue = left + width + 10,
topValue = top - height;
if (position.left !== leftValue || position.top !== topValue) {
setPosition({ left: Math.min(leftValue, window.innerWidth), top: Math.min(topValue, window.innerHeight) });
}
if (el.textContent !== content && el.textContent) {
setContent(el.textContent);
}
}, 200))
});
}
}, [])
这里,我们还做了一个判断,就是position中的left和top不相等,也就是不是同一个位置,我们才存储,选择器内容同理。
以上还涉及到了2个工具函数,如何判断一个对象是否为空,以及我们说的防抖函数。这2个工具函数很常用,原理实现也很简单,这里就不做过多说明了,网上也有很多教程说明。我们直接看代码即可:
TypeScript
export const isEmptyObject = (val: unknown) => {
if (val === null || val === undefined) return true;
if (typeof val !== 'object') return true;
if (Array.isArray(val)) return val.length === 0;
return Object.keys(val).length === 0;
};
export const debounce = <T extends any[]>(handler: (...args: T) => void, ms: number): ((...args: T) => void) => {
let time: ReturnType<typeof setTimeout> | null = null;
return function fn(this: typeof fn,...args: T) {
time && clearTimeout(time);
time = setTimeout(() => handler.apply(this, args), ms);
};
};
你以为到了这里就完了吗?不还有最后一步,也就是我们的鼠标如果移出到代码高亮区域之外,我们的对话框则应该需要隐藏,这里还不包括悬浮到对话框区域中。
由于我们的对话框是额外添加的dom,因此我们需要定义一个状态,用来确定鼠标是否悬浮到对话框区域中。
这很简单,CodeTooltip监听mouseenter和mouseleave然后分别修改状态即可。如下:
TypeScript
// 是否在对话框区域
const [isPanel, setIsPanel] = useState(true);
// CodeTooltip组件中
<CodeTooltip
// ...
onMouseEnter={() => setIsPanel(true)}
onMouseLeave={() => setIsPanel(false)}
/>
最后,我们只需要监听代码高亮组件的鼠标移出事件,然后重置position值即可。如下:
TypeScript
<HighLightCode
// ...
onMouseLeave={debounce((e) => {
if (!isPanel) {
setPosition({});
setContent('');
}
}, 200)}
/>
想要查看完整示例源码的可以点击这里查看~