Cesium为军工助力!动态绘制各类交互式态势图
态势图(Situation Map)是一种用于表示空间环境中动态或静态信息的地图,它能够展示事件、资源、威胁和其他关键因素的地理位置及其变化情况
前言
什么是态势图
态势图(Situation Map)
是一种用于表示空间环境
中动态或静态信息的地图,它能够展示事件
、资源
、威胁
和其他关键因素
的地理位置及其变化情况。
通过可视化的方式,态势图帮助决策者
在复杂环境中迅速获取关键信息,从而做出及时而准确的决策。
随着地理信息系统(GIS)的不断发展,态势图在军事
、应急管理
和地理规划
等领域中扮演着越来越重要的角色。
军工领域
在军工领域,态势图是军事指挥
与控制系统
中的核心组件。
它们能够实时展示战场上的动态信息
,如部队的部署位置、敌军动向、武器系统状态等。这种可视化工具对于战术指挥、作战计划制定和战场态势感知至关重要。
应急管理
在应急管理领域,态势图能够帮助管理者协调资源
和人员应对自然灾害、山林火灾、事故或突发事件。通过态势图,可以清晰地看到灾害影响范围
、救援力量分布
、资源需求
,逃生路线
等关键信息,从而实现有效的应急响应和资源调配。
地理规划
在地理规划中,态势图用于展示和分析区域开发
、土地利用
、交通网络
等方面的信息。能帮助规划者更清晰的理解地理空间关系、评估环境影响,并做出科学的规划决策。
Cesium中绘制态势图
OK,接下来我们主要介绍一下在Cesium中如何绘制态势图,主要包括各种箭头类型的绘制,如直线箭头
、攻击箭头
、钳击箭头
等。
源码地址在文末。
箭头绘制的核心算法
algorithm.js
是实现复杂箭头绘制的核心脚本。
这里定义了多种箭头类型的绘制算法,如双箭头(doubleArrow
)、三箭头(threeArrow
)以及带尾攻击箭头(tailedAttackArrow
)。
这些算法通过接收用户点击的多个点,并计算出箭头的控制点和多边形点来实现箭头形状的生成。
以下是doubleArrow
函数的部分代码与解析:
xp.algorithm.doubleArrow = function (inputPoint) {
// 初始化结果对象
var result = {
controlPoint: null,
polygonalPoint: null
};
// 根据输入点数量决定不同的箭头形状
var t = inputPoint.length;
if (!(2 > t)) {
if (2 == t) return inputPoint;
// 获取关键点
var o = this.points[0],
e = this.points[1],
r = this.points[2];
// 计算连接点和临时点位置
3 == t ? this.tempPoint4 = xp.algorithm.getTempPoint4(o, e, r) : this.tempPoint4 = this.points[3];
3 == t || 4 == t ? this.connPoint = P.PlotUtils.mid(o, e) : this.connPoint = this.points[4];
// 根据点的顺序计算箭头的左右侧点位
P.PlotUtils.isClockWise(o, e, r)
? (n = xp.algorithm.getArrowPoints(o, this.connPoint, this.tempPoint4, !1), g = xp.algorithm.getArrowPoints(this.connPoint, e, r, !0))
: (n = xp.algorithm.getArrowPoints(e, this.connPoint, r, !1), g = xp.algorithm.getArrowPoints(this.connPoint, o, this.tempPoint4, !0));
// 生成最终的箭头形状并返回
result.controlPoint = [o, e, r, this.tempPoint4, this.connPoint];
result.polygonalPoint = Cesium.Cartesian3.fromDegreesArray(xp.algorithm.array2Dto1D(f));
}
return result;
};
该函数首先根据输入点的数量确定是否继续进行箭头的绘制,接着计算关键点的位置,并通过getArrowPoints
函数计算出箭头形状的多个控制点,最终生成一个包含箭头形状顶点的数组。
基于Cesium的箭头实体管理
arrowClass.js
定义了具体的箭头类(如StraightArrow
)和其行为管理。
通过结合Cesium的ScreenSpaceEventHandler
事件处理机制,开发者可以方便地在地图上绘制、修改和删除箭头实体。
以下是StraightArrow
类的部分代码与解析:
StraightArrow.prototype.startDraw = function () {
var $this = this;
this.state = 1;
// 单击事件,获取点击位置并创建箭头起点
this.handler.setInputAction(function (evt) {
var cartesian = getCatesian3FromPX(evt.position, $this.viewer);
if (!cartesian) return;
// 处理点位并开始绘制箭头
if ($this.positions.length == 0) {
$this.firstPoint = $this.creatPoint(cartesian);
$this.floatPoint = $this.creatPoint(cartesian);
$this.positions.push(cartesian);
}
if ($this.positions.length == 3) {
$this.firstPoint.show = false;
$this.floatPoint.show = false;
$this.handler.destroy();
$this.arrowEntity.objId = $this.objId;
$this.state = -1;
}
$this.positions.push(cartesian.clone());
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
// 鼠标移动事件,实时更新箭头形状
this.handler.setInputAction(function (evt) {
if ($this.positions.length < 1) return;
var cartesian = getCatesian3FromPX(evt.endPosition, $this.viewer);
if (!cartesian) return;
$this.floatPoint.position.setValue(cartesian);
if ($this.positions.length >= 2) {
if (!Cesium.defined($this.arrowEntity)) {
$this.positions.push(cartesian);
$this.arrowEntity = $this.showArrowOnMap($this.positions);
} else {
$this.positions.pop();
$this.positions.push(cartesian);
}
}
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
};
在startDraw
函数中,通过设置单击和鼠标移动事件,开发者可以实时捕获用户的操作,并根据点击位置动态绘制箭头。
最终的箭头形状会随着鼠标的移动而更新,当点击完成时箭头形状被确定。
工具类与辅助函数
plotUtil.js
提供了一些用于计算几何关系的实用工具函数。
例如,distance
函数计算两个点之间的距离,而getThirdPoint
函数根据给定的两个点和角度,计算出第三个点的位置。 这些工具函数被广泛用于箭头的绘制逻辑中,以确保箭头的形状符合预期。
以下是distance
和getThirdPoint
函数的代码示例:
P.PlotUtils.distance = function (t, o) {
return Math.sqrt(Math.pow(t[0] - o[0], 2) + Math.pow(t[1] - o[1], 2));
};
P.PlotUtils.getThirdPoint = function (t, o, e, r, n) {
var g = P.PlotUtils.getAzimuth(t, o),
i = n ? g + e : g - e,
s = r * Math.cos(i),
a = r * Math.sin(i);
return [o[0] + s, o[1] + a];
};
这些函数都是为复杂箭头形状的计算提供了基础,确保在地图上绘制的箭头具有精确的几何形态。
总结
以上主要介绍了在Cesium中实现态势图的一些关键代码以及解释,更多细节请参考项目源码,如果有帮助也请给一个免费的star
。
【项目开源地址】:github.com/tingyuxuan2…
来源:juejin.cn/post/7409181068597919781
什么是混入,如何正确地使用Mixin
在 Vue.js 中,mixins
是一种代码复用机制,允许我们将多个组件共享的逻辑提取到一个独立的对象中,从而提高代码的可维护性和重用性。下面将详细介绍 mixin
的概念,并通过示例代码来说明它的用法。
什么是 Mixin?
Mixin
是一种在 Vue 组件中复用代码的方式。我们可以将一个对象(即 mixin 对象)中的数据、方法和生命周期钩子等混入到 Vue 组件中。这样,多个组件就可以共享同一份逻辑代码。
Mixin 的基本用法
1. 定义 Mixin
首先,我们定义一个 mixin 对象,其中包含数据、方法、计算属性等。比如,我们可以创建一个 commonMixin.js
文件来定义一个 mixin:
// commonMixin.js
export default {
data() {
return {
message: 'Hello from mixin!'
};
},
methods: {
greet() {
console.log(this.message);
}
},
created() {
console.log('Mixin created hook called.');
}
};
2. 使用 Mixin
在 Vue 组件中,我们可以通过 mixins
选项来引入上述定义的 mixin。例如,我们可以在 HelloWorld.vue
组件中使用这个 mixin:
<template>
<div>
<p>{{ message }}</p>
<button @click="greet">Greet</button>
</div>
</template>
<script>
// 导入 mixin
import commonMixin from './commonMixin';
export default {
name: 'HelloWorld',
mixins: [commonMixin], // 使用 mixin
mounted() {
console.log('Component mounted hook called.');
}
};
</script>
在上面的示例中,HelloWorld
组件通过 mixins
选项引入了 commonMixin
。这意味着 HelloWorld
组件将拥有 commonMixin
中定义的数据、方法和生命周期钩子。
3. Mixin 的冲突处理
如果组件和 mixin 中都定义了相同的选项,Vue 将遵循一定的优先级规则来处理这些冲突:
- 数据:如果组件和 mixin 中有相同的
data
字段,组件中的data
会覆盖 mixin 中的data
。 - 方法:如果组件和 mixin 中有同名的方法,组件中的方法会覆盖 mixin 中的方法。
- 生命周期钩子:如果组件和 mixin 中有相同的生命周期钩子(如
created
),它们都会被调用,且 mixin 中的钩子会在组件中的钩子之前调用。
// commonMixin.js
export default {
data() {
return {
message: 'Hello from mixin!'
};
},
methods: {
greet() {
console.log('Mixin greet');
}
},
created() {
console.log('Mixin created hook called.');
}
};
// HelloWorld.vue
<template>
<div>
<p>{{ message }}</p>
<button @click="greet">Greet</button>
</div>
</template>
<script>
import commonMixin from './commonMixin';
export default {
name: 'HelloWorld',
mixins: [commonMixin],
data() {
return {
message: 'Hello from component!'
};
},
methods: {
greet() {
console.log('Component greet');
}
},
created() {
console.log('Component created hook called.');
}
};
</script>
在这个例子中,HelloWorld
组件中 message
的值会覆盖 mixin 中的值,greet
方法中的实现会覆盖 mixin 中的方法,created
钩子的调用顺序是 mixin 先调用,然后组件中的 created
钩子调用。
4. 使用 Mixin 的注意事项
- 命名冲突:为了避免命名冲突,建议使用明确且独特的命名方式。
- 复杂性:过度使用 mixin 可能会导致代码难以跟踪和调试。可以考虑使用 Vue 的组合式 API 来替代 mixin,以提高代码的可读性和可维护性。
在 Vue.js 开发中,mixin
主要用于以下场景,帮助我们实现代码复用和逻辑共享:
1. 共享功能和逻辑
当多个组件需要使用相同的功能或逻辑时,mixin
是一个有效的解决方案。通过将共享的逻辑提取到一个 mixin 中,我们可以避免重复代码。例如,多个组件可能都需要处理表单验证或数据格式化,这时可以将这些功能封装到一个 mixin 中:
// validationMixin.js
export default {
methods: {
validateEmail(email) {
const re = /^[^\s@]+@[^\s@]+.[^\s@]+$/;
return re.test(email);
}
}
};
// UserForm.vue
<template>
<form @submit.prevent="handleSubmit">
<input v-model="email" placeholder="Enter your email" />
<button type="submit">Submit</button>
</form>
</template>
<script>
import validationMixin from './validationMixin';
export default {
mixins: [validationMixin],
data() {
return {
email: ''
};
},
methods: {
handleSubmit() {
if (this.validateEmail(this.email)) {
alert('Email is valid!');
} else {
alert('Email is invalid!');
}
}
}
};
</script>
2. 封装重复的生命周期钩子
有时候,多个组件可能需要在相同的生命周期阶段执行某些操作。例如,所有组件都需要在 created
钩子中初始化数据或进行 API 请求。可以将这些操作封装到 mixin 中:
// dataFetchMixin.js
export default {
created() {
this.fetchData();
},
methods: {
async fetchData() {
// 假设有一个 API 请求
try {
const response = await fetch('https://api.example.com/data');
this.data = await response.json();
} catch (error) {
console.error('Failed to fetch data:', error);
}
}
},
data() {
return {
data: null
};
}
};
// DataComponent.vue
<template>
<div>
<pre>{{ data }}</pre>
</div>
</template>
<script>
import dataFetchMixin from './dataFetchMixin';
export default {
mixins: [dataFetchMixin]
};
</script>
3. 跨组件通信
在 Vue 2 中,mixin
可以用来管理跨组件通信。例如,多个子组件可以通过 mixin 共享父组件传递的数据或方法:
// communicationMixin.js
export default {
methods: {
emitEvent(message) {
this.$emit('custom-event', message);
}
}
};
// ParentComponent.vue
<template>
<div>
<ChildComponent @custom-event="handleEvent" />
</div>
</template>
<script>
import communicationMixin from './communicationMixin';
import ChildComponent from './ChildComponent.vue';
export default {
components: { ChildComponent },
mixins: [communicationMixin],
methods: {
handleEvent(message) {
console.log('Received message:', message);
}
}
};
</script>
// ChildComponent.vue
<template>
<button @click="sendMessage">Send Message</button>
</template>
<script>
import communicationMixin from './communicationMixin';
export default {
mixins: [communicationMixin],
methods: {
sendMessage() {
this.emitEvent('Hello from ChildComponent');
}
}
};
</script>
4. 封装组件的默认行为
对于有相似默认行为的多个组件,可以将这些默认行为封装到 mixin 中。例如,处理表单提交、数据清理等:
// formMixin.js
export default {
methods: {
handleSubmit() {
console.log('Form submitted');
// 处理表单提交逻辑
},
clearForm() {
this.$data = this.$options.data();
}
}
};
// LoginForm.vue
<template>
<form @submit.prevent="handleSubmit">
<!-- 表单内容 -->
<button type="submit">Login</button>
</form>
</template>
<script>
import formMixin from './formMixin';
export default {
mixins: [formMixin]
};
</script>
结论
Vue 的 mixin
机制提供了一种简单有效的方式来复用组件逻辑,通过将逻辑封装到独立的 mixin 对象中,可以在多个组件中共享相同的代码。理解和正确使用 mixin 可以帮助我们构建更清晰、可维护的代码结构。不过,随着 Vue 3 的引入,组合式 API 提供了更强大的功能来替代 mixin,在新的开发中,可以根据需要选择合适的方案。
来源:juejin.cn/post/7409110408991768587
uniapp 授权登录、页面跳转及弹窗问题
uniapp 弹框
主要介绍了 uniapp 弹框使用的一些问题,例如 uni.showModal 中的 content 换行显示实现、uni.showToast()字数超过 7 个显示问题、以及 uni-popup 自定义弹层处理
1. uni.showModal 中的 content 换行显示实现
// 注意:\r\n在微信模拟器中无效,真机才行
const content =
"学校名:光谷一小\r\n" +
"班级名:501\r\n" +
"教师名:张哈哈\r\n"
uni.showModal({
title: "确认操作吗?",
content: content,
success: (res) => {
if (res.confirm) {
} else if (res.cancel) {
}
},
});
2. uniapp 解决 showToast 字数超过 7 个显示问题
使用 uni-app 开发小程序,不管是使用微信小程序的 wx.showToast()
API 或 uni-app 的 uni.showToast()
API 显示消息提示框,显示图标 title 文本最多显示 7 个汉字长度
,在不显示图标的情况下,大于两行不显示。
解决方案一:如果要显示超过两行的文本,使用 uview-ui
框架的 Toast 消息提示组件。
// 先在html中引入组件
<u-toast ref="uToast" />;
// 然后调用
this.$refs.uToast.show({
title: "请学生绑定图书后再布置任务!",
duration: 1500,
});
解决方案二:不显示图标,就可以显示两行了
uni.showToast({
title: "请学生绑定图书后再布置任务!",
icon: "none",
duration: 1500,
});
3. uniapp 自定义弹框 uni-popup
复杂弹框,需要自定义内容的话,只能自己写了,可以使用 uni-popup 弹出层进行实现
<template>
<uni-popup ref="popup" class="lm-popup">
<view class="lm-popup-content">
<uni-icons
class="close"
type="closeempty"
size="24"
color="#ccc"
@click="hide"
></uni-icons>
<button class="btn confirm" @click="handleAuth">微信授权登录</button>
<button class="btn cancel" @click="handleLogin">手机验证码登录</button>
</view>
</uni-popup>
</template>
<script>
export default {
name: "login-popup",
methods: {
show() {
this.$refs.popup.open("center");
},
hide() {
this.$refs.popup.close();
},
},
};
</script>
uniapp 授权登录
主要介绍了uniapp授权的几种方式,分别为临时登录授权、手机号授权、用户信息授权
1. 微信登录授权
微信小程序的登录逻辑发生了变化,要求开发者使用静默登录
,即在用户无感知的情况下进行登录操作,不需要弹出授权窗口
了。
如下所示,获取微信的临时登录凭证 code,不会弹出授权窗口了。
uni.getProvider({
// 类型为oauth,用于获取第三方登录提供商
service: "oauth",
success: (res) => {
// 输出支持的第三方登录提供商列表
if (~res.provider.indexOf("weixin")) {
// 发起登录请求,获取临时登录凭证 code
uni.login({
// 登录提供商,如微信
provider: "weixin",
success: (loginRes) => {
// 获取用户登录凭证
this.handleLogin(loginRes.code);
},
});
}
},
});
2. 微信手机号授权
对于一般的用户信息,如头像、昵称等,被视为非敏感信息,以静默登录的方式进行获取。而用户的手机号等敏感信息,是需要授权的,可以通过 open-type="getPhoneNumber"
属性来触发获取手机号码的授权弹框。
getPhoneNumber
:获取用户手机号,可以从@getphonenumber
回调中获取到用户信息,该接口一直会弹出授权弹框,具体可查看官网:uniapp.dcloud.net.cn/component/b…
<button type="default" open-type="getPhoneNumber" @getphonenumber="getPhoneNumber">获取手机号</button>
// 打开获取用户手机号的授权窗口
getPhoneNumber(e) {
console.log('getPhoneNumber', e.detail)
console.log(e.detail.encryptedData); // 获取加密数据
console.log(e.detail.iv); // 获取加密算法的初始向量
const { detail:{ code, encryptedData, iv, errMsg } } = e;
if(errMsg === 'getPhoneNumber:ok') {
// 获取成功,做对应操作
}
}
2.1 拦截默认弹框
如下图所示:一般情况下,需要先勾选隐私协议,才能弹窗。
但是@getphonenumber事件弹窗无法拦截的,就算事件中写了判断,还是会先弹窗的,没找到拦截的方法。
目前的解决方法,用了两个按钮来判断实现
<button v-if="!agree" class="login-btn" hover-class='zn-btn-green-hover' @click="handleSubmit('auth')">手机号验证登录</button>
<button v-else class="login-btn" hover-class='zn-btn-green-hover' open-type="getPhoneNumber" @getphonenumber="(e) => handleSubmit('auth', e)">手机号验证登录</button>
handleSubmit(type, e) {
if (!this.agree) {
uni.showToast({ title: '请勾选用户服务协议', icon: 'none' });
return;
}
}
3. 微信用户信息(头像、昵称)授权
可以使用uni.getUserProfile获取用户信息,如头像、昵称
该 API 对于低版本(基础库 2.10.4-2.27.0 版本
),每次触发 uni.getUserProfile
才会弹出授权窗口;
我开发时,最新的基础库为 3.3.1,不会弹出授权窗口,直接获取到值了,也是静默授权状态。
<button type="default" size="mini" @click="getUserInfo">获取用户信息</button>
getUserInfo(e) {
// 获取用户信息
uni.getUserProfile({
desc: '获取你的昵称、头像、地区及性别',
success: res => {
console.log('获取你的昵称、头像',res);
},
fail: err => {
console.log("拒绝了", err);
}
});
}
uniapp 跳转
主要介绍了 uniapp 小程序跳转的三种方式,分别为内部页面跳转、外部链接跳转、其他小程序跳转。
1. 内部页面
内部页面的跳转,可以通过如下方式:navigateTo、reLaunch、switchTab
// 保留当前页面,跳转到应用内的某个页面
uni.navigateTo({ url: "/pages/home/home" });
// 关闭所有页面,打开到应用内的某个页面
uni.reLaunch({ url: "/pages/home/home" });
// 跳转到 tabBar 页面,并关闭其他所有非 tabBar 页面
uni.switchTab({ url: "/pages/home/home" });
2. 外部链接
通过 webview
打开外部网站链接,web-view
是一个 web 浏览器组件,可以用来承载网页的容器。
// 1. 新建pages/webview/webview.vue页面
<template>
<web-view :src="url"></web-view>
</template>
<script>
export default {
data() {
return {
url: ''
}
},
onLoad(e) {
// 使用web-view标签进行跳转
this.url = decodeURIComponent(e.url)
}
}
</script>
// 2. 外链跳转使用
uni.navigateTo({url: "https://www.taobao.com"})
3. 小程序 appId
通过navigateToMiniProgram
可以打开其他小程序
// 打开其他小程序
uni.navigateToMiniProgram({
appId: "AppId", // 其他小程序的AppId
path: "pages/index/index", // 其他小程序的首页路径
extraData: {}, // 传递给其他小程序的数据
envVersion: "release", // 其他小程序的版本(develop/trial/release)
success(res) {
// 打开其他小程序成功的回调函数
},
fail(err) {
// 打开其他小程序失败的回调函数
},
});
来源:juejin.cn/post/7331717626059817023
纯css实现无限循环滚动logo墙
一、需求
在许多网站的合作伙伴一栏,常常会看到一排排无限地循环滚动的logo墙。
不久前,接到一个类似的需求。需求如下:
1、无限循环滚动;
2、鼠标hover后,暂停滚动,鼠标离开后,继续滚动;
3、支持从左往右和从右往左滚动;
4、滚动速度需要可配置。
简单动画,我们先尝试只使用css实现。
二、实现
1、marquee标签
说到无限循环滚动,很久以前marquee标签可以实现类似的功能,它可以无限循环滚动,并且可以控制方向和速度。但是该标签在HTML5中已经被弃用,虽然还可以正常使用,但w3c不再推荐使用该特性。作为一个标签,只需要负责布局,而不应该有行为特性。
了解marquee标签:marquee标签
2、css3动画
说到无限循环滚动我们会想到使用css3动画。
把animation-iteration-count设置为infinite,代表无限循环播放动画。
为了使动画运动平滑,我们把animation-timing-function设置为linear,代表动画匀速运动。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
padding-top: 200px;
}
@keyframes scrolling {
to {
transform: translateX(100vw);
}
}
.wall-item {
height: 90px;
width: 160px;
background-image: linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%);
animation: scrolling 4s linear infinite;
}
</style>
</head>
<body>
<div class="wall-item"></div>
</body>
</html>
我们上面实现了一个元素的滚动,在多个元素的时候,我们只需为每个元素设置不同的动画延迟时间(animation-delay),让每一项错落开来,就可以实现我们想要的效果了。
至于鼠标hover后暂停动画,我们只需在滚动元素hover时把animation-play-state设置为暂停即可。
有了以上思路,我很快就可以写出一个纯css实现logo墙无限循环滚动的效果。
完整示例代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--wall-item-height: 90px;
--wall-item-width: 160px;
--wall-item-number: 9;
--duration: 16s;
}
body {
padding-top: 300px;
}
@keyframes scrolling {
to {
transform: translateX(calc(var(--wall-item-width) * -1));
}
}
.wall {
margin: 30px auto;
height: var(--wall-item-height);
width: 80vw;
position: relative;
mask-image: linear-gradient(90deg, hsl(0 0% 0% / 0),
hsl(0 0% 0% / 1) 20%,
hsl(0 0% 0% / 1) 80%,
hsl(0 0% 0% / 0));
}
.wall .wall-item {
position: absolute;
top: 0;
left: 0;
transform: translateX(calc(var(--wall-item-width) * var(--wall-item-number)));
height: var(--wall-item-height);
width: var(--wall-item-width);
background-image: linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%);
animation: scrolling var(--duration) linear infinite;
cursor: pointer;
}
.wall[data-direction="reverse"] .wall-item {
animation-direction: reverse;
background-image: linear-gradient(120deg, #84fab0 0%, #8fd3f4 100%);
}
.wall .wall-item:nth-child(1) {
animation-delay: calc((var(--duration) / var(--wall-item-number)) * (var(--wall-item-number) - 1) * -1);
}
.wall .wall-item:nth-child(2) {
animation-delay: calc((var(--duration) / var(--wall-item-number)) * (var(--wall-item-number) - 2) * -1);
}
.wall .wall-item:nth-child(3) {
animation-delay: calc((var(--duration) / var(--wall-item-number)) * (var(--wall-item-number) - 3) * -1);
}
.wall .wall-item:nth-child(4) {
animation-delay: calc((var(--duration) / var(--wall-item-number)) * (var(--wall-item-number) - 4) * -1);
}
.wall .wall-item:nth-child(5) {
animation-delay: calc((var(--duration) / var(--wall-item-number)) * (var(--wall-item-number) - 5) * -1);
}
.wall .wall-item:nth-child(6) {
animation-delay: calc((var(--duration) / var(--wall-item-number)) * (var(--wall-item-number) - 6) * -1);
}
.wall .wall-item:nth-child(7) {
animation-delay: calc((var(--duration) / var(--wall-item-number)) * (var(--wall-item-number) - 7) * -1);
}
.wall .wall-item:nth-child(8) {
animation-delay: calc((var(--duration) / var(--wall-item-number)) * (var(--wall-item-number) - 8) * -1);
}
.wall .wall-item:nth-child(9) {
animation-delay: calc((var(--duration) / var(--wall-item-number)) * (var(--wall-item-number) - 9) * -1);
}
.wall:has(.wall-item:hover) .wall-item {
animation-play-state: paused;
}
</style>
</head>
<body>
<div class="wall" style="--duration:10s">
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
</div>
<div class="wall" data-direction="reverse" style="--wall-item-number:9">
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
</div>
</body>
</html>
- 可配置项:
--wall-item-height: 滚动项高度
--wall-item-width: 滚动项宽度
--wall-item-number: 滚动元素个数
--duration: 动画时长
我们也可以向上面示例代码一样,在.wall标签上,通过style属性,给每个滚动墙单独配置宽高、数量、动画时间。
- 滚动方向:
动画默认从左往右运动。我们可以在.wall标签上设置data-direction="reverse",让动画从右往左运动。
三、局限性
- 滚动元素(wall-item)太少,不足以填满包装元素(wall)时,会达不到预期效果;
(解决办法:用js把所有子元素复制一份push到最后面)
- --wall-item-number默认为9,每次子元素数量变化,需手动修改--wall-item-number值。
(解决办法:使用js计算赋值。说到要用js,那么这一点局限性或许就可以忍受了。)
3. 需要手动为每一个滚动元素设置动画延迟时间(animation-delay)。
(解决办法:可用js计算赋值。)
来源:juejin.cn/post/7408441793790410804
对象有顺序吗?
前言
对象有顺序吗?换句话说,如果我们遍历一个对象,获取属性的顺序是和属性添加时的顺序一样吗?这靠谱吗?这篇文章将为你揭晓答案。
JavaScript 对象基础
在 JavaScript 中,一个对象是一个无序的键值对集合,键通常是字符串或者 Symbol,而值可以是任何类型的数据。对象的基本创建方式如下:
const obj = {
key1: 'value1',
key2: 'value2',
}
虽然我们通常认为对象的属性是无序的,但实际上,JavaScript 对对象属性的排列有其特定的规则。
属性顺序的规则
根据 ECMAScript 规范,JavaScript 对象属性的顺序受以下规则的影响:
整数属性,会按照升序排列。
何为整数属性?指的是一个可以在不做任何更改的情况下与一个整数进行相互转换的字符串,也就是可以被解析为整数的字符串。
const obj = {
2: 'two',
1: 'one',
'3': 'three',
'a': 'alpha',
}
console.log(Object.keys(obj)); // -> ['1', '2', '3', 'a']
所有普通的字符串属性,按照其插入的顺序排列。
const obj = {
b: 'beta',
a: 'alpha',
c: 'gamma',
}
console.log(Object.keys(obj)); // -> ['b', 'a', 'c']
Symbol 类型的属性总是放在最后,并且保留其插入顺序。
const sym1 = Symbol('key1');
const sym2 = Symbol('key2');
const obj = {
[sym1]: 'value1',
1: 'one',
[sym2]: 'value2',
'a': 'alpha',
}
console.log(Object.keys(obj)); // -> ['1', 'a']
console.log(Object.getOwnPropertySymbols(obj)); // -> [sym1, sym2]
结合上述规则,我们再看一个综合示例。
const sym1 = Symbol("key1");
const sym2 = Symbol("key2");
const obj = {
[sym2]: "value2",
z: "last",
3: "three",
2: "two",
a: "alpha",
1: "one",
b: "beta",
[sym1]: "value1",
0: "zero",
};
console.log(Reflect.ownKeys(obj)); // -> ["0", "1", "2", "3", "z", "a", "b", Symbol("key2"), Symbol("key1")];
可以看到,整数属性按升序排列,普通属性按插入顺序排列,符号则放在最后并保留插入顺序。
最后
虽然 JavaScript 对象的属性被称为“无序”,但实际上它们有“特别的顺序”。整数属性会按升序排列,普通属性按插入顺序,Symbol 类型的属性总是排在最后并保留插入时的顺序。
如果文中有错误或者不足之处,欢迎大家在评论区指正。
你的点赞是对我最大的鼓励!感谢阅读~
来源:juejin.cn/post/7409668839199883314
常见呼吸灯闪烁动画
最近在需求里遇到了一个手指引导交互的动画需求。这篇文章就来讲讲如何CSS实现这个动画,如下图所示:
简单分析了一下效果,是一个手指移动到某处位置,然后会触发呼吸灯闪烁的效果,所有实现整个动画可以分两步:
呼吸灯闪烁动画
这里介绍下我遇到过得几种呼吸灯闪烁动画
第一种效果
@keyframes twinkling {
0% {
opacity: 0.2;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.12);
}
100% {
opacity: 0.2;
transform: scale(1);
}
}
.circle {
border-radius: 50%;
width: 12px;
height: 12px;
background: green;
position: absolute;
top: 36px;
left: 36px;
&::after {
content: "";
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
border-radius: 50%;
background: greenyellow;
animation: twinkling 1s infinite ease-in-out;
animation-fill-mode: both;
}
}
第二种效果
@keyframes scale {
0% {
transform: scale(1)
}
50%,
75% {
transform: scale(3)
}
78%,
100% {
opacity: 0
}
}
@keyframes scales {
0% {
transform: scale(1)
}
50%,
75% {
transform: scale(2)
}
78%,
100% {
opacity: 0
}
}
.circle {
position: absolute;
width: 12px;
height: 12px;
background-color: pink;
border-radius: 50%;
top: 36px;
left: 36px;
}
.circle:before {
content: '';
display: block;
width: 12px;
height: 12px;
border-radius: 50%;
opacity: .4;
background-color: pink;
animation: scale 1s infinite cubic-bezier(0, 0, .49, 1.02);
}
.bigcircle {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
opacity: .4;
background-color: pink;
top: 36px;
left: 36px;
animation: scales 1s infinite cubic-bezier(0, 0, .49, 1.02);
}
第三种效果
@keyframes scaless {
0% {
transform: scale(1)
}
50%,
75% {
transform: scale(3)
}
78%,
100% {
opacity: 0
}
}
.circle {
position: absolute;
width: 12px;
height: 12px;
background-color: transparent;
border-radius: 50%;
top: 36px;
left: 36px;
}
.circle:before {
content: '';
display: block;
width: 12px;
height: 12px;
border-radius: 50%;
opacity: .7;
border: 3px solid hotpink;
background-color: transparent;
animation: scaless 1s infinite cubic-bezier(0, 0, .49, 1.02);
}
小手移动动画
@keyframes animation-hand-move {
0% {
transform: translate(0, 0);
}
20% {
transform: translate(-80px, -50px);
}
25% {
transform: translate(-80px, -50px) scale(0.92) rotate(-3deg);
opacity: 1;
}
75% {
transform: translate(-80px, -50px) scale(0.92) rotate(-3deg);
opacity: 1;
}
90% {
transform: translate(-50px, -40px) scale(1) rotate(0deg);
opacity: 0.6;
}
100% {
opacity: 0;
}
}
.hard {
position: absolute;
top: 200px;
left: 318px;
width: 61px;
height: 69px;
background-size: contain;
background-repeat: no-repeat;
background-image: url('./img//174ba1f81a4d0d91a7dc45567b59fd8b.png');
animation: animation-hand-move 4s infinite linear;
}
完整动画代码
<div class="card">
<View class="round circle"></View>
<View class="round small-circle"></View>
<View class="round less-circle"></View>
<div class="hard"></div>
</div>
.card {
margin: 100px auto;
width: 480px;
height: 300px;
background-color: #333333;
border-radius: 16px;
position: relative;
}
.hard {
position: absolute;
top: 200px;
left: 318px;
width: 61px;
height: 69px;
background-size: contain;
background-repeat: no-repeat;
background-image: url('./img/174ba1f81a4d0d91a7dc45567b59fd8b.png');
animation: animation-hand-move 4s infinite linear;
}
.round {
position: absolute;
top: 144px;
left: 227px;
width: 32px;
height: 32px;
border-radius: 50%;
background-color: transparent;
&::before {
content: '';
position: absolute;
border-radius: 50%;
background: transparent;
width: 100%;
height: 100%;
top: 50%;
left: 50%;
border: 6px solid rgba(255, 255, 255, 0.5);
pointer-events: none;
transform: translate(-50%, -50%) scale(0);
opacity: 1;
}
}
.circle {
&::before {
animation: animation-wave 4s infinite linear;
}
}
.small-circle {
&::before {
animation: animation-small-wave 4s infinite linear;
}
}
.less-circle {
&::before {
animation: animation-less-wave 4s infinite linear;
}
}
@keyframes animation-wave {
0%,
20%,
100% {
opacity: 0;
}
25% {
width: 0px;
height: 0px;
transform: translate(-50%, -50%);
opacity: 1;
}
75% {
width: 64px;
height: 64px;
transform: translate(-50%, -50%);
opacity: 0;
}
}
@keyframes animation-small-wave {
0%,
20%,
100% {
opacity: 0;
}
42% {
width: 0px;
height: 0px;
transform: translate(-50%, -50%);
opacity: 1;
}
75% {
width: 64px;
height: 64px;
transform: translate(-50%, -50%);
opacity: 0;
}
}
@keyframes animation-less-wave {
0%,
20%,
100% {
opacity: 0;
}
59% {
width: 0px;
height: 0px;
transform: translate(-50%, -50%);
opacity: 1;
}
75% {
width: 64px;
height: 64px;
transform: translate(-50%, -50%);
opacity: 0;
}
}
@keyframes animation-hand-move {
0% {
transform: translate(0, 0);
}
20% {
transform: translate(-80px, -50px);
}
25% {
transform: translate(-80px, -50px) scale(0.92) rotate(-3deg);
opacity: 1;
}
75% {
transform: translate(-80px, -50px) scale(0.92) rotate(-3deg);
opacity: 1;
}
90% {
transform: translate(-50px, -40px) scale(1) rotate(0deg);
opacity: 0.6;
}
100% {
opacity: 0;
}
}
来源:juejin.cn/post/7408795408861921290
如何写出让同事崩溃的代码
废话不多说,咱直接进入主题。手把手教你如何写出让帮你维护代码的同时瞬间崩溃的代码。
一、方法或变量名字随便取
首先,让同事看不懂自己代码的第一步就是,想尽办法让他看不出来我定义的变量或者方法到底是干嘛用的。哎!对,就是让他去猜,哈哈哈。
来来来,空说没意思,举个栗子。
假设现在想要点击某个input框时,显示一个自定义的组件用于选择选择时间。
正常的写法如下:定义一个 toggleDatePicker 方法
这个一看就知道是时间选择器的显示切换方法。
但是呢,我偏不,定义成下面这样:让同事摸不着头脑,哈哈哈
当看到很多这样的方法名或变量名时,同事的表情估计时这样的。
接下来,第二招
二、方法体尽可能的长,长到不能在长
这一步至关重要,将所有逻辑全部放在一个方法中写完,坚决不分步骤,不按逻辑一步步拆分方法。让同事为我的超长方法体感到叹为观止,默默流泪。
老规矩,上栗子
假设现在有个方法需要处理比较复杂(需要递归,而且每层数据有不同的类型)的json格式的数据回显到页面上(这是用于拼一些条件)。数据格式大概是这样的
[
{
type: "group",
relation: "or",
conditions: [
{
type: "condition",
field: {
name: "员工状态",
id: 12345678
},
logic: "=",
val: 1,
relation: "and"
},
{
type: "condition",
field: {
name: "入职时间",
id: 12345678
},
logic: "<",
val: "2011-07-09",
relation: "and"
}
]
},
{
type: "condition",
field: {
name: "入职时间",
id: 12345678
},
logic: "<",
val: "2001-07-09",
relation: "and"
}
]
由于上面的 json 数组格式中是一个对象,对象都有 type 属性,一种是 group 类型(表示是一组条件),一种是 condition 类型(表示是真正的条件内容)。
因此,正常情况下,我们应该是遍历数组,不同的类型分别用不同的方法去处理,并且在处理条件中各个部分的时候分别处理。如下所示:
但是呢?咱主题是啥,让同时崩溃啊,怎么能把方法拆成这样清晰的逻辑呢。
来给你看看怎么让同事对你的代码叹为观止
怎么样,牛不牛,一个方法写完所以逻辑,从1825行一口气写到2103行,足足... 2103 - 1825 是多少来着,3减5 不够,向前借位 ,嗯。。。278 行。
****怎么样,有没有被哥的机智震惊到,如此代码。同事看到肯定心生敬佩,连连称绝。此时想到得到同事应该是这样的表情
同事还没进到方法体里面,就已经被我的 迷之方法名 和 超长方法体 所折服,接下来就让他在方法体里面快乐的遨游吧
接下来,继续让同时崩溃。
三、坚决不定义统一的变量
这个怎么说呢,就是因为有点懒,所有很多代码直接复制粘贴,多么的方便快捷。
正常情况下,如果某个我们需要的对象在是其他对象的属性,并且层次很深,我们先定义一个变量来接收这个对象,再对这个对象操作。
例如:
let a = {
b: {
c: {
d: {
name: "我是最里面的对象"
}
}
}
}
我们要对d对象进行很多次的操作时,一般先将d赋值给一个变量,然后对变量操作。如下:
var dOfA = a.b.c.d;
dOfA.name = "我现在被赋值给dOfA ";
dOfA.data = 1;
dOfA.other = false;
但是呢,我就不这么干,就是要写得整整齐齐
a.b.c.d.name = "就要这么干,你打我呀";
a.b.c.d.data = 1;
a.b.c.d.other = false;
老规矩,没有 实际的 栗子 怎么能说的形象呢,上图
正常写法:
我偏要这么写
多么的整齐划一,
全场动作必须跟我整齐划一
来左边儿 跟我一起画个龙
在你右边儿 画一道彩虹
来左边儿 跟我一起画彩虹...
咋突然哼起歌来了,不对,咱是要整同事的,怎么能偏题。
继续,此时同事应该是这个表情
然后,方法体里面只有这么点东西怎么够玩,继续 come on
四、代码能复制就复制,坚决不提成公用的方法
代码能 CV ,干嘛费劲封装成方法,而且这样不是显得我代码行数多吗?图片图片图片
就是玩儿,就是不封装
来,上栗子
看到没有,相同的代码。我在 1411行 - 1428行 写了一遍, 后面要用,在1459行-1476行复制一遍
这还不够?那我在1504-1521行再复制一遍
这下,爽了吧,哈哈哈
就是不提方法,就是玩儿,哎! 有意思
这个时候同事估计是这样的吧
怎么样,是不是很绝?不不不,这算啥
虽然以上这些会让看我代码的同事头疼,但是,只要我在公司,他们还会让我改啊。怎么能搞自己呢。
最后一步
五、离职
洋洋洒洒的写完代码,尽早离开。够不够绝,哈哈哈
六、申明:
以上场景纯属个人虚构的,单纯为了给文章增加点乐趣。写这个文章的目的是让各位程序员兄弟尽量避免写这种难以维护的代码。真的太痛苦了!代码质量、代码重构真的是编程过程中很重要的一个步骤。不能抱着能用就行的心态。还是要对自己有一定的要求。只有你看得起自己的代码,别人才有可能看得起你的代码。加油吧!各位
来源:juejin.cn/post/7293888785400856628
我花了一天时间,做了一个图片上传组件,看起来很酷实际上确实很酷
今天,我花了一天的时间做了一个图片上传组件。效果如下:
可能有人觉得,这个组件很简单,没什么技术含量,其实确实也啥技术含量。但是,我是想借这个组件,来表达一种封装的思想在里面,希望可以帮助到一些朋友。
简单的描述下这个组件的功能:
- 用户可以点击下面颜色比较绚丽的上传按钮,选择本地图片进行上传,也可以直接点击图片区域进行上传。
- 上传过程中,会有一个上传中的进度条,上传完成后,会有一个上传成功的提示,如果失败了,会有一个上传失败的提示,而且支持重试。
- 可以点击图片右上角的删除按钮,删除图片。
- 并发控制,最多只能同时上传 N 张图片,也就是所谓的限频,这里是 2 张。
是不是看了这么多功能之后,就开始有点头皮发麻了?哈哈,不要怕,这就带你了解下,如何拆解这种功能,而且,学会了这种拆解的办法,后面你遇到更加复杂的,也可以得心应手。
拆解功能,逐步实现
首先,我们思考,我们该使用自底向上的思路,还是自顶向下的思路来拆解这个功能呢?我的建议自顶向下的思路去思考架构,然后自底向上的去实现功能。
因为我们这个图片上传组件是支持多长图片同时上传的,而且,我们还需要支持上传失败重试的功能,所以,为了让功能更加聚焦,我们把关注点放在 PhotoItem 上,没一个 PhotoItem 就是一个图片上传的单元。他可以独立的上传,独立的删除,独立的重试。
那么,为了让 PhotoItem 这个组件更加简洁,我们把上传逻辑放在hooks useUpload
中,这样,PhotoItem 只需要关注自己的展示逻辑即可。
这样做的目的是做到关注点分离,通常来讲,也是符合单一职责原则的。写出来的组件维护性必定大大提升。
代码实现
我们先来看下 useUpload 的代码,因为PhotoItem 依赖他,我们先实现它。
"use client";
export const useUploader = (uploadAction) => {
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState(null);
const upload = useCallback(async (file) => {
setIsUploading(true);
setError(null);
try {
return await uploadAction(file);
} catch (err) {
setError(err.message || 'Upload failed');
} finally {
setIsUploading(false);
}
}, [uploadAction]);
const reset = useCallback(() => {
setIsUploading(false);
setError(null);
}, []);
return { upload, isUploading, error, reset };
};
可以看到,我们的 hooks 非常之简单,就是暴露了一个实现图片上传的狗子 upload,然而,他替我们的组件管理了上传中
,上传失败
,的状态,因此,接下来看,我们的PhotoItem 组件将会有多清晰。
export const PhotoItem = ({
file,
onRemove,
onUploadComplete,
onUploadError,
}) => {
const { upload, isUploading, error, reset } = useUploader();
const startUpload = useCallback(async () => {
try {
const url = await upload(file);
onUploadComplete(url);
} catch (err) {
onUploadError();
}
}, [file, upload, onUploadComplete, onUploadError]);
useEffect(() => {
startUpload();
}, [queueUpload, startUpload]);
const handleRetry = () => {
reset();
startUpload();
};
return (
<div className="relative w-full h-20">
<img
src={URL.createObjectURL(file)}
/>
{!isUploading && !error(
Uploaded
)}
{isUploading && (
<Progress />
)}
{error && (
<span>Failed</span>
)}
</div>
);
};
OK,到目前为止,还是极其简单的,但是我们貌似忘记了一个很核心的功能,限制并发数。为什么要限制并发数,因为我们自己的服务器或者三方的服务器,可能会有并发数的限制,如果我们不限制并发数,可能会导致一次传多张图片是卡住。
思考,如何限制并发数
我们想一样,是谁触发了上传的呢?是不是 PhotoItem 组件呢?是的,我们可以在 PhotoItem 组件中,去控制并发数,但是,这样做,会导致 PhotoItem 组件的逻辑变得复杂,因为他不仅要关注自己的展示逻辑,还要关注并发数的控制逻辑。这就显的不太合适了。所以,我们应该把他丢出去对吧,截止到目前为止,我们的PhotoUploader 这个组件似乎并没有干任何事情,我们思考下,并发控制的逻辑是否应该是他来呢?
答案是显然的,我们应该把并发控制的逻辑放在 PhotoUploader 组件中,因为他是整个上传组件的入口,他应该关注并发控制,而不是 PhotoItem 组件,而且最本质的原因是,PhotoItem 也不关心是否有其他的 PhotoItem 。
那么,问题来了,并发控制怎么写呢?使用什么数据结构较为合适呢?不卖关子了,我们知道,队列是最合适的数据结构,因为他是先进先出的,我们可以把上传任务放在队列中,然后,每次上传完成,就从队列中取出一个任务,继续上传。
好,我们改造一下,我们的 PhotoItem 组件,让他不要直接执行上传逻辑,而是把他做成一个任务,然后,把任务放在队列中,然后,我们在 PhotoUploader 组件中,去控制并发数。
export const PhotoItem = ({
file,
onRemove,
...
queueUpload // 加一个队列操作器
}) => {
const { upload, isUploading, error, reset } = useUploader();
...
useEffect(() => {
queueUpload(startUpload); // 修改这里
}, [queueUpload, startUpload]);
const handleRetry = () => {
reset();
queueUpload(startUpload);//修改这里
};
// .... 其他几乎不变
在来看看我们的 PhotoUploader 组件,他是如何控制并发数的。很简单,我们只需要维护一个队列,然后,每次上传完成,就从队列中取出一个任务,继续上传。
const processQueue = useCallback(() => {
while (activeUploadsRef.current < MAX_CONCURRENT_UPLOADS && uploadQueueRef.current.length > 0) {
const nextUpload = uploadQueueRef.current.shift();
activeUploadsRef.current++;
nextUpload();
}
}, []);
const queueUpload = useCallback((startUpload) => {
if (activeUploadsRef.current < MAX_CONCURRENT_UPLOADS) {
activeUploadsRef.current++;
startUpload();
} else {
uploadQueueRef.current.push(startUpload);
}
}, []);
这里,只给出最最核心的逻辑,实际上就是维护的了一个任务队列,然后,每次上传完成,就判断下队列中是否还有任务,并且是否超过了并发数,如果没有超过,并且队列中还有任务,就继续上传。仅此而已。
总结一下
这个图片上传组件,看似简单,但是,他涉及到了很多的知识点,比如并发控制,上传失败重试,组件拆解,自顶向下的架构设计,自底向上的功能实现。我们在实现这个组件的过程中。有过很多的思考,比如:
- 如何拆解功能,让组件更加聚焦,做到关注点分离。
- 控制并发数,使用队列是最合适的数据结构。
- 如何设计一个 hooks,让组件更加简洁。
- 以及自顶向下的架构设计,自底向上的功能实现。
只有建立起这些系统性的思维,我们才能在遇到更加复杂的问题时,得心应手。希望这篇文章对你有所帮助。
欢迎关注我老码沉思录,获取我最新的知识分享。
来源:juejin.cn/post/7394854112510951443
Taro搭建支付宝小程序实战
1. 引言
在当今多端应用开发的趋势下,许多开发者面临着需要在不同平台(如微信、支付宝、百度等小程序,以及H5和React Native应用)上编写和维护多套代码的挑战。为了解决这一问题,市场上涌现了多种跨端开发框架,旨在帮助开发者实现“一次编写,多端运行”的目标。Taro 是其中最为流行的框架之一,但在选择开发工具时,了解其他同类框架的优缺点非常重要。
1.1 类似框架的对比
以下是 Taro、Uniapp、WePY 和 MPX 四种多端开发框架的优缺点对比表:
框架 | 优点 | 缺点 |
---|---|---|
Taro | - 支持使用 React 语法,符合许多前端开发者的使用习惯。 - 广泛支持多端,包括微信小程序、支付宝小程序、H5、React Native 等。 - 活跃的社区和丰富的插件生态系统。 - 提供了完善的跨平台 API 兼容性,减少了平台差异的处理工作。 | - 构建时间相对较长,尤其是在多端同时输出时。 - 部分高级特性在某些平台上可能不完全兼容。 |
Uniapp | - 使用 Vue 语法,适合 Vue 开发者。 - 支持广泛的多端输出,包括小程序、H5、App(通过原生渲染)。 - 简单易上手,适合中小型项目。 | - 对复杂业务场景的支持有限,灵活性不如 Taro。 - 生态系统相对 Taro 较弱,插件丰富度不及 Taro。 |
WePY | - 针对微信小程序的开发框架,支持类 Vue 语法。 - 轻量级,专注于微信小程序的开发,简单直接。 | - 多端支持较弱,主要针对微信小程序,跨平台能力不足。 - 社区相对较小,更新频率较慢。 |
MPX | - 提供了增强的组件化编程能力,适合大型复杂小程序项目。 - 拥有更好的编译性能,构建速度较快。 | - 使用自定义语法,学习成本较高。 - 社区资源较少,生态系统不如 Taro 和 Uniapp 丰富。 |
通过这个对比表,我们可以根据项目需求和团队的技术栈选择最适合的多端开发框架。每个框架都有其独特的优势和局限性,因此选择时需要权衡各方面的因素。
1.2 为什么选择 Taro
Taro 作为一个基于 React 的多端开发框架,有以下几大优势使得它在众多选择中脱颖而出:
- 跨平台支持广泛:Taro 支持微信小程序、支付宝小程序、H5、React Native、快应用等多个平台,能够极大地提升开发效率,减少代码重复编写的成本。
- React 生态支持:Taro 使用 React 语法,这使得许多已有的 React 组件和库可以直接复用,开发者不需要学习新的开发模式,便能快速上手。
- 成熟的生态系统:Taro 拥有丰富的社区插件和第三方支持,提供了大量开箱即用的功能模块,如状态管理、路由管理等,这些都能帮助开发者更快地构建应用。
- 持续的更新与支持:Taro 由京东维护,得到了持续的更新与支持,具有较强的社区活力,能够及时响应开发者的需求和问题。
Taro 作为一个成熟且功能强大的多端开发框架,特别适合那些希望一次开发、多平台运行的项目需求。它不仅简化了跨平台开发的复杂性,还提供了丰富的功能支持,使得开发过程更加高效和愉悦。因此,我们选择 Taro 作为开发支付宝小程序的首选工具。
2. 环境搭建
2.1 安装 Taro CLI
首先,你需要安装 Taro 的 CLI 工具。确保你已经安装了 Node.js 环境,然后运行以下命令来全局安装 Taro CLI:
npm install -g @tarojs/cli
2.2 创建项目
安装完成后,可以通过以下命令来创建一个新的 Taro 项目:
taro init myApp
在创建过程中,Taro 会询问你一些选项,如选择框架(默认 React)、CSS 预处理器(如 Sass、Less)等。选择合适的选项后,Taro 会自动生成项目的目录结构和配置文件。
2.3 配置支付宝小程序
在 Taro 项目中,配置支付宝小程序的输出涉及多个方面,包括基本的项目配置、支付宝小程序特有的扩展配置、以及一些针对支付宝平台的优化设置。以下是详细的配置步骤和相关说明。
2.3.1 基本配置
首先,需要在项目的 config/index.js
文件中进行基础配置,确保 Taro 能够正确编译并输出支付宝小程序。
const config = {
projectName: 'myApp',
designWidth: 750,
deviceRatio: {
'640': 2.34 / 2,
'750': 1,
'828': 1.81 / 2
},
sourceRoot: 'src',
outputRoot: 'dist',
plugins: [],
defineConstants: {},
copy: {
patterns: [],
options: {}
},
framework: 'react',
compiler: 'webpack5',
cache: {
enable: true
},
mini: {
postcss: {
autoprefixer: {
enable: true,
config: {}
},
pxtransform: {
enable: true,
config: {}
},
url: {
enable: true,
config: {
limit: 10240 // 设置转换尺寸限制
}
}
}
},
h5: {
publicPath: '/',
staticDirectory: 'static',
esnextModules: [],
postcss: {
autoprefixer: {
enable: true,
config: {}
},
cssModules: {
enable: false, // 默认是 false,若需要支持 CSS Modules,设置为 true
config: {
namingPattern: 'module', // 转换模式,支持 global/module
generateScopedName: '[name]__[local]___[hash:base64:5]'
}
}
}
}
}
2.3.2 支付宝小程序特有的配置
在 config/index.js
文件中,需要在 mini
配置块中进一步设置支付宝小程序的特有配置。Taro 会自动生成支付宝小程序所需的 app.json
、project.config.json
等配置文件,但你可以根据需求自定义一些额外的配置。
mini: {
compile: {
exclude: ['src/lib/alipay-lib.js'] // 示例:排除不需要编译的文件
},
postcss: {
autoprefixer: {
enable: true,
config: {
browsers: ['last 3 versions', 'Android >= 4.1', 'ios >= 8']
}
}
},
// 支付宝小程序特有的配置
alipay: {
component2: true, // 启用支付宝小程序的基础组件规范 v2
axmlStrictCheck: true, // 严格的 axml 校验,帮助发现潜在问题
renderShareComponent: true, // 启用动态组件渲染
usingComponents: { // 注册全局自定义组件
'custom-button': '/components/custom-button/index'
},
// 支付宝扩展配置
plugins: {
myPlugin: {
version: '1.0.0',
provider: 'wx1234567890' // 插件提供者的AppID
}
},
window: {
defaultTitle: '支付宝小程序', // 设置小程序默认的标题
pullRefresh: true, // 支持下拉刷新
allowsBounceVertical: 'YES' // 支持竖向弹性滚动
},
pages: [
'pages/index/index',
'pages/detail/detail'
],
tabBar: {
color: '#000000',
selectedColor: '#1c1c1c',
backgroundColor: '#ffffff',
borderStyle: 'black',
list: [{
pagePath: 'pages/index/index',
text: '首页',
iconPath: 'assets/tabbar/home.png',
selectedIconPath: 'assets/tabbar/home-active.png'
}, {
pagePath: 'pages/detail/detail',
text: '详情',
iconPath: 'assets/tabbar/detail.png',
selectedIconPath: 'assets/tabbar/detail-active.png'
}]
}
}
}
2.3.3 支付宝小程序页面配置
每个页面的配置都在 pages.json
文件中进行定义,Taro 会自动处理这些配置。但你可以根据需要进一步自定义每个页面的表现,如是否启用下拉刷新、页面背景颜色等。
{
"pages": [
"pages/index/index",
"pages/detail/detail"
],
"window": {
"defaultTitle": "我的小程序",
"titleBarColor": "#ffffff",
"navigationBarBackgroundColor": "#000000",
"navigationBarTextStyle": "white",
"backgroundColor": "#ffffff",
"enablePullDownRefresh": true // 启用下拉刷新
}
}
2.3.4 引入支付宝小程序扩展组件
支付宝小程序支持扩展组件和插件,这些可以直接在 usingComponents
中进行引入。例如,如果你需要使用支付宝小程序特有的扩展组件如 RichText
或 Input
,可以在 alipay
配置中进行设置:
alipay: {
usingComponents: {
'rich-text': 'plugin://myPlugin/rich-text', // 引用插件中的组件
'custom-button': '/components/custom-button/index' // 引入自定义组件
}
}
2.3.5 设置支付宝小程序的分包加载
对于较大或复杂的支付宝小程序,你可能希望启用分包加载功能,以减少主包大小并提升加载速度。Taro 支持在配置文件中设置分包:
subPackages: [
{
root: 'packageA',
pages: [
'pages/logs/logs'
]
},
{
root: 'packageB',
pages: [
'pages/index/index'
]
}
],
2.3.6 环境变量配置
在开发和生产环境下,可能需要不同的配置。Taro 支持通过环境变量进行配置管理。你可以在项目根目录下创建 .env
文件,并定义不同环境下的变量:
// .env.development
TARO_ENV = 'alipay'
API_BASE_URL = 'https://api-dev.example.com'
// .env.production
TARO_ENV = 'alipay'
API_BASE_URL = 'https://api.example.com'
然后在代码中通过 process.env
访问这些变量:
const apiBaseUrl = process.env.API_BASE_URL
2.3.7 自定义 Webpack 配置
如果你需要更复杂的配置或优化,可以通过 config/index.js
中的 webpackChain
属性来自定义 Webpack 配置。例如,添加自定义的插件或优化构建过程:
mini: {
webpackChain (chain) {
chain.plugin('analyzer')
.use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin, [])
}
}
2.3.8 部署和调试
在配置完成后,可以通过以下命令生成支付宝小程序的构建文件:
taro build --type alipay
生成的代码会存放在 dist
目录下,然后可以通过支付宝开发者工具打开并进行调试。
2.3.9 常见问题及解决方法
- 自定义组件路径错误:确保
usingComponents
中的路径正确,特别是相对于dist
目录的相对路径。 - 跨域请求问题:在支付宝小程序中进行网络请求时,确保在
config/index.js
中配置了正确的sourceRoot
和outputRoot
。 - 调试模式不一致:在开发和生产环境中使用不同的 API 端点时,确保环境变量配置正确并在代码中正确使用。
以上是使用 Taro 搭建支付宝小程序的详细配置步骤,涵盖了从基础配置到扩展功能的方方面面。通过这些配置,你可以更加灵活地控制支付宝小程序的表现和功能,满足项目的多样化需求。
3. 重要的 API 和组件
3.1 基础组件
Taro 提供了许多常用的基础组件,如 View
、Text
、Button
等,这些组件与 React 组件类似,但 Taro 的组件具有跨平台能力。
import { View, Text, Button } from '@tarojs/components'
const MyComponent = () => (
<View>
<Text>Hello, Taro!</Text>
<Button onClick={() => alert('Clicked!')}>Click me</Button>
</View>
)
3.2 事件处理
Taro 事件处理机制与 React 类似,可以直接使用 onClick
、onChange
等事件属性。此外,Taro 支持跨平台的事件兼容处理,不需要担心事件名称的差异。
3.3 路由跳转
Taro 提供了 Taro.navigateTo
、Taro.redirectTo
等 API 用于在小程序中进行页面跳转。可以根据具体需求选择合适的跳转方式。
Taro.navigateTo({
url: '/pages/detail/index?id=123'
})
3.4 使用 Hooks
在 Taro 中可以使用 React 的 Hooks,如 useState
、useEffect
等,来管理组件的状态和生命周期。
import { useState, useEffect } from 'react'
import { View } from '@tarojs/components'
const Counter = () => {
const [count, setCount] = useState(0)
useEffect(() => {
Taro.setNavigationBarTitle({ title: `Count: ${count}` })
}, [count])
return (
<View>
<Button onClick={() => setCount(count + 1)}>Increment</Button>
<Text>{count}</Text>
</View>
)
}
4. 常见问题及坑点
4.1 样式问题
支付宝小程序的 CSS 支持度与微信小程序有所不同,某些高级的 CSS 特性可能无法生效。建议使用基础的 CSS 进行布局,并在项目中引入适当的 polyfill。
4.2 API 差异
在使用 Taro 开发支付宝小程序时,虽然 Taro 提供了跨平台的 API 兼容性,但是由于各个小程序平台的底层架构和能力不同,仍然存在一些需要特别注意的 API 差异。以下将详细说明一些与原生微信小程序不同的、并且在支付宝小程序中经常会用到的特殊 API。
4.2.1 支付宝特有的 API
1. my.request 与 wx.request
尽管 Taro 封装了 Taro.request
来统一请求接口,但在某些特殊情况下,开发者可能需要直接使用原生 API。支付宝小程序的 my.request
与微信的 wx.request
基本相同,但支持一些额外的配置选项,如 timeout
等。
示例:
// 支付宝小程序
my.request({
url: 'https://api.example.com/data',
method: 'GET',
timeout: 5000, // 设置请求超时时间
success: (res) => {
console.log(res.data);
},
fail: (err) => {
console.error(err);
}
});
在微信小程序中,wx.request
不支持 timeout
配置。
2. my.alert 和 wx.showModal
my.alert
是支付宝小程序中用来展示提示框的 API,而微信小程序使用 wx.showModal
来实现类似功能。my.alert
仅支持一个按钮,而 wx.showModal
可以支持两个按钮(确定和取消)。
示例:
// 支付宝小程序
my.alert({
title: '提示',
content: '这是一个提示框',
buttonText: '我知道了',
success: () => {
console.log('用户点击了确定按钮');
}
});
// 微信小程序
wx.showModal({
title: '提示',
content: '这是一个提示框',
showCancel: false, // 不显示取消按钮
success: (res) => {
if (res.confirm) {
console.log('用户点击了确定按钮');
}
}
});
3. my.getAuthCode 与 wx.login
支付宝小程序的 my.getAuthCode
用于获取用户的授权码,通常用于登录验证或支付场景。而微信小程序使用 wx.login
获取用户的登录凭证(code
),这两者在使用上有所不同。
示例:
// 支付宝小程序
my.getAuthCode({
scopes: 'auth_user',
success: (res) => {
console.log('用户授权码:', res.authCode);
},
fail: (err) => {
console.error('获取授权码失败:', err);
}
});
// 微信小程序
wx.login({
success: (res) => {
console.log('用户登录凭证:', res.code);
},
fail: (err) => {
console.error('获取登录凭证失败:', err);
}
});
4. my.navigateToMiniProgram 与 wx.navigateToMiniProgram
这两个 API 都用于跳转到其他小程序。虽然功能类似,但在支付宝小程序中,my.navigateToMiniProgram
有一些额外的参数,比如 extraData
,用于在跳转时传递数据。
示例:
// 支付宝小程序
my.navigateToMiniProgram({
appId: '2021000000000000',
path: 'pages/index/index',
extraData: {
foo: 'bar'
},
success: () => {
console.log('跳转成功');
},
fail: (err) => {
console.error('跳转失败:', err);
}
});
// 微信小程序
wx.navigateToMiniProgram({
appId: 'wx1234567890',
path: 'pages/index/index',
extraData: {
foo: 'bar'
},
success: () => {
console.log('跳转成功');
},
fail: (err) => {
console.error('跳转失败:', err);
}
});
5. my.tradePay 与 wx.requestPayment
my.tradePay
是支付宝小程序用于发起支付的 API,而微信小程序使用 wx.requestPayment
实现同样的功能。两者的参数配置有所不同,尤其是在支付方式和返回结果处理上。
示例:
// 支付宝小程序
my.tradePay({
tradeNO: '202408280000000000001',
success: (res) => {
if (res.resultCode === '9000') {
console.log('支付成功');
} else {
console.log('支付失败', res.resultCode);
}
},
fail: (err) => {
console.error('支付失败:', err);
}
});
// 微信小程序
wx.requestPayment({
timeStamp: '1609459200',
nonceStr: '5K8264ILTKCH16CQ2502SI8ZNMTM67VS',
package: 'prepay_id=wx20170101abc1234567890',
signType: 'MD5',
paySign: 'ABCD1234',
success: () => {
console.log('支付成功');
},
fail: (err) => {
console.error('支付失败:', err);
}
});
6. my.chooseCity 与 wx.chooseLocation
支付宝小程序提供了 my.chooseCity
API 用于选择城市,而微信小程序没有直接对应的 API,但 wx.chooseLocation
可以选择位置,且在选址过程中包含了城市信息。
示例:
// 支付宝小程序
my.chooseCity({
showLocatedCity: true, // 显示当前所在城市
success: (res) => {
console.log('选择的城市:', res.city);
}
});
// 微信小程序
wx.chooseLocation({
success: (res) => {
console.log('选择的位置:', res.name);
console.log('所在城市:', res.address);
}
});
4.2.2 差异化 API 使用的注意事项
- 功能测试:由于 API 存在差异,所以我们需要在不同平台上进行充分测试,确保应用逻辑在所有平台上都能正常运行。
- 代码隔离:对于平台特有的 API,建议通过
Taro.getEnv()
判断运行环境,并使用条件判断来分别调用不同平台的 API,从而实现代码的隔离与复用。 - 兼容性处理:某些 API 在不同平台上可能有不同的参数或返回值格式,因此需要根据平台特性进行兼容性处理。
通过对以上 API 差异的详细了解,希望我们可以更好地在 Taro 项目中处理支付宝小程序与微信小程序的不同需求,提升应用的跨平台兼容性和用户体验。
4.3 路由管理
在 Taro 中,路由管理是跨平台开发中非常重要的一环。尽管 Taro 封装了微信小程序和支付宝小程序的路由操作,但在使用过程中仍然存在一些差异。以下详细说明 Taro 在支付宝和微信小程序中的路由管理方式,包括不同的跳转方式及参数的获取。
4.3.1 路由跳转方式
Taro 中提供了多种路由跳转方式,主要包括 Taro.navigateTo
、Taro.redirectTo
、Taro.switchTab
、Taro.reLaunch
和 Taro.navigateBack
。这些方法封装了微信和支付宝小程序的原生跳转方式,适用于不同的使用场景。
1. Taro.navigateTo
Taro.navigateTo
用于跳转到应用内的指定页面,新的页面会被加入到页面栈中。
示例:
Taro.navigateTo({
url: '/pages/detail/index?id=123&name=abc'
});
- 微信小程序: 页面栈最大深度为10,超过后会自动释放栈顶页面。
- 支付宝小程序: 页面栈最大深度为10,同样超过后会自动释放栈顶页面。
2. Taro.redirectTo
Taro.redirectTo
用于关闭当前页面并跳转到指定页面,跳转后无法返回到原页面。
示例:
Taro.redirectTo({
url: '/pages/home/index'
});
- 微信小程序 和 支付宝小程序 都会将当前页面从栈中移除,不允许用户回退。
3. Taro.switchTab
Taro.switchTab
用于跳转到指定的 tabBar 页面,并关闭其他所有非 tabBar 页面。
示例:
Taro.switchTab({
url: '/pages/home/index'
});
- 微信小程序:
url
必须是 tabBar 页面,否则会报错。 - 支付宝小程序: 同样必须是 tabBar 页面,但支付宝小程序支持使用
extraData
传递额外数据。
4. Taro.reLaunch
Taro.reLaunch
用于关闭所有页面并跳转到指定页面,适用于需要重置应用状态的场景。
示例:
Taro.reLaunch({
url: '/pages/home/index'
});
- 微信小程序 和 支付宝小程序 行为一致,都会关闭所有页面并创建一个新的页面栈。
5. Taro.navigateBack
Taro.navigateBack
用于关闭当前页面并返回到上一级页面或多级页面。
示例:
Taro.navigateBack({
delta: 1 // 返回上一级页面
});
- 微信小程序 和 支付宝小程序 都支持通过
delta
指定返回的页面层级。
4.3.2 获取路由参数
无论是通过哪种方式跳转,页面之间通常需要传递参数。在 Taro 中,参数的传递和获取可以通过 this.$router.params
实现。以下是如何在页面中获取路由参数的详细说明。
1. URL 参数传递
当使用 Taro.navigateTo
或其他跳转方法时,可以在 url
中通过 query
传递参数。
示例:
// 页面跳转
Taro.navigateTo({
url: '/pages/detail/index?id=123&name=abc'
});
// 在目标页面获取参数
componentDidMount() {
const { id, name } = this.$router.params;
console.log('ID:', id); // 输出:ID: 123
console.log('Name:', name); // 输出:Name: abc
}
- 微信小程序: 参数会自动编码并附加到 URL 后。
- 支付宝小程序: 行为类似微信小程序,参数通过 URL query 传递。
2. extraData
参数传递
支付宝小程序允许通过 extraData
传递复杂对象,这在某些复杂场景下非常有用。
示例:
// 页面跳转
my.navigateTo({
url: '/pages/detail/index',
extraData: {
info: {
id: 123,
name: 'abc'
}
}
});
// 在目标页面获取参数
componentDidMount() {
const { info } = this.$router.params;
console.log('Info:', info); // 输出:Info: { id: 123, name: 'abc' }
}
- 微信小程序: 目前不支持
extraData
参数传递,但可以通过globalData
或其他全局状态管理工具如 Redux 实现类似效果。
3. 场景值与 scene
参数
在小程序的入口页面,通常会涉及到场景值(scene
)的获取。Taro 提供了 this.$router.params.scene
来获取微信小程序中的 scene
值,这在处理分享或扫码进入时非常重要。
示例:
componentDidMount() {
const scene = this.$router.params.scene;
console.log('Scene:', scene); // 输出对应的场景值
}
- 微信小程序: 支持
scene
参数传递,主要用于扫码进入或分享。 - 支付宝小程序: 不直接支持
scene
,但可以通过其他方式获取进入场景(如my.getLaunchOptionsSync
)。
4.3.3 注意事项
- 页面栈限制:无论是微信还是支付宝小程序,都有页面栈深度限制(通常为10层)。在开发复杂应用时,合理控制页面跳转的深度,避免栈溢出。
- 参数编码问题:确保传递的 URL 参数已经过适当的编码,避免特殊字符引发问题。
- 页面返回的数据传递:Taro 并未封装类似
onActivityResult
的机制,但可以通过全局状态管理或eventBus
模式来实现页面返回的数据传递。
4.3.4 其他事项
通过上面的一些介绍,就可以更加灵活地使用 Taro 进行路由管理,充分利用不同平台的特性,提升应用的导航体验和用户体验。在使用 Taro 开发多端应用时,除了路由管理和 API 差异之外,还有一些关键点和常见的坑需要注意,以确保应用的稳定性、性能和可维护性。以下是一些使用 Taro 过程中需要特别注意的事项:
1. 跨平台兼容性
尽管 Taro 旨在提供跨平台的开发体验,但不同平台在渲染引擎、组件行为和 API 支持上仍有差异。开发者需要在每个目标平台上进行充分测试,确保功能和表现一致。
注意事项:
- 组件兼容性:某些 Taro 组件在不同平台上的表现可能有所不同,如
scroll-view
的行为在微信和支付宝小程序中略有差异。 - 样式兼容性:不同平台的样式支持不尽相同,如支付宝小程序对部分 CSS 属性的支持较弱,需要进行适配。
- API 兼容性:Taro 提供的统一 API 在不同平台上可能会有细微的差异,建议使用
Taro.getEnv()
进行环境判断,以便针对特定平台编写适配代码。
2. 状态管理
Taro 支持使用多种状态管理工具,如 Redux、MobX、Recoil 等。根据项目的复杂度和团队的技术栈选择合适的状态管理方案。
注意事项:
- 全局状态管理:对于跨页面的数据共享,使用全局状态管理工具能有效避免组件之间直接传递数据的问题。
- 性能优化:在使用状态管理工具时,注意避免不必要的状态更新,尤其是在大规模应用中,应当进行性能调优以减少重渲染。
3. 性能优化
Taro 封装了小程序的框架,尽管提供了便捷性,但这也带来了一些性能开销。性能优化是确保 Taro 应用顺畅运行的关键。
注意事项:
- 懒加载:对不常用的组件或页面使用懒加载技术,减少初次渲染的压力。
- 分包加载:对于较大的应用,可以使用分包加载(特别是在微信小程序中)来优化启动速度。
- 减少组件嵌套:过多的组件嵌套会增加渲染负担,尽量保持组件结构的扁平化。
- 长列表优化:对长列表(如商品列表、评论列表)使用虚拟列表技术,避免一次性加载大量数据。
4. 开发工具与调试
Taro 提供了开发者工具来简化开发和调试过程,但在实际项目中,调试复杂问题时可能会遇到一些挑战。
注意事项:
- 使用 Taro CLI:Taro CLI 提供了丰富的命令行工具,帮助你快速生成项目、构建应用和调试代码。
- 跨平台调试:确保在每个平台的开发者工具中进行调试,并使用平台特有的工具,如微信小程序开发者工具和支付宝 IDE。
- 源代码映射:使用源代码映射(Source Map)功能来调试编译后的代码,方便追踪错误。
5. 小程序的限制
各个小程序平台都有其独特的限制,如包大小限制、API 速率限制、页面栈限制等。在开发过程中,必须遵守这些限制,以免在发布或运行时遇到问题。
注意事项:
- 包大小限制:微信和支付宝小程序对主包和分包的大小都有严格限制,尽量减少不必要的资源,压缩图片和代码。
- 页面栈限制:小程序的页面栈深度通常为 10 层,超出后可能会引发崩溃,需要合理设计页面的跳转逻辑。
- 请求速率限制:各平台对网络请求的速率和并发量都有要求,应当合并请求或使用请求队列来控制频率。
6. 国际化支持
如果应用需要支持多语言,Taro 提供了基础的国际化支持,但由于不同平台的特性,可能需要额外的配置和适配。
注意事项:
- 文本管理:使用统一的国际化管理工具,如 i18next 或自定义的国际化方案。
- 格式化问题:不同平台对日期、货币等格式化方式支持不同,使用第三方库(如
moment.js
)来统一格式化操作。 - 右到左(RTL)布局:如果应用需要支持 RTL 语言,确保在每个平台上都正确实现 RTL 布局。
7. 版本管理与更新
在多端开发中,版本管理和应用更新也是需要特别注意的地方。不同平台对更新机制的支持不尽相同,需要有针对性的处理策略。
注意事项:
- 小程序版本控制:在不同平台上发布新版本时,注意同步版本号,并在应用内做好版本控制。
- 热更新:Taro 目前不直接支持热更新,但可以通过后台配置管理、版本检测等方式实现相似的效果。
- 数据迁移:在更新过程中,可能需要进行数据迁移(如数据库结构变更),确保用户数据的完整性。
8. 社区与文档支持
Taro 社区活跃,文档也在不断完善,但在实际开发中遇到问题时,了解如何有效利用社区资源和官方文档也很重要。
注意事项:
- 官方文档:Taro 官方文档非常详细,建议在遇到问题时首先查阅文档,以获取官方推荐的解决方案。
- 社区支持:遇到文档未覆盖的问题,可以到 GitHub Issues、Gitter 或其他开发者社区寻求帮助。
- 示例项目:参考官方或社区提供的示例项目,可以帮助你快速上手并解决常见问题。
通过注意以上关键点,开发者可以更好地利用 Taro 的跨平台能力,同时避免常见的坑和问题,提升开发效率和应用质量。
官方文档地址: docs.taro.zone/docs
5. 结语
结语
Taro 的出现不仅解决了多端开发的复杂性问题,还大大提升了开发效率和代码的可维护性。通过统一的 API 和组件库,Taro 让开发者无需深入了解每个小程序平台的细节,即可快速构建和部署功能丰富的应用。
然而,正如任何工具或框架一样,Taro 并非完美无缺。跨平台开发固有的挑战仍然存在,包括平台间的差异、性能优化需求、状态管理复杂性,以及不同平台特有的限制。这些挑战提醒我们,尽管 Taro 能够极大地简化开发流程,但在开发过程中依然需要细致地进行测试、调优和适配工作。
从选择 Taro 作为开发框架,到深入了解其核心功能和最佳实践,再到避开潜在的坑和问题,需要全方位地掌握 Taro 的使用技巧。通过合理使用 Taro 的能力,结合自身项目的实际需求,开发者可以实现跨平台的一致用户体验,并保持代码库的可扩展性和维护性。
展望未来,随着小程序生态的不断发展和 Taro 框架的持续更新,开发者将会有更多的机会去探索和创新。Taro 的灵活性和强大的跨平台能力为我们提供了无限的可能,无论是在支付宝小程序、微信小程序,还是在更多的平台上,都能为用户带来一致、流畅的体验。
在使用 Taro 进行多端开发的过程中,我们不仅仅是编写代码,更是在打造一款能够适应多平台需求的高质量应用。通过不断学习和实践,我们能够充分发挥 Taro 的潜力,让每一个用户无论在哪个平台上使用我们的应用,都能感受到同样的便捷与愉悦。
最终,无论是初次使用 Taro 的新手,还是已经熟练掌握的老手,持续学习和优化始终是提升开发能力的关键。Taro 为我们提供了强大的工具,剩下的就是如何用好这些工具,创造出色的产品。相信随着更多开发者的加入和贡献,Taro 生态将会更加繁荣,为跨平台开发带来更多的可能性和惊喜。
作者:洞窝-海林
来源:juejin.cn/post/7408138735798616102
优雅实现任意形状的水球图,领导看了都说好
前言
翌日
我吃着早餐,划着水。
不一会,领导走了过来。
领导:小伙子,你去XX项目实现一个设备能源图,要求能根据剩余能量显示水波高低。
我: 啊?我?这个项目我没看过代码。
领导:任务有点急,你自己安排时间吧,好好搞,给你争取机会。
我:好吧。(谁叫咱只是一个卑微的打工人,只能干咯😎👌😭。)
分析
看到图,类似要实现这样一个立方体形状的东西,然后需要根据剩余电量显示波浪高低。
我心想,这不简单吗,这不就是一个水球图,恰好之前看过echarts中有一个水球图的插件。
想到这,我立马三下五除二,从echarts官网上下载了这个插件,心想下载好了就算搞定了。
波折
哪知,这个需求没有想象中的那么简单,UI设计的图其实是一个伪3D立方体,通过俯视实现立体效果。并且A面和B面都要有波浪。
这就让我犯了难,因为官方提供的demo没有这样的形状,最相近也就是最后一个图案。
那把两个最后一个图案拼接起来,组成A、B面,不就可以达到我们的效果了吗,然后最后顶上再放一个四边形C面,不就可以完美解决了。
想法是好的,但是具体思考实践方案起来就感觉麻烦了。根据我平时的解决问题的经验:如果方案实践起来,发现很麻烦就说明方法错了,需要换个方向思考。
于是我开始翻阅官方文档,找到了关于形状的定义shape属性。
救世主shape
它支持三种方式定义形状
- 最基础的是,直接编写属性内置的值,支持一些内置的常见形状如:
'circle'
,'rect'
,'roundRect'
,'triangle'
,'diamond'
,'pin'
,'arrow'
- 其次,它还支持根据容器形状填充
container
,具体来说就是可以填充满你的渲染容器。比如一个300X300的div,设置完shape:'container
'后,他的渲染区域就会覆盖这个div大小。此时,你可以调整div的形状实现想要的图案,比如
我们用两个div演示,我们将第二个div样式设置为
border-radius: 100%;
第一个图形就为方形,第二个就成为了经典圆形水球图。我们可以根据需要自行让div变成我们想要的形状。
- 最后,也就是我们这次要说的重点,他支持SVG
path://
路径。
我们可以看到第二种方式实现复杂的图形有局促性,第三种方式告诉我们他支持svg的path路径时,这就给了我们非常多的可能性,我们可以通过路径绘制任意想要的图形。
看到这个属性后,岂不是只需要将UI切的svg文件中的path传入进去就可以实现这个效果了?随后开始了分析。
我们的图形可以由三个四边形构成,每个四边形四个顶点,合计12个顶点。
从svg文件我们可以得到如下内容
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="362.74609375"
height="513.7080078125" viewBox="0 0 362.74609375 513.7080078125" fill="none">
<path d="M128.239 177.367L128.239 512.015L361.056 397.17L361.056 76.6519L128.239 177.367Z" stroke="rgba(0, 0, 0, 1)"
stroke-width="3.3802816901408472" stroke-linejoin="round" fill="#FFFFFF">
</path>
<path d="M1.69043 107.409L128.231 177.364L361.048 76.6482L229.656 1.69043L1.69043 107.409Z"
stroke="rgba(0, 0, 0, 1)" stroke-width="3.3802816901408472" stroke-linejoin="round" fill="#FFFFFF">
</path>
<path d="M1.69043 107.413L1.69043 442.06L128.231 512.015L128.231 177.368L1.69043 107.413Z" stroke="rgba(0, 0, 0, 1)"
stroke-width="3.3802816901408472" stroke-linejoin="round" fill="#FFFFFF">
</path>
</svg>
我们可以发现,它是由三个path路径构成,而我们的水球图只支持一个path://开头的path路径字符串。解决这个问题也很简单,我们只需要将三个路径给他合并在一起就可以了,我们就可以实现这种伪3D效果了。
如此,我们便得到了路径。
path://M128.239 177.367L128.239 512.015L361.056 397.17L361.056 76.6519L128.239 177.367Z M1.69043 107.409L128.231 177.364L361.048 76.6482L229.656 1.69043L1.69043 107.409Z M1.69043 107.413L1.69043 442.06L128.231 512.015L128.231 177.368L1.69043 107.413Z
效果如图:
哇瑟,真不赖,感觉已经实现百分之七八十了,内心已经在幻想,领导看到实现后大悦,说不愧是你呀,然后给我升职加薪,推举升任CTO,赢取白富美,翘着腿坐在库里南里的场景了。
等等!我的线条呢?整个水球图(也不能叫球了,水立方?)只有外轮廓,看不到线条棱角了,其实我觉得现在这种情况还蛮好看的,但是为了忠于UI设计的还原,还是得另寻办法,可不能让人家说菜,这么简单都实现不了。
好在,解决这个问题也很简单,官方提供了边框的配置项。(真是及时雨啊)
backgroundStyle: {
borderColor: "#000",// 边框的颜色
borderWidth: 2, // 边框线条的粗细
color: "#fff", // 背景色
},
配置完边框线的粗细后,啊哈!这不就是我们想要的效果吗?
最后还差一点,再将百分比显示出来,如下图,完美!
拓展
然后我们类比别的图案也是类似,也是只需要将多个path组合在一起就可以了。
悟空
某支
钢铁侠
是不是看起来非常的炫酷,实现方式也是一样,我们只需要将这些图案的path路径传入这个shape属性就行了,然后调整适当的颜色。
注意点:
- 如果图形中包含填充的区域,可以让UI小姐姐,把填充改成线条模拟,用多个线条组成一个面模拟,类似微积分。
- 图形的样式取决于path路径,水球图只支持路径,因此路径上的颜色不能单独设置了,只能通过配置项配置一个整体的颜色。
- 关于svg矢量图标来源,可以上素材网站寻找,如我比较喜欢用的字节图标库、阿里图标库等等
思考
上面实现的水球图有一点让我十分在意,就是图案的是怎么做到根据波浪是否遮挡文字,改变文字的颜色,它做到了即使水球图的波浪漫过了文字,文字会呈现白色,不会因为水漫过后,文字颜色与水球图颜色一致,导致文字不可见。
这个特性也太酷了吧,对于用户体验来说大大增强。
由于强烈的好奇心,我开始研究源码这个是怎么实现的。
看了源码后,恍然大悟,原来是这样的
- 绘制背景
- 绘制内层的文本
- 绘制波浪
- 设置裁剪区域,将波浪覆盖的内层文本部分裁剪掉,留下没有被裁减的地方。(上半部分绿字)
- 绘制外层文本,由于设置了裁剪区域,之后的绘图被限制在裁剪区域。(裁剪区域的下半红字部分)
这样,我们就完成了这个神奇的效果。
下面我提供了一个demo,大家可以通过注释draw中的函数,就能很快明白是怎么一回事了。
值得注意的是
- 内层文本与外层文本的位置需要在同一个位置。
- 裁剪区域位置、大小和波浪的位置、大小重合。
总结
完成这个需求后,领导果然非常高兴给我升了职、加了薪,就在我得意洋洋幻想当上CTO的时候,中午闹钟响了,原来只是中午做了个梦,想到下午还有任务,就继续搬砖去了🤣。
来源:juejin.cn/post/7407995254077767707
这可能是见过的最好用的弹幕库 🔥🔥
最近把五年前写的一个弹幕库给重构了一下,本来两年前就想着做这件事,但是其中有一段工作时间压力很大,所以就搁置了,导致没有时间来做这件事情,最近周末 + 晚上花一些时间重构了下,并把文档好好写了一下。言归正传,这篇文章会介绍部分这个弹幕库有的能力,如果正好符合你的需求或者感兴趣,可以帮忙点点 star 来支持一下。
我们有哪些能力
我们提供了灵活调整轨道,自定义弹幕和容器样式,弹幕运动算法等能力,还在提供非常丰富的钩子来让用户处理自定义的行为,只要你想要的,都能做到,本文档会简单介绍一些能力和一些功能的实现。
- 在线 demo: imtaotao.github.io/danmu/
- github: github.com/imtaotao/da…
- 官方文档:imtaotao.github.io/danmu/docum…
快速开始
对于一个开箱即用的 demo,可以非常简单的接入,如下所示:
import { create } from 'danmu';
const manager = create();
manager.mount('#root');
manager.startPlaying();
// 发送弹幕
manager.push('弹幕内容')
对轨道进行调整
我们对支持类似 CSS calc 表达式的能力,一些位置/宽高等信息都可以用表达式来计算。所以对于轨道来说可以很方便的进行调整。
number
:默认单位为px
。string
:表达式计算。支持(+
,-
,*
,/
)数学计算,只支持%
和px
两种单位。
// 例如,这里的 100% 是指容器宽度(如果是高度相关的配置 100% 就是容器的高度)
manager.setGap('(100% - 10px) / 5');
限制为顶部 3 条弹幕
// 如果我们希望轨道高度为 50px
manager.setTrackHeight('100% / 3');
// 如果不设置渲染区域,轨道的高度会根据默认的 container.height / 3 得到,
// 这可能导致轨道高度不是你想要的
manager.setArea({
y: {
start: 0,
// 3 条轨道的总高度为 150px
end: 150,
},
});
限制为中间 3 条弹幕
manager.setTrackHeight('100% / 3');
manager.setArea({
y: {
start: `50%`,
end: `50% + 150`,
},
});
限制为几条不连续的轨道
限制为几条不连续的轨道,除了要做和连续轨道的操作之外,还需要借助 willRender
这个钩子来实现。
// 如果我们希望轨道高度为 50px,并渲染 0,2,4 这几条轨道
manager.setTrackHeight('100% / 6');
// 设置容器的渲染区域
manager.setArea({
y: {
start: 0,
// 6 条轨道的总高度为 300px
end: 300,
},
});
manager.use({
willRender(ref) {
// 高级弹幕和轨道不强相关,没有 trackIndex 这个属性
if (ref.trackIndex === null) return ref;
// 如果为 1,3,5 这几条轨道就阻止渲染,并重新添加等待下次渲染
if (ref.trackIndex % 2 === 1) {
ref.prevent = true;
manager.unshift(ref.danmaku);
}
return ref;
},
});
自定义渲染
弹幕和容器都允许自定义的渲染样式,你可以很方便的做到。
自定义弹幕的样式
1. 通过 manager.setStyle
来设置
import { create } from 'danmu';
// 需要添加的样式
const styles = {
color: 'red',
fontSize: '15px',
// .
};
const manager = create();
// 后续渲染的弹幕和当前已经渲染的弹幕会设置上这些样式。
for (const key in styles) {
manager.setStyle(key, styles[key]);
}
2. 通过 danamaku.setStyle
来设置
import { create } from 'danmu';
// 需要添加的样式
const styles = {
color: 'red',
fontSize: '15px',
// .
};
// 初始化的时候添加钩子处理,这样当有新的弹幕渲染时会自动添加上这些样式
const manager = create({
plugin: {
$moveStart(danmaku) {
for (const key in styles) {
danmaku.setStyle(key, styles[key]);
}
// 你也可以在这里给弹幕 DOM 添加 className
danmaku.node.classList.add('className');
},
},
});
// 对当前正在渲染的弹幕添加样式
manager.asyncEach((danmaku) => {
for (const key in styles) {
danmaku.setStyle(key, styles[key]);
}
});
自定义容器样式
import { create } from 'danmu';
// 需要添加的样式
const styles = {
background: 'red',
// .
};
const manager = create({
plugin: {
// 你可以在初始化的时候添加钩子处理
init(manager) {
for (const key in styles) {
manager.container.setStyle(key, styles[key]);
}
// 你也可以在这里给容器 DOM 添加 className
manager.container.node.classList.add('className');
},
},
});
// 或者直接调用 api
for (const key in styles) {
manager.container.setStyle(key, styles[key]);
}
高级弹幕的示例
本章节将介绍如何将弹幕固定在某一位置,以 top
和 left
这两个位置举例。由于我们需要自定义位置,所以我们需要使用高级弹幕的能力。
将弹幕固定在顶部
// 这条弹幕将会居中距离顶部 10px 的位置悬停 5s
manager.pushFlexibleDanmaku('弹幕内容', {
duration: 5000,
direction: 'none',
position(danmaku, container) {
return {
x: `50% - ${danmaku.getWidth() / 2}`,
y: 10, // 具体容器顶部的距离为 10px
};
},
});
固定在顶部第 2 条轨道上
// 这条弹幕将会在第二条轨道居中的位置悬停 5s
manager.pushFlexibleDanmaku('弹幕内容', {
duration: 5000,
direction: 'none',
position(danmaku, container) {
// 渲染在第 3 条轨道中
const { middle } = manager.getTrackLocation(2);
return {
x: `50% - ${danmaku.getWidth() / 2}`,
y: middle - danmaku.getHeight() / 2,
};
},
});
将弹幕固定在左边
// 这条弹幕将会在容器中间距离左边 10px 的地方停留 5s
manager.pushFlexibleDanmaku('弹幕内容', {
duration: 5000,
direction: 'none',
position(danmaku, container) {
// 渲染在第 3 条轨道中
const { middle } = manager.getTrackLocation(2);
return {
x: 10,
y: `50% - ${danmaku.getHeight() / 2}`,
};
},
});
发送带图片的弹幕
要让弹幕里面能够携带图片,要在弹幕的节点内部添加自定义的内容,实际上不止图片,你可以往弹幕的节点里面添加任何的内容。
本章节的组件以 React 来实现演示。
开发弹幕组件
export function Danmaku({ danmaku }) {
return (
<div>
<img src="https://abc.jpg" />
{danmaku.data}
</div>
);
}
渲染弹幕
import ReactDOM from 'react-dom/client';
import { create } from 'danmu';
import { Danmaku } from './Danmaku';
const manager = create<string>({
plugin: {
// 将组件渲染到弹幕的内置节点上
$createNode(danmaku) {
ReactDOM.createRoot(danmaku.node).render(<Danmaku danmaku={danmaku} />);
},
},
});
编写一个插件
编写一个插件是很简单的,但是借助内核暴露出来的钩子
和 API
,你可以很轻松的实现强大且定制化的需求。由于内核没有暴露出来根据条件来实现过滤弹幕的功能,原因在于内核不知道弹幕内容的数据结构,这和业务的诉求强相关,所以我们在此通过插件来实现精简弹幕的功能用来演示。
编写一个插件
- 你编写的插件应当取一个
name
,以便于调试定位问题(注意不要和其他插件冲突了)。 - 插件可以选择性的声明一个
version
,这在你的插件作为独立包发到npm
上时很有用。
export function filter({ userIds, keywords }) {
return (manager) => {
return {
name: 'filter-keywords-or-user',
version: '1.0.0', // version 字段不是必须的
willRender(ref) {
const { userId, content } = ref.danmaku.data.value;
console.log(ref.type); // 可以根据此字段来区分是普通弹幕还是高级弹幕
if (userIds && userIds.includes(userId)) {
ref.prevent = true;
} else if (keywords) {
for (const word of keywords) {
if (content.includes(word)) {
ref.prevent = true;
break;
}
}
}
return ref;
},
};
};
}
注册插件
你需要通过 mananger.use()
来注册插件。
import { create } from 'danmu';
const manager = create<{
userId: number;
content: string;
}>();
manager.use(
filter({
userIds: [1],
keywords: ['菜'],
}),
);
发送弹幕
- ❌ 会被插件阻止渲染
manager.push({
userId: 1,
content: '',
});
- ❌ 会被插件阻止渲染
manager.push({
userId: 2,
content: '你真菜',
});
- ✔️ 不会被插件阻止渲染
manager.push({
userId: 2,
content: '',
});
- ✔️ 不会被插件阻止渲染
manager.push({
userId: 2,
content: '你真棒',
});
总结
本文档只是简单介绍了下现在的部分能力,更详细的文档在官网可以查看,如果对你的业务或者学习有帮助的,给个 star 支持一下作者,也欢迎大家评论探讨(不止弹幕,哈哈)。
来源:juejin.cn/post/7408364808607957002
别再用模板语法和'+'来拼接url了
在前端开发中,我们经常需要处理URL,例如在发起HTTP请求时构建API端点,或在页面导航时构建动态链接、拼接动态参数。
过去,我们习惯于使用模板语法和字符串拼接来构建这些URL,现在在代码中依然可以看到新的代码还在使用这种方法。
但这种方法不仅容易出错,而且在维护和阅读代码时也不够直观。本文将介绍更现代和更安全的URL构建方法,并展示如何在实际项目中应用它们。
传统上,我们常使用字符串拼接或模板语法来构建URL。例如:
const baseUrl = "https://api.example.com";
const userId = 12345;
const endpoint = baseUrl + "/users/" + userId + "/details";
console.log(endpoint); // "https://api.example.com/users/12345/details"
import { TYPE_EDIT } from '@/constants/type.ts'
const type = TYPE_EDIT
const url = 'https://api.example.com/userInfo'
const newUrl = url + '?type=' + type + '&model=1&share=1&fromModule=wechat'
console.log(urlUrl) // https://api.example.com/userInfo?type=TYPE_EDIT&model=1&share=1&fromModule=wechat
或使用ES6模板字符串:
const baseUrl = "https://api.example.com";
const userId = 12345;
const endpoint = `${baseUrl}/users/${userId}/details`;
console.log(endpoint); // "https://api.example.com/users/12345/details"
import { TYPE_EDIT } from '@/constants/type.ts'
const type = TYPE_EDIT
const url = 'https://api.example.com/userInfo'
const newUrl = url + `?type=${type}&model=1&share=1&fromModule=wechat`
console.log(urlUrl) // https://api.example.com/userInfo?type=TYPE_EDIT&model=1&share=1&fromModule=wechat
虽然模板字符串在一定程度上提高了可读性,但这种方法仍存在几个问题:
- 易读性差:当URL变得复杂时,拼接和模板字符串会变得难以阅读和维护(现阶段已经难以阅读和维护了)。
- 错误处理麻烦:拼接过程中如果有任何错误(例如漏掉斜杠),可能会导致难以排查的BUG。
- 缺乏类型安全:拼接字符串无法提供编译时的类型检查,容易引入错误。
使用URL构造器
为了解决这些问题,现代JavaScript引入了URL构造器,可以更优雅和安全地处理URL。URL构造器提供了一种更结构化和直观的方法来构建和操作URL。
基本用法
const baseUrl = "https://api.example.com";
const userId = 12345;
const url = new URL(`/users/${userId}/details`, baseUrl);
console.log(url.href); // "https://api.example.com/users/12345/details"
添加查询参数
URL构造器还提供了一种简便的方法来添加和操作查询参数:
const baseUrl = "https://api.example.com";
const userId = 12345;
const url = new URL(`/users/${userId}/details`, baseUrl);
url.searchParams.append('type', 'EDIT');
url.searchParams.append('module', 'wechat');
console.log(url.href); // "https://api.example.com/users/12345/details?type=EDIT&module=wechat"
拼接数组参数
假设我们有一个URL,需要将一个数组作为查询参数添加到URL中。
const baseUrl = 'https://example.com';
const url = new URL(baseUrl);
const arrayParam = ['value1', 'value2', 'value3'];
// 将数组转换为逗号分隔的字符串
url.searchParams.set('array', arrayParam.join(','));
console.log(url.toString()); // https://example.com/?array=value1,value2,value3
解析数组参数
当我们获取URL并需要解析其中的数组参数时,可以使用URLSearchParams
对象进行解析。
const urlString = 'https://example.com/?array=value1,value2,value3';
const url = new URL(urlString);
const arrayParamString = url.searchParams.get('array');
// 将逗号分隔的字符串转换回数组
const arrayParam = arrayParamString ? arrayParamString.split(',') : [];
console.log(arrayParam); // ['value1', 'value2', 'value3']
以下是一个完整示例,包括拼接和解析数组参数的操作:
// 拼接数组参数到URL
const baseUrl = 'https://example.com';
const url = new URL(baseUrl);
const arrayParam = ['value1', 'value2', 'value3'];
url.searchParams.set('array', arrayParam.join(','));
console.log(url.toString()); // https://example.com/?array=value1,value2,value3
// 解析数组参数从URL
const urlString = url.toString();
const parsedUrl = new URL(urlString);
const arrayParamString = parsedUrl.searchParams.get('array');
const parsedArrayParam = arrayParamString ? arrayParamString.split(',') : [];
console.log(parsedArrayParam); // ['value1', 'value2', 'value3']
处理多个同名参数
有时我们可能会遇到需要处理多个同名参数的情况,例如?array=value1&array=value2&array=value3
。可以使用URLSearchParams
的getAll
方法:
// 拼接多个同名参数到URL
const url = new URL(baseUrl);
const arrayParam = ['value1', 'value2', 'value3'];
arrayParam.forEach(value => url.searchParams.append('array', value));
console.log(url.toString()); // https://example.com/?array=value1&array=value2&array=value3
// 解析多个同名参数从URL
const urlString = url.toString();
const parsedUrl = new URL(urlString);
const parsedArrayParam = parsedUrl.searchParams.getAll('array');
console.log(parsedArrayParam); // ['value1', 'value2', 'value3']
通过这些方法,可以更加优雅和简便地处理URL中的数组参数,提升代码的可读性和可维护性。
但实际情况往往比上面的示例更复杂,比如参数是一个对象、根据实际情况来设置参数的值、要处理undefined
、'undefined'
、0
、'0'
、Boolean
、'true'
、NaN
等不同类型和异常的值,每次使用时都去处理显然是不合理的,这时候就可以将拼接和移除参数的函数封装成方法来使用。
/**
* 获取URL查询参数并返回一个对象,支持数组
* @param {string} urlString - 需要解析的URL字符串
* @returns {Object} - 包含查询参数的对象
*/
function getURLParams(urlString) {
const url = new URL(urlString);
const params = new URLSearchParams(url.search);
const result = {};
for (const [key, value] of params.entries()) {
if (result[key]) {
if (Array.isArray(result[key])) {
result[key].push(value);
} else {
result[key] = [result[key], value];
}
} else {
result[key] = value;
}
}
return result;
}
/**
* 设置URL的查询参数,支持对象和数组
* @param {string} urlString - 基础URL字符串
* @param {Object} params - 需要设置的查询参数对象
* @returns {string} - 带有查询参数的URL字符串
*/
function setURLParams(urlString, params) {
const url = new URL(urlString);
const searchParams = new URLSearchParams();
for (const key in params) {
if (params.hasOwnProperty(key)) {
const value = params[key];
if (Array.isArray(value)) {
value.forEach(val => {
if (val !== undefined && !Number.isNaN(val)) {
searchParams.append(key, val);
} else {
console.warn(`Warning: The value of "${key}" is ${val}, which is invalid and will be ignored.`);
}
});
} else if (value !== undefined && !Number.isNaN(value)) {
searchParams.append(key, value);
} else {
console.warn(`Warning: The value of "${key}" is ${value}, which is invalid and will be ignored.`);
}
}
}
url.search = searchParams.toString();
return url.toString();
}
// 测试用例
const baseUrl = 'https://example.com';
// 测试 getURLParams 方法
const testUrl = 'https://example.com/?param1=value1¶m2=value2¶m2=value3';
const parsedParams = getURLParams(testUrl);
console.log(parsedParams); // { param1: 'value1', param2: ['value2', 'value3'] }
// 测试 setURLParams 方法
const params = {
param1: 'value1',
param2: ['value2', 'value3'],
param3: undefined,
param4: NaN,
param5: 'value5',
param6: 0,
};
const newUrl = setURLParams(baseUrl, params);
console.log(newUrl); // 'https://example.com/?param1=value1¶m2=value2¶m2=value3¶m5=value5'
以上代码是根据掌握的知识编写的基本使用示例,像这种工作完全不用自己来写,现在已经有非常成熟的库可以直接使用。
qs
npmjs http://www.npmjs.com/package/qs
它是开源免费项目,每周下载量将近7千万,支持任意字符,对象进行解析和拼接,支持@types/qs
,导入后11.3k,建议打包编译时排除在打包文件外用cdn替代。
query-string
npmjs http://www.npmjs.com/package/que…
它是开源免费项目,每周下载量达千万,支持任意字符、对象进行解析和拼接,支持ts,导入后仅2.5k字节。
PC和H5如果使用了微前端,建议一开始打包时就将依赖排除在打包文件外,用cdn链接来替代,仅加载一次就可以缓存下来,可以加速页面加载、减小打包文件大小。
当然更多时候我们在编写h5、小程序项目的时候并不希望为了一个url解析参数和拼接参数的功能而引入一整个依赖。
这时候一个简单的解析和拼接的函数就可以搞定。
方法有多种实现方式,下面还有一种通过正则来实现的,但下面拼接的时候会忽略数字0,所以参数一定要用字符串。
/**
* 合并查询参数到 URL 的函数
* 将给定的查询对象 Query 合并到指定的 URL 中
*
* @param {Object} query - 要合并到 URL 中的查询对象
* @param {string} url - 作为基础的 URL,默认为当前页面的 URL
* @returns {string} 生成的合并查询参数后的新 URL
*/
export function getUrlMergeQuery(query = {}, url) {
url = url || window.location.href
const _orgQuery = getQueryObject(url)
const _query = {..._orgQuery,...query }
let _arr = []
for (let key in _query) {
const value = _query[key]
if (value) _arr.push(`${key}=${encodeURIComponent(_query[key])}`)
}
return `${url.split('?')[0]}${_arr.length > 0? `?${_arr.join('&')}` : ''}`
}
/**
* 从 URL 中提取查询参数对象
*
* @param {string} [url=window.location.href] - 要解析的 URL 字符串。如果未提供,则使用当前页面的 URL
* @returns {Object} - 包含提取的查询参数的对象
*/
export function getQueryObject(url = window.location.href) {
const search = url.substring(url.lastIndexOf('?') + 1);
const obj = {};
const reg = /([^?&=]+)=([^?&=]*)/g;
search.replace(reg, (rs, $1, $2) => {
const name = decodeURIComponent($1);
let val = decodeURIComponent($2);
val = String(val);
obj[name] = val;
return rs;
});
return obj;
}
你的项目中一定提供了合适的方法,不要在用字符串拼接的方法来拼接参数了。
来源:juejin.cn/post/7392788843097931802
如何访问数组最后一个元素
原文链接:blog.ignacemaes.com/the-easy-wa…
在JavaScript中,想要获取数组的最后一个元素并不是一件简单的事情,尤其是和一些其他编程语言相比。比如说,在Python里,我们可以通过负数索引轻松访问数组的最后一个元素。但是在JavaScript的世界里,负数索引这一招就不管用了,你必须使用数组长度减一的方式来定位最后一个元素。
比如说,我们有一个数组,里面装着一些流行的前端框架:
const frameworks = ['Nuxt', 'Remix', 'SvelteKit', 'Ember'];
如果我们尝试用负数索引去访问它:
frameworks[-1];// 这里是不会得到结果的
你会发现,这样做是行不通的,它不会返回任何东西。正确的做法是使用数组的长度减一来获取最后一个元素:
frameworks[frameworks.length - 1];// 这样就能拿到'Ember'了
at方法
为了让数组索引变得更加灵活,JavaScript引入了一个新方法——at
。这个方法可以让你通过索引来获取数组中的元素,并且支持负数索引。
frameworks.at(-1);// 这样就能直接拿到'Ember'了
不过,需要注意的是,at
方法只是一个访问器方法,它并不能用来改变数组的内容。如果你想要改变数组,还是得用传统的方括号方式。
// 这样是不行的
frameworks.at(-1) = 'React';
// 正确的改变数组的方法是这样的
frameworks[frameworks.length - 1] = 'React';
with方法
另外,如果你想要改变数组的元素并且得到一个新的数组,而不是改变原数组,JavaScript还提供了一个with
方法。这个方法可以帮你做到这一点,但是它会返回一个新的数组,原数组不会被改变。
// 这样会返回一个新的数组,原数组不变
frameworks.with(-1, 'React');
但是从2023年7月开始,它已经在主流浏览器中得到了支持。Node.js从20.0.0版本开始也支持了这个方法。
使用with
方法,你可以非常方便地修改数组中的元素,并且不用担心会影响到原始数组。这就好比是你在做饭的时候,想要尝尝味道,但又不想直接从锅里尝,于是你盛出一小碗来试味,锅里的菜还是原封不动的。
const updatedFrameworks = frameworks.with(-1, 'React');
// updatedFrameworks 就是 ['Nuxt', 'Remix', 'SvelteKit', 'React']
// 而 frameworks 仍然是原来的数组 ['Nuxt', 'Remix', 'SvelteKit', 'Ember']
兼容性
现在,我们来聊聊这两个方法在浏览器中的兼容性。at
方法从2022年开始已经在主流浏览器中得到了支持,Node.js的当前所有长期支持版本也都支持这个方法。
如果你需要在老旧的浏览器上使用这些方法,别担心,core-js
提供了相应的polyfill。
这样的设计思路,其实是在鼓励我们写出更加模块化和可维护的代码。你不需要担心因为修改了一个元素而影响到整个数组的状态,这对于编写清晰、可靠的代码是非常有帮助的。
如果你需要在一些比较老的浏览器上使用这些方法,你可能需要引入一个polyfill来填补浏览器的不足。core-js
这个库就提供了这样的功能,它可以让你的代码在不同的环境中都能正常运行。
总结
总结一下,at
方法和with
方法为我们在JavaScript中操作数组提供了更多的便利。它们让我们可以用一种更加直观和灵活的方式来访问和修改数组,同时也保持了代码的清晰和模块化。虽然这些方法是近几年才逐渐被引入的,但是它们已经在现代浏览器中得到了很好的支持。如果你的项目需要在老旧的浏览器上运行,记得使用polyfill来确保你的代码能够正常工作。这样,无论是新手还是经验丰富的开发者,都能够轻松地利用这些新特性来提升我们的编程体验。
来源:juejin.cn/post/7356446170477215785
用electron写个浏览器给自己玩
浏览器这种东西工程量很唬人,但是有了electron+webview我们就相当于只需要干组装的活就可以了,而且产品目标就是给自己玩,
成品的效果
😄本来想写成专业的技术博客,但是发现大家好像对那种密密麻麻,全是代码的技术博客不感兴趣,我就挑重点来写吧。
下载拦截功能
下载逻辑如果不做拦截处理的话,默认就是我们平常写web那种弹窗的方式,既然是浏览器肯定不能是那样的。
electron中可以监听BrowserWindow的页面下载事件,并把拿到的下载状态传给渲染线程,实现类似浏览器的下载器功能。
//这个global.WIN = global.WIN = new BrowserWindow({ ...})
global.WIN.webContents.session.on('will-download', (evt, item) => {
//其他逻辑
item.on('updated', (evt, state) => {
//实时的下载进度传递给渲染线程
})
})
页面搜索功能
当时做这个功能的时候我就觉得完了,这个玩意看起来太麻烦了,还要有一个的功能这不是头皮发麻啊。
查资料和文档发现这个居然是webview内置的功能,瞬间压力小了很多,我们只需要出来ctrl+f的时候把搜索框弹出来这个UI就可以了,关键字变色和下一个都是内部已经实现好了的。
function toSearch() {
let timer
return () => {
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
if (keyword.value) {
webviewRef.value.findInPage(keyword.value, { findNext: true })
} else {
webviewRef.value.stopFindInPage('clearSelection')
}
}, 200)
}
}
function closeSearch() {
showSearch.value = false
webviewRef.value.stopFindInPage('clearSelection')
}
function installFindPage(webview) {
webviewRef.value = webview
webviewRef.value.addEventListener('found-in-page', (e) => {
current.value = e.result.activeMatchOrdinal
total.value = e.result.matches
})
}
当前标签页打开功能
就是因为chrome和edge这些浏览器每次使用的时候开非常多的标签,挤在一起,所以我想这个浏览器不能主动开标签,打开了一个标签后强制所有的标签都在当前标签覆盖。
app.on('web-contents-created', (event, contents) => {
contents.setWindowOpenHandler((info) => {
global.WIN?.webContents.send('webview-url-is-change')
if (info.disposition === 'new-window') {
return { action: 'allow' }
} else {
global.WIN?.webContents.send('webview-open-url', info.url)
return { action: 'deny' }
}
})
})
渲染线程监听到webview-open-url后也就是tart="_blank"的情况,强制覆盖当前不打开新窗口
ipcRenderer.on('webview-open-url', (event, url) => {
try {
let reg = /http|https/g
if (webviewRef.value && reg.test(url)) {
webviewRef.value.src = url
}
} catch (err) {
console.log(err)
}
})
标签页切换功能
这里的切换是css的显示隐藏,借助了vue-router
这里我们看dom就能清晰的看出来。
地址栏功能
地址栏支持输入url直接访问链接、支持关键字直接打开收藏的网站、还支持关键字搜索。优先级1打开收藏的网页 2访问网站 3关键字搜索
function toSearch(keyword) {
if (`${keyword}`.length === 0) {
return false
}
// app搜索
if (`${keyword}`.length < 20) {
let item = null
const list = [...deskList.value, ...ALL_DATA]
for (let i = 0; i < list.length; i++) {
if (
list[i].title.toUpperCase().search(keyword.toUpperCase()) !== -1 &&
list[i].type !== 'mini-component'
) {
item = list[i]
break
}
}
if (item) {
goApp(item)
return false
}
}
// 网页访问
let url
if (isUrl(keyword)) {
if (!/^https?:\/\//i.test(keyword)) {
url = 'http://' + keyword
} else {
url = keyword
}
goAppNewTab(url)
return false
} else {
// 关键字搜索
let searchEngine = localStorage.getItem('searchEngine')
searchEngine = searchEngine || CONFIG.searchEngine
url = searchEngine + keyword
if (!router.hasRoute('search')) {
router.addRoute({
name: 'search',
path: '/search',
meta: {
title: '搜索',
color: 'var(--app-icon-bg)',
icon: 'search.svg',
size: 1
},
component: WebView
})
keepAliveInclude.value.push('search')
}
router.push({
path: '/search',
query: { url }
})
setTimeout(() => {
Bus.$emit('toSearch', url)
}, 20)
}
}
桌面图标任意位置拖动
这个问题困扰了我很久,因为它不像电脑桌面大小是固定的,浏览器可以全屏也可以小窗口,如果最开始是大窗口然后拖成小窗口,那么图标就看不到了。后来想到我干脆给个中间区域固定大小,就可以解决这个问题了。因为固定大小出来起来就方便多了。这个桌面是上下两层
//背景格子
<div v-show="typeActive === 'me'" class="bg-boxs">
<div
v-for="(item, i) in 224" //这里有点不讲究了直接写死了
:key="item"
class="bg-box"
@dragenter="enter($event, { x: (i % 14) + 1, y: Math.floor(i / 14) + 1 })"
@dragover="over($event)"
@dragleave="leave($event)"
@drop="drop($event)"
></div>
</div>
// 桌面层
// ...
import { ref, computed } from 'vue'
import useDesk from '@/store/deskList'
import { storeToRefs } from 'pinia'
export default function useDrag() {
const dragging = ref(null)
const currentTarget = ref()
const desk = useDesk()
const { deskList } = storeToRefs(desk)
const { setDeskList, updateDeskData } = desk
function start(e, item) {
e.target.classList.add('dragging')
e.dataTransfer.effectAllowed = 'move'
dragging.value = item
currentTarget.value = e
console.log('开始')
}
let timer2
function end(e) {
dragging.value = null
e.target.classList.remove('dragging')
setDeskList(deskList.value)
if (timer2) {
clearTimeout(timer2)
}
timer2 = setTimeout(() => {
updateDeskData()
}, 2000)
}
function over(e) {
e.preventDefault()
}
let timer
function enter(e, item) {
e.dataTransfer.effectAllowed = 'move'
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
if (item?.x) {
dragging.value.x = item.x
dragging.value.y = item.y
}
}, 100)
}
function leave(e) {}
function drop(e) {
e.preventDefault()
}
return { start, end, over, enter, leave, drop }
}
东西太多了就先介绍这些了
安装包地址
也可以到官网后aweb123.com 如何进入微软商店下载,mac版本因为文件大于100mb没有传上去所以暂时还用不了。
来源:juejin.cn/post/7395389351641612300
【前端缓存】localStorage是同步还是异步的?为什么?
🧑💻 写在开头
点赞 + 收藏 === 学会🤣🤣🤣
首先明确一点,localStorage是同步的
🥝 一、首先为什么会有这样的问题
localStorage
是 Web Storage API 的一部分,它提供了一种存储键值对的机制。localStorage
的数据是持久存储在用户的硬盘上的,而不是内存。这意味着即使用户关闭浏览器或电脑,localStorage
中的数据也不会丢失,除非主动清除浏览器缓存或者使用代码删除。
当你通过 JavaScript 访问 localStorage
时,浏览器会从硬盘中读取数据或向硬盘写入数据。然而,在读写操作期间,数据可能会被暂时存放在内存中,以提高处理速度。但主要的特点是它的持久性,以及它不依赖于会话的持续性。
🍉 二、硬盘不是io设备吗?io读取不都是异步的吗?
是的,硬盘确实是一个 IO 设备,而大部分与硬盘相关的操作系统级IO操作确实是异步进行的,以避免阻塞进程。不过,在 Web 浏览器环境中,localStorage
的API是设计为同步的,即使底层的硬盘读写操作有着IO的特性。
js代码在访问 localStorage
时,浏览器提供的API接口通常会处于js执行线程上下文中直接调用。这意味着尽管硬盘是IO设备,当一个js执行流程访问 localStorage
时,它将同步地等待数据读取或写入完成,该过程中js执行线程会阻塞。
这种同步API设计意味着开发者在操作 localStorage
时不需要考虑回调函数或者Promise等异步处理模式,可以按照同步代码的方式来编写。不过,这也意味着如果涉及较多数据的读写操作时,可能对性能产生负面影响,特别是在主线程上,因为它会阻塞UI的更新和其他js的执行。
🍑 三、完整操作流程
localStorage
实现同步存储的方式就是阻塞 JavaScript 的执行,直到数据的读取或者写入操作完成。这种同步操作的实现可以简单概述如下:
- js线程调用: 当 JavaScript 代码执行一个
localStorage
的操作,比如localStorage.getItem('key')
或localStorage.setItem('key', 'value')
,这个调用发生在 js 的单个线程上。 - 浏览器引擎处理: 浏览器的 js 引擎接收到调用请求后,会向浏览器的存储子系统发出同步IO请求。此时 js 引擎等待IO操作的完成。
- 文件系统的同步IO: 浏览器存储子系统对硬盘执行实际的存储或检索操作。尽管操作系统层面可能对文件访问进行缓存或优化,但从浏览器的角度看,它会进行一个同步的文件系统操作,直到这个操作返回结果。
- 操作完成返回: 一旦IO操作完成,数据要么被写入硬盘,要么被从硬盘读取出来,浏览器存储子系统会将结果返回给 js 引擎。
- JavaScript线程继续执行: js 引擎在接收到操作完成的信号后,才会继续执行下一条 js 代码。
在同步的 localStorage
操作期间,由于 js 的单线程性质,整个线程会阻塞,即不会执行其他任何js代码,也不会进行任何渲染操作,直到 localStorage
调用返回。
🍒 四、localStorage限制容量都是因为同步会阻塞的原因吗?
- 资源公平分享:同一用户可能会访问大量不同的网站,如果没有限制,随着时间的积累,每个网站可能会消耗大量的本地存储资源。这样会导致本地存储空间被少数几个站点占用,影响到用户访问其他网页的体验。限制大小可以确保所有网站都有公平的存储机会。
- 防止滥用:如果没有存储限制,网站可能会滥用
localStorage
,存储大量数据在用户的设备上,这可能导致设备存储空间迅速耗尽,也可能侵犯用户的隐私。 - 性能限制:如之前提到的,
localStorage
的操作是阻塞的。如果网站能够存储大量数据,就会加剧读写操作对页面性能的影响。 - 存储效率:
localStorage
存储的是字符串形式的数据,不是为存储大量或结构化数据设计的。当尝试存储过多数据时,效率会降低。 - 历史和兼容性:5MB 的限制很早就已经被大多数浏览器实现,并被作为一个非正式的标准被采纳。尽管现在有些浏览器支持更大的
localStorage
,但出于跨浏览器兼容性的考虑,开发者通常会假设这个限制。 - 浏览器政策:浏览器厂商可能会依据自己的政策来设定限制,可能是出于提供用户更一致体验的角度,或者是出于管理用户数据的方便。
🍐 五、那indexDB会造成滥用吗?
虽然它们提供了更大的存储空间和更丰富的功能,但确实潜在地也可能被滥用。但是与相比 localStorage
增加了一些特性用来降低被滥用的风险:
- 异步操作:
IndexedDB
是一个异步API,即使它被用来处理更大量的数据,也不会像localStorage
那样阻塞主线程,从而避免了对页面响应性的直接影响。 - 用户提示和权限:对于某些浏览器,当网站尝试存储大量数据时,浏览器可能会弹出提示,要求用户授权。这意味着用户有机会拒绝超出合理范围的存储请求。
- 存储配额和限制:尽管
IndexedDB
提供的存储容量比localStorage
大得多,但它也不是无限的。浏览器会为IndexedDB
设定一定的存储配额,这个配额可能基于可用磁盘空间的一个百分比或者是一个事先设定的限额。配额超出时,浏览器会拒绝更多的存储请求。 - 更清晰的存储管理:
IndexedDB
的数据库形式允许有组织的存储和更容易的数据管理。用户或开发者可以更容易地查看和清理占用的数据。 - 逐渐增加的存储:某些浏览器实现
IndexedDB
存储时,可能会在数据库大小增长到一定阈值时,提示用户是否允许继续存储,而不是一开始就分配一个很大的空间。
🤖 六、一个例子简单测试一下
其实也不用测,平时写的时候你也没用异步的方式写localStorage吧,我们这里简单写个例子
html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Documenttitle>
head>
<body>
<script>
const testLocalStorage = () => {
console.log("==========> 设置localStorage之前");
localStorage.setItem('testLocalStorage', '我是同步的');
console.log("==========> 获取localStorage之前");
console.log('=========获取localStorage', localStorage.getItem('testLocalStorage'))
console.log("==========> 获取localStorage之后");
}
testLocalStorage()
script>
body>
html>
来源:juejin.cn/post/7359405716090011659
高德API花式玩法:租房辅助工具
前言
做gis的同学肯定知道等时圈这个东西,即:在一定时间内通过某种出行方式能到达的范围
通过一些计算,我们可以做一些好玩的事情,比如上图通过调用mapbox等时圈接口,计算在蓝色点位附近骑车17分钟可以到达哪些公司或学校。
可能由于国内交通情况更为复杂,做实时性较差的等时圈意义不大,所以高德地图提供了比较稳当的API:公交地铁到达圈
,其概念和等时圈类似,你可以选择地铁或公交出行,或者两者兼可,高德接口将计算出可达范围。
高德公交到达圈的好玩应用
用了很久的高德,发现这个功能好像没有被很好的应用,其实高德早在1.4的API中就提供了此功能,最近正好要换个房子,突然想到这个东西正好可以拿来做一个很棒的辅助。
由于我跟我对象上班的地方离得比较远,所以找个折中的地方租房是是很重要的,我准备查询到两个到达圈后,再计算一下重合部分,在重合部分找房,就准确多了。
使用AMap.ArrivalRange画出到达圈
首先我做了一个简单的页面
左上角的面板用来设置参数,可选地铁+公交
、地铁
、公交
三种方式,出行耗时最大支持60分钟(超过了接口会报错),位置需要传入经纬度。
初始化地图的步骤就不用说了,我讲一下这个小应用的使用逻辑。
首先,点击地图时,拾取该位置的经纬度,并通过逆地理接口获取到位置文本
function handleMapClick(e: any) {
if (!e.lnglat) return
if (currPositionList.value.length >= 2) {
autolog.log("最多添加 2 个位置", 'error') // 你不会想三个人一起住吧?
return
}
var lnglat = e.lnglat;
geocoder.getAddress(lnglat, (status: string, result: {
regeocode: any; info: string;
}) => {
if (status === "complete" && result.info === "OK") {
currPositionList.value.push({ name: result.regeocode.formattedAddress, lnglat: [lnglat.lng, lnglat.lat] })
}
});
}
可以看到,我把点选的位置信息,暂时存放到了currPositionList
里面,比如你在西二旗上班,而你女朋友在国贸,则点选后效果是这样的
左侧面板新增了两个位置,点击查询时,我将依次查询这两个到达圈,并渲染到地图上
function getArriveRange() {
let loopCount = 0
for (let item of currPositionList.value) {
arrivalRange.search(item.lnglat, currTime.value, (_status: any, result: { bounds: any; }) => {
map.remove(polygons);
if (!result.bounds) return
let currPolygons = []
loopCount++
for (let item of result.bounds) {
let polygon = new AMap.Polygon(polygonStyle[`normal${loopCount}` as "normal1" | "normal2"]);
polygon.setPath(item);
currPolygons.push(polygon)
}
map.add(currPolygons);
polygons.push({
lnglat: item.lnglat,
polygon: currPolygons,
bounds: result.bounds
})
if (loopCount === currPositionList.value.length) {
map.setFitView();
}
}, { policy: currStrategy.value });
}
}
由于接口调用方式是以回调函数的形式返回的,所以我这里记录了一下回调次数,当次数满足后,再去调整视角。这段逻辑运行之后,将是如下结果:
很遗憾,你和你的女朋友,下班后的一个小时内见不成面了,这意味着,如果找一个折中的地方租房,你们上班单程通勤,无论如何都超过一个小时了,如果想尽量接近一个小时,那么看下两者的交汇处
大钟寺地铁站将会是个很好的选择。
这样仍需要我们手动去观察,那么能不能算一下两者的交集呢?
在高德地图中使用 turf.js 计算多多边形交集
在上面提到的getArriveRange
函数中,我新增了这样的逻辑
if (loopCount === currPositionList.value.length) {
let poly1 = turf.multiPolygon(toNumber(polygons[0].bounds));
let poly2 = turf.multiPolygon(toNumber(polygons[1].bounds));
var intersection = turf.intersect(turf.featureCollection([poly1, poly2]));
if (intersection) {
let geojson = new AMap.GeoJSON({
geoJSON: {
type: "FeatureCollection",
features: [intersection]
},
getPolygon: (_: any, lnglats: any) => {
return new AMap.Polygon({
path: lnglats,
...polygonStyle.overlap
});
}
});
polygons.push({
lnglat: [0, 0],
polygon: geojson,
bounds: intersection.geometry.coordinates
})
map.add(geojson);
} else {
autolog.log("暂无交集,请自行查找", 'error')
}
map.setFitView();
}
由于高德地图到达圈获取到的经纬度是字符串,放到 turf 里面会报错,所以这里写了一个简单的递归,将多维数组所有的数据都转化为数字。
// 递归的将多维数组内的字符串转为数字
function toNumber(arr: any) {
return arr.map((item: any) => {
if (Array.isArray(item)) {
return toNumber(item)
} else {
return Number(item)
}
})
}
使用turf.multiPolygon
将获取到的多维数组转化为标准的 geojson 格式,以便于 turf 处理,在turf7.x
中,turf.intersect的用法稍有改变,需要turf.featureCollection([poly1, poly2])
作为参数传入。
这一步操作 turf 将计算并返回两个多多边形的交集intersection
(geojson),但是在高德地图API2.0中,直接传入这个geojson会报错(1.4不会),看了下源码,发现高德有一个操作是直接取第 0 个features
,导致它识别不了这种格式的数据,所以我们手动处理下,即:
let geojson = new AMap.GeoJSON({
geoJSON: {
type: "FeatureCollection",
features: [intersection]
},
getPolygon: (_: any, lnglats: any) => {
return new AMap.Polygon({
path: lnglats,
...polygonStyle.overlap
});
}
});
这样,高德就可以正确渲染这个数据了,这里需要注意的是,geojson 虽然也是 AMap.Polygon 构造的,但是需要一个特殊参数:path,没有的话,也不会报错,但是渲染不出来。
渲染完成后是这样的:
使用绿色代表重合部分,说明在这之中找房都是可以的。
通过 AMap.PlaceSearch 搜索交集区域的小区
高德提供了通过多边形区域搜索POI的接口
placeSearch = new AMap.PlaceSearch({ //构造地点查询类
pageSize: 5, // 单页显示结果条数
pageIndex: 1, // 页码
map: map, // 展现结果的地图实例
autoFitView: true // 是否自动调整地图视野使绘制的 Marker点都处于视口的可见范围
});
placeSearch.searchInBounds('小区', intersection.geometry.coordinates);
效果如上图所示,这样就可以轻松租房啦!
但是由于此接口是 get 请求,如果交集区域过大,会超出 get 请求长度限制:
结语
这个就叫产品思维,一个简单的API可以延伸出很多有趣的应用。
此仓库已在 github 开源,地址:
番外
高德云镜(高德云镜三维重建平台)目前已向企业和政府开放使用(暂未对个人开发者开放)
在web端,高德开发了 Cesium 插件用作展示,但目前来看要求配置过高
- CesiumJS引擎:CesiumJS v1.117+(建议)
- 浏览器:Chrome v126+(建议)
- 显卡:16GB显存以上独立显卡,推荐 NVDIA RTX 4090
- CPU:2.5 GHz 以上(建议)
- 内存:32GB 以上(建议)
看起来类似谷歌地球的全量城市建模。
来源:juejin.cn/post/7403991780512399387
大文件分片上传
前言
大文件上传是项目中的一个难点和亮点,在面试中也经常会被面试官问到,所以今天蘑菇头来聊聊这个大文件上传。
什么样的文件算的上是大文件?
对于Web前端来说,当涉及到上传或下载操作时,通常认为任何超过10MB的文件都属于较大文件,尤其是对于HTTP POST上传操作。如果文件大小达到几十MB甚至更大,那么通常就需要考虑使用分块上传、断点续传等技术来优化传输过程,减少因网络不稳定导致的失败率,并提高用户体验。
当然了,这和你的网络带宽也有关系,当你的网络带宽很小时,即时在小的文件传输速率也很慢,也可以被称之为大文件了。
接下里我们来模拟一下如何使用分块上传技术来优化传输过程。
分片上传文件
分片上传技术是解决大文件上传问题的一种有效方法。它通过将大文件分割成多个较小的部分(称为“分片”或“片段”),然后分别上传这些部分,最后再由服务器端合并这些部分来重构原始文件。这种方法的优点包括能够更好地利用网络资源、支持断点续传以及提高上传效率。
主要思想
首先,前端获取到input框里输入的文件对象,通过slice方法将大文件对象进行切割得到小的Blob对象,由于后端无法识别Blob对象,所以需要转为前后端都能识别的对象FormData,然后将这个对象通过post请求发送给后端,将切片一个一个发送给后端。
后端接收到一个一个切割好的对象进行解析,将这些切片保存到一个文件夹下。当所有的切片都发送完毕之后,后端接收到合并这个信号,将文件夹下的切片排好顺序进行合并,创建可写流,将所有的切片读成流类型并汇入到可写流中得到完整的文件资源。
详细过程
有几个点需要我们注意
文件如何切割?用什么方法?
后端什么时候知道前端已经将所有的分片都发送过来了,然后才开始合并?
合并的过程中如何保证分片的顺序?
后端怎么将前端发送过来的分片文件进行合并?
前端
监听input框的change事件,获取文件对象。
使用slice将文件对象进行切片,返回一个数组。
使用FormData构造函数,将Bolb对象包装成formdata对象,以便后端能够识别,并且给这个对象添加文件名,分片名属性,以便后来分片进行排序。
使用Promise.all方法,当所有的分片请求都成功后,在all的then方法里面发送一个分片请求已完成的信号给后端,告诉后端可以开始合并分片了。
<input type="file" name="" id="input">
<button id="btn">上传</button>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
const input = document.getElementById('input');
const btn = document.getElementById('btn');
let fileObj = null
input.addEventListener('change', handleFileChange);
btn.addEventListener('click', handleUpload)
function handleFileChange(e) {//监听change事件,获取文件对象
// console.log(event.target.files);
const [file] = event.target.files;
fileObj = file;
}
function handleUpload() {//点击按钮上传文件到服务器
if (!fileObj) return;
const chunkList = createChunk(fileObj);
// console.log(chunkList);
const chunks = chunkList.map(({ file }, index) => {//创建切片对象
return {
file,
size: file.size,
percent: 0,
index,
chunkName: `${fileObj.name}-${index}`,
fileName: fileObj.name,
}
});
// 发请求
uploadChunks(chunks);
}
//切片
function createChunk(file, size = 5 * 1024 * 1024) {
const chunkList = [];
let cur = 0;
while (cur < file.size) {
chunkList.push({
file: file.slice(cur, cur + size),
})
cur += size;
}
return chunkList;
}
// 发请求到后端
function uploadChunks(chunks) {
console.log(chunks); //这个数组中的元素是对象,对象中有blob类型的文件对象,后端无法识别,所以需要转换成formData对象
const formChunks = chunks.map(({ file, fileName, index, chunkName }) => {
const formData = new FormData();
formData.append('file', file);
formData.append('fileName', fileName);
formData.append('chunkName', chunkName);
return { formData, index }
})
console.log(formChunks); // 后端能识别的类型
//发请求
const requestList = formChunks.map(({ formData, index }) => {//一个一个片段发
return axios.post('http://localhost:3000/upload', formData,()=>{
console.log(index + ' 上传成功');
})
.then(res => {
})
})
Promise.all(requestList).then(() => {
console.log('全部上传成功');
mergeChunks();
})
}
// 合并请求的信号
function mergeChunks(size=5*1024*1024){
axios.post('http://localhost:3000/merge',{
fileName:fileObj.name,
size
})
.then(res=>{
console.log(fileObj.name + '合并成功');
})
}
</script>
后端
使用第三方库multiparty对传输过来的formdata进行解析。
使用fse模块对解析完成的数据进行保存。
当所有的切片都完成时,后端接收到合并切片的请求信号时,使用fse模块读取所有的切片并进行排序。
排序完成之后使用fse模块进行合并。
const http = require('http');
const path = require('path');
const multiparty = require('multiparty');
const fse = require('fs-extra');
const server = http.createServer(async (req, res) => {
res.writeHead(200, {
'access-control-allow-origin': '*',
'access-control-allow-headers': '*',
'access-control-allow-methods': '*'
})
if (req.method === 'OPTIONS') { // 请求预检
res.status = 200
res.end()
return
}
if (req.url === '/upload') {
// 接收前端传过来的 formData
const form = new multiparty.Form();
form.parse(req, (err, fields, files) => {
// console.log(fields); // 切片的描述
// console.log(files); // 切片的二进制资源被处理成对象
const [file] = files.file
const [fileName] = fields.fileName
const [chunkName] = fields.chunkName
// 保存切片
const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`)
if (!fse.existsSync(chunkDir)) { // 该路径是否有效
fse.mkdirSync(chunkDir)
}
// 存入
fse.moveSync(file.path, `${chunkDir}/${chunkName}`)
res.end(JSON.stringify({
code: 0,
message: '切片上传成功'
}))
})
}
if (req.url === '/merge') {
const { fileName, size } = await resolvePost(req) // 解析post参数
const filePath = path.resolve(UPLOAD_DIR, fileName) // 完整文件的路径
// 合并切片
const result = await mergeFileChunk(filePath, fileName, size)
if (result) { // 切片合并完成
res.end(JSON.stringify({
code: 0,
message: '文件合并完成'
}))
}
}
})
// 存放切片的地方
const UPLOAD_DIR = path.resolve(__dirname, '.', 'qiepian')
// 解析post参数
function resolvePost(req) {
return new Promise((resolve, reject) => {
req.on('data', (data) => {
resolve(JSON.parse(data.toString()))
})
})
}
// 合并
function pipeStream(path, writeStream) {
return new Promise((resolve, reject) => {
const readStream = fse.createReadStream(path)
readStream.on('end', () => {
fse.removeSync(path) // 被读取完的切片移除掉
resolve()
})
readStream.pipe(writeStream)
})
}
// 合并切片
async function mergeFileChunk(filePath, fileName, size) {
// 拿到所有切片所在文件夹的路径
const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`)
// 拿到所有切片
let chunksList = fse.readdirSync(chunkDir)
// console.log(chunksList);
// 万一切片是乱序的
chunksList.sort((a, b) => a.split('-')[1] - b.split('-')[1])
const result = chunksList.map((chunkFileName, index) => {
const chunkPath = path.resolve(chunkDir, chunkFileName)
// !!!!!合并
return pipeStream(chunkPath, fse.createWriteStream(filePath, {
start: index * size,
end: (index + 1) * size
}))
})
// console.log(result);
await Promise.all(result)
fse.rmdirSync(chunkDir) // 删除切片目录
return true
}
server.listen(3000, () => {
console.log('listening on port 3000');
})
来源:juejin.cn/post/7407262746700365876
uniapp开发微信小程序,我踩了大家都会踩的坑
最近使用uniapp开发了一个微信小程序(本项目技术栈是uniapp + vue3 + ts,用了最近比较火的模板unibest。),踩了一些大家普遍都会踩的坑,下面做一些总结。文章多处引用到权威官方内容和一些比较可靠的文章。如有错误,欢迎指正。
1. 使用微信昵称填写能力遇到的问题
自 2022 年 10 月 25 日 24 时后,wx.getUserProfile
和 wx.getUserInfo
的接口被收回,要想获取微信的昵称头像需要使用微信的头像昵称填写能力。
我们的设计稿中没有编辑确认按钮,所以应该失焦后就调用后端的变更昵称接口:
但是失焦之后,微信会对昵称内容做合规性校验,导致失焦后不能立马获取到输入的内容:
<uv-input v-model="form.name" type="nickname" placeholder="请输入内容" @blur="handleSubmit"></uv-input>
async function handleSubmit() {
console.log('form.value.name', form.value.name) // 测试用户001
console.log('rawName', rawName) // 测试用户001
if (form.value.name === rawName)
return
// ...
}
因此最开始的想法是等待校验结束:
async function handleSubmit() {
// 微信会对type="nickname"的输入框失焦时进行昵称违规校验,这个校验是异步的,所以需要等待一下
await new Promise((resolve) => setTimeout(resolve, 0))
console.log('form.value.name', form.value.name) // Jude
console.log('rawName', rawName) // 测试用户001
if (form.value.name === rawName) {
return
}
// ...
}
但如果真的输入了违规昵称,微信将自动清空输入框内容,而在此之前我的提交请求已经发送:
因此需要用到官方新加的一个回调事件bindnicknamereview
(文档):
<uv-input v-model="form.name" type="nickname" placeholder="请输入内容" @nicknamereview="handleSubmit"></uv-input>
function onNickNameReview(e) {
console.log('onNickNameReview', e)
if (e.detail.pass) {
// 校验通过
handleSubmit()
} else {
form.value.name = rawName
}
}
但发现 uv-ui 并没有提供这个事件,还是没有生效,只能改node_modules
的uv-input
源码,并给uv-ui
提个pr
:
2. 自定义导航栏
原生导航栏配置方面有很多限制,比如不允许修改字体大小等。所以有的时候需要自定义导航栏。
首先注意,webview的页面无法自定义导航栏!
所以:
导航栏高度 = 状态栏到胶囊的间距(胶囊上坐标位置-状态栏高度) * 2 + 胶囊高度 + 状态栏高度
第一步:配置当前页面的json
文件
// pages.json
{ navigationStyle: "custom" }
第二步:获取状态栏和导航栏高度,只需要获取一次即可,获取到可以放到pinia
里
// 自定义导航栏
const statusBarHeight = ref(0)
const navBarHeight = ref(0)
statusBarHeight.value = uni.getSystemInfoSync().statusBarHeight
let menuButtonInfo = uni.getMenuButtonBoundingClientRect()
navBarHeight.value = menuButtonInfo.height + (menuButtonInfo.top - statusBarHeight.value) * 2
第三步:自定义导航栏
<view class="nav-bar">
<!-- 状态栏占位 -->
<view :style="{ height: statusBarHeight + 'px' }"></view>
<!-- 真正的导航栏内容 ,请按照自己的需求自行定义-->
<view class="nav-bar-content" style="font-size: 34rpx;" :style="{ height: navBarHeight + 'px' }">导航栏标题</view>
</view>
问题:微信小程序原生导航栏会根据微信设置(字体大小,是否开启深色模式)等变化,深色模式是页面是可以获取到的,但字体大小等目前没有开放接口,所以无法根据微信设置动态变化。
3. 自定义tabbar
由于原生底部tabbar的局限性,未能满足产品需求,所以需要自定义tabbar。
首先,自定义tabbar的第一步配置pages.json
:
// pages.json
tabBar: {
custom: true,
// ...
},
然后,我们只需要在项目根目录(src)创建custom-tab-bar目录,uniapp编译器会直接它拷贝到小程序中:
<!-- src/custom-tab-bar/index.wxml -->
<view class="tab-bar">
<view class="tab-bar-border"></view>
<view wx:for="{{list}}" wx:key="index" class="tab-bar-item" data-path="{{item.pagePath}}" data-index="{{index}}" bindtap="switchTab">
<image class="tab-bar-item-img" src="{{selected === index ? item.selectedIconPath : item.iconPath}}"></image>
<view class="tab-bar-item-text" style="color: {{selected === index ? selectedColor : color}}">{{item.text}}</view>
</view>
</view>
// src/custom-tab-bar/index.js
Component({
data: {
selected: 0,
color: "#8d939f",
selectedColor: "#e3eaf9",
list: [{
pagePath: "/pages/index/index",
iconPath: "../static/tabbar/home01.png",
selectedIconPath: "../static/tabbar/home02.png",
text: "首页"
}, {
pagePath: "/pages/my/my",
iconPath: "../static/tabbar/user01.png",
selectedIconPath: "../static/tabbar/user02.png",
text: "我的"
}]
},
attached() {
},
methods: {
switchTab(e) {
const data = e.currentTarget.dataset
const url = data.path
wx.switchTab({url})
this.setData({
selected: data.index
})
}
}
})
// src/custom-tab-bar/index.json
{
"component": true
}
// src/custom-tab-bar/index.wxss
.tab-bar {
position: fixed;
bottom: calc(16rpx + env(safe-area-inset-bottom));
left: 0;
right: 0;
height: 100rpx;
background: linear-gradient(180deg, rgba(13, 15, 26, 0.95) 0%, rgba(42, 50, 76, 0.95) 100%);
box-shadow: 0rpx 4rpx 16rpx 0px rgba(0, 0, 0, 0.12);
display: flex;
width: calc(100% - 2 * 36rpx);
border-radius: 36rpx;
margin: 0 auto;
}
.tab-bar-item {
flex: 1;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.tab-bar-item .tab-bar-item-img {
width: 32rpx;
height: 32rpx;
}
.tab-bar-item .tab-bar-item-text {
margin-top: 10rpx;
font-size: 20rpx;
}
最后,关键坑注意:每个tab页都有自己的tabbar实例:
因此需要每个tab页渲染时设置一下自定义tabbar组件的 activeIndex
(我这里变量名是selected
):
如果是原生小程序开发像官网那样写就好,如果是uniapp
开发,需要:
onShow(() => {
const currentPage = getCurrentPages()[0]; // 获取当前页面实例
const currentTabBar = currentPage?.getTabBar?.();
// 设置当前tab页的下标index
currentTabBar?.setData({ selected: 0 });
})
效果:
4. IOS适配安全距离
当用户使用圆形设备访问页面时,就存在“安全区域”和“安全距离”的概念。安全区域指的是一个可视窗口范围,处于安全区域的内容不受圆角(corners
)、齐刘海(sensor housing
)、小黑条(Home Indicator
)的影响。
上图来自designing-websites-for-iphone-x
uniapp适配:
uniapp适配安全距离有三个方法:
a. manifest.json配置安全距离
// manifest.json
{
"app-plus": {
"safearea": { //可选,JSON对象,安全区域配置
"background": "#RRGGBB", //可选,字符串类型,#RRGGBB格式,安全区域背景颜色
"backgroundDark": "#RRGGBB", //可选,字符串类型,#RRGGBB格式,暗黑模式安全区域背景颜色
"bottom": { //可选,JSON对象,底部安全区域配置
"offset": "auto" //可选,字符串类型,安全区域偏移值,可取值auto、none
},
"left": { //可选,JSON对象,左侧安全区域配置
"offset": "none" //可选,字符串类型,安全区域偏移值,可取值auto、none
},
"right": { //可选,JSON对象,左侧安全区域配置
"offset": "none" //可选,字符串类型,安全区域偏移值,可取值auto、none
}
},
}
}
问题: 这种方式显然不够灵活,它设置的是单独的背景色,如果需要下方一个区域是背景图,延伸到底部安全区就满足不了了。
所以,我是将以上的配置设置成none
,然后手动适配页面的安全距离:
b. js获取安全距离
let app = uni.getSystemInfoSync()
app.statusBarHeight // 手机状态栏的高度
app.bottom // 底部安全距离
c. 使用苹果官方推出的css函数env()、constant()适配
padding-bottom: constant(safe-area-inset-bottom); /*兼容 IOS<11.2*/
padding-bottom: env(safe-area-inset-bottom); /*兼容 IOS>11.2*/
注意: constant
和env
不能调换位置
可以配合calc
使用:
padding-bottom: calc(constant(safe-area-inset-bottom) + 20rpx); /*兼容 IOS<11.2*/
padding-bottom: calc(env(safe-area-inset-bottom) + 20rpx); /*兼容 IOS>11.2*/
h5适配
网页适配安全距离的前提是需要将<meta name="viewport">
标签设置viewport-fit:cover;
:
<meta name='viewport' content='initial-scale=1, viewport-fit=cover'>
直观一点就是:
上图来自移动端安全区域适配方案
然后再使用env
和constant
padding-bottom: constant(safe-area-inset-bottom); /*兼容 IOS<11.2*/
padding-bottom: env(safe-area-inset-bottom); /*兼容 IOS>11.2*/
5. 列表滚动相关问题
列表滚动如果使用overflow: auto;
在首次下拉时(即使触控点在列表内)也会使整个页面下拉:
解决这个问题只需要将内容使用 scroll-view 包裹即可:
<scroll-view scroll-y class="max-h-[800rpx] overflow-auto"></scroll-view>
下拉刷新将列表滚动到顶部:
小程序默认使用webview
渲染,如果需要Skyline
渲染引擎需要配置,而srcoll-view
标签在webview
中有个独有的属性enhanced
,启用后可通过 ScrollViewContext 操作 scroll-view
:
<scroll-view id="scrollview" :enhanced="true" scroll-y class="max-h-[800rpx] overflow-auto"></scroll-view>
/** 将scrollview滚动到顶部 */
function scrollToTop(id: string) {
wx.createSelectorQuery()
.select(id)
.node()
.exec((res) => {
const scrollView = res[0].node;
scrollView.scrollTo({
top: 0,
animated: true
});
})
}
onPullDownRefresh(async () => {
console.log('下拉刷新')
try {
await fetchList()
} catch (error) {
console.log(error)
} finally {
uni.stopPullDownRefresh()
scrollToTop('#scrollview')
}
})
6. 配置小程序用户隐私保护指引
文档:小程序隐私协议开发指南
什么时候要配置:
但凡你的小程序用到上图中任何一种用户信息就得配置,否则使用wx.authorize
来获取相应授权时直接会走到fail
回调,报 { "errMsg": "authorize:fail api scope is not declared in the privacy agreement", "errno": 112 }
配置的是什么:
配置的是将来你的程序打开让用户确认授权的隐私协议内容。
如何配置:
登录微信公众平台 -> 设置 -> 服务内容声明 -> 用户隐私保护指引 -> 修改
隐私弹框触发的流程是什么:
程序调用隐私相关接口 ——> 微信判断该接口是否需要隐私授权 ——> 如果需要隐私授权且开发者没有对其响应(注册onNeedPrivacyAuthorization的监听事件)则主动弹出官方弹框(此时隐私相关接口调用处于pending状态,如果用户拒绝将会报{ "errMsg":" getLocation:fail privacy permission is not authorized", "errno":104 }
)。
代码逻辑:
配置并等待审核通过后,进行以下步骤:
1. 配置 __usePrivacyCheck__: true
尽管官方文档说明2023年10月17日之后无论是否配置改字段,隐私相关功能都会启用,但是实际尝试后发现还是得配置上才生效。
// manifest.config.ts
'mp-weixin': {
__usePrivacyCheck__: true
},
2. 自定义隐私弹框组件
尽管官方提供了官方隐私弹框组件,但是真机上没有生效,于是还是使用了自定义隐私弹框。
我是直接在插件市场找了一个下载量最多的插件,兼容vue2和vue3。
在小程序对应的页面:
<WsWxPrivacy id="privacy-popup" @agree="onAgree" @disagree="onDisAgree"></WsWxPrivacy>
function onAgree() {}
function onDisAgree() {}
tip: 这部分逻辑相对于业务是几乎没有耦合的,甚至如果没有特殊需求agree
和disagree
事件都不用写。如果将来官方主动弹框没问题了,那这个逻辑可以直接删掉。
3. 业务代码
举个例子,我这里隐私相关接口是uni.getLocation
获取用户地理位置。
function handleCheckLocation() {
return new Promise((resolve, reject) => {
uni.getLocation({
type: 'gcj02',
success: async (res) => {
console.log('当前位置:', res)
try {
let r = await checkLocation({
lon: res.longitude.toString(),
lat: res.latitude.toString(),
})
// ...
resolve('success')
} catch (error) {
reject(error)
}
},
fail: (error) => {
console.log('获取位置失败:', error)
reject(error)
}
})
})
}
以上代码,在调用uni.getLocation
时,微信自动发起位置授权,发起位置授权之前又会自动发起隐私授权。到此,这一流程是ok的。但是,如果用户拒绝了隐私授权,或者拒绝了位置授权,该怎么办?
如果拒绝了隐私授权,下次调用隐私相关接口时还会再次弹出隐私授权弹框。
如果拒绝了位置授权,下次调用就不会弹出位置授权弹框,但可以通过uni.getSetting
来判断用户是否拒绝过,再通过wx.openSetting
让用户打开设置界面手动开启授权。代码如下:
function getLocationSetting() {
uni.getSetting({
success: (res) => {
console.log('获取设置:', res)
if (res.authSetting['scope.userLocation']) {
// 已经授权,可以直接调用 getLocation 获取位置
handleCheckLocation()
} else if (res.authSetting['scope.userLocation'] === false) {
// 用户已拒绝授权,引导用户到设置页面开启
wx.showModal({
title: '您未开启地理位置授权',
content: '请在设置中开启授权',
success: res => {
if (res.confirm) {
wx.openSetting({
success(settingRes) {
if (settingRes.authSetting['scope.userLocation']) {
// 用户打开了授权,再次获取地理位置
handleCheckLocation()
}
}
})
}
}
})
} else {
// 首次使用功能,请求授权
uni.authorize({
scope: 'scope.userLocation',
success() {
handleCheckLocation()
}
})
}
}
})
}
当然你也可以封装一下:
function getSetting(scopeName: string, cb: () => any) {
uni.getSetting({
success: (res) => {
console.log('获取设置:', res)
if (res.authSetting[scopeName]) {
// 已经授权,可以直接调用
cb()
} else if (res.authSetting[scopeName] === false) {
// 用户已拒绝授权,引导用户到设置页面开启
wx.showModal({
title: '您未开启相关授权',
content: '请在设置中开启授权',
success: res => {
if (res.confirm) {
wx.openSetting({
success(settingRes) {
if (settingRes.authSetting[scopeName]) {
// 用户打开了授权,再次获取地理位置
cb()
}
}
})
}
}
})
} else {
// 首次使用功能,请求授权
uni.authorize({
scope: scopeName,
success() {
cb()
}
})
}
}
})
}
这样,整个隐私协议指引流程就完整了。
来源:juejin.cn/post/7361688292351967259
面试官问我为什么 [] == ![] 为 true, 我表面冷静,实则内心慌的一批
前言
面试官问我,[] == ![] 的结果是啥,我:蒙一个true; 面试官:你是对的;我:内心非常高兴;
面试官:解释一下为什么; 我:一定要冷静,要不就说不会吧;这个时候,面试官笑了,同学,感觉你很慌的一批啊!
不必慌张,我们慢慢来!
在当今的编程领域,面试不仅是技术能力的考察,更是思维灵活性与深度理解的试金石。面试中偶遇诸如 [] == ![]
表达式这类题目,虽让人初感意外,实则深藏玄机,考验着我们对于JavaScript这类动态语言特性的透彻理解。这类问题触及了类型转换、逻辑运算以及语言设计的微妙之处,促使我们跳出日常编码的舒适区,深入探索编程语言的底层机制。接下来,我们将一步步揭开这道题目的神秘面纱,不仅为解答此类问题提供思路,更旨在通过这一过程,提升我们对JavaScript核心概念的掌握与应用能力。
首先我们来聊一下基础的东西。
1.原始值转布尔
首先是原始值转布尔
console.log(Boolean(1));//true
console.log(Boolean(0));//false
console.log(Boolean(-1));//true
console.log(Boolean(NaN));//false
console.log(Boolean('abc'));//true
console.log(Boolean(''));//false
console.log(Boolean(false));//flase
console.log(Boolean(undefined));//false
console.log(Boolean(null));//false
2.原始值转数字
console.log(Number('123'));//123
console.log(Number('hello'));//NaN
console.log(Number(true));//1
console.log(Number(false));//0
console.log(Number(''));//0
console.log(Number(' '));//0
console.log(Number(undefined));//NaN
console.log(Number(null));//0
3.原始值转字符串
console.log(String(123));//'123'
console.log(String(true));//'true'
console.log(String(false));//'false'
console.log(String(undefined));//'undefined'
console.log(String(null));//'null'
然后我们来了解一下与对象有关的转换逻辑
4. 原始值转对象
let a = new Number(1)
console.log(a);//[Number: 1]
其实也没有很特殊的,就是利用构造函数去进行显式转换即可。
5.对象转原始值
5.1 对象转布尔
首先我们来到这题,最后结果会被打印,说明对象在转换为布尔值的时候,不管什么对象,都是被转换为true。
5.2 + 一元运算符
我们先来了解一下,一元运算符的作用。查阅js官方文档,我们可以知道就是调用ToNumber()得到结果。而ToNumber()就是调用Number方式所调用的内置函数,因此就是强制转换为数字。我们也可以理解为+和Number()的作用是一样的。
5.3 + 二元运算符
二元运算符调用ToPrimitive()方法(ToNumber中的,转换方式有差异)。
5.4 ToNumber()方法
那么这个方法具体执行过程是什么呢?我们可以看到,如果是基本数据类型转数字,我们之前已经聊到,因此不必多聊,而面对对象转数字的时候,我们会先调用ToPrimitive方法。
5.5 ToPrimitive()方法
关于这个方法,我们要看是被ToNumber还是Totring方法给调用了。二者在返回值的顺序上会有所差异。
我们来聊一聊里面的valueOf()和toString()方法。
5.6 toString()和valueOf()方法
1. {}.toString() 得到由"[object class ] "组成字符串
2. [].toString() 返回由数组内部元素以逗号拼接的字符串
3. xx.toString() 返回字符串字面量
- valueOf 也可以将对象转成原始类型
1. 包装类对象
5.6 == 比较
我们首先引入官方文档
首先我们看二者类型相同时的比较, 里面有一点需要注意,只要有一个NaN就返回false,其他的我们应该都清楚。
二者类型不相等时,我们需要特别注意的是,null和undefined是相等的,字符串和数字则把字符串转数字,布尔和其他把布尔转数字,出现对象先把对象转原始值。
估计上面的大量干货已经把大家快搞懵逼了,此这里我们做个简单小结,这里面的方法前面都有提到哦。
5.7 小结(重点)
对象转数字
Number(obj) => ToNumber(obj) => ToNumber(ToPrimitive(obj,Number))
对象转字符串
String(obj) => ToString(obj) => ToString(ToPrimitive(obj,String))
5.8 大量实战练习
这一题我们知道+的作用和Number的方法是一样的。因此是转换为数字123.
那么这一题,我们考虑到
Number([]) => ToNumber([]) => ToNumber(ToPrimitive([], Number))=> ToNumber('') => 0
这里只要对象转布尔均为true
这里的底层原理(5.3里说了)是,我们首先两边都调用ToPrimitive方法,看看有没有字符串,有的话就把另一方转换为字符串,没有的话就全部调用ToNumber方法相加。
这里也是一样的原理。
我们先把两边转换为原始值,左边为' ',右边为'[object object]',发现存在字符串,因此相加。
这里只要我们看5.6就可以很轻松搞懂。
同上一个。
首先有对象,我们把对象转原始值,然后为NaN,为false.
最后回到我们最开始的题目,首先碰见![],我们先把[]转为布尔,为true,!true为false,然后把左边对象转原始值,为' ' == false,出现布尔和字符串,把布尔转数字,为' ' == 0,出现字符串和数字,把字符串转数字,为0 == 0,因此最后结果为true
来源:juejin.cn/post/7371312966364332042
前端比localStorage存储还大的本地存储方案
产品的原话就是“要又大又全”。既然存储量大,也要覆盖全多种设备多种浏览器。
方案选择
- 既然要存储的数量大,得排除cookie
- localStorage,虽然比cookie多,但是同样有上限(5M)左右,备选
- websql 使用简单,存储量大,兼容性差,备选
- indexDB api多且繁琐,存储量大、高版本浏览器兼容性较好,备选
既然罗列了一些选择,都没有十全十美的,那么有没有一种能够集合这多种方式的插件呢?渐进增强 or 优雅降级 的存在
冲着这个想法,就去github和谷歌找了一下,还真的有这么一个插件。
那就是 localforage
localforage
localForage 是一个 JavaScript 库,只需要通过简单类似 localStorage
API 的异步存储来改进你的 Web 应用程序的离线体验。它能存储多种类型的数据,而不仅仅是字符串。
关于兼容性
localForage 有一个优雅降级策略,若浏览器不支持 IndexedDB 或 WebSQL,则使用 localStorage。在所有主流浏览器中都可用:Chrome,Firefox,IE 和 Safari(包括 Safari Mobile)。下面是 indexDB、web sql、localStorage 的一个浏览器支持情况,可以发现,兼容性方面loaclForage基本上满足99%需求
使用
解决了兼容性和存储量的点,我们就来看看localforage的基础用法
安装
# 通过 npm 安装:
npm install localforage
// 直接引用
<script src="localforage.js"></script>
<script>console.log('localforage is: ', localforage);</script>
获取存储
getItem(key, successCallback)
从仓库中获取 key 对应的值并将结果提供给回调函数。如果 key 不存在,getItem() 将返回 null。
localforage.getItem('somekey').then(function(value) {
// 当离线仓库中的值被载入时,此处代码运行
console.log(value);
}).catch(function(err) {
// 当出错时,此处代码运行
console.log(err);
});
// 回调版本:
localforage.getItem('somekey', function(err, value) {
// 当离线仓库中的值被载入时,此处代码运行
console.log(value);
});
设置存储
setItem(key, value, successCallback)
将数据保存到离线仓库。你可以存储如下类型的 JavaScript 对象:
- Array
- ArrayBuffer
- Blob
- Float32Array
- Float64Array
- Int8Array
- Int16Array
- Int32Array
- Number
- Object
- Uint8Array
- Uint8ClampedArray
- Uint16Array
- Uint32Array
- String
localforage
.setItem("somekey", "some value")
.then(function (value) {
// 当值被存储后,可执行其他操作
console.log(value);
})
.catch(function (err) {
// 当出错时,此处代码运行
console.log(err);
});
// 不同于 localStorage,你可以存储非字符串类型
localforage
.setItem("my array", [1, 2, "three"])
.then(function (value) {
// 如下输出 `1`
console.log(value[0]);
})
.catch(function (err) {
// 当出错时,此处代码运行
console.log(err);
});
// 你甚至可以存储 AJAX 响应返回的二进制数据
req = new XMLHttpRequest();
req.open("GET", "/photo.jpg", true);
req.responseType = "arraybuffer";
req.addEventListener("readystatechange", function () {
if (req.readyState === 4) {
// readyState 完成
localforage
.setItem("photo", req.response)
.then(function (image) {
// 如下为一个合法的 <img> 标签的 blob URI
var blob = new Blob([image]);
var imageURI = window.URL.createObjectURL(blob);
})
.catch(function (err) {
// 当出错时,此处代码运行
console.log(err);
});
}
});
删除存储
removeItem(key, successCallback)
从离线仓库中删除 key 对应的值。
localforage.removeItem('somekey').then(function() {
// 当值被移除后,此处代码运行
console.log('Key is cleared!');
}).catch(function(err) {
// 当出错时,此处代码运行
console.log(err);
});
清空存储
clear(successCallback)
从数据库中删除所有的 key,重置数据库。
localforage.clear() 将会删除离线仓库中的所有值。谨慎使用此方法。
localforage.clear().then(function() {
// 当数据库被全部删除后,此处代码运行
console.log('Database is now empty.');
}).catch(function(err) {
// 当出错时,此处代码运行
console.log(err);
});
localforage是否万事大吉?
用上了localforage一开始我也以为可以完全满足万恶的产品了,然而。。。翻车了.。
内存不足的前提下,localforage继续缓存会怎么样?
在这种状态下,尝试使用localforage,不出意外,抛错了 QuotaExceededError 的 DOMErro
解决
存储数据的时候加上存储的时间戳和模块标识,加时间戳一起存储
setItem({
value: '1',
label: 'a',
module: 'a',
timestamp: '11111111111'
})
- 如果是遇到存储使用报错的情况,try/catch捕获之后,通过判断报错提示,去执行相应的操作,遇到内存不足的情况,则根据时间戳和模块标识清理一部分旧数据(内存不足的情况还是比较少的)
- 在用户手机上产生脏数据的情况,想要清理的这种情况的 处理方式是:
- 让后端在用户信息接口里面加上缓存有效期时间戳,当该时间戳存在,则前端会进行一次对本地存储扫描
- 在有效期时间戳之前的数据,结合模块标识,进行清理,清理完毕后调用后端接口上报清理日志
- 模块标识的意义是清理数据的时候,可以按照模块去清理(选填)
来源:juejin.cn/post/7273028474973012007
前端中 JS 发起的请求可以暂停吗
在前端中,JavaScript(JS)可以使用XMLHttpRequest对象或fetch API来发起网络请求。然而,JavaScript本身并没有提供直接的方法来暂停请求的执行。一旦请求被发送,它会继续执行并等待响应。
尽管如此,你可以通过一些技巧或库来模拟请求的暂停和继续执行。下面是一种常见的方法:
1. 使用XMLHttpRequest对象
你可以在发送请求前创建一个XMLHttpRequest对象,并将其保存在变量中。然后,在需要暂停请求时,调用该对象的abort()方法来中止请求。当需要继续执行请求时,可以重新创建一个新的XMLHttpRequest对象并发起请求。
var xhr = new XMLHttpRequest();
xhr.open('GET', 'https://example.com/api', true);
xhr.send();
// 暂停请求
xhr.abort();
// 继续请求
xhr = new XMLHttpRequest();
xhr.open('GET', 'https://example.com/api', true);
xhr.send();
2. 使用fetch API和AbortController
fetch API与AbortController一起使用可以更方便地控制请求的暂停和继续执行。AbortController提供了一个abort()方法,可以用于中止fetch请求。
var controller = new AbortController();
fetch('https://example.com/api', { signal: controller.signal })
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error));
// 暂停请求
controller.abort();
// 继续请求
controller = new AbortController();
fetch('https://example.com/api', { signal: controller.signal })
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error));
请注意,这些方法实际上是通过中止请求并重新发起新的请求来模拟暂停和继续执行的效果,并不能真正暂停正在进行的请求。
3. 曲线救国
模拟一个假暂停的功能,在前端的业务场景上,需要对这些数据进行处理之后渲染在界面上,如果我们能在请求发起之前增加一个控制器,在请求回来时,如果控制器为暂停状态则不处理数据,等待控制器恢复后再进行处理,也可以达到暂停的效果。
// 创建一个暂停控制器 Promise
function createPauseControllerPromise() {
const result = {
isPause: false, // 标记控制器是否处于暂停状态
resolveWhenResume: false, // 表示在恢复时是否解析 Promise
resolve(value) {}, // 解析 Promise 的占位函数
pause() { // 暂停控制器的函数
this.isPause = true;
},
resume() { // 恢复控制器的函数
if (!this.isPause) return;
this.isPause = false;
if (this.resolveWhenResume) {
this.resolve();
}
},
promise: Promise.resolve(), // 初始为已解决状态的 Promise
};
const promise = new Promise((res) => {
result.resolve = res; // 将解析函数与 Promise 关联
});
result.promise = promise; // 更新控制器中的 Promise 对象
return result; // 返回控制器对象
}
function requestWithPauseControl(request) {
const controller = createPauseControllerPromise(); // 创建暂停控制器对象
const controlRequest = request() // 执行请求函数
.then((data) => { // 请求成功回调
if (!controller.isPause) controller.resolve(); // 如果控制器未暂停,则解析 Promise
return data; // 返回请求结果
})
.finally(() => {
controller.resolveWhenResume = true; // 标记在恢复时解析 Promise
});
const result = Promise.all([controlRequest, controller.promise]).then(
(data) => {
controller.resolve(); // 解析控制器的 Promise
return data[0]; // 返回请求处理结果
}
);
result.pause = controller.pause.bind(controller); // 将暂停函数绑定到结果 Promise 对象
result.resume = controller.resume.bind(controller); // 将恢复函数绑定到结果 Promise 对象
return result; // 返回添加了暂停控制功能的结果 Promise 对象
}
为什么需要创建两个promise
在requestWithPauseControl函数中,需要等待两个Promise对象解析:一个是请求处理的Promise,另一个是控制器的Promise。通过使用Promise.all方法,可以将这两个Promise对象组合成一个新的Promise,该新的Promise会在两个原始Promise都解析后才会解析。这样做的目的是确保在处理请求结果之前,暂停控制器的resolve方法被调用,以便在恢复时解析Promise。
因此,将请求处理的Promise和控制器的Promise放入一个Promise数组,并使用Promise.all等待它们都解析完成,可以确保在两个Promise都解析后再进行下一步操作,以实现预期的功能。
使用
const requestFn = () => new Promise(resolve => {
setTimeout(() => resolve({ author: 'vincentzheng', msg: 'hello' }), 0)
})
const result = requestWithPauseControl(requestFn);
result.then((data) => {
console.log("返回结果", data);
});
if (Math.random() > 0.5) {
console.log('命中暂停')
result.pause();
}
setTimeout(() => {
result.resume();
}, 4000);
来源:juejin.cn/post/7310786521082560562
手撸一个精美简约loading加载功能,再也不怕复杂的网页效果了
我来看看怎么个事?
你们还记得自己为什么要做程序员吗?我先来说,就是看见别人有一个精美的网站。但是,现在很多人要么就是后端crud boy,要么就是前端vue渲染数据girl。没有现成的框架,现成的ui组件,就没法写代码了。好看的网页怎么来呢?有人会说是UI设计的,我前端只需要vue渲染数据就行了🤣(今天我们就不探讨后端技术🐶)。久而久之,自己就会慢慢变菜,最后想开发一个项目,发现无从下手,写个页面都费劲!!!所以,还是慢慢做一个全栈,这样既可以写好玩的工具,也可以提高自己的竞争力,强者恒强,没错就是我啦😅
1.loading实际效果图
字不重要,看图👉👉👉👉
pc端
移动端
2.准备css素材
这种loading的效果,网上有很多网站可以直接diy,几乎没有人手写一个。当然你也可以手写,如果觉得闲的话
推荐网站
国内也有很多,我使用的是国外网站(科学上网)
下载素材推荐svg格式,如果你的svg动图存在背景
如图,这种背景一定要去掉,给svg设置一个透明度,找到svg文件,background属性,设置rgb(255,255,255,0)就可以了,如下图:
3.loading隐藏与显示逻辑
思考🤔:
当我们点击按钮的时候,一般会触发请求,比如请求后台数据,这个时候中间就会有加载的样式。
总结就两个条件:
1.按钮要触发点击事件,开启loading效果
2.需要一个事件完成的状态(标记),关闭loading效果
4.编写looding组件,全局注册组件
<script setup>
</script>
<template>
<div class="loading">
<img src="./loading.svg" alt="loading"/>
</div>
</template>
<style scoped lang="scss">
.loading {
//通过定位,实现loading水平垂直居中
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
img {
width: 70px;
height: 70px;
}
}
</style>
全局注册loading组件
5.登录页面使用loading组件
<script setup>
import {reactive, ref, watch} from "vue";
//模拟请求数据,code===200表示事件完成
const result = reactive({data: [], code: 0})
//判断按钮是否触发点击事件,默认false,按钮没有触发点击
const clickFlag = ref(false)
//按钮提交方法
const submit = () => {
//重置code
result.code = 0
//标记请求触发
clickFlag.value = true
//模拟http请求
setTimeout(() => {
//模拟后台数据
result.data = [{'name': 'bobo', 'age': 12}]
//模拟请求完成
result.code = 200
}, 3000)
}
</script>
<template>
<div class="login-container">
<div class="login-box">
<div class="form">
<h2>用户登录</h2>
<div class="content">
<input class="input" type="text" placeholder="请输入账号">
<input class="input" type="password" placeholder="请输入密码">
<button @click="submit" class="button">登录</button>
<!--判断loading 1.有没有点击事件 2.有没有loading终止标记-->
<loading v-show="result.code!==200&&clickFlag"></loading>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.login-container {
.login-box {
.form {
position: relative;
padding: 24px;
text-align: center;
color: rgb(55 65 81);
line-height: 1.75rem;
}
}
}
</style>
具体页面布局的代码,请参考我之前的文章juejin.cn/post/738854…
这里不做过多叙述!能坚持✊看到这里,想必你一定很棒,给你个🍭🍭🍭
在线浏览网址http://www.codingfox.icu/#/dashboard (我不知道可以坚持到多久,靠爱发电,网站搬迁会尽量迁移)
来源:juejin.cn/post/7389178780437921803
首屏优化之:import 动态导入
前言
前面我们聊过可以通过不同的 script 属性比如 defer,async 来实现不同的加载效果,从而实现首屏优化。
今天我们来聊一下动态导入之 import
,当然 import 动态导入也不是一把梭的,也是需要根据项目情况进行使用,在面试以及实际工作当中都可以用到,一起来看看吧!
在了解动态导入之前,我们先来看一下什么是静态导入
。
静态导入
静态导入会在编译时解析所有导入的模块,并在程序开始执行之前加载
它们。这意味着所有被导入的模块在应用启动时就已经加载完毕
。
什么意思,我们先来看一下下面这段代码:
这段代码很简单,我在页面导入了 import.js,当点击按钮时打印输出语句。
我们来看一下浏览器初始化加载情况:
很明显,程序开始执行之前,import.js 就被加载了。
但是在某些时刻,我们不希望文件在没有被使用时就被加载,只希望在使用时加载,这样可以优化首屏的加载速度,这些时刻我们就可以使用动态导入。
动态导入
动态导入是一种在代码执行时按需加载模块的技术,而不是在应用程序初始化时加载所有模块。
默认不会一上来加载所有文件,只会在用到时加载,这样可以优化初始加载时间,提升页面响应速度。
动态导入与静态导入不同,动态导入使用 ES6 中的 import()
语法,可以在函数或代码块中调用,从而实现条件加载、按需加载或延迟加载。例如:
import('./import.js')
还是上面的代码,我们使用动态导入来进行实现一下:
我们再来看一下浏览器的加载情况:
可以看到一上来并没有加载 import.js
当点击按钮时,才加载了 import.js 文件,这就说明import导入的文件不会一上来就直接被加载,而是在相关代码被执行时才进行加载的。
一些应用
路由懒加载
在 react 中我们常常使用 lazy 和 Suspense 来实现路由的懒加载,这样做的好处就是初始化时不会一下加载所有的页面,而是当切换到相应页面时才会加载相应的页面,例如:
组件动态导入
对于一些不常用或者不需要直接加载的组件我们也可以采用动态导入,比如弹出框。
我们只需要在点击时进行加载显示即可。
分包优化
这里就简单说一下分包的优化,webpack 默认的分包规则有以下三点:
- 通过配置多个入口 entry,可以将不同的文件分开打包。
- 使用
import()
语法,Webpack 会自动将动态导入的模块放到单独的包中。‘ entry.runtime
单独组织成一个 chunk。
根据第二点,被动态导入的文件会被单独进行打包,不会被分包进同一个文件,也就不会在初始加载 bundle.js 时被一起进行加载。
通过将代码分割成多个小包,可以在用户需要时才加载特定的模块,从而显著减少初始加载的时间。
总结
在进行首屏优化时,可以采取动态导入的方式实现,使用 import('./文件路径')实现,虽然动态导入有一些优化首屏渲染的优势,但是也有一些缺点,比如首次加载延迟,不利于 SEO 优化等,所以在使用动态导入时应该好好进行规划,比如一些不常用的模块或者内容不太复杂,对加载速度无要求的文件可以进行动态导入,这个还是要根据项目的需求来进行使用的。
来源:juejin.cn/post/7400332893158391819
优雅的处理async/await错误
async/await使用
async/await
解决了Promise的链式调用(.then)造成的回调地狱,使异步回调变得像同步一样美观!
使用的方式如下:
// 异步函数1
let postFun1 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { resolve('postFun1') }, 2000)
})
}
// 异步函数2
let postFun2 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { resolve('postFun2') }, 1000)
})
}
// async/await
async function syncFun() {
let s1 = await postFun1()
console.log(s1)
let s2 = await postFun2()
console.log(s2)
console.log('s1、s2都获取到了,我才会执行')
}
syncFun()
可以看出,在syncFun函数中,我们获取异步信息,书写方式就跟同步一样,不用.then套.then,很美观!
不捕获错误会怎样
// 异步函数1
let postFun1 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { resolve('postFun1') }, 2000)
})
}
// 异步函数2
let postFun2 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject('err') }, 1000)
})
}
async function asyncFun() {
let s1 = await postFun1();
let s2 = await postFun2();
console.log('s1、s2都获取到了,我才会执行')
}
asyncFun();
控制台:
可以看出,控制台没有我们想要打印的信息console.log('s1、s2都获取到了,我才会执行')
try/catch捕获错误
我们日常开发中,都是使用try/catch捕获错误,方式如下:
let postFun1 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject('err1') }, 2000)
})
}
let postFun2 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject('err2') }, 1000)
})
}
async function asyncFun() {
try{
let s1 = await postFun1();
let s2 = await postFun2();
}catch(e){
console.log(e)
}
console.log('s1、s2都获取到了,我才会执行')
}
asyncFun();
控制台:
可以看出,我们抛出两个reject,但是只捕获到了一个错误!
那么捕获多个错误,我们就需要多个try/catch如此,代码便像现在这样:
let postFun1 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject('err1') }, 2000)
})
}
let postFun2 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject('err2') }, 1000)
})
}
async function asyncFun() {
try{
let s1 = await postFun1();
}catch(e){
console.log(e)
}
try{
let s2 = await postFun2();
}catch(e){
console.log(e)
}
console.log('s1、s2都获取到了,我才会执行')
}
asyncFun();
控制台:
仅仅是两个try/catch已经看起来很难受了,那么10个呢?
await-to-js
/**
* @param promise 传进去的请求函数
* @param errorExt 拓展错误信息
* @return 返回一个Promise
*/
function to(promise, errorExt) {
return promise
.then(res => [null, res])
.catch(err => {
if (errorExt) {
const parsedError = Object.assign({}, err, errorExt)
return [parsedError, undefined]
}
return [err, undefined]
})
}
await命令后面是一个 Promise 对象,返回该对象的结果。如果不是 Promise 对象,就直接返回对应的值
这里封装了一个
to
函数,接收promise和扩展的错误信息为参数,返回promise
。[err,res]
分别代表错误信息和成功结果,.then()成功时,[null,res]代表错误信息为null
;.catch()失败时,[err,undefined]代表,成功结果为undefined
。我们获取捕获的结果直接从返回的数组中取就行,第一个是失败信息,第二个是成功结果!
完整代码加使用
function to(promise, errorExt) {
return promise
.then(res => [null, res])
.catch(err => {
if (errorExt) {
const parsedError = Object.assign({}, err, errorExt)
return [parsedError, undefined]
}
return [err, undefined]
})
}
let postFun1 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject({err:'err1'}) }, 2000)
})
}
let postFun2 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject({err:'err2'}) }, 1000)
})
}
async function asyncFun() {
let [err1,res1] = await to(postFun1(), {msg:'抱歉1'});
let [err2,res2] = await to(postFun2(), {msg:'抱歉2'});
console.log(err1,err2)
console.log('s1、s2都获取到了,我才会执行')
}
asyncFun()
把这个学会,在面试官面前装一波,面试官定会直呼优雅!!!
来源:juejin.cn/post/7278280824846925861
threejs 搭建智驾自车场景
智能驾驶业务里常用web 3d来可视化地图和传感器数据、显示路径规划结果等方法协助算法调试和仿真,可以用threejs来做,毕竟在国内社区相对活跃,也比较容易上手,效果类似下图:
当然以上图片都是客户端的版本,web3d版本的ui其实并不会这么精致,毕竟只是服务于内部算法和研发。这个专栏纯属作者一时兴起并希望能产出一个麻雀虽小五脏俱全的行泊场景(简称人太闲),本文就先把自车的基础场景搭建起来
本文基于 three^0.167.1 版本
初始化项目
用 Vite 脚手架快速搭一个 react 项目用来调试
pnpm create vite autopilot --template react-ts
把 threejs 官网的例子稍微改下,加到项目里看看。新建一个 renderer 对象如下:
// src/renderer/index.ts
import * as THREE from "three";
class Renderer {
constructor() {
//
}
initialize() {
const container = document.getElementById("my-canvas")!;
const width = container.offsetWidth,
height = container.offsetHeight;
const camera = new THREE.PerspectiveCamera(70, width / height, 0.01, 10);
camera.position.z = 1;
const scene = new THREE.Scene();
const geometry = new THREE.BoxGeometry(0.2, 0.2, 0.2);
const material = new THREE.MeshNormalMaterial();
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setAnimationLoop(animate);
container.appendChild(renderer.domElement);
function animate(time: number) {
mesh.rotation.x = time / 2000;
mesh.rotation.y = time / 1000;
renderer.render(scene, camera);
}
}
}
export const myRenderer = new Renderer();
// App.tsx
import { useEffect } from "react";
import { myRenderer } from "./renderer";
import "./App.css";
function App() {
useEffect(() => {
myRenderer.initialize();
}, []);
return (
<>
<div id="my-canvas"></div>
</>
);
}
export default App;
加载自车
ok,跨出第一步了,接下来整辆自车(egoCar)
“自车”指的是自动驾驶汽车本身,它能够通过搭载的传感器、计算平台和软件系统实现自主导航和行驶
可以上 free3d 下载个免费的车辆模型,里面有很多种格式的,尽量找 gltf/glb 格式的(文件体积小,加载比较快)。
这里以加载 glb 格式的模型为例,可以先把模型文件放到 public 目录下,因为加载器相对网页的根路径(index.html)解析,而 public 目录在打包后会原封不动保存到根目录里
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
const gltfLoader = new GLTFLoader();
class Renderer {
scene = new THREE.Scene();
// ...
loadEgoCar() {
gltfLoader.load("./su7.glb", (gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
this.scene.add(car);
});
}
// ...
initialize() {
// ...
this.loadEgoCar();
}
}
但如果一定要放到 src/assets/models
目录里呢?然后通过import方式引入文件来用,那这么操作下来就会遇到这个报错(You may need to install appropriate plugins to handle the .glb file format, or if it's an asset, add "**/*.glb" to assetsInclude
in your configuration):
怎么解?在 vite.config.ts
文件加入 assetsInclude
。顺带把 vite 指定路径别名 alias 也支持一下
// vite .config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
// 指定路径别名
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
assetsInclude: ["**/*.glb"],
});
node:url 如果提示没有该模块,先安装下@types/node,可能要重启下vscode才能生效
pnpm i @types/node -D
接下来就可以直接用 import 导入 glb 文件来用了
import carModel from "@/assets/models/su7.glb";
class Renderer {
// ...
loadEgoCar() {
gltfLoader.load(carModel, (gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
this.scene.add(car);
});
}
}
OrbitControls
增加 OrbitControls 插件,便于调节自车视角,这个插件除了围绕目标点(默认是原点[0,0,0])旋转视角,还支持缩放(滚轮)和平移(鼠标右键,触摸板的话是双指长按)
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
class Renderer {
initialize() {
// ...
const controls = new OrbitControls(camera, renderer.domElement);
function animate() {
// ...
controls.update();
renderer.render(scene, camera);
}
}
}
光源设置
看起来场景和自车都比较暗,咱们调下光源,加一个环境光 AmbientLight 和平行光 DirectionalLight,平行光位置放自车后上方,沿着自车方向(也就是原点方向)发射光源
// ...
// 没有特定方向,影响整个场景的明暗
const ambient = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambient);
// 平行光
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(60, 80, 40);
scene.add(directionalLight);
地面网格
增加坐标网格,新建一个 Grid 对象,提供一个水平的基准面,便于观察
// ...
// 50表示网格模型的尺寸大小,20表示纵横细分线条数量
const gridHelper = new THREE.GridHelper(50, 20);
scene.add(gridHelper);
// 顺带调高下相机位置
camera.position.set(0, 1, 1.8);
// 设置场景背景色(颜色值,透明度)
renderer.setClearColor(0x000000, 0.85);
道路实现
这里先简单实现一段不规则道路,封装一个 freespace
对象,还要考虑它的不规则和带洞的可能,所以需要做好接口定义,其实数据主要是点集,一般这些点集都是地图上游发下来的,可能是 protobuf 或者 json 的格式
export interface IFreespace {
// 一般可以用于判断元素是否可复用
id: string;
position: IPos;
contour: IPos[];
// 洞可能有多个,所以这里应该设置成二维数组
holes?: IPos[][];
color?: IColor;
}
export interface IPos {
x: number;
y: number;
z?: number;
}
export interface IColor {
r: number;
g: number;
b: number;
a?: number;
}
因为只是一个平面形状,所以可以用 THREE.Shape
来实现,它可以和 ExtrudeGeometry
、ShapeGeometry
一起使用来创建二维形状
// src/renderers/freespace.ts
class Freespace {
scene = new THREE.Scene();
constructor(scene: THREE.Scene) {
this.scene = scene;
}
draw(data: IFreespace) {
const {
contour,
holes = [],
color = { r: 0, g: 0, b: 0 },
position,
} = data;
if (contour.length < 3) {
return;
}
const shape = new THREE.Shape();
// 先绘制轮廓
// 设置起点
shape.moveTo(contour[0].x, contour[0].y);
contour.forEach((item) => shape.lineTo(item.x, item.y));
// 绘制洞
holes.forEach((item) => {
if (item.length < 3) {
return;
}
const path = new THREE.Path();
path.moveTo(item[0].x, item[0].y);
item.forEach((subItem) => {
path.lineTo(subItem.x, subItem.y);
});
// 注意这一步
shape.holes.push(path);
});
const shapeGeometry = new THREE.ShapeGeometry(shape);
const material = new THREE.MeshPhongMaterial();
// 注意:setRGB传参颜色值需要介于0-1之间
material.color.setRGB(color.r / 255, color.g / 255, color.b / 255);
material.opacity = color.a || 1;
const mesh = new THREE.Mesh(shapeGeometry, material);
mesh.position.set(position.x, position.y, position.z || 0);
mesh.rotateX(-Math.PI / 2);
this.scene.add(mesh);
}
}
export default Freespace;
ok先用mock的数据画一段带洞的十字路口,加在 initialize
代码后就行,其实道路上还应该有一些交通标线,后面再加上吧
最后再监听下界面的 resize 事件,使其能根据容器实际大小变化动态调整场景
// ...
constructor() {
// 初始化渲染对象
this.renderers = {
freespace: new Freespace(this.scene),
};
}
initialize() {
// ...
this.loadEgoCar();
this.registerDefaultEvents();
// mock
this.mockData();
}
mockData() {
this.renderers.freespace.draw(freespaceData1);
}
// 监听resize事件
registerDefaultEvents() {
window.addEventListener("resize", this.onResize.bind(this), false);
}
unmountDefaultEvents() {
window.removeEventListener("resize", this.onResize.bind(this), false);
}
onResize() {
const container = document.getElementById("my-canvas")!;
const width = container.offsetWidth,
height = container.offsetHeight;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
}
最后
ok先到这了,主要是先把项目搭起来,后面会继续分享下更多地图和感知元素以及他车、行人、障碍物等效果的实现
来源:juejin.cn/post/7406643531697913867
想学 pinia ?一文就够了
有时候不得不承认,官方的总结有时就是最精简的:
Pinia 是 Vue 的存储库,它允许您跨组件/页面共享状态。
虽然作为Vuex的升级版,但为了尊重原作者,所以取名pinia,而没有取名Vuex,所以大家可以直接将pinia比作为Vue3的Vuex,同时,pinia提供了一种更简洁、更直观的方式来处理应用程序的状态,更为重要的是,pinia的学习成本更低,低到一篇文章就能涵盖pinia的全部。
Pinia的安装与配置:
首先自然是安装pinia,在基于Vue3的项目环境中,提供了npm
与yarn
两种安装方式:
npm install pinia
yarn add pinia
随后,通常就是在src
目录下新建一个专属的store
文件夹,在其中的js
文件中创建并抛出这个仓库。
import { createPinia } from 'pinia' // 引入pinia模块
const store = createPinia() // 创建一个仓库
export default store // 抛出这个仓库
既然把这个仓库抛出了,那么现在便是让它能在全局起作用,于是在Vue的主要应用文件中(通常为main.js),引入使用pinia
。
import { createApp } from 'vue'
import App from './App3.vue'
import store from './store' //引入这个仓库
createApp(App).use(store).mount('#app') // 再use一下
这样一来pinia
仓库就能全局生效了!
Pinia的主要功能:
在官方文档中,Pinia提供了四种功能,分别是:
- Store:在Pinia中,每个状态管理模块都被称为一个Store。开发者需要创建一个Store实例来定义和管理状态。
- State:在Store中定义状态。可以使用defineState函数来定义一个状态,并通过state属性来访问它。
- Getters:类似于Vuex中的getters,用于从State中派生出一些状态。可以使用
defineGetters
函数来定义getters。 - Actions:在Pinia中,Actions用于处理异步操作或执行一些副作用。可以使用
defineActions
函数来定义Actions。
那么接下来我会通过一个具体的实例来表现出这四个功能,如下图:
分别是充当仓库的Store功能。存储子组件User.vue
中数据的State功能。另一个子组件Update-user.vue
中,点击按钮后数据会实现更新,也就是修改State中数据的Actions功能。与无论点击多少次” 经过一年后按钮 ”,页面都会实现同步更新的Getters功能。
State:
简单来说,State的作用就是作为仓库的数据源。
就比如说,我想在仓库的数据源里面放上一个对象来进行使用,那我们只需在先前创建的store
文件夹中再创建一个js
文件,这里我给它起名为user
,然后再其中这样添加对象。
(第一行引入的defineStore
代表defineStore
是store
的一部分。)
import { defineStore } from 'pinia' // defineStore 是 store 的一部分
export const useUserStore = defineStore({
id: 'user',
state: () => ({ // 仓库数据源
userInfo: {
name: '小明',
age: 18,
sex:'boy'
}
})
})
那么现在,我们想使用仓库中的数据就成为了一件非常容易的事。
正如上图,这里有一个父组件App.vue
,两个子组件User.vue
、Update-user.vue
。
父组件不做任何动作,只包含对两个子组件的引用:
<template>
<User/>
<Updateuser/>
</template>
<script setup>
import User from './components/User.vue'
import Updateuser from './components/Update-user.vue'
</script>
<style lang="css" scoped>
</style>
子组件User.vue:
可以看到在这个子组件中,我们通过import { useUserStore } from '@/store/user'
引用仓库,从而获得了仓库中小明姓名、年龄、性别的数据。
由于接下来的Update-user.vue
组件中会添加几个按钮对这些数据进行修改,那么我们就要把这些数据设置成响应式。
正常情况下,store
自带响应性,但如果我们不想每次都写userStore.userInfo.name
这么长一大串,就可以尝试将这些值取出来赋给其他变量:
这里有两种方法,第一种是引入computed
模块,如第14行年龄的修改。另一种是引入storeToRefs
模块,这是一种属于Pinia
仓库的模块,将整个userInfo
变成响应式。
于是接下来,就轮到我们的Actions登场了
<template>
<ul>
<li>姓名:{{ userStore.userInfo.name }}</li>
<li>年龄:{{ age }}</li>
<li>性别;{{ userInfo.sex }}</li>
</ul>
</template>
<script setup>
import { useUserStore } from '@/store/user'
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const age = computed(() => userStore.userInfo.age) // 1. 计算属性使响应式能生效
const { userInfo } = storeToRefs(userStore) // 2. 专门包裹仓库中函数用来返回对象
</script>
<style lang="scss" scoped>
</style>
Actions:
简单来说,Actions的作用就是专门用来修改State,如果你想要修改仓库中的响应式元素,只需要进行两步操作:
第一步:在user.js
也就是我们的仓库中添加actions
,专门设置函数用来修改state对象中的值。例如changeUserName
作用是修改姓名, changeUserSex
作用是修改性别。
import { defineStore } from 'pinia' // defineStore 是 store的一部分
export const useUserStore = defineStore({
id: 'user',
state: () => ({ // 仓库数据源
userInfo: {
name: '小明',
age: 18,
sex:'boy'
}
}),
actions: { // 专门用来修改state
changeUserName(name) {
this.userInfo.name = name
},
changeUserSex(sex){
this.userInfo.sex = sex
}
}
})
子组件Update-user.vue:
第二步,在控制按钮的组件Update-user.vue
中触发这两个函数,就如第10与14行的两个箭头函数。
<template>
<button @click="changeName">修改仓库中用户姓名</button>
<button @click="changeSex">修改仓库中用户性别</button>
</template>
<script setup>
import { useUserStore } from '@/store/user' // 引入Pinia仓库
const userStore = useUserStore() // 声明仓库
const changeName = () => { // 触发提供的函数
userStore.changeUserName('小红')
}
const changeSex = () => {
userStore.changeUserSex('gril')
}
</script>
<style lang="css" scoped>
</style>
这样一来,依赖于Actions,我们就成功完成了响应式修改仓库中数据的功能,也就是前两个按钮的功能!
Getters:
简单来说Getters就是仓库中的计算属性。
现在我们来实现第三个按钮功能,首先就是在User.vue
组件中第5行,添加 “ 十年之后年龄 ” 一栏:
<template>
<ul>
<li>姓名:{{userStore.userInfo.name}}</li>
<li>年龄:{{ age }}</li>
<li>十年后年龄:{{ userStore.afterAge }}</li> // 添加的栏
<li>性别:{{ userInfo.sex }}</li>
</ul>
</template>
<script setup>
import { useUserStore } from '@/store/user'
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const age = computed(() => userStore.userInfo.age)
const { userInfo } = storeToRefs(userStore)
</script>
<style lang="scss" scoped>
</style>
那么现在你一定能注意到这一栏其中的userStore.afterAge
,这正是我们将在getters中返回的值。
那么关于getters,具体的使用方法就是继续在user.js
中添加进getters,我们在其中打造了一个afterAge
函数来返回userStore.afterAge
,正如第25行。
import { defineStore } from 'pinia' // defineStore 是 store的一部分
export const useUserStore = defineStore({
id: 'user',
state: () => ({ // 仓库数据源
userInfo: {
name: '小明',
age: 18,
sex:'boy'
}
}),
actions: { // 专门用来修改state
changeUserName(name) {
this.userInfo.name = name
},
changeUserSex(sex){
this.userInfo.sex = sex
},
changeUserAge(age){ // 新添加的一年后年龄计算方法
this.userInfo.age += age
}
},
getters: { // 仓库中的计算属性,所依赖的值改变会重新执行
afterAge(state) {
return state.userInfo.age + 10
}
}
})
准备工作完毕,现在就该在页面上添加这个按钮,于是在组件Update-user.vue
添加上按钮与执行函数。
<button @click="changeAge">经过一年后</button>
const changeAge = () => {
userStore.changeUserAge(1)
}
有了这些之后,这个项目的功能便彻底完善,无论点击多少次“ 经过一年后 ”按钮,在页面上显示的值都是正确且实时更新的,这就是Getters的功劳!
补充:数据持久化
关于整个项目的功能实现确实已经结束,但人的贪心却是不得满足的,如果我们想要在原有的基础上实现网页刷新数据却不刷新,也就是说数据的持久化,那又该怎么办呢?
很简单,也就是堪堪三步,便能实现。
第一步:安装persist
插件。
npm i pinia-plugin-persist
第二步:在store
的js
文件中引入这个插件。
import { createPinia } from 'pinia'
import piniaPluginPersist from 'pinia-plugin-persist' //引入插件
const store = createPinia()
store.use(piniaPluginPersist) // 使用插件
export default store
第三步:在我们前文user.js
的defineStore
库内继续添加上persist
功能。
persist: { // 持久化
enabled: true,
strategies: [ // 里面填想要持久化的数据
{
paths: ['userInfo'], // 指明持久化的数据
storage: localStorage // 指明存储
}
]
}
现在可以看到点击按钮后的数据都被存储到浏览器的存储空间中,无论多少次刷新都不会被重置!
最后:
至此,这样一个简简单单的项目,却解释清楚了Pinia功能的核心,读完这篇文章,相信每一个学习Pinia的人都能有所收获。
来源:juejin.cn/post/7407407711879807026
你知道为什么template中不用加.value吗?
Vue3 中定义的ref
类型的变量,在setup
中使用这些变量是需要带上.value
才可以访问,但是在template
中却可以直接使用。
询其原因,可能会说 Vue 自动进行ref
解包了,那具体如何实现的呢?
proxyRefs
Vue3 中有有个方法proxyRefs
,这属于底层 API 方法,在官方文档中并没有阐述,但是 Vue 里是可以导出这个方法。
例如:
<script setup>
import { onMounted, proxyRefs, ref } from "vue";
const user = {
name: "wendZzoo",
age: ref(18),
};
const _user = proxyRefs(user);
onMounted(() => {
console.log(_user.name);
console.log(_user.age);
console.log(user.age);
});
</script>
上面代码定义了一个普通对象user
,其中age
属性的值是ref
类型。当访问age
值的时候,需要通过user.age.value
,而使用了proxyRefs
,可以直接通过user.age
来访问。
这也就是为何template
中不用加.value
的原因,Vue3 源码中使用proxyRefs
方法将setup
返回的对象进行处理。
实现proxyRefs
单测
it("proxyRefs", () => {
const user = {
name: "jack",
age: ref(10),
};
const proxyUser = proxyRefs(user);
expect(user.age.value).toBe(10);
expect(proxyUser.age).toBe(10);
proxyUser.age = 20;
expect(proxyUser.age).toBe(20);
expect(user.age.value).toBe(20);
proxyUser.age = ref(30);
expect(proxyUser.age).toBe(30);
expect(user.age.value).toBe(30);
});
定义一个age
属性值为ref
类型的普通对象user
。proxyRefs
方法需要满足:
proxyUser
直接访问age
是可以直接获取到 10 。- 当修改
proxyUser
的age
值切这个值不是ref
类型时,proxyUser
和原数据user
都会被修改。 age
值被修改为ref
类型时,proxyUser
和user
也会都更新。
实现
既然是访问和修改对象内部的属性值,就可以使用Proxy
来处理get
和set
。先来实现get
export function proxyRefs(objectWithRefs) {
return new Proxy(objectWithRefs, {
get(target, key) {}
});
}
需要实现的是proxyUser.age
能直接获取到数据,那原数据target[key]
是ref
类型,只需要将ref.value
转成value
。
使用unref
即可实现,unref
的实现参见本专栏上篇文章,文章地址:mp.weixin.qq.com/s/lLkjpK9TG…
get(target, key) {
return unref(Reflect.get(target, key));
}
实现set
export function proxyRefs(objectWithRefs) {
return new Proxy(objectWithRefs, {
get(target, key) {
return unref(Reflect.get(target, key));
},
set(target, key, value) {},
});
}
从单侧中可以看出,我们是测试了两种情况,一种是修改proxyUser
的age
为ref
类型, 一种是修改成不是ref
类型的,但是结果都是同步更新proxyUser
和user
。那实现上也需要考虑这两种情况,需要判断原数据值是不是ref
类型,新赋的值是不是ref
类型。
使用isRef
可以判断是否为ref
类型,isRef
的实现参见本专栏上篇文章,文章地址:mp.weixin.qq.com/s/lLkjpK9TG…
set(target, key, value) {
if (isRef(target[key]) && !isRef(value)) {
return (target[key].value = value);
} else {
return Reflect.set(target, key, value);
}
}
当原数据值是ref
类型且新赋的值不是ref
类型,也就是单测中第 1 个情况赋值为 10,将ref
类型的原值赋值为value
,ref
类型值需要.value
访问;否则,也就是单测中第 2 个情况,赋值为ref(30)
,就不需要额外处理,直接赋值即可。
验证
执行单测yarn test ref
来源:juejin.cn/post/7303435124527333416
将html转化成图片
如何将指定html内容转化成图片保存?这个问题很值得深思,实际应用中也很有价值。最直接的想法就是使用
canvas
,熟悉canvas
的同学可以尝试一下。这里不做太多的说明,本文采用html2canvas
库来实现。
html2canvas
库的使用非常简单,只需要引入html2canvas
库,然后调用html2canvas
方法即可,官方地址。
接下来说一下简单的使用,以react
项目为例。
获取整个页面截图,可以使用底层IDroot
,这样下载的就是root
下的所有元素。
import html2canvas from "html2canvas";
const saveCanvas = () => {
// 画布基础元素,要绘制的元素
const canvas: any = document.getElementById("root");
const options: any = { scale: 1, useCORS: true };
html2canvas(canvas, options).then((canvas) => {
const type = "png";
// 返回值是一个数据url,是base64组成的图片的源数据
let imgDt = canvas.toDataURL(type);
let fileName = "img" + "." + type;
// 保存为文件
let a = document.createElement("a");
document.body.appendChild(a);
a.href = imgDt;
a.download = fileName;
a.click();
});
};
图片的默认背景色是#ffffff
,如果想要透明色可设置为null
,比如设置为红色。
const options: any = { scale: 1, useCORS: true, backgroundColor: "red" };
正常情况下网络图片是无法渲染的,可以使用useCORS
属性,设置为true
即可。
const options: any = { scale: 1, useCORS: true };
保存某块元素的截图
const canvas: any = document.getElementById("swiper");
如果希望将某些元素排除,可以将data-html2canvas-ignore
属性添加到这些元素中,html2canvas
将从渲染中排除这些元素。
<Button
data-html2canvas-ignore
color="primary"
fill="solid"
onClick={saveCanvas}
>
download
</Button>
完整代码
npm install html2canvas
// demo.less
.contentSwiper {
width: 710px;
height: 375px;
color: #ffffff;
display: flex;
justify-content: center;
align-items: center;
font-size: 48px;
user-select: none;
}
.swiper {
padding: 0 20px;
}
import React from "react";
import { Button, Space, Swiper } from "antd-mobile";
import html2canvas from "html2canvas";
import styles from "./demo.less";
export default () => {
const saveCanvas = () => {
// 画布基础元素,要绘制的元素
const canvas: any = document.getElementById("root");
const options: any = { scale: 1, useCORS: true, backgroundColor: "red"
};
html2canvas(canvas, options).then((canvas) => {
const type = "png";
// 返回值是一个数据url,是base64组成的图片的源数据
let imgDt = canvas.toDataURL(type);
let fileName = "img" + "." + type;
// 保存为文件
let a = document.createElement("a");
document.body.appendChild(a);
a.href = imgDt;
a.download = fileName;
a.click();
});
};
const colors: string[] = ["#ace0ff", "#bcffbd", "#e4fabd", "#ffcfac"];
const items = colors.map((color, index) => (
<Swiper.Item key={index}>
<div className={styles.contentSwiper} style={{ background: color }}>
{index + 1}
</div>
</Swiper.Item>
));
return (
<div className="content">
<div id="swiper" className={styles.swiper}>
<Swiper
style={{
"--track-padding": " 0 0 16px",
}}
defaultIndex={1}
>
{items}
</Swiper>
</div>
<div>
<img
width={200}
src="https://t7.baidu.com/it/u=2621658848,3952322712&fm=193&f=GIF"
/>
</div>
<Space>
<Button
data-html2canvas-ignore
color="primary"
fill="solid"
onClick={saveCanvas}
>
download
</Button>
<Button color="primary" fill="solid">
Solid
</Button>
<Button color="primary" fill="outline">
Outline
</Button>
<Button color="primary" fill="none">
</Button>
</Space>
</div>
);
};
来源:juejin.cn/post/7407457177483608118
Vue3中watch好用,但watchEffect、watchSyncEffect、watchPostEffect简洁
比较好奇vue项目中使用watch还是watchEffect居多,查看了element-plus、ant-design-vue两个UI库, 整体上看,watch使用居多,而watchEffect不怎么受待见,那这两者之间有什么关系?
API | watch | watchEffect | watchSyncEffect | watchPostEffect |
---|---|---|---|---|
element-plus | 198 | 28 | 0 | 0 |
ant-design-vue | 263 | 168 | 0 | 0 |
watchEffect是watch的衍生
为什么说watchEffect是watch的衍生?
- 首先,两者提供功能是有重叠。大部分监听场景,两者都能满足。
const list = ref([]);
const count = ref(0);
watch(
list,
(newValue) => {
count.value = newValue.length;
}
)
watchEffect(() => {
count.value = list.value.length;
})
- 其次,源码上两者也都是同一出处。以下是两者的函数定义:
export function watch(
source: T | WatchSource,
cb: any,
options?: WatchOptions,
): WatchStopHandle {
return doWatch(source as any, cb, options)
}
export function watchEffect(
effect: WatchEffect,
options?: WatchOptionsBase,
): WatchStopHandle {
return doWatch(effect, null, options)
}
两者内部都调用doWatch函数,并且返回都是WatchStopHandle类型。唯独入参上有比较大的区别,watch的source参数就像大杂烩,支持PlainObject、Ref、ComputedRef以及函数类型;而watchEffect的effect参数仅仅是一个函数类型。
watch早于watchEffect诞生,watch源代码有这样一句提示:
if (__DEV__ && !isFunction(cb)) {
warn(
`\`watch(fn, options?)\` signature has been moved to a separate API. ` +
`Use \`watchEffect(fn, options?)\` instead. \`watch\` now only ` +
`supports \`watch(source, cb, options?) signature.`,
)
}
也就是说历史的某一个版本,watch也是支持watch(fn, options?)
用法,但为了降低API复杂度,将这部分功能迁移至watchEffect函数。一个优秀框架的发展历程也不过如此,都是在不断的重构升级。
话又说回来,到目前,为什么大部分Vue开发者更偏向于使用watch,而不是watchEffect?
,带着这个问题,庖丁解牛式层层分析。
watch、watchEffect底层逻辑
当我们把watch、watchEffect底层逻辑看透,剩下的watchSyncEffect、watchPostEffect也就自然了解。
先回顾下watch、watchEffect内部调用doWatch的参数:
// watch
doWatch(source as any, cb, options)
// demo
watch(
list,
(newValue) => {
count.value = newValue.length;
}
)
// watchEffect
doWatch(effect, null, options)
// demo
watchEffect(() => {
count.value = list.value.length;
})
入参的区别,如下表所示:
API | arg1 | arg2 | arg3 |
---|---|---|---|
watch | T | WatchSource | cb | WatchOptions |
watchEffect | WatchEffect | null | WatchOptionsBase |
根据参数对比,先抛出两个问题:
1. doWatch为什么能自动监听WatchEffect函数内的数据变更,并且能重新执行?
2. 第三个参数WatchOptions
watchOptions
export interface WatchOptions<Immediate = boolean> extends WatchOptionsBase {
immediate?: Immediate
deep?: boolean
once?: boolean
}
export interface WatchOptionsBase extends DebuggerOptions {
flush?: 'pre' | 'post' | 'sync'
}
WatchOptionsBase仅提供了flush,因此watchEffect函数的第三个参数也只有flush一个选项。
flush包含pre
、post
、sync
三个值,缺省为pre。它明确了监听器的触发时机,pre和post比较明确,对应渲染前、渲染后。
sync官方定义为:在某些特殊情况下 (例如要使缓存失效),可能有必要在响应式依赖发生改变时立即触发侦听器
。简而言之,依赖的多个变量,只要其中一个有更新,监听器就会触发一次。
const list = ref([]);
const page = ref(1);
const message = ref('');
watchEffect(() => {
message.value = `总量${list.value.length}, 当前页:${page.value}`
console.log(message.value);
}, { flush: 'sync' })
例如上述的list、page任意一个有更新,则会输出一次console。sync模式得慎重使用,例如监听的是数组,其中一项有更新都会触发监听器,可能带来不可预知的性能问题。
post
也有明确的应用场景,例如:当页面侧边栏显示或隐藏后,需要容器渲染完成后再更新内部的图表等元素。不使用flush选项的解法,一般是监听visible变化并使用setTimeout延迟更新。有了post
,一个属性即可搞定。
watch(visible, (value) => {
setTimeout(() => {
// 更新容器内图表
}, 1000);
})
watch(visible, (value) => {
// 更新容器内图表
}, { flush: 'post' })
完成了第二个问题的解答, 要回答第一个问题,需要深入doWatch函数, 在上一篇《写Vue大篇幅的ref、computed,而reactive为何少见?》也有对doWatch做局部介绍,可以作为辅助参考。
doWatch源码
先从doWatch函数签名上,对其有概括性的认识:
function doWatch(
source: WatchSource | WatchSource[] | WatchEffect | object,
cb: WatchCallback | null,
{
immediate,
deep,
flush,
once,
onTrack,
onTrigger,
}: WatchOptions = EMPTY_OBJ,
): WatchStopHandle
由于我们主要目的是回答问题:doWatch为什么能自动监听WatchEffect函数内的数据变更,并且能重新执行?
因此仅分析source为WatchEffect
的情况,此时,cb为null, 第三个参数仅有flush选项。
WatchEffect
类型定义如下:
export type WatchEffect = (onCleanup: OnCleanup) => void
onCleanup
参数的作用是,在下一次监听器执行前被触发,通常用于状态清理。
doWatch函数实现,最核心的片段是ReactiveEffect的生成:
const effect = new ReactiveEffect(getter, NOOP, scheduler)
为什么ReactiveEffect是其核心?因为它起到了"中介"的作用,在监听器函数内,每一个可监听
的变量都对应有依赖项集合deps,当调用这些变量的getter时,ReactiveEffect会把自身注入到依赖集合deps中,这样每当执行变量的setter时,deps集合中的副作用都会触发,而每个副作用effect内部会调用scheduler, scheduler可理解为调度器,负责处理视图更新时机,scheduler内部选择合适的时机触发监听器。
接下来着重看getter、scheduler定义,当source为WatchEffect
类型时,getter定义片段如下:
// no cb -> simple effect
getter = () => {
if (cleanup) {
cleanup()
}
return callWithAsyncErrorHandling(
source,
instance,
ErrorCodes.WATCH_CALLBACK,
[onCleanup],
)
}
首先执行cleanup,也就是说如果参数传入有onCleanup回调,那么每次在获取新值前都会触发onCleanup。其次是return语句,调用callWithAsyncErrorHandling函数,从函数可探察之,一方面支持异步,另一方面处理异常错误。
支持异步:也就是我们传入的监听器可以是一个异步函数,那么我们可以在其中执行远程请求的调用,例如官方给的示例, 当id.value值变化,从远端请求数据await response
,并赋值给data.value。
watchEffect(async (onCleanup) => {
const { response, cancel } = doAsyncWork(id.value)
// `cancel` 会在 `id` 更改时调用
// 以便取消之前未完成的请求
onCleanup(cancel)
data.value = await response
})
上述示例中,如果id.value频繁更新,则会导致触发多次远端请求,要解决该问题,可调用onCleanup(cancel)
,将cancel传入到doWatch内部,并且每次执行cleanup
时被调用。onCleanup定义如下:
let cleanup: (() => void) | undefined
let onCleanup: OnCleanup = (fn: () => void) => {
cleanup = effect.onStop = () => {
callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP)
cleanup = effect.onStop = undefined
}
}
其中,fn即为上述示例中的cancel,这样就建立了cancel和cleanup的关联,因此每次更新前,先调用cancel中断上一次请求。
callWithAsyncErrorHandling
函数定义如下:
export function callWithAsyncErrorHandling(fn,instance,type,args?): any {
...
const res = callWithErrorHandling(fn, instance, type, args)
if (res && isPromise(res)) {
res.catch(err => {
handleError(err, instance, type)
})
}
return res
...
}
res为fn函数执行结果,由于支持同步、异步。如果fn为异步函数,那么res为Promise类型,并且对异常做了兜底处理。
当fn函数执行后,内部所有可监听变量的deps都会添加上当前effect,所以只要变量有更新,effect的scheduler就被触发。
watchEffect官方定义有:立即运行一个函数,同时响应式地追踪其依赖,并在依赖更改时重新执行。“立即运行一个函数”如何体现?
doWatch
函数的最后几行代码如下:
if (flush === 'post') {
queuePostRenderEffect(
effect.run.bind(effect),
instance && instance.suspense,
)
} else {
effect.run()
}
如果flush不为post
,那么立即执行effect.run()
, 而run函数会调用getter,因此会立即运行监听器函数一次;如果flush为post
,那么effect将会在vue下一次渲染前第一次执行effect.run()
。
至此,我们就分析完watchEffect
的底层逻辑,总结其特点:立即执行,支持异步,并且会自动监听变量更新。
为什么不能两者取一,而必须共存
再次回顾watch的定义:
export function watch(
source: T | WatchSource,
cb: any,
options?: WatchOptions,
) : WatchStopHandle {
return doWatch(source as any, cb, options)
}
其中WatchOptions包含的选项有:immediate、deep、once、flush。如果是watchEffect,选项仅有flush,并且immediate相当于true,剩下的deep、once不支持配置。
先说watchEffect的缺点:
- 不支持immediate为false,必须是立即执行。例如下面的代码,由于autoplay默认false,初始化时不需要立即执行。如果是watchEffect,则pauseTimer初始化会执行一次,完全没必要。
watch(
() => props.autoplay,
(autoplay) => {
autoplay ? startTimer() : pauseTimer()
}
)
- 不支持deep为true的场景,只能见监听当前使用的属性。但如果是调用
watch(source, cb, { deep: true })
, 则会通过traverse(source)
将source所有深度属性读取一次,和effect建立关联,达到自动监听所有属性的目的。 - 异步使用有坑,
watchEffect
仅会在其同步执行期间,才追踪依赖。在使用异步回调时,只有在第一个await
正常工作前访问到的属性才会被追踪。
再说watchEffect优点:
优点也是非常明显,写法非常简洁,无需显式声明监听哪些变量,一个回调函数搞定,并且默认为立即执行,我认为能满足开发中80%的应用场景。另一方面,由于只监听回调中使用的属性,相比于deep为true的一锅端方式,watchEffect则更加直观明了。
总结
watchSyncEffect、watchPostEffect和watchEffect唯一的区别是:flush分别固定为sync
、post
。所以,watchEffect为watch的衍生,而watchSyncEffect、watchPostEffect为watchEffect的衍生。
对于开发使用上:
- watchPostEffect、watchSyncEffect仅在极少数的特殊场景下才使用,完全可以用watchEffect(fn, { flush: 'sync' | 'post' })代替,多了反而对入门开发者来说是徒增干扰。
- 个人认为应优先使用watchEffect函数,毕竟代码写法上更加简洁,属性依赖上也更加明确。满足不了的场景,再考虑使用watch。
来源:juejin.cn/post/7401415643981185078
vue3为啥推荐使用ref而不是reactive
在 Vue 3 中,ref
和 reactive
都是用于声明响应式状态的工具,但它们的使用场景和内部工作机制有所不同。Vue 3 推荐使用 ref
而不是 reactive
的原因主要涉及到以下几个方面:
- 简单的原始值响应式处理:
ref
更适合处理简单的原始值(如字符串、数字、布尔值等),而reactive
更适合处理复杂的对象或数组。
- 一致性和解构:
- 使用
ref
时,解构不会丢失响应性,因为ref
会返回一个包含.value
属性的对象。而reactive
对象在解构时会丢失响应性。
- 使用
- 类型推导和代码提示:
ref
更容易与 TypeScript 配合使用,提供更好的类型推导和代码提示。
示例代码
以下是一个详细的代码示例,演示为什么在某些情况下推荐使用 ref
而不是 reactive
。
使用 ref
的示例
import { ref } from 'vue';
export default {
setup() {
// 使用 ref 声明响应式状态
const count = ref(0);
function increment() {
count.value++;
}
return {
count,
increment
};
}
};
使用 reactive
的示例
import { reactive } from 'vue';
export default {
setup() {
// 使用 reactive 声明响应式状态
const state = reactive({
count: 0
});
function increment() {
state.count++;
}
return {
state,
increment
};
}
};
解构问题
使用 ref
解构
import { ref } from 'vue';
export default {
setup() {
const count = ref(0);
function increment() {
count.value++;
}
// 解构时不会丢失响应性
const { value: countValue } = count;
return {
countValue,
increment
};
}
};
使用 reactive
解构
import { reactive } from 'vue';
export default {
setup() {
const state = reactive({
count: 0
});
function increment() {
state.count++;
}
// 解构时会丢失响应性
const { count } = state;
return {
count,
increment
};
}
};
代码解释
- 使用
ref
:
ref
返回一个包含.value
属性的对象,因此在模板中使用时需要通过.value
访问实际值。- 解构时,可以直接解构
.value
属性,不会丢失响应性。
- 使用
reactive
:
reactive
适用于复杂的对象或数组,返回一个代理对象。- 直接解构
reactive
对象的属性会丢失响应性,因为解构后得到的属性是原始值,不再是响应式的。
总结
- 简单值:对于简单的原始值(如字符串、数字、布尔值等),推荐使用
ref
,因为它更简洁,并且在解构时不会丢失响应性。 - 复杂对象:对于复杂的对象或数组,推荐使用
reactive
,因为它可以更方便地处理嵌套属性的响应性。 - 一致性:
ref
在解构时不会丢失响应性,而reactive
在解构时会丢失响应性,这使得ref
在某些情况下更为可靠。
通过理解 ref
和 reactive
的不同使用场景和内部工作机制,可以更好地选择适合的工具来管理 Vue 3 应用中的响应式状态。
来源:juejin.cn/post/7402869746175393807
Node拒绝当咸鱼,Node 22大进步
这几年,deno和bun风头正盛,大有你方唱罢我登场的态势,deno和bun的每一次更新版本,Node都会被拿来比较,比较结果总是Node落后了。
这种比较是不是非常熟悉,就像卖手机的跟iPhone比,卖汽车的跟特斯拉比,比较的时候有时候还得来个「比一分钱硬币还薄」的套路。
Node虽然没有落后了,但是确实有点压力了,所以20和22版本都大跨步前进,拒绝当咸鱼了。
因为Node官网对22版本特性的介绍太过简单,所以我决定来一篇详细介绍新特性的文章,让学习Node的朋友们知道,Node现在在第几层。
首先我把新特性分为两类,分别是:开发者可能直接用到的特性、开发者相对无感知的底层更新。本文重点介绍前者,简单介绍后者。先来一个概览:
开发者可能直接用到的特性:
- 支持通过
require()
引入ESM - 运行
package.json
中的脚本 - 监视模式(
--watch
)稳定化 - 内置 WebSocket 客户端
- 增加流的默认高水位线
- 文件模式匹配功能
开发者相对无感知的底层更新:
- V8 引擎升级至 12.4 版本
- Maglev 编译器默认启用
- 改进
AbortSignal
的创建性能
接下来开始介绍。
支持通过 require()
导入 ESM
以前,我们认为 CommonJS 与 ESM 是分离的。
例如,在 CommonJS里,我们用并使用 module.exports
导出模块,用 require()
导入模块:
// CommonJS
// math.js
function add(a, b) {
return a + b;
}
module.exports.add = add;
// useMath.js
const math = require('./math');
console.log(math.add(2, 3));
在 ECMAScript Modules (ESM) **** 里,我们使用 export
导出模块,用 import
导入模块:
// ESM
// math.mjs
export function add(a, b) {
return a + b;
}
// useMath.js
import { add } from './math.mjs';
console.log(add(2, 3));
Node 22 支持新的方式——用 require()
导入 ESM:
// Node 22
// math.mjs
export function add(a, b) {
return a + b;
}
// useMath.js
const { add } = require('./mathModule.mjs');
console.log(add(2, 3));
这么设计的原因是为了给大型项目和遗留系统提供一个平滑过渡的方案,因为这类项目难以快速全部迁移到 ESM,通过允许 require()
导入 ESM,开发者就可以逐个模块迁移,而不是一次性对整个项目进行修改。
目前这种写法还是实验性功能,所以使用是有“门槛”的:
- 启动命令需要添加
-experimental-require-module
参数,如:node --experimental-require-module app.js
- 模块标记:确保 ESM 模块通过
package.json
中的"type": "module"
或文件扩展名是.mjs
。 - 完全同步:只有完全同步的ESM才能被
require()
导入,任何含有顶级await
的ESM都不能使用这种方式加载。
运行package.json
中的脚本
假设我们的 package.json
里有一个脚本:
"scripts": {
"test": "jest"
}
在此之前,我们必须依赖 npm 或者 yanr 这样的包管理器来执行命令,比如:npm run test
。
Node 22 添加了一个新命令行标志 --run
,允许直接从命令行执行 package.json
中定义的脚本,可以直接使用 node --run test
这样的命令来运行脚本。
刚开始我还疑惑这是不是脱裤子放屁的行为,因为有 node 的地方一般都有 npm,我要这 node —run
有何用?
后来思考了一下,主要原因应该还是统一运行环境和提升性能。不同的包管理器在处理脚本时可能会有微小的差异,Node 提供一个标准化的方式执行脚本,有助于统一这些行为;而且直接使用 node 执行脚本要比通过 npm 执行脚本更快,因为绕过了 npm 这个中间层。
监视模式(--watch
)稳定化
在 19 版本里,Node 引入了 —watch
指令,用于监视文件系统的变动,并自动重启。22 版本开始,这个指令成为稳定功能了。
要启用监视模式,只需要在启动 Node 应用时加上 --watch
****参数。例如:
node --watch app.js
正在用 nodemon 做自动重启的朋友们可以正式转战 --watch
了~
内置 WebSocket 客户端
以前,要用 Node 开发一个 socket 服务,必须使用 ws、socket.io 这样的第三方库来实现。第三方库虽然稳如老狗帮助开发者许多年,但是终究是有点不方便。
Node 22 正式内置了 WebSocket,并且属于稳定功能,不再需要 -experimental-websocket
来启用了。
除此之外,WebScoket 的实现还遵循了浏览器中 WebSocket API 的标准,这意味着在 Node 中使用 WebSocket 的方式将与在 JavaScript 中使用 WebSocket 的方式非常相似,有助于减少学习成本并提高代码的一致性。
用法示例:
const socket = new WebSocket("ws://localhost:8080");
socket.addEventListener("open", (event) => {
socket.send("Hello Server!");
});
增加流(streams)的默认高水位线(High Water Mark)
streams 在 Node 中有举足轻重的作用,读写数据都得要 streams 来完成。而 streams 可以设置 highWaterMark
参数,用于表示缓冲区的大小。highWaterMark
越大,缓冲区越大,占用内存越多,I/O 操作就减少,highWaterMark
越小,其他信息也对应相反。
用法如下:
const fs = require('fs');
const readStream = fs.createReadStream('example-large-file.txt', {
highWaterMark: 1024 * 1024 // 设置高水位线为1MB
});
readStream.on('data', (chunk) => {
console.log(`Received chunk of size: ${chunk.length}`);
});
readStream.on('end', () => {
console.log('End of file has been reached.');
});
虽然 highWaterMark
是可配置的,但通常情况下,我们是使用默认值。在以前的版本里,highWaterMark
的默认值是 16k,Node 22 版本开始,默认值被提升到 64k 了。
文件模式匹配——glob 和 globSync
Node 22 版本在 fs 模块中新增了 glob
和 globSync
函数,它们用于根据指定模式匹配文件路径。
文件模式匹配允许开发者定义一个匹配模式,以找出符合特定规则的文件路径集合。模式定义通常包括通配符,如 *
(匹配任何字符)和 ?
(匹配单个字符),以及其他特定的模式字符。
glob 函数(异步)
glob
函数是一个异步的函数,它不会阻塞 Node.js 的事件循环。这意味着它在搜索文件时不会停止其他代码的执行。glob
函数的基本用法如下:
const { glob } = require('fs');
glob('**/*.js', (err, files) => {
if (err) {
throw err;
}
console.log(files); // 输出所有匹配的.js文件路径
});
在这个示例中,glob
函数用来查找所有子目录中以 .js
结尾的文件。它接受两个参数:
- 第一个参数是一个字符串,表示文件匹配模式。
- 第二个参数是一个回调函数,当文件搜索完成后,这个函数会被调用。如果搜索成功,
err
将为null
,而files
将包含一个包含所有匹配文件路径的数组。
globSync 函数(同步)
globSync
是 glob
的同步版本,它会阻塞事件循环,直到所有匹配的文件都被找到。这使得代码更简单,但在处理大量文件或在需要高响应性的应用中可能会导致性能问题。其基本用法如下:
const { globSync } = require('fs');
const files = globSync('**/*.js');
console.log(files); // 同样输出所有匹配的.js文件路径
这个函数直接返回匹配的文件数组,适用于脚本和简单的应用,其中执行速度不是主要关注点。
使用场景
这两个函数适用于:
- 自动化构建过程,如自动寻找和处理项目中的 JavaScript 文件。
- 开发工具和脚本,需要对项目目录中的文件进行批量操作。
- 任何需要从大量文件中快速筛选出符合特定模式的文件集的应用。
V8 引擎升级至 12.4 版本
从这一节开始,我们了解一下开发者相对无感知的底层更新,第一个就是 V8 引擎升级到 12.4 版本了,有了以下特性升级:
- WebAssembly 垃圾回收:这一特性将改善 WebAssembly 在内存管理方面的能力。
- Array.fromAsync:这个新方法允许从异步迭代器创建数组。
- Set 方法和迭代器帮助程序:提供了更多内建的Set操作和迭代器操作的方法,增强了数据结构的操作性和灵活性。
Maglev 编译器默认启用
Maglev 是 V8 的新编译器,现在在支持的架构上默认启用。它主要针对短生命周期的命令行程序(CLI程序)性能进行优化,通过改进JIT(即时编译)的效率来提升性能。这对开发者编写的工具和脚本将带来明显的速度提升。
改进AbortSignal
的创建性能
在这次更新中,Node 提高了 AbortSignal
实例的创建效率。AbortSignal
是用于中断正在进行的操作(如网络请求或任何长时间运行的异步任务)的一种机制。通过提升这一过程的效率,可以加快任何依赖这一功能的应用,如使用 fetch
进行HTTP请求或在测试运行器中处理中断的场景。
AbortSignal
的工作方式是通过 AbortController
实例来管理。AbortController
提供一个 signal
属性和一个 abort()
方法。signal
属性返回一个 AbortSignal
对象,可以传递给任何接受 AbortSignal
的API(如fetch
)来监听取消事件。当调用abort()
方法时,与该控制器关联的所有操作将被取消。
const controller = new AbortController();
const signal = controller.signal;
fetch(url, { signal })
.then(response => response.json())
.catch(err => {
if (err.name === 'AbortError') {
console.log('Fetch aborted');
} else {
console.error('Fetch error:', err);
}
});
// 取消请求
controller.abort();
总结
最后,我只替 Node 说一句:Node 没有这么容易被 deno 和 bun 打败~
来源:juejin.cn/post/7366185272768036883
用了这么久Vue,你用过这几个内置指令提升性能吗?
前言
Vue
的内置指令估计大家都用过不少,例如v-for
、v-if
之类的就是最常用的内置指令,但今天给大家介绍几个平时用的比较少的内置指令。毕竟这几个Vue
内置指令可用可不用,不用的时候系统正常跑,但在对的地方用了却能提升系统性能,下面将结合示例进行详细说明。
一、v-once
作用:在标签上使用v-once
能使元素或者表达式只渲染一次。首次渲染之后,后面数据再发生变化时使用了v-once
的地方都不会更新,因此用在数据不需要变化的地方就能进行性能优化。
v-once
指令实现原理: Vue
组件初始化时会标记上v-once
,首次渲染会正常执行,后续再次渲染时如果看到有v-once
标记则跳过二次渲染。
示例代码: 直接作用在标签上,可以是普通标签也可以是图片标签,当2S
后数据变化时标签上的值不会重新渲染更新。
<template>
<div>
<span v-once>{{ message }}</span>
<img v-once :src="imageUrl"></img>
</div>
</template>
<script setup>
import { ref } from 'vue';
let message = ref('Vue指令!');
let imageSrc = ref('/path/my/image.jpg');
setTimeout(() => {
message.value = '修改内容!';
imageUrl.value = '/new/path/my/images.jpg';
}, 2000);
</script>
注意: 作用v-once
会使属性失去响应式,要确保这个地方不需要响应式更新才能使用,否则会导致数据和页面视图对不上。
二、v-pre
作用: 在标签上使用v-pre
后,Vue
编译器会自动跳过这个元素的编译。使用此内置指令后会被视为静态内容。
v-pre
指令实现原理: Vue
初次编译时如果看到有v-pre
标记,那么跳过这部分的编译,直接当成原始的HTML
插入到DOM
中。
示例代码: 常规文本会正常编译成您好!
,但使用了v-pre
后会跳过编译原样输出{{ message }}
。
<template>
<div>
<h2>常规: {{ message }}</h2>
<h2 v-pre>使用v-pre后: {{ message }}</h2>
</div>
</template>
<script setup>
import { ref } from 'vue';
let message = ref('您好!');
</script>
注意: 要区分v-pre
和v-once
的区别,v-once
用于只渲染一次,而v-pre
是直接跳过编译。
这个指令可能很多人没想到应用场景有那些,其实最常见的用途就是要在页面上显示
Vue
代码,如果不用v-pre
就会被编译。如下所示使用v-pre
场景效果。
<template>
<div>
<pre v-pre>
<template>
<p>{{ message }}</p>
</template>
<script setup>
import { ref } from 'vue';
const message = ref('Hello Vue!');
</script>
</pre>
</div>
</template>
<script setup>
import { ref } from 'vue';
let message = ref('您好!');
</script>
页面上展示: 代码原始显示不会被编译。
三、v-memo(支持3.2+版本)
作用: 主要用于优化组件的渲染方面性能,能控制达到某个条件才重新当堂组件,否则不重新渲染。v-memo
会缓存 DOM
,只有当指定的数据发生变化时才会重新渲染,从而减少渲染次数提升性能。
v-memo
指令实现原理: Vue
初始化组件时会识别是否有v-memo
标记,如果有就把这部分vnode
缓存起来,当数据变化时会对比依赖是否变化,变化再重新渲染。
示例代码: 用v-memo
绑定了arr
,那么当arr
的值变化才会重新渲染,否则不会重新渲染。
<template>
<div>
<ul v-memo="arr">
<li v-for="(item, index) in arr" :key="index">
{{ item.text }}
</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue';
let arr = ref([
{ text: '内容1' },
{ text: '内容2' },
{ text: '内容3' }
]);
setInterval(() => {
arr.value[1].text = '修改2';
}, 2000);
</script>
注意: 用v-memo
来指定触发渲染的条件,但只建议在长列表或者说复杂的渲染结构才使用。
小结
总结了几个比较冷门的Vue
内置指令,平时用的不多,但用对了地方却能明显提升性能。如果那里写的不对或者有好建议欢迎大佬指出啊。
来源:juejin.cn/post/7407340295115767808
【在线聊天室😻】前端进阶全栈开发🔥
项目效果
登录注册身份认证、私聊、聊天室
项目前端React18仓库:github.com/mcmcCat/mmc…
项目后端Nestjs仓库:github.com/mcmcCat/mmc…
语雀上的笔记:http://www.yuque.com/maimaicat/t…
技术栈:
Nestjs企业级Node服务端框架+TypeOrm(Mysql)+JWT+Socket.IO🎉
React18/服务端渲染Nextjs+Redux-toolkit+styled-components🎉
登录注册身份认证、私聊、聊天室
项目前端React18仓库:github.com/mcmcCat/mmc…
项目后端Nestjs仓库:github.com/mcmcCat/mmc…
语雀上的笔记:http://www.yuque.com/maimaicat/t…
技术栈:
Nestjs企业级Node服务端框架+TypeOrm(Mysql)+JWT+Socket.IO🎉
React18/服务端渲染Nextjs+Redux-toolkit+styled-components🎉
前言
Nestjs 是一个用于构建高效可扩展的一个基于Node js 服务端 应用程序开发框架。本文不过多赘述,网上的教程有很多。
(注意:对于聊天中user模块和message模块的接口可参考仓库代码,在这里只分析登录注册的身份认证)
下面可以放张图稍微感受一下,用nest写接口很方便。 @Post('auth/register')
使用装饰器的方式,当你请求这个接口时,会自动调用下方函数AuthRegister
。另外还可以加一大堆装饰器,用于生成swagger接口文档,做本地验证、jwt验证等等。
import {
Body,
ClassSerializerInterceptor,
Controller,
Get,
Post,
Req,
Res,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { AppService } from './app.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth/auth.service';
import { CreateUserDto } from './user/dto/create-user.dto';
import { LoginDTO } from './auth/dto/login.dto';
import { AuthGuard } from '@nestjs/passport';
// @Controller装饰器来定义控制器,如每一个要成为控制器的类,都需要借助@Controller装饰器的装饰
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@ApiTags('JWT注册')
@Post('auth/register')
async AuthRegister(@Body() body: CreateUserDto) {
return await this.appService.authRegister(body);
}
@UseInterceptors(ClassSerializerInterceptor) //返回的数据中去除实体中被@Exclude()的字段
@UseGuards(AuthGuard('local')) //使用本地策略验证用户名和密码的正确性
@ApiTags('JWT登录')
@Post('auth/login')
async AuthLogin(@Body() body: LoginDTO, @Req() req) {
// 通过了本地策略证明身份验证通过
return await this.appService.authLogin(req.user);
}
}
Nestjs 是一个用于构建高效可扩展的一个基于Node js 服务端 应用程序开发框架。本文不过多赘述,网上的教程有很多。
(注意:对于聊天中user模块和message模块的接口可参考仓库代码,在这里只分析登录注册的身份认证)
下面可以放张图稍微感受一下,用nest写接口很方便。 @Post('auth/register')
使用装饰器的方式,当你请求这个接口时,会自动调用下方函数AuthRegister
。另外还可以加一大堆装饰器,用于生成swagger接口文档,做本地验证、jwt验证等等。
import {
Body,
ClassSerializerInterceptor,
Controller,
Get,
Post,
Req,
Res,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { AppService } from './app.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth/auth.service';
import { CreateUserDto } from './user/dto/create-user.dto';
import { LoginDTO } from './auth/dto/login.dto';
import { AuthGuard } from '@nestjs/passport';
// @Controller装饰器来定义控制器,如每一个要成为控制器的类,都需要借助@Controller装饰器的装饰
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@ApiTags('JWT注册')
@Post('auth/register')
async AuthRegister(@Body() body: CreateUserDto) {
return await this.appService.authRegister(body);
}
@UseInterceptors(ClassSerializerInterceptor) //返回的数据中去除实体中被@Exclude()的字段
@UseGuards(AuthGuard('local')) //使用本地策略验证用户名和密码的正确性
@ApiTags('JWT登录')
@Post('auth/login')
async AuthLogin(@Body() body: LoginDTO, @Req() req) {
// 通过了本地策略证明身份验证通过
return await this.appService.authLogin(req.user);
}
}
Nestjs中如何进行身份认证?
密码加密 和 生成token
我们可以跟着代码仓库,带有详细的注释,一步步地走
app.service.ts
负责定义注册authRegister
和登录authLogin
- 在注册时,拿到用户输入的密码,使用
**bcryptjs.hash()**
将其转换为 hash加密字符串,并存入数据库
- 在(身份认证的)登录时,先进行校验用户登录信息是否正确在这里我们使用的是
[passport-local](http://nestjs.inode.club/recipes/passport#%E5%AE%9E%E7%8E%B0-passport-%E6%9C%AC%E5%9C%B0%E7%AD%96%E7%95%A5)
本地策略来验证,@UseGuards(AuthGuard('local'))
这个装饰器会在此处的post请求@Post('auth/login')
后进行拦截,去local.strategy.ts
中进行validate
检索出该用户的信息,然后我们使用**bcryptjs.compareSync()**
将 **用户输入的密码 **与数据库中用 **hash加密过的密码 **进行解析对比,若登录信息正确则接着调用AuthLogin
,进而调用(认证成功的)登录接口authService.login()
,即向客户端发送登录成功信息并且是携带有**token**
的,
async login(user: any) {
// 准备jwt需要的负载
const payload = { username: user.username, sub: user.id };
return {
code: '200',
// 配合存储着用户信息的负载 payload 来生成一个包含签名的JWT令牌(access_token)。。
access_token: this.jwtService.sign(payload),
msg: '登录成功',
};
}
我们可以跟着代码仓库,带有详细的注释,一步步地走app.service.ts
负责定义注册authRegister
和登录authLogin
- 在注册时,拿到用户输入的密码,使用
**bcryptjs.hash()**
将其转换为 hash加密字符串,并存入数据库 - 在(身份认证的)登录时,先进行校验用户登录信息是否正确在这里我们使用的是
[passport-local](http://nestjs.inode.club/recipes/passport#%E5%AE%9E%E7%8E%B0-passport-%E6%9C%AC%E5%9C%B0%E7%AD%96%E7%95%A5)
本地策略来验证,@UseGuards(AuthGuard('local'))
这个装饰器会在此处的post请求@Post('auth/login')
后进行拦截,去local.strategy.ts
中进行validate
检索出该用户的信息,然后我们使用**bcryptjs.compareSync()**
将 **用户输入的密码 **与数据库中用 **hash加密过的密码 **进行解析对比,若登录信息正确则接着调用AuthLogin
,进而调用(认证成功的)登录接口authService.login()
,即向客户端发送登录成功信息并且是携带有**token**
的,
async login(user: any) {
// 准备jwt需要的负载
const payload = { username: user.username, sub: user.id };
return {
code: '200',
// 配合存储着用户信息的负载 payload 来生成一个包含签名的JWT令牌(access_token)。。
access_token: this.jwtService.sign(payload),
msg: '登录成功',
};
}
校验token合法性
那么这个token
我们在哪里去拦截它进行校验呢?
那就要提到我们 nest 的guard
(守卫)这个概念。其实就好比我们在vue项目中,封装路由前置守卫拦截路由跳转,去获取存储在localStorage
的token
一样。
在 nest 守卫中我们可以去获取到请求体req
,从而获取到请求头中的Authorization
字段,查看是否携带token
,然后去校验token
合法性,authService.verifyToken()
中调用jwtService.verify()
进行token
的令牌格式校验、签名验证、过期时间校验,确保令牌的完整性、真实性和有效性
import {
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from 'src/app.module';
import { AuthService } from './auth.service';
import { UserService } from 'src/user/user.service';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor() {
super();
}
async canActivate(context: ExecutionContext): Promise<any> {
const req = context.switchToHttp().getRequest();
// 如果是请求路由是白名单中的,则直接放行
if (this.hasUrl(this.whiteList, req.url)) return true;
try {
const accessToken = req.get('Authorization');
if (!accessToken) throw new UnauthorizedException('请先登录');
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const authService = app.get(AuthService);
const userService = app.get(UserService);
const tokenUserInfo = await authService.verifyToken(accessToken);
const resData = await userService.findOne(tokenUserInfo.username);
if (resData[0].id) return true;
} catch (e) {
console.log('1h 的 token 过期啦!请重新登录');
return false;
}
}
// 白名单数组
private whiteList: string[] = ['/auth/register','/auth/login'];
// 验证该次请求是否为白名单内的路由
private hasUrl(whiteList: string[], url: string): boolean {
let flag = false;
if (whiteList.indexOf(url) !== -1) {
flag = true;
}
return flag;
}
}
在guard
中,当我们return true
时,好比路由前置守卫的next()
,就是认证通过了放行的意思
当然,别忘了注册守卫,我们这里可以采用全局守卫的形式注册,在main.ts
中app.useGlobalGuards(new JwtAuthGuard());
那么这个token
我们在哪里去拦截它进行校验呢?
那就要提到我们 nest 的guard
(守卫)这个概念。其实就好比我们在vue项目中,封装路由前置守卫拦截路由跳转,去获取存储在localStorage
的token
一样。
在 nest 守卫中我们可以去获取到请求体req
,从而获取到请求头中的Authorization
字段,查看是否携带token
,然后去校验token
合法性,authService.verifyToken()
中调用jwtService.verify()
进行token
的令牌格式校验、签名验证、过期时间校验,确保令牌的完整性、真实性和有效性
import {
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from 'src/app.module';
import { AuthService } from './auth.service';
import { UserService } from 'src/user/user.service';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor() {
super();
}
async canActivate(context: ExecutionContext): Promise<any> {
const req = context.switchToHttp().getRequest();
// 如果是请求路由是白名单中的,则直接放行
if (this.hasUrl(this.whiteList, req.url)) return true;
try {
const accessToken = req.get('Authorization');
if (!accessToken) throw new UnauthorizedException('请先登录');
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const authService = app.get(AuthService);
const userService = app.get(UserService);
const tokenUserInfo = await authService.verifyToken(accessToken);
const resData = await userService.findOne(tokenUserInfo.username);
if (resData[0].id) return true;
} catch (e) {
console.log('1h 的 token 过期啦!请重新登录');
return false;
}
}
// 白名单数组
private whiteList: string[] = ['/auth/register','/auth/login'];
// 验证该次请求是否为白名单内的路由
private hasUrl(whiteList: string[], url: string): boolean {
let flag = false;
if (whiteList.indexOf(url) !== -1) {
flag = true;
}
return flag;
}
}
在guard
中,当我们return true
时,好比路由前置守卫的next()
,就是认证通过了放行的意思
当然,别忘了注册守卫,我们这里可以采用全局守卫的形式注册,在main.ts
中app.useGlobalGuards(new JwtAuthGuard());
Socket.IO如何实现即时聊天?
Nest中WebSocket网关的作用
使用 @WebSocketGateway 装饰器配置 WebSocket 网关在 Nest.js 应用中具有以下作用:
- 提供 WebSocket 的入口点:WebSocket 网关允许客户端通过 WebSocket 协议与后端建立实时的双向通信。通过配置网关,你可以定义用于处理 WebSocket 连接、消息传递和事件的逻辑。
- 处理跨域请求:在 WebSocket 中,默认存在跨域限制,即只能与同源的 WebSocket 服务进行通信。通过设置 origin 选项,WebSocket 网关可以解决跨域请求问题,允许来自指定源的请求进行跨域访问。
关于Socket.IO是怎么通讯的可以看看官网给出的图
socketIO
是通过事件监听的形式,我们可以很清晰的区分出消息的类型,方便对不同类型的消息进行处理,客户端和服务端双方事先约定好不同的事件,事件由谁监听,由谁触发,就可以把各种消息进行有序管理了
下面是一个简单的通讯事件示例:
import {
WebSocketGateway,
SubscribeMessage,
WebSocketServer,
MessageBody,
ConnectedSocket,
} from '@nestjs/websockets';
import { SocketService } from './socket.service';
import { CreateSocketDto } from './dto/create-socket.dto';
import { UpdateSocketDto } from './dto/update-socket.dto';
import { Socket } from 'socket.io';
const roomList = {};
let roomId = null;
let user = '';
@WebSocketGateway(3001, {
allowEIO3: true, // 开启后要求前后端使用的 Socket.io 版本要保持一致
//后端解决跨域
cors: {
// 允许具体源的请求进行跨域访问
origin: 'http://localhost:8080', //这里不要写*,要写 true 或者具体的前端请求时所在的域,否则会出现跨域问题
// 允许在跨域请求中发送凭据
credentials: true,
},
})
export class SocketGateway {
constructor(private readonly socketService: SocketService) {}
@SubscribeMessage('ToClient')
ToClient(@MessageBody() data: any) {
// 转发信息
const forwardMsg: string = '服务端=>客户端';
return {
//通过return返回客户端转发事件
event: 'forward',
data: forwardMsg, //data后面跟携带数据
};
}
//接收并处理来自客户端的消息
@SubscribeMessage('toServer')
handleServerMessage(client: Socket, data: string) {
console.log(data + ' (让我服务端来进行一下处理)');
client.emit('ToClient', data + '(处理完成给客户端)');
}
}
使用 @WebSocketGateway 装饰器配置 WebSocket 网关在 Nest.js 应用中具有以下作用:
- 提供 WebSocket 的入口点:WebSocket 网关允许客户端通过 WebSocket 协议与后端建立实时的双向通信。通过配置网关,你可以定义用于处理 WebSocket 连接、消息传递和事件的逻辑。
- 处理跨域请求:在 WebSocket 中,默认存在跨域限制,即只能与同源的 WebSocket 服务进行通信。通过设置 origin 选项,WebSocket 网关可以解决跨域请求问题,允许来自指定源的请求进行跨域访问。
关于Socket.IO是怎么通讯的可以看看官网给出的图socketIO
是通过事件监听的形式,我们可以很清晰的区分出消息的类型,方便对不同类型的消息进行处理,客户端和服务端双方事先约定好不同的事件,事件由谁监听,由谁触发,就可以把各种消息进行有序管理了
下面是一个简单的通讯事件示例:
import {
WebSocketGateway,
SubscribeMessage,
WebSocketServer,
MessageBody,
ConnectedSocket,
} from '@nestjs/websockets';
import { SocketService } from './socket.service';
import { CreateSocketDto } from './dto/create-socket.dto';
import { UpdateSocketDto } from './dto/update-socket.dto';
import { Socket } from 'socket.io';
const roomList = {};
let roomId = null;
let user = '';
@WebSocketGateway(3001, {
allowEIO3: true, // 开启后要求前后端使用的 Socket.io 版本要保持一致
//后端解决跨域
cors: {
// 允许具体源的请求进行跨域访问
origin: 'http://localhost:8080', //这里不要写*,要写 true 或者具体的前端请求时所在的域,否则会出现跨域问题
// 允许在跨域请求中发送凭据
credentials: true,
},
})
export class SocketGateway {
constructor(private readonly socketService: SocketService) {}
@SubscribeMessage('ToClient')
ToClient(@MessageBody() data: any) {
// 转发信息
const forwardMsg: string = '服务端=>客户端';
return {
//通过return返回客户端转发事件
event: 'forward',
data: forwardMsg, //data后面跟携带数据
};
}
//接收并处理来自客户端的消息
@SubscribeMessage('toServer')
handleServerMessage(client: Socket, data: string) {
console.log(data + ' (让我服务端来进行一下处理)');
client.emit('ToClient', data + '(处理完成给客户端)');
}
}
私聊模块中的 socket 事件
通过使用client.broadcast.emit('showMessage')
和 client.emit('showMessage')
,你可以实现多人实时聊天的功能。
当一个客户端发送一条消息时,通过 client.broadcast.emit('showMessage')
将该消息广播给其他客户端,让其他客户端可以接收到这条消息并进行相应的处理,从而实现多人实时聊天的效果。
同时,使用 client.emit('showMessage')
可以将消息发送给当前连接的客户端,这样当前客户端也会收到自己发送的消息,以便在界面上显示自己发送的内容。
@SubscribeMessage('sendMessage')
sendMessage(client: Socket) {
// 将该消息广播给其他客户端
client.broadcast.emit('showMessage');
// 将消息发送给当前连接的客户端
client.emit('showMessage');
return;
}
前端中会在UserList.tsx
监听该事件showMessage,并触发更新信息逻辑
useEffect(() => {
socket.on('showMessage', getCurentMessages)
return () => {
socket.off('showMessage')
}
})
通过使用client.broadcast.emit('showMessage')
和 client.emit('showMessage')
,你可以实现多人实时聊天的功能。
当一个客户端发送一条消息时,通过 client.broadcast.emit('showMessage')
将该消息广播给其他客户端,让其他客户端可以接收到这条消息并进行相应的处理,从而实现多人实时聊天的效果。
同时,使用 client.emit('showMessage')
可以将消息发送给当前连接的客户端,这样当前客户端也会收到自己发送的消息,以便在界面上显示自己发送的内容。
@SubscribeMessage('sendMessage')
sendMessage(client: Socket) {
// 将该消息广播给其他客户端
client.broadcast.emit('showMessage');
// 将消息发送给当前连接的客户端
client.emit('showMessage');
return;
}
前端中会在UserList.tsx
监听该事件showMessage,并触发更新信息逻辑
useEffect(() => {
socket.on('showMessage', getCurentMessages)
return () => {
socket.off('showMessage')
}
})
房间模块中的 socket 事件
@SubscribeMessage('sendRoomMessage')
sendRoomMessage(client: Socket, data) {
console.log('服务端接收到了');
// // 将消息发送给指定房间内的所有客户端
this.socketIO.to(roomId).emit('sendRoomMessage', data);
return;
}
在需要发送消息给指定房间时,即我们需要在全局中找到指定房间,所以我们需要整个 WebSocket 服务器的实例
@WebSocketServer()
socketIO: Socket; //它表示整个 WebSocket 服务器的实例。它可以用于执行全局操作,如向所有连接的客户端广播消息或将客户端连接到特定的房间。
@SubscribeMessage('sendRoomMessage')
sendRoomMessage(client: Socket, data) {
console.log('服务端接收到了');
// // 将消息发送给指定房间内的所有客户端
this.socketIO.to(roomId).emit('sendRoomMessage', data);
return;
}
在需要发送消息给指定房间时,即我们需要在全局中找到指定房间,所以我们需要整个 WebSocket 服务器的实例
@WebSocketServer()
socketIO: Socket; //它表示整个 WebSocket 服务器的实例。它可以用于执行全局操作,如向所有连接的客户端广播消息或将客户端连接到特定的房间。
加入和退出房间的 socket API
踩坑
- socket实例的创建写在了函数组件内,useState中的变量的频繁改变,导致的组件不断重新渲染,socket实例也被不断创建,形成过多的连接,让websocket服务崩溃!!!
解决:
把socket实例的创建拿出来放在单独的文件中,这样在各个函数组件中若使用的话只用引用这一共同的socket实例,仅与websocket服务器形成一个连接
- socket事件的监听没有及时的停止,导致对同一事件的监听不断叠加(如sys事件),当触发一次这一事件时,会同时触发到之前叠加的所有监听函数!!!
项目中的效果就是不断重新进入房间时,提示信息的渲染次数会递增的增加,而不是只提示一次
- socket实例的创建写在了函数组件内,useState中的变量的频繁改变,导致的组件不断重新渲染,socket实例也被不断创建,形成过多的连接,让websocket服务崩溃!!!
解决:
把socket实例的创建拿出来放在单独的文件中,这样在各个函数组件中若使用的话只用引用这一共同的socket实例,仅与websocket服务器形成一个连接
- socket事件的监听没有及时的停止,导致对同一事件的监听不断叠加(如sys事件),当触发一次这一事件时,会同时触发到之前叠加的所有监听函数!!!
项目中的效果就是不断重新进入房间时,提示信息的渲染次数会递增的增加,而不是只提示一次
解决:
在离开房间后要socket.off('sys');
要停止事件监听,另外最好是在组件销毁时停止所有事件的监听(此处为Next/React18,即项目前端的代码)
/* client */
useEffect(() => {
console.log('chat组件挂载');
// 连接自动触发
socket.on('connect', () => {
socket.emit('connection');
// 其他客户端事件和逻辑
});
return () => {
console.log('chat组件卸载');
socket.off();// 停止所有事件的监听 !!!
};
}, []);
/* server */
@SubscribeMessage('connection')
connection(client: Socket, data) {
console.log('有一个客户端连接成功', client.id);
// 断连自动触发
client.on('disconnect', () => {
console.log('有一个客户端断开连接', client.id);
// 处理断开连接的额外逻辑
});
return;
}
来源:juejin.cn/post/7295681529606832138
前端时间分片渲染
在经典的面试题中:”如果后端返回了十万条数据要你插入到页面中,你会怎么处理?”
除了像 useVirtualList 这样的虚拟列表来处理外,我们还可以通过 时间分片
来处理
通过 setTimeout
直接上一个例子:
<!--
* @Author: Jolyne
* @Date: 2023-09-22 15:45:45
* @LastEditTime: 2023-09-22 15:47:24
* @LastEditors: Jolyne
* @Description:
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>十万数据渲染</title>
</head>
<body>
<ul id="list-container"></ul>
<script>
const oListContainer = document.getElementById('list-container')
const fetchData = () => {
return new Promise(resolve => {
const response = {
code: 0,
msg: 'success',
data: [],
}
for (let i = 0; i < 100000; i++) {
response.data.push(`content-${i + 1}`)
}
setTimeout(() => {
resolve(response)
}, 100)
})
}
// 模拟请求后端接口返回十万条数据
// 渲染 total 条数据中的第 page 页,每页 pageCount 条数据
const renderData = (data, total, page, pageCount) => {
// base case -- total 为 0 时没有数据要渲染 不再递归调用
if (total <= 0) return
// total 比 pageCount 少时只渲染 total 条数据
pageCount = Math.min(pageCount, total)
setTimeout(() => {
const startIdx = page * pageCount
const endIdx = startIdx + pageCount
const dataList = data.slice(startIdx, endIdx)
// 将 pageCount 条数据插入到容器中
for (let i = 0; i < pageCount; i++) {
const oItem = document.createElement('li')
oItem.innerText = dataList[i]
oListContainer.appendChild(oItem)
}
renderData(data, total - pageCount, page + 1, pageCount)
}, 0)
}
fetchData().then(res => {
renderData(res.data, res.data.length, 0, 200)
})
</script>
</body>
</html>
上面的例子中,我们使用了 setTimeout
,在每一次宏任务中插入一页数据,然后设置多个这样地宏任务,直到把所有数据都插入为止。
但是很明显能看到的问题是,快速拖动滚动条时,数据列表中会有闪烁的情况
这是因为:
当使用
setTimeout
来拆分大量的 DOM 插入操作时,虽然我们将延迟时间设置为 0ms,但实际上由于 JavaScript 是单线程的,任务执行时会被放入到事件队列中,而事件队列中的任务需要等待当前任务执行完成后才能执行。所以即使设置了 0ms 延迟,setTimeout
的回调函数也不一定会立即执行,可能会受到其他任务的阻塞。
当
setTimeout
的回调函数执行的间隔超过了浏览器每帧更新的时间间隔(一般是 16.7ms),就会出现丢帧现象。丢帧指的是浏览器在更新页面时,没有足够的时间执行全部的任务,导致部分任务被跳过,从而导致页面渲染不连续,出现闪烁的情况
所以,我们改善一下,通过 requestAnimationFrame
来处理
通过 requestAnimationFrame
<!--
* @Author: Jolyne
* @Date: 2023-09-22 15:45:45
* @LastEditTime: 2023-09-22 15:47:24
* @LastEditors: Jolyne
* @Description:
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>直接插入十万条数据</title>
</head>
<body>
<ul id="list-container"></ul>
<script>
const oListContainer = document.getElementById('list-container')
const fetchData = () => {
return new Promise(resolve => {
const response = {
code: 0,
msg: 'success',
data: [],
}
for (let i = 0; i < 100000; i++) {
response.data.push(`content-${i + 1}`)
}
setTimeout(() => {
resolve(response)
}, 100)
})
}
// 模拟请求后端接口返回十万条数据
// 渲染 total 条数据中的第 page 页,每页 pageCount 条数据
const renderData = (data, total, page, pageCount) => {
// base case -- total 为 0 时没有数据要渲染 不再递归调用
if (total <= 0) return
// total 比 pageCount 少时只渲染 total 条数据
pageCount = Math.min(pageCount, total)
requestAnimationFrame(() => {
const startIdx = page * pageCount
const endIdx = startIdx + pageCount
const dataList = data.slice(startIdx, endIdx)
// 将 pageCount 条数据插入到容器中
for (let i = 0; i < pageCount; i++) {
const oItem = document.createElement('li')
oItem.innerText = dataList[i]
oListContainer.appendChild(oItem)
}
renderData(data, total - pageCount, page + 1, pageCount)
})
}
fetchData().then(res => {
renderData(res.data, res.data.length, 0, 200)
})
</script>
</body>
</html>
很明显,闪烁的问题被解决了
这是因为:
requestAnimationFrame
会在浏览器每次进行页面渲染时执行回调函数,保证了每次任务的执行间隔是稳定的,避免了丢帧现象。所以在处理大量 DOM 插入操作时,推荐使用requestAnimationFrame
来拆分任务,以获得更流畅的渲染效果
来源:juejin.cn/post/7282756858174980132
前端:“这需求是认真的吗?” —— el-select 的动态宽度解决方案
Hello~大家好。我是秋天的一阵风 ~
前言
最近我遇到了一个神奇的需求,客户要求对 el-select 的 宽度 进行动态设置。
简单来说,就是我们公司有一些选择框,展示的内容像“中华人民共和国/广西壮族自治区/南宁市/西乡塘区”
这么长,一不小心就会内容超长,显示不全。详情请看下面动图:
一般来说,想解决内容展示不全的问题,有几种方法。
第一种:给选择框加个tooltip
效果,在鼠标悬浮时展示完整内容。
第二种:对用户选择label值进行切割,只展示最后一层内容。
但是我们的客户对这两种方案都不接受,要求选择的时候让select选择框的宽度动态增加。
有什么办法呢?客户就是上帝,必须满足,他们说什么就是什么,所以我们只能开动脑筋,动手解决。
思路
我们打开控制台,来侦察一下el-select
的结构,发现它是一个el-input--suffix
的div
包裹着一个input
,如下图所示。
内层input
的宽度是100%,外层div
的宽度是由这个内层input
决定的。也就是说,内层input
的宽度如果动态增加,外层div
的宽度也会随之增加。那么问题来了,如何将内层input
的宽度动态增加呢?
tips:
如果你对width的100%和auto有什么区别感兴趣,可以点击查看我之前的文章
解决方案
为了让我们的el-select
宽度能够跟着内容走,我们可以在内层input
同级别增加一个元素,内容就是用户选中的内容。内容越多,它就像一个胃口很大的小朋友,把外层div的宽度撑开。下面来看图示例 :
借助prefix
幸运的是,el-select
本身有一个prefix的插槽选项,我们可以借助这个选项实现:
我们添加一个prefix
的插槽,再把prefix
的定位改成relative
,并且把input
的定位改成绝对定位absolute
。最后将prefix
的内容改成我们的选项内容。看看现在的效果:
<template>
<div>
<el-select class="autoWidth" v-model="value" placeholder="请选择">
<template slot="prefix">
{{optionLabel}}
</template>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
>
</el-option>
</el-select>
</div>
</template>
<script>
export default {
data() {
return {
options: [
{
value: "选项1",
label: "中华人民共和国/广东省/深圳市/福田区",
},
{
value: "选项2",
label: "中华人民共和国/广西壮族自治区/南宁市/西乡塘区",
},
{
value: "选项3",
label: "中华人民共和国/北京市",
},
{
value: "选项4",
label: "中华人民共和国/台湾省",
},
{
value: "选项5",
label: "中华人民共和国/香港特别行政区",
},
],
value: "",
};
},
computed: {
optionLabel() {
return (this.options.find((item) => item.value === this.value) || {})
.label;
},
},
};
</script>
<style lang="scss" scoped>
::v-deep .autoWidth .el-input__prefix {
position: relative;
}
::v-deep .autoWidth input {
position: absolute;
}
</style>
细节调整
现在el-select
已经可以根据选项label的内容长短动态增加宽度了,但是我们还需要继续处理一下细节部分,将prefix
的内容调整到和select
框中的内容位置重叠
,并且将它隐藏
。看看现在的效果
::v-deep .autoWidth .el-input__prefix {
position: relative;
box-sizing: border-box;
border: 1px solid #fff;
padding: 0 30px;
height: 40px;
line-height: 40px;
left: 0px;
visibility: hidden;
}
调整初始化效果(用户未选择内容)
目前已经基本实现了效果了,还有最后一个问题,当用户没有选择内容的时候,select的宽度是“没有”
的,如下图所示。
所以我们还得给他加上一个最小宽度
我们加上最小宽度以后,发现这个select的图标又没对齐,这是因为我们在重写.el-input__prefix
样式的时候设置了padding: 0 30px,
当用户没有选择内容的时候,select
的图标应该是默认位置,我们需要继续调整代码,最后效果如下图所示:
完整代码
最后附上完整代码:
<template>
<div>
<el-select
class="autoWidth"
:class="{ 'has-content': optionLabel }"
v-model="value"
placeholder="请选择"
clearable
>
<template slot="prefix">
{{ optionLabel }}
</template>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
>
</el-option>
</el-select>
</div>
</template>
<script>
export default {
data() {
return {
options: [
{
value: "选项1",
label: "中华人民共和国/广东省/深圳市/福田区",
},
{
value: "选项2",
label: "中华人民共和国/广西壮族自治区/南宁市/西乡塘区",
},
{
value: "选项3",
label: "中华人民共和国/北京市",
},
{
value: "选项4",
label: "中华人民共和国/台湾省",
},
{
value: "选项5",
label: "中华人民共和国/香港特别行政区",
},
],
value: "",
};
},
computed: {
optionLabel() {
return (this.options.find((item) => item.value === this.value) || {})
.label;
},
},
};
</script>
<style lang="scss" scoped>
.autoWidth {
min-width: 180px;
}
::v-deep .autoWidth .el-input__prefix {
position: relative;
box-sizing: border-box;
border: 1px solid #fff;
padding: 0 30px;
height: 40px;
line-height: 40px;
left: 0px;
visibility: hidden;
}
::v-deep .autoWidth input {
position: absolute;
}
.autoWidth {
// 当.has-content存在时设置样式
&.has-content {
::v-deep .el-input__suffix {
right: 5px;
}
}
// 当.has-content不存在时的默认或备选样式
&:not(.has-content) {
::v-deep .el-input__suffix {
right: -55px;
}
}
}
</style>
来源:juejin.cn/post/7385825759118196771
告别频繁登录:教你用Axios实现无感知双Token刷新
一、引言
在现代系统中,Token认证已成为保障用户安全的标准做法。然而,尽管许多系统采用了这种认证方式,却在处理Token刷新方面存在不足,导致用户体验不佳。随着Token有效期的缩短,频繁的重新登录成为常见现象,许多系统未能提供一种无缝的、用户无感知的Token刷新机制。通过结合Vue3和Axios这两大前端技术栈,我们可以借助Promise机制,开发出一种更加完善的自动化Token刷新方案,显著提升系统的稳定性和用户体验。本文将深入探讨这一实现过程,帮助你解决Token刷新难题。
在现代系统中,Token认证已成为保障用户安全的标准做法。然而,尽管许多系统采用了这种认证方式,却在处理Token刷新方面存在不足,导致用户体验不佳。随着Token有效期的缩短,频繁的重新登录成为常见现象,许多系统未能提供一种无缝的、用户无感知的Token刷新机制。通过结合Vue3和Axios这两大前端技术栈,我们可以借助Promise机制,开发出一种更加完善的自动化Token刷新方案,显著提升系统的稳定性和用户体验。本文将深入探讨这一实现过程,帮助你解决Token刷新难题。
二、示意图
三、具体实现
了解了基本步骤后,实际的实现过程其实相当简洁。然而,在具体操作中,仍有许多关键细节需要我们仔细考量,以确保Token刷新机制的稳定性和可靠性。
- Token 存储与管理:首先,明确如何安全地存储和管理Access Token与Refresh Token。这涉及到浏览器的存储策略,比如使用
localStorage
、sessionStorage
,存储策略不在本文中提及,本文采用localStorage 进行存储。 - 请求拦截器的设置:在Axios中设置请求拦截器,用于在每次发送请求前检查Token的有效性。如果发现Token过期,则触发刷新流程。这一步骤需注意避免并发请求引发的重复刷新。
- 处理Token刷新的响应逻辑:当Token过期时,通过发送Refresh Token请求获取新的Access Token。在这里,需要处理刷新失败的情况,如Refresh Token也失效时,如何引导用户重新登录。
- 队列机制的引入:在Token刷新过程中,可能会有多个请求被同时发出。为了避免重复刷新Token,可以引入队列机制,确保在刷新Token期间,其他请求被挂起,直到新的Token可用。
- 错误处理与用户体验:最后,要对整个流程中的错误进行处理,比如刷新失败后的重试逻辑、错误提示信息等,确保用户体验不受影响。
通过以上步骤的实现,你可以构建一个用户无感知、稳定可靠的双Token刷新机制,提升应用的安全性与用户体验。接下来,我们将逐一解析这些关键步骤的具体实现。
了解了基本步骤后,实际的实现过程其实相当简洁。然而,在具体操作中,仍有许多关键细节需要我们仔细考量,以确保Token刷新机制的稳定性和可靠性。
- Token 存储与管理:首先,明确如何安全地存储和管理Access Token与Refresh Token。这涉及到浏览器的存储策略,比如使用
localStorage
、sessionStorage
,存储策略不在本文中提及,本文采用localStorage 进行存储。 - 请求拦截器的设置:在Axios中设置请求拦截器,用于在每次发送请求前检查Token的有效性。如果发现Token过期,则触发刷新流程。这一步骤需注意避免并发请求引发的重复刷新。
- 处理Token刷新的响应逻辑:当Token过期时,通过发送Refresh Token请求获取新的Access Token。在这里,需要处理刷新失败的情况,如Refresh Token也失效时,如何引导用户重新登录。
- 队列机制的引入:在Token刷新过程中,可能会有多个请求被同时发出。为了避免重复刷新Token,可以引入队列机制,确保在刷新Token期间,其他请求被挂起,直到新的Token可用。
- 错误处理与用户体验:最后,要对整个流程中的错误进行处理,比如刷新失败后的重试逻辑、错误提示信息等,确保用户体验不受影响。
通过以上步骤的实现,你可以构建一个用户无感知、稳定可靠的双Token刷新机制,提升应用的安全性与用户体验。接下来,我们将逐一解析这些关键步骤的具体实现。
1. 编写请求拦截器
实现请求拦截器的基本逻辑比较简单,即在每次请求时自动附带上Token以进行认证。
service.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const userStore = useUserStore()
if (userStore.authInfo.accessToken && userStore.authInfo.accessToken !== "") {
// 设置头部 token
config.headers.Authorization = RequestConstant.Header.AuthorizationPrefix + userStore.authInfo.accessToken;
}
return config;
}, (error: any) => {
return Promise.reject(error);
}
);
目前的实现方案是,在请求存在有效Token时,将其附带到请求头中发送给服务器。但在一些特殊情况下,某些请求可能不需要携带Token。为此,我们可以在请求配置中通过config
对象来判断是否需要携带Token。例如:
request: (deptId: number, deptForm: DeptForm): AxiosPromise<void> => {
return request<void>({
url: DeptAPI.UPDATE.endpoint(deptId),
method: "put",
data: deptForm,
headers: {
// 根据需要添加Token,或者通过自定义逻辑决定是否包含Authorization字段
token: false
}
});
}
那么在请求拦截器中,您需要多加一个判断,就是判断请求头中token是否需要
// 代码省略
实现请求拦截器的基本逻辑比较简单,即在每次请求时自动附带上Token以进行认证。
service.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const userStore = useUserStore()
if (userStore.authInfo.accessToken && userStore.authInfo.accessToken !== "") {
// 设置头部 token
config.headers.Authorization = RequestConstant.Header.AuthorizationPrefix + userStore.authInfo.accessToken;
}
return config;
}, (error: any) => {
return Promise.reject(error);
}
);
目前的实现方案是,在请求存在有效Token时,将其附带到请求头中发送给服务器。但在一些特殊情况下,某些请求可能不需要携带Token。为此,我们可以在请求配置中通过config
对象来判断是否需要携带Token。例如:
request: (deptId: number, deptForm: DeptForm): AxiosPromise<void> => {
return request<void>({
url: DeptAPI.UPDATE.endpoint(deptId),
method: "put",
data: deptForm,
headers: {
// 根据需要添加Token,或者通过自定义逻辑决定是否包含Authorization字段
token: false
}
});
}
那么在请求拦截器中,您需要多加一个判断,就是判断请求头中token是否需要
// 代码省略
2. 深究响应拦截器
对于双token刷新的难点就在于响应拦截器中,因为在这里后端会返回token过期的信息。我们需要先清楚后端接口响应内容
对于双token刷新的难点就在于响应拦截器中,因为在这里后端会返回token过期的信息。我们需要先清楚后端接口响应内容
2.1 接口介绍
- 正常接口响应内容
// Status Code: 200 OK
{
"code":"0000",
"msg":"操作成功",
"data":{}
}
- accessToken 过期响应内容
// Status Code: 401 Unauthorized
{
"code":"I009",
"msg":"登录令牌过期"
}
- accessToken 刷新响应内容
// Status Code: 200 OK
{
"code": "0000",
"msg": "操作成功",
"data": {
"accessToken": "",
"refreshToken": "",
"expires": ""
}
}
- refreshToken 过期响应内容
// Status Code: 200 OK
{
"code": "I009",
"msg": "登录令牌过期"
}
注意 : 当Status Code
不是200时,Axios的响应拦截器会自动进入error
方法。在这里,我们可以捕捉到HTTP状态码为401的请求,从而初步判断请求是由于Unauthorized
(未授权)引发的。然而,触发401状态码的原因有很多,不一定都代表Token过期。因此,为了准确判断Token是否真的过期,我们需要进一步检查响应体中的code
字段。
- 正常接口响应内容
// Status Code: 200 OK
{
"code":"0000",
"msg":"操作成功",
"data":{}
}
- accessToken 过期响应内容
// Status Code: 401 Unauthorized
{
"code":"I009",
"msg":"登录令牌过期"
}
- accessToken 刷新响应内容
// Status Code: 200 OK
{
"code": "0000",
"msg": "操作成功",
"data": {
"accessToken": "",
"refreshToken": "",
"expires": ""
}
}
- refreshToken 过期响应内容
// Status Code: 200 OK
{
"code": "I009",
"msg": "登录令牌过期"
}
注意 : 当Status Code
不是200时,Axios的响应拦截器会自动进入error
方法。在这里,我们可以捕捉到HTTP状态码为401的请求,从而初步判断请求是由于Unauthorized
(未授权)引发的。然而,触发401状态码的原因有很多,不一定都代表Token过期。因此,为了准确判断Token是否真的过期,我们需要进一步检查响应体中的code
字段。
2.2 响应拦截器编写
有上面的接口介绍,我们编写的就简单,判断error.response?.status === 401、code === I009 即可,如果出现这种情况就直接刷新token。
service.interceptors.response.use(async (response: AxiosResponse) => {
// 正常请求代码忽略
return Promise.reject(new Error(msg || "Error"));
},
async (error: any) => {
const userStore = useUserStore()
if (error.response?.status === 401) {
if (error.response?.data?.code === RequestConstant.Code.AUTH_TOKEN_EXPIRED) {
// token 过期处理
// 1. 刷新 token
const loginResult: LoginResult = await userStore.refreshToken()
if (loginResult) {
// refreshToken 未过期
// 2.1 重构请求头
error.config.headers.Authorization = RequestConstant.Header.AuthorizationPrefix + userStore.authInfo.accessToken;
// 2.2 请求
return await service.request(error.config);
} else {
// refreshToken 过期
// 1. 重置登录 token , 跳转登录页
await userStore.resetToken()
}
} else {
// 如果是系统发出的401 , 重置登录 token , 跳转登录页
await userStore.resetToken()
}
} else if (error.response?.status === 403) {
// 403 结果处理 , 代码省略
} else {
// 其他错误结果处理 , 代码省略
}
return Promise.reject(error.message);
}
);
有上面的接口介绍,我们编写的就简单,判断error.response?.status === 401、code === I009 即可,如果出现这种情况就直接刷新token。
service.interceptors.response.use(async (response: AxiosResponse) => {
// 正常请求代码忽略
return Promise.reject(new Error(msg || "Error"));
},
async (error: any) => {
const userStore = useUserStore()
if (error.response?.status === 401) {
if (error.response?.data?.code === RequestConstant.Code.AUTH_TOKEN_EXPIRED) {
// token 过期处理
// 1. 刷新 token
const loginResult: LoginResult = await userStore.refreshToken()
if (loginResult) {
// refreshToken 未过期
// 2.1 重构请求头
error.config.headers.Authorization = RequestConstant.Header.AuthorizationPrefix + userStore.authInfo.accessToken;
// 2.2 请求
return await service.request(error.config);
} else {
// refreshToken 过期
// 1. 重置登录 token , 跳转登录页
await userStore.resetToken()
}
} else {
// 如果是系统发出的401 , 重置登录 token , 跳转登录页
await userStore.resetToken()
}
} else if (error.response?.status === 403) {
// 403 结果处理 , 代码省略
} else {
// 其他错误结果处理 , 代码省略
}
return Promise.reject(error.message);
}
);
2.3 解决重复刷新问题
编写完成上面的内容,考虑一下多个请求可能同时遇到 Token 过期,如果没有适当的机制控制,这些请求可能会同时发起刷新 Token 的操作,导致重复请求,甚至可能触发后端的安全机制将这些请求标记为危险操作。
为了解决这个问题,我们实现了一个单例 Promise
的刷新逻辑,通过 singletonRefreshToken
确保在同一时间只有一个请求会发起 Token 刷新操作。其核心思想是让所有需要刷新的请求共享同一个 Promise
,这样即使有多个请求同时遇到 Token 过期,它们也只会等待同一个刷新操作的结果,而不会导致多次刷新。
/**
* 刷新 token
*/
refreshToken(): Promise<LoginResult> {
// 如果 singletonRefreshToken 不为 null 说明已经在刷新中,直接返回
if (singletonRefreshToken !== null) {
return singletonRefreshToken
}
// 设置 singletonRefreshToken 为一个 Promise 对象 , 处理刷新 token 请求
singletonRefreshToken = new Promise<LoginResult>(async (resolve) => {
await AuthAPI.REFRESH.request({
accessToken: this.authInfo.accessToken as string,
refreshToken: this.authInfo.refreshToken as string
}).then(({data}) => {
// 设置刷新后的Token
this.authInfo = data
// 刷新路由
resolve(data)
}).catch(() => {
this.resetToken()
})
})
// 最终将 singletonRefreshToken 设置为 null, 防止 singletonRefreshToken 一直占用
singletonRefreshToken.finally(() => {
singletonRefreshToken = null;
})
return singletonRefreshToken
}
编写完成上面的内容,考虑一下多个请求可能同时遇到 Token 过期,如果没有适当的机制控制,这些请求可能会同时发起刷新 Token 的操作,导致重复请求,甚至可能触发后端的安全机制将这些请求标记为危险操作。
为了解决这个问题,我们实现了一个单例 Promise
的刷新逻辑,通过 singletonRefreshToken
确保在同一时间只有一个请求会发起 Token 刷新操作。其核心思想是让所有需要刷新的请求共享同一个 Promise
,这样即使有多个请求同时遇到 Token 过期,它们也只会等待同一个刷新操作的结果,而不会导致多次刷新。
/**
* 刷新 token
*/
refreshToken(): Promise<LoginResult> {
// 如果 singletonRefreshToken 不为 null 说明已经在刷新中,直接返回
if (singletonRefreshToken !== null) {
return singletonRefreshToken
}
// 设置 singletonRefreshToken 为一个 Promise 对象 , 处理刷新 token 请求
singletonRefreshToken = new Promise<LoginResult>(async (resolve) => {
await AuthAPI.REFRESH.request({
accessToken: this.authInfo.accessToken as string,
refreshToken: this.authInfo.refreshToken as string
}).then(({data}) => {
// 设置刷新后的Token
this.authInfo = data
// 刷新路由
resolve(data)
}).catch(() => {
this.resetToken()
})
})
// 最终将 singletonRefreshToken 设置为 null, 防止 singletonRefreshToken 一直占用
singletonRefreshToken.finally(() => {
singletonRefreshToken = null;
})
return singletonRefreshToken
}
重要点解析:
singletonRefreshToken
的使用:singletonRefreshToken
是一个全局变量,用于保存当前正在进行的刷新操作。如果某个请求发现 singletonRefreshToken
不为 null
,就说明另一个请求已经发起了刷新操作,它只需等待这个操作完成,而不需要自己再发起新的刷新请求。
- 共享同一个
Promise
:- 当
singletonRefreshToken
被赋值为一个新的 Promise
时,所有遇到 Token 过期的请求都会返回这个 Promise
,并等待它的结果。这样就避免了同时发起多个刷新请求。
- 刷新完成后的处理:
- 刷新操作完成后(无论成功与否),都会通过
finally
将 singletonRefreshToken
置为 null
,从而确保下一次 Token 过期时能够重新发起刷新请求。
通过这种机制,我们可以有效地避免重复刷新 Token 的问题,同时也防止了由于过多重复请求而引发的后端安全性问题。这种方法不仅提高了系统的稳定性,还优化了资源使用,确保了用户的请求能够正确地处理。
singletonRefreshToken
的使用:singletonRefreshToken
是一个全局变量,用于保存当前正在进行的刷新操作。如果某个请求发现singletonRefreshToken
不为null
,就说明另一个请求已经发起了刷新操作,它只需等待这个操作完成,而不需要自己再发起新的刷新请求。
- 共享同一个
Promise
:- 当
singletonRefreshToken
被赋值为一个新的Promise
时,所有遇到 Token 过期的请求都会返回这个Promise
,并等待它的结果。这样就避免了同时发起多个刷新请求。
- 当
- 刷新完成后的处理:
- 刷新操作完成后(无论成功与否),都会通过
finally
将singletonRefreshToken
置为null
,从而确保下一次 Token 过期时能够重新发起刷新请求。
- 刷新操作完成后(无论成功与否),都会通过
通过这种机制,我们可以有效地避免重复刷新 Token 的问题,同时也防止了由于过多重复请求而引发的后端安全性问题。这种方法不仅提高了系统的稳定性,还优化了资源使用,确保了用户的请求能够正确地处理。
四、测试
- 当我们携带过期token访问接口,后端就会返回401状态和I009。
这时候进入
const loginResult: LoginResult = await userStore.refreshToken()
- 携带之前过期的accessToken和未过期的refreshToken进行刷新
- 携带过期的accessToken的原因 :
- 防止未过期的 accessToken 进行刷新
- 防止 accessToken 和 refreshToken 不是同一用户发出的
- 其他安全性考虑
- 当我们携带过期token访问接口,后端就会返回401状态和I009。
这时候进入
const loginResult: LoginResult = await userStore.refreshToken()
- 携带之前过期的accessToken和未过期的refreshToken进行刷新
- 防止未过期的 accessToken 进行刷新
- 防止 accessToken 和 refreshToken 不是同一用户发出的
- 其他安全性考虑
- 获取到正常结果
作者:翼飞
来源:juejin.cn/post/7406992576513589286
来源:juejin.cn/post/7406992576513589286
前端到底该如何安全的实现“记住密码”?
在 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
在客户端,可以使用 localStorage
或 sessionStorage
来存储 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();
};
总结
如上示例,展示了如何使用 localStorage
、sessionStorage
、IndexedDB、Cookie 和 JWT 来实现“记住密码”功能。每种方式都有其适用场景和安全考虑,大家可以根据具体需求选择合适的实现方式。
欢迎在评论区留言讨论~
Happy coding! 🚀
来源:juejin.cn/post/7397284874652942363
uniapp 地图如何添加?你要的教程来喽!
地图在 app 中使用还是很广泛的,常见的应用常见有:
1、获取自己的位置,规划路线。
2、使用标记点进行标记多个位置。
3、绘制多边形,使用围墙标记位置等等。
此篇文章就以高德地图为例,以上述三个常见需求为例,教大家如何在 uniapp 中添加地图。
作为一个不管闲事的前端姑娘,我就忽略掉那些繁琐的账号申请,假设需要的信息问项目经理都要来了,如果你没有现成的信息,还需要申请,请查看:
去高德地图注册账号,根据官网指示获取 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 流式传输可太香了!
今天想和大家分享的一个技术是 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', timestamp: new 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 ?
下表展示了两者之间的对比:
特性/因素 | SSE | WebSockets |
---|---|---|
协议 | 基于HTTP,使用标准HTTP连接 | 单独的协议(ws:// 或 wss://),需要握手升级 |
通信方式 | 单向通信(服务器到客户端) | 全双工通信 |
数据格式 | 文本(UTF-8编码) | 文本或二进制 |
重连机制 | 浏览器自动重连 | 需要手动实现重连机制 |
实时性 | 高(适合频繁更新的场景) | 非常高(适合高度交互的实时应用) |
浏览器支持 | 良好(大多数现代浏览器支持) | 非常好(几乎所有现代浏览器支持) |
适用场景 | 实时通知、新闻feed、股票价格等需要从服务器推送到客户端的场景 | 在线游戏、聊天应用、实时交互应用 |
复杂性 | 较低,易于实现和维护 | 较高,需要处理连接的建立、维护和断开 |
兼容性和可用性 | 基于HTTP,更容易通过各种中间件和防火墙 | 可能需要配置服务器和网络设备以支持WebSocket |
服务器负载 | 适合较低频率的数据更新 | 适合高频率消息和高度交互的场景 |
可以发现,SSE 与 WebSocket 各有优缺点,对于需要客户端与服务端高频交互的场景,WebSocket 确实更适合;但对于只需要服务端单向数据传输的场景,SSE 确实能耗更低,且不需要客户端感知。
参考文档
来源:juejin.cn/post/7355666189475954725
整理最近的生活
Hi,见到你真好 :)
写在开始
自从去年 8月 搬到 上海 之后,就很少再写文章,很多的思路都是断断续续,导致也不知道该写点什么,所以最后是是草稿攒了一堆,大纲整整齐齐,内容空空如也,甚是尴尬。
时间长了后,逐渐就有点开摆的心态。也有点理解为什么很多技术同学突然就不更新了。🫨
不过最近好在有些念头又开始跃跃欲试,故想着写一些东西,活跃活跃生锈的脑子🫡。
故本篇,其实算是一个随记,想到哪里就写到哪里,不包含任何技术指南。
搬家三两事
背景
因为当时是需要从北京搬到上海,家里还有两只猫以及超级多的行李,故需要考虑的事情有下面几点:
- 琐碎的行李怎么处理?
- 如何将 两只猫 安全的送到上海?
- 升降桌、工学椅、冰柜、猫厕所等大件怎么处理?
头脑风暴
- 两只猫托运走
成本过高,一只平均需要 1000+ ,以及需要疫苗齐全,以及需要提前7天以上准备。
- 升降桌、工学椅、冰柜出二手
出二手 = 5折以下,最主要是刚买没半年。😑
- 行李快递走?
货拉拉跨城搬运+快递一部分。
上述总费用: 托运🐱2000 + 二手折损(3000) + 货拉拉(4000+) + 动态费用1000 = 9500 左右。
备注点:
- 托运猫的安全性;
- 如果喊货拉拉,那就不需要二手回收;
算完上述费用之后,我忍不住拍了一下家里两只,嘴里嚷嚷着:要不是你两,我何至于如此!!!
最终解决
小红书约了一个跨城搬家师傅,车型依维柯(长4.9,宽1.9,高2.2),最后只花了 3500 解决了。
关于爱好的倒腾
世界奇奇怪怪,生活慢慢悠悠。
有时候会想,人的一生难得有几个爱好,那可能就会包含折腾 电子小垃圾:)
下面列一下今年折腾过的一些小垃圾:
Pixel 7
绿色- 戴尔
U2723QX
ikbc
高达97键盘ipadAir
M1丐版PS5
国行双手柄SlimStudio 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和黑悟空,遂带上几张图。
关于小主机
之前因为只有一个笔记本,上下班都需要背笔记本,遇到冬天还好,到了夏天,就非常反感,故最近诞生出了买一个 小主机 的想法。
因为不玩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:
- 不要轻易把系统装到扩展固态(特别是硬盘盒速度不够40Gbps时)上。
- 开机引导会变慢(如果一直不关机当我没说);
- 如果硬盘散热跟不上,系统会卡顿,如同ANR了一样;
- 因为
MacMini
雷电口 不支持usb3.2 Gen2x2
,在不满足雷电口的情况下,最快读写 只有1k。故导致硬盘速度只能到达 1k 读写,也就是硬盘盒的一半速度。
最后全家福如下:
关于显示器
最近一年连着用过了 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
附近,但一分钱一分货,纸面参数终究是纸面参数,最后看看实际观感,也就那样。
关于一些想法
人生不过几十载,如果工作要占用掉最宝贵的20年华,那未免太过于糟糕。
不知为何,最近总感觉上班的时间过得尤为快,每周过了周二后,周五的下一步就又要到了,下个月初也越来越近了。
来上海后,几乎每天都会和老婆晚上下楼走走,近的时候绕着小区,远的时候绕着小区外面的路。起初刚来上海时,脑子里依然会有过几年会北京的想法,但在上海有段时间后,这个想法就变得没那么重了,直到现在,我两都变成了留在上海也许更好(仔细算了算)😐。
写在最后
兜兜转转,这篇也是写了近一个月,属于是想起来写一点,接下来会更新的频繁一点。
下次再见,朋友们 👋
关于我
我是 Petterp ,一个 Android 工程师。如果本文,你觉得写的还不错,不妨点个赞或者收藏,你的支持,是我持续创作的最大鼓励!
来源:juejin.cn/post/7406258856953790515
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();
通过 find
和 filter
等函数式编程方法,我们可以避免嵌套的 if-else
语句,使代码更加简洁和易于维护。
三、总结
if-else
嵌套过深的问题是前端开发中常见的挑战。通过本文提供的多种解决方案,如早返回、对象字面量、策略模式、多态和函数式编程技巧,开发者可以根据实际需求选择合适的优化方案,从而提高代码的可读性、可维护性和性能。
希望这些方法能对你的开发工作有所帮助,欢迎在评论区分享你的经验与想法!
来源:juejin.cn/post/7406538050228633641
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/年
合着这个新的收费标准是直接基于公司里总的员工数来进行计算的,而不仅仅是使用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。
来源:juejin.cn/post/7405845617282449462
网页也能像 QQ 一样发出右下角消息?轻松实现桌面通知!
网页也能像 QQ 一样发出右下角消息?轻松实现桌面通知!
大家好,我是蒜鸭。今天我们来聊聊如何让网页像 QQ 那样在右下角弹出消息通知。这个功能不仅能提升用户体验,还能增加网站的互动性。让我们一起探索如何在网页中实现这个酷炫的功能吧!
1. 为什么需要网页通知?
在当今信息爆炸的时代,获取用户注意力变得越来越困难。传统的网页通知方式,如弹窗或页面内提示,往往会打断用户的浏览体验。而类似 QQ 那样的右下角消息通知,既能及时传递信息,又不会过分干扰用户,可以说是一种相当优雅的解决方案。
实现这种通知功能,我们有两种主要方式:使用 Web Notifications API 或自定义 CSS+JavaScript 实现。接下来,我们将详细探讨这两种方法的实现过程、优缺点以及适用场景。
2. 使用 Web Notifications API
2.1 Web Notifications API 简介
Web Notifications API 是现代浏览器提供的一个强大功能,它允许网页向用户发送通知,即使在用户没有打开网页的情况下也能工作。这个 API 的使用非常简单,但功能却十分强大。
2.2 基本实现步骤
- 检查浏览器支持
- 请求用户授权
- 创建并显示通知
让我们来看看具体的代码实现:
// 检查浏览器是否支持通知
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 基本思路
- 创建一个固定位置的 div 元素作为通知容器
- 使用 JavaScript 动态创建通知内容
- 添加动画效果使通知平滑显示和消失
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. 性能优化与注意事项
在实现网页通知功能时,我们还需要注意以下几点:
- 防抖和节流:对于频繁触发的事件(如实时通知),使用防抖或节流技术可以有效减少不必要的通知显示。
- 内存管理:确保在移除通知时,同时清理相关的事件监听器和 DOM 元素,避免内存泄漏。
- 优雅降级:对于不支持 Web Notifications API 的浏览器,可以降级使用自定义的 CSS+JavaScript 方案。
- 用户体验:给用户提供控制通知显示的选项,如允许用户设置通知的类型、频率等。
- 安全考虑:在使用 Web Notifications API 时,确保只在 HTTPS 环境下请求权限,并尊重用户的权限设置。
网页通知是一个强大的功能,能够显著提升用户体验和网站的互动性。无论是使用 Web Notifications API 还是自定义的 CSS+JavaScript 方案,都能实现类似 QQ 那样的右下角消息通知。选择哪种方式取决于你的具体需求和目标用户群。通过合理使用通知功能,你可以让你的网站变得更加生动和用户友好。
来源:juejin.cn/post/7403283321793314850
url请求参数带有特殊字符“%、#、&”时,参数被截断怎么办?
是的,最近又踩坑了!
事情是这样的,我们测试小姐姐在一个全局搜索框里输入了一串特殊字符“%%%”,然后进行搜索,结果报错了。而输入常规字符,均可以正常搜索。
一排查,发现特殊字符“%%%”并未成功传给后端。
我们的这个全局搜索功能是需要跳转页面才能查看到搜索结果的。所以,搜索条件是作为参数拼接在页面url上的。
正常的传参:
当输入的是特殊字符“%、#、&”时,参数丢失
也就是说,当路由请求参数带有浏览器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)
此方法对绝大多数特殊字符都适用,但是唯独输入“%”进行搜索时不行,报错如下。
所以在编码之前,还需进行一下如下转换:
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方法进行转换了。
来源:juejin.cn/post/7332048519156776979
前端代码重复度检测
在前端开发中,代码的重复度是一个常见的问题。重复的代码不仅增加了代码的维护成本,还可能导致程序的低效运行。为了解决这个问题,有许多工具和技术被用来检测和消除代码重复。其中一个被广泛使用的工具就是jscpd
。
jscpd简介
jscpd
是一款开源的JavaScript
的工具库,用于检测代码重复的情况,针对复制粘贴的代码检测很有效果。它可以通过扫描源代码文件,分析其中的代码片段,并比较它们之间的相似性来检测代码的重复度。jscpd
支持各种前端框架和语言,包括HTML、CSS和JavaScript等150种的源码文件格式。无论是原生的JavaScript、CSS、HTML代码,还是使用typescript
、scss
、vue
、react
等代码,都能很好的检测出项目中的重复代码。
开源仓库地址:github.com/kucherenko/jscpd/tree/master
如何使用
使用jscpd
进行代码重复度检测非常简单。我们需要安装jscpd
。可以通过npm
或yarn
来安装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-start
和 jscpd: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)
来源:juejin.cn/post/7288699185981095988
2种纯前端换肤方案
前言
换肤功能是一项普遍的需求,尤其是在夜晚,用户更倾向于使用暗黑模式。在我负责的公司项目中,每个项目都有换肤功能的需求。
过去,我主要使用 SCSS 变量,并利用其提供的函数,如 @each
、map-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>
效果图,默认色:
效果图,暗黑色:
5. 修改JS主题色
切换主题色,除了需要修改css样式,有时也需在js文件中修改样式,例如修改echarts的配置文件,来改变柱状图、饼图等的颜色。
1. 新建src/config/theme.js
定义图像的颜色,这里定义字体的颜色,默认情况下字体为黑色,暗黑模式下,字体为白色
const themeColor = {
default: {
font: '#333',
},
black: {
font: '#fff',
},
};
export default themeColor;
2. 修改vue文件
关键点:
- 定义主题色TS类型,规定默认和暗黑两种:
type ThemeTypes = 'default' | 'black';
- 定义theme响应式变量,用来记录当前主题色:
const theme = ref<ThemeTypes>('default');
- 监听change事件,将选中的值赋给theme:
theme.value = selectTheme;
- 使用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
实现一个支持@的输入框
近期产品期望在后台发布帖子或视频时,需要添加 @用户 的功能,以便用户收到通知,例如“xxx在xxx提及了您!”。然而,现有的开源库未能满足我们的需求,例如 ant-design 的 Mentions 组件:
但是不难发现跟微信飞书对比下,有两个细节没有处理。
- @用户没有高亮
- 在删除时没有当做一个整体去删除,而是单个字母删除,首先不谈用户是否想要整体删除,在这块有个模糊查询的功能,如果每删一个字母之后去调接口查询数据库造成一些不必要的性能开销,哪怕加上防抖。
然后也是找了其他的库都没达到产品的期望效果,那么好,自己实现一个,先看看最终实现的效果:
封装之后使用:
<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);
}}
/>
那么实现这么一个输入框大概有以下几个点:
- 高亮效果
- 删除/选中用户时需要整体删除
- 监听@的位置,复制给弹框的坐标,联动效果
- 最后我需要拿到文本内容,并且需要拿到@那些用户,去做表单提交
大多数文本输入框我们会使用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>
实现思路:
- 监听输入@,唤起选择框。
- 截取@xxx的xxx作为搜素的关键字去查询接口
- 选择用户后需要将原先输入的 @xxx 替换成 @姓名,并且将@的用户缓存起来
- 选择文本框中的姓名时需要变为整体选中状态,这块依然可以给标签设置为不可编辑状态就可实现,contentEditable=false,即可实现整体删除,在删除的同时需要将当前用户从之前缓存的@过的用户数组删除
- 那么可以拿到输入框的文本,@的用户, 最后将数据抛给父组件就完成了
以上提到了监听@文本变化,通常绑定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该方法中主要做了以下逻辑:
- 在此之前需要先了解 Selection的一些方法
- 通过getSelection方法可以获取光标的偏移位置,那么可以截取光标之前的字符串,并且使用lastIndexOf从后向前查找最后一个“@”符号,并记录他的下标,那么有了【光标之前的字符串】,【@的下标】就可以拿到到@之后用于过滤用户的关键字,并将其缓存起来。
- 唤起选择器,并通过关键字去过滤用户。这块涉及到一个选择器的位置,直接使用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();
};
选择用户的时候需要做的以下以下几点:
- 删除之前的@xxx字符
- 插入不可编辑的span标签
- 将当前选择的用户缓存起来
- 重新获取输入框的内容
- 关闭选择器
- 将输入框重新聚焦
最后
在选择的用户或者内容发生改变时将数据抛给父组件
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;
以上就是实现一个支持@用户的输入框功能,就目前而言,比较死板,不支持自定义颜色,自定义选择器等等,未来,可以进一步扩展功能,例如添加@用户的高亮样式定制、支持键盘快捷键操作等,从而提升用户体验和功能性。
来源:juejin.cn/post/7357917741909819407
Vue.js 自动路由:告别手动配置,让开发更轻松!
在使用 Vue.js 开发项目的时候,我最头疼的就是创建路由,尤其是项目越来越大的时候,管理 route.ts
或 route.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
,你就会看到以下内容:
是不是很神奇? 希望这篇简短的博客能帮助你在 Vue.js 的旅程中更轻松地创建路由!
来源:juejin.cn/post/7401354593588199465
JS类型判断的四种方法,你掌握了吗?
引言
JavaScript中有七种原始数据类型和几种引用数据类型,本文将清楚地介绍四种用于类型判断的方法,分别是typeOf
、instanceOf
、Object.prototype.toString.call()
、Array.isArray()
,并介绍其使用方法和判定原理。
typeof
- 可以准确判断除
null
之外的所有原始类型,null
会被判定成object 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
- 如果此值是
undefined
类型,则返回‘[object Undefined]’
- 如果此值是
null
类型,则返回‘[object Null]’
- 将 O 作为
ToObject(this)
的执行结果。toString
执行过程中会调用一个ToObject
方法,执行一个类似包装类的过程,我们访问不了这个方法,是JS自己用的 - 定义一个
class
作为内部属性[[class]]
的值。toString可以读取到这个值并把这个值暴露出来让我们看得见 - 返回由
"[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方法有几个版本:
{}.toString()
得到由"[object" 和 class 和 "]" 组成的字符串
[].toString()
数组的toString方法重写了对象上的toString方法,返回由数组内部元素以逗号拼接的字符串
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。判定范围最狭窄
来源:juejin.cn/post/7403288145196580904
学TypeScript必然要了解declare
背景
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表示类型声明文件是哪一个。
可以使用 @types 统一管理第三方库的声明文件。@types 的使用方式很简单,直接用 npm 安装对应的声明模块即可,以 jQuery 举例:
npm install @types/jquery --save-dev
来源:juejin.cn/post/7402811318816702515