写点什么

我用豆包 MarsCode IDE 做了一个 CSS 权重小组件

作者:豆包MarsCode
  • 2024-11-20
    北京
  • 本文字数:8355 字

    阅读完需:约 27 分钟

作者:夕水


查看效果

作为一个前端开发者,应该基本都会用 VSCode 来做开发,所以也应该见过如下这张图的效果:



以上悬浮面板分为 2 个部分展示内容。

  1. <element class="hljs-attr">: 代表元素只有一个类名叫 hljs-attr 的类选择器,如果有 id,比如#app .test那么这里的展示将变成<element id="app" class="test">

  2. 选择器特定性和(0,1,0): 前者是一个链接,可以跳转到 mdn,后者分为 3 个部分,第一个部分代表 id 选择器的数量,第二个部分代表类选择器的数量,第三个部分代表标签选择器的数量。因此(0,1,0)就代表只有一个类选择器,如果是(1,1,1)代表 id,类,标签选择器都有 1 个,即类似#app .test div这样的选择器。

介绍完了以上的功能,接下来,我们就来实现这样一个小组件,不过与原版有所区别的是,我们的实现没有考虑到:hover 等伪类或者:first-letter 之类的伪元素选择器,我们只做了 id 选择器,类选择器以及标签选择器,属性选择器的功能也与原版有所差异,想要实现完整的功能,还需要在此基础上进行扩展。

还有一点就是我们增加了总权重的展示。

接下来,我们来看一下我们的最终效果,如下图所示:



创建项目

第一步,先前往豆包MarsCode 在线 IDE 编辑器地址

ps: 这里需要登陆,自行用各自掘金账号登陆即可。

第二步,选择创建一个项目,如下图所示:



在弹出的面板中选择从模板创建,并选择 react。如下图所示:



创建好之后,会为我们生成一个代码地址,并直接跳转,现在你可能会看到如下图所示的目录结构:



它也会为我们自动安装依赖并运行代码。

根据效果图,我们可以知道我们需要用到代码高亮插件,这里的代码高亮插件我选择的是prismjs,读者也可以自行选择使用代码高亮插件,例如: highlight.js等。

尽管我们也可以自行实现一个代码高亮插件,不过这可以当作另一篇文章的主题了,这里就不自行实现了。

因此,我们需要新开一个终端,或者停止当前终端,来安装代码高亮插件。如下图所示:




使用如下命令安装依赖:

pnpm add prismjs @types/prismjs
复制代码

然后我们需要调整一下项目目录结构,最终的目录应该如下图所示:


下面一一说明目录及文件结构:

  1. utils.ts: 存放工具函数。

  2. hooks.tsx: 存放钩子函数。

  3. test.ts: 默认测试的样式代码字符串。

  4. const.ts: 一些常量。

  5. components: 存放一些封装好的组件。

代码实现

前期项目准备工作已完成,接下来就进入我们的编码时刻。

代码高亮组件

让我们来分析一下,首先我们需要基于 prism.js 封装一下代码高亮插件,在 components 目录下新建一个 HighLightCode.tsx。

根据 prism.js 的文档描述,我们应该知道它是如下这样使用的:

const hignlightCode = Prism.highligh(code, lang, lang);
复制代码

其中第一个参数就是要高亮的代码字符串,第二个参数是导入的语言包,从 Prism.languages 下取,第三个参数就是我们定义的语言字符串,如: 'html'和'css',然后它的返回值就是经过处理的高亮代码字符串。

当然这里我们只需要用到这 2 种语言。现在这个代码高亮插件我们就只需要定义 2 个 props 即可,如下所示:

export interface HighLightCodeProps extends React.HTMLAttributes<HTMLPreElement>{    code?: string;    language?: keyof Prism.Languages & string;}
复制代码

也许有人好奇React.HTMLAttributes<HTMLPreElement>,这里,我们会使用 pre 和 code 标签来展示代码,最外层是一个父组件,因此我们需要继承 pre 标签本身有的一些属性,例如: onClick 事件,又或者是其它的一些 html 属性,所以这里才会继承这个接口。

现在这个组件的结构应该是这样的:

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 元素,这个我们可以放在后面来说明,接下来,我们还是来看看我们完善后的组件。

// ...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 的跳转链接。如下:

// const.ts中export const MDN_LINK = "https://developer.mozilla.org/docs/Web/CSS/Specificity"
复制代码

接下来我们来看 Link 组件。如下所示:

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 个属性,如下:

  1. visible: 控制工具提示是否渲染。

  2. children: 渲染子节点,应该是一个 react node。

  3. style: 样式设置,主要用来设置偏移量。

