注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

啊,富文本没做安全处理被XSS攻击了啊

web
前言 相信很多前端小伙伴项目中都用到了富文本,但你们有没有做防XSS攻击处理?最近的项目由于比较紧急我也没有处理而是直接正常使用,但公司内部有专门的安全部门针对测试,然后测出来富文本被XSS攻击了,而且危险级别为高。 啊这....,那我就去解决一下吧,顺便从X...
继续阅读 »

前言


相信很多前端小伙伴项目中都用到了富文本,但你们有没有做防XSS攻击处理?最近的项目由于比较紧急我也没有处理而是直接正常使用,但公司内部有专门的安全部门针对测试,然后测出来富文本被XSS攻击了,而且危险级别为高。


啊这....,那我就去解决一下吧,顺便从XSS和解决方案两个角度记录到下来毕竟好久没更新文章了。


先说说什么是XSS攻击?


简述XSS全称Cross-Site Scripting也叫跨站脚本攻击,是最最最常见的网络安全漏洞,其实就是攻击者在受害者的浏览器中注入恶意脚本执行。这种攻击通常发生在 Web 应用程序未能正确过滤用户输入的情况下,导致恶意脚本被嵌入到合法的网页中。
执行后会产生窃取信息、篡改网页、和传播病毒与木马等危害,后果相当严重。


XSS又有三大类


1、存储型 XSS即Stored XSS


恶意的脚本被放置在目标服务器上面,通过正常的网页请求返回给用户端执行。


例如 在观看某个私人博客评论中插入恶意脚本,当其他用户访问该页面时,脚本会执行危险操作。


2、反射型 XSS即Reflected XSS


恶意的脚本通过 URL 参数或一些输入的字段传递给目标的服务器,用户在正常请求时会返回并且执行。


例如 通过链接中的参数后面注入脚本,当用户点击此链接时,脚本就会在用户的浏览器中执行危险操作。


3、DOM 基于的 XSS即DOM-based XSS


恶意的脚本利用 DOM(Document Object Model)操作来修改页面内容。
这种类型的 XSS 攻击不涉及服务器端的代码操作,仅仅是通过客户端插入 JavaScript 代码实现操作。


富文本就是属于第一种,把脚本藏在代码中存到数据库,然后用户获取时会执行。


富文本防XSS的方式?


网上一大堆不明不白的方法还有各种插件可以用,但其实自己转义一下就行,根本不需要复杂化。


当我们不做处理时传给后台的富文本数据是这样的。


image.png
上面带有标签,甚至有srcscript之类的操作,在里面放一些脚本真的太简单了。


因此,我们创建富文本成功提交给后台的时候把各种<>/\之类危险符号转义成指定的字符就能防止脚本了。


如下所示,方法参数value就是要传递给后台的富文本内容。


  export const getXssFilter = (value: string): string => {
// 定义一个对象来存储特殊字符及其对应的 HTML 实体
const htmlEntities = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
'\'': '&#39;',
'\\': '&#92;',
'|': '&#124;',
';': '&#59;',
'$': '&#36;',
'%': '&#37;',
'@': '&#64;',
'(': '&#40;',
')': '&#41;',
'+': '&#43;',
'\r': '&#13;',
'\n': '&#10;',
',': '&#44;',
};

// 使用正则表达式替换所有特殊字符
let result = value.replace(/[&<>"'\\|;$%@()+,]/g, function (match) {
return htmlEntities[match] || match;
});

return result;
};


此时传给后台的富文本参数是这样的,把敏感符号全部转义。


image.png


但展现给用户看肯定要看正常的内容啊,这里就要把内容重新还原了,这步操作可以在前端完成,也可以在后端完成。


如果是前端完成可以用以下方法把获取到的数据进行转义。


