AI 治好了我的 CSS 框架恐惧症
00. 写在前面
大家好,我是大家的林语冰。
前端工程中,苦于“前 CSS3 时代”相对落后的原生语法,CSS 架构一直是前端架构师的痛点之一。
因此,我们一般会在项目里引入更先进的 CSS 框架或预处理器,比如国内比较火的 SCSS/LESS,或者海外人气爆棚的 Tailwind CSS/UnoCSS 等。
问题在于,当我们学习从原生 CSS 升级到 SCSS,或者老板要求从 SCSS 迁移到人气更高的 Tailwind 框架时,不同 CSS 框架的学习成本也不容小觑。
本质上而言,这些 CSS 框架提供的高级语法最终都会被转译为原生 CSS,而这种语法转换工作恰恰是 AI 编程助手的拿手好戏。
所以,本期我想分享如何利用 VSCode 和 MarsCode AI 插件,在原生 CSS 和不同 CSS 框架中无缝衔接,直接让 AI 解放我们的双手,不必再因为不同的 CSS 框架而头大。
01. 前期准备
本文的示例代码是用原生 CSS 实现一个仿真的 iPhone 手机,类似的产品模型网页预览效果在很多电商网站都比较常见,最终实现效果如下所示:
上述手机模型对应的原生的 HTML 结构和 CSS 代码如下:
(PS:此处代码仅供参考,大家可以用自己的样式代码进行后续测试,不需要关注这里的代码细节)
02. VSCode AI 插件
假设上述示例是项目遗留的旧代码,我们想要使用其他 CSS 框架重构为可维护的高级样式代码,就需要和 AI 助手联动,让 AI 帮我们写代码。
首先,我们需要可以使用手机号或邮箱注册一个账号,然后在 VSCode 里搜索和安装 MarsCode 扩展插件,登录后就可以在 VSCode 里直接使用 AI 编程助手。
另外,豆包 MarsCode 使用的是字节跳动的国产大模型,所以我们不需要考虑科学上网等复杂问题。
接着就可以让 AI 干活了,我们可以把原生 CSS 抽离到单独的样式文件中,然后让 AI 把它转译为 SCSS 版本,只需要通过聊天的方式命令 AI 执行任务即可,不需要我们手动敲一行代码。
MarsCode 比较人性化的一点是,生成的代码可以直接一键保存到新文件中,然后我们可以测试生成的 SCSS 代码是否和原生版本等效,如果效果有偏差,可以尝试多生成几次。
我这里生成的 SCSS 代码也可以正常工作,因为样式逻辑并不复杂,但所有原生 CSS 都被重构为 SCSS 的嵌套语法。
毋庸置疑,在代码编译或重构方面,AI 可以明显提高我们的生产力,哪怕是复杂的样式代码也不例外。
03. 样式构建
目前前端工程中,大部分项目可能会依赖 Vite 工具链构建,因此我们也可以引入 Vite,再集成需要的 CSS 框架。
Vite 配置在官方文档有具体介绍,以 SCSS 为例,我们需要安装模块,然后更改配置文档。
实际测试中,我偷懒不看文档,而是直接询问 AI 助手如何配置,MarsCode 虽然给出了答案,但是答案未必有效,可能出现配置失败,或者配置生效,但不是最佳配置的情况,我猜可能跟目前 MarsCode 的预训练模型的局限性有关。
这也说明和 AI 编程助手一起使用时,我们最好还是有对应 CSS 框架的知识储备,才能放心地偷懒,遇到 bug 也能了然于胸。
另外,在 CSS 框架选型方面,目前我更推荐 UnoCSS,因为它是一个同构引擎,这意味着,UnoCSS 默认兼容 Tailwind 同款语法,也能够支持类似 SCSS 的功能,更加通用。
在 AI 生成代码过程中,不同 CSS 框架语法本身不会给 AI 带来太大负担,我们同样只需要通过对话,就能生成对应框架的代码。
比如我让 MarsCode 生成的 UnoCSS/Tailwind 代码,也能一键实现相同的样式效果。
高潮总结
CSS 框架或预处理器的本质是提供了某些比原生 CSS 高级的语法,方便我们在前端工程中实现可维护的样式架构,但它们最终还是要编译为原生 CSS。
一般而言,在不同的 CSS 框架中迁移,我们需要重新学习和手动重构,AI 编程助手可以辅助我们一键迁移。
在 VSCode 中,我们可以借助 MarsCode 插件,轻松地将原生 CSS 代码重构为不同 CSS 框架的代码,无需手动敲一行代码,这提高了我们的开发效率,但同时也要注意 AI 工具的局限性。
目前 AI 无法淘汰程序员,但 AI 会淘汰不懂 AI 的程序员。你可以注册和安装 VSCode 插件,在 VSCode 中提前尝试 AIGC 时代的低代码编程方式。
官方链接和二维码在这里分享给大家:http://www.marscode.cn/events/s/ik…
#豆包MarsCode 双节创意征文话题 #豆包 MarsCode 放码过来
来源:juejin.cn/post/7424016262094012443
纠结多年终于决定彻底放弃Tailwindcss
上图来源Fireship
团队代价=深入成本
Tailwindcss很容易火,因为从看到它的的第一眼就会觉得超级直观、超级简单、超级方便,所以无论是写文章 出视频 都很容易吸引一波流量。
但是真要用在企业级应用我们需要考虑全部的复杂性,你至少要吃透官方文档的大部分内容,最好是写几个稍微复杂点的Demo,光是吃透文档就需要至少10小时以上的成本你才能彻底在企业级应用all in tailwind,如果你的团队有10名前端同学,你们将会付出100个小时的代价,这些代价不光是个人的,更是企业的损失,而花了这100小时掌握之后能够靠那一点点便捷提速弥补损失吗?不能。或许100小时早就用以前的方式写完了全部样式。团队还会扩大,新招进来的同学还得培训一下。
范式强偏好
Tailwindcss是非常opinionated强偏好的,他会鼓励一种他们特定的范式和规则,这种规则不是通用的,是tw创新的。那scss less是不是强偏好呢?不是,因为你还是以标准的css范式书写,只是scss less给你提供了额外的语法和工具方法 你的范式没有改变。
tw强偏好的范式包括不限于:
- tailwindconfig 配置文件
- 默认主题、工具类
- 行内class书写规则
- IDE插件
强偏好本身没有对错之分,通常我们使用UI组件库就是强偏好的,但是对于样式的书写,这种强偏好会缺少一定的规范一致性,说白了就是潜规则太多了。
强IDE插件依赖
没有IDE的插件提示,tw基本不可用,因为你可用的类名强依赖与上面说的范式中的tailwindconfig配置文件。
但是这好像也没什么问题,装个插件也很轻松,事情没这么简单,你失去了静态类型检查 和可测试性。假设你写了个错误的类名shadows,tailwind插件可不会给你报红,而传统的css样式文件是会报红的。
既然静态阶段无法发现这个错误,那编译时能不能发现呢?也不能,tw会将主题中未定义的类名 当成你自己的类名,所以对tw来说不是错误。
单元测试?很遗憾,也不行,这个范式最大的好处也是最大的缺点,样式全部在类目中,你不可能去equal所有的类名 这样就没有用tw的意义了。
所以tw最方便的地方,也是最容易出错且难以被发现的地方。
完全错误的主题范式
官方文档提供了Dark Mode暗色主题切换的方式,但是如果现在客户提个需求,需要增加4套颜色主题 和亮暗色无关 就是额外的主题,你会发现tw根本没有考虑到这点(或者说很难实现,网上几乎没有解决方案,我有但我不说😝(下面补充解释了)
tw是通过类名中以dark:
前缀开头来表示暗色下的样式,默认不加就是亮色, 所以你根本无法增加这两种主题以外的更多主题,你只能在亮色暗色这两之间切换,这就是tw官方强偏好导致的弊端。
我们假设,即使tw实现了可以增加主题前缀比如 onedark:
monokai:
...,那么你需要在每一个元素类名上 书写所有这些前缀的样式
<div
className="
bg-blue-500 //亮色
dark:bg-blue-700 //暗色
onedark:bg-black-900 //onedark主题
monokai:bg-gold-600 //monokai主题
kanagawa:bg-green-200 //kanagawa主题
"
></div>
真的你会写疯掉,因为每增加一个主题,意味着你要在源码中所有元素身上加上新的主题和样式类名,想象一下如果有20个主题,你一个标签的类名可能就占了100行。
并且你无法动态增加主题,因为tw是编译时的,生产环境下,你无法实现让用户自己任意配置主题、持久、载入这样的功能。
总结
文章还会更新,想当什么补充什么。以上几个最大的痛点是导致我对这个库关注多年,尝试多次,却迟迟没有投入使用,最终决定放弃的原因。我相信肯定很多同学会有同感,也会有很多持反对意见,非常欢迎评论区讨论,如果真能解决这几个大痛点,我会毅然决然All in tw。
↓↓↓↓🆕 以下为更新内容 时间升序↓↓↓↓
2024-08-22 17:45:21
难以调试
实现复杂UI会让类名又臭又长,无法根据类名理解样式,影响对html结构的浏览
来对比看一下传统类名的可读性,这是MDN网站的类名,干净整洁,一眼就知道每一个标签块代表什么内容
类名即样式导致dev tool中无法通过style面板根据特定一类元素修改样式,因为你改的是工具类名 而不是一类元素的类名,例如修改.text-left {text-align: right} 会将所有元素的样式修改完全不符合预期
菜就多练?
好吧我猜到评论区会有此类不和谐的声音,怪我没有事先叠甲,但是文章的开始其实已经说的很清楚了,个人能力再强是没用的,开发从来不是一个人的事,在公司需要跟同事配合,在开源社区需要和世界各地的开源爱好者协作。
如果你是组长、技术经理、CTO甚至老板,你一定要站在团队的角度对新兴技术栈评估收益比,因为对于企业来说商业价值永远是第一位的,所以你不能只考虑自己的效率,还要考虑团队整体的效率和质量。
如果你是开源作者,你也要为贡献者的参与门槛考虑,如果你的技术栈不是主流 只是一小挫人会用 甚至难度极高,那么你很难收获世界各地的爱心,只能自己一个人默默发电。你甚至要考虑技术栈的可替换性,因为我们大部分的依赖库都是开源的,人家也是为爱发电,意味着人家很有可能哪天累了不再维护了,你要留足能够用其他库或框架平滑替换的可能,否则为了某个库的废弃你可能需要做大量的重构工作甚至Breaking Change破坏性升级,再甚至你也没办法坚持下去了,因为你花了大量的时间在填坑而不是专注于自己项目的开发。
复杂性守恒原理
泰斯勒定律 复杂性守恒,临界复杂性不能凭空消失 只能被转移。我经常用这个原理审视各种新兴技术栈的两面性,因为你们懂得-前端娱乐圈,经常会出现很多让你快乐的新东西,而往往容易忽视背后的代价。当我们收获巨大的简化后,一定要思考 曾经的复杂性被转移到哪里去了呢?如果你能搞清楚两面性,仔细评估后再做决定,会走的更顺。
就如上面所述,tw在简化的背后,牺牲了静态类型检查、单元测试、调试、运行时动态主题载入、文档强依赖、IDE插件强依赖、构建工具强依赖等等诸多缺点。
2024-08-23 17:53:51
关于tw错误主题范式的补充
掘友还是很多大佬的,评论区发表了很多关于解决主题受限于默认和dark这两种的局限,这里补充一下我自己的方式
tw配置文件 theme中不配置darkMode,将所有主题值绑定css变量如 colors: {primary: 'var(--primary)'}
,然后依然是动态控制css变量来切换主题。
坏处很多: classname不再允许使用dark变量;tw配置麻烦,变量套变量需要两边维护,css变量文件 和tw配置要保持同步;
我为什么前面不把解决办法说出来?因为不重要。
tw是范式强偏好的,首先理解什么是范式?我的理解是很有fan的方式就是范式,有fan的前提是流行、统一。
所以我们在使用强偏好的库时,一定要在范式之内,不要自己创新方式,否则你会脱离流行、统一、一致性,并且会因为库的迭代升级导致适配问题,并且会与其他人无法达成共识 你们之间的技术经验产生越来越大的偏差 为合作带来困扰。
如果我遵循tw范式不魔改,与别人协作只需要告诉别人“看tw文档就行了”;但如果我不遵循,魔改了主题范式,我需要格外提醒别人“注意! 我们的主题不是按tw用的 请不要写dark:xxx”
这还只是一例,如果项目中有10例你自己创新的范式别人就很难快速上手了
2024-08-24 00:36:52
与tw同样方便的CSS in JS用法
上图是react中使用styled-component(后面简称sc)结合style工具写的一段样式,它既能拥有sc组件即样式的好处 又能拥有类似vue中样式分离且scoped的方便。
还能倒过来,jsx在上面,style在下面
通用组件通常用sc组件即样式来定义
const Button = styled.button`...`
view组件(业务域)通常结合sc组件和style来灵活使用
...
return style`font-size: 12px;`(
<div>...</div>
)
我们还能将常用样式抽离出来,达到如同tw的方便程度
return style`
${[button.md, bg.200, text.color.primary]}
`(<Button>...</Button>)
如果样式很长,你可以抽离,也可以直接折叠,完全不需要像tw那样还需要vscode插件
篇幅关系,只是简单介绍,后续可能单独出个文章细讲css in js
2024-08-27 12:52:07
@apply解决类名过多的问题?
评论区出现多个建议用@apply
在css复用类名的样式来减少class中书写类名,因此觉得有必要单独拿出来讲一下给大家避坑。
结论是千万万万不要这么用!这就是文档没看完,上手就用犯的错误,10小时的学习成本你是逃不掉的,不然以后麻烦就会找上你。
在tw官方文档中明确强调不要以减少类名为目的使用@apply
,鼓励你就把所有类名写在class中。
上图来源 tailwindcss.com/docs/reusin…
除此之外,@apply语法其实是一个被废弃的css标准语法,曾经在chromium内核中被短暂实现过,后来废弃掉了,废弃的原因也是因为会破坏常规书写类名+样式的范式,会导致用户无节制的重用类名样式,最终无法溯源,修改困难。
来源:juejin.cn/post/7405449753741328393
前端大佬都在用的useFetcher究竟有多强?
useFetcher:让数据管理变得如此简单
大家好,今天我要和你们分享一个让我惊喜万分的小工具——useFetcher。说实话,第一次用它的时候,我感觉自己像是发现了新大陆!它彻底改变了我处理数据预加载和跨组件更新的方式。
alovajs简介
在介绍useFetcher之前,我们先来聊聊alovajs。它是一个革命性的新一代请求工具,可以大大简化我们的API集成流程。 与react-query和swrjs等hooks库不同,alovajs提供了针对各种请求场景的完整解决方案。
alovajs的强大之处在于:
- 它将API的集成从7个步骤降低为只需要1个步骤
- 提供了15+个针对特定场景的"请求策略"
- 不仅能在客户端使用,还提供了服务端的请求策略
如果你想深入了解alovajs,强烈推荐去官网 alova.js.org 看看。相信你会像我一样,被它的强大功能所吸引。
useFetcher的妙用
现在,让我们聚焦到今天的主角——useFetcher。这个小工具真的太棒了,它让我轻松实现了一些以前觉得很复杂的功能。
数据预加载
想象一下,你正在开发一个分页列表,希望在用户浏览当前页面时就预加载下一页的数据。useFetcher可以轻松实现这一点:
const { fetch } = useFetcher({ updateState: false });
const currentPage = ref(1);
const { data } = useWatcher(() => getTodoList(currentPage.value), [currentPage], {
immediate: true
}).onSuccess(() => {
fetch(getTodoList(currentPage.value + 1));
});
这段代码会在当前页加载成功后,自动预加载下一页的数据。是不是感觉很简单?我第一次实现这个功能时,都被自己的效率惊到了!
跨组件更新
另一个让我惊喜的功能是跨组件更新。假设你在一个组件中修改了todo数据,想要在另一个组件中更新列表。useFetcher配合method快照匹配器可以轻松实现:
const { fetch } = useFetcher();
const handleSubmit = () => {
// 提交数据...
const lastMethod = alovaInstance.snapshots.match({
name: 'todoList',
filter: (method, index, ary) => index === ary.length - 1
}, true);
if (lastMethod) {
await fetch(lastMethod);
}
};
这段代码会在提交数据后,自动找到最后一个名为'todoList'的method实例并重新获取数据,从而更新列表。这种优雅的数据管理方式,让我的代码结构变得更加清晰了。
总结
useFetcher真的改变了我对数据管理的看法。它不仅可以帮我们实现数据预加载,还能轻松处理跨组件更新的问题。使用它,我们可以写出更加高效、更加优雅的代码。
你们平时是怎么处理这些数据管理的问题的呢?有没有遇到过什么困难?我很好奇大家的经验和想法,欢迎在评论区分享。如果这篇文章对你有帮助,别忘了点个赞哦!让我们一起探讨,一起进步!
来源:juejin.cn/post/7425225508613816320
解析vue中nextTick
在 Vue.js 中,nextTick
是一个非常重要的函数,它用于延迟回调函数的执行,直到下次 DOM 更新循环之后执行。这是因为 Vue 的数据绑定系统需要确保在数据变化后 DOM 被更新之前不执行某些依赖于 DOM 状态的操作。
实现原理
Vue 的 nextTick
主要是为了处理异步更新队列的问题。当数据发生变化时,并不是立刻更新 DOM,而是将这个更新放入一个队列中,等到当前执行栈清空时(也就是当前同步任务执行完毕),Vue 会进行一次更新,将所有需要更新的内容一次性渲染到 DOM 中,这样做是为了减少 DOM 操作次数,提高性能。
在 Vue 2 中的实现
在 Vue 2 中,nextTick
的实现在 src/core/util/next-tick.js
文件里。它利用了 setTimeout
, setImmediate
(如果支持的话),以及 MessageChannel
这几种不同的异步方法来实现。如果浏览器环境支持 Promise
,那么它也会使用 Promise.then
来实现异步操作。
Javascript
深色版本
function nextTick (cb, ctx) {
var _resolve
callbacks.push(function () {
if (cb) {
try {
cb.call(ctx)
} catch (e) {
handleError(e, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
if (!pending) {
pending = true
if (typeof Promise !== 'undefined' && isNative(Promise)) {
// 使用 Promise 的话,创建一个 resolve 函数,并立即调用它。
var resolve = Promise.resolve
// 使用 then 回调来执行
resolve.call(Promise, function () { _resolve = $internal; })
} else {
// 如果没有 Promise 支持,就退而求其次使用 setImmediate 或 setTimeout
setTimeout(_flushCallbacks, 0)
}
}
}
这里的关键在于 Promise.resolve()
后面跟着的 .then()
会放到 JavaScript 引擎的微任务队列中,等待当前执行栈清空后执行。这样可以确保 nextTick
的回调是在所有同步任务完成后被执行。
在 Vue 3 中的实现
Vue 3 中使用了更现代的方法来实现 nextTick
,主要是基于 queueMicrotask
API。这个 API 可以让回调函数在当前任务完成后的下一个微任务时机执行。如果没有 queueMicrotask
支持,则退回到 Promise
的 .then()
方法。
Javascript
深色版本
export function nextTick(cb?: Function, delay = 0): PromiseLike<any> | void {
if (cb) {
// 如果提供了回调函数,则注册回调
registerFlushSyncCallback(() => {
queueMicrotask(() => {
// 使用 queueMicrotask 来确保回调是在微任务时机执行
// 这样可以保证在任何其他异步任务之前执行
callWithAsyncErrorHandling(cb, activeInstance, SchedulerGuards.NEXT_TICK_HANDLER);
});
});
} else {
// 如果没有提供回调,则返回一个 promise
return new Promise((res) => {
queueMicrotask(res);
});
}
}
总结来说,nextTick
的主要作用是确保某些操作在 DOM 更新之后进行,从而避免了由于数据变化但 DOM 尚未更新导致的问题。通过使用异步机制(如微任务或宏任务),nextTick
能够确保在适当的时机执行回调。
来源:juejin.cn/post/7426206782022074431
关于WebView的秒开实战
通过这篇文章你可以了解到:
- 性能优化的思维方式
- WebView进阶知识
写在前面
最近组里做新的Web容器的,一次承载多个H5页面,以实现左右切换,默认展示主会场页,并要达到提升打开率的目标。要达到这个目标,那势必要从加载优化入手,缩短页面的打开时间。优化的点包括但不限于,Activity初始化、ViewPager和Fragment的初始化、WebView的初始化等等。
上一片文章给大家分享了我在ViewPager上面做的优化,本篇文章再接着给大家分享下WebView秒开的尝试。
优化效果
我们以提升打开率为目标,口径是资源位点击到WebView的onPageFinished。
使用新的容器之后,打开率提升了大约10%-20%(65%—>85%),在低端机上的提升较为明显。为了让各位同学更加直观的感受到优化后的效果,这里用两张图简化的流程图来表示:
以上是我们的容器简略的加载过程需经过6个步骤,加载时长从Activity的onCreate开始计算到WebView的onPageFinished大约需要3000ms(低端机)。很显然,在如今这个快节奏的社会,用户是不会等待这么长时间的。为此我们对它进行了一场手术,把它整成了下边的样子:
???what's the ****?玩俄罗斯方块吗?
同学憋急,你现在只需要关注的是:它由6个冗长的步骤,变成了两个步骤(Na组件放在了WebView初始化完成后加载),大大缩减了我们首页的加载时间。关于你的疑问,我会在下边的章节解释。
过程分析
上一节我们讲到,一次完整的打开过程需要经过6个步骤,经过了我们大刀阔斧的改造后,只需要两个步骤。这节接着给大家剖析我们这么做的底层逻辑。
Native优化
资源预加载
WebView组件加载过程有三处网络耗时分别是主文档HTML的加载、JS/CSS的加载和内容数据的加载,串行的流程是效率及其低下的。那么我们是不是改成并行的?当然不能!
- 主文档HTML其实就是一个H5的框架,一个页面内所有的资源都是先通过主文档来触发加载,在主文档被加载之前我们是不能知道有哪些JS和CSS文件的。
- 内容数据(包括图片)是由我们的业务方决定的,涵盖了各个营销场景,不像新闻浏览类的页面有固定的排版样式。由于页面不统一,单独对它进行下载再注入的改造成本有点大。(后续的离线化方案可实现)
基于上述两点,可取的做法是:先把主文档数据预取后缓存,待WebView loadUrl之后,通过WebViewClient的监听去拦截主文档的请求
@Nullable
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
for (RequestInterceptor interceptor : Interceptors) {
//拦截请求,去缓存中取出主文档数据
WebResourceResponse response = interceptor.intercept(view, request);
if (response != null) {
return response;
}
}
return super.shouldInterceptRequest(view, request);
}
预加载时机
预加载放在点击资源位(资源位在首页区域)之前是最理想的,这也是我们的初步设想。但是涉及到首页模块的改造,需要对应组件方的配合支持,会导致开发周期的延长。所以我们决定在第一版以HOOK Instumentation的方式,在点击资源位之后,Activity的onCreate之前去开启子线程对主文档进行预加载。
同时跟首页组件方协调方案:在首页的T2阶段(不影响其它优先级更高的任务)对资源位进行预加载,点击后如果首页预加载成功则直接打开Activity,否则继续Instrumentation加载逻辑。
JS/CSS预加载
主文档加载完成了之后,可以对缓存的数据进行识别查找到需要加载的JS/CSS文件,紧接着开始进行JS/CSS的预加载。
下面时查找JS文件的伪代码:
private static final String JS_PATTERN = "<script\\s+[^>]*?src=[\"']([^\"']+)[\"'][^>]*>(?:<\\/script>)?";
/**
*@param htmlData 将主文档的二进制文件转换成的String类型
*@param JSPattern 用于从主文档内匹配JS文件的正则表达式
*/
private void recognitionJS(String htmlData, String JSPattern) {
try {
Pattern scriptPattern = Pattern.compile(JSPattern, Pattern.CASE_INSENSITIVE);
Matcher scriptMatcher = scriptPattern.matcher(htmlData);
while (scriptMatcher.find()) {
String link = scriptMatcher.group(1) + "";
if (TextUtils.isEmpty(link)) {
continue;
}
mResSet.add(link);
}
} catch (Exception e) {
}
}
这样一来我们的流程在第二版就变成了:
到这里我们在数据请求这一块所做的优化就结束了,那么我们的矛头接下来该指向哪里?
WebView预热
首次创建耗时较长
我从埋点的数据中发现,容器冷启打开的时间比热启要长的多,从Activity onCreate到WebView loadUrl之前的耗时比起热启大约慢了200多ms。这个过程中初始化的组件除了WebView还有有ViewPager和Fragment,通过再次细分阶段的埋点统计耗时发现,启动方式对这两者的初始化时间影响不大,WebView初始化时间自然就成了我们攻克的对象。
我们找来了其它几个机型重复上述的步骤,高端机上表现并不明显,但也存在差异(大约80ms)。进一步确定了是WebView自身的原因,可以得出结论:WebView第一次初始化的时间会比后续创建的时间长,具体差异取决于机型性能。
WebView Pool
利用前面得出的结论,可以在App启动时开始WebView的第一次初始化再销毁,以减少后续使用过程的创建时间。但还是避免不了往后创建带来的时间开销,这个时候池化技术就呼之欲出了。
我们可以将创建好的WebView放入容器中,可以一个也可以多个,取决于业务。由于创建WebView需要和Context绑定,而预创建WebView是无法提前获知所需要挂载的Activity的,为此我们找到了MutableContextWrappe。引用官方对它的介绍:
Special version of ContextWrapper that allows the base context to be modified after it is initially set. Change the base context for this ContextWrapper. All calls will then be delegated to the base context. Unlike ContextWrapper, the base context can be changed even after one is already set.
翻译成人话:它允许在运行时动态地更改 Context。这在某些特定场景下非常有用,例如,当您需要在不同的 Context 之间灵活切换或修改 Context 时。真是完美的解决了我们预创建绑定Context的问题!
//预创建WebView,存入缓存池
MutableContextWrapper contextWrapper = new MutableContextWrapper(getAppContext());
mWebViewPool.push(new WebView(contextWrapper));
//取出WebView,替换我们所需要的Context
WebView webView = mWebViewPool.pop();
MutableContextWrapper contextWrapper = (MutableContextWrapper) webView.getContext();
contextWrapper.setBaseContext(activityContext);
看到这里,如果你是不是以为WebView的池化就这样结束了?
那是不可能滴
那是不可能滴
那是不可能滴
子进程承接
众所周知,一个亿级DAU的商业化App是非常庞杂的。在App启动时,有许多的任务需要初始化,势必会带来很大的性能开销。如果在这个阶段进行WebView的创建和池化的操作。前者可能会引出ANR,后者则是会面临内存溢出的风险。一波刚平,一波又起!
怎么办?再开个线程?WebView不能在子线程初始化,即使可以也解决不了内存开销的问题。PASS!
线程不行,进程呢?Bingo!
我们可以在App启动时开启一个子进程,在子进程进行WebView的初始化和池化的任务。系统会为子进程重新开辟内存空间,同时在子进程创建WebView也不会阻塞主进程的主线程,顺带也可以提高我们主进程的稳定性,可谓是一举多得。整个加载流程也就变成了三个大步骤。
组件懒加载
上一篇文章里有讲到我们容器的页面结构,没看过的请点击这里。在开始WebView加载之前会经过ViewPager和Fragment的初始化,经过线下实验统计,省去这两玩意儿大约可以提升67%(口径:Activity onCreate到WebView loadUrl,也就是说ViewPager和Fragment占这个过程的67%)。这不,优化点又来了。
打开容器的第一阶段,只需加载一个页面。因此我们可以将WebView直接放在Activity上显示,无需ViewPager和Fragment的介入,等到首页加载完成后再初始化这两组件,并开始缓存其它页面。
到这里我们的加载流程就变成了开头的样子了:
不要抬杠:你开头画的也不是这个样子的啊?。
咱这不是为了更方便的理解,所以在开头小小的抽象了一下吗。手动狗头
其它优化
剩下还有一些前端的通用优化方式、网络通用优化方式在网上有同学总结的很清楚,在这里我就不一一列举。感兴趣的可以跳转对应文章进行查阅
来源:juejin.cn/post/7364283070869028899
如何开发一个chrome 扩展
前言
最近开发一个涉及到很多颜色转换的工作,每次都搜索打开一个新页面在线转换,十分麻烦,于是想着开发一个颜色转换的浏览器插件,每次点击即可使用。
查看Chrome插件开发的文档developer.chrome.com/docs/extens… ,从头开始开发一个插件还是比较麻烦且原始的。搜索网上资料,发现了2个工具
- CRXJS: github.com/crxjs/chrom…
- Plasmo: github.com/PlasmoHQ/pl…
- WXT: github.com/wxt-dev/wxt
最后选择了WXT,因为它用起来更方便,且支持多浏览器。
WXT是什么
WXT号称下一代浏览器扩展开发框架
,免费、开源、易用且支持多种浏览器。
这段文字是关于WXT框架的介绍,它是一个用于构建浏览器扩展的开源框架。下面是对文中提到的几个关键点的解释:
- WXT有自己一套约定的框架,为开发者提供了一套标准化的做法,有助于保持项目的一致性,使得新手能够更容易地理解和接手项目。
- 基于项目文件结构自动生成manifest。manifest是浏览器扩展的配置文件,定义了扩展的名称、版本、权限等信息。WXT框架能够根据项目结构自动创建这个文件,简化开发过程。
- 单文件配置Entrypoint,比如背景脚本或内容脚本,这样可以更直观地管理和维护代码。
- WXT提供了开箱即用的TypeScript支持,并且改进了浏览器API的类型定义。TypeScript是一种强类型语言,它在JavaScript的基础上增加了类型系统,有助于在开发过程中捕捉到潜在的错误。
- 输出文件的路径最小化,这意味着WXT在构建扩展时会优化文件路径,减少runtime的path长度,可以提高扩展的加载速度和性能。
WXT 安装&开发
我们直接脚手架开一个项目
pnpm dlx wxt@latest init wxt-demo
cd wxt-demo
pnpm install
套用官网的一个图
不过我选的react,生成的工作目录文件如下
调试运行pnpm dev
,WXT直接开了一个无头浏览器,可以实时看到效果
颜色转换开发
因为我的需求比较简单,实现各种颜色的转换,页面UI就直接使用antd,样式直接Inline。代码如下:
<>
<header style={pageLayoutStyle}>
<p style={{ fontSize: '1.5rem', textAlign: 'center', fontWeight: 'medium' }}>Color Converter</p>
<p>This tool helps you convert colors between different color formats.</p>
</header>
<main style={pageLayoutStyle}>
<div>
<p style={{ fontSize: '1.2rem', textAlign: 'left' }}>Enter a color:</p>
</div>
<Input
suffix={
<ColorPicker
defaultValue={defaultColor}
value={hex === '' ? defaultColor : hex}
styles={{ popupOverlayInner: { position: 'absolute', left: '50%', transform: 'translate(-100%, -50%)' } }}
onChangeComplete={(color) => {
const str = (color.toRgbString())
}} />
}
placeholder={defaultColor}
autoFocus={true}
onChange={(e) => {
const str = (e.target.value)
}} />
<div>
<p style={{ fontSize: '1.2rem', textAlign: 'left' }}>Results</p>
</div>
{contextHolder}
<Input addonBefore="RGB" value={rgb} suffix={<CopyOutlined onClick={() => { copyToClipboard(rgb) }} />} readOnly={true} defaultValue="" />
<Input addonBefore="HEX" value={hex} suffix={<CopyOutlined onClick={() => { copyToClipboard(hex) }} />} readOnly={true} defaultValue="" style={{ marginTop: '8px' }} />
<Input addonBefore="HSL" value={hsl} suffix={<CopyOutlined onClick={() => { copyToClipboard(hsl) }} />} readOnly={true} defaultValue="" style={{ marginTop: '8px' }} />
<Input addonBefore="HSV" value={hsv} suffix={<CopyOutlined onClick={() => { copyToClipboard(hsv) }} />} readOnly={true} defaultValue="" style={{ marginTop: '8px' }} />
<Input addonBefore="CMYK" value={cmyk} suffix={<CopyOutlined onClick={() => { copyToClipboard(cmyk) }} />} readOnly={true} defaultValue="" style={{ marginTop: '8px' }} />
</main>
</>
新建color.ts,定义几个变量名称和方法名称,一路按tab,AI自动补全了代码。代码太长就不贴出来了,主要是颜色的正则匹配和转换。同上面UI绑定后,最终实现效果如下:
在调试firefox时,遇到一个小坑:content.ts中需要至少有一个匹配matches,否则会直接退出提示插件invalid。
发布
WXT发布也比较简单,直接运行 pnpm zip
就会构建chrome的扩展压缩包,发布firefox只需要pnpm zip:firefox
。在ouput目录下就会生成对应产物。
不过记得在打包前修改wxt.config.ts,添加名称、版本、描述等。如:
export default defineConfig({
modules: ['@wxt-dev/module-react'],
manifest: {
version: '1.0.0',
name: 'color-converter',
description: 'A color converter tool',
}
});
最后完整代码见github: github.com/xckevin/col…
现在插件也已经上架了市场,欢迎下载:
- chrome: chromewebstore.google.com/detail/colo…
- firefox: addons.mozilla.org/en-US/firef…
来源:juejin.cn/post/7425803259443019815
课表拖拽(一)拖拽实现
最近接到一个任务,要求实现一个课表拖拽的功能,支持快速修改个人日程时间。项目采用taro框架。
基于性能的抉择
container采用grid布局,7colum+12row,共84个单元格
拖拽的方式有两种
盒子跟随手指,并实时显示松手后落入的位置,松手时寻找一个离手指最近的单元格放入
盒子实时在格子之内,根据手指位置实时计算填入的格子,将盒子放入
哪一种性能更高??????
显然第一种方案在第二种方案上多了盒子实时跟随手指这个额外操作,性能不占优势。
catch-move避免滑动穿透
因为课表支持左右滑动查看自己每一周的课程安排,采用了一个Swiper
包裹在container之外,在滑动时会带动Swiper的滑动,那该怎么办?????????
不妨请教学长,经过学长的指导,告诉了我一个api
不得不感慨阅读官方文档的重要性(老实了,以后必须多看官方文档)
如何根据手指的位置,计算所在单元格
const unitwith = 350 / 7;
const unitheight = 600 / 12;
先得到了每一个单元格的宽高
然后通过滑动的事件对象可以获取当前的(x,y),那么动态设置grid样式就可以实现
const getGridPositionByXY = (xp, yp) => {
return `gridColumn:${Math.floor(xp / unitwith)} ;gridRow:${Math.floor(
yp / unitheight
)}/${Math.floor(yp / unitheight) + 2}`;
};
handleTouchMove函数的实现
我们需要两个响应式的变量x,y
,通过在handleTouchMove
函数中修改x,y
来带动style的修改
const [x, setX] = useState(350 / 7);
const [y, setY] = useState(600 / 12);
const handleTouchMove =(e) => {
setX(e.changedTouches[0].clientX + 50);
setY(e.changedTouches[0].clientY + 50);
};
性能优化(节流)
节流:在一定时间内,无论函数被触发多少次,函数只会在固定的时间间隔内执行一次
为防止handleTouchMove
的触发频率太高,我们采用节流函数来让它在固定时间内只执行一次
function Throttle(fn, delay) {
let timer = null;
return function () {
if (timer) return;
timer = setTimeout(() => {
fn.apply(this, arguments);
timer = null;
}, delay);
};
}
const handleTouchMove = Throttle((e) => {
setX(e.changedTouches[0].clientX + 50);
setY(e.changedTouches[0].clientY + 50);
}, 10);
提升交互性
我们可以让用户长按激活,随后才能滑动,并且在激活的时候触发震动
直接贴完整代码在这里
import { View, Swiper, SwiperItem } from "@tarojs/components";
import { useState, useRef } from "react";
import Taro, { useLoad } from "@tarojs/taro";
import "./index.css";
function Throttle(fn, delay) {
let timer = null;
return function () {
if (timer) return;
timer = setTimeout(() => {
fn.apply(this, arguments);
timer = null;
}, delay);
};
}
export default function Index() {
const [isLongPress, setIsLongPress] = useState(false);
useLoad(() => {
console.log("Page loaded.");
});
const timer = useRef(null);
const [x, setX] = useState(350 / 7);
const [y, setY] = useState(600 / 12);
// const [StartPosition, setStartPosition] = useState({ x: 0, y: 0 });
const unitwith = 350 / 7;
const unitheight = 600 / 12;
const getGridPositionByXY = (xp, yp) => {
return `gridColumn:${Math.floor(xp / unitwith)} ;gridRow:${Math.floor(
yp / unitheight
)}/${Math.floor(yp / unitheight) + 2}`;
};
const handleTouchMove = Throttle((e) => {
if (!isLongPress) return;
setX(e.changedTouches[0].clientX + 50);
setY(e.changedTouches[0].clientY + 50);
}, 10);
return (
<View className='index'>
<Swiper circular style={{ width: "100vw", height: "100vh" }}>
<SwiperItem>
<view className='container'>
<view
style={getGridPositionByXY(x, y)}
className={`items-1 ${isLongPress ? "pressActive" : ""}`}
catch-move
onTouchStart={() => {
timer.current = setTimeout(() => {
setIsLongPress(true);
Taro.vibrateShort();
// console.log("长按");
}, 1000);
}}
onTouchMove={handleTouchMove}
onTouchEnd={() => {
clearTimeout(timer.current);
setIsLongPress(false);
}}
></view>
<view className="items-2">2</view>
</view>
</SwiperItem>
<SwiperItem>
<view className="container">
<view className="items-1">no</view>
</view>
</SwiperItem>
<SwiperItem>
<view className="container">
<view className="items-1" catch-move></view>
</view>
</SwiperItem>
<SwiperItem>
<view className="container">
<view className="items-1" catch-move></view>
</view>
</SwiperItem>
<SwiperItem>
<view className="container">
<view className="items-1" catch-move></view>
</view>
</SwiperItem>
</Swiper>
</View>
);
}
//index.css
.container {
width: 700px;
height: 1200px;
background-color: #ccc;
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: repeat(12, 1fr);
}
.griditems-1 {
border: 1px solid #fff;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background-color: cadetblue;
}
.griditems-2 {
border: 1px solid #fff;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background-color: aquamarine;
}
.griditems-3 {
border: 1px solid #fff;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background-color: burlywood;
}
.griditems-4 {
border: 1px solid #fff;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background-color: darkcyan;
}
.griditems-5 {
border: 1px solid #fff;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background-color: darkgoldenrod;
}
.items-1 {
grid-column: 1; /* 从第1列开始,到第2列结束 */
grid-row: 1 / 4;
border-radius: 10px;
border: 1px solid #fff;
background-color: burlywood;
}
.items-2 {
grid-column: 3;
grid-row: 1 / 4;
border-radius: 10px;
border: 1px solid #fff;
background-color: burlywood;
}
.pressActive {
border-radius: 10px;
border: 1px solid #fff;
background-color: #fff;
opacity: 0.5;
}
下一期将介绍如何控制方块不重合,以及在展开后方块的处理和对多方块的情况怎么单独管理每一个方块的情况
来源:juejin.cn/post/7425562027412815882
你是否遇到过断网检测的需求?
你也碰到断网检测的需求啦?
一般的断网检测需求,大部分都是在用户网络状态不好或者网络掉线时,我们给用户一个提示或者引导界面,防止用户不知道自己卡了在那里一直等待。
方案1,轮询请求
直接说最有效的方案,我们通过轮训请求来检测网络是否可用,比如加载图片或者访问接口等...
下面我以加载图片为例,搞个小demo:
- 首先尽可能的找一个小的图片,不要让图片的请求堵塞我们的其他功能使用。
推荐一个图片在线的压缩的网站: https://www.yalijuda.com/ ,然后把图片上到内部的服务器上
- 既然搞了,就搞个通用的,使用
tsup
我们搞个npm包,然后想一想我们要搞的功能,先把入口函数的出入参类型定了。我们既然要做轮训请求图片,我们首先需要一个图片地址,然后请求后的回调事件,甚至可能需要一些控制参数,那我们的入口代码也就有了。
const request = (imgUrl) => {
// do something
};
type CheckNetworkOptionsType = {
interval: number; // 循环时间 单位ms
};
type CheckNetworkType = (
imgUrl: string, // 图片url
callback: (isOnline: boolean) => void, // 回调,返回网络是否在线
options?: CheckNetworkOptionsType, // 配置项
) => void;
const checkNetwork: CheckNetworkType = (imgUrl, callback, options) => {
const { interval = 30_000 } = options || {};
const timer = setInterval(() => {
request(imgUrl)
}, interval);
return timer
};
export default checkNetwork
- 接下来我们要考虑一下如何进行请求,我们需要一个创一个
promise
和img标签
,resove
出去onload
和onerror
对应的在线和离线状态。
const request = (imgUrl) => {
return new Promise((resolve) => {
let imgRef = null;
let isRespond = false;
imgRef = document.createElement('img');
imgRef.onerror = () => {
isRespond = true;
resolve(false);
return '';
};
imgRef.onload = () => {
isRespond = true;
resolve(true);
return '';
};
imgRef.src = `${imgUrl}?time=${new Date().toLocaleString()}`;
});
};
type CheckNetworkOptionsType = {
interval: number; // 循环时间 单位ms
};
type CheckNetworkType = (
imgUrl: string, // 图片url
callback: (isOnline: boolean) => void, // 回调,返回网络是否在线
options?: CheckNetworkOptionsType, // 配置项
) => void;
const checkNetwork: CheckNetworkType = (imgUrl, callback, options) => {
const { interval = 30_000 } = options || {};
const timer = setInterval(async () => {
const status = (await request(imgUrl)) as boolean;
callback(status);
}, interval);
return timer
};
export default checkNetwork
- 这样基本的功能似乎就差不多了,但是感觉好像少点什么?比如服务器就是返回图片资源慢?那我们是不是可以加个超时时间?又或者是不是可以可以让用户手动取消循环?
const request = (imgUrl) => {
return new Promise((resolve) => {
let imgRef = null;
let isRespond = false;
imgRef = document.createElement('img');
imgRef.onerror = () => {
isRespond = true;
resolve(false);
return '';
};
imgRef.onload = () => {
isRespond = true;
resolve(true);
return '';
};
// 加个参数,防止浏览器缓存
imgRef.src = `${imgUrl}?time=${new Date().toLocaleString()}`;
});
};
type CheckNetworkOptionsType = {
interval: number; // 循环时间 单位ms
};
type CheckNetworkType = (
imgUrl: string, // 图片url
callback: (isOnline: boolean) => void, // 回调,返回网络是否在线
options?: CheckNetworkOptionsType, // 配置项
) => void;
const checkNetwork: CheckNetworkType = (imgUrl, callback, options) => {
const { interval = 30_000 } = options || {};
const timer = setInterval(async () => {
const status = (await request(imgUrl)) as boolean;
callback(status);
}, interval);
return timer
};
export default checkNetwork
- 完整的代码就完整了,具体用的时间还是建议大家关键模块来进行断网检测。不要一个后台配置表单都弄检测,这种完全可以在提交表单的时候接口响应进行处理,断网检测一般都是用在需要实时监控之类的。不多说了,我们来体验下:
6. 没问题,一切ok,发个包,就叫network-watcher
吧,欢迎大家star!
github.com/waltiu/netw…
方案2,直接调api
首先说下这种方案不推荐,其一浏览器兼容性有问题,其二只能检测到是否网络有连接,但是不能检测是否可用,这种实用性真的很差。
浏览器也提供了navigator.onLine
和 navigator.connection
可以直接查询网络状态,我们可以监听网络状态的变化。
window.addEventListener('online',function () {
alert("正常上网");
})
window.addEventListener('offline',function () {
alert('无网络');
})
这种实用性真的很差,用户网络连接但是没有网或者网很慢,实际上都会影响用户体验!!
用户的体验永远是NO.1
来源:juejin.cn/post/7299671709476700212
VirtualList虚拟列表
首先感谢
Vue3 封装不定高虚拟列表 hooks,复用性更好!这篇文章提供的一些思路,在此基础作者进一步对相关代码进行了一些性能上的优化(解决了通过鼠标操作滚动条时的卡顿)。因为项目没有用到ts,就先去掉了。
hooks
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
export default function useVirtualList(config) {
// 获取元素
let actualHeightContainerEl = null,
translateContainerEl = null,
scrollContainerEl = null;
// 数据源,便于后续直接访问
let dataSource = [];
onMounted(() => {
actualHeightContainerEl = document.querySelector(
config.actualHeightContainer
);
scrollContainerEl = document.querySelector(config.scrollContainer);
translateContainerEl = document.querySelector(config.translateContainer);
});
// 数据源发生变动
watch(
() => config.data.value,
(newValue) => {
// 更新数据源
dataSource = newValue;
// 计算需要渲染的数据
updateRenderData();
}
);
/*
更新相关逻辑
*/
// 更新实际高度
let flag = false;
const updateActualHeight = (oldValue, value) => {
let actualHeight = 0;
if (flag) {
// 修复偏差
actualHeight =
actualHeightContainerEl.offsetHeight -
(oldValue || config.itemHeight) +
value;
} else {
// 首次渲染
flag = true;
for (let i = 0; i < dataSource.length; i++) {
actualHeight += getItemHeightFromCache(i);
}
}
actualHeightContainerEl.style.height = `${actualHeight}px`;
};
// 缓存已渲染元素的高度
const RenderedItemsCache = {};
const RenderedItemsCacheProxy = new Proxy(RenderedItemsCache, {
get(target, key, receiver) {
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const oldValue = target[key];
const result = Reflect.set(target, key, value, receiver);
// 更新实际高度
updateActualHeight(oldValue, value);
return result;
},
});
// 更新已渲染列表项的缓存高度
const updateRenderedItemCache = (index) => {
// 当所有元素的实际高度更新完毕,就不需要重新计算高度
const shouldUpdate =
Reflect.ownKeys(RenderedItemsCacheProxy).length < dataSource.length;
if (!shouldUpdate) return;
nextTick(() => {
// 获取所有列表项元素(size条数)
const Items = Array.from(document.querySelectorAll(config.itemContainer));
// 进行缓存(通过下标作为key)
for (let i = 0; i < Items.length; i++) {
const el = Reflect.get(Items, i);
const itemIndex = index + i;
if (!Reflect.get(RenderedItemsCacheProxy, itemIndex)) {
Reflect.set(RenderedItemsCacheProxy, itemIndex, el.offsetHeight);
}
}
});
};
// 获取缓存高度,无缓存,取配置项的 itemHeight
const getItemHeightFromCache = (index) => {
const val = Reflect.get(RenderedItemsCacheProxy, index);
return val === void 0 ? config.itemHeight : val;
};
// 实际渲染的数据
const actualRenderData = ref([]);
// 更新实际渲染数据
const updateRenderData = (scrollTop = 0) => {
let startIndex = 0;
let offsetHeight = 0;
for (let i = 0; i < dataSource.length; i++) {
offsetHeight += getItemHeightFromCache(i);
// 第几个以上进行隐藏
if (offsetHeight >= scrollTop - (config.offset || 0)) {
startIndex = i;
break;
}
}
// 计算得出的渲染数据
actualRenderData.value = dataSource
.slice(startIndex, startIndex + config.size)
.map((data, idx) => {
return {
key: startIndex + idx + 1, // 为了在vue的for循环中绑定唯一key值
data,
};
});
// 缓存最新的列表项高度
updateRenderedItemCache(startIndex);
updateOffset(offsetHeight - getItemHeightFromCache(startIndex));
};
// 更新偏移值
const updateOffset = (offset) => {
translateContainerEl.style.transform = `translateY(${offset}px)`;
};
/*
注册事件、销毁事件
*/
// 滚动事件
const handleScroll = (e) =>
// 渲染正确的数据
updateRenderData(e.target.scrollTop);
// 注册滚动事件
onMounted(() => {
scrollContainerEl?.addEventListener("scroll", handleScroll);
});
// 移除滚动事件
onBeforeUnmount(() => {
scrollContainerEl?.removeEventListener("scroll", handleScroll);
});
return { actualRenderData };
}
vue
<script setup>
import { ref } from "vue";
import useVirtualList from "../utils/useVirtualList.js"; // 上面封装的hooks文件
import list from "../json/index.js"; // 造的数据模拟
const tableData = ref([]);
// 模拟异步请求
setTimeout(() => {
tableData.value = list;
}, 0);
const { actualRenderData } = useVirtualList({
data: tableData, // 列表项数据
scrollContainer: ".scroll-container", // 滚动容器
actualHeightContainer: ".actual-height-container", // 渲染实际高度的容器
translateContainer: ".translate-container", // 需要偏移的目标元素,
itemContainer: ".item", // 列表项
itemHeight: 400, // 列表项的大致高度
size: 10, // 单次渲染数量
offset: 200, // 偏移量
});
</script>
<template>
<div>
<h2>virtualList 不固定高度虚拟列表</h2>
<ul class="scroll-container">
<div class="actual-height-container">
<div class="translate-container">
<li
v-for="item in actualRenderData"
:key="item.key"
class="item"
:class="[{ 'is-odd': item.key % 2 }]"
>
<div class="item-title">第{{ item.key }}条:</div>
<div>{{ item.data }}</div>
</li>
</div>
</div>
</ul>
</div>
</template>
<style scoped>
* {
list-style: none;
padding: 0;
margin: 0;
}
.scroll-container {
border: 1px solid #000;
width: 1000px;
height: 200px;
overflow: auto;
}
.item {
border: 1px solid #ccc;
padding: 20px;
display: flex;
flex-wrap: wrap;
word-break: break-all;
}
.item.is-odd {
background-color: rgba(0, 0, 0, 0.1);
}
</style>
来源:juejin.cn/post/7425598941859102730
聊聊try...catch 与 then...catch
处理错误的两种方式:try...catch
与 then
、catch
在前端编程中,错误和异常处理是保证代码健壮性和用户体验的重要环节。JavaScript 提供了多种方式来处理错误,其中最常见的两种是 try...catch
和 Promise 的 then
、catch
,但是什么时候该用try...catch
,什么时候该用then
、catch
呢,下面将详细探讨这两种机制的区别及其适用场景。
一、try...catch
try...catch
是一种用于捕获和处理同步代码中异常的机制。其基本结构如下:
try {
// 可能会抛出异常的代码
} catch (error) {
// 处理异常
}
使用场景:
- 主要用于同步代码,尤其是在需要处理可能抛出的异常时。
- 适用于函数调用、操作对象、数组等传统代码中。
示例:
function divide(a, b) {
try {
if (b === 0) {
throw new Error("Cannot divide by zero");
}
return a / b;
} catch (error) {
console.error(error.message);
}
}
divide(4, 0); // 输出: Cannot divide by zero
在这个例子中,如果 b
为零,则会抛出一个错误,并被 catch
块捕获。
二、then
和 catch
在处理异步操作时,使用 Promise 的 then
和 catch
方法是更加常见的做法。其结构如下:
someAsyncFunction()
.then(result => {
// 处理成功的结果
})
.catch(error => {
// 处理错误
});
使用场景:
- 主要用于处理异步操作,例如网络请求、文件读取等。
- 可以串联多个 Promise 操作,清晰地处理成功和错误。
示例:
function fetchData() {
return new Promise((resolve, reject) => {
// 模拟异步操作
setTimeout(() => {
const success = Math.random() > 0.5; // 随机决定成功或失败
if (success) {
resolve("Data fetched successfully");
} else {
reject("Failed to fetch data");
}
}, 1000);
});
}
fetchData()
.then(result => {
console.log(result);
})
.catch(error => {
console.error(error);
});
在这个示例中,fetchData
函数模拟了一个异步操作,通过 Promise 来处理结果和错误。
三、async/await
与 try...catch
为了使异步代码更具可读性,JavaScript 引入了 async/await
语法。结合 try...catch
,可以让异步错误处理更加简洁:
async function fetchDataWithAwait() {
try {
const result = await fetchData();
console.log(result);
} catch (error) {
console.error(error);
}
}
fetchDataWithAwait();
总结
try...catch
:适合于同步代码,能够捕获代码块中抛出的异常。then
和catch
:用于处理 Promise 的结果和错误,适合异步操作。async/await
结合try...catch
:提供了清晰的异步错误处理方式,增强了代码的可读性。
在实际开发中,选择哪种方式取决于代码的性质(同步或异步)以及个人或团队的编码风格。
往期推荐
怎么进行跨组件通信,教你如何使用provide 和 inject🔥
来源:juejin.cn/post/7418133347543121939
用零宽字符来隐藏代码
什么是零宽度字符
一种不可打印的Unicode字符,在浏览器等环境不可见,但是真是存在,获取字符串长度时也会占位置,表示某一种控制功能的字符。
常见的零宽字符有:
空格符:格式为U+null00B,用于较长字符的换行分隔;
非断空格符:格式为U+FEFF,用于阻止特定位置的换行分隔;
连字符:格式为U+null00D,用于阿拉伯文与印度语系等文字中,使不会发生连字的字符间产生连字效果;
断字符:格式为U+200C,用于阿拉伯文、德文、印度语系等文字中,阻止会发生连字的字符间的连字效果;
左至右符:格式为U+200E,用于在混合文字方向的多种语言文本中,规定排版文字书写方向为左至右;
右至左符:格式为U+200F : 用于在混合文字方向的多种语言文本中,规定排版文字书写方向为右至左;
使用零宽字符给信息加密
(function(window) {
var rep = { // 替换用的数据,使用了4个零宽字符代理二进制
'00': '\u200b',
'0null': '\u200c',
'null0': '\u200d',
'nullnull': '\uFEFF'
};
function hide(str) {
str = str.replace(/[^\x00-\xff]/g, function(a) { // 转码 Latin-null 编码以外的字符。
return escape(a).replace('%', '\\');
});
str = str.replace(/[\s\S]/g, function(a) { // 处理二进制数据并且进行数据替换
a = a.charCodeAt().toString(2);
a = a.length < 8 ? Array(9 - a.length).join('0') + a : a;
return a.replace(/../g, function(a) {
return rep[a];
});
});
return str;
}
var tpl = '("@code".replace(/.{4}/g,function(a){var rep={"\u200b":"00","\u200c":"0null","\u200d":"null0","\uFEFF":"nullnull"};return String.fromCharCode(parseInt(a.replace(/./g, function(a) {return rep[a]}),2))}))';
window.hider = function(code, type) {
var str = hide(code); // 生成零宽字符串
str = tpl.replace('@code', str); // 生成模版
if (type === 'eval') {
str = 'eval' + str;
} else {
str = 'Function' + str + '()';
}
return str;
}
})(window);
var code = hider('测试一下');
console.log(code);
直接复制到项目中可以使用,我们现在来试试
var code = hider('测试一下');
console.log(code);
结果如下:
实际用法
功能用途
这个技术可以应用到很多领域,非常具有实用性。
比如:代码加密、数据加密、文字隐藏、内容保密、隐形水印,等等。
原理介绍
实现字符串隐形,技术原理是“零宽字符”。
在编程实现隐形字符功能时,先将字符串转为二进制,再将二进制中的1转换为\u200b;0转换为\u200c;空格转换为\u200d,最后使用\ufeff 零宽度非断空格符作分隔符。这几种unicode字符都是不可见的,因此最终转化完成并组合后,就会形成一个全不可见的“隐形”字符串。
功能源码
function text_2_binary(text){
return text.split('').map(function(char){ return char.charCodeAt(0).toString(2)}).join(' ');
}
function binary_2_hidden_text(binary){
return binary.split('').map(function (binary_num){
var num = parseInt(binary_num, 10);
if (num === 1) {
return '\u200b';
} else if(num===0) {
return '\u200c';
}
return '\u200d';
}).join('\ufeff')
}
var text = "jshaman是专业且强大的JS代码混淆加密工具";
var binary_text = text_2_binary(text);
var hidden_text = binary_2_hidden_text(binary_text);
console.log("原始字符串:",text);
console.log("二进制:",binary_text);
console.log("隐藏字符:",hidden_text,"隐藏字符长度:",hidden_text.length);
隐型还原
接下来介绍“隐形”后的内容如何还原。
在了解上文内容之后,知道了字符隐形的原理,再结合源代码可知:还原隐形内容,即进行逆操作:将隐形的unicode编码转化成二进制,再将二进制转成原本字符。
直接给出源码:
function hidden_text_2_binary(string){
return string.split('\ufeff').map(function(char){
if (char === '\u200b') {
return '1';
} else if(char === '\u200c') {
return '0';
}
return ' ';
}).join('')
}
function binary_2_Text(binaryStr){
var text = ""
binaryStr.split(' ').map(function(num){
text += String.fromCharCode(parseInt(num, 2));
}).join('');
return text.toString();
}
console.log("隐形字符转二进制:",hidden_text_2_binary(hidden_text));
console.log("二进制转原始字符:",binary_2_Text(hidden_text_2_binary(hidden_text)));
运行效果
如果在代码中直接提供“隐形”字符内容,比如ajax通信时,将“隐形”字符由后端传给前端,并用以上解密方法还原,那么这种方式传递的内容会是非常隐秘的。
但还是存在一个安全问题:他人查看JS源码,能看到解密函数,这可能引起加密方法泄露、被人推导出加密、解密方法。
前端的js想做到纯粹的加密目前是不可能的,因为 JavaScript 是一种在客户端执行的脚本语言,其代码需要在浏览器或其他 JavaScript 运行环境中解释和执行,由于需要将 JavaScript 代码发送到客户端,并且在客户端环境中执行,所以无法完全避免代码的逆向工程和破解。
来源:juejin.cn/post/7356208563101220915
前端如何生成临时链接?
前言
前端基于文件上传需要有生成临时可访问链接的能力,我们可以通过URL.createObjectURL
和FileReader.readAsDataUR
API来实现。
URL.createObjectURL()
URL.createObjectURL()
静态方法会创建一个 DOMString
,其中包含一个表示参数中给出的对象的URL。这个 URL 的生命周期和创建它的窗口中的 document
绑定。这个新的URL 对象表示指定的 File
对象或 Blob
对象。
1. 语法
let objectURL = URL.createObjectURL(object);
2. 参数
用于创建 URL 的 File
对象、Blob
对象或者 MediaSource
对象。
3. 返回值
一个DOMString
包含了一个对象URL,该URL可用于指定源 object的内容。
4. 示例
"file" id="file">
document.querySelector('#file').onchange = function (e) {
console.log(e.target.files[0])
console.log(URL.createObjectURL(e.target.files[0]))
}
将上方console控制台打印的blob文件资源地址粘贴到浏览器中
blob:http://localhost:8080/1ece2bb1-b426-4261-89e8-c3bec43a4020
URL.revokeObjectURL()
在每次调用 createObjectURL()
方法时,都会创建一个新的 URL 对象,即使你已经用相同的对象作为参数创建过。当不再需要这些 URL 对象时,每个对象必须通过调用 URL.revokeObjectURL()
方法来释放。
浏览器在 document 卸载的时候,会自动释放它们,但是为了获得最佳性能和内存使用状况,你应该在安全的时机主动释放掉它们。
1. 语法
window.URL.revokeObjectURL(objectURL);
2. 参数 objectURL
一个 DOMString
,表示通过调用 URL.createObjectURL()
方法返回的 URL 对象。
3. 返回值
undefined
4. 示例
"file" id="file">
<img id="img1" style="width: 200px;height: auto" />
<img id="img2" style="width: 200px;height: auto" />
document.querySelector('#file').onchange = function (e) {
const file = e.target.files[0]
const URL1 = URL.createObjectURL(file)
console.log(URL1)
document.querySelector('#img1').src = URL1
URL.revokeObjectURL(URL1)
const URL2 = URL.createObjectURL(file)
console.log(URL2)
document.querySelector('#img2').src = URL2
}
与FileReader.readAsDataURL(file)区别
1. 主要区别
- 通过
FileReader.readAsDataURL(file)
可以获取一段data:base64
的字符串 - 通过
URL.createObjectURL(blob)
可以获取当前文件的一个内存URL
2. 执行时机
createObjectURL
是同步执行(立即的)FileReader.readAsDataURL
是异步执行(过一段时间)
3. 内存使用
createObjectURL
返回一段带hash
的url
,并且一直存储在内存中,直到document
触发了unload
事件(例如:document close
)或者执行revokeObjectURL
来释放。FileReader.readAsDataURL
则返回包含很多字符的base64
,并会比blob url
消耗更多内存,但是在不用的时候会自动从内存中清除(通过垃圾回收机制)
4. 优劣对比
- 使用
createObjectURL
可以节省性能并更快速,只不过需要在不使用的情况下手动释放内存 - 如果不在意设备性能问题,并想获取图片的
base64
,则推荐使用FileReader.readAsDataURL
来源:juejin.cn/post/7333236033038778409
小程序海报绘制方案(原生,Uniapp,Taro)
背景
- 小程序海报绘制方案有很多,但是大多数都是基于canvas的,而且都是自己封装的,不够通用,不够灵活,不够简单,不够好用。
- 本方使用一个开源的小程序海报绘制,非常灵活,扩展性非常好,仅布局就能得到一张海报。
准备工作
安装依赖,也可以把源码下载到本地,源码地址。
npm install wxml2canvas
布局
无论哪种方案,布局都是一致的,需要注意一些暂未支持的属性:
- 变形:transform,但是节点元素使能读取此属性,但是库不支持,所以不要使用
- 圆角,border-radius,同上,不要使用,圆形图片有特定的属性去实现,除此之外无法实现其他类型的圆角
布局示例:
注意,除了uniapp,原生和Taro要使用原生组件的方式绘制canvas,因为Taro不支持data-xx的属性绑定方式,这一点很糟糕
<!-- 外层wrap用于fixed定位,使得整个布局离屏,离屏canvas暂未支持 -->
<view class='wrap'>
<!-- canvas id,一会 new 的时候需要 -->
<canvas canvas-id="poster-canvas"></canvas>
<view class="container">
<view data-type="text" data-text="测试文字绘制" class='text'>测试文字绘制</view>
<image data-type="image" data-src="https://img.yzcdn.cn/vant/cat.jpeg" class='image'></image>
<image data-type="radius-image" data-src="https://img.yzcdn.cn/vant/cat.jpeg" class='radius-image'></image>
</view>
</view>
原生小程序
import Wxml2Canvas from 'wxml2canvas'
Component({
methods: {
paint() {
wx.showLoading({ title: '生成海报' });
// 创建绘制实例
const drawInstance = new Wxml2canvas({
// 组件的this指向,组件内使用必传
obj: this,
// 画布宽高
width: 275,
height: 441,
// canvas-id
element: 'poster-canvas',
// 画布背景色
background: '#f0f0f0',
// 成功回调
finish: (url) => {
console.log('生成的海报url,开发者工具点击可预览', url);
wx.hideLoading();
},
// 失败回调
error: (err) => {
console.error(err);
wx.hideLoading();
},
});
// 节点数据
const data = {
list: [
{
// 此方式固定 wxml
type: 'wxml',
class: '.text', // draw_canvas指定待绘制的元素
limit: '.container', // 限定绘制元素的范围,取指定元素与它的相对位置计算
}
{
// 此方式固定 wxml
type: 'wxml',
class: '.image', // draw_canvas指定待绘制的元素
limit: '.container', // 限定绘制元素的范围,取指定元素与它的相对位置计算
}
{
// 此方式固定 wxml
type: 'wxml',
class: '.radius-image', // draw_canvas指定待绘制的元素
limit: '.container', // 限定绘制元素的范围,取指定元素与它的相对位置计算
}
]
}
// 调用绘制方法
drawInstance.draw(data);
}
}
})
Uniapp
uniapp 主要讲Vue3的版本,因为涉及 this,需要获取 this 以及时机
import { getCurrentInstance} from 'vue';
// 调用时机 setup内,不能在其他时机
// @see https://github.com/dcloudio/uni-app/issues/3174
const instance = getCurrentInstance();
function paint() {
uni.showLoading({ title: '生成海报' });
const drawInstance = new Wxml2Canvas({
width: 290, // 宽, 以iphone6为基准,传具体数值,其他机型自动适配
height: 430, // 高
element: 'poster-canvas', // canvas-id
background: '#f0f0f0',
obj: instance,
finish(url: string) {
console.log('生成的海报url,开发者工具点击可预览', url);
uni.hideLoading();
},
error(err: Error) {
console.error(err);
uni.hideLoading();
},
});
// 节点数据
const data = {
list: [
{
// 此方式固定 wxml
type: 'wxml',
class: '.text', // draw_canvas指定待绘制的元素
}
{
// 此方式固定 wxml
type: 'wxml',
class: '.image', // draw_canvas指定待绘制的元素
}
{
// 此方式固定 wxml
type: 'wxml',
class: '.radius-image', // draw_canvas指定待绘制的元素
}
]
}
// 调用绘制方法
drawInstance.draw(data);
}
Taro
Taro 比较特殊,框架层面的设计缺陷导致了 Taro 组件增加的 data-xx
属性在编译的时候是会清除的,因此Taro要使用这个库要用原生小程序的方式编写组件。
代码和原生的一样,只是要用原生的方式编写组件,然后在 Taro 中使用。
参考原生的代码,原生小程序js参考这
假设原生组件名为 draw-poster
,那么首先需要再Taro的页面中引入这个组件,然后在页面中使用这个组件,然后在组件中使用这个库。
export default {
navigationBarTitleText: '',
usingComponents: {
'draw-poster': '../../components/draw-poster/index',
},
};
const draw = useCallback(() => {
const { page } = Taro.getCurrentInstance();
// 拿到目标组件实例调用里面的方法
const instance = page!.selectComponent('#draw_poster');
// 调用原生组件绘制方法
instance.paint();
}, []);
return <draw-poster id="draw_poster"/>
总结
对比原生的canvas绘制方案,布局的方式获取节点的方式都是一样的,只是绘制的时候不一样,原生的是直接绘制到canvas上,而这个库是先把布局转换成canvas,然后再绘制到canvas上,所以这个库的性能会比原生的差一些,但是这个库的优势在于布局的方式,不需要自己去计算位置,只需要布局,然后调用绘制方法就可以了,非常方便,而且扩展性非常好,可以自己扩展一些布局方式,比如说flex布局,grid布局等等,这些都是可以的,只需要在布局的时候把布局转换成canvas的布局就可以了,这个库的布局方式是参考的微信小程序的布局方式,所以布局的时候可以参考微信小程序的布局方式,这样就可以很方便的布局了。
来源:juejin.cn/post/7300460850010521654
还在用轮询、websocket查询大屏数据?sse用起来
常见的大屏数据请求方式
1、http请求轮询:使用定时器每隔多少时间去请求一次数据。优点:简单,传参方便。缺点:数据更新不实时,浪费服务器资源(一直请求,但是数据并不更新)
2、websocket:使用websocket实现和服务器长连接,服务器向客户端推送大屏数据。优点:长连接,客户端不用主动去请求数据,节约服务器资源(不会一直去请求数据,也不会一直去查数据库),数据更新及时,浏览器兼容较好(web、h5、小程序一般都支持)。缺点:有点大材小用,一般大屏数据只需要查询数据不需要向服务端发送消息,还要处理心跳、重连等问题。
3、sse:基于http协议,将一次性返回数据包改为流式返回数据。优点:sse使用http协议,兼容较好、sse轻量,使用简单、sse默认支持断线重连、支持自定义响应事件。缺点:浏览器原生的EventSource不支持设置请求头,需要使用第三方包去实现(event-source-polyfill)、需要后端设置接口的响应头Content-Type: text/event-stream
sse和websocket的区别
- websocket支持双向通信,服务端和客户端可以相互通信。sse只支持服务端向客户端发送数据。
- websocket是一种新的协议。sse则是基于http协议的。
- sse默认支持断线重连机制。websocket需要自己实现断线重连。
- websocket整体较重,较为复杂。sse较轻,简单易用。
Websocket和SSE分别适用于什么业务场景?
根据sse的特点(轻量、简单、单向通信)更适用于大屏的数据查询,业务应用上查询全局的一些数据,比如消息通知、未读消息等。
根据websocket的特点(双向通信)更适用于聊天功能的开发
前端代码实现
sse的前端的代码非常简单
const initSse = () => {
const source = new EventSource(`/api/wisdom/terminal/stats/change/notify/test`);
// 这里的stats_change要和后端返回的数据结构里的event要一致
source.addEventListener('stats_change', function (event: any) {
const types = JSON.parse(event.data).types;
});
// 如果event返回的是message 数据监听也可以这样监听
// source.onmessage =function (event) {
// var data = event.data;
// };
// 下面这两个监听也可以写成addEventListener的形式
source.onopen = function () {
console.log('SSE 连接已打开');
};
// 处理连接错误
source.onerror = function (error: any) {
console.error('SSE 连接错误:', error);
};
setSseSource(source);
};
// 关闭连接
sseSource.close();
这种原生的sse连接是不能设置请求头的,但是在业务上接口肯定是要鉴权需要传递token的,那么怎么办呢? 我们可以使用event-source-polyfill这个库
const source = new EventSourcePolyfill(`/api/wisdom/terminal/stats/change/notify/${companyId}`, {
headers: {
Authorization: sessionStorage.get(StorageKey.TOKEN) || storage.get(StorageKey.TOKEN),
COMPANYID: storage.get(StorageKey.COMPANYID),
COMPANYTYPE: 1,
CT: 13
}
});
//其它的事件监听和原生的是一样
后端代码实现
后端最关键的是设置将响应头的Content-Type设置为text/event-stream、Cache-Control设置为no-cache、Connection设置为keep-alive。每次发消息需要在消息体结尾用"/n/n"进行分割,一个消息体有多个字段每个字段的结尾用"/n"分割。
var http = require("http");
http.createServer(function (req, res) {
var fileName = "." + req.url;
if (fileName === "./stream") {
res.writeHead(200, {
"Content-Type":"text/event-stream",
"Cache-Control":"no-cache",
"Connection":"keep-alive",
"Access-Control-Allow-Origin": '*',
});
res.write("retry: 10000\n");
res.write("event: connecttime\n");
res.write("data: " + (new Date()) + "\n\n");
res.write("data: " + (new Date()) + "\n\n");
interval = setInterval(function () {
res.write("data: " + (new Date()) + "\n\n");
}, 1000);
req.connection.addListener("close", function () {
clearInterval(interval);
}, false);
}
}).listen(8844, "127.0.0.1");
其它开发中遇到的问题
我在开发调试中用的是umi,期间遇到个问题就是sse连接上了但是在控制台一直没有返回消息,后端那边又是正常发出了的,灵异的是在后端把服务干掉的一瞬间可以看到控制台一下接到好多消息。我便怀疑是umi的代理有问题,然后我就去翻umi的文档,看到了下面的东西:
一顿操作之后正常
来源:juejin.cn/post/7424908830902042658
啊?两个vite项目怎么共用一个端口号啊
问题:
最近在业务开发中遇到一个问题,问题是这样的,当前有一个主项目和一个子项目,主项目通过微前端wujie来嵌套这个子项目,其中呢为了方便项目之间进行通信,所以规定该子项目的端口号必须为5173,否则通信失败,但是这时候发现一个问题,当我启动了该子项目后:
该项目的端口号为5173,但是此时我再次通过vite的官方搭建一个react+ts+vite项目:npm create vite@latest react_demos -- --template react-ts
,之后通过npm run dev
启动项目,发现端口号并没有更新
:
这是什么原因呢?
寻因:
查阅官方文档,我发现:
那么我主动在vite.config.ts中添加这个配置:
正常来说,会出现这个报错:
但是此时结果依然为:
我百思不得不得其解,于是再次查阅官方文档:
我寻思这也与文档描述不一致啊,于是我再次尝试,思考是不是vite版本号的问题
,两个项目的版本号分别为:
我决定创建一个4版本的项目npm create vite@^4.1.4 react_demos3 -- --template react-ts
结果发现,还是有这个问题,跟版本号没有关系
,于是我又耐心继续看官方文档,看到了这个配置:
我抱着试试的态度,在其中一个vite项目中添加这个配置:
发现,果然是这个配置的锅,当其中一个项目host配置为0.0.0.0时,vite不会自动尝试更新端口号
难道vite的端口监测机制与host也有关?
结果:
不甘心的我再次进行尝试,将两个项目的host都设置成:
vite会自动尝试更新端口号
原来如此,vite的端口号检测机制在对比端口号之前,会先对比host,由于我的微前端项目中设置了host,而新建的项目中没有设置host,新建的项目host默认值为localhost对比不成功,vite不会自动尝试下一个可用端口,而是共用一个端口
总结:
在遇到问题时,要多多去猜,去想各种可能,并且最重要的是去尝试各种可能,还要加上积极去翻阅官方文档,问题一定会得到解决的;哪怕不能解决,那也会在尝试中,学到很多东西
来源:juejin.cn/post/7319699173740363802
还搞不明白浏览器缓存?
一:前言
浏览器缓存与浏览器储存是不一样的,友友们不要混淆,关于浏览器储存,具体可以看这篇文章 : 一篇打通浏览器储存
这里大概介绍一下:
cookies | localStorage | sessionStorage | IndexedDB |
---|---|---|---|
服务端设置 | 一直存在 | 页面关闭就消失 | 一直存在 |
4K | 5M | 5M | 无限大 |
自动携带在http请求头中 | 不参与后端 | 不参与后端 | 不参与后端 |
默认不允许跨域,但可以设置 | 可跨域 | 可跨域 | 可跨域 |
二:强缓存
强缓存是指浏览器在请求资源时,如果本地有符合条件的缓存,那么浏览器会直接使用缓存而不会向服务器发送新的请求。这可以通过设置 Cache-Control
或 Expires
响应头来实现。
2.1:Cache-Control 头详解
Cache-Control
是一个非常强大的HTTP头部字段,它包含多个指令,用以控制缓存的行为:
- max-age:指定从响应生成时间开始,该资源可被缓存的最大时间(秒数)。
- s-maxage:类似于
max-age
,但仅对共享缓存(如代理服务器)有效。 - public:表明响应可以被任何缓存存储,即使响应通常是私有的。
- private:表明响应只能被单个用户缓存,不能被共享缓存存储。
- no-cache:强制缓存在使用前必须先验证资源是否仍然新鲜。
- no-store:禁止缓存该响应,每次请求都必须获取最新数据。
- must-revalidate:一旦资源过期,必须重新验证其有效性。
例如,通过设置 Cache-Control: max-age=86400
,可以告诉浏览器这个资源可以在本地缓存24小时。在这段时间内,如果再次访问相同URL,浏览器将直接使用缓存中的副本,而不与服务器通信。
2.2:Expires 头
Expires
是一个较旧的头部字段,用于设定资源过期的具体日期和时间。尽管现在推荐使用 Cache-Control
,但在某些情况下,Expires
仍然是有效的。Expires
的值是一个绝对的时间点,而不是相对时间。例如:
Expires: Wed, 09 Oct 2024 18:29:00 GMT
2.3:浏览器默认行为
当用户通过地址栏直接请求资源时,浏览器通常会自动添加 Cache-Control: no-cache
到请求头中。这意味着即使资源已经存在于缓存中,浏览器也会尝试重新验证资源新鲜度,以确保用户看到的是最新的内容。
三:协商缓存
协商缓存发生在资源的缓存条目已过期或设置了 no-cache
指令的情况下。这时,浏览器会向服务器发送请求,并携带上次请求时收到的一些信息,以便服务器决定是否返回完整响应或只是确认没有更新。
3.1:Last-Modified/If-Modified-Since
后端服务器可以为每个资源设置 Last-Modified
头部,表示资源最后修改的时间。当下一次请求同一资源时,浏览器会在请求头中加入 If-Modified-Since
字段,其值为上次接收到的 Last-Modified
值。服务器检查这个时间戳,如果资源自那以后没有改变,则返回304 Not Modified状态码,指示浏览器使用缓存中的版本。
3.2:ETag/If--Match
ETag 提供了一种更精确的方法来检测资源是否发生变化。它是基于文件内容计算出的一个唯一标识符。当客户端请求资源时,服务器会在响应头中提供一个 ETag
值。下次请求时,浏览器会发送 If--Match
头部,包含之前接收到的 ETag
。如果资源未改变,服务器同样返回304状态码;如果有变化,则返回完整的资源及新的 ETag
值。
3.3:比较 Last-Modified 和 ETag
虽然 Last-Modified
简单易用,但它基于时间戳,可能会受到时钟同步问题的影响。相比之下,ETag
更加准确,因为它依赖于资源的实际内容。然而,ETag
计算可能需要更多的服务器处理能力。
四:缓存选择
合理的缓存策略能够显著提升网站性能和用户体验。例如,静态资源(如图片、CSS、JavaScript文件)适合设置较长的缓存时间,而动态内容则需谨慎对待,避免缓存不适当的信息。
- 使用工具如 Chrome DevTools 来分析页面加载时间和缓存效果。
- 对不同类型的资源设置合适的
Cache-Control
参数。 - 注意安全性和隐私保护,确保敏感数据不会被错误地缓存。
五:使用示例
- 引入必要的模块:导入
http
,path
,fs
和mime
模块。 - 创建HTTP服务器:使用
http.createServer
创建一个HTTP服务器。 - 处理请求:
- 根据请求的URL生成文件路径。
- 检查文件是否存在。
- 如果是目录,指向该目录下的
index.html
文件。
- 处理协商缓存:
- 获取请求头中的
If-Modified-Since
字段。 - 比较
If-Modified-Since
与文件的最后修改时间。
- 获取请求头中的
- 读取文件并发送响应:
- 读取文件内容。
- 设置响应头(包括
Content-Type
,Cache-Control
,Last-Modified
,ETag
)。 - 发送响应体。
- 启动服务器:监听3000端口并启动服务器。
server.js:
const http = require('http'); // 引入HTTP模块
const path = require('path'); // 引入路径处理模块
const fs = require('fs'); // 引入文件系统模块
const mime = require('mime'); // 引入MIME类型模块
// 创建一个HTTP服务器
const server = http.createServer((req, res) => {
// console.log(req.url); // /index.html // /assets/image/logo.png
// 根据请求的URL生成文件路径
let filePath = path.resolve(__dirname, path.join('www', req.url));
// 检查文件或目录是否存在
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath); // 获取该路径对应的资源状态信息
// console.log(stats);
const isDir = stats.isDirectory(); // 判断是否是文件夹
const { ext } = path.parse(filePath); // 获取文件扩展名
if (isDir) {
// 如果是目录,则指向该目录下的 index.html 文件
filePath = path.join(filePath, 'index.html');
}
// +++++ 获取前端请求头中的if-modified-since
const timeStamp = req.headers['if-modified-since']; // 获取请求头中的 If-Modified-Since 字段
let status = 200; // 默认响应状态码为200
if (timeStamp && Number(timeStamp) === stats.mtimeMs) { // 如果 If-Modified-Since 存在且与文件最后修改时间相同
status = 304; // 设置响应状态码为304,表示资源未变更
}
// 如果不是目录且文件存在
if (!isDir && fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath); // 读取文件内容
res.writeHead(status, {
'Content-type': mime.getType(ext), // 设置 Content-Type 头
'cache-control': 'max-age=86400', // 设置缓存控制为一天
// 'last-modified': stats.mtimeMs, // 资源最新修改时间(可选)
// 'etag': '由文件内容生成的hash' // 文件指纹(可选)
});
res.end(content); // 发送文件内容作为响应体
}
}
});
// 启动服务器,监听3000端口
server.listen(3000, () => {
console.log('listening on port 3000');
});r.listen(3000, () => {
console.log('listening on port 3000');
});
index.html:
<body>
<h1>midsummer</h1>
<img src="assets/image/1.png" alt="">
</body>
项目结构如下图,友友们自行准备一张图片,将项目npm init -y
初始化为后端项目,之后下载mime@3包,在终端输入npx nodemon server.js
运行起来,在浏览器中查看http://localhost:3000/index.html ,观察效果。在检查中的网络里看缓存效果,同时友友们可以更改图片或者缓存方式,体验下不同的浏览器缓存方式
来源:juejin.cn/post/7423298788873142326
告别axios,这个库让你爱上前端分页!
嗨,我们又见面了!
今天咱们聊聊前端分页加载那些事儿。你有没有遇到过这样的烦恼:在做分页的时候,要手动维护各种状态,比如页码、每页显示数量、总数据量等等,还要处理各种边界情况,哎呀妈呀,真是太麻烦了!
那么,有没有什么好办法能让我们从这些繁琐的工作中解脱出来呢?这时候,alovajs就派上用场了!
alovajs:轻量级请求策略库
alovajs是一个轻量级的请求策略库,它可以帮助我们轻松处理分页请求。它支持开发者使用声明式实现各种复杂的请求,比如请求共享、分页请求、表单提交、断点续传等等。使用alovajs,我们可以用很少的代码就实现高效、流畅的请求功能。比如,在Vue中,你可以这样使用alovajs进行分页请求:
const alovaInstance = createAlova({
// VueHook用于创建ref状态,包括请求状态loading、响应数据data、请求错误对象error等
statesHook: VueHook,
requestAdapter: GlobalFetch(),
responded: response => response.json()
});
const { loading, data, error } = useRequest(
alovaInstance.Get('https://api.alovajs.org/profile', {
params: {
id: 1
}
})
);
看到了吗?只需要几行代码,alovajs就帮我们处理了分页请求的各种细节,我们再也不用手动维护那些繁琐的状态了!
对比axios,alovajs的优势
和axios相比,alovajs有哪些优势呢?首先,alovajs与React、Vue等现代前端框架深度融合,可以自动管理请求相关数据,大大提高了开发效率。其次,alovajs在性能方面做了很多优化,比如默认开启了内存缓存和请求共享,这些都能显著提高请求性能,提升用户体验的同时还能降低服务端的压力。最后,alovajs的体积更小,压缩后只有4kb+,相比之下,axios则有11+kb。
总之,如果你想在分页加载方面做得更轻松、更高效,alovajs绝对值得一试!
来源:juejin.cn/post/7331924057925533746
我为什么要搓一个useRequest
背景
- 在日常开发网络请求过程中,为了维护loading和error状态开发大量重复代码
- 对于竞态问题,要么不处理,要么每个需要请求的地方都要写重复逻辑
- 图表接口数据量大,甚至单接口响应就足以达到数十兆字节,而一个页面有数十个这样的请求,响应时间长,需要能够取消网络请求
以上逻辑,每个人的解法各不相同。为了解决上述问题,统一处理逻辑,需要一个能够统一管理网络请求状态的工具。
调研
首先想到的当然不是自己搓轮子,而是在社区上寻找是否已有解决方案。果不其然,找到了一些方案。
对于React,有像react-query这样的老前辈,功能全面,大而重;有像SWR这样的中流砥柱,受到社区广泛追捧;有像ahooks的useRequest这样的小清新,功能够用,小而美。
而对于Vue,一开始还真没让我找到类似的解决方案,后续进一步查找,发现有一个外国哥们仿造react-qeury仿写了一个vue-query,同时了解到雷达团队正是用的这一套解决方案,便又更深入了解了一下,发现这个库已经不维护了......进而了解到@tanstack/query,好家伙,这玩意胃口大得把react-query和vue-query都吃进去了,甚至svelte也不放过。继续找,发现有个哥们写了一个vue-request库,差不多类似于ahooks的useRequest,不错。然后经典的vue-use库也看了下,有一个useFetch方法,比较鸡肋,只适用于Fetch请求。
上述的社区库都相当不错,但对于我来说都太重了,功能繁多,而且在使用上,几个query都需要花费大量心智在缓存key上,太难用了。而ahooks和vue-request提供的useRequest的高阶函数,是比较符合我的胃口的,但是我还是嫌他们功能太多了。最关键的是,上述所有方案都没有达到我最主要的目的,能够真正取消网络请求。
因此,自己动手,丰衣足食。
动手
说干就干,搓一个咱自己的useRequest。
首先,定义useRequest的接口:
export declare const useRequest: <P extends unknown[], R>(request: (signal: AbortSignal, ...args: P) => Promise<R>, options?: IUseRequestOptions<R> | undefined) => {
result: ShallowRef<R | null>;
loading: ShallowRef<boolean>;
error: ShallowRef<Error | null>;
run: (...args: P) => Promise<Error | R>;
forceRun: (...args: P) => Promise<Error | R>;
cancel: () => boolean;
};
然后定义三个响应式状态,这里之所以用shallowRef,是考虑到部分请求结果可能很深,如果用ref会导致性能很差。
const result = shallowRef<IResult | null>(null);
const loading = shallowRef(false);
const error = shallowRef<Error | null>(null);
定义普通变量,在useRequest内部使用,不要在内部实现读取响应式变量(PS:踩过坑了,有个页面用watchEffect,loading状态一变就发请求,导致无线循环):
let abortController = new AbortController();
let isFetching = false;
然后定义run函数,如果有进行中的请求就取消掉:
const run = async (...args: IParams) => {
if (mergedOptions.cancelLastRequest && isFetching) {
cancel();
}
abortController = new AbortController();
setLoadingState(true);
const res = await runRequest(...args);
return res;
};
const runRequest = async (...args: IParams) => {
const currentAbortController = abortController;
try {
const res = await request(currentAbortController.signal, ...args);
if (currentAbortController.signal.aborted) {
return new Error('canceled');
}
handleSuccess(res);
return res;
} catch (error) {
if (currentAbortController.signal.aborted) {
return new Error('canceled');
}
handleError(error as Error);
return error as Error;
}
};
另外暴露出cancel方法:
const cancel = () => {
if (isFetching) {
mergedOptions.onCancel?.();
setLoadingState(false);
abortController.abort('cancel request');
return true;
}
return false;
};
在组件卸载时也取消掉未完成的请求:
onScopeDispose(() => {
if (mergedOptions.cancelOnDispose && isFetching) {
cancel();
}
});
以上,就是最基础版的useRequest实现,想要了解更多,欢迎直接阅读useRequest源码,核心代码一共也就一百来行。看完再把star一点,诶嘿,美滋滋。
产出
- useRequest源码
- useRequest使用文档
- 本次文章分享
收益
业务贡献
- 提供响应式的result、loading、error状态
- 内置缓存逻辑
- 内置错误重试逻辑
- 内置竞态处理逻辑
- 兼容 Vue 2 & 3
- 兼容 Axios & Fetch
- 取消网络请求
个人成长
- 学会如何编写一个基本的Vue工具库
- 了解如何用vite打包,并且带上类型文件
- 学会如何使用vue-demi兼容Vue2 & Vue3
- 学会如何用VuePress编写文档,过程中没少看源码
- 学会如何在npm上发包并维护
- 之前用jest写过测试,这次尝试了一下vitest,体感不错,过程中暴露不少代码问题
- 通过这个项目将以往所学的部分知识串联起来
参考
来源:juejin.cn/post/7293786784126255131
shadcn/ui 一个真·灵活的组件库
当前主流组件库的问题
我之前使用过很多组件库,比如 MUI,AntDesign,ElementUI,等等。他们都是很出名的组件库。
优点就不说了。他们的缺点是不灵活。
不灵活有 2 个原因。
生态不开放
第 1 个不灵活的原因是我感觉选了一家之后,就得一用到底,没有办法使用其他派系的组件了,比如我觉得 MUI 中的表格不好,Ant Design 的表格好,但是我无法在 MUI 中使用 AntDesign 的表格组件,因为在一个项目中无法同时使用 Mui 和 AntDesign。
无法使用的原因组件库把样式和组件库绑定在一起了,MUI 和 AntD 的样式又是不兼容的。使用了一个组件库,就需要套一个 ConfigProvider 或 ThemeProvider, 套上之后,就把地盘占领了,其他组件库就没法再套了。
修改不方便
第 2 个不灵活的原因要修改定制二次开发一个组件时感觉很麻烦,成本很高。有时需要看很多文档才能找到怎么修改,有时直接就无法修改。
Headless UI
为了解决组件库不灵活的问题,出现了无头组件库( headless ui ),不过 headless ui 虽然灵活却不方便。
如果要写一个按钮,按钮的各种状态都需要自己来关心,那还不如直接用大而全的组件库。大部分场景中,方便的优先级还是大于灵活的。
这也是为什么 radix-ui 一开始做了一个 headless 组件库,http://www.radix-ui.com/primitives , 后来又做了一个带主题的组件库
shadcn/ui
shadcn/ui 的优势正是解决了上面两个问题,同时又尽量保留了组件库使用方便的优势。
真·灵活
shadcn/ui 给人的感觉没有什么负担,因为 shadcn/ui ,主打一个按需加载,真·按需加载,加载组件的方式是通过命令把代码加到你的项目中,而不是从依赖中引用代码,所以代码不在外部依赖 node_modules 中,直接在自己的项目源代码中,不会有依赖了重重的一坨代码的感觉。因为都是源代码,这样你可以直接随意修改,二次开发。实际场景中,通常是不需要修改的,在偶尔需要修改是也很灵活,大不了复制粘贴一份,总比“明明知道怎么实现却无法实现强”。
拥抱 Tailwindcss
shadcn/ui 使用了 tailwindcss 作为样式方案,这样可以复制 tailwindcss 生态中的其他代码,比如 tailspark.co/ 和 flowbite.com/ ,一下子生态就大了很多,而且修改起来也方便。
方便
通过官方封装组件 + CLI 命令,灵活的同时并没有明显降低效率
比如要使用按钮组件,ui.shadcn.com/docs/compon… , 直接通过一行命令添加后就可以使用了 npx shadcn-ui@latest add button
总结
总结 shancn/ui 的优点
- 组件代码直接在项目源代码中,将灵活做到极致
- 拥抱 tailwindcss 生态
- 灵活的同时并没有明显降低效率
来源:juejin.cn/post/7382747688112783360
小红书路由处理大揭秘
起因
前两天看到小红书网页版的这个效果,感觉挺神奇的:


就是它同一个url对应了两种不同的页面。
上面这个是从列表页点开一个文章的时候,浏览器的路由变了,但是页面没有发生跳转,而是以一个弹窗的模式显示文章,底下我们还能看到列表。
但是当我们把这个url发送给别人,或者刷新浏览器后,同一个url会显示为下面这一个文章详情页,这样就避免了查看详情的时候还需要加载背后的列表。并且小红书的列表和详情是有对应关系(hero效果),但是列表页是随机排列的,如果要加载列表后再加载详情,就很难定位到文章在列表中的位置(随机推荐逻辑就很难改),而且还会影响性能。
前两天看到小红书网页版的这个效果,感觉挺神奇的:
就是它同一个url对应了两种不同的页面。
上面这个是从列表页点开一个文章的时候,浏览器的路由变了,但是页面没有发生跳转,而是以一个弹窗的模式显示文章,底下我们还能看到列表。
但是当我们把这个url发送给别人,或者刷新浏览器后,同一个url会显示为下面这一个文章详情页,这样就避免了查看详情的时候还需要加载背后的列表。并且小红书的列表和详情是有对应关系(hero效果),但是列表页是随机排列的,如果要加载列表后再加载详情,就很难定位到文章在列表中的位置(随机推荐逻辑就很难改),而且还会影响性能。
思考
解决方案我跟小伙伴思考了很久(基于vue-router),一开始我想的是通过路由守卫来控制,如果from来自列表,to就不跳转;如果from不是列表,则to跳转。但是这个方案会导致路由出现问题,因为如果没有跳转,则路由也不会变化。
另一个小伙伴想的是在路由表上,复用相同的组件,并使用keepAlive控制,来达到组件重用的目的。但是这个逻辑页有问题,keepAlive是路由的重用,其实不是组件的重用。
但当真正写起代码,才发现我们根本是想太多,其实解决方案简单到不足100行。
解决方案我跟小伙伴思考了很久(基于vue-router),一开始我想的是通过路由守卫来控制,如果from来自列表,to就不跳转;如果from不是列表,则to跳转。但是这个方案会导致路由出现问题,因为如果没有跳转,则路由也不会变化。
另一个小伙伴想的是在路由表上,复用相同的组件,并使用keepAlive控制,来达到组件重用的目的。但是这个逻辑页有问题,keepAlive是路由的重用,其实不是组件的重用。
但当真正写起代码,才发现我们根本是想太多,其实解决方案简单到不足100行。
代码
第一步:搭建项目
这里我采用vite来搭建项目,其实小红书这种网站需要考虑SEO的需求,应该会采用nuxt或者next等同构解决方案,这里我们简化了一下,只考虑路由的变化,所以也就不使用nuxt来搭建项目了。
这里我采用vite来搭建项目,其实小红书这种网站需要考虑SEO的需求,应该会采用nuxt或者next等同构解决方案,这里我们简化了一下,只考虑路由的变化,所以也就不使用nuxt来搭建项目了。
第二步,加入vue-router
routes.ts
import { RouteRecordRaw } from "vue-router";
export const routes: RouteRecordRaw[] = [
{
path: "/",
redirect: '/home'
},
{
path: "/home",
name: "Home",
component: () => import("./Home.vue"),
children: [
{
path: ':id',
name: "Detail",
component: () => import('./Detail.vue'),
}
]
},
]
router.ts
import {createRouter, createWebHistory} from "vue-router";
import { routes } from './routes.ts'
export const router = createRouter({
history: createWebHistory(),
routes,
})
文件结构:

我习惯吧routes和router分开两个文件,一个专心做路由表的编辑,另一个就可以专门做路由器(router)和路由守卫的编辑。
代码结构其实很简单,为了缩减代码量,我直接把page组件跟router放在一起了。
简单解释一下:
routes.ts 文件中我写了三个路由,一个是根路由/
,一个是列表/home
,一个是详情页Detail,这里使用了一个相对路由:id
的小技巧,待会你们就会知道为什么要这样了。
routes.ts
import { RouteRecordRaw } from "vue-router";
export const routes: RouteRecordRaw[] = [
{
path: "/",
redirect: '/home'
},
{
path: "/home",
name: "Home",
component: () => import("./Home.vue"),
children: [
{
path: ':id',
name: "Detail",
component: () => import('./Detail.vue'),
}
]
},
]
router.ts
import {createRouter, createWebHistory} from "vue-router";
import { routes } from './routes.ts'
export const router = createRouter({
history: createWebHistory(),
routes,
})
文件结构:
我习惯吧routes和router分开两个文件,一个专心做路由表的编辑,另一个就可以专门做路由器(router)和路由守卫的编辑。
代码结构其实很简单,为了缩减代码量,我直接把page组件跟router放在一起了。
简单解释一下:
routes.ts 文件中我写了三个路由,一个是根路由/
,一个是列表/home
,一个是详情页Detail,这里使用了一个相对路由:id
的小技巧,待会你们就会知道为什么要这样了。
第三步,编写Home.vue
<template>
<div>
<div class="text-red-700">Homediv>
<div class="w-full flex flex-wrap gap-3">
<router-link v-for="item in dataList" :to="`/home/${item.id}`">
<img :src="item.url" alt="">
router-link>
div>
<el-dialog title="Detail" v-model="dialogVisible">
<router-view>router-view>
el-dialog>
div>
template>
<script setup lang="ts">
import {computed, ref} from "vue";
import {useRoute, useRouter} from "vue-router";
import axios from "axios";
import {randomSize} from "../utils/randomSize.ts";
const route = useRoute()
const router = useRouter()
const lastRoute = computed(() => route.matched[route.matched.length - 1])
const dialogVisible = computed({
get() {
return lastRoute.value.name == 'Detail'
},
set(val) {
if (!val) {
router.go(-1)
}
},
})
const dataList = ref([])
const loading = ref(false)
function getList() {
loading.value = true
const data = localStorage.getItem('imageData')
if (!data) {
axios.get('https://picsum.photos/v2/list')
.then(({data}) => setDataList(data))
.then(data => localStorage.setItem('imageData', JSON.stringify(data)))
.finally(() => {
loading.value = false
})
} else {
setDataList(JSON.parse(data))
}
}
getList()
function setDataList(data) {
dataList.value = data.map(item => ({
id: item.url.split('/').pop(),
url: randomSize(item.download_url)
}))
return data
}
script>
这里重点看两个地方:
- template里需要有显示
detail
视图的地方,因为Home.vue除了要显示列表,还需要显示弹窗中的Detail,所以我把列表做成了router-link,并且把router-view放在了dialog里。(这里借助了tailwindcss和element-plus)

- 为了控制弹窗的显隐,我定义了一个dialogVisible计算对象,他的get来自router.matched列表中最后一个路由(最终命中的路由)是否为Detail,如果为Detail,就true,否则为false;它的set我们只需要处理false的情况,当false的时候,路由回退1。(其实是用push/replace还是用go我是有点纠结的,但是我看到小红书这里是用的回退,所以我也就用回退了,虽然回退在这种使用场景中存在一定的隐患)

<template>
<div>
<div class="text-red-700">Homediv>
<div class="w-full flex flex-wrap gap-3">
<router-link v-for="item in dataList" :to="`/home/${item.id}`">
<img :src="item.url" alt="">
router-link>
div>
<el-dialog title="Detail" v-model="dialogVisible">
<router-view>router-view>
el-dialog>
div>
template>
<script setup lang="ts">
import {computed, ref} from "vue";
import {useRoute, useRouter} from "vue-router";
import axios from "axios";
import {randomSize} from "../utils/randomSize.ts";
const route = useRoute()
const router = useRouter()
const lastRoute = computed(() => route.matched[route.matched.length - 1])
const dialogVisible = computed({
get() {
return lastRoute.value.name == 'Detail'
},
set(val) {
if (!val) {
router.go(-1)
}
},
})
const dataList = ref([])
const loading = ref(false)
function getList() {
loading.value = true
const data = localStorage.getItem('imageData')
if (!data) {
axios.get('https://picsum.photos/v2/list')
.then(({data}) => setDataList(data))
.then(data => localStorage.setItem('imageData', JSON.stringify(data)))
.finally(() => {
loading.value = false
})
} else {
setDataList(JSON.parse(data))
}
}
getList()
function setDataList(data) {
dataList.value = data.map(item => ({
id: item.url.split('/').pop(),
url: randomSize(item.download_url)
}))
return data
}
script>
这里重点看两个地方:
- template里需要有显示
detail
视图的地方,因为Home.vue除了要显示列表,还需要显示弹窗中的Detail,所以我把列表做成了router-link,并且把router-view放在了dialog里。(这里借助了tailwindcss和element-plus)
- 为了控制弹窗的显隐,我定义了一个dialogVisible计算对象,他的get来自router.matched列表中最后一个路由(最终命中的路由)是否为Detail,如果为Detail,就true,否则为false;它的set我们只需要处理false的情况,当false的时候,路由回退1。(其实是用push/replace还是用go我是有点纠结的,但是我看到小红书这里是用的回退,所以我也就用回退了,虽然回退在这种使用场景中存在一定的隐患)
剩下的代码就是获取数据相关的,我借用了picsum的接口(获取demo图片),并且我也没有做小红书的瀑布流(毕竟还是有点难度的,等有空了再做个仿小红书瀑布流来水一篇文章)。
Detail.vue
的代码就不贴了,它没有太多技术含量。
大概的页面效果是这样的:这里我就没有做数据加载优化之类功能了。(代码尽量简短)
我们可以看到,当点击详情的时候,浏览器右下角是有显示对应的路由,点开之后浏览器地址栏也变化了,详情内容在弹窗中出现,是我们想要的效果。
但是此时如果刷新页面,页面还是会一样先加载列表页,然后以Dialog显示详情。
刷新只显示详情
怎么做到刷新的时候只显示Detail页面而不显示列表页呢?我很快有一个想法:在路由表(routes.ts)的下面再增加一个路由,让它的路由路径跟详情的一样,这样刷新的时候会不会能够匹配到这个新路由呢?
// route.ts
export const routes = [
...
{
path: '/home/:id',
name: "DetailId",
component: () => import('./Detail.vue')
}
]
这个路由跟Home是同级的,使用了绝对路径来标记path(这就是上面detail采用相对路径的原因),同时为了避免name冲突,我换了一个name,component还是使用Detail.vue(这里我后来发现其实也可以使用其他的组件,其实真正起作用的是path,而不是component)。
但是不行,不论是将这个路由放在Home前面还是Home后面,都没法做到小红书的那种效果,放在home前面会导致从列表页直接跳转到详情页,不会在弹窗中显示;放在home后面又会因为匹配优先级的问题,匹配不到底下的DetailId
解决方案
但是前面的思考还是给了我灵感,添加一个路由守卫
是不是就可以解决问题呢?于是我添加了这样一个全局路由守卫:
// router.ts
router.beforeEach((to, from) => {
if (to.name === 'Detail') {
if (from.name === 'Home') {
return true
} else {
return { name: 'DetailId', params: to.params }
}
}
})
这个守卫的作用是,当发生路由跳转时,如果to为Detail,则判断from是否为Home,如果from为Home,则可以正常跳转,如果from不为Home,则说明是刷新或者链接打开,这时跳转至DetailId页面,并且params保持不变。
短短十行代码,就解决了问题。
可以看到,正常从列表显示详情还是会正常从弹窗中显示,而如果此时刷新页面,就会直接进入到详情页面。
如此我们成功的模仿了小红书的路由逻辑。
总结
其实做完效果才会发现代码非常简单无非就是一个路由守卫,一个弹窗显示,加一起不到一百行代码。代码地址我贴在下方了,希望对大家有帮助。
来源:juejin.cn/post/7343883765540962355
入职2个月,我写了一个VSCode插件解决团队遗留的any问题
背景
团队项目用的是React Ts,接口定义使用Yapi
。
但是项目中很多旧代码为了省事,都是写成 any
,导致在使用的时候没有类型提示,甚至在迭代的时候还发现了不少因为传参导致的bug。
举个例子
表格分页接口定义的参数是 pageSize
和 offset
,但是代码里传的却是 size
和 offset
,导致每次都是全量拉数据,然而因为测试环境数据量少,完全没测出来。
在这种项目背景下,大致过了一个月,结合自己试用期的目标(我也不想搞啊......),想通过一个工具来快速解决这类问题。
团队项目用的是React Ts,接口定义使用Yapi
。
但是项目中很多旧代码为了省事,都是写成 any
,导致在使用的时候没有类型提示,甚至在迭代的时候还发现了不少因为传参导致的bug。
举个例子
表格分页接口定义的参数是 pageSize
和 offset
,但是代码里传的却是 size
和 offset
,导致每次都是全量拉数据,然而因为测试环境数据量少,完全没测出来。
在这种项目背景下,大致过了一个月,结合自己试用期的目标(我也不想搞啊......),想通过一个工具来快速解决这类问题。
目标
把代码中接口的 any
替换成 Yapi
上定义的类型,减少因为传参导致的bug数量。
把代码中接口的 any
替换成 Yapi
上定义的类型,减少因为传参导致的bug数量。
交互流程
设计
鉴于当前项目中接口数量庞大(eslint扫出来包含any的接口有768个),手动逐一审查并替换类型显得既不现实又效率低下。
显然需要一种更加高效且可靠的方法来解决。
因为组内基本上都是使用 VSCode
开发,因此最终决定开发一个 VSCode
插件来实现类型的替换。
考虑到直接扫描整个项目进行替换风险较大,因此最终是 按文件维度,针对当前打开的文件执行替换。
整个插件分为3个命令:
- 单个接口替换
- 整个文件所有接口替换
- 新增接口

鉴于当前项目中接口数量庞大(eslint扫出来包含any的接口有768个),手动逐一审查并替换类型显得既不现实又效率低下。
显然需要一种更加高效且可靠的方法来解决。
因为组内基本上都是使用 VSCode
开发,因此最终决定开发一个 VSCode
插件来实现类型的替换。
考虑到直接扫描整个项目进行替换风险较大,因此最终是 按文件维度,针对当前打开的文件执行替换。
整个插件分为3个命令:
- 单个接口替换
- 整个文件所有接口替换
- 新增接口
整体设计
插件按功能划分为6个模块:

插件按功能划分为6个模块:
环境检测
Easy Yapi需要和Yapi服务器交互,需要用户提供Yapi Project相关的信息,因此需要填写配置文件(由使用者提供)。
插件执行命令时会对配置文件内的信息进行检测。
Easy Yapi需要和Yapi服务器交互,需要用户提供Yapi Project相关的信息,因此需要填写配置文件(由使用者提供)。
插件执行命令时会对配置文件内的信息进行检测。
缓存接口列表
从性能上考虑,一次批量替换后,会缓存当前Yapi项目所有接口的定义到cache文件中,下次替换不会重新请求。
从性能上考虑,一次批量替换后,会缓存当前Yapi项目所有接口的定义到cache文件中,下次替换不会重新请求。
接口捕获
不管是单个接口替换还是整个文件接口替换都需要先捕获接口,这里是通过将代码转成AST来实现。


不管是单个接口替换还是整个文件接口替换都需要先捕获接口,这里是通过将代码转成AST来实现。
类型生成
将接口定义转化成TS类型,通过循环+递归拼接字符串生成类型。
为什么不直接使用Yapi自带的ts类型?
- 命名问题,Yapi自带的ts类型命名过于简单粗暴,就是直接把接口路径拼接起来
- 有的字段因为粗心带了空格,最后还需要手动修改一遍类型

- 实际项目中有一层请求中间件,可能最终需要的类型只有data那一层,而Yapi定义的是整个类型
将接口定义转化成TS类型,通过循环+递归拼接字符串生成类型。
为什么不直接使用Yapi自带的ts类型?
- 命名问题,Yapi自带的ts类型命名过于简单粗暴,就是直接把接口路径拼接起来
- 有的字段因为粗心带了空格,最后还需要手动修改一遍类型
- 实际项目中有一层请求中间件,可能最终需要的类型只有data那一层,而Yapi定义的是整个类型
代码插入
- 将生成的类型插入文件中
// 检查文件是否存在
if (fs.existsSync(targetFilePath)) {
const currentContent = fs.readFileSync(targetFilePath);
if (!currentContent.includes(typeName)) { // 判断类型是否已存在
try {
fs.appendFileSync(targetFilePath, content); // 追加内容
editor.document.save(); // 调用vscode api保存文件
return true;
} catch (err: any) {
......
return false;
}
} else {
......
return false;
}
} else { // 文件不存在,创建并写入类型
try {
fs.writeFileSync(targetFilePath, content);
editor.document.save();
return true;
} catch (err: any) {
......
}
}
- 替换原有函数字符串
const nextFnStr = functionText
.replace(/(\w+:\s*)(any)/, (_, $1) => {
if (query.apiReq) {
return `${$1}${query.typeName}`;
}
// 没参数
else {
return "";
}
})
.replace(/Promise<([a-zA-Z0-9_]+|any)>/ , (_, $1) => {
if (res?.apiRes) {
return `Promise<${res?.typeName}>`;
}
return `Promise` ;
})
.replace(/,\s*\{\s*params\s*\}/, (_) => {
// 对于没有参数的case, 应该删除参数
if (!query.apiReq) {
return "";
}
return _;
});
- 调用vscode api替换函数字符串
const startPosition = new vscode.Position(functionStartLine - 1, 0); // 减1因为VS Code的行数是从0开始的
const endPosition = new vscode.Position(
functionEndLine - 1,
document.lineAt(functionEndLine - 1).text.length
);
const textRange = new vscode.Range(startPosition, endPosition);
const editApplied = await editor.edit((editBuilder) => {
editBuilder.replace(textRange, nextFnStr);
});
......
- 引入类型, 插入import语句
const document = editor.document;
const fullText = document.getText(); // 调用vscode api 拿到当前文件字符串
// 匹配单引号或双引号,并确保结束引号与开始引号相匹配
const importRegex =
/(import\s+(type\s+)?\{\s*[^}]*)(}\s+from\s+(['"])\.\/types(\.ts)?['"]);?/g;
let matchIndex = fullText.search(importRegex); // 使用search得到全局匹配的起始索引
if (matchIndex !== -1) {
// 已经有类型语句
let matchText = fullText.match(importRegex)?.[0]; // 获取完整的匹配文本
// 去重,如果 import { a, b } from './types'中已经有typeNames中的类型,则不需要重复引入
// existingTypes = ['a', 'b']
const existingTypes = (
/\{\s*([^}]+)\s*\}/g.exec(matchText)?.[1] as string
)
.split(",")
.map((v) => v.trim());
const uniqueTypeNames = typeNames.filter(
(v) => !existingTypes.includes(v)
);
// 将生成的类型插入原有的import type语句中
// 例如: import { a } from './types'
// 生成了类型 b c 则变成 import { a, b, c } from './types'
let updatedImport = matchText?.replace(
importRegex,
(_, group1, group2, group3) => {
// group1 对应 $1,即 import 语句到第一个 "}" 之前的所有内容
// group3 对应 $3,即 "}" 到语句末尾的部分
return `${
(group1.trim() as string).endsWith(",") ? group1 : `${group1}, `
}${uniqueTypeNames.join(", ")} ${group3}`;
}
);
// 计算确切的起始和结束位置
let startPos = document.positionAt(matchIndex);
let endPos = document.positionAt(matchIndex + matchText.length);
let range = new vscode.Range(startPos, endPos);
// 替换
await editor.edit((editBuilder) => {
editBuilder.replace(range, updatedImport as string);
});
} else {
// 直接插入import type
await editor.edit((editBuilder) => {
editBuilder.insert(
new vscode.Position(0, 0),
`import type { ${typeNames.join(",")} } from './types';\n`
);
});
}
// importStr导入语句需要进行判断再导入
// 例如:import request from '@service/request';
if (importStr && requestName) {
const importStatementRegex = new RegExp(
`import\\s+(?:\\{\\s*${requestName}\\s*\\}|${requestName})\\s+from\\s+['"]([^'"]+)['"];?`
);
const match = importStatementRegex.exec(editor.document.getText());
// 当前文件没有这个语句,插入
if (!match) {
await editor.edit((editBuilder) => {
editBuilder.insert(new vscode.Position(0, 0), `${importStr};\n`);
});
}
}
- 将生成的类型插入文件中
// 检查文件是否存在
if (fs.existsSync(targetFilePath)) {
const currentContent = fs.readFileSync(targetFilePath);
if (!currentContent.includes(typeName)) { // 判断类型是否已存在
try {
fs.appendFileSync(targetFilePath, content); // 追加内容
editor.document.save(); // 调用vscode api保存文件
return true;
} catch (err: any) {
......
return false;
}
} else {
......
return false;
}
} else { // 文件不存在,创建并写入类型
try {
fs.writeFileSync(targetFilePath, content);
editor.document.save();
return true;
} catch (err: any) {
......
}
}
- 替换原有函数字符串
const nextFnStr = functionText
.replace(/(\w+:\s*)(any)/, (_, $1) => {
if (query.apiReq) {
return `${$1}${query.typeName}`;
}
// 没参数
else {
return "";
}
})
.replace(/Promise<([a-zA-Z0-9_]+|any)>/ , (_, $1) => {
if (res?.apiRes) {
return `Promise<${res?.typeName}>`;
}
return `Promise` ;
})
.replace(/,\s*\{\s*params\s*\}/, (_) => {
// 对于没有参数的case, 应该删除参数
if (!query.apiReq) {
return "";
}
return _;
});
const startPosition = new vscode.Position(functionStartLine - 1, 0); // 减1因为VS Code的行数是从0开始的
const endPosition = new vscode.Position(
functionEndLine - 1,
document.lineAt(functionEndLine - 1).text.length
);
const textRange = new vscode.Range(startPosition, endPosition);
const editApplied = await editor.edit((editBuilder) => {
editBuilder.replace(textRange, nextFnStr);
});
......
const document = editor.document;
const fullText = document.getText(); // 调用vscode api 拿到当前文件字符串
// 匹配单引号或双引号,并确保结束引号与开始引号相匹配
const importRegex =
/(import\s+(type\s+)?\{\s*[^}]*)(}\s+from\s+(['"])\.\/types(\.ts)?['"]);?/g;
let matchIndex = fullText.search(importRegex); // 使用search得到全局匹配的起始索引
if (matchIndex !== -1) {
// 已经有类型语句
let matchText = fullText.match(importRegex)?.[0]; // 获取完整的匹配文本
// 去重,如果 import { a, b } from './types'中已经有typeNames中的类型,则不需要重复引入
// existingTypes = ['a', 'b']
const existingTypes = (
/\{\s*([^}]+)\s*\}/g.exec(matchText)?.[1] as string
)
.split(",")
.map((v) => v.trim());
const uniqueTypeNames = typeNames.filter(
(v) => !existingTypes.includes(v)
);
// 将生成的类型插入原有的import type语句中
// 例如: import { a } from './types'
// 生成了类型 b c 则变成 import { a, b, c } from './types'
let updatedImport = matchText?.replace(
importRegex,
(_, group1, group2, group3) => {
// group1 对应 $1,即 import 语句到第一个 "}" 之前的所有内容
// group3 对应 $3,即 "}" 到语句末尾的部分
return `${
(group1.trim() as string).endsWith(",") ? group1 : `${group1}, `
}${uniqueTypeNames.join(", ")} ${group3}`;
}
);
// 计算确切的起始和结束位置
let startPos = document.positionAt(matchIndex);
let endPos = document.positionAt(matchIndex + matchText.length);
let range = new vscode.Range(startPos, endPos);
// 替换
await editor.edit((editBuilder) => {
editBuilder.replace(range, updatedImport as string);
});
} else {
// 直接插入import type
await editor.edit((editBuilder) => {
editBuilder.insert(
new vscode.Position(0, 0),
`import type { ${typeNames.join(",")} } from './types';\n`
);
});
}
// importStr导入语句需要进行判断再导入
// 例如:import request from '@service/request';
if (importStr && requestName) {
const importStatementRegex = new RegExp(
`import\\s+(?:\\{\\s*${requestName}\\s*\\}|${requestName})\\s+from\\s+['"]([^'"]+)['"];?`
);
const match = importStatementRegex.exec(editor.document.getText());
// 当前文件没有这个语句,插入
if (!match) {
await editor.edit((editBuilder) => {
editBuilder.insert(new vscode.Position(0, 0), `${importStr};\n`);
});
}
}
总结
开发这个插件自己学到了不少东西,团队也用上了,有同学给了插件使用的反馈。
最后,试用期过了。
不过,新公司ppt文化是真的很重!!!
来源:juejin.cn/post/7423649211190591488
未登录也能知道你是谁?浏览器指纹了解一下!
引言
大多数人都遇到过这种场景,我在某个网站上浏览过的信息,但我并未登录,可是到了另一个网站发现被推送了类似的广告,这是为什么呢?
本文将介绍一种浏览器指纹的概念,以及如何利用它来判断浏览者身份。
浏览器指纹
浏览器指纹是指通过浏览器的特征来唯一标识用户身份的一种技术。
它通过记录用户浏览器的一些基本信息,包括操作系统、浏览器类型、浏览器版本、屏幕分辨率、字体、颜色深度、插件、时间戳等,通过这些信息,可以唯一标识用户身份。
应用场景
其实浏览器指纹这类的技术已经被运用的很广泛了,通常都是用在一些网站用途上,比如:
- 资讯等网站:精准推送一些你感兴趣的资讯给你看
- 购物网站: 精确推送一些你近期浏览量比较多的商品展示给你看
- 广告投放: 有一些网站是会有根据你的喜好,去投放不同的广告给你看的,大家在一些网站上经常会看到广告投放吧?
- 网站防刷: 有了浏览器指纹,就可以防止一些恶意用户的恶意刷浏览量,因为后端可以通过浏览器指纹认得这些恶意用户,所以可以防止这些用户的恶意行为
- 网站统计: 通过浏览器指纹,网站可以统计用户的访问信息,比如用户的地理位置、访问时间、访问频率等,从而更好的为用户提供服务
如何获取浏览器指纹
指纹算法有很多,这里介绍一个网站 https://browserleaks.com/
上面介绍了很多种指纹,可以根据自己的需要选择。
这里我们看一看canvas,可以看到光靠一个canvas的信息区分,就可以做到15万用户只有7个是重复的,如果结合其他信息,那么就可以做到更精准的识别。
canvas指纹
canvas
指纹的原理就是通过 canvas
生成一张图片,然后将图片的像素点信息记录下来,作为指纹信息。
不同的浏览器、操作系统、cpu、显卡等等,画出来的 canvas 是不一样的,甚至可能是唯一的。
具体步骤如下:
- 用canvas 绘制一个图像,在画布上渲染图像的方式可能因web浏览器、操作系统、图形卡和其他因素而异,从而生成可用于创建指纹的唯一图像。在画布上呈现文本的方式也可能因不同web浏览器和操作系统使用的字体渲染设置和抗锯齿算法而异。
- 要从画布生成签名,我们需要通过调用
toDataURL()
函数从应用程序的内存中提取像素。此函数返回表示二进制图像文件的base64
编码字符串。然后,我们可以计算该字符串的MD5
哈希来获得画布指纹。或者,我们可以从IDAT块
中提取CRC校验和
,IDAT块
位于每个PNG
文件末尾的16到12个字节处,并将其用作画布指纹。
我们来看看结果,可以知道,无论是否在无痕模式下,都可以生成相同的 canvas
指纹。
换台设备试试
其他浏览器指纹
除了canvas
,还有很多其他的浏览器指纹,比如:
WebGL 指纹
WebGL(Web图形库)
是一个 JavaScript API
,可在任何兼容的 Web
浏览器中渲染高性能的交互式 3D
和 2D
图形,而无需使用插件。
WebGL
通过引入一个与 OpenGL ES 2.0
非常一致的 API
来做到这一点,该 API
可以在 HTML5
元素中使用。
这种一致性使 API
可以利用用户设备提供的硬件图形加速。
网站可以利用 WebGL
来识别设备指纹,一般可以用两种方式来做到指纹生产:
WebGL 报告
——完整的 WebGL
浏览器报告表是可获取、可被检测的。在一些情况下,它会被转换成为哈希值以便更快地进行分析。
WebGL 图像
——渲染和转换为哈希值的隐藏 3D
图像。由于最终结果取决于进行计算的硬件设备,因此此方法会为设备及其驱动程序的不同组合生成唯一值。这种方式为不同的设备组合和驱动程序生成了唯一值。
可以通过 Browserleaks test
检测网站来查看网站可以通过该 API
获取哪些信息。
产生 WebGL
指纹原理是首先需要用着色器(shaders)
绘制一个梯度对象,并将这个图片转换为Base64
字符串。
然后枚举 WebGL
所有的拓展和功能,并将他们添加到 Base64
字符串上,从而产生一个巨大的字符串,这个字符串在每台设备上可能是非常独特的。
例如 fingerprint2js
库的 WebGL
指纹生产方式:
HTTP标头
每当浏览器向服务器发送请求时,它会附带一个HTTP标头,其中包含了诸如浏览器类型、操作系统、语言偏好等信息。
这些信息可以帮助网站优化用户体验,但同时也能用来识别和追踪用户。
屏幕分辨率
屏幕分辨率指的是浏览器窗口的大小和设备屏幕的能力,这个参数因用户设备的不同而有所差异,为浏览器指纹提供了又一个独特的数据点。
时区
用户设备的本地时间和日期设置可以透露其地理位置信息,这对于需要提供地区特定内容的服务来说是很有价值的。
浏览器插件
用户安装的插件列表是非常独特的,可以帮助形成识别个体的浏览器指纹。
音频和视频指纹
通过分析浏览器处理音频和视频的方式,网站可以获取关于用户设备音频和视频硬件的信息,这也可以用来构建用户的浏览器指纹。
那么如何防止浏览器指纹呢?
先讲结论,成本比较高,一般人不会使用。
现在开始实践,根据上述的原理,我们知道了如何生成一个浏览器指纹,我们只需要它在获取toDataURL
时,修改其中的内容,那么结果就回产生差异,从而无法通过浏览器指纹进行识别。
那么,我们如何修改toDataURL
的内容呢?
我们不知道它会在哪里调用,所以我们只能通过修改它的原型链来修改。
又或者使用专门的指纹浏览,该浏览器可以随意切换js版本等信息来造成无序的随机值。
修改 toDataURL
第三方指纹库
FingerprintJS
FingerprintJS
是一个源代码可用的客户端浏览器指纹库,用于查询浏览器属性并从中计算散列访问者标识符。
与cookie
和本地存储不同,指纹在匿名/私人模式下保持不变,即使浏览器数据被清除。
ClientJS Library
ClientJS
是另一个常用的JavaScript
库,它通过检测浏览器的多个属性来生成指纹。
该库提供了易于使用的接口,适用于多种浏览器指纹应用场景。
来源:juejin.cn/post/7382344353069088803
如何为上传文件取一个唯一的文件名
作者:陈杰
背景
古茗内部有一个 CDN 文件上传平台,用户在平台上传文件时,会将文件上传至阿里云 OSS 对象存储,并将 OSS 链接转换成 CDN 链接返回给用户,即可通过 CDN 链接访问到文件资源。我们对 CDN 文件的缓存策略是持久化强缓存(Cache-Control: public, max-age=31536000
),这就要求所有上传文件的文件名都是唯一的,否则就有文件被覆盖的风险。有哪些方式可以保证文件名全局唯一?
唯一命名方式
方式一:使用时间戳+随机数
这是我们最容易想到的一种方式:
const name = Date.now() + Math.random().toString().slice(2, 6);
// '17267354922380490'
使用时间戳,加上 4 位随机数,已经可以 99.99999% 保证不会存在文件名重复。可以稍微优化一下:
const name = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
// 'm191x7bii63s'
将时间戳和随机数分别转换成 36 进制,以减少字符串长度。通过上面一步优化可以将字符长度从 17 位减少至 12 位。
使用时间戳+随机数作为文件名的优势是简单粗暴,基本上可以满足述求;但是有极小概率存在文件名冲突的可能。
方式二:使用文件 MD5 值
生成文件的 MD5 Hash 摘要值,在 node 中代码示例如下:
const crypto = require('crypto');
const name = crypto.createHash('md5').update([file]).digest('hex');
// 'f668bd04d1a6cfc29378e24829cddba9'
文件的 MD5 Hash 值可以当成文件指纹,每个文件都会生成唯一的 hash 值(有极小的概率会 hash 碰撞,可以忽略)。使用 MD5 Hash 值作为文件名还可以避免相同文件重复上传;但是缺点是文件名较长。
方式三:使用 UUID
UUID (通用唯一识别码) 是用于计算机体系中以识别信息的一个标识符,重复的概率接近零,可以忽略不计。生成的 UUID 大概长这样:279e573f-c787-4a84-bafb-dfdc98f445cc。
使用 UUID 作为文件名的缺点也是文件名较长。
最终方案
从上述的几种命名方式可以看出,每种方式都有各种的优缺点,直接作为 OSS 的文件命名都不是很满意(期望 CDN 链接尽可能简短)。所以我们通过优化时间戳+随机数方式来作为最终方案版本。
本质上还是基于时间戳、随机数 2 部分来组成文件名,但是有以下几点优化:
- 由于 CDN 链接区分大小写,可以充分利用 数字+大写字母+小写字母(一共 62 个字符),也就是可以转成 62 进制,来进一步缩短字符长度
- 时间戳数字的定义是,当前时间减去 1970-01-01 的毫秒数。显然在 2024 年的今天,这个数字是非常大的。对此,可以使用 当前时间减去 2024-01-01 的毫秒数 来优化,这会大幅减少时间戳数字大小(2024-01-01 这个时间点是固定的,而且必须是功能上线前的一个时间点,确保不会减出负数)
示例代码如下:
/**
* 10 进制整数转 62 进制
*/
function integerToBase62(value) {
const base62Chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const base62 = base62Chars.length;
value = parseInt(value);
if (isNaN(value) || !value) {
return String(value);
}
let prefix = '';
if (value < 0) {
value = -value;
prefix = '-';
}
let result = '';
while (value > 0) {
const remainder = value % base62;
result = base62Chars[remainder] + result;
value = Math.floor(value / base62);
}
return prefix + result || '0';
}
const part1 = integerToBase62(Date.now() - new Date('2024-01-01').getTime()); // 'OkLdmK'
const part2 = integerToBase62(Math.random() * 1000000).slice(-4); // '3hLT'
const name = part1 + part2; // 'OkLdmK3hLT'
最终文件名字符长度减少到 10 位。但是始终感觉给 4 位随机数太浪费了,于是想着能否在保证唯一性的同时,还能减少随机数的位数。那就只能看看时间戳部分还能不能压榨一下。
只要能保证同一毫秒内只生成一个文件的文件名,就可以保证这个文件名是唯一的,这样的话,随机数部分都可以不要了,所以可以做如下优化:
// 伪代码
async function getFileName() {
// 等待锁释放,并发调用时保证至少等待 1ms
await waitLockRelease();
return integerToBase62(Date.now() - new Date('2024-01-01').getTime());
}
const name = await getFileName();
// 'OkLdmK'
由于 node 服务线上是多实例部署,所以 waitLockRelease
方法是基于 Redis 来实现多进程间加锁,保证多进程间创建的文件名也是唯一的。与此同时,还额外加上了一位随机数,来做冗余设计。最终将文件名字符长度减少至 7 位,且可以 100% 保证唯一性!
总结
看似非常简单的一个问题,想要处理的比较严谨和完美,其实也不太容易,甚至引入了 62 进制编码及加锁逻辑的处理。希望本文的分享能给大家带来收获!
来源:juejin.cn/post/7424901430378545164
签字板很难吗?纯 JS 实现一个!
前段时间有位同学问我:“公司项目中需要增加一个签字板的功能”,问我如何进行实现。
我说:“这种功能很简单呀,目前市面上有很多开源的库,比如:signature_pad
就可以直接引入实现”。
但是,该同学说自己公司的项目比较特殊,尽量不要使用 第三方的库,所以想要自己实现,那怎么办呢?
没办法!只能帮他实现一个了.
签字板实现逻辑
签字板的功能实现其实并不复杂,核心是 基于 canvas 的 2d
绘制能力,监听用户 鼠标 或者 手指 的移动行为,完成对应的 线绘制。
所以,想要实现签字板那么必须要有一个 canvas
,先看 html 的实现部分:
html
<body>
<canvas id="signature-pad" width="400" height="200">canvas>
<div class="controls">
<select id="stroke-style">
<option value="pen">钢笔option>
<option value="brush">毛笔option>
select>
<button id="clear">清空button>
div>
<script src="script.js">script>
body>
我们可以基于以上代码完成 HTML 布局,核心是两个内容:
canvas
画布:它是完成签字板的关键controls
控制器:通过它可以完成 画笔切换 以及 清空画布 的功能
css
css 相对比较简单,大家可以根据自己的需求进行调整就可以了,以下是 css 大家可以作为参考:
* {
margin: 0;
padding: 0;
}
body {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
height: 100vh;
width: 100vw;
background-color: #f0f0f0;
overflow: hidden;
}
canvas {
border: 1px solid #000;
background-color: #fff;
}
.controls {
margin-top: 10px;
display: flex;
gap: 10px;
}
button,
select {
padding: 5px 10px;
cursor: pointer;
}
js
js 部分是整个签字板的核心,我们需要在这里考虑较多的内容,比如:
- 为了绘制更加平滑,我们需要使用
ctx.quadraticCurveTo
方法完成平滑过渡 - 为了解决移动端手指滑动滚动条的问题,我们需要在
move
事件中通过e.preventDefault()
取消默认行为 - 为了完成画笔切换,我们需要监听
select
的change
事件,从而修改ctx.lineWidth
画笔
最终得到的 js 代码如下所示(代码中提供的详细的注释):
document.addEventListener('DOMContentLoaded', function () {
// 获取 canvas 元素和其 2D 上下文
var canvas = document.getElementById('signature-pad')
var ctx = canvas.getContext('2d')
var drawing = false // 标志是否正在绘制
var lastX = 0,
lastY = 0 // 保存上一个点的坐标
var strokeStyle = 'pen' // 初始笔画样式
// 开始绘制的函数
function startDrawing(e) {
e.preventDefault() // 阻止默认行为,避免页面滚动
drawing = true // 设置为正在绘制
ctx.beginPath() // 开始新路径
// 记录初始点的位置
const { offsetX, offsetY } = getEventPosition(e)
lastX = offsetX
lastY = offsetY
ctx.moveTo(offsetX, offsetY) // 移动画笔到初始位置
}
// 绘制过程中的函数
function draw(e) {
e.preventDefault() // 阻止默认行为,避免页面滚动
if (!drawing) return // 如果不是在绘制,直接返回
// 获取当前触点位置
const { offsetX, offsetY } = getEventPosition(e)
// 使用贝塞尔曲线进行平滑过渡绘制
ctx.quadraticCurveTo(
lastX,
lastY,
(lastX + offsetX) / 2,
(lastY + offsetY) / 2
)
ctx.stroke() // 实际绘制路径
// 更新上一个点的位置
lastX = offsetX
lastY = offsetY
}
// 停止绘制的函数
function stopDrawing(e) {
e.preventDefault() // 阻止默认行为
drawing = false // 结束绘制状态
}
// 获取事件中触点的相对位置
function getEventPosition(e) {
// 鼠标事件或者触摸事件中的坐标
const offsetX = e.offsetX || e.touches[0].clientX - canvas.offsetLeft
const offsetY = e.offsetY || e.touches[0].clientY - canvas.offsetTop
return { offsetX, offsetY }
}
// 鼠标事件绑定
canvas.addEventListener('mousedown', startDrawing) // 鼠标按下开始绘制
canvas.addEventListener('mousemove', draw) // 鼠标移动时绘制
canvas.addEventListener('mouseup', stopDrawing) // 鼠标抬起停止绘制
canvas.addEventListener('mouseout', stopDrawing) // 鼠标移出画布停止绘制
// 触摸事件绑定
canvas.addEventListener('touchstart', startDrawing) // 触摸开始绘制
canvas.addEventListener('touchmove', draw) // 触摸移动时绘制
canvas.addEventListener('touchend', stopDrawing) // 触摸结束时停止绘制
canvas.addEventListener('touchcancel', stopDrawing) // 触摸取消时停止绘制
// 清除画布的功能
document.getElementById('clear').addEventListener('click', function () {
ctx.clearRect(0, 0, canvas.width, canvas.height) // 清空整个画布
})
// 修改笔画样式的功能
document
.getElementById('stroke-style')
.addEventListener('change', function (e) {
strokeStyle = e.target.value // 获取选中的笔画样式
updateStrokeStyle() // 更新样式
})
// 根据 strokeStyle 更新笔画样式
function updateStrokeStyle() {
if (strokeStyle === 'pen') {
ctx.lineWidth = 2 // 细线条
ctx.lineCap = 'round' // 线条末端圆角
} else if (strokeStyle === 'brush') {
ctx.lineWidth = 5 // 粗线条
ctx.lineCap = 'round' // 线条末端圆角
}
}
// 初始化默认的笔画样式
updateStrokeStyle()
})
以上就是 纯JS实现签字板的完整代码,大家可以直接组合代码进行使用,最终展示的结果如下:
来源:juejin.cn/post/7424498500890705935
蓝牙耳机丢了,我花几分钟写了一个小程序,找到了!
你是否曾经经历过蓝牙耳机不知道丢到哪里去的困扰?特别是忙碌的早晨,准备出门时才发现耳机不见了,整个心情都被影响。幸运的是,随着技术的进步,我们可以利用一些简单的小程序和蓝牙技术轻松找到丢失的耳机。今天,我要分享的是我如何通过一个自制的小程序,利用蓝牙发现功能,成功定位自己的耳机。这不仅是一次有趣的技术尝试,更是对日常生活中类似问题的一个智能化解决方案。
1. 蓝牙耳机丢失的困扰
现代生活中,蓝牙耳机几乎是每个人的必备品。然而,耳机的体积小、颜色常常与周围环境融为一体,导致丢失的情况时有发生。传统的寻找方式依赖于我们对耳机放置地点的记忆,但往往不尽人意。这时候,如果耳机还保持在开机状态,我们就可以借助蓝牙技术进行定位。然而,市场上大部分设备并没有自带这类功能,而我们完全可以通过编写小程序实现。
2. 蓝牙发现功能的原理
蓝牙发现功能是通过设备之间的信号传输进行连接和识别的。当一个蓝牙设备处于开机状态时,它会周期性地广播自己的信号,周围的蓝牙设备可以接收到这些信号并进行配对。这个过程的背后其实是信号的强度和距离的关系。当我们在手机或其他设备上扫描时,能够检测到耳机的存在,但并不能直接告诉我们耳机的具体位置。此时,我们可以通过信号强弱来推测耳机的大概位置。
3. 实现步骤:从构想到实践
有了这个想法后,我决定动手实践。首先,我使用微信小程序作为开发平台,利用其内置的蓝牙接口实现设备扫描功能。具体步骤如下:
- • 环境搭建:选择微信小程序作为平台主要因为其开发简便且自带蓝牙接口支持。
- • 蓝牙接口调用:调用
wx.openBluetoothAdapter
初始化蓝牙模块,确保设备的蓝牙功能开启。 - • 设备扫描:通过
wx.startBluetoothDevicesDiscovery
函数启动设备扫描,并使用wx.onBluetoothDeviceFound
监听扫描结果。 - • 信号强度分析:通过读取蓝牙信号强度(RSSI),结合多次扫描的数据变化,推测设备的距离,最终帮助定位耳机。
在代码的实现过程中,信号强度的变化尤为重要。根据RSSI值的波动,我们可以判断耳机是在靠近还是远离,并通过走动测试信号的变化,逐渐缩小搜索范围。
下面是我使用 Taro 实现的全部代码:import React, { useState, useEffect } from "react";
import Taro, { useReady } from "@tarojs/taro";
import { View, Text } from "@tarojs/components";
import { AtButton, AtIcon, AtProgress, AtList, AtListItem } from "taro-ui";
import "./index.scss";
const BluetoothEarphoneFinder = () => {
const [isSearching, setIsSearching] = useState(false);
const [devices, setDevices] = useState([]);
const [nearestDevice, setNearestDevice] = useState(null);
const [isBluetoothAvailable, setIsBluetoothAvailable] = useState(false);
const [trackedDevice, setTrackedDevice] = useState(null);
useEffect(() => {
if (isSearching) {
startSearch();
} else {
stopSearch();
}
}, [isSearching]);
useEffect(() => {
if (devices.length > 0) {
const nearest = trackedDevice
? devices.find((d) => d.deviceId === trackedDevice.deviceId)
: devices[0];
setNearestDevice(nearest || null);
} else {
setNearestDevice(null);
}
}, [devices, trackedDevice]);
const startSearch = () => {
const startDiscovery = () => {
setIsBluetoothAvailable(true);
Taro.startBluetoothDevicesDiscovery({
success: () => {
Taro.onBluetoothDeviceFound((res) => {
const newDevices = res.devices.map((device) => ({
name: device.name || "未知设备",
deviceId: device.deviceId,
rssi: device.RSSI,
}));
setDevices((prevDevices) => {
const updatedDevices = [...prevDevices];
newDevices.forEach((newDevice) => {
const index = updatedDevices.findIndex(
(d) => d.deviceId === newDevice.deviceId
);
if (index !== -1) {
updatedDevices[index] = newDevice;
} else {
updatedDevices.push(newDevice);
}
});
return updatedDevices.sort((a, b) => b.rssi - a.rssi);
});
});
},
fail: (error) => {
console.error("启动蓝牙设备搜索失败:", error);
Taro.showToast({
title: "搜索失败,请重试",
icon: "none",
});
setIsSearching(false);
},
});
};
Taro.openBluetoothAdapter({
success: startDiscovery,
fail: (error) => {
if (error.errMsg.includes("already opened")) {
startDiscovery();
} else {
console.error("初始化蓝牙适配器失败:", error);
Taro.showToast({
title: "蓝牙初始化失败,请检查蓝牙是否开启",
icon: "none",
});
setIsSearching(false);
setIsBluetoothAvailable(false);
}
},
});
};
const stopSearch = () => {
if (isBluetoothAvailable) {
Taro.stopBluetoothDevicesDiscovery({
complete: () => {
Taro.closeBluetoothAdapter({
complete: () => {
setIsBluetoothAvailable(false);
},
});
},
});
}
};
const getSignalStrength = (rssi) => {
if (rssi >= -50) return 100;
if (rssi <= -100) return 0;
return Math.round(((rssi + 100) / 50) * 100);
};
const getDirectionGuide = (rssi) => {
if (rssi >= -50) return "非常接近!你已经找到了!";
if (rssi >= -70) return "很近了,继续朝这个方向移动!";
if (rssi >= -90) return "正确方向,但还需要继续寻找。";
return "信号较弱,尝试改变方向。";
};
const handleDeviceSelect = (device) => {
setTrackedDevice(device);
Taro.showToast({
title: `正在跟踪: ${device.name}`,
icon: "success",
duration: 2000,
});
};
return (
"bluetooth-finder">
{isSearching && (
"loading-indicator">
"loading-3" size="30" color="#6190E8" />
"loading-text">搜索中...Text>
View>
)}
{nearestDevice && (
"nearest-device">
"device-name">{nearestDevice.name}Text>
{getSignalStrength(nearestDevice.rssi)}
status="progress"
isHidePercent
/>
"direction-guide">
{getDirectionGuide(nearestDevice.rssi)}
Text>
View>
)}
"device-list">
{devices.map((device) => (
{device.deviceId}
title={device.name}
note={`${device.rssi} dBm`}
extraText={
trackedDevice && trackedDevice.deviceId === device.deviceId
? "跟踪中"
: ""
}
arrow="right"
onClick={() => handleDeviceSelect(device)}
/>
))}
AtList>
View>
"action-button">
type="primary"
circle
onClick={() => setIsSearching(!isSearching)}
>
{isSearching ? "停止搜索" : "开始搜索"}
AtButton>
View>
View>
);
};
export default BluetoothEarphoneFinder;
嘿嘿,功夫不负苦心人,我最终通过自己的小程序找到了我的蓝牙耳机。
我将我的小程序发布到了微信小程序上,目前已经通过审核,可以直接使用了。搜索老码宝箱 即可体验。
顺带还加了非常多的小工具,而且里面还有非常多日常可能会用到的工具,有些还非常有意思。
比如
绘制函数图
每日一言
汇率转换(实时)
BMI 计算
简易钢琴
算一卦
这还不是最重要的
最重要的是,这里的工具是会不断增加的,而且,更牛皮的是,你还可以给作者提需求,增加你想要的小工具,作者是非常欢迎一起讨论的。有朝一日,你也希望你的工具也出现在这个小程序上,被千万人使用吧。
4. 实际应用与优化空间
这个小程序的实际效果超出了我的预期。我能够通过它快速找到丢失的耳机,整个过程不到几分钟时间。然而,值得注意的是,由于蓝牙信号会受到环境干扰,例如墙体、金属物等,导致信号强度并不总是精确。在后续的优化中,我计划加入更多的信号处理算法,例如利用三角定位技术,结合多个信号源来提高定位精度。此外,还可以考虑在小程序中加入可视化的信号强度图,帮助用户更直观地了解耳机的大致方位。
一些思考:
蓝牙耳机定位这个小程序的开发,展示了技术在日常生活中的强大应用潜力。虽然这个项目看似简单,但背后的原理和实现过程非常具有教育意义。通过这次尝试,我们可以看到,借助开源技术和简单的编程能力,我们能够解决许多日常生活中的实际问题。
参考资料:
- 微信小程序官方文档:developers.weixin.qq.com
- 蓝牙信号强度(RSSI)与距离关系的研究:http://www.bluetooth.com
- 个人开发者经验分享: 利用蓝牙发现功能定位设备
来源:juejin.cn/post/7423610485180727332
opentype.js 使用与文字渲染
大家好,我是前端西瓜哥。
opentype.js 是一个 JavaScript 库,支持浏览器和 Node.js,可以解析字体文件,拿到字体信息,并提供一些渲染方法。
虽然名字叫做 opentype.js,但除了可以解析 OpenType,也可以解析 TrueType。
支持常见的字体类型,比如 WOFF, OTF, TTF。像是 AutoCAD 的 shx 就不支持了。
本文使用的 opentype.js 版本为 1.3.4
加载文字
加载文件字体为二进制数据,然后使用 opentype.js 解析:
import opentype from 'opentype.js'
const buffer = await fetch('./SourceHanSansCN-Normal.otf').then(
(res) => res.arrayBuffer(),
);
const font = opentype.parse(buffer);
需要注意的是,woff2 字体是用 Brotli 压缩过的文件,需要你额外用解压库做解压。
opentype.js 没有提供对应解压 Brotli 的能力,倒是提供了 Inflate 解压能力,所以可以解析 woff 字体。
font 这个对象保存了很多属性。
比如所有的 glyph(字形)、一些 table(表)、字体的信息(字体名、设计师等)等等。
获取字形(glyph)信息
glyph 为单个需要渲染的字形,是渲染的最小单位。
const glyph = font.charToGlyph('i')
另外 stringToGlyphs
方法会返回一个 glyph 数组。
const glyphs = font.stringToGlyph('abcd');
获取文字轮廓(path)
getPaths 计算得到一段字符串中每个 glyph 的轮廓数据。传入的坐标值为文字的左下角位置和文字大小。
const x = 60;
const y = 60;
const fontSize = 24;
const text = '前端西瓜哥/Ab1';
const textPaths = font.getPaths(text, x, y, fontSize);
textPaths 是一个 path 数组。
字符串长度为 9,产生了 9 个 glyph(字形),所以一共有 9 个 path 对象。
形状的表达使用了经典的 SVG 的 Path 命令,对应着 command 属性。
TrueType 字体的曲线使用二阶贝塞尔曲线(对应 Q 命令);而 OpenType 支持三阶贝塞尔曲线(对应 C 命令)。
渲染
我们有了 Path 数据,就能渲染 SVG 或 Canvas。
当然这个 OpenType.js 专门暴露了方法给我们,不用自己折腾做这层转换实现。
Canvas
基于 Canvas 2D 的方式绘制文字。
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
// ...
font.draw(ctx, text, x, y, fontSize);
渲染效果:
如果使用的字体找不到对应的字形,比如只支持英文的字体,但是却想要渲染中文字符。
此时 opentype.js 会帮你显示一个 豆腐块(“tofu” glyph)。豆腐块是取自字体自己设定的 glyph,不同字体的豆腐块还长得不一样,挺有意思的。
辅助点和线
字体是基于直线和贝塞尔曲线控制点形成的轮廓线进行表达的,我们可以绘制字体的控制点:
font.drawPoints(ctx, text, x, y, fontSize);
对文字做度量(metrics)得到尺寸数据。蓝色线为包围盒,绿色线为前进宽度。
font.drawMetrics(ctx, text, x, y, fontSize);
SVG
Path 实例有方法可以转为 SVG 中 Path 的 pathData 字符串。(Glyph 对象也支持这些方法)
path 长这个样子:
"M74.5920 47.6640L74.5920 57.5040L76.1040 57.5040L76.1040 47.6640ZM79.4640 46.9200L79.4640 59.8080C79.4640 60.1440 79.3440 60.2400 78.9600 60.2640C78.5520 60.2880 77.2320 60.2880 75.7440 60.2400C75.9840 60.6720 76.2480 61.3440 76.3200 61.7760Z"
拿到一段字符串对应的 path。
const textPath = font.getPath(text, x, y, fontSize);
const pathData = textPath.toPathData(4); // 4 为小数精度
// 创建 path 元素,指定 d 属性,添加到 SVG 上...
渲染结果。
另外还有一个 getPaths 方法,会返回一个 path 数组,里面是每个 glyph 的 path。
也可以直接拿到一个 path 元素的字符串形式。
path.toSVG(4)
会返回类似这样的字符串:
<path d="M74.5920 47.6640L74.5920 57.5040L76.1040 57.5040L76.1040 47.6640ZM79.4640 46.9200L79.4640 59.8080C79.4640 60.1440 79.3440 60.2400 78.9600 60.2640C78.5520 60.2880 77.2320 60.2880 75.7440 60.2400C75.9840 60.6720 76.2480 61.3440 76.3200 61.7760Z"/>
连字(ligature)
连字(合字、Ligatrue),指的是能够将多个字符组成成一个字符(glyph)。如:
像是 FiraCode 编程字体,该字体会将一些符号进行连字。
opentype.js 虽然说自己支持连字(Support for ligatures),但实际测试发现 API 好像并不会做处理。
用法为:
const textPath = font.getPath(text, x, y, fontSize, {
features: { liga: true },
});
字距(kerning)
两个 glyph 的距离如果为 0,会因为负空间不均匀,导致视觉上的失衡。
此时字体设计师就会额外调整特定 glyph 之间的字距(kerning),使其空间布局保持均衡。如下图:
opentype.js 可以帮我们获取两个 glyph 之间的字距。
const leftGlyph = font.charToGlyph('A');
const rightGlyph = font.charToGlyph('V');
font.getKerningValue(leftGlyph, rightGlyph)
// -15
返回值为 -15。代表右侧的字形 V 需往左移动 15 的距离。
结尾
本文简单介绍了 opentype.js 的一些用法,更多用法可以阅读官方文档。
不过你大概发现里面有某些方法对不上号,应该是迟迟未发布的 2.0.0 版本的文档。所以正确做法是切为 1.3.4 分支阅读 README.md 文档。
我是前端西瓜哥,关注我学习更多前端知识。
来源:juejin.cn/post/7424906244215455780
setTimeout是准时的吗?
引言
最近在一些论坛上,有人讨论 setTimeout
的准确性。因此,我进行了探索,以解答这个问题。结果发现,setTimeout
并不完全可靠,因为它是一个宏任务。所指定的时间实际上是将任务放入主线程队列的时间,而不是任务实际执行的时间。
`setTimeout(callback, 进入主线程的时间)`
因此,何时执行回调取决于主线程上待处理的任务数量。
演示
这段代码使用一个计数器来记录每次 setTimeout
的调用。设定的间隔时间乘以计数次数,理想情况下应等于预期的延迟。通过以下示例,可以检查我们计时器的准确性。
function time () {
var speed = 50, // 间隔
count = 1 // 计数
start = new Date().getTime();
function instance() {
var ideal=(count * speed),
real = (new Date().getTime() - start);
count++;
console.log(count + '理想值------------------------:', ideal); // 记录理想值
console.log(count + '真实值------------------------:', real); // 记录理想值
var diff = (real - ideal);
console.log(count + '差值------------------------:', diff); // 差值
// 小于5执行
if (count < 5) {
window.setTimeout(function(){
instance();
}, speed);
};
};
window.setTimeout(function () {
instance();
}, speed);
};
打印1
我们可以在 setTimeout
执行之前加入额外的代码逻辑,然后再观察这个差值。
...
window.setTimeout(function(){
instance();
}, speed);
for(var a = 1, i = 0; i < 10000000; i++) {
a *= (i + 1);
};
...
打印2
可以看出,这大大增加了误差。随着时间的推移,setTimeout
实际执行的时间与理想时间之间的差距会不断扩大,这并不是我们所期望的结果。在实际应用中,例如倒计时和动画,这种时间偏差会导致不理想的效果。
如何实现更精准的 setTimeout
?
requestAnimationFrame
window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。
该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行,回调函数执行次数通常是每秒60次,也就是每16.7ms 执行一次,但是并不一定保证为 16.7 ms。
我们用requestAnimationFrame
模拟 setTimeout
function setTimeout2(cb, delay) {
const startTime = Date.now();
function loop() {
const now = Date.now();
if (now - startTime >= delay) {
cb();
} else {
requestAnimationFrame(loop);
}
}
requestAnimationFrame(loop);
};
打印3
貌似误差问题还是没有得到解决,因此这个方案还是不行。
while
想得到准确的,我们第一反应就是如果我们能够主动去触发,获取到最开始的时间,以及不断去轮询当前时间,如果差值是预期的时间,那么这个定时器肯定是准确的,那么用while
可以实现这个功能。
function time2(time) {
const startTime = Date.now();
function checkTime() {
const now = Date.now();
if (now - startTime >= time) {
console.log('误差', now - startTime - time);
} else {
setTimeout(checkTime, 1); // 每毫秒检查一次
}
}
checkTime();
}
time2(5000);
误差存在是 2
, 甚至为 0
, 但使用 while(true)
会导致 CPU 占用率极高,因为它会持续循环而不进行任何等待,会使得页面进入卡死状态,这样的结果显然是不合适的。
setTimeout 系统时间补偿
这个方案是在 Stack Overflow
看到的一个方案,我们来看看此方案和原方案的区别。
当每一次定时器执行时后,都去获取系统的时间来进行修正,虽然每次运行可能会有误差,但是通过系统时间对每次运行的修复,能够让后面每一次时间都得到一个补偿。
function time () {
var speed = 50, // 间隔
count = 1 // 计数
start = new Date().getTime();
function instance() {
var ideal=(count * speed),
real = (new Date().getTime() - start);
count++;
console.log(count + '理想值------------------------:', ideal); // 记录理想值
console.log(count + '真实值------------------------:', real); // 记录理想值
var diff = (real - ideal);
console.log(count + '差值------------------------:', diff); // 差值
// 5次后不再执行
if (count < 5) {
window.setTimeout(function(){
instance();
}, (speed - diff));
};
};
window.setTimeout(function () {
instance();
}, speed);
};
打印4
结论
多次尝试后,是非常稳定的,误差微乎其微,几乎可以忽略不计,因此通过系统的时间补偿,能使 setTimeout
变得更加准时。
来源:juejin.cn/post/7420059840971980834
CSS实现一个故障时钟效果
起因
最近公司事情不是太多,我趁着这段时间在网上学习一些Cool~
的效果。今天我想和大家分享一个故障时钟的效果。很多时候,一个效果开始看起来很难,但是当你一步步摸索之后,就会发现其实它们只是由一些简单的效果组合而成的。
什么是故障效果(Glitch)
"glitch" 效果是一种模拟数字图像或视频信号中出现的失真、干扰或故障的视觉效果。它通常表现为图像的一部分或整体闪烁、抖动、扭曲、重叠或变形。这种效果常常被用来传达技术故障、数字崩溃、未来主义、复古风格等主题,也经常在艺术作品、音乐视频、电影、广告和网页设计中使用。Glitch 效果通常通过调整图像、视频或音频的编码、解码或播放过程中的参数来实现。 来自ChatGPT
可以看到关键的表现为一部分或整体闪烁、抖动、扭曲、重叠或变形
,所以我们应该重点关注用CSS
实现整体闪烁、抖动、扭曲、重叠或变形
CSS
实现闪烁
Glitch 闪烁通常是指图像或视频中出现的突然的、不规则的、瞬间的明暗变化或闪烁效果
那么我们有没有办法通过CSS
来实现上述的效果,答案是通过随机不规则的clip-path
来实现!
我们先来看看clip-path
的定义与用法
clip-path
CSS 属性使用裁剪方式创建元素的可显示区域。区域内的部分显示,区域外的隐藏。
/* <basic-shape> values */
clip-path: inset(100px 50px);
clip-path: circle(50px at 0 100px);
clip-path: ellipse(50px 60px at 0 10% 20%);
clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
clip-path: path(
"M0.5,1 C0.5,1,0,0.7,0,0.3 A0.25,0.25,1,1,1,0.5,0.3 A0.25,0.25,1,1,1,1,0.3 C1,0.7,0.5,1,0.5,1 Z"
);
再想想所谓的Glitch
故障闪烁时的效果是不是就是部分画面被切掉了~
span {
display: block;
position: relative;
font-size: 128px;
line-height: 1;
animation: clock 1s infinite linear alternate-reverse;
}
@keyframes clock {
0%{
clip-path: inset(0px 0px calc(100% - 10px) 0);
}
100%{
clip-path: inset(calc(100% - 10px) 0px 0px 0);
}
}
此时的效果如下:
啥啥啥,这看着是什么呀根本不像闪烁效果嘛,先别急,想想我们闪烁效果的定义突然的、不规则的、瞬间的明暗变化
,此时因为我们是在切割整体元素,如果我们再后面再重叠一个正常元素!
span {
display: block;
position: relative;
font-size: 128px;
line-height: 1;
//animation: clock 1s infinite linear alternate-reverse;
&:before{
display: block;
content: attr(data-time);
position: absolute;
top: 0;
color: $txt-color;
background: $bg-color;
overflow: hidden;
width: 720px;
height: 128px;
}
&:before {
left: -2px;
animation: c2 1s infinite linear alternate-reverse;
}
}
@keyframes c2 {
0%{
clip-path: inset(0px 0px calc(100% - 10px) 0);
}
100%{
clip-path: inset(calc(100% - 10px) 0px 0px 0);
}
}
可以看到通过手动偏移了-2px
后然后不断剪裁元素已经有了一定的闪烁效果,但是目前的闪烁效果过于呆滞死板,我们通过scss
的随机函数优化一下效果。
@keyframes c2 {
@for $i from 0 through 20 {
#{percentage($i / 20)} {
$y1: random(100);
$y2: random(100);
clip-path: polygon(0% $y1 * 1px, 100% $y1 * 1px, 100% $y2 * 1px, 0% $y2 * 1px);
}
}
23% {
transform: scaleX(0.8);
}
}
此时效果如下
可以看到闪烁的效果已经很强烈了,我们依葫芦画瓢再叠加一个元素上去使得故障效果再强烈一些。
span {
display: block;
position: relative;
font-size: 128px;
line-height: 1;
&:before,
&:after {
display: block;
content: attr(data-time);
position: absolute;
top: 0;
color: $txt-color;
background: $bg-color;
overflow: hidden;
width: 720px;
height: 128px;
}
&:before {
left: calc(-#{$offset-c2});
text-shadow: #{$lay-c2} 0 #{$color-c2};
animation: c2 1s infinite linear alternate-reverse;
}
&:after {
left: #{$offset-c1};
text-shadow: calc(-#{$lay-c1}) 0 #{$color-c1};
animation: c1 2s infinite linear alternate-reverse;
}
}
此时我们已经通过:before
和:after
叠加了相同的元素并且一个设置蓝色一个设置红色,让故障效果更真实!
CSS
实现扭曲效果
上述的效果已经非常贴近我们传统意义上理解的Glitch
效果了,但是还差了一点就是通常表现为图像的一部分或整体闪烁、抖动、扭曲、重叠或变形
中的扭曲
和变形
,碰巧的是CSS
实现这个效果非常容易,来看看~
skewX()
函数定义了一个转换,该转换将元素倾斜到二维平面上的水平方向。它的结果是一个<transform-function>
数据类型。
Cool~
最后一块拼图也被补上了~~
@keyframes is-off {
0%, 50%, 80%, 85% {
opacity: 1;
}
56%, 57%, 81%, 84% {
opacity: 0;
}
58% {
opacity: 1;
}
71%, 73% {
transform: scaleY(1) skewX(0deg);
}
72% {
transform: scaleY(3) skewX(-60deg);
}
91%, 93% {
transform: scaleX(1) scaleY(1) skewX(0deg);
color: $txt-color;
}
92% {
transform: scaleX(1.5) scaleY(0.2) skewX(80deg);
color: green;
}
}
来看看完整的效果和代码吧!
结语
春风若有怜花意,可否许我再少年。
感谢
Glitch Clock
来源:juejin.cn/post/7355302255409184807
解决小程序web-view两个恶心问题
1.web-view覆盖层问题
问题由来
web-view
是一个 web 浏览器组件,可以用来承载网页的容器,会自动铺满整个页面。
所以这得多恶心。。。不仅铺满,还覆盖了普通的标签,调z-index都无解。
解决办法
web-view内部使用cover-view
,调整cover-view的样式即可覆盖在web-view上。
cover-view
覆盖在原生组件上的文本视图。
app-vue和小程序框架,渲染引擎是webview的。但为了优化体验,部分组件如map、video、textarea、canvas通过原生控件实现,原生组件层级高于前端组件(类似flash层级高于div)。为了能正常覆盖原生组件,设计了cover-view。
支持的平台:
App | H5 | 微信小程序 | 支付宝小程序 | 百度小程序 |
---|
具体实现
<template>
<view>
<web-view :src="viewUrl" v-if="viewUrl" >
<cover-view class="close-view" @click="closeView()">
<cover-image class="close-icon" src="../../static/design/close-icon.png"></cover-image>
</cover-view>
</web-view>
</view>
</template>
.close-view{
position: fixed;
z-index: 99999;
top: 30rpx;
left: 45vw;
.close-icon{
width: 100rpx;
height: 80rpx;
}
}
代码说明:
这里的案例是一个关闭按钮图标悬浮在webview上,点击图标可以关闭当前预览的webview。
注意
仅仅真机上才生效,开发者工具上是看不到效果的,如果要调整覆盖层的样式,可以先把web-view标签注释了,写完样式没问题再释放web-view标签。
2.web-view导航栏返回
问题由来
- 小程序端 web-view 组件一定有原生导航栏,下面一定是全屏的 web-view 组件,navigationStyle: custom 对 web-view 组件无效。
场景
用户在嵌套的webview里填写表单,不小心按到导航栏的返回了,就全没了。
解决办法
使用page-container容器,点击到返回的时候,给个提示。
page-container
页面容器。
小程序如果在页面内进行复杂的界面设计(如在页面内弹出半屏的弹窗、在页面内加载一个全屏的子页面等),用户进行返回操作会直接离开当前页面,不符合用户预期,预期应为关闭当前弹出的组件。 为此提供“假页”容器组件,效果类似于 popup
弹出层,页面内存在该容器时,当用户进行返回操作,关闭该容器不关闭页面。返回操作包括三种情形,右滑手势、安卓物理返回键和调用 navigateBack
接口。
具体实现
<template>
<view>
<web-view :src="viewUrl" v-if="viewUrl" >
<cover-view class="close-view" @click="closeView()">
<cover-image class="close-icon" src="../../static/design/close-icon.png"></cover-image>
</cover-view>
</web-view>
<!--这里这里,就这一句-->
<page-container :show="isShow" :overlay="false" @beforeleave="beforeleave"></page-container>
</view>
</template>
export default {
data() {
return {
isShow: true
}
},
methods: {
beforeleave(){
this.isShow = false
uni.showToast({
title: '别点这里',
icon: 'none',
duration: 3000
})
}
}
}
结语
算是小完美的解决了吧,这里记录一下,看看就行,勿喷。
连夜更新安卓cover-view失效问题
由于之前一直用ios测试的,今晚才发现这个问题
解决办法
cover-view, cover-image{
visibility: visible!important;
z-index: 99999;
}
继续连夜更新cover-view在安卓上的问题
如果cover-view的展示是通过v-if控制的,后续通过v-if显示时会出现问题
解决方案
将v-if换成v-show,一换一个不吱声,必然好使!
来源:juejin.cn/post/7379960023407198220
微信小程序、h5、微信公众号之间的跳转
一、微信小程序不同页面之间的跳转
wx.switchTab
跳转到 tabBar 页面,并关闭所有非 tabBar 页面。
wx.switchTab({
url: '', // app.json 里定义的 tabBar 页面路径,不可传参数
success: function() {},
fail: function() {},
complete: function() {}
});
wx.reLaunch
关闭所有页面,跳转到指定页面。
wx.reLaunch({
url: '', // app.json 里定义页面路径,可传参数,例如 'path?key1=val1&key2=val2'
success: function() {},
fail: function() {},
complete: function() {}
});
// url 上传递的参数可以在被打开页面的 onLoad 生命周期中接收
Page({
onLoad(options) {
// your code...
}
});
如果传递的参数有中文,为了避免乱码,可以先
encodeURIComponent
,再decodeURIComponent
wx.redirectTo
关闭当前页跳转到指定页面,但是不允许跳转到 tabbar 页。
wx.redirectTo({
url: '', // app.json 里定义页面路径,可传参数,例如 'path?key1=val1&key2=val2'
success: function() {},
fail: function() {},
complete: function() {}
});
// url 上传递的参数可以在被打开页面的 onLoad 生命周期中接收
Page({
onLoad(options) {
// your code...
}
});
wx.navigateTo
保留当前页面,跳转到应用内的某个页面。但是不能跳到 tabbar 页面。使用 wx.navigateBack
可以返回到原页面。小程序中页面栈最多十层。
wx.navigateTo({
url: '', // app.json 里定义页面路径,可传参数,例如 'path?key1=val1&key2=val2'
events: {
// 为指定事件添加一个监听器,获取被打开页面传送到当前页面的数据
acceptDataFromOpenedPage: function(data) {
console.log(data);
},
someEvent: function(data) {
console.log(data);
}
},
success: function(res) {
// 通过eventChannel向被打开页面传送数据
res.eventChannel.emit('acceptDataFromOpenerPage', { data: 'test' });
},
fail: function() {},
complete: function() {}
});
// eventChannel 传递的参数可以在被打开页面的 onLoad 生命周期中接收
Page({
onLoad(options) {
console.log('options', options);
const eventChannel = this.getOpenerEventChannel();
eventChannel.emit('acceptDataFromOpenedPage', {data: 'test'});
eventChannel.emit('someEvent', {data: 'test'});
// 监听acceptDataFromOpenerPage事件,获取上一页面通过eventChannel传送到当前页面的数据
eventChannel.on('acceptDataFromOpenerPage', function(data) {
console.log(data)
})
}
});
wx.navigateBack
关闭当前页面,返回上级页面或多级页面,可以通过 getCurrentPages
获取当前的页面栈,决定返回几层。
const pages = getCurrentPages();
const prevPages = pages[pages.length -2];
// 向跳转页面传递参数
prevPages.setData({...});
wx.navigateBack({
delta: 1, // 返回的页面数,默认是 1,如果 delta 大于现有页面,则返回到首页
success: function() {},
fail: function() {},
complete: function() {}
});
二、微信小程序和H5之间的跳转
微信小程序跳转到 H5
使用微信小程序自身提供的 web-view
组件,它作为承载网页的容器,会自动铺满整个小程序页面。
// app.json
{
pages: [
"pages/webView/index"
]
}
// webView/index.wxml
"{{url}}">
// webView/index.js
Page({
data: {
url: ''
},
onLoad: function(options) {
this.setData({
url: options.url
});
}
})
H5 跳转微信小程序
wx-open-launch-weapp
用于H5页面中提供一个可以跳转小程序的按钮。
在使用wx-open-launch-weapp
这个标签之前,需要先引入微信JSSDK,通过 wx.config
接口注入权限验证配置,然后通过 openTagList
字段申请所需要的开放标签。
<wx-open-launch-weapp class="dialog-footer" id="iKnow" username="跳转的小程序原始id" path="所需跳转的小程序内页面路径及参数">
<style>style>
<template>
<div class="dialog-footer" style="font-size: 2rem; text-align: center;">前往小程序div>
template>
wx-open-launch-weapp>
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js">script>
const IKnowElem = document.querySelector("#iKnow");
IKnowElem.addEventListener("launch", function (e) {
console.log("success", e);
});
IKnowElem.addEventListener("error", function (e) {
console.log("fail", e.detail);
});
function jsApiSignature() {
return post(
"/api/mp/jsapi/signature",
{ url: location.href }
).then((resp) => {
if (resp.success) {
const data = resp.data;
wx.config({
appId: appid,
timestamp: data.timestamp,
nonceStr: data.nonceStr,
signature: data.signature,
openTagList: ["wx-open-launch-weapp"],
jsApiList: [],
});
wx.ready(function () {
console.log("ready");
// config信息验证后会执行ready方法,所有接口调用都必须在config接口获得结果之后,config是一个客户端的异步操作,所以如果需要在页面加载时就调用相关接口,则须把相关接口放在ready函数中调用来确保正确执行。对于用户触发时才调用的接口,则可以直接调用,不需要放在ready函数中
});
wx.error(function (res) {
console.error("授权失败", res);
// config信息验证失败会执行error函数,如签名过期导致验证失败,具体错误信息可以打开config的debug模式查看,也可以在返回的res参数中查看,对于SPA可以在这里更新签名
});
}
});
}
三、H5 和微信公众号之间的相互跳转
H5 跳转到微信公众号
在微信公众号里打开的 H5 页面执行 closeWindow
关闭窗口事件即可。
const handleFinish = function () {
console.log("handleFinish");
document.addEventListener(
"WeixinJSBridgeReady",
function () {
WeixinJSBridge.call("closeWindow");
},
false
);
WeixinJSBridge.call("closeWindow");
};
如有问题,欢迎指正~~
来源:juejin.cn/post/7314546931863240723
如何从任意地方点击链接跳转到微信公众号?
一、微信内部点击链接
微信公众号主页链接:
https://mp.weixin.qq.com/mp/profile_ext?action=home&__biz=XXX#wechat_redirect
微信公众号主页链接:
https://mp.weixin.qq.com/mp/profile_ext?action=home&__biz=XXX#wechat_redirect
1.1 action
action
代表当前路径前端框架的哪个页面
- home:当前路径前端框架首页
action
代表当前路径前端框架的哪个页面
- home:当前路径前端框架首页
1.2 __biz
__biz
代表微信公众号 ID
__biz
代表微信公众号 ID
1.2.1 __biz 的获取方式
- 在网页中打开该公众号的任意一篇推文 ➡️ 右击鼠标选择检查 ➡️ 在元素下搜素
__biz

org:url 表示当前内容链接
- 从公众平台进入公众号 ➡️ 公众号设置页,右键打开检查 ➡️ 搜索 uin_base64

- 在网页中打开该公众号的任意一篇推文 ➡️ 右击鼠标选择检查 ➡️ 在元素下搜素
__biz
org:url 表示当前内容链接
- 从公众平台进入公众号 ➡️ 公众号设置页,右键打开检查 ➡️ 搜索 uin_base64
二、微信外部点击链接
目前微信官方没有提供相应的功能,但是有的第三方平台可以实现,比如天天外链
但是需要注意,配置的网页地址不能是公众号首页或关注页,必须是永久公众号文章链接。
来源:juejin.cn/post/7216518492613656636
Next.js 使用 Hono 接管 API
直入正题,Next.js 自带的 API Routes (现已改名为 Route Handlers) 异常难用,例如当你需要编写一个 RESTful API 时,尤为痛苦,就像这样
这还没完,当你需要数据验证、错误处理、中间件等等功能,又得花费不小的功夫,所以 Next.js 的 API Route 更多是为你的全栈项目编写一些简易的 API 供外部服务,这也可能是为什么 Next.js 宁可设计 Server Action 也不愿为 API Route 提供传统后端的能力。
但不乏有人会想直接使用 Next.js 来编写这些复杂服务,恰好 Hono.js 便提供相关能力。
这篇文章就带你在 Next.js 项目中要如何接入 Hono,以及开发可能遇到的一些坑点并如何优化。
Next.js 中使用 Hono
可以按照 官方的 cli 搭建或者照 next.js 模版 github.com/vercel/hono… 搭建,核心代码 app/api/[[...route]]/route.ts
的写法如下所示。
import { Hono } from 'hono'
import { handle } from 'hono/vercel'
const app = new Hono().basePath('/api')
app.get('/hello', (c) => {
return c.json({
message: 'Hello Next.js!',
})
})
export const GET = handle(app)
export const POST = handle(app)
export const PUT = handle(app)
export const DELETE = handle(app)
从 hono/vercel
导入的 handle
函数会将 app 实例下的所有请求方法导出,例如 GET、POST、PUT、DELETE 等。
一开始的 User CRUD 例子,则可以将其归属到一个文件内下,这里我不建议将后端业务代码放在 app/api 下,因为 Next.js 会自动扫描 app 下的文件夹,这可能会导致不必要的热更新,并且也不易于服务相关代码的拆分。而是在根目录下创建名为 server 的目录,并将有关后端服务的工具库(如 db、redis、zod)放置该目录下以便调用。
至此 next.js 的 api 接口都将由 hono.js 来接管,接下来只需要按照 Hono 的开发形态便可。
数据效验
zod 可以说是 TS 生态下最优的数据验证器,hono 的 @hono/zod-validator
很好用,用起来也十分简单。
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
import { Hono } from 'hono'
const paramSchema = z.object({
id: z.string().cuid(),
})
const jsonSchema = z.object({
status: z.boolean(),
})
const app = new Hono().put(
'/users/:id',
zValidator('param', paramSchema),
zValidator('json', jsonSchema),
(c) => {
const { id } = c.req.valid('param')
const { status } = c.req.valid('json')
// 逻辑代码...
return c.json({})
},
)
export default app
支持多种验证目标(param,query,json,header 等),以及 TS 类型完备,这都不用多说。
但此时触发数据验证失败,响应的结果令人不是很满意。下图为访问 /api/todo/xxx
的响应结果(其中 xxx 不为 cuid 格式,因此抛出数据验证异常)
所返回的响应体是完整的 zodError 内容,并且状态码为 400
:::tip
数据验证失败的状态码通常为 422
:::
因为 zod-validator 默认以 json 格式返回整个 result,代码详见 github.com/honojs/midd…
这就是坑点之一,返回给客户端的错误信息肯定不会是以这种格式。这里我将其更改为全局错误捕获,做法如下
- 复制 zod-validator 文件并粘贴至
server/api/validator.ts
,并将 return 语句更改为 throw 语句。
if (!result.success) {
- return c.json(result, 400)
}
if (!result.success) {
+ throw result.error
}
- 在
server/api/error.ts
中,编写 handleError 函数用于统一处理异常。(后文前端请求也需要统一处理异常)
import { z } from 'zod'
import type { Context } from 'hono'
import { HTTPException } from 'hono/http-exception'
export function handleError(err: Error, c: Context): Response {
if (err instanceof z.ZodError) {
const firstError = err.errors[0]
return c.json(
{ code: 422, message: `\`${firstError.path}\`: ${firstError.message}` },
422,
)
}
// handle other error, e.g. ApiError
return c.json(
{
code: 500,
message: '出了点问题, 请稍后再试。',
},
{ status: 500 },
)
}
- 在
server/api/index.ts
,也就是 hono app 对象中绑定错误捕获。
const app = new Hono().basePath('/api')
app.onError(handleError)
- 更改 zValidator 导入路径。
- import { zValidator } from '@hono/zod-validator'
+ import { zValidator } from '@/server/api/validator'
这样就将错误统一处理,且后续自定义业务错误也同样如此。
:::note 顺带一提
如果需要让 zod 支持中文错误提示,可以使用 zod-i18n-map
:::
RPC
Hono 有个特性我很喜欢也很好用,可以像 TRPC 那样,导出一个 client 供前端直接调用,省去编写前端 api 调用代码以及对应的类型。
这里我不想在过多叙述 RPC(可见我之前所写有关 TRPC 的使用),直接来说说有哪些注意点。
链式调用
还是以 User CRUD 的代码为例,不难发现 .get
.post
.put
都是以链式调用的写法来写的,一旦拆分后,此时接口还是能够调用,但这将会丢失此时路由对应的类型,导致 client 无法使用获取正常类型,使用链式调用的 app 实例化对象则正常。
替换原生 Fetch 库
hono 自带的 fetch 或者说原生的 fetch 非常难用,为了针对业务错误统一处理,因此需要选用请求库来替换,这里我的选择是 ky,因为他的写法相对原生 fetch 更友好一些,并且不会破坏 hono 原有类型推导。
在 lib/api-client.ts
编写以下代码
import { AppType } from '@/server/api'
import { hc } from 'hono/client'
import ky from 'ky'
const baseUrl =
process.env.NODE_ENV === 'development'
? 'http://localhost:3000'
: process.env.NEXT_PUBLIC_APP_URL!
export const fetch = ky.extend({
hooks: {
afterResponse: [
async (_, __, response: Response) => {
if (response.ok) {
return response
} else {
throw await response.json()
}
},
],
},
})
export const client = hc<AppType>(baseUrl, {
fetch: fetch,
})
这里我是根据请求状态码来判断本次请求是否为异常,因此使用 response.ok,而响应体正好有 message 字段可直接用作 Error message 提示,这样就完成了前端请求异常处理。
至于说请求前自动添加协议头、请求后的数据转换,这就属于老生常谈的东西了,这里就不多赘述,根据实际需求编写即可。
请求体与响应体的类型推导
配合 react-query 可以更好的获取类型安全。此写法与 tRPC 十分相似,相应代码 → Inferring Types
// hooks/users/use-user-create.ts
import { client } from '@/lib/api-client'
import { InferRequestType, InferResponseType } from 'hono/client'
import { useMutation } from '@tanstack/react-query'
import { toast } from 'sonner'
const $post = client.api.users.$post
type BodyType = InferRequestType<typeof $post>['json']
type ResponseType = InferResponseType<typeof $post>['data']
export const useUserCreate = () => {
return useMutation<ResponseType, Error, BodyType>({
mutationKey: ['create-user'],
mutationFn: async (json) => {
const { data } = await (await $post({ json })).json()
return data
},
onSuccess: (data) => {
toast.success('User created successfully')
},
onError: (error) => {
toast.error(error.message)
},
})
}
在 app/users/page.tsx
中的使用
'use client'
import { useUserCreate } from '@/features/users/use-user-create'
export default function UsersPage() {
const { mutate, isPending } = useUserCreate()
const handleSubmit = (e: React.FormEvent ) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const name = formData.get('name') as string
const email = formData.get('email') as string
mutate({ name, email })
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor='name'>Name:label>
<input type='text' id='name' name='name' />
div>
<div>
<label htmlFor='email'>Email:label>
<input type='email' id='email' name='email' />
div>
<button type='submit' disabled={isPending}>
Create User
button>
form>
)
}
OpenAPI 文档
这部分我已经弃坑了,没找到一个很好的方式为 Hono 写 OpenAPI 文档。不过对于 TS 全栈开发者,似乎也没必要编写 API 文档(接口自给自足),更何况还有 RPC 这样的黑科技,不担心接口的请求参数与响应接口。
如果你真要写,那我说说几个我遇到的坑,也是我弃坑的原因。
首先就是写法上,你需要将所有的 Hono 替换成 OpenAPIHono (来自 @hono/zod-openapi, 其中 zod 实例 z 也是)。以下是官方的示例代码,我将其整合到一个文件内
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
import { swaggerUI } from '@hono/swagger-ui'
const app = new OpenAPIHono()
const ParamsSchema = z.object({
id: z
.string()
.min(3)
.openapi({
param: {
name: 'id',
in: 'path',
},
example: '123',
}),
})
const UserSchema = z
.object({
id: z.string().openapi({ example: '123' }),
name: z.string().openapi({ example: 'John Doe' }),
})
.openapi('User')
const route = createRoute({
method: 'get',
path: '/api/users/{id}',
request: {
params: ParamsSchema,
},
responses: {
200: {
content: {
'application/json': {
schema: UserSchema,
},
},
description: 'Retrieve the user',
},
},
})
app.openapi(route, async (c) => {
const { id } = c.req.valid('param')
// 逻辑代码...
const user = {
id,
name: 'Ultra-man',
}
return c.json(user)
})
从上述代码的可读性来看,第一眼你很难看到清晰的看出这个接口到底是什么请求方法、请求路径,并且在写法上需要使用 .openapi
方法,传入一个由 createRoute 所创建的 router 对象。并且写法上不是在原有基础上扩展,已有的代码想要通过代码优先的方式来编写 OpenAPI 文档将要花费不小的工程,这也是我为何不推荐的原因。
定义完接口(路由)之后,只需要通过 app.doc 方法与 swaggerUI 函数,访问 /api/doc 查看 OpenAPI 的 JSON 数据,以及访问 /api/ui 查看 Swagger 界面。
import { swaggerUI } from '@hono/swagger-ui'
app.doc('/api/doc', {
openapi: '3.0.0',
info: {
version: '1.0.0',
title: 'Demo API',
},
})
app.get('/api/ui', swaggerUI({ url: '/api/doc' }))
从目前来看,OpenAPI 文档的生成仍面临挑战。我们期待 Hono 未来能推出一个功能,可以根据 app 下的路由自动生成接口文档(相关Issue已存在)。
仓库地址
附上本文中示例 demo 仓库链接(这个项目就不搞线上访问了)
后记
其实我还想写写 Auth、DB 这些服务集成的(这些都在我实际工作中实践并应用了),或许是太久未写 Blog 导致手生了不少,这篇文章也是断断续续写了好几天。后续我将会出一版完整的我个人的 Nextjs 与 Hono 的最佳实践模版。
也说说我为什么会选用 Hono.js 作为后端服务, 其实就是 Next.js 的 API Route 实在是太难用了,加之轻量化,你完全可以将整个 Nextjs + Hono 服务部署在 Vercel 上,并且还能用上 Edge Functions 的特性。(就是有点小贵)
但不过从我的 Nest.js 开发经验来看(也可能是习惯了 Spring Boot 那套三层架构开发形态),总觉得 Hono 差了点意思,说不出来的体验,可能这就是所谓的全栈框架的开发感受吧。
来源:juejin.cn/post/7420597224516812837
丰富的诗词资源!一个现代化诗词学习网站!
大家好,我是 Java陈序员
。
之前,给大家推荐过一个古诗文起名工具,利用古诗文进行起名。
今天,给大家介绍一个现代化诗词学习网站,完美适用于自身、孩子学习背诵古诗词!
关注微信公众号:【Java陈序员】,获取开源项目分享、AI副业分享、超200本经典计算机电子书籍等。
项目介绍
aspoem
—— 现代化诗词学习网站,一个更加注重UI和阅读体验的诗词网站。收集了丰富的诗词资源,用户可以通过作者、诗词、名句快速查找诗词。
功能特色:
- 提供丰富的中国古典诗词资源
- 提供诗词欣赏与学习、拼音标注、注释和白话文翻译
- 提供全站搜索、诗人及词牌名索引以及标签系统方便查找相关主题诗词
- 界面友好,便于用户使用,支持暗黑模式和多种主题
- 注重移动端的适配,支持 PC 和手机端访问
技术栈:
- React
- Next
- Tailwind CSS
- PostgreSQL
项目体验
诗词
丰富的诗词:aspoem
目前已经收集了 6000+ 首诗词。
诗词鉴赏:提供拼音标注、注释和白话文等的展示方式,使诗词更加易于阅读。
摘抄卡片:提供高清大图,支持免费下载。
诗人
海量的诗人:aspoem
目前汇总了 700+ 个诗人、词人。
诗人介绍:提供诗人介绍,以及创作的诗词,方便有针对性的学习。
词牌名&标签&片段
词牌名:收集了多种多样的词牌名,并汇总对应的诗词。
标签:按照近体诗、书籍、诗经、节日、情感等分类进行打标签,方便检索查询。
片段:摘抄经典的名片诗句、词句。
其他功能
检索查询:查找诗人、诗词、名句。
暗黑模式
多种主题
适配移动端
![]() | ![]() |
---|
本地运行
前期准备
1、下载代码
git clone https://github.com/meetqy/aspoem.git
2、复制一份 .env.example
并重命名为 .env
aspoem
提供了是否集成 PostgreSQL 两种版本,可自行挑选。
集成 PostgreSQL
1、修改配置文件 .env
中的 PostgreSQL 连接信息
# 后台操作需要的 Token, http://localhost:3000/create?token=v0
TOKEN="v0"
# 本地
POSTGRES_PRISMA_URL="postgresql://meetqy@localhost:5432/aspoem"
POSTGRES_URL_NON_POOLING="postgresql://meetqy@localhost:5432/aspoem"
# 统计相关 没有可不填 不会加载对应的代码
# google analytics id
NEXT_PUBLIC_GA_ID="G-PYEC5EG749"
# microsoft-clarity-id
NEXT_PUBLIC_MC_ID="ksel7bmi48"
2、安装依赖
pnpm install
3、启动项目
pnpm run dev
4、浏览器访问 http://localhost:3000
不集成 PostgreSQL
1、修改 .env
POSTGRES_PRISMA_URL="postgresql://meetqy@localhost:5432/aspoem"
POSTGRES_URL_NON_POOLING="postgresql://meetqy@localhost:5432/aspoem"
改为
POSTGRES_PRISMA_URL="file:./db.sqlite"
POSTGRES_URL_NON_POOLING="file:./db.sqlite"
2、修改 prisma/schema.prisma
中的
datasource db {
provider = "postgresql"
url = env("POSTGRES_PRISMA_URL")
directUrl = env("POSTGRES_URL_NON_POOLING")
}
改为
datasource db {
provider = "sqlite"
url = env("POSTGRES_PRISMA_URL")
directUrl = env("POSTGRES_URL_NON_POOLING")
}
3、将 prisma/sample.sqlite
改为 db.sqlite
4、安装依赖并启动,推荐使用 pnpm
pnpm i
pnpm db:push
pnpm dev
Docker 部署
aspoem
项目提供 Dockerfile 和 docker-compose.yml 文件。Dockfile 用于构建 aspoem
服务镜像,docker-compose.yml 用于启动 aspoem
和一个 PostgresSQl
.
执行以下命令,一键启动项目:
cd aspoem
docker compose up
aspoem
一个致力于分享诗词的平台,为用户提供了一个良好的诗词阅读体验!对于喜欢中国诗词的朋友们来说,真的是一个宝藏。它不仅资源丰富,而且界面简洁,使用起来非常友好。大家快去体验吧~
项目地址:https://github.com/meetqy/aspoem
最后
推荐的开源项目已经收录到 GitHub
项目,欢迎 Star
:
https://github.com/chenyl8848/great-open-source-project
或者访问网站,进行在线浏览:
https://chencoding.top:8090/#/
大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!
来源:juejin.cn/post/7419267723782570022
Chrome 浏览器惊现严重漏洞
近期,Chrome 又爆出了一个惊天漏洞,其内部的 JavaScript 引擎 V8 存在不恰当的实现让远程攻击者可以通过精心设计的 HTML 页面对堆损坏进行潜在的攻击。
前置知识
V8 引擎是 Google 开发的开源 JavaScript 引擎
,最初是为 Chrome 浏览器设计的,现在也被 Node.js 和许多其他项目广泛使用。值得注意的是,从 2020 年开始,Edge 浏览器转而使用了 Chromium 项目,这意味着现在的 Microsoft Edge 浏览器确实使用 V8 引擎来执行 JavaScript 代码,与 Google Chrome 浏览器相同。
堆损坏(Heap Corruption)
是一种常见的内存错误,发生在程序错误地操作堆内存时。堆是动态内存分配的区域,程序在运行时可以从堆中分配或释放内存。如果程序不正确地处理这些操作,就可能导致堆损坏。攻击者可能利用堆损坏来执行任意代码,这是许多安全攻击的基础。
漏洞信息
漏洞名称 | Google Chrome 安全漏洞 | 威胁等级 | 高 |
---|---|---|---|
影响产品 | Chromium(Edge、Chrome等) | 影响版本 | 小于等于128.0.6613.84 |
该漏洞已经在新版本的 Chrome 和 Edge 修复,可以更新至最新版本预防该威胁。
漏洞分析
以下是为 ARM64
设备设计的漏洞利用原型,用于触发该漏洞:
var arrx = new Array(150);
arrx[0] = 1.1;
var fake = new Uint32Array(10);
fake[0]= 1;
fake[1] =3;
fake[2]=2;
fake[3] = 4;
fake[4] = 5;
fake[5] = 6;
fake[6] = 7;
fake[7] = 8;
fake[8] = 9;
var tahir = 0x1;
function poc(a) {
var oob_array = new Array(5);
oob_array[0] = 0x500;
let just_a_variable = fake[0];
let another_variable3 = fake[7];
if(a % 7 == 0)
another_variable3 = 0xff00000000; //spray high bytes
another_variable3 = Math.max(another_variable3,tahir);
another_variable3 = another_variable3 >>> 0;
var index = fake[3];
var for_phi_modes = fake[6];
let c = fake[1];
//giant loop for generate cyclic graph
for(var i =0;i<10;i++) {
if( a % 3 == 0){
just_a_variable = c;
}
if( a % 37 == 0) {
just_a_variable = fake[2];
}
if( a % 11 == 0){
just_a_variable = fake[8];
}
if( a % 17 == 0){
just_a_variable = fake[5];
}
if( a % 19 == 0){
just_a_variable = fake[4];
}
if( a % 7 == 0 && i>=5){
for_phi_modes = just_a_variable;
just_a_variable = another_variable3;
}
if(i>=6){
for(let j=0;j<5;j++){
if(a % 5 == 0) {
index = for_phi_modes;
oob_array[index] = 0x500; //zero extends before getting value
}
}
}
for_phi_modes = c;
c = just_a_variable;
}
//zero extend
return [index,BigInt(just_a_variable)];
}
for(let i = 2; i<0x500;i++) {
poc(i); //compile using turbofan
}
poc(7*5);
通过复杂的数组操作和循环逻辑,企图达到越界访问或者修改内存的目的,从而可能实现任意代码执行。脚本的核心部分是利用 TurboFan 编译器优化的特性,通过特定的数据操作来破坏内存结构
。
代码分析
首先代码对如下几个变量进行了初始化,分别为:
- arrx 是一个长度为
150
的数组,初始化第一个元素为 1.1。 - fake 是一个长度为
10
的 Uint32Array,用于存储一系列整数。 - tahir 是一个十六进制的整数值 0x1。
然后就是函数部分,包含了复杂的逻辑和条件判断,主要用于操作和修改 oob_array 和 fake 数组的元素。主要有以下几点信息:
- oob_array 是一个长度为 5 的数组,用于存储操作结果。
- 函数内部使用了多个
局部变量
来从 fake 数组中读取数据,并根据输入参数 a 的不同值改变这些数据。 - 特别是在 a % 5 == 0 的条件下,代码尝试访问
oob_array[index]
,其中 index 是从 fake 数组中获取的。这可能导致越界访问,因为 index 的值可能超出 oob_array 的索引范围。
最后就是通过多次调用 poc 函数,并且特意让 TurboFan 编译器优化这些循环调用。在一些优化过程中,编译器可能未能处理好边界条件,导致安全问题。
关键点
- TurboFan 编译器优化:
TurboFan
是 V8 引擎中的优化编译器,通过频繁调用 poc 函数,脚本试图诱导TurboFan
生成的代码在边界检查上产生漏洞,从而实现越界访问或写入。 - 内存破坏:通过复杂的条件控制流,脚本试图创建出一种可以操纵内存指针的情况(如
index
和for_phi_modes
),从而进行越界写入
,可能导致内存破坏,进一步用于任意代码执行。 - 条件分支与循环:脚本中多次使用复杂的条件判断和循环逻辑来混淆内存操作,可能意在规避一些简单的防护机制,并诱导编译器优化过程中出现漏洞。
来源:juejin.cn/post/7416517826041790514
前端可以玩“锁”🔐了
大家好,我是CC,在这里欢迎大家的到来~
“锁”经常使用在多进程的语言理和数据库事务的架构当中,现在 Web API 当中也提供了“锁”- Web Locks API。
领域
在浏览器多标签页或 worker 中运行的脚本中获取锁,执行工作时保持锁,最后释放锁。
锁的范围仅限于同一源内
请求锁
同一源下,当持有锁时,其他相同锁的请求将排队,仅当锁被释放时第一个排队的请求才会被授予锁。
回调函数执行完毕后锁会自动释放
navigator.locks.request('mylock', {}, async (lock) => {
console.log(lock);
});
在这里我们能看到 request 方法的第二个参数(可选),可以在请求锁时传递一些选项,这个我们在后边会介绍到。
监控锁
判断锁管理器的状态,有利于调试;返回结果是一个锁管理器状态的快照,标识了持有锁和请求中的锁的有关数据,像名称、client_id和模式。
navigator.locks.query().then((locks) => {
console.log(locks);
});
实现
接下来将使用请求锁的可选参数实现以下内容:
从异步任务返回值
request() 方法本身返回一个 Promise,一旦锁被释放,该 Promise 就会 resolve。
const result = await navigator.locks.request('ccmama'}, async (lock) => {
// 任务
return data;
});
// 拿到内部回调函数返回的 data
console.log(result);
共享锁和独占锁模式
配置项 mode 默认是 'exclusive',可选项还有 'shared'。
锁只能有一个持有者,但是可以同时授权多个共享。
在读写模式中经常使用 'shared' 模式进行读取,'exclusive' 模式用于写入。
navigator.locks.request('ccmama', {
mode: 'shared',
}, async (lock) => {
// 任务
});
📢
持有 'exclusive' 锁,同名 'exclusive' 锁排队等候
持有 'exclusive' 锁,同名 'shared' 锁排队等候
持有 'shared' 锁,同名 'shared' 锁也可访问同一资源
持有 'shared' 锁,同名 'exclusive' 锁排队等候
条件获取
配置项 ifAvailable 默认 false,当设置 true 时锁请求仅在不需要排队时才会被授予,也就是说在任务没有其他等待的情况下锁请求才会被授予,否则返回 null。
navigator.locks.request('ccmama', { ifAvailable: true }, async lock => {
if (!lock) return;
// 任务
});
注意:同名锁
防止死锁的应急通道
配置项 steal 默认 false,当设置为 true 时任何持有的同名锁将被释放,并且请求将被授权,抢占任何排队中的锁请求。
navigator.locks.request('ccmama', { steal: true }, async lock => {
// 任务
});
⚠️
使用要小心。之前在锁内运行的代码会继续运行,并且可能与现在持有锁的代码发生冲突。
中止锁定请求
配置项 signal 是 AbortSignal 类型;如果指定并且 AbortController 被中止,则锁请求将被丢弃。
try {
const controller = new AbortController();
setTimeout(() => controller.abort(), 400);
navigator.locks.request('ccmama', { signal: controller.signal }, async lock => {
// 任务
});
} catch(ex) {}
// 或
try {
navigator.locks.request('ccmama', { signal: AbortSignal.timeout(1000) }, async lock => {
// 任务
});
} catch(ex) {}
⚠️
超时时会报出一个异常错误,需要使用 try catch 捕获
参考文章
可能理解并不一定到位,欢迎交流。
来源:juejin.cn/post/7382640456109490211
向全栈靠齐的前端分享
背景与思考
前端在很多后端开发人员中,总是觉得没啥技术含量。尤其是在老java眼中,深深感觉存在严重的鄙视链。然后就是自己的职业规划,也不想一直做前端敲代码。毕竟自己的付出不少,也想收获属于自己的成就感。然后自己的横向发展就成了必然。
后端技术首推Node
- 前后端编程环境和语法一致,上手非常快。
- 轻量级,部署简单。
- 生态丰富,文档颇多,碰到问题,百度查询方便。
- 高效的异步I/O模型,易处理大并发和连接。
Node框架推荐Koa
- 相对于express,Koa更加的轻便,上手主打一个简单易学好用。
- 语法上它的中间件和前端的模块化很像,开发思路一致。
- 前端熟悉的async await,promise方式,很好的解决了多层嵌套,地狱回调问题。
- 借助 co 和 generator,很好地解决了异步流程控制和异常捕获问题。
学习推荐
我当初学习也是想看了一下官网,发现确实如介绍般的简单,但是对于入门者来说,有点简单的过分了。在此推荐阮一峰老师的网络日志(不是打广告,确实是我当初前端起步阶段的老师之一,受益匪浅)。
主要代码解析
项目结构
app.js源码
const Koa = require('koa');
const Router = require('koa-router');
// 跨域模块
var cors = require('koa2-cors');
//文件模块
const fs = require('fs');
const { historyApiFallback } = require('koa2-connect-history-api-fallback');
//静态文件加载
const serve = require('koa-static');
//路径管理
const path = require('path');
//koa-body对文件上传进行配置
const koaBody = require('koa-body')
//实例化koa
const app = new Koa();
app.use(historyApiFallback());
app.use(cors());
const router = new Router();
const bodyParser = require('koa-bodyparser');
const controller = require('./controller');
app.use(async (ctx, next) => {
ctx.set("Access-Control-Allow-Origin", "*")
await next()
})
app.use(bodyParser());
// 处理跨域
app.use(controller());
app.use(koaBody({
multipart:true,
formidable:{
maxFileSize:50000*1024*1024, //设置上传文件大小最大限制,默认为2m,2000*1024*1024
keepExtensions: true // 保留文件拓展名
}
}))
// 1.主页静态网页 把静态页统一放到public中管理
const main = serve(path.join(__dirname) + '/build');
//配置路由
app.use(router.routes()).use(router.allowedMethods());
const port = 5000;
app.use(main)
app.listen(port, () => {
console.log(`server started on ${port}`)
});
依赖包讲解
const Koa = require('koa');
这是引入koa框架,这是重中之重,只有引入了才能够在项目中使用。在项目中会通过new来实例化,比如代码中的const app = new Koa();
。然后再定义一个监听的端口,app.listen()
方法来进行监听。
const fs = require('fs');
这是koa自带的文件模块,如果你想对系统文件进行读取,修改。或者文件上传保存,都离不开整个fs模块,fs.readFile
和fs.readFileSync
。
const koaBody = require('koa-body')
Koa-body是基于Koa的中间件模型构建的,主要用于文件上传,以及在中间件中对请求体的解析。对请求体的解析中,我们主要使用koa-bodyparser,它可以将http请求中的数据,解析成我们需要的JavaScript对象。
const Router = require('koa-router');
```门口
Router模块就是路由,此路由和前端路由有差异,此路由可以理解为前端理解的api接口,只是叫法不一样而已。
```js
const { historyApiFallback } = require('koa2-connect-history-api-fallback');
koa2-connect-history-api-fallback
是一个专门为 Koa2 框架设计的中间件,它的主要目的是在SPA应用中处理URL重定向,尤其是在用户直接输入或者通过后退按钮访问非根URL时。 这个中间件会将所有未匹配到特定路由的请求转发到默认HTML文件(通常是 index.html
),确保SPA可以正常启动并处理路由。还记得当初自己终于完成了一整套的项目线上部署,可把自己开心坏了,但是同事在一次用着发现,刷新页面时,页面直接变成了404,你说吓不吓人。盘查一下发现自己在vue前端中的路由为何在后端中变成了一个get请求。
controller.js源码
const fs = require('fs')
// add url-route in /controllers:
function addMapping (router, mapping) {
for (var url in mapping) {
if (url.startsWith('GET ')) {
var path = url.substring(4)
router.get(path, mapping[url])
// console.log(`register URL mapping: GET ${path}`);
} else if (url.startsWith('POST ')) {
var path = url.substring(5)
router.post(path, mapping[url])
// console.log(`register URL mapping: POST ${path}`);
} else if (url.startsWith('PUT ')) {
var path = url.substring(4)
router.put(path, mapping[url])
// console.log(`register URL mapping: PUT ${path}`);
} else if (url.startsWith('DELETE ')) {
var path = url.substring(7)
router.del(path, mapping[url])
// console.log(`register URL mapping: DELETE ${path}`);
} else {
// console.log(`invalid URL: ${url}`);
}
}
}
function addControllers (router, dir) {
fs.readdirSync(__dirname + '/' + dir)
.filter(f => {
return f.endsWith('.js')
})
.forEach(f => {
// console.log(`process controller: ${f}...`);
let mapping = require(__dirname + '/' + dir + '/' + f)
addMapping(router, mapping)
})
}
module.exports = function (dir) {
let controllers_dir = dir || 'controllers',
router = require('koa-router')()
addControllers(router, controllers_dir)
return router.routes`()`
}
controller讲解
在这个controller中,我们主要做了一件事,那就是路由映射逻辑处理。
function addControllers()
这个方法用于自动加载指定目录下的js文件,它使用fs.readdirSync
读取目录,然后通过filter
和forEach
方法来处理每个文件名,只选择以.js
结尾的文件,并将这些文件的路由映射添加到router
上
function addMapping()
这个函数用于将HTTP方法(如GET、POST、PUT、DELETE)和对应的URL路径映射到处理函数上。它遍历传入的mapping
对象,根据URL的前缀(如GET
、POST
等)来确定使用哪个HTTP方法,并将路径和处理函数注册到router
上。
controllers下路由POST方法
const jwt = require('jsonwebtoken')
module.exports = {
'POST /login': async (ctx, next) => {
var key = ctx.request.body
if (key.username && key.password) {
return new Promise(function (resolve, reject) {
var MongoClient = require('mongodb').MongoClient
var MG_URL = 'mongodb://***********/'
MongoClient.connect(
MG_URL,
{ useUnifiedTopology: true },
function (err, db) {
if (err) throw err
var dbo = db.db('website')
dbo
.collection('user')
.find({ username: key.username, password: key.password })
.toArray(function (err, result) {
if (result.length) {
const TOKEN = jwt.sign(
{
name: result[0].username
},
'MY_TOKEN',
{ expiresIn: '24h' }
)
let data = {
username: result[0].username,
token: TOKEN
}
ctx.response.body = {
result: 1,
status: 200,
code: 200,
data: data
}
} else {
ctx.response.body = {
result: 0,
status: 200,
code: 0,
msg: '该用户不存在'
}
}
if (err) throw err
resolve(result)
db.close()
})
}
)
})
} else {
ctx.response.body = {
result: 0,
status: 200,
code: 0,
msg: 'error'
}
}
}
}
这是一个登录的login方法,用POST进行请求。在这个地方用了一下mongodb数据库存储。在api接口请求login方法时,获取请求中所携带的参数进行解析,并判断此用户以及密码是否在我们的数据库中,如果存在返回成功的提示以及相关数据,如果错误,则提示错误。当然如果还不会数据库的使用,可以去除数据库相关部分,直接用本地json数据,这个比较简单,就是fs读取本地json文件,然后返回给api接口。不在此做详细说明。
controllers下路由GET方法
module.exports = {
'GET /getNews': async (ctx, next) => {
return new Promise(function (resolve, reject) {
var MongoClient = require('mongodb').MongoClient
var MG_URL = 'mongodb://********'
MongoClient.connect(
MG_URL,
{ useUnifiedTopology: true },
function (err, db) {
if (err) throw err
var dbo = db.db('website')
dbo
.collection('news')
.find({})
.toArray(function (err, result) {
if (result.length) {
ctx.response.body = {
result: 1,
status: 200,
code: 1,
data: result
}
} else {
ctx.response.body = {
result: 0,
status: 200,
code: 0,
msg: '暂无数据'
}
}
if (err) throw err
resolve(result)
db.close()
})
}
)
})
}
}
这是一个获取新闻的getNews方法,用GET请求。主要用来查询数据库中的list的信息。DELETE,PUT等方法不在此处贴出更多源码。
数据库首推mondodb
- 面向集合存储,易存储对象类型的数据。
- 模式自由。
- 高性能、易部署、易使用。
- 文档型数据结构灵活,适应不同类型的数据。
- 支持动态查询。
- 非关系型数据库。
学习推荐
为啥选择MongoDB数据库,相对来说操作还是比较简单,而且存储的数据类型都是对象的形式,前端可以轻松拿捏。在这里直接推荐菜鸟的mongodb教程,看名字就知道,这是一个适合菜鸟初步学习的地方。讲解也比较详细,学完上面的内容,用mongodb数据库进行基本的数据存储和操作已经没有问题了。
总结
通过以上的分享,其实对大多数前端来说,开启一个简单的后端服务和接口请求,已经可以开箱即用了。想要完整的学习代码,也可以私信我。虽然不是很完善,但麻雀虽小五脏俱全。
思考
在前端行业已经接7载。曾经害怕java的恐惧而转入前端行业,所有受到鄙视也是有一部分原因吧,毕竟自己曾经年少无知,害怕吃苦选择了一个稍微简单的前端就稀里糊涂的就业了,保命要紧。但是在后来又想改变这个鄙视链,自己就开始了nodejs的学习,python的学习,数据库MongoDB,MySQL,PostgreSQL。学不完,压根学不完。
后面再无尽的内卷中,有的做开发不是自己的路,也想做做管理,毕竟前端做到前端组长就已经是极限了,在公司以java为尊的环境下,想做更高的级别几乎不可能。毕竟自己算是耿直死宅,不善交际,讨不到大领导的喜爱。然后又开始了原型的学习,PMP项目管理证书的考取(进行中),也曾有单独出去做产品的想法,面试过一个,但是与自己的预期薪资相差太大,没去。
来源:juejin.cn/post/7415654362993639439
如果你使用的第三方库有bug,你会怎么办
早上好,中午好,晚上好
在当今的前端工程化领域,第三方库的使用已经成为标配。然而,不可避免的是,这些库可能会存在bug,或者是库的一些功能并不能满足需要,需要修改库的某个功能,或添加功能。当遇到这种情况时,我们应该如何应对?本文将介绍三种解决第三方库bug的方法,并重点介绍使用patch-package
库来修复bug的全过程。
在当今的前端工程化领域,第三方库的使用已经成为标配。然而,不可避免的是,这些库可能会存在bug,或者是库的一些功能并不能满足需要,需要修改库的某个功能,或添加功能。当遇到这种情况时,我们应该如何应对?本文将介绍三种解决第三方库bug的方法,并重点介绍使用patch-package
库来修复bug的全过程。
方法一:提issues给第三方库的作者,让作者修复
这个方式是比较常见的解决方式了,但有几个缺点:
- 库作者不维护这个库了,那提issues自然就没有人close了,gg
- 库作者很忙,或者项目缺乏活跃的贡献者,导致问题可能长时间都不懂响应,那如果你这个项目很急的话,那gg
- bug或者功能的优先级不高,库作者先解决其他高优先级的,或者他不接受你的建议或者及时修复问题,那gg
- 还有可能出现的沟通成本,以确保库作者完全理解了问题的本质和重要性。
那如果库作者很勤奋,每天都在维护,对issues的问题,都满怀热情的进行解决,那我们可以按照以下流程进行提issues:
- 发现bug:在使用第三方库时,发现了一个bug。
- 复现bug:在本地环境中尝试复现该bug,并记录详细的复现步骤。
- 提交issues:访问第三方库的GitHub仓库,点击“New issue”按钮,填写以下信息:
- 标题:简洁地描述bug现象。
- 描述:详细描述bug的复现步骤、预期结果和实际结果。
- 环境:列出你的操作系统、浏览器版本、库的版本等信息。
- 等待回复:作者可能会要求你提供更多信息,或者告诉你解决方案。耐心等待并积极配合。nice
这个方式是比较常见的解决方式了,但有几个缺点:
- 库作者不维护这个库了,那提issues自然就没有人close了,gg
- 库作者很忙,或者项目缺乏活跃的贡献者,导致问题可能长时间都不懂响应,那如果你这个项目很急的话,那gg
- bug或者功能的优先级不高,库作者先解决其他高优先级的,或者他不接受你的建议或者及时修复问题,那gg
- 还有可能出现的沟通成本,以确保库作者完全理解了问题的本质和重要性。
那如果库作者很勤奋,每天都在维护,对issues的问题,都满怀热情的进行解决,那我们可以按照以下流程进行提issues:
- 发现bug:在使用第三方库时,发现了一个bug。
- 复现bug:在本地环境中尝试复现该bug,并记录详细的复现步骤。
- 提交issues:访问第三方库的GitHub仓库,点击“New issue”按钮,填写以下信息:
- 标题:简洁地描述bug现象。
- 描述:详细描述bug的复现步骤、预期结果和实际结果。
- 环境:列出你的操作系统、浏览器版本、库的版本等信息。
- 等待回复:作者可能会要求你提供更多信息,或者告诉你解决方案。耐心等待并积极配合。nice
方法二:fork第三方库,修复好bug后,发布到npm,项目下载自己发布的npm包
这个方式也有局限性:
- 维护负担:一旦你fork了库,你需要负责维护这个分支,包括合并上游的更新和修复新出现的bug。
- 长期兼容性:随着时间的推移,原库和新fork的库可能会出现分歧,使得合并更新变得更加困难。
- 版本管理:需要管理自己发布的npm包版本,确保它与其他依赖的兼容性。
- 社区隔离:使用自己的fork可能会减少与原社区的合作机会,错过原库的其他改进和特性。
那如果你觉得这个方式很不错,那最佳实践是这样的:
这个方式也有局限性:
- 维护负担:一旦你fork了库,你需要负责维护这个分支,包括合并上游的更新和修复新出现的bug。
- 长期兼容性:随着时间的推移,原库和新fork的库可能会出现分歧,使得合并更新变得更加困难。
- 版本管理:需要管理自己发布的npm包版本,确保它与其他依赖的兼容性。
- 社区隔离:使用自己的fork可能会减少与原社区的合作机会,错过原库的其他改进和特性。
那如果你觉得这个方式很不错,那最佳实践是这样的:
步骤 1: Fork 原始库
- 访问原始库的GitHub页面。
- 点击页面上的“Fork”按钮,将库复制到你的GitHub账户下。
- 访问原始库的GitHub页面。
- 点击页面上的“Fork”按钮,将库复制到你的GitHub账户下。
步骤 2: 克隆你的Fork
git clone https://github.com/your-username/original-repo.git
cd original-repo
git clone https://github.com/your-username/original-repo.git
cd original-repo
步骤 3: 设置上游仓库
git remote add upstream https://github.com/original-owner/original-repo.git
这样当作者更新维护库的时候,可以获取上游仓库的最新更新。
git remote add upstream https://github.com/original-owner/original-repo.git
这样当作者更新维护库的时候,可以获取上游仓库的最新更新。
步骤 4: 创建特性分支
git checkout -b fix-bug-branch
git checkout -b fix-bug-branch
步骤 5: 修复Bug
在这个分支上,进行必要的代码更改来修复bug。
在这个分支上,进行必要的代码更改来修复bug。
步骤 6: 测试更改
在本地环境中测试你的更改,确保修复了bug并且没有引入新的问题。
在本地环境中测试你的更改,确保修复了bug并且没有引入新的问题。
步骤 7: 提交并推送更改
git add .
git commit -m "Fix bug description"
git push origin fix-bug-branch
git add .
git commit -m "Fix bug description"
git push origin fix-bug-branch
步骤 8: 创建Pull Request(可选)
如果你希望原始库接受你的修复,可以向上游仓库创建一个Pull Request。
如果你希望原始库接受你的修复,可以向上游仓库创建一个Pull Request。
步骤 9: 发布到NPM
如果原始库没有接受你的PR,或者你需要立即使用修复,可以发布到NPM:
- 登录到NPM。
npm login
这个地方有个坑点,就是你使用了npm镜像需要将镜像更改为npm官方仓库:
npm config set registry https://registry.npmjs.org
- 修改
package.json
中的名称,避免与原始库冲突,例如添加你的用户名前缀。
{
"name": "@your-username/original-repo",
// ...
}
- 更新版本号。
npm version patch
- 发布到NPM。
npm publish
如果原始库没有接受你的PR,或者你需要立即使用修复,可以发布到NPM:
- 登录到NPM。
npm login
这个地方有个坑点,就是你使用了npm镜像需要将镜像更改为npm官方仓库:
npm config set registry https://registry.npmjs.org
- 修改
package.json
中的名称,避免与原始库冲突,例如添加你的用户名前缀。
{
"name": "@your-username/original-repo",
// ...
}
npm version patch
npm publish
步骤 10: 在你的项目中使用Forked库
在你的项目package.json
中,将依赖项更改为你的forked版本。
{
"dependencies": {
"original-repo": "^1.0.0",
"@your-username/original-repo": "1.0.1"
}
}
步骤 11: 维护你的Fork
定期从上游仓库合并更新到你的fork,以保持与原始库的同步。
git checkout master
git pull upstream master
git push origin master
最佳实践总结
- 保持与上游仓库的同步。
- 清晰地记录你的更改和发布。
- 为你的fork创建文档,说明它与原始库的区别。
- 考虑长期维护策略,如果可能,尽量回归到官方版本。
方法三:使用patch-package库来修复
patch-package
是一个非常有用的 npm 包,它允许我们在没有修改原始 npm 依赖包的情况下,对 npm 依赖进行修复或自定义。这在以下场景中特别有用:
- 当你发现一个第三方库的 bug,但作者还没有修复它,或者修复后的版本尚未发布。
- 当你需要对第三方库进行微小的定制,而不想维护一个完整的分支或分叉。
patch-package 的工作原理
patch-package
的工作流程通常如下:
- 修改
node_modules
中的依赖包文件。 - 运行
patch-package
命令,它会生成一个补丁文件,通常是.patch
文件,保存在项目根目录下的patches
文件夹中。 - 在
package.json
的scripts
部分添加一个脚本来应用这些补丁,通常是在postinstall
阶段。 - 将生成的
.patch
文件提交到版本控制系统中。 - 当其他开发者运行
npm install
或yarn
安装依赖时,或者 CI/CD 系统构建项目时,这些补丁会被自动应用。
但使用这种方式也有前提:
1. 潜在冲突:如果第三方库的官方更新解决了相同的bug,但采用了不同的方法,那么你的补丁可能会与这些更新冲突
2. 库没有源码:这种方式是在node_modules里对应的包进行修改,如果包是压缩后的,那就没办法改了,所以只能针对node_modules里的包有源码的情况下。
最佳实践:
步骤 1:安装patch-package postinstall-postinstall
postinstall-postinstall
,作用是 postinstall
脚本在 Yarn 安装过程中运行。
yarn add patch-package postinstall-postinstall --dev
步骤 2:配置 package.json
在你的 package.json
文件中,添加一个 postinstall
脚本来确保在安装依赖后应用补丁:
"scripts": {
"postinstall": "patch-package"
}
步骤 3:修复依赖包中的 bug
假如vue3有个bug,我们直接在 node_modules/vue/xxx
中修复这个 bug。
步骤 4:创建补丁
修复完成后,我们运行以下命令来生成补丁:
npx patch-package example-lib
这会在项目根目录下创建一个 patches
文件夹,并在其中生成一个名为 vue+3.4.29.patch
的文件(假设vue当前库的版本是3.4.29)。
步骤 5:提交补丁文件到代码库中
现在,我们将 patches
文件夹和里面的 .patch
文件提交到版本控制系统中。
git add patches/example-lib+1.0.0.patch
git commit -m "Add patch for vue3.4.29"
git push
步骤 6:安装依赖并应用补丁
就是其他同事在下载项目或者更新依赖后,postinstall
脚本会自动运行,并应用补丁。
npm install
# 或者
yarn install
当 npm install
或 yarn install
完成后,patch-package
会自动检测 patches
文件夹中的补丁,并将其应用到对应的依赖上。
志哥我想说
遇到第三方库的bug时,我们可以选择提issues、fork并发布自己的npm包,或者使用patch-package
进行本地修复。当然你还可以有:
- 使用替代库
- 社区支持
每种方法都有其适用场景,根据实际情况选择最合适的方法。希望本文能帮助你更好地应对第三方库的bug问题,或者面试
或者技术分享
等。
来源:juejin.cn/post/7418797840796254271
抖音自动进入直播间的动画挺有意思的,看看有多少种方式可以实现
在刷抖音的时候,发现有一个直播的专属导航页签,切换到这个页签之后,刷出来的内容全都是直播,不过都是在“门外”观看,没有进入直播间;
短暂的停留之后,会出现一个自动进入直播间的提示,并且有一个描边动画,动画结束之后,就会进入直播间,今天我就尝试通过多种方式来实现这个动画效果。
1. 渐变实现
效果如上图所示,渐变需要使用到的是conic-gradient
锥形渐变,文档地址:conic-gradient
代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
html,
body {
height: 100%;
margin: 0;
}
body {
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(90deg, #1F1C2C 0%, #928DAB 100%);
color: #fff;
}
.wrap {
position: relative;
background-color: rgba(255, 255, 255, 0.2);
width: fit-content;
padding: 10px 20px;
border-radius: calc(1em + 10px);
}
/*使用自定义属性来控制进度*/
@property --offset {
syntax: "<length-percentage>";
inherits: false;
initial-value: 0;
}
.wrap.gradient-animation {
overflow: hidden;
/*和普通 css 变量一样使用即可*/
background-image:
conic-gradient(
#fff 0%,
#fff var(--offset),
transparent var(--offset) 100%
);
}
/*需要使用一个遮挡来挡住多余的部分,只保留描边部分*/
.wrap.gradient-animation::before {
content: ' ';
position: absolute;
top: 2px;
left: 2px;
right: 2px;
bottom: 2px;
background: #1F1C2C;
border-radius: inherit;
z-index: 0;
}
.wrap.gradient-animation:hover {
animation: gradient 5s linear 1 forwards;
}
@keyframes gradient {
0% {
--offset: 0;
}
100% {
--offset: 100%;
}
}
</style>
</head>
<body>
<div class="wrap gradient-animation">
<!-- 需要控制层级显示 -->
<span style="position: relative; z-index: 1;">自动进入直播间</span>
</div>
</body>
</html>
conic-gradient
的技术细节就不展开了,感兴趣的可以自行查阅文档,这里主要的技术点在于--offset
这个自定义属性,因为渐变本身是不支持动画的,所以需要借助这个自定义属性来实现动画效果,文档地址:@property
这里的效果其并不是很理想,因为conic-gradient
的渐变是一个圆形的渐变,而实际效果是边框的一个描边,所以需要使用一个遮罩来挡住多余的部分,只保留描边部分。
由于使用了伪元素来实现遮罩,所以还需要控制层级显示,避免遮罩挡住了文字,并且原效果是透明的背景,这里使用遮罩层之后背景就不能是透明的了,而且动画在每一个部分执行的时间都不连贯。
可以说这种方式有很多的局限性,所以我们来看看下一种方式。
2. 渐变加 mask 实现
渐变加mask
的实现思路和上面的类似,主要是解决了上面的背景半透明的问题,文档地址:mask
代码如下:
<div class="wrap gradient-mask">
<span style="position: relative; z-index: 1;">自动进入直播间</span>
</div>
.wrap.gradient-mask::before {
content: ' ';
position: absolute;
inset: 0;
background-color: rgba(255, 255, 255, 0.1);
background-image: conic-gradient(
#fff 0%,
#fff var(--offset),
transparent var(--offset) 100%
);
border-radius: inherit;
mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg'><rect width='150' x='1' y='1' height='40' rx='20' fill='transparent' stroke='red' stroke-width='2' stroke-alignment='outside'/></svg>");
mask-size: 100% 100%;
mask-repeat: no-repeat;
}
.wrap.gradient-mask:hover::before {
animation: gradient 5s linear 1 forwards;
}
这里把效果整体迁移到了::before
伪元素上,使用mask-image
里面加了一个svg
来处理描边的问题,这样就不需要使用遮罩来挡住多余的部分,只保留描边部分。
但是这里的问题也很明显,那就是svg
并不能很好的响应式,而且因为svg
的其他原因,导致描边的边宽有点被裁剪,这里也只是提供一个思路,并不是最佳实践。
3. 使用 svg 实现
上面都已经使用到了svg
作为遮罩,那么直接使用svg
更简单直接,这种情况个人也比较推荐,代码如下:
<div class="wrap svg">
<svg xmlns="http://www.w3.org/2000/svg">
<rect width="150" x="1" y="1" height="40" rx="20" stroke="rgba(255, 255, 255, 0.1)" stroke-width="2" />
<rect width="150" x="1" y="1" height="40" rx="20" class="rect" />
</svg>
<span style="position: relative; z-index: 1;">自动进入直播间</span>
</div>
.wrap.svg svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.wrap.svg svg > rect {
width: calc(100% - 2px);
height: calc(100% - 2px);
fill: transparent;
stroke-width: 2;
}
.wrap.svg svg > .rect {
stroke-dasharray: 350;
stroke-dashoffset: 350;
stroke: white;
stroke-width: 2;
}
.wrap.svg:hover svg > .rect {
transition: stroke-dashoffset 5s linear;
stroke-dashoffset: 0;
}
svg
的描边效果主要是通过stroke-dasharray
和stroke-dashoffset
来实现的,svg
描边效果也是一个非常有趣的实现。
stroke-dasharray
是用来控制虚线的,这个值越大,虚线之间的间隔也越大,大到一定程度这个虚线就正好将整个形状包裹住。
stroke-dashoffset
是用来控制虚线的偏移量,当这个值等于stroke-dasharray
的时候,虚线就会完全消失,等于0
的时候,虚线就会完全显示。
根据svg
的特性,描边是在形状的外部,所以最外层有一个半透明的边框路径显示的并不完全,需要通过一些技巧来处理。
所以上面的代码会有两个rect
,一个是用来描边的,一个是用来做半透明的边框的,这样就可以实现一个比较完美的描边动画效果。
但是上面的描边起点和终点都是在左上角,如果需要在中间的话,可以通过path
来实现,感兴趣的可以自行尝试,这里提供path
的代码:
<svg viewBox="0 0 600 150" xmlns="http://www.w3.org/2000/svg">
<path d="M 100 0 L 500 0 A 50 50 0 1 1 500 150 L 100 150 A 50 50 0 1 1 100 0 Z" fill="transparent" stroke="rgba(255, 255, 255, 0.1)" stroke-width="2" />
</svg>
path
需要微调,因为没有贴边,可以使用一些在线网站来进行调整,比如:svg-path-editor,可自行探索。
总结
这一篇文章通过多种方式来实现一个描边动画效果,主要技术有:
conic-gradient
锥形渐变,本身渐变是不支持动画的,但是我们可以通过自定义属性来实现动画效果;mask
遮罩,mask
本身其实和背景图的使用方式差不多,但是mask
主要用来遮挡多余的部分,在这里我们使用mask
来遮挡主要部分,只保留描边部分来实现动画效果;svg
描边,svg
描边是一个非常有趣的技术,通过stroke-dasharray
和stroke-dashoffset
来实现描边动画效果,这种方式是最推荐的。
当然肯定还有其他的方式来实现这个效果,这里只是提供了一些思路,希望对大家有所帮助。
来源:juejin.cn/post/7420814883576414259
抛弃 `!important` 吧,一个更友好的技巧让你的 CSS 优先级变大
原文:Double your specificity with this one weird trick
在一个理想的世界里,我们的 CSS 代码组织得井井有条,易于维护。然而,现实往往大相径庭。你的 CSS 代码是完美的,但其他人那些烦人的 CSS 可能会与你的风格冲突,或者应用了你不需要的样式。
此外,你可能也无法修改那些 CSS。也许它来自你正在使用的 UI 库,也许是一些第三方的小组件。
更糟糕的是,HTML 也不受你控制,添加一些额外的 class
或 id
属性来覆盖样式也并不可行。
不知不觉中,你被卷入了一场 CSS 优先级之战。你的选择器需要优先于他们的选择器。开发者很容易被『诱惑 😈』去使用 !important
,但你知道这是不好的实践,我们能不能有一种更优雅的方式来实现我们覆盖的诉求?
本文将教给你一个技巧,可以用一种不是很 hacky 的方式应对这些情况 👩💻。
示例 🔮
假设你正在开发一个网站,该网站有一个新闻订阅表单。它包含一个复选框,但复选框的位置有点偏。你需要修正这个问题,但注册表单是一个嵌入到页面上的第三方组件,你无法直接修改它的 CSS。
通过浏览器检查复选框,确定只要改变它的 top
位置即可。当前的位置是通过选择器.newsletter .newsletter__checkbox .checkbox__icon
设置的,它的权重为 (0,3,0)
。
一开始你可能会使用相同的选择器来修改 top
值:
/* 覆盖新闻通讯复选框的顶部位置 */
.newsletter .newsletter__checkbox .checkbox__icon {
top: 5px;
}
当 CSS 的顺序是固定的,并且你可以保证你的 CSS 规则一定在他们的后面的情况下,这足够了。因为『后来居上』:即如果有多个相同的 CSS 选择器选择了同一个DOM元素,那么最后出现的将“获胜”。
然而,大多数时候你无法保证代码顺序。此时你需要增加选择器的优先级。你可以在 DOM 中寻找一些额外的类名,一般从父元素中添加:
/* 更多的类名!权重现在是(0,4,0) */
.parent-thing .newsletter .newsletter__checkbox .checkbox__icon {
top: 5px;
}
或者你发现这个元素恰好是一个 ,可以将其加入选择器提高优先级:
/* 权重现在是 (0,3,1) */
.newsletter .newsletter__checkbox span.checkbox__icon {
top: 5px;
}
但所有这些方法都有副作用,都会使你的代码变得脆弱。如果 .parent-thing
突然不见了呢,比如你升级了某个外部依赖(比如 antd 😅)?或者如果 .checkbox__icon
从 span
改成了不同的元素怎么办?突然间,你的高优先级选择器什么也选不到了!
当浏览器计算 CSS 选择器优先级时,它们本质上是在计算你组合了多少 ID
、类
、元素
或等效选择器。实际上可以多次重复同一个选择器,每次重复都会增加权重。CSS 选择器 Level 4 规范 写到:
CSS 选择器允许多次出现相同的简单选择器,而且可以增加权重。
因此,你可以通过重复(三次、四次……)相同的选择器提高权重:
/* 双重 .checkbox__icon!权重现在是 (0,4,0) */
.newsletter .newsletter__checkbox .checkbox__icon.checkbox__icon {
top: 5px;
}
注意
.checkbox__icon.checkbox__icon
中没有空格!它是一个选择器,因为你针对的是具有那个类的单个元素
现在你可以简单地重复几次选择器来提升优先级!
译者注:该技巧其实在 MDN !important 章节 有示例(以下示例重复了3次
#myElement
):
#myElement#myElement#myElement .myClass.myClass p:hover {
color: blue;
}
p {
color: red !important;
}
在 HTML 中重复 🚫
注意,这个技巧只在 CSS 中有效!在 HTML 中重复相同的类名对优先级没有任何影响。
<div class="badger badger badger">
Mushroom!
div>
总结 🎯
CSS 可以多次重复同一个选择器,每次重复都会增加权重 🏋️♂️
这种 CSS 技巧是否有点 hack?也许是。然而我认为它让我们:
- 避免诉诸于
!important
- 『就近原则』提高可读性:重复多次的选择器,这样代码的意图对读者来说更清晰
- 这种模式让你很容易在代码中找到其他人的 CSS 覆盖,如果不再需要我们可以放心删除
只要你不过度使用它,我认为这是一个完全合法且 Robust 的技巧至少相比我们之前学会的所有技巧,下次处理棘手的覆盖三方样式情况时可以考虑用一用。
是否还有更好的解决办法?其实有,@layer 是官方推荐的最佳实践但是兼容性不好 Chrome>=99,而且使用场景有限。
来源:juejin.cn/post/7411686792342618153
想弄一个节日头像,结果全是广告!带你用 Canvas 自己制作节日头像
一、为什么要自己制作节日头像?
很多人想为节日换上特别的头像,尤其是在国庆这样的节日气氛中,给自己的WX头像添加节日元素成为了不少人的选择。最初我也以为只需通过一些WX公众号简单操作,就能轻松给头像加上节日图案,比如国庆节、圣诞节头像等。然而,实际体验却很糟糕——广告无处不在!每一步操作几乎都被强制插入广告打断,不仅浪费时间,体验也非常差。
为了避开这些广告,享受更自由、更个性化的制作过程,我决定分享一个不用看广告的好方法:使用 Canvas 自己动手制作一个专属的节日头像!
很多人想为节日换上特别的头像,尤其是在国庆这样的节日气氛中,给自己的WX头像添加节日元素成为了不少人的选择。最初我也以为只需通过一些WX公众号简单操作,就能轻松给头像加上节日图案,比如国庆节、圣诞节头像等。然而,实际体验却很糟糕——广告无处不在!每一步操作几乎都被强制插入广告打断,不仅浪费时间,体验也非常差。
为了避开这些广告,享受更自由、更个性化的制作过程,我决定分享一个不用看广告的好方法:使用 Canvas 自己动手制作一个专属的节日头像!
二、源码 & 在线体验
三、 实现的功能与后续发展
在解决了广告干扰的问题后,我通过 Canvas 实现了多个实用功能,让大家可以轻松制作个性化的节日头像:
- 头像裁剪功能
- 头像与框架的拼接
- 头像框透明度调节
- 头像框颜色过滤(可自定义头像框)
- 后续发展:Fabric.js 自定义贴图功能
- 后续发展:更新更多节日的头像 & 贴图
在解决了广告干扰的问题后,我通过 Canvas 实现了多个实用功能,让大家可以轻松制作个性化的节日头像:
- 头像裁剪功能
- 头像与框架的拼接
- 头像框透明度调节
- 头像框颜色过滤(可自定义头像框)
- 后续发展:Fabric.js 自定义贴图功能
- 后续发展:更新更多节日的头像 & 贴图
四、当前素材及投稿征集
展示目前头像框素材,也欢迎大家投稿,我也会陆续更进头像框(项目中头像框已进行分类,这里为了方便展示,也可以自定义头像框)
展示目前头像框素材,也欢迎大家投稿,我也会陆续更进头像框(项目中头像框已进行分类,这里为了方便展示,也可以自定义头像框)
1. 头像框
2. 贴图
五、代码实现
整体逻辑非常简单 : 头像 + 头像框 = 所需头像
整体逻辑非常简单 : 头像 + 头像框 = 所需头像
1. 头像裁剪功能
页面部分
- 使用
:width
来根据设备类型设置宽度(device === DeviceEnum.MOBILE ? '95%' : '42%'
)。
用于图像裁剪功能。- 底部有文件上传和旋转按钮。
<template>
<el-dialog
v-model="dialog.visible"
:width="device === DeviceEnum.MOBILE ? '95%' : '42%'"
class="festival-avatar-upload-dialog"
destroy-on-close
draggable
overflow
title="上传头像"
>
<div style="height: 45vh">
<vue-cropper
ref="cropper"
:autoCrop="true"
:centerBox="true"
:fixed="true"
:fixedNumber="[1,1]"
:img="imgTemp"
:outputType="'png'"
/>
div>
<template #footer>
<div class="festival-avatar-dialog-options">
<el-button @click="uploadAvatar">
<el-icon style="margin-right: 5px;">
<UploadFilled/>
el-icon>
上传头像
<input ref="avatarUploaderRef" accept="image/*" class="avatar-uploader" name="file" type="file"
@change="handleFileChange">
el-button>
<el-button @click="rotateLeft">
<el-icon><RefreshLeft/>el-icon>
el-button>
<el-button @click="rotateRight">
<el-icon><RefreshRight/>el-icon>
el-button>
<el-button type="primary" @click="submitForm">提 交el-button>
div>
template>
el-dialog>
template>
- 使用
:width
来根据设备类型设置宽度(device === DeviceEnum.MOBILE ? '95%' : '42%'
)。
用于图像裁剪功能。- 底部有文件上传和旋转按钮。
<template>
<el-dialog
v-model="dialog.visible"
:width="device === DeviceEnum.MOBILE ? '95%' : '42%'"
class="festival-avatar-upload-dialog"
destroy-on-close
draggable
overflow
title="上传头像"
>
<div style="height: 45vh">
<vue-cropper
ref="cropper"
:autoCrop="true"
:centerBox="true"
:fixed="true"
:fixedNumber="[1,1]"
:img="imgTemp"
:outputType="'png'"
/>
div>
<template #footer>
<div class="festival-avatar-dialog-options">
<el-button @click="uploadAvatar">
<el-icon style="margin-right: 5px;">
<UploadFilled/>
el-icon>
上传头像
<input ref="avatarUploaderRef" accept="image/*" class="avatar-uploader" name="file" type="file"
@change="handleFileChange">
el-button>
<el-button @click="rotateLeft">
<el-icon><RefreshLeft/>el-icon>
el-button>
<el-button @click="rotateRight">
<el-icon><RefreshRight/>el-icon>
el-button>
<el-button type="primary" @click="submitForm">提 交el-button>
div>
template>
el-dialog>
template>
代码逻辑部分(核心部分)
imgTemp
用来存储上传的临时图片数据。handleFileChange
处理文件上传事件,校验文件类型并使用 FileReader
读取图片数据,并本地存储。rotateLeft
和 rotateRight
分别用于左旋和右旋图片。
// 省略部分属性定义
const imgTemp = ref<string>("") // 临时图片数据
const cropper = ref(); // 裁剪实例
const avatarUploaderRef = ref<HTMLInputElement | null>(null); // 上传头像 input 引用
// 上传头像功能
function uploadAvatar() {
avatarUploaderRef.value?.click(); // 点击 input 触发上传
}
// 上传文件前校验 : 略
// 处理文件上传
function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files[0]) {
const file = input.files[0];
if (!beforeAvatarUpload(file)) return;
const reader = new FileReader();
reader.onload = (e: ProgressEvent ) => {
imgTemp.value = e.target?.result as string; // 读取的图片数据赋给 imgTemp
};
reader.readAsDataURL(file);
}
}
// 旋转功能
function rotateLeft() {
cropper.value?.rotateLeft();
}
const rotateRight = () => {
cropper.value?.rotateRight();
};
imgTemp
用来存储上传的临时图片数据。handleFileChange
处理文件上传事件,校验文件类型并使用FileReader
读取图片数据,并本地存储。rotateLeft
和rotateRight
分别用于左旋和右旋图片。
// 省略部分属性定义
const imgTemp = ref<string>("") // 临时图片数据
const cropper = ref(); // 裁剪实例
const avatarUploaderRef = ref<HTMLInputElement | null>(null); // 上传头像 input 引用
// 上传头像功能
function uploadAvatar() {
avatarUploaderRef.value?.click(); // 点击 input 触发上传
}
// 上传文件前校验 : 略
// 处理文件上传
function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files[0]) {
const file = input.files[0];
if (!beforeAvatarUpload(file)) return;
const reader = new FileReader();
reader.onload = (e: ProgressEvent ) => {
imgTemp.value = e.target?.result as string; // 读取的图片数据赋给 imgTemp
};
reader.readAsDataURL(file);
}
}
// 旋转功能
function rotateLeft() {
cropper.value?.rotateLeft();
}
const rotateRight = () => {
cropper.value?.rotateRight();
};
实现效果图
2. 头像与头像框合并
页面部分 (核心部分)
compositeAvatar
为组合头像 , avatarData
为头像数据 ,compositeCanvas
头像 Canvas , avatarFrameCanvas
头像框 Canvas- 在没有
compositeAvatar
的时候展示 avatarData
, 没有 avatarData
提示用户点击 PLUS
的图片
<div class="festival-avatar-preview">
<div class="festival-avatar-preview__plus" @click="openAvatarDialog">
<img v-if="compositeAvatar" :src="compositeAvatar" alt="合成头像"/>
<img v-else-if="avatarData" :src="avatarData" alt="头像"/>
<el-icon v-else color="#8c939d" size="28">
<Plus>Plus>
el-icon>
div>
div>
<canvas ref="compositeCanvas" style="display: none;">canvas>
<canvas ref="avatarFrameCanvas" style="display: none;">canvas>
compositeAvatar
为组合头像 ,avatarData
为头像数据 ,compositeCanvas
头像 Canvas ,avatarFrameCanvas
头像框 Canvas- 在没有
compositeAvatar
的时候展示avatarData
, 没有avatarData
提示用户点击PLUS
的图片
<div class="festival-avatar-preview">
<div class="festival-avatar-preview__plus" @click="openAvatarDialog">
<img v-if="compositeAvatar" :src="compositeAvatar" alt="合成头像"/>
<img v-else-if="avatarData" :src="avatarData" alt="头像"/>
<el-icon v-else color="#8c939d" size="28">
<Plus>Plus>
el-icon>
div>
div>
<canvas ref="compositeCanvas" style="display: none;">canvas>
<canvas ref="avatarFrameCanvas" style="display: none;">canvas>
逻辑部分 (核心部分)
- 通过
toDataURL
转换后合成为组合头像 , 通过 drawImage
合并 avatarFrameCanvas
和上文中avatarData
进行合并
const context = getCanvasContext(compositeCanvas); // 获取主 Canvas 上下文
const frameContext = getCanvasContext(avatarFrameCanvas); // 获取头像框 Canvas 上下文
// 省略非相关逻辑 , context 中写入 avatarData 内容
// 将处理后的头像框绘制到主 Canvas 上
context.drawImage(avatarFrameCanvas.value, 0, 0, avatarImg.width, avatarImg.height);
// 将合成后的图片转换为数据 URL
compositeAvatar.value = compositeCanvas.value!.toDataURL('image/png');
- 通过
toDataURL
转换后合成为组合头像 , 通过drawImage
合并avatarFrameCanvas
和上文中avatarData
进行合并
const context = getCanvasContext(compositeCanvas); // 获取主 Canvas 上下文
const frameContext = getCanvasContext(avatarFrameCanvas); // 获取头像框 Canvas 上下文
// 省略非相关逻辑 , context 中写入 avatarData 内容
// 将处理后的头像框绘制到主 Canvas 上
context.drawImage(avatarFrameCanvas.value, 0, 0, avatarImg.width, avatarImg.height);
// 将合成后的图片转换为数据 URL
compositeAvatar.value = compositeCanvas.value!.toDataURL('image/png');
实现效果
当我们点击头像框的时候,合并头像