基于以上的分析,我们的 tootip 组件结构如下:

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;
复制代码

可以看到这个组件代码结构很简单,然后就是我们的样式代码:

.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 选择器的特性了。我们以一个示例来说明,如下所示:

#root .app > span + .hover ~ .text, .active {    // 样式代码}
复制代码

以上的 css 选择器,#root 是一个 id 选择器,.app 是一个类选择器,以此类推,我们可以看到多个 css 选择器都是由固定的符号来区分的,如果存在空白,或者","又或者是">",那么前后一定是拆分成 2 个 css 选择器的,我们需要根据这些符号将选择器拆分出来,组成一个 css 选择器数组,不过这里为了统一拆分,我们需要去匹配字符串,转换成统一的分隔符,我这里取的是"s-"。

根据以上分析,我们可以写出如下代码:

export const useCssTypeCode = (str: string) => {    const splitSymbol = [' ', '>', '+', '~', ',']    splitSymbol.forEach((symbol) => {        // 将匹配到的符号转换成统一的s-分隔符        str = str.replace(symbol, 's-');    });    // ...}
复制代码

还没有结束,假如我们碰到的是这样的 css 选择器呢?

#app,.app,.text {  // 样式代码}
复制代码

因此,我们在转换后还需要过滤一下空白,再根据','来分隔一次。代码如下:

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)的展示,因此这里我们最终的结果需要返回一个对象,它的结构应该是如下这样:

{ res: '', id: 0, className: 0, tag: 0 }
复制代码

其中 id 代表统计的 id 选择器的数量,用作括号里的第一个值展示,依次类推。

有了如上的分析,我们就可以完善我们的解析钩子函数了,如下所示:

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 })}
复制代码

这里虽然考虑了属性选择器,但是属性选择器的解析展示是还要继续进行完善的,不过这里就暂时这样,然后再次说明,我们没有考虑伪类选择器和伪元素选择器的情况,如果要考虑,还要再增加一个判断分支,而且每个选择器里面也需要去进行判断,这种情况是比较复杂的。例如考虑一下如下的选择器:

.text:hover {}#app:hover,.test:hover {}// ...
复制代码

这些场景是很多的,我们要考虑完善的话,那就要增加很多逻辑。好了废话不多说,让我们继续往下看。

最后的对话框提示组件

有了前面所说的钩子函数,我们的代码对话框展示组件实现起来就简单多了。

接下来的代码对话框提示组件,我们所需要做的无非就是将前面的所有代码合并起来使用,代码如下:

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,然后收集选择器子元素,并具体给每一个子元素监听悬浮事件,这里为了防止频繁监听,我们还需要使用防抖函数。

然后我们监听悬浮事件,存储当前这个选择器子元素所占据的偏移量,我们需要依据这个偏移量去计算对话框的偏移位置。

然后,对话框的显示与隐藏条件呢?这很容易,我们只要根据这个偏移量来判断即可,怎么判断呢?

我们的偏移量应该是如下值:

 const [position, setPosition] = useState<{ left?: number, top?: number }>({});
复制代码

很显然如果是一个空对象,那么我们就不展示对话框,否则就展示。

还有,这里我们还需要存储选择器的内容,为什么呢?因为我们需要基于这个选择器的内容进行解析,并渲染到对话框中。即:

const [content, setContent] = useState('');
复制代码

根据以上分析,我们可以写出如下代码:

// ...
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;
复制代码


以下是我写的一个简单的样式代码:

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 减去高度即可。如下:

  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 个工具函数很常用,原理实现也很简单,这里就不做过多说明了,网上也有很多教程说明。我们直接看代码即可:

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 然后分别修改状态即可。如下:

// 是否在对话框区域const [isPanel, setIsPanel] = useState(true);// CodeTooltip组件中<CodeTooltip   // ...   onMouseEnter={() => setIsPanel(true)}   onMouseLeave={() => setIsPanel(false)}/>
复制代码

最后,我们只需要监听代码高亮组件的鼠标移出事件,然后重置 position 值即可。如下:

<HighLightCode   // ...   onMouseLeave={debounce((e) => {      if (!isPanel) {        setPosition({});        setContent('');      }   }, 200)}/>
复制代码


想要查看完整示例源码的可以点击这里查看。

用户头像

还未添加个人签名 2024-08-27 加入

用 AI 激发创造

评论

发布
暂无评论
我用豆包MarsCode IDE 做了一个 CSS 权重小组件_CSS_豆包MarsCode_InfoQ写作社区