lizheming 发布于 07月26, 2026

字体对齐真难啊

很早之前,我们团队对接的设计同学推行了一个字体规范,凡是金额价格的数字表达,需要使用他们制作的某个自定义字体,该字体仅包含数字和英文等字符。

但该字体在后续的大量使用中发现在 iOS 上存在基于 baseline 无法对齐的问题,该问题在 Web 和 Native 场景都有同学反馈。

图片展示了设计稿与iOS实现的对比。左侧设计稿中,数字“991元”位于“通用优惠券包”下方,数字为红色,且有“券包”标识。右侧iOS实现中,数字“991元”同样位于“通用优惠券包”下方,数字颜色为红色,但“券包”标识位置有变化。图片直观呈现了设计稿与iOS实现中数字及标识位置的差异,与上下文提到的iOS上数字基于baseline无法对齐的问题相关,展示了问题的具体表现形式。

这个问题困扰大家久矣,目前大家的解法通常是 baseline 对齐之后,再将数字设置成绝对定位人肉再微调。整体解决方案比较 Hack。

图片展示了网页元素的代码及样式信息。代码中有一个span元素,其class为“box-number”,内有数字“991”。样式部分显示该元素字体为DouyinNumberABC,字体加粗,大小24px,行高28px,相对定位,top为1px,颜色为红色。该图片与文档中字体对齐难的问题相关,可能是为了解决数字字体对齐问题,通过代码样式调整来实现。

我们也曾尝试找设计同学,他们反馈这个数字字体他们已经不维护了,要解决的话只能新搞字体成本比较高。鉴于你们不清楚是否是字体产生的这个问题,且你们现在不是有解决方案吗,那就保持现状就好。

这个回复虽然让人有点无语,但确实比较现实。痛定思痛,我想着求人不如求己。虽然我不会字体设计不清楚原因,但我不会不代表 AI 不会呀!于是在 AI 老师的带领下,我的字体求知之路开始了。

怎么就对不齐了?

在此之前我们整体感觉就是这个数字字体偏上所以导致对不齐,且这个问题只在 iOS 中存在,Android 是没有问题的。所以我直接给 AI 下了指令,帮我分析字体的上下空白边距,的确发现上下边距是不一致的,下边距是比较高的。我让其帮我调整字体上下边距一致给我输出调整后的文件。最终 iOS 上使用该修改版确实能达到对齐的效果,但是在 Android 上整体就偏下了。

图片展示了有问题版和修复版的数字对齐情况。左侧有问题版,数字“3819”在“最高”下方偏上;右侧修复版,数字“3819”与“最高”对齐。右侧还展示了数字“3819”在不同背景下的对齐情况,数字与“最高”对齐。该图片与上下文紧密相关,用于直观呈现问题所在,辅助说明字体对齐不一致的问题及修复效果。

到这我就有点不知道怎么办了。直到有同学给了一张图,告知字形轮廓整体偏上,我顺势对比了下 Android 发现是正常的之后。于是我换了个思路,让 AI 分析下为什么 iOS 上数字会偏上,Android 是正常的。

这个时候 AI 开始发现真相了,他给了好几个关键字 OS/2 sTypo, hhea, usWin 等。告知我是因为这个字体的 hhea 和 OS/2 sTypo 配置不一致导致的。

图片展示的是文档中关于字体对齐问题分析及修复方案的内容。文档提到AI分析发现字体hhea和OS/2 sTypo配置不一致导致数字偏上,AI给出修复版文件。图片中详细说明了iOS和Android上数字对齐差异,iOS数字偏上,Android数字居中,还列出了hhea和sTypo的具体数值差异,以及如何选择不同度量导致平台差异。图片与上下文紧密相关,直观呈现了AI分析结果及修复方向。

OK 我暂时先不管这些东西是什么,先让 AI 按照它发现的问题给我一份修复版的文件看看效果。最终测试下来发现确实返回的字体偏上以及居中的问题都好一些,可以看到数字相对于左侧的标签的是居中对齐了。

图片展示了有问题版和修复版字体对齐效果的对比。左侧“有问题版”中,数字“3819”相对于左侧的“最高”标签偏上且不居中;右侧“修复版”中,数字“3819”相对于“最高”标签居中对齐。右侧“有问题版”和“修复版”也呈现了类似情况。该图片与上下文紧密相关,直观呈现了AI修复字体对齐问题前后的效果差异,验证了AI修复方案的有效性。

字体格式

在了解问题的原因之前,我们得先补习一些印刷和字体相关的知识。我们都知道活字印刷是通过预先对铅字块进行排版然后像盖章一样印刷。后续进入计算机时代之后,出现了照排技术,可以简单理解就是通过将一个一个字模在相纸上光照显影的形式替换了铅字块进行排版,最终将排版后的相纸进行印刷。再后来就是现在的数码印刷时代,所有的排版都是在电脑端完成,打印机通过感知排版后的效果逐行逐帧打印。

这里就会涉及到一个字怎么在电脑上存储表达的。我们现在大多数的字体都是轮廓字体。电脑就像是一支“笔”,字体文件中记录了这支笔从哪里开始起笔绘制整体字形的轮廓所有需要的数据。现代字体格式基本上都是这个逻辑,字体格式的本质不是艺术的存储,而是字体在电脑上可编程化的参数表达。

至于为什么有这么多字体格式,这就涉及到 Adobe, Apple 和 Microsoft 三家巨头公司的爱恨情仇了。

PostScript

PostScript 由 Adobe 在 1984 年推出,是桌面出版领域的革命性技术,使用贝塞尔曲线描述字形轮廓,能用少量数据勾勒出平滑复杂的曲线,支持无限缩放不失真,成为高端印刷和出版行业的标准。它本质是命令型编程语言,通过栈式虚拟机执行图形指令,为后续字体格式奠定了矢量轮廓的核心逻辑 。

早期 Apple 一直做的是硬件设备,乔布斯比较看好未来数字艺术这块的市场。所以当时和 Adobe 进行了合作,在 Apple 的打印机设备上支持了 PostScript 字体。

TrueType

但 PostScript 字体有个比较头疼的问题是太编程化了,你让字体设计师去直接写就很痛苦。所以最好是能有个软件来设计这个字体再转成 PostScript 字体文件比较好。所以当时 Apple 希望 Adobe 能把 PostScript 授权给自己,让他们在 Mac 上去做对应的字体软件。

这在现在看起来是个非常正常的思路,但是在当时闭源的世界大家都是守着自己的垄断软件故步自封。Adobe 担心授权给电脑之后被破解了他们的垄断地位就没有了。我们站在未来的视角看过去就会发现这个决策是非常可笑的。但 Adobe 确实做了这个决定,并在当时为他们的失策付出了惨痛代价。

Apple 在被拒之后痛定思痛,于是自己研发了 TrueType 字体规范,并和 Microsoft 合作进行推广。它的核心目的就是旨在打破 Adobe 在 PostScript 领域的垄断。TrueType 的最大优势是 Apple 吸取了 PostScript 的教训,它选择了公开 TrueType 技术规范。同时它集合了 Windows 和 Mac 未来两大电脑操作系统的支持,所以跨平台兼容性非常好。同时在两家公司的支持下,它保证了电脑屏幕显示和打印输出的一致性。

OpenType

虽然 Apple 公开了 TrueType 规范本身,但是基于 TrueType 还有很多衍生的技术把持在 Apple 手上。当 Microsoft 由于捆绑 TrueType 赚的盆满钵满的时候,流出羡慕泪水的 Apple 拒绝了 Microsoft 想要更多 TrueType 字体技术的授权的申请,期望通过复刻 Microsoft 通过在自家 Mac 上捆绑来实现类似的商业道路。

无独有偶,Microsoft 在被拒之后痛定思痛,转手就和 Adobe 合作开发了新的字体格式 OpenType。俗话说的好,商场如战场,敌人的敌人就是朋友嘛!

OpenType 字体整体沿用了 TrueType 的 sfnt容器,并对其进行了扩展。正因如此,可承载 PostScript CFF/CFF2 或 TrueType glyf 轮廓。在此基础上,OpenType 新增了连字、上下文替换、多语言支持、可变字体轴(2016年 OpenType 1.8加入支持)等高级排版特性,彻底扩展了字体的表现力,如今已成为跨平台字体的事实标准,支持从桌面到 Web 全场景的排版需求。

最终 Apple 在 Mac OS X 慢慢加入了对 OpenType 的系统支持,这场字体的商战慢慢才告一 段落。

字体度量

我知道你们很着急,但请先别太着急。知道字体度量之前,我们还需要知道一些字体的基础知识。

字体设计中,我们会定义一个 em 空间,作为字体的基础区域。基于 Baseline(也就是坐标原点)之上会定义一个上升空间 Ascender,之下会定义一个下降空间 Descender。对选定的一组字体纵向度量,推荐的 baseline-to-baseline distance 通常按以下方式计算:

行高(Line Heigt)= Ascender - Descender + LineGap

其中定义 Ascender, Descender 和 LineGap 的配置就叫 Vertical Metrics(纵向度量)。当然我这里列举的只是和本文相关的几个参数,纵向度量配置还有一些其他的配置项我就不一一列举了。

在 TrueType 格式中,使用 hhea 字段来描述相关的配置:

  • hhea.ascender
  • hhea.descender
  • hhea.lineGap

后续 Windows 在引入 TrueType 的支持的时候,额外增加了 usWin* 字段来描述最大裁剪区域,包含:

  • usWinAscent
  • usWinDescent

再后来到 OpenType 标准规范引入,为了保持兼容性,它并没有废弃 hhea 字段描述,而是额外新增了 sTypo* 字段描述度量配置。它包含:

  • sTypoAscender
  • sTypoDescender
  • sTypoLineGap

同时 OpenType 还增加了 USE_TYPO_METRICS 标志,当其值为 1 时优先会读取 sTypo* 度量配置。但不同的操作系统有不同的实现。前文咱们已经知道,Apple 创建了 TrueType 字体格式。作为 TrueType 的亲爹,咱们必须得高优支持 hhea 度量配置呀!

图片展示了FontForge软件中“DouyinNumberABC”字体的属性设置界面。界面左侧显示字体名称及“Medium”字体样式。右侧有多个参数设置区域,其中“自定义参数”部分,多个参数开关处于开启状态,如“typoAscender”“typoDescender”“typoLineGap”等,数值分别为930、-210、100等。该图片与上下文紧密相关,直观呈现了上下文中提到的字体度量配置参数在软件中的实际展示情况。

诚如 AI 所说,我们用字体软件打开字体配置一看,typoDescender 和 hheaDescender 的值明显不一样,且 hheaDescender 的绝对值明显要比 typoDescender 要大。这就解释了为什么 Android 和 iOS 上的表现为什么会不一样,且为什么 iOS 上体感会更靠上。因为 hheaDescender 代表的基线以下的下降高度,下降高度越大,居中对齐时字形就会看起来靠上。

我们使用 AI 将 hheaDescender 的配置改成和 typoDescender 的配置一致之后,最终双端的表现就拉齐一致了。

图片展示了有问题版和修复版字体对齐情况的对比。左侧有问题版中“最高3819元”字体对齐不一致,右侧修复版字体对齐一致。修复版中“最高”和“3819元”字体对齐统一,且“最高”字体颜色为红色,与“3819元”字体颜色一致。该图片与上下文内容相关,用于直观呈现字体对齐问题及修复效果,辅助说明字体对齐难的问题及修复情况。

怎么还对不齐?

我们修改 hhea 配置和 sTypo 一致之后发现,iOS 的表现确实好了不少,但是还是和右侧的元字有轻微的不对齐。这里就涉及到苹方字体中西文和中文字形的设计策略了。AI 告诉我苹方的几个字在本文测试的字体版本、字号和渲染设备上,截图中“元”的可见轮廓中心比数字低约 1px。所以即使按照 baseline 对齐,由于中文更靠下所以在视觉上仍然不会对齐。

图片展示的是通用优惠券包的价格信息。红色边框内显示“券包”二字,其右侧是红色数字“991”,下方为“元”字。下方文字为“通用优惠券包”。图片中红色边框与文字位置对应,与上下文提到的数字和中文字体对齐问题相关,可能是用于说明在不同系统下数字和中文对齐情况的示例,以辅助理解字形位移适配对字体对齐的影响。