// 还原特殊字符
export const setXssFilter = (input) => {
return input
.replace(/&#124;/g, '|')
.replace(/&amp;/g, '&')
.replace(/&#59;/g, ';')
.replace(/&#36;/g, '$')
.replace(/&#37;/g, '%')
.replace(/&#64;/g, '@')
.replace(/&#39;/g, '\'')
.replace(/&quot;/g, '"')
.replace(/&#92;/g, '\\')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&#40;/g, '(')
.replace(/&#41;/g, ')')
.replace(/&#43;/g, '+')
.replace(/&#13;/g, '\r')
.replace(/&#10;/g, '\n')
.replace(/&#44;/g, ',');
}

但是。。。。


上面只适合使用于纯富文本的场景,如果在普通文本的地方回显会依然触发危险脚本。如下所示


image.png


其实直接转义后不还原即可解决,但由于是富文本这种情况比较特殊情况,不还原就失去文本样式了,怎么办??


最终解决方案是对部分可能造成XSS攻击的特殊字符和标签进行转义处理,例如:script、iframe等。


示例代码


  export const getXssFilter = (value: string): string => {
// 定义一个对象来存储特殊字符及其对应的 HTML 实体
const htmlEntities = {
'&': '&amp;',
'\'': '&#39;',
'\r': '&#13;',
'\n': '&#10;',
'script': '&#115;&#99;&#114;&#105;&#112;&#116;',
'iframe': '&#105;&#102;&#114;&#97;&#109;&#101;',
// 'img': '&#105;&#109;&#103;',
'object': '&#111;&#106;&#115;&#116;',
'embed': '&#101;&#109;&#98;&#101;&#100;',
'on': '&#111;&#110;',
'javascript': '&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;',
'expression': '&#101;&#120;&#112;&#114;&#101;&#115;&#115;&#115;&#105;&#111;&#110;',
'video': '&#118;&#105;&#100;&#101;&#111;',
'audio': '&#97;&#117;&#100;&#105;&#111;',
'svg': '&#115;&#118;&#103;',
'background-image': '&#98;&#97;&#99;&#107;&#103;&#114;&#111;&#117;&#110;&#100;-&#105;&#109;&#97;&#103;&#101;',
};

// 使用正则表达式替换所有特殊字符
let result = value.replace(/[&<>"'\\|;$%@()+,]/g, function (match) {
return htmlEntities[match] || match;
});

// 额外处理 `script`、`iframe`、`img` 等关键词
result = result.replace(/script|iframe|object|embed|on|javascript|expression|background-image/gi, function (match) {
return htmlEntities[match] || match;
});

return result;
};

效果只会对敏感部分转义


image.png
但这种方案不用还原转义,因为做的针对性限制。


小结


其实就是对特殊符号转换后还原的思路,相当的简单。


如果那里写的不好或者有更好的建议,欢迎大佬指点啦。


作者:天天鸭
来源:juejin.cn/post/7415911762128404480
收起阅读 »

大厂必问 · 如何防止订单重复?

在电商系统或任何涉及订单操作的场景中,用户多次点击“提交订单”按钮可能会导致重复订单提交,造成数据冗余和业务逻辑错误,导致库存问题、用户体验下降或财务上的错误。因此,防止订单重复提交是一个常见需求。 常见的重复提交场景 网络延迟:用户在提交订单后未收到确认,...
继续阅读 »

在电商系统或任何涉及订单操作的场景中,用户多次点击“提交订单”按钮可能会导致重复订单提交,造成数据冗余和业务逻辑错误,导致库存问题、用户体验下降或财务上的错误。因此,防止订单重复提交是一个常见需求。


常见的重复提交场景



  1. 网络延迟:用户在提交订单后未收到确认,误以为订单未提交成功,连续点击提交按钮。

  2. 页面刷新:用户在提交订单后刷新页面,触发订单的重复提交。

  3. 用户误操作:用户无意中点击多次订单提交按钮。


防止重复提交的需求



  1. 幂等性保证:确保相同的请求多次提交只能被处理一次,最终结果是唯一的。

  2. 用户体验保障:避免由于重复提交导致用户感知的延迟或错误。


常用解决方案


前端防重机制:在前端按钮点击时禁用按钮或加锁,防止用户多次点击。


后端幂等处理



  • 利用Token机制:在订单生成前生成一个唯一的Token,保证每个订单提交时只允许携带一次Token。

  • 基于数据库的唯一索引:通过对订单字段(如订单号、用户ID)创建唯一索引来防止重复数据的插入。

  • 分布式锁:使用Redis等分布式缓存加锁,保证同一时间只允许处理一个订单请求。


功能实践


Spring Boot 提供了丰富的工具和库,今天我们基于Spring Boot框架,可以利用 Token机制Redis分布式锁 来防止订单的重复提交。


功能原理与技术实现


通过Redis的原子性操作,我们可以确保高并发情况下多个请求对同一个订单的操作不会冲突。


请在此添加图片描述


Token机制


Token机制是一种常见的防止重复提交的手段,通常的工作流程如下:



  1. Token生成:在用户开始提交订单时,服务器生成一个唯一的 OrderToken 并将其存储在 Redis 等缓存中,同时返回给客户端。

  2. Token验证:用户提交订单时,客户端会将 OrderToken 发送回服务器。服务器会验证此 OrderToken 是否有效。

  3. Token销毁:一旦验证通过,服务器会立即销毁 OrderToken,防止重复使用同一个Token提交订单。


这种机制确保每次提交订单时都需要一个有效且唯一的Token,从而有效防止重复提交。


Redis分布式锁


在多实例的分布式环境中,Token机制可以借助 Redis 来实现更高效的分布式锁:



  1. Token存储:生成的Token可以存储在Redis中,Token的存活时间通过设置TTL(如10分钟),保证Token在一定时间内有效。

  2. Token校验与删除:当用户提交订单时,服务器通过Redis查询该Token是否存在,并立即删除该Token,确保同一个订单只能提交一次。


流程设计



  1. 用户发起订单请求时,后端生成一个唯一的Token(例如UUID),并将其存储在Redis中,同时将该Token返回给前端。

  2. 前端提交订单时,将Token携带至后端。

  3. 后端校验该Token是否有效,若有效则执行订单创建流程,同时删除Redis中的该Token,确保该Token只能使用一次。

  4. 如果该Token已被使用或过期,则返回错误信息,提示用户不要重复提交。


功能实现


依赖配置(pom.xml)


<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

application. properties


# Thymeleaf ??
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
spring.thymeleaf.mode=HTML
spring.thymeleaf.encoding=UTF-8
spring.thymeleaf.cache=false

spring.redis.host=127.0.0.1
spring.redis.port=23456
spring.redis.password=pwd

订单Token生成服务


生成Token并存储到Redis: 当用户请求生成订单页面时,服务器生成一个唯一的UUID作为订单Token,并将其与用户ID一起存储在Redis中。


package com.example.demo.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Service
@Slf4j
public class OrderTokenService {

@Autowired
private RedisTemplate<String, String> redisTemplate;
// 生成订单Token
public String generateOrderToken(String userId) {
String token = UUID.randomUUID().toString();
// 将Token存储在Redis中,设置有效期10分钟
redisTemplate.opsForValue().set("orderToken:" + userId, token, 10, TimeUnit.MINUTES);
return token;
}
// 验证订单Token
public boolean validateOrderToken(String userId, String token) {
String redisToken = redisTemplate.opsForValue().get("orderToken:" + userId);
log.info("@@ 打印Redis中记录的redisToken :{} `VS` @@ 打印当前请求过来的token :{}", redisToken, token);
if (token.equals(redisToken)) {
// 验证成功,删除Token
redisTemplate.delete("orderToken:" + userId);
return true;
}
return false;
}
}


订单控制器


订单提交与验证Token: 提交订单时,系统会检查用户传递的Token是否有效,若有效则允许提交并删除该Token,确保同一Token只能提交一次。


package com.example.demo.controller;

import com.example.demo.entity.Order;
import com.example.demo.service.OrderTokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/order")
public class OrderController {

@Autowired
private OrderTokenService orderTokenService;
// 获取订单提交的Token
@GetMapping("/getOrderToken")
public ResponseEntity<String> getOrderToken(@RequestParam String userId) {
String token = orderTokenService.generateOrderToken(userId);
return ResponseEntity.ok(token);
}
// 提交订单
@PostMapping("/submitOrder")
public ResponseEntity<String> submitOrder(Order order) {
// 校验Token
if (!orderTokenService.validateOrderToken(order.getUserId(), order.getOrderToken())) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("订单重复提交,请勿重复操作");
}

// 此处处理订单逻辑
// ...

// 假设订单提交成功
return ResponseEntity.ok("订单提交成功");
}
}


前端实现


前端通过表单提交订单,并在每次提交前从服务器获取唯一的订单Token:



<script>

document.getElementById('orderForm').addEventListener('submit', function(event) {
event.preventDefault();

const userId = document.getElementById('userId').value;
if (!userId) {
alert("请填写用户ID");
return;
}

// 先获取Token,再提交订单
fetch(`/order/getOrderToken?userId=${userId}`)
.then(response => response.text())
.then(token => {
document.getElementById('orderToken').value = token;

// 提交订单请求
const formData = new FormData(document.getElementById('orderForm'));
fetch('/order/submitOrder', {
method: 'POST',
body: formData
})
.then(response => response.text())
.then(result => {
document.getElementById('message').textContent = result;
})
.catch(error => {
document.getElementById('message').textContent = '订单提交失败,请重试';
});
})
.catch(error => {
document.getElementById('message').textContent = '获取Token失败';
});
});

</script>


为了验证功能,我们在代码中增加 Thread.sleep(2000); 来进行阻塞。


请在此添加图片描述


然后快速点击提交表单,可以看到提示表单重复提价的信息


请在此添加图片描述


**技术选型与优化:**通过Redis结合Token机制,我们有效地防止了订单的重复提交,并通过Token的唯一性和时效性保证了订单操作的幂等性。



  • Redis缓存:通过Redis的分布式锁和高并发处理能力,确保系统在高并发情况下仍然可以正常运行,并发订单提交的场景中不会出现Token重复使用问题。

  • UUID:使用UUID生成唯一的Token,保证Token的唯一性和安全性。

  • Token时效性:Token通过设置Redis的TTL(过期时间)来控制有效期,避免无效Token长期占用资源。


总结


防止订单重复提交的关键在于:



  1. Token的唯一性与时效性:确保每次订单提交前都有唯一且有效的Token。

  2. Token的原子性验证与删除:在验证Token的同时删除它,防止同一个Token被多次使用。

  3. Redis的高效存储与分布式锁:通过Redis在高并发环境中提供稳定的锁机制,保证并发提交的准确性。


这套基于Token机制和Redis的解决方案具有简单、高效、可扩展的特点,适合各种高并发场景下防止重复订单提交。


作者:不惑_
来源:juejin.cn/post/7418776600738840628
收起阅读 »

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

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

前言


Code Review 是什么?


Code Review 通常也简称 CR,中文意思就是 代码审查


一般来说 CR只关心代码规范和代码逻辑,不关心业务


但是,如果CR的人是组长,建议有时间还是看下与自己组内相关业务,能避免一些生产事故的发生


作为前端组长做 Code Review 有必要吗?


主要还是看公司业务情况吧,如果前端组长需求不多的情况,是可以做下CR,能避免一些生产事故



  • 锻炼自己的 CR 能力

  • 看看别人的代码哪方面写的更好,学习总结

  • 和同事交流,加深联系

  • 你做了 CR,晋升和面试,不就有东西吹了不是


那要怎么去做Code Review呢?


可以从几个方面入手



  • 项目架构规范

  • 代码编写规范

  • 代码逻辑、代码优化

  • 业务需求


具体要怎么做呢?


传统的做法是PR时查看,对于不合理的地方,打回并在PR中备注原因或优化方案


每隔一段时间,和组员开一个简短的CR分享会,把一些平时CR过程中遇到的问题做下总结


当然,不要直接指出是谁写出的代码有问题,毕竟这不是目的,分享会的目的是交流学习


人工CR需要很大的时间精力,与心智负担


随着 AI 的发展,我们可以借助一些 AI 来帮我们完成CR


接下来,我们来看下,vscode中是怎么借助 AI 工具来 CR


安装插件 CodeGeex
image-20240723191918678.png


新建一个项目


mkdir code-review
cd code-review

创建 test.js 并用 vscode 打开


cd .>test.js
code ./

image-20240723192853589.png


编写下 test.js


function checkStatus() {
if (isLogin()) {
if (isVip()) {
if (isDoubleCheck()) {
done();
} else {
throw new Error("不要重复点击");
}
} else {
throw new Error("不是会员");
}
} else {
throw new Error("未登录");
}
}

这是连续嵌套的判断逻辑,要怎么优化呢?


侧边栏选择这个 AI 插件,选择我们需要CR的代码


输入 codeRiview,回车


动画.gif


我们来看下 AI 给出的建议


image-20240723194729540.png


AI 给出的建议还是很不错的,我们可以通过更多的提示词,优化它给出的修改建议,这里就不过多赘述了


通常我们优化这种类型的代码,基本优化思路也是,前置校验逻辑,正常逻辑后置


除了CodeGeex外,还有一些比较专业的 codeRiview 的 AI 工具


比如:CodeRabbit


那既然都有 AI 工具了,我们还需要自己去CR 吗?


还是有必要的,借助 AI 工具我们可以减少一些阅读大量代码环节,提高效率,减少 CR 的时间


但是仍然需要我们根据 AI 工具的建议进行改进,并且总结,有利于拓宽我们见识,从而写出更优质的代码


具体 CR 实践


判断逻辑优化


1. 深层对象判空


// 深层对象
if (
store.getters &&
store.getters.userInfo &&
store.getters.userInfo.menus
) {}

// 可以使用 可选链进行优化
if (store?.getters?.userInfo?.menus) {}

2. 空函数判断


优化之前


props.onChange && props.onChange(e)

支持 ES11 可选链写法,可这样优化,js 中需要这样,ts 因为有属性校验,可以不需要判断,当然也特殊情况


props?.onChange?.(e)

老项目,不支持 ES11 可以这样写


const NOOP = () => 8
const { onChange = NOOP } = props
onChange(e)

3. 复杂判断逻辑抽离成单独函数


// 复杂判断逻辑
function checkGameStatus() {
if (remaining === 0 ||
(remaining === 1 && remainingPlayers === 1) ||
remainingPlayers === 0) {
quitGame()
}
}

// 复杂判断逻辑抽离成单独函数,更方便阅读
function isGameOver() {
return (
remaining === 0 ||
(remaining === 1 && remainingPlayers === 1) ||
remainingPlayers === 0
);
}

function checkGameStatus() {
if (isGameOver()) {
quitGame();
}
}

4. 判断处理逻辑正确的梳理方式


// 判断逻辑不要嵌套太深
function checkStatus() {
if (isLogin()) {
if (isVip()) {
if (isDoubleCheck()) {
done();
} else {
throw new Error('不要重复点击');
}
} else {
throw new Error('不是会员');
}
} else {
throw new Error('未登录');
}
}

这个是不是很熟悉呀~


没错,这就是使用 AI 工具 CR的代码片段


通常这种,为了处理特殊状况,所实现的判断逻辑,都可以采用 “异常逻辑前置,正常逻辑后置” 的方式进行梳理优化


// 将判断逻辑的异常逻辑提前,将正常逻辑后置
function checkStatus() {
if (!isLogin()) {
throw new Error('未登录');
}

if (!isVip()) {
throw new Error('不是会员');
}

if (!isDoubleCheck()) {
throw new Error('不要重复点击');
}

done();
}

函数传参优化


// 形参有非常多个
const getMyInfo = (
name,
age,
gender,
address,
phone,
email,
) => {
// ...
}

有时,形参有非常多个,这会造成什么问题呢?



  • 传实参是的时候,不仅需要知道传入参数的个数,还得知道传入顺序

  • 有些参数非必传,还要注意添加默认值,且编写的时候只能从形参的后面添加,很不方便

  • 所以啊,那么多的形参,会有很大的心智负担


怎么优化呢?


// 行参封装成对象,对象函数内部解构
const getMyInfo = (options) => {
const { name, age, gender, address, phone, email } = options;
// ...
}

getMyInfo(
{
name: '张三',
age: 18,
gender: '男',
address: '北京',
phone: '123456789',
email: '123456789@qq.com'
}
)

你看这样是不是就清爽了很多了


命名注释优化


1. 避免魔法数字


// 魔法数字
if (state === 1 || state === 2) {
// ...
} else if (state === 3) {
// ...
}

咋一看,这 1、2、3 又是什么意思啊?这是判断啥的?


语义就很不明确,当然,你也可以在旁边写注释


更优雅的做法是,将魔法数字改用常量


这样,其他人一看到常量名大概就知道,判断的是啥了


// 魔法数字改用常量
const UNPUBLISHED = 1;
const PUBLISHED = 2;
const DELETED = 3;

if (state === UNPUBLISHED || state === PUBLISHED) {
// ...
} else if (state === DELETED) {
// ...
}

2. 注释别写只表面意思


注释的作用:提供代码没有提供的额外信息


// 无效注释
let id = 1 // id 赋值为 1

// 有效注释,写业务逻辑 what & why
let id = 1 // 赋值文章 id 为 1

3. 合理利用命名空间缩短属性前缀


// 过长命名前缀
class User {
userName;
userAge;
userPwd;

userLogin() { };
userRegister() { };
}

如果我们把前面的类里面,变量名、函数名前面的 user 去掉


似乎,也一样能理解变量和函数名称所代表的意思


代码却,清爽了不少


// 利用命名空间缩短属性前缀
class User {
name;
age;
pwd;

login() {};
register() {};
}

分支逻辑优化


什么是分支逻辑呢?


使用 if else、switch case ...,这些都是分支逻辑


// switch case
const statusMap = (status: string) => {
switch(status) {
case 'success':
return 'SuccessFully'
case 'fail':
return 'failed'
case 'danger'
return 'dangerous'
case 'info'
return 'information'
case 'text'
return 'texts'
default:
return status
}
}

// if else
const statusMap = (status: string) => {
if(status === 'success') return 'SuccessFully'
else if (status === 'fail') return 'failed'
else if (status === 'danger') return 'dangerous'
else if (status === 'info') return 'information'
else if (status === 'text') return 'texts'
else return status
}

这些处理逻辑,我们可以采用 映射代替分支逻辑


// 使用映射进行优化
const STATUS_MAP = {
'success': 'Successfull',
'fail': 'failed',
'warn': 'warning',
'danger': 'dangerous',
'info': 'information',
'text': 'texts'
}

return STATUS_MAP[status] ?? status

【扩展】


??TypeScript 中的 “空值合并操作符”


当前面的值为 null 或者 undefined 时,取后面的值


对象赋值优化


// 多个对像属性赋值
const setStyle = () => {
content.body.head_style.style.color = 'red'
content.body.head_style.style.background = 'yellow'
content.body.head_style.style.width = '100px'
content.body.head_style.style.height = '300px'
// ...
}

这样一个个赋值太麻烦了,全部放一起赋值不就行了


可能,有些同学就这样写


const setStyle = () => {
content.body.head_style.style = {
color: 'red',
background: 'yellow',
width: '100px',
height: '300px'
}
}

咋一看,好像没问题了呀?那 style 要是有其他属性呢,其他属性不就直接没了吗~


const setStyle = () => {
content.body.head_style.style = {
...content.body.head_style.style
color: 'red',
background: 'yellow',
width: '100px',
height: '300px'
}
}

采用展开运算符,将原属性插入,然后从后面覆盖新属性,这样原属性就不会丢了


隐式耦合优化


// 隐式耦合
function responseInterceptor(response) {
const token = response.headers.get("authorization");
if (token) {
localStorage.setItem('token', token);
}
}

function requestInterceptor(response) {
const token = localStorage.getItem('token');
if (token) {
response.headers.set("authorization", token);
}
}

这个上面两个函数有耦合的地方,但是不太明显


比如这样的情况,有一天,我不想在 responseInterceptor 函数中保存 tokenlocalStorage


function responseInterceptor(response) {
const token = response.headers.get("authorization");
}

function requestInterceptor(response) {
const token = localStorage.getItem('token');
if (token) {
response.headers.set("authorization", token);
}
}

会发生什么?


localStorage.getItem('token')一直拿不到数据,requestInterceptor 这个函数就报废了,没用了


函数 responseInterceptor改动,影响到函数 requestInterceptor 了,隐式耦合了


怎么优化呢?


// 将隐式耦合的常数抽离成常量
const TOKEN_KEY = "authorization";
const TOKEN = 'token';

function responseInterceptor(response) {
const token = response.headers.get(TOKEN_KEY);
if (token) {
localStorage.setItem(TOKEN_KEY, token);
}
}

function requestInterceptor(response) {
const token = localStorage.getItem(TOKEN_KEY);
if (token) {
response.headers.set(TOKEN_KEY, token);
}
}

这样做有什么好处呢?比刚才好在哪里?


还是刚才的例子,我去掉了保存 localStorage.setItem(TOKEN_KEY, token)


我可以根据TOKEN_KEY这个常量来查找还有哪些地方用到了这个 TOKEN_KEY,从而进行修改,就不会出现冗余,或错误


不对啊,那我不用常量,用token也可以查找啊,但你想想 token 这个词是不是得全局查找,其他地方也会出现token


查找起来比较费时间,有时可能还会改错了


用常量的话,全局查找出现重复的概率很小


而且如果你是用 ts 的话,window 下鼠标停在常量上,按 ALT 键就能看到使用到这个常量的地方了,非常方便


小结


codeRiview(代码审查)不仅对个人技能的成长有帮助,也对我们在升职加薪、面试有所裨益


CR 除了传统的方式外,也可以借助 AI 工具,来简化其中流程,提高效率


上述的优化案例,虽然优化方式不同,但是核心思想都是一样,都是为了代码 更简洁、更容易理解、更容易维护


当然了,优化方式还有很多,如果后期遇到了也会继续补充进来


作者:大麦大麦
来源:juejin.cn/post/7394792228215128098
收起阅读 »

简单的 Web 端实时日志实现

web
背景 cron service 在执行定时任务时,需要能够实时查看该任务的执行日志以确保程序正确的工作。为了能够在尽可能短的时间内实现该功能,我们需要一个足够简单的方案。 方案如何选择? 我相信大多数开发者第一个想到的就是 WebSocket ,然后是 HTT...
继续阅读 »

Feb-20-2024 21-40-36.gif


背景


cron service 在执行定时任务时,需要能够实时查看该任务的执行日志以确保程序正确的工作。为了能够在尽可能短的时间内实现该功能,我们需要一个足够简单的方案。


方案如何选择?


我相信大多数开发者第一个想到的就是 WebSocket ,然后是 HTTP/SSE 。WebSocket 在很多情况下是实至名归的万金油选择。但这这里他太重了,对服务端和客户端具有侵入性需要花费额外的时间来集成到项目中。


那么 SSE 呢,为什么不是他?
虽然 SSE 即轻量又实时,但 SSE 无法设置请求头。这导致无法使用缓存和通过请求头设置的 Token。而且作为长链接他还一直占用并发额度。



  • WebSocket:❌

    • 优势:

      • 实时性高

      • 不会占用 HTTP 并发额度



    • 劣势:

      • 复杂度较高,需要在客户端和服务器端都进行特殊的处理

      • 消耗更多的服务器资源。





  • SSE(Server-Sent Events):❌

    • 优势:

      • 基于HTTP协议,不需要在服务端和客户端做额外的处理

      • 实时性高



    • 劣势:

      • 无法设置请求头

      • 占用 HTTP 并发额度





  • HTTP:✅

    • 优势:

      • 简单易用,不需要在服务端和客户端做额外的处理。

      • 支持的功能丰富,如缓存,压缩,认证等功能。



    • 劣势:

      • 实时性差,取决于轮询时间间隔。

      • 每次HTTP请求都需要建立新的连接(仅针对 HTTP/0.x 而言)并可能阻塞同源的其他请求而导致性能问题。12

        • HTTP/1.x 支持持久连接以避免每次请求都重新建立 TCP 连接,但数据通信是串行的。

        • HTTP/2.x 支持持久连接且支持并行的数据通信。









以上列出的优缺点是仅对于本文所讨论的场景(即Web 端的实时日志)而言,这三种数据交互技术在其他场景中的优缺点不在本文讨论范围内。



实现


HTTP 轮询已经是老熟人了,不再做介绍。本文的着重于实时日志的实现及优化。


sequenceDiagram

participant 浏览器

participant 服务器

Note right of 浏览器: 首先,获取日志文件最后 X bytes 的内容

浏览器->>服务器: 最新的日志文件有多大?

服务器->>浏览器: 日志文件大小: Y bytes

浏览器->>服务器: 从 Y - X bytes 处返回内容

服务器->>浏览器: 日志文件大小: Y1 bytes, 日志内容: XXXX



loop 持续轮询获取最新的日志

浏览器->>服务器: 从 Y1 bytes 处返回内容

服务器->>浏览器: 日志文件大小: Y2 bytes, 日志内容: XXXX

end

上方是基本工作原理的流程图。
实现的关键点在于



  • 前端如何知道日志文件当前的大小

  • 服务端如何从指定位置获取日志文件内容


这两点都可以通过 HTTP Header 来解决。Content-Range HTTP 响应头会包含完整文件的大小,而 Range HTTP 请求头可以指示服务器返回指定位置的文件内容。


因此在服务端不需要额外的逻辑,仅通过 Web 端代码就可以实现实时日志功能。


代码实现



篇幅限制,代码不会处理异常情况



首先,根据上述流程图。我们需要获取日志文件的大小。


const res = await fetch(URL, {  
method: "GET",
headers: { Range: "bytes=0-0" }
});

const contentRange = res.headers.get("Content-Range") || "";
const match = /bytes (\d+)-(\d+)\/(\d+)/.exec(contentRange);

// 日志文件大小
const total = Number.parseInt(match[3], 10);


Range: "bytes=0-0" 指定仅获取第一个字节的内容,以免较大的日志文件导致响应时间过长。



我们发起了一个 GET 请求并将 Range 请求头设置为 bytes=0-0 如果服务器能够正确处理 Range 请求头,响应中将包含一个 Content-Range 列其中将包含日志文件的完整大小,可以通过正则解析拿到。


现在我们已经拿到了日志文件的大小并存储在名为 total 的变量中。然后根据 total 获取到最后 10 KB 的日志内容。


const res = await fetch(url, {  
method: "GET",
headers: {
Range: `bytes=${total - 1000 * 10}-`
}
});

const contentRange = res.headers.get("Content-Range") || "";
const match = /bytes (\d+)-(\d+)\/(\d+)/.exec(contentRange);

// 下一次请求的起始位置
const start = Number.parseInt(match[2], 10) + 1;

// 日志内容
const content = await res.text();

现在我们发起了一个 GET 请求并将 Range 请求头设置为 bytes=${total - 1000 * 10}- 以获取最后 10 KB 的日志内容。并且通过正则解析拿到了下一次请求的起始位置。


现在我们已经拿到了日志文件的大小和最后 10 KB 的日志内容。接下来就是持续轮询去获取最新的日志。轮询代码区别仅在于将 Range 标头设置为 bytes=${start}- 以便获取最新的日志。


const res = await fetch(url, {  
method: "GET",
headers: {
Range: `bytes=${start}-`
}
});

const contentRange = res.headers.get("Content-Range") || "";
const match = /bytes (\d+)-(\d+)\/(\d+)/.exec(contentRange);

// 下一次请求的起始位置
start = Number.parseInt(match[2], 10) + 1;

// 日志内容
const content = await res.text();

以上,基本的功能已经实现了。日志内容保存在名为 content 的变量中。


优化


HTTP 轮询常因其高延迟为人诟病,我们可以通过指数退避的方式来尽可能的降低延时且不会显著的增加服务器负担。


指数退避是一种常见的网络重试策略,它会在每次重试时将等待时间乘以一个固定的倍数。这样做的好处是,当网络出现问题时,重试的时间间隔会逐渐增加,直到达到最大值。


一个简单的实现如下:


/**
* 使用指数退避策略获取日志.
*/

export class ExponentialBackoff {
private readonly base: number;
private readonly max: number;
private readonly factor: number;
private retries: number;

/**
* @param base 基础延迟时间 默认 1000ms (1秒)
* @param max 最大延迟时间 默认 60000ms (1分钟)
* @param factor 延迟时间增长因子 默认 2
*/

constructor(base: number = 1000, max: number = 60000, factor: number = 2) {
this.base = base;
this.max = max;
this.factor = factor;
this.retries = 0;
}

/**
* 获取下一次重试的延迟时间.
*/

next() {
const delay = Math.min(this.base * Math.pow(this.factor, this.retries), this.max);
this.retries++;
return delay;
}

/**
* 重置重试次数.
*/

reset() {
this.retries = 0;
}
}

值得一提的是带有 Range 标头的请求成功时会返回 206 Partial Content 状态码。而在请求的范围超出文件大小时会返回 416 Range Not Satisfiable 状态码。我们可以通过这两个状态码来判断请求是否成功。


成功时调用 reset 方法重置重试次数,失败时调用 next 方法获取下一次重试的延迟时间。


总结


即使再优秀的方案也不是银弹,真正合适的方案需要考虑的不仅仅是技术本身,还有业务场景,团队技术栈,团队技术水平等等。在选择技术方案时,我们需要权衡各种因素,而不是盲目的选择最流行的技术。


当我们责备现有的技术方案为何如此糟糕时,或许在那个时间点上,这个方案是最合适的。


Pasted image 20240220213326.png


感谢以下网友的帮助和建议:齐洛格德


Footnotes




作者:siaikin
来源:juejin.cn/post/7337519776796295177
收起阅读 »

方寸之间窥万象——这样的Tooltip,你会开发吗?

web
序言 提示信息(tooltip)是一种常见的 GUI 元素。在可视化领域,tooltip 通常指用户将鼠标悬停在图元上或者图表区域时弹出的明细数据信息框。如果是桌面环境,通常会在用户将指针悬停在元素上而不单击它时显示 tooltip;如果是移动环境,通常会在长...
继续阅读 »

序言


提示信息(tooltip)是一种常见的 GUI 元素。在可视化领域,tooltip 通常指用户将鼠标悬停在图元上或者图表区域时弹出的明细数据信息框。如果是桌面环境,通常会在用户将指针悬停在元素上而不单击它时显示 tooltip;如果是移动环境,通常会在长按(即点击并按住)元素时显示 tooltip。


这样一个小小的组件,却可以十分有效地丰富图表的数据展现能力和图表交互效果,同时在实际业务领域的用途也非常广泛。


近些年来,业界主要图表库(如ECharts、G2等)都提供了 tooltip 的配置能力和默认渲染能力,以达到开箱即用的效果。VChart 更不例外,提供了更加灵活的 tooltip 展示与配置方案。


通过使用 VChart,你既可以显示图表中任何系列的图元所携带的数据信息(mark tooltip):



也可以显示某个特定维度项下的所有图元的数据信息(dimension tooltip):



乃至可以灵活地自定义 tooltip,甚至在其中插入子图表,拓展交互的边界:



本文将通过一些实战案例,详细讲述 VChart 提示信息的重点用法、自定义方式以及设计细节。


示例一:可触及的 tooltip,与 Amazon 的安全三角形


为了不对用户的鼠标交互进行干扰,VChart 的 tooltip 默认不会响应鼠标事件。但是在某些情况,用户却希望鼠标可以移到 tooltip 中进行一些额外的交互行为,比如点击 tooltip 中的按钮和链接,或者选取并复制一些数据。


为了满足这类需求,tooltip 支持在 spec 中配置 enterable 属性。如果不配置或者配置 enterable: false,默认效果是这样的,鼠标无法移到 tooltip 元素内:



而如果配置 enterable: true,效果如以下截图所示:



图表简化版 spec 为:


const spec = {
type: 'waterfall',
data: [], // 数据略
legends: { visible: true, orient: 'bottom' },
xField: 'x',
yField: 'y',
seriesField: 'type',
total: {
type: 'field',
tagField: 'total'
},
title: {
visible: true,
text: 'Chinese quarterly GDP in 2022'
},
tooltip: {
enterable: true // TOOLTIP SPEC
}
};

const vchart = new VChart(spec, { dom: CONTAINER_ID });
vchart.renderSync();

简单对比两个 tooltip 的效果,可以发现后者在鼠标靠近 tooltip 时,tooltip 便适时地停住了。


这个小小的交互细节里却有些文章,灵感来源直接来自 Amazon 的官网实现。


这个例子也许已经为人熟知,我们简单回顾一下。这首先要从普通网站的下拉菜单开始讲起:



在一个设计欠佳的菜单组件里(如 bootstrap),鼠标从一级菜单移入二级菜单往往是很困难的,很容易触发二级菜单的隐藏策略,从而变成一场无聊的打地鼠游戏。


但是 Amazon 早期官网的菜单,由于用户使用频率高,根本无法接受这样的体验。于是他们完美地解决了这个问题,并成为一个交互优化的经典案例。


其思路的核心,便是检测鼠标移动的方向。如果鼠标移动到下图的蓝色三角形中,当前显示的子菜单将继续打开一小会儿:



在鼠标的每个位置,你可以想象鼠标当前位置与下拉菜单的右上角和右下角之间形成一个三角形。如果下一个鼠标位置在该三角形中,说明用户可能正在将鼠标移向当前显示的子菜单。Amazon 利用这一点实现了很好的效果。只要鼠标停留在该蓝色三角形中,当前子菜单就会保持打开。如果鼠标移出该三角形,他们会立即切换子菜单,使其感觉非常敏捷。


整体效果类似于下图所示:



正所谓,上帝在细节中(God is in the details)。从这个交互优化里,我们看到的不仅是一个精妙的算法,而是一个科技巨头对于产品和用户体验的态度。Amazon 的数百亿市值有多少是从这些很小很小,但是明显很用心的产品细节中积累起来的呢?



VChart 的 tooltip 也一样,着重参考了 Amazon 的交互优化。如果配置 enterable: true,在每个时刻,都会存在一个这样的“安全三角形”,三个顶点分别是鼠标光标以及 tooltip 的两个端点,取面积最大的三角形:



如果鼠标在下一刻滑到这个三角形区域中, tooltip 便为鼠标“停留一会儿”,直到鼠标移到 tooltip 区域内。



但是在鼠标移到 tooltip 区域之前,tooltip 并不会永远停下来等待鼠标。如果鼠标过于缓慢地靠近 tooltip,tooltip 还是会离开的(变成一场失败、却又在意料之中的奔赴)。这样便可以同时保证用户鼠标有足够的行动自由度。以下示例特地将鼠标移动速度放慢,便可以实现既进入三角形区域,又不会被 tooltip “挡路”:



作为对比,ECharts 的 tooltip 虽然同样支持 enterable 属性,但是 ECharts 主要通过简单的 tooltip 缓动来支持鼠标移入,鼠标仍需要不停地“追逐” tooltip 才能移至其中,灵活性便打了折扣。以下为 ECharts 的效果:



示例二:灵活的 pattern,内容与样式的自由配置


为了尽最大可能满足更多业务方的需求,VChart 的 tooltip 支持比较灵活的内容和样式配置。下文将以官网 demo(http://www.visactor.io/vchart/demo…



在这个图表中,用户配置了一条 y=10000 的标注线。同时要求在 dimension tooltip 中实现:



  • 数据项从大到小排序;

  • 比标注线高的数据项标红(条件格式);

  • 在 tooltip 内容的最后一行加上标注线所代表的数据。


同时,这个 tooltip 的位置还拥有以下特征:



  • dimension tooltip 的位置固定在光标上方;

  • mark tooltip 的位置固定在数据项下方。


如以下动图所示:



这个示例实际上代表了很多不同类型的业务需求。下面拆解来看一下:


基本 tooltip 内容配置


首先,剥去自定义内容和样式的部分,这个图表的最简 spec 和基本 tooltip 配置如下:


const markLineValue = 10000;
const spec = {
type: 'line',
data: {
values: [
{ type: 'Nail polish', country: 'Africa', value: 4229 },
// 其他数据略
]
},
stack: false,
xField: 'type',
yField: 'value',
seriesField: 'country',
legends: [{ visible: true, position: 'middle', orient: 'bottom' }],
markLine: [
{
y: markLineValue,
endSymbol: { visible: false },
line: { style: { /* 样式配置略 */ }}
}
],
tooltip: { // TOOLTIP SPEC
mark: {
title: {
value: datum => datum.type
},
content: [
{
key: datum => datum.country,
value: datum => datum.value
}
]
},
dimension: {
title: {
value: datum => datum.type
},
content: [
{
key: datum => datum.country,
value: datum => datum.value
}
]
}
}
};

const vchart = new VChart(spec, { dom: CONTAINER_ID });
vchart.renderSync();

显示效果如下:



观察 spec 不难发现,mark tooltip 和 dimension tooltip 分别用回调方式配置了 tooltip 的显示内容。其中:



  • title.value 显示的是数据项中对应于 xField 的内容;

  • content.key 显示的是数据项中对应于 seriesField(也是区分出图例项的 field)的内容;

  • content.value 显示的是数据项中对应于 yField 的内容。


回调是 tooltip 内容的基本配置方式,用户可以在 title 和 content 中自由配置回调函数来实现数据绑定和字符串的格式化。


Tooltip 内容的排序、增删、条件格式


我们再来看一下 dimension tooltip 的 spec:


{
tooltip: { // TOOLTIP SPEC
mark: { /* ...略 */ },
dimension: {
title: {
value: datum => datum.type
},
content: [
{
key: datum => datum.country,
value: datum => datum.value
}
]
}
}
}

不难发现,content 配置的数组只包含 1 个对象,但是上图显示出来却有 4 行内容。为什么呢?


其实在 dimension tooltip 中发生了和折线图元类似的 data join 过程:由于在数据中,seriesField 划分出了 4 个数据组(图例项有 4 个),因此在经过笛卡尔积后,真实 tooltip 内容行数为 content 数组成员数量乘以 4。在数据组装过程中,每个数据组都要依次走一遍 content 数组成员里的回调。


我们把 spec 中的 tooltip 内容配置称为 TooltipPattern,tooltip 所需数据称为 TooltipData,最终的 tooltip 结构称为 TooltipActual。数据组装过程可以表示为:


MakeTooltip(TooltipPattern,TooltipData) = TooltipActualMakeTooltip(TooltipPattern, TooltipData) = TooltipActual


在本例中,经过这个过程,TooltipPattern 中的回调在 TooltipActual 中消失(回调已被执行 4 次),且由 1 行变成了 4 行。


这个过程完整的执行流程如下:



那么回到示例中的用户需求,用户希望将 tooltip 内容行由大到小排序。那么这个步骤自然要在 TooltipActual 生成之后执行,也就是上图中的 “updateTooltipContent” 过程。


Tooltip spec 中支持配置 updateContent 回调来对 TooltipActual 的 content 部分进行操作。排序可以这样写:


{
tooltip: { // TOOLTIP SPEC
mark: { /* ...略 */ },
dimension: {
title: {
value: datum => datum.type
},
content: [
{
key: datum => datum.country,
value: datum => datum.value
}
],
updateContent: prev => {
// 排序
prev.sort((a, b) => b.value - a.value);
}
}
}
}

updateContent 回调的第一个参数为已经计算好的 TooltipActual。加上回调以后,排序生效:



在 tooltip 中实现条件格式以及新增一行也是一样的方法,可以直接在 updateContent 回调中处理:


{
updateContent: prev => {
// 排序
prev.sort((a, b) => b.value - a.value);
// 条件格式:比标注线高的数据项标红
prev.forEach(item => {
if (item.value >= markLineValue) {
item.valueStyle = {
fill: 'red'
};
}
});
// 新增一行
prev.push({
key: 'Mark Line',
value: markLineValue,
keyStyle: { fill: 'orange' },
valueStyle: { fill: 'orange' },
// 自定义 shape 的 svg path
shapeType: 'M44.3,22.1H25.6V3.3h18.8V22.1z M76.8,3.3H58v18.8h18.8V3.3z M99.8,3.3h-9.4v18.8h9.4V3.3z M12.9,3.3H3.5v18.8h9.4V3.3z',
shapeColor: 'orange',
hasShape: true
});
}
}

调试 spec,回调生效,最后效果如下:



Tooltip 样式和位置


VChart tooltip 支持将 tooltip 固定于某图元附近或者鼠标光标附近。在本例中,mark tooltip 固定于图元的下方,而 dimension tooltip 固定于鼠标光标的上方,可以这样配置:


{
tooltip: { // TOOLTIP SPEC
mark: {
// 其他配置略
position: 'bottom' // 显示在下方
positionAt: 'mark' // 固定在图元附近,由于这是默认值,这行可以删掉
},
dimension: {
// 其他配置略
position: 'top', // 显示在上方
positionAt: 'pointer' // 固定在鼠标光标附近
}
}
}

而样式配置可以在 tooltip spec 上的 style 配置项下进行自定义。style 支持配置 tooltip 组件各个组成部分的统一样式,详细配置项可参考官网文档(http://www.visactor.io/vchart/opti…


最后效果如下,完整 spec 可见官网 demo(http://www.visactor.io/vchart/demo…



示例三:锦上添花,可按需修改的 tooltip dom 树


VChart 的 tooltip 共支持两种渲染模式:



  • Dom 渲染,适用于桌面或移动端浏览器环境;

  • Canvas 渲染,适用于移动端小程序、node 环境等非浏览器环境。


对于 dom 版本的 tooltip,为了更好的支持业务方的自定义需求,VChart 开放了对 tooltip dom 树的修改接口。下文将以官网 demo(http://www.visactor.io/vchart/demo…



在这个示例中,用户要求在 tooltip 的底部增加一个超链接,用户点击链接后,便可以自动跳转到 Google,对 tooltip 标题进行进一步搜索。这个示例要求两个能力:



  • 示例一介绍的 enterable 能力,开启后鼠标会被允许滑入 tooltip 区域,这是在 tooltip 中实现交互的前提;

  • 在默认 tooltip 上绘制自定义的 dom 元素。


为了实现第二个能力,tooltip 支持了回调 updateElement,这个回调配在 tooltip spec 顶层。这个示例的 tooltip 配置如下:


{
tooltip: { // TOOLTIP SPEC
enterable: true,
updateElement: (el, actualTooltip, params) => {
// 自定义元素只加在 dimension tooltip 上
if (actualTooltip.activeType === 'dimension') {
const { changePositionOnly, dimensionInfo } = params;
// 判断本次 tooltip 显示是否仅改变了位置,如果是的话退出
if (changePositionOnly) { return; }
// 改变默认 tooltip dom 的宽高策略
el.style.width = 'auto';
el.style.height = 'auto';
el.style.minHeight = 'auto';
el.getElementsByClassName('value-box')[0].style.flex = '1';
for (const valueLabel of el.getElementsByClassName('value')) {
valueLabel.style.maxWidth = 'none';
}
// 删除上次执行回调添加的自定义元素
if (el.lastElementChild?.id === 'button-container') {
el.lastElementChild.remove();
}
// 添加新的自定义元素
const div = document.createElement('div');
div.id = 'button-container';
div.style.margin = '10px -10px 0px';
div.style.padding = '10px 0px 0px';
div.style.borderTop = '1px solid #cccccc';
div.style.textAlign = 'center';
div.innerHTML = `href="https://www.google.com/search?q=${dimensionInfo[0]?.value}"
style="text-decoration: none"
target="_blank"
>Search with Google
`;
el.appendChild(div);
} else {
// 对于 mark tooltip,删除上次执行回调添加的自定义元素
if (el.lastElementChild?.id === 'button-container') {
el.lastElementChild.remove();
}
}
}
}
}

updateElement在每次 tooltip 被激活或者更新时触发,在触发时,TooltipActual 已经计算完毕,且 dom 节点也已经准备好。回调的第一个参数便是本次将要显示的 tooltip dom 根节点。目前不支持替换该节点,只支持对该节点以及其孩子进行修改。


这个配置的设计最大限度地复用了 VChart tooltip 的内置逻辑,同时提供了足够自由的自定义功能。你可以随心所欲地定制 tooltip 显示内容,并且复用任何你没有覆盖的逻辑。


比如,你可以对 tooltip 的大小进行重新定义,而不用关心窗口边界的躲避策略是否会出问题。事实上 VChart 会自动把 tooltip 定位逻辑复用在修改过的 dom 上:



这个回调还可以进一步封装,比如在 react-vchart 中将用户侧的 react 组件插入 tooltip。目前这个封装主要由业务侧自主进行,后续 VChart 也有计划提供官方支持。


示例四:完全自定义,由业务托管 tooltip 渲染


若要更进一步,VChart tooltip 最高级别的自定义,便是让 VChart 完全将 tooltip 渲染交给用户。有以下两种方式可以选择:



  • 用户自定义 tooltip handler

  • 用户使默认 tooltip 失效,监听 tooltip 事件


再结合示例二、示例三的铺垫,便可以带出整个 tooltip 模块的设计架构。熟悉了架构便更容易了解每条渲染路径以及各个层级的关系。



由上图可见,示例三对应的是 “Custom DOM Render” 的自定义,示例二对应了 “Custom TooltipActual” 部分的自定义。而示例四,便是对应整个 “Tooltip Events” 以及 “Custom TooltipHandler” 的自定义。


由于上图中,“Tooltip Events” 和 “Custom TooltipHandler” 纵跨了多个层级,因此它覆盖的默认逻辑是最多的,体现在:



  • 当给图表设置了自定义 tooltip handler 后,内置的 tooltip 将不再起作用。

  • VChart 不感知、不托管自定义 tooltip 的渲染,需要自行实现 tooltip 渲染,包括处理原始数据、tooltip 内容设计,以及根据项目环境创建组件并设置样式。

  • 当图表删除时会调用当前 tooltip handler 的release函数,需要自行实现删除。


目前,火山引擎 DataWind 正是使用自定义 tooltip handler 的方式实现了自己的图表 tooltip。DataWind 支持用户对tooltip 进行富文本渲染,甚至支持了 tooltip 内渲染图表的能力。



另外,也可以参考官网示例(http://www.visactor.io/vchart/demo…



自定义 tooltip handler 的核心是调用 VChart 实例方法 setTooltipHandler,部分示例代码如下:


vchart.setTooltipHandler({
showTooltip: (activeType, tooltipData, params) => {
const tooltip = document.getElementById('tooltip');
tooltip.style.left = params.event.x + 'px';
tooltip.style.top = params.event.y + 'px';
let data = [];
if (activeType === 'dimension') {
data = tooltipData[0]?.data[0]?.datum ?? [];
} else if (activeType === 'mark') {
data = tooltipData[0]?.datum ?? [];
}
tooltipChart.updateData(
'tooltipData',
data.map(({ type, value, month }) => ({ type, value, month }))
);
tooltip.style.visibility = 'visible';
},
hideTooltip: () => {
const tooltip = document.getElementById('tooltip');
tooltip.style.visibility = 'hidden';
},
release: () => {
tooltipChart.release();
const tooltip = document.getElementById('tooltip');
tooltip.remove();
}
});

其他特性一览


VChart tooltip 包含一些其他的高级特性,下文将简要介绍。


在任意轴上触发 dimension tooltip


Dimension tooltip 一般最适合用于离散轴,ECharts 同时支持连续轴上的 dimension tooltip(axis tooltip)。而 VChart 支持了在连续轴、时间轴乃至在一个图表中的任意一个轴上触发 dimension tooltip。


以下示例展示了 dimension tooltip 在连续轴(时间轴)上汇总离散数据的能力(这个 case 和一般的 dimension tooltip 刚好相反):



一般的 dimension tooltip 会在离散轴(纵轴)触发 tooltip,汇总连续数据(对应于时间轴)。而 VChart 同时支持这两种方式的 tooltip。


Demo 地址:http://www.visactor.io/vchart/demo…


长内容支持:换行和局部滚动


过长的内容在 tooltip 上一般是 bad case。但是为了使长内容的浏览体验更好,VChart tooltip 可以配置多行文本以及内容区域局部滚动。如以下示例:



局部滚动 Demo 地址:http://www.visactor.io/vchart/demo…


多行文本配置项:http://www.visactor.io/vchart/opti…


结语


Tooltip 在提升用户浏览图表的体验中扮演着重要的角色。本文介绍了 VChart tooltip 的基本使用方法、技术设计以及多层面的自定义方案。然而为了保证行文清晰,VChart tooltip 还有一些其他的用法细节本文没有涉及,想了解更多可以查阅官网 demo 以及文档。


然而需要提醒的是,虽然 tooltip 能够有效传递数据与信息、以及增加图表的互动能力,但过分依赖它们可能会导致用户体验下降。合理地利用 tooltip,让它们在需要时出现而不干扰用户的主要任务,是设计和开发中应保持的平衡。


希望本文能为你在配置 VChart tooltip 时提供有用的指导。愿你在图表中创造更加直观、轻松且愉快的用户体验时,VChart 能成为你强大的伙伴。




Feb-22-2024 10-11-30.gif


github:github.com/VisActor/VC…


相关参考:



作者:玄魂
来源:juejin.cn/post/7337963242416422924
收起阅读 »

寒门子弟想跨越阶层有多难

快要高考了,最近身边发生了不少事,感触颇深,我想记录一下。大家都知道高考是唯一公平竞争的机会,也是寒门贵子唯一一次想翻身的机会。但是,每个人情况和起点不一致,考试是公平的,但出身是不公平的,出身在河南落后的农村的小镇做题家和出身在上海的繁华大都市的国际学校的先...
继续阅读 »

快要高考了,最近身边发生了不少事,感触颇深,我想记录一下。大家都知道高考是唯一公平竞争的机会,也是寒门贵子唯一一次想翻身的机会。但是,每个人情况和起点不一致,考试是公平的,但出身是不公平的,出身在河南落后的农村的小镇做题家和出身在上海的繁华大都市的国际学校的先进教育资源一样吗?答案可想而知。


身边有个亲人出身河南农村,这几天刚大学本科毕业,通过校招拿到了某物流领域独角兽公司的offer,base杭州,待遇是6k左右底薪,思考再三,他弟弟选择拒掉了offer,原因是,觉得靠自己这个收入全年无休也买不起杭州的房子,还要跟对象常年异地(对象在老家教师),于是选择去卷老家的编制岗,至少不用考虑买房,还能顾上了家庭,但是他的父母却表示不理解:说一辈子都没出息了,作为农民出身辛苦一辈子供出来的大学生,还要继续留在那个贫穷落后的小地方。为此他感到很苦恼,如何做才能不负如来不负卿!


同样,他说,比起其他同样出身在农村的孩子已经好很多了,至少有个月薪三千的稳定工作,很多农村的孩子都是提前辍学,进厂打螺丝或者失业待家,甚至娶不起农村媳妇,只能通过玩游戏,对抗父母证明自己的存在感,不然要被周围人的流言蜚语的唾沫星子淹死。提到这里甚至还有一丝欣慰。


他说,你不懂,像我这样出身农村的底层孩子,大多数在高考前就已经被淘汰掉了,因为太多穷苦老百姓,觉得读书没有直接去社会上进厂打工来的实在,孩子叛逆不想上学,也不会管太多,更不会觉得人生被毁掉了,相反,早点出去打过工挣钱才是农村人的常态,就这样,穷人的孩子干着穷苦的工作,攒点钱早点结婚生子,一代又一代,恶性循环,也不会觉得有任何不对,因为身边的环境和周围的人大多命运也都是这样。


形成鲜明对比的是,另一个同学,出身在江浙沪独生子,他说,从小只要学习不好,父母就感觉天塌了,因为他父母是很早一批的大学生,虽然他父母也是农村人的孩子,但是是最早一批的大学生,吃到了知识改变命运的红利,甚至不惜任何代价,帮他找资源,托关系,请到了他的老师给他在家补课开小灶,他说他学习一直不好,但是就是这么一路走过来的,甚至他父亲还帮他联系到了211院校的校长,讲到这里,大家意识到差距了吧。他说他学习很差,但被父母一路逼下去读到了研究生。。。毕业后家人关系托底去了某国有五大行,待遇不用多说大家也都懂。


同样,出身农村,学习很差就要回农村继续种地,娶个媳妇生个娃,干着廉价的劳动力,甚至都不知道好日子好工作是什么意思,因为资源和眼界有限,接触不到,也没见过,也无法给孩子形容出来,更没法用言语具体说服叛逆期的孩子。只好为了多挣钱去大城市,离开家人,最后挣点钱回到家了,孩子不理解,继续叛逆,等到了懂事的年纪,已经没有机会翻身了。这里声明:并不是我歧视农村,农村环境好,农村人淳朴,农村土地辽阔,除了经济医疗交通教育不发达,农家乐也非常有趣。


mmexport1717496259324.jpg


别的行业咱不敢说,至少计算机行业对比传统行业,给了穷人一次走向小康的机会,但这种机会也不会一直都有,互联网寒冬的背景下,即使选择了计算机专业,大多数底层出身的孩子也无法真正的跨越阶层,因为那个时代的红利,已经褪去了。。。


最近看到了一条新闻,说曾经在衡水中学的高考状元,出身农村,声称自己是“乡下的土猪拱了城里的白菜”,报考了浙大计算机专业,三年过后了,这位高考状元却迷茫了,因为他对计算机专业并没有兴趣,当初仅仅因为觉得这个行业高薪才报名的,但是行业在变化,在这个时代下,计算机行业已经发展迅速,没有了昨日的如日中天,相反,他的眼里没有光,他厌倦了这种一天都坐在电脑面前的工作,但是未来何去何从,他依然迷茫,他只知道学习高考,却没有好好规划自己的人生,没有认真思考自己的定位,不懂得人生价值,只知道脱离贫苦。这点也许是大多数底层出身孩子的迷茫吧!


回过神来,今年高考,如果再给你一次机会,你还会像曾经一样,没有把高考当成唯一一次公平翻身的机会吗?寒门贵子想跨越阶层到底有多难?大多数人的机会,也许就这么一次吧。


作者:为了WLB努力
来源:juejin.cn/post/7376407925646770239
收起阅读 »

总算体会到jsx写法为啥灵活

web
前言 大家好,我是你不会困,写代码就不会困,今天分享的是总算体会到jsx写法为啥灵活 什么是jsx写法? 当谈到JavaScript中的JSX写法时,人们往往会想到React和Vue这样的流行前端框架。JSX作为一种在JavaScript中编写类似于HTML的...
继续阅读 »

前言


大家好,我是你不会困,写代码就不会困,今天分享的是总算体会到jsx写法为啥灵活


什么是jsx写法?


当谈到JavaScript中的JSX写法时,人们往往会想到React和Vue这样的流行前端框架。JSX作为一种在JavaScript中编写类似于HTML的语法,为前端开发者提供了更灵活和直观的方式来构建用户界面。


JSX的灵活性体现在多个方面。首先,JSX允许开发者在JavaScript中嵌入HTML标记,使得代码更易读和维护。通过使用JSX,开发者可以在同一个文件中编写JavaScript逻辑和界面布局,而无需频繁切换不同的文件。这种混合编程风格提高了开发效率,同时也方便了代码的组织和调试。


其次,JSX支持在标记中使用JavaScript表达式,这使得动态生成界面变得更加简单。开发者可以在JSX中直接使用JavaScript变量、函数调用和逻辑控制语句,从而动态地渲染页面内容。这种灵活性使得开发者能够根据不同的数据状态和条件来动态展示内容,提升了用户体验。


另外,JSX还支持在标记中使用循环和条件语句,比如map函数和条件渲染,从而实现列表展示、条件展示等常见的UI需求。这种功能使得开发者可以更方便地处理复杂的UI逻辑,同时简化了代码的编写和维护。


此外,JSX的组件化特性也为前端开发带来了很多好处。通过将UI拆分成独立的组件,开发者可以更好地组织和管理代码,提高代码的重用性和可维护性。JSX中的组件可以嵌套使用,形成复杂的UI结构,同时每个组件可以单独管理自己的状态和逻辑,使得代码更加清晰和可扩展。


今天在开发的时候发现,这两个即可开启总计列


show-summary
:summary-method="getSummaries"

但是产品的需求比较麻烦,需要渲染多行,查了相关的文档,好像没有这种渲染的demo,翻看项目的代码,有一部分代码的实现比较巧妙,使用的是jsx写法,然后就尝试着去实现


要在vue里面使用jsx写法,在script标签使用<script lang="jsx">,即可使用


getSummaries(param) {
const { columns } = param
const sums = []
const nullHtml = '-'
columns.forEach((column, index) => {
if (index === 0) {
sums[index] = '总计'
return
}
if (this.totalSum.summaryReceivableComparisons) {
sums[index] = (
<div>
{this.totalSum.summaryReceivableComparisons.map((item) => (
<div class='cell-item' key={item.invoiceCurrency}>
<p>
{this.formatValue(
item[column.property],
column.property.includes('Ratio')
? 'percentage'
: 'thousandth'
)}
</p>
</div>
))}
</div>
)
} else {
sums[index] = nullHtml
return
}
})
return sums
},

上面的代码使用了map来遍历,将对应的html返回,el-table的总计列即可生效,来应对不同的需求


总结


总的来说,JSX作为JavaScript中的一种扩展语法,为前端开发带来了更灵活、直观和高效的开发体验。通过使用JSX,开发者可以更轻松地构建交互丰富、动态变化的用户界面,同时提高了代码的可读性和可维护性。JSX的灵活性和表现力使其成为现代前端开发中不可或缺的一部分。


作者:你不会困
来源:juejin.cn/post/7410672790020800548
收起阅读 »

JDK23,带来了哪些新功能?

前言 2024年9月17日,Java开发者们迎来了期待已久的JDK23版本。 下载地址:jdk.java.net/23/ 文档地址:jdk.java.net/23/release-… 为 JDK 21 之后的第一个非 LTS 版本,最终的 12 个 JEP ...
继续阅读 »

前言


2024年9月17日,Java开发者们迎来了期待已久的JDK23版本。


下载地址:jdk.java.net/23/


文档地址:jdk.java.net/23/release-…



为 JDK 21 之后的第一个非 LTS 版本,最终的 12 个 JEP 特性包括:


JEP 455:模式、instanceof 和 switch 中的原始类型(Primitive Types in Patterns, instanceof, and switch,预览)


JEP 466:类文件 API(Class-File API,第二轮预览)


JEP 467:Markdown 文档注释(Markdown Documentation Comments)


JEP 469:Vector API(第八轮孵化)


JEP 471:废弃 sun.misc.Unsafe 中的内存访问方法以便于将其移除(Deprecate the Memory-Access Methods in sun.misc.Unsafe for Removal)


JEP 473:流收集器(Stream Gatherers,第二轮预览)


JEP 474:ZGC:默认的分代模式(ZGC: Generational Mode by Default)


JEP 476:模块导入声明(Module Import Declarations,预览)


JEP 477:隐式声明的类和实例主方法(Implicitly Declared Classes and Instance Main Methods,第三轮预览)


JEP 480:结构化并发(Structured Concurrency,第三轮预览)


JEP 481:作用域值(Scoped Values,第三轮预览)


JEP 482:灵活的构造函数体(: Flexible Constructor Bodies,第二轮预览)


基本上每次JDK的升级,在带来一些新功能的同时,也会提升一些性能。


一些优秀的语法,也能提升开发效率。


除了编程语言升级能提升开发效率之外,一些好的开发工具或者设备,也可以。


2 提升开发效率


显示器可以提升开发效率?


答:是真的。


2.1 屏幕尺寸


在之后的一段时间内,我尝试过一些不同品牌和型号的外接显示器。


常见的显示器的屏幕比是16:9。


而我现在正在用的明基RD280U显示器的屏幕比是3:2。

跟我的笔记本电脑屏幕相比,高度是笔记本电脑的两倍了。


我第一次使用时,就明显感觉到,明基RD240Q显示器的屏幕更高一些,一屏可以多看十几行代码。


在开发过程中,每次滚动屏幕,都可以多看几行代码,如果次数多了,可以多看很多行代码,真的可以提高开发效率。


2.2 专业编程模式


我后来才知道明基RD280U是一个专业的编程显示器,专门给程序员设计的。


屏幕正下方的这个按键,可以调整编程模式,可以优化IDE上代码的显示效果,让代码更加清晰:


2.3 背光灯


我们之前在晚上编程的时候,经常需要打开台灯,才能让屏幕看到更清楚。


为了解决这个问题,明基RD280U提供了Moonhalo背光灯的功能,下面这张图是我在关灯的情况下拍摄的:

可以看到屏幕有黄色的背景灯光。


下面的这张灯光图更直观:

可以让你沉浸在开发中,不被打扰。


3 全方位呵护


明基RD280U显示器使用了莱茵认证护眼技术,实现了:低蓝光、无屏闪的效果。


3.1 护眼模式


在夜间开发,可以切换夜间保护模式:


如果切换成自动模式,当外面环境变亮时,屏幕会自动变暗。当外面环境变暗时,屏幕会自动变暗。


保护我们的眼睛。


智慧蓝光模式是为了减少蓝光对眼睛的刺激,提供更舒适的视觉体验。

我们可以调节让自己眼睛感到舒服的蓝光。


3.2 抗反射面板


当我们的屏幕出现其他的灯光直射时,笔记本电脑的效果是这样的:

代码完全看不清楚。


而明基RD280U显示器,即使遇到强光也能看清代码。


这是我非常喜欢的设计。


4 软件协同


明基RD280U显示器为了方便我们操作,还提供了一个驱动软件:Display Pilot2。


里面包含了画面切换,快速搜索,桌面分区和键盘快速切换功能。


我们可以在电脑上直接控制显示器:

文章前面介绍的这些功能,都可以直接在电脑上通过Display Pilot2进行控制。


比如开启显示器的Moonhalo背光灯。


新增的flow功能可以设置特定时间场景下的一些显示器的参数。


5 总结


本文主要介绍了JDK23的12项新特性,涵盖了语言预览、API增强、性能优化等多个方面,可能会对开发者的工作流程和编程习惯产生深远的影响。


同时也介绍了我正在使用的明基RD280U显示器的一些优秀的功能,比如:屏幕尺寸更大、专业编程模式、Moonhalo背光灯、护眼功能(夜间防护功能、智慧蓝光)、抗反射面板、display pilot2功能,能够提升开发效率和保护我们的眼睛。


作者:苏三说技术
来源:juejin.cn/post/7418072992838500362
收起阅读 »

如何组装一台高性价比的电脑

前言最近想配置一台电脑,由于在这方面是小白,就花了一部分时间以及精力在这方面,相信你看完,也能轻轻松松装电脑。目标高性价比、小钱办大事电脑配置基础知识组装一台台式机需要考虑多个组件,以下是一份基本的装机配置清单,包括了主要硬件和一些可选配件我把他们划分为必须和...
继续阅读 »

1718195402741.png

前言

最近想配置一台电脑,由于在这方面是小白,就花了一部分时间以及精力在这方面,相信你看完,也能轻轻松松装电脑。

目标

高性价比、小钱办大事

电脑配置基础知识

组装一台台式机需要考虑多个组件,以下是一份基本的装机配置清单,包括了主要硬件和一些可选配件

我把他们划分为必须和非必须(可理解为表单的必填或者非必填)

有了必须项目的配置就可以组装出一台电脑,非必须项目属于个人需求(可理解为个性化定制)

必须项

  1. 处理器(CPU) :

    • 选择适合需求的处理器,如Intel Core系列或AMD Ryzen系列。
  2. 显卡(GPU) :

    • 集成显卡或独立显卡,如NVIDIA GeForce或AMD Radeon系列。
  3. 主板(Motherboard) :

    • 根据CPU选择相应插槽类型的主板,如ATX、Micro-ATX或Mini-ITX规格。
  4. 内存(RAM) :

    • 至少8GB DDR4内存,更高需求可以选择16GB或32GB。
  5. 固态硬盘(SSD) :

    • 用于安装操作系统和常用软件,256GB或更大容量。
  6. 散热器(Cooler) :

    • CPU散热器,盒装CPU可能附带散热器。
  7. 电源供应器(PSU) :

    • 根据系统需求选择合适的功率,如500W、600W等,并确保有80 PLUS认证。
  8. 机箱(Case) :

    • 根据主板规格和个人喜好选择机箱。

机箱严格来讲属于非必须,因为你用纸箱子也能行,但是对于小白来说还是整个便宜的比较好

  1. 外围设备

    • 显示器、键盘、鼠标。
  2. 音频设备

    • 耳机、扬声器。

非必须项

  1. 机械硬盘(HDD, 可选) :

    • 用于数据存储,1TB或更大容量。
  2. 机箱风扇(可选) :

    • 用于改善机箱内部空气流通。
  3. 光驱(可选) :

    • 如有需要,可以选择DVD或蓝光光驱。
  4. 无线网卡(可选) :

    • 如果主板不支持无线连接,可以添加无线网卡。
  5. 声卡(可选) :

    • 如果需要更好的音频性能,可以添加独立声卡。
  6. 扩展卡(可选) :

    • 如网络卡、声卡、图形卡等。
  7. 机箱装饰(可选) :

    • RGB灯条、风扇等。

处理器(CPU)天梯图

image.png

显卡(GPU)天梯图

image.png

如何选择显示器

选择电脑显示器时,有几个关键因素需要考虑:

  1. 分辨率:分辨率决定了显示器的清晰度。常见的有1080p(全高清)、1440p(2K)、2160p(4K)等。分辨率越高,画面越清晰,但同时对显卡的要求也越高。
  2. 屏幕尺寸:根据你的使用习惯和空间大小来选择。大屏幕可以提供更宽广的视野,但也需要更大的桌面空间。
  3. 刷新率:刷新率表示显示器每秒可以刷新多少次画面。常见的有60Hz、144Hz、240Hz等。高刷新率可以提供更流畅的视觉效果,特别适合游戏玩家。
  4. 响应时间:响应时间指的是像素从一种状态变化到另一种状态所需的时间,通常以毫秒(ms)为单位。响应时间越短,画面变化越快,越适合快速变化的游戏或视频。
  5. 面板类型:主要有TN、IPS、VA三种面板。TN面板响应速度快,但色彩表现一般;IPS面板色彩表现好,视角宽,但响应时间相对较慢;VA面板则介于两者之间。
  6. 连接接口:确保显示器的连接接口与你的电脑兼容,常见的有HDMI、DisplayPort、DVI、VGA等。
  7. 色彩准确性:如果你的工作涉及到图像或视频编辑,那么选择色彩准确性高的显示器非常重要。
  8. 附加功能:一些显示器可能提供额外的功能,如USB接口、扬声器、可调节支架等。
  9. 预算:根据你的预算来决定购买哪种类型的显示器。通常来说,价格越高,显示器的性能和功能也越全面。
  10. 品牌和售后服务:选择知名品牌的显示器通常可以保证较好的质量和售后服务。

根据你的具体需求和预算,综合考虑上述因素,选择最适合你的显示器。 并不是配置越高越好,重点是根据你的显卡和CPU来看,实现极致性价比,需要对应电脑配置能带动的最大区间,比方说 英特尔 i5-12400F(6核心12线程,睿频4.4GHz)+ 华硕DUAL-RTX 4060-8G游戏显卡能带起来的刷星率极限基本就是165Hz左右,那么你配置2K,180Hz的显示器就够用了,当然你有钱也可以整个4K,200+Hz的

关于外围设备(键鼠、耳机、音响等)

这个看个人喜好以及预算来决定吧,比方说有线还是无线,机械还是非机械等等....

不同价位装机性价比清单(根据目前市场上)

装机其实主要花费就在显卡(GPU)和处理器(CPU)上,显卡(GPU)金额占比超过50%比比皆是,处理器(CPU)金额占比一般是在百分之20%~30%,其他配件金额占比20%~30%左右。

如果预算足够建议优先升级显卡

CPU分为盒装和散片,预算充足盒装,预算不足散片也能用

3K推荐

  • CPU: Intel 12代酷睿i5 12400F
  • 显卡: 华硕DUAL RTX 3050 6G
  • 内存: 威刚D4 16GB 3200MHz
  • 硬盘: 英睿达 500GB PCI-E 4.0 M.2
  • 主板: 圣旗H610M
  • 电源: 爱国者额定500W
  • 跑分: 107W±
  • 合计:3.5K左右

4K极致性价比

4K-6K这个价位其实CPU最佳的就是就是在下图区间 同理可自行对比GPU天梯图

image.png

image.png

  • CPU: 英特尔 i5-12400F(6核心12线程,睿频4.4GHz)
  • 显卡: 华硕DUAL-RTX 4060-8G游戏显卡 升级选项: +299元升级至华硕TX-RTX 4060-O8G-GAMING天选白三风扇
  • 散热: 动力火车四铜管白色强劲散热器
  • 主板: 华硕PRIME B760M-FD4(支持AURA神光同步)
  • 内存: 雷克沙DDR4 16GB 3200MHz高频内存
  • 硬盘: 雷克沙500GB NVMe PCIe SSD(读速高达3300MB/S)
  • 电源: 源之力静音大师额定600W安规3C白色
  • 机箱: 动力火车琉璃海景房
  • 合计:4K左右

1718192436562.png

5K极致性价比

1718192483898.png

image.png

image.png

6K极致性价比

image.png

7K极致性价比

image.png

8K极致性价比

image.png

1W极致性价比

1W预算以上可能考虑的不单单是配置了,还有外观之类的,可以DIY定制之类的

image.png

image.png

1.2W极致性价比

1718192287164.png

1.3w极致性价比

image.png

总结下,其实根据CPU、GPU天梯图就可以找到自己的目标区间,其他配置看个人预算来就行,核心就是两大件!如果有装机高手,欢迎留言交流,有不懂的,欢迎提问。后续会根据问题持续补充


作者:凉城a
来源:juejin.cn/post/7379420157670670372
收起阅读 »

Electron实现静默打印小票

web
Electron实现静默打印小票 静默打印流程 1.渲染进程通知主进程打印 //渲染进程 data是打印需要的数据 window.electron.ipcRenderer.send('handlePrint', data) 2.主进程接收消息,创建打印页面...
继续阅读 »

Electron实现静默打印小票


静默打印流程


09c00eb5-f171-4090-a178-37e149d1d0f7.png


1.渲染进程通知主进程打印


//渲染进程 data是打印需要的数据
window.electron.ipcRenderer.send('handlePrint', data)

2.主进程接收消息,创建打印页面


//main.ts
/* 打印页面 */
let printWindow: BrowserWindow | undefined
/**
* @Author: yaoyaolei
* @Date: 2024-06-07 09:27:22
* @LastEditors: yaoyaolei
* @description: 创建打印页面
*/

const createPrintWindow = () => {
return new Promise<void>((resolve) => {
printWindow = new BrowserWindow({
...BASE_WINDOW_CONFIG,
title: 'printWindow',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
nodeIntegration: true,
contextIsolation: false
}
})

printWindow.on('ready-to-show', () => {
//打印页面创建完成后不需要显示,测试时可以调用show查看页面样式(下面有我处理的样式图片)
// printWindow?.show()
resolve()
})

printWindow.webContents.setWindowOpenHandler((details: { url: string }) => {
shell.openExternal(details.url)
return { action: 'deny' }
})

if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
printWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/print.html`)
} else {
printWindow.loadFile(join(__dirname, `../renderer/print.html`))
}
})
}

ipcMain.on('handlePrint', (_, obj) => {
//主进程接受渲染进程消息,向打印页面传递数据
if (printWindow) {
printWindow!.webContents.send('data', obj)
} else {
createPrintWindow().then(() => {
printWindow!.webContents.send('data', obj)
})
}
})

3.打印页面接收消息,拿到数据渲染页面完成后通知主进程开始打印


<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>打印</title>
<style>
</style>
</head>

<body>

</body>
<script>
window.electron.ipcRenderer.on('data', (_, obj) => {
//这里是接受的消息,处理完成后将html片段放在body里面完成后就可以开始打印了
//样式可以写在style里,也可以内联
console.log('event, data: ', obj);
//这里自由发挥
document.body.innerHTML = '处理的数据'
//通知主进程开始打印
window.electron.ipcRenderer.send('startPrint')
})
</script>
</html>



这个是我处理完的数据样式,这个就是print.html
9f17ea7e-3f83-408f-a780-05d50da305de.png
微信图片_20240609102325.jpg



4,5.主进程接收消息开始打印,并且通知渲染进程打印状态


ipcMain.on('startPrint', () => {
//这里如果不指定打印机使用的是系统默认打印机,如果需要指定打印机,
//可以在初始化的时候使用webContents.getPrintersAsync()获取打印机列表,
//然后让用户选择一个打印机,打印的时候将打印机名称传过来
printWindow!.webContents.print(
{
silent: true,
margins: { marginType: 'none' }
//deviceName:如果要指定打印机传入打印机名称
},
(success) => {
//通知渲染进程打印状态
if (success) {
mainWindow.webContents.send('printStatus', 'success')
} else {
mainWindow.webContents.send('printStatus', 'error')
}
}
)
})

aa.jpg



完毕~



作者:彷徨的耗子
来源:juejin.cn/post/7377645747448365091
收起阅读 »

我的 Electron 客户端被第三方页面入侵了...

web
问题描述 公司有个内部项目是用 Electron 来开发的,有个功能需要像浏览器一样加载第三方站点。 本来一切安好,但是某天打开某个站点的链接,导致 整个客户端直接变成了该站点的页面。 这一看就是该站点做了特殊的处理,经排查网页源码后,果然发现了有这么一句代码...
继续阅读 »

问题描述


公司有个内部项目是用 Electron 来开发的,有个功能需要像浏览器一样加载第三方站点。


本来一切安好,但是某天打开某个站点的链接,导致 整个客户端直接变成了该站点的页面


这一看就是该站点做了特殊的处理,经排查网页源码后,果然发现了有这么一句代码。


    if (window.top !== window.self) {
window.top.location = window.location;
}

翻译一下就是:如果当前窗口不是顶级窗口的话,将当前窗口设置为顶级窗口。


奇怪的是两者不是 跨域 了吗,为什么 iframe 还可以影响顶级窗口。


先说一下我当时的一些解决办法:



  1. webview 替换 iframe

  2. iframe 添加 sandbox 属性


后续内容就是一点复盘工作。


场景复现(Web端)


一开始怀疑是客户端的问题,所以我用在纯 Web 上进行了一次对比验证。


这里我们新建两个文件:1.html2.html,我们称之为 页面A页面B


然后起了两个本地服务器来模拟同源与跨域的情况。


页面A:http://127.0.0.1:5500/1.html


页面B:http://127.0.0.1:5500/2.htmlhttp://localhost:3000/2.html


符合同源策略


<body>
<h1>这是页面A</h1>
<!-- 这是同源的情况 -->
<iframe id="iframe" src="http://127.0.0.1:5500/2.html" />

<script>
iframe.onload = () => {
console.log('iframe loaded..')
console.log('子窗口路径', iframe.contentWindow.location.href)
}
</script>
</body>

<body>
<h2>这是页面B</h2>

<script>
console.log('page2...')
console.log(window === window.top)
console.log('顶部窗口路径', window.top.location.href)
</script>
</body>

我们打开控制台可以看到 页面A 和 页面B 是可以 互相访问 到对方窗口的路径。


image.png


如果这个时候在 页面B 加上文章开头提到的 代码片段,那么显然页面将会发生变化。


image.png


跨域的情况


这时候我们修改 页面A 加载 页面B 的地址,使其不符合同源策略。


image.png


理所应当的是,两个页面不能够相互访问了,这才是正常的,否则内嵌第三方页面可以互相修改,那就太不安全了。


场景复现(客户端)


既然 Web 端是符合预期的,那是不是 Electron 自己的问题呢?


我们通过 electron-vite 快速搭建了一个 React模板的electron应用,版本为:electron@22.3.27,并且在 App 中也嵌入了刚才的 页面B。


function App(): JSX.Element {
return (
<>
<h1>这是Electron页面</h1>
<iframe id="iframe" src="http://localhost:3000/2.html"/>
</>

)
}
export default App

image.png


对不起,干干净净的 Electron 根本不背这个锅,在它身上的表现如同 Web端 一样,也受同源策略的限制。


那么肯定是我的项目里有什么特殊的配置,通过对比主进程的代码,答案终于揭晓。


new BrowserWindow({
...,
webPreferences: {
...,
webSecurity: false // 就是因为它
}
})

Electron 官方文档 里是这么描述 webSecurity 这个配置的。



webSecurity boolean (可选) - 当设置为 false, 它将禁用同源策略 (通常用来测试网站), 如果此选项不是由开发者设置的,还会把 allowRunningInsecureContent设置为 true. 默认值为 true



也就是说,Electron本身是有一层屏障的,但当该属性设置为 false 的时候,我们的客户端将会绕过同源策略的限制,这层屏障也就消失了,因此 iframe 的行为表现得像是嵌套了同源的站点一样。


解决方案


把这个配置去掉,确实是可以解决这个问题,但考虑到可能对其他功能造成的影响,只能采取其他方案。


如文章开头提到的,用 webview 替换 iframe


webviewElectron的一个自定义元素(标签),可用于在应用程序中嵌入第三方网页,它默认开启安全策略,直接实现了主应用与嵌入页面的隔离。


因为目前这个需求是仅作展示,不需要与嵌套页面进行交互以及复杂的通信,因此在一开始的开发过程中,并没有使用它,而是直接采用了 iframe


iframe 也能够实现类似的效果,只需要添加一个 sandbox 属性可以解决。


MDN 中提到,sandbox 控制应用于嵌入在 <iframe> 中的内容的限制。该属性的值可以为空以应用所有限制,也可以为空格分隔的标记以解除特定的限制。


如此一来,就算是同源的,两者也不会互相干扰。


总结


这不是一个复杂的问题,发现后及时修复了,并没有造成很大的影响(还好是自己人用的平台)。


写这篇文章的主要目的是为了记录这次事件,让我意识到在平时开发过程中,把注意力过多的放在了 业务样式性能等这些看得见的问题上,可能很少关注甚至忽略了 安全 这一要素,以为前端框架能够防御像 XSS 这样的攻击就能安枕无忧。


谨记,永远不要相信第三方,距离产生美。


如有纰漏,欢迎在评论区指出。


作者:小陈同学吗
来源:juejin.cn/post/7398418805971877914
收起阅读 »

如何将用户输入的名称转成艺术字体-fontmin.js

web
写在开头 日常我们在页面中使用特殊字体,一般操作都是直接由前端来全量引入设计师提供的整个字体包即可,具体操作如下: <template> <div class="font">橙某人</div> </template...
继续阅读 »

写在开头


日常我们在页面中使用特殊字体,一般操作都是直接由前端来全量引入设计师提供的整个字体包即可,具体操作如下:


<template>
<div class="font">橙某人</div>
</template>

<style scoped>
@font-face {
font-family: "orange";
src: url("./orange.ttf");
}
.font {
font-family: "orange";
}
</style>


很简单吧🤡,但有时应用场景不同,可能需要我们考虑一下性能问题。



一般来说,我们常见的字体包整个是非常大的,小的有几M到十几M,大的可能去到上百M都有,特别是中文类的字体包会相对英文类的要更大一些。



如本文案例,我们仅需在用户输入完后加载对应的字体包即可,这样能避免性能的损耗。


为此,我们需要把整个字体包拆分、细致化、子集化,让它能达到按需引入的效果。


那么这要如何来做这个事情呢?这个方案单单前端可做不了,我们需要配合后端一起,下面就来看看具体的实现过程吧。😗


前端


前端小编用 Vue 来编写,具体如下:


<template>
<div>
<input v-model="name" />
<button @click="handleClick">生成</button>
<div v-if="showName" class="font">{{ showName }}</div>
</div>

</template>

<script>
export default {
data() {
return {
name: "",
showName: "",
};
},
methods: {
handleClick() {
// 创建link标签
const linkElement = document.createElement("link");
linkElement.setAttribute("rel", "stylesheet");
linkElement.setAttribute("type", "text/css");
linkElement.href = `http://localhost:3000?name=${encodeURIComponent(this.name)}`;
document.body.appendChild(linkElement);
// 优化显示效果
setTimeout(() => {
this.showName = this.name;
}, 300);
},
},
};
</script>


<style>
.font {
font-family: orange;
font-size: 50px;
}
</style>


应该都能看懂吧,主要就是生成了一个 <link /> 标签并插入到文档中,标签的请求地址指向我们服务端,至于服务端会返回什么你可以先猜一猜。👻


服务端


服务端小编选择用 Koa2 来编写,你也可以选择 Express 或者 Egg ,甚至 Node 也是可以的,差异不大,具体逻辑如下:


const koa = require("koa2");
const fs = require("fs");
const FontMin = require("fontmin");

const app = new koa();

/** @name 优化,缓存已经加载过的字体包进内存 **/
const fontFamilyMap = {};

/** @name 加载字体包 **/
function loadFontLibrary(fontPath, fontFamily) {
if (fontFamilyMap[fontFamily]) return fontFamilyMap[fontFamily];
return new Promise((resolve, reject) => {
fs.readFile(fontPath, (error, file) => {
if (error) {
reject(new Error(error.message));
} else {
fontFamilyMap[fontFamily] = file;
resolve(file);
}
});
});
}

app.use(async (ctx) => {
const { name } = ctx.query;
// 设置返回文件类型
ctx.set("Content-Type", "text/css");

const fontPath = "./font/orange.ttf";
const fontFamily = "orange";
if (!fs.existsSync(fontPath)) return (ctx.body = "字体包读取失败");

const fontMin = new FontMin();
const fontFile = await loadFontLibrary(fontPath, fontFamily);
fontMin.src(fontFile);

const getFontCSS = () => {
return new Promise((resolve) => {
fontMin
.use(FontMin.glyph({ text: name }))
.use(FontMin.css({ base64: true, fontFamily }))
.run((error, files) => {
if (error) {
console.log("error", error.message);
} else {
const fontContent = files?.[1]?.contents;
resolve(fontContent);
}
});
});
};

const fontCSS = await getFontCSS();

ctx.body = fontCSS;
});

app.listen(3000);

console.log("服务器开启: http://localhost:3000/");

我们主要是采用了 Fontmin 库来完成整个字体包的按需加载功能,这个库是第一个纯 JavaScript 字体子集化方案。



可能有后端是 Java 或者其他技术栈的小伙伴,你们也不用担心,据小编和公司后端同事了解,不同技术栈也是有对应的库可以解决的,需要的可以自行查查看。










至此,本篇文章就写完啦,撒花撒花。


image.png


希望本文对你有所帮助,如有任何疑问,期待你的留言哦。

老样子,点赞+评论=你会了,收藏=你精通了。


作者:橙某人
来源:juejin.cn/post/7293151700869038099
收起阅读 »

登录问题——web端

问题描述:在集成环信SDK的过程中,大家可能会遇到一个令人困惑的问题:明明已经通过open登录成功了,但是在调用api时却总是报错,错误类型为type28或者type700或者type39 not login。本文将详细分析这个问题的原因,并提供相应的解决方案...
继续阅读 »

问题描述:

在集成环信SDK的过程中,大家可能会遇到一个令人困惑的问题:明明已经通过open登录成功了,但是在调用api时却总是报错,错误类型为type28或者type700或者type39 not login。本文将详细分析这个问题的原因,并提供相应的解决方案。


原因分析:


要解决这个问题,我们首先需要了解环信SDK的登录机制。登录过程实际上分为两个步骤:

1. 请求Token:这是open登录操作的第一步,即在open.then或者success回调中返回token。
2. 建立长连接:即建立WebSocket连接,触发onOpened或者onConnected回调。只有当onOpened或者onConnected回调被触发,才算是真正与环信服务器建立了连接。
SDK在拿到token后,会将其设置进入SDK并尝试建立连接。如果在onOpened或者onConnected回调触发之前就执行了api的调用,那么token可能还没有被正确设置进入SDK,从而导致后续的HTTP请求报token无效的错误。也就是出现type28或者type700或者type 39的报错。

解决方案:


为了避免这个问题,我们需要调整代码逻辑,确保在onOpened或者onConnected回调触发后再去请求一系列的接口。以下是具体的调整步骤:

1. 监听连接状态:在SDK初始化后,监听onOpened或者onConnected回调

2. 延迟调用api操作:不要在open.then或者success回调中立即执行api的调用,而是等待onOpened或者onConnected回调触发后再执行。
3. 检查SDK状态:调用api前检查SDK是否已经成功建立连接。
可以用以下三种方法中的一种判断检查SDK是否已经成功建立连接~
1、WebIM.conn方法下有一个logOut字段,该字段为true时表明未登录状态,该字段为false时表明登录;
2、WebIM.conn.isOpened () 方法有三个状态,undefined为未登录状态,true为已登录状态,false为未登录状态,可以根据这三个状态去判断是否登录;
3、通过onOpened 这个回调来判断,只要执行了就说明登录成功了,输出的话,输出的是undefined

收起阅读 »

183天打造行业新标杆!BOE(京东方)国内首条第8.6代AMOLED生产线提前全面封顶

2024年9月25日,BOE(京东方)投建的国内首条第8.6代AMOLED生产线全面封顶仪式在成都市高新区举行,该生产线从开工到封顶仅用183天,以科学、高效、高质的速度再树行业新标杆。这不仅是BOE(京东方)创新突破、打造新质生产力的又一重大举措,也是OLE...
继续阅读 »

2024年9月25日,BOE(京东方)投建的国内首条第8.6代AMOLED生产线全面封顶仪式在成都市高新区举行,该生产线从开工到封顶仅用183天,以科学、高效、高质的速度再树行业新标杆。这不仅是BOE(京东方)创新突破、打造新质生产力的又一重大举措,也是OLED领域的里程碑事件,极大推动OLED显示产业快速迈进中尺寸发展阶段,对促进半导体显示产业优化升级、引领行业高质量发展具有重要意义。京东方科技集团董事长陈炎顺出席并宣布仪式启动,项目总指挥刘晓东、项目执行总指挥杨国波等领导及中建三局集团有限公司、中国建筑一局(集团)有限公司、中国电子工程设计院股份有限公司、四川华凯工程项目管理有限公司等相关单位领导共同出席封顶仪式。

BOE(京东方)第8.6代AMOLED生产线项目总指挥刘晓东在致辞中表示:“BOE(京东方)第8.6代AMOLED生产线自今年初正式开工以来,始终秉持‘五同时、五确保、五典范’建设原则,以坚韧不拔的意志和团结协作的精神,历时183天,提前达成全面封顶目标,标志着该生产线正式迈入新阶段。BOE(京东方)第8.6代AMOLED生产线必将成为行业标杆工程,为企业发展注入新的活力与动力。我们有信心、有能力打造全球最具竞争力的第8.6代AMOLED生产线,为全球显示产业进步贡献重要力量。”

BOE(京东方)第8.6代AMOLED生产线总投资630亿元,是四川省迄今投资体量最大的单体工业项目,设计产能每月3.2万片玻璃基板(尺寸2290mm×2620mm),主要生产笔记本电脑、平板电脑等智能终端高端触控OLED显示屏。BOE(京东方)通过采用低温多晶硅氧化物(LTPO)背板技术与叠层发光器件制备工艺,使OLED屏幕实现更低的功耗和更长的使用寿命,也将带动下游笔记本及平板电脑产品的迭代升级。目前,BOE(京东方)已在成都、重庆、绵阳投建了三条第6代柔性AMOLED生产线,再加上国内首条第8.6代AMOLED生产线的投建,全面展现了其全球领先的技术实力和行业影响力。值得关注的是,截至2023年,BOE(京东方)柔性OLED出货量已连续多年稳居国内第一,全球第二(数据来源:Omdia),柔性OLED相关专利申请超3万件。BOE(京东方)柔性显示技术不仅应用于手机领域,还持续拓展笔记本、车载、可穿戴等领域,折叠屏、滑卷屏、全面屏等柔性显示解决方案已覆盖国内外众多头部终端品牌,进一步确立BOE(京东方)在OLED领域的全球领先地位。

2024年,BOE(京东方)面向下一个三十年的新征程全新出发,公司将始终坚持“传承、创新、发展”的企业文化内核,坚定信念、创新变革,持续探索契合市场需求的企业发展“第N曲线”。BOE(京东方)第8.6代AMOLED生产线也将汇聚新型显示产业人才,发挥引擎作用,打造以柔性显示为核心的“世界柔谷”,在持续提升竞争力的同时,谱写行业高质发展的新篇章。

收起阅读 »

“你好BOE”即将重磅亮相上海国际光影节 这场“艺术x科技”的顶级光影盛宴不容错过!

当艺术遇上科技,将会擦出怎样的璀璨火花?答案即将在首届上海国际光影节揭晓。9月29日-10月5日,全球显示龙头企业BOE(京东方)年度标杆性品牌IP“你好BOE”即将重磅亮相上海国际光影节,这也是虹口区的重点项目之一。现场BOE(京东方)将携手上海电影、上影元...
继续阅读 »

当艺术遇上科技,将会擦出怎样的璀璨火花?答案即将在首届上海国际光影节揭晓。9月29日-10月5日,全球显示龙头企业BOE(京东方)年度标杆性品牌IP“你好BOE”即将重磅亮相上海国际光影节,这也是虹口区的重点项目之一。

现场BOE(京东方)将携手上海电影、上影元、OUTPUT、新浪微博、海信、OPPO、京东等众多顶级文化机构与全球一线知名企业,在上海城市地标北外滩临江5米平台打造一场以创新科技赋能影像艺术的顶级视觉盛宴,将成为本届光影节期间上海北外滩“最吸睛”的打卡地点!


看点一:全球首款升降式裸眼3D数字艺术装置“大地穹幕” 闪耀北外滩最具科技感的光影亮色

在 “你好BOE”活动现场,全球首款升降式裸眼3D数字艺术装置——“大地穹幕”将正式与观众见面!你可以在高达5米的大型裸眼3D折角屏幕前亲身感受极具视觉冲击力的3D大屏画面,还可以通过智能识别技术与屏幕光影尽情交互,让整个北外滩广场瞬间变成户外露天观众席,极具特色的“大地穹幕”与临江对岸的上海“三件套”交相辉映,市民朋友们可以在外滩的微风中尽情畅享裸眼3D影院级体验。

看点二:灵动“舞屏”、 MELD 裸眼3D沉浸空间、智慧光幕技术 打造极具未来感的科技大秀

屏幕会跳舞?即将亮相的全新“舞屏”将颠覆你的想象力!动态的机械臂仿佛拥有生命一般自如地抓取、搬运、旋转,让高清大屏在你眼前自由舞动;或者在MELD 裸眼3D沉浸空间体验一场虚拟与现实之间的穿梭;亦或是在搭载智慧光幕技术的“玻璃窗”前,感受玻璃明暗随日升月落不断流转变化,未来科技带来的美好生活仿若近在眼前!

看点三:经典动画IP、当代摄影艺术、国产3A游戏巨制 焕活文化艺术全新生命力

在活动现场,《大闹天宫》、《哪吒闹海》等上影元运营的经典动画作品将在AI技术加持下以全新面貌惊艳呈现,带你瞬间梦回童年!敦煌画院的古老壁画也在8K超高清全真还原技术下纤毫毕现;现场你还能看到当下最炙手可热的全球首款量产的“三折屏”手机等最新科技产品齐齐亮相。在这一文化艺术的聚集地,自然也少不了时下最火热的国产3A游戏巨制《黑神话·悟空》,游戏爱好者可以在BOE(京东方)赋能的110英寸超大尺寸IP定制电视上亲眼感受媲美OLED的顶级画质,亲身体验288Hz极致超高刷新率带来的酣畅淋漓。

你还在等什么?这个十一假期,让我们相约金秋时节的上海北外滩,一起打卡这场顶级的光影科技盛会,开启令人期待的美好科技体验吧!

收起阅读 »

iframe的基本使用与注意点

web
iframe(Inline Frame)是一种在网页中嵌套其他网页的 HTML 元素。通过 iframe,开发者可以在一个页面中加载另一个页面的内容,提升用户体验和功能性。下面将详细探讨 iframe 的原理、使用场景以及注意事项,并提供相应的代码示例。 一、...
继续阅读 »


iframe(Inline Frame)是一种在网页中嵌套其他网页的 HTML 元素。通过 iframe,开发者可以在一个页面中加载另一个页面的内容,提升用户体验和功能性。下面将详细探讨 iframe 的原理、使用场景以及注意事项,并提供相应的代码示例。


一、iframe 的原理


iframe 是一种 HTML 标签,其基本语法如下:


<iframe src="https://example.com" width="600" height="400" frameborder="0"></iframe>


  • src:指定要加载的网页地址。

  • widthheight:定义 iframe 的宽度和高度。

  • frameborder:控制边框显示(在 HTML5 中不推荐使用)。


当浏览器遇到 iframe 标签时,会发起一个独立的网络请求来加载指定的 URL。这使得嵌入的内容在主文档之外独立渲染。


二、使用场景



  1. 广告展示



    • iframe 经常用于展示广告内容,允许网站在不影响主页面的情况下,灵活更新广告。


    <iframe src="https://ad.example.com" width="300" height="250" frameborder="0"></iframe>


  2. 第三方内容集成



    • 嵌入社交媒体帖子、视频播放器或地图等内容。例如,嵌入 YouTube 视频:


    <iframe src="https://www.youtube.com/embed/VIDEO_ID" width="560" height="315" frameborder="0" allowfullscreen></iframe>


  3. 内容隔离



    • 当需要展示用户生成的内容(如评论或论坛)时,可以使用 iframe 进行内容隔离,避免对主页面造成影响。



  4. 安全性




    • 使用 sandbox 属性,可以限制 iframe 的功能,增加安全性。



      1. allow-forms


        允许 iframe 内部的表单提交。默认情况下,表单提交被禁止。


      2. allow-same-origin


        允许 iframe 中的文档以相同来源访问其父页面。这允许脚本与同源的内容交互。


      3. allow-scripts


        允许 iframe 中的脚本执行。默认情况下,脚本执行被禁止。


      4. allow-top-navigation


        允许 iframe 中的内容导航到父页面。这使得嵌入页面可以改变主页面的 URL。


      5. allow-popups


        允许 iframe 中的内容打开新窗口或标签页。默认情况下,这种操作被禁止。


      6. allow-modals


        允许 iframe 显示模态对话框,例如 alertpromptconfirm


      7. allow-presentation


        允许 iframe 进入展示模式,例如全屏模式。





    <iframe src="https://example.com" width="600" height="400" sandbox="allow-scripts"></iframe>



三、注意点



  1. 安全性问题



    • 由于跨站点脚本攻击(XSS)的风险,很多网站设置了 X-Frame-OptionsContent-Security-Policy 来限制 iframe 的嵌入。这会导致“拒绝了我们的连接请求”的错误提示。




image.png


```http
X-Frame-Options: DENY
`
``


  1. 性能影响



    • 嵌套多个 iframe 会增加页面的加载时间和复杂性,影响性能。因此,建议合理使用。



  2. 跨域限制



    • 由于同源策略,iframe 中加载的页面不能与主页面进行直接交互。这意味着无法访问嵌入页面的 DOM 或 JavaScript。



  3. SEO 考虑



    • 搜索引擎可能不会索引 iframe 内的内容,从而影响整体的 SEO 表现。避免将重要内容仅放在 iframe 中。



  4. 响应式设计



    • 确保 iframe 在不同设备和屏幕尺寸下表现良好,可以通过 CSS 设置其宽度为百分比。例如:


    iframe {
    width: 100%;
    height: auto;
    }



四、示例代码


以下是一个综合示例,展示了如何使用 iframe 加载一个 YouTube 视频并应用响应式设计:


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Iframe Example</title>
<style>
.responsive-iframe {
position: relative;
padding-bottom: 56.25%; /* 16:9 Aspect Ratio */
height: 0;
overflow: hidden;
}
.responsive-iframe iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>
</head>
<body>

<h1>嵌入 YouTube 视频</h1>
<div class="responsive-iframe">
<iframe src="https://www.youtube.com/embed/VIDEO_ID" frameborder="0" allowfullscreen></iframe>
</div>

</body>
</html>

结论


iframe 是一种强大的网页嵌入技术,能够增强网页功能和用户体验。在使用时,需要充分考虑安全性、性能和跨域问题,以确保良好的用户体验。通过合理配置和使用,iframe 可以为网页增加更多的互动性和功能性。


---09/19ヾ( ̄▽ ̄)ByeBye


再见.png


作者:不爱说话郭德纲
来源:juejin.cn/post/7415914059106533439
收起阅读 »

get请求参数放在body中?

web
1、背景 与后端对接口时,看到有一个get请求的接口,它的参数是放在body中的 ******get请求参数可以放在body中?? 随即问了后端,后端大哥说在postman上是可以的,还给我看了截图 可我传参怎么也调不通! 下面就来探究到底是怎么回事 2、...
继续阅读 »

1、背景


与后端对接口时,看到有一个get请求的接口,它的参数是放在body中的



******get请求参数可以放在body中??


随即问了后端,后端大哥说在postman上是可以的,还给我看了截图



可我传参怎么也调不通!


下面就来探究到底是怎么回事


2、能否发送带有body参数的get请求


项目中使用axios来进行http请求,使用get请求传参的基本姿势:


// 参数拼接在url上
axios.get(url, {
params: {}
})

如果想要将参数放在body中,应该怎么做呢?


查看axios的文档并没有看到对应说明,去github上翻看下axios源码看看


lib/core/Axios.js文件中



可以看到像deletegetheadoptions方法,它们只接收两个参数,不过在config中有一个data



熟悉的post请求,它接收的第二个参数data就是放在body的,然后一起作为给this.request作为参数


所以看样子get请求应该可以在第二个参数添加data属性,它会等同于post请求的data参数


顺着源码,再看看lib/adapters/xhr.js,上面的this.request最终会调用这个文件封装的XMLHttpRequest


export default isXHRAdapterSupported && function (config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
let requestData = config.data

// 将config.params拼接在url上
request.open(config.method.toUpperCase(),
buildURL(fullPath, config.params, config.paramsSerializer), true);

// 省略若干代码
...

// Send the request
request.send(requestData || null);
});
}

最终会将data数据发送出去


所以只要我们传递了data数据,其实axios会将其放在body发送出去的


2.1 实战


本地起一个koa服务,弄一个简单的接口,看看后端能否接收到get请求的body参数


router.get('/api/json', async (ctx, next) => {
console.log('get请求获取body: ', ctx.request.body)

ctx.body = ctx.request.body
})

router.post('/api/json', async (ctx, next) => {
console.log('post请求获取body: ', ctx.request.body)

ctx.body = ctx.request.body
})

为了更好地比较,分别弄了一个getpost接口


前端调用接口:


const res = await axios.get('/api/json', {
data: {
id: 1,
type: 'GET'
}
})


const res = await axios.post('/api/json', {
data: {
id: 2,
type: 'POST'
}
})
console.log('res--> ', res)

axiossend处打一个断点



可以看到数据已经被放到body中了


后端已经接收到请求了,但是get请求无法获取到body



结论:



  • 前端可以发送带body参数的get请求,但是后端接收不到

  • 这就是接口一直调不通的原因


3、这是为何呢?


我们查看WHATGW标准,在XMLHttpRequest中有这么一个说明:



大概意思:如果请求方法是GETHEAD ,那么body会被忽略的


所以我们虽然传递了,但是会被浏览器给忽略掉


这也是为什么使用postman可以正常请求,但是前端调不通的原因了


因为postman并没有遵循WHATWG的标准,body参数没有被忽略



3.1 fetch是否可以?


fetch.spec.whatwg.org/#request-cl…


答案:也不可以,fetch会直接报错



总结



  1. 结论:浏览器并不支持get请求将参数放在body

  2. XMLHTTPRequest会忽略body参数,而fetch则会直接报错


作者:蝼蚁之行
来源:juejin.cn/post/7283367128195055651
收起阅读 »

Systeminformation.js: 为什么不试试最强的系统信息获取工具?

web
大家好,我是徐徐。今天跟大家分享一款获取系统信息的工具库:systeminformation。前言在现代开发环境中,跨平台获取系统信息已经成为许多应用程序的重要需求。无论你是在开发需要详细系统信息的应用,还是需要获取硬件和软件的状态信息,一个强大且灵活的工具库...
继续阅读 »

大家好,我是徐徐。今天跟大家分享一款获取系统信息的工具库:systeminformation。

前言

在现代开发环境中,跨平台获取系统信息已经成为许多应用程序的重要需求。无论你是在开发需要详细系统信息的应用,还是需要获取硬件和软件的状态信息,一个强大且灵活的工具库可以显著提升你的开发效率。今天,我们要分享的是systeminformation这个 Node.js 库,可以帮你轻松获取到你想要的各种系统信息。

基本信息

什么是 systeminformation?

systeminformation 是一个轻量级的 Node.js 库,旨在提供跨平台的系统信息获取功能。无论是在 Windows、macOS 还是 Linux 上,它都能为你提供一致的接口,获取系统的硬件和软件信息。自2015年发布以来,systeminformation 已经成为开发者们获取系统信息的首选工具之一。

它提供了超过 50 个函数,用于检索详细的硬件、系统和操作系统信息。该库支持 Linux、macOS、部分 Windows、FreeBSD、OpenBSD、NetBSD、SunOS 以及 Android 系统,并且完全无依赖。无论你需要全面了解系统状况,还是仅仅想获取特定的数据,systeminformation 都能满足你的需求,帮助你在各个平台上轻松获取系统信息。

主要特点

  • 跨平台支持:支持 Windows、macOS 和 Linux 系统,提供一致的接口。
  • 全面的信息获取:能够获取 CPU、内存、磁盘、网络、操作系统等详细信息。
  • 实时监控:支持获取实时的系统性能数据,如 CPU 使用率、内存使用率、网络速度等。
  • 易于集成:通过简单的 API 调用即可获取所需信息,便于集成到各种应用程序中。

使用场景

  • 服务器监控:实时监控服务器性能,获取 CPU、内存、磁盘等硬件信息。
  • 桌面应用:获取本地系统信息,展示系统状态和性能数据。
  • IoT 设备:在物联网设备上获取系统信息,进行设备管理和监控。

快速上手

要在你的 Node.js 项目中使用 systeminformation,只需以下简单步骤:

    1. 安装 systeminformation
npm install systeminformation
    1. 获取系统信息示例
const si = require('systeminformation');

// 获取 CPU 信息
si.cpu()
.then(data => console.log(data))
.catch(error => console.error(error));

// 获取内存信息
si.mem()
.then(data => console.log(data))
.catch(error => console.error(error));

// 获取操作系统信息
si.osInfo()
.then(data => console.log(data))
.catch(error => console.error(error));
    1. 实时监控示例
const si = require('systeminformation');

// 实时监控 CPU 使用率
setInterval(() => {
si.currentLoad()
.then(data => console.log(`CPU Load: ${data.currentload}%`))
.catch(error => console.error(error));
}, 1000);

// 实时监控内存使用情况
setInterval(() => {
si.mem()
.then(data => console.log(`Memory Usage: ${data.used / data.total * 100}%`))
.catch(error => console.error(error));
}, 1000);

结语

systeminformation 是一个功能强大且灵活的 Node.js 库,能够帮助你轻松获取系统的各种信息。无论你是需要实时监控服务器性能,还是需要获取本地系统的详细信息,systeminformation 都能为你提供稳定且易用的解决方案。

希望这篇文章能帮助你了解 systeminformation 的强大功能,并激发你在项目中使用它的灵感。赶快分享给你的朋友们吧!


作者:前端徐徐
来源:juejin.cn/post/7413643760771072015
收起阅读 »

axios VS alova.js,谁是真正的通信王者?

web
新年快乐!在这个快速发展的前端世界里,咱们工程师面临的挑战也是一个接一个。今天,咱们就来聊聊前端实时通信这个话题。 想想看,你在使用那些传统的HTTP客户端时,比如axios,是否遇到过这样的问题:与React、Vue等框架的结合不够紧密,导致开发效率低下;...
继续阅读 »

新年快乐!在这个快速发展的前端世界里,咱们工程师面临的挑战也是一个接一个。今天,咱们就来聊聊前端实时通信这个话题。



想想看,你在使用那些传统的HTTP客户端时,比如axios,是否遇到过这样的问题:与React、Vue等框架的结合不够紧密,导致开发效率低下;在性能方面表现不佳,尤其是在处理频繁或重复的请求时;还有那略显臃肿的体积,以及混乱的响应数据类型定义?


哎呀妈呀,这些问题听着就让人头大。但别急,有个叫做alovajs的工具,可能会让你眼前一亮。


alovajs是一个轻量级的请求策略库,它不仅提供了与axios相似的API设计,让你能更快上手,还解决了上述的那些问题。它如何解决?咱们来一探究竟。


首先,alovajs能够与UI框架深度融合,自动管理请求相关的数据。这意味着你在Vue或React等框架中使用alovajs时,不再需要手动创建和维护请求状态,大大提高了开发效率。


其次,alovajs默认开启了内存缓存和请求共享,这些功能可以在提高请求性能的同时,提升用户体验并降低服务端的压力。比如,当你实现一个列表页,用户点击列表项进入详情页时,alovajs可以智能地使用缓存数据,避免不必要的重复请求。


最后,alovajs的体积只有4kb+,仅是axios的30%左右,而且它提供了更加直观的响应数据TS类型定义,对于重度使用Typescript的同学来说,这绝对是个福音。


说了这么多,是不是有点心动了?如果你对alovajs感兴趣,可以访问它的官网查看更多详细信息:alovajs官网。也欢迎你在评论区分享你对alovajs的看法和使用经验,让我们一起交流学习吧!
有任何问题,你可以加入以下群聊咨询,也可以在github 仓库中发布 Discussions,如果遇到问题,也请在github 的 issues中提交,我们会在最快的时间解决。


作者:胡镇alovajs
来源:juejin.cn/post/7334503381200437299
收起阅读 »

一文搞懂JS类型判断的四种方法

web
前言 在JavaScript中,类型判断是一个非常基础但也十分重要的知识点。不同的类型判断方法适用于不同的场景,掌握这些方法可以帮助我们更好地理解和使用JavaScript。本文将详细介绍typeof、instanceof、Object.prototype.t...
继续阅读 »

前言


在JavaScript中,类型判断是一个非常基础但也十分重要的知识点。不同的类型判断方法适用于不同的场景,掌握这些方法可以帮助我们更好地理解和使用JavaScript。本文将详细介绍typeofinstanceofObject.prototype.toString以及Array.isArray这四种常用的类型判断方法,并通过实例代码帮助大家加深理解。


正文


typeof


typeof操作符可以用来判断基本数据类型,如stringnumberbooleanundefinedsymbolbigint等。它对于null和所有引用类型的判断会返回"object",而对于函数则会返回"function"


特点:



  1. 可以判断除null之外的所有原始类型。

  2. 除了function,其他所有的引用类型都会被判断成object

  3. typeof是通过将值转换为二进制后判断其二进制前三位是否为0,是则为object


示例代码:


let s = '123'; // string
let n = 123; // number
let f = true; // boolean
let u = undefined; // undefined
let nu = null; // null
let sy = Symbol(123); // Symbol
let big = 1234n; // BigInt

console.log(typeof s); // "string"
console.log(typeof n); // "number"
console.log(typeof f); // "boolean"
console.log(typeof u); // "undefined"
console.log(typeof sy); // "symbol"
console.log(typeof big); // "bigint"
console.log(typeof nu); // "object" - 特殊情况

let obj = {};
let arr = [];
let fn = function() {};
let date = new Date();

console.log(typeof obj); // "object"
console.log(typeof arr); // "object"
console.log(typeof date); // "object"
console.log(typeof fn); // "function"

function isObject(o) {
if (typeof o === 'object' && o !== null) {
return true;
}
return false;
}

let res = isObject({a: 1});
console.log(res); // true

instanceof


instanceof用于检测构造函数的prototype属性是否出现在某个实例对象的原型链上。因此,它主要用于判断引用类型


特点:



  1. 只能判断引用类型。

  2. 通过原型链查找来判断类型。


示例代码:


let obj = {};
let arr = [];
let fn = function() {};
let date = new Date();

console.log(obj instanceof Object); // true
console.log(arr instanceof Array); // true
console.log(fn instanceof Function); // true
console.log(date instanceof Date); // true
console.log(arr instanceof Object); // true

console.log(arr instanceof String); // false
console.log(n instanceof Number); // false

因为原始类型没有原型而引用类型有原型,所有instanceof主要用于判断引用类型,那么根据这个我们是不是可以手写一个instanceof


手写·instanceof实现:


首先我们要知道v8创建对象自变量是这样的,拿let arr = []举例子:


function createArray() {
// 创建一个新的对象
let arr = new Array();
// 设置原型
arr.__proto__ = Array.prototype;
// 返回创建的数组对象
return arr;
}

V8 引擎会调用 Array 构造函数来创建一个新的数组对象,Array 构造函数的内部实现会创建一个新的空数组对象,并初始化其内部属性并且将新创建的数组对象的 __proto__ 属性设置为 Array.prototype,这意味着数组对象会继承 Array.prototype 上的所有方法和属性,最后,新创建的数组对象会被赋值给变量 arr


那么我们是不是可以通过实例对象的隐式原型等于其构造函数的显式原型来判断类型,代码如下:


function myInstanceOf(L,R){
if(L.__proto__ === R.prototype){
return true;
}
return false;
}

但是我们看到console.log([] instanceof Object); // true,所有还要改进一下:


我们要知道这么一件事情:



  1. 内置构造函数的原型链



    • 大多数内置构造函数(如 ArrayFunctionDateRegExpErrorNumberStringBooleanMapSetWeakMapWeakSet 等)的原型(Constructor.prototype)都会直接或间接地继承自 Object.prototype

    • 这意味着这些构造函数创建的对象的原型链最终会指向 Object.prototype



  2. Object.prototype 的原型



    • Object.prototype 的隐式原型(即 __proto__)为 null。这是原型链的终点,表示没有更多的原型可以继承。




所以我们是不是可以这样:


function myinstanceof(L, R) {
while (L !== null) {
if (L.__proto__ === R.prototype) {
return true;
}
L = L.__proto__;
}
return false;
}

console.log(myinstanceof([], Array)); // true
console.log(myinstanceof([], Object)); // true
console.log(myinstanceof({}, Array)); // false

所以就完美实现了。


Object.prototype.toString.call


Object.prototype.toString.call 是一个非常有用的工具,可以用来获取任何 JavaScript 值的类型信息。它结合了 Object.prototype.toStringFunction.prototype.call 两个方法的功能。


特点:



  1. 可以判断任何类型


代码示例


console.log(Object.prototype.toString.call(null));       // [object Null]
console.log(Object.prototype.toString.call(123)); // [object Number]
console.log(Object.prototype.toString.call([])); // [object Array]
console.log(Object.prototype.toString.call({})); // [object Object]
console.log(Object.prototype.toString.call('hello')); // [object String]
console.log(Object.prototype.toString.call(true)); // [object Boolean]
console.log(Object.prototype.toString.call(undefined)); // [object Undefined]
console.log(Object.prototype.toString.call(Symbol())); // [object Symbol]
console.log(Object.prototype.toString.call(123n)); // [object BigInt]

Object.prototype.toString


底层逻辑


根据官方文档,Object.prototype.toString 方法的执行步骤如下:



  1. 如果此值未定义,则返回 "[object undefined]"

  2. 如果此值为 null,则返回 "[object Null]"

  3. 定义 O 是调用 ToObject (该方法作用是把 O 转换为对象) 的结果,将 this 值作为参数传递

  4. 定义 class 是 O 的 [[Class]] 内部属性的值

  5. 返回 "[object" 和 class 和 "]" 组成的字符串的结果


关键点解释



  • ToObject 方法:将传入的值转换为对象。对于原始类型(如 stringnumberboolean),会创建对应的包装对象(如 StringNumberBoolean)。对于 null 和 undefined,会有特殊处理。

  • [[Class]] 内部属性:每个对象都有一个 [[Class]] 内部属性,表示对象的类型。例如,数组的 [[Class]] 值为 "Array",对象的 [[Class]] 值为 "Object"


console.log(Object.prototype.toString(123));//[object Object]
console.log(Object.prototype.toString('123'));//[object Object]
console.log(Object.prototype.toString({}));//[object Object]
console.log(Object.prototype.toString([]));//[object Object]

为什么需要 call


Object.prototype.toString 方法默认的 this 值是 Object.prototype 本身。如果我们直接调用 Object.prototype.toString(123)this 值仍然是 Object.prototype,而不是我们传入的值。因此,我们需要使用 call 方法来改变 this 值,使其指向我们传入的值。


手写call


obj = {
a:1,
}

function foo(){
console.log(this.a);
}

//我们需要将foo中的this指向obj里面
Function.prototype.myCall = function(context){
if(!(this instanceof Function)){ 在构造函数原型上,this指向的是实例对象,这里即foo
return new TypeError(this+'is not function')
}

const fn = Symbol('key'); //使用symbol作为key是因为可能会同名
context[fn] = this;//添加变量名为fn,值为上面的,context={Symbol('key'): foo}
context[fn](); // 触发了隐式绑定
delete context[fn]; //删除这个方法
}

foo.myCall(obj) // 1
console.log(obj); // {a:1}

我们知道call方法是将函数里面的this强行掰弯到我们传入的对象里面去,它的原理是这样的,首先判断你传入的参数是不是一个函数,因为只有函数身上才有call方法,函数调用然后通过隐式绑定规则,将this指向这个对象,那么不就强行更改了this的指向,[不知道this的可以看这篇文章](你不知道的JavaScript(核心知识点概念详细整理-掘金 (juejin.cn))


Array.isArray


Array.isArray是一个静态方法,用于检测给定的值是否为数组。


示例代码:


let arr = [];
let obj = {};

console.log(Array.isArray(arr)); // true
console.log(Array.isArray(obj)); // false

手写Array.isArray实现:


function myIsArray(value) {
return Object.prototype.toString.call(value) === '[object Array]';
}

console.log(myIsArray(arr)); // true
console.log(myIsArray(obj)); // false

总结



  • typeof适合用于检查基本数据类型,但对于null和对象类型的判断不够准确。

  • instanceof用于检查对象的构造函数,适用于引用类型的判断。

  • Object.prototype.toString提供了一种更通用的方法来判断所有类型的值。

  • Array.isArray专门用于判断一个值是否为数组。


希望这篇文章能够帮助你更好地理解和使用JavaScript中的类型判断方法,感谢你的阅读!


image.png


作者:反应热
来源:juejin.cn/post/7416657615369388084
收起阅读 »

三方接口不动声色将http改为了https,于是开启了我痛苦的一天

早上刚来,就看到仓库那边不停发消息说,我们的某个功能用不了了。赶紧放下早餐加紧看。 原来是调的一个三方接口报错了: javax.net.ssl.SSLHandshakeException: sun.security.validator.ValidatorExc...
继续阅读 »

早上刚来,就看到仓库那边不停发消息说,我们的某个功能用不了了。赶紧放下早餐加紧看。


原来是调的一个三方接口报错了:


javax.net.ssl.SSLHandshakeException: sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
at sun.security.ssl.Alert.createSSLException(Alert.java:131)
at sun.security.ssl.TransportContext.fatal(TransportContext.java:353)
at sun.security.ssl.TransportContext.fatal(TransportContext.java:296)
at sun.security.ssl.TransportContext.fatal(TransportContext.java:291)
at sun.security.ssl.CertificateMessage$T12CertificateConsumer.checkServerCerts(CertificateMessage.java:652)
at sun.security.ssl.CertificateMessage$T12CertificateConsumer.onCertificate(CertificateMessage.java:471)
at sun.security.ssl.CertificateMessage$T12CertificateConsumer.consume(CertificateMessage.java:367)
at sun.security.ssl.SSLHandshake.consume(SSLHandshake.java:376)
at sun.security.ssl.HandshakeContext.dispatch(HandshakeContext.java:444)
at sun.security.ssl.HandshakeContext.dispatch(HandshakeContext.java:422)
at sun.security.ssl.TransportContext.dispatch(TransportContext.java:183)
at sun.security.ssl.SSLTransport.decode(SSLTransport.java:154)
at sun.security.ssl.SSLSocketImpl.decode(SSLSocketImpl.java:1279)
at sun.security.ssl.SSLSocketImpl.readHandshakeRecord(SSLSocketImpl.java:1188)
at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:401)
at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:373)

查看原因:



由于JVM默认信任证书不包含该目标网站的SSL证书,导致无法建立有效的信任链接。



奥...原来是他们把接口从http改为了https,导致我们获取数据报错了。再看看他们的证书,奥...新的。


image.png


好了,看看我们的逻辑,这其实是一个获取对方生成的PDF文件的接口


PdfReader pdfReader = new PdfReader(url);

url就是他们给的链接,是这行代码报的错。这时候,开始研究,在网上扒拉,找到了初版方案


尝试1


写一个程序专门获取安全证书,这代码有点长,全贴出来影响阅读。我给扔我hithub上了github.com/lukezhao6/I… 将这个文件贴到本地,执行javac InstallCert.java将其进行编译


image.png


编译完长这样:


image.png
然后再执行java InstallCert http://www.baidu.com (这里我们用百度举例子,实际填写的就是你想要获取证书的目标网站)


image.png
报错不用怕,因为它会去检查目标服务器的证书,如果出现了SSLException,表示证书可能存在问题,这时候会把异常信息打印出来。


在生成的时候需要输入一个1


image.png
这样,我们需要的证书文件就生成好了


image.png


这时候,将它放入我们本地的 jdk的lib\security文件夹内就行了


image.png


重启,这时候访问是没有问题了。阶段性胜利。


但是,但是。一顿操作下来,对于测试环境的docker,还有生产环境貌似不能这么操作。 放这个证书文件比较费事。


那就只能另辟蹊径了。


尝试2


搜到了,还有两种方案。



1.通过System.setProperty("javax.net.ssl.trustStore", "你的jssecacerts证书路径");


2.程序启动命令-Djavax.net.ssl.trustStore=你的jssecacerts证书路径 -Djavax.net.ssl.trustStorePassword=changeit



我尝试了第一种,System.setProperty可以成功,但是读不到文件,权限什么的都是ok的。
检查了蛮多地方



  • 路径格式问题

  • 文件是否存在

  • 文件权限

  • 信任库密码

  • 系统属性优先级


貌似都是没问题的,但肯定又是有问题的,因为没起作用。但是想着这样的接口有4个,万一哪天其他三个也改了,我又得来一遍。所以就算研究出来了,还是不能稳坐钓鱼台。有没有一了百了的方法嘞。


尝试3


还真找到了:这个错是因为对方网站的证书不被java信任么,那咱不校验了,直接全部信任。这样就算其他接口改了,咱也不愁。而且这个就是获取pdf,貌似安全性没那么重。那就开搞。


代码贴在了下方,上边的大概都能看懂吧,下方的我加了注释。


URL console = new URL(url);
HttpURLConnection conn = (HttpURLConnection) console.openConnection();
if (conn instanceof HttpsURLConnection) {
SSLContext sc = SSLContext.getInstance("SSL");
sc.init(null, new TrustManager[]{new TrustAnyTrustManager()}, new java.security.SecureRandom());
((HttpsURLConnection) conn).setSSLSocketFactory(sc.getSocketFactory());
((HttpsURLConnection) conn).setHostnameVerifier(new TrustAnyHostnameVerifier());
}
conn.connect();
InputStream inputStream = conn.getInputStream();
PdfReader pdfReader = new PdfReader(inputStream);
inputStream.close();
conn.disconnect();

private static class TrustAnyTrustManager implements X509TrustManager {
//这个方法用于验证客户端的证书。在这里,方法体为空,表示不对客户端提供的证书进行任何验证。
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
}
//这个方法用于验证服务器的证书。同样,方法体为空,表示不对服务器提供的证书进行任何验证。
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
}
//这个方法返回一个信任的证书数组。在这里,返回空数组,表示不信任任何证书,也就是对所有证书都不做任何信任验证。
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[]{};
}
}
//这个方法用于验证主机名是否可信。在这里,无论传入的主机名是什么,方法始终返回 true,表示信任任何主机名。这就意味着对于 SSL 连接,不会对主机名进行真实的验证,而是始终接受所有主机名。
private static class TrustAnyHostnameVerifier implements HostnameVerifier {
public boolean verify(String hostname, SSLSession session) {
return true;
}
}

解决了解决了,这样改算是个比较不错的方案了吧。


作者:奔跑的毛球
来源:juejin.cn/post/7362587412066893834
收起阅读 »

大屏页面崩溃排查(Too many active WebGL contexts. Oldest context will be lost)

web
1 问题背景 顾问反馈大屏在反复切换的过程中,页面会出现白屏,导致后续的操作无法进行,只能通过刷新页面才能恢复正常。 我们的页面类似于这样的布局(下方的是直接从网络上找的截图) 点击下方红线框住的区域,可以展示不同的图表(echarts图表) 区别在于我们的...
继续阅读 »

1 问题背景


顾问反馈大屏在反复切换的过程中,页面会出现白屏,导致后续的操作无法进行,只能通过刷新页面才能恢复正常。



  1. 我们的页面类似于这样的布局(下方的是直接从网络上找的截图

  2. 点击下方红线框住的区域,可以展示不同的图表(echarts图表)

  3. 区别在于我们的主区域不是图片,用的是基于cesium封装的地图(webgl


image.png


2 问题复现


测试同事经过几分钟的快速切换导航后,复现了,报错了如下内容
image.png


问题如果复现了,其实就解决了一半了


3 查找问题


经过复现后,发现除了上面的报错,每当页面崩溃前,chrome总会有下方的warning。然后基于cesium封装的地图就会崩溃。


image.png


翻译成中文:警告:目前有了太多激活的webgl上下文,最早的上下文将会丢失


4 排查问题


经过和地图组的人沟通,得到一个结论WebGL一个页面上最多有16个实例



  • 怀疑echarts在下方菜单切换过程中,没有进行销毁


检查了代码中的echats的页面在销毁的时候,发现都进行了dispose,排除了这个的原因



  • 怀疑起echarts的3d的饼状图
    之前设计师设计了一个3d的饼状图,参考了 3d柄图环图,一个页面上有多这个组件。
    效果如下:


f8a015b3-0b94-4fd2-a3b3-07a745fa401a.gif


5 锁定组件进行验证



  1. 先把一个页面上的所有组件改为上方的饼状图,然后点击导航栏,频繁进行切换,

  2. 页面很快就崩溃了,然后检查这个组件在页面销毁的时候,是否进行dispose
    检查后,发现没有,添加后进行测试,问题依旧

  3. 继续检查发现这个组件导入了echarts-gl,就去ecahrts的github的issues进行搜索,终于找到了一个类似的问题
    github.com/ecomfe/echa…


image.png


加入了类似的代码,进行验证后解决了此问题


6 总结



  1. chrome浏览器中最多有16个webgl的实例。当过多的时候,会把最早创建的实例销毁

  2. 当使用echarts在页面销毁的时候及时进行dispose,释放上下文

  3. 当使用echarts-gl的时候,调用dispose的时候是不生效的,需要找到页面上的canvas,然后手动将上下文释放,类似下方的代码


const canvasArr = myChart.getDom().getElementsByTagName('canvas');
for(let i=0; i<canvasArr.length; i++){
canvasArr[i].getContext('webgl').getExtension('WEBGL_lose_context').loseContext()
}


7 参考文档



作者:pauldu
来源:juejin.cn/post/7351712561672798260
收起阅读 »

不是,哥们,谁教你这样处理生产问题的?

你好呀,我是歪歪。 最近遇到一个生产问题,我负责的一个服务触发了内存使用率预警,收到预警的时候我去看了内存使用率已经到了 80%,看了一眼 GC 又发现还没有触发 FullGC,一次都没有。 基于这个现象,当时推测有两种可能,一种是内存溢出,一种是内存泄漏。 ...
继续阅读 »

你好呀,我是歪歪。


最近遇到一个生产问题,我负责的一个服务触发了内存使用率预警,收到预警的时候我去看了内存使用率已经到了 80%,看了一眼 GC 又发现还没有触发 FullGC,一次都没有。


基于这个现象,当时推测有两种可能,一种是内存溢出,一种是内存泄漏。


好,假设现在是面试,面试官目前就给了这点信息,他问你到底是溢出还是泄漏,你怎么回答?


在回答之前,我们得现明确啥是溢出,啥情况又是泄漏。



  • 内存溢出(OutOfMemoryError):内存溢出指的是程序请求的内存超出了 JVM 当前允许的最大内存容量。当 JVM 试图为一个对象分配内存时,如果当前可用的堆内存不足以满足需求,就会抛出 java.lang.OutOfMemoryError 异常。这通常是因为堆空间太小或者由于某些原因导致堆空间被占满。

  • 内存泄漏 (Memory Leak):内存泄漏是指不再使用的内存空间没有被释放,导致这部分内存无法再次被使用。虽然内存泄漏不会立即导致程序崩溃,但它会逐渐消耗可用内存,最终可能导致内存溢出。


虽然都与内存相关,但它们发生的时机和影响有所不同。内存溢出通常发生在程序运行时,当数据结构的大小超过预设限制时,常见的情况是你要分配一个大对象,比如一次从数据中查到了过多的数据。


而内存泄漏和“过多”关系不大,是一个细水长流的过程,一次内存泄漏的影响可能微乎其微,但随着时间推移,多次内存泄漏累积起来,最终可能导致内存溢出。


概念就是这个概念,这两个玩意经常被大家搞混,所以多嘴提一下。


概念明确了,回到最开始这个问题,你怎么回答?


你回答不了。


因为这些信息太不完整了,所以你回答不了。


面试的时候面试官就喜欢出这种全是错误选项的题目来迷惑你,摸摸你的底子到底怎么样。


首先,为什么不能判断,是因为前面说了:一次 FullGC 都没有。


虽然现在内存使用率已经到 80% 了,万一一次 FullGC 之后,内存使用率又下去了呢,说明程序没有任何问题。


如果没有下去,说明大概率是内存溢出了,需要去代码里面找哪里分配了大对象了。


那如果下去了,能说明一定没有内存泄漏吗?


也不能,因为前面又说了:内存泄漏是一个细水长流的过程。


关于内存溢出,如果监控手段齐全到位的话,你就记住左边这个走势图:



一个缓慢的持续上升的内存趋势图, 最后疯狂触发 GC,但是并没有内存被回收,最后程序直接崩掉。


内存泄漏,一眼定真假。


这个图来自我去年写的这篇文章:《虽然是我遇到的一个棘手的生产问题,但是我写出来之后,就是你的了。》


里面就是描述了一个内存泄漏的问题,通过分析 Dump 文件的方式,最终成功定位到泄漏点,修复代码。


一个不论多么复杂的内存泄漏问题,处理起来都是有方法论的。


不过就是 Dump 文件分析、工具的使用以及足够的耐心和些许的运气罢了。


所以我不打算赘述这些东西了,我想要分享的是我这次是怎么对应文章开始说的内存预警的。


我的处理方式就是:重启服务。


是的,常规来说都是会保留现场,然后重启服务。但是我的处理方式是:直接执行重启服务的预案。没有后续动作了。


我当时脑子里面的考虑大概是这样的。


首先,这个服务是一个边缘服务,它所承载的数据量不多,其业务已经超过一年多没有新增,存量数据正在慢慢的消亡。代码近一两年没啥改动,只有一些升级 jar 包,日志埋点这类的横向改造。


其次,我看了一下这个服务已经有超过四个月没有重启过了,这期间没有任何突发流量,每天处理的数据呈递减趋势,内存走势确实是一个缓慢上升的过程,我初步怀疑是有内存泄漏。


然后,这个服务是我从别的团队那边接手的一个服务,基于前一点,业务正在消亡这个因素,我也只是知道大概的功能,并不知道内部的细节,所以由于对系统的熟悉度不够,如果要定位问题,会较为困难。


最后,基于公司制度,虽然我知道应该怎么去排查问题,命令和工具我都会使用,但是我作为开发人员是没有权限使用运维人员的各类排查工具和排查命令的,所以如果要定位问题,我必须请求协调一个运维同事帮忙。


于是,在心里默默的盘算了一下投入产出比,我决定直接重启服务,不去定位问题。


按照目前的频率,程序正常运行四五个月后可能会触发内存预警,那么大不了就每隔三个月重启一次服务嘛,重启一次只需要 30s。一年按照重启 4 次算,也就是才 2 分钟。


这个业务我们就算它要五年后才彻底消亡,那么也就才 10 分钟而已。


如果我要去定位到底是不是内存泄露,到底在哪儿泄露的,结合我对于系统的熟悉程度和公司必须有的流程,这一波时间消耗,少说点,加起来得三五个工作日吧。


10 分钟和三五个工作日,这投入产出比,该选哪个,一目了然了吧?


我分享这个事情的目的,其实就是想说明我在这个事情上领悟到的一个点:在工作中,你遇到的问题,不是每一个都必须被解决的,也可以选择绕过问题,只要最终结果是好的就行。


如果我们抛开其他因素,只是从程序员的本职工作来看,那么遇到诸如内存泄漏的问题的时候,就是应该去定位问题、解决问题。


但是在职场中,其实还需要结合实际情况,进行分析。


什么是实际情况呢?


我前面列出来的那个“首先,其次,然后,最后”,就是我这个问题在技术之外的实际情况。


这些实际情况,让我决定不用去定位这个问题。


这也不是逃避问题,这是权衡利弊之后的最佳选择。


同样是一天的时间,我可以去定位这个“重启就能解决”的问题,也可以去做其他的更有价值事情,敲一些业务价值更大的代码。


这个是需要去权衡的,一个重要的衡量标准就是前面说的:投入产出比。


关于“不是所有的问题都必须被解决的,也可以选择绕过问题”这个事情,我再给你举一个我遇到的真实的例子。


几年前,我们团队遇到一个问题,我们使用的 RPC 框架是 Dubbo,有几个核心服务在投产期间滚动发布的时候,流量老是弄不干净,导致服务已经下线了,上游系统还在调用。


当时安排我去调研一下解决方案。


其实这就是一个优雅下线的问题,但是当时资历尚浅,我认真研究了一段时间,确实没研究出问题的根本解决方案。


后来我们给出的解决方案就是做一个容错机制,如果投产期间有因为流量不干净的问题导致请求处理失败的,我们把这些数据记录下来,然后等到投产完成后再进行重发。


没有解决根本问题,选择绕过了问题,但是从最终结果上看,问题是被解决了。


再后来,我们搭建了双中心。投产之前,A,B 中心都有流量,每次投产的时候,先把所有流量从 A 中心切到 B 中心去,在 A 中心没有任何流量的情况下,进行服务投产。B 中心反之。


这样,从投产流程上就规避了“流量老是弄不干净”的问题,因为投产的时候对应的服务已经没有在途流量了,不需要考虑优雅的问题了,从而规避了优雅下线的问题。


问题还是没有被解决,但是问题被彻底绕过。


最后,再举一个我在知乎上看到的一个回答,和我想要表达的观点,有异曲同工之妙:



http://www.zhihu.com/question/63…




这个回答下面的评论也很有意思,有兴趣的可以去翻一下,我截取两个我觉得有意思的:





在职场上,甚至在生活中,一个虽然没有解决方案但是可以被绕过的问题,我认为不是问题。


但是这个也得分情况,不是所有问题都能绕开的,假如是一个关键服务,那肯定不能置之不理,硬着头皮也得上。


关键是,我在职场上和生活中遇到过好多人,遇到问题的时候,似乎只会硬着头皮往上冲。


只会硬着头皮往上冲和知道什么时候应该硬着头皮往上冲,是两种截然不同的职场阶段。


所以有时候,遇到问题的时候,不要硬上,也让头皮休息一下,看看能不能绕过去。


作者:why技术
来源:juejin.cn/post/7417842116506058771
收起阅读 »

谁也别拦我们,网页里直接增删改查本地文件!

web
欢迎来到 Jax 的专栏「Web 玩转文件操作」,快来了解 Web 端关于文件操作的方方面面! 转载请联系作者 Jax。 先来玩玩这个 Demo —— 一个网页端的本地文件管理器。 在上面的 Demo 中,点击「打开文件夹」按钮后,你可以选择电脑本地的某个...
继续阅读 »

欢迎来到 Jax 的专栏「Web 玩转文件操作」,快来了解 Web 端关于文件操作的方方面面!


转载请联系作者 Jax。



先来玩玩这个 Demo —— 一个网页端的本地文件管理器


demo.gif


在上面的 Demo 中,点击「打开文件夹」按钮后,你可以选择电脑本地的某个文件夹来打开。然后就能对其中的文件做各种操作了,包括新建、右键打开、编辑、删除等等。你会发现,在网页上的操作会立刻在本地文件上实际生效。


如果你感觉是:”哟?有点儿意思!“,那么这篇文章就是专门为你而写的,读下去吧。


正所谓「边牧是边牧、狗是狗」,Web 应用早已不是当初的简陋网页了,它进化出了令人眼花缭乱的新能力,同时也在迅速追齐系统原生能力。如 Demo 所示,我们甚至可以把文件管理系统搬进网页,并且简单的 JavaScript 代码就能实现。Web 应用能有这样的威力,得益于浏览器对文件系统 API 的实现。


文件系统(File System Access)API 目前还处于社区草稿状态,但早在 2016 年,WHATWG 社区就已经开始着手设计这一标准,并且八年来一直在持续不断地打磨,迄今已经被各大主流浏览器所实现。


venders.jpeg


这套 API 足够简单易用,你很快就能掌握它,并且让它成为你的得力助手。但由于涉及到文件管理这个课题,它有很多方面都可以挖掘得很深很深,比如读写流、OPFS、安全策略等等。咱们这个专栏本着少吃多餐的原则,不会一次性囊括那么多内容。咱们的这第一铲,就先浅浅地挖一个「憨豆」 —— FileSystemHandle


FileSystemHandle


在文件系统中,文件和文件夹是一个个实体,是所有状态和交互的核心。相对应地,在文件系统 API 中,我们用「FileSystemHandle」这个对象来对实体进行抽象。为了能够简洁地描述,我们在下文中将称之为「憨豆」。我们用对象属性来保存实体的名称、类型,通过执行对象方法来操作实体。


那么 FileSystemHandle 从何而来呢?一般是从用户操作而来。以文件为例,当用户通过文件窗口选择了某个本地文件之后,我们就能从代码层面获取到通向这个文件的入口,从而可以对文件进行管理操作。除此之外,拖拽文件进入也能得到憨豆


属性:name 和 kind


name:无论是文件还是文件夹,必然都有一个名字。


kind:实体的类型,值为 ‘file’ 代表文件;值为 ‘directory’ 代表文件夹。


校验方法 isSameEntry()


用于判断两个憨豆是否代表相同的实体。例如用户上传图片时,先后两次选择的是同一张图片,那么两个憨豆指向的其实是同一个文件。


const [handle1] = await showOpenFilePicker() // 第一次上传
const [handle2] = await showOpenFilePicker() // 第二次选择了同一个文件

const isSame = await handle1.isSameEntry(handle2)
console.log(isSame) // true

该方法也同样适用于文件夹校验。


我们可以借此来检测重复性。


删除方法 remove()


用于删除实体。比如,执行下列代码会现场抽取一个幸运文件来删除:


const [handle] = await showOpenFilePicker()
handle.remove()

但如果我们想要选中文件夹来删除,像上面那样直接调用是会报错并删除失败的。我们需要额外传入配置参数:


handle.remove({ recursive: true })

传参后的删除,是对文件夹里面的嵌套结构进行递归删除,这个设计理念旨在让开发者和用户在删除前知悉操作后果。


权限方法 queryPermission() 和 requestPermission()


用于向用户查询和获取读写权限,可以传入一个参数对象来细分权限类型。


const permission = await handle.queryPermission({ mode: 'read' }) // mode 的值还可以是 readwrite
console.log(permission)// 若值为 'granted',则代表有足够权限

我们在对实体进行操作前,最好总是先查询权限。因此一个最佳实践是把这两个方法封装进通用操作逻辑中。


其他特性


除此之外,FileSystemHandle 还具有一些其他特性,比如可以转化为 indexDB 实例、可以通过 postMessage 传输到 Web Workers 里进行操作等等。我们会在后续的专栏文章中继续顺藤摸瓜地了解它们。


两个子类


到目前为止,FileSystemHandle 所具有的属性和方法都在上面了。你可能也意识到了,单靠这三招两式是不可能实现像 Demo 里那么丰富完备的文件操作的。


没错,这个憨豆只是一个抽象父类,它还有两个子类 FileSystemFileHandleFileSystemDirectoryHandle,这两位才是文件和文件夹实体的真正代言人。对实体的常规操作,几乎都是通过这两个紫憨豆来执行的。


除了从父类继承而来的基因,两个紫憨豆都各自具有独特的属性和方法,满足了开发者对文件和文件夹两种不同实体的不同操作需求。


FileSystemFileHandle


在本专栏的上一篇文章《绕过中间商,不用 input 标签也能搞定文件选择》中,我们曾与文件憨豆有过一面之缘。那时候,我们通过 showOpenFilePicker 获取了文件憨豆,并调用它的 getFile 方法拿到了 文件 Blob


此外,文件憨豆还具有的方法如下:



  • createSyncAccessHandle():用于同步读写文件,但是仅限于在 Web Workers 中。

  • createWritable:创建一个写入流对象,用于向文件写入数据。


FileSystemDirectoryHandle


文件夹憨豆的特有方法如下:



  • getDirectoryHandle():按名称查找子文件夹。

  • getFileHandle():按名称查找子文件。

  • removeEntry():按名称移除子实体。

  • resovle():返回指向子实体的路径。


经过上述罗列,我们对两种憨豆的能力有了一个大概的印象。下面,我们将回到 Demo 的交互场景,从具体操作上手,看看文件系统 API 是如何在 JavaScript 中落地的。


操作 & 用法


载入文件夹


我们首先需要载入一个文件夹,然后才能对其中的子实体进行操作。


如果你碰巧读过上一篇文章,知道如何用 showOpenFilePicker() 选择文件,那你一定能举一反三推断出,选择文件夹的方法是 showDirectoryPicker()


const dirHandle = await showDirectoryPicker()

showDirectoryPicker 方法也接收一些参数,其中 idstartIn 这两个参数与 showOpenFilePicker 方法 的同名参数完全对应。另外还支持一个参数 mode ,其值可以是 readreadwrite,用于指定所需的权限。


用户选择文件夹后得到的 dirHandle,就是一个 FileSystemDirectoryHandle 格式的对象。我们可以遍历出它的子实体:


for await (const sub of dirHandle.values()) {
const { name, kind } = sub
console.log(name, kind)
}

从子实体中取出名称和类别属性值,就可以对文件夹内容一目了然了。


读取文件内容


在上一步中,我们已经读取到了子实体的名字和类型,那么对于文件类型,我们可以先按名称检索到对应的憨豆:


// if sub.kind === 'file'
const fileHandle = await dirHandle.getFileHandle(sub.name)

再从文件憨豆中掏出文件 Blob,进一步读取到文件的内容:


const file = await fileHandle.getFile()
const content = file.text()

如果你用来调试的文件是文本内容的文件,那么打印 content 的值,你就可以看到内容文本了。


同理,获取子文件夹的方式是 dirHandle.getDirectoryHandle(sub.name)


新建文件、文件夹


除了指定名称参数,getFileHandlegetDirectoryHandle 这两个方法还支持第二个参数,是一个一个配置对象 { create: true/false },用于应对指定名称的实体不存在的情况。


例如,我们对一个文件夹实体执行 dirHandle.getFileHandle('fileA'),但该文件夹中并没有名为 fileA 的文件。此时第二个参数为空,等同于 create 的默认值为 false,那么此时会抛出一个 NotFoundError 错误,提示我们文件不存在。


而如果我们这样执行:dirHandle.getFileHandle('fileA', { create: true }),那么就会在当前文件夹中新建一个名为 fileA 的空文件。


同理,我们也可以用 dirHandle.getDirectoryHandle('dirA', { create: true }) 新建一个名为 dirA 的空文件夹。


在 Demo 中,我们实现了让用户在新建文件时自定义文件名,这其实是使用了 prompt 方法:


const fileName = prompt('请输入文件名')
await dirHandle.getFileHandle(fileName, { create: true })

在当下这个 AI 时代,Prompt 更多是以「LLM 提示词」被大家初识。在更早时候,它是浏览器实现的一个组件。其实这两种形态的含义是一致的,都是在人机交互中,从一端到另一端的输入输出流程。


编辑文件内容


刚刚我们读取了文件的内容,现在我们来对文件内容进行修改,然后再存回去。


我们已经能够通过 getFile() 方法拿到文本内容,那应该把内容放到哪里进行编辑呢?你有很多种选择:富文本编辑器、给 div 设置 contenteditable、唤起 VS Code…… 但本着最(能)小(用)原(就)则(行),我们还有更便捷的选项 —— prompt!


prompt() 方法也支持第二个参数,我们把文本内容传入,弹出弹窗后,你就会看到内容已经填在了输入框中,现在就可以随意编辑里面的字符了。


const file = await fileHandle.getFile()
const fileContent = await file.text()
const newContent = prompt('', fileContent) // newContent 即为修改后的文件内容

但是点击 Prompt 的确认按钮并不会让新内容自动写入文件,这里就需要用到上面提到的 createWritable 了。下面是一个完整的写入流流程:


const writable = await fileHandle.createWritable() // 对待修改文件开启写入流
await writable.write(newContent) // 把新内容写入到文件
await writable.close() // 关闭写入流

至此,新的内容就已经被保存到文件中了。你可以在 Demo 中再次右键、打开文件查看内容,你会发现确实读取到了修改后的内容。


文件重命名


修改文件名也是文件管理中的常规操作,文件系统 API 也提供了对应的方法来实现。可能手快的同学已经在尝试 fileHandle.rename() 方法了。但 API 中还真没有这个方法,我们其实是要用一个 move() 方法。惊不惊喜意不意外?


因为从底层视角看,重命名文件和移动文件的处理过程类型,都是需要先新建一个文件(使用新的命名或者放到新的位置),再把源文件的数据复制过去,最后把源文件删除。目前在 Web 端还没有更高效的处理方式。


我们只需从 Prompt 获取新名称,再传给 move() 方法即可:


const newName = prompt('请输入新的文件名')
await fileHandle.move(newName)

这样,文件重命名就搞定了。


删除文件、文件夹


删除实体就没有那么多幺蛾子了,我们甚至不用对文件和文件夹做逻辑上的区分,简单直接地调用 currentHandle.remove({ recursive: true }) 就行了。


但越是方便,就越要谨慎,因为涉及到删除用户的文件,所以如果要用于生产环境,最好给用户提供二次确认的机会。


写在结尾


恭喜你读完了本文,你真棒!


这次我们通过实现一个 Web 版文件管理器 demo,对文件管理 API 进行了较为深入的理解和实践。我再嘱咐两句:



  1. 涉及到操作用户文件,请务必谨慎。

  2. 为了保障安全性,文件系统 API 仅支持 https。



我是 Jax,在畅游 Web 技术领域的第 7 年,我仍然是坚定不移的 JavaScript 迷弟,Web 开发带给我太多乐趣。如果你也喜欢 Web 技术,或者想讨论本文内容,欢迎来聊!你可以通过下列方式找到我:


掘金:juejin.cn/user/113435…


GitHub:github.com/JaxNext


微信:JaxNext



作者:JaxNext
来源:juejin.cn/post/7416933490136252452
收起阅读 »

35 岁时我改掉的三个习惯

大家好,我是双越老师,wangEditor 作者。 我正在开发一个 Nodejs 全栈 AIGC 知识库项目 划水AI,包括富文本编辑器,多人协同编辑,AI 写作,AI 处理文本等。有兴趣的同学可以围观项目。 开始 虽然标题是 35 岁,但其实本文 202...
继续阅读 »

大家好,我是双越老师,wangEditor 作者。



我正在开发一个 Nodejs 全栈 AIGC 知识库项目 划水AI,包括富文本编辑器,多人协同编辑,AI 写作,AI 处理文本等。有兴趣的同学可以围观项目。



开始


虽然标题是 35 岁,但其实本文 2024 年我 36 岁时写的。36 岁总结 35 岁,也没毛病。


35 岁对于我们程序员来说是一个非常敏感的年龄,但我已过 35 ,看看往后会越来越敏感,还是越来越麻木?


本文和大家分享一下我个人在业余生活中的,我觉得很有意义的事情。甚至我觉得这些是事情的意义,都大于工作的成绩。


生活是一个整体,工作只是其中一部分,只是我们养家糊口的一个手段。工作以外的其他部分应该更加重要,例如业余爱好、饮食、休息、娱乐、家庭等。


1. 戒烟


我从大学毕业开始学着吸烟,到 35 岁已经有十几年的烟龄了。虽然每天吸烟量不大,但也断不了,有瘾。


我为什么要戒烟呢?


是觉得吸烟有害健康?—— 对,吸烟肯定有害健康,但人类天生是一种及时行乐的动物,谁会在乎 20 年 30 年以后的健康呢?人类天生是一个心存侥幸的动物(赌徒性质),也不是每个吸烟者都会有 xxx 病,对吧?之前看过一个段子,说某呼吸外科医生做完,累几个小时做完一台手术,先出去吸烟休息一下。


我戒烟,单纯就是想戒。我想摆脱烟草和尼古丁的控制,而且是想了很久了,不是一时突发奇想,之前就有充分的心理准备。


还有,我想在 35+ 的年纪做一点叛逆的事情,叛逆使人年轻,叛逆使人保持活力。年轻时吸烟是叛逆,年龄大了戒烟就是叛逆。年轻时叛逆是狂,年龄大了叛逆是帅。所以,各位大龄程序员,有机会要让自己叛逆起来,做一点帅的事情。


最后,当时 2023 年夏天,我正好不忙,天天闲着,总得找点事儿干。既然工作上不忙,那就在自己身上找点有意义事情做吧 —— 外求不得而向内求,如果没法从外面挣取,那就去提高自身。


烟瘾是什么?就是尼古丁的戒断反应,没有其他理由,没人其他任何事情会让你 1-2 小时就想一次,而且持续想。


关于烟草的本质,烟草的历史,烟草的商业化推广过程,烟草的洗脑广告…… 还有很多内容可以讲,本文就不展开了,有兴趣的可以留言咨询。我很早之前就看过,去学习这些,并且接受这些,才能更好的帮助戒烟。


所以,就这么戒了,到写作本文的时候,正好是戒了一年。我觉得这是我去年做过的最有价值的事情,比我工作挣的钱都有价值。


2. 戒酒


之前我是比较喜欢喝酒的,喜欢一帮人聚餐那种兴奋的状态。但后来喝多了就肚子难受,一躺下就想吐,于是决定不喝了。


有些人说:你可以少喝点。但在中国北方的酒桌上,只有 0 和 1 ,没有中间态。只要你喝了,一开始朋友劝你多喝点,再喝多一点就不用别人劝了,自己就开始主动找酒瓶子了。


我不懂酒,没喝过啥好酒,很少喝红酒。就日常喝的白酒和啤酒而言,我觉得都不好喝。


白酒,度数高,辣(尤其酱味的),容易醉。全世界就中国及其周边人喝白酒,国内几千的白酒没有国际市场。而且单就中国而言,白酒蒸馏技术几百年了,也只有最近这不到 100 年喝白酒。《红楼梦》上层人不喝白酒,《孔乙己》下层人也不喝白酒。


现在喝白酒的人,有两类:1. 被酒桌文化感染而顺从的人; 2. 有酒瘾想快速体验酒精的人。


啤酒,要分好多种,咱日常喝的瓶装的、桶装的,都可以统称为工业啤酒,像水一样,轻薄寡淡 —— 但它有酒精啊!


那种全麦啤酒(忘记名字了,不常喝)还是很好喝的,但价格较高,自己买点喝还行,聚餐喝那个太贵了(普通饭店也没有卖的),很少喝。


我身边也有一些朋友,每周都喝好几次,大部分是为了工作,拉拢客户关系。我觉得我还是比较幸运的,写写代码,改改 bug ,也不用太考虑人际关系。程序员的为数不多的好处。


3. 不看和自己无关的事情


我从不刷抖音(虽然我会发布一些抖音视频),但我之前很喜欢看今日头条 app ,每天闲了就刷一下,吃饭也看,睡前也看。


但是我都看了些啥呢?有一些是娱乐性的小视频,搞笑的,猎奇的,做饭吃饭的,我觉得这些很好,提供情绪价值。


其他的,什么俄 x 战争,什么国外 xxx 冲突,什么体育明星谁比谁厉害,什么传统武术,什么中医 …… 还有这些的评论,各路网友互怼。有时候看的都让人很带情绪,感觉有些人是不是傻,这么简单的道理看不明白?有些人是不是坏,不看事实,只看立场?


这些不仅不能提供情绪价值,反而会增加你的负面情绪。而且,根据《乌合之众》大众心理学研究,你只要参与了其中,你参与了互怼,你就会成为他们其中的一员,也变成傻子或坏人。围观,也是一种参与,你的心里会支持某一方。


更关键的是,这些事情我自己有关系吗?或者我的表态能决定这件事儿的走向吗?哪怕投一票决定一点点呢 —— 答案是显然的,要么没有任何关系,要么自己瞎操心。


所以,我卸载了今日头条 app ,不看了,眼不见心不烦,这些事情我不知道,也不影响我个人工作生活。从此以后,我觉得我的世界瞬间清净了,至少不会被那些负面情绪所打扰。


另外,我睡前也不看手机了,把手机扔在书房充电,直接睡觉。如果偶尔失眠或想事情,那就想,也不用非得拿着手机想,对吧。


总结


35 岁是一个里程碑和转折点,工作上如此,生活中也是如此。程序员是一个相对来说比较“单纯”的群体,我觉得更应该关注个人生活中的成长,共勉,加油~


作者:前端双越老师
来源:juejin.cn/post/7417630844100247590
收起阅读 »

BOE(京东方)携故宫博物院举办2024“照亮成长路”公益项目落地仪式以创新科技赋能教育可持续发展

2024年9月20日,BOE(京东方)“照亮成长路”智慧教室落成暨百堂故宫传统文化公益课山西活动落地仪式在山西省太原市娄烦县实验小学隆重举行。自“照亮成长路”教育公益项目正式设立以来,BOE(京东方)持续以创新科技赋能偏远地区的教育升级,随着2024年捐赠的2...
继续阅读 »

2024年9月20日,BOE(京东方)“照亮成长路”智慧教室落成暨百堂故宫传统文化公益课山西活动落地仪式在山西省太原市娄烦县实验小学隆重举行。自“照亮成长路”教育公益项目正式设立以来,BOE(京东方)持续以创新科技赋能偏远地区的教育升级,随着2024年捐赠的23间智慧教室全面圆满竣工并正式投入使用,BOE(京东方)捐建的智慧教室总数已达126间,它们不仅代表了教育创新、文化传承与先进技术的融合,也开启了BOE(京东方)面向新三十年发展征程、积极践行企业社会责任的新起点。

故宫博物院作为“照亮成长路”公益项目的重要合作伙伴,一直致力于促进中华优秀传统文化在青少年中的普及与传播。尤其是与京东方科技集团共同发起的“百堂故宫传统文化公益课”项目是故宫博物院教育推广的又一次重要实践。项目启动后近一年间已为26所学校,2万余名学生送去了400余场线上公益课程。此次落地的山西娄烦实验小学和静乐君宇中学也成为该计划的线下落地试点学校。活动现场,娄烦县委副书记、县长景博,娄烦县委常委、常务副县长任瑛,娄烦县委常委、副县长李学斌,静乐县副县长许龙平,中国乡村发展基金会副秘书长丁亚冬,故宫博物院副院长朱鸿文,京东方科技集团执行副总裁、艺云科技董事长姚项军,京东方科技集团副总裁、首席品牌官司达等出席了本次仪式,共同见证这一重要时刻。

在活动现场,京东方科技集团执行副总裁姚项军表示:“教育数字化是推进教育现代化的关键力量。BOE(京东方)充分发挥自身在物联网创新领域的专长,通过首创的多项类纸护眼显示技术,制定的低蓝光健康显示技术国际标准,推出了一系列智慧校园产品与服务;同时还充分发挥企业产业优势,开发科学与工程教育产品,用科学创新实践支持做公益。BOE(京东方)将携手各界同仁开启‘照亮成长路’教育公益项目的下一个十年篇章,继续推动教育与科技的深度融合,迈向一个更加智慧、更加光明、更加美好的未来!”

中国乡村发展基金会副秘书长丁亚冬在致辞中表示:“BOE(京东方)是我们多年的合作伙伴,持续关注乡村数字化教育的发展,携手实施的‘照亮成长路’教育公益项目已改造完成126间智慧教室,用科技力量助力消弭教育鸿沟,照亮乡村学生的成长之路。未来,我们将继续与BOE(京东方)、故宫博物院及社会各界通力合作,充分发挥各自优势,着力推进教育公平,促进教育均衡发展,为助力全面乡村振兴做出不懈努力。”

故宫博物院副院长朱鸿文在致辞中表示:“故宫博物院作为中华民族五千多年文明的重要承载者、中华优秀传统文化的汇聚地,始终将传承弘扬中华优秀传统文化作为己任,不断探索创新,希望通过丰富多彩的博物馆教育项目,将中华优秀传统文化传递给广大观众。很高兴能够携手京东方这样的科技企业通过传统文化振兴乡村发展,在以科技赋能偏远地区提升数字化水平的基础上,融入传统文化教育,增强师生文化自信,建设文化强国,助力中华民族伟大复兴。”故宫博物院也在活动中向学校的全体师生赠送了《我要去故宫》系列图书。

本次活动上,中国乡村发展基金会副秘书长丁亚冬,京东方科技集团副总裁、首席品牌官司达,娄烦县委副书记、县长景博,静乐县副县长许龙平,故宫博物院社会教育部主任吕晓刚,艺云科技智慧校园事业部总经理李慧军,共同为2024年“照亮成长路”项目新建的23间智慧教室举行了揭牌仪式。仪式结束后,故宫博物院社教人员还在新落成的智慧教室中,通过生动有趣的互动式教学,将故宫蕴含的中华优秀传统文化展现给孩子们,课堂上孩子们积极与老师交流,并动手制作多种手工材料包,获得了一份来自故宫的珍贵文化礼物。同时,BOE(京东方)志愿者也为孩子们带来了生动有趣的科学实践课,通过讲解屏幕显示的原理,让孩子们充分了解屏幕背后的技术知识,感受显示科技的精妙;此外,还设置了小组实践环节,模拟工厂流水线,让孩子们合作组装屏幕像素模拟装置,在动手中加深对知识的理解,在体验中收获知识,在实践中收获成长。

2024 BOE(京东方)“照亮成长路”教育公益项目的成功落地与本次活动的顺利举办,得益于山西省娄烦县政府和故宫博物院的大力支持,也离不开中国乡村发展基金会在项目推进过程中的通力合作。此次活动过程中,各方领导嘉宾围绕科技文化在教育领域的融合应用、智慧教育的未来趋势以及公益事业的长足发展进行了深入探讨。娄烦县政府相关领导作为代表对BOE(京东方)“百所校园”的公益新里程表示肯定与祝贺,并祝愿“照亮成长路”及“百堂故宫传统文化公益课”在未来能够惠及更多校园,助力更多偏远地区师生了解优秀传统文化、体验智慧教育。

作为一家全球化的科技公司,BOE(京东方)坚持Green+、Innovation+、Community+可持续发展理念,在教育、文化、健康等领域积极开展公益活动,通过引领绿色永续发展、持续驱动科技创新、赋能整个产业和社会。其中,“照亮成长路”是BOE(京东方)2014年启动的教育公益项目,通过智慧教室建设、教育资源融合、教师赋能培训计划等,携手社会各界力量,将技术融入社区公益发展与乡村振兴事业。目前,BOE(京东方)已在全国8大省市地区建成126间智慧教室,为63500余名师生提供软硬融合的智慧教育解决方案和教师赋能计划,切实帮助偏远地区学生群体获得更优质的教育和成长机会,在缩小城乡间数字差距、推动区域教育现代化、促进社会全面进步方面彰显了重要价值。

作为“照亮成长路”的特色项目,“百堂故宫传统文化公益课”让偏远地区的孩子能够通过BOE(京东方)的智慧教育创新技术跨越时间和空间的限制,近距离感受故宫的魅力,了解中国传统文化的精髓。接下来,更多课程将陆续在更为广泛的偏远地区展开,到2025年故宫博物院建院百年之际,双方将联手在北京故宫博物院为孩子们带来第100堂特别课程。

未来,BOE(京东方)与故宫博物院也将继续携手,以科技和文化双重赋能教育,让知识的光芒照亮每一个孩子的未来。

收起阅读 »

独家授权!广东盈世获网易邮箱反垃圾服务的独家授权,邮件反垃圾更全面

近日,广东盈世计算机科技有限公司(以下简称“Coremail”)成功获得了网易(杭州)网络有限公司(以下简称“网易”)授予的网易邮箱反垃圾服务独家使用权。这一授权使得Coremail能够在邮件安全产品上运用网易邮箱反垃圾服务,进一步强化其反垃圾能力,邮件反垃圾...
继续阅读 »

近日,广东盈世计算机科技有限公司(以下简称“Coremail”)成功获得了网易(杭州)网络有限公司(以下简称“网易”)授予的网易邮箱反垃圾服务独家使用权。这一授权使得Coremail能够在邮件安全产品上运用网易邮箱反垃圾服务,进一步强化其反垃圾能力,邮件反垃圾更全面。

凭借24年的反垃圾反钓鱼技术沉淀,Coremail邮件安全致力于提供一站式邮件安全解决方案,为用户提供安全、可靠的安全解决方案。而网易作为国内邮箱行业的佼佼者,拥有强大的技术实力和丰富的经验,其网易邮箱反垃圾服务更是享有盛誉。

通过合作,网易为Coremail提供Saas在线网关服务,进行进信和外发的在线反垃圾检测。Coremail邮件安全反垃圾服务将以自研反垃圾引擎为主,网易反垃圾服务为辅,以“双引擎”机制保障用户享有最高等级的邮件反垃圾服务。

此外,除网易自身、广州网易计算机系统有限公司及其关联公司外,Coremail是唯一被授权在服务期内独家使用网易邮箱反垃圾服务的公司。这一独家授权充分体现了网易对Coremail的高度认可和信任,同时也彰显了Coremail在邮件安全领域的卓越实力。

438d0df1a5824e3eb3cec6ffdfd1e1e9.jpg

此次独家授权,为Coremail带来更多的技术优势和市场竞争优势,进一步巩固其在邮件安全领域的领先地位。同时,对于广大用户来说,这也意味着用户将能够享受到更加安全、高效的邮件安全服务。

未来,Coremail将继续秉持技术创新的精神,致力于为用户提供更优质、安全、智能的邮件安全服务。与此同时,Coremail也将与网易保持紧密的合作关系,为企业的邮件安全保驾护航。

收起阅读 »

微信小程序避坑scroll-view,用tween.js实现吸附动画

web
背景 在开发一个小程序项目时,遇到一个需要列表滚动,松手时自动吸附的动画需求,如下(最终效果): 很自然用了scroll-view组件,但使用过程中发现scroll-view 里包含'position: fixed;top:0;'的元素时,应用scroll-...
继续阅读 »

背景


在开发一个小程序项目时,遇到一个需要列表滚动,松手时自动吸附的动画需求,如下(最终效果):


吸附动画.gif


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


问题.gif


于是决定不用组件内置的scroll-with-animation,改用手动控制scroll-top实现吸附的效果。


思路


通常,要做动画,我们就得确定以下信息,然后用代码实现:



  • 初始状态

  • 结束状态

  • 动画时长

  • 动画过程状态如何变化(匀速/先加速后减速/...)


这四个信息一般从UI/交互那里确认,前三个代码实现很简单,第四个在css动画里(如transition/animation)用 timing-function 指定:


image.png


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


image.png


而如果需要动画的状态不是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


导入适配


下载 github.com/tweenjs/twe… 文件,修改下‘now’的实现,把performance.now()改成Date.now() 即可在小程序里使用:


image.png


动画循环


小程序里没有直接支持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
收起阅读 »

前端滑块旋转验证登录

web
效果图如下 实现: 封装VerifyImg组件 <template> <el-dialog v-model="dialogShow" width="380px" top="24vh" class="verifyDialog"> ...
继续阅读 »
效果图如下

效果.gif


实现: 封装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(){
...
}

作者:点赞侠01
来源:juejin.cn/post/7358004857889275958
收起阅读 »

API接口超时,网络波动,不要一直弹Alert了!

web
前言前段时间,我司的IoT平台的客户大量上新了一堆设备后,在使用过程中,出现了网络连接超时,服务器错误等问题,客户小题大做,抓着这个尾巴不放手,喷我司系统不健壮,要求我们杜绝API错误。由于IoT多是日志、设备、测试、Action、图表、通信类的功能,API请...
继续阅读 »

前言

前段时间,我司的IoT平台的客户大量上新了一堆设备后,在使用过程中,出现了网络连接超时服务器错误等问题,客户小题大做,抓着这个尾巴不放手,喷我司系统不健壮,要求我们杜绝API错误

由于IoT多是日志、设备、测试、Action、图表、通信类的功能,API请求&返回数据量大、时间长。在后端添加索引的情况下,接口仍然达不到秒级,接口普遍都是1~3s,部分接口甚至达到小10s。我司把服务器的硬盘和CPU都进行了一波升级,效果都不是很理想。

这个烫手的山芋,落在了前端的头上。我们被迫进行了一波系统升级优化

解决方案

我们结合这个需求,制定了以下几条标准:

  1. 不能入侵其他的功能
  2. 对系统的破坏尽可能的小
  3. 杜绝或者尽可能的减少弹框问题
  4. 保证数据的正确展示,对于错误要正确的暴露出来

根据以上几条标准,于是方案就自然的确定了:

API请求时间

拉长API的请求时间,将超时时间由30s,更新为60s

const service = axios.create({
baseURL: '/xxx',
timeout: 60 * 1000 // 请求超时时间
})

重发机制

  1. API请求超时: 当新页面大量接口请求,某个接口请求时间过长,总时间>60s时,我们会对这个接口进行至多重发3次,用180s的时间去处理这个接口,当请求成功后,关闭请求

重发的接口不能超过timeout时长,否则一直会重发失败。也可以自定义重发的时间

  1. 偶发的服务器异常: 当接口出现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

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次后停止。

比如: 监听高低电平的变化 - 如快递柜的打开&关闭。

  1. 一直轮询的请求:

    • 使用WebSocket
    • 连续失败N次后,谈框。
  2. 轮询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

节流机制

  1. 用户连续多次请求同一个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();
}

404

else if (error.toString().indexOf("404") !== -1) {
// 404
}

401

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();
}
}

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 {
// 未知错误,等待以后解析
}

总结

结果将状态码梳理后,客户基本看不到API错误了,对服务的稳定性和可靠性非常满意,给我们提出了表扬和感谢。我们也期待老板升职加薪!

参考资料


作者:高志小鹏鹏
来源:juejin.cn/post/7413187186131533861

收起阅读 »

为什么2.01 变成了 2.00 ,1分钱的教训不可谓不深刻

前些日子,测试提过来一个bug,说下单价格应该是 2.01,但是在订单详情中展示了2.00元。我头嗡的一下子,艹,不会是因为double 的精度问题吧~果不其然,经过排查代码,最终定位原因订单详情展示金额时,使用double 进行了金额转换,导致金额不准。我马...
继续阅读 »

前些日子,测试提过来一个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-1000.004秒
0-10.002秒

总结

涉及金额转换的 地方,一定要小心处理,防止出现精度丢失问题。可以使用代码审查工具,查看代码中是否存在使用double 进行金额转换的代码, 同时提供 金额转换工具类。


作者:五阳
来源:juejin.cn/post/7399985723673837577
收起阅读 »

Video.js:视频播放的全能解决方案

web
大家好,我是徐徐。今天跟大家分享一款多媒体类前端工具库:Video.js。前言在现代网页开发中,视频播放功能已经成为用户体验的一个重要组成部分。无论你是开发一个视频分享平台还是一个简单的博客,选择合适的视频播放器都至关重要。今天,我们要介绍的 Vid...
继续阅读 »

大家好,我是徐徐。今天跟大家分享一款多媒体类前端工具库:Video.js。

前言

在现代网页开发中,视频播放功能已经成为用户体验的一个重要组成部分。无论你是开发一个视频分享平台还是一个简单的博客,选择合适的视频播放器都至关重要。今天,我们要介绍的 Video.js 是一个强大且灵活的 HTML5 视频播放器,它能够满足你对视频播放的所有需求。

基本信息

什么是 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,只需以下简单步骤:

  1. 引入 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>

  1. 添加视频播放器元素
<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>
  1. 初始化播放器
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
收起阅读 »

文档协同软件是如何解决编辑冲突的?

web
前言 本文将介绍在线协同文档编辑器是如何解决冲突的,大部分公司在解决冲突上目前用的都是 OT 算法,与之对应,也有一个 CRDT 算法实现。接下来,我们将深入这两种算法的实现及原理。 解决冲突的方案 在线协同文档编辑器通常使用不同的算法来解决冲突,具体算法取决...
继续阅读 »

前言


本文将介绍在线协同文档编辑器是如何解决冲突的,大部分公司在解决冲突上目前用的都是 OT 算法,与之对应,也有一个 CRDT 算法实现。接下来,我们将深入这两种算法的实现及原理。


解决冲突的方案


在线协同文档编辑器通常使用不同的算法来解决冲突,具体算法取决于编辑器的实现和设计。以下是一些常见的解决冲突的算法:



  1. OT(Operational Transformation,操作转换):这是一种常见的解决冲突的算法,用于实现实时协同编辑。OT算法通过将用户的编辑操作转换为操作序列,并在多个用户同时编辑时进行操作转换,以确保最终的文档状态一致。

  2. CRDT(Conflict-free Replicated Data Type,无冲突复制数据类型):这是一种基于数据结构的解决冲突的算法,它允许多个用户在不同的副本上进行并发编辑,并最终将编辑结果合并为一致的文档状态。CRDT算法通过设计特定的数据结构和操作规则来实现冲突自由的复制。


这些算法都有各自的优缺点,并且在不同的场景和需求下可能更适合不同的编辑器实现。


接下来,我们先聊聊 OT 算法。


OT 算法


image.png


当多个用户同时编辑同一文档时,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?" 的操作。


操作转换的过程如下:



  1. 用户 A 的操作 "insert(" How are you?")" 在用户 B 的操作 "insert(" I'm fine.")" 之前发生,因此用户 B 的操作不会受到影响。

  2. 用户 B 的操作 "insert(" I'm fine.")" 在用户 A 的操作 "insert(" How are you?")" 之后发生,因此用户 B 的操作需要向后移动。

  3. 用户 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 算法


image.png


当多个用户同时编辑同一文档时,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算法中,合并的过程是通过比较标记的大小来确定字符的顺序,并确保最终的文本状态一致。


合并的过程如下:



  1. 用户 A 的操作序列中的标记小于用户 B 的操作序列中的标记,因此 "H" 在 "W" 之前。

  2. 用户 A 的操作序列中的标记小于用户 B 的操作序列中的标记,因此 "e" 在 "o" 之前。

  3. 用户 A 的操作序列中的标记小于用户 B 的操作序列中的标记,因此 "l" 在 "r" 之前。

  4. 用户 A 的操作序列中的标记小于用户 B 的操作序列中的标记,因此 "l" 在 "l" 之前。

  5. 用户 A 的操作序列中的标记小于用户 B 的操作序列中的标记,因此 "o" 在 "d" 之前。


最终,合并后的有序列表为 "HelloWorld"。


这个例子展示了CRDT算法如何通过设计特定的数据结构和操作规则,以及比较标记的大小来实现冲突自由的复制。在实际应用中,CRDT算法可以应用于各种数据结构和操作类型,并进行更复杂的合并过程。


CRDT 的标记实现方案



  1. 递增整数标记:最简单的标记实现方式是使用递增的整数作为标记。每个操作都会分配一个唯一的整数标记,标记的大小可以用来比较操作的发生顺序。

  2. 时间戳标记:另一种常见的标记实现方式是使用时间戳作为标记。每个操作都会被分配一个时间戳,可以使用系统时间或逻辑时钟来生成时间戳。时间戳可以用来比较操作的发生顺序。

  3. 向量时钟标记:向量时钟是一种用于标记并发事件的数据结构。每个操作都会被分配一个向量时钟标记,向量时钟由多个节点的时钟组成,每个节点维护自己的逻辑时钟。向量时钟可以用来比较操作的发生顺序,并检测并发操作之间的关系。

  4. 哈希值标记:有些情况下,可以使用操作内容的哈希值作为标记。每个操作的内容都会被哈希为一个唯一的标记,这样可以通过比较哈希值来比较操作的发生顺序。


方案选型


OT算法和CRDT算法都是用于实现实时协同编辑的算法,但它们有不同的优点、缺点和工作原理。


OT算法的优点:



  1. 简单性:OT算法相对较简单,易于理解和实现。

  2. 实时协同编辑:OT算法可以实现实时协同编辑,多个用户可以同时编辑文档,并通过操作转换保持文档状态的一致性。


OT算法的缺点:



  1. 操作转换复杂性:OT算法的操作转换过程相对复杂,需要考虑多种情况和操作类型,实现和测试上有一定的挑战。

  2. 需要服务器参与:OT算法需要中央服务器来处理操作转换,这可能会导致一定的延迟和依赖性。


CRDT算法的优点:



  1. 冲突自由:CRDT算法设计了特定的数据结构和操作规则,可以实现冲突自由的复制,多个用户同时编辑时不会发生冲突。

  2. 去中心化:CRDT算法可以在去中心化的环境中工作,不依赖于中央服务器进行操作转换,每个用户可以独立地进行编辑和合并。


CRDT算法的缺点:



  1. 数据结构复杂性:CRDT算法的数据结构和操作规则相对复杂,需要更多的设计和实现工作。

  2. 数据膨胀:CRDT算法可能会导致数据膨胀,特别是在复杂的编辑操作和大规模的协同编辑场景下。


OT算法和CRDT算法的区别:



  1. 算法原理:OT算法通过操作转换来保持文档状态的一致性,而CRDT算法通过设计特定的数据结构和操作规则来实现冲突自由的复制。

  2. 中心化 vs. 去中心化:OT算法需要中央服务器进行操作转换,而CRDT算法可以在去中心化的环境中工作,每个用户都有完整的本地数据,可以将操作发送给服务器,然后网络分发到其他客户端。

  3. 复杂性:OT算法相对较简单,但在处理复杂操作和转换时可能会变得复杂;CRDT算法相对复杂,需要更多的设计和实现工作。


选择使用哪种算法取决于具体的应用场景和需求。OT算法适用于需要实时协同编辑的场景,而CRDT算法适用于去中心化和冲突自由的场景。


总结


本文介绍了在线协同文档编辑器中解决冲突的算法,主要包括OT算法和CRDT算法。OT算法通过操作转换来保持文档状态的一致性,而CRDT算法通过设计特定的数据结构和操作规则实现冲突自由的复制。OT算法需要中央服务器进行操作转换,适用于需要实时协同编辑的场景。CRDT算法可以在去中心化环境中工作,每个用户都有完整的本地数据,适用于去中心化和冲突自由的场景。选择使用哪种算法取决于具体的应用场景和需求。


作者:谦宇
来源:juejin.cn/post/7283018190593785896
收起阅读 »

audio自动播放为什么会失败

web
背景 某天客户报了一个问题,说是大屏的声音不能自动播放了,我们的大屏应用是有个报警的,当有报警的时候,自动会播放警报的声音 复线步骤 测试后发现如下结论 当刷新页面后,audio不会自动播放 当从另外的一个页面进入到当前页面,可以直接播放声音 如果你想测试...
继续阅读 »

背景


某天客户报了一个问题,说是大屏的声音不能自动播放了,我们的大屏应用是有个报警的,当有报警的时候,自动会播放警报的声音


复线步骤


测试后发现如下结论



  1. 当刷新页面后,audio不会自动播放

  2. 当从另外的一个页面进入到当前页面,可以直接播放声音


如果你想测试,可以点我进行测试


你可以先点击上方链接的 尝试一下 ,下方为截图


image.png


这个时候你会听到一声马叫声


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


image.png


报错问题排查


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


image.png


翻译为中文的意思为
不允许的错误。播放失败,因为用户没有先与文档交互。goo.gl/xX8pDD


尝试解决


那我给通过给body添加点击事件,自动触发点击事件,在点击的事件后自动播放声音。
(当是我的想法是,这个大概率是不行的,chrome应该不会忽略这一点,不然这个功能就相当于不存在)


经过测试后,发现确实还不行,在意料中。


参考别人的网站,用抖音测试


点击我跳转抖音


想到了我们可以参考抖音,我用抖音的进行测试,当你不做任何的操作,页面应该如下
image.png


我们从这里得出结论,这个应该是浏览器的限制,需要查看官方文档,看看原因


查阅官方文档


点我查看chrome的官方文档


我截取了一些关键的信息


image.png


注意浏览器会有一个媒体互动指数,这是浏览器自动计算的,该分越高,才会触发自动播放


查看电脑的媒体互动指数


在url上输入 about://media-engagement,你会看到如下的截图,


image.png


经过测试后 当网站变成了is High,音频会自动播放,不会播放失败。


这就解释了为什么有的网站可以自动播放声音,有的网站不可以自动播放声音


ok,我们继续往下看,这个时候看到了一些关键的信息。


作为开发者,我们不应该相信音频/视频会播放成功,要始终在播放的回掉中来进行判断


image.png


看到这些,我们来模仿抖音的实现.在播放声音的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>",
});
}
}
});

实现效果如下


image.png


总结



  1. 在用到video或者audio的时候,要始终不相信他会播放声音成功,并且添加catch处理异常场景,给用户友好的提示

  2. video或者audio的自动播放跟媒体互动指数有关(MEI),当媒体指数高,会自动播放,否则需要用户先交互后,audio才可以自动播放。

  3. 从一个页面window.open另外一个页面可以自动播放声音,当刷新页面后,需要有高的MEI,audio才会自动播放,如果你需要在后台打开一个大屏的页面,刚好可以这样设计,不要用页面跳转


作者:pauldu
来源:juejin.cn/post/7412505754383007744
收起阅读 »

我的 Electron 客户端也可以全量/增量更新了

前言 本文主要介绍 Electron 客户端应用的自动更新,包括全量和增量这两种方式。 全量更新: 运行新的安装包,安装目录所有资源覆盖式更新。 增量更新: 只更新修改的部分,通常是渲染进程和主进程文件。 本文并没有拿真实项目来举例子,而是起一个 新的项目 从...
继续阅读 »

前言


本文主要介绍 Electron 客户端应用的自动更新,包括全量和增量这两种方式。


全量更新: 运行新的安装包,安装目录所有资源覆盖式更新。


增量更新: 只更新修改的部分,通常是渲染进程和主进程文件。


本文并没有拿真实项目来举例子,而是起一个 新的项目 从 0 到 1 来实现自动更新。


如果已有的项目需要支持该功能,借鉴本文的主要步骤即可。


前置说明:



  1. 由于业务场景的限制,本文介绍的更新仅支持 Windows 操作系统,其余操作系统未作兼容处理。

  2. 更新流程全部由前端完成,不涉及到后端,没有轮询 更新的机制。

  3. 发布方式限制为 generic,线上服务需要配置 nginx 确保访问到资源文件。


准备工作


脚手架搭建项目


我们通过 electron-vite 快速搭建一个基于 Vite + React + TSElectron 项目。


1.jpg


该模板已经包括了我们需要的核心第三方库:electron-builderelectron-updater


前者是用来打包客户端程序的,后者是用来实现自动更新的。


在项目根目录下,已经自动生成了两份配置文件:electron-builder.ymldev-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


image.png


模拟服务器


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


image.png


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


image.png


因此我们在开发调试的时候,需要起一个本地服务器,来模拟真实的情况。


新建一个文件夹 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. 发现新版本
GIF 2024-9-19 16-48-44.gif


2. 无需更新


image.png


增量更新


为什么要这么做


其实全量更新是一种比较简单粗暴的更新方式,我没有花费太大的篇幅去介绍,基本上就是使用 autoUpdater 封装的一些方法,开发过程中更多的注意力则是放在了渲染进程的交互上。


此外,我们的更新交互用的是一种 用户感知 的方式,即更不更新是由用户自己决定的。而 静默更新 则是用户无感知,在监测到更新后不用去通知用户,而是自行在后台下载,在某个时刻执行安装的逻辑,这种方式往往是一键安装,不会弹出让用户去选择安装路径的窗口。接下去要介绍的增量更新其实是这种 用户无感知 的形式,只不过我们更新的不是整个应用程序。


由于产品迭代比较频繁,我们的业务方经常收到更新提示,意味着他们要经常手动更新应用,以便使用到最新的功能特性。众所周知,Electron 给前端开发提供了一个比较容易上手的客户端开发解决方案,但在享受 跨平台 等特性的同时,还得忍受 臃肿的安装包


带宽、流量在当前不是什么大问题,下载和安装的速度也挺快,但这频繁的下载覆盖一模一样的资源文件还是挺糟心的,代码改动量小时,全量更新完全没有必要。我们希望的是,开发更新了什么代码,应用程序就替换掉这部分代码就好了,这很优雅。在 我们的场景 中,这部分代码指的是 —— main、preload、renderer,并不包括 dll第三方SDK等资源。


网上有挺多种增量更新的 解决方案,例如:



  1. 通过 win.loadURL(一个线上地址) 实现,相当于就套了一层客户端的壳子,与加载的Web 端同步更新。这种方法对于简单应用来说是最省心的,但是也有一定的局限性,例如不能使用 node 去操作一些底层的东西。

  2. 设置 asar 的归档方式,替换app.asarapp.asar.unpack来实现。但后者在我实践过程中存在文件路径不存在的问题。

  3. 禁用 asar 归档,解压渲染进程压缩包后实现替换。简单尝试过,首次执行安装包的时候写入文件很慢,不知道是不是这个方式引起的。

  4. 欢迎补充。


本文我们采用较普遍的 替换asar 来实现。


优化 app.asar 体积



asar是 Electron提供的一种将多个文件合并成一个文件的类 tar 风格的归档格式,不仅可以缓解Windows下路径名过长的问题, 还能够略微加快一下 require的速度, 并且可以隐藏你的源代码。(并非完全隐藏,有方法可以找出来)



Electron 应用程序启动的时候,会读取 app.asar.unpacked 目录中的内容合并到 app.asar 的目录中进行加载。在此之前我们已经打包了一份 Demo 项目,安装到本地后,我们可以根据安装路径找到 app.asar 这个文件。


例如:D:\你的安装路径\electron-update-demo\resources


image.png


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


npm i -g asar // 全局安装

asar e app.asar folder // 解压到folder文件夹

image.png


解压后我们不难发现,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 在打包时会以该文件夹作为打包的根文件夹,即只会打包这个文件夹下的文件。


但是我原项目的主进程和预加载脚本也放在这,尝试修改后出现打包错误的情况,感兴趣的可以试试


image.png


这是我们优化之后的结果,1.32 MB,后面只需要替换这个文件就实现了版本的更新。


校验增量更新


全量更新的校验逻辑是第三方库已经封装好了的,那么对于增量更新,就得由我们自己来实现了。


首先明确一下 校验的时机package.jsonversion 字段表示应用程序的版本,用于全量更新的判断,那也就意味着,当这个字段保持不变的时候,会触发上文提到的 update-not-available 事件。所以我们可以在这个事件的回调函数里来进行校验。


autoUpdater.on('update-not-available', info => { console.log('不需要全量更新', info.version) })

然后就是 如何校验,我们回过头来看 electron-builder 的打包配置,在 releaseInfo 字段里描述了发行版本的一些信息,目前我们用到了 releaseNotes 来存储更新日志,查阅官网得知还有个 releaseName 好像对于我们而言是没什么用的,那么我们就可以把它作为增量更新的 热版本号。(但其实 官网 配置项还有个 vendor 字段,可是我在用的时候打包会报错,可能是版本的问题,感兴趣的可以试试。)


每次发布新版本的时候,只要不是 Electron自身版本变化 等重大更新,我们都可以通过修改 releaseInforeleaseName 来发布我们的热版本号。现在我们已经有了线上的版本,那 本地热版本号 该如何获取呢。这里我们在每次热更新完成之后,就会把最新的版本号存储在本地的一个配置文件中,通常是 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 文件夹里来模拟真实的情况。有很多方式可以去下载文件,这里我用了 nodejshttp 模块去实现,如果是 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.asarresources 目录,并重命名为 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文件。


image.png


至此,下载这一步已经完成了,但这样还会有一个问题,因为文件名没有加上版本号,也没有重复性校验,倘若一直没有更新,那么每次启动应用需要增量更新的时候都需要再次下载。


替换 app.asar 文件


好了,新文件也有了,接下来就是直接替换掉老文件就可以了,但是 替换的时机 很重要。


Windows 操作系统下,直接替换 app.asar 会提示程序被占用而导致失败,所以我们应该在程序关闭的时候实现替换。



  1. 进入应用,下载新版本,自动关闭应用,替换资源,重启应用。

  2. 进入应用,下载新版本,不影响使用,用户 手动关闭 后替换,下次启动 就是新版本了。


我们有上面两种方案,最终采用了 方案2


在主进程监听 app.on('quit') 事件,在应用退出的时候,判断 app.asarapp.asar-temp 是否同时存在,若同时存在,就去替换。这里替换不能直接用 nodejs 在主进程里去写,因为主进程执行的时候,资源还是被占用的。因此我们需要额外起一个 子进程 去做。


nodejs 可以通过 spawnexec 等方法创建一个子进程。子进程可以执行一些自定义的命令,我们并没有使用子进程去执行 nodejs,因为业务方的机器上不一定有这个环境,而是采用了启动 exe 可执行文件的方式。可能有人问为什么不直接运行 .bat 批处理文件,因为我调试关闭应用的时候,会有一个 命令框闪烁 的现象,这不太优雅,尽管已经设置 spawnwindowsHide: 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]
})

