注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

前端中 JS 发起的请求可以暂停吗

web
在前端中,JavaScript(JS)可以使用XMLHttpRequest对象或fetch API来发起网络请求。然而,JavaScript本身并没有提供直接的方法来暂停请求的执行。一旦请求被发送,它会继续执行并等待响应。 尽管如此,你可以通过一些技巧或库来模...
继续阅读 »

在前端中,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);

作者:来点vc
来源:juejin.cn/post/7310786521082560562
收起阅读 »

手撸一个精美简约loading加载功能,再也不怕复杂的网页效果了

web
我来看看怎么个事? 你们还记得自己为什么要做程序员吗?我先来说,就是看见别人有一个精美的网站。但是,现在很多人要么就是后端crud boy,要么就是前端vue渲染数据girl。没有现成的框架,现成的ui组件,就没法写代码了。好看的网页怎么来呢?有人会说是UI设...
继续阅读 »

我来看看怎么个事?


你们还记得自己为什么要做程序员吗?我先来说,就是看见别人有一个精美的网站。但是,现在很多人要么就是后端crud boy,要么就是前端vue渲染数据girl。没有现成的框架,现成的ui组件,就没法写代码了。好看的网页怎么来呢?有人会说是UI设计的,我前端只需要vue渲染数据就行了🤣(今天我们就不探讨后端技术🐶)。久而久之,自己就会慢慢变菜,最后想开发一个项目,发现无从下手,写个页面都费劲!!!所以,还是慢慢做一个全栈,这样既可以写好玩的工具,也可以提高自己的竞争力,强者恒强,没错就是我啦😅


08469aa7b28d505fc4246c429e3806a0.gif


1.loading实际效果图


字不重要,看图👉👉👉👉


pc端


pc.gif


移动端


yidon.gif


2.准备css素材


这种loading的效果,网上有很多网站可以直接diy,几乎没有人手写一个。当然你也可以手写,如果觉得闲的话


317470000b0f2a2d76eba.gif


推荐网站
国内也有很多,我使用的是国外网站(科学上网)


loading.io/#editor


image.png


下载素材推荐svg格式,如果你的svg动图存在背景


image.png


如图,这种背景一定要去掉,给svg设置一个透明度,找到svg文件,background属性,设置rgb(255,255,255,0)就可以了,如下图:


image.png


3.loading隐藏与显示逻辑


思考🤔:


当我们点击按钮的时候,一般会触发请求,比如请求后台数据,这个时候中间就会有加载的样式。


总结就两个条件:


1.按钮要触发点击事件,开启loading效果


2.需要一个事件完成的状态(标记),关闭loading效果


4.编写looding组件,全局注册组件


image.png
<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组件


image.png


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年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.jsRecord.jsPhoneCall.jsPower.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开发,二是满共就给了两天的工时,中间做了大量的测试代码的工作,时间确实有点紧了。所幸最起码的功能没啥问题,也算是交付了。


image.png


第二版


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的样子,基本上该有的功能也基本都已经实现了,美中不足的一点是下面的图标没有找到合适的替换,然后录音上传的功能暂未实现,不过这个也好实现了。


image.png


后面的计划



  • 把图标改好

  • 把录音文件是否已上传、录音上传功能做好

  • 把APP的关于页面加上,对接方法、使用方法和视频、问题咨询等等

  • 原本通话任务、通时通次这些是放在一个PHP后端的,对接较麻烦。要用云函数再实现一遍,然后对外暴露几个接口,这样任何一个系统都可以对接这个APP,而我也可以通过控制云空间的跨域配置来开放权限

  • 把数据留在这边之后,就可以再把uni-admin上加几个页面,并且绑定到阿里云的云函数前端网页托管上去

  • 如果有可能的话,上架应用商店,增加上一些广告或者换量联盟之类的东西

  • 后台运行时,屏幕上加个悬浮图标,来电时能显示个振铃啥的

  • 增加拨号前的校验,对接平台,对于经常拉黑电销的客户号码进行过滤


大致的想法就这些了,如果这个产品能继续卖下去,我就会不断的完善它。


最后


现在的行情真的是不好啊,不知道有没有大哥给个内推的机会,本人大专计算专业、6.5年Vue经验(专精后台管理、监控大屏方向,其他新方向愿意尝试),多个0-1-2项目经验,跨多个领域如人员管理、项目管理、产品设计、软件测试、数据爬虫、NodeJS、流程规范等等方面均有了解,工作稳定不经常跳,求路过的大哥给个内推机会把!



😂被举报标题党了,换个名字。


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

中国研发部门一锅端,IBM程序员的“黑色星期五”

大家好,我是晓凡。 程序员的“黑色星期五” 想象一下,你正坐在办公室,准备享受周末的轻松时刻,突然,你的工作账号被停用了,各种公司相关的权限没了,无法访问公司内网。 这不是电影情节,而是IBM中国研发部门员工的真实遭遇。一夜之间,千余名员工被一锅端。 这件事发...
继续阅读 »

大家好,我是晓凡。


程序员的“黑色星期五”


想象一下,你正坐在办公室,准备享受周末的轻松时刻,突然,你的工作账号被停用了,各种公司相关的权限没了,无法访问公司内网。


这不是电影情节,而是IBM中国研发部门员工的真实遭遇。一夜之间,千余名员工被一锅端。


这件事发生得太突然,几乎没有一点点征兆和信号,看得晓凡是一脸懵逼。


----


IBM裁员:波及千人


裁员,在互联网行业并不是新鲜事。


但IBM这次裁员的规模和速度,着实让人震惊。


据悉,IBM中国在不同区设有多个分公司,据称大约有12000名员工。


被收回权限的员工属于IBMV,下设CDL(IBM中国研发中心)和CSL(IBM中国系统中心),主要负责研发和测试。


波及到了1000+人,遍布北京、上海、大连等各地的员工。赔偿方案为N+3,但具体情况可能更为复杂。


img


我们来看看IBM官方给出的解释


中国的企业,尤其是民营企业,越来越重视抓住混合云和人工智能技术带来的机遇。


因此,IBM 在中国的本地战略重点将转向利用自身在技术和服务方面的丰富经验,组建一支具备相应技能的团队,以更好地与中国客户合作,共同创造符合客户需求的解决方案。


下面是网传的针对此此次裁员3分钟会议纪要


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 中国研发部全体被裁或许也与此有关。


img


多轮裁员,用AI替代近8000人


裁员,往往不是单一因素的结果。IBM的裁员,背后是市场和技术的双重压力。


随着云计算和人工智能的兴起,传统的研发模式正在发生变化。


企业为了追求发展,需要尽可能的压缩成本。说实话,这两年,大家都不好过。


IBM CEO Arvind Krishna在采访中表示,后台职能部门,如人力资源的招聘将暂停或放缓。


未来5年,我们将看到30%的人将被AI和自动化所取代。


IBM中国裁员千余人,AI即将接管8000岗位!


程序员的自救


面对裁员,作为一名普通程序员,我们该怎么做呢?


① 保持良好心态,不要焦虑,不要内卷。真的不是自己不优秀,而是大环境不好。


工作没了,身体也不能跨。只要身体不垮,一切都可以重来。


② 守住自己手里的钱,不要负债,不要负债,不要负债。


正所谓:金库充盈,心绪宁静。即使不幸被裁了,也能靠积蓄养活自己


③ 虽然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个人瓜分。如何能够保证只有3个人能抢到。100个人去抢,相当于就是100个线程去争夺这3个资源,如果处理不好,可能就会发生“超卖”,产生脏数据,威胁系统的正常运行。


当100个人同时去抢,也就是线程1,线程2,线程3...,此时线程1和线程2已经抢到了,就还剩一个红包了,而此时线程3和线程4同时发出抢红包的命令,线程3查询数据库发现还剩1个,抢下成功,而线程3还未修改库存时,线程4也来读取,发现还剩一个,也抢成功。结果这就发生“超卖”,红包被抢了4个,数据库一看红包剩余为-1。


image.png


解决思路


为了保证资源的安全,不能让多个用户同时访问到资源,也就是需要互斥的访问共有资源,同一时刻只能让一个用户访问,也就是给共享资源加上一个悲观锁,只有拿到锁的线程才能正常访问资源,拿不到锁的线程也不能让他一直等着,直接返回用户让他稍后重试。


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);
}

image.png


Redis分布式锁


但当我们使用分布式系统中,一个业务功能会打包部署到多台服务器上,也就是会有多个进程来尝试获取共享资源,本地JVM锁也就无法完成需求了,所以我们需要第三方统一控制资源的分配,也就是分布式锁。


image.png
分布式锁一般一般需要满足四个基本条件:



  1. 互斥:同一时刻,只能有一个线程获取到资源。

  2. 可重入:获取到锁资源后,后续还能继续获取到锁。

  3. 高可用:锁服务一个宕机后还能有另一个接着服务;再者即使发生了错误,一定时间内也能自动释放锁,避免死锁发生。

  4. 非阻塞:如果获取不到锁,不能无限等待。


有关分布式锁的具体实现我之前的文章有讲到Java实现Redis分布式锁 - 掘金 (juejin.cn)


Mysql行锁


再者我们还可以通过Mysql的行锁实现,SELECT...FOR UPDATE,这种方式会将查询时的行锁住,不允许其他事务修改,直到读取完毕。将行锁和修改红包剩余数量放在一个事务中,也能做到互斥。不过这种做法效率较差,不推荐使用。


总结


方案实现举例优点缺点
JVM本地锁synchronized实现简单,性能较好只能在单个 JVM 进程内使用,无法用于分布式环境
Mysql行锁SELECT...FOR UPDATE保证并发情况下的隔离性,避免出现脏数据增加了数据库的开销,特别是在高并发场景下;对应用程序有一定的侵入性,需要在 SQL 语句中正确使用锁定机制。
分布式锁Redis分布式锁可用于分布式,性能较高实现相对复杂,需要考虑锁的续租、释放等问题。

作者:BLACK595
来源:juejin.cn/post/7398038222985543692
收起阅读 »

二维码扫码登录业务详解

二维码扫码登录业务详解 前言 二维码登录 顾名思义 重要是在于登录这俩个字 登录简单点来说可以概括为俩点 告诉系统我是谁 向系统证明我是谁 下面我们就会围绕着这俩点来展开详细说明 原理解析 其实大部分的二维码 都是一个url地址 我们以掘金扫码登录为例来进...
继续阅读 »

二维码扫码登录业务详解


前言


二维码登录 顾名思义 重要是在于登录这俩个字


登录简单点来说可以概括为俩点



  • 告诉系统我是谁

  • 向系统证明我是谁


下面我们就会围绕着这俩点来展开详细说明


原理解析


其实大部分的二维码 都是一个url地址


我们以掘金扫码登录为例来进行剖析


image-20240711145306580


我们进行一个解析


image-20240711145434556


juejin.cn/app?next_ur…


我们可以发现她实际就是这样的一个url


所以说 我们二维码的一个操作 做出来的就是一个url地址


那么我们知道这个后 我们就可以来进行一个流程的解析。


就是一个这样简单的流程


image-20240711150137958


流程概述


简单来说氛围下面的步骤:



  1. PC端:进入二维码登录页面,请求服务端获取二维码的ID。

  2. 服务端:生成二维码ID,并将其与请求的设备绑定后,返回有效的二维码ID。

  3. PC端:根据二维码ID生成二维码图片,并展示出来。

  4. 移动端:扫描二维码,解析出二维码ID。

  5. 移动端:使用移动端的token和二维码ID请求服务端进行登录。

  6. 服务端:解析验证请求,绑定用户信息,并返回给移动端一个用于二次确认的临时token。

  7. PC端:展示二维码为“待确认”状态。

  8. 移动端:使用二维码ID、临时token和移动端的token进行确认登录。

  9. 服务端:验证通过后,修改二维码状态,并返回给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大概


image-20240711151538374


之后当我们扫描后发现已经变成等待确认


image-20240711151702348


当我们确认后 他会返回


image-20240711151803795


和我们说的流程大概的相同


作者:小u
来源:juejin.cn/post/7389952503041884170
收起阅读 »

数据无界,存储有方:MinIO,为极致性能而生!

MinIO: 数据宇宙的超级存储引擎,解锁云原生潜能- 精选真开源,释放新价值。 概览 MinIO,这一高性能分布式对象存储系统的佼佼者,正以开源的力量重塑企业数据存储的版图。它设计轻巧且完全兼容Amazon S3接口,成为连接...
继续阅读 »

MinIO: 数据宇宙的超级存储引擎,解锁云原生潜能- 精选真开源,释放新价值。


image.png


概览


MinIO,这一高性能分布式对象存储系统的佼佼者,正以开源的力量重塑企业数据存储的版图。它设计轻巧且完全兼容Amazon S3接口,成为连接全球开发者与企业的桥梁,提供了一个既强大又灵活的数据管理平台。超越传统存储桶的范畴,MinIO是专为应对大规模数据挑战而生,尤其适用于AI深度学习、大数据分析等高负载场景,其单个对象高达5TB的存储容量设计,确保了数据密集应用运行无阻,流畅高效。


无论是部署于云端、边缘计算环境还是本地服务器,MinIO展现了极高的适配性和灵活性。它以超简单的安装步骤、直观的管理界面以及平滑的扩展能力,极大地降低了企业构建复杂数据架构的门槛。利用MinIO,企业能够快速部署数据湖,以集中存储海量数据,便于分析挖掘;构建高效的内容分发网络(CDN),加速内容的全球分发;或建立可靠的备份存储方案,确保数据资产的安全无虞。


MinIO的特性还包括自动数据分片、跨区域复制、以及强大的数据持久性和高可用性机制,这些都为数据的全天候安全与访问提供了坚实保障。此外,其微服务友好的架构,使得集成到现有的云原生生态系统中变得轻而易举,加速了DevOps流程,提升了整体IT基础设施的响应速度和灵活性。


image.png




主要功能


你可以进入官方网站下载体验min.io/download


image.png



  • 高性能存储



  1. 优化的I/O路径:MinIO通过精心设计的I/O处理逻辑,减少了数据访问的延迟,确保了数据读写操作的高速执行。

  2. 并发设计:支持高并发访问,能够有效利用多核处理器,即使在高负载情况下也能维持稳定的吞吐量,特别适合处理大数据量的读写请求。

  3. 裸机级性能:通过底层硬件的直接访问和资源高效利用,使得在普通服务器上也能达到接近硬件极限的存储性能,为PB级数据的存储与处理提供强大支撑。


image.png



  • 分布式架构



  1. 多节点部署:允许用户根据需求部署多个节点,形成分布式存储集群,横向扩展存储容量和处理能力。

  2. 纠删码技术:采用先进的纠删码(Erasure Coding)代替传统的RAID,即使在部分节点故障的情况下,也能自动恢复数据,确保数据的完整性和服务的连续性,提高了系统的容错能力。

  3. 高可用性与持久性:通过跨节点的数据复制或纠删码,确保数据在不同地理位置的多个副本,即使面临单点故障,也能保证数据的不间断访问,满足严格的SLA要求。


image.png



  • 全面的S3兼容性



  1. 无缝集成:MinIO完全兼容Amazon S3 API,这意味着现有的S3应用程序、工具和库可以直接与MinIO对接,无需修改代码。

  2. 迁移便利:企业可以从AWS S3或任何其他S3兼容服务平滑迁移至MinIO,降低迁移成本,加速云原生应用的部署进程。


image.png



  • 安全与合规



  1. 加密传输:支持SSL/TLS协议,确保数据在传输过程中加密,防止中间人攻击,保障数据通信安全。

  2. 访问控制:提供细粒度的访问控制列表(ACLs)和策略管理,实现用户和群体的权限分配,确保数据访问权限的严格控制。

  3. 审计与日志:记录详细的系统活动日志,便于监控和审计,符合GDPR、HIPAA等国际安全标准和法规要求。


image.png


image.png



  • 简易管理与监控



  1. 直观Web界面:用户可通过Web UI进行集群配置、监控和日常管理,界面友好,操作简便。

  2. Prometheus集成:集成Prometheus监控系统,实现存储集群的实时性能监控和告警通知,帮助管理员及时发现并解决问题,确保系统稳定运行。


image.png




信息


截至发稿概况如下:



语言占比
Go99.0%
Other1.0%


  • 收藏数量:44.3K


面对不断增长的数据管理挑战,MinIO不仅是一个存储解决方案,更是企业在数字化转型旅程中的核心支撑力量,助力各行各业探索数据价值的无限可能。无论是优化存储成本、提升数据处理效率,还是确保数据安全与合规,MinIO持续推动技术边界,邀请每一位技术探索者加入其活跃的开源社区,共同参与讨论,贡献智慧,共同塑造数据存储的未来。


尽管MinIO凭借其卓越的性能与易于使用的特性在存储领域独树一帜,但面对数据量的指数级增长和环境的日益复杂,一系列新挑战浮出水面。首要任务是在海量数据中实现高效的数据索引与查询机制,确保信息的快速提取与分析。其次,在混合云及多云部署的趋势下,如何平滑实现数据在不同平台间的迁移与实时同步,成为提升业务连续性和灵活性的关键。再者,数据安全虽为根基,但在成本控制上也不可忽视,优化存储策略,在强化防护的同时降低开支,实现存储经济性与安全性的完美平衡,是当前亟待探讨与解决的课题。这些问题不仅考验着技术的极限,也为MinIO及其用户社区带来了新的研究方向与实践机遇。


热烈欢迎各位在评论区分享交流心得与见解!!!




作者:辣码甄源
来源:juejin.cn/post/7363570869065498675
收起阅读 »

数字签名 Signature

这一章,我们将简单的介绍以太坊中的数字签名ECDSA,以及如何利用它发放NFT白名单。代码中的ECDSA库由OpenZeppelin的同名库简化而成。 数字签名 如果你用过opensea交易NFT,对签名就不会陌生。下图是小狐狸(metamask)钱包进行签名...
继续阅读 »

这一章,我们将简单的介绍以太坊中的数字签名ECDSA,以及如何利用它发放NFT白名单。代码中的ECDSA库由OpenZeppelin的同名库简化而成。


数字签名


如果你用过opensea交易NFT,对签名就不会陌生。下图是小狐狸(metamask)钱包进行签名时弹出的窗口,它可以证明你拥有私钥的同时不需要对外公布私钥。


截屏2024-06-04 14.35.26.png


以太坊使用的数字签名算法叫双椭圆曲线数字签名算法(ECDSA),基于双椭圆曲线“私钥-公钥”对的数字签名算法。它主要起到了三个作用



  1. 身份认证:证明签名方是私钥的持有人。

  2. 不可否认:发送方不能否认发送过这个消息。

  3. 完整性:消息在传输过程中无法被修改。


ECDSA合约


ECDSA标准中包含两个部分:



  1. 签名者利用私钥(隐私的)对消息(公开的)创建签名(公开的)。

  2. 其他人使用消息(公开的)和签名(公开的)恢复签名者的公钥(公开的)并验证签名。 我们将配合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));
}

截屏2024-06-04 14.42.33.png


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

截屏2024-06-04 14.44.07.png


3-1. 利用钱包签名:  日常操作中,大部分用户都是通过这种方式进行签名。在获取到需要签名的消息之后,我们需要使用metamask钱包进行签名。metamaskpersonal_sign方法会自动把消息转换为以太坊签名消息,然后发起签名。所以我们只需要输入消息签名者钱包account即可。需要注意的是输入的签名者钱包account需要和metamask当前连接的account一致才能使用。


因此首先把例子中的私钥导入到小狐狸钱包,然后打开浏览器的console页面:Chrome菜单-更多工具-开发者工具-Console。在连接钱包的状态下(如连接opensea,否则会出现错误),依次输入以下指令进行签名:


ethereum.enable()
account = "0xe16C1623c1AA7D919cd2241d8b36d9E79C1Be2A2"
hash = "0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c"
ethereum.request({method: "personal_sign", params: [account, hash]})

在返回的结果中(PromisePromiseResult)可以看到创建好的签名。不同账户有不同的私钥,创建的签名值也不同。利用教程的私钥创建的签名如下所示:


0x390d704d7ab732ce034203599ee93dd5d3cb0d4d1d7c600ac11726659489773d559b12d220f99f41d17651b0c1c6a669d346a397f8541760d6b32a5725378b241c

截屏2024-06-04 14.57.06.png


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

截屏2024-06-04 15.09.23.png


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

截屏2024-06-04 15.10.34.png


利用签名发放白名单


NFT项目方可以利用ECDSA的这个特性发放白名单。由于签名是链下的,不需要gas。方法非常简单,项目方利用项目方账户把白名单发放地址签名(可以加上地址可以铸造的tokenId)。然后mint的时候利用ECDSA检验签名是否有效,如果有效,则给他mint


SignatureNFT合约实现了利用签名发放NFT白名单。


状态变量


合约中共有两个状态变量:



  • signer公钥,项目方签名地址。

  • mintedAddress是一个mapping,记录了已经mint过的地址。


函数


合约中共有4个函数:



  • 构造函数初始化NFT的名称和代号,还有ECDSA的签名地址signer

  • mint()函数接受地址addresstokenId_signature三个参数,验证签名是否有效:如果有效,则把tokenIdNFT铸造给address地址,并将它记录到mintedAddress。它调用了getMessageHash()ECDSA.toEthSignedMessageHash()verify()函数。

  • getMessageHash()函数将mint地址(address类型)和tokenIduint256类型)拼成消息

  • 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模式还要经济;

  • 但由于用户要请求中心化接口去获取签名,不可避免的牺牲了一部分去中心化;

  • 额外还有一个好处是白名单可以动态变化,而不是提前写死在合约里面了,因为项目方的中心化后端接口可以接受任何新地址的请求并给予白名单签名。


作者:Subs
来源:juejin.cn/post/7376324160484327424
收起阅读 »

我写了个ffmpeg-spring-boot-starter 使得Java能剪辑视频!!

最近工作中在使用FFmpeg,加上之前写过较多的SpringBoot的Starter,所以干脆再写一个FFmpeg的Starter出来给大家使用。 首先我们来了解一下FFmpeg能干什么,FFmpeg 是一个强大的命令行工具和库集合,用于处理多媒体数据。它可以...
继续阅读 »

最近工作中在使用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()启动外部进程,通过线程分别读取标准输出流和错误输出流,确保命令执行过程中的所有输出都被记录并可被进一步分析。

  • 异常:抛出IOExceptionInterruptedException,需在调用处妥善处理。


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文件长这样:
在这里插入图片描述


后续版本考虑支持



  1. 添加更多丰富的api

  2. 区分win和Linux环境(脚本执行条件不同)

  3. 支持在系统配置环境变量(用户如果没有配置配置文件的ffmpegPath信息可以自动使用环境变量)



在这里插入图片描述



作者:掉头发的王富贵
来源:juejin.cn/post/7391326728461647872
收起阅读 »

Swoole v6 能否让 PHP 再次伟大?

大家好,我是码农先森。 现状 传统的 PHP-FPM 也是多进程模型的的运行方式,但每个进程只能处理完当前请求,才能接收下一个请求。而且对于 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 生态的发展。





欢迎关注、分享、点赞、收藏、在看,我是微信公众号「码农先森」作者。



作者:A码农先森
来源:juejin.cn/post/7384696986845085731
收起阅读 »

微信公众号推送消息笔记

根据业务需要,开发一个微信公众号的相关开发,根据相关开发和整理总结了一下相关的流程和需要,进行一些整理和总结分享给大家,最近都在加班和忙碌,博客已经很久未更新了,打气精神,再接再厉,申请、认证公众号的一系列流程就不在这里赘述了,主要进行的是技术的分享,要达到的...
继续阅读 »

根据业务需要,开发一个微信公众号的相关开发,根据相关开发和整理总结了一下相关的流程和需要,进行一些整理和总结分享给大家,最近都在加班和忙碌,博客已经很久未更新了,打气精神,再接再厉,申请、认证公众号的一系列流程就不在这里赘述了,主要进行的是技术的分享,要达到的效果如下图:


999999.png


开发接入


首先说明我这里用的是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/


作者:stark张宇
来源:juejin.cn/post/7394392321988575247
收起阅读 »

首屏优化之:import 动态导入

web
 前言 前面我们聊过可以通过不同的 script 属性比如 defer,async 来实现不同的加载效果,从而实现首屏优化。 今天我们来聊一下动态导入之 import,当然 import 动态导入也不是一把梭的,也是需要根据项目情况进行使用,在面试以...
继续阅读 »

 前言


前面我们聊过可以通过不同的 script 属性比如 defer,async 来实现不同的加载效果,从而实现首屏优化。


今天我们来聊一下动态导入之 import,当然 import 动态导入也不是一把梭的,也是需要根据项目情况进行使用,在面试以及实际工作当中都可以用到,一起来看看吧!


在了解动态导入之前,我们先来看一下什么是静态导入。 


静态导入


静态导入会在编译时解析所有导入的模块,并在程序开始执行之前加载它们。这意味着所有被导入的模块在应用启动时就已经加载完毕


什么意思,我们先来看一下下面这段代码:


这段代码很简单,我在页面导入了 import.js,当点击按钮时打印输出语句。



我们来看一下浏览器初始化加载情况:



很明显,程序开始执行之前,import.js 就被加载了。


但是在某些时刻,我们不希望文件在没有被使用时就被加载,只希望在使用时加载,这样可以优化首屏的加载速度,这些时刻我们就可以使用动态导入。


动态导入


动态导入是一种在代码执行时按需加载模块的技术,而不是在应用程序初始化时加载所有模块。


默认不会一上来加载所有文件,只会在用到时加载,这样可以优化初始加载时间,提升页面响应速度。


动态导入与静态导入不同,动态导入使用 ES6 中的 import() 语法,可以在函数或代码块中调用,从而实现条件加载、按需加载或延迟加载。例如:


import('./import.js')

 还是上面的代码,我们使用动态导入来进行实现一下:



我们再来看一下浏览器的加载情况:


可以看到一上来并没有加载 import.js



当点击按钮时,才加载了 import.js 文件,这就说明import导入的文件不会一上来就直接被加载,而是在相关代码被执行时才进行加载的。



 一些应用


路由懒加载


