独家授权!广东盈世获网易邮箱反垃圾服务的独家授权,邮件反垃圾更全面
近日,广东盈世计算机科技有限公司(以下简称“Coremail”)成功获得了网易(杭州)网络有限公司(以下简称“网易”)授予的网易邮箱反垃圾服务独家使用权。这一授权使得Coremail能够在邮件安全产品上运用网易邮箱反垃圾服务,进一步强化其反垃圾能力,邮件反垃圾更全面。
凭借24年的反垃圾反钓鱼技术沉淀,Coremail邮件安全致力于提供一站式邮件安全解决方案,为用户提供安全、可靠的安全解决方案。而网易作为国内邮箱行业的佼佼者,拥有强大的技术实力和丰富的经验,其网易邮箱反垃圾服务更是享有盛誉。
通过合作,网易为Coremail提供Saas在线网关服务,进行进信和外发的在线反垃圾检测。Coremail邮件安全反垃圾服务将以自研反垃圾引擎为主,网易反垃圾服务为辅,以“双引擎”机制保障用户享有最高等级的邮件反垃圾服务。
此外,除网易自身、广州网易计算机系统有限公司及其关联公司外,Coremail是唯一被授权在服务期内独家使用网易邮箱反垃圾服务的公司。这一独家授权充分体现了网易对Coremail的高度认可和信任,同时也彰显了Coremail在邮件安全领域的卓越实力。

此次独家授权,为Coremail带来更多的技术优势和市场竞争优势,进一步巩固其在邮件安全领域的领先地位。同时,对于广大用户来说,这也意味着用户将能够享受到更加安全、高效的邮件安全服务。
未来,Coremail将继续秉持技术创新的精神,致力于为用户提供更优质、安全、智能的邮件安全服务。与此同时,Coremail也将与网易保持紧密的合作关系,为企业的邮件安全保驾护航。
收起阅读 »微信小程序避坑scroll-view,用tween.js实现吸附动画
背景
在开发一个小程序项目时,遇到一个需要列表滚动,松手时自动吸附的动画需求,如下(最终效果):

很自然用了scroll-view组件,但使用过程中发现scroll-view 里包含'position: fixed;top:0;'的元素时,应用scroll-with-animation=true,搭配更改scroll-top时,松手后fixed的元素会抖一下......

于是决定不用组件内置的scroll-with-animation,改用手动控制scroll-top实现吸附的效果。
思路
通常,要做动画,我们就得确定以下信息,然后用代码实现:
- 初始状态
- 结束状态
- 动画时长
- 动画过程状态如何变化(匀速/先加速后减速/...)
这四个信息一般从UI/交互那里确认,前三个代码实现很简单,第四个在css动画里(如transition/animation)用 timing-function 指定:

在js动画里,改变css的属性可通过 Web Animations API 里的 easing 属性指定:

而如果需要动画的状态不是css的属性呢(例如上面的scrollTop)?这就要用到补间/插值工具了,tween.js登场!
关于 tween.js
tween翻译有‘补间‘的意思
补间(动画)(来自 in-between)是一个概念,允许你以平滑的方式更改对象的属性。你只需告诉它哪些属性要更改,当补间结束运行时它们应该具有哪些最终值,以及这需要多长时间,补间引擎将负责计算从起始点到结束点的值。
简单点就是tweenjs可以指定状态从初始值到结束值该怎么变化,下面是简单的使用例子:
const box = document.getElementById('box') // 获取我们想要设置动画的元素。
const coords = {x: 0, y: 0} // 从 (0, 0) 开始
const tween = new TWEEN.Tween(coords, false) // 创建一个修改“坐标”的新 tween。
.to({x: 300, y: 200}, 1000) // 在 1 秒内移动到 (300, 200)。
.easing(TWEEN.Easing.Quadratic.InOut) // 使用缓动函数使动画流畅。
.onUpdate(() => {
// 在 tween.js 更新“坐标”后调用。
// 使用 CSS transform 将 'box' 移动到 'coords' 描述的位置。
box.style.setProperty('transform', 'translate(' + coords.x + 'px, ' + coords.y + 'px)')
})
.start() // 立即开始 tween。
// 设置动画循环。
function animate(time) {
tween.update(time)
requestAnimationFrame(animate)
}
requestAnimationFrame(animate)
- 完整用法请看tween.js 用户指南
在微信小程序里使用tween.js
导入适配
下载 github.com/tweenjs/twe… 文件,修改下‘now’的实现,把performance.now()改成Date.now() 即可在小程序里使用:

动画循环
小程序里没有直接支持requestAnimationFrame,这个可以用canvas组件的requestAnimationFrame方法代替:
// wxml
// ...
<canvas type="2d" id="canvas" style="width: 0; height: 0; pointer-events: none; position: fixed"></canvas>
// ...
// js
wx.createSelectorQuery()
.select("#canvas")
.fields({
node: true,
})
.exec((res) => {
this.canvas = res[0].node;
});
// ...
// ...
const renderLoop = () => {
TWEEN.update();
this.canvas.requestAnimationFrame(renderLoop);
};
renderLoop();
其他
锁帧
手动改scrolltop还是得通过setData方法,频繁调用可能会导致动画卡顿,requestAnimationFrame一般1s跑60次,也就是60fps,根据需要可以增加锁帧逻辑:
const fps = 30; // 锁30fps
const interval = 1000 / fps;
let lastTime = Date.now();
const renderLoop = () => {
this.canvas.requestAnimationFrame(renderLoop);
const now = Date.now();
if(now - lastTime > interval){
// 真正的动作在这里运行
TWEEN.update();
lastTime = now;
}
};
renderLoop();
官方支持?
要是 scrollView 组件支持 wxs 更改scrollTop就好了
developers.weixin.qq.com/community/d…
来源:juejin.cn/post/7300771357523820594
前端滑块旋转验证登录
效果图如下

实现: 封装VerifyImg组件
<template>
<el-dialog v-model="dialogShow" width="380px" top="24vh" class="verifyDialog">
<div class="verify-v">
<div
class="check"
@mousedown="onMouseDown"
@mouseup="onMouseUp"
@mousemove="onMouseMove"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<p>拖动滑块使图片角度为正</p>
<div class="img-con">
<img :src="imgUrl" :style="{ transform: imgAngle }" />
<div v-if="showError" class="check-state">验证失败</div>
<div v-else-if="showSuccess" class="check-state">验证成功</div>
<div v-else-if="checking" class="check-state">验证中</div>
</div>
<div
ref="sliderCon"
class="slider-con"
:class="{ 'err-anim': showError }"
:style="{ '--percent': percent, '--bgColor': showError ? bgError : bgColor }"
>
<div ref="slider" class="slider" id="slider" :class="{ sliding }" :style="{ '--move': `${slidMove}px` }">
<el-icon size="22"><Right id="slider" /></el-icon>
</div>
</div>
</div>
</div>
</el-dialog>
</template>
<script>
export default {
data() {
return {
imgUrl: '',
dialogShow: false,
showError: false,
showSuccess: false,
checking: false,
sliding: false,
slidMove: 0,
percent: 0,
sliderConWidth: 0,
bgColor: 'rgba(25, 145, 250, 0.2)',
bgError: 'rgba(255,78,78,0.2)',
imgList: [
new URL(`../../assets/images/verify/fn1.png`, import.meta.url).href,
new URL(`../../assets/images/verify/fn2.png`, import.meta.url).href,
new URL(`../../assets/images/verify/fn3.png`, import.meta.url).href
]
}
},
computed: {
angle() {
let sliderConWidth = this.sliderConWidth ?? 0
let sliderWidth = this.sliderWidth ?? 0
let ratio = this.slidMove / (sliderConWidth - sliderWidth)
return 360 * ratio
},
imgAngle() {
return `rotate(${this.angle}deg)`
}
},
mounted() {
this.imgUrl = this.imgList[this.rand(0, 2)]
},
methods: {
onTouchMove(event) {
console.log('onTouchMove')
if (this.sliding && this.checking === false) {
// 滑块向右的平移距离等于鼠标移动事件的X坐标减去鼠标按下时的初始坐标。
let m = event.touches[0].clientX - this.sliderLeft
if (m < 0) {
// 如果m小于0表示用户鼠标向左移动超出了初始位置,也就是0
// 所以直接等于 0,以防止越界
m = 0
} else if (m > this.sliderConWidth - this.sliderWidth) {
// 滑块向右移动的最大距离是滑槽的宽度减去滑块的宽度。
// 因为css的 translateX 函数是以元素的左上角坐标计算的
// 所以要减去滑块的宽度,这样滑块在最右边时,才不会左上角和滑槽右上角重合。
m = this.sliderConWidth - this.sliderWidth
}
this.slidMove = m
console.log(this.slidMove)
this.percent = ((this.slidMove / document.querySelector('.slider-con').offsetWidth) * 100).toFixed(2) + '%'
}
},
onTouchEnd() {
console.log('onTouchEnd')
if (this.sliding && this.checking === false) {
this.checking = true
this.validApi(this.angle)
.then(isok => {
if (isok) {
this.showSuccess = true
} else {
this.showError = true
}
return new Promise((resolve, reject) => {
// setTimeout(() => {
if (isok) {
resolve(Math.round(this.angle))
} else {
reject()
}
setTimeout(() => {
this.resetSlider()
}, 1000)
// }, 1500)
})
})
.then(angle => {
// 处理业务,或者通知调用者验证成功
console.log(angle)
this.$emit('toLogin')
})
.catch(e => {
console.log(e)
// alert('旋转错误')
})
}
},
onTouchStart(event) {
console.log('onTouchStart', event)
// 设置状态为滑动中
this.sliding = true
// 下面三个变量不需要监听变化,因此不放到 data 中
this.sliderLeft = event.touches[0].clientX // 记录鼠标按下时的x位置
this.sliderConWidth = this.$refs.sliderCon.clientWidth // 记录滑槽的宽度
this.sliderWidth = this.$refs.slider.clientWidth // 记录滑块的宽度
console.log(this.sliderLeft, this.sliderConWidth, this.sliderWidth)
},
rand(m, n) {
return Math.ceil(Math.random() * (n - m + 1) + m - 1)
},
showVerify() {
this.imgUrl = this.imgList[this.rand(0, 2)]
this.dialogShow = true
},
closeVerify() {
//1.5s后关闭弹框
setTimeout(() => {
this.dialogShow = false
}, 1500)
},
// 重置滑块
resetSlider() {
this.sliding = false
this.slidMove = 0
this.checking = false
this.showSuccess = false
this.showError = false
this.percent = 0
},
//拖拽开始
onMouseDown(event) {
console.log(event.target.id, this.checking)
if (event.target.id !== 'slider') {
return
}
if (this.checking) return
// 设置状态为滑动中
this.sliding = true
// 下面三个变量不需要监听变化,因此不放到 data 中
this.sliderLeft = event.clientX // 记录鼠标按下时的x位置
this.sliderConWidth = this.$refs.sliderCon.clientWidth // 记录滑槽的宽度
this.sliderWidth = this.$refs.slider.clientWidth // 记录滑块的宽度
},
//拖拽停止
onMouseUp(event) {
if (this.sliding && this.checking === false) {
this.checking = true
this.validApi(this.angle)
.then(isok => {
if (isok) {
this.showSuccess = true
} else {
this.showError = true
}
return new Promise((resolve, reject) => {
// setTimeout(() => {
if (isok) {
resolve(Math.round(this.angle))
} else {
reject()
}
setTimeout(() => {
this.resetSlider()
}, 1000)
// }, 1500)
})
})
.then(angle => {
// 处理业务,或者通知调用者验证成功
console.log(angle)
this.$emit('toLogin')
})
.catch(e => {
console.log(e)
// alert('旋转错误')
})
}
},
//拖拽进行中
onMouseMove(event) {
if (this.sliding && this.checking === false) {
// 滑块向右的平移距离等于鼠标移动事件的X坐标减去鼠标按下时的初始坐标。
let m = event.clientX - this.sliderLeft
if (m < 0) {
// 如果m小于0表示用户鼠标向左移动超出了初始位置,也就是0
// 所以直接等于 0,以防止越界
m = 0
} else if (m > this.sliderConWidth - this.sliderWidth) {
// 滑块向右移动的最大距离是滑槽的宽度减去滑块的宽度。
// 因为css的 translateX 函数是以元素的左上角坐标计算的
// 所以要减去滑块的宽度,这样滑块在最右边时,才不会左上角和滑槽右上角重合。
m = this.sliderConWidth - this.sliderWidth
}
this.slidMove = m
this.percent = ((this.slidMove / document.querySelector('.slider-con').offsetWidth) * 100).toFixed(2) + '%'
}
},
// 验证角度是否正确
validApi(angle) {
return new Promise((resolve, reject) => {
// 模拟网络请求
setTimeout(() => {
// 图片已旋转的角度
const imgAngle = 90
// 图片已旋转角度和用户旋转角度之和
let sum = imgAngle + angle
// 误差范围
const errorRang = 20
// 当用户旋转角度和已旋转角度之和为360度时,表示旋转了一整圈,也就是转正了
// 但是不能指望用户刚好转到那么多,所以需要留有一定的误差
let isOk = Math.abs(360 - sum) <= errorRang
resolve(isOk)
}, 1000)
})
}
}
}
</script>
<style lang="scss">
.verifyDialog {
.el-dialog__body {
padding: 15px !important;
}
}
</style>
<style lang="scss" scoped>
.verify-v {
display: flex;
justify-content: center;
align-items: center;
}
.check {
--slider-size: 40px;
width: 300px;
background: white;
box-shadow: 0px 0px 12px rgb(0 0 0 / 8%);
border-radius: 5px;
padding: 10px 0;
display: flex;
flex-direction: column;
align-items: center;
.img-con {
position: relative;
overflow: hidden;
width: 120px;
height: 120px;
border-radius: 50%;
margin-top: 20px;
img {
width: 100%;
height: 100%;
user-select: none;
}
.check-state {
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
color: white;
position: absolute;
top: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
}
}
}
.check .slider-con {
width: 80%;
height: var(--slider-size);
border-radius: 3px;
margin-top: 1rem;
position: relative;
background: #f5f5f5;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1) inset;
background: linear-gradient(to right, var(--bgColor) 0%, var(--bgColor) var(--percent), #fff var(--percent), #fff 100%);
.slider {
&:hover {
background: #1991fa;
color: #fff;
}
background: #fff;
width: var(--slider-size);
height: var(--slider-size);
border-radius: 3px;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
cursor: move;
display: flex;
justify-content: center;
align-items: center;
--move: 0px;
transform: translateX(var(--move));
.sliding {
background: #4ed3ff;
}
}
}
.slider-con.err-anim {
animation: jitter 0.5s;
.slider {
background: #ff4e4e;
}
}
body {
padding: 0;
margin: 0;
background: #fef5e0;
}
@keyframes jitter {
20% {
transform: translateX(-5px);
}
40% {
transform: translateX(10px);
}
60% {
transform: translateX(-5px);
}
80% {
transform: translateX(10px);
}
100% {
transform: translateX(0);
}
}
</style>
使用
<VerifyImg ref="verifyRef" @to-login="handleLogin"></VerifyImg>
handleLogin(){
...
}
来源:juejin.cn/post/7358004857889275958
API接口超时,网络波动,不要一直弹Alert了!
前言
前段时间,我司的IoT平台的客户大量上新了一堆设备后,在使用过程中,出现了网络连接超时,服务器错误等问题,客户小题大做,抓着这个尾巴不放手,喷我司系统不健壮,要求我们杜绝API错误。
由于IoT多是日志、设备、测试、Action、图表、通信类的功能,API请求&返回数据量大、时间长。在后端添加索引的情况下,接口仍然达不到秒级,接口普遍都是1~3s,部分接口甚至达到小10s。我司把服务器的硬盘和CPU都进行了一波升级,效果都不是很理想。
这个烫手的山芋,落在了前端的头上。我们被迫进行了一波系统升级优化
前段时间,我司的IoT平台的客户大量上新了一堆设备后,在使用过程中,出现了网络连接超时,服务器错误等问题,客户小题大做,抓着这个尾巴不放手,喷我司系统不健壮,要求我们杜绝API错误。
由于IoT多是日志、设备、测试、Action、图表、通信类的功能,API请求&返回数据量大、时间长。在后端添加索引的情况下,接口仍然达不到秒级,接口普遍都是1~3s,部分接口甚至达到小10s。我司把服务器的硬盘和CPU都进行了一波升级,效果都不是很理想。
这个烫手的山芋,落在了前端的头上。我们被迫进行了一波系统升级优化
解决方案
我们结合这个需求,制定了以下几条标准:
- 不能入侵其他的功能
- 对系统的破坏尽可能的小
- 杜绝或者尽可能的减少弹框问题
- 保证数据的正确展示,对于错误要正确的暴露出来
根据以上几条标准,于是方案就自然的确定了:
我们结合这个需求,制定了以下几条标准:
- 不能入侵其他的功能
- 对系统的破坏尽可能的小
- 杜绝或者尽可能的减少弹框问题
- 保证数据的正确展示,对于错误要正确的暴露出来
根据以上几条标准,于是方案就自然的确定了:
API请求时间
拉长API的请求时间,将超时时间由30s,更新为60s
const service = axios.create({
baseURL: '/xxx',
timeout: 60 * 1000 // 请求超时时间
})
拉长API的请求时间,将超时时间由30s,更新为60s
const service = axios.create({
baseURL: '/xxx',
timeout: 60 * 1000 // 请求超时时间
})
重发机制
- API请求超时: 当新页面大量接口请求,某个接口请求时间过长,总时间
>60s时,我们会对这个接口进行至多重发3次,用180s的时间去处理这个接口,当请求成功后,关闭请求
重发的接口不能超过timeout时长,否则一直会重发失败。也可以自定义重发的时间
- 偶发的服务器异常: 当接口出现50X时,重发一次
可以使用axois自带的方法,也可以使用axios-retry插件,axios-retry插件更简单,底层也是axois方法实现的。相比较而言,axios-retry更简单,但不知为什么,我没有实现
// request.js
// 默认重发3次,每次的间隔时间为3s
service.defaults.retry = 3;
service.defaults.retryDelay = 3000;
export function againRequest(
error,
axios,
time = error.config.retry
) {
const config = error.config;
if (!config || !config.retry) return Promise.reject(error);
// 设置用于记录重试计数的变量 默认为0
config.__retryCount = config.__retryCount || 0;
// 判断是否超过了重试次数
if (config.__retryCount >= time) {
// alert
return Promise.reject(error);
}
config.__retryCount += 1;
const backoff = new Promise(resolve => {
setTimeout(() => {
resolve();
}, config.retryDelay || 1);
});
return backoff.then(() => {
/*
以下三行,根据项目实际调整
这三行是处理重发请求接口变成string,并且重定向的问题
*/
if (config.data && isJsonStr(config.data)) {
config.data = JSON.parse(config.data);
}
config.baseURL = "/";
return axios(config);
});
}
export let isLoop = config => {
if (
config.isLoop &&
config.isLoop.count >= 0 &&
config.url == config.isLoop.url
) {
return true;
}
return false;
};
export let isJsonStr = str => {
if (typeof str == "string") {
try {
var obj = JSON.parse(str);
if (typeof obj == "object" && obj) {
return true;
} else {
return false;
}
} catch (e) {
console.log("error: " + str + "!!!" + e);
return false;
}
}
};
注意到是: axois不能是0.19.x
issue中提到使用0.18.0。经过实践,0.20.x也是可以的,但需要进行不同的配置,具体请看issue。参考资料中有提到 axios issues github
- API请求超时: 当新页面大量接口请求,某个接口请求时间过长,总时间
>60s时,我们会对这个接口进行至多重发3次,用180s的时间去处理这个接口,当请求成功后,关闭请求
重发的接口不能超过timeout时长,否则一直会重发失败。也可以自定义重发的时间
- 偶发的服务器异常: 当接口出现50X时,重发一次
可以使用axois自带的方法,也可以使用axios-retry插件,axios-retry插件更简单,底层也是axois方法实现的。相比较而言,axios-retry更简单,但不知为什么,我没有实现
// request.js
// 默认重发3次,每次的间隔时间为3s
service.defaults.retry = 3;
service.defaults.retryDelay = 3000;
export function againRequest(
error,
axios,
time = error.config.retry
) {
const config = error.config;
if (!config || !config.retry) return Promise.reject(error);
// 设置用于记录重试计数的变量 默认为0
config.__retryCount = config.__retryCount || 0;
// 判断是否超过了重试次数
if (config.__retryCount >= time) {
// alert
return Promise.reject(error);
}
config.__retryCount += 1;
const backoff = new Promise(resolve => {
setTimeout(() => {
resolve();
}, config.retryDelay || 1);
});
return backoff.then(() => {
/*
以下三行,根据项目实际调整
这三行是处理重发请求接口变成string,并且重定向的问题
*/
if (config.data && isJsonStr(config.data)) {
config.data = JSON.parse(config.data);
}
config.baseURL = "/";
return axios(config);
});
}
export let isLoop = config => {
if (
config.isLoop &&
config.isLoop.count >= 0 &&
config.url == config.isLoop.url
) {
return true;
}
return false;
};
export let isJsonStr = str => {
if (typeof str == "string") {
try {
var obj = JSON.parse(str);
if (typeof obj == "object" && obj) {
return true;
} else {
return false;
}
} catch (e) {
console.log("error: " + str + "!!!" + e);
return false;
}
}
};
注意到是: axois不能是0.19.x
issue中提到使用0.18.0。经过实践,0.20.x也是可以的,但需要进行不同的配置,具体请看issue。参考资料中有提到 axios issues github
也可以使用axios-retry
npm install axios-retry
// ES6
import axiosRetry from 'axios-retry';
axiosRetry(axios, { retries: 3 });
取消机制
当路由发生变化时,取消上一个路由正在请求的API接口
监控路由页面: 调用cancelAllRequest方法
// request.js
const pendingRequests = new Set();
service.cancelAllRequest = () => {
pendingRequests.forEach(cancel => cancel());
pendingRequests.clear();
};
轮询
轮询有2种情况,一种是定时器不停的请求,一种是监听请求N次后停止。
比如: 监听高低电平的变化 - 如快递柜的打开&关闭。
- 一直轮询的请求:
- 使用WebSocket
- 连续失败N次后,谈框。
- 轮询N次的请求:
- 连续失败N次后,谈框。
- 连续失败N次后,谈框。
export function api(data, retryCount) {
return request({
url: `/xxx`,
method: "post",
isLoop: {
url: "/xxx",
count: retryCount
},
data: { body: { ...data } }
});
}
自定义api url的原因是:
同一个页面中,有正常的接口和轮询的接口,url是区分是否当前的接口是否是轮询的接口
监听滚动
对于图表类的功能,监听滚动事件,根据不同的高度请求对应的API
节流机制
- 用户连续多次请求同一个API
- 按钮loading。最简单有效
- 保留最新的API请求,取消相同的请求
- 用户连续多次请求同一个API
- 按钮loading。最简单有效
- 保留最新的API请求,取消相同的请求
错误码解析
网络错误 & 断网
if (error.toString().indexOf("Network Error") !== -1) {
if (configData.isLoop && configData.isLoop.count >= 0) {
networkTimeout();
// 关闭所有的定时器
let t = setInterval(function() {}, 100);
for (let i = 1; i <= t; i++) {
clearInterval(i);
}
return;
}
networkTimeout();
}
if (error.toString().indexOf("Network Error") !== -1) {
if (configData.isLoop && configData.isLoop.count >= 0) {
networkTimeout();
// 关闭所有的定时器
let t = setInterval(function() {}, 100);
for (let i = 1; i <= t; i++) {
clearInterval(i);
}
return;
}
networkTimeout();
}
404
else if (error.toString().indexOf("404") !== -1) {
// 404
}
else if (error.toString().indexOf("404") !== -1) {
// 404
}
401
else if (error.toString().indexOf("401") !== -1) {
// 清除token,及相应的数据,返回到登录页面
}
else if (error.toString().indexOf("401") !== -1) {
// 清除token,及相应的数据,返回到登录页面
}
超时
else if (error.toString().indexOf("Error: timeout") !== -1) {
if (!axios.isCancel(error) && !isLoop(configData)) {
return againRequest(error, service);
}
if (configData.isLoop && configData.isLoop.count === 3) {
requestTimeout();
}
}
else if (error.toString().indexOf("Error: timeout") !== -1) {
if (!axios.isCancel(error) && !isLoop(configData)) {
return againRequest(error, service);
}
if (configData.isLoop && configData.isLoop.count === 3) {
requestTimeout();
}
}
50X
else if (error.toString().indexOf("50") !== -1) {
if (!axios.isCancel(error) && !isLoop(configData)) {
return againRequest(error, service, 1, 2);
}
if (configData.isLoop && configData.isLoop.count === 1) {
requestTimeout();
}
}
else if (error.toString().indexOf("50") !== -1) {
if (!axios.isCancel(error) && !isLoop(configData)) {
return againRequest(error, service, 1, 2);
}
if (configData.isLoop && configData.isLoop.count === 1) {
requestTimeout();
}
}
未知错误
else {
// 未知错误,等待以后解析
}
else {
// 未知错误,等待以后解析
}
总结
结果将状态码梳理后,客户基本看不到API错误了,对服务的稳定性和可靠性非常满意,给我们提出了表扬和感谢。我们也期待老板升职加薪!
参考资料
作者:高志小鹏鹏
来源:juejin.cn/post/7413187186131533861
来源:juejin.cn/post/7413187186131533861
为什么2.01 变成了 2.00 ,1分钱的教训不可谓不深刻
前些日子,测试提过来一个bug,说下单价格应该是 2.01,但是在订单详情中展示了2.00元。我头嗡的一下子,艹,不会是因为double 的精度问题吧~
果不其然,经过排查代码,最终定位原因订单详情展示金额时,使用double 进行了金额转换,导致金额不准。
我马上排查核心购买和售后链路,发现涉及资金交易的地方没有问题,只有这一处问题,要不然这一口大锅非得扣我身上。
为什么 2.01 变成了 2.0
2.01等小数 在计算机中按照2进制补码存储时,存在除不尽,精度丢失的问题。 例如 2.01的补码为 000000010.009999999999999787 。正如十进制场景存在 1/3等无限小数问题,二进制场景也存在无限小数,所以一定会存在精度问题。
什么场景小数转换存在问题
for (int money = 0; money < 10000; money++) {
String valueYuan = String.format("%.2f", money * 1.0 / 100);
int value = (int) (Double.valueOf(valueYuan) * 100);
if (value != money) {
System.out.println(String.format("原值: %s, 现值:%s", money, value));
}
}
如上代码中,先将数字 除以 100,转为元, 精度为2位,然后将double 乘以100,转为int。 在除以、乘以两个操作后,精度出现丢失。
我把1-10000 的范围测试一遍,共有573个数字出现精度转换错误。 这个概率已经相当大了。
如何转换金额更安全?
Java 提供了BigDecimcal 专门处理精度更高的浮点数。简单封装一下代码,元使用String表示,分使用int表示。提供两个方法实现 元和分的 互转。
public static String change2Yuan(int money) {
BigDecimal base = BigDecimal.valueOf(money);
BigDecimal yuanBase = base.divide(new BigDecimal(100));
return yuanBase.setScale(2, BigDecimal.ROUND_HALF_UP).toString();
}
public static int change2Fen(String money) {
BigDecimal base = new BigDecimal(money);
BigDecimal fenBase = base.multiply(new BigDecimal(100));
return fenBase.setScale(0, BigDecimal.ROUND_HALF_UP).intValue();
}
测试
测试0-1 亿 的金额转换逻辑,均成功转换,不存在精度丢失。
int error = 0;
long time = System.currentTimeMillis();
for (int money = 0; money < 100000000; money++) {
String valueYuan = change2Yuan(money);
int value = change2Fen(valueYuan);
if (value != money) {
error++;
}
}
System.out.println(String.format("时间:%s", (System.currentTimeMillis() - time)));
System.out.println(error);
性能测试
网上很多人说使用 BigDecimcal 存在性能影响,但是我测试性能还是不错的。可能首次耗时略高,大约2ms
| 标题 | 耗时 |
|---|---|
| 0-1亿 | 14.9 秒 |
| 0-100万 | 0.199秒 |
| 0-1万 | 0.59秒 |
| 0-100 | 0.004秒 |
| 0-1 | 0.002秒 |
总结
涉及金额转换的 地方,一定要小心处理,防止出现精度丢失问题。可以使用代码审查工具,查看代码中是否存在使用double 进行金额转换的代码, 同时提供 金额转换工具类。
来源:juejin.cn/post/7399985723673837577
Video.js:视频播放的全能解决方案

大家好,我是徐徐。今天跟大家分享一款多媒体类前端工具库:Video.js。
前言
在现代网页开发中,视频播放功能已经成为用户体验的一个重要组成部分。无论你是开发一个视频分享平台还是一个简单的博客,选择合适的视频播放器都至关重要。今天,我们要介绍的 Video.js 是一个强大且灵活的 HTML5 视频播放器,它能够满足你对视频播放的所有需求。
基本信息
- 官网:videojs.com
- GitHub:github.com/videojs/vid…
- Star:37.8K
- 类别:多媒体
什么是 Video.js?
Video.js 是一个从零开始为 HTML5 世界打造的网页视频播放器。它不仅支持 HTML5 视频和现代流媒体格式,还支持 YouTube 和 Vimeo。自2010年中期项目启动以来,Video.js 已经发展成为一个拥有数百名贡献者并广泛应用于超过** 80 **万个网站的播放器。
主要特点
- 全能播放:
Video.js支持传统的视频格式,如 MP4 和 WebM,同时也支持自适应流媒体格式,如 HLS 和 DASH。对于直播流,Video.js还提供了专门的用户界面,使直播体验更加流畅。 - 易于定制:虽然
Video.js自带的播放器界面已经非常美观,但它的设计也考虑到了可定制性。通过简单的 CSS 你可以轻松地为播放器增添个人风格,使其更符合你的网页设计需求。 - 丰富的插件生态:当你需要额外功能时,
Video.js的插件架构能够满足你的需求。社区已经开发了大量的插件和皮肤,包括 Chromecast、IMA 以及 VR 插件,帮助你快速扩展播放器的功能。
使用场景
Video.js 适用于各种视频播放场景:
- 视频分享平台:无论是播放本地视频还是流媒体内容,
Video.js都能提供稳定的播放体验。 - 直播应用:通过专用的直播流 UI,
Video.js能够实现高质量的实时视频播放。 - 教育和培训平台:支持多种格式和流媒体,确保你的教学视频能够在不同设备上顺畅播放。
快速上手
要在你的网页中使用 Video.js,只需以下简单步骤:
- 引入
Video.js的库:
<link href="https://unpkg.com/video.js/dist/video-js.min.css" rel="stylesheet">
<script src="https://unpkg.com/video.js/dist/video.min.js">script>
<link href="https://unpkg.com/video.js@8.17.3/dist/video-js.min.css" rel="stylesheet">
<script src="https://unpkg.com/video.js@8.17.3/dist/video.min.js">script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/video.js/8.17.3/video-js.min.css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/video.js/8.17.3/video.min.js">script>
- 添加视频播放器元素:
<video
id="my-player"
class="video-js"
controls
preload="auto"
poster="//vjs.zencdn.net/v/oceans.png"
data-setup='{}'>
<source src="//vjs.zencdn.net/v/oceans.mp4" type="video/mp4">source>
<source src="//vjs.zencdn.net/v/oceans.webm" type="video/webm">source>
<source src="//vjs.zencdn.net/v/oceans.ogv" type="video/ogg">source>
<p class="vjs-no-js">
To view this video please enable JavaScript, and consider upgrading to a
web browser that
<a href="https://videojs.com/html5-video-support/" target="_blank">
supports HTML5 video
a>
p>
video>
- 初始化播放器:
var player = videojs('my-video');
就这样,你就可以在网页上嵌入一个功能丰富的视频播放器了。
该videojs函数还接受一个options对象和一个回调:
var options = {};
var player = videojs('my-player', options, function onPlayerReady() {
videojs.log('Your player is ready!');
// In this context, `this` is the player that was created by Video.js.
this.play();
// How about an event listener?
this.on('ended', function() {
videojs.log('Awww...over so soon?!');
});
});
结语
Video.js 是一个功能强大且灵活的视频播放器,它支持多种视频格式和流媒体协议,并且具有丰富的插件生态和良好的定制性。无论你是构建一个视频分享平台还是需要实现高质量的直播播放,Video.js 都能为你提供稳定且可扩展的解决方案。
希望这篇文章能帮助你了解 Video.js 的强大功能,并激发你在项目中使用它的灵感,这么好的东西,赶快分享给你的朋友们吧!
来源:juejin.cn/post/7411046020840964131
文档协同软件是如何解决编辑冲突的?
前言
本文将介绍在线协同文档编辑器是如何解决冲突的,大部分公司在解决冲突上目前用的都是 OT 算法,与之对应,也有一个 CRDT 算法实现。接下来,我们将深入这两种算法的实现及原理。
解决冲突的方案
在线协同文档编辑器通常使用不同的算法来解决冲突,具体算法取决于编辑器的实现和设计。以下是一些常见的解决冲突的算法:
- OT(Operational Transformation,操作转换):这是一种常见的解决冲突的算法,用于实现实时协同编辑。OT算法通过将用户的编辑操作转换为操作序列,并在多个用户同时编辑时进行
操作转换,以确保最终的文档状态一致。 - CRDT(Conflict-free Replicated Data Type,无冲突复制数据类型):
这是一种基于数据结构的解决冲突的算法,它允许多个用户在不同的副本上进行并发编辑,并最终将编辑结果合并为一致的文档状态。CRDT算法通过设计特定的数据结构和操作规则来实现冲突自由的复制。
这些算法都有各自的优缺点,并且在不同的场景和需求下可能更适合不同的编辑器实现。
接下来,我们先聊聊 OT 算法。
OT 算法

当多个用户同时编辑同一文档时,OT算法通过操作转换来保持文档状态的一致性。下面是一个简单的示例,帮助你理解OT算法的工作原理。
假设有两个用户 A 和 B 同时编辑一段文本,初始文本为 "Hello, world!"。
用户 A 在文本末尾添加了字符 " How are you?"。
用户 B 在文本末尾添加了字符 " I'm fine."。
在 OT 算法中,每个操作都由一个操作类型和操作内容组成。在这个例子中,添加字符操作的操作类型为 "insert",操作内容为被插入的字符。
用户 A 的操作序列为:[insert(" How are you?")]
用户 B 的操作序列为:[insert(" I'm fine.")]
首先,服务器收到用户 A 的操作序列,将其应用到初始文本上,得到 "Hello, world! How are you?"。然后,服务器收到用户 B 的操作序列,将其应用到初始文本上,得到 "Hello, world! I'm fine."。
接下来,服务器需要进行操作转换,将用户 B 的操作序列转换为适应用户 A 的文本状态。在这个例子中,用户 B 的操作 "insert(" I'm fine.")" 需要转换为适应 "Hello, world! How are you?" 的操作。
操作转换的过程如下:
- 用户 A 的操作 "insert(" How are you?")" 在用户 B 的操作 "insert(" I'm fine.")"
之前发生,因此用户 B 的操作不会受到影响。 - 用户 B 的操作 "insert(" I'm fine.")" 在用户 A 的操作 "insert(" How are you?")"
之后发生,因此用户 B 的操作需要向后移动。 - 用户 B 的操作 "insert(" I'm fine.")"
向后移动到 "Hello, world! How are you? I'm fine."。
最终,服务器将转换后的操作序列发送给用户 A 和用户 B,他们将其应用到本地文本上,最终得到相同的文本状态 "Hello, world! How are you? I'm fine."。
这个例子展示了 OT 算法如何通过操作转换来保持多个用户同时编辑时文档状态的一致性。在实际应用中,OT 算法需要处理更复杂的操作类型和情况,并进行更详细的操作转换。
接下来,我们聊聊 CRDT 算法:
CRDT 算法

当多个用户同时编辑同一文档时,CRDT(Conflict-free Replicated Data Type,无冲突复制数据类型)算法通过设计特定的数据结构和操作规则来实现冲突自由的复制。下面是一个简单的示例,帮助你理解CRDT算法的工作原理。
在CRDT算法中,文档被表示为一个数据结构,常见的数据结构之一是有序列表(Ordered List)。每个用户的编辑操作会被转换为操作序列,并应用到本地的有序列表上。
假设有两个用户 A 和 B 同时编辑一段文本,初始文本为空。用户 A 在文本末尾添加了字符 "Hello",用户 B 在文本末尾添加了字符 "World"。
在CRDT算法中,每个字符都被赋予一个唯一的标识符,称为标记(Marker)。在这个例子中,我们使用递增的整数作为标记。
用户 A 的操作序列为:[insert("H", 1), insert("e", 2), insert("l", 3), insert("l", 4), insert("o", 5)]
用户 B 的操作序列为:[insert("W", 6), insert("o", 7), insert("r", 8), insert("l", 9), insert("d", 10)]
每个操作都包含要插入的字符以及对应的标记。
当用户 A 应用自己的操作序列时,有序列表变为 "Hello"。同样地,当用户 B 应用自己的操作序列时,有序列表变为 "World"。
接下来,服务器需要将两个用户的操作序列合并为一致的文本状态。在CRDT算法中,合并的过程是通过比较标记的大小来确定字符的顺序,并确保最终的文本状态一致。
合并的过程如下:
- 用户 A 的操作序列中的标记小于用户 B 的操作序列中的标记,因此 "H" 在 "W" 之前。
- 用户 A 的操作序列中的标记小于用户 B 的操作序列中的标记,因此 "e" 在 "o" 之前。
- 用户 A 的操作序列中的标记小于用户 B 的操作序列中的标记,因此 "l" 在 "r" 之前。
- 用户 A 的操作序列中的标记小于用户 B 的操作序列中的标记,因此 "l" 在 "l" 之前。
- 用户 A 的操作序列中的标记小于用户 B 的操作序列中的标记,因此 "o" 在 "d" 之前。
最终,合并后的有序列表为 "HelloWorld"。
这个例子展示了CRDT算法如何通过设计特定的数据结构和操作规则,以及比较标记的大小来实现冲突自由的复制。在实际应用中,CRDT算法可以应用于各种数据结构和操作类型,并进行更复杂的合并过程。
CRDT 的标记实现方案
- 递增整数标记:最简单的标记实现方式是使用递增的整数作为标记。每个操作都会分配一个唯一的整数标记,标记的大小可以用来比较操作的发生顺序。
- 时间戳标记:另一种常见的标记实现方式是使用时间戳作为标记。每个操作都会被分配一个时间戳,可以使用系统时间或逻辑时钟来生成时间戳。时间戳可以用来比较操作的发生顺序。
- 向量时钟标记:向量时钟是一种用于标记并发事件的数据结构。每个操作都会被分配一个向量时钟标记,向量时钟由多个节点的时钟组成,每个节点维护自己的逻辑时钟。向量时钟可以用来比较操作的发生顺序,并检测并发操作之间的关系。
- 哈希值标记:有些情况下,可以使用操作内容的哈希值作为标记。每个操作的内容都会被哈希为一个唯一的标记,这样可以通过比较哈希值来比较操作的发生顺序。
方案选型
OT算法和CRDT算法都是用于实现实时协同编辑的算法,但它们有不同的优点、缺点和工作原理。
OT算法的优点:
- 简单性:OT算法相对较简单,易于理解和实现。
- 实时协同编辑:OT算法可以实现实时协同编辑,多个用户可以同时编辑文档,并通过操作转换保持文档状态的一致性。
OT算法的缺点:
- 操作转换复杂性:OT算法的操作转换过程相对复杂,需要考虑多种情况和操作类型,实现和测试上有一定的挑战。
- 需要服务器参与:OT算法需要中央服务器来处理操作转换,这可能会导致一定的延迟和依赖性。
CRDT算法的优点:
- 冲突自由:CRDT算法设计了特定的数据结构和操作规则,可以实现冲突自由的复制,多个用户同时编辑时不会发生冲突。
- 去中心化:CRDT算法可以在去中心化的环境中工作,不依赖于中央服务器进行操作转换,每个用户可以独立地进行编辑和合并。
CRDT算法的缺点:
- 数据结构复杂性:CRDT算法的数据结构和操作规则相对复杂,需要更多的设计和实现工作。
- 数据膨胀:CRDT算法可能会导致数据膨胀,特别是在复杂的编辑操作和大规模的协同编辑场景下。
OT算法和CRDT算法的区别:
- 算法原理:OT算法通过操作转换来保持文档状态的一致性,而CRDT算法通过设计特定的数据结构和操作规则来实现冲突自由的复制。
- 中心化 vs. 去中心化:OT算法需要中央服务器进行操作转换,而CRDT算法可以在去中心化的环境中工作,每个用户都有完整的本地数据,可以将操作发送给服务器,然后网络分发到其他客户端。
- 复杂性:OT算法相对较简单,但在处理复杂操作和转换时可能会变得复杂;CRDT算法相对复杂,需要更多的设计和实现工作。
选择使用哪种算法取决于具体的应用场景和需求。OT算法适用于需要实时协同编辑的场景,而CRDT算法适用于去中心化和冲突自由的场景。
总结
本文介绍了在线协同文档编辑器中解决冲突的算法,主要包括OT算法和CRDT算法。OT算法通过操作转换来保持文档状态的一致性,而CRDT算法通过设计特定的数据结构和操作规则实现冲突自由的复制。OT算法需要中央服务器进行操作转换,适用于需要实时协同编辑的场景。CRDT算法可以在去中心化环境中工作,每个用户都有完整的本地数据,适用于去中心化和冲突自由的场景。选择使用哪种算法取决于具体的应用场景和需求。
来源:juejin.cn/post/7283018190593785896
audio自动播放为什么会失败
背景
某天客户报了一个问题,说是大屏的声音不能自动播放了,我们的大屏应用是有个报警的,当有报警的时候,自动会播放警报的声音
复线步骤
测试后发现如下结论
- 当刷新页面后,audio不会自动播放
- 当从另外的一个页面进入到当前页面,可以直接播放声音
如果你想测试,可以点我进行测试
你可以先点击上方链接的 尝试一下 ,下方为截图

这个时候你会听到一声马叫声
然后,你刷新下马叫声的页面,这个时候声音的自动播放将不会生效

报错问题排查
打开控制台,不出意外看到了一个报错信息。

翻译为中文的意思为
不允许的错误。播放失败,因为用户没有先与文档交互。goo.gl/xX8pDD
尝试解决
那我给通过给body添加点击事件,自动触发点击事件,在点击的事件后自动播放声音。
(当是我的想法是,这个大概率是不行的,chrome应该不会忽略这一点,不然这个功能就相当于不存在)
经过测试后,发现确实还不行,在意料中。
参考别人的网站,用抖音测试
想到了我们可以参考抖音,我用抖音的进行测试,当你不做任何的操作,页面应该如下

我们从这里得出结论,这个应该是浏览器的限制,需要查看官方文档,看看原因
查阅官方文档
我截取了一些关键的信息

注意浏览器会有一个媒体互动指数,这是浏览器自动计算的,该分越高,才会触发自动播放
查看电脑的媒体互动指数
在url上输入 about://media-engagement,你会看到如下的截图,

经过测试后 当网站变成了is High,音频会自动播放,不会播放失败。
这就解释了为什么有的网站可以自动播放声音,有的网站不可以自动播放声音
ok,我们继续往下看,这个时候看到了一些关键的信息。
作为开发者,我们不应该相信音频/视频会播放成功,要始终在播放的回掉中来进行判断

看到这些,我们来模仿抖音的实现.在播放声音的catch的时候,显示一个错误的弹窗,提示用户,当用户点击的时候,自动播放声音
this.alarmAudio = new Audio(require("@/assets/sound/alarm.mp3"));
this.alarmAudio
.play()
.then(() => {
this.notifyId && this.notifyId.close();
})
.catch((error) => {
if (error instanceof DOMException) {
// 这里可以根据异常类型进行相应的错误处理
if (error.name === "NotAllowedError") {
if (this.notifyId) return;
this.notifyId = Notification({
title: "",
duration: 0,
position: "bottom-left",
dangerouslyUseHTMLString: true,
onClick: this.onAudioNotifyConfirm,
showClose: false,
customClass: "audio-notify-confirm",
message:
"<div style='color:#fff;font-size:18px;cursor:pointer'>因浏览器限制,需<span style='color:#ff2c55'>点击打开声音</span></div>",
});
}
}
});
实现效果如下

总结
- 在用到video或者audio的时候,要始终不相信他会播放声音成功,并且添加catch处理异常场景,给用户友好的提示
- video或者audio的自动播放跟媒体互动指数有关(MEI),当媒体指数高,会自动播放,否则需要用户先交互后,audio才可以自动播放。
- 从一个页面window.open另外一个页面可以自动播放声音,当刷新页面后,需要有高的MEI,audio才会自动播放,如果你需要在后台打开一个大屏的页面,刚好可以这样设计,不要用页面跳转
来源:juejin.cn/post/7412505754383007744
我的 Electron 客户端也可以全量/增量更新了
前言
本文主要介绍 Electron 客户端应用的自动更新,包括全量和增量这两种方式。
全量更新: 运行新的安装包,安装目录所有资源覆盖式更新。
增量更新: 只更新修改的部分,通常是渲染进程和主进程文件。
本文并没有拿真实项目来举例子,而是起一个 新的项目 从 0 到 1 来实现自动更新。
如果已有的项目需要支持该功能,借鉴本文的主要步骤即可。
前置说明:
- 由于业务场景的限制,本文介绍的更新仅支持
Windows操作系统,其余操作系统未作兼容处理。 - 更新流程全部由前端完成,不涉及到后端,没有轮询 更新的机制。
- 发布方式限制为
generic,线上服务需要配置nginx确保访问到资源文件。
准备工作
脚手架搭建项目
我们通过 electron-vite 快速搭建一个基于 Vite + React + TS 的 Electron 项目。

该模板已经包括了我们需要的核心第三方库:electron-builder,electron-updater。
前者是用来打包客户端程序的,后者是用来实现自动更新的。
在项目根目录下,已经自动生成了两份配置文件:electron-builder.yml 和 dev-app-update.yml。
electron-builder.yml
该文件描述了一些 打包配置,更多信息可参考 官网。
在这些配置项中,publish 字段比较重要,因为它关系到更新源。
publish:
provider: generic // 使用一个通用的 HTTP 服务器作为更新源。
url: https://example.com/auto-updates // 存放安装包和描述文件的地址
provider 字段还有其他可选项,但是本文只介绍 generic 这种方式,即把安装包放在 HTTP 服务器里。
dev-app-update.yml
provider: generic
url: https://example.com/auto-updates
updaterCacheDirName: electron-update-demo-updater
其中,updaterCacheDirName 定义下载目录,也就是安装包存放的位置。全路径是C:\Users\用户名\AppData\Local\electron-update-demo-updater,不配置则在C:\Users\用户名\AppData\Local下自动创建文件夹,开发环境下为项目名,生产环境下为项目名-updater。

模拟服务器
我们直接运行 npm run build:win,在默认 dist 文件夹下就出现了打包后的一些资源。

其实更新的基本思路就是对比版本号,即对比本地版本和 线上服务器 版本是否一致,若不一致则需要更新。

因此我们在开发调试的时候,需要起一个本地服务器,来模拟真实的情况。
新建一个文件夹 mockServer,把打包后的 setup.exe 安装包和 latest.yml 文件粘贴进去,然后通过 serve 命令默认起了一个 http://localhose:3000 的本地服务器。
既然有了存放资源的本地地址,在开发调试的时候,我们更新下 dev-app-update.yml 文件的 url 字段,也就是修改为 http://localhose:3000。
注:如果需要测试打包后的更新功能,需要同步修改打包配置文件。
全量更新
与主进程文件同级,创建 update.ts 文件,之后我们的更新逻辑将在这里展开。
import { autoUpdater } from 'electron-updater' //核心库
需要注意的是,在我们开发过程中,通过 npm run dev 起来的 Electron 程序其实不能算是打包后的状态。
你会发现在调用 autoUpdater 的一些方法会提示下面的错误:
Skip checkForUpdates because application is not packed and dev update config is not forced
因此我们需要在开发环境去做一些动作,并且手动指定 updateConfigPath:
// update.ts
if (isDev) {
Object.defineProperty(app, 'isPackaged', {
get: () => true
})
autoUpdater.updateConfigPath = path.join(__dirname, '../../dev-app-update.yml')
}
核心对象 autoUpdater 有很多可以主动调用的方法,也有一些监听事件,同时还有一些属性可以设置。
这里只展示了本人项目场景所需的一些配置。
autoUpdater.autoDownload = false // 不允许自动下载更新
autoUpdater.allowDowngrade = true // 允许降级更新(应付回滚的情况)
autoUpdater.checkForUpdates()
autoUpdater.downloadUpdate()
autoUpdater.quitAndInstall()
autoUpdater.on('checking-for-update', () => {
console.log('开始检查更新')
})
autoUpdater.on('update-available', info => {
console.log('发现更新版本')
})
autoUpdater.on('update-not-available', info => {
console.log('不需要全量更新', info.version)
})
autoUpdater.on('download-progress', progressInfo => {
console.log('更新进度信息', progressInfo)
})
autoUpdater.on('update-downloaded', () => {
console.log('更新下载完成')
})
autoUpdater.on('error', errorMessage => {
console.log('更新时出错了', errorMessage)
})
在监听事件里,我们可以拿到下载新版本 整个生命周期 需要的一些信息。
// 新版本的版本信息(已筛选)
interface UpdateInfo {
readonly version: string;
releaseName?: string | null;
releaseNotes?: string | Array<ReleaseNoteInfo> | null;
releaseDate: string;
}
// 下载安装包的进度信息
interface ProgressInfo {
total: number;
delta: number;
transferred: number;
percent: number;
bytesPerSecond: number;
}
在写完这些基本的方法之后,我们需要决定 检验更新 的时机,一般是在应用程序真正启动之后,即 mainWindow 创建之后。
运行项目,预期会提示 不需要全量更新,因为刚才复制到本地服务器的 latest.yml 文件里的版本信息与本地相同。修改 version 字段,重启项目,主进程就会提示有新版本需要更新了。
频繁启动应用太麻烦,除了应用初次启动时主进程主动帮我们检验更新外,还需要用户 手动触发 版本更新检测,此外,由于产品场景需要,当发现有新版本的时候,需要在 渲染进程 通知用户,包括版本更新时间和更新内容。因此我们需要加上主进程与渲染进程的 IPC通信 来实现这个功能。
其实需要的数据主进程都能够提供了,渲染进程具体的展示及交互方式就自由发挥了,这里简单展示下我做的效果。
1. 发现新版本

2. 无需更新

增量更新
为什么要这么做
其实全量更新是一种比较简单粗暴的更新方式,我没有花费太大的篇幅去介绍,基本上就是使用 autoUpdater 封装的一些方法,开发过程中更多的注意力则是放在了渲染进程的交互上。
此外,我们的更新交互用的是一种 用户感知 的方式,即更不更新是由用户自己决定的。而 静默更新 则是用户无感知,在监测到更新后不用去通知用户,而是自行在后台下载,在某个时刻执行安装的逻辑,这种方式往往是一键安装,不会弹出让用户去选择安装路径的窗口。接下去要介绍的增量更新其实是这种 用户无感知 的形式,只不过我们更新的不是整个应用程序。
由于产品迭代比较频繁,我们的业务方经常收到更新提示,意味着他们要经常手动更新应用,以便使用到最新的功能特性。众所周知,Electron 给前端开发提供了一个比较容易上手的客户端开发解决方案,但在享受 跨平台 等特性的同时,还得忍受 臃肿的安装包 。
带宽、流量在当前不是什么大问题,下载和安装的速度也挺快,但这频繁的下载覆盖一模一样的资源文件还是挺糟心的,代码改动量小时,全量更新完全没有必要。我们希望的是,开发更新了什么代码,应用程序就替换掉这部分代码就好了,这很优雅。在 我们的场景 中,这部分代码指的是 —— main、preload、renderer,并不包括 dll、第三方SDK等资源。
网上有挺多种增量更新的 解决方案,例如:
- 通过
win.loadURL(一个线上地址)实现,相当于就套了一层客户端的壳子,与加载的Web端同步更新。这种方法对于简单应用来说是最省心的,但是也有一定的局限性,例如不能使用node去操作一些底层的东西。 - 设置
asar的归档方式,替换app.asar或app.asar.unpack来实现。但后者在我实践过程中存在文件路径不存在的问题。 - 禁用
asar归档,解压渲染进程压缩包后实现替换。简单尝试过,首次执行安装包的时候写入文件很慢,不知道是不是这个方式引起的。 - 欢迎补充。
本文我们采用较普遍的 替换asar 来实现。
优化 app.asar 体积
asar是Electron提供的一种将多个文件合并成一个文件的类tar风格的归档格式,不仅可以缓解Windows下路径名过长的问题, 还能够略微加快一下require的速度, 并且可以隐藏你的源代码。(并非完全隐藏,有方法可以找出来)
Electron 应用程序启动的时候,会读取 app.asar.unpacked 目录中的内容合并到 app.asar 的目录中进行加载。在此之前我们已经打包了一份 Demo 项目,安装到本地后,我们可以根据安装路径找到 app.asar 这个文件。
例如:D:\你的安装路径\electron-update-demo\resources

在这个 Demo 项目中,脚手架生成的模板将图标剥离出来,因此出现了一个 app.asar.unpacked 文件夹。我们不难发现,app.asar 这个文件竟然有 66 MB,再回过头看我们真正的代码文件,甚至才 1 MB !那么到底是什么原因导致的这个文件这么大呢,刚才提到了,asar 其实是一种压缩格式,因此我们只要解压看看就知道了。
npm i -g asar // 全局安装
asar e app.asar folder // 解压到folder文件夹

解压后我们不难发现,out 文件夹里的才是我们打包出来的资源,罪魁祸首竟然是 node_modules,足足有 62.3 MB。
查阅资料得知,Electron 在打包的时候,会把 dependencies 字段里的依赖也一起打包进去。而我们的渲染进程是用 React开发的,这些第三方依赖早就通过 Vite 等打包工具打到资源文件里了,根本不需要再打包一次,不然我们重新下载的 app.asar 文件还是很大的,因此需要尽可能减少体积。
优化应用程序体积 == 减少 node_modules 文件夹的大小 == 减少需要打包的依赖数量 == 减少 dependencies 中的依赖。
1. 移除 dependencies
最开始我想的是把 package.json 中的 dependencies 全都移到 devDependencies,打包后的体积果然减小了,但是在测试过程中会发现某些功能依赖包会缺失,比如 @electron/remote。因此在修改这部分代码的时候,需要特别留意,哪些依赖是需要保留下来的。
由于不想影响 package.json 的版本结构,我只是在写了一个脚本,在 npm i 之后,执行打包命令前修改 devDependencies 就好了。
2. 双 package.json 结构
这是 electron-builder 官网上看到的一种技巧,传送门, 创建 app 文件夹,再创建第二个 package.json,在该文件中配置我们应用的名称、版本、主进程入口文件等信息。这样一来,electron-builder 在打包时会以该文件夹作为打包的根文件夹,即只会打包这个文件夹下的文件。
但是我原项目的主进程和预加载脚本也放在这,尝试修改后出现打包错误的情况,感兴趣的可以试试。

这是我们优化之后的结果,1.32 MB,后面只需要替换这个文件就实现了版本的更新。
校验增量更新
全量更新的校验逻辑是第三方库已经封装好了的,那么对于增量更新,就得由我们自己来实现了。
首先明确一下 校验的时机,package.json 的 version 字段表示应用程序的版本,用于全量更新的判断,那也就意味着,当这个字段保持不变的时候,会触发上文提到的 update-not-available 事件。所以我们可以在这个事件的回调函数里来进行校验。
autoUpdater.on('update-not-available', info => { console.log('不需要全量更新', info.version) })
然后就是 如何校验,我们回过头来看 electron-builder 的打包配置,在 releaseInfo 字段里描述了发行版本的一些信息,目前我们用到了 releaseNotes 来存储更新日志,查阅官网得知还有个 releaseName 好像对于我们而言是没什么用的,那么我们就可以把它作为增量更新的 热版本号。(但其实 官网 配置项还有个 vendor 字段,可是我在用的时候打包会报错,可能是版本的问题,感兴趣的可以试试。)
每次发布新版本的时候,只要不是 Electron自身版本变化 等重大更新,我们都可以通过修改 releaseInfo 的 releaseName 来发布我们的热版本号。现在我们已经有了线上的版本,那 本地热版本号 该如何获取呢。这里我们在每次热更新完成之后,就会把最新的版本号存储在本地的一个配置文件中,通常是 userData 文件夹,用于存储我们应用程序的用户信息,即使程序卸载 了也不会影响里面的资源。在 Windows 下,路径如下:
C:\Users\用户名\AppData\Roaming\electron-update-demo。
因此,整个 校验流程 就是,在打开程序的时候,autoUpdater 触发 update-not-available 事件,拿到线上 latest.yml 描述的 releaseName 作为热版本号,与本地配置文件(我们命名为 config.json)里存储的热版本号(我们命名为 hotVersion)进行对比,若不同就去下载最新的 app.asar 文件。
我们使用 electron-log 来记录日志,代码如下所示。
// 本地配置文件
const localConfigFile = path.join(app.getPath('userData'), 'config.json')
const checkHotVersion = (latestHotVersion: string): boolean => {
let needDownload = false
if (!fs.existsSync(localConfigFile)) {
fs.writeFileSync(localConfigFile, '{}', 'utf8')
log.info('配置文件不存在,已自动创建')
} else {
log.info('监测到配置文件')
}
try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
// 对比版本号
if (latestHotVersion !== configData.hotVersion) {
log.info('当前版本不是最新热更版本')
needDownload = true
} else {
log.info('当前为最新版本')
}
} catch (error) {
log.error('读取配置文件时出错', error)
}
return needDownload
}
下载增量更新包
通过刚才的校验,我们已经知道了什么时候该去下载增量更新包。
在开发调试的时候,我们可以把新版本的 app.asar 也放到起了本地服务器的 mockServer 文件夹里来模拟真实的情况。有很多方式可以去下载文件,这里我用了 nodejs 的 http 模块去实现,如果是 https 的需要引用 https 模块。
下载到本地的时候,我是放在了与 app.asar 同级目录的 resources 文件夹,我们不能直接覆盖原文件。一是因为进程权限占用的问题,二是为了容错,所以我们需要以另一个名字来命名下载后的文件(这里我们用的是 app.asar-temp),也就不需要去备份原文件了,代码如下。
const resourcePath = isDev
? path.join('D:\\测试\\electron-update-demo\\resources')
: process.resourcesPath
const localAsarTemp = path.join(resourcePath, 'app.asar-temp')
const asarUrl = 'http://localhost:3000/app.asar'
downloadAsar() {
const fileStream = fs.createWriteStream(localAsarTemp)
http.get(asarUrl, res => {
res.pipe(fileStream)
fileStream
.on('finish', () => {
log.info('asar下载完成')
})
.on('error', error => {
// 删除文件
fs.unlink(localAsarTemp, () => {
log.error('下载出现异常,已中断', error)
})
})
})
}
因此,我们的流程更新为:发现新版本后,下载最新的 app.asar 到 resources 目录,并重命名为 app.asar-temp。这个时候我们启动项目进行调试,找到记录在本地的日志—— C:\Users\用户名\AppData\Roaming\electron-update-demo\logs,会有以下的记录:
[2024-09-20 13:49:22.456] [info] 监测到配置文件
[2024-09-20 13:49:22.462] [info] 当前版本不是最新热更版本
[2024-09-20 13:49:23.206] [info] asar下载完成
在看看项目 resources 文件夹,多了一个 app.asar-temp文件。

至此,下载这一步已经完成了,但这样还会有一个问题,因为文件名没有加上版本号,也没有重复性校验,倘若一直没有更新,那么每次启动应用需要增量更新的时候都需要再次下载。
替换 app.asar 文件
好了,新文件也有了,接下来就是直接替换掉老文件就可以了,但是 替换的时机 很重要。
在 Windows 操作系统下,直接替换 app.asar 会提示程序被占用而导致失败,所以我们应该在程序关闭的时候实现替换。
- 进入应用,下载新版本,自动关闭应用,替换资源,重启应用。
- 进入应用,下载新版本,不影响使用,用户 手动关闭 后替换,下次启动 就是新版本了。
我们有上面两种方案,最终采用了 方案2。
在主进程监听 app.on('quit') 事件,在应用退出的时候,判断 app.asar 和 app.asar-temp 是否同时存在,若同时存在,就去替换。这里替换不能直接用 nodejs 在主进程里去写,因为主进程执行的时候,资源还是被占用的。因此我们需要额外起一个 子进程 去做。
nodejs 可以通过 spawn、exec 等方法创建一个子进程。子进程可以执行一些自定义的命令,我们并没有使用子进程去执行 nodejs,因为业务方的机器上不一定有这个环境,而是采用了启动 exe 可执行文件的方式。可能有人问为什么不直接运行 .bat 批处理文件,因为我调试关闭应用的时候,会有一个 命令框闪烁 的现象,这不太优雅,尽管已经设置 spawn 的 windowsHide: true。
那么如何获得这个 exe 可执行文件呢,其实是通过 bat 文件去编译的,命令如下:
@echo off
timeout /T 1 /NOBREAK
del /f /q /a %1\app.asar
ren %1\app.asar-temp app.asar
我们加了延时,等待父进程完全关闭后,再去执行后续命令。其中 %1 为运行脚本传入的参数,在我们的场景里就是 resources 文件夹的地址,因为每个人的安装地址应该是不一样的,所以需要作为参数传进去。
转换文件的工具一开始用的是 Bat To Exe Converter 下载地址,但是在实际使用的过程中,我的电脑竟然把转换后的 exe 文件 识别为病毒,但在其他同事电脑上没有这种表现,这肯定就不行了,最后还是同事使用 python 帮我转换生成了一份可用的文件(replace.exe)。
这里我们可以选择不同的方式把 replace.exe 存放到 用户目录 上,要么是从线上服务器下载,要么是安装的时候就已经存放在本地了。我们选择了后者,这需要修改 electron-builder 打包配置,指定 asarUnpack, 这样就会存放在 app.asar.unpacked 文件夹中,不经常修改的文件都可以放在这里,不会随着增量更新而替换掉。
有了这个替换脚本之后,开始编写子进程相关的代码。
import { spawn } from 'child_process'
cosnt localExePath = path.join(resourcePath, 'app.asar.unpacked/resources/replace.exe')
replaceAsar() {
if (fs.existsSync(localAsar) && fs.existsSync(localAsarTemp)) {
const command = `${localExePath} ${resourcePath}`
log.info(command)
const logPath = app.getPath('logs')
const childOut = fs.openSync(path.join(logPath, './out.log'), 'a')
const childErr = fs.openSync(path.join(logPath, './err.log'), 'a')
const child = spawn(`"${localExePath}"`, [`"${resourcePath}"`], {
detached: true, // 允许子进程独立
shell: true,
stdio: ['ignore', childOut, childErr]
})
child.on('spawn', () => log.info('子进程触发'))
child.on('error', err => log.error('child error', err))
child.on('close', code => log.error('child close', code))
child.on('exit', code => log.error('child exit', code))
child.stdout?.on('data', data => log.info('stdout', data))
child.stderr?.on('data', data => log.info('stderr', data))
child.unref()
}
}
app.on('quit', () => {
replaceAsar()
})
在这块代码中,创建子进程的配置项比较重要,尤其是路径相关的。因为用户安装路径是不同的,可能会存在 文件夹名含有空格 的情况,比如 Program Files,这会导致在执行的时候将空格识别为分隔符,导致命令执行失败,因此需要加上 双引号。此外,detached: true 可以让子进程独立出来,也就是父进程退出后可以执行,而shell: true 可以将路径名作为参数传过去。
const child = spawn(`"${localExePath}"`, [`"${resourcePath}"`], {
detached: true,
shell: true,
stdio: ['ignore', childOut, childErr]
})
但这块有个 疑惑,为什么我的 close、exit 以及 stdout 都没有触发,以至于不能拿到命令是否执行成功的最终结果,了解的同学可以评论区交流一下。
至此,在关闭应用之后,app.asar 就已经被替换为最新版本了,还差最后一步,更新本地配置文件里的 hotVersion,防止下次又去下载更新包了。
child.on('spawn', () => {
log.info('子进程触发')
updateHotVersion()
})
updateHotVersion() {
fs.writeFileSync(localConfigFile, JSON.stringify({ hotVersion }, null, 2))
}
增量更新日志提示
既然之前提到的全量更新有日志说明,那增量更新可以不用,也应该需要具备这个能力,不然我们神不知鬼不觉帮用户更新了,用户都不知道 新在哪里。
至于更新内容,我们可以复用 releaseInfo 的 releaseNotes 字段,把更新日志写在这里,增量更新完成后展现给用户就好了。
但是总不能每次打开都展示,这里需要用户有个交互,比如点击 知道了 按钮,或者关闭 Modal 后,把当前的热更新版本号保存在本地配置文件,记录为 logVersion。在下次打开程序或者校验更新的时候,我们判断一下 logVersion === hotVersion,若不同,再去提示更新日志。
日志版本 校验和修改的代码如下所示:
checkLogVersion() {
let needShowLog = false
try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
const { hotVersion, logVersion } = configData
if (hotVersion !== logVersion) {
log.info('日志版本与当前热更新版本不同,需要提示更新日志')
needShowLog = true
} else {
log.info('日志已是最新版本,无需提示更新日志')
}
} catch (error) {
log.error('读取配置文件失败', error)
}
return needShowLog
}
updateLogVersion() {
try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
const { hotVersion } = configData
fs.writeFileSync(localConfigFile, JSON.stringify({ hotVersion, logVersion: hotVersion }, null, 2))
log.info('日志版本已更新')
} catch (error) {
log.error('读取配置文件失败', error)
}
}
读取 config.json 文件的方法自行封装一下,我这里就重复使用了。主进程 ipcMain.on 监听一下用户传递过来的事件,再去调用 updateLogVersion 即可,渲染进程效果如下:
提示增量更新日志

点击 知道了 后,再次打开应用后就不会提示日志了,因为本地配置文件已经被修改了。

当然可能也有这么个场景,开发就改了一点点无关功能的代码,无需向用户展示日志,我们只需要加一个判断 releaseNotes 是否为空的逻辑就好了,也做到了 静默更新。
小结
不足之处
本文提出的增量更新方案应该算是比较简单的,可能并不适用于所有的场景,考虑也不够全面,例如:
dll、第三方SDK等资源的更新。- 增量更新失败后应该通过全量更新 兜底。
- 用户在使用过程中发布新版本,得等到 第二次打开 才能用到新版本。
流程图
针对本文的解决方案,我简单画了一个 流程图。

参考文章
网上其实有不少关于 Electron 自动更新的文章,在做这个需求之前也是浏览了好多,但也有些没仔细看完,所以就躺在我的标签页没有关闭,在这罗列出来,也当作收藏了。
写这篇文章的目的也是为了记录和复盘,如果能为你提供一些思路那就再好不过了。
鸣谢:
来源:juejin.cn/post/7416311252580352034
国产语言MoonBit崛起,比Rust快9倍,比GO快35倍
在这个技术日新月异的时代,编程语言的更新换代似乎已经成为了家常便饭。但国产语言却一直不温不火,对于程序员来说,拥有一款属于我们自己的语言,一直是大家梦寐以求的事情。
如果我说有一种国产编程语言,它的运行速度比Rust快9倍,比GO快35倍,你会相信吗?
这不是天方夜谭,最近,被称为“国产编程语引领者”的MoonBit(月兔),宣布正式进入Beta预览版本阶段啦!
一听月兔这名字起得挺中式的。
一、初识MoonBit
MoonBit是由粤港澳大湾区数字经济研究院(福田)研发的全新编程语言。
① 官网

② 目前状态
MoonBit是2022年推出的国产编程语言,并在2023年8月18日海外发布后,立即获得国际技术社区的广泛关注。
经过一年多的高速迭代,MoonBit推出了beta预览版。
MoonBit 目前处于 Beta-Preview 阶段。官方希望能在 2024/11/22 达到 Beta 阶段,2025年内达到 1.0 阶段。
③ 由来
诞生于AI浪潮,没有历史包袱:MoonBit 诞生于 ChatGPT 出世之后,使得 MoonBit 团队有更好的机会去重新构想整个程序语言工具链该如何与 AI 友好的协作,不用承担太多的历史包袱
二、MoonBit 语言优势
① 编译与运行速度快
MoonBit在编译速度和运行时性能上表现出色,其编译626个包仅需1.06秒,比Rust快了近9倍;运行速度比GO快35倍!

② 代码体积小
MoonBit 在输出 Wasm 代码体积上相较于传统语言有显著优势。
一个简单的HTTP 服务器时,MoonBit 的输出文件大小仅为 27KB,而 WasmCloud提供的http-hello-world 模板中 Rust 的输出为 100KB,TypeScript 为 8.7MB,Python 更是高达 17MB。

③ 多重安全保障
MoonBit 采用了强大的类型系统,并内置静态检测工具,在编译期检查类型错误,
MoonBit自身的静态控制流分析能在编译器捕获异常的类型,从而提高代码的正确性和可靠性。
④ 高效迭代器
MoonBit创新地使用了零开销的迭代器设计,使得用户能够写出既优雅又高效的代码。
⑤ 创新的泛型系统设计
MoonBit语言在它的测试版里就已经搞定了泛型和特殊的多态性,而且在编译速度特别快的同时,还能做到用泛型时不增加额外负担。
你要知道,这种本事在很多流行的编程语言里,都是正式发布很久之后才慢慢有的,但MoonBit一开始就做到了。这种设计在现在编程语言越来越复杂的大背景下特别关键,因为一个好的类型系统对于整个编程语言生态的健康成长是特别重要的。
三、应用场景
① 云计算
② 边缘计算
③ AI 以及教学领域的发展
四、开发样例
我们在官网 http://www.moonbitlang.cn/gallery/ 可以看到用使用MoonBit 开发的游戏样例
- 罗斯方块游戏
- 马里奥游戏
- 数独求解器
- 贪吃蛇游戏

五、语言学习
5.1 语法文档
如果你也对
MoonBit感兴趣,想学习它,访问官方文档docs.moonbitlang.cn/。文档算是比较详细的了

5.2 在线编译器
无需本地安装编译器即可使用,官方提供了在线编译器
① 在线编辑器地址

② 点击这儿运行代码

5.3 VS Code 中安装插件编写代码、
① 安装插件


② 下载程序
按下shift+cmd+p快捷键(mac快捷键,windows和linux快捷键是ctrl+shift+p),输入 MoonBit:install latest moonbit toolchain,随后会出现提示框,点击“yes”,等待程序下载完成。

③ 创建并打开新项目
下载完成后,点击terminal,输入moon new hello && code hello以创建并打开新项目。
④ 始执行代码
项目启动后,再次打开terminal,输入moon run main命令,即可开始执行代码。
六、小结
下面是晓凡的一些个人看法
MoonBit 作为一款新兴的国产编程语言,其在性能和安全性方面的表现令人印象深刻。
特别是它在编译速度和运行效率上的优化,对于需要处理大量数据和高并发请求的现代应用来说,是一个很大的优势。
同时,它的设计理念符合当前软件开发的趋势,比如对云计算和边缘计算的支持,以及对 AI 应用的适配。
此外,MoonBit 团队在语言设计上的前瞻性思考,比如泛型系统的实现,显示出了其对未来编程语言发展趋势的深刻理解。
而且,提供的游戏开发样例不仅展示了 MoonBit 的实用性,也降低了初学者的学习门槛。
来源:juejin.cn/post/7416604150933733410
花了一天时间帮财务朋友开发了一个实用小工具
大家好,我是晓凡。
写在前面
不知道大家有没有做财务的朋友,我就有这么一位朋友就经常跟我抱怨。一到月底简直就是噩梦,总有加不完的班,熬不完的夜,做不完的报表。

一听到这儿,这不就一活生生的一个“大表哥”么,这加班跟我们程序员有得一拼了,忍不住邪恶一笑,心里平衡了很多。

身为牛马,大家都不容易啊。我不羡慕你数钱数到手抽筋,你也别羡慕我整天写CRUD 写到手起老茧🤣
吐槽归吐槽,饭还得吃,工作还得继续干。于是乎,真好赶上周末,花了一天的时间,帮朋友写了个小工具
一、功能需求
跟朋友吹了半天牛,终于把需求确定下来了。就一个很简单的功能,通过名字,将表一和表二中相同名字的金额合计。
具体数据整合如下图所示

虽然一个非常简单的功能,但如果不借助工具,数据太多,人工来核对,整合数据,还是需要非常消耗时间和体力的。
怪不得,这朋友到月底就消失了,原来时间都耗在这上面了。
二、技术选型
由于需求比较简单,只有excel导入导出,数据整合功能。不涉及数据库相关操作。
综合考虑之后选择了
PowerBuilderPbidea.dll
使用PowerBuilder开发桌面应用,虽然界面丑一点,但是开发效率挺高,简单拖拖拽拽就能完成界面(对于前端技术不熟的小伙伴很友好)
其次,由于不需要数据库,放弃web开发应用,又省去了云服务器费用。最终只需要打包成exe文件即可跑起来
Pbidea.dll 算是Powerbuilder最强辅助开发,没有之一。算是PBer们的福音吧
三、简单界面布局



四、核心代码
① 导入excel
string ls_pathName,ls_FileName //路径+文件名,文件名
long ll_Net
long rows
dw_1.reset()
uo_datawindowex dw
dw = create uo_datawindowex
dw_1.setredraw(false)
ll_Net = GetFileSaveName("请选择文件",ls_pathName,ls_FileName,"xlsx","Excel文(*.xlsx),*.xlsx")
rows = dw.ImportExcelSheet(dw_1,ls_pathName,1,0,0)
destroy dw
dw_1.setredraw(true)
MessageBox("提示信息","导入成功 " + string(rows) + "行数据")
② 数据整合
long ll_row,ll_sum1,ll_sum2
long ll_i,ll_j
long ll_yes
string ls_err
//重置表三数据
dw_3.reset()
//处理表一数据
ll_sum1 = dw_1.rowcount( )
if ll_sum1<=0 then
ls_err = "表1 未导入数据,请先导入数据"
goto err
end if
for ll_i=1 to ll_sum1
ll_row = dw_3.insertrow(0)
dw_3.object.num[ll_row] =ll_row //序号
dw_3.object.name[ll_row]=dw_1.object.name[ll_i] //姓名
dw_3.object.salary[ll_row]=dw_1.object.salary[ll_i] //工资
dw_3.object.endowment[ll_row]=dw_1.object.endowment[ll_i] //养老
dw_3.object.medical[ll_row]=dw_1.object.medical[ll_i] //医疗
dw_3.object.injury[ll_row]=dw_1.object.injury[ll_i] //工伤
dw_3.object.unemployment[ll_row]=dw_1.object.unemployment[ll_i] //失业
dw_3.object.publicacc[ll_row]=dw_1.object.publicacc[ll_i] //公积金
dw_3.object.annuity[ll_row]=dw_1.object.annuity[ll_i] //年金
next
//处理表二数据
ll_sum2 = dw_2.rowcount( )
if ll_sum2<=0 then
ls_err = "表2未导入数据,请先导入数据"
goto err
end if
for ll_j =1 to ll_sum2
string ls_name
ls_name = dw_2.object.name[ll_j]
ll_yes = dw_3.Find("name = '"+ ls_name +"' ",1,dw_3.rowcount())
if ll_yes<0 then
ls_err = "查找失败!"+SQLCA.SQLErrText
goto err
end if
if ll_yes = 0 then //没有找到
ll_row = dw_3.InsertRow (0)
dw_3.ScrollToRow(ll_row)
dw_3.object.num[ll_row] = ll_row //序号
dw_3.object.name[ll_row] = dw_1.object.name[ll_j] //姓名
dw_3.object.salary[ll_row] = dw_1.object.salary[ll_j] //工资
dw_3.object.endowment[ll_row] = dw_1.object.endowment[ll_j] //养老
dw_3.object.medical[ll_row] = dw_1.object.medical[ll_j] //医疗
dw_3.object.injury[ll_row] = dw_1.object.injury[ll_j] //工伤
dw_3.object.unemployment[ll_row] = dw_1.object.unemployment[ll_j] //失业
dw_3.object.publicacc[ll_row] = dw_1.object.publicacc[ll_j] //公积金
dw_3.object.annuity[ll_row] = dw_1.object.annuity[ll_j] //年金
end if
if ll_yes >0 then //找到
dec{2} ld_salary,ld_endowment,ld_medical,ld_injury,ld_unemployment,ld_publicacc,ld_annuity
ld_salary = dw_3.object.salary[ll_yes] + dw_2.object.salary[ll_j]
ld_endowment = dw_3.object.endowment[ll_yes] + dw_2.object.endowment[ll_j]
ld_medical = dw_3.object.medical[ll_yes] + dw_2.object.medical[ll_j]
ld_injury = dw_3.object.injury[ll_yes] + dw_2.object.injury[ll_j]
ld_unemployment = dw_3.object.unemployment[ll_yes] + dw_2.object.unemployment[ll_j]
ld_publicacc = dw_3.object.publicacc[ll_yes] + dw_2.object.publicacc[ll_j]
ld_annuity = dw_3.object.annuity[ll_yes] + dw_2.object.annuity[ll_j]
dw_3.object.salary[ll_yes]= ld_salary //工资
dw_3.object.endowment[ll_yes]=ld_endowment //养老
dw_3.object.medical[ll_yes]=ld_medical //医疗
dw_3.object.injury[ll_yes]=ld_injury //工伤
dw_3.object.unemployment[ll_yes]=ld_unemployment //失业
dw_3.object.publicacc[ll_yes]=ld_publicacc //公积金
dw_3.object.annuity[ll_yes]=ld_publicacc //年金
end if
next
return 0
err:
messagebox('错误信息',ls_err)
③ excel导出
string ls_err
string ls_pathName,ls_FileName //路径+文件名,文件名
long ll_Net
if dw_3.rowcount() = 0 then
ls_err = "整合数据为空,不能导出"
goto err
end if
uo_wait_box luo_waitbox
luo_waitbox = create uo_wait_box
luo_waitBox.OpenWait(64,RGB(220,220,220),RGB(20,20,20),TRUE,"正在导出 ", 8,rand(6) - 1)
long rows
CreateDirectory("tmp")
uo_datawindowex dw
dw = create uo_datawindowex
ll_Net = GetFileSaveName("选择路径",ls_pathName,ls_FileName,"xlsx","Excel文(*.xlsx),*.xlsx")
rows = dw.ExportExcelSheet(dw_3,ls_pathName,true,true)
destroy dw
destroy luo_waitbox
MessageBox("提示信息","成功导出 " + string(rows) + " 行数据")
return 0
err:
messagebox('错误信息',ls_err)
五、最终效果

这次分享就到这吧,★,°:.☆( ̄▽ ̄)/$: .°★ 。希望对您有所帮助,也希望多来几个这样的朋友,不多说了, 蹭饭去了
我们下期再见ヾ(•ω•`)o (●'◡'●)
来源:juejin.cn/post/7404036818973245478
37K star!实时后端服务,一个文件实现
如果你经常开发web类的的项目,那你一定经常会接触到后端服务,给项目找一个简单、好用的后端服务可以大大加速开发。
今天我们分享的开源项目,它可以作为SaaS或者是Mobile的后端服务,最简单情况只要一个文件,它就是:PocketBase
PocketBase 是什么
PocketBase是一个开源的Go后端框架,它以单个文件的形式提供了一个实时的后端服务。这个框架特别适合于快速开发小型到中型的Web应用和移动应用。它的设计哲学是简单性和易用性,使得开发者能够专注于他们的产品而不是后端的复杂性。
PocketBase包含以下功能:
- 内置数据库(SQLite)支持实时订阅
- 内置文件和用户管理
- 方便的管理面板 UI
- 简洁的 REST 风格 API
安装使用PocketBase
首先你可以下载PocketBase的预构建版本,你可以在github的release页面下载到对应平台的包。
下载后,解压存档并./pocketbase serve在解压的目录中运行。
启动完成后会3个web服务的路由:
- http://127.0.0.1:8090 - 如果存在pb_public目录,则提供其中的静态内容(html、css、图像等)
- http://127.0.0.1:8090/_/ - Admin的管理页面
- http://127.0.0.1:8090/api/ - Rest API
默认情况下,PocketBase 在端口上运行8090。但您可以通过在 serve 命令后附加--http和--https参数将其绑定到任何端口。
Admin panel
第一次访问管理仪表板 UI 时,它会提示您创建第一个管理员帐户。在管理页面里您可以完全使用 GUI 构建数据架构、添加记录并管理数据库。
API
它带有一个开箱即用的 API,可让您操作任何集合,还具有一个优雅的查询系统,可让您分页搜索记录。这将使您不必自己编写和维护同样无聊的 CRUD 操作,而可以更专注于产品特定的功能。
内置访问规则
PocketBase 可让您通过简单的语法直接从 GUI 定义对资源的访问规则。例如,这有助于定义访问范围和控制对用户特定数据的访问。同样,这将使您无需担心编写身份验证和授权代码。
SDK
使用PocketBase的API可以通过官方SDK,目前官方提供了JS SDK和Dart SDK。
- JavaScript - pocketbase/js-sdk (浏览器和nodejs)
- Dart - pocketbase/dart-sdk(网页、移动、桌面)
它们提供了用于连接数据库、处理身份验证、查询、实时订阅等的库,使开发变得简单。
开发定制应用
PocketBase 作为常规 Go 库包分发,允许您构建自己的自定义应用程序特定的业务逻辑,并且最后仍具有单个可移植的可执行文件。
这是一个简单的例子:
- 首先如果你没有Go的环境,那么需要安装 Go1.21以上版本
- 创建一个新的项目目录,并创建一个main.go文件,文件包含以下内容:
package main
import (
"log"
"net/http"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
)
func main() {
app := pocketbase.New()
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// add new "GET /hello" route to the app router (echo)
e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/hello",
Handler: func(c echo.Context) error {
return c.String(200, "Hello world!")
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(app),
},
})
return nil
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
- 初始化依赖项,请运行go mod init myapp && go mod tidy。
- 要启动应用程序,请运行go run main.go serve。
- 要构建静态链接的可执行文件,您可以运行CGO_ENABLED=0 go build,然后使用 启动创建的可执行文件./myapp serve。
总结
整体来说PocketBase是一个非常不错的后端服务,它兼顾了易用性和定制的灵活性,如果你有项目的需要或是想要自己开发一个SAAS的服务,都可以选择它来试试。
项目信息
- 项目名称:pocketbase
- GitHub 链接:github.com/pocketbase/…
- Star 数:37K
来源:juejin.cn/post/7415672130190704640
Vue3真的不需要用pinia!!!
前言
之前使用vue3都是在公司的基建项目中,为了快速达到目的,把以前vue2的模板拿来简单改改就直接用了,所以项目中用法特别乱,比如:状态管理依旧用的vuex,各种类型定义全是any,有些代码是选项式API,有些代码是组合式API...
最近终于有时间推动一下业务项目使用vue3了。作为极简主义的我,始终奉行少即是多,既然是新场景,一切从新,从头开始写模版:
- 使用最新的vue3版本
v3.5.x。 - 所有使用的内部库全部生成
ts类型并引入到环境中。 - 将所有的
mixins重写,包装成组合式函数。 - 将以前的
vue上的全局变量挂载到app.config.globalProperties。 - 全局变量申明类型到
vue-runtime-core.d.ts中,方便使用。 - 全部使用
setup语法,使用标签<script setup lang="ts"> - 使用
pinia作为状态管理。
pinia使用
等等,pinia?好用吗?打开官方文档研究了下,官方优先推荐的是选项式API的写法。
调用defineStore方法,添加属性state, getters, actions等。
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0, name: 'Eduardo' }),
getters: {
doubleCount: (state) => state.count * 2,
},
actions: {
increment () {
this.count++
},
},
})
使用的时候,调用useCounterStore即可。
import { useCounterStore } from '@/stores/counter'
import { computed } from 'vue'
const store = useCounterStore()
setTimeout(() => {
store.increment()
}, 1000)
const doubleValue = computed(() => store.doubleCount)
看上去还不错,但是我模版中全部用的是组合式写法,肯定要用组合式API,试着写了个demo,ref就是选项式写法中的state,computed就是选项式中的getters,function就是actions。
// useTime.ts
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import vueConfig from '../../../common/config/vueConfig'
import * as dayjs from 'dayjs'
export default defineStore('time', () => {
const $this = vueConfig()
const time = ref<number>()
const timeFormat = computed(() => dayjs(time.value).format('YYYY-MM-DD HH:mm:ss'))
const getSystemTime = async () => {
const res = await $this?.$request.post('/system/time')
time.value = Number(res.timestamp)
}
return { timeFormat, getSystemTime }
})
调用时解构赋值,就可以直接用了。
// index.vue
<script setup lang="ts">
import { onMounted } from 'vue'
import useTime from './use/useTime'
const { timeFormat, getSystemTime } = useTime()
onMounted(async () => {
// 请求
await getSystemTime()
console.log('当前时间:', timeFormat)
})
</script>
优雅了很多,之前用vuex时还有个问题,storeA中的state、actions等,会在storeB中使用,这一点pinia文档也有说明,直接在storeB调用就好了,比如我想在另一个组件中调用上文中提到的timeFormat。
defineStore('count', () => {
const count = ref<number>(0)
const { timeFormat } = useTime()
return {
count,
timeFormat,
}
})
怎么看着这么眼熟呢,这不就是组合式函数吗?为什么我要用defineStore再包一层呢?试一试不用pinia,看能不能完成状态管理。
组合式函数
直接添加一个useCount.ts文件,申明一个组合式函数。
// useCount.ts
import { computed, ref } from 'vue'
const useCount = () => {
const count = ref<number>(0)
const doubleCount = computed(() => {
return count.value * 2
})
const setCount = (v: number) => {
count.value = v
}
return {
count,
doubleCount,
setCount,
}
}
export default useCount
使用时直接解构申明,并使用。
import useCount from './use/useCount'
const { count, setCount } = useCount()
onMounted(async () => {
console.log('count', count.value) // 0
setCount(10)
console.log('count', count.value) // 10
})
最大的问题来了,如何在多个地方共用count的值呢,这也是store最大的好处,了解javascript函数机制的我们知道useCount本身是一个闭包,每次调用,里面的ref就会重新生成。count就会重置。
import useCount from './use/useCount'
const { count, setCount } = useCount()
const { doubleCount } = useCount()
onMounted(async () => {
console.log('count', count.value, doubleCount.value) // 0 0
setCount(10)
console.log('count', count.value, doubleCount.value) // 10 0
})
这个时候doubleCount用的并不是第一个useCount中的count,而是第二个重新生成的,所以setCount并不会引起doubleCount的变化。
怎么办呢?简单,我们只需要把count的声明暴露在全局环境中,这样在import时就会申明了,调用函数时不会被重置。
import { computed, ref } from 'vue'
const count = ref<number>(0)
const useCount = () => {
const doubleCount = computed(() => {
return count.value * 2
})
const setCount = (v: number) => {
count.value = v
}
return {
count,
doubleCount,
setCount,
}
}
export default useCount
当我们多次调用时,发现可以共享了。
import useCount from './use/useCount'
const { count, setCount } = useCount()
const { doubleCount } = useCount()
onMounted(async () => {
console.log('count', count.value, doubleCount.value) // 0 0
setCount(10)
console.log('count', count.value, doubleCount.value) // 10 20
})
但是这个时候count是比较危险的,store应该可以保护state不被外部所修改,很简单,我们只需要用readonly包裹一下返回的值即可。
import { computed, readonly, ref } from 'vue'
const count = ref<number>(0)
const useCount = () => {
const doubleCount = computed(() => {
return count.value * 2
})
const setCount = (v: number) => {
count.value = v
}
return {
// readonly可以确保引用对象不会被修改
count: readonly(count),
doubleCount,
setCount,
}
}
export default useCount
总结
经过我的努力,vue3又减少了一个库的使用,我就说不需要用pinia,不过放弃pinia也就意味着放弃了它自带的一些方法store.$state,store.$patch等等,这些方法实现很简单,很轻松就可以手写出来,如果你是这些方法的重度用户,保留pinia也没问题,如果你也想代码更加精简,赶紧尝试下组合式函数吧。
来源:juejin.cn/post/7411328136740847654
上6休3上3休2……,为了理清这烧脑的调休安排我制作一个调休日历!
调休日历
前些天,使用python做了一个日历,可以打印出调休的节假日。效果还可以,但是遇到了一些小问题。
有一个朋友提出了这样一个问题:“日历确实是很好,但是,使用控制台print不怎么样,别人的日历都是很好看的,而我们的日历还只能用print。这太丢人了,我出门说自己是你的粉丝,都没什么面子了啊!”
还有一个朋友提出了另外一个问题:“在我们国家,‘农历’也是很重要的,比如说‘农历新年’是一年中最重要的日子。所以,我们的日历也应该能看到农历才比较好。”
好吧,这次就来解决一下这些问题!
农历
农历介绍
农历,也称为“阴历”,“月亮历”,“阴阳历”,是一种重要的历法,起源于中国的古代,这种历法同时结合了太阳运动,和月亮周期。其中月球围绕地球旋转的周期,大约为29.5天,这构成了农历的一个月,而一年通常是12个月。然而,一个太阳年(太阳公转一周)大概是365.24天,显然月亮的12个月不够这个数字,因此,为了填补到太阳年的时长,就会加入“闰月“,大概每3年就会出现一个”闰月“,”闰月“和公历中的“闰年”有异曲同工之妙。
那么农历有什么用呢?农历有很大的作用!节日安排,就是农历决定的(新年,中秋,端午等)。农业活动也和农历有很大的关系,比如说,仍然有很多农民,根据农历的指导,进行农业活动,什么时候播种,什么时候收割,都是农历决定的。还有很多人过生日,都会选择过农历生日。除此之外,像“季节变化”也和农历有微妙的关系,我们经常可以听到,“现在已经立秋了,已经是秋天了,马上就要凉快了。”,诸如此类的话,可见,农历在日常生活中发挥了重要作用。是我们祖先的伟大发明。
农历实现
那么,农历到底是怎么确定日子的呢?这和天文观测有很大的关系。我们要通过观察天象,来确定农历的准确性。比如说,在中国古代,就有专门的机构,如皇家天文台负责观测天文,然后调整历法,这是一个重要的活动。在历史上,历法也经过了多次修订。
到了现代,对于天文的观测,不再是必须的了。因为现代的科技较为发达,已经能够通过数学计算,历史天文数据推导等,精确的计算出农历。因此,即使我们没有“夜观星象”,也可以知道未来上百年的农历运作。
但是,我们既不会观察天象,也不会科学计算月亮运动,怎么办呢?当然没关系啦,因为,别人已经算好了,我们直接引用就可以了!
安装:pip install lunarcalendar
from lunarcalendar import Converter, Solar, Lunar
# 将公历日期转换为农历
solar = Solar(2024, 9, 17)
lunar = Converter.Solar2Lunar(solar)
# 输出结果为农历日期
print(lunar)
# 将农历日期转换为公历
lunar = Lunar(2024, 8, 15)
solar = Converter.Lunar2Solar(lunar)
# 输出结果为公历日期
print(solar)
转换为汉字日期
一般在农历中,我们并不使用阿拉伯数字的记录,而是常说,“正月初三”,“八月廿二”,这样的表达方式。因此,我们还需要将数字的农历,转为常见的汉字日期:
from lunarcalendar import Converter, Solar
def lunar_date_str(year, month, day):
lunar = Converter.Solar2Lunar(Solar(year, month, day))
leap_month = lunar.isleap
months = ["正月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "冬月", "腊月"]
days = ["初一", "初二", "初三", "初四", "初五", "初六", "初七", "初八", "初九", "初十",
"十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十",
"廿一", "廿二", "廿三", "廿四", "廿五", "廿六", "廿七", "廿八", "廿九", "三十"]
month_str = months[lunar.month - 1]
if leap_month:
month_str = "闰" + month_str
day_str = days[lunar.day - 1]
# 该实现是特别为了符合日历的实现,仅在每个月的第一天返回月份,例如正月初一,返回“正月”
# 而其他日子,不返回月份,仅返回日期,例如正月初三,返回“初三”
if lunar.day == 1:
return month_str
else:
return day_str
如果要更广泛意义的月份加日期,只需要简单的修改返回值即可:f"{month_str}{day_str}"
日历实现
假期判断
因为“调休”的存在,所以我们的放假日期不总是固定的,每年都会有很大的变化,那么如何判断某个日子是不是节假日呢?是不是需要调休呢?
实际上,这是一件困难的事情,没有办法提前知道,只能等到每一年,国家公布了放假安排以后,我们才能够知道准确的放假调休日期。比如说,一些日历等不及,还没等公布放假安排,就已经开始提前印刷了,那么这样的日历,其上包含的信息,就是不完整的,他只能告诉你常规的节日和星期,没办法告诉你调休。
看过我上一期关于日历文章的,应该知道在当时,我是使用了“标记调休”的方式,实现这一点的,大概像这样:

这当然是简单有效,且可行的,只不过一次标记只能管一年,到了明年就不能用了,还得自己重新标记,况且,标记也是一件麻烦的事情,有没有什么更好的办法呢?
当然是有的,我们让别人给我们标记好了,自己等着用现成的,不就好了吗?那么哪里能找到这样的好心人呢?当然是有的,python有一个库叫做chinese-calendar,其中维护了每年的中国节假日,我们只需要使用这个库,让他告诉我们,今天休不休息,就好了。
安装:pip install chinese_calendar
import chinese_calendar as cc
from datetime import date
# 检查某一天是否是工作日或节假日
on_holiday, holiday_name = cc.get_holiday_detail(date(2024, 10, 1))
# 输出是否为假日,假日名称
print(on_holiday, holiday_name)
唉,世界上还是好人多啊!用上了这个,我们可以省很多事,真是好东西啊。
matplotlib绘制日历
matplotlib非常常用,今天就不主要介绍了,虽然它不常用于绘制日历,但是,它的功能其实是很广泛的,包括我们今天的绘制日历。
在下面的实现中,允许提供一个额外信息表,来覆盖默认的农历。也就是你可以通过extra_info_days来增加节日,纪念日的提示。
import datetime
import chinese_calendar as cc
import matplotlib.pyplot as plt
from calendar import monthrange
from lunarcalendar import Converter, Solar
class CalendarDrawer:
plt.rcParams['font.family'] = 'SimHei' # 设置显示字体为黑体
months = ["正月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"]
days = ["初一", "初二", "初三", "初四", "初五", "初六", "初七", "初八", "初九", "初十",
"十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十",
"廿一", "廿二", "廿三", "廿四", "廿五", "廿六", "廿七", "廿八", "廿九", "三十"]
def __init__(self, year, month, extra_info_days=):
self.year = year
self.month = month
self.extra_info_days = extra_info_days or {}
self.fig, self.ax = plt.subplots()
def ax_init(self):
self.ax.axis([0, 7, 0, 7])
self.ax.axis("on")
self.ax.grid(True)
self.ax.set_xticks([])
self.ax.set_yticks([])
@staticmethod
def lunar_date_str(year, month, day):
lunar = Converter.Solar2Lunar(Solar(year, month, day))
month_str = "闰" + CalendarDrawer.months[lunar.month - 1] if lunar.isleap else CalendarDrawer.months[lunar.month - 1]
return month_str if lunar.day == 1 else CalendarDrawer.days[lunar.day - 1]
def plot_month(self):
self.ax.text(3.5, 7.5, f"{self.year}年{self.month}月", color="black", ha="center", va="center")
def plot_weekday_headers(self):
for i, weekday in enumerate(["周一", "周二", "周三", "周四", "周五", "周六", "周日"]):
x = i + 0.5
self.ax.text(x, 6.5, weekday, ha="center", va="center", color="black")
def plot_day(self, day, x, y, color):
ex_day = datetime.date(self.year, self.month, day)
day_info = f"{day}\n{self.extra_info_days.get(ex_day, self.lunar_date_str(self.year, self.month, day))}"
self.ax.text(x, y, day_info, ha="center", va="center", color=color)
def check_color_day(self, day):
date = datetime.date(self.year, self.month, day)
return "red" if cc.get_holiday_detail(date)[0] else "black"
def save(self):
self.ax_init()
self.plot_month()
self.plot_weekday_headers()
weekday, num_days = monthrange(self.year, self.month)
y = 5.5
x = weekday + 0.5
for day in range(1, num_days + 1):
color = self.check_color_day(day)
self.plot_day(day, x, y, color)
weekday = (weekday + 1) % 7
if weekday == 0:
y -= 1
x = weekday + 0.5
plt.savefig(f"日历{self.year}-{self.month}.png")
if __name__ == "__main__":
extra_info_days = {
datetime.date(2024, 1, 1): "元旦",
datetime.date(2024, 2, 10): "春节",
datetime.date(2024, 2, 14): "情人节",
datetime.date(2024, 2, 24): "元宵节",
datetime.date(2024, 3, 8): "妇女节",
datetime.date(2024, 4, 4): "清明节",
datetime.date(2024, 5, 1): "劳动节",
datetime.date(2024, 5, 12): "母亲节",
datetime.date(2024, 5, 20): "520",
datetime.date(2024, 6, 1): "儿童节",
datetime.date(2024, 6, 10): "端午节",
datetime.date(2024, 6, 16): "父亲节",
datetime.date(2024, 8, 10): "七夕节",
datetime.date(2024, 9, 17): "中秋节",
datetime.date(2024, 10, 1): "国庆节",
datetime.date(2024, 11, 1): "万圣节",
datetime.date(2024, 11, 11): "双十一",
datetime.date(2024, 12, 12): "双十二",
datetime.date(2024, 12, 24): "平安夜",
datetime.date(2024, 12, 25): "圣诞节"
}
calendar_drawer = CalendarDrawer(2024, 12, extra_info_days) # 第一个参数为年份,第二个参数为月份,第三个参数为额外信息字典
calendar_drawer.save()
绘制结果,2024-09:

2024-10:

2024-11:

2024-12:

引用与致谢
日历样式,部分参考了便民查询:wannianrili.bmcx.com/
matplotlib绘制,部分参考了shimo164:medium.com/@shimo164/
详细的中国节假日,不再需要个人手动标记了,chinese-calendar:github.com/LKI/chinese…
快速将公历转为农历,支持转换到2100年,LunarCalendar:github.com/wolfhong/Lu…
总结
从我们的日历中,可以清晰的看出,中秋到国庆,我们经历了:
- 上6休3
- 上3休2
- 上5休1
- 上2休7
- 上5休1
嗯,确实是太烧脑了,没有日历,很难算的清楚啊,最后,那么问题来了,3+7到底等于几呢?
来源:juejin.cn/post/7414013230954774579
还在用 top htop? 赶紧换 btop 吧,真香!
top
在 Linux 服务器上,或类 Unix 的机器上,一般我们想查看每个进程的 CPU 使用率、内存使用情况以及其他相关信息时会使用 top 命令。

top 是一个标准的 Linux/Unix 工具,实际上我从一开始接触 Linux 就一直使用 top , 一般是两种场景:
- Linux 服务器上用
- 自己的 Mac 电脑上用
top 有一些常用的功能,比如可以动态的显示进程的情况,按照 CPU 、内存使用率排序等。说实话,这么多年了,使用最多的还就是 top ,一来是因为习惯了,工具用惯了很多操作都是肌肉记忆。二来是 top 一般系统自带不用安装,省事儿。
htop
top 挺好的,但 top 对于初学者和小白用户不太友好,尤其是它的用户界面和操作。于是后来有了 htop

htop 是 top 的一个增强替代品,提供了更加友好的用户界面和更多的功能。与 top 相比,htop 默认以颜色区分不同的信息,并且支持水平滚动查看更多的进程信息。htop 还允许用户使用方向键来选择进程,并可以直接发送信号给进程(如 SIGKILL)。htop 支持多种视图和配置选项,使得用户可以根据自己的喜好定制显示的内容。
htop 我也用了几年,确实舒服一些,但由于需要安装和我对 top 的肌肉记忆 ,htop 在我的使用中并未完全替代 top。 直到 btop 的出现
btop
现在,我本机使用的是 btop,有了 btop,top 和 htop 一点儿都不想用了,哈哈。
在服务器上有时候因为懒不想安装,一部分时间还是 top,一部分用 btop。

第一印象是真漂亮啊,然而它不止好看,功能也是很实用,操作还很简单,你说能不喜欢它吗?
说是 btop ,实际上人家真正的名字是 btop++ , 用 C++ 开发的

安装
btop 支持各种类 Unix 系统,你可以在它的文档中找到对应系统的安装方法 github.com/aristocrato…

本文演示,我是用我自己的 Mac 笔记本电脑,用 Mac 安装很简单,用 brew 一行搞定
brew install btop
我的系统情况是这样的:

安装完成后,直接运行 btop 就可以看到如上图的界面了。
功能界面
打开 btop 后不要被它的界面唬住了,其实非常的简单,我们来介绍一下。
打开 btop 后,其实显示的是它给你的 “预置” 界面。 默认有 4 个预置界面,你可以按 p 键进行切换。命令行界面上会分别显示:
- preset 0
- preset 1
- preset 2
- preset 3

你可能注意到了,这 4 个预置界面中有很多内容是重复的,没错,其实 btop 一共就 4 个模块,预置界面只是把不同的模块拼在一起显示罢了。这 4 个模块分别是:
- CPU 模块
- 存储 模块
- 网络 模块
- 进程 模块
这 4 个模块对应的快捷键分别就是 1,2,3,4 你按一下模块显示,再按一下模块隐藏。

所以如果你对预置界面的内容想立刻调整,就可以按快捷键来显示/隐藏 你想要的模块,当然预置界面也是可以通过配置文件调整的,这个我们后面说。
CPU 模块
CPU 模块可以显示 CPU 型号、各内核的使用率、温度,CPU 整体的负载,以及一个直观的图象,所有数据都是实时显示的。

存储 模块
存储模块包括两部分,一个是内存使用情况,一个是磁盘使用情况:

因为比较直观,具体内容我就不解释了。
网络模块
网络模块可以看下网络的整体负载和吞吐情况,主要包括上行和下行数据汇总,你可以通过按快捷键 b和n 来切换看不同的网卡。

进程模块
初始的进程模块可以看到:
- pid
- Program: 进程名称
- Command: 执行命令的路径
- Threads: 进程包含的线程数
- User: 启动进程的用户
- MemB: 进程所占用内存
- Cpu%: 进程所占用 CPU 百分比

你可以按快捷键 e 显示树状视图:

可以按快捷键 r 对进行排序,按一下是倒序,再按一下是正序。具体排序列可以按左右箭头,根据界面显示进行选择,比如我要按照内存使用排序,那么右上角就是这样的:

按 f 键输入你想过滤的内容然后回车,可以过滤一下界面显示的内容,比如我只想看 chrome 的进程情况:

还可以通过 上下箭头选中某一个进程按回车查看进程详情,再次按回车可以隐藏详情:

显示进程详情后可以对进程进行操作,比如 Kill 只需要按快捷键 k 就可以了,然后会弹出提示:

主题
怎么样,是不是很方便,操作简单,上手容易,还好看。关于 btop 的主要操作就这些了,剩下的可以参考 help 和 menu 中显示的内容自行操作和设置都很简单。
btop 的配置文件默认在这里:$HOME/.config/btop ,你可以直接修改配置文件中的详细参数,如我们前文提到的 “预置” 界面以及预置界面内容都可以在配置文件中设置 :

此外 btop 还有很多好看的主题配色,但默认安装的情况下只带了一个 Default 的,如果你想切换用其他的主题,需要先下载这些主题,主题文件在这里:github.com/aristocrato…
下载好以后放到本地对应的文件夹中 ~/.config/btop/themes
然后你就可以要界面上进行主题的切换了,具体流程是先按快捷键 m ,然后选 OPTIONS

接着在 Color theme 中就能看到你当前拥有的 theme 数据,按方向键就可以切换主题配色了:

主题有很多,我这里给大家一个完整的预览:

我目前使用的就是 Default 我觉得最符合我的审美。
最后
用了 btop 后你就再也回不去了,一般情况下再也不会想用 htop 和 top 了,大家没有换的可以直接换了
来源:juejin.cn/post/7415197972009287692
简单实现一个插件系统(不引入任何库),学会插件化思维
插件系统被广泛应用在各个系统中,比如浏览器、各个前端库如vue、webpack、babel,它们都可以让用户自行添加插件。插件系统的好处是允许开发人员在保证内部核心逻辑不变的情况下,以安全、可扩展的方式添加功能。
本文参考了webpack的插件,不引入任何库,写一个简单的插件系统,帮助大家理解插件化思维。
下面我们先看看插件有哪些概念和设计插件的流程。
准备
三个概念
- 核心系统(Core):有着系统的基本功能,这些功能不依赖于任何插件。
- 核心和插件之间的联系(Core <--> plugin):即插件和核心系统之间的交互协议,比如插件注册方式、插件对核心系统提供的api的使用方式。
- 插件(plugin):相互独立的模块,提供了单一的功能。

插件系统的设计和执行流程
那么对着上面三个概念,设计插件的流程:
- 首先要有一个核心系统。
- 然后确定核心系统的生命周期和暴露的 API。
- 最后设计插件的结构。
- 插件的注册 -- 安装加载插件到核心系统中。
- 插件的实现 -- 利用核心系统的生命周期钩子和暴露的 API。
最后代码执行的流程是:
- 注册插件 -- 绑定插件内的处理函数到生命周期
- 调用插件 -- 触发钩子,执行对应的处理函数
直接看代码或许更容易理解⬇️
代码实现
准备一个核心系统
一个简单的 JavaScript 计算器,可以做加、减操作。
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);
}
}
// test
const calculator = new Calculator()
calculator.plus(10);
calculator.getCurrentValue() // 10
calculator.minus(5);
calculator.getCurrentValue() // 5
确定核心系统的生命周期
实现Hooks
核心系统想要对外提供生命周期钩子,就需要一个事件机制。不妨叫Hooks。(日常开发可以考虑使用webpack的核心库 Tapable )
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运用在核心系统中 -- JavaScript 计算器
每个钩子对应的事件:
- pressedPlus 做加法操作
- pressedMinus 做减法操作
- valueWillChanged 即将赋值currentValue,如果执行此钩子后返回值为false,则中断赋值。
- valueChanged 已经赋值currentValue
class Calculator {
constructor(options = {}) {
this.hooks = new Hooks();
const { initialValue = 0 } = options
this.currentValue = initialValue;
}
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);
}
}
设计插件的结构
插件注册
class Calculator {
constructor(options = {}) {
this.hooks = new Hooks();
const { initialValue = 0, plugins = [] } = options
this.currentValue = initialValue;
// 在options中取出plugins
// 通过plugin执行apply来注册插件 -- apply执行后会绑定(插件内的)处理函数到生命周期
plugins.forEach(plugin => plugin.apply(this.hooks));
}
...
}
插件实现
插件一定要实现apply方法。在Calculator的constructor调用时,才能确保插件“apply执行后会绑定(插件内的)处理函数到生命周期”。
apply的入参是this.hooks,通过this.hooks来监听生命周期并添加处理器。
下面实现一个日志插件和限制最大值插件:
// 日志插件:用console.log模拟下日志
class LogPlugins {
apply(hooks) {
hooks.on('pressedPlus',
(currentVal, addend) => console.log(`${currentVal} + ${addend}`));
hooks.on('pressedMinus',
(currentVal, subtrahend) => console.log(`${currentVal} - ${subtrahend}`));
hooks.on('valueChanged',
(currentVal) => console.log(`结果: ${currentVal}`));
}
}
// 限制最大值的插件:当计算结果大于100时,禁止赋值
class LimitPlugins {
apply(hooks) {
hooks.on('valueWillChanged', (newVal) => {
if (100 < newVal) {
console.log('result is too large')
return false;
}
return true
});
}
}
全部代码
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);
}
}
class LogPlugins {
apply(hooks) {
hooks.on("pressedPlus", (currentVal, addend) =>
console.log(`${currentVal} + ${addend}`)
);
hooks.on("pressedMinus", (currentVal, subtrahend) =>
console.log(`${currentVal} - ${subtrahend}`)
);
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;
});
}
}
// run test
const calculator = new Calculator({
plugins: [new LogPlugins(), new LimitPlugins()],
});
calculator.plus(10);
calculator.minus(5);
calculator.plus(1000);
脚本的执行结果如下,大家也可以自行验证一下

看完代码可以回顾一下“插件系统的设计和执行流程”哈。
更多实现
假如要给Calculator设计一个扩展运算方式的插件,支持求平方、乘法、除法等操作,这时候怎么写?
实际上目前核心系统Calculator是不支持的,因为它并没有支持的钩子。那这下只能改造Calculator。
可以自行尝试一下怎么改造。也可以直接看答案:github.com/coder-xuwen…
最后
插件化的好处
在上文代码实现的过程中,可以感受到插件让Calculator变得更具扩展性。
- 核心系统(Core)只包含系统运行的最小功能,大大降低了核心代码的包体积。
- 插件(plugin)则是互相独立的模块,提供单一的功能,提高了内聚性,降低了系统内部耦合度。
- 每个插件可以单独开发,也支持了团队的并行开发。
- 另外,每个插件的功能不一样,也给用户提供了选择功能的能力。
本文的局限性
另外,本文的代码实现很简单,仅供大家理解,大家还可以继续完善:
- 增加ts类型,比如给把所有钩子的类型用emun记录起来
- 支持动态加载插件
- 提供异常拦截机制 -- 处理注册插件插件的情况
- 暴露接口、处理钩子返回的结构时要注意代码安全
参考
Designing a JavaScript Plugin System | CSS-Tricks
干货!撸一个webpack插件(内含tapable详解+webpack流程) - 掘金
来源:juejin.cn/post/7344670957405126695
拖拽神器:Pragmatic-drag-and-drop!
哈喽,大家好 我是
xy👨🏻💻。今天给大家分享一个开源的前端最强拖拽组件 —pragmatic-drag-and-drop!
前言
在前端开发中,拖拽功能是一种常见的交互方式,它能够极大提升用户体验。

今天,我们要介绍的是一个开源的前端拖拽组件 — pragmatic-drag-and-drop,它以其轻量级、高性能和强大的兼容性,成为了前端开发者的新宠。
什么是 pragmatic-drag-and-drop?
pragmatic-drag-and-drop 是由 Atlassian 开源的一款前端拖拽组件。

Atlassian,作为全球知名的软件开发公司,其核心产品 Trello、Jira 和 Confluence 都采用了 pragmatic-drag-and-drop 组件。
这不仅证明了该组件的实用性和可靠性,也反映了 Atlassian 对前端交互体验的极致追求。
组件的作者:Alex Reardon,也是流行 React 开源拖拽组件 react-beautiful-dnd 的开发者。
pragmatic-drag-and-drop 继承了作者对拖拽交互的深刻理解,支持多种拖拽场景,包括列表、面板、表格、树、网格、绘图和调整大小等。
为什么选择 pragmatic-drag-and-drop?
- 轻量化:核心包大小仅为
4.7KB,轻量级的体积使得它在加载速度上具有优势。 - 灵活性:提供
无头(headless)解决方案,开发者可以完全自定义视觉效果和辅助技术控制。 - 框架无关性:适用于
所有主流前端框架,如 React、Svelte、Vue 和 Angular。 - 高性能:支持
虚拟化,适应各种复杂的用户体验,确保拖拽操作流畅。 - 全平台覆盖:在
所有主流浏览器和移动设备上运行良好,包括 Firefox、Safari、Chrome 以及 iOS 和 Android 设备。 - 无障碍支持:为
非鼠标操作用户提供友好体验,确保所有用户都能享受拖拽体验。
应用场景
pragmatic-drag-and-drop 功能适用于多种场景,包括但不限于:
- 任务管理应用:通过拖放操作,轻松实现卡片式任务列表的排序与整理。
- 文档管理系统:简化文件夹和文件的移动与组织过程,提高工作效率。
- 在线编辑器:提供直观的内容布局调整体验,增强用户自定义能力。
- 数据可视化工具:允许用户动态调整图表元素位置,实现更丰富的信息展示。
- 设计工具:在组件库中轻松排列组合元素,激发创意无限可能。
案例演示
列表拖拽排序:

面板拖拽:

表格拖拽排序:

树形节点拖拽:

绘图功能鼠标拖动:

可拖动棋子的棋盘:

在线演示地址:https://atlassian.design/components/pragmatic-drag-and-drop/examples
最后
如果觉得本文对你有帮助,希望能够给我点赞支持一下哦 💪 也可以关注wx公众号:
前端开发爱好者回复加群,一起学习前端技能 公众号内包含很多实战精选资源教程,欢迎关注
来源:juejin.cn/post/7406139000265752639
为什么不写注释?写“为什么不”注释?

代码是用结构化的机器语言编写的,而注释是用富有表现力的人类语言编写的。人类语言让注释比代码更具表达性和沟通能力。代码中也包含少量类似于人类语言的内容,例如标识符。所谓“注释要写为什么,而不是写做了什么”,意思是尽可能将信息嵌入到标识符中。并非所有“做了什么”都能这样嵌入,但很多都可以。
近年来,我看到越来越多的人主张,连“为什么”也不应该出现在注释中,它们可以通过LongFunctionNames(长函数名)或测试用例的名称体现出来。几乎所有“自解释”代码库都通过增加标识符来进行文档化。
那么,有哪些人类表达的内容是无法通过更多代码来呈现的呢?
反面信息,也就是引起注意系统中“没有的”东西。“为什么不”的问题。
一个近期的例子
这是一个来自《Logic for Programmers》的例子。由于技术上的复杂原因,epub 电子书构建过程中未能将数学符号(\forall)正确转换为符号(∀)。我写了一个脚本,手动遍历并将数学字符串中的标记替换为对应的 Unicode 等价符号。最简单的方法是对每个需要替换的 16 个数学符号依次调用string = string.replace(old, new)(一些数学字符串包含多个符号)。
这种方法效率非常低,我可以将所有 16 个替换在一次遍历中完成。但那将是一个更复杂的解决方案。因此,我选择了简单的方法,并加了一条注释:
对每个字符串进行了 16 次遍历。
整本书中只有 25 个数学字符串,大多数字符少于 5 个。
因此,速度仍然足够快。
你可以把这看作是“为什么我用了慢的代码”的解释,但也可以理解为“为什么不用快的代码”。它引起了对“没有的东西”的关注。
为什么要有注释
如果慢速代码没有造成任何问题,为什么还要写注释呢?
首先,这段代码可能以后会成为问题。如果将来的《Logic for Programmers》版本中有上百个数学字符串,而不是几十个,这个构建步骤将成为整个构建过程的瓶颈。现在留下标注,方便将来知道该修复什么。
即使这段代码永远不会有问题,注释仍然很重要:它表明我意识到了权衡。假设两年后我回到这个项目,打开epub_math_fixer.py,看到我这段糟糕的慢代码。我会问自己:“当时为什么写了这么糟糕的代码?” 是因为缺乏经验,时间紧迫,还是纯粹的随机失误?
这条反面注释告诉我,我知道这段代码很慢,考虑过替代方案,并决定不做优化。这样,我不必花大量时间重新调查,却得出同样的结论。
为什么这不能通过代码“自解释”(self-documented)
当我第一次尝试这个想法时,有人告诉我,我的反面注释没有必要,只需将函数命名为RunFewerTimesSlowerAndSimplerAlgorithmAfterConsideringTradeOffs。除了名字过长、未解释权衡点,并且如果我优化了代码,还得在所有地方修改函数名外……这实际上使代码更不能自解释。因为它没有告诉你函数实际做了什么。
核心问题在于,函数和变量的标识符只能包含一条信息。我无法在一个标识符中同时存储“函数做了什么”和“它作出了什么权衡”。
那么用测试代替注释呢?我猜你可以写一个测试,用grep查找书中的数学块,并在超过 80 个时失败?但这并没有直接测试EpubMathFixer。函数本身没有任何内容可以让你直接关联上。
这是“自解释”反面信息的根本问题。“自解释”是伴随代码书写的,它描述了代码在做什么。而反面信息是关于代码没有做什么的。
最后的思考
我在想,是否可以将“为什么不”注释视为反事实的一个例子。如果是这样,那么“人类沟通的抽象”是否一般都无法“自解释”?你能“自解释”一个比喻吗?不确定性呢?伦理主张呢?
来源:juejin.cn/post/7413311432970993704
用了Go的匿名结构体,搬砖效率更高,产量更足了
今天给大家分享一个使用匿名结构体,提升Go编程效率的小技巧,属于在日常写代码过程中积累下来的一个提升自己效率的小经验。
这个技巧之所以提效率主要体现在两方面:
- 减少一些不会复用的类型定义
- 节省纠结该给类型起什么名字的时间
尤其第二项,通过匿名结构体这个名字就能体现出来,它本身没有类型名,这能节省不少想名字的时间。再一个也能减少起错名字给其他人带来的误解,毕竟并不是所有人编程时都会按照英文的词法做命名的。
下面我先从普通结构体说起,带大家看看什么情形下用匿名结构体会带来编码效率的提升。
具名结构体
具名结构体就是平时用的普通结构体。
结构体大家都知道,用于把一组字段组织在一起,来在Go语言里抽象表达现实世界的事物,类似“蓝图”一样。
比如说定义一个名字为Car的结构体在程序里表示“小汽车”
// 定义结构体类型'car'
type car struct {
make string
model string
mileage int
}
用到这个结构体的地方通过其名字引用其即可,比如创建上面定义的结构体的实例
// 创建car 的实例
newCar := car{
make: "Ford",
model: "taurus",
mileage: 200000,
}
匿名结构体
匿名结构体顾名思义就是没有名字的结构体,通常只用于在代码中仅使用一次的结构类型,比如
func showMyCar() {
newCar := struct {
make string
model string
mileage int
}{
make: "Ford",
model: "Taurus",
mileage: 200000,
}
fmt.Printlb(newCar.mode)
}
上面这个函数中声明的匿名结构体赋值给了函数中的变量,所以只能在函数中使用。
如果一个结构体初始化后只被使用一次,那么使用匿名结构体就会很方便,不用在程序的package中定义太多的结构体类型,比如在解析接口的响应到结构体后,就可以使用匿名结构体
用于解析接口响应
func createCarHandler(w http.ResponseWriter, req *http.Request) {
defer req.Body.Close()
decoder := json.NewDecoder(req.Body)
newCar := struct {
Make string `json:"make"`
Model string `json:"model"`
Mileage int `json:"mileage"`
}{}
err := decoder.Decode(&newCar)
if err != nil {
log.Println(err)
return
}
......
return
}
类似上面这种代码一般在控制层写,可以通过匿名结构体实例解析到请求后再去创建对应的DTO或者领域对象供服务层或者领域层使用。
有人会问为什么不直接把API的响应解析到DTO对象里,这里说一下,匿名结构体的使用场景是在觉得定一个Struct 不值得、不方便的情况下才用的。 比如程序拿到接口响应后需要按业务规则加工下才能创建DTO实例这种情况,就很适合用匿名结构体先解析响应。
比用map更健壮
这里再说一点使用匿名结构体的好处。
使用匿名解析接口响应要比把响应解析到map[string]interface{}类型的变量里要好很多,json数据解析到匿名结构体的时候在解析的过程中会进行类型检查,会更安全。使用的时候直接通过s.FieldName访问字段也比map访问起来更方便和直观。
用于定义项目约定的公共字段
除了上面这种结构体初始化后只使用一次的情况,在项目中定义各个接口的返回或者是DTO时,有的公共字段使用匿名结构体声明类型也很方便。
一般在启动项目的时候我们都会约定项目提供的接口的响应值结构,比如响应里必须包含Code、Msg、Data三个字段,每个接口会再细分定义返回的Data的结构,这个时候用匿名结构题能节省一部分编码效率。
比如下面这个Reponse的结构体类型的定义
type UserCouponResponse struct {
Code int64 `json:"code"`
Msg string `json:"message"`
Data []*struct {
CouponId int `json:"couponId"`
ProdCode string `json:"prodCode"`
UserId int64 `json:"userId"`
CouponStatus int `json:"couponStatus"`
DiscountPercentage int `json:"discount"`
} `json:"data"`
}
就省的先去定义一个UserCoupon类型
type UserCoupon struct {
CouponId int `json:"couponId"`
ProdCode string `json:"prodCode"`
UserId int64 `json:"userId"`
CouponStatus int `json:"couponStatus"`
DiscountPercentage int `json:"discount"`
}
再在Response声明里使用定义的UserCoupon了
type UserCouponResponse struct {
Code int64 `json:"code"`
Msg string `json:"message"`
Data []*UserCoupon `json:"data"`
}
当然如果UserCoupon是你的项目其他地方也会用到的类型,那么先声明,顺带在Response结构体里也使用是没问题的,只要会多次用到的类型都建议声明成正常的结构体类型。
还是那句话匿名结构体只在你觉得"这还要定义个类型?”时候使用,用好的确实能提高点代码生产效率。
总结
本次的分享就到这里了,内容比较简单,记住这个口诀:匿名结构体只在你写代码时觉得这还要定义个类型,感觉没必要的时候使用,采纳这个技巧,时间长了还是能看到一些自己效率的提高的。
来源:juejin.cn/post/7359084604663709748
不到50元如何自制智能开关?
前言
家里床头离开关太远了,每次躺在床上玩手机准备睡觉时候还的下去关灯,着实麻烦,所以用目前仅有的一点单片机知识,做了一个小小小的智能开关,他有三个模块构成,如下:
- 主模块是ESP32(20元)
他是一个低成本、低功耗的微控制器,集成了 Wi-Fi 和蓝牙功能,因为我们需要通过网络去开/关灯,还有一个ESP8266,比这个便宜,大概6 块钱,但是他烧录程序的时候比较慢。

- 光电开关(10元)
这个可有可无,这个是用来当晚上下班回家后,开门自动开灯使用的,如果在他前面有遮挡,他的信号线会输出高/低电压,这个取决于买的是常开还是常闭,我买的是常开,当有物体遮挡时,会输出低电压,所以当开门时,门挡住了它,它输出低电压给ESP32,ESP32读取到电压状态后触发开灯动作。
家里床头离开关太远了,每次躺在床上玩手机准备睡觉时候还的下去关灯,着实麻烦,所以用目前仅有的一点单片机知识,做了一个小小小的智能开关,他有三个模块构成,如下:
- 主模块是ESP32(20元)
他是一个低成本、低功耗的微控制器,集成了 Wi-Fi 和蓝牙功能,因为我们需要通过网络去开/关灯,还有一个ESP8266,比这个便宜,大概6 块钱,但是他烧录程序的时候比较慢。

- 光电开关(10元)
这个可有可无,这个是用来当晚上下班回家后,开门自动开灯使用的,如果在他前面有遮挡,他的信号线会输出高/低电压,这个取决于买的是常开还是常闭,我买的是常开,当有物体遮挡时,会输出低电压,所以当开门时,门挡住了它,它输出低电压给ESP32,ESP32读取到电压状态后触发开灯动作。

- 舵机 SG90(5元)
这是用来触发开/关灯动作的设备,需要把它用胶粘在开关上,他可以旋转0-180度,力度也还行,对于开关足够了。还有一个MG90舵机,力度特别大,但是一定要买180度的,360度的舵机只能正转和反转,不能控制角度。


- 杜邦线(3元)

Arduino Ide
Arduino是什么就不说了,要烧录代码到ESP32,需要使用官方乐鑫科技提供的ESP-IDF工具,它是用来开发面向ESP32和ESP32-S系列芯片的开发框架,但是,Arduino Ide提供了一个核心,封装了ESP-IDF一些功能,便于我们更方便的开发,当然Arduino还有适用于其他开发板的库。
Arduino配置ESP32的开发环境比较简单,就是点点点、选选选即可。
接线
下面就是接线环节,先看下ESP32的引脚,他共有30个引脚,有25个GPIO(通用输入输出)引脚,如下图中紫色的引脚,在我们的这个设备里,舵机和光电开关都需要接入正负级到下图中的红色(VCC)和黑色(GND)引脚上,而他们都需要在接入一个信号作为输出/输入点,可以在着25个中选择一个,但还是有几个不能使用的,比如有一些引脚无法配置为输出,只用于作输入,还有RX和TX,我们这里使用26(光电开关)和27(舵机)引脚就可以了。

esp32代码
下面写一点点代码,主要逻辑很简单,创建一个http服务器,用于通过外部去控制舵机的转向,外部通过http请求并附带一个角度参数,在通过ESP32Servo这个库去使舵机角度发生改变。
esp32的wifi有以下几种模式。
- Station Mode(STA模式): 在STA模式下,esp32可以连接到一个wifi,获取一个ip地址,并且可以与网络中的其他设备进行通信。
- Access Point Mode(AP模式): 在AP模式下,它充当wifi热点,其他设备可以连接到esp32,就像连接到普通路由器一样,一般用作配置模式使用,经常买到的智能设备,进入配置模式和后,他会开一个热点,你的手机连接到这个热点后,在通过他们提供的app去配置,就是用这种模式。
- Soft Access Point Mode(SoftAP模式): 同时工作在STA模式和AP模式下。
下一步根据自己的逻辑,比如当光电开关被遮挡时,并且又是xxxx时,就开灯,或者当xxx点后就关灯。
#include
#include
#include
#include
#include
#define SERVO_PIN_NUMBER 27
#define STATE_PIN_NUMBER 26
#define CLOSE_VALUE 40
#define OPEN_VALUE 150
const char* ssid = "wifi名称";
const char* password = "wifi密码";
AsyncWebServer server(80);
Servo systemServo;
bool openState = false;
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_AP_STA);
WiFi.begin(ssid, password);
Serial.println("\nConnecting");
while (WiFi.status() != WL_CONNECTED) {
Serial.print(".");
delay(100);
}
systemServo.attach(SERVO_PIN_NUMBER);
systemServo.write(90);
openState = false;
write_state(CLOSE_VALUE);//启动时候将灯关闭
Serial.print("Local ESP32 IP: ");
Serial.println(WiFi.localIP());
pinMode(STATE_PIN_NUMBER, INPUT);
int timezone = 8 * 3600;
configTime(timezone, 0, "pool.ntp.org");
server.on("/set_value", HTTP_GET, [](AsyncWebServerRequest * request) {
if (request->hasParam("value")) {
String value = request->getParam("value")->value();
int intValue = value.toInt();
write_state(intValue);
request->send(200, "text/plain", "value: " + String(intValue));
} else {
request->send(400, "text/plain", "error");
}
});
server.begin();
}
void write_state(int value) {
openState = value < 90 ? false : true;
systemServo.write(value);
delay(100);
systemServo.write(90);
}
void loop() {
time_t now = time(nullptr);
struct tm *timeinfo;
timeinfo = localtime(&now);
//指定时间关灯
int currentMin = timeinfo->tm_min;
int currentHour = timeinfo->tm_hour;
if (currentHour == 23 && currentMin == 0 && openState ) {
write_state(CLOSE_VALUE);
openState = false;
}
//下班开灯
if (digitalRead(STATE_PIN_NUMBER) == 0 && currentHour > 18 && !openState) {
write_state(OPEN_VALUE);
openState = true;
}
}
Android下控制
当然,还得需要通过外部设备进行手动开关,这里就简单写一个Android程序,上面写了一个http服务,访问esp32的ip地址,发起一个http请求就可以了,所以浏览器也可以,但更方便的是app,效果如下。

package com.example.composedemo
import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import com.example.composedemo.ui.theme.ComposeDemoTheme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
class MainActivity : ComponentActivity() {
private val state = State()
private lateinit var sharedPreferences: SharedPreferences
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sharedPreferences = getPreferences(Context.MODE_PRIVATE)
state.ipAddressChange = {
with(sharedPreferences.edit()) {
putString("ipAddress", it)
apply()
}
}
state.slideChange = {setValue(it) }
state.lightChange = {
Log.i(TAG, "onCreate: $it")
if (it) openLight()
if (!it) closeLight()
}
state.esp32IpAddress.value = sharedPreferences.getString("ipAddress", "")!!
setContent {
ComposeDemoTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
SlidingOvalLayout(state)
}
}
}
}
private fun closeLight() =setValue(40)
private fun openLight() = setValue(150)
private fun setValue(value: Int) {
sendHttpRequest("http://${state.esp32IpAddress.value}/set_value/?value=$value:")
}
private fun sendHttpRequest(url: String) {
GlobalScope.launch(Dispatchers.IO) {
try {
val connection = URL(url).openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.connect()
if (connection.responseCode == HttpURLConnection.HTTP_OK) {
val response = connection.inputStream.bufferedReader().readText()
withContext(Dispatchers.Main) {
}
} else {
withContext(Dispatchers.Main) {
}
}
connection.disconnect()
} catch (e: Exception) {
e.printStackTrace()
withContext(Dispatchers.Main) {
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
ComposeDemoTheme {
}
}
ui组件
package com.example.composedemo
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layout
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.example.composedemo.ui.theme.ComposeDemoTheme
const val TAG = "TAG"
@Composable
fun SlidingOvalLayout(state: State) {
var offset by remember { mutableStateOf(Offset(0f, 0f)) }
var parentWidth by remember { mutableStateOf(0) }
var sliderValue by remember { mutableStateOf(0) }
var closeStateColor by remember { mutableStateOf(Color(0xFFDF2261)) }
var openStateColor by remember { mutableStateOf(Color(0xFF32A34B)) }
Box(
modifier = Modifier
.fillMaxSize()
.padding(40.dp)
.width(100.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally
) {
Column {
Box() {
TextField(
value = state.esp32IpAddress.value,
onValueChange = {
state.esp32IpAddress.value = it
state.ipAddressChange(it)
},
colors = TextFieldDefaults.textFieldColors(
disabledTextColor = Color.Transparent,
backgroundColor = Color(0xFFF1EEF1),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent
),
modifier = Modifier
.fillMaxWidth()
.background(color = Color(0xFFF1EEF1))
)
}
Box() {
Column() {
Text(text = sliderValue.toString())
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Slider(
value = sliderValue.toFloat(),
onValueChange = {
sliderValue = it.toInt()
state.slideChange(sliderValue)},
valueRange = 0f..180f,
onValueChangeFinished = {
},
colors = SliderDefaults.colors(
thumbColor = Color.Blue,
activeTrackColor = Color.Blue
)
)
}
}
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(70.dp)
.shadow(10.dp, shape = RoundedCornerShape(100.dp))
.background(color = Color(0xFFF1EEF1))
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
parentWidth = placeable.width
layout(placeable.width, placeable.height) {
placeable.place(0, 0)
}
}
) {
Box(
modifier = Modifier
.offset {
if (state.lightValue.value) {
IntOffset((parentWidth - 100.dp.toPx()).toInt(), 0)
} else {
IntOffset(0, 0)
}
}
.graphicsLayer {
translationX = offset.x
}
.clickable() {
state.lightValue.value = !state.lightValue.value
state.lightChange(state.lightValue.value )
}
.pointerInput(Unit) {
}
.background(
color = if(state.lightValue.value) openStateColor else closeStateColor,
shape = RoundedCornerShape(100.dp)
)
.size(Dp(100f), Dp(80f))
)
}
}
}
}
@Preview
@Composable
fun PreviewSlidingOvalLayout() {
ComposeDemoTheme {
}
}
class State {
var esp32IpAddress: MutableState = mutableStateOf("")
var lightValue :MutableState<Boolean> = mutableStateOf(false)
var ipAddressChange :(String)->Unit={}
var slideChange:(Int)->Unit={}
var lightChange:(Boolean)->Unit={}
}
来源:juejin.cn/post/7292245569482407988
从《逆行人生》聊聊中年程序员的出路

赶在下架前去看了《逆行人生》。
这部电影讲述了高志垒——一个架构师,被裁员后,在找工作两个月颗粒无收的情况下,被逼无奈去跑外卖的故事。
个人感觉还是很值得一看的,是一部很好的现实题材电影,并没有网上说的资本迫害打工人还要打工人努力奋斗的感觉。
有年轻人说,难以共情。70万年薪的人最后要落到为了 15k 的月薪而奔波,他不理解为什么。然而就我亲身经历而言,无路可走的时候,我们的确会做这样的选择。
我们先来看看中年程序员有哪些选择。
中年程序员有哪些出路?

继续打工
打工,无疑是多数人的选择。毕竟上一天班赚一天的钱,这种稳稳的幸福还是大部分人的追求。但打工也不能停滞不前,还是要不断学习、拓展自己的能力,尤其是我们IT行业,技术更新迭代快。如果不学习,很可能 3 到 5 年就被淘汰了。
程序员要晋升、跳槽,主要学习方向以下两种:
- 拓展技术的深度与广度:高级开发、架构师、热门行业的开发如AI等;
- 向外拓展自己的能力:培训机构老师、高校老师;技术管理;
自己单干
继续打工,无疑都会碰到被裁员的风险,你个人的命运始终掌握在老板甚至顶头上司的手里。如果你不甘于此,就要开创性地走其他路了。这对个人的挑战都是极大的。
程序员可以凭借技术能力逐渐向外扩展:
- 独立开发:承接项目或者自研产品
- 创业:成立公司、团队,完成开发工作
彻底转行
也有部分人被彻底伤透了心,完全不再进入这个行业了,转向其他行业了。
- 网络兼职:写手、影视剪辑等;
- 中年三保、铁人三项:保安、保姆、保洁、快递、司机、外卖。这个是被大家调侃最多的;
- 其他行业的打工者:如制造业、外贸等行业;
- 开店或者创业:存上一笔钱开店或者做一间自己喜欢的公司,也是一些人的选择。
我们应该如何选择?
如上所见,程序员能做的选择还是比较多的。我们将这些工作列个表,列一下所需要的能力与所承担的责任,以及最后的风险,以便做选择:

可以看到,每个方向其实都是有风险的,并没有不存在无风险的职业与方向。那是不是我们就可以完全凭借个人喜好来决定呢?并非如此,这些选择对大部分人而言,还是有优劣之分的。
不推荐铁人三项、中年三宝

首先,我个人其实非常不建议程序员转行去做起他行业的,除非迫不得已,尤其是从事体力劳动。
因为这需要消耗大量的体力与时间。中年人无法靠比拼体力取胜,工作时间长,也无法取得工作生活平衡。在电影《逆行人生》中,高志垒虽然赢了第一个单王,但可以看出其靠的更多是运气,行业老大哥或退出竞赛、或家里有事提早离开。
另外就是,AI 技术发展和市场供需变化。不久前武汉的萝卜快跑落地,相信大部分滴滴司机都感受到了被淘汰的可能。而且这类工作市场基本上已经饱和,所以薪酬只会越来越低。
其他的网络兼职、去制造业服务业打工,这些都是门槛低,程序员即使有技术与能力,也不见得有任何优势的,所以也是不推荐的。
而开店或按自己的兴趣来创业,则非常看你个人能力了,同样需要更谨慎的考虑,当然你如果家财万贯,倒是可以任性一把。
更推荐提早规划、提早行动
剩下的职业方向其实都是推荐的,因为多多少少跟我们自身学习的技术是相关的。将我们的能力逐步往外扩,逐渐走出舒适圈,是更合适的一个发展路径。但是需要注意的是,建议尽早立下目标,提前规划,尽快行动的。
如,希望做老师,可以提早在企业内部做讲师、技术讲师,给新人讲解。锻炼好自己的沟通表达能力,多想想如何让新人更好地融入企业、进入工作状态。
又如,你想自己创业,那可以开始就留意你手头上做的产品是如何开发、运营的。公司如何分配人力物力去做的,如何做商业变现的,如何寻找客户的等等这些问题。不仅要站在技术角度、也要站在公司的角度多思考、多学习、多实践。甚至在时机成熟的时候,提出转岗去做产品、技术管理,更早地锻炼自己所需的创业的能力,能让自己日后的路走的更顺。
高志垒为何还是选择送外卖?

回到电影,既然都不建议程序员从事体力劳动,高志垒好好的一个架构师,也是有脑子的,为啥最后还是选择了外卖员呢?
首先,从影片一开始可以看出,高志垒选择了架构师或者技术管理偏技术方向,因其手头上还有一线开发的任务。显然对于 45 岁的他,在打工这条路上几乎已经到顶了。
然而,他并没有做好职业规划,甚至从未考虑过失业的风险。在突然失业时,才发现市场上几乎找不到自己的职位、薪酬,最后简历也是乱投一气了;而中产返贫三件套:高额房贷、全职太太、国际学校,他几乎全都拥有;并且还大笔地投资了 P2P ,因其爆雷导致家庭财产大量损失;再加上其父亲突发重病,住院急需要钱。
所有的状况同时出现,所有的压力压在身上,在两个月投递简历无果时,他听说送外卖能补上房贷月供差额的数目,宛如找到救命稻草一般,毅然加入了外卖行业。
如何避免陷入被动状况?
如何避免我们也陷入高志垒的状况?
除了像上面说的提早积攒自己的能力,提早做规划、更早地行动外,程序员也应提升技能多样性,特别是专业外的技能;同时在职业中后期应寻找到更利于个人发展的公司或项目;还需要拓展人脉,保持与行业内的沟通交流;在最后,保持健康的生活习惯和平衡好工作,让自己的职业寿命尽可能地延长。

而在财务上,做好失业准备、甚至为后续独立开发、创业等积攒资金都是必要的,所以需要采取一些措施,做好家庭财务的规划,如:
- 留出紧急备用金:为应对突发事件,如失业或疾病,应建立足够的紧急基金,一般建议为家庭日常开支的3-6个月。
- 谨慎投资:只投资自己熟悉的产品;了解自身的风险承受能力再投资;同时避免将所有资金投入到单一的高风险产品中,如P2P,应进行资产配置,分散风险。
- 购买保险:为家庭成员购买适当的健康保险,以减轻因病致贫的风险。
- 做好财务预算、规划:每年、每月做好财务预算;同时对于房贷和教育投资等大额支出,应进行详细的财务规划,确保在收入中断时也能应对。
- 增加收入来源:尽可能地增加家庭收入来源,比如配偶就业或开展副业,减少对单一收入的依赖。
总结与思考

在戏里的高志垒无疑是幸运的,家庭和睦,家人都给予最大的支持,愿意一起度过难关。再加上自己开发的小程序“路路通”,同事间互助互利,最后,成功拿到了单王,并帮家里度过经济危机。
然而最后的结局,高志垒并没有“逆袭”人生,而是在“逆行”人生中,调整了自己。最后他卖掉了大房子,搬到了小房子住,老婆依然在工作,孩子也放弃了就读国际学校、老人靠自身意志力完成了康复。
这也是我觉得这部电影还算现实主义之处。并没有理想中的事情发生,就像现实生活中那些受挫的人们一样,最后选择降低生活标准,继续前行。
最后的最后,问一下大家,如果你面临电影结尾彩蛋中的情景,有一个外卖公司的高层老板对你开发的“路路通”小程序感兴趣,你会如何选择?
- 卖掉小程序,拿钱走人
- 加入外卖公司,继续开发
- 不卖,开源
欢迎留下你的答案与思考,一起讨论。
来源:juejin.cn/post/7414732910240972835
「滚动绽放」页面滚动时逐渐展示/隐藏元素
本文将介绍如何使用HTML、CSS和JavaScript代码实现页面在滚动时元素逐渐出现/隐藏。这个动画效果会在用户滚动/隐藏页面时从不同方向逐渐显示出一组彩色方块🏳️🌈
HTML结构
首先,HTML部分包含了一个<section>元素和一个名为container的容器,其中包含了多个box元素。别忘了引入外部CSS和JS文件;
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="./style.css">
<title>Scroll To Reveal Animation</title>
</head>
<body>
<section>
<h2>Scroll To Reveal</h2>
</section>
<div class="container">
<!-- 调试CSS样式阶段 -->
<!-- <div class="box"></div>
<div class="box"></div>
<div class="box"></div>
<div class="box"></div> -->
</div>
<script src="./index.js"></script>
</body>
</html>
CSS样式
接着,设置一些基本的全局样式和居中布局、背景颜色和文字颜色;
- 关于
container容器,使用grid布局三列。 - 对于
box容器,这部分CSS伪类代码定义了元素在动画中的位置和缩放变换。解释一下每个选择器的作用:
.box:nth-child(3n + 1):选择容器中每隔3个元素的第一个方块元素(这里表示第一列)。沿X轴向左平移400像素,缩放为0,即隐藏起来。.box:nth-child(3n + 2):选择容器中每隔3个元素的第二个方块元素(这里表示第二列)。沿Y轴向下平移400像素,缩放为0。.box:nth-child(3n + 3):选择容器中每隔3个元素的第三个方块元素(这里表示第三列)。沿X轴向右平移400像素,缩放为0。
这些选择器定义了方块元素的初始状态,使它们在页面加载时处于隐藏状态。并且预设了.box.active激活状态的样式。
- 将其平移到原始位置并恢复为原始尺寸,即显示出来。当滚动触发相应的事件时,方块元素将根据添加或移除
active类来决定是逐渐显示或隐藏。
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: Arial, Helvetica, sans-serif;
}
body {
display: flex;
flex-direction: column;
justify-content:center;
align-items: center;
background-color: #111;
color: #fff;
overflow-x: hidden;
}
section {
min-height: 100vh;
display: flex;
justify-content:center;
align-items: center;
}
section h2 {
font-size: 8vw;
font-weight: 500;
}
.container {
width: 700px;
position: relative;
top: -200px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 30px;
}
.container .box {
width: 200px;
height: 200px;
background-color: #fff;
border-radius: 10px;
position: relative;
top: 50vh;
transition: .5s;
}
.container .box:nth-child(3n + 1) {
transform: translate(-400px, 0) scale(0);
}
.container .box:nth-child(3n + 2) {
transform: translate(0, 400px) scale(0);
}
.container .box:nth-child(3n + 3) {
transform: translate(400px, 0) scale(0);
}
.container .box.active {
transform: translate(0, 0) scale(1);
}
表现

JavaScript实现
最后,使用JavaScript生成每个方块并设置了随机的背景颜色,随后将它们添加到container容器中,通过监听滚动事件,使方块在用户滚动页面时根据位置添加类名否,应用CSS样式实现逐渐显示或隐藏;
- 定义
randomColor函数,用于生成随机的颜色值。这个函数会从一组字符中随机选择6个字符(每次循环随机取一个)作为颜色代码,并将其拼接成一个十六进制颜色值返回。 - 获取container容器元素,并创建一个文档片段
fragment用于存储循环创建出来带有背景色的.box方块元素,最后将文档片段附加到container中。 - 定义
scrollTrigger函数,绑定到窗口的滚动事件上。在这个函数中,遍历每个方块,检查相对于窗口顶部的偏移量,如果小于等于当前滚动的距离,则添加active类,显示方块。反之,则移除active类,隐藏方块。
/**创建随机色 */
const randomColor = () => {
const chars = "1234567890abcdef",
colorLegh = 6;
let color = '#';
for (let i = 0; i < colorLegh; i++) {
const p = Math.floor(Math.random() * chars.length);
color += chars.substring(p, p + 1);
};
return color;
};
/**创建DOM */
const container = document.querySelector('.container'),
fragment = document.createDocumentFragment();
for (let i = 0; i < 60; i++) {
const box = document.createElement('div');
box.style.backgroundColor = randomColor();
box.classList.add('box');
fragment.appendChild(box);
};
container.appendChild(fragment);
/**创建动画 */
const randomColorBlock = document.querySelectorAll('.box');
const scrollTrigger = () => {
randomColorBlock.forEach((box) => {
if (box.offsetTop <= window.scrollY) {
box.classList.add('active')
} else {
box.classList.remove('active')
}
});
};
window.addEventListener('scroll', scrollTrigger);
总结
通过本篇文章的详细介绍,相信能够帮助你更好地使用CSS和JavaScript来创建一个滚动显示元素动画,从而理解掌握和应用这个效果。通过设置合适的样式和脚本来控制元素的显示和隐藏为网页提供了生动和吸引力。
希望这篇文章对你在开发类似交互动画效果时有所帮助!如果你对这个案列还有任何问题,欢迎在评论区留言或联系(私信)我。码字不易🥲,不要忘了三连鼓励🤟,谢谢阅读,Happy Coding🎉!
源码我放在了GitHub,里面还有一些酷炫的效果、动画案列,喜欢的话不要忘了 starred 不迷路!
来源:juejin.cn/post/7280926568854781987
Java音视频文件解析工具
@[toc]
小伙伴们知道,松哥平时录了蛮多视频课程,视频录完以后,就想整理一个视频文档出来,在整理视频文档的时候,就会遇到一个问题,就是怎么统计视频时长?
特别是有时候为了方便大家看到每一集视频的时长,我要把视频目录整理成下面这个样子:

这个逐集去查看就很麻烦,一套视频动辄几百集,挨个统计不现实,也不符合咱们程序员做事风格。
那么怎么办呢?
一开始我是使用 Python 去解决的,Python 做这样一个小工具其实特别方便,简简单单 30 行代码左右就能搞定了。之前的课程的这些时间统计我基本上都是用 Python 去完成的。
不过最近松哥发现 Java 里边其实也有一个视频处理的库,做这个事情也是非常方便,而且使用 Java 属于主场作战,就能够更加灵活的扩展功能了。
一 jave-all-deps
在 Java 开发中,处理音视频文件经常需要复杂的编解码操作,开发者通常需要依赖于外部库来实现这些功能,其中最著名的是 FFmpeg。然而,直接在 Java 中使用 FFmpeg 并不是一件容易的事,因为它需要处理本地库和复杂的命令行接口。
幸运的是,jave-all-deps 库提供了一个简洁而强大的解决方案,让 Java 开发者能够轻松地进行音视频文件的转码和处理。
jave-all-deps 是 JAVE2(Java Audio Video Encoder)项目的一部分,它是一个基于 ffmpeg 项目的 Java 封装库。JAVE2 通过提供一套简单易用的 API,允许 Java 开发者在不直接处理 ffmpeg 复杂命令的情况下,进行音视频文件的格式转换、转码、剪辑等操作。
jave-all-deps 库特别之处在于它集成了核心 Java 代码和所有支持平台的二进制可执行文件,使得开发者无需手动配置 ffmpeg 环境,即可在多个操作系统上无缝使用。
是不是非常方便?
整体上来说,jave-all-deps 帮我们解决了三大类问题:
- 跨平台兼容性问题:音视频处理往往涉及到不同的操作系统和硬件架构,jave-all-deps 库提供了针对不同平台的预编译 ffmpeg 二进制文件,使得开发者无需担心平台兼容性问题。
- 复杂的命令行操作:ffmpeg 虽然功能强大,但其命令行接口复杂且难以记忆。jave-all-deps 通过封装 ffmpeg 的命令行操作,提供了简洁易用的 Java API,降低了使用门槛。
- 依赖管理:在项目中集成音视频处理功能时,往往需要处理多个依赖项。jave-all-deps 库将核心代码和所有必要的二进制文件打包在一起,简化了依赖管理。
简单来说,就是你想在项目中使用 ffmpeg,但是又嫌麻烦,那么就可以使用 jave-all-deps 这个工具封装后的 ffmpeg,简单快捷!
二 具体用法
jave-all-deps 库提供了多种音视频处理功能,松哥这里来和大家演示几个常见的。
2.1 添加依赖
添加依赖有两种方式,一种就是添加所有的依赖库,如下:
<dependency>
<groupId>ws.schild</groupId>
<artifactId>jave-all-deps</artifactId>
<version>3.5.0</version>
</dependency>
这个库中包含了不同平台所依赖的库的内容。
也可以根据自己平台选择不同的依赖库,这种方式需要首先添加 java-core:
<dependency>
<groupId>ws.schild</groupId>
<artifactId>jave-core</artifactId>
<version>3.5.0</version>
</dependency>
然后再根据自己使用的不同平台,继续添加不同依赖库:
Linux 64 位 amd/intel:
<dependency>
<groupId>ws.schild</groupId>
<artifactId>jave-nativebin-linux64</artifactId>
<version>3.5.0</version>
</dependency>
Linux 64 位 arm:
<dependency>
<groupId>ws.schild</groupId>
<artifactId>jave-nativebin-linux-arm64</artifactId>
<version>3.5.0</version>
</dependency>
Linux 32 位 arm:
<dependency>
<groupId>ws.schild</groupId>
<artifactId>jave-nativebin-linux-arm32</artifactId>
<version>3.5.0</version>
</dependency>
Windows 64 位:
<dependency>
<groupId>ws.schild</groupId>
<artifactId>jave-nativebin-win64</artifactId>
<version>3.5.0</version>
</dependency>
MacOS 64 位:
<dependency>
<groupId>ws.schild</groupId>
<artifactId>jave-nativebin-osx64</artifactId>
<version>3.5.0</version>
</dependency>
2.2 视频转音频
将视频文件从一种格式转换为另一种格式,例如将 AVI 文件转换为 MPEG 文件。
File source = new File("D:\\AI智能体\\mp4\\01.大模型有什么缺陷.mp4");
File target = new File("D:\\AI智能体\\mp4\\01.大模型有什么缺陷.mp3");
AudioAttributes audio = new AudioAttributes();
audio.setCodec("libmp3lame");
audio.setBitRate(128000);
audio.setChannels(2);
audio.setSamplingRate(44100);
EncodingAttributes attrs = new EncodingAttributes();
attrs.setOutputFormat("mp3");
attrs.setAudioAttributes(audio);
Encoder encoder = new Encoder();
encoder.encode(new MultimediaObject(source), target, attrs);
2.3 视频格式转换
将一种视频格式转换为另外一种视频格式,例如将 mp4 转为 flv:
File source = new File("D:\\AI智能体\\mp4\\01.大模型有什么缺陷.mp4");
File target = new File("D:\\AI智能体\\mp4\\01.大模型有什么缺陷.flv");
AudioAttributes audio = new AudioAttributes();
audio.setCodec("libmp3lame");
audio.setBitRate(64000);
audio.setChannels(1);
audio.setSamplingRate(22050);
VideoAttributes video = new VideoAttributes();
video.setCodec("flv");
video.setBitRate(160000);
video.setFrameRate(15);
video.setSize(new VideoSize(400, 300));
EncodingAttributes attrs = new EncodingAttributes();
attrs.setOutputFormat("flv");
attrs.setAudioAttributes(audio);
attrs.setVideoAttributes(video);
Encoder encoder = new Encoder();
encoder.encode(new MultimediaObject(source), target, attrs);
2.4 获取视频时长
这个就是松哥的需求了,我这块举个简单例子。
public class App {
static SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("mm:ss");
public static void main(String[] args) throws EncoderException {
System.out.println("输入视频目录:");
String dir = new Scanner(System.in).next();
File folder = new File(dir);
List<String> files = sort(folder);
outputVideoTime(files);
}
private static void outputVideoTime(List<String> files) throws EncoderException {
for (String file : files) {
File video = new File(file);
if (video.isFile() && !video.getName().startsWith(".") && video.getName().endsWith(".mp4")) {
MultimediaObject multimediaObject = new MultimediaObject(video);
long duration = multimediaObject.getInfo().getDuration();
String s = "%s %s";
System.out.println(String.format(s, video.getName(), DATE_FORMAT.format(duration)));
} else if (video.isDirectory()) {
System.out.println(video.getName());
outputVideoTime(sort(video));
}
}
}
public static List<String> sort(File folder) {
return Arrays.stream(folder.listFiles()).map(f -> f.getAbsolutePath()).sorted(String.CASE_INSENSITIVE_ORDER).collect(Collectors.toList());
}
}
这段代码基本上都是 Java 基础语法,没啥难的,我也就不多说了。有不明白的地方欢迎加松哥微信讨论。
其实 Java 解决这个似乎也不难,也就是 20 行代码左右,似乎和 Python 不相上下。
三 总结
jave-all-deps 库是 Java 音视频处理领域的一个强大工具,它通过封装 ffmpeg 的复杂功能,为 Java 开发者提供了一个简单易用的音视频处理解决方案。该库解决了跨平台兼容性问题、简化了复杂的命令行操作,并简化了项目中的依赖管理。无论是进行格式转换、音频转码还是其他音视频处理任务,jave-all-deps 库都是一个值得考虑的选择。
通过本文的介绍,希望能够帮助读者更好地理解和使用 jave-all-deps 库。
来源:juejin.cn/post/7415723701947154473
前端中的 File 和 Blob两个对象到底有什么不同❓❓❓
JavaScript 在处理文件、二进制数据和数据转换时,提供了一系列的 API 和对象,比如 File、Blob、FileReader、ArrayBuffer、Base64、Object URL 和 DataURL。每个概念在不同场景中都有重要作用。下面的内容我们将会详细学习每个概念及其在实际应用中的用法。
接下来的内容中我们将来了解 File和 Blob 这两个对象。
blob
在 JavaScript 中,Blob(Binary Large Object)对象用于表示不可变的、原始的二进制数据。它可以用来存储文件、图片、音频、视频、甚至是纯文本等各种类型的数据。Blob 提供了一种高效的方式来操作数据文件,而不需要将数据全部加载到内存中,这在处理大型文件或二进制数据时非常有用。
我们可以使用 new Blob() 构造函数来创建一个 Blob 对象,语法如下:
const blob = new Blob(blobParts, options);
- blobParts: 一个数组,包含将被放入 Blob 对象中的数据,可以是字符串、数组缓冲区(ArrayBuffer)、TypedArray、Blob 对象等。
- options: 一个可选的对象,可以设置 type(MIME 类型)和 endings(用于表示换行符)。
例如:
const blob = new Blob(["Hello, world!"], { type: "text/plain" });

Blob 对象主要有以下几个属性:
- size: 返回 Blob 对象的大小(以字节为单位)。
console.log(blob.size); // 输出 Blob 的大小
- type: 返回 Blob 对象的 MIME 类型。
console.log(blob.type); // 输出 Blob 的 MIME 类型
Blob 对象提供了一些常用的方法来操作二进制数据。
slice([start], [end], [contentType])
该方法用于从 Blob 中提取一部分数据,并返回一个新的 Blob 对象。参数 start 和 end 表示提取的字节范围,contentType 设置提取部分的 MIME 类型。
const blob = new Blob(["Hello, world!"], { type: "text/plain" });
const partialBlob = blob.slice(0, 5);
text()
该方法将 Blob 的内容读取为文本字符串。它返回一个 Promise,解析为文本数据。
blob.text().then((text) => {
console.log(text); // 输出 "Hello, world!"
});

arrayBuffer()
该方法将 Blob 的内容读取为 ArrayBuffer 对象,适合处理二进制数据。它返回一个 Promise,解析为 ArrayBuffer 数据。
const blob = new Blob(["Hello, world!"], { type: "text/plain" });
blob.arrayBuffer().then((buffer) => {
console.log(buffer);
});

stream()
该方法将 Blob 的数据作为一个 ReadableStream 返回,允许你以流的方式处理数据,适合处理大文件。
const stream = blob.stream();
Blob 的使用场景
Blob 对象在很多场景中非常有用,尤其是在 Web 应用中处理文件、图片或视频等二进制数据时。以下是一些常见的使用场景:
- 生成文件下载
你可以通过 Blob 创建文件并生成下载链接供用户下载文件。
const blob = new Blob(["This is a test file."], { type: "text/plain" });
const url = URL.createObjectURL(blob); // 创建一个 Blob URL
const a = document.createElement("a");
a.href = url;
a.download = "test.txt";
a.click();
URL.revokeObjectURL(url); // 释放 URL 对象
当我们刷新浏览器的时候发现是可以自动给我们下载图片了:

- 上传文件
你可以通过 FormData 对象将 Blob 作为文件上传到服务器:
const formData = new FormData();
formData.append("file", blob, "example.txt");
fetch("/upload", {
method: "POST",
body: formData,
}).then((response) => {
console.log("File uploaded successfully");
});
- 读取图片或其他文件
通过 FileReader API 可以将 Blob 对象读取为不同的数据格式。举例来说,你可以将 Blob 读取为图片并显示在页面上:
html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Documenttitle>
head>
<body>
<input type="file" id="fileInput" accept="image/*" />
<div id="imageContainer">div>
<script>
const fileInput = document.getElementById("fileInput");
const imageContainer = document.getElementById("imageContainer");
fileInput.addEventListener("change", function (event) {
const file = event.target.files[0];
if (file && file.type.startsWith("image/")) {
const reader = new FileReader();
reader.onload = function (e) {
const img = document.createElement("img");
img.src = e.target.result;
img.style.maxWidth = "500px";
img.style.margin = "10px";
imageContainer.innerHTML = "";
imageContainer.appendChild(img);
};
reader.readAsDataURL(file);
} else {
alert("请选择一个有效的图片文件。");
}
});
script>
body>
html>

- Blob 和 Base64
有时你可能需要将 Blob 转换为 Base64 编码的数据(例如用于图像的内联显示或传输)。可以通过 FileReader 来实现:
const reader = new FileReader();
reader.onloadend = function () {
const base64data = reader.result;
console.log(base64data); // 输出 base64 编码的数据
};
reader.readAsDataURL(blob); // 将 Blob 读取为 base64

File
File 是 JavaScript 中代表文件的数据结构,它继承自 Blob 对象,包含文件的元数据(如文件名、文件大小、类型等)。File 对象通常由用户通过 选择文件时创建,也可以使用 JavaScript 构造函数手动创建。
<input type="file" id="fileInput" />
<script>
document.getElementById("fileInput").addEventListener("change", (event) => {
const file = event.target.files[0];
console.log("文件名:", file.name);
console.log("文件类型:", file.type);
console.log("文件大小:", file.size);
});
script>
最终输出结果如下图所示:

我们可以使用 File 的方式来访问用户上传的文件,我们也可以手动创建 File 对象:
const file = new File(["Hello, world!"], "hello-world.txt", {
type: "text/plain",
});
console.log(file);

File 对象继承了 Blob 对象的方法,因此可以使用一些 Blob 对象的方法来处理文件数据。
- slice(): 从文件中获取一个子部分数据,返回一个新的 Blob 对象。
const blob = file.slice(0, 1024); // 获取文件的前 1024 个字节
- text(): 读取文件内容,并将其作为文本返回(这是 Blob 的方法,但可以用于 File 对象)。
file.text().then((text) => {
console.log(text); // 输出文件的文本内容
});
- arrayBuffer(): 将文件内容读取为 ArrayBuffer(用于处理二进制数据)。
file.arrayBuffer().then((buffer) => {
console.log(buffer); // 输出文件的 ArrayBuffer
});
- stream(): 返回一个 ReadableStream 对象,可以通过流式读取文件内容。
const stream = file.stream();

总结
Blob 是纯粹的二进制数据,它可以存储任何类型的数据,但不具有文件的元数据(如文件名、最后修改时间等)。
File 是 Blob 的子类,File 对象除了具有 Blob 的所有属性和方法之外,还包含文件的元数据,如文件名和修改日期。
你可以将 File 对象看作是带有文件信息的 Blob。
const file = new File(["Hello, world!"], "hello.txt", { type: "text/plain" });
console.log(file instanceof Blob); // true
二者在文件上传和二进制数据处理的场景中被广泛使用。Blob 更加通用,而 File 更专注于与文件系统的交互。
来源:juejin.cn/post/7413921824066551842
127.0.0.1 和 localhost,如何区分?
在实际开发中,我们经常会用到 127.0.0.1 和 localhost,那么,两者到底有什么区分呢?这篇文章,我们来详细了解 127.0.0.1 和 localhost。
127.0.0.1
127.0.0.1 是一个特殊的 IPv4 地址,通常被称为“环回地址”或“回送地址”。它被用于测试和调试网络应用程序。
当你在计算机上向 127.0.0.1 发送数据包时,数据不会离开计算机,而是直接返回到本地。这种机制允许开发者测试网络应用程序而不需要实际的网络连接。
127.0.0.1 是一个专用地址,不能用于实际的网络通信,仅用于本地通信。除了 127.0.0.1,整个 127.0.0.0/8(即 127.0.0.1 到 127.255.255.255)范围内的地址都是保留的环回地址。
在 IPv6 中,类似的环回地址是 ::1。如下图,为 MacOS的 /etc/hosts 文件中的内容:

使用场景
1. 开发和测试
- 开发人员常常使用
127.0.0.1来测试网络应用程序,因为它不需要实际的网络连接。 - 可以在本地机器上运行服务器和客户端,进行开发和调试。
2. 网络配置和诊断: - 使用
ping 127.0.0.1可以测试本地网络栈是否正常工作。 - 一些服务会绑定到
127.0.0.1以限制访问范围,仅允许本地访问。
示例
运行一个简单的 Python HTTP 服务器并访问它:
python -m http.server --bind 127.0.0.1 8000
然后在浏览器中访问 http://127.0.0.1:8000,你会看到服务器响应。通过 127.0.0.1,开发人员和系统管理员可以方便地进行本地网络通信测试和开发工作,而不需要依赖实际的网络连接。
优点
- 快速测试:可以快速测试本地网络应用程序。
- 独立于网络:不依赖于实际的网络连接或外部网络设备。
- 安全:由于数据包不离开本地计算机,安全性较高。
缺点
- 局限性:只能用于本地计算机,不适用于与其他计算机的网络通信。
- 调试范围有限:无法测试跨网络的通信问题。
localhost
localhost 是一个特殊的域名,指向本地计算机的主机名。
- 在 IPv4 中,
localhost通常映射到 IP 地址127.0.0.1。 - 在 IPv6 中,
localhost通常映射到 IP 地址::1。
localhost 被定义在 hosts 文件中(例如,在 Linux 系统中是 /etc/hosts 文件)。如下图,为 MacOS的 /etc/hosts 文件中的内容:

因此,当你在应用程序中使用 localhost 作为目标地址时,系统会将其解析为 127.0.0.1,然后进行相同的环回处理。
使用场景
- 开发和测试:开发人员常使用
localhost来测试应用程序,因为它不需要实际的网络连接。 - 本地服务:一些服务(如数据库、Web 服务器等)可以配置为只在
localhost上监听,以限制访问范围仅限于本地计算机,增强安全性。 - 网络调试:使用
localhost可以帮助诊断网络服务问题,确保服务在本地环境中正常运行。
优点
- 易记:相对 IP 地址,
localhost更容易记忆和输入。 - 一致性:在不同操作系统和环境中,
localhost通常都被解析为127.0.0.1。
缺点
- 依赖 DNS 配置:需要正确的 hosts 文件配置,如果配置错误可能导致问题。
- 与 127.0.0.1 相同的局限性:同样只能用于本地计算机。
两者对比
- 本质:
127.0.0.1是一个 IP 地址,而localhost是一个主机名。 - 解析方式:
localhost需要通过 DNS 或 hosts 文件解析为127.0.0.1,而127.0.0.1是直接使用的 IP 地址。 - 易用性:
localhost更容易记忆和输入,但依赖于正确的 DNS/hosts 配置。 - 性能:通常情况下,两者在性能上没有显著差异,因为
localhost最终也会解析为127.0.0.1。
结论
127.0.0.1 和 localhost都是指向本地计算机的地址,适用于本地网络应用程序的测试和调试。选择使用哪个主要取决于个人偏好和具体需求。在需要明确指定 IP 地址的场景下,127.0.0.1 更为直接;而在需要易记和通用的主机名时,localhost 更为合适。两者在实际使用中通常是等价的,差别微乎其微。
来源:juejin.cn/post/7413189674107273257
uni-app小程序超过2M怎么办?
一、开发版
开发版可以调整上限为4M
开发者工具 -> 详情 -> 本地设置 -> 预览及真机调试时主包、分包体积上限调整为4M -> 勾选
二、体验版、正式版
上传代码时,主包必须在2M以内。
小程序tabbar页面必须放在主包。
推荐除了tabbar页面以外,其余的都放在分包。其实只要这样做了,再复杂的小程序,主包代码都很难超过2M,但如果是uni-app开发的,那就不一定了。
uni-app优化
开发环境压缩代码
使用cli创建的项目
在package.json,script中设置压缩:在命令中加入--minimize
"dev:mp-weixin": "cross-env NODE_ENV=development UNI_PLATFORM=mp-weixin vue-cli-service uni-build --watch --minimize",
使用hbuilderx创建的项目
顶部菜单栏点击运行 -> 运行到小程序模拟器 -> 运行时是否压缩代码 -> 勾选
开启压缩后,开发环境的小程序代码体积会大大降低
uni.scss优化
uni-app项目创建后会自带一个uni.scss文件,这个文件无需手动引入,会自动引入到每一个页面文件,所以尽量不要在这个文件内写公共css代码。
我接手的一个uni-app小程序项目,随着功能迭代,打包代码主包体积越来越接近2M,终于有一天写完一个功能,突然就达到了2.2M,无法上传了。参考小程序提供的代码依赖分析,发现wxss文件占用了大部分体积,于是我就去一个个搜,看某个class有没有被用到,没用到的就删掉,可是再怎么优化冗余代码,也无法降到2M以下。
直到我看到了uni.scss文件,除了里面自带的一些颜色变量代码,另外还加了700行的公共class,然后我在根目录新建一个assets/common.scss文件,把那700行代码移出去,在App.vue内引入
@import './assets/common.scss'
主包体积瞬间降到了1.41M

总结
重要的事情说三遍
- 不要在uni.scss文件内写公共css代码
- 不要在uni.scss文件内写公共css代码
- 不要在uni.scss文件内写公共css代码
来源:juejin.cn/post/7411334549739733018
2024 前端趋势:全栈也许已经是必选项
《2023 的 Javascript 星趋势》在 1 月就出来了,当时略略看了一下,并没有想太多。
过年期间,回想起来,似乎感觉到这次有一点不一样:也许,全栈的时代已经到来了。
React 与 Vue 生态对比
首先,我们来看看 React 与 Vue 生态的星趋势对比:

上图中,React 整个生态的星星数远超于 Vue,第十名都要比 Vue 第一名的多。我们将其做一个分类:
| 排名 | React | Vue |
|---|---|---|
| 1 | UI | 全栈 |
| 2 | 白板 | 演示文稿 |
| 3 | 全栈 | 后台管理系统 |
| 4 | 状态管理 | hook |
| 5 | 后台管理系统 | UI |
| 6 | 文档 | 文档 |
| 7 | 全栈框架集成 | UI |
| 8 | 全栈框架UI | 框架 |
| 9 | 后台管理系统 | UI |
| 10 | 无服务栈 | 状态管理 |
可以看到 React 这边的生态链基本成熟,几乎每一个分类都有一个上榜的库,不再像 Vue 那样还在卷 UI 框架。
在全栈方面,Vue 的首位就是全栈 Nuxt。
React 的 Next.js 虽然不在首位,但是服务端/全栈相关的内容就占了 4 个,其中包含第 10 名的无服务栈。另外值得注意的是,React 这边还有服务端组件的概念。Shadcn/ui 能占到第一位,因为它基于无头 UI Radix 实现的,在服务端组件也能运用。所以,服务端/全栈在 React 中占的比重相当大的。
这样看来,前端往服务端进发已经成为一个必然趋势。
htmx 框架的倒退
再看看框架这边,htmx 在星趋势里,排行第二位,2023增长的星星数为 15.6K,与第一位的 React 颇为相近。
而 htmx 也是今年讨论度最高的。
在我经历过前后端不分离的阶段中,使用 jsp 生成前端页面,js 更多是页面炫技的工具。然后在 jQuery + Ajax 得到广泛应用之后,才真正有前后端分离的项目。
htmx 的出现,不了解的人,可能觉得是倒退到 Java + jQuery + Ajax 的前后端分离状态。但是,写过例子之后,我发现,它其实是倒退到了前后端不分离的阶段。
用 java 也好,世界上最好的 php 也好,或者用现在的 nodejs 服务,都能接入 htmx。你只要在服务端返回 html 即可。
/** nodejs fastity 写的一个例子 **/
import fastify from 'fastify'
import fastifyHtml from 'fastify-html'
import formbody from '@fastify/formbody';
const app = fastify()
await app.register(fastifyHtml)
await app.register(formbody);
// 省略首页引入 htmx
// 首页的模板,提供一个按钮,点击后请求 html,然后将请求返回的内容渲染到 parent-div 中
app.get('/', async (req, reply) => {
const name = req.query.name || 'World'
return reply.html`
Hello ${name}
`, reply
})
// 请求返回 html
app.post('/clicked', (req, reply) => {
reply.html`Clicked!
`;
})
await app.listen({ port: 3000 })
也许大家会觉得离谱,但是很显然,事情已经开始发生了变化,后端也来抢前端饭碗了。

htmx 在 github 上已经有不少跟随者,能搜出前端代码已有不少,前三就有基于 Python 语言的 Django 服务端框架。
jQuery 见势头不错,今年也更新了 4.0 的 beta 版本,对现代浏览器提供了更好的支持。这一切似乎为旧架构重回大众视野做好了准备。
企业角度
站在企业角度来看,一个人把前后端都干了不是更好吗?
的确如此。前后端一把撸更符合企业的利益。国外的小公司更以全栈作为首选项。
也许有人觉得国情不同,但是在我接触的前端群里,这两年都有人在群里说他们公司前后端分离的情况。
还有的人还喜欢大厂那一套,注意分工合作,但是其实大厂里遗留项目也不少,有的甚至是 php;还有新的实验项目,如果能投入最少人力,快速试错,这种全栈的框架自然也是最优选择。
我并不是说,前后端分离不值得。但是目前已经进入 AI 赛道,企业对后台系统的开发,并不愿意投入更多了。能用就行已经成为当前企业的目标,自然我们也应该跟着变化。
全栈破局
再说说前端已死的论调。我恰恰觉得这是最好做改变的时机。
在浏览器对新技术支持稳定,UI 框架趋同,UI 组件库稳定之后,前端不再需要为浏览器不兼容素手无策了,不再需要苦哈哈地为1个像素争辩不停了,也不再需要为产品莫名其妙的交互焦头烂额了。
这并不意味着前端已死,反而可能我们某个阶段的任务完成了,后面有更重要的任务交给我们。也许,全栈就是一个破局。
在云服务/云原生如此普遍的情况下,语言不再是企业开发考虑的主要因素,这也为 nodejs 全栈铺平了道路。
前端一直拣最苦最脏的话来做,从 UI 中拿到了切图的工作,然后接手了浏览器兼容的活,后来又从后端拿到了渲染页面的工作。
那我们为何不再进一步,主动把 API 开发的工作也拿过来?
来源:juejin.cn/post/7340603873604599843
8个小而美的前端库
前端有很多小而美的库,接入成本很低又能满足日常开发需求,同时无论是 npm 方式引入还是直接复制到本地使用都可以。
2024 年推荐以下小而美的库。
radash
实用的工具库,相比与 lodash,更加面向现代,提供更多新功能(tryit,retry 等函数),源码可读性高,如果不想安装它,大部分函数可以直接复制到本地使用。

use-debounce
React Hook Debouce 库,让你不再为使用防抖烦恼。库的特点:体积小 < 1 Kb、与 underscore / lodash impl 兼容 - 一次学习,随处使用、服务器渲染友好。

timeago.js
格式化日期时间库,比如:“3 hours ago”,支持多语言,仅 2Kb 大小。同时提供了 React 版本 timeago-react。
timeage.format(1544666010224, 'zh_CN') // 输出 “5 年前”
timeage.format(Date.now() - 1000, 'zh_CN') // 输出 “刚刚”
timeage.format(Date.now() - 1000 * 60 * 5, 'zh_CN') // 输出 “5 分钟前”
react-use
实用 Hook 大合集 - 内容丰富,从跟踪电池状态和地理位置,到设置收藏夹、防抖和播放视频,无所不包。

dayjs
Day.js 是一个简约的 JavaScript 库,仅 2 Kb 大小。它可以使用基本兼容 Moment.js,为你提供日期的解析、处理和显示,支持多语言能力。

filesize
filesize.js 提供了一种简单方法,便于从数字(浮点数或整数)或字符串转换成可读性高的文件大小,filesize.min.js 大小为 2.94 kb。
import {filesize} from "filesize";
filesize(265318, {standard: "jedec"}); // "259.1 KB"
driver.js
driver.js 是一款用原生 js 实现的页面引导库,上手非常简单,体积在 gzip 压缩下仅仅 5kb。

@formkit/drag-and-drop
FormKit DnD 是一个小型拖拽库,它简单、灵活、与框架无关,压缩后只有 4Kb 左右,设计理念为数据优先。

小结
前端小而美的库使用起来一般都比较顺手,欢迎在评论区推荐你们开发中的使用小而美的库。
来源:juejin.cn/post/7350140676615798824
登录页面一些有趣的css效果
前言
今天无意看到一个登录页,input框focus时placeholder上移变成label的效果,无聊没事干就想着自己来实现一下,登录页面能做文章的,普遍的就是按钮动画,title的动画,以及input的动画,这是最终的效果图(如下), 同时附上预览页以及实现源码。

title 的动画实现
首先描述一下大概的实现效果, 我们需要一个镂空的一段白底文字,在鼠标移入时给一个逐步点亮的效果。
文字镂空我们可以使用text-stroke, 逐步点亮只需要使用filter即可
text-stroke
text-stroke属性用于在文本的边缘周围添加描边效果,即文本字符的外部轮廓。这可以用于创建具有描边的文本效果。text-stroke属性通常与-webkit-text-stroke前缀一起使用,因为它目前主要在WebKit浏览器(如Chrome和Safari)中支持。
text-stroke属性有两个主要值:
- 宽度(width) :指定描边的宽度,可以是像素值、百分比值或其他长度单位。
- 颜色(color) :指定描边的颜色,可以使用颜色名称、十六进制值、RGB值等。
filter
filter是CSS属性,用于将图像或元素的视觉效果进行处理,例如模糊、对比度调整、饱和度调整等。它可以应用于元素的背景图像、文本或任何具有视觉内容的元素。
filter属性的值是一个或多个滤镜函数,这些函数以空格分隔。以下是一些常见的滤镜函数和示例:
- 模糊(blur) : 通过
blur函数可以实现模糊效果。模糊的值可以是像素值或其他长度单位。
.blurred-image {
filter: blur(5px);
}
- 对比度(contrast) : 通过
contrast函数可以调整对比度。值为百分比,1表示原始对比度。
.high-contrast-text {
filter: contrast(150%);
}
- 饱和度(saturate) : 通过
saturate函数可以调整饱和度。值为百分比,1表示原始饱和度。
.desaturated-image {
filter: saturate(50%);
}
- 反色(invert) : 通过
invert函数可以实现反色效果。值为百分比,1表示完全反色。
.inverted-text {
filter: invert(100%);
}
- 灰度(grayscale) : 通过
grayscale函数可以将图像或元素转换为灰度图像。值为百分比,1表示完全灰度。
.gray-text {
filter: grayscale(70%);
}
- 透明度(opacity) : 通过
opacity函数可以调整元素的透明度。值为0到1之间的数字,0表示完全透明,1表示完全不透明。
.semi-transparent-box {
filter: opacity(0.7);
}
- 阴影(drop-shadow) :用于在图像、文本或其他元素周围添加阴影效果。这个属性在 CSS3 中引入,通常用于创建阴影效果,使元素看起来浮在页面上或增加深度感
drop-shadow(<offset-x> <offset-y> <blur-radius>? <spread-radius>? <color>?)
各个值的含义如下:
<offset-x>: 阴影在 X 轴上的偏移距离。<offset-y>: 阴影在 Y 轴上的偏移距离。<blur-radius>(可选): 阴影的模糊半径。默认值为 0。<spread-radius>(可选): 阴影的扩散半径。默认值为 0。<color>(可选): 阴影的颜色。默认值为当前文本颜色。
filter属性的支持程度因浏览器而异,因此在使用时应谨慎考虑浏览器兼容性。
实现移入标题点亮的效果
想实现移入标题点亮的效果我们首先需要两个通过定位重叠的span元素,一个做镂空用于展示,另一个作为
hover时覆盖掉镂空元素,并通过filter: drop-shadow实现光影效果,需要注意的是这里需要使用inline元素实现效果。

input 的动画实现
input的效果比较简单,只需要在focus时span(placeholder)上移变成span(label)同时给input的border-bottom做一个底色的延伸,效果确定了接着就看看实现思路。
input placeholder 作为 label
使用div作为容器包裹input和span, span首先绝对定位到框内,伪装为placeholder, 当input状态为focus提高span的top值,即可伪装成label, 这里有两个问题是:
- 当用户输入了值的时候,
span并不需要恢复为之前的top值, 这里我们使用css或者js去判断都可以,js就是拿到输入框的值,这里不多做赘述,css有个比较巧妙的做法, 给input required属性值设置为required, 这样可以使用css:valid伪类去判断input是否有值。 - 由于span层级高于input,当点击span时无法触发input的聚焦,这个问题我们可以使用
pointer-events: none;来解决。pointer-events是一个CSS属性,用于控制元素是否响应用户的指针事件(例如鼠标点击、悬停、触摸等)。这个属性对于控制元素的可交互性和可点击性非常有用。
pointer-events具有以下几个可能的值:
- auto(默认值):元素会按照其正常行为响应用户指针事件。这是默认行为。
- none:元素不会响应用户的指针事件,就好像它不存在一样。用户无法与它交互。
- visiblePainted:元素在绘制区域上响应指针事件,但不在其透明区域上响应。这使得元素的透明部分不会响应事件,而其他部分会。
- visibleFill:元素在其填充区域上响应指针事件,但不在边框区域上响应。
- visibleStroke:元素在其边框区域上响应指针事件,但不在填充区域上响应。
- painted:元素会在其绘制区域上响应指针事件,包括填充、边框和透明区域。
- fill:元素在其填充区域上响应指针事件,但不在边框区域上响应。
- stroke:元素在其边框区域上响应指针事件,但不在填充区域上响应。
pointer-events 属性非常有用,特别是在创建交互性复杂的用户界面时,可以通过它来控制元素的响应区域。例如,你可以使用它来创建自定义的点击区域,而不仅仅是元素的边界。它还可以与其他CSS属性和JavaScript事件处理程序结合使用,以创建特定的交互效果。
input border bottom 延伸展开效果
效果比较简单,input被聚焦的时候,一个紫色的边从中间延伸覆盖白色的底边即可。 在使用一个span作为底部的边, 初始不可见, focus时从中间向两边延伸直至充满, 唯一头痛的就是怎么从中间向两边延伸,这里可以使用transform变形,首先使用transform: scaleX(0);达到不可见的效果, 然后设置变形原点为中间transform-origin: center;,这样效果就可以实现了
input 的动画实现效果

按钮的动画实现
关于按钮的动画很多,我们这里就实现一个移入的散花效果,移入时发散出一些星星,这里需要使用到动画去实现了,首先通过伪类创建一些周边元素,这里需要用到 background-image(radial-gradient)
background-image(radial-gradient)
background-image 属性用于设置元素的背景图像,而 radial-gradient 是一种 CSS 渐变类型,可用于创建径向渐变背景。这种径向渐变背景通常以一个中心点为基础,然后颜色渐变向外扩展,形成一种放射状的效果。
radial-gradient 的语法如下:
background-image: radial-gradient([shape] [size] at [position], color-stop1, color-stop2, ...);
[shape]: 可选,指定渐变的形状。常用的值包括 "ellipse"(椭圆)和 "circle"(圆形)。[size]: 可选,指定渐变的大小。可以是长度值或百分比值。at [position]: 可选,指定渐变的中心点位置。color-stopX: 渐变的颜色停止点,可以是颜色值、百分比值或长度值。
按钮移入动画效果实现

结尾
css 能实现的效果越来越多了,遇到有趣的效果,可以自己想想实现方式以及动手实现一下,思路毕竟是思路,具体实现起来说不定会遇到什么坑,逐步解决问题带来的成就感满足感还是很强的。
来源:juejin.cn/post/7294908459002331171
BOE(京东方)领先科技赋能体育产业全面向新 以击剑、电竞、健身三大应用场景诠释未来健康运动新生活
巴黎全球体育盛会虽已闭幕,但世界范围内的运动热潮并未消退。9月12日,在北京恒通国际商务园(UBP)的之所ICC,BOE(京东方)开启了以“屏实力 FUN肆热爱”为主题的“科技赋能体育”互动体验活动。活动现场,BOE(京东方)携手海信、创维、联想、AGON、ROG、一加、红魔等众多全球一线合作伙伴,全面展示了围绕击剑、电竞、健身三大应用场景的尖端科技产品,并打造了“显示视界”、“电子竞技”、“运动健身”三大互动体验区。中国国家击剑队女子重剑运动员余思涵、北京JDG英特尔战队分析师Vusso以及众多在京媒体出席了开幕仪式,并共同探讨“前沿科技赋能体育新生态”的深耕布局与应用趋势。据悉,该活动将全面向公众开放至9月14日,大众将通过现场沉浸式互动体验全方位感受创新科技赋能体育向新的独特魅力,更深度诠释了未来健康运动新生活的全新范式。
BOE(京东方)“科技赋能体育”互动体验活动现场
BOE(京东方)副总裁、首席品牌官司达在现场发言中表示,体育产业是BOE(京东方)“屏之物联”战略赋能应用场景的重要发力方向之一。在当前人工智能等新技术引领的智能化浪潮下,BOE(京东方)的创新科技正在体育产业中发挥着日益重要的作用。从2016年里约全球体育赛事的首次8K超高清实况转播,到2021年携手中国击剑队亮剑东京;到2022年冰雪盛会开闭幕式上的“雪花”绽放、再到2023年助力《梦三国2》电竞项目在杭州赛场奋勇夺金、2024年助力中国国家击剑队亮剑巴黎,BOE(京东方)正在通过全方位的科技赋能推动体育产业向智能化、科技化全面迈进。
BOE(京东方)副总裁、首席品牌官司达现场发言
科技赋能击剑,打造沉浸式赛训观赛新视界
在“显示视界”展区,由BOE(京东方)ADS Pro赋能的创维75英寸A7E Pro壁纸电视可呈现110% DCI-P3电影级超广色域,带来极致绚丽的画面表现力和丰富细腻的层次变化,高达288Hz的极速高刷新率让每一次出剑瞬间都行云流水般流畅丝滑,畅享全新沉浸式大屏观赛视觉盛宴。海信75英寸E8N Ultra ULED超画质电视同样由ADS Pro赋能,5800nits超高亮度配合288Hz超高刷新率呈现清晰锐利、逼真生动、流畅灵动的专业级画质表现,搭载击剑互动游戏让现场观众惊叹于大屏操作的每一个精彩瞬间。不仅如此,现场BOE(京东方)还带来了由高端柔性OLED显示技术解决方案f-OLED赋能的全球一线终端品牌的内折、上下翻折高端旗舰手机,全面解锁未来体育观赛的无限想象空间。
BOE(京东方)ADS Pro赋能创维75英寸A7E Pro壁纸电视
中国国家击剑队女子重剑运动员余思涵、北京JDG英特尔战队分析师Vusso现场体验
作为中国国家击剑队首席战略合作伙伴,多年来,BOE(京东方)的智慧显示、智慧物联、数字健康等物联网创新解决方案已覆盖击剑运动员的训练备战、战术分析、体能监测、健康管理等方方面面,全方位助力中国击剑队征战2018年亚洲体育盛会、2021东京全球体育盛会、2023年杭州亚洲体育盛会、2024年巴黎全球体育盛会等众多荣耀巅峰时刻,以硬核实力为体育注入科技力量。
科技赋能电竞,打造沉浸式竞技极致新体验
在“电子竞技”展区,BOE(京东方)联合AGON重磅打造的电竞显示终端,在ADS Pro加持下可实现高达520Hz的极致超高刷新率,配合千分之一秒的极限响应速度,精准还原职业电竞选手每一帧精妙的操作细节;由ADS Pro强势赋能的联想拯救者R9000P以100%DCI-P3超广色域及240Hz超高刷新率的领先性能,让电竞玩家尽情畅享大圣战斗的极致竞技体验;ROG 绝神27 XG27UCS在ADS Pro加持下可实现接近180°的超广视角,玩家无论正面观看还是侧面观看,都能获得原生态焕彩完美画质。此外,搭载BOE(京东方)高端柔性OLED技术的红魔、一加等多款游戏手机凭借高清、高刷、低蓝光护眼等领先优势,强势助力玩家在手游赛场尽情发挥,克敌制胜。
520Hz超高刷新率电竞显示终端
BOE(京东方)科技赋能专业电竞显示产品
作为电竞领域的科技引领者,BOE(京东方)已携手联想、戴尔、华硕、AOC等全球一线品牌推出众多超高刷、超高清、超高画质的专业电竞显示产品,目前,BOE(京东方)在电竞笔记本、显示器、手机等专业电竞显示领域均已处于全球领先地位。同时,BOE(京东方)还携手京东,与众多全球一线品牌联合成立Best of Esports电竞高阶联盟,并联合虎牙直播及联盟成员共同举办BOE无畏杯《无畏契约》挑战赛,推动构筑覆盖硬件、终端、内容、市场全链路的电竞生态,为我国电竞产业向新发展注入创新动力。
科技赋能健身,打造沉浸式健康生活新空间
在“运动健身”展区,BOE(京东方)更将显示、VR、传感等前沿技术与运动健身场景创新融合,引领全新的健康生活新潮流。展区内,动感单车配备专业VR头显设备,通过高清、高画质的VR显示技术实现虚拟与现实的深度交互,以别开生面的创新骑行模式引领健身运动新风潮;极具科技感的健身“魔镜”融合多种智能化功能于一体,用户可一边观看教程一边同步对镜矫正姿态,让健身更加智能化、可视化、趣味化;此外,现场展出的可穿戴智能健康手表搭载专业健康监测软件系统,为用户带来全方位的健康管理贴心呵护。
中国国家击剑队运动员余思涵现场体验
近年来,BOE(京东方)深入布局健康领域,推出的数字人体终端、智能体征监测系统、远望学习屏等一系列创新科技产品为大众健康生活带来全新体验。同时,基于多年在“医工融合”高潜方向的前瞻布局与深厚积淀,BOE(京东方)还将显示、传感、物联网、人工智能等技术与前沿医学融合创新,聚焦AI+医疗、数字医院、智慧医养社区等全新技术方向及场景形态,为未来医疗健康产业带来深远影响。
当前,随着物联网、人工智能、大数据等前沿技术引领数字化、智能化浪潮奔涌而来,BOE(京东方)的创新科技还将进一步深度融入体育竞技与运动健康等各大应用场景,携手更多顶级体育赛事及产业链合作伙伴,以顶尖科技力量描绘体育产业智能化高质量发展新图景!
收起阅读 »桌面端Electron基础配置
机缘
机缘巧合之下获取到一个桌面端开发的任务。
为了最快的上手速度,最低的开发成本,选择了electron。

介绍
Electron是一个使用 JavaScript、HTML 和 CSS 构建桌面应用程序的框架。 嵌入 Chromium 和 Node.js 到 二进制的 Electron 允许您保持一个 JavaScript 代码代码库并创建 在Windows上运行的跨平台应用 macOS和Linux——不需要本地开发 经验。
主要结构

electron主要有一个主进程和一个或者多个渲染进程组成,方便的脚手架项目有
electron-vite
安装方式
npm i electron-vite -D
electron-vite分为3层结构
main // electron主进程
preload // electron预加载进程 node
renderer // electron渲染进程 vue
创建项目
npm create @quick-start/electron
项目创建完成启动之后
会在目录中生成一个out目录

out目录中会生成项目文件代码,在electron-vite中使用ESmodel来加载文件,启动的时候会被全部打包到out目录中合并在一起。所以一些使用CommonJs的node代码复制进来需要做些修改。npm安装的依赖依然可以使用CommonJs的方式引入。
node的引入

在前面的推荐的几篇文章中都有详细的讲解,无需多言。electron是以chrom+node,所以node的加入也非常的简单。
nodeIntegration: true,
main主进程中的简单配置

preload目录下引入node代码,留一个口子在min主进程中调用。
配置数据库
以sequelize为例
npm install --save sequelize
npm install --save sqlite3
做本地应用使用推荐sqlite3,使用本地数据库,当然了用其他的数据也没问题,用法和node中一样。需要注意的是C++代码编译的问题,可能会存在兼容性问题,如果一直尝试还是报错就换版本吧。electron-vite新版本问题不大,遇到过老版本一直编译失败的问题
测试能让用版本
- "electron": "^25.6.0",
- "electron-vite": "^1.0.27",
- "sequelize": "^6.33.0",

node-gyp vscode 这些安装环境网上找找也很多就不多说了。
import { Sequelize } from 'sequelize'
import log from '../config/log/log'
const path = require('path')
let documentsPath
if (process.env['ELECTRON_RENDERER_URL']) {
documentsPath = './out/config/sqlite/sqlite.db'
} else {
documentsPath = path.join(process.env.USERPROFILE, 'Documents') + '\\sqlite\\sqlite.db'
}
console.log('documentsPath-------------****-----------', documentsPath)
export const seq = new Sequelize({
dialect: 'sqlite',
storage: documentsPath
})
seq
.authenticate()
.then(() => {
log.info('数据库连接成功')
})
.catch((err) => {
log.error('数据库连接失败' + err)
})
终端乱码问题
"dev:win": "chcp 65001 && electron-vite dev",
chcp 65001只在win环境下添加
electron多页签
electron日志
import logger from 'electron-log'
logger.transports.file.level = 'debug'
logger.transports.file.maxSize = 30 * 1024 * 1024 // 最大不超过10M
logger.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}]{scope} {text}' // 设置文件内容格式
var dayjs = require('dayjs')
const date = dayjs().format('YYYY-MM-DD') // 格式化日期为 yyyy-mm-dd
logger.transports.file.fileName = date + '.log' // 创建文件名格式为 '时间.log' (2023-02-01.log)
// 可以将文件放置到指定文件夹中,例如放到安装包文件夹中
const path = require('path')
let logsPath
if (process.env['ELECTRON_RENDERER_URL']) {
logsPath = './out/config/logs/' + date + '.log'
} else {
logsPath = path.join(process.env.USERPROFILE, 'Documents') + '\\logs\\' + date + '.log'
}
console.log('logsPath-------------****-----------', logsPath) // 获取到安装目录的文件夹名称
// 指定日志文件夹位置
logger.transports.file.resolvePath = () => logsPath
// 有六个日志级别error, warn, info, verbose, debug, silly。默认是silly
export default {
info(param) {
logger.info(param)
},
warn(param) {
logger.warn(param)
},
error(param) {
logger.error(param)
},
debug(param) {
logger.debug(param)
},
verbose(param) {
logger.verbose(param)
},
silly(param) {
logger.silly(param)
}
}
对应用做好日志维护是一个很重要的事情
主进程中也可以在main文件下监听
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
// 渲染进程崩溃
app.on('renderer-process-crashed', (event, webContents, killed) => {
log.error(
`APP-ERROR:renderer-process-crashed; event: ${JSON.stringify(event)}; webContents:${JSON.stringify(
webContents
)}; killed:${JSON.stringify(killed)}`
)
})
// GPU进程崩溃
app.on('gpu-process-crashed', (event, killed) => {
log.error(`APP-ERROR:gpu-process-crashed; event: ${JSON.stringify(event)}; killed: ${JSON.stringify(killed)}`)
})
// 渲染进程结束
app.on('render-process-gone', async (event, webContents, details) => {
log.error(
`APP-ERROR:render-process-gone; event: ${JSON.stringify(event)}; webContents:${JSON.stringify(
webContents
)}; details:${JSON.stringify(details)}`
)
})
// 子进程结束
app.on('child-process-gone', async (event, details) => {
log.error(`APP-ERROR:child-process-gone; event: ${JSON.stringify(event)}; details:${JSON.stringify(details)}`)
})
应用更新
在Electron中实现自动更新,需要使用electron-updater
npm install electron-updater --save
需要知道服务器地址,单版本号有可更新内容的时候可以通过事件监听控制更新功能
provider: generic
url: 'http://localhost:7070/urfiles'
updaterCacheDirName: 111-updater
import { autoUpdater } from 'electron-updater'
import log from '../config/log/log'
export const autoUpdateInit = (mainWindow) => {
let result = {
message: '',
result: {}
}
autoUpdater.setFeedURL('http://localhost:50080/latest.yml')
//设置自动下载
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = false
// 监听error
autoUpdater.on('error', function (error) {
log.info('检测更新失败' + error)
result.message = '检测更新失败'
result.result = error
mainWindow.webContents.send('update', JSON.stringify(result))
})
// 检测开始
autoUpdater.on('checking-for-update', function () {
result.message = '检测更新触发'
result.result = ''
// mainWindow.webContents.send('update', JSON.stringify(result))
log.info(`检测更新触发`)
})
// 更新可用
autoUpdater.on('update-available', (info) => {
result.message = '有新版本可更新'
result.result = info
mainWindow.webContents.send('update', JSON.stringify(result))
log.info(`有新版本可更新${JSON.stringify(info)}${info}`)
})
// 更新不可用
autoUpdater.on('update-not-available', function (info) {
result.message = '检测更新不可用'
result.result = info
mainWindow.webContents.send('update', JSON.stringify(result))
log.info(`检测更新不可用${info}`)
})
// 更新下载进度事件
autoUpdater.on('download-progress', function (progress) {
result.message = '检测更新当前下载进度'
result.result = progress
mainWindow.webContents.send('update', JSON.stringify(result))
log.info(`检测更新当前下载进度${JSON.stringify(progress)}${progress}`)
})
// 更新下载完毕
autoUpdater.on('update-downloaded', function () {
//下载完毕,通知应用层 UI
result.message = '检测更新当前下载完毕'
result.result = {}
mainWindow.webContents.send('update', result)
autoUpdater.quitAndInstall()
log.info('检测更新当前下载完毕,开始安装')
})
}
export const updateApp = (ctx) => {
let message
if (ctx.params == 'inspect') {
console.log('检测是否有新版本')
message = '检测是否有新版本'
autoUpdater.checkForUpdates() // 开始检查是否有更新
}
if (ctx.params == 'update') {
message = '开始更新'
autoUpdater.downloadUpdate() // 开始下载更新
}
return (ctx.body = {
code: 200,
message,
result: {
currentVersion: 0
}
})
}
dev下想测试更新功能,可以在主进程main文件中添加
Object.defineProperty(app, 'isPackaged', {
get() {
return true
}
})
接口封装
eletron中可以像node一样走http的形式编写接口,但是更推荐用IPC走内存直接进行主进程和渲染进程之间的通信
前端
import { ElMessage } from 'element-plus'
import router from '../router/index'
export const getApi = (url: string, params: object) => {
return new Promise(async (resolve, rej) => {
try {
console.log('-------------------url+params', url, params)
// 如果有token的话
let token = sessionStorage.getItem('token')
// 走ipc
if (window.electron) {
const res = await window.electron.ipcRenderer.invoke('getApi', JSON.stringify({ url, params, token }))
console.log('res', res)
if (res?.code == 200) {
return resolve(res.result)
} else {
// token校验不通过退出登录
if (res?.error == 10002 || res?.error == 10002) {
router.push({ name: 'loginPage' })
}
// 添加接口错误的处理
ElMessage.error(res?.message || res || '未知错误')
rej(res)
}
} else {
// 不走ipc
}
} catch (err) {
console.error(url + '接口请求错误----------', err)
rej(err)
}
})
}
后端
ipcMain.handle('getApi', async (event, args) => {
const { url, params, token } = JSON.parse(args)
//
})
electron官方文档中提供的IPC通信的API有好几个,每个使用的场景不一样,根据情况来选择
node中使用的是esmodel和一般的node项目写法上还有些区别,得适应一下。
容易找到的都是渲染进程发消息,也就是vue发消息给node,但是node发消息给vue没有写
这时候就需要使用webContents方法来实现
this.mainWindow.webContents.send('receive-tcp', JSON.stringify({ code: key, data: res.data }))
使用webContents的时候在vue中一样是通过事件监听‘receive-tcp’事件来获取
本地图片读取
// node中IO操作是异步所以得订阅一下
const subscribeImage = new Promise((res, rej) => {
// 读取图片文件进行压缩
sharp(imagePath)
.webp({ quality: 80 })
.toBuffer((err, buffer) => {
if (err) {
console.error('读取本地图片失败Error converting image to buffer:', err)
rej(
(ctx.body = {
error: 10003,
message: '本地图片读取失败'
})
)
} else {
log.info(`读取本地图片成功:${ctx.params}`)
res({
code: 200,
msg: '读取本地图片成功:',
result: buffer.toString('base64')
})
}
})
})
TCP
既然写了桌面端,那数据交互的方式可能就不局限于http,也会有WS,TCP,等等其他的通信协议。
node中提供了Tcp模块,net
const net = require('net')
const server = net.createServer()
server.on('listening', function () {
//获取地址信息
let addr = server.address()
tcpInfo.TcpAddress = `ip:${addr.port}`
log.info(`TCP服务启动成功---------- ip:${addr.port}`)
})
//设置出错时的回调函数
server.on('error', function (err) {
if (err.code === 'EADDRINUSE') {
console.log('地址正被使用,重试中...')
tcpProt++
setTimeout(() => {
server.close()
server.listen(tcpProt, 'ip')
}, 1000)
} else {
console.error('服务器异常:', err)
}
})
TCP链接成功获取到数据之后在data事件中,就可以使用webContents方法来主动传递消息给渲染进程
也得对Tcp数据包进行解析,一般都是和外部系统协商沟通的数据格式。一般是十六进制或者是二进制数据,需要对数据进行解析,切割,缓存。
使用 Bufferdata = Buffer.concat([overageBuffer, data]) 对数据进行处理
根据数据的长度对数据进行切割,判断数据的完整性质,对数据进行封包和拆包
粘包处理网上都有
处理完.toString()一下 over
socket.on('data', async (data) => {
...
let buffer = data.slice(0, packageLength) // 取出整个数据包
data = data.slice(packageLength) // 删除已经取出的数据包
// 数据处理
let key = buffer.slice(4, 8).reverse().toString('hex')
console.log('data', key, buffer)
let res = await isFunction[key](buffer)
this.mainWindow.webContents.send('receive-tcpData', JSON.stringify({ code: key, data: res.data }))
})
// 获取包长度的方法
getPackageLen(buffer) {
let bufferCopy = Buffer.alloc(12)
buffer.copy(bufferCopy, 0, 0, 12)
let bufferSize = bufferCopy.slice(8, this.headSize).reverse().readInt32BE(0)
console.log('bufferSize', bufferSize, bufferSize + this.headSize, buffer.length)
if (bufferSize > buffer.length - this.headSize) {
return -1
}
if (buffer.length >= bufferSize + this.headSize) {
return bufferSize + this.headSize // 返回实际长度 = 消息头长度 + 消息体长度
}
}
打完收工

来源:juejin.cn/post/7338265878289301567
日历表格的制作,我竟然选择了这样子来实现...
前言
最近有个日历表格的需求,具体效果如下所示,鼠标经过时表格还有一个十字高亮的效果,在拿到这个设计图的时候,就在想应该用什么来实现,由于我所在的项目用的是vue3 + element,所以我第一时间想到的就是饿了么里面的表格组件,但是经过一番调式之后,发现在饿了么表格的基础上想要调整我要的样式效果太复杂太麻烦了,所以我决定用原生的div循环来实现!

第一步 初步渲染表格
由于表格的表头是固定的,我们可以先渲染出来
<script setup lang="ts">
const tableFileds = Array.from({ length: 31 }, (_, i) =>
String(i + 1).padStart(2, '0')
)
</script>
<template>
<div class="table">
<div class="tabble-box">
<div class="table-node">
<div class="table-item table-header">
<div class="table-item-content diagonal-cell">
<span class="top-content">日</span>
<span class="bottom-content">月</span>
</div>
<div
class="table-item-content"
v-for="(item, _index) in tableFileds"
:key="item"
:style="{
background: '#EFF5FF'
}"
>
{{ item }}
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.table {
flex: 1;
display: flex;
flex-direction: column;
background-color: #fff;
padding: 1.8519vh 0.83vw 2.1296vh 1.09vw;
.tabble-box {
display: flex;
flex: 1;
box-sizing: border-box;
overflow: hidden;
color: #666666;
.table-node {
display: flex;
flex-direction: column;
flex: 1;
.table-header {
.table-item-content {
background-color: #eff5ff;
}
.diagonal-cell {
position: relative; /* 使伪元素相对于此单元格定位 */
padding-bottom: 8px; /* 为对角线下方留出空间以显示内容 */
width: 4.64vw !important;
&::before {
content: ''; /* 必须有内容才能显示伪元素 */
position: absolute;
top: 0;
left: 1px;
width: 5.16vw;
right: 0;
height: 1px; /* 对角线的高度 */
background-color: #e8e8e8; /* 对角线的颜色,可自定义 */
transform-origin: top left;
transform: rotate(30.5deg); /* 斜切角度,可微调 */
}
.top-content {
position: absolute;
top: 0.2778vh;
left: 2.67vw;
font-size: 0.83vw;
}
.bottom-content {
position: absolute;
top: 2.2222vh;
left: 0.83vw;
font-size: 0.83vw;
}
}
}
.table-item {
display: flex;
.table-item-content:first-child {
width: 4.64vw;
padding-top: 1.9444vh;
padding-bottom: 1.2037vh;
}
.table-item-content {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
// width: calc((100% - 9.53vw) / 15);
padding: 0.1vw;
text-align: center;
font-size: 0.78vw;
border-top: 0.05vw solid #eeeeee;
border-right: 0.05vw solid #eeeeee;
width: 2.48vw;
// flex-grow: 1
}
}
.table-header {
.table-item-content {
padding-top: 1.9444vh;
padding-bottom: 1.5741vh;
}
}
}
}
}
看一下页面效果:

表格的表头初步完成!
第二步 确认接口返回的数据格式
这是接口返回的格式数据 就例如第一个对象代表着3月9号有数据
{
"3": {
"9": 1
},
"4": {
"12": 2
},
"5": {
"11": 1,
"12": 2,
"21": 1
},
"6": {
"6": 5,
"8": 1,
"9": 2,
"10": 1,
"12": 2,
"17": 1,
"20": 1
},
"7": {
"1": 8,
"4": 1,
"7": 1,
"6": 1,
"13": 1,
"22": 1,
"25": 1,
"26": 1,
"27": 1,
"29": 6,
"30": 1
},
"8": {
"1": 1,
"2": 2,
"7": 1,
"20": 1,
"24": 1,
"27": 1,
"31": 1
},
"9": {
"15": 1,
"17": 9,
"21": 2
},
"10": {
"23": 1
}
}
接着我们需要对返回的数据做处理,由于表格的表头已经渲染出来,这意味着表格的每一列都有了,接下来我们就需要渲染表格的每一行与其对应就可以了.十二个月份我们需要十二行,同时每一行的第一个单元格表示的是月份,那我们可以定义一个月份的数据,然后再根据接口数据做处理,返回一个带有对应月份数据的数组.
代码如下:
const tableDataList = [
'一月',
'二月',
'三月',
'四月',
'五月',
'六月',
'七月',
'八月',
'九月',
'十月',
'十一月',
'十二月'
]
// 把接口数据转换为对应的月份数组对象
const parseData = (data: any) => {
const parsedData = new Array(12).fill({}) // 初始化一个包含12个空对象的数组
console.log('parsedData', parsedData)
for (let month = 1; month <= 12; month++) {
// 确保每次循环创建一个新的空对象
parsedData[month - 1] = {} // 从0开始索引
if (data[month]) {
Object.entries(data[month]).forEach(([day, value]) => {
parsedData[month - 1][parseInt(day)] = value
})
}
}
return parsedData
}
const tableData = ref<any[]>([])
onMounted(() => {
tableData.value = parseData(data)
console.log('tableData.value', tableData.value)
})
我们可以看一下控制台,此时的tableData的数据格式是怎么样的

接下来就可以开始渲染表格的内容了,给有数据的单元格做个高亮,同时固定31天,所以可以先遍历出每一行31个单元格出来
<div class="table-list">
<div
class="table-item"
v-for="(item, rowIndex) in tableData"
:key="item"
>
<div
class="table-item-content"
:key="item"
:style="{
background: '#EFF5FF'
}"
>
{{ tableDataList[rowIndex] }}
</div>
<div
class="table-item-content"
:data-col-index="index"
v-for="index in 31"
:key="rowIndex"
:style="{
background: item[index] ? '#6fa7ea' : ''
}"
>
<span v-if="item[index]" style="color: #fff">
{{ item[index] }}
</span>
<span v-else>0</span>
</div>
</div>
</div>

到这里基本就完成了,还差一个鼠标经过表格十字高亮的需求
我们可以给每个单元格加上鼠标的移入移出事件,移入事件函数传两个参数,一个就是行一个就是列,行可以从一开始的tableData那里拿到,列就是遍历31长度的当前项;这样子就可以拿到当前单元格的坐标,再封装一个辅助函数进行判断是否为当前单元格所在的行所在的列就可以了
高亮的时候记住判断的样式需要在之前的有数据高亮的样式的后面,这样子就不会被覆盖,可以保证有数据高亮的样式会一直存在,哪怕鼠标经过也不会被覆盖!
// 表格十字高亮
const highlightedRow = ref<any>()
const highlightedColumn = ref<any>()
const isCurrentCellHighlighted = (rowIndex: number, columnIndex: number) => {
return (
(highlightedRow.value !== null && highlightedRow.value === rowIndex) ||
(highlightedColumn.value !== null &&
highlightedColumn.value === columnIndex)
)
}
//鼠标移入
const onCellMouseOver = (rowIndex: any, columnIndex: any) => {
console.log('坐标', rowIndex, columnIndex)
highlightedRow.value = rowIndex
highlightedColumn.value = columnIndex
}
// 在鼠标移出事件(onCellMouseLeave)触发时,恢复所有单元格的原始背景色
const onCellMouseLeave = () => {
highlightedRow.value = null
highlightedColumn.value = null
}
<div
class="table-item-content"
:data-col-index="index"
v-for="index in 31"
:key="rowIndex"
:style="{
background: item[index]
? '#6fa7ea'
: isCurrentCellHighlighted(rowIndex + 1, index)
? '#EFF5FF'
: ''
}"
@mouseover="onCellMouseOver(rowIndex + 1, index)"
@mouseout="onCellMouseLeave"
>
<span v-if="item[index]" style="color: #fff">
{{ item[index] }}
</span>
<span v-else>0</span>
</div>
最终的效果就是:

以下就是完整的代码:
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const tableFileds = Array.from({ length: 31 }, (_, i) =>
String(i + 1).padStart(2, '0')
)
const tableDataList = [
'一月',
'二月',
'三月',
'四月',
'五月',
'六月',
'七月',
'八月',
'九月',
'十月',
'十一月',
'十二月'
]
const parseData = (data: any) => {
const parsedData = new Array(12).fill({}) // 初始化一个包含12个空对象的数组
console.log('parsedData', parsedData)
for (let month = 1; month <= 12; month++) {
// 确保每次循环创建一个新的空对象
parsedData[month - 1] = {} // 从0开始索引
if (data[month]) {
Object.entries(data[month]).forEach(([day, value]) => {
parsedData[month - 1][parseInt(day)] = value
})
}
}
return parsedData
}
const data = {
'3': {
'9': 1
},
'4': {
'12': 2
},
'5': {
'11': 1,
'12': 2,
'21': 1
},
'6': {
'6': 5,
'8': 1,
'9': 2,
'10': 1,
'12': 2,
'17': 1,
'20': 1
},
'7': {
'1': 8,
'4': 1,
'7': 1,
'6': 1,
'13': 1,
'22': 1,
'25': 1,
'26': 1,
'27': 1,
'29': 6,
'30': 1
},
'8': {
'1': 1,
'2': 2,
'7': 1,
'20': 1,
'24': 1,
'27': 1,
'31': 1
},
'9': {
'15': 1,
'17': 9,
'21': 2
},
'10': {
'23': 1
}
}
const tableData = ref<any[]>([])
// 表格十字高亮
const highlightedRow = ref<any>()
const highlightedColumn = ref<any>()
const isCurrentCellHighlighted = (rowIndex: number, columnIndex: number) => {
return (
(highlightedRow.value !== null && highlightedRow.value === rowIndex) ||
(highlightedColumn.value !== null &&
highlightedColumn.value === columnIndex)
)
}
const onCellMouseOver = (rowIndex: any, columnIndex: any) => {
console.log('坐标', rowIndex, columnIndex)
highlightedRow.value = rowIndex
highlightedColumn.value = columnIndex
}
// 在鼠标移出事件(onCellMouseLeave)触发时,恢复所有单元格的原始背景色
const onCellMouseLeave = () => {
highlightedRow.value = null
highlightedColumn.value = null
}
onMounted(() => {
tableData.value = parseData(data)
console.log('tableData.value', tableData.value)
})
</script>
<template>
<div class="table">
<div class="tabble-box">
<div class="table-node">
<div class="table-item table-header">
<div class="table-item-content diagonal-cell">
<span class="top-content">日</span>
<span class="bottom-content">月</span>
</div>
<div
class="table-item-content"
v-for="(item, _index) in tableFileds"
:key="item"
:style="{
background: '#EFF5FF'
}"
>
{{ item }}
</div>
</div>
<div class="table-list">
<div
class="table-item"
v-for="(item, rowIndex) in tableData"
:key="item"
>
<div
class="table-item-content"
:key="item"
:style="{
background: '#EFF5FF'
}"
>
{{ tableDataList[rowIndex] }}
</div>
<div
class="table-item-content"
:data-col-index="index"
v-for="index in 31"
:key="rowIndex"
:style="{
background: item[index]
? '#6fa7ea'
: isCurrentCellHighlighted(rowIndex + 1, index)
? '#EFF5FF'
: ''
}"
@mouseover="onCellMouseOver(rowIndex + 1, index)"
@mouseout="onCellMouseLeave"
>
<span v-if="item[index]" style="color: #fff">
{{ item[index] }}
</span>
<span v-else>0</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.table {
flex: 1;
display: flex;
flex-direction: column;
background-color: #fff;
padding: 1.8519vh 0.83vw 2.1296vh 1.09vw;
.tabble-box {
display: flex;
flex: 1;
box-sizing: border-box;
overflow: hidden;
color: #666666;
.table-node {
display: flex;
flex-direction: column;
flex: 1;
.table-header {
.table-item-content {
background-color: #eff5ff;
}
.diagonal-cell {
position: relative; /* 使伪元素相对于此单元格定位 */
padding-bottom: 8px; /* 为对角线下方留出空间以显示内容 */
width: 4.64vw !important;
&::before {
content: ''; /* 必须有内容才能显示伪元素 */
position: absolute;
top: 0;
left: 1px;
width: 5.16vw;
right: 0;
height: 1px; /* 对角线的高度 */
background-color: #e8e8e8; /* 对角线的颜色,可自定义 */
transform-origin: top left;
transform: rotate(30.5deg); /* 斜切角度,可微调 */
}
.top-content {
position: absolute;
top: 0.2778vh;
left: 2.67vw;
font-size: 0.83vw;
}
.bottom-content {
position: absolute;
top: 2.2222vh;
left: 0.83vw;
font-size: 0.83vw;
}
}
}
.table-item {
display: flex;
.table-item-content:first-child {
width: 4.64vw;
padding-top: 1.9444vh;
padding-bottom: 1.2037vh;
}
.table-item-content {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
// width: calc((100% - 9.53vw) / 15);
padding: 0.1vw;
text-align: center;
font-size: 0.78vw;
border-top: 0.05vw solid #eeeeee;
border-right: 0.05vw solid #eeeeee;
width: 2.48vw;
// flex-grow: 1
}
}
.table-header {
.table-item-content {
padding-top: 1.9444vh;
padding-bottom: 1.5741vh;
}
}
}
}
}
</style>
如果对你有帮助的话,欢迎点赞留言收藏🌹
来源:juejin.cn/post/7413311432971141160
贼好用!五分钟搭建一个美观且易用的导航页面!
大家好,我是 Java陈序员。
今天,给大家介绍一个贼好用的导航网站搭建工具,只需通过几步操作,就能搭建出个性化导航网站!
关注微信公众号:【Java陈序员】,获取开源项目分享、AI副业分享、超200本经典计算机电子书籍等。
项目简介
Pintree 是一个开源项目,旨在将浏览器书签导出成导航网站。通过简单的几步操作,就可以将书签转换成一个美观且易用的导航页面。

Pintree 支持使用 GitHub Pages 进行部署,无需购买服务器、域名等资源!
因此,只要有一个 Github 账号,就能快速搭建一个导航网站。接下来我们就来部署实现下!
项目部署
步骤一:Fork 项目
1、访问 pintree 项目地址
https://github.com/Pintree-io/pintree
2、Fork 项目到自己的仓库中

步骤二:启用 Github Pages
1、打开 GitHub 账号中 Fork 的 pintree 项目
2、切换到仓库的 Settings 标签页,点击 Pages,在 Source 下拉菜单中,选择 gh-pages 分支,然后点击 Save

3、几分钟后,静态导航网站将会在 https://yourusername.github.io/pintree 上可用
yourusername是你的 Github 账号,如https://chenyl8848.github.io/pintree.

这样,一个美观且易用的导航网站就搭建好了!
这时,好奇的小明就会问,要怎么个性化修改配置网站内容呢?别急,继续看步骤三。
步骤三:替换 JSON 文件自定义导航内容
1、pintree 渲染的导航网站内容是基于 json/pintree.json 文件里面的配置信息,我们可以通过修改 pintree.json 文件来自定义导航网站内容

2、打开 pintree.json 文件,并点击修改按钮进入编辑模式

3、在修改前,我们需要先了解下具体的语法规则,一个最小化的规则配置如下:
[
{
"//": "folder 表示是一个文件夹,可以配置子模块信息",
"type": "folder",
"//": "添加的时间信息",
"addDate": 1718526477999,
"//": "标题",
"title": "Java 陈序员",
"//": "子模块",
"children": [
{
"//": "link 表示是一个网站链接,最小化的配置单元",
"type": "link",
"//": "添加的时间信息",
"addDate": 1718526687700,
"//": "网站标题",
"title": "个人博客网站",
"//": "网站图标",
"icon": "https://chencoding.top:8090/_media/logo.png",
"//": "网站地址",
"url": "https://chencoding.top/"
},
"//": "依此类推",
{
"type": "folder",
"addDate": 1718526865665,
"title": "编程网站",
"children": [
{
"type": "link",
"addDate": 1718526707006,
"title": "CSDN",
"icon": "https://img-home.csdnimg.cn/images/20201124032511.png",
"url": "https://www.csdn.net/"
},
{
"type": "link",
"addDate": 1718526707006,
"title": "掘金",
"icon": "https://lf-web-assets.juejin.cn/obj/juejin-web/xitu_juejin_web/e08da34488b114bd4c665ba2fa520a31.svg",
"url": "https://juejin.cn/"
},
{
"type": "link",
"addDate": 1718526707006,
"title": "博客园",
"icon": "https://www.cnblogs.com/images/logo.svg?v=2SMrXdIvlZwVoB1akyXm38WIKuTHVqvGD0CweV-B6cY",
"url": "https://www.cnblogs.com/"
}
]
}
]
}
]
4、文件修改完后,点击 Commit changes 保存

5、过几分钟后,再访问 https://yourusername.github.io/pintree

可以看到,网站的内容变成了个性化的配置信息了。
由于浏览器有缓存的原因,如一开始没有变化,可以使用无痕模式访问或者用其他浏览器访问。
浏览器书签导航
通过前面的内容,我们知道 pintree 只需要一个 JSON 文件,就能搭建出一个导航网站。因此我们可以将浏览器中收藏的书签导出成 JSON 文件,再生成一个静态导航网站!
步骤一:导出浏览器书签
1、安装 Pintree Bookmarks Exporter 插件
安装地址:https://chromewebstore.google.com/detail/pintree-bookmarks-exporte/mjcglnkikjidokobpfdcdmcnfdicojce

2、使用插件导出浏览器书签,并保存 JSON 文件到本地

步骤二:替换 JSON 文件
将 JSON 文件替换到 Fork 项目的 json/pintree.json 文件中,保存成功后过几分钟再访问。
pintree 通过简单的配置,只需要几分钟就能快速搭建出一个导航网站,而且不用提供服务器、域名等资源,是一个非常优秀的开源项目!如果你想搭建一个静态导航网站可以去试试哈。
项目地址:https://github.com/Pintree-io/pintree
最后
推荐的开源项目已经收录到 GitHub 项目,欢迎 Star:
https://github.com/chenyl8848/great-open-source-project
或者访问网站,进行在线浏览:
https://chencoding.top:8090/#/

大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!
来源:juejin.cn/post/7413187186132631589
flex 布局中更巧妙的布局方案!比 justify-content 和 align-items 好用多了!
在前端开发中,实现水平垂直居中一直是个热门话题。随着 CSS Flexbox 布局的普及,开发者们开始更多地使用
justify-content和align-items这两个属性来解决这个问题。
然而,还有一种更加简洁、灵活的方式——使用
margin: auto;来实现居中以及更多实际场景下的特定效果。让我们一起回顾一下常见方式:justify-content和align-items,然后再来探讨一下使用:margin的优势,以及如何在实际项目中使用它。
一、常见方式:justify-content 和 align-items
1.1 justify-content (用于水平对齐)
justify-content 决定主轴(通常是水平方向)上子元素如何分配空间。常见的取值有:
flex-start:元素排列在容器的起始位置(默认值)。flex-end:元素排列在容器的末尾。center:元素在容器内水平居中。space-between:第一个元素与容器起点对齐,最后一个元素与容器终点对齐,其他元素之间均匀分布空间。space-around:每个元素左右两侧都分配均等的空白区域(元素两边的空隙会有一半分布在两端)。space-evenly:所有元素之间、以及与容器两端的空隙都相等。
1.2 align-items(用于垂直对齐)
align-items 决定交叉轴(通常是垂直方向)上子元素如何对齐。常见的取值有:
stretch:子元素在交叉轴上填满整个容器高度(默认值,前提是子元素没有设置具体的高度)。flex-start:子元素在交叉轴的起始位置对齐。flex-end:子元素在交叉轴的末端对齐。center:子元素在交叉轴上垂直居中对齐。baseline:子元素以其文本基线对齐。
1.3 flexbox 的常见用法
下面给出一些常见的 flexbox 的使用案例:
示例 : 公共样式
.container {
width: 800px;
height: 200px;
margin: 50px auto;
display: flex;
border: 1px solid black;
padding: 10px;
box-sizing: border-box;
}
.box {
width: 50px;
height: 50px;
background-color: lightblue;
text-align: center;
line-height: 50px;
border: 1px solid #333;
}
示例 1: 水平居中 + 垂直居中
<div class="container example-1">
<div class="box">1</div>
<div class="box">2</div>
<div class="box">3</div>
</div>
.example-1 {
justify-content: center;
align-items: center;
}

如上图所示,元素在水平和垂直方向都居中了。
示例 2: 水平居中 + 垂直靠顶
<div class="container example-2">
<div class="box">1</div>
<div class="box">2</div>
<div class="box">3</div>
</div>
.example-2 {
justify-content: center;
align-items: flex-start;
}

如上图所示,
justify-content: center;使元素在水平方向居中;align-items: flex-start;使元素垂直方向靠近顶部。
示例 3: 水平等间距 + 垂直居中
<div class="container example-3">
<div class="box">1</div>
<div class="box">2</div>
<div class="box">3</div>
</div>
.example-3 {
justify-content: space-between;
align-items: center;
}

如上图所示,
justify-content: space-between;使元素在垂直方向居中;align-items: center;使元素在水平方向两端对齐。
示例 4: 水平左对齐 + 垂直底部对齐
<div class="container example-4">
<div class="box">1</div>
<div class="box">2</div>
<div class="box">3</div>
</div>
.example-4 {
justify-content: flex-start;
align-items: flex-end;
}

如上图所示,
justify-content: flex-start;使元素在水平方向居左;align-items: flex-end;使元素在垂直方向靠底。
示例 5: 水平等间距 + 垂直拉伸
<div class="container example-5">
<div class="box">1</div>
<div class="box">2</div>
<div class="box">3</div>
</div>
.example-5 {
height: auto;
justify-content: space-evenly;
align-items: stretch;
}

如上图所示,
justify-content: space-evenly;会使元素会在水平方向等间距;如果不设置元素的高度,使其自适应,align-items: stretch;会使其垂直方向拉伸铺满。
1.4 思考与延伸
但你有没有想过,这些写法是否是最简洁的?能否实现我们日常开发的需求呢?有没有更优雅、更轻量的方案呢?
实际上在很多情况下这两个属性并不能够满足我们的开发需求。
比如我需要实现子元素部分集中的布局:
单纯依靠
justify-content和align-items,很难让几个子元素集中在一起。比如我们希望某些元素靠近并且与其他元素保持一定的间距就会比较麻烦了。
此时为了实现这种布局,通常需要结合
flex-grow、margin或者space-between,甚至需要使用嵌套的flex布局,增加了复杂性。

又或者是等宽子项的平均分布问题:
比如在导航菜单或展示商品卡片时,可能要求子项无论数量多少,都要从左向右均匀分布,并且保持等宽。
通过
justify-content: space-between或space-around可以部分解决这个问题,但是往往会出现无法保证元素从左向右,或者是无法等分的问题。

以及一些其他的情况,如垂直排列的固定间距、复杂的网格布局、混合布局等,justify-content 和 align-items都无法简洁、优雅的解决问题。
二、更优雅的方式:margin
2.1 下使用 margin: auto 使元素居中
其实,Flexbox 布局下还有另一种更加简洁的方法使元素居中——直接使用 margin: auto;。你可能会问,这怎么能居中呢?让我们先看一个例子:
<div class="box">
<div class="item"></div>
</div>
.box {
width: 200px;
height: 100px;
border: 2px solid #ccc;
display: flex; /* 启用 Flex 布局 */
margin: 100px auto;
}
.item {
background: red;
width: 50px;
height: 50px;
margin: auto; /* 自动分配外边距 */
}

在这个例子中,我们没有使用 justify-content 和 align-items,仅通过设置 .item 元素的 margin: auto;,就实现了水平和垂直居中。
它的工作原理是:在 Flexbox 布局中,
margin: auto;会根据父容器的剩余空间自动调整元素的外边距,直到子元素居中。
在传统布局中,margin: auto; 主要用于水平居中对齐,不适用于垂直居中。因为普通流布局的垂直方向是由文档流控制的,不支持类似 Flexbox 中的自动调整行为。
.container {
width: 500px;
}
.element {
width: 200px;
margin: 0 auto; /* 左右外边距自动分配,实现水平居中 */
}
相比之下,在 Flexbox 布局中,margin: auto; 具有更多的灵活性,可以同时实现水平和垂直居中对齐。
它不仅可以处理水平居中,还可以在 Flexbox 布局下根据剩余空间自动调整外边距,实现完全的居中对齐。
2.2 实现更多实际开发中的布局
示例 1:实现子元素部分集中
在实际开发中,我们常遇到这样一种需求:将元素水平分布在容器内,其中某些元素需要靠近在一起,与其他元素保持一定的自适应距离。
在这种情况下使用
justify-content: space-between是一种常见的办法,但这种方法也有一定的局限性:每个元素之间平等分配剩余空间,无法实现特定元素之间紧密靠拢。

代码实现:
<div class="container c2">
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
</div>
* {
margin: 0;
padding: 0;
}
.container {
width: 500px;
background: #eee;
margin: 50px auto;
padding: 10px;
display: flex;
}
.item {
width: 50px;
height: 50px;
border: 1px solid #333;
box-sizing: border-box;
}
.item:nth-child(odd) {
background: #046f4e;
}
.item:nth-child(even) {
background: #d53b3b;
}
.c2 .item:nth-child(2){
margin: 0 0 0 auto; /* 第二个 item 右对齐 */
}
.c2 .item:nth-child(4){
margin: 0 auto 0 0; /* 第四个 item 左对齐 */
}
在上述代码中,其实除掉一些基本样式的设置,实现了这个布局的关键代码就2行。
具体来说,
.c2 .item:nth-child(2)的margin: 0 0 0 auto;使得第二个.item紧贴容器的右边缘,而.c2 .item:nth-child(4)的margin: 0 auto 0 0;使得第四个.item紧贴容器的左边缘。这样就使第二个元素的左侧和第四个元素的右侧将会自适应边距间隔。
因此,我们可以使用
margin巧妙地通过调整子元素的外边距,实现元素的部分集中和对齐布局。
示例 2:实现等宽子项的平均分布
在很多情况下,我们需要将商品卡片或其他内容等宽地分布在每一行中,使每个子项都具有相同的宽度并且平均分布,每一行都是从左到右。
这种布局通常用于网格展示或商品列表等场景,确保每个子项在视觉上统一且整齐。
在这种情况下直接使用
justify-content和align-items可能会出现以下问题:
- 使用
space-between时如果最后一行的元素数量不足以填满整行,剩余的元素会分散到两侧,留出较大的空白区域,导致布局不整齐。
- 使用
space-around时如果最后一行的元素数量不满,元素会在行中均匀分布,导致它们集中在中间,而不是靠左或对齐其他行。
大家在遇到这些情况时是不是就在考虑换用grid布局了呢?先别急,我们其实直接通过margin就可以直接实现的!
在这里我们可以使用 margin 的动态计算来实现等宽子项的平均分布。
代码实现:
<div class="container c3">
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
</div>
* {
margin: 0;
padding: 0;
}
.container {
width: 500px;
background: #eee;
margin: 50px auto;
padding: 10px;
display: flex;
flex-wrap: wrap;
}
.item {
width: 50px;
height: 50px;
border: 1px solid #333;
box-sizing: border-box;
}
.item:nth-child(odd) {
background: #046f4e;
}
.item:nth-child(even) {
background: #d53b3b;
}
.c3 .item {
--n: 5; /* 每行显示的子项数量 */
--item-width: 50px; /* 子项宽度 */
--space: calc(100% / var(--n) - var(--item-width)); /* 计算子项之间的间距 */
--m: calc(var(--space) / 2); /* 左右间距的一半 */
margin: 10px var(--m); /* 动态计算左右的间距 */
}
在在上述代码中,除掉基础的样式,实现了这个布局的关键代码仅仅5行。通过动态计算 margin,我们能够简单而有效地实现等宽子项的平均分布,使布局更加简洁明了。

三、总结
在前端开发中,实现各种页面布局一直是一个常见的需求。
传统的做法如使用 justify-content 和 align-items 属性已经被广泛采用,但这种方法有时可能显得不够简洁或灵活。
在适当的情况下直接使用 margin 进行布局是一种更优雅、简洁的替代方案,可以在 Flexbox 布局中有效地实现居中对齐和一些复杂的布局需求。掌握并运用这种方法,可以提高开发效率,并使布局更加优雅。快来玩起来吧!
来源:juejin.cn/post/7413222778855964706
告别繁琐的 try-catch:JavaScript 安全赋值运算符 (?= ) 来了!
你是否厌倦了代码中难以阅读和维护的冗长 try-catch 代码块?全新的 ECMAScript 安全赋值运算符 (?= ) 将彻底改变游戏规则!这一突破性的特性简化了错误处理,让你的代码更简洁、更高效。让我们深入了解 ?= 运算符如何彻底改变你的编码体验!
简化代码,轻松处理错误
告别嵌套的 try-catch 混乱
问题: 传统的 try-catch 代码块会导致代码深度嵌套,难以理解和调试。
解决方案: 使用 ?= 运算符,你可以将函数结果转换为一个元组,更优雅地处理错误。如果出现错误,你将得到 [error, null] ,如果一切正常,你将得到 [null, result] 。你的代码将会感谢你!
使用 ?= 之前:
async function fetchData() {
try {
const response = await fetch("https://api.example.com/data");
try {
const data = await response.json();
return data;
} catch (parseError) {
console.error('Failed to parse JSON:', parseError);
}
} catch (networkError) {
console.error('Network error:', networkError);
}
}
使用 ?= 之后:
async function fetchData() {
const [networkError, response] ?= await fetch("https://api.example.com/data");
if (networkError) return console.error('Network error:', networkError);
const [parseError, data] ?= await response.json();
if (parseError) return console.error('Failed to parse JSON:', parseError);
return data;
}
提升代码清晰度:保持代码线性,简洁易懂
问题: try-catch 代码块会打断代码流程,降低可读性。
解决方案: ?= 运算符使错误处理变得简单直观,保持代码线性,易于理解。
示例:
const [error, result] ?= await performAsyncTask();
if (error) handleError(error);
标准化错误处理:跨 API 保持一致性
问题: 不同的 API 通常需要不同的错误处理方法,导致代码不一致。
解决方案: ?= 运算符提供了一种统一的错误处理方式,使你的代码在各种 API 中保持一致。
提升安全性:每次都捕获所有错误
问题: 漏掉错误会导致 bug 和潜在的安全问题。
解决方案: ?= 运算符确保始终捕获错误,降低漏掉关键问题的风险。
Symbol.result 背后的奥秘
自定义错误处理变得简单
概述: 实现 Symbol.result 方法的对象可以使用 ?= 运算符定义自己的错误处理逻辑。
示例:
function customErrorHandler() {
return {
[Symbol.result]() {
return [new Error("Custom error message"), null];
},
};
}
const [error, result] ?= customErrorHandler();
轻松处理嵌套错误:平滑处理复杂场景
概述: ?= 运算符可以处理包含 Symbol.result 的嵌套对象,使复杂错误场景更容易管理。
示例:
const complexObj = {
[Symbol.result]() {
return [
null,
{ [Symbol.result]: () => [new Error("Nested error"), null] }
];
},
};
const [error, data] ?= complexObj;
与 Promise 和异步函数无缝集成
概述: ?= 运算符专门设计用于与 Promise 和 async/await 无缝协作,简化异步错误处理。
示例:
const [error, data] ?= await fetch("https://api.example.com/data");
使用 using 语句简化资源管理
概述: 将 ?= 运算符与 using 语句结合使用,可以更有效地管理资源。
示例:
await using [error, resource] ?= getResource();
优先处理错误:先处理错误,后处理数据
概述: 将错误放在 [error, data] ?= 结构的第一个位置,确保在处理数据之前先处理错误。
示例:
const [error, data] ?= someFunction();
让你的代码面向未来:简化填充
概述: 虽然无法直接填充 ?= 运算符,但你可以使用后处理器在旧环境中模拟其行为。
示例:
const [error, data] = someFunction[Symbol.result]();
汲取灵感:从 Go、Rust 和 Swift 中学习
概述: ?= 运算符借鉴了 Go、Rust 和 Swift 等语言的先进错误处理实践,这些语言以其强大的错误管理功能而闻名。
当前限制和未来方向
仍在发展: ?= 运算符仍在开发中。改进领域包括:
- 命名: 为实现
Symbol.result的对象提供更好的术语。 - finally 代码块: 没有新的
finally代码块语法,但传统用法仍然有效。
总结
安全赋值运算符 (?= ) 将通过使 JavaScript 错误处理更加直观和简洁来彻底改变 JavaScript 错误处理。随着该提案的不断发展,它将有望成为每个 JavaScript 开发人员工具箱中的必备工具。准备迎接更干净、更安全的代码吧!🚀
来源:juejin.cn/post/7413284830945493001
两个月写完的校园社交小程序,这是篇uniapp踩坑记录
人员搭配和两个舍友,一前两后,从开发到最终上线,耗时两个月,此篇文章记录一下整个开发流程中踩的坑......
前置准备:
- 资质核验。需要企业认证才能发布社交类的小程序。需要上传营业执照、法人信息等
- 类目选择。小程序中有个类目选择,选择自己小程序涉及的类目,但这里就比较抽象了(下文讲)
- 微信认证。300元,腾子的吃相很难看,但奈何寄人篱下
- 小程序备案。在前面流程完成之后才能进行小程序的备案

审核流程
整个审核流程给我的感觉就是跟便秘一样,交一次吐一句,交一次吐一句,然后打回来重改 这里记录一下几个需要注意的点,如果你和我一样也是做UGC类的小程序的话
- 微信审核。所有但凡涉及言论的,能展示出你的思想的、个性的,对不起,请通通接入微信审核。包括但不限于用户昵称、头像;发布的帖子内容、图片
文字审核算比较快,图片审核就有点慢,且图片审核不是很精准。为了避免等待检测而让用户在界面等待过久的问题,后面无奈自己搭了个后台管理。以致于流程就变成了:用户发点什么东西 -> 后端调用微信检测接口 -> 检测完甩到后台 -> 后台管理员做个二次审核 -> 通过后在小程序获取
- 不能使用微信icon图标。之前做微信快捷登录,想着搞个微信的icon图标好看点,结果给我审核失败的原因就是不能使用微信图标

- 请预留足够的时间和审核慢慢耗。以上讲到的还只是真正发布之前踩的坑,等所有条件都符合后,算可以真正的发布,然后提示你首次提交一般需要7个自然日,然后就真的是7*24小时

5. 第一次代码真正预上线之后,此后更新代码再发布新的包,一半就只需要1~2天
开发过程
- 文件上传。以往网页开发中涉及文件上传的业务都是
new FormData,然后再append必要的字段。但是,小程序中使用FormData会报错,所以,得使用uniapp自带的uni.uoloadFile - 消息提示。在帖子发布成功之后等场景都会涉及到消息提示,一般涉及消息提示、页面回退,执行顺序请按
navigateBack再uni.showToast,如果二者调用顺序反了的话,只会页面回退,不会显示提示内容 - 分享功能。小程序的分享功能需要在
onShareAppMessage(分享至好友)或者onShareTimeline(分享至朋友圈)调用。这两个是和onLoad同级的,如果你的技术选型也是Vue3的话,可以从@dcloudio/uni-app中导入 - 消息订阅。小程序涉及到一个需求:当用户发布帖子,微信审核+后台审核都通过之后会通知用户,此时就需要进行消息订阅
先在微信公众号平台开通消息订阅模板,拿到模板ID,前端中再调用 uni.requestSubscribeMessage 传入拿到的模板ID就可以实现消息订阅
- webSocket。小程序中的树洞评论功能我们选用的是
webSocket,小程序中我们没有使用三方库,调用的是uniapp的uni.connectSocket,创建一个webSocket实例,然后处理对应的回调。由于用户一段时间内如果不发送消息,服务端也就没东西推送过来,webSocket自己会断掉。所以我们引入了心跳机制和断线重连机制。但是在做短信重连的时候就发现一个问题:断线重连之后确实是会创建新的实例,心跳包也正常推送给服务端,但是就是接收不到服务端反推回来的东西,后面经过排查,是webSocket实例的onMessage事件应当写在onOpen中,而不是独立写到外面
独立写到外面进行处理就会出现:断线重连之后死活接不到最新的实例返回的消息
这里再次吐槽微信的内容审核机制。原先选用webSocket的原因就是看中了它的实时推送,但是接入了内容审核就变得很抽象,时而秒通过,时而得等一下,这也搞得失去了选用webSocket的意义
- 请求池。小程序中使用到了tabs组件,当tab在切换过程中,比如tabA切换至tabB,由于需要请求数据,所以页面会有短暂的白屏时间,这里采用的是请求池,在获取第一个tab的列表数据的时候,由请求池顺便把之后的tab的内容也请求回来,此后在进行tab切换时就可以避免白屏,优化用户体验


- 响应式布局。小程序在个人页模仿了小红书个人页的实现形式,也就是随着页面的滚动,页面的布局(主要是用户头像缩小并由透明度为0逐渐变为1)发生变化。一开始采用的是监听页面
scroll事件。但是,scroll涉及大量的计算;后面采用Intersection Observer。但是注意,uniapp不能直接使用这个API,得调用uni.createIntersectionObserver,二者语法差不多 - 防抖节流的使用。页面滚动加载下一页使用防抖,按钮点击进行节流,常规操作。
大概暂时先能想到这么多,后面有想到再接着补充......
后记
其实校园小程序这个题材市面上早已烂大街,说不上有任何的创新。此前决定搞这个的原因有以下几点:
- 很多东西只停留于理论,没有实操。就像webSocket的心跳和断线重连,博客教你怎样怎样,始终不如你自己去亲手实现一下,这个算主要驱动原因
- 这个程序刚好拿去参加学校的比赛,拿奖有钱doge
- ......
然后整一个项目跟下来吧,给我的感觉就是:技术重要吗?重要;但也不太重要,技术的重要性就跟我这句废话一样。因为一个东西,推广不起来,没人用,你就没动力去更新、去维护。当然,有没有人用对我们此次的开发来说还不算是必选项。
大家觉得校园社交类的程序还能集成什么功能,也欢迎提出您的宝贵意见
来源:juejin.cn/post/7412665439501844490
短信接口被爆破了,一晚上差点把公司干破产了
背景
某天夜里,你正睡着觉,与周公神游。
老板打来电话:“小李,快看一下,系统出故障了,一个小时发了200条短信,再搞下去,我要破产了..."
巴拉巴拉...
于是,你赶紧跳下床,查了一个后台日志,发送短信API接口5s发送一次,都已经发送了500条了。在达到每日限额后,自动终止了。很明显被黑客攻击了。
500 * 0.1 * 8 = 400
一晚上约干掉了400元人民币
睡意全无,赶紧起来排查原因
故障分析
我司是做国外业务的,用的短信厂家是RingRing, 没有阿里云那种自带的强悍的预警和封禁功能。黑客通过伪造IP地址和手机号然后攻破了APP的短信接口,然后顺藤摸瓜的拿到相关发布的全部应用。于是,一个晚上,单个APP的每日短信限额和全部短信限额都攻破了。
APP使用的是https双向加密,黑客也不是单纯的爆破,没有大量的验证码错误日志。我们现在都不清楚黑客是通过什么方式绕过我们系统的,或者直接攻破了验证码
可能有懂这方面的掘友,可以分享一下哈
我们先上了一个临时方案,如果10分钟内,发送短信超过30条,且手机号超过60%都是同一个国家,我们关闭短信发送功能10分钟,并推送告警
然后抓紧时间去升级验证码,提高安全标准
验证码
文字验证码

我司最开始用的就是这种,简单易用。但是任你把噪点和线条铺满,整的面目全非,都防不住机器的识别,这种验证码直接pass了
优点:简易,具有一定的防爆破功能
缺点:防君子不防小人,在黑客面前,GG
滑块验证码

我司对于滑块验证码有几点考虑:
- 安全有待商榷,
- 背景图片需要符合国外市场和审美,需要UI介入,增加人工成本
- 不确定是否符合国外的习惯
基于这几点考虑,我司放弃了这个方案。但平心而论,国内用滑块验证码的是最多的,原因如下:
- 用户体验好
- 防破解性更强
- 适应移动设备
- 适用性广
npm install rc-slider-captcha
import SliderCaptcha from 'rc-slider-captcha';
const Demo = () => {
return (
<SliderCaptcha
request={async () => {
return {
bgUrl: 'background image url',
puzzleUrl: 'puzzle image url'
};
}}
onVerify={async (data) => {
console.log(data);
// verify data
return Promise.resolve();
}}
/>
);
};
滑块验证码是用的最多的验证码,操作简单,基本平替了图片验证码
图形顺序验证码 & 图形匹配验证码 & 语顺验证码


我司没有采用这种方案的原因如下:
- 我们的APP是多语言,点击文字这种方案不适用
- 没有找到免费且合适的APP插件
- 时间紧,项目紧急,没有功夫就研究
总结:
安全性更强,用户量越大的网站越受青睐
难度相对更大,频繁验证会流失一些用户
reCAPTCHA v3
综上,我司使用了reCAPTCHA

理由如下:
- 集成简单
- 自带控制台,方便管理和查看
- 谷歌出品,值得信赖,且有保障
<script src="https://www.google.com/recaptcha/api.js?render=reCAPTCHA_site_key"></script>
<script>
function onClick(e) {
e.preventDefault();
grecaptcha.ready(function() {
grecaptcha.execute('reCAPTCHA_site_key', {action: 'submit'}).then(function(token) {
// Add your logic to submit to your backend server here.
});
});
}
</script>
// 返回值
{
score: 1 // 评分0 到 1。1:确认为人类,0:确认为机器人。
hostname: "localhost"
success: true,
challenge_ts: "2024-xx-xTxx:xx:xxZ"
action: "homepage"
}
紧急上线后,安全性大大增强,再也没有遭受黑客袭击了。本以为可以睡个安稳觉了,又有其他的问题了,听我细讲
根据官方文档,建议score取0.5, 我们根据测试的情况,降低了标准,设置为0.3。上线后,很多用户投诉安全度过低,请30分后重试。由于我们当时的业务是出行和游乐, APP受限后,用户生活受到了很大限制,很多用户预约了我们的产品,却用不了,导致收到了大量的投诉。更糟糕的时候,我们的评分标准0.3是写死的,只能重新发布,一来二去,3天过去了。客服被用户骂了后,天天来我们技术部骂我们。哎,想想都是泪
我们紧急发布了一版,将评分标准设置成可配置的,通过API获取, 暂定0.1。算是勉强度过了这一关
reCAPTCHA v2
把分数调整到0.1后,我们觉得不是很安全,有爆破的风险,于是在下个版本使用了v2

使用v2,一切相对平稳,APP短信验证码风波也算平安度过了
2FA
双因素验证(Two-factor authentication,简称2FA,又名二步验证、双重验证),是保证账户安全的一道有效防线。在登录或进行敏感操作时,需要输入验证器上的动态密码(类似于银行U盾),进一步保护您的帐户免受潜在攻击者的攻击。双因素验证的动态密码生成器分为软件和硬件两种,最常用的软件有OTP Auth和谷歌验证器 (Google Authenticator)


经市场调用,客户要求,后续的APP,我们的都采用2fa方案,一人一码,安全可靠。
实现起来也比较简单,后端使用sha1加密一串密钥,生成哈希值,用户扫码绑定,然后每次将这个验证码提交给服务器进行比对即可
每次使用都要看一下验证码,感觉有点烦
服务器和手机进行绑定,是同一把密钥,每次输入都找半天。一旦用户更换手机,就必须生成全新的密钥。
总结
参考资料
来源:juejin.cn/post/7413322738315378697
利用CSS延迟动画,打造令人惊艳的复杂动画效果!
动画在前端开发中是经常遇到的场景之一,加入动画后页面可以极大的提升用户体验。
绝大多数简单的动画场景可以直接通过CSS实现,对于一些特殊场景的动画可能会使用到JS计算实现,通过本文的学习,可以让你在一些看似需要使用JS实现的动画场景,使用纯CSS一样可以实现,并且更方便快捷。
先看一个简单的例子:一个方块的位置随着滑条滑动的位置改变

这个场景实现起来很简单,滑条值改变后,使用JS计算方块应该移动的距离,然后将方块定位到指定位置即可。代码如下:
.box {
height: 50px;
width: 50px;
background-color: aquamarine;
}
<div class="box">div>
<input type="range" min="0" max="1" step="0.01"/>
现在稍微增加一些动画效果:
- 方块在中间位置时缩放为原来的一半大小
- 方块在中间位置时变成球形
- 方块从红色变为绿色

对于大小和圆角,同样可以使用简单的JS进行计算实现,但是对于颜色变化,使用JS计算将会是一个非常复杂的过程。
先抛开动画跟随滑条运动这个要求,如果使用CSS实现上面从0-1的动画过程是一个很简单的事:

.box {
height: 50px;
width: 50px;
background-color: aquamarine;
transform: translateX(0);
animation: run 1s linear forwards;
}
@keyframes run {
0% {
transform: translateX(0) scale(1);
border-radius: 0%;
background: red;
}
50% {
transform: translateX(100px) scale(.5);
border-radius: 50%;
}
100% {
transform: translateX(200px) scale(1);
border-radius: 0%;
background: green;
}
}
利用CSS动画帮我们可以很轻松的计算出每个时间点时的状态,现在的问题就变成如何让动画停留在指定的时间点,这就需要使用到动画的两个属性:
annimation-play-state:设置动画是运行还是暂停,有两个属性值runing、paused
annimation-delay:设置动画开始时间的偏移量,如果是正值,则动画会延迟开始;如果是负值(-d),动画会立即开始,开始位置在动画(d)s时所处的位置。
有了这两个属性,现在将上面的动画停留在50%的位置

假设整个动画过程需要1s,50%的位置则需要将延迟值设置为-0.5s,这样动画就会停留在0.5s的位置。
.box {
height: 50px;
width: 50px;
background-color: aquamarine;
transform: translateX(0);
animation: run 1s -0.5s linear forwards infinite paused;
}
接下来只需要将滑条的值与动画延迟的值关联起来即可,这里可以通过CSS变量来实现:
.box {
--duration: -0.5s; // 定义延迟变量
height: 50px;
width: 50px;
background-color: aquamarine;
transform: translateX(0);
animation: run 1s var(--duration) linear forwards infinite paused;
}
@keyframes run {
0% {
transform: translateX(0) scale(1);
border-radius: 0%;
background: red;
}
50% {
transform: translateX(100px) scale(.5);
border-radius: 50%;
}
100% {
transform: translateX(200px) scale(1);
border-radius: 0%;
background: green;
}
}
应用场景
利用CSS延迟动画可以轻松实现很多交互场景,例如:跟随鼠标滚动界面发生反馈动画、根据当天时间界面从日出到日落、根据不同分值出现不同表情变化等等。

来源:juejin.cn/post/7363094767557378099
实现 height: auto 的高度过渡动画
对于一个 height 设置为 auto 的元素,当它的高度发生了不由样式引起的改变时,并不会触发 transition 过渡动画。
容器元素的高度往往是由其内容决定的,如果一个容器元素的内容高度突然发生了改变,而无法进行过渡动画,有时会显得比较生硬,比如下面的登录框组件:

那么这种非样式引起的变化如何实现过渡效果呢?可以借助 FLIP 技术。
FLIP 是什么
FLIP 是 First,Last,Invert,Play 的缩写,其含义是:
First- 获取元素变化之前的状态Last- 获取元素变化后的最终状态Invert- 将元素从Last状态反转到First状态,比如通过添加transform属性,使得元素变化后,看起来仍像是处于First状态一样Play- 此时添加过渡动画,再移除Invert效果(取消transform),动画就会开始生效,使得元素看起来从First过渡到了Last
需要用到的 Web API
要实现一个基本的 FLIP 过渡动画,需要使用到以下一些 Web API:
- Resize Observer API - Web API 接口参考 | MDN (mozilla.org)
- Element.getBoundingClientRect() - Web API 接口参考 | MDN (mozilla.org)
- Window:requestAnimationFrame() 方法 - Web API 接口参考 | MDN (mozilla.org)
基本过渡效果实现
使用以上 API,就可以初步实现一个监听元素尺寸变化,并对其应用 FLIP 动画的函数 useBoxTransition,代码如下:
/**
*
* @param {HTMLElement} el 要实现过渡的元素 DOM
* @param {number} duration 过渡动画持续时间,单位 ms
* @returns 返回一个函数,调用后取消对过渡元素尺寸变化的监听
*/
export default function useBoxTransition(el: HTMLElement, duration: number) {
// boxSize 用于记录元素处于 First 状态时的尺寸大小
let boxSize: {
width: number
height: number
} | null = null
const elStyle = el.style // el 的 CSSStyleDeclaration 对象
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
// 被观察的 box 发生尺寸变化时要进行的操作
// 获取当前回调调用时,box 的宽高
const borderBoxSize = entry.borderBoxSize[0]
const writtingMode = elStyle.getPropertyValue('writing-mode')
const isHorizontal =
writtingMode === 'vertical-rl' ||
writtingMode === 'vertical-lr' ||
writtingMode === 'sideways-rl' ||
writtingMode === 'sideways-lr'
? false
: true
const width = isHorizontal
? borderBoxSize.inlineSize
: borderBoxSize.blockSize
const height = isHorizontal
? borderBoxSize.blockSize
: borderBoxSize.inlineSize
// 当 box 尺寸发生变化时,使用 FLIP 动画技术产生过渡动画,使用过渡效果的是 scale 形变
// 根据 First 和 Last 计算出 Inverse 所需的 scale 大小
// box 首次被观察时会触发一次回调,此时 boxSize 为 null,scale 应为 1
const scaleX = boxSize ? boxSize.width / width : 1
const scaleY = boxSize ? boxSize.height / height : 1
// 尺寸发生变化的瞬间,要使用 scale 变形将其保持变化前的尺寸,要先将 transition 去除
elStyle.setProperty('transition', 'none')
elStyle.setProperty('transform', `scale(${scaleX}, ${scaleY})`)
// 将 scale 移除,并应用 transition 以实现过渡效果
setTimeout(() => {
elStyle.setProperty('transform', 'none')
elStyle.setProperty('transition', `transform ${duration}ms`)
})
// 记录变化后的 boxSize
boxSize = { width, height }
}
})
resizeObserver.observe(el)
const cancelBoxTransition = () => {
resizeObserver.unobserve(el)
}
return cancelBoxTransition
}
效果如下所示:

效果改进
目前已经实现了初步的过渡效果,但在一些场景下会有些瑕疵:
- 如果在过渡动画完成前,元素有了新的状态变化,则动画被打断,无法平滑过渡到新的状态
FLIP动画过渡过程中,实际上发生变化的是transform属性,并不影响元素在文档流中占据的位置,如果需要该元素影响周围的元素,那么周围元素无法实现平滑过渡
如下所示:

对于动画打断问题的优化思路
- 使用
Window.requestAnimationFrame()方法在每一帧中获取元素的尺寸 - 这样做可以实时地获取到元素的尺寸,实时地更新
First状态
对于元素在文档流中问题的优化思路
- 应用过渡的元素外可以套一个
.outer元素,其定位为relative,过渡元素的定位为absolute,且居中于.outer元素 - 当过渡元素尺寸发生变化时,通过
resizeObserver获取其最终的尺寸,将其宽高设置给.outer元素(实例代码运行于Vue 3中,因此使用的是Vue提供的ref api将其宽高暴露出来,可以方便地监听其变化;如果在React中则可以将设置.outer元素宽高的方法作为参数传入useBoxTransition中,在需要的时候调用),并给.outer元素设置宽高的过渡效果,使其在文档流中所占的位置与过渡元素的尺寸同步 - 但是也要注意,这样做可能会引起浏览器高频率的重排,在复杂布局中慎用!
改进后的useBoxTransition 函数如下:
import throttle from 'lodash/throttle'
import { ref } from 'vue'
type BoxSize = {
width: number
height: number
}
type BoxSizeRef = globalThis.Ref<BoxSize>
/**
*
* @param {HTMLElement} el 要实现过渡的元素 DOM
* @param {number} duration 过渡动画持续时间,单位 ms
* @param {string} mode 过渡动画缓动速率,同 CSS transition-timing-function 可选值
* @returns 返回一个有两个项的元组:第一项为 keyBoxSizeRef,当元素大小发生变化时会将变化后的目标尺寸发送给 keyBoxSizeRef.value;第二项为一个函数,调用后取消对过渡元素尺寸变化的监听
*/
export default function useBoxTransition(
el: HTMLElement,
duration: number,
mode?: string
) {
let boxSizeList: BoxSize[] = [] // boxSizeList 表示对 box 的尺寸的记录数组;为什么是使用列表:因为当 box 尺寸变化的一瞬间,box 的 transform 效果无法及时移除,此时 box 的尺寸可能是非预期的,因此使用列表来记录 box 的尺寸,在必要的时候尽可能地将非预期的尺寸移除
const keyBoxSizeRef: BoxSizeRef = ref({ width: 0, height: 0 }) // keyBoxSizeRef 是暴露出去的 box 的实时目标尺寸
let isObserved = false // box 是否已经开始被观察
let frameId = 0 // 当前 animationFrame 的 id
let isTransforming = false // 当前是否处于变形过渡中
const elStyle = el.style // el 的 CSSStyleDeclaration 对象
const elComputedStyle = getComputedStyle(el) // el 的只读动态 CSSStyleDeclaration 对象
// 获取当前 boxSize 的函数
function getBoxSize() {
const rect = el.getBoundingClientRect() // el 的 DOMRect 对象
return { width: rect.width, height: rect.height }
}
// 更新 boxSizeList
function updateBoxsize(boxSize: BoxSize) {
boxSizeList.push(boxSize)
// 只保留前最新的 4 条记录
boxSizeList = boxSizeList.slice(-4)
}
// 定义 animationFrame 的回调函数,使得当 box 变形时可以更新 boxSize 记录
const animationFrameCallback = throttle(() => {
// 为避免使用了函数节流后,导致回调函数延迟触发使得 cancelAnimationFrame 失败,因此使用 isTransforming 变量控制回调函数中的操作是否执行
if (isTransforming) {
const boxSize = getBoxSize()
updateBoxsize(boxSize)
frameId = requestAnimationFrame(animationFrameCallback)
}
}, 20)
// 过渡事件的回调函数,在过渡过程中实时更新 boxSize
function onTransitionStart(e: Event) {
if (e.target !== el) return
// 变形中断的一瞬间,boxSize 的尺寸可能是非预期的,因此在变形开始时,将最新的 3 个可能是非预期的 boxSize 移除
if (boxSizeList.length > 1) {
boxSizeList = boxSizeList.slice(0,1)
}
isTransforming = true
frameId = requestAnimationFrame(animationFrameCallback)
// console.log('过渡开始')
}
function onTransitionCancel(e: Event) {
if (e.target !== el) return
isTransforming = false
cancelAnimationFrame(frameId)
// console.log('过渡中断')
}
function onTransitionEnd(e: Event) {
if (e.target !== el) return
isTransforming = false
cancelAnimationFrame(frameId)
// console.log('过渡完成')
}
el.addEventListener('transitionstart', onTransitionStart)
el.addEventListener('transitioncancel', onTransitionCancel)
el.addEventListener('transitionend', onTransitionEnd)
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
// 被观察的 box 发生尺寸变化时要进行的操作
// 获取当前回调调用时,box 的宽高
const borderBoxSize = entry.borderBoxSize[0]
const writtingMode = elStyle.getPropertyValue('writing-mode')
const isHorizontal =
writtingMode === 'vertical-rl' ||
writtingMode === 'vertical-lr' ||
writtingMode === 'sideways-rl' ||
writtingMode === 'sideways-lr'
? false
: true
const width = isHorizontal
? borderBoxSize.inlineSize
: borderBoxSize.blockSize
const height = isHorizontal
? borderBoxSize.blockSize
: borderBoxSize.inlineSize
const boxSize = { width, height }
// 当 box 尺寸发生变化时以及初次触发回调时,将此刻 box 的目标尺寸暴露给 keyBoxSizeRef
keyBoxSizeRef.value = boxSize
// box 首次被观察时会触发一次回调,此时不需要应用过渡,只需将当前尺寸记录到 boxSizeList 中
if (!isObserved) {
isObserved = true
boxSizeList.push(boxSize)
return
}
// 当 box 尺寸发生变化时,使用 FLIP 动画技术产生过渡动画,使用过渡效果的是 scale 形变
// 根据 First 和 Last 计算出 Inverse 所需的 scale 大小
const scaleX = boxSizeList[0].width / width
const scaleY = boxSizeList[0].height / height
// 尺寸发生变化的瞬间,要使用 scale 变形将其保持变化前的尺寸,要先将 transition 去除
elStyle.setProperty('transition', 'none')
const originalTransform =
elStyle.transform || elComputedStyle.getPropertyValue('--transform')
elStyle.setProperty(
'transform',
`${originalTransform} scale(${scaleX}, ${scaleY})`
)
// 将 scale 移除,并应用 transition 以实现过渡效果
setTimeout(() => {
elStyle.setProperty('transform', originalTransform)
elStyle.setProperty('transition', `transform ${duration}ms ${mode}`)
})
}
})
resizeObserver.observe(el)
const cancelBoxTransition = () => {
resizeObserver.unobserve(el)
cancelAnimationFrame(frameId)
}
const result: [BoxSizeRef, () => void] = [keyBoxSizeRef, cancelBoxTransition]
return result
}
相应的 vue 组件代码如下:
<template>
<div class="outer" ref="outerRef">
<div class="card-container" ref="cardRef">
<div class="card-content">
<slot></slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import useBoxTransition from '@/utils/useBoxTransition'
type Props = {
transition?: boolean
duration?: number
mode?: string
}
const props = defineProps<Props>()
const { transition, duration = 200, mode = 'ease' } = props
const cardRef = ref<HTMLElement | null>(null)
const outerRef = ref<HTMLElement | null>(null)
let cancelBoxTransition = () => {} // 取消 boxTransition 效果
onMounted(() => {
if (cardRef.value) {
const cardEl = cardRef.value as HTMLElement
const outerEl = outerRef.value as HTMLElement
if (transition) {
const boxTransition = useBoxTransition(cardEl, duration, mode)
const keyBoxSizeRef = boxTransition[0]
cancelBoxTransition = boxTransition[1]
outerEl.style.setProperty(
'--transition',
`weight ${duration}ms ${mode}, height ${duration}ms ${mode}`
)
watch(keyBoxSizeRef, () => {
outerEl.style.setProperty('--height', keyBoxSizeRef.value.height + 'px')
outerEl.style.setProperty('--width', keyBoxSizeRef.value.width + 'px')
})
}
}
})
onUnmounted(() => {
cancelBoxTransition()
})
</script>
<style scoped lang="less">
.outer {
position: relative;
&::before {
content: '';
display: block;
width: var(--width);
height: var(--height);
transition: var(--transition);
}
.card-container {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
--transform: translate(-50%, -50%);
transform: var(--transform);
box-sizing: border-box;
background-color: rgba(255, 255, 255, 0.7);
border-radius: var(--border-radius, 20px);
overflow: hidden;
backdrop-filter: blur(10px);
padding: 30px;
box-shadow: var(--box-shadow, 0 0 15px 0 rgba(0, 0, 0, 0.3));
}
}
</style>
优化后的效果如下:


注意点
过渡元素本身的 transform 样式属性
useBoxTransition 函数中会覆盖应用过渡的元素的 transform 属性,如果需要额外为元素设置其它的 transform 效果,需要使用 css 变量 --transform 设置,或使用内联样式设置。
这是因为,useBoxTransition 函数中对另外设置的 transform 效果和过渡所需的 transform 效果做了合并。
然而通过 getComputedStyle(Element) 读取到的 transform 的属性值总是会被转化为 matrix() 的形式,使得 transform 属性值无法正常合并;而 CSS 变量和使用 Element.style 获取到的内联样式中 transform 的值是原始的,可以正常合并。
如何选择获取元素宽高的方式
Element.getBoundingClientRect() 获取到的 DOMRect 的宽高包含了 transform 变化,而 Element.offsetWidth / Element.offsetHeight 以及 ResizeObserverEntry 对象获取到的宽高是元素本身的占位大小。
因此在需要获取 transition 过程中,包含 transform 效果的元素大小时,使用 Element.getBoundingClientRect(),否则可以使用 Element.offsetWidth / Element.offsetHeight 或 ResizeObserverEntry 对象。
获取元素高度时遇到的 bug
测试案例中使用了 elementPlus UI 库的 el-tabs 组件,当元素包含该组件时,无论是使用 Element.getBoundingClientRect()、Element.offsetHeight 还是使用 Element.Style、getComputedStyle(Element) 获取到的元素高度均缺少了 40px;而使用 ResizeObserverEntry 对象获取到的高度则是正确的,但是它无法脱离 ResizeObserver API 独立使用。
经过测试验证,缺少的 40px 高度来自于 el-tabs 组件中 .el-tabs__header 元素的高度,也就是说,在获取元素高度时,将 .el-tabs__header 元素的高度忽略了。
测试后找出的解决方法是,手动将 .el-tabs__header 元素样式(注意不要写在带 scoped 属性的 style 标签中,会被判定为局部样式而无法生效)的 height 属性指定为 calc(var(--el-tabs-header-height) - 1px),即可恢复正常的高度计算。
至于为什么这样会造成高度计算错误,希望有大神能解惑。
来源:juejin.cn/post/7307894647655759911
驱动产业升级,OpenHarmony赋能千行百业,擘画开源新蓝图
OpenAtom OpenHarmony(简称“OpenHarmony”)凭借其领先的技术创新能力和丰富的商业实践应用,展现了其在推动地方开源生态建设发展方面的强大动力。随着数字化转型的深入,OpenHarmony作为下一代智能终端操作系统的佼佼者,正逐步成为赋能千行百业数字化转型的关键力量。
各行业遍地开花
截至8月底,OpenHarmony社区累计超过8000名贡献者,总计有超过70家的共建企业参与贡献,超过731款软硬件产品通过兼容性测评,覆盖教育、交通、金融、家居、安防等多个行业。得益于来自各行各业共建者的共同努力,OpenHarmony在智能终端领域迅速发展,已成为增速领先的开源操作系统之一。
OpenHarmony应用于实践的成功案例如雨后春笋般涌现。在教育领域,基于OpenHarmony的智能学生证和智能手写板等产品,通过多设备协同体验,为学生和教师提供了高效、便捷的学习与教学工具。在金融领域,云喇叭、POS机等金融终端的推出,不仅丰富了金融市场业务,还显著提升了信息安全性能。此外,OpenHarmony还广泛应用于政务、工业、交通等多个领域,推动了相关行业的数字化转型进程。
赋能地方开源生态建设
OpenHarmony社区不仅在商业实践上取得了显著成果,还积极赋能地方开源生态建设。近年来,深圳,福州,惠州,重庆,南京,成都,无锡等多地政府纷纷出台政策措施,从产业应用、产业集聚、生态体系建设等维度支持OpenHarmony发展,从供给侧和需求侧共同推动生态建设。
开源技术深入融合地方产业进步,持续拓宽合作的新领域。各地持续提升服务保障体系,全力促进开源项目的成长,共同探索和丰富开源生态。这些举措极大地激发了企业和社会各界的参与热情,为OpenHarmony在地方的落地和应用提供了坚实的政策支持。
同时,OpenHarmony社区积极与高校、企业等合作伙伴建立紧密联系,共同推动人才培养和技术创新。通过产学合作、实训基地建设、课程开发等多种形式,OpenHarmony为地方培养了大量具备开源技术能力的专业人才,为地方开源生态的繁荣发展注入了新鲜血液。
构筑根技术人才护城河
开源人才已逐步成为推动信息产业发展的基石与战略支柱。OpenHarmony坚守“培育根技术人才,共筑根社区未来”的宗旨,大力建设OpenHarmony人才认证体系,为社区制定了一套权威的人才能力评估标准。这一举措不仅巩固了开发者的核心竞争力,也促进了根技术人才生态的繁荣发展。
OpenHarmony社区不断深化开发者护城河的建设,同时为社区成员、企业技术人员及院校学生提供了更开阔、更具体的职业成长路径,确保源源不断地向生态产业输送高质量人才。
引领技术革新
展望未来,OpenHarmony将继续秉承开源开放的精神,加强与产业链上下游伙伴的合作,共同推动技术创新和生态建设。随着OpenHarmony版本的不断迭代和生态的日益完善,相信将有更多基于OpenHarmony的创新应用和产品问世,为各行各业的数字化转型提供更加坚实的支撑。
同时,OpenHarmony也将持续加强探索开源技术在地方经济社会发展中的新路径、新模式。通过构建更加完善的开源生态体系,OpenHarmony将助力地方实现高质量发展目标,共同书写数字化转型的新篇章。
在9月25-27日举行的2024开放原子开源生态大会上,OpenHarmony将聚焦技术创新和生态发展,与广大生态伙伴共同见证OpenHarmony最新版本、新能力,以及兼容性、软硬件生态和开发者生态的新进展,一起共享技术实践,使能千行百业,共话商业落地。敬请关注。
9月26日上午,OpenHarmony项目群工作委员会将举办OpenHarmony生态主题演讲,特邀全球开源操作系统产业伙伴、技术大咖和学术专家,面向全球展示OpenHarmony的技术创新和产业落地成果,分享开源社区生态进展,共同见证开源赋能产业的国际盛会!
精准倒计时逻辑:揭秘前端倒计时逻辑的实现策略
在业务运营中,倒计时功能是常见的需求,尤其是在限时秒杀等促销活动中。为了确保时间的精确性和一致性,推荐使用服务器时间作为倒计时的基准。那么,如何在前端实现一个既准确又用户友好的倒计时组件的计时逻辑呢?
传统计时器实现
传统计时器实现倒计时的核心原理很简单,它使用了 setInterval 或 setTimeout 的对计时信息进行更新。类似于如下代码:
import React, { useState, useEffect } from 'react';
const CountdownTimer: React.FC<{ duration: number }> = ({ duration }) => {
const [secondsRemaining, setSecondsRemaining] = useState(duration);
useEffect(() => {
const intervalId = setInterval(() => {
if (secondsRemaining > 0) {
setSecondsRemaining(secondsRemaining - 1);
} else {
clearInterval(intervalId);
}
}, 1000);
// 清理计时器
return () => clearInterval(intervalId);
}, [secondsRemaining]);
return (
<div>
倒计时: {secondsRemaining} 秒
</div>
);
};
export default CountdownTimer;
上述代码实现很好地实现了倒计时逻辑,但是,还是存在一些问题。我们先来讨论一下浏览器事件循环关于延时队列的优先级。我们知道,为了有效地管理任务和事件,事件循环使用了一个队列系统。事件循环主要包含如下两个队列:
- 宏任务队列(Macro Task Queue) :包括如
setTimeout、setInterval、I/O、UI 事件等。 - 微任务队列(Micro Task Queue) :包括Promise回调、
MutationObserver等。
在事件循环中,当一个宏任务执行完毕后,JavaScript 引擎会先清空所有微任务队列中的所有任务,然后再去检查是否需要执行下一个宏任务。这意味着微任务的优先级高于宏任务。
setTimeout 或 setInterval 任务会在指定的延时后被加入到宏任务队列的末尾。当当前的宏任务执行完毕后,如果微任务队列不为空,JavaScript 引擎会先执行完所有微任务,然后才会执行下一个宏任务,也就是 setTimeout 或 setInterval 中的回调函数。因此,setTimeout 或 setInterval 的优先级是相对较低的,因为它们必须等待当前宏任务执行完毕以及所有微任务执行完毕后才能执行。
这种机制可能导致一个问题:如果页面上的其他微任务执行时间较长,倒计时显示可能会出现“跳秒”现象。例如,倒计时可能从 60 秒直接跳到 58 秒,而不是平滑地递减。
requestAnimationFrame 实现
针对上述“跳秒”问题,我们可以改用 requestAnimationFrame 去进行时间的更新逻辑执行。我们将上述代码修改为如下代码:
import React, { useState, useEffect } from 'react';
const CountdownTimer: React.FC<{ duration: number }> = ({ duration }) => {
const [secondsRemaining, setSecondsRemaining] = useState(duration);
useEffect(() => {
let animationFrameId: number;
const updateTimer = () => {
if (secondsRemaining > 0) {
setSecondsRemaining(prev => prev - 1);
animationFrameId = requestAnimationFrame(updateTimer);
} else {
cancelAnimationFrame(animationFrameId);
}
};
// 启动动画帧
animationFrameId = requestAnimationFrame(updateTimer);
// 清理动画帧
return () => cancelAnimationFrame(animationFrameId);
}, [secondsRemaining]);
return (
<div>
倒计时: {secondsRemaining} 秒
</div>
);
};
export default CountdownTimer;
在编写倒计时功能的代码时,我们应当确保在每次更新倒计时秒数后重新启动动画帧。这样做可以避免在动画帧完成后,倒计时逻辑停止更新,导致倒计时在减少一秒后不再继续。同时,为了确保资源的有效管理,我们还需要提供一个函数来清理动画帧,这样当组件不再需要时,可以停止执行动画帧,避免不必要的性能消耗。通过这些措施,我们可以保证倒计时功能的准确性和组件的高效卸载。
优势
要深入理解 requestAnimationFrame 在实现倒计时中的优势,我们首先需要探讨一个问题:在 requestAnimationFrame 中直接修改 DOM 是否合适?requestAnimationFrame 是一个专为动画效果设计的 Web API,它通过在浏览器的下一次重绘之前调用回调函数,帮助我们创建更加流畅且高效的动画。与传统的定时器方法(如 setTimeout 和 setInterval)相比,requestAnimationFrame 提供了更优的性能和更少的资源消耗。
在 requestAnimationFrame 中修改 DOM 是合适的,尤其是当涉及到动画和视觉更新时。这是因为 requestAnimationFrame 的设计初衷就是为了优化动画性能,确保动画的流畅性和效率。总结来说,requestAnimationFrame 相较于传统的计时器方法,具有以下显著优势:
- 性能优化:通过在浏览器的下一次重绘前调用回调,确保动画的流畅性。
- 节能高效:当浏览器标签页不处于活跃状态时,
requestAnimationFrame会自动暂停,从而减少 CPU 的使用,延长设备电池寿命。 - 同步刷新:能够与浏览器的刷新率同步,有效避免动画中的跳帧现象。
因此,requestAnimationFrame 不仅适用于复杂的动画场景,也非常适合实现需要精确时间控制的倒计时功能,提供了一种更加高效和节能的解决方案。
劣势
尽管 requestAnimationFrame 在动画制作方面表现出色,但在实现倒计时功能时,它也存在一些局限性:
- 精确度问题:
requestAnimationFrame并不适用于需要严格时间控制的场景。因为它的调用时机依赖于浏览器的重绘周期,这可能导致时间间隔的不稳定性。 - 管理复杂性:使用
requestAnimationFrame需要开发者手动管理动画状态和进行资源清理,这增加了实现的复杂度。
正因如此,许多现代前端框架和库,如 ahook 等,在选择实现倒计时功能时,倾向于采用传统的定时器(如 setTimeout 或 setInterval),而非 requestAnimationFrame。这些传统方法虽然可能不如 requestAnimationFrame 在动画性能上优化,但它们提供了更稳定和可预测的时间控制,这对于倒计时这类功能来说至关重要。
总结
实现一个倒计时组件的计时逻辑,我们有如下的一些建议:
- 动画与浏览器同步:对于涉及动画或需要与浏览器重绘周期同步的任务,
requestAnimationFrame是一个理想的选择。它能够确保动画的流畅性和性能优化。 - 体验优化:为了进一步提升用户体验,可以利用
performance.now()来提高时间控制的精度。这个高精度的时间戳 API 可以帮助你更准确地计算时间间隔,从而优化倒计时的显示效果。 - 时间控制与简易任务:如果你的应用场景需要精确的时间控制或涉及到简单的定时任务,传统的
setTimeout和setInterval方法可能更加方便和直观。它们提供了稳定的时间间隔,易于理解和实现。
总结来说,选择最合适的技术方案取决于你的具体需求。无论是 requestAnimationFrame 还是传统的定时器方法,都有其适用场景和优势。关键在于根据项目需求,做出明智的选择,以实现最佳的用户体验。
来源:juejin.cn/post/7412951456549175306
Spring Boot整合Kafka+SSE实现实时数据展示
2024年3月10日
知识积累
为什么使用Kafka?
不使用Rabbitmq或者Rocketmq是因为Kafka是Hadoop集群下的组成部分,对于大数据的相关开发适应性好,且当前业务场景下不需要使用死信队列,不过要注意Kafka对于更新时间慢的数据拉取也较慢,因此对与实时性要求高可以选择其他MQ。
使用消息队列是因为该中间件具有实时性,且可以作为广播进行消息分发。
为什么使用SSE?
使用Websocket传输信息的时候,会转成二进制数据,产生一定的时间损耗,SSE直接传输文本,不存在这个问题
由于Websocket是双向的,读取日志的时候,如果有人连接ws日志,会发送大量异常信息,会给使用段和日志段造成问题;SSE是单向的,不需要考虑这个问题,提高了安全性
另外就是SSE支持断线重连;Websocket协议本身并没有提供心跳机制,所以长时间没有数据发送时,会将这个连接断掉,因此需要手写心跳机制进行实现。
此外,由于是长连接的一个实现方式,所以SSE也可以替代Websocket实现扫码登陆(比如通过SSE的超时组件在实现二维码的超时功能,具体实现我可以整理一下)
另外,如果是普通项目,不需要过高的实时性,则不需要使用Websocket,使用SSE即可
代码实现
Java代码
pom.xml引入SSE和Kafka
<!-- SSE,一般springboot开发web应用的都有 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- kafka,最主要的是第一个,剩下两个是测试用的 -->
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>3.4.0</version>
</dependency>
application.properties增加Kafka配置信息
# KafkaProperties
spring.kafka.bootstrap-servers=localhost:9092
spring.kafka.consumer.group-id=community-consumer-group
spring.kafka.consumer.auto-offset-reset=earliest
spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
配置Kafka信息
@Configuration
public class KafkaProducerConfig {
@Value("${spring.kafka.bootstrap-servers}")
private String bootstrapServers;
@Bean
public Map<String, Object> producerConfigs() {
Map<String, Object> props = new HashMap<>();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
return props;
}
@Bean
public ProducerFactory<String, String> producerFactory() {
return new DefaultKafkaProducerFactory<>(producerConfigs());
}
@Bean
public KafkaTemplate<String, String> kafkaTemplate() {
return new KafkaTemplate<>(producerFactory());
}
}
配置controller,通过web方式开启效果
@RestController
@RequestMapping(path = "sse")
public class KafkaSSEController {
private static final Map<String, SseEmitter> sseCache = new ConcurrentHashMap<>();
@Resource
private KafkaTemplate<String, String> kafkaTemplate;
@Resource
private SseEmitter sseEmitter;
/**
* @param message
* @apiNote 发送信息到Kafka主题中
*/
@PostMapping("/send")
public void sendMessage(@RequestBody String message) {
kafkaTemplate.send("my-topic", message);
}
/**
* 监听Kafka数据
*
* @param message
*/
@KafkaListener(topics = "my-topic", groupId = "my-group-id")
public void consume(String message) {
System.out.println("Received message: " + message);
//使用接口建立起sse连接后,监听到kafka消息则会发送给对应链接
SseEmitter sseEmitter = sseCache.get(id); if (sseEmitter != null) { sseEmitter.send(content); }
}
/**
* 连接sse服务
*
* @param id
* @return
* @throws IOException
*/
@GetMapping(path = "subscribe", produces = {MediaType.TEXT_EVENT_STREAM_VALUE})
public SseEmitter push(@RequestParam("id") String id) throws IOException {
// 超时时间设置为5分钟,用于演示客户端自动重连
SseEmitter sseEmitter = new SseEmitter(5_60_000L);
// 设置前端的重试时间为1s
// send(): 发送数据,如果传入的是一个非SseEventBuilder对象,那么传递参数会被封装到 data 中
sseEmitter.send(SseEmitter.event().reconnectTime(1000).data("连接成功"));
sseCache.put(id, sseEmitter);
System.out.println("add " + id);
sseEmitter.send("你好", MediaType.APPLICATION_JSON);
SseEmitter.SseEventBuilder data = SseEmitter.event().name("finish").id("6666").data("哈哈");
sseEmitter.send(data);
// onTimeout(): 超时回调触发
sseEmitter.onTimeout(() -> {
System.out.println(id + "超时");
sseCache.remove(id);
});
// onCompletion(): 结束之后的回调触发
sseEmitter.onCompletion(() -> System.out.println("完成!!!"));
return sseEmitter;
}
/**
* http://127.0.0.1:8080/sse/push?id=7777&content=%E4%BD%A0%E5%93%88aaaaaa
* @param id
* @param content
* @return
* @throws IOException
*/
@ResponseBody
@GetMapping(path = "push")
public String push(String id, String content) throws IOException {
SseEmitter sseEmitter = sseCache.get(id);
if (sseEmitter != null) {
sseEmitter.send(content);
}
return "over";
}
@ResponseBody
@GetMapping(path = "over")
public String over(String id) {
SseEmitter sseEmitter = sseCache.get(id);
if (sseEmitter != null) {
// complete(): 表示执行完毕,会断开连接
sseEmitter.complete();
sseCache.remove(id);
}
return "over";
}
}
前端方式
<html>
<head>
<script>
console.log('start')
const clientId = "your_client_id_x"; // 设置客户端ID
const eventSource = new EventSource(`http://localhost:9999/v1/sse/subscribe/${clientId}`); // 订阅服务器端的SSE
eventSource.onmessage = event => {
console.log(event.data)
const message = JSON.parse(event.data);
console.log(`Received message from server: ${message}`);
};
// 发送消息给服务器端 可通过 postman 调用,所以下面 sendMessage() 调用被注释掉了
function sendMessage() {
const message = "hello sse";
fetch(`http://localhost:9999/v1/sse/publish/${clientId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(message)
});
console.log('dddd'+JSON.stringify(message))
}
// sendMessage()
</script>
</head>
</html>
来源:juejin.cn/post/7356770034180898857
看了Kubernetes 源码后,我的Go水平突飞猛进
接口方式隐藏传入参数的细节
当方法的入参是一个结构体的时候,内部去调用时会看到入参过多的细节,这个时候可以将入参隐式转成结构,让内部只看到需要的方法即可。
type Kubelet struct{}
func (kl *Kubelet) HandlePodAdditions(pods []*Pod) {
for _, pod := range pods {
fmt.Printf("create pods : %s\n", pod.Status)
}
}
func (kl *Kubelet) Run(updates <-chan Pod) {
fmt.Println(" run kubelet")
go kl.syncLoop(updates, kl)
}
func (kl *Kubelet) syncLoop(updates <-chan Pod, handler SyncHandler) {
for {
select {
case pod := <-updates:
handler.HandlePodAdditions([]*Pod{&pod})
}
}
}
type SyncHandler interface {
HandlePodAdditions(pods []*Pod)
}
这里我们可以看到 Kubelet 本身有比较多的方法:
- syncLoop 同步状态的循环
- Run 用来启动监听循环
- HandlePodAdditions 处理Pod增加的逻辑
由于 syncLoop 其实并不需要知道 kubelet 上其他的方法,所以通过 SyncHandler 接口的定义,让 kubelet 实现该接口后,外面作为参数传入给 syncLoop ,它就会将类型转换为 SyncHandler 。
经过转换后 kubelet 上其他的方法在入参里面就看不到了,编码时就可以更加专注在 syncLoop 本身逻辑的编写。
但是这样做同样会带来一些问题,第一次研发的需求肯定是能满足我们的抽象,但是随着需求的增加和迭代,我们在内部需要使用 kubelet 其他未封装成接口的方法时,我们就需要额外传入 kubelet 或者是增加接口的封装,这都会增加我们的编码工作,也破坏了我们最开始的封装。
分层隐藏设计是我们设计的最终目的,在代码设计的过程中让一个局部关注到它需要关注的东西即可。
接口封装方便Mock测试
通过接口的抽象,我们在测试的时候可以把不关注的内容直接实例化成一个 Mock 的结构。
type OrderAPI interface {
GetOrderId() string
}
type realOrderImpl struct{}
func (r *realOrderImpl) GetOrderId() string {
return ""
}
type mockOrderImpl struct{}
func (m *mockOrderImpl) GetOrderId() string {
return "mock"
}
这里如果测试的时候不需要关注 GetOrderId 的方法是否正确,则直接用 mockOrderImpl 初始化 OrderAPI 即可,mock的逻辑也可以进行复杂编码
func TestGetOrderId(t *testing.T) {
orderAPI := &mockOrderImpl{} // 如果要获取订单id,且不是测试的重点,这里直接初始化成mock的结构体
fmt.Println(orderAPI.GetOrderId())
}
gomonkey 也同样能进行测试注入,所以如果以前的代码没能够通过接口封装也同样可以实现mock,而且这种方式更加强大
patches := gomonkey.ApplyFunc(GetOrder, func(orderId string) Order {
return Order{
OrderId: orderId,
OrderState: delivering,
}
})
return func() {
patches.Reset()
}
使用 gomonkey 能够更加灵活的进行 mock , 它能直接设置一个方法的返回值,而接口的抽象只能够处理结构体实例化出来的内容。
接口封装底层多种实现
iptables 、ipvs等的实现就是通过接口的抽象来实现,因为所有网络设置都需要处理 Service 和 Endpoint ,所以抽象了 ServiceHandler 和 EndpointSliceHandler
// ServiceHandler 是一个抽象接口,用于接收有关服务对象更改的通知。
type ServiceHandler interface {
// OnServiceAdd 在观察到创建新服务对象时调用。
OnServiceAdd(service *v1.Service)
// OnServiceUpdate 在观察到现有服务对象的修改时调用。
OnServiceUpdate(oldService, service *v1.Service)
// OnServiceDelete 在观察到现有服务对象的删除时调用。
OnServiceDelete(service *v1.Service)
// OnServiceSynced 一旦所有初始事件处理程序都被调用并且状态完全传播到本地缓存时调用。
OnServiceSynced()
}
// EndpointSliceHandler 是一个抽象接口,用于接收有关端点切片对象更改的通知。
type EndpointSliceHandler interface {
// OnEndpointSliceAdd 在观察到创建新的端点切片对象时调用。
OnEndpointSliceAdd(endpointSlice *discoveryv1.EndpointSlice)
// OnEndpointSliceUpdate 在观察到现有端点切片对象的修改时调用。
OnEndpointSliceUpdate(oldEndpointSlice, newEndpointSlice *discoveryv1.EndpointSlice)
// OnEndpointSliceDelete 在观察到现有端点切片对象的删除时调用。
OnEndpointSliceDelete(endpointSlice *discoveryv1.EndpointSlice)
// OnEndpointSlicesSynced 一旦所有初始事件处理程序都被调用并且状态完全传播到本地缓存时调用。
OnEndpointSlicesSynced()
}
然后通过 Provider 注入即可,
type Provider interface {
config.EndpointSliceHandler
config.ServiceHandler
}
这个也是我在做组件的时候用的最多的一种编码技巧,通过将类似的操作进行抽象,能够在替换底层实现后,上层代码不发生改变。
封装异常处理
我们开启协程之后如果不对异常进行捕获,则会导致协程出现异常后直接 panic ,但是每次写一个 recover 的逻辑做全局类似的处理未免不太优雅,所以通过封装 HandleCrash 方法来实现。
package runtime
var (
ReallyCrash = true
)
// 全局默认的Panic处理
var PanicHandlers = []func(interface{}){logPanic}
// 允许外部传入额外的异常处理
func HandleCrash(additionalHandlers ...func(interface{})) {
if r := recover(); r != nil {
for _, fn := range PanicHandlers {
fn(r)
}
for _, fn := range additionalHandlers {
fn(r)
}
if ReallyCrash {
panic(r)
}
}
}
这里既支持了内部异常的函数处理,也支持外部传入额外的异常处理,如果不想要 Crash 的话也可以自己进行修改。
package runtime
func Go(fn func()) {
go func() {
defer HandleCrash()
fn()
}()
}
要起协程的时候可以通过 Go 方法来执行,这样也避免了自己忘记增加 panic 的处理。
waitgroup的封装
import "sync"
type Gr0up struct {
wg sync.WaitGr0up
}
func (g *Gr0up) Wait() {
g.wg.Wait()
}
func (g *Gr0up) Start(f func()) {
g.wg.Add(1)
go func() {
defer g.wg.Done()
f()
}()
}
这里最主要的是 Start 方法,内部将 Add 和 Done 进行了封装,虽然只有短短的几行代码,却能够让我们每次使用 waitgroup 的时候不会忘记去对计数器增加一和完成计数器。
信号量触发逻辑封装
type BoundedFrequencyRunner struct {
sync.Mutex
// 主动触发
run chan struct{}
// 定时器限制
timer *time.Timer
// 真正执行的逻辑
fn func()
}
func NewBoundedFrequencyRunner(fn func()) *BoundedFrequencyRunner {
return &BoundedFrequencyRunner{
run: make(chan struct{}, 1),
fn: fn,
timer: time.NewTimer(0),
}
}
// Run 触发执行 ,这里只能够写入一个信号量,多余的直接丢弃,不会阻塞,这里也可以根据自己的需要增加排队的个数
func (b *BoundedFrequencyRunner) Run() {
select {
case b.run <- struct{}{}:
fmt.Println("写入信号量成功")
default:
fmt.Println("已经触发过一次,直接丢弃信号量")
}
}
func (b *BoundedFrequencyRunner) Loop() {
b.timer.Reset(time.Second * 1)
for {
select {
case <-b.run:
fmt.Println("run 信号触发")
b.tryRun()
case <-b.timer.C:
fmt.Println("timer 触发执行")
b.tryRun()
}
}
}
func (b *BoundedFrequencyRunner) tryRun() {
b.Lock()
defer b.Unlock()
// 可以增加限流器等限制逻辑
b.timer.Reset(time.Second * 1)
b.fn()
}
写在最后
感谢你读到这里,如果想要看更多 Kubernetes 的文章可以订阅我的专栏: juejin.cn/column/7321… 。
来源:juejin.cn/post/7347221064429469746
多人开发小程序设置体验版的痛点
抛出痛点
在分配任务时,我们将需求分为三个分支任务,分别由前端A、B、C负责:
- 前端A: HCC-111-实现登录功能
- 前端B: HCC-112-实现用户注册
- 前端C: HCC-113-实现用户删除
相应地,我们创建三个功能分支:
- feature_HCC-111-实现登录功能
- feature_HCC-112-实现用户注册
- feature_HCC-113-实现用户删除
当所有的前端都开发完成了他们的任务,我们就要开始测试小程序了。但是如果按照以往体验版的测试方式,我们就需要排个顺序。比如,前端 A 先将他的小程序设置为体验版,测试把他的功能测试完成之后,再把前端 B 的设置为体验版,以此类推。可以看出真的很麻烦,而且浪费开发时间,我想你肯定不想在开发的时候突然被叫把你的小程序版本设置为体验版。
解决方案
小程序开发助手 这是一个官方提供的小程序,里面有多个版本的小程序可供选择,很方便测试人员的测试,并且也会节省开发人员的时间。点击版本查看就可以看到所有开发人员提交的最近的一次版本了。这样也不用设置体验版就可以测试最新的提交了。

再次抛出痛点
如果前端 A 头上有三个任务单呢?任务单:HCC-121-实现框架搭建,HCC-122-实现在线录屏,HCC-123-实现画板。此时你可能想说, 为啥前端 A 这么多的任务单呢?他命苦啊!
这个时候就需要配合微信的机器人了,我们可以创建多个机器人作为我们提交版本的媒介,这样我们就不受限于微信账号了。
可以在微信的官方文档看到 robot 参数有30个机器人可供选择。

接下来看下微信的机器人的使用方式。
微信官方是这样介绍这个工具的; miniprogram-ci 是从微信开发者工具中抽离的关于小程序/小游戏项目代码的编译模块。它其实是一个自动上传代码的工具,可以帮助我们自动化的编译代码并且上传到微信。
下面是一个大概得使用的示例,具体还是要参考官方文档。
const ci = require('miniprogram-ci');
(async () => {
const project = new ci.Project({
appid: 'wxsomeappid',
type: 'miniProgram',
projectPath: 'the/project/path',
privateKeyPath: 'the/path/to/privatekey',
ignores: ['node_modules/**/*'],
})
const previewResult = await ci.preview({
project,
desc: 'hello', // 此备注将显示在“小程序助手”开发版列表中
setting: {
es6: true,
},
qrcodeFormat: 'image',
qrcodeOutputDest: '/path/to/qrcode/file/destination.jpg',
onProgressUpdate: console.log,
// pagePath: 'pages/index/index', // 预览页面
// searchQuery: 'a=1&b=2', // 预览参数 [注意!]这里的`&`字符在命令行中应写成转义字符`\&`
})
console.log(previewResult)
})()
当我们使用这个脚本上传完代码就可以在小程序开发助手或者小程序管理平台看到以下内容。
微信管理后台

小程序开发助手页面

最后
我们可以使用 miniprogram-ci 配合 Jenkins 实现自动化部署,提交完成代码就可以自动部署了。以下是一个 github 的 actions 示例。当然也可以使用别的方式,例如本地提交,Jenkins提交等。
name: Feature Branch CI
on:
workflow_dispatch:
push:
branches: ['feature_*'] # 使用通配符匹配所有feature分支
jobs:
build_and_deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'yarn'
- name: Install dependencies
run: |
npm install -g miniprogram-ci cross-env
yarn install --frozen-lockfile
- name: Build Package
run: yarn cross-env ENV=PROD uni build -p mp-weixin --mode PROD
- name: Create private key file
run: echo "${{ secrets.PRIVATE_KEY }}" > private.key
- name: Deploy Package
env:
APP_ID: ${{ secrets.APP_ID }}
run: |
COMMIT_MESSAGE=$(git log --format=%B -n 1 ${{ github.sha }})
if [[ $COMMIT_MESSAGE =~ VERSION-([A-Za-z0-9_]+-[A-Za-z0-9_-]+)_DEV ]]; then
VERSION=${BASH_REMATCH[1]}
echo "Extracted Version: $VERSION"
miniprogram-ci preview \
--pp ./dist/build/mp-weixin \
--pkp ./private.key \
--appid $APP_ID \
--uv "${VERSION}" \
-r 7 \
--desc "${COMMIT_MESSAGE}" \
--upload-description "${COMMIT_MESSAGE}" \
--enable-es6 true \
--enable-es7 true \
--enable-minifyJS true \
--enable-minifyWXML true \
--enable-minifyWXSS true \
--enable-minify true \
--enable-bigPackageSizeSupport true \
--enable-autoPrefixWXSS true
else
echo "No Version found in commit message. Skipping upload."
fi
来源:juejin.cn/post/7412854873439027240
刚刚,英特尔发布最强CPU,AI PC迎来最高效x86芯片
金磊 发自 柏林
量子位 | 公众号 QbitAI
最高效的x86芯片,应当是一种怎样的“打开方式”?
就在刚刚,英特尔给出了一份答案——
英特尔® 酷睿™ Ultra 200V系列处理器。

△英特尔高级副总裁Jim Johnson
话不多说,直接上亮点:
- 最快的CPU:E核比上一代快68%,P核则快了14%
- 最快的内置(built-in)GPU:首次推出全新Xe2图形微架构,平均提升30% 的移动图形性能
- 最高AI性能:CPU、NPU和GPU整体算力高达120TOPS,直接拉高AI体验
- 最高效x86:整体功耗降低50%
- 超强的兼容性:各种软件应用程序均可兼容

由此,在搭载英特尔® 酷睿™ Ultra 200V系列芯片之后,AI PC们的生产力也迎来了“蜕变”:
- 每线程处理性能提高3倍
- 峰值性能提高80%
- 续航长达20小时

不仅如此,全球顶级的OEM和ISV们纷纷前来站台,例如谷歌、微软、联想、戴尔、惠普等等。
那么英特尔具体又是如何做到的?以及这个“史上最高效”又是如何界定的?我们继续往下看。
史上最高效的x86处理器
首先我们需要说明的是,Ultra 200V系列一共包含9款处理器,CPU均为8核8线程,GPU和NPU的核心数量会有所不同:

在低功耗方面,这里比较重要的一个变化,就是英特尔对低功耗岛(Low Power Island)做出的改变。
它把Lunar Lake核心数量和缓存增加了一倍(达到4MB和4个内核),并将E核从Crestmont更新到Skymont。

然后英特尔使用各种电源管理技术(包括 Thread Director),通过这个低功耗岛以低功耗来实现效率上的大幅提升。
一个比较有意思的点是,从这里开始,英特尔就直接开始向高通猛开炮火了,性能表现如下:

而除了高通之外,AMD也没能逃过被英特尔拿来公开做比较的命运。
例如在电池寿命方面,英特尔就表示已经超过了AMD和高通。

其次,是CPU方面。
英特尔在CPU上采用了全新的架构,即4个Skymont E核和4个Lion Cove P核,官方所展示的核心信息如下:

不过这里有一个关键的问题。
那就是P核不包括超线程,这个技术实际上允许单个CPU内核支持多个任务线程。
根据英特尔的说法,不采用超线程是因为这样会有助于芯片的整体集成。

尽管英特尔这次是“4+4”模型(8个线程,比上一代少很多),但从给出来的性能结果来看,要比AMD和高通要好得多。

在CPU之后,便是英特尔的内置GPU了。
英特尔这次首次亮相了全新的Xe2图形微架构,不仅适用于集成图形领域,而且适用于独立显卡。

在GPU性能的比较上就更有意思了。
英特尔先是用Ultra 7 155H和Ultra 9 288V在众多游戏上进行了PK,在Xe2加持之下,有了31%的性能提升。

然而到了与高通相比较的时候,结果是这样的:
高通X1E-84-100无法运行23款游戏。

在与AMD HX 370的比较过程中,Ultra 9 288V表现出来的结果是要快出16%。

除此之外,光线追踪也是Xe2的另一大亮点,领先于竞争对手,RT性能提高了30%。
最后,便是Ultra 200V在AI方面的性能了。
正如我们在开头提到的,英特尔此次整体算力达到了120TOPS,其中GPU是67TOPS,NPU 48TOPS,以及还有CPU的5 TOPS。
在性能对比上,同样是和高通相比,在使用Adobe Premiere和Lightroom功能等应用程序时,明显是要快得多。

值得一提的是,在量子位与英特尔交流过程中了解到,英特尔是目前与同行相比,唯一一家在CPU、NPU和GPU三个AI Engine都能做到均衡发展的那一个,而这也成为了其核心竞争力之一。

AI PC们都来站台了
除了英特尔此次“最高效x86处理器”的发布之外,现场的OEM和ISV们也是不可忽视的亮点。
以OEM为例,其数量之多,从一张图就能感受到这个feel了:

近乎所有的AI PC们都有所参与:20多个品牌,80多款机型都搭载了最新的Ultra 200V系列处理器。
不仅如此,在发布活动的现场,谷歌、微软、联想、戴尔和惠普等,也上台表达了对Ultra 200V能上机、上服务而感到的期待。


同样的,即便是在场外,PK的味道依旧是非常浓郁。
例如在demo演示区,英特尔就拉着AMD现场以赛车的游戏来比拼了一番:
**,时长00:17
当然,高通依旧是不能落下:

据了解,首批搭载Ultra 200V处理器的笔记本电脑将在9月24日上线。
不过在此之前,我们也不妨蹲一波更多的实测结果。
One More Thing
英特尔除了Ultra 200V系列芯片之外,此次还发布了Evo Edition标识。

据悉,获得标识的电脑必须通过严苛的英特尔Evo Edition OEM系统验证流程。

每款机型须首先通过预评估,并在通过之后的六个月内接受 10 种不同的测试标准。在此期间会对该机型进行调优,以满足英特尔Evo标准。
来源:juejin.cn/post/7410786086580437004
Go 重构:尽量避免使用 else、break 和 continue
今天,我想谈谈相当简单的事情。我不会发明什么,但我在生产代码中经常看到这样的事情,所以我不能回避这个话题。
我经常要解开多个复杂的 if else 结构。多余的缩进、过多的逻辑只会加深理解。首先,这篇文章的主要目的是让代码更透明、更易读。不过,在某些情况下还是必须使用这些操作符。
else 操作
例如,我们有简单的用户处理程序:
func handleRequest(user *User) {
if user != nil {
showUserProfilePage(user)
} else {
showLoginPage()
}
}
如果没有提供用户,则需要将收到的请求重定向到登录页面。If else 似乎是个不错的决定。但我们的主要任务是确保业务逻辑单元在任何输入情况下都能正常工作。因此,让我们使用提前返回来实现这一点。
func handleRequest(user *User) {
if user == nil {
return showLoginPage()
}
showUserProfilePage(user)
}
逻辑是一样的,但是下面的做法可读性会更强。
break 操作
对我来说,Break 和 Continue 语句总是可以分解的信号。
例如,我们有一个简单的搜索任务。找到目标并执行一些业务逻辑,或者什么都不做。
func processData(data []int, target int) {
for i, value := range data {
if value == target {
performActionForTarget(data[i])
break
}
}
}
你应该始终记住,使用 break 操作符并不能保证整个数组都会被处理。这对性能有好处,因为我们丢弃了不必要的迭代,但对代码支持和可读性不利。因为我们永远不知道程序会在列表的开头还是结尾停止。
在某些情况下,带有子任务的简单功能可能会破坏这段代码。
func processData(data []int, target int, subtask int) {
for i, value := range data {
if value == subtask {
performActionForSubTarget(data[i])
}
if value == target {
performActionForTarget(data[i])
break
}
}
}
这样我们实际上可以拆出一个 find 的方法:
func processData(data []int, target int, subTarget int) {
found := findTarget(data, target)
if found > notFound {
performActionForTarget(found)
}
found = findTarget(data, subTarget)
if found > notFound {
performActionForSubTarget(found)
}
}
const notFound = -1
func findTarget(data []int, target int) int {
if len(data) == 0 {
return notFound
}
for _, value := range data {
if value == target {
return value
}
}
return notFound
}
同样的逻辑,但是拆分成更细粒度的方法,也有精确的返回语句,可以很容易地通过测试来实现。
continue 操作
该操作符与 break 类似。为了正确阅读代码,您应该牢记它对操作顺序的具体影响。
func processWords(words []string, substring string) {
for _, word := range words {
if !strings.Contains(word, substring) {
continue
}
// do some buisness logic
performAction(word)
}
}
Continue 使得这种简单的流程变得有点难以理解。
让我们写得更简洁些:
func processWords(words []string, substring string) {
for _, word := range words {
if strings.Contains(word, substring) {
performAction(word)
}
}
}
来源:juejin.cn/post/7290931758786756669
实现基于uni-app的项目自动检查APP更新
我们平时工作中开发APP时,及时为用户提供应用更新是提升用户体验和保证功能完整性的重要一环。本文将通过一段实际的代码示例,详细介绍如何在基于uni-app框架的项目中实现自动检查应用更新的功能,并引导用户完成升级过程。该功能主要涉及与服务器端交互获取最新版本信息、比较版本号、提示用户升级以及处理下载安装流程。
创建一个checkappupdate.js文件
这个文件是写升级逻辑处理的文件,可以不创建,直接在App.vue中写,但是为了便于维护,还是单独放出来比较好,可以放在common或者util目录中(App.vue能引入到就行,随意放,根目录也行),App.vue中引入该文件,调用升级函数如下图所示:

js完整代码
为了防止一点点代码写,容易让人云里雾里,先放完整代码,稍后再详细解释,其实看注释也就够了。
//这是服务端请求url配置文件,如果你直接卸载下面的请求中,可以不引入
import configService from '@/common/service/config.service.js'
export default function checkappupdate(param = {}) {
// 合并默认参数
param = Object.assign({
title: "A new version has been detected!",
content: "Please upgrade the app to the latest version!",
canceltext: "No upgrade",
oktext: "Upgrade now"
}, param)
plus.runtime.getProperty(plus.runtime.appid, (widgetInfo) => {
let platform = plus.os.name.toLocaleLowerCase() //Android
let os_version = plus.os.version //13 安卓版本
let vendor = plus.device.vendor //Xiaomi
let url = configService.apiUrl
uni.request({
url: url + '/checkAppUpdate',
method: 'GET',
data: {
platform: platform,
os_version: os_version,
vendor: vendor,
cur_version: widgetInfo.version
},
success(result) {
console.log(result)
let versionCode = parseInt(widgetInfo.versionCode)
let data = result.data ? result.data : null;
// console.log(data);
let downAppUrl = data.url
//判断版本是否需要升级
if (versionCode >= data.versionCode) {
return;
}
//升级提示
uni.showModal({
title: param.title,
content: data.log ? data.log : param.content,
showCancel: data.force ? false : true,
confirmText: param.oktext,
cancelText: param.canceltext,
success: res => {
if (!res.confirm) {
console.log('Cancel the upgrade');
// plus.runtime.quit();
return
}
// if (data.shichang === 1) {
// //去应用市场更新
// plus.runtime.openURL(data.shichangurl);
// plus.runtime.restart();
// } else {
// 开始下载
// 创建下载任务
var dtask = plus.downloader.createDownload(downAppUrl, {
filename: "_downloads/"
},
function (d, status) {
// 下载完成
if (status == 200) {
plus.runtime.install(d.filename, {
force: true
}, function () {
//进行重新启动;
plus.runtime.restart();
}, (e) => {
uni.showToast({
title: 'install fail:' + JSON
.stringify(e),
icon: 'none'
})
console.log(JSON.stringify(e))
});
} else {
this.tui.toast("download fail,error code: " +
status);
}
});
let view = new plus.nativeObj.View("maskView", {
backgroundColor: "rgba(0,0,0,.6)",
left: ((plus.screen.resolutionWidth / 2) - 45) +
"px",
bottom: "80px",
width: "90px",
height: "30px"
})
view.drawText('start download...', {}, {
size: '12px',
color: '#FFFFFF'
});
view.show()
// console.log(dtask);
dtask.addEventListener("statechanged", (e) => {
if (e && e.downloadedSize > 0) {
let jindu = ((e.downloadedSize / e.totalSize) *
100).toFixed(2)
view.reset();
view.drawText('Progress:' + jindu + '%', {}, {
size: '12px',
color: '#FFFFFF'
});
}
}, false);
dtask.start();
// }
},
fail(e) {
console.log(e);
uni.showToast({
title: 'Request error'
})
}
})
}
})
});
}
函数定义:checkappupdate
定义核心函数checkappupdate,它接受一个可选参数param,用于自定义提示框的文案等信息。函数内部首先通过Object.assign合并默认参数与传入参数,以确保即使未传入特定参数时也能有良好的用户体验。
获取应用信息与环境变量
利用plus.runtime.getProperty获取当前应用的详细信息,包括但不限于应用ID、版本号(version)和版本号代码(versionCode),以及设备的操作系统名称、版本和厂商信息。这些数据对于后续向服务器请求更新信息至关重要。
请求服务器检查更新
构建包含平台信息、操作系统版本、设备厂商和当前应用版本号的请求参数,发送GET请求至配置好的API地址/checkAppUpdate,查询是否有新版本可用。后端返回参数参考下面:
/**
* 检测APP升级
*/
public function checkAppUpdate()
{
$data['versionCode'] = 101;//更新的版本号
$data['url'] = 'http://xxx/app/xxx.apk';//下载地址
$data['force'] = true;//是否强制更新
return json_encode($data);//返回json格式数据到前端
}
比较版本与用户提示
一旦收到服务器响应,解析数据并比较当前应用的版本号与服务器提供的最新版本号。如果存在新版本,使用uni.showModal弹窗提示用户,展示新版本日志(如果有)及升级选项。此步骤充分考虑了是否强制更新的需求,允许开发者灵活配置确认与取消按钮的文案。
下载与安装新版本
用户同意升级后,代码将执行下载逻辑。通过plus.downloader.createDownload创建下载任务,并监听下载进度,实时更新进度提示。下载完成后,利用plus.runtime.install安装新APK文件,并在安装成功后调用plus.runtime.restart重启应用,确保新版本生效。
用户界面反馈
在下载过程中,通过创建原生覆盖层plus.nativeObj.View展示一个半透明遮罩和下载进度信息,给予用户直观的视觉反馈,增强了交互体验,进度展示稍微有点丑,可以提自己改改哈。

总结
通过上述步骤,我们实现了一个完整的应用自动检查更新流程,不仅能够有效通知用户新版本的存在,还提供了平滑的升级体验。此功能的实现,不仅提升了用户体验,也为产品迭代和功能优化提供了有力支持。开发者可以根据具体需求调整提示文案、下载逻辑、进度样式等细节,以更好地适配自身应用的特点和用户群体。
来源:juejin.cn/post/7367555191337828361
Innodb之buffer pool 图文详解
介绍
数据通常都是持久化在磁盘中的,innodb如果在处理客户端的请求时直接访问磁盘,无论是IO压力还是响应速度都是无法接受的;所以innodb在处理请求时,如果需要某个页的数据就会把整个页都加载到缓存中,数据页会长时间待在缓存中,根据一定策略进行淘汰,后续如果在缓存中的数据就不需要再次加载数据页了,这样即可提高响应时间又可以节省磁盘IO.
buffer pool
上述介绍中我们有提到一个缓存,这个缓存就指的是buffer pool,通过innodb_buffer_pool_size进行设置它的大小,默认为128MB,最小为5MB;可以根据各自线上机器的情况来设置它的大小,设置几个G甚至上百个G都是合理的。
内部组成

buffer pool中包含数据页、索引页、change buffer、自适应hash等内容;数据页、索引页在buffer pool中占用了大部分,不能简单的认为缓冲池中只有数据页和索引页;change buffer在较老的版本中叫insert buffer,后面对其进行了升级形成了现在的change buffer;自适应hash可以方便我们快速查询数据;锁信息、数据字典都是占用比较小的一部分;以上就是buffer pool的内部组成。
页数据
数据页、索引页数据在mysql启动的时候,会直接给申请一块连续的内存空间;如图:

上图中的缓冲页对应的就是磁盘中的数据,默认每个页大小为16KB,并且innodb为每个缓冲页都创建了一些控制块,每个控制块占用大小是800字节左右,需要额外付出百分之5的内存,它记录页所属的表空间编号、页号、缓存页在buffer pool中的地址、链表节点信息等。内存中间可能会有碎片进行对齐。
注意:这里只有缓冲页占用的空间是计算在buffer pool中的。
free链表
根据上面的图可以了解到,buffer pool中有一堆缓冲页,但innodb从磁盘中读取数据页时,由于不能直接知道哪些缓冲页是空闲的、哪些页已经被使用了,导致了不知道把要读取的数据页存放到哪里;此时就引入了一个free链表的概念。如图:

上图中可以看到free链表靠一个free节点连接到控制块中,其中free头节点仅占用40字节空间,但它也不计算在buffer pool中;有了这个free链表后每当需要从磁盘中加载一个页到buffer pool中时就可以从free链表上取一个控制块,把控制块所需信息填充上,同时把从磁盘上加载的数据放到对应的缓冲页上,并把该控制块从free链表中移除。此时就把磁盘中的页加载到内存中了,后续查询数据时就会优先查询该内存页,但每次查询时没办法立刻知道该页是在内存中还是磁盘中,上述操作后还会把这个页信息放到一个散列表中,以(表空间号+页号)作为key,以控制块地址作为value。
flush链表
上述介绍了读数据时通过优先读取内存页可以提高我们的响应速度以及节省磁盘io,那么如果是写数据呢?其实在innodb中,更改也会优先在内存中更改,在后续会根据一定规则(会在后续redolog文章中详细介绍)进行刷盘,在刷盘时只需要刷被更改的缓冲页即可,那么哪些缓存页被更改了innodb是不知道的,此时innodb就设计了flush链表,它和free链表几乎一样,如图:

当需要刷盘时会从flush链表中拿出一部分控制块对应的缓冲页进行刷盘,刷盘后控制块会从flush链表中移除,并放到free链表中。
LRU链表
由于buffer pool的内存区域是有限的,如果当来不及刷盘时内存就不够用了;此时innodb采用了LRU淘汰策略,标准的LRU算法:
- 如果数据页已经被加载到buffer pool中了,则直接把对应的控制块移动到LRU链表的头部;
- 如果数据页不在buffer pool中,则从磁盘中加载到buffer pool中,并把其对应的控制块放到LRU头部;此时内存空间已经满了的话,就会从链表中移除最后一个内存页。
但直接采用lru方案,在内存较小或者临时一次性加载到内存中的页过多时会把真正的热点数据刷掉。如预读和全表扫描。
- 线性预读:如果顺序访问的某个区的页面数超过
innodb_read_ahead_threshold(默认值为56)就会触发一次异步预加载下一个区中的全部页到内存中; - 随机预读:如果某个区的13个连续的页都被加载到young区前1/4的位置中,会触发一次异步预加载本区中的全部页到内存中;
- 全表扫描:把整张表的数据都加载到内存中。
为了解决上述问题,innodb对这个淘汰策略做了一点改变。如图:

innodb根据innodb_old_blocks_pct(默认37)参数把整个lru分成一定比例,具体的淘汰策略:
- 当数据页第一次加载进来时会先放到old head处,当链表满时会把old tail刷盘并从链表中移除。
- 当再次使用一个数据页时,并且该页在old区,会先判断在old区的停留时间是否超过
innodb_old_blocks_time(默认1000ms),如果超过则把该数据页移动到young head处,反之移动到old head处。 - 当再次使用一个数据页时,并且该页young区为了节省移动的操作,会判断该缓冲页是否在young区前1/4中,如果在就不进行移动,如果不在则移动到young head处。
多buffer pool实例
对于buffer pool设置很大内存的实例,由于操作各种链表都需要进行加锁这样就比较影响整体的性能,为了提高并发处理能力,可以通过innodb_buffer_pool_instances来设置buffer pool的实例个数。在mysql5.7.5版本中,会以chunk为单位向系统申请内存空间,每个buffer pool中包含了N个chunk。如图:

可以通过innodb_buffer_pool_chunk_size(默认128M)来设置chunk的大小,只能在启动前设置好,启动后可以更改innodb_buffer_pool_size的大小,但必须时innodb_buffer_pool_chunk_size * innodb_buffer_pool_instances的整数倍。
自适应hash
对于b+数来讲,整体的查询时间复杂度为O(logN),而innodb为了进一步提升性能,引入了自适应hash,对热点数据可以做到O(1)的时间复杂度就可以定位到数据在具体哪个页中。
innodb会自动根据访问频率和模式自动为某些热点页在内存中建立自适应哈希索引,规则:
- 模式:例如有一个联合索引(a,b);查询条件
where a = xxx与where a = xxx and b = xxx这就属于两种模式,如果交叉使用这两种查询,不会为其建立自适应哈希索引;
- 频率:使用一种默认访问的次数大于Math.min(100,页中数据/16)。
根据官方数据,启动自适应哈希索引读写速度可以提升2倍,辅助索引的连接性能可以提升5倍。
总结
通过上述的介绍,希望能帮助大家对buffer pool有一个基础的了解,想进一步深入了解可以通过执行show engine innodb status观察下各种参数,通过对每个参数的细致研究可以全方面的掌握buffer pool。
创作不易,觉得文章写得不错帮忙点个赞吧!如转载请标明出处~
来源:juejin.cn/post/7413196978601295899
仿树木生长开花的动画效果
效果介绍
使用 canvas 进行绘制树木生长的效果,会逐渐长出树干,长出树叶,开出花。当窗口大小发生变化时进行重新渲染。
实现效果展示

实现步骤
创建画布
import React, { useEffect, useRef } from 'react'
function TreeCanvas(props: {
width: number;
height: number;
}) {
const { width = 400, height = 400 } = props;
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const context = canvas?.getContext('2d');
if (!canvas || !context) return;
context.strokeStyle = '#a06249';
}, [])
return (
<canvas ref={canvasRef} width={width} height={height} />
)
}
export default TreeCanvas
封装创建树枝的方法
- 树枝需要起点,终点,树枝宽度
function lineTo(p1: PointType, p2: PointType, lineWidth: number) {
context?.beginPath();
context?.moveTo(p1.x, p1.y);
context?.lineTo(p2.x, p2.y);
context.lineWidth = lineWidth;
context?.stroke();
}
绘制树叶和花朵的方法封装
- 提前生成图片实例
- 传递图片和坐标进行绘制
// 花的实例
const image = new Image();
image.src ='https://i.postimg.cc/D0LLWwKy/flower1.png';
// 叶子的实例
const imageLeaves = new Image();
imageLeaves.src = 'https://i.postimg.cc/PJShQmH6/leaves.png';
function drawTmg(imageUrl: any, p1: PointType) {
context?.drawImage(imageUrl, p1.x, p1.y, 20 * Math.random(), 20 * Math.random());
}
封装绘制处理
- 提供绘制的起点,计算绘制的终点
- 根据起点和终点进行绘制
// 计算终点
function getEnd(b: BranchType) {
const { start, theta, length } = b;
return {
x: start.x + Math.cos(theta) * length,
y: start.y + Math.sin(theta) * length
};
}
// 绘制整理
function drawBranch(b: BranchType) {
// 绘制树干
lineTo(b.start, getEnd(b), b.lineWidth);
if (Math.random() < 0.4) { // 绘制花朵的密度
drawTmg(image, getEnd(b));
}
if (Math.random() < 0.4) {
drawTmg(imageLeaves, b.start); // 绘制树叶的密度
}
}
绘制树的方法
- 起点和终点的计算及绘制数的角度计算
- 绘制左边树和右边树
- 随机绘制
function step(b: BranchType, depth: number = 0) {
const end = getEnd(b);
drawBranch(b);
if (depth < depthNum || Math.random() < 0.5) {
step(
{
start: end,
theta: b.theta - 0.3 * Math.random(),
length: b.length + (Math.random() * 10 - 5),
lineWidth: depthNum - depth
},
depth + 1
);
}
if (depth < depthNum || Math.random() < 0.5) {
step(
{
start: end,
theta: b.theta + 0.3 * Math.random(),
length: b.length + (Math.random() * 10 - 5),
lineWidth: depthNum - depth
},
depth + 1
);
}
}
动画处理
- 把所有绘制添加到动画处理中
const pendingTasks: Function[] = []; // 动画数组
function step(b: BranchType, depth: number = 0) {
const end = getEnd(b);
drawBranch(b);
if (depth < depthNum || Math.random() < 0.5) {
pendingTasks.push(() => { // 添加左侧动画
step(
{
start: end,
theta: b.theta - 0.3 * Math.random(), // 角度变化
length: b.length + (Math.random() * 10 - 5), // 长度变化
lineWidth: depthNum - depth
},
depth + 1
);
});
}
if (depth < depthNum || Math.random() < 0.5) {
pendingTasks.push(() => { // 添加右侧动画
step(
{
start: end,
theta: b.theta + 0.3 * Math.random(), // 角度变化
length: b.length + (Math.random() * 10 - 5),// 长度变化
lineWidth: depthNum - depth
},
depth + 1
);
});
}
}
function frame() {
const tasks = [...pendingTasks];
pendingTasks.length = 0;
tasks.forEach((fn) => fn());
}
let framesCount = 0;
function satrtFrame() {
requestAnimationFrame(() => {
framesCount += 1;
// if (framesCount % 10 === 0) {
frame();
satrtFrame();
// }
});
}
封装执行方法
useEffect(() => {
function init() {
step(startBranch);
}
satrtFrame();
init();
}, []);
添加常用场景封装
- 宽高获取当前屏幕大小
- 屏幕发生变化时进行重新渲染
export const TreeCanvasInner = () => {
const [innerSize, setInnerSize] = useState({ x: window.innerWidth, y: window.innerHeight });
useEffect(() => {
const resizeFunc = () => {
setInnerSize({ x: window.innerWidth, y: window.innerHeight });
};
window.addEventListener('resize', resizeFunc);
return () => {
window.removeEventListener('resize', resizeFunc);
};
}, []);
return (
<TreeCanvas
key={JSON.stringify(innerSize)}
width={innerSize.x}
height={innerSize.y}
startBranch={{ start: { x: 0, y: 0 }, theta: 20, length: 25, lineWidth: 3 }}
/>
);
};
完整代码
来源:juejin.cn/post/7309061655095361571
为什么很多人不推荐你用JWT?
为什么很多人不推荐你用JWT?
如果你经常看一些网上的带你做项目的教程,你就会发现 有很多的项目都用到了JWT。那么他到底安全吗?为什么那么多人不推荐你去使用。这个文章将会从全方面的带你了解JWT 以及他的优缺点。
什么是JWT?
这个是他的官网JSON Web Tokens - jwt.io
这个就是JWT

JWT 全称JSON Web Token
如果你还不熟悉JWT,不要惊慌!它们并不那么复杂!
你可以把JWT想象成一些JSON数据,你可以验证这些数据是来自你认识的人。
当然如何实现我们在这里不讲,有兴趣的可以去自己了解。
下面我们来说一下他的流程:
- 当你登录到一个网站,网站会生成一个JWT并将其发送给你。
- 这个JWT就像是一个包裹,里面装着一些关于你身份的信息,比如你的用户名、角色、权限等。
- 然后,你在每次与该网站进行通信时都会携带这个JWT。
- 每当你访问一个需要验证身份的页面时,你都会把这个JWT带给网站。
- 网站收到JWT后,会验证它的签名以确保它是由网站签发的,并且检查其中的信息来确认你的身份和权限。
- 如果一切都通过了验证,你就可以继续访问受保护的页面了。

为什么说JWT很烂?
首先我们用JWT应该就是去做这些事情:
- 用户注册网站
- 用户登录网站
- 用户点击并执行操作
- 本网站使用用户信息进行创建、更新和删除 信息
这些事情对于数据库的操作经常是这些方面的
- 记录用户正在执行的操作
- 将用户的一些数据添加到数据库中
- 检查用户的权限,看看他们是否可以执行某些操作
之后我们来逐步说出他的一些缺点
大小
这个方面毋庸置疑。
比如我们需要存储一个用户ID 为xiaou
如果存储到cookie里面,我们的总大小只有5个字节。
如果我们将 ID 存储在 一个 JWT 里。他的大小就会增加大概51倍

这无疑就增大了我们的宽带负担。
冗余签名
JWT的主要卖点之一就是其加密签名。因为JWT被加密签名,接收方可以验证JWT是否有效且可信。
但是,在过去20年里几乎每一个网络框架都可以在使用普通的会话cookie时获得加密签名的好处。
事实上,大多数网络框架会自动为你加密签名(甚至加密!)你的cookie。这意味着你可以获得与使用JWT签名相同的好处,而无需使用JWT本身。
实际上,在大多数网络身份验证情况下,JWT数据都是存储在会话cookie中的,这意味着现在有两个级别的签名。一个在cookie本身上,一个在JWT上。
令牌撤销问题
由于令牌在到期之前一直有效,服务器没有简单的方法来撤销它。
以下是一些可能导致这种情况危险的用例。
注销并不能真正使你注销!
想象一下你在推特上发送推文后注销了登录。你可能会认为自己已经从服务器注销了,但事实并非如此。因为JWT是自包含的,将在到期之前一直有效。这可能是5分钟、30分钟或任何作为令牌一部分设置的持续时间。因此,如果有人在此期间获取了该令牌,他们可以继续访问直到它过期。
可能存在陈旧数据
想象一下用户是管理员,被降级为权限较低的普通用户。同样,这不会立即生效,用户将继续保持管理员身份,直到令牌过期。
JWT通常不加密
因此任何能够执行中间人攻击并嗅探JWT的人都拥有你的身份验证凭据。这变得更容易,因为中间人攻击只需要在服务器和客户端之间的连接上完成
安全问题
对于JWT是否安全。我们可以参考这个文章
JWT (JSON Web Token) (in)security - research.securitum.com
同时我们也可以看到是有专门的如何攻击JWT的教程的
高级漏洞篇之JWT攻击专题 - FreeBuf网络安全行业门户
总结
总的来说,JWT适合作为单次授权令牌,用于在两个实体之间传输声明信息。
但是,JWT不适合作为长期持久数据的存储机制,特别是用于管理用户会话。使用JWT作为会话机制可能会引入一系列严重的安全和实现上的问题,相反,对于长期持久数据的存储,更适合使用传统的会话机制,如会话cookie,以及建立在其上的成熟的实现。
但是写了这么多,我还是想说,如果你作为自己开发学习使用,不考虑安全,不考虑性能的情况下,用JWT是完全没有问题的,但是一旦用到生产环境中,我们就需要避免这些可能存在的问题。
来源:juejin.cn/post/7365533351451672612


















