注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

三行代码实现完美瀑布流

web
需求 最近准备做一个瀑布流的需求,这里每个卡片的高度是不固定的,有点类似下图这样的。 难点 如果绝对定位,如何定位每个卡片的位置。 因为可以发现需求里边的每个卡片的高度都是不固定的,所以如果想使用绝对定位的话,需要实时动态的计算每个卡片的left、top,...
继续阅读 »

需求


最近准备做一个瀑布流的需求,这里每个卡片的高度是不固定的,有点类似下图这样的。



难点



  1. 如果绝对定位,如何定位每个卡片的位置。
    因为可以发现需求里边的每个卡片的高度都是不固定的,所以如果想使用绝对定位的话,需要实时动态的计算每个卡片的left、top,这里会涉及大量的计算。


因为每个卡片的高度是不固定,所以如果想要计算left、top,必须首先获取卡片的高度,但是卡片里边不仅包含图片,还有文字,这个时候计算高度是比较困难的。




  1. 如何结合虚拟列表实现瀑布流


这个时候必须要根据scrollTop的位置,判断什么时候需要加载哪些数据,判断可视区域里边数据的起始索引以及结束索引,这里同样会涉及大量的计算,同时还因为每个卡片的高度不固定,甚至只有图片和文字加载到浏览器以后,才能得到真实的高度,这样会更困难。


解决方案


解决方案1



  1. 如果绝对定位,如何定位每个卡片的位置。


1.1 后端计算
后端可以先把每个图片的高度和宽度提前计算好,直接返回给前端进行处理,然后前端根据后端返回的图片高度和宽度,然后再动态的计算出每个卡片的高度(文字部分也可以固定高度,使用省略号实现)。


1.2 前端计算


前端计算还是比较麻烦的,需要先等卡片组件加载完成,才能得到宽度和高度,而且因为数据量比较大,每个卡片计算出来以后,还需要去根据计算出来的结果去更新left、top,会非常麻烦。
这里可以采用node作为中间层进行计算,还是使用类似后端计算的思路。


还有一种方法是使用observe api 动态观察每个卡片,当观察到卡片加载完成后,再动态根据卡片的宽度和高度计算,不过这样同样很麻烦。



  1. 如何结合虚拟列表实现瀑布流


这里因为卡片的高度是不固定的,同时也是瀑布流,所以不能使用react-window 来解决,不过可以使用react-window的类似思路,自己封装一个npm 包,根据scroll事件判断需要加载那些数据。


解决方案2


使用css3的columns来实现,该技术解决方案不需要计算高度,也不需要去定位,但是columns这个属性会把卡片高度给切开
如下图:



不过可以使用下面代码来解决,


js
复制代码

.test {
// color: red;
// height: 2000px;
background-color: red;
gap: 1rem;
columns: 5;
.no-break {
break-inside: avoid;
}
}

效果如下:



作者:邹小邹
链接:https://juejin.cn/post/7273890921506553890
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

被中文输入法坑死了

web
PM:在PC端做一个@功能吧,就是那种...。 我:你不用解释🤔我知道那个功能,监听keydown事件,然后e.keycode === 50,那可太简单了。 那可太简单了,可太简单了,太简单了,简单了,单了,了......(掉进坑里的回声) 坑1:KeyB...
继续阅读 »

PM:在PC端做一个@功能吧,就是那种...。



我:你不用解释🤔我知道那个功能,监听keydown事件,然后e.keycode === 50,那可太简单了。



那可太简单了,可太简单了,太简单了,简单了,单了,了......(掉进坑里的回声)


坑1:KeyBoardEvent.keycode



废弃的属性你就坚持用吧,一用一个不吱声。以后线上跑得好好的代码突然报错了,你都不知道bug在哪儿。


现在的web标准里,要确定一个键盘事件是靠e.keye.codecode代表触发事件的物理按键,比如2的位置code='Digit2'key返回用户按下的物理按键的值,它还与 shiftKey 等调节性按键的状态、键盘的区域和布局有关。


所以对于@来说,直接判断e.key === "@"来做后续的操作就行了。


addEventListner('keydown', (e) => {
if (e.key === "@") {
e.preventDefalut(); // 这里去掉@字符是为了后续插入和监听方便处理
// 插入有@字符的,可监听输入的元素
// 唤起小窗....
}
});


仔细看上面的这几行代码和注释,要开始考(坑)了。


坑2:输入法的坑


起因


在我美滋滋地以为避过了坑1就没事了的时候,一个夜晚我的测试同学告诉我,在测试环境突然就体验不到这个功能了,无论输入多少个@都不行,白天还好好的🤯。


好一个「白天还好好的」。


我自己测试的时候又百分百能体验到🤔,所以最开始我还在怀疑他没有配上测试系统......


于是,让测试同学的windows电脑连到我的开发环境debug一看:


好家伙,真是好家伙😅他的电脑的e.key === "Process"????!!!


什么意思呢,就是正常我们理想中的@字符产生是shift+2按键的组合,监听keydown之后我们会按顺序收到两个回调:



  1. e.key === "Shift"e.code === "ShiftLeft"或者shiftRight

  2. e.key === "@"e.code === "Digit2"


但是实际在测试同学的电脑里,1是一样的,但是2变了,2变成了e.key === "Process"


虽然键盘事件有变化,但是在前端页面上的@字符是没有任何变化的。难怪他说他会突然失效了。我问他做了什么怎么会突然变了,他想了想说晚上从系统输入法换成了微信输入法.....


上网检索(chatGPT)了一番,明白了一个新的知识点:


输入法的全称叫Input Method Editor输入法编辑器(IME)。本质上它也是个编辑器。为了能输入各类字符(比如汉字和阿拉伯字等),IME会先处理用户的英文字母输入然后通过系统的底层调用传递给浏览器,浏览器再显示到用户的界面。这里的Process很大概率就是当时输入法给出的某个值表示那个时刻它还在处理中。


解决办法


既然KeyBoardEvent靠不住,那我们换一种监听方式。


我找到了一个非常适用于输入法的监听事件叫做CompositionEvent,它表示用户间接输入文本(如使用输入法)时发生的事件。此接口的常用事件有compositionstart, compositionupdatecompositionend。它们三个事件分别对应的动作,通俗一点说就是你用输入法开始打字、正在打字和结束打字。


于是乎,我监听compositionend不就行了!在输入法end的时候我再去看你end的字符是不是@不就行了!


// addEventListner('keydown', (e) => {
addEventListner('compositionend', (e) => {
// if (e.key === "@") {
if (e.data === "@") {
e.preventDefalut(); // 这里去掉@字符是为了后续插入和监听方便处理
// 插入有@字符并且可监听输入的元素
// 唤起小窗....
}
});

对于输入法来说,按键的up和down的key值就算不尽人意也没什么损失,毕竟用户毫无感知。但是,compositionend永远是不会错的,如果compositionende.data都不是@字符了,那么在用户的编辑器界面上的显示肯定也会跟着出错。


所以监听这个肯定就是万无一失的方法了,哈哈哈我真是个“天才”(蠢材)。
修改之后让测试同学尝试之后果然就可以了。


坑3:输入法继续坑


起因


时间过去了没一会,本天才就收到了另一个测试同学反馈的问题说为什么输入了一个@字符之后,会出现两个@在界面上?


我第一反应就是难道没有执行到e.preventDefalut()?既然后续功能能正常使用,没执行到也不应该啊🤔。然后在我电脑一通尝试,发现safari浏览器在输入法为中文的情况下也会触发这个问题。


于是,让测试同学的windows电脑连到我的开发环境debug一看(梅开二度):


执行到了,也没有报错什么的,但是@字符并没有被prevent掉🤯。


再加上我自己传入的@,所以界面上就出现了两个@字符。啊这这这,这很难评......


ceeb653ely8gzozgvjq1cg20j20hhgvb.gif


我是左思右想,百思不得其解,于是只能:



stack overflow上也有这个问题


上面大概的意思就是compositionend事件里使用 e.preventDefault() 在技术上可行的,但它可能不会产生你期望的效果。可能是因为 compositionend 事件标志着一次输入构成(composition)会话的结束,而在这个点上阻止默认行为可能没有意义,因为输入的流程已经完成了。


更推荐用keydown,compositionstartinput来处理这种情况。


keydown是不可能keydown了,已经被坑了。compositionstart也不行,因为刚开始输入那会才按下了shift键,@字符还没出来呢。那就只能input了。


解决办法


最开始我没有选择input就是因为它不能使用e.preventDefault()。我必须要对输入的字符串进行单独处理,去掉@,当时觉得很麻烦就没有选择这个方法。


额....好好好,行行行,现在还是必须得处理一下了。


// addEventListner('keydown', (e) => {
// addEventListner('compositionend', (e) => {
addEventListner('input', (e) => {
// if (e.key === "@") {
if (e.data === "@") {
?怎么去处理字符呢 // 这里去掉@字符是为了后续插入和监听方便处理
// 插入有@字符并且可监听输入的元素
// 唤起小窗....
}
});

对于这个处理字符的方法,也是一个新知识点了。起初我还想的是去处理编辑器里的content,然后再给它插入回去,这样子复杂度很高并且出错的概率极大。


这里的解决办法主要是使用CharacterData接口。CharacterData 接口是操作那些包含字符数据的节点的核心,特别是在需要动态地更改文本内容时。


例如,在一个文本节点上使用 deleteData() 方法可以从文本中移除一部分内容,而不必完全替换或重写整个节点


const selection = window.getSelection();
const { anchorNode, anchorOffset } = selection;
if (anchorNode.substringData(anchorOffset - 1, 1) === '@') {
selection.anchorNode.deleteData(anchorOffset - 1, 1);
}

写完这个之后,我用自己的safari浏览器测试发现果然没有问题了。


哈哈哈我真是个“天才”(蠢材)。


坑4:输入法深坑🕳️


我自信满满地让测试同学再重试一下😎,然后测试同学说:和之前一样啊,还是有两个@字符。


我:啊?啊啊??啊啊啊???


IMG_6547.jpg


于是,让测试同学的windows电脑连到我的开发环境debug一看(梅开三度):


发现测试同学电脑上的anchorOffset和正常的情况下是不一样的,会小一位,所以导致anchorOffset - 1拿到的前一个字符并不等于@,所以后续也没有把它处理掉🤯。


我是左思右想,百思不得其解,stack overflow上也没有相关的问题。不过,结合IME的概念,肯定还是输入法的问题。


结合之前keydown的e.key==="Processing",可能在input触发时输入法的编辑器其实还是没有完成工作(composition),导致在那个时候SelectionanchorOffset不一致。其实浏览器的Selection肯定不会错,那anchorOffset看起来像是错了,我觉得应该是输入法在转换的过程对我们的前端页面做了一些用户看不到的东西,而anchorOffset把它显化出来罢了。


解决办法


于是乎,我尝试性的对处理字符串的那串代码进行延时,目的是为了等待输入法彻底工作完毕。


// addEventListner('keydown', (e) => {
// addEventListner('compositionend', (e) => {
addEventListner('input', (e) => {
// if (e.key === "@") {
if (e.data === "@") {
setTimeout(() => {
const selection = window.getSelection();
const { anchorNode, anchorOffset } = selection;
if (anchorNode.substringData(anchorOffset - 1, 1) === '@') {
selection.anchorNode.deleteData(anchorOffset - 1, 1);
} // 这里去掉@字符是为了后续插入和监听方便处理
});

// 插入有@字符并且可监听输入的元素
// 唤起小窗....
}
});

然后,问题真的就彻底解决了。


这个功能做起来可太简单了......😅


作者:Liqiuyue
来源:juejin.cn/post/7307041255740981286
收起阅读 »

使用flex实现瀑布流

web
什么是瀑布流 瀑布流,又称瀑布流式布局。是比较流行的一种网站页面布局,视觉表现为参差不齐的多栏布局,随着页面滚动条向下滚动,这种布局还会不断加载数据块并附加至当前尾部。 特点: 固定宽度,高度不一 参差不齐的布局 使用flex实现瀑布流 实现的效果是分成两...
继续阅读 »

什么是瀑布流


瀑布流,又称瀑布流式布局。是比较流行的一种网站页面布局,视觉表现为参差不齐的多栏布局,随着页面滚动条向下滚动,这种布局还会不断加载数据块并附加至当前尾部。


特点:



  • 固定宽度,高度不一

  • 参差不齐的布局


使用flex实现瀑布流


实现的效果是分成两列的瀑布流,往下滑动会加载下一页的数据,并渲染到页面中!


微信图片_20230731231617.jpg


样式的实现


<view class="blessing-con">
<view class='blessing-con-half'>
<view id="leftHalf">
<view class="blessing-con-half-item" :class="bgColor[index % 3]"
v-for="(item, index) in newBlessingWordsList1" :key="index">
<view class="item-con">
</view>
</view>
</view>
</view>
<view class='blessing-con-half'>
<view id="rightHalf">
<view class="blessing-con-half-item" :class="bgColor[(index + 1) % 3]"
v-for="(item, index) in newBlessingWordsList2" :key="index">
<view class="item-con"></view>
</view>
</view>
</view>
</view>
<view class="blessing-more" @click="handlerMore">
<image v-if="hasWallNext" class="more-icon"
src="xx/blessingGame/arr-down.png">
</image>
<view class="blessing-more-text">{{ blessingStatus }}</view>
</view>

.blessing-con 定义外层容器为flex布局,同时设置主轴对齐方式为space-between


.blessing-con-half定义左右两侧的容器的样式


.blessing-con-half-item定义每一个小盒子的样式


.blessing-con {
padding: 32rpx 20rpx;
display: flex;
justify-content: space-between;
height: 1100rpx;
overflow-y: auto;
.blessing-con-half {
width: 320rpx;
height: 100%;
box-sizing: border-box;
.blessing-con-half-item {
width: 100%;
height: auto;
display: flex;
flex-direction: column;
box-sizing: border-box;
margin: 0 0 24rpx;
position: relative;
}
}
}

这里每个小盒子的背景色按蓝-黄-红的顺序,同时通过伪类给盒子顶部添加锯齿图片,实现锯齿效果


bgColor: ['blueCol', 'yellowCol', 'pinkCol'], //祝福墙背景

// 不同颜色
.blessing-con-half-item {
&.pinkCol {
&::before {
.setbg(320rpx, 16rpx, 'blessingGame/pink-bg.png');
}
.item-con {
background: #FFE7DF;
}
}

&.yellowCol {
&::before {
.setbg(320rpx, 16rpx, 'blessingGame/orange-bg.png');
}
.item-con {
background: #fff0e0;
}
}

&.blueCol {
&::before {
.setbg(320rpx, 16rpx, 'blessingGame/blue-bg.png');
}
.item-con {
background: #e0f7ff;
}
}
}
}

功能实现


在data中定义两个数组存储左右列表的数据


data(){
return{
blessingWordsList: [],// 祝福墙数据
newBlessingWordsList: [],//已添加的数据
newBlessingWordsList1: [],//左列表
newBlessingWordsList2: [],//右列表
isloading:false,//是否正在加载
hasWallNext:false,//是否有下一页
leftHeight: 0,//左高度
rightHeight: 0,//右高度
blessingWordsCount: 0,//计数器
isActive: 0, //tab初始化索引
timer:null,//定时器
}
}

调取接口请求列表数据



  • 第一次请求数据需要初始化列表数据和计数器

  • 每一次请求完都需要开启定时器


// 获取祝福墙列表(type=1则请求下一页)
async getBlessingWall(type = 0) {
try {
let res = await api.blessingWall({
activityId: this.activityId,
pageNum: this.pageWallNum,
pageSize: this.pageWallSize
})
this.isloading = false
if (res.code == 1 && res.rows) {
let list = res.rows
this.blessingWordsList = (type==0 ? list : [...this.blessingWordsList, ...list])
if (this.blessingWordsList.length > 0 && !this.timer && this.isActive == 1) {
if(this.pageWallNum == 1){
this.newBlessingWordsList = []
this.newBlessingWordsList1 = []
this.newBlessingWordsList2 = []
this.blessingWordsCount = 0
}
this.start()
}
// 处理请求下一页的情况
if (type == 1) {
this.start()
}
this.hasWallNext = res.hasNext
if (!this.hasWallNext) {
this.blessingStatus = "没有更多了哟"
} else {
this.blessingStatus = "点击加载更多"
}
}
} catch (error) {
console.log(error)
}
},
// 加载更多
async handlerMore() {
if (this.hasWallNext && !this.isloading) {
this.isloading = true
this.pageWallNum++
await this.getBlessingWall(1)
}
},

开启一个定时器,用于动态添加左右列表的数据


start() {
// 清除定时器
clearInterval(this.timer)
this.timer = null;

this.timer = setInterval(() => {
let len = this.blessingWordsList.length
if (this.blessingWordsCount < len) {
let isHave = false
// 在列表中获取一个元素
let item =this.blessingWordsList[this.blessingWordsCount]
// 判断新列表中是否已经存在相同元素,防止重复添加
this.newBlessingWordsList.forEach((tmp)=>{
if(tmp.id == item.id){
isHave = true
}
})
// 如果不存在
if (!isHave) {
this.newBlessingWordsList.push(item)//添加该元素
this.$nextTick(() => {
this.getHei(item)//添加元素到左右列表
})
}
} else {
// 遍历完列表中的数据,则清除定时器
clearInterval(this.timer)
this.timer = null;
}
}, 10)
}

计算当前左右容器的高度,判断数据要添加到哪一边



  • 使用uni-app的方法获取左右容器的dom对象,再获取他们当前的高度

  • 比较左右高度,向两个数组动态插入数据

  • 每插入一条数据,计数器+1


getHei(item) {
const query = uni.createSelectorQuery().in(this)
// 左边
query.select('#leftHalf').boundingClientRect(res => {
if (res) {
this.leftHeight = res.height
}
// 右边
const query1 = uni.createSelectorQuery().in(this)
query1.select('#rightHalf').boundingClientRect(dataRight => {
if (dataRight) {
this.rightHeight = dataRight.height != 0 ? dataRight.height : 0
if (this.leftHeight == this.rightHeight || this.leftHeight < this.rightHeight) {
// 相等 || 左边小
this.newBlessingWordsList1.push(item)
} else {
// 右边小
this.newBlessingWordsList2.push(item)
}
}
this.blessingWordsCount++
}).exec()
}).exec()
},

这里有一个注意点,调用start方法的时候,必须确保页面渲染了左右容器的元素,否则会拿不到容器的高度


比如我这个项目是有tab切换的!


微信图片_20230731231616.jpg
进入页面的时候会请求一次数据,这时候因为tab初始状态在0,所以并不会调用start方法,要到切换tab到1时,才会调用start方法开始计算高度。


data(){
return{
isActive: 0, //tab初始化索引
timer:null,//定时器
}
}
async onLoad(options) {
this.getBlessingWall()
}
// tab选项卡切换
tabClick(index) {
this.isActive = index
this.isLoaded = false;
if (this.blessingWordsList.length > 0 && !this.timer && this.isActive == 1) {
if(this.pageWallNum == 1){
this.newBlessingWordsList = []
this.newBlessingWordsList1 = []
this.newBlessingWordsList2 = []
this.blessingWordsCount = 0
}
this.start()
}
},

最后


这次选用了flex实现瀑布流,实现瀑布流的方式还有其他几种方法,后续有机会的话,我会补充其他几种方式,如果感兴趣的话,可以点点关注哦!


作者:藤原豆腐店
来源:juejin.cn/post/7260713996165021754
收起阅读 »

前端同事最讨厌的后端行为,看看你中了没有

web
前端同事最讨厌的后端行为,看看你中了没有 听说这是前端程序员最讨厌的后端行为,不知道你有没有碰到过,或者你的前端同事虽然没跟你说过,但是你已经被偷偷吐槽了。 前端吐槽:后端从不自测接口,等到前后端联调时,这个接口获取不到,那个接口提交不了,把前端当成自己...
继续阅读 »

前端同事最讨厌的后端行为,看看你中了没有



听说这是前端程序员最讨厌的后端行为,不知道你有没有碰到过,或者你的前端同事虽然没跟你说过,但是你已经被偷偷吐槽了。




前端吐槽:后端从不自测接口,等到前后端联调时,这个接口获取不到,那个接口提交不了,把前端当成自己的接口测试员,耽误前端的开发进度。



听到这个吐槽,仿佛看到曾经羞愧的自己。这个毛病以前我也有啊,有些接口,尤其是大表单提交接口,表单特别大,字段很多,有时候就偷懒了,直接编译过了,就发到测试环境了。前端同时联调的时候一调接口,异常了。


好在后来改了,毕竟让人发现自己接口写的有问题,也是一件丢脸的事儿。


但是我还真见过后端的同学,写完接口一个都不测,直接发测试环境的。


我就碰到过厉害的,编译都不过,就直接提代码。以前,有个新来的同事,分了任务就默默的干着,啥也不问,然后他做的功能测试就各种发现问题。说过之后,就改一下,但是基本上还是不测试,本想再给他机会的,所以后来他每次提代码,我都review一下。直到有一天,我发现忍不了了,他把一段全局配置给注释了,然后把代码提了,我过去问他是不是本地调试,忘了取消注释了。他的回答直接让我震惊了,他说:不是的,是因为不注释那段代码,我本地跑步起来,所以肯定是那段代码有问题,所以就注释了。


然后,当晚,他就离职了。


解决方式


对于这种大表单类似的问题,应该怎么处理呢?


好像没有别的方法,只能克服自己的懒惰,为自己写的代码负责。就想着,万一接口有问题,别人可能会怀疑你水平不行,你水平不行,就是你不行啊,程序员怎么能不行呢。


你可以找那么在线 Java BeanJSON的功能,直接帮你生成请求参数,或者现在更可以借助 ChatGPT ,帮你生成请求参数,而且生成的参数可能比你自己瞎填的看上去更合理。


或者,如果是小团队,不拘一格的话,可以让前端的同事把代码提了,你本地跑着自测一下,让前端同事先做别的功能,穿插进行也可以。



前端吐槽:后端修改了字段或返回结构不通知前端



这个就有点不讲武德了。


正常情况下,返回结构和字段都是事先约定好的,一般都是先写接口,做一些 Mock 数据,然后再实现真实的逻辑。


除了约定好返回字段和结构外,还包括接口地址、请求方法、头信息等等,而且一个项目都会有项目接口规范,同一类接口的返回字段可能有很多相同的部分。


后端如果改接口,必须要及时通知前端,这其实应该是正常的开发流程。后端改了接口,不告诉前端,到时候测试出问题了,一般都会先找前端,这不相当于让前端背锅了吗,确实不地道啊。


后端的同学们,谨记啊。



前端吐槽:为了获取一个信息,要先调用好几个接口,可能参数还是相同的



假设在一个详情页面,以前端的角度就是,我获取详情信息,就调用详情接口好了,为什么调用详情接口之前,要调用3、4个其他的接口,你详情里需要啥参数,我直接给你传过去不就好了吗。


在后端看来可能是这样的,我这几个接口之前就写好了,前端拿过去就能用,只不过就是多调几次罢了,没什么大不了的吧。


有些时候,可能确实是必须这么做的,比如页面内容太多,有的部分查询逻辑复杂,比较耗时,这时候需要异步加载,这样搞确实比较好。


但是更多时候其实就是后端犯懒了,不想再写个新接口。除了涉及到性能的问题,大多数逻辑都应该在后端处理,能用一个接口处理完,就不应该让前端多调用第二个接口。


有前端的朋友曾经问过我,他说,他们现在做的系统中有些接口是根据用户身份来展示数据的,但是前端调用登录接口登录系统后,在调用其他接口的时候,除了在 Header 中加入 token 外,还有传很多关于用户信息的很多参数,这样做是不是不合理的。


这肯定不合理,token 本来就是根据用户身份产生的,后端拿到 token 就能获取用户信息,这是常识问题,让前端在接口中再传一遍,既不合理也不安全。


类似的问题还有,比如后端接口返回一堆数据,然后有的部分有用、有的部分没有,有的部分还涉及到逻辑,不借助文档根本就看不明白怎么用,这其实并不合理。


接口应该尽量只包含有用的部分,并且尽可能结构清晰,配合简单的字段说明就能让人明白是怎么回事,是最好的效果。


如果前后端都感觉形势不对了,后端一个接口处理性能跟不上了,前端处理又太麻烦了。这时候就要向上看了,产品设计上可能需要改一改了。


后端的同学可以学一点前端,前端的同学也可以学一点后端,当你都懂一些的时候,就能两方面考虑了,这样做出来的东西可能会更好用一点。总之,前后端相互理解,毕竟都是为了生活嘛。


作者:古时的风筝
来源:juejin.cn/post/7254927062425829413
收起阅读 »

面试官:你能说说常见的前端加密方法吗?

web
前言 本篇文章略微介绍一下前端中常见的加密算法。前端中常见的加密算法主要形式包括——哈希函数,对称加密和非对称加密算法。 一、哈希函数 定义:哈希也叫散列,是指将任意长度的消息映射为固定长度的输出的算法,该输出一般叫做散列值或者哈希值,也叫做摘要(Dige...
继续阅读 »

前言


本篇文章略微介绍一下前端中常见的加密算法。前端中常见的加密算法主要形式包括——哈希函数,对称加密和非对称加密算法。


一、哈希函数


image.png



  • 定义:哈希也叫散列,是指将任意长度的消息映射为固定长度的输出的算法,该输出一般叫做散列值或者哈希值,也叫做摘要(Digest)。简单来说,这种映射就是一种数据压缩,而且散列是不可逆的,也就是无法通过输出还原输入。

  • 特点:不可逆性(单向性)、抗碰撞性(消息不同其散列值也不同)、长度固定

  • 常见应用场景:由于不可逆性,常用于密码存储、数字签名、电子邮件验证、验证下载等方面,更多的是用用在验证数据的完整性方面。



    • 密码存储:明文保存密码是危险的。通常我们把密码哈希加密之后保存,这样即使泄漏了密码,因为是散列后的值,也没有办法推导出密码明文(字典攻击难以破解)。验证的时候,只需要对密码(明文)做同样的散列,对比散列后的输出和保存的密码散列值,就可以验证同一性。

    • 可用于验证下载文件的完整性以及防篡改:比如网站提供安装包的时候,通常也同时提供md5值,这样用户下载之后,可以重算安装包的md5值,如果一致,则证明下载到本地的安装包跟网站提供的安装包是一致的,网络传输过程中没有出错。



  • 优势:不可逆,速度快、存储体积小,可以帮助保护数据的完整性和减轻篡改风险。

  • 缺点:安全性不高、容易受到暴力破解


image.png


常见类型:SHA-512、SHA-256、MD5(MD5生成的散列码是128位)等。



  • MD5(Message Digest Algorithm 5) :是RSA数据安全公司开发的一种单向散列算法,非可逆,相同的明文产生相同的密文。

  • SHA(Secure Hash Algorithm) :可以对任意长度的数据运算生成一个固定位数的数值。

  • SHA/MD5对比:SHA在安全性方面优于MD5,并且可以选择多种不同的密钥长度。 但是,由于内存需求更高,运行速度可能会更慢。 不过,MD5因其速度而得到广泛使用,但是由于存在碰撞攻击风险,因此不再推荐使用。


二、对称加密



  • 定义:指加密和解密使用同一种密钥的算法。


image.png



  • 特点:优点是速度快,通信效率高;缺点是安全性相对较低。信息传输使用一对一,需要共享相同的密码,密码的安全是保证信息安全的基础,服务器和N个客户端通信,需要维持N个密码记录且不能修改密码。

  • 优势:效率高,算法简单,系统开销小,速度快,适合大数量级的加解密,安全性中等

  • 缺点:秘钥管理比较难,密钥存在泄漏风险。

  • 常见应用场景:适用于需要高速加密/解密的场景,例如 HTTP 传输的 SSL/TLS 部分,适用于加密大量数据,如文件加密、网络通信加密、数据加密、电子邮件、Web 聊天等。



    • 文件加密:将文件用相同的密钥加密后传输或存储,只有拥有密钥的用户才能解密文件。

    • 数据库加密:对数据库中的敏感信息进行加密保护,防止未经授权的人员访问。

    • 通信加密:将网络数据通过对称加密算法进行加密,确保数据传输的机密性,比较适合大量短消息的加密和解密。

    • 个人硬盘加密:对称加密可以为硬盘加密提供较好的安全性和高处理速度,这对个人电脑而言可能是一个不错的选择。



  • 常见类型DES,3DES,AES 等:



    • DES(Data Encryption Standard):分组式加密算法,以64位为分组对数据加密,加解密使用同一个算法,速度较快,适用于加密大量数据的场合。

    • 3DES(Triple DES):三重数据加密算法,是基于DES,对每个数据块应用三次DES加密算法,强度更高。

    • AES(Advanced Encryption Standard):高级加密标准算法,速度快,安全级别高,目前已被广泛应用,适用于加密大量数据,如文件加密、网络通信加密等。




AES与DES区别

AES与DES之间的主要区别在于加密过程。在DES中,将明文分为两半,然后再进行进一步处理;而在AES中,整个块不进行除法,整个块一起处理以生成密文。相对而言,AES比DES快得多,与DES相比,AES能够在几秒钟内加密大型文件。



  • DES



    • 优点:DES算法具有极高安全性,到目前为止,除了用穷举搜索法对DES算法进行攻击外,还没有发现更有效的办法。

    • 缺点:分组比较短、密钥太短、密码生命周期短、运算速度较慢。



  • AES



    • 优点:运算速度快,对内存的需求非常低,适合于受限环境。分组长度和密钥长度设计灵活, AES标准支持可变分组长度;具有很好的抵抗差分密码分析及线性密码分析的能力。

    • 缺点:目前尚未存在对AES 算法完整版的成功攻击,但已经提出对其简化算法的攻击。




三、非对称加密


-定义:指加密和解密使用不同密钥的算法,通常情况下使用公共密钥进行加密,而私有密钥用于解密数据。公钥和私钥是成对存在,公钥是从私钥中提取产生公开给所有人的,如果使用公钥对数据进行加密,那么只有对应的私钥(不能公开)才能解密,反之亦然。


image.png



  • 特点:缺点是加密解密速度较慢,通信效率较低,优点是安全性高,需要两个不同密钥,信息一对多。因为它使用的是不同的密钥,所以需要耗费更多的计算资源。服务器只需要维持一个私钥就可以和多个客户端进行通信,但服务器发出的信息能够被所有的客户端解密,且该算法的计算复杂,加密的速度慢。

  • 优势:秘钥容易管理,不存在密钥的交换问题,安全性好,主要用在数字签名,更适用于区块链技术的点对点之间交易的安全性与可信性。

  • 缺点:加解密的计算量大,比对称加密算法计算复杂,性能消耗高,速度慢,适合小数据量或数据签名

  • 常见应用场景:在实际应用中,非对称加密通常用于需要确保数据完整性和安全性的场合,例如数字证书的颁发、SSL/TLS 协议的加密、数字签名、加密小文件、密钥交换、实现安全的远程通信等。



    • 数字签名:数字签名是为了保证数据的真实性和完整性,通常使用非对称加密实现。发送方使用自己的私钥对数据进行签名,接收方使用发送方的公钥对签名进行验证,如果验证通过,则可以确认数据的来源和完整性。常见的数字签名算法都基于非对称加密,如RSA、DSA等。

    • ** 身份认证**:Web浏览器和服务器使用SSL/TLS技术来进行安全通信,其中就使用了非对称加密技术。Web浏览器在与服务器建立连接时,会对服务器进行身份验证并请求其证书。服务器将其证书发送给浏览器,证书包含服务器的公钥。浏览器使用该公钥来加密随机生成的“对话密钥”,然后将其发送回服务器。服务器使用自己的私钥解密此“对话密钥”,以确保双方之间的会话是安全的。

    • 安全电子邮件:非对称加密可用于电子邮件中,确保邮件内容只能由预期的收件人看到。发件人使用收件人的公钥对邮件进行加密,收件人使用自己的私钥对其进行解密。这确保了只有目标收件人才能读取邮件。



  • 常见类型RSA,DSA,DSS,ECC 等



    • RSA:由 RSA 公司发明,是一个支持变长密钥的公共密钥算法,需要加密的文件块的长度也是可变的。RSA 是一种非对称加密算法,即加密和解密使用一对不同的密钥,分别称为公钥和私钥。公钥用于加密数据,私钥用于解密数据。RSA 算法的安全性基于大数分解问题,密钥长度通常选择 1024 位、2048 位或更长。RSA 算法用于保护数据的机密性、确保数据的完整性和实现数字签名等功能。

    • DSA(Digital Signature Algorithm) :数字签名算法,仅能用于签名,不能用于加解密。

    • ECC(Elliptic Curves Cryptography) :椭圆曲线密码编码学。

    • DSS:数字签名标准,可用于签名,也可以用于加解密。




总结


前端使用非对称加密原理很简单,平时用的比较多的也是非对称加密,前后端共用一套加密解密算法,前端使用公钥对数据加密,后端使用私钥将数据解密为明文。中间攻击人拿到密文,如果没有私钥的话是没办法破解的。


欢迎大佬继续评论区补充


作者:优秀稳妥的Zn
来源:juejin.cn/post/7280057907055919144
收起阅读 »

你的网站如何接入QQ,微信登录

web
主要实现步骤 对接第三方平台,获取第三方平台的用户信息。 利用该用户信息,完成本应用的注册。 qq登录接入 接入前的配置 qq互联 登录后,点击头像,进行开发者信息填写,等待审核。 邮箱验证后,等待审核。 审核通过后,然后就可以创建应用了。 然后填写...
继续阅读 »

主要实现步骤



  • 对接第三方平台,获取第三方平台的用户信息。

  • 利用该用户信息,完成本应用的注册。


qq登录接入


接入前的配置


qq互联


登录后,点击头像,进行开发者信息填写,等待审核。


image.png


邮箱验证后,等待审核。


image.png


审核通过后,然后就可以创建应用了。


image.png


然后填写一些网站信息,等待审核。审核通过后,即可使用。


开始接入



  1. 导入qq登录的sdk



<script type="text/javascript" charset="utf-8" src="https://connect.qq.com/qc_jssdk.js" data-appid="您应用的appid"
data-redirecturi="qq扫码后的回调地址(上面配置中可以查到)">
script>


  1. 点击qq登录,弹出扫码窗口。


// QQ 登录的 URL
const QQ_LOGIN_URL =
'https://graph.qq.com/oauth2.0/authorize?client_id=您的appid&response_type=token&scope=all&redirect_uri=您的扫码后的回调地址'
window.open(
QQ_LOGIN_URL,
'oauth2Login_10609',
'height=525,width=585, toolbar=no, menubar=no, scrollbars=no, status=no, location=yes, resizable=yes'
)


  1. 挂起qq登录。需要注意的是,扫码登录成功后,调试代码需要在线上环境。


"qqLoginBtn" v-show="false">

// QQ 登录挂起
onMounted(() => {
QC.Login(
{
btnId: 'qqLoginBtn' //插入按钮的节点id
},
// 登录成功之后的回调,但是需要注意,这个回调只会在《登录回调页面中被执行》
// 登录存在缓存,登录成功一次之后,下次进入会自动重新登录(即:触发该方法,所以我们应该在离开登录页面时,注销登录)
// data就是当前qq的详细信息
(data, opts) => {
console.log('QQ登录成功')
// 1. 注销登录,否则在后续登录中会直接触发该回调
QC.Login.signOut()
// 2. 获取当前用户唯一标识,作为判断用户是否已注册的依据。(来决定是否跳转到注册页面)
const accessToken = /access_token=((.*))&expires_in/.exec(
window.location.hash
)[1]
// 3. 拼接请求对象
const oauthObj = {
nickname: data.nickname,
figureurl_qq_2: data.figureurl_qq_2,
accessToken
}
// 4. 完成跨页面传输 (需要将数据传递到项目页面,而非qq登录弹框页面中进行操作)
brodacast.send(oauthObj)

// 针对于 移动端而言:通过移动端触发 QQ 登录会展示三个页面,原页面、QQ 吊起页面、回调页面。并且移动端一个页面展示整屏内容,且无法直接通过 window.close() 关闭,所以在移动端中,我们需要在当前页面继续进行后续操作。
oauthLogin(LOGIN_TYPE_QQ, oauthObj)
// 5. 在 PC 端下,关闭第三方窗口
window.close()
}
)
})


  1. 跨页面窗口通信


想要实现跨页面信息传输,通常有两种方式:



  • BroadcastChannel:允许 同源 的不同浏览器窗口,Tab页,frame或者 iframe 下的不同文档之间相互通信。但是会存在兼容性问题。

  • localStorage + window.onstorage:通过localStorage 进行 同源 的数据传输。用来处理 BroadcastChannel 不兼容的浏览器。以前写过一篇文章


// brodacast.js
// 频道名
const LOGIN_SUCCESS_CHANNEL = 'LOGIN_SUCCESS_CHANNEL'

// safari@15.3 不支持 BroadcastChannel,所以我们需要对其进行判定使用,在不支持 BroadcastChannel 的浏览器中,使用 localstorage
let broadcastChannel = null
if (window.BroadcastChannel) {
broadcastChannel = new BroadcastChannel(LOGIN_SUCCESS_CHANNEL)
}

/**
* 等待 QQ 登录成功
* 因为 QQ 登录会在一个新的窗口中进行,用户扫码登录成功之后会回调《新窗口的 QC.Login 第二参数 cb》,而不会回调到原页面。
* 所以我们需要在《新窗口中通知到原页面》,所以就需要涉及到 JS 的跨页面通讯,而跨页面通讯指的主要就是《同源页面的通讯》
* 同源页面的通讯方式有很多,我们这里主要介绍:
* 1. BroadcastChannel ->
https://developer.mozilla.org/zh-CN/docs/Web/API/BroadcastChannel

* 2. window.onstorage:注意:该事件不在导致数据变化的当前页面触发
*/

/**
* 等待回调,它将返回一个 promise,并携带对应的数据
*/

const wait = () => {
return new Promise((resolve, reject) => {
if (broadcastChannel) {
// 触发 message 事件时的回调函数
broadcastChannel.onmessage = async (event) => {
// 改变 promise 状态
resolve(event.data)
}
} else {
// 触发 localStorage 的 setItem 事件时回调函数
window.onstorage = (e) => {
// 判断当前的事件名
if (e.key === LOGIN_SUCCESS_CHANNEL) {
// 改变 promise 状态
resolve(JSON.parse(e.newValue))
}
}
}
})
}

/**
* 发送消息。
* broadcastChannel:触发 message
* localStorage:触发 setItem
*/

const send = (data) => {
if (broadcastChannel) {
broadcastChannel.postMessage(data)
} else {
localStorage.setItem(LOGIN_SUCCESS_CHANNEL, JSON.stringify(data))
}
}

/**
* 清除
*/

const clear = () => {
if (broadcastChannel) {
broadcastChannel.close()
broadcastChannel = null
}
localStorage.removeItem(LOGIN_SUCCESS_CHANNEL)
}

export default {
wait,
send,
clear
}


  1. 拿到数据后,进行登录(自己服务器登录接口)操作。



  • 传入对应参数(loginType, accessToken)等参数进行用户注册判断。

  • 通过accessToken判断用户已经注册,那么我们就直接在后台查出用户名和密码直接登录了。

  • 通过accessToken判断用户未注册,那么我们将跳转到注册页面,让其注册。


 // 打开视窗之后开始等待
brodacast.wait().then(async (oauthObj) => {
// 登录成功,关闭通知
brodacast.clear()
// TODO: 执行登录操作
oauthLogin("QQ", oauthObj)
})

// oauthLogin.js
import store from '@/store'
import router from '@/router'
import { message } from '@/libs'
import { LOGIN_TYPE_OAUTH_NO_REGISTER_CODE } from '@/constants'

/**
* 第三方登录统一处理方法
*
@param {*} oauthType 登录方式
*
@param {*} oauthData 第三方数据
*/

export const oauthLogin = async (oauthType, oauthData) => {
const code = await store.dispatch('user/login', {
loginType: oauthType,
...oauthData
})
// 返回 204 表示当前用户未注册,此时给用户一个提示,走注册页面
if (code === LOGIN_TYPE_OAUTH_NO_REGISTER_CODE) {
message('success', `欢迎您 ${oauthData.nickname},请创建您的账号`, 6000)
// 进入注册页面,同时携带当前的第三方数据和注册标记
router.push({
path: '/register',
query: {
reqType: oauthType,
...oauthData
}
})
return
}

// 否则表示用户已注册,直接进入首页
router.push('/')
}

微信扫码登录接入


微信开放平台


登录后,进行对应的应用注册,填写一大堆详细信息,然后进行交钱,就可以使用微信登录了。


image.png


开始接入


整个微信登录流程与QQ登录流程略有不同,分为以下几步:


1.通过 微信登录前置数据获取 接口,获取登录数据(比如 APP ID)。就是后台将一些敏感数据通过接口返回。


2.根据获取到的数据,拼接得到 open url 地址。打开该地址,展示微信登录二维码。移动端微信扫码确定登录。


// 2. 根据获取到的数据,拼接得到 `open url` 地址
window.open(
`https://open.weixin.qq.com/connect/qrconnect?appid=${appId}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}&state=${state}#wechat_redirect`,
'',
'height=525,width=585, toolbar=no, menubar=no, scrollbars=no, status=no, location=yes, resizable=yes'
)

3.等待用户扫码后,从当前窗口中解析 window.location.search 得到用户的 code数据。 微信扫码后,会重定向到登录页面。


/**
* 微信登录成功之后的窗口数据解析
*/

if (window.location.search) {
const code = /code=((.*))&state/.exec(window.location.search)[1]
if (code) {
brodacast.send({
code
})
// 关闭回调网页
window.close()
}
}

4.根据 appId、appSecret、code 通过接口获取用户的 access_token


5.根据 access_token 获取用户信息


6.通过用户信息触发 oauthLogin 方法。


调用的接口,都是后端通过微信提供的api来获取到对应的数据,然后再通过接口返回给开发者。  以前也写过微信登录文章


// 等待扫码登录成功通知
brodacast.wait().then(async ({ code }) => {
console.log('微信扫码登录成功')
console.log(code)
// 微信登录成功,关闭通知
brodacast.clear()
// 获取 AccessToken 和 openid
const { access_token, openid } = await getWXLoginToken(
appId,
appSecret,
code
)
// 获取登录用户信息
const { nickname, headimgurl } = await getWXLoginUserInfo(
access_token,
openid
)
console.log(nickname, headimgurl)
// 执行登录操作
oauthLogin(LOGIN_TYPE_WX, {
openid,
nickname,
headimgurl
})
})

需要注意的是,在手机端,普通h5页面是不能使用微信扫码登录的。


总结


相同点



  • 接入前需要配置一些内容信息。

  • 都需要在线上环境进行调试。

  • 都是扫码后在三方窗口中获取对应的信息,发送到当前项目页面进行请求,判断用户是否已经注册,还是未注册。已经注册时,调用login接口时,password直接传递空字符串即可,后端可以通过唯一标识,获取到对应的用户名和密码,直接返回token进行登录。未注册,就跳转到注册页面,让其注册。


不同点



  • qq接入需要导入qc_sdk。

  • qq直接扫码后即可获取到用户信息,就可以直接调用login接口进行判断用户是否注册了。

  • 微信扫码后,获取code来换取access_token, openid,然后再通过access_token, openid来换取用户信息。然后再调用login接口进行判断用户是否注册了。


作者:Spirited_Away
来源:juejin.cn/post/7311343161363234866
收起阅读 »

HTML问题:如何实现分享URL预览?

web
前端功能问题系列文章,点击上方合集↑ 序言 大家好,我是大澈! 本文约2100+字,整篇阅读大约需要3分钟。 本文主要内容分三部分,如果您只需要解决问题,请阅读第一、二部分即可。如果您有更多时间,进一步学习问题相关知识点,请阅读至第三部分。 感谢关注微信公众号...
继续阅读 »

前端功能问题系列文章,点击上方合集↑


序言


大家好,我是大澈!


本文约2100+字,整篇阅读大约需要3分钟。


本文主要内容分三部分,如果您只需要解决问题,请阅读第一、二部分即可。如果您有更多时间,进一步学习问题相关知识点,请阅读至第三部分。


感谢关注微信公众号:“程序员大澈”,然后加入问答群,从此让解决问题的你不再孤单!


1. 需求分析


为了提高用户对页面链接分享的体验,需要对分享链接做一些处理。


以 Telegram(国外某一通讯软件) 为例,当在 Telegram 上分享已做过处理的链接时,它会自动尝试获取链接的预览信息,包括标题、描述和图片。


如此当接收者看到时,可以立即获取到分享链接的一些重要信息。这有助于接收者更好地了解链接的内容,决定是否点击查看详细内容。


图片


2. 实现步骤


2.1 实现前的说明


对于URL分享预览这个功能问题,在项目中挺常用的,只不过今天我们是以一些框架分享API的底层原理角度来讲的。


实现这种功能的关键,是在分享的链接中嵌入适当的元数据信息,应用软件会自动解析,请求分享链接的预览信息,并根据返回的元数据生成预览卡片。


对于国内的应用软件,目前我试过抖音,它可以实现分享和复制粘贴都自动解析,而微信、QQ等只能实现分享的自动解析。


对于国外的应用软件,我只实验过Telegram,它可以实现分享和复制粘贴都自动解析,但我想FacebookTwitterInstagram这些应用应该也都是可以的。


2.2 实现代码


实现URL链接的分享预览,你可以使用 Open Graph协议或 Twitter Cards,然后在 HTML 的 标签中,添加以下 meta 标签来定义链接预览的信息。


使用时,将所有meta全部复制过去,然后根据需求进行自定义即可。


还要注意两点,确保你页面的服务器正确配置了 SSL 证书,以及确保链接的URL有效(即:服务器没有做白名单限制)。


<head>
  
  <meta property="og:title" content="预览标题">
  <meta property="og:description" content="预览描述">
  <meta property="og:image:width" content="图片宽度">
  <meta property="og:image:height" content="图片高度">
  <meta property="og:image" content="预览图片的URL">
  <meta property="og:url" content="链接的URL">
  
  
  <meta name="twitter:card" content="summary">
  <meta name="twitter:title" content="预览标题">
  <meta name="twitter:description" content="预览描述">
  <meta property="twitter:image:width" content="图片宽度">
  <meta property="twitter:image:height" content="图片高度">
  <meta name="twitter:image" content="预览图片的URL">
  <meta name="twitter:url" content="链接的URL">
head>

下面我们做一些概念的整理、总结和学习。


3. 问题详解


3.1 什么是Open Graph协议?


Open Graph协议是一种用于在社交媒体平台上定义和传递网页元数据的协议。它由 Facebook 提出,并得到了其他社交媒体平台的支持和采纳。Open Graph 协议旨在标准化网页上的元数据,使网页在社交媒体上的分享和预览更加一致和可控。


通过在网页的 HTML  标签中添加特定的 meta 标签,使用 Open Graph 协议可以定义和传递与网页相关的元数据信息,如标题、描述、图片等。这些元数据信息可以被社交媒体平台解析和使用,用于生成链接预览、分享内容和提供更丰富的社交图谱。


使用 Open Graph 协议,网页的所有者可以控制链接在社交媒体上的预览内容,确保链接在分享时显示的标题、描述和图片等信息准确、有吸引力,并能够准确传达链接的主题和内容。这有助于提高链接的点击率、转化率和用户体验。


Open Graph 协议定义了一组标准的 meta 标签属性,如 og:titleog:descriptionog:image 等,用于提供链接预览所需的元数据信息。通过在网页中添加这些 meta 标签并设置相应的属性值,可以实现链接预览在社交媒体平台上的一致展示。


需要注意的是,Open Graph 协议是一种开放的标准,并不限于 Facebook 平台。其他社交媒体平台,如 Twitter、LinkedIn 等,也支持使用 Open Graph 协议定义和传递网页元数据,以实现链接预览的一致性。


图片


3.2 什么是Twitter Cards?


Twitter Cards 是一种由 Twitter 推出的功能,它允许网站所有者在他们的网页上定义和传递特定的元数据,以便在 Twitter 上分享链接时生成更丰富和吸引人的预览卡片。通过使用 Twitter Cards,网页链接在 Twitter 上的分享可以展示标题、描述、图片、链接和其他相关信息,以提供更具吸引力和信息丰富的链接预览。


Twitter Cards 提供了多种类型的卡片,以适应不同类型的内容和需求。以下是 Twitter Cards 的一些常见类型:



  • Summary CardSummary Card 类型的卡片包含一个标题、描述和可选的图片。它适用于分享文章、博客帖子等内容。

  • Summary Card with Large ImageSummary Card with Large Image 类型的卡片与 Summary Card 类型类似,但图片尺寸更大,更突出地展示在卡片上。

  • App CardApp Card 类型的卡片用于分享移动应用程序的信息。它包含应用的名称、图标、描述和下载按钮,以便用户可以直接从预览卡片中下载应用。

  • Player CardPlayer Card 类型的卡片用于分享包含媒体播放器的内容,如音频文件、视频等。它允许在预览卡片上直接播放媒体内容。


通过在网页的 HTML  标签中添加特定的 meta 标签,使用 Twitter Cards 可以定义和传递与链接预览相关的元数据信息,如标题、描述、图片、链接等。这些元数据信息将被 Twitter 解析和使用,用于生成链接预览卡片。


使用 Twitter Cards 可以使链接在 Twitter 上的分享更加吸引人和信息丰富,提高链接的点击率和用户参与度。它为网站所有者提供了更多控制链接在 Twitter 上展示的能力,并提供了一种更好的方式来呈现他们的内容。


图片


图片


作者:程序员大澈
来源:juejin.cn/post/7310112330663231515
收起阅读 »

大厂是怎么封装api层的?ts,axios 基于网易公开课

web
先看一下使用方法 先声明,这套写法是我在一次网易公开课学来的,说是大厂内部是怎么封装请求的,本人只是给它改成ts版本。 挺香的。 上核心代码 代码一:utils/request/getrequest.ts import axios, { type Axi...
继续阅读 »

先看一下使用方法
请求封装2.png


先声明,这套写法是我在一次网易公开课学来的,说是大厂内部是怎么封装请求的,本人只是给它改成ts版本。
挺香的。


上核心代码


WX20231124-143933@2x.png


代码一:utils/request/getrequest.ts



import axios, { type AxiosRequestConfig, type CancelTokenSource } from "axios";
import { manualStopProgram } from '@/utils/index';

import server from "./server";
import type { RequestConfig, ApiRouter, ServerRes } from './server.types'

class Requestextends keyof ApiRouter = keyof ApiRouter> {
requestRouter: ApiRouter = {} as ApiRouter
requestTimes = 0;
requestMap: Record<string, CancelTokenSource> = {};
toLogined = false

/**
*
@feat <注册请求路由>
*/

parseRouter(routerName: T, defaultAxiosConfigMap: Record) {
const apiModule = this.requestRouter[routerName] = {} as ApiRouter[T]

Object.entries(defaultAxiosConfigMap).forEach((item) => {
type ApiName = keyof ApiRouter[T]

const [apiName, defaultRequestConfig] = item as [ApiName, RequestConfig];

const request = this.sendMes.bind(
this,
routerName,
apiName,
defaultRequestConfig,
);

apiModule[apiName] = request as ApiRouter[T][ApiName]
apiModule[apiName].state = "ready";
});
}
async sendMes<ApiName extends keyof ApiRouter[T] = keyof ApiRouter[T]>(
routerName: T,
apiName: ApiName,
defaultRequestConfig: RequestConfig,
requestParams: Record<string, any>,
otherAxiosConfig?: RequestConfig
): Promise<ServerRes> {
this.requestTimes += 1;

return new Promise(async (resolve, reject) => {
try {
const selfMe = this.requestRouter[routerName][apiName];
const requstConfig: RequestConfig = {
...defaultRequestConfig,
...otherAxiosConfig,
data: requestParams,
};

/**
*
@feat <取消上一个同url请求>
*
@remarks [注:
* 个别页面需要同个api地址,多次请求,请传uniKey 作为区分,
* 不然有概率出现上一个请求被干掉
* ]
*/

if (selfMe.state === 'pending') this.cancelLastSameUrlRequest(requstConfig);

// 保险方案,传了 uniKey 才取消请求
// if (selfMe.state === 'pending' && requstConfig.uniKey) this.cancelLastSameUrlRequest(requstConfig);

const successCb = (res: ServerRes) => {
const ret = this.responseHandle(res, requstConfig)
resolve(ret);
};
const failCb = (error: unknown) => {
console.error("接口报错: " + requstConfig.url, error);
// 处理错误逻辑
throw error;
};
const complete = () => {
selfMe.state = "ready";
this.requestTimes -= 1;

if (this.requestTimes === 0) {
this.toLogined = false;
}
};

selfMe.state = "pending";
requstConfig.cancelToken = this.axiosSourceHandle(requstConfig).token;

await server(requstConfig).then(successCb).catch(failCb).finally(complete);

} catch (error) {
reject(error);
}
})
}

responseHandle(res: ServerRes, config: RequestConfig) {
const { code } = res;
console.warn(`请求返回: ${config.url}`, res);

if (code === 405) throw String("405 检查请求方式");
if (code === 401) this.toLogin();
if (code !== 200) throw String(res.message);

return res;
}

toLogin() {
if (this.toLogined) return;
throw String("请先登录");
}

generateReqKey(requestConfig: RequestConfig) {
return `${requestConfig.url}__${requestConfig.uniKey || ''}`
}
axiosSourceHandle(requestConfig: RequestConfig) {
const cancelToken = axios.CancelToken;
const source = cancelToken.source();

const reqKey = this.generateReqKey(requestConfig);
this.requestMap[reqKey] = source;

return source;
}
// 处理取消上一个请求
cancelLastSameUrlRequest(requestConfig: RequestConfig) {
const reqKey = this.generateReqKey(requestConfig);
const currentReqKey = this.requestMap[reqKey];

currentReqKey.cancel(`${manualStopProgram} reqKey: ${reqKey}`); // manualStopProgram 是一个标识,让外面的提示框忽略报错
}
}


export default new Request();



代码二:utils/request/server.ts


import axios from "axios";
import { UserInfo } from "@/utils/index";
import type { RequestConfig, ServerRes } from "./server.types";

export default async function server(
axiosRequestConfig: RequestConfig
): Promise<ServerRes> {
const token = UserInfo.getToken() || "";
const reqData = (() => {
const data = axiosRequestConfig.data;
const isFormData = data instanceof FormData;

if (isFormData) {
data.
append("token", token);
return data;
}
return {
...data,
token
};
}
)();

const { data: resBody, status } = await axios({
...axiosRequestConfig,
withCredentials: true,
data: reqData
}
).
catch((err) => {
const errMsg = err && typeof err === "object" && err !== null && "message" in err
if (errMsg) throw err.message;
throw err;
}
);

return resBody;
}

export {
server
}

import type { AxiosRequestConfig } from "axios";
import type { Api } from "@/apis/index";

export type RequestConfigany> = AxiosRequestConfig & {
uniKey?: string | number;
};

export type ApiConfig = {
params: T;
return: K;
};

export type List_pagiantion = {
page: number;
page_size: number;
};

// 这里有点绕,把各个api的参数和返回值 合成一个个特定的函数
export type ApiRouter = {
[K in keyof Api]: {
[T in keyof Api[K]]: Api[K][T] extends ApiConfig<any, any>
? {
(params: Api[K][T]["params"], otherRequestConfig?: RequestConfig): Promise<{
message: string;
code: number;
data: Api[K][T]["return"];
}>;
state?: 'pending' | 'ready'
}
: never;
};
}

export type ApiRouter__requestConfig = {
[K in keyof Api]: {
[T in keyof Api[K]]: RequestConfig;
};
}
export type ServerRes = {
code: number,
message: string,
data: any
};

接下来是apis文件夹(即开头的那个图片),在这里配置接口信息,日常业务代码在这里写


接口.png


代码一 写api配置 :src/apis/modules/admin-admin/index.ts


import type { ApiRouter__requestConfig } from "@/utils/modules/request/server.types.d";

const indexAdmin: ApiRouter__requestConfig["adminAdmin"] = {
getList: {
method: "post",
url: "/admin/admin/getList"
},
};

export default indexAdmin;

代码二 写接口类型声明 :src/apis/modules/admin-admin/index.types.d.ts


import type { ApiConfig } from "@/utils/modules/request/server.types.d";

export type AdminAdmin = {
getList: ApiConfig<
{
page: number,
page_size: number,
phone?: string,
status?: ManagerStatus,
groups_id?: number
},
{
count: number,
list: {
id: number,
phone: string,
groups_id: number,
create_at: string,
status: number,
status_txt: string,
groups_txt: string
}[]
}
>,
};


代码三 注册路由 :src/apis/index.ts


import { request } from "@/utils/index";
import indexAdmin from "./modules/index-admin/index";

request.parseRouter("indexAdmin", indexAdmin);

export type Api = {
indexAdmin: IndexAdmin;
}

// 这个是另一个作用,到处配置项,配合接口做权限控制,下面在说
export function getApiConfigMap() {
return {
indexAdmin,
};
}

下面说说这个封装方式好在哪里:


1. 解决的痛点:


以往我们看到最常见的封装方式,就这种

export function Api1() {
return axios.get('xx')
}

export function Api2() {
return axios.get('xx')
}

export function Api3() {
return axios.get('xx')
}

export function Api4() {
return axios.get('xx')
}

这种就非常麻木,一直写函数,每一个都要写配置项,没有数据结构结构=(无法复用)。
如果换成上面的 const indexAdmin: ApiRouter__requestConfig["adminAdmin"] = {},这种写法,就有数据结构了,有了结构之后就可以进行组合复用
比如上面提到的 getApiConfigMap 可以把数据结构直接导出,配合接口做按钮级权限控制,
接口会返回一份配置项{authen1: '/admin/admin/getList'}。
我们二者一比对,就能判断出是否有权限了。


比如看下面代码
PermissionWrapper 是一个权限容器组件 hasPermission=true就显示按钮
store.state.myPermission?.enterpriseAlarm?.edit 是用 getApiConfigMap 和结构权限表配合生成的


        <PermissionWrapper
hasPermission={store.
state.myPermission?.enterpriseAlarm?.edit}
>

<el-button type="primary" size="small" text onClick={openEditDialog}>
编辑
el-button>

PermissionWrapper>

ts直接提示,写起来很舒服,快准狠
ts提示.png


2. 请求函数封闭又开放


经过上面的 parseRouter 注册路由之后,sendMes 生成了N个请求函数,独一无二的函数,里面的fail success 可以做的事情很多,不如限制登录,取消上一个请求等等。大家有啥想法欢迎评论区写出来,我们一起优化它。


sendMes 最后一个参数,保持了开放性,在调用的时候我们传入uniKey就可以取消上一个请求了,还有一些特殊的参数,随便造。


3. 方便提取Api类型的参数和返回值类型(这是我额外拓展的)


我们经常会需要把参数和返回值的类型拿出来到页面上使用,这时候,就可以通过下面这个XX全局声明拿到。


declare namespace XX {
export type PromiseParams = T extends Promise ? T : never;

/**
*
@feat < 提取 api 参数 >
*
@describe <
type GetListParams = XX.ExtractApiParams

* >
*/

export type ExtractApiParams<Function_type> = Function_type extends (e: infer U) => any
? Excludeundefined>
: never;

/**
*
@feat < 提取 api 返回值 >
*
@describe <
type GetListReturn = XX.ExtractApiReturnType

* >
*/

export type ExtractApiReturnType<Function_type> = PromiseParams<
ReturnType<Function_type>
>["data"];

/**
*
@feat < 提取 api 为分页的 list item>
*
@describe <
type TableRow = XX.ExtractPromiseListItem

* >
*/

export type ExtractPromiseListItem<Function_type> =
ExtractApiReturnType<Function_type>["list"][number];
}

下面是一些使用方法举例
image.png


image.png


image.png


image.png


用上面的写法很方便就能在页面中把具体的类型拿出来。做到一次类型声明,到处使用。


api封装是一个长期的话题,axios很好用,但其实它就是一个请求方法而已。相信大家也见过很多乱七八糟的写法。特别是一些老项目,想新增api都不知道放在哪个文件夹。


很幸运无意中看到网易公开课的老师们讲解,那时候他们写的是js版本,看到这种由配置对象直接生成api函数的做法瞬间眼前一亮,这不就是我一直在找的封装方式,满足了我所有的想象。感谢感谢


后来我花了点时间,让它变成ts版本,还封装XX这个全局声明,让它彻底好用起来。希望这个封装能让大家受益。


细心的读者可能会发现上面的代码,一直抛错误,但是却没有拦截提示。 这是笔者推崇的报错终止程序,而不是用return的方式。(js终止程序,我常用throw 替代 return


如果您有什么好的建议或想法,欢迎评论区留言。有用请点点赞,还有更多经验总结在路上。
嘴下留情,骂我倒无所谓,重要的是别把评论区搞得乌烟瘴气


原课程链接:js es5版本,有兴趣的可以看看。但我觉得它那个取消请求,得再升级一下不然万一同个页面调用同个接口2次,就会取消第一个请求了。所以我加装了uniKey作为标识。
live.study.163.com/live/index.…


作者:闸蟹
来源:juejin.cn/post/7304594468157849640
收起阅读 »

喊话各大流行UI库,你们的Select组件到底行不行啊?

web
各种 UI 库的 Select,你们能不能人性化一点! 最近在云效上合并代码,本想着懒的目的输入了非连续的关键字搜索分支,结果... 大概逻辑就是,搜索时必须输入连续的字母,比如,要找 “master-alpha”分支,非要输入 master-al 才能搜到...
继续阅读 »

各种 UI 库的 Select,你们能不能人性化一点!


最近在云效上合并代码,本想着懒的目的输入了非连续的关键字搜索分支,结果...


1.gif


大概逻辑就是,搜索时必须输入连续的字母,比如,要找 “master-alpha”分支,非要输入 master-al 才能搜到,像图中输入 “masal” 就完全搜索不到。这导致了很多场景下使用起来很不方便,例如我们只记得几个非连续的关键字,或者懒得打那么多连续的关键字来搜索,用户体验较差。


然后我又看了几个流行组件库的 Select。


Element-ui


2.gif


Antd


3.gif


Naive-ui


4.gif


全军覆没!


那我们来自己实现一个吧!先来两个实战图。


不带高亮的非连续搜索


6.gif


带高亮的非连续搜索


5.gif


实现不带高亮的非连续搜索


以vue3+ElementUI为例,在这里将会用到一个小小的js库叫sdm2来实现非连续的字符串匹配。


视图部分


<el-select
v-model="value"
size="large"
placeholder="Filter options"
filterable
:filter-method="q => (query = q)">

<el-option
v-for="item in optionsComputed"
:key="item.value"
:value="item.value"
:label="item.label">

el-option>
el-select>

没有什么特别的,就是加了个filterMethod函数将关键词赋值给query状态,然后optionsComputedquery值根据关键词进行筛选


import { match } from 'sdm2';

const options = [/* 选项列表 */];
const query = ref('');
const optionsComputed = computed(() => options.filter(({ label }) =>
// 使用sdm2的match函数筛选
match(label, query.value, {
// 忽略大小写匹配
ignoreCase: true,
}
)));

就这么简单完成了。


实现带高亮的非连续搜索


视图部分


高亮部分使用v-html动态解析html字符串。


<el-select
v-model="value"
size="large"
placeholder="Filter options"
filterable
:filter-method="q => (query = q)">

<el-option
v-for="item in optionsComputed"
:key="item.value"
:value="item.value"
:label="item.label">

<div v-html="item.highlight">div>
el-option>
el-select>

为了让匹配到的关键字高亮,我们需要将匹配到的关键字转换为html字符串,并将高亮部分用包裹,最后用v-html动态解析。


import { filterMap } from 'sdm2';

const options = [/* 选项列表 */];
const query = ref('');
const optionsComputed = computed(() =>
// 为了过滤出选项,并将到的转换为html字符串,此时我们要用sdm2的filterMap函数
filterMap(options, query.value, {
ignoreCase: true,

// 把matchStr返回的字符串作为被匹配项
matchStr: ({ label }) => label,

// 匹配到后转换为html高亮字符串
onMatched: (matchedStr) => `${matchedStr}`,

// 将匹配到的项转换转换为需要的格式,str为onMatched转换后的字符串,origin为数组的每项原始值
onMap: ({ str, origin }) => {
return {
highlight: str,
...origin
}
}
})
);

然后一个带高亮的非连续搜索就完成了。


总结


这样你的搜索库就又更智能点了吧,然后各位 UI 库作者,你们也可以考虑考虑这个方案,或者有哪位朋友愿意的话也可以去为他们提一个issue或PR。

作者:古韵
来源:juejin.cn/post/7310104657212178459
收起阅读 »

Ant Design Mini 支持微信小程序啦!

web
Ant Design Mini 经过一段时间的开发与不断的努力,我们兴奋地宣布,Ant Desigin Mini 组件库中已有 16 个核心组件完成了微信小程序的适配工作!现在你不仅可以在支付宝小程序中使用 Ant Desigin Mini 组件库,也可以在微...
继续阅读 »

Ant Design Mini


经过一段时间的开发与不断的努力,我们兴奋地宣布,Ant Desigin Mini 组件库中已有 16 个核心组件完成了微信小程序的适配工作!现在你不仅可以在支付宝小程序中使用 Ant Desigin Mini 组件库,也可以在微信小程序中使用了!


目前这项适配正处于 Beta 阶段,我们诚挚地邀请大家前来体验。首批适配的组件包括:ButtonSliderContainerIconLoadingSwitchTagInputCalendarListResultPopoverMaskStepperPopupCheckbox


先来看看 Demo 效果:
image.png


我们的官网文档、Demo 都已同步更新双端切换能力:
2023-11-28 14.44.51.gif


你可以参考以下文档进行接入:



双平台无法对齐的特性


受制于各平台框架设计,以下这些特性存在差异:



  • 两个平台的事件 API 不同。支付宝小程序可以把实例上通过 props 传递给子组件,而微信需要在 data 里传递函数。视图层的写法也所有不同。

    • 下面是 Calendar 在两种平台的使用方式。

      • 微信小程序






Page({
data:{
handleFormat() {
}
}
})

<calendar onFormat="{{handleFormat}}" />

  - 支付宝小程序

Page({
handleFormat() {

}
})
<calendar onFormat="handleFormat" />


  • 微信小程序不支持在 slot 为空时显示默认值, 所以我们在微信平台取消了部分 slot,对应会损失一些定制能力。 比如说 Calendar 组件, 在支付宝可以通过 calendarTitle 这个 slot 定制标题,在微信端只能通过 css 来控制样式。


<View class="ant-calendar-title-container">
{/* #if ALIPAY */}
<Slot name="calendarTitle">
{/* #endif */}
<View class="ant-calendar-title">{currentMonth.title}</View>
{/* #if ALIPAY */}
</Slot>
{/* #endif */}
</View>


  • 微信小程序不支持循环渲染 slot , 所以部分组件无法迁移到微信, 比如说 IndexBar 组件, 使用了 Slot 递归渲染整个组件的内容。这种写法无法迁移到微信。


<view a:for="{{items}}">
<slot
value="{{item}}"
index="{{index}}"
name="labelPreview" />

</view>

双平台适配背后的工程技术


下面我们为大家介绍一下 Antd Mini 支持多平台背后的一些工程方案。


中立的模板语法: 使用 tsx 开发小程序


由于支付宝和微信的小程序的语法各有差异,为了解决让 Antd Mini 同时支持两个端,我们团队选择的 tsx 的一个子集作为小程序的模板语言。
使用 tsx 具有以下优势:



  • 可以直接使用 babel 解析代码,无需自己开发编译器。

  • 各个 IDE 原生支持 TSX 的类型推导与代码提示。

  • 视图层和逻辑层可以复用同一份 props 类型。

  • 可以直接通过 import 导入其他的小程序组件,使用 typescript 进行类型检查。

  • 视图层脚本也可以享受类型校验,无需依赖平台 IDE


由于小程序的视图语法比较受限,从 tsx 向跨平台视图语法转换是比较容易的。我们基于 babel 开发了一个简单的编译器,解析原先 tsx 的语法树以后,将 React 的语法平行转换为可读性比较强的小程序视图语法。
具体举例来看:



  • 条件判断 : 我们使用了 &&以及 ?: 三元表达式替代了之前的 :if 标签。

    • tsx: !!a && <Text>a</Text>

    • 微信小程序:<text wx:if="{{ !!a }}" />

    • 支付宝小程序:<text wx:if="{{ !!a }}" />



  • 循环: 我们使用了 map 代替之前的 :for 标签,从源码里自动分析出 :for-item :for-index :key 等标签。

    • tsx:




{todoList.map((task, taskIndex) => (
<Text
hidden={!mixin.value}
key={task.id}
data-item-id={taskIndex}
data-num={20}
>

{taskIndex} {task}
</Text>

))}


  • 微信小程序:


<block
wx:for="{{ todoList }}"
wx:for-index="taskIndex"
wx:for-item="task"
wx:key="{{ task.id }}">
<!-- display: inline -->
<text
hidden="{{ !mixin.value }}"
data-item-id="{{ taskIndex }}"
data-num="{{ 20 }}"
>
{{ taskIndex }}{{ task }}</text
>
</block>



  • 支付宝小程序


  <block
a:for="{{ todoList }}"
a:for-index="taskIndex"
a:for-item="task"
a:key="{{ task.id }}">
<!-- display: inline -->
<text
hidden="{{ !mixin.value }}"
data-item-id="{{ taskIndex }}"
data-num="{{ 20 }}"
>
{{ taskIndex }}{{ task }}</text
>
</block>



  • 事件绑定: 我们会按照配置,自动将直接转换为两个平台的格式。

    • tsx:<Text onClick="handleClick" />

    • 微信小程序: <text bind:click="handleClick" />

    • 支付宝小程序: <text onClick="handleClick" />



  • 视图层脚本

    • 我们还规定了以 sjs.ts 作为视图层脚本的文件格式。 在编译时候转换为对应平台的文件格式。

      • tsx: import helper from './helper.sjs.ts'

      • 微信小程序: <wxs src="./helper.wxs" module="helper" />

      • 支付宝小程序: <import-sjs src="./helper.sjs" module="helper" />





  • 类型方案

    • 为了让逻辑层类型与视图层关联,我们设计了一些工具类型。 比如说下面使用的 TSXMLProps,将 IProps 的 onClick 转换成了字符串。




// Calendar.axml.tsx
import { TSXMLProps, View } from 'tsxml';

interface IProps {
className?: string;
style?: string;
onClick: (e) => void;
}

interface InternalData {
size: number;
}

export default (
{ className, style }: TSXMLProps<IProps>,
{ size }: InternalData
) => (
<View class={`ant-calendar ${className ? className : ''}`} style={style}>
{size}
</View>

);

// Page.axml.tsx

import Calendar from './Calendar.axml.tsx'

export default () => (<Calendar onClick="handleClick" />)

目前使用 tsx 的这套方案还存在一些限制:



  • 和小程序相同,一个文件内只能定义一个组件。

  • 如果使用自定义组件,需要配置组件事件在各个平台的写法。


老组件语法转换?用 AI 就行了


在决定使用 tsx 语法之后,我们还面临一个很棘手的工作量问题:如何把历史组件库 axml 代码全量转换为最新的 tsx 语法?
这时候就该 ChatGPT 出场了,我们请 AI 来帮助我们完成这个一次性转换工作。
为了让转换结果更靠谱,我们使用了一些技巧:



  • 使用了 tsx 编译器等测试用例作为 prompt ,让 AI 可以更好的了解 tsx 的写法。

  • 除了 tsx 文件以外,我们还将组件的 props.ts 与 config.json 加到了 propmt 里,可以帮助 AI 生成更好的 import 导入。


在这里,你可以看到这份转换的完整 prompt。


确保 AI 产出的正确性?再用我们的编译器转回来


为了确保 AI 产出的代码是正确的,我们使用编译器将 AI 编写的 tsx 重新编译回 axml ,再用 git diff 对原始代码做比对,由此即可核查 AI 转换的正确性。


当然,这两次转换的过程不会完全等价,比如转换 map 的过程中会出现一层嵌套的 <block/>。好在这样的差异不多,一般肉眼看一遍就能确认正确性了。


跨平台通用的组件逻辑:小程序函数式组件(functional-mini)


除了视图,我们还需要确保组件逻辑适配到双端。这里我们使用了小程序函数式组件( functional-mini )的形式来编写,functional-mini 的源码及文档放置均在 ant-design/functional-mini


使用了函数式组件后,Antd Mini 用上了计算属性、useEffect 等特性,也能通过 hooks 来替换原有的大量 mixin 实现,让代码的可维护性提升了一个台阶。


以典型的 Popover 组件为例,逻辑部分适配完成后,它的代码完全变成了 React 风格,数据变更流程一目了然:


const Popover = (props: IPopoverProps) => {
const [value] = useMergedState(props.defaultVisible, {
value: props.visible,
});
const [popoverStyle, setPopoverStyle] = useState({
popoverContentStyle: '',
adjustedPlacement: '',
});

useEffect(() => {
setPopoverStyle({
popoverContentStyle: '',
adjustedPlacement: '',
});
}, [value, props.autoAdjustOverflow, props.placement]);

return {
adjustedPlacement: popoverStyle.adjustedPlacement,
popoverContentStyle: popoverStyle.popoverContentStyle,
};
};

关于小程序函数式组件的原理、特性介绍,我们将在后续的分享中另行展开。


写在最后


欢迎大家一起来尝试 Ant Design Mini 的跨平台能力,你可以在 issue 区提出宝贵的建议与 Bug 反馈。


官网: mini.ant.design/


国内镜像:ant-design-mini.antgroup.com/


作者:支付宝体验科技
来源:juejin.cn/post/7311603519570952246
收起阅读 »

大厂前端开发规定,你也能写成诗一样的代码(保姆级教程)

web
BEM 使用起来很多人不晓得BEM是什么东西 我来解释给你们听  BEM是一种前端开发的命名方法论,全称为Block-Element-Modifier,中文翻译为块、元素、修饰符。BEM的主要思想是将页面分解成多个可重用、独立的模块,每个模块由一个主块(Blo...
继续阅读 »

BEM 使用起来

很多人不晓得BEM是什么东西 我来解释给你们听

  BEM是一种前端开发的命名方法论,全称为Block-Element-Modifier,中文翻译为块、元素、修饰符。BEM的主要思想是将页面分解成多个可重用、独立的模块,每个模块由一个主块(Block)和零个或多个元素(Element)组成,可以使用修饰符(Modifier)来描述模块的不同状态和变化。

BEM的命名规则如下:

  • 块(Block):一个独立的、可重用的组件,通常由一个或多个元素组成,并且有一个命名空间作为标识符。通常以单个单词命名,使用连字符分割单词,例如:menu、button、header等。
  • 元素(Element):块的组成部分,不能独立存在,必须隶属于某个块。元素的命名使用两个连字符“__”与块名分隔,例如:menu__item、button__text、header__logo等。
  • 修饰符(Modifier):描述块或元素的某种状态或变化,以单个单词或多个单词组成,使用两个连字符“--”与块或元素名分隔。例如:menu--horizontal、button--disabled、header__logo--small等。

  通过使用BEM命名方法论,可以实现更好的代码复用性、可维护性和可扩展性。BEM的命名规则清晰明了,易于理解和使用,可以有效地提高团队开发效率和代码质量。

page+ hd/bd/ft 用起来

"page+ hd/bd/ft" 是一种简化的命名约定,常用于网页布局中。下面是对这些缩写的解释:

  • page:页面的整体容器,表示整个页面的最外层包裹元素。
  • hd:代表页头(header),用于放置页面的标题、导航栏等顶部内容。
  • bd:代表页体(body),用于放置页面的主要内容,如文章、图片、表格等。
  • ft:代表页脚(footer),用于放置页面的底部内容,如版权信息、联系方式等。

  这种命名约定的好处是简洁明了,可以快速理解页面的结构和布局。通过将页面划分为页头、页体和页脚,可以更好地组织和管理页面的各个部分,提高代码的可读性和可维护性。

更好的使用工具(stylus插件)

,Stylus 是一种 CSS 预处理器,它允许你使用更加简洁、优雅的语法编写 CSS。通过在命令行中运行 npm i -g stylus 命令,你可以在全局范围内安装 Stylus,并开始使用它来编写样式文件。 .styl 是 Stylus 文件的扩展名,你可以使用 Stylus 编写样式规则。然后,你可以将这些编写好的 Stylus 文件转换为普通的 CSS 文件,以便在网页中使用。

  具体地说,你可以创建一个名为 common.styl 的文件,并在其中编写 Stylus 样式规则。然后,通过运行 stylus -w common.styl -o common.css 命令,你可以让 Stylus 监听 common.styl 文件的变化,并自动将其编译为 common.css 文件。

  以下是一份示例代码来说明这个过程:

  1. 创建 common.styl 文件,并在其中编写样式规则:
// common.styl
$primary-color = #ff0000

body
font-family Arial, sans-serif
background-color $primary-color

h1
color white
  1. 打开终端,进入 common.styl 文件所在的目录,运行以下命令:
Copy Code
stylus -w common.styl -o common.css

  这将启动 Stylus 监听模式,并将 common.styl 文件编译为 common.css 文件。每当你在 common.styl 文件中进行更改时,Stylus 将自动重新编译 common.css 文件,以反映出最新的样式更改。 请注意,为了运行上述命令,你需要先在全局范围内安装 Stylus,可以使用 npm i -g stylus 命令进行安装。

stylus的优点

  Stylus 作为一种 CSS 预处理器,在实际开发中有以下几个优点:

  1. 更加简洁、优雅的语法:Stylus 的语法比原生 CSS 更加简洁,可以让我们更快地编写样式规则,同时保持代码的可读性和可维护性。
  2. 变量和函数支持:Stylus 支持变量和函数,可以提高样式表的重用性和可维护性。通过使用变量和函数,我们可以在整个样式表中轻松更改颜色、字体等属性,而无需手动修改每个样式规则。
  3. 混合(Mixins)支持:Stylus 的混合功能允许我们将一个样式规则集合包装在一个可重用的块中,并将其应用于多个元素。这可以大大简化样式表的编写,并减少重复代码。
  4. 自动前缀处理:Stylus 可以自动添加适当的浏览器前缀,以确保样式规则在不同的浏览器中得到正确的渲染。
  5. 非常灵活的配置:Stylus 提供了非常灵活的配置选项,可以根据项目的需要启用或禁用不同的功能,例如自动压缩、源映射等。

  总之,Stylus 通过提供更加简洁、灵活的语法和功能,可以使我们更加高效地编写 CSS 样式表,并提高代码的可重用性和可维护性。

最后一个大招阿里的适配神器 flexible.js

  flexible.js 是一款由阿里巴巴的前端团队开发的移动端适配解决方案。它通过对 Viewport 的缩放和 rem 单位的使用,实现了在不同设备上的自适应布局。

具体来说,flexible.js 主要包括以下几个步骤:

  1. 根据屏幕的宽度计算出一个缩放比例,并将该值设置到 Viewport 的 meta 标签中。
  2. 计算出 1rem 对应的像素值,并将其动态设置到 HTML 元素的 font-size 属性中。
  3. 在 CSS 中使用 rem 单位来定义样式规则。这些规则会自动根据 HTML 元素的 font-size 属性进行适配。

  通过这种方式,我们可以实现在不同设备上的自适应布局。具体来说,我们只需要在 CSS 中使用 rem 单位来定义样式规则,而不需要关注具体的像素值。当页面在不同设备上打开时,flexible.js 会自动根据屏幕宽度和像素密度等信息进行适配,从而保证页面的布局和样式在不同设备上都可以得到正确的显示。

  需要注意的是,flexible.js 并不能完全解决移动端适配的所有问题,还有一些特殊情况需要我们手动处理。例如,一些图片或者 Canvas 等元素可能需要根据不同设备的像素密度进行缩放,而这些操作需要我们手动实现。不过,flexible.js 可以帮助我们简化移动端适配的工作,提高开发效率。

  下期我来教大家手动写适配器 喜欢的来个关注 点赞 这个也是以后写文章的动力所在 谢谢大家能观看我的文章 咱下期在见 拜拜


作者:扯蛋438
来源:juejin.cn/post/7303126570323443722

收起阅读 »

JS问题:简单的console.log不要再用了!试试这个

web
前端功能问题系列文章,点击上方合集↑ 序言 大家好,我是大澈! 本文约1500+字,整篇阅读大约需要3分钟。 本文主要内容分三部分,如果您只需要解决问题,请阅读第一、二部分即可。如果您有更多时间,进一步学习问题相关知识点,请阅读至第三部分。 1. 需求分析 一...
继续阅读 »

前端功能问题系列文章,点击上方合集↑


序言


大家好,我是大澈!


本文约1500+字,整篇阅读大约需要3分钟。


本文主要内容分三部分,如果您只需要解决问题,请阅读第一、二部分即可。如果您有更多时间,进一步学习问题相关知识点,请阅读至第三部分。


1. 需求分析


一般情况下,我们在项目中进行代码调试时,往往只会在逻辑中使用console.log进行控制台打印调试。


这种方式虽然比较常规直接,但是如果打印数据多了,就会导致你的控制台消息变得异常混乱。


所以,我们有了更好的选择,那就是console对象提供的其它API,来让我们能够更清晰的区分打印信息。


图片


2. 实现步骤


2.1 console.warn


当我们需要区分一些比较重要的打印信息时,可以使用warn进行警告提示。


图片



2.2 console.error


当我们需要区分一些异常错误的打印信息时,可以使用error进行错误提示。


图片


2.3 console.time/timeEnd


想看看一段代码运行需要多长时间,可以使用time


这对于需要一些时间的CPU密集型应用程序非常有用,例如神经网络或 HTML Canvas读取。


下面执行这段代码:


console.time("Loop timer")
for(let i = 0; i < 10000; i++){
    // Some code here
}
console.timeEnd("Loop timer")


结果如下:图片


2.4 console.trace


想看看函数的调用顺序是怎样的吗?可以使用trace


下面执行这段代码:



  function trace(){
    console.trace()
  }
  function randomFunction(){
      trace();
  }
  randomFunction()


setup中,randomFunction 调用trace,然后又调用console.trace


因此,当您调用 randomFunction 时,您将得到类似的输出,结果如下:


图片


2.5 console.group/groupEnd


当我们需要将一类打印信息进行分组时,可以使用group


下面执行这段代码:


console.group("My message group");

console.log("Test2!");
console.log("Test2!");
console.log("Test2!");

console.groupEnd()

结果如下:


图片



2.6 console.table


在控制台中打印表格信息,可以使用table


对!你没听错,就是让我们以表格形式展示打印信息。


如果使用log打印:


var person1 = {name: "Weirdo", age : "-23", hobby: "singing"}
var person2 = {name: "SomeName", age : "Infinity", hobby: "programming"}

console.log(person1, person2);

结果如下:


这样做是不是让数据看起来很混乱。


图片


反之,如果我们使用table输出:


var person1 = {name: "Weirdo", age : "-23", hobby: "singing"}
var person2 = {name: "SomeName", age : "Infinity", hobby: "programming"}

console.table({person1, person2})

结果如下:


怎么样!从来不知道控制台可以看起来如此干净,对吧!


图片


2.7 console.clear


最后,使用clear把控制台清空吧!


图片


3. 问题详解


3.1 可以自定义log的样式吗?


答案当然是可以的,只需要借助%c这个占位符。


%c 是console的占位符,用于指定输出样式或应用 CSS 样式到特定的输出文本。


但请注意,%c 占位符只在部分浏览器中支持,如 Chrome、Firefox 等。


通过使用 %c 占位符,可以在 console.log 中为特定的文本应用自定义的 CSS 样式。这样可以改变输出文本的颜色、字体、背景等样式属性,以便在控制台中以不同的样式突出显示特定的信息。


以下是使用%c 占位符应用样式的示例:


console.log("%c Hello, World!", 
  "color: red; font-weight: bold;border1px solid red;");

结果如下:


图片


通过使用 %c 占位符和自定义的样式规则,可以在控制台输出中以不同的样式突出显示特定的文本,使得输出更加清晰和易于识别。


这在调试和日志记录过程中非常有用,特别是当需要突出显示特定类型的信息或错误时。


结语


建立这个平台的初衷:



  • 打造一个仅包含前端问题的问答平台,让大家高效搜索处理同样问题。

  • 通过不断积累问题,一起练习逻辑思维,并顺便学习相关的知识点。

  • 遇到难题,遇到有共鸣的问题,一起讨论,一起沉淀,一起成长。

作者:程序员大澈
来源:juejin.cn/post/7310102466570321958
收起阅读 »

TS中,到底用`type`还是`interface`呢?

web
结论 直接说结论,用type一把梭即可,除非你要发布到npm,接下来我一一解释 为什么定义对象都要使用type呢? 如图所示,我鼠标悬浮后,并不知道里面是什么东西 只能获取结果时调出代码提示,或者ctrl + 鼠标左键进入,查看类型定义 那么我用type呢? ...
继续阅读 »

结论


直接说结论,用type一把梭即可,除非你要发布到npm,接下来我一一解释


为什么定义对象都要使用type呢?


如图所示,我鼠标悬浮后,并不知道里面是什么东西


只能获取结果时调出代码提示,或者ctrl + 鼠标左键进入,查看类型定义


那么我用type呢?


image.png


可以看到,现在鼠标悬浮能直接查看类型定义了


这一点是让我最受不了的,所以直接选择type即可


image.png


区别


1. 如何继承



先看看interface,通过extends关键字



image.png



type,则通过交叉类型。不过我认为interface好看点



image.png


2. 其他特性



interface重写时



  • 如果有不同的属性,则会添加;

  • 如果是相同的属性但是类型不同,则会报错;



这点有好有坏,当你不小心名字重复了,那你就容易出问题


但同时利于扩展,不过没有人会这么写吧?


直接去原来的接口添加属性不行吗?


唯一的场景,就是开发工具库后。别人使用你的工具时,可以为你扩展类型


image.png


3. type独有的优势


除了上面的悬浮能查看具体类型外,type还提供了很多的关键字使用,这是interface不具备的


比如in关键字,用来枚举类型


这里我写个删除属性的泛型,和Omit一样的,但是interface不支持


此外还有很多TS特有的关键字,都只能通过type使用,比如infer


不过这也符合直觉,因为interface就是定义一个类型而已


image.png


经过以上探讨,可以得出一个结论


平时开发可以都用type


发布工具库给别人用时,用interface


作者:寅时码
来源:juejin.cn/post/7304867327752912906
收起阅读 »

个人代码优化技巧

web
背景 贴近目前公司的业务,做的增删改查比较多。基本上都是做一些表格的业务系统比较多,因此在写的过程中,都会遇到一些优化的细点,仅供参考,觉得好的可以采纳,不好的欢迎提出来。 一、import按需加载 很多小伙伴肯定不少看到过,性能优化路由要用import(...
继续阅读 »

背景



贴近目前公司的业务,做的增删改查比较多。基本上都是做一些表格的业务系统比较多,因此在写的过程中,都会遇到一些优化的细点,仅供参考,觉得好的可以采纳,不好的欢迎提出来。



一、import按需加载


很多小伙伴肯定不少看到过,性能优化路由要用import('@/views/xxxx.vue')这样就可以按需加载了。
本身的vue-cli自动创建出来的时候也会有这一条语句。除了给路由优化之外呢,还有别的场景优化空间呢?那肯定有的啦。那就是结合<component/>自带的组件去一起实现。


场景呈现


正常情况下,做一个业务模块,都会分为【基础表】、【业务表】,一般情况下,用户维护好了基础表信息了之后,剩下的就是信息交叉复用,有可能在某个业务页面,我需要点击某个按钮后根据某个值到某个基础表的页面进行搜索信息,并勾选行信息。


<template>
<div>
<div class="count" @click="showDynamicComponent">按需加载页面</div>
<Modal title="动态数据" :visible="visible" @ok="()=>dynamicComponent=null">
<component :is="dynamicComponent" ref="dynamicRef"/>
</Modal>
</div>

</template>

<script>
import { Modal } from 'ant-design-vue'
export default {
components: {
Modal
},
data() {
return {
dynamicComponent: null,
visible: false
};
},
methods: {
showDynamicComponent() {
this.visible = true
import('@/views/baseInfo/a.vue').then(res=>{
this.dynamicComponent = res.module
})
},
},
};
</script>


最后通过this.$refs.dynamicRef这个方式来拿到组件的信息和方法。




二、表格维护


因为公司的做的系统报表比较多,这时候表头的数量和表单都是比较多的,恰好公司使用的UI框架是ant-design-vue,表头的数量达到40-50的时候,那么代码的占用函数就很大,而且在产品经常在开发阶段,定义的表头位置顺序变来变去,于是为了方便维护和开发,我封装成一个函数,我还没考虑过这个性能损耗问题,但是维护起来确实方便很多。


业务场景


举个例子,一个表头有用户姓名年龄,正常情况下,ant-design-vue表头是这么写的。


const columns = [{
dataIndex: 'username',
title: '用户'
}, {
dataIndex: 'realname',
title: '姓名'
}, {
dataIndex: 'age',
title: '年龄'
}]

数据少的时候,维护没有什么问题,倒是表头数量很多的时候,可能40-50个,一百个?大概是这个数,看起来就很费劲。因为自己业务确实遇到过这个问题,维护起来要么单独创建一个文件大概一百多行一点点找,要么就放在业务代码里,但是无论如何阅读性都很差。所以我想了个办法,把它平铺变成数组形式。


import { genTableColumnsUtil } from '@/utils/tableJs'
const columns = genTableColumnsUtil([
['username', '用户'],
['realname', '姓名'],
['age', '年龄'],
])

这时候是不是就好看多了?甚至这个可以做成二级表头,递归做嵌套。那额外的配置项拓展项怎么搞?


const columns = genTableColumnsUtil([
['username', '用户'],
['realname', '姓名'],
['age', '年龄'],
],
{username: { width: '20%' }})

我的做法就是在函数里面在穿多一个对象,这样就可以填充上去了。毕竟大多数字段只是展示而已,没有做太多的单元格定制化,如果要定制化,搜索对应的dataIndex就好了。


image.png


image.png


这时候调整顺序的时候,还有定制化的时候就阅读性就好很多。




三、依赖包单独抽离


性能优化不只是代码层面的优化,除了nginx配置http2,gzip...
单独抽离chunk包也可以达到加快访问速度的目的。


业务场景


// 在vue.config.js加入这段代码
module.exports = {
configureWebpack: config => {
// 分包,打包时将node_modules中的代码单独打包成一个chunk
config.optimization.splitChunks = {
maxInitialRequests: Infinity, // 一个入口最大的并行请求数,默认为3
minSize: 0, // 一个入口最小的请求数,默认为0
chunks: 'all', // async只针对异步chunk生效,all针对所有chunk生效,initial只针对初始chunk生效
cacheGr0ups: { // 这里开始设置缓存的 chunks
packVendor: { // key 为entry中定义的 入口名称
test: /[\\/]node_modules[\\/]/, // 正则规则验证,如果符合就提取 chunk
name(module) {
const packageName = module.context.match(
/[\\/]node_modules[\\/](.*?)([\\/]|$)/
)[1]
return `${packageName.replace('@', '')}`
}
}
}
}
}
}
}

最后在打包完了之后。可以查看一下。


image.png




四、thread-loader打包


业务场景


充分利用cpu核心数,进行快速打包、其实我也没感觉有多快。


 // 开启多线程打包
config.module
.rule('js')
.test(/\.js$/)
.use('thread-loader')
.loader('thread-loader')
.options({
// worker使用cpu核心数减1
workers: require('os').cpus().length - 1,
// 设置cacheDirectory
cacheDirectory: '.cache/thread-loader'
})
.end()



五、ECharts按需使用


业务场景


数字化是趋势,图形可视化在所难免,但往往我们有时候没做那么复杂的图形,可能只用到了饼图和柱状图,或者别的,怎么样都用不完ECharts更多的图形,ECharts是大家常用的图形化之一,ECharts第一步教程都是告诉我们,在
vue文件里


import * as echarts from 'echarts'

殊不知,我们用不到的图形都加载进来,打包的时候就可以看到,这玩意,3M多。
所以,看情况来加载图形配置


import * as echarts from 'echarts/core'

import { BarChart, LineChart, PieChart } from 'echarts/charts'

import {
TitleComponent,
TooltipComponent,
GridComponent,
LegendComponent,
ToolboxComponent,
} from 'echarts/components'

import { CanvasRenderer } from 'echarts/renderers'

echarts.use([
TitleComponent,
TooltipComponent,
GridComponent,
BarChart,
LineChart,
PieChart,
CanvasRenderer,
LegendComponent,
ToolboxComponent
])

export default echarts

通过vscode的包插件,可以看到引入的模块大小


image.png


作者:hhope
来源:juejin.cn/post/7309791510873784372
收起阅读 »

学会Grid之后,我觉得再也没有我搞不定的布局了

web
说到布局很多人的感觉应该都是恐惧,为此很多人都背过一些很经典的布局方案,例如:圣杯布局、双飞翼布局等非常耳熟的名词; 为了实现这些布局我们有很多种实现方案,例如:table布局、float布局、定位布局等,当然现在比较流行的肯定是flex布局; flex布局属...
继续阅读 »

说到布局很多人的感觉应该都是恐惧,为此很多人都背过一些很经典的布局方案,例如:圣杯布局双飞翼布局等非常耳熟的名词;


为了实现这些布局我们有很多种实现方案,例如:table布局float布局定位布局等,当然现在比较流行的肯定是flex布局


flex布局属于弹性布局,所谓弹性也可以理解为响应式布局,而同为响应式布局的还有Grid布局


Grid布局是一种二维布局,可以理解为flex布局的升级版,它的出现让我们在布局方面有了更多的选择,废话不多说,下面开始全程高能;



本篇不会过多介绍grid的基础内容,更多的是一些布局的实现方案和一些小技巧;



常见布局


所谓的常见布局只是我们在日常开发中经常会遇到的布局,例如:圣杯布局双飞翼布局这种名词我个人觉得不用太过于去在意;


因为这类布局最后的解释都会变成几行几列,内容在哪一行哪一列,而这些就非常直观的对标了grid的特性;


接下来我们来一起看看一些非常常见的布局,并且用grid来实现;


1. 顶部 + 内容


image.png


html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Titletitle>
<style>
html, body {
margin: 0;
padding: 0;
}

body {
display: grid;
grid-template-rows: 60px 1fr;
height: 100vh;
}

.header {
background-color: #039BE5;
}

.content {
background-color: #4FC3F7;
}

.header,
.content {
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
style>
head>
<body>
<div class="header">Headerdiv>
<div class="content">Contentdiv>
body>
html>


2. 顶部 + 内容 + 底部


image.png


html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Titletitle>
<style>
html, body {
margin: 0;
padding: 0;
}

body {
display: grid;
grid-template-rows: 60px 1fr 60px;
height: 100vh;
}

.header {
background-color: #039BE5;
}

.content {
background-color: #4FC3F7;
}

.footer {
background-color: #039BE5;
}

.header,
.content,
.footer {
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
style>
head>
<body>
<div class="header">Headerdiv>
<div class="content">Contentdiv>
<div class="footer">Footerdiv>
body>
html>


这里示例和上面的示例唯一的区别就是多了一个footer,但是我们可以看到代码并没有多少变化,这就是grid的强大之处;


可以看码上掘金的效果,这里的内容区域是单独滚动的,从而实现了headerfooter固定,内容区域滚动的效果;


实现这个效果也非常简单,只需要在content上加上overflow: auto即可;




3. 左侧 + 内容


image.png


html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Titletitle>
<style>
html, body {
margin: 0;
padding: 0;
}

body {
display: grid;
grid-template-columns: 240px 1fr;
height: 100vh;
}

.left {
background-color: #039BE5;
}

.content {
background-color: #4FC3F7;
}

.left,
.content {
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}


style>
head>
<body>
<div class="left">Leftdiv>
<div class="content">Contentdiv>
body>
html>


这个示例效果其实和第一个是类似的,只不过是把grid-template-rows换成了grid-template-columns,这里就不提供码上掘金的示例了;



4. 顶部 + 左侧 + 内容


image.png


html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Titletitle>
<style>
html, body {
margin: 0;
padding: 0;
}

body {
display: grid;
grid-template-rows: 60px 1fr;
grid-template-columns: 240px 1fr;
height: 100vh;
}

.header {
grid-column: 1 / 3;
background-color: #039BE5;
}

.left {
background-color: #4FC3F7;
}

.content {
background-color: #99CCFF;
}

.header,
.left,
.content {
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}

style>
head>
<body>
<div class="header">Headerdiv>
<div class="left">Leftdiv>
<div class="content">Contentdiv>
body>
html>


这个示例不同点在于header占据了两列,这里我们可以使用grid-column来实现,grid-column的值是start / end,例如:1 / 3表示从第一列到第三列;


如果确定这一列是占满整行的,那么我们可以使用1 / -1来表示,这样如果后续变成顶部 + 左侧 + 内容 + 右侧的布局,那么header就不需要修改了;



5. 顶部 + 左侧 + 内容 + 底部


image.png


html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Titletitle>
<style>
html, body {
margin: 0;
padding: 0;
}

body {
display: grid;
grid-template-areas:
"header header"
"left content"
"left footer";
grid-template-rows: 60px 1fr 60px;
grid-template-columns: 240px 1fr;
height: 100vh;
}

.header {
grid-area: header;
background-color: #039BE5;
}

.left {
grid-area: left;
background-color: #4FC3F7;
}

.content {
grid-area: content;
background-color: #99CCFF;
}

.footer {
grid-area: footer;
background-color: #6699CC;
}

.header,
.left,
.content,
.footer {
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
style>
head>
<body>
<div class="header">Headerdiv>
<div class="left">Leftdiv>
<div class="content">Contentdiv>
<div class="footer">Footerdiv>
body>
html>


这个示例的小技巧是使用了grid-template-areas,使用这个属性可以让我们通过代码来直观的看到布局的样式;


这里的值是一个字符串,每一行代表一行,每个字符代表一列,例如:"header header"表示第一行的两列都是header,这里的header是我们自己定义的,可以是任意值;


定义好了之后就可以在对应的元素上使用grid-area来指定对应的区域,这里的值就是我们在grid-template-areas中定义的值;





码上掘金中的效果可以看到,左侧的菜单和内容区域都是单独滚动的,这里的实现方式和第二个示例是一样的,只需要需要滚动的元素上加上overflow: auto即可;



响应式布局


响应式布局指的是页面的布局会随着屏幕的大小而变化,这里的变化可以是内容区域大小可以自动调整,也可以是页面布局随着屏幕大小进行自动调整;


这里我就用掘金的页面来举例,这里只提供一个思路,所以不会像上面那样提供那么多示例;


1. 基础布局实现


移动端布局


image.png



以移动端的效果开始,掘金的移动端的布局就是上面的效果,这里我简单的将页面分为了三个部分,分别是headernavigationcontent


注:这里不是要100%还原掘金的页面,只是为了演示grid布局,具体页面结构和最后实现的效果会有非常大的差异,这里只会实现一些基础的布局;



html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Titletitle>
<style>
html, body {
margin: 0;
padding: 0;
}

body {
display: grid;
grid-template-areas:
"header"
"navigation"
"content";
grid-template-rows: 60px 48px 1fr;
height: 100vh;
}

.header {
grid-area: header;
background-color: #039BE5;
}

.navigation {
grid-area: navigation;
background-color: #4FC3F7;
}

.content {
grid-area: content;
background-color: #99CCFF;
}


.header,
.navigation,
.content {
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}

style>
head>
<body>
<div class="header">Headerdiv>
<div class="navigation">Navigationdiv>
<div class="content">Contentdiv>
body>
html>

iPad布局


image.png



这里是需要借助媒体查询来实现的,在媒体查询中只需要调整一下grid-template-rowsgrid-template-columns的值即可;


由于这里的效果是上面一个的延伸,为了阅读体验会移除上面相关的css代码,只保留需要修改的代码;



html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Titletitle>
<style>

.right {
display: none;
background-color: #6699CC;
}

@media (min-width: 1000px) {
body {
grid-template-areas:
"header header"
"navigation navigation"
"content right";
grid-template-columns: 1fr 260px;
}

.right {
grid-area: right;

display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
}
style>
head>
<body>
<div class="header">Headerdiv>
<div class="navigation">Navigationdiv>
<div class="content">Contentdiv>
<div class="right">Rightdiv>
body>
html>

PC端布局


image.png



和上面处理方式相同,由于Navigation移动到了左侧,所以还要额外的修改一下grid-template-areas的值;


这里就可以体现grid的强大之处了,我们可以简单的修改grid-template-areas就可以实现一个完全不同的布局,而且代码量非常少;


为了居中显示内容,我们需要在左右两侧加上一些空白区域,可以简单的使用.来实现,这里的.表示一个空白区域;


由于内容的宽度基本上是固定的,所以留白区域简单的使用1fr进行占位即可,这样就可以平均的分配剩余的空间;



html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Titletitle>
<style>
@media (min-width: 1220px) {
body {
grid-template-areas:
"header header header header header"
". navigation content right .";
grid-template-columns: 1fr 180px minmax(0, 720px) 260px 1fr;
grid-template-rows: 60px 1fr;
}
}
style>
head>
<body>
<div class="header">Headerdiv>
<div class="navigation">Navigationdiv>
<div class="content">Contentdiv>
<div class="right">Rightdiv>
body>
html>

完善一些细节


QQ录屏20231210000552.gif



最终的布局大概就是上图这样,这里主要处理的各个版块的间距和响应式内容区域的大小,这里的处理方式主要是使用column-gap和一个空的区域进行占位来实现的;


这里的column-gap表示列与列之间的间距,值可以是pxemrem等基本的长度属性值,也可以使用计算函数,但是不能使用弹性值fr


空区域进行占位留间距其实我并不推荐,这里只是演示grid布局可以实现的一些功能,具体的实现方式还是要根据实际情况来定,这里我更推荐使用margin来实现;


完整代码如下:



html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Titletitle>
<style>
html, body {
margin: 0;
padding: 0;
}

body {
display: grid;
grid-template-areas:
"header header header"
"navigation navigation navigation"
". . ."
". content .";
grid-template-columns: 1fr minmax(0, 720px) 1fr;
grid-template-rows: 60px 48px 10px 1fr;
column-gap: 10px;
height: 100vh;
}

.header {
grid-area: header;
background-color: #039BE5;
}

.navigation {
grid-area: navigation;
background-color: #4FC3F7;
}

.content {
grid-area: content;
background-color: #99CCFF;
}

.right {
display: none;
background-color: #6699CC;
}

@media (min-width: 1000px) {
body {
grid-template-areas:
"header header header header"
"navigation navigation navigation navigation"
". . . ."
". content right .";
grid-template-columns: 1fr minmax(0, 720px) 260px 1fr;
}

.right {
grid-area: right;

display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
}

@media (min-width: 1220px) {
body {
grid-template-areas:
"header header header header header"
". . . . ."
". navigation content right .";
grid-template-columns: 1fr 180px minmax(0, 720px) 260px 1fr;
grid-template-rows: 60px 10px 1fr;
}
}

.header,
.navigation,
.content {
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}

style>
head>
<body>
<div class="header">Headerdiv>
<div class="navigation">Navigationdiv>
<div class="content">Contentdiv>
<div class="right">Rightdiv>
body>
html>

简单复刻版




码上掘金上的效果来说已经完成了大部分的布局和一些效果,目前来说就是还差一些交互,还有一些细节上的处理,感兴趣的同学可以自行完善;



异型布局


异性布局指的是页面中的元素不是按照常规的流式布局进行排版,又或者说不规则的布局,这里我简单的列出几个布局,来看看grid是如何实现的;


1. 照片墙


image.png


html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Titletitle>
<style>
html, body {
margin: 0;
padding: 0;
background: #f2f3f5;
overflow: auto;
}

body {
display: grid;
grid-template-columns: repeat(12, 100px);
grid-auto-rows: 100px;
place-content: center;
gap: 6px;
height: 100vh;
}

.photo-item {
width: 200px;
height: 200px;
clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%);
}

style>
head>
<body>

body>
<script>
function randomColor() {
return '#' + Math.random().toString(16).substr(-6);
}

let row = 1;
let col = 1;
for (let i = 0; i < 28; i++) {
const div = document.createElement('div');
div.className = 'photo-item';
div.style.backgroundColor = randomColor();
div.style.gridRow = `${row} / ${row + 2}`;
div.style.gridColumn = `${col} / ${col + 2}`;

document.body.appendChild(div);
col += 2;
if (col > 11) {
row += 1;
col = row % 2 === 0 ? 2 : 1;
}
}
script>
html>


这是一个非常简单的照片墙效果,如果不使用grid的话,我们大概率是会使用定位去实现这个效果,但是换成grid的话就非常简单了;


而且代码量是非常少的,这里就不提供码上掘金的 demo 了,感兴趣的同学可以将代码复制下来自行查看效果;



2. 漫画效果


image.png




在漫画中有很多类似这种不规则的漫画框,如果使用定位的话,那么代码量会非常大,而且还需要计算一些位置,使用grid的话就非常简单了;


可以看到这里还有一个气泡文字显示的效果,按照页面书写顺序,气泡是不会显示的,这里我们可以使用z-index来实现,这里的z-index值越大,元素就越靠前;


而且气泡文字效果也是通过grid来进行排版的,并没有使用其他的布局来实现,代码量也并不多,感兴趣的同学可以自行查看;



3. 画报效果


image.png




在一个画报中,我们经常会看到文字和图片混合排版的效果,由于这里直接使用的是渐变的背景,而且我的文字都是随意进行排列的,没有什么规律,所以看起来会比较混乱;


在画报效果中看似文字排版非常混乱不规则,但是实际上设计师在设计的时候也是会划分区域的,当然用定位也是没问题的,但是使用grid的话就会简单很多;


我这里将页面划分为12 * 12区域的网格,然后依次对不同的元素进行单独排列和样式的设置;



流式布局


流式布局指的是页面的内容会随着屏幕的大小而变化,流式布局也可以理解为响应式布局;


但是不同于响应式布局的是,流式布局的布局不会像响应式布局那样发生变化,只是内容会随着轴进行流动;


通常这种指的是grid-template-columns: repeat(auto-fit, minmax(0, 1fr))这种;


直接看效果:


QQ录屏20231210222012.gif




这里有两个关键字,一个是auto-fit,还有一个是auto-fill,在行为上它们是相同的,不同的是它们在网格创建的不同,



image.png



就像上面图中看到的一样,使用auto-fit会将空的网格进行折叠,可以看到他们的结束colum的数字都是6;


像我们上面的实例中不会出现这个问题,因为我们使用了响应式单位fr,只有使用固定单位才会出现这个现象;


感兴趣的同学可以将minmax(200px, 1fr)换成200px尝试;



对比 Flex 布局


在我上面介绍了这么多的布局场景和案例,其实可以很明显的发现一件事,那就是我使用grid进行的布局基本上都是大框架;


当然上面也有一些布局使用flex也是可以实现的,但是我们再换个思路,除了flex可以做到上面的一些布局,float布局、table布局、定位布局其实也都能实现;


不同的是float布局、table布局、定位布局基本上都是一些hack的方案,就拿table布局来说,table本身就是一个html标签,作用就是用来绘制表格,被拿来当做布局的一种方案也是迫不得已;


web布局发展到现在的我们有了正儿八经可以布局的方案flex,为什么又要出一个grid呢?


grid的出现绝对不是用来替代flex的,在我上面的实现的一些布局案例中,也可以看到我还会使用flex


我个人理解的是使用grid进行主体的大框架的搭建,flex作为一些小组件的布局控制,两者搭配使用;


flex能实现一些grid不好实现的布局,同样grid也可以实现flex实现困难的布局;


本身它们的定位就不痛,flex作为一维布局的首选,grid定位就是比flex高一个维度,它的定位是二维布局,所以他们之间没有必要进行对比,合理使用就好;


总结


上面介绍的这么多基于grid布局实现的布局方案,足以看出grid布局的强大;


grid布局的体系非常庞大,本文只是梳理出一些常见的布局场景,通过grid布局去实现这些布局,来体会grid带来的便利;


可能需要完全理解我上面的全部示例需要对grid有一定的了解才可以,但是都看到这里了,不妨去深挖一下;


grid布局作为一项强大的布局技术,有望在未来继续发展,除了我上面说到的布局,grid还有很多小技巧来实现非常多的布局场景;


碍于我的见识和文笔的限制,我这次介绍grid肯定是有很多不足的,但是还是希望这篇文章能为你对于布局相关能有新的认识;


作者:田八
来源:juejin.cn/post/7310423470546354239
收起阅读 »

今天还要用 React 吗:利弊权衡

web
免责声明 本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考,英文原味版请临幸 The Pros and Cons of Using React Today。 在过去的十年中,React 因辅助整个行业的开发者构建顶尖 UI 的超能力,为大家所熟知。...
继续阅读 »




免责声明


本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考,英文原味版请临幸 The Pros and Cons of Using React Today



00-wall.jpg


在过去的十年中,React 因辅助整个行业的开发者构建顶尖 UI 的超能力,为大家所熟知。


本文在 2023 年底和 2024 年对 React 进行了深入而平衡的展望。我们将看看它值得称道的优势、明显的短板,以及对当今开发者的可靠性。


让我们先从 React 与众不同的创新功能开始,然后再将注意力转向它给开发者带来的挑战。


React JS 是什么鬼物?


ReactJS 是一个组件筑基的 JS 库,最初由 Facebook 创建,并在十年前发布。该库简化了开发者创建交互式 UI,同时有效管理组件状态。它能够为复杂 App 编写多个组件,而不会丢失它们在浏览器的 DOM(文档对象模型)中的状态,这对一大坨开发者而言是一个明显的福利。


虽然 React 主要是一个用于 Web App 的工具,但其多功能性通过 React Native 扩展到移动 App 开发。这个强大的开源库允许开发 Android、iOS 和 Windows App,展示了 React 跨平台开发的灵活性。


React 生态系统


React 最大的资源之一是其庞大的生态系统,其中充满了第三方库和工具,极大地扩展了其功能。这对于路线规划 App 等复杂项目尤其有利,这些项目通常依赖集成大量外部服务,比如地图 API 和路径算法。


React 的灵活性和与各种第三方服务的兼容性简化了集成过程,允许开发者使用高级功能增强其 App,而不会产生过多的开销。


其核心是基本的库和工具,比如 React Router,用于 SPA(单页应用程序)中的动态路由,确保无缝的 UX(用户体验)过渡。Redux 是一个关键的状态管理工具,它为状态创建了一个中心化 store,使不同的组件能够一致地访问和更新它,这在大型 App 中尤为重要。


React.js:不仅仅是复杂性


虽然 React 在 UI 创建方面表现出色,但在状态管理和 SEO 优化等领域存在不足。幸运的是,更广泛的 JS 生态系统提供了许多工具,这些工具好处多多,比如更简化的状态管理方案、通过 SSR(服务器端渲染)增强的 SEO 和数据库管理。让我们瞄一下 React 若干更突出的集成选项。


对于那些寻求更简单替代方案的人而言,MobX 提供了一种直观的状态管理方案,并且样板更少。此外,Next.js 通过提供 SSR 和 SSG(静态站点生成)解决了客户端渲染 App 的当前 SEO 限制。在开发和测试方面,CRA(Create React App)简化了设置新前端构建管道的过程,使开发者能够立即开始运行,而不会受到配置的困扰。


同时,Storybook 作为一个 UI 开发环境,开发者可以在其中独立可视化其 UI 组件的不同状态。Jest 在单元和快照测试中很受欢迎,它与 React 无缝集成。由 Airbnb 开发的 Enzyme 是一个测试工具,它简化了断言、操作和遍历 React 组件输出的过程。


额外的库和工具进一步丰富了 React 生态系统;Material-UI 和 Ant Design 提供了全面的 UI 框架,可以满足美学和功能要求,而 Axios 则提供了一个 Promise 筑基的 HTTP 客户端来发送 HTTP 请求。React Query 简化了获取、缓存和更新异步数据的过程,React Helmet 有助于管理对文档头的更改,这对于 SPA 中的 SEO 至关重要。


React 与其他技术的集成 —— 比如后端框架,包括 Node.js 和 Django;状态管理库,比如 Apollo for GraphQL,增强了其灵活性。如今,开发者甚至可以将 PDF 查看器嵌入到网站中,并大大优化 UX。


然而,React 的不断发展要求开发者跟上最新的变化和进步,React 为试图制作高质量、可扩展和可维护的 Web App 的开发者提供的无数解决方案抵消了这一挑战。


React 之利


React 已经将自己确立为构建动态和响应式 Web App 的关键库,原因如下:


组件筑基架构


传统的 JS App 在扩展时经常会遇到状态管理问题。虽然但是,React 提供了复杂的、独立维护的可复用组件,允许开发者在不影响其他页面的情况下更新网页的局部 —— 确保松耦合和协作功能。


当然,这个概念并不是 React 独有的;举个栗子,Angular 也使用组件作为基本构建块。尽管如此,React 庞大的社区、Meta 的支持和相对丝滑的学习曲线使其成为开发者的最爱。


开发中的增强定制


React 的多功能性在构建针对特定业务需求量身定制的 App 时大放异彩。尤其是其组件筑基架构允许在 App 中无缝组装复杂结构。


举个栗子,在构建集成仪表板时,React 的生态系统有助于将各种模块(比如图表、小部件和实时数据源)集成到一个有凝聚力的 UI 中,使开发者能够打造不仅功能强大,而且直观且具有视觉吸引力的 UX。


这种强大的适应性恰恰凸显了为什么 React 仍然是旨在创建多功能和健壮的 Web App 的开发者的首选。


面向未来的开发者选项


React 面向未来的特性是它为开发者提供的最引人注目的优势之一。React 灵活的架构迎合了当前的 Web 开发需求,同时也无缝地适应了将塑造行业近期的新兴技术。


值得注意的是,机器学习正在向 Web 开发领域取得重大进展,2022 年全球 ML 市场价值已经达到 210 亿美元,这凸显了 React 面向未来的特性以及与此类进步相协调的能力的重要性。


其中一个比较突出的例子是 TensorFlow.js,一个用于图像和模式识别的 ML 库。同样,React 允许集成 ML 驱动的聊天机器人甚至推荐功能。此外,WebAssembly 可以帮助允许用 Rust、Python 或 C++ 编码的 ML 应用程序存在于原生 App 中。


用于状态管理的 Redux


在 SPA 中,多个组件驻留在单个页面上,管理状态和组件间通信很快就会变得具有挑战性 —— 这正是 Redux for React 的亮点。


作为 React 不可或缺的一部分,它充当“管理器”,确保组件之间的数据流一致且准确,集中状态管理并促进组件自治性,显着提高数据稳定性和 UX。


React 之弊


虽然 React 为不同技能水平的开发者提供了许多优势,但它并非没有各自的缺点,包括以下内容:



  • 复杂的概念和高级模式:React 引入了若干高级概念和模式,这些概念和模式一开始可能会让初学者不知所措。要了解 JSX、组件、props、状态管理、生命周期方法和钩子,需要扎实掌握 JS 基础知识。

  • 与其他技术的集成复杂性:React 经常与其他工具和技术结合使用——如 Redux、React Router 和各种中间件 —— 对于新手来说,了解如何将这些技术与 React 集成可能极具挑战。

  • 非 JS 开发者的障碍:React 对 JS 的严重依赖对于不精通 JS 的开发者而言可能是一个障碍。虽然 JS 是一种通用且广泛使用的语言,但来自不同编程背景的开发者可能会发现适应 JS 的范式和 React 的使用方式极具挑战。

  • 不是一个成熟的框架:React 主要处理 MVC 的“视图”部分,也称为模型视图控制器架构。对于“模型”和“控制器”,需要额外的库,与 Angular 等功能齐全的框架相比,这最终会导致结构化程度较低且可能更加混乱的代码。

  • 代码膨胀:React.js 的特点是其大量的库和依赖需求,因其臃肿的 App 而臭名昭著。这种膨胀通常表现为较长的加载时间,尤其是在复杂的项目中。该框架的结构严重依赖其虚拟 DOM,即使是次要功能也需要加载整个库,这大大增加了 App 的数字足迹并降低了其效率。

  • 在传统设备和弱网络上的性能下降:React.js App 的性能在较旧的硬件和互联网连接较差的地区往往会下降。这主要是由于框架的客户端渲染模型和密集的 JS 处理。这些因素可能会导致渲染交互式元素的延迟,这在计算能力有限的设备或带宽有限的环境中尤为明显,这会对 UX 产生不利影响。


最终裁决


随着 Web 开发领域的不断发展,React 的灵活性和强大的生态系统使其处于有利地位。它将继续使开发者能够将尖端功能无缝地整合到其 App 中。虽然但是,虽然 React 为开发者提供了很多好处,但它仍然有其缺点。


React 的复杂性和对高级 JS 概念的依赖带来了曲折的学习曲线,尤其是对于新手或尚未精通 JS 的人。它还主要解决了 MVC 架构的“视图”方面,需要额外的工具来进行完整的 App 开发,这可能会导致更复杂和结构化更少的代码库。


尽管存在这些挑战,但庞大而活跃的 React 社区在其持续发展中发挥着至关重要的作用。在可预见的未来,它将继续成为 Web 和移动 App 开发的关键库。


作者:人猫神话
来源:juejin.cn/post/7310033153905164303
收起阅读 »

一个30岁老前端的人生经历(学习+工作+婚姻+孩子),给迷茫的朋友一点激励。

web
前言 我93年的,还差几天就到30周岁生日。做前端开发大概也有6、7年,算是老前端了。 2023 写作 在掘金看文章已经很多年了,学到了很多东西。今年三月份在掘金写下了第一篇文章,开始的想法是把自己的开发笔记整理一下写成文章,来巩固一下自己的知识,没想到在写文...
继续阅读 »

前言


我93年的,还差几天就到30周岁生日。做前端开发大概也有6、7年,算是老前端了。


2023


写作


在掘金看文章已经很多年了,学到了很多东西。今年三月份在掘金写下了第一篇文章,开始的想法是把自己的开发笔记整理一下写成文章,来巩固一下自己的知识,没想到在写文章分享的过程中帮助了很多人,也得到了很多人认可,让我写文章分享的动力也越来越强,基本每周都会写一篇,闲在没事的时候基本都是在构思下一篇文章写什么。希望明年能写更多的文章,帮助更多的人。


写作给我带来了什么



  1. 巩固知识。把自己学到的东西,分享出去,印象更深刻了。

  2. 更多的机会。写作过程中,有大厂私信我,给我一些面试机会,这个时候的面试机会还是很宝贵的,不过因为一些原因都拒绝了。

  3. 快乐。很多人私信我说很感谢我分享的东西,让他们学到了很多东西。看到这些感谢的话,自己得到认可,还是很开心的。

  4. 钱。写文章参与掘金的金石计划活动,陆陆续续差不多获得了接近1000的收入,每次拿到钱,带着老婆孩子去吃顿好的,还是不错的。


降薪


今年公司受大环境影响,裁了一部分人,留下来的人也都降薪了。开始有点接受不了,想跑,但是因为是在上海嘉定郊区,附近找不到好的工作,最近的也要1个小时的地铁,并且小孩刚上一年级,上海基本不可能跨区转校,所以打消了换工作的念头,只能在公司了干下去,相信公司会好起来的。


家庭


看了上面肯定有倔友怀疑,30岁小孩怎么能上一年级?不错我大四结的婚,还是奉子成婚,所以早早的有了孩子。


因为自己小时候是留守儿童,不想让自己孩子过留守儿童的生活,所以孩子一直是和我们在一起,记得我刚出来工作的时候,一个月才3500,我老婆全职带孩子,这些薪资刚够花销,生活过的比较拮据,有时候还要靠我父母接济。


现在收入稍微好了一些,但是我们还没有买房子,存款也不多,在别人看来压力可能会有点大,但是我心比较大,平时消费欲望也比较低,对钱不是那么渴望,一家人在一起也是开开心心的,不过还是想给老婆孩子一个自己的家,努力奋斗吧。


孩子今年上一年级了,再上一年级后,作业明显变多了,每天都要写到很晚,看着孩子很累,也没办法,不写好作业,第二天老师就会在群里点名说。


孩子比较调皮,经常在学校里和同学打架,最多的时候,一周被班主任叫了三次家长,有时候是他的错,有时候是别人的错。因为老婆全职在家带孩子,这些都是我老婆处理的,她最近有点焦虑,每天都担惊受怕的,害怕孩子在学校又闯祸,整的我也有点焦虑,工作状态有点差。


关于孩子打架的事,我和老婆猜测可能是学习压力太大了,每天放学回来就开始写作业,一直写到睡觉,平时还有一些兴趣班要上,玩的时间太少了,积累了很多怨气没地方发泄,所以比较暴躁。现在每天放学后先让他玩半个小时再写作业,并且和他多次沟通,告诉他暴力解决问题是不对的,目前稍微好了一些。有这方面经验的兄弟,可以在评论区指点一下。


健康


今年五月份的时候,身体有点不舒服,平时熬夜比较多,人也比较胖,就想着去体检一下,体检结果肝功能有一项转氨酶比正常高三倍,然后到医院做了一次全身体检,抽了9管血,结果还好没啥大问题,可能是脂肪肝导致的转氨酶很高,医生建议要减肥。


因为平时比较忙,没有时间健身,就搞了个自行车上下班骑,公司离家大概5公里左右,上下班每天骑10公里左右,从6月份买车到现在基本没断过,虽然体重没有降下来,但是精神状态和体力好了不少,以前稍微有点运动量,就气喘吁吁全身冒汗,现在好多了。


希望倔友们多注意健康,少熬夜,身体才是最重要的。


2024展望


关于2024,立几个flag吧



  1. 最少分享40篇文章

  2. 完善fluxy-admin平台,把前后端低代码平台集成进来,做出一个企业级低代码平台开源出去。

  3. 看react源码,并做个专栏分享。每次面试,被面试官问react底层一些东西的时候,回答的都不是很好,就是因为没有彻底了解底层,所以回答的都很片面,明年一定要把react吃透。今年年初买了卡颂大佬的react设计原理书籍,现在在床头吃灰呢。

  4. 减肥


个人真实经历分享


给大家分享一下我的个人真实经历,与君共勉。


我出生在一个很普通的农村家庭,有点小聪明,但是贪玩,高中三年基本都是看电子书度过,天天上课把手机放在书下面,装作看书,实际上都是在看小说,现在回想起来,想不通老师为啥重来没有发现过。


开始决定好好学习是高三上学期,有次上课和同桌说话,被老师说你自己不学,不要影响别人学习,还说了一些很难听的话(当时我在班里大概倒数10几名的样子,同桌10几名左右。),虽然我贪玩,但是我自尊心比较强,我就不服气,然后上课开始好好听课,后面一次月考竟然考到了10几名,和同桌成绩差不多,然后就开始飘了,上课又开始看小说,下次月考又考的很差,然后难受,又开始好好听课,就这样成绩一会好一会坏,不过拿了几次进步奖,同学笑话我是不是为了拿进去奖,故意退步的。


真正让我决定好好学习的是高三下学期开学的前一天晚上,我家庭条件不是特别好,而我当时因为中考考的很差,只能上一个学费比较贵的私立高中,高三下学期开学前一天晚上我爸还在为我筹学费(家里没有穷到付不起学费的地步,只是当前家里钱被其他地方占用了,拿不出来。),最终从亲戚那里借了点钱,然后我爸把钱交到我手里,让我明天交学费,看着我爸粗糙的手(我爸是干工地的),这一刻我决定好好学习,不然都对不起这学费。高三下学期上课就没看过手机了,由于底子太差,高考离二本线差了几分,最终上了个三本。


高考结束,暑假期间迷上了英雄联盟。大学的时候,室友也玩,经常和室友一块包夜,第二天要么旷课在宿舍睡觉,要么在教室最后一排睡觉,导致第一学期就挂了三科,不过后面补考都过了。后面还是继续玩,大二下学期突然觉得不能这样浑浑噩噩了,还不如出去打工,给家里省点学费还能挣点钱(不知道当时为啥有这想法),然后就和父母说了一下,不上学了出去打工,当时是想退学的,还好我好朋友和我说先休学吧,以后后悔还有机会。


在苏州找了一个工厂,干了一个星期干不下去了,身体上的劳累倒是其次,主要是看不到生活的希望,每天就像一个机器一样,后面就回去上学了,然后学习非常努力,后面还得了奖学金,毕业论文也被评上了优秀论文,也是优秀毕业生,但是毕业学校没有给学位证,只给了毕-业-证,因为挂科超过5门(补考过了也没用,只要挂科超过5门,就完了,我们那一届有不少没有学位证的。),这个政策最开始都不知道,没有学位证后问学校,学校才说的,也不能怨学校,算是自食其果吧。没有学位证对找工作还是有很大影响的,后面有几次面试通过大厂了,因为没有学位证而被拒。


实习的时候,实习单位和学校是有合作的,学校知道我的事迹也知道我在实习单位表现的不错,所以就邀请我回去给学弟学妹们分享我的经历。当时分享完后,有几个学弟加我微信说,他们现在也是这个状态,我的经历让他们有了重新开始的信心。


后面的工作之旅也是一路坎坷,不过最后的结果是好的,目前在公司里做前端负责人,收入还不错。工作之旅明年年终总结再和大家分享吧。


和大家分享我的经历,就是想告诉大家永远不要放弃,只要坚持,就会有希望,同时也想告诉大家每个人都要为自己做过的事负责,因为贪玩我没考上好一点的大学,因为贪玩我没有学位证,但是我后面迷途知返,通过自己的努力,还是得到了一份不错的工作,一个美满的家庭。


最后


很喜欢deft在夺冠时说的一句话:我唯一会的仅剩英雄联盟,如果在这条路上我不能成功,那我的人生将没有任何意义。


而我唯一会的就是写代码,我不一定能成功,但是我想努力做到更好。


作者:前端小付
来源:juejin.cn/post/7310549035965890614
收起阅读 »

客户要一次加载上千张轮播图如何丝滑地实现?不用第三方(没找到),十来行核心代码实现

web
引言 最近再做3D的大屏,然后客户发来1G的图片压缩包,让我全放上去当轮播图 这不得卡死啊,现成且现代化好用的第三方库没找到 于是又到了我最爱的实现源码环节,核心代码十多行即可 底部有源码 思路 压缩图片 轮播只需要两张,来回交换,用点障眼法就是无缝了 批...
继续阅读 »

引言


最近再做3D的大屏,然后客户发来1G的图片压缩包,让我全放上去当轮播图


这不得卡死啊,现成且现代化好用的第三方库没找到


于是又到了我最爱的实现源码环节,核心代码十多行即可


底部有源码


思路



  • 压缩图片

  • 轮播只需要两张,来回交换,用点障眼法就是无缝了


批量压缩


这个用canvas就能实现,于是我写了个HTML来批量压缩


canvas转存图片时,使用jpeg | webp的格式即可压缩,MDN上有


使用canvas.toBlob可以压缩更多空间,这个不是重点,提一嘴而已


image.png


虚拟无缝轮播实现


直接一张动图,清晰明了的解决问题


t.gif


是不是看起来很简单,加载前两张图,当动画结束时,改变移动那张图的src,同时位移


再加个overflow: hidden不就行了吗


e.gif


编码实现


HTMLCSS我就不贴了,这个没什么难度,容器固定大小,子元素排列一行


然后给包装的容器添加个transform即可


下面是用vue3写的,定义了一个imgIndexArr数组装当前要显示的索引


_data为计算属性,根据imgIndexArr自动变化,里面放的就是图片


我们只需要修改imgIndexArr即可实现数据切换


image.png


我们需要在动画完成时改变,即添加ontransitionend事件


当触发next方法,图片滚动停止后,就要执行onTransitionEnd


定义俩变量,一个代表最左边的图,一个为右边的图


这里根据变量,决定谁会更新src,并且改变left值实现位移,不好描述啊


transform会一直向右位移,left值也是,所以他们会形成永动机


image.png


HTML里写上他们位移的样式即可自动更新


image.png


Bug


至此,看着已完成,似乎没有任何问题


但是你把页面隐藏了过后,过一会图片全都不见了,我们打开控制台看看为什么


可以看到,left停止更新了,也就是说,onTransitionEnd没有执行


image.png


transitionend在你浏览器隐藏页面时,就会停止执行


这时需要在页面隐藏时,停止执行,执行如下代码即可


/** 离开浏览器时 不会执行`transitionend` 所以要停止 */
function bindEvent() {
window.addEventListener('visibilitychange', () => {
document.hidden
? stop()
: play()
})
}

这时一定有人会说,你这不能往左啊,没有控制组件啊


如果要往左的话,只需要把两张图轮流交换改成4张图即可


具体逻辑都是差不多的



源码: gitee.com/cjl2385/dig…



作者:寅时码
来源:juejin.cn/post/7310111620368597011
收起阅读 »

吐槽大会,来瞧瞧资深老前端写的代码

web
忍无可忍,不吐不快。 本期不写技术文章,单纯来吐槽下公司项目里的奇葩代码,还都是一些资深老前端写的,希望各位对号入座。 知道了什么是烂代码,才能写出好代码。 别说什么代码和人有一个能跑就行的话,玩笑归玩笑。 人都有菜的时候,写出垃圾代码无可厚非,但是工作几年...
继续阅读 »

忍无可忍,不吐不快。



本期不写技术文章,单纯来吐槽下公司项目里的奇葩代码,还都是一些资深老前端写的,希望各位对号入座。


知道了什么是烂代码,才能写出好代码。


别说什么代码和人有一个能跑就行的话,玩笑归玩笑。


人都有菜的时候,写出垃圾代码无可厚非,但是工作几年了还是写垃圾代码,有点说不过去。


我认为的垃圾代码,不是你写不出深度优先、广度优先的算法,也不是你写不出发布订阅、策略模式的 if else


我认为垃圾代码都有一个共性:看了反胃。就是你看了天然的不舒服,看了就头疼的代码,比如排版极丑,没有空格、没有换行、没有缩进。


优秀的代码就是简洁清晰,不能用策略模式优化 if else 没关系,多几行代码影响不大,你只要清晰,就是好代码。


有了差代码、好代码的基本标准,那我们也废话不多说,来盘盘这些资深前端的代码,到底差在哪里。


-------------更新------------


集中回答一下评论区的问题:


1、项目是原来某大厂的项目,是当时的外包团队写的,整体的代码是还行的。eslintcommitlint 之类的也是有的,只不过这些都是可以禁用的,作用全靠个人自觉。


2、后来项目被我司收购,原来的人也转为我司员工,去支撑其他项目了。这个项目代码也就换成我们这批新来的维护了。很多代码是三几年前留下的确实没错,谁年轻的时候没写过烂代码,这是可以理解的。只不过同为三年前的代码,为啥有人写的简单清晰,拆分清楚,有的就这么脏乱差呢?


3、文中提到的很多代码其实是最近一年写的,写这些功能的前端都是六七年经验的,当时因为项目团队刚组建,没有前端,抽调这些前端过来支援,写的东西真的非常非常不规范,这是让人难以理解的,他们在原来的项目组(核心产品,前端基建和规范都很厉害)也是这么写代码的吗?


4、关于团队规范,现在的团队是刚成立没多久的,都是些年轻人,领导也不是专业前端,基本属于无前端 leader 的状态,我倒是很想推规范,但是我不在其位,不好去推行这些东西,提了建议,领导是希望你简单写写业务就行(说白了我不是 leader 说啥都没用)。而且我们这些年轻前端大家都默认打开 eslint,自觉性都很高,所以暂时都安于现状,代码 review 做过几次,都没太大的毛病,也就没继续做了。


5、评论区还有人说想看看我写的代码,我前面文章有,我有几个开源项目,感兴趣可以自己去看。项目上的代码因为涉及公司机密,没法展示。


6、本文就是篇吐槽,也不针对任何人,要是有人看了不舒服,没错,说的就是你。要是没事,大家看了乐呵乐呵就行。轻喷~


文件命名千奇百怪


同一个功能,都是他写的,三个文件夹三种奇葩命名法,最重要的是第三个,有人能知道它这个文件夹是什么功能吗?这完全不知道写的什么玩意儿,每次我来看代码都得重新理一遍逻辑。


image.png


组件职责不清


还是刚才那个组件,这个 components 文件夹应该是放各个组件的,但是他又在底下放一个 RecordDialog.jsx 作为 components 的根组件,那 components 作为文件名有什么意义呢?


image.png


条件渲染逻辑置于底层


这里其实他写了几个渲染函数,根据客户和需求的不同条件性地渲染不同内容,但是判断条件应该放在函数外,和函数调用放在一起,根据不同条件调用不同渲染函数,而不是函数内就写条件,这里就导致逻辑内置,过于分散,不利于维护。违反了我们常说的高内聚、低耦合的编程理念。


image.png


滥用、乱用 TS


项目是三四年前的项目了,主要是类组件,资深前端们是属于内部借调来支援维护的,发现项目连个 TS 都没有,不像话,赶紧把 TS 配上。配上了,写了个 tsx 后缀,然后全是无类型或者 anyscript。甚至于完全忽视 TSESlint 的代码检查,代码里留下一堆红色报错。忍无可忍,我自己加了类型。


image.png


留下大量无用注释代码和报错代码


感受下这个注释代码量,我每次做需求都删,但是几十个工程,真的删不过来。


image.png


image.png


丑陋的、隐患的、无效的、混乱的 css


丑陋的:没有空格,没有换行,没有缩进


隐患的:大量覆盖组件库原有样式的 css 代码,不写类名实现 namespace


无效的:大量注释的 css 代码,各种写了类名不写样式的垃圾类名


混乱的:从全局、局部、其他组件各种引入 css 文件,导致样式代码过于耦合


image.png


一个文件 6 个槽点


槽点1:代码的空行格式混乱,影响代码阅读


槽点2:空函数,写了函数名不写函数体,但是还调用了!


槽点3:函数参数过多不优化


槽点4:链式调用过长,属性可能存在 undefined 或者 null 的情况,代码容易崩,导致线上白屏


槽点5:双循环嵌套,影响性能,可以替换为 reduce 处理


槽点6:参数、变量、函数的命名随意而不语义化,完全不知道有何作用,且不使用小驼峰命名


image.png


变态的链式取值和赋值


都懒得说了,各位观众自己看吧。


image.png


代码拆分不合理或者不拆分导致代码行数超标


能写出这么多行数的代码的绝对是人才。


尽管说后来维护的人加了一些代码,但是其初始代码最起码也有近 2000 行。


image.png


这是某个功能的数据流文件,使用 Dva 维护本身代码量就比 Redux 少很多了,还是能一个文件写出这么多。导致到现在根本不敢拆出去,没人敢动。


image.png


杂七杂八的无用 js、md、txt 文件


在我治理之前,充斥着非常多的无用的、散乱的 md 文档,只有一个函数却没有调用的 js 文件,还有一堆测试用的 html 文件。


实在受不了干脆建个文件夹放一块,看起来也要舒服多了。


image.png


less、scss 混用


这是最奇葩的。


image.png


特殊变量重命名


这是真大佬,整个项目基建都是他搭的。写的东西也非常牛,bug 很少。但是大佬有一点个人癖好,例如喜欢给 window 重命名为 G。这虽然算不上大缺点,但真心不建议大家这么做,window 是特殊意义变量,还请别重命名。


const G = window;
const doc = G.document;

混乱的 import


规范的 import 顺序,应该是框架、组件等第三方库优先,其次是内部的组件库、包等,然后是一些工具函数,辅助函数等文件,其次再是样式文件。乱七八糟的引入顺序看着都烦,还有这个奇葩的引入方式,直接去 lib 文件夹下引入组件,也是够奇葩了。


image.png


写在最后


就先吐槽这么多吧,这些都是平时开发过程中容易犯的错误。


要想保持一个好的编码习惯,写代码的时候就得时刻告诉自己,你的代码后面是会有人来看的,不想被骂就写干净点。


我觉得什么算法、设计模式都是次要的,代码清晰,数据流向清晰,变量名起好,基本 80% 不会太差。


写代码就像做人,现实总是千难万苦,各种妥协和无奈,但是这不意味着我们可以无底线的做事。给自己设个底线,不论做人还是做事。


共勉。


作者:北岛贰
来源:juejin.cn/post/7265505732158472249
收起阅读 »

【Java集合】双列集合HashMap的概念、特点及使用

HashMap是Java中的一个集合类,它实现了Map接口,提供了一种存储键值对的方式。你可以把它想象成一个没有固定大小和形状的储物柜,你可以随时往里面放东西,也可以随时取出东西。而且,这个储物柜还有一个神奇的功能,那就是无论你放进去的是什么,取出来的总是你放...
继续阅读 »

HashMap是Java中的一个集合类,它实现了Map接口,提供了一种存储键值对的方式。你可以把它想象成一个没有固定大小和形状的储物柜,你可以随时往里面放东西,也可以随时取出东西。而且,这个储物柜还有一个神奇的功能,那就是无论你放进去的是什么,取出来的总是你放进去的那个。

上篇文章讲了Map接口的概念,以及Map接口中的常用方法和对Map集合的遍历,本篇文章我们将继续介绍另一个十分重要的双列集合—HashMap。


HashMap 概念

HashMap集合是Map接口的一个实现类,它用于存储键值映射关系,该集合的键和值允许为空,但键不能重复,且集合中的元素是无序的。

特点

HashMap底层是由哈希表结构组成的,其实就是“数组+链表”的组合体,数组是HashMap的主体结构,链表则主要是为了解决哈希值冲突而存在的分支结构。正因为这样特殊的存储结构,HashMap集合对于元素的增、删、改、查操作表现出的效率都比较高。

结构

在java1.8以后采用数组+链表+红黑树的形势来进行存储,通过散列映射来存储键值对,如下图:

Description

  • 在初始化时将会给定默认容量为16

  • 对key的hashcode进行一个取模操作得到数组下标

  • 数组存储的是一个单链表

  • 数组下标相同将会被放在同一个链表中进行存储

  • 元素是无序排列的

  • 链表超过一定长度(TREEIFY_THRESHOLD=8)会转化为红黑树

  • 红黑树在满足一定条件会再次退回链表

看到这个图,是不是挺熟悉!没错,这个就是我们在讲Set时,它的内存结构图,当时我们说 HashSet的底层就是 Map集合,只不过Set只使用了Map集合中的Key,没有使用Value而已。

小练习

在之前我们已经讲了不少Map的使用方法,本篇中就不做过多解释了,来上了个小练习,在体会下它的使用。

每位学生(姓名,年龄)都有自己的家庭住址。那么,既然有对应关系,则将学生对象和家庭住址存储到map集合中。学生作为键, 家庭住址作为值。

注意,学生姓名相同并且年龄相同视为同一名学生。

编写学生类:

    public class Student {
private String name;
private int age;

public Student() {
}

public Student(String name, int age) {
this.name = name;
this.age = age;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public int getAge() {
return age;
}

public void setAge(int age) {
this.age = age;
}

@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
Student student = (Student) o;
return age == student.age && Objects.equals(name, student.name);
}

@Override
public int hashCode() {
return Objects.hash(name, age);
}
}

编写测试类:

    public class HashMapTest {
public static void main(String[] args) {
//1,创建Hashmap集合对象。
Map<Student,String>map = new HashMap<Student,String>();
//2,添加元素。
map.put(newStudent("lisi",28), "上海");
map.put(newStudent("wangwu",22), "北京");
map.put(newStudent("zhaoliu",24), "成都");
map.put(newStudent("zhouqi",25), "广州");
map.put(newStudent("wangwu",22), "南京");

//3,取出元素。键找值方式
Set<Student>keySet = map.keySet();
for(Student key: keySet){
Stringvalue = map.get(key);
System.out.println(key.toString()+"....."+value);
}
}
}
  • 当给HashMap中存放自定义对象时,如果自定义对象作为key存在,这时要保证对象唯一,必须复写对象的hashCode和equals方法(如果忘记,请回顾HashSet存放自定义对象)。

  • 如果要保证map中存放的key和取出的顺序一致,可以使用java.util.LinkedHashMap集合来存放。

你还在苦恼找不到真正免费的编程学习平台吗?可以试试云端源想!课程视频、在线书籍、在线编程、实验场景模拟、一对一咨询……你想要的全部学习资源这里都有,重点是统统免费!点这里即可查看

LinkedHashMap

我们知道HashMap保证成对元素唯一,并且查询速度很快,可是成对元素存放进去是没有顺序的,那么我们要保证有序,还要速度快怎么办呢?

在HashMap下面有一个子类LinkedHashMap,它继承自HashMap。特别的是,LinkedHashMap在HashMap的基础上维护了一个双向链表,可以按照插入顺序或者访问顺序来迭代元素。此外,LinkedHashMap结合了HashMap的数据操作和LinkedList的插入顺序维护的特性,因此也可以被看做是HashMap与LinkedList的结合。它是链表和哈希表组合的一个数据存储结构。把上个练习使用LinkedHashMap的使用一下

    publicclass LinkedHashMapDemo {
publicstaticvoid main(String[] args) {

//Map<String, String> map = new HashMap<String, String>();

LinkedHashMap<String, String> map = new LinkedHashMap<String, String>();
map.put("马云", "阿里巴巴");
map.put("马化腾", "腾讯");
map.put("李彦宏", "百度");
Set<Entry<String, String>> entrySet = map.entrySet();
for (Entry<String, String> entry : entrySet) {
System.out.println(entry.getKey() + " " + entry.getValue());
}
}
}

总结

总的来说,HashMap是Java中的一个强大工具,它可以帮助我们高效地处理大量的数据。但是,我们也需要注意,虽然HashMap的性能很高,但如果不正确地使用它,可能会导致内存泄漏或者数据丢失的问题。因此,我们需要正确地理解和使用HashMap,才能充分发挥它的强大功能。

本系列文章写到这里,为大家介绍集合家族的知识,基本上就可以告一段落了。

在这个系列文章中,我们讲述了单列和双列集合的家族体系以及简单的使用。集合中不少的实现类,我们并未讲述,大家下来可以通过java的API文档,去学习使用。还是那句话,熟能生巧!只看不练,假把式!

本系列以上内容,都是在实际项目中,会经常碰到这些概念的使用,当然了,文中的内容可能也不是尽善尽美的,如有错误,可以私信,探讨!
happy ending!

收起阅读 »

教你如何实现一个页面自动打字效果

web
前言: 最近在写一个类似于 windows 启动页的项目,不知道大家是否还记的 windows 很经典的小光标加载页面,我又稍微改造了一下效果如下: 一. 光标闪烁效果的实现 tips: 在这里我们使用了 UnoCSS,如果你不清楚 UnoCSS 的使用方...
继续阅读 »

前言: 最近在写一个类似于 windows 启动页的项目,不知道大家是否还记的 windows 很经典的小光标加载页面,我又稍微改造了一下效果如下:


loading.gif




一. 光标闪烁效果的实现


tips: 在这里我们使用了 UnoCSS,如果你不清楚 UnoCSS 的使用方法,那你可以点击下面这篇文章。

🫱 🎁手把手教你创建自己的代码仓库



  1. 首先准备一块黑色的背景。

    image.png

  2. 其实光标的样式非常非常简单,仅仅只需要你创建一个宽高合适的 div,然后创建一个底部的 border 效果即可。

    image.png
    下面应该是你目前的效果。

    image.png

  3. 现在需要清楚的知道,这个白块的展示其实就是我们控制展示这个 divborder 的显示还是隐藏。那么现在我们的思路就很清晰了,所以这里我们只需要写一个变量来动态的切换这个 border 值即可。

    image.png

  4. 现在你的页面效果应该是漆黑一片,那交给谁来动态的切换这个状态呢?这里其实很简单,当页面挂载的时候,我们只需要开启一个定时器来动态切换即可。

    image.png

    这时候我们其实就能看到一丢丢效果了:

    flash.gif


二. 自动打字效果的实现



  1. 首先我们应该明确一个概念,我们目前要做的事很简单,只需要在百块 div 的前面插入文字其实就是在向后推白块

    image.png

    image.png

    所以白块的移动是我们无需关心的,我们仅仅只需要去处理如何插入字体的问题。

  2. 这里我们先准备一个常量来书写一段字符串文字,然后还需要给准备放文字的 div 打上 ref 为后面的工作做准备,之后我们需要用到它身上相关的属性。

    image.png

  3. 接下来我们要编写一个函数去处理这个问题,名字起的就随意点吧,就叫做 autoPrint

    image.png

  4. 这里我们仍需要开启一个循环定时器去控制,因为我们无法得知文字具体有多少,不考虑使用 setTimeout

    image.png

  5. 还需要准备两个变量,来存放接下来我们要处理的文字信息。

    image.png

  6. 下面代码的思路就比较简单了,其实就是调用了 substring 方法来一直切割获取下一个字符串的值。substring本身也是不改变原字符串的,所以我们只需要控制 index 就可以很轻松的获取到最后的值。

    image.png

    效果如下:

    3.gif

  7. 最后别忘了在合适的时机清除这个定时器。

    image.png


三. 更优雅的实现小方块闪烁


更新于 2023/02/22



  1. 在写上面的代码之前我没有考虑文字过长的问题,导致小光标不会换行的问题。

  2. 今天更新一下,修复这个 bug

    自动.gif

  3. 我们删除上面之前控制 border 的显示与否而展示的小光标样式。

    image.png

  4. 在放置文字的 div 添加一个伪元素来实现这个效果,更加简洁一点。

    image.png

  5. 并且使用动画来替换之前的 flicker
    image.png


四. 源码



<script>

//tips: automatic printing welcome words.
function autoPrintText(text: string) {
let _str = ""
let _index = 0
const _timerID = window.setInterval(() => {
if (!textAreas.value) return
if (_index > text.length - 1) {
clearInterval(_timerID)
return
}
_str = _str + text.substring(_index, _index + 1)
textAreas.value!.innerText = _str
_index++
}, printSpeed)
}

</script>

<template>

<div v-if="isFlicker" class="w-full h-full">
<div class="text-box w-fit">
<span ref="textAreas" class="text-1.8rem font-600"></span>
</div>
</template>

<style scoped>
.text-box::after {
display: inline-block;
content: "";
width: 2rem;
vertical-align: text-bottom;
border-bottom: 3px solid white;
margin-left: 8px;
animation: flicker 0.5s linear infinite;
}

@keyframes flicker {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

预告


最近在实现一个 window 的全套 UI ,PC 和移动端的效果是完全自适应的,两者有两套 UI

4.gif

我会在本周更新拖拽这个经典面试题的实现,仍会使用费曼学习法通俗易懂的讲解。如果你有兴趣,不妨保持关注。🎁


作者:韩振方
来源:juejin.cn/post/7200773486796914725
收起阅读 »

前端如何使用websocket发送消息

web
1 基础介绍 1.1 什么是WebSocket WebSocket 是一种在单个 TCP 连接上进行 全双工 通信的协议,它可以让客户端和服务器之间进行实时的双向通信。与传统的 HTTP 请求不同,WebSocket 使用了一个长连接,在客户端和服务器之间保...
继续阅读 »

1 基础介绍


1.1 什么是WebSocket



WebSocket 是一种在单个 TCP 连接上进行 全双工 通信的协议,它可以让客户端和服务器之间进行实时的双向通信。与传统的 HTTP 请求不同,WebSocket 使用了一个长连接,在客户端和服务器之间保持持久的连接,从而可以实时地发送和接收数据。




在 WebSocket 中,客户端和服务器之间可以互相发送消息。
客户端可以使用 JavaScript 中的 WebSocket API 发送消息到服务器,也可以接收服务器发送的消息。



1.2 WebSocket与HTTP的区别



WebSocket与HTTP的区别在于连接的性质和通信方式。WebSocket是一种双向通信的协议,通过一次握手即可建立持久性的连接,服务器和客户端可以随时发送和接收数据。而HTTP协议是一种请求-响应模式的协议,每次通信都需要发送一条请求并等待服务器的响应。WebSocket的实时性更好,延迟更低,并且在服务器和客户端之间提供双向的即时通信能力,适用于需要实时数据传输的场景。



1.3 代码示例



下面是一个使用 WebSocket API 发送消息的代码示例:



var socket = new WebSocket("ws://example.com/socketserver");

socket.onopen = function(event) {
socket.send("Hello server!");
};

socket.onmessage = function(event) {
console.log("Received message from server: " + event.data);
};

socket.onerror = function(event) {
console.log("WebSocket error: " + event.error);
};

socket.onclose = function(event) {
console.log("WebSocket connection closed with code " + event.code);
};


在上面的代码中,首先创建了一个 WebSocket 对象,指定了服务器的地址。然后在 onopen 回调函数中,发送了一个消息到服务器。当服务器发送消息到客户端时,onmessage 回调函数会被触发,从而可以处理服务器发送的消息。如果出现错误或者连接被关闭,onerror 和 onclose 回调函数会被触发,从而可以处理这些事件。


需要注意的是,在使用 WebSocket 发送消息之前,必须先建立 WebSocket 连接。在上面的代码中,通过创建一个 WebSocket 对象来建立连接,然后在 onopen 回调函数中发送消息到服务器。如果在连接建立之前就尝试发送消息,那么这些消息将无法发送成功。



2 前端使用WebSocket的流程


2.1 创建WebSocket对象


通过JavaScript中的new WebSocket(URL)方法创建WebSocket对象,其中URL是WebSocket服务器的地址。根据实际情况修改URL以与特定的WebSocket服务器进行连接。例如:


const socket = new WebSocket('ws://localhost:8000');

2.2 监听WebSocket事件


WebSocket对象提供多种事件用于监听连接状态和接收消息,例如:open、message、close、error等。



  • open:当与服务器建立连接时触发。

  • message:当收到服务器发送的消息时触发。

  • close:当与服务器断开连接时触发。

  • error:当连接或通信过程中发生错误时触发。


通过添加事件监听器,可以在相应事件发生时执行特定的逻辑。例如:


socket.addEventListener('open', () => {
console.log('WebSocket连接已建立');
});

socket.addEventListener('message', (event) => {
const message = event.data;
console.log('收到消息:', message);
});

socket.addEventListener('close', () => {
console.log('WebSocket连接已断开');
});

socket.addEventListener('error', (error) => {
console.error('发生错误:', error);
});

2.3 发送消息


通过WebSocket对象的send(data)方法发送消息,其中data是要发送的数据,可以是字符串、JSON对象等。可以根据实际需求将数据格式化成特定的类型进行发送。例如:


const message = 'Hello, server!';
socket.send(message);

2.4 关闭WebSocket连接


当通信结束或不再需要与服务器通信时,需要关闭WebSocket连接以释放资源。通过调用WebSocket对象的close()方法可以主动关闭连接,也可以根据业务需求设置自动关闭连接的条件。例如:


socket.close();

3 前端发送消息的应用实例


一个常见的前端发送消息的应用实例是在线聊天应用。在这种应用中,前端通过WebSocket与后端服务器建立连接,并实时发送和接收聊天消息。


以下是一个简单的前端发送消息的示例代码:


const socket = new WebSocket('ws://localhost:8000');

// 连接建立事件
socket.addEventListener('open', () => {
console.log('WebSocket连接已建立');
});

// 消息接收事件
socket.addEventListener('message', (event) => {
const message = event.data;
console.log('收到消息:', message);
// 处理接收到的消息,将其显示在前端界面上
});

// 发送消息
function sendMessage(message) {
socket.send(message);
}

// 调用发送消息的函数,例如在点击按钮后发送消息
const sendButton = document.getElementById('sendBtn');
sendButton.addEventListener('click', () => {
const messageInput = document.getElementById('messageInput');
const message = messageInput.value;
sendMessage(message);
messageInput.value = ''; // 清空输入框
});

// 连接关闭事件
socket.addEventListener('close', () => {
console.log('WebSocket连接已断开');
});

// 连接错误事件
socket.addEventListener('error', (error) => {
console.error('发生错误:', error);
});


该示例中,通过创建WebSocket对象,监听连接建立事件、消息接收事件、连接关闭事件和错误事件,从而实现与服务器的实时通信。通过构建界面和处理消息的逻辑,可以实现实时聊天功能。


这只是一个简单的示例,实际上,前端发送消息的应用可以更广泛,如实时数据更新、多人协作编辑、实时游戏等。具体的实现方式和功能根据实际需求而定,可以灵活调整和扩展。



4 WebSocket的应用场景


WebSocket的应用场景包括但不限于以下几个方面:



  1. 实时聊天应用:WebSocket能够提供双向、实时的通信机制,使得实时聊天应用能够快速、高效地发送和接收消息,实现即时通信。

  2. 实时协作应用:WebSocket可以用于实时协作工具,如协同编辑文档、白板绘画、团队任务管理等,团队成员可以实时地在同一页面上进行互动和实时更新。

  3. 实时数据推送:WebSocket可以用于实时数据推送场景,如股票行情、新闻快讯、实时天气信息等,服务器可以实时将数据推送给客户端,确保数据的及时性和准确性。

  4. 多人在线游戏:WebSocket提供了实时的双向通信机制,适用于多人在线游戏应用,使得游戏服务器能够实时地将游戏状态和玩家行为传输给客户端,实现游戏的实时互动。

  5. 在线客服和客户支持:WebSocket可以用于在线客服和客户支持系统,实现实时的客户沟通和问题解决,提供更好的用户体验,减少等待时间。



WebSocket适用于需要实时双向通信的场景,在这些场景中,它能够提供更好的实时性、低延迟和高效性能,为Web应用程序带来更好的交互性和用户体验。



作者:李泽南
来源:juejin.cn/post/7277835425959886882
收起阅读 »

面试官:你知道websocket的心跳机制吗?

web
前言 哈喽,大家好,我是泽南Zn👨‍🎓。在之前的一篇文章写到, 前端如何使用websocket发送消息,websocket是怎么建立连接的呢?如果断开了会怎样?如何一直保持长连接呢?接下来,本篇文章将会带你了解--- WebSocket心跳机制 一、...
继续阅读 »

前言


哈喽,大家好,我是泽南Zn👨‍🎓。在之前的一篇文章写到, 前端如何使用websocket发送消息,websocket是怎么建立连接的呢?如果断开了会怎样?如何一直保持长连接呢?接下来,本篇文章将会带你了解--- WebSocket心跳机制


一、WebSocket心跳机制


前端实现WebSocket心跳机制的方式主要有两种:




  1. 使用setInterval定时发送心跳包。

  2. 在前端监听到WebSocket的onclose()事件时,重新创建WebSocket连接。



第一种方式会对服务器造成很大的压力,因为即使WebSocket连接正常,也要定时发送心跳包,从而消耗服务器资源。第二种方式虽然减轻了服务器的负担,但是在重连时可能会丢失一些数据。


二、WebSocket心跳包机制


WebSocket心跳包是WebSocket协议的保活机制,用于维持长连接。有效的心跳包可以防止长时间不通讯时,WebSocket自动断开连接。


心跳包是指在一定时间间隔内,WebSocket发送的空数据包。常见的WebSocket心跳包机制如下:




  1. 客户端定时向服务器发送心跳数据包,以保持长连接。

  2. 服务器定时向客户端发送心跳数据包,以检测客户端连接是否正常。

  3. 双向发送心跳数据包。



三、WebSocket心跳机制原理


WebSocket心跳机制的原理是利用心跳包及时发送和接收数据,保证WebSocket长连接不被断开。WebSocket心跳机制的原理可以用下面的流程来说明:




  1. 客户端建立WebSocket连接。

  2. 客户端向服务器发送心跳数据包,服务器接收并返回一个表示接收到心跳数据包的响应。

  3. 当服务器没有及时接收到客户端发送的心跳数据包时,服务器会发送一个关闭连接的请求。

  4. 服务器定时向客户端发送心跳数据包,客户端接收并返回一个表示接收到心跳数据包的响应。

  5. 当客户端没有及时接收到服务器发送的心跳数据包时,客户端会重新连接WebSocket



四、WebSocket心跳机制必要吗


WebSocket心跳机制是必要的,它可以使 WebSocket 连接保持长连接,避免断开连接的情况发生。同时,心跳机制也可以检查WebSocket连接的状态,及时处理异常情况。


五、WebSocket心跳机制作用


WebSocket心跳机制的作用主要有以下几点:



  1. 保持WebSocket连接不被断开。

  2. 检测WebSocket连接状态,及时处理异常情况。

  3. 减少WebSocket连接及服务器资源的消耗。


六、WebSocket重连机制


WebSocket在发送和接收数据时,可能会因为网络原因、服务器宕机等因素而断开连接,此时需要使用WebSocket重连机制进行重新连接。


WebSocket重连机制可以通过以下几种方式实现:




  1. 前端监听WebSocket的onclose()事件,重新创建WebSocket连接。

  2. 使用WebSocket插件或库,例如Sockjs、Stompjs等。

  3. 使用心跳机制检测WebSocket连接状态,自动重连。

  4. 使用断线重连插件或库,例如ReconnectingWebSocket等。



七、WebSocket的缺点和不足


WebSocket的缺点和不足主要有以下几点:




  1. WebSocket需要浏览器和服务器端都支持该协议。

  2. WebSocket会增加服务器的负担,不适合大规模连接的应用场景。



八、关键代码


  // 开启心跳
const start = () => {
clearTimeout(timeoutObj);
// serverTimeoutObj && clearTimeout(serverTimeoutObj);
timeoutObj = setTimeout(function () {
if (websocketRef.current?.readyState === 1) {
//连接正常
sendMessage('hello');
}
}, timeout);
};
const reset = () => {
// 重置心跳 清除时间
clearTimeout(timeoutObj);
// 重启心跳
start();
};

ws.onopen = (event) => {
onOpenRef.current?.(event, ws);
reconnectTimesRef.current = 0;
start(); // 开启心跳
setReadyState(ws.readyState || ReadyState.Open);
};
ws.onmessage = (message: WebSocketEventMap['message']) => {
const { data } = message;

if (data === '收到,hello') {
reset();
return;
}
if (JSON.parse(data).status === 408) {
reconnect();
return;
}
onMessageRef.current?.(message, ws);
setLatestMessage(message);
};
const connect = () => {
reconnectTimesRef.current = 0;
connectWs();
};

主要思路:在建立长连接的时候开启心跳,通过和服务端发送信息,得到服务端给返回的信息,然后重置心跳,清楚时间,再重新开启心跳。如果网络断开的话,会执行方法,重新连接。


作者:泽南Zn
来源:juejin.cn/post/7290005438153867283
收起阅读 »

qq农场私信我,您菜死了🥬

web
最近在写代码的时候发现自己总是有这样几种症状: 脸红心跳,像发烧一样😳; 口干舌燥、咳嗽不停😮‍💨; 脑袋放空,像刚通宵了一般👀; ...... 我逐渐怀疑,自己有没有可能是🐑了,甚至时不时就拿起体温计量一量,拿起自测试纸测一测,这样的情况一直没有得到好转...
继续阅读 »

最近在写代码的时候发现自己总是有这样几种症状



  1. 脸红心跳,像发烧一样😳;

  2. 口干舌燥、咳嗽不停😮‍💨;

  3. 脑袋放空,像刚通宵了一般👀;

  4. ......


我逐渐怀疑,自己有没有可能是🐑了,甚至时不时就拿起体温计量一量,拿起自测试纸测一测,这样的情况一直没有得到好转,直到收到QQ农场给我发来这样的一条信息:


尊敬的QQ农场主,您去年和今年菜死了!🥬🥬🥬


🤔于是,我开始分析我症状根因是什么:



  1. 脸红心跳:是因为自己脑海中想象好的实现方案,但实际却写不出一行代码,或者各种Error,导致我心里落差很大,自我怀疑,或者是被人看穿菜的窘迫、害羞?

  2. 口干舌燥:是因为自己陷入了 写不出代码 => 憋着气接着写,不休息喝水 => 写不出代码 这样的闭环🐶里面;

  3. 脑袋放空:摆脱了内耗,很容易得出结论,就是看的技术不够多,写的代码不够多


痛定思痛,决定在这里立下FLAG,要多看多实践,学习和思考好的代码写法,看得多,写得多。


今天分享的主要是:用好发布订阅、偏函数的一对多 & 多对一关系工厂函数


发布订阅 & 偏函数(一对多/多对一关系)


是一种一对多的模式,或者说多对多的模式;一个事件对应多个处理函数,多个事件对应各自对应的处理函数



那假如我们想实现一个多对一的关系呢?我们可以使用偏函数


偏函数个人理解类似工厂函数,利用了闭包的特性


// 偏函数
function after(times, cb) {
let count = 0;
const result = {};
return inner(key, value) => {
result[key] = value;
count++;
if (count === times) {
cb(result);
}
};
}

结合代码看此处相当于多个inner函数对应一个callback函数,由count来控制是否触发callback,这种模式常常用于异步编程,比如Promise.all



综合一对多和多对一模式:


// 偏函数
function after(times, cb) {
let count = 0;
const result = {};
return (key, value) => {
result[key] = value;
count++;
if (count === times) {
cb(result);
}
};
}

// 发布订阅
const emitter = new (require("events").EventEmitter)();
const done = after(3, render);

emitter.on("done", done);
emitter.on("done", other);

fs.readFile(file, (err, template) => {
emitter.emit("done", "template", template);
});

fs.readFile(file, (err, data) => {
emitter.emit("done", "data", data);
});

fs.readFile(file, (err, str) => {
emitter.emit("done", "str", str);
});



工厂函数


类似现实工厂,在代码中用来生产特定结构函数/对象等的函数


比如想实现一个生成校验函数的工厂函数:


/**
* config里可以包含一般的描述性属性,钩子函数等
**/
export function factory(config) {
config.before = config.before || ((d) => d);
// pre钩子
handlersMap[config.type]?.pre(config);

return function (data) {
// before钩子函数
data = config.before(data);
return handlersMap[config.type].check(data);
};
}

// 通过该方法注册不同的校验函数
const handlersMap = {};
factory.registerHandler = function (type, handler) {
handlersMap[type] = handler;
};

在项目中的实现可如图:



🌊总结:


阅读好的代码,并学习一些好的写法,才是比较实际提高代码能力的方式,我也将💪持续阅读好的代码库,思考学习好的代码,把自己的成长分享出来。


作者:Kuroo
来源:juejin.cn/post/7182545613282623549
收起阅读 »

WebSocket 从入门到入土

web
前言因新部门需求有一个后台管理需要一个右上角的实时的消息提醒功能,第一时间想到的就是使用WebSocket建立实时通信了,之前没整过,于是只能学习了。和原部门相比现在太忙了,快乐的日子一去不复返了。经典的加量不加薪啊!!!一.WebSocket 基本概念1.W...
继续阅读 »

前言

因新部门需求有一个后台管理需要一个右上角的实时的消息提醒功能,第一时间想到的就是使用WebSocket建立实时通信了,之前没整过,于是只能学习了。和原部门相比现在太忙了,快乐的日子一去不复返了。经典的加量不加薪啊!!!

一.WebSocket 基本概念

1.WebSocket是什么?

WebSocket 是基于 TCP 的一种新的应用层网络协议。它提供了一个全双工的通道,允许服务器和客户端之间实时双向通信。因此,在 WebSocket 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输,客户端和服务器之间的数据交换变得更加简单。 WebSocket

2.与 HTTP 协议的区别

与 HTTP 协议相比,WebSocket 具有以下优点:

  1. 更高的实时性能:WebSocket 允许服务器和客户端之间实时双向通信,从而提高了实时通信场景中的性能。
  2. 更少的网络开销:HTTP 请求和响应之间需要额外的数据传输,而 WebSocket 通过在同一个连接上双向通信,减少了网络开销。
  3. 更灵活的通信方式:HTTP 请求和响应通常是一一对应的,而 WebSocket 允许服务器和客户端之间以多种方式进行通信,例如消息 Push、事件推送等。
  4. 更简洁的 API:WebSocket 提供了简洁的 API,使得客户端开发人员可以更轻松地进行实时通信。

当然肯定有缺点的:

  1. 不支持无连接: WebSocket 是一种持久化的协议,这意味着连接不会在一次请求之后立即断开。这是有利的,因为它消除了建立连接的开销,但是也可能导致一些资源泄漏的问题。
  2. 不支持广泛: WebSocket 是 HTML5 中的一种标准协议,虽然现代浏览器都支持,但是一些旧的浏览器可能不支持 WebSocket。
  3. 需要特殊的服务器支持: WebSocket 需要服务端支持,只有特定的服务器才能够实现 WebSocket 协议。这可能会增加系统的复杂性和部署的难度。
  4. 数据流不兼容: WebSocket 的数据流格式与 HTTP 不同,这意味着在不同的网络环境下,WebSocket 的表现可能会有所不同。

3.WebSocket工作原理

1. 握手阶段

WebSocket在建立连接时需要进行握手阶段。握手阶段包括以下几个步骤:

  • 客户端向服务端发送请求,请求建立WebSocket连接。请求中包含一个Sec-WebSocket-Key参数,用于生成WebSocket的随机密钥。
  • 服务端接收到请求后,生成一个随机密钥,并使用随机密钥生成一个新的Sec-WebSocket-Accept参数。
  • 客户端接收到服务端发送的新的Sec-WebSocket-Accept参数后,使用原来的随机密钥和新的Sec-WebSocket-Accept参数共同生成一个新的Sec-WebSocket-Key参数,用于加密数据传输。
  • 客户端将新的Sec-WebSocket-Key参数发送给服务端,服务端接收到后,使用该参数加密数据传输。

2. 数据传输阶段

建立连接后,客户端和服务端就可以通过WebSocket进行实时双向通信。数据传输阶段包括以下几个步骤:

  • 客户端向服务端发送数据,服务端收到数据后将其转发给其他客户端。
  • 服务端向客户端发送数据,客户端收到数据后进行处理。

双方如何进行相互传输数据的 具体的数据格式是怎么样的呢?WebSocket 的每条消息可能会被切分成多个数据帧(最小单位)。发送端会将消息切割成多个帧发送给接收端,接收端接收消息帧,并将关联的帧重新组装成完整的消息。

发送方 -> 接收方:ping。

接收方 -> 发送方:pong。

ping 、pong 的操作,对应的是 WebSocket 的两个控制帧

3. 关闭阶段

当不再需要WebSocket连接时,需要进行关闭阶段。关闭阶段包括以下几个步骤:

  • 客户端向服务端发送关闭请求,请求中包含一个WebSocket的随机密钥。
  • 服务端接收到关闭请求后,向客户端发送关闭响应,关闭响应中包含服务端生成的随机密钥。
  • 客户端收到关闭响应后,关闭WebSocket连接。

总的来说,WebSocket通过握手阶段、数据传输阶段和关闭阶段实现了服务器和客户端之间的实时双向通信。

二.WebSocket 数据帧结构和控制帧结构。

1. 数据帧结构

WebSocket 数据帧主要包括两个部分:帧头和有效载荷。以下是 WebSocket 数据帧结构的简要介绍:

  • 帧头:帧头包括四个部分:fin、rsv1、rsv2、rsv3、opcode、masked 和 payload_length。其中,fin 表示数据帧的结束标志,rsv1、rsv2、rsv3 表示保留字段,opcode 表示数据帧的类型,masked 表示是否进行掩码处理,payload_length 表示有效载荷的长度。
  • 有效载荷:有效载荷是数据帧中实际的数据部分,它由客户端和服务端进行数据传输。

2. 控制帧结构

除了数据帧之外,WebSocket 协议还包括一些控制帧,主要包括 Ping、Pong 和 Close 帧。以下是 WebSocket 控制帧结构的简要介绍:

  • Ping 帧:Ping 帧用于测试客户端和服务端之间的连接状态,客户端向服务端发送 Ping 帧,服务端收到后需要向客户端发送 Pong 帧进行响应。
  • Pong 帧:Pong 帧用于响应客户端的 Ping 帧,它用于测试客户端和服务端之间的连接状态。
  • Close 帧:Close 帧用于关闭客户端和服务端之间的连接,它包括四个部分:fin、rsv1、rsv2、rsv3、opcode、masked 和 payload_length。其中,opcode 的值为 8,表示 Close 帧。

三. JavaScript 中 WebSocket 对象的属性和方法,以及如何创建和连接 WebSocket。

WebSocket 对象的属性和方法:

  1. WebSocket 对象:WebSocket 对象表示一个新的 WebSocket 连接。
  2. WebSocket.onopen 事件处理程序:当 WebSocket 连接打开时触发。
  3. WebSocket.onmessage 事件处理程序:当接收到来自 WebSocket 的消息时触发。
  4. WebSocket.onerror 事件处理程序:当 WebSocket 发生错误时触发。
  5. WebSocket.onclose 事件处理程序:当 WebSocket 连接关闭时触发。
  6. WebSocket.send 方法:向 WebSocket 发送数据。
  7. WebSocket.close 方法:关闭 WebSocket 连接。

创建和连接 WebSocket:

  1. 创建 WebSocket 对象:
var socket = new WebSocket('ws://example.com');

其中,ws://example.com 是 WebSocket 的 URL,表示要连接的服务器。

  1. 连接 WebSocket:

使用 WebSocket.onopen 事件处理程序检查 WebSocket 是否成功连接。

socket.onopen = function() {
console.log('WebSocket connected');
};
  1. 接收来自 WebSocket 的消息:

使用 WebSocket.onmessage 事件处理程序接收来自 WebSocket 的消息。

socket.onmessage = function(event) {
console.log('WebSocket message:', event.data);
};
  1. 向 WebSocket 发送消息:

使用 WebSocket.send 方法向 WebSocket 发送消息。

socket.send('Hello, WebSocket!');
  1. 关闭 WebSocket:

当需要关闭 WebSocket 时,使用 WebSocket.close 方法。

socket.close();

注意:在 WebSocket 连接成功打开和关闭时,会分别触发 WebSocket.onopen 和 WebSocket.onclose 事件。在接收到来自 WebSocket 的消息时,会触发 WebSocket.onmessage 事件。当 WebSocket 发生错误时,会触发 WebSocket.onerror 事件。

四.webSocket简单示例

以下是一个简单的 WebSocket 编程示例,通过 WebSocket 向服务器发送数据,并接收服务器返回的数据:

  1. 首先,创建一个 HTML 文件,添加一个按钮和一个用于显示消息的文本框:
html>
<html>
<head>
<meta charset="UTF-8">
<title>WebSocket 示例title>
head>
<body>
<button id="sendBtn">发送消息button>
<textarea id="messageBox" readonly>textarea>
<script src="main.js">script>
body>
html>
  1. 接下来,创建一个 JavaScript 文件(例如 main.js),并在其中编写以下代码:
// 获取按钮和文本框元素
const sendBtn = document.getElementById('sendBtn');
const messageBox = document.getElementById('messageBox');

// 创建 WebSocket 对象
const socket = new WebSocket('ws://echo.websocket.org'); // 使用一个 WebSocket 服务器进行测试

// 设置 WebSocket 连接打开时的回调函数
socket.onopen = function() {
console.log('WebSocket 连接已打开');
};

// 设置 WebSocket 接收到消息时的回调函数
socket.onmessage = function(event) {
console.log('WebSocket 接收到消息:', event.data);
messageBox.value += event.data + '\n';
};

// 设置 WebSocket 发生错误时的回调函数
socket.onerror = function() {
console.log('WebSocket 发生错误');
};

// 设置 WebSocket 连接关闭时的回调函数
socket.onclose = function() {
console.log('WebSocket 连接已关闭');
};

// 点击按钮时发送消息
sendBtn.onclick = function() {
const message = 'Hello, WebSocket!';
socket.send(message);
messageBox.value += '发送消息: ' + message + '\n';
};

五.webSocket应用场景

  1. 实时通信:WebSocket 非常适合实时通信场景,例如聊天室、在线游戏、实时数据传输等。通过 WebSocket,客户端和服务器之间可以实时通信,无需依赖轮询,从而提高通信效率和减少网络延迟。
  2. 监控数据传输:WebSocket 可以在监控系统中实现实时数据传输,例如通过 WebSocket,客户端可以实时接收和处理监控数据,而无需等待轮询数据。
  3. 自动化控制:WebSocket 可以在自动化系统中实现远程控制,例如通过 WebSocket,客户端可以远程控制设备或系统,而无需直接操作。
  4. 数据分析:WebSocket 可以在数据分析场景中实现实时数据传输和处理,例如通过 WebSocket,客户端可以实时接收和处理数据,而无需等待数据存储和分析。
  5. 人工智能:WebSocket 可以在人工智能场景中实现实时数据传输和处理,例如通过 WebSocket,客户端可以实时接收和处理数据,而无需等待数据处理和分析。

六.WebSocket 错误处理

WebSocket 的错误处理

  1. WebSocket is not supported:当浏览器不支持 WebSocket 时,会出现此错误。解决方法是在浏览器兼容性列表中检查是否支持 WebSocket。
  2. WebSocket connection closed:当 WebSocket 连接被关闭时,会出现此错误。解决方法是在 WebSocket.onclose 事件处理程序中进行错误处理。
  3. WebSocket error:当 WebSocket 发生错误时,会出现此错误。解决方法是在 WebSocket.onerror 事件处理程序中进行错误处理。
  4. WebSocket timeout:当 WebSocket 连接超时时,会出现此错误。解决方法是在 WebSocket.ontimeout 事件处理程序中进行错误处理。
  5. WebSocket handshake error:当 WebSocket 握手失败时,会出现此错误。解决方法是在 WebSocket.onerror 事件处理程序中进行错误处理。
  6. WebSocket closed by server:当 WebSocket 连接被服务器关闭时,会出现此错误。解决方法是在 WebSocket.onclose 事件处理程序中进行错误处理。
  7. WebSocket closed by protocol:当 WebSocket 连接被协议错误关闭时,会出现此错误。解决方法是在 WebSocket.onclose 事件处理程序中进行错误处理。
  8. WebSocket closed by network:当 WebSocket 连接被网络错误关闭时,会出现此错误。解决方法是在 WebSocket.onclose 事件处理程序中进行错误处理。
  9. WebSocket closed by server:当 WebSocket 连接被服务器错误关闭时,会出现此错误。解决方法是在 WebSocket.onclose 事件处理程序中进行错误处理。

通过为 WebSocket 对象的 oncloseonerror 和 ontimeout 事件添加处理程序,可以及时捕获和处理 WebSocket 错误,从而确保程序的稳定性和可靠性。

七.利用单例模式创建完整的wesocket连接

class webSocketClass {
constructor(thatVue) {
this.lockReconnect = false;
this.localUrl = process.env.NODE_ENV === 'production' ? 你的websocket生产地址' : '测试地址';
this.globalCallback = null;
this.userClose = false;
this.createWebSocket();
this.webSocketState = false
this.thatVue = thatVue
}

createWebSocket() {
let that = this;
// console.log('
开始创建websocket新的实例', new Date().toLocaleString())
if( typeof(WebSocket) != "function" ) {
alert("您的浏览器不支持Websocket通信协议,请更换浏览器为Chrome或者Firefox再次使用!")
}
try {
that.ws = new WebSocket(that.localUrl);
that.initEventHandle();
that.startHeartBeat()
} catch (e) {
that.reconnect();
}
}

//初始化
initEventHandle() {
let that = this;
// //连接成功建立后响应
that.ws.onopen = function() {
console.log("连接成功");
};
//连接关闭后响应
that.ws.onclose = function() {
// console.log('
websocket连接断开', new Date().toLocaleString())
if (!that.userClose) {
that.reconnect(); //重连
}
};
that.ws.onerror = function() {
// console.log('
websocket连接发生错误', new Date().toLocaleString())
if (!that.userClose) {
that.reconnect(); //重连
}
};
that.ws.onmessage = function(event) {
that.getWebSocketMsg(that.globalCallback);
// console.log('
socket server return '+ event.data);
};
}
startHeartBeat () {
// console.log('
心跳开始建立', new Date().toLocaleString())
setTimeout(() => {
let params = {
request: '
ping',
}
this.webSocketSendMsg(JSON.stringify(params))
this.waitingServer()
}, 30000)
}
//延时等待服务端响应,通过webSocketState判断是否连线成功
waitingServer () {
this.webSocketState = false//在线状态
setTimeout(() => {
if(this.webSocketState) {
this.startHeartBeat()
return
}
// console.log('
心跳无响应,已断线', new Date().toLocaleString())
try {
this.closeSocket()
} catch(e) {
console.log('
连接已关闭,无需关闭', new Date().toLocaleString())
}
this.reconnect()
//重连操作
}, 5000)
}
reconnect() {
let that = this;
if (that.lockReconnect) return;
that.lockReconnect = true; //没连接上会一直重连,设置延迟避免请求过多
setTimeout(function() {
that.createWebSocket();
that.thatVue.openSuccess(that) //重连之后做一些事情
that.thatVue.getSocketMsg(that)
that.lockReconnect = false;
}, 15000);
}

webSocketSendMsg(msg) {
this.ws.send(msg);
}

getWebSocketMsg(callback) {
this.ws.onmessage = ev => {
callback && callback(ev);
};
}
onopenSuccess(callback) {
this.ws.onopen = () => {
// console.log("连接成功", new Date().toLocaleString())
callback && callback()
}
}
closeSocket() {
let that = this;
if (that.ws) {
that.userClose = true;
that.ws.close();
}
}
}
export default webSocketClass;

作者:耀耀切克闹灬
来源:juejin.cn/post/7309687967063818292

收起阅读 »

按钮点击的水波效果

web
实现思路:水波效果可以用一个 span 来模拟,动画效果是缩放从 0 到大于零的值(比如 4),同时透明度从 1 到 0。点击 button 后,我们把这个 span 添加到 button 里即可。 HTML 结构比较简单,我们用 div 来表示 button...
继续阅读 »

image


实现思路:水波效果可以用一个 span 来模拟,动画效果是缩放从 0 到大于零的值(比如 4),同时透明度从 1 到 0。点击 button 后,我们把这个 span 添加到 button 里即可。


HTML


结构比较简单,我们用 div 来表示 button:


<div class="button">
Click Me
</div>

CSS


给 div 加点样式,让它看起来像个 button:


image


.button {
margin-left: 100px;
position: relative;
width: 100px;
padding: 8px 10px;
border: 1px solid lightgray;
border-radius: 5px;
cursor: pointer;
overflow: hidden;
user-select: none;
}

定义水波样式,默认 scale 为 0:


.ripple {
position: absolute;
border-radius: 50%;
transform: scale(0);
animation: ripple 600ms linear;
background-color: rgba(30, 184, 245, 0.7);
}

水波动画:


@keyframes ripple {
to {
transform: scale(4);
opacity: 0;
}
}

javascript


点击按钮时,生成水波效果,先把结构加上:


function playRipple(event) {
// TODO:生成水波效果
}

// 为 button 添加点击事件
document
.querySelector('.button')
.addEventListener('click', event => {
playRipple(event);
})

我们看一下水波如何生成,为了方便理解,可以结合图来看,其中黑点表示鼠标点击的位置,蓝色的圆是点击后水波默认大小的圆,** ?**就表示要计算的 circle.style.left:


image


function playRipple(event) {
const button = event.currentTarget;
const buttonRect = button.getBoundingClientRect();

const circle = document.createElement("span");
// 圆的直径
const diameter = Math.max(button.clientWidth, button.clientHeight);
// 圆的半径
const radius = diameter / 2;

// 计算 ripple 的位置
circle.style.width = circle.style.height = `${diameter}px`;
circle.style.left = `${event.clientX - (buttonRect.left + radius)}px`;
circle.style.top = `${event.clientY - (buttonRect.top + radius)}px`;
// 添加 ripple 样式
circle.classList.add("ripple");
// 移除已存在的 ripple
removeRipple(button);
// 将 ripple 添加到 button 上
button.appendChild(circle);
}

// 移除 ripple
function removeRipple(button) {
const ripple = button.querySelector(".ripple");

if (ripple) {
ripple.remove();
}
}

看下效果:
image


总结


又水了一篇文章😂,如果对你有启发,欢迎点赞、评论。


参考


css-tricks.com/how-to-recr…


作者:探险家火焱
来源:juejin.cn/post/7224063449617383485
收起阅读 »

“浏览器切换到其他页面或最小化时,倒计时不准确“问题解析

web
背景 我最近修复了一个倒计时延迟的bug,情况是用户10:00设置了10分钟倒计时,10:06查看时发现倒计时还有8分钟,倒计时出不准确、延迟的情况。 倒计时大概逻辑如下: const leftTime = 600; //单位为秒 const timer = ...
继续阅读 »

背景


我最近修复了一个倒计时延迟的bug,情况是用户10:00设置了10分钟倒计时,10:06查看时发现倒计时还有8分钟,倒计时出不准确、延迟的情况。


倒计时大概逻辑如下:


const leftTime = 600; //单位为秒
const timer = setInterval(() => {
leftTime -= 1;
if(leftTime === 0) {
clearInterval(timer);
}
}, 1000);

通过排查是浏览器的优化策略导致的。


为什么浏览器优化策略会造成定时器不准时?又该怎么解决这个问题?本文会围绕这两个问题展开说明!


浏览器优化策略对定时器的影响


浏览器的优化策略是指浏览器为了提高性能和节省资源而对特定任务进行的优化。在后台标签页中,浏览器可能会对一些任务进行节流或延迟执行,以减少CPU和电池的消耗。


而定时器setIntervalsetTimeout就是受浏览器优化策略的影响,导致定时器的执行时间间隔被延长。所以在浏览器切换到其他页面或者最小化时,当前页面的定时器可能不会按照预期的时间间隔准时执行。


我们实验一下:设置一个定时器,每500ms在控制台输出当前时间;然后再监听该标签页的visibilitychange事件,当其选项卡的内容变得可见或被隐藏时,会触发该事件。


// 设置定时器
const leftTime = 600; // 倒计时剩余时间
setInterval(() => {
const date = new Date();
leftTime.value -= 1;
console.log(`倒计时剩余秒数:${ leftTime.value }`, `当前时间秒数:${ date.getSeconds() }`);
}, 1000);
// 通过监听 visibilitychange 事件来判别该页面是否可见
document.addEventListener('visibilitychange', function () {
if(document.hidden) {
console.log('页面不可见')
}
})

执行结果如下:


image.png


我们观察执行结果会发现,在标签页处于不可见状态后,setInterval从1000ms的时间间隔延长成了2000ms。


由此可见,当浏览器切换其他页面或者最小化时,倒计时的误差就出现了,setInterval定时器也不会在1000ms后减去1。对于时间较长的倒计时来说,误差会更大。


解决思路


既然浏览器的定时器有问题,那我们就不依赖定时器去计算剩余时间。


我们可以在用户配置倒计时后,立即计算出结束时间并保存,随后通过结束时间减去本地时间就得出了剩余时间,而且不会受定时器延迟的影响。将最上面提及到的倒计时伪代码修改如下:


// ......
const leftTime = 600 * 1000
const endTime = Date.now() + leftTime; // 倒计时结束时间
setInterval(() => {
const date = new Date();
leftTime = Math.round((endTime - Date.now()) / 1000);
console.log(`倒计时剩余秒数:${ leftTime }`, `当前时间秒数:${ date.getSeconds() }`);
if(leftTime <= 0) {
clearInterval(timer);
}
}, 1000)

根据以上代码进行计算,即使标签页不处于可见状态,setInterval延迟执行,对leftTime也没有影响。
执行结果如下(标签页处于不可见状态时):
image.png


题外话


用 setTimeout 实现 setInterval


实现思路是setTimeout的递归调用。以上面的举例代码为例作修改:


const leftTime = 600 * 1000;
const endTime = Date.now() + leftTime; // 倒计时结束时间
function setTimer() {
leftTime = Math.round((endTime - Date.now()) / 1000);
if ( leftTime <= 0 ) {
endTime = 0;
leftTime = 0;
} else {
setTimeout(setTimer, 1000);
}
}

本次分享就到这,希望可以帮助到有同样困扰的小伙伴哦~


作者:Swance
来源:juejin.cn/post/7309693162369171507
收起阅读 »

JS: function前面加!,引发思考🤔

web
简介 我们基本都知道,函数的声明方式有这两种 function msg(){alert('msg');}//声明式定义函数 var msg = function(){alert('msg');}//函数赋值表达式定义函数 但其实还有第三种声明方式,Func...
继续阅读 »

简介


我们基本都知道,函数的声明方式有这两种


function msg(){alert('msg');}//声明式定义函数

var msg = function(){alert('msg');}//函数赋值表达式定义函数

但其实还有第三种声明方式,Function构造函数


var msg = new function(msg) {
alert('msg')
}

等同于


function msg(msg) {
alert('msg')
}

函数的调用方式通常是方法名()

但是,如果我们尝试为一个“定义函数”末尾加上(),解析器是无法理解的。


function msg(){
alert('message');
}();//解析器是无法理解的

定义函数的调用方式应该是 print(); 那为什么将函数体部分用()包裹起来就可以了呢?

原来,使用括号包裹定义函数体,解析器将会以函数表达式的方式去调用定义函数。 也就是说,任何能将函数变成一个函数表达式的作法,都可以使解析器正确的调用定义函数。而 ! 就是其中一个,而 + - || ~ 都有这样的功能。


但是请注意如果用括号包裹函数体,然后立即执行。这种方式只适用一次性调用该函数,涉及到了一个作用域问题,当你想复用该函数的时候,会如下问题:


image.png

可如果你想复用该函数的话,就可按先声明函数,然后再调用函数,在同一个父级作用域下,可以复用该函数,如下:


var msg = function(msg) {}
msg();

关于这个问题,后面会进一步分析


function前面加 ! ?


自执行匿名函数:


在很多js代码中我们常常会看见这样一种写法:


(function( window, undefined ) {
// code
})(window);

这种写法我们称之为自执行匿名函数。正如它的名字一样,它是自己执行自己的,前一个括号是一个匿名函数,后一个括号代表立即执行


前面也提到 + - || ~这些运算符也同样有这样的功能


(function () { /* code */ } ()); 
!function () { /* code */ } ();
~function () { /* code */ } ();
-function () { /* code */ } ();
+function () { /* code */ } ();

image.png

① ( ) 没什么实际意义,不操作返回值


② ! 对返回值的真假取反


③ 对返回值进行按位取反(所有正整数的按位取反是其本身+1的负数,所有负整数的按位取反是其本身+1的绝对值,零的按位取反是 -1。其中,按位取反也会对返回值进行强制转换,将字符串5转化为数字5,然后再按位取反。
false被转化为0,true会被转化为1。
其他非数字或不能转化为数字类型的返回值,统一当做0处理)


④ ~
+、- 是对返回值进行数学运算 ( 可见返回值不是数字类型的时候 +、- 会将返回值进行强制转换,字符串强制转换后为NaN)


先从IIFE开始介绍 (注:这个例子是参考网上


IIFE(Imdiately Invoked Function Expression 立即执行的函数表达式)


function(){
alert('IIFE');
}

把这个代码放在console中执行会报错


image.png


因为这是一个匿名函数,要想让它正常运行就必须给个函数名,然后通过函数名调用。

其实在匿名函数前面加上这些符号后,就把一个函数声明语句变成了一个函数表达式,是表达式就会在script标签中自动执行


所以现在很多对代码压缩和编译后,导出的js文件通常如下:


(function(e,t){"use strict";function n(e){var t=e.length,n=st.type(e);return st.isWindow(e)?!1:1===e.nodeType&&t?!0:"array"===n||"function"!==n&&(0===t||"number"==typeof t&&t>0&&t-1 in e)}function r(e){var t=Tt[e]={};return st.each(e.match(lt)||[],function(e,n){t[n]=!0}),t}function i(e,n,r,i){if(st.acceptData(e)){var o,a,s=st.expando,u="string"==typeof n,l=e.nodeType,c=l?st.cache:e,f=l?e[s]:e[s]&&s;if(f&&c[f]&&(i||c[f].data)||!u||r!==t)return f||(l?e[s]=f=K.pop()||st.guid++:f=s),c[f]||(c[f]={},l||(c[f].toJSON=st.noop)),("object"==typeof n||"function"==typeof n)&&(i?c[f]=st.extend(c[f],n):c[f].data=st.extend(c[f].data,n)),o=c[f],i||(o.data||(o.data={}),o=o.data),r!==t&&(o[st.camelCase(n)]=r),u?(a=o[n],null==a&&(a=o[st.camelCase(n)])):a=o,a}}function o(e,t,n){if(st.acceptData(e)){var r,i,o,a=e.nodeType,u=a?st.cache:e,l=a?e[st.expando]:st.expando;if(u[l]){if(t&&(r=n?u[l]:u[l].data)){st.isArray(t)?t=t.concat(st.map(t,st.camelCase)):t in r?t=[t]:(t=st.camelCase(t),t=t in r?[t]:t.split(" "));for(i=0,o=t.length;o>i;i++)delete r[t[i]];if(!(n?s:st.isEmptyObject)(r))return}(n||(delete u[l].data,s(u[l])))&&(a?st.cleanData([e],!0):st.support.deleteExpando||u!=u.window?delete u[l]:u[l]=null)}}}function a(e,n,r){if(r===t&&1===e.nodeType){var i=""+n.replace(Nt,"-$1").toLowerCase();if(r=e.getAttribute(i),"string"==typeof r){try{r="true"===r?!0:"false"===r?!1:"null"===r?null:+r+""===r?+r:wt.test(r)?st.parseJSON(r):r}catch(o){}st.data(e,n,r)}else r=t}return r}function s(e){var t;for(t in e)if(("data"!==t||!st.isEmptyObject(e[t]))&&"toJSON"!==t)return!1;return!0}function u(){return!0}function l(){return!1}function c(e,t){do 

运算符


也许这里有人会疑惑,运算符为何能将声明式函数,转译成函数表达式,这里就涉及到了一个概念解析器


程序在运行之前需要经过编译或解释的过程,把源程序翻译成为字节码,但是在翻译之前,需要把字符串形式的程序源码解析为语法树或者抽象语法树等数据结构,这就需要用到解析器


那么什么是解析器?


所谓解析器(Parser),一般是指把某种格式的文本(字符串)转换成某种数据结构的过程。最常见的解析器(Parser),是把程序文本转换成编译器内部的一种叫做抽象语法树(AST)的数据结构,此时也叫做语法分析器(Parser)。也有一些简单的解析器(Parser),用于处理CSV、JSON,XML之类的格式


JS解析器在执行第一步预解析的时候,会从代码的开始搜索直到结尾,只去查找var、function和参数等内容。一般把第一步称之为“JavaScript的预解析”。而且,当找到这些内容时,所有的变量,在正式运行代码之前,都提前赋了一个值:未定义;所有的函数,在正式运行代码之前,都是整个函数块。让解析器识别到是一个表达式,那就得加上特殊符号来让其解析器识别出来,比如刚才提到的特殊运算符。


解析过程大致如下:


1、“找一些东西”: var、 function、 参数;(也被称之为预解析)


备注:如果遇到重名分为以下两种情况:遇到变量和函数重名了,只留下函数;遇到函数重名了,根据代码的上下文顺序,留下最后一个。


2、逐行解读代码。


备注:表达式可以修改预解析的值 (可以自行查阅文档,这就是后面说到的内容)


函数声明与函数定义


函数声明
一般相对规范的声明形式为:fucntion msg(void) 注意是有分号


function msg() 

函数定义 function msg()注意没有分号


{
alert('IIFE');
}

函数调用


这样是一个函数调用


msg();

函数声明加一个()就可以调用函数了


function msg(){
alert('IIFE');
}()

就这样

但是我们按上面在console中执行发现出错了


image.png


因为这样的代码混淆了函数声明和函数调用,以这种方式声明的函数 `msg`,就应该以 `msg()` 的方式调用。

若改成(function msg())()就是这样的一个结构体: (函数体)(IIFE),能被Javascript的解析器识别并正常执行


从Js解析器的预解析过程了解到:


解析器都能识别一种模式:使用括号封装函数。对于解析器来说,这几乎总是一个积极的信号,即函数需要立即执行。如果解析器看到一个左括号,紧接着是一个函数声明,它将立即解析这个函数。可以通过显式地声明立即执行的函数来帮助解析器加快解析速度


那么也就是说,括号的作用,就是将一个函数声明,让解析器识别为一个表达式,最后由程序执行这个函数


总结


任何消除函数声明和函数表达式间歧义的方法,都可以被Javascript解析器正确识别


赋值,逻辑,甚至是逗号,各种操作符,只要是解析器支持且用来识别的特殊符号都可以用作消除歧义的方式方法,而!function()(function()), 都是其中转换成表达式的一种方式。


测试


至于优先使用哪一个,推荐(), 而其他运算符,相对于多了一步执行步骤,比如+(表达式),那就是,立即执行+运算符运算,
大致测了一下:


image.png


结论


从测试结果的截图中我们能大致的看到,(IIFE)方式,比运算符快的是一个级别(进一位数的速度),如果说立即执行()的时间复杂度是O(n),那么运算符就是O(10n),当然这也只是粗略的测试,而且在现有的浏览器解析速度,时间基数小到可以忽略不计,所以看个人需求,写法就是萝卜白菜,大家各有所好,看个人


作者:糖墨夕
来源:juejin.cn/post/7203734711780081722
收起阅读 »

重新认识下网页水印

web
使用背景图图片 单独使用 css 实现,使用 backgroundImage,backgroundRepeat 将背景图片平铺到需要加水印的容器中即可。 如果希望实现旋转效果,可以借助伪元素,将背景样式放到伪元素中,旋转伪元素实现: <style>...
继续阅读 »

使用背景图图片


单独使用 css 实现,使用 backgroundImage,backgroundRepeat 将背景图片平铺到需要加水印的容器中即可。
如果希望实现旋转效果,可以借助伪元素,将背景样式放到伪元素中,旋转伪元素实现:


<style>
.watermark {
position: relative;
overflow: hidden;
background-color: transparent;
}
.watermark::before {
content: '';
position: absolute;
width: 160%;
height: 160%;
top: -20%;
left: -20%;
z-index: -1;
background-image: url('./watermark.png');
background-position: 0 0;
background-origin: content-box;
background-attachment: scroll;
transform: rotate(-20deg);
background-size: auto;
background-repeat: round;
opacity: 0.3;
pointer-events: none;
}
</style>

动态生成div


根据水印容器的大小动态生成div,div内可以任意设置文本样式和图片,借助userSelect禁止用户选中文本水印;


const addDivWaterMark = (el, text) => {
const { clientWidth, clientHeight } = el;
const waterWrapper = document.createElement('div');
waterWrapper.className = "waterWrapper";
const column = Math.ceil(clientWidth / 100);
const rows = Math.ceil(clientHeight / 100);
// 根据容器宽高动态生成div
for (let i = 0; i < column * rows; i++) {
const wrap = document.createElement('div');
wrap.className = "water";
wrap.innerHTML = `<div class="water-item">${text}</div>`
waterWrapper.appendChild(wrap)
}
el.append(waterWrapper)
}

Canvas写入图片做背景水印


将图片写入Canvas然后将Canvas作为背景图


  const img = new Image();
const { ctx, canvas } = createWaterMark(config);
img.onload = function () {
ctx.globalAlpha = 0.2;
ctx.rotate(Math.PI / 180 * 20);
ctx.drawImage(img, 0, 16, 180, 100);
canvasRef.value.style.backgroundImage = `url(${canvas.toDataURL()})`
};
img.src = ImageBg;

Canvas写入文字做背景水印


将文字写入Canvas然后将Canvas作为背景图


 const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = fillStyle;
ctx.globalAlpha = opacity;
ctx.font = font
ctx.rotate(Math.PI / 180 * rotate);
ctx.fillText(text, 0, 50);
return canvas

Svg做水印


通过svg样式来控制水印样式,再将svg转换成base64的背景图


  const svgStr =
`<svg xmlns="http://www.w3.org/2000/svg" width="180px" height="100px">
<text x="0px" y="30px" dy="16px"
text-anchor="start"
stroke="#000"
stroke-opacity="0.1"
fill="none"
transform="rotate(-20)"
font-weight="100"
font-size="16"> 前端小书童</text>
</svg>`
;
return `data:image/svg+xml;base64,${window.btoa(unescape(encodeURIComponent(svgStr)))}`;

shadowDom水印


使用customElements自定义个一个标签(可以使用其他任意标签,不过注意shadow DOM会使起同级的元素不显示。)
可以像shadow DOM写入style样式和水印节点(可以使用背景或者div形式)
shadow DOM内部实现的样式隔离不用担心写入的style影响页面其他元素样式,这个特性在微前端的实现中也被广泛使用。


 class ShadowMark extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' });
const wrapContainer = document.createElement('div')
const style = document.createElement('style');
style.textContent = `
.wrapContainer {
width: 100%;
height: 100%;
display: flex;
flex-wrap: wrap;
}
.watermark-item {
display: flex;
font-size: 16px;
opacity: .3;
transform: rotate(-20deg);
user-select: none;
white-space: nowrap;
justify-content: center;
align-items: center;
}`
;
const waterHeight = 100
const waterWidth = 100
const { clientWidth, clientHeight } = document.querySelector('.shadow-watermark')
const column = Math.ceil(clientWidth / waterWidth)
const rows = Math.ceil(clientHeight / waterHeight)
wrapContainer.setAttribute('class', "wrapContainer")
for (let i = 0; i < column * rows; i++) {
const wrap = document.createElement('div')
wrap.setAttribute('class', 'watermark-item')
wrap.style.width = waterWidth + 'px'
wrap.style.height = waterHeight + 'px'
wrap.textContent = "前端小书童"
wrapContainer.appendChild(wrap)
}
shadowRoot.appendChild(style);
shadowRoot.appendChild(wrapContainer)
}
}
customElements.define('shadow-mark', ShadowMark);

盲水印


canvas画布(canvas.getContext('2d'))调用 getImageData 得到一个 ArrayBuffer,用于记录画布每个像素的 rgba 值


r: Red取值范围0255
g: Green取值范围0
255
b:Blue取值范围0255
a:Alpha 透明度取值范围0
1,0代表全透明
可以理解为每个像素都是通过红、绿、蓝三个颜色金额透明度来合成颜色


方案一:低透明度方案的暗水印


当把水印内容的透明度 opacity 设置很低时,视觉上基本无法看到水印内容,但是通过修改画布的 rgba 值,可以使水印内容显示出来。
选择固定的一个色值例如R,判断画布R值的奇偶,将其重置为0或者255,低透明的内容就便可以显示出来了。


const decode = (canvas, colorKey, flag, otherColorValue) => {
const ctx = canvas.getContext('2d');
const originalData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
let data = originalData.data;
for (let i = 0; i < data.length; i++) {
//筛选每个像素点的R值
if (i % 4 == colorKey) {
if (data[i] % 2 == 0) {
//如果色值为偶数
data[i] = flag ? 255 : 0;
} else {
//如果色值为奇数
data[i] = flag ? 0 : 255;
}
} else if (i % 4 == 3) {
//透明度不作处理
continue;
} else {
// 关闭其他色值
if (otherColorValue !== undefined) {
data[i] = otherColorValue
}
}
}
ctx.putImageData(originalData, 0, 0);
}

方案二:将水印内容以像素偏差记录到画布中


用画布和水印后的画布绘制的像素进行ArrayBuffer对比,在存在水印像素的位置(水印画布透明度不为0)修改图片画布的奇偶,这样通过上面指定色值和奇偶去解码时,修改的文本像素就会被显示出来;


const encode = (ctx, textData, color, originalData) => {
for (let i = 0; i < originalData.data.length; i++) {
// 只处理目标色值
if (i % 4 == color) {
// 水印画布透明度为0
if (textData[i + offset] === 0 && (originalData.data[i] % 2 === 1)) {
// 放置越界
if (originalData.data[i] === 255) {
originalData.data[i]--;
} else {
originalData.data[i]++;
}
// 水印画布透明度不为0
} else if (textData[i + offset] !== 0 && (originalData.data[i] % 2 === 0)) {
originalData.data[i]++;
}
}
}
ctx.putImageData(originalData, 0, 0);
}

方案三:数字加密


在图像信号的频域(变换域)中隐藏信息要比在空间域(上面得到的像素颜色的ArrayBuffer)中隐藏信息具有更好的防攻击性。
这部分暗水印的实现,可以直接使用阿里云提供给的api,不过需要图片资源藏到的阿里云的OSS下;


MutationObserver


可以发现上面水印基本都是通过增加节点或者背景图的形式来实现,那用户其实可以通过屏蔽样式或者删除Dom来消除水印,那么我们可以借用MutationObserver来监听下水印dom的变化,来阻止用户以这种形式来消除水印;



代码



以上代码见:github.com/wenjuGao/wa…


线上效果:watermark-demo.vercel.app/



参考:



http://www.cnblogs.com/88223100/p/…


blog.csdn.net/bluebluesky…


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


作者:前端小书童
来源:juejin.cn/post/7208465670991872061
收起阅读 »

你的代码着色好看吗?来这里看看吧!

web
如果你想在网页上展示一些代码,你可能会遇到一个问题:代码看起来很单调,没有任何颜色或格式,这样的代码不仅不美观,也不利于阅读和理解。 那么,有没有什么办法可以让代码变得更漂亮呢?答案是有的,而且很简单。 你只需要使用一个叫做 highlight.js 的第三方...
继续阅读 »

如果你想在网页上展示一些代码,你可能会遇到一个问题:代码看起来很单调,没有任何颜色或格式,这样的代码不仅不美观,也不利于阅读和理解。


那么,有没有什么办法可以让代码变得更漂亮呢?答案是有的,而且很简单。


你只需要使用一个叫做 highlight.js 的第三方库,就可以轻松实现代码着色的效果。



highlight.js 是一个非常强大和流行的库,它可以自动识别和着色超过 190 种编程语言。


它支持多种主题和样式,让你可以根据自己的喜好选择合适的配色方案。


在本文中,子辰将向你介绍如何使用 highlight.js 来为你的代码着色,以及它的基本原理和优势。


让我们开始吧!


如何使用 highlight.js


使用 highlight.js 的方法有两种:一种是通过 npm 下载并安装到你的项目中,另一种是通过 CDN 引入到你的网页中。


这里我们以 CDN 的方式为例,如果你想使用 npm 的方式,可以参考官方文档。


首先,我们需要在网页中引入 highlight.js 的 JS 文件和 CSS 文件。


JS 文件是核心文件,负责识别和着色代码,CSS 文件是样式文件,负责定义代码的颜色和格式。



我们可以从 CDN 中选择一个合适的 JS 文件和 CSS 文件。


highlight.js 提供了多个 CDN 服务商,你可以根据自己的需求选择一个,这里我们以 jsDelivr 为例。


JS 文件的链接如下:


<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/highlight.min.js"></script>

CSS 文件的链接则需要根据你想要的主题来选择。


highlight.js 提供了很多主题,你可以在官网上预览每个主题的效果,并找到对应的 CSS 文件名,这里我们以 github-dark 为例。


CSS 文件的链接如下:


<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/styles/github-dark.min.css">

将上面两个链接分别放到网页的 head 标签中,就完成了引入 highlight.js 的步骤。


接下来,我们需要在网页中写一些代码,并用 pre 标签和 code 标签包裹起来。


pre 标签用于保留代码的格式,code 标签用于标识代码内容。例如:


<pre>
<code id="code-area">
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
height: 100vh;
width: 100vw;
}
</code>
</pre>

注意,我们给 code 标签添加了一个 id 属性,方便后面通过 JS 获取它。


最后,我们需要在网页中添加一些 JS 代码,来调用 highlight.js 的方法,实现代码着色的功能。


highlight.js 提供了两个主要的方法:highlightElement 和 highlight。


这两个方法都可以实现代码着色的效果,但是适用于不同的场景。


highlightElement


highlightElement 方法适用于当你的代码是直接写在网页中的情况。


这个方法接受一个元素作为参数,并将该元素内部的文本内容进行着色处理。例如:


// 获取 code 元素
const codeEle = document.getElementById("code-area");
// 调用 highlightElement 方法,传入 code 元素
hljs.highlightElement(codeEle);

如果一切顺利,你应该能看到类似下图的效果:



代码已经被着色了,并且你可以看到代码被替换成了一个个标签,标签被加上了样式。


在最后的原理里我们在详细的说一下。


highlight


highlight 方法适用于当你的代码是通过 Ajax 请求获取到的纯文本数据的情况。


这个方法接受一个字符串作为参数,并返回一个对象,包含着色后的文本内容和代码的语言。例如:


<script>
const codeEle = document.getElementById('code-area')
// 比如说现在 code 就是 Ajax 返回的数据,lang 就是代码语言,content 就是代码内容
const code = {
lang: 'css',
content: `
* {
margin: 0;
padding: 0;
}`

}
// 我们接下来可以使用 hljs.highlight,将代码内容与代码语言传入进去
const result = hljs.highlight(code.content, {
language: code.lang
})
// 它会返回一个结果,我们打印到控制台看看
console.log('result >>> ', result)
</script>


我们可以看到,打印出来的是一个对象,code 是它原始的代码,language 是它的语言,而 value 就是它着色后的代码。


那么现在要做的就是将 value 添加到 code 元素里边去。


<script>
const code = {
lang: 'css',
content: `
* {
margin: 0;
padding: 0;
}`

}
const result = hljs.highlight(code.content, {
language: code.lang
})
const codeEle = document.getElementById('code-area')
codeEle.innerHTML = result.value
</script>


我们可以看到,代码确实被着色了,但是和之前的有所差别,我们看一下是什么原因。



打开控制后我们发现,用这种方式 code 元素就没有办法被自动加上类样式了,所以说我们就需要手动给 code 加上类样式才可以。


// 通过 className 为 code 手动添加类样式,并添加类的语言
codeEle.className = `hljs language-${code.lang}`

highlight.js 的语言支持


无论使用哪种方法,都需要注意指定代码所属的语言。


如果不指定语言,highlight.js 会尝试自动识别语言,并可能出现错误或不准确的结。


指定语言可以通过两种方式:



  • 在 code 标签中添加 class 属性,并设置为 language-xxx 的形式,其中 xxx 是语言名称。

  • 在调用 highlightElement 或 highlight 方法时,在第二个参数中传入一个对象,并设置 language 属性为语言名称。



上图是 highlight.js 支持的语言,可以看到有很多种,需要用其他语言的时候,language 设置成指定的语言名称就可以了。


原理


它的原理你可能已经猜到了,在 highlightElement 里我们简单说了一下,现在再看下图:



之所以可以实现着色,其实就是查找和替换的过程,将原来的纯文本替换为元素标签包裹文本,元素是可以加上样式的,而样式就是我们引入的 css 文件。


这就是它的基本原理了。


总结


其实有时候我们去设置 Mackdown 的自定义样式呢,在代码区域设置的时候也是这样设置的,当然类样式的名字呢,基本上都是标准的格式。


好了,这个库分享介绍给你了,库的原理也为你做了简单的科普,希望对你有所帮助。


如果你有什么问题或建议,请在评论区留言,如果你觉得这篇文章有用,请点赞收藏或分享给你的朋友!


作者:子辰Web草庐
来源:juejin.cn/post/7245584147456507965
收起阅读 »

😲什么!!一个开关要这么花里胡哨??

web
前言 前几天我的朋友突然找上我,说他公司产品要他做个很花哨的开关特效,我一想一个开关而已,还能花哨到哪去,无非就是加点动画特效吗,随后我承认我低估了这个产品的脑洞,需求是要一个粉粉嫩嫩的爱心开关,关的时候背景色是白的,打开后要粉色,而且爱心开关按钮是从左心房滚...
继续阅读 »

前言


前几天我的朋友突然找上我,说他公司产品要他做个很花哨的开关特效,我一想一个开关而已,还能花哨到哪去,无非就是加点动画特效吗,随后我承认我低估了这个产品的脑洞,需求是要一个粉粉嫩嫩的爱心开关,关的时候背景色是白的,打开后要粉色,而且爱心开关按钮是从左心房滚动到右心房(这是我朋友对产品心里话:************ 😄)随后我也是去翻了一下收藏集,找了一个效果给了他,让他自己再根据公司需求进行改动


结构


这里我们利用label标签对开关按钮及爱心的点击触发效果,内部使用一个复选框跟一个svg图标来进行布局


<label class="box">
<!-- 复选框,有选中状态 -->
<input type="checkbox">

<!-- 心形图标 -->
<svg viewBox="0 0 33 23" fill="pink">
<path
d="M23.5,0.5 C28.4705627,0.5 32.5,4.52943725 32.5,9.5 C32.5,16.9484448 21.46672,22.5 16.5,22.5 C11.53328,22.5 0.5,16.9484448 0.5,9.5 C0.5,4.52952206 4.52943725,0.5 9.5,0.5 C12.3277083,0.5 14.8508336,1.80407476 16.5007741,3.84362242 C18.1491664,1.80407476 20.6722917,0.5 23.5,0.5 Z">

</path>
</svg>
</label>


svg图标大家可以复制这个或者自己去网上找一个图标也可以,不过如果是网上找的则需要自己去重新计算开关打开和关闭的动画位置


样式


结构有了开始写样式,让开关好看点



  • 初始化


        * {
margin: 0;
padding: 0;
box-sizing: border-box;
/* 解决手机浏览器点击有选框的问题 */
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}


  • 大盒子居中,盒子样式及移入鼠标样式,svg样式调整


        body {
/* 常规居中显示,简单背景色 */
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
min-height: 100vh;
background-color: #f6f6ff;
}

.box {
/* 整个父盒子区域都可以点,是个小手 */
cursor: pointer;
/* 过渡动画时间,主要是按下缩小一圈 */
transition: transform 0.2s;
position: relative;
}
.box input {
/* 去除默认复选框样式 */
appearance: none;
/* 中间滑动圆圈的宽高,简单白色背景 */
width: 20vmin;
height: 20vmin;
border-radius: 50%;
background-color: #ffffff;
/* 灰色阴影 */
box-shadow: 0 0.5vmin 2vmin rgba(0, 0, 0, 0.2);
/* 鼠标小手 */
cursor: pointer;
}

.box svg {
/* 中间心形图标的宽高,撑开整个开关区域 */
width: 40vmin;
height: 30vmin;
/* background-color: skyblue; */

/* 中间填充颜色 */
fill: #ffffff;
/* 描边颜色,描边头是圆润的 */
stroke: #d6d6ee;
stroke-linejoin: round;

}



  • 开关动画


 @keyframes animate-on {

/* 动画就是简单的位置变换,要根据情况调整 */
0% {
top: 2.5vmin;
left: 1.5vmin;
}

25% {
top: 5.5vmin;
left: 5vmin;
}

50% {
top: 7vmin;
left: 10vmin;
/* 到正中间时圆大一小圈 */
transform: scale(1.05);
}

75% {
top: 5.5vmin;
left: 15vmin;
}

100% {
top: 2.5vmin;
left: 18.5vmin;
}
}

@keyframes animate-off {

/* 关闭的动画就是反着来 */
0% {
top: 2.5vmin;
left: 18.5vmin;
}

25% {
top: 5.5vmin;
left: 15vmin;
}

50% {
top: 7vmin;
left: 10vmin;
transform: scale(1.05);
}

75% {
top: 5.5vmin;
left: 5vmin;
}

100% {
top: 2.5vmin;
left: 1.5vmin;
}
}

细节:开关按钮的小球到中间时要变大一点点,因为爱心之间位置比较大一点,这样滑动起来才好看


完整代码


code.juejin.cn/pen/7173909…


结尾


朋友收到代码后连连道谢,还非要请我周末去吃个烤🐏腰子补补,哎!!盛情难却,勉为其难的去吃吧,声明:我可不是为了那🐏腰子去的啊!主要是人家盛情邀请,咱们没办法拒绝😁。如果代码中有任何错误欢迎大家指正,相互学习相互进步


作者:一骑绝尘蛙
来源:juejin.cn/post/7173940249440026631
收起阅读 »

产品经理:能不能根据用户心情自动切换主题。我:好的。

web
效果展示 在线体验地址:dbfu.github.io/antd-pro-ex…,需要开启摄像头权限,不支持手机浏览器。 代码仓库地址:github.com/dbfu/antd-p… 前言 这个灵感来自于zxg_神说要有光大佬的写一个可以当镜子照的Button...
继续阅读 »

效果展示


17.gif


在线体验地址:dbfu.github.io/antd-pro-ex…,需要开启摄像头权限,不支持手机浏览器。


代码仓库地址:github.com/dbfu/antd-p…


前言


这个灵感来自于zxg_神说要有光大佬的写一个可以当镜子照的Button这篇文章,正好今天看了一个人脸识别的前端仓库,可以动态识别人的表情,本来想写一个“根据用户心情变色的按钮”,同事说能不能实现”根据用户心情自动切换系统主题“,我想了一下好像可以的。


实现思路


借助第三方库透过摄像头事实获取用户的表情,然后根据表情动态切换主题。


具体实现


先使用antd pro脚手架初始化一个antd pro项目


pro create antd-pro-expression-theme

安装face-api.js


pnpm i face-api.js

到仓库中下载源码,把weights文件夹复制到antd pro项目中的public文件夹下,这一步很关键,我被这个地方卡了一段时间。


改造antd pro项目,支持动态主题。


在src目录下创建expression.tsx标题组件


import { useEffect, useRef, useState } from 'react';
import * as faceapi from 'face-api.js';


const expressionMap: any = {
"neutral": '正常',
"happy": '开心',
"sad": '悲伤',
"surprised": '惊讶',
}

const Hidden = true;

function getExpressionResult(expression: any) {
if (!expression) return;
const keys = [
'neutral',
'happy',
'sad',
'angry',
'fearful',
'disgusted',
'surprised',
];

const curExpression = keys.reduce((prev: string, cur: string) => {
if (!prev) {
return cur;
} else {
return expression[cur] > expression[prev] ? cur : prev;
}
}, '');
return curExpression;
}

export function Expression({
onExpressionChange,
}:
any
) {

const videoRef = useRef<HTMLVideoElement>(null);
const [expression, setExpression] = useState<string | undefined>('');

useEffect(() => {
if (onExpressionChange) {
onExpressionChange(expression);
}
}, [expression]);

async function run() {
await faceapi.nets.tinyFaceDetector.load('/widgets/');

await faceapi.loadSsdMobilenetv1Model('/widgets/');
await faceapi.loadFaceLandmarkModel('/widgets/');
await faceapi.loadFaceExpressionModel('/widgets/');

const stream = await navigator.mediaDevices.getUserMedia({ video: {} });
if (videoRef.current) {
videoRef.current.srcObject = stream;
}
}

useEffect(() => {
run();
}, []);

async function onPlay(): Promise<any> {

const videoEl = videoRef.current;

if (!videoEl) return;

if (videoEl.paused || videoEl.ended) return setTimeout(() => onPlay());

const result = await faceapi
.detectSingleFace(videoEl)
.withFaceExpressions();

setExpression(getExpressionResult(result?.expressions))

setTimeout(() => onPlay());
}

return (
<div style={{ opacity: Hidden ? 0 : 1 }} >
<video
style={{
background: '#fff',
width: 640,
height: 480,
position: 'fixed',
top: 0,
left: 0,
zIndex: Hidden ? 0 : 10001
}}
onLoadedMetadata={() =>
{ onPlay() }}
id="inputVideo"
autoPlay
muted
playsInline
ref={videoRef}
/>
<div
style={{
opacity: 1,
width: 640,
height: 480,
position: 'fixed',
top: 0,
left: 0,
zIndex: 10001,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
}}
>

{expressionMap?.[expression || 'neutral']}
div>

div>
)
}

这样就简单的获取到了表情,目前我就支持了正常、开心、惊讶、伤心四种表情,实际上他还支持其他一些表情,大家可以自己去体验一下。我主要参考了这个demo,这里面还有其他demo大家可以去体验一下。
如果不想显示视频,把上Hidden变量设置为true就行了。


在src目录下创建theme-provider.tsx文件


import { ConfigProvider } from 'antd';
import throttle from 'lodash/throttle';

import { Expression } from './expression';
import { useMemo, useState } from 'react';

export default function ThemeProvider({ children }: any) {

const [theme, setTheme] = useState<string>('');

const expressionChange = useMemo(
() => throttle((expression: string) => {
const map: any = {
happy: 'rgb(245, 34, 45)',
sad: 'rgb(192, 192, 192)',
surprised: 'rgb(250, 173, 20)',
};

setTheme(map[expression] ? map[expression] : 'rgb(22, 119, 255)')
}, 1000), [])


return (
<ConfigProvider theme={{
token: {
colorPrimary: theme || 'rgb(22, 119, 255)',
}
}}>

<Expression onExpressionChange={expressionChange} />
{children}
ConfigProvider>

)
}

这个文件用来监听表情变化,然后动态设置主题,目前也是只支持了正常、开心、惊讶、伤心四种主题。


最后在src/app.tsx使用theme-provider组件,并删除下面截图中的代码,不然我们的主题会被默认主题覆盖掉,导致不能改主题。


export function rootContainer(container: any) {
return React.createElement(ThemeProvider, null, container);
}

image.png


然后启动项目就行了。第一次获取表情有点慢,可能要等一会。


总结


这个功能看似没用,实则真没用,主要是想整个活让大家乐一下。大家应该还记得以前有个比较热门的话题吧,根据手机壳改变主题颜色,如果能通过摄像头获取到手机壳的颜色,好像也不是不行🐶。


在线体验地址:dbfu.github.io/antd-pro-ex…,需要开启摄像头权限。


代码仓库地址:github.com/dbfu/antd-p…


作者:前端小付
来源:juejin.cn/post/7226385396167704634
收起阅读 »

实现抖音“刚刚看过”的功能(原生js的写法)

web
先上一下效果图吧 点击一下刚刚看过的按钮就会滚动到视频的位置 实现这个效果,如果不考虑效率问题肯定是非常简单,但是我们就是要考虑这个传输效率的问题 比方说这个主页有2000条视频,但是目前看的视频在第1900个,那需要滑到这第1900个视频的位置,不可能把...
继续阅读 »

先上一下效果图吧


点击一下刚刚看过的按钮就会滚动到视频的位置
image.png


image.png


实现这个效果,如果不考虑效率问题肯定是非常简单,但是我们就是要考虑这个传输效率的问题


比方说这个主页有2000条视频,但是目前看的视频在第1900个,那需要滑到这第1900个视频的位置,不可能把之前所有的视频都加载出来吧,这样子的话这效率太低了吧,传输量加上请求,怎么可能吃得消
所以这个时候我们只需要创建好元素,但是不需要向服务器要这1900个视频的内容,我只要创建好元素,就可以滑动到这个视频的位置了,那要怎么加载这视频的内容呢?那就是判断用户看到哪一块,看到哪,我们加载到哪,类似于懒加载的效果


所以我这里提供一个思路,最主要的就是两个关键函数(createELement,loadPages)


createElement(page)的作用就是传入页码,他就会创建好这页面加上之前所有的元素,这个函数只管创建好元素,内容不归他管,内容等到后面在进行加载


loadPages()这个函数就是根据用户当前能看到第几页,那么就把第几页的内容加载出来,看到哪就加载哪个页面的数据,这里还需要考虑到两个页面重叠,都需要加载出来


那么首先来准备好html


<div class="contain"></div> //放置内容的盒子
<div class="btn"> //刚刚看过的按钮
<button class="full-rounded">
<span>刚刚看过</span>
<div class="border full-rounded"></div>
</button>
</div>

当然css样式也是要准备好的,可以根据自己公司的UI设计图来写


body {
background-color: #000;
padding: 100px 300px;
}

.contain {
width: 100%;
height: 100%;
display: grid; //宫格布局
grid-template-columns: repeat(5, 1fr);
grid-column-gap: 50px; //每一列的间距
grid-row-gap: 80px; //每一行的间距
}
.item {
width: 200px;
height: 300px;
border: 1px solid #fff;
}
.playing {
width: 200px;
height: 300px;
position: relative;
}
.playing img {
filter: blur(3px);
-webkit-filter: blur(3px);
}
.playing::after {
content: "播放中";
position: absolute;
top: 0;
left: 0;
width: 200px;
height: 300px;
font-size: 20px;
font-weight: bold;
color: white;
display: flex;
justify-content: center;
align-items: center;
}
.btn {
position: fixed;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
}
button {
font-size: 16px;
position: relative;
margin: auto;
padding: 1em 2.5em 1em 2.5em;
border: none;
background: #fff;
transition: all 0.1s linear;
box-shadow: 0 0.4em 1em rgba(0, 0, 0, 0.1);
}
button:hover {
cursor: pointer;
}
button:active {
transform: scale(0.95);
}

button span {
color: #464646;
}

button .border {
position: absolute;
border: 0.15em solid #fff;
transition: all 0.3s 0.08s linear;
top: 50%;
left: 50%;
width: 9em;
height: 3em;
transform: translate(-50%, -50%);
}

button:hover .border {
display: block;
width: 9.9em;
height: 3.7em;
}

.full-rounded {
border-radius: 2em;
}

这些都不是最重要的


还有一些工具函数


1.getOffset(id) 来获取当前视频前面有多少个视频


这个根据实际情况来做,正常情况这里是向服务端获取的,我这里就模拟了一下请求


// 传入当前视频的id就可以获取之前有多少个视频
function getOffset(id) {
return new Promise((res, rej) => {
let result = id - 1;
res(result);
});
}

2.getVideo(page,size)


获取页面的资源
同样这里也是向服务端发请求获取的,我这里也是自己模拟


// 传入页码和每页多少条,即可获取图片数据
function getVideo(page, size) {
return new Promise((res) => {
let arr = [];
// 上一页有多少个,从哪开始num
let num = (page - 1) * size;
for (let i = 0; i < size; i++) {
let obj = {
id: num + i,
cover: `https://picsum.photos/200/300?id=${num + i}`,
};
arr.push(obj);
}
res(arr);
});
}

3.getIndexRange(page,size)


获取这个页码的最小索引和最大索引


// 传入页码和大小算出这个页码的起始和结束下标
function getIndexRange(page, size) {
let start = (page - 1) * size;
let end = start + size - 1;
return [start, end];
}

4.debounce(fn,deplay=300)


这个就是防抖啦,让loadpage函数不要执行太多次,节省性能


function debounce(fn, delay = 300) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}

5.getPage(index,size)


传入当前视频的下标和页面大小,返回当前视频在第几页


function getPage(index, size) {
return Math.ceil((index + 1) / size);
}

以上都是工具函数


准备工作


1.定义好一页需要多少元素


const SIZE = 15;
// 刚刚看过视频的id
const currentId = 200;
// 页码
let i = 1;

2.获取页面两个重点元素


let contain = document.querySelector(".contain");
let btn = document.querySelector(".btn");

现在来写最重要的函数


1.createElement(page)


传入页码即可创建好这个页面包括之前的所有元素

步骤:

1.算出需要创建多少元素page*size

2.创建item添加到contain元素的children中

3.给每个item添加侦查器,判断是否出现在视口内


function createElement(page) {
// 防止一页重复创建
const childLen = contain.children.length;
const count = page * SIZE - childLen;
for (let i = 0; i < count; i++) {
const item = document.createElement("div");
item.className = "item";
item.dataset.index = i + childLen;
contain.appendChild(item);
ob.observe(item); //侦查器,判断是否出现在视口内
}
}

2.视口观察器


const visibleIndex = new Set(); //全局创建一个不重复的集合
let ob = new IntersectionObserver((entries) => {
for (const e of entries) {
const index = e.target.dataset.index;
//isIntersecting为true就代表在视口内
if (e.isIntersecting) {
visibleIndex.add(index);
} else {
visibleIndex.delete(index);
}
}
debounceLoadPage();// 防抖后的loadpage
});

3.获取集合的最大及最小的索引


function getRange() {
if (visibleIndex.size === 0) return [0, 0];
const max = Math.max(...visibleIndex);
const min = Math.min(...visibleIndex);
return [min, max];
}

4.加载视口内的元素的资源


      function loadPage() {
// 得到当前能看到的元素索引范围
const [minIndex, maxIndex] = getRange();
const pages = new Set(); // 不重复的页码集合
for (let i = minIndex; i <= maxIndex; i++) {
pages.add(getPage(i, SIZE));// 遍历将侦查器集合范围内的所在页面都加入到pages的集合内
}
// 遍历页码集合
for (const page of pages) {
const [minIndex, maxIndex] = getIndexRange(page, SIZE);//获取页码的索引范围
if (contain.children[minIndex].dataset.loaded) { //如果页码最小索引的元素有自定义属性就跳过,代表加载过
continue;
}
contain.children[minIndex].dataset.loaded = true;//如果没有就代表没有加载过,添加上自定义属性
//将当前页码传给获取资源的函数
getVideo(page, SIZE).then((res) => {
//拿到当前页面需要的数据数组,遍历渲染到页面上
for (let i = minIndex; i < maxIndex; i++) {
const item = contain.children[i];
item.innerHTML = `<img src="${res[i - minIndex].cover}" alt="">`;
}
});
}
}

// 创建防抖加载函数,将loadpage函数防抖
const debounceLoadPage = debounce(loadPage, 300);

5.判断刚刚看过的按钮是否显示


// 页面进来就需要触发获取当前视频之前有多少个视频,判断按钮是否显示
      async function setVisible() {
        // 获取之前有多少个视频
        let offest = await getOffset(currentId);
        let [minIndex, maxIndex] = getRange();

        // 返回告诉你第几页
        const page = getPage(offest, SIZE);
        if (offest >= minIndex && offest <= maxIndex) {
          btn.style.display = "none";
        } else {
          btn.style.display = "block";
        }
        btn.dataset.page = page;
        btn.dataset.index = offest;
      }

6.给按钮添加点击事件,滚动到指定位置


btn.onclick = () => {
const page = +btn.dataset.page;
const index = +btn.dataset.index;
i = page; // 跳转将页码更新
createElement(page);
contain.children[index].scrollIntoView({
behavior: "smooth",
block: "center",
});
contain.children[index].classList.add("playing");
btn.style.display = "none";
};

7.给window添加滚动事件,页面触底页码加一


window.addEventListener("scroll", () => {
//窗口高度
var windowHeight =
document.documentElement.clientHeight || document.body.clientHeight;
//滚动高度
var scrollTop =
document.documentElement.scrollTop || document.body.scrollTop;
//页面高度
var documentHeight =
document.documentElement.scrollHeight || document.body.scrollHeight;

if (windowHeight + scrollTop == documentHeight) {
createElement(i++); //页面触底就页码加一
}
});

完整代码


<body>
<div class="contain"></div>
<div class="btn">
<button class="full-rounded">
<span>刚刚看过</span>
<div class="border full-rounded"></div>
</button>
</div>

<script src="./api.js"></script>
<script src="./index.js"></script>
<script>
const SIZE = 15;
let contain = document.querySelector(".contain");
let btn = document.querySelector(".btn");
// 页码
let i = 1;

const visibleIndex = new Set();

// 视口观察器
let ob = new IntersectionObserver((entries) => {
for (const e of entries) {
const index = e.target.dataset.index;
if (e.isIntersecting) {
// 将在视口内的元素添加到集合内
visibleIndex.add(index);
} else {
// 将不在视口内的元素从集合内删除
visibleIndex.delete(index);
}
}
debounceLoadPage();
});

function getRange() {
if (visibleIndex.size === 0) return [0, 0];
const max = Math.max(...visibleIndex);
const min = Math.min(...visibleIndex);
return [min, max];
}

// 创建元素
function createElement(page) {
// 防止一页重复创建
const childLen = contain.children.length;
const count = page * SIZE - childLen;
for (let i = 0; i < count; i++) {
const item = document.createElement("div");
item.className = "item";
item.dataset.index = i + childLen;
contain.appendChild(item);
ob.observe(item);
}
}

// 得到当前能看到的元素索引范围
function loadPage() {
const [minIndex, maxIndex] = getRange();
const pages = new Set();
for (let i = minIndex; i <= maxIndex; i++) {
pages.add(getPage(i, SIZE));
}
for (const page of pages) {
const [minIndex, maxIndex] = getIndexRange(page, SIZE);
if (contain.children[minIndex].dataset.loaded) {
continue;
}
contain.children[minIndex].dataset.loaded = true;
getVideo(page, SIZE).then((res) => {
for (let i = minIndex; i < maxIndex; i++) {
const item = contain.children[i];
item.innerHTML = `<img src="${res[i - minIndex].cover}" alt="">`;
}
});
}
}

// 创建防抖加载函数
const debounceLoadPage = debounce(loadPage, 300);

// 刚刚看过视频的id
const currentId = 200;

// 页面进来就需要触发获取之前有多少个视频,判断按钮是否显示
async function setVisible() {
// 获取之前有多少个视频
let offest = await getOffset(currentId);
let [minIndex, maxIndex] = getRange();
// 返回告诉你第几页
const page = getPage(offest, SIZE);
if (offest >= minIndex && offest <= maxIndex) {
btn.style.display = "none";
} else {
btn.style.display = "block";
}
btn.dataset.page = page;
btn.dataset.index = offest;
}

btn.onclick = () => {
const page = +btn.dataset.page;
const index = +btn.dataset.index;
i = page;
createElement(page);
contain.children[index].scrollIntoView({
behavior: "smooth",
block: "center",
});
contain.children[index].classList.add("playing");
btn.style.display = "none";
};

window.addEventListener("scroll", () => {
//窗口高度
var windowHeight =
document.documentElement.clientHeight || document.body.clientHeight;
//滚动高度
var scrollTop =
document.documentElement.scrollTop || document.body.scrollTop;
//页面高度
var documentHeight =
document.documentElement.scrollHeight || document.body.scrollHeight;

if (windowHeight + scrollTop == documentHeight) {
createElement(i++);
}
});
createElement(i);
setVisible();
</script>
</body>


🔥🔥🔥🔥🔥🔥到这里就实现了抖音的刚刚看过的功能!!!!!🔥🔥🔥🔥🔥🔥🔥🔥🔥


作者:井川不擦
来源:juejin.cn/post/7257441472445644855
收起阅读 »

Swiper,一款超赞的 JavaScript 滑动库?

web
嗨,大家好,欢迎来到猿镇,我是镇长,lee。 又到了和大家见面的时间,今天分享一款 JavaScript 滑动库 - Swiper。Swiper 不仅是一个简单的滑动库,更是一个全面的滑动解决方案,让你轻松创建出各种炫酷的滑动效果。 git...
继续阅读 »

嗨,大家好,欢迎来到猿镇,我是镇长,lee。


又到了和大家见面的时间,今天分享一款 JavaScript 滑动库 - SwiperSwiper 不仅是一个简单的滑动库,更是一个全面的滑动解决方案,让你轻松创建出各种炫酷的滑动效果。


github.com/nolimits4we…


什么是Swiper?


Swiper 是一个基于现代触摸滑动的 Javascript 库,用于创建轮播、幻灯片以及任何需要滑动的网页组件。它的灵活性和强大功能使得开发者能够实现各种复杂的滑动效果,而不需要深入了解复杂的滑动原理。


为什么选择Swiper?



  • 易于使用:  Swiper 提供了简单易懂的 API 和文档,使得即便是初学者也能轻松上手。只需几行代码,你就可以创建一个漂亮的轮播。

  • 跨平台兼容:  Swiper 支持多平台,包括PC、移动端和平板电脑,确保你的滑动效果在各种设备上都能够流畅运行。

  • 丰富的配置选项:  你可以根据自己的需求定制 Swiper 的各种参数,如滑动速度、自动播放、循环模式等,满足不同场景的需求。


如何开始使用Swiper?


步骤1:引入Swiper


首先,你需要在你的项目中引入 Swiper 库。你可以选择使用 CDN,也可以通过 npm 或 yarn 进行安装。



<link
  rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css"
/>


<script src="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.js">script>

步骤2:创建HTML结构


创建一个包裹你滑动内容的容器,并添加滑动项。


<div class="swiper">
      <div class="swiper-wrapper">
        <div class="swiper-slide">Slide 1div>
        <div class="swiper-slide">Slide 2div>
        <div class="swiper-slide">Slide 3div>
        
      div>
      
      <div class="swiper-pagination">div>

      
      <div class="swiper-button-prev">div>
      <div class="swiper-button-next">div>
    div>

设置样式


.swiper {
      width600px,
    height: 300px;
}
.swiper-slide {
    background-color: red; // 设置背景色方便查看效果
}

步骤3:初始化Swiper


使用 Javascript 初始化 Swiper,并传入配置选项。


var mySwiper = new Swiper('.swiper-container', {
  // 配置项
  // 可选参数
  looptrue,

  // 分页器
  pagination: {
    el'.swiper-pagination',
  },

  // 导航箭头
  navigation: {
    nextEl'.swiper-button-next',
    prevEl'.swiper-button-prev',
  },
});

步骤4:享受滑动的乐趣


你已经成功集成了 Swiper,现在你可以在网页上看到炫丽的滑动效果了。


1.gif


进阶用法


Swiper 提供了许多高级用法和定制选项,以适应各种复杂的需求。以下是一些Swiper的高级用法:


1. 自定义动画和过渡效果


通过使用 Swiper 的effect属性,你可以指定不同的过渡效果,例如 "slide"、"fade"、"cube"等。这可以为你的滑动项添加独特的动画效果。


var mySwiper = new Swiper('.swiper-container', {
  effect'cube',
  cubeEffect: {
    slideShadowsfalse,
    shadowfalse,
  },
});

2. 动态添加或删除滑动项


通过 Swiper 的API,你可以在运行时动态地添加或删除滑动项。这在需要根据用户操作或数据变化来更新滑动项时非常有用。


// 添加新的滑动项
mySwiper.addSlide(0'New Slide
');

// 删除指定索引的滑动项
mySwiper.removeSlide(1);

3. 深度定制分页器和导航按钮


入门示例中简单引入了分页器,Swiper 的分页器和导航按钮可以进行高度的自定义。你可以通过自定义HTML、样式和事件来实现自己想要的分页器和导航按钮效果。


var mySwiper = new Swiper('.swiper-container', {
  pagination: {
    el'.swiper-pagination',
    clickabletrue,
    renderBulletfunction (index, className) {
      return ' + className + '">' + (index + 1) + '';
    },
  },
  
navigation: {
    
nextEl'.swiper-button-next',
    
prevEl'.swiper-button-prev',
  },
});

4. 使用Swiper插件


Swiper 支持插件系统,你可以使用一些第三方插件来增强 Swiper 的功能,例如 Swiper 的滚动条插件、懒加载插件等。通过导入并配置插件,你可以轻松地扩展 Swiper 的能力。


// 导入并使用懒加载插件
import SwiperCore, { Lazy } from 'swiper/core';
SwiperCore.use([Lazy]);

var mySwiper = new Swiper('.swiper-container', {
  // 启用懒加载
  lazytrue,
});

swiperjs.com/plugins


5.gif


5. 响应式设计


Swiper 允许你根据不同的屏幕尺寸设置不同的配置选项,实现响应式设计。这样,你可以在不同设备上提供最佳的用户体验。


var mySwiper = new Swiper('.swiper-container', {
  slidesPerView: 3,
  spaceBetween: 30,
  breakpoints: {
    // 当窗口宽度小于等于 768 像素时
    768: {
      slidesPerView: 2,
      spaceBetween: 20,
    },
    // 当窗口宽度小于等于 480 像素时
    480: {
      slidesPerView: 1,
      spaceBetween: 10,
    },
  },
});

这些高级用法展示了 Swiper 库的强大功能和灵活性,深入了解这些特性将使你能够更好地适应各种项目需求。


示例演示


2.gif


3.gif


4.gif


结语


通过 Swiper,你可以轻松实现网页上的各种滑动效果,为用户提供更加出色的交互体验。它的简单易用性和丰富的功能使其成为前端开发中不可或缺的利器。不论你是新手还是有经验的开发者,都值得深入了解 Swiper ,为你的网页增添一份技术的魔法。


更多


今天的分享就到这里,如果觉得对你有帮助,感谢点赞、分享、关注一波,你的认可是我创造的最大动力。


作者:繁华落尽丶lee
来源:juejin.cn/post/7309061655094575139
收起阅读 »

没用的东西,你连个内存泄漏都排查不出来!!

web
背景 ui妹子的无理要求,我通通满足了。但是不出意外的话,意外就出来了。 此功能在上线之后,我们的业务在客户app内使用刷脸的时候会因为内存过高导致app将webview杀死。 然后我们leader爆了,让我排查问题。可是可是,我哪里会排查内存泄漏呀。 我:...
继续阅读 »

背景



  • ui妹子的无理要求,我通通满足了。但是不出意外的话,意外就出来了。

  • 此功能在上线之后,我们的业务在客户app内使用刷脸的时候会因为内存过高导致app将webview杀死。

  • 然后我们leader爆了,让我排查问题。可是可是,我哪里会排查内存泄漏呀。

  • 我:我不会。你自己不会上吗?你tm天天端个茶,抽个烟,翘个二郎腿,色眯眯的看着ui妹妹。

  • 领导:污蔑,你纯粹就是污蔑。我tm现在就可以让你滚蛋,你信吗?

  • 我:我怕你个鸟哦,我还不知道你啥水平,你tm能写出来个防抖节流,我就给你磕头。

  • 领导:hi~ui妹妹,今天过的好吗,来,哥哥这里有茶喝。(此时ui妹妹路过)。你赶快给我干活,以后ui妹妹留给你。

  • 艹!你早这么说不就好了。





开始学习


Chrome devTools查看内存情况




  • 打开Chrome的无痕模式,这样做的目的是为了屏蔽掉Chrome插件对我们之后测试内存占用情况的影响

  • 打开开发者工具,找到Performance这一栏,可以看到其内部带着一些功能按钮,例如:开始录制按钮;刷新页面按钮;清空记录按钮;记录并可视化js内存、节点、事件监听器按钮;触发垃圾回收机制按钮等




简单录制一下百度页面,看看我们能获得什么,如下动图所示:




从上图中我们可以看到,在页面从零到加载完成这个过程中JS Heap(js堆内存)、documents(文档)、Nodes(DOM节点)、Listeners(监听器)、GPU memoryGPU内存)的最低值、最高值以及随时间的走势曲线,这也是我们主要关注的点



看看开发者工具中的Memory一栏,其主要是用于记录页面堆内存的具体情况以及js堆内存随加载时间线动态的分配情况



堆快照就像照相机一样,能记录你当前页面的堆内存情况,每快照一次就会产生一条快照记录




如上图所示,刚开始执行了一次快照,记录了当时堆内存空间占用为33.7MB,然后我们点击了页面中某些按钮,又执行一次快照,记录了当时堆内存空间占用为32.5MB。并且点击对应的快照记录,能看到当时所有内存中的变量情况(结构、占总占用内存的百分比...)





在开始记录后,我们可以看到图中右上角有起伏的蓝色与灰色的柱形图,其中蓝色表示当前时间线下占用着的内存;灰色表示之前占用的内存空间已被清除释放



在得知有内存泄漏的情况存在时,我们可以改用Memory来更明确得确认问题和定位问题


首先可以用Allocation instrumentation on timeline来确认问题,如下图所示:



内存泄漏的场景



  • 闭包使用不当引起内存泄漏

  • 全局变量

  • 分离的DOM节点

  • 控制台的打印

  • 遗忘的定时器


1. 闭包使用不当引起内存泄漏


使用PerformanceMemory来查看一下闭包导致的内存泄漏问题


<button onclick="myClick()">执行fn1函数button>
<script>
function fn1 () {
let a = new Array(10000) // 这里设置了一个很大的数组对象

let b = 3

function fn2() {
let c = [1, 2, 3]
}

fn2()

return a
}

let res = []

function myClick() {
res.
push(fn1())
}
script>


在退出fn1函数执行上下文后,该上下文中的变量a本应被当作垃圾数据给回收掉,但因fn1函数最终将变量a返回并赋值给全局变量res,其产生了对变量a的引用,所以变量a被标记为活动变量并一直占用着相应的内存,假设变量res后续用不到,这就算是一种闭包使用不当的例子



设置了一个按钮,每次执行就会将fn1函数的返回值添加到全局数组变量res中,是为了能在performacne的曲线图中看出效果,如图所示:




  • 在每次录制开始时手动触发一次垃圾回收机制,这是为了确认一个初始的堆内存基准线,便于后面的对比,然后我们点击了几次按钮,即往全局数组变量res中添加了几个比较大的数组对象,最后再触发一次垃圾回收,发现录制结果的JS Heap曲线刚开始成阶梯式上升的,最后的曲线的高度比基准线要高,说明可能是存在内存泄漏的问题

  • 在得知有内存泄漏的情况存在时,我们可以改用Memory来更明确得确认问题和定位问题

  • 首先可以用Allocation instrumentation on timeline来确认问题,如下图所示:




  • 在我们每次点击按钮后,动态内存分配情况图上都会出现一个蓝色的柱形,并且在我们触发垃圾回收后,蓝色柱形都没变成灰色柱形,即之前分配的内存并未被清除

  • 所以此时我们就可以更明确得确认内存泄漏的问题是存在的了,接下来就精准定位问题,可以利用Heap snapshot来定位问题,如图所示:




  • 第一次先点击快照记录初始的内存情况,然后我们多次点击按钮后再次点击快照,记录此时的内存情况,发现从原来的1.1M内存空间变成了1.4M内存空间,然后我们选中第二条快照记录,可以看到右上角有个All objects的字段,其表示展示的是当前选中的快照记录所有对象的分配情况,而我们想要知道的是第二条快照与第一条快照的区别在哪,所以选择Object allocated between Snapshot1 and Snapshot2即展示第一条快照和第二条快照存在差异的内存对象分配情况,此时可以看到Array的百分比很高,初步可以判断是该变量存在问题,点击查看详情后就能查看到该变量对应的具体数据了


以上就是一个判断闭包带来内存泄漏问题并简单定位的方法了


2. 全局变量


全局的变量一般是不会被垃圾回收掉的当然这并不是说变量都不能存在全局,只是有时候会因为疏忽而导致某些变量流失到全局,例如未声明变量,却直接对某变量进行赋值,就会导致该变量在全局创建,如下所示:


function fn1() {
// 此处变量name未被声明
name = new Array(99999999)
}

fn1()


  • 此时这种情况就会在全局自动创建一个变量name,并将一个很大的数组赋值给name,又因为是全局变量,所以该内存空间就一直不会被释放

  • 解决办法的话,自己平时要多加注意,不要在变量未声明前赋值,或者也可以开启严格模式,这样就会在不知情犯错时,收到报错警告,例如


function fn1() {
'use strict';
name = new Array(99999999)
}

fn1()

3. 分离的DOM节点


假设你手动移除了某个dom节点,本应释放该dom节点所占用的内存,但却因为疏忽导致某处代码仍对该被移除节点有引用,最终导致该节点所占内存无法被释放,例如这种情况


<div id="root">
<div class="child">我是子元素div>
<button>移除button>
div>
<script>
let btn = document.querySelector('button')
let child = document.querySelector('.child')
let root = document.querySelector('#root')

btn.
addEventListener('click', function() {
root.
removeChild(child)
})
script>


该代码所做的操作就是点击按钮后移除.child的节点,虽然点击后,该节点确实从dom被移除了,但全局变量child仍对该节点有引用,所以导致该节点的内存一直无法被释放,可以尝试用Memory的快照功能来检测一下,如图所示





同样的先记录一下初始状态的快照,然后点击移除按钮后,再点击一次快照,此时内存大小我们看不出什么变化,因为移除的节点占用的内存实在太小了可以忽略不计,但我们可以点击第二条快照记录,在筛选框里输入detached,于是就会展示所有脱离了却又未被清除的节点对象



解决办法如下图所示:


<div id="root">
<div class="child">我是子元素div>
<button>移除button>
div>
<script>
let btn = document.querySelector('button')

btn.
addEventListener('click', function() {
let child = document.querySelector('.child')
let root = document.querySelector('#root')

root.
removeChild(child)
})

script>


改动很简单,就是将对.child节点的引用移动到了click事件的回调函数中,那么当移除节点并退出回调函数的执行上文后就会自动清除对该节点的引用,那么自然就不会存在内存泄漏的情况了,我们来验证一下,如下图所示:




结果很明显,这样处理过后就不存在内存泄漏的情况了


4. 控制台的打印


<button>按钮button>
<script>
document.querySelector('button').addEventListener('click', function() {
let obj = new Array(1000000)

console.log(obj);
})
script>

我们在按钮的点击回调事件中创建了一个很大的数组对象并打印,用performance来验证一下




开始录制,先触发一次垃圾回收清除初始的内存,然后点击三次按钮,即执行了三次点击事件,最后再触发一次垃圾回收。查看录制结果发现JS Heap曲线成阶梯上升,并且最终保持的高度比初始基准线高很多,这说明每次执行点击事件创建的很大的数组对象obj都因为console.log被浏览器保存了下来并且无法被回收



接下来注释掉console.log,再来看一下结果:


<button>按钮button>
<script>
document.querySelector('button').addEventListener('click', function() {
let obj = new Array(1000000)

// console.log(obj);
})
script>


可以看到没有打印以后,每次创建的obj都立马被销毁了,并且最终触发垃圾回收机制后跟初始的基准线同样高,说明已经不存在内存泄漏的现象了


其实同理 console.log也可以用Memory来进一步验证


未注释 console.log



注释掉了console.log




最后简单总结一下:在开发环境下,可以使用控制台打印便于调试,但是在生产环境下,尽可能得不要在控制台打印数据。所以我们经常会在代码中看到类似如下的操作:



// 如果在开发环境下,打印变量obj
if(isDev) {
console.log(obj)
}


这样就避免了生产环境下无用的变量打印占用一定的内存空间,同样的除了console.log之外,console.errorconsole.infoconsole.dir等等都不要在生产环境下使用



5. 遗忘的定时器



定时器也是平时很多人会忽略的一个问题,比如定义了定时器后就再也不去考虑清除定时器了,这样其实也会造成一定的内存泄漏。来看一个代码示例:



<button>开启定时器button>
<script>

function fn1() {
let largeObj = new Array(100000)

setInterval(() => {
let myObj = largeObj
},
1000)
}

document.querySelector('button').addEventListener('click', function() {
fn1()
})
script>

这段代码是在点击按钮后执行fn1函数,fn1函数内创建了一个很大的数组对象largeObj,同时创建了一个setInterval定时器,定时器的回调函数只是简单的引用了一下变量largeObj,我们来看看其整体的内存分配情况吧:



按道理来说点击按钮执行fn1函数后会退出该函数的执行上下文,紧跟着函数体内的局部变量应该被清除,但图中performance的录制结果显示似乎是存在内存泄漏问题的,即最终曲线高度比基准线高度要高,那么再用Memory来确认一次:




  • 在我们点击按钮后,从动态内存分配的图上看到出现一个蓝色柱形,说明浏览器为变量largeObj分配了一段内存,但是之后这段内存并没有被释放掉,说明的确存在内存泄漏的问题,原因其实就是因为setInterval的回调函数内对变量largeObj有一个引用关系,而定时器一直未被清除,所以变量largeObj的内存也自然不会被释放

  • 那么我们如何来解决这个问题呢,假设我们只需要让定时器执行三次就可以了,那么我们可以改动一下代码:


<button>开启定时器button>
<script>
function fn1() {
let largeObj = new Array(100000)
let index = 0

let timer = setInterval(() => {
if(index === 3) clearInterval(timer);
let myObj = largeObj
index ++
},
1000)
}

document.querySelector('button').addEventListener('click', function() {
fn1()
})
script>

现在我们再通过performancememory来看看还不会存在内存泄漏的问题



  • performance




这次的录制结果就能看出,最后的曲线高度和初始基准线的高度一样,说明并没有内存泄漏的情况




  • memory



这里做一个解释,图中刚开始出现的蓝色柱形是因为我在录制后刷新了页面,可以忽略;然后我们点击了按钮,看到又出现了一个蓝色柱形,此时就是为fn1函数中的变量largeObj分配了内存,3s后该内存又被释放了,即变成了灰色柱形。所以我们可以得出结论,这段代码不存在内存泄漏的问题



简单总结一下: 大家在平时用到了定时器,如果在用不到定时器后一定要清除掉,否则就会出现本例中的情况。除了setTimeoutsetInterval,其实浏览器还提供了一个API也可能就存在这样的问题,那就是requestAnimationFrame




  • 好了好了,学完了,ui妹妹我来了






  • ui妹妹:去你m的,滚远点





好了兄弟们,内存泄漏学会了吗?


作者:顾昂_
来源:juejin.cn/post/7309040097936474175
收起阅读 »

以为 flv.js 直播超简单,结果被延迟和卡顿整疯了

web
大家好,我是杨成功。 之前写过一篇浏览器直播的文章,叫《用一个 flv.js 播放监控的例子,带你深撅直播流技术》。这片文章的热度还不错,主要内容就是科普直播是什么,以及如何在浏览器中播放直播。 实现方法很简单,使用一个流行的第三方包 flv.js,即可快速播...
继续阅读 »

大家好,我是杨成功。


之前写过一篇浏览器直播的文章,叫《用一个 flv.js 播放监控的例子,带你深撅直播流技术》。这片文章的热度还不错,主要内容就是科普直播是什么,以及如何在浏览器中播放直播。


实现方法很简单,使用一个流行的第三方包 flv.js,即可快速播放直播。


在我们的项目中也使用这种方式,比如播放海康监控器的直播、教学直播等都可以正常播放。然而在产品成熟后,我们发现直播中有两个致命问题:



  1. 直播延迟,播越久延迟越高。

  2. 直播卡顿,无法判断什么时候卡顿。


解决上述两个问题是直播稳定性和可用性的关键,下面就来详解一下。


抗延迟关键 —— “追帧”


使用 flv.js 直播,需要一个 标签承载直播画面。默认情况下 video 标签用于播放点播(录制好的)视频,因此它会一边播放一边下载。


点播不要求实时性,暂停之后再继续播放,视频会接着暂停的画面继续播放;而如果是直播,暂停后继续播放时必须切换到最新的画面帧,这就是 “追帧” 的概念。


一图胜千言,不追帧的效果是这样的:


iShot_2023-11-07_11.29.55.gif


追帧的效果是这样的:


iShot_2023-11-07_11.44.16.gif


可以看到,设置追帧后的暂停重播,会立即切换到最新的画面。


在实际场景中,直播没有暂停按钮,但是常常会因为网络问题卡顿。如果卡顿恢复后视频没有追帧,就会导致直播延迟越来越高。


使用 mpegts.js 替代 flv.js


据传说,flv.js 的作者是一个高中毕业在 B 站上班的小伙子,月薪仅仅不到 5k。后来小伙离职去了日本,无法更新 flv.js,于是有了 mpegts.js。


目前 flv.js 已停止维护,mpegts.js 是其升级版,开发者是同一个人。涉及到追帧的高级功能,mpegts.js 支持的更好。在 flv.js 主页也可以看到推荐:


image.png


mpegts.js 的用法与 flv.js 基本一致,如下:


import mpegts from 'mpegts.js';

let config = {};
let player = mpegts.createPlayer(
{
type: 'flv',
isLive: true,
url: 'http://xxxx.flv',
},
config,
);

mpegts.js 提供了自动追帧的配置项 liveBufferLatencyChasing,开启自动追帧方法如下:


let config = {
liveBufferLatencyChasing: true,
};

设置自动追帧后,虽然延迟问题解决了,但画面可能会更加卡顿。这里涉及到 IO 缓存的问题。


配置 IO 缓存,优化追帧卡顿


首先思考一个问题:直播的延迟越低越好吗?


从需求上讲,当然是越低越好;可从技术上讲,并不是越低越好。


直播是实时流,从远端拉流并实时解码播放,但这个过程极容易受到网络影响。不管是推流端或拉流端遇到了网路抖动,数据传输受阻,直播必然会卡顿,这个是正常现象。


怎么办呢?这个时候就要用到 IO 缓存,牺牲一点实时性,用延迟换取流畅。


假设播放器缓存了 1 秒的数据流,并将直播延迟 1 秒播放。当遇到网络抖动时,播放器会读取缓存数据继续播放,网络恢复后再向缓冲区追加数据,这样用户在看直播时,完全感受不到卡顿。


但如果网络异常时间超过 1 秒,缓冲区中的数据读取完毕,直播还是会卡住;如果加大缓存量,缓存了 3 秒的数据,这又会导致直播延迟过高。


所以,设置缓存可以有效解决追帧卡顿问题;若要在保证流畅的前提下,尽可能地降低延迟,则需要一个合理的缓存值。


mpegts.js 提供了 liveBufferLatencyMaxLatencyliveBufferLatencyMinRemain 两个配置项来控制缓存时间,分别表示最大缓存时间和最小缓存时间,单位为秒。


以下方配置为例,缓存时间设置越长、流畅性越好、延迟越高:


let config = {
liveBufferLatencyChasing: true, // 开启追帧
liveBufferLatencyMaxLatency: 0.9, // 最大缓存时间
liveBufferLatencyMinRemain: 0.2, // 最小缓存时间
};

实际的缓存时间会根据网络情况动态变化,值的范围在上述两个配置项之间。


处理卡顿关键 —— “断流检测”


直播是实时流播放,任何一个环节出现异常,都会导致直播卡顿、出现黑屏等现象。这是因为实时拉取的流数据断开了,我们称之为“断流”。


多数情况下的断流都是网络原因导致,此时可能需要提醒用户“当前网络拥堵”、或者显示“直播加载中”的字样,告诉用户发生了什么。


而实现这些功能的前提,必须要知道流什么时候断开,我们就需要做“断流检测”。


mpegts.js 提供了几个内置事件来监听直播的状态,常用如下:



  • mpegts.Events.ERROR:出现异常事件。

  • mpegts.Events.LOADING_COMPLETE:流结束事件。

  • mpegts.Events.STATISTICS_INFO:流状态变化事件。


前两个事件分别会在出现异常和直播结束的时候触发,监听方法如下:


let player = mpegts.createPlayer({...})

player.on(mpegts.Events.ERROR, e=> {
console.log('发生异常')
});
player.on(mpegts.Events.LOADING_COMPLETE, (e) => {
console.log("直播已结束");
});

当未发生异常、且直播未结束的情况下,我们就需要监听直播卡顿。通过监听 STATISTICS_INFO 事件来实现。


首先科普一下:播放器在播放直播时需要实时解码,每一帧画面过来,就需要解码一次。当直播卡顿时,没有画面过来,解码也会暂停,因此可以通过已解码的帧数来判断是否卡顿。


STATISTICS_INFO 事件的回调函数参数中,有一个 decodedFrames 属性,正是表示当前已解码的帧数,我们来看一下:


player.on(mpegts.Events.STATISTICS_INFO, (e) => {
console.log("解码帧:"e.decodedFrames); // 已经解码的帧数
});

在直播过程中,上述回调函数会一直执行,打印结果如下:


image-1.png


可以看到,解码帧一直在递增,表示直播正常。当直播卡顿时,打印结果是这样的:


2023-11-08-21-17-53.png


解码帧连续 9 次卡在了 904 这个值不变,这是因为直播卡顿了,没有画面需要解码。


所以,判断卡顿的方法是将上一次的解码帧与当前解码帧做对比,如果值一致则出现了卡顿。


当然轻微的卡顿不需要处理。我们可以将连续 N 次出现相同的解码帧视为一次卡顿,然后执行自己的业务逻辑。


当解码帧的值长时间没有变化时,我们可以视为推流已结束,此时可以主动结束直播。


作者:杨成功
来源:juejin.cn/post/7299037876636663847
收起阅读 »

用一个 flv.js 播放监控的例子,带你深撅直播流技术

web
大家好,我是杨成功。 本文记录一下在使用 flv.js 播放监控视频时踩过的各种各样的坑。虽然官网给的 Getting Started 只有短短几行代码,跑一个能播视频的 demo 很容易,但是播放时各种各样的异常会搞到你怀疑人生。 究其原因,一方面 GitH...
继续阅读 »

大家好,我是杨成功。


本文记录一下在使用 flv.js 播放监控视频时踩过的各种各样的坑。虽然官网给的 Getting Started 只有短短几行代码,跑一个能播视频的 demo 很容易,但是播放时各种各样的异常会搞到你怀疑人生。


究其原因,一方面 GitHub 上文档比较晦涩,说明也比较简陋;另一方面是受“视频播放”思维的影响,没有对的足够认识以及缺乏处理流的经验。


下面我将自己踩过的坑,以及踩坑过程中补充的相关知识,详细总结一下。


大纲预览


本文介绍的内容包括以下方面:



  • 直播与点播

  • 静态数据与流数据

  • 为什么选 flv?

  • 协议与基础实现

  • 细节处理要点

  • 样式定制


点播与直播


啥是直播?啥是点播?


直播就不用说了,抖音普及之下大家都知道直播是干嘛的。点播其实就是视频播放,和咱们哔哩哔哩看视频一摸一样没区别,就是把提前做好的视频放出来,就叫点播。


点播对于我们前端来说,就是拿一个 mp4 的链接地址,放到 video 标签里面,浏览器会帮我们处理好视频解析播放等一些列事情,我们可以拖动进度条选择想看的任意一个时间。


但是直播不一样,直播有两个特点:



  1. 获取的是流数据

  2. 要求实时性


先看一下什么叫流数据。大部分没有做过音视频的前端同学,我们常接触的数据就是 ajax 从接口获取的 json 数据,特别一点的可能是文件上传。这些数据的特点是,它们都属于一次性就能拿到的数据。我们一个请求,一个响应,完整的数据就拿回来了。


但是流不一样,流数据获取是一帧一帧的,你可以理解为是一小块一小块的。像直播流的数据,它并不是一个完整的视频片段,它就是很小的二进制数据,需要你一点一点的拼接起来,才有可能输出一段视频。


再看它的实时性。如果是点播的话,我们直接将完整的视频存储在服务器上,然后返回链接,前端用 video 或播放器播就行了。但是直播的实时性,就决定了数据源不可能在服务器上,而是在某一个客户端。


数据源在客户端,那么又是怎么到达其他客户端的呢?


这个问题,请看下面这张流程图:


Untitled Diagram.drawio (7).png


如图所示,发起直播的客户端,向上连着流媒体服务器,直播产生的视频流会被实时推送到服务端,这个过程叫做推流。其他客户端同样也连接着这个流媒体服务器,不同的是它们是播放端,会实时拉取直播客户端的视频流,这个过程叫做拉流


推流—> 服务器-> 拉流,这是目前流行的也是标准的直播解决方案。看到了吧,直播的整个流程全都是流数据传输,数据处理直面二进制,要比点播复杂了几个量级。


具体到我们业务当中的摄像头实时监控预览,其实和上面的完全一致,只不过发起直播的客户端是摄像头,观看直播的客户端是浏览器而已。


静态数据与流数据


我们常接触的文本,json,图片等等,都属于静态数据,前端用 ajax 向接口请求回来的数据就是静态数据。


像上面说到的,直播产生的视频和音频,都属于流数据。流数据是一帧一帧的,它的本质是二进制数据,因为很小,数据像水流一样连绵不断的流动,因此非常适合实时传输。


静态数据,在前端代码中有对应的数据类型,比如 string,json,array 等等。那么流数据(二进制数据)的数据类型是什么?在前端如何存储?又如何操作?


首先明确一点,前端是可以存储和操作二进制的。最基本的二进制对象是 ArrayBuffer,它表示一个固定长度,如:


let buffer = new ArrayBuffer(16) // 创建一个 16 字节 的 buffer,用 0 填充
alert(buffer.byteLength) // 16

ArrayBuffer 只是用于存储二进制数据,如果要操作,则需要使用 视图对象


视图对象,不存储任何数据,作用是将 ArrayBuffer 的数据做了结构化的处理,便于我们操作这些数据,说白了它们是操作二进制数据的接口。


视图对象包括:



  • Uint8Array:每个 item 1 个字节

  • Uint16Array:每个 item 2 个字节

  • Uint32Array:每个 item 4 个字节

  • Float64Array:每个 item 8 个字节


按照上面的标准,一个 16 字节 ArrayBuffer,可转化的视图对象和其长度为:



  • Uint8Array:长度 16

  • Uint16Array:长度 8

  • Uint32Array:长度 4

  • Float64Array:长度 2


这里只是简单介绍流数据在前端如何存储,为的是避免你在浏览器看到一个长长的 ArrayBuffer 不知道它是什么,记住它一定是二进制数据。


为什么选 flv?


前面说到,直播需要实时性,延迟当然越短越好。当然决定传输速度的因素有很多,其中一个就是视频数据本身的大小。


点播场景我们最常见的 mp4 格式,对前端是兼容性最好的。但是相对来说 mp4 的体积比较大,解析会复杂一些。在直播场景下这就是 mp4 的劣势。


flv 就不一样了,它的头部文件非常小,结构简单,解析起来又块,在直播的实时性要求下非常有优势,因此它成了最常用的直播方案之一。


当然除了 flv 之外还有其他格式,对应直播协议,我们一一对比一下:



  • RTMP: 底层基于 TCP,在浏览器端依赖 Flash。

  • HTTP-FLV: 基于 HTTP 流式 IO 传输 FLV,依赖浏览器支持播放 FLV。

  • WebSocket-FLV: 基于 WebSocket 传输 FLV,依赖浏览器支持播放 FLV。

  • HLS: Http Live Streaming,苹果提出基于 HTTP 的流媒体传输协议。HTML5 可以直接打开播放。

  • RTP: 基于 UDP,延迟 1 秒,浏览器不支持。


其实早期常用的直播方案是 RTMP,兼容性也不错,但是它依赖 Flash,而目前浏览器下 Flash 默认是被禁用的状态,已经被时代淘汰的技术,因此不做考虑。


HLS 协议也很常见,对应视频格式就是 m3u8。它是由苹果推出,对手机支持非常好,但是致命缺点是延迟高(10~30 秒),因此也不做考虑。


RTP 不必说,浏览器不支持,剩下的就只有 flv 了。


但是 flv 又分为 HTTP-FLVWebSocket-FLV,它两看着像兄弟,又有什么区别呢?


前面我们说过,直播流是实时传输,连接创建后不会断,需要持续的推拉流。这种需要长连接的场景我们首先想到的方案自然是 WebSocket,因为 WebSocket 本来就是长连接实时互传的技术。


不过呢随着 js 原生能力扩展,出现了像 fetch 这样比 ajax 更强的黑科技。它不光支持对我们更友好的 Promise,并且天生可以处理流数据,性能很好,而且使用起来也足够简单,对我们开发者来说更方便,因此就有了 http 版的 flv 方案。


综上所述,最适合浏览器直播的是 flv,但是 flv 也不是万金油,它的缺点是前端 video 标签不能直接播放,需要经过处理才行。


处理方案,就是我们今天的主角:flv.js


协议与基础实现


前面我们说到,flv 同时支持 WebSocket 和 HTTP 两种传输方式,幸运的是,flv.js 也同时支持这两种协议。


选择用 http 还是 ws,其实功能和性能上差别不大,关键看后端同学给我们什么协议吧。我这边的选择是 http,前后端处理起来都比较方便。


接下来我们介绍 flv.js 的具体接入流程,官网在这里


假设现在有一个直播流地址:http://test.stream.com/fetch-media.flv,第一步我们按照官网的快速开始建一个 demo:


import flvjs from 'flv.js'
if (flvjs.isSupported()) {
var videoEl = document.getElementById('videoEl')
var flvPlayer = flvjs.createPlayer({
type: 'flv',
url: 'http://test.stream.com/fetch-media.flv'
})
flvPlayer.attachMediaElement(videoEl)
flvPlayer.load()
flvPlayer.play()
}

首先安装 flv.js,代码的第一行是检测浏览器是否支持 flv.js,其实大部分浏览器是支持的。接下来就是获取 video 标签的 DOM 元素。flv 会把处理后的 flv 流输出给 video 元素,然后在 video 上实现视频流播放。


接下来是关键之处,就是创建 flvjs.Player 对象,我们称之为播放器实例。播放器实例通过 flvjs.createPlayer 函数创建,参数是一个配置对象,常用如下:



  • type:媒体类型,flvmp4,默认 flv

  • isLive:可选,是否是直播流,默认 true

  • hasAudio:是否有音频

  • hasVideo:是否有视频

  • url:指定流地址,可以是 https(s) or ws(s)


上面的是否有音频,视频的配置,还是要看流地址是否有音视频。比如监控流只有视频流没有音频,那即便你配置 hasAudio: true 也是不可能有声音的。


播放器实例创建之后,接下来就是三步走:



  • 挂载元素:flvPlayer.attachMediaElement(videoEl)

  • 加载流:flvPlayer.load()

  • 播放流:flvPlayer.play()


基础实现流程就这么多,下面再说一下处理过程中的细节和要点。


细节处理要点


基本 demo 跑起来了,但若想上生产环境,还需要处理一些关键问题。


暂停与播放


点播中的暂停与播放很容易,播放器下面会有一个播放/暂停按键,想什么时候暂停都可以,再点播放的时候会接着上次暂停的地方继续播放。但是直播中就不一样了。


正常情况下直播应该是没有播放/暂停按钮以及进度条的。因为我们看的是实时信息,你暂停了视频,再点播放的时候是不能从暂停的地方继续播放的。为啥?因为你是实时的嘛,再点播放的时候应该是获取最新的实时流,播放最新的视频。


具体到技术细节,前端的 video 标签默认是带有进度条和暂停按钮的,flv.js 将直播流输出到 video 标签,此时如果点击暂停按钮,视频也是会停住的,这与点播逻辑一致。但是如果你再点播放,视频还是会从暂停处继续播放,这就不对了。


那么我们换个角度,重新审视一下直播的播放/暂停逻辑。


直播为什么需要暂停?拿我们视频监控来说,一个页面会放好几个摄像头的监控视频,如果每个播放器一直与服务器保持连接,持续拉流,这会造成大量的连接和消耗,流失的都是白花花的银子。


那我们是不是可以这样:进去网页的时候,找到想看的摄像头,点击播放再拉流。当你不想看的时候,点击暂停,播放器断开连接,这样是不是就会节省无用的流量消耗。


因此,直播中的播放/暂停,核心逻辑是拉流/断流


理解到这里,那我们的方案应该是隐藏 video 的暂停/播放按钮,然后自己实现播放和暂停的逻辑。


还是以上述代码为例,播放器实例(上面的 flvPlayer 变量)不用变,播放/暂停代码如下:


const onClick = isplay => {
// 参数 isplay 表示当前是否正在播放
if (isplay) {
// 在播放,断流
player.unload()
player.detachMediaElement()
} else {
// 已断流,重新拉流播放
player.attachMediaElement(videoEl.current)
player.load()
player.play()
}
}

异常处理


用 flv.js 接入直播流的过程会遇到各种问题,有的是后端数据流的问题,有的是前端处理逻辑的问题。因为流是实时获取,flv 也是实时转化输出,因此一旦发生错误,浏览器控制台会循环连续的打印异常。


如果你用 react 和 ts,满屏异常,你都无法开发下去了。再有直播流本来就可能发生许多异常,因此错误处理非常关键。


官方对异常处理的说明不太明显,我简单总结一下:


首先,flv.js 的异常分为两个级别,可以看作是 一级异常二级异常


再有,flv.js 有一个特殊之处,就是它的 事件错误 都是用枚举来表示,如下:



  • flvjs.Events:表示事件

  • flvjs.ErrorTypes:表示一级异常

  • flvjs.ErrorDetails:表示二级异常


下面介绍的异常和事件,都是基于上述枚举,你可以理解为是枚举下的一个 key 值。


一级异常有三类:



  • NETWORK_ERROR:网络错误,表示连接问题

  • MEDIA_ERROR:媒体错误,格式或解码问题

  • OTHER_ERROR:其他错误


二级级异常常用的有三类:



  • NETWORK_STATUS_CODE_INVALID:HTTP 状态码错误,说明 url 地址有误

  • NETWORK_TIMEOUT:连接超时,网络或后台问题

  • MEDIA_FORMAT_UNSUPPORTED:媒体格式不支持,一般是流数据不是 flv 的格式


了解这些之后,我们在播放器实例上监听异常:


// 监听错误事件
flvPlayer.on(flvjs.Events.ERROR, (err, errdet) => {
// 参数 err 是一级异常,errdet 是二级异常
if (err == flvjs.ErrorTypes.MEDIA_ERROR) {
console.log('媒体错误')
if(errdet == flvjs.ErrorDetails.MEDIA_FORMAT_UNSUPPORTED) {
console.log('媒体格式不支持')
}
}
if (err == flvjs.ErrorTypes.NETWORK_ERROR) {
console.log('网络错误')
if(errdet == flvjs.ErrorDetails.NETWORK_STATUS_CODE_INVALID) {
console.log('http状态码异常')
}
}
if(err == flvjs.ErrorTypes.OTHER_ERROR) {
console.log('其他异常:', errdet)
}
}

除此之外,自定义播放/暂停逻辑,还需要知道加载状态。可以通过以下方法监听视频流加载完成:


player.on(flvjs.Events.METADATA_ARRIVED, () => {
console.log('视频加载完成')
})

样式定制


为什么会有样式定制?前面我们说了,直播流的播放/暂停逻辑与点播不同,因此我们要隐藏 video 的操作栏元素,通过自定义元素来实现相关功能。


首先要隐藏播放/暂停按钮,进度条,以及音量按钮,用 css 实现即可:


/* 所有控件 */
video::-webkit-media-controls-enclosure {
display: none;
}
/* 进度条 */
video::-webkit-media-controls-timeline {
display: none;
}
video::-webkit-media-controls-current-time-display {
display: none;
}
/* 音量按钮 */
video::-webkit-media-controls-mute-button {
display: none;
}
video::-webkit-media-controls-toggle-closed-captions-button {
display: none;
}
/* 音量的控制条 */
video::-webkit-media-controls-volume-slider {
display: none;
}
/* 播放按钮 */
video::-webkit-media-controls-play-button {
display: none;
}

播放和暂停的逻辑上面讲了,样式这边自定义一个按钮即可。除此之外我们还可能需要一个全屏按钮,看一下全屏的逻辑怎么写:


const fullPage = () => {
let dom = document.querySelector('.video')
if (dom.requestFullscreen) {
dom.requestFullscreen()
} else if (dom.webkitRequestFullScreen) {
dom.webkitRequestFullScreen()
}
}

其他自定义样式,比如你要做弹幕,在 video 上面盖一层元素自行实现就可以了。


作者:杨成功
来源:juejin.cn/post/7044707642693910541
收起阅读 »

前端访问系统文件夹

web
随着前端技术和浏览器的升级,越来越多的功能可以在前端实现而不用依赖于后端。其中,访问系统文件夹是一个非常有用的功能,例如上传文件时,用户可以直接从自己的电脑中选择文件。 使用方法 在早期的浏览器中,要访问系统中的文件夹需要使用 ActiveX 控件或 Java...
继续阅读 »

随着前端技术和浏览器的升级,越来越多的功能可以在前端实现而不用依赖于后端。其中,访问系统文件夹是一个非常有用的功能,例如上传文件时,用户可以直接从自己的电脑中选择文件。


使用方法


在早期的浏览器中,要访问系统中的文件夹需要使用 ActiveX 控件或 Java Applet,这些方法已经逐渐淘汰。现在,访问系统文件夹需要使用HTML5的 API。


最常使用的 API 是FileAPI,配合 input[type="file"] 通过用户的交互行为来获取文件。但是,这种方法需要用户选择具体的文件而不是像在系统中打开文件夹来进行选择。


HTML5 还提供了更加高级的 API,如 showDirectoryPicker。它支持在浏览器中打开一个目录选择器,从而简化了选择文件夹的流程。这个 API 的使用也很简单,只需要调用 showDirectoryPicker() 方法即可。


async function pickDirectory() {
const directoryHandle = await window.showDirectoryPicker();
console.log(directoryHandle);
}

但是需要注意的是该api的兼容性较低,目前所支持的浏览器如下图所示:


image.png


点击一个按钮后,调用 pickDirectory() 方法即可打开选择文件夹的对话框,选择完文件夹后,该方法会返回一个 FileSystemFileHandle 对象,开发者可以使用这个对象来访问所选择的目录的内容。


应用场景


访问系统文件夹可以用于很多场景,下面列举几个常用的场景。


上传文件


在前端上传文件时,用户需要选择所需要上传的文件。这时,打开一个文件夹选择器,在用户选择了一个文件夹后,就可以读取文件夹中的文件并进行上传操作。


本地文件管理


将文件夹中的文件读取到前端后,可以在前端进行一些操作,如修改文件名、查看文件信息等。这个在 纯前端文件管理器 中就被广泛使用。


编辑器功能


访问系统文件夹可以将前端编辑器与本地的文件夹绑定,使得用户直接在本地进行编写代码,而不是将代码保存到云端,这对于某些敏感数据的处理尤为重要。


结尾的话


通过HTML5的 API,前端可以访问到系统中的文件夹,这项功能可以应用于上传文件、本地文件管理和编辑器功能等场景,为用户带来了极大的便利。


作者:白椰子
来源:juejin.cn/post/7222636308740014135
收起阅读 »

【手把手教学】基于vue封装一个安全键盘组件

web
基于vue封装一个安全键盘组件 为什么需要安全键盘 大部分中文应用弹出的默认键盘是简体中文输入法键盘,在输入用户名和密码的时候,如果使用简体中文输入法键盘,输入英文字符和数字字符的用户名和密码时,会自动启动系统输入法自动更正提示,然后用户的输入记录会被缓存下...
继续阅读 »

基于vue封装一个安全键盘组件



为什么需要安全键盘


大部分中文应用弹出的默认键盘是简体中文输入法键盘,在输入用户名和密码的时候,如果使用简体中文输入法键盘,输入英文字符和数字字符的用户名和密码时,会自动启动系统输入法自动更正提示,然后用户的输入记录会被缓存下来。


系统键盘缓存最方便拿到的就是利用系统输入法自动更正的字符串输入记录。 缓存文件的地址是:



/private/var/mobile/Library/Keyboard/dynamic-text.dat



导出该缓存文件,查看内容,欣喜的发现一切输入记录都是明文存储的。因为系统不会把所有的用户输入记录都当作密码等敏感信息来处理。 一般情况下,一个常规 iPhone 用户的 dynamic-text.dat 文件,高频率出现的字符串就是用户名和密码。


使用自己定制的安全键盘的原因主要有:



  • 避免第三方读取系统键盘缓存

  • 防止屏幕录制 (自己定制的键盘按键不加按下效果)


实现方案


封装组件


首先建一个文件safeKeyboard.vue安全键盘子组件.



话不多说,直接上才艺(代码)



<template>
<div class="keyboard">
<div class="key_title">
<p><img src="../../../../static/img/ic_logo@2x.png"><span>小猴子的安全键盘span>p>
div>
<p v-for="keys in keyList" :style="(keys.length<10&&keys.indexOf('ABC')<1&&keys.indexOf('del')<1&&keys.indexOf('suc')<1)?'padding: 0px 20px;':''">
<template v-for="key in keys">
<i v-if="key === 'top'" @click.stop="clickKey" @touchend.stop="clickKey" class="tab-top"><img class="top" :src='top_img'>i>
<i v-else-if="key === 'del'" @click.stop="clickKey" @touchend.stop="clickKey" class="key-delete"><img class="delete" src='删除图标路径'>i>
<i v-else-if="key === 'blank'" @click.stop="clickBlank" class="tab-blank">空格i>
<i v-else-if="key === 'suc'" @click.stop="success" @touchend.stop="success" class="tab-suc">确定i>
<i v-else-if="key === '.?123' || key === 'ABC'" @click.stop="symbol" class="tab-sym">{{(status==0||status==1)?'.?123':'ABC'}}i>
<i v-else-if="key === '123' || key === '#+='" @click.stop="number" class="tab-num">{{status==3?'123':'#+='}}i>
<i v-else @click.stop="clickKey" @touchend.stop="clickKey">{{key}}i>
template>
p>
div>
template>

<script>
export default {
data () {
return {
keyList: [],
status: 0, // 0 小写 1 大写 2 数字 3 符号
topStatus: 0, // 0 小写 1 大写
top_img: require('小写图片路径'),
lowercase: [
['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
['top', 'z', 'x', 'c', 'v', 'b', 'n', 'm', 'del'],
['.?123', 'blank', 'suc']
],
numbercase: [
['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'],
['-', '/', ':', ';', '(', ')', '$', '&', '@', '"'],
['#+=', '.', ',', '?', '!', "'", 'del'],
['ABC', 'blank', 'suc']
],
symbolcase: [
['[', ']', '{', '}', '#', '%', '^', '*', '+', '='],
['_', '\\', '|', '~', '<', '>', '€', '`', '¥', '·'],
['123', '.', ',', '?', '!', "'", 'del'],
['ABC', 'blank', 'suc']
],
uppercase: [
['Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'],
['A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L'],
['top', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', 'del'],
['.?123', 'blank', 'suc']
],
equip: !!navigator.userAgent.toLocaleLowerCase().match(/ipad|mobile/i)// 是否是移动设备
}
},
props: {
option: {
type: Object
}
},

mounted () {
this.keyList = this.lowercase
},

methods: {
tabHandle ({value = ''}) {
if (value.indexOf('tab-num') > -1) {
if (this.status === 3) {
this.status = 2
this.keyList = this.numbercase
} else {
this.status = 3
this.keyList = this.symbolcase
}
// 数字键盘数据
} else if (value.indexOf('delete') > -1) {
this.emitValue('delete')
} else if (value.indexOf('tab-blank') > -1) {
this.emitValue(' ')
} else if (value.indexOf('tab-point') > -1) {
this.emitValue('.')
} else if (value.indexOf('tab-sym') > -1) {
if (this.status === 0) {
this.topStatus = 0
this.status = 2
this.keyList = this.numbercase
} else if (this.status === 1) {
this.topStatus = 1
this.status = 2
this.keyList = this.numbercase
} else {
if (this.topStatus == 0) {
this.status = 0
this.top_img = require('小写图片路径')
this.keyList = this.lowercase
}else{
this.status = 1
this.keyList = this.uppercase
this.top_img = require('大写图片路径')
}
}
// 符号键盘数据
} else if (value.indexOf('top') > -1) {
if (this.status === 0) {
this.status = 1
this.keyList = this.uppercase
this.top_img = require('大写图片路径')
} else {
this.status = 0
this.keyList = this.lowercase
this.top_img = require('小写图片路径')
}
} else if (value.indexOf('tab-suc') > -1) {
this.$emit('closeHandle', this.option) // 关闭键盘
}
},
number (event) {
this.tabHandle(event.srcElement.classList)
},
clickBlank (event) {
this.tabHandle(event.srcElement.classList)
},
symbol (event) {
this.tabHandle(event.srcElement.classList)
},
success (event) {
this.tabHandle(event.srcElement.classList)
},
english (event) {
this.tabHandle(event.srcElement.classList)
},
clickKey (event) {
if (event.type === 'click' && this.equip) return
let value = event.srcElement.innerText
value ? this.emitValue(value) : this.tabHandle(event.srcElement.classList)
},

emitValue (key) {
this.$emit('keyVal', key) // 向父组件传值
},

closeModal (e) {
if (e.target !== this.option.sourceDom) {
this.$emit('closeHandle', this.option)
this.keyList = this.lowercase
}
},
}
}
script>
<style scoped lang="scss">
.keyboard {
width: 100%;
margin: 0 auto;
font-size: 18px;
border-radius: 2px;
background-color: #fff;
box-shadow: 0 -2px 2px 0 rgba(89,108,132,0.20);
user-select: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 999;
pointer-events: auto;
.key_title{
height: 84px;
font-size: 32px;
color: #0B0B0B;
overflow: hidden;
margin-bottom: 16px;
p{
display: flex;
justify-content: center;
align-items: center;
min-width: 302px;
height: 32px;
margin: 32px auto 0px;
img{
width: 32px;
height: 32px;
margin-right: 10px;
}
}
}
p {
width: 99%;
margin: 0 auto;
height: 84px;
margin-bottom: 24px;
display: flex;
display: -webkit-box;
flex-direction: row;
flex-wrap: nowrap;
justify-content: center;
box-sizing: border-box;
i {
position: relative;
display: block;
margin: 0px 5px;
height: 84px;
line-height: 84px;
font-style: normal;
font-size: 48px;
border-radius: 8px;
width: 64px;
background-color: #F2F4F5;
box-shadow: 0 2px 0 0 rgba(0,0,0,0.25);
text-align: center;
flex-grow: 1;
flex-shrink: 1;
flex-basis: 0;
-webkit-box-flex: 1;
img{
width: 48px;
height: 48px;
}
}
i:first-child{
margin-left: 0px
}
i:last-child{
margin-right: 0px
}
i:active {
background-color: #A9A9A9;
}
.tab-top, .key-delete, .tab-num, .tab-eng, .tab-sym{
background-color: #CED6E0;
}
.tab-top,.key-delete {
display: flex;
justify-content: center;
align-items: center;
width: 84px;
height: 84px;
}
.tab-top{
margin-right: 30px;
font-size: 32px;
}
.key-delete{
margin-left: 30px;
}
.tab-num, .tab-eng, .tab-sym{
font-size: 32px;
}
.tab-point {
width: 70px;
}
.tab-blank, .tab-suc{
text-align: center;
line-height: 84px;
font-size: 32px;
color: #000;
}
.tab-blank{
flex: 2.5;
}
.tab-suc{
background-color: #CFA46A;
}
}
p:last-child{
margin-bottom: 8px;
}
}
style>

但是,键盘的特性是,点击除键盘和输入框以外的地方,键盘收起。


所以还需要一个clickoutside.js文件,用来自定义一个指令,实现需求:


代码如下:


export default {
bind(el, binding, vnode) {
function documentHandler(e) {
if (el.contains(e.target)) {
return false;
}
if (binding.expression) {
binding.value(e);
}
}
el.__vueClickOutside__ = documentHandler;
document.addEventListener('click', documentHandler);
},
unbind(el, binding) {
document.removeEventListener('click', el.__vueClickOutside__);
delete el.__vueClickOutside__;
}
};

然后在safeKeyboard.vue中引入:


import clickoutside from './clickoutside'

并注册局部指令:


directives: { clickoutside }

然后绑定方法:


class="keyboard" v-clickoutside="closeModal">

声明方法:


closeModal (e) {
if (e.target !== this.option.sourceDom) {
this.$emit('closeHandle', this.option)
this.keyList = this.lowercase
}
},

安全键盘组件就构建完成了,接下来是在需要用到安全键盘的页面引入使用了。


使用组件


引入组件

import Keyboard from './safeKeyboard'

components: {
Keyboard
}

使用范例

type="password" ref="setPwd" v-model='password'/> 

v-if="option.show" :option="option" @keyVal="getInputValue" @closeHandle="onLeave">

键盘相关数据对象及方法


  • option


option: {
show: false, // 键盘是否显示
sourceDom: '', // 键盘绑定的Input元素
_type: '' // 键盘绑定的input元素ref
},


  • getInputValue



getInputValue(val)会接收键盘录入的数据,val是输入的单个字符或者是删除操作,由于是单个字符,所以需在方法中手动拼接成字符串。在方法中根据option._type区分是哪个输入框的数据。




  • onLeave



onLeave()相当于blur,这是由于在移动端H5项目中,input获取焦点时会调起手机软键盘,所以需要禁止软键盘被调起来,办法是:



document.activeElement.blur() // ios隐藏键盘

this.$refs.setPwd.blur() // android隐藏键盘


就相当于强制使input元素处于blur状态,那么软键盘就不会被调起,所以如果要做blur监听,就需要onLeave()。



但是这样出现了一个新的问题,输入框里面没有光标!!虽然不影响业务逻辑,但是用户用起来会很不舒服。


所以,只能和input元素说再见了,自己手写一个吧:


输入框组件


再来一个子组件cursorBlink.vue


<template>
<div class="cursor-blink" @click.stop="isShow">
<span v-if="pwd.length>0" :style="options.show?'':'border:0;animation:none;'" class="blink">{{passwordShow}}span>
<span v-else style="color: #ddd" :style="options.show?'':'border:0;animation:none;'" class="blink_left">{{options.desc}}span>
div>
template>
<script>
export default {
props: {
pwd: {
type: String
},
options: {
type: Object
},
},
data(){
return {
passwordShow: '',
}
},
mounted() {
if(this.pwd.length > 0){
for (let i = 0; i < this.pwd.length; i++) {
this.passwordShow += '*' // 显示为掩码
}
}
},
watch: {
pwd(curVal, oldVal){
if (oldVal.length < curVal.length) {
// 输入密码时
this.passwordShow += '*'
} else if (oldVal.length > curVal.length) {
// 删除密码时
this.passwordShow = this.passwordShow.slice(0, this.passwordShow.length - 1)
}
}
},
methods: {
isShow(){
this.$emit('cursor')
}
},
}
script>
<style lang="scss" scoped>
.cursor-blink{
display: inline-block;
width: 500px;
height: 43px;
letter-spacing: 0px;
word-spacing: 0px;
padding: 2px 0px;
font-size: 28px;
overflow: hidden;
.blink,.blink_left{
display: inline;
margin: 0px;
}
.blink{ // 输入密码后
border-right: 2px solid #000;
animation: blink 1s infinite steps(1, start);
}
.blink_left{ // 输入密码前
border-left: 2px solid #000;
animation: blinkLeft 1s infinite steps(1, start);
}
}
@keyframes blink {
0%, 100% {
border-right: 2px solid #fff;
}
50% {
border-right: 2px solid #000;
}
}
@keyframes blinkLeft {
0%, 100% {
border-left: 2px solid #fff;
}
50% {
border-left: 2px solid #000;
}
}
style>

引入之后光荣的接替input的位置:


<CursorBlink :pwd='password' ref="setPwd" :options='option2' @cursor="onFocus"></CursorBlink>

数据方法说明:


option2: {
show: false, // 区分输入前输入后
desc: '请重复输入密码' // 相当于placeholder
},

onFocus() 相当于input标签的focus

这样一个完美的安全键盘就做好了。


我是摸鱼君,你的【三连】就是摸鱼君创作的最大动力,如果本篇文章有任何错误和建议,欢迎大家留言!


作者:摸鱼君111
来源:juejin.cn/post/7309158055018168346
收起阅读 »

前人在 vue 项目中的 “砍树型“ 写法,让后人乘不了凉!

web
前言 最近在协助小伙伴解决问题时,在项目中都会遇到一些 “砍树型” 的写法,这些写法容易让后续 简单 的需求变得 复杂,都说 "前人栽树后人乘凉",但项目中有些写法是真的让后人乘不了凉的,甚至还得被迫加入 “砍树队伍”。 本篇文章就列举一些,在 vue 项目中...
继续阅读 »

前言


最近在协助小伙伴解决问题时,在项目中都会遇到一些 “砍树型” 的写法,这些写法容易让后续 简单 的需求变得 复杂,都说 "前人栽树后人乘凉",但项目中有些写法是真的让后人乘不了凉的,甚至还得被迫加入 “砍树队伍”。


本篇文章就列举一些,在 vue 项目中的 “砍树型” 的写法,以及分析一下如何写才更合适 “栽树”,如果你有更好的方案,欢迎在评论区分享!!!


89DFA925.png


砍树 & 栽树


由于项目源码不便于直接展示,下面会使用同等的代码实例来替代。


其项目技术栈为:vue2 + vue-class-component + vue-property-decorator + typescript


滥用 watch


砍树型写法


@Watch('person', { deep: true })
doSomething(){}

@Watch('person.name', { deep: true })
doSomething(){}

@Watch('person.age', { deep: true })
doSomething(){}

@Watch('person.hobbies', { deep: true })
doSomething(){}

第一次看到这个写法我有点迷茫,但想了想好像也不难理解:



  • 首先 person.x 的部分监听 是为了处理针对不同属性值发生修改时要执行的特定逻辑

  • 而针对 person 的整体监听 是为了执行属于公共部分的逻辑


因此,上面的写法就只是相当于只是少写了几个 if 的条件分支罢了,更何况还都用了深度监听,而实际上这种 简化方式vue 内部会实例化出多个 Watcher 实例,如下:


image.png


image.png


image.png


栽树型写法


针对上述写法,如果说后续需要追加不同属性变更时的新逻辑,会有两种情况:



  • 看懂的人,会使用一样的 person.x 的部分监听 方式去添加新逻辑

    • 实际上一个 Watcher 就可以解决,没必要实例化多个 Watcher



  • 看不懂的人,可能会把新逻辑杂糅在 person 的整体监听 的公共逻辑中

    • 还得注意添加执行时机条件的判断,很容易出问题




总之,这两种情况都并不好,因此更推荐原本 if 的写法:


@Watch('person', { deep: true })
doSomething(newVal, oldVal){
doSomethingCommon() // 公共逻辑

if(newVal.name !== oldVal.name){
doSomethingName() // 逻辑抽离
}

if(newVal.age !== oldVal.age){
doSomethingAge() // 逻辑抽离
}

...
}

值得注意的是,当使用 watch 深度监听对象时,其中的 newValoldVal 的值会一致,因为此时它们指向的是 同一个对象,因此如果真的需要如上例的方式来使用,就需要提前将目标对象进行 深度克隆


因此,这两种写法到底哪种是 "栽树",哪种是 "砍树",需要见仁见智了!


946CB97F.gif


不合理使用 async/await


砍树型写法


记得当时有反馈前端视图更新太慢,因为后端通过日志查看接口响应速度还是很快的,于是查看前端代码时发现类似如下的使用:


 async mounted(){
await this.request1(); // 耗时接口
this.request2(); // request2 需要依赖 request1 的请求结果
this.request3(); // request3 不需要依赖任何的请求结果
this.request4(); // request4 不需要依赖任何请求结果
}

这种写法就导致了 request3request4 虽然不需要依赖前面异步请求结果,但是必须要等待耗时操作完成才能请求,而视图更新又必须等待接口调用完成。


上述写法可能在 开发 和 测试 环境没有太明显的影响,但是在 生产环境,这个影响就会被放大,因为不同环境数据量不同,所接口响应速度更不同,并且用户可能不会注意你的数据是否准备完成就进行相应操作,这个时候就有可能出现问题。


93DE32BE.gif


栽树型写法


为了更快的得到视图更新,针对以上写法可进行如下调整:



  • 将无关相互依赖的请求前置在 await 之前

    • 这种方式适合使用的场景就是 request1 本身还需要再其他地方单独调用,因此其内部不适合在存放额外的逻辑


     async mounted(){
    this.request3();
    this.request4();

    await this.request1(); // 耗时接口
    this.request2(); // request2 需要依赖 request1 的请求结果
    }


  • 将相互依赖的请求在统一在内部处理

    • 例如,将 request2 放置到 request1 的具体实现中,这种方式适用于 request1request2 间在任何情况下都有紧密联系的情况下,当然也可以在 request1 内通过 条件判断 决定是否要执行 request2


     async mounted(){
    this.request3();
    this.request4();
    this.request1(); // 耗时接口
    }

    async request1(){
    const res = await asyncReq();
    this.request2(res); // request2 需要依赖 request1 的请求结果
    }



同时还需要注意的是,虽然 request2 需要依赖 request1 的结果,但是对于视图更新来说,却没有必要等待 request2 请求完成后再去更新视图,也就是说,request1 请求结束后有需要更新视图的部分就可以先更新,这样视图更新时机就不会延后。


组件层层传参


砍树型写法


项目中有一个模版切换的功能,而这个模版功能封装成了一个组件,在外部看起来是 Grandpa 组件,实际上其内部包含了 Parents 组件,而最底层使用的是 Son 组件


// 顶层组件
<Grandpa :data="data" @customEvent="customEvent" />

// 中间层组件
<Parents :data="data" @customEvent="customEvent" />

// 底层组件
<Son :data="data" @customEvent="customEvent" />

由于底层的 Son 组件 需要使用到 props data自定义事件 customEvent,在代码中通过逐层传递的方式来实现,甚至在 Grandpa 组件Parents 组件 中都有对 props.datadeepClone 深克隆 且修改后在往下层传递。


缺点很明显了:



  • 重复定义 props

    • 需要分别在 Grandpa、Parents、Son 三个组件中定义相关的 propsevent



  • props 的修改来源不确定

    • 由于 Grandpa、Parents 组件都对 props.data 有修改,在出现问题需要排查时可能都要排查 Grandpa、Parents 组件




栽树型写法


上面的写法属实繁琐且不优雅,实际上可以通过 $attrs$listeners 来实现 属性和事件透传,如下:


// 顶层组件
<Grandpa :data="data" @customEvent="customEvent" />

// 中间层组件
<Parents v-bind="$attrs" v-on="$listeners" />

// 底层组件
<Son v-bind="$attrs" v-on="$listeners" />

而其中涉及到直接通过 deepClone 深克隆 的原因应该是为了便于 直接 增加/删除 props.data 中的属性,实际上应该在 props 提供层 提供修改的方法。


946B61BF.gif


没有必要的响应式数据


砍树型写法


很多时候在 Vue 中我们需要在

收起阅读 »

🌅 让你的用户头像更具艺术感,实现一个自动生成唯一渐变色的头像组件

web
前言 这一篇文章依然是组件实现系列,这次我们来实现一个基于用户昵称动态生成用户头像的组件。在很多中后台场景中,用户并不需要自己去上传头像,而是简单的展示一个基本的头像图片,但是这样就太没意思啦。这次头像组件的,预期是能够基于用户昵称字符串生成某种颜色,然后设置...
继续阅读 »

前言


这一篇文章依然是组件实现系列,这次我们来实现一个基于用户昵称动态生成用户头像的组件。在很多中后台场景中,用户并不需要自己去上传头像,而是简单的展示一个基本的头像图片,但是这样就太没意思啦。这次头像组件的,预期是能够基于用户昵称字符串生成某种颜色,然后设置为 渐变色背景 并添加一些额外的细节就能展示出一个相对好看的头像。


实现过程


Avatar 组件


首先我们封装一个 Avatar 组件,这里我引用了 ChakraUI 组件库:


const Avatar: React.FC<{ name: string } & AvatarProps> = ({
name,
...rest
}
) =>
{
return (
<Box w="12" h="12" p="0" {...rest}>
<ChakraAvatar {...getGradientStyle(name)} name={name} />
Box>

);
};

export default Avatar;

样式生成函数


这一步还是很简单的,在组件的 props 中我使用了一个 getGradientStyle(name) 函数用于获取头像组件的样式,下面我们来实现这个函数:


const getGradientStyle: (text: string) => AvatarProps = (text) => {
const color1 = getRandomColor(text, 1);
const color2 = getRandomColor(text, 0.7);

return {
backgroundImage: `linear-gradient(135deg, ${color1}, ${color2})`,
color: "white",
display: "flex",
justifyContent: "center",
alignItems: "center",
fontWeight: "bold",
borderRadius: "10px",
background: "transparent",
fontFamily: "Helvetica, Arial, sans-serif",
};
};

随机颜色函数


这个函数返回一个包含渐变和其他属性的样式对象,这里面还有一个核心的函数 getRandomColor,这个函数可以基于字符串生成颜色,并且可以自己传入透明度,下面说说这个函数是如何实现的:


function getRandomColor(str: string, alpha: number) {
let asciiSum = 0;
for (let i = 0; i < str.length; i++) {
asciiSum += str.charCodeAt(i);
}
const red = Math.abs(Math.sin(asciiSum) * 256).toFixed(0);
const green = Math.abs(Math.sin(asciiSum + 1) * 256).toFixed(0);
const blue = Math.abs(Math.sin(asciiSum + 2) * 256).toFixed(0);
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}

解析一下 getRandomColor 函数的执行过程:



  1. 首先遍历字符串 str 中的每个字符,计算它们的 ASCII 码值之和 asciiSum

  2. 使用这个数字作为参数,通过 Math.sin 函数生成一个介于 -1 和 1 之间的正弦值。再通过 Math.sin

  3. 将这个正弦值乘以 256 并四舍五入,得到一个介于 0 和 255 之间的整数,作为 rgba 颜色值的红色、绿色、蓝色分量。

  4. 最后,将传入的透明度 alpha 与颜色值一起组成一个 rgba 颜色值字符串并返回。


实现的效果如下图:


image.png


字体阴影


基本的一个头像已经做好了,但是我们还需要补充一些细节。为了让字体在浅色背景下也可以看清楚,我们可以为字体加上一点字体阴影。


textShadow: "1px 1px 3px rgba(0, 0, 0, 0.2)"

添加前后的对比可以看下图,重点是像 "王" 字这种比较浅色的是不是一下就清晰多了。



添加前:
image.png


添加后:
image.png



背景纹理


现在的头像背景效果已经蛮不错了,但是只是一个渐变背景我还是觉得太单调了,如果里面能够增加一点纹理就好了。于是我又添加了一个水波纹的效果,实现的代码如下:


return {
backgroundImage: `linear-gradient(135deg, ${color1}, ${color2})`,
color: "white",
display: "flex",
justifyContent: "center",
alignItems: "center",
fontWeight: "bold",
borderRadius: "10px",
background: "transparent",
textShadow: "1px 1px 3px rgba(0, 0, 0, 0.2)",
fontFamily: "Helvetica, Arial, sans-serif",
position: "relative",
_before: {
content: `""`,
position: "absolute",
left: "0",
top: "0",
w: "full",
h: "full",
borderRadius: "10px",
backgroundColor: "white",
backgroundImage: `repeating-radial-gradient( circle at 0 0, transparent 0, #ffffff 9px ), repeating-linear-gradient( ${color2}, ${color1} )`,
zIndex: "-1",
},
};

在原有背景的基础上,我增加了 before 伪元素,将它的位置大小与背景重叠,然后通过 backgroundImage 设置了背景的纹理:


backgroundImage: \`repeating-radial-gradient( circle at 0 0, transparent 0, #ffffff 9px ), repeating-linear-gradient( ${color2}, ${color1} )

再通过将原本的背景色设置为 transparent 透明,将 before 的层级设置为 -1,将背后的纹理给透出去,实现的效果如下图:


image.png


好不好看这点仁者见仁,但是我觉得是精致了一点的。before 中的纹理是这样的:


image.png


是不是有点像阿尔卑斯糖呢 🍭。这个效果我是从一个背景生成网站中调整并生成的,网站地址在这:http://www.magicpattern.design/tools/css-b… ,这个网站提供了很多好看的背景纹理,可以在线调整颜色和间距,预览效果,还能直接复制 CSS 到代码里使用。


image.png


最后再放一下 26 个字母生成的头像效果,不同字母生成的颜色差别还是相对比较大的。我觉着效果都还不错,即便是不太好看的颜色在背景纹理和渐变的加成下也还凑合能看:


image.png


性能优化


最后我们看回前面生成颜色的函数,在代码里有这么一段用于生成两个渐变色的逻辑:


const color1 = getRandomColor(text, 1);
const color2 = getRandomColor(text, 0.7);

但是这里我们仅仅是改变了透明度,颜色其实是不变的,那么去计算两次颜色就没有必要了,我们可以先获取颜色,然后再改透明度,避免重复计算颜色。修改的方式有很多种,如果是你,你会怎么改呢?我的调整方式是这样的:


function getRandomColor(str: string) {
let asciiSum = 0;
for (let i = 0; i < str.length; i++) {
asciiSum += str.charCodeAt(i);
}

const red = Math.abs(Math.sin(asciiSum) * 256).toFixed(0);
const green = Math.abs(Math.sin(asciiSum + 1) * 256).toFixed(0);
const blue = Math.abs(Math.sin(asciiSum + 2) * 256).toFixed(0);
return (alpha: number) => `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}

const color = getRandomColor(text);
const color1 = color(1);
const color2 = color(0.7);

这里我将函数进行柯里化,将一个多参数的函数转换为一系列单参数的函数,每个函数接受一个参数并返回一个函数,最终返回值由最后一个函数计算得出。


我们可以通过对 getRandomColor() 函数进行一次调用来获取一个特定字符串对应的颜色生成函数,然后多次调用该生成函数并传入不同的透明度参数来生成不同的颜色。这是柯里化的一个常见应用场景。


最终完整代码如下:


import { Avatar as ChakraAvatar, AvatarProps } from "@chakra-ui/react";

function getRandomColor(str: string) {
let asciiSum = 0;
for (let i = 0; i < str.length; i++) {
asciiSum += str.charCodeAt(i);
}

const red = Math.abs(Math.sin(asciiSum) * 256).toFixed(0);
const green = Math.abs(Math.sin(asciiSum + 1) * 256).toFixed(0);
const blue = Math.abs(Math.sin(asciiSum + 2) * 256).toFixed(0);
return (alpha: number) => `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}

const getGradientStyle: (text: string) => AvatarProps = (text) => {
const color = getRandomColor(text);
const color1 = color(1);
const color2 = color(0.7);

return {
backgroundImage: `linear-gradient(135deg, ${color1}, ${color2})`,
color: "white",
display: "flex",
justifyContent: "center",
alignItems: "center",
fontWeight: "bold",
borderRadius: "10px",
background: "transparent",
textShadow: "1px 1px 3px rgba(0, 0, 0, 0.2)",
fontFamily: "Helvetica, Arial, sans-serif",
position: "relative",
_before: {
content: `""`,
position: "absolute",
left: "0",
top: "0",
w: "full",
h: "full",
borderRadius: "10px",
backgroundColor: "white",
backgroundImage: `repeating-radial-gradient( circle at 0 0, transparent 0, #ffffff 9px ), repeating-linear-gradient( ${color2}, ${color1} )`,
zIndex: "-1",
},
};
};

const Avatar: React.FC<{ name: string } & AvatarProps> = ({
name,
...rest
}
) =>
{
return <ChakraAvatar {...getGradientStyle(name)} {...rest} name={name} />;
};

export default Avatar;


总结


后面我想着可以再将 纹理的类型和方向 也基于传入的字符串去定制,这样就能实现随机度更高的定制头像了,如果未来有了更好的效果我再单独写篇文章分享!


后续这类组件封装的文章可能会出一个系列,也准备把这些组件都开源了,如果有使用或打算使用 ChakraUI 进行项目搭建的同学欢迎插眼关注。如果文章对你有帮助除了收藏之余可以点个赞 👍,respect



作者:oil欧哟
来源:juejin.cn/post/7218506966545170493
收起阅读 »

封装一个工作日历组件,顺便复习一下Date常用方法

web
背景 上周接到一个需求,开发一个工作日历组件。找了一圈没找到合适的,索性自己写一个。 下面给大家分享一下组件中使用到的一些日期API和后面实现的框选元素功能。 效果展示 demo体验地址:dbfu.github.io/work-calend… 开始之前 lu...
继续阅读 »

背景


上周接到一个需求,开发一个工作日历组件。找了一圈没找到合适的,索性自己写一个。


下面给大家分享一下组件中使用到的一些日期API和后面实现的框选元素功能。


效果展示


Kapture 2023-12-05 at 13.02.36.gif


demo体验地址:dbfu.github.io/work-calend…


开始之前


lunar-typescript


介绍组件之前先给大家介绍一个库lunar-typescript


lunar是一个支持阳历、阴历、佛历和道历的日历工具库,它开源免费,有多种开发语言的版本,不依赖第三方,支持阳历、阴历、佛历、道历、儒略日的相互转换,还支持星座、干支、生肖、节气、节日、彭祖百忌、每日宜忌、吉神宜趋、凶煞宜忌、吉神方位、冲煞、纳音、星宿、八字、五行、十神、建除十二值星、青龙名堂等十二神、黄道日及吉凶等。仅供参考,切勿迷信。


这个库封装了很多常用的api,并且使用起来也比较简单。


本文用到了上面库的获取农历和节气方法。


复习Date Api


new Date


可以使用new Date()传年月日三个参数来构造日期,这里注意一下月是从零开始的。


image.png


获取星期几


可以使用getDay方法获取,注意一下,获取的值是从0开始的,0表示星期日。


image.png


获取上个月最后一天


基于上面api,如果第三个参数传0,就表示上个月最后一天,-1,是上个月倒数第二天,以此类推。(PS:这个方法还是我有次面试,面试官告诉我的。)


image.png


获取某个月有多少天


想获取某个月有多少天,只需要获取当月最后天的日期,而当月最后一天,可以用上面new Date第三个参数传零的方式获取。


假设我想获取2023年12月有多少天,按照下面方式就可以获取到。


image.png


日期加减


假设我现在想实现在某个日期上加一天,可以像下面这样实现。


image.png


这样实现有个不好的地方,改变了原来的date,如果不想改变date,可以这样做。


image.png


比较两个日期


在写这个例子的时候,我发现一个很神奇的事情,先看例子。


image.png


大于等于结果是true,小于等于结果也是true,正常来说肯定是等于的,但是等于返回的是false,是不是很神奇。


其实原理很简单,用等于号去比较的时候,会直接比较两个对象的引用,因为是分别new的,所以两个引用肯定不相等,返回false。


用大于等于去比较的时候,会默认使用date的valueOf方法返回值去比较,而valueOf返回值也就是时间戳,他们时间戳是一样的,所以返回true。


说到这里,给大家分享一个经典面试题。


console.log(a == 1 && a == 2 && a == 3),希望打印出true


原理和上面类似,感兴趣的可以挑战一下。


这里推荐大家比较两个日期使用getTime方法获取时间戳,然后再去比较。


image.png


实战


数据结构


开发之前先把数据结构定一下,一个正确的数据结构会让程序开发变得简单。


根据上面效果图,可以把数据结构定义成这样:



/**
* 日期信息
*/

export interface DateInfo {
/**
* 年
*/

year: number;
/**
* 月
*/

month: number;
/**
* 日
*/

day: number;
/**
* 日期
*/

date: Date;
/**
* 农历日
*/

cnDay: string;
/**
* 农历月
*/

cnMonth: string;
/**
* 农历年
*/

cnYear: string;
/**
* 节气
*/

jieQi: string;
/**
* 是否当前月
*/

isCurMonth?: boolean;
/**
* 星期几
*/

week: number;
/**
* 节日名称
*/

festivalName: string;
}

/**
* 月份的所有周
*/

export interface MonthWeek {
/**
* 月
*/

month: number;
/**
* 按周分组的日期,7天一组
*/

weeks: DateInfo[][];
}

通过算法生成数据结构


现在数据结构定义好了,下面该通过算法生成上面数据结构了。


封装获取日期信息方法


/**
* 获取给定日期的信息。
* @param date - 要获取信息的日期。
* @param isCurMonth - 可选参数,指示日期是否在当前月份。
* @returns 包含有关日期的各种信息的对象。
*/

export const getDateInfo = (date: Date, isCurMonth?: boolean): DateInfo => {
// 从给定日期创建 农历 对象
const lunar = Lunar.fromDate(date);

// 获取 Lunar 对象中的农历日、月和年
const cnDay = lunar.getDayInChinese();
const cnMonth = lunar.getMonthInChinese();
const cnYear = lunar.getYearInChinese();

// 获取农历节日
const festivals = lunar.getFestivals();

// 获取 Lunar 对象中的节气
const jieQi = lunar.getJieQi();

// 从日期对象中获取年、月和日
const year = date.getFullYear();
const month = date.getMonth();
const day = date.getDate();

// 创建包含日期信息的对象
return {
year,
month,
day,
date,
cnDay,
cnMonth,
cnYear,
jieQi,
isCurMonth,
week: date.getDay(),
festivalName: festivals?.[0] || festivalMap[`${month + 1}-${day}`],
};
};

上面使用了lunar-typescript库,获取了一些农历信息,节气和农历节日。方法第二个参数isCurMonth是用来标记是否是当月的,因为很多月的第一周或最后一周都会补一些其他月日期。


把月日期按照每周7天格式化


思路是先获取给定月的第一天是星期几,如果前面有空白,用上个月日期填充,然后遍历当月日期,把当月日期填充到数组中,如果后面有空白,用下个月日期填充。


/**
* 返回给定年份和月份的周数组。
* 每个周是一个天数数组。
*
* @param year - 年份。
* @param month - 月份 (0-11)。
* @param weekStartDay - 一周的起始日 (0-6) (0: 星期天, 6: 星期六)。
* @returns 给定月份的周数组。
*/

const getMonthWeeks = (year: number, month: number, weekStartDay: number) => {
// 获取给定月份的第一天
const start = new Date(year, month, 1);

// 这里为了支持周一或周日在第一天的情况,封装了获取星期几的方法
const day = getDay(start, weekStartDay);

const days = [];

// 获取给定月份的前面的空白天数,假如某个月第一天是星期3,并且周日开始,那么这个月前面的空白天数就是3
// 如果是周一开始,那么这个月前面的空白天数就是2
// 用上个月日期替换空白天数
for (let i = 0; i < day; i += 1) {
days.push(getDateInfo(new Date(year, month, -day + i + 1)));
}

// 获取给定月份的天数
const monthDay = new Date(year, month + 1, 0).getDate();

// 把当月日期放入数组
for (let i = 1; i <= monthDay; i += 1) {
days.push(getDateInfo(new Date(year, month, i), true));
}

// 获取给定月份的最后一天
const endDate = new Date(year, month + 1, 0);
// 获取最后一天是星期几
const endDay = getDay(endDate, weekStartDay);

// 和前面一样,如果有空白位置就用下个月日期补充上
for (let i = endDay; i <= 5; i += 1) {
days.push(getDateInfo(new Date(year, month + 1, i - endDay + 1)));
}

// 按周排列
const weeks: DateInfo[][] = [];
for (let i = 0; i < days.length; i += 1) {
if (i % 7 === 0) {
weeks.push(days.slice(i, i + 7));
}
}

// 默认每个月都有6个周,如果没有的话就用下个月日期补充。
while (weeks.length < 6) {
const endDate = weeks[weeks.length - 1][6];
weeks.push(
Array.from({length: 7}).map((_, i) => {
const newDate = new Date(endDate.date);
newDate.setDate(newDate.getDate() + i + 1)
return getDateInfo(newDate);
})
);
}
return weeks;
};

getDay方法实现


function getDay(date: Date, weekStartDay: number) {
// 获取给定日期是星期几
const day = date.getDay();
// 根据给定的周开始日,计算出星期几在第一天的偏移量
if (weekStartDay === 1) {
if (day === 0) {
return 6;
} else {
return day - 1;
}
}
return day;
}

获取一年的月周数据


/**
* 获取年份的所有周,按月排列
* @param year 年
* @param weekStartDay 周开始日 0为周日 1为周一
* @returns
*/

export const getYearWeeks = (year: number, weekStartDay = 0): MonthWeek[] => {
const weeks = [];
for (let i = 0; i <= 11; i += 1) {
weeks.push({month: i, weeks: getMonthWeeks(year, i, weekStartDay)});
}
return weeks;
};

页面


页面布局使用了grid和table,使用grid布局让一行显示4个,并且会自动换行。日期显示使用了table布局。


如果想学习grid布局,推荐这篇文章


工作日历日期分为三种类型,工作日、休息日、节假日。在渲染单元格根据不同的日期类型,渲染不同背景颜色用于区分。


image.png


image.png


image.png


维护日期类型


背景


虽然节假日信息可以从网上公共api获取到,但是我们的业务希望可以自己调整日期类型,这个简单给单元格加一个点击事件,点击后弹出一个框去维护当前日期类型,但是业务希望能支持框选多个日期,然后一起调整,这个就稍微麻烦一点,下面给大家分享一下我的做法。


实现思路


实现框选框


定义一个fixed布局的div,设置背景色和边框颜色,背景色稍微有点透明。监听全局点击事件,记录初始位置,然后监听鼠标移动事件,拿当前位置减去初始位置就是宽度和高度了,初始位置就是div的left和top。


获取框选框内符合条件的dom元素


当框选框位置改变的时候,获取所有符合条件的dom元素,然后通过坐标位置判断dom元素是否和框选框相交,如果相交,说明被框选了,把当前dom返回出去。


判断两个矩形是否相交


interface Rect {
x: number;
y: number;
width: number;
height: number;
}

export function isRectangleIntersect(rect1: Rect, rect2: Rect) {
// 获取矩形1的左上角和右下角坐标
const x1 = rect1.x;
const y1 = rect1.y;
const x2 = rect1.x + rect1.width;
const y2 = rect1.y + rect1.height;

// 获取矩形2的左上角和右下角坐标
const x3 = rect2.x;
const y3 = rect2.y;
const x4 = rect2.x + rect2.width;
const y4 = rect2.y + rect2.height;

// 如果 `rect1` 的左上角在 `rect2` 的右下方(即 `x1 < x4` 和 `y1 < y4`),并且 `rect1` 的右下角在 `rect2` 的左上方(即 `x2 > x3` 和 `y2 > y3`),那么这意味着两个矩形相交,函数返回 `true`。
// 否则,函数返回 `false`,表示两个矩形不相交。
if (x1 < x4 && x2 > x3 && y1 < y4 && y2 > y3) {
return true;
} else {
return false;
}
}

具体实现


框选框组件实现


import { useEffect, useRef, useState } from 'react';

import { createPortal } from 'react-dom';
import { isRectangleIntersect } from './utils';

interface Props {
selectors: string;
sourceClassName: string;
onSelectChange?: (selectDoms: Element[]) => void;
onSelectEnd?: () => void;
style?: React.CSSProperties,
}

function BoxSelect({
selectors,
sourceClassName,
onSelectChange,
style,
onSelectEnd,
}: Props
) {

const [position, setPosition] = useState({ top: 0, left: 0, width: 0, height: 0 });

const isPress = useRef(false);

const startPos = useRef<any>();

useEffect(() => {
// 滚动的时候,框选框位置不变,但是元素位置会变,所以需要重新计算
function scroll() {
if (!isPress.current) return;
setPosition(prev => ({ ...prev }));
}

// 鼠标按下,开始框选
function sourceMouseDown(e: any) {
isPress.current = true;
startPos.current = { top: e.clientY, left: e.clientX };
setPosition({ top: e.clientY, left: e.clientX, width: 1, height: 1 })
// 解决误选择文本情况
window.getSelection()?.removeAllRanges();
}
// 鼠标移动,移动框选
function mousemove(e: MouseEvent) {
if (!isPress.current) return;

let left = startPos.current.left;
let top = startPos.current.top;
const width = Math.abs(e.clientX - startPos.current.left);
const height = Math.abs(e.clientY - startPos.current.top);

// 当后面位置小于前面位置的时候,需要把框的坐标设置为后面的位置
if (e.clientX < startPos.current.left) {
left = e.clientX;
}

if (e.clientY < startPos.current.top) {
top = e.clientY;
}

setPosition({ top, left, width, height })
}

// 鼠标抬起
function mouseup() {

if(!isPress.current) return;

startPos.current = null;
isPress.current = false;
// 为了重新渲染一下
setPosition(prev => ({ ...prev }));

onSelectEnd && onSelectEnd();
}

const sourceDom = document.querySelector(`.${sourceClassName}`);

if (sourceDom) {
sourceDom.addEventListener('mousedown', sourceMouseDown);
}

document.addEventListener('scroll', scroll);
document.addEventListener('mousemove', mousemove);
document.addEventListener('mouseup', mouseup);

return () => {
document.removeEventListener('scroll', scroll);
document.removeEventListener('mousemove', mousemove);
document.removeEventListener('mouseup', mouseup);

if (sourceDom) {
sourceDom.removeEventListener('mousedown', sourceMouseDown);
}
}
}, [])

useEffect(() => {
const selectDoms: Element[] = [];
const boxes = document.querySelectorAll(selectors);
(boxes || []).forEach((box) => {
// 判断是否在框选区域
if (isRectangleIntersect({
x: position.left,
y: position.top,
width: position.width,
height: position.height,
},
box.getBoundingClientRect()
)) {
selectDoms.push(box);
}
});
onSelectChange && onSelectChange(selectDoms);
}, [position]);


return createPortal((
isPress.current && (
<div
className='fixed bg-[rgba(0,0,0,0.2)]'
style={{
border: '1px solid #666',
...style,
...position,
}}
/>
)
), document.body)
}


export default BoxSelect;

使用框选框组件,并在框选结束后,给框选日期设置类型


import { Modal, Radio } from 'antd';
import { useEffect, useMemo, useRef, useState } from 'react';
import BoxSelect from './box-select';
import WorkCalendar from './work-calendar';

import './App.css';

function App() {

const [selectDates, setSelectDates] = useState<string[]>([]);
const [open, setOpen] = useState(false);
const [dateType, setDateType] = useState<number | null>();
const [dates, setDates] = useState<any>({});

const selectDatesRef = useRef<string[]>([]);

const workDays = useMemo(() => {
return Object.keys(dates).filter(date => dates[date] === 1)
}, [dates])

const restDays = useMemo(() => {
return Object.keys(dates).filter(date => dates[date] === 2)
}, [dates]);

const holidayDays = useMemo(() => {
return Object.keys(dates).filter(date => dates[date] === 3)
}, [dates]);

useEffect(() => {
selectDatesRef.current = selectDates;
}, [selectDates]);

return (
<div>
<WorkCalendar
defaultWeekStartDay={0}
workDays={workDays}
holidayDays={holidayDays}
restDays={restDays}
selectDates={selectDates}
year={new Date().getFullYear()}
/>

<BoxSelect
// 可框选区域
sourceClassName='work-calendar'
// 可框选元素的dom选择器
selectors='td.date[data-date]'
// 框选元素改变时的回调可以拿到框选中元素
onSelectChange={(selectDoms) =>
{
// 内部给td元素上设置了data-date属性,这样就可以从dom元素上拿到日期
setSelectDates(selectDoms.map(dom => dom.getAttribute('data-date') as string))
}}
// 框选结束事件
onSelectEnd={() => {
// 如果有框选就弹出设置弹框
if (selectDatesRef.current.length) {
setOpen(true)
}
}}
/>
<Modal
title="设置日期类型"
open={open}
onCancel={() =>
{
setOpen(false);
setSelectDates([]);
setDateType(null);
}}
onOk={() => {
setOpen(false);
selectDatesRef.current.forEach(date => {
setDates((prev: any) => ({
...prev,
[date]: dateType,
}))
})
setSelectDates([]);
setDateType(null);
}}
>
<Radio.Gr0up
options={[
{ label: '工作日', value: 1 },
{ label: '休息日', value: 2 },
{ label: '节假日', value: 3 },
]}
value={dateType}
onChange={e =>
setDateType(e.target.value)}
/>
</Modal>
</div>

)
}

export default App


工作日历改造


给td的class里加了个date,并且给元素上加了个data-date属性


image.png


image.png


如果被框选,改变一下背景色


image.png


效果展示


Kapture 2023-12-05 at 13.02.36.gif


小结


本来想给mousemove加节流函数,防止触发太频繁影响性能,后面发现不加节流很流畅,加了节流后因为延迟,反而不流畅了,后面如果有性能问题,再优化吧。


最后


借助这次封装又复习了一下Date的一些常用方法,也学到了一些关于Date不常见但是很有用的方法。


demo体验地址:dbfu.github.io/work-calend…


demo仓库地址:github.com/dbfu/work-c…


作者:前端小付
来源:juejin.cn/post/7308948738659155983
收起阅读 »

不是Typescript用不起,而是JSDoc更有性价比?

web
1. TS不香了? 2023年,几条关于 Typescript 的新闻打破了沉寂,让没什么新活好整的前端开发圈子又热闹了一番。 先是 GitHub 的报告称:“TypeScript 取代 Java 成为第三受欢迎语言”。 在其当年度 Octoverse 开...
继续阅读 »

1. TS不香了?


2023年,几条关于 Typescript 的新闻打破了沉寂,让没什么新活好整的前端开发圈子又热闹了一番。


image.png


先是 GitHub 的报告称:“TypeScript 取代 Java 成为第三受欢迎语言”



在其当年度 Octoverse 开源状态报告中,在最流行的编程语言方面,TypeScript 越来越受欢迎,首次取代 Java 成为 GitHub 上 OSS 项目中第三大最受欢迎的语言,其用户群增长了 37%。


而 Stack Overflow 发布的 2023 年开发者调查报告也显示,JavaScript 连续 11 年成为最流行编程语言,使用占比达 63.61%,TypeScript 则排名第五,使用占比 38.87%。



image.png


更大的争议则来自于:2023年9月,Ruby on Rails 作者 DHH 宣布移除其团队开源项目 Turbo 8 中的 TypeScript 代码



他认为,TypeScript 对他来说只是阻碍。不仅因为它需要显式的编译步骤,还因为它用类型编程污染了代码,很影响开发体验。



无独有偶,不久前,知名前端 UI 框架 Svelte 也宣布从 TypeScript 切换到 JavaScript。负责 Svelte 编译器的开发者说,改用 JSDoc 后,代码不需要编译构建即可进行调试 —— 简化了编译器的开发工作。


Svelte 不是第一个放弃 TypeScript 的前端框架。早在 2020 年,Deno 就迁移了一部分內部 TypeScript 代码到 JavaScript,以减少构建时间。


如此一来,今年短期内已经有几个项目从 TypeScript 切换到 JavaScript 了,这个状况就很令人迷惑。难道从 TypeScript 切回 JavaScript 已经成了当下的新潮流?这难道不是在开历史的倒车吗?


TypeScript


由微软发布于 2012 年的 TypeScript,其定位是 JavaScript 的一个超集,它的能力是以 TC39 制定的 ECMAScript 规范为基准(即 JavaScript )。业内开始用 TypeScript 是因为 TypeScript 提供了类型检查,弥补了 JavaScript 只有逻辑没有类型的问题,


对于大型项目、多人协作和需要高可靠性的项目来说,使用 TypeScript 是很好的选择;静态类型检查的好处,主要包括:



  • 类型安全

  • 代码智能感知

  • 重构支持


而 TS 带来的主要问题则有:



  • 某些库的核心代码量很小,但类型体操带来了数倍的学习、开发和维护成本

  • TypeScript 编译速度缓慢,而 esbuild 等实现目前还不支持装饰器等特性

  • 编译体积会因为各种重复冗余的定义和工具方法而变大


相比于 Svelte 的开发者因为不厌其烦而弃用 TS 的事件本身,其改用的 JSDoc 对于很多开发者来说,却是一位熟悉的陌生人。


2. JSDoc:看我几分像从前?


早在 1999 年由 Netscape/Mozilla 发布的 Rhino -- 一个 Java 编写的 JS 引擎中,已经出现了类似 Javadoc 语法的 JSDoc 雏形


Michael Mathews 在 2001 年正式启动了 JSDoc 项目,2007 年发布了 1.0 版本。直到 2011 年,重构后的 JSDoc 3.0 已经可以运行在 Node.js 上


JSDoc 语法举例


定义对象类型:


/**
* @typedef {object} Rgb
* @property {number} red
* @property {number} green
* @property {number} blue
*/


/** @type {Rgb} */
const color = { red: 255, green: 255, blue: 255 };

定义函数类型:


/**
* @callback Add
* @param {number} x
* @param {number} y
* @returns {number}
*/

const add = (x, y) => x + y;

定义枚举:


/**
* Enumerate values type
* @enum {number}
*/

const Status = {
on: 1,
off: 0,
};

定义类:


class Computer {
/**
* @readonly Readonly property
* @type {string}
*/

CPU;

/**
* @private Private property
*/

_clock = 3.999;

/**
* @param {string} cpu
* @param {number} clock
*/

constructor(cpu, clock) {
this.CPU = cpu;
this._clock = clock;
}
}

在实践中,多用于配合 jsdoc2md 等工具,自动生成库的 API 文档等。


随着前后端分离的开发范式开始流行,前端业务逻辑也日益复杂,虽然不用为每个应用生成对外的 API 文档,但类型安全变得愈发重要,开发者们也开始尝试在业务项目中使用 jsdoc。但不久后诞生的 Typescript 很快就接管了这一进程。


但前面提到的 TS 的固有问题也困扰着开发者们,直到今年几起标志性事件的发生,将大家的目光拉回 JSDoc,人们惊讶地发现:JSDoc 并没有停留在旧时光中。



吾谓大弟但有武略耳,至于今者,学识英博,非复吴下阿蒙



除了 JSDoc 本身能力的不断丰富,2018 年发布的 TypeScript 2.9 版本无疑是最令人惊喜的一剂助力;该版本全面支持了将 JSDoc 的类型声明定义成 TS 风格,更是支持了在 JSDoc 注释的类型声明中动态引入并解析 TS 类型的能力。


image.png


比如上文中的一些类型定义,如果用这种新语法,写出来可以是这样的:


定义对象类型:


/**
* @typedef {{ brand: string; color: Rgb }} Car
*/


/** @type {Rgb} */
const color = { red: 255, green: 255, blue: 255 };

定义函数类型:


/**
* @typedef {(x: number, y: number) => number} TsAdd
*/


/** @type {TsAdd} */
const add = (x, y) => x + y;

TS 中的联合类型等也可以直接用:


/**
* Union type with pipe operator
* @typedef {Date | string | number} MixDate
*/


/**
* @param {MixDate} date
* @returns {void}
*/

function showDate(date) {
// date is Date
if (date instanceof Date) date;
// date is string
else if (typeof date === 'string') date;
// date is number
else date;
}

范型也没问题:


/**
* @template T
* @param {T} data
* @returns {Promise<T>}
* @example signature:
* function toPromise<T>(data: T): Promise<T>
*/

function toPromise(data) {
return Promise.resolve(data);
}

/**
* Restrict template by types
* @template {string|number|symbol} T
* @template Y
* @param {T} key
* @param {Y} value
* @returns {{ [K in T]: Y }}
* @example signature:
* function toObject<T extends string | number | symbol, Y>(key: T, value: Y): { [K in T]: Y; }
*/

function toObject(key, value) {
return { [key]: value };
}

类型守卫:


/**
* @param {any} value
* @return {value is YOUR_TYPE}
*/

function isYourType(value) {
let isType;
/**
* Do some kind of logical testing here
* - Always return a boolean
*/

return isType;
}

至于动态引入 TS 定义也很简单,不管项目本身是否支持 TS,我们都可以放心大胆地先定义好类型定义的 .d.ts 文件,如:


// color.d.ts
export interface Rgb {
red: number;
green: number;
blue: number;
}

export interface Rgba extends Rgb {
alpha: number;
}

export type Color = Rgb | Rbga | string;

然后在 JSDoc 中:


// color.js 
/** @type {import('<PATH_TO_D_TS>/color').Color} */
const color = { red: 255, green: 255, blue: 255, alpha: 0.1 };

当然,对于内建了基于 JSDoc 的类型检查工具的 IDE,比如以代表性的 VSCode 来说,其加持能使类型安全锦上添花;与 JSDoc 类型(即便不用TS语法也可以)对应的 TS 类型会被自动推断出来并显示、配置了 //@ts-check后可以像 TS 项目一样实时显示类型错误等。这些都很好想象,在此就不展开了。


JSDoc 和 TS 能力的打通,意味着前者书写方式的简化和现代化,成为了通往 TS 的便捷桥梁;也让后者有机会零成本就能下沉到业内大部分既有的纯 JS 项目中,这路是裤衩一下子就走宽了。


3. 用例:Protobuf+TS 的渐进式平替


既然我们找到了一种让普通 JS 项目也能快速触及类型检查的途径,那也不妨想一想对于在那些短期内甚至永远不会重构为 TS 的项目,能够复刻哪些 TS 带来的好处呢?


对于大部分现代的前后端分离项目来说,一个主要的痛点就是核心的业务知识在前后端项目之间是割裂的。前后端开发者根据 PRD 或 UI,各自理解业务逻辑,然后总结出各自项目中的实体、枚举、数据派生逻辑等;这些也被成为领域知识或元数据,其割裂在前端项目中反映为一系列问题:



  • API 数据接口的入参、响应类型模糊不清

  • 表单项的很多默认值需要硬编码、多点维护

  • 前后端对于同一概念的变量或动作命名各异

  • mock 需要手写,并常与最后实际数据结构不符

  • TDD缺乏依据,代码难以重构

  • VSCode 中缺乏智能感知和提示


对于以上问题,比较理想的解决方法是前端团队兼顾 Node.js 中间层 BFF 的开发,这样无论是组织还是技术都能最大程度通用。



  • 但从业内近年的诸多实践来看,这无疑是很难实现的:即便前端团队有能力和意愿,这样的 BFF 模式也难以为继,此中既有 Node.js 技术栈面临复杂业务不抗打的问题,更多的也有既有后端团队的天然抗拒问题。

  • 一种比较成功的、前后端接受度都较好的解决方案,是谷歌推出的 ProtoBuf。


在通常的情况下,ProtoBuf(Protocol Buffers)的设计思想是先定义 .proto 文件,然后使用编译器生成对应的代码(例如 Java 类和 d.ts 类型定义)。这种方式确保了不同语言之间数据结构的一致性,并提供了跨语言的数据序列化和反序列化能力



  • 但是这无疑要求前后端团队同时改变其开发方式,如果不是从零起步的项目,推广起来还是有一点难度


因此,结合 JSDoc 的能力,我们可以设计一种退而求其次、虽不中亦不远矣的改造方案 -- 在要求后端团队写出相对比较规整的实体定义等的前提下,编写提取转换脚本,定期或手动生成对应的 JSDoc 类型定义,从而实现前后端业务逻辑的准确同步。


image.png


比如,以一个Java的BFF项目为例,可以做如下转换


枚举:


public enum Color {
RED("#FF0000"), GREEN("#00FF00"), BLUE("#0000FF");

private String hexCode;

Color(String hexCode) {
this.hexCode = hexCode;
}

public String getHexCode() {
return hexCode;
}
}

public enum Day {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}

转换为:


/**
* @readonly
* @enum {String}
*/

export const Color = {
RED: '#FF0000',
GREEN: '#00FF00',
BLUE: '#0000FF',
}

/**
* @readonly
* @enum {Number}
*/

export const Day = {
MONDAY: 0,
TUESDAY: 1,
WEDNESDAY: 2,
THURSDAY: 3,
FRIDAY: 4,
SATURDAY: 5,
}


POJO:


public class MyPojo {
private Integer id;
private String name;

public Integer getId() {
return id;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}
}

转换为:


/**
* @typedef {Object} MyPojo
* @property {Integer} [id]
* @property {String} [name]
*/


在转换的方法上,理论上如果能基于 AST 等手段当然更好,但如本例中的 Java 似乎没有特别成熟的转换工具,java-parser 等库文档资料又过少。


而基于正则的转换虽然与后端具体写法耦合较大,但也算简单灵活。这里给出一个示例 demo 项目供参考:github.com/tonylua/jav…


作者:江米小枣tonylua
来源:juejin.cn/post/7308923428149395491
收起阅读 »

实现 height: auto 的高度过渡动画

web
对于一个 height 设置为 auto 的元素,当它的高度发生了不由样式引起的改变时,并不会触发 transition 过渡动画。 容器元素的高度往往是由其内容决定的,如果一个容器元素的内容高度突然发生了改变,而无法进行过渡动画,有时会显得比较生硬,比如下面...
继续阅读 »

对于一个 height 设置为 auto 的元素,当它的高度发生了不由样式引起的改变时,并不会触发 transition 过渡动画。


容器元素的高度往往是由其内容决定的,如果一个容器元素的内容高度突然发生了改变,而无法进行过渡动画,有时会显得比较生硬,比如下面的登录框组件:


001.gif


那么这种非样式引起的变化如何实现过渡效果呢?可以借助 FLIP 技术。


FLIP 是什么


FLIPFirstLastInvertPlay 的缩写,其含义是:



  • First - 获取元素变化之前的状态

  • Last - 获取元素变化后的最终状态

  • Invert - 将元素从 Last 状态反转到 First 状态,比如通过添加 transform 属性,使得元素变化后,看起来仍像是处于 First 状态一样

  • Play - 此时添加过渡动画,再移除 Invert 效果(取消 transform),动画就会开始生效,使得元素看起来从 First 过渡到了 Last


需要用到的 Web API


要实现一个基本的 FLIP 过渡动画,需要使用到以下一些 Web API



基本过渡效果实现


使用以上 API,就可以初步实现一个监听元素尺寸变化,并对其应用 FLIP 动画的函数 useBoxTransition,代码如下:


/**
*
* @param {HTMLElement} el 要实现过渡的元素 DOM
* @param {number} duration 过渡动画持续时间,单位 ms
* @returns 返回一个函数,调用后取消对过渡元素尺寸变化的监听
*/

export default function useBoxTransition(el: HTMLElement, duration: number) {
// boxSize 用于记录元素处于 First 状态时的尺寸大小
let boxSize: {
width: number
height: number
} | null = null

const elStyle = el.style // el 的 CSSStyleDeclaration 对象

const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
// 被观察的 box 发生尺寸变化时要进行的操作

// 获取当前回调调用时,box 的宽高
const borderBoxSize = entry.borderBoxSize[0]
const writtingMode = elStyle.getPropertyValue('writing-mode')
const isHorizontal =
writtingMode === 'vertical-rl' ||
writtingMode === 'vertical-lr' ||
writtingMode === 'sideways-rl' ||
writtingMode === 'sideways-lr'
? false
: true
const width = isHorizontal
? borderBoxSize.inlineSize
: borderBoxSize.blockSize
const height = isHorizontal
? borderBoxSize.blockSize
: borderBoxSize.inlineSize

// 当 box 尺寸发生变化时,使用 FLIP 动画技术产生过渡动画,使用过渡效果的是 scale 形变
// 根据 First 和 Last 计算出 Inverse 所需的 scale 大小
// box 首次被观察时会触发一次回调,此时 boxSize 为 null,scale 应为 1
const scaleX = boxSize ? boxSize.width / width : 1
const scaleY = boxSize ? boxSize.height / height : 1
// 尺寸发生变化的瞬间,要使用 scale 变形将其保持变化前的尺寸,要先将 transition 去除
elStyle.setProperty('transition', 'none')
elStyle.setProperty('transform', `scale(${scaleX}, ${scaleY})`)
// 将 scale 移除,并应用 transition 以实现过渡效果
setTimeout(() => {
elStyle.setProperty('transform', 'none')
elStyle.setProperty('transition', `transform ${duration}ms`)
})
// 记录变化后的 boxSize
boxSize = { width, height }
}
})
resizeObserver.observe(el)
const cancelBoxTransition = () => {
resizeObserver.unobserve(el)
}
return cancelBoxTransition
}

效果如下所示:


002.gif


效果改进


目前已经实现了初步的过渡效果,但在一些场景下会有些瑕疵:



  • 如果在过渡动画完成前,元素有了新的状态变化,则动画被打断,无法平滑过渡到新的状态

  • FLIP 动画过渡过程中,实际上发生变化的是 transform 属性,并不影响元素在文档流中占据的位置,如果需要该元素影响周围的元素,那么周围元素无法实现平滑过渡


如下所示:


003.gif


对于动画打断问题的优化思路



  • 使用 Window.requestAnimationFrame() 方法在每一帧中获取元素的尺寸

  • 这样做可以实时地获取到元素的尺寸,实时地更新 First 状态


对于元素在文档流中问题的优化思路



  • 应用过渡的元素外可以套一个 .outer 元素,其定位为 relative,过渡元素的定位为 absolute,且居中于 .outer 元素

  • 当过渡元素尺寸发生变化时,通过 resizeObserver 获取其最终的尺寸,将其宽高设置给 .outer 元素(实例代码运行于 Vue 3 中,因此使用的是 Vue 提供的 ref api 将其宽高暴露出来,可以方便地监听其变化;如果在 React 中则可以将设置 .outer 元素宽高的方法作为参数传入 useBoxTransition 中,在需要的时候调用),并给 .outer 元素设置宽高的过渡效果,使其在文档流中所占的位置与过渡元素的尺寸同步

  • 但是也要注意,这样做可能会引起浏览器高频率的重排,在复杂布局中慎用!


改进后的useBoxTransition 函数如下:


import throttle from 'lodash/throttle'
import { ref } from 'vue'

type BoxSize = {
width: number
height: number
}
type BoxSizeRef = globalThis.Ref<BoxSize>

/**
*
* @param {HTMLElement} el 要实现过渡的元素 DOM
* @param {number} duration 过渡动画持续时间,单位 ms
* @param {string} mode 过渡动画缓动速率,同 CSS transition-timing-function 可选值
* @returns 返回一个有两个项的元组:第一项为 keyBoxSizeRef,当元素大小发生变化时会将变化后的目标尺寸发送给 keyBoxSizeRef.value;第二项为一个函数,调用后取消对过渡元素尺寸变化的监听
*/

export default function useBoxTransition(
el: HTMLElement,
duration: number,
mode?: string
) {
let boxSizeList: BoxSize[] = [] // boxSizeList 表示对 box 的尺寸的记录数组;为什么是使用列表:因为当 box 尺寸变化的一瞬间,box 的 transform 效果无法及时移除,此时 box 的尺寸可能是非预期的,因此使用列表来记录 box 的尺寸,在必要的时候尽可能地将非预期的尺寸移除
const keyBoxSizeRef: BoxSizeRef = ref({ width: 0, height: 0 }) // keyBoxSizeRef 是
let isObserved = false // box 是否已经开始被观察
let frameId = 0 // 当前 animationFrame 的 id
let isTransforming = false // 当前是否处于变形过渡中

const elStyle = el.style // el 的 CSSStyleDeclaration 对象
const elComputedStyle = getComputedStyle(el) // el 的只读动态 CSSStyleDeclaration 对象

// 获取当前 boxSize 的函数
function getBoxSize() {
const rect = el.getBoundingClientRect() // el 的 DOMRect 对象
return { width: rect.width, height: rect.height }
}

// 同步更新 boxSizeList
function updateBoxsize(boxSize: BoxSize) {
boxSizeList.push(boxSize)
// 只保留前最新的 4 条记录
boxSizeList = boxSizeList.slice(-4)
}

// 定义 animationFrame 的回调函数,使得当 box 变形时可以更新 boxSize 记录
const animationFrameCallback = throttle(() => {
// 为避免使用了函数节流后,导致回调函数延迟触发使得 cancelAnimationFrame 失败,因此使用 isTransforming 变量控制回调函数中的操作是否执行
if (isTransforming) {
const boxSize = getBoxSize()
updateBoxsize(boxSize)
frameId = requestAnimationFrame(animationFrameCallback)
}
}, 20)

// 过渡事件的回调函数,在过渡过程中实时更新 boxSize
function onTransitionStart(e: Event) {
if (e.target !== el) return
// 变形中断的一瞬间,boxSize 的尺寸可能是非预期的,因此在变形开始时,将最新的 3 个可能是非预期的 boxSize 移除
if (boxSizeList.length > 1) {
boxSizeList = boxSizeList.slice(0, 1)
// console.log('移除3项', boxSizeList.slice(0, 1))
}
isTransforming = true
frameId = requestAnimationFrame(animationFrameCallback)
// console.log('过渡开始')
}
function onTransitionCancel(e: Event) {
if (e.target !== el) return
isTransforming = false
cancelAnimationFrame(frameId)
// console.log('过渡中断')
}
function onTransitionEnd(e: Event) {
if (e.target !== el) return
isTransforming = false
cancelAnimationFrame(frameId)
// console.log('过渡完成')
}

el.addEventListener('transitionstart', onTransitionStart)
el.addEventListener('transitioncancel', onTransitionCancel)
el.addEventListener('transitionend', onTransitionEnd)

const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
// 被观察的 box 发生尺寸变化时要进行的操作

// 获取当前回调调用时,box 的宽高
const borderBoxSize = entry.borderBoxSize[0]
const writtingMode = elStyle.getPropertyValue('writing-mode')
const isHorizontal =
writtingMode === 'vertical-rl' ||
writtingMode === 'vertical-lr' ||
writtingMode === 'sideways-rl' ||
writtingMode === 'sideways-lr'
? false
: true
const width = isHorizontal
? borderBoxSize.inlineSize
: borderBoxSize.blockSize
const height = isHorizontal
? borderBoxSize.blockSize
: borderBoxSize.inlineSize

const boxSize = { width, height }

// 当 box 尺寸发生变化时以及初次触发回调时,将此刻 box 的目标尺寸暴露给 keyBoxSizeRef
keyBoxSizeRef.value = boxSize

// box 首次被观察时会触发一次回调,此时不需要应用过渡,只需将当前尺寸记录到 boxSizeList 中
if (!isObserved) {
isObserved = true
boxSizeList.push(boxSize)
return
}

// 当 box 尺寸发生变化时,使用 FLIP 动画技术产生过渡动画,使用过渡效果的是 scale 形变
// 根据 First 和 Last 计算出 Inverse 所需的 scale 大小
// 不读取序号为 0 的记录,以免尺寸变化的一瞬间,box 的 transform 未来得及移除,使得最新的一条尺寸记录是非预期的
const scaleX = boxSizeList[0].width / width
const scaleY = boxSizeList[0].height / height
// 尺寸发生变化的瞬间,要使用 scale 变形将其保持变化前的尺寸,要先将 transition 去除
elStyle.setProperty('transition', 'none')
const originalTransform =
elStyle.transform || elComputedStyle.getPropertyValue('--transform')
elStyle.setProperty(
'transform',
`${originalTransform} scale(${scaleX}, ${scaleY})`
)
// 将 scale 移除,并应用 transition 以实现过渡效果
setTimeout(() => {
elStyle.setProperty('transform', originalTransform)
elStyle.setProperty('transition', `transform ${duration}ms ${mode}`)
})
}
})
resizeObserver.observe(el)
const cancelBoxTransition = () => {
resizeObserver.unobserve(el)
cancelAnimationFrame(frameId)
}
const result: [BoxSizeRef, () => void] = [keyBoxSizeRef, cancelBoxTransition]
return result
}


相应的 vue 组件代码如下:


<template>
<div class="outer" ref="outerRef">
<div class="card-container" ref="cardRef">
<div class="card-content">
<slot></slot>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import useBoxTransition from '@/utils/useBoxTransition'

type Props = {
transition?: boolean
duration?: number
mode?: string
}
const props = defineProps<Props>()

const { transition, duration = 200, mode = 'ease' } = props

const cardRef = ref<HTMLElement | null>(null)
const outerRef = ref<HTMLElement | null>(null)
let cancelBoxTransition = () => {} // 取消 boxTransition 效果

onMounted(() => {
if (cardRef.value) {
const cardEl = cardRef.value as HTMLElement
const outerEl = outerRef.value as HTMLElement
if (transition) {
const boxTransition = useBoxTransition(cardEl, duration, mode)
const keyBoxSizeRef = boxTransition[0]
cancelBoxTransition = boxTransition[1]
outerEl.style.setProperty(
'--transition',
`weight ${duration}ms ${mode}, height ${duration}ms ${mode}`
)
watch(keyBoxSizeRef, () => {
outerEl.style.setProperty('--height', keyBoxSizeRef.value.height + 'px')
outerEl.style.setProperty('--width', keyBoxSizeRef.value.width + 'px')
})
}
}
})
onUnmounted(() => {
cancelBoxTransition()
})
</script>

<style scoped lang="less">
.outer {
position: relative;
&::before {
content: '';
display: block;
width: var(--width);
height: var(--height);
transition: var(--transition);
}

.card-container {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
--transform: translate(-50%, -50%);
transform: var(--transform);
box-sizing: border-box;
background-color: rgba(255, 255, 255, 0.7);
border-radius: var(--border-radius, 20px);
overflow: hidden;
backdrop-filter: blur(10px);
padding: 30px;
box-shadow: var(--box-shadow, 0 0 15px 0 rgba(0, 0, 0, 0.3));
}
}
</style>

优化后的效果如下:


004.gif


005.gif


注意点


过渡元素本身的 transform 样式属性


useBoxTransition 函数中会覆盖应用过渡的元素的 transform 属性,如果需要额外为元素设置其它的 transform 效果,需要使用 css 变量 --transform 设置,或使用内联样式设置。


这是因为,useBoxTransition 函数中对另外设置的 transform 效果和过渡所需的 transform 效果做了合并。


然而通过 getComputedStyle(Element) 读取到的 transform 的属性值总是会被转化为 matrix() 的形式,使得 transform 属性值无法正常合并;而 CSS 变量和使用 Element.style 获取到的内联样式中 transform 的值是原始的,可以正常合并。


如何选择获取元素宽高的方式


Element.getBoundingClientRect() 获取到的 DOMRect 的宽高包含了 transform 变化,而 Element.offsetWidth / Element.offsetHeight 以及 ResizeObserverEntry 对象获取到的宽高是元素本身的占位大小。


因此在需要获取 transition 过程中,包含 transform 效果的元素大小时,使用 Element.getBoundingClientRect(),否则可以使用 Element.offsetWidth / Element.offsetHeightResizeObserverEntry 对象。


获取元素高度时遇到的 bug


测试案例中使用了 elementPlus UI 库的 el-tabs 组件,当元素包含该组件时,无论是使用 Element.getBoundingClientRect()Element.offsetHeight 还是使用 Element.StylegetComputedStyle(Element) 获取到的元素高度均缺少了 40px;而使用 ResizeObserverEntry 对象获取到的高度则是正确的,但是它无法脱离 ResizeObserver API 独立使用。


经过测试验证,缺少的 40px 高度来自于 el-tabs 组件中 .el-tabs__header 元素的高度,也就是说,在获取元素高度时,将 .el-tabs__header 元素的高度忽略了。


测试后找出的解决方法是,手动将 .el-tabs__header 元素样式(注意不要写在带 scoped 属性的 style 标签中,会被判定为局部样式而无法生效)的 height 属性指定为 calc(var(--el-tabs-header-height) - 1px),即可恢复正常的高度计算。


至于为什么这样会造成高度计算错误,希望有大神能解惑。


作者:zzc6332
来源:juejin.cn/post/7307894647655759911
收起阅读 »

大屏可视化适配

web
如何适配屏幕 1.页面尺寸比与屏幕尺寸比的关系 首先设计稿的项目宽高比是16:9 大屏可视化需要用同一个页面,适配各种尺寸的屏幕。当显示屏幕的尺寸比与页面的尺寸比不一致时,则需要将页面尽可能放大居中显示,其余地方留白。 以16:9为例,当显示屏幕的尺寸比小于1...
继续阅读 »

如何适配屏幕


1.页面尺寸比与屏幕尺寸比的关系


首先设计稿的项目宽高比是16:9


大屏可视化需要用同一个页面,适配各种尺寸的屏幕。当显示屏幕的尺寸比与页面的尺寸比不一致时,则需要将页面尽可能放大居中显示,其余地方留白。

以16:9为例,当显示屏幕的尺寸比小于16:9时,
整个页面应该垂直居中,页面有效宽度与屏幕宽度相同。


image.png
当显示屏幕的尺寸比大于等于16:9 时,整个页面应该水平居中,页面有效高度应该与屏幕高度相同。


image.png


计算方法


image.png



  • Wp 为页面有效宽度

  • Hp 为页面有效高度

  • 页面左右居中,上下居中,四周留白即可

  • 然后在 head 里用 JS 设置 1rem = Wp / 100


* 2.动态 rem 方案



  • 为了适配不同的屏幕,在页面布局时要使用自适应布局,即不能使用固定像素,需要用相对单位 remem 是相对于父元素的字号的比例,rem 是相对于根元素 html 的字号的比例。
    为了使用上的方便,需要为根元素设置合适的字号。如果将页面有效宽度看成100份的话,我们希望 1rem=(Wp/100)px。因此将根元素的字号设置为Wp/100 即可。

    当我们根据设计图进行布局时,我们能得到的是每个区域的像素值 px,我们需要一个计算公式将 px 转换为 rem 单位。


适配一个div


div在设计稿的宽度:


image.png
换算公式封装成CSS函数


@function px($n) {
@return $n / 2420 * 100rem;
}

代码实现


<head> 中用 JS 获取到浏览器(即设备)的高度和宽度,并为根元素设置合适的字号。这部分可以定义为 initHTMLFontSize 函数


const clientHeight = document.documentElement.clientHeight
const clientWidth = document.documentElement.clientWidth

const pageWidth = (clientWidth / clientHeight < 16 / 9 && clientWidth > 500)? clientWidth : clientHeight * 16 / 9
const pageHeight = pageWidth * 9 / 16
window.pageWidth = pageWidth
window.pageHeight = pageHeight
document.documentElement.style.fontSize = `${pageWidth / 100}px`

<body> 底部用 JS 设置页面的有效高度和宽度,并使页面有效内容 #root 垂直居中。

这部分则定义为 initPagePosition 函数


const root = <HTMLElement>document.querySelector('#root')
root.style.width = window.pageWidth + 'px'
root.style.height = window.pageHeight + 'px'
root.style.marginTop = (document.documentElement.clientHeight - window.pageHeight) / 2 + 'px'

使页面有效内容 #root 水平居中只需用 CSS 设置margin-left: automargin-right: auto即可


3.Grid 布局划分各图表区域


在容器 <main>  标签里面用 grid-template-areas 给各图表写好分区,每一个栏位使用一个变量表示,对应的 item 内设置 grid-area 即可。

再用 grid-template-columnsgrid-template-rows 来设定每行每列的长度,设定长度时可以用 fr 来按比例分配。

grid-column-gapgrid-row-gap 用来设置列与列,行与行之间的间隙。


.home > main {
flex: 1;
display: grid;
grid-template-areas:
"box1 box2 box4 box5"
"box3 box3 box4 box5";
grid-template-columns: 366fr 361fr 811fr 747fr;
grid-template-rows: 755fr 363fr;
grid-column-gap: px(28);
grid-row-gap: px(28);
.section1 {
grid-area: box1;
}
.section2 {
grid-area: box2;
}
.section3 {
grid-area: box3;
}
.section4 {
grid-area: box4;
}
.section5 {
grid-area: box5;
}
}

作者:用户45275688681
来源:juejin.cn/post/7308434215811924018
收起阅读 »

实现一个简单的文本溢出提示效果

web
需求背景 写一段简单的HTML代码: <div class="container">超级无敌大怪兽在此!</div> 此时如果我们为其加上文本溢出处理,只需简单三行css代码即可搞定: .container { width:...
继续阅读 »

需求背景


写一段简单的HTML代码:


<div class="container">超级无敌大怪兽在此!</div>


此时如果我们为其加上文本溢出处理,只需简单三行css代码即可搞定:


.container {
width: 100px;

overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}


如果要加上hover显示完整文字的效果,也简单,各大组件库都有tooltip组件,套上就行了,我这里就简单加个title属性演示:


<div class="container" title="超级无敌大怪兽在此!">超级无敌大怪兽在此!</div>


可是这样不是很合理,如果我的文字本来就没有溢出,加这个提示没有意义,我只需要这段当文字不能完全展示时,才需要有这个提示,类似这种效果:



那么现在,别往下滑了,如果是聪明的你会怎么开发这个需求呢?先想一想,再往下看。









需求方案


其实比较简单哈,监听元素的mouseenter事件,然后判断元素的scrollWidth是不是大于clientWidth,就可以知道元素是否在水平方向上发生溢出,然后再加上tooltip就好了,完整代码如下:


<div class="container" onmouseover="handleMouseEnter(this)">超级无敌大怪兽在此!</div>
<div class="container large" onmouseenter="handleMouseEnter(this)">超级无敌大怪兽在此!</div>

.container {
width: 100px;

overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.large {
width: 500px;
}

const handleMouseEnter = (node) => {
if (node.scrollWidth > node.clientWidth) {
node.setAttribute('title', node.innerText);
}
};

然后就没了,emmmmmm。。。。


对,没了,就这么简单。


总结


这个需求呢,其实如果之前没接触过,一时半会还真不太能想到什么好的解法,但其实做过一遍或者看到过别人分享的思路,之后自己做的时候一下就能想到,所以就给大伙分享一下,万一就帮到你了呢。最重要的是,我又成功水了一篇文,嘿嘿。


作者:超级无敌大怪兽
来源:juejin.cn/post/7307468904732426267
收起阅读 »

基于css3写出的底部导航栏效果(详细注释)

web
进行准备工作 这边对基本的样式进行了设置,首先在html部分设置了一个名为nav的div,推荐大家语义化来写不要学我这种,随后进行基本的默认样式的清除,并且设置盒子为ie盒子方便后续的计算,整体都设置为弹性盒,方便后续矢量文字的操作,对导航栏nav进行定位,...
继续阅读 »

进行准备工作



这边对基本的样式进行了设置,首先在html部分设置了一个名为nav的div,推荐大家语义化来写不要学我这种,随后进行基本的默认样式的清除,并且设置盒子为ie盒子方便后续的计算,整体都设置为弹性盒,方便后续矢量文字的操作,对导航栏nav进行定位,方便后续位置上的操作



image.png



<body>
<!-- 目前就一个简单的nav,推荐大家语义化来写 -->
<div class="nav"></div>
</body>

<style>
/* 清除一些默认样式 */
*{
margin: 0;
padding: 0;
box-sizing: border-box;
list-style: none;
}
a{
text-decoration: none;/*确保在浏览器中显示链接时,没有任何文本装饰,如下划线。 */
}
/* 对整体进行设置,并且都设置为弹性盒,方便进行操作 */
body{
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #222327;
}
/* 设置导航栏样式 */
.nav{
/* 对导航栏位置进行定位处理,方便后续的图标位置的设置 */
position: relative;
width: 400px;
height: 70px;
background: #fff;
display: flex;
justify-content: center;
align-items: center;
border-radius: 10px;
}
</style>


引入矢量文字



这里面呢引用了阿里巴巴的矢量文字效果,具体如何使用请见http://www.iconfont.cn/manage/inde…
里面的教程,这边我挑了五个字体图标加入到了网页中,并且用ul和lil加入到了导航栏中,目前是竖着排列的,后续加入css样式之后会好起来,并且在第一个li上加入了active的css样式,用于设置选中效果



image.png


image.png


<link rel="stylesheet" href="//at.alicdn.com/t/c/font_4173165_2g4t5a6pg9v.css">
<div class="nav">
<ul>
<li class="active"> <span><i class="iconfont icon-shouye"></i></span></li>
<li > <span><i class="iconfont icon-liuyan"></i></span></li>
<li > <span><i class="iconfont icon-code"></i></span></li>
<li > <span><i class="iconfont icon-box-empty"></i></span></li>
<li > <span><i class="iconfont icon-gitee-fill-round"></i></span></li>
</ul>
</div>

对导航栏和ui li字体图标进行设置



这里面呢针对ul和li进行了设置,使之达到了图下的效果,对ul 和li进行了弹性盒的设置,li中的使用flex:1让这些矢量文字按等份划分容器宽度,使之达到了一个距离平均的样式,并且设置了这个zindex的叠加级别



image.png


    .nav{
/* 对导航栏位置进行定位处理,方便后续的图标位置的设置 */
position: relative;
width: 400px;
height: 70px;
background: #fff;
display: flex;
justify-content: center;
align-items: center;
border-radius: 10px;
}
.nav ul{
display: flex;
width: 350px;
}
.nav ul li{
height: 60px;
/* flex:1是让所有的li平均分nav这个容器 */
flex: 1;
position: relative;
z-index: 2;
display: flex;
justify-content: center;
}

继续设置i元素和span元素



这里呢针对了span元素和i元素进行了设置,通过span元素蒋i元素中的矢量图标设置到水平垂直都居中的位置,并且设置了圆角,加入了动画和动画延迟,针对i元素将文字大小设置了,并且在html中加入了对应图标的文字效果,并且为例美观在每个li元素中都添加了一个选中时候的不同的颜色,使用了变量--clr用于获取选中效果 行内样式是一种直接在HTML元素上指定样式的方法,在这种情况下,你使用 style 属性将 --clr 变量设为不同色



  .nav ul li span{
/* 进行定位,使之通过span元素带动矢量图标进行水平垂直到中心位置 */
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: 55px;
height: 55px;
border-radius: 50%;
/* 设置鼠标移入的样式 */
cursor: pointer;
/* 设置动画过度事件以及延迟 */
transition: 0.5s;
transition-delay: 0s;
}
.nav ul li span i{
color: #222327;
font-size: 1.5em;
}

<body>
<!-- 目前就一个简单的nav,推荐大家语义化来写 -->
<div class="nav">
<ul>
<!-- 设置active效果,用于获取选中效果 用于获取选中效果 行内样式是一种直接在HTML元素上指定样式的方法,在这种情况下,你使用 style 属性将 --clr 变量设为不同色 -->
<li class="active" style="--clr:#f44336"><span><i class="iconfont icon-shouye"></i>首页</span></li>
<li style="--clr:#0fc70f"> <span><i class="iconfont icon-liuyan"></i>留言</span></li>
<li style="--clr:#2196f3"> <span><i class="iconfont icon-code"></i>代码</span></li>
<li style="--clr:#b145e9"> <span><i class="iconfont icon-box-empty"></i>盒子</span></li>
<li style="--clr:#ffa111"> <span><i class="iconfont icon-gitee-fill-round"></i>gitee</span></li>
<div class="indicator"></div>
</ul>
</div>
</body>

image.png


下面设置选中时候的样式,在这里呢针对span元素设置了选中的时候会向上位移到这个地方,并且在矢量图标的地方设置了开始选中的时候将文字颜色改为和背景颜色一样的颜色,这样当点击的那一刻,图标会出现消失的情况,当超出导航栏到黑色部分的时候,文字就会显示出来,在后面,设置了一个半圆的背景图,当背景图位移到文字的位置的时候,矢量文字就会显示出来


/* 下面是针对选中效果做的样式处理 */
.nav ul li.active span {
/* 设置了一开始的背景颜色,后面会被取代,设置了点击的时候会向上移动 */
background: orange;
transform: translateY(-27px);
transition-delay: 0.25s;
}

.nav ul li.active span i {
/* 设置了点击时候矢量图标的文字颜色 */
color: #fff;
}


image.png


设置模糊效果



这里呢加入了一个模糊的效果,配合后面的选中的时候图标颜色显示会形成一个类似于色彩过度的效果,并且将i元素上面设置的颜色显示出来



    .nav ul li span::before {
content: '';
position: absolute;
top: 10px;
left: 0;
width: 100%;
height: 100%;
background: orange;
border-radius: 50%;
filter: blur(40px);
opacity: 0;
transition: 0.5s;
transition-delay: 0s;
}
.nav ul li span::before {
opacity: 0.5;
transition-delay: 0.25s;
}
/* 这里将i元素设置的颜色显示出来 这两个样式块中都使用了 background: var(--clr); 属性,可以将背景颜色设置为clr 变量所表示的值。这种使用自定义变量的方式,可以在代码中统一定义颜色值,以便在需要时进行统一更改。*/
.nav ul li.active span {
background: var(--clr);
}

.nav ul li span::before {
background: var(--clr);
}


image.png


接下来设置背景圆



这里呢设置了背后的那个向下突兀的圆,其原理是通过位置的调整和颜色的与背景颜色的一致加上zindex的图册优先级的显示,构成了这么一个背景半圆形图



.indicator {
/* 这里进行了定位,并且设置了背景园的位置,同时将圆的背景颜色与背景颜色设为一致,会形成那种向下突兀的圆形,并且加入了动画 ps:这个过度的小圆弧我是真设置不好,凑合看吧,大佬们有能力的可以试试设置一下*/
position: absolute;
top: -35px;
width: 70.5px;
height: 70px;
background: #222327;
border-radius: 50%;
z-index: 1;
transition: 0.5s;
}
/* 设置左边半弧 */
.indicator::before {
content: '';
position: absolute;
top: 16px;
left: -34px;
width: 10px;
height: 5px;
background: transparent;
border-radius: 50%;
box-shadow: 20.5px 19px 0 4px #fff;
}
/* 设置右边半弧 */
.indicator::after {
content: '';
position: absolute;
top: 16px;
left: 54px;
width: 10px;
height: 5px;
background: transparent;
border-radius: 50%;
box-shadow: 20px 19px 0 4px #fff;
}

image.png


****动画设置,配合js形成点击的时候,active会移动到点击的目标身上



这里呢使用了nth-child选择器选中对应的i元素,注意,这里设置的平移效果是由clac函数计算而来,选中其中一个i元素,并且当且仅当具有active类之后的所有兄弟中的.indicator类元素,有一个指示器元素(.indicator)。指示器的位置会根据活动项目(具有active类的<li>元素)的位置进行调整。
根据活动项目的位置设置指示器的水平平移距离,实现一个在导航菜单中显示当前选中项目的效果。指示器的位置和平移距离是根据活动项目的索引和固定的长度单位(70px)进行计算的。



/*/* nth-child()选中低某个i元素,然后配合js完成背景圆的移动 
在CSS中,calc() 是一个用于执行计算的函数。它允许在CSS属性值中使用数学表达式。
这种计算函数通常用于允许动态计算和调整元素的尺寸、间距或位置。在 calc() 函数中,可以使用不同的运算符(如加号 +、减号 -、乘号 *、除号 /)来结合数值和单位进行计算。
它可以包含其他长度单位(如像素 px、百分比 % 等),并且可以与其他CSS属性值和变量一起使用。

当一个 `<li>` 元素具有 `active` 类时,对应的 `.indicator` 元素会相对于活动项目的位置水平平移一个特定的距离。每个 `.indicator` 元素的平移距离相对于其前面的活动项目索引和一个固定的长度单位(`70px`)计算得出。

*/
*/

.nav li:nth-child(1).active~.indicator{
transform: translateX(calc(70px*0));
}

.nav li:nth-child(2).active~.indicator {
transform: translateX(calc(70px*1));
}

.nav li:nth-child(3).active~.indicator {
transform: translateX(calc(70px*2));
}

.nav li:nth-child(4).active~.indicator {
transform: translateX(calc(70px*3));
}

.nav li:nth-child(5).active~.indicator {
transform: translateX(calc(70px*4));
}


这里配合js代码,通过foreach为点击的li或者为所有的li进行添加或者移入active样式


<script>
//通过 `lis.forEach(li => li.addEventListener('click', function () {...}))` 遍历 `lis` 数组中的每个元素,并为每个元素都添加一个 ‘click’ 事件监听器。
//在每次点击事件中,使用 `lis.forEach(item => {...})` 遍历 `lis` 数组中的每个元素,将它们的 `active` 类都移除,然后在当前被点击的元素上添加 `active` 类,
const lis = document.querySelectorAll('.nav li')
lis.forEach(li => li.addEventListener('click', function () {
lis.forEach(item => {
item.classList.remove('active');
this.classList.add('active');
})
}))
</script>

image.png


效果展示


ezgif.com-video-to-gif.gif


总结


这里配合js使用的动画是值得我学习的,通过js点击赋予不同的liactive样式,又根据active所在的li元素经过计算对.indicator元素进行平移,使之完成这个动画效果


已上传至gitee
gitee.com/wu-canhua/b…


作者:如意呀
来源:juejin.cn/post/7262334378759405605
收起阅读 »

代码刚上线,页面就白屏了

web
前言 白屏一直是一个前端开发谈之变色的问题。 “什么?我的页面刚上线就白屏了,是报错了,还是兼容性问题,还是性能问题,多刷新几次就好了,用户网络不行吧。” 简单来说,白屏就是用户打开前端页面什么有没有。 这是一个很重要的质量指标。 那么我们如何监控页面白屏异常...
继续阅读 »

前言


白屏一直是一个前端开发谈之变色的问题。


“什么?我的页面刚上线就白屏了,是报错了,还是兼容性问题,还是性能问题,多刷新几次就好了,用户网络不行吧。”


简单来说,白屏就是用户打开前端页面什么有没有。


这是一个很重要的质量指标。


那么我们如何监控页面白屏异常呢?


白屏异常检测主要分为两个部分,一个是如何检测,一个是什么时候检测,


检测方案


首先明确一点,页面打开慢,白屏时间长,不等于白屏;页面就是白色图,不等于白屏。


关键节点是否渲染


在当前SPA页面都是挂在根节点之下,通过查看关键dom是否渲染,如查看dom的高度heigt属性是否存在,如果存在,则证明关键dom已经渲染,页面不是白屏,反之,则判断页面是白屏


实现思路


在上面的代码中,我们首先使用querySelectorAll方法选中了具有 .critical-node类名的关键节点。然后,通过checkNodesRendered函数检测这些节点是否已经渲染,如果有任何一个节点的高度为0,即判断为未渲染,将返回false。最后,在页面加载完成后调用checkNodesRendered函数来判断页面状态。


// 获取关键节点
const criticalNodes = Array.from(document.querySelectorAll('.critical-node'));

// 检测节点渲染
function checkNodesRendered() {
let allNodesRendered = true;

for (const node of criticalNodes) {
if (node.offsetHeight === 0 || node.clientHeight === 0) {
allNodesRendered = false;
break;
}
}

return allNodesRendered;
}

// 判断页面状态
if (checkNodesRendered()) {
// 关键节点已经渲染,页面不是白屏
console.log("页面不是白屏");
// 可以进行后续操作
} else {
// 关键节点未渲染,页面是白屏
console.log("页面是白屏");
// 可以进行相应处理
}

// 在页面加载完成后调用检测函数
window.addEventListener('load', checkNodesRendered);

优点


1.简单易懂:代码相对简洁,易于理解和实现。


2.快速检测:代码通过检测关键节点的渲染状态来快速判断页面是否为白屏,方便进行后续处理。


3.可扩展性:示例代码可以根据实际需求进行修改和扩展,例如添加其他检测条件或特定行为。


缺点



  1. 局限性:示例代码仅仅关注关键节点是否渲染,但并不能涵盖所有可能的页面白屏情况。

  2. 不适用于异步加载:如果页面中的关键节点是通过异步加载或延迟加载的方式渲染的,示例代码可能无法正确判断页面状态。

  3. 可能的误判:某些情况下,即使关键节点已经渲染,它们的高度可能仍为0。这可能导致误判,将页面错误地视为白屏。


观察FP/FCP


PerformanceObserver观察FP/FCP指标,出现该指标判断为非白屏


代码实现


const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
entries.forEach(entry => {
if (entry.name === 'first-paint') {
// 处理FP指标
console.log('First Paint:', entry.startTime);
// 进行白屏判断
if (/* 根据需求判断是否为白屏 */) {
console.log('白屏!');
}
} else if (entry.name === 'first-contentful-paint') {
// 处理FCP指标
console.log('First Contentful Paint:', entry.startTime);
// 进行白屏判断
if (/* 根据需求判断是否为白屏 */) {
console.log('白屏!');
}
}
});
});

observer.observe({ entryTypes: ['paint'] });

优点



  1. 通过观察FP和FCP指标,可以精确地确定页面加载过程中是否出现白屏,以及白屏持续的时间。这对于优化网页加载速度和用户体验非常有帮助。

  2. PerformanceObserver提供了一个直接的、标准化的接口来监测性能指标,使开发者能够更方便地收集和分析网页性能数据。


缺点



  1. 依赖于浏览器对PerformanceObserver的支持,在某些旧版本或不常见的浏览器中可能无法正常工作。

  2. 只通过FP和FCP来判断白屏可能不够全面,因为白屏可能涉及其他因素,如网络延迟、脚本执行等。因此,需要结合其他性能指标和实际场景来综合评估页面的加载情况。


基于视窗坐标采集法


基于视窗坐标采集元素,如果所有元素是包裹元素,则判断是白屏


1.页面中间取17个采样点(如下图),利用 elementsFromPoint api 获取该坐标点下的 HTML 元素


2、定义属于容器元素的集合,如 ['html', 'body', '#app', '#root']


3、判断17这个采样点是否在该容器集合中。说白了,就是判断采样点有没有内容;如果没有内容,该点的 dom 元素还是容器元素,若17个采样点都没有内容则算作白屏



代码实现


const samplePoints = [
{ x: 100, y: 100 }, // 示例采样点1
{ x: 200, y: 200 }, // 示例采样点2
// 添加更多采样点...
];

const containerElements = ['html', 'body', '#app', '#root']; // 定义容器元素集合

function hasContentAtSamplePoints() {
for (const point of samplePoints) {
const elements = document.elementsFromPoint(point.x, point.y);
const hasContent = elements.some(element => !isContainerElement(element));
if (!hasContent) {
return false; // 该采样点没有内容
}
}
return true; // 所有采样点都有内容
}

function isContainerElement(element) {
// 判断元素是否属于容器元素集合的逻辑,例如根据元素的标签名或选择器进行判断
return containerElements.includes(element.tagName.toLowerCase()) || containerElements.includes(element.id);
}

// 调用函数判断是否存在白屏状态
const isWhiteScreen = !hasContentAtSamplePoints();

if (isWhiteScreen) {
console.log('白屏状态');
} else {
console.log('非白屏状态');
}

优点



  1. 快速确定白屏状态:通过采样点的方式,可以快速检查页面中是否存在白屏状态,而无需遍历整个页面。

  2. 简单实现:实现起来相对简单,只需要使用 elementsFromPoint API 获取元素并进行判断。


缺点



  1. 采样点数量和位置选择:在示例中,我们选择了固定数量和位置的采样点,但这可能并不能涵盖所有情况。正确选择采样点的数量和位置是必要的,以保证准确性和可靠性。

  2. 容器元素定义的准确性:需要准确定义容器元素集合,以确保正确判断哪些元素属于容器元素。容器元素集合的定义可能会因页面结构变化而需要定期更新维护。

  3. 采样点是否具有代表性:通过采样点判断白屏状态,需要确保采样点能够代表页面的关键区域和内容。如果关键区域未覆盖到,或者采样点无法代表页面的典型情况,可能会导致误判。


图像检测


基于图像像素色值对比方案,白色大于阈值判断为白屏


代码实现


function isWhiteScreen(imageData) {
const threshold = 200;
const pixels = imageData.data;
const length = pixels.length;

for (let i = 0; i < length; i += 4) {
const red = pixels[i];
const green = pixels[i + 1];
const blue = pixels[i + 2];
const alpha = pixels[i + 3];

// 将 RGB 转换为灰度值
const grayscale = (red + green + blue) / 3;

// 如果灰度值低于阈值,则返回 false
if (grayscale < threshold) {
return false;
}
}

// 如果所有像素的灰度值都高于阈值,则返回 true
return true;
}

// 获取页面截图,可以通过其他方式获取 imageData
const imageData = ...;

// 调用函数判断页面是否为白屏
const isWhite = isWhiteScreen(imageData);

if (isWhite) {
console.log("页面出现白屏");
} else {
console.log("页面正常");
}

优点



  1. 具有广泛适用性:该方法可以适用于各种类型的网页和应用程序,不受页面结构和布局的限制。

  2. 准确性较高:通过对页面截图进行像素色值对比,可以较为准确地判断页面是否呈现白色,避免了部分误判的可能性。


缺点



  1. 截图准确性:该方法的准确性依赖于页面截图的质量和准确性。如果截图质量较低或者不准确,则可能导致判断结果不准确。

  2. 阈值选择:选择合适的阈值是关键。过高的阈值可能导致漏判,而过低的阈值可能导致误判。阈值的选择应该根据具体情况和实际测试进行调整。

  3. 页面动态性:对于动态页面或存在异步加载内容的页面,截图时可能无法捕获到完全加载的状态,从而导致判断结果不准确。

  4. 效率问题:对整个页面进行截图并处理像素色值对比可能会消耗较多的计算资源和时间,特别是对于复杂的页面或者移动端设备


检测时机


其实检测方案并不难,难的是什么时候检测。


这里介绍三种方案。


延迟检测


通过设定延迟时间(如5s),在页面加载后的5s后开始检测


代码实现


// 设置延迟时间(单位:毫秒)
const delay = 5000;

// 在延迟时间后执行检测
setTimeout(() => {
// 在这里编写检测的代码,例如调用 isWhiteScreen() 函数进行白屏检测
// 调用函数判断页面是否为白屏
const isWhite = isWhiteScreen();

if (isWhite) {
console.log("页面在加载后的5秒后出现白屏");
} else {
console.log("页面正常");
}
}, delay);

缺点



  1. 固定延迟时间:使用固定的延迟时间可能不适用于所有情况。页面加载时间的变化、网络速度的差异等因素可能导致延迟时间不准确,有可能延迟过长或过短。

  2. 不适用于快速加载的页面:如果您的页面加载速度很快,在延迟时间之内已经完成加载并呈现内容,延迟检测可能会错过白屏状态。

  3. 无法应对动态内容:如果页面内容是动态加载的,延迟检测可能在页面加载完成后立即触发,此时页面尚未呈现完全。


轮询检测


既然延迟检测时间不好定,那我们就去每秒都轮询页面,判断是否白屏。


代码实现


// 设置轮询时间间隔(毫秒)
const pollInterval = 1000;

// 启动轮询检测
function startPolling() {
// 设置一个定期执行的定时器
setInterval(isWhiteScreen, pollInterval);
}

// 页面加载完成后开始轮询检测
window.addEventListener('load', startPolling);

缺点



  1. 资源消耗:频繁的轮询检测可能会增加浏览器的资源消耗,包括 CPU 和内存。这可能对性能产生一定的影响,特别是在较低性能的设备或者页面加载较慢的情况下。

  2. 不准确性:轮询检测往往基于时间间隔来判断页面加载状态,而不是依赖于实际的视觉变化。这可能导致在某些情况下误判页面加载完成,或者延迟较长时间才判断出白屏状态。

  3. 反应迟钝:由于轮询需要等待一定的时间间隔才能进行下一次检测,因此可能会导致对白屏状态的响应有一定的延迟。这对于需要快速捕捉白屏问题的场景可能不太理想。


错误监听


这是一种由果索因的方案


发生白屏的原因无非以下几种



  1. 脚本错误:当页面中的 JavaScript 代码存在错误时,可能导致页面渲染中断,进而出现白屏情况。常见的错误包括语法错误、逻辑错误、资源加载错误等。

  2. 网络问题:如果页面所需的资源(如样式表、脚本、图片等)无法正确加载,或者网络连接不稳定,可能导致页面无法正确渲染,最终呈现为白屏。这种情况下,可能还会出现超时错误或网络请求失败的错误。

  3. HTML结构错误

  4. 样式问题

  5. 见兼容性问题。


其中前两个原因占绝大多数,那么我们去监听以上错误,做白屏处理就好了。


优点:



  1. 简单易实现:通过监听错误事件,可以比较简单地实现白屏检测逻辑。

  2. 可靠性较高:当页面发生未捕获的错误时,通常表明页面加载或解析出现了问题,可能导致白屏情况。


缺点:



  1. 性能开销:错误处理函数可能会对页面性能产生一定的影响,尤其是在页面发生多个错误时。因此,需要注意错误处理逻辑的优化,避免性能问题。


总结


没有最完美的方案,只有最合适的方案。


白屏方案的检测无非就是检测时机+判断方案做排列组合,找到那个投入产出比最合适的方案。


作者:虎妞先生
来源:juejin.cn/post/7302367564838567962
收起阅读 »

手搓微信小程序生日滑动选择😉

web
简单说一下功能点 微信小程序设置用户的生日,直接使用日历有些不太友好,所以选择手搓一个类似某音和某红书差不多的样式。 在实现该功能还是有一些小的注意点的,最主要的就是-->日期选择需要3级联动,因为要获取当前年份有多少月份、以及当前年份的月份有多少天。总...
继续阅读 »

简单说一下功能点


微信小程序设置用户的生日,直接使用日历有些不太友好,所以选择手搓一个类似某音和某红书差不多的样式。


在实现该功能还是有一些小的注意点的,最主要的就是-->日期选择需要3级联动,因为要获取当前年份有多少月份、以及当前年份的月份有多少天。总不能今天是2023年12月3号,但滑动选项里面有明天甚至以后的日期吧。


使用的是VantWeapp组件实现的滑动效果,当然,使用其他组件的一样,结尾附源代码。


功能样式图


日期选择默认的打开样式


image.png


在选择最新日期时候


image.png


除了选择天数不会去重新拉取日期外,当滑动触发年和月的改变,都需要去拉取最新的日期。若拉取的日期的天数或月份不够上一次选择的时候,默认会选择最后一个日期等等小细节吧。


主要代码功能


自己封装的一个时间工具


/**
* 获取有多少年份[默认截至1949]
* @param actYear 截至到多少年份
* @returns 年份数组
*/

export const getYear = (actYear?: number): Array<number> => {
actYear = actYear || 1949;
const date = new Date();
if (actYear >= date.getFullYear()) return [1949];
let yearArr = [];
for (let i = actYear; i <= date.getFullYear(); i++) yearArr.push(i);
return yearArr;
};

/**
* 获取当前年份有多少月份
* @param year 年份
* @returns 月份数组
*/

export const getMonthToYear = (year: number): Array<number> => {
const date = new Date();
const nowYear = date.getFullYear();
if (year > nowYear) return [1];
let monthArr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
if (year == nowYear) {
monthArr = [];
for (let i = 1; i <= date.getMonth() + 1; i++) monthArr.push(i);
}
return monthArr;
};

/**
* 获取当前年的月份有多少天
* @param year 年份
* @param month 月份
* @returns 天数数组
*/

export const getDayToMoYe = (year: number, month: number): Array<number> => {
const date = new Date();
const nowYear = date.getFullYear();
const nowMonth = date.getMonth() + 1;
if (year > nowYear) return [1];
let monthArr = getMonthToYear(year);
if (month > monthArr.length) return [1];
let dayArr = [];
if (year == nowYear && month == nowMonth) {
for (let i = 1; i <= date.getDate(); i++) dayArr.push(i);
return dayArr;
}
for (let i = 1; i <= new Date(year, month, 0).getDate(); i++) dayArr.push(i);
return dayArr;
};

组件代码


特别说明:手动删掉了不重要的代码,请勿直接复制


<template>
<van-popup
class="pd-10"
:show="showDateChoose"
round
position="bottom"
@close="showDateChoose = false">

<view class="mt-10 flex-center-zy pd-zy-15">
<view class="ft-color-hui" @click="showDateChoose = false">取消</view>
<view>选择你的生日</view>
<view class="ft-big-4 ft-color-red" @click="saveDate">保存</view>
</view>
<van-picker
:columns="initDate"
@change="onDateChange"
:visible-item-count="3"
:loading="dateLoding" />

</van-popup>
</template>

<script setup lang="ts">
import { ref, onMounted, reactive } from 'vue';
import FixVue from '@/common/pages/fix_vue/FixVue';
import { getYear, getMonthToYear, getDayToMoYe } from '@/utils/TimeUtil';

//展示日期选择框和日期加载
let showDateChoose = ref(false);
let dateLoding = ref(true);

//打开日期选择
const openDate = () => {
dateLoding.value = true;
showDateChoose.value = true;
//测试数据,后需要修改为动态获取的用户生日,若用户生日没有则给默认值
initDateMethod('2001-5-10');
dateLoding.value = false;
};

//保存日期
let newDate = ref('');
const saveDate = () => {
if (!newDate.value) return;
//与原本日期进行对比若不同,调用修改生日的接口。。。
};

//选择新时间
const onDateChange = (e: any) => {
const { picker, index } = e.detail;
if (index == 2) return (newDate.value = picker.getValues());
const upDate = picker.getIndexes();
const year = initDate.value[0].values[upDate[0]];
const month = initDate.value[1].values[upDate[1]];
const day = initDate.value[2].values[upDate[2]];
initDate.value = [];
const result = initDateMethod(year + '-' + month + '-' + day);
newDate.value = picker.getValues();
setTimeout(() => {
picker.setColumnIndex(0, result[0]);
picker.setColumnIndex(1, result[1]);
picker.setColumnIndex(2, result[2]);
}, 10);
};

//初始化年份
let initDate = ref([]);
const initDateMethod = (date: string) => {
let dateSplit = date.split('-');
const year = getYear();
const month = getMonthToYear(+dateSplit[0]);
const day = getDayToMoYe(+dateSplit[0], +dateSplit[1]);
let yearIndex = year.indexOf(+dateSplit[0]) == -1 ? year.length - 1 : year.indexOf(+dateSplit[0]);
initDate.value.push({
values: year,
defaultIndex: yearIndex
});
let monthIndex =
month.indexOf(+dateSplit[1]) == -1 ? month.length - 1 : month.indexOf(+dateSplit[1]);
initDate.value.push({
values: month,
defaultIndex: monthIndex
});
let dayIndex = day.indexOf(+dateSplit[2]) == -1 ? day.length - 1 : day.indexOf(+dateSplit[2]);
initDate.value.push({
values: day,
defaultIndex: dayIndex
});
return [yearIndex, monthIndex, dayIndex];
};
</script>

//主要的css样式,主要添加日期后面的一些提示字,如年、月、日
<style lang="less" scoped>
::v-deep.van-picker-column__item--selected {
color: black;
}
::v-deep[data-index='0'] {
.van-picker-column__item--selected::after {
content: ' 年';
}
}
::v-deep[data-index='1'] {
.van-picker-column__item--selected::after {
content: ' 月';
}
}
::v-deep[data-index='2'] {
.van-picker-column__item--selected::after {
content: ' 日';
}
}
::v-deep.van-picker {
height: 150px !important;
margin-top: 20px;
}
</style>


结束语


至此功能就完结了,接下来已编写完成仿某信的聊天样式,如自动根据输入框弹起高度修改聊天内容触底,以及动态调整输入框的高度和最大限制等等。若有需求的小伙伴,可以聊聊,我会分享个人的想法以及做法,若需求大会写一篇文章以及源码分享。


作者:你浩
来源:juejin.cn/post/7307587537295851535
收起阅读 »

微信小程序动态生成表单来啦!你再也不需要手写表单了!

web
dc-vant-form 由于我们在小程序上涉及到数据采集业务,需要经常使用表单,微信小程序的表单使用起来非常麻烦,数据和表单是分离的,每个输入框都需要做数据处理才能实现响应式数据,所以我开发了dc-vant-form,针对原生微信小程序+vant组件构建的自...
继续阅读 »

dc-vant-form


由于我们在小程序上涉及到数据采集业务,需要经常使用表单,微信小程序的表单使用起来非常麻烦,数据和表单是分离的,每个输入框都需要做数据处理才能实现响应式数据,所以我开发了dc-vant-form,针对原生微信小程序+vant组件构建的自定义表单,开发者可以通过表单配置项来快速生成表单。


1、🚩解决微信小程序表单双向绑定问题


2、👍解决微信小程序下拉弹框值与表单绑定问题


3、✨配置项自动生成表单


4、🎉表单详情通过配置项控制详情回显


5、🚀操作表单单项数据修改


6、🔥提供9种输入组件覆盖表单的大部分业务场景


说明


1、在使用前需要保证项目中安装了vant


2、在使用表单之前,你需要准备表单渲染的数据,以及当前用作回显的详情数据。


3、该表单提供了9种输入组件,分别为:文本、小数、整数、级联选择器、文本域、数字间隔输入器、标准时间选择器、年月日时间选择器、年月时间选择器。


4、初始化时配置参数必传,表单可传可不传,若只传配置参数,我们会根据配置参数自动生成表单。


5、表单提供编辑回显、单条数据传入回显。


6、通过getInit函数初始化表单,通过submit函数获取表单结果。




开始


npm i dc-vant-form

自定义表单示例:


初始化


在初始化前,需要先定义初始化配置,配置项如下:


key说明
label表单label
module表单绑定的数据key
type表单组件类型,值对应:1文本、2小数、3整数、4级联选择器、5文本域、6时间选择器、7数字间隔输入器
isRequired是否星号校验,值对应:true、false
options表单下拉菜单项,值对应数组对象:[{label: '红色',value: 'red'}]
dateType时间选择器类型,默认标准时间选择器,值对应:datetime标准时间、date年月日、year-month年月

注意点


类型说明
type: 4必须配置options项,你可以给它默认值空数组[]
type: 6必须配置dateType项,你可以选择三种对应值:datetime、date、year-month
type: 7必须配置 beginModule、endModule,分别对应左侧、右侧输入框;type为7不需要配置module项

下面是示例:


"usingComponents": {
"dc-vant-form": "/miniprogram_npm/dc-vant-form/dc-vant-form/index"
}

页面:


<dc-vant-form id="dc-vant-form" />

配置项:


config: [
{
label: '详细地址',
module: 'address',
type: 1,
isRequired: true
},
{
label: '商品类型',
module: 'goodsType',
type: 4,
isRequired: true,
options: [
{
id: 1,
label: '电子产品',
value: 101
},
{
id: 2,
label: '儿童玩具',
value: 102
},
{
id: 3,
label: '服装饰品',
value: 103
}
]
},
{
label: '商品颜色',
module: 'goodsColor',
type: 4,
isRequired: true,
options: [
{
id: 1,
label: '红色',
value: 'red'
},
{
id: 2,
label: '青色',
value: 'cyan'
},
{
id: 3,
label: '绿色',
value: 'green'
}
]
},
{
label: '包装体积',
module: 'packingVolume',
type: 2,
isRequired: false
},
{
label: '商品重量',
module: 'goodsWeight',
type: 2,
isRequired: true
},
{
label: '商品结构',
module: 'goodsStructure',
type: 4,
isRequired: true,
options: [
{
id: 1,
label: '成品',
value: 2230
},
{
id: 2,
label: '组装',
value: 2231
}
]
},
{
label: '商品数量',
module: 'goodsNumber',
type: 3,
isRequired: false
},
{
label: '可购范围',
beginModule: 'beginLimit',
endModule: 'endLimit',
type: 7,
isRequired: false
},
{
label: '联系人',
module: 'contact',
type: 1,
isRequired: false
},
{
label: '创建时间',
module: 'createDate',
type: 6,
dateType: 'date',
isRequired: true
},
{
label: '标准时间',
module: 'createDate2',
type: 6,
dateType: 'datetime',
isRequired: true
},
{
label: '选区年月',
module: 'createDate3',
type: 6,
dateType: 'year-month',
isRequired: true
},
{
label: '备注',
module: 'remark',
type: 5,
isRequired: false
}
]

我们将上面的配置项传入init函数初始化表单


  // 数据初始化
init() {
let dom = this.selectComponent("#dc-vant-form");
dom.getInit(this.data.config)
},

onLoad(options) {
this.init();
},

image-20231118110736510




获取表单数据


我们通过submit函数获取表单数据


  // 提交
sure() {
let dom = this.selectComponent("#dc-vant-form");
console.log(dom.submit());
}

image-20231118112342663


image-20231118112407795




表单回显


在初始化时,可以传入表单详情,我们会根据配置项回显表单数据。


// 表单详情数据
form: {
address: '浙江省杭州市',
goodsType: 101,
goodsColor: 'red',
packingVolume: 10,
goodsWeight: 5,
goodsStructure: 2230,
goodsNumber: 100,
beginLimit: 1,
endLimit: 10,
contact: 'DCodes',
createDate: '2023-01-01',
createDate2: '2023-01-01 20:00:00',
createDate3: '2023-01',
remark: '这是一个动态的文本域'
}

init() {
let { config,form } = this.data;
let dom = this.selectComponent("#dc-vant-form");
dom.getInit(config, form)
},

onLoad(options) {
this.init();
},

image-20231118112138758




单项数据修改


我们提供onAccept函数,用于接收指定表单项的修改


onAccept接收三个参数,依次为:value、key、place


参数说明
value更改的值
key表单中对应的key
place如果是数字间隔修改器,需要传入place,分为两个固定参数:left、right,表示需要修改间隔输入框的左侧和右侧

bandicam 2023-11-16 16-14-16-944 00_00_00-00_00_30~1


// 修改某项
update() {
let dom = this.selectComponent("#dc-vant-form");
// 普通类型
// dom.onAccept('浙江省杭州市', 'address')

// 级联选择器-value为options中的key
// dom.onAccept(103, 'goodsType')

// 数字间隔输入器
// dom.onAccept(1, 'beginLimit', 'left')
// dom.onAccept(3, 'endLimit', 'right')
}



如果觉得该组件不错,欢迎点赞👍、收藏💖、转发✨哦~


作者:DCodes
来源:juejin.cn/post/7302359255331110947
收起阅读 »