在 react 中我们常常使用 lazy 和 Suspense 来实现路由的懒加载,这样做的好处就是初始化时不会一下加载所有的页面,而是当切换到相应页面时才会加载相应的页面,例如:



组件动态导入


 对于一些不常用或者不需要直接加载的组件我们也可以采用动态导入,比如弹出框。


我们只需要在点击时进行加载显示即可。



 分包优化


这里就简单说一下分包的优化,webpack 默认的分包规则有以下三点:



  1. 通过配置多个入口 entry,可以将不同的文件分开打包。

  2. 使用 import() 语法,Webpack 会自动将动态导入的模块放到单独的包中。‘

  3. entry.runtime 单独组织成一个 chunk。


根据第二点,被动态导入的文件会被单独进行打包,不会被分包进同一个文件,也就不会在初始加载 bundle.js 时被一起进行加载。


通过将代码分割成多个小包,可以在用户需要时才加载特定的模块,从而显著减少初始加载的时间。


总结


在进行首屏优化时,可以采取动态导入的方式实现,使用 import('./文件路径')实现,虽然动态导入有一些优化首屏渲染的优势,但是也有一些缺点,比如首次加载延迟,不利于 SEO 优化等,所以在使用动态导入时应该好好进行规划,比如一些不常用的模块或者内容不太复杂,对加载速度无要求的文件可以进行动态导入,这个还是要根据项目的需求来进行使用的。


作者:JacksonChen
来源:juejin.cn/post/7400332893158391819
收起阅读 »

优雅的处理async/await错误

web
async/await使用 async/await 解决了Promise的链式调用(.then)造成的回调地狱,使异步回调变得像同步一样美观! 使用的方式如下: // 异步函数1 let postFun1 = function () { retur...
继续阅读 »

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();

控制台:


async1.jpg


可以看出,控制台没有我们想要打印的信息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();

控制台:


async2.png


可以看出,我们抛出两个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();

控制台:


async3.png



仅仅是两个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
智能驾驶业务里常用web 3d来可视化地图和传感器数据、显示路径规划结果等方法协助算法调试和仿真,可以用threejs来做,毕竟在国内社区相对活跃,也比较容易上手,效果类似下图: 当然以上图片都是客户端的版本,web3d版本的ui其实并不会这么精致,毕竟只...
继续阅读 »

智能驾驶业务里常用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 来实现,它可以和 ExtrudeGeometryShapeGeometry 一起使用来创建二维形状


// 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先到这了,主要是先把项目搭起来,后面会继续分享下更多地图和感知元素以及他车、行人、障碍物等效果的实现



作者:_lucas
来源:juejin.cn/post/7406643531697913867
收起阅读 »

想学 pinia ?一文就够了

web
有时候不得不承认,官方的总结有时就是最精简的: Pinia 是 Vue 的存储库,它允许您跨组件/页面共享状态。 虽然作为Vuex的升级版,但为了尊重原作者,所以取名pinia,而没有取名Vuex,所以大家可以直接将pinia比作为Vue3的Vuex,同时...
继续阅读 »

有时候不得不承认,官方的总结有时就是最精简的:



Pinia 是 Vue 的存储库,它允许您跨组件/页面共享状态。



虽然作为Vuex的升级版,但为了尊重原作者,所以取名pinia,而没有取名Vuex,所以大家可以直接将pinia比作为Vue3的Vuex,同时,pinia提供了一种更简洁、更直观的方式来处理应用程序的状态,更为重要的是,pinia的学习成本更低,低到一篇文章就能涵盖pinia的全部。


Pinia的安装与配置:


首先自然是安装pinia,在基于Vue3的项目环境中,提供了npmyarn两种安装方式:



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提供了四种功能,分别是:



  1. Store:在Pinia中,每个状态管理模块都被称为一个Store。开发者需要创建一个Store实例来定义和管理状态。

  2. State:在Store中定义状态。可以使用defineState函数来定义一个状态,并通过state属性来访问它。

  3. Getters:类似于Vuex中的getters,用于从State中派生出一些状态。可以使用defineGetters函数来定义getters。

  4. Actions:在Pinia中,Actions用于处理异步操作或执行一些副作用。可以使用defineActions函数来定义Actions。


那么接下来我会通过一个具体的实例来表现出这四个功能,如下图:


未命名.jpg


分别是充当仓库的Store功能。存储子组件User.vue中数据的State功能。另一个子组件Update-user.vue中,点击按钮后数据会实现更新,也就是修改State中数据的Actions功能。与无论点击多少次” 经过一年后按钮 ”,页面都会实现同步更新的Getters功能。


State:


简单来说,State的作用就是作为仓库的数据源。


就比如说,我想在仓库的数据源里面放上一个对象来进行使用,那我们只需在先前创建的store文件夹中再创建一个js文件,这里我给它起名为user,然后再其中这样添加对象。


(第一行引入的defineStore代表defineStorestore的一部分。)


import { defineStore } from 'pinia'   // defineStore 是 store 的一部分

export const useUserStore = defineStore({
id: 'user',
state: () => ({ // 仓库数据源
userInfo: {
name: '小明',
age: 18,
sex:'boy'
}
})
})

那么现在,我们想使用仓库中的数据就成为了一件非常容易的事。


正如上图,这里有一个父组件App.vue,两个子组件User.vueUpdate-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>


1724744897493.png


这样一来,依赖于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的功劳!


补充:数据持久化


关于整个项目的功能实现确实已经结束,但人的贪心却是不得满足的,如果我们想要在原有的基础上实现网页刷新数据却不刷新,也就是说数据的持久化,那又该怎么办呢?


未命名3.jpg


很简单,也就是堪堪三步,便能实现。


第一步:安装persist插件。



npm i pinia-plugin-persist



第二步:在storejs文件中引入这个插件。


import { createPinia } from 'pinia'
import piniaPluginPersist from 'pinia-plugin-persist' //引入插件

const store = createPinia()
store.use(piniaPluginPersist) // 使用插件

export default store

第三步:在我们前文user.jsdefineStore库内继续添加上persist功能。


persist: { // 持久化
enabled: true,
strategies: [ // 里面填想要持久化的数据
{
paths: ['userInfo'], // 指明持久化的数据
storage: localStorage // 指明存储
}
]
}

现在可以看到点击按钮后的数据都被存储到浏览器的存储空间中,无论多少次刷新都不会被重置!


1724747584370.png


最后:


至此,这样一个简简单单的项目,却解释清楚了Pinia功能的核心,读完这篇文章,相信每一个学习Pinia的人都能有所收获。


作者:木笙
来源:juejin.cn/post/7407407711879807026
收起阅读 »

你知道为什么template中不用加.value吗?

web
Vue3 中定义的ref类型的变量,在setup中使用这些变量是需要带上.value才可以访问,但是在template中却可以直接使用。 询其原因,可能会说 Vue 自动进行ref解包了,那具体如何实现的呢? proxyRefs Vue3 中有有个方法prox...
继续阅读 »

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类型的普通对象userproxyRefs方法需要满足:



  1. proxyUser直接访问age是可以直接获取到 10 。

  2. 当修改proxyUserage值切这个值不是ref类型时,proxyUser和原数据user都会被修改。

  3. age值被修改为ref类型时,proxyUseruser也会都更新。


实现


既然是访问和修改对象内部的属性值,就可以使用Proxy来处理getset。先来实现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) {},
});
}

从单侧中可以看出,我们是测试了两种情况,一种是修改proxyUserageref类型, 一种是修改成不是ref类型的,但是结果都是同步更新proxyUseruser。那实现上也需要考虑这两种情况,需要判断原数据值是不是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类型的原值赋值为valueref类型值需要.value访问;否则,也就是单测中第 2 个情况,赋值为ref(30),就不需要额外处理,直接赋值即可。


验证


执行单测yarn test ref



作者:wendZzoo
来源:juejin.cn/post/7303435124527333416
收起阅读 »

将html转化成图片

web
如何将指定html内容转化成图片保存?这个问题很值得深思,实际应用中也很有价值。最直接的想法就是使用canvas,熟悉canvas的同学可以尝试一下。这里不做太多的说明,本文采用html2canvas库来实现。 html2canvas库的使用非常简单,只需要...
继续阅读 »

如何将指定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简洁

web
比较好奇vue项目中使用watch还是watchEffect居多,查看了element-plus、ant-design-vue两个UI库, 整体上看,watch使用居多,而watchEffect不怎么受待见,那这两者之间有什么关系? APIwatchwatch...
继续阅读 »

比较好奇vue项目中使用watch还是watchEffect居多,查看了element-plus、ant-design-vue两个UI库, 整体上看,watch使用居多,而watchEffect不怎么受待见,那这两者之间有什么关系?


APIwatchwatchEffectwatchSyncEffectwatchPostEffect
element-plus1982800
ant-design-vue26316800

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;
})

入参的区别,如下表所示:


APIarg1arg2arg3
watchT | WatchSourcecbWatchOptions
watchEffectWatchEffectnullWatchOptionsBase

根据参数对比,先抛出两个问题:


1. doWatch为什么能自动监听WatchEffect函数内的数据变更,并且能重新执行?


2. 第三个参数WatchOptions、WatchOptionBase有什么区别?


watchOptions、WatchOptionBase的定义如下:


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包含prepostsync三个值,缺省为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内部选择合适的时机触发监听器。


image.png


接下来着重看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分别固定为syncpost。所以,watchEffect为watch的衍生,而watchSyncEffect、watchPostEffect为watchEffect的衍生。


对于开发使用上:



  • watchPostEffect、watchSyncEffect仅在极少数的特殊场景下才使用,完全可以用watchEffect(fn, { flush: 'sync' | 'post' })代替,多了反而对入门开发者来说是徒增干扰。

  • 个人认为应优先使用watchEffect函数,毕竟代码写法上更加简洁,属性依赖上也更加明确。满足不了的场景,再考虑使用watch。

作者:前端下饭菜
来源:juejin.cn/post/7401415643981185078
收起阅读 »

vue3为啥推荐使用ref而不是reactive

web
在 Vue 3 中,ref 和 reactive 都是用于声明响应式状态的工具,但它们的使用场景和内部工作机制有所不同。Vue 3 推荐使用 ref 而不是 reactive 的原因主要涉及到以下几个方面: 简单的原始值响应式处理: ref 更适合处理简单...
继续阅读 »

在 Vue 3 中,refreactive 都是用于声明响应式状态的工具,但它们的使用场景和内部工作机制有所不同。Vue 3 推荐使用 ref 而不是 reactive 的原因主要涉及到以下几个方面:



  1. 简单的原始值响应式处理



    • ref 更适合处理简单的原始值(如字符串、数字、布尔值等),而 reactive 更适合处理复杂的对象或数组。



  2. 一致性和解构



    • 使用 ref 时,解构不会丢失响应性,因为 ref 会返回一个包含 .value 属性的对象。而 reactive 对象在解构时会丢失响应性。



  3. 类型推导和代码提示



    • 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
};
}
};

代码解释



  1. 使用 ref



    • ref 返回一个包含 .value 属性的对象,因此在模板中使用时需要通过 .value 访问实际值。

    • 解构时,可以直接解构 .value 属性,不会丢失响应性。



  2. 使用 reactive



    • reactive 适用于复杂的对象或数组,返回一个代理对象。

    • 直接解构 reactive 对象的属性会丢失响应性,因为解构后得到的属性是原始值,不再是响应式的。




总结



  • 简单值:对于简单的原始值(如字符串、数字、布尔值等),推荐使用 ref,因为它更简洁,并且在解构时不会丢失响应性。

  • 复杂对象:对于复杂的对象或数组,推荐使用 reactive,因为它可以更方便地处理嵌套属性的响应性。

  • 一致性ref 在解构时不会丢失响应性,而 reactive 在解构时会丢失响应性,这使得 ref 在某些情况下更为可靠。


通过理解 refreactive 的不同使用场景和内部工作机制,可以更好地选择适合的工具来管理 Vue 3 应用中的响应式状态。


作者:小小小小宇
来源:juejin.cn/post/7402869746175393807
收起阅读 »

Node拒绝当咸鱼,Node 22大进步

web
这几年,deno和bun风头正盛,大有你方唱罢我登场的态势,deno和bun的每一次更新版本,Node都会被拿来比较,比较结果总是Node落后了。 这种比较是不是非常熟悉,就像卖手机的跟iPhone比,卖汽车的跟特斯拉比,比较的时候有时候还得来个「比一分钱硬币...
继续阅读 »

这几年,deno和bun风头正盛,大有你方唱罢我登场的态势,deno和bun的每一次更新版本,Node都会被拿来比较,比较结果总是Node落后了。


这种比较是不是非常熟悉,就像卖手机的跟iPhone比,卖汽车的跟特斯拉比,比较的时候有时候还得来个「比一分钱硬币还薄」的套路。


1.png


Node虽然没有落后了,但是确实有点压力了,所以20和22版本都大跨步前进,拒绝当咸鱼了。


因为Node官网对22版本特性的介绍太过简单,所以我决定来一篇详细介绍新特性的文章,让学习Node的朋友们知道,Node现在在第几层。


首先我把新特性分为两类,分别是:开发者可能直接用到的特性、开发者相对无感知的底层更新。本文重点介绍前者,简单介绍后者。先来一个概览:


开发者可能直接用到的特性:



  1. 支持通过 require() 引入ESM

  2. 运行 package.json 中的脚本

  3. 监视模式(--watch)稳定化

  4. 内置 WebSocket 客户端

  5. 增加流的默认高水位线

  6. 文件模式匹配功能


开发者相对无感知的底层更新:



  1. V8 引擎升级至 12.4 版本

  2. Maglev 编译器默认启用

  3. 改进 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 模块中新增了 globglobSync 函数,它们用于根据指定模式匹配文件路径。


文件模式匹配允许开发者定义一个匹配模式,以找出符合特定规则的文件路径集合。模式定义通常包括通配符,如 *(匹配任何字符)和 ?(匹配单个字符),以及其他特定的模式字符。


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 函数(同步)


globSyncglob 的同步版本,它会阻塞事件循环,直到所有匹配的文件都被找到。这使得代码更简单,但在处理大量文件或在需要高响应性的应用中可能会导致性能问题。其基本用法如下:


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 打败~


3.jpeg


作者:程普
来源:juejin.cn/post/7366185272768036883
收起阅读 »

用了这么久Vue,你用过这几个内置指令提升性能吗?

web
前言 Vue的内置指令估计大家都用过不少,例如v-for、v-if之类的就是最常用的内置指令,但今天给大家介绍几个平时用的比较少的内置指令。毕竟这几个Vue内置指令可用可不用,不用的时候系统正常跑,但在对的地方用了却能提升系统性能,下面将结合示例进行详细说明。...
继续阅读 »

前言


Vue的内置指令估计大家都用过不少,例如v-forv-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>

image.png


注意: 要区分v-prev-once的区别,v-once用于只渲染一次,而v-pre是直接跳过编译。



这个指令可能很多人没想到应用场景有那些,其实最常见的用途就是要在页面上显示Vue代码,如果不用v-pre就会被编译。如下所示使用v-pre场景效果。



<template>
<div>
<pre v-pre>
&lt;template&gt;
&lt;p&gt;{{ message }}&lt;/p&gt;
&lt;/template&gt;

&lt;script setup&gt;
import { ref } from 'vue';

const message = ref('Hello Vue!');
&lt;/script&gt;
</pre>
</div>
</template>

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

let message = ref('您好!');
</script>

页面上展示: 代码原始显示不会被编译。


image.png


三、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 创新巡展(上海站...
继续阅读 »

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火山引擎A...
继续阅读 »

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,邮箱和电话变...
继续阅读 »

事情的起因是这样:



有一个小伙伴说自己用某电动车 App,由于种种原因后来注销了账号,注销完成之后,该 App 提示 “您的账户已删除。与您的账户关联的所有个人数据也已永久删除”。当时当他重新打开 App 之后,发现账户名变为了 unknown,邮箱和电话变成了账号的 uid@delete.account.品牌.com。更炸裂的是,这个 App 此时还是可以正常控制电动车,可以查看定位、电量、客服记录、维修记录等等信息。



小伙伴觉得心塞,感觉被这个 App 耍了,明明就没有删除个人信息,却信誓旦旦的说数据已经永久删除了。


其实咱们做后端服务的小伙伴都知道,基本上都是逻辑删除,很少很少有物理删除。


大部分公司可能都是把账号状态标记为删除,然后踢用户下线;有点良心的公司除了将账号状态标记为删除,还会将用户信息脱敏;神操作公司则把账号状态标记为删除,但是忘记踢用户下线。


于是就出现了咱们小伙伴遇到的场景了。


逻辑删除这事,其实不用看代码,就从商业角度稍微分析就知道不可能是物理删除。比如国内很多 App 对新用户都会送各种优惠券、代金券等等,如果物理删除岂不是意味着可以反复薅平台羊毛。


当然这个是各个厂的实际做法,那么这块有没有相关规定呢?松哥专门去查看了一下相关资料。



根据 GB/T 35273 中的解释,我挑两段给大家看下。


首先文档中解释了什么是删除:



去除用户个人信息的行为,使其保持不可被检索、访问的状态。


理论上来说,逻辑删除也能够实现用户信息不可被检索和访问。


再来看关于用户注销账户的规范:



删除个人信息或者匿名化处理


从这两处解释大家可以看到,平台逻辑删除用户信息从合规上来说没有问题。


甚至可能物理删除了反而有问题。


比如张三注册了一个聊天软件实施诈骗行为,骗到钱了光速注销账号,平台也把张三的信息删除了,最后取证找不到人,在目前这种情况下,平台要不要背锅?如果平台要背锅,那你说平台会不会就真把张三信息给清空了?


对于这个小伙伴的遭遇,其实算是一个系统 BUG,账户注销,应该强制退出登录,退出之后,再想登录肯定就登录不上去了,所以也看不到自己之前的用户信息了。


小伙伴们说说,你们的系统是怎么处理这种场景的呢?


作者:江南一点雨
来源:juejin.cn/post/7407274895929638964
收起阅读 »

【在线聊天室😻】前端进阶全栈开发🔥

web
项目效果登录注册身份认证、私聊、聊天室 项目前端React18仓库:github.com/mcmcCat/mmc…项目后端Nestjs仓库:github.com/mcmcCat/mmc…语雀上的笔记:http://www.yuque.com/maim...
继续阅读 »


项目效果

登录注册身份认证、私聊、聊天室

即时聊天演示.gif 项目前端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中如何进行身份认证?

密码加密 和 生成token

我们可以跟着代码仓库,带有详细的注释,一步步地走
app.service.ts 负责定义注册authRegister和登录authLogin

  • 在注册时,拿到用户输入的密码,使用**bcryptjs.hash()**将其转换为 hash加密字符串,并存入数据库
    image.png
  • 在(身份认证的)登录时,先进行校验用户登录信息是否正确在这里我们使用的是[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项目中,封装路由前置守卫拦截路由跳转,去获取存储在localStoragetoken一样。

在 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.tsapp.useGlobalGuards(new JwtAuthGuard());

Socket.IO如何实现即时聊天?

Nest中WebSocket网关的作用

使用 @WebSocketGateway 装饰器配置 WebSocket 网关在 Nest.js 应用中具有以下作用

  1. 提供 WebSocket 的入口点:WebSocket 网关允许客户端通过 WebSocket 协议与后端建立实时的双向通信。通过配置网关,你可以定义用于处理 WebSocket 连接、消息传递和事件的逻辑。
  2. 处理跨域请求:在 WebSocket 中,默认存在跨域限制,即只能与同源的 WebSocket 服务进行通信。通过设置 origin 选项,WebSocket 网关可以解决跨域请求问题,允许来自指定源的请求进行跨域访问。

关于Socket.IO是怎么通讯的可以看看官网给出的图
image.png
socketIO是通过事件监听的形式,我们可以很清晰的区分出消息的类型,方便对不同类型的消息进行处理,客户端和服务端双方事先约定好不同的事件,事件由谁监听,由谁触发,就可以把各种消息进行有序管理了
image.png
下面是一个简单的通讯事件示例:

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')
}
})

房间模块中的 socket 事件

@SubscribeMessage('sendRoomMessage')
sendRoomMessage(client: Socket, data) {
console.log('服务端接收到了');

// // 将消息发送给指定房间内的所有客户端
this.socketIO.to(roomId).emit('sendRoomMessage', data);
return;
}

在需要发送消息给指定房间时,即我们需要在全局中找到指定房间,所以我们需要整个 WebSocket 服务器的实例

@WebSocketServer()
socketIO: Socket; //它表示整个 WebSocket 服务器的实例。它可以用于执行全局操作,如向所有连接的客户端广播消息或将客户端连接到特定的房间。

加入和退出房间的 socket API

// 加入房间
client.join(roomId);
// 退出房间
client.leave(roomId);

注意这个socket API的作用只是会被用于this.socketIO.to(roomId).emit('sendRoomMessage', data)时的指定to房间去发送信息,而对于房间人员的变动情况得自己准备一个对象来记录,如roomList

踩坑

  1. socket实例的创建写在了函数组件内,useState中的变量的频繁改变,导致的组件不断重新渲染,socket实例也被不断创建,形成过多的连接,让websocket服务崩溃!!!

解决:
把socket实例的创建拿出来放在单独的文件中,这样在各个函数组件中若使用的话只用引用这一共同的socket实例,仅与websocket服务器形成一个连接

  1. 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年提出的黑洞热力学第三定律**相悖。 极端黑洞是一种非常特殊的情况,指黑洞表面或事件视界的引力为零,它的表面...
继续阅读 »

明敏 发自 凹非寺


量子位 | 公众号 QbitAI



霍金50年前提出的猜想被颠覆了!


数学家们最新证明,极端黑洞可能存在


图片


这与霍金等人在1973年提出的黑洞热力学第三定律**相悖。


极端黑洞是一种非常特殊的情况,指黑洞表面或事件视界的引力为零,它的表面不吸引任何东西,但是如果把粒子推出到黑洞中心,还是无法逃逸。


而且由于黑洞的温度与表面重力成正比,表面重力不存在即意味着黑洞没有温度,无法发射热辐射。


这又与霍金辐射理论相违背,该理论提出黑洞不是完全“黑暗”的,而是能以特定方式缓慢向外辐射能量,从而逐渐失去质量并最终可能消失。


但是来自MIT的克里斯托夫·凯勒(Christoph Kehle)和斯坦福大学的瑞安·昂格尔(Ryan Unger)用数学方法证明,这种情况可能存在。


图片


而且它们还证明,极端黑洞存在并不会导致裸奇点存在


诺奖得主彭罗斯**之前提出,自然界不允许裸奇点存在,如果它存在将破坏宇宙因果性,奇点附近的空间区域可能会允许违反因果关系的行为,导致时间和空间在局部变得不再有序。


哥伦比亚大学数学家艾琳娜·乔治(Elena Giorgi)评价:



这是数学回馈物理学一个很棒的例子。



极端黑洞是什么?


自然界中绝大多数黑洞都是旋转的。


当带电荷的物质掉入黑洞后,因为角动量守恒,黑洞自旋转速度会增加,同时黑洞本身也会带上电荷。


理论上,随着黑洞吸入越来越多物质,它的电荷量和转速将会无限大,这样就会出现极端黑洞。


对于极端黑洞,只要再加上任何一点电荷,它的视界就会消失,并留下一个裸奇点。


而且它的表面不再吸引任何东西。


图片


1973年,霍金、约翰·巴丁、布兰登·卡特提出,极端黑洞不可能形成。


这条定律指出黑洞的表面引力不可能在有限时间内降至0,三位科学家认为任何允许黑洞的电荷或自旋达到极限的过程都有可能导致黑洞视界完全消失。


学界普遍认为没有视界的黑洞(即裸奇点)是不可能存在的。


此外,由于黑洞的温度和表面重力呈正比,如果没有表面重力黑洞也不会有温度,这样黑洞就无法发射热辐射。但是霍金提出,向外发出辐射是黑洞的必备属性。


1986年,物理学家沃纳·伊斯雷尔(Werner Israel)曾试图模拟用一个普通黑洞构建极端黑洞,并试着让它自旋更快、带上更多电荷,但最终结论表明,这样做并不能让黑洞的表面重力在有限时间内降低到0。


无心插柳找到证明方法


凯勒和昂格尔本身并没有在研究极端黑洞。


他们是在琢磨带电黑洞如何形成时,意外发现可以构建一个具有极高电荷量的黑洞,这是极端黑洞的一个重要标志。


他们从一个不旋转、没有电荷的黑洞开始,模拟它被放置到标量场中可能发生的情况。


图片


他们利用磁场脉冲冲击黑洞,给它增加电荷。这些脉冲为黑洞提供了电磁能量,也增加了黑洞的质量。


通过发射漫射的低频脉冲,就能让黑洞质量(M)的增速大于电荷(q)的增速。


按照分类,当|q|=M时,代表极端黑洞形成;|q|M时为非极端黑洞。


如果质量增速超过电荷增速,意味着黑洞能从亚极端状态向极端状态转变。


论文不仅提出了一种新的特征粘连方法,而且展示了如何构造黑洞内部的结构、分析了黑洞形成和演化的过程,包括从规则初始数据出发的引力坍缩以及黑洞外部的几何结构等。


不过需要注意的是,尽管利用数学方法证明了极端黑洞理论存在,但是也不能说明极端黑洞一定存在。


理论中的例子具有最大电荷量,但是目前人类还没有观测到明显带有电荷的黑洞。找到一个快速自旋的黑洞更有可能,所以凯勒和昂格尔还想构建一个模型,让黑洞能够在自旋速度上达到极限。


但是构建这样一个模型在数学上的挑战更大。目前他们才刚刚开始着手研究。


一直以来,凯勒和昂格尔都在尝试利用数学方法探索黑洞的秘密。


2023年,凯勒和老师艾琳娜等还通过一项1000页的研究证明,数学意义上,缓慢自旋的黑洞是稳定的。这对于验证广义相对论很重要,因为如果在数学意义上不稳定,那么可能意味着基础理论存在问题。


** **

图片

左为凯勒,右为昂格尔

而今年最新发表的研究,不仅颠覆了霍金提出的猜想,也为广义相对论、量子力学、弦理论等前沿领域研究提供新见解。


参考链接:

http://www.quantamagazine.org/mathematici…


