编程开源技术交流,分享技术与知识

网站首页 > 开源技术 正文

Preact作者:前端请立即放弃 SVG-in-JS!

wxchong 2024-07-19 05:41:16 开源技术 9 ℃ 0 评论

家好,很高兴又见面了,我是"高级前端?进阶?",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发,您的支持是我不断创作的动力。

本文大部分内容来自对 kurtextrem 的《Breaking Up with SVG-in-JS in 2023》文章的翻译,但是对部分内容进行了增改,有不对的地方,欢迎大家指正。

前言

2022 年 10 月 17 日,一篇《Why We’re Breaking Up with CSS-in-JS》 广为流传,文章论述了为什么开发者不应该在 JS bundle 中包含 CSS。下面是我发表的关于 CSS-in-JS的系列文章:

然而,CSS 并不是 JS 包中唯一出现的东西,比如说:SVG 也是如此,正如 Preact 的作者 Jason Miller 所说的:

请不要将 SVG 导入为 JSX,它是最昂贵的 sprite sheet 形式:成本至少比其他技术高出 3 倍,并且会损害运行时(渲染)性能和内存使用。某些热门网站打包的包几乎有 50% 是 SVG 图标 (250kb),而且大多数都未使用。

JS 中的 SVG 有成本,并且 SVG 不属于 JS 包,是时候恢复 SVG-in-HTML 了。 本文接下来的部分将带着大家一起看看在 JSX 中使用 SVG 的更好技术,从而保持 JS 包小而高性能。

1.如何将 svg 从 JavaScript 中移除

首先,需要知道 SVG 是如何最终出现在 JavaScript 代码中的。通常,这是作为编写 JSX 的一部分来实现的,比如下面的例子:

<!-- HeartIcon.svg -->
<svg viewBox="0 0 300 300">
  <g><path d="M0 200 v-200 h200 a100,100 90 0,1 0,200 a100,100 90 0,1 -200,0z" /></g>
</svg>

下面是引用 svg 的代码:

// App.jsx
import HeartIcon from "./HeartIcon.svg";
const App = () => <HeartIcon fill="red" />;

为了使 .svg 文件导入成功并正常工作,需要告知打包器如何处理非 JavaScript(或 TypeScript)的文件。 这里就不得不提到 Webpack 加载器,即 svgr 。

SVGR 是一个将 SVG 转换为 React 组件的通用工具,其接受原始 SVG 并将其转换为即用型 React 组件,从而允许开发者方便地向 SVG 标签添加属性(如 fill="red"),比如下面的例子:

<?xml version="1.0" encoding="UTF-8"?>
<svg
  width="48px"
  height="1px"
  viewBox="0 0 48 1"
  version="1.1"
  xmlns="http://www.w3.org/2000/svg"
  xmlns:xlink="http://www.w3.org/1999/xlink"
>
  <!-- Generator: Sketch 46.2 (44496) - http://www.bohemiancoding.com/sketch -->
  <title>Rectangle 5</title>
  <desc>Created with Sketch.</desc>
  <defs></defs>
  <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
    <g
      id="19-Separator"
      transform="translate(-129.000000, -156.000000)"
      fill="#063855"
    >
      <g id="Controls/Settings" transform="translate(80.000000, 0.000000)">
        <g id="Content" transform="translate(0.000000, 64.000000)">
          <g id="Group" transform="translate(24.000000, 56.000000)">
            <g id="Group-2">
              <rect id="Rectangle-5" x="25" y="36" width="48" height="1"></rect>
            </g>
          </g>
        </g>
      </g>
    </g>
  </g>
</svg>

运行下面的 SVGR 进行编译:

npx @svgr/cli --icon --replace-attr-values "#063855=currentColor" -- icon.svg

渲染后,输出结果如下所示:

import * as React from "react";
const SvgComponent = (props) => (
  <svg width="1em" height="1em" viewBox="0 0 48 1" {...props}>
    <path d="M0 0h48v1H0z" fill="currentColor" fillRule="evenodd" />
  </svg>
);
export default SvgComponent;