图片展示了baseline对齐后的文字对齐情况。在实际字号下,数字字体为24px,苹方字体为12px。数字轮廓相对baseline的下边界数据给出,如1/2/4/7下边界为0px,3/5/6/8/0下边界为-0.288px。草方汉字相对同一条baseline的下边界数据也给出,如元、分下边界为-1.248px,角、张下边界为-1.272px。最后说明baseline对齐后,汉字可见底边会比数字低大约0.9 - 1.3px,与截图里的差值相符。

而 Android 使用的系统中文 fallback 通常不是苹方,而是设备对应的 Noto Sans CJK/厂商字体.字形轮廓、ideographic baseline、字体边距,以及系统的文本测量方式都可能不同。

所以这个问题已经不是修改字体整体配置能解决的了,它确实需要字形去做微调来适配。由于字形的位移适配是统一的,也会影响原本正常渲染的设备。为了保证影响最小化,AI 给我的建议是下移30个单元,实际移动距离不足 1px 在不同的系统上可能会由于渲染的取舍最终抹平差异。

这张图片展示了针对字体基线对齐问题的具体技术方案与参数说明,内容围绕修改字形轮廓调整字体位置的方法展开,核心说明了不同系统适配时的下移单位选择:提到在字体UPM为1000、24px字号下,不同下移单位对应的实际移动距离,如下移20单位约0.5px、下移30单位约0.72px;还给出了三个实验版本的方案,并明确了iOS下字形调整的建议,同时兼顾Android系统的适配,避免对原有渲染效果造成过多影响,为解决跨系统字体基线对齐问题提供了可落地的技术指引。

最终按照 AI 给的建议修改观察双端的效果,基本能比较符合预期了。

这张图片对比展示了字体对齐问题修复前后的效果,两组均包含有问题版与修复版的内容。左右两组中,“最高3819元”的字体内容,在有问题版里字形未对齐,而经过修正的修复版,字体的基线实现了对齐,对应文档中提到的为解决Android字体对齐问题,通过移动微调字形来适配,使双端渲染效果符合预期的内容,直观呈现了字体对齐问题修复的成果。

后记

后续为了不影响历史的场景,我将修改后的字体单独整理发布了个新的字体,后续碰到问题的场景再按需替换。现在我们再整体脱水总结下:

  1. 该字体的 hhea 垂直度量配置下降空间要比上升空间大,导致 iOS 中基于该配置渲染的字形垂直居中后依旧整体偏上;
  2. 历史原因,字体文件中存在多套度量配置。而 Android 等非 iOS 设备中在 USE_TYPO_METRICS=1 条件下会优先读取的是 sTypo 度量配置,所以 Android 上渲染和 iOS 有差异是正常的;
  3. iOS 中苹方字体设计上整体会偏基线靠下,导致基线对齐后视觉还是不对齐。最终通过微调字形下移实现对齐

相关文章:

阅读全文 »

lizheming 发布于 05月07, 2023

GitHub 自动翻译 GitHub Action

简介

github-translator 是我最近做的一个小工具。它是一款将非英文的 GitHub issue 和 GitHub discussion 自动翻译成英文的 GitHub Action。https://github.com/lizheming/github-translate-action 已启用该 Action,感兴趣的同学可以直接上仓库测试一下。

使用

使用其实很简单,在你的项目仓库中新建 .github/workflows/translate.yml 文件,并添加如下内容。它实现了当有 issue 或者 discussion 创建或者修改时会自动翻译并将翻译内容追加到原始的内容后面。

name: 'translator'
on:
  issues:
    types: [opened, edited]
  issue_comment:
    types: [created, edited]
  discussion: 
    types: [created, edited]
  discussion_comment:
    types: [created, edited]

jobs:
  translate:
    permissions:
      issues: write
      discussions: write
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: lizheming/github-translate-action
        env: 
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          IS_MODIFY_TITLE: true
          APPEND_TRANSLATION: true

起因

我一直在维护的评论系统 Waline 自身定位为国际化项目,所以一直都在思考如何为非中文的用户提供更多的资料。由于我们的项目中文用户还是占绝大多数,所以我并不想改变大部分用户的习惯强制大家使用英文在 GitHub issue/discussion 交流,于是就有了自动翻译的想法。

我找了相关的一些工具之后,最终 dromara/issues-translate-action 进入了我的视野。它基本上符合我的诉求,基于 GitHub Action 当有用户发布 issue 的时候就会执行翻译脚本并将翻译内容作为新评论发布出去。

但发布新评论是有提醒的,这个和我想要不打扰用户的初衷相悖。而且发布新评论上下文看起来会不太流畅,我更期望的是基于原始内容修改。于是乎就 Fork 过来准备增加一个配置项改造下。

结果在改造的过程中发现原作者的代码写的比较乱,所有的逻辑都在一个文件里写的过程式代码。强迫症的我就将其进行重构,对代码进行简单的函数拆分。

在阅读代码的过程中发现它使用的是作者自荐的一个账号作为机器人发布评论。如果使用者想要用自己项目的账号的话就需要自己去新建个第三方账号,比较麻烦。之前我一直都知道 GitHub Action 会注入一个机器人的自动令牌来方便我们对 GitHub 进行操作,所以我尝试简化了第三方机器人令牌的操作,直接使用 GitHub Action 令牌让流程变得非常简单。

除了 GitHub issue,GitHub discussion 也会有很多用户的内容产生,GitHub Action 本身是支持 discussion 的相关事件触发的。

不过官方却默认没有提供 GitHub discussion 的 RESTful 操作接口,仅提供了 GraphQL 的操作 API。之前一直没有尝试过 GraphQL,而 GitHub Action 又不太支持本地调试,试错的成本比较高。好在经过一段时间的摸索后总算是搞定了。

使用 GraphQL 的过程中踩了一个坑,在修改 discussion 的接口中需要提交 discussion_id。按照之前 RESTful 接口的操作经验,我惯性的认为这个 discussion_id 就是我们 github discussion url 上的 id。结果执行给我报了个错:

Error: Request failed due to following response errors:
 - Could not resolve to a node with the global id of '8'

由于根本没想到这个 id 的值有问题,一直认为是哪里的权限或者流程有问题查了半天。最后在《Automate your process with GitHub》的一个举例启发下才想着是不是这个 id 有问题,看了下数据发现有一个 node_id 才恍然大悟。后面就一马平川了,当接口调通的那一刻还是非常开心的!

将翻译内容追加到内容里的话,有一个点不好解决,当用户再次编辑内容的时候,我如何知道哪些是用户的原始内容,哪些是机翻的内容。这里我取了个巧,在内容中插入了一段固定文案的 HTML 注释。这段注释作为分隔符分隔原始内容和翻译内容,同时在注释中做好说明,让用户不要修改。这样就解决了再编辑的翻译问题。

原始内容
<!--This is a translation content dividing line, the content below is generated by machine, please do not modify the content below-->
翻译内容

于是乎「github-translator」这个项目就诞生了!

阅读全文 »

lizheming 发布于 09月13, 2022

你不知道的前端新特性

有些你不知道是正常的……因为他们基本都没怎么被浏览器实现 🥶

CSS Toggles

https://tabatkins.github.io/css-toggle/

纯 CSS 实现状态切换一般使用 Checkbox 或者 Radio 配合选择器来实现。实现起来麻烦不说,而且 CheckBox/Radio 的位置限制了你可控制的范围,用起来很不方便。

交互中越来越多依赖状态,比如 Tab, 弹窗, Summary 等,所以有了原生的状态切换草案。目前还是草案中,不过已经有对应的 Polyfill 了 https://github.com/oddbird/css-toggles

  • toggle-root:定义该元素可切换状态

      <toggle-root> = <custom-ident>
      [
        <toggle-states> [at <toggle-value>]? ||
        <toggle-overflow> ||
        group ||
        self
      ]?
    
    <toggle-states> = <integer [1,∞]> | '[' <custom-ident>{2,} ']'
    <toggle-value> = <integer [0,∞]> | <custom-ident>
    <toggle-overflow> = cycle | cycle-on | sticky
    
    // mode
    // mode 1 at 0 cycle wide
    // mode 3 at 0
    // mode [light dark] at light
    
  • toggle-overflow 定义设置的值超出之后的行为,针对数字类型有效

    • cycle / cycle-on: 比最小值小则为最大值,比最大值大则为 0 / 1
    • sticky: 最小值 <= value <= 最大值
  • self 定义触发元素和可切换元素的查找关系:

    • wide: 任意
    • narrow: 必须是父子关系
  • toggle-rigger:定义该元素为 的切换触发器

    <toggle-trigger> = <custom-ident> <trigger-action>?
    <trigger-action> =
      [prev | next] <integer [1,∞]>? |
      set <toggle-value>
    
    // mode
    // mode next 1
    // mode prev 1
    // mode next 2 
    // mode 2
    // mode set light
    
  • :toggle():根据 值选择元素

  • toggle:当可切换元素和切换触发器元素为同一个时,可使用 toggle 进行简写

  • toggle-group:指定该元素为 的组内元素

我们可以通过 Element.toggles() 来获取可切换元素的所有切换状态枚举,也通过 Element.addEventListener('togglechange', ...) 获取当前可切换元素的当前状态。

更多示例见:https://toggles.oddbird.net

PopUp API

https://open-ui.org/components/popup.research.explainer

如果要自己从 0 开始写一个弹窗是比较麻烦的,需要考虑很多事情:全屏浮层,内容居中,页面滚动失效,遮罩点击关闭,ESC 按下关闭...

所以就有好多组件封装,虽然原生已经有 <dialog> 标签可以干类似的事情了。但毕竟还是有点原始,所以 Chrome 就将 PopUp 原生实现了~~(真卷啊)~~。

  • popup: auto | hint | manual 默认为 auto,指定多弹窗的关系
  • popuptoggletarget:指向带有 popup 属性的元素 id,用来切换 popup 元素的显隐
  • popupshowtarget:指向带有 popup 属性的元素 id,用来显示 popup 元素
  • popuphidetarget:指向带有 popup 属性的元素 id,用来隐藏 popup 元素
  • Element.showPopUp() Element.hidePopUp()

目前仅最新的 Chromium 生效 :-) 还有些问题~ https://chromestatus.com/feature/5463833265045504

CSS 作用域

https://www.w3.org/TR/css-scoping-1/

以往我们要实现 CSS 作用域,组件之间样式不互相影响,一般就是 BEM 命名或者 CSS Module, CSS in JS 之流最终生成带 hash 的唯一选择器伪实现,亦或是使用 Shadow DOM 这种高成本的完美实现。

现在我们能直接使用 @``scope 来实现样式隔离了!

图来自:https://weibo.com/1708684567/LzHEY1wGm

不用太多介绍,简单好用~

structuredClone

https://developer.mozilla.org/en-US/docs/Web/API/structuredClone

原生的深拷贝方法,可以对结构化数据进行深拷贝,避免 JSON.parse(JSON.stringify()) 的尴尬。

structuredClone(value: any, { transfer?: any[] })
  • 不允许克隆Error、Function和DOM对象,如果对象中含有,将抛出DATA_CLONE_ERR异常。
  • 不保留RegExp 对象的 lastIndex 字段。
  • 不保留属性描述符,setters 以及 getters(以及其他类似元数据的功能)。例如,如果一个对象用属性描述符标记为 read-only,它将会被复制为 read-write。
  • 不保留原型链。

Navigation API

SPA 的基石路由管理,早有 History API 支持,但因为本质是历史记录的管理,缺少一些切换后的控制等功能,所以 Chrome 从新提出了 Navigation API 来专门实现路由管理。

navigation.addEventListener('navigate', navigateEvent => {
  if (shouldNotIntercept(navigateEvent)) return;

  const url = new URL(navigateEvent.destination.url);

  if (url.pathname === '/') {
    navigateEvent.intercept({handler: loadIndexPage});
  } else if (url.pathname === '/cats/') {
    navigateEvent.intercept({handler: loadCatsPage});
  }
});

navigateEvent包含以下信息:

  • canIntercept 是否支持拦截,跨域等无法拦截场景会返回 false
  • destination.url 跳转目标地址
  • hashChange是否是锚点跳转
  • userInitiated 是否是由页面内 <a> 标签触发的跳转,为 false 表示是浏览器前进后退等触发的跳转
  • downloadRequest是否是由具有download属性的链接带来的跳转
  • formData表单跳转时对应提交的表单数据,可以针对 Form 表单拦截后发送数据
  • navigationType枚举值"reload", "push","replace"或"traverse"(类似 history.goBack())之一。如果是"traverse",则无法通过preventDefault()阻止跳转
  • signal 提供给拦截 handler 中异步请求使用,方便当跳转终端后同步中断请求
  • scroll()控制跳转后滚动,在异步 handler 中比较有用,可能会多次滚动