但这块有个 疑惑,为什么我的 closeexit 以及 stdout 都没有触发,以至于不能拿到命令是否执行成功的最终结果,了解的同学可以评论区交流一下。


至此,在关闭应用之后,app.asar 就已经被替换为最新版本了,还差最后一步,更新本地配置文件里的 hotVersion,防止下次又去下载更新包了。


child.on('spawn', () => {
log.info('子进程触发')
updateHotVersion()
})

updateHotVersion() {
fs.writeFileSync(localConfigFile, JSON.stringify({ hotVersion }, null, 2))
}

增量更新日志提示


既然之前提到的全量更新有日志说明,那增量更新可以不用,也应该需要具备这个能力,不然我们神不知鬼不觉帮用户更新了,用户都不知道 新在哪里


至于更新内容,我们可以复用 releaseInforeleaseNotes 字段,把更新日志写在这里,增量更新完成后展现给用户就好了。


但是总不能每次打开都展示,这里需要用户有个交互,比如点击 知道了 按钮,或者关闭 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 即可,渲染进程效果如下:


提示增量更新日志


image.png


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


image.png


当然可能也有这么个场景,开发就改了一点点无关功能的代码,无需向用户展示日志,我们只需要加一个判断 releaseNotes 是否为空的逻辑就好了,也做到了 静默更新


