普通视图

发现新文章,点击刷新页面。
今天 — 2025年9月16日技术

“蒙”出花样!用 CSS Mask 实现丝滑视觉魔法

作者 墨渊君
2025年9月16日 13:43

引言

在前端开发中, CSS 不再仅仅用于布局和样式修饰, 越来越多的高级视觉效果也可以通过纯 CSS 实现, 其中就包括令人惊艳的 蒙版 效果。mask-image 作为 CSS 中用于控制元素可见区域的强大属性, 能够帮助开发者实现类似 Photoshop 中的遮罩操作, 无需借助复杂的图像处理。无论是实现渐隐文字、柔和的图像遮罩, 还是动态的 手电筒 追光效果, mask-image 都提供了灵活而优雅的解决方案。本文将深入介绍 mask-image 的基本用法、支持的各种类型(如渐变、SVG)、配套属性 (如 mask-mode), 并结合多个实战示例, 带你全面掌握 CSS 蒙版的使用技巧。

一、基本语法

mask-imageCSS 中用来定义蒙版效果的一个属性。它可以根据一个图像或渐变、控制 元素 的可见区域, 实现类似 Photoshop 中的蒙版功能。

如下代码所示, background-image 所支持的 mask-image 都可以适用, 并且类似 background 系列属性, mask-* 中也都有对应的属性, 用于设置蒙版图参数

<div className="wrapper" />
<style>
.wrapper {
  width: 500px;
  height: 500px;

  // 设置背景图
  background-image: url('./bg.png');
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;

  // 设置蒙版
  mask-image: url('./mask_alpha.png');
  mask-size: 100%;
  mask-repeat: no-repeat;
  mask-position: center;
}
</style>

如下图是上面代码效果, 默认情况下 遮罩图像的 Alpha(透明度) 的值将会作用于 元素:

  • 透明度为 100% 的区域, 则会完全展示对应元素内容
  • 透明度为 0% 的区域, 则会完全隐藏对应元素内容

image

二、 使用渐变

background-image 我们这边也是可以使用渐变的:

<div className="wrapper" />
<style>
.wrapper {
  width: 500px;
  height: 500px;
  // 设置背景图
  background-image: url('./bg.png');
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
  // 设置渐变蒙版
  mask-image: linear-gradient(to bottom, rgb(255 0 0 / 0%) 0%, rgb(255 0 0 / 100%) 100%);
}
</style>

如上代码, mask-image 设置为一个渐变效果, 整个渐变只是在透明度上发生变化, 而最终效果如下:

image

三、 使用 SVG

注意不同于 background-image, mask-image 还可以设置为某个 svg 上的 <mask/>。如下代码所示我们在 svg 中定义了一个 <mask/> 并在 mask-image 中通过 url('#mask') 方式进行了引用。

<div className="wrapper" />
<svg viewBox="-10 -10 300 300">
  <mask id="mask">
    <ellipse
      cx="50%"
      cy="50%"
      rx="25%"
      ry="25%"
      fill="white"
    />
  </mask>
</svg>

<style>
.wrapper {
  width: 500px;
  height: 500px;
  // 设置背景图
  background-image: url('./bg.png');
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
  // 设置渐变蒙版
  mask-image: url('#mask');
}
</style>

而最终的效果如下:

image

四、可应用于任意元素

我们先将上文用到的蒙版图片转为 base64 数据

image

下面我们在掘金上进行尝试, 在控制台直接在 body 上设置蒙版:

image

上面例子是为了说明蒙版可作用于任何元素, 可直接将任意元素部分

五、 mask-mode: 设置蒙版规则

上文我们提到, 默认情况下 遮罩图像的 Alpha(透明度) 的值将会作用于 元素:

  • 透明度为 100% 的区域, 则会完全展示对应元素内容
  • 透明度为 0% 的区域, 则会完全隐藏对应元素内容

之所以如此是因为 mask-mode 默认值为 match-source 即模版作用规则由源决定, 这里就两种情况:

  • 如果 mask-image 引用的是 SVG 中的 <mask>, 则使用其 mask-type 属性值(如果存在)。如果未明确设置, 则此值默认为 Alpha 模式。
  • 如果蒙版图片的源是 <image><gradient>, 则使用蒙版图像的 Alpha 值。

那么除了 Alpha 模式之外还有其他的模式吗? 有的, 那就是 luminance(亮度), 即根据模版图片不同的亮度来控制元素的显隐:

  • 蒙版图片中黑色区域, 其对应位置元素完全透明(不可见)
  • 蒙版图片中白色区域, 其对应位置元素完全不透明(可见)
  • 蒙版图片中灰色区域, 其对应位置元素半透明
  • 蒙版图片中越白部分, 其对应位置元素透明度越高, 反之越黑则透明度越低

而这里我们可通过 mask-mode: <alpha | luminance | match-source>, 来设置模版作用模式, 如下代码所示