function shouldNotIntercept(navigationEvent) {
  return (
    !navigationEvent.canIntercept ||
    navigationEvent.hashChange ||
    navigationEvent.downloadRequest ||
    navigationEvent.formData
  );
}

signal 和 scroll() 的例子:

navigation.addEventListener('navigate', navigateEvent => {
  if (shouldNotIntercept(navigateEvent)) return;
  const url = new URL(navigateEvent.destination.url);

  if (url.pathname.startsWith('/articles/')) {
    navigateEvent.intercept({
      async handler() {
        // The URL has already changed, so quickly show a placeholder.
        renderArticlePagePlaceholder();
        // Then fetch the real data.
        const articleContentURL = new URL(
          '/get-article-content',
          location.href
        );
        articleContentURL.searchParams.set('path', url.pathname);
        const response = await fetch(articleContentURL, {
          signal: navigateEvent.signal,
        });
        const articleContent = await response.json();
        renderArticlePage(articleContent);
        navigateEvent.scroll();

        const secondaryContent = await getSecondaryContent(url.pathname);
        addSecondaryContent(secondaryContent);
      },
    });
  }
});

// navigate
const { committed, finished } = navigation.navigate('/articles/hello-world');

设置好跳转拦截回调后,我们就能正常使用 navigation.navigate() 进行跳转了。返回两个 Promise 对象,分别对应 Navigate 完成的状态 committed,以及导航拦截的回调 Handler 结束的状态 finished。

比起 History API 惊喜的是,我们可以通过 navigation.entries() 获取当前所有的历史记录。通过 navigation.currentEntry 返回当前的记录。

navigation.entries() 获取的只能是同域的历史记录,跨域的无法获取。

兼容性:https://caniuse.com/mdn-api_navigation_navigate

URLPattern

https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API

可以算是原生版的 path-to-regexp ,用来做 URL 格式匹配解析的。最开始是因为 service worker 场景有解析拦截的资源请求地址的需求创造,但适用所有 URL 解析场景。

虽然 URLPattern 可以解析完整的域名,但一般 hostname 相关可以用 URL 解析,query 可以使用 URLSearchParams 解析。所以其实用的比较多的场景还是 pathname 的解析。

const pattern = new URLPattern({ pathname: '/books/:id(\\d+)' });
console.log(pattern.test('https://example.com/books/123')); // true
console.log(pattern.exec('https://example.com/books/123').pathname.groups); // { id: '123' }
console.log(pattern.test('https://example.com/books/detail')); // false
  • 使用 :<group> 来为当前匹配内容分组
  • {}是非捕获组,相当于正则中的 (?:),可以在后面增加{}?表示可选,不加的话其实可有可无
  • (正则表达式)也可以通过正则进行精确匹配,可以跟在命名分组的后面,相当于(?<group>正则表达式)。内部正则关键字需要做转义处理。
  • * 表示贪婪匹配,独立使用相当于正则 .*,也可以搭配在前几个规则后使用,例如 :id*
  • + 相当于 {1,} 不可独立使用

使用 URLPattern 而不是自己使用正则解析的一个好处就是它会帮助我们把 URL 规范化之后再进行解析,而不是简单的做一个字符串的正则转换匹配。

目前仅 Chrome 系兼容性还行,Node 也暂时还没有跟上版本。不过有对应的 Polyfill 了已经。

图片

https://developer.mozilla.org/en-US/docs/Web/CSS/aspect-ratio

如果需要按比例显示图片,一般会保持比例设置 DOM 尺寸后使用 background-image 或者使用 <img> 来展示,比较不便。所以增加了 aspectio-ratio 属性直接支持设置图片显示的比例。

配合 object-fit 属性指定图片比例不对的时候填充模式,食用更加。

img { 
  aspect-ratio: 16 / 9; 
  object-fit: contain;
}

https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-loading

object-fit 的兄弟属性 object-position:用于指定图片的展示区域

https://developer.mozilla.org/en-US/docs/Web/CSS/object-position

为了性能优化我们一般都会为图片增加懒加载的支持,这个官方也做了原生的支持。

<img src="image.jpg" alt="..." loading="lazy">

https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images

为了更好的性能优化,我们一般会从图片尺寸和图片格式上对图片资源进行处理,此为响应式图片。

关于图片格式,静图有 jpg,png,webp,avif,jpeg XL 这些格式。动图有 gif,apng,webp,avif之这些格式。avif 是基于视频编码 AV1 衍生的图片格式。针对不同的浏览器适配不同的格式,我们可以使用 <picture> 进行图片渲染。

<picture>
  <source type="image/avif" srcset="....avif" />
  <img src="....webp" loading="lazy" />
</picture>

除了格式之外,我们还可以按照分辨率来设置,图片尺寸也不在话下。综合如下:

<style>
img { 
    width: 320px;
    aspect-ratio: 320 / 240; 
    object-fit: contain; 
}
</style>
<picture>
  <source 
      type="image/avif" 
      srcset="...320x240.avif 1x ...640x480.avif 2x ...960x720.avif 3x" 
  />
  <img 
      srcset="...320x240.webp 1x ...640x480.webp 2x ...960x720.webp 3x" 
      src="....webp" 
      loading="lazy" 
  />
</picture>

参考资料:

阅读全文 »

lizheming 发布于 06月18, 2022

如何制作 Figma 插件

Figma 是一款专业的在线 UI 设计工具,它因为个人使用免费、在线跨平台、多人协作、蓬勃的 Figma Community 社区组织而广受欢迎。目前设计团队都在使用 Figma 进行 UI 设计交付。

能被 SaaS 化的终将被 SaaS 化

Figma 本身是 Web 服务,其客户端也是使用 Electron 进行的封装。所以它的插件系统是前端友好型,和日常前端开发没有什么太大的区别。

插件原理

Figma 的插件也采用了双线程的架构。UI 线程能获得完整 Web 的能力,但是无法直接操作 Figma;主线程则相反,可以通过 Figma API 对数据进行操作,但无完整的 Web 能力,仅有 JS 执行以及 Figma API 支持。两者通过 postMessage 进行通信。

根据官方文章描述,通过 WebAssembly 版的 QuickJS 来实现主线程的沙箱执行,UI 线程则是通过 iframe 执行。

采用这种方案的原因主要是既想保证代码隔离,但是又希望能方便的操作 Figma 数据。

根据官博文章描述,之前也曾有尝试使用 Web 原生的沙盒 API Realms 来实现主线程的沙盒执行,但因为该 API 的一些安全漏洞还是回退回了 QuickJS 的实现。

了解了插件的原理之后,下面我就以帮助设计师同学快速插入占位图的插件 Placeholder 为例,带大家一步一步的了解如何进行 Figma 插件开发。

需求整理

在进行插件开发之前,我们捋一捋我们需要实现的功能。http://placeimg.com/ 是一个专门用来生成占位图的网站,我们将利用该网站提供的服务制作一个生成指定大小的占位图并插入到 Sketch 画板中的功能。插件会提供一个面板,可以让使用者输入尺寸、分类等可选项,同时提供插入按钮,点击后会在画板插入一张图片图层。

项目结构

在 Figma 客户端中按照如上操作即可完成插件的初始化。除了默认的三个例子之外,官方也有一个示例插件的仓库,也可以参考。

https://github.com/figma/plugin-samples

Figma 插件默认推荐使用 TypeScript 开发,官方提供了完善的 TypeScript 类型支持。以默认的带 UI 的模板为例,初始化后进入文件夹 npm i 安装依赖后执行 npm run build 编译完成后点击插件即可看到效果。

.
├── README.md
├── code.js
├── code.ts
├── manifest.json
├── package-lock.json
├── package.json
├── tsconfig.json
└── ui.html

manifest.json

可以看到整体的接口和大多数 JS 项目一样,其中 manifest.json 用来记录插件的信息。manifest.json 这个文件大家可以理解为是 Figma 插件的 package.json 文件。我们来看看默认生成的 manifest.json。

{
  "name": "figma-placeimg",
  "id": "1117699210834344763",
  "api": "1.0.0",
  "main": "code.js",
  "editorType": [
    "figma"
  ],
  "ui": "ui.html"
}

其中重点的是 main 和 ui 两个字段:

  • main:指定插件的入口文件,该文件中的代码会运行在主进程中的沙箱里。
  • ui: 指定插件的 UI 代码文件,该文件中的代码会运行在 iframe 中。实际上,UI 代码文件的内容会作为字符串传递给 figma 内置变量 __html__,在沙箱内可以通过 figma.showUI(__html__) 创建 iframe。

这里注意到是将UI代码文件中的内容作为字符串注入到主线程中,类似 <iframe srcdoc="__html__" />。这就导致了我们无法直接引用插件中的其他资源,所有插件内依赖的资源都需要内嵌到最终的字符串中。

ui 字段也支持指定多个文件,当指定多个文件的时候会注入 __uiFiles__ 对象来映射文件。 manifest.json 中还支持通过 menu 字段定义插件的菜单。如果不想写 UI 也可以通过parameters指定支持的指令,直接通过输入指令来操作也是可以的。更多的配置可以查看官方文档 Plugin Manifest。

插件开发

一些基本原理了解清楚之后我们就可以进行插件的开发了。首先我们需要用户点击插件菜单之后打开一个面板,该面板可以配置尺寸、分类等基础信息。

<link rel="stylesheet" href="https://unpkg.com/figma-plugin-ds@1.0.1/dist/figma-plugin-ds.css">
<style>
.content { display: flex; }
.icon--swap { animation: rotate 1s linear infinite; }
.hide { display: none; }
@keyframes rotate {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}
</style>
<div id="app">
  <div class="field">
    <label for="" class="label">请输入图片尺寸:</label>
    <div class="content" style="padding-left: 10px;">
      <div class="input">
        <input type="input" class="input__field" placeholder="宽" name="width">
      </div>
      <div class="label" style="flex:0;">×</div>
      <div class="input">
        <input type="input" class="input__field" placeholder="高" name="height">
      </div>
    </div>
  </div>
  <div class="field">
    <label for="" class="label">请选择图片分类:</label>
    <div class="content">
      <div class="radio">
        <input id="radioButton1" type="radio" class="radio__button" value="any" name="category" checked>
        <label for="radioButton1" class="radio__label">全部</label>
      </div>
      <div class="radio">
        <input id="radioButton2" type="radio" class="radio__button" value="animals" name="category" >
        <label for="radioButton2" class="radio__label">动物</label>
      </div>
      <div class="radio">
        <input id="radioButton3" type="radio" class="radio__button" value="arch" name="category" >
        <label for="radioButton3" class="radio__label">建筑</label>
      </div>
      <div class="radio">
        <input id="radioButton4" type="radio" class="radio__button" value="nature" name="category" >
        <label for="radioButton4" class="radio__label">自然</label>
      </div>
      <div class="radio">
        <input id="radioButton5" type="radio" class="radio__button" value="people" name="category" >
        <label for="radioButton5" class="radio__label">人物</label>
      </div>
      <div class="radio">
        <input id="radioButton6" type="radio" class="radio__button" value="tech" name="category" >
        <label for="radioButton6" class="radio__label">科技</label>
      </div>
    </div>
  </div>
  <div class="field">
    <label for="" class="label">请选择图片滤镜:</label>
    <div class="content">
      <div class="radio">
        <input id="radioButton7" type="radio" class="radio__button" value="none" name="filter" checked>
        <label for="radioButton7" class="radio__label">正常</label>
      </div>
      <div class="radio">
        <input id="radioButton8" type="radio" class="radio__button" value="grayscale" name="filter" >
        <label for="radioButton8" class="radio__label">黑白照</label>
      </div>
      <div class="radio">
        <input id="radioButton9" type="radio" class="radio__button" value="sepia" name="filter" >
        <label for="radioButton9" class="radio__label">老照片</label>
      </div>
    </div>
  </div>
  <div class="field" style="padding:0 10px;">
    <div id="create" class="icon-button" style="width: 100%;">
      <div class="icon icon--image"></div>
      <div class="type type--small type--medium type--inverse">插入</div>
    </div>
    <div class="icon-button loading hide" style="width: 100%;">
      <div class="icon icon--swap"></div>
    </div>
  </div>
