昨天晚上,RPC 线程池被打满了,原因哭笑不得
大家好,我是五阳。
1. 故障背景
昨天晚上,我刚到家里打开公司群,就看见群里有人讨论:线上环境出现大量RPC请求报错,异常原因:被线程池拒绝。虽然异常量很大,但是异常服务非核心服务,属于系统旁路,服务于数据核对任务,即使有大量异常,也没有实际的影响。
原来有人在线上刷数据,产生了大量 binlog,数据核对任务的请求量大幅上涨,导致线程池被打满。因为并非我负责的工作内容,也不熟悉这部分业务,所以没有特别留意。
第二天我仔细思考了一下,觉得疑点很多,推导过程过于简单,证据链不足,最终结论不扎实,问题根源也许另有原因。
1.1 疑点
- 请求量大幅上涨, 上涨前后请求量是多少?
- 线程池被打满, 线程池初始值和最大值是多少,线程池队列长度是多少?
- 线程池拒绝策略是什么?
- 影响了哪些接口,这些接口的耗时波动情况?
- 服务的 CPU 负载和 GC情况如何?
- 线程池被打满的原因仅仅是请求量大幅上涨吗?
带着以上的几点疑问,第二天一到公司,我就迫不及待地打开各种监控大盘,开始排查问题,最后还真叫我揪出问题根源了。
因为公司的监控系统有水印,所以我只能陈述结论,不能截图了。
2. 排查过程
2.1 请求量的波动情况
- 单机 RPC的 QPS从 300/s 涨到了 450/s。
- Kafka 消息 QPS 50/s 无 明显波动。
- 无其他请求入口和 无定时任务。
这也能叫请求量大幅上涨,请求量增加 150/s 能打爆线程池?就这么糊弄老板…… ,由此我坚定了判断:故障另有根因
2.2 RPC 线程池配置和监控
线上的端口并没有全部被打爆,仅有 1 个 RPC 端口 8001 被打爆。所以我特地查看了8001 的线程池配置。
- 初始线程数 10
- 最大线程数 1024(数量过大,配置的有点随意了)
- 队列长度 0
- 拒绝策略是抛出异常立即拒绝。
- 在 20:11到 20:13 分,线程从初始线程数10,直线涨到了1024 。
2.3 思考
QPS 450次/秒 需要 1024 个线程处理吗?按照我的经验来看,只要接口的耗时在 100ms 以内,不可能需要如此多的线程,太蹊跷了。
2.4 接口耗时波动情况
- 接口 平均耗时从 5.7 ms,增加到 17000毫秒。
接口耗时大幅增加。后来和他们沟通,他们当时也看了接口耗时监控。他们认为之所以平均耗时这么高,是因为RPC 请求在排队,增加了处理耗时,所以监控平均耗时大幅增长。
这是他们的误区,错误的地方有两个。
- 此RPC接口线程池的队列长度为 0,拒绝策略是抛出异常。当没有可用线程,请求会即被拒绝,请求不会排队,所以无排队等待时间。
- 公司的监控系统分服务端监控和调用端监控,服务端的耗时监控不包含 处理连接的时间,不包含 RPC线程池排队的时间。仅仅是 RPC 线程池实际处理请求的耗时。 RPC 调用端的监控包含 RPC 网络耗时、连接耗时、排队耗时、处理业务逻辑耗时、服务端GC 耗时等等。
他们误认为耗时大幅增加是因为请求在排队,因此忽略了至关重要的这条线索:接口实际处理阶段的性能严重恶化,吞吐量大幅降低,所以线程池大幅增长,直至被打满。
接下来我开始分析,接口性能恶化的根本原因是什么?
- CPU 被打满?导致请求接口性能恶化?
- 频繁GC ,导致接口性能差?
- 调用下游 RPC 接口耗时大幅增加 ?
- 调用 SQL,耗时大幅增加?
- 调用 Redis,耗时大幅增加
- 其他外部调用耗时大幅增加?
2.5 其他耗时监控情况
我快速的排查了所有可能的外部调用耗时均没有明显波动。也查看了机器的负载情况,cpu和网络负载 均不高,显然故障的根源不在以上方向。
- CPU 负载极低。在故障期间,cpu.busy 负载在 15%,还不到午高峰,显然根源不是CPU 负载高。
- gc 情况良好。无 FullGC,youngGC 1 分钟 2 次(younggc 频繁,会导致 cpu 负载高,会使接口性能恶化)
- 下游 RPC 接口耗时无明显波动。我查看了服务调用 RPC 接口的耗时监控,所有的接口耗时无明显波动。
- SQL 调用耗时无明显波动。
- 调用 Redis 耗时无明显波动。
- 其他下游系统调用无明显波动。(如 Tair、ES 等)
2.6 开始研究代码
为什么我一开始不看代码,因为这块内容不是我负责的内容,我不熟悉代码。
直至打开代码看了一眼,恶心死我了。代码非常复杂,分支非常多,嵌套层次非常深,方法又臭又长,堪称代码屎山的珠穆朗玛峰,多看一眼就能吐。接口的内部分支将近 10 个,每个分支方法都是一大坨代码。
这个接口是上游 BCP 核对系统定义的 SPI接口,属于聚合接口,并非单一职责的接口。看了 10 分钟以后,还是找不到问题根源。因此我换了问题排查方向,我开始排查异常 Trace。
2.7 从异常 Trace 发现了关键线索
我所在公司的基建能力还是很强大的。系统的异常 Trace 中标注了各个阶段的处理耗时,包括所有外部接口的耗时。如SQL、 RPC、 Redis等。
我发现确实是内部代码处理的问题,因为 trace 显示,在两个 SQL 请求中间,系统停顿长达 1 秒多。不知道系统在这 1 秒执行哪些内容。我查看了这两个接口的耗时,监控显示:SQL 执行很快,应该不是SQL 的问题
机器也没有发生 FullGC,到底是什么原因呢?
前面提到,故障接口是一个聚合接口,我不清楚具体哪个分支出现了问题,但是异常 Trace 中指明了具体的分支。
我开始排查具体的分支方法……, 然而捏着鼻子扒拉了半天,也没有找到原因……
2.8 山穷水复疑无路,柳暗花明又一村
这一坨屎山代码看得我实在恶心,我静静地冥想了 1 分钟才缓过劲。
- 没有外部调用的情况下,阻塞线程的可能性有哪些?
- 有没有加锁? Synchiozed 关键字?
于是我按着关键字搜索Synchiozed
关键词,一无所获,代码中基本没有加锁的地方。
马上中午了,肚子很饿,就当我要放弃的时候。随手扒拉了一下,在类的属性声明里,看到了 Guava限流器。
激动的心,颤抖的手
private static final RateLimiter RATE_LIMITER = RateLimiter.create(10, 20, TimeUnit.SECONDS);
限流器:1 分钟 10次调用。
于是立即查看限流器的使用场景,和异常 Trace 阻塞的地方完全一致。
嘴角出现一丝很容易察觉到的微笑。
破案了,真相永远只有一个。
3. 问题结论
Guava 限流器的阈值过低,每秒最大请求量只有10次。当并发量超过这个阈值时,大量线程被阻塞,RPC线程池不断增加新线程来处理新的请求,直到达到最大线程数。线程池达到最大容量后,无法再接收新的请求,导致大量的后续请求被线程池拒绝。
于是我开始建群、摇人。把相关的同学,还有老板们,拉进了群里。把相关截图和结论发到了群里。
由于不是紧急问题,所以我开开心心的去吃午饭了。后面的事就是他们优化代码了。
4. 思考总结
4.1 八股文不是完全无用,有些八股文是有用的
有人质疑面试为什么要问八股文? 也许大部分情况下,大部分八股文是没有用的,然而在排查问题时,多知道一些八股文是有助于排查问题的。
例如要明白线程池的原理、要明白 RPC 的请求处理过程、要知道影响接口耗时的可能性有哪些,这样才能带着疑问去追踪线索。
4.2 可靠好用的监控系统,能提高排查效率
这个问题排查花了我 1.5 小时,大部分时间是在扒拉代码,实际查看监控只用了半小时。如果没有全面、好用的监控,线上出了问题真的很难快速定位根源。
此外应该熟悉公司的监控系统。他们因为不清楚公司监控系统,误认为接口监控耗时包含了线程池排队时间,忽略了 接口性能恶化 这个关键结论,所以得出错误结论。
4.2 排查问题时,要像侦探一样,不放过任何线索,确保证据链完整,逻辑通顺。
故障发生后第一时间应该是止损,其次才是排查问题。
出现故障后,故障制造者的心理往往处于慌乱状态,逻辑性较差,会倾向于为自己开脱责任。这次故障后,他们给定的结论就是:因为请求量大幅上涨,所以线程池被打满。 这个结论逻辑不通。没有推导过程,跳过了很多推导环节,所以得出错误结论,掩盖了问题的根源~
其他人作为局外人,逻辑性会更强,可以保持冷静状态,这是老板们的价值所在,他们可以不断地提出问题,在回答问题、质疑、继续排查的循环中,不断逼近事情的真相。
最终一定要重视证据链条的完整。别放过任何线索。
出现问题不要慌张,也不要吃瓜嗑瓜子。行动起来,此时是专属你的柯南时刻
我是五阳,关注我,追踪更多我在大厂的工作经历和大型翻车现场。
来源:juejin.cn/post/7409181068597313573
别再混淆了!一文带你搞懂@Valid和@Validated的区别
上篇文章我们简单介绍和使用了一下Springboot的参数校验,同时也用到了 @Valid 注解和 @Validated 注解,那它们之间有什么不同呢?
区别
先总结一下它们的区别:
- 来源
- @Validated :是Spring框架特有的注解,属于Spring的一部分,也是JSR 303的一个变种。它提供了一些 @Valid 所没有的额外功能,比如分组验证。
- @Valid:Java EE提供的标准注解,它是JSR 303规范的一部分,主要用于Hibernate Validation等场景。
- 注解位置
- @Validated : 用在类、方法和方法参数上,但不能用于成员属性。
- @Valid:可以用在方法、构造函数、方法参数和成员属性上。
- 分组
- @Validated :支持分组验证,可以更细致地控制验证过程。此外,由于它是Spring专有的,因此可以更好地与Spring的其他功能(如Spring的依赖注入)集成。
- @Valid:主要支持标准的Bean验证功能,不支持分组验证。
- 嵌套验证
- @Validated :不支持嵌套验证。
- @Valid:支持嵌套验证,可以嵌套验证对象内部的属性。
这些理论性的东西没什么好说的,记住就行。我们主要看分组和嵌套验证是什么,它们怎么用。
实操阶段
话不多说,通过代码来看一下分组和嵌套验证。
为了提示友好,修改一下全局异常处理类:
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 参数校检异常
* @param e
* @return
*/
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public ResponseResult handle(MethodArgumentNotValidException e) {
BindingResult bindingResult = e.getBindingResult();
StringJoiner joiner = new StringJoiner(";");
for (ObjectError error : bindingResult.getAllErrors()) {
String code = error.getCode();
String[] codes = error.getCodes();
String property = codes[1];
property = property.replace(code ,"").replaceFirst(".","");
String defaultMessage = error.getDefaultMessage();
joiner.add(property+defaultMessage);
}
return handleException(joiner.toString());
}
private ResponseResult handleException(String msg) {
ResponseResult result = new ResponseResult<>();
result.setMessage(msg);
result.setCode(500);
return result;
}
}
分组校验
分组验证是为了在不同的验证场景下能够对对象的属性进行灵活地验证,从而提高验证的精细度和适用性。一般我们在对同一个对象进行保存或修改时,会使用同一个类作为入参。那么在创建时,就不需要校验id,更新时则需要校验用户id,这个时候就需要用到分组校验了。
对于定义分组有两点要特别注意:
- 定义分组必须使用接口。
- 要校验字段上必须加上分组,分组只对指定分组生效,不加分组不校验。
有这样一个需求,在创建用户时校验用户名,修改用户时校验用户id。下面对我们对这个需求进行一个简单的实现。
- 创建分组
CreationGr0up 用于创建时指定的分组:
public interface CreationGr0up {
}
UpdateGr0up 用于更新时指定的分组:
public interface UpdateGr0up {
}
- 创建用户类
创建一个UserBean用户类,分别校验 username
字段不能为空和id
字段必须大于0,然后加上CreationGr0up
和 UpdateGr0up
分组。
/**
* @author 公众号-索码理(suncodernote)
*/
@Data
public class UserBean {
@NotEmpty( groups = {CreationGr0up.class})
private String username;
@Min(value = 18)
private Integer age;
@Email(message = "邮箱格式不正确")
private String email;
@Min(value = 1 ,groups = {UpdateGr0up.class})
private Long id;
}
- 创建接口
在ValidationController 中新建两个接口 updateUser
和 createUser
:
@RestController
@RequestMapping("validation")
public class ValidationController {
@GetMapping("updateUser")
public UserBean updateUser(@Validated({UpdateGr0up.class}) UserBean userBean){
return userBean;
}
@GetMapping("createUser")
public UserBean createUser(@Validated({CreationGr0up.class}) UserBean userBean){
return userBean;
}
}
- 测试
先对 createUser
接口进行测试,我们将id的值设置为0,也就是不满足id必须大于0的条件,同样 username 不传值,即不满足 username 不能为空的条件。 通过测试结果我们可以看到,虽然id没有满足条件,但是并没有提示,只提示了username不能为空。
再对 updateUser
接口进行测试,条件和测试 createUser
接口的条件一样,再看测试结果,和 createUser
接口测试结果完全相反,只提示了id最小不能小于1。
至此,分组功能就演示完毕了。
嵌套校验
介绍嵌套校验之前先看一下两个概念:
- 嵌套校验(Nested Validation) 指的是在验证对象时,对对象内部包含的其他对象进行递归验证的过程。当一个对象中包含另一个对象作为属性,并且需要对这个被包含的对象也进行验证时,就需要进行嵌套校验。
- 嵌套属性指的是在一个对象中包含另一个对象作为其属性的情况。换句话说,当一个对象的属性本身又是一个对象,那么这些被包含的对象就可以称为嵌套属性。
有这样一个需求,在保存用户时,用户地址必须要填写。下面来简单看下示例:
- 创建地址类 AddressBean
在AddressBean 设置 country
和city
两个属性为必填项。
@Data
public class AddressBean {
@NotBlank
private String country;
@NotBlank
private String city;
}
- 修改用户类,将AddressBean作为用户类的一个嵌套属性
特别提示:想要嵌套校验生效,必须在嵌套属性上加 @Valid
注解。
@Data
public class UserBean {
@NotEmpty(groups = {CreationGr0up.class})
private String username;
@Min(value = 18)
private Integer age;
private String email;
@Min(value = 1 ,groups = {UpdateGr0up.class})
private Long id;
//嵌套验证必须要加上@Valid
@Valid
@NotNull
private AddressBean address;
}
- 创建一个嵌套校验测试接口
@PostMapping("nestValid")
public UserBean nestValid(@Validated @RequestBody UserBean userBean){
System.out.println(userBean);
return userBean;
}
- 测试
我们在传参时,只传 country
字段,通过响应结果可以看到提示了city
字段不能为空。
可以看到使用了 @Valid
注解来对 Address 对象进行验证,这会触发对其中的 Address 对象的验证。通过这种方式,可以确保嵌套属性内部的对象也能够参与到整体对象的验证过程中,从而提高验证的完整性和准确性。
总结
本文介绍了@Valid
注解和@Validated
注解的不同,同时也进一步介绍了Springboot 参数校验的使用。不管是 JSR-303、JSR-380又或是 Hibernate Validator ,它们提供的参数校验注解都是有限的,实际工作中这些注解可能是不够用的,这个时候就需要我们自定义参数校验了。下篇文章将介绍一下如何自定义一个参数校验器。
来源:juejin.cn/post/7344958089429434406
如何访问数组最后一个元素
原文链接:blog.ignacemaes.com/the-easy-wa…
在JavaScript中,想要获取数组的最后一个元素并不是一件简单的事情,尤其是和一些其他编程语言相比。比如说,在Python里,我们可以通过负数索引轻松访问数组的最后一个元素。但是在JavaScript的世界里,负数索引这一招就不管用了,你必须使用数组长度减一的方式来定位最后一个元素。
比如说,我们有一个数组,里面装着一些流行的前端框架:
const frameworks = ['Nuxt', 'Remix', 'SvelteKit', 'Ember'];
如果我们尝试用负数索引去访问它:
frameworks[-1];// 这里是不会得到结果的
你会发现,这样做是行不通的,它不会返回任何东西。正确的做法是使用数组的长度减一来获取最后一个元素:
frameworks[frameworks.length - 1];// 这样就能拿到'Ember'了
at方法
为了让数组索引变得更加灵活,JavaScript引入了一个新方法——at
。这个方法可以让你通过索引来获取数组中的元素,并且支持负数索引。
frameworks.at(-1);// 这样就能直接拿到'Ember'了
不过,需要注意的是,at
方法只是一个访问器方法,它并不能用来改变数组的内容。如果你想要改变数组,还是得用传统的方括号方式。
// 这样是不行的
frameworks.at(-1) = 'React';
// 正确的改变数组的方法是这样的
frameworks[frameworks.length - 1] = 'React';
with方法
另外,如果你想要改变数组的元素并且得到一个新的数组,而不是改变原数组,JavaScript还提供了一个with
方法。这个方法可以帮你做到这一点,但是它会返回一个新的数组,原数组不会被改变。
// 这样会返回一个新的数组,原数组不变
frameworks.with(-1, 'React');
但是从2023年7月开始,它已经在主流浏览器中得到了支持。Node.js从20.0.0版本开始也支持了这个方法。
使用with
方法,你可以非常方便地修改数组中的元素,并且不用担心会影响到原始数组。这就好比是你在做饭的时候,想要尝尝味道,但又不想直接从锅里尝,于是你盛出一小碗来试味,锅里的菜还是原封不动的。
const updatedFrameworks = frameworks.with(-1, 'React');
// updatedFrameworks 就是 ['Nuxt', 'Remix', 'SvelteKit', 'React']
// 而 frameworks 仍然是原来的数组 ['Nuxt', 'Remix', 'SvelteKit', 'Ember']
兼容性
现在,我们来聊聊这两个方法在浏览器中的兼容性。at
方法从2022年开始已经在主流浏览器中得到了支持,Node.js的当前所有长期支持版本也都支持这个方法。
如果你需要在老旧的浏览器上使用这些方法,别担心,core-js
提供了相应的polyfill。
这样的设计思路,其实是在鼓励我们写出更加模块化和可维护的代码。你不需要担心因为修改了一个元素而影响到整个数组的状态,这对于编写清晰、可靠的代码是非常有帮助的。
如果你需要在一些比较老的浏览器上使用这些方法,你可能需要引入一个polyfill来填补浏览器的不足。core-js
这个库就提供了这样的功能,它可以让你的代码在不同的环境中都能正常运行。
总结
总结一下,at
方法和with
方法为我们在JavaScript中操作数组提供了更多的便利。它们让我们可以用一种更加直观和灵活的方式来访问和修改数组,同时也保持了代码的清晰和模块化。虽然这些方法是近几年才逐渐被引入的,但是它们已经在现代浏览器中得到了很好的支持。如果你的项目需要在老旧的浏览器上运行,记得使用polyfill来确保你的代码能够正常工作。这样,无论是新手还是经验丰富的开发者,都能够轻松地利用这些新特性来提升我们的编程体验。
来源:juejin.cn/post/7356446170477215785
用electron写个浏览器给自己玩
浏览器这种东西工程量很唬人,但是有了electron+webview我们就相当于只需要干组装的活就可以了,而且产品目标就是给自己玩,
成品的效果
😄本来想写成专业的技术博客,但是发现大家好像对那种密密麻麻,全是代码的技术博客不感兴趣,我就挑重点来写吧。
下载拦截功能
下载逻辑如果不做拦截处理的话,默认就是我们平常写web那种弹窗的方式,既然是浏览器肯定不能是那样的。
electron中可以监听BrowserWindow的页面下载事件,并把拿到的下载状态传给渲染线程,实现类似浏览器的下载器功能。
//这个global.WIN = global.WIN = new BrowserWindow({ ...})
global.WIN.webContents.session.on('will-download', (evt, item) => {
//其他逻辑
item.on('updated', (evt, state) => {
//实时的下载进度传递给渲染线程
})
})
页面搜索功能
当时做这个功能的时候我就觉得完了,这个玩意看起来太麻烦了,还要有一个的功能这不是头皮发麻啊。
查资料和文档发现这个居然是webview内置的功能,瞬间压力小了很多,我们只需要出来ctrl+f的时候把搜索框弹出来这个UI就可以了,关键字变色和下一个都是内部已经实现好了的。
function toSearch() {
let timer
return () => {
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
if (keyword.value) {
webviewRef.value.findInPage(keyword.value, { findNext: true })
} else {
webviewRef.value.stopFindInPage('clearSelection')
}
}, 200)
}
}
function closeSearch() {
showSearch.value = false
webviewRef.value.stopFindInPage('clearSelection')
}
function installFindPage(webview) {
webviewRef.value = webview
webviewRef.value.addEventListener('found-in-page', (e) => {
current.value = e.result.activeMatchOrdinal
total.value = e.result.matches
})
}
当前标签页打开功能
就是因为chrome和edge这些浏览器每次使用的时候开非常多的标签,挤在一起,所以我想这个浏览器不能主动开标签,打开了一个标签后强制所有的标签都在当前标签覆盖。
app.on('web-contents-created', (event, contents) => {
contents.setWindowOpenHandler((info) => {
global.WIN?.webContents.send('webview-url-is-change')
if (info.disposition === 'new-window') {
return { action: 'allow' }
} else {
global.WIN?.webContents.send('webview-open-url', info.url)
return { action: 'deny' }
}
})
})
渲染线程监听到webview-open-url后也就是tart="_blank"的情况,强制覆盖当前不打开新窗口
ipcRenderer.on('webview-open-url', (event, url) => {
try {
let reg = /http|https/g
if (webviewRef.value && reg.test(url)) {
webviewRef.value.src = url
}
} catch (err) {
console.log(err)
}
})
标签页切换功能
这里的切换是css的显示隐藏,借助了vue-router
这里我们看dom就能清晰的看出来。
地址栏功能
地址栏支持输入url直接访问链接、支持关键字直接打开收藏的网站、还支持关键字搜索。优先级1打开收藏的网页 2访问网站 3关键字搜索
function toSearch(keyword) {
if (`${keyword}`.length === 0) {
return false
}
// app搜索
if (`${keyword}`.length < 20) {
let item = null
const list = [...deskList.value, ...ALL_DATA]
for (let i = 0; i < list.length; i++) {
if (
list[i].title.toUpperCase().search(keyword.toUpperCase()) !== -1 &&
list[i].type !== 'mini-component'
) {
item = list[i]
break
}
}
if (item) {
goApp(item)
return false
}
}
// 网页访问
let url
if (isUrl(keyword)) {
if (!/^https?:\/\//i.test(keyword)) {
url = 'http://' + keyword
} else {
url = keyword
}
goAppNewTab(url)
return false
} else {
// 关键字搜索
let searchEngine = localStorage.getItem('searchEngine')
searchEngine = searchEngine || CONFIG.searchEngine
url = searchEngine + keyword
if (!router.hasRoute('search')) {
router.addRoute({
name: 'search',
path: '/search',
meta: {
title: '搜索',
color: 'var(--app-icon-bg)',
icon: 'search.svg',
size: 1
},
component: WebView
})
keepAliveInclude.value.push('search')
}
router.push({
path: '/search',
query: { url }
})
setTimeout(() => {
Bus.$emit('toSearch', url)
}, 20)
}
}
桌面图标任意位置拖动
这个问题困扰了我很久,因为它不像电脑桌面大小是固定的,浏览器可以全屏也可以小窗口,如果最开始是大窗口然后拖成小窗口,那么图标就看不到了。后来想到我干脆给个中间区域固定大小,就可以解决这个问题了。因为固定大小出来起来就方便多了。这个桌面是上下两层
//背景格子
<div v-show="typeActive === 'me'" class="bg-boxs">
<div
v-for="(item, i) in 224" //这里有点不讲究了直接写死了
:key="item"
class="bg-box"
@dragenter="enter($event, { x: (i % 14) + 1, y: Math.floor(i / 14) + 1 })"
@dragover="over($event)"
@dragleave="leave($event)"
@drop="drop($event)"
></div>
</div>
// 桌面层
// ...
import { ref, computed } from 'vue'
import useDesk from '@/store/deskList'
import { storeToRefs } from 'pinia'
export default function useDrag() {
const dragging = ref(null)
const currentTarget = ref()
const desk = useDesk()
const { deskList } = storeToRefs(desk)
const { setDeskList, updateDeskData } = desk
function start(e, item) {
e.target.classList.add('dragging')
e.dataTransfer.effectAllowed = 'move'
dragging.value = item
currentTarget.value = e
console.log('开始')
}
let timer2
function end(e) {
dragging.value = null
e.target.classList.remove('dragging')
setDeskList(deskList.value)
if (timer2) {
clearTimeout(timer2)
}
timer2 = setTimeout(() => {
updateDeskData()
}, 2000)
}
function over(e) {
e.preventDefault()
}
let timer
function enter(e, item) {
e.dataTransfer.effectAllowed = 'move'
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
if (item?.x) {
dragging.value.x = item.x
dragging.value.y = item.y
}
}, 100)
}
function leave(e) {}
function drop(e) {
e.preventDefault()
}
return { start, end, over, enter, leave, drop }
}
东西太多了就先介绍这些了
安装包地址
也可以到官网后aweb123.com 如何进入微软商店下载,mac版本因为文件大于100mb没有传上去所以暂时还用不了。
来源:juejin.cn/post/7395389351641612300
准备离开杭州
上个月的时候,我被公司裁掉了,陆陆续续找了 1 个月的工作,没有拿到 1 份 Offer,从网上看着各式各样的消息和自己的亲身体会,原来对于像我这样的普通打工族,找工作是如此的难。我相信,任何时候只要实力强,都能有满意的工作,但我不知道,能达到那样的水平还需要多久。
本人是前端,工作 6 年,期间经历过 4 家公司,前两份是外包,后面两份都是领大礼包走的,回想起来,职业生涯也是够惨的。虽然说惨,但是最近领的这一份大礼包个人认为还是值得,工作很难待下去,也没有任何成长,继续待着也是慢性死亡。
这几天我每天都会在 BOSS 上面投十几家公司,能回复非常少,邀请面试的就更少了。外包公司倒是挺多的,而我是从那个火坑里出来的,是不会选择再进去的。于是,我需要做好打持久战的准备,说不定不做程序员了。
我的房子 7 月底就要到期了,我必须要马上做决定,杭州的行情对我来说很不友好,短期内我大概率找不到工作。基于对未来的悲观考虑,我不想把过多的钱花费在房租上面,所以希望就近找一个三线城市,我搜了一下嘉兴,整租 95 平左右的房子只需要 1200 块钱,还是民用水电,思前想后,打算移居到那里嘉兴去。
一方面,我想尝试一下在三线城市生活是一种什么感觉。另一方面,这可以省钱,如果一个月的房租是 1000,民用水电,一个月的开销只要 2500 块。我搜索了一下货拉拉,从我的位置运到嘉兴,需要花费 600 块钱,这个价格也是可以接受的。思考了这些,我觉得是时候离开待了 5 年的杭州。
未来要到哪里去呢,目前可能的选择是上海。我还得想想未来能做什么,我想学一门手艺傍身,比如修理电器、炒菜。毕竟骑手行业太拥挤了,估计也不是长久之计。
房租降下来了,等我把行李都安置妥当,我打算回老家待一段时间。自从上大学以来,很少有长时间待在家里的时候,眼看父母年纪也越来越大了,很想多陪陪他们。如果进入正常的工作节奏,想做到这样还是会受到局限,这次也算是一个弥补的机会。
被裁也是一件好事,可以让我提前考虑一下未来的出路。
这段时间我想把时间用来专门学英语,再自己做几个项目,学英语的目的是为了 35 岁之后做打算,做项目是为了写到简历上面,并且个人觉得自己需要多做一个项目,这样自己才能成长到下一个级别。虽然不知道收益怎么样,但是我想尝试一下。人还活着,有精力,就还是瞎折腾一下。
离职没敢和家里说,说了估计要担心死了,反正是年轻人,有事就先自己扛一扛,我前几天把我的行李寄回去了一批,我妈问我,怎么,寄东西回来了?我回答说要搬家了。本来也想找机会开口说自己离职了,她说,这次搬家也别离公司远了,我也把话憋了进去,只好说“没事的,放心就行”。我自己没觉得离职有什么,正常的起起落落,只是觉得父母可能会过度的担心。
如果做最坏的打算,那就是回去种地,应该这几年还饿不死。有还没离职的同学,建议还是继续苟着。希望社会的低谷期早点过去,希望我们都能有美好的未来。
来源:juejin.cn/post/7395523104743178279
效率跃升16倍!火山引擎ByteHouse助力销售数据平台复杂查询效率大幅提高
销售数据,是反映市场趋势、消费者行为以及产品表现的重要指标,也是企业做出精准决策的关键依据。因此,对销售数据进行全面利用、高效分析与合规管理,在企业经营中占据着重要地位。
为了更高效、安全地使用销售数据,某公司引入了开源ClickHouse作为数据分析引擎,将分散的销售数据统一到一套可视化分析平台中,并采用鉴权ACL模式来精细化管理企业内部员工的看数、用数权限。
但实际上,该公司销售数据平台在引入鉴权ACL后,出现了性能不足、用户体验受损的状况。其一,ClikHouse的性能难以满足复杂且量级巨大的查询需求,使得集群复杂恶化;其二,ClickHouse集群的CPU使用率长期处于打满状态对用户体验造成影响。
为了解决以上问题,在复杂查询领域具备显著优势且完全兼容ClickHouse的ByteHouse成为该公司迁移首选。
据了解,ByteHouse支持优化器和MPP执行模型,能够较好地支持复杂join与聚合计算的场景。其中,ByteHouse 的优化器在RBO与CBO方向上分别进行了大量的自研优化,并且实现了动态 Filter 下推、物化视图改写、计划复用以及结果复用等高阶优化能力。从而能够根据表的结构、索引等信息生成最优的查询执行计划,提高查询执行效率,减少资源消耗,整体上提升了ByteHouse在复杂场景下的查询性能。
在ByteHouse的支持下,目前该公司在销售数据的非ACL查询和ACL查询两个方向上,都实现了查询效率的显著提升。以ACL查询的60M广告客户DI场景为例,查询效率已经从优化前的16秒大幅缩短至如今的1秒,效率提升高达16倍。
图:抽取该公司销售平台某数据集测试结果
作为新一代云原生数仓产品,ByteHouse在离线、在线复杂分析性能、便捷弹性扩缩容、全场景分析引擎等核心能力上持续优化,并已在互联网、游戏、金融、气象等领域广泛应用。未来,ByteHouse持续以卓越的数据分析能力,为更多业务系统赋能,助力企业数智化转型升级。(作者:李双)
收起阅读 »豆包大模型全面升级:语言模型提升20.3%,图像、语音再进阶
在近日举办的火山引擎AI创新巡展上海站活动中,火山引擎谭待对外表示,相比于5月15日正式发布的版本,豆包语言模型在3个月内,整体综合能力提升了20.3%。
谭待表示,这意味着豆包大模型可以在越来越多的生产力环节中得到应用,在企业服务中更具竞争力。
具体来说,角色扮演能力提升了38.3%,语言理解方面提升33.3%,同时在长文任务,以及数学、专业知识、代码能力等方面也都有不同程度增强。
在图像创作方面,豆包大模型对“文生图模型”进行了升级迭代。新的模型在长文本图文匹配能力方面表现得更加精准,使用户通过文字描述,就可以对图片生成提出更精确的需求。
另外,对于多主体、多位置、人物手部结构等复杂问题,新模型均有大幅提升。新的文生图模型对于中国风格的人物、物品、艺术风格都有着更深理解,未来在设计、广告、营销、电商等多领域,都可以帮助企业解决更多实际问题。
语音模型方面,语义识别准确性进行了相关升级。
对此,谭待在现场举例加以说明。他表示,自2022年冬奥会后,越来越多的人开始喜欢滑雪运动,但在滑雪运动领域中,存在非常多专业的术语,如立刃、搓雪等等,在以往,模型对此很难识别。
但是现在,通过更加精准的上下文理解,人们在讲滑雪相关话题时,模型就可以更好地加以理解。
谭待认为,语音大模型的进一步演进,是实现AI与人之间实时流畅的对话,即在对话中,人可以像与其他人对话一样,去随机打断AI、纠正AI,甚至与AI争辩,而不是像回合制游戏一样,你说一句,我说一句。
对此,火山引擎将大模型与实时音频技术(RTC)相结合,从而能够提供端到端的大模型实时对话能力,企业可以在自身的AI应用中具体应用这一实时语音功能,让用户真正做到和模型非常直接、自由的对话。
通过视频Demo,谭待还在现场具体演示了大模型实时对话能力。他表示,通过将大模型与RTC结合,人与AI之间实现了更加自然的对话,首先是对话可以随时插话、打断,实现了如同真人之间的对话效果。
同时,在以上的前提下,AI声音仍然具备很好的表现力和情感色彩,让用户体验到与真人交流的感觉,并且AI也更加“懂”用户。
最后,通过大模型推理与RTC端到端优化的叠加,火山引擎已经可以将这种人机对话的延迟做到1秒以内,即使在网络环境很差,可能80%丢包的情况下,仍然可以保持非常清晰、流畅的通话质量。
谭待表示,相信这样的新技术,可以让AI时代的人机交互,上升到一个新的高度。(作者:李双)
收起阅读 »【前端缓存】localStorage是同步还是异步的?为什么?
🧑💻 写在开头
点赞 + 收藏 === 学会🤣🤣🤣
首先明确一点,localStorage是同步的
🥝 一、首先为什么会有这样的问题
localStorage
是 Web Storage API 的一部分,它提供了一种存储键值对的机制。localStorage
的数据是持久存储在用户的硬盘上的,而不是内存。这意味着即使用户关闭浏览器或电脑,localStorage
中的数据也不会丢失,除非主动清除浏览器缓存或者使用代码删除。
当你通过 JavaScript 访问 localStorage
时,浏览器会从硬盘中读取数据或向硬盘写入数据。然而,在读写操作期间,数据可能会被暂时存放在内存中,以提高处理速度。但主要的特点是它的持久性,以及它不依赖于会话的持续性。
🍉 二、硬盘不是io设备吗?io读取不都是异步的吗?
是的,硬盘确实是一个 IO 设备,而大部分与硬盘相关的操作系统级IO操作确实是异步进行的,以避免阻塞进程。不过,在 Web 浏览器环境中,localStorage
的API是设计为同步的,即使底层的硬盘读写操作有着IO的特性。
js代码在访问 localStorage
时,浏览器提供的API接口通常会处于js执行线程上下文中直接调用。这意味着尽管硬盘是IO设备,当一个js执行流程访问 localStorage
时,它将同步地等待数据读取或写入完成,该过程中js执行线程会阻塞。
这种同步API设计意味着开发者在操作 localStorage
时不需要考虑回调函数或者Promise等异步处理模式,可以按照同步代码的方式来编写。不过,这也意味着如果涉及较多数据的读写操作时,可能对性能产生负面影响,特别是在主线程上,因为它会阻塞UI的更新和其他js的执行。
🍑 三、完整操作流程
localStorage
实现同步存储的方式就是阻塞 JavaScript 的执行,直到数据的读取或者写入操作完成。这种同步操作的实现可以简单概述如下:
- js线程调用: 当 JavaScript 代码执行一个
localStorage
的操作,比如localStorage.getItem('key')
或localStorage.setItem('key', 'value')
,这个调用发生在 js 的单个线程上。 - 浏览器引擎处理: 浏览器的 js 引擎接收到调用请求后,会向浏览器的存储子系统发出同步IO请求。此时 js 引擎等待IO操作的完成。
- 文件系统的同步IO: 浏览器存储子系统对硬盘执行实际的存储或检索操作。尽管操作系统层面可能对文件访问进行缓存或优化,但从浏览器的角度看,它会进行一个同步的文件系统操作,直到这个操作返回结果。
- 操作完成返回: 一旦IO操作完成,数据要么被写入硬盘,要么被从硬盘读取出来,浏览器存储子系统会将结果返回给 js 引擎。
- JavaScript线程继续执行: js 引擎在接收到操作完成的信号后,才会继续执行下一条 js 代码。
在同步的 localStorage
操作期间,由于 js 的单线程性质,整个线程会阻塞,即不会执行其他任何js代码,也不会进行任何渲染操作,直到 localStorage
调用返回。
🍒 四、localStorage限制容量都是因为同步会阻塞的原因吗?
- 资源公平分享:同一用户可能会访问大量不同的网站,如果没有限制,随着时间的积累,每个网站可能会消耗大量的本地存储资源。这样会导致本地存储空间被少数几个站点占用,影响到用户访问其他网页的体验。限制大小可以确保所有网站都有公平的存储机会。
- 防止滥用:如果没有存储限制,网站可能会滥用
localStorage
,存储大量数据在用户的设备上,这可能导致设备存储空间迅速耗尽,也可能侵犯用户的隐私。 - 性能限制:如之前提到的,
localStorage
的操作是阻塞的。如果网站能够存储大量数据,就会加剧读写操作对页面性能的影响。 - 存储效率:
localStorage
存储的是字符串形式的数据,不是为存储大量或结构化数据设计的。当尝试存储过多数据时,效率会降低。 - 历史和兼容性:5MB 的限制很早就已经被大多数浏览器实现,并被作为一个非正式的标准被采纳。尽管现在有些浏览器支持更大的
localStorage
,但出于跨浏览器兼容性的考虑,开发者通常会假设这个限制。 - 浏览器政策:浏览器厂商可能会依据自己的政策来设定限制,可能是出于提供用户更一致体验的角度,或者是出于管理用户数据的方便。
🍐 五、那indexDB会造成滥用吗?
虽然它们提供了更大的存储空间和更丰富的功能,但确实潜在地也可能被滥用。但是与相比 localStorage
增加了一些特性用来降低被滥用的风险:
- 异步操作:
IndexedDB
是一个异步API,即使它被用来处理更大量的数据,也不会像localStorage
那样阻塞主线程,从而避免了对页面响应性的直接影响。 - 用户提示和权限:对于某些浏览器,当网站尝试存储大量数据时,浏览器可能会弹出提示,要求用户授权。这意味着用户有机会拒绝超出合理范围的存储请求。
- 存储配额和限制:尽管
IndexedDB
提供的存储容量比localStorage
大得多,但它也不是无限的。浏览器会为IndexedDB
设定一定的存储配额,这个配额可能基于可用磁盘空间的一个百分比或者是一个事先设定的限额。配额超出时,浏览器会拒绝更多的存储请求。 - 更清晰的存储管理:
IndexedDB
的数据库形式允许有组织的存储和更容易的数据管理。用户或开发者可以更容易地查看和清理占用的数据。 - 逐渐增加的存储:某些浏览器实现
IndexedDB
存储时,可能会在数据库大小增长到一定阈值时,提示用户是否允许继续存储,而不是一开始就分配一个很大的空间。
🤖 六、一个例子简单测试一下
其实也不用测,平时写的时候你也没用异步的方式写localStorage吧,我们这里简单写个例子
html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Documenttitle>
head>
<body>
<script>
const testLocalStorage = () => {
console.log("==========> 设置localStorage之前");
localStorage.setItem('testLocalStorage', '我是同步的');
console.log("==========> 获取localStorage之前");
console.log('=========获取localStorage', localStorage.getItem('testLocalStorage'))
console.log("==========> 获取localStorage之后");
}
testLocalStorage()
script>
body>
html>
来源:juejin.cn/post/7359405716090011659
高德API花式玩法:租房辅助工具
前言
做gis的同学肯定知道等时圈这个东西,即:在一定时间内通过某种出行方式能到达的范围
通过一些计算,我们可以做一些好玩的事情,比如上图通过调用mapbox等时圈接口,计算在蓝色点位附近骑车17分钟可以到达哪些公司或学校。
可能由于国内交通情况更为复杂,做实时性较差的等时圈意义不大,所以高德地图提供了比较稳当的API:公交地铁到达圈
,其概念和等时圈类似,你可以选择地铁或公交出行,或者两者兼可,高德接口将计算出可达范围。
高德公交到达圈的好玩应用
用了很久的高德,发现这个功能好像没有被很好的应用,其实高德早在1.4的API中就提供了此功能,最近正好要换个房子,突然想到这个东西正好可以拿来做一个很棒的辅助。
由于我跟我对象上班的地方离得比较远,所以找个折中的地方租房是是很重要的,我准备查询到两个到达圈后,再计算一下重合部分,在重合部分找房,就准确多了。
使用AMap.ArrivalRange画出到达圈
首先我做了一个简单的页面
左上角的面板用来设置参数,可选地铁+公交
、地铁
、公交
三种方式,出行耗时最大支持60分钟(超过了接口会报错),位置需要传入经纬度。
初始化地图的步骤就不用说了,我讲一下这个小应用的使用逻辑。
首先,点击地图时,拾取该位置的经纬度,并通过逆地理接口获取到位置文本
function handleMapClick(e: any) {
if (!e.lnglat) return
if (currPositionList.value.length >= 2) {
autolog.log("最多添加 2 个位置", 'error') // 你不会想三个人一起住吧?
return
}
var lnglat = e.lnglat;
geocoder.getAddress(lnglat, (status: string, result: {
regeocode: any; info: string;
}) => {
if (status === "complete" && result.info === "OK") {
currPositionList.value.push({ name: result.regeocode.formattedAddress, lnglat: [lnglat.lng, lnglat.lat] })
}
});
}
可以看到,我把点选的位置信息,暂时存放到了currPositionList
里面,比如你在西二旗上班,而你女朋友在国贸,则点选后效果是这样的
左侧面板新增了两个位置,点击查询时,我将依次查询这两个到达圈,并渲染到地图上
function getArriveRange() {
let loopCount = 0
for (let item of currPositionList.value) {
arrivalRange.search(item.lnglat, currTime.value, (_status: any, result: { bounds: any; }) => {
map.remove(polygons);
if (!result.bounds) return
let currPolygons = []
loopCount++
for (let item of result.bounds) {
let polygon = new AMap.Polygon(polygonStyle[`normal${loopCount}` as "normal1" | "normal2"]);
polygon.setPath(item);
currPolygons.push(polygon)
}
map.add(currPolygons);
polygons.push({
lnglat: item.lnglat,
polygon: currPolygons,
bounds: result.bounds
})
if (loopCount === currPositionList.value.length) {
map.setFitView();
}
}, { policy: currStrategy.value });
}
}
由于接口调用方式是以回调函数的形式返回的,所以我这里记录了一下回调次数,当次数满足后,再去调整视角。这段逻辑运行之后,将是如下结果:
很遗憾,你和你的女朋友,下班后的一个小时内见不成面了,这意味着,如果找一个折中的地方租房,你们上班单程通勤,无论如何都超过一个小时了,如果想尽量接近一个小时,那么看下两者的交汇处
大钟寺地铁站将会是个很好的选择。
这样仍需要我们手动去观察,那么能不能算一下两者的交集呢?
在高德地图中使用 turf.js 计算多多边形交集
在上面提到的getArriveRange
函数中,我新增了这样的逻辑
if (loopCount === currPositionList.value.length) {
let poly1 = turf.multiPolygon(toNumber(polygons[0].bounds));
let poly2 = turf.multiPolygon(toNumber(polygons[1].bounds));
var intersection = turf.intersect(turf.featureCollection([poly1, poly2]));
if (intersection) {
let geojson = new AMap.GeoJSON({
geoJSON: {
type: "FeatureCollection",
features: [intersection]
},
getPolygon: (_: any, lnglats: any) => {
return new AMap.Polygon({
path: lnglats,
...polygonStyle.overlap
});
}
});
polygons.push({
lnglat: [0, 0],
polygon: geojson,
bounds: intersection.geometry.coordinates
})
map.add(geojson);
} else {
autolog.log("暂无交集,请自行查找", 'error')
}
map.setFitView();
}
由于高德地图到达圈获取到的经纬度是字符串,放到 turf 里面会报错,所以这里写了一个简单的递归,将多维数组所有的数据都转化为数字。
// 递归的将多维数组内的字符串转为数字
function toNumber(arr: any) {
return arr.map((item: any) => {
if (Array.isArray(item)) {
return toNumber(item)
} else {
return Number(item)
}
})
}
使用turf.multiPolygon
将获取到的多维数组转化为标准的 geojson 格式,以便于 turf 处理,在turf7.x
中,turf.intersect的用法稍有改变,需要turf.featureCollection([poly1, poly2])
作为参数传入。
这一步操作 turf 将计算并返回两个多多边形的交集intersection
(geojson),但是在高德地图API2.0中,直接传入这个geojson会报错(1.4不会),看了下源码,发现高德有一个操作是直接取第 0 个features
,导致它识别不了这种格式的数据,所以我们手动处理下,即:
let geojson = new AMap.GeoJSON({
geoJSON: {
type: "FeatureCollection",
features: [intersection]
},
getPolygon: (_: any, lnglats: any) => {
return new AMap.Polygon({
path: lnglats,
...polygonStyle.overlap
});
}
});
这样,高德就可以正确渲染这个数据了,这里需要注意的是,geojson 虽然也是 AMap.Polygon 构造的,但是需要一个特殊参数:path,没有的话,也不会报错,但是渲染不出来。
渲染完成后是这样的:
使用绿色代表重合部分,说明在这之中找房都是可以的。
通过 AMap.PlaceSearch 搜索交集区域的小区
高德提供了通过多边形区域搜索POI的接口
placeSearch = new AMap.PlaceSearch({ //构造地点查询类
pageSize: 5, // 单页显示结果条数
pageIndex: 1, // 页码
map: map, // 展现结果的地图实例
autoFitView: true // 是否自动调整地图视野使绘制的 Marker点都处于视口的可见范围
});
placeSearch.searchInBounds('小区', intersection.geometry.coordinates);
效果如上图所示,这样就可以轻松租房啦!
但是由于此接口是 get 请求,如果交集区域过大,会超出 get 请求长度限制:
结语
这个就叫产品思维,一个简单的API可以延伸出很多有趣的应用。
此仓库已在 github 开源,地址:
番外
高德云镜(高德云镜三维重建平台)目前已向企业和政府开放使用(暂未对个人开发者开放)
在web端,高德开发了 Cesium 插件用作展示,但目前来看要求配置过高
- CesiumJS引擎:CesiumJS v1.117+(建议)
- 浏览器:Chrome v126+(建议)
- 显卡:16GB显存以上独立显卡,推荐 NVDIA RTX 4090
- CPU:2.5 GHz 以上(建议)
- 内存:32GB 以上(建议)
看起来类似谷歌地球的全量城市建模。
来源:juejin.cn/post/7403991780512399387
2024-08-28 跟着国家政策走
认知迭代
回顾过去改革开放以来,那些赚钱比较多的,基本都是跟对了政策的方向,吃到了由此带来的福利,比如90年代那波做外贸的,2000年后做房地产和互联网的。
在快速发展的当今时代,国家政策成为引导社会发展的重要指南针。对于个人而言,紧跟国家政策不仅是明智的选择,更是实现自身价值和梦想的关键。
国家政策代表了国家的意志和方向,它汇集了众多专家的智慧和经验,经过深思熟虑后形成。
紧跟国家政策还可以为我们提供更多的机遇和资源。国家为了推动某项政策的实施,往往会投入大量的人力、物力和财力。这意味着我们只要紧跟国家政策,就有可能获得更多的支持和帮助。
观念改变命运,思路决定出路。要想人生总富有,跟着国家政策走。要想人生总辉煌,不同时期改改行!
昨日回顾
锻炼上,昨晚先带着儿子做了蹲下起立,俯卧撑和仰卧起做,并跑了一会热身,然后把娃送回家,才开始去跑的三公里,跑起来感觉有点疲惫,确实身体素质下降得比较多。
为了解决思维导图协作的问题,昨天花了两个小时,在后端、后台系统和导图的项目,加上了增删改查的功能,因为以后还有200本的绘本需要做四维导出,存电脑本地还是不方便。
昨天下午,没想到会有一个粉丝找我付费咨询职业发展的问题,和他聊了半小时,感慨波多,即使他是985毕业,毕业后就去了腾讯工作了6年,后面一直在小公司,近期被裁员后,一直没有面试的机会,我认为除了环境的因素,更多是没有做好职业规划。
晚上和一个做校招面试辅导的朋友,交流了一下想法,制定了一些改进的方向,通过讨论,感觉这个项目还是有机会的。
今日安排
- 锻炼上:晚上跑一个三公里,俯卧撑和仰卧起坐各50个,蹲下起立100个;
- 工作上:
- 确定跨学科美育课程50个美术知识点对应的200本绘本;
- 调研一下3D模型的制作和灯光效果的技术实现;
- 讨论秋季续费和上课安排的方案;
- 3D 画展加上浏览量和支持点赞,原来的点赞改成留言。
- 参加银河的复试。
- 生活上:和美团的同事一起吃个午饭。
来源:juejin.cn/post/7408072039922581556
老板说,2 天开发一个 App,双端支持,我做到了
老板说,2 天开发一个 App,我用 Expo 做到了,当然,学习怎么使用 Expo 花了1个小时时间不算哈。Expo 是一个非常强大的工具,特别适合那些想要快速构建和发布React Native应用的开发者。你有没有遇到过这种情况?刚刚上手React Native,发现配置开发环境、调试代码这些事情耗费了太多时间,而你真正想做的是快速看到成果。那么,Expo 就是为你量身定做的解决方案。
首先,Expo 是一个开源框架,背后有一个强大的社区支持。你可以在 Expo 的 GitHub 仓库 找到它的源码、更新日志以及社区贡献的内容。这也意味着,你可以完全掌控你项目的每一个细节,而且社区成员之间的经验分享和合作让开发变得更加顺畅。
1. Expo 的核心特点
你可能会问,Expo 和普通的 React Native 开发有什么不同?Expo 的一大特点就是“省心”。它帮你封装了大量底层配置,让你不需要花时间在复杂的环境搭建上。想要启动一个新项目?只需几条命令,你的开发环境就配置好了,甚至不需要接触到原生代码。这对于不太熟悉 iOS 和 Android 原生开发的前端开发者来说,简直是福音。话又说回来,如果想看源码,人家也没拦着你,因为生态是开源的。
2. 零门槛开发
如果你还没用过 Expo CLI,那你一定要试试。通过几条简单的命令,你就可以创建并运行一个 React Native 应用。Expo Go 应用甚至允许你直接在手机上预览你的应用,而不需要复杂的配置。这就像是给你装了一双翅膀,让你可以随时随地测试你的应用。
🗄️ npx create-expo-app@latest
🌭 bunx create-expo-app
📦 pnpm create expo-app
🧶 yarn create expo-app
3. 丰富的生态系统
Expo 的生态系统也是它的一大亮点。它内置了大量的常用功能模块,比如相机、位置服务、传感器等等,你可以直接调用这些API,而不需要自己动手去编写原生代码。而且,Expo SDK 每年都会发布几次更新,哦不好意思,每个月都会更新,奶奶的,我刚用就从 50 更新到 51 了,也够速度的,但是好在,是兼容的,好处是确保你能用上最新最酷的功能,比如 react native 的恐怖性能的新架构,妥妥的给安排上。
你也不用担心这些功能的性能问题。Expo 团队非常注重性能优化,确保你的应用能在各类设备上流畅运行。
使用相机,使用数据库啥的,一个 import 搞定,兼容 API,双端几乎一致的体验简直爽大爆炸。
import { CameraView } from 'expo-camera';
import * as SQLite from 'expo-sqlite';
4. 云端构建与发布
说到发布,Expo 还提供了EAS(Expo Application Services),这个服务可以帮你处理繁琐的构建和发布流程。你只需专注于开发,剩下的事情交给EAS就好。无论是 iOS 还是 Android 平台,它都能帮你轻松搞定。更棒的是,你可以通过EAS进行云端构建,不再需要配置繁琐的构建环境。我比较好奇的是他尽然帮我托管了我的签名,所以基本上意味着交给 eas 去构建,发布到 Google play,和 App Store 就是点点鼠标的事情,但是前提是你得功能测试过,不要闪退和白屏。
5. 社区与支持
最让人欣慰的是,Expo 背后有一个活跃的社区。你可以随时在GitHub上提出问题,或者浏览别人已经解决的类似问题。除此之外,Expo 的文档非常详细,新手也能很快上手。如果你想了解某个API的用法,文档里都有详细的示例代码,这让学习曲线变得非常平滑。我遇到的一些问题就是在 docs 上找答案,比如如何本地构建,如何弹出原生模块,因为有可能需要做一些原生开发。
6. 什么时候不该用Expo?
当然,Expo 也并不是万能的。如果你需要使用某些非常特殊的原生功能,Expo 可能并不能完全满足你的需求。在这种情况下,你可能需要“弹出”Expo(也就是所谓的“eject”),从而使用纯粹的 React Native 环境。这时候,你就要自己管理所有原生模块了。
不过,对于大多数应用开发者来说,特别是那些不太熟悉原生开发的前端,Expo 已经足够强大。这里页打一只强心针,只要不是那些小众的三方库,比如腾讯云 cos,基本上问题不大。
个人感觉,Expo是简化了开发流程的,而且哦还为你提供了强大的工具和服务。你只需要专注于编写业务代码,正在做移动端,或者想做移动端开发的,快去试试吧,我相信你会爱上它的。
反问一波
那位说,你知道不是搞 Flutter 的吗,怎么突然就用 react native 了呢?我想说的是,这些都是工具而已,就好比我们夹菜用筷子,喝粥用瓢羹。关键看什么需求,如果你的 App 要求双端 UI 渲染一致性非常高,有非常多高性能动画的需求,那么 Flutter 很适合你,如果你需要快速实现需求,对双端一致性没那么强,且你对 web 开发很熟悉,ok,react native 是你比较好的选择,能说的就是这么多。
来源:juejin.cn/post/7403288145197744168
前端程序员职业发展方向和学习路线
大家好呀,我是前端创可贴。
不管是什么行业和职业,都要做好相应的了解,提前做好职业规划和学习路线,否则在现如今的大环境下,倘若摸着石头过河,很容易还没摸到石头,就被大水冲跑了。
尤其是对于现在的应届毕业生,大环境让很多人都很难找到心仪的工作,竞争的人很多,岗位却变少了。有了清晰的职业发展方向后,便可提前做好技术积累,给面试官一个 surprise。
面试官 be like:
今天咱们就来聊一聊,作为前端程序员,我们的职业发展思路和方向,可以是什么样子的呢。
Advancement Path
先来看看晋升路线。
程序员的技术职业生涯,这里咱们暂且先不提转岗为管理层,技术人的 title 可大致分为几个等级:初级
、中级
、高级
、资深
、专家
、架构师
、CTO
。
每个阶段所掌握的技术内容,和需要具备的能力,都是不尽相同的。我们需要基于当前的等级,规划未来几年需要达到什么样的层次,为了达到这个等级我需要掌握什么样的技能,而不是盲目去学习试图可以很自然的就得到了晋升。
初级前端需要掌握怎么使用框架进行基本的开发,学习产品开发思维和提升学习能力,需要掌握基本的 HTML、CSS、JS 相关知识,例如原型链、闭包、作用域、异步编程等;
中级前端需要接触一些更深刻的问题,例如如何进行一些基本的工程化配置和原理、了解浏览器的渲染原理、常用框架的一些实现原理、了解性能优化、了解网络协议、掌握 TS 等等;
高级前端需要掌握浏览器运行机制、性能优化、代码重构、不同框架的底层实现原理、前端工程化、技术选型、可视化开发、熟悉 Docker、了解后端知识、算法、设计模式、项目组织结构、指导新人等等;
资深前端需要掌握跨端、前端的高级应用、管理一个小组、掌握大型项目的架构设计、掌握前端领域的研究方法、跟进前端最新发展趋势、掌握前端运维部署、掌握计算机底层原理等等;
专家需要带领团队攻坚技术难题、管理前端团队、培养有潜力的团队成员、提高团队整体工作效率等等;
架构师一般不会仅停留在纯前端或纯后端,需要做到统筹帷幄,需要设计系统架构,保障系统的稳定性、安全性、扩展性等等;
CTO 就是一家公司技术层面的最高负责人了,需要带领公司进行技术探索,保障整个公司的系统稳定,探索公司未来的技术发展方向等等,CTO 一般需要较高的学历或极其丰富的阅历,才能作为公司的技术招牌、稳定人心、拉到投资。
不同的公司对于不同的职级会有不同的划分,以上仅是参考,以实际具体的公司要求为准,不同职级所需要掌握的技术内容也没有限制那么严格,毕竟学无止境,会的更多当然更好。当然也不是说一定非要学那么多,但是至少要保障有一些核心竞争力,才不会被淘汰。
Basic
程序员有一些永恒的话题:程序员到底需不需要高学历?程序员英文不好到底行不行?前端/Java/... 到底有没有前途?...
这些问题的答案其实每个人的回答可能都不太相同,我想以我的过往经历来认真的回答一下,给大家一个参考。
- 程序员到底需不需要高学历?
不可否认,程序员(尤其是前端)的门槛没有那么的高,不像有些职业,对于普通的岗位都设定为最低硕士,甚至是博士。程序员大部分基本上没有这个要求,在以前,有个大专、普通本科的学历就可以了,再会一点代码,应届生可以随随便便找个工作。
但是到了现在,相信很多刚毕业的同学,已经经历过社会的第一次拷打了吧,应届生越来越难找工作,因为和你竞争的人太多了,一名专科和一名 985 毕业同学,谁的入职几率更大呢?我们没有学历歧视,但是现在的情况就是僧多粥少,企业是既要还要,现在就是一个全民降本增效的年代。
所以答案是:不是一定要,但是尽量要。虽然很多人在上学期间不知道读书的重要性,在工作之后才反应过来,然后勤奋苦学,通过自己努力也可能实现不小的成就,但是企业都是现实的,不会去赌你的未来,而且有可能你在一家企业只会呆上两三年,也等不到你实现成就,为公司带来价值。
那么对于因为学历屡屡碰壁的同学该怎么办呢?只能前期猥琐发育,先找份工作安定下来,不眼高手低,先入行、后学习、再跳槽,当你有了很多的技术积累,还是能缩小很多差距的。
如果没有好项目,写在简历上不够亮眼,自己的技术积累又得不到展示,就只能另辟蹊径,让企业知道你的水平。比如去贡献开源项目、开博客写文章、自己做项目、做个人网站、包装自己简历等等,都是大家值得去试试的方法。总之,要想方设法让别人看到你的能力,给予你足够的正反馈,否则你会陷入对自己的怀疑:我学了那么多,为什么 Boss 还是已读不回,我还需要继续努力学习吗。
学习相对来说本来就很痛苦,要是得不到企业的认可,很难保持动力一直坚持下去,所以一定要表现出来,至少可以争得很多面试机会,通过面试也可以检验一下自己的水平,还可以做到查缺补漏。
- 程序员英文不好到底行不行?
我说的残酷点,不行!或者说对于想要在技术生涯里有所成就的,英文一定要好,至少可以看懂英文文档、博客等。
有人可能会嗤之以鼻,反正我都是靠百度过来的,要是实在需要看懂英文的文档我机器翻译不就好了?
踩过坑的人都知道,很多时候,技术文档和博客,英文版都比中文版要更新更全更详细,也很少像国内一些文章那样鱼龙混杂,相对来说会更靠谱。我就遇到过一次,我平时都是看英文版的 MDN,不记得为什么有一次我看了中文版的,然后我的 bug 对照文档上的说明,根本没有问题,但是就是有 bug,调试了很长时间都不知道原因是什么,最后切换回了英文版,马上就看到了不一样的说明,英文版更加详细,立马就解决了我的问题。
而且,全球🌐最大的同性交友网站 GitHub 上绝大多数都是英文,全球🌐 最大的程序员问答网站 Stack Overflow 也是如此,所以想要解决、查询或发帖询问一些前沿技术的问题,能读写英文还是非常有必要的。
而且 GitHub 上的开源项目源码,基本上全都是英文,想让你的开源项目做到世界闻名,用英文是最基本的条件啦。像 Java 这样的语言,很多类、方法等都可以直接跳转到源码,并且会有很多注释,英文好的话阅读效率杠杠的。
至于用机器翻译,个人认为还是没那么靠谱,很多专业性词汇使用机器翻译,大概率是离谱到没边儿的,终究没有自己能看得懂来的方便。
还有一点就是,大家经常会戏称,工作中大部分时间都用来取类、变量、函数名了(当然是玩笑啦),英文不太好的人需要每次都去翻译软件儿上查询,还是挺耽误时间的。更有甚者,有人变量名直接用拼音,别人看到了真的很降低专业性,就显得有点儿 low。
所以,建议大家务必学好英文!!!后续我会发表的从底层官方规范讲起的系列文章也都尽量多带上一些英文,让大家多熟悉熟悉英文。
这里也推荐一些高质量的英文网站,链接放在文末。
再提一嘴,尽量用 Google,用过 Google 再去用百度,你会发现很多搜索结果都质量挺差的,只不过得科学上网啦。
- 前端到底有没有前途?
这个问题得分两种情况:
- 满足现状、对未来发展没有方向、觉得前端工作画画页面就可以了的工程师:很难在技术上有所为,这个世界太卷了,遍地都是培训机构,大家都是卷王,当你安心于当前的工作、不去思考未来的发展方向和学习路线,残酷的现实就是没有什么前途,分分钟就被淘汰了。所以我也建议对这一行不热爱,也不能做到坚持学习的人,还是尽量别选择做程序员了,会非常痛苦的。
- 有职业发展方向和学习路线的工程师:稳步发展,目标清晰,辛苦学习得来的技术积累,未来终究有一天会得到回报。这条路会很累,需要学很多东西,并且要一直保持学习,但也是最有前途、有竞争力的方向。我们可以做 Web、移动端、桌面应用、小程序、数据可视化、架构基建、全栈工程师等等,可选择的方向非常多。
所以看到这篇文章,还在犹豫要不要入行的小伙伴,请先三思而后行~
- AI 时代来临,前端岗位会不会被取代?
不会,目前 AI 最多会取代一些重复的机械性的简单前端工作,例如画一画简单的页面、调一调页面的样式。
对于复杂的有核心竞争力的岗位,目前来说还是不会有什么影响的,最多可以借助一下 AI,帮助提高效率,所以大家也不要太焦虑~
如果还是比较担心的话,可以尝试拓展自己的知识圈,例如去接触后端、运维,技多不压身,轮到淘汰也是淘汰别人。
What to learn?
一定要注意,在我们的职业发展中,一定不能只满足于实现了普通的需求,比如说工作内容是开发某某系统中的一些模块,这些模块虽然复杂,但是工作都是一样的内容:画页面。繁重的工作会让人觉得,我已经做了这么多模块了,我已经学到了很多了,但这样其实相当于是 同一个项目 * n
,而不是 n 个不同的项目
。
在我们早期的职业生涯,很难有机会能遇到特别好的项目(我对于好的项目的定义是:有深度、有广度,能学到很多东西),经常只是写写组件、画画页面,甚至都不用太过考虑性能(我和代码,有一个能跑就行),更别提架构、工程化、前端高级应用(例如前端埋点、前端监控、白屏检测等)这些东西了。
久而久之,很多人就在一家公司里产生了舒适圈,做的都是完全一样的东西,没有任何冲出舒适圈的内容。这是国内环境催生出来的,尤其对于前端工程师,很多中小型公司在前端方面,并不需要做多少有深度的东西,他们要的是螺丝钉(说的残酷点就是纯纯工具人),能快速响应需求、迭代上线,性能方面只要不是很严重的问题,对于他们来说就够了。
我相信作为前端工程师,不管在学校还是在工作中,多多少少有时候会受到一些“歧视”,很多人对于前端的理解,仍然停留在“切图仔“的年代,这跟公司、前端工程师的职级、周边环境有很大关系,毕竟不会有人说尤雨溪、Dan Abramov 这类大佬是切图仔吧~
所以我们需要有危机意识,不能停留在画页面的舒适圈里。我见过工作两三年就对前后端各种原理、计算机相关知识、算法等等都非常熟练的人;也见过工作了十余年的人,还在跟刚毕业的同事一起做无聊重复的画页面的工作。在越来越严峻的大环境下,谁会更先被淘汰,无需多言。
所以在最初的前两三年的工作里,可以只是画画页面,学习基础和框架,顺带着了解一些底层原理。再往后,已经不是初中级工程师了,要往高级应用的方向走,比如去做前端架构基建、前端埋点监控、前端截图、探索前端发展趋势、低代码无代码平台、大型组件库、游戏、3D 等等,这些方向都是需要运用到很多底层的相关知识,并且可能有一些方向没有很多现有的例子可以参考,需要一定的创新和突破。
还可以让自己不仅仅局限在前端,可以和别的方向结合起来,跨出前端的圈子,但是服务于前端。举个例子,淘宝的代理服务器 Tengine
开发的 concat
模块,可以做到文件资源合并,比如页面有两个 css 文件,就需要发送两个 HTTP 请求,减少 HTTP 请求是很常见的性能优化的一种方式,通过 concat
模块就可以实现只发送一个 HTTP 请求,就可以把两个 css 文件都加载过来。该模块也开源给了原版 Nginx
,以下是官方介绍:
This is a module that is distributed with tengine which is a distribution of Nginx that is used by the e-commerce/auction site Taobao.com. This distribution contains some modules that are new on the Nginx scene. The
ngx_http_concat
module is one of them.
The module is inspired by Apache's modconcat. It follows the same pattern for enabling the concatenation. It uses two
?
, like this:
http://example.com/??style1.css,style2.css,foo/style3.css
举个代码例子:
在 Nginx
上下载完单独的 concat
模块后,在 location
中配置:
location /static/css {
concat on;
concat_max_files 20; # 一次最多请求多少文件数
}
并且在 HTML 文件里,原本是:
<link href="a.css" rel="stylesheet" />
<link href="b.css" rel="stylesheet" />
只需要改成:
<link href="??a.css,b.css" rel="stylesheet" />
再举个例子,大家都知道 Webpack
,它使用起来很方便,在以前是前端工程化必备的基建。但是随着时代的发展,前端代码量越来越庞大,Webpack
这种使用 JS 单线程的语言,并且在构建时需要从入口文件打包整个文件依赖树,性能上其实挺差的。所以出现了 ESBuild
、SWC
等工具来解决性能问题,ESBuild
采用 go
语言编写,SWC
采用 Rust
语言编写,通过别的语言的优势来弥补 JS 的劣势。
这就是典型的跨出前端,但服务于前端。越是到了职业生涯后期,越是可以考虑要不要通过突破前端来服务前端。
How to learn?
最好的方式,就是能在日常工作中接触到上文所说的方向,你每天 8 个小时(可能更多😭)都在接触这些东西,对自己的提升是相当大的,也可能会成为这些领域的专家。
当然肯定有人是这样的:“我工作就是这些内容,根本接触不到那些有竞争力的项目,导致换工作的时候,有广度有深度的项目,还是轮不到我”。那么该如何破局呢?
确实想把这些感兴趣的方向塞到公司的项目里,基本上是不太可能的,所以就只能靠自己业余时间自己去动手了,多查阅些资料,多看看前人已经做好的相关的项目源码,最好是能结合起来自己动手写一个项目。亲自动手写项目所带来的收获是相当丰富的。
但是我们每个人的学习时间都很有限,工作繁忙的时候在公司根本没有时间去学别的东西。所以我们要充分利用好碎片化时间,可以先把想看的网站、文档、公众号文章等先收藏起来,在工作中有碎片化时间了的时候可以迅速找到想看的东西,然后品鉴起来,就一个字儿:宣~
平时在地铁上、公交上、代码编译的时候、等同事回复消息的时候,这些碎片化的时间都可以用来看一看感兴趣的文章,工作了以后一定要尽量学会这个技能。
Where to learn?
在现在这么一个信息化爆炸的时代,不是很推荐有自制力的人去报班,学会运用 Google、AI、常用的一些网站,基本上大部分想要了解的东西都能找到相应的资源。
推荐一些学习网站:
- W3C:Web 标准全都在这里,HTML、CSS 标准和草稿都能在这里找到。
- ECMAScript Language Specification:最新最准确的 ECMAScript 规范,含有一切标准和处于 Stage 4 的提案。
- MDN:前端开发者终极利器,基本上前端的基础技术、教程全都有,可以查看浏览器兼容情况。只不过有少数文章介绍的比较简单,有时还得再结合一下别的网站。
- CodeSandbox:云编程网站,非常适合那些懒得本地启项目去学习的人,大多前端 UI、图表等框架都会有 CodeSandbox 的在线运行链接。缺点是代码调试起来稍稍有些麻烦。
- 谷歌 Web 开发指南:里面含有很多基础教程,写的非常用心,配合了很多代码。
- Chrome Devtools:谷歌官网介绍 Chrome Devtools 的使用。
- 谷歌爬虫官方文档:详细介绍了什么是
SEO
,如何优化谷歌SEO
等,写的非常详细。 - ECMAScript 6 入门:阮一峰大佬的 ECMAScript 6 教程,适合爱看中文教程的小伙伴。
- Build your own React:交互很友好的 React 教程网站,带你写一个自己的 React。
- Regulex:可视化 JS 的正则表达式执行过程,帮助理解正则表达式。
- Discover three.js:学习 Three.js 的国外网站。
- Linux Command:学习 Linux 命令的网站,就是导航做的不是很好,如果你只是想快速找一下 Linux 对应的命令,链接在这里。这里还有个中文的 Linux 命令网站。
- VisuAlgo:可视化数据结构算法过程,帮助理解算法。
- GeeksforGeeks:数据结构、算法讲解网站,写的还是很不错的。
- Medium:国外一个有很多高质量文章的网站,活跃人数还是很多的,只不过一些文章需要开通订阅计划才能看。
- Baeldung:学习 Java 和 Spring 框架及其相关内容的国外网站。
- SQLZoo:学习 SQL 的国外网站,可以直接运行 SQL 代码,省去自己动手建库建表的时间。
- ChatGPT:有条件科学上网的同学,一定要学会利用 AI 模型,大大提升学习和工作效率。
- Gemini:谷歌的 AI 模型。
...
目前想到的是这些,还有新的话以后再补充~
Conclusion
前端程序员的必备技能就是保持学习,以上这些是我的建议和心得,不一定适合所有人,如果你有更好的想法,欢迎交流~
未来我会陆续发很多系列文章,从底层的官方英文标准规范来讲解 HTML、CSS、JS 等,会附上规范里的定义,以及前端高级应用、框架、跨端、工程化、计算机网络、操作系统、算法、设计模式等,打造全能超级前端。
为啥要从底层官方英文标准规范来讲解呢,因为网上很多文章说的知识点都不够准确,让人不知道是否可信。JavaScript 不像 Java 这样的语言,很多类、方法等都可以直接跳转到源码进行分析,JavaScript 就像是个黑盒,保持着神秘。HTML、CSS 也同样如此,所以我会带着大家从标准规范讲起,有理有据,绝不再受到模糊不清的文章和知识点的影响。
来源:juejin.cn/post/7407999205493719090
大文件分片上传
前言
大文件上传是项目中的一个难点和亮点,在面试中也经常会被面试官问到,所以今天蘑菇头来聊聊这个大文件上传。
什么样的文件算的上是大文件?
对于Web前端来说,当涉及到上传或下载操作时,通常认为任何超过10MB的文件都属于较大文件,尤其是对于HTTP POST上传操作。如果文件大小达到几十MB甚至更大,那么通常就需要考虑使用分块上传、断点续传等技术来优化传输过程,减少因网络不稳定导致的失败率,并提高用户体验。
当然了,这和你的网络带宽也有关系,当你的网络带宽很小时,即时在小的文件传输速率也很慢,也可以被称之为大文件了。
接下里我们来模拟一下如何使用分块上传技术来优化传输过程。
分片上传文件
分片上传技术是解决大文件上传问题的一种有效方法。它通过将大文件分割成多个较小的部分(称为“分片”或“片段”),然后分别上传这些部分,最后再由服务器端合并这些部分来重构原始文件。这种方法的优点包括能够更好地利用网络资源、支持断点续传以及提高上传效率。
主要思想
首先,前端获取到input框里输入的文件对象,通过slice方法将大文件对象进行切割得到小的Blob对象,由于后端无法识别Blob对象,所以需要转为前后端都能识别的对象FormData,然后将这个对象通过post请求发送给后端,将切片一个一个发送给后端。
后端接收到一个一个切割好的对象进行解析,将这些切片保存到一个文件夹下。当所有的切片都发送完毕之后,后端接收到合并这个信号,将文件夹下的切片排好顺序进行合并,创建可写流,将所有的切片读成流类型并汇入到可写流中得到完整的文件资源。
详细过程
有几个点需要我们注意
文件如何切割?用什么方法?
后端什么时候知道前端已经将所有的分片都发送过来了,然后才开始合并?
合并的过程中如何保证分片的顺序?
后端怎么将前端发送过来的分片文件进行合并?
前端
监听input框的change事件,获取文件对象。
使用slice将文件对象进行切片,返回一个数组。
使用FormData构造函数,将Bolb对象包装成formdata对象,以便后端能够识别,并且给这个对象添加文件名,分片名属性,以便后来分片进行排序。
使用Promise.all方法,当所有的分片请求都成功后,在all的then方法里面发送一个分片请求已完成的信号给后端,告诉后端可以开始合并分片了。
<input type="file" name="" id="input">
<button id="btn">上传</button>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
const input = document.getElementById('input');
const btn = document.getElementById('btn');
let fileObj = null
input.addEventListener('change', handleFileChange);
btn.addEventListener('click', handleUpload)
function handleFileChange(e) {//监听change事件,获取文件对象
// console.log(event.target.files);
const [file] = event.target.files;
fileObj = file;
}
function handleUpload() {//点击按钮上传文件到服务器
if (!fileObj) return;
const chunkList = createChunk(fileObj);
// console.log(chunkList);
const chunks = chunkList.map(({ file }, index) => {//创建切片对象
return {
file,
size: file.size,
percent: 0,
index,
chunkName: `${fileObj.name}-${index}`,
fileName: fileObj.name,
}
});
// 发请求
uploadChunks(chunks);
}
//切片
function createChunk(file, size = 5 * 1024 * 1024) {
const chunkList = [];
let cur = 0;
while (cur < file.size) {
chunkList.push({
file: file.slice(cur, cur + size),
})
cur += size;
}
return chunkList;
}
// 发请求到后端
function uploadChunks(chunks) {
console.log(chunks); //这个数组中的元素是对象,对象中有blob类型的文件对象,后端无法识别,所以需要转换成formData对象
const formChunks = chunks.map(({ file, fileName, index, chunkName }) => {
const formData = new FormData();
formData.append('file', file);
formData.append('fileName', fileName);
formData.append('chunkName', chunkName);
return { formData, index }
})
console.log(formChunks); // 后端能识别的类型
//发请求
const requestList = formChunks.map(({ formData, index }) => {//一个一个片段发
return axios.post('http://localhost:3000/upload', formData,()=>{
console.log(index + ' 上传成功');
})
.then(res => {
})
})
Promise.all(requestList).then(() => {
console.log('全部上传成功');
mergeChunks();
})
}
// 合并请求的信号
function mergeChunks(size=5*1024*1024){
axios.post('http://localhost:3000/merge',{
fileName:fileObj.name,
size
})
.then(res=>{
console.log(fileObj.name + '合并成功');
})
}
</script>
后端
使用第三方库multiparty对传输过来的formdata进行解析。
使用fse模块对解析完成的数据进行保存。
当所有的切片都完成时,后端接收到合并切片的请求信号时,使用fse模块读取所有的切片并进行排序。
排序完成之后使用fse模块进行合并。
const http = require('http');
const path = require('path');
const multiparty = require('multiparty');
const fse = require('fs-extra');
const server = http.createServer(async (req, res) => {
res.writeHead(200, {
'access-control-allow-origin': '*',
'access-control-allow-headers': '*',
'access-control-allow-methods': '*'
})
if (req.method === 'OPTIONS') { // 请求预检
res.status = 200
res.end()
return
}
if (req.url === '/upload') {
// 接收前端传过来的 formData
const form = new multiparty.Form();
form.parse(req, (err, fields, files) => {
// console.log(fields); // 切片的描述
// console.log(files); // 切片的二进制资源被处理成对象
const [file] = files.file
const [fileName] = fields.fileName
const [chunkName] = fields.chunkName
// 保存切片
const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`)
if (!fse.existsSync(chunkDir)) { // 该路径是否有效
fse.mkdirSync(chunkDir)
}
// 存入
fse.moveSync(file.path, `${chunkDir}/${chunkName}`)
res.end(JSON.stringify({
code: 0,
message: '切片上传成功'
}))
})
}
if (req.url === '/merge') {
const { fileName, size } = await resolvePost(req) // 解析post参数
const filePath = path.resolve(UPLOAD_DIR, fileName) // 完整文件的路径
// 合并切片
const result = await mergeFileChunk(filePath, fileName, size)
if (result) { // 切片合并完成
res.end(JSON.stringify({
code: 0,
message: '文件合并完成'
}))
}
}
})
// 存放切片的地方
const UPLOAD_DIR = path.resolve(__dirname, '.', 'qiepian')
// 解析post参数
function resolvePost(req) {
return new Promise((resolve, reject) => {
req.on('data', (data) => {
resolve(JSON.parse(data.toString()))
})
})
}
// 合并
function pipeStream(path, writeStream) {
return new Promise((resolve, reject) => {
const readStream = fse.createReadStream(path)
readStream.on('end', () => {
fse.removeSync(path) // 被读取完的切片移除掉
resolve()
})
readStream.pipe(writeStream)
})
}
// 合并切片
async function mergeFileChunk(filePath, fileName, size) {
// 拿到所有切片所在文件夹的路径
const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`)
// 拿到所有切片
let chunksList = fse.readdirSync(chunkDir)
// console.log(chunksList);
// 万一切片是乱序的
chunksList.sort((a, b) => a.split('-')[1] - b.split('-')[1])
const result = chunksList.map((chunkFileName, index) => {
const chunkPath = path.resolve(chunkDir, chunkFileName)
// !!!!!合并
return pipeStream(chunkPath, fse.createWriteStream(filePath, {
start: index * size,
end: (index + 1) * size
}))
})
// console.log(result);
await Promise.all(result)
fse.rmdirSync(chunkDir) // 删除切片目录
return true
}
server.listen(3000, () => {
console.log('listening on port 3000');
})
来源:juejin.cn/post/7407262746700365876
uniapp开发微信小程序,我踩了大家都会踩的坑
最近使用uniapp开发了一个微信小程序(本项目技术栈是uniapp + vue3 + ts,用了最近比较火的模板unibest。),踩了一些大家普遍都会踩的坑,下面做一些总结。文章多处引用到权威官方内容和一些比较可靠的文章。如有错误,欢迎指正。
1. 使用微信昵称填写能力遇到的问题
自 2022 年 10 月 25 日 24 时后,wx.getUserProfile
和 wx.getUserInfo
的接口被收回,要想获取微信的昵称头像需要使用微信的头像昵称填写能力。
我们的设计稿中没有编辑确认按钮,所以应该失焦后就调用后端的变更昵称接口:
但是失焦之后,微信会对昵称内容做合规性校验,导致失焦后不能立马获取到输入的内容:
<uv-input v-model="form.name" type="nickname" placeholder="请输入内容" @blur="handleSubmit"></uv-input>
async function handleSubmit() {
console.log('form.value.name', form.value.name) // 测试用户001
console.log('rawName', rawName) // 测试用户001
if (form.value.name === rawName)
return
// ...
}
因此最开始的想法是等待校验结束:
async function handleSubmit() {
// 微信会对type="nickname"的输入框失焦时进行昵称违规校验,这个校验是异步的,所以需要等待一下
await new Promise((resolve) => setTimeout(resolve, 0))
console.log('form.value.name', form.value.name) // Jude
console.log('rawName', rawName) // 测试用户001
if (form.value.name === rawName) {
return
}
// ...
}
但如果真的输入了违规昵称,微信将自动清空输入框内容,而在此之前我的提交请求已经发送:
因此需要用到官方新加的一个回调事件bindnicknamereview
(文档):
<uv-input v-model="form.name" type="nickname" placeholder="请输入内容" @nicknamereview="handleSubmit"></uv-input>
function onNickNameReview(e) {
console.log('onNickNameReview', e)
if (e.detail.pass) {
// 校验通过
handleSubmit()
} else {
form.value.name = rawName
}
}
但发现 uv-ui 并没有提供这个事件,还是没有生效,只能改node_modules
的uv-input
源码,并给uv-ui
提个pr
:
2. 自定义导航栏
原生导航栏配置方面有很多限制,比如不允许修改字体大小等。所以有的时候需要自定义导航栏。
首先注意,webview的页面无法自定义导航栏!
所以:
导航栏高度 = 状态栏到胶囊的间距(胶囊上坐标位置-状态栏高度) * 2 + 胶囊高度 + 状态栏高度
第一步:配置当前页面的json
文件
// pages.json
{ navigationStyle: "custom" }
第二步:获取状态栏和导航栏高度,只需要获取一次即可,获取到可以放到pinia
里
// 自定义导航栏
const statusBarHeight = ref(0)
const navBarHeight = ref(0)
statusBarHeight.value = uni.getSystemInfoSync().statusBarHeight
let menuButtonInfo = uni.getMenuButtonBoundingClientRect()
navBarHeight.value = menuButtonInfo.height + (menuButtonInfo.top - statusBarHeight.value) * 2
第三步:自定义导航栏
<view class="nav-bar">
<!-- 状态栏占位 -->
<view :style="{ height: statusBarHeight + 'px' }"></view>
<!-- 真正的导航栏内容 ,请按照自己的需求自行定义-->
<view class="nav-bar-content" style="font-size: 34rpx;" :style="{ height: navBarHeight + 'px' }">导航栏标题</view>
</view>
问题:微信小程序原生导航栏会根据微信设置(字体大小,是否开启深色模式)等变化,深色模式是页面是可以获取到的,但字体大小等目前没有开放接口,所以无法根据微信设置动态变化。
3. 自定义tabbar
由于原生底部tabbar的局限性,未能满足产品需求,所以需要自定义tabbar。
首先,自定义tabbar的第一步配置pages.json
:
// pages.json
tabBar: {
custom: true,
// ...
},
然后,我们只需要在项目根目录(src)创建custom-tab-bar目录,uniapp编译器会直接它拷贝到小程序中:
<!-- src/custom-tab-bar/index.wxml -->
<view class="tab-bar">
<view class="tab-bar-border"></view>
<view wx:for="{{list}}" wx:key="index" class="tab-bar-item" data-path="{{item.pagePath}}" data-index="{{index}}" bindtap="switchTab">
<image class="tab-bar-item-img" src="{{selected === index ? item.selectedIconPath : item.iconPath}}"></image>
<view class="tab-bar-item-text" style="color: {{selected === index ? selectedColor : color}}">{{item.text}}</view>
</view>
</view>
// src/custom-tab-bar/index.js
Component({
data: {
selected: 0,
color: "#8d939f",
selectedColor: "#e3eaf9",
list: [{
pagePath: "/pages/index/index",
iconPath: "../static/tabbar/home01.png",
selectedIconPath: "../static/tabbar/home02.png",
text: "首页"
}, {
pagePath: "/pages/my/my",
iconPath: "../static/tabbar/user01.png",
selectedIconPath: "../static/tabbar/user02.png",
text: "我的"
}]
},
attached() {
},
methods: {
switchTab(e) {
const data = e.currentTarget.dataset
const url = data.path
wx.switchTab({url})
this.setData({
selected: data.index
})
}
}
})
// src/custom-tab-bar/index.json
{
"component": true
}
// src/custom-tab-bar/index.wxss
.tab-bar {
position: fixed;
bottom: calc(16rpx + env(safe-area-inset-bottom));
left: 0;
right: 0;
height: 100rpx;
background: linear-gradient(180deg, rgba(13, 15, 26, 0.95) 0%, rgba(42, 50, 76, 0.95) 100%);
box-shadow: 0rpx 4rpx 16rpx 0px rgba(0, 0, 0, 0.12);
display: flex;
width: calc(100% - 2 * 36rpx);
border-radius: 36rpx;
margin: 0 auto;
}
.tab-bar-item {
flex: 1;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.tab-bar-item .tab-bar-item-img {
width: 32rpx;
height: 32rpx;
}
.tab-bar-item .tab-bar-item-text {
margin-top: 10rpx;
font-size: 20rpx;
}
最后,关键坑注意:每个tab页都有自己的tabbar实例:
因此需要每个tab页渲染时设置一下自定义tabbar组件的 activeIndex
(我这里变量名是selected
):
如果是原生小程序开发像官网那样写就好,如果是uniapp
开发,需要:
onShow(() => {
const currentPage = getCurrentPages()[0]; // 获取当前页面实例
const currentTabBar = currentPage?.getTabBar?.();
// 设置当前tab页的下标index
currentTabBar?.setData({ selected: 0 });
})
效果:
4. IOS适配安全距离
当用户使用圆形设备访问页面时,就存在“安全区域”和“安全距离”的概念。安全区域指的是一个可视窗口范围,处于安全区域的内容不受圆角(corners
)、齐刘海(sensor housing
)、小黑条(Home Indicator
)的影响。
上图来自designing-websites-for-iphone-x
uniapp适配:
uniapp适配安全距离有三个方法:
a. manifest.json配置安全距离
// manifest.json
{
"app-plus": {
"safearea": { //可选,JSON对象,安全区域配置
"background": "#RRGGBB", //可选,字符串类型,#RRGGBB格式,安全区域背景颜色
"backgroundDark": "#RRGGBB", //可选,字符串类型,#RRGGBB格式,暗黑模式安全区域背景颜色
"bottom": { //可选,JSON对象,底部安全区域配置
"offset": "auto" //可选,字符串类型,安全区域偏移值,可取值auto、none
},
"left": { //可选,JSON对象,左侧安全区域配置
"offset": "none" //可选,字符串类型,安全区域偏移值,可取值auto、none
},
"right": { //可选,JSON对象,左侧安全区域配置
"offset": "none" //可选,字符串类型,安全区域偏移值,可取值auto、none
}
},
}
}
问题: 这种方式显然不够灵活,它设置的是单独的背景色,如果需要下方一个区域是背景图,延伸到底部安全区就满足不了了。
所以,我是将以上的配置设置成none
,然后手动适配页面的安全距离:
b. js获取安全距离
let app = uni.getSystemInfoSync()
app.statusBarHeight // 手机状态栏的高度
app.bottom // 底部安全距离
c. 使用苹果官方推出的css函数env()、constant()适配
padding-bottom: constant(safe-area-inset-bottom); /*兼容 IOS<11.2*/
padding-bottom: env(safe-area-inset-bottom); /*兼容 IOS>11.2*/
注意: constant
和env
不能调换位置
可以配合calc
使用:
padding-bottom: calc(constant(safe-area-inset-bottom) + 20rpx); /*兼容 IOS<11.2*/
padding-bottom: calc(env(safe-area-inset-bottom) + 20rpx); /*兼容 IOS>11.2*/
h5适配
网页适配安全距离的前提是需要将<meta name="viewport">
标签设置viewport-fit:cover;
:
<meta name='viewport' content='initial-scale=1, viewport-fit=cover'>
直观一点就是:
上图来自移动端安全区域适配方案
然后再使用env
和constant
padding-bottom: constant(safe-area-inset-bottom); /*兼容 IOS<11.2*/
padding-bottom: env(safe-area-inset-bottom); /*兼容 IOS>11.2*/
5. 列表滚动相关问题
列表滚动如果使用overflow: auto;
在首次下拉时(即使触控点在列表内)也会使整个页面下拉:
解决这个问题只需要将内容使用 scroll-view 包裹即可:
<scroll-view scroll-y class="max-h-[800rpx] overflow-auto"></scroll-view>
下拉刷新将列表滚动到顶部:
小程序默认使用webview
渲染,如果需要Skyline
渲染引擎需要配置,而srcoll-view
标签在webview
中有个独有的属性enhanced
,启用后可通过 ScrollViewContext 操作 scroll-view
:
<scroll-view id="scrollview" :enhanced="true" scroll-y class="max-h-[800rpx] overflow-auto"></scroll-view>
/** 将scrollview滚动到顶部 */
function scrollToTop(id: string) {
wx.createSelectorQuery()
.select(id)
.node()
.exec((res) => {
const scrollView = res[0].node;
scrollView.scrollTo({
top: 0,
animated: true
});
})
}
onPullDownRefresh(async () => {
console.log('下拉刷新')
try {
await fetchList()
} catch (error) {
console.log(error)
} finally {
uni.stopPullDownRefresh()
scrollToTop('#scrollview')
}
})
6. 配置小程序用户隐私保护指引
文档:小程序隐私协议开发指南
什么时候要配置:
但凡你的小程序用到上图中任何一种用户信息就得配置,否则使用wx.authorize
来获取相应授权时直接会走到fail
回调,报 { "errMsg": "authorize:fail api scope is not declared in the privacy agreement", "errno": 112 }
配置的是什么:
配置的是将来你的程序打开让用户确认授权的隐私协议内容。
如何配置:
登录微信公众平台 -> 设置 -> 服务内容声明 -> 用户隐私保护指引 -> 修改
隐私弹框触发的流程是什么:
程序调用隐私相关接口 ——> 微信判断该接口是否需要隐私授权 ——> 如果需要隐私授权且开发者没有对其响应(注册onNeedPrivacyAuthorization的监听事件)则主动弹出官方弹框(此时隐私相关接口调用处于pending状态,如果用户拒绝将会报{ "errMsg":" getLocation:fail privacy permission is not authorized", "errno":104 }
)。
代码逻辑:
配置并等待审核通过后,进行以下步骤:
1. 配置 __usePrivacyCheck__: true
尽管官方文档说明2023年10月17日之后无论是否配置改字段,隐私相关功能都会启用,但是实际尝试后发现还是得配置上才生效。
// manifest.config.ts
'mp-weixin': {
__usePrivacyCheck__: true
},
2. 自定义隐私弹框组件
尽管官方提供了官方隐私弹框组件,但是真机上没有生效,于是还是使用了自定义隐私弹框。
我是直接在插件市场找了一个下载量最多的插件,兼容vue2和vue3。
在小程序对应的页面:
<WsWxPrivacy id="privacy-popup" @agree="onAgree" @disagree="onDisAgree"></WsWxPrivacy>
function onAgree() {}
function onDisAgree() {}
tip: 这部分逻辑相对于业务是几乎没有耦合的,甚至如果没有特殊需求agree
和disagree
事件都不用写。如果将来官方主动弹框没问题了,那这个逻辑可以直接删掉。
3. 业务代码
举个例子,我这里隐私相关接口是uni.getLocation
获取用户地理位置。
function handleCheckLocation() {
return new Promise((resolve, reject) => {
uni.getLocation({
type: 'gcj02',
success: async (res) => {
console.log('当前位置:', res)
try {
let r = await checkLocation({
lon: res.longitude.toString(),
lat: res.latitude.toString(),
})
// ...
resolve('success')
} catch (error) {
reject(error)
}
},
fail: (error) => {
console.log('获取位置失败:', error)
reject(error)
}
})
})
}
以上代码,在调用uni.getLocation
时,微信自动发起位置授权,发起位置授权之前又会自动发起隐私授权。到此,这一流程是ok的。但是,如果用户拒绝了隐私授权,或者拒绝了位置授权,该怎么办?
如果拒绝了隐私授权,下次调用隐私相关接口时还会再次弹出隐私授权弹框。
如果拒绝了位置授权,下次调用就不会弹出位置授权弹框,但可以通过uni.getSetting
来判断用户是否拒绝过,再通过wx.openSetting
让用户打开设置界面手动开启授权。代码如下:
function getLocationSetting() {
uni.getSetting({
success: (res) => {
console.log('获取设置:', res)
if (res.authSetting['scope.userLocation']) {
// 已经授权,可以直接调用 getLocation 获取位置
handleCheckLocation()
} else if (res.authSetting['scope.userLocation'] === false) {
// 用户已拒绝授权,引导用户到设置页面开启
wx.showModal({
title: '您未开启地理位置授权',
content: '请在设置中开启授权',
success: res => {
if (res.confirm) {
wx.openSetting({
success(settingRes) {
if (settingRes.authSetting['scope.userLocation']) {
// 用户打开了授权,再次获取地理位置
handleCheckLocation()
}
}
})
}
}
})
} else {
// 首次使用功能,请求授权
uni.authorize({
scope: 'scope.userLocation',
success() {
handleCheckLocation()
}
})
}
}
})
}
当然你也可以封装一下:
function getSetting(scopeName: string, cb: () => any) {
uni.getSetting({
success: (res) => {
console.log('获取设置:', res)
if (res.authSetting[scopeName]) {
// 已经授权,可以直接调用
cb()
} else if (res.authSetting[scopeName] === false) {
// 用户已拒绝授权,引导用户到设置页面开启
wx.showModal({
title: '您未开启相关授权',
content: '请在设置中开启授权',
success: res => {
if (res.confirm) {
wx.openSetting({
success(settingRes) {
if (settingRes.authSetting[scopeName]) {
// 用户打开了授权,再次获取地理位置
cb()
}
}
})
}
}
})
} else {
// 首次使用功能,请求授权
uni.authorize({
scope: scopeName,
success() {
cb()
}
})
}
}
})
}
这样,整个隐私协议指引流程就完整了。
来源:juejin.cn/post/7361688292351967259
Web3:未来互联网的颠覆与机遇
一、Web3 的定义与核心概念
Web3 是区块链等技术的总称,这些技术可以分散互联网上的数据所有权和控制权。大多数互联网应用程序都由集中式实体控制,这些实体决定如何保存和使用最终用户数据。Web3(也称为 Web 3.0、去中心化 Web 或语义 Web)技术允许社区驱动的项目,而不是集中式管理结构。在这些项目中,最终用户控制数据、确定定价、直接为技术开发做出贡献,并在项目的方向上拥有更重要的发言权。这些技术具有自动调节用户相互交互方式的机制。因此,不需要集中式实体来管理这些交互。
- Web3是互联网的第三代,旨在通过区块链技术实现去中心化、更加安全和隐私的数据交互。
- Web3的主要特点包括语义Web、人工智能、3D图形、无处不在的网络、开放性和互操作性。
- Web3通过去中心化的区块链技术,将权力和数据集中到用户手中,而不是某个公司。
- Web3应用程序(DApps)在去中心化网络上运行,用户可以在未经中央公司许可的情况下构建和连接不同的DApp。
Web 3.0 具备四项主要功能。
去中心化
去中心化的 Web 应用程序是 Web 3.0 的关键功能。其目的是在去中心化网络中分发和存储数据。在这些网络中,不同的实体拥有底层基础设施,用户直接向存储提供商付费以访问该空间。
去中心化的应用程序还将信息副本存储在多个位置,并确保整个过程中的数据一致性。每位用户可以控制其数据存放的位置,而不必将其移交给集中式基础设施。去中心化的互联网用户可根据需要出售自己的数据。
去信任性
在集中式 Web 应用程序和服务中,用户通常需要信任中央权威机构来管理其数据、交易和交互。这些中央权威机构可以控制用户数据,并且可以操纵系统的规则。数据可能存在安全风险或管理不善,从而导致用户信息丢失或滥用。
相比之下,Web3 引入去信任性,因此用户可以在无需信任任何特定方的情况下进行交易和交互。
语义网
借助语义网,应用程序能够通过理解 Web 数据的内容和上下文来执行复杂的任务。语义网使用元数据和人工智能为用户生成的数据提供含义(语义)。
Web 3.0 旨在更全面地转向目前存在于现有 Web 技术某些方面中的语义网技术。例如,搜索引擎可提供更准确且与上下文相关的搜索结果,而智能代理则可帮助用户更高效地执行任务。
互操作性
Web 3.0 的目标是在不同技术之间建立更多的互连,从而数据无需中介即可在不同平台之间流动。互操作性使数据具有可移植性,因此用户可以在服务之间无缝切换,同时保持自己的首选项、配置文件和设置。
与此同时,集成各种物联网(IoT)设备的协议将 Web 的覆盖范围扩展到传统边界之外。例如,支持无边界交易的加密货币技术允许跨地域和政治边界进行价值交换。
二、互联网的发展史:从Web 1.0到Web 2.0再到Web3
要充分理解Web3的含义,就必须先看互联网的发展史,以及Web3与前两个发展阶段的不同之处。
Web 1.0(1994-2004)
Web 1.0是互联网的第一个发展阶段,这个阶段从1994年一直延续到2004年,期间出现了Twitter和Facebook等社交媒体巨头。虽然大众在1994年左右才接触到Web 1.0,但实际上早在1968年,一个名为“ARPANET”(全称是Advanced Research Projects Agency Network)的美国政府项目就启动了Web 1.0。ARPANET最初是由军方承包商和大学教授组成的一个小型网络,他们在其中互相交换数据。
Web 1.0主要是静态的HTML网页,用户之间很少交互。虽然有门户网站以及私人聊天室和BBS等论坛,但总的来说当时的互联网仍没有什么交互或支付交易功能。
Web 2.0(2004年至今)
互联网在2004年左右经历了蜕变,由于当时互联网在网速、光纤基础设施和搜索引擎等方面都取得了发展,因此用户对社交、音乐、视频分享和支付交易的需求大幅上升。
这种更具互动性的全新互联网体验为用户带来了许多新的功能,并提升了用户体验。但问题也随之而来,并且直到今天也一直无法彻底解决,那就是:用户如果要使用这些新功能,就必须授权中心化的第三方平台管理大量数据。因此这些中心化的实体在数据和内容权限方面被赋予了巨大的权力和影响力。
Web3(2008年之后)
在2008年,中本聪发布了比特币白皮书,在其中指出了区块链技术的核心基础并发明了点对点的数字货币,由此掀起了Web 2.0的改革浪潮。比特币彻底颠覆了我们对数字化交易的概念,并首次提出了一种无需可信中间方的安全在线交易模式。中本聪写道:“需要基于加密证明,而非信任,来建立电子支付系统。”
直到智能合约被发明后,去中心化的互联网模式才真正进入公众视野。如果说比特币实现了点对点支付,智能合约扩展了可编程协议的概念,实现了保险、游戏、身份管理和供应链等更高级的用例,那么这一切会如何影响互联网用户体验和数字化交互呢?智能合约用户可以直接、安全地交互,因此打造了一个更加公平、透明且基于加密事实的新型互联网。
Gavin Wood将这个升级版的互联网称作“Web3”,即“一个安全的、由社会运行的系统”。
简而言之,Web3就是一个去中心化的互联网,旨在打造出一个全新的合约系统,并颠覆个人和机构达成协议的方式。Web3复刻了第一版互联网(即Web 1.0)的去中心化基础架构,Web 1.0的特色是用户自己架设博客网站以及RSS feed。在此基础上,Web3还结合了Web 2.0丰富的交互体验,比如社交媒体平台。Web 1.0和Web 2.0相结合,就形成了Web3的数字化生态,在其中用户可以真正拥有自己的数据,并且交易受到了加密技术保障。用户无需再信任品牌背书,而是可以依赖确定的软件代码逻辑来严格执行协议。
三、Web3对前端开发的影响
- 在Web3.0中,前端不再仅仅是展示层,而是成为了与智能合约、区块链网络直接交互的重要桥梁。
- 前端开发者需要掌握如何通过Web3.0技术栈,如以太坊智能合约、IPFS等,实现去中心化应用(DApp)的开发。
- Web3.0强调去中心化和用户数据主权,这要求前端开发在设计和实现应用时,更加注重用户隐私保护和数据安全。
- Web3.0时代的前端开发还面临着性能优化的挑战,由于区块链操作通常较慢,前端需要进行相应的优化。
四、Web3前端开发需要掌握的技术
- 区块链技术:了解区块链的基本原理、共识机制、加密算法等基础知识。
- 智能合约开发:掌握智能合约的编写语言(如Solidity)和开发工具,以及合约的部署和调用方法。
- 去中心化应用设计:了解去中心化应用的设计原则、用户体验和开发流程。
- 分布式存储技术:熟悉常见的分布式存储方案,如IPFS。
- 前端开发技术:具备HTML、CSS和JavaScript等基础技能,同时了解React、Vue.js、Web3.js等前端框架和库。
- 安全性和隐私保护:了解如何进行合约审计、安全防范和数据加密等方面的知识。
五、Web3前端开发的工具和库
- Web3.js:一个JavaScript API库,用于与以太坊区块链进行交互。
- Ethers.js:一个小而完整的JavaScript API库,为以太坊区块链及其生态系统提供支持。
- Truffle:一个以太坊智能合约开发框架,提供编译和测试智能合约的开发环境。
- Remix IDE:一个用于编写和使用智能合约的在线编辑器。
- MetaMask:一个Chrome扩展程序,可让用户从浏览器连接到以太坊区块链网络。
- Ganache:提供本地区块链环境,用于测试智能合约。
六、Web3前端开发挑战
Web3的去中心化特性和用户数据主权要求前端开发者在编码时采用更高的安全标准。前端开发者需要掌握如何使用端到端加密技术来保护用户数据,确保数据在传输和存储过程中的安全性。
由于区块链操作通常较慢,前端开发者需要优化性能以确保用户体验。通过使用高效的缓存策略和异步处理技术,开发者可以减少用户等待时间,提升应用的响应速度。
用户体验在Web3应用中至关重要。前端开发者需要设计直观且友好的用户界面,确保用户能够轻松地与区块链进行交互。这包括使用现代前端框架如React和Vue.js来构建动态和响应式的界面。
Web3技术栈的复杂性要求前端开发者掌握多种新技术和工具,如Web3.js、ethers.js等库。这些工具帮助开发者与区块链进行交互,实现智能合约调用和数据读取。
隐私保护是Web3前端开发的核心要求之一。开发者需要采用端到端加密技术,确保用户数据在传输和存储过程中的安全性,防止数据泄露和篡改。
七、Web3前端开发未来趋势
虚拟现实(VR)技术与Web3的融合,正在为用户提供前所未有的沉浸式交互体验。通过Web3的去中心化特性,用户可以在虚拟现实中拥有更高的自主权和数据安全性。这种技术融合不仅改变了传统互联网的边界,还为开发者提供了创造丰富多样用户体验的机会。
元宇宙是Web3技术的一个重要应用领域。通过去中心化的区块链技术,元宇宙中的虚拟世界可以实现更高的透明度和安全性。用户在元宇宙中不仅可以进行虚拟资产交易,还可以参与到去中心化的社区治理中,真正实现虚拟世界的自治。
智能合约作为Web3的重要组成部分,将继续在前端开发中发挥关键作用。前端开发者需要掌握智能合约的编写和部署,以便在去中心化应用中实现自动化和安全的交易。智能合约的应用不仅限于金融领域,还可以扩展到供应链管理、数字身份验证等多个方面。
去中心化存储技术,如IPFS,将成为Web3前端开发的重要组成部分。IPFS通过分布式存储网络,提供了更高效和安全的数据存储解决方案。前端开发者可以利用IPFS来存储和检索数据,确保数据的完整性和不可篡改性,从而提升应用的可靠性。
跨链技术的发展将促进不同区块链之间的互操作性。前端开发者需要关注跨链技术的最新进展,以便在开发去中心化应用时实现不同区块链之间的数据和资产互通。跨链技术不仅可以提升区块链网络的整体效率,还可以为用户提供更加便捷的跨链交易体验。
参考:
作者:洞窝-雪花
来源:juejin.cn/post/7407263786132308003
面试官问我为什么 [] == ![] 为 true, 我表面冷静,实则内心慌的一批
前言
面试官问我,[] == ![] 的结果是啥,我:蒙一个true; 面试官:你是对的;我:内心非常高兴;
面试官:解释一下为什么; 我:一定要冷静,要不就说不会吧;这个时候,面试官笑了,同学,感觉你很慌的一批啊!
不必慌张,我们慢慢来!
在当今的编程领域,面试不仅是技术能力的考察,更是思维灵活性与深度理解的试金石。面试中偶遇诸如 [] == ![]
表达式这类题目,虽让人初感意外,实则深藏玄机,考验着我们对于JavaScript这类动态语言特性的透彻理解。这类问题触及了类型转换、逻辑运算以及语言设计的微妙之处,促使我们跳出日常编码的舒适区,深入探索编程语言的底层机制。接下来,我们将一步步揭开这道题目的神秘面纱,不仅为解答此类问题提供思路,更旨在通过这一过程,提升我们对JavaScript核心概念的掌握与应用能力。
首先我们来聊一下基础的东西。
1.原始值转布尔
首先是原始值转布尔
console.log(Boolean(1));//true
console.log(Boolean(0));//false
console.log(Boolean(-1));//true
console.log(Boolean(NaN));//false
console.log(Boolean('abc'));//true
console.log(Boolean(''));//false
console.log(Boolean(false));//flase
console.log(Boolean(undefined));//false
console.log(Boolean(null));//false
2.原始值转数字
console.log(Number('123'));//123
console.log(Number('hello'));//NaN
console.log(Number(true));//1
console.log(Number(false));//0
console.log(Number(''));//0
console.log(Number(' '));//0
console.log(Number(undefined));//NaN
console.log(Number(null));//0
3.原始值转字符串
console.log(String(123));//'123'
console.log(String(true));//'true'
console.log(String(false));//'false'
console.log(String(undefined));//'undefined'
console.log(String(null));//'null'
然后我们来了解一下与对象有关的转换逻辑
4. 原始值转对象
let a = new Number(1)
console.log(a);//[Number: 1]
其实也没有很特殊的,就是利用构造函数去进行显式转换即可。
5.对象转原始值
5.1 对象转布尔
首先我们来到这题,最后结果会被打印,说明对象在转换为布尔值的时候,不管什么对象,都是被转换为true。
5.2 + 一元运算符
我们先来了解一下,一元运算符的作用。查阅js官方文档,我们可以知道就是调用ToNumber()得到结果。而ToNumber()就是调用Number方式所调用的内置函数,因此就是强制转换为数字。我们也可以理解为+和Number()的作用是一样的。
5.3 + 二元运算符
二元运算符调用ToPrimitive()方法(ToNumber中的,转换方式有差异)。
5.4 ToNumber()方法
那么这个方法具体执行过程是什么呢?我们可以看到,如果是基本数据类型转数字,我们之前已经聊到,因此不必多聊,而面对对象转数字的时候,我们会先调用ToPrimitive方法。
5.5 ToPrimitive()方法
关于这个方法,我们要看是被ToNumber还是Totring方法给调用了。二者在返回值的顺序上会有所差异。
我们来聊一聊里面的valueOf()和toString()方法。
5.6 toString()和valueOf()方法
1. {}.toString() 得到由"[object class ] "组成字符串
2. [].toString() 返回由数组内部元素以逗号拼接的字符串
3. xx.toString() 返回字符串字面量
- valueOf 也可以将对象转成原始类型
1. 包装类对象
5.6 == 比较
我们首先引入官方文档
首先我们看二者类型相同时的比较, 里面有一点需要注意,只要有一个NaN就返回false,其他的我们应该都清楚。
二者类型不相等时,我们需要特别注意的是,null和undefined是相等的,字符串和数字则把字符串转数字,布尔和其他把布尔转数字,出现对象先把对象转原始值。
估计上面的大量干货已经把大家快搞懵逼了,此这里我们做个简单小结,这里面的方法前面都有提到哦。
5.7 小结(重点)
对象转数字
Number(obj) => ToNumber(obj) => ToNumber(ToPrimitive(obj,Number))
对象转字符串
String(obj) => ToString(obj) => ToString(ToPrimitive(obj,String))
5.8 大量实战练习
这一题我们知道+的作用和Number的方法是一样的。因此是转换为数字123.
那么这一题,我们考虑到
Number([]) => ToNumber([]) => ToNumber(ToPrimitive([], Number))=> ToNumber('') => 0
这里只要对象转布尔均为true
这里的底层原理(5.3里说了)是,我们首先两边都调用ToPrimitive方法,看看有没有字符串,有的话就把另一方转换为字符串,没有的话就全部调用ToNumber方法相加。
这里也是一样的原理。
我们先把两边转换为原始值,左边为' ',右边为'[object object]',发现存在字符串,因此相加。
这里只要我们看5.6就可以很轻松搞懂。
同上一个。
首先有对象,我们把对象转原始值,然后为NaN,为false.
最后回到我们最开始的题目,首先碰见![],我们先把[]转为布尔,为true,!true为false,然后把左边对象转原始值,为' ' == false,出现布尔和字符串,把布尔转数字,为' ' == 0,出现字符串和数字,把字符串转数字,为0 == 0,因此最后结果为true
来源:juejin.cn/post/7371312966364332042
前端比localStorage存储还大的本地存储方案
产品的原话就是“要又大又全”。既然存储量大,也要覆盖全多种设备多种浏览器。
方案选择
- 既然要存储的数量大,得排除cookie
- localStorage,虽然比cookie多,但是同样有上限(5M)左右,备选
- websql 使用简单,存储量大,兼容性差,备选
- indexDB api多且繁琐,存储量大、高版本浏览器兼容性较好,备选
既然罗列了一些选择,都没有十全十美的,那么有没有一种能够集合这多种方式的插件呢?渐进增强 or 优雅降级 的存在
冲着这个想法,就去github和谷歌找了一下,还真的有这么一个插件。
那就是 localforage
localforage
localForage 是一个 JavaScript 库,只需要通过简单类似 localStorage
API 的异步存储来改进你的 Web 应用程序的离线体验。它能存储多种类型的数据,而不仅仅是字符串。
关于兼容性
localForage 有一个优雅降级策略,若浏览器不支持 IndexedDB 或 WebSQL,则使用 localStorage。在所有主流浏览器中都可用:Chrome,Firefox,IE 和 Safari(包括 Safari Mobile)。下面是 indexDB、web sql、localStorage 的一个浏览器支持情况,可以发现,兼容性方面loaclForage基本上满足99%需求
使用
解决了兼容性和存储量的点,我们就来看看localforage的基础用法
安装
# 通过 npm 安装:
npm install localforage
// 直接引用
<script src="localforage.js"></script>
<script>console.log('localforage is: ', localforage);</script>
获取存储
getItem(key, successCallback)
从仓库中获取 key 对应的值并将结果提供给回调函数。如果 key 不存在,getItem() 将返回 null。
localforage.getItem('somekey').then(function(value) {
// 当离线仓库中的值被载入时,此处代码运行
console.log(value);
}).catch(function(err) {
// 当出错时,此处代码运行
console.log(err);
});
// 回调版本:
localforage.getItem('somekey', function(err, value) {
// 当离线仓库中的值被载入时,此处代码运行
console.log(value);
});
设置存储
setItem(key, value, successCallback)
将数据保存到离线仓库。你可以存储如下类型的 JavaScript 对象:
- Array
- ArrayBuffer
- Blob
- Float32Array
- Float64Array
- Int8Array
- Int16Array
- Int32Array
- Number
- Object
- Uint8Array
- Uint8ClampedArray
- Uint16Array
- Uint32Array
- String
localforage
.setItem("somekey", "some value")
.then(function (value) {
// 当值被存储后,可执行其他操作
console.log(value);
})
.catch(function (err) {
// 当出错时,此处代码运行
console.log(err);
});
// 不同于 localStorage,你可以存储非字符串类型
localforage
.setItem("my array", [1, 2, "three"])
.then(function (value) {
// 如下输出 `1`
console.log(value[0]);
})
.catch(function (err) {
// 当出错时,此处代码运行
console.log(err);
});
// 你甚至可以存储 AJAX 响应返回的二进制数据
req = new XMLHttpRequest();
req.open("GET", "/photo.jpg", true);
req.responseType = "arraybuffer";
req.addEventListener("readystatechange", function () {
if (req.readyState === 4) {
// readyState 完成
localforage
.setItem("photo", req.response)
.then(function (image) {
// 如下为一个合法的 <img> 标签的 blob URI
var blob = new Blob([image]);
var imageURI = window.URL.createObjectURL(blob);
})
.catch(function (err) {
// 当出错时,此处代码运行
console.log(err);
});
}
});
删除存储
removeItem(key, successCallback)
从离线仓库中删除 key 对应的值。
localforage.removeItem('somekey').then(function() {
// 当值被移除后,此处代码运行
console.log('Key is cleared!');
}).catch(function(err) {
// 当出错时,此处代码运行
console.log(err);
});
清空存储
clear(successCallback)
从数据库中删除所有的 key,重置数据库。
localforage.clear() 将会删除离线仓库中的所有值。谨慎使用此方法。
localforage.clear().then(function() {
// 当数据库被全部删除后,此处代码运行
console.log('Database is now empty.');
}).catch(function(err) {
// 当出错时,此处代码运行
console.log(err);
});
localforage是否万事大吉?
用上了localforage一开始我也以为可以完全满足万恶的产品了,然而。。。翻车了.。
内存不足的前提下,localforage继续缓存会怎么样?
在这种状态下,尝试使用localforage,不出意外,抛错了 QuotaExceededError 的 DOMErro
解决
存储数据的时候加上存储的时间戳和模块标识,加时间戳一起存储
setItem({
value: '1',
label: 'a',
module: 'a',
timestamp: '11111111111'
})
- 如果是遇到存储使用报错的情况,try/catch捕获之后,通过判断报错提示,去执行相应的操作,遇到内存不足的情况,则根据时间戳和模块标识清理一部分旧数据(内存不足的情况还是比较少的)
- 在用户手机上产生脏数据的情况,想要清理的这种情况的 处理方式是:
- 让后端在用户信息接口里面加上缓存有效期时间戳,当该时间戳存在,则前端会进行一次对本地存储扫描
- 在有效期时间戳之前的数据,结合模块标识,进行清理,清理完毕后调用后端接口上报清理日志
- 模块标识的意义是清理数据的时候,可以按照模块去清理(选填)
来源:juejin.cn/post/7273028474973012007
总要有点爱好来支撑乏味的生活
我,一名7年老前端,目前还在从事前端工作,接下来这篇文章可能有点长,主要围绕我是如何接触画画这件事。
2015年毕业后,第一份工作是浙江台州的一家药企:海正药业,在实验室从事肿瘤药的研发工作,工资3000+,包吃住。
2016-2017年,这期间算是成为前端程序员的探路阶段,在这个过程中,我接触了很多不一样的工作。第一份工作:某三方检测机构检测员,因为公司规模不大,所以和老板几乎每天待在一起,每天做的事情就是陪着老板出入各酒店,检测酒店的房间是否符合卫生标准,检测游泳池细菌是否在规定范围内,然后出具一份检测报告,用来保证酒店的正常营业,检测报告每年一检,每到一处酒店的人员都是笑脸相迎,很有面的感觉,但是这没有给我带来成就感,工资也才4000+,干了大半年后我还是决定离开;第二份工作:SGS宁波分公司检测员,每天在实验室检测各种材料的材质是否符合标准,和上一份工作有点类似,有人会好奇既然差不多,为什么还要选择尝试,因为SGS当时给人的感觉特别高大上,连邮件都是纯英文的,干了三个月,转完正我离职了,原因还是工资太低看不到希望;第三份工作:宁波荃盛粽子公司,包住,铺盖都搬进去了,突然灵光一闪,我以后就是个卖粽子的,赶紧跑。至此2016年已过半,我该怎么办,思来想去,我决定先不找工作了,专心自学安卓开发,学了小半年,感觉入门了但是心里还是空空的,出去面试的时候也不够自信,试了两家公司后我感觉还是不行,我还是没办法在这个行业扎根,于是2017年初,我毅然决然去了杭州,在那边培训了半年,回到宁波,找到了当时的第一份前端工作,老东家的同事都很好,刚去就体验了一把福利,整个部门集体三亚7日游,成年往事这里就不找照片了。也是在这一年,我在父母的帮助下,在宁波买了房,因为这时候月供才还的起,之前的工资都是养活不了自己的。
2017.08-现在,在现在的公司待了快5年了,公司敏捷开发模式,好在福利都还不错,每年有3000块的京东卡福利,逢年过节也都有很正式的礼盒,就加班稍微多一点,但早已习惯。
小插曲,2019年因为自己从事小程序开发有两年时间了,当时给自己做了两个小程序,都开源出来了,小程序云开发布道者,不知道那些粉丝还在不在,相当老的粉丝了,哈哈哈。
接下来我要讲画画这件事,这是文章的重头戏,我先附下链接从不懂油画到作品入展我用了多久。
今年,脑子里想的最多的还是,我是不是要找点事情做做,与其每天刷小视频不如利用这些时间做点有意义的事,于是有了下面这篇文章:心流C大调画展,截取里面的个人介绍方便大家查看:
在这里还是啰嗦一遍,虽然上面链接有讲到我入门油画的过程。上面说的有意义的事情其实在我脑子里已经计划了大半年,那就是画画,画什么画呢,高大上一点的,那就油画吧,油画保存的好的话可以几百年不褪色,那跟谁学 呢,不知道。一次偶然的机会,我在小红书看到了Michael老师的画画视频,发现老师真的很厉害,加上老师有留言说如果想学习就扣“666”,于是我顺利联系上了我现在的助教老师:sandy老师(mjssandy),交谈下来除了费用一下子有点难接受,其他都满意,那些天我像着了魔似的,就是想成为老师的终身会员,终身跟着老师画下去,于是经过两次发工资的倒腾,我终于报上了终身课,于是有了我现在的作品,下面我一一展示给大家看看:
田园风景
田园风景是入门油画的第一幅作品,很多人可能会觉得这不像没有基础的人画的,错,我至今不懂素描是什么,小时候喜欢一个人安静的画画,上学后就没有支撑这个爱好的资本了,一直搁置到现在。
苹果
苹果是第二幅作品,我突然发现自己对这种要求素描功底的静物反而画的有信心一些,于是第三幅作品我打算选一个有难度的。
狸花猫
这幅画用时20天,每天1-2小时,是我真正意义上的第一幅满意的作品。
清晨薄雾
这是一幅特别美的风景画,我自己还是蛮喜欢,只是前景的草画的有点凌乱。
客户定制猫咪-冥想猫
画完狸花猫,居然有人找我画订单了,这真的太不可思议了,接到这个消息后我第一时间联系我的老师,老师鼓励我勇敢去尝试,最终以1250的价格成交了这幅画。
英伦玫瑰
这幅画是继苹果之后的另一幅静物画作,画的过程中很得心应手,但是选这幅画也是有原因的,当时离520还有10天的时间,打算浪漫一下,画完送给老婆,谁知中间接到客户的猫咪定制,就把玫瑰暂时搁置了,画完冥想猫已经是520之后的事了,但是换的钱上交给了老婆,老婆很满意。
老者画像
老者画像,用时32天,每天1-2小时,这是有史以来画的最吃力的一幅作品,一个鼻子画了4天,好在最后很完美,老师都惊呆了,这是接触油画3个月的人能完成的事?!
儿子的大猩猩
儿子的大猩猩,儿子喜欢,那就安排,现在技法还太单一,以后画肯定不是这么个水平,这是继冥想猫之后的第二幅创作作品。
我是如何平衡工作,生活与画画的
一开始,我尝试晚上画,常常一画就是凌晨,导致第二天上班的精神萎靡,知道行不通我就改在周末多画画,但是有一个问题,一天画太长时间人会烦躁郁闷,周末也行不通。而且上面两个时间段我都放弃了陪家人的时间,完全不可取。后来我和老婆商量晚上早点睡,如果儿子晚上太兴奋我也不管,让老婆陪着他,我到了10点就要准时睡觉,不然无法保证我早上5点起床的目标,适应了1个月,感觉良好,一直到现在已经坚持4个月了,白天中午都会休息半小时,早上都能保证至少画1个小时,兴致高就2小时,这个时间段老婆孩子还在睡,完全属于我的个人放空时间。而且养成了早睡早起的习惯后我整个人的精神状态也好了,还有老婆孩子看到我能画出这么厉害的画,都有点崇拜主义,老婆更是每完成一幅作品必帮我发朋友圈炫耀,画画原来还有助于家庭和谐,夫妻幸福!
结语
我没敢想以后靠画画为生,但是我在坚持,坚持这个词看似不妥,但是没有坚持再好的天赋也会被埋没。最后我想对大家说的是,如果你有爱好,你不妨去尝试一下,不去尝试你永远不知道自己有多厉害!
最后的最后送给大家一句话,与君共勉:坚持和热爱是最好的天赋!
来源:juejin.cn/post/7404777095623802890
Jenkins:运维早搞定了,但我就是想偷学点前端CI/CD!
前言:运维已就绪,但好奇心作祟
- 背景故事: 虽然前端开发人员平时可能不会直接操控Jenkins,运维团队已经把这一切搞得井井有条。然而,你的好奇心驱使你想要深入了解这些自动化的流程。本文将带你一探究竟,看看Jenkins如何在前端项目中发挥作用。
- 目标介绍: 了解Jenkins如何实现从代码提交到自动部署的全过程,并学习如何配置和优化这一流程。
- 背景故事: 虽然前端开发人员平时可能不会直接操控Jenkins,运维团队已经把这一切搞得井井有条。然而,你的好奇心驱使你想要深入了解这些自动化的流程。本文将带你一探究竟,看看Jenkins如何在前端项目中发挥作用。
- 目标介绍: 了解Jenkins如何实现从代码提交到自动部署的全过程,并学习如何配置和优化这一流程。
Jenkins基础与部署流程
1. Jenkins到底是个什么东西?
- Jenkins简介: Jenkins是一个开源的自动化服务器,用于持续集成和持续交付。它能够自动化各种开发任务,提高开发效率和软件质量。
- 核心功能: Jenkins可以自动化构建、测试和部署任务,帮助开发团队实现快速、高效的开发流程。
- Jenkins简介: Jenkins是一个开源的自动化服务器,用于持续集成和持续交付。它能够自动化各种开发任务,提高开发效率和软件质量。
- 核心功能: Jenkins可以自动化构建、测试和部署任务,帮助开发团队实现快速、高效的开发流程。
2. 自动化部署流程详解
- 流程概述: 本文将重点介绍前端自动化部署的完整流程,包括代码提交、构建、打包、部署等步骤。具体流程如下:
- 代码提交:
- 开发人员通过
git push
将代码提交到远程仓库。
- 触发Jenkins自动构建:
- Jenkins配置为在代码提交时自动触发构建任务。
- 拉取代码仓库代码:
- Jenkins从仓库拉取最新代码。
- 构建打包:
- Jenkins运行构建命令(如
npm run build
),将源代码编译成可部署版本。
- 生成dist文件:
- 构建生成
dist
文件夹,包含打包后的静态资源。
- 压缩dist文件:
- 使用压缩工具(如
tar
或 zip
)将 dist
文件夹压缩成 dist.tar
或 dist.zip
。
- 迁移到指定环境目录下:
- 将压缩包迁移到目标环境目录(如
/var/www/project/
)。
- 删除旧dist文件:
- 删除目标环境目录下旧的
dist
文件,以确保保留最新版本。
- 解压迁移过来的dist.tar:
- 在目标环境目录下解压新的
dist.tar
文件。
- 删除dist.tar:
- 解压后删除压缩包,节省存储空间。
- 部署成功:
- 自动化流程完成,新的前端版本已经成功部署。
- 流程概述: 本文将重点介绍前端自动化部署的完整流程,包括代码提交、构建、打包、部署等步骤。具体流程如下:
- 代码提交:
- 开发人员通过
git push
将代码提交到远程仓库。
- 开发人员通过
- 触发Jenkins自动构建:
- Jenkins配置为在代码提交时自动触发构建任务。
- 拉取代码仓库代码:
- Jenkins从仓库拉取最新代码。
- 构建打包:
- Jenkins运行构建命令(如
npm run build
),将源代码编译成可部署版本。
- Jenkins运行构建命令(如
- 生成dist文件:
- 构建生成
dist
文件夹,包含打包后的静态资源。
- 构建生成
- 压缩dist文件:
- 使用压缩工具(如
tar
或zip
)将dist
文件夹压缩成dist.tar
或dist.zip
。
- 使用压缩工具(如
- 迁移到指定环境目录下:
- 将压缩包迁移到目标环境目录(如
/var/www/project/
)。
- 将压缩包迁移到目标环境目录(如
- 删除旧dist文件:
- 删除目标环境目录下旧的
dist
文件,以确保保留最新版本。
- 删除目标环境目录下旧的
- 解压迁移过来的dist.tar:
- 在目标环境目录下解压新的
dist.tar
文件。
- 在目标环境目录下解压新的
- 删除dist.tar:
- 解压后删除压缩包,节省存储空间。
- 部署成功:
- 自动化流程完成,新的前端版本已经成功部署。
- 代码提交:
准备工作
话不多说干就完了!!!
安装git
yum install -y git
查看是否安装成功
git --version
生成秘钥
ssh-keygen -t rsa -b 4096 -C "your_email@example.com"
查看公钥
cat ~/.ssh/id_rsa.pub
将公钥添加到GitHub或其他代码库的SSH Keys
yum install -y git
查看是否安装成功
git --version
生成秘钥
ssh-keygen -t rsa -b 4096 -C "your_email@example.com"
查看公钥
cat ~/.ssh/id_rsa.pub
将公钥添加到GitHub或其他代码库的SSH Keys
Docker安装
直接查看 菜鸟教程
安装完之后,配置docker镜像源详情参考 24年6月国内Docker镜像源失效解决办法...
编辑 /etc/resolv.conf
文件:
sudo vim /etc/resolv.conf
添加或修改以下行以使用 Cloudflare 的 DNS 服务器:
nameserver 1.1.1.1
nameserver 1.0.0.1
创建完成的docker-hub镜像输出示例:
查看docker相关的rpm源文件是否存在
rpm -qa |grep docker
作用
rpm -qa
:列出所有已安装的 RPM 包。grep docker
:筛选出包名中包含 docker
的条目。
示例输出 启动Docker服务:
- 启动Docker服务并设置为开机自启:
sudo systemctl start docker
sudo systemctl enable docker
直接查看 菜鸟教程
安装完之后,配置docker镜像源详情参考 24年6月国内Docker镜像源失效解决办法...
编辑 /etc/resolv.conf
文件:
sudo vim /etc/resolv.conf
添加或修改以下行以使用 Cloudflare 的 DNS 服务器:
nameserver 1.1.1.1
nameserver 1.0.0.1
创建完成的docker-hub镜像输出示例:
查看docker相关的rpm源文件是否存在
rpm -qa |grep docker
作用
rpm -qa
:列出所有已安装的 RPM 包。grep docker
:筛选出包名中包含docker
的条目。
示例输出 启动Docker服务:
- 启动Docker服务并设置为开机自启:
sudo systemctl start docker
sudo systemctl enable docker
Docker安装Docker Compose
Docker Compose 可以定义和运行多个 Docker 容器
应用的工具。它允许你使用一个单独的文件(通常称为 docker-compose.yml)来配置应用程序的服务,然后使用该文件快速启动整个应用的所有服务。
第一步,下载安装
curl -L https://get.daocloud.io/docker/compose/releases/download/v2.4.1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose
第二步,查看是否安装成功
docker-compose -v
第三步,给/docker/jenkins_home
目录设置最高权限,所有用户都具有读、写、执行这个目录的权限。(等建了/docker/jenkins_home
目录之后设置)
chmod 777 /docker/jenkins_home
Docker Compose 可以定义和运行多个 Docker 容器
应用的工具。它允许你使用一个单独的文件(通常称为 docker-compose.yml)来配置应用程序的服务,然后使用该文件快速启动整个应用的所有服务。
第一步,下载安装
curl -L https://get.daocloud.io/docker/compose/releases/download/v2.4.1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose
第二步,查看是否安装成功
docker-compose -v
第三步,给/docker/jenkins_home
目录设置最高权限,所有用户都具有读、写、执行这个目录的权限。(等建了/docker/jenkins_home
目录之后设置)
chmod 777 /docker/jenkins_home
创建Docker相关文件目录
可以命令创建或者相关shell可视化工具创建, 命令创建如下:
mkdir /docker
mkdir /docker/compose
mkdir /docker/jenkins_home
mkdir /docker/nginx
mkdir /docker/nginx/conf
mkdir /docker/html
mkdir /docker/html/dev
mkdir /docker/html/release
mkdir /docker/html/pro
创建docker-compose.yml
、nginx.conf
配置文件
cd /docker/compose touch docker-compose.yml
cd /docker/nginx/conf touch nginx.conf
完成后目录结构如下:
可以命令创建或者相关shell可视化工具创建, 命令创建如下:
mkdir /docker
mkdir /docker/compose
mkdir /docker/jenkins_home
mkdir /docker/nginx
mkdir /docker/nginx/conf
mkdir /docker/html
mkdir /docker/html/dev
mkdir /docker/html/release
mkdir /docker/html/pro
创建docker-compose.yml
、nginx.conf
配置文件
cd /docker/compose touch docker-compose.yml
cd /docker/nginx/conf touch nginx.conf
完成后目录结构如下:
编写nginx.conf
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
gzip on;
#dev环境
server {
#监听的端口
listen 8001;
server_name localhost;
#设置日志
access_log logs/dev.access.log main;
#定位到index.html
location / {
#linux下HTML文件夹,就是你的前端项目文件夹
root /usr/share/nginx/html/dev/dist;
# root /home/html/dev/dist;
#输入网址(server_name:port)后,默认的访问页面
index index.html;
try_files $uri $uri/ /index.html;
}
}
#release环境
server {
#监听的端口
listen 8002;
server_name localhost;
#设置日志
access_log logs/release.access.log main;
#定位到index.html
location / {
#linux下HTML文件夹,就是你的前端项目文件夹
root /usr/share/nginx/html/release/dist;
# root /home/html/release/dist;
#输入网址(server_name:port)后,默认的访问页面
index index.html;
try_files $uri $uri/ /index.html;
}
}
#pro环境
server {
#监听的端口
listen 8003;
server_name localhost;
#设置日志
access_log logs/pro.access.log main;
#定位到index.html
location / {
#linux下HTML文件夹,就是你的前端项目文件夹
root /usr/share/nginx/html/pro/dist;
# root /home/html/pro/dist;
#输入网址(server_name:port)后,默认的访问页面
index index.html;
try_files $uri $uri/ /index.html;
}
}
# include /etc/nginx/conf.d/*.conf;
}
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
gzip on;
#dev环境
server {
#监听的端口
listen 8001;
server_name localhost;
#设置日志
access_log logs/dev.access.log main;
#定位到index.html
location / {
#linux下HTML文件夹,就是你的前端项目文件夹
root /usr/share/nginx/html/dev/dist;
# root /home/html/dev/dist;
#输入网址(server_name:port)后,默认的访问页面
index index.html;
try_files $uri $uri/ /index.html;
}
}
#release环境
server {
#监听的端口
listen 8002;
server_name localhost;
#设置日志
access_log logs/release.access.log main;
#定位到index.html
location / {
#linux下HTML文件夹,就是你的前端项目文件夹
root /usr/share/nginx/html/release/dist;
# root /home/html/release/dist;
#输入网址(server_name:port)后,默认的访问页面
index index.html;
try_files $uri $uri/ /index.html;
}
}
#pro环境
server {
#监听的端口
listen 8003;
server_name localhost;
#设置日志
access_log logs/pro.access.log main;
#定位到index.html
location / {
#linux下HTML文件夹,就是你的前端项目文件夹
root /usr/share/nginx/html/pro/dist;
# root /home/html/pro/dist;
#输入网址(server_name:port)后,默认的访问页面
index index.html;
try_files $uri $uri/ /index.html;
}
}
# include /etc/nginx/conf.d/*.conf;
}
编写docker-compose.yml
networks:
frontend:
external: true
services:
docker_jenkins:
user: root # root权限
restart: always # 重启方式
image: jenkins/jenkins:lts # 使用的镜像
container_name: jenkins # 容器名称
ports: # 对外暴露的端口定义
- 8999:8080
- 50000:50000
environment:
- TZ=Asia/Shanghai ## 设置时区 否则默认是UTC
#- "JENKINS_OPTS=--prefix=/jenkins_home"
## 自定义 jenkins 访问前缀(设置了的话访问路径就为你的ip:端口/jenkins_home,反之则直接为ip:端口)
volumes: # 卷挂载路径
- /docker/jenkins_home/:/var/jenkins_home
# 挂载到容器内的jenkins_home目录
# docker-compose up 就会自动生成一个jenkins_home文件夹
- /usr/local/bin/docker-compose:/usr/local/bin/docker-compose
docker_nginx_dev:
restart: always
image: nginx
container_name: nginx_dev
ports:
- 8001:8001
volumes:
- /docker/nginx/conf/nginx.conf:/etc/nginx/nginx.conf
- /docker/html:/usr/share/nginx/html
- /docker/nginx/logs:/var/log/nginx
docker_nginx_release:
restart: always
image: nginx
container_name: nginx_release
ports:
- 8002:8002
volumes:
- /docker/nginx/conf/nginx.conf:/etc/nginx/nginx.conf
- /docker/html:/usr/share/nginx/html
- /docker/nginx/logs:/var/log/nginx
environment:
- TZ=Asia/Shanghai
docker_nginx_pro:
restart: always
image: nginx
container_name: nginx_pro
ports:
- 8003:8003
volumes:
- /docker/nginx/conf/nginx.conf:/etc/nginx/nginx.conf
- /docker/html:/usr/share/nginx/html
- /docker/nginx/logs:/var/log/nginx
networks:
frontend:
external: true
services:
docker_jenkins:
user: root # root权限
restart: always # 重启方式
image: jenkins/jenkins:lts # 使用的镜像
container_name: jenkins # 容器名称
ports: # 对外暴露的端口定义
- 8999:8080
- 50000:50000
environment:
- TZ=Asia/Shanghai ## 设置时区 否则默认是UTC
#- "JENKINS_OPTS=--prefix=/jenkins_home"
## 自定义 jenkins 访问前缀(设置了的话访问路径就为你的ip:端口/jenkins_home,反之则直接为ip:端口)
volumes: # 卷挂载路径
- /docker/jenkins_home/:/var/jenkins_home
# 挂载到容器内的jenkins_home目录
# docker-compose up 就会自动生成一个jenkins_home文件夹
- /usr/local/bin/docker-compose:/usr/local/bin/docker-compose
docker_nginx_dev:
restart: always
image: nginx
container_name: nginx_dev
ports:
- 8001:8001
volumes:
- /docker/nginx/conf/nginx.conf:/etc/nginx/nginx.conf
- /docker/html:/usr/share/nginx/html
- /docker/nginx/logs:/var/log/nginx
docker_nginx_release:
restart: always
image: nginx
container_name: nginx_release
ports:
- 8002:8002
volumes:
- /docker/nginx/conf/nginx.conf:/etc/nginx/nginx.conf
- /docker/html:/usr/share/nginx/html
- /docker/nginx/logs:/var/log/nginx
environment:
- TZ=Asia/Shanghai
docker_nginx_pro:
restart: always
image: nginx
container_name: nginx_pro
ports:
- 8003:8003
volumes:
- /docker/nginx/conf/nginx.conf:/etc/nginx/nginx.conf
- /docker/html:/usr/share/nginx/html
- /docker/nginx/logs:/var/log/nginx
启动Docker-compose
cd /docker/compose
docker-compose up -d
此时就会自动拉取jenkins镜像与Nginx镜像
查看运行状态
docker-compose ps -a
示例输出
cd /docker/compose
docker-compose up -d
此时就会自动拉取jenkins镜像与Nginx镜像
查看运行状态
docker-compose ps -a
示例输出
验证Nginx
在/docker/html/dev/dist
目录下新建index.html
,文件内容如下:
html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello Nginxtitle>
head>
<body>
<h1>Hello Nginxh1>
body>
html>
浏览器打开,输入服务器地址:8001
看到下面的页面说明nginx配置没问题,同样的操作可测试下8002端口和8003端口
在/docker/html/dev/dist
目录下新建index.html
,文件内容如下:
html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello Nginxtitle>
head>
<body>
<h1>Hello Nginxh1>
body>
html>
浏览器打开,输入服务器地址:8001
看到下面的页面说明nginx配置没问题,同样的操作可测试下8002端口和8003端口
验证Jenkins
浏览器输入服务器地址:8999
查看jenkins初始密码
docker logs jenkins
安装插件
浏览器输入服务器地址:8999
查看jenkins初始密码
docker logs jenkins
安装插件
安装Publish Over SSH、NodeJS
【Dashboard】——>【Manage Jenkins】——>【Plugins】——>【Available plugins】,搜索Publish Over SSH
、NodeJS
,安装后重启。
- Publish Over SSH配置远程服务器
找到SSH Servers
点击Test Configuration
显示successs
则成功,之后再Apply
并且Save
。
- NodeJS配置
找到Nodejs
坑点!!! 使用阿里云镜像
否则会容易报错
阿里云node镜像地址
https://mirrors.aliyun.com/nodejs-release/
【Dashboard】——>【Manage Jenkins】——>【Plugins】——>【Available plugins】,搜索Publish Over SSH
、NodeJS
,安装后重启。
- Publish Over SSH配置远程服务器
找到SSH Servers
点击Test Configuration
显示successs
则成功,之后再Apply
并且Save
。
- NodeJS配置
找到Nodejs
坑点!!! 使用阿里云镜像
否则会容易报错
阿里云node镜像地址
https://mirrors.aliyun.com/nodejs-release/
添加凭据
添加凭据,也就是GitHub或者其他远程仓库的凭据可以是账号密码也可以是token,方便之后使用。话不多说,Lets go!
我这里使用的是ssh
,原因用账号密码
拉不下来源码不知道为啥
添加凭据,也就是GitHub或者其他远程仓库的凭据可以是账号密码也可以是token,方便之后使用。话不多说,Lets go!
我这里使用的是ssh
,原因用账号密码
拉不下来源码不知道为啥
创建Job
选择自由风格
- 配置
git
仓库地址 - 构建环境 在 Jenkins 中将 Node.js 和 npm 的 bin 文件夹添加到
PATH
中,否则可能就会报错。 - 选择
nodejs
版本
- 创建
shell
命令
- 自动部署到对应环境项目目录:上面打包到了Jenkins中的workspace中,但是我们设置的项目环境路径跟这个不同,比如开发环境项目目录是
/docker/html/dev/dist/
,所以需要打包后,把dist文件内容推送到/docker/html/dev/dist/
目录下- 修改一下上面的
shell
脚本
#!/bin/bash
echo "Node版本:"
node -v
pnpm i
echo "依赖安装成功"
pnpm build
echo "打包成功"
rm -rf dist.tar # 每次构建删除已存在的dist压缩包
tar -zcvf dist.tar ./dist #将dist文件压缩成dist.tar
echo $PATH
- 然后
Add build step
选择Send files or execute commands over SSH
,Send files or execute
- 通过SSH连接到远程服务器执行命令和发送文件:
选择自由风格
- 配置
git
仓库地址 - 构建环境 在 Jenkins 中将 Node.js 和 npm 的 bin 文件夹添加到
PATH
中,否则可能就会报错。 - 选择
nodejs
版本
- 创建
shell
命令
- 自动部署到对应环境项目目录:上面打包到了Jenkins中的workspace中,但是我们设置的项目环境路径跟这个不同,比如开发环境项目目录是
/docker/html/dev/dist/
,所以需要打包后,把dist文件内容推送到/docker/html/dev/dist/
目录下- 修改一下上面的
shell
脚本
#!/bin/bash
echo "Node版本:"
node -v
pnpm i
echo "依赖安装成功"
pnpm build
echo "打包成功"
rm -rf dist.tar # 每次构建删除已存在的dist压缩包
tar -zcvf dist.tar ./dist #将dist文件压缩成dist.tar
echo $PATH
- 然后
Add build step
选择Send files or execute commands over SSH
,Send files or execute
- 通过SSH连接到远程服务器执行命令和发送文件:
- 修改一下上面的
ps:脚本解释
cd /docker/html/dev
- 切换到
/docker/html/dev
目录。这是你要进行操作的工作目录。
- 切换到
rm -rf dist/
- 递归地删除
dist/
目录及其所有内容。这是为了确保旧的dist
目录被完全移除。
- 递归地删除
tar zxvf dist.tar
- 解压
dist.tar
文件,并将其中的内容解压到当前目录下。通常,dist.tar
会包含dist/
目录,解压后就会生成一个新的dist/
目录。
- 解压
rm dist.tar
- 解压完成后,删除
dist.tar
文件。这样可以节省存储空间并清理不再需要的压缩包。
- 解压完成后,删除
至此全部配置完成
- 测试ci/cd 点击
Build Now
开始构建
查看构建中任务的Console Output
,日志出现Finished: SUCCESS
即为成功
预览产物:
其他配置
GitHub webHooks配置
payload URL
为:http://ip:jenkins端口/github-webhook/
在Github webHooks
创建好后,到jenkins
中开启触发器 这样配置完后在push到相应分支就会自动构建发布
其他的个性化配置例如:钉钉通知、邮箱通知、pipeline配置等等就不做学习了,毕竟运维的事,我也学不会。😊😊
完结撒花🎉🎉🎉
来源:juejin.cn/post/7407333344889896998
前端中 JS 发起的请求可以暂停吗
在前端中,JavaScript(JS)可以使用XMLHttpRequest对象或fetch API来发起网络请求。然而,JavaScript本身并没有提供直接的方法来暂停请求的执行。一旦请求被发送,它会继续执行并等待响应。
尽管如此,你可以通过一些技巧或库来模拟请求的暂停和继续执行。下面是一种常见的方法:
1. 使用XMLHttpRequest对象
你可以在发送请求前创建一个XMLHttpRequest对象,并将其保存在变量中。然后,在需要暂停请求时,调用该对象的abort()方法来中止请求。当需要继续执行请求时,可以重新创建一个新的XMLHttpRequest对象并发起请求。
var xhr = new XMLHttpRequest();
xhr.open('GET', 'https://example.com/api', true);
xhr.send();
// 暂停请求
xhr.abort();
// 继续请求
xhr = new XMLHttpRequest();
xhr.open('GET', 'https://example.com/api', true);
xhr.send();
2. 使用fetch API和AbortController
fetch API与AbortController一起使用可以更方便地控制请求的暂停和继续执行。AbortController提供了一个abort()方法,可以用于中止fetch请求。
var controller = new AbortController();
fetch('https://example.com/api', { signal: controller.signal })
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error));
// 暂停请求
controller.abort();
// 继续请求
controller = new AbortController();
fetch('https://example.com/api', { signal: controller.signal })
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error));
请注意,这些方法实际上是通过中止请求并重新发起新的请求来模拟暂停和继续执行的效果,并不能真正暂停正在进行的请求。
3. 曲线救国
模拟一个假暂停的功能,在前端的业务场景上,需要对这些数据进行处理之后渲染在界面上,如果我们能在请求发起之前增加一个控制器,在请求回来时,如果控制器为暂停状态则不处理数据,等待控制器恢复后再进行处理,也可以达到暂停的效果。
// 创建一个暂停控制器 Promise
function createPauseControllerPromise() {
const result = {
isPause: false, // 标记控制器是否处于暂停状态
resolveWhenResume: false, // 表示在恢复时是否解析 Promise
resolve(value) {}, // 解析 Promise 的占位函数
pause() { // 暂停控制器的函数
this.isPause = true;
},
resume() { // 恢复控制器的函数
if (!this.isPause) return;
this.isPause = false;
if (this.resolveWhenResume) {
this.resolve();
}
},
promise: Promise.resolve(), // 初始为已解决状态的 Promise
};
const promise = new Promise((res) => {
result.resolve = res; // 将解析函数与 Promise 关联
});
result.promise = promise; // 更新控制器中的 Promise 对象
return result; // 返回控制器对象
}
function requestWithPauseControl(request) {
const controller = createPauseControllerPromise(); // 创建暂停控制器对象
const controlRequest = request() // 执行请求函数
.then((data) => { // 请求成功回调
if (!controller.isPause) controller.resolve(); // 如果控制器未暂停,则解析 Promise
return data; // 返回请求结果
})
.finally(() => {
controller.resolveWhenResume = true; // 标记在恢复时解析 Promise
});
const result = Promise.all([controlRequest, controller.promise]).then(
(data) => {
controller.resolve(); // 解析控制器的 Promise
return data[0]; // 返回请求处理结果
}
);
result.pause = controller.pause.bind(controller); // 将暂停函数绑定到结果 Promise 对象
result.resume = controller.resume.bind(controller); // 将恢复函数绑定到结果 Promise 对象
return result; // 返回添加了暂停控制功能的结果 Promise 对象
}
为什么需要创建两个promise
在requestWithPauseControl函数中,需要等待两个Promise对象解析:一个是请求处理的Promise,另一个是控制器的Promise。通过使用Promise.all方法,可以将这两个Promise对象组合成一个新的Promise,该新的Promise会在两个原始Promise都解析后才会解析。这样做的目的是确保在处理请求结果之前,暂停控制器的resolve方法被调用,以便在恢复时解析Promise。
因此,将请求处理的Promise和控制器的Promise放入一个Promise数组,并使用Promise.all等待它们都解析完成,可以确保在两个Promise都解析后再进行下一步操作,以实现预期的功能。
使用
const requestFn = () => new Promise(resolve => {
setTimeout(() => resolve({ author: 'vincentzheng', msg: 'hello' }), 0)
})
const result = requestWithPauseControl(requestFn);
result.then((data) => {
console.log("返回结果", data);
});
if (Math.random() > 0.5) {
console.log('命中暂停')
result.pause();
}
setTimeout(() => {
result.resume();
}, 4000);
来源:juejin.cn/post/7310786521082560562
手撸一个精美简约loading加载功能,再也不怕复杂的网页效果了
我来看看怎么个事?
你们还记得自己为什么要做程序员吗?我先来说,就是看见别人有一个精美的网站。但是,现在很多人要么就是后端crud boy,要么就是前端vue渲染数据girl。没有现成的框架,现成的ui组件,就没法写代码了。好看的网页怎么来呢?有人会说是UI设计的,我前端只需要vue渲染数据就行了🤣(今天我们就不探讨后端技术🐶)。久而久之,自己就会慢慢变菜,最后想开发一个项目,发现无从下手,写个页面都费劲!!!所以,还是慢慢做一个全栈,这样既可以写好玩的工具,也可以提高自己的竞争力,强者恒强,没错就是我啦😅
1.loading实际效果图
字不重要,看图👉👉👉👉
pc端
移动端
2.准备css素材
这种loading的效果,网上有很多网站可以直接diy,几乎没有人手写一个。当然你也可以手写,如果觉得闲的话
推荐网站
国内也有很多,我使用的是国外网站(科学上网)
下载素材推荐svg格式,如果你的svg动图存在背景
如图,这种背景一定要去掉,给svg设置一个透明度,找到svg文件,background属性,设置rgb(255,255,255,0)就可以了,如下图:
3.loading隐藏与显示逻辑
思考🤔:
当我们点击按钮的时候,一般会触发请求,比如请求后台数据,这个时候中间就会有加载的样式。
总结就两个条件:
1.按钮要触发点击事件,开启loading效果
2.需要一个事件完成的状态(标记),关闭loading效果
4.编写looding组件,全局注册组件
<script setup>
</script>
<template>
<div class="loading">
<img src="./loading.svg" alt="loading"/>
</div>
</template>
<style scoped lang="scss">
.loading {
//通过定位,实现loading水平垂直居中
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
img {
width: 70px;
height: 70px;
}
}
</style>
全局注册loading组件
5.登录页面使用loading组件
<script setup>
import {reactive, ref, watch} from "vue";
//模拟请求数据,code===200表示事件完成
const result = reactive({data: [], code: 0})
//判断按钮是否触发点击事件,默认false,按钮没有触发点击
const clickFlag = ref(false)
//按钮提交方法
const submit = () => {
//重置code
result.code = 0
//标记请求触发
clickFlag.value = true
//模拟http请求
setTimeout(() => {
//模拟后台数据
result.data = [{'name': 'bobo', 'age': 12}]
//模拟请求完成
result.code = 200
}, 3000)
}
</script>
<template>
<div class="login-container">
<div class="login-box">
<div class="form">
<h2>用户登录</h2>
<div class="content">
<input class="input" type="text" placeholder="请输入账号">
<input class="input" type="password" placeholder="请输入密码">
<button @click="submit" class="button">登录</button>
<!--判断loading 1.有没有点击事件 2.有没有loading终止标记-->
<loading v-show="result.code!==200&&clickFlag"></loading>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.login-container {
.login-box {
.form {
position: relative;
padding: 24px;
text-align: center;
color: rgb(55 65 81);
line-height: 1.75rem;
}
}
}
</style>
具体页面布局的代码,请参考我之前的文章juejin.cn/post/738854…
这里不做过多叙述!能坚持✊看到这里,想必你一定很棒,给你个🍭🍭🍭
在线浏览网址http://www.codingfox.icu/#/dashboard (我不知道可以坚持到多久,靠爱发电,网站搬迁会尽量迁移)
来源:juejin.cn/post/7389178780437921803
uni-app初体验,如何实现一个外呼APP
起因
2024年3月31日,我被公司裁员了。
2024年4月1日,果断踏上了回家的路,决定先休息一个星期。晚上回到了郑州,先跟一起被裁的同事在郑州小聚一下,聊聊后面的打算。第二天下午回家。
2024年4月8日,知道现在的大环境不好,不敢错过“金三银四”,赶忙回上海开始找工作。结果环境比预想的还要差啊,以前简历放开就有人找,现在每天投个几十封都是石沉大海。。。
2024年4月15日,有个好朋友找我,想让我给他们公司开发一个“拨号APP”(主要原因其实是这个好哥们想让我多个赚钱门路😌),主要的功能就是在他们的系统上点击一个“拨号”按钮,然后员工的工作手机上就自动拨打这个号码。
可行性分析
涉及到的修改:
- 系统前后端
- 拨号功能的APP
拿到这个需求之后,我并没有直接拒绝或者同意,而是先让他把他公司那边的的源代码发我了一份,大致看了一下使用的框架,然后找一些后端的朋友看有没人有人一起接这个单子;而我自己则是要先看下能否实现APP的功能(因为我以前从来没有做过APP!!!)。
我们各自看过自己的东西,然后又沟通了一番简单的实现过程后达成了一致,搞!
因为我这边之前的技术栈一直是VUE,所以决定使用uni-app实现,主要还是因为它的上手难度会低很多。
第一版
需求分析
虽说主体的功能是拨号,但其实是隐含很多辅助性需求的,比如拨号日志、通时通次统计、通话录音、录音上传、后台运行,另外除了这些外还有额外的例如权限校验、权限引导、获取手机号、获取拨号状态等功能需要实现。
但是第一次预算给的并不高,要把这些全部实现显然不可能。因此只能简化实现功能实现。
- 拨号APP
- 权限校验
- 实现部分(拨号、录音、文件读写)
- ❌权限引导
- 查询当前手机号
- 直接使用input表单,由用户输入
- 查询当前手机号的拨号任务
- 因为后端没有socket,使用setTimeout模拟轮询实现。
- 拨号、录音、监测拨号状态
- 根据官网API和一些安卓原生实现
- 更新任务状态
- 告诉后端拨号完成
- ❌通话录音上传
- ❌通话日志上传
- ❌本地通时通次统计
- 程序运行日志
- 其他
- 增加开始工作、开启录音的状态切换
- 兼容性,只兼容安卓手机即可
- 权限校验
基础设计
一个input框来输入用户手机号,一个开始工作的switch,一个开启录音的切换。用户输入手机号,点击开始工作后开启轮询,轮询到拨号任务后就拨号同时录音,同时监听拨号状态,当挂断后结束录音、更新任务状态,并开启新一轮的轮询。
开干
虽然本人从未开发过APP,但本着撸起袖子就是干的原则,直接打开了uni-app的官网就准备开怼。
1、下载 HbuilderX。
2、新建项目,直接选择了默认模板。
3、清空 Hello页面,修改文件名,配置路由。
4、在vue文件里写主要的功能实现,并增加 Http.js
、Record.js
、PhoneCall.js
、Power.js
来实现对应的模块功能。
⚠️关于测试和打包
运行测试
在 HbuilderX 中点击“运行-运行到手机或模拟器-运行到Android APP基座”会打开一个界面,让你选择运行到那个设备。这是你有两种选择:
- 把你手机通过USB与电脑连接,然后刷新列表就可以直接运行了。
- 很遗憾,可能是苹果电脑与安卓手机的原因,插上后检测不出设备😭。。。
- 安装Android Studio,然后通过运行内置的模拟器来供代码运行测试。
- 这种很麻烦,要下载很久,且感觉测试效果并不好,最好还是用windows电脑连接手机的方法测试。
关于自定义基座和标准基座的差别,如果你没有买插件的话,直接使用基准插座就好。如果你要使用自定义基座,就首先要点击上图中的制作自定义基座,然后再切换到自定义基座执行。
但是不知道为什么,我这里一直显示安装自定义基座失败。。。
打包测试
除了以上运行测试的方法外,你还有一种更粗暴的测试方法,那就是打包成APP直接在手机上安装测试。
点击“发行-原生APP 云打包”,会生成一个APK文件,然后就可以发送到手机上安装测试。不过每天打包的次数有限,超过次数需要购买额外的打包服务或者等第二天打包。
我最终就是这样搞得,真的我哭死,我可能就是盲调的命,好多项目都是盲调的。
另外,在打包之前我们首先要配置manifest.json
,里面包含了APP的很多信息。比较重要的一个是AppId,一个是App权限配置。参考uni-app 权限配置、Android官方权限常量文档。以下是拨号所需的一些权限:
// 录制音频
<uses-permission android:name="android.permission.RECORD_AUDIO" />
// 修改音频设置
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
// 照相机
<uses-permission android:name="android.permission.CAMERA" />
// 写入外部存储
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
// 读取外部存储
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
// 读取电话号码
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
// 拨打电话
<uses-permission android:name="android.permission.CALL_PHONE" />
// 呼叫特权
<uses-permission android:name="android.permission.CALL_PRIVILEGED" />
// 通话状态
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
// 读取拨号日志
<uses-permission android:name="android.permission.READ_CALL_LOG" />
// 写入拨号日志
<uses-permission android:name="android.permission.WRITE_CALL_LOG" />
// 读取联系人
<uses-permission android:name="android.permission.READ_CONTACTS" />
// 写入联系人
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
// 读取SMS?
<uses-permission android:name="android.permission.READ_SMS" />
// 写入设置
<uses-permission android:name="android.permission.WRITE_SETTINGS" />
// 唤醒锁定?
<uses-permission android:name="android.permission.WAKE_LOCK" />
// 系统告警窗口?
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
// 接受完整的引导?
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
⚠️权限配置这个搞了很长时间,即便现在有些权限还是不太清楚,也不知道是不是有哪些权限没有配置上。。。
⚠️权限校验
1、安卓 1
好像除了这样的写法还可以写"scope.record"
或者permission.CALL_PHONE
。
permision.requestAndroidPermission("android.permission.CALL_PHONE").then(res => {
// 1 获得权限 2 本次拒绝 -1 永久拒绝
});
2、安卓 2
plus.android.requestPermissions(["android.permission.CALL_PHONE"], e => {
// e.granted 获得权限
// e.deniedPresent 本次拒绝
// e.deniedAlways 永久拒绝
});
3、uni-app
这个我没测试,AI给的,我没有用这种方法。有一说一,百度AI做的不咋地。
// 检查权限
uni.hasPermission({
permission: 'makePhoneCall',
success() {
console.log('已经获得拨号权限');
},
fail() {
// 示例:请求权限
uni.authorize({
scope: 'scope.makePhoneCall',
success() {
console.log('已经获得授权');
},
fail() {
console.log('用户拒绝授权');
// 引导用户到设置中开启权限
uni.showModal({
title: '提示',
content: '请在系统设置中打开拨号权限',
success: function(res) {
if (res.confirm) {
// 引导用户到设置页
uni.openSetting();
}
}
});
}
});
}
});
✅拨号
三种方法都可以实现拨号功能,只要有权限,之所以找了三种是为了实现APP在后台的情况下拨号的目的,做了N多测试,甚至到后面搞了一份原生插件的代码不过插件加载当时没搞懂就放弃了,不过到后面才发现原来后台拨号出现问题的原因不在这里,,具体原因看后面。
另外获取当前设备平台可以使用let platform = uni.getSystemInfoSync().platform;
,我这里只需要兼容固定机型。
1、uni-app API
uni.makePhoneCall({
phoneNumber: phone,
success: () => {
log(`成功拨打电话${phone}`);
},
fail: (err) => {
log(`拨打电话失败! ${err}`);
}
});
2、Android
plus.device.dial(phone, false);
3、Android 原生
写这个的时候有个小插曲,当时已经凌晨了,再加上我没有复制,是一个个单词敲的,结果竟然敲错了一个单词,测了好几遍都没有成功。。。还在想到底哪里错了,后来核对一遍才发现😭,control cv才是王道啊。
// Android
function PhoneCallAndroid(phone) {
if (!plus || !plus.android) return;
// 导入Activity、Intent类
var Intent = plus.android.importClass("android.content.Intent");
var Uri = plus.android.importClass("android.net.Uri");
// 获取主Activity对象的实例
var main = plus.android.runtimeMainActivity();
// 创建Intent
var uri = Uri.parse("tel:" + phone); // 这里可修改电话号码
var call = new Intent("android.intent.action.CALL", uri);
// 调用startActivity方法拨打电话
main.startActivity(call);
}
✅拨号状态查询
第一版用的就是这个获取状态的代码,有三种状态。第二版的时候又换了一种,因为要增加呼入、呼出、挂断、未接等状态的判断。
export function getCallStatus(callback) {
if (!plus || !plus.android) return;
let maintest = plus.android.runtimeMainActivity();
let Contexttest = plus.android.importClass("android.content.Context");
let telephonyManager = plus.android.importClass("android.telephony.TelephonyManager");
let telManager = plus.android.runtimeMainActivity().getSystemService(Contexttest.TELEPHONY_SERVICE);
let receiver = plus.android.implements('io.dcloud.android.content.BroadcastReceiver', {
onReceive: (Contexttest, intent) => {
plus.android.importClass(intent);
let phoneStatus = telManager.getCallState();
callback && callback(phoneStatus);
//电话状态 0->空闲状态 1->振铃状态 2->通话存在
}
});
let IntentFilter = plus.android.importClass("android.content.IntentFilter");
let filter = new IntentFilter();
filter.addAction(telephonyManager.ACTION_PHONE_STATE_CHANGED);
maintest.registerReceiver(receiver, filter);
}
⚠️录音
录音功能这个其实没啥,都是官网的API,无非是简单的处理一些东西。但是这里有一个大坑!
一坑
就是像通话录音这种涉及到的隐私权限很高,正常的这种录音是在通话过程中是不被允许的。
二坑
后来一次偶然的想法,在接通之后再开启录音,发现就可以录音了。
但随之而来的是第二个坑,那就是虽然录音了,但是当播放的时候发现没有任何的声音,还是因为保护隐私的原因,我当时还脱离代码专门试了试手机自带的录音器来在通话时录音,发现也不行。由此也发现uni的录音本身也是用的手机录音器的功能。
三坑
虽然没有声音,但是我还是试了下保存,然后就发现了第三个坑,那就是虽然获取了文件权限,但是现在手机给的读写权限都是在限定内的,录音所在的文件夹是无权访问的。。。
另辟蹊径
其实除了自己手动录音外还可以通过手机自带的通话录音来实现,然后只要手机去读取录音文件并找到对应的那个就可以了。思路是没啥问题,不过因为设置通话录音指引、获取录音文件都有问题,这一版本就没实现。
// 录音
var log = console.log,
recorder = null,
// innerAudioContext = null,
isRecording = false;
export function startRecording(logFun = console.log) {
if (!uni.getRecorderManager || !uni.getRecorderManager()) return logFun('不支持录音!');
log = logFun;
recorder = uni.getRecorderManager();
// innerAudioContext = uni.createInnerAudioContext();
// innerAudioContext.autoplay = true;
recorder.onStart(() => {
isRecording = true;
log(`录音已开始 ${new Date()}`);
});
recorder.onError((err) => {
log(`录音出错:${err}`);
console.log("录音出错:", err);
});
recorder.onInterruptionBegin(() => {
log(`检测到录音被来电中断...`);
});
recorder.onPause(() => {
log(`检测到录音被来电中断后尝试启动录音..`);
recorder.start({
duration: 10 * 60 * 1000,
});
});
recorder.start({
duration: 10 * 60 * 1000,
});
}
export function stopRecording() {
if (!recorder) return
recorder.onStop((res) => {
isRecording = false;
log(`录音已停止! ${new Date()}`); // :${res.tempFilePath}
// 处理录制的音频文件(例如,保存或上传)
// powerCheckSaveRecord(res.tempFilePath);
saveRecording(res.tempFilePath);
});
recorder.stop();
}
export function saveRecording(filePath) {
// 使用uni.saveFile API保存录音文件
log('开始保存录音文件');
uni.saveFile({
tempFilePath: filePath,
success(res) {
// 保存成功后,res.savedFilePath 为保存后的文件路径
log(`录音保存成功:${res.savedFilePath}`);
// 可以将res.savedFilePath保存到你的数据中,或者执行其他保存相关的操作
},
fail(err) {
log(`录音保存失败! ${err}`);
console.error("录音保存失败:", err);
},
});
}
运行日志
为了更好的测试,也为了能实时的看到执行的过程,需要一个日志,我这里就直接渲染了一个倒序的数组,数组中的每一项就是各个函数push的字符串输出。简单处理。。。。嘛。
联调、测试、交工
搞到最后,大概就交了个这么玩意,不过也没有办法,一是自己确实不熟悉APP开发,二是满共就给了两天的工时,中间做了大量的测试代码的工作,时间确实有点紧了。所幸最起码的功能没啥问题,也算是交付了。
第二版
2024年05月7日,老哥又找上我了,想让我们把他们的这套东西再给友商部署一套,顺便把这个APP再改一改,增加上通时通次的统计功能。同时也是谈合作,如果后面有其他的友商想用这套系统,他来谈,我们来实施,达成一个长期合作关系。
我仔细想了想,觉得这是个机会,这块东西的市场需求也一直有,且自己现在失业在家也有时间,就想着把这个简单的功能打磨成一个像样的产品。也算是做一次尝试。
需求分析
- ✅拨号APP
- 登录
- uni-id实现
- 权限校验
- 拨号权限、文件权限、自带通话录音配置
- 权限引导
- 文件权限引导
- 通话录音配置引导
- 获取手机号权限配置引导
- 后台运行权限配置引导
- 当前兼容机型说明
- 拨号
- 获取手机号
- 是否双卡校验
- 直接读取手机卡槽中的手机号码
- 如果用户不会设置权限兼容直接input框输入
- 拨号
- 全局拨号状态监控注册、取消
- 支持呼入、呼出、通话中、来电未接或挂断、去电未接或挂断
- 获取手机号
- 录音
- 读取录音文件列表
- 支持全部或按时间查询
- 播放录音
- ❌上传录音文件到云端
- 读取录音文件列表
- 通时通次统计
- 云端数据根据上面状态监控获取并上传
- 云端另写一套页面
- 本地数据读取本机的通话日志并整理统计
- 支持按时间查询
- 支持呼入、呼出、总计的通话次数、通话时间、接通率、有效率等
- 云端数据根据上面状态监控获取并上传
- 其他
- 优化日志显示形式
- 封装了一个类似聊天框的组件,支持字符串、Html、插槽三种显示模式
- 在上个组件的基础上实现权限校验和权限引导
- 在上两个组件的基础上实现主页面逻辑功能
- 增加了拨号测试、远端连接测试
- 修改了APP名称和图标
- 打包时增加了自有证书
- 优化日志显示形式
- 登录
中间遇到并解决的一些问题
关于框架模板
这次重构我使用了uni中uni-starter + uni-admin 项目模板。整体倒还没啥,这俩配合还挺好的,就只是刚开始不知道还要配置东西一直没有启动起来。
建立完项目之后还要进uniCloud/cloudfunctions/common/uni-config-center/uni-id
配置一个JSON文件来约定用户系统的一些配置。
打包的时候也要在manifest.json
将部分APP模块配置进去。
还搞了挺久的,半天才查出来。。
类聊天组件实现
- 设计
- 每个对话为一个无状态组件
- 一个图标、一个名称、一个白底的展示区域、一个白色三角
- 内容区域通过类型判断如何渲染
- 根据前后两条数据时间差判断是否显示灰色时间
- 参数
- ID、名称、图标、时间、内容、内容类型等
- 样式
- 根据左边右边区分发送接收方,给与不同的类名
- flex布局实现
样式实现这里,我才知道原来APP和H5的展示效果是完全不同的,个别地方需要写两套样式。
关于后台运行
这个是除了录音最让我头疼的问题了,我想了很多实现方案,也查询了很多相关的知识,但依旧没效果。总体来说有以下几种思路。
- 通过寻找某个权限和引导(试图寻找到底是哪个权限控制的)
- 通过不停的访问位置信息
- 通过查找相应的插件、询问GPT、百度查询
- 通过程序切入后台之后,在屏幕上留个悬浮框(参考游戏脚本的做法)
- 通过切入后台后,发送消息实现(没测试)
测试了不知道多少遍,最终在一次无意中,终于发现了如何实现后台拨号,并且在之后看到后台情况下拨号状态异常,然后又查询了应用权限申请记录,也终于知道,归根到底能否后台运行还是权限的问题。
关于通话状态、通话记录中的类型
这个倒还好,就是测试的问题,知道了上面为啥异常的情况下,多做几次测试,就能知道对应的都是什么状态了。
通话状态:呼入振铃、通话中(呼入呼出)、通话挂断(呼入呼出)、来电未接或拒绝、去电未接或拒接。
通话日志:呼入、呼出、未接、语音邮件、拒接
交付
总体上来说还过得去,相比于上次简陋的东西,最起码有了一点APP的样子,基本上该有的功能也基本都已经实现了,美中不足的一点是下面的图标没有找到合适的替换,然后录音上传的功能暂未实现,不过这个也好实现了。
后面的计划
- 把图标改好
- 把录音文件是否已上传、录音上传功能做好
- 把APP的关于页面加上,对接方法、使用方法和视频、问题咨询等等
- 原本通话任务、通时通次这些是放在一个PHP后端的,对接较麻烦。要用云函数再实现一遍,然后对外暴露几个接口,这样任何一个系统都可以对接这个APP,而我也可以通过控制云空间的跨域配置来开放权限
- 把数据留在这边之后,就可以再把uni-admin上加几个页面,并且绑定到阿里云的云函数前端网页托管上去
- 如果有可能的话,上架应用商店,增加上一些广告或者换量联盟之类的东西
- 后台运行时,屏幕上加个悬浮图标,来电时能显示个振铃啥的
- 增加拨号前的校验,对接平台,对于经常拉黑电销的客户号码进行过滤
大致的想法就这些了,如果这个产品能继续卖下去,我就会不断的完善它。
最后
现在的行情真的是不好啊,不知道有没有大哥给个内推的机会,本人大专计算专业、6.5年Vue经验(专精后台管理、监控大屏方向,其他新方向愿意尝试),多个0-1-2项目经验,跨多个领域如人员管理、项目管理、产品设计、软件测试、数据爬虫、NodeJS、流程规范等等方面均有了解,工作稳定不经常跳,求路过的大哥给个内推机会把!
😂被举报标题党了,换个名字。
来源:juejin.cn/post/7368421971384860684
中国研发部门一锅端,IBM程序员的“黑色星期五”
大家好,我是晓凡。
程序员的“黑色星期五”
想象一下,你正坐在办公室,准备享受周末的轻松时刻,突然,你的工作账号被停用了,各种公司相关的权限没了,无法访问公司内网。
这不是电影情节,而是IBM中国研发部门员工的真实遭遇。一夜之间,千余名员工被一锅端。
这件事发生得太突然,几乎没有一点点征兆和信号,看得晓凡是一脸懵逼。
IBM裁员:波及千人
裁员,在互联网行业并不是新鲜事。
但IBM这次裁员的规模和速度,着实让人震惊。
据悉,IBM中国在不同区设有多个分公司,据称大约有12000名员工。
被收回权限的员工属于IBMV,下设CDL(IBM中国研发中心)和CSL(IBM中国系统中心),主要负责研发和测试。
波及到了1000+人,遍布北京、上海、大连等各地的员工。赔偿方案为N+3,但具体情况可能更为复杂。
我们来看看IBM官方给出的解释
中国的企业,尤其是民营企业,越来越重视抓住混合云和人工智能技术带来的机遇。
因此,IBM 在中国的本地战略重点将转向利用自身在技术和服务方面的丰富经验,组建一支具备相应技能的团队,以更好地与中国客户合作,共同创造符合客户需求的解决方案。
下面是网传的针对此此次裁员3分钟会议纪要
我们将内容翻译过来大概如下:
【我叫 Jack Hergenrother,是全球企业系统开发的副总裁。今天我们有一个重要的管理决策要与大家分享。
为了支持我们的全球客户和我们的业务战略,IBM 基础设施决定将开发任务从中国系统实验室转移到海外的其他 IBM基础设施基地。
我们正在退出在中国的所有开发任务。
正如你们所知道的,IBM 基础设施继续转型,以帮助释放我们组织必须提供的全部价值,并帮助我们实现具有挑战性的全球市场的可持续业务。这种转变受市场动态和激烈竞争的影响。而**中国的基建业务近年来有所下滑。
对于 IBM Z,我们做出了艰难的决定——将开发工作转移到其他国家,以便更好地抓住市场机遇,并且更加更接近客户。
在存储方面,我们正在将开发工作整合到更少的地点,以应对激烈的竞争。基础设施的协同办公战略是全球性的。协同办公也不仅限于中国。我们做出了这一艰难的商业决策,以便提高效率并简化运营。
我是 Ross Moury,IBM Z 和 Linux One 的总经理。我要感谢大家为 IBM 所做的贡献以及在这个平台成功中所扮演的重要角色。我希望获得你们的理解和今后的合作。
我是 Danny Mace,存储工程副总裁。我知道这是一个艰难的决定,但这是支持我们的全球客户和业务战略所必需的行动。在此,我也要感谢你们的贡献。】
此外有不少网友注意到,现任 IBM CEO 是一名印度人 Arvind Krishna,自从他 2020 年上任后就曾在全球范围内进行了多轮裁员。此外根据 IBM 的招聘信息显示,目前 IBM 似乎正在印度不断增设岗位,故而部分网友猜测此次 IBM 中国研发部全体被裁或许也与此有关。
多轮裁员,用AI替代近8000人
裁员,往往不是单一因素的结果。IBM的裁员,背后是市场和技术的双重压力。
随着云计算和人工智能的兴起,传统的研发模式正在发生变化。
企业为了追求发展,需要尽可能的压缩成本。说实话,这两年,大家都不好过。
IBM CEO Arvind Krishna在采访中表示,后台职能部门,如人力资源的招聘将暂停或放缓。
未来5年,我们将看到30%的人将被AI和自动化所取代。
程序员的自救
面对裁员,作为一名普通程序员,我们该怎么做呢?
① 保持良好心态,不要焦虑,不要内卷。真的不是自己不优秀,而是大环境不好。
工作没了,身体也不能跨。只要身体不垮,一切都可以重来。
② 守住自己手里的钱,不要负债,不要负债,不要负债。
正所谓:金库充盈,心绪宁静。即使不幸被裁了,也能靠积蓄养活自己
③ 虽然AI短时间不能完全替代程序员,但一些重复性的工作将被AI和自动化所取代。
保持学习,多了解一些AI,确实可以帮我们提高工作效率
④ 不要在一棵树上吊死,趁着年轻,试错成本不是那么高,多尝试尝试其他赛道,随然不一定能成。
但也有可能发现可以一直干下去的副业。
来源:juejin.cn/post/7408070878829117491
火山引擎数智平台:A/B测试个性化配置能力发布,拓展多场景策略最优解
对于这些场景,你一定不会感到陌生:打开手机时,一款购物应用推荐的正好是你心仪已久的商品;浏览网页时,新闻资讯自动排列,展示的都是你最感兴趣的话题;沉浸于在线娱乐时,所呈现的内容仿佛是为你量身定制……
这一切与“用户个性化配置发布”能力息息相关。“用户个性化配置发布”指根据行为、性别,兴趣、地理位置等数据,为不同用户提供不同的内容和体验,以提高用户满意度和转化率。
作为火山引擎数智平台旗下的核心产品,DataTester近期重磅发布了“个性化配置发布”能力,支持企业根据用户的偏好、行为和历史数据,为其精准匹配内容和服务。
据介绍,火山引擎DataTester依托于字节跳动长期技术沉淀,已支持字节内部500多个业务,并对外服务美的、华泰证券、博西家电、乐刻健身等上百家企业。此次“个性化配置发布”能力,也是DataTester长期经验的产品化输出,在易用性、灵活性等层面,对比行业其他产品更具优势。
以某购物网站举例,为了提升购物站点订单转化率,在识别到用户即将离开站点时,用户运营团队可以在“个性化配置发布”能力的支持下,及时推送优惠券或折扣信息等,进一步驱动用户留存。专属定制化服务不仅能增强用户对平台的依赖和信任,有效地留住了可能因未找到心仪商品而流失的客户,同时吸引了更多原本处于观望状态的潜在客户进行购买。这种量身定制的体验,不仅让用户在获取资讯和服务的过程中节省时间精力,还让他们感受到被“读懂”和专属对待,从而增强了用户对品牌的好感和忠诚度。
不仅仅是“用户个性化配置发布”能力,DataTester在满足业务复杂需求的过程中,更演化出一站式实验管理与场景化特型实验等全方位能力,基于其稳定可靠的分流功能、科学完善的统计引擎、智能的调优算法,为业务增长、用户转化、产品迭代、策略优化、运营提效等各个环节提供科学的决策依据,让业务真正做到数据驱动。
为了给用户提供更极致的数据服务,火山引擎数智平台旗下的更多产品也在企业数据应用场景上持续拓展,如智能数据洞察DataWind、增长分析平台DataFinder、客户数据平台VeCDP、增长营销平台GMP等工具,覆盖企业所需的全链路数智能力,助力企业实现全场景数据消费,充分释放数据价值。(作者:李双)
收起阅读 »多用户抢红包,如何保证只有一个抢到
前言
在一个百人群中,群主发了个红包,设置的3个人瓜分。如何能够保证只有3个人能抢到。100个人去抢,相当于就是100个线程去争夺这3个资源,如果处理不好,可能就会发生“超卖”,产生脏数据,威胁系统的正常运行。
当100个人同时去抢,也就是线程1,线程2,线程3...,此时线程1和线程2已经抢到了,就还剩一个红包了,而此时线程3和线程4同时发出抢红包的命令,线程3查询数据库发现还剩1个,抢下成功,而线程3还未修改库存时,线程4也来读取,发现还剩一个,也抢成功。结果这就发生“超卖”,红包被抢了4个,数据库一看红包剩余为-1。
解决思路
为了保证资源的安全,不能让多个用户同时访问到资源,也就是需要互斥的访问共有资源,同一时刻只能让一个用户访问,也就是给共享资源加上一个悲观锁,只有拿到锁的线程才能正常访问资源,拿不到锁的线程也不能让他一直等着,直接返回用户让他稍后重试。
JVM本地锁
JVM本地锁由ReentrantLock或synchronized实现
//抢红包方法加锁
public synchronized void grabRedPaper(){
...业务处理
}
不过这种同步锁粒度太大,我们需要的是针对抢同一红包的用户互斥,而这种方式是所有调用grabRedPaper方法的线程都需要等待,即限制所有人抢红包操作,效率低且不符合业务需求。每个红包应该都有一个唯一性ID,在单个红包上加锁效率就会高很多,也是单进程常用的使用方式。
private Map<String, Object> lockMap = new HashMap<>();
//抢红包方法
public void grabRedPaper(String redPaperId) {
Object lock = getLock(redPaperId);
synchronized (lock) {
// 在这里进行对业务的互斥访问操作
}
}
//获取红包ID锁对象
private Object getLock(String redPaperId) {
if (!lockMap.containsKey(redPaperId)) {
lockMap.put(redPaperId, new Object());
}
return lockMap.get(redPaperId);
}
Redis分布式锁
但当我们使用分布式系统中,一个业务功能会打包部署到多台服务器上,也就是会有多个进程来尝试获取共享资源,本地JVM锁也就无法完成需求了,所以我们需要第三方统一控制资源的分配,也就是分布式锁。
分布式锁一般一般需要满足四个基本条件:
- 互斥:同一时刻,只能有一个线程获取到资源。
- 可重入:获取到锁资源后,后续还能继续获取到锁。
- 高可用:锁服务一个宕机后还能有另一个接着服务;再者即使发生了错误,一定时间内也能自动释放锁,避免死锁发生。
- 非阻塞:如果获取不到锁,不能无限等待。
有关分布式锁的具体实现我之前的文章有讲到Java实现Redis分布式锁 - 掘金 (juejin.cn)
Mysql行锁
再者我们还可以通过Mysql的行锁实现,SELECT...FOR UPDATE,这种方式会将查询时的行锁住,不允许其他事务修改,直到读取完毕。将行锁和修改红包剩余数量放在一个事务中,也能做到互斥。不过这种做法效率较差,不推荐使用。
总结
方案 | 实现举例 | 优点 | 缺点 |
---|---|---|---|
JVM本地锁 | synchronized | 实现简单,性能较好 | 只能在单个 JVM 进程内使用,无法用于分布式环境 |
Mysql行锁 | SELECT...FOR UPDATE | 保证并发情况下的隔离性,避免出现脏数据 | 增加了数据库的开销,特别是在高并发场景下;对应用程序有一定的侵入性,需要在 SQL 语句中正确使用锁定机制。 |
分布式锁 | Redis分布式锁 | 可用于分布式,性能较高 | 实现相对复杂,需要考虑锁的续租、释放等问题。 |
来源:juejin.cn/post/7398038222985543692
二维码扫码登录业务详解
二维码扫码登录业务详解
前言
二维码登录 顾名思义 重要是在于登录这俩个字
登录简单点来说可以概括为俩点
- 告诉系统
我是谁
- 向系统证明
我是谁
下面我们就会围绕着这俩点来展开详细说明
原理解析
其实大部分的二维码 都是一个url
地址
我们以掘金扫码登录为例来进行剖析
我们进行一个解析
我们可以发现她实际就是这样的一个url
所以说 我们二维码的一个操作 做出来的就是一个url地址
那么我们知道这个后 我们就可以来进行一个流程的解析。
就是一个这样简单的流程
流程概述
简单来说氛围下面的步骤:
- PC端:进入二维码登录页面,请求服务端获取二维码的ID。
- 服务端:生成二维码ID,并将其与请求的设备绑定后,返回有效的二维码ID。
- PC端:根据二维码ID生成二维码图片,并展示出来。
- 移动端:扫描二维码,解析出二维码ID。
- 移动端:使用移动端的token和二维码ID请求服务端进行登录。
- 服务端:解析验证请求,绑定用户信息,并返回给移动端一个用于二次确认的临时token。
- PC端:展示二维码为“待确认”状态。
- 移动端:使用二维码ID、临时token和移动端的token进行确认登录。
- 服务端:验证通过后,修改二维码状态,并返回给PC端一个登录的token。
下面我们来用一个python的代码来描述一下这个过程。
首先是服务端:
from flask import Flask, request, jsonify
import uuid
import time
app = Flask(__name__)
# 存储二维码ID和对应的设备信息以及临时token
qr_code_store = {}
temporary_tokens = {}
@app.route('/generate_qr', methods=['POST'])
def generate_qr():
device_id = request.json['device_id']
qr_id = str(uuid.uuid4())
qr_code_store[qr_id] = {'device_id': device_id, 'timestamp': time.time(), 'status': 'waiting'}
return jsonify({'qr_id': qr_id})
@app.route('/scan_qr', methods=['POST'])
def scan_qr():
qr_id = request.json['qr_id']
token = request.json['token']
if qr_id in qr_code_store:
qr_code_store[qr_id]['status'] = 'scanned'
temp_token = str(uuid.uuid4())
temporary_tokens[temp_token] = {'qr_id': qr_id, 'timestamp': time.time()}
return jsonify({'temp_token': temp_token})
return jsonify({'error': 'Invalid QR code'}), 400
@app.route('/confirm_login', methods=['POST'])
def confirm_login():
qr_id = request.json['qr_id']
temp_token = request.json['temp_token']
mobile_token = request.json['mobile_token']
if temp_token in temporary_tokens and temporary_tokens[temp_token]['qr_id'] == qr_id:
login_token = str(uuid.uuid4())
qr_code_store[qr_id]['status'] = 'confirmed'
return jsonify({'login_token': login_token})
return jsonify({'error': 'Invalid confirmation'}), 400
if __name__ == '__main__':
app.run(debug=True)
之后来看PC端:
import requests
import json
# 1. 请求生成二维码ID
response = requests.post('http://localhost:5000/generate_qr', json={'device_id': 'PC_device'})
qr_id = response.json()['qr_id']
# 2. 根据二维码ID生成二维码图片 (此处省略,可以使用第三方库生成二维码图片)
print(f"QR Code ID: {qr_id}")
# 7. 显示二维码进入“待确认”状态
print("QR Code Status: Waiting for confirmation")
之后再来看移动端的代码:
import requests
# 4. 扫描二维码,解析出二维码ID
qr_id = '解析出的二维码ID'
token = '移动端token'
# 5. 请求服务端进行登录
response = requests.post('http://localhost:5000/scan_qr', json={'qr_id': qr_id, 'token': token})
temp_token = response.json()['temp_token']
# 8. 使用二维码ID、临时token和移动端的token进行确认登录
response = requests.post('http://localhost:5000/confirm_login', json={'qr_id': qr_id, 'temp_token': temp_token, 'mobile_token': token})
login_token = response.json().get('login_token')
if login_token:
print("登录成功!")
else:
print("登录失败!")
这样一个简单的二维码登录的流程就出来了
案例解析
了解了流程之后我们来看看其他大型网站是如何实施的 这里拿哔哩哔哩来举例。
我们可以看到她的那个json实例
{
"code": 0,
"message": "0",
"ttl": 1,
"data": {
"url": "",
"refresh_token": "",
"timestamp": 0,
"code": 86101,
"message": "未扫码"
}
}
我们可以发现他是不断的去发送这个请求 每过1s大概
之后当我们扫描后发现已经变成等待确认
当我们确认后 他会返回
和我们说的流程大概的相同
来源:juejin.cn/post/7389952503041884170
数据无界,存储有方:MinIO,为极致性能而生!
MinIO: 数据宇宙的超级存储引擎,解锁云原生潜能- 精选真开源,释放新价值。
概览
MinIO,这一高性能分布式对象存储系统的佼佼者,正以开源的力量重塑企业数据存储的版图。它设计轻巧且完全兼容Amazon S3接口,成为连接全球开发者与企业的桥梁,提供了一个既强大又灵活的数据管理平台。超越传统存储桶的范畴,MinIO是专为应对大规模数据挑战而生,尤其适用于AI深度学习、大数据分析等高负载场景,其单个对象高达5TB的存储容量设计,确保了数据密集应用运行无阻,流畅高效。
无论是部署于云端、边缘计算环境还是本地服务器,MinIO展现了极高的适配性和灵活性。它以超简单的安装步骤、直观的管理界面以及平滑的扩展能力,极大地降低了企业构建复杂数据架构的门槛。利用MinIO,企业能够快速部署数据湖,以集中存储海量数据,便于分析挖掘;构建高效的内容分发网络(CDN),加速内容的全球分发;或建立可靠的备份存储方案,确保数据资产的安全无虞。
MinIO的特性还包括自动数据分片、跨区域复制、以及强大的数据持久性和高可用性机制,这些都为数据的全天候安全与访问提供了坚实保障。此外,其微服务友好的架构,使得集成到现有的云原生生态系统中变得轻而易举,加速了DevOps流程,提升了整体IT基础设施的响应速度和灵活性。
主要功能
你可以进入官方网站下载体验:min.io/download
- 高性能存储
- 优化的I/O路径:MinIO通过精心设计的I/O处理逻辑,减少了数据访问的延迟,确保了数据读写操作的高速执行。
- 并发设计:支持高并发访问,能够有效利用多核处理器,即使在高负载情况下也能维持稳定的吞吐量,特别适合处理大数据量的读写请求。
- 裸机级性能:通过底层硬件的直接访问和资源高效利用,使得在普通服务器上也能达到接近硬件极限的存储性能,为PB级数据的存储与处理提供强大支撑。
- 分布式架构
- 多节点部署:允许用户根据需求部署多个节点,形成分布式存储集群,横向扩展存储容量和处理能力。
- 纠删码技术:采用先进的纠删码(Erasure Coding)代替传统的RAID,即使在部分节点故障的情况下,也能自动恢复数据,确保数据的完整性和服务的连续性,提高了系统的容错能力。
- 高可用性与持久性:通过跨节点的数据复制或纠删码,确保数据在不同地理位置的多个副本,即使面临单点故障,也能保证数据的不间断访问,满足严格的SLA要求。
- 全面的S3兼容性
- 无缝集成:MinIO完全兼容Amazon S3 API,这意味着现有的S3应用程序、工具和库可以直接与MinIO对接,无需修改代码。
- 迁移便利:企业可以从AWS S3或任何其他S3兼容服务平滑迁移至MinIO,降低迁移成本,加速云原生应用的部署进程。
- 安全与合规
- 加密传输:支持SSL/TLS协议,确保数据在传输过程中加密,防止中间人攻击,保障数据通信安全。
- 访问控制:提供细粒度的访问控制列表(ACLs)和策略管理,实现用户和群体的权限分配,确保数据访问权限的严格控制。
- 审计与日志:记录详细的系统活动日志,便于监控和审计,符合GDPR、HIPAA等国际安全标准和法规要求。
- 简易管理与监控
- 直观Web界面:用户可通过Web UI进行集群配置、监控和日常管理,界面友好,操作简便。
- Prometheus集成:集成Prometheus监控系统,实现存储集群的实时性能监控和告警通知,帮助管理员及时发现并解决问题,确保系统稳定运行。
信息
截至发稿概况如下:
- 软件地址:github.com/minio/minio
- 软件协议:AGPL 3.0
- 编程语言:
语言 | 占比 |
---|---|
Go | 99.0% |
Other | 1.0% |
- 收藏数量:44.3K
面对不断增长的数据管理挑战,MinIO不仅是一个存储解决方案,更是企业在数字化转型旅程中的核心支撑力量,助力各行各业探索数据价值的无限可能。无论是优化存储成本、提升数据处理效率,还是确保数据安全与合规,MinIO持续推动技术边界,邀请每一位技术探索者加入其活跃的开源社区,共同参与讨论,贡献智慧,共同塑造数据存储的未来。
尽管MinIO凭借其卓越的性能与易于使用的特性在存储领域独树一帜,但面对数据量的指数级增长和环境的日益复杂,一系列新挑战浮出水面。首要任务是在海量数据中实现高效的数据索引与查询机制,确保信息的快速提取与分析。其次,在混合云及多云部署的趋势下,如何平滑实现数据在不同平台间的迁移与实时同步,成为提升业务连续性和灵活性的关键。再者,数据安全虽为根基,但在成本控制上也不可忽视,优化存储策略,在强化防护的同时降低开支,实现存储经济性与安全性的完美平衡,是当前亟待探讨与解决的课题。这些问题不仅考验着技术的极限,也为MinIO及其用户社区带来了新的研究方向与实践机遇。
热烈欢迎各位在评论区分享交流心得与见解!!!
来源:juejin.cn/post/7363570869065498675
数字签名 Signature
这一章,我们将简单的介绍以太坊中的数字签名ECDSA
,以及如何利用它发放NFT
白名单。代码中的ECDSA
库由OpenZeppelin
的同名库简化而成。
数字签名
如果你用过opensea
交易NFT
,对签名就不会陌生。下图是小狐狸(metamask
)钱包进行签名时弹出的窗口,它可以证明你拥有私钥的同时不需要对外公布私钥。
以太坊使用的数字签名算法叫双椭圆曲线数字签名算法(ECDSA
),基于双椭圆曲线“私钥-公钥”对的数字签名算法。它主要起到了三个作用:
- 身份认证:证明签名方是私钥的持有人。
- 不可否认:发送方不能否认发送过这个消息。
- 完整性:消息在传输过程中无法被修改。
ECDSA
合约
ECDSA
标准中包含两个部分:
- 签名者利用
私钥
(隐私的)对消息
(公开的)创建签名
(公开的)。 - 其他人使用
消息
(公开的)和签名
(公开的)恢复签名者的公钥
(公开的)并验证签名。 我们将配合ECDSA
库讲解这两个部分。本教程所用的私钥
,公钥
,消息
,以太坊签名消息
,签名
如下所示:
私钥: 0x227dbb8586117d55284e26620bc76534dfbd2394be34cf4a09cb775d593b6f2b
公钥: 0xe16C1623c1AA7D919cd2241d8b36d9E79C1Be2A2
消息: 0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c
以太坊签名消息: 0xb42ca4636f721c7a331923e764587e98ec577cea1a185f60dfcc14dbb9bd900b
签名: 0x390d704d7ab732ce034203599ee93dd5d3cb0d4d1d7c600ac11726659489773d559b12d220f99f41d17651b0c1c6a669d346a397f8541760d6b32a5725378b241c
创建签名
1. 打包消息: 在以太坊的ECDSA
标准中,被签名的消息
是一组数据的keccak256
哈希,为bytes32
类型。我们可以把任何想要签名的内容利用abi.encodePacked()
函数打包,然后用keccak256()
计算哈希,作为消息
。我们例子中的消息
是由一个address
类型变量和一个uint256
类型变量得到的:
/*
* 将mint地址(address类型)和tokenId(uint256类型)拼成消息msgHash
* _account: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
* _tokenId: 0
* 对应的消息msgHash: 0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c
*/
function getMessageHash(address _account, uint256 _tokenId) public pure returns(bytes32){
return keccak256(abi.encodePacked(_account, _tokenId));
}
2. 计算以太坊签名消息: 消息
可以是能被执行的交易,也可以是其他任何形式。为了避免用户误签了恶意交易,EIP191
提倡在消息
前加上"\x19Ethereum Signed Message:\n32"
字符,并再做一次keccak256
哈希,作为以太坊签名消息
。经过toEthSignedMessageHash()
函数处理后的消息,不能被用于执行交易:
/**
* @dev 返回 以太坊签名消息
* `hash`:消息
* 遵从以太坊签名标准:https://eth.wiki/json-rpc/API#eth_sign[`eth_sign`]
* 以及`EIP191`:https://eips.ethereum.org/EIPS/eip-191`
* 添加"\x19Ethereum Signed Message:\n32"字段,防止签名的是可执行交易。
*/
function toEthSignedMessageHash(bytes32 hash) internal pure returns (bytes32) {
// 哈希的长度为32
return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
}
处理后的消息为:
以太坊签名消息: 0xb42ca4636f721c7a331923e764587e98ec577cea1a185f60dfcc14dbb9bd900b
3-1. 利用钱包签名: 日常操作中,大部分用户都是通过这种方式进行签名。在获取到需要签名的消息之后,我们需要使用metamask
钱包进行签名。metamask
的personal_sign
方法会自动把消息
转换为以太坊签名消息
,然后发起签名。所以我们只需要输入消息
和签名者钱包account
即可。需要注意的是输入的签名者钱包account
需要和metamask
当前连接的account一致才能使用。
因此首先把例子中的私钥
导入到小狐狸钱包,然后打开浏览器的console
页面:Chrome菜单-更多工具-开发者工具-Console
。在连接钱包的状态下(如连接opensea,否则会出现错误),依次输入以下指令进行签名:
ethereum.enable()
account = "0xe16C1623c1AA7D919cd2241d8b36d9E79C1Be2A2"
hash = "0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c"
ethereum.request({method: "personal_sign", params: [account, hash]})
在返回的结果中(Promise
的PromiseResult
)可以看到创建好的签名。不同账户有不同的私钥,创建的签名值也不同。利用教程的私钥创建的签名如下所示:
0x390d704d7ab732ce034203599ee93dd5d3cb0d4d1d7c600ac11726659489773d559b12d220f99f41d17651b0c1c6a669d346a397f8541760d6b32a5725378b241c
3-2. 利用web3.py签名: 批量调用中更倾向于使用代码进行签名,以下是基于web3.py的实现。
from web3 import Web3, HTTPProvider
from eth_account.messages import encode_defunct
private_key = "0x227dbb8586117d55284e26620bc76534dfbd2394be34cf4a09cb775d593b6f2b"
address = "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4"
rpc = 'https://rpc.ankr.com/eth'
w3 = Web3(HTTPProvider(rpc))
#打包信息
msg = Web3.solidityKeccak(['address','uint256'], [address,0])
print(f"消息:{msg.hex()}")
#构造可签名信息
message = encode_defunct(hexstr=msg.hex())
#签名
signed_message = w3.eth.account.sign_message(message, private_key=private_key)
print(f"签名:{signed_message['signature'].hex()}")
运行的结果如下所示。计算得到的消息,签名和前面的案例一致。
消息:0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c
签名:0x390d704d7ab732ce034203599ee93dd5d3cb0d4d1d7c600ac11726659489773d559b12d220f99f41d17651b0c1c6a669d346a397f8541760d6b32a5725378b241c
验证签名
为了验证签名,验证者需要拥有消息
,签名
,和签名使用的公钥
。我们能验证签名的原因是只有私钥
的持有者才能够针对交易生成这样的签名,而别人不能。
4. 通过签名和消息恢复公钥: 签名
是由数学算法生成的。这里我们使用的是rsv签名
,签名
中包含r, s, v
三个值的信息。而后,我们可以通过r, s, v
及以太坊签名消息
来求得公钥
。下面的recoverSigner()
函数实现了上述步骤,它利用以太坊签名消息 _msgHash
和签名 _signature
恢复公钥
(使用了简单的内联汇编):
// @dev 从_msgHash和签名_signature中恢复signer地址
function recoverSigner(bytes32 _msgHash, bytes memory _signature) internal pure returns (address){
// 检查签名长度,65是标准r,s,v签名的长度
require(_signature.length == 65, "invalid signature length");
bytes32 r;
bytes32 s;
uint8 v;
// 目前只能用assembly (内联汇编)来从签名中获得r,s,v的值
assembly {
/*
前32 bytes存储签名的长度 (动态数组存储规则)
add(sig, 32) = sig的指针 + 32
等效为略过signature的前32 bytes
mload(p) 载入从内存地址p起始的接下来32 bytes数据
*/
// 读取长度数据后的32 bytes
r := mload(add(_signature, 0x20))
// 读取之后的32 bytes
s := mload(add(_signature, 0x40))
// 读取最后一个byte
v := byte(0, mload(add(_signature, 0x60)))
}
// 使用ecrecover(全局函数):利用 msgHash 和 r,s,v 恢复 signer 地址
return ecrecover(_msgHash, v, r, s);
}
参数分别为:
// 以太坊签名消息
_msgHash:0xb42ca4636f721c7a331923e764587e98ec577cea1a185f60dfcc14dbb9bd900b
// 签名
_signature:0x390d704d7ab732ce034203599ee93dd5d3cb0d4d1d7c600ac11726659489773d559b12d220f99f41d17651b0c1c6a669d346a397f8541760d6b32a5725378b241c
5. 对比公钥并验证签名: 接下来,我们只需要比对恢复的公钥
与签名者公钥_signer
是否相等:若相等,则签名有效;否则,签名无效:
/**
* @dev 通过ECDSA,验证签名地址是否正确,如果正确则返回true
* _msgHash为消息的hash
* _signature为签名
* _signer为签名地址
*/
function verify(bytes32 _msgHash, bytes memory _signature, address _signer) internal pure returns (bool) {
return recoverSigner(_msgHash, _signature) == _signer;
}
参数分别为:
_msgHash:0xb42ca4636f721c7a331923e764587e98ec577cea1a185f60dfcc14dbb9bd900b
_signature:0x390d704d7ab732ce034203599ee93dd5d3cb0d4d1d7c600ac11726659489773d559b12d220f99f41d17651b0c1c6a669d346a397f8541760d6b32a5725378b241c
_signer:0xe16C1623c1AA7D919cd2241d8b36d9E79C1Be2A2
利用签名发放白名单
NFT
项目方可以利用ECDSA
的这个特性发放白名单。由于签名是链下的,不需要gas
。方法非常简单,项目方利用项目方账户把白名单发放地址签名(可以加上地址可以铸造的tokenId
)。然后mint
的时候利用ECDSA
检验签名是否有效,如果有效,则给他mint
。
SignatureNFT
合约实现了利用签名发放NFT
白名单。
状态变量
合约中共有两个状态变量:
signer
:公钥
,项目方签名地址。mintedAddress
是一个mapping
,记录了已经mint
过的地址。
函数
合约中共有4个函数:
- 构造函数初始化
NFT
的名称和代号,还有ECDSA
的签名地址signer
。 mint()
函数接受地址address
,tokenId
和_signature
三个参数,验证签名是否有效:如果有效,则把tokenId
的NFT
铸造给address
地址,并将它记录到mintedAddress
。它调用了getMessageHash()
,ECDSA.toEthSignedMessageHash()
和verify()
函数。getMessageHash()
函数将mint
地址(address
类型)和tokenId
(uint256
类型)拼成消息
。verify()
函数调用了ECDSA
库的verify()
函数,来进行ECDSA
签名验证。
contract SignatureNFT is ERC721 {
address immutable public signer; // 签名地址
mapping(address => bool) public mintedAddress; // 记录已经mint的地址
// 构造函数,初始化NFT合集的名称、代号、签名地址
constructor(string memory _name, string memory _symbol, address _signer)
ERC721(_name, _symbol)
{
signer = _signer;
}
// 利用ECDSA验证签名并mint
function mint(address _account, uint256 _tokenId, bytes memory _signature)
external
{
bytes32 _msgHash = getMessageHash(_account, _tokenId); // 将_account和_tokenId打包消息
bytes32 _ethSignedMessageHash = ECDSA.toEthSignedMessageHash(_msgHash); // 计算以太坊签名消息
require(verify(_ethSignedMessageHash, _signature), "Invalid signature"); // ECDSA检验通过
require(!mintedAddress[_account], "Already minted!"); // 地址没有mint过
_mint(_account, _tokenId); // mint
mintedAddress[_account] = true; // 记录mint过的地址
}
/*
* 将mint地址(address类型)和tokenId(uint256类型)拼成消息msgHash
* _account: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
* _tokenId: 0
* 对应的消息: 0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c
*/
function getMessageHash(address _account, uint256 _tokenId) public pure returns(bytes32){
return keccak256(abi.encodePacked(_account, _tokenId));
}
// ECDSA验证,调用ECDSA库的verify()函数
function verify(bytes32 _msgHash, bytes memory _signature)
public view returns (bool)
{
return ECDSA.verify(_msgHash, _signature, signer);
}
}
总结
这一讲,我们介绍了以太坊中的数字签名ECDSA
,如何利用ECDSA
创建和验证签名,还有ECDSA
合约,以及如何利用它发放NFT
白名单。代码中的ECDSA
库由OpenZeppelin
的同名库简化而成。
- 由于签名是链下的,不需要
gas
,因此这种白名单发放模式比Merkle Tree
模式还要经济; - 但由于用户要请求中心化接口去获取签名,不可避免的牺牲了一部分去中心化;
- 额外还有一个好处是白名单可以动态变化,而不是提前写死在合约里面了,因为项目方的中心化后端接口可以接受任何新地址的请求并给予白名单签名。
来源:juejin.cn/post/7376324160484327424
我写了个ffmpeg-spring-boot-starter 使得Java能剪辑视频!!
最近工作中在使用FFmpeg,加上之前写过较多的SpringBoot的Starter,所以干脆再写一个FFmpeg的Starter出来给大家使用。
首先我们来了解一下FFmpeg能干什么,FFmpeg 是一个强大的命令行工具和库集合,用于处理多媒体数据。它可以用来做以下事情:
- 解码:将音频和视频从压缩格式转换成原始数据。
- 编码:将音频和视频从原始数据压缩成各种格式。
- 转码:将一种格式的音频或视频转换为另一种格式。
- 复用:将音频、视频和其他流合并到一个容器中。
- 解复用:从一个容器中分离出音频、视频和其他流。
- 流媒体:在网络上传输音频和视频流。
- 过滤:对音频和视频应用各种效果和调整。
- 播放:直接播放媒体文件。
FFmpeg支持广泛的编解码器和容器格式,并且由于其开源性质,被广泛应用于各种多媒体应用程序中,包括视频会议软件、在线视频平台、编辑软件等。
例如
作者很喜欢的一款截图软件ShareX就使用到了FFmpeg的功能。
现在ffmpeg-spring-boot-starter已发布,maven地址为
ffmpeg-spring-boot-starter
那么如何使用ffmpeg-spring-boot-starter 呢?
第一步,新建一个SpringBoot项目
SpringBoot入门:如何新建SpringBoot项目(保姆级教程)
第二步,在pom文件里面引入jar包
<dependency>
<groupId>io.gitee.wangfugui-ma</groupId>
<artifactId>ffmpeg-spring-boot-starter</artifactId>
<version>${最新版}</version>
</dependency>
第三步,配置你的ffmpeg信息
在yml或者properties文件中配置如下信息
ffmpeg.ffmpegPath=D:\\ffmpeg-7.0.1-full_build\\bin\\
注意这里要配置为你所安装ffmpeg的bin路径,也就是脚本(ffmpeg.exe)所在的目录,之所以这样设计的原因就是可以不用在系统中配置环境变量,直接跳过了这一个环节(一切为了Starter)
第四步,引入FFmpegTemplate
@Autowired
private FFmpegTemplate ffmpegTemplate;
在你的项目中直接使用Autowired
注解注入FFmpegTemplate
即可使用
第五步,使用FFmpegTemplate
execute(String command)
- 功能:执行任意FFmpeg命令,捕获并返回命令执行的输出结果。
- 参数:
command
- 需要执行的FFmpeg命令字符串。 - 返回:命令执行的输出结果字符串。
- 实现:使用
Runtime.getRuntime().exec()
启动外部进程,通过线程分别读取标准输出流和错误输出流,确保命令执行过程中的所有输出都被记录并可被进一步分析。 - 异常:抛出
IOException
和InterruptedException
,需在调用处妥善处理。
FFmpeg执行器,这是这里面最核心的方法,之所以提供这个方法,是来保证大家的自定义的需求,例如FFmpegTemplate中没有封装的方法,可以灵活自定义ffmpeg的执行参数。
convert(String inputFile, String outputFile)
- 功能:实现媒体文件格式转换。
- 参数:
inputFile
- 待转换的源文件路径;outputFile
- 转换后的目标文件路径。 - 实现:构建FFmpeg命令,调用FFmpeg执行器完成媒体文件格式的转换。
就像这样:
@Test
void convert() {
ffmpegTemplate.convert("D:\\video.mp4","D:\\video.avi");
}
extractAudio(String inputFile)
- 功能:精确提取媒体文件的时长信息。
- 参数:
inputFile
- 需要提取时长信息的媒体文件路径。 - 实现:构造特定的FFmpeg命令,仅请求媒体时长数据,直接调用FFmpeg执行器并解析返回的时长值。
就像这样:
@Test
void extractAudio() { System.out.println(ffmpegTemplate.extractAudio("D:\\video.mp4"));
}
copy(String inputFile, String outputFile)
- 功能:执行流复制,即在不重新编码的情况下快速复制媒体文件。
- 参数:
inputFile
- 源媒体文件路径;outputFile
- 目标媒体文件路径。 - 实现:创建包含流复制指令的FFmpeg命令,直接调用FFmpeg执行器,以达到高效复制的目的。
就像这样:
@Test
void copy() {
ffmpegTemplate.copy("D:\\video.mp4","D:\\video.avi");
}
captureVideoFootage(String inputFile, String outputFile, String startTime, String endTime)
- 功能:精准截取视频片段。
- 参数:
inputFile
- 源视频文件路径;outputFile
- 截取片段的目标文件路径;startTime
- 开始时间;endTime
- 结束时间。 - 实现:构造FFmpeg命令,指定视频片段的开始与结束时间,直接调用FFmpeg执行器,实现视频片段的精确截取。
@Test
void captureVideoFootage() {
ffmpegTemplate.captureVideoFootage("D:\\video.mp4","D:\\cut.mp4","00:01:01","00:01:12");
}
scale(String inputFile, String outputFile, Integer width, Integer height)
- 功能:调整媒体文件的分辨率。
- 参数:
inputFile
- 源媒体文件路径;outputFile
- 输出媒体文件路径;width
- 目标宽度;height
- 目标高度。 - 实现:创建包含分辨率调整指令的FFmpeg命令,直接调用FFmpeg执行器,完成媒体文件分辨率的调整。
@Test
void scale() {
ffmpegTemplate.scale("D:\\video.mp4","D:\\video11.mp4",640,480);
}
cut(String inputFile, String outputFile, Integer x, Integer y, Integer width, Integer height)
- 功能:实现媒体文件的精确裁剪。
- 参数:
inputFile
- 源媒体文件路径;outputFile
- 裁剪后媒体文件路径;x
- 裁剪框左上角X坐标;y
- 裁剪框左上角Y坐标;width
- 裁剪框宽度;height
- 裁剪框高度。 - 实现:构造FFmpeg命令,指定裁剪框的坐标与尺寸,直接调用FFmpeg执行器,完成媒体文件的精确裁剪。
@Test
void cut() {
ffmpegTemplate.cut("D:\\video.mp4","D:\\video111.mp4",100,100,640,480);
}
embedSubtitle(String inputFile, String outputFile, String subtitleFile)
- 功能:将字幕文件内嵌至视频中。
- 参数:
inputFile
- 视频文件路径;outputFile
- 输出视频文件路径;subtitleFile
- 字幕文件路径。 - 实现:构造FFmpeg命令,将字幕文件内嵌至视频中,直接调用FFmpeg执行器,完成字幕的内嵌操作。
@Test
void embedSubtitle() {
ffmpegTemplate.embedSubtitle("D:\\video.mp4","D:\\video1211.mp4","D:\\srt.srt");
}
merge(String inputFile, String outputFile)
- 功能: 通过外部ffmpeg工具将多个视频文件合并成一个。
- 参数:
inputFile
: 包含待合并视频列表的文本文件路径。outputFile
: 合并后视频的输出路径。
是这样用的:
@Test
void merge() {
ffmpegTemplate.merge("D:\\mylist.txt","D:\\videoBig.mp4");
}
注意,这个mylist.txt文件长这样:
后续版本考虑支持
- 添加更多丰富的api
- 区分win和Linux环境(脚本执行条件不同)
- 支持在系统配置环境变量(用户如果没有配置配置文件的ffmpegPath信息可以自动使用环境变量)
来源:juejin.cn/post/7391326728461647872
Swoole v6 能否让 PHP 再次伟大?
大家好,我是码农先森。
现状
传统的 PHP-FPM 也是多进程模型的的运行方式,但每个进程只能处理完当前请求,才能接收下一个请求。而且对于 PHP 脚本来说,只是接收请求和响应请求,并不参与网络通信。对数据库资源的操作,也是一次请求一次有效,用完即销毁不能复用,在系统高负载的情况下对数据库等资源的消耗会很大,能承受的并发量有限。
Swoole 的出现给 PHP 带来了一种新的运行方式,完全接管了 PHP-FPM 的功能,并且弥补了 PHP 在异步网络通信领域的空白。Swoole 提供了 PHP 的全生命周期管理,此外 Swoole 的常驻进程模式,也能够高效的利用资源,比如可以建立数据库连接池、共享内存变量等。还有 Swoole 中能够支撑高并发的利器「协程」,更加使 PHP 的性能上了一个新的台阶,甚至在某些特定场景下都可以与 Go 语言的性能相媲美。
虽说 Swoole 给 PHP 带来了很大的性能提升,但也还是一个基于多进程模型的异步通信扩展,多进程的模式也存在着许多的问题,比如跨进程间的通信、进程间的资源共享等问题。简而言之,多进程会带来一定的系统资源消耗及产生新的问题。
因此 Swoole 官方为了解决多进程的问题,引进了多线程的支持,这意味着 v6 版本之后,Swoole 将会变成单进程多线程的运行模式。
v6 新特性
根据 Swoole 作者韩天峰发布的预告,在 v6 版本中增加多线程的支持。其中多线程的实现是基于 PHP 的 ZTS 机制和 TSRM API,在 PHP 层面隔离所有全局变量,实现线程安全。Swoole v6 的多线程将是真正的多线程实现,在单进程的模式下所有的 PHP 程序代码均是在多核并行执行,能够高效的利用好 CPU 资源。
v6 版本还提供了线程安全的 Map 和 ArrayList 数据结构,可以实现跨线程的数据共享读写。在 Server 端的 Event Worker、Task Worker、User Process 等将全部替换为 线程的运行方式,在同一个进程空间内执行,彻底摒弃了多进程的模式。
当然新的特性势必会带来新的开销,对于 Map 等共享的数据结构在多线程的模式下需要加锁,来避免数据竞争,可能会损耗一些性能。
以下是列举的一些线程相关的 API 方法:
- use Swoole\Thread 线程对象。
- use Swoole\Thread\Map 线程安全下的 Map 数据结构。
- use Swoole\Thread\ArrayList 线程安全下的 ArrayList 数据结构。
- Swoole\Thread::getId() 获取当前线程的 ID。
- Swoole\Thread::getArguments() 获取父线程传递给子线程的参数列表。
- Swoole\Thread::join() 等待子线程退出,请注意 $thread 对象销毁时会自动执行 join() ,这可能会导致进程阻塞。
- Swoole\Thread::joinable() 检测子线程是否已退出。
- Swoole\Thread::detach() 使子线程独立运行,不再需要 Thread::join()。
- Swoole\Thread::HARDWARE_CONCURRENCY 硬件层支持的并行线程数量。
- Swoole\Thread::$id 获取子线程的 ID。
- Swoole\Thread::exec() 开启一个新的线程。
最后
自 Swoole 从 2012 年发布第一个版本开始,就扛起了 PHP 领域异步通信的大旗,但这多年以来 Swoole 的发展也是实属不易。还记得刚开始时的异步回调模式的套娃式编程方式,开发起来异常艰难,到后来的同步式编程,直接降低了PHP程序员的学习门槛,让 PHP 在实时通信、物联网通信、游戏开发等领域也能大展拳脚,同时在 PHP 的发展史上也产生了重大的影响。
随着 Go 语言在编程界的持续火热,Swoole 常常被 PHP 程序员拿来和 Go 语言一决高下,总是被诟病 Swoole 无法有效利用多核 CPU、进程间的通信困难等问题。话又说回来,Swoole 作为一个 PHP 的扩展程序和天生具有高性能的 Go 语言自然是不可比拟的,但 Swoole 也是在逐渐的向 Go 语言靠近,比如 Swoole 中也使用了「go、channel」关键词来实现协程及通信通道,虽说底层的实现机制还是大不相同的。
当然 Swoole 也在不断地努力持续优化,就像将要推出的 v6 版本增加多线程的支持,来改变目前多进程的局面。至于这个版本对 PHP 发展来说有没有很大的影响,我认为影响有限。但对 Swoole 的发展还是有很大的影响,毕竟以后再也不用受多进程的困扰了,这也是一大进步。
在 Web 领域作为世界上最好的语言,尽管 PHP 近年来的发展不尽如人意,但作为一名 PHPer 也有必要和有义务一起来维护和推动 PHP 生态的发展。
欢迎关注、分享、点赞、收藏、在看,我是微信公众号「码农先森」作者。
来源:juejin.cn/post/7384696986845085731
微信公众号推送消息笔记
根据业务需要,开发一个微信公众号的相关开发,根据相关开发和整理总结了一下相关的流程和需要,进行一些整理和总结分享给大家,最近都在加班和忙碌,博客已经很久未更新了,打气精神,再接再厉,申请、认证公众号的一系列流程就不在这里赘述了,主要进行的是技术的分享,要达到的效果如下图:
开发接入
首先说明我这里用的是PHP开发语言来进行的接入,设置一个url让微信公众号的服务回调这个url,在绑定之前需要一个token的验证,设置不对会提示token不正确的提示
官方提供的测试Url工具:developers.weixin.qq.com/apiExplorer…
private function checkSignature()
{
$signature = isset($_GET["signature"]) ? $_GET["signature"] : '';
$timestamp = isset($_GET["timestamp"]) ? $_GET["timestamp"] : '';
$nonce = isset($_GET["nonce"]) ? $_GET["nonce"] : '';
$echostr = isset($_GET["echostr"]) ? $_GET["echostr"] : '';
$token = 'klsg2024';
$tmpArr = array($token, $timestamp, $nonce);
sort($tmpArr, SORT_STRING);
$tmpStr = implode( $tmpArr );
$tmpStr = sha1( $tmpStr );
if( $tmpStr == $signature ){
return $echostr;
}else{
return false;
}
}
在设置的地方调用: 微信公众号的 $echostr 和 自定义的匹配上说明调用成功了
public function console(){
//关注公众号推送
$posts = $this->posts;
if(!isset($_GET['openid'])){
$res = $this->checkSignature();
if($res){
echo $res;
return true;
}else{
return false;
}
}
}
设置access_token
公众号的开发的所有操作的前提都是先设置access_token,在于验证操作的合法性,所需要的token在公众号后台的目录中获取:公众号-设置与开发-基本设置 设置和查看:
#POST https://api.weixin.qq.com/cgi-bin/token
{
"grant_type": "client_credential",
"appid": "开发者ID(AppID)",
"secret": "开发者密码(AppSecret)"
}
返回的access_token,过期时间2个小时,Http url 返回结构如下:
{
"access_token": "82_W8kdIcY2TDBJk6b1VAGEmA_X_DLQnCIi5oSZBxVQrn27VWL7kmUCJFVr8tjO0S6TKuHlqM6z23nzwf18W1gix3RHCw6uXKAXlD-pZEO7JcAV6Xgk3orZW0i2MFMNGQbAEARKU",
"expires_in": 7200
}
为了方便起见,公众号平台还开放了一个稳定版的access_token,参数略微有不同。
POST https://api.weixin.qq.com/cgi-bin/stable_token
{
"grant_type": "client_credential",
"appid": "开发者ID(AppID)",
"secret": "开发者密码(AppSecret)",
"force_refresh":true
}
自定义菜单
第一个疑惑是公众号里的底部菜单 是怎么搞出来的,在官方文档中获取到的,如果公众号后台没有设置可以根据自定义菜单来进行设置。
1、创建菜单,参数自己去官方文档上查阅
POST https://api.weixin.qq.com/cgi-bin/menu/create?access_token=ACCESS_TOKEN
2、查询菜单接口,文档和调试工具给的有点不一样,我使用的是调试工具给出的url
GET https://api.weixin.qq.com/cgi-bin/menu/get?access_token=ACCESS_TOKEN
3、删除菜单
GET https://api.weixin.qq.com/cgi-bin/menu/delete?access_token=ACCESS_TOKEN
事件拦截
在公众号的开发后台里会设置一个Url,每次在操作公众号时都会回调接口,用事件去调用和处理,操作公众号后,微信公众平台会请求到设置的接口上,公众号的openid 比较重要,是用来识别用户身份的唯一标识,openid即当前用户。
{
"signature": "d43a23e838e2b580ca41babc78d5fe78b2993dea",
"timestamp": "1721273358",
"nonce": "1149757628",
"openid": "odhkK64I1uXqoUQjt7QYx4O0yUvs"
}
用户进行相关操作时,回调接口会收到这样一份请求,都是用MsgType和Event去区分,下面是关注的回调:
{
"ToUserName": "gh_d98fc9c8e089",
"FromUserName": "用户openID",
"CreateTime": "1721357413",
"MsgType": "event",
"Event": "subscribe",
"EventKey": []
}
下面是点击菜单跳转的回调:
{
"ToUserName": "gh_d98fc9c8e089",
"FromUserName": "用户openID",
"CreateTime": "1721381657",
"MsgType": "event",
"Event": "VIEW",
"EventKey": "https:\/\/zhjy.rchang.cn\/api\/project_audit\/getOpenid?type=1",
"MenuId": "421351906"
}
消息推送
消息能力是公众号中最核心的能力,我们这次主要分享2个,被动回复用户消息和模板推送能力。
被动回复用户消息
被动回复用户消息,把需要的参数拼接成xml格式的,我觉得主要是出于安全上的考虑作为出发点。
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>12345678</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[你好]]></Content>
</xml>
在php代码里的实现即为:
protected function subscribe($params)
{
$time = time();
$content = "欢迎的文字";
$send_msg = '<xml>
<ToUserName><![CDATA['.$params['FromUserName'].']]></ToUserName>
<FromUserName><![CDATA['.$params['ToUserName'].']]></FromUserName>
<CreateTime>'.time().'</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA['.$content.']]></Content>
</xml>';
echo $send_msg;
return false;
}
模板推送能力
模版推送的两个关键是申请了模版,还有就是模版的data需要和模版中的一致,才能成功发送,模版设置和申请的后台位置在 广告与服务-模版消息
public function project_message()
{
$touser = '发送人公众号openid';
$template_id = '模版ID';
$url = 'https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=' . $this->access_token;
$details_url = '点开链接,需要跳转的详情url';
$thing1 = '模版里定义的参数';
$time2 = '模版里定义的参数';
$const3 = '模版里定义的参数';
$send_data = [
'touser' => $touser,
'template_id' => $template_id,
'url' => $details_url,
'data' => [
'thing1' => ['value' => $thing1],
'time2' => ['value' => $time2],
'const3' => ['value' => $const3],
]
];
$result = curl_json($url, $send_data);
}
错误及解决方式
1、公众号后台: 设置与开发-安全中心-IP白名单 把IP地址加入白名单即可。
{
"errcode": 40164,
"errmsg": "invalid ip 47.63.30.93 ipv6 ::ffff:47.63.30.93, not in whitelist rid: 6698ef60-27d10c40-100819f9"
}
2、模版参数不正确时,接口返回
{
"errcode": 47003,
"errmsg": "argument invalid! data.time5.value invalid rid: 669df26e-538a8a1a-15ab8ba4"
}
3、access_token不正确
{
"errcode": 40001,
"errmsg": "invalid credential, access_token is invalid or not latest, could get access_token by getStableAccessToken, more details at https://mmbizurl.cn/s/JtxxFh33r rid: 669df2f1-74be87a6-05e77d20"
}
4、access_token超过调用次数
{
"errcode": 45009,
"errmsg": "reach max api daily quota limit, could get access_token by getStableAccessToken, more details at https:\/\/mmbizurl.cn\/s\/JtxxFh33r rid: 669e5c4c-2bb4e05f-61d6917c"
}
文档参考
公众号开发文档首页: developers.weixin.qq.com/doc/offiacc…
分享一个微信公众号的调试工具地址,特别好用 : mp.weixin.qq.com/debug/
来源:juejin.cn/post/7394392321988575247
首屏优化之:import 动态导入
前言
前面我们聊过可以通过不同的 script 属性比如 defer,async 来实现不同的加载效果,从而实现首屏优化。
今天我们来聊一下动态导入之 import
,当然 import 动态导入也不是一把梭的,也是需要根据项目情况进行使用,在面试以及实际工作当中都可以用到,一起来看看吧!
在了解动态导入之前,我们先来看一下什么是静态导入
。
静态导入
静态导入会在编译时解析所有导入的模块,并在程序开始执行之前加载
它们。这意味着所有被导入的模块在应用启动时就已经加载完毕
。
什么意思,我们先来看一下下面这段代码:
这段代码很简单,我在页面导入了 import.js,当点击按钮时打印输出语句。
我们来看一下浏览器初始化加载情况:
很明显,程序开始执行之前,import.js 就被加载了。
但是在某些时刻,我们不希望文件在没有被使用时就被加载,只希望在使用时加载,这样可以优化首屏的加载速度,这些时刻我们就可以使用动态导入。
动态导入
动态导入是一种在代码执行时按需加载模块的技术,而不是在应用程序初始化时加载所有模块。
默认不会一上来加载所有文件,只会在用到时加载,这样可以优化初始加载时间,提升页面响应速度。
动态导入与静态导入不同,动态导入使用 ES6 中的 import()
语法,可以在函数或代码块中调用,从而实现条件加载、按需加载或延迟加载。例如:
import('./import.js')
还是上面的代码,我们使用动态导入来进行实现一下:
我们再来看一下浏览器的加载情况:
可以看到一上来并没有加载 import.js
当点击按钮时,才加载了 import.js 文件,这就说明import导入的文件不会一上来就直接被加载,而是在相关代码被执行时才进行加载的。
一些应用
路由懒加载
在 react 中我们常常使用 lazy 和 Suspense 来实现路由的懒加载,这样做的好处就是初始化时不会一下加载所有的页面,而是当切换到相应页面时才会加载相应的页面,例如:
组件动态导入
对于一些不常用或者不需要直接加载的组件我们也可以采用动态导入,比如弹出框。
我们只需要在点击时进行加载显示即可。
分包优化
这里就简单说一下分包的优化,webpack 默认的分包规则有以下三点:
- 通过配置多个入口 entry,可以将不同的文件分开打包。
- 使用
import()
语法,Webpack 会自动将动态导入的模块放到单独的包中。‘ entry.runtime
单独组织成一个 chunk。
根据第二点,被动态导入的文件会被单独进行打包,不会被分包进同一个文件,也就不会在初始加载 bundle.js 时被一起进行加载。
通过将代码分割成多个小包,可以在用户需要时才加载特定的模块,从而显著减少初始加载的时间。
总结
在进行首屏优化时,可以采取动态导入的方式实现,使用 import('./文件路径')实现,虽然动态导入有一些优化首屏渲染的优势,但是也有一些缺点,比如首次加载延迟,不利于 SEO 优化等,所以在使用动态导入时应该好好进行规划,比如一些不常用的模块或者内容不太复杂,对加载速度无要求的文件可以进行动态导入,这个还是要根据项目的需求来进行使用的。
来源:juejin.cn/post/7400332893158391819
优雅的处理async/await错误
async/await使用
async/await
解决了Promise的链式调用(.then)造成的回调地狱,使异步回调变得像同步一样美观!
使用的方式如下:
// 异步函数1
let postFun1 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { resolve('postFun1') }, 2000)
})
}
// 异步函数2
let postFun2 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { resolve('postFun2') }, 1000)
})
}
// async/await
async function syncFun() {
let s1 = await postFun1()
console.log(s1)
let s2 = await postFun2()
console.log(s2)
console.log('s1、s2都获取到了,我才会执行')
}
syncFun()
可以看出,在syncFun函数中,我们获取异步信息,书写方式就跟同步一样,不用.then套.then,很美观!
不捕获错误会怎样
// 异步函数1
let postFun1 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { resolve('postFun1') }, 2000)
})
}
// 异步函数2
let postFun2 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject('err') }, 1000)
})
}
async function asyncFun() {
let s1 = await postFun1();
let s2 = await postFun2();
console.log('s1、s2都获取到了,我才会执行')
}
asyncFun();
控制台:
可以看出,控制台没有我们想要打印的信息console.log('s1、s2都获取到了,我才会执行')
try/catch捕获错误
我们日常开发中,都是使用try/catch捕获错误,方式如下:
let postFun1 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject('err1') }, 2000)
})
}
let postFun2 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject('err2') }, 1000)
})
}
async function asyncFun() {
try{
let s1 = await postFun1();
let s2 = await postFun2();
}catch(e){
console.log(e)
}
console.log('s1、s2都获取到了,我才会执行')
}
asyncFun();
控制台:
可以看出,我们抛出两个reject,但是只捕获到了一个错误!
那么捕获多个错误,我们就需要多个try/catch如此,代码便像现在这样:
let postFun1 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject('err1') }, 2000)
})
}
let postFun2 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject('err2') }, 1000)
})
}
async function asyncFun() {
try{
let s1 = await postFun1();
}catch(e){
console.log(e)
}
try{
let s2 = await postFun2();
}catch(e){
console.log(e)
}
console.log('s1、s2都获取到了,我才会执行')
}
asyncFun();
控制台:
仅仅是两个try/catch已经看起来很难受了,那么10个呢?
await-to-js
/**
* @param promise 传进去的请求函数
* @param errorExt 拓展错误信息
* @return 返回一个Promise
*/
function to(promise, errorExt) {
return promise
.then(res => [null, res])
.catch(err => {
if (errorExt) {
const parsedError = Object.assign({}, err, errorExt)
return [parsedError, undefined]
}
return [err, undefined]
})
}
await命令后面是一个 Promise 对象,返回该对象的结果。如果不是 Promise 对象,就直接返回对应的值
这里封装了一个
to
函数,接收promise和扩展的错误信息为参数,返回promise
。[err,res]
分别代表错误信息和成功结果,.then()成功时,[null,res]代表错误信息为null
;.catch()失败时,[err,undefined]代表,成功结果为undefined
。我们获取捕获的结果直接从返回的数组中取就行,第一个是失败信息,第二个是成功结果!
完整代码加使用
function to(promise, errorExt) {
return promise
.then(res => [null, res])
.catch(err => {
if (errorExt) {
const parsedError = Object.assign({}, err, errorExt)
return [parsedError, undefined]
}
return [err, undefined]
})
}
let postFun1 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject({err:'err1'}) }, 2000)
})
}
let postFun2 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject({err:'err2'}) }, 1000)
})
}
async function asyncFun() {
let [err1,res1] = await to(postFun1(), {msg:'抱歉1'});
let [err2,res2] = await to(postFun2(), {msg:'抱歉2'});
console.log(err1,err2)
console.log('s1、s2都获取到了,我才会执行')
}
asyncFun()
把这个学会,在面试官面前装一波,面试官定会直呼优雅!!!
来源:juejin.cn/post/7278280824846925861
threejs 搭建智驾自车场景
智能驾驶业务里常用web 3d来可视化地图和传感器数据、显示路径规划结果等方法协助算法调试和仿真,可以用threejs来做,毕竟在国内社区相对活跃,也比较容易上手,效果类似下图:
当然以上图片都是客户端的版本,web3d版本的ui其实并不会这么精致,毕竟只是服务于内部算法和研发。这个专栏纯属作者一时兴起并希望能产出一个麻雀虽小五脏俱全的行泊场景(简称人太闲),本文就先把自车的基础场景搭建起来
本文基于 three^0.167.1 版本
初始化项目
用 Vite 脚手架快速搭一个 react 项目用来调试
pnpm create vite autopilot --template react-ts
把 threejs 官网的例子稍微改下,加到项目里看看。新建一个 renderer 对象如下:
// src/renderer/index.ts
import * as THREE from "three";
class Renderer {
constructor() {
//
}
initialize() {
const container = document.getElementById("my-canvas")!;
const width = container.offsetWidth,
height = container.offsetHeight;
const camera = new THREE.PerspectiveCamera(70, width / height, 0.01, 10);
camera.position.z = 1;
const scene = new THREE.Scene();
const geometry = new THREE.BoxGeometry(0.2, 0.2, 0.2);
const material = new THREE.MeshNormalMaterial();
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setAnimationLoop(animate);
container.appendChild(renderer.domElement);
function animate(time: number) {
mesh.rotation.x = time / 2000;
mesh.rotation.y = time / 1000;
renderer.render(scene, camera);
}
}
}
export const myRenderer = new Renderer();
// App.tsx
import { useEffect } from "react";
import { myRenderer } from "./renderer";
import "./App.css";
function App() {
useEffect(() => {
myRenderer.initialize();
}, []);
return (
<>
<div id="my-canvas"></div>
</>
);
}
export default App;
加载自车
ok,跨出第一步了,接下来整辆自车(egoCar)
“自车”指的是自动驾驶汽车本身,它能够通过搭载的传感器、计算平台和软件系统实现自主导航和行驶
可以上 free3d 下载个免费的车辆模型,里面有很多种格式的,尽量找 gltf/glb 格式的(文件体积小,加载比较快)。
这里以加载 glb 格式的模型为例,可以先把模型文件放到 public 目录下,因为加载器相对网页的根路径(index.html)解析,而 public 目录在打包后会原封不动保存到根目录里
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
const gltfLoader = new GLTFLoader();
class Renderer {
scene = new THREE.Scene();
// ...
loadEgoCar() {
gltfLoader.load("./su7.glb", (gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
this.scene.add(car);
});
}
// ...
initialize() {
// ...
this.loadEgoCar();
}
}
但如果一定要放到 src/assets/models
目录里呢?然后通过import方式引入文件来用,那这么操作下来就会遇到这个报错(You may need to install appropriate plugins to handle the .glb file format, or if it's an asset, add "**/*.glb" to assetsInclude
in your configuration):
怎么解?在 vite.config.ts
文件加入 assetsInclude
。顺带把 vite 指定路径别名 alias 也支持一下
// vite .config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
// 指定路径别名
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
assetsInclude: ["**/*.glb"],
});
node:url 如果提示没有该模块,先安装下@types/node,可能要重启下vscode才能生效
pnpm i @types/node -D
接下来就可以直接用 import 导入 glb 文件来用了
import carModel from "@/assets/models/su7.glb";
class Renderer {
// ...
loadEgoCar() {
gltfLoader.load(carModel, (gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
this.scene.add(car);
});
}
}
OrbitControls
增加 OrbitControls 插件,便于调节自车视角,这个插件除了围绕目标点(默认是原点[0,0,0])旋转视角,还支持缩放(滚轮)和平移(鼠标右键,触摸板的话是双指长按)
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
class Renderer {
initialize() {
// ...
const controls = new OrbitControls(camera, renderer.domElement);
function animate() {
// ...
controls.update();
renderer.render(scene, camera);
}
}
}
光源设置
看起来场景和自车都比较暗,咱们调下光源,加一个环境光 AmbientLight 和平行光 DirectionalLight,平行光位置放自车后上方,沿着自车方向(也就是原点方向)发射光源
// ...
// 没有特定方向,影响整个场景的明暗
const ambient = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambient);
// 平行光
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(60, 80, 40);
scene.add(directionalLight);
地面网格
增加坐标网格,新建一个 Grid 对象,提供一个水平的基准面,便于观察
// ...
// 50表示网格模型的尺寸大小,20表示纵横细分线条数量
const gridHelper = new THREE.GridHelper(50, 20);
scene.add(gridHelper);
// 顺带调高下相机位置
camera.position.set(0, 1, 1.8);
// 设置场景背景色(颜色值,透明度)
renderer.setClearColor(0x000000, 0.85);
道路实现
这里先简单实现一段不规则道路,封装一个 freespace
对象,还要考虑它的不规则和带洞的可能,所以需要做好接口定义,其实数据主要是点集,一般这些点集都是地图上游发下来的,可能是 protobuf 或者 json 的格式
export interface IFreespace {
// 一般可以用于判断元素是否可复用
id: string;
position: IPos;
contour: IPos[];
// 洞可能有多个,所以这里应该设置成二维数组
holes?: IPos[][];
color?: IColor;
}
export interface IPos {
x: number;
y: number;
z?: number;
}
export interface IColor {
r: number;
g: number;
b: number;
a?: number;
}
因为只是一个平面形状,所以可以用 THREE.Shape
来实现,它可以和 ExtrudeGeometry
、ShapeGeometry
一起使用来创建二维形状
// src/renderers/freespace.ts
class Freespace {
scene = new THREE.Scene();
constructor(scene: THREE.Scene) {
this.scene = scene;
}
draw(data: IFreespace) {
const {
contour,
holes = [],
color = { r: 0, g: 0, b: 0 },
position,
} = data;
if (contour.length < 3) {
return;
}
const shape = new THREE.Shape();
// 先绘制轮廓
// 设置起点
shape.moveTo(contour[0].x, contour[0].y);
contour.forEach((item) => shape.lineTo(item.x, item.y));
// 绘制洞
holes.forEach((item) => {
if (item.length < 3) {
return;
}
const path = new THREE.Path();
path.moveTo(item[0].x, item[0].y);
item.forEach((subItem) => {
path.lineTo(subItem.x, subItem.y);
});
// 注意这一步
shape.holes.push(path);
});
const shapeGeometry = new THREE.ShapeGeometry(shape);
const material = new THREE.MeshPhongMaterial();
// 注意:setRGB传参颜色值需要介于0-1之间
material.color.setRGB(color.r / 255, color.g / 255, color.b / 255);
material.opacity = color.a || 1;
const mesh = new THREE.Mesh(shapeGeometry, material);
mesh.position.set(position.x, position.y, position.z || 0);
mesh.rotateX(-Math.PI / 2);
this.scene.add(mesh);
}
}
export default Freespace;
ok先用mock的数据画一段带洞的十字路口,加在 initialize
代码后就行,其实道路上还应该有一些交通标线,后面再加上吧
最后再监听下界面的 resize 事件,使其能根据容器实际大小变化动态调整场景
// ...
constructor() {
// 初始化渲染对象
this.renderers = {
freespace: new Freespace(this.scene),
};
}
initialize() {
// ...
this.loadEgoCar();
this.registerDefaultEvents();
// mock
this.mockData();
}
mockData() {
this.renderers.freespace.draw(freespaceData1);
}
// 监听resize事件
registerDefaultEvents() {
window.addEventListener("resize", this.onResize.bind(this), false);
}
unmountDefaultEvents() {
window.removeEventListener("resize", this.onResize.bind(this), false);
}
onResize() {
const container = document.getElementById("my-canvas")!;
const width = container.offsetWidth,
height = container.offsetHeight;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
}
最后
ok先到这了,主要是先把项目搭起来,后面会继续分享下更多地图和感知元素以及他车、行人、障碍物等效果的实现
来源:juejin.cn/post/7406643531697913867
想学 pinia ?一文就够了
有时候不得不承认,官方的总结有时就是最精简的:
Pinia 是 Vue 的存储库,它允许您跨组件/页面共享状态。
虽然作为Vuex的升级版,但为了尊重原作者,所以取名pinia,而没有取名Vuex,所以大家可以直接将pinia比作为Vue3的Vuex,同时,pinia提供了一种更简洁、更直观的方式来处理应用程序的状态,更为重要的是,pinia的学习成本更低,低到一篇文章就能涵盖pinia的全部。
Pinia的安装与配置:
首先自然是安装pinia,在基于Vue3的项目环境中,提供了npm
与yarn
两种安装方式:
npm install pinia
yarn add pinia
随后,通常就是在src
目录下新建一个专属的store
文件夹,在其中的js
文件中创建并抛出这个仓库。
import { createPinia } from 'pinia' // 引入pinia模块
const store = createPinia() // 创建一个仓库
export default store // 抛出这个仓库
既然把这个仓库抛出了,那么现在便是让它能在全局起作用,于是在Vue的主要应用文件中(通常为main.js),引入使用pinia
。
import { createApp } from 'vue'
import App from './App3.vue'
import store from './store' //引入这个仓库
createApp(App).use(store).mount('#app') // 再use一下
这样一来pinia
仓库就能全局生效了!
Pinia的主要功能:
在官方文档中,Pinia提供了四种功能,分别是:
- Store:在Pinia中,每个状态管理模块都被称为一个Store。开发者需要创建一个Store实例来定义和管理状态。
- State:在Store中定义状态。可以使用defineState函数来定义一个状态,并通过state属性来访问它。
- Getters:类似于Vuex中的getters,用于从State中派生出一些状态。可以使用
defineGetters
函数来定义getters。 - Actions:在Pinia中,Actions用于处理异步操作或执行一些副作用。可以使用
defineActions
函数来定义Actions。
那么接下来我会通过一个具体的实例来表现出这四个功能,如下图:
分别是充当仓库的Store功能。存储子组件User.vue
中数据的State功能。另一个子组件Update-user.vue
中,点击按钮后数据会实现更新,也就是修改State中数据的Actions功能。与无论点击多少次” 经过一年后按钮 ”,页面都会实现同步更新的Getters功能。
State:
简单来说,State的作用就是作为仓库的数据源。
就比如说,我想在仓库的数据源里面放上一个对象来进行使用,那我们只需在先前创建的store
文件夹中再创建一个js
文件,这里我给它起名为user
,然后再其中这样添加对象。
(第一行引入的defineStore
代表defineStore
是store
的一部分。)
import { defineStore } from 'pinia' // defineStore 是 store 的一部分
export const useUserStore = defineStore({
id: 'user',
state: () => ({ // 仓库数据源
userInfo: {
name: '小明',
age: 18,
sex:'boy'
}
})
})
那么现在,我们想使用仓库中的数据就成为了一件非常容易的事。
正如上图,这里有一个父组件App.vue
,两个子组件User.vue
、Update-user.vue
。
父组件不做任何动作,只包含对两个子组件的引用:
<template>
<User/>
<Updateuser/>
</template>
<script setup>
import User from './components/User.vue'
import Updateuser from './components/Update-user.vue'
</script>
<style lang="css" scoped>
</style>
子组件User.vue:
可以看到在这个子组件中,我们通过import { useUserStore } from '@/store/user'
引用仓库,从而获得了仓库中小明姓名、年龄、性别的数据。
由于接下来的Update-user.vue
组件中会添加几个按钮对这些数据进行修改,那么我们就要把这些数据设置成响应式。
正常情况下,store
自带响应性,但如果我们不想每次都写userStore.userInfo.name
这么长一大串,就可以尝试将这些值取出来赋给其他变量:
这里有两种方法,第一种是引入computed
模块,如第14行年龄的修改。另一种是引入storeToRefs
模块,这是一种属于Pinia
仓库的模块,将整个userInfo
变成响应式。
于是接下来,就轮到我们的Actions登场了
<template>
<ul>
<li>姓名:{{ userStore.userInfo.name }}</li>
<li>年龄:{{ age }}</li>
<li>性别;{{ userInfo.sex }}</li>
</ul>
</template>
<script setup>
import { useUserStore } from '@/store/user'
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const age = computed(() => userStore.userInfo.age) // 1. 计算属性使响应式能生效
const { userInfo } = storeToRefs(userStore) // 2. 专门包裹仓库中函数用来返回对象
</script>
<style lang="scss" scoped>
</style>
Actions:
简单来说,Actions的作用就是专门用来修改State,如果你想要修改仓库中的响应式元素,只需要进行两步操作:
第一步:在user.js
也就是我们的仓库中添加actions
,专门设置函数用来修改state对象中的值。例如changeUserName
作用是修改姓名, changeUserSex
作用是修改性别。
import { defineStore } from 'pinia' // defineStore 是 store的一部分
export const useUserStore = defineStore({
id: 'user',
state: () => ({ // 仓库数据源
userInfo: {
name: '小明',
age: 18,
sex:'boy'
}
}),
actions: { // 专门用来修改state
changeUserName(name) {
this.userInfo.name = name
},
changeUserSex(sex){
this.userInfo.sex = sex
}
}
})
子组件Update-user.vue:
第二步,在控制按钮的组件Update-user.vue
中触发这两个函数,就如第10与14行的两个箭头函数。
<template>
<button @click="changeName">修改仓库中用户姓名</button>
<button @click="changeSex">修改仓库中用户性别</button>
</template>
<script setup>
import { useUserStore } from '@/store/user' // 引入Pinia仓库
const userStore = useUserStore() // 声明仓库
const changeName = () => { // 触发提供的函数
userStore.changeUserName('小红')
}
const changeSex = () => {
userStore.changeUserSex('gril')
}
</script>
<style lang="css" scoped>
</style>
这样一来,依赖于Actions,我们就成功完成了响应式修改仓库中数据的功能,也就是前两个按钮的功能!
Getters:
简单来说Getters就是仓库中的计算属性。
现在我们来实现第三个按钮功能,首先就是在User.vue
组件中第5行,添加 “ 十年之后年龄 ” 一栏:
<template>
<ul>
<li>姓名:{{userStore.userInfo.name}}</li>
<li>年龄:{{ age }}</li>
<li>十年后年龄:{{ userStore.afterAge }}</li> // 添加的栏
<li>性别:{{ userInfo.sex }}</li>
</ul>
</template>
<script setup>
import { useUserStore } from '@/store/user'
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const age = computed(() => userStore.userInfo.age)
const { userInfo } = storeToRefs(userStore)
</script>
<style lang="scss" scoped>
</style>
那么现在你一定能注意到这一栏其中的userStore.afterAge
,这正是我们将在getters中返回的值。
那么关于getters,具体的使用方法就是继续在user.js
中添加进getters,我们在其中打造了一个afterAge
函数来返回userStore.afterAge
,正如第25行。
import { defineStore } from 'pinia' // defineStore 是 store的一部分
export const useUserStore = defineStore({
id: 'user',
state: () => ({ // 仓库数据源
userInfo: {
name: '小明',
age: 18,
sex:'boy'
}
}),
actions: { // 专门用来修改state
changeUserName(name) {
this.userInfo.name = name
},
changeUserSex(sex){
this.userInfo.sex = sex
},
changeUserAge(age){ // 新添加的一年后年龄计算方法
this.userInfo.age += age
}
},
getters: { // 仓库中的计算属性,所依赖的值改变会重新执行
afterAge(state) {
return state.userInfo.age + 10
}
}
})
准备工作完毕,现在就该在页面上添加这个按钮,于是在组件Update-user.vue
添加上按钮与执行函数。
<button @click="changeAge">经过一年后</button>
const changeAge = () => {
userStore.changeUserAge(1)
}
有了这些之后,这个项目的功能便彻底完善,无论点击多少次“ 经过一年后 ”按钮,在页面上显示的值都是正确且实时更新的,这就是Getters的功劳!
补充:数据持久化
关于整个项目的功能实现确实已经结束,但人的贪心却是不得满足的,如果我们想要在原有的基础上实现网页刷新数据却不刷新,也就是说数据的持久化,那又该怎么办呢?
很简单,也就是堪堪三步,便能实现。
第一步:安装persist
插件。
npm i pinia-plugin-persist
第二步:在store
的js
文件中引入这个插件。
import { createPinia } from 'pinia'
import piniaPluginPersist from 'pinia-plugin-persist' //引入插件
const store = createPinia()
store.use(piniaPluginPersist) // 使用插件
export default store
第三步:在我们前文user.js
的defineStore
库内继续添加上persist
功能。
persist: { // 持久化
enabled: true,
strategies: [ // 里面填想要持久化的数据
{
paths: ['userInfo'], // 指明持久化的数据
storage: localStorage // 指明存储
}
]
}
现在可以看到点击按钮后的数据都被存储到浏览器的存储空间中,无论多少次刷新都不会被重置!
最后:
至此,这样一个简简单单的项目,却解释清楚了Pinia功能的核心,读完这篇文章,相信每一个学习Pinia的人都能有所收获。
来源:juejin.cn/post/7407407711879807026
你知道为什么template中不用加.value吗?
Vue3 中定义的ref
类型的变量,在setup
中使用这些变量是需要带上.value
才可以访问,但是在template
中却可以直接使用。
询其原因,可能会说 Vue 自动进行ref
解包了,那具体如何实现的呢?
proxyRefs
Vue3 中有有个方法proxyRefs
,这属于底层 API 方法,在官方文档中并没有阐述,但是 Vue 里是可以导出这个方法。
例如:
<script setup>
import { onMounted, proxyRefs, ref } from "vue";
const user = {
name: "wendZzoo",
age: ref(18),
};
const _user = proxyRefs(user);
onMounted(() => {
console.log(_user.name);
console.log(_user.age);
console.log(user.age);
});
</script>
上面代码定义了一个普通对象user
,其中age
属性的值是ref
类型。当访问age
值的时候,需要通过user.age.value
,而使用了proxyRefs
,可以直接通过user.age
来访问。
这也就是为何template
中不用加.value
的原因,Vue3 源码中使用proxyRefs
方法将setup
返回的对象进行处理。
实现proxyRefs
单测
it("proxyRefs", () => {
const user = {
name: "jack",
age: ref(10),
};
const proxyUser = proxyRefs(user);
expect(user.age.value).toBe(10);
expect(proxyUser.age).toBe(10);
proxyUser.age = 20;
expect(proxyUser.age).toBe(20);
expect(user.age.value).toBe(20);
proxyUser.age = ref(30);
expect(proxyUser.age).toBe(30);
expect(user.age.value).toBe(30);
});
定义一个age
属性值为ref
类型的普通对象user
。proxyRefs
方法需要满足:
proxyUser
直接访问age
是可以直接获取到 10 。- 当修改
proxyUser
的age
值切这个值不是ref
类型时,proxyUser
和原数据user
都会被修改。 age
值被修改为ref
类型时,proxyUser
和user
也会都更新。
实现
既然是访问和修改对象内部的属性值,就可以使用Proxy
来处理get
和set
。先来实现get
export function proxyRefs(objectWithRefs) {
return new Proxy(objectWithRefs, {
get(target, key) {}
});
}
需要实现的是proxyUser.age
能直接获取到数据,那原数据target[key]
是ref
类型,只需要将ref.value
转成value
。
使用unref
即可实现,unref
的实现参见本专栏上篇文章,文章地址:mp.weixin.qq.com/s/lLkjpK9TG…
get(target, key) {
return unref(Reflect.get(target, key));
}
实现set
export function proxyRefs(objectWithRefs) {
return new Proxy(objectWithRefs, {
get(target, key) {
return unref(Reflect.get(target, key));
},
set(target, key, value) {},
});
}
从单侧中可以看出,我们是测试了两种情况,一种是修改proxyUser
的age
为ref
类型, 一种是修改成不是ref
类型的,但是结果都是同步更新proxyUser
和user
。那实现上也需要考虑这两种情况,需要判断原数据值是不是ref
类型,新赋的值是不是ref
类型。
使用isRef
可以判断是否为ref
类型,isRef
的实现参见本专栏上篇文章,文章地址:mp.weixin.qq.com/s/lLkjpK9TG…
set(target, key, value) {
if (isRef(target[key]) && !isRef(value)) {
return (target[key].value = value);
} else {
return Reflect.set(target, key, value);
}
}
当原数据值是ref
类型且新赋的值不是ref
类型,也就是单测中第 1 个情况赋值为 10,将ref
类型的原值赋值为value
,ref
类型值需要.value
访问;否则,也就是单测中第 2 个情况,赋值为ref(30)
,就不需要额外处理,直接赋值即可。
验证
执行单测yarn test ref
来源:juejin.cn/post/7303435124527333416
将html转化成图片
如何将指定html内容转化成图片保存?这个问题很值得深思,实际应用中也很有价值。最直接的想法就是使用
canvas
,熟悉canvas
的同学可以尝试一下。这里不做太多的说明,本文采用html2canvas
库来实现。
html2canvas
库的使用非常简单,只需要引入html2canvas
库,然后调用html2canvas
方法即可,官方地址。
接下来说一下简单的使用,以react
项目为例。
获取整个页面截图,可以使用底层IDroot
,这样下载的就是root
下的所有元素。
import html2canvas from "html2canvas";
const saveCanvas = () => {
// 画布基础元素,要绘制的元素
const canvas: any = document.getElementById("root");
const options: any = { scale: 1, useCORS: true };
html2canvas(canvas, options).then((canvas) => {
const type = "png";
// 返回值是一个数据url,是base64组成的图片的源数据
let imgDt = canvas.toDataURL(type);
let fileName = "img" + "." + type;
// 保存为文件
let a = document.createElement("a");
document.body.appendChild(a);
a.href = imgDt;
a.download = fileName;
a.click();
});
};
图片的默认背景色是#ffffff
,如果想要透明色可设置为null
,比如设置为红色。
const options: any = { scale: 1, useCORS: true, backgroundColor: "red" };
正常情况下网络图片是无法渲染的,可以使用useCORS
属性,设置为true
即可。
const options: any = { scale: 1, useCORS: true };
保存某块元素的截图
const canvas: any = document.getElementById("swiper");
如果希望将某些元素排除,可以将data-html2canvas-ignore
属性添加到这些元素中,html2canvas
将从渲染中排除这些元素。
<Button
data-html2canvas-ignore
color="primary"
fill="solid"
onClick={saveCanvas}
>
download
</Button>
完整代码
npm install html2canvas
// demo.less
.contentSwiper {
width: 710px;
height: 375px;
color: #ffffff;
display: flex;
justify-content: center;
align-items: center;
font-size: 48px;
user-select: none;
}
.swiper {
padding: 0 20px;
}
import React from "react";
import { Button, Space, Swiper } from "antd-mobile";
import html2canvas from "html2canvas";
import styles from "./demo.less";
export default () => {
const saveCanvas = () => {
// 画布基础元素,要绘制的元素
const canvas: any = document.getElementById("root");
const options: any = { scale: 1, useCORS: true, backgroundColor: "red"
};
html2canvas(canvas, options).then((canvas) => {
const type = "png";
// 返回值是一个数据url,是base64组成的图片的源数据
let imgDt = canvas.toDataURL(type);
let fileName = "img" + "." + type;
// 保存为文件
let a = document.createElement("a");
document.body.appendChild(a);
a.href = imgDt;
a.download = fileName;
a.click();
});
};
const colors: string[] = ["#ace0ff", "#bcffbd", "#e4fabd", "#ffcfac"];
const items = colors.map((color, index) => (
<Swiper.Item key={index}>
<div className={styles.contentSwiper} style={{ background: color }}>
{index + 1}
</div>
</Swiper.Item>
));
return (
<div className="content">
<div id="swiper" className={styles.swiper}>
<Swiper
style={{
"--track-padding": " 0 0 16px",
}}
defaultIndex={1}
>
{items}
</Swiper>
</div>
<div>
<img
width={200}
src="https://t7.baidu.com/it/u=2621658848,3952322712&fm=193&f=GIF"
/>
</div>
<Space>
<Button
data-html2canvas-ignore
color="primary"
fill="solid"
onClick={saveCanvas}
>
download
</Button>
<Button color="primary" fill="solid">
Solid
</Button>
<Button color="primary" fill="outline">
Outline
</Button>
<Button color="primary" fill="none">
</Button>
</Space>
</div>
);
};
来源:juejin.cn/post/7407457177483608118
Vue3中watch好用,但watchEffect、watchSyncEffect、watchPostEffect简洁
比较好奇vue项目中使用watch还是watchEffect居多,查看了element-plus、ant-design-vue两个UI库, 整体上看,watch使用居多,而watchEffect不怎么受待见,那这两者之间有什么关系?
API | watch | watchEffect | watchSyncEffect | watchPostEffect |
---|---|---|---|---|
element-plus | 198 | 28 | 0 | 0 |
ant-design-vue | 263 | 168 | 0 | 0 |
watchEffect是watch的衍生
为什么说watchEffect是watch的衍生?
- 首先,两者提供功能是有重叠。大部分监听场景,两者都能满足。
const list = ref([]);
const count = ref(0);
watch(
list,
(newValue) => {
count.value = newValue.length;
}
)
watchEffect(() => {
count.value = list.value.length;
})
- 其次,源码上两者也都是同一出处。以下是两者的函数定义:
export function watch(
source: T | WatchSource,
cb: any,
options?: WatchOptions,
): WatchStopHandle {
return doWatch(source as any, cb, options)
}
export function watchEffect(
effect: WatchEffect,
options?: WatchOptionsBase,
): WatchStopHandle {
return doWatch(effect, null, options)
}
两者内部都调用doWatch函数,并且返回都是WatchStopHandle类型。唯独入参上有比较大的区别,watch的source参数就像大杂烩,支持PlainObject、Ref、ComputedRef以及函数类型;而watchEffect的effect参数仅仅是一个函数类型。
watch早于watchEffect诞生,watch源代码有这样一句提示:
if (__DEV__ && !isFunction(cb)) {
warn(
`\`watch(fn, options?)\` signature has been moved to a separate API. ` +
`Use \`watchEffect(fn, options?)\` instead. \`watch\` now only ` +
`supports \`watch(source, cb, options?) signature.`,
)
}
也就是说历史的某一个版本,watch也是支持watch(fn, options?)
用法,但为了降低API复杂度,将这部分功能迁移至watchEffect函数。一个优秀框架的发展历程也不过如此,都是在不断的重构升级。
话又说回来,到目前,为什么大部分Vue开发者更偏向于使用watch,而不是watchEffect?
,带着这个问题,庖丁解牛式层层分析。
watch、watchEffect底层逻辑
当我们把watch、watchEffect底层逻辑看透,剩下的watchSyncEffect、watchPostEffect也就自然了解。
先回顾下watch、watchEffect内部调用doWatch的参数:
// watch
doWatch(source as any, cb, options)
// demo
watch(
list,
(newValue) => {
count.value = newValue.length;
}
)
// watchEffect
doWatch(effect, null, options)
// demo
watchEffect(() => {
count.value = list.value.length;
})
入参的区别,如下表所示:
API | arg1 | arg2 | arg3 |
---|---|---|---|
watch | T | WatchSource | cb | WatchOptions |
watchEffect | WatchEffect | null | WatchOptionsBase |
根据参数对比,先抛出两个问题:
1. doWatch为什么能自动监听WatchEffect函数内的数据变更,并且能重新执行?
2. 第三个参数WatchOptions
watchOptions
export interface WatchOptions<Immediate = boolean> extends WatchOptionsBase {
immediate?: Immediate
deep?: boolean
once?: boolean
}
export interface WatchOptionsBase extends DebuggerOptions {
flush?: 'pre' | 'post' | 'sync'
}
WatchOptionsBase仅提供了flush,因此watchEffect函数的第三个参数也只有flush一个选项。
flush包含pre
、post
、sync
三个值,缺省为pre。它明确了监听器的触发时机,pre和post比较明确,对应渲染前、渲染后。
sync官方定义为:在某些特殊情况下 (例如要使缓存失效),可能有必要在响应式依赖发生改变时立即触发侦听器
。简而言之,依赖的多个变量,只要其中一个有更新,监听器就会触发一次。
const list = ref([]);
const page = ref(1);
const message = ref('');
watchEffect(() => {
message.value = `总量${list.value.length}, 当前页:${page.value}`
console.log(message.value);
}, { flush: 'sync' })
例如上述的list、page任意一个有更新,则会输出一次console。sync模式得慎重使用,例如监听的是数组,其中一项有更新都会触发监听器,可能带来不可预知的性能问题。
post
也有明确的应用场景,例如:当页面侧边栏显示或隐藏后,需要容器渲染完成后再更新内部的图表等元素。不使用flush选项的解法,一般是监听visible变化并使用setTimeout延迟更新。有了post
,一个属性即可搞定。
watch(visible, (value) => {
setTimeout(() => {
// 更新容器内图表
}, 1000);
})
watch(visible, (value) => {
// 更新容器内图表
}, { flush: 'post' })
完成了第二个问题的解答, 要回答第一个问题,需要深入doWatch函数, 在上一篇《写Vue大篇幅的ref、computed,而reactive为何少见?》也有对doWatch做局部介绍,可以作为辅助参考。
doWatch源码
先从doWatch函数签名上,对其有概括性的认识:
function doWatch(
source: WatchSource | WatchSource[] | WatchEffect | object,
cb: WatchCallback | null,
{
immediate,
deep,
flush,
once,
onTrack,
onTrigger,
}: WatchOptions = EMPTY_OBJ,
): WatchStopHandle
由于我们主要目的是回答问题:doWatch为什么能自动监听WatchEffect函数内的数据变更,并且能重新执行?
因此仅分析source为WatchEffect
的情况,此时,cb为null, 第三个参数仅有flush选项。
WatchEffect
类型定义如下:
export type WatchEffect = (onCleanup: OnCleanup) => void
onCleanup
参数的作用是,在下一次监听器执行前被触发,通常用于状态清理。
doWatch函数实现,最核心的片段是ReactiveEffect的生成:
const effect = new ReactiveEffect(getter, NOOP, scheduler)
为什么ReactiveEffect是其核心?因为它起到了"中介"的作用,在监听器函数内,每一个可监听
的变量都对应有依赖项集合deps,当调用这些变量的getter时,ReactiveEffect会把自身注入到依赖集合deps中,这样每当执行变量的setter时,deps集合中的副作用都会触发,而每个副作用effect内部会调用scheduler, scheduler可理解为调度器,负责处理视图更新时机,scheduler内部选择合适的时机触发监听器。
接下来着重看getter、scheduler定义,当source为WatchEffect
类型时,getter定义片段如下:
// no cb -> simple effect
getter = () => {
if (cleanup) {
cleanup()
}
return callWithAsyncErrorHandling(
source,
instance,
ErrorCodes.WATCH_CALLBACK,
[onCleanup],
)
}
首先执行cleanup,也就是说如果参数传入有onCleanup回调,那么每次在获取新值前都会触发onCleanup。其次是return语句,调用callWithAsyncErrorHandling函数,从函数可探察之,一方面支持异步,另一方面处理异常错误。
支持异步:也就是我们传入的监听器可以是一个异步函数,那么我们可以在其中执行远程请求的调用,例如官方给的示例, 当id.value值变化,从远端请求数据await response
,并赋值给data.value。
watchEffect(async (onCleanup) => {
const { response, cancel } = doAsyncWork(id.value)
// `cancel` 会在 `id` 更改时调用
// 以便取消之前未完成的请求
onCleanup(cancel)
data.value = await response
})
上述示例中,如果id.value频繁更新,则会导致触发多次远端请求,要解决该问题,可调用onCleanup(cancel)
,将cancel传入到doWatch内部,并且每次执行cleanup
时被调用。onCleanup定义如下:
let cleanup: (() => void) | undefined
let onCleanup: OnCleanup = (fn: () => void) => {
cleanup = effect.onStop = () => {
callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP)
cleanup = effect.onStop = undefined
}
}
其中,fn即为上述示例中的cancel,这样就建立了cancel和cleanup的关联,因此每次更新前,先调用cancel中断上一次请求。
callWithAsyncErrorHandling
函数定义如下:
export function callWithAsyncErrorHandling(fn,instance,type,args?): any {
...
const res = callWithErrorHandling(fn, instance, type, args)
if (res && isPromise(res)) {
res.catch(err => {
handleError(err, instance, type)
})
}
return res
...
}
res为fn函数执行结果,由于支持同步、异步。如果fn为异步函数,那么res为Promise类型,并且对异常做了兜底处理。
当fn函数执行后,内部所有可监听变量的deps都会添加上当前effect,所以只要变量有更新,effect的scheduler就被触发。
watchEffect官方定义有:立即运行一个函数,同时响应式地追踪其依赖,并在依赖更改时重新执行。“立即运行一个函数”如何体现?
doWatch
函数的最后几行代码如下:
if (flush === 'post') {
queuePostRenderEffect(
effect.run.bind(effect),
instance && instance.suspense,
)
} else {
effect.run()
}
如果flush不为post
,那么立即执行effect.run()
, 而run函数会调用getter,因此会立即运行监听器函数一次;如果flush为post
,那么effect将会在vue下一次渲染前第一次执行effect.run()
。
至此,我们就分析完watchEffect
的底层逻辑,总结其特点:立即执行,支持异步,并且会自动监听变量更新。
为什么不能两者取一,而必须共存
再次回顾watch的定义:
export function watch(
source: T | WatchSource,
cb: any,
options?: WatchOptions,
) : WatchStopHandle {
return doWatch(source as any, cb, options)
}
其中WatchOptions包含的选项有:immediate、deep、once、flush。如果是watchEffect,选项仅有flush,并且immediate相当于true,剩下的deep、once不支持配置。
先说watchEffect的缺点:
- 不支持immediate为false,必须是立即执行。例如下面的代码,由于autoplay默认false,初始化时不需要立即执行。如果是watchEffect,则pauseTimer初始化会执行一次,完全没必要。
watch(
() => props.autoplay,
(autoplay) => {
autoplay ? startTimer() : pauseTimer()
}
)
- 不支持deep为true的场景,只能见监听当前使用的属性。但如果是调用
watch(source, cb, { deep: true })
, 则会通过traverse(source)
将source所有深度属性读取一次,和effect建立关联,达到自动监听所有属性的目的。 - 异步使用有坑,
watchEffect
仅会在其同步执行期间,才追踪依赖。在使用异步回调时,只有在第一个await
正常工作前访问到的属性才会被追踪。
再说watchEffect优点:
优点也是非常明显,写法非常简洁,无需显式声明监听哪些变量,一个回调函数搞定,并且默认为立即执行,我认为能满足开发中80%的应用场景。另一方面,由于只监听回调中使用的属性,相比于deep为true的一锅端方式,watchEffect则更加直观明了。
总结
watchSyncEffect、watchPostEffect和watchEffect唯一的区别是:flush分别固定为sync
、post
。所以,watchEffect为watch的衍生,而watchSyncEffect、watchPostEffect为watchEffect的衍生。
对于开发使用上:
- watchPostEffect、watchSyncEffect仅在极少数的特殊场景下才使用,完全可以用watchEffect(fn, { flush: 'sync' | 'post' })代替,多了反而对入门开发者来说是徒增干扰。
- 个人认为应优先使用watchEffect函数,毕竟代码写法上更加简洁,属性依赖上也更加明确。满足不了的场景,再考虑使用watch。
来源:juejin.cn/post/7401415643981185078
vue3为啥推荐使用ref而不是reactive
在 Vue 3 中,ref
和 reactive
都是用于声明响应式状态的工具,但它们的使用场景和内部工作机制有所不同。Vue 3 推荐使用 ref
而不是 reactive
的原因主要涉及到以下几个方面:
- 简单的原始值响应式处理:
ref
更适合处理简单的原始值(如字符串、数字、布尔值等),而reactive
更适合处理复杂的对象或数组。
- 一致性和解构:
- 使用
ref
时,解构不会丢失响应性,因为ref
会返回一个包含.value
属性的对象。而reactive
对象在解构时会丢失响应性。
- 使用
- 类型推导和代码提示:
ref
更容易与 TypeScript 配合使用,提供更好的类型推导和代码提示。
示例代码
以下是一个详细的代码示例,演示为什么在某些情况下推荐使用 ref
而不是 reactive
。
使用 ref
的示例
import { ref } from 'vue';
export default {
setup() {
// 使用 ref 声明响应式状态
const count = ref(0);
function increment() {
count.value++;
}
return {
count,
increment
};
}
};
使用 reactive
的示例
import { reactive } from 'vue';
export default {
setup() {
// 使用 reactive 声明响应式状态
const state = reactive({
count: 0
});
function increment() {
state.count++;
}
return {
state,
increment
};
}
};
解构问题
使用 ref
解构
import { ref } from 'vue';
export default {
setup() {
const count = ref(0);
function increment() {
count.value++;
}
// 解构时不会丢失响应性
const { value: countValue } = count;
return {
countValue,
increment
};
}
};
使用 reactive
解构
import { reactive } from 'vue';
export default {
setup() {
const state = reactive({
count: 0
});
function increment() {
state.count++;
}
// 解构时会丢失响应性
const { count } = state;
return {
count,
increment
};
}
};
代码解释
- 使用
ref
:
ref
返回一个包含.value
属性的对象,因此在模板中使用时需要通过.value
访问实际值。- 解构时,可以直接解构
.value
属性,不会丢失响应性。
- 使用
reactive
:
reactive
适用于复杂的对象或数组,返回一个代理对象。- 直接解构
reactive
对象的属性会丢失响应性,因为解构后得到的属性是原始值,不再是响应式的。
总结
- 简单值:对于简单的原始值(如字符串、数字、布尔值等),推荐使用
ref
,因为它更简洁,并且在解构时不会丢失响应性。 - 复杂对象:对于复杂的对象或数组,推荐使用
reactive
,因为它可以更方便地处理嵌套属性的响应性。 - 一致性:
ref
在解构时不会丢失响应性,而reactive
在解构时会丢失响应性,这使得ref
在某些情况下更为可靠。
通过理解 ref
和 reactive
的不同使用场景和内部工作机制,可以更好地选择适合的工具来管理 Vue 3 应用中的响应式状态。
来源:juejin.cn/post/7402869746175393807
Node拒绝当咸鱼,Node 22大进步
这几年,deno和bun风头正盛,大有你方唱罢我登场的态势,deno和bun的每一次更新版本,Node都会被拿来比较,比较结果总是Node落后了。
这种比较是不是非常熟悉,就像卖手机的跟iPhone比,卖汽车的跟特斯拉比,比较的时候有时候还得来个「比一分钱硬币还薄」的套路。
Node虽然没有落后了,但是确实有点压力了,所以20和22版本都大跨步前进,拒绝当咸鱼了。
因为Node官网对22版本特性的介绍太过简单,所以我决定来一篇详细介绍新特性的文章,让学习Node的朋友们知道,Node现在在第几层。
首先我把新特性分为两类,分别是:开发者可能直接用到的特性、开发者相对无感知的底层更新。本文重点介绍前者,简单介绍后者。先来一个概览:
开发者可能直接用到的特性:
- 支持通过
require()
引入ESM - 运行
package.json
中的脚本 - 监视模式(
--watch
)稳定化 - 内置 WebSocket 客户端
- 增加流的默认高水位线
- 文件模式匹配功能
开发者相对无感知的底层更新:
- V8 引擎升级至 12.4 版本
- Maglev 编译器默认启用
- 改进
AbortSignal
的创建性能
接下来开始介绍。
支持通过 require()
导入 ESM
以前,我们认为 CommonJS 与 ESM 是分离的。
例如,在 CommonJS里,我们用并使用 module.exports
导出模块,用 require()
导入模块:
// CommonJS
// math.js
function add(a, b) {
return a + b;
}
module.exports.add = add;
// useMath.js
const math = require('./math');
console.log(math.add(2, 3));
在 ECMAScript Modules (ESM) **** 里,我们使用 export
导出模块,用 import
导入模块:
// ESM
// math.mjs
export function add(a, b) {
return a + b;
}
// useMath.js
import { add } from './math.mjs';
console.log(add(2, 3));
Node 22 支持新的方式——用 require()
导入 ESM:
// Node 22
// math.mjs
export function add(a, b) {
return a + b;
}
// useMath.js
const { add } = require('./mathModule.mjs');
console.log(add(2, 3));
这么设计的原因是为了给大型项目和遗留系统提供一个平滑过渡的方案,因为这类项目难以快速全部迁移到 ESM,通过允许 require()
导入 ESM,开发者就可以逐个模块迁移,而不是一次性对整个项目进行修改。
目前这种写法还是实验性功能,所以使用是有“门槛”的:
- 启动命令需要添加
-experimental-require-module
参数,如:node --experimental-require-module app.js
- 模块标记:确保 ESM 模块通过
package.json
中的"type": "module"
或文件扩展名是.mjs
。 - 完全同步:只有完全同步的ESM才能被
require()
导入,任何含有顶级await
的ESM都不能使用这种方式加载。
运行package.json
中的脚本
假设我们的 package.json
里有一个脚本:
"scripts": {
"test": "jest"
}
在此之前,我们必须依赖 npm 或者 yanr 这样的包管理器来执行命令,比如:npm run test
。
Node 22 添加了一个新命令行标志 --run
,允许直接从命令行执行 package.json
中定义的脚本,可以直接使用 node --run test
这样的命令来运行脚本。
刚开始我还疑惑这是不是脱裤子放屁的行为,因为有 node 的地方一般都有 npm,我要这 node —run
有何用?
后来思考了一下,主要原因应该还是统一运行环境和提升性能。不同的包管理器在处理脚本时可能会有微小的差异,Node 提供一个标准化的方式执行脚本,有助于统一这些行为;而且直接使用 node 执行脚本要比通过 npm 执行脚本更快,因为绕过了 npm 这个中间层。
监视模式(--watch
)稳定化
在 19 版本里,Node 引入了 —watch
指令,用于监视文件系统的变动,并自动重启。22 版本开始,这个指令成为稳定功能了。
要启用监视模式,只需要在启动 Node 应用时加上 --watch
****参数。例如:
node --watch app.js
正在用 nodemon 做自动重启的朋友们可以正式转战 --watch
了~
内置 WebSocket 客户端
以前,要用 Node 开发一个 socket 服务,必须使用 ws、socket.io 这样的第三方库来实现。第三方库虽然稳如老狗帮助开发者许多年,但是终究是有点不方便。
Node 22 正式内置了 WebSocket,并且属于稳定功能,不再需要 -experimental-websocket
来启用了。
除此之外,WebScoket 的实现还遵循了浏览器中 WebSocket API 的标准,这意味着在 Node 中使用 WebSocket 的方式将与在 JavaScript 中使用 WebSocket 的方式非常相似,有助于减少学习成本并提高代码的一致性。
用法示例:
const socket = new WebSocket("ws://localhost:8080");
socket.addEventListener("open", (event) => {
socket.send("Hello Server!");
});
增加流(streams)的默认高水位线(High Water Mark)
streams 在 Node 中有举足轻重的作用,读写数据都得要 streams 来完成。而 streams 可以设置 highWaterMark
参数,用于表示缓冲区的大小。highWaterMark
越大,缓冲区越大,占用内存越多,I/O 操作就减少,highWaterMark
越小,其他信息也对应相反。
用法如下:
const fs = require('fs');
const readStream = fs.createReadStream('example-large-file.txt', {
highWaterMark: 1024 * 1024 // 设置高水位线为1MB
});
readStream.on('data', (chunk) => {
console.log(`Received chunk of size: ${chunk.length}`);
});
readStream.on('end', () => {
console.log('End of file has been reached.');
});
虽然 highWaterMark
是可配置的,但通常情况下,我们是使用默认值。在以前的版本里,highWaterMark
的默认值是 16k,Node 22 版本开始,默认值被提升到 64k 了。
文件模式匹配——glob 和 globSync
Node 22 版本在 fs 模块中新增了 glob
和 globSync
函数,它们用于根据指定模式匹配文件路径。
文件模式匹配允许开发者定义一个匹配模式,以找出符合特定规则的文件路径集合。模式定义通常包括通配符,如 *
(匹配任何字符)和 ?
(匹配单个字符),以及其他特定的模式字符。
glob 函数(异步)
glob
函数是一个异步的函数,它不会阻塞 Node.js 的事件循环。这意味着它在搜索文件时不会停止其他代码的执行。glob
函数的基本用法如下:
const { glob } = require('fs');
glob('**/*.js', (err, files) => {
if (err) {
throw err;
}
console.log(files); // 输出所有匹配的.js文件路径
});
在这个示例中,glob
函数用来查找所有子目录中以 .js
结尾的文件。它接受两个参数:
- 第一个参数是一个字符串,表示文件匹配模式。
- 第二个参数是一个回调函数,当文件搜索完成后,这个函数会被调用。如果搜索成功,
err
将为null
,而files
将包含一个包含所有匹配文件路径的数组。
globSync 函数(同步)
globSync
是 glob
的同步版本,它会阻塞事件循环,直到所有匹配的文件都被找到。这使得代码更简单,但在处理大量文件或在需要高响应性的应用中可能会导致性能问题。其基本用法如下:
const { globSync } = require('fs');
const files = globSync('**/*.js');
console.log(files); // 同样输出所有匹配的.js文件路径
这个函数直接返回匹配的文件数组,适用于脚本和简单的应用,其中执行速度不是主要关注点。
使用场景
这两个函数适用于:
- 自动化构建过程,如自动寻找和处理项目中的 JavaScript 文件。
- 开发工具和脚本,需要对项目目录中的文件进行批量操作。
- 任何需要从大量文件中快速筛选出符合特定模式的文件集的应用。
V8 引擎升级至 12.4 版本
从这一节开始,我们了解一下开发者相对无感知的底层更新,第一个就是 V8 引擎升级到 12.4 版本了,有了以下特性升级:
- WebAssembly 垃圾回收:这一特性将改善 WebAssembly 在内存管理方面的能力。
- Array.fromAsync:这个新方法允许从异步迭代器创建数组。
- Set 方法和迭代器帮助程序:提供了更多内建的Set操作和迭代器操作的方法,增强了数据结构的操作性和灵活性。
Maglev 编译器默认启用
Maglev 是 V8 的新编译器,现在在支持的架构上默认启用。它主要针对短生命周期的命令行程序(CLI程序)性能进行优化,通过改进JIT(即时编译)的效率来提升性能。这对开发者编写的工具和脚本将带来明显的速度提升。
改进AbortSignal
的创建性能
在这次更新中,Node 提高了 AbortSignal
实例的创建效率。AbortSignal
是用于中断正在进行的操作(如网络请求或任何长时间运行的异步任务)的一种机制。通过提升这一过程的效率,可以加快任何依赖这一功能的应用,如使用 fetch
进行HTTP请求或在测试运行器中处理中断的场景。
AbortSignal
的工作方式是通过 AbortController
实例来管理。AbortController
提供一个 signal
属性和一个 abort()
方法。signal
属性返回一个 AbortSignal
对象,可以传递给任何接受 AbortSignal
的API(如fetch
)来监听取消事件。当调用abort()
方法时,与该控制器关联的所有操作将被取消。
const controller = new AbortController();
const signal = controller.signal;
fetch(url, { signal })
.then(response => response.json())
.catch(err => {
if (err.name === 'AbortError') {
console.log('Fetch aborted');
} else {
console.error('Fetch error:', err);
}
});
// 取消请求
controller.abort();
总结
最后,我只替 Node 说一句:Node 没有这么容易被 deno 和 bun 打败~
来源:juejin.cn/post/7366185272768036883
用了这么久Vue,你用过这几个内置指令提升性能吗?
前言
Vue
的内置指令估计大家都用过不少,例如v-for
、v-if
之类的就是最常用的内置指令,但今天给大家介绍几个平时用的比较少的内置指令。毕竟这几个Vue
内置指令可用可不用,不用的时候系统正常跑,但在对的地方用了却能提升系统性能,下面将结合示例进行详细说明。
一、v-once
作用:在标签上使用v-once
能使元素或者表达式只渲染一次。首次渲染之后,后面数据再发生变化时使用了v-once
的地方都不会更新,因此用在数据不需要变化的地方就能进行性能优化。
v-once
指令实现原理: Vue
组件初始化时会标记上v-once
,首次渲染会正常执行,后续再次渲染时如果看到有v-once
标记则跳过二次渲染。
示例代码: 直接作用在标签上,可以是普通标签也可以是图片标签,当2S
后数据变化时标签上的值不会重新渲染更新。
<template>
<div>
<span v-once>{{ message }}</span>
<img v-once :src="imageUrl"></img>
</div>
</template>
<script setup>
import { ref } from 'vue';
let message = ref('Vue指令!');
let imageSrc = ref('/path/my/image.jpg');
setTimeout(() => {
message.value = '修改内容!';
imageUrl.value = '/new/path/my/images.jpg';
}, 2000);
</script>
注意: 作用v-once
会使属性失去响应式,要确保这个地方不需要响应式更新才能使用,否则会导致数据和页面视图对不上。
二、v-pre
作用: 在标签上使用v-pre
后,Vue
编译器会自动跳过这个元素的编译。使用此内置指令后会被视为静态内容。
v-pre
指令实现原理: Vue
初次编译时如果看到有v-pre
标记,那么跳过这部分的编译,直接当成原始的HTML
插入到DOM
中。
示例代码: 常规文本会正常编译成您好!
,但使用了v-pre
后会跳过编译原样输出{{ message }}
。
<template>
<div>
<h2>常规: {{ message }}</h2>
<h2 v-pre>使用v-pre后: {{ message }}</h2>
</div>
</template>
<script setup>
import { ref } from 'vue';
let message = ref('您好!');
</script>
注意: 要区分v-pre
和v-once
的区别,v-once
用于只渲染一次,而v-pre
是直接跳过编译。
这个指令可能很多人没想到应用场景有那些,其实最常见的用途就是要在页面上显示
Vue
代码,如果不用v-pre
就会被编译。如下所示使用v-pre
场景效果。
<template>
<div>
<pre v-pre>
<template>
<p>{{ message }}</p>
</template>
<script setup>
import { ref } from 'vue';
const message = ref('Hello Vue!');
</script>
</pre>
</div>
</template>
<script setup>
import { ref } from 'vue';
let message = ref('您好!');
</script>
页面上展示: 代码原始显示不会被编译。
三、v-memo(支持3.2+版本)
作用: 主要用于优化组件的渲染方面性能,能控制达到某个条件才重新当堂组件,否则不重新渲染。v-memo
会缓存 DOM
,只有当指定的数据发生变化时才会重新渲染,从而减少渲染次数提升性能。
v-memo
指令实现原理: Vue
初始化组件时会识别是否有v-memo
标记,如果有就把这部分vnode
缓存起来,当数据变化时会对比依赖是否变化,变化再重新渲染。
示例代码: 用v-memo
绑定了arr
,那么当arr
的值变化才会重新渲染,否则不会重新渲染。
<template>
<div>
<ul v-memo="arr">
<li v-for="(item, index) in arr" :key="index">
{{ item.text }}
</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue';
let arr = ref([
{ text: '内容1' },
{ text: '内容2' },
{ text: '内容3' }
]);
setInterval(() => {
arr.value[1].text = '修改2';
}, 2000);
</script>
注意: 用v-memo
来指定触发渲染的条件,但只建议在长列表或者说复杂的渲染结构才使用。
小结
总结了几个比较冷门的Vue
内置指令,平时用的不多,但用对了地方却能明显提升性能。如果那里写的不对或者有好建议欢迎大佬指出啊。
来源:juejin.cn/post/7407340295115767808
火山引擎携零售巨头成立大模型联盟,抖音电商及生活服务加盟助阵
2024年虽被外界普遍认为是“大模型应用落地元年”,但至今仍有很多声音,质疑大模型在具体行业的应用落地效果。大模型究竟如何更好发挥自身作用,助力企业实现AI转型,促进创新增长,也一直是媒体和行业的热议话题。
8月21日,在2024火山引擎 AI 创新巡展(上海站)期间,火山引擎发布了豆包大模型的一系列产品升级,并携手多点DMALL等零售巨头,成立了零售大模型生态联盟。联盟首批成员包括物美集团、抖音电商、抖音生活服务、百胜、麦当劳等。
火山引擎总裁谭待表示,企业要真正做好AI转型,是一件非常有挑战的事。希望通过大模型生态联盟,与更多企业伙伴一起探索,共同促进零售企业的AI转型,让大模型更好地为企业发展服务。
火山引擎智能算法负责人、火山方舟负责人吴迪则以《豆包大模型,助力企业AI转型》为题,现场分享了大模型如何在具体行业应用落地的细节。
吴迪表示,自今年5月15日豆包大模型发布以来,60天时间里云计算客户总调用量增长了三倍左右。随着处理的问题越来越多,火山引擎对市场挑战的理解也越来越深刻,并将AI大模型落地具体行业所面临的问题总结为“三大挑战”:
一是基础模型是否足够“聪明”;二是价格和成本;三是落地过程中所面临新工作范式和企业原有IT系统之间的改造,以及兼容成本等具体问题。
而对于这些问题,豆包大模型则以更强模型、更低价格、更易落地的解决方案加以应对。
吴迪表示,目前在字节跳动企业内部,包括抖音、剪映、头条、豆包APP、飞书、懂车帝、猫箱、河马、番茄等约50余个业务线在使用豆包大模型,在外部每天则有30余个行业客户在使用。
而在价格方面,豆包通用模型pro的推理输入为0.8厘/千tokens,输出为2厘/千tokens。之所以能够把价格做到这个水平,背靠的则是强劲的系统承载力、充沛算力,以及积累多年的推理算法、系统优化及系统调度能力。
首先,火山引擎拥有海量GPU资源,目前在豆包大模型和火山方舟平台,已投入多达数万张不同型号GPU算力。
同时,造成算力枯竭的一个重要原因,是很多企业做不到灵活调配GPU算力,从而造成2/3甚至更多时间里,算力出现闲置或低效率表现。而火山引擎通过极致调度,避免浪费,则可以进一步将成本优势控制到同行的1/3甚至1/10。
第三则是极致弹性。火山引擎可以做到分钟级完成数千卡伸缩,有效支持突发流量和业务高峰。而火山引擎推出的多种批量推理模式,则提供了业界领先的TPU初始额度。
除此之外,火山引擎还配备了优秀精干的算法工程师团队,支撑企业客户需求以及疑难问题的解决,用抖音内容、抖音搜索、知识库等插件,配合Coze扣子平台,打造更易使用的开发者环境,并利用安全沙箱,使客户可以更加放心地使用大模型。
在安全方面,首先通过TLS和安全沙箱实现双向身份认证和加密,建立互信连接,保证用户访问的安全。
其次则通过全链路数据加密,确保用户的使用安全。
第三则是通过安全沙箱技术,杜绝内外风险入侵和数据泄露的风险。
第四是“信息无痕”,做到“全链路”、“全内存”、“零日志”,在任务结束时安全沙箱自动销毁,用户画像全程无痕。
第五是操作可审计,对沙箱系统及用户流量的访问均有日志记录,客户也可以自行通过token API的方式对日志进行审计。
目前,火山引擎新升级的内容和联网插件提供包括金融、旅游、影视、生活服务等27个行业垂直内容的数据源,并新增抖音百科类型数据。
吴迪表示,升级后的知识库,在文档解析和检索能力方面都有了大幅提高,可以应对包括图片、多列表格、PPT、Markdown等更丰富的文档类型,并更具性价比,支持向量库的语义检索以及类似传统搜索引擎的准确检索等。
在活动现场,火山引擎还公开发布了全新的Coze扣子专业版,用于企业开发智能体。吴迪表示,火山引擎将在Coze扣子专业版上提供企业级稳定性保障,以及一键式接入火山方舟模型的能力、更高的tokens配额。
作为零售行业大模型生态联盟的发起者之一,多点DMALL创始人、物美集团创始人张文中博士也来到现场,并从具体的操作层面,与在场与会者分享了大模型如何在零售行业中具体落地。
张文中提出,目前AI大模型已经可以广泛应用于包括超市智能防损、智能补货、智能客服、以及折扣出清等多个方面。由于豆包大模型tokens定价极低,很多以往很难解决的难题,现在都有了很高性价比的解决方案。
张文中最后表示,AI时代,零售企业再也不能“单打独斗”。大模型时代,行业更需要携手共进,希望与火山引擎一起,向零售界发出呼吁,通过全面拥抱AI,一起努力共创智慧零售的新未来。(作者:李双)
收起阅读 »一文揭秘:火山引擎云基础设施如何支撑大模型应用落地
2024年被普遍认为是“大模型落地应用元年”,而要让大模型真正落地应用到企业的生产环节中,推理能力至关重要。所谓“推理能力”,即大模型利用输入的新数据,一次性获得正确结论的过程。除模型本身的设计外,还需要强大的硬件作为基础。
在8月21日举办的2024火山引擎AI创新巡展上海站活动上,火山引擎云基础产品负责人罗浩发表演讲,介绍了火山引擎AI全栈云在算力升级、资源管理、性能和稳定性等方面做出的努力,尤其是分享了针对大模型推理问题的解决方案。
罗浩表示,在弹性方面,与传统的云原生任务相比,推理任务,以及面向AI native应用,由于其所对应的底层资源池更加复杂,因此面临的弹性问题也更加复杂。传统的在线任务弹性,主要存在于CPU、内存、存储等方面,而AI native应用的弹性问题,则涉及模型弹性、GPU弹性、缓存弹性,以及RAG、KV Cache等机制的弹性。
同时,由于底层支撑算力和包括数据库系统在内的存储都发生了相应的变化,也导致对应的观测体系和监控体系出现不同的变化,带来新的挑战。
在具体应对上,火山引擎首先在资源方面,面向不同的需求,提供了更多类型的多达几百种计算实例,包括推理、训练以及不同规格推理和训练的实例类型,同时涵盖CPU和GPU。
在选择实例时,火山引擎应用了自研的智能选型产品,当面训练场景或推理场景时,在给定推理引擎,以及该推理引擎所对应的模型时,都会给出更加适配的GPU或CPU实例。该工具也会自动探索模型参数,包括推理引擎性能等,从而找到最佳匹配实例。
最后,结合整体资源调度体系,可以通过容器、虚拟机、Service等方式,满足对资源的需求。
而在数据领域,目前在训练场景,最主要会通过TOS、CFS、VPFS支持大模型的训练和分发,可以看到所有的存储、数据库等都在逐渐转向高维化,提供了对应的存储和检索能力。
在数据安全方向,当前的存储数据,已经有了更多内容属性,企业和用户对于数据存储的安全性也更加在意。对此,火山引擎在基础架构层面提供全面的路审计能力,可通过专区形式,支持从物理机到交换机,再到专属云以及所有组件的对应审计能力。
对此,罗浩以火山引擎与游戏公司沐瞳的具体合作为例给予了解释。在对移动端游戏里出现的语言、行为进行审计和审核时,大量用到各种各样的云基础,以及包括大模型在内的多种AI产品,而火山引擎做到了让所有的产品使用都在同一朵云上,使其在整体调用过程当中,不出现额外的流量成本,也使整体调用延时达到最优化。
另外,在火山引擎与客户“美图”合作的案例中,在面对新年、元旦、情人节等流量高峰时,美图通过火山引擎弹性的资源池,同时利用火山潮汐的算力,使得应用整体使用GPU和CPU等云资源时,成本达到最优化。
罗浩最后表示,未来火山引擎AI全栈云在算力、资源管理、性能及稳定性等方面还将继续探索,为AI应用在各行业的落地,奠定更加坚实的基础,为推动各行业智能化和数字化转型的全新助力。(作者:李双)
收起阅读 »逻辑删除用户账号合规吗?
事情的起因是这样:
有一个小伙伴说自己用某电动车 App,由于种种原因后来注销了账号,注销完成之后,该 App 提示 “您的账户已删除。与您的账户关联的所有个人数据也已永久删除”。当时当他重新打开 App 之后,发现账户名变为了 unknown,邮箱和电话变成了账号的
uid@delete.account.品牌.com
。更炸裂的是,这个 App 此时还是可以正常控制电动车,可以查看定位、电量、客服记录、维修记录等等信息。
小伙伴觉得心塞,感觉被这个 App 耍了,明明就没有删除个人信息,却信誓旦旦的说数据已经永久删除了。
其实咱们做后端服务的小伙伴都知道,基本上都是逻辑删除,很少很少有物理删除。
大部分公司可能都是把账号状态标记为删除,然后踢用户下线;有点良心的公司除了将账号状态标记为删除,还会将用户信息脱敏;神操作公司则把账号状态标记为删除,但是忘记踢用户下线。
于是就出现了咱们小伙伴遇到的场景了。
逻辑删除这事,其实不用看代码,就从商业角度稍微分析就知道不可能是物理删除。比如国内很多 App 对新用户都会送各种优惠券、代金券等等,如果物理删除岂不是意味着可以反复薅平台羊毛。
当然这个是各个厂的实际做法,那么这块有没有相关规定呢?松哥专门去查看了一下相关资料。
根据 GB/T 35273
中的解释,我挑两段给大家看下。
首先文档中解释了什么是删除:
去除用户个人信息的行为,使其保持不可被检索、访问的状态。
理论上来说,逻辑删除也能够实现用户信息不可被检索和访问。
再来看关于用户注销账户的规范:
删除个人信息或者匿名化处理。
从这两处解释大家可以看到,平台逻辑删除用户信息从合规上来说没有问题。
甚至可能物理删除了反而有问题。
比如张三注册了一个聊天软件实施诈骗行为,骗到钱了光速注销账号,平台也把张三的信息删除了,最后取证找不到人,在目前这种情况下,平台要不要背锅?如果平台要背锅,那你说平台会不会就真把张三信息给清空了?
对于这个小伙伴的遭遇,其实算是一个系统 BUG,账户注销,应该强制退出登录,退出之后,再想登录肯定就登录不上去了,所以也看不到自己之前的用户信息了。
小伙伴们说说,你们的系统是怎么处理这种场景的呢?
来源:juejin.cn/post/7407274895929638964
【在线聊天室😻】前端进阶全栈开发🔥
项目效果
登录注册身份认证、私聊、聊天室
项目前端React18仓库:github.com/mcmcCat/mmc…
项目后端Nestjs仓库:github.com/mcmcCat/mmc…
语雀上的笔记:http://www.yuque.com/maimaicat/t…
技术栈:
Nestjs企业级Node服务端框架+TypeOrm(Mysql)+JWT+Socket.IO🎉
React18/服务端渲染Nextjs+Redux-toolkit+styled-components🎉
登录注册身份认证、私聊、聊天室
项目前端React18仓库:github.com/mcmcCat/mmc…
项目后端Nestjs仓库:github.com/mcmcCat/mmc…
语雀上的笔记:http://www.yuque.com/maimaicat/t…
技术栈:
Nestjs企业级Node服务端框架+TypeOrm(Mysql)+JWT+Socket.IO🎉
React18/服务端渲染Nextjs+Redux-toolkit+styled-components🎉
前言
Nestjs 是一个用于构建高效可扩展的一个基于Node js 服务端 应用程序开发框架。本文不过多赘述,网上的教程有很多。
(注意:对于聊天中user模块和message模块的接口可参考仓库代码,在这里只分析登录注册的身份认证)
下面可以放张图稍微感受一下,用nest写接口很方便。 @Post('auth/register')
使用装饰器的方式,当你请求这个接口时,会自动调用下方函数AuthRegister
。另外还可以加一大堆装饰器,用于生成swagger接口文档,做本地验证、jwt验证等等。
import {
Body,
ClassSerializerInterceptor,
Controller,
Get,
Post,
Req,
Res,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { AppService } from './app.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth/auth.service';
import { CreateUserDto } from './user/dto/create-user.dto';
import { LoginDTO } from './auth/dto/login.dto';
import { AuthGuard } from '@nestjs/passport';
// @Controller装饰器来定义控制器,如每一个要成为控制器的类,都需要借助@Controller装饰器的装饰
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@ApiTags('JWT注册')
@Post('auth/register')
async AuthRegister(@Body() body: CreateUserDto) {
return await this.appService.authRegister(body);
}
@UseInterceptors(ClassSerializerInterceptor) //返回的数据中去除实体中被@Exclude()的字段
@UseGuards(AuthGuard('local')) //使用本地策略验证用户名和密码的正确性
@ApiTags('JWT登录')
@Post('auth/login')
async AuthLogin(@Body() body: LoginDTO, @Req() req) {
// 通过了本地策略证明身份验证通过
return await this.appService.authLogin(req.user);
}
}
Nestjs 是一个用于构建高效可扩展的一个基于Node js 服务端 应用程序开发框架。本文不过多赘述,网上的教程有很多。
(注意:对于聊天中user模块和message模块的接口可参考仓库代码,在这里只分析登录注册的身份认证)
下面可以放张图稍微感受一下,用nest写接口很方便。 @Post('auth/register')
使用装饰器的方式,当你请求这个接口时,会自动调用下方函数AuthRegister
。另外还可以加一大堆装饰器,用于生成swagger接口文档,做本地验证、jwt验证等等。
import {
Body,
ClassSerializerInterceptor,
Controller,
Get,
Post,
Req,
Res,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { AppService } from './app.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth/auth.service';
import { CreateUserDto } from './user/dto/create-user.dto';
import { LoginDTO } from './auth/dto/login.dto';
import { AuthGuard } from '@nestjs/passport';
// @Controller装饰器来定义控制器,如每一个要成为控制器的类,都需要借助@Controller装饰器的装饰
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@ApiTags('JWT注册')
@Post('auth/register')
async AuthRegister(@Body() body: CreateUserDto) {
return await this.appService.authRegister(body);
}
@UseInterceptors(ClassSerializerInterceptor) //返回的数据中去除实体中被@Exclude()的字段
@UseGuards(AuthGuard('local')) //使用本地策略验证用户名和密码的正确性
@ApiTags('JWT登录')
@Post('auth/login')
async AuthLogin(@Body() body: LoginDTO, @Req() req) {
// 通过了本地策略证明身份验证通过
return await this.appService.authLogin(req.user);
}
}
Nestjs中如何进行身份认证?
密码加密 和 生成token
我们可以跟着代码仓库,带有详细的注释,一步步地走
app.service.ts
负责定义注册authRegister
和登录authLogin
- 在注册时,拿到用户输入的密码,使用
**bcryptjs.hash()**
将其转换为 hash加密字符串,并存入数据库
- 在(身份认证的)登录时,先进行校验用户登录信息是否正确在这里我们使用的是
[passport-local](http://nestjs.inode.club/recipes/passport#%E5%AE%9E%E7%8E%B0-passport-%E6%9C%AC%E5%9C%B0%E7%AD%96%E7%95%A5)
本地策略来验证,@UseGuards(AuthGuard('local'))
这个装饰器会在此处的post请求@Post('auth/login')
后进行拦截,去local.strategy.ts
中进行validate
检索出该用户的信息,然后我们使用**bcryptjs.compareSync()**
将 **用户输入的密码 **与数据库中用 **hash加密过的密码 **进行解析对比,若登录信息正确则接着调用AuthLogin
,进而调用(认证成功的)登录接口authService.login()
,即向客户端发送登录成功信息并且是携带有**token**
的,
async login(user: any) {
// 准备jwt需要的负载
const payload = { username: user.username, sub: user.id };
return {
code: '200',
// 配合存储着用户信息的负载 payload 来生成一个包含签名的JWT令牌(access_token)。。
access_token: this.jwtService.sign(payload),
msg: '登录成功',
};
}
我们可以跟着代码仓库,带有详细的注释,一步步地走app.service.ts
负责定义注册authRegister
和登录authLogin
- 在注册时,拿到用户输入的密码,使用
**bcryptjs.hash()**
将其转换为 hash加密字符串,并存入数据库 - 在(身份认证的)登录时,先进行校验用户登录信息是否正确在这里我们使用的是
[passport-local](http://nestjs.inode.club/recipes/passport#%E5%AE%9E%E7%8E%B0-passport-%E6%9C%AC%E5%9C%B0%E7%AD%96%E7%95%A5)
本地策略来验证,@UseGuards(AuthGuard('local'))
这个装饰器会在此处的post请求@Post('auth/login')
后进行拦截,去local.strategy.ts
中进行validate
检索出该用户的信息,然后我们使用**bcryptjs.compareSync()**
将 **用户输入的密码 **与数据库中用 **hash加密过的密码 **进行解析对比,若登录信息正确则接着调用AuthLogin
,进而调用(认证成功的)登录接口authService.login()
,即向客户端发送登录成功信息并且是携带有**token**
的,
async login(user: any) {
// 准备jwt需要的负载
const payload = { username: user.username, sub: user.id };
return {
code: '200',
// 配合存储着用户信息的负载 payload 来生成一个包含签名的JWT令牌(access_token)。。
access_token: this.jwtService.sign(payload),
msg: '登录成功',
};
}
校验token合法性
那么这个token
我们在哪里去拦截它进行校验呢?
那就要提到我们 nest 的guard
(守卫)这个概念。其实就好比我们在vue项目中,封装路由前置守卫拦截路由跳转,去获取存储在localStorage
的token
一样。
在 nest 守卫中我们可以去获取到请求体req
,从而获取到请求头中的Authorization
字段,查看是否携带token
,然后去校验token
合法性,authService.verifyToken()
中调用jwtService.verify()
进行token
的令牌格式校验、签名验证、过期时间校验,确保令牌的完整性、真实性和有效性
import {
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from 'src/app.module';
import { AuthService } from './auth.service';
import { UserService } from 'src/user/user.service';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor() {
super();
}
async canActivate(context: ExecutionContext): Promise<any> {
const req = context.switchToHttp().getRequest();
// 如果是请求路由是白名单中的,则直接放行
if (this.hasUrl(this.whiteList, req.url)) return true;
try {
const accessToken = req.get('Authorization');
if (!accessToken) throw new UnauthorizedException('请先登录');
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const authService = app.get(AuthService);
const userService = app.get(UserService);
const tokenUserInfo = await authService.verifyToken(accessToken);
const resData = await userService.findOne(tokenUserInfo.username);
if (resData[0].id) return true;
} catch (e) {
console.log('1h 的 token 过期啦!请重新登录');
return false;
}
}
// 白名单数组
private whiteList: string[] = ['/auth/register','/auth/login'];
// 验证该次请求是否为白名单内的路由
private hasUrl(whiteList: string[], url: string): boolean {
let flag = false;
if (whiteList.indexOf(url) !== -1) {
flag = true;
}
return flag;
}
}
在guard
中,当我们return true
时,好比路由前置守卫的next()
,就是认证通过了放行的意思
当然,别忘了注册守卫,我们这里可以采用全局守卫的形式注册,在main.ts
中app.useGlobalGuards(new JwtAuthGuard());
那么这个token
我们在哪里去拦截它进行校验呢?
那就要提到我们 nest 的guard
(守卫)这个概念。其实就好比我们在vue项目中,封装路由前置守卫拦截路由跳转,去获取存储在localStorage
的token
一样。
在 nest 守卫中我们可以去获取到请求体req
,从而获取到请求头中的Authorization
字段,查看是否携带token
,然后去校验token
合法性,authService.verifyToken()
中调用jwtService.verify()
进行token
的令牌格式校验、签名验证、过期时间校验,确保令牌的完整性、真实性和有效性
import {
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from 'src/app.module';
import { AuthService } from './auth.service';
import { UserService } from 'src/user/user.service';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor() {
super();
}
async canActivate(context: ExecutionContext): Promise<any> {
const req = context.switchToHttp().getRequest();
// 如果是请求路由是白名单中的,则直接放行
if (this.hasUrl(this.whiteList, req.url)) return true;
try {
const accessToken = req.get('Authorization');
if (!accessToken) throw new UnauthorizedException('请先登录');
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const authService = app.get(AuthService);
const userService = app.get(UserService);
const tokenUserInfo = await authService.verifyToken(accessToken);
const resData = await userService.findOne(tokenUserInfo.username);
if (resData[0].id) return true;
} catch (e) {
console.log('1h 的 token 过期啦!请重新登录');
return false;
}
}
// 白名单数组
private whiteList: string[] = ['/auth/register','/auth/login'];
// 验证该次请求是否为白名单内的路由
private hasUrl(whiteList: string[], url: string): boolean {
let flag = false;
if (whiteList.indexOf(url) !== -1) {
flag = true;
}
return flag;
}
}
在guard
中,当我们return true
时,好比路由前置守卫的next()
,就是认证通过了放行的意思
当然,别忘了注册守卫,我们这里可以采用全局守卫的形式注册,在main.ts
中app.useGlobalGuards(new JwtAuthGuard());
Socket.IO如何实现即时聊天?
Nest中WebSocket网关的作用
使用 @WebSocketGateway 装饰器配置 WebSocket 网关在 Nest.js 应用中具有以下作用:
- 提供 WebSocket 的入口点:WebSocket 网关允许客户端通过 WebSocket 协议与后端建立实时的双向通信。通过配置网关,你可以定义用于处理 WebSocket 连接、消息传递和事件的逻辑。
- 处理跨域请求:在 WebSocket 中,默认存在跨域限制,即只能与同源的 WebSocket 服务进行通信。通过设置 origin 选项,WebSocket 网关可以解决跨域请求问题,允许来自指定源的请求进行跨域访问。
关于Socket.IO是怎么通讯的可以看看官网给出的图
socketIO
是通过事件监听的形式,我们可以很清晰的区分出消息的类型,方便对不同类型的消息进行处理,客户端和服务端双方事先约定好不同的事件,事件由谁监听,由谁触发,就可以把各种消息进行有序管理了
下面是一个简单的通讯事件示例:
import {
WebSocketGateway,
SubscribeMessage,
WebSocketServer,
MessageBody,
ConnectedSocket,
} from '@nestjs/websockets';
import { SocketService } from './socket.service';
import { CreateSocketDto } from './dto/create-socket.dto';
import { UpdateSocketDto } from './dto/update-socket.dto';
import { Socket } from 'socket.io';
const roomList = {};
let roomId = null;
let user = '';
@WebSocketGateway(3001, {
allowEIO3: true, // 开启后要求前后端使用的 Socket.io 版本要保持一致
//后端解决跨域
cors: {
// 允许具体源的请求进行跨域访问
origin: 'http://localhost:8080', //这里不要写*,要写 true 或者具体的前端请求时所在的域,否则会出现跨域问题
// 允许在跨域请求中发送凭据
credentials: true,
},
})
export class SocketGateway {
constructor(private readonly socketService: SocketService) {}
@SubscribeMessage('ToClient')
ToClient(@MessageBody() data: any) {
// 转发信息
const forwardMsg: string = '服务端=>客户端';
return {
//通过return返回客户端转发事件
event: 'forward',
data: forwardMsg, //data后面跟携带数据
};
}
//接收并处理来自客户端的消息
@SubscribeMessage('toServer')
handleServerMessage(client: Socket, data: string) {
console.log(data + ' (让我服务端来进行一下处理)');
client.emit('ToClient', data + '(处理完成给客户端)');
}
}
使用 @WebSocketGateway 装饰器配置 WebSocket 网关在 Nest.js 应用中具有以下作用:
- 提供 WebSocket 的入口点:WebSocket 网关允许客户端通过 WebSocket 协议与后端建立实时的双向通信。通过配置网关,你可以定义用于处理 WebSocket 连接、消息传递和事件的逻辑。
- 处理跨域请求:在 WebSocket 中,默认存在跨域限制,即只能与同源的 WebSocket 服务进行通信。通过设置 origin 选项,WebSocket 网关可以解决跨域请求问题,允许来自指定源的请求进行跨域访问。
关于Socket.IO是怎么通讯的可以看看官网给出的图socketIO
是通过事件监听的形式,我们可以很清晰的区分出消息的类型,方便对不同类型的消息进行处理,客户端和服务端双方事先约定好不同的事件,事件由谁监听,由谁触发,就可以把各种消息进行有序管理了
下面是一个简单的通讯事件示例:
import {
WebSocketGateway,
SubscribeMessage,
WebSocketServer,
MessageBody,
ConnectedSocket,
} from '@nestjs/websockets';
import { SocketService } from './socket.service';
import { CreateSocketDto } from './dto/create-socket.dto';
import { UpdateSocketDto } from './dto/update-socket.dto';
import { Socket } from 'socket.io';
const roomList = {};
let roomId = null;
let user = '';
@WebSocketGateway(3001, {
allowEIO3: true, // 开启后要求前后端使用的 Socket.io 版本要保持一致
//后端解决跨域
cors: {
// 允许具体源的请求进行跨域访问
origin: 'http://localhost:8080', //这里不要写*,要写 true 或者具体的前端请求时所在的域,否则会出现跨域问题
// 允许在跨域请求中发送凭据
credentials: true,
},
})
export class SocketGateway {
constructor(private readonly socketService: SocketService) {}
@SubscribeMessage('ToClient')
ToClient(@MessageBody() data: any) {
// 转发信息
const forwardMsg: string = '服务端=>客户端';
return {
//通过return返回客户端转发事件
event: 'forward',
data: forwardMsg, //data后面跟携带数据
};
}
//接收并处理来自客户端的消息
@SubscribeMessage('toServer')
handleServerMessage(client: Socket, data: string) {
console.log(data + ' (让我服务端来进行一下处理)');
client.emit('ToClient', data + '(处理完成给客户端)');
}
}
私聊模块中的 socket 事件
通过使用client.broadcast.emit('showMessage')
和 client.emit('showMessage')
,你可以实现多人实时聊天的功能。
当一个客户端发送一条消息时,通过 client.broadcast.emit('showMessage')
将该消息广播给其他客户端,让其他客户端可以接收到这条消息并进行相应的处理,从而实现多人实时聊天的效果。
同时,使用 client.emit('showMessage')
可以将消息发送给当前连接的客户端,这样当前客户端也会收到自己发送的消息,以便在界面上显示自己发送的内容。
@SubscribeMessage('sendMessage')
sendMessage(client: Socket) {
// 将该消息广播给其他客户端
client.broadcast.emit('showMessage');
// 将消息发送给当前连接的客户端
client.emit('showMessage');
return;
}
前端中会在UserList.tsx
监听该事件showMessage,并触发更新信息逻辑
useEffect(() => {
socket.on('showMessage', getCurentMessages)
return () => {
socket.off('showMessage')
}
})
通过使用client.broadcast.emit('showMessage')
和 client.emit('showMessage')
,你可以实现多人实时聊天的功能。
当一个客户端发送一条消息时,通过 client.broadcast.emit('showMessage')
将该消息广播给其他客户端,让其他客户端可以接收到这条消息并进行相应的处理,从而实现多人实时聊天的效果。
同时,使用 client.emit('showMessage')
可以将消息发送给当前连接的客户端,这样当前客户端也会收到自己发送的消息,以便在界面上显示自己发送的内容。
@SubscribeMessage('sendMessage')
sendMessage(client: Socket) {
// 将该消息广播给其他客户端
client.broadcast.emit('showMessage');
// 将消息发送给当前连接的客户端
client.emit('showMessage');
return;
}
前端中会在UserList.tsx
监听该事件showMessage,并触发更新信息逻辑
useEffect(() => {
socket.on('showMessage', getCurentMessages)
return () => {
socket.off('showMessage')
}
})
房间模块中的 socket 事件
@SubscribeMessage('sendRoomMessage')
sendRoomMessage(client: Socket, data) {
console.log('服务端接收到了');
// // 将消息发送给指定房间内的所有客户端
this.socketIO.to(roomId).emit('sendRoomMessage', data);
return;
}
在需要发送消息给指定房间时,即我们需要在全局中找到指定房间,所以我们需要整个 WebSocket 服务器的实例
@WebSocketServer()
socketIO: Socket; //它表示整个 WebSocket 服务器的实例。它可以用于执行全局操作,如向所有连接的客户端广播消息或将客户端连接到特定的房间。
@SubscribeMessage('sendRoomMessage')
sendRoomMessage(client: Socket, data) {
console.log('服务端接收到了');
// // 将消息发送给指定房间内的所有客户端
this.socketIO.to(roomId).emit('sendRoomMessage', data);
return;
}
在需要发送消息给指定房间时,即我们需要在全局中找到指定房间,所以我们需要整个 WebSocket 服务器的实例
@WebSocketServer()
socketIO: Socket; //它表示整个 WebSocket 服务器的实例。它可以用于执行全局操作,如向所有连接的客户端广播消息或将客户端连接到特定的房间。
加入和退出房间的 socket API
踩坑
- socket实例的创建写在了函数组件内,useState中的变量的频繁改变,导致的组件不断重新渲染,socket实例也被不断创建,形成过多的连接,让websocket服务崩溃!!!
解决:
把socket实例的创建拿出来放在单独的文件中,这样在各个函数组件中若使用的话只用引用这一共同的socket实例,仅与websocket服务器形成一个连接
- socket事件的监听没有及时的停止,导致对同一事件的监听不断叠加(如sys事件),当触发一次这一事件时,会同时触发到之前叠加的所有监听函数!!!
项目中的效果就是不断重新进入房间时,提示信息的渲染次数会递增的增加,而不是只提示一次
- socket实例的创建写在了函数组件内,useState中的变量的频繁改变,导致的组件不断重新渲染,socket实例也被不断创建,形成过多的连接,让websocket服务崩溃!!!
解决:
把socket实例的创建拿出来放在单独的文件中,这样在各个函数组件中若使用的话只用引用这一共同的socket实例,仅与websocket服务器形成一个连接
- socket事件的监听没有及时的停止,导致对同一事件的监听不断叠加(如sys事件),当触发一次这一事件时,会同时触发到之前叠加的所有监听函数!!!
项目中的效果就是不断重新进入房间时,提示信息的渲染次数会递增的增加,而不是只提示一次
解决:
在离开房间后要socket.off('sys');
要停止事件监听,另外最好是在组件销毁时停止所有事件的监听(此处为Next/React18,即项目前端的代码)
/* client */
useEffect(() => {
console.log('chat组件挂载');
// 连接自动触发
socket.on('connect', () => {
socket.emit('connection');
// 其他客户端事件和逻辑
});
return () => {
console.log('chat组件卸载');
socket.off();// 停止所有事件的监听 !!!
};
}, []);
/* server */
@SubscribeMessage('connection')
connection(client: Socket, data) {
console.log('有一个客户端连接成功', client.id);
// 断连自动触发
client.on('disconnect', () => {
console.log('有一个客户端断开连接', client.id);
// 处理断开连接的额外逻辑
});
return;
}
来源:juejin.cn/post/7295681529606832138
颠覆霍金猜想!数学家证明极端黑洞可能存在
明敏 发自 凹非寺
量子位 | 公众号 QbitAI
霍金50年前提出的猜想被颠覆了!
数学家们最新证明,极端黑洞可能存在。
这与霍金等人在1973年提出的黑洞热力学第三定律**相悖。
极端黑洞是一种非常特殊的情况,指黑洞表面或事件视界的引力为零,它的表面不吸引任何东西,但是如果把粒子推出到黑洞中心,还是无法逃逸。
而且由于黑洞的温度与表面重力成正比,表面重力不存在即意味着黑洞没有温度,无法发射热辐射。
这又与霍金辐射理论相违背,该理论提出黑洞不是完全“黑暗”的,而是能以特定方式缓慢向外辐射能量,从而逐渐失去质量并最终可能消失。
但是来自MIT的克里斯托夫·凯勒(Christoph Kehle)和斯坦福大学的瑞安·昂格尔(Ryan Unger)用数学方法证明,这种情况可能存在。
而且它们还证明,极端黑洞存在并不会导致裸奇点存在。
诺奖得主彭罗斯**之前提出,自然界不允许裸奇点存在,如果它存在将破坏宇宙因果性,奇点附近的空间区域可能会允许违反因果关系的行为,导致时间和空间在局部变得不再有序。
哥伦比亚大学数学家艾琳娜·乔治(Elena Giorgi)评价:
这是数学回馈物理学一个很棒的例子。
极端黑洞是什么?
自然界中绝大多数黑洞都是旋转的。
当带电荷的物质掉入黑洞后,因为角动量守恒,黑洞自旋转速度会增加,同时黑洞本身也会带上电荷。
理论上,随着黑洞吸入越来越多物质,它的电荷量和转速将会无限大,这样就会出现极端黑洞。
对于极端黑洞,只要再加上任何一点电荷,它的视界就会消失,并留下一个裸奇点。
而且它的表面不再吸引任何东西。
1973年,霍金、约翰·巴丁、布兰登·卡特提出,极端黑洞不可能形成。
这条定律指出黑洞的表面引力不可能在有限时间内降至0,三位科学家认为任何允许黑洞的电荷或自旋达到极限的过程都有可能导致黑洞视界完全消失。
学界普遍认为没有视界的黑洞(即裸奇点)是不可能存在的。
此外,由于黑洞的温度和表面重力呈正比,如果没有表面重力黑洞也不会有温度,这样黑洞就无法发射热辐射。但是霍金提出,向外发出辐射是黑洞的必备属性。
1986年,物理学家沃纳·伊斯雷尔(Werner Israel)曾试图模拟用一个普通黑洞构建极端黑洞,并试着让它自旋更快、带上更多电荷,但最终结论表明,这样做并不能让黑洞的表面重力在有限时间内降低到0。
无心插柳找到证明方法
凯勒和昂格尔本身并没有在研究极端黑洞。
他们是在琢磨带电黑洞如何形成时,意外发现可以构建一个具有极高电荷量的黑洞,这是极端黑洞的一个重要标志。
他们从一个不旋转、没有电荷的黑洞开始,模拟它被放置到标量场中可能发生的情况。
他们利用磁场脉冲冲击黑洞,给它增加电荷。这些脉冲为黑洞提供了电磁能量,也增加了黑洞的质量。
通过发射漫射的低频脉冲,就能让黑洞质量(M)的增速大于电荷(q)的增速。
按照分类,当|q|=M时,代表极端黑洞形成;|q|M时为非极端黑洞。
如果质量增速超过电荷增速,意味着黑洞能从亚极端状态向极端状态转变。
论文不仅提出了一种新的特征粘连方法,而且展示了如何构造黑洞内部的结构、分析了黑洞形成和演化的过程,包括从规则初始数据出发的引力坍缩以及黑洞外部的几何结构等。
不过需要注意的是,尽管利用数学方法证明了极端黑洞理论存在,但是也不能说明极端黑洞一定存在。
理论中的例子具有最大电荷量,但是目前人类还没有观测到明显带有电荷的黑洞。找到一个快速自旋的黑洞更有可能,所以凯勒和昂格尔还想构建一个模型,让黑洞能够在自旋速度上达到极限。
但是构建这样一个模型在数学上的挑战更大。目前他们才刚刚开始着手研究。
一直以来,凯勒和昂格尔都在尝试利用数学方法探索黑洞的秘密。
2023年,凯勒和老师艾琳娜等还通过一项1000页的研究证明,数学意义上,缓慢自旋的黑洞是稳定的。这对于验证广义相对论很重要,因为如果在数学意义上不稳定,那么可能意味着基础理论存在问题。
** **
△左为凯勒,右为昂格尔
而今年最新发表的研究,不仅颠覆了霍金提出的猜想,也为广义相对论、量子力学、弦理论等前沿领域研究提供新见解。
来源:juejin.cn/post/7407259722430119947
前端时间分片渲染
在经典的面试题中:”如果后端返回了十万条数据要你插入到页面中,你会怎么处理?”
除了像 useVirtualList 这样的虚拟列表来处理外,我们还可以通过 时间分片
来处理
通过 setTimeout
直接上一个例子:
<!--
* @Author: Jolyne
* @Date: 2023-09-22 15:45:45
* @LastEditTime: 2023-09-22 15:47:24
* @LastEditors: Jolyne
* @Description:
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>十万数据渲染</title>
</head>
<body>
<ul id="list-container"></ul>
<script>
const oListContainer = document.getElementById('list-container')
const fetchData = () => {
return new Promise(resolve => {
const response = {
code: 0,
msg: 'success',
data: [],
}
for (let i = 0; i < 100000; i++) {
response.data.push(`content-${i + 1}`)
}
setTimeout(() => {
resolve(response)
}, 100)
})
}
// 模拟请求后端接口返回十万条数据
// 渲染 total 条数据中的第 page 页,每页 pageCount 条数据
const renderData = (data, total, page, pageCount) => {
// base case -- total 为 0 时没有数据要渲染 不再递归调用
if (total <= 0) return
// total 比 pageCount 少时只渲染 total 条数据
pageCount = Math.min(pageCount, total)
setTimeout(() => {
const startIdx = page * pageCount
const endIdx = startIdx + pageCount
const dataList = data.slice(startIdx, endIdx)
// 将 pageCount 条数据插入到容器中
for (let i = 0; i < pageCount; i++) {
const oItem = document.createElement('li')
oItem.innerText = dataList[i]
oListContainer.appendChild(oItem)
}
renderData(data, total - pageCount, page + 1, pageCount)
}, 0)
}
fetchData().then(res => {
renderData(res.data, res.data.length, 0, 200)
})
</script>
</body>
</html>
上面的例子中,我们使用了 setTimeout
,在每一次宏任务中插入一页数据,然后设置多个这样地宏任务,直到把所有数据都插入为止。
但是很明显能看到的问题是,快速拖动滚动条时,数据列表中会有闪烁的情况
这是因为:
当使用
setTimeout
来拆分大量的 DOM 插入操作时,虽然我们将延迟时间设置为 0ms,但实际上由于 JavaScript 是单线程的,任务执行时会被放入到事件队列中,而事件队列中的任务需要等待当前任务执行完成后才能执行。所以即使设置了 0ms 延迟,setTimeout
的回调函数也不一定会立即执行,可能会受到其他任务的阻塞。
当
setTimeout
的回调函数执行的间隔超过了浏览器每帧更新的时间间隔(一般是 16.7ms),就会出现丢帧现象。丢帧指的是浏览器在更新页面时,没有足够的时间执行全部的任务,导致部分任务被跳过,从而导致页面渲染不连续,出现闪烁的情况
所以,我们改善一下,通过 requestAnimationFrame
来处理
通过 requestAnimationFrame
<!--
* @Author: Jolyne
* @Date: 2023-09-22 15:45:45
* @LastEditTime: 2023-09-22 15:47:24
* @LastEditors: Jolyne
* @Description:
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>直接插入十万条数据</title>
</head>
<body>
<ul id="list-container"></ul>
<script>
const oListContainer = document.getElementById('list-container')
const fetchData = () => {
return new Promise(resolve => {
const response = {
code: 0,
msg: 'success',
data: [],
}
for (let i = 0; i < 100000; i++) {
response.data.push(`content-${i + 1}`)
}
setTimeout(() => {
resolve(response)
}, 100)
})
}
// 模拟请求后端接口返回十万条数据
// 渲染 total 条数据中的第 page 页,每页 pageCount 条数据
const renderData = (data, total, page, pageCount) => {
// base case -- total 为 0 时没有数据要渲染 不再递归调用
if (total <= 0) return
// total 比 pageCount 少时只渲染 total 条数据
pageCount = Math.min(pageCount, total)
requestAnimationFrame(() => {
const startIdx = page * pageCount
const endIdx = startIdx + pageCount
const dataList = data.slice(startIdx, endIdx)
// 将 pageCount 条数据插入到容器中
for (let i = 0; i < pageCount; i++) {
const oItem = document.createElement('li')
oItem.innerText = dataList[i]
oListContainer.appendChild(oItem)
}
renderData(data, total - pageCount, page + 1, pageCount)
})
}
fetchData().then(res => {
renderData(res.data, res.data.length, 0, 200)
})
</script>
</body>
</html>
很明显,闪烁的问题被解决了
这是因为:
requestAnimationFrame
会在浏览器每次进行页面渲染时执行回调函数,保证了每次任务的执行间隔是稳定的,避免了丢帧现象。所以在处理大量 DOM 插入操作时,推荐使用requestAnimationFrame
来拆分任务,以获得更流畅的渲染效果
来源:juejin.cn/post/7282756858174980132
前端:“这需求是认真的吗?” —— el-select 的动态宽度解决方案
Hello~大家好。我是秋天的一阵风 ~
前言
最近我遇到了一个神奇的需求,客户要求对 el-select 的 宽度 进行动态设置。
简单来说,就是我们公司有一些选择框,展示的内容像“中华人民共和国/广西壮族自治区/南宁市/西乡塘区”
这么长,一不小心就会内容超长,显示不全。详情请看下面动图:
一般来说,想解决内容展示不全的问题,有几种方法。
第一种:给选择框加个tooltip
效果,在鼠标悬浮时展示完整内容。
第二种:对用户选择label值进行切割,只展示最后一层内容。
但是我们的客户对这两种方案都不接受,要求选择的时候让select选择框的宽度动态增加。
有什么办法呢?客户就是上帝,必须满足,他们说什么就是什么,所以我们只能开动脑筋,动手解决。
思路
我们打开控制台,来侦察一下el-select
的结构,发现它是一个el-input--suffix
的div
包裹着一个input
,如下图所示。
内层input
的宽度是100%,外层div
的宽度是由这个内层input
决定的。也就是说,内层input
的宽度如果动态增加,外层div
的宽度也会随之增加。那么问题来了,如何将内层input
的宽度动态增加呢?
tips:
如果你对width的100%和auto有什么区别感兴趣,可以点击查看我之前的文章
解决方案
为了让我们的el-select
宽度能够跟着内容走,我们可以在内层input
同级别增加一个元素,内容就是用户选中的内容。内容越多,它就像一个胃口很大的小朋友,把外层div的宽度撑开。下面来看图示例 :
借助prefix
幸运的是,el-select
本身有一个prefix的插槽选项,我们可以借助这个选项实现:
我们添加一个prefix
的插槽,再把prefix
的定位改成relative
,并且把input
的定位改成绝对定位absolute
。最后将prefix
的内容改成我们的选项内容。看看现在的效果:
<template>
<div>
<el-select class="autoWidth" v-model="value" placeholder="请选择">
<template slot="prefix">
{{optionLabel}}
</template>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
>
</el-option>
</el-select>
</div>
</template>
<script>
export default {
data() {
return {
options: [
{
value: "选项1",
label: "中华人民共和国/广东省/深圳市/福田区",
},
{
value: "选项2",
label: "中华人民共和国/广西壮族自治区/南宁市/西乡塘区",
},
{
value: "选项3",
label: "中华人民共和国/北京市",
},
{
value: "选项4",
label: "中华人民共和国/台湾省",
},
{
value: "选项5",
label: "中华人民共和国/香港特别行政区",
},
],
value: "",
};
},
computed: {
optionLabel() {
return (this.options.find((item) => item.value === this.value) || {})
.label;
},
},
};
</script>
<style lang="scss" scoped>
::v-deep .autoWidth .el-input__prefix {
position: relative;
}
::v-deep .autoWidth input {
position: absolute;
}
</style>
细节调整
现在el-select
已经可以根据选项label的内容长短动态增加宽度了,但是我们还需要继续处理一下细节部分,将prefix
的内容调整到和select
框中的内容位置重叠
,并且将它隐藏
。看看现在的效果
::v-deep .autoWidth .el-input__prefix {
position: relative;
box-sizing: border-box;
border: 1px solid #fff;
padding: 0 30px;
height: 40px;
line-height: 40px;
left: 0px;
visibility: hidden;
}
调整初始化效果(用户未选择内容)
目前已经基本实现了效果了,还有最后一个问题,当用户没有选择内容的时候,select的宽度是“没有”
的,如下图所示。
所以我们还得给他加上一个最小宽度
我们加上最小宽度以后,发现这个select的图标又没对齐,这是因为我们在重写.el-input__prefix
样式的时候设置了padding: 0 30px,
当用户没有选择内容的时候,select
的图标应该是默认位置,我们需要继续调整代码,最后效果如下图所示:
完整代码
最后附上完整代码:
<template>
<div>
<el-select
class="autoWidth"
:class="{ 'has-content': optionLabel }"
v-model="value"
placeholder="请选择"
clearable
>
<template slot="prefix">
{{ optionLabel }}
</template>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
>
</el-option>
</el-select>
</div>
</template>
<script>
export default {
data() {
return {
options: [
{
value: "选项1",
label: "中华人民共和国/广东省/深圳市/福田区",
},
{
value: "选项2",
label: "中华人民共和国/广西壮族自治区/南宁市/西乡塘区",
},
{
value: "选项3",
label: "中华人民共和国/北京市",
},
{
value: "选项4",
label: "中华人民共和国/台湾省",
},
{
value: "选项5",
label: "中华人民共和国/香港特别行政区",
},
],
value: "",
};
},
computed: {
optionLabel() {
return (this.options.find((item) => item.value === this.value) || {})
.label;
},
},
};
</script>
<style lang="scss" scoped>
.autoWidth {
min-width: 180px;
}
::v-deep .autoWidth .el-input__prefix {
position: relative;
box-sizing: border-box;
border: 1px solid #fff;
padding: 0 30px;
height: 40px;
line-height: 40px;
left: 0px;
visibility: hidden;
}
::v-deep .autoWidth input {
position: absolute;
}
.autoWidth {
// 当.has-content存在时设置样式
&.has-content {
::v-deep .el-input__suffix {
right: 5px;
}
}
// 当.has-content不存在时的默认或备选样式
&:not(.has-content) {
::v-deep .el-input__suffix {
right: -55px;
}
}
}
</style>
来源:juejin.cn/post/7385825759118196771
程序媛28岁前畅游中国是什么体验?
本人计算机硕士毕业,先后在三家厂工作,工作节奏虽说不是 007 吧,但偶尔 996 是有的,勤勤恳恳搬砖是常态,也偶尔累了就划划水摸鱼。在这行业不焦虑是假的,35 岁危机时刻提醒着每一位年轻的程序员,这行主打一个精神内耗。
前几年互联网飞速发展高薪招人时,大家都有肉吃,现在遇到互联网寒冬了,有汤喝就不错了,尤其对晚入行的95 后社畜,现在回过头看,已经是互联网红利退潮的末期了。对于 80 后早一批入行的程序员, 肯定钱也挣够了,房子也早就翻几倍了,早就有抗御风险的能力了,即使裁员了也能拿着分手费找个差不多的厂子继续苟着。但是对于 95 后来说,惨不忍睹,行业内卷及其严重,刚有点工作经验就遭遇大规模裁员,重点买房都是踩在最高点接盘,现在房价跌了,车子打价格战,直接把前几年辛辛苦苦挣的首付跌没了,这几年白干了,说起来,心就抽搐的疼。不像人家00 后,直接看开了,不破三个 dai,房贷,车贷和传宗接代,直接卷老家公务员躺平,享受人生,逃离大城市的拥挤,拒绝被房子的套牢。
金融危机,经济下行,行业越来越卷,精神内耗极其严重,身体健康堪忧。我突然顿悟了,我决定,为自己而活。想看世界的心也越来越强烈,最后我坐不住了,做了个大胆的决定,畅游中国。刚好疫情快结束时,航空公司推出了自己的产品,畅游中国随心飞,我立刻入手了,入手价是三千多点,全国飞不限次数。我一边安排好自己的时间订机票,一边计划旅行路线,一个女生独自环游中国之旅开始了。没有队友,不给生活中任何糟心事打断我的计划,一人吃饱,全家不饿,当时就已经下定决心了,哪怕一天就只吃个树上的野果子就好,我也要去看世界,可能喜欢宅的人不太理解,但我明白自己想要什么,我理解自己就好,我并不是给别人活的。
下面给大家说说我去了哪些地方。
贵州-贵阳
我看好时间后立刻定机票,从上海飞到了贵阳,准备打卡黄果树瀑布。我定的酒店就在黄果树景点附近不远,一大清早7点我就起床了,呼吸着让人神清气爽的空气,吃了一些自己带的进口苹果作为早餐,特别甘甜,饱腹感足足的。8点进山了,那一刻,我别提多开心了。
回想起当社畜时,每次都是8.30起床,9.30左右到公司,每天上班心情比上坟都沉重,永远干不完的KPI,OCR,不是被PUA就是吃老板画的大饼,再丰盛的早餐一想到一堆任务要做,吃着也如同嚼蜡,更别提神清气爽,心境开拓了。
在进入黄果树后,我欢快的脚步往前走,因为我是一个女孩子独行,所以不太愿意跟陌生人说话,一路上虽然很沉默,但看到这些壮观的自然景色,闻着草木花果香,内心激动不已。爬了一个钟左右的山,终于看到了大瀑布。
下面是我实拍的景点图:
有句古诗,疑似银河落九天,一路好山好水,逛完黄果树后我出来就去吃了贵州的特色菜,价格美丽,味道很不错,超级喜欢,
当时就在感慨,上海要是能吃到这么好吃又鲜美的酸汤鱼就好了。
重庆
本来下一步去梵净山再顺路去成都自驾318路线的,但时间紧迫,我弟弟在重庆读书,说要跟我一起去自驾318,我就先去重庆跟他汇合了。
最后那个火锅要适度吃啊,吃两顿辣的我的陈年胃病都犯了,好几天没缓过来,哭晕在厕所,我弟跟个没事人一样,这是我深刻认识到当了多年的社畜的后果就是,经常熬夜加班点外卖,把好好的身体给造坏了。重庆的洪崖洞,解放碑也去了很多次了,这里给个图
成都
抵达成都,在春熙路逛了逛,宽窄巷子之前逛过就没去了,
本来想租自驾神车-坦克300的,价格是普通suv的2倍,结果路上纠结一会的功夫就被抢先租走了(自我反思:以后看准就下手吧,人生有几次这种机会,有啥好犹豫的),租了一辆1.5T的大众SUV,跟我弟一起直奔车行,然后去超市采购路上的食物,大包小包买了一堆,放车后备箱,深夜就起航了
都江堰
教科书上的都江堰,真正去看了,才深深佩服古人治水的智慧,我不是文盲,所以不用一句:卧槽,发表感叹。之前也去过洛阳的黄河小浪底水库,武汉的长江大桥,这些水利工程的智慧。
青城山
这里是青城山下白素贞的故事发源地。爬山是个体力活,当时穿着拖鞋就上山了,下山就傻眼了,不好意思,这里我偷懒了,坐缆车下车,嘿嘿。
泸定桥
打卡泸定桥,走上面摇摇晃晃确实需要一些勇气,特别怕手机掉下去。
海螺沟
一鼓作气,一路直行,抵达海螺沟。来之前,我觉得新能源车咋自驾318,路上看到同样是特斯拉车主,我感觉自己有点狭隘。人啊,果然要多出去看看,不能活在自己的局限认知中。
不过开车还是要小心,路上遇到有车盘山时发生侧翻的。还有山上偶尔会有落石下来,要当心了。
木格措
一路景色壮观,蓝天白云,川西一定要必去。到了康定情歌的原地。打个卡。
不过我路上听的歌一直都是朴树的《平凡之路》,一路循环:
我曾经跨过山和大海 也穿过人山人海
我曾经拥有着的一切 转眼都飘散如烟
我曾经失落失望 失掉所有方向
直到看见平凡 才是唯一的答案
....
不正是正值青春的我受伤了,但又奋力前行寻找答案吗。
四姑娘山
一路直行。。。抵达四姑娘山,四姑娘山有四座雪山组成,远看景色很壮观,雪已经化了很多。
当地信仰
遇到了一群一动不动的牦牛,还有一匹热情好客的长脸马。拿出来一个饼给它,它吃的还很香。本来开心的事现在记录起来突然感觉在暗示自己在公司当牛做马,不说了,emo了。据说那白色塔这是当地的信仰,表示尊重。
雅拉山口
盘山路,1.5T的车开着有点吃力,油门上不去。终于爬上山了,下车拍照时,激动过头了,开始缺氧,头疼,吸氧。。。。。。。。
后面走着走着身体扛不住了,我去当地买了高反的药,吃了没啥用,氧气越吸头越疼,我弟要回去上课,我身体不抗造,遗憾的半途而归了。再次强调一下,好风景要趁年轻,体力好,等老了走不动了,确实再好的风景,都没那心情和体力去欣赏了。
乐山
跟我弟散伙后,我自己开车去了乐山大佛,保佑我顺风顺水吧。还去看了东方佛群,卧佛,药师佛,看了各种佛,记不清楚了。。。
峨眉山
接着我自己又自驾去了峨眉山,两个地方相差不是很远,看到了峨眉山的云海,云雾缭绕,超级刺眼!
下山后当晚接着又踩着点返回成都还车。休息一晚后,又顺路打卡了锦里。感受人世间的烟火和繁华
又吃了一顿火锅后,回上海。这时,胃没有不舒服,看来,这一圈下来,肠胃好很多了。
又回到了我熟悉的大上海。
安徽
经过一段时间的调养后,我觉得的身体状态老好了,爬山那不是小意思,走,爬山去,什么黄山,三清山,庐山,武功山,离沪这么近,爬起来不费劲!我到了安徽省,黄山市,休息一晚准备去爬山。当晚被出租车司机拉到了老街逛逛。
就一个小型的徽派建筑青砖白瓦的特色,跟顾村差不多。逛完后突然下起了大雨,我猝不及防没带伞,
就记得那晚的雨,比情深深雨蒙蒙中依萍找她爸要钱被鞭子抽回去时遇到的那场大雨还大。。。。。。
黄山
不凑巧,上山时遇到了大雾,但来都来了,那就爬山下去吧。到了光明顶也啥都看不见,但幸运的时,下山时,守得云开见月明,气喘吁吁的开心拍照。
江西
黄山结束后,顺路就来到了江西,江西景色比较集中,一定要去上饶啊,那就先去望仙谷看看吧。
上饶-望仙谷
人工打造的经典,现实版的仙侠世界。小雨朦胧,青山傍水,景色秀丽。
上饶-三清山
谁说黄山归来不看山,我觉得三清山值得一去,至少我是不后悔的。每座山都有每座山的特色,爬到这时,腿开始抖了,但我可不是那么轻易就能认输的人啊,继续爬,专挑难爬的道:一线天!!!!!
哈哈,说这个像蟒蛇,像吗?
下山时腿疼的不行,扛不住了,嘴不硬了,不去庐山了,武功山了。。。。
南昌
对了,不明白为啥江西彩礼那么高?
广东
广州
从南昌飞到广州了,看了小蛮腰,在附近喝喝茶,遛遛弯,吃点茶点
深圳
到深圳后租了个车溜达到海边吃海鲜,还去华强北也溜一溜,吃了很多粤菜
香港
从深圳坐高铁到香港也就十几分钟,跟快的。香港巴士,香港茶餐厅,路过金店,想买项链的,但又怕弄丢了就没买,现在金价那么高,有点损失。
新疆
从上海飞新疆要4个多小时,一路太无聊了,下飞机后,心情就好很多
乌鲁木齐
去了大巴扎,吃了羊肉串和切糕,还有新疆大盘鸡
无人区
没信号,没水,荒漠一片。。。
伊犁
到了伊犁市区后,去了小吃街,吃了羊肉
赛里木湖
高原湖泊,非常适合自驾游玩,我这里是跟人拼车去的。看着真舒服,可惜我把单反带来,也背不动,这是人家的
边境-国门,果子沟大桥, 薰衣草
新疆白天长,夜里段,到了晚上9点多,天才慢慢开始变黑。
北京
这次我飞到了老北京,看了天安门,看了老城墙
内蒙古
从北京顺路来了内蒙古呼和浩特,先填饱肚了,去那个什么街买了一堆牛肉干
呼和浩特
青甘环线
说到去青甘,想起有个在学生时期就在玩的狐朋狗友,听说我打算去自驾就想跟我一起去。因为我的车是新能源,自驾充电比较麻烦,他打算提混动车方便些,他说让我等他提车带他一起去自驾,本来约定好了时间,到快出发时,一会又说不打算提车了,又说等他面试换好工作后,最后他自己又各种理由怂了,这种又想出去玩,又想挣钱,又不舍得花钱,这种拧巴的状态,我很无语,当然,这也是现实中大部分人的写实吧,这里我想说,做好权衡利弊和取舍就好,既然决定去追求诗和远方,就不要再去跟钱分文必争了,不可否认,旅行确实需要花钱,我们能做的就是按照自己能承担的最低的成本去看世界。人家说勇敢的人先享受世界,让他纠结犹豫去吧,我就先溜了,毕竟老祖宗给的经验是:欲买桂花同载酒,终不似,少年游。再后来,他说他提车了,问我还去不去,我说我早就已经打卡过了。我问他新工作找好了?他说还没有。。。所以他白拧巴了,车还是要提,想去的地方最终还是要去,挣不了的钱最终还是没到口袋里去。毕竟能随时说走就走的同行者只有自己。
我是从内蒙飞到了青海的西宁。
西宁市
填饱肚子先,然后出发去青海湖,远看蓝色,近看青色,全靠天气
青海湖
茶卡盐湖
天空之境,名不虚传。
丹霞地貌,策马奔腾
策马奔腾很潇洒,归来草原上都是马粪,有点臭。。。
仙气飘飘的牦牛,跟川西的大黑牛不一样
后面的敦煌,莫高窟去不了了,青海也是有3000多海拔的,玩嗨了,又又又高反了,不得已要回去了,哎,当了这么多年生产驴,身体熬废了。回去后多锻炼身体吧。毕竟身体是革命的成本。
武汉
于是,先飞回了武汉玩几天。回家转转,熟悉的感觉。喜欢武汉的大江大湖和历史文化。黄鹤一去不复返,白云千载空悠悠。
然后又从武汉飞到上海狗着。
上海市
这个城市充满了魅力。只要你有钱,就可以纸醉金迷,去和平饭店享受,去挥霍。没钱,只能继续搬砖。
回去后改善饮食,一边努力干活学习,一边下定决心锻炼,都有马甲线了,五公里so easy ,哈哈哈哈。每次回到上海这个繁华的国际大都市,我都深深感受到,这座城市虽然压力大,但终究是自由的,没人关心和打扰你的私人生活,你可以为自己而活,安排自己的一生,不必循规蹈矩,不必顾及世俗的眼光,这个城市包容能力很强,不妨大胆一些,追求自己的人生。去不同的城市体验不一样的生活和文化。
总结
在买随心飞之前我也去过很多城市,比如:湖北的荆州,湖南的岳阳,张家界,广东的东莞,广西的桂林和北海,海南的三亚,云南的昆明大理丽江,江浙沪包邮一带的杭州,南京,无锡,湖州,台州,宁波,福建的厦门,河南的洛阳,开封,郑州,信阳,山东青岛,陕西西安,安徽合肥等城市。时间有限,码字不易,很抱歉这里我就不全部列出了。尤其在学生时代,那是真的快乐,没有一丝丝杂念,单纯的快乐。后面打算环游世界了,已经去了东南亚的一些国家,这里我想说我本来就是为了WLB努力的,工作生活两不误,我的旅途未完待续~
回顾这么多年,走过的国内大大小小的城市,也没具体统计过,开始逐渐让自己的眼界开阔起来,不让自己的眼光那么狭隘了,看待任何事物更具包容性吧,以前不理解的东西,现在慢慢理解了。也许人生就是这样,思想和观念一直变化。还是那句话,勇敢的人先享受人生吧,不要辜负努力写代码的自己。
后续
关于有人问我旅游的钱哪来的?我说我钱抢银行来的你信吗?开头已经提到自己已经牛马些年了,不然之前身上也不至于带这么大的班味,而且平时也不是月光族,手里有点小存款算是可以抵御日常的一些风险吧。
旅行中机票费用占大头,不过都在随心飞里头了,真是省了一笔巨款吧!我每次定机票只需要付100元建机燃油费就行(约等于一周的奶茶或者咖啡费,那会的机票费用价格还没有现在高的这么离谱)。酒店也不是住啥五星级酒店,基本上都是找的干净评分比较高的。吃的也不是啥高档餐厅,都是网红性价比高的饭店,全程主打一个性价比,一人吃饱全家不饿。总费用加起来差不多消耗了三个月工资吧,在自己的消费能力范围之内。因为每个人的消费标准和收入不一样,这里就没必要去扣一个死数字了,当然这个消费标准肯定要根据收入水平来的,不建议超额负担消费,我路上碰到过有人住青年旅舍吃泡面都能一路玩的特别开心,也见过有的人开豪车,晚上吃烤全羊喝茅台,一路有专人专车服务着,这一路的所见真的不是在家里坐着就能接触到的。所以,我个人觉得,穷游有穷的开心,富人有富玩的旅途,所以,大家不该纠结比别人多花多少碎银,而是应该多些出发的勇气和和收获快乐。本身我在上学时期就喜欢跟家人一起自驾游,后备箱塞满了干粮,哈哈,就差煤气开火了,那会真的很快乐,有时出去玩坐绿皮车吃泡面都能激动一路,不过工作后,时间不自由了。收获多少快乐跟赚多少钱并不能成正比!
关于时间问题:每年有二十多天假(不包括调休假,另算累加),同时加上换工作GAP,基本上约等于一个缓冲期了,不再像刚毕业一样把自己当牛马使了,可能打工人血脉开始觉醒了,相对自己好点,有时真的,自己想明白,比一直低头苦干重要多了,极端的逼自己很有可能是压死骆驼的最后一根稻草,适当的给自己放个假,反而更容易想明白很多事情,别太喜欢跟自己较真,放过自己,面对生活更从容一点不好吗?
不理解的掘友请绕过,你继续熬夜加你的班走好你的奈何桥,我看我的风景过好我的阳关道,我不需要用别人的执念去过我短暂的一生,我知道自己的人生该是什么样,我为自己而活。
最后,勇敢的人先享受世界!做好取舍就行,至少我已经完成了自己人生的一段旅途!在此做个记录,顺便鼓励迷茫中的“同道中人”!
来源:juejin.cn/post/7351301965034586152
告别频繁登录:教你用Axios实现无感知双Token刷新
一、引言
在现代系统中,Token认证已成为保障用户安全的标准做法。然而,尽管许多系统采用了这种认证方式,却在处理Token刷新方面存在不足,导致用户体验不佳。随着Token有效期的缩短,频繁的重新登录成为常见现象,许多系统未能提供一种无缝的、用户无感知的Token刷新机制。通过结合Vue3和Axios这两大前端技术栈,我们可以借助Promise机制,开发出一种更加完善的自动化Token刷新方案,显著提升系统的稳定性和用户体验。本文将深入探讨这一实现过程,帮助你解决Token刷新难题。
在现代系统中,Token认证已成为保障用户安全的标准做法。然而,尽管许多系统采用了这种认证方式,却在处理Token刷新方面存在不足,导致用户体验不佳。随着Token有效期的缩短,频繁的重新登录成为常见现象,许多系统未能提供一种无缝的、用户无感知的Token刷新机制。通过结合Vue3和Axios这两大前端技术栈,我们可以借助Promise机制,开发出一种更加完善的自动化Token刷新方案,显著提升系统的稳定性和用户体验。本文将深入探讨这一实现过程,帮助你解决Token刷新难题。
二、示意图
三、具体实现
了解了基本步骤后,实际的实现过程其实相当简洁。然而,在具体操作中,仍有许多关键细节需要我们仔细考量,以确保Token刷新机制的稳定性和可靠性。
- Token 存储与管理:首先,明确如何安全地存储和管理Access Token与Refresh Token。这涉及到浏览器的存储策略,比如使用
localStorage
、sessionStorage
,存储策略不在本文中提及,本文采用localStorage 进行存储。 - 请求拦截器的设置:在Axios中设置请求拦截器,用于在每次发送请求前检查Token的有效性。如果发现Token过期,则触发刷新流程。这一步骤需注意避免并发请求引发的重复刷新。
- 处理Token刷新的响应逻辑:当Token过期时,通过发送Refresh Token请求获取新的Access Token。在这里,需要处理刷新失败的情况,如Refresh Token也失效时,如何引导用户重新登录。
- 队列机制的引入:在Token刷新过程中,可能会有多个请求被同时发出。为了避免重复刷新Token,可以引入队列机制,确保在刷新Token期间,其他请求被挂起,直到新的Token可用。
- 错误处理与用户体验:最后,要对整个流程中的错误进行处理,比如刷新失败后的重试逻辑、错误提示信息等,确保用户体验不受影响。
通过以上步骤的实现,你可以构建一个用户无感知、稳定可靠的双Token刷新机制,提升应用的安全性与用户体验。接下来,我们将逐一解析这些关键步骤的具体实现。
了解了基本步骤后,实际的实现过程其实相当简洁。然而,在具体操作中,仍有许多关键细节需要我们仔细考量,以确保Token刷新机制的稳定性和可靠性。
- Token 存储与管理:首先,明确如何安全地存储和管理Access Token与Refresh Token。这涉及到浏览器的存储策略,比如使用
localStorage
、sessionStorage
,存储策略不在本文中提及,本文采用localStorage 进行存储。 - 请求拦截器的设置:在Axios中设置请求拦截器,用于在每次发送请求前检查Token的有效性。如果发现Token过期,则触发刷新流程。这一步骤需注意避免并发请求引发的重复刷新。
- 处理Token刷新的响应逻辑:当Token过期时,通过发送Refresh Token请求获取新的Access Token。在这里,需要处理刷新失败的情况,如Refresh Token也失效时,如何引导用户重新登录。
- 队列机制的引入:在Token刷新过程中,可能会有多个请求被同时发出。为了避免重复刷新Token,可以引入队列机制,确保在刷新Token期间,其他请求被挂起,直到新的Token可用。
- 错误处理与用户体验:最后,要对整个流程中的错误进行处理,比如刷新失败后的重试逻辑、错误提示信息等,确保用户体验不受影响。
通过以上步骤的实现,你可以构建一个用户无感知、稳定可靠的双Token刷新机制,提升应用的安全性与用户体验。接下来,我们将逐一解析这些关键步骤的具体实现。
1. 编写请求拦截器
实现请求拦截器的基本逻辑比较简单,即在每次请求时自动附带上Token以进行认证。
service.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const userStore = useUserStore()
if (userStore.authInfo.accessToken && userStore.authInfo.accessToken !== "") {
// 设置头部 token
config.headers.Authorization = RequestConstant.Header.AuthorizationPrefix + userStore.authInfo.accessToken;
}
return config;
}, (error: any) => {
return Promise.reject(error);
}
);
目前的实现方案是,在请求存在有效Token时,将其附带到请求头中发送给服务器。但在一些特殊情况下,某些请求可能不需要携带Token。为此,我们可以在请求配置中通过config
对象来判断是否需要携带Token。例如:
request: (deptId: number, deptForm: DeptForm): AxiosPromise<void> => {
return request<void>({
url: DeptAPI.UPDATE.endpoint(deptId),
method: "put",
data: deptForm,
headers: {
// 根据需要添加Token,或者通过自定义逻辑决定是否包含Authorization字段
token: false
}
});
}
那么在请求拦截器中,您需要多加一个判断,就是判断请求头中token是否需要
// 代码省略
实现请求拦截器的基本逻辑比较简单,即在每次请求时自动附带上Token以进行认证。
service.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const userStore = useUserStore()
if (userStore.authInfo.accessToken && userStore.authInfo.accessToken !== "") {
// 设置头部 token
config.headers.Authorization = RequestConstant.Header.AuthorizationPrefix + userStore.authInfo.accessToken;
}
return config;
}, (error: any) => {
return Promise.reject(error);
}
);
目前的实现方案是,在请求存在有效Token时,将其附带到请求头中发送给服务器。但在一些特殊情况下,某些请求可能不需要携带Token。为此,我们可以在请求配置中通过config
对象来判断是否需要携带Token。例如:
request: (deptId: number, deptForm: DeptForm): AxiosPromise<void> => {
return request<void>({
url: DeptAPI.UPDATE.endpoint(deptId),
method: "put",
data: deptForm,
headers: {
// 根据需要添加Token,或者通过自定义逻辑决定是否包含Authorization字段
token: false
}
});
}
那么在请求拦截器中,您需要多加一个判断,就是判断请求头中token是否需要
// 代码省略
2. 深究响应拦截器
对于双token刷新的难点就在于响应拦截器中,因为在这里后端会返回token过期的信息。我们需要先清楚后端接口响应内容
对于双token刷新的难点就在于响应拦截器中,因为在这里后端会返回token过期的信息。我们需要先清楚后端接口响应内容
2.1 接口介绍
- 正常接口响应内容
// Status Code: 200 OK
{
"code":"0000",
"msg":"操作成功",
"data":{}
}
- accessToken 过期响应内容
// Status Code: 401 Unauthorized
{
"code":"I009",
"msg":"登录令牌过期"
}
- accessToken 刷新响应内容
// Status Code: 200 OK
{
"code": "0000",
"msg": "操作成功",
"data": {
"accessToken": "",
"refreshToken": "",
"expires": ""
}
}
- refreshToken 过期响应内容
// Status Code: 200 OK
{
"code": "I009",
"msg": "登录令牌过期"
}
注意 : 当Status Code
不是200时,Axios的响应拦截器会自动进入error
方法。在这里,我们可以捕捉到HTTP状态码为401的请求,从而初步判断请求是由于Unauthorized
(未授权)引发的。然而,触发401状态码的原因有很多,不一定都代表Token过期。因此,为了准确判断Token是否真的过期,我们需要进一步检查响应体中的code
字段。
- 正常接口响应内容
// Status Code: 200 OK
{
"code":"0000",
"msg":"操作成功",
"data":{}
}
- accessToken 过期响应内容
// Status Code: 401 Unauthorized
{
"code":"I009",
"msg":"登录令牌过期"
}
- accessToken 刷新响应内容
// Status Code: 200 OK
{
"code": "0000",
"msg": "操作成功",
"data": {
"accessToken": "",
"refreshToken": "",
"expires": ""
}
}
- refreshToken 过期响应内容
// Status Code: 200 OK
{
"code": "I009",
"msg": "登录令牌过期"
}
注意 : 当Status Code
不是200时,Axios的响应拦截器会自动进入error
方法。在这里,我们可以捕捉到HTTP状态码为401的请求,从而初步判断请求是由于Unauthorized
(未授权)引发的。然而,触发401状态码的原因有很多,不一定都代表Token过期。因此,为了准确判断Token是否真的过期,我们需要进一步检查响应体中的code
字段。
2.2 响应拦截器编写
有上面的接口介绍,我们编写的就简单,判断error.response?.status === 401、code === I009 即可,如果出现这种情况就直接刷新token。
service.interceptors.response.use(async (response: AxiosResponse) => {
// 正常请求代码忽略
return Promise.reject(new Error(msg || "Error"));
},
async (error: any) => {
const userStore = useUserStore()
if (error.response?.status === 401) {
if (error.response?.data?.code === RequestConstant.Code.AUTH_TOKEN_EXPIRED) {
// token 过期处理
// 1. 刷新 token
const loginResult: LoginResult = await userStore.refreshToken()
if (loginResult) {
// refreshToken 未过期
// 2.1 重构请求头
error.config.headers.Authorization = RequestConstant.Header.AuthorizationPrefix + userStore.authInfo.accessToken;
// 2.2 请求
return await service.request(error.config);
} else {
// refreshToken 过期
// 1. 重置登录 token , 跳转登录页
await userStore.resetToken()
}
} else {
// 如果是系统发出的401 , 重置登录 token , 跳转登录页
await userStore.resetToken()
}
} else if (error.response?.status === 403) {
// 403 结果处理 , 代码省略
} else {
// 其他错误结果处理 , 代码省略
}
return Promise.reject(error.message);
}
);
有上面的接口介绍,我们编写的就简单,判断error.response?.status === 401、code === I009 即可,如果出现这种情况就直接刷新token。
service.interceptors.response.use(async (response: AxiosResponse) => {
// 正常请求代码忽略
return Promise.reject(new Error(msg || "Error"));
},
async (error: any) => {
const userStore = useUserStore()
if (error.response?.status === 401) {
if (error.response?.data?.code === RequestConstant.Code.AUTH_TOKEN_EXPIRED) {
// token 过期处理
// 1. 刷新 token
const loginResult: LoginResult = await userStore.refreshToken()
if (loginResult) {
// refreshToken 未过期
// 2.1 重构请求头
error.config.headers.Authorization = RequestConstant.Header.AuthorizationPrefix + userStore.authInfo.accessToken;
// 2.2 请求
return await service.request(error.config);
} else {
// refreshToken 过期
// 1. 重置登录 token , 跳转登录页
await userStore.resetToken()
}
} else {
// 如果是系统发出的401 , 重置登录 token , 跳转登录页
await userStore.resetToken()
}
} else if (error.response?.status === 403) {
// 403 结果处理 , 代码省略
} else {
// 其他错误结果处理 , 代码省略
}
return Promise.reject(error.message);
}
);
2.3 解决重复刷新问题
编写完成上面的内容,考虑一下多个请求可能同时遇到 Token 过期,如果没有适当的机制控制,这些请求可能会同时发起刷新 Token 的操作,导致重复请求,甚至可能触发后端的安全机制将这些请求标记为危险操作。
为了解决这个问题,我们实现了一个单例 Promise
的刷新逻辑,通过 singletonRefreshToken
确保在同一时间只有一个请求会发起 Token 刷新操作。其核心思想是让所有需要刷新的请求共享同一个 Promise
,这样即使有多个请求同时遇到 Token 过期,它们也只会等待同一个刷新操作的结果,而不会导致多次刷新。
/**
* 刷新 token
*/
refreshToken(): Promise<LoginResult> {
// 如果 singletonRefreshToken 不为 null 说明已经在刷新中,直接返回
if (singletonRefreshToken !== null) {
return singletonRefreshToken
}
// 设置 singletonRefreshToken 为一个 Promise 对象 , 处理刷新 token 请求
singletonRefreshToken = new Promise<LoginResult>(async (resolve) => {
await AuthAPI.REFRESH.request({
accessToken: this.authInfo.accessToken as string,
refreshToken: this.authInfo.refreshToken as string
}).then(({data}) => {
// 设置刷新后的Token
this.authInfo = data
// 刷新路由
resolve(data)
}).catch(() => {
this.resetToken()
})
})
// 最终将 singletonRefreshToken 设置为 null, 防止 singletonRefreshToken 一直占用
singletonRefreshToken.finally(() => {
singletonRefreshToken = null;
})
return singletonRefreshToken
}
编写完成上面的内容,考虑一下多个请求可能同时遇到 Token 过期,如果没有适当的机制控制,这些请求可能会同时发起刷新 Token 的操作,导致重复请求,甚至可能触发后端的安全机制将这些请求标记为危险操作。
为了解决这个问题,我们实现了一个单例 Promise
的刷新逻辑,通过 singletonRefreshToken
确保在同一时间只有一个请求会发起 Token 刷新操作。其核心思想是让所有需要刷新的请求共享同一个 Promise
,这样即使有多个请求同时遇到 Token 过期,它们也只会等待同一个刷新操作的结果,而不会导致多次刷新。
/**
* 刷新 token
*/
refreshToken(): Promise<LoginResult> {
// 如果 singletonRefreshToken 不为 null 说明已经在刷新中,直接返回
if (singletonRefreshToken !== null) {
return singletonRefreshToken
}
// 设置 singletonRefreshToken 为一个 Promise 对象 , 处理刷新 token 请求
singletonRefreshToken = new Promise<LoginResult>(async (resolve) => {
await AuthAPI.REFRESH.request({
accessToken: this.authInfo.accessToken as string,
refreshToken: this.authInfo.refreshToken as string
}).then(({data}) => {
// 设置刷新后的Token
this.authInfo = data
// 刷新路由
resolve(data)
}).catch(() => {
this.resetToken()
})
})
// 最终将 singletonRefreshToken 设置为 null, 防止 singletonRefreshToken 一直占用
singletonRefreshToken.finally(() => {
singletonRefreshToken = null;
})
return singletonRefreshToken
}
重要点解析:
singletonRefreshToken
的使用:singletonRefreshToken
是一个全局变量,用于保存当前正在进行的刷新操作。如果某个请求发现 singletonRefreshToken
不为 null
,就说明另一个请求已经发起了刷新操作,它只需等待这个操作完成,而不需要自己再发起新的刷新请求。
- 共享同一个
Promise
:- 当
singletonRefreshToken
被赋值为一个新的 Promise
时,所有遇到 Token 过期的请求都会返回这个 Promise
,并等待它的结果。这样就避免了同时发起多个刷新请求。
- 刷新完成后的处理:
- 刷新操作完成后(无论成功与否),都会通过
finally
将 singletonRefreshToken
置为 null
,从而确保下一次 Token 过期时能够重新发起刷新请求。
通过这种机制,我们可以有效地避免重复刷新 Token 的问题,同时也防止了由于过多重复请求而引发的后端安全性问题。这种方法不仅提高了系统的稳定性,还优化了资源使用,确保了用户的请求能够正确地处理。
singletonRefreshToken
的使用:singletonRefreshToken
是一个全局变量,用于保存当前正在进行的刷新操作。如果某个请求发现singletonRefreshToken
不为null
,就说明另一个请求已经发起了刷新操作,它只需等待这个操作完成,而不需要自己再发起新的刷新请求。
- 共享同一个
Promise
:- 当
singletonRefreshToken
被赋值为一个新的Promise
时,所有遇到 Token 过期的请求都会返回这个Promise
,并等待它的结果。这样就避免了同时发起多个刷新请求。
- 当
- 刷新完成后的处理:
- 刷新操作完成后(无论成功与否),都会通过
finally
将singletonRefreshToken
置为null
,从而确保下一次 Token 过期时能够重新发起刷新请求。
- 刷新操作完成后(无论成功与否),都会通过
通过这种机制,我们可以有效地避免重复刷新 Token 的问题,同时也防止了由于过多重复请求而引发的后端安全性问题。这种方法不仅提高了系统的稳定性,还优化了资源使用,确保了用户的请求能够正确地处理。
四、测试
- 当我们携带过期token访问接口,后端就会返回401状态和I009。
这时候进入
const loginResult: LoginResult = await userStore.refreshToken()
- 携带之前过期的accessToken和未过期的refreshToken进行刷新
- 携带过期的accessToken的原因 :
- 防止未过期的 accessToken 进行刷新
- 防止 accessToken 和 refreshToken 不是同一用户发出的
- 其他安全性考虑
- 当我们携带过期token访问接口,后端就会返回401状态和I009。
这时候进入
const loginResult: LoginResult = await userStore.refreshToken()
- 携带之前过期的accessToken和未过期的refreshToken进行刷新
- 防止未过期的 accessToken 进行刷新
- 防止 accessToken 和 refreshToken 不是同一用户发出的
- 其他安全性考虑
- 获取到正常结果
作者:翼飞
来源:juejin.cn/post/7406992576513589286
来源:juejin.cn/post/7406992576513589286