作者:量子位
来源:juejin.cn/post/7407259722430119947
收起阅读 »

前端时间分片渲染

web
在经典的面试题中:”如果后端返回了十万条数据要你插入到页面中,你会怎么处理?” 除了像 useVirtualList 这样的虚拟列表来处理外,我们还可以通过 时间分片 来处理 通过 setTimeout 直接上一个例子: <!-- * @Author:...
继续阅读 »

在经典的面试题中:”如果后端返回了十万条数据要你插入到页面中,你会怎么处理?


除了像 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,在每一次宏任务中插入一页数据,然后设置多个这样地宏任务,直到把所有数据都插入为止。


1111111.webp


但是很明显能看到的问题是,快速拖动滚动条时,数据列表中会有闪烁的情况


这是因为:



当使用 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>

222222.webp


很明显,闪烁的问题被解决了


这是因为:



requestAnimationFrame 会在浏览器每次进行页面渲染时执行回调函数,保证了每次任务的执行间隔是稳定的,避免了丢帧现象。所以在处理大量 DOM 插入操作时,推荐使用 requestAnimationFrame 来拆分任务,以获得更流畅的渲染效果



作者:Jolyne_
来源:juejin.cn/post/7282756858174980132
收起阅读 »

前端:“这需求是认真的吗?” —— el-select 的动态宽度解决方案

web
Hello~大家好。我是秋天的一阵风 ~ 前言 最近我遇到了一个神奇的需求,客户要求对 el-select 的 宽度 进行动态设置。 简单来说,就是我们公司有一些选择框,展示的内容像“中华人民共和国/广西壮族自治区/南宁市/西乡塘区”这么长,一不小心就会内容超...
继续阅读 »

Hello~大家好。我是秋天的一阵风 ~


前言


最近我遇到了一个神奇的需求,客户要求对 el-select宽度 进行动态设置。


简单来说,就是我们公司有一些选择框,展示的内容像“中华人民共和国/广西壮族自治区/南宁市/西乡塘区”这么长,一不小心就会内容超长,显示不全。详情请看下面动图:


1.gif


一般来说,想解决内容展示不全的问题,有几种方法。


第一种:给选择框加个tooltip效果,在鼠标悬浮时展示完整内容。


第二种:对用户选择label值进行切割,只展示最后一层内容。


但是我们的客户对这两种方案都不接受,要求选择的时候让select选择框的宽度动态增加。


有什么办法呢?客户就是上帝,必须满足,他们说什么就是什么,所以我们只能开动脑筋,动手解决。


思路


我们打开控制台,来侦察一下el-select的结构,发现它是一个el-input--suffixdiv包裹着一个input,如下图所示。


image.png

内层input的宽度是100%,外层div的宽度是由这个内层input决定的。也就是说,内层input的宽度如果动态增加,外层div的宽度也会随之增加。那么问题来了,如何将内层input的宽度动态增加呢?



tips:


如果你对width的100%和auto有什么区别感兴趣,可以点击查看我之前的文章


探究 width:100%与width:auto区别



解决方案


为了让我们的el-select宽度能够跟着内容走,我们可以在内层input同级别增加一个元素,内容就是用户选中的内容。内容越多,它就像一个胃口很大的小朋友,把外层div的宽度撑开。下面来看图示例 :


image.png

借助prefix


幸运的是,el-select本身有一个prefix的插槽选项,我们可以借助这个选项实现:


image.png

我们添加一个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>



2.gif

细节调整


现在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;
}

3.gif

调整初始化效果(用户未选择内容)


目前已经基本实现了效果了,还有最后一个问题,当用户没有选择内容的时候,select的宽度是“没有”的,如下图所示。


image.png

所以我们还得给他加上一个最小宽度


image.png

我们加上最小宽度以后,发现这个select的图标又没对齐,这是因为我们在重写.el-input__prefix样式的时候设置了padding: 0 30px,当用户没有选择内容的时候,select的图标应该是默认位置,我们需要继续调整代码,最后效果如下图所示:


4.gif

完整代码


最后附上完整代码:



<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 岁危机时刻提醒着每一位年轻的程序员,这行主打一个精神内耗。 前几年互联网飞速发展高薪招人时,大家...
继续阅读 »

本人计算机硕士毕业,先后在三家厂工作,工作节奏虽说不是 007 吧,但偶尔 996 是有的,勤勤恳恳搬砖是常态,也偶尔累了就划划水摸鱼。在这行业不焦虑是假的,35 岁危机时刻提醒着每一位年轻的程序员,这行主打一个精神内耗


前几年互联网飞速发展高薪招人时,大家都有肉吃,现在遇到互联网寒冬了,有汤喝就不错了,尤其对晚入行的95 后社畜,现在回过头看,已经是互联网红利退潮的末期了。对于 80 后早一批入行的程序员, 肯定钱也挣够了,房子也早就翻几倍了,早就有抗御风险的能力了,即使裁员了也能拿着分手费找个差不多的厂子继续苟着。但是对于 95 后来说,惨不忍睹,行业内卷及其严重,刚有点工作经验就遭遇大规模裁员,重点买房都是踩在最高点接盘,现在房价跌了,车子打价格战,直接把前几年辛辛苦苦挣的首付跌没了,这几年白干了,说起来,心就抽搐的疼。不像人家00 后,直接看开了,不破三个 dai,房贷,车贷和传宗接代,直接卷老家公务员躺平,享受人生,逃离大城市的拥挤,拒绝被房子的套牢。


金融危机,经济下行,行业越来越卷,精神内耗极其严重,身体健康堪忧。我突然顿悟了,我决定,为自己而活。想看世界的心也越来越强烈,最后我坐不住了,做了个大胆的决定,畅游中国。刚好疫情快结束时,航空公司推出了自己的产品,畅游中国随心飞,我立刻入手了,入手价是三千多点,全国飞不限次数。我一边安排好自己的时间订机票,一边计划旅行路线,一个女生独自环游中国之旅开始了。没有队友,不给生活中任何糟心事打断我的计划,一人吃饱,全家不饿,当时就已经下定决心了,哪怕一天就只吃个树上的野果子就好,我也要去看世界,可能喜欢宅的人不太理解,但我明白自己想要什么,我理解自己就好,我并不是给别人活的。


下面给大家说说我去了哪些地方。


贵州-贵阳


我看好时间后立刻定机票,从上海飞到了贵阳,准备打卡黄果树瀑布。我定的酒店就在黄果树景点附近不远,一大清早7点我就起床了,呼吸着让人神清气爽的空气,吃了一些自己带的进口苹果作为早餐,特别甘甜,饱腹感足足的。8点进山了,那一刻,我别提多开心了。


回想起当社畜时,每次都是8.30起床,9.30左右到公司,每天上班心情比上坟都沉重,永远干不完的KPI,OCR,不是被PUA就是吃老板画的大饼,再丰盛的早餐一想到一堆任务要做,吃着也如同嚼蜡,更别提神清气爽,心境开拓了。


在进入黄果树后,我欢快的脚步往前走,因为我是一个女孩子独行,所以不太愿意跟陌生人说话,一路上虽然很沉默,但看到这些壮观的自然景色,闻着草木花果香,内心激动不已。爬了一个钟左右的山,终于看到了大瀑布。


下面是我实拍的景点图:


WechatIMG110.jpg
WechatIMG114.jpg
WechatIMG111.jpg
WechatIMG115.jpg

有句古诗,疑似银河落九天,一路好山好水,逛完黄果树后我出来就去吃了贵州的特色菜,价格美丽,味道很不错,超级喜欢,
当时就在感慨,上海要是能吃到这么好吃又鲜美的酸汤鱼就好了。


WechatIMG109.jpg

重庆


本来下一步去梵净山再顺路去成都自驾318路线的,但时间紧迫,我弟弟在重庆读书,说要跟我一起去自驾318,我就先去重庆跟他汇合了。


1191711609983_.pic.jpg
1211711610320_.pic.jpg
1241711610640_.pic.jpg

最后那个火锅要适度吃啊,吃两顿辣的我的陈年胃病都犯了,好几天没缓过来,哭晕在厕所,我弟跟个没事人一样,这是我深刻认识到当了多年的社畜的后果就是,经常熬夜加班点外卖,把好好的身体给造坏了。重庆的洪崖洞,解放碑也去了很多次了,这里给个图


1201711609995_.pic.jpg

成都


抵达成都,在春熙路逛了逛,宽窄巷子之前逛过就没去了,


1221711610329_.pic.jpg
1461711611961_.pic.jpg

本来想租自驾神车-坦克300的,价格是普通suv的2倍,结果路上纠结一会的功夫就被抢先租走了(自我反思:以后看准就下手吧,人生有几次这种机会,有啥好犹豫的),租了一辆1.5T的大众SUV,跟我弟一起直奔车行,然后去超市采购路上的食物,大包小包买了一堆,放车后备箱,深夜就起航了


都江堰


教科书上的都江堰,真正去看了,才深深佩服古人治水的智慧,我不是文盲,所以不用一句:卧槽,发表感叹。之前也去过洛阳的黄河小浪底水库,武汉的长江大桥,这些水利工程的智慧。


1261711610658_.pic.jpg
1251711610651_.pic.jpg

青城山


1451711611634_.pic.jpg

这里是青城山下白素贞的故事发源地。爬山是个体力活,当时穿着拖鞋就上山了,下山就傻眼了,不好意思,这里我偷懒了,坐缆车下车,嘿嘿。


泸定桥


1281711611190_.pic.jpg
1271711610783_.pic.jpg

打卡泸定桥,走上面摇摇晃晃确实需要一些勇气,特别怕手机掉下去。


海螺沟


一鼓作气,一路直行,抵达海螺沟。来之前,我觉得新能源车咋自驾318,路上看到同样是特斯拉车主,我感觉自己有点狭隘。人啊,果然要多出去看看,不能活在自己的局限认知中。


1301711611238_.pic.jpg
1481711613272_.pic.jpg
1311711611256_.pic.jpg

不过开车还是要小心,路上遇到有车盘山时发生侧翻的。还有山上偶尔会有落石下来,要当心了。


木格措


一路景色壮观,蓝天白云,川西一定要必去。到了康定情歌的原地。打个卡。


1331711611313_.pic.jpg
1471711612939_.pic.jpg

不过我路上听的歌一直都是朴树的《平凡之路》,一路循环:


我曾经跨过山和大海 也穿过人山人海

我曾经拥有着的一切 转眼都飘散如烟

我曾经失落失望 失掉所有方向

直到看见平凡 才是唯一的答案
....


不正是正值青春的我受伤了,但又奋力前行寻找答案吗。


四姑娘山


一路直行。。。抵达四姑娘山,四姑娘山有四座雪山组成,远看景色很壮观,雪已经化了很多。


1291711611214_.pic.jpg
1441711611506_.pic.jpg
1421711611464_.pic.jpg

当地信仰


遇到了一群一动不动的牦牛,还有一匹热情好客的长脸马。拿出来一个饼给它,它吃的还很香。本来开心的事现在记录起来突然感觉在暗示自己在公司当牛做马,不说了,emo了。据说那白色塔这是当地的信仰,表示尊重。


1361711611362_.pic.jpg
1351711611349_.pic.jpg
1391711611410_.pic.jpg

雅拉山口


盘山路,1.5T的车开着有点吃力,油门上不去。终于爬上山了,下车拍照时,激动过头了,开始缺氧,头疼,吸氧。。。。。。。。


1371711611374_.pic.jpg
1381711611398_.pic.jpg
1561711614555_.pic.jpg

后面走着走着身体扛不住了,我去当地买了高反的药,吃了没啥用,氧气越吸头越疼,我弟要回去上课,我身体不抗造,遗憾的半途而归了。再次强调一下,好风景要趁年轻,体力好,等老了走不动了,确实再好的风景,都没那心情和体力去欣赏了。


1551711614521_.pic.jpg
1571711614582_.pic.jpg

乐山


跟我弟散伙后,我自己开车去了乐山大佛,保佑我顺风顺水吧。还去看了东方佛群,卧佛,药师佛,看了各种佛,记不清楚了。。。


1531711614452_.pic.jpg
1601711617453_.pic.jpg
1611711617471_.pic.jpg

峨眉山


接着我自己又自驾去了峨眉山,两个地方相差不是很远,看到了峨眉山的云海,云雾缭绕,超级刺眼!


1521711614435_.pic.jpg
1511711614416_.pic.jpg

下山后当晚接着又踩着点返回成都还车。休息一晚后,又顺路打卡了锦里。感受人世间的烟火和繁华


1591711614637_.pic.jpg
1581711614623_.pic.jpg

又吃了一顿火锅后,回上海。这时,胃没有不舒服,看来,这一圈下来,肠胃好很多了。


1621711617506_.pic.jpg

又回到了我熟悉的大上海。


安徽


经过一段时间的调养后,我觉得的身体状态老好了,爬山那不是小意思,走,爬山去,什么黄山,三清山,庐山,武功山,离沪这么近,爬起来不费劲!我到了安徽省,黄山市,休息一晚准备去爬山。当晚被出租车司机拉到了老街逛逛。


1631711617723_.pic.jpg

就一个小型的徽派建筑青砖白瓦的特色,跟顾村差不多。逛完后突然下起了大雨,我猝不及防没带伞,
就记得那晚的雨,比情深深雨蒙蒙中依萍找她爸要钱被鞭子抽回去时遇到的那场大雨还大。。。。。。


黄山


不凑巧,上山时遇到了大雾,但来都来了,那就爬山下去吧。到了光明顶也啥都看不见,但幸运的时,下山时,守得云开见月明,气喘吁吁的开心拍照。


1641711617970_.pic.jpg
1651711617990_.pic.jpg
1661711618010_.pic.jpg

江西


黄山结束后,顺路就来到了江西,江西景色比较集中,一定要去上饶啊,那就先去望仙谷看看吧。


上饶-望仙谷


人工打造的经典,现实版的仙侠世界。小雨朦胧,青山傍水,景色秀丽。


1681711618049_.pic.jpg
1671711618030_.pic.jpg
1691711618070_.pic.jpg

上饶-三清山


谁说黄山归来不看山,我觉得三清山值得一去,至少我是不后悔的。每座山都有每座山的特色,爬到这时,腿开始抖了,但我可不是那么轻易就能认输的人啊,继续爬,专挑难爬的道:一线天!!!!!


1721711618536_.pic.jpg
1701711618509_.pic.jpg
1711711618524_.pic.jpg

哈哈,说这个像蟒蛇,像吗?


1731711618548_.pic.jpg

下山时腿疼的不行,扛不住了,嘴不硬了,不去庐山了,武功山了。。。。


南昌


对了,不明白为啥江西彩礼那么高?


1741711619525_.pic.jpg

广东


广州


从南昌飞到广州了,看了小蛮腰,在附近喝喝茶,遛遛弯,吃点茶点


1751711619548_.pic.jpg
1761711619562_.pic.jpg

深圳


到深圳后租了个车溜达到海边吃海鲜,还去华强北也溜一溜,吃了很多粤菜


2121711623487_.pic.jpg
2131711623507_.pic.jpg
2141711623518_.pic.jpg

香港


从深圳坐高铁到香港也就十几分钟,跟快的。香港巴士,香港茶餐厅,路过金店,想买项链的,但又怕弄丢了就没买,现在金价那么高,有点损失。


2171711623568_.pic.jpg
2181711623590_.pic.jpg
2161711623554_.pic.jpg
2151711623538_.pic.jpg

新疆


从上海飞新疆要4个多小时,一路太无聊了,下飞机后,心情就好很多


1781711619752_.pic.jpg

乌鲁木齐


去了大巴扎,吃了羊肉串和切糕,还有新疆大盘鸡


1911711619947_.pic.jpg
1921711619959_.pic.jpg
1931711619971_.pic.jpg
1951711620020_.pic.jpg

无人区


没信号,没水,荒漠一片。。。


1901711619929_.pic.jpg
1891711619916_.pic.jpg

伊犁


到了伊犁市区后,去了小吃街,吃了羊肉


1961711620038_.pic.jpg

赛里木湖


高原湖泊,非常适合自驾游玩,我这里是跟人拼车去的。看着真舒服,可惜我把单反带来,也背不动,这是人家的


1851711619856_.pic.jpg
1841711619835_.pic.jpg
1801711619776_.pic.jpg
1831711619820_.pic.jpg
1811711619793_.pic.jpg
1821711619807_.pic.jpg

边境-国门,果子沟大桥, 薰衣草


1881711619895_.pic.jpg
1871711619882_.pic.jpg
1861711619867_.pic.jpg

新疆白天长,夜里段,到了晚上9点多,天才慢慢开始变黑。


北京


这次我飞到了老北京,看了天安门,看了老城墙


1971711620074_.pic.jpg
1981711620099_.pic.jpg

内蒙古


从北京顺路来了内蒙古呼和浩特,先填饱肚了,去那个什么街买了一堆牛肉干


呼和浩特


1991711623013_.pic.jpg
2011711623052_.pic.jpg
2021711623101_.pic.jpg

青甘环线


说到去青甘,想起有个在学生时期就在玩的狐朋狗友,听说我打算去自驾就想跟我一起去。因为我的车是新能源,自驾充电比较麻烦,他打算提混动车方便些,他说让我等他提车带他一起去自驾,本来约定好了时间,到快出发时,一会又说不打算提车了,又说等他面试换好工作后,最后他自己又各种理由怂了,这种又想出去玩,又想挣钱,又不舍得花钱,这种拧巴的状态,我很无语,当然,这也是现实中大部分人的写实吧,这里我想说,做好权衡利弊和取舍就好,既然决定去追求诗和远方,就不要再去跟钱分文必争了,不可否认,旅行确实需要花钱,我们能做的就是按照自己能承担的最低的成本去看世界。人家说勇敢的人先享受世界,让他纠结犹豫去吧,我就先溜了,毕竟老祖宗给的经验是:欲买桂花同载酒,终不似,少年游。再后来,他说他提车了,问我还去不去,我说我早就已经打卡过了。我问他新工作找好了?他说还没有。。。所以他白拧巴了,车还是要提,想去的地方最终还是要去,挣不了的钱最终还是没到口袋里去。毕竟能随时说走就走的同行者只有自己。


我是从内蒙飞到了青海的西宁。


西宁市


填饱肚子先,然后出发去青海湖,远看蓝色,近看青色,全靠天气


2031711623308_.pic.jpg
2351711690717_.pic.jpg

青海湖


2091711623426_.pic.jpg
2051711623359_.pic.jpg

茶卡盐湖


天空之境,名不虚传。


2061711623379_.pic.jpg
2081711623412_.pic.jpg
2361711690772_.pic.jpg

丹霞地貌,策马奔腾


2111711623453_.pic.jpg
2101711623438_.pic.jpg

策马奔腾很潇洒,归来草原上都是马粪,有点臭。。。
仙气飘飘的牦牛,跟川西的大黑牛不一样
2231711624048_.pic.jpg
2041711623341_.pic.jpg


后面的敦煌,莫高窟去不了了,青海也是有3000多海拔的,玩嗨了,又又又高反了,不得已要回去了,哎,当了这么多年生产驴,身体熬废了。回去后多锻炼身体吧。毕竟身体是革命的成本。


武汉


于是,先飞回了武汉玩几天。回家转转,熟悉的感觉。喜欢武汉的大江大湖和历史文化。黄鹤一去不复返,白云千载空悠悠。
然后又从武汉飞到上海狗着。


2241711624590_.pic.jpg
2261711624823_.pic.jpg
2251711624807_.pic.jpg

上海市


这个城市充满了魅力。只要你有钱,就可以纸醉金迷,去和平饭店享受,去挥霍。没钱,只能继续搬砖。


2291711625200_.pic.jpg
2301711625213_.pic.jpg
2271711624846_.pic.jpg

回去后改善饮食,一边努力干活学习,一边下定决心锻炼,都有马甲线了,五公里so easy ,哈哈哈哈。每次回到上海这个繁华的国际大都市,我都深深感受到,这座城市虽然压力大,但终究是自由的,没人关心和打扰你的私人生活,你可以为自己而活,安排自己的一生,不必循规蹈矩,不必顾及世俗的眼光,这个城市包容能力很强,不妨大胆一些,追求自己的人生。去不同的城市体验不一样的生活和文化。




总结


在买随心飞之前我也去过很多城市,比如:湖北的荆州,湖南的岳阳,张家界,广东的东莞,广西的桂林和北海,海南的三亚,云南的昆明大理丽江,江浙沪包邮一带的杭州,南京,无锡,湖州,台州,宁波,福建的厦门,河南的洛阳,开封,郑州,信阳,山东青岛,陕西西安,安徽合肥等城市。时间有限,码字不易,很抱歉这里我就不全部列出了。尤其在学生时代,那是真的快乐,没有一丝丝杂念,单纯的快乐。后面打算环游世界了,已经去了东南亚的一些国家,这里我想说我本来就是为了WLB努力的,工作生活两不误,我的旅途未完待续~


回顾这么多年,走过的国内大大小小的城市,也没具体统计过,开始逐渐让自己的眼界开阔起来,不让自己的眼光那么狭隘了,看待任何事物更具包容性吧,以前不理解的东西,现在慢慢理解了。也许人生就是这样,思想和观念一直变化。还是那句话,勇敢的人先享受人生吧,不要辜负努力写代码的自己。




后续


关于有人问我旅游的钱哪来的?我说我钱抢银行来的你信吗?开头已经提到自己已经牛马些年了,不然之前身上也不至于带这么大的班味,而且平时也不是月光族,手里有点小存款算是可以抵御日常的一些风险吧。


旅行中机票费用占大头,不过都在随心飞里头了,真是省了一笔巨款吧!我每次定机票只需要付100元建机燃油费就行(约等于一周的奶茶或者咖啡费,那会的机票费用价格还没有现在高的这么离谱)。酒店也不是住啥五星级酒店,基本上都是找的干净评分比较高的。吃的也不是啥高档餐厅,都是网红性价比高的饭店,全程主打一个性价比,一人吃饱全家不饿。总费用加起来差不多消耗了三个月工资吧,在自己的消费能力范围之内。因为每个人的消费标准和收入不一样,这里就没必要去扣一个死数字了,当然这个消费标准肯定要根据收入水平来的,不建议超额负担消费,我路上碰到过有人住青年旅舍吃泡面都能一路玩的特别开心,也见过有的人开豪车,晚上吃烤全羊喝茅台,一路有专人专车服务着,这一路的所见真的不是在家里坐着就能接触到的。所以,我个人觉得,穷游有穷的开心,富人有富玩的旅途,所以,大家不该纠结比别人多花多少碎银,而是应该多些出发的勇气和和收获快乐。本身我在上学时期就喜欢跟家人一起自驾游,后备箱塞满了干粮,哈哈,就差煤气开火了,那会真的很快乐,有时出去玩坐绿皮车吃泡面都能激动一路,不过工作后,时间不自由了。收获多少快乐跟赚多少钱并不能成正比!


关于时间问题:每年有二十多天假(不包括调休假,另算累加),同时加上换工作GAP,基本上约等于一个缓冲期了,不再像刚毕业一样把自己当牛马使了,可能打工人血脉开始觉醒了,相对自己好点,有时真的,自己想明白,比一直低头苦干重要多了,极端的逼自己很有可能是压死骆驼的最后一根稻草,适当的给自己放个假,反而更容易想明白很多事情,别太喜欢跟自己较真,放过自己,面对生活更从容一点不好吗?


不理解的掘友请绕过,你继续熬夜加你的班走好你的奈何桥,我看我的风景过好我的阳关道,我不需要用别人的执念去过我短暂的一生,我知道自己的人生该是什么样,我为自己而活。


最后,勇敢的人先享受世界!做好取舍就行,至少我已经完成了自己人生的一段旅途!在此做个记录,顺便鼓励迷茫中的“同道中人”!


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

告别频繁登录:教你用Axios实现无感知双Token刷新

web
一、引言在现代系统中,Token认证已成为保障用户安全的标准做法。然而,尽管许多系统采用了这种认证方式,却在处理Token刷新方面存在不足,导致用户体验不佳。随着Token有效期的缩短,频繁的重新登录成为常见现象,许多系统未能提供一种无缝的、用户无感知的Tok...
继续阅读 »

一、引言

在现代系统中,Token认证已成为保障用户安全的标准做法。然而,尽管许多系统采用了这种认证方式,却在处理Token刷新方面存在不足,导致用户体验不佳。随着Token有效期的缩短,频繁的重新登录成为常见现象,许多系统未能提供一种无缝的、用户无感知的Token刷新机制。通过结合Vue3和Axios这两大前端技术栈,我们可以借助Promise机制,开发出一种更加完善的自动化Token刷新方案,显著提升系统的稳定性和用户体验。本文将深入探讨这一实现过程,帮助你解决Token刷新难题。

二、示意图

image.png

三、具体实现

了解了基本步骤后,实际的实现过程其实相当简洁。然而,在具体操作中,仍有许多关键细节需要我们仔细考量,以确保Token刷新机制的稳定性和可靠性。

  1. Token 存储与管理:首先,明确如何安全地存储和管理Access Token与Refresh Token。这涉及到浏览器的存储策略,比如使用localStoragesessionStorage,存储策略不在本文中提及,本文采用localStorage 进行存储。
  2. 请求拦截器的设置:在Axios中设置请求拦截器,用于在每次发送请求前检查Token的有效性。如果发现Token过期,则触发刷新流程。这一步骤需注意避免并发请求引发的重复刷新。
  3. 处理Token刷新的响应逻辑:当Token过期时,通过发送Refresh Token请求获取新的Access Token。在这里,需要处理刷新失败的情况,如Refresh Token也失效时,如何引导用户重新登录。
  4. 队列机制的引入:在Token刷新过程中,可能会有多个请求被同时发出。为了避免重复刷新Token,可以引入队列机制,确保在刷新Token期间,其他请求被挂起,直到新的Token可用。
  5. 错误处理与用户体验:最后,要对整个流程中的错误进行处理,比如刷新失败后的重试逻辑、错误提示信息等,确保用户体验不受影响。

通过以上步骤的实现,你可以构建一个用户无感知、稳定可靠的双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是否需要

// 代码省略

2. 深究响应拦截器

对于双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字段。

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);
}
);

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
}