</div>

官方文档里有推荐 https://github.com/thomas-lowry/figma-plugin-ds 这个仓库,提供 Figma 的 UI 库组件,会让你的插件显的更加“原生”。

由于之前说过所有的资源都需要内嵌到 html 中,所以我使用了 CDN 地址的形式引入了样式文件。另外由于功能比较简单,这里也没有使用 React 等框架去进行开发。官方模板中有 React 模板可以参考 https://github.com/figma/plugin-samples/tree/master/webpack-react

获取图片

UI 完成之后接下来我们需要实现功能。我们需要将图片下载下来插入到 Figma 图层中。由于主线程没有网络能力,所以这部分工作需要在 UI 线程中完成,再通过 postMessage 传递回主线程中完成后续操作。具体的代码如下:

<script>
async function loadImage(url) {
  const resp = await fetch('http://localhost:3000/' + url);
  const buffer = await resp.arrayBuffer();
  return new Uint8Array(buffer);
}

document.getElementById('create').onclick = async (e) => {
  const width = parseInt(document.querySelector('input[name="width"]').value);
  const height = parseInt(document.querySelector('input[name="height"]').value);
  const category = document.querySelector('input[name="category"]:checked').value;
  const filter = document.querySelector('input[name="filter"]:checked').value;
  const loading = document.querySelector('.icon-button.loading');

  e.target.classList.add('hide');
  loading.classList.remove('hide');
  
  const imgBytes = await loadImage(`https://placeimg.com/${width}/${height}/${category}/${filter}`);
  parent.postMessage({ pluginMessage: { type: 'insert', bytes: imgBytes, width: width, height: height } }, '*');

  loading.classList.add('hide');
  e.target.classList.remove('hide');
}
</script>

由于 UI 线程是一个纯 Web 环境,当我们使用 XMLHttpRequest 或者 fetch 发送请求的时候,肯定会碰到跨域的问题。按照文档 https://www.figma.com/plugin-docs/making-network-requests/ 提供的解决办法,我们只能依靠服务端加层代理来解决。

当你的插件没有 UI 面板的时候,如何进行网络请求?按照文档所说,我们是可以设置 figma.ui.show() 的第二个参数,将其设置成 visible: false 的形式创建 iframe 获取数据。

// code.ts
function fetch(url, options) {
  const html = `<script>
    fetch(${url}, ${JSON.stringify(options)}).then(resp => resp.json()).then(resp => parent.sendMessage({
      pluginMessage: { type: 'networkRequest', data: resp }
    });
  </script>`;
  
  return new Promise(resolve => {
    figma.ui.on('message', msg => 
      msg.type === 'networkRequest' && resolve(msg.data)
    );
    figma.ui.show(html, { visible: false });
  });
}

插入图片

由于只有主线程才能操作 Figma 数据,所以需要在 UI 线程 postMessage 传递数据到主线程中继续进行操作。

主线程中的步骤就比较简单了,使用 Figma API 创建好矩形并将图片填充即可完成图片的插入。

我们可以通过设置 figma.currentPage.selection 设置选中项,并使用 figma.viewport.scrollAndZoomIntoView 将刚插入的数据滚动到视野中。

figma.ui.onmessage = msg => {
  if (msg.type === 'insert') {
    const rectNode = figma.createRectangle();
    const image = figma.createImage(msg.bytes);

    rectNode.name = 'Image';
    rectNode.resize(msg.width, msg.height);
    rectNode.fills = [{
      imageHash: image.hash,
      scaleMode: 'FILL',
      scalingFactor: 0.5,
      type: 'IMAGE'
    }];
    
    figma.currentPage.appendChild(rectNode);
    figma.currentPage.selection = [rectNode];
    figma.viewport.scrollAndZoomIntoView([rectNode]);
  }

  figma.closePlugin();
};

除了需要显示的调用 figma.ui.show 来展示 UI 之外,在执行完插件后需要显示的调用 figma.closePlugin() 告知 Figma 进行关闭插件操作。

优化插件

上面我们实现了配置宽高然后插入一张图片。但有时候我们会先插入一个矩形占位,之后才会将其替换成图片。所以我们可以优化下操作步骤,当选中到一个矩形的时候,自动获取到它的尺寸,然后点击插入后会直接插入到该矩形中。

// code.ts
function initSelectionState() {
  if (figma.currentPage.selection.length === 1 && figma.currentPage.selection[0].type === 'RECTANGLE') {
    const rectNode = figma.currentPage.selection[0];
    figma.ui.postMessage({ type: 'update', width: rectNode.width, height: rectNode.height });
  }
}
figma.on('selectionchange', initSelectionState);
initSelectionState();

通过在主线程中监听 selectionchange 事件,我们能实时获取到当前选中的元素。我们将尺寸信息发送到 UI 线程后让其填充到输入框中称为默认值。

window.onmessage = function(e) {
  if (e.data.pluginMessage.type === 'update') {
    document.querySelector('input[name="width"]').value = e.data.pluginMessage.width;
    document.querySelector('input[name="height"]').value = e.data.pluginMessage.height;
  }
}

最后再插入的时候,我们也需要判断如果有选中矩形的话则优先使用选中的矩形,而不是新增矩形。

let rectNode: RectangleNode;
if (figma.currentPage.selection.length === 1 && figma.currentPage.selection[0].type === 'RECTANGLE') {
  rectNode = figma.currentPage.selection[0];
} else {
  rectNode = figma.createRectangle();
}
// const rectNode = figma.createRectangle();

插件发布

最终我们的插件的主体功能就开发完毕了。下面我们就可以进行插件的发布了。我们可以直接通过插件管理中 Publish 操作进行发布。

和 Chrome 插件有点类似,Figma 插件支持发布到社区,也支持发布到组织。支持发布到多个组织中。发布到组织不需要审核,但只有该组织的同学和文件可使用。发布到社区的需要由 Figma 官方审核。

插件调试

由于是 Web 技术向,所以 Figma 的插件调试非常简单。直接 Command + Shift + I 打开控制台即可。

不过比较麻烦的是热更新的支持不太好。之前页面资源需要编译到 html 问价中的方式也不太友好。所以有人就想到了** iframe 套娃**来解决 UI 的更新问题。

简单来说就是通过在 UI 线程中再嵌套一个在线页面,UI 线程作为主线程和新的 iframe 的消息中转。这样相当于将插件在线化,回到了纯 Web 开发模式了,热更新自然就没有什么问题了。

不过这仅能解决 UI 线程的热更新问题,主线程如果有变化还需要重新更新插件解决。基于上面的方案,其实我们能做的更“绝”一点。我们可以将主线程变成一个壳,具体的业务代码由 iframe 下发,通过这种方式来解决主线程的更新问题。

// ui.html
parent.postMessage({ pluginMessage: { type: 'MAIN_CODE', code: 'console.log(figma)' } });
// code.ts
figma.ui.onmessage = (msg) => {
  msg.type === 'MAIN_CODE' && eval(msg.code);
}

后记

通过示例讲述了如何开发一个 Figma 插件,包含了获取 Figma 数据信息,操作 Figma 文件等双向操作。基于以上简单操作我们可以完成更多有意义的事情帮助我们更好的开发。比如快速导出多尺寸图片、导出图标自动发布到 npm 等…

以上示例代码已发布到 GitHub 中,欢迎参考。

https://github.com/lizheming/figma-placeimg

阅读全文 »

lizheming 发布于 03月14, 2022

豆瓣书影音同步 GitHub Action

2023-07-12 更新:《关于豆瓣图片无法直接使用的说明》

简介

doumark-action 是我前段时间造的一个轮子。它是一款 GitHub Action,支持在 GitHub 中同步你的豆瓣书影音数据到本地的文件或者 Notion 中。我利用它,定时同步我的豆瓣观影数据到我的博客仓库中,并利用 Hugo 读取文件数据渲染成页面,观影 是最终的效果。

使用

使用其实很简单,在你的博客仓库中新建 .github/workflows/douban.yml 文件,以观影为例添加如下内容。它实现了每小时自动抓取你的豆瓣观影记录并更新到文件中,如果发现文件有更新则触发 commit 提交。

name: douban
on: 
  schedule:
  - cron: "30 * * * *"

jobs:
  douban:
    name: Douban mark data sync
    runs-on: ubuntu-latest
    steps:
    - name: Checkout
      uses: actions/checkout@v2

    - name: movie
      uses: lizheming/doumark-action@master
      with:
        id: lizheming
        type: movie
        format: csv
        dir: ./douban

    - name: Commit
      uses: EndBug/add-and-commit@v8
      with:
        message: 'chore: update douban data'
        add: './douban'

该 workflow 总共分为三步,第一步初始化 Git 仓库;第二步调用 doumark-action 同步豆瓣账号 lizheming 的 movie 类型数据到 ./douban 文件夹下,并保存为 csv 格式文件;最后一步则是当 ./douban 文件夹下有更新则调用插件提交修改。

Notion

如果是要同步到 Notion 中会稍微复杂一点。需要先准备好 Notion Token 并初始化好页面。

  1. 我们可以在 My Integrations 里创建机器人得到 NOTION_TOKEN。
  2. 电影 | 阅读 | 音乐 基于这三个模板点击右上角的 Duplicate 按钮渲染复制页面。
  3. 复制后的页面右上角选择右上角的 Share - Invite 将第一步创建的机器人加入,这样机器人就有权限更新你的页面数据。
# .github/workflows/douban.yml
name: douban
on: 
  schedule:
  - cron: "30 * * * *"

jobs:
  douban:
    name: Douban mark data sync
    runs-on: ubuntu-latest
    steps:
    - name: movie
      uses: lizheming/doumark-action@master
      with:
        id: lizheming
        type: movie
        format: notion
        dir: xxxx
        notion_token: ${{ secrets.notion_token }}

其中 format 需要为 notion,dir 为 Notion 页面 ID,Notion 页面 URL 第一个随机字符即为页面的 ID。

渲染

数据已经有了,剩下的就是我们需要读取该数据源的数据,并渲染出页面。除了数据渲染之外,我还给自己增加了筛选查找的需求,所以我在头部还渲染了一些筛选项。

{{$movies := getCSV "," "douban/movie.csv" }}
{{$scratch := newScratch}}
{{$scratch.Add "genres" slice}}
{{range $idx, $movie := $movies}}
  {{if ne $idx 0}}
    {{$scratch.Set "genres" (union ($scratch.Get "genres") (split (index $movie 7) ","))}}
  {{end}}
{{end}}
<div class="sc-ksluID gFnzgG">
  <!--分类筛选-->
  <div class="sc-bdnxRM jvCTkj">
    <a href="javascript:void 0;" class="sc-gtsrHT kEoOHR" data-search="genres" data-method="contain" data-value="">全部</a>
    {{range $genre := $scratch.Get "genres"}}
    <a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="genres" data-method="contain" data-value="{{$genre}}">{{$genre}}</a>
    {{end}}
  </div>

  
  <!--时间筛选-->
  <div class="sc-bdnxRM jvCTkj">
    <a href="javascript:void 0;" class="sc-gtsrHT kEoOHR" data-search="year" data-method="equal" data-value="">全部</a>
    {{range $year := (seq 2022 -1 2009)}}
    <a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="year" data-method="equal" data-value="{{$year}}">{{$year}}</a>
    {{end}}
  </div>
  
  <!--评分筛选-->
  <div class="sc-bdnxRM jvCTkj">
    <a href="javascript:void 0;" class="sc-gtsrHT kEoOHR" data-search="star" data-method="equal" data-value="">全部</a>
    <a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="star" data-method="equal" data-value="5">五星</a>
    <a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="star" data-method="equal" data-value="4">四星</a>
    <a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="star" data-method="equal" data-value="3">三星</a>
    <a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="star" data-method="equal" data-value="2">二星</a>
    <a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="star" data-method="equal" data-value="1">一星</a>
    <a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="star" data-method="equal" data-value="0">零星</a>
  </div>

  <!--排序规则-->
  <div class="sc-bdnxRM jvCTkj sort-by">
    <a href="javascript:void 0;" class="sort-by-item active" data-order="time">
      观影时间排序
    </a>
    <a href="javascript:void 0;" class="sort-by-item" data-order="rating">
      网友评分排序
    </a>
  </div>

  <!-影片列表-->
  <div class="sc-dIsUp fIuTG">
    {{range $idx, $movie := $movies}}
    <!--排除第一行表头-->
    {{if ne $idx 0 }}
    <div 
      class="sc-gKAaRy dfdORB" 
      data-year="{{index $movie 9}}" 
      data-star="{{index $movie 8}}"
      data-rating="{{index $movie 6}}"
      data-genres="{{index $movie 7}}"  
    >
      <a href="{{index $movie 5}}" target="_blank">
        <div class="sc-hKFxyN HPRth">
          <div class="lazyload-wrapper ">
            <img class="lazy" data-src="https://dou.img.lithub.cc/movie/{{ index (findRE `\d+` (index $movie 5)) 0 }}.jpg" referrer-policy="no-referrer" loading="lazy" alt="{{index $movie 1}}" width="150" height="220">
          </div>
        </div>
        <div class="sc-iCoGMd kMthTr">{{index $movie 1}}</div>
        <div class="sc-fujyAs eysHZq">
          <span class="sc-jSFjdj jcTaHb">
            {{range $star := (seq 0 2 8)}}
            <svg viewBox="0 0 24 24" width="24" height="24" class="sc-dlnjwi {{if gt (index $movie 6) $star}}lhtmRw{{else}}gaztka{{end}}">
              <path fill="none" d="M0 0h24v24H0z"></path>
              <path fill="currentColor" d="M12 18.26l-7.053 3.948 1.575-7.928L.587 8.792l8.027-.952L12 .5l3.386 7.34 8.027.952-5.935 5.488 1.575 7.928z"></path>
            </svg>
            {{end}}
          </span>
          <span class="sc-pNWdM iibjPt">{{index $movie 6}}</span>
        </div>
      </a>
    </div>
    {{end}}
    {{end}}
  </div>  