小结


不足之处


本文提出的增量更新方案应该算是比较简单的,可能并不适用于所有的场景,考虑也不够全面,例如:



  1. dll第三方SDK 等资源的更新。

  2. 增量更新失败后应该通过全量更新 兜底

  3. 用户在使用过程中发布新版本,得等到 第二次打开 才能用到新版本。


流程图


针对本文的解决方案,我简单画了一个 流程图


未命名绘图.png


参考文章


网上其实有不少关于 Electron 自动更新的文章,在做这个需求之前也是浏览了好多,但也有些没仔细看完,所以就躺在我的标签页没有关闭,在这罗列出来,也当作收藏了。


写这篇文章的目的也是为了记录和复盘,如果能为你提供一些思路那就再好不过了。


鸣谢:



作者:小陈同学吗
来源:juejin.cn/post/7416311252580352034
收起阅读 »

国产语言MoonBit崛起,比Rust快9倍,比GO快35倍

在这个技术日新月异的时代,编程语言的更新换代似乎已经成为了家常便饭。但国产语言却一直不温不火,对于程序员来说,拥有一款属于我们自己的语言,一直是大家梦寐以求的事情。 如果我说有一种国产编程语言,它的运行速度比Rust快9倍,比GO快35倍,你会相信吗? 这不是...
继续阅读 »