重要点解析:

  1. singletonRefreshToken 的使用

    • singletonRefreshToken 是一个全局变量,用于保存当前正在进行的刷新操作。如果某个请求发现 singletonRefreshToken 不为 null,就说明另一个请求已经发起了刷新操作,它只需等待这个操作完成,而不需要自己再发起新的刷新请求。
  2. 共享同一个 Promise

    • 当 singletonRefreshToken 被赋值为一个新的 Promise 时,所有遇到 Token 过期的请求都会返回这个 Promise,并等待它的结果。这样就避免了同时发起多个刷新请求。
  3. 刷新完成后的处理

    • 刷新操作完成后(无论成功与否),都会通过 finally 将 singletonRefreshToken 置为 null,从而确保下一次 Token 过期时能够重新发起刷新请求。

通过这种机制,我们可以有效地避免重复刷新 Token 的问题,同时也防止了由于过多重复请求而引发的后端安全性问题。这种方法不仅提高了系统的稳定性,还优化了资源使用,确保了用户的请求能够正确地处理。

四、测试

  1. 当我们携带过期token访问接口,后端就会返回401状态和I009。

image.png

这时候进入

const loginResult: LoginResult = await userStore.refreshToken()
  1. 携带之前过期的accessToken和未过期的refreshToken进行刷新
  • 携带过期的accessToken的原因 :
    • 防止未过期的 accessToken 进行刷新
    • 防止 accessToken 和 refreshToken 不是同一用户发出的
    • 其他安全性考虑

image.png

  1. 获取到正常结果

image.png


作者:翼飞
来源:juejin.cn/post/7406992576513589286

收起阅读 »

前端到底该如何安全的实现“记住密码”?

web
在 web 应用里,“记住密码”这个小小的功能,可是咱用户的贴心小棉袄啊,用起来超级方便!但话说回来,咱们得怎样做才能既让用户享受这便利,又能牢牢护住他们的数据安全呢?这可得好好琢磨一番哦!接下来,咱们就来聊聊,有哪些靠谱的方法能实现“记住密码”这个功能,而且...
继续阅读 »

web 应用里,“记住密码”这个小小的功能,可是咱用户的贴心小棉袄啊,用起来超级方便!但话说回来,咱们得怎样做才能既让用户享受这便利,又能牢牢护住他们的数据安全呢?这可得好好琢磨一番哦!接下来,咱们就来聊聊,有哪些靠谱的方法能实现“记住密码”这个功能,而且安全性也是杠杠的!


1. 使用 localStorage


localStorage 是一种持久化存储方式,数据在浏览器关闭后仍然存在。适用于需要长期保存的数据。


示例代码


// 生成对称密钥
async function generateSymmetricKey() {
const key = await crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 256,
},
true,
["encrypt", "decrypt"]
);
return key;
}

// 加密数据
async function encryptData(data, key) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encryptedData = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: iv,
},
key,
new TextEncoder().encode(data)
);
return { iv, encryptedData };
}

// 解密数据
async function decryptData(encryptedData, key, iv) {
const decryptedData = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: iv,
},
key,
encryptedData
);
return new TextDecoder().decode(decryptedData);
}

// 保存用户信息
async function saveUserInfo(username, password) {
const key = await generateSymmetricKey();
const { iv, encryptedData } = await encryptData(password, key);
localStorage.setItem('username', username);
localStorage.setItem('password', JSON.stringify({ iv, encryptedData }));
// 密钥可以存储在更安全的地方,如服务器端
}

// 获取用户信息
async function getUserInfo() {
const username = localStorage.getItem('username');
const { iv, encryptedData } = JSON.parse(localStorage.getItem('password'));
const key = await generateSymmetricKey(); // 这里应使用同一个密钥
const password = await decryptData(encryptedData, key, iv);
return { username, password };
}

// 示例:用户登录时调用
async function login(username, password, rememberMe) {
if (rememberMe) {
await saveUserInfo(username, password);
}
// 其他登录逻辑
}

// 示例:页面加载时自动填充
window.onload = async function() {
const userInfo = await getUserInfo();
if (userInfo.username && userInfo.password) {
document.getElementById('username').value = userInfo.username;
document.getElementById('password').value = userInfo.password;
}
};

2. 使用 sessionStorage


sessionStorage 是一种会话级别的存储方式,数据在浏览器关闭后会被清除。适用于需要临时保存的数据。


示例代码


// 保存用户信息
async function saveUserInfoSession(username, password) {
const key = await generateSymmetricKey();
const { iv, encryptedData } = await encryptData(password, key);
sessionStorage.setItem('username', username);
sessionStorage.setItem('password', JSON.stringify({ iv, encryptedData }));
// 密钥可以存储在更安全的地方,如服务器端
}

// 获取用户信息
async function getUserInfoSession() {
const username = sessionStorage.getItem('username');
const { iv, encryptedData } = JSON.parse(sessionStorage.getItem('password'));
const key = await generateSymmetricKey(); // 这里应使用同一个密钥
const password = await decryptData(encryptedData, key, iv);
return { username, password };
}

// 示例:用户登录时调用
async function loginSession(username, password, rememberMe) {
if (rememberMe) {
await saveUserInfoSession(username, password);
}
// 其他登录逻辑
}

// 示例:页面加载时自动填充
window.onload = async function() {
const userInfo = await getUserInfoSession();
if (userInfo.username && userInfo.password) {
document.getElementById('username').value = userInfo.username;
document.getElementById('password').value = userInfo.password;
}
};

3. 使用 IndexedDB


IndexedDB 是一种更为复杂和强大的存储方式,适用于需要存储大量数据的场景。


示例代码


// 打开数据库
function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('UserDatabase', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
db.createObjectStore('users', { keyPath: 'username' });
};
request.onsuccess = (event) => {
resolve(event.target.result);
};
request.onerror = (event) => {
reject(event.target.error);
};
});
}

// 保存用户信息
async function saveUserInfoIndexedDB(username, password) {
const key = await generateSymmetricKey();
const { iv, encryptedData } = await encryptData(password, key);
const db = await openDatabase();
const transaction = db.transaction('users', 'readwrite');
const store = transaction.objectStore('users');
store.put({ username, iv, encryptedData });
// 密钥可以存储在更安全的地方,如服务器端
}

// 获取用户信息
async function getUserInfoIndexedDB(username) {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction('users', 'readonly');
const store = transaction.objectStore('users');
const request = store.get(username);
request.onsuccess = async (event) => {
const result = event.target.result;
if (result) {
const key = await generateSymmetricKey(); // 这里应使用同一个密钥
const password = await decryptData(result.encryptedData, key, result.iv);
resolve({ username: result.username, password });
} else {
resolve(null);
}
};
request.onerror = (event) => {
reject(event.target.error);
};
});
}

// 示例:用户登录时调用
async function loginIndexedDB(username, password, rememberMe) {
if (rememberMe) {
await saveUserInfoIndexedDB(username, password);
}
// 其他登录逻辑
}

// 示例:页面加载时自动填充
window.onload = async function() {
const username = 'exampleUsername'; // 从某处获取用户名
const userInfo = await getUserInfoIndexedDB(username);
if (userInfo && userInfo.username && userInfo.password) {
document.getElementById('username').value = userInfo.username;
document.getElementById('password').value = userInfo.password;
}
};

4. 使用 Cookie


Cookie 是一种简单的存储方式,适用于需要在客户端和服务器之间传递少量数据的场景。需要注意的是,Cookie 的安全性较低,建议结合 HTTPS 和 HttpOnly 属性使用。


示例代码


// 设置 Cookie
function setCookie(name, value, days) {
const date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
const expires = "expires=" + date.toUTCString();
document.cookie = name + "=" + value + ";" + expires + ";path=/";
}

// 获取 Cookie
function getCookie(name) {
const nameEQ = name + "=";
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
}
return null;
}

// 保存用户信息
async function saveUserInfoCookie(username, password) {
const key = await generateSymmetricKey();
const { iv, encryptedData } = await encryptData(password, key);
setCookie('username', username, 7);
setCookie('password', JSON.stringify({ iv, encryptedData }), 7);
// 密钥可以存储在更安全的地方,如服务器端
}

// 获取用户信息
async function getUserInfoCookie() {
const username = getCookie('username');
const { iv, encryptedData } = JSON.parse(getCookie('password'));
const key = await generateSymmetricKey(); // 这里应使用同一个密钥
const password = await decryptData(encryptedData, key, iv);
return { username, password };
}

// 示例:用户登录时调用
async function loginCookie(username, password, rememberMe) {
if (rememberMe) {
await saveUserInfoCookie(username, password);
}
// 其他登录逻辑
}

// 示例:页面加载时自动填充
window.onload = async function() {
const userInfo = await getUserInfoCookie();
if (userInfo.username && userInfo.password) {
document.getElementById('username').value = userInfo.username;
document.getElementById('password').value = userInfo.password;
}
};

5. 使用 JWT(JSON Web Token)


JWT 是一种常用的身份验证机制,特别适合在前后端分离的应用中使用。JWT 可以安全地传递用户身份信息,并且可以在客户端存储以实现“记住密码”功能。


示例代码


服务器端生成 JWT


假设你使用 Node.js 和 Express 作为服务器端框架,并使用 jsonwebtoken 库来生成和验证 JWT。


const express = require('express');
const jwt = require('jsonwebtoken');
const bodyParser = require('body-parser');

const app = express();
app.use(bodyParser.json());

const SECRET_KEY = 'your_secret_key';

// 用户登录接口
app.post('/login', (req, res) => {
const { username, password } = req.body;
// 验证用户名和密码
if (username === 'user' && password === 'password') {
// 生成 JWT
const token = jwt.sign({ username }, SECRET_KEY, { expiresIn: '1h' });
res.json({ token });
} else {
res.status(401).json({ message: 'Invalid credentials' });
}
});

// 受保护的资源
app.get('/protected', (req, res) => {
const token = req.headers['authorization'];
if (!token) {
return res.status(401).json({ message: 'No token provided' });
}
jwt.verify(token, SECRET_KEY, (err, decoded) => {
if (err) {
return res.status(401).json({ message: 'Failed to authenticate token' });
}
res.json({ message: 'Protected resource', user: decoded.username });
});
});

app.listen(3000, () => {
console.log('Server is running on port 3000');
});

客户端存储和使用 JWT


在客户端,可以使用 localStoragesessionStorage 来存储 JWT,并在后续请求中使用。


// 用户登录
async function login(username, password, rememberMe) {
const response = await fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok) {
const token = data.token;
if (rememberMe) {
localStorage.setItem('token', token);
} else {
sessionStorage.setItem('token', token);
}
} else {
console.error(data.message);
}
}

// 获取受保护的资源
async function getProtectedResource() {
const token = localStorage.getItem('token') || sessionStorage.getItem('token');
if (!token) {
console.error('No token found');
return;
}
const response = await fetch('/protected', {
method: 'GET',
headers: {
'Authorization': token
}
});
const data = await response.json();
if (response.ok) {
console.log(data);
} else {
console.error(data.message);
}
}

// 示例:用户登录时调用
document.getElementById('loginButton').addEventListener('click', async () => {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const rememberMe = document.getElementById('rememberMe').checked;
await login(username, password, rememberMe);
});

// 示例:页面加载时自动填充
window.onload = async function() {
await getProtectedResource();
};

总结


如上示例,展示了如何使用 localStoragesessionStorage、IndexedDB、Cookie 和 JWT 来实现“记住密码”功能。每种方式都有其适用场景和安全考虑,大家可以根据具体需求选择合适的实现方式。


欢迎在评论区留言讨论~
Happy coding! 🚀


作者:我是若尘
来源:juejin.cn/post/7397284874652942363
收起阅读 »

uniapp 地图如何添加?你要的教程来喽!

web
地图在 app 中使用还是很广泛的,常见的应用常见有: 1、获取自己的位置,规划路线。 2、使用标记点进行标记多个位置。 3、绘制多边形,使用围墙标记位置等等。 此篇文章就以高德地图为例,以上述三个常见需求为例,教大家如何在 uniapp 中添加地图。 作为一...
继续阅读 »

地图在 app 中使用还是很广泛的,常见的应用常见有:


1、获取自己的位置,规划路线。


2、使用标记点进行标记多个位置。


3、绘制多边形,使用围墙标记位置等等。


此篇文章就以高德地图为例,以上述三个常见需求为例,教大家如何在 uniapp 中添加地图。


作为一个不管闲事的前端姑娘,我就忽略掉那些繁琐的账号申请,假设需要的信息问项目经理都要来了,如果你没有现成的信息,还需要申请,请查看:


lbs.amap.com/api/javascr…


去高德地图注册账号,根据官网指示获取 key。然后就正式开始前端 uniapp + 高德地图之旅啦!


一、地图配置


在使用地图之前需要配置一下你的地图账号信息,找到项目中的 manifest.json 文件,打开 web 配置,如图:


图片


此处是针对 h5 端,如果我们要打包 安卓和 IOS app 需要配置对应的key信息,如图:


图片


如果这些信息没有人给你提供,就需要自己去官网注册账号实名认证获取。


二、地图使用


2.1、使用标记点进行标记多个位置,具体效果图如下:


图片


<template>
<view class="map-con">
<map style="width: 100%; height: 300px;"
:latitude="latitude"
:longitude="longitude"
:markers="covers"
:scale="12">

</map>
</view>
</template>

<script>
export default {
data() {
return {
longitude: '116.473115',
latitude: '39.993207',
covers: [{
id: 1,
longitude: "116.474595",
latitude: "40.001321",
iconPath: '/static/images/point.png',
},
{
id: 1,
longitude: "116.274595",
latitude: "40.101321",
iconPath: '/static/images/point.png',
},
{
id: 1,
longitude: "116.374595",
latitude: "40.101321",
iconPath: '/static/images/point.png',
},
{
id: 1,
longitude: "116.374595",
latitude: "40.011321",
width: 44,
height: 50,
iconPath:'/static/images/point.png',
}
]
}
}
}
</script>

注意:


看着代码很简单,运行在 h5 之后一切正常,但是运行在安卓模拟器的时候,发现自定义图标没有起作用,显示的是默认标记点。


图片


iconpath 的路径不是相对路径,没有 ../../ 这些,直接根据官网提示写图片路径,虽然模拟器不显示但是真机是正常的。


2.2、绘制多边形,使用围墙标记位置等等。


图片


<template>
<view class="map-con">
<map style="width: 100%; height: 400px;" :latitude="latitude" :longitude="longitude" :scale="11"
:polygons="polygon" :markers="covers">

</map>
</view>
</template>

<script>
export default {
data() {
return {
longitude: '116.304595',
latitude: '40.053207',
polygon: [{
fillColor: '#f00',
strokeColor: '#0f0',
strokeWidth: 3,
points: [{
latitude: '40.001321',
longitude: '116.304595'
},
{
latitude: '40.101321',
longitude: '116.274595'
},
{
latitude: '40.011321',
longitude: '116.374595'
}
]
}],
covers: [{
id: 1,
width: 30,
height: 33,
longitude: "116.314595",
latitude: "40.021321",
iconPath: '/static/images/point.png',
}, ]
}
}
}
</script>

更多样式配置我们去参考官网,官网使用文档写的很细致,地址为:


uniapp 官网:uniapp.dcloud.net.cn/component/m…


三、易错点


1、地图已经显示了,误以为地图未展示


图片


左下角有高德地图标识,就说明地图已经正常显示了,此时可以使用鼠标进行缩放,或设置地图的缩放比例或者修改下地图中心点的经纬度。


2、标记点自定义图标不显示


marker 中的 iconPath 设置标记点的图标路径,可以使用相对路径、base64 等,但是在 h5 查看正常,app 打包之后就不能正常显示了,务必参考官网。


3、uni.getLocation 无法触发


在调试模式中,调用 uni.getLocation 无法触发,其中的 success fail complete 都无法执行,不调用的原因是必须在 https 环境下,所以先保证是在 https 环境下。****


四、有可用插件吗?


uniapp 插件:ext.dcloud.net.cn/search?q=ma…


搜索地图插件的时候,插件挺多的,有免费的也有付费的,即使使用插件也是需要需要注册第三方地图账号的。


我个人认为 uniapp 已经将第三方地图封装过了,使用挺便捷的,具体是否使用插件就根据项目实际情况定。


作者:前端人_倩倩
来源:juejin.cn/post/7271942371637559348
收起阅读 »

告别轮询,SSE 流式传输可太香了!

web
今天想和大家分享的一个技术是 SSE 流式传输 。如标题所言,通过 SSE 流式传输的方式可以让我们不再通过轮询的方式获取服务端返回的结果,进而提升前端页面的性能。 对于需要轮询的业务场景来说,采用 SSE 确实是一个更好的技术方案。 接下来,我将从 SSE ...
继续阅读 »

今天想和大家分享的一个技术是 SSE 流式传输 。如标题所言,通过 SSE 流式传输的方式可以让我们不再通过轮询的方式获取服务端返回的结果,进而提升前端页面的性能。


对于需要轮询的业务场景来说,采用 SSE 确实是一个更好的技术方案。


接下来,我将从 SSE 的概念、与 Websocket 对比、SSE 应用场景多个方面介绍 SSE 流式传输,感兴趣的同学一起来了解下吧!


什么是 SSE 流式传输


SSE 全称为 Server-sent events , 是一种基于 HTTP 协议的通信技术,允许服务器主动向客户端(通常是Web浏览器)发送更新。


它是 HTML5 标准的一部分,设计初衷是用来建立一个单向的服务器到客户端连接,使得服务器可以实时地向客户端发送数据。


这种服务端实时向客户端发送数据的传输方式,其实就是流式传输。


我们在与 ChatGPT 交互时,可以发现 ChatGPT 的响应总是间断完成。细扒 ChatGPT 的网络传输模式,可以发现,用的也是流式传输。


图片


SSE 流式传输的好处


在 SSE 技术出现之前,我们习惯把需要等待服务端返回的过程称为长轮询。


长轮询的实现其实也是借助 http 请求来完成,一个完整的长轮询过程如下图所示:


图片


从图中可以发现,长轮询最大的弊端是当服务端响应请求之前,客户端发送的所有请求都不会被受理。并且服务端发送响应的前提是客户端发起请求。


前后端通信过程中,我们常采用 ajax 、axios 来异步获取结果,这个过程,其实也是长轮询的过程。


而同为采用 http 协议通信方式的 SSE 流式传输,相比于长轮询模式来说,优势在于可以在不需要客户端介入的情况下,多次向客户端发送响应,直至客户端关闭连接。


这对于需要服务端实时推送内容至客户端的场景可方便太多了!


SSE 技术原理


1. 参数设置

前文说到,SSE 本质是一个基于 http 协议的通信技术。


因此想要使用 SSE 技术构建需要服务器实时推送信息到客户端的连接,只需要将传统的 http 响应头的 contentType 设置为 text/event-stream 。


并且为了保证客户端展示的是最新数据,需要将 Cache-Control 设置为 no-cache 。


在此基础上,SSE 本质是一个 TCP 连接,因此为了保证 SSE 的持续开启,需要将 Connection 设置为 keep-alive 。


Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

完成了上述响应头的设置后,我们可以编写一个基于 SSE 流式传输的简单 Demo 。


2. SSE Demo

服务端代码:


const express = require('express');
const app = express();
const PORT = 3000;

app.use(express.static('public'));

app.get('/events'function(req, res) {
    res.setHeader('Content-Type''text/event-stream');
    res.setHeader('Cache-Control''no-cache');
    res.setHeader('Connection''keep-alive');

    let startTime = Date.now();

    const sendEvent = () => {
        // 检查是否已经发送了10秒
        if (Date.now() - startTime >= 10000) {
            res.write('event: close\ndata: {}\n\n'); // 发送一个特殊事件通知客户端关闭
            res.end(); // 关闭连接
            return;
        }

        const data = { message'Hello World'timestampnew Date() };
        res.write(`data: ${JSON.stringify(data)}\n\n`);

        // 每隔2秒发送一次消息
        setTimeout(sendEvent, 2000);
    };

    sendEvent();
});

app.listen(PORT() => {
    console.log(`Server running on http://localhost:${PORT}`);
});

客户端代码:


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

<head>
    <meta charset="UTF-8">
    <title>SSE Example</title>
</head>

<body>
    <h1>Server-Sent Events Example</h1>
    <div id="messages"></div>

    <script>
        const evtSource = new EventSource('/events');
        const messages = document.getElementById('messages');

        evtSource.onmessage = function(event) {
            const newElement = document.createElement("p");
            const eventObject = JSON.parse(event.data);
            newElement.textContent = "Message: " + eventObject.message + " at " + eventObject.timestamp;
            messages.appendChild(newElement);
        };
    
</script>
</body>
</html>

当我们在浏览器中访问运行在 localhost: 3000 端口的客户端页面时,页面将会以 流式模式 逐步渲染服务端返回的结果:


图片


需要注意的是,为了保证使用 SSE 通信协议传输的数据能被客户端正确的接收,服务端和客户端在发送数据和接收数据应该遵循以下规范:


服务端基本响应格式

SSE 响应主要由一系列以两个换行符分隔的事件组成。每个事件可以包含以下字段:


data:事件的数据。如果数据跨越多行,每行都应该以data:开始。
id:事件的唯一标识符。客户端可以使用这个ID来恢复事件流。
event:自定义事件类型。客户端可以根据不同的事件类型来执行不同的操作。
retry:建议的重新连接时间(毫秒)。如果连接中断,客户端将等待这段时间后尝试重新连接。

字段之间用单个换行符分隔,而事件之间用两个换行符分隔。


客户端处理格式

客户端使用 EventSource 接口监听 SSE 消息:


const evtSource = new EventSource('path/to/sse');
evtSource.onmessage = function(event) {
    console.log(event.data); // 处理收到的数据
};

SSE 应用场景


SSE 作为基于 http 协议由服务端向客户端单向推送消息的通信技术,对于需要服务端主动推送消息的场景来说,是非常适合的:


图片


SSE 兼容性


图片


可以发现,除了 IE 和低版本的主流浏览器,目前市面上绝大多数浏览器都支持 SSE 通信。


SSE 与 WebSocket 对比


看完 SSE 的使用方式后,细心的同学应该发现了:


SSE 的通信方式和 WebSocket 很像啊,而且 WebSocket 还支持双向通信,为什么不直接使用 WebSocket ?


下表展示了两者之间的对比:


特性/因素SSEWebSockets
协议基于HTTP,使用标准HTTP连接单独的协议(ws:// 或 wss://),需要握手升级
通信方式单向通信(服务器到客户端)全双工通信
数据格式文本(UTF-8编码)文本或二进制
重连机制浏览器自动重连需要手动实现重连机制
实时性高(适合频繁更新的场景)非常高(适合高度交互的实时应用)
浏览器支持良好(大多数现代浏览器支持)非常好(几乎所有现代浏览器支持)
适用场景实时通知、新闻feed、股票价格等需要从服务器推送到客户端的场景在线游戏、聊天应用、实时交互应用
复杂性较低,易于实现和维护较高,需要处理连接的建立、维护和断开
兼容性和可用性基于HTTP,更容易通过各种中间件和防火墙可能需要配置服务器和网络设备以支持WebSocket
服务器负载适合较低频率的数据更新适合高频率消息和高度交互的场景

可以发现,SSE 与 WebSocket 各有优缺点,对于需要客户端与服务端高频交互的场景,WebSocket 确实更适合;但对于只需要服务端单向数据传输的场景,SSE 确实能耗更低,且不需要客户端感知


参考文档


developer.mozilla.org/zh-CN/docs/…


作者:veneno
来源:juejin.cn/post/7355666189475954725
收起阅读 »

高德地图 JS API key 的保护,安全密钥的使用方案

背景 因为高德地图的 key 被盗用,导致额度不耗尽。增加了不必要的成本,所以对 key 的保护尤为重要。 目前情况 现在项目中使用高德地图是直接将 key 写在代码中。 在调用高德 api 的时候,key 会明文拼接在请求地址上,因此会被别有用心的人利用。...
继续阅读 »

背景


因为高德地图的 key 被盗用,导致额度不耗尽。增加了不必要的成本,所以对 key 的保护尤为重要。


目前情况


现在项目中使用高德地图是直接将 key 写在代码中。
carbon.png


在调用高德 api 的时候,key 会明文拼接在请求地址上,因此会被别有用心的人利用。


解决方案


业务运营多年,高德地图的 key 已是多年前创建的,所以第一步就是创建一个新的 key。
Snipaste_2024-08-20_15-50-12.png


明文密钥配合域名白名单


2021年12月02日以后创建的 key 需要配合安全密钥一起使用,而且添加了域名白名单配置。
Snipaste_2024-08-20_15-50-48.png
项目代码做个简单的修改即可:
carbon.png
如果在域名白名单中的调用接口能正常使用,如域名不在白名单中,则提示没有权限。
Snipaste_2024-08-20_11-55-32.png
从此看已经起到了限制作用,但实际是防君子不防小人的方案。不建议在生产环境使用,至于原因,你琢磨琢磨。


代理转发请求


因为需要 key 需要配合安全密钥一起使用,不然就会提示没有权限,所以只需要将安全密钥“隐藏”起来就可以了。
Snipaste_2024-08-20_16-03-27.png
请求会将 key 和安全密钥明文拼接在一起,为了将安全密钥“隐藏”起来,只需要将请求代理到自己的服务器上,然后在服务器上将安全密钥拼接上。


以 Nginx 为例:
carbon.png


项目代码配置代理地址即可:
carbon.png


到处,完美收官。


后记


个人项目,可以随意玩耍。公司项目凡是涉及到钱财的东西都要谨慎一些,不要低估灰产的能力。


作者:前端提桶人
来源:juejin.cn/post/7405777954516025370
收起阅读 »

整理最近的生活

web
Hi,见到你真好 :)写在开始自从去年 8月 搬到 上海 之后,就很少再写文章,很多的思路都是断断续续,导致也不知道该写点什么,所以最后是是草稿攒了一堆,大纲整整齐齐,内容空空如也,甚是尴尬。时间长了后,逐渐就有点开摆的心态。也有点理解为什么很多技术同学突然就...
继续阅读 »

Hi,见到你真好 :)

写在开始