</div>

整体的布局我使用了 Flex 布局,增加了图片懒加载。

搜索

使用 CSS 的属性选择器,可以非常简单的实现搜索的功能。事先将数据通过属性挂载在 DIV 上,通过 [data-year^=2022][data-genres*=喜剧] 就可以查询到 2022 年看过的喜剧片了!

function search(e) {
  // 隐藏全部电影
  document.querySelectorAll('.dfdORB').forEach(item => item.classList.add('hide'));
  // 移除当前筛选项之前的选项
  document.querySelector(`.dvtjjf.active[data-search="${e.target.dataset.search}"]`)?.classList.remove('active');
  // 如果选择的是非全部选项,则高亮该选项
  if(e.target.dataset.value) {
    e.target.classList.add('active');
  }

  // 找到所有筛选项的值
  const searchItems = document.querySelectorAll('.dvtjjf.active');
  // 根据筛选值拼接 CSS 选择器,JSON 数据类型的需要使用 *=,其它的需要使用 ^=
  const attributes = Array.from(searchItems, searchItem => {
    const property = `data-${searchItem.dataset.search}`;
    const logic = searchItem.dataset.method === 'contain' ? '*' : '^';
    const value = searchItem.dataset.method === 'contain' ? `${searchItem.dataset.value}` : searchItem.dataset.value;
    return `[${property}${logic}='${value}']`;
  });
  const selector = `.dfdORB${attributes.join('')}`;
  // 找到目标元素对其进行展现操作
  document.querySelectorAll(selector).forEach(item => item.classList.remove('hide'));
}

window.addEventListener('click', function(e) {
  if(e.target.classList.contains('sc-gtsrHT')) {
    e.preventDefault();
    search(e);
  }
});

排序

由于我使用了 Flex 布局,所以排序这个实行实际上是可以通过 Flex 的 order 属性来实现的。这样做的好处就是我不需要真的去修改 DOM 结构,只需要生成或者删除 CSS 就好了。

function sort(e) {
  const sortBy = e.target.dataset.order;
  const style = document.createElement('style');
  style.classList.add('sort-order-style');
  document.querySelector('style.sort-order-style')?.remove();
  document.querySelector('.sort-by-item.active')?.classList.remove('active');
  e.target.classList.add('active');
  if(sortBy === 'rating') {
    const movies = Array.from(document.querySelectorAll('.dfdORB'));
    movies.sort((movieA, movieB) => {
      const ratingA = parseFloat(movieA.dataset.rating) || 0;
      const ratingB = parseFloat(movieB.dataset.rating) || 0;
      if(ratingA === ratingB) {
        return 0;
      }
      return ratingA > ratingB ? -1 : 1;
    });
    const stylesheet = movies.map((movie, idx) => `.dfdORB[data-rating="${movie.dataset.rating}"] { order: ${idx}; }`).join('\r\n');
    style.innerHTML = stylesheet;
    document.body.appendChild(style);
  }
}
window.addEventListener('click', function(e) {
  if(e.target.classList.contains('sort-by-item')) {
    e.preventDefault();
    sort(e);
  }
});

起因

很早以前我就养成了看完电影就要上豆瓣上标记一下的习惯,并在每年年末的时候统计一下。为了满足自己的需求,很早之前我写过一款 Chrome 插件,用于统计豆瓣电影记录,具体可以看这篇文章《豆瓣电影统计插件For Chrome》。

在后来无意间知道了牧风老师开发的布克牧为,用户同步豆瓣记录数据并支持在第三方网站中挂件展示。所以我为我的博客增加了观影页面,用来展示我看过的电影。后来,每当我和朋友聊电影,想要推荐之前看过的电影给他们的时候,它也成为了重要的查找入口。

布克牧为的第三方挂件样式很好看,但筛选功能偏弱,仅支持分类的筛选。对于我有搜索和统计的需求其实没办法很好的满足。再加之最近布克牧为时长不出数据,变的不太稳定,导致我又有了重新造轮子的想法。

自从博客切换成 Hugo 之后,我对 SSG(Server Side Generate) 就非常的痴迷,连评论都是使用 SSG 的方式渲染到页面上的,具体可以查看我之前写的这篇文章《静态博客如何高性能插入评论》。于是关于这次的功能理所当然我也想使用类似的方式。

所以最开始我是写了个独立的服务,该服务会定时抓取数据并更新到数据库中,同时提供了 API 用于获取数据。在博客中则去调用该接口获取到数据后渲染页面。后来因为需要找一个第三方定义任务服务,用于定时触发抓取任务接口。更新数据后还需要调用博客的构建触发器,同时又觉得每次构建的时候都需要花时间去请求一次接口有点浪费,就一直在思考有没有其它的方式。

其实 Hugo 除了支持 JSON 接口的数据读取之外,也支持本地 CSV 文件的数据读取。直接读取从库中的表格文件获取到数据能减少不必要的网络请求,而表格文件更新的时候会自动触发 Git 操作从何触发博客的构建任务。所以最终就想到了 GitHub Action 的方案,通过免费的 GitHub Action 触发 CSV 文件的更新,最终触发构建更新。

于是乎「doumark-action」这个项目就诞生了!

阅读全文 »

lizheming 发布于 02月13, 2022

Eureka 主题性能优化小结

我在之前的文章 《Hugo 主题 Eureka 自定义》 中有讲到我现在用的博客主题就是 Eureka。不过主题虽然好看,但是性能跑分却比较低。遂趁着周末时间给优化了一下,遂有本文。

打开控制台看了下资源的加载,之前没注意,这会才发现首页竟然后 10M 这么多资源要加载,怪不得性能不好呢。

JS 资源

JS 资源中大头是 FontAwesome,主题中直接使用了引用了所有图标的集成版地址 @fortawesome/fontawesome-free/js/all.min.js,该资源有 1.2M。但其实在主题中根本没有使用到如此之多的图标,完全可以按需加载优化。

参考《Using Font Awesome Icons in Hugo》 中的优化方法。通过关键词查找收集了主题中用到的图标,下载下来后通过模板语法直接在构建阶段把所有的 SVG 内联到 HTML 中。

不过我发现在首页中会有大量的重复图标,使用该方法后会有重复的 SVG 内容内联到 HTML 中。所以我再上述方法的基础之上再次尝试优化,将所有的 SVG 图标合并到一个文件中,每个使用的地方使用 <use href="#<icon>" /> 来进行引用。

首先我们还是像之前那样,把所有的图标下载下来。区别是通过 <symbol> 将图标转成图元,方便后续使用 <use> 进行复用。

// deno run --allow-net --allow-write fontsvg.ts
import * as path from "https://deno.land/std/path/mod.ts";
const __dirname = new URL('.', import.meta.url).pathname;

const icons = [
  "calendar-alt",
  "calendar",
  "star-half-alt",
  "comment",
  "clock",
  "bars",
  "search",
  "moon",
  "sun",
  "adjust",
  "globe",
  "th-list",
  "folder",
  "caret-right",
  "edit",
  "user",
  "pen",
  "book",
  "rss"
];

