看完前端各种风骚操作,我眼睛被亮瞎了!
一、实现一个快速评分组件
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库,竟能做这么多项目
大家好,我是一名前端工程师,也是开源图片编辑器vue-fabric-editor项目的作者,2024年5月从北京辞职,我便开始了自己的轻创业之路,接触了不同的客户和业务场景,回顾这半年,没想到学会fabric.js
这个Canvas
库,竟能做这么多项目。
如果你打算学习一个Canvas
库或者做图片设计、定制设计相关的工具,我建议你学习一下fabric.js
这个库,它非常强大,可以做出很多有意思的项目,希望我的项目经历能给你的技术选型做一些参考。
项目经历
从北京回老家邯郸后,我陆续做了很多项目,包括正件照设计、锦旗/铭牌定制工具、Shopify定制插件、批量生成图片、手机版图片设计工具、服装设计、电商工具等,这些项目都离不开fabric.js
这个库。回顾这段经历,让我深刻体会到它的强大和广泛应用。
图片设计
图片设计是我接触的第一个主要应用领域。项目最初源于一个小红书成语卡片设计工具的构想,随后逐步扩展到更广泛的设计场景,包括小红书封面、公众号头图、营销海报以及电商图片等多种自媒体内容制作。
这类应用的核心功能在于自定义画布尺寸和元素排版,得益于fabric.js的原生支持,实现起来相对简单。我们主要工作是开发直观的属性编辑面板,使用户能够便捷地调整所选元素的文字和图片属性。
当然如果做的完善一些,还需要历史记录
、标尺
、辅助线对齐
、快捷键
等,这些功能fabric.js
并没有包含,需要我们自己实现,这些功能可以参考vue-fabric-editor 项目,它已经实现了这些功能。
还有很多细节的功能,比如组合保存、字体特效、图层拖拽、图片滤镜等,这些功能我们做的比较完善了。
定制设计工具
图片设计的场景相对通用,没有太多定制化的需求。而定制类的设计工具则需要针对特定场景深度开发,比如正件照、锦旗/铭牌设计、相册设计等,每个场景有不同的定制功能。
正件照设计工具的核心在于自动化的处理。主要工作量集中在尺寸的匹配,确保图片能自动调整到最佳大小。同时,需要提供人物图片的裁剪功能,让用户能便捷地进行换装、切换正件尺寸、更换背景等操作。
锦旗与铭牌设计则更注重文字内容的自动排版。系统需要根据用户输入的抬头、落款、赠言等内容,自动计算最优的文字间距和整体布局,确保作品的美观性。特别是铭牌设计,还需要实现曲线文字功能,让文字能够优雅地沿着弧形排布。
相册设计工具的重点是提供灵活的画布裁剪功能。用户可以使用各种预设的形状模板来裁剪图片,需要确保裁剪后的图片既美观又协调,最终生成精美的画册作品,交互上方便用户拖拽图片快速放入裁剪区域。
电商工具
电商场景比图片设计更垂直,除了普通的平面设计,例如店铺装修、商品主图、详情图的设计,另外还需要对商品进行换尺寸、抠图、换背景、去水印、涂抹消除、超清放大等操作,这些对图片处理的要求更高一些。
批量生成
批量算是一个比较刚需的功能,比如电商的主图,很多需要根据不同产品到图片和价格来批量加边框和文字,以及节庆价格折扣等,来生成商品主图,结合图片和表格可以快速生成,减少设计师的重复工作量。
另一部分是偏打印的场景,比如批量制作一些商品的二维码条形码,用在超市价签、电子价签、一物一码、服装标签等场景,根据数据表格来批量生成。
这种项目主要的工作量在交互上,如何将画布中的文字和图片元素与表格中的数据一一对应,并批量生成,另外会有一些细节,比如条形码的尺寸、图片的尺寸如何与画布中的尺寸比例进行匹配,这些细节需要我们自己实现。
上边的方式是通过表格来批量生成图片,还有一种是根据 API来批量生成图片,很多场景其实没有编辑页面,只希望能够通过一个 API,传入模板和数据,直接生成图片,fabric.js 支持在nodejs 中使用,我们要做的就是根据模板和数据拼接 JSON,然后通过fabric.js 在后端生成图片,然后返回给前端,性能很好,实际测试 2 核 2G 的机器,每张图片在 100ms 左右。
很多营销内容和知识卡片、证书、奖状也可以通过批量生成图片API来实现。
当然,还有一些更复杂的场景,比如不同的数据匹配不同的模板,不同的组件展示不同的形式等,包括错别字检测、翻译等,我们也为客户做了很多定制化的匹配规则。
服装/商品定制
服装/商品定制是让用户在设计平台上上传图片,然后将图片贴图到对应的商品模板上,实现让用户快速预览设计效果的需求。
这种场景一般会分为 2 类,一类是是针对 C 端用户,需要的是简单、直观,能够让用户上传一张图片,简单调整一下位置就能确认效果快速下单。
我在这篇文章里做了详细介绍:《fabric.js 实现服装/商品定制预览效果》。
另一类是针对小 B 端的用户,他们对设计细节有更高的要求,比如领子、口袋、袖子等,不同的位置进行不同的元素贴图,最后将这些元素组合成一个完整的服装效果图,最后需要生成预览图片,在电商平台售卖,完成设计后,还要将不同区域的图片进行存储,提供给生产厂家,厂家快速进行生产。
比如抱枕、手机壳、T恤、卫衣、帽子、鞋子、包包等,都可以通过类似服装设计的功能来实现。
很多开发者会提出疑问,是否需要介入 3D 的开发呢?
我们也和很多客户沟通过,从业务的角度看,他回答是:3D 的运营成本太高。他们做的都是小商品,SKU 很多很杂,如果每上一个商品就要进行 3D 建模,周期长并且成本高,他们更希望的是通过 2D 的图片来实现,而且 2D 完全能够满足让用户快速预览确认效果的需求,所以 2D 的服装设计工具就成为了他们的首选。
包装设计
包装设计是让用户在设计平台上,上传自己的图片,然后将图片贴图都包装模板上,主要的场景是生成定制场景,比如纸箱、纸袋、纸盒、纸杯、纸质包装等,这些场景需要根据不同的尺寸、形状、材质、颜色等进行定制化设计,最后生成预览图片。
因为设计到不同的形状和切面,而且大部分是大批量定制生产,所以对细节比较谨慎,另外包装规格相对比较固定,所有用3D模型来实现就比较符合。
另外,在确定设计效果后,需要导出刀版图,提供给生产厂家,厂家根据刀版图进行生产,所以需要将设计图导出为刀版图,这个功能 fabric.js 也支持,可以导出为 SVG 格式直接生产使用。
AI结合
在AI 大火的阶段,就不得不提 AI 的场景了,无论在自媒体内容、电商、商品、服装设计的场景,都有 AI 介入的影子,举个例子,通过 AI生成内容来批量生成营销内容图片,通过 AI 来对电商图片进行换背景和图片翻译,通过 AI 生成印花图案来制作服装,通过 AI 来生成纹理图来生成纸盒包装,太多太多的 AI 的应用场景,也是客户真金白银定制开发的功能。
展望2025
从图片设计的场景来看,我们的产品已经很成熟了,也算是主力产品了,未来会持续迭代和优化,让体验更好,功能更强大,把细节做的更完善,例如支持打印、视频生成等功能。
从定制设计工具的场景来看,我们积累了不同商品定制设计的经验,从技术和产品到角度看,我们还可以抽象出更好的解决方案,让客户能够更高效、低成本的接入,提供给他们的客户使用,快速实现设计生产的打通。
2024 到 2025 ,从在家办公一个人轻创业,搬到了我们的办公室,期待未来越来创造更多价值。
总结
半年的时间,这些项目的需求fabric.js
都帮我们实现了,所以如果你对Canvas
感兴趣,我的亲身经历告诉你,学习fabric.js
是一个不错的选择。
另外,对我来说更重要的是,客户教会了我们很多业务知识,这些才是宝贵的业务知识和行业经验,一定要心存敬畏,保持空杯,只有这样我们才能做好在线设计工具解决方案。
这篇文章也算是我从 2024年离职出来到现在的一个年终总结了,希望我们踩过的坑和积累的经验都变成有价值的服务,作为基石在2025年服务更多客户,文章内容供大家一些参考,期待你的批评指正,一起成长,祝大家 2025年大展宏图。
给我们的开源项目一个Star吧:github.com/ikuaitu/vue… 😄。
来源:juejin.cn/post/7459286862839054373
Timesheet.js - 轻松打造炫酷时间表
Timesheet.js - 轻松打造炫酷时间表
前言
在现代网页设计中,时间表是一个常见的元素,用于展示项目进度、历史事件、个人经历等信息。
然而,创建一个既美观又功能强大的时间表并非易事。
幸运的是,Timesheet.js
这款神奇的 JavaScript
开源时间表库为我们提供了一个简洁而强大的解决方案。
本文将详细介绍 Timesheet.js
的特点、使用方法,并通过一个真实的使用案例来展示其强大功能。
介绍
Timesheet.js
是一个轻量级的 JavaScript
库,专门用于创建基于 HTML5
和 CSS3
的时间表。
它无需依赖任何外部框架,如 jQuery
或 Angular.js
,即可快速生成美观的时间表布局。
Timesheet.js
的优势在于其简洁性和用户友好性,仅需几行 JavaScript
代码即可实现功能,同时提供了丰富的自定义选项,允许开发者根据需求进行样式调整。
核心特性
无依赖:不依赖任何外部 JavaScript
框架,减少了项目复杂性和加载时间。
易于使用:通过简单的 JavaScript
代码即可创建时间表,易于上手。
高度可定制:提供了丰富的 CSS
类,方便开发者自定义时间表的外观。
响应式设计:支持移动设备,确保在不同屏幕尺寸上都能良好显示。
官方资源
官网:sbstjn.github.io/timesheet.j…
GitHub 仓库:github.com/sbstjn/time…
使用案例
假设我们要为一个在线教育平台创建一个展示学生学习历程的时间表。
这个时间表将展示学生从入学到毕业的各个阶段,包括参加的课程、获得的证书等信息。
步骤 1:引入库文件
首先,在 HTML
文件中引入 Timesheet.js
的 CSS
和 JavaScript
文件。
<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
前端同时联调多个后端
前言
最近公司项目有时需要和多个后端同时对接(😔小公司一对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
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端口
7. 测试是否达到效果
同样我们前端也起两个8000和8001
接下来看下8000端口请求
再看下8001请求
vite
结语
以上只写了webpack不过vite和这也差不多!!!
来源:juejin.cn/post/7456266020379541531
原来微信小游戏用的技术就是web, 有想法的直接可以做
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个算能跑的项目, 来说说开发的过程.
- 资源目录下新建一些文件夹: scripts, imgs, animation, scene.
- 主要开发就是编辑场景. 在场景里添加节点, 然后给节点贴图, 从"资源列表"把资源拖到"节点属性面板"就好了, 容易.
我的节点很简单, 就是玩家角色, 和背景.
- 建立个空节点, 写游戏逻辑. 具体操作是新建个 ts 文件, 然后拖到这个节点属性上.
- 游戏逻辑需要操作的内容, 包括动画, 都以"拖动"的方式关联到"节点属性面板"上.
这样就写好一个游戏了.
游戏逻辑开发是和 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点:
- 游戏的完整度, 需要美术和动画, 程序只能控制角色的位置, 加上动画才让人有操作角色的感觉. 精美的游戏场景也能让玩家觉得真实.
- 策划: 数值系统, 货币系统, 奖励系统, 活动这些. 让玩家重复玩同一个场景几百遍还觉得自己在成长, 真是牛逼.
来源:juejin.cn/post/7456805812045725734
海康摄像头 web 对接
真的烦躁,一个月拿着死工资,每天写着增删改查,不知道以后能做什么,有时候真的想离职,进广东电子厂....
这段时间,XXXX 要加一个海康监控,哎。
苦命开发
\webs\codebase 目录中有,第一个是插件,必须安装的, 后面两个JS文件是开发必要的。还要一个 JQ的,它内部使用了jq
初始化插件
引入了提供的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
我:偷偷告诉你,我们项目里的进度条,全都是假的!🤣 产品:???😲
扯皮
最近接到了一个需求:前端点击按钮触发某个任务并开启轮询获取任务进度,直至 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 文件里了:
需要注意的是我们看的是这个版本:rstacruz/nprogress at v0.2.0,master 分支与 npm 安装的 0.2.0 内部实现还是有些差别的
我们这里不关注它的样式相关计算,主要来看看对进度的控制,直奔 start 方法:
还是比较清晰的,这里的 status
就是内部维护的进度值,默认为 null,所以会执行 NProgress.set
,我们再来看看 set 方法:
set 方法里有一大堆设置动画样式逻辑都被我剪掉了,关于进度相关的只有这些。相当于利用 clamp 来做一个夹层,因为初始进来的 n 为 null,所以经过处理后进度变为 0.08
再回到 start 的逻辑,其中 work
就是内部轮询控制进度自增的方法了,初始配置 trickle
为 true 代表自动开启进度自增,由于进度条在 set 方法中已经设置为 0.08,所以走到后面的 NProgress.trickle
逻辑
看来这里就是进度控制的核心逻辑了, 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
方法,它就比较诡异了:
按理来说直接将进度设置为 1 就行,但它以链式调用 inc
再调用 set
,相当于调用了两次 set
而这里 inc
传参又没什么规律性,推测是为了 set
中的样式处理,感兴趣的可以去看看那部分逻辑,还挺多的...😶
一句话总结一下 NProgress 的进度计算逻辑:随机值自增,最大值限制
但是因为 NProgress 与进度条样式强绑定,我们肯定是没法直接用的
fake-progress
至于 fake-progress 则是我在调研期间直接搜关键词搜出来的😶:piercus/fake-progress: Fake a progress bar using an exponential progress function
很明显看介绍就是干这个事的,而且还十分专业,引入数学函数展示假进度条效果,具有说服力:
所以我们项目中其实就是用的这个包,只不过和 NProgress 类似,两个包都比较老了,瞟一眼源码发现都是老 ES5 了🤐
因为我们项目中用的是 React,这里给出 React 的 demo 吧,为了编写方便使用了几个 ahooks 里的 hook:
其实使用方法上与 NProgress 都类似,不过两者都属于通用的工具库不依赖其他框架,所以像视图渲染都需要自己手动来做
注意实例化中的传参 timeConstant
,某种意义上来讲这个值就相当于“进度增长的速率”,但也不完全等价,我们来看看源码
因为不涉及到样式,fake-progress 源码更简单,核心就在这里:
下方的数学公式就是介绍图中展示的,只能说刚看到这部分内容是真的是死去的数学只是突然又开始攻击我😅,写了那么多函数,数学函数是啥都快忘了
我们来简单分析一下这个函数 1 - Math.exp(-1 * x)
,exp(x)= , 的图像长这样,高中的时候见的太多了:
那假如这里改成 exp(-x) 呢?有那味了,以前应该是有一个类似的公式 与 图像效果是关于 y 轴对称,好像是有些特殊的不符合这个规律?🤔反正大部分都是满足的
OK,那我们继续进行转换,看看 -exp(-x) 的效果
同样有个公式 与 图像效果是关于 x 轴对称:
初见端倪,不知道你们有没有注意 -exp(-x) 最终呈现的图像是无限接近于 x 轴的,也就是 0:
那有了🤓,假如我再给它加个 1 呢?它不就无限接近于 1 了,即 -exp(-x) + 1,这其实就是 fake-progress 里公式的由来:
但你会发现如果 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
useFakeProgress
虽然我们现在项目中使用的是 fake-progress,但是个人感觉用起来十分鸡肋,而且上面的 bug 也需要自己手动兼容,因此萌生出自己封装一个 hook 的想法,让它更符合业务场景
首先我们确定一下进度计算方案,这里我毫不犹豫选择的是 NProgress 随机值增长方案,为什么?因为方便用户自定义
而且 NProgress 相比于 fake-progress 有一个巨大优势:手动 set 进度后仍然保持进度正常自动递增
这点在 fake-progress 中实现是比较困难的,因为你无法保证手动 set 的进度是在这个函数曲线上,相当于给出函数 y 值反推 x 值,根据反推的 x 值再进行递增,想想都麻烦
确定好方案后我们来看下入参吧,参考 NProgress 我定义了这几个配置项:
这里我简单解释一下 rerender 和 amount 配置:
实际上在封装这个 hook 的时候我一直在纠结这里的 progress 到底是 state 还是 ref,因为大多数场景下 hook 内部通过轮询定时器更新进度,而真实业务代码中也会开启定时器去轮询监听业务接口的
所以如果写死为 state,那这个场景 hook 内部的每次更新 render 是没必要的,但是假如用户又想只是使用假进度展示,没有后端业务接口呢?
思来想去其实完全可以放权给用户进行配置,因为 state = ref + update,统一使用 ref,用户配置 rerender 时我们在每次更新时 update 即可
至于 amount 我是希望放权给用户进行自定义递增值,你可以配置成一个固定值也可以配置成随机值,更可以像 NProgress master 分支下这样根据当前进度来控制自增,反正以函数参数的形式能够拿到当前的 progress:
至于实现细节就不再讲述了,实际上就是轮询定时器没什么复杂的东西,直接上源码了:
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 看看效果,感觉还可以:
当然由于直接返回了 ref,为了防止用户篡改可以再上一层代理劫持,我们就省略了
这也算一个工具偏业务的 hook,可以根据自己的业务来进行定制,这里很多细节都没有补充只是一个示例罢了🤪
End
以上就是这篇文章的内容,记得上班之前还在想哪有那么多业务场景需要封装自定义 hook,现在发现真的是各种奇葩需求都可以封装,也算是丰富自己武器库了...
来源:juejin.cn/post/7449307011710894080
2025年,前端开发为什么一定要学习Rust?
引言
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语言是一门现代系统编程语言,由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++ 统治。虽然这两种语言在性能方面表现出色,但它们也存在一些固有的缺陷,促使了 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。
- 保证安全、内存占用小的同时能提供和C/C++ 一样的性能
- 广泛的适用性,系统编程、网络服务、命令行工具、WebAssembly等场景都能应用
- 生态渐渐完善,有大量的库和框架,完整的工程化开发工具链,强大的包管理
- 社区非常活跃和友好,文档全面
Rust不是闭门造车的语言,能看出来设计者是做过大量不同语言的项目的人,Rust从解决实际问题出发,借鉴和融合其他语言的优点,又能够创新地提出所有权和生命周期,这个强大的能力带来了0开销的内存安全和线程安全。
凡事有利必有弊,Rust是站在前人的肩膀上,它集百家之长,借鉴了其他语言的许多优秀特性,如npm的包管理、go的channel来进行并发通信、Haskell的trait(类似java的接口),还有元组、泛型、枚举,闭包、智能指针这些特性并非rust原创,但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 只能保证变量引用不可变,但如果引用的是对象或数组,内容依然可以改变。可谓是相当鸡肋。
- 静态类型检查:
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
来清除引用,但这个操作需要手动执行,容易忘记。
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则强制你一定要考虑内存安全,否则编译都不过。
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去开发出健壮又高性能的代码是很难的,这可能也是为什么前端框架和库百花齐放而又前仆后继的原因。
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 的摧残。
Rust没有传统意义上的异常机制。在许多编程语言中,错误通常会通过运行时抛出异常来传递,而Rust采用了一种完全不同的方式来处理错误。
Rust通过Result类型和Option类型来明确地处理错误和空值,Rust的错误处理是编译时检查的,必须显式处理Result或Option,如果忽略了错误处理,编译器会报错,确保错误处理不被遗漏。这种做法可以避免程序出现未处理的异常,增强程序的健壮性。
Result 类型:Rust使用Result
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): 抽象的意义在于提供新的工具,由底层组件组装而成,让开发者更容易写出他们想要的代码,提高开发效率和代码可读性。
举几个例子
- Rust 的所有权系统(ownership system)和生命周期(lifetimes)。
当你写一个简单的程序,没有使用所有权系统的特性时,编译器会对这些特性进行优化,使得它们对程序的性能没有任何影响。只有当你使用这些特性时,编译器才会引入相关的检查和优化。
- 迭代器(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);
}
1. 零成本抽象
零成本抽象是指使用高级编程语言的抽象(如函数式编程的高阶函数、泛型、闭包等)时,不会引入额外的性能开销或运行时成本。换句话说,编写高抽象层的代码并不会影响程序的性能,编译器能够将抽象代码转化为与低级代码相同的高效机器码。
在 Rust 中,“零成本抽象”特别重要,因为 Rust 旨在提供与 C 和 C++ 等低级语言相似的性能,同时保持高层次的代码抽象和安全性。通过静态分析和优化,Rust 能够在编译时消除大多数抽象层的开销。
零成本抽象的三个原则:
- 没有全局成本(No global cost): 一个零成本抽象不应该对不使用该功能的程序的性能产生负面影响。
换句话说,零成本抽象应该只在使用时产生影响,在未使用时不会引入任何额外的开销。
- 最佳性能(Optimal performance): 一个零成本的抽象应该编译成相当于底层指令编写的最佳实现。意味着它在使用时会以尽可能接近底层代码的方式运行,即它的性能应当与手写的低级实现相当。
可以理解为,如果你想要用rust抽象某个高级能力,那么抽象完成的性能不能比用更原始写法实现的性能差,如果你想要抽象前端框架,那么就不能比直接操作DOM的JS原生写法性能差。
- 改善开发者体验(Improves developer experience): 抽象的意义在于提供新的工具,由底层组件组装而成,让开发者更容易写出他们想要的代码,提高开发效率和代码可读性。
举几个例子
- Rust 的所有权系统(ownership system)和生命周期(lifetimes)。
当你写一个简单的程序,没有使用所有权系统的特性时,编译器会对这些特性进行优化,使得它们对程序的性能没有任何影响。只有当你使用这些特性时,编译器才会引入相关的检查和优化。
- 迭代器(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引擎实际上也是做了很多的优化的,这点不用太过于担心,放心用就好了。
- 泛型(Generics) Rust 中的泛型是一个典型的零成本抽象。
使用泛型时,Rust 在编译时会根据类型替换(monomorphization单态化)生成具体的代码,在运行时没有任何额外的开销。
fn add(a: T, b: T) -> T
where
T: std::ops::Add
在这个例子中,add 函数是一个泛型函数。Rust 编译器在编译时会为每种特定类型(例如 i32、f64 等)生成专门的机器码。
如果你调用 add(1, 2),Rust 会为 i32 类型生成专门的代码。同样,如果你调用 add(1.5, 2.5),Rust 会为 f64 类型生成专门的代码。泛型不会引入运行时的性能损失。编译后的代码与手写的具体类型版本在性能上是等价的。
- 错误处理
包括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的软件。
来源:juejin.cn/post/7450021642377199643
从前端的角度出发,目前最具性价比的全栈路线是啥❓❓❓
今年大部分时间都是在编码上和写文章上,但是也不知道自己都学到了啥,那就写篇文章来盘点一下目前的技术栈吧,也作为下一年的参考目标,方便知道每一年都学了些啥。
我的技术栈
首先我先来对整体的技术做一个简单的介绍吧,然后后面再对当前的一些技术进行细分吧。
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 了。
全栈性价比最高的一套技术
最近刷到一个帖子,讲到了
我目前也算是一个小全栈了吧,我也来分享一下我的技术吧:
- NextJs
- React Native
- prisma
- NestJs
- taro (目前还不会,如果有需求就会去学)
剩下的描述也是和他下面那句话一样了(毕业后对技术态度的转变就是什么能让我投入最小,让我最快赚到钱的就是好技术)
总结
学无止境,任重道远。
来源:juejin.cn/post/7451483063568154639
App出现技术问题,这样的中国电信让用户糟心了
前言
最近在中国电信app上销户一张中国电信山西区的电话卡,一打开销户界面我就惊了
点开一看,写入的本地变量、cookie一览无遗。
查看数据
存在采集用户手机型号、来源等数据行为产生的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"
}
简单解释一下各种参数
很显然 ,这是一个用户行为追踪工具,记录你从那个平台点进来(广告投放)、你手机是啥样的(用户画像)、你搜索了什么。可能还会有你住哪里之类的数据
- 博主一下懵了,之前常听人说大数据时代没有什么秘密,还不以为然。今日一遇还真是阿
登录状态与服务取消cookie
SXH5_CANCEL_SERVICE_LOGINSTATUS=SXH5_CANCEL_SERVICE_a2d95a5a764d4744b1eb1e468b583287
SXH5_CANCEL_SERVICE_LOGINTYPE=fmknjikneolbnhclejikfnbggkmnookc
- 这两没什么好说的,看不出来啥
查看持久化数据
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 项
- 更多就不继续探索了,这里的填写号码获取验证码控制台正常输出。。。。
问题可能产生原因
- 首先,得知道中国电信app现在的模式是怎样的。
- 据博主个人观察,电信app页面虽然都一样,但每个地区各自为营。如果你使用福州的电信手机卡登录,那么是跳转到福州电信负责的页面,如下图
- 所以本次app出现问题,是山西电信没有处理好app端
- 山西电信微信小程序是没什么问题的,而他的app没有做好对接,要么版本不一样……
吐槽
- 我有个朋友之前注销电信流量卡时,被告知要去号码归属地才能销户。。这归属地离他十万八千里,过去就为了销卡显然不划算。于是他去工信部12300(微信公众号 现改名为 电信用户投诉 )投诉才成功线上销卡。
- 电信现在按省来处理业务,如果你电信卡丢了且忘记卡号、归属地,那只能通过线上投诉才能得知自己卡号、归属地,不然各省是无权查别省号码。
- 我线上销户时,客服A要求先交40元月租才能销户,但这张卡我从未使用,为何会产生月租?联系客服B后,他让我提供身-份-证照片、委托书及手持委托书照片,最终未交钱完成注销。但不同客服的说法不一,且身份信息完全暴露给客服,让人不安。
来源:juejin.cn/post/7456898384352362522
和后端大战三百回合后,卑微前端还是选择了自己写excel导出
前言
对于一个sass项目,或者一个中后台项目来说,导出excel表格
应该是家常便饭,本来最简单的实现方式是后端去做表格,前端请求接口拿到一个地址下载就行了。但是,因为我们这个项目之前就是前端做表格,加上这个表格相对比较复杂需要合并行和列,后端不会加上又有别的项目堆积没有时间研究,所以就是后端提供数据,前端来做表格。
那里复杂
可以看到,有二级标题,还有行的合并,如果仅仅是二级标题,倒是可以直接写死,但是行的合并是根据数据去计算该合并那些行的
,再比如后面如果有三级标题,四级标题的需求呢?那不是又寄了,所以我选择将这个封装成一个方法,当然也是在网上找大佬们的解决方案实现的,东抄抄西抄抄就实现了我想要的功能。
传参
既然封装成一个方法,最好就是传入数据,表头,文件名后,就能自动下载一个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…
所以封装这个函数,主要流程也是和这个一样的,只不过我们要做的时候,将传入的参数处理成我们想要的二维数组,以及在这基础做一些合并,样式的操作,下面介绍了一些属性的作用,具体大家还是需要去官网查看的。
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个版本
犹抱琵琶半遮面,千呼万唤始出来,pnpm v10
终于正式发布了。
众所周知,笔者有关注行业技术最新进展的爱好,这次的 pnpm v10
版本,也已经跟踪了好几个月了。
而这次,v10
正式版终于发布了。
版本 | 时间 |
---|---|
pnpm 10.0 | 2025年01月08日 |
pnpm 10.0 RC 3 | 2025年01月05日 |
pnpm 10.0 RC 2 | 2024年12月29日 |
pnpm 10.0 RC 1 | 2024年12月27日 |
pnpm 10.0 RC 0 | 2024年12月16日 |
pnpm 10.0 Beta 3 | 2024年12月12日 |
pnpm 10.0 Beta 2 | 2024年12月09日 |
pnpm 10.0 Beta 1 | 2024年11月29日 |
pnpm 10.0 Alpha 4 | 2024年11月25日 |
pnpm 10.0 Alpha 3 | 2024年11月25日 |
pnpm 10.0 Alpha 2 | 2024年11月15日 |
pnpm 10.0 Alpha 1 | 2024年11月15日 |
pnpm 10.0 Alpha 0 | 2024年10月08日 |
以上是笔者整理的 pnpm v10
发布过程,从 草案
,到 测试版
,到 候选版
,再到最后的 正式版
,可谓是花了不少功夫啊。
也从侧面说明了,pnpm 团队对这次 v10
版本的重视程度,必然是有大事发生,那么话不多说,我们看看本次的更新内容吧。
依赖项的生命周期脚本不会在安装期间执行
这是一个重要变化,依赖包的 生命周期脚本
不会自动执行了。
那么问题来了,可能有些读者还不知道什么是 生命周期脚本
?生命周期脚本
英文名叫做 Lifecycle scripts
。
包括以下几种:
- 安装相关脚本
preinstall
:在安装软件包之前执行。install
:在安装软件包时执行。postinstall
:在安装软件包之后执行。
- 发布相关脚本
prepare
:在发布软件包之前执行。prepublishOnly
:只在 npm publish 时执行。prepack
:在打包软件包之前执行。postpack
:在打包软件包之后执行。
- 运行相关脚本
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 个:
- 替换已安装的软件包
- 当你正在开发一个依赖包,想在另一个项目中测试它时,可以使用
pnpm link
将本地版本链接到目标项目。 - 这样可以避免频繁地发布和安装依赖包,提高开发效率。
- 当你正在开发一个依赖包,想在另一个项目中测试它时,可以使用
- 添加全局可用的二进制文件
- 如果你开发了一个包含命令行工具的软件包,可以使用
pnpm link
将其二进制文件注册到全局,以便在任何地方都可以执行。 - 这对于开发 CLI 工具非常有用。
- 如果你开发了一个包含命令行工具的软件包,可以使用
那么这次的主要变化有 2 个。
- 通过
pnpm link
默认创建的是全局包,在之前,则需要pnpm link -g
才可以创建全局包。 - 在
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.yaml
的packageExtensionsChecksum
字段中存储的哈希现在为 SHA256。- 副作用缓存密钥现在使用 SHA256。
- 锁定文件中的
pnpmfile
校验和现在使用 SHA256。
配置更新
manage-package-manager-versions
:默认启用。pnpm 现在默认根据package.json
中的packageManager
字段管理自己的版本。public-hoist-pattern
:默认情况下不会提升任何内容。名称中包含eslint
或prettier
的包不再提升到node_modules
的根目录。- 已将
@yarnpkg/extensions
升级至v2.0.3
,这可能会改变您的pnpm-lock
文件。 virtual-store-dir-max-length
:Windows 上的默认值已减少到 60 个字符。- 减少脚本的环境变量:在脚本执行期间,会设置较少的
npm_package_*
环境变量。仅保留name
、version
、bin
、engines
和config
。 - 即使
NODE_ENV=production
,所有依赖项现在都会安装。
从现在开始,NODE_ENV=production
也会安装所有依赖,包括开发依赖,这对于像我这样的强迫症来说,有点难以接受,没有用到的依赖我为啥要安装?
查看官方文档,可以通过 pnpm add --prod
来只安装 dependencies
依赖。
全局存储更新
- 全局
store
升级到v10
。 - 一些注册表允许使用不同的软件包名称或版本发布相同的内容。为了适应这种情况,商店中的索引文件现在使用内容哈希和软件包标识符来存储。
- 验证锁文件中的完整性是否与正确的包相对应,在 Git 冲突解决不佳后可能并非如此。
- 允许相同的内容被不同的包或者同一个包的不同版本引用。
- 更高效的副作用索引。存储中的索引文件结构已更改。现在通过仅列出文件差异而不是所有文件,可以更有效地跟踪副作用。
- 新的索引目录存储了包内容映射。以前,这些文件位于文件中。
其他重大变化
#
字符现在在node_modules/.pnpm
内的目录名称中被转义。- 运行
pnpm add --global pnpm
或pnpm add --global @pnpm/exe
现在会失败并出现错误消息,指导您改用 pnpm 自我更新。 - 通过 URL 添加的依赖项现在在锁文件中记录最终解析的 URL,确保完全捕获任何重定向。
pnpm deploy
命令现在仅适用于具有inject-workspace-packages=true
的工作区。引入此限制是为了让我们能够使用工作区锁定文件为已部署的项目创建适当的锁定文件。- 删除了从
lockfile v6
到v9
的转换。如果您需要v6
到v9
的转换,请使用pnpm CLI v9
。 pnpm test
现在将 test 关键字后的所有参数直接传递给底层脚本。这与pnpm run test
的行为一致。以前您需要使用--
前缀。pnpm deploy
现在尝试从共享锁文件创建专用锁文件以进行部署。如果没有共享锁文件或force-legacy-deploy
设置为true
,它将回退到没有锁文件的部署。
次要变化
添加了对一种名为 configurational dependencie
的新依赖项类型的支持
这些依赖项在所有其他类型的依赖项之前安装 (在 dependencies
、devDependencies
、optionalDependencies
之前)。
配置依赖项不能具有其自身或生命周期脚本的依赖项,应使用精确版本和完整性校验和添加它们。
示例:
{
"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
我不允许还有人不知道前端实现时刻洪水模拟的方法!🔥
二维水动力 HydroDynamic2D
二维水动力介绍
二维水动力模型对象 HydroDynamic2D,基于真实数据驱动生成水动力模型(根据不同时刻下每个网格的流向、流速、高程、水位)
二维水动力模型考虑了水流在平面上的变化,适用于河道弯曲、水流方向多变的情况。这种模型能够更准确地反映水流在平面上的分布情况,适用于需要精确模拟水流动态的场景,如城市排水系统设计、洪水模拟。二维模型的优势在于能够提供更详细的水流信息,但计算复杂度较高,需要更多的计算资源和时间。

本篇文章主要介绍在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是网格的高程值,单位是米

- 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
文件,如下图所示
添加方法
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
文件中的水深字段进行配色

仅需要把add()
中的displayMode
参数设置为1热力样式,再通过valueRange
、colors
进行热力样式的调整
- 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示例,感受一下水动力的效果与参数调用。
- **下载资源:**下载百度网盘数据资源
- 替换资源:把
【文件资源】@path
的文件放到cloud文件资源路径下 - **启动cloud:**cloud启动demo工程
- 替换sdk:
【示例代码】code\lib\aircity
中的ac.min.js
与ac.min.js
,替换为cloud右上角"sdk"路径的对应文件 - **运行:**双击运行
示例代码】code\二维水动力.html
代码里的shpFilePath
、shpDataFilePath
路径得和第2步中一致

.dat 数据转换?
在数据源中,网格对应的水深、流速、流向数据,大家获取到可能不是标准的dat
数据,有可能是json、csv甚至是excel数据。所以这里教大家如何把常见的数据转为dat
二进制文件!
大象进冰箱需要三步,咱们转数据也需要三步
- 解析数据:读取文件,把不同数据源中的
id,h,u,v
(网格id、水深、流速流向u、流速流向v)提取出来。 - 转为二进制数据:把
id,h,u,v
转化为二进制的格式。 - 文件创建并写入:把二进制的格式数据保存为
.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
- 解析数据:使用
require('./data.json')
同步地引入并解析JSON数据文件,将其内容存储在jsonData
变量中。 - 转为二进制数据:
pamarToBuffer
函数将id
、h
、u
、v
转换为小端字节序的二进制Buffer。 - 文件创建并写入:遍历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
- 解析数据:使用
json.load(f)
方法从打开的JSON文件对象f
中读取并解析数据,将JSON格式的数据转换为Python的字典或列表结构,存储在变量json_data
中。 - 转为二进制数据:使用
struct.pack('=iddd', id, h, u, v)
方法将这些数据按照指定的格式(=
表示本地字节顺序,i
表示整数,d
表示双精度浮点数)打包成二进制数据。 - 文件创建并写入:使用
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文件。
- 解析数据:使用
ObjectMapper
从data.json
文件中读取JSON数据,并解析为TimePoint
对象的列表。 - 转为二进制数据:
convertToBytes
方法将TimePointData
对象的id
、h
、u
、v
字段转换为小端字节序的字节数组。 - 文件创建并写入:遍历
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时,样式就只需要控制waterMode
、waterColor
设置水体样式
- waterMode (number) 水面显示模型,枚举类型 0 水动画模式 ,1水仿真模式,2 水流向模式
- waterColor (Color) 水体颜色和透明度,注意:仅在displayMode=0时生效
- 当
displayMode
为1时,样式就需要通过valueRange
、colors
控制热力样式
- 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
- 格式同上方的显示样式参数
- arrowColors (object)箭头颜色调色板 仅在arrowDisplayMode=1时生效,河道箭头热力样式下的调色板配色对象,包含颜色渐变控制、无效像素颜色和调色板区间数组
- 当
- arrowAlphaMode (number) 箭头透明度模式,仅在arrowDisplayMode=0时生效,取值:[0,1],0使用arrowColor的透明度,1使用调色板的透明度
- arrowTiling (number) 箭头平铺系数 值越小则箭头越小越密集,反之则更大更疏松

水面效果参数
- foamWidth (number) 泡沫宽度取值范围:[0~10000],默认值:1米
- foamIntensity (number) 泡沫强度 取值范围:[0~1],默认值:0.5
- speedFactor (number) 速度因子

- flowThreshold (array) 水浪效果漫延的范围 即把水动力模型[minSpeed,maxSpeed],最小最大流速的范围映射到[0~~1],取值示例:[0.1,0.4],取值范围[0-1]

- rippleDensity (number)水波纹辐射强度

- rippleTiling (number) 水波纹辐射平铺系数
以上就是本篇文章的所有内容,相信大家看完这篇文章后可以轻松的通过DTS实现二维水动力效果。
在DTS中还有各式各样的水分析相关接口,如FloodFill 水淹分析、Fluid 流体仿真对象、HydroDynamic1D 一维水动力、WaterFlowField 水流场,大家可以根据自身需求选择,这里给大家推荐一篇《开闸放水》的教程,后续也会陆续推出更多教程~
来源:juejin.cn/post/7452181029994971147
神了,Chrome 这个记录器简直是开发测试提效神器🚀🚀🚀
在开发工作中,你是否遇到过这样的场景:
当你需要开发某个功能时,这个功能依赖一系列的点击或者选择操作,才能获取到最终的数据。而在开发和调试的过程中,你往往需要多次验证流程的正确性。早期的时候,这种验证通常非常繁琐——你可能需要反复提交表单、重新执行操作流程,才能完成一次完整的自测。
如今,这一切变得更加高效了。
现在,我们可以使用记录器(Recorder)
来优化这一开发流程。这个工具允许你将整个操作过程录制下来,保存为一个可复现的操作记录。每次需要重新验证或提交流程时,只需一键执行这条记录,便能完成所有的重复性操作。
更棒的是,这个功能还支持二次编辑。如果你需要在某个步骤后面新增额外的操作,或者减少不必要的步骤,都可以轻松修改操作记录,而无需重新录制整个流程。
文章同步在公众号:萌萌哒草头将军,欢迎关注哦~
🚀 功能亮点与用途
1. 高效的开发与调试
对于开发者来说,这个功能不仅可以节省大量时间,还能确保操作流程的准确性,避免因手动操作而导致的遗漏或错误。
2. 性能监控的得力助手
谷歌推出这个功能的主要目的是为了帮助开发者更方便地监听用户在某些操作流程中的性能体验。例如,在查看录制的操作流程时,你可以直接点击某个步骤,跳转到性能面板(Performance Panel),并且工具会自动锁定当前帧的数据。这种体验优化相比以往手动查找性能问题,提升了不少效率。
3. 测试自动化的天然工具
如果你是一名测试人员,这个功能同样非常实用。操作流程录制完成后,你可以直接将其导出为Puppeteer脚本,方便地将其集成到你的自动化测试中,进一步提升测试的覆盖率和效率。
🚀 使用方法
我们以表单提交为例子展示
以下是如何使用记录器功能的步骤:
1. 💎 打开记录器
并点击创建新录制按钮
2. 💎 开始录制流程
可以重命名下,方便后续复用,然后点击最下方的开始录制按钮
我们在填写完表单,并且点击 sumbit
按钮,然后点击控制台的结束录制按钮,可以看到我们的每个步骤都被记录下来
3. 💎 执行录制
- 在
记录器
面板中,点击播放
按钮,浏览器会自动按照录制的流程重新执行操作。 - 你可以在执行过程中观察页面行为,确认流程是否正确。
- 如果遇到下面的情况,说明是超时了,需要设置下超时时间
点击这个地方展开就可以重新设置超时限制参数了
然后你点击播放按钮就一切正常了
4. 💎 查看和编辑录制
你可以在 记录器
面板中,看到录制的每个步骤,包含操作类型(如点击、输入、导航等)和目标元素。
你也可以点击每个步骤进行详细查看,也可以通过右键菜单进行编辑,例如增加新步骤、删除步骤或修改操作。
🚀 应用场景
1. 💎 表单提交及验证
录制复杂的表单提交流程,方便反复验证数据的提交逻辑是否正确。这个场景如上,相信你也感受它的便利性了。
2. 💎 性能优化
在模拟用户真实操作的同时,快速捕捉性能瓶颈,定位问题并优化。
点击性能面板按钮,等待自动回填数据,然后跳到性能面板,为了压缩我把很多帧去掉了
最终你可以在如下的性能面板开始分析了
3. 💎 自动化测试开发
如果需要将录制的流程用于自动化测试,可以点击 导出
按钮,将其导出为 Puppeteer
脚本或者 json
数据,这样可以减少编写测试脚本的时间,通过导出的 Puppeteer
脚本直接复用操作流程。我不是测试人员就不多赘述了。
🚀 小结
“记录器”
功能的出现,不仅让开发和调试更加高效,还为性能监控和测试自动化提供了重要支持。它减少了重复操作的浪费,让开发者和测试人员都能将更多精力集中在核心工作上。
是不是觉得这个功能非常有趣又实用?赶紧试试看吧!
如果有用记得关注我的公众号:萌萌哒草头将军
来源:juejin.cn/post/7447456628284244005
程序员就得会偷懒,重写了一个electron小工具,解放美女运营老师!
前言
之前只是写了一个脚本,本地运行,通过读取文件流获取文件数据,格式化对应数据,运营老师也不会安装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
页面效果
后续还会继续更新~
来源:juejin.cn/post/7445289957893259327
antd 对 ai 下手了!Vue 开发者表示羡慕!
前端开发者应该对 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项目被打崩后,连夜做限流!
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时令牌是共享的,而集群模式下每个服务是一个实例,每个实例有自己的内存空间,所以你要适当地考虑使用内存限流器时每个实例的限流速率。
注意事项
- 基于内存的限流器更适用于单机限流的场景,集群或分布式部署时,如果你不能计算出每一个实例的合适限流配置的话推荐使用基于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 '';
}
}
来源:juejin.cn/post/7454095190379888666
百亿补贴为什么用 H5?H5 未来会如何发展?
23 年 11 月末,拼多多市值超过了阿里。我想写一篇《百亿补贴为什么用 H5》,没有动笔;24 年新年,我想写一篇《新的一年,H5 会如何发展》,也没有动笔。
眼看着灵感烂在手里,我决定把两篇文章合为一篇,与你分享。提前说明,我并非百亿补贴的开发人员,本文的内容是我的推理。
拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的 H5、小程序武学。
我的证据
在 Android 开发者模式下,开启显示布局边界,你可以看到「百亿补贴」是一个完整大框,这说明「百亿补贴」在 App 内是 H5。拷贝分享链接,在浏览器打开,可以看到资源中有 React,说明「百亿补贴」技术栈是 React。
不只是拼多多,利用同样的方法,你可以发现京东、淘宝的「百亿补贴」技术栈也是 H5。
那么,为什么电商巨头会选择做「百亿补贴」时会选择 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 女神节链接、新人链接和品牌链接:
可以想到,几乎每到一个节日、每换一个品牌,「百亿补贴」就需要更新一次。
这样频繁的迭代,框架必须满足快速开发、快速部署、一次开发多端复用条件。因此可以排除掉 Native 技术栈,留下动态化技术栈。
原因二:百亿补贴需要投放小程序和其他 App
如图所示,你可以在微信上搜索拼多多,可以看到百亿补贴不仅在 App 上投放,还在微信小程序里投放。
此时我们几乎可以排除掉 React Native 和 Flutter 技术栈。因为社区虽然有方案让 React Native、Flutter 适配小程序,但并不成熟,不适合用到生产项目中。
此外,如果你在抖音、B 站和小红书搜索百亿补贴,你可以看到百亿补贴在这些 App 上都有投放广告。
这点可以完全排除 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,尽管它们结构都差不多,但长得是千奇百怪。
我估计未来,电商活动 H5 的外观将变得极具个性,各位前端同学可以在卷卷 CSS、动效方向。
总结
本文介绍了我认为「百亿补贴」会选用 H5 的 3 大原因:
- 百亿补贴迭代频繁
- 百亿补贴需要投放小程序、其他 App
- 百亿补贴核心流量是自己的 App
以及我 H5 未来发展趋势的 2 个预测:
- 离线包、SSR 比例增加
- 定制化要求苛刻
拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的 H5、小程序武学。
来源:juejin.cn/post/7344325496983732250
pnpm 的崛起:如何降维打击 npm 和 yarn🫡
今天研究了一下 pnpm
的机制,发现它确实很强大,甚至可以说对 yarn
和 npm
形成了降维打击
我们从包管理工具的发展历史,一起看下到底好在哪里?
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
设计缺陷
这种嵌套依赖树的设计确实存在几个严重的问题
- 路径过长问题: 由于包的嵌套结构 ,
node_modules
的目录结构可能会变得非常深,甚至可能会超出系统路径长度上限 ,毕竟 windows 系统的文件路径默认最多支持 256 个字符 - 磁盘空间浪费: 多个包之间难免会有公共的依赖,公共依赖会被多次安装在不同的包目录下,导致磁盘空间被大量浪费 。比如上面
express
和 A 都依赖了accepts
,它就被安装了两次 - 安装速度慢:由于依赖包之间的嵌套结构,
npm
在安装包时需要多次处理和下载相同的包,导致安装速度变慢,尤其是在依赖关系复杂的项目中
当时 npm 还没解决这些问题, 社区便推出了新的解决方案 ,就是 yarn。 它引入了一种新的依赖管理方式——扁平化依赖。
看到 yarn 的成功,npm 在 3.0 版本中也引入了类似的扁平化依赖结构
yarn
yarn 的主要改进之一就是通过扁平化依赖结构来解决嵌套依赖树的问题
具体来说铺平,yarn 尽量将所有依赖包安装在项目的顶层 node_modules
目录下,而不是嵌套在各自的 node_modules
目录中。
这样一来,减少了目录的深度,避免了路径过长的问题 ,也尽可能避免了依赖被多次重复安装的问题
我们可以在 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
那么所有的(次级)依赖去哪了呢? binggo,在node_modules/.pnpm/
目录下,.pnpm/
以平铺的形式储存着所有的包
三层寻址
- 所有 npm 包都安装在全局目录
~/.pnpm-store/v3/files
下,同一版本的包仅存储一份内容,甚至不同版本的包也仅存储 diff 内容。 - 顶层
node_modules
下有.pnpm
目录以打平结构管理每个版本包的源码内容,以硬链接方式指向 pnpm-store 中的文件地址。 - 每个项目
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 的实现原理。官方给了一张原理图,可以搭配食用
前面说过,npm 包都被安装在全局
pnpm store
,默认情况下,会创建多个存储(每个驱动器(盘符)一个),并在项目所在盘符的根目录
所以,同一个盘符下的不同项目,都可以共用同一个全局
pnpm store
,绝绝子啊👏,大大节省了磁盘空间,提高了安装速度
软硬链接
也就是说,所有的依赖都是从全局 store 硬连接到了 node_modules/.pnpm
下,然后之间通过软链接来相互依赖。
那么,这里的软连接、硬链接到底是什么东西?
硬链接是指向磁盘上原始文件所在的同一位置 (直接指向相同的数据块)
软连接可以理解为新建一个文件,它包含一个指向另一个文件或目录的路径 (指向目标路径)
总结
npm2 的嵌套结构: 每个依赖项都会有自己的 node_modules
目录,导致了依赖被重复安装,严重浪费了磁盘空间💣;在依赖层级比较深的项目中,甚至会超出 windows 系统的文件路径长度💣
npm3+ 和 Yarn 的扁平化策略: 尽量将所有依赖包安装在项目的顶层 node_modules
目录下,解决了 npm2
嵌套依赖的问题。但是该方案有一个重大缺陷就是“幽灵依赖”💣;而且依赖包有多个版本时,只会提升一个,那其余版本依然会被重复安装,还是有浪费磁盘空间的问题💣
pnpm全局存储和符号链接机制: 结合软硬链和三层寻址,解决了依赖被重复安装的问题,更加变态的是,同一盘符下的不同项目都可以共用一个全局 pnpm store
。节省了磁盘空间,并且根本不存在“幽灵依赖”,安装速度还贼快💪💪💪
来源:juejin.cn/post/7410923898647461938
微信公众平台:天下之事合久必分分久必合
微信公众平台的故事,还得从 “再小的个体,也有自己的品牌” 的 Slogan 开始说起。
时间线开始
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日
哎,到哪都逃不掉 ToB 的 CURD 业务。
随着微信开发者生态的繁荣,微信意识到了很多开发者在微信的服务号上做 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.
来源:juejin.cn/post/7451561994799890483
手把手教你做个煎饼小程序,摊子开起来
前言
周饼伦在街头摊煎饼,摊后人群熙熙攘攘。他忙得不可开交,既要记住面前小哥要加的培根,又要记住身后奶奶要加的生菜,这时又来了个小妹妹,点的煎饼既要培根又要腊肠。他把鸡蛋打进煎饼后,竟突然忘了前面光头大叔要加的料,是火腿还是鸡柳?一时记不清,好像是火腿,对。然而当把煎饼交给大叔,大叔却怒了,说要的是鸡柳。😡
这可咋办?周饼伦赶忙道歉,大叔却语重心长地说:“试试用小程序云开发吧!最近的数据模型新功能好用得很!” 周饼伦亮出祖传手艺,边摊煎饼边开发小程序,把新开发的小程序点餐页面二维码贴在摊前。从此再没出过错,终于能安心摊煎饼啦!
设计思路
客户扫摊子上面贴的二维码后,会进入点餐页面,在选好要加的配料之后,点击确定就可以点餐,随后,即可在云后台上看到食客提交的数据
实现过程
周饼伦就把当前摊位的主食、配菜,以及各自相应的价格贴在了摊位上,也要把食客的点餐内容记在脑里或者用笔写在纸上。
点餐页要实现两个功能: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;
}
于是,煎饼摊的小程序就大功告成了!
接着就可以在云后台管理订单了,在将订单完成之后,即可在云后台将订单的状态修改成已完成。
我们还可以做的更多…
是否可以在订单中新增一个点餐号,这样就知道是哪个顾客点的餐?是否可以使用数据模型的关联关系将配菜、主食和订单关联起来?
是否可以在小程序中创建一个管理订单的页面?是否可以添加优惠券数据表,来给客户一些限时优惠?
期待大家的体验反馈!
来源:juejin.cn/post/7413376270518042651
代码与蓝湖ui颜色值一致!但页面效果出现色差问题?
前言
最近在开发新需求,按照蓝湖的ui图进行开发,但是在开发完部署后发现做出来的页面部分元素的颜色和设计图有出入,有色差!经过一步步的排查最终破案,解决。仅以此篇记录自己踩坑、学习的过程,也希望可以帮助到其他同学。
发现问题
事情是这样的,那是一个愉快的周五的下午,和往常一样我开心的提交了代码后进行打包发版,然后通知负责人查看我的工作成果。
但是,过了不久后,负责人找到了我,说我做出来的效果和ui有点出入,有的颜色有点不一样。我一脸懵逼,心想怎么可能呢,我是根据ui图来的,ui的颜色可是手把手从蓝湖复制到代码中的啊。
随后他就把页面和ui的对比效果图发了出来:
上图中左侧是蓝湖ui图,右侧是页面效果图。我定睛一看,哇趣!!!好像是有点不一样啊。 感觉右侧的比左侧的更亮一些。于是我赶紧本地查看我的页面和ui,果然也是同样问题! 开发时真的没注意,没发现这个问题!!!
排查问题
于是,我迅速开始进行问题排查,看看到底是什么问题,是值写错了?还是那里的问题。
ui、页面、代码对比
下图中:最上面部分是蓝湖ui图、下面左侧是我的页面、右侧是我的页面代码样式
仔细检查后发现颜色的值没错啊,我的代码中背景颜色、边框颜色的值都和ui的颜色值是一致的! 但这是什么问题呢??? 值都一样为什么渲染到页面会出现色差?
起初,我想到的是屏幕的问题,因为不同分辨率下展示出来的页面效果是会有差距的。但是经过查看发现同事的win10笔记本、我的mac笔记本、外接显示器上都存在颜色有色差这个问题!!!
ui、页面、源文件对比
通过对比ui、页面、颜色值,不同设备展示效果可以初步确认:和显示器关系不大。当我在百思不解的时候,我突然想到了ui设计师!ui提供的ui图是蓝湖上切出来的,那么她的源文件颜色是什么呢?
于是我火急火燎的联系到了公司ui小姐姐,让她发我源文件该元素的颜色值,结果值确实是一样的,但是!!! 源文件展示出来的效果好像和蓝湖上的不太一样!
然后我进行了对比(左侧蓝湖、右上页面、右下源文件):
可以看到源文件和我页面的效果基本一致!到这一步基本可以确定我的代码是没问题的!
尝试解决
首先去网上找了半天没有找到想要的答案,于是我灵光一现,想到了蓝湖客服!然后就询问了客服,为什么上传后的ui图内容和源文件有色差?
沟通了很久,期间我又和ui小姐姐在询问她的软件版本、电脑版本、源文件效果、设置等内容就不贴了,最终得到如下解答:
解决方式
下载最新版蓝湖插件,由于我们的ui小姐姐用的 sketch
切图工具,然后操作如下:
1.下载安装最新版蓝湖插件: lanhuapp.com/mac?formHea…
2.安装新版插件后--插件重置
3.后台程序退出 sketch
,重新启动再次尝试打开蓝湖插件.
4.插件设置打开高清导出上传(重要!)
5.重新切图上传蓝湖
最终效果
左侧ui源文件、右侧蓝湖ui:
页面效果:
可以看到我的页面元素的border
好像比ui粗一些,感觉设置0.5px就可以了,字体效果的话是因为我还没来得及下载ui对应的字体文件。
但是走到这一步发现整体效果已经和ui图到达了95%以上相似了,不至于和开始有那么明显的色差。
总结
至此,问题已经基本是解决。遇到问题不能怕,多想一想,然后有思路后就一步一步排查、尝试解决问题。当解决完问题后会发现心情舒畅!整个人都好起来了,也会增加自信心!
来源:juejin.cn/post/7410712345226035200
基于Vue.js和高德地图API来实现一个简易的天气预报
今天就让我们来使用 Vue.js 和高德地图开放平台提供的 API,实现一个关于天气预报的小demo,实时查询当前城市的天气以及未来三天的天气预测,且实现切换城市查询。实现效果如下;
准备工作
既然要使用真实的数据,那么就需要用到高德地图开放平台提供的天气查询 API,先高德地图api注册为开发者。然后点击文档与支持,选择JS API。
然后登录到控制台创建一个应用并且添加一个key,服务平台为Web端(JS API)。
终端npm create vite@latest
使用vite创建项目,npm install
下载该项目需要用的包,npm run dev
运行项目。
将天气预报的功能全部开发在weather.vue
里面,再将这个组件import weather from "./components/weather.vue"
引入到app.vue中。
js代码概览
具体代码步骤实现
开始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样式美化成如下界面;
js部分
接下来就是渲染其中的数据了,首先使用高德 api 来获取定位数据,查看官方文档,JS API结合 Vue 使用,首先安装Loader,如下所示,复制到当前文件终端安装。
然后复制代码粘贴;
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) // 使用一个函数,将获取地址信息放到这个函数中
})
})
获取城市信息
官方文档:
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) // 获取天气
}
})
})
}
利用该地址获取实时天气数据
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) // 后面用来获取未来三天的天气
});
});
}
将这一整个对象赋值给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 返回天气预报数据,返回数据见下表
});
});
}
最后就是切换城市中的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 能够对你的前端开发有所帮助,同时记得给文章点点赞哦🤗。
来源:juejin.cn/post/7448246468471521307
一个网页打造自己的天气预报
概念解释
通过数据接口,简化功能开发。天气数据从哪里来?如果是自己采集,不光要写后端代码,最难的是维护啊,所以必须《天气预报》此类APP特别适合 前后端分离的,以下用一个简单的例子快速打通前后端的调用
前端使用HTML页面,通过JS的Fetch发起请求,向天气API拿到JSON数据回显到页面上。
比较麻烦的是找到免费易用天气API接口。
前后端分离
前端负责用户界面展示和交互体验,
后端负责业务逻辑和数据处理。
这里后端直接使用免费的天气API,所以后端可以视为云服务。图上的左半部分。
- 前端:HTML+JS+Fetch请求
- 后端:云服务API(天气数据接口网站)
- 数据:JSON格式传输
数据接口
简化理解为一个返回JSON数据的网页。
项目《天气预报》
一、后端 云服务API(天气数据接口网站)
1. 注册激活帐号(目标得到APPID和APPSecret即可)
找到免费方便的天气API数据接口网页,这里使用 http://www.yiketianqi.com/
(非广告 只是顺手找到,如果有更方便的欢迎评论区留言),每天有1000次免费调用
注册记下自己 APPID
和 APPSecret
,前端请求时要用
2. 数据接口文档
一定要注册帐号,才能看到自己的 APPID
和 APPSecret
文档中 http://www.yiketianqi.com/index/doc 。
直接复制下图(1) 就是前端用到的 目标数据接口
3.测试天气数据API
以下URL供 前端请求时替换成自己的 APPID
和 APPSecret
gfeljm.tianqiapi.com/api?unescap…
使用浏览器打开即可,可以通过浏览器观察,其实前端有这个URL就开业啦
二、前端 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
2. 完整静态HTML页面
制作一个简易的HTML页面,显示出关键数据。更多数据需要参考接口文档。
使用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样式

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多城市查询
4.1 增加对应的HTML代码
4.2 增加对应的JS代码
三、项目图示总结
使用Fetch和async/await极大的简化了前端代码。后端数据接口就是一个URL地址。
整个后端具备云服务的特征,可以视作云服务数据接口,如图所示
四、天气接口
1.易客云天气API 推荐:⭐⭐⭐⭐⭐
对新手比较友好。
tianqiapi.com/
2.高德地图 需要注册开发者(推荐:⭐⭐⭐)
3.心知天气(推荐:⭐)
免费的API数据只有一行,且文档藏得太深难用
http://www.seniverse.com/
欢迎大家提供更多更好的天气API。
来源:juejin.cn/post/7441560184010014735
“有办法让流程图动起来吗?”“当然有!”:一起用LogicFlow实现动画边
引言
在流程图中,边(Edge) 的主要作用是连接两个节点,表示从一个节点到另一个节点的关系或流程。在业务系统中,边通常代表某种逻辑连接,比如状态转移、事件触发、任务流动等。对于复杂的流程图,边不仅仅是两点之间的连接,它还可以传递信息、约束流程的顺序,并通过不同的样式或标记来表达不同的含义。
不同的场景下,边可能需要具备丰富的样式或交互,比如箭头表示方向、虚线表示条件判断、动画表示动态效果等。因此,灵活定义和实现自定义边对于流程图的可视化设计尤为重要。
LogicFlow的边
为了灵活适配不同场景下的需求,LogicFlow的边模型是由 线条、箭头、文本、调整点五个模块组成。用户可以继承基础边类,对边的线条、箭头、文本和调整点进行自定义。
在技术实现上,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中存储的数据会以外部参数的形式传给组件,由不同渲染方法消费。每个渲染方法都是从Model存储的核心数据中获取图形信息、从样式方法中获取图形渲染样式,组装到svg图形上。最终由render函数将不同模块方法返回的内容呈现出来。
内置衍生边
LogicFlow内部基于基础边衍生提供三种边:直线边、折线边和曲线边。
直线边
在基础边的之上做简单的定制:
- 支持样式快速设置
- 限制文本位置在线段中间
- 使用svg的line元素实现线条的绘制
View | Model |
---|---|
![]() | ![]() |
直线边数据层和视图层源码逻辑
折线边
折线边在Model类的实现上针对边路径计算做了比较多的处理,会根据两个节点的位置、重叠情况,使用 A*查找 结合 曼哈顿距离 计算路径,实时自动生成pointsList数据。在View类中则重写了getEdge方法,使用svg polyline元素渲染路径。
曲线边
曲线边和折线边类似,Model类针对边路径计算做了较多处理,不一样的是,为了调整曲线边的弧度,曲线边额外还提供了两个调整点,边路径也是根据边起终点和两个调整点的位置和距离计算得出,View类里使用svg的path元素渲染路径。
一起实现一条自定义动画边
自定义边的实现思路和内置边的实现类似:继承基础边 → 重写Model类/View类的方法 → 按需增加自定义方法 → 命名并导出成模块
今天就带大家一起实现一条复杂动画边,话不多说,先看效果:
要实现这样效果的边,我们核心只需要做一件事:重新定义边的渲染内容。
在实际写代码时,主要需要继承视图类,重写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提供的边动画能力其实是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属性的值作为返回的,默认情况下默认的动画是这样的效果:
开发者可以通过修改全局样式来设置边动画样式;但如果是只是指定类型边需要设置动画部分,则需要重写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,
},
}),
])
我们就得到了定制样式的动画边:
添加渐变颜色和阴影
最后来增加样式效果,我们需要给这些边增加渐变颜色和阴影。
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,
},
}),
])
就得到了我们的自定义动画边
结尾
在流程图中,边不仅仅是节点之间的连接,更是传递信息、表达逻辑关系的重要工具。通过 LogicFlow,开发者可以轻松地创建和自定义边,以满足不同的业务场景需求。从基础的直线边到复杂的曲线边,甚至动画边,LogicFlow 都为开发者提供了高度的灵活性和定制能力。
希望能通过这篇文章抛砖引玉,帮助你了解在 LogicFlow 中创建和定制边的核心技巧,打造出符合你业务需求的流程图效果。
如果这篇文章对你有帮助,请为我们的项目点上star,非常感谢ღ( ´・ᴗ・` )
项目传送门:github.com/didi/LogicF…
来源:juejin.cn/post/7431379490969010212
svg实现地铁线路图
简介
最近学习了svg,想着使用svg实现地铁线路图
其中黄色是1号线,蓝色是2号线,橙色是3号线
实现:react+svg+数据结构-图。
考虑范围包括了每站时间,但未包括了换站时间。考虑到换站时间可以加到每2个交换站的路程里
最近学习了svg,想着使用svg实现地铁线路图
其中黄色是1号线,蓝色是2号线,橙色是3号线
实现:react+svg+数据结构-图。
考虑范围包括了每站时间,但未包括了换站时间。考虑到换站时间可以加到每2个交换站的路程里
功能
功能:选择2个地铁站,标出最短路程。
求最少换站路线,暂未做
功能:选择2个地铁站,标出最短路程。
求最少换站路线,暂未做
实现思路
- 简化问题,先将所有地铁站分2类,交换站和非交换站。那么交换站可以充当图中的。那么从a=>b, 变成a=>交换站=>交换站=>b的问题,需要写死的是非交换站(a,b)能到达的交换站(下面的adjcent数组), 其中a=>交换站 和b=>交换站 相对静止,但是我这里也考虑到了非交换站到交换站需要的时间(time)
地铁线路图

图

- 首先根据每条地铁图数据绘制出地铁线路图,并添加上点击事件,这里要处理好地铁线路图的数据,数据需要相对准确,因为后面需要计算出最短路径。
- 简化问题,先将所有地铁站分2类,交换站和非交换站。那么交换站可以充当图中的。那么从a=>b, 变成a=>交换站=>交换站=>b的问题,需要写死的是非交换站(a,b)能到达的交换站(下面的adjcent数组), 其中a=>交换站 和b=>交换站 相对静止,但是我这里也考虑到了非交换站到交换站需要的时间(time)
地铁线路图
图
- 首先根据每条地铁图数据绘制出地铁线路图,并添加上点击事件,这里要处理好地铁线路图的数据,数据需要相对准确,因为后面需要计算出最短路径。
- 求最短距离,使用的是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点到所有点的最短路程。你们可以思考一下如果获取最短路径。
大概长这样
- 求最短路径 使用一个对象,存储每次找到较短路径。 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
插件系统为什么在前端开发中如此重要?
插件系统是一种软件架构模式,允许开发者通过添加外部模块或组件来扩展和定制软件应用的功能,而无需修改其核心代码。这种方式为软件提供了高度的可扩展性、灵活性和可定制性。
用过构建工具的同学都知道,grunt, webpack, gulp 都支持插件开发。后端框架比如 egg koa 都支持插件机制拓展,前端页面也有许多可拓展性的要求。插件化无处不在,所有的框架都希望自身拥有最强大的可拓展能力,可维护性,而且都选择了插件化的方式达到目标。
什么是插件系统
插件系统主要由三个关键部分组成:
- 核心系统(Host Application):这是主软件应用,提供了插件可以扩展或修改的基础功能。
- 插件接口(Plugin Interface):定义了插件和核心系统之间的交互协议。插件接口规定了插件必须遵循的规则和标准,以便它们能够被核心系统识别和使用。
- 插件(Plugins):根据插件接口规范开发的外部模块或组件,用于扩展核心系统的功能。插件可以被添加或移除,而不影响核心系统的运行。
插件的执行流程和实现方式
插件的执行流程是指从插件被加载到执行其功能直至卸载的一系列步骤。
- 设计核心系统:首先,我们需要一个核心系统。这个系统负责维护基础功能,并提供插件可以扩展或修改的接口。
- 核心系统的生命周期:定义核心系统的关键阶段,例如启动、运行中、关闭等。每个阶段可能会触发特定的事件。
- 暴露的 API:确定哪些内部功能是可以被插件访问的。这包括数据访问、系统服务调用等接口。
- 插件的结构设计:插件需要有一个清晰的结构,使其能够容易地集成到核心系统中。一个典型的插件结构可能包含:
- 初始化代码:插件加载时执行的代码,用于设置插件的运行环境。
- 处理函数:实现插件功能的核心代码,根据插件的目的可以有多个。
- 资源清理:插件卸载时需要执行的清理代码,以确保资源被适当释放。
- 插件的注册和加载:开发者通过配置文件、命令或图形界面在核心系统中注册插件,系统随后根据注册信息安装并加载插件,这个过程涉及读取插件元数据、执行初始化代码,以及将插件绑定到特定的生命周期事件或 API 上。
- 插件的实现:插件的实现依赖于核心系统提供的生命周期钩子和 API。
- 利用生命周期钩子:插件可以注册函数来响应核心系统的生命周期事件,例如在系统启动完成后执行初始化操作,或在系统关闭前进行资源清理。
- 调用暴露的 API:插件通过调用核心系统暴露的 API 来实现其功能。这些 API 可以提供系统信息、修改数据、触发事件等功能。
- 代码执行流程:插件通过注册自身到核心系统,绑定处理函数至特定事件或 API,以响应系统生命周期变化或 API 调用执行特定任务。在适当时机,如系统关闭或更新时,插件被卸载,其资源得以清理并从系统中移除。
通过这个流程,插件系统提供了一个灵活、可扩展的方式来增强和定制核心系统的功能。插件的开发者可以专注于插件逻辑的实现,而无需修改核心系统的代码。同时,核心系统能够保持稳定性和安全性,因为插件的执行是在明确定义的接口和约束条件下进行的。
插件的几种形式
插件的主要形式主要分为以下几种形式:
- 约定式插件
- 注入式插件
- 事件式插件
- 插槽式插件
约定式插件
约定式插件通常在那些采用“约定优于配置”理念的框架或工具中很常见。以 Webpack 为例,它过各种加载器(Loaders)和插件(Plugins)提供强大的扩展性,而这些扩展往往遵循一定的约定,以简化配置的复杂性。
在 Webpack 配置中使用插件时,通常不需要指定插件工作的具体点,只需要将插件加入到配置的 plugins 数组中。Webpack 根据内部的运行机制和生命周期事件,自动调用这些插件,执行相关的任务。
例如,使用 HtmlWebpackPlugin 可以自动生成一个 HTML 文件,并自动将打包后的 JS 文件注入到这个 HTML 文件中。开发者只需要按照约定将 HtmlWebpackPlugin 加入到 plugins 数组中,无需指定具体的注入点或方式,Webpack 就会自动完成这些任务。
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
// 其他配置...
plugins: [
new HtmlWebpackPlugin({
template: "./src/template.html",
}),
],
};
通过这种约定式的插件机制,Webpack 极大地简化了开发者的配置工作,同时保持了强大的灵活性和扩展性。用户只需遵循简单的约定,如将插件实例添加到 plugins 数组,Webpack 便能自动完成复杂的集成工作,如资源打包、文件处理等,从而提高了开发效率和项目的可维护性。这正体现了约定式插件的主要优势:通过遵循一套预定义的规则,减少配置的需求,同时提供强大的功能扩展能力。
注入式插件
注入式插件通过在应用程序的运行时或编译时将插件的功能注入到应用程序中,从而扩展应用程序的功能。这种方式往往依赖于一种中间件或框架来实现插件的动态加载和执行。一个典型的例子就是 NestJs 世界中广泛使用的依赖注入(DI)功能。
除此之外,尽管 Webpack 更常被人们提及其约定式插件机制,但我们可以从一个角度将 Loaders 视为一种注入式插件,在 Webpack 配置中,Loaders 允许你在模块被添加到依赖图中时,预处理文件。可以看作是在编译过程中“注入”了额外的处理步骤。这些处理步骤可以包括将 TypeScript 转换为 JavaScript、将 SASS 转换为 CSS,或者将图片和字体文件转换为 Webpack 可以处理的格式。
module.exports = {
// ...其他配置
module: {
rules: [
{
test: /\.js$/, // 使用正则表达式匹配文件路径,处理.js文件
exclude: /node_modules/, // 排除node_modules目录
use: {
loader: "babel-loader", // 指定使用babel-loader
options: {
presets: ["@babel/preset-env"], // 使用预设配置转换ES6+代码
},
},
},
],
},
// ...其他配置
};
通过 loader 的配置,Webpack 实现了一种灵活的“注入式”扩展机制,允许开发者根据需要为构建过程注入各种预处理步骤。
事件插件化
事件插件化是一种基于事件驱动编程模式的插件化机制,其中插件通过监听和响应系统中发生的特定事件来工作。这种机制允许插件在不直接修改主程序代码的情况下增加或改变程序的行为。
Node.js 的 EventEmitter 类是实现事件插件化的一个很好的例子。假设我们正在开发一个应用程序,该程序需要在完成某个任务后执行一系列的操作,这些操作由不同的插件来实现。
首先,创建一个基于 EventEmitter 的任务执行器,它在完成任务时会发出一个事件:
const EventEmitter = require("events");
class TaskExecutor extends EventEmitter {
execute(taskFunc) {
console.log("Executing task...");
taskFunc();
this.emit("taskCompleted", "Task execution finished");
}
}
接着,我们可以开发插件来监听 taskCompleted 事件。每个插件都可以注册自己的监听器来响应事件:
// Plugin A
executor.on("taskCompleted", (message) => {
console.log(`Plugin A responding to event: ${message}`);
});
// Plugin B
executor.on("taskCompleted", (message) => {
console.log(`Plugin B responding to event: ${message}`);
});
最后,创建 TaskExecutor 的实例,并执行一个任务,看看插件如何响应:
const executor = new TaskExecutor();
// 注册插件
// ...此处省略插件注册代码...
executor.execute(() => {
console.log("Task is done.");
});
运行上述代码时,TaskExecutor 执行一个任务,并在任务完成后发出 taskCompleted 事件。注册监听该事件的所有插件(在这个例子中是插件 A 和插件 B)都会接到通知,并执行相应的响应操作。这种模式使得开发者可以很容易地通过添加更多的事件监听器来扩展应用程序的功能,而无需修改 TaskExecutor 或其他插件的代码,实现了高度的解耦和可扩展性。
插槽插件化
在 React 中,插槽插件化的概念可以通过组件的 children 属性或使用特定的插槽来实现。这种模式允许开发者定义一个组件框架,其中一些部分可以通过传入的子组件来填充,从而实现自定义内容的注入。这类似于 Vue 中的插槽(slots)功能,但在 React 中,它通过 props.children 或通过特定的 props 来传递组件来实现。
function Card({ children }) {
return <div className="card">{children}</div>;
}
function App() {
return (
<Card>
<h2>标题</h2>
<p>这是一段文本</p>
</Card>
);
}
通过这种方式,React 支持了组件的插槽化,使组件的复用和自定义变得更加容易。这种模式在构建可扩展和可复用的 UI 组件库时尤其有用。
代码实现
接下来我们通过插件来实现一个计算器,可以实现加减乘除
插件核心实现
class Calculator {
constructor(options = {}) {
const { initialValue = 0 } = options;
this.currentValue = initialValue;
}
getCurrentValue() {
return this.currentValue;
}
setValue(value) {
this.currentValue = value;
}
plus(addend) {
this.setValue(this.currentValue + addend);
}
minus(subtrahend) {
this.setValue(this.currentValue - subtrahend);
}
multiply(multiplicand) {
this.setValue(this.currentValue * multiplicand);
}
division(divisor) {
if (divisor === 0) {
console.error("不允许除零。");
return;
}
this.setValue(this.currentValue / divisor);
}
}
// test
const calculator = new Calculator();
calculator.plus(10);
console.log(calculator.getCurrentValue()); // 10
calculator.minus(5);
console.log(calculator.getCurrentValue()); // 5
calculator.multiply(2);
console.log(calculator.getCurrentValue()); // 10
calculator.division(2);
console.log(calculator.getCurrentValue()); // 5
实现 hooks
核心系统想要对外提供生命周期钩子,就需要一个事件机制。
class Hooks {
constructor() {
this.listeners = {};
}
on(eventName, handler) {
let listeners = this.listeners[eventName];
if (!listeners) {
this.listeners[eventName] = listeners = [];
}
listeners.push(handler);
}
off(eventName, handler) {
const listeners = this.listeners[eventName];
if (listeners) {
this.listeners[eventName] = listeners.filter((l) => l !== handler);
}
}
trigger(eventName, ...args) {
const listeners = this.listeners[eventName];
const results = [];
if (listeners) {
for (const listener of listeners) {
const result = listener.call(null, ...args);
results.push(result);
}
}
return results;
}
destroy() {
this.listeners = {};
}
}
这个 Hooks 类是一个事件监听器或事件钩子的简单实现,它允许你在应用程序的不同部分之间传递消息或事件,而不必直接引用那些部分。
暴露生命周期(通过 Hooks)
然后将 hooks 运用在核心系统中 -- JavaScript 计算器。
每个钩子对应的事件:
- pressedPlus 做加法操作
- pressedMinus 做减法操作
- pressedMultiply 做乘法操作
- pressedDivision 做乘法操作
- valueWillChanged 即将赋值 currentValue,如果执行此钩子后返回值为 false,则中断赋值。
- valueChanged 已经赋值 currentValue
class Calculator {
constructor(options = {}) {
this.hooks = new Hooks();
const { initialValue = 0, plugins = [] } = options;
this.currentValue = initialValue;
plugins.forEach((plugin) => plugin.apply(this.hooks));
}
getCurrentValue() {
return this.currentValue;
}
setValue(value) {
const result = this.hooks.trigger("valueWillChanged", value);
if (result.length !== 0 && result.some((_) => !_)) {
} else {
this.currentValue = value;
this.hooks.trigger("valueChanged", this.currentValue);
}
}
plus(addend) {
this.hooks.trigger("pressedPlus", this.currentValue, addend);
this.setValue(this.currentValue + addend);
}
minus(subtrahend) {
this.hooks.trigger("pressedMinus", this.currentValue, subtrahend);
this.setValue(this.currentValue - subtrahend);
}
multiply(factor) {
this.hooks.trigger("pressedMultiply", this.currentValue, factor);
this.setValue(this.currentValue * factor);
}
division(divisor) {
if (divisor === 0) {
console.error("Division by zero is not allowed.");
return;
}
this.hooks.trigger("pressedDivision", this.currentValue, divisor);
this.setValue(this.currentValue / divisor);
}
}
插件实现
插件要实现 apply 方法,在 Calculator 的 constructor 调用时,才能确保插件 apply 执行后会绑定(插件内的)处理函数到生命周期。
apply 的入参是 this.hooks,通过 this.hooks 来监听生命周期并添加处理器。
class LogPlugins {
apply(hooks) {
hooks.on("pressedPlus", (currentVal, addend) =>
console.log(`${currentVal} + ${addend}`)
);
hooks.on("pressedMinus", (currentVal, subtrahend) =>
console.log(`${currentVal} - ${subtrahend}`)
);
hooks.on("pressedMultiply", (currentVal, factor) =>
console.log(`${currentVal} * ${factor}`)
);
hooks.on("pressedDivision", (currentVal, divisor) =>
console.log(`${currentVal} / ${divisor}`)
);
hooks.on("valueChanged", (currentVal) =>
console.log(`结果: ${currentVal}`)
);
}
}
class LimitPlugins {
apply(hooks) {
hooks.on("valueWillChanged", (newVal) => {
if (100 < newVal) {
console.log("result is too large");
return false;
}
return true;
});
}
}
LogPlugins 的目的是记录计算器操作的详细日志。通过监听 Calculator 类中定义的事件(如加、减、乘、除操作和值变化时的事件),这个插件在这些操作执行时打印出相应的操作和结果。
LimitPlugins 的目的是在值变更前进行检查,以确保计算器的结果不会超出预设的限制(在这个例子中是 100)。如果预计的新值超出了限制,这个插件会阻止值的更改并打印一条警告消息。
通过这两个插件,Calculator 类获得了额外的功能,而无需直接在其代码中加入日志记录和值限制检查的逻辑。
完整代码
最后我们应该贴上全部代码:
class Hooks {
constructor() {
this.listener = {};
}
on(eventName, handler) {
if (!this.listener[eventName]) {
this.listener[eventName] = [];
}
this.listener[eventName].push(handler);
}
trigger(eventName, ...args) {
const handlers = this.listener[eventName];
const results = [];
if (handlers) {
for (const handler of handlers) {
const result = handler(...args);
results.push(result);
}
}
return results;
}
off(eventName, handler) {
const handlers = this.listener[eventName];
if (handlers) {
this.listener[eventName] = handlers.filter((cb) => cb !== handler);
}
}
destroy() {
this.listener = {};
}
}
class Calculator {
constructor(options = {}) {
this.hooks = new Hooks();
const { initialValue = 0, plugins = [] } = options;
this.currentValue = initialValue;
plugins.forEach((plugin) => plugin.apply(this.hooks));
}
getCurrentValue() {
return this.currentValue;
}
setValue(value) {
const result = this.hooks.trigger("valueWillChanged", value);
if (result.length !== 0 && result.some((_) => !_)) {
} else {
this.currentValue = value;
this.hooks.trigger("valueChanged", this.currentValue);
}
}
plus(addend) {
this.hooks.trigger("pressedPlus", this.currentValue, addend);
this.setValue(this.currentValue + addend);
}
minus(subtrahend) {
this.hooks.trigger("pressedMinus", this.currentValue, subtrahend);
this.setValue(this.currentValue - subtrahend);
}
multiply(factor) {
this.hooks.trigger("pressedMultiply", this.currentValue, factor);
this.setValue(this.currentValue * factor);
}
division(divisor) {
if (divisor === 0) {
console.error("Division by zero is not allowed.");
return;
}
this.hooks.trigger("pressedDivision", this.currentValue, divisor);
this.setValue(this.currentValue / divisor);
}
}
class LogPlugins {
apply(hooks) {
hooks.on("pressedPlus", (currentVal, addend) =>
console.log(`${currentVal} + ${addend}`)
);
hooks.on("pressedMinus", (currentVal, subtrahend) =>
console.log(`${currentVal} - ${subtrahend}`)
);
hooks.on("pressedMultiply", (currentVal, factor) =>
console.log(`${currentVal} * ${factor}`)
);
hooks.on("pressedDivision", (currentVal, divisor) =>
console.log(`${currentVal} / ${divisor}`)
);
hooks.on("valueChanged", (currentVal) =>
console.log(`结果: ${currentVal}`)
);
}
}
class LimitPlugins {
apply(hooks) {
hooks.on("valueWillChanged", (newVal) => {
if (100 < newVal) {
console.log("result is too large");
return false;
}
return true;
});
}
}
// 运行测试
const calculator = new Calculator({
initialValue: 0,
plugins: [new LogPlugins(), new LimitPlugins()],
});
calculator.plus(10);
calculator.minus(5);
calculator.multiply(2);
calculator.division(5);
calculator.plus(1000); // 尝试加到超过限制的值
最终输出结果如下图所示:
参考资料
总结
通过这两个插件的例子,我们可以看到插件化设计模式在软件开发中的强大之处。它允许开发者在不修改原有代码基础上扩展功能、增加新的处理逻辑,使得应用更加模块化和易于维护。这种模式特别适用于那些需要高度可扩展性和可定制性的应用程序。
最后分享两个我的两个开源项目,它们分别是:
这两个项目都会一直维护的,如果你也喜欢,欢迎 star 🚗🚗🚗
如果你对开源项目感兴趣的,可以加我微信 yunmz777
来源:juejin.cn/post/7347220605609410595
没想到,axios下载文件竟然比fetch好用
前言
还是和上篇一样,是关于导出excel的问题。好像是生产上导出的excel有问题,具体是啥没和我说,当然和我上篇写的没有什么关系,这是另一个模块历史遗留的问题。反正到我手里的任务就是更改导出的接口让后端去做表格。
原来的写法
原来的写法很粗暴,直接用window.location
去跳转下载链接就把excel下载了,后端具体怎么做我的不清楚,前端的逻辑就是有一个固定的地址,然后通过query去传参让后端知道该导出什么样的excel表格。
function exportExcel(params){
const url = 'xxxxx/exportExcel?id=params.id&type=params.type'
window.location = url
}
content-disposition
基础没学好应该也是会这样的一个疑问,为什么我在浏览器中输入一个地址就会下载文件
,是的我也是,所以我去查了一下,主要是由于Content-Disposition
这个响应头字段。它告诉浏览器该文件是作为附件下载,还是在浏览器中直接打开。如果该字段的值为 attachment
,则浏览器会将文件下载到本地;如果该字段的值为inline
,则浏览器会尝试在浏览器中直接打开文件。
语法格式
- 其基本语法格式为:
Content-Disposition: attachment; filename="filename.ext"
或Content-Disposition: inline; filename="filename.ext"
。
- 其中,
attachment
表示将内容作为附件下载,这是最常见的用于文件下载的设置;而inline
则表示在浏览器中内联显示内容,即直接在浏览器窗口中展示,而不是下载。
filename
参数用于指定下载文件的名称,若不指定,浏览器可能会根据服务器返回的其他信息或自身的默认规则来确定文件名。
标题党?
才不是啊,因为我要对接的接口变成post请求,用原来这种方式肯定是不行的,这个时候我就想到了我之前写过的类似需求,就是用fetch。但是一直请求不成功,后端一直报请求参数异常。
fetch
function exportExcel(data){
fetch(`xxxxxxx/ExportExcel`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Authorization': 'xxxxxxxxxxxxxxxxxxxxxxxxxx'
}
}).then(res => {
const readableStream = res.body
if (readableStream) {
return new Response(readableStream).blob()
} else {
console.error('No readable stream available.')
}
}).then(blob => {
// 创建一个下载链接
const downloadLink = document.createElement('a')
downloadLink.href = URL.createObjectURL(blob)
// 设置下载属性,指定文件名
downloadLink.download = '测试.xlsx'
// 模拟点击下载链接
downloadLink.click()
// 释放 URL 对象
URL.revokeObjectURL(downloadLink.href)
})
}
我感觉我写的没有什么毛病啊,fetch第一个then回调转成blob数据类型,第二个then模拟a标签点击下载。但是后端老给报参数类型异常。
我本来想让后端给我看看什么原因的,是什么参数没传对,还是什么请求头不对,但是他就老给甩一张swagger的请求成功的截图,根本不会帮你去看日志是因为什么原因。当然,swagger能调成功,说明接口肯定是没问题的,肯定是我没有传对东西,但是就挺烦的,都没有沟通欲望了,想着自己去换种方式去解决,然后我就想着用axios去试一下,没想到成功了
axios
function exportExcel(data) {
axios({
method: 'post',
url: `xxxxx/ExportExcel`,
data,
responseType: 'blob'// 这里就是转化为blob文件流
}).then(res => {
console.log(res, 'res')
// 创建一个下载链接
const downloadLink = document.createElement('a')
downloadLink.href = URL.createObjectURL(res.data)
// 设置下载属性,指定文件名
downloadLink.download = '测试.xlsx'
// 模拟点击下载链接
downloadLink.click()
// 释放 URL 对象
URL.revokeObjectURL(downloadLink.href)
})
}
这里通过responseType
设置blob
值,就会自动将响应的东西转成blob二进制的格式内容,然后还是通过模拟a标签下载。相比于fetch,我们要在第二个then中对数据进行转换,而axios配置一个参数就行了。
总结
现在大部分的项目中,基本都是使用axios封装的交互方法,所以我们其实用axios是最好的,只需要配置一个参数就可以下载excel,相较于fetch来说,代码是比较简洁一点。虽然我这里fetch是没有成功的,但是放心,肯定是没有问题,是可以这样下载excel的,我估摸着应该是请求头的原因吧,可能是后端做了什么对请求头的处理,我也不知道,但是我之前做这个需求都是用fetch肯定没问题。
来源:juejin.cn/post/7450310230536208418
🌿一个vue3指令让el-table自动轮播
前言
本文开发的工具,是vue3 element-plus ui库专用的,需要对vue3指令概念有一定的了解
最近开发的项目中,需要对项目中大量的列表实现轮播效果,经过一番折腾.最终决定不使用第三方插件,手搓一个滚动指令.
效果展示
实现思路
第一步先确定功能
- 列表自动滚动
- 鼠标移入停止滚动
- 鼠标移出继续滚动
- 滚轮滚动完成,还可以继续在当前位置滚动
- 元素少于一定条数时,不滚动
滚动思路
通过观察el-table
的结构可以发现el-scrollbar__view
里面放着所有的元素,而el-scrollbar__wrap
是一个固定高度的容器,那么只需要获取到el-scrollbar__wrap
这个DOM,并且再给一个定时器,不断的改变它的scrollTop
值,就可以实现自动滚动的效果,这个值必须要用一个变量来存储,不然会失效
停止和继续滚动思路
设置一个boolean
类型变量,每次执行定时器的时候判断一下,true
就滚动,否则就不滚动
滚轮事件思路
为了每次鼠标在列表中滚动之后,我们的轮播还可以在当前滚动的位置,继续轮播,只需要在鼠标移出的时候,将当前el-scrollbar__wrap
的scrollTop
赋给前面存储的变量,这样执行定时器的时候,就可以继续在当前位置滚动
不滚动的思路
只需要判断el-scrollbar__view
这个容器的高度,是否大于el-scrollbar__wrap
的高度,是就可以滚动,不是就不滚动。
大致的思路是这样的,下面上源码
实现代码
文件名:tableAutoScroll.ts
interface ElType extends HTMLElement {
timer: number | null
isScroll: boolean
curTableTopValue: number
}
export default {
created(el: ElType) {
el.timer = null
el.isScroll = true
el.curTableTopValue = 0
},
mounted(el: ElType, binding: { value?: { delay?: number } }) {
const { delay = 15 } = binding.value || {}
const tableDom = el.getElementsByClassName(
'el-scrollbar__wrap'
)[0] as HTMLElement
const viewDom = el.getElementsByClassName(
'el-scrollbar__view'
)[0] as HTMLElement
const onMouseOver = () => (el.isScroll = false)
const onMouseOut = () => {
el.curTableTopValue = tableDom.scrollTop
el.isScroll = true
}
tableDom.addEventListener('mouseover', onMouseOver)
tableDom.addEventListener('mouseout', onMouseOut)
el.timer = window.setInterval(() => {
const viewDomClientHeight = viewDom.scrollHeight
const tableDomClientHeight = el.clientHeight
if (el.isScroll && viewDomClientHeight > tableDomClientHeight) {
const curScrollPosition = tableDom.clientHeight + el.curTableTopValue
el.curTableTopValue =
curScrollPosition === tableDom.scrollHeight
? 0
: el.curTableTopValue + 1
tableDom.scrollTop = el.curTableTopValue
}
}, delay)
},
unmounted(el: ElType) {
if (el.timer !== null) {
clearInterval(el.timer)
}
el.timer = null
const tableDom = el.getElementsByClassName(
'el-scrollbar__wrap'
)[0] as HTMLElement
tableDom.removeEventListener('mouseover', () => (el.isScroll = false))
tableDom.removeEventListener('mouseout', () => {
el.curTableTopValue = tableDom.scrollTop
el.isScroll = true
})
},
}
上面代码中,我在 created中初始化了三个变量,分别用于存储,定时器对象 、是否滚动判断、滚动当前位置。
在 mounted中我还获取了一个options,主要是为了可以定制滚动速度
用法
- 将这段代码放在你的文件夹中
- 在
main.ts
中注册这个指令
import tableAutoScroll from './modules/tableAutoScroll.ts'
const directives: any = {
tableAutoScroll,
}
/**
* @function 批量注册指令
* @param app vue 实例对象
*/
export const install = (app: any) => {
Object.keys(directives).forEach((key) => {
app.directive(key, directives[key]) // 将每个directive注册到app中
})
}
我这边是将自己的弄了一个批量注册,正常使用就像官网里面注册指令就可以了
在需要滚动的el-table
上使用这个指令就可以
<!-- element 列表滚动指令插件 -->
<template>
<div class="container">
<el-table v-tableAutoScroll :data="tableData" height="300">
<el-table-column prop="date" label="时间" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="address" label="Address" />
</el-table>
<!-- delay:多少毫秒滚动一次 -->
<el-table
v-tableAutoScroll="{
delay: 50,
}"
:data="tableData"
height="300"
>
<el-table-column prop="date" label="时间" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="address" label="Address" />
</el-table>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const tableData = ref<any>([])
onMounted(() => {
tableData.value = Array.from(Array(100), (item, index) => ({
date: '时间' + index,
name: '名称' + index,
address: '地点' + index,
}))
console.log('👉 ~ tableData.value=Array.from ~ tableData:', tableData)
})
</script>
<style lang="scss" scoped>
.container {
height: 100%;
display: flex;
align-items: flex-start;
justify-content: center;
gap: 100px;
.el-table {
width: 500px;
}
}
</style>
上面这个例子,分别演示两种调用方法,带参数和不带参数
最后
做了这个工具之后,突然有很多思路,打算后面再做几个,做成一个开源项目,一个开源的vue3指令集
来源:juejin.cn/post/7452667228006678540
马上2025年了,你还在用组件式弹窗? 来看看这个吧~
闲言少叙,直切正题。因为我喜欢命令式弹窗,所以就封装了它做为了业务代码的插件!如今在实际项目中跑了大半年,挺方便也挺灵活的!
如何使用
// vue2
npm install @e-dialog/v2
// main.js 入口文件
import Vue from 'vue'
import App from './App'
//导包
import eDialog from '@e-dialog/v2'
//注册插件
Vue.use(eDialog, {
width:'50%',//全局配置
top:'15vh',
//...省略
})
new Vue({
el: '#app',
render: h => h(App)
})
// vue3
npm install @e-dialog/v3
// main.js 入口文件
import { createApp } from 'vue'
import App from './App.vue'
//导包
import eDialog from '@e-dialog/v3'
// 创建实例
const setupAll = async () => {
const app = createApp(App)
app.use(eDialog,{
width:'50%',//全局配置
top:'15vh',
//...省略
})
app.mount('#app')
}
setupAll()
插件简介
vue2是基于element ui elDialog组件做的二次封装,vue3则是基于element-plus elDialog组件做的二次封装,属性配置这一块可以全部参考element UI文档!
扩展的属性配置
参数 | 说明 | 类型 | 默认值 |
---|---|---|---|
isBtn | 是否显示底部操作按钮 | boolean | true |
draggable | 是否开启拖拽,vue3版本element-plus内置了该属性 | boolean | true |
floorBtnSize | 底部操作按钮的尺寸 | medium、small、mini | small |
sureBtnText | 确定按钮的文案 | string | 确定 |
closeBtnText | 关闭按钮的文案 | string | 关闭 |
footer | 底部按钮的插槽,是一个函数返回值必须是JSX | function | - |
底部插槽用法
// index.vue
<template>
<!-- vue2示例 -->
<div>
<el-button @click="handleDialog">弹窗</el-button>
</div>
</template>
<script>
//弹窗内容
import Edit form './edit.vue'
export default {
methods: {
handleDialog() {
this.$Dialog(Edit, props,function(vm,next){
//vm可以通过vm.formData拿到数据
},{
isBtn:false //如果定义了插槽,建议关闭底部操作按钮,不然会出现布局问题
footer:function(h,next){
return (
<el-button onClick={()=>{this.handleCheck(next)}}>按钮</el-button>
)
}
})
},
//按钮点击触发
handleCheck(next){
//next是一个手动关闭函数
console.log('业务逻辑')
}
}
}
</script>
页面使用:vue2
// index.vue
<template>
<!-- vue2示例 -->
<div>
<el-button @click="handleDialog">弹窗</el-button>
</div>
</template>
<script>
//弹窗内容
import Edit form './edit.vue'
export default {
methods: {
handleDialog() {
/**
* @function $Dialog是一个全局方法,自动挂载到了vue的原型
* @description 它总共接收4个参数
* @param 组件实例 | html字符串
* @param props要传递到组件的参数对象。
* @param 点击确定按钮的回调,回调里面的第一个参数是弹窗内容的组件实例,第二个参数是关闭弹窗的执行函数
* @param 配置对象,可以覆盖全局的配置
* @return void
*/
this.$Dialog(Edit, props,function(vm,next){
//vm可以通过vm.formData拿到数据
},{
//配置对象
})
}
}
}
</script>
//edit.vue
<template>
<div>弹窗内容!</div>
</template>
<script>
export default {
props:[/*这里可以接收$Dialog第二个参数props的数据*/]
data() {
return {
formData:{
a:'',
b:'',
c:''
}
}
},
}
</script>
页面使用:vue3
// index.vue
<template>
<!-- vue2示例 -->
<div>
<el-button @click="handleDialog">弹窗</el-button>
</div>
</template>
<script setup>
//弹窗内容
import Edit form './edit.vue'
const { proxy } = getCurrentInstance();
const $Dialog = proxy.useDialog()
function handleDialog() {
/**
* @function $Dialog是一个全局方法,自动挂载到了vue的原型
* @description 它总共接收4个参数
* @param 组件实例 | html字符串
* @param props要传递到组件的参数对象。
* @param 点击确定按钮的回调,回调里面的第一个参数是弹窗内容的组件实例,第二个参数是关闭弹窗的执行函数
* @param 配置对象,可以覆盖全局的配置
* @return void
*/
$Dialog(Edit, props,function(vm,next){
//vm可以通过vm.formData拿到数据
},{
//配置对象
})
}
</script>
//edit.vue
<template>
<div>弹窗内容!</div>
</template>
<script setup>
const formData = reactive({
a:'',
b:'',
c:''
})
defineExpose({ formData }) //这里注意一点要把外部要用的抛出去,如果不抛,则$Dialog回调将拿不到任何数据
</script>
函数参数设计理念
- 如果你弹窗内容比较复杂,例如涉及一些表单操作。最好建议抽离成一个组件,导入到Dialog第一个入参里面,如果只是简单的静态文本,则直接可以传HTML。
- 如果你Dialog导入的是组件,那么你有可能需要给组件传参。所以Dialog第二个入参就是给你开放的入口。
- 如果你点击确认按钮可能需要执行一些逻辑,例如调用API接口。所以你可能在Dialog第三个回调函数里面写入逻辑。回调函数会把第一个入参组件的实例给你传递回来,你拿到实例就可以干任何事情咯!
- Dialog第四个参数考虑到不同页面的配置不同。可以灵活设置。
vue2源码地址(github.com/zy1992829/e…)
vue3源码地址(github.com/zy1992829/e…)
喜欢的朋友可以去看一看,顺便帮忙点个星星。这个就不贴源码了。。
来源:juejin.cn/post/7448661024440401957
Vue3.5正式上线,父传子props用法更丝滑简洁
前言
Vue3.5
在2024-09-03
正式上线,目前在Vue
官网显最新版本已经是Vue3.5
,其中主要包含了几个小改动,我留意到日常最常用的改动就是props
了,肯定是用Vue3
的人必用的,所以针对性说一下props
的两个
小改动使我们日常使用更加灵活。
一、带响应式Props解构赋值
简述: 以前我们对Props
直接进行解构赋值是会失去响应式的,需要配合使用toRefs
或者toRef
解构才会有响应式,那么就多了toRefs
或者toRef
这工序,而最新Vue3.5
版本已经不需要了。
这样直接解构,testCount能直接渲染显示,但会失去响应式,当我们修改testCount时页面不更新。
<template>
<div>
{{ testCount }}
</div>
</template>
<script setup>
import { defineProps } from 'vue';
const props = defineProps({
testCount: {
type: Number,
default: 0,
},
});
const { testCount } = props;
</script>
保留响应式的老写法,使用
toRefs
或者toRef
解构
<template>
<div>
{{ testCount }}
</div>
</template>
<script setup>
import { defineProps, toRef, toRefs } from 'vue';
const props = defineProps({
testCount: {
type: Number,
default: 0,
},
});
const { testCount } = toRefs(props);
// 或者
const testCount = toRef(props, 'testCount');
</script>
最新
Vue3.5
写法,不借助”外力“直接解构,依然保持响应式
<template>
<div>
{{ testCount }}
</div>
</template>
<script setup>
import { defineProps } from 'vue';
const { testCount } = defineProps({
testCount: {
type: Number,
},
});
</script>
相比以前简洁了真的太多,直接解构使用省去了toRefs
或者toRef
二、Props默认值新写法
简述: 以前默认值都是用default: ***
去设置,现在不用了,现在只需要解构的时候直接设置默认值,不需要额外处理。
先看看旧的
default: ***
默认值写法
如下第12
就是旧写法,其它以前Vue2
也是这样设置默认值
<template>
<div>
{{ props.testCount }}
</div>
</template>
<script setup>
import { defineProps } from 'vue';
const props = defineProps({
testCount: {
type: Number,
default: 1
},
});
</script>
最新优化的写法
如下第9
行,解构的时候直接一步到位设置默认值,更接近js
语法的写法。
<template>
<div>
{{ testCount }}
</div>
</template>
<script setup>
import { defineProps } from 'vue';
const { testCount=18 } = defineProps({
testCount: {
type: Number,
},
});
</script>
小结
这次更新其实props
的本质功能并没有改变,但写法确实变的更加丝滑好用了,props
使用非常高频感觉还是有必要跟进这种更简洁的写法。如果那里写的不对或者有更好建议欢迎大佬指点啊。
来源:juejin.cn/post/7410333135118090279
2024 年了! CSS 终于加入了 light-dark 函数!
一. 前言
随着 Web 技术的不断发展,用户体验成为了设计和开发过程中越来越重要的因素之一。为了更好地适应用户的视觉偏好,CSS 在 2024 年正式引入了一项新的功能 —— light-dark()
函数。
这项功能的加入主要在于简化网页对于浅色模式(Light Mode
)与深色模式(Dark Mode
)的支持,使得我们能够更快更轻松轻松地实现不同的主题切换。
接下来,我们就来详细了解一下我们在开发网页是如何实现主题切换的!
以下 Demo 示例,支持跟随系统模式和自定义切换主题,先一睹为快吧!
二. 传统方式
在 light-dark()
函数出现之前,开发者通常需要通过 JavaScript 或者 CSS 变量配合媒体查询来实现主题切换。例如:
使用 CSS 变量 + 媒体查询:
开发者会定义一套 CSS 变量,然后基于用户的偏好设置(如:prefers-color-scheme: dark
或 prefers-color-schema: light
)来改变这些变量的值。
/* 默认模式 */
:root {
--background-color: white;
--text-color: black;
}
/* dark模式 */
@media (prefers-color-scheme: dark) {
:root {
--background-color: #333;
--text-color: #fff;
}
}
也可以使用 JavaScript 监听主题切换:
JavaScript 可以监听用户更改其操作系统级别的主题设置,并相应地更新网页中的类名或样式表链接。
// 检测是否启用了dark模式
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.body.classList.add('dark-mode')
} else {
document.body.classList.remove('dark-mode')
}
以上这种方法虽然有效,但增加了代码复杂度,特别是当需要处理多个元素的颜色变化时,我们可能需要更多的代码来支持主题。
接下来我们看一下 light-dark
是如何实现的?
三. 什么是 light-dark?
light-dark()
是在 2024 年新加入的一种新的 CSS 函数,它允许我们根据用户的系统颜色方案(浅色或深色模式)来自动选择合适的颜色值。这个函数的引入简化了创建响应用户偏好主题的应用程序和网站的过程,而无需使用媒体查询或其他复杂的逻辑。
1. 基本用法
具体的说,light-dark()
函数接受两个参数,分别对应于浅色模式下的颜色值和深色模式下的颜色值。
- 第一个参数是在浅色模式下使用的颜色。
- 第二个参数是在深色模式下使用的颜色。
当用户的设备设置为浅色模式时,light-dark()
会返回第一个参数的颜色;当用户的设备设置为深色模式时,则返回第二个参数的颜色。
基本语法如下:
color: light-dark(浅色模式颜色, 深色模式颜色);
因此,light-dark()
提供了一种更简洁的方式来直接在 CSS 中指定两种模式下的颜色,而不需要额外的脚本或复杂的 CSS 结构。例如:
body {
background-color: light-dark(white, #333);
color: light-dark(black, #fff);
}
这里的 light-dark(白色, 深灰色)
表示如果用户处于浅色模式下,则背景色为白色;如果是深色模式,则背景色为深灰色。同样适用于文本颜色等其他属性。
2. 结合其他 CSS 特性
light-dark()
可以很好地与其他 CSS 特性结合使用,如变量、渐变等,以创造更加丰富多样的效果。当结合其他 CSS 特性使用 light-dark()
将更加灵活的创造页面的效果。
(1) 结合 CSS 变量
你可以利用 CSS 变量来存储颜色值,然后在 light-dark()
内引用这些变量,这样就能够在一处更改颜色方案并影响整个站点。
CSS 变量(也称为自定义属性)允许你存储可重复使用的值,这使得在不同的主题之间切换变得非常方便。你可以设置基础颜色变量,然后利用 light-dark()
来决定这些变量的具体值。
:root {
--primary-color: light-dark(#007bff, #6c757d);
--background-color: light-dark(white, #212529);
--text-color: light-dark(black, white);
}
body {
background-color: var(--background-color);
color: var(--text-color);
}
(2) 结合媒体查询
虽然 light-dark()
本身就可以根据系统偏好自动调整颜色,但有时候你可能还需要针对特定的屏幕尺寸或分辨率进行额外的样式调整。这时可以将 light-dark()
与媒体查询结合使用。
@media (max-width: 600px) {
body {
--button-bg: light-dark(#f8f9fa, #343a40); /* 更小的屏幕上按钮背景色 */
--button-text: light-dark(black, white);
}
button {
background-color: var(--button-bg);
color: var(--button-text);
}
}
(3) 结合伪类
light-dark()
也可以与伪类一起工作,比如 :hover
, :focus
等,以实现不同状态下的颜色变化。
button {
background-color: light-dark(#007bff, #6c757d);
color: light-dark(white, black);
}
button:hover,
button:focus {
background-color: light-dark(#0056b3, #5a6268);
}
(4) 结合渐变
如果你希望在浅色模式和深色模式下使用不同的渐变效果,同样可以通过 light-dark()
来实现。
.header {
background: linear-gradient(light-dark(#e9ecef, #343a40), light-dark(#dee2e6, #495057));
}
(5) 结合阴影
对于元素的阴影效果,你也可以根据不同主题设置不同的阴影颜色和强度。
.box-shadow {
box-shadow: 0 4px 8px rgba(light-dark(0, 255), light-dark(0, 255), light-dark(0, 255), 0.1);
}
通过上述方法,你可以充分利用 light-dark()
函数的优势,并与其他 CSS 特性结合,创造出既美观又具有高度适应性的网页设计。这样不仅提高了用户体验,还简化了开发过程中的复杂度。
四. 兼容性
在 2024 年初时,light-dark()
函数作为 CSS 的一个新特性被加入到规范中,并且开始得到一些现代浏览器的支持。
其实,通过上图我们可以看到,light-dark()
在主流浏览器在大部分版本下都是支持了,所以我们可以放心的使用它。
但是同时我们也要注意,在一些较低的浏览器版本上仍然不被支持,比如 IE。因此,为了确保兼容性,在生产环境中使用该功能前需要检查目标浏览器是否支持这一特性。
如果浏览器不支持 light-dark()
,可能需要提供回退方案,比如使用传统的媒体查询 @media (prefers-color-scheme: dark)
或者通过 JavaScript 来动态设置颜色。
五. 总结
通过本文,我们了解到,light-dark() 函数是 CSS 中的一个新特性,它允许开发者根据用户的系统偏好(浅色或深色模式)来自动切换颜色。
通过与传统模式开发深浅主题的比较,我们可以总结出 light-dark()
的优势应该包括:
- 使用简洁:不需要编写额外的媒体查询,简洁高效。
- 自动响应:能够随着系统的颜色方案改变而自动切换颜色。
- 易于维护:所有与颜色相关的样式可以在同一处定义。
- 减少代码量:相比使用多个媒体查询,可以显著减少 CSS 代码量。
light-dark()
函数是 CSS 领域的一项进步,它不仅简化了响应式设计的过程,也体现了对终端用户个性化体验的重视。随着越来越多的现代浏览器开始支持这一特性,我们未来可以在更多的应用场景中使用这一特性!
文档链接
码上掘金演示
可以点击按钮切换主题,也可以切换系统的暗黑模式跟随:
🔥 我正在参加2024年度人气创作者评选,每投2票可以抽奖! 点击链接投票
来源:juejin.cn/post/7443828372775764006
⚡聊天框 - 微信加载历史数据的效果原来这样实现的
前言
我记得2021年的时候做过聊天功能,那时业务也只限微信小程序
那时候的心路历程是:
卧槽,让我写一个聊天功能这么高大上??
嗯?这么简单,不就画画页面来个轮询吗,加个websocket也还行吧
然后,卧槽?这查看历史聊天记录什么鬼,页面闪一下不太好啊,真的能做到微信的那种效果吗
然后一堆调研加测试,总算在小程序中查看历史记录没那么鬼畜了,但是总是感觉不是最佳解决方案。
那时打出的子弹,一直等到现在击中了我
最近又回想到了这个痛点,于是网上想看看有没有大佬发解决方案,结果还真被我找到了。
正文开始
1,效果展示
上才艺~~~
2,聊天页面
2.1,查看历史聊天记录的坑
常规写法加载历史记录拼接到聊天主体的顶部后,滚动条会回到顶部、不在原聊天页面
直接上图
而我们以往的解决方案也只是各种利用缓存
、scroll的滚动定位
把回到顶部的滚动条重新拉回加载历史记录前的位置,好让我们可以继续在原聊天页面。
但即使我们做了很多优化,也会有安卓和苹果部分机型适配问题,还是不自然,可能会出现页面闪动
。
其实吧,解决方案只有两行css代码
~~~
2.2,解决方案:flex神功
想优雅顺滑的在聊天框里查看历史记录,这两行css代码
就是flex的这个翻转属性
dispaly:flex;
flex-direction: column-reverse
灵感来源~~~
小伙伴可以看到,在加载更多数据时
滚动条位置没变、加载数据后还是原聊天页面的位置
这不就是我们之前的痛点吗~~~
所以,我们只需要翻转位置,用这个就可以优雅流畅的实现微信的加载历史记录啦
flex-direction: column-reverse
官方的意思:指定Flex容器中子元素的排列方向为列(从上到下),并且将其顺序反转(从底部到顶部)
如果感觉还是抽象,不好理解的话,那就直接上图,不加column-reverse
的样子
加了column-reverse
的样子
至此,我们用column-reverse
再搭配data数据的位置处理
就完美解决加载历史记录的历史性问题啦
代码放最后啦~~~
2.3,其他问题
2.3.1,数据过少时第一屏展示
因为用了翻转,数据少的时候会出现上图的问题
只需要.mainArea
加上height:100%
然后额外写个适配盒子就行
flex-grow: 1;
flex-shrink: 1;
2.3.2,用了scroll-view导致的问题
这一part是因为我用了uniapp
里 scroll-view
组件导致的坑以及解决方案,小伙伴们没用这个组件的可忽略~~~
如下图,.mainArea
使用了height:100%
后,继承了父级高度后scroll-view滚动条消失了。
.mainArea
去掉height:100%
后scroll-view滚动条出现,但是第一屏数据过多时不会滚动到底部展示最新信息
解决方案:第一屏手动进行滚动条置顶
scrollBottom() {
if (this.firstLoad) return;
// 第一屏后不触发
this.$nextTick(() => {
const query = uni.createSelectorQuery().in(this);
query
.select("#mainArea")
.boundingClientRect((data) => {
console.log(data);
if (data.height > +this.chatHeight) {
this.scrollTop = data.height; // 填写个较大的数
this.firstLoad = true;
}
})
.exec();
});
},
3,服务端
使用koa自己搭一个websocket服务端
3.1 服务端项目目录
package.json
{
"name": "websocketapi",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"koa": "^2.14.2",
"koa-router": "^12.0.1",
"koa-websocket": "^7.0.0"
}
}
koa-tcp.js
const koa = require('koa')
const Router = require('koa-router')
const ws = require('koa-websocket')
const app = ws(new koa())
const router = new Router()
/**
* 服务端给客户端的聊天信息格式
* {
id: lastid,
showTime: 是否展示时间,
time: nowDate,
type: type,
userinfo: {
uid: this.myuid,
username: this.username,
face: this.avatar,
},
content: {
url:'',
text:'',
w:'',
h:''
},
}
消息数据队列的队头为最新消息,以次往下为老消息
客户端展示需要reverse(): 客户端聊天窗口最下面需要为最新消息,所以队列尾部为最新消息,以此往上为老消息
*/
router.all('/websocket/:id', async (ctx) => {
// const query = ctx.query
console.log(JSON.stringify(ctx.params))
ctx.websocket.send('我是小服,告诉你连接成功啦')
ctx.websocket.on('message', (res) => {
console.log(`服务端收到消息, ${res}`)
let data = JSON.parse(res)
if (data.type === 'chat') {
ctx.websocket.send(`我也会说${data.text}`)
}
})
ctx.websocket.on('close', () => {
console.log('服务端关闭')
})
})
// 将路由中间件添加到Koa应用中
app.ws.use(router.routes()).use(router.allowedMethods())
app.listen(9001, () => {
console.log('socket is connect')
})
切到server目录
下yarn
然后执行nodemon koa-tcp.js
没有nodemon
的小伙伴要装一下
代码区
聊天页面的核心代码如下(包含data数据的位置处理和与服务端联动)
完结
这篇文章我尽力把我的笔记和想法放到这了,希望对小伙伴有帮助。
到这里,想给小伙伴分享两句话
现在搞不清楚的事,不妨可以先慢下来,不要让自己钻到牛角尖了
一些你现在觉得解决不了的事,可能需要换个角度
欢迎转载,但请注明来源。
最后,希望小伙伴们给我个免费的点赞,祝大家心想事成,平安喜乐。
来源:juejin.cn/post/7337114587123335180
在高德地图上实现建筑模型动态单体化
前言
前段时间系统性地学习了国内知名GIS平台的三维应用解决方案,受益匪浅,准备分几期对所学内容进行整理归纳和分享,今天来讲讲城市3D模型的处理方案。
城市3D模型图层在Web GIS平台中能够对地面物体进行单独选中、查询、空间分析等操作,是属于比较普遍的功能需求。这样的图层一般不会只有单一的数据来源,而是个集成了倾斜投影、BIM建模、动态参数建模的混合型图层,并且需要对模型进行适当的处理优化以提升其性能,因此在GIS平台不会选择支持单独地选中地图,我们需要对模型进行拆分——即单体化处理。
对建筑模型单体化处理通常有三种方案,各有其优点和适用场景,对比如下:
方案 | 实现原理 | 优势 | 缺陷 |
---|---|---|---|
切割单体化 | 将三维模型与二维面进行切割操作成为单独可操作对象 | 能够实现非常精细的单体化效果,适用于精细度要求高的场景 | 数据处理量大,对计算机性能要求较高 |
ID 单体化 | 预先为每个三维模型对象和二维矢量数据分配一个可关联的 ID | 数据处理相对简单,不需要进行复杂的几何运算,适用于数据量相对较小、模型结构相对简单的场景 | 可能会出现 ID 管理困难的情况,且对于模型的几何形状变化适应性较差 |
动态单体化 | 实时判断鼠标点击或选择操作的位置与三维模型的关系,动态地将选中的部分从整体模型中分离出来进行单独操作 | 无需预先处理数据,可根据用户的交互实时进行单体化操作,适用于需要快速响应用户交互的场景 | 对计算机的图形处理能力和性能要求较高 |
在这些方案中,动态单体化无需对模型进行预处理,依数据而变化使用较为灵活,接下来我们通过一个简单的例子来演示该方案的整体实现过程,源代码在这里供下载交流。
需求分析
假设我们拿到一个城市行政区域内的三维建筑数据,对里面该区域里面的几栋关键型建筑进行操作管理,我们需要把建筑模型放置到对应的地图位置上,可宏观地浏览模型;支持通过鼠标选中建筑的楼层,查看当前楼层的相关数据;楼层数据支持动态变更,且可以进行结构性存储。因此我们得到以下功能需求:
- 在Web地图上建立3D区域模型图层
- 根据当前光标位置动态高亮楼层,并展示楼层基本信息
- 建筑单体化数据为通用geoJSON格式,方便直接转换为csv或导入数据库
技术栈说明
工具名称 | 版本 | 用途 |
---|---|---|
高德地图 JSAPI | 2.0 | 为GIS平台提供基础底图和服务 |
three.js | 0.157 | 主流webGL引擎之一,负责实现展示层面的功能 |
QGIS | 3.32.3 | GIS数据处理工具,用于处理本文的矢量化数据 |
cesiumlab | 3.1.11 | 三维数据处理工具集,用于将模型转换为互联网可用的3DTiles |
blender | 3.6 | 模型处理工具,用于对BIM模型进行最简单的预处理 |
实现步骤
制作3DTiles
城市级的三维模型通常以无人机倾斜投影获取到的数据最为快捷,数据格式为OSGB且体积巨大不利于分享,由于手头没有合适的倾斜投影数据,我们以一个小型的BIM模型为例进行三维瓦片化处理也是一样的。
- 模型预处理。从sketchfab寻找到一个合适的建筑模型,下载其FBX格式并导入到模型处理工具(C4D、blender等)进行简单的预处理,调整模型的大小、重置坐标轴原点的位置到模型的几何中心,然后导出带材质的FBX模型备用,这里blender如何带材质地导出模型有一些技巧。
- 启动cesiumlab,点击“通用模型切片”选项,选择预处理好的模型,指定它的地理位置(ENU: 维度,经度),点击确认
- 在最后的“数据存储”设置原始坐标为打开、存储类型为散列(最终输出多个文件)、输出路径,提交处理等待3DTiles生成
- 生成过程结束后我们来到分发服务选项,点击chrome的图标就能够进入3DTiles的预览了,注意看路径这一列,这里面包含了入口文件tileset.json的两个路径(文件存储目录和静态资源服务地址),后面开发中我们会用到它。
- 至此模型准备完毕,我们可以把输出出的3Tiles目录放到开发i工程中,也可以单独部署为静态资源服务,保证tileset.json可访问即可。
- 开发3DTiles图层,详细的教程之前已经分享过了,这里直接上代码。
// 默认地图状态
const mapConf = {
name: '虚拟小区',
tilesURL: '../static/tiles/small-town/tileset.json',
//...
}
// 添加3DTiles图层
async function initTilesLayer() {
const layer = new TilesLayer({
container,
id: 'tilesLayer',
map: getMap(),
center: [113.536206, 22.799285],
zooms: [4, 22],
zoom: mapConf.zoom,
tilesURL: mapConf.tilesURL,
alone: false,
interact: false
})
layer.on('complete', ({ scene }) => {
// 调整模型的亮度
const aLight = new THREE.AmbientLight(0xffffff, 3.0)
scene.add(aLight)
})
layerManger.add(layer)
}
- 这一阶段实现的效果如下
创建单体化数据
- 使用QGIS处理矢量数据,绘制建筑模型轮廓的矢量面。由于本示例是虚拟的,我们需要自己创建矢量数据,把上一个步骤完成的内容高空垂直截图导入到QGIS上配准位置,作为描绘的参考图。
- 创建形状文件图层,进入编辑模式绘制建筑轮廓
- 选择图层右键打开属性表,开始编辑每个建筑的基础数据,导出为monobuildingexample1.geojson
- 对关键建筑“商业办公楼A”和“商业办公楼B”的楼层数据进行进一步编辑(bottomAltitude为每层楼的离地高度,extendAltitude为楼层高度),这块数据与GIS无关,我直接用wps图表去做了,完成后将csv文件转换成为json格式,然后与monobuildingexample1.geojson做一个组合,得到最终的geoJSON数据。
开发动态单体化图层
底座和数据准备好,终于可以进行动态单体化图层开发了,实现原理其实非常简单,根据上一步获得的建筑矢量和楼层高度数据,我们就可以在于模型匹配的地理位置上创建若干个“罩住”楼栋模型的盒状网格体,并监听网格体的鼠标拾取状态,即可实现楼层单体化交互。
- 我们的数据来自monobuildingexample1.geojson,生成每个楼层侧面包围盒的核心代码如下,通过path数据和bottomAltitued、extendAltitude就能得到网格体的所有顶点。
/**
* 根据路线创建侧面几何面
* @param {Array} path [[x,y],[x,y],[x,y]...] 路线数据
* @param {Number} height 几何面高度,默认为0
* @returns {THREE.BufferGeometry}
*/
createSideGeometry (path, region) {
if (path instanceof Array === false) {
throw 'createSideGeometry: path must be array'
}
const { id, bottomAltitude, extendAltitude } = region
// 保持path的路线是闭合的
if (path[0].toString() !== path[path.length - 1].toString()) {
path.push(path[0])
}
const vec3List = [] // 顶点数组
let faceList = [] // 三角面数组
let faceVertexUvs = [] // 面的UV层队列,用于纹理和几何信息映射
const t0 = [0, 0]
const t1 = [1, 0]
const t2 = [1, 1]
const t3 = [0, 1]
for (let i = 0; i < path.length; i++) {
const [x1, y1] = path[i]
vec3List.push([x1, y1, bottomAltitude])
vec3List.push([x1, y1, bottomAltitude + extendAltitude])
}
for (let i = 0; i < vec3List.length - 2; i++) {
if (i % 2 === 0) {
// 下三角
faceList = [
...faceList,
...vec3List[i],
...vec3List[i + 2],
...vec3List[i + 1]
]
// UV
faceVertexUvs = [...faceVertexUvs, ...t0, ...t1, ...t3]
} else {
// 上三角
faceList = [
...faceList,
...vec3List[i],
...vec3List[i + 1],
...vec3List[i + 2]
]
// UV
faceVertexUvs = [...faceVertexUvs, ...t3, ...t1, ...t2]
}
}
const geometry = new THREE.BufferGeometry()
// 顶点三角面
geometry.setAttribute(
'position',
new THREE.BufferAttribute(new Float32Array(faceList), 3)
)
// UV面
geometry.setAttribute(
'uv',
new THREE.BufferAttribute(new Float32Array(faceVertexUvs), 2)
)
return geometry
}
- 经过前面步骤,得到网格体如下
- 添加默认状态和选中状态下材质
initMaterial () {
const { initial, hover } = this._conf.style
// 顶部材质
this._mt = {}
this._mt.initial = new THREE.MeshBasicMaterial({
color: initial.color,
transparent: true,
opacity: initial.opacity,
side: THREE.DoubleSide,
wireframe: true
})
this._mt.hover = new THREE.MeshBasicMaterial({
color: hover.color,
transparent: true,
opacity: hover.opacity,
side: THREE.DoubleSide
})
}
- 添加拾取事件,对选中的网格体Mesh设置选中材质,并对外派发事件
// 处理拾取事件
onPicked ({ targets, event }) {
let attrs = null
if (targets.length > 0) {
const cMesh = targets[0]?.object
if (cMesh?.type == 'Mesh') {
// 设置选中状态
this.setLastPick(cMesh)
attrs = cMesh._attrs
} else {
// 移除选中状态
this.removeLastPick()
}
} else {
this.removeLastPick()
}
/**
* 外派模型拾取事件
* @event ModelLayer#pick
* @type {object}
* @property {Number} screenX 图层场景
* @property {Number} screenY 图层相机
* @property {Object} attrs 模型属性
*/
this.handleEvent('pick', {
screenX: event?.pixel?.x,
screenY: event?.pixel?.y,
attrs
})
}
- 外部监听到拾取事件,调动浮层展示详情
/**
* 建筑单体化图层
* @return {Promise<void>}
*/
async function initMonoBuilding() {
const data = await fetchData('../static/mock/monobuildingexample1.geojson')
const layer = new MonoBuildingLayer({
//...
data
})
layerManger.add(layer)
layer.on('pick', (event) => {
updateMarker(event)
})
}
// 更新浮标
function updateMarker(event) {
const { screenX, screenY, attrs } = event
if (attrs) {
// 更新信息浮层
const { id, name, belong, bottomAltitude, extendAltitude } = attrs
tip.style.left = screenX + 20 + 'px'
tip.style.top = screenY + 10 + 'px'
tip.innerHTML = `
<ul>
<li>id: ${id}</li>
<li>楼层: ${name}</li>
<li>离地高度: ${bottomAltitude}米</li>
<li>楼层高度: ${extendAltitude}米</li>
<li>所属: ${belong}</li>
</ul>
`
tip.style.display = 'block'
// 更新鼠标手势
container.classList.add('mouse_hover')
} else {
tip.style.display = 'none'
container.classList.remove('mouse_hover')
}
}
- 最终得到的交互效果如下
- 把3DTiles图层和点标记图层加上叠加显示,得到本示例最终效果
待拓展功能
- 对建筑模型单体的进一步细化
楼层功能还可以细化到每个楼层中各个户型,也许每个楼层都有独特的户型分布图,这个应该结合内部的墙体轮廓一起展示,选个弹窗在子内容页进行下一步操作,还是直在当前场景下钻到楼层内部?具体交互流程我还没想好。
- 如何处理异体模型
目前的方案仅针对规规矩矩的立方体建筑楼栋,而对于鸟巢、大裤衩、小蛮腰之类的异形地标性建筑,每个楼层的轮廓可能都是不一样的,因此在数据和代码方面仍需再做改进。
本示例使用到的高德JSAPI
相关工具链接
来源:juejin.cn/post/7404007685643501595
震惊,开源项目vant 2.13.5 被投毒,挖矿!
2024年12月19日,vant仓库新增一条issue,vant 2.13.5 被投毒,挖矿。
具体原因
可能是团队一名成员的 token 被盗用
与本次事件关联的攻击
攻击者在利用 @landluck 的 token 进行攻击后,进一步拿到了同个 GitHub 组织下的维护者 @chenjiahan 的 token,并发布了带有相同恶意代码的 Rspack 1.1.7 版本。
Rspack 团队已经在一小时内完成该版本的废弃处理,并发布了 1.1.8 修复版本,参考 web-infra-dev/rspack#8767 (comment)
目前相关 token 已经全部清理。
相关版本
以下异常版本被盗号者注入了脚本,已经全部标记为废弃,请勿使用!
有使用的大家可以升级版本,降低影响。
来源:juejin.cn/post/7450001084067627058
禁止调试,阻止浏览器F12开发者工具
写在前面
这两天突然想看看文心一言的http通信请求接口,于是想着用F12看看。
谁知道刚打开开发者工具,居然被动debugger了。
直接被JS写死的debugger关键字下了断点。行吧,不让调试就不让调试吧,关闭开发者工具之后,直接跳到了空白页。
其实几年之前就碰到过类似的情况,不过当时才学疏浅,也没当回事,就没研究过。这次又碰到了,毕竟已经不是当年的我了,于是便来研究研究。
分析
大家都知道浏览器的开发者工具能干啥,正经的用法:开发时调试代码逻辑,修改布局样式;不正经的用法:改改元素骗骗人,找找网站接口写爬虫,逆向js破解加密等等,所以说前端不安全,永远不要相信用户的输入。
而这次碰到的这个情况确实可以在用户端做一些防御操作,但是也可以绕过。 (PS:感谢评论区大佬指教:开发者工具Ctrl+F8可以禁用断点调试,学到了)
先做一波分析。
首先,防止你用F12调试,先用debugger关键字阻止你进行任何操作。随后,在你关闭之后,又直接跳转到空白页,不让你接着操作。
这就需要一个开发者工具检测的机制了,发现你打开了开发者工具,就给你跳走到空白页。
所以,关键就是要实现开发者工具的检测。
实现
经过查阅一番,发现原来这个debugger可能并不仅仅是阻止你进行调试的功能,同时还兼具判断开发者工具是否打开的作用。怎么实现?
debugger本身只是调试,阻止你继续对前端进行调试,但是代码中并不知道用户是否打开了开发者工具,所以就无法进行更进一步的操作,例如文心一言的跳转到空白页。
但是,有一点,你打开开发者工具之后,debugger下了断点,程序就停到那里了,如果你不打开开发者工具,程序是不会停止到断点的。没错,这就是我们可以判断的方式,时间间隔。正常情况下debugger前后的时间间隔可以忽略不计。但是,当你打开开发者工具之后,这个时间间隔就产生了,判断这个时间间隔,就可以知道是否打开了开发者工具。
直接上示例代码
<!DOCTYPE html>
<html>
<header>
<title>test</title>
</header>
<body>
<h1>test</h1>
</body>
<script>
setInterval(function() {
var startTime = performance.now();
// 设置断点
debugger;
var endTime = performance.now();
// 设置一个阈值,例如100毫秒
if (endTime - startTime > 100) {
window.location.href = 'about:blank';
}
}, 100);
</script>
</html>
通过设置一个定时循环任务来进行检测。
在不打开发者工具的情况下,debugger是不会执行将页面卡住,而恰恰是利用debugger的这一点,如果你打开开发者工具一定会被debugger卡住,那么上下文时间间隔就会增加,在对时间间隔进行判断,就能巧妙的知道绝对开了开发者工具,随后直接跳转到空白页,一气呵成。
测试
现在来进行测试,打开F12
关闭开发者工具。
完美!
写在后面
这样确实可以阻挡住通过在开发者工具上获取信息,但是仅仅是浏览器场景。我想要拿到对话的api接口也不是只有这一种方法。
感谢评论区大佬指教:开发者工具Ctrl+F8可以禁用断点调试
或者说,开个代理抓包不好吗?hhh
来源:juejin.cn/post/7337188759055663119
Cesium从入门到入坟
大扎好,我系渣渣辉,斯一扔,介四里没有挽过的船新版本,挤需体验三番钟,里造会干我一样,爱象节款js裤
Cesium 概述
Cesium是国外一个基于JavaScript编写的使用WebGL的地图引擎,支持3D,2D,2.5D形式的地图展示,也是目前最流行的三维数字地球渲染引擎。
Cesium 基础介绍
首先我们需要登录上Cesium的官网,网址是 cesium.com/ ,获取源代码可以在Platform菜单项的Downloads中下载 。
接下来,第一个比较重要的事情就是我们需要注册一个免费账户以获取Cesium世界地形资产所需的访问令牌,而这个账户的token决定了哪些资产咱们可以使用;而第二个比较重要的事情就是Cesium的文档中心( cesium.com/learn/cesiu… ),我们在实际使用的过程中会经常来查阅这些API。
Cesium 的使用
由于我是使用的vue-cli生成的项目,所以直接安装vite-plugin-cesium依赖项,当然你也可以使用直接下载源码,在HTML中引入的方式。如果使用的是vite-plugin-cesium,你还需要在vite.config.ts中添加一下Cesium的引用。
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import VueDevTools from 'vite-plugin-vue-devtools'
// add this line
import cesium from 'vite-plugin-cesium';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
vueJsx(),
VueDevTools(),
// add this line
cesium()
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})
初始化地球
<script setup lang="ts">
import { onMounted } from 'vue'
import * as Cesium from 'cesium'
onMounted(() => {
const defaultToken = 'your access token'
Cesium.Ion.defaultAccessToken = defaultToken
const viewer = new Cesium.Viewer('cesiumContainer', {
//这里是配置项
})
})
</script>
<template>
<div id="cesiumContainer" class="cesium-container"></div>
</template>
<style scoped>
#cesiumContainer {
width: 100vw;
height: 100vh;
}
</style>
效果如下:
现在我们就可以看到Cesium生成的地球了,可以对其进行二维和三维状态的切换,也可以用其自带的播放器,对时间轴进行一个播放,支持正放和倒放,Cesium还自带了搜索地理位置组件,并且兼容了中文。
Cesium 常用的类
1. Viewer
它是Cesium展示三维要素内容的主要窗口,不仅仅包含了三维地球的视窗,还包含了一些基础控件,在定义Viewer对象的时候需要设定基础部件、图层等的初始化状态,下面演示一下部分属性的使用。
const viewer = new Cesium.Viewer('cesiumContainer', {
// 这里是配置项
// 动画播放控件
animation: false,
// 时间轴控件
timeline: false,
// 全屏按钮
fullscreenButton: true,
// 搜索位置按钮
geocoder: true,
// 帮助按钮
navigationHelpButton: false,
// VR按钮
vrButton: true
})
除了上述的控件属性之外,还有entities这种实体合集属性,主要用于加载实体模型,几何图形并对其进行样式设置,动效修改等,我们可以通过下述代码生成一个绿色的圆点。
const entity = viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(116.39, 39.9, 400),
point: {
pixelSize: 100,
color: new Cesium.Color(0, 1, 0, 1)
}
})
viewer.trackedEntity = entity
效果如下:
当然,我们也可以用entities来加载模型文件,下面我们用飞机模型试试
/** 通过entities加载一个飞机模型 */
const orientation = Cesium.Transforms.headingPitchRollQuaternion(
position,
new Cesium.HeadingPitchRoll(-90, 0, 0)
)
const entity = viewer.entities.add({
position: position,
orientation: orientation,
model: {
uri: '/Cesium_Air.glb',
minimumPixelSize: 100,
maximumScale: 10000,
show: true
}
})
viewer.trackedEntity = entity
效果如下:
2. Camera
Cesium中可以通过相机来描述和操作场景的视角,而通过相机Camera操作场景的视角还有下面的几种方法
- 飞行fly,比如flyTo,flyHome,flyToBoundingSphere
- 缩放zoom,比如zoomIn,zoomOut
- 移动move,比如moveBackward,moveDown,moveForward,moveLeft,moveRight,moveUp
- 视角look,比如lookDown,lookLeft,lookRight,lookUp
- 扭转twist,比如twistLeft,twistRight
- 旋转rotate,比如rotateDown,rotateLeft,rotateRight,rotateUp
- 其他方法,比如setView,lookAt
viewer.scene.camera.setView({
// 设定相机的目的地
destination: position,
// 设定相机视口的方向
orientation: {
// 控制视口方向的水平旋转,即沿着Y轴旋转
heading: Cesium.Math.toRadians(0),
// 控制视口方向的上下旋转,即沿着X轴旋转
pitch: Cesium.Math.toRadians(-20),
// 控制视口的翻转角度,即沿着Z轴旋转
roll: 0
}
})
我们尝试使用setView后可以发现,相机视角直接被定位到了下图的位置
3. DataSourceCollection
DataSourceCollection是Cesium中加载矢量数据的主要方式之一,它最大的特点是支持加载矢量数据集和外部文件的调用,主要有三种调用方法,分别为 CzmlDataSource,KmlDataSource,GeoJsonDataSource,分别对应加载Czml,Kml,GeoJSON格式的数据,在使用过程中我们只需要将矢量数据转换为以上任意一种格式就可以在Cesium中实现矢量数据的加载和存取。
viewer.dataSources.add(Cesium.GeoJsonDataSource.load('/ne_10m_us_states.topojson'))
效果如下:
这时候我们看到图层已经被加载上去了~
Cesium的坐标体系
通过上面的示例我们可以得知Cesium具有真实地理坐标的三维球体,但是用户是通过二维屏幕与Cesium进行操作的,假设我们需要将一个三维模型绘制到三维球体上,我们就需要再地理坐标和屏幕坐标之间做转换,而这就需要涉及到Cesium的坐标体系。
Cesium主要有5种坐标系:
- WGS84经纬度坐标系
- WGS84弧度坐标系
- 笛卡尔空间直角坐标系
- 平面坐标系
- 4D笛卡尔坐标系
他们的基础概念大家感兴趣的可以百度查阅一下,我也说不太清楚,问我他们的区别我也只能用 恰特鸡屁踢 敷衍你,下面我们演示一下怎么将WGS84左边西转换为笛卡尔空间直角坐标系:
const cartesian3 = Cesium.Cartesian3.fromDegrees(longitude, latitude, height)
我们可以通过经纬度进行转换,当然我们还有其他的方式,比如Cesium.Cartesian3.fromDegreesArray(coordinates),这里的coordinates格式为不带高度的数组。
Cesium加载地图和地形
加载地图
我们使用ArcGis地图服务来加载新地图,Cesium也给其提供了相关的加载方法:
const esri = await Cesium.ArcGisMapServerImageryProvider.fromUrl(
'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer'
)
/** 这里是配置项 */
const viewer = new Cesium.Viewer('cesiumContainer', {
baseLayerPicker: false,
})
// 加载ArcGis地图
viewer.imageryLayers.addImageryProvider(esri)
效果如下:
我们再来看一下之前的地球效果来对比对比:
可以明显看出来ArcGisMapServer提供的地图更加的清晰和立体。
注:加载ArcGis地图服务请使用我上述提供的代码,从Cesium中文网看到的示例代码可能很久没更新了,使用会报错~
当然我们还可以加载一些特定场景的地图,比如夜晚的地球,官网上直接给出了示例代码:
// addImageryProvider方法用于添加一个新的图层
viewer.imageryLayers.addImageryProvider(await Cesium.IonImageryProvider.fromAssetId(3812))
效果如下:
加载地形
我们回到刚刚的ArcGis地图,我们进入到地球内部查看一些山脉,会发现从俯视角度来看山脉是有轮廓的,但是当我们旋转相机后会发现,实际上地球表面是平的,并没有显示出地形,效果如下:
这时候我们就需要加载出地形数据了
const esri = await Cesium.ArcGisMapServerImageryProvider.fromUrl(
'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer'
)
/** 这里是配置项 */
const viewer = new Cesium.Viewer('cesiumContainer', {
baseLayerPicker: false,
terrainProvider: await Cesium.CesiumTerrainProvider.fromIonAssetId(1, {
// 可以增加法线,用于提高光照效果
requestVertexNormals: true,
// 可以增加水面特效
requestWaterMask: true
})
})
// 加载ArcGis地图
viewer.imageryLayers.addImageryProvider(esri)
效果如下:
可以看到原先的平面通过加载了地形数据,已经有了山势起伏,河流湖泊~
Cesium加载建筑体
我们在实际开发中,比如搭建一个智慧城市,光有地图和地形是远远不够的,还需要加载城市中的建筑模型信息,这时候我们就需要用到Cesium中建筑体的添加和使用的相关功能了,我们以官网的纽约市的模型数据为例:
/** 添加建筑物 */
const tileset = viewer.scene.primitives.add(await Cesium.Cesium3DTileset.fromIonAssetId(75343))
/** 添加相机信息 */
const position = Cesium.Cartesian3.fromDegrees(-74.006, 40.7128, 100)
viewer.camera.setView({
destination: position,
orientation: {
heading: 0,
pitch: 0,
roll: 0.0
}
})
效果如下:
我们看到纽约市建筑物的数据已经加载出来了,但是看起来都是白白的过于单调,我们还可以通过style来修改建筑物的样式
tileset.style = new Cesium.Cesium3DTileStyle({
color: {
conditions: [
['${Height} >= 300', 'rgba(45,0,75,0.5)'],
['${Height} >= 100', 'rgb(170,162,204)'],
['${Height} >= 50', 'rgb(102,71,151)'],
['true', 'rgb(127,59,8)']
]
},
show: '${Height} > 0',
meta: {
description: '"Building id ${id} has height ${Height}."'
}
})
现在我们再来看一下效果:
可以看出我们根据建筑物的不同高度,设定了不同的颜色,比如超过300米的建筑就带有透明效果了,比较上图的效果更有层次感。
最后
关于Cesium我也是初窥门径,具体的学习和使用大家还是要以 英文官网 为准,中文网上很多都过时了,使用的时候可能会报错,我已经帮大家踩好坑了😭,也欢迎大家在评论区里多沟通交流,互相学习~
来源:juejin.cn/post/7392558409742925874
震惊!🐿浏览器居然下毒!
发生什么事了
某天,我正在愉快的摸鱼,然后我看到测试给我发了条消息,说我们这个系统在UC浏览器中有问题,没办法操作,点了经常没反应(测试用得iPhone14,是一个h5的项目)。我直接懵了,这不是都测了好久了吗,虽然不是在uc上测得,chrome、safari、自带浏览器等,都没这个问题,代码应该是没问题的,uc上为啥会没有反应呢?难道是有什么隐藏的bug,需要一定的操作顺序才能触发?我就去找了测试,让他重新操作一下,看看是啥样的没反应。结果就是,正常进入列表页(首页),正常点某一项,正常进入详情页,然后点左上角返回,没反应。我上手试了下,确实,打开vconsole看了下,也没有报错。在uc上看起来还是必现的,我都麻了,这能是啥引起的啊。
找问题
在其他浏览器上都是好好的,uc上也不报错,完全看不出来代码有啥bug,完全没有头绪啊!那怎么办,刷新看看:遇事不决,先刷新
,还不行就清空缓存刷新
。刷新了之后,哎,好了!虽然不知道是什么问题,但现在已经好了,就当作遇到了灵异事件,我就去做其他事了。
过了一会,测试来找我了,说又出现了,不止是详情页,进其他页面也返回不了。这就难受住了呀,说明肯定是有问题的,只是还没找到原因。我就只好打开vconsole,一遍一遍的进入详情页点返回;刷新再进;清掉缓存,再进。
然后,我就发现,network中,出现了一个没有见过的请求
根据track、collect
这些单词来判断,这应该是uc在跟踪、记录某些操作,requestType还是个ping;我就在想,难道是这个请求的问题?但是请求为啥会导致,我页面跳转产生问题?然后我又看到了intercept(拦截)、pushState(history添加记录)
,拦截了pushState?
这个项目确实使用的是history路由,问题也确实出在路由跳转的时候;而且出现问题的时候,路由跳转,浏览器地址栏中的地址是没有变化的,返回就g了(看起来是后退没有反应,实际是前进时G了)
。这样看,uc确实拦截了pushState的操作。那它是咋做到的?
原来如此
然后,我想起来,前段时间在掘金上看到了一篇,讲某些第三方cdn投毒的事情,那么uc是不是在我们不知情的情况下,改了我们的代码。然后我切到了vconsole的element,展开head,发现了一个不属于我们项目的script,外链引入了一段js,就挂在head的最顶上。通过阅读,发现它在window的history上加了点料,覆写了forward和pushState(forward和pushState是继承来的方法)
正常的history应该是这样:
复写的类似这样:
当然,有些系统或框架,为了实现某些功能,比如实现触发popstate的效果,也会复写
但uc是纯纯的为了记录你的操作,它这玩意主要还有bug,会导致路由跳转出问题,真是闹麻了
如何做
删掉就好了,只要删掉uc添加的,当我们调用相关方法时,history就会去继承里找
// 判断是否是uc浏览器
if (navigator.userAgent.indexOf('UCBrowser') > -1) {
if (history.hasOwnProperty('pushState')) {
delete window.history.forward
delete window.history.pushState
}
// 找到注入的script
const ucScript = document.querySelector('script[src*="ucbrowser_script"]')
if (ucScript) {
document.head.removeChild(ucScript)}
}
}
吐槽
你说你一个搞浏览器的,就不能在底层去记录用户行为吗,还不容易被发现。主要是你这玩意它有bug呀,这不是更容易被发现吗。(这是23年11月份遇到的问题,当时产品要求在qq/百度/uc这些浏览器上也都测一下才发现的,现在记录一下,希望能帮助到其他同学)
来源:juejin.cn/post/7411358506048766006
耗时6个月做的可视化大屏编辑器, 开源!
hi, 大家好, 我是徐小夕.
5年前就开始着手设计和研发可视化大屏编辑器, 当时低代码在国内还没有现在那么火, 有人欢喜有人哀, 那个时候我就比较坚定的认为无码化搭建未来一定是个趋势, 能极大的帮助企业提高研发效率和降低研发成本, 所以 all in 做了2年, 上线了一个相对闭环的MVP可视化大屏搭建平台——V6.Dooring.
通过在技术社区不断的分享可视化搭建的技术实践和设计思路, 也参与了很多线上线下的技术分享, 慢慢市场终于“热了”起来.(机缘巧合)
从V6.Dooring的技术架构的设计, 到团队组建, 再到帮助企业做解决方案, 当时几乎所有的周末都花在这上面了, 想想收获还是挺大的, 接触到了形形色色的企业需求, 也不断完整着可视化大屏编辑器的功能, 最后推出了一个还算通用的解决方案:
当然上面介绍的还都不是这篇文章的重点.
重点是, 时隔4年, 我们打算把通用的可视化大屏解决方案, 开源!
一方面是供大家学习参考, 更好的解决企业自身的业务需求, 另一方面可以提供一个技术交流的平台, 大家可以对可视化搭建领域的技术实践, 提出自己的想法和观点, 共同打造智能化, 体验更好的搭建产品.
先上github地址: github.com/MrXujiang/v…
V6.Dooring开源大屏编辑器演示
其实最近几年我在掘金专栏分享了很多零代码和可视化搭建的技术实现和产品设计:
这里为了让大家更近一步了解V6-Dooring可视化大屏编辑器, 我还是会从技术设计到产品解决方案设计的角度, 和大家详细分享一下, 让大家在学习我们可视化大屏开源方案的过程中, 对可视化搭建技术产品, 有更深入的理解.
如果大家觉得有帮助, 不要忘记点赞 + 收藏哦, 后面我会持续分享最干的互联网干货.
你将收获
- 可视化大屏产品设计思路
- 主流可视化图表库技术选型
- 大屏编辑器设计思路
- 大屏可视化编辑器Schema设计
- 用户数据自治探索
方案实现
1.可视化大屏产品设计思路
目前很多企业或多或少的面临“信息孤岛”问题,各个系统平台之间的数据无法实现互通共享,难以实现一体化的数据分析和实时呈现。
相比于传统手工定制的图表与数据仪表盘,可视化大屏制作平台的出现,可以打破抵消的定制开发, 数据分散的问题,通过数据采集、清洗、分析到直观实时的数据可视化展现,能够多方位、多角度、全景展现各项指标,实时监控,动态一目了然。
针对以上需求, 我们设计了一套可视化大屏解决方案, 具体包含如下几点:
上图是笔者4个月前设计的基本草图, 后期会持续更新. 通过以上的设计分解, 我们基本可以搭建一个可自己定制的数据大屏.
2.主流可视化图表库技术选型
目前我调研的已知主流可视化库有:
- echart 一个基于 JavaScript 的老牌开源可视化图表库
- D3.js 一个数据驱动的可视化库, 可以不需要其他任何框架独立运行在现代浏览器中,它结合强大的可视化组件来驱动 DOM 操作
- antv 包含一套完整的可视化组件体系
- Chart.js 基于 HTML5 的 简单易用的 JavaScript 图表库
- metrics-graphics 建立在D3之上的可视化库, 针对可视化和布置时间序列数据进行了优化
- C3.js 通过包装构造整个图表所需的代码,使生成基于D3的图表变得容易
我们使用以上任何一个库都可以实现我们的可视化大屏搭建的需求, 各位可以根据喜好来选择.
3.大屏编辑器设计思路
在上面的分析中我们知道一个大屏编辑器需要有个编辑器核心, 主要包含以下部分:
- 组件库
- 拖拽(自由拖拽, 参考线, 自动提示)
- 画布渲染器
- 属性编辑器
如下图所示:
组件库我们可以用任何组件封装方式(react/vue等), 这里沿用H5-Dooring的可视化组件设计方式, 对组件模型进行优化和设计.
类似的代码如下:
import { Chart } from '@antv/f2';
import React, { memo, useEffect, useRef } from 'react';
import styles from './index.less';
import { IChartConfig } from './schema';
const XChart = (props:IChartConfig) => {
const { data, color, size, paddingTop, title } = props;
const chartRef = useRef(null);
useEffect(() => {
const chart = new Chart({
el: chartRef.current || undefined,
pixelRatio: window.devicePixelRatio, // 指定分辨率
});
// step 2: 处理数据
const dataX = data.map(item => ({ ...item, value: Number(item.value) }));
// Step 2: 载入数据源
chart.source(dataX);
// Step 3:创建图形语法,绘制柱状图,由 genre 和 sold 两个属性决定图形位置,genre 映射至 x 轴,sold 映射至 y 轴
chart
.interval()
.position('name*value')
.color('name');
// Step 4: 渲染图表
chart.render();
}, [data]);
return (
<div className={styles.chartWrap}>
<div className={styles.chartTitle} style={{ color, fontSize: size, paddingTop }}>
{title}
</div>
<canvas ref={chartRef}></canvas>
</div>
);
};
export default memo(XChart);
以上只是一个简单的例子, 更具业务需求的复杂度我们往往会做更多的控制, 比如动画(animation), 事件(event), 数据获取(data inject)等.
当然实际应用中大屏展现的内容和形式远比这复杂, 我们从上图可以提炼出大屏页面的2个直观特征:
- 可视化组件集
- 空间坐标关系
因为我们可视化大屏载体是页面, 是html
, 所以还有另外一个特征: 事件/交互。综上我们总结出了可视化大屏的必备要素:
我们只要充分的理解了可视化大屏的组成和特征, 我们才能更好的设计可视化大屏搭建引擎, 基于以上分析, 我设计了一张基础引擎的架构图:
接下来我就带大家一起来拆解并实现上面的搭建引擎。
大屏搭建引擎核心功能实现
俗话说: “好的拆解是成功的一半”, 任何一个复杂任务或者系统, 我们只要能将其拆解成很多细小的子模块, 就能很好的解决并实现它. (学习也是一样)
接下来我们就逐一解决上述基础引擎的几个核心子模块:
- 拖拽器实现
- 物料中心设计
- 动态渲染器实现
- 配置面板设计
- 控制中心概述
- 功能辅助设计
1.拖拽器实现
拖拽器是可视化搭建引擎的核心模块, 也是用来解决上述提到的大屏页面特征中的“空间坐标关系”这一问题。我们先来看一下实现效果:
组件拖拽可以采用市面已有的 Dragable 等插件, 也可以采用 H5-Dooring 的智能网格拖拽. 这里笔者选择自由拖拽来实现. 已有的有:
- rc-drag
- sortablejs
- react-dnd
- react-dragable
- vue-dragable
等等. 具体拖拽呈现流程如下:
具体拖拽流程就是:
- 使用H5 dragable API拖拽左侧组件(component data)进入目标容器(targetBox)
- 监听拖拽结束事件拿到拖拽事件传递的
data
来渲染真实的可视化组件 - 可视化组件挂载,
schema
注入编辑面板, 编辑面板渲染组件属性编辑器 - 拖拽, 属性修改, 更新
- 预览, 发布
组件的schema
参考H5-Dooring DSL设计.
2.物料中心设计
物料中心主要为大屏页面提供 “原材料”。为了设计健壮且通用的物料, 我们需要设计一套标准组件结构和属性协议。并且为了方便物料管理和查询, 我们还需要对物料进行分类, 我的分类如下:
- 可视化组件 (柱状图, 饼图, 条形图, 地图可视化等)
- 修饰型组件 (图片, 轮播图, 修饰素材等)
- 文字类组件 (文本, 文本跑马灯, 文字看板)
具体的物料库演示如下:
这里我拿一个可视化组件的实现来举例说明:
import React, { memo, useEffect } from 'react'
import { Chart } from '@antv/g2'
import { colors } from '@/components/BasicShop/common'
import { ChartConfigType } from './schema'
interface ChartComponentProps extends ChartConfigType {
id: string
}
const ChartComponent: React.FC<ChartComponentProps> = ({
id, data, width, height,
toggle, legendPosition, legendLayout, legendShape,
labelColor, axisColor, multiColor, tipEvent, titleEvent,
dataType, apiAddress, apiMethod, apiData, refreshTime,
}) => {
useEffect(() => {
let timer:any = null;
const chart = new Chart({
container: `chart-${id}`,
autoFit: true,
width,
height
})
// 数据过滤, 接入
const dataX = data.map(item => ({ ...item, value: Number(item.value) }))
chart.data(dataX)
// 图表属性组装
chart.legend(
toggle
? {
position: legendPosition,
layout: legendLayout,
marker: {
symbol: legendShape
},
}
: false,
)
chart.tooltip({
showTitle: false,
showMarkers: false,
})
// 其他图表信息源配置, 方法雷同, 此处省略
// ...
chart.render()
}, [])
return <div id={`chart-${id}`} />
}
export default memo(ChartComponent)
以上就是我们的基础物料的实现模式, 可视化组件采用了g2
, 当然大家也可以使用熟悉的echart
, D3.js
等. 不同物料既有通用的 props
, 也有专有的 props
, 取决于我们如何定义物料的Schema
。
在设计 Schema
前我们需要明确组件的属性划分, 为了满足组件配置的灵活性和通用性, 我做了如下划分:
- 外观属性 (组件宽高, 颜色, 标签, 展现模式等)
- 数据配置 (静态数据, 动态数据)
- 事件/交互 (如单击, 跳转等)
有了以上划分, 我们就可以轻松设计想要的通用Schema
了。我们先来看看实现后的配置面板:
这些属性项都是基于我们定义的schema
配置项, 通过 解析引擎 动态渲染出来的, 有关 解析引擎 和配置面板, 我会在下面的章节和大家介绍。我们先看看组件的 schema
结构:
const Chart: ChartSchema = {
editAttrs: [
{
key: 'layerName',
type: 'Text',
cate: 'base',
},
{
key: 'y',
type: 'Number',
cate: 'base',
},
...DataConfig, // 数据配置项
...eventConfig, // 事件配置项
],
config: {
width: 200,
height: 200,
zIndex: 1,
layerName: '柱状图',
labelColor: 'rgba(188,200,212,1)',
// ... 其他配置初始值
multiColor: ['rgba(91, 143, 249, 1)', 'rgba(91, 143, 249, 1)', 'rgba(91, 143, 249,,1)', 'rgba(91, 143, 249, 1)'],
data: [
{
name: 'A',
value: 25,
},
{
name: 'B',
value: 66,
}
],
},
}
其中 editAttrs 表示可编辑的属性列表, config 为属性的初始值, 当然大家也可以根据自己的喜好, 设计类似的通用schema
。
我们通过以上设计的标准组件和标准schema
, 就可以批量且高效的生产各种物料, 还可以轻松集成任何第三方可视化组件库。
3.动态渲染器实现
我们都知道, 一个页面中元素很多时会影响页面整体的加载速度, 因为浏览器渲染页面需要消耗CPU / GPU。对于可视化页面来说, 每一个可视化组件都需要渲染大量的信息元, 这无疑会对页面性能造成不小的影响, 所以我们需要设计一种机制, 让组件异步加载到画布上, 而不是一次性加载几十个几百个组件(这样的话页面会有大量的白屏时间, 用户体验极度下降)。
动态加载器就是提供了这样一种机制, 保证组件的加载都是异步的, 一方面可以减少页面体积, 另一方面用户可以更早的看到页面元素。目前我们熟的动态加载机制也有很多, Vue
和 React
生态都提供了开箱即用的解决方案(虽然我们可以用 webpack
自行设计这样的动态模型, 此处为了提高行文效率, 我们直接基于现成方案封装)。我们先看一下动态渲染组件的过程:
上面的演示可以细微的看出从左侧组件菜单拖动某个组件图标到画布上后, 真正的组件才开始加载渲染。
这里我们以 umi3.0
提供的 dynamic
函数来最小化实现一个动态渲染器. 如果不熟悉 umi
生态的朋友, 也不用着急, 看完我的实现过程和原理之后, 就可以利用任何熟悉的动态加载机制实现它了。实现如下:
import React, { useMemo, memo, FC } from 'react'
import { dynamic } from 'umi'
import LoadingComponent from '@/components/LoadingComponent'
const DynamicFunc = (cpName: string, category: string) => {
return dynamic({
async loader() {
// 动态加载组件
const { default: Graph } = await import(`@/components/materies/${cpName}`)
return (props: DynamicType) => {
const { config, id } = props
return <Graph {...config} id={id} />
}
},
loading: () => <LoadingComponent />
})
}
const DynamicRenderEngine: FC<DynamicType> = memo((props) => {
const {
type,
config,
// 其他配置...
} = props
const Dynamic = useMemo(() => {
return DynamicFunc(config)
}, [config])
return <Dynamic {...props} />
})
export default DynamicRenderEngine
是不是很简单? 当然我们也可以根据自身业务需要, 设计更复杂强大的动态渲染器。
4.配置面板设计
实现配置面板的前提是对组件 Schema
结构有一个系统的设计, 在介绍组件库实现中我们介绍了通用组件 schema
的一个设计案例, 我们基于这样的案例结构, 来实现 动态配置面板。
由上图可以知道, 动态配置面板的一个核心要素就是 表单渲染器。表单渲染器的目的就是基于属性配置列表 attrs
来动态渲染出对应的表单项。我之前写了一篇文章详细的介绍了表单设计器的技术实现的文章, 大家感兴趣也可以参考一下: Dooring可视化之从零实现动态表单设计器。
我这里来简单实现一个基础的表单渲染器模型:
const FormEditor = (props: FormEditorProps) => {
const { attrs, defaultValue, onSave } = props;
const onFinish = (values: Store) => {
// 保存配置项数据
onSave && onSave(values);
};
const handlechange = (value) => {
// 更新逻辑
}
const [form] = Form.useForm();
return (
<Form
form={form}
{...formItemLayout}
onFinish={onFinish}
initialValues={defaultValue}
onValuesChange={handlechange}
>
{
attrs.map((item, i) => {
return (
<React.Fragment key={i}>
{item.type === 'Number' && (
<Form.Item label={item.name} name={item.key}>
<InputNumber />
</Form.Item>
)}
{item.type === 'Text' && (
<Form.Item label={item.name} name={item.key}>
<Input placeholder={item.placeholder} />
</Form.Item>
)}
{item.type === 'TextArea' && (
<Form.Item label={item.name} name={item.key}>
<TextArea rows={4} />
</Form.Item>
)}
// 其他配置类型
</React.Fragment>
);
})}
</Form>
);
};
如果大家想看更完整的配置面板实现, 可以参考开源项目 H5-Dooring | H5可视化编辑器
我们可以看看最终的配置面板实现效果:
5.控制中心概述 & 功能辅助设计
控制中心的实现主要是业务层的, 没有涉及太多复杂的技术, 所以这里我简单介绍一下。因为可视化大屏页面展示的信息有些可能是私密数据, 只希望一部分人看到, 所以我们需要对页面的访问进行控制。其次由于企业内部业务战略需求, 可能会对页面进行各种验证, 状态校验, 数据更新频率等, 所以我们需要设计一套控制中心来管理。最基本的就是访问控制, 如下:
功能辅助设计 主要是一些用户操作上的优化, 比如快捷键, 画布缩放, 大屏快捷导航, 撤销重做等操作, 这块可以根据具体的产品需求来完善。大家后期设计搭建产品时也可以参考实现。
可视化大屏数据自治探索
目前我们实现的搭建平台可以静态的设计数据源, 也可以注入第三方接口, 如下:
我们可以调用内部接口来实时获取数据, 这块在可视化监控平台用的场景比较多, 方式如下:
参数(params
)编辑区可以自定义接口参数. 代码编辑器笔者这里推荐两款, 大家可以选用:
- react-monaco-editor
- react-codemirror2
使用以上之一可以实现mini
版vscode
, 大家也可以尝试一下.
辅助功能
可视化大屏一键截图 一键截图功能还是沿用H5-Dooring 的快捷截图方案, 主要用于对大屏的分享, 海报制作等需求, 我们可以使用以下任何一个组件实现:
- dom-to-image
- html2canvas
撤销重做撤销重做功能我们可以使用已有的库比如react-undo
, 也可以自己实现, 实现原理:有点链表的意思, 我们将每一个状态存储到数组中, 通过指针来实现撤销重做的功能, 如果要想更健壮一点, 我们可以设计一套“状态淘汰机制”, 设置可保留的最大状态数, 之前的自动淘汰(删除, 更高大上一点的叫出栈). 这样可以避免复杂操作中的大量状态存储, 节约浏览器内存.
标尺参考线 标尺和参考线这里我们自己实现, 通过动态dom渲染来实现参考线在缩放后的动态收缩, 实现方案核心如下:
arr.forEach(el => {
let dom = [...Array.from(el.querySelectorAll('.calibrationNumber'))][0] as HTMLElement;
if (dom) {
dom.style.transform = `translate3d(-4px, -8px, 0px) scale(${(multiple + 0.1).toFixed(
1,
)})`;
}
});
详细源码可参考: H5-Dooring | 参考线设计源码
如果大家有好的建议也欢迎随时交流反馈, 开源不易, 别忘了star哦~
github地址: github.com/MrXujiang/v…
来源:juejin.cn/post/7451246345568387091
从前端的角度出发,目前最具性价比的全栈路线是啥❓❓❓
今年大部分时间都是在编码上和写文章上,但是也不知道自己都学到了啥,那就写篇文章来盘点一下目前的技术栈吧,也作为下一年的参考目标,方便知道每一年都学了些啥。
我的技术栈
首先我先来对整体的技术做一个简单的介绍吧,然后后面再对当前的一些技术进行细分吧。
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 了。
全栈性价比最高的一套技术
最近刷到一个帖子,讲到了
我目前也算是一个小全栈了吧,我也来分享一下我的技术吧:
- NextJs
- React Native
- prisma
- NestJs
- taro (目前还不会,如果有需求就会去学)
剩下的描述也是和他下面那句话一样了(毕业后对技术态度的转变就是什么能让我投入最小,让我最快赚到钱的就是好技术)
总结
学无止境,任重道远。
最后再来提一下这两个开源项目,它们都是我们目前正在维护的开源项目:
如果你想参与进来开发或者想进群学习,可以添加我微信 yunmz777
,后面还会有很多需求,等这个项目完成之后还会有很多新的并且很有趣的开源项目等着你。
来源:juejin.cn/post/7451483063568154639
📊 弃用 Echarts!这一次我选择 - Vue Data UI!
大家好,我是
xy
👨🏻💻。今天,我要向大家隆重推荐一款令人惊艳的可视化图表库——Vue Data UI
,一个赋予用户权力的数据可视化Vue3
组件库!🎉
🌈 前言
Vue Data UI
诞生于一个问题:如果你的仪表板这么好,为什么你的用户要求 CSV 导出功能?
这个开源库
的目的是为最终用户提供一组围绕图表和表格的内置工具,以减少重新计算导出数据的麻烦。当然,Vue Data UI 保留了导出为 CSV 和 PDF 的选项,以防万一。
数据,是现代商业决策的基石。但如何将复杂的数据转化为直观、易理解的视觉信息?这正是 Vue Data UI
致力于解决的问题。
🚀 丰富的图表类型,颜值爆表
探索数据的无限可能,Vue Data UI
带你领略数据之美!目前官方共提供 54 种 可视化组件,满足您的各种需求:
- 🌟 迷你图表:小巧精致,适合快速展示数据。
- 📈 折线图:流畅的线条,清晰展现数据趋势。
- 🍕 饼图:直观展示数据占比,一目了然。
- 📋 仪表盘:动态展示关键指标,提升决策效率。
- 🔍 雷达图:全面展示多变量数据,洞察数据全貌。
- 🎨 3D 图表:立体展示数据,增强视觉冲击力。
- 🚀 其它:更多组件查看-vue-data-ui.graphieros.com/examples。
📊 强大的图表生成器
告别繁琐,迎接效率!Vue Data UI
提供了一款超强大的图表可视化生成器
,可视化编辑,所见即所得
- 通过直观的可视化界面,编写数据集,调整配置设置。
- 一切配置皆可可视化,无需再翻阅大量 API 文档。
- 直接复制组件代码,快速集成到您的项目中。
一键复制
组件代码,重点:组件代码
📈 提供高定制化 APi
Vue Data UI
不仅仅是一个图表库,它是您项目中的定制化利器
。提供了丰富的 API
和 插槽
属性,确保您的每一个独特需求都能得到满足。
- 利用提供的 API,您可以对图表的每一个细节进行精细调整。
- 插槽属性让您能够插入自定义的
HTML
或Vue
组件,实现真正的个性化设计。
比如我们需要在一个图表中注入另外一个图表:
注入一个箭头:
🛠️ 易于集成,快速上手
官方文档有很显眼的一句:1 import , 3 props , 54 components
安装
npm i vue-data-ui
# or
yarn add vue-data-ui
组件使用
<script setup>
import { ref } from "vue";
import { VueDataUi } from "vue-data-ui";
import "vue-data-ui/style.css";
const dataset = ref([...]);
const config = ref({...});
</script>
<template>
<div style="width:600px;">
<VueDataUi
component="VueUiXy"
:dataset="dataset"
:config="config"
/>
</div>
</template>
如果您也是一名前端开发
,请一定要尝试下这个可视化组件库
,因为这个可视化库真的太酷啦!
最后给大家送上官网地址:vue-data-ui.graphieros.com/
写在最后
如果觉得本文对你有帮助,希望能够给我点赞支持一下哦 💪 也可以关注wx公众号:
前端开发爱好者
回复加群,一起学习前端技能 公众号内包含很多实战
精选资源教程,欢迎关注
来源:juejin.cn/post/7419272082595708955
🌿一个vue3指令让el-table自动轮播
前言
本文开发的工具,是vue3 element-plus ui库专用的,需要对vue3指令概念有一定的了解
最近开发的项目中,需要对项目中大量的列表实现轮播效果,经过一番折腾.最终决定不使用第三方插件,手搓一个滚动指令.
效果展示
实现思路
第一步先确定功能
- 列表自动滚动
- 鼠标移入停止滚动
- 鼠标移出继续滚动
- 滚轮滚动完成,还可以继续在当前位置滚动
- 元素少于一定条数时,不滚动
滚动思路
通过观察el-table
的结构可以发现el-scrollbar__view
里面放着所有的元素,而el-scrollbar__wrap
是一个固定高度的容器,那么只需要获取到el-scrollbar__wrap
这个DOM,并且再给一个定时器,不断的改变它的scrollTop
值,就可以实现自动滚动的效果,这个值必须要用一个变量来存储,不然会失效
停止和继续滚动思路
设置一个boolean
类型变量,每次执行定时器的时候判断一下,true
就滚动,否则就不滚动
滚轮事件思路
为了每次鼠标在列表中滚动之后,我们的轮播还可以在当前滚动的位置,继续轮播,只需要在鼠标移出的时候,将当前el-scrollbar__wrap
的scrollTop
赋给前面存储的变量,这样执行定时器的时候,就可以继续在当前位置滚动
不滚动的思路
只需要判断el-scrollbar__view
这个容器的高度,是否大于el-scrollbar__wrap
的高度,是就可以滚动,不是就不滚动。
大致的思路是这样的,下面上源码
实现代码
文件名:tableAutoScroll.ts
interface ElType extends HTMLElement {
timer: number | null
isScroll: boolean
curTableTopValue: number
}
export default {
created(el: ElType) {
el.timer = null
el.isScroll = true
el.curTableTopValue = 0
},
mounted(el: ElType, binding: { value?: { delay?: number } }) {
const { delay = 15 } = binding.value || {}
const tableDom = el.getElementsByClassName(
'el-scrollbar__wrap'
)[0] as HTMLElement
const viewDom = el.getElementsByClassName(
'el-scrollbar__view'
)[0] as HTMLElement
const onMouseOver = () => (el.isScroll = false)
const onMouseOut = () => {
el.curTableTopValue = tableDom.scrollTop
el.isScroll = true
}
tableDom.addEventListener('mouseover', onMouseOver)
tableDom.addEventListener('mouseout', onMouseOut)
el.timer = window.setInterval(() => {
const viewDomClientHeight = viewDom.scrollHeight
const tableDomClientHeight = el.clientHeight
if (el.isScroll && viewDomClientHeight > tableDomClientHeight) {
const curScrollPosition = tableDom.clientHeight + el.curTableTopValue
el.curTableTopValue =
curScrollPosition === tableDom.scrollHeight
? 0
: el.curTableTopValue + 1
tableDom.scrollTop = el.curTableTopValue
}
}, delay)
},
unmounted(el: ElType) {
if (el.timer !== null) {
clearInterval(el.timer)
}
el.timer = null
const tableDom = el.getElementsByClassName(
'el-scrollbar__wrap'
)[0] as HTMLElement
tableDom.removeEventListener('mouseover', () => (el.isScroll = false))
tableDom.removeEventListener('mouseout', () => {
el.curTableTopValue = tableDom.scrollTop
el.isScroll = true
})
},
}
上面代码中,我在 created中初始化了三个变量,分别用于存储,定时器对象 、是否滚动判断、滚动当前位置。
在 mounted中我还获取了一个options,主要是为了可以定制滚动速度
用法
- 将这段代码放在你的文件夹中
- 在
main.ts
中注册这个指令
import tableAutoScroll from './modules/tableAutoScroll.ts'
const directives: any = {
tableAutoScroll,
}
/**
* @function 批量注册指令
* @param app vue 实例对象
*/
export const install = (app: any) => {
Object.keys(directives).forEach((key) => {
app.directive(key, directives[key]) // 将每个directive注册到app中
})
}
我这边是将自己的弄了一个批量注册,正常使用就像官网里面注册指令就可以了
在需要滚动的el-table
上使用这个指令就可以
<!-- element 列表滚动指令插件 -->
<template>
<div class="container">
<el-table v-tableAutoScroll :data="tableData" height="300">
<el-table-column prop="date" label="时间" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="address" label="Address" />
</el-table>
<!-- delay:多少毫秒滚动一次 -->
<el-table
v-tableAutoScroll="{
delay: 50,
}"
:data="tableData"
height="300"
>
<el-table-column prop="date" label="时间" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="address" label="Address" />
</el-table>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const tableData = ref<any>([])
onMounted(() => {
tableData.value = Array.from(Array(100), (item, index) => ({
date: '时间' + index,
name: '名称' + index,
address: '地点' + index,
}))
console.log('👉 ~ tableData.value=Array.from ~ tableData:', tableData)
})
</script>
<style lang="scss" scoped>
.container {
height: 100%;
display: flex;
align-items: flex-start;
justify-content: center;
gap: 100px;
.el-table {
width: 500px;
}
}
</style>
上面这个例子,分别演示两种调用方法,带参数和不带参数
最后
做了这个工具之后,突然有很多思路,打算后面再做几个,做成一个开源项目,一个开源的vue3指令集
来源:juejin.cn/post/7452667228006678540
大屏适配方案--scale
CSS3的scale等比例缩放
宽度比率 = 当前网页宽度 / 设计稿宽度
高度比率 = 当前网页高度 / 设计稿高度
设计稿: 1920 * 1080
适配屏幕:1920 * 1080 3840 * 2160(2 * 2) 7680 * 2160(4 * 2)
方案一:根据宽度比率
进行缩放(超宽屏比如9/16的屏幕会出现滚动条)
方案二:动态计算网页的宽高比,决定根据宽度比率
还是高度比率
进行缩放
首先基于1920 * 1080进行基础的布局,下面针对两种方案进行实现
<!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>
body,
ul {
margin: 0;
padding: 0;
}
body {
width: 1920px;
height: 1080px;
box-sizing: border-box;
/* 在js中添加translate居中 */
position: relative;
left: 50%;
/* 指定缩放的原点在左上角 */
transform-origin: left top;
}
ul {
width: 100%;
height: 100%;
list-style: none;
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
li {
width: 33.333%;
height: 50%;
box-sizing: border-box;
border: 2px solid rgb(198, 9, 135);
font-size: 30px;
}
</style>
</head>
<body>
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
<li>5</li>
<li>6</li>
</ul>
<script>
// ...实现适配方案
</script>
</body>
</html>
方案一:根据宽度比率
进行缩放
// 设计稿尺寸以及宽高比
let targetWidth = 1920;
// html的宽 || body的宽
let currentWidth =
document.documentElement.clientWidth || document.body.clientWidth;
console.log(currentWidth);
// 按宽度计算缩放比率
let scaleRatio = currentWidth / targetWidth;
// 进行缩放
document.body.style = `transform: scale(${scaleRatio})`;
实现效果如下:
这时我们发现在7680 * 2160尺寸下,屏幕根据宽度缩放会出现滚动条,为了解决这个问题,我们就要动态的选择根据宽度缩放还是根据高度缩放。
方案二:动态计算网页的宽高比,决定根据宽度比率
还是高度比率
进行缩放
// 设计稿尺寸以及宽高比
let targetWidth = 1920;
let targetHeight = 1080;
let targetRatio = 16 / 9; // targetWidth /targetHeight
// 当前屏幕html的宽 || body的宽
let currentWidth =
document.documentElement.clientWidth || document.body.clientWidth;
// 当前屏幕html的高 || body的高
let currentHeight =
document.documentElement.clientHeight || document.body.clientHeight;
// 当前屏幕宽高比
let currentRatio = currentWidth / currentHeight;
// 默认 按宽度计算缩放比率
let scaleRatio = currentWidth / targetWidth;
if (currentRatio > targetRatio) {
scaleRatio = currentHeight / targetHeight;
}
// 进行缩放
document.body.style = `transform: scale(${scaleRatio}) translateX(-50%);`;
效果如下:
这样就可以解决在超宽屏幕下出现滚动条的问题,另外我们做了居中的样式处理,这样在超宽屏幕时,两边留白,内容居中展示显得更加合理些。
来源:juejin.cn/post/7359077652416725018
WebSocket太笨重?试试SSE的轻量级魅力!
一、 前言
Hello~ 大家好。我是秋天的一阵风~
关注我时间长一点的同学们应该会了解,我最近算是跟旧项目 “较上劲” 了哈哈哈。
刚发布了一篇清除项目里的“僵尸”文件文章,这不,我又发现了旧项目上的一个问题。请听我慢慢说来~
在2024年12月18日的午后,两点十八分,阳光透过窗帘的缝隙,洒在键盘上。我像往常一样,启动了那个熟悉的本地项目。浏览器的network
面板静静地打开,准备迎接那个等待修复的bug。就在这时,一股尿意突然袭来,我起身,走向了厕所。
当我回来,坐回那把椅子,眼前的一幕让我愣住了。network
面板上,不知何时,跳出了一堆http请求
,它们像是一场突如其来的雨,让人措手不及。我的头皮开始发麻,那种麻,是那种从心底里涌上来的,让人无法忽视的麻。这堆请求,它们似乎在诉说着什么,又或许,它们只是在提醒我,这个世界,有时候,比我们想象的要复杂得多。
好了,矫情的话咱不说了,直接步入正题。😄😄😄

在查看代码以后发现这些频繁的请求是因为我们的项目首页有一个待办任务数量和消息提醒数量的展示,所以之前的同事使用了定时器,每隔十秒钟发送一次请求到后端接口拿数据,这也就是我们常说的轮询做法。
1. 轮询的缺点
我们都知道轮询的缺点有几种:
资源浪费:
- 网络带宽:频繁的请求可能导致不必要的网络流量,增加带宽消耗。
- 服务器负载:每次请求都需要服务器处理,即使是空返回,也会增加服务器的CPU和内存负载。
用户体验:
- 界面卡顿:频繁的请求和更新可能会造成用户界面的卡顿,影响用户体验。
2. websocket的缺点
那么有没有替代轮询的做法呢? 聪明的同学肯定会第一时间想到用websocket
,但是在目前这个场景下我觉得使用websocket
是显得有些笨重。我从以下这几方面对比:
- 客户端实现:
- WebSocket 客户端实现需要处理连接的建立、维护和关闭,以及可能的重连逻辑。
- SSE 客户端实现相对简单,只需要处理接收数据和连接关闭。
- 适用场景:
- WebSocket 适用于需要双向通信的场景,如聊天应用、在线游戏等。
- SSE 更适合单向数据推送的场景,如股票价格更新、新闻订阅等。
- 实现复杂性:
- WebSocket 是一种全双工通信协议,需要在客户端和服务器之间建立一个持久的连接,这涉及到更多的编程复杂性。
- SSE 是单向通信协议,实现起来相对简单,只需要服务器向客户端推送数据。
- 浏览器支持:
- 尽管现代浏览器普遍支持 WebSocket,但 SSE 的支持更为广泛,包括一些较旧的浏览器版本。
- 服务器资源消耗:
- WebSocket 连接需要更多的服务器资源来维护,因为它们是全双工的,服务器需要监听来自客户端的消息。
- SSE 连接通常是单向的,服务器只需要推送数据,减少了资源消耗。
二、 详细对比
对于这三者的详细区别,你可以参考下面我总结的表格:
以下是 WebSocket、轮询和 SSE 的对比表格:
特性 | WebSocket | 轮询Polling | Server-Sent Events (SSE) |
---|---|---|---|
定义 | 全双工通信协议,支持服务器和客户端之间的双向通信。 | 客户端定期向服务器发送请求以检查更新。 | 服务器向客户端推送数据的单向通信协议。 |
实时性 | 高,服务器可以主动推送数据。 | 低,依赖客户端定时请求。 | 高,服务器可以主动推送数据。 |
开销 | 相对较高,需要建立和维护持久连接。 | 较低,但频繁请求可能导致高网络和服务器开销。 | 相对较低,只需要一个HTTP连接,服务器推送数据。 |
浏览器支持 | 现代浏览器支持,需要额外的库来支持旧浏览器。 | 所有浏览器支持。 | 现代浏览器支持良好,旧浏览器可能需要polyfill。 |
实现复杂性 | 高,需要处理连接的建立、维护和关闭。 | 低,只需定期发送请求。 | 中等,只需要处理服务器推送的数据。 |
数据格式 | 支持二进制和文本数据。 | 通常为JSON或XML。 | 仅支持文本数据,通常为JSON。 |
控制流 | 客户端和服务器都可以控制消息发送。 | 客户端控制请求发送频率。 | 服务器完全控制数据推送。 |
安全性 | 需要wss://(WebSocket Secure)来保证安全。 | 需要https://来保证请求的安全。 | 需要SSE通过HTTPS提供,以保证数据传输的安全。 |
适用场景 | 需要双向交互的应用,如聊天室、实时游戏。 | 适用于更新频率不高的场景,如轮询邮箱。 | 适用于服务器到客户端的单向数据流,如股票价格更新。 |
跨域限制 | 默认不支持跨域,需要服务器配置CORS。 | 默认不支持跨域,需要服务器配置CORS。 | 默认不支持跨域,需要服务器配置CORS。 |
重连机制 | 客户端可以实现自动重连逻辑。 | 需要客户端实现重连逻辑。 | 客户端可以监听连接关闭并尝试重连。 |
服务器资源 | 较高,因为需要维护持久连接。 | 较低,但频繁的请求可能增加服务器负担。 | 较低,只需要维护一个HTTP连接。 |
这个表格概括了 WebSocket、轮询和 SSE 在不同特性上的主要对比点。每种技术都有其适用的场景和限制,选择合适的技术需要根据具体的应用需求来决定。
三、 SSE(Server-Sent Events)介绍
我们先来简单了解一下什么是Server-Sent Events
?
Server-Sent Events (SSE)
是一种允许服务器主动向客户端浏览器推送数据的技术。它基于 HTTP 协议
,但与传统的 HTTP 请求-响应模式不同,SSE 允许服务器在建立连接后,通过一个持久的连接不断地向客户端发送消息。
工作原理
- 建立连接:
- 客户端通过一个普通的 HTTP 请求订阅一个 SSE 端点。
- 服务器响应这个请求,并保持连接打开,而不是像传统的 HTTP 响应那样关闭连接。
- 服务器推送消息:
- 一旦服务器端有新数据可以发送,它就会通过这个持久的连接向客户端发送一个事件。
- 每个事件通常包含一个简单的文本数据流,遵循特定的格式。
- 客户端接收消息:
- 客户端监听服务器发送的事件,并在收到新数据时触发相应的处理程序。
- 连接管理:
- 如果连接由于任何原因中断,客户端可以自动尝试重新连接。
著名的计算机科学家林纳斯·托瓦兹(Linus Torvalds) 曾经说过:talk is cheap ,show me your code
。
我们直接上代码看看效果:
java代码
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("platform/todo")
public class TodoSseController {
private final ExecutorService executor = Executors.newCachedThreadPool();
@GetMapping("/endpoint")
public SseEmitter refresh(HttpServletRequest request) {
final SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
executor.execute(() -> {
try {
while (true) { // 无限循环发送事件,直到连接关闭
// 发送待办数量更新
emitter.send(SseEmitter.event().data(5));
// 等待5秒
TimeUnit.SECONDS.sleep(5);
}
} catch (IOException e) {
emitter.completeWithError(e);
} catch (InterruptedException e) {
// 当前线程被中断,结束连接
Thread.currentThread().interrupt();
emitter.complete();
}
});
return emitter;
}
}
前端代码
beforeCreate() {
const eventSource = new EventSource('/platform/todo/endpoint');
eventSource.onmessage = (event) => {
console.log("evebt:",event)
};
eventSource.onerror = (error) => {
console.error('SSE error:', error);
eventSource.close();
};
this.$once('hook:beforeDestroy', () => {
if (eventSource) {
eventSource.close();
}
});
},
改造后的效果


可以看到,客户端只发送了一次http请求,后续所有的返回结果都可以在event.data
里面获取,先不谈性能,对于有强迫症的同学是不是一个很大改善呢?
总结
虽然 SSE
(Server-Sent Events)因其简单性和实时性在某些场景下提供了显著的优势,比如在需要服务器向客户端单向推送数据时,它能够以较低的开销维持一个轻量级的连接,但 SSE 也存在一些局限性。例如,它不支持二进制数据传输,这对于需要传输图像、视频或复杂数据结构的应用来说可能是一个限制。此外,SSE 只支持文本格式的数据流,这可能限制了其在某些数据传输场景下的应用。还有,SSE 的兼容性虽然在现代浏览器中较好,但在一些旧版浏览器中可能需要额外的 polyfill 或者降级方案。
考虑到这些优缺点,我们在选择数据通信策略时,应该基于项目的具体需求和上下文来做出决策。如果项目需要双向通信或者传输二进制数据,WebSocket 可能是更合适的选择。
如果项目的数据更新频率不高,或者只需要客户端偶尔查询服务器状态,传统的轮询可能就足够了。
而对于需要服务器频繁更新客户端数据的场景,SSE 提供了一种高效的解决方案。
总之,选择最合适的技术堆栈需要综合考虑项目的需求、资源限制、用户体验和未来的可维护性。
来源:juejin.cn/post/7451991754561880115
让同事用Cesium写一个测量工具并支持三角测量,他说有点难。。
大家好,我是日拱一卒的攻城师不浪,致力于技术与艺术的融合。这是2024年输出的第39/100篇文章。
可视化&Webgis交流群+V:brown_7778(备注来意)
前言
最近在开发智慧城市的项目,产品想让同事基于Cesium
开发一个测量工具,需要支持长度测量
、面积测量
以及三角测量
,但同事挠了挠头,说做这个有点费劲,还反问了产品:做这功能有啥意义?
产品经理:测量工具在智慧城市中发挥了重要的作用,通过对城市道路,地形,建筑物,场地等的精确测量,确保施工规划能够与现实场景精准吻合,节省人力以及施工成本。
对桥梁、隧道、地铁、管网等城市基础设施进行结构健康监测,安装传感器,实时监测结构体震动以及结构体偏移量
等数据,确保设施安全运行并能够提前发现问题,防患于未然。
开发同事听完,觉得还蛮有道理,看向我:浪浪,如何应对?
我:呐,拿走直接抄!下班请吃铜锅涮肉!
三角测量
先来了解下三角测量
:是一种基于三角形
几何原理的测量方法,用于确定未知点的位置。它通过已知基线(即两个已知点之间的距离)和从这两个已知点测量的角
度,计算出目标点的精确位置。
例如在建筑施工中,工程师使用三角测量法来测量楼体高度
、桥梁等结构的位置
和角度
,确保建筑的精准施工。
代码解析
接下来看下这个MeasureTool
类,主要包含以下功能:
- 坐标转换:整理了地理坐标(WGS84)与笛卡尔坐标(Cartesian)之间的转换功能。
- 拾取功能:通过屏幕坐标拾取场景中的三维位置,并判断该位置是位于模型上、地形上还是椭球体表面。
- 距离测量:绘制线段,并在场景中显示起点和终点之间的距离。
- 面积测量:通过给定的一组坐标,计算它们组成的多边形面积。
- 三角测量:绘制一个三角形来测量水平距离、直线距离和高度差。
坐标转换功能
transformWGS84ToCartesian
: 将WGS84坐标(经度、纬度、高度)转换为Cesium中的三维笛卡尔坐标。transformCartesianToWGS84
: 将Cesium的三维笛卡尔坐标转换为WGS84坐标。
核心代码:
transformWGS84ToCartesian(position, alt) {
return position
? Cesium.Cartesian3.fromDegrees(
position.lng || position.lon,
position.lat,
(position.alt = alt || position.alt),
Cesium.Ellipsoid.WGS84
)
: Cesium.Cartesian3.ZERO;
}
transformCartesianToWGS84(cartesian) {
var ellipsoid = Cesium.Ellipsoid.WGS84;
var cartographic = ellipsoid.cartesianToCartographic(cartesian);
return {
lng: Cesium.Math.toDegrees(cartographic.longitude),
lat: Cesium.Math.toDegrees(cartographic.latitude),
alt: cartographic.height,
};
}
Cesium的Cartesian3.fromDegrees
和Ellipsoid.WGS84.cartesianToCartographic
方法分别用于实现经纬度与笛卡尔坐标系的相互转换。
拾取功能
拾取功能允许通过屏幕像素坐标来获取3D场景中的位置。主要依赖scene.pickPosition
和scene.globe.pick
来实现拾取。
核心代码:
getCatesian3FromPX(px) {
var picks = this._viewer.scene.drillPick(px);
var cartesian = this._viewer.scene.pickPosition(px);
if (!cartesian) {
var ray = this._viewer.scene.camera.getPickRay(px);
cartesian = this._viewer.scene.globe.pick(ray, this._viewer.scene);
}
return cartesian;
}
这里首先尝试从3D模型或地形上拾取位置,如果未能拾取到模型或地形上的点,则尝试通过射线投射到椭球体表面。
距离测量
通过拾取点并记录每个点的坐标,计算相邻两个点的距离,并显示在Cesium场景中。通过ScreenSpaceEventHandler
来捕获鼠标点击和移动事件。
核心代码:
drawLineMeasureGraphics(options = {}) {
var positions = [];
var _handlers = new Cesium.ScreenSpaceEventHandler(this._viewer.scene.canvas);
_handlers.setInputAction(function (movement) {
var cartesian = this.getCatesian3FromPX(movement.position);
positions.push(cartesian);
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
_handlers.setInputAction(function (movement) {
var cartesian = this.getCatesian3FromPX(movement.endPosition);
positions.pop();
positions.push(cartesian);
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
_handlers.setInputAction(function () {
_handlers.destroy();
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK);
}
测距的基本思想是通过鼠标点击获取多个点的坐标,然后计算每两个相邻点的距离。
面积测量
面积测量通过计算多个点围成的多边形的面积,基于Cesium的PolygonHierarchy
实现多边形绘制。
核心代码:
getPositionsArea(positions) {
let ellipsoid = Cesium.Ellipsoid.WGS84;
let area = 0;
positions.push(positions[0]); // 闭合多边形
for (let i = 1; i < positions.length; i++) {
let p1 = ellipsoid.cartographicToCartesian(this.transformWGS84ToCartographic(positions[i - 1]));
let p2 = ellipsoid.cartographicToCartesian(this.transformWGS84ToCartographic(positions[i]));
area += p1.x * p2.y - p2.x * p1.y;
}
return Math.abs(area) / 2.0;
}
这里通过一个简单的多边形面积公式(叉乘)来计算笛卡尔坐标下的面积。
三角测量
三角测量通过拾取三个点,计算它们之间的直线距离
、水平距离
以及高度差
,构建一个三角形并在场景中显示这些信息。
核心代码:
drawTrianglesMeasureGraphics(options = {}) {
var _positions = [];
var _handler = new Cesium.ScreenSpaceEventHandler(this._viewer.scene.canvas);
_handler.setInputAction(function (movement) {
var position = this.getCatesian3FromPX(movement.position);
_positions.push(position);
if (_positions.length === 3) _handler.destroy();
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
该方法核心思想是获取三个点的坐标,通过高度差来构建水平线和垂线,然后显示相应的距离和高度差信息。
使用
封装好,之后,使用起来就非常简单了。
import MeasureTool from "@/utils/cesiumCtrl/measure.js";
const measure = new MeasureTool(viewer);
**
* 测距
*/
const onLineMeasure = () => {
measure.drawLineMeasureGraphics({
clampToGround: true,
callback: (e) => {
console.log("----", e);
},
});
};
/**
* 测面积
*/
const onAreaMeasure = () => {
measure.drawAreaMeasureGraphics({
clampToGround: true,
callback: () => {},
});
};
/**
* 三角量测
*/
const onTrianglesMeasure = () => {
measure.drawTrianglesMeasureGraphics({
callback: () => {},
});
};
最后
这些测量工具都是依赖于Cesium提供的坐标转换、拾取以及事件处理机制,核心思路是通过ScreenSpaceEventHandler
捕捉鼠标事件,获取坐标点,并通过几何算法计算距离、面积和高度。
【完整源码
地址】:github.com/tingyuxuan2…
如果认为有帮助,希望可以给我们一个免费的star
,激励我们持续开源更多代码。
如果想系统学习Cesium,可以看下作者的Cesium系列教程
《Cesium从入门到实战》
,将Cesium的知识点进行串联,让不了解Cesium的小伙伴拥有一个完整的学习路线,学完后直接上手做项目,+作者:brown_7778(备注来意)了解课程细节。
另外有需要进
可视化&Webgis交流群
可以加我:brown_7778(备注来意),也欢迎数字孪生可视化领域
的交流合作。
来源:juejin.cn/post/7424902468243669029
因离线地图引发的惨案
小王最近接到了一个重要的需求,要求在现有系统上实现离线地图功能,并根据不同的经纬度在地图上显示相应的标记(marks)。老板承诺,如果小王能够顺利完成这个任务,他的年终奖就有着落了。
为了不辜负老板的期望并确保自己能够拿到年终奖,小王开始了马不停蹄的开发工作。他查阅了大量文档,研究了各种离线地图解决方案,并一一尝试。经过48小时的连续奋战,凭借着顽强的毅力和专业的技术能力,小王终于成功完成了需求。
他在系统中集成了离线地图,并实现了根据经纬度显示不同区域标记的功能。每个标记都能准确地反映地理位置的信息,系统的用户体验得到了极大的提升。小王的心中充满了成就感和对未来奖励的期待。
然而,天有不测风云。当小王准备向老板汇报工作成果时,却得知一个令人震惊的消息:老板因涉嫌某些违法行为(爬取不当得利)被逮捕了,公司也陷入了一片混乱。年终奖的承诺随之泡汤,甚至连公司未来的发展都蒙上了一层阴影。
尽管如此,小王并没有因此而气馁。这次通过技术让老板成功的获得了编制,他深知只有不断技术的积累和经验的增长才能更好的保护老板。
1.离线地图
首先需要怎么做呢,你需要一个地图瓦片生成器(爬取谷歌、高德、百度等各个平台的地图瓦片,其实就是一张张缩小的图片,这里爬取可以用各种技术手段,但是违法偶,老板就是这么进去的),有个工具推荐:
链接:pan.baidu.com/s/1nflY8-KL…
提取码:yqey
下载解压打开下面的文件
打开了界面就长这样
可以调整瓦片样式
下载速度龟慢,建议开启代理,因为瓦片等级越高数量越多,需要下载的包越大,这里建议下载到11-16级别,根据自己需求
下载完瓦片会保存在自己定义的文件夹,这里不建议放在c盘,会生成以下文件
使用一个文件服务去启动瓦片额静态服务,可以使用http-server
安装http-server
yarn add http-server -g
cd到下载的mapabc目录下
http-server roadmap
本地可以这么做上线后需要使用nginx代理这个静态服务
server {
listen 80;
server_name yourdomain.com; # 替换为你的域名或服务器 IP
root /var/www/myapp/public; # 设置根目录
index index.html; # 设置默认文件
location / {
try_files $uri $uri/ =404;
}
# 配置访问 roadmap 目录下的地图瓦片
location ^~/roloadmap/{
alias /home/d5000/iot/web/roloadmap/;
autoindex on; # 如果你想列出目录内容,可以开启这个选项
}
# 配置其他静态文件的访问(可选)
location /static/ {
alias /var/www/myapp/public/static/;
}
# 其他配置,例如反向代理到应用服务器等
# location /api/ {
# proxy_pass http://localhost:3000;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
}
配置完重启一下ngix即可
对于如何将瓦片结合成一张地图并在vue2中使用,这里采用vueLeaflet,它是在leaflet基础上进行封装的
这个插件需要安装一系列包
yarn add leaflet vue2-leaflet leaflet.markercluster
<l-tile-layer url="http://192.168.88.211:8080/{z}/{x}/{y}.png" ></l-tile-layer>
这里的url就是上面启动的服务,包括端口和ip,要能访问到瓦片
编写代码很简单
<template>
<div class="map">
<div class="search">
<map-search @input_val="inputVal" @select_val="selectVal" />
</div>
<div class="map_container">
<l-map
:zoom="zoom"
:center="center"
:max-bounds="bounds"
:min-zoom="9"
:max-zoom="15"
:key="`${center[0]}-${center[1]}-${zoom}`"
style="height: 100vh; width: 100%"
>
<l-tile-layer
url="http://192.168.88.211:8080/{z}/{x}/{y}.png"
></l-tile-layer>
<l-marker-cluster>
<l-marker
v-for="(marker, index) in markers"
:key="index"
:lat-lng="marker.latlng"
:icon="customIcon"
@click="handleMarkerClick(marker)"
>
<l-tooltip :offset="tooltipOffset">
<div class="popup-content">
<p>设备名称: {{ marker.regionName }}</p>
<p>主线设备数量: {{ marker.endNum }}</p>
<p>边缘设备数量: {{ marker.edgNum }}</p>
</div>
</l-tooltip>
</l-marker>
</l-marker-cluster>
</l-map>
</div>
</div>
</template>
<script>
import { LMap, LTileLayer, LMarker, LPopup, LTooltip, LMarkerCluster } from "vue2-leaflet";
import mapSearch from "./search.vue";
import "leaflet/dist/leaflet.css";
import L from "leaflet";
// import geojsonData from "./city.json"; // 确保这个路径是正确的
import geoRegionData from "./equip.json"; // 确保这个路径是正确的
// 移除默认的图标路径
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: require("leaflet/dist/images/marker-icon-2x.png"),
iconUrl: require("leaflet/dist/images/marker-icon.png"),
shadowUrl: require("leaflet/dist/images/marker-shadow.png"),
});
export default {
name: "Map",
components: {
LMap,
LTileLayer,
LMarker,
LPopup,
LTooltip,
mapSearch,
LMarkerCluster
},
data() {
return {
zoom: 9,
center: [32.0617, 118.7636], // 江苏省的中心坐标
bounds: [
[30.7, 116.3],
[35.1, 122.3],
], // 江苏省的地理边界
markers: geoRegionData,
customIcon: L.icon({
iconUrl: require("./equip.png"), // 自定义图标的路径
iconSize: [21, 27], // 图标大小
iconAnchor: [12, 41], // 图标锚点
popupAnchor: [1, -34], // 弹出框相对于图标的锚点
shadowSize: [41, 41], // 阴影大小(如果有)
shadowAnchor: [12, 41], // 阴影锚点(如果有)
}),
tooltipOffset: L.point(10, 10), // 调整偏移值
};
},
methods: {
inputVal(val) {
// 处理输入值变化
this.center = val;
this.zoom = 15;
},
selectVal(val) {
// 处理选择值变化
this.center = val;
this.zoom = 15;
},
handleMarkerClick(marker) {
this.center = marker.latlng;
this.zoom = 15;
},
},
};
</script>
<style scoped lang="less">
@import "~leaflet/dist/leaflet.css";
@import "~leaflet.markercluster/dist/MarkerCluster.css";
@import "~leaflet.markercluster/dist/MarkerCluster.Default.css";
.map {
width: 100%;
height: 100%;
position: relative;
.search {
position: absolute;
z-index: 1000;
left: 20px;
top: 10px;
padding: 10px; /* 设置内边距 */
}
}
.popup-content {
font-family: Arial, sans-serif;
text-align: left;
}
.popup-content h3 {
margin: 0;
font-size: 16px;
font-weight: bold;
}
.popup-content p {
margin: 4px 0;
font-size: 14px;
}
/deep/.leaflet-control {
display: none !important; /* 隐藏默认控件 */
}
/deep/.leaflet-control-zoom {
display: none !important; /* 隐藏默认控件 */
}
</style>
这里使用遇到一个坑,需要切换地图中心center,需要给l-map绑定一个key="${center[0]}-${center[1]}-${zoom}
",不然每次切换第一次会失败,第二次才能成功
可以给行政区添加范围,这里需要geojson数据,可以在阿里云数据平台上获取
通过组件加载即可
<l-geo-json :geojson="geojson"></l-geo-json>
效果如下
以上方法,不建议使用,如果是商业使用,不建议使用,不然容易被告侵权,最好能是使用官方合法的地图api,例如谷歌、百度、腾讯、高德,这里我使用高德api给兄弟们们展示一下
2.高德在线地图
2.1首先需要在高德的开放平台申请一个账号
创建一个项目,如下,我们需要使用到这个key和密钥,这里如果是公司使用可以使用公司的信息注册一个账号,公司的账号权限高于个人,具体区别如下参看官网
developer.amap.com/api/faq/acc…
2.2如何在框架中使用
因为不想在创建一个react应用了,这里还是用vue2演示,vue2需要下载一个高德提供的npm包
yarn add @amap/amap-jsapi-loader
编写代码
<template>
<div class="map">
<div class="serach">
<map-search @share_id="shareId" @input_val="inputVal" @select_val="selectVal" @change_theme="changeTheme" />
</div>
<div class="map_container" id="container"></div>
</div>
</template>
<script>
import AMapLoader from "@amap/amap-jsapi-loader";
import mapSearch from "./search.vue";
import cityJson from "../../assets/area.json";
window._AMapSecurityConfig = {
//这里是高德开放平台创建项目时生成的密钥
securityJsCode: "xxxx",
};
export default {
name: "mapContainer",
components: { mapSearch },
mixins: [],
props: {},
data() {
return {
map: null,
autoOptions: {
input: "",
},
auto: null,
AMap: null,
placeSearch: null,
searchPlaceInput: "",
polygons: [],
positions: [],
//地图样式配置
inintMapStyleConfig: {
//设置地图容器id
viewMode: "3D", //是否为3D地图模式
zoom: 15, //初始化地图级别
rotateEnable: true, //是否开启地图旋转交互 鼠标右键 + 鼠标画圈移动 或 键盘Ctrl + 鼠标左键画圈移动
pitchEnable: true, //是否开启地图倾斜交互 鼠标右键 + 鼠标上下移动或键盘Ctrl + 鼠标左键上下移动
mapStyle: "amap://styles/whitesmoke", //设置地图的显示样式
center: [118.796877, 32.060255], //初始化地图中心点位置
},
//地图配置
mapConfig: {
key: "xxxxx", // 申请好的Web端开发者Key,首次调用 load 时必填
version: "2.0", // 指定要加载的 JSAPI 的版本,缺省时默认为 1.4.15
plugins: [
"AMap.AutoComplete",
"AMap.PlaceSearch",
"AMap.Geocoder",
"AMap.DistrictSearch",
], // 需要使用的的插件列表,如比例尺'AMap.Scale'等
},
// 实例化DistrictSearch配置
districtSearchOpt: {
subdistrict: 1, //获取边界不需要返回下级行政区
extensions: "all", //返回行政区边界坐标组等具体信息
},
//这里是mark中的设置
icon: {
type: "image",
image: require("../../assets/equip.png"),
size: [15, 21],
anchor: "bottom-center",
fitZoom: [14, 20], // Adjust the fitZoom range for scaling
scaleFactor: 2, // Zoom scale factor
maxScale: 2, // Maximum scale
minScale: 1 // Minimum scale
}
};
},
created() {
this.initMap();
},
methods: {
//初始化地图
async initMap() {
this.AMap = await AMapLoader.load(this.mapConfig);
this.map = new AMap.Map("container", this.inintMapStyleConfig);
//根据地理位置查询经纬度
this.positions = await Promise.all(cityJson.map(async item => {
try {
const dot = await this.queryGeocodes(item.cityName, this.AMap);
return {
...item,
dot: dot
};
} catch (error) {
}
}));
//poi查询
this.addMarker();
//显示安徽省的区域
this.drawBounds("安徽省");
},
//查询地理位置
async queryGeocodes(newValue, AMap) {
return new Promise((resolve, reject) => {
//加载行政区划插件
const geocoder = new AMap.Geocoder({
// 指定返回详细地址信息,默认值为true
extensions: 'all'
});
// 使用地址进行地理编码
geocoder.getLocation(newValue, (status, result) => {
if (status === 'complete' && result.geocodes.length) {
const geocode = result.geocodes[0];
const latitude = geocode.location.lat;
const longitude = geocode.location.lng;
resolve([longitude, latitude]);
} else {
reject('无法获取该地址的经纬度');
}
});
});
},
//结合输入提示进行POI搜索
shareId(val) {
this.autoOptions.input = val;
},
//根据设备搜索
inputVal(val) {
if (val?.length === 0) {
//poi查询
this.addMarker();
//显示安徽省
this.drawBounds("安徽省");
return;
}
var position = val
this.icon.size = [12, 18]
this.map.setCenter(position)
this.queryPoI()
this.map.setZoom(12, true, 1);
},
//修改主题
changeTheme(val) {
const styleName = "amap://styles/" + val;
this.map.setMapStyle(styleName);
},
//区域搜索
selectVal(val) {
if (val && val.length > 0) {
let vals = val[val?.length - 1];
vals = vals.replace(/\s+/g, '');
this.queryPoI()
this.placeSearch.search(vals);
this.drawBounds(vals);
this.map.setZoom(15, true, 1);
}
},
//添加marker
addMarker() {
const icon = this.icon
let layer = new this.AMap.LabelsLayer({
zooms: [3, 20],
zIndex: 1000,
collision: false,
});
// 将图层添加到地图
this.map.add(layer);
// 普通点
let markers = [];
this.positions.forEach((item) => {
const content = `
<div class="custom-info-window">
<div class="info-window-header"><b>${item.cityName}</b></div>
<div class="info-window-body">
<div>边设备数 : ${item.edgNum} 台</div>
<div>端设备数 : ${item.endNum} 台</div>
</div>
</div>
`;
let labelMarker = new AMap.LabelMarker({
position: item.dot,
icon: icon,
rank: 1, //避让优先级
});
const infoWindow = new AMap.InfoWindow({
content: content, //传入字符串拼接的 DOM 元素
anchor: "top-left",
});
labelMarker.on('mouseover', () => {
infoWindow.open(this.map, item.dot);
});
labelMarker.on('mouseout', () => {
infoWindow.close();
});
labelMarker.on('click', () => {
this.map.setCenter(item.dot)
this.queryPoI()
this.map.setZoom(15, true, 1);
})
markers.push(labelMarker);
});
// 一次性将海量点添加到图层
layer.add(markers);
},
//POI查询
queryPoI() {
this.auto = new this.AMap.AutoComplete(this.autoOptions);
this.placeSearch = new this.AMap.PlaceSearch({
map: this.map,
}); //构造地点查询类
this.auto.on("select", this.select);
this.addMarker();
},
//选择数据
select(e) {
this.placeSearch.setCity(e.poi.adcode);
this.placeSearch.search(e.poi.name); //关键字查询查询
this.map.setZoom(15, true, 1);
},
// 行政区边界绘制
drawBounds(newValue) {
//加载行政区划插件
if (!this.district) {
this.map.plugin(["AMap.DistrictSearch"], () => {
this.district = new AMap.DistrictSearch(this.districtSearchOpt);
});
}
//行政区查询
this.district.search(newValue, (_status, result) => {
if (Object.keys(result).length === 0) {
this.$message.warning("未查询到该地区数据");
return
}
if (this.polygons != null) {
this.map.remove(this.polygons); //清除上次结果
this.polygons = [];
}
//绘制行政区划
result?.districtList[0]?.boundaries?.length > 0 &&
result.districtList[0].boundaries.forEach((item) => {
let polygon = new AMap.Polygon({
strokeWeight: 1,
path: item,
fillOpacity: 0.1,
fillColor: "#22886f",
strokeColor: "#22886f",
});
this.polygons.push(polygon);
});
this.map.add(this.polygons);
this.map.setFitView(this.polygons); //视口自适应
});
},
},
};
</script>
<style lang="less" scoped>
.map {
width: 100%;
height: 100%;
position: relative;
.map_container {
width: 100%;
height: 100%;
}
.serach {
position: absolute;
z-index: 33;
left: 20px;
top: 10px;
}
}
</style>
<style>
//去除高德的logo
.amap-logo {
right: 0 !important;
left: auto !important;
display: none !important;
}
.amap-copyright {
right: 70px !important;
left: auto !important;
opacity: 0 !important;
}
/* 自定义 infoWindow 样式 */
.custom-info-window {
font-family: Arial, sans-serif;
padding: 10px;
border-radius: 8px;
background-color: #ffffff;
max-width: 250px;
}
</style>
在子组件中构建查询
<template>
<div class="box">
<div class="input_area">
<el-input placeholder="请输入设备名称" :id="search_id" v-model="input" size="mini" class="input_item" />
<img src="../../assets/input.png" alt="" class="img_logo" />
<span class="el-icon-search search" @click="searchMap"></span>
</div>
<div class="select_area">
<el-cascader :options="options" size="mini" placeholder="选择地域查询" :show-all-levels="false" :props="cityProps"
clearable v-model="cityVal" @change="selectCity"></el-cascader>
</div>
<div class="date_area">
<el-select v-model="themeValue" placeholder="请选择地图主题" size="mini" @change="changeTheme">
<el-option v-for="item in themeOptions" :key="item.value" :label="item.label" :value="item.value">
</el-option>
</el-select>
</div>
</div>
</template>
<script>
import cityRegionData from "../../assets/area"
import cityJson from "../../assets/city.json";
export default {
name: "search",
components: {},
mixins: [],
props: {},
data() {
return {
search_id: "searchId",
input: "",
options: cityRegionData,
cityProps: {
children: "children",
label: "business_name",
value: "business_name",
checkStrictly: true
},
cityVal: "",
themeOptions: [
{ label: "标准", value: "normal" },
{ label: "幻影黑", value: "dark" },
{ label: "月光银", value: "light" },
{ label: "远山黛", value: "whitesmoke" },
{ label: "草色青", value: "fresh" },
{ label: "雅士灰", value: "grey" },
{ label: "涂鸦", value: "graffiti" },
{ label: "马卡龙", value: "macaron" },
{ label: "靛青蓝", value: "blue" },
{ label: "极夜蓝", value: "darkblue" },
{ label: "酱籽", value: "wine" },
],
themeValue: ""
};
},
computed: {},
watch: {},
mounted() {
this.sendId();
},
methods: {
sendId() {
this.$emit("share_id", this.search_id);
},
searchMap() {
console.log(this.input,'ssss');
if (!this.input) {
this.$emit("input_val", []);
return
}
let val = cityJson.find(item => item.equipName === this.input)
if (val) {
this.$emit("input_val", val.dot);
return
}
this.$message.warning("未查询到该设备,请输入正确的设备名称");
},
selectCity() {
this.$emit("select_val", this.cityVal);
},
changeTheme(val) {
this.$emit("change_theme", val);
}
},
};
</script>
<style lang="less" scoped>
.box {
display: flex;
.input_area {
position: relative;
width: 170px;
height: 50px;
display: flex;
align-items: center;
.input_item {
width: 100%;
/deep/ .el-input__inner {
padding-left: 30px !important;
}
}
.img_logo {
position: absolute;
left: 5px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
margin-right: 10px;
}
span {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
font-size: 16px;
color: #ccc;
cursor: pointer;
}
}
.select_area {
width: 150px;
display: flex;
align-items: center;
height: 50px;
margin-left: 10px;
}
.date_area {
width: 150px;
display: flex;
align-items: center;
height: 50px;
margin-left: 10px;
}
}
</style>
效果如下
来源:juejin.cn/post/7386650134744596532
用Three.js搞个炫酷雷达扩散和扫描特效
1.画点建筑模型
添加光照,开启阴影
//开启renderer阴影
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
//设置环境光
const light = new THREE.AmbientLight(0xffffff, 0.6); // soft white light
this.scene.add(light);
//夜晚天空蓝色,假设成蓝色的平行光
const dirLight = new THREE.DirectionalLight(0x0000ff, 3);
dirLight.position.set(50, 50, 50);
this.scene.add(dirLight);
平行光设置阴影
//开启阴影
dirLight.castShadow = true;
//阴影相机范围
dirLight.shadow.camera.top = 100;
dirLight.shadow.camera.bottom = -100;
dirLight.shadow.camera.left = -100;
dirLight.shadow.camera.right = 100;
//阴影影相机远近
dirLight.shadow.camera.near = 1;
dirLight.shadow.camera.far = 200;
//阴影贴图大小
dirLight.shadow.mapSize.set(1024, 1024);
- 平行光的阴影相机跟正交相机一样,因为平行光的光线是平行的,就跟视线是平行一样,切割出合适的阴影视角范围,用于计算阴影。
- shadow.mapSize设置阴影贴图的宽度和高度,值越高,阴影的质量越好,但要花费计算时间更多。
增加建筑
//添加一个平面
const pg = new THREE.PlaneGeometry(100, 100);
//一定要用受光材质才有阴影效果
const pm = new THREE.MeshStandardMaterial({
color: new THREE.Color('gray'),
transparent: true,//开启透明
side: THREE.FrontSide//只有渲染前面
});
const plane = new THREE.Mesh(pg, pm);
plane.rotateX(-Math.PI * 0.5);
plane.receiveShadow = true;//平面接收阴影
this.scene.add(plane);
//随机生成建筑
this.geometries = [];
const helper = new THREE.Object3D();
for (let i = 0; i < 100; i++) {
const h = Math.round(Math.random() * 15) + 5;
const x = Math.round(Math.random() * 50);
const y = Math.round(Math.random() * 50);
helper.position.set((x % 2 ? -1 : 1) * x, h * 0.5, (y % 2 ? -1 : 1) * y);
const geometry = new THREE.BoxGeometry(5, h, 5);
helper.updateWorldMatrix(true, false);
geometry.applyMatrix4(helper.matrixWorld);
this.geometries.push(geometry);
}
//长方体合成一个形状
const mergedGeometry = BufferGeometryUtils.mergeGeometries(this.geometries, false);
//建筑贴图
const texture = new THREE.TextureLoader().load('assets/image.jpg');
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
const material = new THREE.MeshStandardMaterial({ map: texture,transparent: true });
const cube = new THREE.Mesh(mergedGeometry, material);
//形状产生阴影
cube.castShadow = true;
//形状接收阴影
cube.receiveShadow = true;
this.scene.add(cube);
效果就是很多高楼大厦的样子,为什么楼顶有窗?别在意这些细节,有的人就喜欢开天窗呢~
2.搞个雷达扩散和扫描特效
改变建筑材质shader,计算建筑的俯视uv
material.onBeforeCompile = (shader, render) => {
this.shaders.push(shader);
//范围大小
shader.uniforms.uSize = { value: 50 };
shader.uniforms.uTime = { value: 0 };
//修改顶点着色器
shader.vertexShader = shader.vertexShader.replace(
'void main() {',
` uniform float uSize;
varying vec2 vUv;
void main() {`
);
shader.vertexShader = shader.vertexShader.replace(
'#include <fog_vertex>',
`#include <fog_vertex>
//计算相对于原点的俯视uv
vUv=position.xz/uSize;`
);
//修改片元着色器
shader.fragmentShader = shader.fragmentShader.replace(
'void main() {',
`varying vec2 vUv;
uniform float uTime;
void main() {`
);
shader.fragmentShader = shader.fragmentShader.replace(
'#include <dithering_fragment>',
`#include <dithering_fragment>
//渐变颜色叠加
gl_FragColor.rgb=gl_FragColor.rgb+mix(vec3(0,0.5,0.5),vec3(1,1,0),vUv.y);`
);
};
然后你将同样的onBeforeCompile函数赋值给平面的时候,没有对应的效果。
因为平面没有z,只有xy,而且经过了-90度旋转后,坐标位置也要对应反转,由此可以得出平面的uv计算公式
vUv=vec2(position.x,-position.y)/uSize;
至此,建筑和平面的俯视uv一致了。
雷达扩散特效
- 雷达扩散就是一段渐变的环,随着时间扩大。
- 顶点着色器不变,改一下片元着色器,增加扩散环颜色uColor,对应shader.uniforms也要添加
shader.uniforms.uColor = { value: new THREE.Color('#00FFFF') };
const fragmentShader1 = `varying vec2 vUv;
uniform float uTime;
uniform vec3 uColor;
uniform float uSize;
void main() {`;
const fragmentShader2 = `#include <dithering_fragment>
//计算与中心的距离
float d=length(vUv);
if(d >= uTime&&d<=uTime+ 0.1) {
//扩散圈
gl_FragColor.rgb = gl_FragColor.rgb+mix(uColor,gl_FragColor.rgb,1.0-(d-uTime)*10.0 )*0.5 ;
}`;
shader.fragmentShader = shader.fragmentShader.replace('void main() {', fragmentShader1);
shader.fragmentShader = shader.fragmentShader.replace(
'#include <dithering_fragment>',
fragmentShader2);
//改变shader的时间变量,动起来
animateAction() {
if (this.shaders?.length) {
this.shaders.forEach((shader) => {
shader.uniforms.uTime.value += 0.005;
if (shader.uniforms.uTime.value >= 1) {
shader.uniforms.uTime.value = 0;
}
});
}
}
噔噔噔噔,完成啦!是立体化的雷达扩散,看起来很酷的样子。
雷达扫描特效
跟上面雷达扩散差不多,只要修改一下片元着色器
- 雷达扫描是通过扇形渐变形成的,还要随着时间旋转角度,shaderToy参考链接
const fragmentShader1 = `varying vec2 vUv;
uniform float uTime;
uniform vec3 uColor;
uniform float uSize;
//旋转角度矩阵
mat2 rotate2d(float angle)
{
return mat2(cos(angle), - sin(angle),
sin(angle), cos(angle));
}
//雷达扫描渐变扇形
float vertical_line(in vec2 uv)
{
if (uv.y > 0.0 && length(uv) < 1.2)
{
float theta = mod(180.0 * atan(uv.y, uv.x)/3.14, 360.0);
float gradient = clamp(1.0-theta/90.0,0.0,1.0);
return 0.5 * gradient;
}
return 0.0;
}
void main() {`;
const fragmentShader2 = `#include <dithering_fragment>
mat2 rotation_matrix = rotate2d(- uTime*PI*2.0);
//将雷达扫描扇形渐变混合到颜色中
gl_FragColor.rgb= mix( gl_FragColor.rgb, uColor, vertical_line(rotation_matrix * vUv)); `;
GitHub地址
来源:juejin.cn/post/7349837128508964873
粒子特效particles.js
效果图
版本:"particles.js": "^2.0.0"
npm i particles.js
Vue2版本
组件代码:src/App.vue
<template>
<div class="particles-js-box">
<div id="particles-js"></div>
</div>
</template>
<script>
import particlesJs from "particles.js";
import particlesConfig from "./assets/particles.json";
export default {
data() {
return {};
},
mounted() {
this.init();
},
methods: {
init() {
particlesJS("particles-js", particlesConfig);
document.body.style.overflow = "hidden";
},
},
};
</script>
<style scoped>
.particles-js-box {
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: -1;
}
#particles-js {
background-color: #18688d;
width: 100%;
height: 100%;
}
</style>
代码里的json数据:
目录:src/assets/particles.json
{
"particles": {
"number": {
"value": 60,
"density": {
"enable": true,
"value_area": 800
}
},
"color": {
"value": "#ddd"
},
"shape": {
"type": "circle",
"stroke": {
"width": 0,
"color": "#000000"
},
"polygon": {
"nb_sides": 5
},
"image": {
"src": "img/github.svg",
"width": 100,
"height": 100
}
},
"opacity": {
"value": 0.5,
"random": false,
"anim": {
"enable": false,
"speed": 1,
"opacity_min": 0.1,
"sync": false
}
},
"size": {
"value": 3,
"random": true,
"anim": {
"enable": false,
"speed": 40,
"size_min": 0.1,
"sync": false
}
},
"line_linked": {
"enable": true,
"distance": 150,
"color": "#ffffff",
"opacity": 0.4,
"width": 1
},
"move": {
"enable": true,
"speed": 4,
"direction": "none",
"random": false,
"straight": false,
"out_mode": "out",
"bounce": false,
"attract": {
"enable": false,
"rotateX": 100,
"rotateY": 1200
}
}
},
"interactivity": {
"detect_on": "Window",
"events": {
"onhover": {
"enable": true,
"mode": "grab"
},
"onclick": {
"enable": true,
"mode": "push"
},
"resize": true
},
"modes": {
"grab": {
"distance": 140,
"line_linked": {
"opacity": 1
}
},
"bubble": {
"distance": 400,
"size": 40,
"duration": 2,
"opacity": 8,
"speed": 3
},
"repulse": {
"distance": 200,
"duration": 0.4
},
"push": {
"particles_nb": 4
},
"remove": {
"particles_nb": 2
}
}
},
"retina_detect": true
}
Vue3版本
{
"name": "vue3-test",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"dependencies": {
"particles.js": "^2.0.0",
"vue": "^3.5.13"
},
"devDependencies": {
"@eslint/js": "^9.14.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-prettier": "^10.1.0",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^9.30.0",
"prettier": "^3.3.3",
"sass-embedded": "^1.83.0",
"vite": "^6.0.1",
"vite-plugin-vue-devtools": "^7.6.5"
}
}
需要修改 /node_modules/particles.js/particles.js 的代码
修改此 2 处,
我是把它拷贝到 src 目录下
组件使用:跟 vue2 一样,就是上面第8行引入不一样
import particlesJs from "@/particles.js";
来源:juejin.cn/post/7452931747785883684
为什么Rust 是 web 开发的理想选择
为什么Rust 是 web 开发的理想选择
Rust 经常被视为仅仅是一种系统编程语言,但实际上它是一种多用途的通用语言。像 Tauri(用于桌面应用)、Leptos(用于前端开发)和 Axum(用于后端开发)这样的项目表明 Rust 的用途远不止于系统编程。
当我开始学习 Rust 时,我构建了一个网页应用来练习。因为我主要是一名后端工程师,这是我最为熟悉的领域。很快我就意识到 Rust 非常适合做网页开发。它的特性让我有信心构建出可靠的应用程序。让我来解释一下为什么 Rust 是网页编程的理想选择。
错误处理
一段时间以前,我开始学习 Python,那时我被机器学习的热潮所吸引,甚至是在大型语言模型(LLM)热潮之前。我需要让机器学习模型可以被使用,因此我选择了编写一个 Django REST API。在 Django 中获取请求体,你可能会这样写代码:
class User(APIView):
def post(self, request):
body = request.data
这段代码大多数时候都能正常工作。然而,当我意外地发送了一个格式不正确的请求体时,它就不再起作用了。访问数据时抛出了异常,导致返回了 500 状态码的响应。我没有意识到这种访问可能会抛出异常,并且也没有明确的提示。
Rust 通过不抛出异常而是以 Result 形式返回错误作为值来处理这种情况。Result 同时包含值和错误,你必须处理错误才能访问该值。
let body: RequestBody = serde_json::from_slice(&requestData)?;
问号 (?) 表示你想在调用函数中处理错误,将错误向上一级传播。
我认为任何将错误作为值来处理的语言都是正确处理错误的方式。这种方法允许你编写代码时避免出现意外的情况,就像 Python 示例中的那样。
默认不可变性
最近,我的一位同事在我们的一个开源项目上工作,他需要替换一个客户端库为另一个。这是他使用的代码:
newClient(
WithHTTPClient(httpClient), // &http.Client{}
WithEndpoint(config.ApiBasePath),
)
突然间,集成测试开始抛出竞态条件错误,他搞不清楚为什么会这样。他向我求助,我们一起追踪问题回到了这行代码。我们在其他客户端之间共享了这个HTTP客户端,这导致了错误的发生。多个协程在读取客户端,而 WithHttpClient 函数修改了客户端的状态。在同一资源上同时有读线程和写线程会导致未定义的行为或在 Go 语言中引发恐慌。
这又是一个令人不悦的意外。而在 Rust 中,所有变量默认是不可变的。如果你想修改一个变量,你需要显式地声明它,使用 mut 关键字。这有助于 API 客户端理解发生了什么,并避免了意外的修改。
fn with_httpclient(client: &mut reqwest::Client) {}
宏
在像 Java 和 Python 这样的语言中,你们有注解;而在 Rust 中,我们使用宏。注解可以在某些环境下如 Spring 中带来优雅的代码,其中大量的幕后工作是通过反射完成的。虽然 Rust 的宏提供的“魔法”较少,但也同样能产生更清晰的代码。这里有一个 Rust 宏的例子:
sqlx::query_as!(Student, "DELETE FROM student WHERE id = ? RETURNING *", id)
Rust 中的宏会在后台生成代码,编译器在构建过程中会检查这些代码的正确性。通过宏,你甚至可以在编译时扩展编译器检查并验证 SQL 查询,方法是在编译期间生成运行查询的真实数据库上的代码。
这种能够在编译时检查代码正确性的能力开辟了新的可能性,特别是在 web 开发中,我们经常编写原始的数据库语句或 HTML 和 CSS 代码。它帮助我们写出更少 bug 的代码。
这里提到的宏被称为声明式宏。Rust 还有过程式宏,它们更类似于其他语言中的注解。
#[instrument(name = "UserRepository::begin")]
pub async fn begin(&self) {}
核心思想保持不变:在后台生成代码,并在方法调用前后执行一些逻辑,从而确保代码更加健壮和易于维护。
Chaining
来看看这段在 Rust 中优雅的代码:
let key_value = request.int0_inner()
.key_value
.ok_or_else(|| ServerError::InvalidArgument("key_value must be set".to_string()))?;
与这种更为冗长的方法相比:
Optional<KeyValue> keyValueOpt = request.getInner().getKeyValue();
if (!keyValueOpt.isPresent()) {
throw new IllegalArgumentException("key_value must be set");
}
KeyValue keyValue = keyValueOpt.get();
在 Rust 中,我们可以将操作链接在一起,从而得到简洁且易读的代码。但是,为了实现这种流畅的语法,我们通常需要实现诸如 From 这样的特质。
功能性技术大佬们可能会认识并欣赏这种方法,他们有这样的见解是有道理的。我认为任何允许混合函数式和过程式编程的语言都是走在正确的道路上。它为开发者提供了灵活性,让他们可以选择最适合其特定应用场景的方式。
线程安全
这里有没有人曾经因为竞态条件而在生产环境中触发过程序崩溃?我羞愧地承认,我有过这样的经历。是的,这是一个技能问题。当你在启动多个线程的同时对同一内存地址进行修改和读取时,很难不去注意到这个问题。但让我们考虑这样一个例子:
type repo struct {
m map[int]int
}
func (r *repo) Create(i int) {
r.m[i] = i
}
type Server struct {
repo *repo
}
func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) {
s.repo.Create(1)
}
没有显式启动任何线程,乍一看,一切似乎都很好。然而实际上,HTTP 服务器是在多个线程上运行的,这些线程被抽象隐藏了起来。在 web 开发中,这种抽象可能会掩盖与多线程相关的潜在问题。现在,让我们用 Rust 实现相同的功能:
struct repo {
m: std::collections::HashMap<i8, i8>
}
#[post("/maps")]
async fn crate_entry(r: web::Data<repo>) -> HttpResponse {
r.m.insert(1, 2);
HttpResponse::Ok().json(MessageResponse {
message: "good".to_string(),
})
}
当我们尝试编译这个程序时,Rust 编译器将会抛出一个错误:
error[E0596]: cannot
borrow data in an
`Arc` as mutable
--> src\main.rs:117:5
|
117 | r.m.insert(1, 2);
| ^^^ cannot borrow as mutable
|
= help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `Arc<repo>`
很多人说 Rust 的错误信息通常是很有帮助的,这通常是正确的。然而,在这种情况下,错误信息可能会让人感到困惑并且不是立即就能明白。幸运的是,如果知道如何解决,修复方法很简单:只需要添加一个小的互斥锁:
struct repo {
m: HashMap<i8, i8>
}
#[post("/maps")]
async fn create_entry(r: web::Data<Mutex<repo>>) -> HttpResponse {
let mut map = r.lock().await();
map.m.insert(1, 2);
HttpResponse::Ok().json(MessageResponse {
message: "good".to_string(),
})
}
确实非常美妙,编译器能够帮助我们避免这些问题,让我们的代码保持安全和可靠。
空指针解引用
大多数人认为这个问题只存在于 C 语言中,但你也会在像 Java 或 Go 这样的语言中遇到它。这里是一个典型问题的例子:
type valueObject struct {
value *int
}
func getValue(vo *valueObject) int {
return *vo.value
}
你可能会说,“在使用值之前检查它是否为 nil 就好了。”这是 Go 语言中最大的陷阱之一 —— 它的指针机制。有时候我们会优化内存分配,有时候我们使用指针来表示可选值。
空指针解引用的风险在处理接口时尤其明显。
type Repository interface {
Get() int
}
func getValue(r Repository) int {
return r.Get()
}
func main() {
getValue(nil)
}
在许多语言中,将空值作为接口的有效选项传递是可以的。虽然代码审查通常会发现这类问题,但我还是见过一些空接口进入开发阶段的情况。在 Rust 中,这类问题是不可能发生的,这是对我们错误的另一层保护:
trait Repository {
fn get(&self) -> i32;
}
fn get_value(r: impl Repository) -> i32 {
r.get()
}
fn main() {
get_value(std::ptr::null());
}
Not to mention that it does not compile.
更不用说这段代码根本无法编译。
我承认,我是端口和适配器模式的大粉丝,这些模式包括了一些抽象概念。根据复杂度的不同,这些抽象可能是必要的,以便在你的应用程序中创建清晰的边界,从长远来看提高单元测试性和可维护性。批评者的一个论点是性能会下降,因为通常需要动态调度,因为在编译时无法确定具体的接口实现。让我们来看一个 Java 的例子:
@Service
public class StudentServiceImpl implements StudentService {
private final StudentRepository studentRepository;
@Autowired
public StudentServiceImpl(StudentRepository studentRepository) {
this.studentRepository = studentRepository;
}
}
Spring 为我们处理了很多幕后的事务。其中一个特性就是使用 @Autowired 注解来进行依赖注入。当应用程序启动时,Spring 会进行类路径扫描和反射。然而,这种便利性却伴随着性能成本。
在 Rust 中,我们可以创建这些清晰的抽象而不付出性能代价,这得益于所谓的零成本抽象:
struct ServiceImpl<T: Repository> {
repo: T,
}
trait Service{}
fn new_service<T: Repository>(repo: T) -> impl Service {
ServiceImpl { repo: repo }
}
这些抽象在编译时就被处理好,确保在运行时不会有任何性能开销。这使我们能够在不牺牲性能的情况下保持代码的整洁和高效。
数据转换
在企业级应用中,我们经常使用端口和适配器模式来处理复杂的业务需求。这种模式涉及将数据转换成不同层次所需的表示形式。我们可能通过异步通信接收到用户数据作为事件,或者通过同步通信接收到用户数据作为请求。然后,这些数据被转换成领域模型并通过服务和适配器层进行传递。
这就提出了一个问题:数据转换的逻辑应该放在哪里?应该放在领域包中吗?还是放在数据映射所在的包中?我们应该如何调用方法来转换数据?这些问题常常导致整个代码库中出现不一致性。
Rust 在提供一种清晰的方式来处理数据转换方面表现出色,使用 From 特质。如果我们需要将数据从适配器转换到领域,我们只需在适配器中实现 From 特质:
impl From<UserRequest> for domain::DomainUser {
fn from(user: UserRequest) -> Self {
domain::DomainUser {}
}
}
impl From<domain::DomainUser> for UserResponse {
fn from(user: domain::DomainUser) -> Self {
UserResponse {}
}
}
fn create_user(user: UserRequest) -> Result<()> {
let domain_user = domain::upsert_user(user.int0());
send_response(domain_user.int0())?;
Ok(())
}
通过在需要的地方实现 From 特质,Rust 提供了一种一致且直接的方式来处理数据转换,减少了不一致性,并使代码库更加易于维护。
性能
当然,Rust 很快这一点毋庸置疑,但它实际上给我们带来了哪些好处呢?我记得第一次将我的 Django 应用部署到 Kubernetes 上,并使用 kubectl top pods 命令来检查 CPU 和内存使用情况的时候。我很震惊地发现,即使没有任何负载,这个应用也几乎占用了 1GB 的 RAM。Java 也没好到哪里去。后来我发现了像 Rust 和 Go 这样的新语言,意识到事情可以做得更高效。
我查找了一些性能和资源使用方面的基准测试,并发现使用能够高效利用资源的语言可以节省很多成本。这里有一个例子:
Link to the original article
Link to the original article
想象一下,有一个 Lambda 函数被创建用来列出 AWS 账户中的所有存储桶,并确定每个存储桶所在的区域。你可能会认为,进行一些 REST API 调用并使用 for 循环在性能上不会有太大的区别。任何语言都应该能够合理地处理这个任务,对吧?
然而,测试显示 Rust 在执行这项任务时比 Python 快得多,并且使用更少的内存来达到这些执行时间。事实上,他们每百万次调用节省了 6 美元。
来自 web 和 Kubernetes 的背景,在那里我们根据用户负载进行扩缩容,我可以确认高效的资源使用能够节省成本并提高系统的可靠性。每个副本使用较少的资源意味着更多的容器可以装入一个节点。如果每个副本能够处理更多的请求,则总体上需要的副本数量就会减少。高性能和高效的资源利用对于构建成本效益高且可靠的系统至关重要。
我已经在 web 开发领域使用 Rust 三年了,对此我非常满意。那些具有挑战性的方面,比如编写异步代码或宏,都被我们使用的库很好地处理了。例如,如果你研究过 Tokio 库,你会知道它可能相当复杂。但在 web 开发中,我们专注于业务逻辑并与外部系统(如数据库或消息队列)交互,我们得以享受更简单的一面,同时受益于 Rust 出色的安全特性。
试试 Rust 吧;你可能会喜欢它,甚至成为一名更好的程序员。
来源:juejin.cn/post/7399288740908531712
我:CSS,你怎么变成紫色了?CSS:别管这些,都快2025了,这些新特性你还不知道吗?🤡
事情起因是这样的,大二的苦逼学生在给老外做页面的时候,做着做着无意间瞥见了css的图标。
wait!你不是我认识的css!你是谁?
我的天呐,你怎么成了这种紫色方块?(如果只关心为什么图标换了,可以直接跳到文章末尾)
这提起了我的兴趣,立马放下手中的工作去了解。查才知道,这是有原因的,而且在这之间CSS也更新了很多新特性。
不过看了许多博客,发现没啥人说这件事(也可能是我没找到),所以到我来更新了!😄
这里主要谈谈我认为还算有点用的新特性,全文不长,如果对您有用的话,麻烦给个赞和收藏加关注呗🙏!如果可以的话,掘金人气作者评选活动给孩子投一票吧😭
先叠个甲:所有观点纯本菜🐔意淫,各位大佬地方看个乐呵就行。
参考文献:张鑫旭的个人主页 » 张鑫旭-鑫空间-鑫生活
和MDN Web Docs
块级元素居中新方式:Align Content for Block Elements
元素垂直居中对齐终于有了专门的CSS属性,之前Flex布局和Grid布局中使用的align-content
属性,现在已经可以在普通的block块级元素中使用。
垂直居中的快级元素不再需要 flex 或 grid,注意是在垂直居中!!!
display: block; <-非块级元素请加上这个代码
align-content: center;
不过我好像以前用过,不过用的很少,不知道是不是发生了改变造成了这种效果🤡
请看如下代码
<style>
.father {
display: block;
align-content: center;
background-color: aqua;
width: 300px;
height: 300px
}
.son {
width: 100px;
height: 100px;
background-color: red;
}
</style>
可以发现是div是垂直居中显示的
实现效果和用flex是一样的
<style>
.father {
display: flex;
align-item:center
background-color: aqua;
width: 300px;
height: 300px
}
.son {
width: 100px;
height: 100px;
background-color: red;
}
</style>
提醒一下,目前普通元素并不支持justify-content属性,必须Flex布局或者Grid布局。
subgrid
额,这个特性似乎国内没有很多文章讲解,但是我记得之前看过一个统计这个特性在老外那里很受欢迎,所以我还是讲解一下。
subgrid并不是一个CSS属性,而是 grid-template-columns
和grid-template-rows
属性支持的关键字,其使用的场景需要外面已经有个Grid布局,否则……嗯,虽然语法上不会识别为异常,但是渲染结果上却是没有区别的。
例如
.container {
display: grid;
}
.item {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: subgrid;
}
那我们什么时候使用它呢?🤔
当我们想实现这种效果
Grid布局负责大的组织结构,而里面更细致的排版对齐效果,则可以使用subgrid布局。,这对于复杂的嵌套布局特别有用,在 subgrid 出现之前,嵌套网格往往会导致 CSS 变得复杂冗长。(其实你用flex也可以)
子网格允许子元素与父网格无缝对齐,从而简化了这一过程。
.container {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
.item {
display: grid;
grid-template-rows: subgrid;
grid-row: span 4;
gap: .5rem;
}
/* 以下CSS与布局无关 */
.item {
padding: .75rem;
background: #f0f3f9;
}
.item blockquote {
background-color: skyblue;
}
.item h4 {
background-color: #333;
color: #fff;
}
<div class="container">
<section class="item">
<h4>1</h4>
<p>负责人:张三</p>
<blockquote>脑子和我只能活一个</blockquote>
<footer>3人参与 12月21日</footer>
</section>
<section class="item">
<h4>1</h4>
<p>负责人:张三</p>
<blockquote>脑子和我只能活一个</blockquote>
<footer>3人参与 12月21日</footer>
</section>
</div>
效果
@property
@property规则属于CSS Houdini中的一个特性,可以自定义CSS属性的类型,这个特性在现代CSS开发中还是很有用的,最具代表性的例子就是可以让CSS变量支持动画或过渡效果。
我个人认为这个东西最大的作用就是在我们写颜色渐变的时候很好避免使用var()
不小心造成颜色继承的,而导致效果不理想。
用法
@property --rotation {
syntax: "<angle>";
inherits: false;
initial-value: 45deg;
}
描述符
syntax
描述已注册自定义属性允许的值类型的字符串。可以是数据类型名称(例如<color>
、<length>
或<number>
等),带有乘数(+
、#
)和组合符(|
),或自定义标识。inherits
一个布尔值,控制指定的自定义属性注册是否默认@property
继承。initial-value
设置属性的起始值的值。
描述
注意
简单演示
@property --box-pink {
syntax: "<color>";
inherits: false;
initial-value: pink;
}
.box {
width: 100px;
height: 100px;
background-color: var(--box-pink);
}
使用它进行颜色渐变
@property --colorA {
syntax: "<color>";
inherits: false;
initial-value: red;
}
@property --colorB {
syntax: "<color>";
inherits: false;
initial-value: yellow;
}
@property --colorC {
syntax: "<color>";
inherits: false;
initial-value: blue;
}
.box {
width: 300px;
height: 300px;
background: linear-gradient(45deg,
var(--colorA),
var(--colorB),
var(--colorC));
animation: animate 3s linear infinite alternate;
}
@keyframes animate {
20% {
--colorA: blue;
--colorB: #F57F17;
--colorC: red;
}
40% {
--colorA: #FF1744;
--colorB: #5E35B1;
--colorC: yellow;
}
60% {
--colorA: #E53935;
--colorB: #1E88E5;
--colorC: #4CAF50;
}
80% {
--colorA: #76FF03;
--colorB: teal;
--colorC: indigo;
}
}
</style>
transition-behavior让display none也有动画效果
大家都知道我们在设置一个元素隐藏和出现是一瞬间的,那有没有办法让他能出现类似于淡入淡出的动画效果呢?
这里我们就要介绍transition-behavior了,但是也有其他方法,这里就只介绍它。
语法如下:
transition-behavior: allow-discrete;
transition-behavior: normal;
allow-discrete
表示允许离散的CSS属性也支持transition
过渡效果,其中,最具代表性的离散CSS属性莫过于display属性了。
使用案例
仅使用transition
属性,实现元素从 display:inline ↔ none 的过渡效果。
img {
transition: .25s allow-discrete;
opacity: 1;
height: 200px;
}
img[hidden] {
opacity: 0;
}
<button id="trigger">图片显示与隐藏</button>
<img id="target" src="./1.jpg" />
trigger.onclick = function () {
target.toggleAttribute('hidden');
};
这里我们可以发现消失的时候是有淡出效果的,但是出现却是一瞬间的,这是为什么?
原因是:
display:none
到display:block
的显示是突然的,在浏览器的渲染绘制层面,元素display计算值变成block和opacity设为1是在同一个渲染帧完成的,由于没有起始opacity,所以看不到动画效果。
那有没有什么办法能解决呢?🤔
使用@starting-style规则声明过渡起点
@starting-style
顾名思义就是声明起始样式,专门用在transition过渡效果中。
例如上面的例子,要想让元素display显示的时候有淡出效果,很简单,再加三行代码就可以了:
img {
transition: .25s allow-discrete;
opacity: 1;
@starting-style {
opacity: 0;
}
}
或者不使用CSS嵌套语法,这样写也是可以的:
img {
transition: .25s allow-discrete;
opacity: 1;
}
@starting-style {
img {
opacity: 0;
}
}
此时,我们再点击按钮让图片显示,淡入淡出效果就都有了。
注意:
@starting-style
仅与 CSS 过渡相关。使用CSS 动画实现此类效果时,@starting-style
就不需要。
light-dark
先说明一下,我认为 CSS 的新 light-dark() 函数是 2024 年实现暗模式的最佳方式!
你自 2019 年以来,开发人员只需一行 CSS 就可以为整个站点添加暗模式?只需在 :root 中添加 color-scheme: light dark;,就可以获得全站暗模式支持——但它只适用于未指定颜色的元素,因此使用默认的浏览器颜色。
如果你想让自定义颜色的暗模式生效(大多数网站都需要),你需要将每个颜色声明包装在笨拙的 @media (prefers-color-scheme: ...) 块中:
@media (prefers-color-scheme: dark) {
body {
color: #fff;
background-color: #222;
}
}
@media (prefers-color-scheme: light) {
body {
color: #333;
background-color: #fff;
}
}
基本上,你需要把每个颜色声明写两遍。糟糕!这种冗长的语法使得编写和维护都很麻烦。因此,尽管 color-scheme
已发布五年,但从未真正流行起来。
light-dark很好解决了这个问题
基本语法
/* Named color values */
color: light-dark(black, white);
/* RGB color values */
color: light-dark(rgb(0 0 0), rgb(255 255 255));
/* Custom properties */
color: light-dark(var(--light), var(--dark));
body {
color-scheme: light dark; /* 启用浅色模式和深色模式 */
color: light-dark(#333, #fff); /* 文本浅色和深色颜色 */
background-color: light-dark(#fff, #222); /* 背景浅色和深色颜色 */
}
在这个示例代码中,正文文本在浅色模式下定义为 #333
,在深色模式下定义为 #fff
,而背景色则分别定义为 #fff
和 #222
。就这样!浏览器会根据用户的系统设置自动选择使用哪种颜色。
无需 JavaScript 逻辑、自定义类或媒体查询。一切都能正常工作!
:user-vaild pseudo class
:user-valid
CSS伪类表示任何经过验证的表单元素,其值根据其验证约束正确验证。然而,与:valid
此不同的是,它仅在用户与其交互后才匹配。
有什么用呢?🤔
这就很好避免了我们在进行表单验证的时候,信息提示在你交互之前出现的尴尬。
<form>
<label for="email">Email *: </label>
<input
id="email"
name="email"
type="email"
value="test@example.com"
required />
<span></span>
</form>
input:user-valid {
border: 2px solid green;
}
input:user-valid + span::before {
content: "😄";
}
在以下示例中,绿色边框和😄仅在用户与字段交互后显示。我们将电子邮件地址更改为另一个有效的电子邮件地址就可以看到了
interpolate-size
interpolate-size
和calc-size()
函数属性的设计初衷是一致的,就是可以让width、height等尺寸相关的属性即使值是auto,也能有transition过渡动画效果。
最具代表性的就是height:auto的过渡动画实现。
p {
height: 0;
transition: height .25s;
overflow: hidden;
}
.active+p {
height: auto;
height: calc-size(auto, size);
}
<button onClick="this.classList.toggle('active');">点击我</button>
<p>
<img src="./1.jpg" width="256" />
</p>
其实,要让height:auto
支持transition过渡动画,还有个更简单的方法,就是在祖先容器上设置:
interpolate-size: allow-keywords;
换句话说,calc-size()
函数是专门单独设置,而interpolate-size
是全局批量设置。
interpolate-size: allow-keywords;
interpolate-size: numeric-only;
/* 全局设置 */
/* :root {
interpolate-size: allow-keywords;
} */
div {
width: 320px;
padding: 1em;
transition: width .25s;
/* 父级设置 */
interpolate-size: allow-keywords;
background: deepskyblue;
}
.active+div {
width: 500px;
}
</style>
<button onClick="this.classList.toggle('active');">点击我</button>
<div>
<img src="./1.jpg" width="256" />
</div>
全新的CSS相对颜色语法-使用from
from的作用我认为是简化了我们让文字自动适配背景色的步骤
我们先来看看用法
p {
color: rgb(from red r g b / alpha);
}
原因:r g b 以及 alpha 实际上是对red
的解构,其计算值分别是255 0 0 / 1(或者100%)。
注意:实际开发,我们不会使用上面这种用法,这里只是为了展示语法作用。
使用from让文字适配背景色
<button id="btn" class="btn">我是按钮</button>
<p>请选择颜色:<input
type="color"
value="#2c87ff"
onInput="btn.style.setProperty('--color', this.value);"
></p>
<p>请选择颜色:<input
type="color"
value="#2c87ff"
onInput="btn.style.setProperty('--color', this.value);"
></p>
rebecca purple(#663399)
好了,最重要的东西来了,关于为什么变成了紫色,其实他们把它叫做rebecca紫,为什么叫这个名字呢?这其实是一个令人悲伤的故事😭。
在关于css这个新颜色以及logo的时候,内部发生了许多争议。
但是相信大部分人都读过CSS The Definitive guide,他的作者Eric A.Myer的女儿在这期间因为癌症去世了
在她去世的前几周,Rebecca说她即将成为一个六岁大的女孩,而becca是一个婴儿的名字。六岁后,他希望每个人都叫他Rebecca,而不是becca。
而那个女孩和病魔抗争,一直坚持到她到六岁,
我无法想象假如我是父亲,失去一个那么可爱的一个六岁的孩子,那个心情有多么痛苦。
最终社区被他的故事感动了,css的logo也就变成了这样。
总结
新特性多到让人麻木,真的非常非常多!!!!😵💫
这些新特性出现的速度比某四字游戏出皮肤的速度还快🚀,关键这些特性浏览器支持情况参差不齐,短时间无法在生产环境应用。
我真的看了非常都非常久,从早上五点起来开始看文档,除去吃饭上课,加上去写文章一直弄到凌晨三点,才选出这么几个我认为还算有点作用的新特性。
而且现有的JavaScript能力已经足够应付目前所有的交互场景,很多新特性没有带来颠覆性的改变,缺少迫切性和必要性,很难被重视。
最后的最后,希望大家的身边的亲人身体都健健康康的,也希望饱受癌症折磨的人们能够早日康复🙏
来源:juejin.cn/post/7450434330672234530