自从去年 8月 搬到 上海 之后,就很少再写文章,很多的思路都是断断续续,导致也不知道该写点什么,所以最后是是草稿攒了一堆,大纲整整齐齐,内容空空如也,甚是尴尬。

时间长了后,逐渐就有点开摆的心态。也有点理解为什么很多技术同学突然就不更新了。🫨

不过最近好在有些念头又开始跃跃欲试,故想着写一些东西,活跃活跃生锈的脑子🫡。

故本篇,其实算是一个随记,想到哪里就写到哪里,不包含任何技术指南。

搬家三两事

背景

因为当时是需要从北京搬到上海,家里还有两只猫以及超级多的行李,故需要考虑的事情有下面几点

  1. 琐碎的行李怎么处理?
  2. 如何将 两只猫 安全的送到上海?
  3. 升降桌、工学椅、冰柜、猫厕所等大件怎么处理?

头脑风暴

  1. 两只猫托运走

    成本过高,一只平均需要 1000+ ,以及需要疫苗齐全,以及需要提前7天以上准备。

  2. 升降桌、工学椅、冰柜出二手

    出二手 = 5折以下,最主要是刚买没半年。😑

  3. 行李快递走?

    货拉拉跨城搬运+快递一部分。

上述总费用: 托运🐱2000 + 二手折损(3000) + 货拉拉(4000+) + 动态费用1000 = 9500 左右。

备注点:

  1. 托运猫的安全性;
  2. 如果喊货拉拉,那就不需要二手回收;

算完上述费用之后,我忍不住拍了一下家里两只,嘴里嚷嚷着:要不是你两,我何至于如此!!!

最终解决

小红书约了一个跨城搬家师傅,车型依维柯(长4.9,宽1.9,高2.2),最后只花了 3500 解决了。

关于爱好的倒腾

世界奇奇怪怪,生活慢慢悠悠。

有时候会想,人的一生难得有几个爱好,那可能就会包含折腾 电子小垃圾:)

下面列一下今年折腾过的一些小垃圾:

  • Pixel 7 绿色
  • 戴尔 U2723QX
  • ikbc 高达97键盘
  • ipadAir M1丐版
  • PS5 国行双手柄Slim
  • Studio Display 带升降
  • 富士 XT-3、35-1.4镜头
  • MacMini M1 16+256 丐版

关于相机

一直以来,其实都比较喜欢富士相机的直出,主要因为自己较懒。所以对于学习修图,实在是提不起感觉,而对于摄像的技巧,也只是草草了解几个构图方式,也谈不上研究,故富士就比较适合我这种[懒人]。

但其实这个事情的背景是,在最开始接触相机时,大概是21年,那时想买富士 xs-10,结果因为疫情缺货,国内溢价到了1w,属实是离谱。所以当时就买了佳能 M6 Mark2

等过了半年左右,觉得佳能差点意思,就又换了索尼的 A7C,对焦嘎嘎猛,主打的就是一个激情拍摄,后期就是套个滤镜就行🫡。

等新手福利期一过,时间线再往后推半年,相机开始吃土,遂出了二手👀。

来上海后,又想起了相机,故在闲鱼上又收了一个富士XT3,属于和xs20同配置(少几个滤镜),主打的就是一个拍到就是赚到(当然现在也是吃土🤡)。

关于PS5

因为21年淘过一个 PS4 Pro ,平时也是处于吃灰,玩的最多的反而是 双人成行(现在也没和老婆打完,80%)😬。

但抵不住冲动的心,没事就会看几眼 PS5 ,为此,老婆专门买了一个,以解我没事的念想。

不过真得知快递信息后,还是心里有点不舍。就以家里还有一个在吃土,买这个没啥用为由退掉🫨。

抵得过暂时,抵不过长久,过了一段时间,念头又上来了,没事又翻起了pdd和二手鱼。

于是,在一个风和日丽的中午,激情下单。

结果卖家的名字居然和我只有一字之差,真的是造化弄人。🤡

到手之后,每周的愿望就是能在周末打一会游戏,结果很难有实现过,唯一一次畅玩 [潜水员戴夫🐟],结果导致身心疲惫。

ps: 截止写完这篇时候,最近在爽玩黑神话悟空,故真正实现了使用。

最近抽空在打大镖客2和黑悟空,遂带上几张图。

IMG_8222

image-20240825173156146

关于小主机

之前因为只有一个笔记本,上下班都需要背笔记本,遇到冬天还好,到了夏天,就非常反感,故最近诞生出了买一个 小主机 的想法。

因为不玩pc游戏,故 winodws 系天然不用选择,当然也就不用考虑同事推荐的 零刻 这种小主机,直接去搞一个 mac mini 即可。

最后综合对比了一下,觉得 Mac Mini(M1-16/256) 即可,闲鱼只需要3000左右即可。

对 M1 及以后的Mac设备而言,RAM>CPU>固态 ,故 cpu 的性能对我而言足矣。

故而以 16g 作为标准,而存储方面,可以考虑外接固态做扩展盘解决,这套方案是性价比最高的。🫡

有了上面的结论之后,就直接去闲鱼收,最后 3100 收了一个 MacMini M1/16g

然后又在京东买了一个 硬盘盒子(海康威视20Gbps)+雷神固态(PR5000/读写4.5k)。

本来想去买一个 40Gbps 的硬盘盒,结果一看价,399起步,有点夸张(我盘才4xx),遂放弃。

Tips:

  1. 不要轻易把系统装到扩展固态(特别是硬盘盒速度不够40Gbps时)上。
  • 开机引导会变慢(如果一直不关机当我没说);
  • 如果硬盘散热跟不上,系统会卡顿,如同ANR了一样;
  1. 因为 MacMini 雷电口 不支持 usb3.2 Gen2x2,在不满足雷电口的情况下,最快读写 只有1k。故导致硬盘速度只能到达 1k 读写,也就是硬盘盒的一半速度

最后全家福如下:

image.png

关于显示器

最近一年连着用过了 3 个显示器,价位一路从 1K -> 1.2W,也算又多了一点点经验分享。

先说背景:因为没有游戏与高刷需求,更多的是追求接近Mac体验(用同事话说就是,被果子惯得),故下面的评测仅限于特定范围。

本次的参照物:MacBook Pro14寸自带的 XDR显示器、峰值亮度 1600nt

Redmi 27/4k (1.5k)

HDR400、65W Typc、95%P3、E<2

全功能typc、支持kvm,色彩模式有forMac。

塑料机身,但设计挺好看的,观感不错,for Mac模式 能接近 70% 体验;

仔细对比,与mac屏幕差距最大的是通透度与色准问题,如果说MacBook是 100% 通透,红米只有 60-70% 之间;

后期偶尔会出现连接不稳定,屏幕闪烁问题,时好时不好,猜测可能与 typc65w 电压不稳有关。

综合来说,这个价位,性价比非常之高

戴尔U2723QX (3k)

HDR400、90W Typc、98%P3

全功能typc、支持kvm、接口大满贯,多到发指。

全金属机身,屏幕是 IPS Black ,也就是 LG 的 NanoIPS 面板,不过也算是一块老屏幕了。

整体体验下来不错,有接近MacBook 80% 的体验,色准什么的都很ok,在使用过程中,也没遇见过任何问题。

综合来说,算是 普通消费级代码显示器的王者 ,不过如果要提性价比,可能不如同款屏幕的 LG。

至于戴尔的售后,因为没体验过,所以难评。

Studio Display (1.2w)

5k分辨率、600nits、A13、六扬声器、4麦克风、自带摄像头

接口方面:雷电3(40G) + 3 x typc(10G)

Mac系列的最佳搭配,最接近 MacBook 的色准,非常通透,95%+ 的接近水平,亮度差点,不过已经够了;

整体来说,如果对屏幕要求,或者眼睛比较敏感,那么这个显示器是比较不错的选择(别忘了开原彩+自动亮度,看习惯了后,还是很舒服)。

至于不足之处,可能就只有价格这一个因素。

家用设备的倒腾

来上海之后,家庭设备倒腾的比较少,少有的几个物件是:

  • 小米除湿机 22L;
  • 追觅洗地机 H30Mix;
  • 小米55寸 MiniLed 电视;

关于除湿机

当时刚来上海,作为一个土生土长的北方人,遇到黄梅天,那感觉,简直浑身难受,一个字 黏,两个字 闷热

故当时紧急下单了一款除湿机,一晚上可以除满满一桶,实测大概4-5L,最高档开1小时左右,家里基本就可以感受到干爽,比较适合40m的小家使用。再说说缺点:

  • 吵!
  • 如果没有接下水管,可能需要隔两天换一次水;

再说说现状,成功实现 100% 吃土状态,几乎毫无悬念,因为空调更省事。。。

最后给一些北方人首次去南方居住的建议:

  • 不要选3层以下,太潮;
  • 注意白天看房,看看光线如何;
  • 注意附近切记不是建筑工地等等;
  • 上海合租比较便宜,自如挺合适;

关于洗地机

刚到上海时,之前的 米家扫拖机器人 也一起带过来了,但因为实在 版本太旧,逐渐不堪大用 ,只能用勉强可用这个词来形容,而且特别容易 积攒头发加手动加水清洁 ,时间久了,就比较烦。

故按照我的性格,没事就在看新的替代物件,最开始锁定的是追觅扫拖机器人,但最后经过深思熟虑,觉得家里太小(60m) ,故扫地机器人根本转不开腿,可能咣咣撞椅子了,故退而求其次,去看洗地机。入手了追觅的 H30mix洗地吸尘都能干

最后经过实际证明:洗地机就老老实实洗地,吸尘还是交给专门的吸尘器,主要是拆卸太麻烦🤡。故家里本来已经半个身子准备退休的德尔玛又被强行续命了一波。

再说优点,真的很好用,拖完地放架子上自动洗烘一体,非常方便。实测拖的比我干净,唯一缺点就是,每天洗完需要手动倒一下脏水(不倒可能会反味)。

关于电视

来上海后,一直想换个电视打游戏用,就没事看了看电视。因为之前的 Tcl 邮给了岳父老房子里,于是按耐不住的心又开始躁动了,故某个夜晚就看了下电视,遂对比后下单了小米的55寸 miniLed。考虑到家里地方不是很大,故也顺带卖了一个可移动的支架。

现在电视的价格是真的便宜,Miniled 都被小米干到了 2k 附近,但一分钱一分货,纸面参数终究是纸面参数,最后看看实际观感,也就那样。

image.png

关于一些想法

人生不过几十载,如果工作要占用掉最宝贵的20年华,那未免太过于糟糕。

不知为何,最近总感觉上班的时间过得尤为快,每周过了周二后,周五的下一步就又要到了,下个月初也越来越近了。

来上海后,几乎每天都会和老婆晚上下楼走走,近的时候绕着小区,远的时候绕着小区外面的路。起初刚来上海时,脑子里依然会有过几年会北京的想法,但在上海有段时间后,这个想法就变得没那么重了,直到现在,我两都变成了留在上海也许更好(仔细算了算)😐。

写在最后

兜兜转转,这篇也是写了近一个月,属于是想起来写一点,接下来会更新的频繁一点。

下次再见,朋友们 👋

关于我

我是 Petterp ,一个 Android 工程师。如果本文,你觉得写的还不错,不妨点个赞或者收藏,你的支持,是我持续创作的最大鼓励!


作者:Petterp
来源:juejin.cn/post/7406258856953790515
收起阅读 »

if-else嵌套太深怎么办?

web
在前端开发中,if-else 嵌套过深往往会导致代码可读性下降、维护难度增加,甚至引发潜在的逻辑错误。本文将从一个典型的深层 if-else 嵌套案例出发,逐步分析并探讨多种优化策略,帮助开发者解决这一问题。 一、深层 if-else 嵌套的案例 假设我们正在...
继续阅读 »

在前端开发中,if-else 嵌套过深往往会导致代码可读性下降、维护难度增加,甚至引发潜在的逻辑错误。本文将从一个典型的深层 if-else 嵌套案例出发,逐步分析并探讨多种优化策略,帮助开发者解决这一问题。


一、深层 if-else 嵌套的案例


假设我们正在开发一个处理订单状态的功能,根据订单的不同状态执行相应的操作。下面是一个典型的 if-else 嵌套过深的代码示例:


function processOrder(order) {
if (order) {
if (order.isPaid) {
if (order.hasStock) {
if (!order.isCanceled) {
// 处理已付款且有库存的订单
return 'Processing paid order with stock';
} else {
// 处理已取消的订单
return 'Order has been canceled';
}
} else {
// 处理库存不足的订单
return 'Out of stock';
}
} else {
// 处理未付款的订单
return 'Order not paid';
}
} else {
// 处理无效订单
return 'Invalid order';
}
}
****

这段代码展示了多个条件的嵌套判断,随着条件的增多,代码的层级不断加深,使得可读性和可维护性大幅降低。


二、解决方案


1. 使用早返回


早返回是一种有效的方式,可以通过尽早退出函数来避免不必要的嵌套。


function processOrder(order) {
if (!order) {
return 'Invalid order';
}
if (!order.isPaid) {
return 'Order not paid';
}
if (!order.hasStock) {
return 'Out of stock';
}
if (order.isCanceled) {
return 'Order has been canceled';
}
return 'Processing paid order with stock';
}


通过早返回,条件判断被简化为一系列独立的判断,减少了嵌套层级,代码更直观。


2. 使用对象字面量或映射表


当条件判断基于某个特定的值时,可以利用对象字面量替代 if-else


const orderStatusActions = {
'INVALID': () => 'Invalid order',
'NOT_PAID': () => 'Order not paid',
'OUT_OF_STOCK': () => 'Out of stock',
'CANCELED': () => 'Order has been canceled',
'DEFAULT': () => 'Processing paid order with stock',
};

function processOrder(order) {
if (!order) {
return orderStatusActions['INVALID']();
}
if (!order.isPaid) {
return orderStatusActions['NOT_PAID']();
}
if (!order.hasStock) {
return orderStatusActions['OUT_OF_STOCK']();
}
if (order.isCanceled) {
return orderStatusActions['CANCELED']();
}
return orderStatusActions['DEFAULT']();
}


使用对象字面量将条件与行为进行映射,使代码更加模块化且易于扩展。


3. 使用策略模式


策略模式可以有效应对复杂的多分支条件,通过定义一系列策略类,将不同的逻辑封装到独立的类中。


class OrderProcessor {
constructor(strategy) {
this.strategy = strategy;
}

process(order) {
return this.strategy.execute(order);
}
}

class PaidOrderStrategy {
execute(order) {
if (!order.hasStock) {
return 'Out of stock';
}
if (order.isCanceled) {
return 'Order has been canceled';
}
return 'Processing paid order with stock';
}
}

class InvalidOrderStrategy {
execute(order) {
return 'Invalid order';
}
}

class NotPaidOrderStrategy {
execute(order) {
return 'Order not paid';
}
}

// 使用策略模式
const strategy = order ? (order.isPaid ? new PaidOrderStrategy() : new NotPaidOrderStrategy()) : new InvalidOrderStrategy();
const processor = new OrderProcessor(strategy);
processor.process(order);


策略模式将不同逻辑分散到独立的类中,避免了大量的 if-else 嵌套,增强了代码的可维护性。


4. 使用多态


通过多态性,可以通过继承和方法重写替代 if-else 条件分支。


优化后的代码:


class Order {
process() {
throw new Error('This method should be overridden');
}
}

class PaidOrder extends Order {
process() {
if (!this.hasStock) {
return 'Out of stock';
}
if (this.isCanceled) {
return 'Order has been canceled';
}
return 'Processing paid order with stock';
}
}

class InvalidOrder extends Order {
process() {
return 'Invalid order';
}
}

class NotPaidOrder extends Order {
process() {
return 'Order not paid';
}
}

// 通过多态处理订单
const orderInstance = new PaidOrder(); // 根据order实例化相应的类
orderInstance.process();


多态性允许我们通过不同的子类实现不同的逻辑,从而避免在同一个函数中使用大量的 if-else


5. 使用函数式编程技巧


函数式编程中的 map, filter, 和 reduce 可以帮助我们避免复杂的条件判断。


优化后的代码:


const orderProcessors = [
{condition: (order) => !order, process: () => 'Invalid order'},
{condition: (order) => !order.isPaid, process: () => 'Order not paid'},
{condition: (order) => !order.hasStock, process: () => 'Out of stock'},
{condition: (order) => order.isCanceled, process: () => 'Order has been canceled'},
{condition: () => true, process: () => 'Processing paid order with stock'},
];

const processOrder = (order) => orderProcessors.find(processor => processor.condition(order)).process();


通过 findfilter 等函数式编程方法,我们可以避免嵌套的 if-else 语句,使代码更加简洁和易于维护。


三、总结


if-else 嵌套过深的问题是前端开发中常见的挑战。通过本文提供的多种解决方案,如早返回、对象字面量、策略模式、多态和函数式编程技巧,开发者可以根据实际需求选择合适的优化方案,从而提高代码的可读性、可维护性和性能。


希望这些方法能对你的开发工作有所帮助,欢迎在评论区分享你的经验与想法!


作者:争取不脱发的程序猿
来源:juejin.cn/post/7406538050228633641
收起阅读 »

Oracle开始严查Java许可!

web
0x01、 前段时间在论坛里就看到一个新闻,说“Oracle又再次对Java下手,开始严查Java许可,有企业连夜删除JDK”,当时就曾在网上引起了一阵关注和讨论。 这不最近在科技圈又看到有媒体报道,Oracle再次严查,对于Java许可和版权的审查越来越严格...
继续阅读 »

0x01、


前段时间在论坛里就看到一个新闻,说“Oracle又再次对Java下手,开始严查Java许可,有企业连夜删除JDK”,当时就曾在网上引起了一阵关注和讨论。


这不最近在科技圈又看到有媒体报道,Oracle再次严查,对于Java许可和版权的审查越来越严格了。


其实很早之前就有看到新闻报道说,甲骨文公司Oracle已经开始将Java纳入其软件许可审查中,并且对一些公司的Java采用情况开启审计,目的是找出那些处于不合规边缘或已经违规的客户。


之前主要还是针对一些小公司发出过审查函件,而现在,甚至包括财富200强在内的一些组织或公司都收到了来自Oracle有关审查方面的信件。



0x02、


还记得去年上半年的时候,Oracle就曾发布过一个PDF格式的新版Java SE收费政策《Oracle Java SE Universal Subscription Global Price List (PDF)》。



打开那个PDF,在里面可以看到Oracle新的Java SE通用订阅全球价目表:



表格底部还举了一个具体计费的例子。


比方说一个公司有28000名总雇员,里面可能包含有23000名全职、兼职、临时雇员,以及5000其他类型员工(比如说代理商、合约商、咨询顾问),那这个总价格是按如下方式进行计算:


28000 * 6.75/12个月=2268000/月 * 12个月 = 2268000/年


合着这个新的收费标准是直接基于公司里总的员工数来进行计算的,而不仅仅是使用Java SE的员工数。


这样一来,可能就会使企业在相同软件的的使用情况下会多出不少费用,从而增加软件成本。


看到这里不得不说,Oracle接手之后把Java的商业化运作这块整得是明明白白的。


0x03、


众所周知,其实Java最初是由Sun公司的詹姆斯·高斯林(James Gosling,后来也被称为Java之父)及其团队所研发的。



并且最开始名字并不叫Java,而是被命名为:Oak,这个名字得自于 Gosling 想名字时看到了窗外的一棵橡树。



就在 Gosling 的团队即将发布成果之前,又出了个小插曲——Oak 竟然是一个注册商标。Oak Technology(OAKT)是一家美国半导体芯片制造商,Oak 是其注册商标。


既然不能叫Oak,那应该怎么命名好呢?


后来 Gosling 看见了同事桌上有一瓶咖啡,包装上写着 Java,于是灵感一现。至此,Java语言正式得名,并使用至今。


1995年5月,Oak语言才更名为Java(印度尼西亚爪哇岛的英文名称,因盛产咖啡而闻名),并于当时的SunWorld大会上发布了JAVA 1.0,而且那句“Write Once,Run Anywhere”的slogan也是那时候推出的。



此后,Java语言一直由Sun公司来进行维护开发,一直到早期的JDK 7。


2009年4月,Oracle以74亿美元现金收购了Sun公司,至此一代巨头基本没落。


与此同时,Java商标也被列入Oracle麾下,成为了Oracle的重要资源。



众所周知,Oracle接手Java之后,就迅速开始了商业化之路的实践,也于后续推出了一系列调整和改革的操作。


其实Oracle早在2017年9月就宣布将改变JDK版本发布周期。新版本发布周期中,一改原先以特性驱动的发布方式,而变成了以时间为驱动的版本迭代。


也即:每6个月会发布一个新的Java版本,而每3年则会推出一个LTS版本。



而直到前段时间,Java 22都已经正式发布了。



0x04、


那针对Oracle这一系列动作,以及新的定价策略和订阅问题,有不少网友讨论道,那就不使用Oralce JDK,切换到OpenJDK,或者使用某些公司开源的第三方JDK。


众所周知,OpenJDK是一个基于GPL v2 许可的开源项目,自Java 7开始就是Java SE的官方参考实现。


既然如此,也有不少企业或者组织基于OpenJDK从而构建了自己的JDK版本,这些往往都是基于OpenJDK源码,然后增加或者说定制一些自己的专属内容。


比如像阿里的Dragonwell,腾讯的Kona,AWS的Amazon Corretto,以及Azul提供的Zulu JDK等等,都是这类典型的代表。




它们都是各自根据自身的业务场景和业务需求并基于OpenJDK来打造推出的开源JDK发行版本,像这些也都是可以按需去选用的。


文章的最后,也做个小调查:


大家目前在用哪款JDK和版本来用于开发环境或生产环境的呢?



注:本文在GitHub开源仓库「编程之路」 github.com/rd2coding/R… 中已经收录,里面有我整理的6大编程方向(岗位)的自学路线+知识点大梳理、面试考点、我的简历、几本硬核pdf笔记,以及程序员生活和感悟,欢迎star。



作者:CodeSheep
来源:juejin.cn/post/7405845617282449462
收起阅读 »

网页也能像 QQ 一样发出右下角消息?轻松实现桌面通知!

web
网页也能像 QQ 一样发出右下角消息?轻松实现桌面通知! 大家好,我是蒜鸭。今天我们来聊聊如何让网页像 QQ 那样在右下角弹出消息通知。这个功能不仅能提升用户体验,还能增加网站的互动性。让我们一起探索如何在网页中实现这个酷炫的功能吧! 1. 为什么需要网页通知...
继续阅读 »

网页也能像 QQ 一样发出右下角消息?轻松实现桌面通知!


大家好,我是蒜鸭。今天我们来聊聊如何让网页像 QQ 那样在右下角弹出消息通知。这个功能不仅能提升用户体验,还能增加网站的互动性。让我们一起探索如何在网页中实现这个酷炫的功能吧!


1. 为什么需要网页通知?


在当今信息爆炸的时代,获取用户注意力变得越来越困难。传统的网页通知方式,如弹窗或页面内提示,往往会打断用户的浏览体验。而类似 QQ 那样的右下角消息通知,既能及时传递信息,又不会过分干扰用户,可以说是一种相当优雅的解决方案。


实现这种通知功能,我们有两种主要方式:使用 Web Notifications API 或自定义 CSS+JavaScript 实现。接下来,我们将详细探讨这两种方法的实现过程、优缺点以及适用场景。


2. 使用 Web Notifications API


2.1 Web Notifications API 简介


Web Notifications API 是现代浏览器提供的一个强大功能,它允许网页向用户发送通知,即使在用户没有打开网页的情况下也能工作。这个 API 的使用非常简单,但功能却十分强大。


2.2 基本实现步骤



  1. 检查浏览器支持

  2. 请求用户授权

  3. 创建并显示通知


让我们来看看具体的代码实现:


// 检查浏览器是否支持通知
if ("Notification" in window) {
// 请求用户授权
Notification.requestPermission().then(function (permission) {
if (permission === "granted") {
// 创建并显示通知
var notification = new Notification("Hello from Web!", {
body: "这是一条来自网页的通知消息",
icon: "path/to/icon.png"
});

// 点击通知时的行为
notification.onclick = function() {
window.open("https://example.com");
};
}
});
}

2.3 优点和注意事项


优点:

– 原生支持,无需额外库

– 可以在用户未浏览网页时发送通知

– 支持富文本和图标


注意事项:

– 需要用户授权,一些用户可能会拒绝

– 不同浏览器的显示样式可能略有不同

– 过度使用可能会引起用户反感


3. 自定义 CSS+JavaScript 实现


如果你想要更多的样式控制,或者希望通知始终显示在网页内,那么使用自定义的 CSS+JavaScript 方案可能更适合你。


3.1 基本思路



  1. 创建一个固定位置的 div 元素作为通知容器

  2. 使用 JavaScript 动态创建通知内容

  3. 添加动画效果使通知平滑显示和消失


3.2 HTML 结构


<div id="notification-container"></div>

3.3 CSS 样式


#notification-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9999;
}

.notification {
background-color: #f8f8f8;
border-left: 4px solid #4CAF50;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
padding: 16px;
margin-bottom: 10px;
width: 300px;
opacity: 0;
transform: translateX(100%);
transition: all 0.3s ease-in-out;
}

.notification.show {
opacity: 1;
transform: translateX(0);
}

.notification-title {
font-weight: bold;
margin-bottom: 5px;
}

.notification-body {
font-size: 14px;
}

3.4 JavaScript 实现


function showNotification(title, message, duration = 5000) {
const container = document.getElementById('notification-container');

const notification = document.createElement('div');
notification.className = 'notification';

const titleElement = document.createElement('div');
titleElement.className = 'notification-title';
titleElement.textContent = title;

const bodyElement = document.createElement('div');
bodyElement.className = 'notification-body';
bodyElement.textContent = message;

notification.appendChild(titleElement);
notification.appendChild(bodyElement);

container.appendChild(notification);

// 触发重绘以应用初始样式
notification.offsetHeight;

// 显示通知
notification.classList.add('show');

// 设置定时器移除通知
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => {
container.removeChild(notification);
}, 300);
}, duration);
}

// 使用示例
showNotification('Hello', '这是一条自定义通知消息');

3.5 优点和注意事项


优点:

– 完全可定制的外观和行为

