注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

我为展开收起功能做了动画,被老板称赞!

web
需求简介 这几天接了个新项目,需要实现下图中左侧边栏的菜单切换。这种功能其实就是一个折叠面板,实现方式多种多样。 实现上面的功能,无非就是一个v-show的事儿,但没有过渡,会显得非常生硬。想添加一些过渡效果, 最简单的就是使用element ui、或者a...
继续阅读 »

需求简介


这几天接了个新项目,需要实现下图中左侧边栏的菜单切换。这种功能其实就是一个折叠面板,实现方式多种多样。



实现上面的功能,无非就是一个v-show的事儿,但没有过渡,会显得非常生硬。想添加一些过渡效果,



最简单的就是使用element ui、或者ant的折叠面板组件了。但可惜的是,我们的项目不能使用任何第三方组件库。



为了做好产品,我还是略施拳脚,实现了一个简单且丝滑的过渡效果:



老板看后,觉得我的细节处理的很好,给我一顿画饼,承诺只要我好好坚持,一定可以等到升职加薪!当然,我胃口小,老板的饼消化不了。我还是分享一下自己在不借助第三方组件的情况下,如何快速的实现这样一个效果。


技术实现方案


业务分析


仔细观察需求,我们可以分析出其实动画主要是两个部分:一级标题的箭头旋转二级标题区域的折叠展开



我们先实现一下基本的html结构:


<template>
<div class="nav-bar-content">

<div class="header-wrap" @click="open = !open">
<span class="text">自动化需求计算条件输如</span>
<span class="arrow">
>
</span>
</div>

<div v-show="open" class="content">
<p>算法及跃变计算条件</p>
<p>空间品质判断条件</p>
<p>需求自动计算条件</p>
<p>通风系统</p>
</div>

</div>

</template>

<script setup>
const open = ref(false);
</script>


上述代码非常简单,点击一级标题时,更改open的值,从而实现二级标题的内容区域展示与隐藏。


箭头旋转动画



实现箭头旋转动画其实非常容易,我们只要在红色面板展开时,给箭头添加一个新的类名,在这个类名中做一些动画处理即可。


<template>
<div class="header-wrap" @click="open = !open">
<span class="text">自动化需求计算条件输如</span>
<span class="arrow flex-be-ce" :class="{ open: open }">
>
</span>
</div>

</template>
<style lang="less" scoped>
.arrow {
width: 16px;
height: 16px;
cursor: pointer;
margin-left: 1px;
transition: transform 0.2s ease;
}
.open {
transform: rotate(90deg);
transition: transform 0.2s ease;
}
</style>


上述的代码通过 CSS 的 transform 属性和动态绑定open类名实现了箭头的旋转效果。



注意:arrow也需要定义过渡效果



折叠区域动画效果


要实现折叠区域的动画效果,大致思路和上面一样。


使用vue的transition组件实现


借助vue的transition组件,我们可以实现折叠区域进入(v-show='true')和消失(v-show='fasle')的动画。一种可行的动画方案就是让面板进入前位置在y轴-100%的位置,进入后处于正常位置。



<template>
<div class="nav-bar-content">

<div class="header-wrap" @click="open = !open">
<span class="text">自动化需求计算条件输如</span>
<span class="arrow" :class="{ open: open }">
>
</span>
</div>

<div class="content-wrap">
<Transition>
<div v-show="open" class="content">
<p>算法及跃变计算条件</p>
<p>空间品质判断条件</p>
<p>需求自动计算条件</p>
<p>通风系统</p>
</div>
</Transition>
</div>

</div>

</template>

<script setup>

const open = ref(false);
</script>


<style lang="less" scoped>
.v-enter-active,
.v-leave-active {
transition: transform 0.5s ease;
}
.v-enter-from,
.v-leave-to {
transform: translateY(-100%);
}
</style>


上述效果有一点瑕疵,就是出现位置把一级标题盖住了,我们稍微修改下


<div class="content-wrap">
<Transition>
<div v-show="open" class="content">
<p>算法及跃变计算条件</p>
<p>空间品质判断条件</p>
<p>需求自动计算条件</p>
<p>通风系统</p>
</div>
</Transition>

</div>

.content-wrap {
overflow: hidden;
}


使用动态类名的方式实现


效果好很多!但这种效果和第三方组件库的效果不太一致,我们以element的折叠面板效果为例:



我们可以发现,它的这种动画,是折叠面板的高度从0逐渐增高的一个过程。所以最简单的就是,如果我们知道折叠面板的高度,一个类名就可以搞定!


<template>
<div class="nav-bar-content">

<div class="header-wrap" @click="open = !open">
<span class="text">自动化需求计算条件输如</span>
<span class="arrow flex-be-ce" :class="{ open: open }">
>
</span>
</div>

<div class="content-wrap" :style="{ height: open ? '300px' : 0 }">
<div class="content">
<p>算法及跃变计算条件</p>
<p>空间品质判断条件</p>
<p>需求自动计算条件</p>
<p>通风系统</p>
</div>
</div>

</div>

</template>

<script setup>
const open = ref(false);
</script>


<style lang="less" scoped>
.content-wrap {
height: 0;
transition: height 0.5s ease;
}
</style>



如果这个折叠面板的内容通过父组件传递,高度是动态的,我们只需要使用js计算这里的高度即可:


<template>
<div class="nav-bar-content">

<div class="header-wrap" @click="open = !open">
<span class="text">自动化需求计算条件输如</span>
<span class="arrow flex-be-ce" :class="{ open: open }">
>
</span>
</div>

<div class="content-wrap" :style="{ height: open ? '300px' : 0 }">
<div class="content" ref="contentRef">
<slot></slot>
</div>
</div>

</div>

</template>

<script setup>
const open = ref(false);
const contentRef = ref();
const height = ref(0);
onMounted(() => {
height.value = contentRef.value.offsetHeight + 'px';
});
</script>


<style lang="less" scoped>
.content-wrap {
height: 0;
transition: height 0.5s ease;
}
</style>


这样,我们就通过几行代码就实现了一个非常简单的折叠面板手风琴效果!



总结


要想实现一个折叠面板的效果,最简单的还是直接使用第三方组件库,但是如果项目不能使用其他组件库的话,手写一个也是非常简单的!也希望大家能在评论区给出更好的实现方式,供大家学习!


作者:石小石Orz
来源:juejin.cn/post/7369029201579278351
收起阅读 »

产品经理:实现一个微信输入框

web
近期在开发AI对话产品的时候为了提升用户体验增强了对话输入框的相关能力,产品初期阶段对话框只是一个单行输入框,导致在文本内容很多的时候体验很不好,所以进行体验升级,类似还原了微信输入框的功能(只是其中的一点点哈🤏)。 初期认为这应该改动不大,就是把input换...
继续阅读 »


近期在开发AI对话产品的时候为了提升用户体验增强了对话输入框的相关能力,产品初期阶段对话框只是一个单行输入框,导致在文本内容很多的时候体验很不好,所以进行体验升级,类似还原了微信输入框的功能(只是其中的一点点哈🤏)。


初期认为这应该改动不大,就是把input换成textarea吧。但是实际开发过程发现并没有这么简单,本文仅作为开发过程的记录,因为是基于uniapp开发,相关实现代码都是基于uniapp


简单分析我们大概需要实现以下几个功能点:



  • 默认单行输入

  • 可多行输入,但有最大行数限制

  • 超过限制行术后内容在内部滚动

  • 支持回车发送内容

  • 支持常见组合键在输入框内换行输入

  • 多行输入时高度自适应 & 页面整体自适应


单行输入


默认单行输入比较简单直接使用input输入框即可,使用textarea的时候目前的实现方式是通过设置行内样式的高度控制,如我们的行内高度是36px,那么就设置其高度为36px。为什么要通过这种方式设置呢?因为要考虑后续多行输入超出最大行数的限制,需要通过高度来控制textarea的最大高度。


<textarea style="{ height: 36px }" />


多行输入


多行输入核心要注意的就是控制元素的高度,因为不能随着用户的输入一直增加高度,我们需要设置一个最大的行数限制,超出限制后就不再增加高度,内容可以继续输入,可以在输入框内上下滚动查看内容。


这里需要借助于uniapp内置在textarea@linechange事件,输入框行数变化时调用并回传高度和行数。如果不使用uniapp则需要对输入文字的长度及当前行高计算出对应的行数,这种情况还需要考虑单行文本没有满一行且换行的情况。


代码如下,在linechange事件中获取到最新的高度设置为textarea的高度,当超出最大的行数限制后则不处理。


linechange(event) {
const { height, lineCount } = event.detail
if (lineCount < maxLine) {
this.textareaHeight = height
}
}

这是正常的输入,还有一种情况是用户直接粘贴内容输入的场景,这种时候不会触发@linechange事件,需要手动处理,根据粘贴文本后的textarea的滚动高度进行计算出对应的行数,如超出限制行数则设置为最大高度,否则就设置为实际的行数所对应的高度。代码如下:


const paddingTop = parseInt(getComputedStyle(textarea).paddingTop);
const paddingBottom = parseInt(getComputedStyle(textarea).paddingBottom);
const textHeight = textarea.scrollHeight - paddingTop - paddingBottom;
const numberOfLines = Math.floor(textHeight / lineHeight);

if (numberOfLines > 1 && this.lineCount === 1) {
const lineCount = numberOfLines < maxLine ? numberOfLines : maxLine
this.textareaHeight = lineCount * lineHeight
}

键盘发送内容


正常我们使用电脑聊天时发送内容都是使用回车键发送内容,使用ctrlshiftalt等和回车键的组合键将输入框的文本进行换行处理。所以接下来要实现的就是对键盘事件的监听,基于事件进行发送内容和内容换行输入处理。


首先是事件的监听,uniapp不支持keydown的事件监听,所以这里使用了原生JS做监听处理,为了避免重复监听,对每次开始监听前先进行移除事件的监听,代码如下:


this.$refs.textarea.$el.removeEventListener('keydown', this.textareaKeydownHandle)
this.$refs.textarea.$el.addEventListener('keydown', this.textareaKeydownHandle)

然后是对textareaKeydownHandle方法的实现,这里需要注意的是组合键对内容换行的处理,需要获取到当前光标的位置,使用textarea.selectionStart可获取,基于光标位置增加一个换行\n的输入即可实现换行,核心代码如下:


const cursorPosition = textarea.selectionStart;
if(
(e.keyCode == 13 && e.ctrlKey) ||
(e.keyCode == 13 && e.metaKey) ||
(e.keyCode == 13 && e.shiftKey) ||
(e.keyCode == 13 && e.altKey)
){
// 换行
this.content = `${this.content.substring(0, cursorPosition)}\n${this.content.substring(cursorPosition)}`
}else if(e.keyCode == 13){
// 发送
this.onSend();
e.preventDefault();
}

高度自适应


当多行输入内容时输入框所占据的高度增加,导致页面实际内容区域的高度减小,如果不进行动态处理会导致实际内容会被遮挡。如下图所示,红色区域就是需要动态处理的高度。



主要需要处理的场景就是输入内容行数变化的时候和用户粘贴文本的时候,这两种情况都会基于当前的可视行数计算输入框的高度,那么内容区域的高度就好计算了,使用整个窗口的高度减去输入框的高度和其他固定的高度如导航高度和底部安全距离高度即是真实内容的高度。


this.contentHeight = this.windowHeight - this.navBarHeight - this.fixedBottomHeight - this.textareaHeight;

最后


到此整个输入框的体验优化核心实现过程就结束了,增加了多行输入,组合键换行输入内容,键盘发送内容,整体内容高度自适应等。整体实现过程的细节功能点还是比较多,有实现过类似需求的同学欢迎留言交流~


看完本文如果觉得有用,记得点个赞支持,收藏起来说不定哪天就用上啦~




作者:南城FE
来源:juejin.cn/post/7267791228872753167
收起阅读 »

前端视角下的鸿蒙开发

web
前言 鸿蒙系统,一个从诞生就一直处于舆论风口浪尖上的系统,从最开始的“套壳”安卓的说法,到去年的不再兼容安卓的NEXT版本的技术预览版发布,对于鸿蒙到底是什么,以及鸿蒙的应用开发的讨论从来没停止过。 这次我们就从一个前端开发的角度来了解一下鸿蒙,学习一下鸿蒙...
继续阅读 »

前言



鸿蒙系统,一个从诞生就一直处于舆论风口浪尖上的系统,从最开始的“套壳”安卓的说法,到去年的不再兼容安卓的NEXT版本的技术预览版发布,对于鸿蒙到底是什么,以及鸿蒙的应用开发的讨论从来没停止过。


这次我们就从一个前端开发的角度来了解一下鸿蒙,学习一下鸿蒙应用的开发。



一、 什么是鸿蒙


在开始之前,先问大家一个问题,大家听说过几种鸿蒙?


其实到目前为止,我们经常听到的鸿蒙系统,总共有三种,分别是:


OpenHarmony,HarmonyOS,以及HarmonyOS NEXT。


1. OpenHarmony


OpenHarmony


OpenHarmony(开源鸿蒙系统),由开放原子开源基金会进行管理。开放原子开源基金会由华为、阿里、腾讯、百度、浪潮、招商银行、360等十家互联网企业共同发起组建。包含了“鸿蒙操作系统”的基础能力,是“纯血”鸿蒙的底座。


这个版本的鸿蒙是开源的,代码仓库的地址在这里:gitee.com/openharmony


从我个人的一些粗浅理解来看,OpenHarmony类似于Android里的AOSP,可以装到各种设备上,比如手表、电视甚至是一些嵌入式设备上,详见可见官网的一些例子


2. HarmonyOS


HarmonyOS


基于 OpenHarmony、AOSP等开源项目,同时加入了自己的HMS(因为被美国限制后无法使用GMS)的商用版本,可以兼容安卓,也可以运行部分OpenHarmony开发的鸿蒙原生应用。


这个也是目前经常被吐槽是“套壳”安卓的系统,截止到目前(2024.04)已经更新到了HarmonyOS 4.2。


3. HarmonyOS NEXT


HarmonyOS NEXT


2023年秋季发布的技术预览版,在当前HarmonyOS的基础上去除了AOSP甚至是JVM,不再兼容安卓,只能运行鸿蒙原生应用,同时对OpenHarmony的能里进行了大量的更新,增加和修改了很多API。


这个也就是所谓的“纯血”鸿蒙系统,可惜的是这个目前我们用不到,需要以公司名义找华为合作开权限,或者个人开发者使用一台Mate60 Pro做专门的开发机。并且目前由于有保密协议,网上也没有太多关于最新API的消息。



NEXT版本文档:developer.huawei.com/consumer/cn…



无法直接访问的NEXT版本的开发文档


据说目前HarmonyOS NEXT使用的API版本已经到了API12,目前官网可以访问的最新文档还是API9,所以接下来的内容也都是基于API9的版本来的。


4. 小结


所以一个粗略的视角来看,OpenHarmony、HarmonyOS以及HarmonyOS NEXT这三者之间的关系是这样的:


三者之间的关系


二、 初识鸿蒙开发


在大概知道了什么是鸿蒙之后,我们先来简单看一下鸿蒙开发的套件。下图是官网所描述的一些开发套件,包括了设计、开发、测试、上架所涉及到的技术和产品。


鸿蒙开发套件


我们这篇文章里主要讨论右下角的三个:ArkTSArkUIArkCompiler


ArkTS&ArkUI


ArkCompiler


三、 关于ArkTS的一些疑惑


作为一个前端开发,最常用的编程语言就是JavaScript或者TypeScript,那么在看到鸿蒙应用开发用到的编程语言是ArkTS之后,我脑子里最先蹦出来的就是下面这几个问题:


1. ArkTS语言的运行时是啥?


既然编程语言是TS(TS的拓展,ArkTS),那么它的运行时是什么呢?是V8?JSC?Hermes?还是其他什么呢?


2. ArkTS还是单线程语言吗?


ArkTS还是和JS一样,是单线程语言吗?


3. 基于TS拓展了什么?


TS是JS的超集,对JS进行了拓展,增加了开发时的类型支持。而ArkTS对对TS又进行了拓展,是TS的超集,那它基于TS拓展了什么内容呢?


下面我们一个一个来看。


1. Question1 - ArkTS语言的运行时


先说结论,ArkTS的运行时不是V8,不是JSC、Hermes,不是目前任何一种JS引擎。ArkTS的运行时是一个自研的运行时,叫做方舟语言运行时(简称方舟运行时)。


方舟运行时


而这个运行时,执行的也不是JS/TS/ArkTS代码,而是执行的字节码和机器码
这是因为方舟运行时是ArkCompiler(方舟编译器)的一部分,对于JS/TS/ArkTS的编译在运行前就进行了(和Hermes有点像,下面会讲到)。


方舟开发框架示意图


我们来简单了解一下ArkCompiler,从官网的描述可以看到,ArkCompiler关注的重点主要有三个方面:



  • AOT 编译模式

  • LiteActor 轻量化并发

  • 源码安全


AOT 编译模式


首先是编译模式,我们知道,目前编程语言大多以下几方式运行:



  • 机器码AOT编译


    在程序运行之前进行AST生成和代码编译,编译为机器码,在运行的时候无需编译,直接运行,比如C语言。


  • 中间产物AOT编译


    在程序运行前进行AST生成并进行编译,但不是编译为机器码,而是编译为中间产物,之后在运行时将字节码解释为机器码再执行。比如Hermes或Java编译为字节码,之后运行时由Hermes引擎或JVM解释执行字节码。


  • 完全的解释执行


    在程序运行前不进行任何编译,在运行时动态地根据源码生成AST,再编译为字节码,最后解释执行字节码。比如没有开启JIT的V8引擎执行JS代码时的流程。


  • 混合的JIT编译


    在通过解释执行字节码时(运行时动态生成或者AOT编译生成),对多次执行的热点代码进行进一步的优化编译,生成机器码,后续执行到这部分逻辑时,直接执行优化后的机器码。比如开启JIT的V8引擎运行JS或者支持JIT的JVM运行class文件。




当然,以上仅考虑生产环境下的运行方式,不考虑部分语言在生产和开发阶段不同的运行方式。比如Dart和Swift,一般是开发阶段通过JIT实时编译快速启动,生产环境下为了性能通过AOT编译。



在V8 JIT出现之前,所有的JS虚拟机所采用的都是采用的完全解释执行的方式,在运行时把源码生成AST语法树,之后生成字节码,然后将字节码解释为机器码执行,这是JS执行速度过慢的主要原因之一。


而这么做有以下两个方面的原因:



  • JS是动态语言,变量类型在运行时可能改变

  • JS主要用于Web应用,Web应用如果提前编译为字节码将导致体积增大很多,对网络资源的消耗会更大


我们一个一个来说。


a. JS变量类型在运行时可能改变

首先我们来看一张图,这张图描述了现在V8引擎的工作流程,目前Chrome和Node里的JS引擎都是这个:


V8现有工作流程


从上面可以看到,V8在拿到JS源码后,会先解析成AST,之后经过Ignition解释器把语法树编译成字节码,然后再解释字节码执行。


于此同时还会收集热点代码,比如代码一共运行了多少次、如何运行的等信息,也就是上面的Feedback的流程。


如果发现一段代码会被重复执行,则监视器会将此段代码标记为热点代码,交给V8的Turbofan编译器对这段字节码进行编译,编译为对应平台(Intel、ARM、MIPS等)的二进制机器码,并执行机器码,也就是图里的Optimize流程。


等后面V8再次执行这段代码,则会跳过解释器,直接运行优化后的机器码,从而提升这段代码的运行效率。


但是我们发现,图里面除了Optimize外,还有一个Deoptimize,反优化,也就是说被优化成机器码的代码逻辑,可能还会被反优化回字节码,这是为什么呢?


其实原因就是上面提到的“JS变量类型在运行时可能改变”,我们来看一个例子:


JS变量类型在运行时可能改变


比如一个add函数,因为JS没有类型信息,底层编译成字节码后伪代码逻辑大概如这张图所示。会判断xy的各种类型,逻辑比较复杂。


在Ignition解释器执行add(1, 2)时,已经知道add函数的两个参数都是整数,那么TurboFan在进一步编译字节码时,就可以假定add函数的参数是整数,这样可以极大地简化生成的汇编代码,不再判断各种类型,伪代码如第三张图里所示。


接下来的add(3, 4)add(5, 6)由于入参也是整数,可以直接执行之前编译的机器码,但是add("7", "8")时,发现并不是整数,这时候就只能将这段逻辑Deoptimize为字节码,然后解释执行字节码。


这就是所谓的Deoptimize,反优化。可以看出,如果我们的JS代码中变量的类型变来变去,是会给V8引擎增加不少麻烦,为了提高性能,我们可以尽量不要去改变变量的类型。


虽然说使用TS可以部分缓解这个问题,但是TS只能约束开发时的类型,运行的时候TS的类型信息是会被丢弃的,也无法约束,V8还是要做上面的一些假定类型的优化,无法一开始就编译为机器码。


TS类型信息运行时被丢弃


可以说TS的类型信息被浪费了,没有给运行时代码特别大的好处。


b. JS编译为字节码将导致体积增大

上面说到JS主要用于Web应用,Web应用如果提前编译为字节码将导致体积增大很多,对网络资源的消耗会更大。那么对于非Web应用,其实是可以做到提前编译为字节码的,比如Hermes引擎。


Hermes作为React Native的运行时,是作为App预装到用户的设备上,除了热更新这种场景外,绝大部分情况下是不需要打开App时动态下载代码资源的,所以体积增大的问题影响不是很大,但是预编译带来的运行时效率提升的好处却比较明显。


所以相对于V8,Hermes去掉了JIT,支持了生成字节码,在构建App的时候,就把JS代码进行了预编译,预编译为了Hermes运行时可以直接处理的字节码,省去了在运行阶段解析AST语法树、编译为字节码的工作。


Hermes对JS编译和执行流程的改进



一句题外话,Hermes去除了对JIT的支持,除了因为JIT会导致JS引擎启动时间变长、内存占用增大外,还有一部分可能的原因是,在iOS上,苹果为了安全考虑,不允许除了Safari和WebView(只有WKWebView支持JIT,UIWebView不支持)之外的第三方应用里直接使用JSC的JIT能力,也不允许第三方JS运行时支持JIT(相关问题)。


甚至V8专门出了一个去掉JIT的JIT-less V8版本来在iOS上集成,Hermes似乎也不太可能完全没考虑到这一点。



c. 取长补短

在讨论了V8的JIT和Hermes的预编译之后,我们再来看看ArkCompiler,截取一段官方博客里的描述


博客描述


还记得上面说的“TS的类型信息被浪费了”吗?TS的类型信息只在开发时有用,在编译阶段就被丢弃了,而ArkCompiler就是利用了这一点,直接在App构建阶段,利用TS的类型信息直接预编译为字节码以及优化机器码。


即在ArkCompiler中,不存在TS->JS的这一步转译,而是直接从TS编译为了字节码和优化机器码(这里存疑,官网文档没有找到很明确的说法,不是特别确定是否有TS->JS的转译。详见评论区,如果有知道的大佬可以在评论区交流一下)。


同时由于鸿蒙应用也是一个App而不是Web应用,所以ArkCompiler和Hermes一样,也是在构建App时就进行了预编译,而不是在运行阶段做这个事情。


ArkCompiler对JS/TS编译和执行流程的改进


简单总结下来,ArkCompiler像Hermes一样支持生成字节码,同时又将V8引擎JIT生成机器码的工作也提前在预编译阶段做了。是比Hermes只生成字节码的AOT更进一步的AOT(同时生成字节码和部分优化后的机器码)。


LiteActor轻量化并发


到这里其实已经可以回答上面讲到的第二个问题了,ArkTS还是单线程语言吗?


答案是:是的,还是单线程语言。但是ArkTS里通过Worker和TaskTool这两种方式支持并发。


同时ArkCompiler对现有的Worker进行了一些优化,直接看官网博客


LiteActor轻量化并发


LiteActor轻量化并发博客描述


这里的Actor是什么呢?Actor是一种并发编程里的线程模型。


线程模型比较常见的就是共享内存模型,多个线程之间共享内存,比如Java里多个线程共享内存数据,需要通过synchronized同步锁之类的来防止数据一致性的问题。


Actor模型是另一种线程模型,“Actor”是处理并发计算的基本单位,每个Actor都有自己的状态,并且可以接收和发送消息。当一个Actor接收到消息时,它可以改变自己的状态,发送消息给其他Actor,或者创建新的Actor。


这种模型可以帮助开发者更好地管理复杂的状态和并发问题,因为每个Actor都是独立的,它们之间不会共享状态,这可以避免很多并发问题。同时,Actor模型也使得代码更易于理解和维护,因为每个Actor都是独立的,它们的行为可以被清晰地定义和隔离。


到这里大家应该已经比较明白了,前端里的Web Worker就是这种线程模型的一种体现,通过Worker来开启不同的线程。


源码安全


按照官网的说法,ArkCompiler会把ArkTS编译为字节码,并且ArkCompiler使用多种混淆技术提供更高强度的混淆与保护,使得HarmonyOS应用包中装载的是多重混淆后的字节码,有效提高了应用代码安全的强度。


源码安全


2. Question2 - ArkTS还是单线程语言吗


这个刚刚已经回答了,还是单线程语言,借用官网的描述:



HarmonyOS应用中每个进程都会有一个主线程,主线程有如下职责:



  1. 执行UI绘制;

  2. 管理主线程的ArkTS引擎实例,使多个UIAbility组件能够运行在其之上;

  3. 管理其他线程(例如Worker线程)的ArkTS引擎实例,例如启动和终止其他线程;

  4. 分发交互事件;

  5. 处理应用代码的回调,包括事件处理和生命周期管理;

  6. 接收Worker线程发送的消息;


除主线程外,还有一类与主线程并行的独立线程Worker,主要用于执行耗时操作,但不可以直接操作UI。Worker线程在主线程中创建,与主线程相互独立。最多可以创建8个Worker。



ArkTS线程模型


3. Question3 - 基于TS拓展了什么


当前,ArkTS在TS的基础上主要扩展了如下能力:



  • 基本语法:ArkTS定义了声明式UI描述、自定义组件和动态扩展UI元素的能力,再配合ArkUI开发框架中的系统组件及其相关的事件方法、属性方法等共同构成了UI开发的主体。

  • 状态管理:ArkTS提供了多维度的状态管理机制。在UI开发框架中,与UI相关联的数据可以在组件内使用,也可以在不同组件层级间传递,比如父子组件之间、爷孙组件之间,还可以在应用全局范围内传递或跨设备传递。另外,从数据的传递形式来看,可分为只读的单向传递和可变更的双向传递。开发者可以灵活地利用这些能力来实现数据和UI的联动。

  • 渲染控制:ArkTS提供了渲染控制的能力。条件渲染可根据应用的不同状态,渲染对应状态下的UI内容。循环渲染可从数据源中迭代获取数据,并在每次迭代过程中创建相应的组件。数据懒加载从数据源中按需迭代数据,并在每次迭代过程中创建相应的组件。


而上面这些,也就是我们接下来要介绍的ArkTS+ArkUI的语法。


四、 ArkTS & ArkUI


首先,在聊ArkUI之前,还有一个问题大家可能比较感兴趣:ArkUI是怎么渲染我们写的UI呢?


答案是自绘,类似于Flutter,使用自己的渲染引擎(应该是发展于Skia),而不是像RN那样将UI转为不同平台上的底层UI。


不管是从官网的描述[1]、[2]来看,还是社区里的讨论来看,ArkUI的渲染无疑是自绘制的,并且ArkUI和Flutter之间的联系很密切:


社区里的一些讨论


1. 基本语法


从前端的角度来看,ArkTS和ArkUI的定位其实就是类似于前端中TS+React+配套状态管理工具(如Redux),可以用TS写声明式UI(有点像写jsx),下面是基本语法:


基本语法



  • 装饰器


    用于装饰类、结构、方法以及变量,并赋予其特殊的含义。


    如上述示例中@Entry、@Component和@State都是装饰器,@Component表示自定义组件,@Entry表示该自定义组件为入口组件,@State表示组件中的状态变量,状态变量变化会触发UI刷新


  • 自定义组件


    可复用的UI单元,可组合其他组件,如上述被@Component装饰的struct Hello


  • UI描述


    以声明式的方式来描述UI的结构,例如build()方法中的代码块


  • 系统组件


    ArkUI框架中默认内置的基础和容器组件,可直接被开发者调用,比如示例中的ColumnTextDividerButton


  • 事件方法


    组件可以通过链式调用设置多个事件的响应逻辑,如跟随在Button后面的onClick()


  • 属性方法


    组件可以通过链式调用配置多项属性,如fontSize()width()height()backgroundColor()



2. 数据驱动UI


作为一个声明式的UI框架,ArkUI和其他众多UI框架(比如React、Vue)一样,都是通过数据来驱动UI变化的,即UI = f(State)。我们这里引用一下官网的描述:



在声明式UI编程框架中,UI是程序状态的运行结果,用户构建了一个UI模型,其中应用的运行时的状态是参数。当参数改变时,UI作为返回结果,也将进行对应的改变。这些运行时的状态变化所带来的UI的重新渲染,在ArkUI中统称为状态管理机制。


自定义组件拥有变量,变量必须被装饰器装饰才可以成为状态变量,状态变量的改变会引起UI的渲染刷新。如果不使用状态变量,UI只能在初始化时渲染,后续将不会再刷新。 下图展示了State和View(UI)之间的关系。



State和UI



View(UI):UI渲染,指将build方法内的UI描述和@Builder装饰的方法内的UI描述映射到界面。
State:状态,指驱动UI更新的数据。用户通过触发组件的事件方法,改变状态数据。状态数据的改变,引起UI的重新渲染。



在ArkUI中,提供了大量的状态管理相关的装饰器,比如@State@Prop@Link等。


ArkTS & ArkUI的状态管理总览


更多细节详见状态管理


3. 渲染控制


在ArkUI中,可以像React那样,通过if elsefor each等进行跳转渲染、列表渲染等,更多细节详见渲染控制



ArkUI通过自定义组件build()函数和@builder装饰器中的声明式UI描述语句构建相应的UI。在声明式描述语句中开发者除了使用系统组件外,还可以使用渲染控制语句来辅助UI的构建,这些渲染控制语句包括控制组件是否显示的条件渲染语句,基于数组数据快速生成组件的循环渲染语句以及针对大数据量场景的数据懒加载语句。



4. 更多语法


语法其实不是我们这篇文章的重点,上面是一些大概的介绍,更多语法可以详见官网,或者我的另外一篇专门讲解语法的笔记《前端视角下的ArkTS语法》(先留个占位符,有时间了补充一下)。


5. ArkTS & ArkUI小结


从前面的内容其实可以看到,ArkUI和RN相似点还挺多的:



  1. 都是使用JS/TS作为语言(ArkTS)

  2. 都有自己的JS引擎/运行时(ArkCompiler,方舟运行时)

  3. 引擎还都支持直接AOT编译成字节码


不同的是RN是将JS声明的UI,转换成iOS、Android原生的组件来渲染,而ArkUI则是采用自绘制的渲染引擎来自绘UI。


从这点来看,鸿蒙更像是Flutter,只不过把开发语言从Dart换成了JS/TS(ArkTS),和Flutter同样是自绘制的渲染引擎。


社区里其实也有类似的思考:其它方向的探索:JS Engine + Flutter RenderPipeLine。而ArkUI则是对这种思路的实现。


感觉这也可以从侧面解释为什么ArkUI的语法和Flutter比较像,应该参考了不少Flutter的实现(比如渲染引擎)。


而华为宣称鸿蒙可以反向兼容Flutter甚至是RN也就没有那么难以理解了,毕竟ArkUI里Flutter和RN的影子确实不少。


另外,除了ArkUI以外,华为还提供了一个跨平台的开发框架ArkUI-X,可以像Flutter那样,跨HarmonyOS、Android、iOS三个平台。


这么看来,ArkTS&ArkUI从开发语言、声明式UI的语法、设计思想来看,不管是前端、iOS、安卓、或者Flutter、RN,鸿蒙应用开发都是比较入门友好的。


五、 其他


1. 包管理工具


HarmonyOS开发中,使用的包管理工具是ohpm,目前看来像是一个借鉴pnpm的三方包管理工具,详见官方文档


另外,鸿蒙也提供了第三方包发布的仓库:ohpm.openharmony.cn


2. 应用程序结构


在鸿蒙系统中,一个应用包含一个或者多个Module,每一个Module都可以独立进行编译和运行。


应用程序结构


发布时,每个Module编译为一个.hap后缀的文件,即HAP。每个HarmonyOS应用可以包含多个.hap文件。


在应用上架到应用市场时,需要把应用包含的所有.hap文件打包为一个.app后缀的文件用于上架。


但是.app包不能直接安装到设备上,只是上架应用市场的单元,安装到设备上的是.hap


打包结构


开发态和打包后视图


鸿蒙应用的整体开发调试与发布部署流程大概是这样的:


开发-调试-发布-部署


HAP可分为Entry和Feature两种类型:



  • Entry类型的HAP:是应用的主模块
    在同一个应用中,同一设备类型只支持一个Entry类型的HAP,通常用于实现应用的入口界面、入口图标、主特性功能等。

  • Feature类型的HAP:是应用的动态特性模块
    一个应用程序包可以包含一个或多个Feature类型的HAP,也可以不包含;Feature类型的HAP通常用于实现应用的特性功能,可按需下载安装


而设计成多hap,主要是有3个目标:



  1. 为了解耦应用的各个模块,比如一个支付类型的App,Entry类型的hap可以是首页主界面,上面的扫一扫、消息、理财等可以的feature类型的HAP

  2. 方便开发者将多HAP合理地组合并部署到不同的设备上,比如有三个HAP,Entry、Feature1和Feature2,其中A类型的设备只能部署Entry和Feature1。B类型的设备只能部署Entry和Feature2

  3. 方便应用资源共享,减少程序包大小。多个HAP都需要用到的资源文件可以放到单独的HAP中



多说一句:从这些描述来看,给我的感觉是每个.hap有点类似于前端项目中Mono-repo仓库中的一个package,各个package之间有一定的依赖,同时每个package可以独立发布。



另外,HarmonyOS也支持类似RN热更新的功能,叫做快速修复(quick fix)。


六、 总结


现在再回到最开始那个问题:什么是鸿蒙?从前端视角来看,它是这样一个系统:



  • ArkTS作为应用开发语言

  • 类Flutter、Compose、Swift的声明式UI语法

  • 和React有些相似的数组驱动UI的设计思想

  • ArkCompiler进行字节码和机器码的AOT编译 + 方舟运行时

  • 类似Flutter Skia渲染引擎的自绘制渲染引擎

  • 通过提供一系列ohos.xxx的系统内置包来提供TS访问系统底层的能力(比如网络、媒体、文件、USB等)


所以关于HarmonyOS是不是安卓套壳,个人感觉其实已经比较明了了:以前应该是,但快要发布的HarmonyOS NEXT大概率不再是了。


其他一些讨论


其实在华为宣布了HarmonyOS NEXT不再兼容安卓后,安卓套壳的声音越来越少了,但现在网上另外一种声音越来越多了:




  1. HarmonyOS NEXT是一个大号的小程序底座,上面的应用都是网页应用,应用可以直接右键查看源码,没有安全性可言

  2. HarmonyOS NEXT上的微信小程序就是在小程序里运行小程序

  3. 因为使用的是ArkTS开发,所以的HarmonyOS NEXT上的应用性能必然很差



这种说法往往来自于只知道鸿蒙系统应用开发语言是TS,但是没有去进一步了解的人,而且这种说法还有很多人信。其实只要稍微看下文档,就知道这种说法是完全错误的


首先它的View层不是DOM,而是类似Flutter的自绘制的渲染引擎,不能因为使用了TS就说是网页,就像可以说React Web是网页应用,但不能说React Native是网页应用,同样也不是说Flutter是网页应用。


另外开发语言本身并不能决定最终运行性能,还是要看编译器和运行时的优化。同样是JS,从完全的解释执行(JS->AST->字节码->执行),到开启JIT的V8,性能都会有质的飞跃。从一些编程语言性能测试中可以看到,开启JIT的NodeJs的性能,甚至和Flutter所使用的Dart差不多。


而ArkCompiler是结合了Hermes和V8 JIT的特点,AOT编译为字节码和机器码,所以理论上讲性能应该相当不错。


(当然我也没有实机可以测试,只能根据文档来分析)。


上面这种HarmonyOS NEXT是网页应用的说法还有可能是由于,最早鸿蒙应用支持使用HTML、CSS、JS三件套进行兼容Web的开发,导致了刻板印象。这种开发方式使用的是FA模型,而目前这种方式已经不是鸿蒙主推的开发方式了。


到这里这篇文章就结束了,整体上是站在一个前端开发的视角下来认识和了解鸿蒙开发的,希望能帮助一些像我一样对鸿蒙开发感兴趣的前端开发入门。大家如果感兴趣可以到鸿蒙官网查看更多的了解。


如果感觉对你有帮助,可以点个赞哦~


作者:酥风
来源:juejin.cn/post/7366948087129309220
收起阅读 »

责任链模式最强工具res-chain🚀

web
上面的logo是由ai生成 责任链模式介绍 责任链模式(Chain of Responsibility Pattern)是一种行为型设计模式,它通过把请求的发送者和接收者解耦,将多个对象连接成一个链,并沿着这条链传递请求,直到有一个对象能够处理它为止,从而避...
继续阅读 »
image.png

上面的logo是由ai生成



责任链模式介绍


责任链模式(Chain of Responsibility Pattern)是一种行为型设计模式,它通过把请求的发送者和接收者解耦,将多个对象连接成一个链,并沿着这条链传递请求,直到有一个对象能够处理它为止,从而避免了请求的发送者和接收者之间的直接耦合


在责任链模式中,每个处理者都持有对下一个处理者的引用,即构成一个链表结构。当请求从链头开始流经链上的每个处理者时,如果某个处理者能够处理该请求,就直接处理,否则将请求发送给下一个处理者,直到有一个处理者能够处理为止。


这种方式可以灵活地动态添加或修改请求的处理流程,同时也避免了由于请求类型过多而导致类的爆炸性增长的问题。


看完以上责任链的描述,有没有发现跟Node.js的某些库特别的像,没错,就是koa。什么?没用过koa?那我建议你立马学起来,因为它用起来特别的简单。


下面来一个简单使用koa的例子:


const Koa = require('koa');
const app = new Koa();

app.use(async (ctx, next) => {
if (ctx.request.url === '/') {
ctx.body = 'home';
return;
}

next(); // 执行下面的回调函数
});

app.use(async (ctx, next) => {
if (ctx.request.url === '/hello') {
ctx.body = 'hello world';
return;
}
});

app.listen(3000);

通过node运行上面的代码,在浏览器请求localhost:3000,接口就会返回home,当我们请求localhost:3000/hello,接口就会返回hello world


上面对请求的处理过程就很符合职责链模式的思想,我们可以清楚的知道每个链做的工作,并且链条的顺序流程也很清晰。


有人就会问,只在一个回调里面也能处理呀,比如下面的代码:


app.use(async (ctx, next) => {
if (ctx.request.url === '/') {
ctx.body = 'home';
return;
} else if (ctx.request.url === '/home') {
ctx.body = 'hello world';
return
}
});

是的,上面的代码确实可以实现,但是这就要回到我们使用责任链模式的初衷了:为了逻辑解耦。


责任链解决的问题


我们继续接着聊上一节的问题,使用if确实可以实现相同效果,但是在某些场景中,if并没有职责链那么好用,为什么这么说呢。


我们找一个应用案例举个例子:



假设我们负责一个售卖手机的网站,需求的定义是:经过分别缴纳500元定金和200元定金的两轮预订,现在到了正式购买阶段。公司对于交了定金的用户有一定的优惠政策,规则如下:




  • 缴纳500元定金的用户可以收到100元优惠券;

  • 缴纳200元定金的用户可以收到50元优惠券;

  • 没有缴纳定金的用户进入普通购买模式,没有优惠券。

  • 而且在库存不足的情况下,不一定能保证买得到。


下面开始设计几个字段,解释它们的含义:



  • orderType:表示订单类型,值为1表示500元定金用户,值为2表示200元定金用户,值为3表示普通用户。

  • pay:表示用户是否支付定金,值为布尔值true和false,就算用户下了500元定金的订单,但是如果没有支付定金,那也会降级为普通用户购买模式。

  • stock:表示当前用户普通购买的手机库存数量,已经支付过定金的用户不受限制。


下面我们分别用if和职责链模式来实现:


使用if:


const order = function (orderType, pay, stock) {
if (orderType === 1) {
if (pay === true) {
console.log('500元定金预购,得到100元优惠券')
} else {
if (stock > 0) {
console.log('普通用户购买,无优惠券')
} else {
console.log('手机库存不足')
}
} else if (orderType === 2) {
if (pay === true) {
console.log('200元定金预购,得到50元优惠券')
} else {
if (stock > 0) {
console.log('普通用户购买,无优惠券')
} else {
console.log('手机库存不足')
}
}
} else if (orderType === 3) {
if (stock > 0) {
console.log('普通用户购买,无优惠券')
} else {
console.log('手机库存不足')
}
}
}

order(1, true, 500) // 输出:500元定金预购,得到100元优惠券'

虽然上面的代码也可以实现需求,但是代码实在是难以阅读,维护起来更是困难,如果继续在这个代码上开发,未来肯定会成为一座很大的屎山。


下面我们使用责任链模式来实现:


function printResult(orderType, pay, stock) {
// 这里ResChain类是模拟koa的写法,后面会讲如何实现ResChain
// 请先耐心看完它是如何处理的
const resChain = new ResChain();
// 针对500元定金的情况
resChain.add('order500', (_, next) => {
if (orderType === 1 && pay === true) {
console.log('500元定金预购,拿到100元优惠券');
return;
}
next(); // 这里将会调用order200对应的回调函数
});
// 针对200元定金的情况
resChain.add('order200', (_, next) => {
if (orderType === 2 && pay === true) {
console.log('200元定金预购,拿到50元优惠券');
return;
}
next(); // 这里会调用noOrder对应回调函数
});
// 针对普通用户购买的情况
resChain.add('noOrder', (_, next) => {
if (stock > 0) {
console.log('普通用户购买,无优惠券');
} else {
console.log('手机库存不足');
}
});

resChain.run(); // 开始执行order500对应的回调函数
}

// 测试
printResult(1, true, 500); // 500元定金预购,得到100元优惠券
printResult(1, false, 500); // 普通用户购买,无优惠券
printResult(2, true, 500); // 200元定金预购,得到50元优惠券
printResult(3, false, 500); // 普通用户购买,无优惠券
printResult(3, false, 0); // 手机库存不足

以上的代码经过责任链处理之后特别的清晰,并且减少了大量的if-else嵌套,每个链的职责分,我们可以看出责任链模式存在的优点:



  1. 降低了代码之间的耦合,很好的对每个处理逻辑进行封装。在每个链条内,只需要关注自身的逻辑实现。

  2. 增强了代码的可维护性。我们可以很轻易在原有链条内的任何位置添加新的节点,或者对链条内的节点进行替换或者删除。


责任链还特别的灵活,如果说后面pm找我们加需求,需要加多一个预付定金400,返回80元优惠券,处理起来也是易如反掌,只需要怼回去,只需要在order500下面加多一个节点处理即可:


... 
resChain.add('order500', (_, next) => {
if (orderType === 1 && pay === true) {
console.log('500元定金预购,拿到100元优惠券');
return;
}
next();
})
+ // 加上这一块
+ resChain.add('order400', (_, next) => {
+ if (orderType === 3 && pay === true) {
+ console.log('400元定金预购,拿80元优惠券');
+ return;
+ }
+ next();
+ })

resChain.add('order200', (_, next) => {
if (orderType === 2 && pay === true) {
console.log('200元定金预购,拿到50元优惠券');
return;
}
next();
})
...

就是这么简单。那这个ResChain是如何实现的呢?


封装ResChain


先别急,首先我们来了解一下koa是如何实现:在链节点的回调函数内调用next就可以跳到下一个节点的呢?


话不多说,直接看源码,参考的库是koa-compose,代码也是特别的简洁:


function compose (middleware) {
// 这里传入的middleware是函数数组,例如: [fn1, fn2, fn3, fn4, ...]
if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
// 判断数组里的元素是不是函数类型
for (const fn of middleware) {
if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
}

return function (context, next) {
// last called middleware #
let index = -1
return dispatch(0);

// 这里利用了函数申明提升的特性
function dispatch (i) {
// 这里是防止重复调用next
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i

// 从middleware中取出回调函数
let fn = middleware[i]
if (i === middleware.length) fn = next

// 如果fn为空了,则结束运行
if (!fn) return Promise.resolve()

try {
// next函数其实就是middleware的下一个函数,执行next就是执行下一个函数
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
} catch (err) {
return Promise.reject(err)
}
}
}
}

看完源代码,我们接着来实现ResChain类,首先整理一下应该要有的方法:



  • add方法。可以添加回调函数,并按添加的顺序执行。

  • run方法。开始按顺序执行责任链。


add方法执行的时候,把回调函数按顺序push进一个数组中。


export class ResChain {

/**
* 按顺序存放链的key
*/

keyOrder = [];
/**
* key对应的函数
*/

key2FnMap = new Map();
/**
* 每个节点都可以拿到的对象
*/

ctx = {}
constructor(ctx) {
this.ctx = ctx;
}

// 这里用key来标识当前callback的唯一性,后面重复添加可以区分。
add(key, callback) {
if (this.key2FnMap.has(key)) {
throw new Error(`Chain ${key} already exists`);
}

this.keyOrder.push(key);
this.key2FnMap.set(key, callback);
return this;
}

async run() {
let index = -1;
const dispatch = (i) => {
if (i <= index) {
return Promise.reject(new Error('next() called multiple times'));
}

index = i;
const fn = this.key2FnMap.get(this.keyOrder[i]);
if (!fn) {
return Promise.resolve(void 0);
}

return fn(this.ctx, dispatch.bind(null, i + 1));
};

return dispatch(0);
}
}

add方法的第一个参数key可以用来判断是否已经添加过相同的回调。


有人会说,koa的中间件是异步函数的,你这个行不行?


当然可以,接下来看个异步的例子:


const resChain = new ResChain();

resChain.add('async1', async (_, next) => {
console.log('async1');
await next();
});


resChain.add('async2', async (_, next) => {
console.log('async2')
// 这里可以执行一些异步处理函数
await new Promise((resolve, reject) => {
setTimeOut(() => {
resolve();
}, 1000)
});

await next();
});


resChain.add('key3', async (_, next) => {
console.log('key3');
await next();
});


// 执行责任链
await resChain.run();

console.log('finished');

// 先输出 async1 async2 然后停顿了1秒钟之后,才输出async3 finished


🚧 需要注意:如果是异步模式,则链上的每个回调函数必须要 await next(),因为next函数代表下一个环的异步函数。



koa的中间件方式简直一毛一样。


有人可能还注意到了,ResChain实例化的时候可以传入对象,比如下面的代码:


const resChain = new ResChain({ interrupt: false });

传入对象具体有什么用法呢?可以用来获取一些在链中处理好的数据,来实现发送者和处理者的解耦。可能比较抽象,我们来举个例子。


比如需要进行数据校验的场景,如果不通过,则中断提交:


const ctx = {
// 表单项
model: {
name: '',
phone: '',
},
// 错误提示
error: '',
// 是否中断
interrupt: false,
}
const resChain = new ResChain(ctx);

resChain.add('校验name', (ctx, next) => {
const { name = '' } = ctx;
if (name === '') {
ctx.error = '请填写name';
ctx.interrupt = true;
return;
}

next();
})

resChain.add('校验phone', (ctx, next) => {
const { phone = '' } = ctx;
if (phone === '') {
ctx.error = '请填写手机号';
ctx.interrupt = true;
return;
}

next();
})

// 执行责任链
resChain.run();

// 如果需要中断,则提示
if (resChain.ctx.interrupt) {
alert(resChain.ctx.error);
return;
}

如果是使用if来实现:


const ctx = {
// 表单项
model: {
name: '',
phone: '',
},
// 错误提示
error: '',
// 是否中断
interrupt: false,
}

if(ctx.model.name === '') {
ctx.error = '请填写用户名';
ctx.interrupt = true;
}

if (!ctx.interrupt && ctx.model.phone === '') {
ctx.error = '请填写手机号';
ctx.interrupt = true;
}

// 如果需要中断,则提示
if (resChain.ctx.interrupt) {
alert(resChain.ctx.error);
return;
}

可以发现,对phone的判断逻辑,就要先判断interrupt是否为false才能继续,而且如果下面还有其他的字段校验,那必须都走一遍if。


这也是责任链的一个优势,可以在某个环节按自己的想法停止,不用继续走后面的节点。


目前我已经把这个工具上传到npm了,如果想要在自己的项目中使用,直接安装:
res-chain即可使用:


npm install res-chain

# 或者
# yarn add res-chain

引入:


import { ResChain } from 'res-chain';
// CommonJS方式的引入也是支持的
// const { ResChain } = 'res-chain';

const resChain = new ResChain();

resChain.add('key1', (_, next) => {
console.log('key1');
next();
});

resChain.add('key2', (_, next) => {
console.log('key2');
// 这里没有调用next,则不会执行key3
});

resChain.add('key3', (_, next) => {
console.log('key3');
next();
});

// 执行职责链
resChain.run(); // => 将会按顺序输出 key1 key2

芜湖起飞🚀。


有了这个工具函数,我们就可以视场景去优化项目中的一大坨if-else嵌套,或者直接使用它来实现一些业务中比较复杂的逻辑。


起源


这个工具诞生的过程还挺巧合的,某一天周六我在公司加班赶需求,发现需要在一堆旧逻辑if-else中添加新的逻辑,强迫症的我实在是无法忍受在💩山上继续堆💩。。。


我陷入沉思,用什么方式去优化呢?看了网上责任链模式的实现,感觉还是不够优雅。


无意中翻到了之前用koa写的项目,突然灵光乍现💡,koa的中间件不就是一个很棒的实践。调用next就能够往下一个节点走,不调用的话就可以终止。


于是立即动工,三下五除二就完成了。我还推荐给部门的其他前端小伙伴,他们也在一些需求的复杂逻辑中有运用。


总结



过去无意学到的某个知识,或者某个概念,在未来也许会发挥作用,你只需要做的就是等待。



没有koa这么棒的库,估计也不会有这工具了,所以还是得感谢它的作者如此聪明。😁


如果你也喜欢这个工具,欢迎去github里给个🌟,感谢。


如果有什么更好的建议,在底下留言,一起探讨。


工具链接


res-chain


参考



作者:Johnhom
来源:juejin.cn/post/7368662916151377959
收起阅读 »

你没见过的【只读模式】,被我玩出花了

web
前言 不是标题党,不是标题党,不是标题党,重要的话说三遍!大家常见的【只读模式】,下面简称 readonly,可能最常用的是在 表单场景中,除了正常的表单场景,你还会想象到它可以应用在我们中后台场景的 编辑表格、描述列表、查询表格 吗?先看看效果吧 ~ 表单场...
继续阅读 »

前言


不是标题党,不是标题党,不是标题党,重要的话说三遍!大家常见的【只读模式】,下面简称 readonly,可能最常用的是在 表单场景中,除了正常的表单场景,你还会想象到它可以应用在我们中后台场景的 编辑表格描述列表查询表格 吗?先看看效果吧 ~


表单场景


form-readonly.gif


表单列表场景


form-list-readonly.gif


描述列表场景


description-readonly.gif


查询表格场景


table-readonly.gif


编辑表格场景


edit-table-readonly.gif


上面看到的所有效果,背后都有 readonly 的存在



  1. 表单场景示例中表单列表场景示例中 使用 readonly,在实际业务中可能会应用到 编辑详情

  2. 描述列表场景示例中 使用 readonly,在实际业务中可能会应用到 单独的详情页 页面中

  3. 查询表格场景示例中 使用 readonly,在实际业务中应用很广泛,比如常见的日期,后端可能会返回字符串、空、时间戳,就不需要用户单独处理了 (挺麻烦的,不是吗)

  4. 编辑表格场景示例中 使用 readonly,在做一些类似 行编辑单元格编辑 功能中常用


下面就以 实现思路 + 伪代码 的方式和大家分享 readonly 的玩法


以 Date 组件为例


我们这里说的 Date 就是单纯的日期组件,不包含 pickermonth(月份)quarter(季度) 等,我们先思考一下,如何让 日期组件 可以在多处公用(查询表格、表单、编辑表格、描述列表)


多处公用


我们可以将 Date 组件进行封装,变成 ProDate,我们在 ProDate 中扩展一个属性为 readonly,在扩展一个插槽 readonly,方便用户自定义,以下为伪代码


<script lang="tsx">
import { DatePicker, TypographyText } from 'ant-design-vue'

export default defineComponent({
name: 'ProDate',
inheritAttrs: false,
props: {
readonly:{
type: Boolean,
default:false
}
},
slots: {
readonly: { rawValue: any }
},
setup(props, { slots, expose }) {
const getReadonlyText = computed(() => {
const value = toValue(dateValue)
return getDateText(value, {
format: toValue(valueFormat),
defaultValue: toValue(emptyText),
})
})

return {
readonly,
getReadonlyText,
}
},
render() {
const {
readonly,
getReadonlyText,
} = this

if (readonly)
return $slots.readonly?.({ rawValue: xxx }) ?? getReadonlyText

return <DatePicker {...xxx} v-slots={...xxx} />
},
})
</script>


上面的伪代码中,我们扩展了 readonly 属性和 readonly 插槽,我们 readonly 模式下会调用 getDateText 方法返回值,下面代码是 getDateText 的实现


interface GetDateTextOptions {
format: string | ((date: number | string | Dayjs) => any)
defaultValue: any
}

// 工具函数
export function getDateText(date: Dayjs | number | string | undefined | null, options: GetDateTextOptions) {
const {
format,
defaultValue,
} = options

if (isNull(date) || isUndefined(date))
return defaultValue

if (isNumber(date) || isString(date)) {
// 可能为时间戳或者字符串
return isFunction(format)
? format(date)
: dayjs(date).format(format)
}

if (isDayjs(date)) {
return isFunction(format)
? format(date)
: date.format(format)
}

return defaultValue
}

好了,伪代码我们实现完了,现在我们就假设我们的 ProDate 就是加强版的 DatePicker,这样我们就能很方便的集成到各个组件中了


集成到 表单中


因为我们是加强版的 DatePicker,还应该支持原来的 DatePicker 用法,我们上面伪代码没有写出来的,但是如果使用的话,还是如下使用


<template>
<AForm>
<AFormItem>
<ProDate v-model:value="xxxx" />
</AFormItem>
</AForm>

</template>

这样的话,我们如果是只读模式,可以在 ProDate 中增加 readonly 属性或插槽即可,当然,为了方便,我们实际上应该给 Form 组件也扩展一个 readonly 属性,然后 ProDatereadonly 属性的默认值应该是从 Form 中去取,这里实现我就不写出来了,思路的话可以通过在 Formprovide 注入默认值,然后 ProDate 中通过 inject


好了,我们集成到 表单中 就说这么多,实际上还是有很多的细节的,如果大家想看的话,后面再写吧


集成到 描述列表中


描述列表用的是 Descriptions 组件,因为大部分用来做详情页,比较简单,所以这里我将它封装成了 json 方式,用 schemas 属性来描述每一项的内容,大概是以下用法


<ProDescriptions
title="详情页"
:data-source="{time:'2023-01-30'}"
:schemas="[
{
label:'日期',
name:'time',
component:'ProDate'
}
]"

/>

解释一下:


上面的 schemas 中的项可以简单看成如下代码


<DescriptionsItem>
<ProDate
readonly
:value="get(dataSource,'time')"
/>

</DescriptionsItem>

我们在描述组件中应该始终传递 readonly: true,这样渲染出来虽然也是一个文本,但是经过了 ProDate 组件的日期处理,这样就可以很方便的直接展示了,而不用去写一个 render 函数自己去处理


集成到 查询表格中


实际上是和 集成到描述列表中 一样的思路,无非是将 ProDescriptions 组件换成 ProTable 组件,schemas 我们用同一套就可以,伪代码如下


<ProTable
title="详情页"
:data-source="{time:'2023-01-30'}"
:schemas="[
{
label:'日期',
name:'time',
component:'ProDate'
}
]"

/>

当然我们在 ProTable 内部对 schemas 的处理就要在 customRender 函数中去渲染了,内部实现的伪代码如下


<Table 
:columns="[
{
title:'日期',
dataIndex:'time',
customRender:({record}) =>{
return <ProDate
readonly
value={get(record,'time')}
/>
}
}
]"

/>

ProTableProDescriptions 的处理方式是类似的


集成到 编辑表格中


没啥好说的,实际上是和 集成到表单中 一样的思路,伪代码用法如下


<ProForm>
<ProEditTable
:data-source="{time:'2023-01-30'}"
:schemas="[
{
label:'日期',
name:'time',
component:'ProDate'
}
]"

/>

</ProForm>

我们还是复用同一套的 schemas,只不过组件换成了 ProEditTable,不同的是,我们在内部就不能写死 readonly 了,因为可能会 全局切换成编辑或者只读某一行切换成编辑或者只读某个单元格切换成编辑或者只读,所以我们这里应该对每一个单元格都需要定义一个 readonly 的响应式属性,方便切换,具体的实现就不细说了,因为偏题了


结语


好了,我们分享了 只读模式 下不同组件下的表现,而不是简单的在 表单中 为了好看而实现的,下期再见 ~


作者:一名爱小惠的前端
来源:juejin.cn/post/7329691357211361318
收起阅读 »

面试官:能不能给 Promise 增加取消功能和进度通知功能... 我:???

web
扯皮 这段时间闲着没事就去翻翻红宝书,已经看到 Promise 篇了,今天又让我翻到两个陌生的知识点。 因为 Promise 业务场景太多了自我感觉掌握的也比较透彻,之前也跟着 Promise A+ 的规范手写过完整的 Promise,所以这部分内容基本上就大...
继续阅读 »

扯皮


这段时间闲着没事就去翻翻红宝书,已经看到 Promise 篇了,今天又让我翻到两个陌生的知识点。


因为 Promise 业务场景太多了自我感觉掌握的也比较透彻,之前也跟着 Promise A+ 的规范手写过完整的 Promise,所以这部分内容基本上就大致过一遍,直到看见关于 Promise 的取消以及监听进度...🤔


只能说以后要是我当上面试官一定让候选人来谈谈这两个点,然后顺势安利我这篇文章🤣


不过好像目前为止也没见哪个面试官出过...


2024-04-03 更新:这段时间在刷牛客,无意间看到了 25 届佬: 收心檬 的个人主页 - 文章 - 掘金 (juejin.cn) 美团暑期实习的面经,绷不住了🤣


pic.png


原面经链接:美团暑期一面_牛客网 (nowcoder.com)


不知道这位面试官是不是看了我的文章出的题,例子举的都大差不差🤣


我确实标题党了想整个活,没想到大厂面试官真出啊,还是实习生,刁难人有一手的🙃...


正文


取消功能


我们都知道 Promise 的状态是不可逆的,也就是说只能从 pending -> fulfilled 或 pending -> rejected,这一点是毋庸置疑的。


但现在可能会有这样的需求,在状态转换过程当中我们可能不再想让它进行下去了,也就是说让它永远停留至 pending 状态


奇怪了,想要一直停留在 pending,那我不调用 resolve 和 reject 不就行了🤔


 const p = new Promise((resolve, reject) => {
setTimeout(() => {
// handler data, no resolve and reject
}, 1000);
});
console.log(p); // Promise {<pending>} 💡

但注意我们的需求条件,是在状态转换过程中,也就是说必须有调用 resolve 和 reject,只不过中间可能由于某种条件,阻止了这两个调用。


其实这个场景和超时中断有点类似但还是不太一样,我们先利用 Promise.race 来看看:模拟一个发送请求,如果超时则提示超时错误:


const getData = () =>
new Promise((resolve) => {
setTimeout(() => {
console.log("发送网络请求获取数据"); // ❗
resolve("success get Data");
}, 2500);
});

const timer = () =>
new Promise((_, reject) => {
setTimeout(() => {
reject("timeout");
}, 2000);
});

const p = Promise.race([getData(), timer()])
.then((res) => {
console.log("获取数据:", res);
})
.catch((err) => {
console.log("超时: ", err);
});

问题是现在确实能够确认超时了,但 race 的本质是内部会遍历传入的 promise 数组对它们的结果进行判断,那好像并没有实现网络请求的中断哎🤔,即使超时网络请求还会发出:


超时中断.png


而我们想要实现的取消功能是希望不借助 race 等其他方法并且不发送请求。


比如让用户进行控制,一个按钮用来表示发送请求,一个按钮表示取消,来中断 promise 的流程:



当然这里我们不讨论关于请求的取消操作,重点在 Promise 上



取消请求.png


其实按照我们的理解只用 Promise 是不可能实现这样的效果的,因为从一开始接触 Promise 就知道一旦调用了 resolve/reject 就代表着要进行状态转换。不过 取消 这两个字相信一定不会陌生,clearTimeoutclearInterval 嘛。


OK,如果你想到了这一点这个功能就出来了,我们直接先来看红宝书上给出的答案:


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<button id="send">Send</button>
<button id="cancel">Cancel</button>

<script>
class CancelToken {
constructor(cancelFn) {
this.promise = new Promise((resolve, reject) => {
cancelFn(() => {
console.log("delay cancelled");
resolve();
});
});
}
}
const sendButton = document.querySelector("#send");
const cancelButton = document.querySelector("#cancel");

function cancellableDelayedResolve(delay) {
console.log("prepare send request");
return new Promise((resolve, reject) => {
const id = setTimeout(() => {
console.log("ajax get data");
resolve();
}, delay);

const cancelToken = new CancelToken((cancelCallback) =>
cancelButton.addEventListener("click", cancelCallback)
);
cancelToken.promise.then(() => clearTimeout(id));
});
}
sendButton.addEventListener("click", () => cancellableDelayedResolve(1000));
</script>
</body>
</html>

这段代码说实话是有一点绕的,而且个人觉得是有多余的地方,我们一点一点来看:


首先针对于 sendButton 的事件处理函数,这里传入了一个 delay,可以把它理解为取消功能期限,超过期限就要真的发送请求了。我们看该处理函数内部返回了一个 Promise,而 Promise 的 executor 中首先开启了定时器,并且实例化了一个 CancelToken,而在 CancelToken 中才给 cancelButton 添加点击事件。


这里的 CancelToken 就是我觉得最奇怪的地方,可能没有体会到这个封装的技巧,路过的大佬如果有理解的希望能帮忙解释一下。它的内部创建了一个 Promise,绕了一圈后相当于 cancelButton 的点击处理函数是调用这个 Promise 的 resolve,最终是在其 pending -> fuilfilled,即 then 方法里才去取消定时器,那为什么不直接在事件处理函数中取消呢?难道是为了不影响主执行栈的执行所以才将其推到微任务处理🤔?


介于自己没理解,我就按照自己的思路封装个不一样的🤣:


const sendButton = document.querySelector("#send");
const cancelButton = document.querySelector("#cancel");

class CancelPromise {

// delay: 取消功能期限 request:获取数据请求(必须返回 promise)
constructor(delay, request) {
this.req = request;
this.delay = delay;
this.timer = null;
}

delayResolve() {
return new Promise((resolve, reject) => {
console.log("prepare request");
this.timer = setTimeout(() => {
console.log("send request");
this.timer = null;
this.req().then(
(res) => resolve(res),
(err) => reject(err)
);
}, this.delay);
});
}

cancelResolve() {
console.log("cancel promise");
this.timer && clearTimeout(this.timer);
}
}

// 模拟网络请求
function getData() {
return new Promise((resolve) => {
setTimeout(() => {
resolve("this is data");
}, 2000);
});
}

const cp = new CancelPromise(1000, getData);

sendButton.addEventListener("click", () =>
cp.delayResolve().then((res) => {
console.log("拿到数据:", res);
})
);
cancelButton.addEventListener("click", () => cp.cancelResolve());

正常发送请求获取数据:


发送请求.gif


中断 promise:


取消请求.gif


没啥大毛病捏~


进度通知功能


进度通知?那不就是类似发布订阅嘛?还真是,我们来看红宝书针对这块的描述:



执行中的 Promise 可能会有不少离散的“阶段”,在最终解决之前必须依次经过。某些情况下,监控 Promise 的执行进度会很有用



这个需求就比较明确了,我们直接来看红宝书的实现吧,核心思想就是扩展之前的 Promise,为其添加 notify 方法作为监听,并且在 executor 中增加额外的参数来让用户进行通知操作:


class TrackablePromise extends Promise {
constructor(executor) {
const notifyHandlers = [];
super((resolve, reject) => {
return executor(resolve, reject, (status) => {
notifyHandlers.map((handler) => handler(status));
});
});
this.notifyHandlers = notifyHandlers;
}
notify(notifyHandler) {
this.notifyHandlers.push(notifyHandler);
return this;
}
}
let p = new TrackablePromise((resolve, reject, notify) => {
function countdown(x) {
if (x > 0) {
notify(`${20 * x}% remaining`);
setTimeout(() => countdown(x - 1), 1000);
} else {
resolve();
}
}
countdown(5);
});

p.notify((x) => setTimeout(console.log, 0, "progress:", x));
p.then(() => setTimeout(console.log, 0, "completed"));


emm 就是这个例子总感觉不太好,为了演示这种效果还用了递归,大伙们觉得呢?


不好就自己再写一个🤣!不过这次的实现就没有多大问题了,基本功能都具备也没有什么阅读障碍,我们再添加一个稍微带点实际场景的例子吧:



// 模拟数据请求
function getData(timer, value) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(value);
}, timer);
});
}

let p = new TrackablePromise(async (resolve, reject, notify) => {
try {
const res1 = await getData1();
notify("已获取到一阶段数据");
const res2 = await getData2();
notify("已获取到二阶段数据");
const res3 = await getData3();
notify("已获取到三阶段数据");
resolve([res1, res2, res3]);
} catch (error) {
notify("出错!");
reject(error);
}
});

p.notify((x) => console.log(x));
p.then((res) => console.log("Get All Data:", res));


notify获取数据.gif


对味儿了~😀


End


关于取消功能在红宝书上 TC39 委员会也曾准备增加这个特性,但相关提案最终被撤回了。结果 ES6 Promise 被认为是“激进的”:只要 Promise 的逻辑开始执行,就没有办法阻止它执行到完成。


实际上我们学了这么久的 Promise 也默认了这一点,因此这个取消功能反而就不太符合常理,而且十分鸡肋。比如说我们有使用 then 回调接收数据,但因为你点击了取消按钮造成 then 回调不执行,我们知道 Promise 支持链式调用,那如果还有后续操作都将会被中断,这种中断行为 debug 时也十分痛苦,更何况最麻烦的一点是你还需要传入一个 delay 来表示取消的期限,而这个期限到底要设置多少才合适呢...


至于说进度通知功能,仁者见仁智者见智吧...


但不管怎么样两个功能实现的思路都是比较有趣的,而且不太常见,不考虑实用性确实能够成为一道考题,只能说很符合面试官的口味😏


作者:討厭吃香菜
来源:juejin.cn/post/7312349904046735400
收起阅读 »

检测图片是否cmyk

web
引入 最近业务上有要求,要求如果是 Jpeg 格式文件, 前端在上传的时候要求判断一下这个文件是否 CMYK 颜色模式(color mode/ color space)。 这个颜色模式是打印行业需要的。如果不是则禁止上传,并提示用户。 一开始我以为这个应该存储...
继续阅读 »

引入


最近业务上有要求,要求如果是 Jpeg 格式文件, 前端在上传的时候要求判断一下这个文件是否 CMYK 颜色模式(color mode/ color space)。 这个颜色模式是打印行业需要的。如果不是则禁止上传,并提示用户。


一开始我以为这个应该存储在 exif 文件信息中, 去拿一下就好了, 但是简单测试发现两个问题:



  1. 文件是否携带 exif 信息是不确定的, 即便出自设计师导出文件, 有可能也是不携带颜色模式信息的。

  2. 除此之外, 依靠 exif 信息去判断,严格来说,即便携带,也是不准确的, 因为这个信息是可以被人为修改的。


经过一番研究, 我暂时发现可能有两种方式,去达成目的。 但是这篇文章实际不是以解决问题为导向,而是期望尽可能的深入一丢丢。 如果急于找到解决方案, 直接翻到文章底部查看具体 编码实现 即可。


什么是 CMYK 颜色模式?



了解 Photoshop 颜色模式 (adobe.com)



CMYK 是一种颜色模式,它表示四种颜色通道:青色(Cyan)、品红色(Magenta)、黄色(Yellow)和黑色(Key,通常表示黑色)。这种颜色模式主要用于印刷和彩色印刷工作中。


以下是 CMYK 颜色模式中各颜色通道的简要介绍:



  1. 青色 (Cyan): 表示蓝绿色。在印刷中,它用于调整蓝色和绿色的浓度。

  2. 品红色 (Magenta): 表示品红或洋红色。在印刷中,它用于调整红色和蓝色的浓度。

  3. 黄色 (Yellow): 表示黄色。在印刷中,它用于调整红色和绿色的浓度。

  4. 黑色 (Key): 通常表示黑色。在印刷中,黑色是通过使用黑色油墨单独添加的,以增加图像的深度和对比度。在 CMYK 模式中,K 代表 Key,以避免与蓝色 (B) 冲突。


这四个颜色通道可以叠加在一起以创建各种颜色。通过调整每个通道的浓度,可以实现广泛的颜色表达。CMYK 被广泛用于印刷领域,因为它能够准确地模拟很多颜色,并提供了在印刷过程中需要的色彩控制。


与 RGB(红绿蓝)颜色模式不同,CMYK 是一种适合印刷的颜色模式,因为它更好地反映了油墨混合的方式,并考虑到印刷物质上的光的特性


怎么在web判断一个 jpeg/jpg 文件 颜色模式是否 cmyk ?


简单说一下这两种方法, 实际上是同一种原理, 因为对于一张图片而言, 它除了携带有 exif 文件元信息之外, 还有文件头信息。


既然不能通过 exif 元信息去判断, 那么我们可以通过文件头信息去做判断。


首先,简单测试可以发现, 即便一个 cmyk 图片没有 exif 描述元信息标识这是一个 cmyk 颜色模式的图片, 但是 各种设计类软件都能够标记出来。 以ps为例:


image-20231128163932682.png
但是 exif 信息中是没有的:


image-20231128164033843.png


甚至一些解析库,就连最基本携带的元信息都没读出来:



stackblitz.com/edit/exif-j…



image-20231128164214625.png


为什么设计软件可以标记出这个图片是否是 cmyk 颜色模式?


这个问题, 我在网上翻了很久,确实是找不到相关文章有阐述设计软件的原理。 不过Ai 的回答是这样的, 具备一定的参考性:



有朋友找到了记得踢我一脚,这里提前感谢啦~



image-20231128174834089.png


用 ImageMagic 解析图片文件



什么是 imageMagic ?


ImageMagick 主要由大量的命令行程序组成,而不提供像 Adobe Photoshop、GIMP 这样的图形界面。它还为很多程序语言提供了 API 库。


ImageMagick 的功能包括:



  • 查看、编辑位图文件

  • 进行图像格式转换

  • 图像特效处理

  • 图像合成

  • 图像批处理


ImageMagick 广泛用于图像处理、图形设计、Web 开发等领域。它是许多开源软件项目的重要组成部分,例如 GIMP、Inkscape、Linux 系统中的图像工具等。


ImageMagick 的优势包括:



  • 功能强大,支持多种图像格式和图像处理功能

  • 开放源代码,免费使用

  • 、、可移植性强,支持多种操作系统



@jayce: imageMagic 类似于 ffmpeg, 只不过它专注图像处理




我们可以利用 ImageMagic 的 identify 工具命令 去解析图片以查看一些信息:


image-20231128180008082.png


加上 -verbose 选项可以查看更多详细信息:


$ ./magick identify -verbose ./CMYK.jpg
$ ./magick identify -verbose ./RGB.jpg

image-20231129092244504.png


这些数据是什么? 从哪里解析出来的呢? 这个需要看一下 jpeg 文件的一些标准文件结构


ISO/IEC 10918-1 和 ISO/IEC 10918-5


这两个文件都是 JPEG 的标准文档,只是不同的部分,wiki 上对二者描述大致是 5 是 对 1 的很多细节的展开和补充。是补充规范


JPEG File Interchange Format (JFIF) 和 Exif


JFIF(JPEG File Interchange Format)和 EXIF(Exchangeable image file format)是两种与 JPEG 图像相关的标准,但它们具有不同的目的和功能。


JFIF 是一个图片文件格式标准, 它被发布于 10918-5, 是对 10918-1 的细节补充。



  1. JFIF (JPEG File Interchange Format):

    • 目的: JFIF 是一种用于在不同设备和平台之间交换 JPEG 图像的简单格式。它定义了 JPEG 文件的基本结构,以确保文件在不同系统中的一致性和可互操作性。

    • 特点: JFIF 文件通常包含了基本的图像数据,但不一定包含元数据信息。它主要关注图像的编码和解码,而不太关心图像的其他详细信息。JFIF 文件通常使用 .jpg 或 .jpeg 扩展名。



  2. EXIF (Exchangeable image file format):

    • 目的: EXIF 是一种在数字摄影中广泛使用的标准,用于嵌入图像文件中的元数据信息。这些元数据可以包括拍摄日期、相机型号、曝光时间、光圈值等。EXIF 提供了更丰富的信息,有助于记录和存储与拍摄有关的详细数据。

    • 特点: EXIF 数据以二进制格式嵌入在 JPEG 图像中,提供了关于图像和拍摄条件的详细信息。这对于数字相机和其他支持 EXIF 的设备非常有用。EXIF 文件通常使用 .jpg 或 .jpeg 扩展名。




JPEG 文件标准结构语法


jpeg 作为压缩数据结构, 是一个非常复杂的数据组织, 我们的关注点只在关系到我们想要解决的问题。 标准文档 ISO/IEC 10918-1 : 1993(E).中有部分相关说明。


概要:


结构上来说, jpeg 的数据格式由以下几个部分,有序组成: parameters, markers, 以及 entropy-coded data segments, 其中 parameters 和 markers 部分通常被组织到 marker segments, 因为它们都是用字节对齐的代码表, 都是由8位字节的有序序列组成。


Parameters


这部分携带有参数编码关键信息, 是图片成功被解析的关键。


Markers


Markers 标记用于标识压缩数据格式的各种结构部分。大多数标记开始包含一组相关参数的标记段;有些标记是单独存在的。所有标记都被分配了两个字节的代码


例如 SOI : 从 0xFF,0xD8这两个字节开始,标记为图片文件的文件头开始, SOF0: 从 0xFF, 0xD8这两个字节开始,标记了 ”帧“ 的开始,它实际上会携带有图片的一些基本信息, 例如宽高,以及颜色通道等。 这个颜色通道其实也是我们主要需要关注的地方。


下表是完整的标记代码:


image-20231129095415255.png



@refer:


http://www.digicamsoft.com/itu/itu-t81…
http://www.digicamsoft.com/itu/itu-t81…



wiki 上也有相关的帧头部字段说明:


Short nameBytesPayloadNameComments
SOI0xFF, 0xD8noneStart Of Image
SOF00xFF, 0xC0variable sizeStart Of Frame (baseline DCT)Indicates that this is a baseline DCT-based JPEG, and specifies the width, height, number of components, and component subsampling (e.g., 4:2:0).
SOF20xFF, 0xC2variable sizeStart Of Frame (progressive DCT)Indicates that this is a progressive DCT-based JPEG, and specifies the width, height, number of components, and component subsampling (e.g., 4:2:0).
DHT0xFF, 0xC4variable sizeDefine Huffman Table(s)Specifies one or more Huffman tables.
DQT0xFF, 0xDBvariable sizeDefine Quantization Table(s)Specifies one or more quantization tables.
DRI0xFF, 0xDD4 bytesDefine Restart IntervalSpecifies the interval between RSTn markers, in Minimum Coded Units (MCUs). This marker is followed by two bytes indicating the fixed size so it can be treated like any other variable size segment.
SOS0xFF, 0xDAvariable sizeStart Of ScanBegins a top-to-bottom scan of the image. In baseline DCT JPEG images, there is generally a single scan. Progressive DCT JPEG images usually contain multiple scans. This marker specifies which slice of data it will contain, and is immediately followed by entropy-coded data.
RSTn0xFF, 0xDn (n=0..7)noneRestartInserted every r macroblocks, where r is the restart interval set by a DRI marker. Not used if there was no DRI marker. The low three bits of the marker code cycle in value from 0 to 7.
APPn0xFF, 0xEnvariable sizeApplication-specificFor example, an Exif JPEG file uses an APP1 marker to store metadata, laid out in a structure based closely on TIFF.
COM0xFF, 0xFEvariable sizeCommentContains a text comment.
EOI0xFF, 0xD9noneEnd Of Image


Syntax and structure



整体结构


image-20231129111546427.png



@refer: http://www.digicamsoft.com/itu/itu-t81…



Frame Header


image-20231129111645470.png


image-20231129112439294.png


image-20231129112306831.png



@refer: http://www.digicamsoft.com/itu/itu-t81…



SOFn : 帧开始标记标记帧参数的开始。下标n标识编码过程是基线顺序、扩展顺序、渐进还是无损,以及使用哪种熵编码过程。


在其标准文档中,我们有找到 SOFn 的子字段说明,不过在其他地方,倒是看到了不少描述:


特别是在这里 JPEG File Layout and Format


image-20231129141121729.png


可以看到,在 SOFn 这个标记中, 有一个字段为会指明 components 的数量,它代表的实际上颜色通道, 如果是 1,那么就是灰度图, 如果是3,那就是RGB, 如果是 4 就是 CMYK.


到这里我们就知道了, 我们可以读取到这个对应的字节段,从而判断一个图片的颜色模式了。


怎么读取呢?


这篇资料说了明了 Jpeg 文件格式中字节和上述字段的关联关系: Anatomy of a JPEG


注意这篇资料中有一段描述,会影响到我们后续的逻辑判断:


image-20231129142053598.png



就是 SOF0 是必须的,但是可以被 SOFn>=1 替换。 所以在做逻辑判断的时候,后续的也要判断。



我们可以先大概看看一个图片文件的字节流数据长什么样子:(因为所有的字段都是 FF 字节位开头,所以高亮了)


1701248911601.png



以上页面可以在这里访问: jaycethanks.github.io/demos/DemoP…



但样太不便于阅读了, 而且实在太长了。 这里有个网站 here,可以将关键的字节段截取出来:


image-20231129171534152.png


我们主要看这里:


image-20231129171621345.png
可以看到 components 为 4.


如果是 RGB:


image-20231129171722071.png


这里就是 3,


如果是灰度图,components 就会是1


image-20231129172500605.png


EXIF 在哪里?


一个额外的小问题, 我们常见的 exif 元信息存储在哪里呢?


其实上面的 Markers 部分给出的表格中也说明了 ,在 Appn 中可以找到 exif 信息, 但是wiki 上说的是 App0, 在这个解析网站中,我们可以看到:


image-20231201113938640.png


编码实现


有了上述具体的分析, 我们就能有大致思路, 这里直接给出相关代码:



代码参考 github.com/zengming00/node-jpg-is-cmyk




/**
*
@refer https://github.com/zengming00/node-jpg-is-cmyk/blob/master/src/index.ts

*
@refer https://cyber.meme.tips/jpdump/#
*
@refer https://mykb.cipindanci.com/archive/SuperKB/1294/JPEG%20File%20Layout%20and%20Format.htm
*
@refer https://www.ccoderun.ca/programming/2017-01-31_jpeg/
*
* 通过 jpg 文件头判断是否是 CMYK 颜色模式
*
@param { Uint8Array } data
*/

function checkCmyk(data: Uint8Array) {
let pos = 0;
while (pos < data.length) {
pos++;
switch (data[pos]) {
case 0xd8: {// SOI - Start of Image
pos++;
break;
}
case 0xd9: {// EOI - End of Image
pos++;
break;
}
case 0xc0: // SOF0 - Start of Frame, Baseline DCT
case 0xc1: // SOF1 - Start of Frame, Extended Sequential DCT
case 0xc2: { // SOF2 - Start of Frame, Progressive DCT
pos++;
const len = (data[pos] << 8) | data[pos + 1];
const compoNum = data[pos + 7];
if (compoNum === 4) {
// 如果components 数量为4, 那么就认为是 cmyk
return true;
}
pos += len;
break;
}
case 0xc4: { // DHT - Define Huffman Table
pos++;
const len = (data[pos] << 8) | data[pos + 1];
pos += len;
break;
}
case 0xda: { // SOS - Start of Scan
pos++;
const len = (data[pos] << 8) | data[pos + 1];
pos += len;
break;
}
case 0xdb: { // DQT - Define Quantization Table
pos++;
const len = (data[pos] << 8) | data[pos + 1];
pos += len;
break;
}
case 0xdd: { // DRI - Define Restart Interval
pos++;
const len = (data[pos] << 8) | data[pos + 1];
pos += len;
break;
}
case 0xe0: { // APP0 - Application-specific marker
pos++;
const len = (data[pos] << 8) | data[pos + 1];
pos += len;
break;
}
case 0xfe: { // COM - Comment
pos++;
const len = (data[pos] << 8) | data[pos + 1];
pos += len;
break;
}
default: {
pos++;
const len = (data[pos] << 8) | data[pos + 1];
pos += len;
}
}
}
return false;
}

有没有其他的方法?


既然 imageMagic 这么成熟且强大, 我们有办法利用它来做判断吗?


我们可以通过 wasm, 在web中去利用这些工具, 我找到了 WASM-ImageMagick 这个, 但是他的打包好像有些问题 vite 引入的时候会报错,看着好像也没有要修复的意思, issue 里面有老哥自己修改了打包配置进行了修复在这里: image-magick


我们就写的demo测试函数:


import * as Magick from '@xn-sakina/image-magick'

export default function (file: File) {
if (!file) return;
// 创建FileReader对象
var reader = new FileReader();
// 当读取完成时的回调函数
reader.onload = async function (e) {
// 获取ArrayBuffer
var arrayBuffer = e.target?.result as ArrayBuffer;
if (arrayBuffer) {
// 将 ArrayBuffer 转换为 Uint8Array
const sourceBytes = new Uint8Array(arrayBuffer);
const inputFiles = [{ name: 'srcFile.png', content: sourceBytes }]
let commands: string[] = ["identify srcFile.png"]
const { stdout } = await Magick.execute({inputFiles, commands});

// 这里打印一下结果
console.log('stdout:',stdout[0])

}
};
// 读取文件为ArrayBuffer
reader.readAsArrayBuffer(file);
}

import isCmyk from '../utils/isCmyk.ts'
const handleFileChange = (e: Event) => {
const file = (e.target as HTMLInputElement)?.files?.[0]
isCmyk(file) // 这里文件上传调用一下
......

测试几个文件


image-20231130104941592.png


可以看到, Gray, RGB, CMYK 检测都可以正常输出, 说明可以这么干。



但是这个库, 文档写的太乱了。 - -



这个库的大小有 5 m之大 - -, npm 上找了下, 目前相关的包,也没有比这个更小的好像。


作者:sun_zy
来源:jaycethanks.github.io/blog_11ty/posts/Others/%E6%A3%80%E6%B5%8B%E5%9B%BE%E7%89%87%E6%98%AF%E5%90%A6cmyk/
收起阅读 »

需求小能手——拦截浏览器窗口关闭

web
前言 最近碰到一个需求,网页端页面有评价功能,要求用户点击关闭浏览器时强制弹出评价对话框让用户评价。刚听到这个需求我大意了没有闪,以为很简单,没想到很难实现,很多需求果然不能想当然啊,接下来我们来看一下该功能实现的一些思路。 窗口关闭 要想实现该功能最简单的想...
继续阅读 »

前言


最近碰到一个需求,网页端页面有评价功能,要求用户点击关闭浏览器时强制弹出评价对话框让用户评价。刚听到这个需求我大意了没有闪,以为很简单,没想到很难实现,很多需求果然不能想当然啊,接下来我们来看一下该功能实现的一些思路。


窗口关闭


要想实现该功能最简单的想法就是监听浏览器关闭事件,然后阻止默认事件,执行自定义的事件。整个思路核心就是监听事件,搜索一番果然有浏览器关闭触发的事件。


事件



  • onunload:资源被卸载时触发,浏览器窗口关闭时卸载资源就会触发,我们监听一下该事件看能不能阻止窗口关闭。


    window.addEventListener('unload', function (e) {
console.log(e);
e.preventDefault()
});

打开页面再关闭,会发现控制台打印出了e然后就关闭了,看来在onunload事件中并不能阻止窗口关闭,得另找方法,刚好在onunload事件介绍中还链接了一个事件——beforeonunlaod。



  • beforeunload :当窗口关闭或刷新时触发,该事件在onunload之前触发。并且在该事件中可以弹出对话框,询问用户是否确认离开或者重新加载,这不是正是我们想要的效果。根据mdn上的介绍,要想出现弹出对话看需要用preventDefault()事件,并且为了兼容性我们最好再加上以下方法中的一个:

    1.将e.renturenValue赋一个字符串。

    2.事件函数返回一个字符串。
    接下来让我们试一试:


    window.addEventListener('beforeunload', function (e) {
e.preventDefault()
e.returnValue = ''
});

打开关闭未生效,再检查下代码没问题呀,这是因为浏览器本身安全机制导致的,在ie浏览器中没有任何限制,但是在chrome、edge等浏览器中用户必须在短时间操作过页面才能触发。打开页面点几个文字在关闭窗口,这次就能出现弹窗了。

2(W_WV8AVWRT3(4R1HWBRR7.png

当我们点击离开页面就会关闭,点击取消继续停留,上面提到过刷线也能触发,我们再点下刷新。

T456)ZI7MJ2XK1X3M%BE7SN.png

出现的提示有所改变,我们知道浏览器的刷新有好几种方式,我们可以都尝试一下:



  • ctrl+R:本身就是浏览器刷新按钮的快捷键,能够触发。

  • f5:能否触发。

  • 前进、后退:能够触发。

    这三种方式提示内容跟点击刷新按钮一样。回到我们的需求,虽然已经能够阻止窗口关闭,但是刷新依旧能阻止,我们需求是用户关闭,所以我们要区分用户操作是刷新还是关闭。


区分


要想区分就要找到以下两者之间的区别,两者都会执行onbeforeunload与onunload两个事件,不能直接通过某个事件区分。但是两个事件之间的时间差是不同的。刷新时两者时间差在10毫秒左右,而关闭时在3毫秒左右,判断以下时间差就能区分出来。


       var time = null;
window.addEventListener('beforeunload', function (e) {
time = new Date().getTime();
});
window.addEventListener('unload', function (e) {
const nowTime = new Date().getTime();
if (nowTime - time < 5) {
console.log('窗口关闭');
}
});

用此方法就能区分出来,但是此判断是在onunload事件中的,而窗口弹出是在beforeunlaod,这方法只适用于在关闭时执行某个函数,但不能满足我们的需求。除此之外还有一个问题就是刷新默认弹出对话框的内容是不能修改的,所以如果我们想要弹出自定义的对话框是不可能的。经过分析操作能够做到的就是,在用户刷新或关闭时出现系统自带对话框,同时在下方弹出自定义对话框,然后用户点击取消再去操作自定义对话框。


总结


总的来说要想拦截浏览器窗口关闭并且弹出自定义对话框,目前我还没有完美的实现方案,只能带有众多缺陷的去实现。如果我们只是想在关闭窗口前执行函数那就使用时间差区分即可。


作者:躺平使者
来源:juejin.cn/post/7281912738862481448
收起阅读 »

前端实现文件预览img、docx、xlsx、ppt、pdf、md、txt、audio、video

web
前言 最近有接到一个需求,要求前端支持上传制定后缀文件,且支持页面预览,上传简单,那么预览该怎么实现呢,尤其是不同类型的文件预览方案,那么下面就我这个需求的实现,分不同情况来讲解一下👇 具体的预览需求: 预览需要支持的文件类型有: png、jpg、jpeg...
继续阅读 »

前言



最近有接到一个需求,要求前端支持上传制定后缀文件,且支持页面预览,上传简单,那么预览该怎么实现呢,尤其是不同类型的文件预览方案,那么下面就我这个需求的实现,分不同情况来讲解一下👇



具体的预览需求:
预览需要支持的文件类型有: png、jpg、jpeg、docx、xlsx、ppt、pdf、md、txt、audio、video,另外对于不同文档还需要有定位的功能。例如:pdf 定位到页码,txtmarkdown定位到文字并滚动到指定的位置,音视频定位到具体的时间等等。




⚠️ 补充: 我的需求是需要先将文件上传到后台,然后我拿到url地址去展示,对于markdowntxt的文件需要先用fetch获取,其他的展示则直接使用url链接就可以。


不同文件的实现方式不同,下面分类讲解,总共分为以下几类:



  1. 自有标签文件:png、jpg、jpeg、audio、video

  2. 纯文字的文件: markdown & txt

  3. office 类型的文件: docx、xlsx、ppt

  4. embed 引入文件:pdf

  5. iframe:引入外部完整的网站




自有标签文件:png、jpg、jpeg、audio、video



对于图片、音视频的预览,直接使用对应的标签即可,如下:



图片:png、jpg、jpeg


示例代码:


 <img src={url} key={docId} alt={name} width="100%" />;

预览效果如下:


截屏2024-04-30 11.18.01.png


音频:audio


示例代码:


<audio ref={audioRef} controls controlsList="nodownload" style={{ width: '100%' }}>
<track kind="captions" />
<source src={url} type="audio/mpeg" />
</audio>

预览效果如下:


截屏2024-04-30 11.18.45.png


视频:video


示例代码:


<video ref={videoRef} controls muted controlsList="nodownload" style={{ width: '100%' }}>
<track kind="captions" />
<source src={url} type="video/mp4" />
</video>

预览效果如下:


截屏2024-05-13 18.21.13.png


关于音视频的定位的完整代码:


import React, { useRef, useEffect } from 'react';

interface IProps {
type: 'audio' | 'video';
url: string;
timeInSeconds: number;
}

function AudioAndVideo(props: IProps) {
const { type, url, timeInSeconds } = props;
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);

useEffect(() => {
// 音视频定位
const secondsTime = timeInSeconds / 1000;
if (type === 'audio' && audioRef.current) {
audioRef.current.currentTime = secondsTime;
}
if (type === 'video' && videoRef.current) {
videoRef.current.currentTime = secondsTime;
}
}, [type, timeInSeconds]);

return (
<div>
{type === 'audio' ? (
<audio ref={audioRef} controls controlsList="nodownload" style={{ width: '100%' }}>
<track kind="captions" />
<source src={url} type="audio/mpeg" />
</audio>
) : (
<video ref={videoRef} controls muted controlsList="nodownload" style={{ width: '100%' }}>
<track kind="captions" />
<source src={url} type="video/mp4" />
</video>
)}
</div>
);
}

export default AudioAndVideo;



纯文字的文件: markdown & txt



对于markdown、txt类型的文件,如果拿到的是文件的url的话,则无法直接显示,需要请求到内容,再进行展示。



markdown 文件



在展示markdown文件时,需要满足字体高亮、代码高亮、如果有字体高亮,需要滚动到字体所在位置、如果有外部链接,需要新开tab页面再打开。



需要引入两个库:


marked:它的作用是将markdown文本转换(解析)为HTML


highlight: 它允许开发者在网页上高亮显示代码。


字体高亮的代码实现:



高亮的样式,可以在行间样式定义



  const highlightAndMarkFirst = (text: string, highlightText: string) => {
let firstMatchDone = false;
const regex = new RegExp(`(${highlightText})`, 'gi');
return text.replace(regex, (match) => {
if (!firstMatchDone) {
firstMatchDone = true;
return `<span id='first-match' style="color: red;">${match}</span>`;
}
return `<span style="color: red;">${match}</span>`;
});
};

代码高亮的代码实现:



需要借助hljs这个库进行转换



marked.use({
renderer: {
code(code, infostring) {
const validLang = !!(infostring && hljs.getLanguage(infostring));
const highlighted = validLang
? hljs.highlight(code, { language: infostring, ignoreIllegals: true }).value
: code;
return `<pre><code class="hljs ${infostring}">${highlighted}</code></pre>`;
}
},
});

链接跳转新tab页的代码实现:


marked.use({
renderer: {
// 链接跳转
link(href, title, text) {
const isExternal = !href.startsWith('/') && !href.startsWith('#');
if (isExternal) {
return `<a href="${href}" title="${title}" target="_blank" rel="noopener noreferrer">${text}</a>`;
}
return `<a href="${href}" title="${title}">${text}</a>`;
},
},
});

滚动到高亮的位置的代码实现:



需要配合上面的代码高亮的方法



const firstMatchElement = document.getElementById('first-match');
if (firstMatchElement) {
firstMatchElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}

完整的代码如下:



入参的docUrlmarkdown文件的线上url地址,searchText 是需要高亮的内容。



import React, { useEffect, useState, useRef } from 'react';
import { marked } from 'marked';
import hljs from 'highlight.js';

const preStyle = {
width: '100%',
maxHeight: '64vh',
minHeight: '64vh',
overflow: 'auto',
};

// Markdown展示组件
function MarkdownViewer({ docUrl, searchText }: { docUrl: string; searchText: string }) {
const [markdown, setMarkdown] = useState('');
const markdownRef = useRef<HTMLDivElement | null>(null);

const highlightAndMarkFirst = (text: string, highlightText: string) => {
let firstMatchDone = false;
const regex = new RegExp(`(${highlightText})`, 'gi');
return text.replace(regex, (match) => {
if (!firstMatchDone) {
firstMatchDone = true;
return `<span id='first-match' style="color: red;">${match}</span>`;
}
return `<span style="color: red;">${match}</span>`;
});
};

useEffect(() => {
// 如果没有搜索内容,直接加载原始Markdown文本
fetch(docUrl)
.then((response) => response.text())
.then((text) => {
const highlightedText = searchText ? highlightAndMarkFirst(text, searchText) : text;
setMarkdown(highlightedText);
})
.catch((error) => console.error('加载Markdown文件失败:', error));
}, [searchText, docUrl]);

useEffect(() => {
if (markdownRef.current) {
// 支持代码高亮
marked.use({
renderer: {
code(code, infostring) {
const validLang = !!(infostring && hljs.getLanguage(infostring));
const highlighted = validLang
? hljs.highlight(code, { language: infostring, ignoreIllegals: true }).value
: code;
return `<pre><code class="hljs ${infostring}">${highlighted}</code></pre>`;
},
// 链接跳转
link(href, title, text) {
const isExternal = !href.startsWith('/') && !href.startsWith('#');
if (isExternal) {
return `<a href="${href}" title="${title}" target="_blank" rel="noopener noreferrer">${text}</a>`;
}
return `<a href="${href}" title="${title}">${text}</a>`;
},
},
});
const htmlContent = marked.parse(markdown);
markdownRef.current!.innerHTML = htmlContent as string;
// 当markdown更新后,检查是否需要滚动到高亮位置
const firstMatchElement = document.getElementById('first-match');
if (firstMatchElement) {
firstMatchElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}, [markdown]);

return (
<div style={preStyle}>
<div ref={markdownRef} />
</div>

);
}

export default MarkdownViewer;

预览效果如下:


截屏2024-05-13 17.59.04.png


txt 文件预览展示



支持高亮和滚动到指定位置



支持高亮的代码:


  function highlightText(text: string) {
if (!searchText.trim()) return text;
const regex = new RegExp(`(${searchText})`, 'gi');
return text.replace(regex, `<span style="color: red">$1</span>`);
}

完整代码:


import React, { useEffect, useState, useRef } from 'react';
import { preStyle } from './config';

function TextFileViewer({ docurl, searchText }: { docurl: string; searchText: string }) {
const [paragraphs, setParagraphs] = useState<string[]>([]);
const targetRef = useRef<HTMLDivElement | null>(null);

function highlightText(text: string) {
if (!searchText.trim()) return text;
const regex = new RegExp(`(${searchText})`, 'gi');
return text.replace(regex, `<span style="color: red">$1</span>`);
}

useEffect(() => {
fetch(docurl)
.then((response) => response.text())
.then((text) => {
const highlightedText = highlightText(text);
const paras = highlightedText
.split('\n')
.map((para) => para.trim())
.filter((para) => para);
setParagraphs(paras);
})
.catch((error) => {
console.error('加载文本文件出错:', error);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [docurl, searchText]);

useEffect(() => {
// 处理高亮段落的滚动逻辑
const timer = setTimeout(() => {
if (targetRef.current) {
targetRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);

return () => clearTimeout(timer);
}, [paragraphs]);

return (
<div style={preStyle}>
{paragraphs.map((para: string, index: number) => {
const paraKey = para + index;

// 确定这个段落是否包含高亮文本
const isTarget = para.includes(`>${searchText}<`);
return (
<p key={paraKey} ref={isTarget && !targetRef.current ? targetRef : null}>
<div dangerouslySetInnerHTML={{ __html: para }} />
</p>
);
})}
</div>

);
}

export default TextFileViewer;

预览效果如下:


截屏2024-05-13 18.34.27.png




office 类型的文件: docx、xlsx、ppt



docx、xlsx、ppt 文件的预览,用的是office的线上预览链接 + 我们文件的线上url即可。




关于定位:用这种方法我暂时尝试是无法定位页码的,所以定位的功能我采取的是后端将office 文件转成pdf,再进行定位,如果只是纯展示,忽略这个问题即可。



示例代码:


<iframe
src={`https://view.officeapps.live.com/op/view.aspx?src=${url}`}
width="100%"
height="500px"
frameBorder="0"
></iframe>

预览效果如下:


截屏2024-05-07 17.58.45.png




embed 引入文件:pdf



pdf文档预览时,可以采用embed的方式,这个httpsUrl就是你的pdf文档的链接地址



示例代码:


 <embed src={`${httpsUrl}`} style={preStyle} key={`${httpsUrl}`} />;

关于定位,其实是地址上拼接的页码sourcePage,如下:


 const httpsUrl = sourcePage
? `${doc.url}#page=${sourcePage}`
: doc.url;

<embed src={`${httpsUrl}`} style={preStyle} key={`${httpsUrl}`} />;


预览效果如下:


截屏2024-05-07 17.50.07.png




iframe:引入外部完整的网站



除了上面的各种文件,我们还需要预览一些外部的网址,那就要用到iframe的方式



示例代码:


 <iframe
title="网址"
width="100%"
height="100%"
src={doc.url}
allow="microphone;camera;midi;encrypted-media;"/>


预览效果如下:


截屏2024-05-07 17.51.26.png




总结: 到这里我们支持的所有文件都讲述完了,有什么问题,欢迎评论区留言!


作者:玖月晴空
来源:juejin.cn/post/7366432628440924170
收起阅读 »

28个令人惊艳的JavaScript单行代码

web
JavaScript作为一种强大而灵活的脚本语言,充满了许多令人惊艳的特性。本文将带你探索28个令人惊艳的JavaScript单行代码,展示它们的神奇魅力。 1. 阶乘计算 使用递归函数计算给定数字的阶乘。 const factorial = n => ...
继续阅读 »

JavaScript作为一种强大而灵活的脚本语言,充满了许多令人惊艳的特性。本文将带你探索28个令人惊艳的JavaScript单行代码,展示它们的神奇魅力。


1. 阶乘计算


使用递归函数计算给定数字的阶乘。


const factorial = n => n === 0 ? 1 : n * factorial(n - 1);
console.log(factorial(5)); // 输出 120

2. 判断一个变量是否为对象类型


const isObject = variable === Object(variable);

3. 数组去重


利用Set数据结构的特性,去除数组中的重复元素。


const uniqueArray = [...new Set(array)];

4. 数组合并


合并多个数组,创建一个新的数组。


const mergedArray = [].concat(...arrays);

5. 快速最大值和最小值


获取数组中的最大值和最小值。


const max = Math.max(...array);
const min = Math.min(...array);

6. 数组求和


快速计算数组中所有元素的和。


const sum = array.reduce((acc, cur) => acc + cur, 0);

7. 获取随机整数


生成一个指定范围内的随机整数。


const randomInt = Math.floor(Math.random() * (max - min + 1)) + min;

8. 反转字符串


将字符串反转。


const reversedString = string.split('').reverse().join('');

9. 检查回文字符串


判断一个字符串是否为回文字符串。


const isPalindrome = string === string.split('').reverse().join('');

10. 扁平化数组


将多维数组转换为一维数组。


const flattenedArray = array.flat(Infinity);

11. 取随机数组元素


从数组中随机取出一个元素。


const randomElement = array[Math.floor(Math.random() * array.length)];

12. 判断数组元素唯一


检查数组中的元素是否唯一。


const isUnique = array.length === new Set(array).size;

13. 字符串压缩


将字符串中重复的字符进行压缩。


const compressedString = string.replace(/(.)\1+/g, match => match[0] + match.length);

14. 生成斐波那契数列


生成斐波那契数列的前n项。


const fibonacci = Array(n).fill().map((_, i, arr) => i <= 1 ? i : arr[i - 1] + arr[i - 2]);

15. 数组求交集


获取多个数组的交集。


const intersection = arrays.reduce((acc, cur) => acc.filter(value => cur.includes(value)));

16. 验证邮箱格式


检查字符串是否符合邮箱格式。


const isValidEmail = /^[\w-]+(\.[\w-]+)*@([\w-]+\.)+[a-zA-Z]{2,7}$/.test(email);

17. 数组去除假值


移除数组中的所有假值,如falsenull0""undefined


const truthyArray = array.filter(Boolean);

18. 求阶乘


计算一个数的阶乘。


const factorial = n => n <= 1 ? 1 : n * factorial(n - 1);

19. 判断质数


检查一个数是否为质数。


const isPrime = n => ![...Array(n).keys()].slice(2).some(i => n % i === 0);

20. 检查对象是空对象


判断对象是否为空对象。


const isEmptyObject = Object.keys(object).length === 0 && object.constructor === Object;

21. 判断回调函数为真


检查数组中的每个元素是否满足特定条件。


const allTrue = array.every(condition);

22. 检查回调函数为假


检查数组中是否有元素满足特定条件。


const anyFalse = array.some(condition);

23. 数组排序


对数组进行排序。


const sortedArray = array.sort((a, b) => a - b);

24. 日期格式化


将日期对象格式化为指定格式的字符串。


const formattedDate = new Date().toISOString().slice(0, 10);

25. 将字符串转为整数类型


const intValue = +str;

26. 计算数组中元素出现的次数


统计数组中各元素的出现次数。


const countOccurrences = array.reduce((acc, cur) => (acc[cur] ? acc[cur]++ : acc[cur] = 1, acc), {});

27. 交换两个变量的值


[a, b] = [b, a];

28. 利用逗号运算符分隔多个表达式


const result = (expression1, expression2, ..., expressionN);

作者:慕仲卿
来源:juejin.cn/post/7307963529872605218
收起阅读 »

如何快速实现多行文本擦除效果

web
今天来实现一个多行文本擦除的效果,有种经典咏流传节目中表演开始前阅读诗句的一些既视感,在工作中其实也遇到过这样的需求当时是用的其他方法来实现的,现在发现了更简单的一种方法并且里面也涵盖了不少的知识点。 以上就是最终要实现的效果,比较敏感的同学呢应该能看到文本...
继续阅读 »

今天来实现一个多行文本擦除的效果,有种经典咏流传节目中表演开始前阅读诗句的一些既视感,在工作中其实也遇到过这样的需求当时是用的其他方法来实现的,现在发现了更简单的一种方法并且里面也涵盖了不少的知识点。


img1.gif


以上就是最终要实现的效果,比较敏感的同学呢应该能看到文本是由歌词组成的哈哈,没错今天是我偶像发新歌的一天,就用歌词来致敬一下吧!


思路


首先先来捋一下思路,乍一看效果好像只有一段文本,但其实是由两段相同文本组成的。



  1. 两段相同文本组成,这是为了让它们实现重合,第二段文本会覆盖在第一段文本上。

  2. 修改第二段文本背景色为渐变色。

  3. 最后对渐变颜色的背景色添加动画效果。


先来搭建一下结构部分:


<body>
<div class="container">
<p>
失去你以来 万物在摇摆 你指的山海 像玩具一块一块 我是你缔造又提防的AI 如果我存在 是某种伤害
不被你所爱 也不能具象出来 我想拥有你说失去过谁的 那种痛感 失去你以来 万物在摇摆 你指的山海 像玩具一块一块我是你缔造又提防的AI 如果我存在 只对你无害 想做你所爱 再造你要的时代 执行你最初设计我的大概
成为主宰 失去你以来 万物在摇摆 你指的山海 像玩具一块一块 也许我本来 就是种伤害 我终于明白 我根本就不存在 谁不在造物主设置的循环 活去死来
</p>
<p class="eraser">
<span class="text">
失去你以来 万物在摇摆 你指的山海 像玩具一块一块 我是你缔造又提防的AI 如果我存在 是某种伤害
不被你所爱 也不能具象出来 我想拥有你说失去过谁的 那种痛感 失去你以来 万物在摇摆 你指的山海 像玩具一块一块我是你缔造又提防的AI 如果我存在 只对你无害 想做你所爱 再造你要的时代
执行你最初设计我的大概
成为主宰 失去你以来 万物在摇摆 你指的山海 像玩具一块一块 也许我本来 就是种伤害 我终于明白 我根本就不存在 谁不在造物主设置的循环 活去死来
</span>
</p>
</div>
</body>

代码中两段文本都是由p标签包裹,第二段中加入了一个span标签是因为后面修改背景色的时候凸显出行的效果,这个下面加上样式后就看到了。


添加样式:


* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
background: #000;
color: #fff;
}

.container {
width: 60%;
text-indent: 20px;
line-height: 2;
font-size: 18px;
margin: 30px auto;
}

img2.png


现在只需要给第二段增加一个定位效果即可实现文本的覆盖:


* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
background: #000;
color: #fff;
}

.container {
width: 60%;
/* 直接加在父元素中即可对所有块级元素的子元素进行首行缩进 */
text-indent: 20px;
line-height: 2;
font-size: 18px;
margin: 30px auto;
position: relative;
}

.eraser {
position: absolute;
/* 这里等同于top:0 right:0 bottom:0 left:0 */
inset: 0;
/*
这里解释一下inset属性,inset属性用作定位元素的top、right、bottom 、left这些属性的简写
依照的也是上右下左的顺序。
例如:inset:1px 2px 等同于 top:1px right:2px bottom:1px left:2px
*/

}

image.png


那接下来就应该修改背景颜色了。


以上重复代码省略......

.text {
background: #fff;
}

这时候给span标签加上背景颜色后会看到:


image.png


而不是这样的效果,这就是为什么需要加一个span标签的原因了。


image.png


以上重复代码省略......

.text {
background: linear-gradient(to right, #0000 10%, #000 10%);
color:transparent;
}

image.png


下面要调整的就是将渐变里面的百分比变为动态的,我们可以声明一个变量:


以上重复代码省略......

.text {
--p:0%;
background: linear-gradient(to right, #0000 var(--p), #000 calc( var(--p) + 30px)); // 加上30px显示一个默认的渐变区域
color:transparent;
}

image.png


下面就该加上动画效果了,在设置动画时改变--p变量的值为100%


以上重复代码省略......

.text {
--p:0%;
background: linear-gradient(to right, #0000 var(--p), #000 calc( var(--p) + 30px));
color:transparent;
animation: erase 8s linear;
}

@keyframes erase{
to{
--p:100%;
}
}

但是这样写完之后发现并没有出现动画的效果,这是因为css动画中只有数值类的css属性才会生效,这里已经是一个数值了但--p还不是一个属性,所以我们要把他变成一个css属性,可以利用@property规则来帮助我们生成一个-xxx的自定义,它的结构:


@property 属性名称 {
syntax: '<类型>'; // 必须
initial-value: 默认值; // 必须
inherits: false; // 是否可继承 非必须
}

以上重复代码省略......

.text {
--p:0%;
background: linear-gradient(to right, #0000 var(--p), #000 calc( var(--p) + 30px));
color:transparent;
animation: erase 8s linear;
}

@property --p {
syntax: '<percentage>';
initial-value: 0%;
inherits: false;
}

@keyframes erase{
to{
--p:100%;
}
}

到此为止也就实现开头的效果了!!!


作者:孤独的根号_
来源:juejin.cn/post/7333761832472838144
收起阅读 »

超级离谱的前端需求:搜索图片里的文字!!难倒我了!

web
前言 大家好,我是林三心,用最通俗易懂的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心~ 背景 是这样的,我们公司有一个平台,这个平台上面有一个页面,是一个我们公司内部存放一些字幕图片的,图片很多,差不多每一页有100张的样子,类似于下面这样的图片 ...
继续阅读 »

前言


大家好,我是林三心,用最通俗易懂的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心~


背景


是这样的,我们公司有一个平台,这个平台上面有一个页面,是一个我们公司内部存放一些字幕图片的,图片很多,差不多每一页有100张的样子,类似于下面这样的图片



前几天上面大佬们说想要更加方便快捷地找到某一张图片,怎么个快捷法呢?就是通过搜索文字,能搜索到包含这些文字的图片。。。我一想,这需求简直逆天啊!!!!平时只做过搜索文字的,没做过根据文字搜索出图片的。。。。



思路


其实思路很清晰,分析出每一张图片上的文字,并存在对象的keyword中,搜搜的时候去过滤出keyword包含搜索文字的图片即可。


但是难就难在,我要怎么分析出图片上的文字并存起来呢?


tesseract.js


于是我就去网上找找有哪些库可以实现这个功能,你还真别说,还真有!!这个库就是tesseract.js



tesseract.js 是一个可以分析出图片上文字的一个库,我们通过一个小例子来看看他的使用方式


首先需要安装这个库


npm i tesseract.js

接着引入并使用它解析图片文字,它识别后会返回一个 Promise,成功的话会走 then



可以看出他直接能把图片上的结果解析出来!!!真的牛逼!!!有了这个,那我轻轻松松就可以完成上面交代的任务了!!!



实现功能


我们需要解析每一张图片的文字,并存入 keyword属性中,以供过滤筛选



可以看到每一张图片都解析得到keyword



那么搜索效果自然可以完成



搜索中文呢?


上面只能解析英文,可以看到有 eng 这个参数,那怎么才能解析中文呢?只需要改成chi_sim即可




如果你想要中文和英文一起解析,可以这么写eng+chi_sim





作者:Sunshine_Lin
来源:juejin.cn/post/7355554711167369268
收起阅读 »

设计呀,你是真会给前端找事呀!!!

web
背景 设计:我想要的你听明白了吗,你做出来的和我想要的差距很大,你怎么没有一点审美(你个臭男人,你怎么不按我画的做)! 我:啊?这样自适应不是很好吗,适配了大部分机型呀,而且不会有啥显示的兼容性,避免不必要的客户咨询和客户投诉。 设计: 你上一家公司就是因为...
继续阅读 »

背景



  • 设计:我想要的你听明白了吗,你做出来的和我想要的差距很大,你怎么没有一点审美(你个臭男人,你怎么不按我画的做)!

  • :啊?这样自适应不是很好吗,适配了大部分机型呀,而且不会有啥显示的兼容性,避免不必要的客户咨询和客户投诉。

  • 设计: 你上一家公司就是因为有你这样的优秀员工才倒闭的吧?!

  • :啊?ntm和产品是一家的是吗?





我该如何应对


先看我实现的


b0nh2-9h1qy.gif


在看看设计想要的


9e2b0572-aff4-4644-9eeb-33a9ea76265c.gif
总结一下:



  • 1.一个的时候宽度固定,不管屏幕多大都占屏幕的一半。

  • 2.俩个的时候,各占屏幕的一半,当屏幕过小的时候两个并排展示换行。

  • 3.三个的时候,上面俩,下面一个,且宽度要一样。

  • 4.大于三个的时候,以此类推。



有句话叫做什么,乍一看很合理,细想一下,这不是扯淡么。



所以我又和设计进行了亲切的对话



  • :两个的时候你能考虑到小屏的问题,那一个和三个的时候你为啥不考虑,难道你脑袋有泡,在想一个和三个的时候泡刚好堵住了?

  • 设计: 你天天屌不拉几的,我就要这样,这样好看,你懂个毛的设计,你知道什么是美感和人体工学设计,视觉效果拉满吗?

  • :啊?我的姑奶奶耶,你是不是和产品一个学校毕业的,咋就一根筋呢?

  • 产品:ui说的对,我听ui的。汪汪汪(🐶)


当时那个画面就像是,就像是:





而我就像是
1b761c13b4439463a77ac8abf563677d.png


那咋办,写呗,我能咋办?



我月黑风夜,
黑衣傍我身,
潜入尔等房,
打你小屁屁?



代码实现


   class={[
'group-even-number' : this.evenNumber,
'group-odd-number' : this.oddNumber,
'themeSelectBtnBg'
]}
value={this.currentValue}
onInput={(value: any) => {
this.click(value)
}}
>
...


   .themeSelectBtnBg {
display: flex;
&:nth-child(2n - 1) {
margin-left: 0;
margin-right: 10px;
}
&:nth-child(2n) {
margin-left: 0;
margin-right: 0;
}

}
// 奇数的情况,宽度动态计算,将元素挤下去
.group-odd-number {
// 需要减去padding的宽度
width: calc(50% - 7.5px);
}

.group-even-number {
justify-content: space-between;
@media screen and (max-width:360px) {
justify-content: unset;
margin-right: unset;
flex: 1;
flex-wrap: wrap;
}
}

行吧,咱就这样吧




作者:顾昂_
来源:juejin.cn/post/7304268647101939731
收起阅读 »

一个排查了一天的BUG,你在摸鱼🐟吧!

web
站会 在一次日常站会上,组员们轮流分享昨天的工作进展。一个组员提到:“昨天我整天都在排查一个BUG,今天还得继续。” 出于好奇,我问:“是什么BUG让你排查了这么久还没解决呢?” 他解释说:“是关于一个数据选择弹窗的问题。这个弹窗用表格展示数据,并且表格具有选...
继续阅读 »

站会


在一次日常站会上,组员们轮流分享昨天的工作进展。一个组员提到:“昨天我整天都在排查一个BUG,今天还得继续。”


出于好奇,我问:“是什么BUG让你排查了这么久还没解决呢?”


他解释说:“是关于一个数据选择弹窗的问题。这个弹窗用表格展示数据,并且表格具有选择功能。问题在于,编辑这个弹窗时,表格中原本应该显示为已选状态的数据并没有正确显示已选状态。”


我猜测道:“是不是因为表格中数据的主键ID是大数值导致的?”


他回答说:“大数值?我不太确定。”


我有些质疑地问:“那你昨天都是怎么排查的?需要花一整天的时间,难道是在摸鱼吗?”


“没有摸鱼,只是这个BUG真得有点难搞,那个什么是大数值?”


“行吧,姑且信你,我待会给你看看。”


排查


表格使用的是 Ant Design 4.0 提供的 Table 组件。我检查了组件的 rowKey 属性配置,如下所示:


<Table rowKey={record => record.obj_id}></Table>

这表明表格行的 key 是通过数据中的 obj_id 字段来指定的。随后,我进一步查看了服务端返回的数据。


image.png

可以看到一条数据中的 obj_id 字段值为 "898186844400803840",这是一个18位的数值。



在ES6(ECMAScript 2015)之前,JavaScript没有专门的整数类型,所有的数字都被表示为双精度64位浮点数(遵循IEEE 754标准)。这意味着在这种情况下,JavaScript能够安全地表示的整数范围是从253+1-2^{53} + 125312^{53} - 1(即-9,007,199,254,740,991到9,007,199,254,740,991)。可以简单地认为超过16位的数值就是大数值。



JavaScript中很多操作处理大数值时会导致大数值失去精度。比如 Number("898186844400803840")


image.png


可以看到 "898186844400803840""898186844400803800" 的区别在第16位后,从 40 变成 00 这就是大数值失去精度的表现。


在看一下表格的数据展示,如下图所示:


image.png


可以确定的是,从服务端返回的数据到在表格中的渲染过程是没有问题的。那么,可能出现问题的地方还有两个:一是在选择数据后,数据被传递到父组件的过程中;二是父组件将已选数据发送回选择数据组件的过程中。


定位


我检查了他将数据传递给父组件的逻辑代码,发现了一个可疑点。


image.png

在上述代码中,JSON.parse 被用来转换数据中的每个值。在这个转换过程中,如果 item[key] 是以字符串形式出现的数值,并且这个字符串能够被 JSON.parse() 解析为 JSON 中的数值类型,那么 JSON.parse() 将会把它转换为 JavaScript 的 Number 类型。


这种转换过程中可能会出现精度丢失的问题。因为一旦字符串表示的数值的位数超过16位后,在转换为 Number 类型时就无法保证其精度完整无损。


解决


我们通过正则表达式排除了这种情况,如下所示:


newItem[key] = typeof item[key] === 'string' && /^\d{16,}$/.test(item[key]) ? 
item[key] :
JSON.parse(item[key]);

经过修改并重新验证,问题得到了解决,数据选择弹窗现在可以正确展示已选择状态。


image.png


反思


这个表面上不起眼的BUG为何花费了如此长的时间来排查?除了对大数值的概念不甚了解外,还有一个关键原因是对JavaScript中可能导致大数值失去精度的操作缺乏深入理解。


大数值通常由两种表示方式,一个是用数值类型表示,一个是字符串类型表示。


如果用数值类型表示一个大数值,而且你不能直接修改源代码或源数据,这种情况比较棘手,因为一旦 JavaScript 解析器处理这个数值,它可能已经失去了精度。


这种情况通常发生在你从某个源(比如一个API或者外部数据文件)接收到一个数值类型的大数值,如果数据源头不能修改,只能使用第三方库lossless-json、json-bigint来解决。


如果用字符串类型表示一个大数值,在JS中只要有把其转成Number类型的值就会失去精度,不管是显式转换还是隐式转换。


显式转换,比如 Number()parseInt()parseFloat()Math.floorMath.ceilMath.round等等。


隐式转换,比如除了加法外的算术运算符、JSON.parseswitch 语句、sort的回调函数等等。


作者:前端大骆
来源:juejin.cn/post/7348712837849284644
收起阅读 »

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

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

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


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


1.png


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


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


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


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



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

  2. 运行 package.json 中的脚本

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

  4. 内置 WebSocket 客户端

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

  6. 文件模式匹配功能


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



  1. V8 引擎升级至 12.4 版本

  2. Maglev 编译器默认启用

  3. 改进 AbortSignal 的创建性能


接下来开始介绍。


支持通过 require() 导入 ESM


以前,我们认为 CommonJS 与 ESM 是分离的。


例如,在 CommonJS里,我们用并使用 module.exports 导出模块,用 require() 导入模块:


// CommonJS

// math.js
function add(a, b) {
return a + b;
}
module.exports.add = add;

// useMath.js
const math = require('./math');
console.log(math.add(2, 3));

在 ECMAScript Modules (ESM) **** 里,我们使用 export 导出模块,用 import 导入模块:


// ESM

// math.mjs
export function add(a, b) {
return a + b;
}

// useMath.js
import { add } from './math.mjs';
console.log(add(2, 3));

Node 22 支持新的方式——用 require() 导入 ESM:


// Node 22

// math.mjs
export function add(a, b) {
return a + b;
}

// useMath.js
const { add } = require('./mathModule.mjs');
console.log(add(2, 3));

这么设计的原因是为了给大型项目和遗留系统提供一个平滑过渡的方案,因为这类项目难以快速全部迁移到 ESM,通过允许 require() 导入 ESM,开发者就可以逐个模块迁移,而不是一次性对整个项目进行修改。


目前这种写法还是实验性功能,所以使用是有“门槛”的:



  • 启动命令需要添加 -experimental-require-module 参数,如:node --experimental-require-module app.js

  • 模块标记:确保 ESM 模块通过 package.json 中的 "type": "module" 或文件扩展名是 .mjs

  • 完全同步:只有完全同步的ESM才能被 require() 导入,任何含有顶级 await 的ESM都不能使用这种方式加载。


运行package.json中的脚本


假设我们的 package.json 里有一个脚本:


"scripts": {
"test": "jest"
}

在此之前,我们必须依赖 npm 或者 yanr 这样的包管理器来执行命令,比如:npm run test


Node 22 添加了一个新命令行标志 --run,允许直接从命令行执行 package.json 中定义的脚本,可以直接使用 node --run test 这样的命令来运行脚本。


刚开始我还疑惑这是不是脱裤子放屁的行为,因为有 node 的地方一般都有 npm,我要这 node —run 有何用?


后来思考了一下,主要原因应该还是统一运行环境和提升性能。不同的包管理器在处理脚本时可能会有微小的差异,Node 提供一个标准化的方式执行脚本,有助于统一这些行为;而且直接使用 node 执行脚本要比通过 npm 执行脚本更快,因为绕过了 npm 这个中间层。


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


在 19 版本里,Node 引入了 —watch 指令,用于监视文件系统的变动,并自动重启。22 版本开始,这个指令成为稳定功能了。


要启用监视模式,只需要在启动 Node 应用时加上 --watch ****参数。例如:


node --watch app.js

正在用 nodemon 做自动重启的朋友们可以正式转战 --watch 了~


内置 WebSocket 客户端


以前,要用 Node 开发一个 socket 服务,必须使用 ws、socket.io 这样的第三方库来实现。第三方库虽然稳如老狗帮助开发者许多年,但是终究是有点不方便。


Node 22 正式内置了 WebSocket,并且属于稳定功能,不再需要 -experimental-websocket 来启用了。


除此之外,WebScoket 的实现还遵循了浏览器中 WebSocket API 的标准,这意味着在 Node 中使用 WebSocket 的方式将与在 JavaScript 中使用 WebSocket 的方式非常相似,有助于减少学习成本并提高代码的一致性。


用法示例:


const socket = new WebSocket("ws://localhost:8080");

socket.addEventListener("open", (event) => {
socket.send("Hello Server!");
});

增加流(streams)的默认高水位线(High Water Mark)


streams 在 Node 中有举足轻重的作用,读写数据都得要 streams 来完成。而 streams 可以设置 highWaterMark 参数,用于表示缓冲区的大小。highWaterMark 越大,缓冲区越大,占用内存越多,I/O 操作就减少,highWaterMark 越小,其他信息也对应相反。


用法如下:


const fs = require('fs');

const readStream = fs.createReadStream('example-large-file.txt', {
highWaterMark: 1024 * 1024 // 设置高水位线为1MB
});

readStream.on('data', (chunk) => {
console.log(`Received chunk of size: ${chunk.length}`);
});

readStream.on('end', () => {
console.log('End of file has been reached.');
});

虽然 highWaterMark 是可配置的,但通常情况下,我们是使用默认值。在以前的版本里,highWaterMark 的默认值是 16k,Node 22 版本开始,默认值被提升到 64k 了。


文件模式匹配——glob 和 globSync


Node 22 版本在 fs 模块中新增了 globglobSync 函数,它们用于根据指定模式匹配文件路径。


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


glob 函数(异步)


glob 函数是一个异步的函数,它不会阻塞 Node.js 的事件循环。这意味着它在搜索文件时不会停止其他代码的执行。glob 函数的基本用法如下:


const { glob } = require('fs');

glob('**/*.js', (err, files) => {
if (err) {
throw err;
}
console.log(files); // 输出所有匹配的.js文件路径
});

在这个示例中,glob 函数用来查找所有子目录中以 .js 结尾的文件。它接受两个参数:



  • 第一个参数是一个字符串,表示文件匹配模式。

  • 第二个参数是一个回调函数,当文件搜索完成后,这个函数会被调用。如果搜索成功,err 将为 null,而 files 将包含一个包含所有匹配文件路径的数组。


globSync 函数(同步)


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


const { globSync } = require('fs');

const files = globSync('**/*.js');
console.log(files); // 同样输出所有匹配的.js文件路径

这个函数直接返回匹配的文件数组,适用于脚本和简单的应用,其中执行速度不是主要关注点。


使用场景


这两个函数适用于:



  • 自动化构建过程,如自动寻找和处理项目中的 JavaScript 文件。

  • 开发工具和脚本,需要对项目目录中的文件进行批量操作。

  • 任何需要从大量文件中快速筛选出符合特定模式的文件集的应用。


V8 引擎升级至 12.4 版本


从这一节开始,我们了解一下开发者相对无感知的底层更新,第一个就是 V8 引擎升级到 12.4 版本了,有了以下特性升级:



  • WebAssembly 垃圾回收:这一特性将改善 WebAssembly 在内存管理方面的能力。

  • Array.fromAsync:这个新方法允许从异步迭代器创建数组。

  • Set 方法和迭代器帮助程序:提供了更多内建的Set操作和迭代器操作的方法,增强了数据结构的操作性和灵活性。


Maglev 编译器默认启用


Maglev 是 V8 的新编译器,现在在支持的架构上默认启用。它主要针对短生命周期的命令行程序(CLI程序)性能进行优化,通过改进JIT(即时编译)的效率来提升性能。这对开发者编写的工具和脚本将带来明显的速度提升。


改进AbortSignal的创建性能


在这次更新中,Node 提高了 AbortSignal 实例的创建效率。AbortSignal 是用于中断正在进行的操作(如网络请求或任何长时间运行的异步任务)的一种机制。通过提升这一过程的效率,可以加快任何依赖这一功能的应用,如使用 fetch 进行HTTP请求或在测试运行器中处理中断的场景。


AbortSignal 的工作方式是通过 AbortController 实例来管理。AbortController 提供一个 signal 属性和一个 abort() 方法。signal 属性返回一个 AbortSignal 对象,可以传递给任何接受 AbortSignal 的API(如fetch)来监听取消事件。当调用abort()方法时,与该控制器关联的所有操作将被取消。


const controller = new AbortController();
const signal = controller.signal;

fetch(url, { signal })
.then(response => response.json())
.catch(err => {
if (err.name === 'AbortError') {
console.log('Fetch aborted');
} else {
console.error('Fetch error:', err);
}
});

// 取消请求
controller.abort();

总结


最后,我只替 Node 说一句:Node 没有这么容易被 deno 和 bun 打败~


3.jpeg


关于我


全栈工程师,Next.js 开源手艺人,AI降临派。


今年致力于 Next.js 和 Node.js 领域的开源项目开发和知识分享。




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

Vue3 新项目,没必要再用 Pinia 了!

web
最近弄了一个新的 Vue3 项目,页面不多,其中有三四个页面需要共享状态,我几乎条件反射般地安装了 Pinia 来做状态管理。后来一想,我只需要一个仓库,存放几个状态而已,有必要单独接一套 Pinia 吗?其实不需要,我差点忘记了 Vue3...
继续阅读 »

最近弄了一个新的 Vue3 项目,页面不多,其中有三四个页面需要共享状态,我几乎条件反射般地安装了 Pinia 来做状态管理。

后来一想,我只需要一个仓库,存放几个状态而已,有必要单独接一套 Pinia 吗?

其实不需要,我差点忘记了 Vue3 的一个重要特性,那就是 组合式函数

组合式 API 大家都知道,组合式函数可能大家没有特别留意。但是它功能强大,足矣实现全局状态管理。

组合式函数

什么是组合式函数?以下是官网介绍:

在 Vue 应用的概念中,“组合式函数”(Composables) 是一个利用 Vue 的组合式 API 来封装和复用有状态逻辑的函数。

从这段介绍中可以看出,组合式函数要满足两个关键点:

  1. 组合式 API。
  2. 有状态逻辑的函数。

在 Vue 组件中,状态通常定义在组件内部。比如典型的选项式 API,状态定义在组件的 data() 方法下,因此这个状态只能在组件内使用。

Vue3 出现之后,有了组合式 API。但对于大部分人来说,只是定义状态的方式从 data()变成了 ref(),貌似没有多大的区别。

实际上,区别大了去了。

组合式 API 提供的 ref() 等方法,不是只可以在 Vue 组件内使用,而是在任意 JS 文件中都可以使用。

这就意味着,组合式 API 可以将 组件与状态分离,状态可以定义在组件之外,并在组件中使用。当我们使用组合式 API 定义了一个有状态的函数,这就是组合式函数。

因此,组合式函数,完全可以实现全局状态管理。

举个例子:假设将用户信息状态定义在一个组合式函数中,方法如下:

// user.js
import { ref } from 'vue'

export function useUinfo() {
// 用户信息
const user_info = ref(null)
// 修改信息
const setUserInfo = (data) => {
user_info.value = data
}
return { user_info, setUserInfo }
}

代码中的 useUinfo() 就是一个组合式函数,里面使用 ref() 定义了状态,并将状态和方法抛出。

在 Vue3 组件之中,我们就可以导入并使用这个状态:


仔细看组合式函数的使用方法,像不像 React 中的 Hook?完全可以将它看作一个 Hook。

在多个组件中使用上述方法导入状态,跨组件的状态管理方式也就实现了。

模块化的使用方法

组合式函数在多个组件中调用,可能会出现重复创建状态的问题。其实我们可以用模块化的方法,更简单。

将上方 user.js 文件中的组合式函数去掉,改造如下:

import { ref } from 'vue'

// 用户信息
export const user_info = ref(null)
// 修改信息
export const setUserInfo = (data) => {
user_info.value = data
}

这样在组件中使用时,直接导入即可:


经过测试,这种方式是可以的。

使用模块化的方法,也就是一个文件定义一组状态,可以看作是 Pinia 的仓库。这样状态模块化的问题也解决了。

Pinia 中最常用的功能还有 getters,基于某个状态动态计算的另一个状态。在组合式函数中用计算属性完全可以实现。

import { ref, computed } from 'vue'

export const num1 = ref(3)

export const num2 = computed(()=> {
return num1 * num1
}

所以思考一下,对于使用 Vue3 组合式 API 开发的项目,是不是完全可以用组合式函数来替代状态管理(Pinia,Vuex)呢?

当然,以上方案仅适用于组合式 API 开发的普通项目。对于选项式 API 开发的项目,或者需要 SSR,还是乖乖用 Pinia 吧 ~

最重要的是!如果面试官问你:除了 Pinia 和 Vuex 还有没有别的状态管理方案?

你可别说不知道,记住这几个字:组合式函数!


作者:杨成功
来源:juejin.cn/post/7348680291937435682
收起阅读 »

不容错过的秘籍:JavaScript数组的创建和使用详解

在编程的世界里,数据是构建一切的基础。而在JavaScript中,有一种特殊且强大的数据结构,它就是——数组。今天,我们就来一起探索数组的奥秘,从创建到使用,一步步掌握这个重要的工具。一、什么是数组数组(Array)是一种按顺序存储多个值的数据结构。你可以把它...
继续阅读 »

在编程的世界里,数据是构建一切的基础。而在JavaScript中,有一种特殊且强大的数据结构,它就是——数组。

今天,我们就来一起探索数组的奥秘,从创建到使用,一步步掌握这个重要的工具。

一、什么是数组

数组(Array)是一种按顺序存储多个值的数据结构。你可以把它想象成一个盒子,这个盒子可以存放多个物品,而且每个物品都有一个编号,我们可以通过这个编号来找到或者修改这个物品。

在JavaScript中,数组是一种特殊的对象,用于存储和操作多个值。与其他编程语言不同,JavaScript的数组可以同时存储不同类型的值,并且长度是动态的,可以根据需要随时添加或删除元素。

Description

JavaScript数组使用方括号([])来表示,其中的每个元素用逗号分隔。例如,以下是一个包含不同类型元素的数组的示例:

var myArray = [1, "two", true, [3, 4, 5]];

数组中的元素可以通过索引来访问和修改,索引从0开始。例如,要访问数组中的第一个元素,可以使用以下代码:

var firstElement = myArray[0];

JavaScript也提供了一些内置方法来操作数组,如push()、pop()、shift()、unshift()等,用于添加、删除和修改数组中的元素。

二、数组的作用

数组在编程中扮演着非常重要的角色。它可以帮助我们:

  • 存储多个值:我们可以在一个变量中存储多个值,而不需要为每个值创建单独的变量。

  • 操作数据:我们可以对数组中的元素进行添加、删除、修改和查找等操作。

  • 实现各种算法:通过数组,我们可以实现排序、搜索等常见算法。

  • 循环遍历:数组的元素是有序的,可以使用循环结构遍历数组的每个元素,从而对每个元素进行相同或类似的操作。这在处理大量数据时非常有用。

三、创建数组的方法

在JavaScript中,有多种方法可以创建数组,下面列出常见的三种:

1)字面量方式:

这是最常见的创建数组的方式,只需要在一对方括号[]中放入元素即可,如

var arr = [];

2)使用Array构造函数:

通过new Array()也可以创建数组,如

var arr = new Array();

3)使用Array.of()方法:

这个方法可以创建一个具有相同元素的新数组实例,如

var arr = Array.of(1, 2, 3);

四、使用数组的方法

创建了数组后,我们就可以对它进行各种操作了:

1、访问和修改数组元素

要访问和修改数组元素,需要使用数组的索引。数组的索引从0开始,依次递增。要访问数组元素,可以使用以下语法:

console.log(arr[0]); // 输出第一个元素
arr[1] = 4; // 修改第二个元素的值

2、向数组末尾添加元素

要向数组的末尾添加一个元素,可以使用数组的push()方法。该方法 会在数组的末尾添加指定的元素。以下是使用push()方法向数组末尾添加元素的示例:

arr.push(5);

3、从数组末尾移除元素

要从数组的末尾移除一个元素,可以使用数组的pop()方法。该方法 会移除并返回数组中的最后一个元素。以下是使用pop()方法从数组末尾移除元素的示例:

arr.pop();

4、从数组末尾移除元素
要从数组的末尾移除一个元素,可以使用数组的unshift()方法。该方法 会移除并返回数组中的最后一个元素。以下是使用unshift()方法从数组末尾移除元素的示例:

arr.unshift(0);

5、从数组开头移除元素
要从数组的开头移除一个元素,可以使用数组的shift()方法,并将索引值设置为0。该方法 会移除并返回数组中的第一个元素。以下是使用shift()方法从数组开头移除元素的示例:

arr.shift();

6、获取数组的长度
要获取数组的长度,可以使用内置函数length()。length()函数返回数组中元素的个数。以下是获取数组长度的示例:

console.log(arr.length);

7、遍历数组

要遍历数组的所有元素,可以使用for循环。下面是遍历数组的示例:

for (var i = 0; i < arr.length; i++) {
console.log(arr[i]);
}

8、数组排序

要对数组进行排序,可以使用JavaScript内置的sort()方法。下面是对数组进行排序的示例:

arr.sort();

9、数组反转

要对数组进行反转,可以使用JavaScript内置的reverse()方法。下面是对数组进行反转的示例:

arr.reverse();

10、数组搜索
要在数组中搜索特定的元素,可以使用循环遍历数组,逐个比较每个元素与目标值,找到目标值后返回其索引。下面是一个示例代码:

console.log(arr.indexOf(3)); // 返回3在数组中的索引位置
console.log(arr.includes(4)); // 检查数组中是否包含4

以上就是一些常见的数组操作方法,可以根据需要使用适当的方法来操作数组中的元素。

想要快速入门前端开发吗?推荐一个免费前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!点这里前往学习哦!

五、使用数组方法的注意事项

  • 数组方法是JavaScript中针对数组对象的内置方法,可以方便地对数组进行操作和处理。

  • 使用数组方法之前,需要先创建一个数组对象。可以使用数组字面量创建一个数组,也可以使用Array()构造函数来创建一个数组。

  • 数组方法可以改变原始数组,也可以返回一个新的数组。需要根据实际需求来选择使用具体的方法。

  • 改变原始数组的方法包括:push()、pop()、shift()、unshift()、splice()、sort()、reverse()等。

  • 不改变原始数组的方法包括:slice()、concat()、join()、map()、filter()、reduce()、forEach()等。

  • 使用数组方法时需要注意方法的参数和返回值,不同的方法可能需要不同的参数,并且返回值类型也可能不同。

  • 数组方法的具体用法可以参考JavaScript官方文档或者其他相关教程和资源。熟练掌握数组方法可以提高代码的效率和可读性。

以上就是JavaScript数组的创建和使用方法,希望对你有所帮助。记住,数组是JavaScript中非常重要的一部分,掌握好它可以让我们的编程工作更加高效。

收起阅读 »

你还以为前端无法操作文件吗

web
这里面有个值得说明一点的问题是,我一直以为(可能有人跟我一样)前端是无法操作文件的,可实际上自从HTML5标准出现之后,前端虽然无法像后端那样能灵活的进行文件处理,但因为有了File System Api这套接口,前端也能够进行简单的文件处理操作(不只是读,还...
继续阅读 »

这里面有个值得说明一点的问题是,我一直以为(可能有人跟我一样)前端是无法操作文件的,可实际上自从HTML5标准出现之后,前端虽然无法像后端那样能灵活的进行文件处理,但因为有了File System Api这套接口,前端也能够进行简单的文件处理操作(不只是读,还有写)。当然,网络环境鱼龙混杂,为防止不法网站任意获取和修改用户数据,所有本地文件操作都需要用户手动操作,不能自动保存或打开。

  1. 使用场景

    File System Api为浏览器应用增加了无限可能,比如我们经常用到的一些流程图工具,上面的保存到本地的功能,就不用再依赖后端,可以直接将数据保存到本地的文件系统中,下次打开时选中本地的指定文件,可以直接加载到浏览器中,大大提高的前端的能力边界。

  2. 功能描述

    我们就利用File Access Api搞一个简单的在线编辑器,能实现的功能如下:

    第一步,新建一个文件,命名为hello.txt,并填写初始信息 "hello world"

    第二步,打开文件,修改文件内容为“hello world,hello you!”

    第三步,保存文件

editfile.gif

  1. 实现方式概述

    直接看代码:

    <template>
     <div>
       <el-button type="primary" @click="editFile">编辑文件el-button>
       <el-button type="primary" @click="saveFile">保存文件el-button>
       <el-input
           type="textarea"
           :rows="20"
           placeholder="请输入内容"
           v-model="textarea">
    el-input>
     
     div>
    template>

    <script>
    export default {
       data() {
           return {
               textarea: ''
          }
      },
       methods: {
           editFile: async function() {
               // 选择文件
               let [fileHandle] = await window.showOpenFilePicker()
               // 复显文件内容
               fileHandle.getFile().then(blob => {
                   blob.text().then(val => {
                       this.textarea = val
                  })
              })
          },
           saveFile: async function() {
               // 新建一个文件
               const fileHandle = await window.showSaveFilePicker({
                   types: [
                      {
                           description: 'hello',
                           accept: {
                               'text/plain': ['.txt']
      // 对于一些非常用的后缀,均使用这种方式进行定义
                               // 参考:https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
                               // 'application/octet-stream': ['.a','.b']
                          }
                      }
                  ]
              })
               // 在文件内写入内容,写入内容用的是Stream Api,流式写入
               const writable = await fileHandle.createWritable();
               await writable.write(this.textarea);
               await writable.close();
          }
      }
    }
    script>

    可以看到,只需要短短的几行代码就可以完成本地文件的修改,需要注意的是,文件的保存不是实际意义上的修改,而是新建一个文件,进行替换,然后在新的文件里写入最新信息进行的修改。

    另:File System Api目前支持程度还不够普遍,从mdn上来看,大多数api上还有Experimental: This is an experimental technology Check the Browser compatibility table carefully before using this in production.的描述,使用前需要确认好是否满足浏览器要求。


作者:DaEar图图
来源:juejin.cn/post/7365679089811947561
收起阅读 »

这么炫酷的换肤动画,看一眼你就会爱上

web
实现过程 我们先创建下 vue 项目 npm init vite-app vue3-vite-animation 进入文件夹中 cd vue3-vite-animation 安装下依赖 npm install 启动 npm run dev 重新修改 ...
继续阅读 »

动画.gif


实现过程


我们先创建下 vue 项目


npm init vite-app vue3-vite-animation

进入文件夹中


cd vue3-vite-animation

安装下依赖


npm install

启动


npm run dev

image-20240503171537954.png


重新修改 App.vue


<template>
<div class="info-box">
<div class="change-theme-btn">改变主题</div>
<h1>Element Plus</h1>
<p>基于 Vue 3,面向设计师和开发者的组件库</p>
</div>

</template>

<script setup lang="ts">

</script>



<style>

.change-theme-btn {
width: 80px;
height: 40px;
background-color: #fff;
text-align: center;
line-height: 40px;
color: #282c34;
cursor: pointer;
border-radius: 8px;
border: 2px solid #282c34;
}

.info-box {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
</style>


基本样式出来了,但是页面出现了滚动条,我们需要去掉原有样式


image-20240503175456039.png


src/index.css,里的所有样式都删除了,再到 index.html 中将 bodymargin 属性去掉


<body style="margin: 0;">
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>

接下来,我们来实现下换肤功能


使用 css 变量,先定义下一套黑暗主题、一套白色主题


:root {
--background-color: #fff;
--color: #282c34;
background-color: var(--background-color);
color: var(--color);
}

:root.dark {
--background-color: #282c34;
--color: #fff;
}

再定义点击事件 changeColor,点击 "改变主题" 就会改变主题颜色


classList.toggle 这个方法的第一个参数是类名,第二个参数是布尔值,表示是否添加类


如果第二个参数为 true,则添加类;如果第二个参数为 false,则移除类


<div class="change-theme-btn" @click="changeColor">改变主题</div>

/* 改变颜色 */
const changeColor = () => {
document.documentElement.classList.toggle('dark')
}

image-20240503180914393.png


按钮背景颜色、边框、字体颜色都没有改变


调整下按钮样式,把背景颜色、边框、字体颜色这些都用 css 变量代替


.change-theme-btn {
width: 80px;
height: 40px;
background-color: var(--background-color);
text-align: center;
line-height: 40px;
color: var(--color);
cursor: pointer;
border-radius: 8px;
border: 2px solid var(--color);
}

image-20240503181138545.png


这个效果不是我们想要的,需要一个过渡动画对不对


使用 startViewTransition,这个 API 会生成一个屏幕截图,将新旧屏幕截图进行替换


截图分别对应两个伪元素 ::view-transition-new(root)::view-transition-old(root)


 // 创建一个过渡对象
document.startViewTransition(() => {
document.documentElement.classList.toggle('dark')
})

可以看到,一个淡入淡出的效果,但是我们需要的是一个圆向外扩散的效果


用剪切效果就可以实现,其中 circle(动画进度 at 动画初始x坐标 动画初始y坐标)


设置动画时间为 1秒,作用在新的伪元素上,也即是作用在新的截图上


const transition = document.startViewTransition(() => {
document.documentElement.classList.toggle('dark')
})

transition.ready.then(() => {
document.documentElement.animate({
clipPath: ['circle(0% at 50% 50%)', 'circle(100% at 100% 100%)']
}, {
duration: 1000,
pseudoElement: '::view-transition-new(root)'
})
})

动画-1714752074132-6.gif


为什么动画效果和预期的不一样


因为,默认的动画效果,把当前动画覆盖了,我们把默认动画效果去掉


/* 隐藏默认的过渡效果 */
::view-transition-new(root),
::view-transition-old(root) {
animation: none;
}

动画-1714752309164-8.gif


效果出来了,但是圆的扩散不是从按钮中心扩散的


那么,通过 ref="btn" 来获取 “改变主题” 按钮的坐标位置


再获取按钮坐标减去宽高,就能得到按钮的中心坐标了


<div ref="btn" class="change-theme-btn" @click="changeColor">改变主题</div>

<script setup>
import { ref } from 'vue';
const btn = ref<any>(null)

/* 改变颜色 */
const changeColor = () => {
// 创建一个过渡对象
const transition = document.startViewTransition(() => {
document.documentElement.classList.toggle('dark')
})

const width = btn.value.getBoundingClientRect().width // 按钮的宽度
const height = btn.value.getBoundingClientRect().height // 按钮的高度
const x = btn.value.getBoundingClientRect().x + width / 2 // 按钮的中心x坐标
const y = btn.value.getBoundingClientRect().y + height / 2 // 按钮的中心y坐标

transition.ready.then(() => {
document.documentElement.animate({
clipPath: [`circle(0% at ${x}px ${y}px)`, `circle(100% at ${x}px ${y}px)`]
}, {
duration: 1000,
pseudoElement: '::view-transition-new(root)',
})
})
}
</script>

扩展,如果,我不要从中心扩展,要从左上角开始动画呢,右上角呢...


我们把按钮放在左上角,看看效果


修改下样式、与模板


<template>
<div ref="btn" class="change-theme-btn" @click="changeColor">改变主题</div>
<div class="info-box">
<h1>Element Plus</h1>
<p>基于 Vue 3,面向设计师和开发者的组件库</p>
</div>

</template>

.info-box {
width: 100vw;
height: calc(100vh - 44px);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}

动画这个圆的半径不对,导致动画到快末尾的时候,直接就结束了


动画-1714753474905-10.gif


动画的圆的半径 = 按钮中心坐标 到 对角点的坐标


可以使用三角函数计算,两短边平方 = 斜边平方


image-20240504002759638.png


// 计算展开圆的半径
const tragetRadius = Math.hypot(
window.innerWidth - x,
innerHeight - y
)

// 设置过渡的动画效果
transition.ready.then(() => {
document.documentElement.animate({
clipPath: [`circle(0% at ${x}px ${y}px)`, `circle(${tragetRadius}px at ${x}px ${y}px)`]
}, {
duration: 1000,
// pseudoElement
// 设置过渡效果的伪元素,这里设置为根元素的伪元素
// 这样过渡效果就会作用在根元素上
pseudoElement: '::view-transition-new(root)',
})
})

动画-1714754131456-15.gif


如果是右上角呢


.change-theme-btn {
float: right;
width: 80px;
height: 40px;
background-color: var(--background-color);
text-align: center;
line-height: 40px;
color: var(--color);
cursor: pointer;
border-radius: 8px;
border: 2px solid var(--color);
}

动画-1714754468881-23.gif


在右边的话,使用三角函数计算,其中一个短边就不能是 屏幕宽度 - 按钮x坐标,直接是 x 坐标就对了


那要怎么实现呢,直接取 屏幕宽度 - 按钮x坐标 与 按钮x坐标 的最大值就可以了


y 也是同理


const tragetRadius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y)
)

动画-1714754788538-25.gif


你可以试试其他位置,是否也是可行的


完整代码


<template>
<div ref="btn" class="change-theme-btn" @click="changeColor">改变主题</div>
<div class="info-box">
<h1>Element Plus</h1>
<p>基于 Vue 3,面向设计师和开发者的组件库</p>
</div>

</template>

<script setup lang="ts">
import { ref } from 'vue';
const btn = ref<any>(null)

/* 改变颜色 */
const changeColor = () => {
// 创建一个过渡对象
const transition = document.startViewTransition(() => {
document.documentElement.classList.toggle('dark')
})

const width = btn.value.getBoundingClientRect().width // 按钮的宽度
const height = btn.value.getBoundingClientRect().height // 按钮的高度
const x = btn.value.getBoundingClientRect().x + width / 2 // 按钮的中心x坐标
const y = btn.value.getBoundingClientRect().y + height / 2 // 按钮的中心y坐标

// 计算展开圆的半径
const tragetRadius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y)
)

// 设置过渡的动画效果
transition.ready.then(() => {
document.documentElement.animate({
clipPath: [`circle(0% at ${x}px ${y}px)`, `circle(${tragetRadius}px at ${x}px ${y}px)`]
}, {
duration: 1000,
// pseudoElement
// 设置过渡效果的伪元素,这里设置为根元素的伪元素
// 这样过渡效果就会作用在根元素上
pseudoElement: '::view-transition-new(root)',
})
})
}
</script>


<style>

:root {
--background-color: #fff;
--color: #282c34;
background-color: var(--background-color);
color: var(--color);
}

:root.dark {
--background-color: #282c34;
--color: #fff;
}

/* 隐藏默认的过渡效果 */
::view-transition-new(root),
::view-transition-old(root) {
animation: none;
}

.change-theme-btn {
float: right;
width: 80px;
height: 40px;
background-color: var(--background-color);
text-align: center;
line-height: 40px;
color: var(--color);
cursor: pointer;
border-radius: 8px;
border: 2px solid var(--color);
}

.info-box {
width: 100vw;
height: calc(100vh - 44px);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
</style>

换肤动画源码


小结


换肤功能,主要靠 css 变量 与 classList.toggle


startViewTransition 这个 API 来实现过渡动画效果,注意需要清除默认动画


圆点扩散效果,主要运用剪切的方式进行实现,计算过程运用了三角函数运算


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

background简写,真细啊!

web
背景原因 今天写需求,需要使用background简写属性,心想这还不简单吗,真男人写样式只需要两秒: background: url('./bg.png') no-repeat center contain ; 搞定! 上面设置的依次是 背景图片 背...
继续阅读 »

背景原因


今天写需求,需要使用background简写属性,心想这还不简单吗,真男人写样式只需要两秒:


background:  url('./bg.png') no-repeat center contain ;

搞定!


上面设置的依次是 背景图片 背景平铺模式 背景位置 背景图片是保有其原有的尺寸还是拉伸到新的尺寸。


so easy~


看我ctrl + s 保存代码,编译。


嗯? 怎么不生效? 俺的背景呢?
打开控制台一看,好家伙,压根没生效:


image.png


问题排查


第一反应是这些属性有固定顺序,但是凭我练习两年半的经验,不应该啊,之前也是这样用的啊,遂打开MDN,仔细翻阅....


发现了下面这段话:


image.png


这让我更加确信 写的没毛病啊!!


background-attachment、background-color、background-image、background-position、background-repeat、background-size
这些属性可以以任意顺序书写。


见了鬼了,待我排查两小时(摸鱼...)


原因浮现


在仔细阅读文档后发现,其实在文档的上面,还有另外一段话:


image.png


我恍然大悟,索嘎,以后看文档不能马虎了,得仔细查阅,过于经验主义了,这都是细节啊!


background使用注意事项和总结


其实,使用background时,大部分时候 属性的顺序是可以任意位置书写的,
但是有两个属性有点特殊,那就是background-size和background-position,


当background简写同时有这两个属性时,那么必须background-position在前,background-size在后,且两者只能紧挨着书写并且以 "/"分隔。
例如:


错误: background: url('./bg.png') no-repeat center  contain ; // 没有以 "/"分隔
错误: background: url('./bg.png') center no-repeat contain ; // 没有紧挨着书写
错误: background: url('./bg.png') no-repeat contain / center; //background-size写在了 background-position的前面

正确: background: url('./bg.png') no-repeat center / contain ;


写在最后


其实MDN在关于background的文档最开头的例子中就有写:


image.png


只不过没有用语言描述出来,一般没有认真看很难发现,所以有时候能够静下心来认真查阅文档,真的会发现很多细节(甩锅:这tm是谁写的文档,出来挨打).


作者:可狗可乐
来源:juejin.cn/post/7234825495333158949
收起阅读 »

JavaScript 流程控制语句详解:if语句、switch语句、while循环、for循环等

JavaScript,作为一种广泛使用的编程语言,它的流程控制语句是构建逻辑和实现功能的基础。流程控制语句包括条件语句、循环语句和转向语句,它们是编程中不可或缺的部分。接下来,我们将一一解析这些语句,带你走进JavaScript的世界。一、什么是流程控制语句流...
继续阅读 »

JavaScript,作为一种广泛使用的编程语言,它的流程控制语句是构建逻辑和实现功能的基础。流程控制语句包括条件语句、循环语句和转向语句,它们是编程中不可或缺的部分。

接下来,我们将一一解析这些语句,带你走进JavaScript的世界。

一、什么是流程控制语句

流程控制语句是用来控制程序中语句执行顺序的语句,它们可以影响程序的流程,从而实现不同的逻辑。流程控制语句主要分为以下三类:
Description

顺序结构: 这是最基本的流程控制,代表代码按照书写的顺序从上到下依次执行。通常程序都是从第一行代码开始顺序执行到结束的。

选择结构: 用于根据特定条件来控制代码的执行路径。常见的选择结构包括if、else、if-else if和switch等。这些语句允许程序在满足某些条件时执行特定的代码块,而在其他条件下执行另外的代码块或跳过某些代码。

循环结构: 用于重复执行某段代码直到满足退出条件为止。循环语句包括for、foreach、while和do-while等。通过这些语句,可以实现固定次数的循环或者当某个条件成立时的持续循环。

此外,还有跳转语句如break、continue和return等,它们可以改变正常的控制流程,例如跳出当前循环或者返回函数的结果。

二、条件判断语句

使用条件判断语句可以在执行某个语句之前进行判断,如果条件成立才会执行语句,条件不成立,则语句不执行。

语法一:if(条件表达式){语句…};

执行流程:
if语句在执行时,会先对条件表达式进行求值判断,

  • 如果条件表达式的值为true,则执行if后的语句,

  • 如果条件表达式的值为false,则不会执行if后的语句if语句只能控制紧随其后的那个语句。

如果希望if语句可以控制多条语句,可以将这些语句统一放在代码块中,如果就一条if语句,代码块不是必须的,但在开发中尽量写清楚。

代码演示:

<script>
if(true) console.log('好好学习,天天向上');
// 加上条件运算符 && ||
var a=20;
if(a>10&&a<=20){
alert('a在10-20之间');
alert("4567")
}
</script>

语法二:if…else…语句

语法:

if(条件表达式){
语句....
}else{
语句....
}

执行流程:
当该语句执行时,会先对if后的条件进行判断,

  • 如果该值为true,则执行if后的语句,

  • 如果该值为false,则执行else后的语句,两者选其一执行。

语法三:if…else if…else

语法:

if(条件表达式){
语句....
}else if(条件表达式){

语句....
}else{
语句....
}

执行流程:
当该语句执行时,会从上到下依次对条件表达式进行求值,

  • 如果值为true,则执行当前语句。

  • 如果值为false,则继续向下判断,如果所有的条件都不满意,就执行最后一个else或者不执行,该语句中,只会有一个代码块被执行,一旦代码块执行了, 则直接结束语句。

<script>
var age=16;
/* if(age>=60){
alert("你已经退休了~~~")
}else{
alert("你还没退休~~~")
} */


if(age>=100){
alert("您老高寿呀~~~");
}else if(age>=80){
alert("你也不小了");
} else if(age>=60){
alert("你刚退休呀~~~");
}else if(age>=30){
alert("你已经中年了");
}else if(age>=17){
alert("你刚成年呀~~~");
}else{
alert("你还是个小孩子~~")
};
</script>

三、条件分支语句

switch语句是一种多分支选择结构,它可以根据表达式的值,来选择执行不同的代码块。

语法:switch…case…

switch(条件表达式){
case 表达式:
语句....
break;
case 表达式:
语句....
break;
default:
语句...
break;
}

执行流程:

在执行时,会依次将case后的表达式的值和switch后的条件表达式的值进行全等比较。

  • 如果比较结果为true,则从当前case处开始执行代码,当前case后的所有代码都会执行;

  • 在case的后边跟着一个break关键字,这样可以确保只会执行当前case后的语句,而不会执行其他的case;

  • 如果比较结果为false,则继续向下比较;

  • 如果所有的比较结果都为false,则只执行default后的语句;

注意: switch语句和if语句的功能实际上有重复的,使用switch可以实现if的功能,同样使用if也可以实现switch的功能,所以我们使用时,可以根据自己的习惯选择。
代码演示:

<script>
var num=2;
switch(num){
case 1:
console.log("壹");
//使用break可以退出switch语句
break;
case 2:
console.log("贰");
break;
case 3:
console.log("叁")
break;

default:
console.log("非法数字~~~");
break;
}
</script>

四、循环语句

循环语句,就是让某段代码反复执行。在JavaScript中,主要有for循环、while循环、do…while循环等。

1) while循环

语法:

while(条件表达式){
语句
}

while语句执行流程:

先对条件表示式进行求值判断,如果值为true,则执行循环体,循环体执行完毕以后,继续对表达式进行判断,如果值为false,则终止循环。

2) do…while循环

语法:

do{

语句....

}while(条件表达式)

执行流程:

do…while 语句在执行时,会先执行循环体,循环体执行完毕后,再对while后的条件表示式进行判断,如果结果为true,则继续执行,执行完毕继续判断,如果结果为false,则停止执行。

注意: 实际上以上两个语句功能类似,不同的是while 是先判断后执行,而do…while会先执行后判断,do…while可以保证循环体至少执行一次,而while不行。


想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!点这里前往学习哦!

3)for语句(for循环)

在for循环中,为我们提供了专门的位置,用来放三个表达式。

  • 初始化表达式
  • 条件表达式
  • 更新表达式

for循环的语法:

for(初始化表达式;条件表达式;更新表达式){

语句....

}

for循环的执行流程:

  • 初始化表达式,初始化变量(初始化表达式,只会执行一次);

  • 条件表达式,判断是否执行循环;

  • 如果为true,则执行循环,如果为false,终止循环;

  • 执行更新表达式,更新表达式执行完毕继续重复。

<script>
//第一种写法
for(var i=0;i<10;i++){
alert(i);
}
//第二种写法 for循环中的三个部分都可以省略,也都可以写在外部
var i=0;
for(;i<10;){
alert(i++);
}

//如果在for循环中,不写任何的表达式,只写两个;
//此时循环是一个死循环,会一直执行下去,慎用
for(;;){
alert("hello");
}
</script>

五、break和continue语句

break关键字

可以用来退出switch或循环语句,不能在if语句中使用break和continue,break关键字,会立即终止离它最近的那个循环语句。

continue关键字

可以用来跳过当次循环,同样continue也是默认只会对离它最近的循环起作用。

终止指定循环

可以为循环语句创建一个label(标签),来标识当前的循环。

语法:

label(给起的标签名字):循环语句

使用break语句时,可以在break后跟着一个label,这样break可以结束指定的循环,而不是最近的。

代码演示

<script>

/* for(var i=0;i<5;i++){
console.log(i);
//break;//用来结束for的循环语句,for只会循环一次
if(i==2){
break;//这个break是对整个for循环起作用的
}
} */


/* for (var i = 0; i < 5; i++) {
console.log("@外层循环" + i);
for (var j = 0; j < 5; j++) {
break;//只会结束离他最近的内层循环
console.log("内层循环" + j);
}
} */



/* outer: for (var i = 0; i < 5; i++) {
console.log("@外层循环" + i);
for (var j = 0; j < 5; j++) {
break outer; //指定结束外层的for循环
console.log("内层循环" + j);
}
} */



for (var i = 0; i < 5; i++) {
if (i == 2) {
continue;
}
console.log(i);
}
</script>

JavaScript的流程控制语句,就像是一把魔法棒,它能让我们的代码按照我们的意愿去运行。掌握了这些语句,我们就可以在编程的世界里自由翱翔。

希望这篇文章能帮助你更好地理解和使用JavaScript的流程控制语句,让我们一起在编程的道路上,探索更多的可能性。

收起阅读 »

前端使用a链接下载内容增加loading效果

web
问题描述:最近工作中出现一个需求,纯前端下载 Excel 数据,并且有的下载内容很多,这时需要给下载增加一个 loading 效果。代码如下:// utils.jsconst XLSX = require('xlsx')// 将一个sheet转成最终的exce...
继续阅读 »
  1. 问题描述:最近工作中出现一个需求,纯前端下载 Excel 数据,并且有的下载内容很多,这时需要给下载增加一个 loading 效果。
  2. 代码如下:
// utils.js
const XLSX = require('xlsx')
// 将一个sheet转成最终的excel文件的blob对象,然后利用URL.createObjectURL下载
export const sheet2blob = (sheet, sheetName) => {
sheetName = sheetName || 'sheet1'
var workbook = {
SheetNames: [sheetName],
Sheets: {}
}
workbook.Sheets[sheetName] = sheet
// 生成excel的配置项
var wopts = {
bookType: 'xlsx', // 要生成的文件类型
bookSST: false, // 是否生成Shared String Table,官方解释是,如果开启生成速度会下降,但在低版本IOS设备上有更好的兼容性
type: 'binary'
}
var wbout = XLSX.write(workbook, wopts)
var blob = new Blob([s2ab(wbout)], { type: 'application/octet-stream' })
// 字符串转ArrayBuffer
function s2ab(s) {
var buf = new ArrayBuffer(s.length)
var view = new Uint8Array(buf)
for (var i = 0; i !== s.length; ++i) view[i] = s.charCodeAt(i) & 0xff
return buf
}
return blob
}

/**
* 通用的打开下载对话框方法,没有测试过具体兼容性
* @param url 下载地址,也可以是一个blob对象,必选
* @param saveName 保存文件名,可选
*/
export const openDownloadDialog = (url, saveName) => {
if (typeof url === 'object' && url instanceof Blob) {
url = URL.createObjectURL(url) // 创建blob地址
}
var aLink = document.createElement('a')
aLink.href = url
aLink.download = saveName + '.xlsx' || '1.xlsx' // HTML5新增的属性,指定保存文件名,可以不要后缀,注意,file:///模式下不会生效
var event
if (window.MouseEvent) event = new MouseEvent('click')
else {
event = document.createEvent('MouseEvents')
event.initMouseEvent(
'click',
true,
false,
window,
0,
0,
0,
0,
0,
false,
false,
false,
false,
0,
null
)
}
aLink.dispatchEvent(event)
}

<el-button
@click="clickExportBtn"
>
<i class="el-icon-download"></i>下载数据
</el-button>
<div class="mongolia" v-if="loadingSummaryData">
<el-icon class="el-icon-loading loading-icon">
<Loading />
</el-icon>
<p>loading...</p>
</div>

clickExportBtn: _.throttle(async function() {
const downloadDatas = []
const summaryDataForDownloads = this.optimizeHPPCDownload(this.summaryDataForDownloads)
summaryDataForDownloads.map(summaryItem =>
downloadDatas.push(this.parseSummaryDataToBlobData(summaryItem))
)
// donwloadDatas 数组是一个三维数组,而 json2sheet 需要的数据是一个二维数组
this.loadingSummaryData = true
const downloadBlob = aoa2sheet(downloadDatas.flat(1))
openDownloadDialog(downloadBlob, `${this.testItem}报告数据`)
this.loadingSummaryData = false
}, 2000),

// css
.mongolia {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.9);
display: flex;
justify-content: center;
align-items: center;
font-size: 1.5rem;
color: #409eff;
z-index: 9999;
}
.loading-icon {
color: #409eff;
font-size: 32px;
}
  1. 解决方案探究:
  • 在尝试了使用 $nextTick、将 openDownloadDialog 改写成 Promise 异步函数,或者使用 async/await、在 openDownloadDialog 中添加 loadingSummaryData 逻辑,发现依旧无法解决问题,因此怀疑是 document 添加新元素与 vue 的 v-if 渲染产生冲突,即 document 添加新元素会阻塞 v-if 的执性。查阅资料发现,问题可能有以下几种:

    • openDownloadDialog 在执行过程中执行了较为耗时的同步操作,阻塞了主线程,导致了页面渲染的停滞。
    • openDownloadDialog 的 click 事件出发逻辑存在问题,阻塞了事件循环(Event Loop)。
    • 浏览器在执行 openDownloadDialog 时,将其脚本任务的优先级设置得较高,导致占用主线程时间片,推迟了其他渲染任务。
    • Vue 的批量更新策略导致了 v-if 内容的显示被延迟。
  • 查阅资料后找到了如下几种方案:

      1. 使用 setTimeout 使 openDownloadDialog 异步执行
      clickExport() {
      this.loadingSummaryData = true;

      setTimeout(() => {
      openDownloadDialog(downloadBlob, `${this.testItem}报告数据`);

      this.loadingSummaryData = false;
      });
      }


      1. 对 openDownloadDialog 内部进行优化
      • 避免大循环或递归逻辑
      • 将计算工作分批进行
      • 使用 Web Worker 隔离耗时任务
        • 在编写 downloadWorker.js 中的代码时,要明确这部分代码是运行在一个独立的 Worker 线程内部,而不是主线程中。

            1. 不要直接依赖或者访问主线程的全局对象,比如 window、document 等。这些在 Worker 内都无法直接使用。
            1. 不要依赖 DOM 操作,比如获取某个 DOM 元素。Worker 线程无法访问页面的 DOM。
            1. 代码执行的入口是 onmessage 回调函数,在其中编写业务逻辑。
            1. 和主线程的通信只能通过 postMessage 和 onmessage 发送消息事件。
            1. 代码应该是自包含的,不依赖外部变量或状态。
            1. 可以导入其他脚本依赖,比如用 import 引入工具函数等。
            1. 避免修改或依赖全局作用域,比如定义全局变量等。
        • 总之,编写 downloadWorker.js 时要注意它执行在一个独立环境中,只能通过消息事件与主线程通信。
    // 创建 Worker
    const worker = new Worker('downloadWorker.js');

    // 点击下载时向 Worker 发送消息
    function clickDownload() {

    showLoading();

    worker.postMessage({
    url: fileURL,
    filename: 'report.xlsx'
    });

    worker.onmessage = function(e) {
    // 收到下载完成的消息
    hideLoading();
    }

    }

    // 显示 loading
    function showLoading() {
    loading.style.display = 'block';
    }

    // 隐藏 loading
    function hideLoading() {
    loading.style.display = 'none';
    }

    // downloadWorker.js

    onmessage = function(e) {
    const url = e.data.url;
    const filename = e.data.filename;

    // 创建并点击链接触发下载
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    a.click();

    postMessage('下载完成');
    }

    <div id="loading" style="display:none;">
    Downloading...
    </div>

    <button onclick="clickDownload()">
    Download
    </button>

    <script src="downloadWorker.js"></script>


      1. 使用 requestIdleCallback 进行调度
      clickExport() {
      this.loadingSummaryData = true;

      requestIdleCallback(() => {
      openDownloadDialog(downloadBlob, `${this.testItem}报告数据`);

      this.loadingSummaryData = false;
      });
      }
    • 当使用上述方案时发现里面的内容变得异常的慢,于是去问GPT后,得到以下答案:

      使用 requestIdleCallback 来执行 openDownloadDialog 确实可能会需要更多时间。

      因为 requestIdleCallback 会在主线程空闲时才执行回调函数,所以不像 setTimeout 可以直接按指定时间异步执行。

      requestIdleCallback 的优先级较低,只在主线程空闲时才会执行,所以 download 的速度会比 setTimeout 慢一些。

      但使用 requestIdleCallback 的优点是它不会占用主线程时间,可以避免阻塞页面渲染。

      所以需要权衡执行速度和避免阻塞之间的关系:

      • 如果下载速度是优先考虑的因素,可以继续使用 setTimeout 异步执行。
      • 如果避免阻塞渲染更重要,则 requestIdleCallback 是更好的选择。

      偶尔下载任务被延迟不是大问题,但频繁的下载任务阻塞会严重影响页面体验。

      此外,可以结合两者试试:

      setTimeout 先异步调度,然后在回调中使用 requestIdleCallback 避免阻塞。这样就可以兼顾下载速度和避免渲染阻塞了。

    clickExport() {

    this.loadingSummaryData = true;

    setTimeout(() => {

    requestIdleCallback(() => {
    openDownloadDialog(downloadBlob);
    });

    this.loadingSummaryData = false;

    }, 200);

    }


      1. 分析线程模型,找到具体的阻塞点
      • 使用 Performance 工具分析线程
      • debugger 及 console.log 打印关键函数的执行时间
      • 检查是否有非主线程的任务阻塞了主线程
      1. 调整 vue 的批量更新策略
    new Vue({
    config: {
    // 修改批量更新的队列长度
    batchUpdateDuration: 100
    }
    })



作者:李卫泽
来源:juejin.cn/post/7268050036474609683
收起阅读 »

我改进了数据滚动方式!老板直接加薪

web
需求背景 前几天,甲方提了个需求,想让下面的数据循环展示,准备放在他们集团首页给他们领导演示用。 我们领导很重视这个事儿,拍了拍我,语重心长的说,小伙子,好好做。 我啪的一下就兴奋了,老板居然如此器重我,我必当鞠躬尽瘁,减少摸鱼,我要让老板拜倒在精湛的技术下...
继续阅读 »

需求背景


前几天,甲方提了个需求,想让下面的数据循环展示,准备放在他们集团首页给他们领导演示用。



我们领导很重视这个事儿,拍了拍我,语重心长的说,小伙子,好好做。


我啪的一下就兴奋了,老板居然如此器重我,我必当鞠躬尽瘁,减少摸鱼,我要让老板拜倒在精湛的技术下!


于是,我搬出自己的库存代码,仅2min就实现了数据的滚动:


没错,我直接照搬了自己以前写过的文章:JS实现可滚动区域自动滚动展示 - 掘金


就在我准备告诉老板我做完了的时候,我突然想了想,这么快做完,老板一定觉得我没好好做,我以后还怎么升职加薪,赢取白富美?


于是,我连夜研究,终于改进了数据滚动方式,赢得了老板的大饼(以后涨500)。最终效果:



技术方案


技术选型


观察最终效果图,可以发现这其实就是一个数据循环滚动的效果,每条内容之间间隔1000ms,每条出现动的时间为500ms。用术语来说,这就是一个单步停顿滚动效果。


我百度了一下,社区还是有这个实现的现成方案的:vue-seamless-scroll,周下载也还行。



于是,我果断试了试,结果不知道什么原因,并不生效...


既然如此,直接手写一个吧!


实现思路


要实现上述效果其实很简单,如图



我们创造一个含有六个值的数组,每隔一段时间循环更改黄色区域的数据,当黄色区域数据变成最新的时候,红色区域整体向下移动,当有数值超出滚动区域后,在删除这个数据即可。


数据更新


如果不考虑动画,我们的代码应该这么写


<template>
<div class="item-wrap" v-for="(item, index) in animationData">
<!-- 模块内容 -->
</div>

</template>
<script setup lang="ts">
// #假设这是接口请求的10条最新数据
const allCarouseData = ref([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
// #需要轮播的数据
const animationData = ref<any>([])
// *定时器
const animationTimerMeta: any = {
timer: null,
// 这个函数负责设置轮播数据的更新逻辑。
timeFuc() {
let setTimeoutId: any = null
if (this.timer) return
this.timer = setInterval(() => {
// 取轮播数据的第一条id
let firstId = animationData.value[0].id
// 为轮播数据添加最新的第一项数据
let index = allCarouseData.value.findIndex((res: any) => res.id === firstId)
let addIndex = index - 1 < 0 ? allCarouseData.value.length - 1 : index - 1
animationData.value.unshift(allCarouseData.value[addIndex])
setTimeout(() => {
// 删除数组的最后一项
animationData.value.pop()
}, 1000)

}, 1500)
}
}
animationData.value = allCarouseData.value.slice(-5)
animationTimerMeta.timeFuc()
</script>

上述代码的主要功能是:



  1. 从 allCarouseData 中取出最后5个元素作为初始的轮播数据。

  2. 每1.5秒更新一次轮播数据,具体逻辑是:移除当前 animationData 的第一个元素,并从 allCarouseData 中取出前一个元素(如果已经是第一个元素,则取最后一个)添加到 animationData 的开头。

  3. 每1秒从 animationData 的末尾移除一个元素。


上述代码没有实现动画,他的效果是这样的:



动画添加


<template>
<div class="item-wrap" v-for="(item, index) in animationData"
:class="[{ moveToBottom: animationActive }, { show: animationActive && index === 0 }]"
>

<!-- 模块内容 -->
</div>

</template>
<script setup lang="ts">
// #假设这是接口请求的10条最新数据
const allCarouseData = ref([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
// #需要轮播的数据
const animationData = ref<any>([])
// #是否开启动画
const animationActive = ref(false)
// *定时器
const animationTimerMeta: any = {
timer: null,
// 这个函数负责设置轮播数据的更新逻辑。
timeFuc() {
let setTimeoutId: any = null
if (this.timer) return
this.timer = setInterval(() => {
// 取轮播数据的第一条id
let firstId = animationData.value[0].id
// 为轮播数据添加最新的第一项数据
let index = allCarouseData.value.findIndex((res: any) => res.id === firstId)
let addIndex = index - 1 < 0 ? allCarouseData.value.length - 1 : index - 1
animationData.value.unshift(allCarouseData.value[addIndex])
setTimeout(() => {
// 删除数组的最后一项
animationData.value.pop()
}, 1000)

}, 1500)
}
}
animationData.value = allCarouseData.value.slice(-5)
animationTimerMeta.timeFuc()
</script>


@keyframes moveToBottom {
0% {
transform: translateY(-47px);
}

100% {
transform: translateY(0);
}
}

.moveToBottom {
animation: moveToBottom 500ms ease-in-out forwards;
}

@keyframes fadeInFromTop {
0% {
opacity: 0;
transform: translateY(-47px);
}

100% {
opacity: 1;
transform: translateY(0);
color: #683BD6;
}
}

.show {
animation: fadeInFromTop 500ms ease-in-out forwards;
}

上述代码中,为了实现动画效果,采用了动态添加类名的技术方案。


animationData 数组中的元素会按照一定顺序进行显示和隐藏,同时伴随有动画效果。当第一个元素进入视图时,它会应用 fadeInFromTop 动画;其他元素会应用 moveToBottom 动画。通过定时器,元素会定期从 allCarouseData 中获取新的数据并更新 animationData。


代码释义:



  • moveToBottom: 当 animationActive 为真值时,此类名会被添加到 div 上。

  • show: 当 animationActive 为真值且当前元素是数组的第一个元素时,此类名会被添加到 div 上。


CSS 释义:



  • moveToBottom 动画:


定义一个名为 moveToBottom 的关键帧动画,使元素从上方移动到其原始位置。


moveToBottom 类将此动画应用到元素上。



  • fadeInFromTop 动画:


定义一个名为 fadeInFromTop 的关键帧动画,使元素从上方淡入并改变颜色。


show 类将此动画应用到元素上。


通过上述简单的实现方式,就能最终实现我们想要的效果



相比于普通滚动,这种方式看起来要好很多!


结语


要想实现这种单步停帧的效果,其实有很多实现方式,这只是笔者实现的一种,核心逻辑就是动态改变数据、增添类名。如果大家还有更好的方式,也欢迎大家指点。


作者:石小石Orz
来源:juejin.cn/post/7348433631944556555
收起阅读 »

只写后台管理的前端要怎么提升自己

web
本人写了五年的后台管理。每次面试前就会头疼,因为写的页面除了表单就是表格。抱怨过苦恼过也后悔过(虽然我现在已经心安理得的摆烂),但是站在现在的时间点回想以前,发现有很多事情可以做的更好,于是有了这篇文章。 写优雅的代码 一道面试题 大概两年以前,面试美团的时候...
继续阅读 »

本人写了五年的后台管理。每次面试前就会头疼,因为写的页面除了表单就是表格。抱怨过苦恼过也后悔过(虽然我现在已经心安理得的摆烂),但是站在现在的时间点回想以前,发现有很多事情可以做的更好,于是有了这篇文章。


写优雅的代码


一道面试题


大概两年以前,面试美团的时候,面试官让我写一道代码题,时间单位转换。具体的题目我忘记了。


原题目我没做过,但是我写的业务代码代码里有类似的单位转换,后端返回一个数字,单位是kb,而我要展示成 KBMB 等形式。大概写一个工具函数(具体怎么写的忘记了,不过功能比这个复杂点):


function formatSizeUnits(kb) {
let units = ['KB', 'MB', 'GB', 'TB', 'PB'];
let unitIndex = 0;

while (kb >= 1024 && unitIndex < units.length - 1) {
kb /= 1024;
unitIndex++;
}

return `${kb.toFixed(2)} ${units[unitIndex]}`;
}

而在此之前,是一个后端同学写的代码(说起来,后台管理系统前端人力不够后端帮忙写好像是常规操作,后端总觉得写前端简单,而我只能说你们那代码写的,一言难尽……),如下:


function formatSizeUnits(kb) {
var result = '';

if (kb < 1024) {
result = kb + ' KB';
} else if (kb < 1024 * 1024) {
result = (kb / 1024).toFixed(2) + ' MB';
} else if (kb < 1024 * 1024 * 1024) {
result = (kb / 1024 / 1024).toFixed(2) + ' GB';
} else {
result = (kb / 1024 / 1024 / 1024).toFixed(2) + ' TB';
}

return result;
}

虽然功能都一样,但是我写在面试的时候写了上面的代码得到了面试官的赞扬,也因此非常顺利的通过了面试。而如果我写成了下面的样子,估计不会有这种效果。


如何提升代码质量


想提升代码质量,最理想的是靠 code review ,但实际上这玩意在大多数公司根本就不存在。其次就只能多看看大佬们写的代码,但是一来后台管理本来就没太大难度很少有大佬,二来很多时候一个后台管理系统就一两个人负责。


还好现在有了各种 AI 工具,可以非常方便的优化自己的代码了。


还是上面的问题,看看 GPT 给的答案


// 提问:写一个工具函数,对文件大小的单位进行转换,加注释
// 回答:下面是一个JavaScript工具函数,它接受文件大小、初始单位、目标单位和小数位数,然后返回目标单位下的文件大小。

/**
* 将文件大小从一个单位转换为另一个单位。
*
* @param {number} size 文件大小。
* @param {string} fromUnit 初始单位('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')。
* @param {string} toUnit 目标单位('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')。
* @param {number} [decimalPoint=2] 结果保留的小数位数,默认为2。
* @return {string} 转换后的文件大小,带单位。
*/

function convertFileSize(size, fromUnit, toUnit, decimalPoint = 2) {
// 定义单位与字节之间的转换关系
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
// 获取初始单位和目标单位的索引
const fromIndex = units.indexOf(fromUnit);
const toIndex = units.indexOf(toUnit);

// 如果单位不在列表中,抛出错误
if (fromIndex === -1 || toIndex === -1) {
throw new Error('Invalid units');
}

// 计算初始单位与目标单位之间的转换系数
const exponent = toIndex - fromIndex;
// 计算结果大小
const resultSize = size / Math.pow(1024, exponent);

// 返回格式化后的结果
return parseFloat(resultSize.toFixed(decimalPoint)) + ' ' + toUnit;
}

// 示例使用
console.log(convertFileSize(1, 'GB', 'MB')); // 输出: 1024.00 MB
console.log(convertFileSize(1, 'MB', 'KB')); // 输出: 1024.00 KB
console.log(convertFileSize(1, 'KB', 'B')); // 输出: 1024.00 B
console.log(convertFileSize(1, 'MB', 'GB', 5)); // 输出: 0.00098 GB

还能说什么,我只能说 NB!(经评论区提示,此段代码可能有问题,建议大家在使用AI工具时,重点是借鉴思路,直接使用代码需要谨慎~)


我现在每次写大段逻辑之后都习惯拿给 AI 看看,有什么更好的实现方式,或者用什么设计模式。AI 是非常低成本且高效提升代码质量的工具。


学会封装


一个功能用到了好多次,为什么不封装成组件?一个组件用到了好几个项目,为什么不单独写个npm包?差不多的项目创建了好几个,为什么不封装成脚手架?


你说,没时间,没必要,复制粘贴反而更快。


那你就完全没理解,这么做不一定是为了让工作更快完成,而是可以让你在年年终述职时更有话说(你就算写了一百个表单表格没有写一个脚手架更值得炫耀),如果不会写可以问问 AI。


而当你真正开始封装组件,开始写工具库了,你会发现你需要思考的确实比之前多了。


关注业务


对于前端业务重要吗?


相比于后端来说,前端一般不会太关注业务。就算出了问题大部分也是后端的问题。


但是就我找工作的经验,业务非常重要!


如果你做的工作很有技术含量,比如你在做低代码,你可以面试时讲一个小时的技术难点。但是你只是一个破写后台管理,你什么都没有的说。这个时候,了解业务就成为了你的亮点。


一场面试


还是拿真实的面试场景举例,当时前同事推我字节,也是我面试过N次的梦中情厂了,刚好那个组做的业务和我之前呆的组做的一模一样。



  • 同事:“做的东西和咱们之前都是一样的,你随便走个过场就能过,我在前端组长面前都夸过你了!”

  • 我:“好嘞!”


等到面试的时候:



  • 前端ld:“你知道xxx吗?(业务名词)”

  • 我:“我……”

  • 前端ld:“那xxxx呢?(业务名词)”

  • 我:“不……”

  • 前端ld:“那xxxxx呢??(业务名词)”

  • 我:“造……”


然后我就挂了………………


如何了解业务



  1. 每次接需求的时候,都要了解需求背景,并主动去理解


    我们写一个表格简简单单,把数据展示出来就好,但是表格中的数据是什么意思呢?比如我之前写一个 kafka 管理平台,里面有表格表单,涉及什么 cluster controller topic broker partition…… 我真的完全不了解,很后悔我几年时间也没有耐下心来去了解。


  2. 每次做完一个需求,都需要了解结果


    有些时候,后台管理的团队可能根本没有PM,那你也要和业务方了解,这个功能做了之后,多少人使用,效率提高了吗?数据是怎样的?


  3. 理解需求,并主动去优化


    产品要展示一千条数据,你要考虑要不要分页,不分页会不会卡,要不要上虚拟表格?


    产品要做一个可拖拽表单,你要考虑是否需要拖动,是否需要配置。


    其实很多时候,产品的思维可能会被局限在竞品的实现方式,而前端可以给TA更多选项。在和产品沟通的时候,你不仅是沟通页面的实现,也更能理解业务。



关注源码


说到源码, Vue,React 这些基本是每次必问,也有太多大佬们的总结我就不多说了。


除此之外,我想大家每次面试应该都会被问,你写了什么亮点的项目,困难的项目,你会说什么?哦,都是表单表格,都用 Element UI 实现的,没什么难度。那估计你面试也就到这里了。


那说什么,后台管理就这些啊?!


如果你用了 Element UI 你可以说写了什么自定义 Element UI 表单组件,Element UI 表单源码是什么原理,怎么通信的?用了 Axios 可以说 Axios 的原理,它的拦截器怎么实现的?用了编辑器 diff 那你知道 diff 算法是什么吗?用了 ECharts 那你知道他底层是用什么绘制的吗?用了 husky ?husky的原理是什么?用了Vite,它为什么快?是怎么打包的?写过插件吗?


可能有人觉得我离谱,事实上上面每一个问题都是我在面试中真实被问到的。当然,如果全部去了解,肯定不现实,每个人的精力有限,不可能每个用到的每个工具每个库都去了解原理,不过只要有一两个你去了解过,就可以在面试中引导面试官来问,要学会表现出你擅长的点


至于怎么读源码?以前我不知道,现在嘛…… 为什么不问问神奇的 ChatGPT 呢?



讲一下 Axios 源码中,拦截器是怎么实现的?


Axios 的拦截器实现基于 Axios 的核心原理,即 Axios 实例是一个包含请求和响应拦截器堆栈的对象。当发出请求或接收响应时,Axios 会遍历这些拦截器,并按照添加的顺序执行请求拦截器,以及按照相反的顺序执行响应拦截器。


在 Axios 的源码中,拦截器是通过一个 AxiosInterceptorManager 实例来管理的,它维护了一个拦截器数组。每个拦截器都是一个包含 fulfilledrejected 函数的对象。这两个函数分别对应于拦截器成功处理和拦截器处理出错的情况。


以下是 Axios 拦截器管理器的一个简化版本,展示了其核心实现思路:


class InterceptorManager {
constructor() {
this.handlers = []; // 存储拦截器的数组
}

use(fulfilled, rejected) {
this.handlers.push({
fulfilled: fulfilled,
rejected: rejected
});
return this.handlers.length - 1; // 返回拦截器的ID
}

eject(id) {
if (this.handlers[id]) {
this.handlers[id] = null; // 移除拦截器
}
}

forEach(fn) {
this.handlers.forEach((h) => {
if (h !== null) {
fn(h);
}
});
}
}

在发送请求或接收响应时,Axios 会创建一个 promise 链,并通过 forEach 方法将拦截器中的 fulfilledrejected 函数添加到这个链中。这样,每个拦截器都可以对请求或响应进行处理,然后将结果传递到链的下一个拦截器,或者在出错时结束链的执行。


axios.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
chain.unshift(interceptor.fulfilled, interceptor.rejected);
});

axios.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
chain.push(interceptor.fulfilled, interceptor.rejected);
});

在 Axios 的完整实现中,这个拦截器机制被集成到了 Axios 的请求发送和响应处理流程中。通过这种方式,Axios 可以在发送请求之前和接收响应之后,但在用户定义的 .then.catch 执行之前,插入自定义的逻辑。


请注意,这里提供的代码只是为了说明 Axios 拦截器的实现原理,并不是 Axios 源码的完整复制。如果你对 Axios 的拦截器实现细节感兴趣,建议查看 Axios 的官方 GitHub 仓库中的源码。



前端基建


当我们工作时间久了面试难免会遇到这些问题,前端工程化,前端监控,工作流,部署,性能等等。其实我们在工作中绝大部分时间都在写代码,对于这些不是所有人都有机会接触到,不过这些和所做的业务无关,是我们提升自己很好的一个思路。


技术选型


技术栈选 Vue 还是 React?Vue 选 Vue2 还是 Vue3?组件库选 ElementUI 还是 Ant Design?微前端有没有使用过?打包工具用 Vite 还是 Webpack?有那么多表单怎么实现的,有没有什么表达配置化方案,比如Formily?


对于我这种菜鸡,我这种只写简单的表单表格的人,这些都……无所谓……


image.png

不过为了应对面试我们还是需要了解下未选择技术栈的缺点,和已选择技术栈的优点(有点本末倒置…但是常规操作啦)


Vue 你可以说简单高效轻量级,面试必会问你为什么,你就开始说 Vue 的响应式系统,依赖收集等。


React 你可以说 JSX、Hooks 很灵活,那你必然要考虑 JSX 怎么编译, Hooks 实现方式等。


总体而言,对于技术选型,依赖于我们对所有可选项的理解,做选择可能很容易,给出合理的理由还是需要花费一些精力的。


开发规范


这个方面,在面试的时候我被问到的不多,我们可以在创建项目的时候,配置下 ESlintstylelintprettiercommitlint 等。


前端监控


干了这么多年前端,前端监控我是……一点没做过。


image.png

前端监控,简单来说就是我们在前端程序中记录一些信息并上报,一般是错误信息,来方便我们及时发现问题并解决问题。除此之外也会有性能监控,用户行为的监控(埋点)等。之前也听过有些团队分享前端监控,为了出现问题明确责任(方便甩锅)。


对于实现方案,无论使用第三方库还是自己实现,重要的都是理解实现原理。


对于错误监控,可以了解一下 Sentry,原理简单来说就是通过 window.onerrorwindow.addEventListener('unhandledrejection', ...) 去分别捕获同步和异步错误,然后通过错误信息和 sourceMap 来定位到源码。


对于性能监控,我们可以通过 window.performancePerformanceObserver 等 API 收集页面性能相关的指标,除此之外,还需要关注接口的响应时间。


最后,收集到信息之后,还要考虑数据上报的方案,比如使用 navigator.sendBeacon 还是 Fetch、AJAX?是批量上报,实时上报,还是延迟上报?上报的数据格式等等。


CI/CD


持续集成(Continuous Integration, CI)和 持续部署(Continuous Deployment, CD),主要包括版本控制,代码合并,构建,单测,部署等一系列前端工作流。


场景的工作流有 Jenkins、 Gitlab CI 等。我们可以配置在合并代码时自动打包部署,在提交代码时自动构建并发布包等。


这块我了解不多,但感觉这些工具层面的东西,不太会涉及到原理,基本上就是使用的问题。还是需要自己亲自动手试一下,才能知道细节。比如在 Gitlab CI 中, Pipeline 、 Stage 和 Job 分别是什么,怎么配置,如何在不同环境配置不同工作流等。


了解技术动态


这个可能还是比较依赖信息收集能力,虽然我个人觉得很烦,但好像很多领导级别的面试很愿意问。


比如近几年很火的低代码,很多面试官都会问,你用过就问你细节,你没用过也会问你有什么设计思路。


还有最近的两年爆火的 AI,又或者 Vue React的最新功能,WebAssembly,还有一些新的打包工具 Vite Bun 什么的,还有鸿蒙开发……


虽然不可能学完每一项新技术,但是可以多去了解下。


总结


写了这么多,可能有人会问,如果能回到过去,你会怎么做。


啊,我只能说,说是一回事,做又是另一回事,事实上我并不希望回到过去去卷一遍,菜点没关系,快乐就好,一切都是最好的安排。


image.png

作者:我不吃饼干
来源:juejin.cn/post/7360528073631318027
收起阅读 »

JavaScript运算符及优先级全攻略,点击立刻升级你的编程水平!

在编程的世界里,运算符是构建逻辑、实现功能的重要工具。它能帮助我们完成各种复杂的计算和操作。今天,我们就来深入探索JavaScript中运算符的奥秘,掌握它们的种类和优先级,让你的代码更加高效、简洁!一、什么是运算符运算符,顾名思义,就是用于执行特定操作的符号...
继续阅读 »

在编程的世界里,运算符是构建逻辑、实现功能的重要工具。它能帮助我们完成各种复杂的计算和操作。

今天,我们就来深入探索JavaScript中运算符的奥秘,掌握它们的种类和优先级,让你的代码更加高效、简洁!

一、什么是运算符

运算符,顾名思义,就是用于执行特定操作的符号。

Description

在JavaScript中,运算符用于对一个或多个值进行操作,并返回一个新的值。它们是编程语言中的基础构件,帮助我们完成各种复杂的计算和逻辑判断。

运算符可以分为多种类型,如算术运算符、关系运算符、逻辑运算符等。通过使用不同的运算符,我们可以实现各种复杂的计算和逻辑判断,让程序更加灵活、强大。


二、运算符的分类

1、算术运算符

用于执行数学计算,如加法、减法、乘法、除法等。常见的算术运算符有:+、-、*、/、%、++、–等。

Description

+ 加法运算

  • 两个字符串进行加法运算,则作用是连接字符串,并返回;

  • 任何字符串 + “ ”空串做运算,都将转换为字符串,由浏览器自动完成,相当于调用了String ( )。

-减法运算 *乘法运算 /除法运算

  • 先转换为 Number 再进行正常的运算。

注意: 可以通过为一个值 -0 *1 /1 来将其转换为Number数据类型,原理和Number ( )函数一样。

%求余运算

对一个数进行求余运算

代码示例:

var num1 = 1;
var num2 = 2;
var res = num1-num2; //返回值为 -1
var res = num1*num2; //返回值为 2
var res = num1/num2; //返回值为 0.5——js中的除法为真除法
var res = num1%num2; //返回值为 1
console.log(res);


2、关系运算符

通过关系运算符可以比较两个值之间的大小关系,如果关系成立它会返回true,如果关系不成立则返回false。常见的比较运算符有:==、!=、>、<、>=、<=等。

> 大于号

  • 判断符号左侧的值是否大于右侧的值;

  • 如果关系成立,返回true,如果关系不成立则返回false。

>= 大于等于

  • 判断符号左侧的值是否大于或等于右侧的值。

< 小于号

  • 判断符号左侧的值是否小于右侧的值;

  • 如果关系成立,返回true,如果关系不成立则返回false。

<= 小于等于

  • 判断符号左侧的值是否小于或等于右侧的值。

非数值的情况

  • 对于非数值进行比较时,会将其转换为数字然后再比较。

  • 如果符号两侧的值都是字符串时,不会将其转换为数字进行比较,而会分别比较字符串中字符的Unicode编码。

== 相等运算符

  • 两者的值相等即可。

  • 比较两个值是否相等,相等返回 true,否则返回 flase。

  • 使用==来做相等运算

特殊:

console.log(null==0);  //返回 false
console.log(undefined == null); //返回true 因为 undefined衍生自null
console.log(NaN == NaN); //返回 false NaN不和任何值相等

isNan() 函数来判断一个值是否是NaN,是返回 true ,否则返回 false。

Description

=== 全等

  • 两者的值不仅要相等,而且数据类型也要相等。

  • 判断两个值是否全等, 全等返回 true 否则返回 false 。

!= 不相等运算符

  • 只考量两者的数据是否不等。

  • 比较两个值是否不相等,不相等返回 true,否则返回 flas。

  • 使用==来做相等运算。

!== 不全等运算符

  • 两者的值不仅要不等,而且数据类型也要不等,才会返回true,否则返回false;

  • 判断两个值是否不全等,不全等返回true,如果两个值的类型不同,不做类型转换直接返回true。

var num1 = 1;
var num2 = '2';
var res =(num1 !== num2); //返回值 true
console.log(res);


3、逻辑运算符

用于连接多个条件判断,如与、或、非等。常见的逻辑运算符有:&&、||、!等。

Description

&& 与

&&可以对符号两侧的值进行与运算并返回结果。

运算规则:

  • 两个值中只要有一个值为false就返回false,只有两个值都为true时,才会返回true;

  • JS中的“与”属于短路的与,如果第一个值为false,则不会看第二个值。

|| 或

  • ||可以对符号两侧的值进行或运算并返回结果

  • 两个值中只要有一个true,就返回true;

  • 如果两个值都为false,才返回false。

JS中的“或”属于短路的或,如果第一个值为true,则不会检查第二个值。

! 非

!可以用来对一个值进行非运算,所谓非运算就是值对一个布尔值进行取反操作,true变false,false变true。

  • 如果对一个值进行两次取反,它不会变化;

  • 如果对非布尔值进行元素,则会将其转换为布尔值,然后再取反;

  • 所以我们可以利用该特点,来将一个其他的数据类型转换为布尔值;

  • 可以为一个任意数据类型取两次反,来将其转换为布尔值;原理和Boolean()函数一样;

非布尔值的与 或 非

非布尔值的与 或 非( 会将其先转换为布尔值, 再进行运算 )

代码示例如下:

var b1 = true;
var b2 = false;
var res = b1 && b2; //返回值为 false
var res = b1 || b2; //返回值为true
console.log(res);


4、赋值运算符

用于给变量赋值,如等于、加等于、减等于等。常见的赋值运算符有:=、+=、-=等。

将右侧的值赋值给符号左侧的变量。

=   右赋给左
+= a+=5 等价于 a=a +5;
-= a-=5 等价于 a=a-5;
*= a*=5 等价于 a=a*5;
/= a/=5 等价于 a=a/5;
%= a%=5 等价于 a=%+5;


5、其他运算符

还有一些特殊的运算符,如类型转换运算符、位运算符等。这些运算符虽然不常用,但在特定场景下会发挥重要作用。

想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!点这里前往学习哦!


三、运算符的优先级

在JavaScript中,不同类型的运算符具有不同的优先级。优先级高的运算符会先于优先级低的运算符进行计算。了解运算符的优先级,有助于我们编写出正确、高效的代码。

以下是一些常见运算符的优先级(从高到低):

  • 括号:( )
  • 单目运算符:++、–、!、+、-、~、typeof等
  • 算术运算符:*、/、%、+、-等
  • 比较运算符:<、>、<=、>=、in、instanceof等
  • 相等运算符:==、!=、===、!==等
  • 逻辑运算符:&&、||等
  • 赋值运算符:=、+=、-=等

掌握了这些运算符及其优先级,我们就可以根据实际需求灵活运用,编写出更加高效、简洁的代码。

通过了解JavaScript中的运算符及其优先级,我们可以更好地编写和理解代码。掌握这些知识,你将能更加自如地操纵数据,实现你想要的功能。

收起阅读 »

揭秘JavaScript数据世界:一文通晓基本类型和引用类型的精髓!

在编程的世界里,数据是构建一切的基础。就像建筑师需要了解不同材料的强度和特性一样,程序员也必须熟悉各种数据类型。今天,我们就来深入探讨JavaScript中的数据类型,看看它们如何塑造我们的代码世界。一、JavaScript数据类型简介数据类型是计算机语言的基...
继续阅读 »

在编程的世界里,数据是构建一切的基础。就像建筑师需要了解不同材料的强度和特性一样,程序员也必须熟悉各种数据类型。

今天,我们就来深入探讨JavaScript中的数据类型,看看它们如何塑造我们的代码世界。

一、JavaScript数据类型简介

数据类型是计算机语言的基础知识,数据类型广泛用于变量、函数参数、表达式、函数返回值等场合。JavaScript语言的每一个值,都属于某一种数据类型。

Description

JavaScript的数据类型主要分为两大类:基本数据类型引用数据类型。下面就来详细介绍这两类数据类型中都包含哪些及如何使用它们。

二、基本(值类型)数据类型

首先,让我们从最基本的数据类型开始。JavaScript的基本数据类型包括:字符串(String)、数字(Number)、布尔(Boolean)、空(Null)、未定义(Undefined)、符号(Symbol)。

1、字符串(String)

tring类型用于表示由零或多个16位的Unicode字符组成的字符序列,即字符串。至于用单引号,还是双引号,在js中还是没有差别的。记得成对出现。

let name1 = '张三'
let name2 = "李四"
let name3 = `王五`

1.转换为字符串有2个方法:toString()、String()
let n = 100
n.toString() // '100' 数值类型转换为字符串类型
String(200) // '200' 数值类型转换为字符串类型

2.模板字符串相当于加强版的字符串,可以定义多行字符串。还可以利用${}在字符串中插入变量和表达式
let name = '张三丰'
let age = 180
`我叫${name},今年${age}岁啦!` // 我叫张三丰,今年180岁啦!

2、数字(Number)

该类型的表示方法有两种形式,第一种是整数,第二种为浮点数。整数:可以通过十进制,八进制,十六进制的字面值来表示。

浮点数:就是该数值中必须包含一个小数点,且小数点后必须有一位数字。

let num = 100  // 整数
let floatNum = 3.14 // 浮点数
// toFixed() 方法可以对计算结果进行四舍五入
let pi = Math.PI // 3.141592653589793
pi.toFixed(2) // 3.14 保留2位小数

// 八进制的值第一位必须是零0,后面每一位数的范围在0~7。如果某一位数超出范围,首位的0会被忽略,后面的数值会按照十进制来解析
let octalNum1 = 076 // 八进制的 63
let octalNum2 = 083 // 八进制 83
let octalNum3 = 06 // 八进制 6

// 十六进制的值前两位必须是0x,后面每一位十六进制数字的范围在0~9及A~F,字母A~F可以大写也可以小写。
let hexNum1 = 0xA // 十六进制 10
let hexNum2 = 0x3f // 十六进制 63

// 数值转换的三个方法 Number()、parseInt()、parseFloat()

1.Number() // 可以将字符串、布尔值、null、undefined 等转换为对应的数值,如果无法转换返回NaN
Number("123") // 输出123
Number("hello") // 输出NaN


2.parseInt() // 可以将字符串转换为整数,如果无法转换返回NaN
parseInt("123") // 输出123
parseInt("123.45") // 输出:123
parseInt("hello") // 输出NaN


3.parseFloat() // 可以将字符串转换为浮点数,如果无法转换返回NaN
parseFloat("123.45") // 输出123.45
parseFloat("hello") // 输出NaN

3、布尔(Boolean)

Boolean 数据类型只有两个值:true 和 false,分别代表真和假。很多时候我们需要将各种表达式和变量转换成 Boolean 数据类型来当作判断条件。


1.数值运算判断

1 + 2 === 3 // true
1 + 1 > 3 // false


2.数值类型转换
let bool1 = Boolean(0); // 数值转换为布尔值
let bool2 = Boolean(""); // 字符串转换为布尔值
let bool3 = Boolean(null); // null 转换为布尔值
let bool4 = Boolean(undefined); // undefined 转换为布尔值
let bool5 = Boolean(NaN); // NaN 转换为布尔值
let bool6 = Boolean([]); // 空数组转换为布尔值
let bool7 = Boolean({}); // 空对象转换为布尔值

ECMAScript 类型的值都有与布尔值等价的形式。可以调用 Boolean() 函数来将其他类型转换为布尔值。不同类型转换为布尔值的规则如下表

Description

4、未定义(Undefined)

在 JavaScript 中,undefined 是一个特殊的值和数据类型。当一个变量声明但未赋值时,该变量的值就是 undefined。它表示一个未定义或未初始化的值。

1.声明但未赋值的变量

// 当使用 var、let 或 const 声明一个变量但未对其赋值时,该变量的初始值为 undefined。
let n;
console.log(n) // 输出 undefined


2.未定义的属性

// 当访问一个不存在的属性时,该属性的值为undefined
let obj = { name: '张三丰' }
console.log(obj.age) // 输出 undefined


3.函数没有返回值

// 如果函数没有明确返回值或者使用 return 语句返回一个未定义的值,函数的返回值将是 undefined
function getName() {
// 没有返回值
}
console.log(foo()) // 输出 undefined


4.函数参数未传递

// 如果函数定义了参数但未传递相应的值,那么该参数的值将是 undefined
function getName(name) {
console.log("Hello, " + name)
}
getName() // 输出:Hello, undefined

5、空(Null)

在 JavaScript 中,null 是一个特殊的值和数据类型。它表示一个空值或者不存在的对象。

与undefined不同,null是JavaScript 保留关键字,而 undefined 只是一个常量。也就是说可以声明名称为 undefined 的变量,但将 null 作为变量使用时则会报错。

1.空值

// null 表示一个空值,用于表示变量的值为空
let name = null
console.log(name) // 输出 null


2.不存在的对象

// 当使用 typeof 运算符检测一个值为 null 的对象时,会返回 "object"
let obj = null
console.log(typeof obj) // 输出:object

null 与 undefined 区别

  • undefined 是表示一个未定义或未初始化的值,常用于声明但未赋值的变量,或者访问不存在的属性。

  • null 是一个被赋予的值,用于表示变量被故意赋值为空。

  • 在判断变量是否为空时,使用严格相等运算符(===),因为 undefined 和 null 在非严格相等运算符(==)下会相等。

let x;
let y = null;
console.log(x === undefined) // 输出:true
console.log(x === null) // 输出:false
console.log(y === null) // 输出:true
console.log(y === undefined) // 输出:false

6、符号(Symbol)

符号 (Symbols) 是 ECMAScript 第 6 版新定义的。符号类型是唯一的并且是不可修改的。

1.创建Symbol

// 使用全局函数 Symbol() 可以创建一个唯一的 Symbol 值
let s = Symbol()
console.log(typeof s) // 输出 symbol


2.唯一性

// 每个通过 Symbol() 创建的 Symbol 值都是唯一的,不会与其他 Symbol 值相等,即使它们的描述相同
let s1 = Symbol()
let s2 = Symbol()
console.log(s1 == s2) // 输出 false
let s3 = Symbol('hello')
let s4 = Symbol('hello')
console.log(s3 == s4) // 输出 false


3.Symbol 常量

// 通过 Symbol.for() 方法可以创建全局共享的 Symbol 值,称为 Symbol 常量
let s5 = Symbol.for('key')
let s6 = Symbol.for('key')
console.log(s5 === s6) // 输出 true

Symbol 的主要作用是创建独一无二的标识符,用于定义对象的属性名或者作为一些特殊的标记。它在一些特定的应用场景中非常有用,如在迭代器和生成器中使用 Symbol.iterator 标识可迭代对象。

三、引用数据类型

除了基本数据类型,JavaScript还有引用数据类型:对象(Object)、数组(Array)和函数(Function)

1、对象(Object)

Object 是一个内置的基本数据类型和构造函数。是一组由键、值组成的无序集合,定义对象类型需要使用花括号{ },它是 JavaScript 中最基本的对象类型,也是其他对象类型的基础。

1.创建对象

// Object 类型可以用于创建新的对象。可以使用对象字面量 {} 或者通过调用 Object() 构造函数来创建对象
let obj1 = {} // 使用对象字面量创建空对象
let obj2 = new Object() // 使用 Object() 构造函数创建空对象


2.添加、修改、删除属性

let obj = {}
obj.name = '张三丰' // 添加属性
obj.age = 30 // 添加属性
obj.name = '张无忌' // 修改属性
delete obj.age // 删除属性

2、数组(Array)

JavaScript 中,数组(Array)是一组按顺序排列的数据的集合,数组中的每个值都称为元素,而且数组中可以包含任意类型的数据。

在 JavaScript 中定义数组需要使用方括号[ ],数组中的每个元素使用逗号进行分隔。

数组的特点有哪些?

  • 有序集合: 数组是一种有序的数据集合,每个元素在数组中都有一个对应的索引,通过索引可以访问和操作数组中的元素。

  • 可变长度: 数组的长度是可变的,可以根据需要动态添加或删除元素,或者修改数组的长度。可以使用 push()、pop()、shift()、unshift() 等方法来添加或删除元素,也可以直接修改数组的 length 属性来改变数组的长度。

  • 存储不同类型的值: 数组可以存储任意类型的值,包括基本类型和对象类型。同一个数组中可以混合存储不同类型的值。

  • 索引访问: 通过索引来访问数组中的元素,索引从 0 开始。可以使用方括号语法 [] 或者点号语法 . 来访问数组的元素。

  • 内置方法: 数组提供了许多内置的方法,用于对数组进行常见的操作和处理,如添加、删除、查找、排序、遍历等。常用的数组方法包括 push()、pop()、shift()、unshift()、concat()、slice()、splice()、indexOf()、forEach()、map()、filter()、reduce() 等。

  • 可迭代性: 数组是可迭代的,可以使用 for…of 循环或者 forEach() 方法遍历数组中的元素。

1.创建数组

// 可以使用数组字面量 [] 或者通过调用 Array() 构造函数来创建数组。
let arr1 = [] // 使用数组字面量创建空数组
let arr2 = new Array() // 使用 Array() 构造函数创建空数组
let arr3 = [1, 2, 3] // 使用数组字面量创建包含初始值的数组


2.访问和修改数组元素

// 数组的元素通过索引访问,索引从 0 开始。可以使用索引来读取或修改数组的元素。
let arr = [1, 2, 3]
console.log(arr[0]) // 访问数组的第一个元素,输出:1
arr[1] = 5 // 修改数组的第二个元素
arr.length // 获取数组长度,输出:3

3、函数(Function)

ECMAScript中的函数是对象,与其他引用类型一样具有属性和方法。因此,函数名实际是一个指向函数对象的指针。

1.创建函数

// 可以使用函数声明或函数表达式来创建函数。函数声明使用 function 关键字,后面跟着函数名称和函数体,而函数表达式将函数赋值给一个变量。
// 函数声明
function add(a, b) {
return a + b
}

// 函数表达式
let multiply = function(a, b) {
return a * b
}


2.函数调用

// 函数可以通过函数名后面加括号 () 进行调用。调用函数时,可以传递参数给函数,函数可以接收参数并进行相应的处理。
let result = add(3, 5) // 调用 add 函数并传递参数
console.log(result) // 输出:8


3.函数返回值

// 函数可以使用 return 语句返回一个值,也可以不返回任何值。当函数执行到 return 语句时,会立即停止执行,并将返回值传递给函数调用者。
function calculateSum(a, b) {
return a + b
}
let result = calculateSum(2, 3)
console.log(result) // 输出:5


4.函数作用域

// 函数作用域是指函数内部声明的变量在函数内部有效,外部无法访问。函数内部定义的变量只能在函数内部被访问和使用,在函数外部是不可见的。

function myFunction() {
var x = 10 // 局部变量
console.log(x) // 在函数内部可见
}
myFunction() // 输出:10
console.log(x) // 报错:x is not defined

此外,JavaScript还有一些特殊的数据类型,如Date(表示日期和时间)、RegExp(表示正则表达式),以及ES6新增的Map、Set、WeakMap和WeakSet,用于存储特定类型的数据。


想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!点这里前往学习哦!


四、数据类型检测

检测数据类型可以使用typeof操作符,它可以检测基本数据类型和function,但无法区分不同的引用数据类型。

var arr = [
null, // object
undefined, // undefined
true, // boolean
12, // number
'haha', // string
Symbol(), // symbol
20n, // bigint
function(){}, // function
{}, // object
[], // object
]
for (let i = 0; i < arr.length; i++) {
console.log(typeof arr[i])
}

掌握JavaScript数据类型是成为一名高效开发者的关键。它们是构建程序的砖石,理解它们的用法和限制将使你能够构建更稳健、更可维护的代码。

现在,你已经了解了JavaScript的数据类型,是时候在你的代码中运用这些知识了。记住,实践是学习的最佳方式,所以动手尝试吧!

如果觉得本文对你有所帮助,别忘了点赞和分享哦!

收起阅读 »

商品 sku 在库存影响下的选中与禁用

web
分享一下,最近使用 React 封装的一个 Skus 组件,主要用于处理商品的sku在受到库存的影响下,sku项的选中和禁用问题; 需求分析 需要展示商品各规格下的sku信息,以及根据该sku的库存是否为空,判断是否禁用该sku的选择。 以下讲解将按照我的 ...
继续阅读 »

分享一下,最近使用 React 封装的一个 Skus 组件,主要用于处理商品的sku在受到库存的影响下,sku项的选中和禁用问题;


需求分析


需要展示商品各规格下的sku信息,以及根据该sku的库存是否为空,判断是否禁用该sku的选择。


sku-2.gif

以下讲解将按照我的 Skus组件 来,我这里放上我组件库中的线上 demo 和码上掘金的一个 demo 供大家体验;由于码上掘金导入不了组件库,我就上传了一份开发组件前的一份类似的代码,功能和代码思路是差不多的,大家也可以自己尝试写一下,可能你的思路会更优;


线上 Demo 地址


码上掘金



传入的sku数据结构


需要传入的商品的sku数据类型大致如下:


type SkusProps = { 
/** 传入的skus数据列表 */
data: SkusItem[]
// ... 其他的props
}

type SkusItem = {
/** 库存 */
stock?: number;
/** 该sku下的所有参数 */
params: SkusItemParam[];
};

type SkusItemParam = {
name: string;
value: string;
}

转化成需要的数据类型:


type SkuStateItem = {
value: string;
/** 与该sku搭配时,该禁用的sku组合 */
disabledSkus: string[][];
}[];

生成数据


定义 sku 分类


首先假装请求接口,造一些假数据出来,我这里自定义了最多 6^6 = 46656 种 sku。


sku-66.gif

下面的是自定义的一些数据:


const skuData: Record<string, string[]> = {
'颜色': ['红','绿','蓝','黑','白','黄'],
'大小': ['S','M','L','XL','XXL','MAX'],
'款式': ['圆领','V领','条纹','渐变','轻薄','休闲'],
'面料': ['纯棉','涤纶','丝绸','蚕丝','麻','鹅绒'],
'群体': ['男','女','中性','童装','老年','青少年'],
'价位': ['<30','<50','<100','<300','<800','<1500'],
}
const skuNames = Object.keys(skuData)

页面初始化



  • checkValArr: 需要展示的sku分类是哪些;

  • skusList: 接口获取的skus数据;

  • noStockSkus: 库存为零对应的skus(方便查看)。


export default () => {
// 这个是选中项对应的sku类型分别是哪几个。
const [checkValArr, setCheckValArr] = useState<number[]>([4, 5, 2, 3, 0, 0]);
// 接口请求到的skus数据
const [skusList, setSkusList] = useState<SkusItem[]>([]);
// 库存为零对应的sku数组
const [noStockSkus, setNoStockSkus] = useState<string[][]>([])

useEffect(() => {
const checkValTrueArr = checkValArr.filter(Boolean)
const _noStockSkus: string[][] = [[]]
const list = getSkusData(checkValTrueArr, _noStockSkus)
setSkusList(list)
setNoStockSkus([..._noStockSkus])
}, [checkValArr])

// ....

return <>...</>
}

根据上方的初始化sku数据,生成一一对应的sku,并随机生成对应sku的库存。


getSkusData 函数讲解


先看总数(total)为当前需要的各sku分类的乘积;比如这里就是上面传入的 checkValArr 数组 [4,5,2,3]120种sku选择。对应的就是 skuData 中的 [颜色前四项,大小前五项,款式前两项,面料前三项] 即下图的展示。


image.png

遍历 120 次,每次生成一个sku,并随机生成库存数量,40%的概率库存为0;然后遍历 skuNames 然后找到当前对应的sku分类即 [颜色,大小,款式,面料] 4项;


接下来就是较为关键的如何根据 sku的分类顺序 生成对应的 120个相应的sku。


请看下面代码中注释为 LHH-1 的地方,该 value 的获取是通过 indexArr 数组取出来的。可以看到上面 indexArr 数组的初始值为 [0,0,0,0] 4个零的索引,分别对应 4 个sku的分类;



  • 第一次遍历:


indexArr: [0,0,0,0] -> skuName.forEach -> 红,S,圆领,纯棉


看LHH-2标记处: 索引+1 -> indexArr: [0,0,0,1];



  • 第二次遍历:


indexArr: [0,0,0,1] -> skuName.forEach -> 红,S,圆领,涤纶


看LHH-2标记处: 索引+1 -> indexArr: [0,0,0,2];



  • 第三次遍历:


indexArr: [0,0,0,2] -> skuName.forEach -> 红,S,圆领,丝绸


看LHH-2标记处: 由于已经到达该分类下的最后一个,所以前一个索引加一,后一个重新置为0 -> indexArr: [0,0,1,0];



  • 第四次遍历:


indexArr: [0,0,1,0] -> skuName.forEach -> 红,S,V领,纯棉


看LHH-2标记处: 索引+1 -> indexArr: [0,0,1,1];



  • 接下来的一百多次遍历跟上面的遍历同理


image.png
function getSkusData(skuCategorys: number[], noStockSkus?: string[][]) {
// 最终生成的skus数据;
const skusList: SkusItem[] = []
// 对应 skuState 中各 sku ,主要用于下面遍历时,对 product 中 skus 的索引操作
const indexArr = Array.from({length: skuCategorys.length}, () => 0);
// 需要遍历的总次数
const total = skuCategorys.reduce((pre, cur) => pre * (cur || 1), 1)
for(let i = 1; i <= total; i++) {
const sku: SkusItem = {
// 库存:60%的几率为0-50,40%几率为0
stock: Math.floor(Math.random() * 10) >= 4 ? Math.floor(Math.random() * 50) : 0,
params: [],
}
// 生成每个 sku 对应的 params
let skuI = 0;
skuNames.forEach((name, j) => {
if(skuCategorys[j]) {
// 注意:LHH-1
const value = skuData[name][indexArr[skuI]]
sku.params.push({
name,
value,
})
skuI++;
}
})
skusList.push(sku)

// 注意: LHH-2
indexArr[indexArr.length - 1]++;
for(let j = indexArr.length - 1; j >= 0; j--) {
if(indexArr[j] >= skuCategorys[j] && j !== 0) {
indexArr[j - 1]++
indexArr[j] = 0
}
}

if(noStockSkus) {
if(!sku.stock) {
noStockSkus.at(-1)?.push(sku.params.map(p => p.value).join(' / '))
}
if(indexArr[0] === noStockSkus.length && noStockSkus.length < skuCategorys[0]) {
noStockSkus.push([])
}
}
}
return skusList
}

Skus 组件的核心部分的实现


初始化数据


需要将上面生成的数据转化为以下结构:


type SkuStateItem = {
value: string;
/** 与该sku搭配时,该禁用的sku组合 */
disabledSkus: string[][];
}[];

export default function Skus() {
// 转化成遍历判断用的数据类型
const [skuState, setSkuState] = useState<Record<string, SkuStateItem>>({});
// 当前选中的sku值
const [checkSkus, setCheckSkus] = useState<Record<string, string>>({});

// ...
}

将初始sku数据生成目标结构


根据 data (即上面的假数据)生成该数据结构。


第一次遍历是对skus第一项进行的,会生成如下结构:


const _skuState = {
'颜色': [{value: '红', disabledSkus: []}],
'大小': [{value: 'S', disabledSkus: []}],
'款式': [{value: '圆领', disabledSkus: []}],
'面料': [{value: '纯棉', disabledSkus: []}],
}

第二次遍历则会完整遍历剩下的skus数据,并往该对象中填充完整。


export default function Skus() {
// ...
useEffect(() => {
if(!data?.length) return
// 第一次对skus第一项的遍历
const _checkSkus: Record<string, string> = {}
const _skuState = data[0].params.reduce((pre, cur) => {
pre[cur.name] = [{value: cur.value, disabledSkus: []}]
_checkSkus[cur.name] = ''
return pre
}, {} as Record<string, SkuStateItem>)
setCheckSkus(_checkSkus)

// 第二次遍历
data.slice(1).forEach(item => {
const skuParams = item.params
skuParams.forEach((p, i) => {
// 当前 params 不在 _skuState 中
if(!_skuState[p.name]?.find(params => params.value === p.value)) {
_skuState[p.name].push({value: p.value, disabledSkus: []})
}
})
})

// ...接下面
}, [data])
}

第三次遍历主要用于为每个 sku的可点击项 生成一个对应的禁用sku数组 disabledSkus ,只要当前选择的sku项,满足该数组中的任一项,该sku选项就会被禁用。之所以保存这样的一个二维数组,是为了方便后面点击时的条件判断(有点空间换时间的概念)。


遍历 data 当库存小于等于0时,将当前的sku的所有参数传入 disabledSkus 中。


例:第一项 sku(红,S,圆领,纯棉)库存假设为0,则该选项会被添加到 disabledSkus 数组中,那么该sku选择时,勾选前三个后,第四个 纯棉 的勾选会被禁用。


image.png
export default function Skus() {
// ...
useEffect(() => {
// ... 接上面
// 第三次遍历
data.forEach(sku => {
// 遍历获取库存需要禁用的sku
const stock = sku.stock!
// stockLimitValue 是一个传参 代表库存的限制值,默认为0
// isStockGreaterThan 是一个传参,用来判断限制值是大于还是小于,默认为false
if(
typeof stock === 'number' &&
isStockGreaterThan ? stock >= stockLimitValue : stock <= stockLimitValue
) {
const curSkuArr = sku.params.map(p => p.value)
for(const name in _skuState) {
const curSkuItem = _skuState[name].find(v => curSkuArr.includes(v.value))
curSkuItem?.disabledSkus?.push(
sku.params.reduce((pre, p) => {
if(p.name !== name) {
pre.push(p.value)
}
return pre
}, [] as string[])
)
}
}
})

setSkuState(_skuState)
}, [data])
}

遍历渲染 skus 列表


根据上面的 skuState,生成用于渲染的列表,渲染列表的类型如下:


type RenderSkuItem = {
name: string;
values: RenderSkuItemValue[];
}
type RenderSkuItemValue = {
/** sku的值 */
value: string;
/** 选中状态 */
isChecked: boolean
/** 禁用状态 */
disabled: boolean;
}

export default function Skus() {
// ...
/** 用于渲染的列表 */
const list: RenderSkuItem[] = []
for(const name in skuState) {
list.push({
name,
values: skuState[name].map(sku => {
const isChecked = sku.value === checkSkus[name]
const disabled = isChecked ? false : isSkuDisable(name, sku)
return { value: sku.value, disabled, isChecked }
})
})
}
// ...
}

html css 大家都会,以下就简单展示了。最外层遍历sku的分类,第二次遍历遍历每个sku分类下的名称,第二次遍历的 item(类型为:RenderSkuItemValue),里面会有sku的值,选中状态和禁用状态的属性。


export default function Skus() {
// ...
return list?.map((p) => (
<div key={p.name}>
{/* 例:颜色、大小、款式、面料 */}
<div>{p.name}</div>
<div>
{p.values.map((sku) => (
<div
key={p.name + sku.value}
onClick={() =>
selectSkus(p.name, sku)}
>
{/* classBem 是用来判断当前状态,增加类名的一个方法而已 */}
<span className={classBem(`sku`, {active: sku.isChecked, disabled: sku.disabled})}>
{/* 例:红、绿、蓝、黑 */}
{sku.value}
</span>
</div>
))}
</div>
</div>

))
}

selectSkus 点击选择 sku


通过 checkSkus 设置 sku 对应分类下的 sku 选中项,同时触发 onChange 给父组件传递一些信息出去。


const selectSkus = (skuName: string, {value, disabled, isChecked}: RenderSkuItemValue) => {
const _checkSkus = {...checkSkus}
_checkSkus[skuName] = isChecked ? '' : value;
const curSkuItem = getCurSkuItem(_checkSkus)
// 该方法主要是 sku 组件点击后触发的回调,用于给父组件获取到一些信息。
onChange?.(_checkSkus, {
skuName,
value,
disabled,
isChecked: disabled ? false : !isChecked,
dataItem: curSkuItem,
stock: curSkuItem?.stock
})
if(!disabled) {
setCheckSkus(_checkSkus)
}
}

getCurSkuItem 获取当前选中的是哪个sku



  • isInOrder.current 是用来判断当前的 skus 数据是否是整齐排列的,这里当成 true 就好,判断该值的过程就不放到本文了,感兴趣可以看 源码


由于sku是按顺序排列的,所以只需按顺序遍历上面生成的 skuState,找出当前sku选中项对应的索引位置,然后通过 就可以直接得出对应的索引位置。这样的好处是能减少很多次遍历。


如果直接遍历原来那份填充所有 sku 的 data 数据,则需要很多次的遍历,当sku是 6^6 时, 则每次变换选中的sku时最多需要 46656 * 6 (data总长度 * 里面 sku 的 params) 次。


const getCurSkuItem = (_checkSkus: Record<string, string>) => {
const length = Object.keys(skuState).length
if(!length || Object.values(_checkSkus).filter(Boolean).length < length) return void 0
if(isInOrder.current) {
let skuI = 0;
// 由于sku是按顺序排列的,所以索引可以通过计算得出
Object.keys(_checkSkus).forEach((name, i) => {
const index = skuState[name].findIndex(v => v.value === _checkSkus[name])
const othTotal = Object.values(skuState).slice(i + 1).reduce((pre, cur) => (pre *= cur.length), 1)
skuI += index * othTotal;
})
return data?.[skuI]
}
// 这样需要遍历太多次
return data.find(s => (
s.params.every(p => _checkSkus[p.name] === getSkuParamValue(p))
))
}

isSkuDisable 判断该 sku 是否是禁用的


该方法是在上面 遍历渲染 skus 列表 时使用的。



  1. 开始还未有选中值时,需要校验 disabledSkus 的数组长度,是否等于该sku参数可以组合的sku总数,如果相等则表示禁用。

  2. 判断当前选中的 sku 还能组成多少种组合。例:当前选中 红,S ,而 isSkuDisable 方法当前判断的 sku 为 款式 中的 圆领,则还有三种组合 红\S\圆领\纯棉红\S\圆领\涤纶红\S\圆领\丝绸

  3. 如果当前判断的 sku 的 disabledSkus 数组中存在这三项,则表示该 sku 选项会被禁用,无法点击。


const isCheckValue = !!Object.keys(checkSkus).length

const isSkuDisable = (skuName: string, sku: SkuStateItem[number]) => {
if(!sku.disabledSkus.length) return false
// 1.当一开始没有选中值时,判断某个sku是否为禁用
if(!isCheckValue) {
let checkTotal = 1;
for(const name in skuState) {
if(name !== skuName) {
checkTotal *= skuState[name].length
}
}
return sku.disabledSkus.length === checkTotal
}

// 排除当前的传入的 sku 那一行
const newCheckSkus: Record<string, string> = {...checkSkus}
delete newCheckSkus[skuName]

// 2.当前选中的 sku 一共能有多少种组合
let total = 1;
for(const name in newCheckSkus) {
if(!newCheckSkus[name]) {
total *= skuState[name].length
}
}

// 3.选中的 sku 在禁用数组中有多少组
let num = 0;
for(const strArr of sku.disabledSkus) {
if(Object.values(newCheckSkus).every(str => !str ? true : strArr.includes(str))) {
num++;
}
}

return num === total
}

至此整个商品sku从生成假数据到sku的选中和禁用的处理的核心代码就完毕了。还有更多的细节问题可以直接查看 源码 会更清晰。


作者:滑动变滚动的蜗牛
来源:juejin.cn/post/7313979106890842139
收起阅读 »

实现抖音 “视频无限滑动“效果

web
前言 在家没事的时候刷抖音玩,抖音首页的视频怎么刷也刷不完,经常不知不觉的一刷就到半夜了😅 不禁感叹道 "垃圾抖音,费我时间,毁我青春😅" 这是我的 模仿抖音 系列文章的第二篇,本文将一步步实现抖音首页 视频无限滑动 的效果,干货满满。 如果您对滑动原理不太熟...
继续阅读 »

前言


在家没事的时候刷抖音玩,抖音首页的视频怎么刷也刷不完,经常不知不觉的一刷就到半夜了😅

不禁感叹道 "垃圾抖音,费我时间,毁我青春😅"


这是我的 模仿抖音 系列文章的第二篇,本文将一步步实现抖音首页 视频无限滑动 的效果,干货满满。


如果您对滑动原理不太熟悉,推荐先看我的这篇文章:200行代码实现类似Swiper.js的轮播组件


最终效果


在线预览:zyronon.gitee.io/douyin/


Github地址:github.com/zyronon/dou…


源码:SlideVerticalInfinite.vue


实现原理


无限滑动的原理和虚拟滚动的原理差不多,要保持 SlideList 里面永远只有 NSlideItem,就要在滑动时不断的删除和增加 SlideItem

滑动时调整 SlideList 的偏移量 translateY 的值,以及列表里那几个 SlideItemtop 值,就可以了


为什么要调整 SlideList 的偏移量 translateY 的值同时还要调整 SlideItemtop 值呢?

因为 translateY 只是将整个列表移动,如果我们列表里面的元素是固定的,不会变多和减少,那么没关系,只调整 translateY 值就可以了,上滑了几页就减几页的高度,下滑同理


但是如果整个列表向前移动了一页,同时前面的 SlideItem 也少了一个,,那么最终效果就是移动了两页...因为 塌陷 了一页

这显然不是我们想要的,所以我们还需要同时调整 SlideItemtop 值,加上前面少的 SlideItem 的高度,这样才能显示出正常的内容


步骤


定义




virtualTotal:页面中同时存在多少个 SlideItem,默认为 5


//页面中同时存在多少个SlideItem
virtualTotal: {
type: Number,
default: () => 5
},

设置这个值可以让外部组件使用时传入,毕竟每个人的需求不同,有的要求同时存在 10 条,有的要求同时存在 5 条即可。

不过同时存在的数量越大,使用体验就越好,即使用户快速滑动,我们依然有时间处理。

如果只同时存在 5 条,用户只需要快速滑动两次就到底了(因为屏幕中显示第 3 条,刚开始除外),我们可能来不及添加新的视频到最后




render:渲染函数,SlideItem内显示什么由render返回值决定


render: {
type: Function,
default: () => {
return null
}
},

之所以要设定这个值,是因为抖音首页可不只有视频,还有图集、推荐用户、广告等内容,所以我们不能写死显示视频。

最好是定义一个方法,外部去实现,我们内部去调用,拿到返回值,添加到 SlideList




list:数据列表,外部传入


list: {
type: Array,
default: () => {
return []
}
},

我们从 list 中取出数据,然后调用并传给 render 函数,将其返回值插入到 SlideList中


初始化



watch(
() => props.list,
(newVal, oldVal) => {
//新数据长度比老数据长度小,说明是刷新
if (newVal.length < oldVal.length) {
//从list中取出数据,然后调用并传给render函数,将其返回值插入到SlideList中
insertContent()
} else {
//没数据就直接插入
if (oldVal.length === 0) {
insertContent()
} else {
// 走到这里,说明是通过接口加载了下一页的数据,
// 为了在用户快速滑动时,无需频繁等待请求接口加载数据,给用户更好的使用体验
// 这里额外加载3条数据。所以此刻,html里面有原本的5个加新增的3个,一共8个dom
// 用户往下滑动时只删除前面多余的dom,等滑动到临界值(virtualTotal/2+1)时,再去执行新增逻辑
}
}
}
)

watch 监听 list 是因为它一开始不一定有值,通过接口请求之后才有值

同时当我们下滑 加载更多 时,也会触发接口请求新的数据,用 watch 可以在有新数据时,多添加几条到 SlideList 的最后面,这样用户快速滑动也不怕了


如何滑动


这里就不再赘述,参考我的这篇文章:200行代码实现类似Swiper.js的轮播组件


滑动结束


判断滑动的方向


当我们向上滑动时,需要删除最前面的 dom ,然后在最后面添加一个 dom

下滑时反之


slideTouchEnd(e, state, canNext, (isNext) => {
if (props.list.length > props.virtualTotal) {
//手指往上滑(即列表展示下一条视频)
if (isNext) {
//删除最前面的 `dom` ,然后在最后面添加一个 `dom`
} else {
//删除最后面的 `dom` ,然后在最前面添加一个 `dom`
}
}
})

手指往上滑(即列表展示下一条视频)



  • 首先判断是否要加载更多,快到列表末尾时就要加载更多数据了

  • 再判断是否符合 腾挪 的条件,即当前位置要大于 half,且小于列表长度减 half

  • 在最后面添加一个 dom

  • 删除最前面的 dom

  • 将所有 dom 设置为最新的 top 值(原因前面有讲,因为删除了最前面的 dom,导致塌陷一页,所以要加上删除 dom 的高度)


let half = (props.virtualTotal - 1) / 2

//删除最前面的 `dom` ,然后在最后面添加一个 `dom`
if (state.localIndex > props.list.length - props.virtualTotal && state.localIndex > half) {
emit('loadMore')
}

//是否符合 `腾挪` 的条件
if (state.localIndex > half && state.localIndex < props.list.length - half) {
//在最后面添加一个 `dom`
let addItemIndex = state.localIndex + half
let res = slideListEl.value.querySelector(`.${itemClassName}[data-index='${addItemIndex}']`)
if (!res) {
slideListEl.value.appendChild(getInsEl(props.list[addItemIndex], addItemIndex))
}

//删除最前面的 `dom`
let index = slideListEl.value
.querySelector(`.${itemClassName}:first-child`)
.getAttribute('data-index')
appInsMap.get(Number(index)).unmount()

slideListEl.value.querySelectorAll(`.${itemClassName}`).forEach((item) => {
_css(item, 'top', (state.localIndex - half) * state.wrapper.height)
})
}

手指往下滑(即列表展示上一条视频)


逻辑和上滑都差不多,不过是反着来而已



  • 再判断是否符合 腾挪 的条件,和上面反着

  • 在最前面添加一个 dom

  • 删除最后面的 dom

  • 将所有 dom 设置为最新的 top


//删除最后面的 `dom` ,然后在最前面添加一个 `dom`
if (state.localIndex >= half && state.localIndex < props.list.length - (half + 1)) {
let addIndex = state.localIndex - half
if (addIndex >= 0) {
let res = slideListEl.value.querySelector(`.${itemClassName}[data-index='${addIndex}']`)
if (!res) {
slideListEl.value.prepend(getInsEl(props.list[addIndex], addIndex))
}
}
let index = slideListEl.value
.querySelector(`.${itemClassName}:last-child`)
.getAttribute('data-index')
appInsMap.get(Number(index)).unmount()

slideListEl.value.querySelectorAll(`.${itemClassName}`).forEach((item) => {
_css(item, 'top', (state.localIndex - half) * state.wrapper.height)
})
}

其他问题


为什么不直接用 v-for直接生成 SlideItem 呢?


如果内容不是视频就可以。要删除或者新增时,直接操作 list 数据源,这样省事多了


如果内容是视频,修改 list 时,Vue 会快速的替换 dom,正在播放的视频,突然一下从头开始播放了😅😅😅


如何获取 Vue 组件的最终 dom


有两种方式,各有利弊



  • Vuerender 方法

    • 优点:只是渲染一个 VNode 而已,理论上讲内存消耗更少。

    • 缺点:但我在开发中,用了这个方法,任何修改都会刷新页面,有点难蚌😅



  • VuecreateApp 方法再创建一个 Vue 的实例

    • 和上面相反😅




import { createApp, onMounted, reactive, ref, render as vueRender, watch } from 'vue'

/**
* 获取Vue组件渲染之后的dom元素
* @param item
* @param index
* @param play
*/

function getInsEl(item, index, play = false) {
// console.log('index', cloneDeep(item), index, play)
let slideVNode = props.render(item, index, play, props.uniqueId)
const parent = document.createElement('div')
//TODO 打包到线上时用这个,这个在开发时任何修改都会刷新页面
if (import.meta.env.PROD) {
parent.classList.add('slide-item')
parent.setAttribute('data-index', index)
//将Vue组件渲染到一个div上
vueRender(slideVNode, parent)
appInsMap.set(index, {
unmount: () => {
vueRender(null, parent)
parent.remove()
}
})
return parent
} else {
//创建一个新的Vue实例,并挂载到一个div上
const app = createApp({
render() {
return <SlideItem data-index={index}>{slideVNode}</SlideItem>
}
})
const ins = app.mount(parent)
appInsMap.set(index, app)
return ins.$el
}
}

总结


原理其实并不难。主要是一开始可能会用 v-for 去弄,折腾半天发现不行。v-for 不行,就只能想想怎么把 Vue 组件搞到 html 里面去,又去研究如何获取 Vue 组件的最终 dom,又查了半天资料,Vue 官方文档也不写,还得去翻 api ,麻了


结束



以上就是文章的全部内容,感谢看到这里,希望对你有所帮助或启发!创作不易,如果觉得文章写得不错,可以点赞收藏支持一下,也欢迎关注我的公众号 前端张余让,我会更新更多实用的前端知识与技巧,期待与你共同成长~



作者:前端张余让
来源:juejin.cn/post/7361614921519054883
收起阅读 »

微信小程序用户授权获取手机号流程

web
在做小程序开发的过程中,经常会涉及到用户身份的问题,最普遍的就是要获取用户的手机号码,通过微信获取手机号码后可以减少很多操作,比如用户手机号码验证等,以及给用户发送提示短信等等。 ※ 正常情况下,小程序可获取的公开信息有:昵称,城市,ip等公开信息,如果想要手...
继续阅读 »

在做小程序开发的过程中,经常会涉及到用户身份的问题,最普遍的就是要获取用户的手机号码,通过微信获取手机号码后可以减少很多操作,比如用户手机号码验证等,以及给用户发送提示短信等等。


※ 正常情况下,小程序可获取的公开信息有:昵称,城市,ip等公开信息,如果想要手机号等非公开信息,前提是需要已认证的非个人小程序账号。


小程序具有非常简洁的api,通过小程序内部封装,只要通过一个类型 调取他们的api方法,便可直接拉起授权。


接下来和大家聊聊,获取用户手机号码的api:getPhoneNumber


官方文档:developers.weixin.qq.com/miniprogram…


大致实现思路:


无标题.png


1687328383569.png


获取用户手机号码 分为以下几步:


第一步,点击页面获取授权按钮


第二步,获取用户授权参数


第三步,根据加解密算法解密手机号码


接下来我们来实现以上三步(包含前后端)


前端


代码:


<button open-type="getPhoneNumber" bindgetphonenumber="getPhoneNumber"></button>


只要在你的view里将此button放上,便可拉起授权。


分析:
open-type=“getPhoneNumber” 这个是官方给出的api。


bindgetphonenumber=“getPhoneNumber”,是调起授权框之后用户操作的回调 用户点击了拒绝还是接受,由此方法接收通知。


bindgetphonenumber:官方指定点击事件。 如果使用uniapp则需要改为@getphonenumber,并遵循uniapp开发规范。


至于getPhoneNumber


下面看代码


getPhoneNumber (e) {
var that = this;
if (e.detail.errMsg == 'getPhoneNumber:fail user deny') { //用户点击拒绝
wx.showToast({
title: '请绑定手机号',
duration: 5000,
icon: 'none',
});
} else {
}
}

getPhoneNumber:fail user deny 拒绝与否,这个是依据
※ 很多博客上写着 getPhoneNumber:user deny 缺少了fail 。


以下为获取手机号封装方法:


getPhoneNumber(e) {
var that = this;
wx.login({
success (res) {
if (res.code) {
console.log('步骤2获检查用户登录状态,获取用户电话号码!', res)
wx.request({
url: '这里写自己的获取授权的服务器地址',
data: {code: res.code},
header: {'content-type': 'application/json'},
success: function(res) {
console.log("步骤三获取授权码,获取授权openid,session_key",res);
var userphone=res.data.data;
wx.setStorageSync('userphoneKey',userphone);
//解密手机号
var msg = e.detail.errMsg;
var sessionID=wx.getStorageSync("userphoneKey").session_key;
var encryptedData=e.detail.encryptedData;
var iv=e.detail.iv;
if (msg == 'getPhoneNumber:ok') {//这里表示获取授权成功
wx.checkSession({
success:function(){
//这里进行请求服务端解密手机号
that.deciyption(sessionID,encryptedData,iv);
},
fail:function(){
// that.userlogin()
}
})
}

},fail:function(res){
console.log("fail",res);
}
})
} else {
console.log('登录失败!' + res.errMsg)
}
}
})

以上


e.detail.iv
e.detail.encryptedData


两个必传 传到后台 换取mobile


后端


不管是spring还是servlet只要请求能进到该方法即可,所以重点关注中间部分,把参数值传正确即可
1687329461422.png


工具类方法 WxUtil.doGetStr(url)


1687329536242.png


以上值可以返回给前端,前端可以收到三个参数:openid,session_key,expires_in。


接着我们通过授权之后,获取第三个参数iv,调用下面方法进行服务端解密


1687329672055.png


服务端解密代码参考:


1687329836867.png


deciphering解密方法参考:


1687329894328.png


以上


作者:SC前端开发
来源:juejin.cn/post/7246997498571554871
收起阅读 »

如何快速实现一个无缝轮播效果

web
需求简介 轮播图是我们前端开发中的一个常见需求,在项目开发中,我们可以使用element、ant等UI库实现。某些场景,为了一个简单的功能安装一个库是没必要的,我们最好的选择就是手搓。 我们来看一个需求 上述需求核心就是实现一个无缝轮播的切换效果。以这个需求...
继续阅读 »

需求简介


轮播图是我们前端开发中的一个常见需求,在项目开发中,我们可以使用element、ant等UI库实现。某些场景,为了一个简单的功能安装一个库是没必要的,我们最好的选择就是手搓。


我们来看一个需求



上述需求核心就是实现一个无缝轮播的切换效果。以这个需求为例,我们看看最终实现效果:



实现思路


要想实现一个无缝的轮播效果,其实非常简单,核心思想就是动态改变显示的列表而已。比如我们有这样一个数组


const list = ref([
{ name: 1, id: 1 },
{ name: 2, id: 2 },
{ name: 3, id: 3 }
])

如果我们想无缝切换的展示这个数据,最简单的代码就是动态的改变下面的代码的index


<template>
<div>
{{ list[index] }}
</div>

</template>
<script setup>
const index = ref(0)
const list = ref([{ name: 1, id: 1 }, { name: 2, id: 2 }, { name: 2, id: 2 }])
<scriptp>

那如何实现切换的样式呢?也非常简单,我们只要给元素添加一个出现样式和离开样式即可。现在,我们来具体实现这样一个需求。


技术方案


数据的动态切换


要想实现一个数据的动态循环切换效果,是非常容易的:


<template>
<div v-for="(build, index) in list" :key="index">
<div v-show="index === selectIndex">
卡片自定义内容
</div>
</div>

</template>
<script setup>
const selectIndex = ref(0)
const list = ref(
[{ name: "卡片1", id: 1 }, { name: "卡片1", id: 2 }, { name: "卡片1", id: 2 }]
)

// #计时器实例
let timer: any = null

// >计时器逻辑
const timeFuc = () => {
timer = setInterval(() => {
// 更改选中的index
if (selectIndex.value >= list.value.length - 1) {
selectIndex.value = 0
} else {
selectIndex.value++
}
}, 5000)
}

timeFuc()
<scriptp>

上述代码中,我们设置了一个定时器,定时器每5s执行一次,每次执行都会动态更改当前要显示的数据索引值,当索引值达到最大实,在将其重置。通过上述的简单代码,我们就实现了一个可以自动切换的循环渲染的卡片。


动画添加


要想实现最终效果的动态效果也非常容易,我们只需要给每个元素出现时设置一些样式,离开时设置一些样式即可。借助vue的Transition组件,我们能很容易实现这样一个效果。



如果你不了解vue的Transition组件,请去官网补充下知识:cn.vuejs.org/guide/built…



<template>
<div class="main-content">
<Transition v-for="(build, index) in list" :key="selectIndex">
<div class="banner-scroll-wrap" v-show="index === selectIndex">
卡片自定义内容
</div>
</Transition>
</div>

</template>
<script setup>
const selectIndex = ref(0)
const list = ref(
[{ name: "卡片1", id: 1 }, { name: "卡片1", id: 2 }, { name: "卡片1", id: 2 }]
)

// #计时器实例
let timer: any = null

// >计时器逻辑
const timeFuc = () => {
timer = setInterval(() => {
// 更改选中的index
if (selectIndex.value >= list.value.length - 1) {
selectIndex.value = 0
} else {
selectIndex.value++
}
}, 5000)
}

timeFuc()
<scriptp>
<style lang="less" scoped>
.main-content {
position: relative;
height: 100%;
.banner-scroll-wrap {
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
}
}

.v-enter-from {
transform: translateX(100%);
opacity: 0;
}

.v-enter-active,
.v-leave-active {
transition: transform 600ms ease-in-out, opacity 600ms ease-in-out;
}

.v-enter-to {
transform: translateX(0);
opacity: 1;
}

.v-leave-from {
transform: translateX(0);
opacity: 1;
}

.v-leave-to {
transform: translateX(-100%);
opacity: 0;
}
<style/>

上述代码中,由于 selectIndex是动态的,元素不断地在显示与隐藏。因此,Transition标签的进入样式和离开样式会动态触发,从而形成切换效果。



v-enter是元素的进入样式,进入时,我们从最右边偏移100%的距离到正常位置,透明度从0到1,这个过程持续0.6s,实现了元素左移淡入的效果。




v-leave是元素的离开样式,离开时,我们从正常位置偏移到100%的左侧位置,透明度从1到0,这个过程持续0.6s,实现了元素左移淡出的效果。



在这些类的共同作用下,我们实现了元素的动态切换。


你可能注意到了我给元素设置了一个banner-scroll-wrap类名,并使用了position: absolute,这样设置的注意目的是保证切换离开元素的淡出效果和进入元素的淡入效果是连贯的。如果你不这样写,可能会出现样式问题。


此外,注意我给Transition设置了key="Transition",这样些会保证每次数据在切换时,transition能够重新渲染,触发元素离开和进入的样式。


至此,我们就完成了基本功能样式



轮播的停止与恢复


很常见的一种情况就是我们需要鼠标放在卡片上时停止轮播,离开卡片的时候恢复轮播,这非常容易。


<template>
<div class="main-content" @mouseenter="stop()" @mouseleave="start()">
<Transition v-for="(build, index) in list" :key="selectIndex">
<div class="banner-scroll-wrap" v-show="index === selectIndex">
卡片自定义内容
</div>
</Transition>
</div>

</template>
<script setup>
const selectIndex = ref(0)
const list = ref(
[{ name: "卡片1", id: 1 }, { name: "卡片1", id: 2 }, { name: "卡片1", id: 2 }]
)

// #计时器实例
let timer: any = null

// >计时器逻辑
const timeFuc = () => {
timer = setInterval(() => {
// 更改选中的index
if (selectIndex.value >= list.value.length - 1) {
selectIndex.value = 0
} else {
selectIndex.value++
}
}, 5000)
}

// >开启轮播
const start = () => {
if (timer) return
timeFuc()
}

// >关闭轮播
const stop = () => {
clearInterval(timer)
timer = null
}

timeFuc()
<scriptp>
<style lang="less" scoped>
<style/>

解决重影问题


在某些情况下,我们离开这个页面很久后(浏览器切换到其他选项卡),然后在切回来的时候,可能会出现短暂的画面重影问题,这个问题也很好解决,加上下面的代码即可


<script setup>

//...

// 解决切屏后重影的问题
onMounted(() => {
document.addEventListener('visibilitychange', () => {
// 用户息屏、或者切到后台运行 (离开页面)
if (document.visibilityState === 'hidden') {
stop()
}
// 用户打开或回到页面
if (document.visibilityState === 'visible') {
start()
}
})
})

onBeforeUnmount(() => stop())

<scriptp>

visibilitychange 事件:当其选项卡的内容变得可见或被隐藏时,会在 document 上触发 visibilitychange 事件。该事件不可取消。


总结


在本教程中,我们通过简单代码实现了无缝轮播效果,样式是左右切换,我们也可以通过样式控制实现上下切换的效果,比如将translateX设置为translateY即可。


 .v-enter-from {
transform: translateY(100%);
opacity: 0;
}

时间原因,本教程也没有对技术上做深究,也希望各位大佬能提供自己的思路与建议,感谢大家分享!


作者:石小石Orz
来源:juejin.cn/post/7351790785743978537
收起阅读 »

打造聊天框丝滑滚动体验:AI 聊天框的翻转之道 ——— 聊天框发送消息后自动滚动到底部(前端框架通用)

web
逐字渲染的挑战 最近在开发AI聊天助手的时候,遇到了一个很有趣的滚动问题。我们需要开发一个类似微信聊天框的交互体验: 每当聊天框中展示新消息时,需要将聊天框滚动到底部,展示最新消息。 如果在 web 什么也不做,聊天体验可能是这样的,需要用户手动滚动到最新消...
继续阅读 »

逐字渲染的挑战


最近在开发AI聊天助手的时候,遇到了一个很有趣的滚动问题。我们需要开发一个类似微信聊天框的交互体验:


每当聊天框中展示新消息时,需要将聊天框滚动到底部,展示最新消息。


请在此添加图片描述


如果在 web 什么也不做,聊天体验可能是这样的,需要用户手动滚动到最新消息:


请在此添加图片描述


试想一下如何在 web 中实现微信的效果。每当聊天框中接收到新消息时,都需要调用滚动方法滚动到消息底部。


element.scrollIntoView({ behavior: "smooth", block: "end");

对于普通的聊天工具来说,这样实现没有什么大问题,因为聊天框接收到每条消息的长度都是确定的。但是 AI 大模型一般都是逐字渲染的,AI 助手聊天框接受的消息体大小不是固定的,而是会随着 AI 大模型的输出不断变大。如果仍使用 scrollIntoView 来滚动到底部,就需要监听消息体的变化,每次消息更新时都要通过 JavaScript 调用一次滚动方法,会造成一些问题:



  1. 频繁的 JavaScript 滚动调用。每输出一个文字要滚动一次,听起来就会性能焦虑。

  2. AI 正在输出内容时,用户无法滚动查看历史消息。用户向上滚动查看历史消息,会被 Javascript 不断执行的 scrollIntoView 打断。需要写特殊逻辑才能避免这个情况。

  3. 通过监听数据变化频繁的执行滚动,基于浏览器单线程的设计,不可避免的会造成滚动行为的滞后,导致聊天体验不够丝滑。


自然列表:灵感来源


聊天框接收到新消息时滚动到最新位置,总感觉这应该是一个很自然的行为,不需要这么多 Javascript 代码去实现滚动行为。


于是联想到了 Excel 表格,当我们在表格中第一行插入一行,这一行后边的内容会被很自然的挤下去。并不需要做什么滚动,这一行就会出现在最顶部的位置。


请在此添加图片描述


想到这里惊讶的发现,聊天框实际上不就是一个倒过来的列表吗? 列表最上边新增的行会把后边的行往下挤,而聊天框最下边新增消息需要把上边的消息往上挤。那假如我们将聊天框旋转 180° 呢...?


聊天框的翻转实现


翻转聊天框


请在此添加图片描述


利用 CSS transform: rotate(180deg) 将整个聊天框倒转,并且把接收到最新的消息插入到消息列表的头部。发现我们的设想确实是行得通的,新增的消息很自然的把历史消息顶了上去,消息卡片内容增加也能很自然的撑开。并且在消息输出时,也可以随意滚动查看历史记录。


滚动条调整与滚动行为反转


最核心的问题已经解决了,但总觉得哪里看起来怪怪的。滚动条怎么跑到左边,并且滚动行为和鼠标滚轮的方向反了,滚轮向上滚,聊天框却向下滚。(让人想起了 MacOS 连鼠标滚轮的反人类体验)


查阅文档发现 CSS 有个 direction: rtl; 属性可以改变内容的排布的方向。这样我们就可以把滚动条放回右边了。然后在通过监听滚动事件,改变滚动方向就可以恢复鼠标滚轮的滚动行为。


element.addEventListener('wheel', event => {
event.preventDefault(); // 阻止默认滚动行为
const { deltaY } = event; // 获取滚动方向和速度
chatContent.current.scrollTop -= deltaY; // 反转方向
});

请在此添加图片描述


消息卡片翻转恢复


可以看到目前就只剩下聊天框中的消息卡片是反的,接下来把聊天框中的消息卡片转正就大功告成了。我们在聊天框中,给每个消息卡片都添加 transform: rotate(180deg);direction: ltr; 样式,把消息重新转正。


这样就把翻转的行为全部隔离在了聊天框组件中。消息卡片组件完全感知不到自己其实已经被旋转了 180° 后又旋转了 180° 了。聊天框的父组件也完全不知道自己的子节点被转了又转。


742ea972f92d4e7abc7344e75c331467.avif


总结


最后总结一下,我们通过两行 CSS 代码 + 反转滚动行为,利用浏览器的默认行为完美的实现了 AI 聊天框中的滚动体验。


transform: rotate(180deg);
direction: rtl;

element.addEventListener('wheel', event => {
event.preventDefault(); // 阻止默认滚动行为
const { deltaY } = event; // 获取滚动方向和速度
chatContent.current.scrollTop -= deltaY; // 反转方向
});

DEMO 仓库:github.com/lrwlf/messa…




更新:


想到一个更简洁的办法可以达到相同的效果,只用把聊天框 CSS 设置为:


display: flex;
flex-direction: column-reverse;

让列表倒序渲染,并且像原来的方法一样,在消息列表的头部插入消息,就可以实现一样的效果。不需要对聊天框和消息体再进行旋转操作,也不需要反转滚动条的行为。


以上两种方法都存在一个相同的问题,当一开始聊天消息还很少时,聊天消息也会紧贴着底部,顶部会留出一片空白。


请在此添加图片描述


这时只需要在聊天列表的最开始设置一个空白的占位元素,把它的 CSS 设置为:


flex-grow: 1;
flex-shrink: 1;

就可以实现消息少的时候自动撑开,把消息撑到顶部。消息列表开始滚动时,占位元素又会被挤压消失,不影响列表滚动效果。


(为了演示,把占位元素设置为了黑色)


请在此添加图片描述


更新部分代码见: github.com/lrwlf/messa…


将 App.js 的 chat 组件,替换为 src/components/chat-flex


作者:lrwlf
来源:juejin.cn/post/7306693980959588379
收起阅读 »

实现一个支持@的输入框

web
近期产品期望在后台发布帖子或视频时,需要添加 @用户 的功能,以便用户收到通知,例如“xxx在xxx提及了您!”。然而,现有的开源库未能满足我们的需求,例如 ant-design 的 Mentions 组件: 但是不难发现跟微信飞书对比下,有两个细节没有处...
继续阅读 »

近期产品期望在后台发布帖子或视频时,需要添加 @用户 的功能,以便用户收到通知,例如“xxx在xxx提及了您!”。然而,现有的开源库未能满足我们的需求,例如 ant-design 的 Mentions 组件:



20240415161851.gif


但是不难发现跟微信飞书对比下,有两个细节没有处理。



  1. @用户没有高亮

  2. 在删除时没有当做一个整体去删除,而是单个字母删除,首先不谈用户是否想要整体删除,在这块有个模糊查询的功能,如果每删一个字母之后去调接口查询数据库造成一些不必要的性能开销,哪怕加上防抖。


然后也是找了其他的库都没达到产品的期望效果,那么好,自己实现一个,先看看最终实现的效果


6ND88RssMr.gif


封装之后使用:


<AtInput
height={150}
onRequest={async (searchStr) => {
const { data } = await UserFindAll({ nickname: searchStr });
return data?.list?.map((v) => ({
id: v.uid,
name: v.nickname,
wechatAvatarUrl: v.wechatAvatarUrl,
}));
}}
onChange={(content, selected) => {
setAtUsers(selected);
}}
/>

那么实现这么一个输入框大概有以下几个点:



  1. 高亮效果

  2. 删除/选中用户时需要整体删除

  3. 监听@的位置,复制给弹框的坐标,联动效果

  4. 最后我需要拿到文本内容,并且需要拿到@那些用户,去做表单提交


大多数文本输入框我们会使用input,或者textarea,很明显以上1,2两点实现不了,antd也是使用的textarea,所以也是没有实现这两个效果。所以这块使用富文本编辑,设置contentEditable,将其变为可编辑去做。输入框以及选择器的dom就如下:


 <div style={{ height, position: 'relative' }}>
{/* 编辑器 */}
<div
id="atInput"
ref={atRef}
className={'editorDiv'}
contentEditable
onInput={editorChange}
onClick={editorClick}
/>

{/* 选择用户框 */}
<SelectUser
options={options}
visible={visible}
cursorPosition={cursorPosition}
onSelect={onSelect}
/>

</div>

实现思路:



  1. 监听输入@,唤起选择框。

  2. 截取@xxx的xxx作为搜素的关键字去查询接口

  3. 选择用户后需要将原先输入的 @xxx 替换成 @姓名,并且将@的用户缓存起来

  4. 选择文本框中的姓名时需要变为整体选中状态,这块依然可以给标签设置为不可编辑状态就可实现,contentEditable=false,即可实现整体删除,在删除的同时需要将当前用户从之前缓存的@过的用户数组删除

  5. 那么可以拿到输入框的文本,@的用户, 最后将数据抛给父组件就完成了


以上提到了监听@文本变化,通常绑定onChange事件就行,但是还有一种用户通过点击移动光标,这块需要绑定change,click两个时间,他们里边的逻辑基本一样,只需要额外处理点击选中输入框中用户时,整体选中g功能,那么代码如下:


    const onObserveInput = () => {
let cursorBeforeStr = '';
const selection: any = window.getSelection();
if (selection?.focusNode?.data) {
cursorBeforeStr = selection.focusNode?.data.slice(0, selection.focusOffset);
}
setFocusNode(selection.focusNode);
const lastAtIndex = cursorBeforeStr?.lastIndexOf('@');
setCurrentAtIdx(lastAtIndex);
if (lastAtIndex !== -1) {
getCursorPosition();
const searchStr = cursorBeforeStr.slice(lastAtIndex + 1);
if (!StringTools.isIncludeSpacesOrLineBreak(searchStr)) {
setSearchStr(searchStr);
fetchOptions(searchStr);
setVisible(true);
} else {
setVisible(false);
setSearchStr('');
}
} else {
setVisible(false);
}
};

const selectAtSpanTag = (target: Node) => {
window.getSelection()?.getRangeAt(0).selectNode(target);
};

const editorClick = async (event) => {
onObserveInput();
// 判断当前标签名是否为span 是的话选中当做一个整体
if (e.target.localName === 'span') {
selectAtSpanTag(e.target);
}
};

const editorChange = (event) => {
const { innerText } = event.target;
setContent(innerText);
onObserveInput();
};

每次点击或者文本改变时都会去调用onObserveInput,以上onObserveInput该方法中主要做了以下逻辑:




  1. 通过getSelection方法可以获取光标的偏移位置,那么可以截取光标之前的字符串,并且使用lastIndexOf从后向前查找最后一个“@”符号,并记录他的下标,那么有了【光标之前的字符串】,【@的下标】就可以拿到到@之后用于过滤用户的关键字,并将其缓存起来。

  2. 唤起选择器,并通过关键字去过滤用户。这块涉及到一个选择器的位置,直接使用window.getSelection()?.getRangeAt(0).getBoundingClientRect()去获取光标的位置拿到的是光标相对于窗口的坐标,直接用这个坐标会有问题,比如滚动条滚动时,这个选择器发生位置错乱,所以这块同时去拿输入框的坐标,去做一个相减,这样就可以实现选择器跟着@符号联动的效果。


 const getCursorPosition = () => {
// 坐标相对浏览器的坐标
const { x, y } = window.getSelection()?.getRangeAt(0).getBoundingClientRect() as any;
// 获取编辑器的坐标
const editorDom = window.document.querySelector('#atInput');
const { x: eX, y: eY } = editorDom?.getBoundingClientRect() as any;
// 光标所在位置
setCursorPosition({ x: x - eX, y: y - eY });
};

选择器弹出后,那么下面就到了选择用户之后的流程了,


 /**
* @param id 唯一的id 可以uid
* @param name 用户姓名
* @param color 回显颜色
* @returns
*/

const createAtSpanTag = (id: number | string, name: string, color = 'blue') => {
const ele = document.createElement('span');
ele.className = 'at-span';
ele.style.color = color;
ele.id = id.toString();
ele.contentEditable = 'false';
ele.innerText = `@${name}`;
return ele;
};

/**
* 选择用户时回调
*/

const onSelect = (item: Options) => {
const selection = window.getSelection();
const range = selection?.getRangeAt(0) as Range;
// 选中输入的 @关键字 -> @郑
range.setStart(focusNode as Node, currentAtIdx!);
range.setEnd(focusNode as Node, currentAtIdx! + 1 + searchStr.length);
// 删除输入的 @关键字
range.deleteContents();
// 创建元素节点
const atEle = createAtSpanTag(item.id, item.name);
// 插入元素节点
range.insertNode(atEle);
// 光标移动到末尾
range.collapse();
// 缓存已选中的用户
setSelected([...selected, item]);
// 选择用户后重新计算content
setContent(document.getElementById('atInput')?.innerText as string);
// 关闭弹框
setVisible(false);
// 输入框聚焦
atRef.current.focus();
};

选择用户的时候需要做的以下以下几点:



  1. 删除之前的@xxx字符

  2. 插入不可编辑的span标签

  3. 将当前选择的用户缓存起来

  4. 重新获取输入框的内容

  5. 关闭选择器

  6. 将输入框重新聚焦


最后


在选择的用户或者内容发生改变时将数据抛给父组件


 const getAttrIds = () => {
const spans = document.querySelectorAll('.at-span');
let ids = new Set();
spans.forEach((span) => ids.add(span.id));
return selected.filter((s) => ids.has(s.id));
};

/** @的用户列表发生改变时,将最新值暴露给父组件 */
useEffect(() => {
const selectUsers = getAttrIds();
onChange(content, selectUsers);
}, [selected, content]);

完整组件代码


输入框主要逻辑代码:


let timer: NodeJS.Timeout | null = null;

const AtInput = (props: AtInputProps) => {
const { height = 300, onRequest, onChange, value, onBlur } = props;
// 输入框的内容=innerText
const [content, setContent] = useState<string>('');
// 选择用户弹框
const [visible, setVisible] = useState<boolean>(false);
// 用户数据
const [options, setOptions] = useState<Options[]>([]);
// @的索引
const [currentAtIdx, setCurrentAtIdx] = useState<number>();
// 输入@之前的字符串
const [focusNode, setFocusNode] = useState<Node | string>();
// @后关键字 @郑 = 郑
const [searchStr, setSearchStr] = useState<string>('');
// 弹框的x,y轴的坐标
const [cursorPosition, setCursorPosition] = useState<Position>({
x: 0,
y: 0,
});
// 选择的用户
const [selected, setSelected] = useState<Options[]>([]);
const atRef = useRef<any>();

/** 获取选择器弹框坐标 */
const getCursorPosition = () => {
// 坐标相对浏览器的坐标
const { x, y } = window.getSelection()?.getRangeAt(0).getBoundingClientRect() as any;
// 获取编辑器的坐标
const editorDom = window.document.querySelector('#atInput');
const { x: eX, y: eY } = editorDom?.getBoundingClientRect() as any;
// 光标所在位置
setCursorPosition({ x: x - eX, y: y - eY });
};

/**获取用户下拉列表 */
const fetchOptions = (key?: string) => {
if (timer) {
clearTimeout(timer);
timer = null;
}
timer = setTimeout(async () => {
const _options = await onRequest(key);
setOptions(_options);
}, 500);
};

useEffect(() => {
fetchOptions();
// if (value) {
// /** 判断value中是否有at用户 */
// const atUsers: any = StringTools.filterUsers(value);
// setSelected(atUsers);
// atRef.current.innerHTML = value;
// setContent(value.replace(/<\/?.+?\/?>/g, '')); //全局匹配内html标签)
// }
}, []);

const onObserveInput = () => {
let cursorBeforeStr = '';
const selection: any = window.getSelection();
if (selection?.focusNode?.data) {
cursorBeforeStr = selection.focusNode?.data.slice(0, selection.focusOffset);
}
setFocusNode(selection.focusNode);
const lastAtIndex = cursorBeforeStr?.lastIndexOf('@');
setCurrentAtIdx(lastAtIndex);
if (lastAtIndex !== -1) {
getCursorPosition();
const searchStr = cursorBeforeStr.slice(lastAtIndex + 1);
if (!StringTools.isIncludeSpacesOrLineBreak(searchStr)) {
setSearchStr(searchStr);
fetchOptions(searchStr);
setVisible(true);
} else {
setVisible(false);
setSearchStr('');
}
} else {
setVisible(false);
}
};

const selectAtSpanTag = (target: Node) => {
window.getSelection()?.getRangeAt(0).selectNode(target);
};

const editorClick = async (e?: any) => {
onObserveInput();
// 判断当前标签名是否为span 是的话选中当做一个整体
if (e.target.localName === 'span') {
selectAtSpanTag(e.target);
}
};

const editorChange = (event: any) => {
const { innerText } = event.target;
setContent(innerText);
onObserveInput();
};

/**
* @param id 唯一的id 可以uid
* @param name 用户姓名
* @param color 回显颜色
* @returns
*/

const createAtSpanTag = (id: number | string, name: string, color = 'blue') => {
const ele = document.createElement('span');
ele.className = 'at-span';
ele.style.color = color;
ele.id = id.toString();
ele.contentEditable = 'false';
ele.innerText = `@${name}`;
return ele;
};

/**
* 选择用户时回调
*/

const onSelect = (item: Options) => {
const selection = window.getSelection();
const range = selection?.getRangeAt(0) as Range;
// 选中输入的 @关键字 -> @郑
range.setStart(focusNode as Node, currentAtIdx!);
range.setEnd(focusNode as Node, currentAtIdx! + 1 + searchStr.length);
// 删除输入的 @关键字
range.deleteContents();
// 创建元素节点
const atEle = createAtSpanTag(item.id, item.name);
// 插入元素节点
range.insertNode(atEle);
// 光标移动到末尾
range.collapse();
// 缓存已选中的用户
setSelected([...selected, item]);
// 选择用户后重新计算content
setContent(document.getElementById('atInput')?.innerText as string);
// 关闭弹框
setVisible(false);
// 输入框聚焦
atRef.current.focus();
};

const getAttrIds = () => {
const spans = document.querySelectorAll('.at-span');
let ids = new Set();
spans.forEach((span) => ids.add(span.id));
return selected.filter((s) => ids.has(s.id));
};

/** @的用户列表发生改变时,将最新值暴露给父组件 */
useEffect(() => {
const selectUsers = getAttrIds();
onChange(content, selectUsers);
}, [selected, content]);

return (
<div style={{ height, position: 'relative' }}>
{/* 编辑器 */}
<div id="atInput" ref={atRef} className={'editorDiv'} contentEditable onInput={editorChange} onClick={editorClick} />
{/* 选择用户框 */}
<SelectUser options={options} visible={visible} cursorPosition={cursorPosition} onSelect={onSelect} />
</div>

);
};

选择器代码


const SelectUser = React.memo((props: SelectComProps) => {
const { options, visible, cursorPosition, onSelect } = props;

const { x, y } = cursorPosition;

return (
<div
className={'selectWrap'}
style={{
display: `${visible ? 'block' : 'none'}`,
position: 'absolute',
left: x,
top: y + 20,
}}
>

<ul>
{options.map((item) => {
return (
<li
key={item.id}
onClick={() =>
{
onSelect(item);
}}
>
<img src={item.wechatAvatarUrl} alt="" />
<span>{item.name}</span>
</li>
);
})}
</ul>
</div>

);
});
export default SelectUser;

以上就是实现一个支持@用户的输入框功能,就目前而言,比较死板,不支持自定义颜色,自定义选择器等等,未来,可以进一步扩展功能,例如添加@用户的高亮样式定制、支持键盘快捷键操作等,从而提升用户体验和功能性。


作者:tech_zjf
来源:juejin.cn/post/7357917741909819407
收起阅读 »

和后端吵架后,我写了个库,让整个前端团队更加规范!

web
前言 大家好,我是林三心,用最通俗易懂的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心~ 本文源码地址:github.com/sanxin-lin/… 背景 在平时的开发中,表格数据->(增加/编辑/查看)行->(增加/编辑)提交,这...
继续阅读 »

前言


大家好,我是林三心,用最通俗易懂的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心~



本文源码地址:github.com/sanxin-lin/…



背景


在平时的开发中,表格数据->(增加/编辑/查看)行->(增加/编辑)提交,这是很常见且简单的业务,但是就是这些业务,我也发现一些问题



首先我们来理性一下这些业务的逻辑



  • 第一步:请求回表格的数据

  • 第二步:点开(增加/编辑/查看)弹窗,如果是(编辑/查看),则需要将表格行的数据传到弹窗中回显

  • 第三部:如果是(编辑)弹窗,则需要把表单数据提交请求接口


我用一个图来概括大概就是:



问题所在


我不知道其他公司怎么样,但是就拿我自身来举例子,公司的后端跟前端的命名规则是不同的



  • 后端命名: 请求方法+字段类型+字段含义+下划线命名(比如 in_name、os_user_id)

  • 前端命名: 字段含义+驼峰命名(比如 name、userId)


回到刚刚的业务逻辑,还是那张图,假如我们前端不去管命名的话,那么数据的传输是这样的,发现了很多人都懒得去转换后端返回的字段名,直接拿着后端的字段名去当做前端的表单字段名,但这是不符合前端规范的



理想应该是表单要用前端的命名,比如这样



但是很多前端就是懒得去转换,原因有多个:



  • 开发者自身比较懒,或者没有规范意识

  • 回显时要转一次,提交时还要再转一次,每次总是得写一遍


解决方案


所以能不能写一个工具,解放开发者的压力又能达到期望的效果呢?比如我开发一个工具,然后像下面这样在弹窗里用



  • state: 响应式表单数据,可以用在弹窗表单中

  • resetState: 重置表单

  • inputState: 将表格行数据转成表单数据

  • outputState: 将表单数据转成提交请求的数据


配置的含义如下:



  • default: 表单字段默认值

  • input: 转入的字段名

  • output: 转出的字段名

  • inputStrategy: 转入的转换策略,可以选择内置的,也可以自定义策略函数

  • outputStrategy: 转出的转换策略,可以选择内置的,也可以自定义策略函数



转入和转出策略,内置了一些,你也可以自定义,内置的有如下



下面是自定义策略函数的例子,必须要在策略函数中返回一个转换值



这样的话,当我们执行对应的转换函数之后,会得到我们想要的结果



use-dsp


所以我开发了一个工具



源码地址:github.com/sanxin-lin/…



其实 dsp 意思就是



  • data

  • state

  • parameter


npm i use-dsp
yarn i use-dsp
pnpm i use-dsp

import useDSP from 'use-dsp'

为啥不从一开始就转?


有人会问,为啥不从一开始请求表格数据回来的时候,就把数据转成前端的命名规范?


其实这个问题我也想过,但是设想一下,有一些表格如果只是单纯做展示作用,那么就没必要去转字段名了,毕竟不涉及任何的数据传递。


但是需要编辑或者查看弹窗的表格,就涉及到了行数据的传递,那么就需要转字段名




作者:Sunshine_Lin
来源:juejin.cn/post/7360892717545799689
收起阅读 »

JavaScript精粹:26个关键字深度解析,编写高质量代码的秘诀!

JavaScript关键字是一种特殊的标识符,它们在语言中有固定的含义,不能用作变量名或函数名。这些关键字是JavaScript的基础,理解它们是掌握JavaScript的关键。今天,我们将一起探索JavaScript中的26个关键字,了解这些关键字各自独特的...
继续阅读 »

JavaScript关键字是一种特殊的标识符,它们在语言中有固定的含义,不能用作变量名或函数名。这些关键字是JavaScript的基础,理解它们是掌握JavaScript的关键。

今天,我们将一起探索JavaScript中的26个关键字,了解这些关键字各自独特的含义、特性和使用方法。

一、JavaScript关键字是什么

Javascript关键字(Keyword)是指在Javascript语言中有特定含义,成为Javascript语法中一部分的那些字,是 JavaScript 语言内部使用的一组名字(或称为命令)。

Description


Javascript关键字是不能作为变量名和函数名使用的。使用Javascript关键字作为变量名或函数名,会使Javascript在载入过程中出现编译错误。

Java中的关键字可用于表示控制语句的开始或结束,或者用于执行特定操作等。按照规则,关键字也是语言保留的,不能用作标识符。

下面我们来详细介绍一下JavaScript关键字的作用和使用方法。

二、JavaScript的26个关键字

JavaScript是一种广泛使用的编程语言,它具有丰富的关键字,这些关键字在JavaScript语言中发挥着重要的作用,JavaScript一共提供了26个关键字:


break, case, catch, continue, debugger, default, delete, do, else, finally, for, function, if, in, instanceof, new, return, switch, this, throw, try, typeof, var, void, while, with
其中,debugger在ECMAScript 5 新增的。

1、break:跳出 循环

break用于跳出循环结构。循环结构是一种重复执行某个代码块的结构,break关键字可以用于循环结构中的条件语句中,用于跳出循环。例如:

for (var i = 0; i < 10; i++) {
if (i == 5) {
break; // 当i等于5时跳出循环
}
console.log(i);
}

2、case:捕捉

它用于在switch语句中定义一个分支。switch语句是一种根据表达式的值执行不同代码块的结构,case关键字可以用于switch语句中,用于定义不同的分支。例如:

switch (n) {

case 1:
console.log('n等于1');
break;
case 2:
console.log('n等于2');
break;
default:
console.log('n不等于1或2');
break;
}

3、catch:配合try进行错误判断

catch用于捕获异常。异常是一种程序运行时出现的错误,catch关键字可以用于try-catch语句中,用于捕获并处理异常。例如:

try {
// 代码
} catch (e) {
console.log('发生异常:' + e.message);
}

4、continue:继续

continue用于跳过当前循环中的某个迭代。循环结构是一种重复执行某个代码块的结构,continue关键字可以用于循环结构中的条件语句中,用于跳过当前迭代。例如:

for (var i = 0; i < 10; i++) {
if (i == 5) {
continue; // 当i等于5时跳过当前迭代
}
console.log(i);
}

5、debugger:设置断点

它用于在代码中设置断点,方便调试代码。调试是一种在代码运行时发现和解决问题的过程,debugger关键字可以用于代码中,用于设置断点。例如:

function foo() {

var x = 10;

debugger; // 在这里设置断点
console.log(x);

}

6、default:配合switch,当条件不存在时使用该项

default用于在switch语句中定义一个默认分支。switch语句是一种根据表达式的值执行不同代码块的结构,default关键字可以用于switch语句中,用于定义默认分支。例如:


switch (n) {
case 1:
console.log('n等于1');
break;
case 2:
console.log('n等于2');
break;
default:
console.log('n不等于1或2');
break;
}

7、delete:删除了一个属性

delete用于删除对象的属性或数组中的元素。对象是JavaScript中的一种数据类型,它由一组属性组成,delete关键字可以用于对象的属性中,用于删除属性。例如:

var obj = {a: 1, b: 2, c: 3};
delete obj.b; // 删除对象obj的属性b
console.log(obj); // 输出{a: 1, c: 3}

8、do:声明一个循环

do用于定义一个do-while循环结构。循环结构是一种重复执行某个代码块的结构,do关键字可以用于do-while循环中,用于定义循环体。例如:

var i = 0;
do {
console.log(i);
i++;
} while (i < 10);

9、else:否则//配合if条件判断,用于条件选择的跳转

else用于在if语句中定义一个分支。if语句是一种根据条件执行不同代码块的结构,else关键字可以用于if语句中,用于定义另一个分支。例如:

if (n == 1) {
console.log('n等于1');
} else {
console.log('n不等于1');
}

10、finally:预防出现异常时用的

finally用于定义一个try-catch-finally语句中的finally块。try-catch-finally语句是一种用于处理异常的结构,finally关键字可以用于finally块中,用于定义一些必须执行的代码。例如:

try {
// 可能会抛出异常的代码
} catch (e) {
// 处理异常的代码
} finally {
// 必须执行的代码
}

11、for:循环语句

for用于定义一个for循环结构。循环结构是一种重复执行某个代码块的结构,for关键字可以用于for循环中,用于定义循环条件。例如:

for (var i = 0; i < 10; i++) {
console.log(i);
}

12、function:定义函数的关键字

function用于定义一个函数。函数是一种封装了一段代码的结构,它可以接受参数并返回结果。function关键字可以用于函数定义中,用于定义函数名和参数列表。例如:

function add(a, b) {
return a + b;
}
console.log(add(1, 2)); // 输出3

13、if:定义一个if语句

if用于定义一个if语句。if语句是一种根据条件执行不同代码块的结构,if关键字可以用于if语句中,用于定义条件。例如:


if (n == 1) {
console.log('n等于1');
} else {
console.log('n不等于1');
}

14、in:判断某个属性属于某个对象

in用于判断一个对象是否包含某个属性。对象是JavaScript中的一种数据类型,它由一组属性组成,in关键字可以用于对象中,用于判断对象是否包含某个属性。例如:


var obj = {a: 1, b: 2, c: 3};
if ('a' in obj) {
console.log('obj包含属性a');
} else {
console.log('obj不包含属性a');
}

15、instanceof:某个对象是不是另一个对象的实例

instanceof用于判断一个对象是否是某个类的实例。类是JavaScript中的一种数据类型,它由一组属性和方法组成,instanceof关键字可以用于类中,用于判断对象是否是该类的实例。例如:

function Person(name) {
this.name = name;
}
var p = new Person('张三');
if (p instanceof Person) {
console.log('p是Person类的实例');
} else {
console.log('p不是Person类的实例');
}

16、new:创建一个新对象

new用于创建一个对象。对象是JavaScript中的一种数据类型,它由一组属性和方法组成,new关键字可以用于类中,用于创建该类的实例。例如:

function Person(name) {
this.name = name;
}
var p = new Person('张三');
console.log(p.name); // 输出张三

17、return:返回

return用于从函数中返回一个值。函数是JavaScript中的一种数据类型,它由一段代码块组成,return关键字可以用于函数中,用于返回函数的执行结果。例如:

function add(a, b) {
return a + b;
}

console.log(add(1, 2)); // 输出3

18、switch:弥补if的多重判断语句

switch用于根据不同的条件执行不同的代码块。switch语句是一种根据条件执行不同代码块的结构,switch关键字可以用于switch语句中,用于定义条件。例如:

var day = 3;
switch (day) {
case 1:
console.log('星期一');
break;
case 2:
console.log('星期二');
break;
case 3:
console.log('星期三');
break;
default:
console.log('不是星期一、二、三');
}

19、this:总是指向调用该方法的对象

this用于引用当前对象。对象是JavaScript中的一种数据类型,它由一组属性和方法组成,this关键字可以用于对象中,用于引用当前对象的属性和方法。例如:

var obj = {
name: '张三',
sayName: function() {
console.log(this.name);
}
};
obj.sayName(); // 输出张三

20、throw:抛出异常

throw用于抛出一个异常。异常是JavaScript中的一种错误类型,它可以用于在程序运行过程中发现错误并停止程序的执行。throw关键字可以用于函数中,用于抛出异常。例如:

function divide(a, b) {
if (b === 0) {
throw new Error('除数不能为0');

}
return a / b;
}
console.log(divide(10, 0)); // 抛出异常

21、try:接受异常并做出判断

try用于捕获异常。异常是JavaScript中的一种错误类型,它可以用于在程序运行过程中发现错误并停止程序的执行。try语句是一种捕获异常的结构,try关键字可以用于try语句中,用于捕获异常。例如:

function divide(a, b) {
if (b === 0) {
throw new Error('除数不能为0');
}
return a / b;
}
try {
console.log(divide(10, 0)); // 抛出异常
} catch (e) {
console.log(e.message); // 输出除数不能为0
}

22、typeof:检测变量的数据类型

typeof用于获取变量的类型。变量是JavaScript中的一种数据类型,它可以是数字、字符串、布尔值等。typeof关键字可以用于变量中,用于获取变量的类型。例如:

var a = 10;
console.log(typeof a); // 输出number

23、var:声明变量

var用于声明变量。变量是JavaScript中的一种数据类型,它可以用于存储数据。var关键字可以用于变量中,用于声明变量。例如:

var a = 10;
console.log(a); // 输出10

24、void:空/ 声明没有返回值

void它用于执行一个表达式并返回undefined。undefined是JavaScript中的一种特殊值,它表示一个未定义的值。void关键字可以用于表达式中,用于执行表达式并返回undefined。例如:

function doSomething() {
console.log('执行了doSomething函数');
}
var result = void doSomething();
console.log(result); // 输出undefined

25、while

while用于创建一个循环结构。循环是JavaScript中的一种控制结构,它可以用于重复执行一段代码。while关键字可以用于循环中,用于创建一个基于条件的循环。例如:

var i = 0;
while (i < 10) {
console.log(i);
i++;
}

26、with

with用于创建一个作用域。作用域是JavaScript中的一种机制,它可以用于控制变量的作用范围。with关键字可以用于代码块中,用于创建一个作用域。例如:


var obj = {
name: '张三',
age: 20
};
with (obj) {
console.log(name); // 输出张三
console.log(age); // 输出20
}


三、JS关键字注意事项

在开发过程中使用关键字我们需要注意以下几点:

  • 区分大小写: JavaScript是区分大小写的,因此关键字的大小写必须正确。

  • 不能用作变量名: 关键字不能被用作变量名,函数名等等,会出现问题

  • 不需要硬记关键字: 关键字不用去硬记,我们在编写代码时根据系统的提示去规避就可以了

  • 保留字: JavaScript有一些保留字,不能用作变量名、函数名或属性名。

  • 不要使用全局变量: 尽量避免使用全局变量,以免与其他脚本或库发生冲突。可以使用立即执行函数表达式(IIFE)或模块模式来避免全局变量污染。

  • 使用严格模式: 在代码中添加"use strict"指令,以启用严格模式。这将有助于避免一些常见的错误,例如未声明的变量、隐式类型转换等。

  • 避免使用eval()函数: eval()函数用于动态执行字符串中的JavaScript代码,但可能导致性能问题和安全风险。尽量避免使用eval(),寻找其他替代方案。

  • 不要使用with语句: with语句会改变代码的作用域链,可能导致意外的错误。尽量避免使用with语句,改用局部变量或对象属性访问。

  • 避免使用重复的标识符: 确保变量名、函数名和属性名在同一作用域内是唯一的,以避免命名冲突。

  • 遵循编码规范: 遵循一致的命名约定、缩进风格和代码结构,以提高代码的可读性和可维护性。

想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!

点这里前往学习哦!


四、关于保留字的了解

除了关键字还有个“保留字”的概念,所谓保留字,实际上就是预留的“关键字”。意思是现在虽然还不是关键字,但是未来可能会成为关键字,同样不能使用它们当充当变量名、函数名等标识符。

下面是JavaScript中保留字的含义,大家同样不用记,了解一下就行了。

Description

以上就是关于JavaScript关键字的相关内容了,通过了解这26个JavaScript关键字的含义、特性和使用方法,你已经迈出了成为编程高手的重要一步。

记住,实践是学习的关键,不断地编写代码并应用这些关键字,你将逐渐掌握JavaScript的精髓。

如果觉得本文对你有所帮助,别忘了点赞和分享哦!

收起阅读 »

文本美学:text-image打造视觉吸引力

web
当我最近浏览 GitHub 时,偶然发现了一个项目,它能够将文字、图片和视频转化为文本,我觉得非常有趣。于是我就花了一些时间了解了一下,发现它的使用也非常简单方便。今天我打算和家人们分享这个发现。 项目介绍 话不多说,我们先看下作者的demo效果: _202...
继续阅读 »

当我最近浏览 GitHub 时,偶然发现了一个项目,它能够将文字、图片和视频转化为文本,我觉得非常有趣。于是我就花了一些时间了解了一下,发现它的使用也非常简单方便。今天我打算和家人们分享这个发现。


项目介绍


话不多说,我们先看下作者的demo效果:


微信截图_20240420194201.png


_20240420194201.jpg


text-image可以将文字、图片、视频进行「文本化」


只需要通过简单的配置即可使用。


虽然这个项目star数很少,但确实是一个很有意思的项目,使用起来很简单的项目。


_20240420194537.jpg


_20240420194537.jpg


github地址:https://github.com/Sunny-117/text-image


我也是使用这个项目做了一个简单的web页面,感兴趣的家人可以使用看下效果:


web地址:http://h5.xiuji.mynatapp.cc/text-image/


_20240420211509.jpg


_20240420211509.jpg


项目使用


这个项目使用起来相对简单,只需按作者的文档使用即可,虽然我前端属于小白的水平,但还是在ai的帮助下做了一个简单的html页面,如果有家人需要的话可以私信我,我发下文件。下边我们就介绍下:



  • 文字「文本化」


先看效果:


_20240420195701.jpg


_20240420195701.jpg


我们在这儿是将配置的一些参数在页面上做了一个可配置的表单,方便我们配置。


家人们想自己尝试的话可以试下以下这个demo。


demo.html


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Document</title>
</head>

<body>
<canvas id="demo"></canvas>
<script src="http://h5.xiuji.mynatapp.cc/text-image/text-image.iife.js"></script>
<script>
textImage.createTextImage({
canvas: document.getElementById('demo'),
replaceText: '123',
source: {
text: '修己xj',
},
});
</script>
</body>
</html>


  • 图片「文本化」


_20240420200651.jpg


_20240420200651.jpg


demo.html


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

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

<body>
<canvas id="demo"></canvas>
<script src="http://h5.xiuji.mynatapp.cc/text-image/text-image.iife.js"></script>
<script>
textImage.createTextImage({
canvas: document.getElementById('demo'),
raduis: 7,
isGray: true,
source: {
img: './assets/1.png',
},
});
</script>
</body>

</html>


  • 视频「文本化」


动画1.gif


1.gif


demo.html


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

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

<body>
<canvas id="demo"></canvas>
<script src="http://h5.xiuji.mynatapp.cc/text-image/text-image.iife.js"></script>

<script>
textImage.createTextImage({
canvas: document.getElementById('demo'),
raduis: 8,
isGray: true,
source: {
video: './assets/1.mp4',
height: 700,
},
});
</script>
</body>

</html>

需要注意的是:作者在项目中提供的视频的demo这个属性值有错误,我们需要改正后方可正常显示:


_20240420211124.jpg


_20240420211124.jpg


总结


text-image 是一个强大的前端工具,可以帮助用户快速、轻松地将文本、图片、视频转换成文本化的图片,增强文本内容的表现力和吸引力。


作者:修己xj
来源:juejin.cn/post/7359510120248786971
收起阅读 »

vue反编译dist包到源码

web
最近由于公司老项目上的问题,由于项目很老,之前交接的源码包中缺少了很大一部分模块,但是现在线上的环境和dist包是正常运行的,领导希望能够手动将这部分补全,由于前期项目的不规范,缺少接口文档以及原型图,因此无法知道到底该如何补全,因此,我想着能不能通过dist...
继续阅读 »

最近由于公司老项目上的问题,由于项目很老,之前交接的源码包中缺少了很大一部分模块,但是现在线上的环境和dist包是正常运行的,领导希望能够手动将这部分补全,由于前期项目的不规范,缺少接口文档以及原型图,因此无法知道到底该如何补全,因此,我想着能不能通过dist包去反编译源码包呢,经过多方面探索发现是可行的,但是只能编译出vue文件,但是也满足基本需要了。


1.如何反编译


1.首先需要在管理员模式下打开cmd


2.找到需要编译的dist/static/js的目录下
执行完成后在该目录会看到目录下存在下面的文件名:0.7ab7d1434ffcc747c1ca.js.map,这里以0.7ab7d1434ffcc747c1ca.js.map为例,如下图:


image.png


3.全局安装reverse-sourcemap资源



npm install --global reverse-sourcemap



4.反编译
执行:reverse-sourcemap --output-dir source 0.7ab7d1434ffcc747c1ca.js.map


2.脚本反编译


上面的方式执行完毕,确实在source中会出现源码,那么有没有可能用脚本去执行呢,通过node的child_process模块中的exec方式便可以执行reverse-sourcemap --output-dir source这个命令,那么只需要拿到当前文件夹中包含.map文件即可,那么可以借助node中fs模块,递归读取文件名,并使用正则将所有.map的文件提取出来放在一个集合或数组中,在对数组进行递归循环执行reverse-sourcemap --output-dir source这个命令


2.1根据child_process模块编写执行函数



function executeReverseSourceMap(outputDir) {
// 构建 reverse-sourcemap 命令
const command = `reverse-sourcemap --output-dir source ${outputDir}`;

// 执行命令
exec(command, (error, stdout, stderr) => {
if (error) {
console.error(`执行命令时出错:${error.message}`);
return;
}
if (stderr) {
console.error(`命令输出错误:${stderr}`);
return;
}
console.log(`命令输出结果:${stdout}`);
});
}

2.2读取文件并匹配文件


// // 读取文件夹中的文件
fs.readdir(folderPath, (err, files) => {
if (err) {
console.error('读取文件夹时出错:', err);
return;
}
// 遍历文件
files.forEach(file => {
// 使用正则表达式匹配特定格式的文件名
const match = /^(\d+)\..+\.js\.map$/.exec(file);
if (match) {
// 如果匹配成功,将文件名存入数组
targetFiles.push(match[0]);
}
});

// 输出目标文件名数组
targetFiles.forEach(file=>{
executeReverseSourceMap(file)
})
});

2.3完整的执行代码


const fs = require('fs');
const path = require('path');
const { exec } = require('child_process');
// 文件夹路径
const folderPath = '../js';

// 存放目标文件名的数组
const targetFiles = [];
function executeReverseSourceMap(outputDir) {
// 构建 reverse-sourcemap 命令
const command = `reverse-sourcemap --output-dir source ${outputDir}`;

// 执行命令
exec(command, (error, stdout, stderr) => {
if (error) {
console.error(`执行命令时出错:${error.message}`);
return;
}
if (stderr) {
console.error(`命令输出错误:${stderr}`);
return;
}
console.log(`命令输出结果:${stdout}`);
});
}
// // 读取文件夹中的文件
fs.readdir(folderPath, (err, files) => {
if (err) {
console.error('读取文件夹时出错:', err);
return;
}
// 遍历文件
files.forEach(file => {
// 使用正则表达式匹配特定格式的文件名
const match = /^(\d+)\..+\.js\.map$/.exec(file);
if (match) {
// 如果匹配成功,将文件名存入数组
targetFiles.push(match[0]);
}
});

// 输出目标文件名数组
targetFiles.forEach(file=>{
executeReverseSourceMap(file)
})
});

image.png


3最终结果展示图


image.png


作者:ws_qy
来源:juejin.cn/post/7359893196439207972
收起阅读 »

为什么不建议在 Vue <style> 中使用 scoped?

web
前言 标签下编写样式。不知你是否留意,在 标签下有一个属性经常出现 - scoped。你知道它起到什么作用吗?原理是怎样的?有没有什么弊端呢?今天我们就来聊聊它。 1. 什么是 scoped? scoped 顾名思义,与作用域有关,因为是设计组件样式的,所以...
继续阅读 »

前言

亲爱的小伙伴,你好!我是 嘟老板。我们使用 Vue 开发页面时,经常需要在 

收起阅读 »

JavaScript注释:单行注释和多行注释详解

为了提高代码的可读性,JS与CSS一样,也提供了注释功能。JS中的注释主要有两种,分别是单行注释和多行注释。在编程的世界里,注释是那些默默无闻的英雄,它们静静地站在代码的背后,为后来的维护者、为未来的自己,甚至是为那些偶然间翻阅你代码的开发者提供着不可或缺的信...
继续阅读 »

为了提高代码的可读性,JS与CSS一样,也提供了注释功能。JS中的注释主要有两种,分别是单行注释和多行注释。

在编程的世界里,注释是那些默默无闻的英雄,它们静静地站在代码的背后,为后来的维护者、为未来的自己,甚至是为那些偶然间翻阅你代码的开发者提供着不可或缺的信息。

Description


今天,我们就来深入探讨JavaScript中的注释,让我们的代码不仅能够运行,还能够“说话”。

一、什么是JavaScript注释

JavaScript注释是用来解释代码的,不会被浏览器执行。它们可以帮助其他开发者理解代码的功能和目的。

注释就像是给代码穿上了一件华丽的外衣,让我们的代码更加优雅、易读。如下图中的例子所示:


Description


在JavaScript中,有两种类型的注释:单行注释和多行注释。下面分别讲解这两种注释的含义和使用。


二、JavaScript注释的种类

1、单行注释

单行注释: 使用两个斜杠(//)开头,后面的内容直到该行结束都被视为注释。例如:

// 这是一个单行注释
console.log("Hello, World!"); // 这也是一个单行注释

它适用于简短的注释,比如对某一行代码的快速说明。

2、多行注释

多行注释: 使用斜杠星号(/)开头,星号斜杠(/)结尾,中间的内容都被视为注释。

例如:

/*
这是一个多行注释
可以跨越多行
*/

console.log("Hello, World!");

这种注释可以跨越多行,适合用于函数描述、复杂的算法解释或者临时屏蔽代码块。

注意: 在HTML文件中,如果需要将JavaScript代码嵌入到<script>标签中,可以使用以下方法来添加多行注释:

<script>
<!--
这是一个多行注释
可以跨越多行
-->

console.log("Hello, World!");
</script>


三、JavaScript注释的作用

1、解释代码功能:

通过注释,我们可以解释代码的功能和作用,让其他程序员更容易理解我们的代码。

// 这是一个求和函数
function sum(a, b) {
return a + b;
}

2、 标记代码状态:

我们可以使用注释来标记代码的状态,例如TODO、FIXME等,提醒自己或其他程序员注意这些问题。

// TODO: 优化这个函数的性能
function slowFunction() {
// ...
}

3、临时禁用代码:

当我们需要暂时禁用某段代码时,可以使用注释将其包裹起来,而不是直接删除。

// function oldFunction() {
// // ...
// }


想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!

点这里前往学习哦!


四、如何写好注释

注释虽好,但过多或不当的注释反而会成为阅读代码的障碍。我们在写注释时也要注意以下几点:

  • 简洁明了: 注释应该简单明了,能够快速传达信息。

  • 适当使用: 不要过度使用注释,只有在必要的时候才添加。

  • 保持一致: 在团队开发中,要遵循统一的注释规范,以便于团队成员之间的沟通。

  • 适时更新: 随着代码的变更,记得更新相关的注释。

JavaScript注释是我们编程过程中的得力助手,它们不仅能够帮助我们更好地理解代码,还能提高代码的可读性和可维护性。让我们一起学会使用注释,让我们的代码更加精彩!

收起阅读 »

记一次划线需求的实现方式

web
1 背景 1.1 需求背景 前半年接了一个划线需求,一直没空总结下。微信公众号和微信读书等读书类应用都有此功能,但前者只对部分用户开放了,并且没有长期运营。 这次只谈下划线技术实现本身。 1.2 功能详叙 用户可以对文章句子进行长按选区,过程中弹出面板,且面...
继续阅读 »

1 背景


1.1 需求背景


前半年接了一个划线需求,一直没空总结下。微信公众号和微信读书等读书类应用都有此功能,但前者只对部分用户开放了,并且没有长期运营。


这次只谈下划线技术实现本身。


1.2 功能详叙



  1. 用户可以对文章句子进行长按选区,过程中弹出面板,且面板位置动态变化,点击点赞按钮后生成划线;

  2. 点击划线句子默认选中,并弹出面板,显示所点句子点赞量;

  3. 划线句子可以合并,规则是选取句子和已赞过的句子有交叉时合并为一条新的首尾更长的句子,选取的句子被包含在已赞过的句子中时显示点赞量,选取句子包含了已赞句子则删掉已赞句子并对新句子点赞量加一;

  4. 点赞量超过3的句子才外显;

  5. 他人的划线句子用虚线展示,自己的划线用实线展示;

  6. 小流量,用户量由小至大,过程中可以对外显策略微调;


1.3 竞品


可以看到,微信公众号划线过程会弹出一个灰色面板,面板上有划线(这次需求改为了点赞)按钮:


image.png
image.png


2 关键逻辑


这个需求乍看可能会觉得没那么复杂,但细细分析后会发现有较长的交互流程和逻辑链:


image.png


其中有几个会影响整体逻辑的关键点需要关注:



  1. 渲染划线的方式:插入 dom 标签还是绝对定位或其他方式;

  2. 监听划线选取的交互事件选择 selectionchange 还是 touchend;

  3. 整个交互过程分为哪些部分;

  4. 怎么判断新划线和其他划线的位置关系,怎么合并或删除;

  5. 数据结构怎么设计;

  6. 怎么将划线序列化;

  7. 怎么将数据反序列化成划线;


3 详细设计


3.1获取划线


window 上提供了 Selection 对象,它代表页面中的文本选区,可能横跨多个元素。文本选区由用户拖拽鼠标经过文字而产生。调用 Selection.toString() 方法会返回被选中区域中的纯文本。


var selObj = window.getSelection();
var range = selObj.getRangeAt(0);

Selection 对象所对应的是用户所选择的 ranges(区域)


var selObj = window.getSelection();
var range = selObj.getRangeAt(0);

3.2 划线渲染方式


渲染划线有两种方式:


1 在划线range对象的首尾dom的位置,插入线段的dom标签;
优点:划线的点击不需要计算点击位置,直接在插入dom上绑定事件即可;
缺点:对原页面结构有入侵,改变了dom结构,可能引发其他问题;


2 绝对定位,相对于整篇文章;
优点:完全增量,对原页面没有入侵;
缺点:需要计算点击位置;


我选择的第二种,原因是为了不影响原有页面逻辑,这样项目风险也是最小的。那么具体怎么实现呢?


range对象提供了一个 getClientRects 方法,表示 range 在屏幕上所占的区域。这个列表相当于汇集了范围中所有元素调用 Element.getClientRects() 方法所得的结果。用拿到的位置信息进行绝对定位即可。


rectList = range.getClientRects()

我们把用户所有划线range对象和其产生的位置信息都存入到一个list中。


pageRangeList.push({
range,
rectInfo
})

3.3 交互过程


我们分析下整个交互过程:
有两个主要的交互事件,一是点击划线,二是滑动选区。


3.3.1 点击事件


处理点击事件,我们拿到点击事件的位置,和存放的 pageRangeList 进行位置比较,得出用户点击的是哪个range对象。


// 点击事件
const {pageX, pageY} = event;
const lineHeight = 23;

const {range} = rectInfo.some(rect => {
const {left, right, realY} = rect;
return pageX < left && pageX > left && pageY > realY
})

this.selection.removeAllRanges();
this.selection.addRange();

3.3.2 选区事件


选区事件我选择的是 selectionchange,需要加防抖和节流处理。


如果你选的是 touchend 安卓系统会点问题。


3.3.3 比较位置关系


如第2点核心逻辑中所说,在滑词过程中,需要比较位置关系,我们直接使用Range.compareBoundaryPoints方法即可。返回值 0 、-1 、1 分别代表不同的位置关系。


const compare = range.compareBoundaryPoints(Range.START_TO_END, sourceRange);

3.4 序列化与反序列化


序列化是整个需求的重点,序列化是指将交互产生的划线转化成某种数据结构能存储在服务器上,反序列化是指如何将server下发的序列化数据转化成非序列化的划线。


两者是两个相反的过程,当我们确定了序列化方案,其实也就知道了反序列化了。


3.4.1 序列化


方案一,识别段落


刚开始我观察文章都会拆分段落,如按P标签或某一个class类名来划分段落,于是计划用段落信息,告诉 server 划线在第几段的第几个字。


interface data {
startParagraph: 1,
startIndex: 22,
endParagraph: 2,
endIndex: 15
}

但后来发现有一些抓取的文章根本内容很混乱,且没有特定的段落,强行识别复杂度极高。(如下图)所以此方案不可行。


image.png


方案二,全文第几个字


前面的方案不可能的原因是,识别段落信息复杂度不可控,那么我们可以绕过段落信息,去识别全文第几个字。


interface data {
startCharacters: 122,
endCharacters: 166
}

具体方式是用Range,圈选文章开头到当前dom,形成一个新Range,再调用range.toString查看字数即可。


const range = new Range();
range.setStart(pageContainer, 0);
range.setEnd(curEndContainer, endOffset);
const str = range.toString();

3.4.2 反序列化


这里注意,由于 Javascript 在大多宿主环境下没有递归的尾调用优化,所以我采用了手动创建栈来进行 dfs:


    dfs({
node = this.content,
}) {
const stack = [];
if (!node) {
return;
}

stack.push(node);

while (stack.length) {
const item = stack.pop();

const children = item.childNodes;
for (let i = children.length - 1; i >= 0; i--) {
stack.push( [i]);
}
}
}

作者:雨默默下了一整夜
来源:juejin.cn/post/7344993022075813938
收起阅读 »

⚡聊天框 - 微信加载历史数据的效果原来这样实现的

web
前言 我记得2021年的时候做过聊天功能,那时业务也只限微信小程序 那时候的心路历程是: 卧槽,让我写一个聊天功能这么高大上?? 嗯?这么简单,不就画画页面来个轮询吗,加个websocket也还行吧 然后,卧槽?这查看历史聊天记录什么鬼,页面闪一下不太好啊,...
继续阅读 »

前言


我记得2021年的时候做过聊天功能,那时业务也只限微信小程序


那时候的心路历程是:



卧槽,让我写一个聊天功能这么高大上??


嗯?这么简单,不就画画页面来个轮询吗,加个websocket也还行吧


然后,卧槽?这查看历史聊天记录什么鬼,页面闪一下不太好啊,真的能做到微信的那种效果吗



然后一堆调研加测试,总算在小程序中查看历史记录没那么鬼畜了,但是总是感觉不是最佳解决方案。



那时打出的子弹,一直等到现在击中了我



最近又回想到了这个痛点,于是网上想看看有没有大佬发解决方案,结果还真被我找到了。


image.png


正文开始


1,效果展示


上才艺~~~


222.gif


2,聊天页面


2.1,查看历史聊天记录的坑


常规写法加载历史记录拼接到聊天主体的顶部后,滚动条会回到顶部、不在原聊天页面


直接上图


111.gif


而我们以往的解决方案也只是各种利用缓存scroll的滚动定位把回到顶部的滚动条重新拉回加载历史记录前的位置,好让我们可以继续在原聊天页面。


但即使我们做了很多优化,也会有安卓和苹果部分机型适配问题,还是不自然,可能会出现页面闪动


其实吧,解决方案只有两行css代码~~~


2.2,解决方案:flex神功


想优雅顺滑的在聊天框里查看历史记录,这两行css代码就是flex的这个翻转属性


dispaly:flex;
flex-direction: column-reverse

灵感来源~~~


333.gif


小伙伴可以看到,在加载更多数据时



滚动条位置没变、加载数据后还是原聊天页面的位置



这不就是我们之前的痛点吗~~~


所以,我们只需要翻转位置,用这个就可以优雅流畅的实现微信的加载历史记录啦


flex-direction: column-reverse


官方的意思:指定Flex容器中子元素的排列方向为列(从上到下),并且将其顺序反转(从底部到顶部)


如果感觉还是抽象,不好理解的话,那就直接上图,不加column-reverse的样子


image.png


加了column-reverse的样子


image.png


至此,我们用column-reverse再搭配data数据的位置处理就完美解决加载历史记录的历史性问题啦


代码放最后啦~~~


2.3,其他问题


2.3.1,数据过少时第一屏展示


因为用了翻转,数据少的时候会出现上图的问题


只需要.mainArea加上height:100%


然后额外写个适配盒子就行


flex-grow: 1; 
flex-shrink: 1;

image.png


2.3.2,用了scroll-view导致的问题


这一part是因为我用了uniappscroll-view组件导致的坑以及解决方案,小伙伴们没用这个组件的可忽略~~~


如下图,.mainArea使用了height:100%后,继承了父级高度后scroll-view滚动条消失了。


image.png


.mainArea去掉height:100%后scroll-view滚动条出现,但是第一屏数据过多时不会滚动到底部展示最新信息


image.png


解决方案:第一屏手动进行滚动条置顶


scrollBottom() {
if (this.firstLoad) return;
// 第一屏后不触发
this.$nextTick(() => {
const query = uni.createSelectorQuery().in(this);
query
.select("#mainArea")
.boundingClientRect((data) => {
console.log(data);
if (data.height > +this.chatHeight) {
this.scrollTop = data.height; // 填写个较大的数
this.firstLoad = true;
}
})
.exec();
});
},

3,服务端


使用koa自己搭一个websocket服务端


3.1 服务端项目目录


image.png


package.json


{
"name": "websocketapi",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"koa": "^2.14.2",
"koa-router": "^12.0.1",
"koa-websocket": "^7.0.0"
}
}


koa-tcp.js


const koa = require('koa')
const Router = require('koa-router')
const ws = require('koa-websocket')

const app = ws(new koa())
const router = new Router()

/**
* 服务端给客户端的聊天信息格式
* {
id: lastid,
showTime: 是否展示时间,
time: nowDate,
type: type,
userinfo: {
uid: this.myuid,
username: this.username,
face: this.avatar,
},
content: {
url:'',
text:'',
w:'',
h:''
},
}
消息数据队列的队头为最新消息,以次往下为老消息
客户端展示需要reverse(): 客户端聊天窗口最下面需要为最新消息,所以队列尾部为最新消息,以此往上为老消息
*/



router.all('/websocket/:id', async (ctx) => {
// const query = ctx.query
console.log(JSON.stringify(ctx.params))
ctx.websocket.send('我是小服,告诉你连接成功啦')
ctx.websocket.on('message', (res) => {
console.log(`服务端收到消息, ${res}`)
let data = JSON.parse(res)
if (data.type === 'chat') {
ctx.websocket.send(`我也会说${data.text}`)
}
})
ctx.websocket.on('close', () => {
console.log('服务端关闭')
})
})

// 将路由中间件添加到Koa应用中
app.ws.use(router.routes()).use(router.allowedMethods())

app.listen(9001, () => {
console.log('socket is connect')
})



切到server目录yarn


然后执行nodemon koa-tcp.js


没有nodemon的小伙伴要装一下


image.png


代码区


完整项目Github传送门


聊天页面的核心代码如下(包含data数据的位置处理和与服务端联动)



完结


这篇文章我尽力把我的笔记和想法放到这了,希望对小伙伴有帮助。


到这里,想给小伙伴分享两句话



现在搞不清楚的事,不妨可以先慢下来,不要让自己钻到牛角尖了


一些你现在觉得解决不了的事,可能需要换个角度



欢迎转载,但请注明来源。


最后,希望小伙伴们给我个免费的点赞,祝大家心想事成,平安喜乐。


image.png


作者:尘落笔记
来源:juejin.cn/post/7337114587123335180
收起阅读 »

基于装饰器——我劝你不要在业务代码上装逼!!!

web
基于装饰器——我劝你不要在业务代码上装逼!!! 装饰器模式的定义 在传统的面向对象语言中,给对象添加功能常使用继承的方式,但继承的方式并不灵活,会带来一些许多问题,如:超类和子类存在强耦合性,也就是说当改变超类时,子类也需要改变。 而装饰器模式的出现改变的这...
继续阅读 »

基于装饰器——我劝你不要在业务代码上装逼!!!


装饰器模式的定义



  • 在传统的面向对象语言中,给对象添加功能常使用继承的方式,但继承的方式并不灵活,会带来一些许多问题,如:超类和子类存在强耦合性,也就是说当改变超类时,子类也需要改变。

  • 而装饰器模式的出现改变的这种方式,装饰器模式可在不改变现有对象解构的基础上,动态地为对象添加功能


传统的 JavaScript 装饰器


var plane = {
fire: function () {
console.log("普通子弹");
},
};

var missleDecorator = function () {
console.log("发射导弹");
};

var atomDecorator = function () {
console.log("发射原子弹");
};

var fire1 = plane.fire;
plane.fire = function () {
fire1();
missleDecorator();
};

var fire2 = plane.fire;
plane.fire = function () {
fire2();
atomDecorator();
};

plane.fire();
/**
普通子弹
发射导弹
发射原子弹
*/



装饰函数



  • 在 JavaScript 中,几乎一切都是对象,其中函数也被成为对象,在平时的开发中,我们都在和函数打交道。在给对象扩展属性和方法时,很难在不改动原功能函数的情况下,给函数添加一些额外的功能,最直接的粗暴方式就是直接改写函数,但这是最差的方式,这违反了开放——封闭原则。

  • 如下:


function a(){
console.log(1);
}

// 改写:
function a(){
console.log(1);

// 新功能
console.log(2);
}



  • 很多时候,我们都不想去触碰之前的一些代码,但需要添加功能,所以如果需要在不改变原功能函数的情况下,给函数添加功能。可使用以下方式:

  • 要想完美的给函数添加功能,可使用 AOP 来装饰函数

    • AOP:一种编程规范,通过将关注点从主业务逻辑中剥离出来并单独处理,以此来提高代码的可读性和重用性。



  • 如下:


Function.prototype.before = function (beforeFn) {
var _self = this;
return function () {
beforeFn.apply(this, arguments);
return _self.apply(this, arguments);
};
};

Function.prototype.after = function (afterFn) {
var _self = this;
return function () {
var ret = _self.apply(this, arguments);
afterFn.apply(this, arguments);
return ret;
}
}

// before 和 after 函数都接收一个函数作为参数,这个函数也就是新添加的函数(里面也就是要添加的新功能逻辑)。
// 而before 和 after 函数区别在于在是原函数之前执行还是之后执行。



  • AOP 函数的使用


Function.prototype.before = function (beforeFn) {
var _self = this;
return function () {
beforeFn.apply(this, arguments);
return _self.apply(this, arguments);
};
};

Function.prototype.after = function (afterFn) {
var _self = this;
return function () {
var ret = _self.apply(this, arguments);
afterFn.apply(this, arguments);
return ret;
}
}

var o1 = function(){
console.log('1');
}
var o2 = function(){
console.log('2');
}
var o3 = function(){
console.log('3');
}

var desctor = o1.after(o2);
desctor = desctor.after(o3);
desctor(); // 1 2 3
/**
var desctor = o1.after(o2);
desctor = desctor.after(o3);
desctor();
1
2
3

var desctor = o1.before(o2);
desctor = desctor.before(o3);
desctor();
3
2
1

var desctor = o1.after(o2);
desctor = desctor.before(o3);
desctor();
3
1
2


var desctor = o1.before(o2);
desctor = desctor.after(o3);
desctor();
2
1
3
*/



AOP的应用


1.数据上报



  • 在程序开发中,当业务代码开发完后,在结尾时需要加很多的日志上报的代码,普遍我们会去改已经之前封装好的功能函数。其实这并不是一个好的方式,那如何在不直接修改之前函数的基础上添加日志上报功能呢?

  • 如下:


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>AOP日志上报</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://unpkg.com/vue@3.2.20/dist/vue.global.js"></script>
</head>
<body>
<div id="app">
<button class="btn" @click="handler">Button</button>
<p id="tt">{{message}}</p>
</div>
</body>
</html>
<script type="text/javascript">
// log report
const { reactive, ref, createApp } = Vue;
const app = createApp({
setup() {
const message = ref("未点击");
const count = ref(0);

Function.prototype.before = function (beforeFn) {
var _self = this;
return function () {
beforeFn.apply(this, arguments);
return _self.apply(this, arguments);
};
};

Function.prototype.after = function (afterFn) {
var _self = this;
return function () {
var ret = _self.apply(this, arguments);
afterFn.apply(this, arguments);
return ret;
};
};

function handler() {
message.value = `已点击${++count.value}`;
}

handler = handler.after(log);

function log() {
message.value = message.value + "-----> log reported";
console.log("log report");
}

return {
message,
handler,
};
},
});
app.mount("#app");
</script>


2.动态参数



  • 在日常开发中,我们需要向后台接口发送请求来获取信息,例如传参如下。业务在后续时需要添加新参数,每个接口需要把 token 值也一并传过去, 普遍我们会去改封装的请求方法,把 token 参数添加进去。但我们直接修改封装好的请求方法不是好的行为,那我们可使用上面说过的 AOP 方式来改进。


{
name: 'xxxx',
password: 'xxxx',
}



  • 如下:


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>AOP动态参数</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://unpkg.com/vue@3.2.20/dist/vue.global.js"></script>
</head>
<body>
<div id="app">{{message}}</div>
</body>
</html>

<script type="text/javascript">
const { reactive, ref, createApp } = Vue;
const app = createApp({
setup() {
const message = ref("empty params");
Function.prototype.before = function (beforeFn) {
var _self = this;
return function () {
beforeFn.apply(this, arguments);
return _self.apply(this, arguments);
};
};

Function.prototype.after = function (afterFn) {
var _self = this;
return function () {
var ret = _self.apply(this, arguments);
afterFn.apply(this, arguments);
return ret;
};
};

function ajax(type, url, params){
message.value = `${type} ----> ${url} -----> ${JSON.stringify(params)}`;
}

function getToken(){
// do something
return 'token';
}

ajax = ajax.before(function(type, url, params){
params.token = getToken();
})

ajax('get', 'https://www.baidu.com/userinfo', {name: 'se', password: 'xsdsd'});
return {
message,
};
},
});
app.mount("#app");
</script>


3.表单校验



  • 在日常开发中,我们经常要去做校验表单数据,通常的方式是在功能函数中进行判断处理或将判断逻辑提取为一个函数的方式。但这种方式其实是与功能性函数相混合,且校验逻辑与功能性函数有耦合关系。那我们可使用 AOP 方式来改进。

  • 如下:


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>AOP表单验证</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://unpkg.com/vue@3.2.20/dist/vue.global.js"></script>
</head>
<body>
<div id="app">
<label>
姓名:
<input
type="text"
v-model="data.name"
placeholder="请输入姓名"
/>

</label>
<label>
密码:
<input
type="text"
v-model="data.pass"
placeholder="请输入密码"
/>

</label>
<p v-if="data.name || data.pass">{{data.name + '/' + data.pass}} ----after-----> {{data.message}}</p>
<hr>
<button @click="submitBtn">submit</button>
</div>
</body>
</html>

<script type="text/javascript">
const { reactive, ref, createApp, watchEffect } = Vue;
const app = createApp({
setup() {
const data = reactive({
name: "",
pass: "",
message: "",
});

Function.prototype.before = function (beforeFn) {
var _self = this;
return function () {
if (beforeFn.apply(this, arguments) === false) return;
return _self.apply(this, arguments);
};
};

function valid() {
if (!data.name || !data.pass) {
alert("用户名或密码不能为空");
return false;
}
}

function formSubmit() {
console.log("data ------>", data);
data.message = `${data.name} ------- ${data.pass}`;
}

formSubmit = formSubmit.before(valid);

function submitBtn() {
formSubmit();
}
return {
data,
submitBtn,
};
},
});
app.mount("#app");
</script>


装饰器模式的优缺点



  • 优点:

    1. 扩展性强:装饰器模式允许在不修改现有代码的情况下,动态地添加新功能或修改现有功能。通过使用装饰器,可以在运行时按需组合和堆叠装饰器对象,实现各种组合方式,从而实现更多的功能扩展。

    2. 遵循开闭原则:装饰器模式通过添加装饰器类来扩展功能,而不是修改现有的代码。这样可以保持原有代码的稳定性,符合开闭原则,即对扩展开放,对修改关闭。

    3. 分离关注点:装饰器模式将功能的扩展和核心功能分离开来,每个装饰器类只关注单一的额外功能。这样可以使代码更加清晰、可读性更高,并且容易维护和测试。



  • 缺点:

    1. 增加复杂性:使用装饰器模式会增加额外的类和对象,引入了更多的复杂性和层次结构。这可能使代码变得更加复杂,理解和调试起来可能更加困难。

    2. 潜在的性能影响:由于装饰器模式涉及多个对象的组合和堆叠,可能会引入额外的运行时开销,对性能产生一定的影响。尤其是当装饰器链较长时,可能会导致性能下降。




装饰器模式的适用场景



  1. 动态地扩展对象功能:当需要在运行时动态地为对象添加额外的功能或责任时,装饰器模式是一个很好的选择

  2. 遵循开闭原则:如果你希望在不修改现有代码的情况下扩展功能,而且要保持代码的稳定性,装饰器模式是一个合适的解决方案。

  3. 分离关注点:当你希望将不同的功能分离开来,使每个功能都有自己独立的装饰器类时,装饰器模式是有用的。每个装饰器只关注单一的额外功能,这样可以使代码更加清晰、可读性更高,并且容易维护和测试。

  4. 多层次的功能组合:如果你需要实现多个功能的组合,而且每个功能都可以灵活选择是否添加,装饰器模式可以很好地满足这个需求。通过堆叠多个装饰器对象,可以按照特定的顺序组合功能,实现各种组合方式。

  5. 继承关系的替代方案:当你面临类似于创建大量子类的情况时,装饰器模式可以作为继承关系的替代方案。通过使用装饰器模式,可以避免创建过多的子类,而是通过组合不同的装饰器来实现不同的功能组合。


Tip: 文章部分内容参考于曾探大佬的《JavaScript 设计模式与开发实践》。文章仅做个人学习总结和知识汇总

作者:南囝coding
来源:juejin.cn/post/7272869799960559679
收起阅读 »

还在封装 xxxForm,xxxTable 残害你的同事?试试这个工具

web
之前写过一篇文章 我理想中的低代码开发工具的形态,已经吐槽了各种封装 xxxForm,xxxTable 的行为,这里就不啰嗦了。今天再来看看我的工具达到了什么程度。 多图预警。。。 以管理后台一个列表页为例 选择对应的模板 截图查询区域,使用 OCR 初始...
继续阅读 »

之前写过一篇文章 我理想中的低代码开发工具的形态,已经吐槽了各种封装 xxxForm,xxxTable 的行为,这里就不啰嗦了。今天再来看看我的工具达到了什么程度。


多图预警。。。


以管理后台一个列表页为例



选择对应的模板



截图查询区域,使用 OCR 初始化查询表单的配置



截图表头,使用 OCR 初始化 table 的配置



使用 ChatGPT 翻译中文字段



生成代码



效果


目前我们没有写一行代码,就已经达到了如下的效果



下面是一部分生成的代码


import { reactive, ref } from 'vue'

import { IFetchTableListResult } from './api'

interface ITableListItem {
/**
* 决算单状态
*/

settlementStatus: string
/**
* 主合同编号
*/

mainContractNumber: string
/**
* 客户名称
*/

customerName: string
/**
* 客户手机号
*/

customerPhone: string
/**
* 房屋地址
*/

houseAddress: string
/**
* 工程管理
*/

projectManagement: string
/**
* 接口返回的数据,新增字段不需要改 ITableListItem 直接从这里取
*/

apiResult: IFetchTableListResult['result']['records'][0]
}

interface IFormData {
/**
* 决算单状态
*/

settlementStatus?: string
/**
* 主合同编号
*/

mainContractNumber?: string
/**
* 客户名称
*/

customerName?: string
/**
* 客户手机号
*/

customerPhone?: string
/**
* 工程管理
*/

projectManagement?: string
}

interface IOptionItem {
label: string
value: string
}

interface IOptions {
settlementStatus: IOptionItem[]
}

const defaultOptions: IOptions = {
settlementStatus: [],
}

export const defaultFormData: IFormData = {
settlementStatus: undefined,
mainContractNumber: undefined,
customerName: undefined,
customerPhone: undefined,
projectManagement: undefined,
}

export const useModel = () => {
const filterForm = reactive<IFormData>({ ...defaultFormData })

const options = reactive<IOptions>({ ...defaultOptions })

const tableList = ref<(ITableListItem & { _?: unknown })[]>([])

const pagination = reactive<{
page: number
pageSize: number
total: number
}>({
page: 1,
pageSize: 10,
total: 0,
})

const loading = reactive<{ list: boolean }>({
list: false,
})

return {
filterForm,
options,
tableList,
pagination,
loading,
}
}

export type Model = ReturnType<typeof useModel>


这就是用模板生成的好处,有规范,随时可以改,而封装 xxxForm,xxxTable 就是一个黑盒。


原理


下面大致说一下原理



首先是写好一个个模版,vscode 插件读取指定目录下模版显示到界面上



每个模版下可能包含如下内容:



选择模版后,进入动态表单配置界面



动态表单是读取 config/schema.json 里的内容进行动态渲染的,目前支持 amis、form-render、formily



配置表单是为了生成 JSON 数据,然后根据 JSON 数据生成代码。所以最终还是无法避免的使用私有的 DSL ,但是生成后的代码是没有私有 DSL 的痕迹的。生成代码本质是 JSON + EJS 模版引擎编译 src 目录下的 ejs 文件。


为了加快表单的配置,可以自定义脚本进行操作



这部分内容是读取 config/preview.json 内容进行显示的



选择对应的脚本方法后,插件会动态加载 script/index.js 脚本,并执行里面对应的方法



以 initColumnsFromImage 方法为例,这个方法是读取剪贴板里的图片,然后使用百度 OCR 解析出文本,再使用文本初始化表单


initColumnsFromImage: async (lowcodeContext) => {
context.lowcodeContext = lowcodeContext;
const res = await main.handleInitColumnsFromImage();
return res;
},

export async function handleInitColumnsFromImage() {
const { lowcodeContext } = context;
if (!lowcodeContext?.clipboardImage) {
window.showInformationMessage('剪贴板里没有截图');
return lowcodeContext?.model;
}
const ocrRes = await generalBasic({ image: lowcodeContext!.clipboardImage! });
env.clipboard.writeText(ocrRes.words_result.map((s) => s.words).join('\r\n'));
window.showInformationMessage('内容已经复制到剪贴板');
const columns = ocrRes.words_result.map((s) => ({
slot: false,
title: s.words,
dataIndex: s.words,
key: s.words,
}));
return { ...lowcodeContext.model, columns };
}

反正就是可以根据自己的需求定义各种各样的脚本。比如使用 ChatGPT 翻译 JSON 里的指定字段,可以看我的上一篇文章 TypeChat、JSONSchemaChat实战 - 让ChatGPT更听你的话


再比如要实现把中文翻译成英文,然后英文使用驼峰语法,这样就可以将中文转成英文代码变量,下面是实现的效果



选择对应的命令菜单后 vscode 插件会加载对应模版里的脚本,然后执行里面的 onSelect 方法。



main.ts 代码如下


import { env, window, Range } from 'vscode';
import { context } from './context';

export async function bootstrap() {
const clipboardText = await env.clipboard.readText();
const { selection, document } = window.activeTextEditor!;
const selectText = document.getText(selection).trim();
let content = await context.lowcodeContext!.createChatCompletion({
messages: [
{
role: 'system',
content: `你是一个翻译家,你的目标是把中文翻译成英文单词,请翻译时使用驼峰格式,小写字母开头,不要带翻译腔,而是要翻译得自然、流畅和地道,使用优美和高雅的表达方式。请翻译下面用户输入的内容`,
},
{
role: 'user',
content: selectText || clipboardText,
},
],
});
content = content.charAt(0).toLowerCase() + content.slice(1);
window.activeTextEditor?.edit((editBuilder) => {
if (window.activeTextEditor?.selection.isEmpty) {
editBuilder.insert(window.activeTextEditor.selection.start, content);
} else {
editBuilder.replace(
new Range(
window.activeTextEditor!.selection.start,
window.activeTextEditor!.selection.end,
),
content,
);
}
});
}


使用了 ChatGPT。


再来看看,之前生成管理后台 CURD 页面的时候,连 mock 也一起生成了,主要逻辑放在了 complete 方法里,这是插件的一个生命周期函数。



因为 mock 服务在另一个项目里,所以需要跨目录去生成代码,这里我在 mock 服务里加了个接口返回 mock 项目所在的目录


.get(`/mockProjectPath`, async (ctx, next) => {
ctx.body = {
status: 200,
msg: '',
result: __dirname,
};
})

生成代码的时候请求这个接口,就知道往哪个目录生成代码了


const mockProjectPathRes = await axios
.get('http://localhost:3001/mockProjectPath', { timeout: 1000 })
.catch(() => {
window.showInformationMessage(
'获取 mock 项目路径失败,跳过更新 mock 服务',
);
});
if (mockProjectPathRes?.data.result) {
const projectName = workspace.rootPath
?.replace(/\\/g, '/')
.split('/')
.pop();
const mockRouteFile = path.join(
mockProjectPathRes.data.result,
`${projectName}.js`,
);
let mockFileContent = `
import KoaRouter from 'koa-router';
import proxy from '../middleware/Proxy';
import { delay } from '../lib/util';

const Mock = require('mockjs');

const { Random } = Mock;

const router = new KoaRouter();
router{{mockScript}}
module.exports = router;
`
;

if (fs.existsSync(mockRouteFile)) {
mockFileContent = fs.readFileSync(mockRouteFile).toString().toString();
const index = mockFileContent.lastIndexOf(')') + 1;
mockFileContent = `${mockFileContent.substring(
0,
index,
)}
{{mockScript}}\n${mockFileContent.substring(index)}`
;
}
mockFileContent = mockFileContent.replace(/{{mockScript}}/g, mockScript);
fs.writeFileSync(mockRouteFile, mockFileContent);
try {
execa.sync('node', [
path.join(
mockProjectPathRes.data.result
.replace(/\\/g, '/')
.replace('/src/routes', ''),
'/node_modules/eslint/bin/eslint.js',
),
mockRouteFile,
'--resolve-plugins-relative-to',
mockProjectPathRes.data.result
.replace(/\\/g, '/')
.replace('/src/routes', ''),
'--fix',
]);
} catch (err) {
console.log(err);
}

mock 项目也可以通过 vscode 插件快速创建和使用



上面展示的模版都放在了 github.com/lowcode-sca… 仓库里,照着 README 步骤做就可以使用了。


作者:若邪
来源:juejin.cn/post/7315242945454735414
收起阅读 »

精美绝伦:小程序日历组件推荐

web
前言众所周知,小程序的渲染性能一直被广大开发者诟病,2023年中旬,小程序团队正式发布了 skyline 渲染引擎,Skyline,旨在替代 WebView 作为小程序的渲染层,以提供更优秀的渲染性能和诸多增强特性,让小程序能达到原生的体验...
继续阅读 »

前言

众所周知,小程序的渲染性能一直被广大开发者诟病,2023年中旬,小程序团队正式发布了 skyline 渲染引擎,Skyline,旨在替代 WebView 作为小程序的渲染层,以提供更优秀的渲染性能和诸多增强特性,让小程序能达到原生的体验。

非常好,那么就是说我们可以在小程序上体验类原生的特性啦!这下谁敢再说小程序是屎?

尝试

在用了一段时间,主要尝试了canvas、手势组件动画等功能,惊奇的发现,小程序做的这个 skyline 渲染引擎,是一陀超大的屎。

噢!腾讯,你小子好啊,研究了这么长时间,跑我这排宿便了是吧?

image.png

image.png

自己写的样式和iconfont样式给我报了很多坨警告,能用吗?能用,但是我是屎我需要恶心你,我必须得给你点警告。

除了控制台脏了之外,还有各种各样数不清的 bug。比如,地图的bindregionchange失效,而你去论坛发,他们只会说:"未复现"、"写片段"、"你试试",发文时实测依然没有修复😅。

爱莲说

铺垫了这么多,实属无奈,我也不想说这么多,只是这口屎憋在嘴里,臭的难受。我本以为出淤泥而不染已经很难得了,没想到在这屎坑里还有大佬栽培了一朵精美绝伦的白莲花,它就是 lspriv/wx-calendar ,github链接:github.com/lspriv/wx-c…

看到这么牛逼的组件,只有区区一百来个 star。

牛逼不牛逼,直接看效果:

QQ2024422-123019.webp

它还同时支持 skyline 和 webview 下渲染。

image.png

每个场景都是丝滑过渡的,元素到元素的联合动画。看的出来,这个日历是有很重的 MIUI 风格的,如果不是右上角的小程序胶囊,我甚至以为是某手机的自带日历。

QQ2024422-14944.webp

依赖 skyline 的 worklet 动画,组件做到了跟手、丝滑,且符合直觉的动画。

lspriv/wx-calendar 使用

lspriv/wx-calendar 需要使用npm下载并构建,然后引入组件使用。

npm i @lspriv/wx-calendar

然后需要使用微信开发者工具构建 npm

{
   "usingComponents": {
       "calendar": "@lspriv/wx-calendar"
  }
}
id="calendar" bindload="handleLoad" />

生态

作者十分聪明,给 lspriv/wx-calendar 预留了插件接口,开发者可以根据自身需求,写扩展功能。

源码中 src>plugins>lunar.ts 是一个内置插件,实现了农历、节气、闰年等功能。

目前为止,还没有看到有第二个人为作者贡献插件。

展望

目前还有很多基础功能还没有开发,比如

  1. 日期标注,日期标注有是有,但是作者将几种标记方式写死了,只能用内置的日程、角标和节假日标记,开发中肯定是期望可以传入组件或自定义样式的。
  2. 选择区间,一个很常见的场景,需要选择日期区间,跨月、跨年选择,这些在不了解源码的情况下,去手写插件也是比较困难的。
  3. 自定义样式,作者将自己的样式隔离了,开发者只能通过传入指定的style字符串修改样式,这个用起来不是很方便。

结语

总的来说,这是一款不可多得的组件,即使在PC端,也是不常见的。在小程序的层层阻挠下能开发出如此的组件,实属不易。

ce2898a24a9846c59a058e07eaeea24c_tplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif


作者:德莱厄斯
来源:juejin.cn/post/7360237771637489679

收起阅读 »

领导让我加水印

web
tips: 文末有完整示例代码。 领导:『小S啊,我们有个新需求🥸,需要在预览的资源上添加水印,让服务端来加水印成本太高了,在前端渲染的时候把水印加上可以吗🤨?』 小S:『加水印啊,简简单单🤏。我们项目使用的是 Vue3,使用自定义指令一下就可以加好了。领导...
继续阅读 »

tips: 文末有完整示例代码。



领导:『小S啊,我们有个新需求🥸,需要在预览的资源上添加水印,让服务端来加水印成本太高了,在前端渲染的时候把水印加上可以吗🤨?』


小S:『加水印啊,简简单单🤏。我们项目使用的是 Vue3,使用自定义指令一下就可以加好了。领导你看我操作!』


小S说着,就把生产力工具打开了。手速熟练🤠的启动了项目。


小S:『领导你看😈,我先在项目自定义指令的文件夹下新建一个自定义水印指令文件 - watermark.ts。在需要添加水印的目标 Dom 挂载时,创建一个 canvas 节点,canvas 的宽高自然要跟 Dom 的大小一样啦,层级也必须是最高的。然后我再给 canvas 里画上水印内容,最后再给 canvas 挂载到目标节点。当然啦,目标节点销毁时也要把 canvas 销毁掉。』


小S一边讲,一边就在生产力工具中敲🫳出了代码。


import type { Directive, App } from 'vue';
import { nextTick } from 'vue';

const watermarkDirective: Directive = {
// 在绑定元素的父组件
// 及他自己的所有子节点都挂载完成后调用
async mounted(el, binding) {
await createWatermark(el, binding.value.text);
},
// 绑定元素的父组件卸载后调用
unmounted(el) {
removeWatermark(el);
},
};

/** 创建水印 */
async function createWatermark(el, text: string) {
const canvasEl = document.createElement('canvas');
const newCanvas = !el.querySelector('canvas');

canvasEl.id = 'watermark-canvas';
canvasEl.style.position = 'absolute';
canvasEl.style.top = '0';
canvasEl.style.left = '0';
canvasEl.style.zIndex = '99';
canvasEl.style.pointerEvents = 'none';
el.appendChild(canvasEl);
canvasEl.width = window.screen.width;
canvasEl.height = window.screen.height;
const ctx = canvasEl.getContext('2d');
ctx.rotate((-20 * Math.PI) / 180); //旋转角度
ctx.font = '24px serif';
ctx.fillStyle = 'rgba(180, 180, 180, 0.3)';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let i = -canvasEl.width / 100; i < canvasEl.width / 100; i++) {
for (let j = -canvasEl.height / 200; j < canvasEl.height / 200; j++) {
ctx.fillText(text, i * 300, j * 300);
}
}
}

async function removeWatermark(el) {
await nextTick();
const canvasEl = el.querySelector('#watermark-canvas');
if (canvasEl) {
canvasEl.remove();
}
}

export default watermarkDirective;


小S得意😏的抖着腿,侧身向领导讲到:『这样就可以生成水印啦! 撒花🥳🥳🥳』。


领导🫲🫱:『你这样是可以实现了,但是也仅仅可以防一下小白,稍微懂点前端知识的人,都可以 F12 把控制台打开,选中水印节点,给它哐哐哐删掉。』


小S听了,一拍脑门:『是哦,我怎么没想到呢!嗯……』小S陷入了沉思,如何防止被删掉呢?小S脑子转了3圈后:『领导,我知道怎么做了!DOM3 Event 规范中有一个 MutationObserver,这个接口可以监视 DOM 进行监视,只要我的水印被删掉了,我就赶紧再生成一个水印!』


小S立刻转身,一边思索🤔着逻辑,一边在生产力工具中继续完善:


小S心里想到:『在目标节点挂载,首次添加 canvas 时,我给目标节点添加 MutationObserver 监听,并把实例化的监视器放在目标节点的自定义属性上,监听它的子节点,如果监听到子节点水印被删除,我就再新建一个水印 canvas,插入到目标节点中,对了,还要考虑到我主动删除水印的操作。水印节点也要加监视,不然手动改一下水印的CSS样式,就可以把水印给隐藏掉了。emmm……最后在 目标节点卸载时把监听移除掉。』


小S搞好了,转身给领导讲道:『领导,搞定了!使用的时候只需要引入自定义指令,在需要加水印的节点添加参数就可以啦』


<template>
<div v-watermark="watermarkOption">
<img src="xxxx">
</div>
</template>

<script setup lang="ts">
// @ts-ignore
import vWatermark from '/@/directives/watermark';

const watermarkOption = {
text: '小S水印'
}
</script>

领导看着小S加好水印,笑😼着说:『针不错,这就去给你涨工资!』


小S听了,连忙摇头🙀道:『领导,不用,不用,这都是前端切图仔的基本功!』




END




完整示例代码


import type { Directive, App } from 'vue';
import { nextTick } from 'vue';

const watermarkDirective: Directive = {
// 在绑定元素的父组件
// 及他自己的所有子节点都挂载完成后调用
async mounted(el, binding) {
await createWatermark(el, binding.value.text);
},
// 绑定元素的父组件卸载后调用
unmounted(el) {
removeWatermark(el);
},
};

async function createWatermark(el, text: string) {
const canvasEl = el.querySelector('canvas') || document.createElement('canvas');
const newCanvas = !el.querySelector('canvas');

if (!el.dataset.mutationObserverParent) {
const mutationObserver = new MutationObserver((records) =>
parentCheckWatermark(records, el, text),
);
mutationObserver.observe(el, {
childList: true,
});
el.dataset.mutationObserverParent = mutationObserver;
}
canvasEl.id = 'watermark-canvas';
canvasEl.style.position = 'absolute';
canvasEl.style.top = '0';
canvasEl.style.left = '0';
canvasEl.style.zIndex = '99';
canvasEl.style.pointerEvents = 'none';
newCanvas && el.appendChild(canvasEl);
canvasEl.width = window.screen.width * 3;
canvasEl.height = window.screen.height * 3;
const ctx = canvasEl.getContext('2d');
if (!ctx) return;
ctx.rotate((-20 * Math.PI) / 180); //旋转角度
ctx.font = '24px serif';
ctx.fillStyle = 'rgba(180, 180, 180, 0.3)';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let i = -canvasEl.width / 100; i < canvasEl.width / 100; i++) {
for (let j = -canvasEl.height / 200; j < canvasEl.height / 200; j++) {
ctx.fillText(text, i * 300, j * 300);
}
}

if (newCanvas) {
// 水印属性监听
const mutationObserver = new MutationObserver(() => canvasCheckWatermark(el, text));
mutationObserver.observe(canvasEl, {
attributes: true,
});
el.dataset.mutationObserverCanvas = mutationObserver;
}
}

/** 检查水印是否被删除 */
async function parentCheckWatermark(records, el, text) {
// 主动删除水印不处理
if (el.dataset.focusRemove) return;
const removedNodes = records[0].removedNodes;
let hasDelWatermark = false;
removedNodes.forEach((el) => {
if (el.id === 'watermark-canvas') {
hasDelWatermark = true;
}
});
// 水印被删除了
hasDelWatermark && createWatermark(el, text);
}

/** 检查水印属性是否变化了 */
async function canvasCheckWatermark(el, text) {
// 防止多次触发
if (el.dataset.canvasRending) return;
el.dataset.canvasRending = 'rending';

// 水印canvas属性变化了,重新创建
await createWatermark(el, text);
el.dataset.canvasRending = '';
}

async function removeWatermark(el) {
el.dataset.focusRemove = true;
el.dataset.mutationObserverParent?.disconnect?.();
await nextTick();
const canvasEl = el.querySelector('#watermark-canvas');
if (canvasEl) {
canvasEl.dataset.mutationObserverCanvas?.disconnect?.();
canvasEl.remove();
}
}
export default watermarkDirective;

作者:sofor
来源:juejin.cn/post/7360269869399392310
收起阅读 »

奇技淫巧:如何修改第三方npm包?

web
如何修改第三方npm包? 有这样一种场景:如果你在开发过程中,发现某个npm包有Bug,应该怎么办? 第一选择当然是向原作者提issue,或者Fork该仓库代码,修改以后,提交合并请求。 但这种情况有个严重的问题,就是耗时,如果遇到严格的作者,或者不活跃的作...
继续阅读 »

如何修改第三方npm包?



有这样一种场景:如果你在开发过程中,发现某个npm包有Bug,应该怎么办?


第一选择当然是向原作者提issue,或者Fork该仓库代码,修改以后,提交合并请求。


但这种情况有个严重的问题,就是耗时,如果遇到严格的作者,或者不活跃的作者,时间线可能会拉得很长。你的项目未必能等这么长时间。


还有一种可能是,你修改的代码并不具备普适性,只有你的业务场景能用到,合并被拒的概率会大大增加。


总而言之,如果能修改npm包的源包,再好不过,如果不行,则需要有个临时方案,或者替代方案。


这时,又有下面两种情况:



  1. 代码量少,可以直接修改npm包代码的,考虑补丁方案。

  2. 代码量多,或者npm包代码是压缩混淆过的,不具备修改条件。修改源码后,再修改包名,重新发布,在应用代码中更换引用。为叙文方便,我将这种方案命名为换日方案(偷天换日,李代桃僵)。


下面,详细介绍下这两种不同方案。


补丁方案


patch-package


patch-package是一个用于修复第三方依赖包的工具,使用方式非常简单。



它支持npm和yarn v1,如果是yarn v2+或者pnpm,则使用自带的patch方案(下文会介绍pnpm方案)。



安装:


$ npm i patch-package
$ yarn add patch-package postinstall-postinstall


如果只是前端使用,可以添加--dev或-D参数。如果是后端使用,为保障生产模式(会去除devDendencies依赖)也能正常使用,就不要加了。



在node_modules中找到你要修改的npm包,修改内容后,就可以运行patch-package创建patch文件了。


$ npx patch-package package-name   # 使用npm
$ yarn patch-package package-name # 使用yarn

运行后会在项目根目录下创建一个patches文件夹,并生成一个名为package-name+version.patch的文件。将该patch文件提交至版本控制中,即可在之后应用该补丁了。


以我修改的verdaccio为例,会生成一个verdaccio+4.4.0.patch的文件,内容大致如下:


diff --git a/node_modules/verdaccio/build/index.js b/node_modules/verdaccio/build/index.js
index 3a79eaa..d00974b 100644
--- a/node_modules/verdaccio/build/index.js
+++ b/node_modules/verdaccio/build/index.js
@@ -5,6 +5,8 @@ Object.defineProperty(exports, "__esModule", {
});
exports.default = void 0;

+console.log('---------------')
+
var _bootstrap = require("./lib/bootstrap");

完成上述操作后,最后在package.jsonscripts中加入"postinstall": "patch-package"


"scripts": {
"postinstall": "patch-package"
}

这样当其他同事拉下代码,运行npm install或是yarn install命令时,便会自动为依赖包打上我们的补丁了。



简单来说,这个方案的原理就是记录补丁的代码与位置,利用npm的hook(postinstall会在npm install后触发),在安装完依赖以后,触发相应的脚本,将补丁覆盖到node_modules对应的包里。


当然,补丁是对应具体版本的,需要锁定版本号。这样的缺点是如果要升级的话,还得重新来一遍,不过不是有Bug或性能问题,通常不必追求新的版本。



pnpm patch


pnpm的patch自称灵感来自yarn的类似命令。由于yarn v2可能走了邪路,我们就不介绍了。


首先,执行pnpm patch @。该命令会将指定的软件包提取到一个可以随意编辑的临时目录中。


完成修改后, 运行pnpm patch-commit ( 是之前提取的临时目录,这个临时目录会长到你根本记不住,不过不用担心,命令行里会有完备的提示) 以生成一个补丁文件,并提供patchedDependencies 字段注册到你的项目中。


比如,我想修改一个is-even的包:


pnpm patch is-even  
You can now edit the following folder: /private/var/folders/sq/0jfgh1js6cs8_31df82hx3jw0000gn/T/29ba74c7c7ffd7aa157831c6436d3738

Once you're done with your changes, run "pnpm patch-commit /private/var/folders/sq/0jfgh1js6cs8_31df82hx3jw0000gn/T/29ba74c7c7ffd7aa157831c6436d3738"

按照提示,打开这个文件夹,加一行代码:
image.png


执行上面控制台的提示:


pnpm patch-commit /private/var/folders/sq/0jfgh1js6cs8_31df82hx3jw0000gn/T/e103de90617a18eee7942d1df35a2c48
Packages: -1
-
Progress: resolved 5, reused 6, downloaded 0, added 1, done

这时你会发现package.json中多了一段内容:


"pnpm": {
"patchedDependencies": {
"is-even@1.0.0": "patches/is-even@1.0.0.patch"
}
}

根目录下,也多了个文件夹patches,打开以后,你就能找到添加的代码:
image.png


打开node_modules/is-even/index.js,可以看到已经多了我们添加的代码:
image.png


删除node_modules,重新pnpm i安装依赖,仍然与现在一样,这就代表成功了。


整个流程下来,我们看得出来相比于patch-package,要稍微复杂点儿,但也是可以接受的。



注意:patches目录是一定得提交到git的。



换日方案


上面说过,如果要修改的代码较多,或者不具备修改条件,这时就需要修改源码。
到GitHub上找到npm包的源码,Fork该项目,修改代码后,再修改包名,重新发布,比如你要修改的包是lodash,可以修改为awesome-lodash,在应用代码中更换引用。


本来这个方案没什么好说的,但有一种情况,如果你修改的是个底层包,也就是说并不是你的应用代码中直接引用的,而是你引用的npm包A所依赖的,甚至可能同时被包B依赖的,这时就比较尴尬了,你不可能再去修改A和B的源码,那就太不值当了。


pnpm提供了一种别名(Aliases)的能力。


假设你发布了一个名为awesome-lodash的新包,并使用lodash作为别名来安装它:


$ pnpm add lodash@npm:awesome-lodash

不需要更改代码,所有的lodash引用都被解析到了awesome-lodash
就这么简单,上面说的问题就解决了。


再说点儿题外话,有时你会想要在项目中使用一个包的两个不同版本,很简单:


$ pnpm add lodash1@npm:lodash@1
$ pnpm add lodash2@npm:lodash@2

现在,您可以通过 require('lodash1') 引入第一个版本的 lodash 并通过 require('lodash2') 引入第二个。


与pnpm的钩子结合使用功能会更加强大,比如你想将node_modules里所有的lodash引用也替换为awesome-lodash,你可以用下面的.pnpmfile.cjs 轻松实现:


function readPackage(pkg) {
if (pkg.dependencies && pkg.dependencies.lodash) {
pkg.dependencies.lodash = 'npm:awesome-lodash@^1.0.0'
}
return pkg
}

module.exports = {
hooks: {
readPackage
}
}

pnpm功能非常强大,后面我会再详细写篇文章介绍下。


总结


在开发过程中发现npm包的Bug,首先向原作者提交issue或Fork代码修改后提交合并请求。但遇到不活跃或拒绝修改的情况,项目等待时间会很长。这时可以使用补丁方案或换日方案进行解决。


补丁方案中,如果是npm或yarn v1,可以使用patch-package工具包处理;如果是yarn v2或pnpm,可以使用各自的patch命令。


换日方案,则是修改源码,发布新的npm包后,利用pnpm的别名功能,将所有依赖原npm包的地方,全部替换为新的包。


这种场景在日常开发中还是比较常见的,这里为大家提供一种思路。当然,如果真是个Bug,别忘了提issue或PR,为开源贡献自己的一份力量,在与作者的沟通交流中,相信你也能受益匪浅。


作者:纪轻昀
来源:juejin.cn/post/7356534347509497919
收起阅读 »

【前端缓存】localStorage是同步还是异步的?为什么?

web
🧑‍💻 写在开头 点赞 + 收藏 === 学会🤣🤣🤣 首先明确一点,localStorage是同步的 🥝 一、首先为什么会有这样的问题 localStorage 是 Web Storage API 的一部分,它提供了一种存储键值对的机制。loca...
继续阅读 »

🧑‍💻 写在开头


点赞 + 收藏 === 学会🤣🤣🤣



首先明确一点,localStorage是同步的



🥝 一、首先为什么会有这样的问题


localStorage 是 Web Storage API 的一部分,它提供了一种存储键值对的机制。localStorage 的数据是持久存储在用户的硬盘上的,而不是内存。这意味着即使用户关闭浏览器或电脑,localStorage 中的数据也不会丢失,除非主动清除浏览器缓存或者使用代码删除。


当你通过 JavaScript 访问 localStorage 时,浏览器会从硬盘中读取数据或向硬盘写入数据。然而,在读写操作期间,数据可能会被暂时存放在内存中,以提高处理速度。但主要的特点是它的持久性,以及它不依赖于会话的持续性。


🍉 二、硬盘不是io设备吗?io读取不都是异步的吗?


是的,硬盘确实是一个 IO 设备,而大部分与硬盘相关的操作系统级IO操作确实是异步进行的,以避免阻塞进程。不过,在 Web 浏览器环境中,localStorage 的API是设计为同步的,即使底层的硬盘读写操作有着IO的特性。


js代码在访问 localStorage 时,浏览器提供的API接口通常会处于js执行线程上下文中直接调用。这意味着尽管硬盘是IO设备,当一个js执行流程访问 localStorage 时,它将同步地等待数据读取或写入完成,该过程中js执行线程会阻塞。


这种同步API设计意味着开发者在操作 localStorage 时不需要考虑回调函数或者Promise等异步处理模式,可以按照同步代码的方式来编写。不过,这也意味着如果涉及较多数据的读写操作时,可能对性能产生负面影响,特别是在主线程上,因为它会阻塞UI的更新和其他js的执行。


🍑 三、完整操作流程


localStorage 实现同步存储的方式就是阻塞 JavaScript 的执行,直到数据的读取或者写入操作完成。这种同步操作的实现可以简单概述如下:



  1. js线程调用: 当 JavaScript 代码执行一个 localStorage 的操作,比如 localStorage.getItem('key') 或 localStorage.setItem('key', 'value'),这个调用发生在 js 的单个线程上。

  2. 浏览器引擎处理: 浏览器的 js 引擎接收到调用请求后,会向浏览器的存储子系统发出同步IO请求。此时 js 引擎等待IO操作的完成。

  3. 文件系统的同步IO: 浏览器存储子系统对硬盘执行实际的存储或检索操作。尽管操作系统层面可能对文件访问进行缓存或优化,但从浏览器的角度看,它会进行一个同步的文件系统操作,直到这个操作返回结果。

  4. 操作完成返回: 一旦IO操作完成,数据要么被写入硬盘,要么被从硬盘读取出来,浏览器存储子系统会将结果返回给 js 引擎。

  5. JavaScript线程继续执行: js 引擎在接收到操作完成的信号后,才会继续执行下一条 js 代码。


在同步的 localStorage 操作期间,由于 js 的单线程性质,整个线程会阻塞,即不会执行其他任何js代码,也不会进行任何渲染操作,直到 localStorage 调用返回。


🍒 四、localStorage限制容量都是因为同步会阻塞的原因吗?



  1. 资源公平分享:同一用户可能会访问大量不同的网站,如果没有限制,随着时间的积累,每个网站可能会消耗大量的本地存储资源。这样会导致本地存储空间被少数几个站点占用,影响到用户访问其他网页的体验。限制大小可以确保所有网站都有公平的存储机会。

  2. 防止滥用:如果没有存储限制,网站可能会滥用 localStorage,存储大量数据在用户的设备上,这可能导致设备存储空间迅速耗尽,也可能侵犯用户的隐私。

  3. 性能限制:如之前提到的,localStorage 的操作是阻塞的。如果网站能够存储大量数据,就会加剧读写操作对页面性能的影响。

  4. 存储效率localStorage 存储的是字符串形式的数据,不是为存储大量或结构化数据设计的。当尝试存储过多数据时,效率会降低。

  5. 历史和兼容性:5MB 的限制很早就已经被大多数浏览器实现,并被作为一个非正式的标准被采纳。尽管现在有些浏览器支持更大的 localStorage,但出于跨浏览器兼容性的考虑,开发者通常会假设这个限制。

  6. 浏览器政策:浏览器厂商可能会依据自己的政策来设定限制,可能是出于提供用户更一致体验的角度,或者是出于管理用户数据的方便。


🍐 五、那indexDB会造成滥用吗?


虽然它们提供了更大的存储空间和更丰富的功能,但确实潜在地也可能被滥用。但是与相比 localStorage 增加了一些特性用来降低被滥用的风险:



  1. 异步操作IndexedDB 是一个异步API,即使它被用来处理更大量的数据,也不会像 localStorage 那样阻塞主线程,从而避免了对页面响应性的直接影响。

  2. 用户提示和权限:对于某些浏览器,当网站尝试存储大量数据时,浏览器可能会弹出提示,要求用户授权。这意味着用户有机会拒绝超出合理范围的存储请求。

  3. 存储配额和限制:尽管 IndexedDB 提供的存储容量比 localStorage 大得多,但它也不是无限的。浏览器会为 IndexedDB 设定一定的存储配额,这个配额可能基于可用磁盘空间的一个百分比或者是一个事先设定的限额。配额超出时,浏览器会拒绝更多的存储请求。

  4. 更清晰的存储管理IndexedDB 的数据库形式允许有组织的存储和更容易的数据管理。用户或开发者可以更容易地查看和清理占用的数据。

  5. 逐渐增加的存储:某些浏览器实现 IndexedDB 存储时,可能会在数据库大小增长到一定阈值时,提示用户是否允许继续存储,而不是一开始就分配一个很大的空间。




作者:ObjectX不知名程序员
来源:juejin.cn/post/7359405716090011659
收起阅读 »

微信小程序dom节点最高限制16000?能忍?盘它!!

web
起因 最近参与了个微信小程序的项目,有个长列表模块 我在自测的时候(测试人员居然没发现,可能懒得加载那么多数据去验证),发现当列表加载加载500条以上的时候,会有一个 Dom limit exceeded, please check if there's an...
继续阅读 »

起因


最近参与了个微信小程序的项目,有个长列表模块 我在自测的时候(测试人员居然没发现,可能懒得加载那么多数据去验证),发现当列表加载加载500条以上的时候,会有一个 Dom limit exceeded, please check if there's any mistake you've made.(超过了Dom限制,请检查你是否犯了任何错误) 渲染报错,页面会白屏,所以猜测小程序的Dom节点应该是有最高限制的。


分析问题原因


网上查了很多资料,查到了Dom节点确实有渲染限制,有位大神还晒出了源码 如下图,限制最高16000个节点


image.png


之前只知道 包大小 限制、域名需要配置不然限制、各种授权api限制,没想到 dom节点数量还要限制 居然还有这种骚操作


图片.png


不过想想也算合理,因为小程序 小程序,小才行,哈哈,资源也不可能无限大,为的就是让你在有限的资源内完成强大的功能。


实验是检验真理的唯一标准


上文图中 代码 左上角显示版本是2019年的,那现在都2023年了,会不会有所改进呢,为了搞清楚现在到底限制多少节点,实验一波。


c5d0486839eb2ee8d6711d4985df6b4.png


136f39b2c09b1ff611f4bcb15157f65.png


测试结果如上面两张图 Dom限制数量没变还是 16000个节点,但实际可新增的 渲染节点为 15999个 ,你要是问为啥,那我告诉你,因为小程序默认根标签 page 也占用一个,一个字 牛!!


图片.png


解决方案


既然找了原因,就要想办法解决,经过一番深思熟虑(面向百度思考),总结了以下几个解决方案。


方案一 人在屋檐下不得不低头


毕竟平台是人家的,规则都是人家定的,那么就得按照规则去开发,下图是官方推荐说明(也是评分标准),单页面节点尽量不超过1000个节点,嵌套不超过30层,子节点不超过60个


image.png
动动你灵活嘴皮子,看看能不能说服 产品 说服老板,就这标准 爱做不做,爱谁谁


方案二 就是不服 就是干


长列表 数据过多、dom节点过多 确实有性能问题,但谁让需求是刚需呢,看了下网上的方案 五花八门,有利用swiper 始终只展示固定数量的 swiper-item的 根据当前的 index 显示第几页数据。还有有自己写算法动态显示的。也有很多现成的组件


核心原理 我总结了下就是,只渲染显示在屏幕的数据(为了减少白页面和滑动更丝滑,要再当前屏幕的前后再分别多渲染一屏幕),实现就是监听 scroll 事件,并且重新计算需要渲染的数据,不需要渲染的数据留一个空的 view 空白占位元素。


具体实现方案就不在这里展开了,留下几个已经实现的链接地址


可以扫码体验下腾讯官方新闻小程序的 渲染效率 感觉很哇塞




小程序长列表组件


文章1


文章2


懒人方案 官方 [Skyline 渲染引擎] list-view


注意点: 列表布局容器,仅支持作为 scroll-view 自定义模式下的直接子节点或组件直接子节点


解释下,就是要注意2点


1 scroll-view要设置自定义模式 type="custom"


2 list-view要作为 scroll-view 直接子节点(不然会失去效果)


示例代码如下


    <scroll-view type="custom">
<list-view>
...
循环列表逻辑
...
</list-view>
</scroll-view>

作者:iwhao
来源:juejin.cn/post/7245223225575374905
收起阅读 »

JavaScript变量的奥秘:从声明到使用,一文掌握!

在编程的世界里,数据是构建一切的基础。而在JavaScript中,变量就是存储数据的容器。它们就像是我们生活中的盒子,可以装下各种物品,让我们在需要的时候随时取用。今天,就让我们一起揭开变量的神秘面纱,探索它们的概念、使用规则,以及那些令人头疼的错误。一、变量...
继续阅读 »

在编程的世界里,数据是构建一切的基础。而在JavaScript中,变量就是存储数据的容器。它们就像是我们生活中的盒子,可以装下各种物品,让我们在需要的时候随时取用。

今天,就让我们一起揭开变量的神秘面纱,探索它们的概念、使用规则,以及那些令人头疼的错误。


一、变量的概念和作用

变量,顾名思义,是可以变化的量。在JavaScript中,变量是用来存储数据的,这些数据可以是数字、字符串、对象等等。想象一下,如果没有变量,我们的程序就会变得非常死板,无法灵活地处理和交换信息。

Description

注意: 变量不是数据本身,它们仅仅是一个用于存储数值的容器。可以理解为是一个个用来装东西的纸箱子。


二、变量的基本使用

1)声明变量

要想使用变量,首先需要创建变量(也称为声明变量或者定义变量),JavaScript中通常使用var关键字或者let关键字进行变量的声明操作。

语法:

var age;       //声明一个名为age的变量
let name; //声明一个名为name的变量
  • 声明变量有两部分构成:声明关键字、变量名(标识符)
  • let 即声明关键字,所谓关键字是在JavaScript中有特殊意义的词汇,比如let、var、function、if、else、switch、case、break等。

举例:

let age
  • 我们声明了一个age变量
  • age 即变量的名称,也叫标识符

2) 变量赋值

声明出来后的变量是没有值的,我们需要对声明出来的变量进行赋值操作。

变量赋值的语法为:

var age;       //声明一个名为age的变量
age = 18; //为该个age变量赋值为18

定义了一个变量后,你就能够初始化它(赋值)。在变量名之后跟上一个“=”,然后是数值。

Description

注意: 是通过变量名来获得变量里面的数据。

3)变量初始化

变量初始化就相当于声明变量和变量赋值操作的结合,声明变量并为其初始化。

变量初始化语法为:

var age = 18;   //声明变量age并赋值为18

案例如下:


<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>变量的使用</title>
</head>

<body>
<script>
// 1. 声明一个年龄变量
let age
// 2. 赋值
age = 18
console.log(age)
// 3. 声明的同时直接赋值 变量的初始化
let age2 = 18
// 小案例
let num = 20
let uname = 'pink老师'
console.log(num)
console.log(uname)
</script>
</body>

</html>

4)更新变量

变量赋值后,还可以通过简单地给它一个不同的值来更新它。

Description

注意: let 不允许多次声明一个变量。
案例如下:

<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>变量的使用更新</title>
</head>

<body>
<script>
// 1 声明的同时直接赋值 变量的初始化
// let age = 18
// age = 19
// // let age = 19
// console.log(age)
// 2. 声明多个变量
// let age = 18, uname = '迪丽热巴'
// console.log(age, uname)
</script>
</body>

</html>

5)声明多个变量

语法:多个变量中间用逗号隔开

let age=18,uname='pink'

**说明:**看上去代码长度更短,但并不推荐这样。为了更好的可读性,请一行只声明一个变量。

Description

输入用户名案例:

<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>输入用户名案例</title>
</head>

<body>
<script>
// 输出用户名案例
// 1. 用户输入
// prompt('请输入姓名')
// 2. 内部处理保存数据
let uname = prompt('请输入姓名')
// 3. 打印输出
document.write(uname)
</script>
</body>

</html>

Description


三、let 和var区别

1、var声明的特点:

  • 变量可以先使用再声明(不合理)。

  • var声明过的变量可以重复声明(不合理)。

  • 比如变量提升、全局变量、没有块级作用域等等

2、let 声明的特点:

  • let声明的变量不会被提升,即在声明之前引用let声明的变量系统会直接报错,直接阻断程序的运行。

  • let不可以在同一个作用域下重复声明同一个变量,如果用let重复声明同一个变量,那么这时候就会报错。

  • 用let声明的变量支持块级作用域,在es6提出块级作用域的概念之前,作用域只存在函数里面,或者全局。而es6提出的块级作用域则是一个大括号就是一个块级作用域,该变量只能在块级作用域里使用,否则就会报错。

注意:

var 在现代开发中一般不再使用它,只是我们可能在老版程序中看到它。

let 是为了解决 var 的一些问题而出现的,以后声明变量我们统一使用 let。
案例如下:


<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>let和var的区别</title>
</head>

<body>
<script>
// var可以重复声明,后面声明的覆盖前面的
var num1
var num1 = 10
var num1= 20
console.log(num1)

// let不能重复声明,直接编译不通过
// let num
// let num = 20
// let num = 10
// console.log(num)
</script>
</body>

</html>


四、变量命名规则与规范

规则: 必须遵守,不遵守报错 (法律层面)

  • 不能用关键字(有特殊含义的字符,JavaScript 内置的一些英语词汇,例如:let、var、if、for等)

  • 只能用下划线、字母、数字、$组成,且数字不能开头

  • 字母严格区分大小写,如 Age 和 age 是不同的变量
    **规范:**建议,不遵守不会报错,但不符合业内通识 (道德层面)

  • 起名要有意义

  • 遵守小驼峰命名法:第一个单词首字母小写,后面每个单词首字母大写。例:userName。

Description

案例如下:

<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>变量的命名规范</title>
</head>

<body>
<script>
// let if = 10
let num1$_ = 11
// let nav-bar = 11
// let 1num = 10
//严格区分大小写
let pink = '老师'
let Pink = '演员'
console.log(pink, Pink)
</script>
</body>

</html>


五、Strict(严格)模式

严格模式是一种限制性更强的JavaScript运行环境。在严格模式下,一些不安全或容易出错的行为会被禁止。

  • JavaScript在设计之初,并不强制要求申明变量,如果一个变量没有申明就被使用,那么该变量就自动被声明为全局变量。

  • 在同一个页面的不同的JavaScript文件中,如果都不声明,将造成变量污染。

  • ECMA在后续规范中推出了strict模式,在strict模式下运行的JavaScript代码,强制要求申明变量,否则报错。启用strict模式的方法是在JavaScript代码的第一行写上:

'use strict';

这是一个字符串,不支持strict模式的浏览器会把它当做一个字符串语句执行,支持strict模式的浏览器将开启strict模式运行JavaScript。

想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!点这里前往学习哦!

如果浏览器不报错,说明你的浏览器太古老了,需要尽快升级。

'use strict';
// 如果浏览器支持strict模式,下面的代码将报ReferenceError错误:
abc = 'Hello, world';
console.log(abc);


六、常量const的概念和使用

有时候,我们希望某些变量的值在程序运行过程中保持不变。这时,可以使用const关键字来声明一个常量。

const是ES6引入的一个新特性,用于声明常量。常量一旦被声明并赋值后,其值就不能被改变。这为我们提供了一种保护机制,确保某些值不会被意外修改。

  • 使用场景:当某个变量永远不会改变的时候,就可以使用 const 来声明,而不是let。

  • 命名规范:和变量一致

  • 注意: 常量不允许重新赋值,声明的时候必须赋值(初始化)

案例如下:

<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>常量</title>
</head>

<body>
<script>
// 1.声明常量,使用常量
const PI = 3.14
console.log(PI)
//不允许更改值
//PI = 3.15
// 2. 常量声明的时候必须赋值
//const G
</script>
</body>

</html>


七、常见错误

1、常量必须要赋值

Description

2、常量被重新赋值

Description

3、变量未定义

Description

分析:

  • 提示 age变量没有定义过。

  • 很可能 age 变量没有声明和赋值。

  • 或者我们输出变量名和声明的变量不一致引起的(简单说写错变量名了)。

4、重复声明变量

Description

分析:

  • 提示 “age”已经声明。

  • 很大概率是因为重复声明了一个变量。

  • 注意let 或者const 不允许多次声明同一个变量。

变量是JavaScript编程的基础,掌握了变量的声明和使用,就能更好地理解和编写代码。希望这篇文章能帮助你更好地理解和使用变量,让你的编程之路更加顺畅。

记住,实践是最好的老师,多写代码,多尝试,你会发现,原来变量的世界,也可以如此精彩!

如果觉得本文对你有所帮助,别忘了点赞和分享哦!

收起阅读 »

一行代码引发的离奇问题,众多大佬纷纷参与点评,最终Typescript之父出手解决

web
故事的起因是这样的, 一个前端开发人员(也算是挺有名的,ariakit.org的作者, wrodPress的前维护者)在社交媒体上发了这么一条帖子。 短短几天就有了51.8万次的view。 简单的文案:又是使用 TypeScript 的一天.。表达了对Typ...
继续阅读 »

故事的起因是这样的, 一个前端开发人员(也算是挺有名的,ariakit.org的作者, wrodPress的前维护者)在社交媒体上发了这么一条帖子。


image-20240317131231595.png


短短几天就有了51.8万次的view。 简单的文案:又是使用 TypeScript 的一天.。表达了对Typescript的又爱又恨😂。在目前的前端市场上,Typescript已经成为标配,ts强大的类型检查机制给我们带来了非常多的好处(代码质量,强大的可维护性,代码即注释),但是其槽点也很多, 很多奇奇怪怪的问题(相信不仅是我一个人这么觉得),繁多的配置项组合,稍不注意就会引起页面爆红,代码量增多和代码组织也会引起一定的负担。但在这些并不能撼动Typescript 在目前前端社区中的地位,在开发项目中一般还是会选择typescript。


反应


话说回到这个帖子上,这个帖子发出来之后迅速引起发酵,被很多大佬转发和引用,下面的评论很多都是wait, what, why happen?类似的语气😂,有很多给出建议,比如换种写法, 重启下Typescript server试试,也有很多开发爱好者希望作者能提供一个例子来复现,他们也想看看是什么问题,看能不能尝试解决这个有趣的例子。


(ps: 在ts中有很多奇怪的东西,特别是在和编辑器配合的时候,有些时候不能判断出来是否是个bug?还是我们代码写的有问题?还是设计如此?还是编辑器的问题?还是版本兼容问题?仅代表个人看法)


复现例子


后来有大佬根据作者提供的信息复现出来了样板例子


declare function hasOwnPropertyextends AnyObject>(
object: T,
prop: keyof any,
): prop is keyof T
;

type EffectCallback = () => void;

declare const useSafeLayoutEffect: (effect: EffectCallback) => void;

type AnyObject = Record<string, any>;
export type State = AnyObject;

type AnyFunction = (...args: any) => any;
type BivariantCallbackextends AnyFunction> = {
bivarianceHack(...args: Parameters): ReturnType;
}["bivarianceHack"];
type SetStateAction = T | BivariantCallback<(prevState: T) => T>;

interface StoreState> {
getState(): S;
setStateextends keyof S>(key: K, value: SetStateAction): void;
}

export function useStoreProps<
S
extends State,
P
extends Partial,
K
extends keyof S,
>(
store: Store, props: P, key: K) {
const value = hasOwnProperty(props, key) ? props[key] : undefined;

useSafeLayoutEffect(() => {
if (value === undefined) return;
value;
// ^?
if (value === undefined) return; // toggle this to see the magic
value;
// ^?
store.setState(key, value);
});
}

将鼠标放到倒数第八行上显示value的类型:


const value: P[K] & ({} | null)

但是将鼠标放到倒数第五行时显示的value类型:


const value: P[K] & {}

真是见了鬼了。同样的操作复制了一遍,显示的类型却不一样?是的,这很Typescript😏。


提出issue


issue的地址在这


image-20240317141601821.png


这个提出issue的哥们就是复现样板例子的人,看的出来他应该是个狂热的技术爱好者,执行力也很强,从问作者要出现这种情况的代码仓库可以是否可以公开 ==> 复现样板例子 ==> 给Typescript提出issue(还尝试了自己能不能解决),执行力power👍。


Typescript之父出手解决


在提出issue之后立即就被官方定位是一个bug, 而且Typescript之父还给出了一个简化版可复现的例子:


function f1extends Record<string, any>, K extends keyof T>(x: T[K] | undefined) {
if (x === undefined) return;
x; // T[K] & ({} | null)
if (x === undefined) return;
x; // T[K] & {}
}

通过上面的例子发现null被意外的消除了。


ahejlsberg(ts之父) 写了一个规范化nullundefined在类型系统中的表现的函数解决了这个问题。


image-20240317152740323.png


至此issue被关闭。


我们打开palyground的nightly版本,可以发现这个问题被解决, 错误不在显示了。


总结


这是无意间从网上看到,然后从问题追溯到问题被一步步的解决。从帖子中可以看出来现在大部分用Typescript写项目的人又爱又恨的普遍状态。不管你是多菜的菜鸟也能感受到ts给日益庞大的前端项目带来的好处,不管你是多厉害的大牛也是会遇到一些奇怪的错误。随着Typescript的普及,社区中有很多不同的声音,有热爱者,有反对者,也有随波逐流者,但这也代表Typescript在社区中展现的旺盛生命力。质疑也好,热爱也罢,我觉得ts会越来越好。


作者:xinling_any
来源:juejin.cn/post/7347210988260147210
收起阅读 »

一行代码搞定禁用web开发者工具

web
在如今的互联网时代,网页源码的保护显得尤为重要,特别是前端代码,几乎就是明文展示,很容易造成源码泄露,黑客和恶意用户往往会利用浏览器的开发者工具来窃取网站的敏感信息。为了有效防止用户打开浏览器的Web开发者工具面板,今天推荐一个不错的npm库,可以帮助开发者更...
继续阅读 »

在如今的互联网时代,网页源码的保护显得尤为重要,特别是前端代码,几乎就是明文展示,很容易造成源码泄露,黑客和恶意用户往往会利用浏览器的开发者工具来窃取网站的敏感信息。为了有效防止用户打开浏览器的Web开发者工具面板,今天推荐一个不错的npm库,可以帮助开发者更好地保护自己的网站源码,本文将介绍该库的功能和使用方法。


功能介绍


npm库名称:disable-devtool,github地址:github.com/theajack/disable-devtool。从f12按钮,右键单击和浏览器菜单都可以禁用Web开发工具。



🚀 一行代码搞定禁用web开发者工具



该库有以下特性:



  • 支持可配置是否禁用右键菜单

  • 禁用 f12 和 ctrl+shift+i 等快捷键

  • 支持识别从浏览器菜单栏打开开发者工具并关闭当前页面

  • 开发者可以绕过禁用 (url参数使用tk配合md5加密)

  • 多种监测模式,支持几乎所有浏览器(IE,360,qq浏览器,FireFox,Chrome,Edge...)

  • 高度可配置、使用极简、体积小巧

  • 支持npm引用和script标签引用(属性配置)

  • 识别真移动端与浏览器开发者工具设置插件伪造的移动端,为移动端节省性能

  • 支持识别开发者工具关闭事件

  • 支持可配置是否禁用选择、复制、剪切、粘贴功能

  • 支持识别 eruda 和 vconsole 调试工具

  • 支持挂起和恢复探测器工作

  • 支持配置ignore属性,用以自定义控制是否启用探测器

  • 支持配置iframe中所有父页面的开发者工具禁用


使用方法


使用该库非常简单,只需按照以下步骤进行操作:


1.1 npm 引用


推荐使用这种方式安装使用,使用script脚本可以被代理单独拦截掉从而无法执行。


npm i disable-devtool

import DisableDevtool from 'disable-devtool';

DisableDevtool(options);

1.2 script方式使用


<script disable-devtool-auto src='https://cdn.jsdelivr.net/npm/disable-devtool'>script>

或者通过版本引用:



<script disable-devtool-auto src='https://cdn.jsdelivr.net/npm/disable-devtool@x.x.x'>script>

<script disable-devtool-auto src='https://cdn.jsdelivr.net/npm/disable-devtool@latest'>script>

1.3 npm 方式 options参数说明


options中的参数与说明如下,各方面的配置相当完善。


interface IConfig {
md5?: string; // 绕过禁用的md5值,默认不启用绕过禁用
url?: string; // 关闭页面失败时的跳转页面,默认值为localhost
tkName?: string; // 绕过禁用时的url参数名称,默认为 ddtk
ondevtoolopen?(type: DetectorType, next: Function): void; // 开发者面板打开的回调,启用时url参数无效,type 为监测模式, next函数是关闭当前窗口
ondevtoolclose?(): void; // 开发者面板关闭的回调
interval?: number; // 定时器的时间间隔 默认200ms
disableMenu?: boolean; // 是否禁用右键菜单 默认为true
stopIntervalTime?: number; // 在移动端时取消监视的等待时长
clearIntervalWhenDevOpenTrigger?: boolean; // 是否在触发之后停止监控 默认为false, 在使用ondevtoolclose时该参数无效
detectors?: Array<DetectorType>; // 启用的检测器 检测器详情
clearLog?: boolean; // 是否每次都清除log
disableSelect?: boolean; // 是否禁用选择文本 默认为false
disableCopy?: boolean; // 是否禁用复制 默认为false
disableCut?: boolean; // 是否禁用剪切 默认为false
disablePaste: boolean; // 是否禁用粘贴 默认为false
ignore?: (string|RegExp)[] | null | (()=>boolean); // 某些情况忽略禁用
disableIframeParents?:
boolean; // iframe中是否禁用所有父窗口
timeOutUrl?:
// 关闭页面超时跳转的url;
}

enum DetectorType {
Unknown = -1,
RegToString = 0, // 根据正则检测
DefineId, // 根据dom id检测
Size, // 根据窗口尺寸检测
DateToString, // 根据Date.toString 检测
FuncToString, // 根据Function.toString 检测
Debugger, // 根据断点检测,仅在ios chrome 真机情况下有效
Performance, // 根据log大数据性能检测
DebugLib, // 检测第三方调试工具 erudavconsole
};

1.4 script 方式使用属性配置


<script 
disable-devtool-auto
src='https://cdn.jsdelivr.net/npm/disable-devtool'
md5='xxx'
url='xxx'
tk-name='xxx'
interval='xxx'
disable-menu='xxx'
detectors='xxx'
clear-log='true'
disable-select='true'
disable-copy='true'
disable-cut='true'
disable-paste='true'
>
script>

1.5 事件监听


ondevtoolopen 事件的回调参数就是被触发的监测模式。可以在 ondevtoolopen 里执行业务逻辑,比如做数据上报、用户行为分析等。


DisableDevtool({
ondevtoolopen(type, next){
alert('Devtool opened with type:' + type);
next();
}
});

1.6 md5 与 tk 绕过禁用


该库中使用 key 与 md5 配合的方式使得开发者可以在线上绕过禁用。


流程如下:


先指定一个 key a(该值不要记录在代码中),使用 md5 加密得到一个值 b,将b作为 md5 参数传入,开发者在访问 url 的时候只需要带上url参数 ddtk=a,便可以绕过禁用。


disableDevtool对象暴露了 md5 方法,可供开发者加密时使用:


DisableDevtool.md5('xxx');

更多细节可查阅官方文档,中文文档地址:https://github.com/theajack/disable-devtool/blob/master/README.cn.md


最后


尽管该库可以有效地禁用浏览器的开发者工具面板,但仍然需要注意以下几点:



  • 该库只能禁用开发者工具的面板,无法阻止用户通过其他途径访问网页源码。因此,建议结合其他安全措施来保护网站。

  • 禁用开发者工具可能会对网站的调试和维护造成一定的困扰。需要调试线上代码的时候可以使用上述1.6绕过禁用进行调试。

  • 该库仅适用于现代浏览器,对于一些较旧的浏览器可能存在兼容性问题。在使用前请确保测试过兼容性。


为了进一步加强网页源码的安全性,我们可以采取以下额外措施:



  • 加密敏感代码,使用加密算法对关键代码进行加密,以防止非授权访问和修改。

  • 使用服务器端渲染,将网页的渲染过程放在服务器端,只返回最终渲染结果给客户端,隐藏源代码和逻辑。

  • 定期更新代码,定期更新代码库以充分利用新的安全特性和修复已知漏洞。


保护网页源码的安全性对于Web开发至关重要。通过使用npm库disable-devtool,并结合其他安全措施,我们可以有效地降低用户访问和修改源代码的风险。但是绝对的安全是不存在的,因此定期更新和加强安全性措施也是必要的。




作者:南城FE
来源:juejin.cn/post/7296089060833148943
收起阅读 »