目前 svgr 在 Github 上通过 MIT 协议开源,有超过 9.8k 的 star、6559k 的项目依赖量,代码贡献者 120+,妥妥的前端优质开源项目。

诚然,svgr 非常方便且易于使用,但易用性也带来了用户必须付出的诸多缺点。

2.性能探究:为什么要放弃 SVG-in-JS

2.1 解析和编译

JavaScript 解析和编译是有成本的,最终 bundle 中的内容体积越大,JavaScript 引擎处理代码所需的时间就越长。虽然在高性能的设备上差异不是很明显,但是低端设备的占比依然是一个不容忽视的问题。

对于浏览器来说,`逐字节处理 JavaScript 的成本比同等大小的图像或 Web 字体的成本更高`

— Tom Dale,web.dev

SVG 不是 JavaScript,是描述图像的类似 HTML 的 XML 标签。 开发者肯定不希望 JS 中包含图像,通过将 SVG 从最终的 JS bundle 中移出,进而节省客户端解析、编译时间,而这种行为从技术上讲是非常有益的。

代码执行之前必须首先经过解析,而解析的时长最终也会影响用户实际的等待交互时长。

对于 React 应用,客户端还需要经过补水, 下载+解析+编译+水合共同构成了终端用户的等待交互时长。 组件树体积越大,水合时间一般会更长, Addy Osmani 的文章《JavaScript Start-up Optimization》深入探讨了这个主题,参考资料在文末。

值得一提的是,压缩也会对处理时间产生影响,但不要忘记 JS 引擎使用未压缩的源代码。 为此,开发者可以使用 <link rel="modulepreload"> 或通过 ServiceWorker 进行拦截,将解析和编译移至较早的时间。

<!doctype html>
<html lang="en-US">
  <head>
    <meta charset="utf-8" />
    <title>Basic JavaScript module example</title>
    // 下面标记为 modulepreload 的三个模块在需要之前都开始异步并行下载
    // 当 main.js 被解析并且其依赖项已知时,它们已经被获取和下载
    <link rel="modulepreload" href="main.js" />
    <link rel="modulepreload" href="modules/canvas.js" />
    <link rel="modulepreload" href="modules/square.js" />
    <style>
      canvas {
        border: 1px solid black;
      }
    </style>
    <script type="module" src="main.js"></script>
  </head>
  <body></body>
</html>

modulepreload 提供了一种声明性方式来抢先获取模块脚本、解析和编译,并将其存储在文档的模块映射中以供以后执行。声明为 modulepreload 的模块,解析和编译将在下载后立即完成,而不是在执行前完成。 虽然管理模块加载时机可以带来一定的收益,但不能解决根本原因。

重点注意:带有 rel="modulepreload" 的链接与带有 rel="preload" 的链接类似。 主要区别在于 preload 只是下载文件并将其存储在缓存中,而 modulepreload 获取模块,解析并编译它,并将结果放入模块映射中以便准备执行。

当然,除了解析和编译的开销外,内存使用也是需要重点考虑的另一个因素。

2.2 从 JS 包中删除 SVG 的方式

为了从 JS 包中删除 SVG,首先需要确认 JavaScript bundle 包中是否有 SVG。 这可以通过检查源代码并搜索 svg 或使用 Lighthouse 内置的包查看器来完成。 开发者可以使用报告“性能”部分中的“查看树形图”按钮来查看具体信息。

如果最终生成的 bundle 中的 svg 体积比较大,可以通过一些选项帮助开发者处理,比如下面的流程图:

使用 <img> 加载

为了在 <img> 标签内使用 SVG,开发者必须告诉打包器、框架将 svg external 化(创建静态 URL)。 对于 Webpack,可以通过更新 Webpack 配置将所有 .svg 文件设置为 asset/resource 类型来完成。Webpack 从 v5 版本开始支持以下几种类型:

  • asset/resource :发送一个单独的文件并导出 URL,之前通过使用 file-loader 实现。
  • asset/inline :导出一个资源的 data URI,之前通过使用 url-loader 实现。
  • asset/source: 导出资源的源代码,之前通过使用 raw-loader 实现。
  • asset :在导出一个 data URI 和发送一个单独的文件之间自动选择,之前通过使用 url-loader,并且配置资源体积限制实现。
