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

作者:夕水

查看效果

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

截屏2024-09-30 上午11.21.39.png

以上悬浮面板分为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选择器,类选择器以及标签选择器,属性选择器的功能也与原版有所差异,想要实现完整的功能,还需要在此基础上进行扩展。

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

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

截屏2024-09-30 上午11.37.39.png

创建项目

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

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

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

截屏2024-09-30 上午11.43.07.png

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

截屏2024-09-30 上午11.41.35.png

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

截屏2024-09-30 上午11.44.48.png

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

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

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

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

截屏2024-09-30 上午11.48.51.png
截屏2024-09-30 上午11.49.11.png

使用如下命令安装依赖:
登录后复制

shell 复制代码
pnpm add prismjs @types/prismjs

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

截屏2024-09-30 上午11.51.24.png

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

  1. utils.ts: 存放工具函数。
  2. hooks.tsx: 存放钩子函数。
  3. test.ts: 默认测试的样式代码字符串。
  4. const.ts: 一些常量。
  5. 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标签里面的具体的选择器元素。由于代码高亮插件为我们进行了选择器匹配,如下图所示:

20240930-121350.jpeg

因此,我们只需要把类名为selector的元素收集起来,然后监听每个元素的悬浮事件即可,这里由于要收集子元素,因此我们就需要通过ref来访问父元素pre标签元素,这也是前面提到要用ForwardedRef包裹代码高亮的原因。

我们可以获取到这个元素的偏移量,并基于这个偏移量来设置工具提示的偏移量,从而达到悬浮到选择器上就可以在对应的位置出现工具提示的功能。

ps: 当然这里我们的实现还是不完善的,更完善的有现成的插件来实现,例如popper.js

现在,我们先来看看我们的悬浮提示组件,我们是将悬浮提示的元素添加到body元素中的,因此我们需要使用createPortal api

现在这个工具提示组件,我们只需要3个属性,如下:

  1. visible: 控制工具提示是否渲染。
  2. children: 渲染子节点,应该是一个react node。
  3. 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;

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

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

css 复制代码
#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选择器呢?
登录后复制

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

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

css 复制代码
.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 [content, setContent] = useState('');

根据以上分析,我们可以写出如下代码:
登录后复制

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)}
/>

想要查看完整示例源码的可以前往查看:示例源码

相关推荐
loey_ln10 分钟前
webpack配置和打包性能优化
前端·webpack·性能优化
建群新人小猿11 分钟前
会员等级经验问题
android·开发语言·前端·javascript·php
爱上语文12 分钟前
HTML和CSS 表单、表格练习
前端·css·html
djk888823 分钟前
Layui Table 行号
前端·javascript·layui
小肚肚肚肚肚哦1 小时前
盘点浏览器盒模型中各种 width、height 、边距和位置属性
css·html
NightCyberpunk1 小时前
HTML、CSS
前端·css·html
xcLeigh1 小时前
HTML5超酷响应式视频背景动画特效(六种风格,附源码)
前端·音视频·html5
zhenryx1 小时前
前端-react(class组件和Hooks)
前端·react.js·前端框架
ZwaterZ1 小时前
el-table-column自动生成序号&&在序号前插入图标
前端·javascript·c#·vue