Bootstrap

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

作者:夕水

查看效果

作为一个前端开发者,应该基本都会用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编辑器地址 www.marscode.cn/dashboard 。

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

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

;