const config = {
  // webpack的其他配置
  module: {
    rules: [
      {
        test: /\.svg/,
        type: "asset/resource",
        // 注意这里选用的类型
      },
    ],
  },
};

其他框架,例如: Astro 可能会自动执行此操作,但是需要确保应用 Brotli/Gzip 压缩,因为 svg 文件格式可以像 JS 文件一样进行压缩。

然后,引用 SVG 就变得像常规 (PNG/JPG) 图像一样简单:

import HeartIcon from "./HeartIcon.svg";
// 关注点1:使用 loading="lazy" 属性进行延迟加载
// 关注点2:使用 important="high" 来更改fetch优先级
const App = () => <img src={HeartIcon} loading="lazy" />;

值得一提的是,对于 DPR > 1 屏幕上的复杂 SVG 动画,与内联 SVG 相比,<img> 可能消耗更少的 CPU 资源。

注意:DPR(Device Pixel Ratio)即设备像素比,是指浏览器渲染页面时物理像素与设备独立像素之间的比率,DPR 越大表示屏幕越清晰,比如典型的 Retina 屏幕。

SVG sprites – 使用 <use>

SVG 代表可缩放矢量图形,是一种允许开发者使用 XML 代码创建和操作图像的格式。 SVG 图像与分辨率无关,这意味着可以放大或缩小,而不会损失质量或清晰度。

而 SVG sprites 和 symbol 是优化使用矢量图形图标网页的强大技术,可以帮助开发者减少 HTTP 请求数量、节省带宽并提高加载速度。

如果开发者想使用 fill 和其他 CSS 属性,或 currentcolor 作为值,开发者需要使用 标签来加载 SVG。结合与上面相同的 Webpack 规则,最终可以像下面的代码引用 SVG:

import HeartIcon from "./HeartIcon.svg";
// 导入资源
const App = () => (
  <svg>
    <use href={`${HeartIcon}#heart`} />
  </svg>
);

上面代码引用了一个 ID,这是<use>需要的,下面是更新后的 HeartIcon SVG:

<svg viewBox="0 0 300 300" id="heart">
  // 注意这里的svg的id值
</svg>

如果网站上有许多 SVG,开发者可以将它们放入一个文件中。 SVG sprites 是使用 <symbol> 标签构建的。开发者需要设定一个 ID,以便可以通过 use 使用,style 样式、currentcolor 等还和以前保持一样。

<!-- icons.svg -->
<svg>
  <!-- 1: 添加一个 `<defs>` 标签 -->
  <defs>
    <!-- 2: 包裹在 `<symbol>` 里面同时给出一个 ID 或者其他`viewBox`属性 -->
    <symbol id="icon1">
      <!-- 3: 将svg的内容添加到 `<symbol>`中 -->
    </symbol>
    <symbol id="icon2">...</symbol>
  </defs>
</svg>

sprites 文件只会加载一次并被缓存,开发者可以通过以下方式引用 SVG:

// 注意点1:从外部加载 SVG 时,<mask> 和 <clipPath> 不起作用,可以通过inline
// 注意点2:使用 <use> 时无法从 CDN 加载 SVG
<svg><use href="icons.svg#icon1" /></svg>
<svg><use href="icons.svg#icon2" /></svg>

删除更多 JS:CSS 和 currentcolor 等属性,如填充、描边、宽度、高度等。

假如有下面的示例代码:

//  非最优
const Icon = (favColor, width) => (
  <svg>
    <use
      href={`${HeartIcon}#heart`}
      fill={favColor ? favColor : "red"}
      width={width}
    />
  </svg>
);
const App = () => (
  <>
    <Icon favColor="#FFFF00" />
    <Icon width={300} />
  </>
);

