阅读视图

发现新文章,点击刷新页面。

介绍一个多人划线标记功能

想法

灵感来源于某次逛公众号文章的时候,在文章中偶然看到了一个下划线,经过它时还会显示多少人划线标记。感觉这个功能其实对博文也挺方便的,因为都有评论系统,感觉可以通过评论用户信息做一个多人划线标记功能,自己留作标记的同时也方便其他浏览文章的博友发现和标记文章主要相关内容。

众所周知我目前用的平台是 wordpress,所以该功能也是基于 php 环境下进行开发的,由于标记数据储存在本地而非数据库中,可以灵活应用于各类基于php框架的博客系统上(如typecho、zblog、emlog等)。唯一一点是使用了 wordpress 的文章 id 来区分不同文章页面(关于这点其实可以使用加密 location.pathname 发送到后端作为文章别名区分,后续版本可能以此更新 已更新(默认url别名),执行标记后请勿随意修改url:*切换请求标识符(postID)可能导致清空本地记录继而误删远程数据)若使用其他平台需要修改前端请求中的pid参数(现可选携带参数:postId 初始化,默认 location.pathname

功能简介

划线标记功能结构由文章—用户—标记三部分组成,通过wordpress文章id区分:同一篇文章下可以理论上可以存在不限量标记,但同一个用户(md5邮件区分)默认情况下仅能存在3个标记(可携带自定义参数初始化标记)。而同一个标记(内容)仅能在该文章中存在一次(若通过某种手段重复标记某段已存在于远程记录时,后端会返回标记已存在的错误信息),另若当前选中文本在当前段落中存在多个相同字符时,前端会阻止用户提交标记。

本着只要能把功能复现就行的想法,在持续一周左右的时间后正式开始测试,目前该功能已集成到2BLOG主题最新的 #v1.3.9.2 版本中,后续将对其进行持续迭代更新,下面讲下简单实现思路。

实现思路

前端直接用的 getSelection api 执行选中标记操作(之前本想做跨浏览器兼容,后面想想太麻烦,先实现可以用了再说,反正又不是上线的东西 后面可以慢慢迭代无所叼谓),用户信息存了本地cookie(刚开始做的内容储存,后来改为用户本地校验内容)。

后端用的东西基本和我之前那篇实现gpt的思路基本是一致的,都是本地储存。唯一只是多了部分用户校验,因为涉及到用户新增和删除操作问题(毕竟所有人在评论后都可以对文章进行标记或删除)暂时只能先这么弄(虽然目前配置了文章标记次数限制,不过并没有做黑白名单限制)。

WordPress 简单实现 chatGPT 文章摘要

WordPress 简单实现 chatGPT 文章摘要

灵感来源于之前在浏览 HEO 博文时候偶然看到文章前有一段 AI 摘要,第三人称以打字形式来简述文章内容还是蛮酷的~ 于是拟了个把这个功能集成到 2BLOG ...

2BROEAR 21/03/2024 | 3192 views.

用户校验

目前做了两个信息校验,

一个是最基本的邮件mail(明文验证,其实刚开始的方案是有md5来做,后来因为新增了显示标记用户 gravatar 头像的需求所以不得不放弃该方案,取而代之的只能是远程明文对比验证。但是,为了防止出现邮件明文暴露后造成伪造请求的情况,在执行部分敏感操作时必须携带储存在用户本地的 timestamp 时间戳(明文)与储存在远程服务器中的ts(加密,防止远程ts暴露后获取到对应的本地ts明文)进行校验,通过后再放行相关操作。

但ts交互验证有一个明显的坏处就是:如果标记浏览器与执行操作的浏览器环境不同(即本地cookie无相关ts记录)哪怕当前是用户本人操作也无法通过远程ts验证,目前应对这种情况目前做了用户操作提示与浏览器user-agent记录(可查看对比记录),暂无具体替代解决方案。

其他

这个功能目前处于测试阶段,使用过程中有任何bug欢迎反馈哟/doge。(*另附一些常用的初始化参数如下(所有参数值均为默认值!具体参数等内容可前往 github 查看)

// 携带参数初始化
new marker.init({
    static: {
        postId: window.location.pathname, // 页面标识符(可选,文章唯一ID)
        apiUrl: "mark.php", // 后端 mark.php 文件地址(可选,缺省同一目录)
        md5Url: "md5.js", // 用户 mid 初始化 md5 资源(可选,缺省同一目录)
        avatar: "//gravatar.cn/", // 用户头像 cdn(可选,默认 cravatar)
    },
    class: {
        blackList: ['chatGPT','article_index','ibox'], //'', 'chatGPT,article_index',
    },
    element: {
        effectsArea: document.querySelector('.content'), // 可选区域(可配合 blackList 使用)
        commentArea: document.querySelector('#vcomments textarea'), // 评论区域
        commentInfo: {
            userNick: document.querySelector('input[name=nick]'), // 评论用户
            userMail: document.querySelector('input[name=mail]'), // 评论邮箱
        }
    },
});

插件

现已集成插件到 github:

2Broear/marker: a local-storage(php) based original javascript api marking-off plugin. (github.com)

todos

一些預計添加或修復的待辦事項

  • ❓ 約束後端請求頻率,(新增延迟文件锁),修復并發請求失敗但返回已完成問題。问了一圈 gpt 没解决,暂时在前端做请求限制与延时。
    • ❌使用 localStorage 修复了授权用户操作失败(弱网、并发)后再次访问时自动更新本地/远程记录。bug:标记后立即刷新页面会导致本地记录(已记录)无法匹配远程(等待返回)被删除,后续刷新页面后导致远程记录(已返回)无法匹配本地记录(已删除)被删除。(可在切换评论系统后,通过读取本地记录仍可执行标记删除等操作)
    • ✅更新方案(v1.3.9.5):标记或删除后等待远程响应,返回后再写入(补全)或删除(更新)本地记录对应逻辑如下:
      • 新增标记:(未响应,新增本地记录)—>刷新页面(未响应,删除本地记录)—>返回数据(已响应,对比本地记录)—>远程记录中 未找到 本地记录—>新增本地记录
      • 删除标记:(未响应,删除本地记录)—>刷新页面(未响应,新增本地记录)—>返回数据(已响应,对比本地记录)—>本地记录中 不存在 远程记录—>删除本地记录
  • ✅ 修复本地、远程标记请求上限多步验证。
  • ✅ 新增更多自定义初始化携带参数配置。
  • ✅ 新增自定义标记文本注释功能。
  • ✅ 支持多人重复(未注释)标记,并显示(前三)标记用户头像。
  • ✅ 更新UI/UE,更好的适配暗黑模式
  • ✅ 支持 SSE 服务端 api 推流(递增显示标记➕动画)bug: 每次接收数据后输出时未即使更新data

我们如何从 Wxml2Canvas 迁移到 Painter

路漫漫其修远兮

糖纸苦 Wxml2Canvas 久矣!

长期以来,糖纸项目使用 Wxml2Canvas 库来生成分享海报。这个库的功能就是将 Wxml 转换成 Canvas,并最终生成一张图片。但是,这个库非常不稳定,经常会出现各种奇怪的 BUG,只能说勉强能用。如果你想了解 Wxml2Canvas 给我们带来的痛苦,可以阅读这篇文章:《一行 Object.keys() 引发的血案》

因此,我们一直希望能找到一个更好的替代方案。在社区搜索后,我们发现 Painter 非常不错。然而,它与 Wxml2Canvas 的使用方式有很大的差异,我们的项目中有二十多个地方使用了 Wxml2Canvas,所以迁移起来并不容易。但 2022 即将结束,我们希望能在最后时刻做点事情来让自己找回一丝慰藉,所以才有了这篇文章。

让我们来看看这两个库的使用方式有什么不同:

image-20221227005600071image-20221227005620310

Wxml2Canvas 使用方式相对直观,使用 Wxml 和 Wxss 实现,而 Painter 则使用 JSON 配置。如果要将项目迁移到 Painter,就需要手写大量的 JSON 配置,这需要相当多的工作量。

吾将上下而求索

俗话说得好:只要思想不滑坡,办法总比困难多!

那么,有没有一种方法可以让我们迁移到 Painter,同时又不用重写 JSON 配置呢?

让我们从不同的角度思考一下:Wxml2Canvas 可以直接将 Wxml 画到 Canvas 上,那么是否也可以将其转换成 JSON 配置呢?这样,我们就可以复用现有的 Wxml 代码,减少迁移的成本。

大致流程如下:

image-20221227222820467

总之,我们需要一个转换器来将 Wxml 转换为符合 Painter 使用的 JSON 配置,我愿称之为 Wxml2Json。

说干就干,我们可以直接照搬 Wxml2Canvas 的做法。首先获取最外层容器的尺寸,用来定义分享海报的宽高。然后,通过 wx.createSelectorQuery().selectAll() 获取所有需要绘制的节点和样式信息。接着,根据不同的节点类型设置对应的属性,最终输出一份 JSON 配置供 Painter 使用。

其核心方法是 getWxml,大致实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
getWxml({container, className} = {}) {
const getNodes = new Promise(resolve => {
query
.selectAll(className)
.fields(
{
id: true,
dataset: true,
size: true,
rect: true,
computedStyle: COMPOUTED_ELEMENT_STYLE,
},
res => {
resolve(this.formatNodes(res))
},
)
.exec()
})

const getContainer = new Promise(resolve => {
query
.select(container)
.fields(
{
dataset: true,
size: true,
rect: true,
},
res => {
resolve(res)
},
)
.exec()
})

return Promise.all([getContainer, getNodes])
}

formatNodes 方法的职责就是根据需要绘制的节点类型进行格式转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
formatNodes(nodes) {
return nodes
.map(node => {
const {dataset = {}} = node

node = {...node, ...dataset}

const n = _.pick(node, ['type', 'text', 'url'])

n.css = this.getCssByType(node)

return n
})
.filter(s => s && s.type)
}

有了这个转换器,我们的迁移工作只需要将 new Wxml2Canvas 替换成 new Wxml2Json ,然后将数据传入 Painter 中即可。因此,一天内完成所有 Wxml2Canvas 迁移到 Painter 的工作将不再是个梦。

山重水复疑无路

缝合结束,不出意外的话马上要出意外了,虽然大部分机型都表示情绪稳定,但成功路上注定不会一马平川。

果不其然,让全网「沸腾」的鸿蒙首当其冲,如下图所示:
image-20221228005732730

然后,测试小姐姐的 iPhone 12 也毫不甘落下风,上来就憋了个大招:微信闪退。

以上这两个页面都有一个共同点,就是生成的分享海报尺寸非常大,比如说这个:1170 × 17259。

我去线上看了一下,发现同一个页面上 Wxml2Canvas 却是稳定的,那这个 Painter 为什么这么拉胯?

开始找茬,分析两者的实现,终于发现了一些端倪:首先是 wx.canvasToTempFilePath 的参数不同:

image-20221228223957183

翻看 wx.canvasToTempFilePath 文档,其中 xy 默认值都是 0,问题不大。

主要问题在于 widthheight,我们先来看看 wx.canvasToTempFilePath 这几个参数的作用:

  • width,画布的宽度
  • height,画布的高度
  • destWidth,输出图片的宽度,默认值是 width × dpr
  • destHeight,输出图片的高度,默认值是 height × dpr

然后再梳理一下这两个库中的参数值是多少:

  • Wxml2Canvas
    • width:与外层容器的宽度、 canvas 宽度一致
    • height:与外层容器的高度、 canvas 高度一致
    • destWidth,width × dpr
    • destHeight,height × dpr
  • Painter
    • width:外层容器的宽度 * dpr、 canvas 宽度一致
    • height:外层容器的宽度 * dpr、 canvas 高度一致
    • destWidth,与 canvas 宽度一致
    • destHeight, 与 canvas 高度一致

答案呼之欲出了,我来解释一下:

  1. Painter 会将所有需要绘制的节点尺寸乘以设备的 dpr。假设我们要生成一张 375 x 800 的海报,其中包含一张 100 x 100 的图片,在当前设备的 dpr 为 3 的情况下,Painter 会创建一张 1125 x 2400 的画布,在画布上绘制一张 300 x 300 的图片。最终在保存图片时,输出的图片尺寸与画布大小完全一致。
  2. Wxml2Canvas 在绘制时是创建一张 375 x 800 的画布,并在画布上绘制一张 100 x 100 的图片,但是在最终保存图片时,输出的图片尺寸是画布大小乘以 dpr。

看上去 Painter 的做法似乎并无不妥,因为画布大小和最终成品是 1:1 的;反观 Wxml2Canvas 却是 1:3,难道这样导出的图片不会影响清晰度吗?我们直接来做个实验,分别用 Painter 和 Wxml2Canvas 生成同一张分享海报,对比两张图片的不同,结果发现导出的图片无论尺寸还是文件大小都是一模一样的,如图所示:

image-20221229181609765

柳暗花明又一村

既然如此,我们就可以直接将 Wxml2Canvas 的方案移植到 Painter,最终发现这样能 work:

image-20221229132803803

总而言之,尽管两者最终生成的成品尺寸是一样的,但是 Painter 设置的画布尺寸比 Wxml2Canvas 大了三倍,这样会使用更多的内存,而且微信官方文档也提到:设置过大的宽高会导致 Crash 的问题。

经过这一番操作,鸿蒙和 iPhone 12 也终于服帖了。然而,又有新的问题出现了。当某个页面生成并保存图片后,在滑动该页面时会明显感觉卡顿,对比一下 fps(帧率)的变化,确实离谱。
image-20230103233431180

这种卡顿是肉眼可见的,猜测可能是因为内存泄露造成。在真机上调试分析了一下内存占用情况,未进行生成海报时,CPU 占用率为 2%,内存占用为 872 MB:

image-20230103235024011

当生成海报时,CPU 占用率快速飙升到 22%,内存占用 895 MB:

image-20230103235506588

随后发现内存占用并没有下降,直到我们离开了当前页面时,占用率才有所下降。

image-20230103235744395

既然如此,可以在生成海报之后立即对分享卡片的内存进行回收,最简单的方式就是使用 wx:if 控制。

1
2
3
4
<share-card 
+ wx:if="{{showShareCard}}"
id='share-card'
/>

最后来晒晒战绩,迁移后生成时间缩短近 50%:

综上所述,Wxml2Canvas 在稳定性和可维护性方面都有所欠缺,但也有值得 Painter 借鉴的地方。例如,Wxml2Canvas 的使用方式更直观,不需要设置过大的画布尺寸,从而避免了 Crash 的风险。因此,将两者缝合起来,以最小的成本提高糖纸生成分享海报的效率和稳定性,何乐而不为?

从一次 yarn 命令死循环说起

前言

最近有个想法,希望在一个 yarn workspace 项目中实现任意一个子包中安装依赖时,都执行一些类似于初始化、同步配置的动作。

然而在操作过程中遇到了一个关于 yarn --cwd 有趣的问题,特地记录下来,希望能对后来者有所帮助。

遇到什么问题呢

先交代一下我们项目的基本情况,它是一个通过 yarn workspace 管理的 monorepo 项目,使用的是 yarn v1.22.11 版本,目录结构大致如下:

1
2
3
4
5
6
7
8
monorepo
├── package.json
├── app-a
│   └── package.json
├── app-b
│   └── package.json
└── config
   └── package.json

其中 app-aapp-b 都使用了 config 这个共享包:

1
2
3
"dependencies": {
"@monorepo/config": "../config",
}

我们需要在根目录的 package.json 中的 preinstall 钩子做一些初始化操作:

1
2
3
"scripts": {
"preinstall": "./bin/init.sh",
}

此时我们在根目录执行 yarn 或者 yarn add <pkg-name>,都会触发 preinstall 这个钩子,但在 app-a 中执行 yarn是不会触发根目录的 preinstall 钩子的。

因此,我们需要分别在每个子包上都加上这行,也即在每个子包安装依赖时都执行一下根目录的 preinstall 命令:

1
2
3
"scripts": {
"preinstall": "yarn --cwd ../ preinstall",
}

于是,奇怪的事情就发生了,当我在 app-a 中执行 yarn 的时候,它停留在安装 @monorepo/config 的阶段,同时我的电脑明显变得卡顿,于是打开 htop 一看,好家伙,满屏都是:

1
4ark   40987  26.3  0.5 409250368  78624   ??  R  8:36下午   0:00.09 /usr/local/bin/node /usr/local/bin/yarn --cwd ../ preinstall

CPU 占用率直接达到 100%,吓得我赶紧 kill 掉这些进程:

1
ps aux | grep preinstall | awk '{print $2}' | xargs kill -9

分析原因

惊吓过后,来分析一下原因,很显然这段命令陷入了死循环,导致越来越多进程,于是尝试在每个子包中都手动执行一遍 yarn --cwd ../ preinstall 后,发现一切正常,那问题出在哪呢?

于是我再执行了一遍 yarn,并且用以下命令将进程信息复制出来,以便分析:

1
ps -ef | pbcopy

随后验证我刚刚的猜测,的确是这个命令在不断触发自己,导致死循环:

1
2
3
4
5
UID   PID  PPID   C STIME   TTY     TIME CMD
501 50399 50379 0 8:50下午 ?? 0:00.10 /usr/local/bin/node /usr/local/bin/yarn --cwd ../ preinstall
501 50400 50399 0 8:50下午 ?? 0:00.11 /usr/local/bin/node /usr/local/bin/yarn --cwd ../ preinstall
501 50401 50400 0 8:50下午 ?? 0:00.11 /usr/local/bin/node /usr/local/bin/yarn --cwd ../ preinstall
501 50402 50401 0 8:50下午 ?? 0:00.12 /usr/local/bin/node /usr/local/bin/yarn --cwd ../ preinstall

由于三个分包执行的命令都一样,不清楚是不是由于某个分包引起,于是修改一下命令以便区分:

1
2
3
"scripts": {
"preinstall": "echo app-a && yarn --cwd ../ preinstall",
}

随后发现问题是出现在 config 这个子包,于是我把这个子包的 preinstall 命令去掉,果然没有这个问题了,非常奇怪。

难道是 --cwd ../ 这个路径有问题?验证一下,把命令改成这样:

1
2
3
"scripts": {
"preinstall": "pwd && yarn --cwd ../ preinstall",
}

发现 pwd 输出是这样子的:

1
/4ark/projects/monorepo/app-a/node_modules/@monorepo/config

从这里的输出我们发现了两个问题,第一个问题是:

  • yarn workspace 共享包的 preinstall 被执行的时候,其实已经被拷贝到 app-anode_modules 中,而不是在当前目录,因此 --cwd ../ 并不指向项目根目录。

这一点比较好理解,毕竟 config 作为一个依赖包,确实应该被拷贝到应用的 node_modules

而第二个问题就不太理解了,为什么明明设置了 --cwd ../,却依然在当前目录执行呢?按照预期 cwd 的指向应该是:

1
/4ark/projects/monorepo/app-a/node_modules/@monorepo

难道是我对 cwd 参数的理解有偏差?看一下 yarn 的文档中对 cwd 描述:

Specifies a current working directory, instead of the default ./. Use this flag to perform an operation in a working directory that is not the current one.

This can make scripts nicer by avoiding the need to cd into a folder and then cd back out.

从文档的描述来看,cwd 的作用不就是代替 cd 吗,但现在的结果看来 yarn --cwd ../ preinstall 并不等价于 cd ../ && yarn preinstall

这就不得不让人疑惑 cwd 的定位方式了,在网上搜寻一番没找到相关的讨论,那只能自己动手丰衣足食,直接从 yarn 源码中寻找答案。

分析源码

前面我们说到,我们使用的是 yarn v1.22.11,在 yarn 的 GitHub 仓库中发现 v1 版本的最新版本停留在 v1.23.0-0,那我们就从这个版本的源码来进行分析,首先克隆代码到本地:

1
git clone --depth=1 https://github.com/yarnpkg/yarn

然后安装依赖并运行起来:

1
yarn && yarn watch

这时候它就会自动监听代码修改然后重新编译,我们查看 package.json 发现 yarn 的 bin 主要是调用 ./bin/yarn.js:

1
2
3
4
"bin": {
"yarn": "./bin/yarn.js",
"yarnpkg": "./bin/yarn.js"
},

也就是我们直接执行 bin/yarn.js 的效果就如同执行 yarn,试一下查看版本:

1
2
> /Users/4ark/projects/yarn/bin/yarn -v
1.23.0-0

PS:当然你也可以在项目目录下使用 npm link 把它挂载到本地中。

接下就是一番调试,终于定位到可以回答我们疑问的代码,在这里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function findProjectRoot(base: string): string {
let prev = null;
let dir = base;

do {
if (fs.existsSync(path.join(dir, constants.NODE_PACKAGE_JSON))) {
return dir;
}

prev = dir;
dir = path.dirname(dir);
} while (dir !== prev);

return base;
}

const cwd = command.shouldRunInCurrentCwd ? commander.cwd : findProjectRoot(commander.cwd);

可以看到 cwd 的定位方式是从当前目录寻找是否存在 package.json,若存在,则返回此目录,否则将目录经过 path.dirname 处理一遍,继续寻找,直到寻找到最外层。

那么这里最关键的是 path.dirname 的返回值,我们先看一下文档对于它的描述:

The path.dirname() method returns the directory name of a path, similar to the Unix dirname command. Trailing directory separators are ignored,

就是返回一个路径中的目录部分,作用与 unix 下的 dirname 命令一致,通常是这么使用的:

1
2
3
4
5
> dirname /4ark/app/index.js
/4ark/app

> dirname /4ark/app/packages/index.js
/4ark/app/packages

是不是会肤浅地认为它的作用就是返回一个路径的上一级目录?如果传入的是一个绝对路径,确实可以这么肤浅地认为,然而当传入的是一个相对路径时,情况就不一样了:

1
2
3
4
5
6
7
8
> dirname ../app/index.js
../app

> dirname ../../
../

> dirname ../
问: 会返回什么呢?

答案是:.,也就是当前目录。

那这里就能回答我们之前的问题,为什么在 node_module/@monorepo/config 中使用 yarn --cwd ../ preinstall 却在当前目录执行,因为它的上一级 node_modules/@monorepo 不存在 package.json,所以经过 dirname ../ 处理后 cwd 的指向就是当前目录。

如果对 node.js 中 path.dirname 的实现方式感兴趣,可以看这里 path.js#L538-L554

解决方案

摸清楚原因后,那解决这个问题也不是难事,只要我们把相对路径改成绝对路径,是不是就能解决这个问题了?

思考一下,其实 yarn --cwd ../ preinstall,把 ../ 改成绝对路径行不行呢?比如在本文的场景,../ 其实就是项目的根目录,那我们完全可以通过别的方式获取到项目的根目录,比如 在 git 中:

1
git rev-parse --show-toplevel

所以,我们把命令改成这样,问题就迎刃而解了:

1
2
- yarn --cwd ../ preinstall
+ yarn --cwd $(git rev-parse --show-toplevel) preinstall

那就不得不提一下,其实在 yarn v2 中新增了一个 --top-level 属性,它的作用刚好就是为了解决这个问题。

结语

其实我们再回过头来想,在本文的例子中,根本不需要在 config 目录中添加 preinstall 这个钩子,因为它作为共享包,每次修改都必然要在其它使用这个包的地方,重新安装一次,所以只要确保这些地方会执行 preinstall 就可以了,那也就意味着不会出现本文遇到的问题。

不过,多踩坑也不是坏事,只要搞清楚背后的原因,问题也就不是问题。

❌