– 不需要用户授权

– 可以轻松集成到现有的网页设计中


注意事项:

– 仅在用户浏览网页时有效

– 需要考虑移动设备的适配

– 过多的通知可能会影响页面性能


4. 高级技巧和最佳实践


4.1 通知分级


根据通知的重要性进行分级,可以使用不同的颜色或图标来区分:


function showNotification(title, message, level = 'info') {
// ... 前面的代码相同

let borderColor;
switch(level) {
case 'success':
borderColor = '#4CAF50';
break;
case 'warning':
borderColor = '#FFC107';
break;
case 'error':
borderColor = '#F44336';
break;
default:
borderColor = '#2196F3';
}

notification.style.borderLeftColor = borderColor;

// ... 后面的代码相同
}

// 使用示例
showNotification('成功', '操作已完成', 'success');
showNotification('警告', '请注意...', 'warning');
showNotification('错误', '出现问题', 'error');

4.2 通知队列


为了避免同时显示过多通知,我们可以实现一个简单的通知队列:


const notificationQueue = [];
let isShowingNotification = false;

function queueNotification(title, message, duration = 5000) {
notificationQueue.push({ title, message, duration });
if (!isShowingNotification) {
showNextNotification();
}
}

function showNextNotification() {
if (notificationQueue.length === 0) {
isShowingNotification = false;
return;
}

isShowingNotification = true;
const { title, message, duration } = notificationQueue.shift();
showNotification(title, message, duration);

setTimeout(showNextNotification, duration + 300);
}

// 使用示例
queueNotification('通知1', '这是第一条通知');
queueNotification('通知2', '这是第二条通知');
queueNotification('通知3', '这是第三条通知');

4.3 响应式设计


为了确保通知在各种设备上都能正常显示,我们需要考虑响应式设计:


@media (max-width: 768px) {
#notification-container {
left: 20px;
right: 20px;
bottom: 20px;
}

.notification {
width: auto;
}
}

4.4 无障碍性考虑


为了提高通知的可访问性,我们可以添加 ARIA 属性和键盘操作支持:


function showNotification(title, message, duration = 5000) {
// ... 前面的代码相同

notification.setAttribute('role', 'alert');
notification.setAttribute('aria-live', 'polite');

const closeButton = document.createElement('button');
closeButton.textContent = '×';
closeButton.className = 'notification-close';
closeButton.setAttribute('aria-label', '关闭通知');

closeButton.addEventListener('click', () => {
notification.classList.remove('show');
setTimeout(() => {
container.removeChild(notification);
}, 300);
});

notification.appendChild(closeButton);

// ... 后面的代码相同
}

5. 性能优化与注意事项


在实现网页通知功能时,我们还需要注意以下几点:



  1. 防抖和节流:对于频繁触发的事件(如实时通知),使用防抖或节流技术可以有效减少不必要的通知显示。

  2. 内存管理:确保在移除通知时,同时清理相关的事件监听器和 DOM 元素,避免内存泄漏。

  3. 优雅降级:对于不支持 Web Notifications API 的浏览器,可以降级使用自定义的 CSS+JavaScript 方案。

  4. 用户体验:给用户提供控制通知显示的选项,如允许用户设置通知的类型、频率等。

  5. 安全考虑:在使用 Web Notifications API 时,确保只在 HTTPS 环境下请求权限,并尊重用户的权限设置。


网页通知是一个强大的功能,能够显著提升用户体验和网站的互动性。无论是使用 Web Notifications API 还是自定义的 CSS+JavaScript 方案,都能实现类似 QQ 那样的右下角消息通知。选择哪种方式取决于你的具体需求和目标用户群。通过合理使用通知功能,你可以让你的网站变得更加生动和用户友好。



作者:Syferie
来源:juejin.cn/post/7403283321793314850
收起阅读 »

localhost和127.0.0.1的区别是什么?

今天在网上逛的时候看到一个问题,没想到大家讨论的很热烈,就是标题中这个: localhost和127.0.0.1的区别是什么? 前端同学本地调试的时候,应该没少和localhost打交道吧,只需要执行 npm run 就能在浏览器中打开你的页面窗口,地址栏显...
继续阅读 »

今天在网上逛的时候看到一个问题,没想到大家讨论的很热烈,就是标题中这个:


localhost和127.0.0.1的区别是什么?



前端同学本地调试的时候,应该没少和localhost打交道吧,只需要执行 npm run 就能在浏览器中打开你的页面窗口,地址栏显示的就是这个 http://localhost:xxx/index.html


可能大家只是用,也没有去想过这个问题。


联想到我之前合作过的一些开发同学对它们俩的区别也没什么概念,所以我觉得有必要普及下。


localhost是什么呢?


localhost是一个域名,和大家上网使用的域名没有什么本质区别,就是方便记忆。


只是这个localhost的有效范围只有本机,看名字也能知道:local就是本地的意思。


张三和李四都可以在各自的机器上使用localhost,但获取到的也是各自的页面内容,不会相互打架。


从域名到程序


要想真正的认清楚localhost,我们还得从用户是如何通过域名访问到程序说起。


以访问百度为例。


1、当我们在浏览器输入 baidu.com 之后,浏览器首先去DNS中查询 baidu.com 的IP地址。


为什么需要IP地址呢?打个比方,有个人要寄快递到你的公司,快递单上会填写:公司的通讯地址、公司名称、收件人等信息,实际运输时快递会根据通信地址进行层层转发,最终送到收件人的手中。网络通讯也是类似的,其中域名就像公司名称,IP地址就像通信地址,在网络的世界中只有通过IP地址才能找到对应的程序。


DNS就像一个公司黄页,其中记录着每个域名对应的IP地址,当然也有一些域名可能没做登记,就找不到对应的IP地址,还有一些域名可能会对应多个IP地址,DNS会按照规则自动返回一个。我们购买了域名之后,一般域名服务商会提供一个域名解析的功能,就是把域名和对应的IP地址登记到DNS中。


这里的IP地址从哪里获取呢?每台上网的电脑都会有1个IP地址,但是个人电脑的IP地址一般是不行的,个人电脑的IP地址只适合内网定位,就像你公司内部的第几栋第几层,公司内部人明白,但是直接发给别人,别人是找不到你的。如果你要对外部提供服务,比如百度这种,你就得有公网的IP地址,这个IP地址一般由网络服务运营商提供,比如你们公司使用联通上网,那就可以让联通给你分配一个公网IP地址,绑定到你们公司的网关服务器上,网关服务器就像电话总机,公司内部的所有网络通信都要通过它,然后再在网关上设置转发规则,将网络请求转发到提供网络服务的机器上。


2、有了IP地址之后,浏览器就会向这个IP地址发起请求,通过操作系统打包成IP请求包,然后发送到网络上。网络传输有一套完整的路由协议,它会根据你提供的IP地址,经过路由器的层层转发,最终抵达绑定该IP的计算机。


3、计算机上可能部署了多个网络应用程序,这个请求应该发给哪个程序呢?这里有一个端口的概念,每个网络应用程序启动的时候可以绑定一个或多个端口,不同的网络应用程序绑定的端口不能重复,再次绑定时会提示端口被占用。通过在请求中指定端口,就可以将消息发送到正确的网络处理程序。


但是我们访问百度的时候没有输入端口啊?这是因为默认不输入就使用80和443端口,http使用80,https使用443。我们在启动网络程序的时候一定要绑定一个端口的,当然有些框架会自动选择一个计算机上未使用的端口。



localhost和127.0.0.1的区别是什么?


有了上边的知识储备,我们就可以很轻松的搞懂这个问题了。


localhost是域名,上文已经说过了。


127.0.0.1 呢?是IP地址,当前机器的本地IP地址,且只能在本机使用,你的计算机不联网也可以用这个IP地址,就是为了方便开发测试网络程序的。我们调试时启动的程序就是绑定到这个IP地址的。


这里简单说下,我们经常看到的IP地址一般都是类似 X.X.X.X 的格式,用"."分成四段。其实它是一个32位的二进制数,分成四段后,每一段是8位,然后每一段再转换为10进制的数进行显示。


那localhost是怎么解析到127.0.0.1的呢?经过DNS了吗?没有。每台计算机都可以使用localhost和127.0.0.1,这没办法让DNS来做解析。


那就让每台计算机自己解决了。每台计算机上都有一个host文件,其中写死了一些DNS解析规则,就包括 localhost 到 127.0.0.1 的解析规则,这是一个约定俗成的规则。


如果你不想用localhost,那也可以,随便起个名字,比如 wodehost,也解析到 127.0.0.1 就行了。


甚至你想使用 baidu.com 也完全可以,只是只能自己自嗨,对别人完全没有影响。


域名的等级划分


localhost不太像我们平常使用的域名,比如 http://www.juejin.cn 、baidu.com、csdn.net, 这里边的 www、cn、com、net都是什么意思?localhost为什么不需要?


域名其实是分等级的,按照等级可以划分为顶级域名、二级域名和三级域名...


顶级域名(TLD):顶级域名是域名系统中最高级别的域名。它位于域名的最右边,通常由几个字母组成。顶级域名分为两种类型:通用顶级域名和国家顶级域名。常见的通用顶级域名包括表示工商企业的.com、表示网络提供商的.net、表示非盈利组织的.org等,而国家顶级域名则代表特定的国家或地区,如.cn代表中国、.uk代表英国等。


二级域名(SLD):二级域名是在顶级域名之下的一级域名。它是由注册人自行选择和注册的,可以是个性化的、易于记忆的名称。例如,juejin.cn 就是二级域名。我们平常能够申请到的也是这种。目前来说申请 xxx.com、xxx.net、xxx.cn等等域名,其实大家不太关心其顶级域名com\net\cn代表的含义,看着简短好记是主要诉求。


三级域名(3LD):三级域名是在二级域名之下的一级域名。它通常用于指向特定的服务器或子网。例如,在blog.example.com中,blog就是三级域名。www是最常见的三级域名,用于代表网站的主页或主站点,不过这只是某种流行习惯,目前很多网站都推荐直接使用二级域名访问了。


域名级别还可以进一步细分,大家可以看看企业微信开放平台这个域名:developer.work.weixin.qq.com,com代表商业,qq代表腾讯,weixin代表微信,work代表企业微信,developer代表开发者。这种逐层递进的方式有利于域名的分配管理。


按照上边的等级定义,我们可以说localhost是一个顶级域名,只不过它是保留的顶级域,其唯一目的是用于访问当前计算机。


多网站共用一个IP和端口


上边我们说不同的网络程序不能使用相同的端口,其实是有办法突破的。


以前个人博客比较火的时候,大家都喜欢买个虚拟主机,然后部署个开源的博客程序,抒发一下自己的感情。为了挣钱,虚拟主机的服务商会在一台计算机上分配N多个虚拟主机,大家使用各自的域名和默认的80端口进行访问,也都相安无事。这是怎么做到的呢?


如果你有使用Nginx、Apache或者IIS等Web服务器的相关经验,你可能会接触到主机头这个概念。主机头其实就是一个域名,通过设置主机头,我们的程序就可以共用1个网络端口。


首先在Nginx等Web程序中部署网站时,我们会进行一些配置,此时在主机头中写入网站要使用的域名。


然后Nginx等Web服务器启动的时候,会把80端口占为己有。


然后当某个网站的请求到达Nginx的80端口时,它会根据请求中携带的域名找到配置了对应主机头的网络程序。


然后再转发到这个网络程序,如果网络程序还没有启动,Nginx会把它拉起来。


私有IP地址


除了127.0.0.1,其实还有很多私有IP地址,比如常见的 192.168.x.x。这些私有IP地址大部分都是为了在局域网内使用而预留的,因为给每台计算机都分配一个独立的IP不太够用,所以只要局域网内不冲突,大家就可劲的用吧。你公司可以用 192.168.1.1,我公司也可以用192.168.1.1,但是如果你要访问我,就得通过公网IP进行转发。


大家常用的IPv4私有IP地址段分为三类:


A类:从10.0.0.0至10.255.255.255


B类:从172.16.0.0至172.31.255.255


C类:从192.168.0.0至192.168.255.255。


这些私有IP地址仅供局域网内部使用,不能在公网上使用。


--


除了上述三个私有的IPv4地址段外,还有一些保留的IPv4地址段:


用于本地回环测试的127.0.0.0至127.255.255.255地址段,其中就包括题目中的127.0.0.1,如果你喜欢也可以给自己分配一个127.0.0.2的IP地址,效果和127.0.0.1一样。


用于局域网内部的169.254.0.0至169.254.255.255地址段,这个很少接触到,如果你的电脑连局域网都上不去,可能会看到这个IP地址,它是临时分配的一个局域网地址。


这些地址段也都不能在公网上使用。


--


近年来,还有一个现象,就是你家里或者公司里上网时,光猫或者路由器对外的IPv4地址也不是公网IP了,这时候获得的可能是一个类似 100.64.x.x 的地址,这是因为随着宽带的普及,运营商手里的公网IP也不够了,所以运营商又加了一层局域网,而100.64.0.0 这个网段是专门分给运营商做局域网用的。如果你使用阿里云等公有云,一些云产品的IP地址也可能是这个,这是为了将客户的私有网段和公有云厂商的私有网段进行有效的区分。


--


其实还有一些不常见的专用IPv4地址段,完整的IP地址段定义可以看这里:http://www.iana.org/assignments…



IPv6


你可能也听说过IPv6,因为IPv4可分配的地址太少了,不够用,使用IPv6甚至可以为地球上的每一粒沙子分配一个IP。只是喊了很多年,大家还是喜欢用IPv4,这里边原因很多,这里就不多谈了。


IPv6地址类似:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX

它是128位的,用":"分成8段,每个X是一个16进制数(取值范围:0-F),IPv6地址空间相对于IPv4地址有了极大的扩充。比如:2001:0db8:3c4d:0015:0000:0000:1a2f:1a2b 就是一个有效的IPv6地址。


关于IPv6这里就不多说了,有兴趣的可以再去研究下。


关注萤火架构,加速技术提升!


作者:萤火架构
来源:juejin.cn/post/7321049446443417638
收起阅读 »

url请求参数带有特殊字符“%、#、&”时,参数被截断怎么办?

web
是的,最近又踩坑了! 事情是这样的,我们测试小姐姐在一个全局搜索框里输入了一串特殊字符“%%%”,然后进行搜索,结果报错了。而输入常规字符,均可以正常搜索。 一排查,发现特殊字符“%%%”并未成功传给后端。 我们的这个全局搜索功能是需要跳转页面才能查看到搜索结...
继续阅读 »

是的,最近又踩坑了!


事情是这样的,我们测试小姐姐在一个全局搜索框里输入了一串特殊字符“%%%”,然后进行搜索,结果报错了。而输入常规字符,均可以正常搜索。


一排查,发现特殊字符“%%%”并未成功传给后端。


我们的这个全局搜索功能是需要跳转页面才能查看到搜索结果的。所以,搜索条件是作为参数拼接在页面url上的。


正常的传参:


image.png


当输入的是特殊字符“%、#、&”时,参数丢失


image.png


也就是说,当路由请求参数带有浏览器url中的特殊含义字符时,参数会被截断,无法正常获取参数。


那么怎么解决这个问题呢?


方案一:encodeURIComponent/decodeURIComponent


拼接参数时,利用encodeURIComponent()进行编码,接收参数时,利用decodeURIComponent()进行解码。


// 编码
this.$router.push({path: `/crm/global-search/search-result?type=${selectValue}&text=${encodeURIComponent(searchValue)}`});

// 解码
const text = decodeURIComponent(this.$route.query.text)

此方法对绝大多数特殊字符都适用,但是唯独输入“%”进行搜索时不行,报错如下。


image.png


所以在编码之前,还需进行一下如下转换:



this.$router.push({path: `/crm/global-search/search-result?type=${selectValue}&text=${encodeURIComponent(encodeSpecialChar(searchValue))}`});


/**
* @param {*} char 字符串
* @returns
*/

export const encodeSpecialChar = (char) => {
// #、&可以不用参与处理
const encodeArr = [{
code: '%',
encode: '%25'
},{
code: '#',
encode: '%23'
}, {
code: '&',
encode: '%26'
},]
return char.replace(/[%?#&=]/g, ($) => {
for (const k of encodeArr) {
if (k.code === $) {
return k.encode
}
}
})
}


方案二: qs.stringify()


默认情况下,qs.stringify()方法会使用encodeURIComponent方法对特殊字符进行编码,以保证URL的合法性。


const qs = require('qs');

const searchObj = {
type: selectValue,
text: searchValue
};
this.$router.push({path: `/crm/global-search/search-result?${qs.stringify(searchObj)}`});


使用了qs.stringify()方法,就无需使用encodeSpecialChar方法进行转换了。


作者:HED
来源:juejin.cn/post/7332048519156776979
收起阅读 »

前端代码重复度检测

web
在前端开发中,代码的重复度是一个常见的问题。重复的代码不仅增加了代码的维护成本,还可能导致程序的低效运行。为了解决这个问题,有许多工具和技术被用来检测和消除代码重复。其中一个被广泛使用的工具就是jscpd。 jscpd简介 jscpd是一款开源的JavaScr...
继续阅读 »

在前端开发中,代码的重复度是一个常见的问题。重复的代码不仅增加了代码的维护成本,还可能导致程序的低效运行。为了解决这个问题,有许多工具和技术被用来检测和消除代码重复。其中一个被广泛使用的工具就是jscpd


jscpd简介


jscpd是一款开源的JavaScript的工具库,用于检测代码重复的情况,针对复制粘贴的代码检测很有效果。它可以通过扫描源代码文件,分析其中的代码片段,并比较它们之间的相似性来检测代码的重复度。jscpd支持各种前端框架和语言,包括HTML、CSS和JavaScript等150种的源码文件格式。无论是原生的JavaScript、CSS、HTML代码,还是使用typescriptscssvuereact等代码,都能很好的检测出项目中的重复代码。


开源仓库地址:github.com/kucherenko/jscpd/tree/master


如何使用


使用jscpd进行代码重复度检测非常简单。我们需要安装jscpd。可以通过npmyarn来安装jscpd


npm install -g jscpd

yarn global add jscpd

安装完成后,我们可以在终端运行jscpd命令,指定要检测的代码目录或文件。例如,我们可以输入以下命令来检测当前目录下的所有JavaScript文件:


jscpd .

指定目录检测:


jscpd /path/to/code

在命令行执行成功后的效果如下图所示:



简要说明一下对应图中的字段内容:



  • Clone found (javascript):
    显示找到的重复代码块,这里是javascript文件。并且会显示重复代码在文件中具体的行数,便于查找。

  • Format:文件格式,这里是 javascript,还可以是 scss、markup 等。

  • Files analyzed:已分析的文件数量,统计被检测中的文件数量。

  • Total lines:所有文件的总行数。

  • Total tokens:所有的token数量,一行代码一般包含几个到几十个不等的token数量。

  • Clones found:找到的重复块数量。

  • Duplicated lines:重复的代码行数和占比。

  • Duplicated tokens:重复的token数量和占比。

  • Detection time:检测耗时。


工程配置


以上示例是比较简单直接检测单个文件或文件夹。当下主流的前端项目大多都是基于脚手架生成或包含相关前端工程化的文件,由于很多文件是辅助工具如依赖包、构建脚本、文档、配置文件等,这类文件都不需要检测,需要排除。这种情况下的工程一般使用配置文件的方式,通过选项配置规范 jscpd 的使用。


jscpd 的配置选项可以通过以下两种方式创建,增加的内容都一致无需区分对应的前端框架。


在项目根目录下创建配置文件 .jscpd.json,然后在该文件中增加具体的配置选项:


    {
"threshold": 0,
"reporters": ["html", "console", "badge"],
"ignore": ["**/__snapshots__/**"],
"absolute": true
}

也可直接在 package.json 文件中添加jscpd


    {
...
"jscpd": {
"threshold": 0.1,
"reporters": ["html", "console", "badge"],
"ignore": ["**/__snapshots__/**"],
"absolute": true,
"gitignore": true
}
...
}

简要介绍一下上述配置字段含义:



  • threshold:表示重复度的阈值,超过这个值,就会输出错误报警。如阈值设为 10,当重复度为18.1%时,会提示以下错误❌,但代码的检测会正常完成。


ERROR: jscpd found too many duplicates (18.1%) over threshold (10%)


  • reporters:表示生成结果检测报告的方式,一般有以下几种:

    • console:控制台打印输出

    • consoleFull:控制台完整打印重复代码块

    • json:输出 json 格式的报告

    • xml:输出 xml 格式的报告

    • csv:输出 csv 格式的报告

    • markdown:输出带有 markdown 格式的报告

    • html:生成html报告到html文件夹

    • verbose:输出大量调试信息到控制台



  • ignore:检测忽略的文件或文件目录,过滤一些非业务代码,如依赖包、文档或静态文件等

  • format:需要进行重复度检测的源代码格式,目前支持150多种,我们常用的如 javascript、typescript、css 等

  • absolute:在检测报告中使用绝对路径


除此之外还有很多其他的配置,有兴趣的可以看源码文档中有详细的介绍。


检测报告


完成以上jscpd配置后执行以下命令即可输出对应的重复检测报告。运行完毕后,jscpd会生成一个报告,展示每个重复代码片段的信息。报告中包含了重复代码的位置、相似性百分比和代码行数等详细信息。通过这些信息,我们可以有针对性的进行代码重构。


jscpd ./src -o 'report'

项目中的业务代码通常会选择放在 ./src 目录下,所以可以直接检测该目录下的文件,如果是放在其他目录下根据实际情况调整即可。
通过命令行参数-o 'report'输出检测报告到项目根目录下的 report 文件夹中,这里的report也可以自定义其他目录名称,输出的目录结构如下所示:



生成的报告页面如下所示:


项目概览数据:



具体重复代码的位置和行数:



默认检测重复代码的行数(5行)和tokens(50)比较小,所以产生的重复代码块可能比较多,在实际使用中可以针对检测范围进行设置,如下设置参数供参考:



  • 最小tokens:--min-tokens,简写 -k

  • 最小行数:--min-lines,简写 -l

  • 最大行数:--max-lines,简写 -x


jscpd ./src --min-tokens 200 --min-lines 20 -o 'report'

为了更便捷的使用此命令,可将这段命令集成到 package.json 中的 scripts 中,后续只需执行 npm run jscpd 即可执行检测。如下所示:


"scripts": {
...
"jscpd": "jscpd ./src --min-tokens 200 --min-lines 20 -o 'report'",
...
}

忽略代码块


上面所提到的ignore可以忽略某个文件或文件夹,还有一种忽略方式是忽略文件中的某一块代码。由于一些重复代码在实际情况中是必要的,可以使用代码注释标识的方式忽略检测,在代码的首尾位置添加注释,jscpd:ignore-startjscpd:ignore-end 包裹代码即可。


在js代码中使用方式:


/* jscpd:ignore-start */
import lodash from 'lodash';
import React from 'react';
import {User} from './models';
import {UserService} from './services';
/* jscpd:ignore-end */

在CSS和各种预处理中与js中的用法一致:


/* jscpd:ignore-start */
.style {
padding: 40px 0;
font-size: 26px;
font-weight: 400;
color: #464646;
line-height: 26px;
}
/* jscpd:ignore-end */

在html代码中使用方式:


<!--
// jscpd:ignore-start
-->

<meta data-react-helmet="true" name="theme-color" content="#cb3837"/>
<link data-react-helmet="true" rel="stylesheet" href="https://static.npmjs.com/103af5b8a2b3c971cba419755f3a67bc.css"/>
<link data-react-helmet="true" rel="apple-touch-icon" sizes="120x120" href="https://static.npmjs.com/58a19602036db1daee0d7863c94673a4.png"/>
<link data-react-helmet="true" rel="icon" type="image/png" href="https://static.npmjs.com/b0f1a8318363185cc2ea6a40ac23eeb2.png" sizes="32x32"/>
<!--
// jscpd:ignore-end
-->


总结


jscpd是一款强大的前端本地代码重复度检测工具。它可以帮助开发者快速发现代码重复问题,简单的配置即可输出直观的代码重复数据,通过解决重复的代码提高代码的质量和可维护性。


使用jscpd我们可以有效地优化前端开发过程,提高代码的效率和性能。希望本文能够对你了解基于jscpd的前端本地代码重复度检测有所帮助。




看完本文如果觉得有用,记得点个赞支持,收藏起来说不定哪天就用上啦~


专注前端开发,分享前端相关技术干货,公众号:南城大前端(ID: nanchengfe)


作者:南城FE
来源:juejin.cn/post/7288699185981095988
收起阅读 »

2种纯前端换肤方案

web
前言 换肤功能是一项普遍的需求,尤其是在夜晚,用户更倾向于使用暗黑模式。在我负责的公司项目中,每个项目都有换肤功能的需求。 过去,我主要使用 SCSS 变量,并利用其提供的函数,如 @each、map-get来实现换肤功能。但因其使用成本高,只能适用于SCSS...
继续阅读 »

前言


换肤功能是一项普遍的需求,尤其是在夜晚,用户更倾向于使用暗黑模式。在我负责的公司项目中,每个项目都有换肤功能的需求。


过去,我主要使用 SCSS 变量,并利用其提供的函数,如 @eachmap-get来实现换肤功能。但因其使用成本高,只能适用于SCSS项目,于是后来我改用 CSS 变量来实现换肤。这样无论是基于 LESS 的 React 项目,还是基于 SCSS 的 Vue 项目,都能应用换肤功能。并且使用时只需调用var函数,降低了使用成本。


Demo地址:github.com/cwjbjy/vite…


1. 一键换肤


1. 前置知识


CSS变量:声明自定义CSS属性,它包含的值可以在整个文档中重复使用。属性名需要以两个减号(--)开始,属性值则可以是任何有效的 CSS 值


--fontColor:'#fff'

Var函数:用于使用CSS变量。第一个参数为CSS变量名称,第二个可选参数作为默认值


color: var(--fontColor);

CSS属性选择器:匹配具有特定属性或属性值的元素。例如[data-theme='black'],将选择所有 data-theme 属性值为 'black' 的元素


2. 定义主题色


1. 新建src/assets/theme/theme-default.css


这里定义字体颜色与布局的背景色,更多CSS变量可根据项目的需求来定义