<div className="wrapper" />
<style>
  .wrapper {
    width: 500px;
    height: 300px;
    background-image: url("./bg.png");
    background-size: cover;
    background-position: center;
    background-repeat: no-repeat;
    // 设置蒙版模式为亮度模式
    mask-mode: luminance;
    // 蒙版图片为渐变, 即白 => 黑
    mask-image: linear-gradient(to bottom, #fff, #000);
    mask-size: 100%;
    mask-repeat: no-repeat;
    mask-position: center;
  }
</style>

最终效果如下, 蒙版白色部分元素完全可见, 黑色部分元素不可见, 中间灰色过渡对应的就是元素可见度的过渡

image

六、 DEMO: 渐变隐藏

我们要实现这么一个效果:

  • 我们有个容器, 容器高度是自适应
  • 同时容器最大高度默认为 200px 超出部分需要隐藏
  • 但是如果直接隐藏容器底部过渡就特别生硬
  • 如下代码所示:
<div className="container">
  <p>
    我经常遇到两种候选人。一种是一听算法题, 就两手一摊, 表情痛苦, 说“哥, 我天天写业务, 真没准备这个”。另一种呢, 正好相反, 题目一出, 眼睛一亮, 不出三十秒, 就把 LeetCode 上背得滚瓜烂熟的最优解, 一字不差地敲了出来, 然后一脸期待地看着我。
    说实话, 这两种, 都不是我最想看到的。
  </p>
  <p>
    这就引出了一个很多候选人都想问, 但不敢问的问题:“你们这些面试官, 到底怎么想的?你们明知道我们前端平时工作中, 99%的时间都用不上这些, 为什么非要折磨我们?”
    今天, 我就想站在桌子对面, 跟大伙掏心窝子地聊聊, 我们问算法题, 到底图个啥。
  </p>
</div>
<style>
  .container {
    width: 400px;
    color: #999;
    max-height: 200px;
    overflow: hidden;
  }
</style>

最后效果如下, 容器底部文字硬生生的被切断了

image

而更好的效果应该是有个完美的过渡效果, 这里我们就可以使用蒙版来处理: 如下代码所示, 蒙版是一个渐变, 从下到上, 渐变透明度从 0 ~ 100 一个过渡

<style>
  .container {
    width: 400px;
    color: #999;
    max-height: 200px;
    overflow: hidden;
+   mask-image: linear-gradient(to top, rgb(0 0 0 / 0%), rgb(0 0 0 / 100%) 40px);
  }
</style>

而最终效果如下: 整个过渡还是很丝滑的

image

然而有些站点为了实现上述过渡效果, 简单粗暴的在容器底部覆盖了一层渐变背景图! 而如此实在不够优雅, 如果页面背景复杂的话就完全没有效果!

image

但是用我们的方式肯定就能应付上面这情况了。

下面看另一个例子, 有代码如下:

<div className="container">
  我经常遇到两种候选人。一种是一听算法题, 就两手一摊, 表情痛苦, 说“哥, 我天天写业务, 真没准备这个”。另一种呢,
  正好相反, 题目一出, 眼睛一亮, 不出三十秒不出三十秒, 就把 LeetCode 上背得滚瓜烂熟的最优解, 一字不差地敲了出来,
  然后一脸期待地看着我。
</div>
<style>
  .container {
    width: 400px;
    color: #999;
    max-height: 200px;
    overflow: hidden;
  }
</style>

效果如下, 但是我们希望在第二行行末, 有个渐隐的效果:

image

下面我们直接改代码:

<div className="container">
  我经常遇到两种候选人。一种是一听算法题, 就两手一摊, 表情痛苦, 说“哥, 我天天写业务, 真没准备这个”。另一种呢,
  正好相反, 题目一出, 眼睛一亮, 不出三十秒不出三十秒, 就把 LeetCode 上背得滚瓜烂熟的最优解, 一字不差地敲了出来,
  然后一脸期待地看着我。
</div>
<style>
.container {
  width: 600px;
  color: #999;
  line-height: 1.6em;
  max-height: 3.2em;
  overflow: hidden;
  mask-mode: luminance;
  mask-image: radial-gradient(ellipse 1000px 300px at 100% 40px, #000, #fff 10%, #fff 10%);;
}
</style>

最终效果:

image

而这里实际上使用了椭圆渐变来实现蒙版, 我们可以把蒙版改为背景, 来看下蒙版图片的样子:

.container {
  width: 600px;
  color: #999;
  line-height: 1.6em;
  max-height: 3.2em;
  overflow: hidden;
+ background-image: radial-gradient(ellipse 1000px 300px at 100% 40px, #000, red 10%, red 10%);;
}

如下图所示:

image

七、 DEMO: 手电筒

你也许看到过下图类似的一个效果, 这看起来也许很唬人, 但是实际上了解了蒙版的概念后, 实现这么一个效果还是很简单的:

ScreenFlow

如下代码所示:

  1. 我们只需要通过 onMouseMoveonMouseLeave 来记录鼠标移动位置
  2. 并将鼠标位置存储下来, 转为 CSS 变量 --client-x 以及 --client-y
  3. 最后使用动态的 CSS 变量来渲染蒙版即可
const MaskImagePage: FC = () => {
  const [client, setClient] = useState(HIDE_CLIENT);

  const handleMouseMove = useCallback((e) => {
    setClient({
      x: e.clientX,
      y: e.clientY,
    });
  }, []);

  const handleMouseLeave = useCallback(() => {
    setClient(HIDE_CLIENT);
  }, []);

  return (
    <div
      className="page"
      onMouseMove={handleMouseMove}
      onMouseLeave={handleMouseLeave}
      style={{ 
        '--client-x': `${client.x}px`, 
        '--client-y': `${client.y}px`,
      }}
    />
  );
};
.page {
  width: 100vw;
  height: 100vh;
  background-image: url("./page_bg.png"); // 随便一个背景图, 这个不重要
  background-size: 100% 100%;
  mask-mode: luminance;
  mask-image: radial-gradient(circle at var(--client-x) var(--client-y), #fff, #000 100px);
}

八、DEMO: 惊艳的过渡转场

奇妙的 CSS MASK 一文中, 有这么一个效果:

608782-20200706102502985-2105393484

而这里就使用到蒙版, 蒙版图片如下所示, 其实就是好多帧蒙版拼接出来的一个长图:

608782-20200706102517861-616339127

而关键代码如下, 其实就是通过控制 mask-position 控制蒙版图片的位置, 从而实现过渡的转场效果:

.container {
  mask-image: url(https://i.imgur.com/AYJuRke.png);
  mask-size: 3000% 100%;
}

@keyframes maskMove {
  from {
    mask-position: 0 0;
  }
  to {
    mask-position: 100% 0;
  }
}

完整 DEMO 查看: mask 制作转场动画

九、参考文档

HTML <a> 标签的 rel 属性全解析:安全、隐私与 SEO 最佳实践

作者 芦苇Z
2025年9月16日 13:20

嗨,我是芦苇Z。
本文聊聊每个前端都踩过、却常被一笔带过的坑:<a> 标签的 rel 属性。
看完你马上能告诉自己: “以后凡是 target="_blank",我都先写 noopener,再按需补其他。”

1. 先搞懂两件事

问题 一句话答案
rel 是干嘛的? 告诉浏览器“我和那条链接是啥关系”,顺带决定要不要把上一页的信息交出去。
为啥总跟 target="_blank" 一起出现? 新标签页会拿到 window.opener 指针,能反过来篡改来源页——这叫 reverse tabnabbing

2. 安全两件套:noopener / noreferrer

属性 干什么 额外效果 推荐场景
noopener 切断 window.opener 所有 target="_blank" 都加,先保命
noreferrer 不发送 Referer 请求头 现代浏览器顺带切断 window.opener 不想让目标站知道你从哪来

“切断 window.opener”到底是啥?
默认情况下,新标签页里 window.opener 指向“是谁把我打开”的那个页面。
有了这把“回头钥匙”,新页面可以:

  • 把原来的页面跳转到假登录页(reverse tabnabbing 反向标签劫持);
  • opener.document 直接读写来源页(跨域时受限,但仍可 location 跳转)。
    写上 noopener 后,浏览器干脆把这把钥匙掰断——新页面拿到的 window.openernull,想搞事也找不到门。

一句话记住:
“只保命”noopener; (“断钥匙,防回头。”
“既保命又隐身”noopener noreferrer(两词顺序随意)。

<!-- 最常用:外链新开标签 -->
<a href="https://external.example"
   target="_blank"
   rel="noopener noreferrer">
   外部资源
</a>

3. 搜索引擎专用暗号

属性 翻译给谷歌听 实战场景
nofollow “别递权重,我只是提一嘴” 不可信的外链
ugc “这是用户自己发的” 评论、论坛帖子
sponsored “我收钱了” 广告、软文

不会阻止收录!只是不递权重,别再把 nofollowrobots.txt 用。

<!-- 评论里出现的链接 -->
<a href="https://xxx.com"
   target="_blank"
   rel="noopener nofollow ugc">
   用户分享的地址
</a>

4. 其他常见 rel 速查

类型 举例 一句话说明
文档关系 alternate author canonical prev/next 给爬虫或浏览器指路,不影响安全
预加载 preload prefetch dns-prefetch 性能优化,和 <link> 更配
书签 bookmark 永久锚点,几乎用不上

5. 我只想藏 Referer,行不行?

可以,但别全站甩 noreferrer,用 referrerpolicy 更细:

<a href="https://external.example"
   target="_blank"
   rel="noopener"
   referrerpolicy="strict-origin-when-cross-origin">
   外部资源
</a>

常见取值:
no-referrer / origin / strict-origin-when-cross-origin
记不住?Chrome DevTools Network 面板里看一眼 Referer 头就明白。

6. 三句话总结(贴墙用)

  1. 凡是 target="_blank",先写 rel="noopener"
  2. 不想暴露来源,再补 noreferrer 或直接上 referrerpolicy
  3. SEO 场景按需加 nofollow / ugc / sponsored,别让搜索引擎误会你卖链接。

复制下方代码,当成 Snippet 用,以后再也不用拼手指:

<!-- 外链通用模板 -->
<a href="URL"
   target="_blank"
   rel="noopener noreferrer nofollow">
   文本
</a>

祝你写链接写得开心,别再被安全扫描报告追着跑。

📚 参考资料

基于 React + MarkdownIt 的 Markdown 渲染器实践:支持地图标签和长按复制

作者 huabuyu
2025年9月16日 12:54

基于 React + MarkdownIt 的 Markdown 渲染器实践:支持地图标签和长按复制

在 React 小程序中,Markdown 内容不能直接渲染成组件。本文分享一个 基于 React + MarkdownIt 的 Markdown 渲染器实践,支持自定义样式、地图标签、列表渲染,以及长按复制功能。


功能概览

实现的核心功能如下:

  1. Markdown 渲染
    支持标题、段落、引用、列表、内联样式(加粗、斜体、删除线、标记、行内代码)、图片等。

  2. 自定义样式
    通过 MarkdownStyleConfig 定义段落、标题、列表、内联文本、图片和地图样式,实现灵活排版。

  3. 地图标签解析
    支持 <map latitude="xx" longitude="yy" name="位置" /> 标签。
    MarkdownIt 默认不识别 <map>,所以通过自定义插件生成 token,再在 AST 渲染阶段生成 Map 组件。

  4. 长按复制
    微信小程序中 <Text> 支持 userSelect 长按复制,但内联 <Text> 嵌套 Image/Map 会导致样式错乱。
    解决方式:

    • AST 构建阶段:内联和块级内容分开,Image/Map 不嵌套在 Text 内。
    • 渲染阶段:尽量用 Text 包裹内联内容,在最外层加 userSelect

MarkdownIt 和 Token

MarkdownIt 是高性能可扩展 Markdown 解析器,特点:

  • 输出 token,方便二次处理
  • 支持插件扩展
  • 支持 HTML 标签

Token 类型

MarkdownIt 输出 token,主要分为:

  • 块级元素(Block Tokens)
    paragraphheadingblockquotebullet_listordered_listlist_item
  • 行内元素(Inline Tokens)
    textstrongemdelmarkcode_inlinelinkimagesoftbreakhardbreak
    自定义插件生成的 map_inline

内联元素通过 isInline(type) 判断。


解析 <map> 标签

MarkdownIt 默认不识别 <map> 标签,我们通过插件生成 token:

function markdownItMapPlugin(md: MarkdownIt) {
  md.block.ruler.before("html_block", "map_block", (state, startLine, endLine, silent) => { ... });
  md.inline.ruler.before("html_inline", "map_inline", (state, silent) => { ... });
}

插件会匹配 <map ...>,生成 map_blockmap_inline token,并保留属性用于 AST 构建。


Token → AST 核心算法

将 MarkdownIt token 转成 AST 的核心目标:

  1. 保留块级元素嵌套关系
  2. 支持内联多层嵌套(strong、em、del、mark、code_inline、link)
  3. 分离块级节点和 Image/Map,保证长按复制不乱

内联 Token 转 AST

function inlineTokensToAST(tokens: any[]): ASTNode[] { ... }

逻辑

  • 遍历内联 token:

    • text → text 节点
    • softbreak / hardbreak → break 节点
    • 内联样式 _open / _close → 递归解析子 token
    • link_open → 解析链接子 token
    • image → image 节点
    • map_inline → map 节点
  • 递归处理嵌套,支持任意深度的内联样式


包装连续内联节点

function wrapInlineNodes(nodes: ASTNode[]): ASTNode[] { ... }

逻辑

  • 初始化 buffer 存放连续内联节点

  • 遇到块级节点 / Image / Map:

    • flush buffer,把连续内联节点合并成一个 inline 节点
    • 块级节点直接加入结果数组
  • 避免内联节点与块级节点混合,Image/Map 不在 Text 内

✅ 这样做的好处:

  • 渲染阶段可以安全地在最外层 <Text> 添加 userSelect,支持长按复制
  • 保持内联节点样式继承

Tokens → AST 核心流程

export function mdToAST(markdown: string): ASTNode[] {
  const tokens = md.parse(markdown, {});
  const root: ASTNode = { id: genNodeId(), type: "root", children: [] };
  const stack: ASTNode[] = [root];

  for (const token of tokens) {
    const parent = stack[stack.length - 1];

    if (token.type.endsWith("_open")) {
      // 块级节点开始
      const node: ASTNode = {
        id: genNodeId(),
        type: token.type.replace(/_open$/, ""),
        tag: token.tag,
        attrs: attrsToMap(token.attrs),
        children: [],
      };
      parent.children!.push(node);
      stack.push(node);
    } else if (token.type.endsWith("_close")) {
      // 块级节点结束
      stack.pop();
    } else if (token.type === "inline") {
      // 内联节点递归解析
      parent.children!.push(...wrapInlineNodes(inlineTokensToAST(token.children || [])));
    } else if (token.type === "map_block" || token.type === "map_inline") {
      // Map 节点独立处理
      const attrs = parseAttrsFromToken(token);
      parent.children!.push({ id: genNodeId(), type: "map", attrs });
    } else if (token.type === "image") {
      parent.children!.push({ id: genNodeId(), type: "image", attrs: attrsToMap(token.attrs) });
    } else if (token.type === "text") {
      parent.children!.push({ id: genNodeId(), type: "text", text: token.content });
    } else if (token.type === "softbreak" || token.type === "hardbreak") {
      parent.children!.push({ id: genNodeId(), type: "break" });
    }
  }

  return root.children || [];
}

核心设计思想

  1. stack 管理块级嵌套_open 入栈,_close 出栈
  2. 内联节点包装:连续内联节点合并成 inline 节点,阻止 Image/Map 嵌套在 Text 内
  3. Map/Image 独立节点:保证渲染和复制不会乱
  4. 唯一 ID:每个 AST 节点生成唯一 id,React 渲染时安全使用 key

AST 渲染

function renderNode(node: ASTNode, styleConfig: MarkdownStyleConfig) {
  switch (node.type) {
    case "paragraph":
    case "heading":
      return (
        <Text key={node.id} userSelect style={styleConfig[node.type]}>
          {node.children?.map(c => renderInlineNode(c, styleConfig))}
        </Text>
      );
    case "bullet_list":
      return <View key={node.id}>{node.children?.map((c, i) => renderListItem(c, styleConfig, i + 1, "bullet"))}</View>;
    case "ordered_list":
      return <View key={node.id}>{node.children?.map((c, i) => renderListItem(c, styleConfig, i + 1, "ordered"))}</View>;
    case "image":
      return <Image key={node.id} src={node.attrs?.src} style={styleConfig.image} />;
    case "map":
      return <MapNode node={node} styleConfig={styleConfig} />;
    default:
      return null;
  }
}

MapNode 核心实现

const MapNode = ({ node, styleConfig }: { node: ASTNode; styleConfig: MarkdownStyleConfig }) => {
  const latitude = parseFloat(node.attrs?.latitude);
  const longitude = parseFloat(node.attrs?.longitude);
  const markers = [{ id: 0, latitude, longitude, width: 20, height: 26 }];

  const handleTap = () => {
    wx.openLocation({ latitude, longitude, name: node.attrs?.name, scale: 18 });
  };

  return (
    <View key={node.id} style={styleConfig.mapContainer}>
      <Text style={styleConfig.mapTitle}>{node.attrs?.name}</Text>
      <Map
        latitude={latitude}
        longitude={longitude}
        markers={markers}
        enableScroll={false}
        enableZoom={false}
        style={{ width: "100%", height: "100%" }}
        onTap={handleTap}
      />
    </View>
  );
};

总结

这套渲染器特点:

  1. Markdown 全量渲染:段落、标题、列表、内联样式、图片、地图
  2. 长按复制优化:AST 构建阶段分离内联与块级元素,渲染阶段最外层 Text 加 userSelect
  3. 地图标签支持:自定义 MarkdownIt 插件解析 <map>,生成小程序 Map 组件
  4. 自定义样式:通过 MarkdownStyleConfig 灵活控制渲染效果

逻辑清晰、可扩展性强,适合小程序富文本展示和交互场景。

后端转全栈之Next.js SEO优化

2025年9月16日 12:10

本文概括:

  • Next.js SEO优化:用服务端组件、Streaming 渲染、next/image 和 next/font 提升加载速度和页面稳定性。
  • SEO 配置robots.txt 控制抓取,sitemap.xml 提供网站结构,可以主动推送Google,百度。
  • 页面 metadata:设置 titledescription,使用 Open Graph 优化分享。
  • 性能指标:关注 LCP ≤2.5秒,提高主要内容加载速度。

基本组件与 SEO 优化

在 Next.js 中,合理使用基础组件可以显著提升页面性能和 SEO 表现:

  1. 服务端组件(Server Components)

    • 尽量使用服务端组件,它们会在服务器直接渲染 HTML 并发送到前端,加快首次可视渲染,对 SEO 非常友好。
  2. Streaming 渲染

    • 使用 Streaming 渲染可以边生成边发送页面,不会影响 SEO,同时可以改善首页性能指标(如 LCP、TTFB)。
  3. Image 组件

    • 使用 next/image 组件加载图片,必须设置 alt 属性,既提升可访问性,也对 SEO 有益。
    • 默认会处理 布局偏移问题(Cumulative Layout Shift,CLS) ,确保页面稳定渲染。
  4. Font 组件

    • 使用 next/font 加载字体,可以避免 FOUT/FOIT(字体闪烁或延迟显示),同样减少布局偏移,提高用户体验和 SEO。
    • 默认也会阻止布局偏移,保持页面稳定。

SEO有关的配置文件

robots.txt

robots.txt 是网站根目录下的文本文件,用于告诉搜索引擎爬虫(如 Googlebot、Bingbot)哪些页面可以抓取,哪些页面不可以抓取。它是 网站爬虫控制协议(Robots Exclusion Standard)  的一部分。

在 SEO(搜索引擎优化)  中,合理设置 robots.txt 的核心目标是:

  1. 确保重要页面被抓取

    首页、栏目页、文章页、产品页等核心内容必须允许爬虫抓取。

  2. 阻止无关或重复内容抓取

    管理后台、用户隐私页、测试页、分页参数页、打印版页面等不需要被抓取,避免浪费爬虫抓取预算(Crawl Budget)并减少重复内容问题。

⚠️注意:如果爬虫抓了太多低价值页面,重要页面可能抓取不够,从而影响 SEO 效果。

# 允许所有爬虫
User-agent: *
# 禁止抓取管理后台和用户隐私页
Disallow: /admin/
Disallow: /login/
Disallow: /cart/
Disallow: /checkout/
# 禁止抓取参数化重复内容
Disallow: /*?sort=
Disallow: /*?filter=
# Sitemap 位置(告诉爬虫网站结构)
Sitemap: <https://www.example.com/sitemap.xml>

sitemap.xml

sitemap.xml 是一个 网站地图文件,主要作用是告诉搜索引擎你网站上的页面结构和更新频率,从而帮助搜索引擎 更快、更全面地抓取网站内容,对 SEO 非常重要。

robots.txt 告诉爬虫哪些页面不能抓,sitemap.xml 告诉爬虫 有哪些页面可以抓

常见的几个字段如下:

标签 含义
<loc> 页面 URL
<lastmod> 页面最后修改时间,爬虫可以优先抓取更新过的内容
<changefreq> 页面更新频率(alwayshourlydailyweeklymonthlyyearlynever
<priority> 页面抓取优先级,0~1,1 表示最重要的页面

在Next.js中,可以使用 next-sitemap库,www.npmjs.com/package/nex…

参考配置即可 next-sitemap.config.js

/**
 * 使用文档地址 <https://github.com/iamvishnusankar/next-sitemap>
 */
/** @type {import('next-sitemap').IConfig} */
const config = {
  siteUrl: process.env.NEXT_PUBLIC_SITE_URL || "<https://next.anqstar.com>",
  generateRobotsTxt: true,
  sitemapSize: 7000,
  generateIndexSitemap: true,
  robotsTxtOptions: {
    policies: [{ userAgent: "*", allow: "/" }],
  },
};

export default config;

页面metadata

每个页面都需要取设置title,description等元信息:

可以参考Google的文档:developers.google.com/search/docs…

keywords 这种属性,Google 已经废弃不用了

SEO元数据

字段 作用 示例
<title> 页面标题,搜索结果显示标题 <title>我的博客 - 首页</title>
<meta name="description"> 页面描述,搜索结果摘要 <meta name="description" content="这是我的技术博客,分享前端知识。">
<meta name="keywords"> 页面关键词(现代SEO已很少用) <meta name="keywords" content="前端, React, SEO">
<link rel="canonical"> 标准化URL,避免重复内容 <link rel="canonical" href="<https://example.com/page>">
<meta name="robots"> 控制搜索引擎抓取和索引 <meta name="robots" content="index,follow">

Open Graph

Open Graph(简称 OG)是 Facebook提出的一套网页元数据协议,目的是让网页内容在社交平台(如 Facebook、LinkedIn、微信、QQ 等)分享时,能够以 结构化、漂亮的卡片形式展示,而不仅仅是一个普通链接。

主要字段如下:

属性 作用 示例
og:title 分享标题 <meta property="og:title" content="我的博客首页">
og:description 分享描述 <meta property="og:description" content="前端开发教程分享">
og:image 分享缩略图 <meta property="og:image" content="<https://example.com/share.png>">
og:url 网页链接 <meta property="og:url" content="<https://example.com/page>">
og:type 内容类型(website/article/video等) <meta property="og:type" content="website">
og:site_name 网站名称 <meta property="og:site_name" content="我的博客">

在Next.js框架中设置metadata:

文档:nextjs.org/docs/app/ge…

注意,需要在服务端组件中使用

静态metadata:

export const metadata: Metadata = {
    title: 'xxx',
    description: 'xxx',
}

动态metadata:

// app/market/[id]/page.js

interface PageProps {
  params: { id: string };
  searchParams: { q?: string };
}

// 注意这里是异步函数
export async function generateMetadata(
  { params, searchParams, parent }: PageProps & { parent?: Promise<Metadata> }
): Promise<Metadata> {
  // 获取父级 metadata(可选)
  const parentMetadata = parent ? await parent : {};

  // 根据 ID 请求接口获取资源信息
  const resource = await getResourceById(params.id);

  // 从 searchParams 获取搜索关键词
  const keyword = searchParams.q;

  return {
    // 可以继承父级 title
    title: resource?.title ? `${resource.title} - ${parentMetadata.title || "资源市场"}` : parentMetadata.title,
    description: resource?.description || parentMetadata.description || "高效开发资源市场",
    openGraph: {
      title: resource?.title || parentMetadata.title,
      description: resource?.description || parentMetadata.description,
      images: [resource?.cover || "/default.png"],
    },
  };
}

// 页面组件
export default async function ResourcePage({ params, searchParams }: PageProps) {
  const resource = await getResourceById(params.id);
  return (
    <div>
      <h1>{resource?.title}</h1>
      {searchParams.q && <p>搜索关键词: {searchParams.q}</p>}
    </div>
  );
}

Next.js Metadata 规则

在 Next.js App Router 中,可以在 templatelayoutpage 等页面定义 metadata。其规则如下:

  1. 优先级

    • 页面级 (page) 的 metadata 优先级最高,会覆盖同名的父级 layout 或 template metadata。
    • layout 或 template 中定义的 metadata 会作为默认值,如果子级没有覆盖,则会继承。
  2. 合并方式

    • 合并使用 浅合并(shallow merge)

      • 对象类型字段(如 openGraphtwitter)会被子级直接覆盖,不会递归合并。
      • 没有在子级定义的字段,会直接继承父级。
  3. 字段丢失注意

    • 如果子级 metadata 对象中没有某些字段,这些字段会从父级继承。
    • 但如果子级定义了该字段,则父级同名字段会被覆盖,原值不会保留。

例子:

// layout.tsx
export const metadata = {
  title: "我的网站",
  description: "网站默认描述",
  openGraph: {
    type: "website",
    images: ["/default.png"],
  },
};

// page.tsx
export const metadata = {
  title: "首页",
  openGraph: {
    title: "首页 OG", // 会覆盖 layout 的 openGraph
  },
};
  • 最终合并结果:
{
  title: "首页",
  description: "网站默认描述", // 继承自 layout
  openGraph: {
    title: "首页 OG", // 覆盖了整个 openGraph 对象
    // images 字段丢失了,因为浅合并不会保留 layout 的 images
  }
}

LCP指标

LCP(Largest Contentful Paint)  是 Web Vitals 中衡量页面加载性能的核心指标之一,用来衡量 页面主要内容加载完成的时间,也就是用户 能看到页面最大可视内容(通常是图片、视频或大块文本)  的时间,Google 推荐的 LCP 理想值

体验等级 LCP 时间
优秀 ≤ 2.5 秒
需要改进 2.5 – 4 秒
> 4 秒

在Next.js中,可以多使用Next.js 提供 next/image 组件,自带优化功能:

  • 自动压缩图片
  • 支持 WebP/AVIF 等现代格式
  • 懒加载(lazy loading)可选
  • priority 属性可提升 LCP 图片加载速度
import Image from 'next/image'

export default function Hero() {
  return (
    <div>
      <h1>欢迎访问我的博客</h1>
      <Image
        src="/hero.jpg"
        alt="首页大图"
        width={1200}
        height={600}
        priority
      />
    </div>
  )
}

其他优化SEO的办法

直接给Google,百度提交Sitemap文件

百度:ziyuan.baidu.com/site/index

Google:search.google.com/search-cons…

企业级文件浏览系统的Vue实现:架构设计与最佳实践

2025年9月16日 11:52
概述 在现代Web应用中,文件管理是一个常见的需求。本文介绍了一个基于Vue.js的双模式文件浏览器组件的设计与实现。该组件支持两种文件模式:手机文件和任务文件,提供了直观的目录树导航、文件列表展示、

如何在 vue3+vite 中使用 Element-plus 实现 自定义主题 多主题切换

作者 北风GI
2025年9月15日 20:27

Element Plus 多主题切换:源码级新玩法

在做 Vue 3 + Element Plus 项目时,自定义主题几乎是必修课。
大家都知道的几种方案:

  • 覆盖 CSS 变量
  • 修改 SCSS 变量重新编译
  • 官方提供的暗黑模式

这些方式足够应付单主题或暗黑/明亮切换,但要实现 多主题自由切换(品牌 A、品牌 B、高对比度…) 时,体验就差强人意了。

翻源码的过程中,我发现了一个关键点 —— Element Plus 内部生成颜色变量的函数 set-css-color-type。默认它写死了 "base" 作为主色入口。

前置条件

  • 使用了sass 因此我们需要安装插件 sass-embedded
pnpm add sass-embedded -D

原版源码(节选)

源码地址:https://github.com/element-plus/element-plus/blob/dev/packages/theme-chalk/src/mixins/_var.scss

// https://github.com/element-plus/element-plus/blob/dev/packages/theme-chalk/src/mixins/_var.scss
@mixin set-css-color-type($colors, $type) {
  @include set-css-var-value(('color', $type), map.get($colors, $type, 'base'));

  @each $i in (3, 5, 7, 8, 9) {
    @include set-css-var-value(
      ('color', $type, 'light', $i),
      map.get($colors, $type, 'light-#{$i}')
    );
  }

  @include set-css-var-value(
    ('color', $type, 'dark-2'),
    map.get($colors, $type, 'dark-2')
  );
}

可以看到,map.get(..., 'base') 被写死了。
这意味着所有主题都只能以 base 为入口。


改造方案

我们把 base 抽象成一个 $theme 参数,这样就能为每个主题(如 default / simple / grand / noble)指定不同的基色。

@mixin set-css-color-type($colors, $type, $theme: "base") {
  @include set-css-var-value(('color', $type), map.get($colors, $type, $theme));

  @each $i in (3, 5, 7, 8, 9) {
    @include set-css-var-value(
      ('color', $type, 'light', $i),
      map.get($colors, $type, 'light-#{$i}')
    );
  }

  @include set-css-var-value(
    ('color', $type, 'dark-2'),
    map.get($colors, $type, 'dark-2')
  );
}

然后批量生成主题 class:

@each $class, $color in ('base', 'default', 'simple', 'grand', 'noble') {
  .#{$class} {
    @each $type in (primary, success, warning, danger, info) {
      @include set-css-color-type($colors, $type, $class);
    }
  }
}

定义多主题颜色

$colors: (
  'primary': (
    default: #0069d9,
    simple: #4a90e2,
    grand: #2c6b97,
    noble: #003b61
  ),
  'success': (
    default: #28a745,
    simple: #5cb85c,
    grand: #2d8c4f,
    noble: #1f7b35
  ),
  'info': (
    default: #17a2b8,
    simple: #32c8d5,
    grand: #2292b7,
    noble: #166f80
  ),
  'warning': (
    default: #ffc107,
    simple: #f39c12,
    grand: #e67e22,
    noble: #e04e01
  ),
  'danger': (
    default: #dc3545,
    simple: #e57373,
    grand: #c0392b,
    noble: #9b2d20
  )
);

Vue 中动态切换

// useTheme.ts
import { ref } from 'vue'

const theme = ref('default')

export function useTheme() {
  const setTheme = (name: string) => {
    theme.value = name
    document.documentElement.className = name
  }
  return { theme, setTheme }
}

只要调用 setTheme('grand'),页面就会切换成深蓝主题,Element Plus 的暗色模式与亮色层级依然会自动生效。不过推荐使用vueuse的 useColorMode,好不好用我就不用多说了

最后 完整代码

目录结构:

src/
└─ index.scss
├─ main.ts
├─ app.vue
vite.ts

// src/index.scss
@use 'sass:map';
@use 'sass:color';

$primary-themes: (
  default: #0069d9,
  simple: #4a90e2,
  grand: #2c6b97,
  noble: #003b61
);

$success-themes: (
  default: #28a745,
  simple: #5cb85c,
  grand: #2d8c4f,
  noble: #1f7b35
);

$info-themes: (
  default: #17a2b8,
  simple: #32c8d5,
  grand: #2292b7,
  noble: #166f80
);

$warning-themes: (
  default: #ffc107,
  simple: #f39c12,
  grand: #e67e22,
  noble: #e04e01
);

$danger-themes: (
  default: #dc3545,
  simple: #e57373,
  grand: #c0392b,
  noble: #9b2d20
);

@forward 'element-plus/theme-chalk/src/common/var.scss' with (
  $colors: (
    'primary': $primary-themes,
    'success': $success-themes,
    'info': $info-themes,
    'warning': $warning-themes,
    'danger': $danger-themes
  )
);

@use 'element-plus/theme-chalk/src/common/var.scss' as *;
@use 'element-plus/theme-chalk/src/mixins/_var.scss' as *;




@mixin set-css-color-type($colors, $type, $theme: "base") {
  @include set-css-var-value(('color', $type), map.get($colors, $type, $theme));
  @each $i in (3, 5, 7, 8, 9) {
    @include set-css-var-value(
      ('color', $type, 'light', $i),
      map.get($colors, $type, 'light-#{$i}')
    );
  }

  @include set-css-var-value(
    ('color', $type, 'dark-2'),
    map.get($colors, $type, 'dark-2')
  );
}


@each $class, $color in $primary-themes {
  .#{$class} {
    @each $type in (primary, success, warning, danger,  info) {
        @include set-css-color-type($colors, $type,$class)
    }
  }
}

// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import ElementPlus from 'element-plus'
// import 'element-plus/dist/index.css'
import "./index.scss"
createApp(App).use(ElementPlus).mount('#app')

// vite.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vite.dev/config/
export default defineConfig({
  plugins: [vue()],
})

// src/app.vue
<script setup lang="ts">

</script>

<template>
  <div>
    element-plus-custom-themes
    <el-button>默认按钮</el-button>
    <el-button type="primary">主要按钮</el-button>
  </div>
</template>

<style scoped>

</style>

按需引入

如果我们使用按需引入,只需要改掉vite.ts 和main.ts

  • 前置条件 安装 按需引入需要的插件
pnpm add unplugin-vue-components unplugin-auto-import -D
  • 修改后的文件
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
// import ElementPlus from 'element-plus'
// import 'element-plus/dist/index.css'
// import "./assets/styles/index.scss"
// createApp(App).use(ElementPlus).mount('#app')
createApp(App).mount('#app')

// vite.ts
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import Components from 'unplugin-vue-components/vite'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      include: [/\.[jt]sx?$/, /\.vue$/, /\.vue\?vue/, /\.md$/],
      imports: [
        'vue',
      ],
      resolvers: [ElementPlusResolver()],
      vueTemplate: true,
    }),
    Components({
      extensions: ['vue', 'md'],
      include: [/\.vue$/, /\.vue\?vue/, /\.md$/],
      resolvers: [ElementPlusResolver({
        importStyle: 'sass',
      })],
    }),
  ],
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@use "@/assets/styles/themes/index.scss" as *;`
      },
    },
  },
})


总结

  • Element Plus 默认 set-css-color-type 写死了 "base",这是多主题受限的根源。
  • 我们改造后,可以让 default / simple / grand / noble 等多套主题并存。
  • 内置的暗黑模式、颜色层级(light/dark)逻辑依旧能正常工作,不需要重复维护。
  • 按需导入,减少构建体积。

这样,你就可以在实际项目里优雅地支持 品牌主题、活动主题、暗黑主题 等多套风格啦 🎉

❌
❌