非常不建议这样做,因为 svg 的渲染逻辑最终再次出现在 JavaScript bundle 中,并且需要由 JavaScript 引擎执行。与避免使用 CSS-in-JS 类似,可以使用类名来代替 svg 的变量渲染,非常建议开发者使用 CSS 来设置 SVG 的样式,比如下面的例子:

// ? 更好的方案
const Icon = (className) => (
  // 添加一个类并让任何消费者通过 CSS 处理细节
  <svg>
    <use href={`${HeartIcon}#heart`} className={`heart ${className}`} />
  </svg>
);
const YellowHeart = () => <Icon className="yellow" />;
const BigHeart = () => <Icon className="big" />;
// 添加yellow/big等自定义的类
const App = () => (
  <>
    <YellowHeart />
    <BigHeart />
  </>
);

现在可以使用 currentcolor 使 SVG 继承 CSS 属性 color 的颜色:

/*  很好的方案 */
.heart { fill: currentcolor }
/* 应用当前的`color` */

/* 以下类甚至可能来自设计系统,不需要特定于 SVG*/
.big { width: 300px }
.yellow { color: #FFFF00 }

如果可以访问 SVG,那就更好了:

<!-- HeartIcon.svg -->
<svg viewBox="0 0 300 300" id="heart">
  <g>
    <path
      d="M0 200 v-200 h200 a100,100 90 0,1 0,200 a100,100 90 0,1 -200,0z"
      fill="currentcolor"
    /><!-- 为  `fill`/`stroke`属性添加 `currentcolor`  -->
  </g>
</svg>
/*  最有的 */
.heart { /* `fill` 无需在css中进行定义了 */ }

应用于 标签的任何 CSS 属性都会自动将样式应用于引用的 SVG 的 <svg>/<symbol> 标签。因此,开发者可以非常容易的向 .heart 添加描边,而无需触及 <path> 元素的颜色。

但是,需要注意的是,<use> 上的宽度和高度要求原始 <svg> 具有 viewBox 属性(或 <view>)。

服务器组件 (React)

Server Components 是一个不需要太多更改的可行解决方案,将采用 React 即将推出的服务器组件,它可以用于 NextJS 13.4。

"use client";
// “use client”指令是声明服务器和客户端组件模块图之间边界的约定。
import { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

当开发者需要在运行时更改组件的行为并允许编写仅在服务器上执行的 JSX 时特别有用。 这样,SVG 就不再是交付给浏览器的 JavaScript 包的一部分。

为了仅将它们保留在服务器上,只需在文件中不添加“use client”即可。

性能与加载时间:内联还是不内联

inline 内联允许开发者保存一个 HTTP 请求,因此 SVG 会立即显示。 缺点是,与关键 CSS 相同,字节会下载到每个非浏览器缓存页面上。 此外,内联 SVG 是 DOM 的一部分,因此会增加浏览器需要执行的计算量 。 因此,虽然减少了 JavaScript 引擎的工作量,但并未显著减慢 HTML 响应的下载时间或破坏 DOM。

因此,可以得出一些内联内容的规则:

  • Logos 具有最高优先级,有些网站甚至测量加载 Logo 的时间(即使不是 LCP 元素)。
  • 接下来是视口中的图标,例如: 搜索等。 这些更有可能被用户触及,因此如果它们加载较晚,则会影响用户的体验。
  • 其余的部分无需内联,如果可能,不要延迟加载。

根据经验, Astro 使用 4 kB 作为内联文件的阈值。 一般来说,首屏以上的任何内容(CSS、SVG、JS、内容)都应保持在 14 kB 以下(压缩后)

对于内联方法本身,可以使用 Cloudfour 的 SVG 图标基准测试中最快的技术:

<img src="data:image/svg+xml,%3Csvg xmlns='...">

或者如果遇到警告,请直接嵌入 <svg>。

如果碰巧超出预算,可以首先进行基准测试,如果需要,请使用非内联技术,并尝试使用 <link rel="preload" as="image"> 提高资源优先级。

如果决定内联并希望最大化缓存优势,可以为第一次访问者内联图标,在后台预取 () 精灵 SVG,并且对于后续访问,仅加载精灵 SVG (例如,通过检查服务器上的 cookie 值)。

// prefetch 可用于获取 HTML 和子资源以进行可能的下一个导航。
// 一个常见的用例是拥有一个简单的网站登陆页面,该页面可以获取网站其余部分使用的更多“重量级”资源。
<link rel="prefetch" href="/app/style.css" />
<link rel="prefetch" href="https://example.com/landing-page" />

内联 SVG 而不污染 JS 包

接下来一起看看如何在不再次将 SVG 添加回 JS 包的情况下执行此操作。下面的示例将心形图标内联在 <body> 之后,因此任何引用精灵的 SVG 都会从一开始就显示出来。

开发者可以对不太重要的精灵使用相同的技术,将精灵输出放在 之前。

import fs from "node:fs";

const svgIcons = await fs.readFile("path/to/icons.svg");
// 加载SVG文件内容

// 路由可以通过任何 HTTP 框架,包括:fastify、express、koa等等
app.get("/", function () {
  const reactOutput = renderToString(App);
  return `<!doctype html><head><title>SVG-in-HTML</title></head>
     <body>
      <!-- 输出要重复使用的 SVG 精灵并使其不可见 -->
      <div style="display:none">${svgIcons}</div>
      ${reactOutput}
    </body>`;
});

接下来需要确保应使用 SVG 的任何实例都不会链接到该文件,而仅链接到 ID:

<!-- 请注意“href”属性中缺少的文件路径 -->
<svg><use href="#heart"></svg>

SVG-in-HTML 可以扩展到提取每个页面、每个输出使用的所有 ID,因此它只会输出实际使用的 SVG,而不是全部。 used-styles 是一个很好的例子,它通过如下步骤来显著减少最终的 bundle 体积。

  • 扫描构建目录中的所有 .css 文件,提取所有样式规则名称。
  • 扫描给定的 html,查找所有使用的类。
  • 这里有两个选项:3a,即计算渲染给定 HTML 所需的所有样式规则。 3b. 计算发送给客户的所有样式文件。
  • 注入 <styles> 或 <links>标签
  • 页面加载后,提升或删除关键样式,将其替换为“真实”样式。

3.本文总结

本文主要和大家探索一个与日常开发息息相关的主题,即前端是否要放弃 SVG-in-JS。相信通过本文的阅读,大家对 SVG-in-JS方案的优缺点、如何从 SVG-in-JS 切换到新的技术方案会有一个初步的了解。

因为篇幅有限,关于 SVG-in-JS 的用法和特性文章并没有过多展开,如果有兴趣,可以在我的主页继续阅读,同时文末的参考资料提供了大量优秀文档以供学习。最后,欢迎大家点赞、评论、转发、收藏,您的支持是我不断创作的动力。

参考资料

https://kurtextrem.de/posts/svg-in-js

https://github.com/theKashey/used-styles

https://github.com/theKashey/used-styles

https://dev.to/srmagura/why-were-breaking-up-wiht-css-in-js-4g9b

https://github.com/gregberge/svgr

https://web.dev/optimizing-content-efficiency-javascript-startup-optimization/#parsecompile

https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/modulepreload

https://webpack.docschina.org/guides/asset-modules/

https://svgsprit.es/

https://www.linkedin.com/advice/3/how-do-you-use-svg-sprites-symbols-reduce-http-requests-improve-1f

https://nextjs.org/docs/getting-started/react-essentials

https://developer.mozilla.org/en-US/docs/Glossary/Prefetch

https://kurtextrem.de/posts/svg-in-js#server-components-react

https://www.standiers.com/how-to-use-an-svg-with-wordpress-and-css/

https://thenewcode.com/1094/Using-JavaScript-in-SVG

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表