[data-theme='default'] {
/* 字体 */
--font-primary: #fff;
--font-highlight: #434a50;
/* 布局 */
--background-header: #2f3542;
--background-aside: #545c64;
--background-main: #0678be;
}

2. 新建src/assets/theme/theme-black.css


再定义一套暗黑主题色


[data-theme='black'] {
/* 字体 */
--font-primary: #fff;
--font-highlight: #434a50;
/* 布局 */
--background-header: #303030;
--background-aside: #303030;
--background-main: #393939;
}

3. 新建src/assets/theme/index.css


在index.css文件中导出全部主题色


@import './theme-default.css'; 
@import './theme-black.css';

4. 引入全局样式


在入口文件引入样式,比如我这里是main.tsx


import '@/assets/styles/theme/index.css';

3. 在html标签上增加自定义属性


修改index.html,在html标签上增加自定义属性data-theme


<html lang="en" data-theme="default"></html>

这里使用data-theme是为了被CSS属性选择器[data-theme='default']选中,也可更换为其他自定义属性,只需与CSS属性选择器对应上即可。


4. 修改CSS主题色


关键点:监听change事件,使用document.documentElement.setAttribute动态修改data-theme属性,然后CSS属性选择器将自动选择对应的css变量


<template>
<div>
<select name="pets" @change="handleChange">
<option value="default">默认色</option>
<option value="black">黑色</option>
</select>
<div>登录页面</div>
</div>

</template>

<script setup lang="ts">
const handleChange = (e: Event) => {
window.document.documentElement.setAttribute('data-theme', (e.target as HTMLSelectElement).value);
};
</script>


<style lang="scss">
body {
color: var(--font-primary);
background-color: var(--background-main);
}
</style>


效果图,默认色:


1708935487468.png


效果图,暗黑色:


1708935536950.png


5. 修改JS主题色


切换主题色,除了需要修改css样式,有时也需在js文件中修改样式,例如修改echarts的配置文件,来改变柱状图、饼图等的颜色。


1. 新建src/config/theme.js


定义图像的颜色,这里定义字体的颜色,默认情况下字体为黑色,暗黑模式下,字体为白色


const themeColor = {
default: {
font: '#333',
},
black: {
font: '#fff',
},
};

export default themeColor;

2. 修改vue文件


关键点:



  1. 定义主题色TS类型,规定默认和暗黑两种:type ThemeTypes = 'default' | 'black';

  2. 定义theme响应式变量,用来记录当前主题色:const theme = ref<ThemeTypes>('default');

  3. 监听change事件,将选中的值赋给theme:theme.value = selectTheme;

  4. 使用watch进行监听,如果theme改变,则重新绘制echarts图形


完整的vue文件:


<template>
<div>
<select name="pets" @change="handleChange">
<option value="default">默认色</option>
<option value="black">黑色</option>
</select>
<div>登录页面</div>
<div ref="echartRef" class="myChart"></div>
</div>

</template>

<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import themeColor from '@/config/theme';
import * as echarts from 'echarts';

type ThemeTypes = 'default' | 'black';

const echartRef = ref<HTMLDivElement | null>(null);
const theme = ref<ThemeTypes>('default');
const handleChange = (e: Event) => {
const selectTheme = (e.target as HTMLSelectElement).value as ThemeTypes;
theme.value = selectTheme;
window.document.documentElement.setAttribute('data-theme', selectTheme);
};

const drawGraph = () => {
let echartsInstance = echarts.getInstanceByDom(echartRef.value!);
if (!echartsInstance) {
echartsInstance = echarts.init(echartRef.value);
}
echartsInstance.clear();
var option = {
color: ['#3398DB'],
title: {
text: '柱状图',
left: 'center',
textStyle: {
color: themeColor[theme.value].font,
},
},
xAxis: [
{
type: 'category',
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
axisLabel: {
show: true,
color: themeColor[theme.value].font,
},
nameTextStyle: {
color: themeColor[theme.value].font,
},
},
],
yAxis: [
{
type: 'value',
axisLabel: {
show: true,
color: themeColor[theme.value].font,
},
nameTextStyle: {
color: themeColor[theme.value].font,
},
},
],
series: [
{
name: '直接访问',
type: 'bar',
barWidth: '60%',
data: [10, 52, 200, 334, 390, 330, 220],
},
],
};

echartsInstance.setOption(option);
};
onMounted(() => {
drawGraph();
});
watch(theme, () => {
drawGraph();
});
</script>

<style lang="scss">
body {
color: var(--font-primary);
background-color: var(--background-main);
}
.myChart {
width: 300px;
height: 300px;
}
</style>

2. 一键变灰


在特殊的日子里,网页有整体变灰色的需求。可以使用filter 的 grayscale() 改变图像灰度,值在 0% 到 100% 之间,值为0%展示原图,值为100% 则完全转为灰度图像


body {
filter: grayscale(1); //1相当于100%
}

结尾


本文只是介绍大概的思路,更多的功能可根据业务增加。例如将主题色theme存储到pinia上,应用到全局上;将主题色存储到localStorage上,在页面刷新时,防止主题色恢复默认。


本文可结合以下文章阅读:



如果有更多的换肤方案,欢迎在留言区留言讨论。我会根据留言区内容实时更新。


作者:敲代码的彭于晏
来源:juejin.cn/post/7342527074526019620
收起阅读 »

实现一个支持@的输入框

web
近期产品期望在后台发布帖子或视频时,需要添加 @用户 的功能,以便用户收到通知,例如“xxx在xxx提及了您!”。然而,现有的开源库未能满足我们的需求,例如 ant-design 的 Mentions 组件: 但是不难发现跟微信飞书对比下,有两个细节没有处...
继续阅读 »

近期产品期望在后台发布帖子或视频时,需要添加 @用户 的功能,以便用户收到通知,例如“xxx在xxx提及了您!”。然而,现有的开源库未能满足我们的需求,例如 ant-design 的 Mentions 组件:



20240415161851.gif


但是不难发现跟微信飞书对比下,有两个细节没有处理。



  1. @用户没有高亮

  2. 在删除时没有当做一个整体去删除,而是单个字母删除,首先不谈用户是否想要整体删除,在这块有个模糊查询的功能,如果每删一个字母之后去调接口查询数据库造成一些不必要的性能开销,哪怕加上防抖。


然后也是找了其他的库都没达到产品的期望效果,那么好,自己实现一个,先看看最终实现的效果


6ND88RssMr.gif


封装之后使用:


<AtInput
height={150}
onRequest={async (searchStr) => {
const { data } = await UserFindAll({ nickname: searchStr });
return data?.list?.map((v) => ({
id: v.uid,
name: v.nickname,
wechatAvatarUrl: v.wechatAvatarUrl,
}));
}}
onChange={(content, selected) => {
setAtUsers(selected);
}}
/>

那么实现这么一个输入框大概有以下几个点:



  1. 高亮效果

  2. 删除/选中用户时需要整体删除

  3. 监听@的位置,复制给弹框的坐标,联动效果

  4. 最后我需要拿到文本内容,并且需要拿到@那些用户,去做表单提交


大多数文本输入框我们会使用input,或者textarea,很明显以上1,2两点实现不了,antd也是使用的textarea,所以也是没有实现这两个效果。所以这块使用富文本编辑,设置contentEditable,将其变为可编辑去做。输入框以及选择器的dom就如下:


 <div style={{ height, position: 'relative' }}>
{/* 编辑器 */}
<div
id="atInput"
ref={atRef}
className={'editorDiv'}
contentEditable
onInput={editorChange}
onClick={editorClick}
/>

{/* 选择用户框 */}
<SelectUser
options={options}
visible={visible}
cursorPosition={cursorPosition}
onSelect={onSelect}
/>

</div>

实现思路:



  1. 监听输入@,唤起选择框。

  2. 截取@xxx的xxx作为搜素的关键字去查询接口

  3. 选择用户后需要将原先输入的 @xxx 替换成 @姓名,并且将@的用户缓存起来

  4. 选择文本框中的姓名时需要变为整体选中状态,这块依然可以给标签设置为不可编辑状态就可实现,contentEditable=false,即可实现整体删除,在删除的同时需要将当前用户从之前缓存的@过的用户数组删除

  5. 那么可以拿到输入框的文本,@的用户, 最后将数据抛给父组件就完成了


以上提到了监听@文本变化,通常绑定onChange事件就行,但是还有一种用户通过点击移动光标,这块需要绑定change,click两个时间,他们里边的逻辑基本一样,只需要额外处理点击选中输入框中用户时,整体选中g功能,那么代码如下:


    const onObserveInput = () => {
let cursorBeforeStr = '';
const selection: any = window.getSelection();
if (selection?.focusNode?.data) {
cursorBeforeStr = selection.focusNode?.data.slice(0, selection.focusOffset);
}
setFocusNode(selection.focusNode);
const lastAtIndex = cursorBeforeStr?.lastIndexOf('@');
setCurrentAtIdx(lastAtIndex);
if (lastAtIndex !== -1) {
getCursorPosition();
const searchStr = cursorBeforeStr.slice(lastAtIndex + 1);
if (!StringTools.isIncludeSpacesOrLineBreak(searchStr)) {
setSearchStr(searchStr);
fetchOptions(searchStr);
setVisible(true);
} else {
setVisible(false);
setSearchStr('');
}
} else {
setVisible(false);
}
};

const selectAtSpanTag = (target: Node) => {
window.getSelection()?.getRangeAt(0).selectNode(target);
};

const editorClick = async (event) => {
onObserveInput();
// 判断当前标签名是否为span 是的话选中当做一个整体
if (e.target.localName === 'span') {
selectAtSpanTag(e.target);
}
};

const editorChange = (event) => {
const { innerText } = event.target;
setContent(innerText);
onObserveInput();
};

每次点击或者文本改变时都会去调用onObserveInput,以上onObserveInput该方法中主要做了以下逻辑:




  1. 通过getSelection方法可以获取光标的偏移位置,那么可以截取光标之前的字符串,并且使用lastIndexOf从后向前查找最后一个“@”符号,并记录他的下标,那么有了【光标之前的字符串】,【@的下标】就可以拿到到@之后用于过滤用户的关键字,并将其缓存起来。

  2. 唤起选择器,并通过关键字去过滤用户。这块涉及到一个选择器的位置,直接使用window.getSelection()?.getRangeAt(0).getBoundingClientRect()去获取光标的位置拿到的是光标相对于窗口的坐标,直接用这个坐标会有问题,比如滚动条滚动时,这个选择器发生位置错乱,所以这块同时去拿输入框的坐标,去做一个相减,这样就可以实现选择器跟着@符号联动的效果。


 const getCursorPosition = () => {
// 坐标相对浏览器的坐标
const { x, y } = window.getSelection()?.getRangeAt(0).getBoundingClientRect() as any;
// 获取编辑器的坐标
const editorDom = window.document.querySelector('#atInput');
const { x: eX, y: eY } = editorDom?.getBoundingClientRect() as any;
// 光标所在位置
setCursorPosition({ x: x - eX, y: y - eY });
};

选择器弹出后,那么下面就到了选择用户之后的流程了,


 /**
* @param id 唯一的id 可以uid
* @param name 用户姓名
* @param color 回显颜色
* @returns
*/

const createAtSpanTag = (id: number | string, name: string, color = 'blue') => {
const ele = document.createElement('span');
ele.className = 'at-span';
ele.style.color = color;
ele.id = id.toString();
ele.contentEditable = 'false';
ele.innerText = `@${name}`;
return ele;
};

/**
* 选择用户时回调
*/

const onSelect = (item: Options) => {
const selection = window.getSelection();
const range = selection?.getRangeAt(0) as Range;
// 选中输入的 @关键字 -> @郑
range.setStart(focusNode as Node, currentAtIdx!);
range.setEnd(focusNode as Node, currentAtIdx! + 1 + searchStr.length);
// 删除输入的 @关键字
range.deleteContents();
// 创建元素节点
const atEle = createAtSpanTag(item.id, item.name);
// 插入元素节点
range.insertNode(atEle);
// 光标移动到末尾
range.collapse();
// 缓存已选中的用户
setSelected([...selected, item]);
// 选择用户后重新计算content
setContent(document.getElementById('atInput')?.innerText as string);
// 关闭弹框
setVisible(false);
// 输入框聚焦
atRef.current.focus();
};

选择用户的时候需要做的以下以下几点:



  1. 删除之前的@xxx字符

  2. 插入不可编辑的span标签

  3. 将当前选择的用户缓存起来

  4. 重新获取输入框的内容

  5. 关闭选择器

  6. 将输入框重新聚焦


最后


在选择的用户或者内容发生改变时将数据抛给父组件


 const getAttrIds = () => {
const spans = document.querySelectorAll('.at-span');
let ids = new Set();
spans.forEach((span) => ids.add(span.id));
return selected.filter((s) => ids.has(s.id));
};

/** @的用户列表发生改变时,将最新值暴露给父组件 */
useEffect(() => {
const selectUsers = getAttrIds();
onChange(content, selectUsers);
}, [selected, content]);

完整组件代码


输入框主要逻辑代码:


let timer: NodeJS.Timeout | null = null;

const AtInput = (props: AtInputProps) => {
const { height = 300, onRequest, onChange, value, onBlur } = props;
// 输入框的内容=innerText
const [content, setContent] = useState<string>('');
// 选择用户弹框
const [visible, setVisible] = useState<boolean>(false);
// 用户数据
const [options, setOptions] = useState<Options[]>([]);
// @的索引
const [currentAtIdx, setCurrentAtIdx] = useState<number>();
// 输入@之前的字符串
const [focusNode, setFocusNode] = useState<Node | string>();
// @后关键字 @郑 = 郑
const [searchStr, setSearchStr] = useState<string>('');
// 弹框的x,y轴的坐标
const [cursorPosition, setCursorPosition] = useState<Position>({
x: 0,
y: 0,
});
// 选择的用户
const [selected, setSelected] = useState<Options[]>([]);
const atRef = useRef<any>();

/** 获取选择器弹框坐标 */
const getCursorPosition = () => {
// 坐标相对浏览器的坐标
const { x, y } = window.getSelection()?.getRangeAt(0).getBoundingClientRect() as any;
// 获取编辑器的坐标
const editorDom = window.document.querySelector('#atInput');
const { x: eX, y: eY } = editorDom?.getBoundingClientRect() as any;
// 光标所在位置
setCursorPosition({ x: x - eX, y: y - eY });
};

/**获取用户下拉列表 */
const fetchOptions = (key?: string) => {
if (timer) {
clearTimeout(timer);
timer = null;
}
timer = setTimeout(async () => {
const _options = await onRequest(key);
setOptions(_options);
}, 500);
};

useEffect(() => {
fetchOptions();
// if (value) {
// /** 判断value中是否有at用户 */
// const atUsers: any = StringTools.filterUsers(value);
// setSelected(atUsers);
// atRef.current.innerHTML = value;
// setContent(value.replace(/<\/?.+?\/?>/g, '')); //全局匹配内html标签)
// }
}, []);

const onObserveInput = () => {
let cursorBeforeStr = '';
const selection: any = window.getSelection();
if (selection?.focusNode?.data) {
cursorBeforeStr = selection.focusNode?.data.slice(0, selection.focusOffset);
}
setFocusNode(selection.focusNode);
const lastAtIndex = cursorBeforeStr?.lastIndexOf('@');
setCurrentAtIdx(lastAtIndex);
if (lastAtIndex !== -1) {
getCursorPosition();
const searchStr = cursorBeforeStr.slice(lastAtIndex + 1);
if (!StringTools.isIncludeSpacesOrLineBreak(searchStr)) {
setSearchStr(searchStr);
fetchOptions(searchStr);
setVisible(true);
} else {
setVisible(false);
setSearchStr('');
}
} else {
setVisible(false);
}
};

const selectAtSpanTag = (target: Node) => {
window.getSelection()?.getRangeAt(0).selectNode(target);
};

const editorClick = async (e?: any) => {
onObserveInput();
// 判断当前标签名是否为span 是的话选中当做一个整体
if (e.target.localName === 'span') {
selectAtSpanTag(e.target);
}
};

const editorChange = (event: any) => {
const { innerText } = event.target;
setContent(innerText);
onObserveInput();
};

/**
* @param id 唯一的id 可以uid
* @param name 用户姓名
* @param color 回显颜色
* @returns
*/

const createAtSpanTag = (id: number | string, name: string, color = 'blue') => {
const ele = document.createElement('span');
ele.className = 'at-span';
ele.style.color = color;
ele.id = id.toString();
ele.contentEditable = 'false';
ele.innerText = `@${name}`;
return ele;
};

/**
* 选择用户时回调
*/

const onSelect = (item: Options) => {
const selection = window.getSelection();
const range = selection?.getRangeAt(0) as Range;
// 选中输入的 @关键字 -> @郑
range.setStart(focusNode as Node, currentAtIdx!);
range.setEnd(focusNode as Node, currentAtIdx! + 1 + searchStr.length);
// 删除输入的 @关键字
range.deleteContents();
// 创建元素节点
const atEle = createAtSpanTag(item.id, item.name);
// 插入元素节点
range.insertNode(atEle);
// 光标移动到末尾
range.collapse();
// 缓存已选中的用户
setSelected([...selected, item]);
// 选择用户后重新计算content
setContent(document.getElementById('atInput')?.innerText as string);
// 关闭弹框
setVisible(false);
// 输入框聚焦
atRef.current.focus();
};

const getAttrIds = () => {
const spans = document.querySelectorAll('.at-span');
let ids = new Set();
spans.forEach((span) => ids.add(span.id));
return selected.filter((s) => ids.has(s.id));
};

/** @的用户列表发生改变时,将最新值暴露给父组件 */
useEffect(() => {
const selectUsers = getAttrIds();
onChange(content, selectUsers);
}, [selected, content]);

return (
<div style={{ height, position: 'relative' }}>
{/* 编辑器 */}
<div id="atInput" ref={atRef} className={'editorDiv'} contentEditable onInput={editorChange} onClick={editorClick} />
{/* 选择用户框 */}
<SelectUser options={options} visible={visible} cursorPosition={cursorPosition} onSelect={onSelect} />
</div>

);
};

选择器代码


const SelectUser = React.memo((props: SelectComProps) => {
const { options, visible, cursorPosition, onSelect } = props;

const { x, y } = cursorPosition;

return (
<div
className={'selectWrap'}
style={{
display: `${visible ? 'block' : 'none'}`,
position: 'absolute',
left: x,
top: y + 20,
}}
>

<ul>
{options.map((item) => {
return (
<li
key={item.id}
onClick={() =>
{
onSelect(item);
}}
>
<img src={item.wechatAvatarUrl} alt="" />
<span>{item.name}</span>
</li>
);
})}
</ul>
</div>

);
});
export default SelectUser;

以上就是实现一个支持@用户的输入框功能,就目前而言,比较死板,不支持自定义颜色,自定义选择器等等,未来,可以进一步扩展功能,例如添加@用户的高亮样式定制、支持键盘快捷键操作等,从而提升用户体验和功能性。


作者:tech_zjf
来源:juejin.cn/post/7357917741909819407
收起阅读 »

Vue.js 自动路由:告别手动配置,让开发更轻松!

web
在使用 Vue.js 开发项目的时候,我最头疼的就是创建路由,尤其是项目越来越大的时候,管理 route.ts 或 route.js 文件简直是一场噩梦! 我曾经做过一个页面超级多的项目,每新增一个页面就要更新路由,删除页面也要更新路由文件,不然就会报错,真是...
继续阅读 »

在使用 Vue.js 开发项目的时候,我最头疼的就是创建路由,尤其是项目越来越大的时候,管理 route.tsroute.js 文件简直是一场噩梦!


我曾经做过一个页面超级多的项目,每新增一个页面就要更新路由,删除页面也要更新路由文件,不然就会报错,真是烦死人了!


所以,我开始寻找自动生成路由的方法。我在网上搜了很久,但大部分结果都是针对 Webpack 和 Vue 2 的,很难找到适合我的方案。最后,我在 Vue 的 GitHub 仓库的讨论区里提问,终于找到了答案!


那就是 Unplugin Vue Router! 它可以为 Vue 3 实现基于文件的自动路由,而且支持 TypeScript,设置起来也超级简单! 虽然官方说它还在实验阶段,但用起来已经很方便了。


创建项目,安装插件


首先,我们创建一个新的 Vue 项目。 相信大家都很熟悉用 Vue CLI 创建项目了,这里就不赘述了,不熟悉的小伙伴可以去看看 Vue.js 官网的快速入门指南。


pnpm create vue@latest 

我创建项目的时候选择了 TypeScript 和 Vue Router,这样它就会自动生成一些页面和路由。


然后,进入项目目录,安装依赖。我最近开始用 pnpm 来管理依赖,感觉还不错。


pnpm add -D unplugin-vue-router 

接下来,更新 vite.config.ts 文件, 注意要把插件放在第 0 个位置


import { fileURLToPath, URL } from "node:url";
import VueRouter from "unplugin-vue-router/vite";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

// https://vitejs.dev/config/
export default defineConfig({
plugins: [
VueRouter({
/* options */
}),
// ⚠️ Vue must be placed after VueRouter()
vue(),
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},

});

然后,更新 env.d.ts 文件,让编辑器能够识别插件的类型。


/// <reference types="vite/client" />
/// <reference types="unplugin-vue-router/client" />

最后,更新路由文件 src/router/index.ts


import { createRouter, createWebHistory } from "vue-router";
import { routes, handleHotUpdate } from "vue-router/auto-routes";

const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});

if (import.meta.hot) {
handleHotUpdate(router);
}

export default router;

创建页面,自动生成路由


现在,我们可以创建 src/pages 目录了,在这个目录下创建的 Vue 组件会自动变成路由和页面,就像 Nuxt 一样方便!


我们先在 src\pages\about.vue 创建一个关于页面:


<template>
    <div>This is the about page</div>
</template>

然后在 src\pages\index.vue 创建首页:


<template>
    <div>This is Home Page</div>
</template>

运行 pnpm dev 启动开发服务器,点击 “Home” 链接就会跳转到首页,点击 “About” 链接就会跳转到关于页面。


怎么样,是不是很方便? 如果你不熟悉路由文件夹结构,可以看看这个文档: uvr.esm.is/guide/file-…


动态路由


我们再来试试创建带参数的动态路由。在 src/pages/blog/[id].vue 创建一个组件,内容如下:


<script setup>
const { id } = useRoute().params;
</script>
<template>
    <div>This is the blog post with id: {{ id }}</div>
</template>

再次运行 pnpm dev ,然后访问 http://localhost:5173/blog/6 ,你就会看到以下内容:


vuejs 自动路由


是不是很神奇? 希望这篇简短的博客能帮助你在 Vue.js 的旅程中更轻松地创建路由!


作者:前端宝哥
来源:juejin.cn/post/7401354593588199465
收起阅读 »

JS类型判断的四种方法,你掌握了吗?

web
引言 JavaScript中有七种原始数据类型和几种引用数据类型,本文将清楚地介绍四种用于类型判断的方法,分别是typeOf、instanceOf、Object.prototype.toString.call()、Array.isArray(),并介绍其使用方...
继续阅读 »

引言


JavaScript中有七种原始数据类型和几种引用数据类型,本文将清楚地介绍四种用于类型判断的方法,分别是typeOfinstanceOfObject.prototype.toString.call()Array.isArray(),并介绍其使用方法和判定原理。


typeof



  1. 可以准确判断除null之外的所有原始类型,null会被判定成object

  2. function类型可以被准确判断为function,而其他所有引用类型都会被判定为object


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

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

console.log(typeof s); // string typeof后面有无括号都行
console.log(typeof n); // number
console.log(typeof f); // boolean
console.log(typeof u); // undefined
console.log(typeof(sy)); // symbol
console.log(typeof(big)); // bigint

console.log(typeof(nu)); // object

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

console.log(typeof(fn)); // function

判定原理


typeof是通过将值转换为二进制之后,判断其前三位是否为0:都是0则为object,反之则为原始类型。因为原始类型转二进制,前三位一定不都是0;反之引用类型被转换成二进制前三位一定都是0。


null是原始类型却被判定为object就是因为它在机器中是用一长串0来表示的,可以把这看作是一个史诗级的bug。


所以用typeof判断接收到的值是否为一个对象时,还要注意排除null的情况:


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

你丢一个值给typeof,它会告诉你这个字值是什么类型,但是它无法准确告诉你这是一个Array或是Date,若想要如此精确地知道一个对象类型,可以用instanceof告诉你是否为某种特定的类型


instanceof


只能精确地判断引用类型,不能判断原始类型


console.log(obj instanceof Object);// true
console.log(arr instanceof Array);// true
console.log(fn instanceof Function);// true
console.log(date instanceof Date);// true
console.log(s instanceof String);// false
console.log(n instanceof Number);// false
console.log(arr instanceof Object);// true

判定原理


instanceof既能把数组判定成Array,又能把数组判定成Object,究其原因是原型链的作用————顺着数组实例 arr 的隐式原型一直找到了 Object 的构造函数,看下面的代码:


arr.__proto__ = Array.prototype
Array.prototype.__proto__ = Object.prototype

所以我们就知道了,instanceof能准确判断出一个对象是否为某种类型,就是依靠对象的原型链来查找的,一层又一层地判断直到找到null为止。


手写instanceOf


根据这个原理,我们可以手写出一个instanceof:


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

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


对象的隐式原型 等于 构造函数的显式原型!可看文章 给我三分钟,带你完全理解JS原型和原型链前言



Object.prototype.toString.call()


可以判断任何数据类型


在浏览器上执行这三段代码,会得到'[object Object]''[object Array]''[object Number]'


var a = {}
Object.prototype.toString.call(a)

var a = {}
Object.prototype.toString.call(a)

var a = 123
Object.prototype.toString.call(a)

原型上的toString的内部逻辑


调用Object.prototype.toString的时候执行会以下步骤: 参考官方文档:带注释的 ES5



  1. 如果此值是undefined类型,则返回 ‘[object Undefined]’

  2. 如果此值是null类型,则返回 ‘[object Null]’

  3. 将 O 作为 ToObject(this) 的执行结果。toString执行过程中会调用一个ToObject方法,执行一个类似包装类的过程,我们访问不了这个方法,是JS自己用的

  4. 定义一个class作为内部属性[[class]]的值。toString可以读取到这个值并把这个值暴露出来让我们看得见

  5. 返回由 "[object"class"]" 组成的字符串