const baseUrl = 'https://cdn.jsdelivr.net/gh/FortAwesome/Font-Awesome@5.x/svgs/solid';
const toDefs = (id: string, svgText: string) => svgText
  .replace(/<svg.+viewBox=['"](\d+) (\d+) (\d+) (\d+)[^>]+>/, `<symbol id="${id}" viewBox="$1 $2 $3 $4">`)
  .replace('</svg>', '</symbol>')
  .replace('<path', '<path fill="currentColor"')
  .replace(/<!--.+?-->/, '');
const iconTexts = await Promise.all(icons.map(async icon => {
  const text = await fetch(`${baseUrl}/${icon}.svg`).then(resp => resp.text());
  const match = text.match(/(viewBox="\d+ \d+ \d+ \d+")/);
  if(!match) {
    throw Error('match error');
  }
  Deno.writeTextFile(path.join(__dirname, `./fontawesome/${icon}.svg`), `<svg ${match[1]}><use href="#${icon}" /></svg>`);
  return toDefs(icon, text);
}));
Deno.writeTextFile(path.join(__dirname, './fontawesome/all.svg'), `<svg width=0 height=0 viewBox="0 0 0 0">${iconTexts.join('\r\n')}</svg>`);

之后我们需要在 header 中加载 all.svg。在主题 header.html 开头增加如下代码:

{{ $svg := resources.Get (print "fontawesome/all.svg") }}
{{ $svg.Content | safeHTML }}

还是像引文中的方式一样,我们定义一个 Partial,所有使用的地方可以直接使用这个 Partial 内联图标 SVG。

<!--layouts/partials/fontawesome.html-->
<span class="inline-svg svg-inline--fa fa-w-14 {{.class}}">
  {{ $svg := resources.Get (print "fontawesome/" .icon ".svg") }}
  {{ $svg.Content | safeHTML }}
</span>

最后我们在使用的地方只需要使用如下 partial 命令即可完成图标的嵌入。修改 calendar 为对应的图标名称可以实现内嵌对应的图标。

{{ partial "fontawesome.html" (dict "icon" "calendar") }}

这么优化之后首页 HTML 文档的体积有着显著的改善,从 89.7k 降低至 77.3k。不过由于内联的图标都变成了不重复的内容,压缩率反而降低了,这倒是我没有想到的。Vercel 使用的是 Brotil 压缩方式,原本基于引文的方式压缩后的体积是 20.4k,优化后压缩后的体积反而增加到了 22.1k。不过 2k 不到的体积增长,倒是还能接受。

解决了大头之后,JS 资源还剩下 highlight.min.js 和 eureka.min.js,前者是代码高亮使用,后者是主题对应的 JS 脚本。由于我在首页实际上是没有代码高亮的需求,所以我将 highlight.js 相关的资源做了判断,仅在详情页的时候再做加载。

而针对 eureka.min.js 这种小文件,我们可以考虑将其内联到 HTML 中减少一个请求。不过该优化在 HTTP/2 场景并不是一个最佳实践,诸君请适度使用。

{{- $eurekaJS := resources.Get "js/eureka.js" | resources.ExecuteAsTemplate "js/eureka.js" . | minify }}
<script defer src="data:application/javascript;base64,{{ $eurekaJS.Content | base64Encode }}"></script>

最后其实还剩下百度统计的请求资源,这个参考以下两篇文章也是可以做类似的优化的,虽然两篇文章讲的都是 Google Analytics 但是原理都差不太多。不过目前这种程度我也能接受了,就没有再继续尝试下去,之后有空再参考优化一下。

CSS 资源

CSS 资源中大头是 eureka.min.css,高达 4M 的体积一看就知道它用了原子类 CSS 库 TailWind(笑哭。毕竟正经人谁能写出 4M 的 CSS 文件,特别还是这么简单的一款主题。

我对原子类 CSS 写法一直不太感冒的原因主要有两点,一个是本质它把 CSS 的功能转嫁到了 HTML class 属性上,看着那些纷繁复杂又臭又长的 class 令人脑壳疼。再一个就是因为它的体积问题。

好在 TailWind CSS 提供了优化选项,通过遍历配置中的文件查找所有可能用到的 class 节省体积。具体的话可以参考文档。由于是静态分析 class,所以不能出现动态拼接,也不能出现变量之类的替代。

将配置开启之后,eureka.min.css 文件从初始的 4M 优化成了 21.4k,Brotil 压缩后体积是 5.2k,整个人都神清气爽了有没有。

初次之外,网站还加载了一款代码高亮主题 solarized-light.min.css 以及一款自定义字体。代码高亮样式则按照 JS 优化策略一样,仅针对详情页再加载。而自定义字体我看了下会加载一款中文的 Web Font,用于提供给全站使用。所以也没有做动态切片等体积优化处理,每次都会加载 2M 的字体资源。考虑到该需求是纯美化场景,系统默认的衬线体也还可以,遂直接将该自定义字体移除解决。

图片资源

图片也是比较中的资源加载灾区,有 3M 的图片资源加载。由于之前没有特别在意这块,很多场景为了方便直接原图就放上来了,也没有做图片的处理。所以这次就使用常规的图片资源处理手段对图片进行了优化处理,主要是图片压缩以及 LazyLoad。

图片处理这块就是很正常的手段了,没有什么值得说的。图片压缩主要是使用了 https://tinypng.com,LazyLoad 则是用了苏卡卡推荐的 vanilla-lazyload。

除了体积的优化之外,图片还可以对它进行格式和尺寸进行优化。现在比较推荐使用 <picture> 的写法渲染图片,内部存放不同的格式的图片,浏览器会根据是否支持选择对应的格式展示。

<picture>
  <source srcset="image.webp" type="image/webp">
  <img src="image.jpg" alt="my image">
</picture>

剩下的就是如何获取 webp 的图片了,网上有比较多使用 cwebp 手动转成 webp 图片的教程,我就不多赘述了。除此之外,Hugo 本身似乎也支持做个格式的转换(https://discourse.gohugo.io/t/image-conversion-without-resizing/32429)。

{{ $i := resources.Get "image.jpg" }}
{{ $resizeOptions := printf "%dx%d webp" $i.Width $i.Height }}
{{ $i = $i.Resize $resizeOptions }}

最后一种方式,也是我比较推荐的方式,是使用外部的 CDN 存储服务。这些外部服务都会有通过 URL 动态转换和裁剪的能力。

除了更好的格式,对图片的裁剪也很重要。实际上 Hugo 本身也有非常多的图片处理方法用于图片优化,主要是裁剪和滤镜。我们可以利用 Hugo 本身的功能,也可以使用外部 CDN 存储服务,基于他们的动态裁剪能力来实现。

不过我的只是我的文章中图片地址五花八门,有本地的也有各种外部 CDN 的,使用那种方式都比较麻烦。所以暂时就没有处理格式的事情了, 如果有需要的可以参考一下。

2022-07-02 更新

最终我采用了外部 CDN 的方式,将博客中所有的外链图片重新抓取下来整理上传到又拍云,并对文章图片进行了整体的清洗,一些老图无法访问的就直接指向一个 404 的图片了。

同时开启了又拍云的 Webp 自适应功能,无需修改图片链接地址,又拍云 CDN 会自动根据浏览器是否支持来返回 Webp 图片,轻松全站支持 Webp 图片访问。最终的优化效果也非常明显,整体图片体积再次缩小了 3 倍左右。

总结

在各种优化之下,最终首页的资源加载从之前的 10M 缩减到了现在的 361k,加载速度已经令我比较满意了。之后我再抽空处理下整站的图片资源。

阅读全文 »

lizheming 发布于 02月07, 2022

断点调试之压缩造成的血案

前段时间组里的小伙伴让我帮忙排查一个线上问题,我觉得排查流程比较有意思,想着记录一下看看是否能对其它同学有所帮助,遂有此文。

事情的起因是前几天线上突然收到一个报警,错误内容是 TypeError: C.fn is not a function。相关同学尝试排查无果后又回滚了最近上线的变更也没有排查到问题。虽然最终确认了复现路径,但是在本地却无法复现。

🔍 初步排查

在线上复现该错误后,点击错误堆栈的文件跳转,快速定位到线上出错的代码。由于线上都是压缩过的代码,这里我们可以点击左下角的 {} 进行代码美化。

经过美化后我们可以看出来,应该就是 189624 行出了问题。我们直接尝试在这一行上打断点,之后会发现代码会在这块疯狂打转。这是因为它处于一个 for 循环中。仔细观察不难看出代码其实上是 this.head 这个链的递归执行,每次执行完当前 C 都会被赋值成链的下一个值,并执行该值对应的 fn() 方法。也就是问题是这个链上的某个值没有 fn() 方法,最终导致了这个报错。

大概确认问题后,我们需要看一下最终这个 C 的值是什么。由于处在循环当中,一次一次的点击下一步实在是麻烦。由于我们有明确的目标,所以我们可以尝试添加条件断点,让只有符合我们条件的断点才停下来,否则都忽略正常执行。

在 189624 行右键点击 Add conditional breakpoint... 选项,并输入 typeof C.fn !== 'function' 作为条件表达式。这样我们就实现了一个仅在 C.fn 不是一个方法的时候才会触发的条件断点。

条件断点触发后,我们可以在控制台中基于断点时的上下文输出变量进行调试。可以从下左图我们可以清晰的看到,此时的 C.fn 的确是不存在的。

由于刚才我们已知 this.head 应该是一条链,依次执行链上的方法。所以理论上来说链上的每个元素都是一样的。于是乎我就尝试输出了 this.head 链上所有的元素想看一下这个链到底是什么样子的。模拟代码里的循环我也在控制台尝试写了下,发现输出的结果如下左图展示。在链的最后一个元素就是我们有问题的元素。

而之前我们已知的是在本地开发环境是无法复现这个问题的,所以我照猫画虎在本地同样的位置也输出了一下 this.head 链,结果见上右图。发现和线上输出的,除了最后这个有问题的元素,其它的输出基本是一样的。

看来问题的原因就在于线上的代码执行在链上增加了这么一个玩意导致的,而本地由于没有这个多余的元素所以没有触发问题。

🐞 确认问题

找到原因后我就想着从代码层面捋一下是哪里给增加了这么个玩意。由于之前的代码中可以明显的看到 i.prototype.finish 的字样,初步猜测这应该是一个类的定义。于是乎就想看看这个类是在哪里实例化执行的。

通过刚报错时的压缩后的代码,我们可以看到报错的模块是”protobuf.js“这个模块。于是乎我在项目和依赖中查找是哪个模块依赖了它,最终查到了是我们内部使用的一个 IM 消息模块有用到。

之后在具体的依赖模块中搜索 .finish() 相关字样,查到了最终的调用在如下地方。serialize() 方法会调用 Request.encode() 方法,它返回一个 $Writer 基类的实例,而 $Writer 就是 protobuf.js 模块中的 Writer 基类。Request.encode() 方法实例化完 Writer 基类后会执行一系列的成员函数,执行完毕后会返回 Writer 实例,并调用它的 finish() 方法。

了解执行流程之后,我就顺着 Request.encode(req).finish() 这一句开始向上对 Request.encode() 方法进行断点(下左图)。如下图先尝试在末尾断点输出 o.head(o 是压缩后指向 Writer 实例的变量),发现此时已经存在异常链元素了(下右图)。

中间的代码稍微打了下断点发现也依旧如此。最终在头部断点处发现了端倪。尝试在开头增加断电之后,发现在 120274 行执行完毕之后 o.head 链上就已经存在了异常数据了。

那我们尝试翻看下代码看一下 o.create() 方法具体干了什么。从下图左我们可以看到 Writer.create() 本质其实就是 Writer 基类的实例化工厂方法。而下图中可以看到 Writer 的构造方法对一些成员属性赋了初值。其中关键的 this.head 的初值是一个 Op 基类的实例。下图右可以看到 Op 基类的构造方法中也是赋了一些初值。同时我们可以看到 function noop() {} 实际上就是一个空方法。也就是说 this.head 默认指向了一个空方法实例化的 Op 对象。

乍一看整个流程其实非常简单,本质上构造函数内都是一些简单的赋值操作,不会有什么问题。于是乎还是按照链路依次向上排查问题。因为上一趴我们排查到执行完 Writer.create() 工厂方法后就有问题了,所以这里我们需要对 Writer 的构造函数进行断点排查。

尝试如下图在构造方法末尾断点后,输出 this.head 链,发现此时已经有异常数据了。而这个时候不过只是做了初值的操作而已,这怎么就能出问题了呢?由于断点情况下我能在当前上下文中进行调试,所以此时我尝试自己执行一下 Op 基类的实例化操作(见下图)。这时候发现确实它的 next 属性不对,是我们要找的问题元素!

此时此刻,我感觉我们已经越来越接近真相了!

如下图左我们在 f 变量上 hover 一会儿,会出现它的定义处链接,点击后会直接跳转到它的定义处下图右(其实就离的不太远)。

大家可能也都注意到了,我们刚才看的代码中 this.next 明明是定义成 undefined 怎么这里给定义成 g 了?而这个 g 又对上了 189456 行 g = s.base64,所以我们才看到 this.head.next 的值这么奇怪。而我们尝试看一下引用的 protobuf.js 代码,发现代码里 this.next 虽然是等于 g 但是它并没有关联到 u.base64 上。

由于我之前有解决过一些压缩再压缩后代码异常的 Case,所以至此我基本上可以断定,由于 protobuf.js 在我们的依赖中是引入的压缩后的代码,而压缩后的代码再走压缩导致了变量指向出现错乱从而导致的问题。这也侧面印证了为什么只有线上可以,本地无法复现的原因。因为本地是没有走压缩的。

🛠 如何解决

找到问题后有两种解决方法。一是正向的去查找压缩工具造成这个问题的原因;二是反向的去规避该问题,我们不引入压缩后的代码而是正常引入未压缩的代码,最终统一由项目进行压缩处理。

这两种方法都能解决问题。而第一种需要的时间会比较久,所以我们先采用了第二种方法临时解决一下。由于该依赖包不是我们维护的,我们只能使用 patch-package 给模块打补丁的方式进行修复。它的功能是在安装完依赖后会根据我们的 diff 文件对依赖进行修改。

这里我们的修改比较简单,找到我们依赖模块引入 protobuf.min.js 的地方,将其修改成 protobuf.js 即可。

🗒 后记

undefined 在压缩后就变成了 g 这个初步猜想应该是本地想要定义一个没有定义的变量,这样就是 undefined 了。我尝试克隆了下 protobuf.js 仓库进行了尝试,发现应该是 UglifyJS 中配置了 marguel.eval 导致有这个特性。

以上就是压缩造成的血案完整的排查经过,整个的过程总结一下有以下几个经验可以供大家参考:

  1. 除了单步断点,我们还有条件断点、日志断点等多种断点方式帮助我们排查问题,合理使用会加速我们排查问题的速度。
  2. 断点后当前 JS 环境会停留在当时的上下文中,我们可以在控制台执行、输出我们想要的当时环境的数据帮助排查。
  3. 控制台中我们也可以 hover 查看定义位置,进行定义间快速跳转。
  4. 压缩后的代码不可怕,我们可以通过源码对比,无法压缩的关键字进行定位查找。
  5. 只要是可以复现的问题,那都不是问题!

最后祝大家开工大吉,新的一年没有 Bug!

阅读全文 »

lizheming 发布于 01月23, 2022

清除 useEffect 副作用

在 React 组件中,我们会在 useEffect() 中执行方法,并返回一个函数用于清除它带来的副作用影响。以下是我们业务中的一个场景,该自定义 Hooks 用于每隔 2s 调用接口更新数据。

import { useState, useEffect } from 'react';

export function useFetchDataInterval(fetchData) {
  const [list, setList] = useState([]);
  useEffect(() => {
    const id = setInterval(async () => {
      const data = await fetchData();
      setList(list => list.concat(data));
    }, 2000);
    return () => clearInterval(id);
  }, [fetchData]);

  return list;
}

🐚 问题

该方法的问题在于没有考虑到 fetchData() 方法的执行时间,如果它的执行时间超过 2s 的话,那就会造成轮询任务的堆积。而且后续也有需求把这个定时时间动态化,由服务端下发间隔时间,降低服务端压力。

所以这里我们可以考虑使用 setTimeout 来替换 setInterval。由于每次都是上一次请求完成之后再设置延迟时间,确保了他们不会堆积。以下是修改后的代码。

import { useState, useEffect } from 'react';

export function useFetchDataInterval(fetchData) {
  const [list, setList] = useState([]);
  useEffect(() => {
    let id;
    async function getList() {
      const data = await fetchData();
      setList(list => list.concat(data));
      id = setTimeout(getList, 2000);
    }
    getList();
    return () => clearTimeout(id);
  }, [fetchData]);

  return list;
}

不过改成 setTimeout 之后会引来新的问题。由于下一次的 setTimeout 执行需要等待 fetchData() 完成之后才会执行。如果在 fetchData() 还没有结束的时候我们就卸载组件的话,此时 clearTimeout() 只能无意义的清除当前执行时的回调,fetchData() 后调用 getList() 创建的新的延迟回调还是会继续执行。

在线示例:CodeSandbox

可以看到在点击按钮隐藏组件之后,接口请求次数还是在继续增加着。那么要如何解决这个问题?以下提供了几种解决方案。

🌟如何解决

🐋 Promise Effect

该问题的原因是 Promise 执行过程中,无法取消后续还没有定义的 setTimeout() 导致的。所以最开始想到的就是我们不应该直接对 timeoutID 进行记录,而是应该向上记录整个逻辑的 Promise 对象。当 Promise 执行完成之后我们再清除 timeout,保证我们每次都能确切的清除掉任务。

在线示例:CodeSandbox

import { useState, useEffect } from 'react';

export function useFetchDataInterval(fetchData) {
  const [list, setList] = useState([]);
  useEffect(() => {
    let getListPromise;
    async function getList() {
      const data = await fetchData();
      setList((list) => list.concat(data));
      return setTimeout(() => {
        getListPromise = getList();
      }, 2000);
    }

    getListPromise = getList();
    return () => {
      getListPromise.then((id) => clearTimeout(id));
    };
  }, [fetchData]);
  return list;
}

🐳 AbortController

上面的方案能比较好的解决问题,但是在组件卸载的时候 Promise 任务还在执行,会造成资源的浪费。其实我们换个思路想一下,Promise 异步请求对于组件来说应该也是副作用,也是需要”清除“的。只要清除了 Promise 任务,后续的流程自然不会执行,就不会有这个问题了。

清除 Promise 目前可以利用 AbortController 来实现,我们通过在卸载回调中执行 controller.abort() 方法,最终让代码走到 Reject 逻辑中,阻止了后续的代码执行。

在线示例:CodeSandbox

import { useState, useEffect } from 'react';

function fetchDataWithAbort({ fetchData, signal }) {
  if (signal.aborted) {
    return Promise.reject("aborted");
  }
  return new Promise((resolve, reject) => {
    fetchData().then(resolve, reject);
    signal.addEventListener("aborted", () => {
      reject("aborted");
    });
  });
}
function useFetchDataInterval(fetchData) {
  const [list, setList] = useState([]);
  useEffect(() => {
    let id;
    const controller = new AbortController();
    async function getList() {
      try {
        const data = await fetchDataWithAbort({ fetchData, signal: controller.signal });
        setList(list => list.concat(data));
        id = setTimeout(getList, 2000);
      } catch(e) {
        console.error(e);
      }
    }
    getList();
    return () => {
      clearTimeout(id);
      controller.abort();
    };
  }, [fetchData]);

  return list;
}

🐬 状态标记

上面一种方案,我们的本质是让异步请求抛错,中断了后续代码的执行。那是不是我设置一个标记变量,标记是非卸载状态才执行后续的逻辑也可以呢?所以该方案应运而生。

定义了一个 unmounted 变量,如果在卸载回调中标记其为 true。在异步任务后判断如果 unmounted === true 的话就不走后续的逻辑来实现类似的效果。

在线示例:CodeSandbox

import { useState, useEffect } from 'react';

export function useFetchDataInterval(fetchData) {
  const [list, setList] = useState([]);
  useEffect(() => {
    let id;
    let unmounted;
    async function getList() {
      const data = await fetchData();
      if(unmounted) {
        return;
      }

      setList(list => list.concat(data));
      id = setTimeout(getList, 2000);
    }
    getList();
    return () => {
      unmounted = true;
      clearTimeout(id);
    }
  }, [fetchData]);

  return list;
}

🎃 后记

问题的本质是一个长时间的异步任务在过程中的时候组件卸载后如何清除后续的副作用。

这个其实不仅仅局限在本文的 Case 中,我们大家平常经常写的在 useEffect 中请求接口,返回后更新 State 的逻辑也会存在类似的问题。

只是由于在一个已卸载组件中 setState 并没有什么效果,在用户层面无感知。而且 React 会帮助我们识别该场景,如果已卸载组件再做 setState 操作的话,会有 Warning 提示。

再加上一般异步请求都比较快,所以大家也不会注意到这个问题。

所以大家还有什么其他的解决方法解决这个问题吗?欢迎评论留言~

注: 题图来自《How To Call Web APIs with the useEffect Hook in React》

阅读全文 »

lizheming 发布于 10月06, 2021

统一路由、菜单、面包屑和权限配置

我最近做的一个新项目是一个典型的中后台项目,采用的是 React + React Router + Antd 方案。正常情况下我们需要定义路由配置,在页面中定义面包屑的数据,页面写完之后需要在左侧菜单中增加页面的路由。写多了之后,我会觉得同一个路由的相关信息在不同的地方重复声明,实在是有点麻烦,为什么我们不统一在一个地方定义,然后各个使用的地方动态获取呢?

单独配置

首先我们看看每个功能单独定义是如何配置的,之后我们再总结规律整理成一份通用的配置。

路由和权限

路由我们使用了 react-router-config 进行了声明化的配置。

// router.ts
import { RouteConfig } from 'react-router-config';
import DefaultLayout from './layouts/default';
import GoodsList from './pages/goods-list';
import GoodsItem from './pages/goods-item';

export const routes: RouteConfig[] = [
  {
    component: DefaultLayout,
    routes: [
      {
        path: '/goods',
        exact: true,
        title: '商品列表',
        component: GoodsList,
      },
      {
        path: '/goods/:id',
        exact: true,
        title: '商品详情',
        component: GoodsItem,
      }
    ],
  },
];

//app.tsx
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import { routes } from './router';

export default function App() {
  return <Router>{renderRoutes(routes)}</Router>;
};

菜单

左侧导航菜单我们使用的是 <Menu /> 组件,大概的方式如下:

//./layouts/default
import React from 'react';
import { renderRoutes } from 'react-router-config';
import { Layout, Menu } from 'antd';

export default function({route}) {
  return (
    <Layout>
      <Layout.Header>
        Header
      </Layout.Header>
      <Layout>
        <Layout.Sider>
          <Menu mode="inline">
            <Menu.SubMenu title="商品管理">
              <Menu.Item key="/goods">商品列表</Menu.Item>
            </Menu.SubMenu>
          </Menu>
        </Layout.Sider>
        <Layout.Content>
          {renderRoutes(route.routes)}
        </Layout.Content>
      </Layout>
    </Layout>
  );
}

权限

这里的权限主要指的是页面的权限。我们会请求一个服务端的权限列表接口,每个页面和功能都对应一个权限点,后台配置后告知我们该用户对应的权限列表。所以我们只需要记录每个页面对应的权限点,并在进入页面的时候判断下对应的权限点在不在返回的权限列表数据中即可。

而页面权限与页面是如此相关,所以我们惯性的会将页面的权限点与页面路由配置在一块,再在页面统一的父组件中进行权限点的判断。

// router.ts
import { RouteConfig } from 'react-router-config';
import DefaultLayout from './layouts/default';
import GoodsList from './pages/goods-list';
import GoodsItem from './pages/goods-item';

export const routes: RouteConfig[] = [
  {
    component: DefaultLayout,
    routes: [
      {
        path: '/goods',
        exact: true,
        title: '商品列表',
        component: GoodsList,
        permission: 'goods',
      },
      {
        path: '/goods/:id',
        exact: true,
        title: '商品详情',
        component: GoodsItem,
        permission: 'goods-item',
      }
    ],
  },
];

// ./layouts/default
import React, { useEffect, useMemo } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { matchRoutes } from 'react-router-config';

export default function({route}) {
  const history = useHistory();
  const location = useLocation();
  const page = useMemo(() => matchRoutes(route.routes, location.pathname)?.[0]?.route, [
    location.pathname,
    route.routes,
  ]);

  useEffect(() => {
    getPermissionList().then(permissions => {
      if(page.permission && !permissions.includes(page.permission)) {
        history.push('/no-permission');
      }
    })
  }, []);
}

面包屑

面包屑则比较简单了,直接使用 <Breadcrumb /> 即可

//./pages/goods-item.tsx
import React from 'react';
import { Link } from 'react-router-dom';
import { Breadcrumb } from 'antd';

export default function() {
  return (
    <Breadcrumb>
      <Breadcrumb.Item>
        <Link to="/goods">商品列表</Link>
      </Breadcrumb.Item>
      <Breadcrumb.Item>商品详情</Breadcrumb.Item>
    </Breadcrumb>
  );
}

合并配置

通过上面的整理我们可以看到,所有的功能都是和配置相关,所有的配置都是对应路由的映射。虽然路由本身是平级的,但由于菜单和面包屑属于多级路由关系,所有我们的最终配置最好是多级嵌套,这样可以记录层级关系,生成菜单和面包屑比较方便。

最终我们定义的配置结构如下:

//router-config.ts
import type { RouterConfig } from 'react-router-config';
import GoodsList from './pages/goods-list';
import GoodsItem from './pages/goods-item';

export interface PathConfig extends RouterConfig {
  menu?: boolean;
  permission?: string;
  children?: PathConfig[];
}

export const routers = [
  {
    path: '/goods',
    exact: true,
    title: '商品列表',
    component: GoodsList,
    children: [
      {
        path: '/goods/:id',
        exact: true,
        title: '商品详情',
        component: GoodsItem
      }
    ]
  }
];

路由

基于上面的嵌套配置,我们需要定义一个 flatRouters() 方法将其进行打平,替换原来的配置即可。

//router.ts
import { RouteConfig } from 'react-router-config';
import DefaultLayout from './layouts/default';
import { routers, PathConfig } from './router-config';

function flatRouters(routers: PathConfig[]): PathConfig[] {
  const results = [];
  for (let i = 0; i < routers.length; i++) {
    const { children, ...router } = routers[i];
    results.push(router);
    if (Array.isArray(children)) {
      results.push(...routeFlat(children));
    }
  }
  return results;
}

export const routes: RouteConfig[] = [
  {
    component: DefaultLayout,
    routes: flatRouters(routers),
  },
];

菜单

菜单本身也是嵌套配置,将其正常渲染出来即可。

//./layouts/default
import React from 'react';
import { renderRoutes } from 'react-router-config';
import { Layout, Menu } from 'antd';

const NavMenu: React.FC<{}> = () => (
  <Menu mode="inline">
    {routers.filter(({ menu }) => menu).map(({ title, path, children }) => (
      Array.isArray(children) && children?.filter(({ menu }) => menu).length ? (
        <Menu.SubMenu key={path} title={title} icon={icon}>
          {children.filter(({ menu }) => menu).map(({ title, path }) => (
            <NavMenuItem key={path} title={title} path={path} />
          ))}
        </Menu.SubMenu>
      ) : (
        <NavMenuItem key={path} title={title} path={path} />
      )
    ))}
  </Menu>
);

const NavMenuItem: React.FC<{path: string, title: string}> = ({path, title}) => (
  <Menu.Item>
    {/^https?:\/\//.test(path) ? (
      <a href={path} target="_blank" rel="noreferrer noopener">{title}</a>
    ) : (
      <Link to={path}>{title}</Link>
    )}
  </Menu.Item>
);

export default function({route}) {
  return (
    <Layout>
      <Layout.Header>
        Header
      </Layout.Header>
      <Layout>
        <Layout.Sider>
          <NavMenu />
        </Layout.Sider>
        <Layout.Content>
          {renderRoutes(route.routes)}
        </Layout.Content>
      </Layout>
    </Layout>
  );
};

面包屑

面包屑的难点在于我们需要根据当前页面路由,不仅找到当前路由,还需要找到它的各种父级路由。

除了定义一个 findCrumb() 方法来查找路由之外,为了方便查找,还在配置上做了一些约定。

如果两个路由是父子关系,那么他们的路由路径也需要是包含关系。例如商品列表和商品详情是父子路由关系,商品列表的路径是 /goods,那么商品详情的路由则应该为 /goods/:id。

这样在递进匹配查找的过程中,只需要判断当前页面路由是否包含该路径即可,减小了查找的难度。

另外还有一个问题大家可能会注意到,商品详情的路由路径是 /goods/:id,由于带有命名参数,当前路由去做字符串匹配的话肯定是没办法匹配到的。所以需要对命名参数进行正则通配符化,方便做路径的匹配。

命名参数除了影响路径查找之外,还会影响面包屑的链接生成。

由于带有命名参数,我们不能在面包屑中直接使用该路径作为跳转路由。为此我们还需要写一个 stringify() 方法,通过当前路由获取到所有的参数列表,并对路径中的命名参数进行替换。

这也是为什么之前我们需要将父子路由的路径定义成包含关系。子路由在该条件下肯定会包含父级路径中所需要的参数,极大的方便我们父级路由的生成。

//src/components/breadcrumb.tsx
import React, { useMemo } from 'react';
import { Breadcrumb as OBreadcrumb, BreadcrumbProps } from 'antd';
import { useHistory, useLocation, useParams } from 'react-router';
import Routers, { PathConfig } from '../router-config';

function findCrumb(routers: PathConfig[], pathname: string): PathConfig[] {
  const ret: PathConfig[] = [];
  const router = routers.filter(({ path }) => path !== '/').find(({ path }) =>
    new RegExp(`^${path.replace(/\:[a-zA-Z]+/g, '.+?').replace(/\//g, '\\/')}`, 'i').test(pathname)
  );
  if (!router) { return ret; }

  ret.push(router);
  if (Array.isArray(router.children)) {
    ret.push(...findCrumb(router.children, pathname));
  }
  return ret;
}

function stringify(path: string, params: Record<string, string>) {
  return path.replace(/\:([a-zA-Z]+)/g, (placeholder, key) => params[key] || placeholder);
}

const Breadcrumb = React.memo<BreadcrumbProps>(props => {
  const history = useHistory();
  const params = useParams();
  const location = useLocation();

  const routers: PathConfig[] = useMemo<PathConfig[]>(
    () => findCrumb(Routers, location.pathname).slice(1), 
    [location.pathname]
  );

  if (!routers.length || routers.length < 2) {
    return null;
  }

  const data = props.data ? props.data : routers.map(({ title: name, path }, idx) => ({
    name,
    onClick: idx !== routers.length - 1 ? () => history.push(stringify(path, params)) : undefined,
  }));
  return (
    <OBreadcrumb {...props}>
      {data.map(({name, onClick}) => (
        <Breadcrumb.Item key={name}>
          <span onClick={onClick}>{name}</span>
        </Breadcrumb.Item>
      ))}
    </OBreadcrumb>
  );
});

export default Breadcrumb;

后记

至此我们的统一配置基本上就屡清楚了,我们发现只是简单的增加了几个属性,就让所有的配置统一到了一起。甚至我们可以更上一层楼,把 component 这个配置进行声明化,最终的配置如下:

//router-config.json
[
  {
    path: "/goods",
    exact: true,
    title: "商品列表",
    component: "goods-list",
    children: [
      {
        path: "/goods/:id",
        exact: true,
        title: "商品详情",
        component: "goods-item"
      }
    ]
  }
]

//router-config.tsx
import React from 'react';
import type { RouterConfig } from 'react-router-config';
import routerConfig from './router-config.json';

export interface PathConfig extends RouterConfig {
  menu?: boolean;
  permission?: string;
  children?: PathConfig[];
}

export interface PathConfigRaw extends PathConfig {
  component?: string;
  children?: PathConfigRaw[];
}

function Component(router: PathConfigRaw[]): PathConfig[] {
  return router.map(route => {
    if(route.component) {
      const LazyComponent = React.lazy(() => import(`./pages/${route.component}`));
      route.component = (
        <React.Suspense fallback="loading...">
          <LazyComponent />
        </React.Suspense>
      );
    }

    if(Array.isArray(route.children)) {
      route.children = Component(route.children);
    }

    return route;
  });
}

export const routers = Component(routerConfig);

将这些配置声明化,最大的好处是我们可以将其存储在后台配置中,通过后台菜单管理之类的功能对其进行各种管理配置。

当然这种统一配置也不一定适合所有的场景,大家还是要具体问题具体分析。比如有同事和我反馈说微前端的场景里可能就不是特别合适,不管怎么统一配置,主应用和子应用中可能都需要分别存在一些配置。主应用需要菜单,子应用需要路由,这种时候可能稍微拆分一下反而更倒是合适的。

阅读全文 »

lizheming 发布于 07月24, 2021

基于 Antd 封装业务 Upload 组件

前言

我们的后台系统都是基于 Antd Design 开发的。最近做的新系统里有比较多的场景需要使用到附件上传的功能,我们针对 Antd 的 <Upload /> 组件在项目里进行了业务的封装。过程中也碰到些问题,遂总结于本文中。

基本使用

我们主要是用到了它多文件上传和功能。

import React from 'react';
import { Upload } from 'antd';

return () => {
  const [fileList, setFileList] = useState([
    {
      uid: '1',
      name: '1.txt',
      status: 'done',
      url: 'https://www.baidu.com',
    },
  ]);

  const handleChange = info => {
    let fileList = info.fileList.slice();
    
    fileList = fileList.map(file => {
      if (file.response) {
        // Component will show file.url as link
        file.url = file.response.url;
      }
      return file;
    });

    setFileList(fileList);
  };

  return (
    <Upload
      action="https://www.mocky.io/v2/5cc8019d300000980a055e76"
      fileList={fileList}
      onChange={handleChange}
    />
  );
};

这是官方文档中提供的示例,我们可以通过 action 属性定义上传的地址,通过 onChange 获取上传后的文件地址以及 fileList 设置上传文件。其中 onChange 以及 fileList 参数类型如下。

import { RcFile as OriRcFile } from 'rc-upload/es/interface';

export interface UploadFile<T = any> {
  uid: string;
  size?: number;
  name: string;
  fileName?: string;
  lastModified?: number;
  lastModifiedDate?: Date;
  url?: string;
  status?: UploadFileStatus;
  percent?: number;
  thumbUrl?: string;
  originFileObj?: RcFile;
  response?: T;
  error?: any;
  linkProps?: any;
  type?: string;
  xhr?: T;
  preview?: string;
}

export interface RcFile extends OriRcFile {
  readonly lastModifiedDate: Date;
}

export type UploadFileStatus = 'error' | 'success' | 'done' | 'uploading' | 'removed';

export interface UploadChangeParam<T extends object = UploadFile> {
  // https://github.com/ant-design/ant-design/issues/14420
  file: T;
  fileList: UploadFile[];
  event?: { percent: number };
}

UploadFile 是主要的类型,最外层是组件包装的一些数据,包括 status, percent 等用于记录下载状态字段。其中还有 response 字段,当下载完成 status === 'done' 的时候,该字段会存储服务端返回的相应数据。

需求描述

中后台场景会有大量的表单场景,其中我们的大部分附件提交都是在表单之中。当然我们的表单也是使用的 Antd 组件。它提供了类似于原生 <form /> 的一套模式,你不需要关心表单的交互,当使用 <Form.Item name="" /> 包裹之后,就会自动认为你是表单元素,在 onFinish 事件中可以达到所有提交后的表单数据。而通过initialValues 属性又可以对整个表单设置初值。简单的通过这两个属性就可以实现表单的大多数需求。

impoprt React from 'react';
import {Form, Input, Upload} from 'antd';

export default function() {
  const initialValues = {
    remark: 'hello', 
    attachment: {
      attachmentNo: 1234,
      fileKey: 2345
    }
  };
  
  return (
    <Form onFinish={onFinish} initialValues={initialValues}>
      <Form.Item name="remark" label="说明">
        <Input.Textarea />
      </Form>
      <Form.Item name="attachment" label="附件">
        <Upload />
      </Form>
      <Button>提交</Button>
    </Form>
  );
}

这套表单的方式让上层交互变的非常纯粹,所以我期望我们封装的组件也最好能适配这套逻辑。而这里的矛盾点在于,我们需要的是 UploadFile['response']['data'] 中的数据,但是当我们要给它赋值的时候,它接收的是 UploadFile[] 的数据格式。所以除了封装业务的配置之外,还需要将数据格式转换的逻辑封装进去。

思考实现

最开始我想的设想类似于下面这个 Demo,只需要定义 uploadFile2value 和 value2UploadFile 两个方法,用于处理数据的双向转换即可。

import { Upload } from 'antd';

const Upload = React.memo(({onChange}) => (
  <Upload 
    fileList={value2UploadFile} 
    onChange={e => onChange(uploadFile2value(e.fileList))} 
  />
));

但是我没想到的是,文件上传是一个异步的过程,最终 onChange 接收到的 fileList 数据是一组多状态数据的集合,具体的状态列表如下。

export type UploadFileStatus = 'error' | 'success' | 'done' | 'uploading' | 'removed';

根据预期效果,显然我想要的是 status=done 后的数据。而如果我在 uploadFile2value 中对数据做过滤仅将 status=done 的数据返回给出去的话,在之后的渲染中 initialValues 传过来的初始数据中则不包含其它状态的数据了,会导致传入的 fileList 数据异常。这样我们就陷入了一种死循环,刚上传文件文件状态是 uploading 然后被 onChange 过滤为空数据传出,之后空数据作为初始数据再次被传入上传中的文件状态丢失组件回复到初始状态……

最终实现

后台维护组件库的小伙伴提醒了我,既然组件本身需要所有的数据,而外部只需要上传完成的数据,那我们可以考虑将所有的数据在组件内部自行维护,仅将外部组件需要的数据传递出去。当外部数据传入进来的时候,将其与内部数据做合并即可。

import React, { useEffect, useState } from 'react';
import { Upload } from 'antd';

const value2UploadFile = record => ({ 
  uid: record.id, 
  name: record.name, 
  status: 'done', 
  response: { code: 0, msg: '', data: record }
});

function useUpload(files, onChange) {
  const [filePool,setFilePool] = useState([]);

  useEffect(() => {
    if(!Array.isArray(files) || files.length === 0) {
      return;
    }

    setFilePool(filePool => {
      const fileIds = filePool.filter(({status}) => status === 'done').map(file => file.response?.data?.id);
      const appendFiles = files.filter(({id}) => !fileIds.includes(id)).map(value2UploadFile);
      return [...filePool, ...appendFiles];
    });
  }, [files]);

  const handleUploadChange = ({fileList}) => {
    fileList.filter(({status, response}) => 
      status === 'done' && response.code !== 0
    ).forEach(file => {
      file.status = 'error';
    });
    setFilePool(fileList);
    
    const doneFiles = fileList.filter(({status}) => status === 'done').map(file => file.response.data);
    onChange(doneFiles);
  }
  
  return [filePool, handleUploadChange];
} 

export default function({value, onChange, ...props}) {
  const [filePool, onFileChange] = useUpload(value, onChange);
  
  return (
    <Upload
      listType="picture"
      btnType="default"
      btnText="上传文件"
      {...props}
      
      fileList={filePool}
      onChange={handleUploadChange}
      withCredentials
      action="/api/file/upload"
    />
  );
}

可以看到我们内部增加了 filePool 的状态用来存储数据,每次内部都会全量的存储待上传的文件列表,但是最终调用外部的 onChange 方法回传出去的时候则只会传出 status=done 的数据。而针对赋值的场景,我们鉴定了 files 的变化,根据最终返回数据的 id 获取到 fileIds 内部已存在的文件,然后再使用这个和传入的数据进行 diff 比较,查看是否有新增的数据。如果存在新增的数据则将其转换成组件需要的数据格式后更新文件列表。

通过以上操作,我们就将上传组件的逻辑封装在了内部组件中。甚至我们还能在内部增加当接口返回非 0 的 code 上传失败的时候我们会将组件数据状态修改为 error 而不是 done。最终外部组件不需要关心上传接口本身内部的逻辑,只需要关系上传之后得到的数据即可,达到了业务上传组件解耦的目的。

阅读全文 »