在这个技术日新月异的时代,编程语言的更新换代似乎已经成为了家常便饭。但国产语言却一直不温不火,对于程序员来说,拥有一款属于我们自己的语言,一直是大家梦寐以求的事情。


如果我说有一种国产编程语言,它的运行速度比Rust快9倍,比GO快35倍,你会相信吗?


这不是天方夜谭,最近,被称为“国产编程语引领者”的MoonBit(月兔),宣布正式进入Beta预览版本阶段啦!


一听月兔这名字起得挺中式的。


一、初识MoonBit



MoonBit是由粤港澳大湾区数字经济研究院(福田)研发的全新编程语言。



① 官网


http://www.moonbitlang.cn/


官网


② 目前状态


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 的输出为 100KBTypeScript8.7MBPython 更是高达 17MB


代码体积比较


多重安全保障


MoonBit 采用了强大的类型系统,并内置静态检测工具,在编译期检查类型错误,


MoonBit自身的静态控制流分析能在编译器捕获异常的类型,从而提高代码的正确性和可靠性。


高效迭代器


MoonBit创新地使用了零开销的迭代器设计,使得用户能够写出既优雅又高效的代码。


创新的泛型系统设计


MoonBit语言在它的测试版里就已经搞定了泛型和特殊的多态性,而且在编译速度特别快的同时,还能做到用泛型时不增加额外负担。


