阅读视图

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:writediscussions:writeruns-on:ubuntu-lateststeps:- uses:actions/checkout@v3- uses:lizheming/github-translate-actionenv:GITHUB_TOKEN:${{ secrets.GITHUB_TOKEN }}with:IS_MODIFY_TITLE:trueAPPEND_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」这个项目就诞生了!
  •  

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

前段时间组里的小伙伴让我帮忙排查一个线上问题,我觉得排查流程比较有意思,想着记录一下看看是否能对其它同学有所帮助,遂有此文。 事情的起因是前几天线上突然收到一个报警,错误内容是 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 导致有这个特性。 以上就是压缩造成的血案完整的排查经过,整个的过程总结一下有以下几个经验可以供大家参考: 除了单步断点,我们还有条件断点、日志断点等多种断点方式帮助我们排查问题,合理使用会加速我们排查问题的速度。 断点后当前 JS 环境会停留在当时的上下文中,我们可以在控制台执行、输出我们想要的当时环境的数据帮助排查。 控制台中我们也可以 hover 查看定义位置,进行定义间快速跳转。 压缩后的代码不可怕,我们可以通过源码对比,无法压缩的关键字进行定位查找。 只要是可以复现的问题,那都不是问题! 最后祝大家开工大吉,新的一年没有 Bug!
  •  

你不知道的前端新特性

有些你不知道是正常的……因为他们基本都没怎么被浏览器实现 🥶 CSS Toggles https://tabatkins.github.io/css-toggle/ 纯 CSS 实现状态切换一般使用 Checkbox 或者 Radio 配合选择器来实现。实现起来麻烦不说,而且 CheckBox/Radio 的位置限制了你可控制的范围,用起来很不方便。 iframe { width: 100%; height: 300px; border: 1px solid #EFEFEF; } 交互中越来越多依赖状态,比如 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> 参考资料: The Future of CSS: CSS Toggles 有哪些以往需要使用javascript实现的功能现在可以直接使用html或者css实现? - 知乎 Scope Proposal & Explainer Proposal for CSS @when | CSS-Tricks JS 深拷贝的原生终结者 structuredClone API - 掘金 https://developer.chrome.com/docs/web-platform/navigation-api/ URLPattern brings routing to the web platform https://web.dev/learn/design/picture-element/
  •  