为什么结合call就能准确判断值类型了呢?


① 首先我们要知道Object.prototype.toString(xxx)往括号中不管传递什么返回结果都是'[object Object]',因为根据上面五个步骤来看,它内部会自动执行ToObject()方法,xxx会被执行一个类似包装类的过程然后转变成一个对象。所以单独一个Object.prototype.toString(xxx)不能用来判定值的类型


② 其次了解call方法的核心原理就是:比如foo.call(obj),利用隐式绑定的规则,让obj对象拥有foo这个函数的引用,从而让foo函数的this指向obj,执行完foo函数内部逻辑后,再将foo函数的引用从obj上删除掉。手搓一个call的源码就是这样的:


// call方法只允许被函数调用,所以它应该是放在Function构造函数的显式原型上的
Function.prototype.mycall = function(context) {
// 判断调用我的那个哥们是不是函数体
if (typeof this !== 'function') {
return new TypeError(this+ 'is not a function')
}

// this(函数)里面的this => context对象
const fn = Symbol('key') // 定义一个独一无二的fn,防止使用该源码时与其他fn产生冲突
context[fn] = this // 让对象拥有该函数 context={Symbol('key'): foo}
context[fn]() // 触发隐式绑定
delete context[fn]
}

③ 所以Object.prototype.toString.call(xxx)就相当于 xxx.toString(),把toString()方法放在了xxx对象上调用,这样就能精准给出xxx的对象类型



toString方法有几个版本:



  1. {}.toString() 得到由"[object" 和 class 和 "]" 组成的字符串

  2. [].toString() 数组的toString方法重写了对象上的toString方法,返回由数组内部元素以逗号拼接的字符串

  3. xx.toString() 返回字符串字面量,比如


let fn = function(){}; 
console.log( fn.toString() ) // "function () {}"


Array.isArray(x)


只能判断是否是数组,若传进去的x是数组,返回true,否则返回false


总结


typeOf:原始类型除了null都能准确判断,引用类型除了function能准确判断其他都不能。依靠值转为二进制后前三位是否为0来判断


instanceOf:只能把引用类型丢给它准确判断。顺着对象的隐式原型链向上比对,与构造函数的显式原型相等返回true,否则false


Object.prototype.toString.call():可以准确判断任何类型。要了解对象原型的toString()内部逻辑和call()的核心原理,二者结合才有精准判定的效果


Array.isArray():是数组则返回true,不是则返回false。判定范围最狭窄


作者:今天一定晴q
来源:juejin.cn/post/7403288145196580904
收起阅读 »

学TypeScript必然要了解declare

web
背景 declare关键字是为了服务TypeScript的。TypeScript是什么在这里就不多介绍了,但是我们要知道ts文件是需要TypeScript编译器转换为js文件才可以执行,并且在编译阶段就会进行类型检查。但是在TypeScript中并不支持js可...
继续阅读 »

背景


declare关键字是为了服务TypeScript的。TypeScript是什么在这里就不多介绍了,但是我们要知道ts文件是需要TypeScript编译器转换为js文件才可以执行,并且在编译阶段就会进行类型检查。但是在TypeScript中并不支持js可识别的所有类型,例如我们使用第三方库JQuery,我们通过一下方法获取一个id为‘foo’的标签元素。


$('#foo');
// or
jQuery('#foo');

然而在ts文件中,使用语法,语法,底下就会爆出一条红线提示到:Cannot find name '$'



因此,需要declare来声明,告诉TypeScript编译器该标识符已存在,通过编译时的检查并在开发时提供类型提示。


定义


在 TypeScript 中,declare关键字告诉编译器存在一个对象(并且可以在代码中引用)。它向 TypeScript 编译器声明该对象。简而言之,它允许开发人员使用在其他地方声明的对象。

注:编译器不会将declare语句编译为 JavaScript。对比下面两段代码:


// declare声明了一个名为 myGlobal 的全局变量,并指定其类型为 any。
// 该声明并不会生成真正的 JavaScript 代码,而只是告诉 TypeScript 编译器该变量存在。
declare var myGlobal: any;

// 给 myGlobal 赋值为 42。
myGlobal = 42;
console.log(myGlobal); // 42

// 直接声明了一个名为 myGlobal 的全局变量,并指定其类型为 any。这会生成真正的 JavaScript 代码。
var myGlobal: any;

// 给 myGlobal 赋值为 42。
myGlobal = 42;
console.log(myGlobal); // 42

使用



  • declare var 声明全局变量

  • declare function 声明全局方法

  • declare class 声明全局类

  • declare enum 声明全局枚举类型

  • declare namespace 声明(含有子属性的)全局对象

  • declare global 扩展全局变量

  • declare module 扩展模块


声明文件


通常,在使用第三方库或模块时,有两种方式引入声明文件:



  • 全局声明:如果第三方库或模块是全局可访问的,你可以在整个项目的任何地方直接使用它们,而无需显式导入。此时,你只需要确保在 TypeScript 项目中正确引入了相应的声明文件。一般情况下,TypeScript 会自动查找并加载全局声明文件。如果没有自动加载,你可以使用 /// 的方式在具体的源文件中将声明文件引入。

  • 模块导入:如果第三方库或模块是通过模块化方式提供的,你需要使用 import 语句将其导入到你的代码中,同时也需要确保相应的声明文件被正确引入。在这种情况下,你可以使用 import 或 require 来引入库,并且不需要显式地引入声明文件,因为 TypeScript 编译器会根据模块的导入语句自动查找和加载相应的声明文件。


有很多第三方库提供了声明文件,可以在packages.json文件中查看。types表示类型声明文件是哪一个。


image.png


可以使用 @types 统一管理第三方库的声明文件。@types 的使用方式很简单,直接用 npm 安装对应的声明模块即可,以 jQuery 举例:


npm install @types/jquery --save-dev

作者:用户483146118862
来源:juejin.cn/post/7402811318816702515
收起阅读 »

厉害了,不用js就能实现文字中间省略号

web
今天发现一个特别有意思的效果,并进行解析,就是标题的效果 参考链接 实现地址 CodePen 如果要实现这个功能,我想很多人第一时间想到的都是用js去计算dom容器和文字之间是否溢出吧?但今天带来一个用css实现的效果,不用自己计算,只需要寥寥几行(bu...
继续阅读 »

今天发现一个特别有意思的效果,并进行解析,就是标题的效果



参考链接



如果要实现这个功能,我想很多人第一时间想到的都是用js去计算dom容器和文字之间是否溢出吧?但今天带来一个用css实现的效果,不用自己计算,只需要寥寥几行(bushi)就可以实现让人头疼的文字中间省略号功能。




实现思路


1. 简单实现


在用css实现的时候我们不妨用这个思路想想,设置一个当前显示文字span伪元素的width为50%,浮动到当前span上面,并且设置direction: rtl; 显示右边文字,不就可以很简单的实现这个功能了?让我们试试:


<style>
.wrap {
width: 200px;
border: 1px solid white;
}
.test-title {
display: block;
color: white;
overflow: hidden;
height: 20px;
}
.test-title::before {
content: attr(title);
width: 50%;
float: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl;
}
</style>
<body>
<div class="wrap">
<span class="test-title" title="这是一行文字文字文字文字文字文字文字文字文字文字文字文字文字">
这是一行文字文字文字文字文字文字文字文字文字文字文字文字文字
</span>
</div>
</body>

Untitled


💡 此处应有图

2. 优化效果


在上面我们已经看到,其实效果我们已经实现了,现在文字中间已经有了省略号了!但是这其中其实有一个弊端不知道大家有没有发现,那就是文本不溢出的情况呢?伪元素是不是会一直显示在上面?这该怎么办?难道我们需要用js监听文本不溢出的情况然后手动隐藏吗?


Untitled


既然是用css来进行实现,那么我们当然不能用这种方式了。这里原作者用了一种很取巧,但也很好玩的一种方法,让我们来看看吧!


既然我们上面实现的是文本溢出的情况,那么当文本不溢出的时候我们直接显示文字不就行了?你可能想说:“这不是废话吗?但我现在不就是不知道怎么判断吗? ”。hhhhh对,那我们就要用css来想想,css该怎么判断呢?我就不卖关子了,让我们想想,我们给文本的容器添加一个固定宽度,那么当文本溢出的时候会发生什么呢?是不是会换行,高度变大呢,那么当我们设置两个文本元素,一个是正常样式,一个是我们上方的溢出样式。等文本不溢出没换行的时候,显示正常样式,当文本溢出高度变大的时候显示溢出样式可以吗?让我们试试吧


<!DOCTYPE html>
<html lang="en">
<head>
<style>
body {
background: #333;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
margin: 0;
font-size: 14px;
}
.wrap {
width: 300px;
background: #333;

/* 设置正常行高,并隐藏溢出画面 */
height: 2em;
overflow: hidden;
line-height: 2;
position: relative;
text-align: -webkit-match-parent;
resize: horizontal;
}

.normal-title {
/* 设置最大高度为双倍行高使其可以换行 */
display: block;
max-height: 4em;
}
.test-title {
position: relative;
top: -4em;
display: block;
color: white;
overflow: hidden;
height: 2em;
text-align: justify;
background: inherit;
overflow: hidden;
}
.test-title::before {
content: attr(title);
width: 50%;
float: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl;
}
</style>
</head>

<body>
<div class="wrap">
<span class="normal-title">这是一行文字文字文字文字文字文字文字文字文字文字文字文字文字</span>
<span class="test-title" title="这是一行文字文字文字文字文字文字文字文字文字文字文字文字文字">
这是一行文字文字文字文字文字文字文字文字文字文字文字文字文字
</span>
</div>
</body>

</html>

大家都试过了吧?那让我们来讲一下这段代码的实现:


实现方式:简单来说这段代码实现的就是一个覆盖的效果,normal-title元素平常是普通高度(1em),等到换行之后就会变成2em,那么我们的溢出样式test-title怎么实现的覆盖呢?这主要依赖于test-title的top属性,让我们这样子想,当normal-title高度为1em的时候,test-title的top为-2em,那么这时候因为wrap的hidden效果,所以test-title是看不到的。那么当normal-title的高度为2em的时候呢?test-title刚好就会覆盖到normal-title上面,所以我们刚好可以看到test-title的省略号效果。


这就是完整的实现过程和方式,css一些取巧的判断方式总会让我们大开眼界,不断学习,方得始终。


作者:一_个前端
来源:juejin.cn/post/7401812292211081226
收起阅读 »

1. 使用openai api实现一个智能前端组件

0. 注意 本文只是提供一个思路,由于现在大模型正在飞速发展,整个生态在不久的将来或许会发生巨大的变化,文章中的代码仅供参考。 1. 一个简单的示例 假设当前时间是2023年12月28日,时间段选择器通过理解用户输入表述,自动设置值。 可以看到组件正确理解了...
继续阅读 »

0. 注意


本文只是提供一个思路,由于现在大模型正在飞速发展,整个生态在不久的将来或许会发生巨大的变化,文章中的代码仅供参考。


1. 一个简单的示例


msedge_2ReNz11Waq.gif


假设当前时间是2023年12月28日,时间段选择器通过理解用户输入表述,自动设置值。


可以看到组件正确理解了用户想要设置的时间。


2.原理简介


graph TD
输入文字描述 --> 请求语言模型接口 --> 处理语言模型响应 --> 功能操作

其实原理很简单,就是通过代码的方式问模型问题,然后让他回答。这和我们使用chatgpt一样的。


3. 实现


输入描述就不说了,就是输入框。关键在于请求和处理语言模型的接口。


最简单的就是直接使用api请求这些大模型的官方接口,但是我们需要处理各种平台之间的接口差异和一些特殊问题。这里我使用了一个开发语言模型应用的框架LangChain


3.1. LangChain


简单的说,这是一个面向语言处理模型的编程框架,从如何输入你的问题,到如何处理回答都有规范的工具来实现。


LangChain官网


// 这是一个最简单的例子
import { OpenAI } from "langchain/llms/openai";
import { ChatOpenAI } from "langchain/chat_models/openai";
// 初始化openai模型
const llm = new OpenAI({
temperature: 0.9,
});
// 准备一个输入文本
const text =
"What would be a good company name for a company that makes colorful socks?";
// 输入文本,获取响应
const llmResult = await llm.predict(text);
//=> 响应一段文本:"Feetful of Fun"

整个框架主要就是下面三个部分组成:


graph LR
A["输入模板(Prompt templates)"] --- B["语言模型(Language models)"] --- C["输出解释器(Output parsers)"]


  • Prompt templates:输入模板分一句话(not chat)对话(chat)模式,区别就是输入一句话和多句话,而且对话模式中每句话有角色区分是谁说的,比如人类AI系统。这里简单介绍一下非对话模式下怎么创建输入模板。


import { PromptTemplate } from "langchain/prompts";  

// 最简单的模板生成,使用fromTemplate传入一句话
// 可以在句子中加入{}占位符表示变量
const oneInputPrompt = PromptTemplate.fromTemplate(
`You are a naming consultant for new companies.
What is a good name for a company that makes {product}?`

);
// 也可以直接实例化设置
const twoInputPrompt = new PromptTemplate({
inputVariables: ["adjective"],
template: "Tell me a {adjective} joke.",
});

// 如果你想要这样和模型对话
// 先给出几个例子,然后在问问题
Respond to the users question in the with the following format:

Question: What is your name?
Answer: My name is John.

Question: What is your age?
Answer: I am 25 years old.

Question: What is your favorite color?
Answer:
// 可以使用FewShotPromptTemplate
// 创建一些模板,字段名随便你定
const examples = [
{
input:
"Could the members of The Police perform lawful arrests?",
output: "what can the members of The Police do?",
},
{
input: "Jan Sindel's was born in what country?",
output: "what is Jan Sindel's personal history?",
},
];
// 输入模板,包含变量就是模板要填充的
const prompt = `Human: {input}\nAI: {output}`;
const examplePromptTemplate = PromptTemplate.fromTemplate(prompt);
// 创建example输入模板
const fewShotPrompt = new FewShotPromptTemplate({
examplePrompt: examplePromptTemplate,
examples,
inputVariables: [], // no input variables
});
console.log(
(await fewShotPrompt.formatPromptValue({})).toString()
);
// 输出
Human: Could the members of The Police perform lawful arrests?
AI: what can the members of The Police do?

Human: Jan Sindel's was born in what country?
AI: what is Jan Sindel'
s personal history?
// 还有很多可以查询官网


  • Language models: 语言模型同样分为LLM(大语言模型)chat模型,其实两个差不多,就是输入多少和是否可以连续对话的区别。


import { OpenAI } from "langchain/llms/openai";  

const model = new OpenAI({ temperature: 1 });
// 可以添加超时
const resA = await model.call(
"What would be a good company name a company that makes colorful socks?",
{ timeout: 1000 } // 1s timeout
);
// 注册一些事件回调
const model = new OpenAI({
callbacks: [
{
handleLLMStart: async (llm: Serialized, prompts: string[]) => {
console.log(JSON.stringify(llm, null, 2));
console.log(JSON.stringify(prompts, null, 2));
},
handleLLMEnd: async (output: LLMResult) => {
console.log(JSON.stringify(output, null, 2));
},
handleLLMError: async (err: Error) => {
console.error(err);
},
},
],
});
// 还有一些配置可以参考文档


  • Output parsers: 顾名思义就是处理输出的模块,当语言模型回答了一段文字程序是很难提取出有用信息的, 我们通常需要模型返回一个程序可以处理的答案,比如JSON。虽然叫输出解释器,实际上是在输入信息中加入一些额外的提示,让模型能够按照需求格式输出。


// 这里用StructuredOutputParser,结构化输出解释器为例
// 使用StructuredOutputParser创建一个解释器
// 定义了输出有两个字段answer、source
// 字段的值是对这个字段的描述在
const parser = StructuredOutputParser.fromNamesAndDescriptions({
answer: "answer to the user's question",
source: "source used to answer the user's question, should be a website.",
});
// 使用RunnableSequence,批量执行任务
const chain = RunnableSequence.from([
// 输入包含了两个变量,一个是结构化解释器的“格式说明”,一个是用户的问题
PromptTemplate.fromTemplate(
"Answer the users question as best as possible.\n{format_instructions}\n{question}"
),
new OpenAI({ temperature: 0 }),
parser,
]);
// 与模型交互
const response = await chain.invoke({
question: "What is the capital of France?",
format_instructions: parser.getFormatInstructions(),
});
// 响应 { answer: 'Paris', source: 'https://en.wikipedia.org/wiki/Paris' }
// 输入的模板是这样
Answer the users question as best as possible. // 这句话就是prompt的第一句
// 下面一大段是StructuredOutputParser自动加上的,大概就是告诉模型json的标准格式应该是什么
The output should be formatted as a JSON instance that conforms to the JSON schema below.

As an example, for the schema {{"properties": {{"foo": {{"title": "Foo", "description": "a list of strings", "type": "array", "items": {{"type": "string"}}}}}}, "required": ["foo"]}}}}
the object {{"foo": ["bar", "baz"]}} is a well-formatted instance of the schema. The object {{"properties": {{"foo": ["bar", "baz"]}}}} is not well-formatted.

Here is the output schema:
```
{"type":"object","properties":{"answer":{"type":"string","description":"answer to the user's question"},"sources":{"type":"array","items":{"type":"string"},"description":"sources used to answer the question, should be websites."}},"required":["answer","sources"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}
`
``
// 这段就是调用的时候传入的问题
What is the capital of France?


// 还有很多不同的解释器
// 如StringOutputParser字符串输出解释器
// JsonOutputFunctionsParser json函数输出解释器等等

除了这三部分,还有一些方便程序操作的一些功能模块,比如记录聊天状态的Memory模块,知识库模块Retrieval等等,这些官网有比较完整的文档,深度的使用后面再来探索。


3.2. 简单版本


// 初始化语言模型
// 这里使用的openai
const llm = new OpenAI({
openAIApiKey: import.meta.env.VITE_OPENAI_KEY,
temperature: 0,
});

function App() {
const [res, setRes] = useState<string>();
const [from] = Form.useForm();
return (
<>
<div>结果:{res}</div>
<Form wrapperCol={{ span: 6 }} form={from}>
<Form.Item label="输入描述">
<Input.Search
onSearch={async (value) =>
{
setRes("正在请求");
// 直接对话模型
const text =
`现在是${dayjs().format("YYYY-MM-DD")},${value},开始结束时间是什么。请用这个格式回答{startTime: '开始时间', endTime: '结束时间'}`;
// 简单预测文本
const llmResult = await llm.predict(text);
const response = JSON.parse(llmResult)
// 解析
const { startTime, endTime } = response;
// 设置
from.setFieldsValue({
times: [dayjs(startTime), dayjs(endTime)],
});
setRes(llmResult)
}}
enterButton={<Button type="primary">确定</Button>}
/>
</Form.Item>
<Form.Item label="时间段" name="times">
<DatePicker.RangePicker />
</Form.Item>
</Form>
</>

);
}

export default App;


前面虽然能实现功能,但是有很多边界条件无法考虑到,比如有的模型无法理解你这个返回格式是什么意思,或者你有很多个字段那你就要写一大串输入模板。


3.3. 使用结构化输出解释器


// 修改一下onSearch
setRes("正在请求");
// 定义输出有两个字段startTime、endTime
const parser = StructuredOutputParser.fromNamesAndDescriptions({
startTime: "开始时间,格式是YYYY-MM-DD HH:mm:ss",
endTime: "结束时间,格式是YYYY-MM-DD HH:mm:ss",
});
const chain = RunnableSequence.from([
// 输入模板
PromptTemplate.fromTemplate(
`{format_instructions}\n现在是${dayjs().format(
"YYYY-MM-DD"
)}
,{question},开始结束时间是什么`

),
llm,
parser,
]);
const response = await chain.invoke({
question: value,
// 把输出解释器的提示放入输入模板中
format_instructions: parser.getFormatInstructions(),
});
// 这个时候经过结构化解释器处理,返回的就是json
setRes(JSON.stringify(response));
const { startTime, endTime } = response;
from.setFieldsValue({
times: [dayjs(startTime), dayjs(endTime)],
});

对于大型一点的项目,使用langChainapi可以更规范的组织我们的代码。


// 完整代码
import { OpenAI } from "langchain/llms/openai";
import { useState } from "react";
import {
PromptTemplate,
} from "langchain/prompts";
import { StructuredOutputParser } from "langchain/output_parsers";
import { RunnableSequence } from "langchain/runnables";
import { Button, DatePicker, Form, Input } from "antd";
import "dayjs/locale/zh-cn";
import dayjs from "dayjs";

const llm = new OpenAI({
openAIApiKey: import.meta.env.VITE_OPENAI_KEY,
temperature: 0,
});

function App() {
const [res, setRes] = useState<string>();
const [from] = Form.useForm();
return (
<>
<div>结果:{res}</div>
<Form wrapperCol={{ span: 6 }} form={from}>
<Form.Item label="输入描述">
<Input.Search
onSearch={async (value) =>
{
setRes("正在请求");
const parser = StructuredOutputParser.fromNamesAndDescriptions({
startTime: "开始时间,格式是YYYY-MM-DD HH:mm:ss",
endTime: "结束时间,格式是YYYY-MM-DD HH:mm:ss",
});
const chain = RunnableSequence.from([
PromptTemplate.fromTemplate(
`{format_instructions}\n现在是${dayjs().format(
"YYYY-MM-DD"
)},{question},开始结束时间是什么`
),
llm,
parser,
]);
const response = await chain.invoke({
question: value,
format_instructions: parser.getFormatInstructions(),
});
setRes(JSON.stringify(response));
const { startTime, endTime } = response;
from.setFieldsValue({
times: [dayjs(startTime), dayjs(endTime)],
});

}}
enterButton={<Button type="primary">确定</Button>}
/>
</Form.Item>
<Form.Item label="时间段" name="times">
<DatePicker.RangePicker />
</Form.Item>
</Form>
</>

);
}

export default App;

4.总结


这篇文章只是我初步使用LangChain的一个小demo,在智能组件上面,大家其实可以发挥更大的想象去发挥。还有很多组件可以变成自然语言驱动的。


随着以后大模型的小型化,专门化,我相信肯定会涌现更多的智能组件。


作者:头上有煎饺
来源:juejin.cn/post/7317440781588840486
收起阅读 »

Vue.js 自动路由:告别手动配置,让开发更轻松!

web
在使用 Vue.js 开发项目的时候,我最头疼的就是创建路由,尤其是项目越来越大的时候,管理 route.ts 或 route.js 文件简直是一场噩梦! 我曾经做过一个页面超级多的项目,每新增一个页面就要更新路由,删除页面也要更新路由文件,不然就会报错,真是...
继续阅读 »

在使用 Vue.js 开发项目的时候,我最头疼的就是创建路由,尤其是项目越来越大的时候,管理 route.tsroute.js 文件简直是一场噩梦!


我曾经做过一个页面超级多的项目,每新增一个页面就要更新路由,删除页面也要更新路由文件,不然就会报错,真是烦死人了!


所以,我开始寻找自动生成路由的方法。我在网上搜了很久,但大部分结果都是针对 Webpack 和 Vue 2 的,很难找到适合我的方案。最后,我在 Vue 的 GitHub 仓库的讨论区里提问,终于找到了答案!


那就是 Unplugin Vue Router! 它可以为 Vue 3 实现基于文件的自动路由,而且支持 TypeScript,设置起来也超级简单! 虽然官方说它还在实验阶段,但用起来已经很方便了。


创建项目,安装插件


首先,我们创建一个新的 Vue 项目。 相信大家都很熟悉用 Vue CLI 创建项目了,这里就不赘述了,不熟悉的小伙伴可以去看看 Vue.js 官网的快速入门指南。


pnpm create vue@latest 

我创建项目的时候选择了 TypeScript 和 Vue Router,这样它就会自动生成一些页面和路由。


然后,进入项目目录,安装依赖。我最近开始用 pnpm 来管理依赖,感觉还不错。


pnpm add -D unplugin-vue-router 

接下来,更新 vite.config.ts 文件, 注意要把插件放在第 0 个位置


import { fileURLToPath, URL } from "node:url";
import VueRouter from "unplugin-vue-router/vite";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

// https://vitejs.dev/config/
export default defineConfig({
plugins: [
VueRouter({
/* options */
}),
// ⚠️ Vue must be placed after VueRouter()
vue(),
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},

});

然后,更新 env.d.ts 文件,让编辑器能够识别插件的类型。


/// <reference types="vite/client" />
/// <reference types="unplugin-vue-router/client" />

最后,更新路由文件 src/router/index.ts


import { createRouter, createWebHistory } from "vue-router";
import { routes, handleHotUpdate } from "vue-router/auto-routes";

const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});

if (import.meta.hot) {
handleHotUpdate(router);
}

export default router;

创建页面,自动生成路由


现在,我们可以创建 src/pages 目录了,在这个目录下创建的 Vue 组件会自动变成路由和页面,就像 Nuxt 一样方便!


我们先在 src\pages\about.vue 创建一个关于页面:


<template>
    <div>This is the about page</div>
</template>

然后在 src\pages\index.vue 创建首页:


<template>
    <div>This is Home Page</div>
</template>

运行 pnpm dev 启动开发服务器,点击 “Home” 链接就会跳转到首页,点击 “About” 链接就会跳转到关于页面。


怎么样,是不是很方便? 如果你不熟悉路由文件夹结构,可以看看这个文档: uvr.esm.is/guide/file-…


动态路由


我们再来试试创建带参数的动态路由。在 src/pages/blog/[id].vue 创建一个组件,内容如下:


<script setup>
const { id } = useRoute().params;
</script>
<template>
    <div>This is the blog post with id: {{ id }}</div>
</template>

再次运行 pnpm dev ,然后访问 http://localhost:5173/blog/6 ,你就会看到以下内容:


vuejs 自动路由


是不是很神奇? 希望这篇简短的博客能帮助你在 Vue.js 的旅程中更轻松地创建路由!


作者:前端宝哥
来源:juejin.cn/post/7401354593588199465
收起阅读 »