你要知道,这种本事在很多流行的编程语言里,都是正式发布很久之后才慢慢有的,但MoonBit一开始就做到了。这种设计在现在编程语言越来越复杂的大背景下特别关键,因为一个好的类型系统对于整个编程语言生态的健康成长是特别重要的。


三、应用场景


① 云计算


② 边缘计算


③ AI 以及教学领域的发展


四、开发样例


我们在官网 http://www.moonbitlang.cn/gallery/ 可以看到用使用MoonBit 开发的游戏样例



  • 罗斯方块游戏

  • 马里奥游戏

  • 数独求解器

  • 贪吃蛇游戏


游戏开发样例


五、语言学习


5.1 语法文档



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



image-20240921212615386


5.2 在线编译器



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



① 在线编辑器地址


try.moonbitlang.cn/


在线编辑器


② 点击这儿运行代码


运行代码


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导入导出,数据整合功能。不涉及数据库相关操作。


综合考虑之后选择了



  • PowerBuilder

  • Pbidea.dll


使用PowerBuilder开发桌面应用,虽然界面丑一点,但是开发效率挺高,简单拖拖拽拽就能完成界面(对于前端技术不熟的小伙伴很友好)


其次,由于不需要数据库,放弃web开发应用,又省去了云服务器费用。最终只需要打包成exe文件即可跑起来