当我们点击头像框的时候,合并头像
3. 头像框透明度调整
页面部分 与上文一样 , 通过调整 avatarFrameCanvas
的内容而调整头像框
页面部分 与上文一样 , 通过调整 avatarFrameCanvas
的内容而调整头像框
逻辑部分 (核心部分)
通过 context
中 globalAlpha
属性设置全局透明度。
setFrameOpacity(frameContext, frameOpacity.value); // 设置头像框透明度
/**
* 设置 Canvas 的透明度
* @param context Canvas 的 2D 上下文
* @param opacity 透明度值
*/
function setFrameOpacity(context: CanvasRenderingContext2D, opacity: number) {
context.globalAlpha = opacity; // 设置全局透明度
}
通过 context
中 globalAlpha
属性设置全局透明度。
setFrameOpacity(frameContext, frameOpacity.value); // 设置头像框透明度
/**
* 设置 Canvas 的透明度
* @param context Canvas 的 2D 上下文
* @param opacity 透明度值
*/
function setFrameOpacity(context: CanvasRenderingContext2D, opacity: number) {
context.globalAlpha = opacity; // 设置全局透明度
}
实现效果
4. 头像框颜色过滤
页面部分 与上文一样 , 通过调整 avatarFrameCanvas
的内容而调整头像框
服务对象 我们有自定义头像框功能,但是自己找的头像很容易有白底
的问题,所以更新此功能。
页面部分 与上文一样 , 通过调整 avatarFrameCanvas
的内容而调整头像框
服务对象 我们有自定义头像框功能,但是自己找的头像很容易有白底
的问题,所以更新此功能。
逻辑部分 (核心部分)
filterColorToTransparent
函数
- 作用:将与指定颜色相近的像素变为透明。
colorDistance
函数
- 作用:计算两种颜色(RGB 值)之间的距离。距离越小,颜色越相似。
- 计算方式:使用欧几里得距离公式计算两个 RGB 颜色向量之间的距离,如果距离小于一定的容差值(
tolerance
),则认为两种颜色足够接近。 
rgbStringToArray
函数
- 作用:将 RGB 字符串(例如
'rgb(255,255,255)'
)转换为包含 r, g, b
值的对象。
/**
* 将指定颜色过滤为透明
* @param context Canvas 的 2D 上下文
* @param width Canvas 宽度
* @param height Canvas 高度
*/
function filterColorToTransparent(context: CanvasRenderingContext2D, width: number, height: number) {
const frameImageData = context.getImageData(0, 0, width, height);
const data = frameImageData.data;
const targetColor = rgbStringToArray(colorFilter.value.color); // 将目标颜色转换为 RGB 数组
// 遍历所有像素点
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const distance = colorDistance({r, g, b}, targetColor); // 计算当前颜色与目标颜色的差距
// 如果颜色差距在容差范围内,则将其透明度设为 0
if (distance <= colorFilter.value.tolerance) {
data[i + 3] = 0; // 设置 alpha 通道为 0(透明)
}
}
// 将处理后的图像数据放回 Canvas
context.putImageData(frameImageData, 0, 0);
}
/**
* 计算两种颜色之间的距离(欧几里得距离)
* @param color1 颜色 1,包含 r、g、b 属性
* @param color2 颜色 2,包含 r、g、b 属性
* @returns number 返回颜色之间的距离
*/
function colorDistance(color1: { r: number; g: number; b: number }, color2: { r: number; g: number; b: number }) {
return Math.sqrt(
(color1.r - color2.r) ** 2 +
(color1.g - color2.g) ** 2 +
(color1.b - color2.b) ** 2
);
}
/**
* 将 RGB 字符串转换为 RGB 数组
* @param rgbString RGB 字符串(例如 'rgb(255,255,255)')
* @returns 返回一个包含 r、g、b 值的对象
*/
function rgbStringToArray(rgbString: string) {
const result = rgbString.match(/\d+/g)?.map(Number) || [0, 0, 0]; // 匹配并转换 RGB 值
return {r: result[0], g: result[1], b: result[2]}; // 返回 r、g、b 对象
}
filterColorToTransparent
函数
- 作用:将与指定颜色相近的像素变为透明。
colorDistance
函数
- 作用:计算两种颜色(RGB 值)之间的距离。距离越小,颜色越相似。
- 计算方式:使用欧几里得距离公式计算两个 RGB 颜色向量之间的距离,如果距离小于一定的容差值(
tolerance
),则认为两种颜色足够接近。
rgbStringToArray
函数
- 作用:将 RGB 字符串(例如
'rgb(255,255,255)'
)转换为包含r, g, b
值的对象。
/**
* 将指定颜色过滤为透明
* @param context Canvas 的 2D 上下文
* @param width Canvas 宽度
* @param height Canvas 高度
*/
function filterColorToTransparent(context: CanvasRenderingContext2D, width: number, height: number) {
const frameImageData = context.getImageData(0, 0, width, height);
const data = frameImageData.data;
const targetColor = rgbStringToArray(colorFilter.value.color); // 将目标颜色转换为 RGB 数组
// 遍历所有像素点
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const distance = colorDistance({r, g, b}, targetColor); // 计算当前颜色与目标颜色的差距
// 如果颜色差距在容差范围内,则将其透明度设为 0
if (distance <= colorFilter.value.tolerance) {
data[i + 3] = 0; // 设置 alpha 通道为 0(透明)
}
}
// 将处理后的图像数据放回 Canvas
context.putImageData(frameImageData, 0, 0);
}
/**
* 计算两种颜色之间的距离(欧几里得距离)
* @param color1 颜色 1,包含 r、g、b 属性
* @param color2 颜色 2,包含 r、g、b 属性
* @returns number 返回颜色之间的距离
*/
function colorDistance(color1: { r: number; g: number; b: number }, color2: { r: number; g: number; b: number }) {
return Math.sqrt(
(color1.r - color2.r) ** 2 +
(color1.g - color2.g) ** 2 +
(color1.b - color2.b) ** 2
);
}
/**
* 将 RGB 字符串转换为 RGB 数组
* @param rgbString RGB 字符串(例如 'rgb(255,255,255)')
* @returns 返回一个包含 r、g、b 值的对象
*/
function rgbStringToArray(rgbString: string) {
const result = rgbString.match(/\d+/g)?.map(Number) || [0, 0, 0]; // 匹配并转换 RGB 值
return {r: result[0], g: result[1], b: result[2]}; // 返回 r、g、b 对象
}
实现效果
六、结束语
开发很容易,祝大家各个节日快乐 !!!
来源:juejin.cn/post/7419223935005605914
我的车被划了,看我实现简易监控拿捏他!node+DroidCam+ffmpeg
某天我骑着我的小电驴下班回到我那出租屋,习惯性的看了一眼我那停在门口的二手奥拓,突然发现有点不对劲,走近一看引擎盖上多了一大条划痕,顿时恶向胆边生,是谁!!!为此我决定用现有条件做一套简易的监控系统来应对日后的情况,于是有了这篇文章。
一 准备工作
由于是要做监控,硬件是必不可少的,所以首先想到的就是闲置的手机了,找了一台安卓8.1的古董出来,就决定是你了。因为之前在公司使用过
DroidCam这款软件用来进行webRTC的开发,所以这次就顺理成章的装了这款软件,连上家里的wifi后打开就相当于有了一台简易的视频服务器。那么硬件搞定了,接下来的就是软件了。梳理下来的话只有以下几点了
- 拉取DroidCam上的视频流
- 将拉取到的内容做存储
由于本人是个前端,因此这里就顺理成章的使用node来作为软件实现的第一方案了。
二 获取视频流,啊?怎么是这玩意儿
怎么获取它传过来的视频流呢?看了一下上打开的软件界面,发现给了两个地址,ip端口 和 ip端口/video,不出意料的这两个里面肯定是有能用的东西,挨个打开后发现不带video的地址是相当于一个控制台,带video的是视频的接口地址。那就好办了,我满怀激动的以为一切都很容易的时候,打开控制台一看,咦,这是啥玩意儿?它的所谓的视频是现在img标签里的,这在之前可是没见过哦,再看一眼接口地址,咦,这是一个长链接?点开详情看了一眼,好吧,又学到新东西了。它的Content-Type是multipart/x-mixed-replace;boundary='xxxx'
,这是啥呀,搜索了一下资料后如下。
MJPEG(Motion Joint Photographic Experts Gr0up)是一种视频压缩格式,其中每一帧图像都分别使用JPEG编码,不使用帧间编码,压缩率通常在20:1-50:1范围内。它的原理是把视频镜头拍成的视频分解成一张张分离的jpg数据发送到客户端。当客户端不断显示图片,即可形成相应的图像。
大致意思懂了,就是这就是一张张的图像呗。后面又看了一下服务端是如何生成这玩意儿的,这里就不细说了。
知道了是啥东西,那就要想怎么把它搞出来了
三 使用ffmpeg 获取每一帧
ffmpeg相信大家都不陌生,使用前需要先在本机上安装,安装方法的话这里就不赘述了。
安装后在系统环境变量高级设置中,增加path变量的值为ffmpeg在电脑上的路径。后续就可以使用了。
随便新建一个js文件
const fs = require('fs')
const path = require('path')
//截取的视频帧的存储路径和命名方式
const outputFilePattern = path.join(__dirname + '/newFrame', 'd.jpg');
//视频服务器地址
const mjpegUrl = 'http://192.168.2.101:4747/video?1920x1080';
//通过child_process的spawn调用外部文件,执行ffmpeg,并传入参数
//下方代码执行后在连接到服务后不手动停止的情况下期间会不断的在指定目录下生成获取到的图片
const ffmpeg = require('child_process').spawn('ffmpeg', [
'-i',
mjpegUrl,
'-vf',
'fps=24',//设置帧率
'-q:v',
'1', // 调整此值以更改输出质量(较低的值表示更好的质量)
outputFilePattern // %d 将被替换为帧编号
], { windowsHide: true });//调用时不显示cmd窗口
//错误监听
ffmpeg.on('error', function (err) {
throw err;
});
//关闭监听
ffmpeg.on('close', function (code) {
console.log('ffmpeg exited with code ' + code);
});
//数据
ffmpeg.stderr.on('data', function (data) {
console.log('stderr: ' + data.toString());
//执行合并图片操作
//....
});
上述代码运行后如果能正常连接上服务的话你会在指定目录下看到不断生成的图片。
四 将图片生成为视频
光有图片是不够的,我最终的预期是生成视频以供查看,所以添加以下的代码将图片合并为视频
//上面生成图片后存放的位置
let filePath = path.join(__dirname + '/newFrame', 'd.jpg');
let comd = [
'-framerate',
'24',
'-i',
filePath,
'-c:v',
'libx264',
'-pix_fmt',
'yuv420p',
`${__dirname}/outVideo/${new Date().getFullYear()}-${(new Date().getMonth() + 1).toString().padStart(2, '0')}-${new Date().getDate().toString().padStart(2, '0')}_${new Date().getHours().toString().padStart(2, '0')}${new Date().getMinutes().toString().padStart(2, '0')}${new Date().getSeconds().toString().padStart(2, '0')}.mp4`
]
const ffmpeg = require('child_process').spawn('ffmpeg', comd,{ windowsHide: true });
ffmpeg.on('close', function (code) {
console.log('ffmpeg exited with code ' + code);
console.log('任务执行结束,开始删除')
});
我这里定的是每2000张图片组合成视频,因此将第三步
中的
ffmpeg.stderr.on('data', function (data) {
console.log('stderr: ' + data.toString());
});
改成
ffmpeg.stderr.on('data', function (data) {
console.log('stderr: ' + data.toString());
//打印结果 =>>frame= 1474 fps= 14 q=1.0 size=N/A time=00:01:01.41 bitrate=N/A speed=0.57x
let arr = data.toString().split('fps')
try {
//获取frame数量用来计数
frameCount = arr[0].split('=')[1].trim()
console.log(frameCount)
//为什么这里用大于而不是等于呢,因为获取frame可能不是总会计数到我们想要的值,踩过坑,注意
if (frameCount > 2000) {
console.log('数量满足')
//关闭本次获取流
ffmpeg.kill('SIGKILL');
//这里执行合并文件的操作
//...
}
} catch (e) { }
});
到这里如果你一切顺利的话就能在指定的文件夹里看到合并完成后的MP4视频了。
五 合并完成后删除上次获取的图片
将第四步
的
ffmpeg.on('close', function (code) {
console.log('ffmpeg exited with code ' + code);
console.log('任务执行结束,开始删除')
});
改为
ffmpeg.on('close', async function (code) {
console.log('ffmpeg exited with code ' + code);
console.log('任务执行结束,开始删除')
try {
await fsE.emptyDir('your folderPath');
console.log(`已清空文件夹`);
//重新执行第二步
//...
} catch (err) {
console.error(`无法清空文件夹: ${err}`);
}
});
这里的fsE
是 const fsE = require('fs-extra');
,需要安装并且导入
到这里为止,整个基本的流程就完成了
六 总结
整个程序到目前为止已经能基本满足我的需求,但是还存在不足,比如频繁的往硬盘上读写文件、容错处理等等,后续我的想法是把图片保存到内存中,在满足条件后再写入硬盘,减少文件的I/O操作,加入对人体的识别,接入之前写过的邮件通知,有人靠近自动记录时间点并发送到邮箱。当然了,我这个肯定比不了市面上的那些成熟产品,就是自己写着好玩的,请各位大佬轻喷!有错误和意见欢迎指正!
来源:juejin.cn/post/7419887017164767268
强大的一笔的Hermes引擎,是如何让你的 App 脱颖而出的!
Hermes 是一款由 Facebook 开源的轻量级 JavaScript 引擎,专门针对 React Native 应用进行了优化。与传统的 JavaScript 引擎(例如 JavaScriptCore 和 V8)相比,Hermes 具有以下优势:
启动时间更快: Hermes 使用预编译字节码(AOT),而不是即时编译(JIT),这可以显著缩短应用的启动时间。
更小的内存占用: Hermes 的体积小巧,占用内存更少,这对于移动设备尤为重要。
更小的应用包大小: 由于 Hermes 的体积小巧,因此可以减小 React Native 应用的包大小。
高效的性能的原因
先看下面这幅图:
Hermes Engine 的设计初衷是为了优化 React Native 应用的性能。它通过对 JavaScript 代码的提前编译,将其转化为字节码,从而减少了运行时的解析时间。这种预编译机制使得应用启动速度显著提升,用户体验更加流畅。
在 CPU 利用率方面,Hermes 也有显著的优势。
通过优化 JavaScript 执行和垃圾回收过程,Hermes 提供了更快的启动时间和更低的内存占用。研究表明,使用 Hermes 的应用在性能上有显著提升,用户体验更加流畅
内存占用和包大小优化
Hermes 采用了优化的内存管理机制,如内存池和高效的垃圾回收算法,能够减少应用在运行时的内存占用。这对于资源受限的移动设备尤为关键。使用 Hermes 编译的应用包体积通常更小。这对于需要快速下载安装的应用很有优势,也有助于提高应用在应用商店的排名。上图就是 Stock RN 应用基于 Hermes 引擎的内存优化后的实际效果。
良好的兼容性
Hermes 提供了强大的调试工具,帮助开发者快速定位和解决问题。其集成的调试功能使得开发者能够实时监控应用的性能,及时发现并修复潜在的性能瓶颈。
Hermes 得到了 Facebook 和开源社区的广泛支持,拥有丰富的文档和活跃的开发者社区。开发者可以轻松获取资源和支持,促进了 Hermes 的快速发展和普及。
一些小众第三方库不支持 Hermes 引擎
虽然,大多数比较有名的第三方库都是支持 Hermes引擎的,但是有一个小小的问题,有些比较小众的第三方库,是不支持 hermes 引擎的,这个时候,你可需要想办法自己改写下这个第三方库,或者给作者提建议。
如,腾讯云 cos ,React Native 的库,就是不支持 Hermes 引擎的。相关issue 在这里:
不过,对于这个问题,你完全可以使用 restful api 呀,所以,解决问题的方式太多了,不要因为一个小众的三方库而放弃恐怖的性能提升,多少有点不值当。
实际应用案例
许多知名应用已经开始采用 Hermes Engine,以提升其性能。例如,Facebook 和 Instagram 的部分功能已成功迁移至 Hermes,用户反馈显示应用的启动时间和流畅度均有显著改善。这些成功案例进一步验证了 Hermes 的强大实力。
如何用上Hermes 引擎
如果你在使用 Expo 做移动端跨端研发,那么恭喜你,默认就是使用的 Hermes 引擎,无需任何配置,如果你想显式配置,也无妨,甚至你可以指定 ios 使用jsc 引擎。
{
"expo": {
"jsEngine": "hermes",
"ios": {
"jsEngine": "jsc"
}
}
}
如果你使用的是 React Native 0.70 或更高版本,则 Hermes 引擎将默认启用。如果你使用的是较早版本的 React Native,则可以按照 React Native 文档 中的说明启用 Hermes 引擎。配置简单的就啰嗦。小伙伴们,React Native 要吊打 Flutter了 吗?拍拍砖?
来源:juejin.cn/post/7394095950383743015
Vite 为何短短几年内变成这样?
给前端以福利,给编程以复利。大家好,我是大家的林语冰。
00. 观前须知
在 Web 开发领域,Vite 如今已如雷贯耳。
自 2020 年 4 月发布以来,Vite 的人气蒸蒸日上。目前 Vite 在 GitHub 上的收藏数量已超过 64k,每周下载量超过 1200 万次,现在为 Nuxt、Remix、Astro 等大多数开源框架提供支持。
尽管众口嚣嚣,我们意识到许多开发者可能仍然不熟悉 Vite 是什么鬼物,也不熟悉 Vite 在推动现代 Web 框架和工具的开发中扮演的重要角色。
在本文中,我们将科普 Vite 的知识储备,以及 Vite 如何在短短几年后发展成为现代 Web 的重量级角色。
免责声明
本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考。英文原味版请传送 What is Vite (and why is it so popular)?。
01. Vite 是什么鬼物?
Vite 的发音为 /vit/
,在法语中是“快速”或“迅捷”的意思,不得不说 Vite 名副其实。
简而言之,Vite 是一种现代 JS 构建工具,为常见 Web 模式提供开箱即用的支持和构建优化,兼具 rollup
的自由度和成熟度。
Vite 还与 esbuild
和原生 ES 模块强强联手,实现快速无打包开发服务器。
Vite 是由“Vue 之父”尤雨溪(Evan You)构思出来的,旨在通过减少开发者在启动开发服务器和处理文件编辑后重载时遭遇的性能瓶颈,简化打包过程。
02. Vite 的核心特性
运行 Vite 时,你会注意到的第一个区别在于,开发服务器会即时启动。
这是因为,Vite 采用按需方法将你的应用程序提供给浏览器。Vite 不会首先打包整个源码,而是响应浏览器请求,将你编写的模块即时转换为浏览器可以理解的原生 ESM 模块。
Vite 为 TS、PostCSS、CSS 预处理器等提供开箱即用的支持,且可以通过不断增长的插件生态系统进行扩展,支持所有你喜欢的框架和工具。
每当你在开发期间更改项目中的任意文件时,Vite 都会使用应用程序的模块图,只热重载受影响的模块(HMR)。这允许开发者预览他们的更改,及其对应用程序的影响。
Vite 的 HMR 速度惊人,可以让编辑器自动保存,并获得类似于在浏览器开发工具中修改 CSS 时的反馈循环。
Vite 还执行 依赖预构建(dependency pre-bundling)。在开发过程中,Vite 使用 esbuild
来打包你的依赖并缓存,加快未来服务器的启动速度。
此优化步骤还有助于加快 lodash
等导出许多迷你模块的依赖的加载时间,因为浏览器只加载每个依赖的代码块(chunk)。这还允许 Vite 在依赖中支持 CJS 和 UMD 代码,因为它们被打包到原生 ESM 模块中。
当你准备好部署时,Vite 将使用优化的 rollup
设置来构建你的应用程序。Vite 会执行 CSS 代码分割,添加预加载指令,并优化异步块的加载,无需任何配置。
Vite 提供了一个通用的 rollup
兼容插件 API,适用于开发和生产,使你可以更轻松地扩展和自定义构建过程。
03. Vite 的优势
使用 Vite 有若干主要优势,包括但不限于:
03-1. 开源且独立
Vite 由开源开发者社区“用爱发电”,由来自不同背景的开发者团队领导,Vite 核心仓库最近贡献者数量已突破 900 人。
Vite 得到积极的开发和维护,不断实现新功能并解决错误。
03-2. 本地敏捷开发
开发体验是 Vite 的核心,每次点击保存时,你都能感受到延迟。我们常常认为重载速度是理所当然的。
但随着您的应用程序增长,且重载速度逐渐停止,你将感恩 Vite 几乎能够保持瞬间重载,而无论应用程序大小如何。
03-3. 广泛的生态系统支持
Vite 的方案人气爆棚,大多数框架和工具都默认使用 Vite 或拥有一流的支持。通过选择使用 Vite 作为构建工具,这些项目维护者可以在它们之间共享一个统一基建,且随着时间的推移共同改良 Vite。
因此,它们可以花更多的时间开发用户需要的功能,而减少重新造轮子的时间。
03-4. 易于扩展
Vite 对 rollup
插件 API 的押注得到了回报。插件允许下游项目共享 Vite 核心提供的功能。
我们有很多高质量的插件可供使用,例如 vite-plugin-pwa
和 vite-imagetools
。
03-5. 框架构建难题中的重要角色
Vite 是现代元框架构建的重要组成部分之一,这是一个更大的工具生态系统的一部分。
Volar 提供了在代码编辑器中为 Vue、MDX 和 Astro 等自定义编程语言构建可靠且高性能的编辑体验所需的工具。Volar 允许框架向用户提供悬停信息、诊断和自动补全等功能,并共享 Volar 作为为它们提供支持的通用基建。
另一个很好的例子是 Nitro,它是一个服务器工具包,用于创建功能齐全的 Web 服务器,开箱即用地支持每个主要部署平台。Nitro 是一个与框架无关的库 UnJS 的奇妙集合的一部分。
04. Vite 的未来
在最近的 ViteConf 大会的演讲中,尤雨溪表示,虽然 Vite 取得了巨大进展,但仍面临一些已知的问题和挑战。
Vite 目前使用 rollup
进行生产构建,这比 esbuild
或 Bun
等原生打包器慢得多。
Vite 还尽可能减少开发和生产环境之间的不一致性,但考虑到 rollup
和 esbuild
之间的差异,某些不一致性无法避免。
尤雨溪现在领导一个新团队开发 rolldown
,这是一个基于 Rust 的 rollup
移植,在 “JS 氧化编译器 OXC”之上构建了最大的兼容性。
这个主意是用 rolldown
替代 Vite 中的 rollup
和 esbuild
。Vite 将拥有一个单独基建,兼具 rollup
的自由度和 esbuild
的速度,消除不一致性,使代码库更易于维护,并加快构建时间。
rolldown
目前处于早期阶段,但已经显示出有希望的结果。rolldown
现已开源,rolldown
团队正在寻找贡献者来辅助实现这一愿景。
与此同时,Vite 团队在每个版本中不断改良 Vite。这项工作从上游的为 Vitest 和 Nuxt Dev SSR 提供动力的引擎 vite-node
开始,现已发展成为框架作者对 Vite API 的完整修订版。
新版 Environment API 预计在 Vite 6 中发布,这将是自 Vite 2 发布以来 Vite 最大的变化之一。这将允许在任意数量的环境中通过 Vite 插件管道运行代码,解锁对 worker、RSC 等的一流支持。
Vite 正在开辟一条前进的道路,并迅速成为 JS 生态系统事实上的构建工具。
参考文献
- Vite:vitejs.dev
- Blog:blog.stackblitz.com/posts/what-…
- Rolldown:rolldown.rs
粉丝互动
本期话题是:如何评价人气爆棚的 Vite,你最喜欢或期待 Vite 的哪个功能?你可以在本文下方自由言论,文明科普。
欢迎持续关注“前端俱乐部”,给前端以福利,给编程以复利。
坚持阅读的小伙伴可以给自己点赞!谢谢大家的点赞,掰掰~
来源:juejin.cn/post/7368836713965486119
因为编辑器没做草稿,老板崩溃了。。。
现场
大家好,我是多喝热水。
事情是这样的,那天晚上老板在群里吐槽说他在手机上写了将近 1000 字的评论不小心点了一下黑屏,然后内容就突然没了,如下:
原来是我们编辑器没有做草稿能力,导致关闭后原本编辑的内容都消失了,确实这个体验不太好,想想怎么把这里优化一下。
调研
像我们平时用得比较多的社交平台,比如某音、某书等,先从它们的评论区入手,看看主流的平台是怎么做的。
1)某音
某音的效果是,在某条视频下评论后划走,再划回来编辑的内容就不在了,看样子是没有做草稿能力,如图:
2)某书
某书的效果是,在某个笔记下面评论然后划走,再回来的时候内容是还在的。而且每条评论都有自己的编辑态,互不干扰,如图:
好看真好看,呸好用真好用,既然体验上某书更好,我决定仿照某书的方案来实现。
既然要做成某书的效果,那我们就需要解决两个问题:
1)他们评论区草稿内容是怎么存的?
2)存在哪里了?
内容怎么存?
先说说我的看法,如果要让每条评论都拥有独立的编辑态,那么肯定是需要一个唯一标识的,那我能想到的唯一标识就是ID。
内容存哪里?
存后端还是存前端?存前端的话又存哪里?这里我简单总结了一下:
存后端
优势:数据真正的持久化、安全性高
缺陷:需要网络连接,依赖后端,开发成本高
存前端
优势:简单易用、性能好、脱机可用
缺陷:无法真正持久化、存储空间有限、不安全
方案选择
回归到需求本身,我们不需要实时性多么高,所以存前端就已经可以满足我们的需求了。
但在前端存储还有一个存储空间问题,需要考虑一下存储内容的有效时间,过期了就得删除,不然会存在很多冗余数据,所以我们又面临新的问题,前端用什么来存?
浏览器常用的存储方案:cookie、localStorage、sessionStorage
1)cookie 是可以设置过期时间的,但如果存 cookie,那它的容量只有5kb,有点太小了,并且每次发请求 cookie 都会被携带上,无疑是增加了额外的带宽开销
2)sessionStorage 存储空间最大支持5MB,但窗口被关闭后数据就过期了,有效期仅仅是窗口会话期间,万一用户不小心关闭了窗口,数据也消失了,所以这个方案也不太妥当
3)相比之下 localStorage 的容量也有 5MB,足够大,但是它本身不支持设置过期时间(默认永久有效),需要人为去控制,好在这个成本并不高,综合之下我们还是选择存 localStorage 了
开发
选好方案后,就可以开始动手开发了!先把支持控制过期时间 的 localStorage 逻辑写一下。
写之前我们需要考虑一下代码的复用性,因为在我们网站中,有很多地方都用到了编辑器,比如评论区、交流内容发布等,如果每一处都写一遍的话,那这个代码就太冗余了,所以将它封装为一个 hook 是一个不错的选择,代码如下:
import { CACHE_TYPE, EXPIRES_TIME } from './constants';
/**
* 缓存数据
* @param key
* @returns
*/
export default function useCache(key: string = CACHE_TYPE.ESSAY_CONTENT) {
/**
* 删除缓存数据
*/
const removeCache = () => {
localStorage.removeItem(key);
};
/**
* 设置缓存数据
* @param data 数据内容
* @param expires 过期时间(毫秒)
*/
const setCache = (data: any, expires: number = EXPIRES_TIME) => {
const cacheData = {
value: data,
expires: expires ? Date.now() + expires : null, // 计算过期时间戳
};
localStorage.setItem(key, JSON.stringify(cacheData));
};
/**
* 获取缓存数据
* @returns 缓存数据或 null
*/
const getCache = () => {
const cachedString = localStorage.getItem(key);
if (!cachedString) {
return null;
}
const cachedObject = JSON.parse(cachedString);
// 检查是否设置了过期时间并且是否已经过期
if (cachedObject.expires && Date.now() > cachedObject.expires) {
removeCache(); // 删除已过期的数据
return null;
}
return cachedObject.value;
};
return { removeCache, setCache, getCache };
}
简单解释一下上面的代码:
1)useCache 函数主要接收一个 KEY,删除、获取、设置草稿数据都会用到这个 KEY,且我们保证它是唯一的
2)在设置需要缓存内容时(setCache),会给出一个 expires 的参数用于控制该数据的有效时间
3)获取数据的时候会校验一下有效时间,如果已经过期了则返回 null
在编辑器中应用
最后我们需要在用到编辑器的地方使用这个 hook。
可能有些小伙伴会觉得我们网站中用到编辑器的地方很多,这一步才是一个大工程,其实不然,因为我们所有用到编辑器的地方都是用的同一个组件,我们需要改动的地方就是那个公共的编辑器组件!
这时候封装带来的便捷性就体现的淋漓尽致,省去了不少时间用来摸鱼!!!
改动代码如下(伪代码):
type GeneralContentEditorProps = {
targetId?: string; // 缓存ID
// 省略不相关代码...
};
/**
* 通用的内容编辑器
* @param props
* @returns
*/
export default function GeneralContentEditor({
targetId,
// 省略不相关代码...
}: GeneralContentEditorProps) {
// 省略不相关代码...
const [content, setContent] = useState('')
const { getCache, setCache, removeCache } = useCache(targetId);
useEffect(() => {
setContent(getCache() ?? '')
}, [])
}
简单解释一下上面的代码:
1)给编辑器新增了一个属性 targetId,这个 targetId 用来作为缓存的唯一标识,由使用方提供给我们
2)初始化的时候去调 getCache 函数读取缓存的数据
3)有内容变更的时候调 setCache 函数去更新缓存的数据
到这里流程已经跑通了,但还缺少重要的一步,需要定时清空一下缓存的数据,因为现在的逻辑是如果我们不主动去获取这个数据,它还是占据着存储空间。
清空冗余数据
其实我们也不需要专门去写定时器来清空,只需要在编辑器初始化的时候去检测一遍就可以,所以代码还需加点料,如下图:
到这一步编辑器草稿能力就完善的差不多了,已经能够正常使用了,我们看看效果,如下:
nice,没有什么问题,好了,我要去摸鱼了 😋
来源:juejin.cn/post/7419598991119532043
老板想集成地图又不想花钱,于是让我...
前言
在数字化时代,地图服务已成为各类应用的标配,无论是导航、位置分享还是商业分析,地图都扮演着不可或缺的角色。然而,高质量的地图服务往往伴随着不菲的授权费用。公司原先使用的是国内某知名地图服务,但随着业务的扩展和成本的考量,老板决定寻找一种成本更低的解决方案。于是,我们的目光转向了免费的地图服务——天地图。
天地图简介
天地图(lbs.tianditu.gov.cn/server/guid…
是中国领先的在线地图服务之一,提供全面的地理信息服务。它的API支持地理编码、逆地理编码、周边搜索等多种功能,且完全免费。这正是我们需要的。
具体实现代码
为了将天地图集成到我们的系统中,我们需要进行一系列的开发工作。以下是实现过程中的关键代码段。
1. 逆地理编码
逆地理编码是将经纬度转换为可读的地址。在天地图中,这一功能可以通过以下代码实现:
public static MapLocation reverseGeocode(String longitude, String latitude) {
Request request = new Request();
LocateInfo locateInfo = GCJ02_WGS84Utils.gcj02_To_Wgs84(Double.valueOf(latitude), Double.valueOf(longitude));
longitude = String.valueOf(locateInfo.getLongitude());
latitude = String.valueOf(locateInfo.getLatitude());
String postStr = String.format(REVERSE_GEOCODE_POST_STR, longitude, latitude);
String encodedPostStr = null;
try {
encodedPostStr = URLEncoder.encode(postStr, "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
String url = REVERSE_GEOCODE_URL + "?tk=" + TK + "&type=" + GEOCODE + "&postStr=" + encodedPostStr;
request.setUrl(url);
Response<String> response = HttpClientHelper.getWithoutUserAgent(request, null);
if (response.getSuccess()) {
String body = response.getBody();
JSONObject jsonObject = JSON.parseObject(body);
String status = jsonObject.getString("status");
if (!"0".equals(status)) {
return null;
}
JSONObject resultObject = jsonObject.getJSONObject("result");
MapLocation mapLocation = new MapLocation();
String formattedAddress = resultObject.getString("formatted_address");
mapLocation.setAddress(formattedAddress);
String locationStr = resultObject.getString("location");
JSONObject location = JSON.parseObject(locationStr);
String lon = location.getString("lon");
String lat = location.getString("lat");
locateInfo = GCJ02_WGS84Utils.wgs84_To_Gcj02(Double.valueOf(lat), Double.valueOf(lon));
lon = String.valueOf(locateInfo.getLongitude());
lat = String.valueOf(locateInfo.getLatitude());
mapLocation.setLongitude(lon);
mapLocation.setLatitude(lat);
JSONObject addressComponent = resultObject.getJSONObject("addressComponent");
String address = addressComponent.getString("address");
mapLocation.setName(address);
mapLocation.setCity(addressComponent.getString("city"));
return mapLocation;
}
return null;
}
2. 周边搜索
周边搜索允许我们根据一个地点的经纬度搜索附近的其他地点。实现代码如下:
public static List<MapLocation> nearbySearch(String query, String longitude, String latitude, String radius) {
LocateInfo locateInfo = GCJ02_WGS84Utils.gcj02_To_Wgs84(Double.valueOf(latitude), Double.valueOf(longitude));
longitude = String.valueOf(locateInfo.getLongitude());
latitude = String.valueOf(locateInfo.getLatitude());
Request request = new Request();
String longLat = longitude + "," + latitude;
String postStr = String.format(NEARBY_SEARCH_POST_STR, query, Integer.valueOf(radius), longLat);
String encodedPostStr = null;
try {
encodedPostStr = URLEncoder.encode(postStr, "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
String url = SEARCH_URL + "?tk=" + TK + "&type=" + QUERY + "&postStr=" + encodedPostStr;
request.setUrl(url);
Response<String> response = HttpClientHelper.getWithoutUserAgent(request, null);
List<MapLocation> list = new ArrayList<>();
if (response.getSuccess()) {
String body = response.getBody();
JSONObject jsonObject = JSON.parseObject(body);
JSONObject statusObject = jsonObject.getJSONObject("status");
String infoCode = statusObject.getString("infocode");
if (!"1000".equals(infoCode)) {
return new ArrayList<>();
}
String resultType = jsonObject.getString("resultType");
String count = jsonObject.getString("count");
if (!"1".equals(resultType) || "0".equals(count)) {
return new ArrayList<>();
}
JSONArray poisArray = jsonObject.getJSONArray("pois");
for (int i = 0; i < poisArray.size(); i++) {
JSONObject poiObject = poisArray.getJSONObject(i);
MapLocation mapLocation = new MapLocation();
mapLocation.setName(poiObject.getString("name"));
mapLocation.setAddress(poiObject.getString("address"));
String lonlat = poiObject.getString("lonlat");
String[] lonlatArr = lonlat.split(",");
locateInfo = GCJ02_WGS84Utils.wgs84_To_Gcj02(Double.valueOf(lonlatArr[1]), Double.valueOf(lonlatArr[0]));
String lon = String.valueOf(locateInfo.getLongitude());
String lat = String.valueOf(locateInfo.getLatitude());
mapLocation.setLongitude(lon);
mapLocation.setLatitude(lat);
list.add(mapLocation);
}
}
return list;
}
3. 文本搜索
文本搜索功能允许用户根据关键词搜索地点。实现代码如下:
public static List<MapLocation> searchByText(String query, String mapBound) {
Request request = new Request();
String postStr = String.format(SEARCH_BY_TEXT_POST_STR, query, mapBound);
String encodedPostStr = null;
try {
encodedPostStr = URLEncoder.encode(postStr, "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
String url = SEARCH_URL + "?tk=" + TK + "&type=" + QUERY + "&postStr=" + encodedPostStr;
request.setUrl(url);
Response<String> response = HttpClientHelper.getWithoutUserAgent(request, null);
List<MapLocation> list = new ArrayList<>();
if (response.getSuccess()) {
String body = response.getBody();
JSONObject jsonObject = JSON.parseObject(body);
JSONObject statusObject = jsonObject.getJSONObject("status");
String infoCode = statusObject.getString("infocode");
if (!"1000".equals(infoCode)) {
return new ArrayList<>();
}
String resultType = jsonObject.getString("resultType");
String count = jsonObject.getString("count");
if (!"1".equals(resultType) || "0".equals(count)) {
return new ArrayList<>();
}
JSONArray poisArray = jsonObject.getJSONArray("pois");
for (int i = 0; i < poisArray.size(); i++) {
JSONObject poiObject = poisArray.getJSONObject(i);
MapLocation mapLocation = new MapLocation();
mapLocation.setName(poiObject.getString("name"));
mapLocation.setAddress(poiObject.getString("address"));
String lonlat = poiObject.getString("lonlat");
String[] lonlatArr = lonlat.split(",");
LocateInfo locateInfo = GCJ02_WGS84Utils.wgs84_To_Gcj02(Double.valueOf(lonlatArr[1]), Double.valueOf(lonlatArr[0]));
String lon = String.valueOf(locateInfo.getLongitude());
String lat = String.valueOf(locateInfo.getLatitude());
mapLocation.setLongitude(lon);
mapLocation.setLatitude(lat);
list.add(mapLocation);
}
}
return list;
}
4. 坐标系转换
由于天地图使用的是WGS84坐标系,而国内常用的是GCJ-02坐标系,因此我们需要进行坐标转换。以下是坐标转换的工具类:
/**
* WGS-84:是国际标准,GPS坐标(Google Earth使用、或者GPS模块)
* GCJ-02:中国坐标偏移标准,Google Map、高德、腾讯使用
* BD-09:百度坐标偏移标准,Baidu Map使用(经由GCJ-02加密而来)
* <p>
* 这些坐标系是对真实坐标系统进行人为的加偏处理,按照特殊的算法,将真实的坐标加密成虚假的坐标,
* 而这个加偏并不是线性的加偏,所以各地的偏移情况都会有所不同,具体的内部实现是没有对外开放的,
* 但是坐标之间的转换算法是对外开放,在网上可以查到的,此算法的误差在0.1-0.4之间。
*/
public class GCJ02_WGS84Utils {
public static double pi = 3.1415926535897932384626;//圆周率
public static double a = 6378245.0;//克拉索夫斯基椭球参数长半轴a
public static double ee = 0.00669342162296594323;//克拉索夫斯基椭球参数第一偏心率平方
/**
* 从GPS转高德
* isOutOfChina 方法用于判断经纬度是否在中国范围内,如果不在中国范围内,则直接返回原始的WGS-84坐标。
* transformLat 和 transformLon 是辅助函数,用于进行经纬度的转换计算。
* 最终,wgs84ToGcj02 方法返回转换后的GCJ-02坐标系下的经纬度。
*/
public static LocateInfo wgs84_To_Gcj02(double lat, double lon) {
LocateInfo info = new LocateInfo();
if (isOutOfChina(lat, lon)) {
info.setChina(false);
info.setLatitude(lat);
info.setLongitude(lon);
} else {
double dLat = transformLat(lon - 105.0, lat - 35.0);
double dLon = transformLon(lon - 105.0, lat - 35.0);
double radLat = lat / 180.0 * pi;
double magic = Math.sin(radLat);
magic = 1 - ee * magic * magic;
double sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * pi);
dLon = (dLon * 180.0) / (a / sqrtMagic * Math.cos(radLat) * pi);
double mgLat = lat + dLat;
double mgLon = lon + dLon;
info.setChina(true);
info.setLatitude(mgLat);
info.setLongitude(mgLon);
}
return info;
}
//从高德转到GPS
public static LocateInfo gcj02_To_Wgs84(double lat, double lon) {
LocateInfo info = new LocateInfo();
LocateInfo gps = transform(lat, lon);
double lontitude = lon * 2 - gps.getLongitude();
double latitude = lat * 2 - gps.getLatitude();
info.setChina(gps.isChina());
info.setLatitude(latitude);
info.setLongitude(lontitude);
return info;
}
// 判断坐标是否在国外
private static boolean isOutOfChina(double lat, double lon) {
if (lon < 72.004 || lon > 137.8347)
return true;
if (lat < 0.8293 || lat > 55.8271)
return true;
return false;
}
//转换
private static LocateInfo transform(double lat, double lon) {
LocateInfo info = new LocateInfo();
if (isOutOfChina(lat, lon)) {
info.setChina(false);
info.setLatitude(lat);
info.setLongitude(lon);
return info;
}
double dLat = transformLat(lon - 105.0, lat - 35.0);
double dLon = transformLon(lon - 105.0, lat - 35.0);
double radLat = lat / 180.0 * pi;
double magic = Math.sin(radLat);
magic = 1 - ee * magic * magic;
double sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * pi);
dLon = (dLon * 180.0) / (a / sqrtMagic * Math.cos(radLat) * pi);
double mgLat = lat + dLat;
double mgLon = lon + dLon;
info.setChina(true);
info.setLatitude(mgLat);
info.setLongitude(mgLon);
return info;
}
//转换纬度所需
private static double transformLat(double x, double y) {
double ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y
+ 0.2 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * pi) + 20.0 * Math.sin(2.0 * x * pi)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(y * pi) + 40.0 * Math.sin(y / 3.0 * pi)) * 2.0 / 3.0;
ret += (160.0 * Math.sin(y / 12.0 * pi) + 320 * Math.sin(y * pi / 30.0)) * 2.0 / 3.0;
return ret;
}
//转换经度所需
private static double transformLon(double x, double y) {
double ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1
* Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * pi) + 20.0 * Math.sin(2.0 * x * pi)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(x * pi) + 40.0 * Math.sin(x / 3.0 * pi)) * 2.0 / 3.0;
ret += (150.0 * Math.sin(x / 12.0 * pi) + 300.0 * Math.sin(x / 30.0 * pi)) * 2.0 / 3.0;
return ret;
}
}
结论
通过上述代码,我们成功地将天地图集成到了我们的系统中,不仅满足了功能需求,还大幅降低了成本。这一过程中,我们深入理解了地图服务的工作原理,也提升了团队的技术能力。
注意事项
- 确保在使用天地图API时遵守其服务条款,尤其是在商业用途中。
- 由于网络或其他原因,天地图API可能存在访问延迟或不稳定的情况,建议在生产环境中做好异常处理和备用方案。
- 坐标系转换是一个复杂的过程,确保使用可靠的算法和工具进行转换,以保证定位的准确性。
通过这次集成,我们不仅为公司节省了成本,还提升了系统的稳定性和用户体验。在未来的开发中,我们将继续探索更多高效、低成本的技术解决方案。
来源:juejin.cn/post/7419524888041472009
js中的finally一定会执行吗?
背景
在我们程序开发中,我们的代码会出现这种或那种的错误,我们使用try...catch
进行捕获。如果需要不管是成功还是失败都需要执行,我们可能需要finally
。
那么有一个问题,无论是否发生错误,在finally
中的代码一定会执行吗?
下面我们看一个案例:
1. 案例
场景:请求一个接口,如果接口没有正确返回,我们使用try...finally
包裹代码,代码如下:
function getMember(num) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (num === 1) {
resolve(num)
}
if (num === 0) {
reject()
}
}, 2000)
})
}
async function init() {
try {
console.log('打印***start')
await getMember(0)
console.log('打印***end')
} catch (err) {
console.log('打印***err')
} finally {
console.log('打印***finally')
}
}
结果如下:
上述案例中,如果请求传入的num
由另外一个接口返回,num
的值不是0
或者1
,上述的getMember
就一直处于pengding
状态,接下来的finally
也不会执行。
我们也可以这样理解,当在处理Promise
问题时,我们需要确保Promise
始终得到结果,不管是成功还是失败。
上述代码可以完善如下:
function getMember(num) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (num === 1) {
resolve(num);
} else if (num === 0) {
reject(new Error('Num is 0'));
} else {
// 默认情况,也解决Promise
resolve('Some default value');
}
}, 2000);
});
}
async function init() {
try {
console.log('打印***start');
const result = await getMember(2); // 传递一个非0非1的值
console.log('打印***end', result);
} catch (err) {
console.log('打印***err', err);
} finally {
console.log('打印***finally'); // 这行总是会被执行
}
}
init();
修改后的例子中,无论num
的值是什么,Promise
都会被解决(要么通过resolve
,要么通过reject
),,确保Promise
被正常处理,才能确保finally
执行。
2. try...catch注意点
2.1 仅对运行时的 error 有效
要使得 try...catch
能工作,代码必须是可执行的。换句话说,它必须是有效的 JavaScript 代码。
如果代码包含语法错误,那么 try..catch
将无法正常工作,例如含有不匹配的花括号:
try {
{
{
} catch (err) {
alert("引擎无法理解这段代码,它是无效的");
}
结果如下:
JavaScript 引擎首先会读取代码,然后运行它。在读取阶段发生的错误被称为“解析时间(parse-time)”错误,并且无法恢复(从该代码内部)。这是因为引擎无法理解该代码。
所以,try...catch
只能处理有效代码中出现的错误。这类错误被称为“运行时的错误(runtime errors)”,有时被称为“异常(exceptions)”。
2.2 try...catch
同步执行
如果在定时代码中发生异常,例如在 setTimeout
中,则 try...catch
不会捕获到异常:
try {
setTimeout(function () {
noSuchVariable; // 脚本将在这里停止运行
}, 1000);
} catch (err) {
alert("不工作");
} finally {
console.log('打印***finally')
}
结果如下:
因为 try...catch
包裹了计划要执行的函数,该函数本身要稍后才执行,这时引擎已经离开了 try...catch
结构。
为了捕获到计划的(scheduled)函数中的异常,那么 try...catch
必须在这个函数内:
try {
setTimeout(function () {
try {
noSuchVariable; // 脚本将在这里停止运行
} catch (error) {
console.log(error)
}
}, 1000);
} catch (err) {
alert("不工作");
} finally {
console.log('打印***finally')
}
结果如下:
总结
在使用try...catch...finally
的时候,无论是否发生异常(即是否执行了catch
块),finally
块中的代码总是会被执行,除非在try
、catch
或finally
块中发生了阻止程序继续执行的情况(如Promsie一直处理pending状态)。
如有错误,请指正O^O!
来源:juejin.cn/post/7419524503200677898
iframe嵌入页面实现免登录思路(以vue为例)
背景:
最近实现一个功能需要使用iframe
嵌入其它系统内部的一个页面,但嵌入后出现一个问题,就是一打开这个页面就会自动跳转到登录页,原因是被嵌入系统没有登录(没有token
)肯定不让访问内部页面的,本文就是解决这个问题的。
附带相关文章:只要用iframe必遇到这6种"坑"之一(以Vue为例)
选择的技术方案:
本地系统使用iframe
嵌入某个系统内部页面,那就证明被嵌入系统是安全的可使用的,所以可以通过通讯方式带一个token
过去实现免登录,我用vue
项目作为例子具体如下:
方法一通过url传:
// 发送方(本地系统):
<div>
<iframe :src="url" id="childFrame" importance="high" name="demo" ></iframe>
</div>
//被嵌入页面进行接收
url = `http://localhost:8080/dudu?mytoken={mytoken}` //
接收方:直接使用window.location.search接收,然后对接收到的进行处理
注意:
- 如果使用这个方法最好把
token
加密一下,要不然直接显示在url
是非常危险的行为,所以我更推荐下面方法二 - 上面接收方要在在
APP.vue
文件的created
生命周期接收,在嵌入页面接收是不行的,这里与VUE
的执行流程有关就不多说了
方法二通过iframe的通讯方式传(推荐):
// 发送方(本地系统):
var params = {
type: "setToken",
token: "这是伟过去的token"
}
window.parent.postMessage(params, "*");
// 接收方(被嵌入系统):在APP.vue文件的created生命周期接收
window.addEventListener( "message",
(e)=>{
if(e.data.type === 'setToken'){
//这里拿到token,然后放入缓存实在免登录即可
}
}
false);
注意: 上面接收方要在在APP.vue
文件的created
生命周期接收,在嵌入页面接收是不行的,这里与VUE的执行流程有关就不多说了
补充:
看着评论不少疑问,所以我就按我个人的思路去补充回答一下,但不绝对实用,欢迎互相指导
(1)如果不同源系统怎么办?
正常使用上述方法二进行通迅,但不带token
过去因为不同源根本无法通用,直接在被嵌入页面请求token,这个要和后端沟通好怎么获取
// 接收方(被嵌入系统):在APP.vue文件的created生命周期接收
window.addEventListener( "message",
(e)=>{
if(e.data.type === 'setToken'){
//这里在被嵌入页面请求接口获取这个系统的token,然后放到缓存中免登录
}
}
false);
(2)如果两个系统保存token字段相当怎么办?
例如
:主系统本地存储的token
叫:access_token
, iframe
嵌入的系统采用的token
也叫:access_token
这分为两种情况:(1)同源并且token字段相同 (2)不同源并且token字段相当
(1)同源并且token字段相同
这种情况同源+token
字段相同,根本不会出现需要登录的情况,因为同一个浏览器缓存都能拿到并且又是通用token
(2)不同源并且token字段相当
这种情况只有嵌入系统
和本地系统
两种情况它们并不会同时出现的,那么只要判断当前是那个情况就行,然后给对应的token
方案
:请求在拦截器那里判断当前请求来自那个系统的页面,然后给对应的token
例如
:两个系统都要传my_token
字段给后端,如果都放缓存就会覆盖,所以直接本地系统放到token1
缓存,嵌入系统放到token2
缓存,拦截器判断后如果本来系统页面 my_token=token1
,嵌入页面 my_token=token2
来源:juejin.cn/post/7350876924393209894
啊,富文本没做安全处理被XSS攻击了啊
前言
相信很多前端小伙伴项目中都用到了富文本,但你们有没有做防XSS
攻击处理?最近的项目由于比较紧急我也没有处理而是直接正常使用,但公司内部有专门的安全部门针对测试,然后测出来富文本被XSS
攻击了,而且危险级别为高。
啊这....,那我就去解决一下吧,顺便从XSS
和解决方案两个角度记录到下来毕竟好久没更新文章了。
先说说什么是XSS攻击?
简述:XSS
全称Cross-Site Scripting
也叫跨站脚本攻击,是最最最常见的网络安全漏洞,其实就是攻击者在受害者的浏览器中注入恶意脚本执行。这种攻击通常发生在 Web
应用程序未能正确过滤用户输入的情况下,导致恶意脚本被嵌入到合法的网页中。
执行后会产生窃取信息、篡改网页、和传播病毒与木马等危害,后果相当严重。
XSS
又有三大类
1、存储型 XSS即Stored XSS
恶意的脚本被放置在目标服务器上面,通过正常的网页请求返回给用户端执行。
例如 在观看某个私人博客评论中插入恶意脚本,当其他用户访问该页面时,脚本会执行危险操作。
2、反射型 XSS即Reflected XSS
恶意的脚本通过 URL
参数或一些输入的字段传递给目标的服务器,用户在正常请求时会返回并且执行。
例如 通过链接中的参数后面注入脚本,当用户点击此链接时,脚本就会在用户的浏览器中执行危险操作。
3、DOM 基于的 XSS即DOM-based XSS
恶意的脚本利用 DOM(Document Object Model)
操作来修改页面内容。
这种类型的 XSS
攻击不涉及服务器端的代码操作,仅仅是通过客户端插入 JavaScript
代码实现操作。
富文本就是属于第一种,把脚本藏在代码中存到数据库,然后用户获取时会执行。
富文本防XSS的方式?
网上一大堆不明不白的方法还有各种插件可以用,但其实自己转义一下就行,根本不需要复杂化。
当我们不做处理时传给后台的富文本数据是这样的。
上面带有标签,甚至有src
和script
之类的操作,在里面放一些脚本真的太简单了。
因此,我们创建富文本成功提交给后台的时候把各种<>/\
之类危险符号转义成指定的字符就能防止脚本了。
如下所示,方法参数value
就是要传递给后台的富文本内容。
export const getXssFilter = (value: string): string => {
// 定义一个对象来存储特殊字符及其对应的 HTML 实体
const htmlEntities = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
'\'': ''',
'\\': '\',
'|': '|',
';': ';',
'$': '$',
'%': '%',
'@': '@',
'(': '(',
')': ')',
'+': '+',
'\r': ' ',
'\n': ' ',
',': ',',
};
// 使用正则表达式替换所有特殊字符
let result = value.replace(/[&<>"'\\|;$%@()+,]/g, function (match) {
return htmlEntities[match] || match;
});
return result;
};
此时传给后台的富文本参数是这样的,把敏感符号全部转义。
但展现给用户看肯定要看正常的内容啊,这里就要把内容重新还原了,这步操作可以在前端完成,也可以在后端完成。
如果是前端完成可以用以下方法把获取到的数据进行转义。
// 还原特殊字符
export const setXssFilter = (input) => {
return input
.replace(/|/g, '|')
.replace(/&/g, '&')
.replace(/;/g, ';')
.replace(/$/g, '$')
.replace(/%/g, '%')
.replace(/@/g, '@')
.replace(/'/g, '\'')
.replace(/"/g, '"')
.replace(/\/g, '\\')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/(/g, '(')
.replace(/)/g, ')')
.replace(/+/g, '+')
.replace(/ /g, '\r')
.replace(/ /g, '\n')
.replace(/,/g, ',');
}
但是。。。。
上面只适合使用于纯富文本的场景,如果在普通文本的地方回显会依然触发危险脚本。如下所示
其实直接转义后不还原即可解决,但由于是富文本这种情况比较特殊情况,不还原就失去文本样式了,怎么办??
最终解决方案是对部分可能造成XSS
攻击的特殊字符和标签进行转义处理,例如:script、iframe
等。
示例代码
export const getXssFilter = (value: string): string => {
// 定义一个对象来存储特殊字符及其对应的 HTML 实体
const htmlEntities = {
'&': '&',
'\'': ''',
'\r': ' ',
'\n': ' ',
'script': 'script',
'iframe': 'iframe',
// 'img': 'img',
'object': 'ojst',
'embed': 'embed',
'on': 'on',
'javascript': 'javascript',
'expression': 'expresssion',
'video': 'video',
'audio': 'audio',
'svg': 'svg',
'background-image': 'background-image',
};
// 使用正则表达式替换所有特殊字符
let result = value.replace(/[&<>"'\\|;$%@()+,]/g, function (match) {
return htmlEntities[match] || match;
});
// 额外处理 `script`、`iframe`、`img` 等关键词
result = result.replace(/script|iframe|object|embed|on|javascript|expression|background-image/gi, function (match) {
return htmlEntities[match] || match;
});
return result;
};
效果只会对敏感部分转义
但这种方案不用还原转义,因为做的针对性限制。
小结
其实就是对特殊符号转换后还原的思路,相当的简单。
如果那里写的不好或者有更好的建议,欢迎大佬指点啦。
来源:juejin.cn/post/7415911762128404480
现在前端组长都是这样做 Code Review
前言
Code Review
是什么?
Code Review
通常也简称 CR
,中文意思就是 代码审查
一般来说 CR
只关心代码规范和代码逻辑,不关心业务
但是,如果CR
的人是组长,建议有时间还是看下与自己组内相关业务,能避免一些生产事故的发生
作为前端组长做 Code Review
有必要吗?
主要还是看公司业务情况吧,如果前端组长需求不多的情况,是可以做下CR
,能避免一些生产事故
- 锻炼自己的
CR
能力 - 看看别人的代码哪方面写的更好,学习总结
- 和同事交流,加深联系
- 你做了
CR
,晋升和面试,不就有东西吹了不是
那要怎么去做Code Review
呢?
可以从几个方面入手
- 项目架构规范
- 代码编写规范
- 代码逻辑、代码优化
- 业务需求
具体要怎么做呢?
传统的做法是PR
时查看,对于不合理的地方,打回并在PR
中备注原因或优化方案
每隔一段时间,和组员开一个简短的CR
分享会,把一些平时CR
过程中遇到的问题做下总结
当然,不要直接指出是谁写出的代码有问题,毕竟这不是目的,分享会的目的是交流学习
人工CR
需要很大的时间精力,与心智负担
随着 AI 的发展,我们可以借助一些 AI 来帮我们完成CR
接下来,我们来看下,vscode
中是怎么借助 AI 工具来 CR
的
安装插件 CodeGeex
新建一个项目
mkdir code-review
cd code-review
创建 test.js
并用 vscode 打开
cd .>test.js
code ./
编写下 test.js
function checkStatus() {
if (isLogin()) {
if (isVip()) {
if (isDoubleCheck()) {
done();
} else {
throw new Error("不要重复点击");
}
} else {
throw new Error("不是会员");
}
} else {
throw new Error("未登录");
}
}
这是连续嵌套的判断逻辑,要怎么优化呢?
侧边栏选择这个 AI 插件,选择我们需要CR
的代码
输入 codeRiview
,回车
我们来看下 AI 给出的建议
AI 给出的建议还是很不错的,我们可以通过更多的提示词,优化它给出的修改建议,这里就不过多赘述了
通常我们优化这种类型的代码,基本优化思路也是,前置校验逻辑,正常逻辑后置
除了CodeGeex
外,还有一些比较专业的 codeRiview
的 AI 工具
比如:CodeRabbit
那既然都有 AI 工具了,我们还需要自己去CR
吗?
还是有必要的,借助 AI 工具我们可以减少一些阅读大量代码环节,提高效率,减少 CR
的时间
但是仍然需要我们根据 AI 工具的建议进行改进,并且总结,有利于拓宽我们见识,从而写出更优质的代码
具体 CR 实践
判断逻辑优化
1. 深层对象判空
// 深层对象
if (
store.getters &&
store.getters.userInfo &&
store.getters.userInfo.menus
) {}
// 可以使用 可选链进行优化
if (store?.getters?.userInfo?.menus) {}
2. 空函数判断
优化之前
props.onChange && props.onChange(e)
支持 ES11 可选链写法,可这样优化,js 中需要这样,ts 因为有属性校验,可以不需要判断,当然也特殊情况
props?.onChange?.(e)
老项目,不支持 ES11 可以这样写
const NOOP = () => 8
const { onChange = NOOP } = props
onChange(e)
3. 复杂判断逻辑抽离成单独函数
// 复杂判断逻辑
function checkGameStatus() {
if (remaining === 0 ||
(remaining === 1 && remainingPlayers === 1) ||
remainingPlayers === 0) {
quitGame()
}
}
// 复杂判断逻辑抽离成单独函数,更方便阅读
function isGameOver() {
return (
remaining === 0 ||
(remaining === 1 && remainingPlayers === 1) ||
remainingPlayers === 0
);
}
function checkGameStatus() {
if (isGameOver()) {
quitGame();
}
}
4. 判断处理逻辑正确的梳理方式
// 判断逻辑不要嵌套太深
function checkStatus() {
if (isLogin()) {
if (isVip()) {
if (isDoubleCheck()) {
done();
} else {
throw new Error('不要重复点击');
}
} else {
throw new Error('不是会员');
}
} else {
throw new Error('未登录');
}
}
这个是不是很熟悉呀~
没错,这就是使用 AI 工具 CR
的代码片段
通常这种,为了处理特殊状况,所实现的判断逻辑,都可以采用 “异常逻辑前置,正常逻辑后置” 的方式进行梳理优化
// 将判断逻辑的异常逻辑提前,将正常逻辑后置
function checkStatus() {
if (!isLogin()) {
throw new Error('未登录');
}
if (!isVip()) {
throw new Error('不是会员');
}
if (!isDoubleCheck()) {
throw new Error('不要重复点击');
}
done();
}
函数传参优化
// 形参有非常多个
const getMyInfo = (
name,
age,
gender,
address,
phone,
email,
) => {
// ...
}
有时,形参有非常多个,这会造成什么问题呢?
- 传实参是的时候,不仅需要知道传入参数的个数,还得知道传入顺序
- 有些参数非必传,还要注意添加默认值,且编写的时候只能从形参的后面添加,很不方便
- 所以啊,那么多的形参,会有很大的心智负担
怎么优化呢?
// 行参封装成对象,对象函数内部解构
const getMyInfo = (options) => {
const { name, age, gender, address, phone, email } = options;
// ...
}
getMyInfo(
{
name: '张三',
age: 18,
gender: '男',
address: '北京',
phone: '123456789',
email: '123456789@qq.com'
}
)
你看这样是不是就清爽了很多了
命名注释优化
1. 避免魔法数字
// 魔法数字
if (state === 1 || state === 2) {
// ...
} else if (state === 3) {
// ...
}
咋一看,这 1、2、3 又是什么意思啊?这是判断啥的?
语义就很不明确,当然,你也可以在旁边写注释
更优雅的做法是,将魔法数字改用常量
这样,其他人一看到常量名大概就知道,判断的是啥了
// 魔法数字改用常量
const UNPUBLISHED = 1;
const PUBLISHED = 2;
const DELETED = 3;
if (state === UNPUBLISHED || state === PUBLISHED) {
// ...
} else if (state === DELETED) {
// ...
}
2. 注释别写只表面意思
注释的作用:提供代码没有提供的额外信息
// 无效注释
let id = 1 // id 赋值为 1
// 有效注释,写业务逻辑 what & why
let id = 1 // 赋值文章 id 为 1
3. 合理利用命名空间缩短属性前缀
// 过长命名前缀
class User {
userName;
userAge;
userPwd;
userLogin() { };
userRegister() { };
}
如果我们把前面的类里面,变量名、函数名前面的 user
去掉
似乎,也一样能理解变量和函数名称所代表的意思
代码却,清爽了不少
// 利用命名空间缩短属性前缀
class User {
name;
age;
pwd;
login() {};
register() {};
}
分支逻辑优化
什么是分支逻辑呢?
使用 if else、switch case ...
,这些都是分支逻辑
// switch case
const statusMap = (status: string) => {
switch(status) {
case 'success':
return 'SuccessFully'
case 'fail':
return 'failed'
case 'danger'
return 'dangerous'
case 'info'
return 'information'
case 'text'
return 'texts'
default:
return status
}
}
// if else
const statusMap = (status: string) => {
if(status === 'success') return 'SuccessFully'
else if (status === 'fail') return 'failed'
else if (status === 'danger') return 'dangerous'
else if (status === 'info') return 'information'
else if (status === 'text') return 'texts'
else return status
}
这些处理逻辑,我们可以采用 映射代替分支逻辑
// 使用映射进行优化
const STATUS_MAP = {
'success': 'Successfull',
'fail': 'failed',
'warn': 'warning',
'danger': 'dangerous',
'info': 'information',
'text': 'texts'
}
return STATUS_MAP[status] ?? status
【扩展】
??
是 TypeScript
中的 “空值合并操作符”
当前面的值为 null
或者 undefined
时,取后面的值
对象赋值优化
// 多个对像属性赋值
const setStyle = () => {
content.body.head_style.style.color = 'red'
content.body.head_style.style.background = 'yellow'
content.body.head_style.style.width = '100px'
content.body.head_style.style.height = '300px'
// ...
}
这样一个个赋值太麻烦了,全部放一起赋值不就行了
可能,有些同学就这样写
const setStyle = () => {
content.body.head_style.style = {
color: 'red',
background: 'yellow',
width: '100px',
height: '300px'
}
}
咋一看,好像没问题了呀?那 style
要是有其他属性呢,其他属性不就直接没了吗~
const setStyle = () => {
content.body.head_style.style = {
...content.body.head_style.style
color: 'red',
background: 'yellow',
width: '100px',
height: '300px'
}
}
采用展开运算符,将原属性插入,然后从后面覆盖新属性,这样原属性就不会丢了
隐式耦合优化
// 隐式耦合
function responseInterceptor(response) {
const token = response.headers.get("authorization");
if (token) {
localStorage.setItem('token', token);
}
}
function requestInterceptor(response) {
const token = localStorage.getItem('token');
if (token) {
response.headers.set("authorization", token);
}
}
这个上面两个函数有耦合的地方,但是不太明显
比如这样的情况,有一天,我不想在 responseInterceptor
函数中保存 token
到 localStorage
了
function responseInterceptor(response) {
const token = response.headers.get("authorization");
}
function requestInterceptor(response) {
const token = localStorage.getItem('token');
if (token) {
response.headers.set("authorization", token);
}
}
会发生什么?
localStorage.getItem('token')
一直拿不到数据,requestInterceptor
这个函数就报废了,没用了
函数 responseInterceptor
改动,影响到函数 requestInterceptor
了,隐式耦合了
怎么优化呢?
// 将隐式耦合的常数抽离成常量
const TOKEN_KEY = "authorization";
const TOKEN = 'token';
function responseInterceptor(response) {
const token = response.headers.get(TOKEN_KEY);
if (token) {
localStorage.setItem(TOKEN_KEY, token);
}
}
function requestInterceptor(response) {
const token = localStorage.getItem(TOKEN_KEY);
if (token) {
response.headers.set(TOKEN_KEY, token);
}
}
这样做有什么好处呢?比刚才好在哪里?
还是刚才的例子,我去掉了保存 localStorage.setItem(TOKEN_KEY, token)
我可以根据TOKEN_KEY
这个常量来查找还有哪些地方用到了这个 TOKEN_KEY
,从而进行修改,就不会出现冗余,或错误
不对啊,那我不用常量,用token
也可以查找啊,但你想想 token
这个词是不是得全局查找,其他地方也会出现token
查找起来比较费时间,有时可能还会改错了
用常量的话,全局查找出现重复的概率很小
而且如果你是用 ts 的话,window 下鼠标停在常量上,按 ALT
键就能看到使用到这个常量的地方了,非常方便
小结
codeRiview(代码审查)不仅对个人技能的成长有帮助,也对我们在升职加薪、面试有所裨益
CR
除了传统的方式外,也可以借助 AI 工具,来简化其中流程,提高效率
上述的优化案例,虽然优化方式不同,但是核心思想都是一样,都是为了代码 更简洁、更容易理解、更容易维护
当然了,优化方式还有很多,如果后期遇到了也会继续补充进来
来源:juejin.cn/post/7394792228215128098
简单的 Web 端实时日志实现
背景
cron service 在执行定时任务时,需要能够实时查看该任务的执行日志以确保程序正确的工作。为了能够在尽可能短的时间内实现该功能,我们需要一个足够简单的方案。
方案如何选择?
我相信大多数开发者第一个想到的就是 WebSocket ,然后是 HTTP/SSE 。WebSocket 在很多情况下是实至名归的万金油选择。但这这里他太重了,对服务端和客户端具有侵入性需要花费额外的时间来集成到项目中。
那么 SSE 呢,为什么不是他?
虽然 SSE 即轻量又实时,但 SSE 无法设置请求头。这导致无法使用缓存和通过请求头设置的 Token。而且作为长链接他还一直占用并发额度。
WebSocket
:❌
- 优势:
- 实时性高
- 不会占用 HTTP 并发额度
- 劣势:
- 复杂度较高,需要在客户端和服务器端都进行特殊的处理
- 消耗更多的服务器资源。
- 优势:
SSE(Server-Sent Events)
:❌
- 优势:
- 基于HTTP协议,不需要在服务端和客户端做额外的处理
- 实时性高
- 劣势:
- 无法设置请求头
- 占用 HTTP 并发额度
- 优势:
HTTP
:✅
- 优势:
- 简单易用,不需要在服务端和客户端做额外的处理。
- 支持的功能丰富,如缓存,压缩,认证等功能。
- 劣势:
- 实时性差,取决于轮询时间间隔。
- 每次HTTP请求都需要建立新的连接(仅针对 HTTP/0.x 而言)并可能阻塞同源的其他请求而导致性能问题。12
- HTTP/1.x 支持持久连接以避免每次请求都重新建立 TCP 连接,但数据通信是串行的。
- HTTP/2.x 支持持久连接且支持并行的数据通信。
- 优势:
以上列出的优缺点是仅对于本文所讨论的场景(即Web 端的实时日志)而言,这三种数据交互技术在其他场景中的优缺点不在本文讨论范围内。
实现
HTTP 轮询已经是老熟人了,不再做介绍。本文的着重于实时日志的实现及优化。
sequenceDiagram
participant 浏览器
participant 服务器
Note right of 浏览器: 首先,获取日志文件最后 X bytes 的内容
浏览器->>服务器: 最新的日志文件有多大?
服务器->>浏览器: 日志文件大小: Y bytes
浏览器->>服务器: 从 Y - X bytes 处返回内容
服务器->>浏览器: 日志文件大小: Y1 bytes, 日志内容: XXXX
loop 持续轮询获取最新的日志
浏览器->>服务器: 从 Y1 bytes 处返回内容
服务器->>浏览器: 日志文件大小: Y2 bytes, 日志内容: XXXX
end
上方是基本工作原理的流程图。
实现的关键点在于
- 前端如何知道日志文件当前的大小
- 服务端如何从指定位置获取日志文件内容
这两点都可以通过 HTTP Header 来解决。Content-Range HTTP 响应头会包含完整文件的大小,而 Range HTTP 请求头可以指示服务器返回指定位置的文件内容。
因此在服务端不需要额外的逻辑,仅通过 Web 端代码就可以实现实时日志功能。
代码实现
篇幅限制,代码不会处理异常情况
首先,根据上述流程图。我们需要获取日志文件的大小。
const res = await fetch(URL, {
method: "GET",
headers: { Range: "bytes=0-0" }
});
const contentRange = res.headers.get("Content-Range") || "";
const match = /bytes (\d+)-(\d+)\/(\d+)/.exec(contentRange);
// 日志文件大小
const total = Number.parseInt(match[3], 10);
Range: "bytes=0-0"
指定仅获取第一个字节的内容,以免较大的日志文件导致响应时间过长。
我们发起了一个 GET 请求并将 Range
请求头设置为 bytes=0-0
如果服务器能够正确处理 Range 请求头,响应中将包含一个 Content-Range
列其中将包含日志文件的完整大小,可以通过正则解析拿到。
现在我们已经拿到了日志文件的大小并存储在名为 total
的变量中。然后根据 total
获取到最后 10 KB 的日志内容。
const res = await fetch(url, {
method: "GET",
headers: {
Range: `bytes=${total - 1000 * 10}-`
}
});
const contentRange = res.headers.get("Content-Range") || "";
const match = /bytes (\d+)-(\d+)\/(\d+)/.exec(contentRange);
// 下一次请求的起始位置
const start = Number.parseInt(match[2], 10) + 1;
// 日志内容
const content = await res.text();
现在我们发起了一个 GET 请求并将 Range
请求头设置为 bytes=${total - 1000 * 10}-
以获取最后 10 KB 的日志内容。并且通过正则解析拿到了下一次请求的起始位置。
现在我们已经拿到了日志文件的大小和最后 10 KB 的日志内容。接下来就是持续轮询去获取最新的日志。轮询代码区别仅在于将 Range
标头设置为 bytes=${start}-
以便获取最新的日志。
const res = await fetch(url, {
method: "GET",
headers: {
Range: `bytes=${start}-`
}
});
const contentRange = res.headers.get("Content-Range") || "";
const match = /bytes (\d+)-(\d+)\/(\d+)/.exec(contentRange);
// 下一次请求的起始位置
start = Number.parseInt(match[2], 10) + 1;
// 日志内容
const content = await res.text();
以上,基本的功能已经实现了。日志内容保存在名为 content
的变量中。
优化
HTTP 轮询常因其高延迟为人诟病,我们可以通过指数退避的方式来尽可能的降低延时且不会显著的增加服务器负担。
指数退避是一种常见的网络重试策略,它会在每次重试时将等待时间乘以一个固定的倍数。这样做的好处是,当网络出现问题时,重试的时间间隔会逐渐增加,直到达到最大值。
一个简单的实现如下:
/**
* 使用指数退避策略获取日志.
*/
export class ExponentialBackoff {
private readonly base: number;
private readonly max: number;
private readonly factor: number;
private retries: number;
/**
* @param base 基础延迟时间 默认 1000ms (1秒)
* @param max 最大延迟时间 默认 60000ms (1分钟)
* @param factor 延迟时间增长因子 默认 2
*/
constructor(base: number = 1000, max: number = 60000, factor: number = 2) {
this.base = base;
this.max = max;
this.factor = factor;
this.retries = 0;
}
/**
* 获取下一次重试的延迟时间.
*/
next() {
const delay = Math.min(this.base * Math.pow(this.factor, this.retries), this.max);
this.retries++;
return delay;
}
/**
* 重置重试次数.
*/
reset() {
this.retries = 0;
}
}
值得一提的是带有 Range
标头的请求成功时会返回 206 Partial Content
状态码。而在请求的范围超出文件大小时会返回 416 Range Not Satisfiable
状态码。我们可以通过这两个状态码来判断请求是否成功。
成功时调用 reset
方法重置重试次数,失败时调用 next
方法获取下一次重试的延迟时间。
总结
即使再优秀的方案也不是银弹,真正合适的方案需要考虑的不仅仅是技术本身,还有业务场景,团队技术栈,团队技术水平等等。在选择技术方案时,我们需要权衡各种因素,而不是盲目的选择最流行的技术。
当我们责备现有的技术方案为何如此糟糕时,或许在那个时间点上,这个方案是最合适的。
感谢以下网友的帮助和建议:齐洛格德
Footnotes
来源:juejin.cn/post/7337519776796295177