清除 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 可以看到在点击按钮隐藏组件之后,接口请求次数还是在继续增加着。那么要如何解决这个问题?以下提供了几种解决方案。 🌟如何解决 { async function getList() { id = setTimeout(async () = { const data = await fetchData(); setList(list = list.concat(data)); getList(); }, 2000); } getList(); return () = clearTimeout(id); }); return list; } ``` -- 🐋 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》
  •  

如何制作 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
  •  

豆瓣书影音同步 GitHub Action

2023-07-12 更新:《关于豆瓣图片无法直接使用的说明》 简介 doumark-action 是我前段时间造的一个轮子。它是一款 GitHub Action,支持在 GitHub 中同步你的豆瓣书影音数据到本地的文件或者 Notion 中。我利用它,定时同步我的豆瓣观影数据到我的博客仓库中,并利用 Hugo 读取文件数据渲染成页面,观影 是最终的效果。 使用 使用其实很简单,在你的博客仓库中新建 .github/workflows/douban.yml 文件,以观影为例添加如下内容。它实现了每小时自动抓取你的豆瓣观影记录并更新到文件中,如果发现文件有更新则触发 commit 提交。 name:doubanon:schedule:- cron:"30 * * * *"jobs:douban:name:Douban mark data syncruns-on:ubuntu-lateststeps:- name:Checkoutuses:actions/checkout@v2- name:movieuses:lizheming/doumark-action@masterwith:id:lizhemingtype:movieformat:csvdir:./douban- name:Commituses:EndBug/add-and-commit@v8with:message: 'chore:update douban data'add:'./douban'该 workflow 总共分为三步,第一步初始化 Git 仓库;第二步调用 doumark-action 同步豆瓣账号 lizheming 的 movie 类型数据到 ./douban 文件夹下,并保存为 csv 格式文件;最后一步则是当 ./douban 文件夹下有更新则调用插件提交修改。 Notion 如果是要同步到 Notion 中会稍微复杂一点。需要先准备好 Notion Token 并初始化好页面。 我们可以在 My Integrations 里创建机器人得到 NOTION_TOKEN。 电影 | 阅读 | 音乐 基于这三个模板点击右上角的 Duplicate 按钮渲染复制页面。 复制后的页面右上角选择右上角的 Share - Invite 将第一步创建的机器人加入,这样机器人就有权限更新你的页面数据。 # .github/workflows/douban.ymlname:doubanon:schedule:- cron:"30 * * * *"jobs:douban:name:Douban mark data syncruns-on:ubuntu-lateststeps:- name:movieuses:lizheming/doumark-action@masterwith:id:lizhemingtype:movieformat:notiondir:xxxxnotion_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」这个项目就诞生了!
  •  

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 但是原理都差不太多。不过目前这种程度我也能接受了,就没有再继续尝试下去,之后有空再参考优化一下。 《本博客零散优化点汇总》 《使用 Cloudflare Workers 加速 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,加载速度已经令我比较满意了。之后我再抽空处理下整站的图片资源。
  •  

Hugo 主题 Eureka 自定义

今天有网友邮件我咨询我现在的主题 Eureka 的一些自定义配置,他想参考一下。由于我的博客仓库是私有的,所以就写一篇文章简单整理一下。 Eureka 是前段时间群友推荐给我的,纯白的朴素风格同时提供了暗色模式瞬间我就喜欢上了。将其 clone 到 Hugo 博客目录 themes/hugo-eureka 下,config.toml 中配置 theme = "hugo-eureka" 即可使用上该款主题。为了方便主题的更新,我将我所有自定义的模板都放在了 layouts 目录下。Hugo 会将主题目录和 layouts 目录下的文件进行合并,并优先使用 layouts 目录中的同名文件。这样之后我只需要单纯的更新 thtmes/hugo-eureka 目录即可。 首页 相较于 Eureka 主题的默认首页,我个人还是比较喜欢传统博客的两栏布局,左侧显示模块列表,右侧显示文章列表,所以我需要自定义首页模板。拷贝以下内容创建 layouts/index.html 文件即可实现同款。 {{ define "main" }} <div class="pl-scrollbar"> <div class="w-full max-w-screen-xl lg:px-4 xl:px-8 mx-auto"> <div class="max-w-screen-xl mx-auto" style="padding-top: 3rem"> <div class="bg-local bg-cover"> <img class="day" src="/banner-day.png" /> <img class="dark" src="/banner.png" /> </div> </div> <!-- <article class="mx-6 my-7"> <h1 class="font-bold text-3xl text-primary-text"></h1> </article> --> <div class="grid grid-cols-2 lg:grid-cols-8 gap-4 lg:pt-12"> <div class="col-span-2 sidebar"> <div class="widget bg-secondary-bg rounded p-6"> <h2 class="widget-title">最新文章</h2> <ul class="widget-list"> {{- $recent := default 5 .Site.Params.numberOfRecentPosts }} {{- $posts := where (where .Site.RegularPages "Permalink" "!=" .Permalink) "Type" "in" .Site.Params.mainSections }} {{- range first $recent $posts }} <li> <a href="{{ .RelPermalink }}" class="nav-link">{{ .Title }}</a> </li> {{- end }} </ul> </div> <div class="widget bg-secondary-bg rounded p-6"> {{ $walineURL := .Site.Params.comment.waline.serverURL }} <h2 class="widget-title ">最近回复</h2> <ul class="widget-list recentcomments"> {{ $resp := getJSON $walineURL "/comment?type=recent&count=10" }} {{ range first 10 $resp }} <li class="recentcomments"> <a href="{{.Site.BaseURL}}{{ .url }}">{{ .nick }}</a>:{{ .comment | safeHTML | truncate 22 }} </li> {{ end }} </ul> </div> <div class="widget bg-secondary-bg rounded p-6"> <h2 class="widget-title">友情链接</h2> <ul class="widget-list"> {{ range .Site.Menus.friends }} <li> <a href="{{ .URL }}">{{ .Name }}</a> </li> {{ end }} </ul> </div> <div class="widget bg-secondary-bg rounded p-6"> <h2 class="widget-title">管理</h2> <ul class="widget-list"> <li> <a href="/admin">🛠 后台管理</a> </li> <li> <a href="{{ .Site.Params.comment.waline.serverURL }}/ui">💬 评论管理</a> </li> </ul> </div> </div> <div class="col-span-2 lg:col-span-6 bg-secondary-bg rounded px-6 py-8"> <div class="bg-secondary-bg rounded overflow-hidden px-4 divide-y"> {{ range .Paginator.Pages }} <div class="px-2 py-6"> {{ partial "components/summary-plain.html" . }} </div> {{ end }} </div> {{ template "_internal/pagination.html" . }} </div> </div> </div> </div> {{ end }} 其中顶部还增加了一组暗色模式切换的横幅图片,添加以下 CSS 内容至 layouts/partials/custom-head.html 文件中,不存在的话需要新建。 .widget + .widget { margin-top: 1rem; } .widget-title { font-weight: bold; margin-bottom: 1rem; } .widget-list li { font-size: 0.9rem; } .bg-cover img { opacity: 1; transition: all .5s ease-in-out; } .bg-cover img.dark { opacity: 0; height: 0; } .dark .bg-cover img.day { opacity: 0; height: 0; } .dark .bg-cover img.dark { opacity: 1; height: auto; } 左侧的模块中,评论是使用了本人自研的 Waline 评论系统并进行了一定的改造,具体可参见我之前的文章《静态博客如何高性能插入评论》。当然也可以直接使用 Waline 自带的最近评论挂件。 友情链接则是在 config.toml 中按照如下格式进行配置的。 [[menu.friends]] name = "童欧巴博客" url = "https://hungryturbo.com/" weight = 20 [[menu.friends]] identifier = "QingXu" name = "QingXu" url = "https://blog.qingxu.live" weight = 19 [[menu.friends]] identifier = "蜘蛛抱蛋" name = "蜘蛛抱蛋" url = "https://blog.zzbd.org/" weight = 18 后台管理则是使用了 forestry 提供的服务,它支持提供在线后台进行文章、页面和其它配置的管理。评论管理则是链接到了 Waline 服务的后台面板中。 Metadata Eureka 主题的文章 metadata 显示分为列表页和详情页两个,分别对应 post_metadata.html 和 post_metadata_full.html 两个文件。我们在 layouts/partials/ 目录下新建这两个文件用来覆盖主题默认的文件。 2021-03-13 更新: 更新后的 Eureka 统一使用了 components/post-metadata.html 显示文章的 metadata,代码和之前的 layouts/partials/post_metadata.html 是一致的。 {{/* layouts/partials/components/post_metadata.html */}} <div class="flex flex-wrap flex-row items-center my-2 text-tertiary-text"> <div class="mr-6 my-2"> <i class="fas fa-calendar mr-1"></i> <span>{{ .Date.Format (.Site.Params.dateFormat | default "2006-01-02") }}</span> </div> {{- $slug := printf "/%s.html" .Slug}} {{- $commentsData := (partialCached "utils/get-comments.html" .)}} {{- $comments := slice }} {{- range where $commentsData "url" "==" $slug}} {{$comments = $comments | append .}} {{- end}} {{- $count := len $comments}} <div class="mr-6 my-2"> <a href="{{ .Permalink }}#waline-comments" title="{{ .Title }}"> <i class="fas fa-comment mr-1"></i> <span>{{- if gt $count 0}}{{$count}} 条评论{{else}}暂无评论{{end -}}</span> </a> </div> <div class="mr-6 my-2"> <i class="fas fa-clock mr-1"></i> <span>{{ i18n "readingTime" . }}</span> </div> {{ with .GetTerms "categories" }} <div class="mr-6 my-2"> <i class="fas fa-folder mr-1"></i> {{ range $index, $value := . }} {{ if gt $index 0 }} <span>, </span> {{ end -}} <a href="{{ .Permalink }}" class="hover:text-eureka">{{ .LinkTitle }}</a> {{ end }} </div> {{ end }} {{ with .GetTerms "series" }} <div class="mr-6 my-2"> <i class="fas fa-th-list mr-1"></i> {{ range $index, $value := . }} {{ if gt $index 0 }} <span>, </span> {{ end -}} <a href="{{ .Permalink }}" class="hover:text-eureka">{{ .LinkTitle }}</a> {{ end }} </div> {{ end }} </div> post_metadata.html 主要是增加了评论条数的显示,而 post_metadata_full.html 中还增加了 Markdown 原文链接的显示。关于如何生成 Markdown 原文链接,可以参考我之前的文章《Hugo 之旅》。 {{/* layouts/partials/post_metadata_full.html */}} <div class="flex flex-wrap flex-row items-center my-2 text-tertiary-text"> <div class="mr-6 my-2"> <i class="fas fa-calendar mr-1"></i> <span>{{ .Date.Format (.Site.Params.dateFormat | default "2006-01-02") }}</span> </div> {{$resp := getJSON "https://imerd.comment.lithub.cc/comment?type=count&url=https://imnerd.org/" .Slug ".html" }} <div class="mr-6 my-2"> <a href="{{ .Permalink }}#waline-comments" title="{{ .Title }}"> <i class="fas fa-comment mr-1"></i> <span>{{- if gt $resp 0}}{{$resp}} 条评论{{else}}暂无评论{{end -}}</span> </a> </div> {{ if eq .Type "posts" -}} {{ with .OutputFormats.Get "MarkDown" -}} <div class="mr-6 my-2"> <a href="{{ .Permalink }}"> <i class="fas fa-book mr-1"></i> <span>阅读Markdown格式</span> </a> </div> {{- end }} {{ end }} <div class="mr-6 my-2"> <a href="{{ .Permalink }}"> <i class="fas fa-pen mr-1"></i> <span>{{ .WordCount }} 字</span> </a> </div> <div class="mr-6 my-2"> <i class="fas fa-clock mr-1"></i> <span>{{ i18n "readingTime" . }}</span> </div> {{ with .GetTerms "categories" }} <div class="mr-6 my-2"> <i class="fas fa-folder mr-1"></i> {{ range $index, $value := . }} {{ if gt $index 0 }} <span>, </span> {{ end -}} <a href="{{ .Permalink }}" class="hover:text-eureka">{{ .LinkTitle }}</a> {{ end }} </div> {{ end }} {{ with .GetTerms "series" }} <div class="mr-6 my-2"> <i class="fas fa-th-list mr-1"></i> {{ range $index, $value := . }} {{ if gt $index 0 }} <span>, </span> {{ end -}} <a href="{{ .Permalink }}" class="hover:text-eureka">{{ .LinkTitle }}</a> {{ end }} </div> {{ end }} </div> 搜索框 搜索也是博客比较重要的功能,为了方便我在顶部增加了搜索框。创建 layouts/partials/header.html 文件用来覆盖默认的头部模板。 {{/* layouts/partials/header.html */}} <script> let storageColorScheme = localStorage.getItem("lightDarkMode") {{- if eq .Site.Params.colorScheme "light" }} if ((storageColorScheme == 'Auto' && window.matchMedia("(prefers-color-scheme: dark)").matches) || storageColorScheme == "Dark") { document.getElementsByTagName('html')[0].classList.add('dark') } {{- else if eq .Site.Params.colorScheme "dark" }} if ((storageColorScheme == 'Auto' && window.matchMedia("(prefers-color-scheme: light)").matches) || storageColorScheme == "Light") { document.getElementsByTagName('html')[0].classList.remove('dark') } {{- else }} if (((storageColorScheme == 'Auto' || storageColorScheme == null) && window.matchMedia("(prefers-color-scheme: dark)").matches) || storageColorScheme == "Dark") { document.getElementsByTagName('html')[0].classList.add('dark') } {{- end }} </script> <nav class="flex items-center justify-between flex-wrap px-4 py-4 md:py-0"> <a href="{{ "/" | relLangURL }}" class="mr-6 text-primary-text text-xl font-bold">{{ .Site.Title }}</a> <button id="navbar-btn" class="md:hidden flex items-center px-3 py-2" aria-label="Open Navbar"> <i class="fas fa-bars"></i> </button> <div id="target" class="hidden block md:flex md:flex-grow md:justify-between md:items-center w-full md:w-auto text-primary-text z-20"> <div class="md:flex md:h-16 text-sm md:flex-grow pb-4 md:pb-0 border-b md:border-b-0"> {{- $relPermalink := .RelPermalink }} {{- range .Site.Menus.main }} {{- $url := .URL | relLangURL }} <a href="{{ $url }}" class="block mt-4 md:inline-block md:mt-0 md:h-(16-4px) md:leading-(16-4px) box-border md:border-t-2 md:border-b-2 {{ if hasPrefix $relPermalink $url }} selected-menu-item {{ else }} border-transparent {{ end }} mr-4">{{ .Name }}</a> {{- end }} </div> <div class="flex"> <div class="search-container relative pt-4 md:pt-0"> <div class="search"> <form role="search" class="search-form" action="/search.html" method="get"> <label> <input name="q" type="text" placeholder="搜索 ..." class="search-field"> </label> <button> <i class="fas fa-search"></i> </button> </form> </div> </div> <div class="relative pl-4 pt-4 md:pt-0"> <div class="cursor-pointer hover:text-eureka" id="lightDarkMode"> {{- if eq .Site.Params.colorScheme "dark" }} <i class="fas fa-moon"></i> {{- else if eq .Site.Params.colorScheme "light" }} <i class="fas fa-sun"></i> {{- else }} <i class="fas fa-adjust"></i> {{- end }} </div> <div class="fixed hidden inset-0 opacity-0 h-full w-full cursor-default z-30" id="is-open"> </div> <div class="absolute flex flex-col left-0 md:left-auto right-auto md:right-0 hidden bg-secondary-bg w-48 rounded py-2 border border-tertiary-bg cursor-pointer z-40" id='lightDarkOptions'> <span class="px-4 py-1 hover:text-eureka" name="Light">{{i18n "light"}}</span> <span class="px-4 py-1 hover:text-eureka" name="Dark">{{i18n "dark"}}</span> <span class="px-4 py-1 hover:text-eureka" name="Auto">{{i18n "auto"}}</span> </div> </div> {{- if .IsTranslated }} <div class="relative pt-4 pl-4 md:pt-0"> <div class="cursor-pointer hover:text-eureka" id="languageMode"> <i class="fas fa-globe"></i> <span class="pl-1">{{ .Language.LanguageName }}</span> </div> <div class="fixed hidden inset-0 opacity-0 h-full w-full cursor-default z-30" id="is-open-lang"> </div> <div class="absolute flex flex-col left-0 md:left-auto right-auto md:right-0 hidden bg-secondary-bg w-48 rounded py-2 border border-tertiary-bg cursor-pointer z-40" id='languageOptions'> <a class="px-4 py-1 hover:text-eureka" href="{{ .Permalink }}">{{ .Language.LanguageName }}</a> {{- range .Translations }} <a class="px-4 py-1 hover:text-eureka" href="{{ .Permalink }}">{{ .Language.LanguageName }}</a> {{- end }} </div> </div> {{- end }} </div> </div> <div class="fixed hidden inset-0 opacity-0 h-full w-full cursor-default z-0" id="is-open-mobile"> </div> </nav> <script> let element = document.getElementById('lightDarkMode') {{- if eq .Site.Params.colorScheme "light" }} if (storageColorScheme == 'Auto') { element.firstElementChild.classList.remove('fa-sun') element.firstElementChild.setAttribute("data-icon", 'adjust') element.firstElementChild.classList.add('fa-adjust') document.addEventListener('DOMContentLoaded', () => { switchMode('Auto') }) } else if (storageColorScheme == "Dark") { element.firstElementChild.classList.remove('fa-sun') element.firstElementChild.setAttribute("data-icon", 'moon') element.firstElementChild.classList.add('fa-moon') } {{- else if eq .Site.Params.colorScheme "dark" }} if (storageColorScheme == 'Auto') { element.firstElementChild.classList.remove('fa-moon') element.firstElementChild.setAttribute("data-icon", 'adjust') element.firstElementChild.classList.add('fa-adjust') document.addEventListener('DOMContentLoaded', () => { switchMode('Auto') }) } else if (storageColorScheme == "Light") { element.firstElementChild.classList.remove('fa-moon') element.firstElementChild.setAttribute("data-icon", 'sun') element.firstElementChild.classList.add('fa-sun') } {{- else }} if (storageColorScheme == null || storageColorScheme == 'Auto') { document.addEventListener('DOMContentLoaded', () => { switchMode('Auto') }) } else if (storageColorScheme == "Light") { element.firstElementChild.classList.remove('fa-adjust') element.firstElementChild.setAttribute("data-icon", 'sun') element.firstElementChild.classList.add('fa-sun') } else if (storageColorScheme == "Dark") { element.firstElementChild.classList.remove('fa-adjust') element.firstElementChild.setAttribute("data-icon", 'moon') element.firstElementChild.classList.add('fa-moon') } {{- end }} document.addEventListener('DOMContentLoaded', () => { getcolorscheme(); switchBurger(); {{- if .IsTranslated }} switchLanguage() {{- end }} }); </script> 大部分的内容都是 Eureka 主题提供的,除了增加了 #search-container 搜索框部分。为了让搜索框更美观一点,我在 layouts/partials/custom-head.html 中自定义了一些样式。 .search-container { margin-top: -0.3rem; } .search-container .search { border: 1px solid #e2e8f0; border-radius: 4px; } .search-container input { padding-left: 1rem; line-height: 2rem; outline: none; background: transparent; } .search-container button { font-size: 0.8rem; margin-right: 0.5rem; color: #e2e8f0; } 最终搜索框跳转至单独的搜索页面。关于如何给 Hugo 博客添加搜索功能,可查看我之前的文章 《Hugo 之旅》。我这边提供一下我的搜索结果页模板。 {{/* layouts/_default/search.html */}} {{ define "main" }} <div class="w-full max-w-screen-xl lg:px-4 xl:px-8 mx-auto"> <article class="mx-6 my-8"> <h1 id="search-count" class="font-bold text-3xl text-primary-text"></h1> </article> <div id="search-result" class="bg-secondary-bg rounded overflow-hidden px-4 divide-y"> </div> </div> <script src="https://cdnjs.cloudflare.com/ajax/libs/fuse.js/3.2.0/fuse.min.js"></script> <script> document.addEventListener('DOMContentLoaded', async () => { const qs = new URLSearchParams(location.search); const searchResult = document.querySelector('#search-result'); const searchCount = document.querySelector('#search-count'); const fuseOptions = { shouldSort: true, includeMatches: true, threshold: 0.0, tokenize: true, location: 0, distance: 100, maxPatternLength: 32, minMatchCharLength: 1, keys: [{ name: "title", weight: 0.8 }, { name: "summary", weight: 0.5 }, { name: "tags", weight: 0.3 }, { name: "date", weight: 0.3 }, ] }; let fuse = null async function getFuse() { if (fuse == null) { const resp = await fetch('/index.json', { method: 'get' }) const indexData = await resp.json() fuse = new Fuse(indexData, fuseOptions); } return fuse } function render(items) { console.log(items); return items.map(item => { item = item.item return ` <div class="px-2 py-6"> <div class="flex flex-col-reverse lg:flex-row justify-between"> <div class="w-full lg:w-2/3"> <div class="my-2"> <div class="mb-4"> <a href="${item.permalink}" class="font-bold text-xl hover:text-eureka">${item.title}</a> </div> <div class="content"> ${item.summary}<p class="more"> <a href="${item.permalink}" title="${item.title}">阅读全文<span class="meta-nav">→</span></a> </p> </div> </div> <div class="flex flex-wrap flex-row items-center my-2 text-tertiary-text"> <div class="mr-6 my-2"> <i class="fas fa-calendar mr-1"></i> <span>${item.date}</span> </div> <div class="mr-6 my-2"> <a href="${item.permalink}#waline-comments" title="${item.title}"> <i class="fas fa-comment mr-1"></i> <span>${item.comments > 0 ? item.comments + ' 条评论' : '暂无评论'}</span> </a> </div> <div class="mr-6 my-2"> <i class="fas fa-clock mr-1"></i> <span>${item.time}分钟阅读时长</span> </div> </div> </div> <div class="w-full lg:w-1/3 mb-4 lg:mb-0 lg:ml-8"> ${item.featuredImage ? `<img src="${item.featuredImage}" class="w-full" alt="Featured Image">` : ''}</div> </div> </div>`; }).join(''); } function updateDOM(html, keyword, number) { document.title = document.title.replace(/包含关键词.*?文章/, `包含关键词 ${keyword}的文章`) searchResult.innerHTML = html searchCount.innerHTML = `共查询到 ${number}篇文章` } async function search(searchString) { console.log(searchString); let result = []; if(searchString) { const fuse = await getFuse() result = fuse.search(searchString) } const html = render(result) updateDOM(html, searchString, result.length) } document.querySelectorAll('input[name="q"]').forEach(el => el.value = qs.get('q')); search(qs.get('q') || '') window.blogSearch = function(keyword) { if(!keyword) { return; } history.pushState('', '', location.pathname + '?q=' + encodeURIComponent(keyword)); document.querySelectorAll('input[name="q"]').forEach(el => el.value = keyword); search(keyword); } }) </script> {{ end }} 归档 之前使用 Typecho 的时候有一个归档插件会按照年月列表展示文章,所以我在 Hugo 中按照之前的格式实现了一下。按照如下内容新建 layouts/_default/archive.html 文件,并新建文章 content/日志.md,文章内容为空即可,在文章的 meta 数据中指定 layout: archive 来映射到该模板。 {{/* layouts/_default/archive.html */}} {{ define "main" }} {{ $hasToc := and (in .TableOfContents "<li>" ) (.Params.toc) }} {{ $hasSidebar := or ($hasToc) (.Params.series) }} <div class="grid grid-cols-2 lg:grid-cols-8 gap-4 lg:pt-12"> <div class="col-span-2 {{ if not $hasSidebar }} {{- print "lg:col-start-2" -}} {{ end }} lg:col-span-6 bg-secondary-bg rounded px-6 py-8"> <h1 class="font-bold text-3xl text-primary-text">{{ .Title }}</h1> <div class="flex flex-wrap flex-row items-center my-2 text-tertiary-text"> <div class="mr-6 my-2"> <i class="fas fa-calendar mr-1"></i> <span>{{ .Date.Format (.Site.Params.dateFormat | default "2006-01-02") }}</span> </div> </div> {{ $featured := partial "utils/get-featured" . }} {{ with $featured }} <div class="my-4"> {{ . }} </div> {{ end }} <div class="content"> <script type='text/javascript' src="https://lib.baomitu.com/jquery/1.11.1/jquery.min.js"></script> <style type="text/css">.car-collapse .car-yearmonth { cursor: s-resize; } </style> <script type="text/javascript"> /* <![CDATA[ */ jQuery(document).ready(function() { jQuery('.car-collapse').find('.car-monthlisting').hide(); jQuery('.car-collapse').find('.car-monthlisting:first').show(); jQuery('.car-collapse').find('.car-yearmonth').click(function() { jQuery(this).next('ul').slideToggle('fast'); }); jQuery('.car-collapse').find('.car-toggler').click(function() { if ( '展开全部' == jQuery(this).text() ) { jQuery(this).parent('.car-container').find('.car-monthlisting').show(); jQuery(this).text('折叠全部'); } else { jQuery(this).parent('.car-container').find('.car-monthlisting').hide(); jQuery(this).text('展开全部'); } return false; }); }); /* ]]> */ </script> <div class="car-container car-collapse"> <a href="#" class="car-toggler">展开全部</a> <ul class="car-list"> {{ range (.Site.RegularPages.GroupByDate "01月 2006") }} <li> <span class="car-yearmonth">{{ .Key }} <span title="Post Count">({{ len .Pages }})</span></span> <ul class="car-monthlisting"> {{ range .Pages }} <li> {{ .Date.Format "02"}}: <a href="{{ .Permalink }}">{{ .Title }} </a> <!--<span title="Comment Count">(0)</span>--> </li> {{ end }} </ul> </li> {{ end }} </ul> </div> </div> </div> </div> {{ end }} 统计 屈屈的博客中还有一个统计页面,我觉得挺有意思的,于是也在我的博客中复刻了一下。按照如下内容新建 layouts/_default/stats.html 文件,并新建文章 content/统计.md,文章内容为空即可,在文章的 meta 数据中指定 layout: stats 来映射到该模板。 {{/* layouts/_default/stats.html */}} {{ define "main" }} {{- $.Scratch.Add "stats" slice -}} {{- range .Site.RegularPages -}} {{- $.Scratch.Add "stats" (dict "title" .Title "slug" .Slug "year" (.Date.Format "2006") "month" (.Date.Format "2006-01") "hour" (.Date.Format "15") "week" (.Date.Format "Monday") "count" .WordCount) -}} {{- end -}} {{ $hasToc := and (in .TableOfContents "<li>" ) (.Params.toc) }} {{ $hasSidebar := or ($hasToc) (.Params.series) }} <style> .chart { margin-top: 15px; width: 100%; height: 350px; } </style> <div class="grid grid-cols-2 lg:grid-cols-8 gap-4 lg:pt-12"> <div class="col-span-2 {{ if not $hasSidebar }} {{- print "lg:col-start-2" -}} {{ end }} lg:col-span-6 bg-secondary-bg rounded px-6 py-8"> <h1 class="font-bold text-3xl text-primary-text">{{ .Title }}</h1> <div class="flex flex-wrap flex-row items-center my-2 text-tertiary-text"> <div class="mr-6 my-2"> <i class="fas fa-calendar mr-1"></i> <span>{{ .Date.Format (.Site.Params.dateFormat | default "2006-01-02") }}</span> </div> </div> {{ $featured := partial "utils/get-featured" . }} {{ with $featured }} <div class="my-4"> {{ . }} </div> {{ end }} <div class="content"> {{ .Content }} </div> </div> {{ if $hasSidebar}} <div class="col-span-2"> {{ if .GetTerms "series" }} {{ partial "components/post-series.html" . }} {{ end }} {{ if $hasToc }} {{ partial "components/post-toc.html" . }} {{ end }} </div> {{ end }} </div> <script src="https://lib.baomitu.com/echarts/5.0.0/echarts.min.js"></script> <script> const data = {{- $.Scratch.Get "stats" -}}; function showChart(id, title, type, d) { var chart = echarts.init(document.getElementById(id)); var xData = []; var yData = []; d.forEach(function(item) { xData.push(item[0]); yData.push(item[1]); }); var option = { title : { text : title }, tooltip : { trigger : 'axis' }, xAxis : [ { type : 'category', data : xData } ], yAxis : [ { type : 'value' } ], grid : { x : 35, y : 45, x2 : 35, y2 : 35 }, series : [ { type : 'bar', name : type, data : yData, markLine : { data : [ { type : 'average', name : '平均值' }], itemStyle : { normal : { color : '#4087bd' } } }, itemStyle : { normal : { color : '#87cefa' } } }] }; chart.setOption(option); } window.addEventListener('load', function() { basicInfo(); yearStats(); monthStats(); hourStats(); weekStats(); }); function basicInfo() { const articles = {{ len (where .Site.RegularPages "Section" "posts") }}; const pages = data.length - articles; const comments = data.reduce((count, article) => count + article.comments, 0); const words = data.reduce((count, article) => count + article.count, 0); document.querySelector('#basic-info').innerHTML = ` <span>文章:<strong><a href="/">${articles}</a></strong> 篇</span>;<span>页面:<strong><a href="/">${pages}</a></strong> 篇</span>;<span>总字数:<strong>${words}</strong></span>; `; }; function yearStats() { const yearGroup = {}; data.forEach(article => { const year = parseInt(article.year); if(!yearGroup.hasOwnProperty(year)) { yearGroup[year] = 0; } yearGroup[year] += 1; }); const d = []; for(let i = 2009; i <= (new Date().getFullYear()); i++) { d.push([i, yearGroup[i] || 0]); } showChart('year-stat', '文章数 - 按年统计', '文章数', d); } function monthStats() { const monthGroup = {}; data.forEach(article => { if(!monthGroup.hasOwnProperty(article.month)) { monthGroup[article.month] = 0; } monthGroup[article.month] += 1; }); const d = []; for(let year = 2009; year <= (new Date().getFullYear()); year++) { for(let month = 1; month < 13; month++) { const text = `${year}-${month < 10 ? '0' + month : month}`; d.push([text, monthGroup[text] || 0]); } } showChart('month-stat', '文章数 - 按月统计', '文章数', d); } function hourStats() { const hourGroup = {}; data.forEach(article => { const hour = parseInt(article.hour); if(!hourGroup.hasOwnProperty(hour)) { hourGroup[hour] = 0; } hourGroup[hour] += 1; }); const d = [ ['00:00-01:00'], ['01:00-02:00'], ['02:00-03:00'], ['03:00-04:00'], ['04:00-05:00'], ['05:00-06:00'], ['06:00-07:00'], ['07:00-08:00'], ['08:00-09:00'], ['09:00-10:00'], ['10:00-11:00'], ['11:00-12:00'], ['12:00-13:00'], ['13:00-14:00'], ['14:00-15:00'], ['15:00-16:00'], ['16:00-17:00'], ['17:00-18:00'], ['18:00-19:00'], ['19:00-20:00'], ['20:00-21:00'], ['21:00-22:00'], ['22:00-23:00'], ['23:00-24:00'] ].map((item, key) => { item[1] = hourGroup[key] || 0; return item; }); showChart('hour-stat', '文章数 - 按时段统计', '文章数', d); } function weekStats() { const weekGroup = {}; data.forEach(article => { if(!weekGroup.hasOwnProperty(article.week)) { weekGroup[article.week] = 0; } weekGroup[article.week] += 1; }); const d = [ ['星期一', weekGroup.Monday], ['星期二', weekGroup.Tuesday], ['星期三', weekGroup.Wednesday], ['星期四', weekGroup.Thursday], ['星期五', weekGroup.Friday], ['星期六', weekGroup.Saturday], ['星期日', weekGroup.Sunday] ]; showChart('weekday-stat', '文章数 - 按星期几统计', '文章数', d); } </script> {{ end }} 其它 除了以上这些,我的博客中改动最大的当属评论这块,但这块定制型比较高,一般玩家就不推荐了,感兴趣的还是去看我之前的《静态博客如何高性能插入评论》一文。除此之外,我还修改了 footer.html 修改了底部显示文案,增加了网页统计脚本;基于自研的 wxhermit 增加了微信分享自定义相关功能;文章页目录底部增加了个人公众号的展示。由于这些内容都比较简单且定制化内容程度比较高,就不一一展示了,感兴趣的朋友可以自行查看源码查阅。
  •  

一道面试题让你更加了解事件队列

今天在群里聊天,突然有人放出了一道面试题。经过群里一番讨论,最终解题思路慢慢完善起来,我这里就整理一下群内解题的思路。 该题定义了一个同步函数对传入的数组进行遍历乘二操作,同时每执行一次就会给 executeCount 累加。最终我们需要实现一个 batcher 函数,使用其对该同步函数包装后,实现每次调用依旧返回预期的二倍结果,同时还需要保证 executeCount 执行次数为1。 let executeCount = 0 const fn = nums => { executeCount++ return nums.map(x => x * 2) } const batcher = f => { // todo 实现 batcher 函数 } const batchedFn = batcher(fn); const main = async () => { const [r1, r2, r3] = await Promise.all([ batchedFn([1,2,3]), batchedFn([4,5]), batchedFn([7,8,9]) ]); //满足以下 test case assert(r1).tobe([2, 4, 6]) assert(r2).tobe([8, 10]) assert(r3).tobe([14, 16, 18]) assert(executeCount).tobe(1) } 抖机灵解法 拿到题目的第一时间,我就想到了抖机灵的方法。直接面向用例编程,执行完之后重置下 executeCount 就好了。 const batcher = f => { return nums => { try { return f(nums) } finally { executeCount = 1 } } } 当然除非你不在乎这次面试,否则一般不建议你用这种抖机灵的方法回答面试官(不要问我为什么知道)。由于 executeCount 的值和 fn() 函数的调用次数呈正相关,所以这道理也就换成了我们需要实现 batcher() 方法返回新的包装函数,该函数会被调用多次,但最终只会执行一次 fn() 函数。 setTimeout 解法 由于题干中使用了 Promise.all(),我们自然而然想到使用异步去解决。也就是每次调用的时候会把所以的传参存下来,直到最后的时候再执行 fn() 返回对应的结果。问题在于什么时候触发开始执行呢?自然而然我们想到了类似 debounce 的方式使用 setTimeout 增加延迟时间。 const batcher = f => { let nums = []; const p = new Promise(resolve => setTimeout(_ => resolve(f(nums)), 100)); return arr => { let start = nums.length; nums = nums.concat(arr); let end = nums.length; return p.then(ret => ret.slice(start, end)); }; }; 这里的难点在于预先定义了一个 Promise 在 100ms 之后才会 resolve。返回的函数本质只是将参数推入到 nums 数组中,待 100ms 后触发 resolve 返回统一执行 fn() 后的结果并获取对应于当前调用的结果片段。 后来有群友反馈,实际上不用定义 100ms 直接 0ms 也是可以的。由于 setTimeout 是在 UI 渲染结束之后才会执行的宏任务,所以理论上来说 setTimeout() 的最小间隔值无法设置为 0。它的最小值和浏览器的刷新频率有关系,根据 MDN 描述,它的最小值一般为 4ms。所以理论上它设置 0ms 和 100ms 效果是差不多的,都类似于 debounce 的效果。 Promise 解法 那么如何能实现延迟 0ms 执行呢?我们知道除了宏任务之外 JS 还有微任务,微任务队列是在 JS 主线程执行完成之后立即执行的事件队列。Promise 的回调就会存储在微任务队列中。于是我们将 setTimeout 修改成了 Promise.resolve(),最终发现也是可以实现同样的效果。 const batcher = f => { let nums = []; const p = Promise.resolve().then(_ => f(nums)); return arr => { let start = nums.length; nums = nums.concat(arr); let end = nums.length; return p.then(ret => ret.slice(start, end)); }; }; 由于 Promise 的微任务队列效果将 _ => f(nums) 推入微任务队列,待主线程的三次 batcherFn() 调用都执行完成之后才会执行。之后 p 的状态变为 fulfilled 后继续完成最终 slice 的操作。 **2020-03-17:**感谢 @kricsleo 帮忙指出由于存在副作用多次调用会存在问题,并提供了优化版本。 const batcher = (f) => { let nums = []; let p; return (nums) => { if(!p) { p = Promise.resolve().then(_ => f(nums)); } const start = nums.length; nums = nums.concat(arr); const end = nums.length; return p.then(ret => { nums = []; p = null; return ret.slice(start, end); }); }; }; 后记 最终分析下来,其实这道理的本质就是要通过某些方法将 fn() 函数的执行后置到主线程执行完毕,至于是使用宏任务还是微任务队列,就看具体的需求了。除了 setTimeout() 之外,还有 setInterval(), requestAnimationFrame() 都是宏任务队列。而微任务队列里除了有 Promise 之外,还有 MutationObserver。关于宏任务和微任务队列相关的,感兴趣的可以看看《微任务、宏任务与Event-Loop》这篇文章。
  •  

不用备案也能支持微信自定义分享

我们知道,在微信中打开网页,使用右上角的 ... 分享给朋友/朋友圈,是可以使用 JS SDK 自定义分享卡片文案的。为了让分享内容能够更好的受到监管,从早期会自动读取网页内第一张大图到后期使用 JS SDK 自定义分享,再到后期需要做域名绑定关联,自定义分享卡片内容的流程变的越来越复杂。 目前如果你的网站想要增加微信自定义分享文案的支持,需要准备以下两件事情: 确保你的网站域名已备案,并被添加到了一个已认证的公众号的“JS安全域名”中。 提供服务端支持,用于与微信交互获取 access token 和 jsapi ticket 并计算获得最终的 signature,用于在前端调用微信 JS SDK 时进行校验。 具体的流程可以参见微信开放文档。可以看到要想实现自定义分享文案,除了开发流程之外,你还需要域名备案和公众号认证,这两个做过的人肯定知道会有多头疼了。而最蛋疼的是,我的博客域名后缀 .org 目前是不支持备案的,难道就没有办法了吗? wxhermit 为了能让未备案网站也能自定义分享文案,我开发了 wxhermit 这个项目。它的原理非常简单,基本就是在已备案域名下 <iframe> 嵌套展示未备案域名,并通过 postMessage 通信,将自定义分享的文案传递到父页面。最终实现了任意网站分享自定义的需求。 当然它的本质还是使用基于最开始的备案域名网站进行分享。由于微信分享需要提供已认证公众号并绑定已备案域名,一个已认证公众号只能绑定至多 5 个安全域名,条件颇为苛刻。针对 5 个以上的域名,部分域名无法备案的情况,要自定义微信分享的文案就非常麻烦。本方案比较好的迂回解决该问题。 如何使用 在服务端使用 Docker 启动服务。其中 WECHAT_ID 和 WECHAT_SECRET 是在微信公众号后台开发-基本配置中获取的“开发者ID”和“开发者密码”。而 ALLOW_HOST_LIST 是为了避免服务被滥用,允许开发者配置允许使用内嵌服务的网站。可以使用逗号拼接多个域名,例如 imnerd.org,eming.li。不在该列表中的域名会直接跳转会源地址。 docker run \ -e WECHAT_ID=<WECHAT_ID> \ -e WECHAT_SECRET=<WECHAT_SECRET> \ -e ALLOW_HOST_LIST=<ALLOW_HOST_LIST> \ -p 8360:8360 lizheming/wxhermit 而对于需要使用该服务的网站,需要在页面中增加以下代码用于自定义分享文案。其中 wxhermit 是固定值,其它的为自定义文案内容。 <script> if (window.parent !== window) { window.parent.postMessage({ type: 'wxhermit', title: '自定义分享的标题', desc: '自定义分享的描述', imgUrl: '自定义分享的封面图' }, '*'); } </script> 配置好后就可以在微信使用 <domain>/?url=<url> 来访问了,其中 <domain> 是你的已绑定的安全域名,<url> 则是在 ALLOW_HOST_LIST 中配置的可使用域名下的网址。 后记 通过代理的形式很好的解决了我未备案域名需要自定义分享的问题。为了能让体验更自然,我在我的网站中增加了在微信中自动跳转至该嵌套页面的逻辑。 <script> if(/micromessenger/i.test(navigator.userAgent) && window.parent === window) { location.href = 'https://wechat.75.team/?url=' + encodeURIComponent(location.href); } </script> 不过它的缺点也很明显,本质相当于将所有的域名都挂靠在某个安全域名之下。所以在微信下拉显示网站地址的时候都还是显示该安全域名。而且子域如果出现内容问题的话风险也全部在该安全域名上,所以建议是 ALLOW_HOST_LIST 配置个人可控域名。 除了我的这种方案之外,也有配置 <meta> 信息通过 Safari 调用系统的分享功能设置封面图和文案的方式,以及通过 QQ 浏览器分享自动获取页面第一张大图的形式自定义分享卡片。不过它们在可定制和确定性上都要稍微弱一些,可以根据实际情况选择使用。
  •  

SameSite 那些事

在《Web 安全漏洞之 CSRF》中我们了解到,CSRF 的本质实际上是利用了 Cookie 会自动在请求中携带的特性,诱使用户在第三方站点发起请求的行为。除了文中说的一些解决方式之外,标准还专门为 Cookie 增加了 SameSite 属性,用来规避该问题。Chrome 于 2015 年 6 月支持了该属性,Firefox 和 Safari 紧随其后也增加了支持。SameSite 属性有以下几个值: SameSite=None:无论是否跨站都会发送 Cookie SameSite=Lax:允许部分第三方请求携带 Cookie SameSite=Strict:仅允许同站请求携带 Cookie,即当前网页 URL 与请求目标 URL 完全一致 该属性适合所有在网页下的请求,包括但不限于网页中的 JS 脚本、图片、iframe、接口等页面内的请求。可以看到 None 是最宽松的,和之前的行为无异。而 Lax 和 Strict 都针对跨站的情况下做了限制。其中 Strict 最为严格,不允许任何跨站情况下携带该 Cookie。Lax 则相对宽松一点,允许了一些显式跳转后的 GET 行为携带。以下是一个带有 SameSite 属性的标准 Cookie 响应示例: Set-Cookie: name=lizheming; SameSite=None; Secure 需要注意的是,浏览器做了仅针对 HTTPS 域名才支持 SameSite=None 配置。所以如果你要设置 SameSite=None 的话,则必须还要携带 Secure 属性才行。 Same Site Same Site 直译过来就是同站,它和我们之前说的同域 Same Origin 是不同的。两者的区别主要在于判断的标准是不一样的。一个 URL 主要有以下几个部分组成: 可以看到同域的判断比较严格,需要 protocol, hostname, port 三部分完全一致。相对而言,Cookie 中的同站判断就比较宽松,主要是根据 Mozilla 维护的公共后缀表(Pulic Suffix List)使用有效顶级域名(eTLD)+1的规则查找得到的一级域名是否相同来判断是否是同站请求。 例如 .org 是在 PSL 中记录的有效顶级域名,imnerd.org 则是一级域名。所以 https://blog.imnerd.org 和 https://www.imnerd.org 是同站域名。而 .github.io 也是在 PSL 中记录的有效顶级域名,所以 https://lizheming.github.io 和 https://blog.github.io 得到的一级域名是不一样的,他们两个是跨域请求。 在类似 GitHub/GitLab Pages, Netlify, Vercel 这种提供子域名给用户建站的第三方服务中,eTLD 的这种同站判断特性往往非常有用。通过将原本是一级域的域名添加到 eTLD 列表中,从而让浏览器认为配有用户名的完整域名才是一级域,有效解决了不同用户站点的 Cookie 共享的问题。 eTLD eTLD 的全称是 effective Top-Level Domain,它与我们往常理解的 Top-Level Domain 顶级域名有所区别。eTLD 记录在之前提到的 PSL 文件中。而 TLD 也有一个记录的列表,那就是 Root Zone Database。RZD 中记录了所有的根域列表,其中不乏一些奇奇怪怪五花八门的后缀。 eTLD 的出现主要是为了解决 .com.cn, .com.hk, .co.jp 这种看起来像是一级域名的但其实需要作为顶级域名存在的场景。这里还可以分享一个有趣的事情,2020年5月份出现了一起阿里云所有 ac.cn 后缀网站解析全部挂掉的事件。原因就是 ac.cn 是中科院申请在册的 eTLD 域名。而阿里云的检测域名备案的脚本不了解规范,没有使用 PSL 列表去查找一级域名,而是使用了.分割的形式去查找的。最终所有 *.ac.cn 的域名由于 ac.cn 这个域名没有进行备案导致解析全部挂掉。而我们现在知道 ac.cn 这个域名是 eTLD 域名,它肯定是无法备案的。 Schemeful Same Site 在 Chrome 86/Firefox 79 中,浏览器增加了一个 Schemeful Same Site 的选项,将协议也增加到了 Same Site 的判断规则中。但是并不是完全的不等判断,可以理解是否有 SSL 的区别。例如 http:// 和 https:// 跨站,但 wss:// 和 https:// 则是同站,ws:// 和 http:/ 也算是同站。 Chrome 可以浏览器输入 chrome://flags/#schemeful-same-site 找到配置并开启。 Lax 我们知道互联网广告通过在固定域 Cookie 下标记用户 ID,记录用户的行为从何达到精准推荐的目的。随着全球隐私问题的整治,同时也是为了更好的规避 CSRF 问题,在 Chrome 80 中浏览器将默认的 SameSite 规则从 SameSite=None 修改为 SameSite=Lax。设置成 SameSite=Lax 之后页面内所有跨站情况下的资源请求都不会携带 Cookie。由于不会为跨站请求携带 Cookie,所以 CSRF 的跨站攻击也无从谈起,广告商也无法固定用户的 ID 来记录行为。 对用户来说这肯定是一件好事。但是对我们技术同学来说,这无疑是上游给我们设置的一个障碍。因为业务也确实会存在着多个域名的情况,并且需要在这些域名中进行 Cookie 传递。例如多站点使用 SSO 登录、接入统一的验证码服务、前端和服务端接口属于两个域名等等情况,都会因为这个修改受到影响。 这个修改影响面广泛,需要网站维护者花大量的时间去修改适配。而 Chrome 80 于 2020 年 2 月发布后全球就开始面临新冠疫情的影响。考虑到疫情问题后续的版本里又暂时先回退了这个特性(相关链接),最终是在 Chrome 86 进行了全量操作。 针对因为此次特性受到影响的网站,可以选择以下一些适配办法: 使用 JWT 等其它非 Cookie 的通信方式 为 Cookie 增加 SameSite=None;Secure 属性配置 所有的跨域接口增加 Nginx 代理,使其和页面保持同域 每一种方法都需要一些取舍。第一种更换 Cookie 的方式改造成本非常高,特别是在有外部业务对接的情况下基本不可能。第三种方式通过将跨域变为同域的转发方式可能会带来线上流量的成倍增加,也是需要考虑的因素。第二种设置成 None 看起来是比较简单的办法,不过也有着诸多的限制。 SameSite=None;Secure 由于仅支持 HTTPS 页面,所以如果有 HTTP 的场景需要考虑跳转至 HTTPS 或者选择其他方案; 由于 SameSite 属性是后来才加入的,一些老浏览器(其实就是 IE)会忽略带有这些属性的 Cookie,所以需要同时下发未配置 SameSite 属性和配置 SameSite 属性的两条 Set-Cookie 响应头,这样支持和不支持的会各取所需; 在 Safari 的某些版本中会将 SamteSite=None 等同于 SameSite=Strict 所以部分 Safari 场景需要特殊处理不进行下发(相关链接); 综上使用代理转发的方式是我比较推荐的方式,除了不那么绿色之外兼容问题处理还是不错的。 SameParty SameSite=Lax 断了我们跨站传递 Cookie 的念想,但实际业务上确实有这种场景。例如 Google 自己就有非常多的域名,这些域名如果都需要共享登录 Cookie 的话可能就会非常困难了。针对这种某个实体拥有多个域名需要共享 Cookie 的情况,就有人(那其实就是 Google 的同学)提出了 SameParty 的概念。 该提案提出了 SameParty 新的 Cookie 属性,当标记了这个属性的 Cookie 可以在同一个主域下进行共享。那如何定义不同的域名属于同一主域呢?主要是依赖了另外一个特性 first-party-set 第一方集合。它规定在每个域名下的该 URL /.well-known/first-party-set 可以返回一个第一方域名的配置文件。在这个文件中你可以定义当前域名从属于哪个第一方域名,该第一方域名下有哪些成员域名等配置。 当然使用固定 URL 会产生额外的请求,对页面的响应造成影响。也可以直接使用 Sec-First-Party-Set 响应头直接指定归属的第一方域名。 不过 W3C TAG 小组已经强烈拒绝了该提案(来源)。W3C 认为该提案重新定义了网站沙箱的边界,带来的影响可能不仅仅只是 Cookie 共享这么简单,包括麦克风、摄像头、地理信息等隐私设置都需要去重新评估影响。 同时该提案可能会和用户的预期不一致,如果 Google 和 Youtube 被定义成第一方网站进行共享的话,那 Google 就能很轻松的获取到用户在 Youtube 上的行为,可能用户并不想要这样。 W3C TAG 小组全称是 Technical Architecture Group,即 W3C 技术架构组。TAG 是 W3C 专注于 Web 架构管理的特殊小组。其使命是为 Web 架构的设计原则寻求共识,且在必要时梳理并澄清这些设计原则,帮助协调 W3C 内部及外部跨越不同技术的架构定义与研发工作。基本可以认为它是 Web 基础规范定义的小组。另外万维网之父 Tim Berners-Lee 也在 TAG 小组中。 不过 W3C 说的有理没理,都阻挡不了 Chrome 去实现这个功能。在 Chrome 89 中已经增加了 SameParty 的相关逻辑,只是目前没有默认开启。目前在 DevTools 中是可以看到 Cookie 的 SameParty 属性列的。Edge 由于使用了 Chromium 也在同版本支持了该功能。只掌管了规范,没有掌管实现,当某一方浏览器实现了“霸权”的情况下,W3C 的处境就变得尴尬了起来。 FLoC SameSite 除了影响单实体多域名共享 Cookie 的情况,最大的问题其实就是互联网广告获取用户行为了。由于广告挂载页面和广告不在同域,所以广告无法获得用于标记用户 ID 从而对用户行为进行聚类。为了解决这个问题,有人(其实也是 Google 的同学)提出了 Federated Learning of Cohorts 同盟学习队列提案。 有别于之前使用 Cookie ID 标记直接将用户行为数据传递到广告商网站处理的方式。它提出了 document.interestCohort() 这个新的 API,将用户的行为在本地转换成了不带个人隐私的关键词,既规避了用户隐私问题,同时又解决了广告的精准投放问题。 不过这看似美好的东西却遭到了各大网站和浏览器的强力抵制,brav、Vivaldi、duckduckgo、GitHub 以及 Edge,Firefox,Safari(来源)都纷纷发表了拒绝支持的观点和行动。 社区主要的担心点在于,新的特性的增加可能会增加特征值为隐私嗅探提供了更广阔的入口。而且通过该 API 能获取到之前碍于权限无法程序获取的用户浏览数据。目前 Chrome 已经支持了这个功能,不过需要开启 Flag 才能支持。amIFLoCed 是一个用来检测你的浏览器是否开启了 FLoC 追踪特性的网站,可以使用它检测你的浏览器是否应用该特性。 后记 为了解决 CSRF 问题,Chrome 强推了 SameSite=Lax 作为默认配置。随之而来的,不仅是全球开发者的配合修改,还造成了已有场景的无法满足。而为了满足现有场景,又提出了 SameParty 和 FLoC 两个方案。这种行为不知能否成为浏览器的内卷行为? SameSite 属性本身是没有什么问题的,但个人认为它应该是一种 CSRF 问题的选择方案,浏览器将其默认修改成 SameSite=Lax 就有点难受了。大部分企业项目里都已经采用其他 CSRF 防范方式规避了该问题,而 Lax 配置又存在着兼容性问题,不能让我们完全免顾 CSRF 之忧。 随着全球隐私问题的白热化,不知道还有什么新的提案搞出来需要我们全球开发者为其买单。 参考资料: 封面图来源 《SameSite cookies explained》 《SameSite cookie recipes》 《Schemeful Same-Site》 《Understanding “same-site” and “same-origin”》 https://www.chromestatus.com/feature/5088147346030592 https://www.chromium.org/updates/same-site https://hacks.mozilla.org/2020/08/changes-to-samesite-cookie-behavior/
  •  

基于 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。最终外部组件不需要关心上传接口本身内部的逻辑,只需要关系上传之后得到的数据即可,达到了业务上传组件解耦的目的。
  •  

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

我最近做的一个新项目是一个典型的中后台项目,采用的是 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); 将这些配置声明化,最大的好处是我们可以将其存储在后台配置中,通过后台菜单管理之类的功能对其进行各种管理配置。 当然这种统一配置也不一定适合所有的场景,大家还是要具体问题具体分析。比如有同事和我反馈说微前端的场景里可能就不是特别合适,不管怎么统一配置,主应用和子应用中可能都需要分别存在一些配置。主应用需要菜单,子应用需要路由,这种时候可能稍微拆分一下反而更倒是合适的。
  •  

南京行之游后感

前言 清明来了一次说走就走的旅行,去南京溜达了一圈。具体的每日见闻游记可见之前的几篇文章: 南京行-Day 1 南京行-Day 2 南京行-Day 3 虽然过程有喜有乐,不过从行程上来看准备确实做的不充足,吃了很多亏。好在我们也是比较佛系的人,景点进不去那就不打卡了吧,人多排队那就换别家吧。这趟出行虽然有囧事,有累点,不过整体来看还是不错的。 旅游 这次出门获得的宝贵经验,那就是出门旅行之前务必需要先搜索一下当地的景点是否需要预约,是否支持网上购票,提前做好准备避免到了景区发现无法进入的尴尬。 这里列举一下我掌握的南京的一些旅游景点的相关讯息,如果大家有要去南京玩的可参考一二: 南京大屠杀纪念馆不需要门票,但是需要提前预约,使用微信公众号”侵华日军南京大屠杀遇难同胞纪念馆“可以查看预约情况并预约。 总统府需要门票,可以使用携程等网上购票,具体链接可查看总统府官网: http://www.njztf.cn/business/index.sh。 玄武湖公园不需要门票,可以直接在携程上预约。 雨花台烈士陵园不需要门票,需要在携程等平台上提前预约。预约的时候有两个选项,分别是陵园预约和陵园+纪念馆套票预约。纪念馆还是值得一看的,第一次去建议选择套票预约。 吃住 我们住的地方是南京机场宾馆夫子庙店,设备比较新,房间内东西齐全,周边环境也不错。交通比较方便,离这些景区都不算太远。价格也还算 OK,最重要的是二楼就有南京当地的淮扬菜连锁店小厨娘,吃饭极其方便。除了隔音效果一般之外,给我的体验非常不错。推荐给有需要的同学。 这次说老实话也没有特地去吃一些经典的菜,毕竟说实话大部分品类南京大牌档都有了,而且品种多都能吃到。盐水鸭确实还不错,鸭子做的不膻不腻肉质紧实多汁。另外鸭血粉丝汤里面的鸭肠可真是好吃呀~ 出行 南京水杉很多,所以看起来树木都很笔直高大,看起来很养眼。比起其他地方,南京路上的红绿灯极其的多,这点可能和台湾类似,基本是没两步路就有斑马线和红绿灯配套。这个对行人比较友好,对开车的人就不知道是否方便了。 南京的路不算宽,路上的车辆也比较多,堵车情况从这几天来看似乎比起北京不逞多让,再加上在修地铁有些路口更是雪上加霜。到了景区附近更是车子动不动不了。 不过可能因为我们住的离去的地方都不远的缘故,打车还挺省钱的。小伙伴用美团打车基本上都只要几块钱就能搞定了,真是惊呆我了。
  •  

南京行-Day 3

今天是行程的最后一天,我们放弃了之前找的攻略路线,准备把之前两天没有逛到的总统府和雨花台补完下。这两个在小伙伴的努力下都已经提前预约了,应该是不能再出什么幺蛾子了。 例行早起收了一波蚂蚁森林的能量之后把行李收拾好和小伙伴一块把房退了。不过因为我们还要出去玩,行李就暂时先寄存在宾馆了。吸取了昨天总统府旁边的早餐店排队盛况的教训,我们决定在宾馆附近吃完早餐再出发。附近找了一家看着还算干净的早餐店,豪放的狂点了一番后发现总价只有昨天早饭的一半,而且还比它更好吃,惹得小伙伴们纷纷对我们今天的机智点赞! 二战总统府 打车去总统府,沿途依旧是熟悉的风景。可能是因为我们来的稍微晚了点,今天排队买票的人没有昨天那么多了。不过我们也没有功夫去称赞自己的机智了,我们昨天预定的时间是8:30-10:30,到地方一看就已经9:45了,赶紧汇入进去的人群。好在突然又开放了一个入口分流,让我们能按时进入景区,成功打卡南京行第二个景区。 注:人不要太多的总统府 本来小伙伴想着这种人文景点,豪放的请一个人工讲解会更有意思一点,奈何天不遂人愿,去咨询台一看人工讲解已然售罄。无奈的我们悄咪咪的尾随了一位讲解听了半路,有些讲解听起来着实有些意思。令我印象深刻的是太平天国的一幅画,洪秀全的妹妹站在比洪秀全更高处,表示了当时太平天国男女平等的观念,这个在那个年代还真是难得。 注:总统府天下为公匾额 参观完前院就到了蒋介石办公的地方了,这块人多地方窄,所有人比肩继踵的往前艰难行走着。上楼梯的时候前面的人踢到后面人的膝盖是在正常不过了,听小伙伴说甚至有小孩摔倒了一度造成了交通的堵塞。其实就是老式的办公室,说实在的没什么可看的,也不知为何吸引了这么多人。 出来后就是一片花园。看着花园的白色围墙,想着昨天只能在墙外面眼巴巴的望着游园的人群心生羡慕的我们,再次感慨提前做攻略的重要性。花园的风景真是不错,成荫的绿树和似锦繁花相得益彰。中间还参观了一些国父孙中山的纪念馆,发现他居然只做了三个月的大总统,但是却给我们带来了巨大的影响,不由心生佩服。 注:革命尚未成功,同志仍需努力 不得不说总统府真的很大,后面的后花园之前是乾隆下江南的行宫,每栋建筑都古色古香。特别是春天各种鲜花争群夺艳,让这三十元的门票花的真值。参观完继续遵循昨天的不要在景区旁吃饭定律,我们找了些稍微远一丢丢的地方,可能是对距离没有把控好,结果也是都要等位。一气之下我们想着干脆吃火锅吧,最后定了距离不太远的马路边边串串香。步行过去的时候发现路边的风景再次熟悉起来,原来定位的地方竟然就是昨天我们沿着珍珠河溜达去总统府路过的地方!真是造化弄人。 注:好看的绣球 据小伙伴说这家马路边边似乎少了很多经典菜色,不过他们家特有的糖蒜牛肉获得了我们的一致好评。酒足饭饱过后,我们打车朝雨花台出发了。路上司机师傅和我们一路吐槽旅游景点,比如夫子庙的南京特色小吃都是安徽人开的,南京只有小笼包压根没有汤包之类的,让我们大吃一惊。 雨花台烈士陵园 雨花台烈士陵园恢弘大气,我们没有完全逛完,驻足比较久的是雨花台烈士纪念馆。里面陈列了大量的烈士事迹,包括我们在《觉醒年代》中看到的邓中夏。每位烈士的事迹都一一驻足瞻仰后,一个多小时已经过去了。大部分人都是在二十几岁人生最美好的年华中去世的,有些甚至只有十几岁,看照片都只是孩子模样。他们基本都是集中在几个时间过世的,有些可能是因为同一事件被抓,有些则是因为同一事件被处决。看到那些年纪轻轻就英勇就义的烈士,有种说不清道不明的感觉油然而生,或疑惑,或震撼,或感慨,或哀叹。 注:雨花台烈士陵园 纪念馆的尽头有个漆黑的小房间,里面陈列了一些烈士相关的物品,扫描他们的二维码可以查看对应的烈士事迹。特别的是每个小物品都使用火焰灯光照射着,通过漆黑的镜面效果将这几簇火焰生生演化出燎原之势,观感效果极好,同时也非常具有寓意。 注:只有9簇火焰的燎原效果 雨花台烈士陵园又非常肃穆庄严。花了一个多小时浏览完纪念馆,我们来到了纪念碑前。小伙伴买了朵小菊花代表我们的心意在纪念碑前为烈士献上了鲜花。看着时间也差不多要离开了,草草的游览了一下就离开了雨花台了。 返程 打车到宾馆拿了行李之后还有一点时间,就在附近找了个生煎包店解决晚饭。路上还碰到个叫”甜星“的好利来李鬼,本来以为只是个小面包店,结果为了防止火车上饿着买了点面包发现小票上居然印着苏州好利来的字样。怪不得在店里看到了好利来的半熟芝士,这就说得通了。回来一搜发现原来好利来苏州的店都改名叫甜星了,其它地方的也纷纷都改成当地的品牌名了。 打了出租车去车站,结果快到站计程车的表跳了一下。师傅应该是跳表钱看的,和我说 25。我看了下表疑惑的和他说:”不是 26 么?“真是老实人碰到老实人,分外尴尬。上车后还用手机写了一会游记,奈何太累了开始还是小憩,后来就沉沉的睡过去了。 到北京后已经是晚上十一点半了,如何回家顿时成为了难题。地铁停运只剩下增开的 4 号线,出租车早已人多车少车站工作人员已经不推荐排队等候了。无奈的我们只能想着出站走一段看看能不能打到车,结果滴滴打车等位都已经超过 200 位了。 出来车站没多远就有一些黑车在等着载客,随口问了下说是要 150 还得拼车,看这坐地起价的口气就不想坐。往前走了段我鬼使神差的建议在一个十字路口逆着人流方向走了一段,碰到一趟公车,想着人走估计是走不出这片拥堵区了,干脆坐公车坐几站出去了再打车吧。结果好家伙也不知是不是运气好,公车还真就能到住的地方附近。不过晚上太冷,最后一公里不太好解决,遂还是按照之前的思路尝试在后几站的公车站打车。 每过一个站我就重新更新下出发地点为后几站,就这么尝试了七八次之后,在我都已经不抱希望的情况下,居然被我打到车了!当时激动的心,颤抖的手,喜悦的心情估计周边人都能感受到了。而且非常幸运的是到站没多久师傅也到了,时间完美匹配!经过这么一个多小时的折腾,总算是顺利到家了。至此清明南京之行顺利结束!
  •  

南京行-Day 2

总统府 本以为昨晚吃一堑长一智,详细的查看了路线攻略,发现没有需要预约才能参观的地点后能开开心心的开启新的一天。没想到我们还是太低估了国内旅游的困难程度。早上把小伙伴催促起来之后已然过了九点,匆忙打了个车就去了本日的第一站—总统府。来了之后发现本日第一囧,那就是排队买票的人实在是太多了,我们是万万没戏了。 注:酒店风景 想着反正也排不上了,遂大众点评上找了一家旁边的早餐店准备慢悠悠的吃个早饭去下个景点。结果去了店里发现这排队的人也非常多,果断换了旁边一家人不太多的吃了鸭血粉丝汤和汤包,不过质量嘛景区的东西你懂的。吃饭的时候顺手查了一下总统府能否在网上购票,发现居然可以!不过遗憾的是只能定明天的票了,不过我们还是先买了想着明天再过来。 注:鸭血粉丝汤 南京大学 费了半天劲打车来到我们的第二站南京大学后发生了本日的第二囧,南大可能因为疫情的原因目前暂不开放!无奈的我们打卡了下不算好看的校门,在附近溜达了一圈后就灰溜溜的跑去我们的第三站古鸡鸣寺了。 注:槽点满满的南京大学校门 古鸡鸣寺 到了古鸡鸣寺发现人更是多,看到门口的人山人海我们就果断放弃了参观的想法。不过因为人实在是太多了,已经需要一堆城管采用人墙策略划分人流维护秩序了。好巧不巧小伙伴又很想上厕所,而上厕所的标识指向了古鸡鸣寺的路口处。结果小伙伴就不小心被挤入到了入寺的人群中,最后好不容易让城管帮忙给放出来了,这算是本日的第三囧了。万般无奈之下就去了就近的地铁站进站上了个厕所,结果反馈地铁站的厕所居然也是要排队的,真是令人崩溃! 注:人山人海的古鸡鸣寺入口 南京大牌档 一连打卡三个景点都没有去成让我们的兴致不是非常高,临近中午了就想着干脆找一家饭馆好好吃一顿排解一下。怀着好奇心想试试南京的南京大牌档会不会特别点,于是找了一家附近的南京大牌档准备溜达过去。沿着珍珠河我们一边溜达一边拍照,走着走着就发现路边的风景慢慢熟悉了起来。原来我定位的这个南京大牌档居然就在总统府的旁边啊!囧上加囧的事情是,店里排队的人可不比去古鸡鸣寺的人要少,只能果断放弃了。 江宴楼 询问了一遍附近的饭馆发现居然都需要等位之后,我们想着是不是打个车逃离总统府景区就可以正常吃上饭了?于是一番讨论后大众点评上找了家评分比较高且不需要等位的江宴楼。去了之后才发现和我平常吃饭的地方档次高了一个数量级,服(菜)务(品)也特别的周(昂)到(贵)。 注:好吃的江宴楼 夫子庙 在包间我们边吃边聊间解决了随意点的一些菜品,感觉我们又复活了!看到古鸡鸣寺那么多人之后,我们放弃了下一站位于古鸡鸣寺旁边的玄武湖公园,直奔夫子庙秦淮河景区了。吃饭的地方离夫子庙倒也不是很远,就想着溜达溜达消消食。夫子庙旁边的步行街到处都充满了熙熙攘攘的人群。按照导航的定位,我们跑到了夫子庙的出口处买了票并成功进入。进去之后发现原来夫子庙真的就是说的孔夫子和学业相关的事情。这才明白过来为什么去的路上听到有人说”我一个山东人跑南京来参观夫子庙?“这种问句了。 作为我们今日成功打卡的第一个景区,也是我们南京之行成功打卡的第一个景区,我们还是非常认真的游览了一遍。里面按照时间先后展览着一些和学生、学习、考试等相关的藏品。其中最有意思的当属于古代人在袜子上用小字撰写的小抄,怕不是和现在的小抄有着异曲同工之妙。除了这些之外还有很多明国时期的毕业证书、课本教材、护照等有意思的文字材料。夫子庙算是已经很成熟的商业景区了,内部的商业气息非常浓重,付费撞钟祝好运,付费挂许愿树、付费扔币许愿、付费平安福等等活动充斥着景区。 注:古代小抄 参观完后我们从景区的入口处出来,发现来夫子庙的人真的很多,只是我们是从出口处进来的。夫子庙入口前面就是秦淮河,上面有秦淮河画舫可以120(白)/140(夜)元坐船的活动,想着晚上坐船可能有灯光会更好看点于是就先过了。路上有用铜片手动压制铭牌的机器,给小伙伴买了一个。想着景区里打车不方便就慢慢走出了景区,结果发现了我们第五件囧事,我们居然沿着之前路过的时候大家疯狂吐槽的”为什么会有很多人从小区门口出来“的地方回到了我们去夫子庙的原点处!实打实的沿着夫子庙走了一圈,真是欲哭无泪。 二战小厨娘 经历了两个轮回圈之后我们已然是精疲力尽,赶紧打了个车回酒店歇息了。稍作整顿后我去了小伙伴那继续补习《觉醒年代》了。晚上则还是在昨天的小厨娘,甚至还是昨天的那个位置吃的。我们点了松鼠桂鱼和烤鸭,想着试试南京的烤鸭和北京的烤鸭到底有什么区别。吃完后本来我们也没有觉得烤鸭有多好吃,但是因为服务员和我们说我们的烤鸭是最后半只,且吃饭过程中一直听到服务员和顾客说烤鸭早就卖完了,莫名的给这烤鸭增加了一颗星。 注:小厨娘的烤鸭 夜游东水关遗址 吃完沿着宾馆旁边的城墙溜达了一圈,走到城墙尽头上去看了下,发现旁边的秦淮河里也有画舫在游河,发现真要去划船着实是没什么可以看的,画舫游船一事也就作罢。本意是想出来溜达找个水果店买些水果回去窝着看剧的。逛了一圈大家又都有写疲了,就在宾馆旁边的超市买了袋牛奶回宾馆歇息去了。 注:夜游东水关
  •  

南京行-Day 1

清明来了一次说走就走的旅行,突然之间决定和小伙伴一块去南京当回游客。本来我是想出去转转定了上海的,后来想想上海似乎也没有需要待好几天的必要,就在小伙伴的建议下选择了附近的南京。 我和小伙伴花了一个晚上的时间定好了酒店和出行时间,第二天早上用智行买的火车票。其中回程的车票因为只有一等座了就暂时先买了,后来智行还很温馨的提示我们有二等座车票了是否需要更换,果断更换后省了一晚酒店钱,同时也让我第一次体验到了智行这种第三方买票软件的优势。 出发 早早收拾好就出门赶地铁去了,想着给小伙伴带新口味的鸡蛋汉堡还路过了一下超市,结果也不知是起晚还是放假休息的缘故,鸡蛋汉堡的老板居然没有来上班,美好的愿望就这么落了个空。只能路上买了个煎饼留了一半给他们吃。 地铁上出行的人还是很多的,看来清明旅游区的人流量应该不会少。进车站后发现车展特地为清明出行开启了快速进站厅,上火车顿时方便了不少。上车后就在我焦急等待小伙伴的时候,他们总算赶着发车的尾巴上来了。上车后发现隔壁两个小孩在车上写作业,小伙伴也发表了他要带工作旅游的艰辛感慨,而我只能感谢他辛苦百忙之中抽空来陪我们旅游了。 注:火车上努力写作业的小朋友 许久不见分外想念,一路上有的没的聊着聊着就到地方了。当我们一边带着疑惑”不是不用扫健康码了吗“一边制作了苏州健康码后,我们碰到了第一个囧事。为了找出租车,我们居然按照火车站的指示牌绕着南站转了大半圈,也是不得不感慨造物主的脑回路。到了宾馆办好入住之后进屋发现居然还挺不错,东西应有尽有,设施也很整洁(不过隔音一般)。特别还碰到了宾馆服务员给房客发放清明节小礼物,顿时好感上升了不少。 注:路边盛开的景观花 由于已过中饭点,赶紧找了附近一家好评还不错的饭店小厨娘吃饭。它是南京一家连锁店,这家分店也是很神奇,居然开在了宾馆的二楼。我也是到了一楼之后才发现的,只能傻不愣登的赶紧跑二楼去了。为了节约用餐时间,趁着小伙伴还没到的时间我先把菜点了。菜口味还不错,就是忘记点招牌的盐水鸭了。而且糯米类的食物比较多,吃完已然是撑得不行了。 注:逛商场看到的可爱童装 南京大屠杀纪念馆 经过几个人轮番努力,总算打上了车去南京大屠杀纪念馆了。快到的时候我才发现我们的第二个囧事,那就是纪念馆是需要提前一天预约的,而不巧的是清明三天的时间都已经预约满了。在某人惋惜的眼神下,我们只好跑去附近的万达广场逛商场去了。不知是否是放假气息的带动,即使是逛着在帝都也能逛到的品牌店,我们依旧也很开心。甚至每个人都买到了自己称心的衣服,还不止一件! 注:蒙蒙细雨中送入云端的大厦 明瓦廊小吃街 逛完商场已然是到了晚上,打车去了小伙伴之前查到了一家南京很好喝的奶茶店拾叁茶明瓦廊店,想着买杯奶茶再在附近找家吃饭的地方就好了。在中江书香世家酒店下车后发现明瓦廊这条小巷似乎是小吃一条街,里面的小吃可太多了。特别是居然在去奶茶店的路上我们还看到了卖鸡蛋汉堡的!虽然比起在公司旁边吃的那家贵了很多,但看在排队的人那么多以及好奇心的驱使下,组织还是把我派出来排队购买了,而他们继续向奶茶店前进。当我看着门口无序的排队人群头大的时候,一个小妹妹非常优秀的和我达成了帮忙排队代购鸡蛋汉堡,她去其它地方排队帮我买好吃的的交易。结果买奶茶的,买其它好吃的都已经回来了,而我还在等鸡蛋汉堡…好在是没过一会都好了。真是到哪里都有排队、堵车的事情呀! 注:超多人排队的鸡蛋汉堡 等车的路上我们又买了点水果,想着干脆不吃晚饭,窝在宾馆边看剧边吃小吃了。回到宾馆发生了第三件囧事,宾馆只能刷卡到卡对应的楼层。而我和小伙伴不在一个楼层,当我想去他们楼层的时候发现过不去了。到一楼咨询了下之后发现需单独授权一下对应楼层,这里就想吐槽下办入住的时候服务员为什么不帮我们一步到位打通一下。 注:打卡到超好喝的茶饮品拾叁茶 吃了下鸡蛋汉堡发现用料确实要比我们在北京吃的扎实很多,带着一股鸡蛋的鲜嫩口感。不过我个人觉得在口味上还是北京的更胜一筹。北京的鸡蛋汉堡会使用榨菜来增加咸鲜味,同时辣酱口味使用的是剁椒而不是辣椒面也会更加符合我的口味。边吃边刷剧发现宾馆电视可以看《觉醒年代》,遂看了两集,正好在说一战后的巴黎合约事件,边看边搜下又恶补了一段历史知识。
  •  

React Server Component 可能并没有那么香

前段时间 React 团队发布了一项用于解决 React 页面在多接口请求下的性能问题的解决方案 React Server Components。当然该方案目前还在草案阶段,官方也只是发了视频和一个示例 demo 来说明这个草案。 Server Components 官方在视频和 RFC 中说明了产生这个方案的主要原因是因为大量的 React 组件依赖数据请求才能做渲染。如果每个组件自己去请求数据的话会出现子组件要等父组件数据请求完成渲染子组件的时候才会开始去请求子组件的数据,也就是官方所谓的 WaterFall 数据请求队列的问题。而将数据请求放在一起请求又非常不便于维护。 既然组件需要数据才能渲染,那为什么接口不直接返回渲染后的组件呢?所以他们提出了 Server Components 的解决方案。我们暂且不管这其中的逻辑有没有道理,先来看看该方案的大体流程是怎样的。 方案的大概就是将 React 组件拆分成 Server 组件(.server.tsx)和 Client 组件(.client.tsx)两种类型。其中 Server 组件会在服务端直接渲染并返回。与 SSR 的区别是 Server Components 返回的是序列化的组件数据,而不是最终的 HTML。 可能带来的问题 通过接口将组件和组件的数据一并返回的方式带来了打包体积的优势,但是它真的能像 React Hooks 一样香吗?我觉得并不然。 接口返回 常规做法里前端 JS 中加载组件,接口返回组件需要的数据。而 React Server Components 中则是将二者合二为一,虽然在打包体积上有所优化,但是明显是把这体积转义到了接口返回中。特别是在类似列表这种有分页的请求中,这种劣势会更明显。明明组件只需要在初始的时候进行加载,但是因为被融合进接口里了,每次接口都会返回冗余的组件结构,这样也不知道是好还是不好。可能后续需要优化一下接口二次返回只返回数据会比较好。 服务器成本问题 这里所说的服务器成本有很多,首先是机器本身的成本。将客户端渲染行为迁移到服务端时候势必会增加服务端的压力,用户量上来之后这块的成本是成量级的在增加的。关于这个问题,官方提供的回复是随着服务器的成本降低势必 Server Components 带来的优势会抵消这块的劣势。 Question: This might become more expensive for applications. In the search demo, finding those search results plus rendering them on the server is a more expensive operation than just an API call sent from the client. Reply: We are moving some of the rendering to the server–so it’s true that your server will be doing more work than before. But server costs are constantly going down, and far more powerful than most consumer devices. I think React Server Components will give you the ability to make that tradeoff and choose where you best want the work to be done, on a per component basis. And that’s not something that’s easily possible today. via: 《RFC: React Server Components》 不过以目前我所在的业务情况来看,服务器的成本还是非常贵的,为了降低成本大家纷纷将逻辑下发到边缘计算甚至是客户端处理。一方面是为了节省成本,另一方面也是为了降低压力加快处理。 除了机器本身的成本之外,请求的成本也会增加。毕竟除了数据请求之外还要处理组件渲染,而且这块作为组件耦合不好进行拆分。相比较常规方案,使用 JS 文件加载组件到客户端,接口单纯返回数据,这块的时间成本增加了非常多。特别是常规方案中 JS 文件加载完之后是在浏览器中缓存的,后续的成本非常小。 体积问题可能还好,但是请求时间增加了这个可能就非常致命了。 心智负担 这点在 RFC 中也有说明。由于 Server Components 中无法使用 useState, useReduce, useEffect, DOM API 等方法,势必这会给使用者带来大量的心智负担。虽然官方说会使用工具让开发者做到无感,且会提供运行时报错,但是我相信光是想什么时候需要写 Server Componet 什么时候需要写 Client Component 就已经脑壳疼了吧,更别提还有个 Shared Component 了。 另外还有就是增加了跨端的流程之后,调试的成本也会变的非常高。别说很多人没有服务端的经验,就算是有相关经验的同学可能也没办法很好的在服务端进行快速定位。关于这个问题官方提供的说法是可以依赖内部的错误监控和日志服务。 回归问题的本质 让我们回归到问题的本质,React Server Component 的目的其实是为了解决接口请求分散在各组件中带来的子组件的数据请求需要等待父组件请求完成渲染子组件时才能开始请求的数据请求队列问题。那么除了 Server Component 之外没有其它的解决方案了吗?其实不然。 import React, {useState, useEffect} from 'react'; import ReactDOM from 'react-dom'; function App() { const [data, setData] = useState([]); useEffect(() => { fetchData.then(setData); }, []); return ( <div> {!data.length ? 'loading' : null} <Child data={data} /> </div> ); } function Child({data}) { const [childData, setData] = useState([]); useEffect(() => { fetchChildData.then(setData); }, []); if(!data.length) { return null; } return ( <div>{data.length + childData.length}</div> ); } ReactDOM.render(<App />, document.querySelector('#root')); 如示例代码所示,只要加载组件,但是在无数据情况下不返回 DOM 也是可以做到子组件的数据先请求而无需等待的。当然这种需要认为的在写法上进行优化,但我也仍然认为比大费周章的去做 Server Component 要好很多。 至于 Server Component 带来的打包体积优化这个问题,我觉得 RFC 里面的评论说的非常的好。”比起 83KB(gzip 后大概是 20KB)打包体积,我觉得在项目中为了格式化日期使用一个 83KB 的库这才是更大的问题。“ Removing a 83KB (20KB gzip) library isn’t a big deal, I would say the bigger problem here is that you’re using a 83KB library to format dates. via: 《RFC: React Server Component》 实际上官方列举的两点关于日期处理以及 Markdown 格式处理的库,可以看到都是针对于数据进行处理的需求。针对这种情况如果觉得这块的体积非常”贵“的话完全是可以让服务端将格式化后的数据返回,这样岂不是更小成本的解决了这个问题? 后记 看完 《RFC: React Server Component》 中所有的讨论,大部分人对 Server Component 还是持不赞成的态度的,认为它可能并没有像 React Hooks 那样解决业务中的实际痛点。就目前暴露的提案,我个人也觉得 Server Component 是弊大于利的。目前就期望官方如果要实现的话能解耦实现,不要影响未使用 Server Component 的 React 用户打包体积。 当然该提案我觉得不是没有好处,它最大的好处我个人认为是带来了 React 组件序列化的官方标准。为多端、多机、多语言之间实现 React 组件交流提供了基础。基于这套序列化方案,我们可以实现组件缓存存储,多机器并发渲染组件等。至于多语言实现也是在 RFC 讨论中大家比较关心的问题,通过这套序列化标准让其它语言去实现 React 组件也不是没有可能。
  •  

2020 岁末总结

不知不觉,2020年都要过去了。今年因为疫情的原因,感觉时间过得特别的快,一不留神,一年就这么过去了。而今年发生的很多事情也都围绕着疫情在改变着。 🚑 疫情 每当你想尝试放松的时候,你都会被工作扼住命运的喉咙。今年要说什么对我的影响最大,非疫情莫属了。当我还在家里做着疫情很快会过去的美梦的时候,不知不觉就已经被疫情专题页的工作搞的日夜颠倒了。可能是因为丁香园疫情专题页的高流量,不知道为何我们突然之间也投入了大量的人力去开发疫情专题页了。整整持续了两个多月的高强度工作让我身心俱疲。 看看我当时发的状态,真是太丧了。印象比较深的是有一天搞到凌晨3点才休息,结果8点的时候就被领导电话叫起来说是线上有 Bug 赶紧看看。我… 😓真是棒呢! 截两个图留存下被大 Boss 直接跟项目带来的恐惧,名字就不留了,看头像懂得人应该都懂。 好在三月之后慢慢开始复工,回到公司之后状态慢慢的就恢复过来了。甚至感觉还能再来个疫情呢(大雾! 现在回过头来不得不感慨,之前一天 50 多次上线,每次上线都没走 QA 就上了,我头是有多铁啊!全程面向微信开发,产品微信发需求,设计微信发设计图,开发微信发上线记录。我们都有美好的未来……个鬼啊! 🌎 Drone 看过我之前文章的同学就知道,Drone 是我非常喜欢的一款 CI/CD 的工具。它的可扩展性非常高,适合用来在企业内部进行 CI/CD 服务的接入和部署推广。奈何内部环境限制,Gitlab 版本过低,网段隔离导致推广起来还挺费劲的。 不过两年后环境发生了很大的变化,Gitlab 版本升级上来了,网段隔离的问题也发现了解决方案。顺顺利利的就部署了起来。而且老板也想统一推广 CI/CD 这块,正好顺着这个风推广给大家了。 为了帮助大家能快速的接入 Drone,也开发了很多内部服务相关的插件,包括内部项目、容器上线的,内部 IM 消息通知的插件。而且高兴的是,除了我之外,公司内还有其它团队的同学也有在使用 Drone,写的插件对他们也很有帮助。 自己一直想推的事情总算有一点小小的进展,而且发现还有同好,真是很开心呢。 👩‍🎨 设计云 设计云是今年团队因为蓝湖收费产出的一款类似于蓝湖的设计交付工具。后续转手到了我这里进行开发维护。它给我最大的收获是打开了 Sketch 插件开发的大门,原来开发一款 Sketch 插件其实没有想象中的那么困难。 当然开始的时候还是非常难的,经常碰到问题需要去请教之前做的小伙伴。不过后来基本就驾轻就熟了。然后反向回馈 Sketch 插件社区一些项目中反馈到的问题。成为了插件核心插件 skpm/skpm 的贡献者,想想都还挺激动的呢。 另外我负责了这个项目包括前端、服务端、客户端上的全部重构,所以这块能总结的东西其实也非常多。我是特别喜欢将自己的知识总结成文字分享给大家的,所以那段时间也一连产出了好些文章,都是从这个项目中反馈出来的经验。 Sketch 插件导出切片 如何制作 Sketch 插件 使用 SVG 制作加载动画 如何使用 ThinkJS 优雅的编写 RESTful API 🙏 司徒正美 4月1日,惊闻正美老师过逝的消息,一度还以为是假消息,后来经过正美老师的室友确认。正美老师是在前端圈非常有技术声望的人,他的离世震惊了圈内人士。 我和正美老师的交集在于我在业务上使用了正美老师之前开发的类 React 框架 anu.js。选择这个库的原因有以下几点: 满足了我们的 React IE 兼容性要求 它比较小巧,代码清晰易懂我们自己维护也不费劲 正美老师个人在前端框架这块的技术声望 后续该框架也在我们的项目中扮演越来越重要的角色,我也不遗余力的在推广其他业务的小伙伴有类似兼容性需求的时候使用该框架。而正美老师的突然离世则让该框架成了没爹的孩子。 为了保证业务的可维护性,以及不让正美老师的遗作就这么销声匿迹,我慢慢开始了 anujs 项目的权限申请,主要是 npm 模块的权限申请以及 Github 仓库的权限申请。这些由于账号主人的离世,都只能去邮件和网站管理沟通了。 在经历了一段时间的等待之后,5月1日我成功获得了 anujs 的 npm 模块的发布权限。而 Github 的权限则非常可惜的没有申请下来,最后我们采用了 fork 的方式继续维护。 目前我们项目组有一位对 React 框架有经验的同学在负责维护该框架,主要是一些日常的 Bug 修复。感谢正美老师为我们带来这么好的作品,也愿他在天堂安息。 🌋 垃圾评论 2020年的年末,我不是很开心,因为……我被网暴了。起因是我发布了一篇 《基于 Serverless 的 Valine 可能并没有那么香》 的文章。文章里描述了一款第三方评论工具 Valine 存在的一些安全问题,然后在文末介绍了我为了解决该问题开发的高度兼容 Valine 的评论系统 Waline。 也不知是哪位无聊之人使用我的昵称和邮箱在全网使用 Valine 的博客中套用我的身份给它们发送了大量该文章和 Waline 系统的垃圾评论广告。导致大量的博主到我的博客上投诉甚至谩骂我。 这本是有人利用了 Valine 系统本身的漏洞问题制作的一场恶作剧,本来解释一下大家应该也都能理解就这么过去了。不过其中有一位用户说什么也不相信这不是我本人干的。在我的博客上疯狂的辱骂我。本来他发些垃圾评论我觉得也没什么,大不了定时清理下数据就好了。但是他开始回复我的博客的其它评论。而评论是有回复通知的,这无疑对其它博主造成了困扰。 为了阻止他,我紧急增加了发送频率限制、关键词过滤、IP黑名单等常见的反垃圾评论操规则。当他发现我有 IP 黑名单之后,还会尝试换一些 IP 来操作。好在经过几次的 IP 黑名单完善之后,慢慢的发的也就少很多了。不过后续更过分的事情又来了,他又顺着我的友链列表,去到我的友链博客下面使用我的信息伪造我的发言。真是可笑的事情啊,屠龙者终成恶龙! 💻 后记 其实今年发生的事情非常多,组织也发生了很大的变化,但有些事情真的无法用言语表达出来,就让它默默的存在我心里吧。新的一年 Flag 就不立了,希望在新的一年里能够在技术上有更好的突破,折腾一些更有意思的东西吧。
  •  

静态博客如何高性能插入评论

🌏 前言 我们知道,静态博客由于不带有动态功能,所以针对评论这种动态需求比较大众的做法就是使用第三方评论系统。第三方评论的本质其实就是使用 JS 去调取第三方服务接口获取评论后动态渲染到页面中。虽然它很好的解决了这个问题,但是由于需要请求接口,在体验上远比动态博客的直出效果要差很多。所以当我把博客从动态博客 Typecho 迁移到静态博客 Hugo 上来时,就一直在思考这个问题。直到我看到了 Hugo 的 getJSON 方法,发现原来静态博客也是能够像动态博客一样直出评论的。 大部分的静态博客的原理是解析存储内容的文件夹,使用一些模板语言遍历数据生成一堆 HTML 文件。而 Hugo 除了解析 Markdown 内容之外,还支持额外的数据获取方法 getJSON。由于有了 getJSON 方法的出现,我们可以实现在博客编译构建过程中动态的去获取评论接口数据,将其渲染到页面中,实现评论数据的直出效果。关于 getJSON 的更多介绍,可以查看 Hugo 文档数据模板一节。 🎃 方案 高性能方案基本思路是在需要评论数据的地方通过 getJSON 方法调用接口获取评论数据并进行模板渲染。当评论更新的时候,我们需要触发重新构建。实现这个方案依赖三个关键要素: 构建过程支持调取接口获取数据 评论服务提供 HTTP 接口返回数据 博客部署服务支持钩子触发重新构建 我的博客使用的是 Hugo 静态博客系统,如上文所说通过 getJSON 即可解决第一个问题。而我的评论服务使用的是自研的 Waline 评论系统,它提供了评论数、评论列表、最近评论等基础接口满足我们的数据获取需求。并且 Waline 提供了丰富的钩子功能,支持在评论发布的时候触发自第一方法。我的博客部署在 Vercel 上,它提供了 Deploy Hooks 功能,通过 URL 即可触发重新构建。也就是说我只要在 Waline 评论发布的钩子中调用 Vercel 的钩子 URL 触发重新构建即可解决第三个问题。 🥪 实现 我的博客上有三处地方和评论有关,分别是首页侧边栏的最近评论,文章标题下方的评论数,以及文章详情页底部的评论列表展示。 🍞 最近评论 Waline 最近评论接口:文档 {{ $walineURL := .Site.Params.comment.waline.serverURL }} <h2 class="widget-title ">最近回复</h2> <ul class="widget-list recentcomments"> {{ $resp := getJSON $walineURL "/comment?type=recent" }} {{ range $resp }} <li class="recentcomments"> <a href="{{.Site.BaseURL}}{{ .url }}">{{ .nick }}</a>:{{ .comment | safeHTML | truncate 22 }} </li> {{ end }} </ul> 🧀 文章评论数 Waline 获取文章对应的评论数接口:文档 {{ $walineURL := .Site.Params.comment.waline.serverURL }} {{ $count := getJSON $walineURL "/comment?type=count&url=https://imnerd.org/" .Slug ".html" }} <a href="{{ .Permalink }}#comments" title="{{ .Title }}"> <i class="fas fa-comment mr-1"></i> <span>{{- if gt $resp 0}}{{$resp}} 条评论{{else}}暂无评论{{end -}}</span> </a> 🍯 评论列表 评论列表由于有分页的存在,不像最近评论和评论数一样简单的调用接口即可。先获取评论数,发现有评论时先获取第一页的评论,主要是用来获取总共有多少页评论。之后再从第二页开始循环获取评论数据。最终将获取到的数据全部存到 {{$scratch.Get "comments"}} 数组中,使用模板语法渲染该数组数据即可。 {{$baseUrl := .Site.Params.comment.waline.serverURL}} {{$slug := .Slug}} {{$count := getJSON $baseUrl "/comment?type=count&url=https://imnerd.org/" $slug ".html" }} {{$scratch := newScratch}} {{$scratch.Add "comments" slice}} {{if gt $count 0}} {{$comments := getJSON $baseUrl "/comment?path=/" $slug ".html&page=1&pageSize=100"}} {{range $cmt := $comments.data}} {{$scratch.Add "comments" $cmt}} {{end}} {{$totalPages := $comments.totalPages}} {{if gt $totalPages 1}} {{range $page := seq 2 $totalPages}} {{$comments := getJSON $baseUrl "/comment?path=/" $slug ".html&pageSize=100&page=" $page}} {{range $cmt := $comments.data}} {{$scratch.Add "comments" $cmt}} {{end}} {{end}} {{end}} {{end}} <div class="vcards"> {{range $cmt := $scratch.Get "comments"}} <div class="vcard" id={{$cmt.objectId}}> <img class="vimg" src="https://gravatar.loli.net/avatar/{{$cmt.mail}}?d=mp"> <div class="vh"> <div class="vhead"> <a class="vnick" rel="nofollow" href="{{$cmt.link}}" target="_blank">{{$cmt.nick}}</a> <span class="vsys">{{$cmt.browser}}</span> <span class="vsys">{{$cmt.os}}</span> </div> <div class="vmeta"> <span class="vtime">{{dateFormat $cmt.insertedAt "2006-01-02 03:04:05"}}</span> <span class="vat">回复</span> </div> <div class="vcontent" data-expand="查看更多..."> {{$cmt.comment | safeHTML}} </div> <div class="vreply-wrapper"></div> <div class="vquote"> {{range $cmt := $cmt.children}} <div class="vh" id="{{$cmt.objectId}}"> <div class="vhead"> <a class="vnick" rel="nofollow" href="{{$cmt.link}}" target="_blank">{{$cmt.nick}}</a> <span class="vsys">{{$cmt.browser}}</span> <span class="vsys">{{$cmt.os}}</span> </div> <div class="vmeta"> <span class="vtime">{{dateFormat $cmt.insertedAt "2006-01-02 03:04:05"}}</span> <span class="vat">回复</span> </div> <div class="vcontent" data-expand="查看更多..."> {{$cmt.comment | safeHTML}} </div> <div class="vreply-wrapper"></div> </div> {{end}} </div> </div> </div> {{end}} </div> 🍳 构建触发 Waline 在评论发布、更新和删除阶段都支持自定义钩子,在钩子中触发 Vercel 的构建钩子即可完成发布评论重新构建的流程。 按照如下内容修改服务端部署的 index.js 文件,查看文档了解全部的 Waline 钩子。 const Waline = require('@waline/vercel'); const https = require('https'); const buildTrigger = _ => https.get('https://api.vercel.com/v1/integrations/deploy/xxxxx'); module.exports = Waline({ async postSave(comment) { if(comment.status !== 'approved') { return; } buildTrigger(); }, async postUpdate() { buildTrigger(); }, async postDelete() { buildTrigger(); } }); 🍾 后记 通过以上操作,就能在不损失用户体验的情况下实现评论数据的动态支持了。有些人可能会担心是否会在构建阶段造成超多的接口请求。这里大可不用担心,Hugo 自己会在构建的时候做接口的缓存,同 URL 的接口调用会走缓存数据而不会重新调用。 除了用户体验之外,由于只会在构建的时候触发数据的获取,针对有调用次数配额的第三方评论服务也能节省额度。当然,理论上构建次数是远小于访问次数的,所以额度节省的结论是能成立的。如果说你的构建次数要比访问次数还要大的话,那这种方法就无法节省额度了。 当然这种方式也会有带来些问题,主要是评论的更新没那么快。好在 Hugo 的构建速度非常快,一两分钟的时间也能接受。而针对用户评论的发布,则可以通过评论发布后先假插入缓解该问题。
  •