Pbidea.dll 算是Powerbuilder最强辅助开发,没有之一。算是PBer们的福音吧


三、简单界面布局


界面布局1


界面布局2


界面布局3


四、核心代码


① 导入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 PocketBas...
继续阅读 »


如果你经常开发web类的的项目,那你一定经常会接触到后端服务,给项目找一个简单、好用的后端服务可以大大加速开发。


今天我们分享的开源项目,它可以作为SaaS或者是Mobile的后端服务,最简单情况只要一个文件,它就是:PocketBase



PocketBase 是什么


PocketBase是一个开源的Go后端框架,它以单个文件的形式提供了一个实时的后端服务。这个框架特别适合于快速开发小型到中型的Web应用和移动应用。它的设计哲学是简单性和易用性,使得开发者能够专注于他们的产品而不是后端的复杂性。



PocketBase包含以下功能:



  • 内置数据库(SQLite)支持实时订阅

  • 内置文件和用户管理

  • 方便的管理面板 UI

  • 简洁的 REST 风格 API


安装使用PocketBase


首先你可以下载PocketBase的预构建版本,你可以在github的release页面下载到对应平台的包。



下载后,解压存档并./pocketbase serve在解压的目录中运行。


启动完成后会3个web服务的路由:



默认情况下,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 库包分发,允许您构建自己的自定义应用程序特定的业务逻辑,并且最后仍具有单个可移植的可执行文件。


这是一个简单的例子:



  1. 首先如果你没有Go的环境,那么需要安装 Go1.21以上版本

  2. 创建一个新的项目目录,并创建一个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)
}
}


  1. 初始化依赖项,请运行go mod init myapp && go mod tidy。

  2. 要启动应用程序,请运行go run main.go serve。

  3. 要构建静态链接的可执行文件,您可以运行CGO_ENABLED=0 go build,然后使用 启动创建的可执行文件./myapp serve。


总结


整体来说PocketBase是一个非常不错的后端服务,它兼顾了易用性和定制的灵活性,如果你有项目的需要或是想要自己开发一个SAAS的服务,都可以选择它来试试。



项目信息



作者:IT咖啡馆
来源:juejin.cn/post/7415672130190704640
收起阅读 »

Vue3真的不需要用pinia!!!

web
前言 之前使用vue3都是在公司的基建项目中,为了快速达到目的,把以前vue2的模板拿来简单改改就直接用了,所以项目中用法特别乱,比如:状态管理依旧用的vuex,各种类型定义全是any,有些代码是选项式API,有些代码是组合式API... 最近终于有时间推动一...
继续阅读 »

前言


之前使用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,试着写了个demoref就是选项式写法中的statecomputed就是选项式中的gettersfunction就是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.$statestore.$patch等等,这些方法实现很简单,很轻松就可以手写出来,如果你是这些方法的重度用户,保留pinia也没问题,如果你也想代码更加精简,赶紧尝试下组合式函数吧。


作者:自在的小李子
来源:juejin.cn/post/7411328136740847654
收起阅读 »

上6休3上3休2……,为了理清这烧脑的调休安排我制作一个调休日历!

调休日历 前些天,使用python做了一个日历,可以打印出调休的节假日。效果还可以,但是遇到了一些小问题。 有一个朋友提出了这样一个问题:“日历确实是很好,但是,使用控制台print不怎么样,别人的日历都是很好看的,而我们的日历还只能用print。这太丢人了,...
继续阅读 »

调休日历


前些天,使用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}"


日历实现


假期判断


因为“调休”的存在,所以我们的放假日期不总是固定的,每年都会有很大的变化,那么如何判断某个日子是不是节假日呢?是不是需要调休呢?


实际上,这是一件困难的事情,没有办法提前知道,只能等到每一年,国家公布了放假安排以后,我们才能够知道准确的放假调休日期。比如说,一些日历等不及,还没等公布放假安排,就已经开始提前印刷了,那么这样的日历,其上包含的信息,就是不完整的,他只能告诉你常规的节日和星期,没办法告诉你调休。


看过我上一期关于日历文章的,应该知道在当时,我是使用了“标记调休”的方式,实现这一点的,大概像这样:


rili2.png


这当然是简单有效,且可行的,只不过一次标记只能管一年,到了明年就不能用了,还得自己重新标记,况且,标记也是一件麻烦的事情,有没有什么更好的办法呢?


当然是有的,我们让别人给我们标记好了,自己等着用现成的,不就好了吗?那么哪里能找到这样的好心人呢?当然是有的,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-9.png


2024-10:
日历2024-10.png


2024-11:
日历2024-11.png


2024-12:
日历2024-12.png


引用与致谢


日历样式,部分参考了便民查询:wannianrili.bmcx.com/


matplotlib绘制,部分参考了shimo164:medium.com/@shimo164/


详细的中国节假日,不再需要个人手动标记了,chinese-calendar:github.com/LKI/chinese…


快速将公历转为农历,支持转换到2100年,LunarCalendar:github.com/wolfhong/Lu…


总结


从我们的日历中,可以清晰的看出,中秋到国庆,我们经历了:



  1. 上6休3

  2. 上3休2

  3. 上5休1

  4. 上2休7

  5. 上5休1


嗯,确实是太烧脑了,没有日历,很难算的清楚啊,最后,那么问题来了,3+7到底等于几呢?


作者:瞎老弟
来源:juejin.cn/post/7414013230954774579
收起阅读 »

还在用 top htop? 赶紧换 btop 吧,真香!

top 在 Linux 服务器上,或类 Unix 的机器上,一般我们想查看每个进程的 CPU 使用率、内存使用情况以及其他相关信息时会使用 top 命令。 top 是一个标准的 Linux/Unix 工具,实际上我从一开始接触 Linux 就一直使用 top...
继续阅读 »

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 个模块对应的快捷键分别就是 1234 你按一下模块显示,再按一下模块隐藏。



所以如果你对预置界面的内容想立刻调整,就可以按快捷键来显示/隐藏 你想要的模块,当然预置界面也是可以通过配置文件调整的,这个我们后面说。


CPU 模块


CPU 模块可以显示 CPU 型号、各内核的使用率、温度,CPU 整体的负载,以及一个直观的图象,所有数据都是实时显示的。



存储 模块


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



因为比较直观,具体内容我就不解释了。


网络模块


网络模块可以看下网络的整体负载和吞吐情况,主要包括上行和下行数据汇总,你可以通过按快捷键 bn 来切换看不同的网卡。



进程模块


初始的进程模块可以看到:



  • pid

  • Program: 进程名称

  • Command: 执行命令的路径

  • Threads: 进程包含的线程数

  • User: 启动进程的用户

  • MemB: 进程所占用内存

  • Cpu%: 进程所占用 CPU 百分比



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



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



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


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



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


主题


怎么样,是不是很方便,操作简单,上手容易,还好看。关于 btop 的主要操作就这些了,剩下的可以参考 helpmenu 中显示的内容自行操作和设置都很简单。


btop 的配置文件默认在这里:$HOME/.config/btop ,你可以直接修改配置文件中的详细参数,如我们前文提到的 “预置” 界面以及预置界面内容都可以在配置文件中设置 :



此外 btop 还有很多好看的主题配色,但默认安装的情况下只带了一个 Default 的,如果你想切换用其他的主题,需要先下载这些主题,主题文件在这里:github.com/aristocrato…


下载好以后放到本地对应的文件夹中 ~/.config/btop/themes


然后你就可以要界面上进行主题的切换了,具体流程是先按快捷键 m ,然后选 OPTIONS



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



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



我目前使用的就是 Default 我觉得最符合我的审美。


最后


用了 btop 后你就再也回不去了,一般情况下再也不会想用 htop 和 top 了,大家没有换的可以直接换了


作者:xiaohezi
来源:juejin.cn/post/7415197972009287692
收起阅读 »

简单实现一个插件系统(不引入任何库),学会插件化思维

插件系统被广泛应用在各个系统中,比如浏览器、各个前端库如vue、webpack、babel,它们都可以让用户自行添加插件。插件系统的好处是允许开发人员在保证内部核心逻辑不变的情况下,以安全、可扩展的方式添加功能。 本文参考了webpack的插件,不引入任何库,...
继续阅读 »

插件系统被广泛应用在各个系统中,比如浏览器、各个前端库如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流程) - 掘金


精读《插件化思维》


【干货】React 组件插件化的简洁实现


作者:xuwentao
来源:juejin.cn/post/7344670957405126695
收起阅读 »

拖拽神器:Pragmatic-drag-and-drop!

web
哈喽,大家好 我是 xy👨🏻‍💻。今天给大家分享一个开源的前端最强拖拽组件 — pragmatic-drag-and-drop! 前言 在前端开发中,拖拽功能是一种常见的交互方式,它能够极大提升用户体验。 今天,我们要介绍的是一个开源的前端拖拽组件 — p...
继续阅读 »

哈喽,大家好 我是 xy👨🏻‍💻。今天给大家分享一个开源的前端最强拖拽组件 — pragmatic-drag-and-drop



前言


在前端开发中,拖拽功能是一种常见的交互方式,它能够极大提升用户体验。



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


什么是 pragmatic-drag-and-drop?


pragmatic-drag-and-drop 是由 Atlassian 开源的一款前端拖拽组件。



Atlassian,作为全球知名的软件开发公司,其核心产品 TrelloJiraConfluence 都采用了 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
收起阅读 »

为什么不写注释?写“为什么不”注释?

原文:Hillel - 2024.09.10 代码是用结构化的机器语言编写的,而注释是用富有表现力的人类语言编写的。人类语言让注释比代码更具表达性和沟通能力。代码中也包含少量类似于人类语言的内容,例如标识符。所谓“注释要写为什么,而不是写做了什么”,意思是尽可...
继续阅读 »

why-not-comments.0.png


原文Hillel - 2024.09.10


代码是用结构化的机器语言编写的,而注释是用富有表现力的人类语言编写的。人类语言让注释比代码更具表达性和沟通能力。代码中也包含少量类似于人类语言的内容,例如标识符。所谓“注释要写为什么,而不是写做了什么”,意思是尽可能将信息嵌入到标识符中。并非所有“做了什么”都能这样嵌入,但很多都可以。


近年来,我看到越来越多的人主张,连“为什么”也不应该出现在注释中,它们可以通过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。函数本身没有任何内容可以让你直接关联上。


这是“自解释”反面信息的根本问题。“自解释”是伴随代码书写的,它描述了代码在做什么。而反面信息是关于代码没有做什么的。


最后的思考


我在想,是否可以将“为什么不”注释视为反事实的一个例子。如果是这样,那么“人类沟通的抽象”是否一般都无法“自解释”?你能“自解释”一个比喻吗?不确定性呢?伦理主张呢?


作者:阿然a
来源:juejin.cn/post/7413311432970993704
收起阅读 »

用了Go的匿名结构体,搬砖效率更高,产量更足了

今天给大家分享一个使用匿名结构体,提升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时,有的公共字段使用匿名结构体声明类型也很方便。


一般在启动项目的时候我们都会约定项目提供的接口的响应值结构,比如响应里必须包含CodeMsgData三个字段,每个接口会再细分定义返回的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结构体里也使用是没问题的,只要会多次用到的类型都建议声明成正常的结构体类型。


还是那句话匿名结构体只在你觉得"这还要定义个类型?”时候使用,用好的确实能提高点代码生产效率。


总结


本次的分享就到这里了,内容比较简单,记住这个口诀:匿名结构体只在你写代码时觉得这还要定义个类型,感觉没必要的时候使用,采纳这个技巧,时间长了还是能看到一些自己效率的提高的。


作者:kevinyan
来源:juejin.cn/post/7359084604663709748
收起阅读 »

不到50元如何自制智能开关?

前言家里床头离开关太远了,每次躺在床上玩手机准备睡觉时候还的下去关灯,着实麻烦,所以用目前仅有的一点单片机知识,做了一个小小小的智能开关,他有三个模块构成,如下:主模块是ESP32(20元)他是一个低成本、低功耗的微控制器,集成了 Wi-Fi 和蓝牙功能,因为...
继续阅读 »

前言

家里床头离开关太远了,每次躺在床上玩手机准备睡觉时候还的下去关灯,着实麻烦,所以用目前仅有的一点单片机知识,做了一个小小小的智能开关,他有三个模块构成,如下:

  1. 主模块是ESP32(20元)

    他是一个低成本、低功耗的微控制器,集成了 Wi-Fi 和蓝牙功能,因为我们需要通过网络去开/关灯,还有一个ESP8266,比这个便宜,大概6 块钱,但是他烧录程序的时候比较慢。

image.png

  1. 光电开关(10元)

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

image.png

  1. 舵机 SG90(5元)

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

image.png

eb46b573f6d1479e9a904699d652893.jpg

  1. 杜邦线(3元)

image.png

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(舵机)引脚就可以了。

image.png

esp32代码

下面写一点点代码,主要逻辑很简单,创建一个http服务器,用于通过外部去控制舵机的转向,外部通过http请求并附带一个角度参数,在通过ESP32Servo这个库去使舵机角度发生改变。

esp32的wifi有以下几种模式。

  1. Station Mode(STA模式): 在STA模式下,esp32可以连接到一个wifi,获取一个ip地址,并且可以与网络中的其他设备进行通信。
  2. Access Point Mode(AP模式): 在AP模式下,它充当wifi热点,其他设备可以连接到esp32,就像连接到普通路由器一样,一般用作配置模式使用,经常买到的智能设备,进入配置模式和后,他会开一个热点,你的手机连接到这个热点后,在通过他们提供的app去配置,就是用这种模式。
  3. 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,效果如下。

6a6462a333e0124fc1ad0d5c2a4e5cf.jpg


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={}

}

作者:i听风逝夜
来源:juejin.cn/post/7292245569482407988

收起阅读 »

从《逆行人生》聊聊中年程序员的出路

赶在下架前去看了《逆行人生》。 这部电影讲述了高志垒——一个架构师,被裁员后,在找工作两个月颗粒无收的情况下,被逼无奈去跑外卖的故事。 个人感觉还是很值得一看的,是一部很好的现实题材电影,并没有网上说的资本迫害打工人还要打工人努力奋斗的感觉。 有年轻人说,难以...
继续阅读 »

a84ac789e4ab76f547708661ed1630f2367c47ce.jpg


赶在下架前去看了《逆行人生》。


这部电影讲述了高志垒——一个架构师,被裁员后,在找工作两个月颗粒无收的情况下,被逼无奈去跑外卖的故事。


个人感觉还是很值得一看的,是一部很好的现实题材电影,并没有网上说的资本迫害打工人还要打工人努力奋斗的感觉。


有年轻人说,难以共情。70万年薪的人最后要落到为了 15k 的月薪而奔波,他不理解为什么。然而就我亲身经历而言,无路可走的时候,我们的确会做这样的选择。


我们先来看看中年程序员有哪些选择。


中年程序员有哪些出路?


中年三宝插画-2.jpeg


继续打工


打工,无疑是多数人的选择。毕竟上一天班赚一天的钱,这种稳稳的幸福还是大部分人的追求。但打工也不能停滞不前,还是要不断学习、拓展自己的能力,尤其是我们IT行业,技术更新迭代快。如果不学习,很可能 3 到 5 年就被淘汰了。


程序员要晋升、跳槽,主要学习方向以下两种:



  • 拓展技术的深度与广度:高级开发、架构师、热门行业的开发如AI等;

  • 向外拓展自己的能力:培训机构老师、高校老师;技术管理;


自己单干


继续打工,无疑都会碰到被裁员的风险,你个人的命运始终掌握在老板甚至顶头上司的手里。如果你不甘于此,就要开创性地走其他路了。这对个人的挑战都是极大的。


程序员可以凭借技术能力逐渐向外扩展:



  • 独立开发:承接项目或者自研产品

  • 创业:成立公司、团队,完成开发工作


彻底转行


也有部分人被彻底伤透了心,完全不再进入这个行业了,转向其他行业了。



  • 网络兼职:写手、影视剪辑等;

  • 中年三保、铁人三项:保安、保姆、保洁、快递、司机、外卖。这个是被大家调侃最多的;

  • 其他行业的打工者:如制造业、外贸等行业;

  • 开店或者创业:存上一笔钱开店或者做一间自己喜欢的公司,也是一些人的选择。


我们应该如何选择?


如上所见,程序员能做的选择还是比较多的。我们将这些工作列个表,列一下所需要的能力与所承担的责任,以及最后的风险,以便做选择:


截屏2024-09-16 14.25.39.png


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


不推荐铁人三项、中年三宝


中年三宝插画-7.jpeg


首先,我个人其实非常不建议程序员转行去做起他行业的,除非迫不得已,尤其是从事体力劳动。


因为这需要消耗大量的体力与时间。中年人无法靠比拼体力取胜,工作时间长,也无法取得工作生活平衡。在电影《逆行人生》中,高志垒虽然赢了第一个单王,但可以看出其靠的更多是运气,行业老大哥或退出竞赛、或家里有事提早离开。


另外就是,AI 技术发展和市场供需变化。不久前武汉的萝卜快跑落地,相信大部分滴滴司机都感受到了被淘汰的可能。而且这类工作市场基本上已经饱和,所以薪酬只会越来越低。


其他的网络兼职、去制造业服务业打工,这些都是门槛低,程序员即使有技术与能力,也不见得有任何优势的,所以也是不推荐的。


而开店或按自己的兴趣来创业,则非常看你个人能力了,同样需要更谨慎的考虑,当然你如果家财万贯,倒是可以任性一把。


更推荐提早规划、提早行动


剩下的职业方向其实都是推荐的,因为多多少少跟我们自身学习的技术是相关的。将我们的能力逐步往外扩,逐渐走出舒适圈,是更合适的一个发展路径。但是需要注意的是,建议尽早立下目标,提前规划,尽快行动的。


如,希望做老师,可以提早在企业内部做讲师、技术讲师,给新人讲解。锻炼好自己的沟通表达能力,多想想如何让新人更好地融入企业、进入工作状态。


又如,你想自己创业,那可以开始就留意你手头上做的产品是如何开发、运营的。公司如何分配人力物力去做的,如何做商业变现的,如何寻找客户的等等这些问题。不仅要站在技术角度、也要站在公司的角度多思考、多学习、多实践。甚至在时机成熟的时候,提出转岗去做产品、技术管理,更早地锻炼自己所需的创业的能力,能让自己日后的路走的更顺。


高志垒为何还是选择送外卖?


中年三宝插画-5.jpeg


回到电影,既然都不建议程序员从事体力劳动,高志垒好好的一个架构师,也是有脑子的,为啥最后还是选择了外卖员呢?


首先,从影片一开始可以看出,高志垒选择了架构师或者技术管理偏技术方向,因其手头上还有一线开发的任务。显然对于 45 岁的他,在打工这条路上几乎已经到顶了。


然而,他并没有做好职业规划,甚至从未考虑过失业的风险。在突然失业时,才发现市场上几乎找不到自己的职位、薪酬,最后简历也是乱投一气了;而中产返贫三件套:高额房贷、全职太太、国际学校,他几乎全都拥有;并且还大笔地投资了 P2P ,因其爆雷导致家庭财产大量损失;再加上其父亲突发重病,住院急需要钱。


所有的状况同时出现,所有的压力压在身上,在两个月投递简历无果时,他听说送外卖能补上房贷月供差额的数目,宛如找到救命稻草一般,毅然加入了外卖行业。


如何避免陷入被动状况?


如何避免我们也陷入高志垒的状况?


除了像上面说的提早积攒自己的能力,提早做规划、更早地行动外,程序员也应提升技能多样性,特别是专业外的技能;同时在职业中后期应寻找到更利于个人发展的公司或项目;还需要拓展人脉,保持与行业内的沟通交流;在最后,保持健康的生活习惯和平衡好工作,让自己的职业寿命尽可能地延长。


中年三宝插画-9.jpeg


而在财务上,做好失业准备、甚至为后续独立开发、创业等积攒资金都是必要的,所以需要采取一些措施,做好家庭财务的规划,如:



  1. 留出紧急备用金:为应对突发事件,如失业或疾病,应建立足够的紧急基金,一般建议为家庭日常开支的3-6个月。

  2. 谨慎投资:只投资自己熟悉的产品;了解自身的风险承受能力再投资;同时避免将所有资金投入到单一的高风险产品中,如P2P,应进行资产配置,分散风险。

  3. 购买保险:为家庭成员购买适当的健康保险,以减轻因病致贫的风险。

  4. 做好财务预算、规划:每年、每月做好财务预算;同时对于房贷和教育投资等大额支出,应进行详细的财务规划,确保在收入中断时也能应对。

  5. 增加收入来源:尽可能地增加家庭收入来源,比如配偶就业或开展副业,减少对单一收入的依赖。


总结与思考


66bf3e22-63b4-443c-9411-038325654067.jpg


在戏里的高志垒无疑是幸运的,家庭和睦,家人都给予最大的支持,愿意一起度过难关。再加上自己开发的小程序“路路通”,同事间互助互利,最后,成功拿到了单王,并帮家里度过经济危机。


然而最后的结局,高志垒并没有“逆袭”人生,而是在“逆行”人生中,调整了自己。最后他卖掉了大房子,搬到了小房子住,老婆依然在工作,孩子也放弃了就读国际学校、老人靠自身意志力完成了康复。


这也是我觉得这部电影还算现实主义之处。并没有理想中的事情发生,就像现实生活中那些受挫的人们一样,最后选择降低生活标准,继续前行。


最后的最后,问一下大家,如果你面临电影结尾彩蛋中的情景,有一个外卖公司的高层老板对你开发的“路路通”小程序感兴趣,你会如何选择?



  • 卖掉小程序,拿钱走人

  • 加入外卖公司,继续开发

  • 不卖,开源


欢迎留下你的答案与思考,一起讨论。


作者:陈佬昔的编程人生
来源:juejin.cn/post/7414732910240972835
收起阅读 »

「滚动绽放」页面滚动时逐渐展示/隐藏元素

web
本文将介绍如何使用HTML、CSS和JavaScript代码实现页面在滚动时元素逐渐出现/隐藏。这个动画效果会在用户滚动/隐藏页面时从不同方向逐渐显示出一组彩色方块🏳️‍🌈 HTML结构 首先,HTML部分包含了一个<section>元素和一个名...
继续阅读 »

本文将介绍如何使用HTMLCSSJavaScript代码实现页面在滚动时元素逐渐出现/隐藏。这个动画效果会在用户滚动/隐藏页面时从不同方向逐渐显示出一组彩色方块🏳️‍🌈



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);
}

表现


scroll-reveal-rendering

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);

总结


通过本篇文章的详细介绍,相信能够帮助你更好地使用CSSJavaScript来创建一个滚动显示元素动画,从而理解掌握和应用这个效果。通过设置合适的样式和脚本来控制元素的显示和隐藏为网页提供了生动和吸引力。


希望这篇文章对你在开发类似交互动画效果时有所帮助!如果你对这个案列还有任何问题,欢迎在评论区留言或联系(私信)我。码字不易🥲,不要忘了三连鼓励🤟,谢谢阅读,Happy Coding🎉!


源码我放在了GitHub,里面还有一些酷炫的效果、动画案列,喜欢的话不要忘了 starred 不迷路!


作者:掘一
来源:juejin.cn/post/7280926568854781987
收起阅读 »

Java音视频文件解析工具

@[toc] 小伙伴们知道,松哥平时录了蛮多视频课程,视频录完以后,就想整理一个视频文档出来,在整理视频文档的时候,就会遇到一个问题,就是怎么统计视频时长? 特别是有时候为了方便大家看到每一集视频的时长,我要把视频目录整理成下面这个样子: 这个逐集去查看就很...
继续阅读 »

@[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 帮我们解决了三大类问题:



  1. 跨平台兼容性问题:音视频处理往往涉及到不同的操作系统和硬件架构,jave-all-deps 库提供了针对不同平台的预编译 ffmpeg 二进制文件,使得开发者无需担心平台兼容性问题。

  2. 复杂的命令行操作:ffmpeg 虽然功能强大,但其命令行接口复杂且难以记忆。jave-all-deps 通过封装 ffmpeg 的命令行操作,提供了简洁易用的 Java API,降低了使用门槛。

  3. 依赖管理:在项目中集成音视频处理功能时,往往需要处理多个依赖项。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两个对象到底有什么不同❓❓❓

web
JavaScript 在处理文件、二进制数据和数据转换时,提供了一系列的 API 和对象,比如 File、Blob、FileReader、ArrayBuffer、Base64、Object URL 和 DataURL。每个概念在不同场景中都有重要作用。下面的内...
继续阅读 »

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);


  1. blobParts: 一个数组,包含将被放入 Blob 对象中的数据,可以是字符串、数组缓冲区(ArrayBuffer)、TypedArray、Blob 对象等。

  2. options: 一个可选的对象,可以设置 type(MIME 类型)和 endings(用于表示换行符)。


例如:


const blob = new Blob(["Hello, world!"], { type: "text/plain" });

20240913142627


Blob 对象主要有以下几个属性:



  1. size: 返回 Blob 对象的大小(以字节为单位)。


console.log(blob.size); // 输出 Blob 的大小


  1. type: 返回 Blob 对象的 MIME 类型。


console.log(blob.type); // 输出 Blob 的 MIME 类型

Blob 对象提供了一些常用的方法来操作二进制数据。



  1. 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);


  1. text()


该方法将 Blob 的内容读取为文本字符串。它返回一个 Promise,解析为文本数据。


blob.text().then((text) => {
console.log(text); // 输出 "Hello, world!"
});

20240913143250



  1. arrayBuffer()


该方法将 Blob 的内容读取为 ArrayBuffer 对象,适合处理二进制数据。它返回一个 Promise,解析为 ArrayBuffer 数据。


const blob = new Blob(["Hello, world!"], { type: "text/plain" });

blob.arrayBuffer().then((buffer) => {
console.log(buffer);
});

20240913143451



  1. stream()


该方法将 Blob 的数据作为一个 ReadableStream 返回,允许你以流的方式处理数据,适合处理大文件。


const stream = blob.stream();

Blob 的使用场景


Blob 对象在很多场景中非常有用,尤其是在 Web 应用中处理文件、图片或视频等二进制数据时。以下是一些常见的使用场景:



  1. 生成文件下载


你可以通过 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 对象

当我们刷新浏览器的时候发现是可以自动给我们下载图片了:


20240913144132



  1. 上传文件


你可以通过 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");
});


  1. 读取图片或其他文件


通过 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>

20240913145303



  1. 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

20240913145547


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>

最终输出结果如下图所示:


20240913141055


我们可以使用 File 的方式来访问用户上传的文件,我们也可以手动创建 File 对象:


const file = new File(["Hello, world!"], "hello-world.txt", {
type: "text/plain",
});

console.log(file);

20240913141356


File 对象继承了 Blob 对象的方法,因此可以使用一些 Blob 对象的方法来处理文件数据。



  1. slice(): 从文件中获取一个子部分数据,返回一个新的 Blob 对象。


const blob = file.slice(0, 1024); // 获取文件的前 1024 个字节


  1. text(): 读取文件内容,并将其作为文本返回(这是 Blob 的方法,但可以用于 File 对象)。


file.text().then((text) => {
console.log(text); // 输出文件的文本内容
});


  1. arrayBuffer(): 将文件内容读取为 ArrayBuffer(用于处理二进制数据)。


file.arrayBuffer().then((buffer) => {
console.log(buffer); // 输出文件的 ArrayBuffer
});


  1. stream(): 返回一个 ReadableStream 对象,可以通过流式读取文件内容。


const stream = file.stream();

20240913141746


总结


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 更专注于与文件系统的交互。




作者:Moment
来源:juejin.cn/post/7413921824066551842
收起阅读 »