注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

纯前端就可以实现的两个需求

web
一:多文件下载并压缩成zip形式   我们一般看到这个需求,大多前端都会觉得这不就是一个接口的事嘛,咱们直接window.open()就搞定了,emm...,我之前也是这样想的,可惜现在是在三线城市的小互联网公司,人家后端说服务器不行,带宽不够,出不来接口0....
继续阅读 »

一:多文件下载并压缩成zip形式


  我们一般看到这个需求,大多前端都会觉得这不就是一个接口的事嘛,咱们直接window.open()就搞定了,emm...,我之前也是这样想的,可惜现在是在三线城市的小互联网公司,人家后端说服务器不行,带宽不够,出不来接口0.0,所以我就尝试着寻找……最终找到了解决办法——


  前端可以直接从cos资源服务器中下载多个文件,并放进压缩包中哦,这样就省去了后端在中间中转的那个环节,实现方式如下:


1.安装依赖


  我们需要用到两个第三方依赖哦,分别执行以下安装


npm i jszip
npm i file-saver

2.引入 


  在需要使用的时候,我们引入


import JSZip from "jszip";
import FileSaver from "file-saver";

3.实现过程


  我这里是在vue框架中,所以方法是在methods中哦,先在data里声明一个文件demo数据


data () {
return {
fileList: [ //这里的数据 实际中 应该是从后端接口中get { name: "test1.doc", url: "https://collaboration-pat-1253575580.cos.ap-shenzhen-fsi.myqcloud.com/baseFile/202303/bdf4303f-4e8d-4d95-977c-4fd5ef28d354/new.doc" }, { name: "test2.doc", url:"https://collaboration-pat-1253575580.cos.ap-shenzhen-fsi.myqcloud.com/baseFile/202303/bdf4303f-4e8d-4d95-977c-4fd5ef28d354/new.doc" } ],
}
}

methods中的方法:


handleClickDownAll () { //实现多文件压缩包下载
let _this = this
let zip = new JSZip()
let cache = {}
let promises = []
for(let item of this.fileList) {
const promise = _this.getFileArrBuffer(item.url).then(res => {
//下载文件, 并存成ArrayBuffer对象(blob)
zip.file(item.name,res,{binary:true}) //逐个添加文件
cache[item.name] = data
})
promises.push(promise) }
Promese.all(promises).then(() => {
zip.generateAsync({type:'blob'}).then(content => {
FileSaver.saveAs(content,"压缩包名字") //利用file-saver保存文件 自定义文件名
})
})
},
getFileArrBuffer(url) {
return new Promise((resolve) => {
let xmlhttp = new XMLHttpRequest()
xmlhttp.open('GET',url,true)
xmlhttp.responseType = 'blob'
xml.onload = function () {
resolve(this.response)
}
xmlhttp.send()
})
}

二:electron-vue中,生成二维码,并支持复制二维码图片


要实现的功能如下,就是点击这个“复制二维码”,可以直接把二维码图片复制下来



1.安装依赖


npm i qrcodejs2

2.引入


import QRCode from 'qrcodejs2';
import { clipboard, nativeImage} = require('electron')

3.实现


  要先在template中写一个这样的元素,用来显示二维码图片框


<div id="qrcodeImg" class="qrcode" style="height: 120px"></div>

然后再写一个画二维码的方法,如下:


drawQrcode() {
new QRCode("qrcodeImg",{
width:120,
height:120,
text:"http://www.baidu.com",
colorDark:"#000",
colorLight:"#fff"
})
}

复制二维码的方法如下:


copyCode() {
let src = document.getElementById("qrcodeImg").getElementsByTagName("img")[0].src
const image = nativeImage.createFromDataURL(src)
clipboard.writeImage(image)
this.$Message.success('复制成功')
}

4.使用


要先确保dom元素已经有了,所以在mounted中调用drawQrcode()这个方法,然后点击“复制二维码”时,调用 copyCode()这个方法就可以实现啦




作者:wenLi
来源:juejin.cn/post/7213983712732348474
收起阅读 »

下一代 Node.js?运行速度狂飙 10 倍!!!

web
【💥 重磅新闻 💥】JavaScript 服务端运行时环境又出新秀,LLRT 来势汹汹!🌟 嘿,各位 JavaScript 开发者们,你们知道吗?最近有个叫 LLRT 的新运行时环境火了起来,据说速度比 Node.js 快了 10 倍!🏎️ 前有 Deno,后...
继续阅读 »

【💥 重磅新闻 💥】JavaScript 服务端运行时环境又出新秀,LLRT 来势汹汹!🌟


嘿,各位 JavaScript 开发者们,你们知道吗?最近有个叫 LLRT 的新运行时环境火了起来,据说速度比 Node.js 快了 10 倍!🏎️


前有 Deno,后有 Bun,现在又来了个 LLRT,Node.js 这些年的日子可真是“不是被超越就是在被超越的路上”啊!😅


那么问题来了,你觉得 LLRT 的出现会对 Node.js 造成威胁吗?


什么是 LLRT


LLRT(低延迟运行时)是亚马逊推出的一种轻量级 JavaScript 运行时。旨在满足对快速高效的无服务器应用程序不断增长的需求。


LLRT 优势:



  • 🔥 不使用 V8 引擎,而是采用 Rust 构建,确保高效内存使用。就像给应用程序装了涡轮增压器,速度瞬间飙升!💨

  • 🔥 使用 QuickJS 作为 JavaScript 引擎,快速启动不是梦!就像闪电一样快,让你的应用程序瞬间响应!⚡


如下所示 LLRT 和 Node.js 20 运行速度对比,可以看出


LLRT - DynamoDB Put, ARM, 128MB:
llrt-ddb-put


Node.js 20 - DynamoDB Put, ARM, 128MB:
node20-ddb-put


LLRT 兼容性



LLRT 仅支持一小部分 Node.js API。它不是 Node.js 的替代品,也永远不会是。下面是部分支持的 API 和模块的高级概述。有关更多详细信息,请参阅 API 文档:github.com/awslabs/llr…



Node.jsLLRT ⚠️
buffer✔︎✔︎️
streams✔︎✔︎*
child_process✔︎✔︎⏱
net:sockets✔︎✔︎⏱
net:server✔︎✔︎
tls✔︎✘⏱
fetch✔︎✔︎
http✔︎✘⏱**
https✔︎✘⏱**
fs/promises✔︎✔︎
fs✔︎✘⏱
path✔︎✔︎
timers✔︎✔︎
uuid✔︎✔︎
crypto✔︎✔︎
process✔︎✔︎
encoding✔︎✔︎
console✔︎✔︎
events✔︎✔︎
ESM✔︎✔︎
CJS✔︎✔︎
async/await✔︎✔︎
Other modules✔︎

LLRT 能否替代 Node.js


LLRT 的出现确实对 Node.js 构成了一定程度的挑战,但这并不意味着 Node.js 会立即被淘汰。


事实上,Node.js 在许多方面仍然具有优势,例如生态系统的庞大规模、广泛的社区支持和大量的现有项目。此外,Node.js 作为一个成熟的运行时环境,已经积累了大量的功能和模块,这些在 LLRT 中可能尚未实现。


然而,LLRT 的出现确实为 JavaScript 服务端运行时环境带来了新的选择。对于那些对性能有较高要求、需要快速启动低内存占用的项目,LLRT 可能是一个更好的选择。


总之,LLRT 的出现对 Node.js 构成了一定程度的挑战,但这并不意味着 Node.js 会立即被淘汰。开发者可以根据项目需求和场景来选择最适合的运行时环境。


参考连接:






作者:前端开发爱好者
来源:juejin.cn/post/7342153878065135667
收起阅读 »

pnpm才是前端工程化项目的未来

web
前言 相信小伙伴们都接触过npm/yarn,这两种包管理工具想必是大家工作中用的最多的包管理工具,npm作为node官方的包管理工具,它是随着node的诞生一起出现在大家的视野中,而yarn的出现则是为了解决npm带来的诸多问题,虽然yarn提高了依赖包的安装...
继续阅读 »

前言


相信小伙伴们都接触过npm/yarn,这两种包管理工具想必是大家工作中用的最多的包管理工具,npm作为node官方的包管理工具,它是随着node的诞生一起出现在大家的视野中,而yarn的出现则是为了解决npm带来的诸多问题,虽然yarn提高了依赖包的安装速度与使用体验,但它依旧没有解决npm的依赖重复安装等致命问题。pnpm的出现完美解决了依赖包重复安装的问题,并且实现了yarn带来的所有优秀体验,所以说pnpm才是前端工程化项目的未来


如果这篇文章有帮助到你,❤️关注+点赞❤️鼓励一下作者,文章公众号首发,关注 前端南玖 第一时间获取最新文章~


npm 与 yarn 存在的问题


早期的npm


在npm@3之前,node_modules结构可以说是整洁可预测的,因为当时的依赖结构是这样的:


node_modules 
└─ 依赖A
├─ index.js
├─ package.json
└─ node_modules
└─ 依赖B
├─ index.js
└─ package.json
└─ 依赖C
├─ index.js
├─ package.json
└─ node_modules
└─ 依赖B
├─ index.js
└─ package.json

每个依赖下面都维护着自己的node_modules,这样看起来确实非常整洁,但同时也带来一些较为严重的问题:



  • 依赖包重复安装

  • 依赖层级过多

  • 模块实例无法共享


依赖包重复安装


从上面的依赖结构我们可以看出,依赖A与依赖C同时引用了依赖B,此时的依赖B会被下载两次。此刻我们想想要是某一个依赖被引用了n次,那么它就需要被下载n次。(此时心里是不是在想,怎么会有如此坑的设计)


01203040_0.jpeg


依赖层级过多


我们再来看另外一种依赖结构:


node_modules 
└─ 依赖A
├─ index.js
├─ package.json
└─ node_modules
└─ 依赖B
├─ index.js
├─ package.json
└─ node_modules
└─ 依赖C
├─ index.js
├─ package.json
└─ node_modules
└─ 依赖D
├─ index.js
└─ package.json

这种依赖层级少还能接受,要是依赖层级多了,这样一层一层嵌套下去,就像一个依赖地狱,不利于维护。


npm@3与yarn


为了解决上述问题,npm3yarn都选择了扁平化结构,也就是说现在我们看到的node_modules里面的结构不再有依赖嵌套了,都是如下依赖结构:


node_modules 
└─ 依赖A
├─ index.js
├─ package.json
└─ node_modules
└─ 依赖C
├─ index.js
├─ package.json
└─ node_modules
└─ 依赖B
├─ index.js
├─ package.json
└─ node_modules

node_modules下所有的依赖都会平铺到同一层级。由于require寻找包的机制,如果A和C都依赖了B,那么A和C在自己的node_modules中未找到依赖C的时候会向上寻找,并最终在与他们同级的node_modules中找到依赖包C。 这样就不会出现重复下载的情况。而且依赖层级嵌套也不会太深。因为没有重复的下载,所有的A和C都会寻找并依赖于同一个B包。自然也就解决了实例无法共享数据的问题


由于这个扁平化结构的特点,想必大家都遇到了这样的体验,自己明明就只安装了一个依赖包,打开node_modules文件夹一看,里面却有一大堆。


nz2.jpeg


这种扁平化结构虽然是解决了之前的嵌套问题,但同时也带来了另外一些问题:



  • 依赖结构的不确定性

  • 扁平化算法的复杂度增加

  • 项目中仍然可以非法访问没有声明过的依赖包(幽灵依赖)


依赖结构的不确定性


这个怎么理解,为什么会产生这种问题呢?我们来仔细想想,加入有如下一种依赖结构:


依赖1.png


A包与B包同时依赖了C包的不同版本,由于同一目录下不能出现两个同名文件,所以这种情况下同一层级只能存在一个版本的包,另外一个版本还是要被嵌套依赖。


那么问题又来了,既然是要一个扁平化一个嵌套,那么这时候是如何确定哪一个扁平化哪一个嵌套的呢?


依赖2.png


这两种结构都有可能,准确点说哪个版本的包被提升,取决于包的安装顺序!


这就是为什么会产生依赖结构的不确定问题,也是 lock 文件诞生的原因,无论是package-lock.json(npm 5.x 才出现)还是yarn.lock,都是为了保证 install 之后都产生确定的node_modules结构。


尽管如此,npm/yarn 本身还是存在扁平化算法复杂package 非法访问的问题,影响性能和安全。


pnpm


前面说了那么多的npmyarn的缺点,现在再来看看pnpm是如何解决这些尴尬问题的。


什么是pnpm



快速的,节省磁盘空间的包管理工具



就这么简单,说白了它跟npmyarn没有区别,都是包管理工具。但它的独特之处在于:



  • 包安装速度极快

  • 磁盘空间利用非常高效


特性


安装包速度快


p1.png


从上图可以看出,pnpm的包安装速度明显快于其它包管理工具。那么它为什么会比其它包管理工具快呢?


我们来可以来看一下各自的安装流程



  • npm/yarn


npm&yarn.png



  1. resolving:首先他们会解析依赖树,决定要fetch哪些安装包。

  2. fetching:安装去fetch依赖的tar包。这个阶段可以同时下载多个,来增加速度。

  3. wrting:然后解压包,根据文件构建出真正的依赖树,这个阶段需要大量文件IO操作。



  • pnpm


pnpm.png


上图是pnpm的安装流程,可以看到针对每个包的三个流程都是平行的,所以速度会快很多。当然pnpm会多一个阶段,就是通过链接组织起真正的依赖树目录结构。


磁盘空间利用非常高效


pnpm 内部使用基于内容寻址的文件系统来存储磁盘上所有的文件,这个文件系统出色的地方在于:



  • 不会重复安装同一个包。用 npm/yarn 的时候,如果 100 个项目都依赖 lodash,那么 lodash 很可能就被安装了 100 次,磁盘中就有 100 个地方写入了这部分代码。但在使用 pnpm 只会安装一次,磁盘中只有一个地方写入,后面再次使用都会直接使用 hardlink

  • 即使一个包的不同版本,pnpm 也会极大程度地复用之前版本的代码。举个例子,比如 lodash 有 100 个文件,更新版本之后多了一个文件,那么磁盘当中并不会重新写入 101 个文件,而是保留原来的 100 个文件的 hardlink,仅仅写入那一个新增的文件


支持monorepo


pnpm 与 npm/yarn 另外一个很大的不同就是支持了 monorepo,pnpm内置了对monorepo的支持,只需在工作空间的根目录创建pnpm-workspace.yaml和.npmrc配置文件,同时还支持多种配置,相比较lerna和yarn workspace,pnpm解决monorepo的同时,也解决了传统方案引入的问题。



monorepo 的宗旨就是用一个 git 仓库来管理多个子项目,所有的子项目都存放在根目录的packages目录下,那么一个子项目就代表一个package



依赖管理


pnpm使用的是npm version 2.x类似的嵌套结构,同时使用.pnpm 以平铺的形式储存着所有的包。然后使用Store + Links和文件资源进行关联。简单说pnpm把会包下载到一个公共目录,如果某个依赖在 sotre 目录中存在了话,那么就会直接从 store 目录里面去 hard-link,避免了二次安装带来的时间消耗,如果依赖在 store 目录里面不存在的话,就会去下载一次。通过Store + hard link的方式,使得项目中不存在NPM依赖地狱问题,从而完美解决了npm3+和yarn中的包重复问题。


store.jpeg


我们分别用npmpnpm来安装vite对比看一下


npmpnpm
npm-demo.pngpnpm-demo.png
所有依赖包平铺在node_modules目录,包括直接依赖包以及其他次级依赖包node_modules目录下只有.pnpm和直接依赖包,没有其他次级依赖包
没有符号链接(软链接)直接依赖包的后面有符号链接(软链接)的标识

pnpm安装的vite 所有的依赖都软链至了 node_modules/.pnpm/ 中的对应目录。 把 vite 的依赖放置在同一级别避免了循环的软链。


软链接 和 硬链接 机制


pnpm 是通过 hardlink 在全局里面搞个 store 目录来存储 node_modules 依赖里面的 hard link 地址,然后在引用依赖的时候则是通过 symlink 去找到对应虚拟磁盘目录下(.pnpm 目录)的依赖地址。


这两者结合在一起工作之后,假如有一个项目依赖了 A@1.0.0B@1.0.0 ,那么最后的 node_modules 结构呈现出来的依赖结构可能会是这样的:


node_modules
└── A // symlink to .pnpm/A@1.0.0/node_modules/A
└── B // symlink to .pnpm/B@1.0.0/node_modules/B
└── .pnpm
├── A@1.0.0
│ └── node_modules
│ └── A -> /A
│ ├── index.js
│ └── package.json
└── B@1.0.0
└── node_modules
└── B -> /B
├── index.js
└── package.json

node_modules 中的 A 和 B 两个目录会软连接到 .pnpm 这个目录下的真实依赖中,而这些真实依赖则是通过 hard link 存储到全局的 store 目录中。


store


pnpm下载的依赖全部都存储到store中去了,storepnpm在硬盘上的公共存储空间。


pnpmstore在Mac/linux中默认会设置到{home dir}>/.pnpm-store/v3;windows下会设置到当前盘符的根目录下。使用名为 .pnpm-store的文件夹名称。


项目中所有.pnpm/依赖名@版本号/node_modules/下的软连接都会连接到pnpmstore中去。



作者:前端南玖
来源:juejin.cn/post/7239875883254300729
收起阅读 »

纯前端也可以访问文件系统!

web
前言 周末逛github的时候,发现我们只需要在github域名上加上1s他就能够打开一个vscode窗口来阅读代码,比起在github仓库中查看更加方便 然后我就想网页端vscode能不能打开我本地的项目呢,带着这个疑惑我打开了网页版vscode,它居然真...
继续阅读 »

前言


周末逛github的时候,发现我们只需要在github域名上加上1s他就能够打开一个vscode窗口来阅读代码,比起在github仓库中查看更加方便


vs0.png


然后我就想网页端vscode能不能打开我本地的项目呢,带着这个疑惑我打开了网页版vscode,它居然真的可以打开我本地的项目代码!


vs1.png


难道又出了新的API让前端的能力更进一步了?打开MDN查了一下相关文档,发现了几个新的API


showOpenFilePicker



用来选择文件



vs-f1.png


语法


showOpenFilePicker()

参数



  • options:(可选)包含以下属性

    • multiple:布尔值,默认为false。为true表示允许用户选择多个文件

    • excludeAcceptAllOption:布尔值,默认为false。默认情况下,文件选择器带有一个允许用户选择所有类型文件的过滤选项(展开于文件类型选项中)。设置此选项为 true 以使该过滤选项不可用。

    • types:表示允许选择的文件类型的数组




返回值


返回一个promise对象,会兑现一个包含 FileSystemFileHandle 对象的 Array 数组。


体验


<template>
<div class="open_file" @click="openFile">打开文件</div>
</template>

<script setup lang="ts">
const openFile = async () => {
const res = await window.showOpenFilePicker();
console.log(res);
};
</script>

默认只能打开一个文件,可以传入multiple:true打开多个文件


vs3.png


showDirectoryPicker



用来选择目录



vs2.png


语法


属于浏览器全局方法,直接调用即可


showDirectoryPicker()

参数



  • options:(可选)包含以下属性

    • multiple:布尔值,默认为false。为true表示允许用户选择多个文件

    • excludeAcceptAllOption:布尔值,默认为false。默认情况下,文件选择器带有一个允许用户选择所有类型文件的过滤选项(展开于文件类型选项中)。设置此选项为 true 以使该过滤选项不可用。

    • types:表示允许选择的文件类型的数组




返回值


返回一个promise对象,会兑现一个包含 FileSystemFileHandle 对象的 Array 数组。


体验


<template>
<div class="open_file" @click="openFile">打开文件</div>
<div class="open_file" @click="openDir">打开文件夹</div>
</template>

<script setup lang="ts">
const openFile = async () => {
const res = await window.showOpenFilePicker({
// multiple: true,
});
console.log(res.length);
};

const openDir = async () => {
const res = await window.showDirectoryPicker();
console.log(res);
};
</script>

vs4.png


扩展


FileSystemFileHandle


FileSystemFileHandle提供了一些方法可以用来获取和操作文件



  • getFile:返回一个Promise对象,用于获取文件;

  • createSyncAccessHandle:返回一个FileSystemSyncAccessHandle对象,用于同步访问文件;

  • createWritable:返回一个Promise对象,用于创建一个可写流,用于写入文件;


FileSystemDirectoryHandle


FileSystemDirectoryHandle对象是一个代表文件系统中的目录的对象,它同样提供了方法来获取和操作目录



  • entries:返回一个AsyncIterable对象,用于获取目录中的所有文件和目录;

  • keys:返回一个AsyncIterable对象,用于获取目录中的所有文件和目录的名称;

  • values:返回一个AsyncIterable对象,用于获取目录中的所有文件和目录的FileSystemHandle对象;

  • getFileHandle:返回一个Promise对象,用于获取目录中的文件;

  • getDirectoryHandle:返回一个Promise对象,用于获取目录中的目录;

  • removeEntry:返回一个Promise对象,用于删除目录中的文件或目录;

  • resolve:返回一个Promise对象,用于获取目录中的文件或目录;


entrieskeysvalues这三个方法都是用来获取目录中的所有文件和目录的,它们返回的都是一个AsyncIterable对象,我们可以通过for await...of语法来遍历它。


开发编辑器


了解完这些知识点,我们就可以来开发一个简陋网页版编辑器了,初期只包含打开文件、打开文件夹、查看文件、切换文件


编辑器大概长这样:


vs5.png


打开文件夹


const openDir = async () => {
const res = await window.showDirectoryPicker({});
const detalAction = async (obj: any) => {
if (obj.entries) {
const dirs = obj.entries();
for await (const entry of dirs) {
if (entry[1].entries) {
// 文件夹,递归处理
detalAction(entry[1]);
} else {
// 文件
fileList.value.push({
name: entry[0],
path: obj.name,
fileHandle: entry[1],
});
}
}
}
};
await detalAction(res);
showCode(fileList.value[0], 0);
console.log("--fileList--", fileList);
};

这里主要是递归处理文件夹,返回一个文件列表


读取文件内容


const showCode = async (item: any, index: number) => {
const file = await item.fileHandle.getFile();
const text = await file.text();
codeText.value = text;
currentIndex.value = index;
};

展示文件内容


使用highlight.js来高亮展示代码


<div class="show_code">
<pre v-highlight>
<code class="lang-dart">
{{ codeText }}
</code>
</pre>
</div>

最终效果如下:


vs6.gif


想不到吧,这种功能现在纯前端就能够实现了,当然还可以做的更复杂一点,包括修改保存等功能,保存可以使用showSaveFilePickerAPI,它可以写入文件,同样是返回一个promise。感兴趣的可以试着完善编辑器的功能。


作者:前端南玖
来源:juejin.cn/post/7277045020423045176
收起阅读 »

如果让你实现实时消息推送你会用什么技术?轮询、websocket还是sse

web
前言 在日常的开发过程中,我们经常能碰见到需要主动推送消息给客户端数据的业务场景,比如数据大屏幕实时数据,聊天消息推送等等。 本文介绍sse: 服务端向客户端推送数据的方式有哪几种呢? WebSocket SSE 长轮询 轮询简介 长轮询是一种模拟实时通...
继续阅读 »

前言


在日常的开发过程中,我们经常能碰见到需要主动推送消息给客户端数据的业务场景,比如数据大屏幕实时数据,聊天消息推送等等。
本文介绍sse:


image.png
服务端向客户端推送数据的方式有哪几种呢?



  • WebSocket

  • SSE

  • 长轮询


轮询简介


长轮询是一种模拟实时通信的技术。在传统的Http请求中,客户端向服务端发送请求,并且在完成请求后立即响应,然后连接关闭。这意味着客户端需要不停的发送请求来更新数据。


相比之下,长轮询的思想是客户端发送一个Http到服务端,服务端不立即返回响应。相反,服务端会保持该请求打开,直到有新的数据可用或超时。如果有新的数据可用,服务端会立即返回响应,并关闭连接。此时,客户端会重新发起一个新的请求,继续等待新的数据。


使用长轮询的优势在于,它在大部分的浏览器中有更好的兼容性,因为它使用的是Http协议。缺点就是较高的延迟性、较大的资源消耗、以及大量并发操作可能导致服务端资源的瓶颈和一些浏览器对并发请求数目进行了限制比如chorme最大并发数目为6,这个限制前提是针对同一个域名下,超过这一限制后续请求就会堵塞。


websocket简介


websocket是一个双向通信的协议,它支持客户端和服务端彼此之间进行通信。功能强大。
缺点就是是一个新的协议,ws/wss,也就是说支持http协议的不一定支持ws协议。相比较websocket结构复杂并且比较重。


SSE简介


sse是一个单向通讯的协议也是一个长链接,它只能支持服务端主动向客户端推送数据,但是无法让客户端向服务端推送消息。


SSE的优点是,它是一个轻量级的协议,相对于websockte来说,他的复杂度就没有那么高,相对于客户端的消耗也比较少。而且_SSE使用的是http协议_(websocket使用的是ws协议),也就是现有的服务端都支持SSE,无需像websocket一样需要服务端提供额外的支持。


websocket和SSE有什么区别?


轮询


对于当前计算机的发展来说,几乎很少出现同时不支持websocket和sse的情况,所以轮询是在极端情况下浏览器实在是不支持websocket和see的下策。


Websocket和SSE


我们一般的服务端和客户端的通讯基本上使用这两个方案。首先声明:这两个方案没有绝对的好坏,只有在不同的业务场景下更好的选择。


SSE的官方对于SSE和Websocket的评价是



  1. WebSocket是全双工通道,可以双向通信,功能更强;SSE是单向通道,只能服务器向浏览器端发送。

  2. WebSocket是一个新的协议,需要服务器端支持;SSE则是部署在HTTP协议之上的,现有的服务器软件都支持。

  3. SSE是一个轻量级协议,相对简单;WebSocket是一种较重的协议,相对复杂。

  4. SSE默认支持断线重连,WebSocket则需要额外部署。

  5. SSE支持自定义发送的数据类型。


Websocket和SSE分别适用于什么业务场景?


对于SSE来说,它的优点就是轻,而且对于服务端的支持度要更好。换言之,可以使用SSE完成的功能需求,没有必要使用更重更复杂的websocket。


比如:数据大屏的实时数据,消息中心的消息推送等一系列只需要服务端单方面推送而不需要客户端同时进行反馈的需求,SSE就是不二之选。


对于Websocket来说,他的优点就是可以同时支持客户端和服务端的双向通讯_。所适用的业务场景:最典型的就是聊天功能。这种服务端需要主动向客户端推送信息,并且客户端也有向服务端推送消息的需求时,Websocket就是更好的选择。


SSE有哪些主要的API?


建立一个SSE链接 :var source = new EventSource(url);


SSE连接状态


source.readyState



  • 0,相当于常量EventSource.CONNECTING,表示连接还未建立,或者连接断线。

  • 1,相当于常量EventSource.OPEN,表示连接已经建立,可以接受数据。

  • 2,相当于常量EventSource.CLOSED,表示连接已断,且不会重连。


SSE相关事件



  • open事件(连接一旦建立,就会触发open事件,可以定义相应的回调函数)

  • message事件(收到数据就会触发message事件)

  • error事件(如果发生通信错误(比如连接中断),就会触发error事件)


数据格式


Content-Type: text/event-stream //文本返回格式  
Cache-Control: no-cache  //不要缓存
Connection: keep-alive //长链接标识

如何实操一个SSE链接?Demo↓


这里Demo前端使用的就是最基本的html静态页面连接,没有使用任何框架。后端选用语言是node,框架是Express。


理论上,把这两段端代码复制过去跑起来就直接可以用了。



  1. 第一步,建立一个index.html文件,然后复制前端代码Demo到index.html文件中,打开文件

  2. 第二步,进入一个新的文件夹,建立一个index.js文件,然后将后端Demo代码复制进去,然后在该文件夹下执行


npm init          //初始化npm         
npm i express     //下载node express框架
node index        //启动服务

上面三行之中,第一行的Content-Type必须指定 MIME 类型为event-steam
每一次发送的信息,由若干个message组成,每个message之间用\n\n分隔。每个message内部由若干行组成,每一行都是如下格式。


[field]: value\n

上面的field可以取四个值。


-   data
- event
- id
- retry

此外,还可以有冒号开头的行,表示注释。通常,服务器每隔一段时间就会向浏览器发送一个注释,保持连接不中断。


: This is a comment

data 字段


数据内容用data字段表示


data:  message\n\n

如果数据很长,可以分成多行,最后一行用\n\n结尾,前面行都用\n结尾。


data: begin message\n
data: continue message\n\n

下面是一个发送 JSON 数据的例子。


data: {\n
data: "foo": "bar",\n
data: "baz", 555\n
data: }\n\n

id 字段


数据标识符用id字段表示,相当于每一条数据的编号。


id: msg1\n
data: message\n\n

浏览器用lastEventId属性读取这个值。一旦连接断线,浏览器会发送一个 HTTP 头,里面包含一个特殊的Last-Event-ID头信息,将这个值发送回来,用来帮助服务器端重建连接。因此,这个头信息可以被视为一种同步机制。


event 字段


event字段表示自定义的事件类型,默认是message事件。浏览器可以用addEventListener()监听该事件。




event: foo\n
data: a foo event\n\n

data: an unnamed event\n\n

event: bar\n
data: a bar event\n\n


retry 字段


服务器可以用retry字段,指定浏览器重新发起连接的时间间隔。




retry: 10000\n


两种情况会导致浏览器重新发起连接:一种是时间间隔到期,二是由于网络错误等原因,导致连接出错。


上面的代码创造了三条信息。第一条的名字是foo,触发浏览器的foo事件;第二条未取名,表示默认类型,触发浏览器的message事件;第三条是bar,触发浏览器的bar事件。


image.png
前端代码


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<ul id="ul"></ul>
</body>
<script>
//生成li元素
function createLi(data) {
let li = document.createElement("li");
li.innerHTML = String(data.message);
return li;
}

//判断当前浏览器是否支持SSE
let source = "";
if (!!window.EventSource) {
source = new EventSource("http://localhost:8088/sse/");
} else {
throw new Error("当前浏览器不支持SSE");
}

//对于建立链接的监听
source.onopen = function (event) {
console.log(source.readyState);
console.log("长连接打开");
};

//对服务端消息的监听
source.onmessage = function (event) {
console.log(JSON.parse(event.data));
console.log("收到长连接信息");
let li = createLi(JSON.parse(event.data));
document.getElementById("ul").appendChild(li);
};

//对断开链接的监听
source.onerror = function (event) {
console.log(source.readyState);
console.log("长连接中断");
};
</script>
</html>



后端代码


const express = require("express"); //引用框架
const app = express(); //创建服务
const port = 8088; //项目启动端口

//设置跨域访问
app.all("*", function (req, res, next) {
//设置允许跨域的域名,*代表允许任意域名跨域
res.header("Access-Control-Allow-Origin", "*");
//允许的header类型
res.header(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Requested-With"
);
//跨域允许的请求方式
res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS");
// 可以带cookies
res.header("Access-Control-Allow-Credentials", true);
if (req.method == "OPTIONS") {
res.sendStatus(200);
} else {
next();
}
});

app.get("/sse", (req, res) => {
res.set({
"Content-Type": "text/event-stream", //设定数据类型
"Cache-Control": "no-cache", // 长链接拒绝缓存
Connection: "keep-alive", //设置长链接
});

console.log("进入到长连接了");
//持续返回数据
setInterval(() => {
console.log("正在持续返回数据中ing");
const data = {
message: `Current time is ${new Date().toLocaleTimeString()}`,
};
res.write(`data: ${JSON.stringify(data)}\n\n`);
}, 1000);
});

//创建项目
app.listen(port, () => {
console.log(`项目启动成功-http://localhost:${port}`);
});


20240229103040.gif


参考文章:http://www.ruanyifeng.com/blog/2017/0…


作者:917号先生
来源:juejin.cn/post/7340621143009067027
收起阅读 »

还在直接用localStorage么?全网最细:本地存储二次封装(含加密、解密、过期处理)

web
背景 很多人在用 localStorage 或 sessionStorage 的时候喜欢直接用,明文存储,直接将信息暴露在;浏览器中,虽然一般场景下都能应付得了且简单粗暴,但特殊需求情况下,比如设置定时功能,就不能实现。就需要对其进行二次封装,为了在使用上增加...
继续阅读 »



背景


很多人在用 localStoragesessionStorage 的时候喜欢直接用,明文存储,直接将信息暴露在;浏览器中,虽然一般场景下都能应付得了且简单粗暴,但特殊需求情况下,比如设置定时功能,就不能实现。就需要对其进行二次封装,为了在使用上增加些安全感,那加密也必然是少不了的了。为方便项目使用,特对常规操作进行封装。


结构设计


在封装一系列操作本地存储的API之前,先准备了一个全局对象,对具体的操作进行判断,如下:


interface globalConfig {
type: 'localStorage' | 'sessionStorage';
prefix: string;
expire: number;
isEncrypt: boolean;
}

const config: globalConfig = {
type: 'localStorage', //存储类型,localStorage | sessionStorage
prefix: 'react-view-ui_0.0.1', //版本号
expire: 24 * 60, //过期时间,默认为一天,单位为分钟
isEncrypt: true, //支持加密、解密数据处理
};


  1. type 表示存储类型,为 localStoragesessionStorage

  2. prefix 表示视图唯一标识,如果配置可在浏览器视图中放在前缀显示;

  3. expire 表示过期时间,默认为一天,单位为分钟;

  4. isEncrypt 表示支持加密、解密数据处理;


加密准备工作


这里是用到了 crypto-js 来处理加密和解密,可先下载包并导入。


npm i --save-dev crypto-js

import CryptoJS from 'crypto-js';

crypto-js 设置密钥和密钥偏移量,可以采用将一个私钥经 MD5 加密生成16位密钥获得。


const SECRET_KEY = CryptoJS.enc.Utf8.parse('3333e6e143439161'); //十六位十六进制数作为密钥
const SECRET_IV = CryptoJS.enc.Utf8.parse('e3bbe7e3ba84431a'); //十六位十六进制数作为密钥偏移量

加密


const encrypt = (data: object | string): string => {
//加密
if (typeof data === 'object') {
try {
data = JSON.stringify(data);
} catch (e) {
throw new Error('encrypt error' + e);
}
}
const dataHex = CryptoJS.enc.Utf8.parse(data);
const encrypted = CryptoJS.AES.encrypt(dataHex, SECRET_KEY, {
iv: SECRET_IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
return encrypted.ciphertext.toString();
};

解密


const decrypt = (data: string) => {
//解密
const encryptedHexStr = CryptoJS.enc.Hex.parse(data);
const str = CryptoJS.enc.Base64.stringify(encryptedHexStr);
const decrypt = CryptoJS.AES.decrypt(str, SECRET_KEY, {
iv: SECRET_IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
const decryptedStr = decrypt.toString(CryptoJS.enc.Utf8);
return decryptedStr.toString();
};

这两个API都是将获取到的本地存储的value作为参数进行传递,这样就实现了加密和解密。


在传入数据进行处理、改变的时候需要进行解密;
在数据需要传出时需要进行加密。


核心API实现


setStorage 设置值


Storage 本身是不支持过期时间设置的,要支持设置过期时间,可以效仿 Cookie 的做法,setStorage(key, value, expire) 方法,接收三个参数,第三个参数就是设置过期时间的,用相对时间,单位分钟,要对所传参数进行类型检查。可以设置统一的过期时间,也可以对单个值得过期时间进行单独配置。


const setStorage = (key: string, value: any, expire: number = 24 * 60): boolean => {
//设定值
if (value === '' || value === null || value === undefined) {
//空值重置
value = null;
}
if (isNaN(expire) || expire < 0) {
//过期时间值合理性判断
throw new Error('Expire must be a number');
}
const data = {
value, //存储值
time: Date.now(), //存储日期
expire: Date.now() + 1000 * 60 * expire, //过期时间
};
//是否需要加密,判断装载加密数据或原数据
window[config.type].setItem(
autoAddPreFix(key),
config.isEncrypt ? encrypt(JSON.stringify(data)) : JSON.stringify(data),
);
return true;
};

getStorageFromKey 根据key获取value


首先要对 key 是否存在进行判断,防止获取不存在的值而报错。对获取方法进一步扩展,只要在有效期内就可以获取 Storage 值,如果过期则直接删除该值,并返回 null。


const getStorageFromKey = (key: string) => {
//获取指定值
if (config.prefix) {
key = autoAddPreFix(key);
}
if (!window[config.type].getItem(key)) {
//不存在判断
return null;
}
const storageVal = config.isEncrypt
? JSON.parse(decrypt(window[config.type].getItem(key) as string))
: JSON.parse(window[config.type].getItem(key) as string);
const now = Date.now();
if (now >= storageVal.expire) {
//过期销毁
removeStorageFromKey(key);
return null;
//不过期回值
} else {
return storageVal.value;
}
};

getAllStorage 获取所有存储值


const getAllStorage = () => {
//获取所有值
const storageList: any = {};
const keys = Object.keys(window[config.type]);
keys.forEach((key) => {
const value = getStorageFromKey(key);
if (value !== null) {
//如果值没有过期,加入到列表中
storageList[key] = value;
}
});
return storageList;
};

getStorageLength 获取存储值数量


const getStorageLength = () => {
//获取值列表长度
return window[config.type].length;
};

removeStorageFromKey 根据key删除存储值


const removeStorageFromKey = (key: string) => {
//删除值
if (config.prefix) {
key = autoAddPreFix(key);
}
window[config.type].removeItem(key);
};

clearStorage 清空存储列表


const clearStorage = () => {
window[config.type].clear();
};

autoAddPreFix 基于全局配置的prefix参数添加前缀


const autoAddPreFix = (key: string) => {
//添加前缀,保持浏览器Application视图唯一性
const prefix = config.prefix || '';
return `${prefix}_${key}`;
};

这是一个不导出的函数,作为整体封装的内部工具函数,在setStorage、getStorageFromKey、removeStorageFromKey会使用到。


导出函数列表


提供了6个函数的处理能力,足够应对实际业务的大部分操作。


export {
setStorage,
getStorageFromKey,
getAllStorage,
getStorageLength,
removeStorageFromKey,
clearStorage,
};

使用


在实际业务中使用,则将函数导入即可,这里先看下笔者的文件目录吧:
在这里插入图片描述
实际使用:


import {
setStorage,
getStorageFromKey,
getAllStorage,
getStorageLength,
removeStorageFromKey,
clearStorage
} from '../../_util/storage/config'

setStorage('name', 'fx', 1)
setStorage('age', { now: 18 }, 100000)
setStorage('history', [1, 2, 3], 100000)
console.log(getStorageFromKey('name'))
removeStorageFromKey('name')
console.log(getStorageFromKey('name'))
console.log(getStorageLength());
console.log(getAllStorage());
clearStorage();

接下来看一下浏览器视图:


在这里插入图片描述


可以看到,key经过处理加入了config.prefix的前缀,有了唯一性。
value经过了加密处理。


再看一下通过get方式获取到的控制台值输出:


在这里插入图片描述


很完美,实际业务会把前缀清除返回进行处理,视图中有前缀绑定以及加密处理,保证了本地存储的安全性。


完整代码


config.ts:


import { encrypt, decrypt } from './encry';
import { globalConfig } from './interface';

const config: globalConfig = {
type: 'localStorage', //存储类型,localStorage | sessionStorage
prefix: 'react-view-ui_0.0.1', //版本号
expire: 24 * 60, //过期时间,默认为一天,单位为分钟
isEncrypt: true, //支持加密、解密数据处理
};

const setStorage = (key: string, value: any, expire: number = 24 * 60): boolean => {
//设定值
if (value === '' || value === null || value === undefined) {
//空值重置
value = null;
}
if (isNaN(expire) || expire < 0) {
//过期时间值合理性判断
throw new Error('Expire must be a number');
}
const data = {
value, //存储值
time: Date.now(), //存储日期
expire: Date.now() + 1000 * 60 * expire, //过期时间
};
//是否需要加密,判断装载加密数据或原数据
window[config.type].setItem(
autoAddPreFix(key),
config.isEncrypt ? encrypt(JSON.stringify(data)) : JSON.stringify(data),
);
return true;
};

const getStorageFromKey = (key: string) => {
//获取指定值
if (config.prefix) {
key = autoAddPreFix(key);
}
if (!window[config.type].getItem(key)) {
//不存在判断
return null;
}

const storageVal = config.isEncrypt
? JSON.parse(decrypt(window[config.type].getItem(key) as string))
: JSON.parse(window[config.type].getItem(key) as string);
const now = Date.now();
if (now >= storageVal.expire) {
//过期销毁
removeStorageFromKey(key);
return null;
//不过期回值
} else {
return storageVal.value;
}
};
const getAllStorage = () => {
//获取所有值
const storageList: any = {};
const keys = Object.keys(window[config.type]);
keys.forEach((key) => {
const value = getStorageFromKey(autoRemovePreFix(key));
if (value !== null) {
//如果值没有过期,加入到列表中
storageList[autoRemovePreFix(key)] = value;
}
});
return storageList;
};
const getStorageLength = () => {
//获取值列表长度
return window[config.type].length;
};
const removeStorageFromKey = (key: string) => {
//删除值
if (config.prefix) {
key = autoAddPreFix(key);
}
window[config.type].removeItem(key);
};
const clearStorage = () => {
window[config.type].clear();
};
const autoAddPreFix = (key: string) => {
//添加前缀,保持唯一性
const prefix = config.prefix || '';
return `${prefix}_${key}`;
};
const autoRemovePreFix = (key: string) => {
//删除前缀,进行增删改查
const lineIndex = config.prefix.length + 1;
return key.substr(lineIndex);
};

export {
setStorage,
getStorageFromKey,
getAllStorage,
getStorageLength,
removeStorageFromKey,
clearStorage,
};


encry.ts:


import CryptoJS from 'crypto-js';

const SECRET_KEY = CryptoJS.enc.Utf8.parse('3333e6e143439161'); //十六位十六进制数作为密钥
const SECRET_IV = CryptoJS.enc.Utf8.parse('e3bbe7e3ba84431a'); //十六位十六进制数作为密钥偏移量

const encrypt = (data: object | string): string => {
//加密
if (typeof data === 'object') {
try {
data = JSON.stringify(data);
} catch (e) {
throw new Error('encrypt error' + e);
}
}
const dataHex = CryptoJS.enc.Utf8.parse(data);
const encrypted = CryptoJS.AES.encrypt(dataHex, SECRET_KEY, {
iv: SECRET_IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
return encrypted.ciphertext.toString();
};

const decrypt = (data: string) => {
//解密
const encryptedHexStr = CryptoJS.enc.Hex.parse(data);
const str = CryptoJS.enc.Base64.stringify(encryptedHexStr);
const decrypt = CryptoJS.AES.decrypt(str, SECRET_KEY, {
iv: SECRET_IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
const decryptedStr = decrypt.toString(CryptoJS.enc.Utf8);
return decryptedStr.toString();
};

export { encrypt, decrypt };


interface.ts:


interface globalConfig {
type: 'localStorage' | 'sessionStorage';
prefix: string;
expire: number;
isEncrypt: boolean;
}

export type { globalConfig };


总结


前端开发中直接使用明文存储在本地是比较常见的一件事情同时也是不安全的一件事,对本地存储进行二次封装可以提高安全性,并且有了API的支持,可以在本地存储操作时更加简单。


作者:sorryhc
来源:juejin.cn/post/7237441562664681529
收起阅读 »

为了不和测试扯皮, 我抄了这个vite插件

web
前言 这算是我的第二个vite插件了,第一个是腾讯云OSS上传,思路以及部份代码借鉴了浏览器API调用工程师的项目,在此基础上完善和增加配置项。 如果文章对你有帮助的话,记得一键三连哟。有问题和疑惑的话也可以在评论区留言。我会第一时间回复大家,如果觉得我的文章...
继续阅读 »

前言


这算是我的第二个vite插件了,第一个是腾讯云OSS上传,思路以及部份代码借鉴了浏览器API调用工程师的项目,在此基础上完善和增加配置项。


如果文章对你有帮助的话,记得一键三连哟。有问题和疑惑的话也可以在评论区留言。我会第一时间回复大家,如果觉得我的文章哪里有知识点错误的话,也恳请能够告知,把错的东西理解成对的,无论在什么行业,都是致命的。


背景


项目环境比较多,经常漏发错发版本,导致测试和开发间的扯皮比较多。于是,这个插件便截天地气运而生🤪🤪🤪。


功能特性


目的: 在控制台显示当前运行代码的构建人、构建时间、分支、最新的COMMIT信息等, 方便确认是否漏发错发版本。

注意: 只在GitLab流水线有用


效果预览


可以在控制台查看代码的部署信息



安装


pnpm i -D vite-plugin-gitlab-flow

或者


yarn add -D vite-plugin-gitlab-flow

或者


npm i -D vite-plugin-gitlab-flow

基本使用


vite.config.js/ts中配置


import vitePluginGitLabFlow from "vite-plugin-gitlab-flow";

plugins: [
vitePluginGitLabFlow({
projectName: '榕树工具',
debug: true,
extra: [
{
keys: 'VITE_APP_TITLE',
label: '项目title'
}
],
styles:{
color: 'red'
}
}),
]

配置项


optionsdescriptiontypedefault
projectName?项目名称,默认取package.json里的name字段。stringpackage.name
debug?debug模式booleanfalse
extra?额外需要显示的字段,需要是env里面有的字段,可开启debug模式查看string[]
styles?自定义样式Style{}

实现原理


import type { Plugin, HtmlTagDescriptor } from 'vite';
import dayjs from 'dayjs';
import fs from 'fs';
import {Properties,PropertiesHyphen} from 'csstype';
interface Style extends Properties, PropertiesHyphen {}
export const defaultStyle:Style = {
color: 'white',
background: 'green',
'font-size': '16px',
border: '1px solid #fff',
'text-shadow': '1px 1px black',
padding: '2px 5px',
}

interface GitLabFlowOptions {
projectName?: string,
debug?:boolean,
extra?:{
label:string
keys:string,
}[],
styles?:Style
}
export default function gitLabFlow(options: GitLabFlowOptions={}): Plugin {
let {debug=false,extra=[],styles=defaultStyle}=options
let styleOption=''
for (const styleOptionKey in styles) {
styleOption+=`${styleOptionKey}:${styles[styleOptionKey]};`
}
const env = process.env;

const pkg:any = JSON.parse(fs.readFileSync(process.cwd() + '/package.json', 'utf-8'));

let packageInfo: any = JSON.parse(fs.readFileSync(process.cwd() + '/node_modules/vite-plugin-gitlab-flow/package.json', 'utf-8'))

const appInfo = {
projectName: options.projectName || pkg.name,
name:pkg.name,
version:pkg.version,
lastBuildTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
};

let extStr=`
console.log("%c插件名称:
${packageInfo.name} 当前版本: V${packageInfo.version}","${styleOption}" );
console.log("%c插件作者:
${packageInfo.author} 仓库地址: ${packageInfo.homepage}","${styleOption}");
console.log("%c项目名称:
${appInfo.projectName}", "${styleOption}");
console.log("%c打包时间:
${appInfo.lastBuildTime}","${styleOption}");
console.log("%c流水线执行人:
${env.GITLAB_USER_NAME || '-'}", "${styleOption}");
console.log("%c标签:
${env.CI_COMMIT_REF_NAME || '-'}", "${styleOption}");
console.log("%cCOMMIT信息:
${env.CI_COMMIT_TITLE || '-'} ${env.CI_COMMIT_SHA || '-'}", "${styleOption}");
`


// 新增自定义字段
extra.forEach(({label,keys})=>{
extStr+=`console.log("%c${label}: ${env?.[keys] || '-'}","${styleOption}");`
})

// debugger模式
if (debug){
extStr+=`console.log('appInfo', ${JSON.stringify(appInfo)});`
extStr+=`console.log('packageInfo', ${JSON.stringify(packageInfo)});`
extStr+=`console.log('env', ${JSON.stringify(env)});`
}

return {
name: 'vite-plugin-gitlab-flow',
apply: 'build',
transformIndexHtml(html): HtmlTagDescriptor[] {
return [
{
tag: 'script',
attrs: { defer: true },
children: extStr,
injectTo: 'body'
},
]
}
};
}

引用



作者:小猪努力学前端
来源:juejin.cn/post/7211428447921422396
收起阅读 »

实战(简单):20分钟页面不操作,页面失效

web
如果没有时间想直接解决问题,看最下面的最终代码即可 场景需求 总结: 20分钟内如果不操作,页面就是提示失效并且回到列表页面,如果操作了,计时就会清零。 如果 A 在编辑,B 点击编辑会提示正在编辑。A 在编辑期间,每分钟会向后端发送续租(即正在编辑...
继续阅读 »

如果没有时间想直接解决问题,看最下面的最终代码即可



场景需求


image.png

总结:




  1. 20分钟内如果不操作,页面就是提示失效并且回到列表页面,如果操作了,计时就会清零。

  2. 如果 A 在编辑,B 点击编辑会提示正在编辑。A 在编辑期间,每分钟会向后端发送续租(即正在编辑)的请求,后端收到请求后,会在服务端帮你保留这一分钟的编辑状态,别人就无法在编辑了。并且别人编辑时,后端会返回相应的信息。



前言


乐了,产品提出了需求,然后我去找导师问问团队中有没有现成的解决方案。。。没有,然后导师提出了 web worker 的思路,让我自己思考解决方案。好吧,那就开始吧。


一开始,我想着能否能用 setInterval 来进行定时的,结果后端发来消息


image.png

emm......后端大佬,惹不起~


如图上所说,如果切换了页面,setInterval 会停止计时的(咱就说不信的可以试试),也就是说这个线程被停止了。


那么就需要新建一个线程,也就是 web worker 了,用它单纯来进行计时,不用管其他逻辑,切换页面也不会终止。


正文思路


基本demo


首先,百度了下 web worker 的基本实现案例,一文彻底学会使用web worker


需要该需求的页面


// editEmail.vue(主线程)
<template>
<div>编辑页面哦</div>
</template>

<script>
const myWorker = new Worker('/worker.js'); // 创建worker,函数里的值为 webWorker 文件名

// 向 worker.js 线程发送消息,对应 worker.js 线程中的 e.data
myWorker.postMessage('Greeting from Main.js');

myWorker.addEventListener('message', e => { // 从 worker.js 那接收消息
console.log(e.data); // Greeting from Worker.js,worker线程发送的消息
});

</script>


放入 public 文件下 worker.js


// worker.js(worker线程)
self.addEventListener('message', e => { // 接收到消息
console.log(e.data); // Greeting from Main.js,主线程发送的消息
self.postMessage('Greeting from Worker.js'); // 向主线程发送消息
});

其中,worker.js 的存放路径和 new Worker()里的值有关,比如此时我是在本地资源的根路径创建的 /worker.js ,那么就是放在public下的。


而如果是 ./worker.js,或者 ../worker.js,这是无法找到的,因为此时的 worker.js 已经被打包编译成了 app.js。



注意,public 文件的变动需要重启项目,和 vue.config.js一样



image.png
worker.js 和 主线程通信走通后,开始分析需求了。


1. 每分钟续租一次 =》 1秒钟续租一次


什么叫续租,每分钟你向服务端发送一个续租请求,后端就会帮你保持正在编辑的状态(假设为 edit: true),而且后端其实也在计时一分钟。在这一分钟内,由于 edit 为 true,如果别人想要编辑,就会拒绝别人的编辑。如果你一分钟后没发送这个续租请求,后端会把 true 改成 false,这时别人想要编辑,后端就会接受别人的编辑了。


因此,前端就需要每隔一分钟发送一次续租请求,来维持此时的编辑状态。


当然,由于产品要求的更复杂,你发送续租请求的时候请求头往往会携带用户信息,来反馈谁在进行编辑以提高用户体验感。


下述代码为了更好的测试,把每分钟续租变为了每秒续租一次


2. 20分钟期间不操作就会提示页面失效 =》 10秒钟一到就会触发提示事件


当然,就算 setInterval 不能作为解决方案,但还是需要用它来做定时器的,这还是挺香的。


// worker.js(worker线程)
self.addEventListener('message', e => { // 接收到消息
console.log(e.data); // Greeting from Main.js,主线程发送的消息
setInterval(() => {
self.postMessage('Greeting from Worker.js'); // 向主线程发送消息
}, 1 * 1000)
});

如上代码,Greeting from Worker.js 这条消息每隔 1 秒钟就会向 editEmail.vue 页面发送,这时就算你切换浏览器标签页也仍然会发送。


好,简单的定时器做完了,那就开始进行计时了。


// worker.js(worker线程)
self.addEventListener('message', e => { // 接收到消息
console.log(e.data); // Greeting from Main.js,主线程发送的消息

let sum = 0;
let msg;

setInterval(() => {
sum += 1;
msg = {
text: 'editing',
sum
}
self.postMessage(msg); // 向主线程发送消息 msg 对象
}, 1 * 1000)
});

每过一秒,worker.js 都会发送一次信息,用来持续触发续租事件,而 sum 则是用来进行计时过了多少秒。


image.png


// editEmail.vue(主线程)
<template>
<div>编辑页面哦</div>
</template>

<script>
const myWorker = new Worker('/worker.js'); // 创建worker,函数里的值为 webWorker 文件名

// 向 worker.js 线程发送消息,对应 worker.js 线程中的 e.data
myWorker.postMessage('start');

myWorker.addEventListener('message', e => { // 从 worker.js 那接收消息,每隔一秒都会接收到
console.log(e.data); // {text: 'editing', sum: sum},worker线程发送的消息
campaignListLock(); // 发起续租请求
if (e.data.sum >= 10) {
message.error("页面失效");
PageGoBack(); // 返回上一页,看产品要求
}
});

</script>


image.png

OK,这样,基本的需求就完成了,10 秒一到就会提示页面失效,并且在这 10 秒内谁都无法进入编辑页面(在进入编辑页面前得先向后端请求看看是否有人在编辑)。


但是,10 秒后呢,这个计时器仍然在进行中,所以我需要在 10 秒过后清除这个计时器了。也就是在 e.data.sum >= 10 这个条件内对 worker 进程进行通信,触发清除事件。


// editEmail.vue(主线程)
<template>
<div>编辑页面哦</div>
<input @change="onChange" />
</template>

<script>
const myWorker = new Worker('/worker.js'); // 创建worker,函数里的值为 webWorker 文件名

// 向 worker.js 线程发送消息,对应 worker.js 线程中的 e.data
myWorker.postMessage('start');

myWorker.addEventListener('message', e => { // 从 worker.js 那接收消息,每隔一秒都会接收到
console.log(e.data); // {text: 'editing', sum: sum},worker线程发送的消息
campaignListLock(); // 发起续租请求
if (e.data.sum >= 10) {
message.error("页面失效");
PageGoBack(); // 返回上一页,看产品要求
myWorker.postMessage('end');
}
});

</script>


在这里我们分别向 worker 进程发送了 startend 两个信息,worker 进程拿到信息后进行判断,如果是 start,那么就开始每秒续租,如果为 end,那么就清除定时器来终止续租(即停止每秒向主线程进行通信来触发续租请求)。


// worker.js(worker线程)
let timer;
self.addEventListener('message', e => { // 接收到消息
console.log(e.data); // Greeting from Main.js,主线程发送的消息

let sum = 0;
let msg;

if (e.data === "start") {
timer = setInterval(() => {
sum += 1;
msg = {
text: '编辑中',
sum
}
self.postMessage(msg); // 向主线程发送消息 msg 对象
}, 1 * 1000)
} else {
clearInterval(timer);
}
});

如上代码,定义一个全局变量 timer 用来存储定时器,以便能够随时清除。


image.png

定时重置



Stop,别冲太猛,这里我们需要总结一下了



开启定时


myWorker.postMessage('start');

就会重新 worker.js 中的 self.addEventListener('message',()=>{}) 函数,sum 重置为 0,计时重新开始计算。


停止定时


myWorker.postMessage('end');

就会触发 worker 中的 clearInterval(timer) 来清除定时器


重置定时


myWorker.postMessage('end');
myWorker.postMessage('start');

先清除定时器停止定时,然后再重新开启定时


最后


// 开启定时
const onTimeStart = () => {
myWorker.postMessage('start');
}
// 停止定时
const onTimeEnd = () => {
myWorker.postMessage('end');
}
// 重置定时
const onTime = () => {
onTimeEnd();
onTimeStart();
}

3. 10 秒内如果进行了表单操作则重置计时


const onChange = () => {
onTime();
}

优化代码


// editEmail.vue(主线程)
<template>
<div>编辑页面哦</div>
<input @change="onChange" />
</template>

<script>
const myWorker = new Worker('/worker.js'); // 创建worker,函数里的值为 webWorker 文件名

// 向 worker.js 线程发送消息,对应 worker.js 线程中的 e.data
myWorker.postMessage('start');

// 开启定时
const onTimeStart = () => {
myWorker.postMessage('start');
}
// 停止定时
const onTimeEnd = () => {
myWorker.postMessage('end');
}
// 重置定时
const onTime = () => {
onTimeEnd();
onTimeStart();
}

myWorker.addEventListener('message', e => { // 从 worker.js 那接收消息,每隔一秒都会接收到
console.log(e.data); // {text: 'editing', sum: sum},worker线程发送的消息
campaignListLock(); // 发起续租请求
if (e.data.sum >= 10) {
message.error("页面失效");
PageGoBack(); // 返回上一页,看产品要求
onTimeEnd(); // 停止计时,终止续租
}
});

const onChange = () => {
onTime();
}

</script>


// worker.js(worker线程)
let timer;
self.addEventListener('message', e => { // 接收到消息
console.log(e.data); // Greeting from Main.js,主线程发送的消息

let sum = 0;
let msg;

if (e.data === "start") {
timer = setInterval(() => {
sum += 1;
msg = {
text: 'editing',
sum
}
self.postMessage(msg); // 向主线程发送消息 msg 对象
}, 1 * 1000)
} else {
clearInterval(timer);
}
});

而到这里,只是实现了单纯的停留在页面,但切换浏览器标签页时,没有做相应的监听事件。虽然有着另一个 worker 线程在运行着,但当你切换页面后过 10s 再返回原页面,提示虽然会有,但是一闪即逝,基本看不到提示信息。


4. 切换浏览器标签页


而监听浏览器标签页的切换事件是 visibilitychangedocument.visibilityStat 属性


document.addEventListener("visibilitychange", function () {
if (document.visibilityState == "visible") {
message.error("页面已失效");
} else if (document.visibilityState == "hidden") {
message.error("页面已隐藏");
}
});

其中的隐藏我们并不需要用到,而且过了 10s 后如果反复的切换标签,“页面已失效”的提示会反复的弹出,因为我们并没有进行控制。


此时我们也需要区分过了 10s 后用户是停留在当前页面还是离开了页面又返回了。


如果是停留,那么页面属性为 visible。如果是返回,那么就需要监听 visibilitychange 事件并且页面属性为 visible


let timeCount = 0; // 全局中定义变量,用以控制切换标签页后的提示次数。

myWorker.addEventListener("message", (e) => {
if (e.data.notime >= 10) {
onTimingEnd();
if (document.visibilityState === "visible") {
message.error("页面已失效");
pageGoBack();
timeCount = 1; // 触发了提示就禁止它后续再触发
}
document.addEventListener("visibilitychange", function () {
if (document.visibilityState == "visible" && timeCount == 0) {
message.error("页面已失效");
pageGoBack();
timeCount = 1; // 触发了提示就禁止它后续再触发
}
});
}
})

最终代码


替换成20分钟了


// editEmail.vue(主线程)
<template>
<div>编辑页面哦</div>
<input @change="onChange" />
</template>

<script>
const myWorker = new Worker('/worker.js'); // 创建worker,函数里的值为 webWorker 文件名

// 向 worker.js 线程发送消息,对应 worker.js 线程中的 e.data
myWorker.postMessage('start');

// 开启定时
const onTimeStart = () => {
myWorker.postMessage('start');
}
// 停止定时
const onTimeEnd = () => {
myWorker.postMessage('end');
}
// 重置定时
const onTime = () => {
onTimeEnd();
onTimeStart();
}

myWorker.addEventListener('message', e => { // 从 worker.js 那接收消息,每隔一秒都会接收到
console.log(e.data); // {text: 'editing', sum: sum},worker线程发送的消息
campaignListLock(); // 发起续租请求
if (e.data.sum >= 20) { // 超过 20 分钟,终止续租并提示页面失效
onTimingEnd();
if (document.visibilityState === "visible") {
message.error("页面已失效");
pageGoBack();
timeCount = 1; // 触发了提示就禁止它后续再触发
}
document.addEventListener("visibilitychange", function () {
if (document.visibilityState == "visible" && timeCount == 0) {
message.error("页面已失效");
pageGoBack();
timeCount = 1; // 触发了提示就禁止它后续再触发
}
});
}
});

const onChange = () => {
onTime();
}

</script>


// worker.js(worker线程)
let timer;
self.addEventListener('message', e => { // 接收到消息
console.log(e.data); // Greeting from Main.js,主线程发送的消息

let sum = 0;
let msg;

if (e.data === "start") {
timer = setInterval(() => {
sum += 1;
msg = {
text: 'editing',
sum
}
self.postMessage(msg); // 向主线程发送消息 msg 对象
}, 60 * 1000) // 每分钟 sum 加 1 标识积累了 1 分钟
} else {
clearInterval(timer);
}
});

作者:吃腻的奶油
来源:juejin.cn/post/7340636105765535796
收起阅读 »

架构: 自由表单设计界面布局

web
简介 设计表单设置界面是为了给用户提供一个直观、吸引人的操作界面,方便用户对表单进行个性化的设置。就像我们日常生活中玩游戏一样,游戏中的设置界面可以让我们调整游戏音量、画面效果等,以获得更好的游戏体验。 设计表单设置界面可以让用户自定义表单的外观和功能。就像换...
继续阅读 »

简介


设计表单设置界面是为了给用户提供一个直观、吸引人的操作界面,方便用户对表单进行个性化的设置。就像我们日常生活中玩游戏一样,游戏中的设置界面可以让我们调整游戏音量、画面效果等,以获得更好的游戏体验。


设计表单设置界面可以让用户自定义表单的外观和功能。就像换装一样,通过设置界面,用户可以选择不同的颜色、字体、图标等,将表单变得更漂亮、更符合自己的喜好。不仅如此,用户还可以设置表单的输入验证规则、选项布局等,使表单更加智能、便捷,确保数据的准确性和一致性。


此外,设计吸引人的表单设置界面还可以提升用户的参与感和满足感。当用户看到一个漂亮、有趣的设置界面时,不仅能够激发他们的兴趣和好奇心,还能给他们一种参与到表单设计的快乐感。通过提供丰富的个性化设置选项和交互效果,用户可以将自己的想法和创意融入到表单中,让表单更有个性和趣味性。


设计表单设置界面还可以提供简洁明了的操作流程和指导。一个好的设置界面应该具备清晰的布局和明确的操作步骤,用户在使用时能够快速找到需要的设置选项,并且清楚地知道如何进行设置。同时,设置界面还应该提供一些提示和建议,帮助用户更好地理解设置选项的作用和影响,减少用户的困惑和错误操作。


如何设计表单操作界面?


模块划分


为了更好地讲解该部分内容,我们可以直接上手设计一个通用的表单操作界面。如下图所示,可以看到它差不多涵盖了我们常见的大部分组件……XXXX



从图中我们可以看到,主要包括了如下三个大的模块:



  • 表单设计器模块:这是自由表单设计的核心模块,用户通过该模块可以创建、编辑和配置表单的各个元素,如输入字段、选项、验证规则等。表单设计器通常提供简单直观的界面,让用户可以轻松地拖拽和调整表单元素的位置、大小和样式。

  • 表单属性设置模块:这个模块用于设置表单的基本属性,如表单名称、描述、提交按钮文本等。用户可以在该模块中对表单的基本信息进行编辑和修改。

  • 字段属性设置模块:用户可以选择一个字段或元素,然后在该模块中设置该字段的属性,如标签文本、默认值、是否必填、验证规则等。通过字段属性设置模块,用户可以灵活地对表单中的各个字段进行个性化的配置和定制。


当然,如果加上表单预览实际效果,那就是锦上添花。因此,我们还可以加上个“表单预览模块”用于显示用户设计的表单的实时预览效果,使用户可以随时查看表单的外观和布局。在该模块中,用户可以模拟填写表单,测试表单的交互和功能。具体显示效果如下:




当然,除了以上四个模块,还有其他的模块点,具体可以按照我们个人或者公司的需求,进行合理扩展。


步骤


设置低代码自由表单的操作界面,可以按照以下步骤进行。



  1. 确定操作界面的目标和功能:明确你希望操作界面能够实现什么功能,满足哪些需求。

  2. 设计主要操作区域:确定主要的操作区域,通常会包括表单编辑、保存、提交等功能按钮。

  3. 定义表单字段的布局和样式:根据表单的字段分组情况,确定各字段在操作界面的布局方式,如垂直排列、水平排列等。为字段选择合适的输入控件和标签样式,并考虑必要的验证信息。

  4. 设计其他操作元素:除了表单字段,还可以考虑添加其他操作元素,如工具栏、菜单、侧边栏等,用于辅助用户进行操作和导航。

  5. 考虑交互设计:确定用户与操作界面进行交互的方式,包括按钮点击、字段编辑、菜单选项等。确保用户能够直观地理解和使用操作界面。

  6. 设计响应式布局:如果需要在不同设备上使用操作界面,确保界面能够根据不同屏幕尺寸进行自适应布局,保证在各种设备上都能显示良好的用户体验。

  7. 进行布局调整和优化:根据实际效果进行布局调整和优化,确保界面的整体美观和易用性。


实战规划


经过上面大致的设计介绍,我想你应该有了初步的认识,也大概有了个设计思路。那么,接下来我们开始规划和设计实战项目中的表单设计页面的布局了。


布局规划


先来看下这张规划图:



共分四个模块:



  1. 表单元素;

  2. 元素布局;

  3. 属性设置(包括字段属性、布局属性及其他,而这里只针对字段属性来实践);

  4. 预览。



其中预览模块可以另起一个页面,更为直观。用户可以随时查看表单的外观和布局。还可以提供一些交互功能,例如表单提交的模拟按钮,让用户可以体验表单实际的交互效果。


设计的界面,并不需要你有多花里胡哨,相反,界面的简洁性和易用性,直观的操作方式和清晰的视觉指引,才可帮助用户快速熟悉并使用该低代码自由表单工具。同时,界面设计也应该考虑不同设备和屏幕尺寸的适配(这里针对 PC 和 h5 做一个示范),以确保在不同的平台上都能够良好地展示和操作。


流程模版开发规划-扩展


当我们封装了表单设计这一块功能后,就可以进一步与流程结合


如下例子:


假设我们封装了一个低代码自由表单设计的功能,我们可以将表单设计与流程管理结合使用,实现一个请假流程的应用。



  1. 表单设计:用户使用表单设计功能创建一个请假表单,包括请假类型、请假时间、请假事由等字段。可以设置字段的属性,例如是否必填、格式校验等。

  2. 流程设计:用户使用流程管理功能创建一个请假流程,包括审批节点、流程图设计、流程条件等。可以设置不同节点的审批人、流程条件,以及流程的流转路径。

  3. 表单与流程关联:在表单设计界面中,用户可以将请假表单与请假流程进行关联。可以选择使用该表单作为流程的申请表单,并将表单的字段与流程的变量进行映射。

  4. 表单数据与流程集成:当用户填写请假表单并提交后,表单的数据将被保存,并触发请假流程的启动。流程将根据流程设计中的条件和审批人设定,自动进行流转和审批。

  5. 审批和处理:在流程进行中,审批人可以通过流程管理界面进行审批操作。审批人可以查看已提交的请假表单数据,根据情况进行批准或拒绝,并可以填写审批意见等备注信息。

  6. 流程状态和统计:用户可以通过流程管理界面查看每个请假流程的状态、进度和统计信息。可以了解每个流程当前所处的节点,各个节点的审批状态,以及整个流程的总体情况。


通过将表单设计和流程管理功能结合,我们可以实现一个完整的请假申请流程,简化了传统的请假流程管理流程,同时减少了开发的工作量,提高了工作效率


作者:糖墨夕
来源:juejin.cn/post/7302965547087527977
收起阅读 »

web端屏幕截屏,生成自定义海报!

web
在一些社群网站,经常会碰到问题、活动、商品的信息分享,这种分享通常是以海报的形式发送给好友或保存到本地。在这种场景下,海报肯定是动态变化的,所以我们要动态的渲染内容并生成图片,海报其实就是图片。 官网:html2canvas 海报示例: 介绍 了解 htm...
继续阅读 »

在一些社群网站,经常会碰到问题、活动、商品的信息分享,这种分享通常是以海报的形式发送给好友或保存到本地。在这种场景下,海报肯定是动态变化的,所以我们要动态的渲染内容并生成图片,海报其实就是图片。

官网:html2canvas

海报示例:

在这里插入图片描述




介绍


了解 html2canvas,它是如何工作的以及它的一些局限性。

在你开始使用这个脚本以前,这里有些帮助你更好的了解脚本的好处及其的一些局限性。


关于


html2canvas 是一个 HTML 渲染器。该脚本允许你直接在用户浏览器截取页面或部分网页的“屏幕截屏”,屏幕截图是基于 DOM,因此生成的图片并不一定 100% 一致,因为它没有制作实际的屏幕截图,而是根据页面上可用的信息构建屏幕截图。


它是如何工作的


该脚本通过读取 DOM 以及应用于元素的不同样式,将当前页面呈现为 canvas 图像。

它不需要来自服务器的任何渲染,因为整个图像是在客户端上创建的。但是,由于它太依赖于浏览器,因此该库不适合在 nodejs 中使用。它也不会神奇地规避任何浏览器内容策略限制,因此呈现跨域内容将需要代理来将内容提供给相同的源。




开始


准备工作


安装依赖


npm install html2canvas

在需要的页面引入依赖


import html2canvas from 'html2canvas'

然后就可以使用html2canvas相关API了。


定义海报结构


在使用之前我们要先定义好页面,我们先在页面上写好海报的html


class="html2canvas">
<view class="poster_title">
海报标题
view>

<view class="img_box">
<img class="img_case" src="http://image.gwmph.com/weican/2024/02/27/695aa1d4c2394be48925a6858dd68e9d.jpg" alt="" />
view>

<view class="poster_title" @click="getPoster()">
确定分享
view>



	.html2canvas{
padding: 20rpx;
.poster_title{
text-align: center;
}
.img_box{
display: flex;
justify-content: space-around;
margin: 10rpx 0;
.img_case{
width: 300rpx;
height: 300rpx;
}
}
}

image.png


script部分


在这里我们要区分两种script类型,一种正常的,一种是renderjs

在一个页面中script可以有多个,它也可以写在任意位置,如果我们做正常的逻辑操作,可以在普通的script中编码;如果我们要对页面进行交互,请使用renderjs



renderjs是一个运行在视图层的js。它比WXS更加强大。它只支持app-vueweb
renderjs的主要作用有2个:



  1. 大幅降低逻辑层和视图层的通讯损耗,提供高性能视图交互能力

  2. 在视图层操作dom,运行 for web 的 js库





点击确定分享,我们则会调用getPoter来生成图片,canvas.toDataURL生成的图片为base64格式,下面是生成后的内容:

在这里插入图片描述


然后我们通过a标签图片进行下载,下面是生成海报并下载的完整逻辑。




下面就是下载下来的图片

在这里插入图片描述

在这里插入图片描述




注意事项


1、多个script




<script module="html2canvas" lang="renderjs">
import html2canvas from 'html2canvas';
export default {
methods: {
}
}
script>


uniapp中,我们如果想要提供逻辑层和视图层的通讯效率,可能会使用renderjs,你可能会在页面中看到多个script,这是正常的,我们可能会将生成海报的功能封装成组件,通过组件传参的方式在多个页面复用,这种结构页面就可能有两个script,一个是正常的vuescrpit,用于处理正常逻辑以及接收传参和事件等,一个是用于视图层通讯的renderjs


2、html2canvas不要用image标签


我们在生成图片的时候,可能会调整清晰度和分辨率,让画面更高清,html2canvas应该使用img标签,而不是image标签,image标签不会对html2canvasscaledpi生效。


3、html2canvas对于现在的css高级属性的支持


html2canvas可能不会支持css高级属性,例如:

● background-blend-mode

● border-image

● box-decoration-break

● box-shadow

● filter

● font-variant-ligatures

● mix-blend-mode

● object-fit

● repeating-linear-gradient()

● writing-mode

● zoom

● ......

对于渐变文字裁切之类的高阶属性可能不支持,如果海报生成的时候没有生效,那就是不支持,需要思考替代方案。




最后


1、html2canvas是基于html的渲染器,只要定义好海报结构即可生成,可以看成html2canvas就是将页面结构转换成图片。

2、不要使用image标签,应该使用img标签。

3、不支持部分css高阶属性。




作者:DCodes
来源:juejin.cn/post/7340208335982903322
收起阅读 »

2024年令人眼前一亮的Web框架

web
本文翻译自 dev.to/wasp/web-fr… 感谢您的阅读! 介绍 2024年正向我们走来,我们怀着满腔热情为新的一年制定计划,探索未来一年可以学习或实现的目标。此时此刻,正是探寻来年值得学习的框架、理解其功能和特色的最佳时刻。我们以2023年JS 新...
继续阅读 »

本文翻译自 dev.to/wasp/web-fr…

感谢您的阅读!



介绍


2024年正向我们走来,我们怀着满腔热情为新的一年制定计划,探索未来一年可以学习或实现的目标。此时此刻,正是探寻来年值得学习的框架、理解其功能和特色的最佳时刻。我们以2023年JS 新星名单为指引,力求保持客观公正的态度。对于每一个特色框架,我们都将突出其最大的优势,使您能够全面理解它们的优点,从而选择适合自己的框架进行尝试!


HTMX - 返璞归真🚲


htmx-演示


为谁而设:



  • 你希望减少JavaScript的编写量

  • 你希望代码更简单,以超媒体为中心


HTMX在2023年迅速走红,过去一年间在GitHub上赢得了大量星标。HTMX并非普通的JS框架。如果你使用HTMX,你将大部分时间都花在超媒体的世界中,以与我们通常对现代Web开发的JS密集型视角完全不同的视角看待Web开发。HTMX利用HATEOAS(Hypermedia作为应用程序状态的引擎)的概念,使开发人员能够直接从HTML访问浏览器功能,而不是使用Javascript。


此外,它还证明了通过发布令人惊叹的表情符号并以口碑作为主要营销手段,你可以获得人气和认可。不仅如此,你还可能成为HTMX的CEO!它吸引了许多开发人员尝试这种构建网站的方法,并重新思考他们当前的实践。所有这些都使2024年对于这个库的未来发展充满了激动人心的可能性。


Wasp - 全栈,开箱即用🚀


开放SaaS


为谁而设:



  • 你希望快速构建全栈应用

  • 你希望在一个出色的一体化解决方案中继续使用React和Node.js,而无需手动挑选堆栈的每一部分

  • 你希望获得一个为React和Node.js预配置的免费SaaS模板—— Open SaaS


对于希望简单轻松地全面控制其堆栈的工具的用户,无需再寻找!Wasp是一个有主见的全栈框架,利用其编译器以快速简便的方式为你的应用创建数据库、后端和前端。它使用React、Node.js和Prisma,这些都是全栈Web开发人员正在使用的一些最著名的工具。


Wasp的核心是main.wasp文件,它作为你大部分需求的一站式服务。在其中,你可以定义:



  • 全栈身份验证

  • 数据库架构

  • 异步作业,无需额外的基础设施

  • 简单且灵活的部署

  • 全栈类型安全

  • 发送电子邮件(Sendgrid、MailGun、SMTP服务器等)

  • 等等……


最酷的事情是?经过编译器步骤后,你的Wasp应用程序的输出是一个标准的React + Vite前端、Node.js后端和PostgreSQL数据库。从那里,你可以使用单个命令轻松将一切部署到Fly.io等平台。


尽管有些人可能会认为Wasp的有主见立场是负面的,但它却是Wasp众多全栈功能的驱动力。使用Wasp,单个开发人员或小型团队启动全栈项目变得更加容易,尤其是如果你使用预制的模板或OpenSaaS作为你的SaaS起点。由于项目的核心是定义明确的,因此开始一个项目并可能在几天内创建自己的全栈SaaS变得非常容易!


此外,还有一点很酷的是,大多数Web开发人员对大多数现有技术的预先存在的知识仍然在这里适用,因为Wasp使用的技术已经成熟。


Solid.js - 一流的reactivity库 ↔️


扎实的例子


适合人群:



  • 如果你希望代码具有高响应性

  • 现有的React开发人员,希望尝试一种对他们来说学习曲线较低的高性能工具


Solid.js是一个性能很高的Web框架,与React有一些相似之处。例如,两者都使用JSX,采用基于函数的组件方法,但Solid.js不使用虚拟DOM,而是将你的代码转换为纯JavaScript。然而,Solid.js因其利用信号、备忘录和效果实现细粒度响应性的方法而更加出名。信号是Solid.js中最简单、最知名的基本元素。它们包含值及其获取和设置函数,使框架能够观察并在DOM中的确切位置按需更新更改,这与React重新渲染整个组件的方式不同。


Solid.js不仅使用JSX,还对其进行了增强。它提供了一些很酷的新功能,例如Show组件,它可以启用JSX元素的条件渲染,以及For组件,它使在JSX中更轻松地遍历集合变得更容易。另一个重要的是,它还有一个名为Solid Start的元框架(目前处于测试版),它使用户能够根据自己的喜好,使用基于文件的路由、操作、API路由和中间件等功能,以不同的方式渲染应用程序。


Astro - 静态网站之王👑


天文示例


适合人群:



  • 如果您需要一款优秀的博客、CMS重型网站工具

  • 需要一个能够集成其他库和框架的框架


如果您在2023年构建了一个内容驱动的网站,那么很有可能您选择了Astro作为首选框架来实现这一目标!Astro是另一个使用不同架构概念来脱颖而出的框架。对于Astro来说,这是岛屿架构。在Astro的上下文中,岛屿是页面上的任何交互式UI组件,与静态内容的大海形成鲜明对比。由于这些岛屿彼此独立运行,因此页面可以有任意数量的岛屿,但它们也可以共享状态并相互通信,这非常有用。


关于Astro的另一个有趣的事情是,他们的方法使用户能够使用不同的前端框架,如React、Vue、Solid来构建他们的网站。因此,开发人员可以轻松地在其当前知识的基础上构建网站,并利用可以集成到Astro网站中的现有组件。


Svelte - 简单而有效🎯


精简演示


适合人群:



  • 您希望学习一个简单易上手的框架

  • 追求简洁且代码执行速度快的开发体验


Svelte是另一个尝试通过尽可能直接和初学者友好的方式来简化和加速Web开发的框架。它是一个很容易学习的框架,因为要使一个属性具有响应性,您只需声明它并在HTML模板中使用它。 每当在JavaScript中程序化地更新值时(例如,通过触发onClick事件按钮),它将在UI上反映出来,反之亦然。


Svelte的下一步将是引入runes。runes将是Svelte处理响应性的方式,使处理大型应用程序变得更加容易。类似于Solid.js的信号,符文通过使用类似函数的语句提供了一种直接访问应用程序响应性状态的方式。与Svelte当前的工作方式相比,它们将允许用户精确定义整个脚本中哪些部分是响应性的,从而使组件更加高效。类似于Solid和Solid Start,Svelte也有其自己的框架,称为SvelteKit。SvelteKit为用户提供了一种快速启动其由Vite驱动的Svelte应用程序的方式。它提供了路由器、构建优化、不同的渲染和预渲染方式、图像优化等功能。


Qwik - 非常快🚤


qwik演示


适合人群:



  • 如果您想要一个高性能的Web应用

  • 现有的React开发人员,希望尝试一种高性能且学习曲线平缓的框架


最后一个但同样重要的框架是Qwik。Qwik是另一个利用JSX和函数组件的框架,类似于Solid.js,为基于React的开发人员提供了一个熟悉的环境,以便尽快上手。正如其名字所表达的,Qwik的主要目标是实现您应用程序的最高性能和最快执行速度。


Qwik通过利用可恢复性(resumability)的概念来实现其速度。简而言之,可恢复性基于在服务器上暂停执行并在客户端上恢复执行而无需重新播放和下载全部应用程序逻辑的想法。这是通过延迟JavaScript代码的执行和下载来实现的,除非有必要处理用户交互,这是一件非常棒的事情。它使整体速度提高,并将带宽降低到绝对最小值,从而实现近乎瞬间的加载。


结论


在我们所提及的所有框架和库中,最大的共同点是它们的熟悉度。每个框架和库都试图以构建在当前知识基础上的方式吸引潜在的新开发者,而不是做一些全新的事情,这是一个非常棒的理念。


当然,还有许多我们未在整篇文章中提及但值得一提的库和框架。例如,Angular 除了新的标志和文档外,还包括信号和新的控制流。还有 Remix,它增加了对 Vite、React Server Components 和新的 Remix SPA 模式的支持。最后,我们不能忘记 Next.js,它在过去几年中已成为 React 开发者的默认选择,为新的 React 功能铺平了道路。


作者:腾讯TNTWeb前端团队
来源:juejin.cn/post/7339830464000213027
收起阅读 »

自适应iframe高度

web
使用iframe嵌入页面很方便,但必须在父页面指定iframe的高度。如果iframe页面内容的高度超过了指定高度,会出现滚动条,很难看。如何让iframe自适应自身高度,让整个页面看起来像一个整体?在HTML5之前,有很多使用JavaScript的Hack技...
继续阅读 »

使用iframe嵌入页面很方便,但必须在父页面指定iframe的高度。如果iframe页面内容的高度超过了指定高度,会出现滚动条,很难看。

如何让iframe自适应自身高度,让整个页面看起来像一个整体?

在HTML5之前,有很多使用JavaScript的Hack技巧,代码量大,而且很难通用。随着现代浏览器引入了新的ResizeObserver API,解决iframe高度问题就变得简单了。

我们假设父页面是index.html,要嵌入到iframe的子页面是target.html,在父页面中,先向页面添加一个iframe

const iframe1 = document.createElement('iframe');
iframe1.src = 'target.html';
iframe1.onload = autoResize;
document.getElementById('sameDomain').appendChild(iframe1);


iframe载入完成后,触发onload事件,然后自动调用autoResize()函数:

function autoResize(event) {
// 获取iframe元素:
const iframeEle = event.target;
// 创建一个ResizeObserver:
const resizeRo = new ResizeObserver((entries) => {
let entry = entries[0];
let height = entry.contentRect.height;
iframeEle.style.height = height + 'px';
});
// 开始监控iframe的body元素:
resizeRo.observe(iframeEle.contentWindow.document.body);
}


通过创建ResizeObserver,我们就可以在iframebody元素大小更改时获得回调,在回调函数中对iframe设置一个新的高度,就完成了iframe的自适应高度。

跨域问题

ResizeObserver很好地解决了iframe的监控,但是,当我们引入跨域的iframe时,上述代码就失效了,原因是浏览器阻止了跨域获取iframebody元素。

要解决跨域的iframe自适应高度问题,我们需要使用postMessage机制,让iframe页面向父页面主动报告自身高度。

假定父页面仍然是index.html,要嵌入到iframe的子页面是http://xyz/cross.html,在父页面中,先向页面添加一个跨域的iframe

const iframe2 = document.createElement('iframe');
iframe2.src = 'http://xyz/cross.html';
iframe2.onload = autoResize;
document.getElementById('crossDomain').appendChild(iframe2);


cross.html页面中,如何获取自身高度?

我们需要现代浏览器引入的一个新的MutationObserver API,它允许监控任意DOM树的修改。

cross.html页面中,使用以下代码监控body元素的修改(包括子元素):

// 创建MutationObserver:
const domMo = new MutationObserver(() => {
// 获取body的高度:
let currentHeight = body.scrollHeight;
// 向父页面发消息:
parent.postMessage({
type: 'resize',
height: currentHeight
}, '*');
});
// 开始监控body元素的修改:
domMo.observe(body, {
attributes: true,
childList: true,
subtree: true
});


iframe页面的body有变化时,回调函数通过postMessage向父页面发送消息,消息内容是自定义的。在父页面中,我们给window添加一个message事件监听器,即可收取来自iframe页面的消息,然后自动更新iframe高度:

window.addEventListener('message', function (event) {
let eventData = event.data;
if (eventData && eventData.type === 'resize') {
iframeEle.style.height = eventData.height + 'px';
}
}, false);


使用现代浏览器提供的ResizeObserverMutationObserver API,我们就能轻松实现iframe的自适应高度。

点击阅读原文查看演示页面:

作者:廖雪峰
来源:mp.weixin.qq.com/s/8NmYRzPlTTihJVUybkqqOQ

收起阅读 »

vue项目部署自动检测更新

web
前言 当我们重新部署前端项目的时候,如果用户一直停留在页面上并未刷新使用,会存在功能使用差异性的问题,因此,当前端部署项目后,需要提醒用户有去重新加载页面。 在以往解决方案中,不少人会使用websocket去通知客户端更新,但是为了这么个小功能加入websoc...
继续阅读 »

前言


当我们重新部署前端项目的时候,如果用户一直停留在页面上并未刷新使用,会存在功能使用差异性的问题,因此,当前端部署项目后,需要提醒用户有去重新加载页面。


在以往解决方案中,不少人会使用websocket去通知客户端更新,但是为了这么个小功能加入websocket是十分不明智的,新方案的思路是去轮询请求index.html文件,从中解析里面的js文件,由于vue打包后每个js文件都有指纹标识,因此可以对比每次打包后的指纹,分析文件是否存在变动,如果有变动即可提示用户更新


原理


自动更新.png


封装函数 auto-update.js


let lastSrcs; //上一次获取到的script地址
let needTip = true; // 默认开启提示

const scriptReg = /<script.*src=["'](?<src>[^"']+)/gm;

async function extractNewScripts() {
const html = await fetch('/?_timestamp=' + Date.now()).then((resp) => resp.text());
scriptReg.lastIndex = 0;
let result = [];
let match;
while ((match = scriptReg.exec(html))) {
result.push(match.groups.src)
}
return result;
}

async function needUpdate() {
const newScripts = await extractNewScripts();
if (!lastSrcs) {
lastSrcs = newScripts;
return false;
}
let result = false;
if (lastSrcs.length !== newScripts.length) {
result = true;
}
for (let i = 0; i < lastSrcs.length; i++) {
if (lastSrcs[i] !== newScripts[i]) {
result = true;
break
}
}
lastSrcs = newScripts;
return result;
}
const DURATION = 5000;

function autoRefresh() {
setTimeout(async () => {
const willUpdate = await needUpdate();
if (willUpdate) {
const result = confirm("页面有更新,请刷新查看");
if (result) {
location.reload();
}
needTip = false; // 关闭更新提示,防止重复提醒
}
if(needTip){
autoRefresh();
}
}, DURATION)
}
autoRefresh();

引入


在main.js中引入


// 引入自动更新提醒
import "@/utils/auto-update.js"

使用element-ui的notify提示更新


修改auto-update.js



let lastSrcs; //上一次获取到的script地址
let needTip = true; // 默认开启提示

const scriptReg = /<script.*src=["'](?<src>[^"']+)/gm;

async function extractNewScripts() {
const html = await fetch('/?_timestamp=' + Date.now()).then((resp) => resp.text());
scriptReg.lastIndex = 0;
let result = [];
let match;
while ((match = scriptReg.exec(html))) {
result.push(match.groups.src)
}
return result;
}

async function needUpdate() {
const newScripts = await extractNewScripts();
if (!lastSrcs) {
lastSrcs = newScripts;
return false;
}
let result = false;
if (lastSrcs.length !== newScripts.length) {
result = true;
}
for (let i = 0; i < lastSrcs.length; i++) {
if (lastSrcs[i] !== newScripts[i]) {
result = true;
break
}
}
lastSrcs = newScripts;
return result;
}
const DURATION = 5000;

function autoRefresh() {
setTimeout(async () => {
const willUpdate = await needUpdate();
if (willUpdate) {
// 延时更新,防止部署未完成用户就刷新空白
setTimeout(()=>{
window.dispatchEvent(
new CustomEvent("onmessageUpdate", {
detail: {
msg: "页面有更新,是否刷新?",
},
})
);
},30000);
needTip = false; // 关闭更新提示,防止重复提醒
}
if(needTip){
autoRefresh();
}
}, DURATION)
}
autoRefresh();

编写模板


CnNotify.vue文件


<template>
<div class="cn_notify">
<div class="content">
<i class="el-icon-message-solid"></i>
{{ msg }}
</div>
<el-row :gutter="20">
<el-col :span="7" :offset="10">
<el-button type="primary" @click="onSubmit">确定</el-button>
</el-col>
<el-col :span="7">
<el-button @click="cancle">取消</el-button>
</el-col>
</el-row>
</div>
</template>
<script>
export default {
props: {
msg: {
type: String,
default: "",
},
},
data() {
return {};
},
created() {},
methods: {
// 点击确定更新
onSubmit() {
location.reload();
},
// 关闭
cancle() {
this.$parent.close();
},
},
};
</script>
<style lang='less' scoped>
.cn_notify {
.content {
padding: 20px 0;
}
.footer {
display: flex;
flex-direction: row-reverse;
}
}
</style>

使用


App.vue


// 引入
import CnNotify from "@/components/CnNotify.vue";
components: {
CnNotify,
},
mounted() {
this.watchUpdate();
},
methods: {
watchUpdate() {
window.addEventListener("onmessageUpdate", (res) => {
let msg = res.detail.msg;
this.$notify({
title: "提示",
duration: 0,
position: "bottom-right",
dangerouslyUseHTMLString: true,
message: this.$createElement("CnNotify", {
// 使用自定义组件
ref: "CnNotify",
props: {
msg: msg,
},
}),
});
});
},
},

作者:howcode
来源:juejin.cn/post/7246997498572619831
收起阅读 »

前端接口多参数请求时如何优雅封装

web
接口多参数优雅封装 开发中经常会遇到有一个接口需要的query参数比较多, 参数的数据类型也不全是 string | number ,还存在数组或者其他类型的情况。 我以前的做法 因为是query参数,我以前的写法就是在接口地址上面进行字符串拼接。 asyn...
继续阅读 »

接口多参数优雅封装


开发中经常会遇到有一个接口需要的query参数比较多,
参数的数据类型也不全是 string | number ,还存在数组或者其他类型的情况。


我以前的做法


因为是query参数,我以前的写法就是在接口地址上面进行字符串拼接。


 async function getJobList(
clusterId: string,
page: number,
pageSize: number,
JobName : string,
JobStatus : string,
Username : string,
Queue : string,
StartTime: string,
EndTime: string
) {
if (JobStatus == undefined) JobStatus = '';
const res = await apiRequest.get(
`/api/Cluster/${clusterId}/Job?page=${page}&pageSize=${pageSize}&JobName=${JobName}&JobStatus=${jobStatus}&Username=${Username}&Queue=${Queue}&StartTime=${
StartTime ? StartTime : ''}
&EndTime=${EndTime ? EndTime : ''}`

);
return res;
}

以前觉得这样做没什么问题,就是写起来很不优雅,代码的可读性也特别差。


那能不能不安代码的格式化自己整理一下表呢?为方阅读这样去写


 const res = await apiRequest.get(
`/api/Cluster/${clusterId}/Job?${page ? 'page=' + page : ''}
${pageSize ? '&pageSize=' + pageSize : ''}
${JobName ? '&JobName=' + JobName : ''}
${JobStatus ? JobStatus?.map((x) => '&JobStatus=' + x).join('') : ''}
${Username ? '&Username=' + Username : ''}
${Queue ? '&Queue=' + Queue : ''}
${StartTime ? '&StartTime=' + StartTime : '' }
${EndTime ? '&EndTime=' + EndTime : '' }`

);

这种写法看起来更易读了,但是 ` 中的换行和空格会保留在里面,参数就会莫名的加上几个空格,查询参数就不对了(好心干坏事了)。


再说,如果参数更多呢,那岂不是要再去一个一个拼接,时间成本也太高了,有没有更优雅的写法呢?


解决办法


在MDN上看到了这个URLSearchParams接口


URLSearchParams 接口定义了一些实用的方法来处理 URL 的查询字符串。


通过URLSearchParams.append(name, value)方法可以不断往里面添加参数


下面是更改后的代码


 async function getJobList(
clusterId: string,
page: number,
pageSize: number,
JobName?: string,
JobStatus?: Array<string>,
Username?: string,
Queue?: string,
StartTime?: string,
EndTime?: string
) {
if (JobStatus?.length == 0) JobStatus = null;
const add_params = {
page: page,
pageSize: pageSize,
JobName: JobName,
JobStatus: JobStatus,
Username: Username,
Queue: Queue,
StartTime: StartTime,
EndTime: EndTime,
};
const searchParams = new URLSearchParams();
Object.keys(add_params).forEach((key) => {
if ( add_params[key] !== undefined && add_params[key] !== null ) {
const value = add_params[key];
if (Array.isArray(value)) {
value.forEach((item) => searchParams.append(key, item));
} else {
searchParams.append(key, value);
}
}
});
const res = await apiRequest.get(
`/api/Cluster/${clusterId}/Job?${searchParams.toString()}`
);
return res;
}


这样去写代码就整洁优雅了太多了


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

记一次页面截图需求

web
需求背景 上图是我所负责的监控产品,页面上有大量的图表,用户的述求是能对页面截屏从而直接分享给别人。 那么就有小伙伴要发问了,为什么不直接把页面链接分享给别人呢? 首先,页面可能有权限校验,被分享的人可能没有该页面的访问权限,而图片不会有这个问题;其次,实践...
继续阅读 »

需求背景



上图是我所负责的监控产品,页面上有大量的图表,用户的述求是能对页面截屏从而直接分享给别人。


那么就有小伙伴要发问了,为什么不直接把页面链接分享给别人呢?


首先,页面可能有权限校验,被分享的人可能没有该页面的访问权限,而图片不会有这个问题;其次,实践表明,如果分享的是链接,用户的点击意愿很低,如果不是直接相关的人往往不会点开链接查看,而如果是图片的话,非常直观,往往第一眼就传递了很多信息给被分享的人。


那么又有小伙伴要发问了,既然如此,为何不让用户自己装一个截屏软件自己截算了?


考虑两个点,第一是不一定所有用户都有一个好用的截屏的软件(特别是在Mac上,大伙应该深有体会),并且页面如果需要滚动截屏,用户的操作就会比较麻烦,因此页面上能提供一个一键截屏的按钮就十分便利了;第二是如果由页面提供截图能力,可以很好地定制最终图片上所呈现的页面,比如可以调整一下布局,修改某些元素。


不过需要注意的是,我们要实现的截屏并不是一个真正的截屏,而是相当于dom的快照,针对传入的dom生成图片。


方案调研


那么咱就来研究研究,市面上都有哪些截屏的方案。


后端方案


一种比较常见的方案,是在服务端使用puppeteer(或者playwright啥的)起一个无头浏览器,渲染完页面后截图返回给前端,比如金山文档就是这么做的。


但是吧,这种方案的缺陷很明显。首先毋庸置疑的是,服务端的压力会变大,成本会变高;其次,最终生成的图片往往与用户所看到的页面有些出入,比如金山文档的截屏,如果源文档是些奇奇怪怪的字体,最终生成的图片里的字体就会是默认字体,另外布局什么的也可能会不一致;


源文档:



生成的图片:



那么后端方案优点也就与缺点一一对应,首先是对用户设备的消耗较小,性能较差的设备也能使用;其次是对于同一页面,后端方案生成图片能够完全一致,不会因为用户的机型不同导致页面布局发生变化,而且更重要的一点是,生成图片基本上都依赖于canvas,而canvas这东西有个坑,它对宽、高、面积有一定的限制,并且不同浏览器、不同设备的限制还不太一样,并且同一设备同一浏览器也会因为用户的设备可用资源受到影响,在生成canvas之前也不能拿到这个限制,这个限制在IOS设备上最为严重(有意思的是canvas是苹果提出的标准),参考javascript - Maximum size of a element - Stack Overflow,因此采用后端方案能够保证结果的一致性。



前端方案


有的小伙伴会说了,浏览器自带截屏功能的,直接用多好呀。是的,浏览器有一个截屏功能,但是我们在JS代码里并没法直接调用,并且浏览器自带的截屏,也无法实现上述所说的修改页面元素的能力。


浏览器自带截屏:



那么比较靠谱的前端截屏方案其实就两种,一种自己实现渲染,将dom一一渲染到canvas上后生成图片,比如html2canvas;另一种是借助foreignObject,将svg绘制到canvas上再生成图片,代表作为dom-to-image


html2canvas


html2canvas可以说是最古老的前端截屏实现方案了,也称得上是独一档的实现。它的原理简单来说就是克隆传入的dom,遍历克隆树,通过getBoundingClientRect获取元素的位置、大小,getComputedStyle获取元素样式,然后使用canvas的底层API,一点一点画出来的。


可想而知,这个过程是多么复杂,相当于自己实现了一套渲染引擎,并且css越来越复杂,想要完全绘制到canvas,够呛,所以html2canvas现在有一个很大的缺点就是对css的支持不够好。


另外,由于它自建了一套渲染,需要处理的情况非常多,所以包体积相当大,官网标注的gzip压缩后也有45kB。



除了上述原因外,真正让我放弃这个库的原因是,它太老了,它真的太老了,作为一个十几年前的库,它现在已经年久失修,上次更新都是两年前,而且看着只是文档修改。



并且已经堆积了800+ issue没有处理,基本上是不维护状态了。



更有意思的是,即使这个库已经存在了十几年,并且有大量页面将其应用到了生产环境,其中不乏一些大公司产品,比如腾讯文档(别问我怎么知道的,问就是我写的),但是它的作者仍在Readme里边写到:



dom-to-image


dom-to-image的基本原理十分简单,不需要做什么复杂的渲染,利用到了svg元素的foreignObject:



只需要把dom丢到foreignObject里边,就会在svg里边渲染出来,因为是浏览器的标准,也不用担心对css的支持不够友好:




其实,到这一步,你会发现已经达到将dom转成图片的目的了,svg本来就是图片。但是你可能会需要其他格式的图片,并且这样生成的svg体积实在是大了点,包含了大量冗余的信息。所以这里还是用到canvas,通过drawImage把svg画到canvas上,再通过canvas的toDataUrl生成图片链接。


从体积上看,不到10kB,是完全可以接受的:


看看它的代码仓库,可以看到已经七八年不更新了,并且有200+ issue没有处理,也基本上处于不维护状态了。:




如果能够满足需求,也不是不能用,遗憾的是,不太能满足我的需求。


首先是资源跨域问题,其实资源本身是支持跨域的,但是原始html中的标签没有加上crossorigin属性,导致生成图片时会报跨域错误,像页面里的图片、外链css啥的得做点特殊处理才能用。另外还有些奇奇怪怪的问题,可以看看issue,反正是不太能用。


dom-to-image-more


dom-to-image-more听名字也能听出来是fork的dom-to-image,解决了dom-to-image的部分bug,增加了一些能力。最重要的能力应该是解决了上述提到的跨域问题,它把link标签做了一下拦截,使用fetch去请求对应的src,加上了跨域配置,然后再对返回结果进行处理。另外还有一个有意思的点,在dom-to-image中,获取元素的样式是通过document.getComputedStyle拿到每个dom节点的样式,然后通过行内样式插入到对应的标签上,会导致最后生成的图片上包含了大量的行内样式,体积自然就比较大;而dom-to-image-more做了一个优化,利用沙盒获取到了元素的默认样式,再和getComputedStyle作比较,只插入不同于默认样式的属性,从而极大地减小了图片的体积,自然而然,这个复杂度高了点,生成图片的耗时稍微长点。


体积很理想,不到6kB:



之前看最新的更新在两年前,但是近期好像又有更新,说明还是有人在维护的:



但是最终还是没有用它,因为有个痛点,在我的场景下用了很多icon,而这些icon都是svg格式的,它们通过defs - SVG定义了一次,然后使用时都是通过 - SVG引用的;但是这个库没有处理这种情况,导致生成图片时只复制了use元素,而没有将其对应的defs元素复制过去,从而导致最终生成的图片上丢失了这些icon。


html-to-image


html-to-image也是fork的dom-to-image,修了部分bug,增加了一些能力。这个库相较于dom-to-image,特点是优化了文件结构,增加typescript支持,对比上述的dom-to-image-more,处理好了svg use和svg defs的情况,在有use的情况下会去找到对应的defs元素并添加进来。但是,它没有解决跨域问题。


另外还有个痛点,之前提到的icon,它们的样式吧,上面我们提到了,是通过getComputedStyle获取到,然后插入到行内样式实现的;对于普通的dom元素而言,这样做没有问题,因为这些dom使用的地方就是它们定义的地方;但是对于svg defs和svg use这样的元素而言,在定义时它的样式就已经被行内样式写死了,使用的时候就没办法覆盖定义时的样式,导致我的彩色icon全变成黑色了:


原图:



生成的图片:



看了下源代码,确实没有针对这点进行处理,所以还是放弃了,另外可想而知的是,像webcomponent这样定义和使用分离的情况,估计也存在样式不能覆盖的问题。


modern-screenshot


modern-screenshot也是基于dom-to-image,但它不是直接fork的dom-to-image,而是上面提到的html-to-image,所以相当于是dom-to-image的孙子辈了。


这个库既然是fork的html-to-image,自然也就继承了html-to-image良好的文件结构以及优秀的ts支持;并且这个库有意思的是,它还整合了dom-to-image-more的优化,不会产生跨域的问题了;对于svg use和svg defs,它更进一步,复用已有的defs,减小了生成图片的体积;另外还有个点,它用到了webworker并行地发起网络请求。


东抄抄西补补,modern-screenshot是目前我看到的效果最理想的前端截屏方案,并且这个库的作者仍在维护:


最近的更新发生在三周前,包体积gzip压缩后不到10kB,完全可以接受。


美中不足的是,这个库依然没有解决上述提到的svg use样式不能覆盖问题。其实想想也明白,通过getComputedStyle再写入行内样式的方式,这个问题是避免不了的。不过,考虑到svg defs元素一般都是icon在使用,而这些icon一般来说不会被外界样式所影响,所以针对svg defs和svg use标签,我们不通过getComputedStyle获取其样式,而是直接使用dom.cloneNode获取的样式,这样就不会写死行内样式,从而解决了这个问题。于是给该项目提了一个PR,也顺利合入:



当然这种解法并不严谨,但是绝大部分情况下应该够用,至少在我的场景下已经足够满足需求,因此最终我也是选择了使用modern-screenshot来实现截屏的需求。


modern-screenshot使用


modern-screenshot用起来也很简单,安装完成之后,只需少量代码即可使用:


// html
<div class="container">
<tw-el-tooltip content="生成图片" placement="top" hide-after="0">
<div class="config-button" @click="generateImage">
<el-icon
:config="common_system_picture"
color="#898A8C"
>
</el-icon>
</div>
</tw-el-tooltip>

</div>

// js
import { domToJpeg } from 'modern-screenshot';

const generateImage = async () => {
// 获取要生成图片的dom节点
const node = document.getElementById('service-analyzer-main');
if (!node) {
return;
}
try {
const dataUrl = await domToJpeg(node, {
// 传入配置
scale: 2,
});

// 通过a标签自动下载图片
const a = document.createElement('a');
a.href = dataUrl;
a.download = route.path.split('/').at(-1) + '.jpg';
a.click();
a.remove();
ElMessage.success('图片生成成功,请耐心等待下载');
} catch (error) {
ElMessage.error('图片生成失败');
}
};

原图(使用截屏软件截的长图):



通过modern-screenshot生成的图片:



当然,这是最基本的使用。如果涉及到一些复杂的操作,比如需要在截图时,对某些元素进行修改,比如把图片转成url展示,就需要在截图前遍历dom树进行一些转换再生成图片了。如果这都还不能满足需求,可能就需要专门实现一个预览模式,通过iframe打开预览模式的页面再生成图片了,腾讯文档就是这么做的。


还有些坑


苹果两倍屏


用Mac的小伙伴应该知道,Mac的屏幕是所谓的Retina屏,它的像素密度是普通屏幕的两倍,因此如果直接生成图片的话,在Mac上看起来会是比较模糊的,因此在生成图片时需要将其放大两倍。


截图元素有滚动条导致图片截断


如果截图元素有滚动条,会导致最终生成的图片只包含当前滚动条区域内的内容。如果想要截取完整内容,可以通过将临时截图元素的宽高设置为fit-content,让其展示完整,截图完成后再修改为原始宽高即可。不可避免的,这种方式会对原始元素产生一些影响,滚动条会“跳一下”,不过问题不大,实在介意的话可以加个遮罩层,在生成图片时盖住就好。


缩放后canvas元素模糊


如果在生成图片时设置scale选项,将其放大,可以看到最终生成的图片上canvas元素虽然放大了,但是并不够清晰。这个是没办法避免的,毕竟canvas是像素级别的画布,做不到无损放大,同理可以推断像素图比如jpg、png这些经过放大后也会模糊。


元素内部的滚动元素


另外还有一个坑点,如果截图元素内部的元素有滚动条,生成的图片只能包含可视区域内的部分。其实这是合理的,当然不能全都包含进来,问题是,我有一个页面,截图元素内部存在滚动元素,但是这个滚动元素的默认滚动位置是居中的,而生成图片时这个元素的滚动位置只能是左上角,因此最终生成的图片就没有我想要的内容:


原图:



生成的图片:



而且好像并没有办法指定滚动条的位置。


这让我想到之前看到别人分享的一个有意思的bug,说是他的服务接入了第三方地图服务,但是不管他如何调试,找遍了官方文档,他的页面始终是蓝色的。后续排查了很久,终于发现,原来这个地图默认定位是在经纬度(0, 0),而这里是一片大海。。。



内容过长时生成空白图片


这个其实就是我一开始提到的canvas存在限制的问题:javascript - Maximum size of a element - Stack Overflow


小结


那么事情就是这么个事情啦,主要是记录一下当时我做截图需求过程中调研的一些方案,以及对应的优缺点,并记录了一些坑。其中也包含了一些我在选择三方库时的考量,比如npm下载量、最近更新情况、仓库维护情况、包体积大小、项目功能完善程度、仓库质量、ts支持情况等。


看到这里,如果对你有所帮助的话,可以给我一些鼓励哦。


作者:超级无敌大怪兽
来源:juejin.cn/post/7339671825646338057
收起阅读 »

同学们说我染上面试了

web
目前大三,最近刚分手,想着正好沉下心来去搞就业,寒假期间抱着摸自己底的心态去试试面试,海投了许多厂,小厂,中厂,大厂都有,给我的感觉就是大部分内容都是八股,怎么跟别人的面试不一样,本期就来记录下我面试中遇到的那些面试题,希望对大家春招有所帮助 一、聊聊盒子模型...
继续阅读 »

目前大三,最近刚分手,想着正好沉下心来去搞就业,寒假期间抱着摸自己底的心态去试试面试,海投了许多厂,小厂,中厂,大厂都有,给我的感觉就是大部分内容都是八股,怎么跟别人的面试不一样,本期就来记录下我面试中遇到的那些面试题,希望对大家春招有所帮助


一、聊聊盒子模型


盒模型是css描述布局用的概念,盒子模型有两种,默认情况下为标准盒模型,还有一种为IE或者怪异盒模型,这个是曾经IE使用的盒模型,如今使用需要打上box-sizing: border-box;


对于标准盒模型,一个盒子最终的宽度为width + padding + border + margin。而对于IE盒模型,一个盒子最终的宽度为width + margin,也就是说,IE盒模型的宽度其实就是标准盒模型的width + padding + border,最终自身的真实宽度会被边框和内边距挤掉一部分,当你为了防止盒子被撑大的时候你可以使用IE盒模型


二、vue3的响应式如何实现


vue3的响应式主要是靠es6的代理proxy来实现的,proxy可以拦截对象,进行读值操作,能够捕获到数据的修改


reactive能够将引用类型变成响应式,ref通常用于将原始数据类型变成响应式,但是ref也可以将引用类型变成响应式,另外还有个副作用函数effect,当响应式数据发生变更,副作用函数就会重新执行


三、computed和watch是什么,有什么应用场景


computed是计算属性,依赖响应式数据,只要发生变更就会重新计算,另外computed有缓存机制,多次使用计算属性的逻辑时只会执行一次。所以当数据是根据其他响应式数据计算而来的时候,可使用computed


watch是监听器,监听一个数据的变化,当数据变化时,就会执行一个回调,可以用于处理异步,另外watch在刚进页面的时候就会执行一次


四、说说你对BFC的理解


BFC全称Block Formatting Context也就是块级格式化上下文,是css描述块级盒子的一种方式


BFC可以让浮动元素的高度也计算在内,因此常用于清除浮动,另外BFC还可以阻止子元素的margin-top和父容器重叠。


像是overflow: auto | hidden | scroll 以及左右浮动,还有相对,固定定位display: flex | grid | inline-block | table开头的属性都可以触发BFC


五、浏览器的事件循环


js事件循环机制是浏览器用于处理js代码执行顺序的机制


因为js设计之初仅仅是个脚本语言,所以为了性能,将其定义为单线程,代码一定会有耗时和不耗时代码,也就是异步和同步代码,并且设计师为了更精细地控制异步代码地执行顺序,又将异步分为宏任务,微任务,因此事件循环机制就是描述同步,宏任务和微任务的执行优先顺序


在浏览器的一个事件循环中,先是执行同步,再是执行微任务,最后才是宏任务,之后宏任务又是下一轮事件循环的开启,像是js全局的打印就是一个同步代码,常见的宏任务有三个定时器,和scriptI/O页面渲染。常见的微任务有promise.thenMutationObserverProcess.nextTick


六、浏览器输入url之后发生了什么


先进行url解析,浏览器解析输入的url,提取出协议,主机名,路径等信息


再是dns解析,浏览器将主机名解析成相应的ip地址


然后建立tcp连接,这个过程就是三次握手,确保客户端和服务器之间的连接正常建立


再是发送http请求,浏览器向服务器发送http请求,请求中包括了方法,路径,请求头等信息


然后服务器会处理请求,根据请求的内容进行处理,查询数据库,读取文件


然后服务器会返回响应,将生成的响应数据通过tcp连接发送回浏览器


浏览器接收响应并进行渲染页面,这个过程包括了解析html,css代码生成相应的dom树和cssom树,然后两树结合形成Render Tree,回流,重绘


断开连接,也就是tcp四次挥手


七、flex:1代表什么


flex: 1通常用于做分配父容器的宽度,比如做三栏布局时,两边固定写死宽度,中间给个flex: 1,那么他会继承到中间剩余的所有宽度


不过其实flex是有三个参数的,flex: 1其实是flex: 1 1 auto的缩写,第一个参数是flex-grow也就是放大比例,1代表会放大,也是默认值,第二个参数flex-shrink是收缩,默认值1代表空间不足时等比例收缩,第三个参数flex-basis是定义弹性元素的初始大小


八、讲讲diff算法


diff算法就是用在比较两颗虚拟dom树的差异,最小化地对真实dom进行修改,就是找不同。


这个比较过程先是对两颗dom树进行深度优先遍历,然后同级节点比较,比较相同位置地节点,类型属性是否不同,不同则替换,相同继续下去


两个相同类型的节点有不同的属性diff算法也会更新到真实dom,如果两个节点有不同的子节点,diff算法也会递归找到子节点地差异


当比较列表节点地时候,diff算法会尽可能地复用已有地节点,而不是删除再重新创建。另外当diff算法发现某个节点已经不同于之前的虚拟dom树,它会立即停止对改节点地进一步比较,避免性能浪费


九、js数据类型判断


typeof可以判断除了null之外的原始数据类型,还可以判断函数


instanceof只能判断引用数据类型,它是通过原型链来查找的


Array.isArray只能判断是否为数组


Object.prototype.toString.call可以判断所有的数据类型,返回一个字符串,比较起来会麻烦点


十、手写防抖节流


防抖就是在规定的时间内没有第二次操作,才执行,规定的时间内一直触发,就不会执行。


防抖如下


function debounce(fn, delay){
let timer
return function(){
let args = arguments
if(timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.call(this,...args)
},delay)
}
}

防抖就是接收一个函数和一个时间,然后在函数中创建一个定时器,如果定时器不存在,那么就会创建一个定时器,并在时间过期之后执行传入的函数,如果定时器存在那么久掐灭这个定时器


节流就是规定的时间内只执行一次,假设手速很快,但是触发事件地速度是恒速


节流如下


function throttle(fn, delay){
let prevTime = Date.now();
return function(){
if(Date.now() - prevTime > delay){
fn.apply(this,arguments)
prevTime = Date.now()
}
}
}

节流同样,接受一个函数,一个时间,只要两次时间差大于传入的时间才会执行函数,并且只有满足了这个条件,prevTime才会更新为当前时间


十一、vite为什么比webpack快


vite使用了ES Module作为开发基础,在开发过程中能够以原生的ES Module直接加载和解析模块


vite可以在你修改代码后,只重新加载和应用发生改变的部分,而非整个页面,并且当你启动项目时,vite只会加载你正在编辑得文件,而非整个项目,vite只在需要时加载代码,而不是一次性加载所有的内容,并且vite可以缓存已经构建过的代码,下次启动时直接使用缓存


十二、如何解决闭包导致的内存泄露


可以使用WeakMap或者WeakSet来存储外部变量,因为二者是弱引用,时刻会被垃圾回收机制回收


如果闭包处于事件处理函数中,可以借助事件委托的机制,将事件处理函数放到父元素上,而非每个子元素上,事件委托就是借助了冒泡的机制


十三、聊聊cookie


cookie是个小型的文本文件,由服务器端发送到用户的浏览器,并存储在用户的计算机上。cookie主要是来跟踪用户的会话信息,存储用户的偏好设置


cookie通常用于身份认证来保证网站资源的安全性,而非大量数据的本地存储,当用户访问一个网站时,服务器会创建一个唯一的会话标识符,存在cookie中,这样用户在同一网站就可以保持登录状态,不用重复登录


十四、聊聊vue的生命周期


vue的生命周期有如下几个阶段,每个阶段都有两个钩子,除了最后一个销毁,时间一到自动触发钩子


创建阶段:创建之前,初始化选项式api,创建之后


挂载阶段:挂载之前,初始渲染dom节点,挂载之后


更新阶段:更新前,更新后,这里的更新是根据数据源的变化


销毁阶段:销毁前,销毁后,移除所有的监听器


错误捕获:子组件报错时触发


十五、vue中父子组件如何通讯


父传子,需要父组件v-bind绑定属性用于传值,子组件用props接收


子传父,需要子组件通过$emit发布该事件,且携带参数


最后


不知道为什么,面了这么多,很少碰到让我手写,大部分都是聊八股和项目。


作者:Dolphin_海豚
来源:juejin.cn/post/7340479361219002405
收起阅读 »

js如何控制一次只加载一张图片,加载完成后再加载下一张

web
今天看到一个面试题,是关于img图片加载方面的,有必要记录一下。其实关于这个问题,只要知道图片什么时候加载完成就能解决了。通过onload事件判断Img标签加载完成实现逻辑:新建一个Image对象实例,为实例对象设置src属性等,在onload事件中添加此实例...
继续阅读 »

今天看到一个面试题,是关于img图片加载方面的,有必要记录一下。其实关于这个问题,只要知道图片什么时候加载完成就能解决了。

通过onload事件判断Img标签加载完成

实现逻辑:新建一个Image对象实例,为实例对象设置src属性等,在onload事件中添加此实例对象到父元素中,然后将图片地址数组中的第一个元素剔除,继续调用此方法直到存储图片地址的数组为空。

代码

const imgArrs = [...]; // 图片地址
const content = document.getElementById('content');
const loadImg = () => {
if (!imgArrs.length) return;
const img = new Image(); // 新建一个Image对象
img.src = imgArrs[0];
img.setAttribute('class', 'img-item');
img.onload = () => { // 监听onload事件
// setTimeout(() => { // 使用setTimeout可以更清晰的看清实现效果
content.appendChild(img);
imgArrs.shift();
loadImg();
// }, 1000);
}
img.onerror = () => {
// do something here
}
}
loadImg();


实现效果

lp_img_load.gif

加上setTimeout后,看到的效果更加明显,我这里加了500毫秒的延迟(录屏软件只支持录制8秒的时间...)

setTimeout_load_img.gif

其实我在网上还看到了一种答案,通过onreadystatechange事件实现监听,于是在我本地调试了一下,发现并不能实现,img实例对象上并没有这个属性方法。查了查MDN,发现目前仅有XmlHttpRequest对象和Document对象中存在onreadystatechange属性,而对于其它元素onreadystatechange此属性并不存在。

因此对于其它元素需要慎用onreadystatechange事件

不过我电脑上目前只有ChormeSafari两种浏览器,对于onreadystatechange测试的覆盖面不全,所以我上面的结论可能还需要进一步验证才行,感兴趣的掘友可以调试一下~。

扩展知识

img标签是什么时候发送图片资源请求的?

  1. HTML文档渲染解析,如果解析到img标签的src时,浏览器就会立刻开启一个线程去请求图片资源。
  2. 动态创建img标签,设置src属性时,即使这个img标签没有添加到dom元素中,也会立即发送一个请求。
// 例1:
const img = new Image();
img.src = 'http://xxxx.com/x/y/z/ccc.png';

上面的代码如果运行起来后,就会发送请求。 如图:

image.png

再看一个例子:创建了一个div元素,然后将存放img标签元素的变量添加到div元素内,而div元素此时并不在dom文档中,页面不会展示该div元素,那么浏览器会发送请求吗?

// 例2:
const img = ``;
const dom = document.createElement('div');
dom.innerHtml = img;

答案:会请求。如图:

image.png

通过设置css属性能否做到禁止发送图片请求资源?

  1. img标签设置样式display:none或者visibility: hidden,隐藏img标签,无法做到禁止发送请求。
"http://xxx.com/x/sdf.png" style="display: none;">
或者
"http://xxx.com/x/sdf.png" style="visibility: hidden;">
  1. 将图片设置为元素的背景图片,但此元素不存在,可以做到禁止发送请求。
DOCTYPE html>
<html lang='en'>
<head>
<meta charset='UTF-8'>
<title>title>
<style>
.test {
height: 200px;
background-image: url('http://eb118-file.cdn.bcebos.com/upload/39148b2a545b48bf9b4ee95fd1b7f1eb_1515564089.png?');
}
style>
head>
<body>
<div>div>
body>
html>

dom文档中不存在test元素时,即使设置了背景图片,也不会发送请求,只有test元素存在时才会发送请求。

另外这个例子其实有点不太贴切,img标签background-image二者有着本质的区别。一个属于HTML标签,另一个属于css样式,加载机制和解析顺序也不同。

一个完整的页面是由jshtmlcss组成的,按照解析机制,html元素会优先解析,尽管css样式是放在head标签内的,但也不意味着它会优先加载,它只有等到html文档加载完成后才会执行。而img标签属于网页内容,所以img标签会随着网页解析渲染优先于css样式表加载出来。

文章中若有描述不正确的地方,欢迎掘友们纠正~。

参考文章


作者:娜个小部呀
来源:juejin.cn/post/7340167256267391012
收起阅读 »

小小导出,我大前端足矣!

web
如果你觉得你现在做的事情很累,很难,那一定是你方法用错了。 一、问题剖析 那是一个倾盆大雨的早上,花瓣随风雨落在我的肩膀上,是五颜六色的花朵。 我轻轻抚摸着他,随后拨开第一朵花瓣,她不爱我。 拨开第二朵,她爱我。 正当我沉迷于甜蜜的幻想中,后端小白🙋喊道:...
继续阅读 »



如果你觉得你现在做的事情很累,很难,那一定是你方法用错了。



2.jpg


一、问题剖析


那是一个倾盆大雨的早上,花瓣随风雨落在我的肩膀上,是五颜六色的花朵。


我轻轻抚摸着他,随后拨开第一朵花瓣,她不爱我。


拨开第二朵,她爱我。


正当我沉迷于甜蜜的幻想中,后端小白🙋喊道:这个导出你前端应该就能做的吧!


🙋🏻‍♂️那是自然,有什么功能是我大前端做不了的,必须得让你们大开眼界。


二、为什么导出要前端做?


前端导出的场景:



  1. 轻量级数据:如果要导出的表格数据相对较小,可以直接在前端生成和导出,避免服务器端的处理和通信开销。

  2. 数据已存在于前端:如果表格数据已经以 JSON 或其他形式存在于前端,可以直接利用前端技术将其导出为 Excel、CSV 或其他格式。

  3. 实时生成/计算:如果导出的表格需要根据用户输入或动态生成,可以使用前端技术基于用户操作实时生成表格,并提供导出功能。

  4. 快速响应:前端导出表格可以提供更快的响应速度,避免等待服务器端的处理和下载时间。


后端导出的场景:



  1. 大量数据:如果要导出的表格数据量很大,超过了前端处理能力或网络传输限制,那么在服务器端进行导出会更高效。

  2. 安全性和数据保护:敏感数据不适合在前端暴露,因此在服务器端进行导出可以更好地控制和保护数据的安全。

  3. 复杂的业务逻辑:如果导出涉及复杂的业务逻辑、数据处理或数据查询,使用服务器端的计算能力和数据库访问更合适。

  4. 跨平台支持:如果需要支持多个前端平台(如 Web、移动应用等),将导出功能放在服务器端可以提供一致的导出体验。


三、讲解一下在前端做的导出


xlsx、xlsx-style


如果是只做表格导出:http://www.npmjs.com/package/xls…


如果导出要包含样式:http://www.npmjs.com/package/xls…


import XLSX from "xlsx";

exportData() {
let tableName = '表格'

if(!getVal(this.dataList, 'length')){
this.$message.info("暂时数据");
return
}


// 处理头部
let headers = {
"B2": "字段-B2",
"E2": "字段-E2",
}
const props = [ "B2", "E2" ]
let tmp_dataListFilter = [
{
"B2": "字段-B2",
"E2": "字段-E2",
},
{
"E2": "2",
"B2": "2",
}
]

tmp_dataListFilter.unshift(headers) // 将表头放入数据源前面
let wb = XLSX.utils.book_new();
let contentWs = XLSX.utils.json_to_sheet(tmp_dataListFilter, {
skipHeader: true, // 是否忽略表头,默认为false
origin: "A2" // 设置插入位置
});
// /单独设置某个单元格内容
contentWs["A1"]={
t:"s",
v:tableName,
};
// /设置单元格合并!merges为一个对象数组,每个对象设定了单元格合并的规侧,
// /{s:{r:0,c:},e:{r:0,c:2}为一个规则,s:起始位置,e:结束位置,r:行,c:列
contentWs["!merges"]=[{ s:{r:0,c:0 },e:{r:0,c:props.length - 1 }}]

// 设置单元格的宽度
contentWs["!cols"] = []
props.forEach(p => contentWs["!cols"].push({wch: 35}))
XLSX.utils.book_append_sheet(wb,contentWs,tableName) // 表格内的下面的tab
XLSX.writeFile(wb,tableName + ".xlsx"); // 导出文件名字
},

package.json


"xlsx": "^0.15.5",
"xlsx-style": "^0.8.13"

大概效果如下:


3.png


感觉前端导出也很容易。


哦哦,那你别高兴太早。


四、需求升级:单元格要居中和加粗。


xlsx


尝试使用xlsx-style设样式。


官方文档:github.com/rockboom/Sh…


文档说给单元格设置s为对象


4.png


let contentWs = XLSX.utils.json_to_sheet(tmp_dataListFilter, {
skipHeader: true, // 是否忽略表头,默认为false
origin: "A2", // 设置插入位置
});
// /单独设置某个单元格内容
contentWs["A1"] = {
t: "s",
v: tableName,
s:{ // 这个是关键s
font: { bold: true },
alignment: { horizontal: 'center' }
}
};

发现设置无效。


有人说要改xlsx、xlsx-style源码:


大概的意思是:修改xlsx.extendscript.js、xlsx.full.min.js更改文件变量。


发现仍然无效。


使用binary方式保存



  1. 首先保存的时候 type要改成 binary方式

  2. 保存的时候需要使用 xlsx-style模块


var writingOpt = { 
bookType: 'xlsx',
bookSST: true,
type: 'binary' // <--- 1.改这里
}


/*
2. type:'array'改为'binary' 后因为下面代码会报错, 打不开excel
new Blob([wbout], { type: 'application/octet-stream' }
要文本转换成数组缓存后再生成二进制对象
*/


// 添加String To 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;
}

let blob = new Blob([s2ab(wbout)], { type: 'application/octet-stream' })

FileSaver.saveAs(blob, exportName)

可以下载了。但依然样式没起作用。


使用 xlsx-style 模块生成文件


首先安装模块


npm install xlsx-style 

在项目里安装报好多错误直接强制安装,不检查依赖。


npm install xlsx-style -force

安装完成后 找不到cptable模块会报错

报错内容如下:


./node_modules/xlsx-style/dist/cpexcel.js Module not found: Error: Can't resolve './cptable' in 

这个问题在vue.config.js里配置一下就可以解决。

其他框架自己找找方法,反正只要不让他报错能启动就行。


module.exports = {
// ...其他配置省略
configureWebpack: {
// ...其他配置省略
externals:{
'./cptable':'var cptable'
},
},

安装完xlsx-style后改代码


import XLSX2 from "xlsx-style";    // 1. 引入模块

// 2. 使用`xlsx-style` 生成。 XLSX.write => XLSX2.write
var wbout = XLSX2.write(wb, writingOpt)

仍然无效。


总结xlsx


大概的意思是说:默认不支持改变样式,想要支持改变样式,需要使用它的收费版本。


本着勤俭节约的原则,很多人使用了另一个第三方库:xlsx-style[4] ,但是使用起来极其复杂,还需要改 node_modules 源码,这个库最后更新时间也定格在了 6年前。还有一些其他的第三方样式拓展库,质量参差不齐。


使用成本和后期的维护成本很高,不得不放弃。


ExcelJS


ExcelJS终于可以了


ExcelJS[5] 周下载量 450k,github star 9k,并且拥有中文文档,对国内开发者很友好。虽然文档是以README 的形式,可读性不太好,但重在内容,常用的功能基本都有覆盖。


最近更新时间是6个月内,试用了一下,集成很简单,再加之文档丰富,就选它了。


npm install exceljs
npm install file-saver // 下载到本地还需要另一个库:file-saver

基本操作


//导入ExcelJS
import ExcelJS from "exceljs";

//下载文件
download_file(buffer, fileName) {
console.log("导出");
let fileURL = window.URL.createObjectURL(new Blob([buffer]));
let fileLink = document.createElement("a");
fileLink.href = fileURL;
fileLink.setAttribute("download", fileName);
document.body.appendChild(fileLink);
fileLink.click();
}

导出xlsx表格的代码


//下面是导出的函数
async export() {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("Sheet1");
//这里是数据列表
const data = [
{ id: 1, name: "艾伦", age: 20, sex: "男", achievement: 90 },
{ id: 2, name: "柏然", age: 25, sex: "男", achievement: 86 },
];
// 设置列,这里的width就是列宽
worksheet.columns = [
{ header: "序号", key: "id", width: 10},
{ header: "姓名", key: "name", width: 10 },
];

// 批量插入数据
data.forEach(item => worksheet.addRow(item));

// 写入文件
const buffer = await workbook.xlsx.writeBuffer();
//下载文件
this.download_file(buffer, "填报汇总.xlsx");
}

设置行高和列宽


列宽上面已经有了,这里说明一下行高怎么设置

worksheet.getRow(2).height = 30;


合并单元格


worksheet.mergeCells("B1:C1");


自定义表格样式


//设置样式表格样式,font里面设置字体大小,颜色(这里是argb要注意),加粗
//alignment 设置单元格的水平和垂直居中
const B1 = worksheet.getCell('B1')
B1.font = { size: 20, color:{ argb: 'FF8B008B' }, bold: true }
B1.alignment = { horizontal: 'center', vertical: 'middle' }

ExcelJS实战


import ExcelJS from "exceljs";

//下载文件
download_file(buffer, fileName) {
console.log("导出");
let fileURL = window.URL.createObjectURL(new Blob([buffer]));
let fileLink = document.createElement("a");
fileLink.href = fileURL;
fileLink.setAttribute("download", fileName);
document.body.appendChild(fileLink);
fileLink.click();
},
async exportClick() {
const loading = this.$loading({
lock: true,
text: "数据导出中,请耐心等待!",
spinner: "el-icon-loading",
background: "rgba(0, 0, 0, 0.7)",
});

this.tableData = [
{ a: 1, b:2 }
]
const enterpriseVisitsColumns = [
{
prop: "a",
label: "银行",
},
{
prop: "b",
label: "企业数",
}
]

// 表格数据:this.tableData
if (!(this.tableData && this.tableData.length)) {
this.$message.info("暂无数据");
loading.close();
return;
}

let tableName = this.tableName; // 表格名
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet(tableName);
const props = enterpriseVisitsColumns();
//这里是数据列表
const data = this.tableData;
// 设置列,这里的width就是列宽
let arr = [];
props.forEach((p) => {
arr.push({
header: p.label,
key: p.prop,
width: 25,
});
});
worksheet.columns = arr;

// 插入一行到指定位置,现在我往表格最前面加一行,值为表名
const rowIndex = 1; // 要插入的行位置
const newRow = worksheet.insertRow(rowIndex);
// 设置新行的单元格值
newRow.getCell(1).value = tableName; // 值为表名

// 批量插入数据,上面插一条,这里就是从第二行开始加
data.forEach((item) => worksheet.addRow(item));

//设置样式表格样式,font里面设置字体大小,颜色(这里是argb要注意),加粗
//alignment 设置单元格的水平和垂直居中
// const B1 = worksheet.getCell("B1");
// B1.font = { size: 20, color: { argb: "FF8B008B" }, bold: true };
// B1.alignment = { horizontal: "center", vertical: "middle" };

// 合并单元格,就是把A1开始到J1的单元格合并
worksheet.mergeCells("A1:J1");

// 批量设置所有表格数据的样式
worksheet.eachRow((row, rowNumber) => {
let size = rowNumber == 1 ? 16 : rowNumber == 2 ? 12 : "";
//设置表头样式
row.eachCell((cell) => {
cell.font = {
size,
// color:{ argb: 'FF8B008B' },
bold: true,
};
cell.alignment = { horizontal: "center", vertical: "middle" };
});

//设置所有行高
row.height = 30;
});

// 写入文件
const buffer = await workbook.xlsx.writeBuffer();
//下载文件
this.download_file(buffer, tableName + ".xlsx");

loading.close();
},

后记


导出功能并不是说都是前端或者后端实现,要具体情况,具体分析,我相信哪方都可以做,但谁适合做,这个才是我们需要去思考的。


就如同我们项目中,该例子后面也是前端实现的,大数据分页当然还是得后端同学来实现较好。


如果有其他更好的方法也欢迎评论区见,这里提供的只是诸多方法之一。


最后,祝君能拿下满意的offer。




作者:Dignity_呱
来源:juejin.cn/post/7339814359886348328
收起阅读 »

小程序使用有赞 UI 库

web
引入有赞 UI 库 1、初始化 npm 在小程序 package.json 所在的目录(代码根目录)中执行下面命令,进行初始化: npm init ps. 这里一路按 enter 键就可以了,命令窗口可以在目录下通过 shift + 鼠标右键选择 Powe...
继续阅读 »

引入有赞 UI 库


1、初始化 npm

在小程序 package.json 所在的目录(代码根目录)中执行下面命令,进行初始化:


npm init 

ps. 这里一路按 enter 键就可以了,命令窗口可以在目录下通过 shift + 鼠标右键选择 PowerShell 打开 。


2、安装 Vant 包

在上面的基础上,输入下面命令,安装有赞 UI 库:


npm i vant-weapp -S --production

如果这里报 rollbackFailedOptional 错误,可以试试修改 npm 的资源镜像链接,输入下面命令:


npm config set registry http://registry.npm.taobao.org

然后再执行上面安装命令,应该就可以了。


在这里插入图片描述


3、使用 npm 模块

点击微信开发者工具右上角详情,选择本地设置,勾选上下面的 “使用 npm 模块”


在这里插入图片描述


4、构建 npm

点击开发者工具中的菜单栏:工具 --> 构建 npm


在这里插入图片描述


5、修改 app.json

将 app.json 中的 "style": "v2" 去除


在这里插入图片描述


6、修改 project.config.json

在根目录下的 project.config.json 文件中,通过 ctrl + f 搜索 packNpmManually ,修改配置,使开发者工具可以正确索引到 npm 依赖的位置。


在这里插入图片描述


改成如下图


在这里插入图片描述
代码如下:


        "packNpmManually": true,
"packNpmRelationList": [
{
"packageJsonPath": "./package.json",
"miniprogramNpmDistDir": "./"
}
],



到这应该就完成安装了,下面看看使用。


使用有赞 UI 库


1、引入控件

在 app.json (或 Page 的 json)中引入控件


"usingComponents": {
"van-button": "@vant/weapp/button/index"
}

2、使用控件

引入组件后,可以在 wxml 中直接使用组件


<van-button type="primary">按钮</van-button>

示例


这里拿一个 Dialog 弹出框作为示例,因为官方文档有问题,在 Page 中引入错了,真的是把我坑到了。


1、引入 Dialog 控件

app.jsonindex.json中引入组件


"usingComponents": {
"van-dialog": "@vant/weapp/dialog/index"
}

2、在 WXML 中设置 Dialog

这里有两种用法,一种是把 Dialog 当布局组件使用,一种是像 wx.showModel 一样弹出对话框,无论哪种都要在 WXML 中写 van-dialog。


这里以后一种用法为例,在 WXML 中随便找个地方,填入下面代码:


<van-dialog id="van-dialog" />

3、在 Page 中使用

先引入组件,就是这里把我坑了,主要就是没有 dist 这个目录了,有赞也不提示。


//import Dialog from 'path/to/@vant/weapp/dist/dialog/dialog';
import Dialog from '../../../miniprogram_npm/@vant/weapp/dialog/dialog';

这里用的相对路径,后面路径是对的,需要直接改下!


在需要的时候像下面一样使用就可以,不过我是觉得还不如微信自带的好看哦!


Dialog.confirm({
title: '标题',
message: '弹窗内容',
})
.then(() => {
// on confirm
})
.catch(() => {
// on cancel
});

4、在 Dialog 中有原生控件

这里提一下,如果 Dialog 中有原生控件,消失的时候原生控件回后消失,很奇怪。例如对话框里面放了一个 canvas 来显示二维码,关闭对话框时,对话框消失了,二维码延迟一会才消失,这时候可以通过变量,先隐藏二维码,再隐藏对话框,有这个思路,就能解决了。


结语


如果不想自己设计各种控件的话,用有赞的 UI 库还是很方便的,但是如果给了设计图,要改这些控件还是有点麻烦。


作者:方大可
来源:juejin.cn/post/7222897518500708407
收起阅读 »

新项目跑不起来,人和项目总得有一个能跑

web
前言 有人说面试造火箭,进去拧螺丝;其实个人觉得问的问题是项目中涉及的点 || 热门的技术栈都是很好的面试体验,不要是旁门左道冷门的知识,实际上并不会用到的。 进入新公司或者接手新项目,都会遇到如何将项目跑起,跑不起的话,可能就得人跑了。 每个人的花期不...
继续阅读 »

前言


  • 有人说面试造火箭,进去拧螺丝;其实个人觉得问的问题是项目中涉及的点 || 热门的技术栈都是很好的面试体验,不要是旁门左道冷门的知识,实际上并不会用到的。

  • 进入新公司或者接手新项目,都会遇到如何将项目跑起,跑不起的话,可能就得人跑了。





每个人的花期不同。


就像万特特在《这世界很好,你也不差》中讲的,每个人花期不同,不必焦虑有人比你提前拥有。



1.jpg


一、问题剖析


那是一个风和日丽的早上,我想要去看漫天霞光。


要和心上人手挽手走在街上,


清晨的花香,傍晚的夕阳。


正当我沉迷于甜蜜的幻想中,前同事发来:我遇到一个问题,帮我看看呗!


真是的,慌慌张张的,说吧~


问题是这样子的:


现在有一个新项目,B项目从A项目复制过去,在原有的功能上扩展。


我又多嘴的问了一句,为啥要复制一份过去啊!


他回道:因为现在要做国际化的需求,之前的项目没有考虑到,改起来很麻烦,打算先拷贝一份出来,通过java程序执行一下,先把大部分的先转化一下,这样子不会影响原本项目的开发。


我又对嘴了一下,开新分支不可以嘛!


他说:因为是给两个地区用的,可能在A页面,两个区的需求不同,改起来也麻烦,需求不同,就没必要硬写在一起了。


懂了!


二、gitlab仓库中有两种方式拉取代码仓库:ssh和https


2.png


ssh


首先,确保你已经在 GitLab 上配置了 SSH 密钥。如果没有,你需要生成 SSH 密钥并将公钥添加到 GitLab 的个人设置中。


https


使用 HTTPS 协议拉取代码需要输入你的 GitLab 用户名和密码,或者是访问令牌(Access Token)。


两者



  • 使用 SSH 协议可以避免频繁输入用户名和密码,但需要提前配置好 SSH 密钥。

  • 使用 HTTPS 协议在拉取代码时需要提供用户名和密码或者访问令牌,相对来说更加方便快捷。


一般来说,都是用https拉取仓库,但我怎么拉都拉不下来。


提示:


SSL certificate problem: self signed certificate

翻译:SSL证书问题:自签名证书

于是,我尝试运行ssh的方式拉取。


我们首先需要先生成秘钥


ssh-keygen -t rsa -C “your_email@youremail.com”  
// 命令中的email,就是gitlab中的账号,需要保持一致

直接三个Enter就行,然后会提示输入密码(可输可不输)


3.png


在~/.ssh/下会生成两个文件,id_rsa和id_rsa.pub


4.png


id_rsa是私钥

id_rsa.pub是公钥

在我们c盘的用户里面有个.ssh文件夹


C:\Users\Lenovo\.ssh

gitlab添加秘钥


5.png


这样子就配置好了,可以通过ssh方式拉取了。


git clone 项目远程仓库ssh地址

项目已拉取。


此时要推送的时候,一直让我输入密码,但我输入完之后,仍然要求输入。


我使用的是tortoisegit


连接gitlab,总是弹出git@xxx.com’s password 对话框


然后打开TortoiseGit设置,如下图进入相应页面,选择相对路径的ssh.exe文件


6.png


查找git在哪里,只需要输入 where git


7.png


到这里项目已经拉取,也能推送了。


接下来运行项目。


三、运行的时候,先安装依赖,发现npm安装失败。


发现npm i


8.png


于是,我尝试cnpm i安装,安装是可以安装,但npm run serve的时候还是报错了。


四、于是,我找同事要来了他本地的依赖,想着在我本地看看能不能跑起来。


但因我和同事的环境(npm、node)环境不一样。


于是,我和他保持一致的版本node:12


但又因为还是有些不同,npm run serve的时候,报了:


error  in ./src/styles/element-variables.scss  
Syntax Error: Error: Missing binary. See message above.

看起来是,sass的问题,于是想着重新安装一下


一般来说sass、node-sass问题经常会出现。


npm install --save-dev sass-loader node-sass

仍然不行,那切换一下node的版本,从node10-node14都轮流切换尝试都不行。


我使用的是nvm管理node版本。


9.png


但点进去看只有10.14.1版本有node_modules文件夹依赖


10.png


其他的版本貌似没有,本来想着和同事保持12版本的node,然后把他的依赖复制给我,但我本地的node12没有node_modules文件夹


关于上面的scss文件引起的:Syntax Error: Error: Missing binary. See message above.


下载fibers


运行的时候报错:而且会弹出框说:中止/忽略(其实是fibers缺少二进制文件执行)


Try running this to fix the issue: D:\Program Files\nodejs\node.exe E:\vue-project\node_modules\fibers/build
Error: Cannot find module 'E:\vue-project\node_modules\fibers\bin\win32-x64-83\fibers'
Require stack:

我们发现了fibers引起的错误


有回答说:


项目node_moudules/fibers/bin文件夹中没有win32-x64-83模块,缺少win32-x64-83文件夹下的fibers.node。


11.png


1.在github下载对应系统版本的node文件


win32-x64-83_binding.node 文件下载地址:github-releases


2.下载后的win32-x64-83_binding.node文件改名为fibers.node。


3.保存fibers.node在项目node_moudules/fibers/bin新建的win32-x64-83文件夹中。


4.然后重新执行run serve就可以


rebuild node-sass

执行


npm rebuild node-sass

如果提示 stack Error: EACCES: permission denied, mkdir 错误,则执行命令:


npm rebuild node-sass --unsafe-perm

失败告终!



但我简单粗暴直接把fibers文件夹给删除重新跑就可以了。(√)



五、后面想了一下,项目的背景,是从A项目复制过来的,那我把A项目的依赖复制过来运行下看看。


复制过来后,可以运行了,但因为B新项目装了vue-i18n国际化依赖,于是我安装一下,运行,终于可以了。


但项目打开页面的时候,还是报错了。


12.webp


看起来,vue-router版本和i18n版本冲突了


我的解决办法是把i18n的版本 改为了 8.26.7 ,再启动项目就可以了


npm install vue-i18n@8.26.7 -S

虽然B项目的package.json写着是版本9的,但这不影响我运行。


只不过在页面用到这个依赖的时候可能会有一些不同。


完成搞定。


后记


那晚,我们一起找了家烧烤店,他说:今日之事,都在酒里了,一口闷。


道不清,理还乱,别是一般滋味在心头~


刚开始接手一个项目的时候,依赖啊,版本啊,什么的都很头疼,但一步一步来,见招拆招,无非就是node版本、npm版本、cnpm他们的故事。


如果有其他更好的方法也欢迎评论区见,这里提供的只是诸多方法之一。



作者:Dignity_呱
来源:juejin.cn/post/7339376488028307456
收起阅读 »

前端更新部署后通知用户刷新

web
前言 周五晚上组里说前端有bug,正在吃宵夜的我眉头一紧,立即打开了钉钉(手贱...),看了一下这不是前几天刚解决的吗,果然,使用刷新大法就解决,原因不过是用户一直停留在页面上,新的版本发布后,没有刷新拿不到新的资源。 现在大部分的前端系统都是SPA,用户在使...
继续阅读 »

前言


周五晚上组里说前端有bug,正在吃宵夜的我眉头一紧,立即打开了钉钉(手贱...),看了一下这不是前几天刚解决的吗,果然,使用刷新大法就解决,原因不过是用户一直停留在页面上,新的版本发布后,没有刷新拿不到新的资源。


现在大部分的前端系统都是SPA,用户在使用中对系统更新无感知,切换菜单等并不能获取最新资源,如果前端是覆盖性部署,切换菜单请求旧资源,这个旧资源已经被覆盖(hash打包的文件),还会出现一直无响应的情况。


那么,当前端部署更新后,提示一直停留在系统中的用户刷新系统很有必要。


解决方案



  1. 在public文件夹下加入manifest.json文件,记录版本信息

  2. 前端打包的时候向manifest.json写入当前时间戳信息

  3. 在入口JS引入检查更新的逻辑,有更新则提示更新

    • 路由守卫router.beforeResolve(Vue-Router为例),检查更新,对比manifest.json文件的响应头Etag判断是否有更新

    • 通过Worker轮询,检查更新,对比manifest.json文件的响应头Etag判断是否有更新。当然你如果不在乎这点点开销,可不使用Worker另开一个线程




Public下的加入manifest.json文件


{
"timestamp":1706518420707,
"msg":"更新内容如下:\n--1.添加系统更新提示机制"
}

这里如果是不向用户提示更新内容,可不填,前段开发者也无需维护manifest.json的msg内容,这里主要考虑到如果用户在填长表单的时候,填了一大半,你这时候给用户弹个更新提示,用户无法判断是否影响当前表单填写提交,如果将更新信息展示出来,用户感知更新内容,可判断是否需要立即刷新,还是提交完表单再刷新。


webpack向manifest.json写入当前时间戳信息


	// 版本号文件
const filePath = path.resolve(`./public`, 'manifest.json')
// 读取文件内容
readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error('读取文件时出错:', err)
return
}
// 将文件内容转换JSON
const dataObj = JSON.parse(data)
dataObj.timestamp = new Date().getTime()
// 将修改后的内容写回文件
writeFile(filePath, JSON.stringify(dataObj), 'utf8', err => {
if (err) {
console.error('写入文件时出错:', err)
return
}
})
})

如果你无需维护更新内容的话,可直接写入timestamp


// 生成版本号文件
const filePath = path.resolve(`./public`, 'manifest.json')
writeFileSync(filePath, `${JSON.stringify({ timestamp: new Date().getTime() })}`)


检查更新的逻辑


入口文件main.js处引入


我这里检查更新的文件是放在utils/checkUpdate


// 检查版本更新
import '@/utils/checkUpdate'

checkUpdate文件内容如下


import router from '@/router'
import { Modal } from 'ant-design-vue'
if (process.env.NODE_ENV === 'production') {
let lastEtag = ''
let hasUpdate = false
let worker = null

async function checkUpdate() {
try {
// 检测前端资源是否有更新
let response = await fetch(`/manifest.json?v=${Date.now()}`, {
method: 'head'
})
// 获取最新的etag
let etag = response.headers.get('etag')
hasUpdate = lastEtag && etag !== lastEtag
lastEtag = etag
} catch (e) {
return Promise.reject(e)
}
}

async function confirmReload(msg = '', lastEtag) {
worker &&
worker.postMessage({
type: 'pause'
})
try {
Modal.confirm({
title: '温馨提示',
content: '系统后台有更新,请点击“立即刷新”刷新页面\n' + msg,
okText: '立即刷新',
cancelText: '5分钟后提示我',
onOk() {
worker.postMessage({
type: 'destroy'
})
location.reload()
},
onCancel() {
worker &&
worker.postMessage({
type: 'recheck',
lastEtag: lastEtag
})
}
})
} catch (e) {}
}

// 路由拦截
router.beforeResolve(async (to, from, next) => {
next()
try {
await checkUpdate()
if (hasUpdate) {
worker.postMessage({
type: 'destroy'
})
location.reload()
}
} catch (e) {}
})

// 利用worker轮询
worker = new Worker(
/* webpackChunkName: "checkUpdate.worker" */ new URL('../worker/checkUpdate.worker.js', import.meta.url)
)

worker.postMessage({
type: 'check'
})
worker.onmessage = ({ data }) => {
if (data.type === 'hasUpdate') {
hasUpdate = true
confirmReload(data.msg, data.lastEtag)
}
}
}


这里因为缺换路由本来就要刷新页面,用户可无需感知系统更新信息,直接通过请求头的Etag即可,这里的Fetch方法就用head获取相应头就好了。


checkUpdate.worker.js文件如下


let lastEtag
let hasUpdate = false
let intervalId = ''
async function checkUpdate() {
try {
// 检测前端资源是否有更新
let response = await fetch(`/manifest.json?v=${Date.now()}`, {
method: 'get'
})
// 获取最新的etag和data
let etag = response.headers.get('etag')
let data = await response.json()
hasUpdate = lastEtag !== undefined && etag !== lastEtag
if (hasUpdate) {
postMessage({
type: 'hasUpdate',
msg: data.msg,
lastEtag: lastEtag,
etag: etag
})
}
lastEtag = etag
} catch (e) {
return Promise.reject(e)
}
}

// 监听主线程发送过来的数据
addEventListener('message', ({ data }) => {
if (data.type === 'check') {
// 每5分钟执行一次
// 立即执行一次,获取最新的etag,避免在setInterval等待中系统更新,第一次获取的etag是新的,但是lastEtag还是undefined,不满足条件,错失刷新时机
checkUpdate()
intervalId = setInterval(checkUpdate,5 * 60 * 1000)
}
if (data.type === 'recheck') {
// 每5分钟执行一次
hasUpdate = false
lastEtag = data.lastEtag
intervalId = setInterval(checkUpdate, 5 * 60 * 1000)
}
if (data.type === 'pause') {
clearInterval(intervalId)
}
if (data.type === 'destroy') {
clearInterval(intervalId)
close()
}
})


如果不使用worker直接讲轮询逻辑放在checkUpdate即可


Worker引入


从 webpack 5 开始,你可以使用 Web Workers 代替 worker-loader


new Worker(new URL('./worker.js', import.meta.url));

以下版本的就只能用worker-loader


也可以逻辑写成字符串,然后通过ToURL给new Worker,如下:


function createWorker(f) {
const blob = new Blob(['(' + f.toString() +')()'], {type: "application/javascript"});
const blobUrl = window.URL.createObjectURL(blob);
const worker = new Worker(blobUrl);
return worker;
}

createWorker(function () {
self.addEventListener('message', function (event) {
// 消费信息
self.postMessage('send message')
}, false);
})


worker数据通信



// 主线程
var uInt8Array = new Uint8Array(new ArrayBuffer(10));
for (var i = 0; i < uInt8Array.length; ++i) {
 uInt8Array[i] = i * 2; // [0, 2, 4, 6, 8,...]
}
worker.postMessage(uInt8Array);
// Worker 线程
self.onmessage = function (e) {
 var uInt8Array = e.data;
 postMessage('Inside worker.js: uInt8Array.toString() = ' + uInt8Array.toString());
 postMessage('Inside worker.js: uInt8Array.byteLength = ' + uInt8Array.byteLength);
};


但是,拷贝方式发送二进制数据,会造成性能问题。比如,主线程向 Worker 发送一个 500MB 文件,默认情况下浏览器会生成一个原文件的拷贝。为了解决这个问题,JavaScript 允许主线程把二进制数据直接转移给子线程,但是一旦转移,主线程就无法再使用这些二进制数据了,这是为了防止出现多个线程同时修改数据的麻烦局面。这种转移数据的方法,叫做Transferable Objects。这使得主线程可以快速把数据交给 Worker,对于影像处理、声音处理、3D 运算等就非常方便了,不会产生性能负担。


如果要直接转移数据的控制权,就要使用下面的写法。


// Transferable Objects 格式
worker.postMessage(arrayBuffer, [arrayBuffer]);

// 例子
var ab = new ArrayBuffer(1);
worker.postMessage(ab, [ab]);


然而,并不是所有的对象都可以被转移。只有那些被设计为可转移的对象(用[ Transferable ] IDL 扩展属性修饰),比如ArrayBuffer、MessagePort,ImageBitmap,OffscreenCanvas,才能通过这种方式来传递。转移操作是不可逆的,一旦对象被转移,原始上下文中的引用将不再有效。转移对象可以显著减少复制数据所需的时间和内存。


作者:Zayn
来源:juejin.cn/post/7329280514628534313
收起阅读 »

用 Puppeteer 把繁琐工作给自动化了,太爽啦!

web
最近在鱼皮的编程导航星球做嘉宾,需要输出一些内容。 而很多内容我之前写过,所以想复制过来。 这时候我就遇到了一个令人头疼的问题: 知识星球的编辑器也太难用了! 比如我在掘金编辑器里这样的 markdown 内容: 复制到星球编辑器是这样的: markdow...
继续阅读 »

最近在鱼皮的编程导航星球做嘉宾,需要输出一些内容。


而很多内容我之前写过,所以想复制过来。


这时候我就遇到了一个令人头疼的问题:


知识星球的编辑器也太难用了!


比如我在掘金编辑器里这样的 markdown 内容:



复制到星球编辑器是这样的:



markdown 语法是识别了,但图片没有自动上传。


如果用富文本格式,格式又不对:



而且 gif 没有识别出来,还是需要手动传一次。


这意味着如果文中有几十张图片,那我需要单独把这几十张图片保存到本地,然后光标定位到对应位置,点击上传图片,把图片插进去。


也就是这样:




把每个图片下载下来,保存为不同的后缀名(png、jpg、gif),然后再定位到对应位置,删除原来的链接,插入图片。


然后这样重复十几次,每篇文章都这样来一遍。


是不是想想都觉得很痛苦。。。


那有什么好的办法解决这个问题呢?


于是我想到了 puppeteer。



它是一个网页自动化的 Node.js 工具,基本所有你手动在浏览器里做的事情,都可以用它来自动化完成。


比如点击、移动光标、输入等等。


那前面那个繁琐的问题自然也可以用 puppeteer 自动化来做,解放我们的生产力。


我们来分析下整个流程:


首先打开星球编辑器页面,如果没登录会跳到登录页:



这一步要扫码,没法自动化。


登录之后进入编辑器页面,输入内容:



这时候我们要把其中的图片链接分析出来,自动下载到本地的目录中。


然后记录每个链接所在的行数,把光标移动到对应的行数,点击上传按钮:



上传这一步也要手动来做,选择之前自动下载的图片就行。


然后光标会自动移动到下一个位置,再点击上传按钮,直到所有图片上传完。


文件浏览器这一步是操作系统的功能,没法自动化。


我们把下载图片、在对应位置插入图片的过程给自动化了。只有登录、选择文件这两步还要还要手动做。


但这样已经方便太多了。


流程理清了,我们就来写下代码吧:


import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({
headless: false,
defaultViewport: {
width: 0,
height: 0
}
});

const page = await browser.newPage();

await page.goto('http://www.baidu.com');

await page.focus('#kw');

await page.keyboard.type('hello', {
delay: 200
});

await page.click('#su');

引入 puppeteer,跑一个 chrome 浏览器,创建一个页面,导航到 baidu,输入 hello,点击搜索。


puppeteer 的 api 还是很容易懂的。


其中 defaultViewport 设置宽高为 0 是让网页充满整个窗口。


然后我们把它跑起来,因为用到了 es module、顶层 await,需要在 package.json 声明 type 为 module:



声明 type 为 module 就是所有的模块都是 es module 的意思。


然后把它跑起来:



可以看到脚本正确执行了。


然后我们让它打开星球编辑器的网址:


import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({
headless: false,
defaultViewport: {
width: 0,
height: 0
}
});

const page = await browser.newPage();

await page.goto('https://wx.zsxq.com/dweb2/article?groupId=51122858222824');


确实跳到登录了:



扫码登录之后进入星球页面,就可以写文章了。



但是,下次跑脚本还是要再登录。


我们不是登录过了么?为啥还需要登录?


因为 chrome 默认的数据保存在一个目录中,叫 userDataDir,而这个目录默认是临时生成的,所以每次保存数据的目录都不一样。


这就导致了每次都需要登陆。


所以我们指定一个固定的 userDataDir 就好了。


import puppeteer from 'puppeteer';
import os from 'os';
import path from 'path';

const browser = await puppeteer.launch({
headless: false,
defaultViewport: {
width: 0,
height: 0
},
userDataDir: path.join(os.homedir(), '.puppeteer-data')
});

const page = await browser.newPage();

await page.goto('https://wx.zsxq.com/dweb2/article?groupId=51122858222824');

通过 os.homedir() 拿到 home 目录,再下面新建一个 .puppeteer-data 的目录来保存用户数据。


这样登录一次之后,下次就不再需要登录了:



这时候可以看到 userDataDir 下是保存了用户数据的:



接下来就是编辑部分的自动化了。


我们要做的事情有这么两件:



  • 提取文本中的所有链接,自动下载。

  • 光标定位到每个链接的位置,自动点击上传按钮。


执行这俩自动化脚本的过程最好让用户控制,比如输入 download-img 就自动下载图片,输入 upload-next 光标就自动定位到下个位置,点击上传。


所以我们引入 readline 这个内置模块接收用户输入。


import readline from 'readline';

const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});

rl.on('line', async (command) => {
switch(command) {
case 'upload-next':
await uploadNext();
break;
case 'download-img':
await downloadImg();
break;
default:
break;
}
});

async function uploadNext() {
console.log('------');
}
async function downloadImg() {
console.log('+++++++');
}

调用 creatInterface api,指定 input、output 为标准输入输出。


然后当收到一行的输入的时候,根据内容决定执行什么方法:



我们先实现 download-img 的部分:



可以看到,编辑器部分的内容就是 .ql-editor 下的一个个 p 标签。


那我们只要取出所有的 p 标签,选出 ![]() 格式的内容就好了。


这需要一个正则,我们先把这个正则写出来:


整体格式是这样的:


![]()

但[] 和 () 需要转义:


!\[\]\(\)

中间部分是除了 [] 和 () 的任意字符出现任意次,也就是这样:


[^\[\]\(\)]*

并且 () 里的内容需要提取,需要用小括号包裹。


完整正则就是这样的:


!\[[^\[\]\(\)]*\]\(([^\[\]\(\)]*)\)

我们测试下:



可以看到 () 中的内容被正确提取出来了。


然后在网页里取出所有的 p 标签,根据内容过滤,把链接和行数记录下来:


const links = await page.evaluate(() => {
let links = [];
const lines = document.querySelectorAll('.ql-editor p');
for(let i = 0; i < lines.length; i ++) {
const matchRes =lines[i].textContent.trim().match(/!\[[^\[\]\(\)]*\]\(([^\[\]\(\)]*)\)/)
if (matchRes) {
links.push({
index: i,
link: matchRes && matchRes[1],
});
}
}
return links;
})

用 page.evaluate 方法在网页里远程执行一段 js,拿到它的返回结果。


这里拿到的就是所有的图片链接:



其实严格来说这不叫行数,而是第几个 p 标签,想要定位到对应的 p 标签,只要点击它就好了。



我们记录的下标是从 0 开始,而 nth-child 从 1 开始,所以要加 1。


可以看到,光标定位到了正确的位置:



不过先不着急定位光标,我们先把图片下载给搞定。


下载部分的代码如下:


import https from 'https';
import fs from 'fs';

function downloadFile(url, destinationPath, progressCallback) {
let resolve , reject;
const promise = new Promise((x, y) => { resolve = x; reject = y; });

const request = https.get(url, response => {
if (response.statusCode !== 200) {
const error = new Error(`Download failed: server returned code ${response.statusCode}. URL: ${url}`);
response.resume();

reject(error);
return;
}
const file = fs.createWriteStream(destinationPath);

file.on('finish', () => resolve ());
file.on('error', error => reject(error));

response.pipe(file);

const totalBytes = parseInt(response.headers['content-length'], 10);
if (progressCallback)
response.on('data', onData.bind(null, totalBytes));
});
request.on('error', error => reject(error));
return promise;

function onData(totalBytes, chunk) {
progressCallback(totalBytes, chunk.length);
}
}

用 https 模块的 get 方法请求 url,然后把 response 用流的方式写入文件,并且通过 content-length 的响应头拿到总长度。


这样,在每次 data 方法里就能根据总长度,当前 chunk 的长度,算出下载进度。


我们测试下:


const url = 'https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/66399947ea6b45289c8d77b6d4568cc5~tplv-k3u1fbpfcp-watermark.image'

let currentTotal = 0;
downloadFile(url, './1.gif', (totalBytes, chunkBytes) => {
const percent = (currentTotal/totalBytes * 100).toFixed(1);
console.log('总长度:' + totalBytes + 'B', '当前已下载:' + currentTotal + 'B','进度' + percent + '%');
currentTotal += chunkBytes;
})


可以看到,图片下载成功了!


但是,我们现在是知道这是个 gif 才给它加上 .gif 后缀,要是任意一个链接,怎么知道它的格式呢?


这个可以用 image-size 这个包:


import sizeOf from 'image-size';
import fs from 'fs';

const buffer = fs.readFileSync('./1.image');
const dimensions = sizeOf(buffer);

console.log(dimensions);

它能拿到图片的类型和宽高信息:



这样我们在下载完改下名就可以了。


const url = 'https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/66399947ea6b45289c8d77b6d4568cc5~tplv-k3u1fbpfcp-watermark.image'

let currentTotal = 0;
let filePath = './1.image';
downloadFile(url, filePath, (totalBytes, chunkBytes) => {
const percent = (currentTotal/totalBytes * 100).toFixed(1);
console.log('总长度:' + totalBytes + 'B', '当前已下载:' + currentTotal + 'B','进度' + percent + '%');
currentTotal += chunkBytes;

if(currentTotal >= totalBytes) {
const {type} = sizeOf(fs.readFileSync(filePath));
fs.renameSync(filePath, filePath + '.' + type)
}
})

当下载完之后,拿到图片信息,重命名一下,把后缀名改成新的。


注意下图中文件名字的变化:



这样,下载图片就搞定了。


我们把它集成到自动化流程中。


先指定下文件保存位置和文件名:


我们在 home 目录下创建一个 .img 目录吧,然后文件名是 1.image、2.image 的形式。


const imgPath = path.join(os.homedir(), '.img');

fs.rmSync(imgPath, {
recursive: true
});
fs.mkdirSync(imgPath);

for(let i = 0; i< links.length; i++) {
const filePath = path.join(imgPath, (i+1) + '.image');
fs.writeFileSync(filePath, 'aaaa')
}


每次先清空 .img 目录,再创建。


执行之后,确实在 .img 目录下创建了对应的图片文件:



然后把下载图片和重命名的逻辑集成进来:


fs.rmSync(imgPath, {
recursive: true
});
fs.mkdirSync(imgPath);

for(let i = 0; i< links.length; i++) {
const filePath = path.join(imgPath, (i+1) + '.image');
let currentTotal = 0;
downloadFile(links[i].link, filePath, (totalBytes, chunkBytes) => {
currentTotal += chunkBytes;

if(currentTotal >= totalBytes) {
setTimeout(() => {
const {type} = sizeOf(fs.readFileSync(filePath));
fs.renameSync(filePath, filePath + '.' + type)
console.log(`${filePath} 下载完成,重命名为 ${filePath + '.' + type}`);
}, 1000);
}
})
}

这里加了一个 setTimeout,1s 之后执行重命名的逻辑,保证在文件下载完之后再重命名。


效果是这样的:



在 .img 下可以看到所有的图片都下载并重命名成功了:



有 png 也有 gif



下一步只要在不同的位置插入就好了。


我们再来做光标定位的部分。


这部分前面演示过,就是触发对应 p 标签的 click 就好了。


let cursor = 0;
async function uploadNext() {
if(cursor >= links.length) {
return;
}
await page.click(`.ql-editor p:nth-child(${links[cursor].index + 1})`);
await page.evaluate((index) => {
const p = document.querySelector(`.ql-editor p:nth-child(${index + 1})`);
p.textContent = '';
}, links[cursor].index);
await page.click('.ql-image');
cursor ++;
}

我们定义一个游标,从 0 开始,先点击第一个 link 的 p 标签,把它的内容清空,插入下载的图片。


然后再次执行就是插入下一个。


这样依次插入。


我们来试试:


首先,打开编辑器页面,自己登录和输入 markdown 内容:



然后输入 download-img 来下载图片:



之后执行 upload-next 插入第一张图片:



再执行 upload-next 插入第二张图片:



插入的位置非常正确!



依次 upload-next 就能把所有图片插入完成。


对比下之前的体验:


一张张下载图片,根据不同的格式来重命名,然后一张张找到对应的位置,删除原来的链接,插入图片。


现在的体验:


输入 download-img 自动下载图片,不断执行 upload-next 选择图片,自动插入到正确的位置。


这体验差距很明显吧!


这就是用 puppeteer 自动化以后的工作流。


全部代码如下:


import puppeteer from 'puppeteer';
import os from 'os';
import path from 'path';
import fs from 'fs';
import readline from 'readline';
import sizeOf from 'image-size';
import downloadFile from './download.js';

const browser = await puppeteer.launch({
headless: false,
defaultViewport: {
width: 0,
height: 0
},
userDataDir: path.join(os.homedir(), '.puppeteer-data')
});

const page = await browser.newPage();

await page.goto('https://wx.zsxq.com/dweb2/article?groupId=51122858222824');

const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});

rl.on('line', async (command) => {
switch(command) {
case 'upload-next':
await uploadNext();
break;
case 'download-img':
await downloadImg();
break;
default:
break;
}
});



let links = [];
async function downloadImg() {
links = await page.evaluate(() => {
let links = [];
const lines = document.querySelectorAll('.ql-editor p');
for(let i = 0; i < lines.length; i ++) {
const matchRes =lines[i].textContent.trim().match(/!\[[^\[\]\(\)]*\]\(([^\[\]\(\)]*)\)/)
if (matchRes) {
links.push({
index: i,
link: matchRes && matchRes[1],
});
}
}
return links;
})

const imgPath = path.join(os.homedir(), '.img');

fs.rmSync(imgPath, {
recursive: true
});
fs.mkdirSync(imgPath);


for(let i = 0; i< links.length; i++) {
const filePath = path.join(imgPath, (i+1) + '.image');
let currentTotal = 0;
downloadFile(links[i].link, filePath, (totalBytes, chunkBytes) => {
currentTotal += chunkBytes;

if(currentTotal >= totalBytes) {
setTimeout(() => {
const {type} = sizeOf(fs.readFileSync(filePath));
fs.renameSync(filePath, filePath + '.' + type)
console.log(`${filePath} 下载完成,重命名为 ${filePath + '.' + type}`);
}, 1000);
}
})
}

console.log(links);
}

let cursor = 0;
async function uploadNext() {
if(cursor >= links.length) {
return;
}
await page.click(`.ql-editor p:nth-child(${links[cursor].index + 1})`);
await page.evaluate((index) => {
const p = document.querySelector(`.ql-editor p:nth-child(${index + 1})`);
p.textContent = '';
}, links[cursor].index);
await page.click('.ql-image');
cursor ++;
}

import https from 'https';
import fs from 'fs';

export default function downloadFile(url, destinationPath, progressCallback) {
let resolve , reject;
const promise = new Promise((x, y) => { resolve = x; reject = y; });

const request = https.get(url, response => {
if (response.statusCode !== 200) {
const error = new Error(`Download failed: server returned code ${response.statusCode}. URL: ${url}`);
response.resume();

reject(error);
return;
}
const file = fs.createWriteStream(destinationPath);

file.on('finish', () => resolve ());
file.on('error', error => reject(error));

response.pipe(file);

const totalBytes = parseInt(response.headers['content-length'], 10);
if (progressCallback)
response.on('data', onData.bind(null, totalBytes));
});
request.on('error', error => reject(error));
return promise;

function onData(totalBytes, chunk) {
progressCallback(totalBytes, chunk.length);
}
}

总结


星球编辑器不好用,每次都要把图片手动下载下来然后插入对应位置,我们通过 puppeteer 把这个流程自动化了。


puppeteer 是一个自动化测试工具,基本所有浏览器手动的操作都能自动化。


我们用 readline 模块读取用户输入,当输入 download-img 的时候,拿到所有的 p 标签,过滤出链接的内容,把信息记录下来。


自动下载图片并用 image-size 读取图片类型来重命名。


然后输入 upload-next,会通过点击对应 p 标签实现光标定位,然后点击上传按钮来选择图片。


自动化以后的工作流程简单太多了,繁琐的工作都给自动化了,体验爽翻了!


作者:zxg_神说要有光
来源:juejin.cn/post/7230757380819812407
收起阅读 »

js如何实现当文本内容过长时,中间显示省略号...,两端正常展示

web
前一阵做需求时,有个小功能实现起来废了点脑细胞,觉得可以记录一下。 产品的具体诉求是:用户点击按钮进入详情页面,详情页内的卡片标题内容过长时,标题的前后两端正常展示,中间用省略号...表示,并且鼠标悬浮后,展示全部内容。 关于鼠标悬浮展示全部内容的代码就不放在...
继续阅读 »

前一阵做需求时,有个小功能实现起来废了点脑细胞,觉得可以记录一下。


产品的具体诉求是:用户点击按钮进入详情页面,详情页内的卡片标题内容过长时,标题的前后两端正常展示,中间用省略号...表示,并且鼠标悬浮后,展示全部内容。


关于鼠标悬浮展示全部内容的代码就不放在这里了,本文主要写关于实现中间省略号...的代码。


实现思路



  1. 获取标题盒子的真实宽度, 我这里用的是clientWidth;

  2. 获取文本内容所占的实际宽度;

  3. 根据文字的大小计算出每个文字所占的宽度;

  4. 判断文本内容的实际宽度是否超出了标题盒子的宽度;

  5. 通过文字所占的宽度累加之和与标题盒子的宽度做对比,计算出要截取位置的索引;

  6. 同理,文本尾部的内容需要翻转一下,然后计算索引,截取完之后再翻转回来;


代码


html代码


<div class="title" id="test">近日,银行纷纷下调大额存单利率,但银行定期存款仍被疯抢。银行理财经理表示:有意向购买定期存款要尽快,不确定利率是否会再降。</div>

css代码: 设置文本不换行,同时设置overflow:hidden让文本溢出盒子隐藏


.title {
width: 640px;
height: 40px;
line-height: 40px;
font-size: 14px;
color: #00b388;
border: 1px solid #ddd;
overflow: hidden;
/* text-overflow: ellipsis; */
white-space: nowrap;
/* box-sizing: border-box; */
padding: 0 10px;
}

javascript代码:


获取标题盒子的宽度时要注意,如果在css样式代码中设置了padding, 就需要获取标题盒子的左右padding值。 通过getComputedStyle属性获取到所有的css样式属性对应的值, 由于获取的padding值都是带具体像素单位的,比如: px,可以用parseInt特殊处理一下。


获取盒子的宽度的代码,我当时开发时是用canvas计算的,但计算的效果不太理想,后来逛社区,发现了嘉琪coder大佬分享的文章,我这里就直接把代码搬过来用吧, 想了解的掘友可以直接滑到文章末尾查看。


判断文本内容是否超出标题盒子


 // 标题盒子dom
const dom = document.getElementById('test');

// 获取dom元素的padding值
function getPadding(el) {
const domCss = window.getComputedStyle(el, null);
const pl = Number.parseInt(domCss.paddingLeft, 10) || 0;
const pr = Number.parseInt(domCss.paddingRight, 10) || 0;
console.log('padding-left:', pl, 'padding-right:', pr);
return {
left: pl,
right: pr
}
}
// 检测dom元素的宽度,
function checkLength(dom) {
// 创建一个 Range 对象
const range = document.createRange();

// 设置选中文本的起始和结束位置
range.setStart(dom, 0),
range.setEnd(dom, dom.childNodes.length);

// 获取元素在文档中的位置和大小信息,这里直接获取的元素的宽度
let rangeWidth = range.getBoundingClientRect().width;

// 获取的宽度一般都会有多位小数点,判断如果小于0.001的就直接舍掉
const offsetWidth = rangeWidth - Math.floor(rangeWidth);
if (offsetWidth < 0.001) {
rangeWidth = Math.floor(rangeWidth);
}

// 获取元素padding值
const { left, right } = getPadding(dom);
const paddingWidth = left + right;

// status:文本内容是否超出标题盒子;
// width: 标题盒子真实能够容纳文本内容的宽度
return {
status: paddingWidth + rangeWidth > dom.clientWidth,
width: dom.clientWidth - paddingWidth
};
}

通过charCodeAt返回指定位置的字符的Unicode编码, 返回的值对应ASCII码表对应的值,0-127包含了常用的英文、数字、符号等,这些都是占一个字节长度的字符,而大于127的为占两个字节长度的字符。


截取和计算文本长度


// 计算文本长度,当长度之和大于等于dom元素的宽度后,返回当前文字所在的索引,截取时会用到。
function calcTextLength(text, width) {
let realLength = 0;
let index = 0;
for (let i = 0; i < text.length; i++) {
charCode = text.charCodeAt(i);
if (charCode >= 0 && charCode <= 128) {
realLength += 1;
} else {
realLength += 2 * 14; // 14是字体大小
}
// 判断长度,为true时终止循环,记录索引并返回
if (realLength >= width) {
index = i;
break;
}
}
return index;
}

// 设置文本内容
function setTextContent(text) {
const { status, width } = checkLength(dom);
let str = '';
if (status) {
// 翻转文本
let reverseStr = text.split('').reverse().join('');

// 计算左右两边文本要截取的字符索引
const leftTextIndex = calcTextLength(text, width);
const rightTextIndex = calcTextLength(reverseStr, width);

// 将右侧字符先截取,后翻转
reverseStr = reverseStr.substring(0, rightTextIndex);
reverseStr = reverseStr.split('').reverse().join('');

// 字符拼接
str = `${text.substring(0, leftTextIndex)}...${reverseStr}`;
} else {
str = text;
}
dom.innerHTML = str;
}

最终实现的效果如下:


image.png


上面就是此功能的所有代码了,如果想要在本地试验的话,可以在本地新建一个html文件,复制上面代码就可以了。


下面记录下从社区内学到的相关知识:



  1. js判断文字被溢出隐藏的几种方法;

  2. JS获取字符串长度的几种常用方法,汉字算两个字节;


1、 js判断文字被溢出隐藏的几种方法


1. Element-plus这个UI框架中的表格组件实现的方案。


通过document.createRangedocument.getBoundingClientRect()这两个方法实现的。也就是我上面代码中实现的checkLength方法。


2. 创建一个隐藏的div模拟实际宽度


通过创建一个不会在页面显示出来的dom元素,然后把文本内容设置进去,真实的文本长度与标题盒子比较宽度,判断是否被溢出隐藏了。


function getDomDivWidth(dom) {
const elementWidth = dom.clientWidth;
const tempElement = document.createElement('div');
const style = window.getComputedStyle(dom, null)
const { left, right } = getPadding(dom); // 这里我写的有点重复了,可以优化
tempElement.style.cssText = `
position: absolute;
top: -9999px;
left: -9999px;
white-space: nowrap;
padding-left:${style.paddingLeft};
padding-right:${style.paddingRight};
font-size: ${style.fontSize};
font-family: ${style.fontFamily};
font-weight: ${style.fontWeight};
letter-spacing: ${style.letterSpacing};
`
;
tempElement.textContent = dom.textContent;
document.body.appendChild(tempElement);
const obj = {
status: tempElement.clientWidth + right + left > elementWidth,
width: elementWidth - left - right
}
document.body.removeChild(tempElement);
return obj;
}

3. 创建一个block元素来包裹inline元素


这种方法是在UI框架acro design vue中实现的。外层套一个块级(block)元素,内部是一个行内(inline)元素。给外层元素设置溢出隐藏的样式属性,不对内层元素做处理,这样内层元素的宽度是不变的。因此,通过获取内层元素的宽度和外层元素的宽度作比较,就可以判断出文本是否被溢出隐藏了。


// html代码
<div class="title" id="test">
<span class="content">近日,银行纷纷下调大额存单利率,但银行定期存款仍被疯抢。银行理财经理表示:有意向购买定期存款要尽快,不确定利率是否会再降。</span>
</div>

// 创建一个block元素来包裹inline元素
const content = document.querySelector('.content');
function getBlockDomWidth(dom) {
const { left, right } = getPadding(dom);
console.log(dom.clientWidth, content.clientWidth)
const obj = {
status: dom.clientWidth < content.clientWidth + left + right,
width: dom.clientWidth - left - right
}
return obj;
}

4. 使用canvas中的measureText方法和TextMetrics对象来获取元素的宽度


通过Canvas 2D渲染上下文(context)可以调用measureText方法,此方法会返回TextMetrics对象,该对象的width属性值就是字符占据的宽度,由此也能获取到文本的真实宽度,此方法有弊端,比如说兼容性,精确度等等。


// 获取文本长度
function getTextWidth(text, font = 14) {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")
context.font = font
const metrics = context.measureText(text);
return metrics.width
}

2、JS获取字符串长度的几种常用方法


1. 通过charCodeAt判断字符编码


通过charCodeAt获取指定位置字符的Unicode编码,返回的值对应ASCII码表对应的值,0-127包含了常用的英文、数字、符号等,这些都是占一个字节长度的字符,而大于127的为占两个字节长度的字符。


function calcTextLength(text) {
let realLength = 0;
for (let i = 0; i < text.length; i++) {
charCode = text.charCodeAt(i);
if (charCode >= 0 && charCode <= 128) {
realLength += 1;
} else {
realLength += 2;
}
}
return realLength;
}

2. 采取将双字节字符替换成"aa"的做法,取长度


function getTextWidth(text) {
return text.replace(/[^\x00-\xff]/g,"aa").length;
};

参考文章


1. JS如何判断文字被ellipsis了?


2. Canvas API 中文网


3. JS获取字符串长度的常用方法,汉字算两个字节


4. canvas绘制字体偏上不居中问题、文字垂直居中后偏上问题、measureText方法和TextMetrics对象


作者:娜个小部呀
来源:juejin.cn/post/7329967013923962895
收起阅读 »

uniapp踩坑合集

web
1、onPullDownRefresh下拉刷新不生效 pages.json对应的style中enablePullDownRefresh设置为true,开启下拉刷新 { "path" : "pages/list/list", "style" : ...
继续阅读 »

1、onPullDownRefresh下拉刷新不生效


pages.json对应的style中enablePullDownRefresh设置为true,开启下拉刷新
{
"path" : "pages/list/list",
"style" :
{
"navigationBarTitleText": "页面标题名称",
"enablePullDownRefresh": true
}
}

2、onReachBottom上拉加载不生效


page中css样式设置了height:100%;
修改为height:auto;即可

3、onPageScroll生命周期不触发


最外层css样式设置了以下样式
height: 100%;
overflow: scroll;

4、onBackPress监听页面返回生命周期


使用场景:APP手机左滑返回时控制执行某些操作,不直接返回上一页(例如:弹框打开时关闭弹框)


注意事项


1、onBackPress上不可使用async,会导致无法阻止默认返回


2、支付宝小程序只有真机可以监听到非navigateBack引发的返回事件(使用小程序开发工具时不会触发onBackPress),不可以阻止默认返回行为


3、只有在该函数中返回值为 true 时,才表示不执行默认的返回,自行处理此时的业务逻辑


4、当不阻止页面返回却直接调用页面路由相关接口(如:uni.switchTab)时,可能会导致页面显示异常,可以通过延迟调用路由相关接口解决


5、H5 平台,顶部导航栏返回按钮支持 onBackPress(),浏览器默认返回按键及Android手机实体返回键不支持 onBackPress()


6、暂不支持直接在自定义组件中配置该函数,目前只能是在页面中来处理。


//场景1:弹框打开时,返回执行关闭弹框
//html
"searchPop" type="right" @change="popupChange">
<view class="popup-con">1111view>


//js
export default {
data() {
return {
boxShow: false
}
},
onBackPress(options) {
if( this.boxShow ){
this.$refs.searchPop.close();
return true
}
//其他情况执行默认返回
},
methods: {
popupChange(e) {
this.boxShow = e.show;
},
}
}

//场景2,多级返回时
export default {
data() {
return {
boxShow: false
}
},
onBackPress(options) {
if( this.boxShow ){
this.$refs.searchPop.close();
return true
}else{
if (options.from === 'navigateBack') {
return false;
}
uni.navigateBack({
delta: 2
});
}
},
methods: {
popupChange(e) {
this.boxShow = e.show;
},
}
}


5、遮罩层不能遮底部导航栏


应用场景:APP升级弹框提示


uni文档api界面——交互反馈中uni.showModal可以遮罩底部导航栏;
uni.showToast(OBJECT)、uni.showLoading(OBJECT)都无法遮罩底部导航栏;


目前可以采用两种方式解决:自定义底部导航栏、打开时隐藏底部导航栏


方法一:自定义底部导航栏


1、在app.vue页面的onLaunch生命周期中隐藏原生底部  
onLaunchfunction() {
console.log('App Launch')
uni.hideTabBar();
}

2、自己封装tab组件
<template>
<view class="foot-bar">
<view v-if="hasBorder" class="foot-barBorder">view>
<view class="foot-con">
<view class="foot-list" v-for="(item,index) in tabList" :key="index" @tap="tabJump(index,item.pagePath)">
<img v-if="index!=selectedIndex" class="foot-icon" :src="'/'+item.iconPath" mode="heightFix" />
<img v-else class="foot-icon" :src="'/'+item.selectedIconPath" mode="heightFix" />
<text v-if="index!=selectedIndex" :style="textStyle">{{item.text}}text>
<text v-else :style="textSelectStyle">{{item.text}}text>
view>
view>
view>
template>

<script>
export default {
name: "tabBar",
props: {
hasBorder: {
type: Boolean,
default: false
},
selectedIndex:{
type:[String,Number],
default:0
},
textStyle: {
type: Object,
default () {
return {
color:'#999'
}
}
},
textSelectStyle:{
type: Object,
default () {
return {
color: 'rgb(0, 122, 255)'
}
}
}
},
data() {
return {
tabList: [{
"pagePath": "pages/tabBar/component/component",
"iconPath": "static/component.png",
"selectedIconPath": "static/componentHL.png",
"text": "内置组件"
},
{
"pagePath": "pages/tabBar/API/API",
"iconPath": "static/api.png",
"selectedIconPath": "static/apiHL.png",
"text": "接口"
}, {
"pagePath": "pages/tabBar/extUI/extUI",
"iconPath": "static/extui.png",
"selectedIconPath": "static/extuiHL.png",
"text": "扩展组件"
}, {
"pagePath": "pages/tabBar/template/template",
"iconPath": "static/template.png",
"selectedIconPath": "static/templateHL.png",
"text": "模板"
}
]
};
},
methods:{
tabJump(index,url){
if( index == this.selectedIndex ){
return
}
uni.switchTab({
url: '/' + url
})
}
}
}
script>

<style lang="scss" scoped>
.foot-bar {
position: fixed;
left: 0px;
right: 0px;
bottom: 0px;
z-index: 998;
width: 100vw;
.foot-barBorder {
position: absolute;
left: 0px;
right: 0px;
top: -1px;
width: 100vw;
height: 1px;
background-color: #eee;
}
.foot-con {
background-color: #fff;
width: 100vw;
height: 50px;
display: flex;
flex-direction: row;
align-items: center;
.foot-list {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
.foot-icon{
width: auto;
height:30px;
}
text{
font-size: 12px;
}
}
}
}
style>


3、需要底部导航的页面引入组件,当前页面是导航栏第几个,selectedIndex就等于几,从0开始
<template>
<view>
<TabBar :selectedIndex="0">TabBar>
view>
template>
<script>
import TabBar from "@/components/tabBar/tabBar";
export default {
components:{
TabBar
},
data() {
return {}
}
}
script>


方法二:打开时隐藏底部导航栏,关闭时打开导航栏


uni.hideTabBar();uni.showTabBar(); 官方文档


image.png


image.png


6、条件编译的正确写法


语法:以 #ifdef 或 #ifndef 加 %PLATFORM% 开头,以 #endif 结尾



  • #ifdef:if defined 仅在某平台存在

  • #ifndef:if not defined 除了某平台均存在

  • %PLATFORM%:平台名称


//仅出现在 App 平台下的代码  
#ifdef APP-PLUS
需条件编译的代码
#endif

//除了 H5 平台,其它平台均存在的代码  
#ifndef H5
需条件编译的代码
#endif

在 H5 平台或微信小程序平台存在的代码  
#ifdef H5 || MP-WEIXIN
需条件编译的代码
#endif

//css样式中  
page{
padding-top:24rpx;
/* #ifdef  H5 */
padding-top:34rpx;
/* #endif */
}

//.vue页面中  
<template>

<view>NFC扫码view>

template>

//page.json页面中  
//json文件中
//API 的条件编译
//生命周期中
//methods方法中
mounted(){
// #ifdef APP-PLUS
//APP更新
this.checkUpdate();
//#endif
}

7、限制input输入类型,replace不生效


//不生效代码
"Code" type="number" placeholder="请输入号码" clearable trim="all" :inputBorder="false" @input="gunChange" maxlength="11">

methods:{
gunChange(e){
this.addForm.oilGunCode = e.replace(/[^\d]/g, '');
},
}

使用v-model绑定值时,replace回显不生效;将v-model修改为:value即可生效;


//生效代码
all" :inputBorder="false" @input="gunChange" maxlength="11">

限制只能输入数字:/[^\d]/g/\D/g (但无法限制0开头)
限制只能输入大小写字母、数字、下划线:/[^\w_]/g
限制只能输入小写字母、数字、下划线:/[^a-z0-9_]/g
限制只能输入数字和点:/[^\d.]/g
限制只能输入中文:/[^\u4e00-\u9fa5]/g
限制只能输入英文(大小写均可):/[^a-zA-Z]/g
去除空格:/\s+/g

8、常见的登录验证


"name" placeholder="请输入用户名" clearable trim="all" maxlength="11">
<uni-easyinput v-model="tell" placeholder="请输入手机号" clearable trim="all" maxlength="11">uni-easyinput>

methods:{
submitHandle(){
//姓名 2-5为的汉字
var reg0 = /^[\u4e00-\u9fa5]{2,5}$/,
//用户名正则,4到16位(字母,数字,下划线,减号)
var reg = /^[a-zA-Z0-9_-]{4,16}$/;
//密码强度正则,最少6位,包括至少1个大写字母,1个小写字母,1个数字,1个特殊字符
var reg2 = /^.*(?=.{6,})(?=.*\d)(?=.*[A-Z])(?=.*[a-z])(?=.*[!@#$%^&*? ]).*$/;
//Email正则
var reg3 = /^([A-Za-z0-9_\-\.])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/;
//手机号正则
var reg4 = /^((13[0-9])|(14[5|7])|(15([0-3]|[5-9]))|(18[0,5-9]))\d{8}$/;
//身-份-证号(18位)正则
var reg5 = /^[1-9]\d{5}(18|19|([23]\d))\d{2}((0[1-9])|(10|11|12))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/;
//车牌号正则
var reg6 = /^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z]{1}[A-Z]{1}[A-Z0-9]{4}[A-Z0-9挂学警港澳]{1}$/;

if( !reg.test(this.name) ){
uni.showToast(
title:'用户名格式不正确!'
)
}
},
}

作者:CRMEB技术团队
来源:juejin.cn/post/7272185503822086203
收起阅读 »

React Server Components引发的分歧与机遇

web
介绍 React Server Components 在以前,当用户访问一个 React 应用时,服务端会返回一个空的 HMTL 文件,里面包含一个或多个 JavaScript 文件,浏览器解析 HTML,然后下载 JavaScript 文件,并在客户端呈现网...
继续阅读 »

介绍 React Server Components


在以前,当用户访问一个 React 应用时,服务端会返回一个空的 HMTL 文件,里面包含一个或多个 JavaScript 文件,浏览器解析 HTML,然后下载 JavaScript 文件,并在客户端呈现网页。


React Server Components(RSC)的出现拓展了 React 的范围。顾名思义,React Server Components 就是 React 的服务端组件,它们只在服务端运行,可以调用服务端的方法、访问数据库等。RSC 每次预渲染后把 HTML 发送到客户端,由客户端进行水合(hydrate)并正式渲染。这种做法的好处是,一部分原本要打包在客户端 JavaScript 文件里的代码,现在可以放在服务端运行了,从而减轻客户端的负担,提升应用的整体性能和响应速度。


「充分利用服务器资源」是发布 RSC 的最大动机,换句话说就是:一切不需要交互的内容都应当放到服务端。React 官方举了一个非常典型的例子——渲染 markdown 内容,


// 客户端组件渲染

import marked from 'marked'; // 35.9K (11.2K gzipped)
import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)

function NoteWithMarkdown({text}) {
const html = sanitizeHtml(marked(text));
return (/* 渲染 */);
}

这个例子中,如果用客户端组件渲染,客户端至少要下载 200 多k的文件才能渲染出内容,但这里的 markdown 内容其实不需要交互,也不会因为用户的操作产生更新信息的需求,非常符合使用 RSC 的理念。如果使用 RSC,


// 服务器组件渲染

import marked from 'marked'; // 零打包大小
import sanitizeHtml from 'sanitize-html'; // 零打包大小

function NoteWithMarkdown({text}) {
// 与之前相同
}

依赖包放在服务端,服务端只返回用户需要看到的内容,客户端包一下子就小了 200 多k。


直到这里,社区主流观点都是积极的,直到 Next.js 基于 RSC 的特性野蛮狂奔,分歧出现了。


社区分歧


出现分歧的最根本原因是 React 引入了服务端的概念,服务端组件和客户端组件有着明显差异:



  • 服务器组件不能使用像 useState 和 useEffect 这样的 React hook;客户端则可以;

  • 服务器组件无权访问浏览器 API;客户端有完整的浏览器 API 权限;

  • 服务端有权限直接访问服务端程序和 API;而客户端组件只能通过请求访问部分程序。


随着 Next.js v13 和 v14 版本发布,React 仍然是金丝雀版本的 RSC 被 Next.js 搬到生产环境,‘use client’‘use server’ 被越来越多人讨论,开发者们说现在有「两个 React」,社区开始争吵 React 这些年在进步还是在退步?


WechatIMG4559.jpeg


社区里反对的声音


首先是知名软件工程师 Cassidy Williams,她指出 React 这两年的发展问题:



  • 「两个 React」带来的新概念对大多数人来说并不是清晰易懂的知识,这种分裂可能导致了额外的混淆和学习障碍。

  • 自 2022 年 6 月以来 React 不仅没有新的发布,还鼓励开发者使用上层框架,而这些上层框架不等 RSC 升级成稳定版,就发布了基于 RSC 的特性(就差点名 Next.js 了)。

  • React 近些年有成员加入其他上层框架的团队,不仅疏于更新版本,还疏于更新文档。


React Query 的开发者 Tanner Linsley 也对 React 的发展表达了担忧和不满:



  • 自从 React 引入 hooks 和 suspense API 以来,React 过分专注于少数几个概念,这些新概念虽然在技术上推动了单线程 UI API 的极限和边界,但对他日常为用户提供价值的工作影响甚微。

  • 从 RSC 发布看出来,React 团队对客户端性能已经没有那么强烈的追求了。


地图技术和可视化技术专家 Tom MacWright 对 React 生态系统的分裂进行了批评:



  • 当前 React 更新缓慢,反而说两个上层框架Remix(由 Shopify 资助)和 Next.js(由 Vercel 资助)在激烈竞争。

  • React 团队和 Next.js 团队交集过多,让 Vercel 获得了领先优势,那些不属于 Vercel 和 Facebook 生态系统的其他框架,如 Remix,它们会受到 React 中已修复但未发布错误的影响。


社区里积极的态度


面对社区里越来越多的反对声音,React 主要贡献者 Dan Abramov 也多次发表里自己看法,他对技术的变革持开放态度:



  • Next.js 的 App Router 有着雄心壮志,但是现在还是发展初期,未来会迭代得更优秀。

  • 客户端组件的工作是 UI = f(state),服务端组件的工作是 UI = f(data),React 希望组合二者的优势,实现 UI = f(data, state),他号召社区共同推动实现这一目标。

  • 对于 Next.js 把 RSC 发布到生产版本,Dan 认为“生产就绪”是一个主观的判断,虽然 RSC 还是金丝雀版本,但是 Facebook 也已经大量使用了。他认为在实践中验证才能更快完善技术,最终达到成熟和稳定。

  • 新技术的发展是一个渐进的过程,涉及到不断的测试、反馈和迭代,社区的力量非常重要。


总的来说,Dan 是希望大家放下偏见,共同在实践中摸索出 React 下一阶段的变革。


我的观点


在 RSC 的讨论中,我比较认同 Dan 提出的开放和包容性的观点。我认为,面对技术的发展,要抛弃个人偏见,可以实践验证,也可以持续观察它们的发展。只有心态上拥抱变革,开发者才能在变革中找到机遇。


RSC 在提升现代 Web 应用开发绝对是有积极意义的,最显而易见的优势是它可以提高大型应用的性能、减少客户端负载、优化数据获取流程等,通过 RSC 完成这些工作会比以往的 SSR 方案要更加方便。


随着 Node v20 的发布和 RSC 的应用,前端和服务端的距离进一步缩小,我们有机会见证前端工作“后端化”——前端工程师会处理更多传统上属于后端的工作,如数据查询优化、服务器资源管理等。这实际上为前端工程师打开了一扇门,让我们有机会更全面地掌握整个 web 应用的开发流程,也就是我们常说的“全栈开发”。这样的转变势必会提高前端的职业天花板和扩大前端工作的广度。


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

React 19 发布在即,抢先学习一下新特性

web
React 上一次发布版本还要追溯到2022年6月14日,版本号是18.2.0。在大前端领域,一项热门技术更新如此缓慢属实罕见。这也引起社区里一些大佬的不满,在我的上一篇文章里有提到,感兴趣的朋友可以点击查看:React 社区里的分歧。 在社区不满的声音越来越...
继续阅读 »

React 上一次发布版本还要追溯到2022年6月14日,版本号是18.2.0。在大前端领域,一项热门技术更新如此缓慢属实罕见。这也引起社区里一些大佬的不满,在我的上一篇文章里有提到,感兴趣的朋友可以点击查看:React 社区里的分歧


在社区不满的声音越来越大的背景下,React 新版本的消息终于来了。


React 团队也回应了迟迟未发布新的正式版本的质疑:此前发布到 Canary 版本的多项特性,因为这些特性是相互关联的,所以 React 团队需要投入大量时间确保它们能够协同工作,然后才能逐步发布到 Stable 版本。


事实也确实如此,虽然在这将近两年的时间里 React 没有发布正式版本,但是 Canary 却有一些重磅更新,例如:useuseOptimistic hook,use clientuse server 指令。这些更新客观上丰富了 React 生态系统,特别是推动了 Next.js 和 Remix 等全栈框架的高速发展。


React 团队已经确定,下一个版本将是大版本号,即版本号会是 19.0.0。


v19 新特性预测


现在,让我们根据 React 团队最新发布的消息,来抢先学习一下 v19 版本可能正式发布的新特性。


自动记忆化


你是否还记得 React Conf 2021 上黄玄介绍的 React Forget?




现在,它来了。


它是一个编译器,目前已经在 instagram 的生产环境中应用,React 团队计划在 Meta 的更多平台中应用,并且未来会进行开源发布。


在使用新编译器以前,我们使用 useMemouseCallbackmemo 来手动缓存状态,以减少不必要的重新渲染,这种实现方式虽然可行,但 React 团队认为这并不是他们认为理想的方式,他们一直寻找让 React 在状态变化时自动且只重新渲染必要部分的方案。经过多年的攻坚,现在新的编译器成功落地了。


新的 React 编译器会是一个开箱即用的特性,对开发者来说是又一次开发范式的改变,这也是 v19 最让人期待的功能。


好玩的是,React 团队在介绍新编译器时完全没有提到“React Forget”,这也让好事的网友爆梗了:They forget React Forget & forget to mentioned Forget in the Forget section.🤣


Actions


React Actions 是 React 团队在探索客户端向服务器发送数据的解决方案过程中发展出来的,这个功能允许开发者向 DOM 元素(如 <form/>)传递一个函数:

<form action={search}>
<input name="query" />
<button type="submit">Search</button>
</form>

action 函数可以同步或异步操作。使用 action 时,React 将为开发者管理数据提交的生命周期,我们可以通过 useFormStatususeFormState 这两个 hook 来访问表单操作的当前状态和响应。


action 可以在执行数据库变更(如增加、删除、更新数据)和实现表单(如登录表单、注册表单)等客户端到服务器交互的场景中使用。


action 不仅可以与 useFormStatususeFormState 结合使用,还可以用与 useOptimisticuse server 结合使用。详细展开篇幅就会很长了,你可以关注我,很快我会单独写一篇文章介绍 action 的详细用法。


指令:use client 与 use server


use clientuse server 两个指令在 Canary 版本发布已久,终于也要在 v19 版本里加入 Stable 版本了。


此前社区频频有人因为 Next.js 在生产环境使用这两个指令而指责 Next.js 在破坏 React 生态、批评 React 团队纵容 Next.js 超前使用非稳定特性。其实大可不必,因为这两个指令就是为 Next.js 和 Remix 这样的全栈框架设计的,短期内普通开发者使用 React 开发应用几乎不会用到它们。


如果你是使用 React,而不是使用全栈框架,你只需要了解这两个指令的作用即可:use clientuse server 标记了前端和服务端两个环境的“分割点”,use client 指示打包工具生成一个 <script> 标签,而 use server 告诉打包工具生成一个POST端点。这两个指令能够让开发者在一份文件里同时写客户端代码和服务端代码。



💡 如果你对这两个指令感兴趣,可以来看我的另一篇文章:「🌍NextJS v13服务端组件和客户端组件及最佳实践



useOptimistic 乐观更新



💡 乐观更新:是一种在前端开发中常用的处理异步操作反馈的策略。它基于一种“乐观”的假设:即假设无论我们向服务器发送什么请求,这些操作都将成功执行,因此在得到服务器响应之前,我们就提前在用户界面上渲染这些改变。

使用场景:点赞、评论、任务添加编辑等。



useOptimistic 是一个新的 hook,很可能在 v19 版本中被标记为稳定版。useOptimistic 允许你在异步操作(如网络请求)进行时,乐观地更新 UI。它通过接受当前状态和一个更新函数作为参数,返回一个在异步操作期间可能会有所不同的状态副本。你需要提供一个函数,这个函数接收当前状态和操作的输入,并返回在操作等待期间使用的乐观状态。


它的用法定义如下:

const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);

// or

const [optimisticState, addOptimistic] = useOptimistic(
state,
// updateFn
(currentState, optimisticValue) => {
// merge and return new state with optimistic value
}
);

参数



  • state: 初始状态值,以及在没有操作进行时返回的值。

  • updateFn(currentState, optimisticValue) : 一个函数,接收当前状态和传递给 addOptimistic 的乐观值,返回结果乐观状态。updateFn 接收两个参数:currentStateoptimisticValue。返回值将是 currentStateoptimisticValue 的合并值。


返回值



  • optimisticState: 产生的乐观状态。当有操作正在进行,它等于 updateFn 返回的值,没有操作正在进行,它等于 state

  • addOptimistic: 这是在进行乐观更新时调用的调度函数。它接受一个参数 optimisticValue(任意类型),并调用带有 stateoptimisticValueupdateFn


更详细的用法如下:

import { useOptimistic } from 'react';

function AppContainer() {
const [state, setState] = useState(initialState); // 假设有一个初始状态
const [optimisticState, addOptimistic] = useOptimistic(
state,
// updateFn
(currentState, optimisticValue) => {
// 合并返回:新状态、乐观值
return { ...currentState, ...optimisticValue };
}
);

// 假设有一个异步操作,如提交表单
function handleSubmit(data) {
// 在实际数据提交前,使用乐观更新
addOptimistic({ data: 'optimistic data' });

// 然后执行异步操作
fetch('/api/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
.then(response => response.json())
.then(realData => {
// 使用实际数据更新状态
setState(prevState => ({ ...prevState, data: realData }));
});
}

return (
// 使用 optimisticState 来渲染 UI
<div>{optimisticState.data}</div>
);
}



useOptimistic 会在异步操作进行时先渲染预期的结果,等到异步操作完成,状态更新后,再渲染真实的返回结果(无论成功和失败)。


其它更新


除此之外,React 团队成员 Andrew Clark 还透露2024年还会有以下变化:



  • forwardRef → ref is a prop:简化对子组件内部元素或组件的引用方式,使 ref 作为一个普通的prop传递

  • React.lazy → RSC, promise-as-child:增强了代码分割和懒加载能力

  • useContext → use(Context):提供一种新的方式来访问 Context

  • throw promise → use(promise):改进异步数据加载的处理方式

  • <Context.Provider> → <Context>:简化了上下文提供者的使用


但目前 React 官网没有对以上潜在更新提供详细的信息。


总结


React 的愿景很大,他们希望打破前端和后端的边界,在维持自身客户端能力优势的基础上,同时为社区的全栈框架提供基建。我非常认可他们的做法,因为打破了端的边界,才能帮助前端工程师打破职业天花板。


React 19 会是引入 hooks 之后又一次里程碑式版本,Andrew Clark 说新版本将在 3 月或 4 月发布,让我们拭目以待!


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

【HTML】交友软件上照片的遮罩是如何做的

web
笑谈 我不知道大家有没有在夜深人静的时候感受到孤苦难耐,🐶。于是就去下了一些交友软件来排遣寂寞。可惜的是,有些交友软件真不够意思,连一些漂亮小姐姐的图片都要进行遮罩,完全不考虑兄弟们的感受,😠。所以今天,我们就一起来看看这些软件的遮罩是如何做的,🐶。 调研...
继续阅读 »

笑谈


我不知道大家有没有在夜深人静的时候感受到孤苦难耐,🐶。于是就去下了一些交友软件来排遣寂寞。可惜的是,有些交友软件真不够意思,连一些漂亮小姐姐的图片都要进行遮罩,完全不考虑兄弟们的感受,😠。所以今天,我们就一起来看看这些软件的遮罩是如何做的,🐶。


调研


市场上这些交友软件比较多,就拿一个我朋友他经常玩的一个软件来研究,叫做《XX之恋》,重申一下,我这里没有任何打广告的嫌疑,毕竟是我朋友玩的,🐶。我们接下来看这软件中遮罩的图片。



注:我实在没有在网上找到该软件这些有遮罩的图片,所以只好从自己的主页上截取了下,如果有当事人认为这是自己的话,请速与我联系,我会及时删除的。




正如上面所见,该软件的遮罩效果还是非常不错的,为什么说非常不错呢?个人认为有两个亮点,🐶保命。



  1. 这个遮罩效果让我们知道对面是女生。

  2. 这个遮罩效果也仅仅只能让我们知道对面是女生。


言归正传,这种效果在我们悠久的前端历史上,有一种专业名词 --> 毛玻璃效果



碎碎念:我看了蛮多毛玻璃的技术文章,这个技术大家说都是为了让能人阅读的时候更赏心悦目,能用来遮小姐姐也算是很不错的创新了。🐶



实现


现在我们只需要两样东西,一个是小姐姐的图片,一个是前端的小知识。我都准备好啦。首先我们先介绍知识点。


backdrop-filter属性


我们看MDN的介绍文档


可以让你为一个元素后面区域添加图形效果(如模糊或颜色偏移)。因为它适用于元素背后的所有元素,为了看到效果,必须使元素或其背景至少部分透明。


backdrop-filter有如下常用属性(带了数值,方便理解)



  • 🌟blur(2px): 对元素背后的背景应用2像素的模糊效果。

  • brightness(60%): 将元素背景的亮度调整为原始亮度的60%。

  • contrast(40%): 将元素背景的对比度调整为原始对比度的40%。

  • ...............等


我们主要使用的便是blur属性,对背景图片模糊,达到类似的效果。


我采用的小姐姐图片如下




思路:使用两个Class。第一个Class的背景图片是上面的小姐姐,第二个Class完全覆盖第一个Class,设置blur10px即可。如下是代码。

效果图如下:





最后,希望大家多多点赞支持,兄弟们的点赞支持是我继续写文章的动力!



作者:鑫宝Code
来源:juejin.cn/post/7333986476030935050
收起阅读 »

面试官:实现一个吸附在键盘上的输入框

web
实现效果 话不多说,先上效果和 demo 地址: demo 地址:codesandbox.io/p/devbox/ke… 体验地址:7fsqr8-5173.csb.app 实现原理 要实现一个吸附在键盘上的 input,可以分为以下步骤: 监听键盘高度...
继续阅读 »

实现效果


话不多说,先上效果和 demo 地址:



demo 地址:codesandbox.io/p/devbox/ke…

体验地址:7fsqr8-5173.csb.app



666.gif


实现原理


要实现一个吸附在键盘上的 input,可以分为以下步骤:



  1. 监听键盘高度的变化

  2. 获取「键盘顶部距离视口顶部的高度」

  3. 设置 input 的位置


第一步:监听监听键盘键盘高度的变化


要监听键盘高度的变化,我们得先看看在键盘展开或收起的时候,分别会触发哪些浏览器事件:



  • iOS 和部分 Android 浏览器


    展开:键盘展示时会依次触发 visualViewport resize -> focusin -> visualViewport scroll,部分情况下手动调用 input.focus 不触发 focusin


    收起:键盘收起时会依次触发 visualViewport resize -> focusout -> visualViewport scroll


  • 其他 Android 浏览器


    展开:键盘展示的时候会触发一段连续的 window resize,约过 200 毫秒稳定


    收起:键盘收起的时候会触发一段连续的 window resize,约过 200 毫秒稳定,但是部分手机上有些异常的 case:键盘收起时 viewport 会先变小,然后变大,最后再变小



总结两者来看,我们要监听键盘高度的变化,可以添加以下监听事件:


if (window.visualViewport) {
 window.visualViewport?.addEventListener("resize", listener);
 window.visualViewport?.addEventListener("scroll", listener);
} else {
 window.addEventListener("resize", listener);
}

window.addEventListener("focusin", listener);
window.addEventListener("focusout", listener);

===========================


📚 题外话: 获取键盘展开和收起状态


===========================


在实际业务中,获取键盘展开和收起的状态,同样很常见,要完成状态的判断,我们可以设定以下规则:


判断键盘展开:当 visualViewport resize/window.reszie、visualViewport scroll、focusin 任意一个事件触发时,如果高度减少,并且屏幕减少的高度(键盘高度)大于 200px 时,判断键盘为展开状态(由于 focusin 部分情况下不触发,所以还需要监听其他事件辅助判断键盘是否为展开状态)


判断键盘收起:当 visualViewport resize/window.reszie、visualViewport scroll、focusout 任意一个事件触发时,如果高度增加,并且屏幕减少的高度(键盘高度)小于 200px,判断键盘为收起状态


// 获取当前视口高度
const height = window.visualViewport
? window.visualViewport.height
: window.innerHeight;

// 获取视口增量:视口高度 - 上次获取的视口高度
const diffHeight = height - lastWinHeight;

// 获取键盘高度:默认屏幕高度 - 当前视口高度
const keyboardHeight = DEFAULT_HEIGHT - height;

// 如果高度减少,且键盘高度大于 200,则视为键盘弹起
if (diffHeight < 0 && keyboardHeight > 200) {
   onKeyboardShow();
} else if (diff > 0) {
   onKeyboardHide();
}

同时,为了避免 “收起时 viewport 会先变小,然后变大,最后再变小” 这种情况,我们需要在展开收起状态发生变化的时候加一个200毫秒的防抖,避免键盘状态频繁改变执行“收起 -> 展开 -> 收起”的逻辑


let canChangeStatus = true;

function onKeyboardShow({ height, top }) {
   if (canChangeStatus) {
     canChangeStatus = false;
     setTimeout(() => {
callback();
        canChangeStatus = true;
    }, 200);
  }
}

第二步:获取键盘顶部距离视口顶部的高度


在 safari 浏览器或者部分安卓手机的浏览器中,在点击输入框的时候,可以看到页面会滚动到输入框所在位置(这是想让被软键盘遮挡的部分展示出来),这个时候,其实是触发了虚拟视口 visualViewport 的 scroll 事件,让页面整体往上顶,即使是 fixed 定位也不例外,因此要获取「键盘顶部距离视口顶部的高度」,我们需要进行如下计算:


键盘顶部距离视口顶部的高度 = 视口当前的高度 + 视口滚动上去高度


// 获取当前视口高度
const height = window.visualViewport ? window.visualViewport.height : window.innerHeight;
// 获取视口滚动高度
const viewportScrollTop = window.visualViewport?.pageTop || 0;
// 获取键盘顶部距离视口顶部的距离,这里是关键
const keyboardTop = height + viewportScrollTop;

第三步:设置 input 的位置


我们先设置 input 的 css 样式


input {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 50px;
transition: all .3s;
}

然后再动态调整 input 的 translateY,让 input 可以配合键盘移动,为了保证 input 能够露出,还需要用上一步计算好的「键盘距离页面顶部高度」再减去「元素高度」,从而获得「当前元素的位移」:


当前元素的位移 = 键盘距离页面顶部高度 - 元素高度


// input 的 position 为 absolute、top 为 0
keyboardObserver.on(KeyboardEvent.PositionChange, ({ top }) => {
 input.style.tranform = `translateY(${top - input.clientHeight}px)`;
});

实现原理是不是很简单?不如来看看完整代码吧~


完整代码


import EventEmitter from "eventemitter3";

// 默认屏幕高度
const DEFAULT_HEIGHT = window.innerHeight;
const MIN_KEYBOARD_HEIGHT = 200;

// 键盘事件
export enum KeyboardEvent {
 Show = "Show",
 Hide = "Hide",
 PositionChange = "PositionChange",
}

interface KeyboardInfo {
 height: number;
 top: number;
}

class KeyboardObserver extends EventEmitter {
 inited = false;
 lastWinHeight = DEFAULT_HEIGHT;
 canChangeStatus = true;

 _unbind = () => {};

 // 键盘初始化
 init() {
   if (this.inited) {
     return;
  }
   
   const listener = () => this.adjustPos();

   if (window.visualViewport) {
     window.visualViewport?.addEventListener("resize", listener);
     window.visualViewport?.addEventListener("scroll", listener);
  } else {
     window.addEventListener("resize", listener);
  }

   window.addEventListener("focusin", listener);
   window.addEventListener("focusout", listener);

   this._unbind = () => {
     if (window.visualViewport) {
       window.visualViewport?.removeEventListener("resize", listener);
       window.visualViewport?.removeEventListener("scroll", listener);
    } else {
       window.removeEventListener("resize", listener);
    }

     window.removeEventListener("focusin", listener);
     window.removeEventListener("focusout", listener);
  };
   
   this.inited = true;
}

// 解绑事件
 unbind() {
   this._unbind();
this.inited = false;
}

 // 调整键盘位置
 adjustPos() {
   // 获取当前视口高度
   const height = window.visualViewport
     ? window.visualViewport.height
    : window.innerHeight;

   // 获取键盘高度
   const keyboardHeight = DEFAULT_HEIGHT - height;
   
   // 获取键盘顶部距离视口顶部的距离
   const top = height + (window.visualViewport?.pageTop || 0);

   this.emit(KeyboardEvent.PositionChange, { top });

   // 与上一次计算的屏幕高度的差值
   const diffHeight = height - this.lastWinHeight;

   this.lastWinHeight = height;

   // 如果高度减少,且减少高度大于 200,则视为键盘弹起
   if (diffHeight < 0 && keyboardHeight > MIN_KEYBOARD_HEIGHT) {
     this.onKeyboardShow({ height: keyboardHeight, top });
  } else if (diffHeight > 0) {
     this.onKeyboardHide({ height: keyboardHeight, top });
  }
}

 onKeyboardShow({ height, top }: KeyboardInfo) {
   if (this.canChangeStatus) {
     this.emit(KeyboardEvent.Show, { height, top });
     this.canChangeStatus = false;
     this.setStatus();
  }
}

 onKeyboardHide({ height, top }: KeyboardInfo) {
   if (this.canChangeStatus) {
     this.emit(KeyboardEvent.Hide, { height, top });
     this.canChangeStatus = false;
     this.setStatus();
  }
}

 setStatus() {
   const timer = setTimeout(() => {
     clearTimeout(timer);
     this.canChangeStatus = true;
  }, 300);
}
}

const keyboardObserver = new KeyboardObserver();

export default keyboardObserver;


使用:


keyboardObserver.on(KeyboardEvent.PositionChange, ({ top }) => {
 input.style.tranform = `translateY(${top - input.clientHeight}px)`;
});

作者:DAHUIAAAAAA
来源:juejin.cn/post/7338335869709385780
收起阅读 »

看完zustand源码后,我的TypeScript水平突飞猛进。

web
前言 过年期间在家里没事,把zustand的源码看了一遍,看完后我最大的收获就是ts水平突飞猛进,再去刷那些类型体操题目就变得简单了,下面和大家分享一下zustand库是怎么定义ts类型的。 ts类型推断 个人认为ts最大的作用有两个,一个是类型约束,另外一个...
继续阅读 »

前言


过年期间在家里没事,把zustand的源码看了一遍,看完后我最大的收获就是ts水平突飞猛进,再去刷那些类型体操题目就变得简单了,下面和大家分享一下zustand库是怎么定义ts类型的。


ts类型推断


个人认为ts最大的作用有两个,一个是类型约束,另外一个是类型推断。



  • 类型约束也叫类型安全,在编译阶段就能发现语法错误,可以有效减少低级错误。

  • 类型推断,当你没有标明变量的类型时,编译器会根据一些简单的规则来推断你定义的变量的类型


这一篇主要和大家分享类型推断,类型推断主要有以下几种情况。


根据变量的值自动推导类型


image.png


image.png


函数返回值自动推断


image.png


函数中如果有条件分支,推导出来的返回值类型是所有分支的返回值类型的联合类型


image.png


ts的类型推导方式是懒推导,也就是说不会实际执行代码。


image.png


上图中如果实际执行了,c的类型是能确认为null的。


使用范型推导


image.png


可以看到按照上面写法,对象合并推导不出来,如果能推导出来u3应该等于 {name: string, age: number}


这时候我们可以借助范型来推导


image.png


可以给上面代码简写为这样,编辑器也能推导出来


image.png


实战


实现pick方法


从一个对象中,返回指定的属性名称。


image.png


上面代码中定义了两个范型T和U,T表示对象,U被限定为T的属性名(U extends keyof T),返回值的类型为{[K in U]: T[K]},in的作用就是遍历U这个数组。


image.png


可以看到数组元素被限制了只能是user对象里的key


image.png


image.png


也正确的推导出来了


实现useRequest


先看一个例子


import { useEffect, useState } from 'react';

// 模拟请求接口,返回用户列表
function getUsers(): Promise<{ name: string }[]> {
return new Promise(resolve => {
setTimeout(() => {
resolve([
{
name: 'tom',
},
{
name: 'jack',
},
]);
}, 1000);
})
}

const App = () => {

const [loading, setLoading] = useState(true);
const [users, setUsers] = useState<Awaited<ReturnType<typeof getUsers>>>([]);
const [error, setError] = useState(false);

useEffect(() => {
setLoading(true);
getUsers().then((res) => {
setUsers(res);
}).catch(() => {
setError(true);
}).finally(() => {
setLoading(false);
})
}, []);

if (loading) {
return (
<div>loading...</div>
)
}

if (error) {
return (
<div>error</div>
)
}

return (
<div
>

{users.map(u => (
<div key={u.name}>{u.name}</div>
))}
</div>

);
};

export default App;

上面这个例子实现了从后端请求用户列表,然后渲染出来。为了提高用户体验,在加载数据时,加了一个loading,当请求出错时,告诉用户请求失败。


代码比较简单我就不一一讲解了,有行代码需要注意一下。


 const [users, setUsers] = useState<Awaited<ReturnType<typeof getUsers>>>([]);


  • typeof getUsers 获取getUsers函数类型

  • ReturnType 获取某个函数的返回值

  • Awaited 如果函数返回值为Promise,这个可以获取到最终的值类型。


image.png


image.png


可以看到,正确的获取到了getUsers函数的返回值类型。


然而一个很简单的功能需要写那么多代码,肯定是不合理的,那么我们给简化一下。目前市面上已经有不少库来解决这个问题了,比如react-query或ahooks库里的useRequest,都可以解决这个问题,我这里分享的不是具体代码实现,而是怎么写ts。


封装useRequest


import { useEffect, useState } from 'react';

export function useRequest<T extends () => Promise<unknown>>(
fn: T,
): {
loading: boolean;
error: boolean;
data: Awaited<ReturnType<T>>;
} {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [data, setData] = useState<any>();

useEffect(() => {
setLoading(true);
fn().then(res => {
setData(res);
}).catch(() => {
setError(true);
}).finally(() => {
setLoading(false);
});
}, [fn])

return {
loading,
error,
data,
};
}

改造app.tsx文件,使用useRequest


import { useRequest } from './useRequest';

// 模拟请求接口,返回用户列表
function getUsers(): Promise<{ name: string }[]> {
return new Promise(resolve => {
setTimeout(() => {
resolve([
{
name: 'tom',
},
{
name: 'jack',
},
]);
}, 1000);
})
}

const App = () => {

const { loading, data: users, error } = useRequest(getUsers);

if (loading) {
return (
<div>loading...</div>
)
}

if (error) {
return (
<div>error</div>
)
}

return (
<div
>

{users.map(u => (
<div
key={u.name}
>

{u.name}
</div>
))}
</div>

);
};

export default App;

对比最开始的代码,是不是简单了很多。


useRequest.tsx代码也很简单,首先使用了范型限制fn只能是一个函数,返回值还必须是Promise。这个hooks返回值loading和error就不说了,主要是data,这个data要求和传进来的方法返回值一致,前面说过,可以使用Awaited<ReturnType<T>>获取函数的返回类型。


image.png


但是上面代码可能会导致bug,看下面代码,如果请求失败,users应该是空的,直接这样使用就会报错了。改造一下,当error为false的时候data为正常类型,error为true的时候data为null,这里可以使用联合类型。


image.png


image.png


image.png


image.png


加了一个判断后,下面就不会报错了。ts在某些时候,真的可以避免一些低级错误,我相信如果没有这个限制,肯定有人在写代码的时候不加判断直接用users。


如果请求接口的函数需要参数怎么办,下面来实现一下。


image.png


使用Parameters获取传入函数的参数类型


image.png


image.png


多个参数也是支持的


image.png


zustand


zustand是一个react状态管理库,使用起来比较简单没啥心智负担,所以我一直在用。


上面带着大家入门了ts的类型推断,下面给大家分享一下zustand的ts定义。我看完zustand源码后,发现这个库的ts定义比功能实现还复杂,这里我只给大家分享ts,具体实现掘金已经有很多大佬写过了,我就不分享了。


先从一个最简单的例子开始


import { create } from 'zustand';

interface State {
count: number;
}

interface Action {
inc: () => void;
}

export const useStore = create<State & Action>((set) => ({
count: 1,
inc: () => set((state) => ({count: state.count + 1})),
}));

image.png


create方法的定义


type Create = {
<T, Mos extends [StoreMutatorIdentifier, unknown][] = []>(
initializer: StateCreator<T, [], Mos>,
): UseBoundStore<Mutate<StoreApi<T>, Mos>>
<T>(): <Mos extends [StoreMutatorIdentifier, unknown][] = []>(
initializer: StateCreator<T, [], Mos>,
) =>
UseBoundStore<Mutate<StoreApi<T>, Mos>>
/**
* @deprecated Use `useStore` hook to bind store
*/

<S extends StoreApi<unknown>>(store: S): UseBoundStore<S>
}

可以看到create有三个重载方法,最后一个废弃不用了,上面例子使用的是第一个方法,第二个重载方法可以这样使用。


image.png


这样做的意义和中间件有关系,这个后面再说。


 <T, Mos extends [StoreMutatorIdentifier, unknown][] = []>(
initializer: StateCreator<T, [], Mos>,
): UseBoundStore<Mutate<StoreApi<T>, Mos>>

我们先看第一个方法,定义了两个范型,T表示返回值类型,对应上面例子中create<State & Action>,Mos是给中间件用的,这个等会再说。


create方法的参数initializer定义


initializer: StateCreator<T, [], Mos>

参数initializer对应的类型是StateCreator


export type StateCreator<
T,
Mis extends [StoreMutatorIdentifier, unknown][] = [],
Mos extends [StoreMutatorIdentifier, unknown][] = [],
U = T,
> = ((
setState: Get<Mutate<StoreApi<T>, Mis>, 'setState', never>,
getState: Get<Mutate<StoreApi<T>, Mis>, 'getState', never>,
store: Mutate<StoreApi<T>, Mis>,
) =>
U) & { $$storeMutators?: Mos }

StateCreator定义了4个范型,T还是表示返回值类型,其余三个暂时用不到。


((
setState: Get<Mutate<StoreApi<T>, Mis>, 'setState', never>,
getState: Get<Mutate<StoreApi<T>, Mis>, 'getState', never>,
store: Mutate<StoreApi<T>, Mis>,
) =>
U) & { $$storeMutators?: Mos }

这段ts表明,initializer是一个函数,并且有三个参数,& { $$storeMutators?: Mos }表示交叉类型,也就是说这个函数可能会有$$storeMutators属性。


举个例子:


image.png


因为函数上没有$$name属性,所以报错了,下面给函数加上属性就可以了


image.png


setState: Get<Mutate<StoreApi<T>, Mis>, 'setState', never>

type Get<T, K, F> = K extends keyof T ? T[K] : F

定义了一个Get类型,表示K如果在T对象的可以中,则返回K属性对应的值类型,如果不在返回F。


看个例子


image.png


因为T对象中没有count属性,所以返回never,never表示不存在的类型。


image.png


因为T对象中有name属性,所以返回name字段对应的类型string。


Mutate<StoreApi<T>, Mis>

Mutate这个类型很复杂,是为了解决中间件类型提示出现的,后面再说,没有使用中间件的情况下可以把这段代码简化为StoreApi<T>


export interface StoreApi<T> {
setState: SetStateInternal<T>
getState: () => T
getInitialState: () => T
subscribe: (listener: (state: T, prevState: T) => void) => () => void
/**
* @deprecated Use `unsubscribe` returned by `subscribe`
*/

destroy: () => void
}

type SetStateInternal<T> = {
_(
partial: T | Partial<T> | { _(state: T): T | Partial<T> }['_'],
replace?: boolean | undefined,
): void
}['_']

到这里我们就看到前面例子中set的定义了,set方法有两个参数,第一个参数可以是前面范型定义的一个对象,可以是对象中的一些属性,也可以是一个函数。第二个属性表示是否覆盖整个对象。


这里的["_"]让我有点迷惑,不知道有啥作用,也可以写成下面这样。


type SetStateInternal<T> = (
partial: T | Partial<T> | { _(state: T): T | Partial<T> }['_'],
replace?: boolean | undefined,
) =>
void

set竟然可以直接设置值,看完源码后,我才知道可以这样用,一般我都是用函数,然后使用函数返回值更新值。


image.png


create方法的返回值类型定义


UseBoundStore<Mutate<StoreApi<T>, Mos>>

上面说了没有中间件的情况下,可以简化为:UseBoundStore<StoreApi<T>>


export type UseBoundStore<S extends WithReact<ReadonlyStoreApi<unknown>>> = {
(): ExtractState<S>
<U>(selector: (state: ExtractState<S>) => U): U
/**
* @deprecated Use `createWithEqualityFn` from 'zustand/traditional'
*/

<U>(
selector: (state: ExtractState<S>) => U,
equalityFn: (a: U, b: U) => boolean,
): U
} & S

type ExtractState<S> = S extends { getState: () => infer T } ? T : never

create返回值是一个函数,这个函数有三个重载方法,并且方法上还有一些属性,(& S)表示这些属性。


第一个重载方法表示没有参数时直接返回ExtractState<S>,ExtractState其实就是获取S对象中getState的返回值类型。


image.png


第二个重载方法有一个参数,可以返回自定义属性。


image.png


第三个重载方法废弃了,就不说了。


image.png


上图中useStore之所以有setState和getState等属性,就是上面& S的作用。


create第二个重载方法的作用


zustand支持使用中间件和编写中间件,看完官方持久化persist中间件的ts定义后,直接把我CPU干烧了,太复杂。


先看一下前面说过的,为啥create方法加了一个重载方法。


<T>(): <Mos extends [StoreMutatorIdentifier, unknown][] = []>(
initializer: StateCreator<T, [], Mos>,
) =>
Mutate<StoreApi<T>, Mos>

这个重载方法主要是给使用了中间件的情况下使用的,看一个例子。


image.png


image.png


上面例子中使用了官方提供的持久化中间件,如果使用第一种重载方法会报错,使用第二种就会报错,下面我们来分析一下为啥会这样。


先给上面代码简化一下


function a() {
console.log('hello');
}
type Fn = {
<T, U extends any[] = []>(name: U): T;
<T>(): <U extends any[] = []>(name: U) => T;
};

const b = a as Fn;

b(['hello'])

image.png


这时候我们调用第一个重载方法没有报错,加了范型后就报错了。


image.png


这是因为不使用范型的时候,编辑器会自动推导类型,如果传了一个范型,那么 U extends any[] = []会强制使用默认值[],所以传['hello']会报错。传[]就不会报错了。第二个重载方法的意义就是给两个范型拆开,这样设置了T不会应用U。


image.png


回到上面问题再看一下create方法的参数类型


image.png


因为传了一个范型约束,所以第二个参数使用默认值[]了


image.png


然而persist中间件返回值类型Mos不为[],所以报错了


image.png


针对这个问题,有两个解决方案


第一个方案是把范型去掉,把范型写在persist上。


image.png


第二个方案是用第二个重载方法


image.png


中间件返回值的类型定义


前面有个东西没说,create返回值里的Mutate<StoreApi<T>, Mos>是干嘛用的,先看下代码


export type Mutate<S, Ms> = number extends Ms['length' & keyof Ms]
? S
: Ms extends []
? S
: Ms extends [[infer Mi, infer Ma], ...infer Mrs]
? Mutate<StoreMutators<S, Ma>[Mi & StoreMutatorIdentifier], Mrs>
: never

第一次看这个的时候,直接给我看懵了,这是啥,怎么还有递归,然后恶补了一下ts类型体操知识,顺便把github上类型体操题目刷了一下,然后再回来看这个类型体操就很简单了。


先写一个简单的例子让大家入门一下类型体操,合并数组中的对象类型。



// 写一个类型给a转换为{name: string, age: number}

type a = [{ name: string }, { age: number }];

// infer 可以理解为定义一个变量,
// infer F 表示取出数组中第一个元素,
// ...infer R表示把数组中剩余的元素放到R中,
// S & F 表示把S和F合并,
// C<R, S & F>递归剩余元素也合并S中
// 最后返回S

type C<T extends any[] = [], S = {}> = T extends [infer F, ...infer R] ? C<R, S & F> : S


image.png


理解了这个,那上面Mutate也就好理解了。


number extends Ms['length' & keyof Ms] ? S : : Ms extends [] ? S : ...这段表示如果Ms的类型为any[]则返回S,如果Ms为[]也返回S。


正常我们没有使用中间件的时候,Ms是[],所以直接返回S也就是StoreApi<State & Action>


当使用中间件的时候,我们先看下persist返回值类型。


image.png


persist中间件源码中的类型定义


image.png


根据create方法initializer参数定义Mos被自动推导成了[["zustand/persist", State & Action]],Mos对应Mutate里的Ms。


image.png


Ms extends [[infer Mi, infer Ma], ...infer Mrs]

对比上面的Ms类型,Mi为"zustand/persist",Ma为State & Action,Mrs为[]。


Mutate<StoreMutators<S, Ma>[Mi & StoreMutatorIdentifier], Mrs>

接下来开始递归了,StoreMutators<S, Ma>[Mi & StoreMutatorIdentifier],把Mi替换成"zustand/persist",变成StoreMutators<S, Ma>["zustand/persist" & StoreMutatorIdentifier]


image.png


最开始这段代码让很迷惑,因为StoreMutators在项目里定义的是空对象,上面这种写法取不到任何东西。然后我去persist中间件源码里看了一下,原来在persist里给StoreMutators扩展了。


image.png


这几个类型定义可以简单理解为是给Mutate里S添加了persist属性。而persist属性有下面这些方法。


image.png


type Write<T, U> = Omit<T, keyof U> & U

Write表示合并两个类型,如果有重复的key,用后面的覆盖前面。


image.png


可以看到两个对象合并了key,并且name被覆盖成了number类型。


所以当使用persist中间件时,Mutate<StoreApi<T>, Mos>最终类型为StoreApi<T> & { persist: { ... } },所以我们能create返回的值里调用persist里的方法。


image.png


自定义中间件


模拟per中间件,自己也写一个,没有写具体实现,只写了类型定义。


import { StateCreator, StoreMutatorIdentifier } from 'zustand';

type Test = <
T,
Mps extends [StoreMutatorIdentifier, unknown][] = [],
Mcs extends [StoreMutatorIdentifier, unknown][] = [],
U = T
>(
initializer: StateCreator<T, [...Mps, ['test', unknown]], Mcs>
) =>
StateCreator<T, Mps, [['test', U], ...Mcs]>;

type Write<T, U> = Omit<T, keyof U> & U;

declare module 'zustand' {
interface StoreMutators<S, A> {
test: Write<S, {test: {log: () => void}}>;
}
}

function a() {
console.log(444);
}

export const test = a as unknown as Test;

image.png


在中间件中也可以重写setState方法


image.png


image.png


总结


到此终于结束了,最复杂的create方法讲完了,其他都是简单的,就不分享了。说实话ts类型定义比代码实现难理解多了,也有可能是我开始的水平不够,所以看起来比较费劲。为了看懂这些ts,我把ts体操类型刷了一遍,现在我感觉自己ts提升了很多。找个时间看一下zod的源码,学习一下它的ts定义。


我看一些ts教程的文章下面,很多人吐槽说TypeScript没有用,个人觉得公司里的业务代码或者个人小项目确实可以不用,但是如果你要开发一个开源框架或组件库,我觉得ts或jsdoc还是有必要的,类型推断和准确的代码提示可以方便用户使用。


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

停止使用 localStorage !

web
medium 优秀文章翻译,也增加自己的一些使用体验。 非标题党!本文标题很明确的想表达对 localStorage 的不推荐。 localStorage 的弊病 2009年 localStorage 诞生,简单来说就是 5MB 的字符串格式的存储。让我拆...
继续阅读 »

medium 优秀文章翻译,也增加自己的一些使用体验。




非标题党!本文标题很明确的想表达对 localStorage 的不推荐。


localStorage 的弊病


2009年 localStorage 诞生,简单来说就是 5MB 的字符串格式的存储。让我拆解下定义中的关键点。



  • 字符串集合:它只能存储字符串。如果你想要存储或者检索其他格式数据,你必须进行序列化和反序列化。假如你忘记了这一点,你将会遇到各种各样的网络Bug。例如当你存储 true 和 false 时,你还要注意处理 null、undefined、空字符串等潜在返回值。

  • 非结构化数据:JavaScript 结构化克隆算法用于复制复杂的 JavaScript 对象的算法。它通过递归输入对象来构建克隆,同时保持先前访问过的引用的映射,以避免无限遍历循环。postMessage, WebWorkers, IndexedDB, Caches API, BroadcastChannel, Channel Messaging API, MessagePortHistory API 都是采用的结构化数据! 采用该结构化数据就是为了解决序列化和反序列化 JSON 带来的问题。很遗憾的是 localStorage 并没有更新该特性,并且未来也没有推进的计划。

  • 安全妥协:你永远不会在任何持久化存储中保存敏感数据,但是开发者依旧会在 localStorage 中保存 Session IDs,JWTs、API keys 等敏感信息。这是个常见的安全隐患,你可以在 window.localStorage 中随意查阅。




  • 性能:localStorage 的性能相对于之前已经有了很好的优化,但是对于超量事务的并发应用程序来说,其性能瓶颈一样要重点考虑。

  • 大小限制:localStorage 有 5MB 的限制,而且可能被浏览器删除。对于现代应用来说,5MB 是很小的容量了,几乎很难存储任何媒体数据。它并不是一成不变的,在一些场景下,浏览器也会主动删除部分持久化存储中的数据,这是个通病,这也是何为常规日志上报会有数据丢失的原因之一。因此有甚至需要主动去管理这部分的数据的生命周期,尽管没人告诉你要做这个。还有个点就是存储剩余容量是无法查询的,因此你无法确定操作是否会以为容量达到上限而无法完整写入。

  • WebWorker 无法访问:localStorage 并不是面向未来的API,也不是适用于并放进程中。

  • 非原子化:localStorage 不保证并行操作中的原子性,也没有任何锁能够保证正在写入的数据不会被覆盖。

  • 无数据隔离:localStorage 仅仅是个字符串的对象,应用下所有数据都被混淆在一起,无法进行数据隔离。

  • 无事务:常规数据库都会支持事务操作,也没办法进行分组。所有操作都是同步的、非独立的、无锁定的。

  • 同步阻塞操作:localStorage 不是异步的,它会阻塞主进程。频繁的读取甚至会影响动画的流畅性,在移动端设备最为明显


WebSQL 何去何从?



WebSQL 目标是为 Web 提供一个简单的 SQL 数据库接口,但是浏览器支持程度确实不好。


你可能好奇它为啥会被抛弃?



  • 单一浏览器厂商实现:WebSQL 主要是 Chrome 和 Safari 实现的,由于 Mozilla 和 Microsoft 不支持,业内开发者几乎不采用它。

  • 非 W3C 标准:这个是至关重要的,W3C 在 2010 年将它从标准中移除了。

  • 与 IndexedDB 的竞争:IndexedDB 主要获得更多的关注,且被设计成标准的跨浏览器解决方案了。

  • 安全问题:一些开发人员和安全专家对WebSQL的安全性表示担忧。他们在很多方面都持怀疑态度,包括缺乏权限控制和SOL风格的漏洞。


最终 IndexedDB 成为浏览器存储的标准,被评价为强壮的、跨浏览器友好。但是大多数经验丰富的开发者都视其为瘟疫,那这种推荐又有什么意义呢?


Cookies 又如何呢?


cookie是1994年由网景公司的网络浏览器程序员卢·蒙图利(Lou Montulli)创建的。


本篇文章的标题实际应该是“停止使用 localStorage 和 Cookie”,但是又不全对,我们应该使用安全的 cookies。



  • 4KB体积限制

  • 默认会被请求传输:非跨域 HTTP 请求会携带 cookie 数据,假如数据不需要被每个请求传输,就会带来带宽开销,导致网络加载速度变慢。

  • 安全隐患:cookie更容易受到XSS的攻击。由于cookie会自动包含在对域的每个请求中,因此它们可能成为恶意脚本的目标。

  • 过期:cookie被设计为在给定日期过期。


IndexedDB 呢?



  • 更好的性能:IndexedDB 操作是异步的,不和阻塞主进程。API 被设计为了事件驱动的。

  • 充足的存储配额:与localStorage的5MB上限相比,IndexedDB提供了更大的存储配额(取决于浏览器、操作系统和可用存储。

  • 可靠且结构化数据:Indexed 减少了强制类型转换,并且采用结构化克隆算法,保证数据的完整。



但是你大概并不想直接使用 IndexedDB。


IndexedDB 大概是避免过多依赖的例外。将 IndexedDB 视为后台数据库,你需要的是 ORM 或者 数据库处理程序来进行查询的管理。由于 IndexedDB 糟糕的 API 设计,你更想要一个 IndexedDB 库。



  • 基于 Promise

  • 更好使用

  • 减少样板代码

  • 关注于更关键的部分


本文比较推荐 dexie.js 和 idb 两个针对 indexedDB 的封装库,其中 idb 的体积是最小的,仅仅 1.19 KB,并不会给程序带来负担。


总结


本文的口号虽然是“停止使用 localStorage”,但是在这个时代实际是难以实现的,但是我们确实应该朝着这个目标出发。


未来开发者应该从 Promise()、async/await 和结构化数据中或者更加清晰且有意义的知识,而不应该关注为何数字“0”在条件语句中会成为“true”,而不应该愤怒与客户获得 null 的返回值。


由于 IndexedDB 的性能优势,你存储各种类型的数据,甚至可以使用游标来遍历所有对象。基于这种技术,你甚至可以构建客户端的搜索引擎,而不会像 localStorage 那样影响动画渲染。



IndexedDB is commonly described as “low-level” . There’s absolutely nothing low-level about IndexedDB, it’s just an API with an old-style and unfriendly syntax. But that doesn't negate it’s underlying capabilities, hence common library usage.



你并不需要直接使用 API,一个体积很小的封装库可以帮助你规避这些。


作者:三省法师
来源:juejin.cn/post/7338422591518457871
收起阅读 »

在高德地图上使用threejs,tweenjs,引入外部模型,实现动画效果

web
init展示地图展示地图-入门教程-地图 JS API 2.0|高德地图API (amap.com) 在vue3项目中使用新版高德地图_vue3使用高德地图-CSDN博客踩坑:KEY异常,错误信息:USERKEY_PLAT_NOMATCH ...
继续阅读 »

init

展示地图

展示地图-入门教程-地图 JS API 2.0|高德地图API (amap.com) 在vue3项目中使用新版高德地图_vue3使用高德地图-CSDN博客

踩坑:KEY异常,错误信息:USERKEY_PLAT_NOMATCH 原因:申请的key和使用的服务不匹配,展示地图使用JS-API的key,地理信息解析是web服务

  1. npm包安装
npm i @amap/amap-jsapi-loader --save
  1. 引入
import AMapLoader from '@amap/amap-jsapi-loader';
  1. 初始化
var AMap, map
window._AMapSecurityConfig = {
securityJsCode: "d0543d6e1c9f40e8272aa30af54e8ded",
};
AMapLoader.load({
key: "d0543d6e1c9f40e8272aa30af54e8ded", //申请好的Web端开发者key,调用 load 时必填
version: "2.0", //指定要加载的 JS API 的版本,缺省时默认为 1.4.15
})
.then((res) => {
//JS API 加载完成后获取AMap对象
AMap = res
initMap()
})
.catch((e) => {
console.error(e); //加载错误提示
});
function initMap () {
map = new AMap.Map("map", {
viewMode: '2D', //默认使用 2D 模式
zoom: 11, //地图级别
center: [116.397428, 39.90923], //地图中心点,背景天安门为例
});
}

此时,一个平平无奇的高德地图跃然纸上

Pasted image 20240221095238.png

结合THREE

自定义图层-GLCustomLayer 结合 THREE-自有数据图层-示例中心-JS API 2.0 示例 | 高德地图API (amap.com)

环境搭建:

  1. 高德地图环境搭建看上一章
  2. three环境搭建 :一定要下载对应版本的three,在官网示例中可以查看其引入的three版本
npm i three@0.142 # 24/2/1日数据

入门小案例-引入外部模型

照搬官网案例就行

我这里做了些许改动

  1. 引入外部模型猴头
  2. 创建mesh,参考官网案例
  3. 添加移动功能,将猴头移动入mesh中

效果如图

Pasted image 20240222161502.png 代码如下:

Pasted image 20240222161509.png

  1. 引入
import AMapLoader from '@amap/amap-jsapi-loader'
import * as THREE from 'three';
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
import {reactive } from 'vue'
  1. 地图准备
// step map 
var AMap, map

window._AMapSecurityConfig = {
securityJsCode: "d0543d6e1c9f40e8272aa30af54e8ded",
};
AMapLoader.load({
key: "d0543d6e1c9f40e8272aa30af54e8ded", //申请好的Web端开发者key,调用 load 时必填
version: "2.0", //指定要加载的 JS API 的版本,缺省时默认为 1.4.15
})
.then((res) => {
//JS API 加载完成后获取AMap对象
AMap = res
createMap() // 创建地图
createThree() // 创建three
})
.catch((e) => {
console.error(e); //加载错误提示
});
function createMap () {
map = new AMap.Map("map", {
center: [116.54, 39.79],
zooms: [2, 20],
zoom: 14,
viewMode: '3D',
pitch: 50,
});
}

  1. three 准备 核心内容是创建GL图层,里面的 render 基本没什么变化
// step three init
var camera, renderer, scene
var model, monkey, mesh
// 数据转换工具
var customCoords
// 测试用数据
var data
function createThree () {
customCoords = map.customCoords;
data = customCoords.lngLatsToCoords([
[116.52, 39.79],
[116.54, 39.79],
[116.56, 39.79],
])
// 创建 GL 图层
var gllayer = new AMap.GLCustomLayer({
// 图层的层级
zIndex: 10,
// 初始化的操作,创建图层过程中执行一次。
init: (gl) => {
initThree(gl)
},
render: () => {
// 这里必须执行!!重新设置 three 的 gl 上下文状态。
renderer.resetState();
// 重新设置图层的渲染中心点,将模型等物体的渲染中心点重置
// 否则和 LOCA 可视化等多个图层能力使用的时候会出现物体位置偏移的问题
customCoords.setCenter([116.52, 39.79]);
var { near, far, fov, up, lookAt, position } =
customCoords.getCameraParams();

// 这里的顺序不能颠倒,否则可能会出现绘制卡顿的效果。
camera.near = near;
camera.far = far;
camera.fov = fov;
camera.position.set(...position);
camera.up.set(...up);
camera.lookAt(...lookAt);
camera.updateProjectionMatrix();

renderer.render(scene, camera);

// 这里必须执行!!重新设置 three 的 gl 上下文状态。
renderer.resetState();
},
});
map.add(gllayer)
window.addEventListener('resize', onWindowResize);
}
function onWindowResize () {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}

然后我们来看initThree

function initThree (gl) {
// 这里我们的地图模式是 3D,所以创建一个透视相机,相机的参数初始化可以随意设置,因为在 render 函数中,每一帧都需要同步相机参数,因此这里变得不那么重要。
// 如果你需要 2D 地图(viewMode: '2D'),那么你需要创建一个正交相机
camera = new THREE.PerspectiveCamera(
60,
window.innerWidth / window.innerHeight,
100,
1 << 30
);

renderer = new THREE.WebGLRenderer({
context: gl, // 地图的 gl 上下文
// alpha: true,
// antialias: true,
// canvas: gl.canvas,
});

// 自动清空画布这里必须设置为 false,否则地图底图将无法显示
renderer.autoClear = false;
scene = new THREE.Scene();

// 环境光照和平行光
var aLight = new THREE.AmbientLight(0xffffff, 3);
var dLight = new THREE.DirectionalLight(0xffffff, 10);
dLight.position.set(1000, -100, 900);
scene.add(dLight);
scene.add(aLight);
// 加载模型、mesh
addModel()
addMesh()
}

以上内容也基本不变,但最后加载模型、mesh按照你需要加载的物体变化。

加载外部模型

function addModel () {
const glftLoader = new GLTFLoader()
glftLoader.load("/public/models/monkeyAndCube.glb", function (gltf) {
model = gltf.scene
model.traverse((child) => {
child.scale.set(500, 500, 500); // 放大模型
child.rotation.x = 0.5 * Math.PI;
child.position.z = 0.8;
console.log(child.name)
if (child.name === "monkey") { monkey = child }
})
monkey.position.set(data[0][0], data[0][1], 500); // 设置位置
scene.add(monkey)
})
}

加载mesh

function addMesh () {
// 这里可以使用 three 的各种材质
var mat = new THREE.MeshLambertMaterial({
side: THREE.DoubleSide,
color: 0x1e2f97,
transparent: true,
opacity: .4,
depthWrite: false
})
var geo = new THREE.BoxBufferGeometry(1200, 1200, 1200);
const d = data[2];
mesh = new THREE.Mesh(geo, mat);
mesh.position.set(d[0], d[1], 500);
scene.add(mesh);
animate()
}
// 动画
function animate () {
mesh.rotateZ((1 / 180) * Math.PI);
map.render();
requestAnimationFrame(animate);
}

移动猴头! 记得自己给这个函数加个按钮

function moveMonkey (checked) {
console.log(checked, 'moveMonkey')
if (checked) {
monkey.position.set(data[2][0], data[2][1], 500);
} else {
monkey.position.set(data[0][0], data[0][1], 500);
}
}

[!TIP] 这里面地图和three好像是一起渲染的。如果你只加载了猴头,没加载mesh,此时是没有动画效果的,所以移动猴头的话,这个效果有延迟,缩放平移一下地图就好了。但是如果添加了动画,由于一直调用 map.render() 函数,因此不会出现此问题

大功告成!

结合tween.js

是不是觉得猴头的移动还不够顺滑,加个tween的动画试试

  1. 引入 npm install @tweenjs/tween.js
import * as TWEEN from '@tweenjs/tween.js'
  1. 更改 moveMonkey函数
function moveMonkey (checked) {
console.log(checked, 'moveMonkey')
if (checked) {
// monkey.position.set(data[2][0], data[2][1], 500);
const tween = new TWEEN.Tween(monkey.position)
.to({ x: data[2][0], y: data[2][1], z: 500 }, 2000)
.start()
} else {
// monkey.position.set(data[0][0], data[0][1], 500);
const tween = new TWEEN.Tween(monkey.position)
.to({ x: data[0][0], y: data[0][1], z: 500 }, 2000)
.start()
}
}
  1. 在 render 函数中加入 ( 这里指Three 函数中的 创建GL图层的 render 函数)
 TWEEN.update()

大功告成!Tween的其他功能也可以使用


作者:写bug的小杜
来源:juejin.cn/post/7338240698703314985

收起阅读 »

简单的 Web 端实时日志实现

web
背景 cron service 在执行定时任务时,需要能够实时查看该任务的执行日志以确保程序正确的工作。为了能够在尽可能短的时间内实现该功能,我们需要一个足够简单的方案。 方案如何选择? 我相信大多数开发者第一个想到的就是 WebSocket ,然后是 HTT...
继续阅读 »

Feb-20-2024 21-40-36.gif


背景


cron service 在执行定时任务时,需要能够实时查看该任务的执行日志以确保程序正确的工作。为了能够在尽可能短的时间内实现该功能,我们需要一个足够简单的方案。


方案如何选择?


我相信大多数开发者第一个想到的就是 WebSocket ,然后是 HTTP/SSE 。WebSocket 在很多情况下是实至名归的万金油选择。但这这里他太重了,对服务端和客户端具有侵入性需要花费额外的时间来集成到项目中。


那么 SSE 呢,为什么不是他?
虽然 SSE 即轻量又实时,但 SSE 无法设置请求头。这导致无法使用缓存和通过请求头设置的 Token。而且作为长链接他还一直占用并发额度。



  • WebSocket:❌

    • 优势:

      • 实时性高

      • 不会占用 HTTP 并发额度



    • 劣势:

      • 复杂度较高,需要在客户端和服务器端都进行特殊的处理

      • 消耗更多的服务器资源。





  • SSE(Server-Sent Events):❌

    • 优势:

      • 基于HTTP协议,不需要在服务端和客户端做额外的处理

      • 实时性高



    • 劣势:

      • 无法设置请求头

      • 占用 HTTP 并发额度





  • HTTP:✅

    • 优势:

      • 简单易用,不需要在服务端和客户端做额外的处理。

      • 支持的功能丰富,如缓存,压缩,认证等功能。



    • 劣势:

      • 实时性差,取决于轮询时间间隔。

      • 每次HTTP请求都需要建立新的连接(仅针对 HTTP/0.x 而言)并可能阻塞同源的其他请求而导致性能问题。12

        • HTTP/1.x 支持持久连接以避免每次请求都重新建立 TCP 连接,但数据通信是串行的。

        • HTTP/2.x 支持持久连接且支持并行的数据通信。









以上列出的优缺点是仅对于本文所讨论的场景(即Web 端的实时日志)而言,这三种数据交互技术在其他场景中的优缺点不在本文讨论范围内。



实现


HTTP 轮询已经是老熟人了,不再做介绍。本文的着重于实时日志的实现及优化。


sequenceDiagram

participant 浏览器

participant 服务器

Note right of 浏览器: 首先,获取日志文件最后 X bytes 的内容

浏览器->>服务器: 最新的日志文件有多大?

服务器->>浏览器: 日志文件大小: Y bytes

浏览器->>服务器: 从 Y - X bytes 处返回内容

服务器->>浏览器: 日志文件大小: Y1 bytes, 日志内容: XXXX



loop 持续轮询获取最新的日志

浏览器->>服务器: 从 Y1 bytes 处返回内容

服务器->>浏览器: 日志文件大小: Y2 bytes, 日志内容: XXXX

end

上方是基本工作原理的流程图。
实现的关键点在于



  • 前端如何知道日志文件当前的大小

  • 服务端如何从指定位置获取日志文件内容


这两点都可以通过 HTTP Header 来解决。Content-Range HTTP 响应头会包含完整文件的大小,而 Range HTTP 请求头可以指示服务器返回指定位置的文件内容。


因此在服务端不需要额外的逻辑,仅通过 Web 端代码就可以实现实时日志功能。


代码实现



篇幅限制,代码不会处理异常情况



首先,根据上述流程图。我们需要获取日志文件的大小。


const res = await fetch(URL, {  
method: "GET",
headers: { Range: "bytes=0-0" }
});

const contentRange = res.headers.get("Content-Range") || "";
const match = /bytes (\d+)-(\d+)\/(\d+)/.exec(contentRange);

// 日志文件大小
const total = Number.parseInt(match[3], 10);


Range: "bytes=0-0" 指定仅获取第一个字节的内容,以免较大的日志文件导致响应时间过长。



我们发起了一个 GET 请求并将 Range 请求头设置为 bytes=0-0 如果服务器能够正确处理 Range 请求头,响应中将包含一个 Content-Range 列其中将包含日志文件的完整大小,可以通过正则解析拿到。


现在我们已经拿到了日志文件的大小并存储在名为 total 的变量中。然后根据 total 获取到最后 10 KB 的日志内容。


const res = await fetch(url, {  
method: "GET",
headers: {
Range: `bytes=${total - 1000 * 10}-`
}
});

const contentRange = res.headers.get("Content-Range") || "";
const match = /bytes (\d+)-(\d+)\/(\d+)/.exec(contentRange);

// 下一次请求的起始位置
const start = Number.parseInt(match[2], 10) + 1;

// 日志内容
const content = await res.text();

现在我们发起了一个 GET 请求并将 Range 请求头设置为 bytes=${total - 1000 * 10}- 以获取最后 10 KB 的日志内容。并且通过正则解析拿到了下一次请求的起始位置。


现在我们已经拿到了日志文件的大小和最后 10 KB 的日志内容。接下来就是持续轮询去获取最新的日志。轮询代码区别仅在于将 Range 标头设置为 bytes=${start}- 以便获取最新的日志。


const res = await fetch(url, {  
method: "GET",
headers: {
Range: `bytes=${start}-`
}
});

const contentRange = res.headers.get("Content-Range") || "";
const match = /bytes (\d+)-(\d+)\/(\d+)/.exec(contentRange);

// 下一次请求的起始位置
start = Number.parseInt(match[2], 10) + 1;

// 日志内容
const content = await res.text();

以上,基本的功能已经实现了。日志内容保存在名为 content 的变量中。


优化


HTTP 轮询常因其高延迟为人诟病,我们可以通过指数退避的方式来尽可能的降低延时且不会显著的增加服务器负担。


指数退避是一种常见的网络重试策略,它会在每次重试时将等待时间乘以一个固定的倍数。这样做的好处是,当网络出现问题时,重试的时间间隔会逐渐增加,直到达到最大值。


一个简单的实现如下:


/**
* 使用指数退避策略获取日志.
*/

export class ExponentialBackoff {
private readonly base: number;
private readonly max: number;
private readonly factor: number;
private retries: number;

/**
* @param base 基础延迟时间 默认 1000ms (1秒)
* @param max 最大延迟时间 默认 60000ms (1分钟)
* @param factor 延迟时间增长因子 默认 2
*/

constructor(base: number = 1000, max: number = 60000, factor: number = 2) {
this.base = base;
this.max = max;
this.factor = factor;
this.retries = 0;
}

/**
* 获取下一次重试的延迟时间.
*/

next() {
const delay = Math.min(this.base * Math.pow(this.factor, this.retries), this.max);
this.retries++;
return delay;
}

/**
* 重置重试次数.
*/

reset() {
this.retries = 0;
}
}

值得一提的是带有 Range 标头的请求成功时会返回 206 Partial Content 状态码。而在请求的范围超出文件大小时会返回 416 Range Not Satisfiable 状态码。我们可以通过这两个状态码来判断请求是否成功。


成功时调用 reset 方法重置重试次数,失败时调用 next 方法获取下一次重试的延迟时间。


总结


即使再优秀的方案也不是银弹,真正合适的方案需要考虑的不仅仅是技术本身,还有业务场景,团队技术栈,团队技术水平等等。在选择技术方案时,我们需要权衡各种因素,而不是盲目的选择最流行的技术。


当我们责备现有的技术方案为何如此糟糕时,或许在那个时间点上,这个方案是最合适的。


Pasted image 20240220213326.png


感谢以下网友的帮助和建议:齐洛格德


Footnotes




作者:siaikin
来源:juejin.cn/post/7337519776796295177
收起阅读 »

【日常总结】iframe内嵌网站初始路由404解决

web
背景 Vue 项目被嵌入到 <iframe> 中,通过src加载页面,结果打开后直接访问404,但是如果不是通过iframe打开的话是不会出现这个问题的。<iframe src="/dist/index.html"></ifram...
继续阅读 »

背景


Vue 项目被嵌入到 <iframe> 中,通过src加载页面,结果打开后直接访问404,但是如果不是通过iframe打开的话是不会出现这个问题的。

<iframe src="/dist/index.html"></iframe>



问题现象


在首次进入时,Vue Router 没有正确地导航到首页路由,而是到了404页面。




原因


vuerouter内部match当前路径的逻辑在这种情况下有问题,所以取到的是错误的信息,找不到这个path,就跳转到了404。前端路由是要依赖当前路径来做处理的,而路径是依赖location字段,但这个时候location跟我们预期的不一样,就跳转错误。




为什么会这样?


是因为iframe 的加载是异步的,router路径跳转之前iframe还没初始化好,导致router内部获取的当前路径不对。




所以思路有2个:



  1. 路径redirect有问题,那就不依赖它,在url中增加参数设置正确的首次跳转路径

  2. 在 iframe 加载完成时,设置正确的路由信息从而导航过去。所以就从iframe加载完后重新设置导航这个关键点下手。


梳理到这里,我意识到上面两种方案是iframe的通信方式:



  • url传递信息

  • postMessage传递消息


解决方案


方案一:iframe的src的URL 参数中增加初始路由信息



  1. 在 iframe 的 src 属性中通过url参数添加首页的路由信息,
<iframe src="/dist/index.html?route=/dashboard"></iframe>


  1. 再在 Vue 应用程序初始化时,如果读取到了router则跳转过去。
import Vue from 'vue'
import App from './App.vue'
import router from './router'

// 方案1:根据添加的url参数判断
const routeParam = new URLSearchParams(window.location.search).get('route');
if (routeParam) {
router.push(routeParam);
}

new Vue({
router,
render: h => h(App),
}).$mount('#app')

我使用的这种方案。


方案二:使用 postMessage 实现父页面和iframe的通信



  1. 父页面监听iframe加载完后通过postMessage发送消息:
const iframe = document.getElementById('myIframe');
if (iframe) {
iframe.onload = function() {
iframe.contentWindow.postMessage({ route: '/dashboard' }, '*');
};
}


  1. iframe内页面main.ts中监听消息,设置路由
// 方案2:postMessage来实现iframe的通信
window.addEventListener('message', handleMessage, false);

function handleMessage(event: any) {
const { data } = event;
if (data.route) {
console.log('message', event, data.route);
router.push(data.route); // 导航到接收到的路由
}
}

这个方案,也可以fix这个bug,但是会先闪到404,再到首页,原因应该是iframe通信是要放到onmounted钩子里的,因为依赖dom加载完,所以会有个时间差。等到iframe onload时候之前已经redirect到404了,然后又重新route了下,效果不是很好。


方案三:利用路由钩子


利用 Vue Router 的导航守卫,在路由导航前检查 URL 参数中是否存在初始路由信息,如果存在则进行相应的导航处

// 方案3: 路由拦截
router.beforeEach((to, from, next) => {
if (to.matched.length === 0) {
// 如果未匹配到任何路由,说明当前页面可能是初始加载
next('/dashboard/base'); // 或者重定向到默认页面
} else {
next(); // 继续正常导航
}
});

结果


先把今天遇到的问题简单总结下:




  • 最后采用了方案1。




  • 方案2 调试出来了,但是效果会有先闪过404页面,后续又到首页的情况,效果不是很好。




  • 3没调出来,暂时不调了。

































标题优点缺点问题
url加参数简单,直接,只需要在内嵌的项目写代码url结构不好看最终使用
postmenssage不需要修改url需要内嵌iframe的页面和iframe的项目中添加额外代码会有一闪而过的效果,体验不好
beforeEach不需要修改url路由钩子每个都处理了逻辑有问题,没调试出来

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

你不知道的JSON.stringify神操

web
一、前言 在我们面试过程中,面试官经常会提及到深浅拷贝的问题。想必大多数小伙伴会说到JSON.parse(JSON.stringify(obj))。正好今天我就和大家好好唠一唠这个JSON.stringify。 二、概念 JSON.stringify对于我...
继续阅读 »

一、前言



在我们面试过程中,面试官经常会提及到深浅拷贝的问题。想必大多数小伙伴会说到JSON.parse(JSON.stringify(obj))。正好今天我就和大家好好唠一唠这个JSON.stringify



二、概念


JSON.stringify对于我们不陌生,一般用来处理序列化(深拷贝)。就是把我们的对象转换JSON字符串,此方法确实很方便在我们的工作中,但是,这个方法也会有一些弊端,只是我们不怎么遇到。


let obj = {
name: 'iyongbao'
}

console.log(JSON.stringify(obj)); // {"name":"iyongbao"}

三、弊端


1. 对函数不友好


如果我们的对象属性是一个函数,那么在序列化的时候该属性丢失


let obj = {
name: 'iyongbao',
foo: function () {
console.log(`${ this.name }是一个小菜鸟!`)
}
}

console.log(JSON.stringify(obj)); // {"name":"iyongbao"}

2. 对undefined不友好


如果对象的属性值是undefined,转换后会丢失


let obj = {
name: undefined
}

console.log(JSON.stringify(obj)); // {}

3. 对正则表达式不友好


如果对象的属性是一个正则表达式,转换后就会变成一个空的Object


let obj = {
name: 'iyongbao',
zoo: /^i/ig,
foo: function () {
console.log(`${ this.name }是一个小菜鸟!`)
}
}

console.log(JSON.stringify(obj)); // {"name":"iyongbao","zoo":{}}

4. 数组对象


如果是一个数组对象,以上的情况也会发生。


let arr = [
{
name: undefined
}
]

console.log(JSON.stringify(arr)); // [{}]

四、JSON.stringify拓展



说完了JSON.stringify不足,下面我们来说一下你可能没有接触过的其他特性,希望看完会对你有所帮助。



1. 接收一个数组(过滤)


其实JSON.stringify第二参数,可能我们不经常用到。我们可以传入一个数组,值就是对应我们对象key,我称之为过滤。


let obj = {
name: 'iyongbao',
age: 25,
hobby: ['JavaScript', 'Vue']
}

let res = JSON.stringify(obj, ['name']);

console.log(res); // {"name":"iyongbao"}

2. 接收一个函数


第二个参数也可以是一个函数,也是类似过滤效果


let obj = {
name: 'iyongbao',
age: 25,
hobby: ['JavaScript', 'Vue']
}

let res = JSON.stringify(obj, (key, value) => {
if (key === 'age') return undefined;
return value;
});

console.log(res); // {"name":"iyongbao","hobby":["JavaScript","Vue"]}

3. 缩进


第三个参数可以接收一个数字,表示缩进多少字符


let obj = {
name: 'iyongbao',
age: 25,
hobby: ['JavaScript', 'Vue']
}

let res = JSON.stringify(obj, null, 2);

console.log(res);

擷取.PNG


4. 自身toJSON方法


对象可以有一个自身的toJSON属性,是一个返回值方法,用来输出我们自定义的数据样式。


let obj = {
name: 'iyongbao',
age: 25,
toJSON: function () {
return {
message: `${ this.name }的年龄为${ this.age }`
}
}
}

let res = JSON.stringify(obj);

console.log(res); // {"message":"iyongbao的年龄为25"}

五、总结


好了,今天就和大家分享到这吧。一般如果真涉及到深拷贝,我还是首选自己封装一个方法或者是使用第三方插件库来做深拷贝,这样最保险,避免不必要的麻烦。


作者:勇宝趣学前端
来源:juejin.cn/post/7337989768636973083
收起阅读 »

掌握Redis核心:常用数据类型的高效运用秘籍!

在数据驱动的时代,高效地存储和处理数据成为了开发者们的重要任务。Redis,作为一个开源的高性能键值对(key-value)数据库,以其独特的数据结构和丰富的功能,成为了众多项目的首选。今天,我们就来揭开Redis的神秘面纱,看看它是如何通过不同的数据类型,为...
继续阅读 »

在数据驱动的时代,高效地存储和处理数据成为了开发者们的重要任务。Redis,作为一个开源的高性能键值对(key-value)数据库,以其独特的数据结构和丰富的功能,成为了众多项目的首选。

今天,我们就来揭开Redis的神秘面纱,看看它是如何通过不同的数据类型,为我们提供高效、灵活的数据存储和处理能力的。

一、字符串(String):数据的基石

String类型简介

字符串是Redis最基本的数据类型,它可以存储文本、数字或者二进制数据。

使用字符串类型,你可以执行原子性的操作,如追加(APPEND)、设置(SET)和获取(GET)。例如,你可以将用户信息作为字符串存储,并通过键快速检索。

  • 一个key对应一个value。

  • String类型是二进制安全的。只要内容可以使用字符串表示就可以存储到string中。比如jpg图片或者序列化的对象。

  • 一个Redis中字符串value最多可以是512M。

常用命令

set key value: 添加键值对。

Description

get key: 查询key对应的键值。

Description

注意:如果设置了两次相同的key,后设置的就会把之前的key覆盖掉。

append key value: 将给定的value追加到原值的末尾。

Description

strlen key: 获得值的长度。

Description

setnx key value: 只有在key 不存在时 ,才能设置 key 的值。

Description

incr key: 将 key 中储存的数字值增1(只能对数字值操作,如果为空,新增值为1)。

Description

decr key: 将 key 中储存的数字值减1(只能对数字值操作,如果为空,新增值为-1)。

Description

incrby / decrby key 步长: 通过自定义步长方式增减 key 中储存的数字值。

Description

mset key1 value1 key2 value2 …: 同时设置一个或多个键值对。

Description

mget key1 key2 key3 …: 同时获取一个或多个value。

Description

msetnx key1 value1 key2 value2 …: 所有给定 key 都不存在时,同时设置一个或多个 key-value 对。

注意:此操作有原子性,只要有一个不符合条件的key。其他的也都不能设置成功。如下图:
Description

getrange key 起始位置、结束位置: 获得值的范围,类似java中的substring。

Description

setrange key 起始位置 value: 用 value覆写key所储存的字符串值,从起始位置开始(索引从0开始)。

Description

setex key 过期时间 value: 可以在设置键值的同时,设置过期时间,单位秒(前面的expire是给已有的键值设置过期时间,注意区别)。

Description

getset key value: 以新换旧,设置了新值同时获得旧值。

Description

应用场景

存储用户信息: 将用户的姓名、年龄等信息作为字符串存储在Redis中,通过键值对的方式快速检索和更新。

计数器: 使用INCR命令实现访问量、点赞数等计数功能。

二、哈希(Hash):组织数据的框架

Hash类型简介

哈希类型允许你存储字段-值对的集合。这种结构非常适合于存储对象,如用户的个人信息。通过HSET和HGET命令,你可以设置和获取哈希中的字段和值。

哈希类型的优势在于它可以对字段进行原子性操作,而不需要读取整个对象。

常用命令

hset key field value: 给key集合中的 field 键赋值value。
Description

hget key1 field: 从key1集合field取出 value。

Description

hmset key1 field1 value1 field2 value2… : 批量设置hash的值(一次性设置多个数据值)。

Description

hexists key1 field: 查看哈希表 key 中,给定域 field 是否存在。

Description

hkeys key: 列出该hash集合的所有field。

Description

hvals key: 列出该hash集合的所有value。

Description

hincrby key field increment: 为哈希表 key 中的域 field 的值加上增量。

Description

hsetnx key field value: 将哈希表 key 中的域 field 的值设置为 value ,当且仅当域 field 不存在。

Description

应用场景

存储用户对象: 将用户对象的多个属性(如姓名、年龄、性别等)存储在一个哈希结构中,通过HGETALL命令获取整个对象,或使用HGET/HSET针对某个属性进行操作。

存储配置信息: 将应用程序的配置信息以字段-值对的形式存储在哈希中,方便集中管理和修改。

三、列表(List):有序数据的队列

List类型简介

列表类型提供了一种顺序存储数据的方式,它类似于Python中的列表或Java中的LinkedList。

你可以使用LPUSH和RPUSH命令在列表的头部或尾部添加元素。列表还支持范围查询和列表内元素的移除操作,非常适合于实现消息队列等场景。

  • List中单键多值,即一个key对应多个value,其中的多个value值使用List进行存储。

  • Redis 列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边)。

  • 它的底层实际是个双向链表,对两端的操作性能很高,通过索引下标的操作中间的节点性能会较差。

常用命令

**lpush / rpush key value1 value2 value3 … :**从左边/右边插入一个或多个值(l代表left,r代表right)。

**lrange key start stop:**按照索引下标获得元素(从左到右)。

其中lrange k1 0 -1表示取出k1中全部value值(0表示左边第一个,-1代表右边第一个)。

Description

lpop / rpop key: 从左边/右边吐出一个值。值在键在,值光键亡。
pop表示把值拿出来。

Description
图片从左边取出k1的一个value值。如下图:

Description

从右边取出k2的一个value值。如下图:

Description

value值全部取完的时候,key就没有了。如下图:

Description

rpoplpush key1 key2: 从key1列表右边吐出一个值,插到key2列表左边。

Description

lindex key index: 按照索引下标获得元素(从左到右)。

Description

llen key: 获得列表长度。

Description

linsert key before value newvalue : 在value的前面插入newvalue插入值。

Description

lrem key n value: 从开始删除n个value(从左到右)图片。

Description

lset key index value: 将列表key下标为index的值替换成value。

Description

应用场景

消息队列: 使用LPUSH/RPUSH命令将待处理的消息添加到列表头部/尾部,使用LPOP/RPOP从列表中取出并处理消息。

关注列表: 存储用户关注的其他用户列表,使用LINSERT命令在列表中插入新关注的对象。

你还在苦恼找不到真正免费的编程学习平台吗?可以试试【云端源想】!课程视频、知识库、微实战、云实验室、一对一咨询……你想要的全部学习资源这里都有,重点是现在还是免费的!

点这里即可查看!

四、集合(Set):去重数据的集合

Set类型简介

集合类型用于存储无序且唯一的数据集合。当你需要存储不允许重复的元素时,集合是一个很好的选择。

SADD命令用于向集合中添加元素,而SMEMBERS可以获取集合中的所有元素。集合还支持交集、并集和差集等高级操作,非常适合于处理标签、好友关系等场景。

Redis的Set是string类型的无序,不可重复集合。Set底层其实是一个value为null的hash表,所以添加,删除,查找的复杂度都是0(1)。

常用命令

sadd key value1 value2 …: 将一个或多个 member 元素加入到key对应的集合中,已经存在的 member 元素将被忽略

smembers key: 取出key对应的集合中的所有值。

Description

sismember key value: 判断集合是否为含有该value值。1表示有,0表示没有。

Description

scard key: 返回key对应集合中的元素个数。

Description

srem key value1 value2…: 删除key对应的集合中的某些元素。

Description

spop key: 随机从key对应的集合中吐出一个值。

Description

srandmember key n: 随机从key对应的集合中取出n个值。不会从集合中删除 (rand即为random)。

Description

smove sourceKey destinationKey value: 把集合中一个值从一个集合移动到另一个集合。

Description

sinter key1 key2: 返回两个集合的交集元素

sunion key1 key2: 返回两个集合的并集元素。

sdiff key1 key2: 返回两个集合的差集元素(key1中的,不包含key2中的)。

Description

应用场景

好友关系: 将用户的好友列表存储在一个集合中,使用SADD命令添加好友,使用SISMEMBER判断某个用户是否为好友。

标签系统: 将文章或商品的标签存储在集合中,使用SUNION/SINTER等命令进行标签的交集、并集操作。

五、有序集合(Sorted Set)

Sorted Set类型简介

有序集合是Redis中的一个高级数据类型,它结合了集合的唯一性和列表的排序功能。

每个元素都关联一个分数(score),根据分数对元素进行排序。ZADD命令用于添加元素和分数,ZRANGE则可以获取排序后的元素列表。

注:zset是sorted set的缩写

常用命令

zadd key score1 value1 score2 value2…: 将一个或多个 member 元素及其 score 值加入到有序集 key 当中。

Description

zrange key start stop (withscores): 返回有序集 key 中,下标在start和stop之间的元素。(带withscores,可以让分数一起和值返回到结果集)。

zrange rank 0 -1 (withscores): 表示取出全部元素,从小到大排列。如下图:

Description

zrangebyscore key min max (withscores): 返回有序集 key 中所有 score 值介于 min 和 max 之间(包括等于 min 或 max )的成员。有序集成员按 score 值递增(从小到大)次序排列。

Description

zrevrangebyscore key max min (withscores): 同上,改为从大到小排列(其中rev表示reverse)。

Description

zincrby key increment value: 为元素的score加上增量。

Description

zrem key value: 删除该集合下,指定值的元素。

Description

zcount key min max: 统计该集合,分数区间内的元素个数。

Description

zrank key value: 返回该值在集合中的排名。(排在第一位的是0)

Description

应用场景

排行榜系统: 可以使用有序集合来存储用户的得分,并根据得分进行排序。

带权重的集合: 有序集合可以用来实现带权重的集合,即每个元素都有一个对应的权重值。

时间线排序: 有序集合可以用于实现时间线排序,即将事件或消息按照时间顺序进行排序。

六、总结

本篇文章我们探索了Redis的五种常用数据类型及常用命令和使用场景。每一种数据类型都有其独特的优势和适用情况,掌握它们将使你在数据处理的道路上更加得心应手。

无论是构建缓存系统,还是实现复杂的数据结构,Redis都能提供强有力的支持。希望这篇文章能帮助你更好地理解和运用Redis,让你的项目在数据的世界中脱颖而出。

收起阅读 »

前端又又出新框架,这次没有打包了

web
最近,前端开发领域又迎来了一个新框架——ofa.js。它的独特之处在于,不依赖于现有的 nodes/npm/webpack 前端开发工作流程。与jQuery类似,只需引用一个脚本,您就能像使用React/Vue/Angular一样轻松地开发大型应用。 极易上...
继续阅读 »

最近,前端开发领域又迎来了一个新框架——ofa.js。它的独特之处在于,不依赖于现有的 nodes/npm/webpack 前端开发工作流程。与jQuery类似,只需引用一个脚本,您就能像使用React/Vue/Angular一样轻松地开发大型应用。


punch-logo.png


极易上手


如果您要开发简单的项目,想要用一个漂亮的按钮组件,例如 Ant Design 中的 Button组件,你需要学习Node.js、NPM和React等知识,才能开始使用该按钮组件。对于非前端开发者或初学者来说,这将是一个漫长的过程。


如果使用基于ofa.js 开发的组件,就不需要这么复杂了;你只需要了解HTML的基础知识(即使不看ofa.js的文档),也可以轻松使用基于ofa.js开发的组件。以下是使用官方的 punch-logo 代码示例:


<!-- 引入ofa.js到您的项目 -->
<script src="https://cdn.jsdelivr.net/gh/kirakiray/ofa.js/dist/ofa.min.js"></script>

<!-- 加载预先开发的punch-logo组件 -->
<l-m src="https://ofajs.github.io/ofa-v4-docs/docs/publics/comps/punch-logo.html"></l-m>

<!-- 使用punch-logo组件 -->
<punch-logo style="margin: 50px 0 0 100px">
<img
src="https://ofajs.github.io/ofa-v4-docs/docs/publics/logo.svg"
logo
height="90"
/>

<h2>不加班了</h2>
<p slot="fly">下班给我</p>
<p slot="fly">迟点下班</p>
<p slot="fly">周末加班</p>
</punch-logo>

punch-demo.gif


你可以最直接拷贝上面的代码,放到一个空白的html文件内运行试试;这使得ofa.js非常容易与传统的Web开发技术栈相融合。


一步封装组件


封装组件同样非常简单,只需一个HTML文件即可实现。以下是一个官方封装的开关(switch)组件示例:


<!-- my-switch.html -->
<template component>
<style>
.switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
background-color: #ccc;
transition: background-color 0.4s;
border-radius: 34px;
cursor: pointer;
}

.slider {
position: absolute;
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
transition: transform 0.4s;
border-radius: 50%;
}

.switch.checked {
background-color: #2196f3;
}

.switch.checked .slider {
transform: translateX(26px);
}
</style>
<div class="switch" class:checked="checked" on:click="checked = !checked">
<span class="slider"></span>
</div>
<script>
export default {
tag: "my-switch",
data: {
checked: true,
},
};
</script>
</template>

在使用时,只需使用 l-m 组件引用它:


<script src="https://cdn.jsdelivr.net/gh/kirakiray/ofa.js/dist/ofa.min.js"></script>
<l-m src="./my-switch.html"></l-m>
<my-switch></my-switch>

switch.gif


示例可以在官方网站下方查看。由于无需打包流程,只需将文件上传到静态服务器即可发布,还可以进行跨域引用,这极大降低了组件共享的成本。


多种模板语法糖


ofa.js与Vue和Angular一样提供了许多模板语法糖,主要包括:



  • 文本渲染

  • 属性绑定/双向绑定

  • 事件绑定

  • 条件渲染

  • 列表渲染

  • ...


具体案例可在官网向下滚动至“提供多样便捷的模板语法”处查看。


天生的状态同步高手


与其他框架不同,ofa.js 使用无感状态同步。这意味着数据不需要通过函数操作,只需设置数据对象即可实现状态同步。以下是一个共享黑夜模式的按钮示例:


// is-dark.js
const isDark = $.stanz({
value: false,
});

export default isDark;

<!-- my-button.html -->
<template component>
<style>
:host {
display: block;
}

.container {
display: inline-block;
padding: 0.5em 1em;
color: white;
border-radius: 6px;
background-color: blue;
cursor: pointer;
user-select: none;
}
.container.dark {
background-color: red;
}
</style>
<div class="container" class:dark="isDark.value">
<slot></slot>
</div>
<script>
import isDark from "./is-dark.js";
export default {
data: {
isDark: {},
},
attached() {
// 共享dark对象数据
this.isDark = isDark;
},
detached() {
// 清除内存记录
this.isDark = {};
},
};
</script>
</template>

sync-state.gif


您可以跳转到 状态同步案例 以查看效果。


最简单的表单操作


表单只需调用formData方法,就能生成自动同步数据的对象:


<form id="myForm">
<input type="text" name="username" value="John Doe" />
<div>
sex:
<label>
man
<input type="radio" name="sex" value="man" />
</label>
<label>
woman
<input type radio="radio" name="sex" value="woman" />
</label>
</div>
<textarea name="message">Hello World!</textarea>
</form>
<br />
<div id="logger"></div>
<script>
const data = $("#myForm").formData();

$("#logger").text = data;
data.watch(() => {
$("#logger").text = data;
});
</script>

form1.gif


您还可以轻松地反向设置表单数据:


<form id="myForm">
<input type="text" name="username" value="John Doe" />
<div>
sex:
<label>
man
<input type="radio" name="sex" value="man" />
</label>
<label>
woman
<input type="radio" name="sex" value="woman" />
</label>
</div>
<textarea name="message">Hello World!</textarea>
</form>
<br />
<div id="logger"></div>
<script>
const data = $("#myForm").formData();

setTimeout(() => {
// 反向设置数据
data.username = "Yao";
data.sex = "man";
data.message = "ofa.js is good!";
}, 1000);
</script>

form2.gif


制作自定义表单组件也没有其他框架那么复杂,只需为组件定义valuename属性即可。


具体效果可跳转至formData API查看。


开发应用


您还可以使用ofa.js开发Web应用,然后直接引用已开发的应用到您的网页上:


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>应用演示</title>
<script src="https://cdn.jsdelivr.net/gh/kirakiray/ofa.js/dist/ofa.min.js"></script>
</head>
<body>
<o-app src="https://xxxxx.com/app-config.mjs"> </o-app>
</body>
</html>

具体效果可跳转至使用o-app组件查看。


SCSR


官方提供了一种类似服务端渲染的解决方案,它不仅保证了用户体验,还使页面在静态状态下可被搜索引擎爬取。官网采用了SCSR的方案。



SCSR的全称是Static Client-Side Rendering,又称为静态客户端渲染。它是CSR(Client-Side Rendering)的一种变种,在保留了CSR用户体验的基础上,还能够让页面在静态状态下被搜索引擎爬取。



您可以点击SCSR方案以查看详细信息。


代码简洁


当前版本4.3.29的 ofa.min.js 文件仅有52KB,经过gzip压缩后仅有18KB。


其他


最近升级到了v4版本,目前可用的第三方库还比较有限,但以后将逐渐增加。作者正在准备开发基于ofa.js的UI库。


作者是一位中国开发者,快去给 ofa.js 加星吧!


作者:皮卡怪兽
来源:juejin.cn/post/7295576148364460071
收起阅读 »

千分位分隔?一个vue指令搞定

web
说在前面 🎈对数字进行千分位分隔应该是大部分同学都做过的功能了吧,常规的做法通常是编写一个工具函数来对数据进行转换,那么我们可不可以通过vue指令来实现这一功能呢? 效果展示 实现原理 非输入框 非输入框我们只需要对其展示进行处理,我们可以判断绑定元素...
继续阅读 »

说在前面



🎈对数字进行千分位分隔应该是大部分同学都做过的功能了吧,常规的做法通常是编写一个工具函数来对数据进行转换,那么我们可不可以通过vue指令来实现这一功能呢?



效果展示




实现原理


非输入框


非输入框我们只需要对其展示进行处理,我们可以判断绑定元素的innerHTML是否不为空,不为空的话则直接对其innerHTML内容进行格式化。


export default {
bind: function (el, binding) {
const separator = binding.value || ",";
if (el.innerHTML) {
el.innerHTML = addThousandSeparator(el.innerText, separator);
}
},
};

输入框



对于输入框,我们希望其有以下功能:



1、输入的时候去掉分隔符


这里我们只需要监听元素的聚焦(focus)事件即可,取到元素的值,将其分隔符去掉后重新赋值。


el.addEventListener("focus", (event) => {
const value = event.target.value;
event.target.value = deleteThousandSeparator(value, separator);
});

2、输入完成后添加分隔符


这里我们只需要监听元素的失焦(blur)事件即可,取到元素的值,对其进行添加分隔符处理后重新赋值。


el.addEventListener("blur", (event) => {
const value = event.target.value;
event.target.value = addThousandSeparator(value, separator);
});

千分位分隔函数


function addThousandSeparator(num, separator = ",") {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, separator);
}


  • num.toString(): 将输入的数字 num 转换为字符串,以便后续处理。

  • .replace(/\B(?=(\d{3})+(?!\d))/g, separator): 这里使用了正则表达式进行替换操作。具体解释如下:



    • \B: 表示非单词边界,用于匹配不在单词边界处的位置。

    • (?=(\d{3})+(?!\d)): 使用正向预查来匹配每三位数字的位置,但不匹配末尾不足三位的数字。

    • (\d{3})+: 匹配连续的三位数字。

    • separator: 作为参数传入的分隔符,默认为 ,。

    • g: 表示全局匹配,即匹配所有满足条件的位置。




这样,通过正则表达式的替换功能,在数字字符串中的每三位数字之间插入指定的千位分隔符,从而实现千位分隔符的添加。


去掉千分位分隔


function deleteThousandSeparator(numberString, separator = ",") {
return numberString.replace(new RegExp(separator, "g"), "");
}

直接将字符串中的分隔符全部替换为空即可。


完整代码


function addThousandSeparator(num, separator = ",") {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, separator);
}
function deleteThousandSeparator(numberString, separator = ",") {
return numberString.replace(new RegExp(separator, "g"), "");
}
export default {
bind: function (el, binding) {
const separator = binding.value || ",";
if (el.innerHTML) {
el.innerHTML = addThousandSeparator(el.innerText, separator);
}
el.addEventListener("focus", (event) => {
const value = event.target.value;
event.target.value = deleteThousandSeparator(value, separator);
});
el.addEventListener("blur", (event) => {
const value = event.target.value;
event.target.value = addThousandSeparator(value, separator);
});
},
};


组件库


组件文档


目前该组件也已经收录到我的组件库,组件文档地址如下:
jyeontu.xyz/jvuewheel/#…


组件内容


组件库中还有许多好玩有趣的组件,如:



  • 悬浮按钮

  • 评论组件

  • 词云

  • 瀑布流照片容器

  • 视频动态封面

  • 3D轮播图

  • web桌宠

  • 贡献度面板

  • 拖拽上传

  • 自动补全输入框

  • 图片滑块验证


等等……


组件库源码


组件库已开源到gitee,有兴趣的也可以到这里看看:gitee.com/zheng_yongt…


作者:JYeontu
来源:juejin.cn/post/7337125978802683956
收起阅读 »

uniapp根据不同的环境配置不同的运行基础路径

web
前言当我们使用uniapp开发同一个项目发布不同的环境二级路径不同时,这时候我们就要根据环境来添加运行的基础路径product:xxx-product-api.xxx.com:9002/productConf…text:xxx-text-api.xxx.com...
继续阅读 »

前言

当我们使用uniapp开发同一个项目发布不同的环境二级路径不同时,这时候我们就要根据环境来添加运行的基础路径

product:xxx-product-api.xxx.com:9002/productConf…

text:xxx-text-api.xxx.com:9002/textConfig/

但是当我们使用HBuilderX开发时你会发现manifest.json手动配置Web配置时只能配置一个像这种情况

image.png

碰到这种情况你会怎么处理?

你是不是会在每次打包发布之前变更该环境对应基础路径?

这样也是一种方法,不过其过程太繁琐,废话不多说,上正文!!!

正文

当我们使用HX创建项目时项目中是没有package.json文件和vue.config.js文件的

  1. 在根目录下创建package.json文件,用于配置多个环境也可用于Hx自定义发行
{
"id": "sin-signature",
"name": "签名组件-兼容H5、小程序、APP",
"version": "1.0.0",
"description": "用于uni-app的签名组件,支持H5、小程序、APP,可导出svg矢量图片。",
"keywords": ["签名,签字,svg,canvas"],

"uni-app": {
"scripts": {
"h5-dev": {
"title": "H5-DEV",
"env": {
"NODE_ENV": "development",
"UNI_PLATFORM": "h5",
"VUE_APP_BASE_API": "http://192.168.3.3:8081"
},
"define": {
"H5": true
}
},
"h5-xx": {
"title": "H5-XX",
"env": {
"NODE_ENV": "production",
"UNI_PLATFORM": "h5",
"VUE_APP_BASE_API": "http://xxx.xx.xx.xxx:8092"
},
"define": {
"H5": true
}
},
"h5-test": {
"title": "H5-TEST",
"env": {
"NODE_ENV": "production",
"UNI_PLATFORM": "h5",
"VUE_APP_BASE_API": "https://beta-text-api.nextopen.cn"
},
"define": {
"H5": true
}
},
"h5-prod": {
"title": "H5-PROD",
"env": {
"NODE_ENV": "production",
"UNI_PLATFORM": "h5",
"VUE_APP_BASE_API": "https://product-api.nextopen.cn"
},
"define": {
"H5": true
}
},

}
}
  1. 在根目录下创建vue.config.js文件,用于处理不同环境配置不同的基础路径
const fs = require('fs')
//此处如果是用HBuilderX创建的项目manifest.json文件在项目跟目录,如果是 cli 创建的则在 src 下,这里要注意
//process.env.UNI_INPUT_DIR为项目所在的绝对路径,经测试,相对路径会找不到文件
const manifestPath = process.env.UNI_INPUT_DIR + '/manifest.json'
let Manifest = fs.readFileSync(manifestPath, { encoding: 'utf-8' })
function replaceManifest(path, value) {
const arr = path.split('.')
const len = arr.length
const lastItem = arr[len - 1]

let i = 0
let ManifestArr = Manifest.split(/\n/)

for (let index = 0; index < ManifestArr.length; index++) {
const item = ManifestArr[index]
if (new RegExp(`"${arr[i]}"`).test(item)) ++i;
if (i === len) {
const hasComma = /,/.test(item)
ManifestArr[index] = item.replace(new RegExp(`"${lastItem}"[\\s\\S]*:[\\s\\S]*`), `"${lastItem}": ${value}${hasComma ? ',' : ''}`)
break;
}
}

Manifest = ManifestArr.join('\n')
}

// 动态修改 h5 路由 base
if (process.UNI_SCRIPT_ENV?.NODE_ENV === 'text'){
//测试的 base
replaceManifest('h5.router.base', '"/textConfig/"')
}else if (process.UNI_SCRIPT_ENV?.NODE_ENV === 'product'){
//生产的 base
replaceManifest('h5.router.base', '"/productConfig/"')
}else {
/其他的 base
replaceManifest('h5.router.base', '""')
}

fs.writeFileSync(manifestPath, Manifest, {
"flag": "w"
})

参考uniapp官方文档:uniapp.dcloud.net.cn/collocation…


作者:快乐是Happy
来源:juejin.cn/post/7337208702201086002
收起阅读 »

新年 10 个面试题,我曾 10 次拷问我的灵魂

web
大家好,这里是大家的林语冰。坚持阅读,自律打卡,每天一次,进步一点。 免责声明 本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考。英文原味版请传送 10 Interview Questions Every JavaScript Developer S...
继续阅读 »

大家好,这里是大家的林语冰。坚持阅读,自律打卡,每天一次,进步一点



免责声明


本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考。英文原味版请传送 10 Interview Questions Every JavaScript Developer Should Know in 2024



本期共享的是 —— 新年里前端面试需要掌握的十大面试题,知识面虽小,但思路清晰。


JS 的世界日新月异,多年来面试趋势也与时俱进。本文科普了新年每个 JS 开发者必知必会十大基本问题,涵盖了从闭包到 TDD(测试驱动开发)的一系列主题,为大家提供应对现代 JS 挑战的知识和信心。


1. 闭包到底是什么鬼物?


闭包让我们有权从内部函数访问外部函数的作用域。当函数嵌套时,内部函数可以访问外部函数作用域中声明的变量,即使外部函数返回后也是如此:


const createCat = cat => {
return {
getCat: () => cat,
setCat: newCat => {
cat = newCat
}
}
}

const myCat = createCat('薛定谔')
console.log(myCat.getCat()) // 薛定谔

myCat.setCat('龙猫')
console.log(myCat.getCat()) // 龙猫

闭包变量是对外部作用域变量的实时引用,而不是拷贝。这意味着,如果变更外部作用域变量,那么变更会反映在闭包变量中,反之亦然,这意味着,在同一外部函数中声明的其他函数将可以访问这些变更。


闭包的常见用例包括但不限于:



  • 数据隐藏

  • 柯里化和偏函数(经常用于改进函数组合,比如形参化 Express 中间件或 React 高阶组件)

  • 与事件处理程序和回调共享数据


数据隐藏


封装是面向对象编程的一个重要特征。封装允许我们向外界隐藏类的实现细节。JS 中的闭包允许我们声明对象的私有变量:


// 数据隐藏
const createGirlFans = () => {
let fans = 0
return {
increment: () => ++fans,
decrement: () => --fans,
getFans: () => fans
}
}

柯里化函数和偏函数:


// 一个柯里化函数一次接受多个参数。
const add = a => b => a + b

// 偏函数是已经应用了某些参数的函数,
// 但没有完全应用所有参数。
const increment = add(1) // 偏函数

increment(2) // 3

2. 纯函数是什么鬼物?


纯函数在函数式编程中兹事体大。纯函数是可预测的,这使得它们比非纯函数更易理解、调试和测试。纯函数遵循两个规则:



  1. 确定性 —— 给定相同的输入,纯函数会始终返回相同的输出。

  2. 无副作用 —— 副作用是在被调用函数外部可观察到的、不是其返回值的任何 App 状态更改。


非确定性函数依赖于以下各项的函数,包括但不限于:



  • 随机数生成器

  • 可以改变状态的全局变量

  • 可以改变状态的参数

  • 当前系统时间


副作用包括但不限于:



  • 修改任何外部变量或对象属性(比如全局变量或父函数作用域链中的变量)

  • 打印到控制台

  • 写入屏幕、文件或网络

  • 报错。相反,该函数应该返回表明错误的结果

  • 触发任何外部进程


在 Redux 中,所有 reducer 都必须是纯函数。如果不是,App 的状态不可预测,且时间旅行调试等功能无法奏效。reducer 函数中的杂质还可能导致难以追踪的错误,包括过时的 React 组件状态。


3. 函数组合是什么鬼物?


函数组合是组合两个或多个函数,产生新函数或执行某些计算的过程:(f ∘ g)(x) = f(g(x))


const compose = (f, g) => x => f(g(x))

const g = num => num + 1
const f = num => num * 2

const h = compose(f, g)

h(20) // 42

React 开发者可通过函数组合来清理大型组件树。我们可以将它们组合,创建一个新的高阶组件,而不是嵌套组件,该组件可以通过附加功能强化传递给它的任何组件。


4. 函数式编程是什么鬼物?


函数式编程是一种使用纯函数作为主要组合单元的编程范式。组合在软件开发中兹事体大,几乎所有编程范式都是根据它们使用的组合单元来命名的:



  • 面向对象编程使用对象作为组合单元

  • 过程式编程使用过程作为组合单元

  • 函数式编程使用函数作为组合单元


函数式编程是一种声明式编程范式,这意味着,程序是根据它们做什么,而不是如何做来编写的。这使得函数式程序比命令式程序更容理解、调试和测试。它们往往更加简洁,这降低了代码复杂性,并使其更易维护。


函数式编程的其他关键方面包括但不限于:



  • 不变性 —— 不可变数据结构比可变数据结构更易推理

  • 高阶函数 —— 将其他函数作为参数或返回函数作为结果的函数

  • 避免共享可变状态 —— 共享可变状态使程序难以理解、调试和测试。这也使得推断程序的正确性更加头大


5. Promise 是什么鬼物?


JS 中的 Promise 是一个表示异步操作最终完成或失败的对象,它充当最初未知值的占位符,通常是因为该值的计算尚未完成。


Promise 的主要特征包括但不限于:



  • 有状态Promise 处于以下三种状态之一:

    • 待定:初始状态,既未成功也未失败

    • 已完成:操作成功完成

    • 拒绝:操作失败



  • 不可变:一旦 Promise 被完成或拒绝,其状态就无法改变。它变得不可变,永久保留其结果。这使得 Promise 在异步流控制中变得可靠。

  • 链接Promise 可以链接起来,这意味着,一个 Promise 的输出可以用作另一个 Promise 的输入。这通过使用 .then() 表示成功或使用 .catch() 处理失败来链接,从而允许优雅且可读的顺序异步操作。链接是函数组合的异步等价物。


const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve('成功!')
// 我们也可以在失败时 reject 新错误。
}, 1000)
})

promise
.then(value => {
console.log(value) // 成功
})
.catch(error => {
console.log(error)
})

6. TS 是什么鬼物?


TS 是 JS 的超集,由微软开发和维护。近年来,TS 的人气与日俱增,如果您是一名 JS 工程师,您最终很可能需要使用 TS。它为 JS 添加了静态类型,JS 是一种动态类型语言。静态类型可以辅助开发者在开发过程的早期发现错误,提高代码质量和可维护性。


TS 的主要特点包括但不限于:



  • 静态类型:定义变量和函数参数的类型,确保整个代码一致性。

  • 给力的 IDE 支持:IDE(集成开发环境)可以提供更好的自动补全、导航和重构,使开发过程更加高效。

  • 编译:TS 代码被转译为 JS,使其与任何浏览器或 JS 环境兼容。在此过程中,类型错误会被捕获,使代码更鲁棒。

  • 接口:接口允许我们指定对象和函数必须满足的抽象契约。

  • 与 JS 的兼容性:Ts 与现有 JS 代码高度兼容。JS 代码可以逐步迁移到 JS,使现有项目能够顺利过渡。


interface User {
id: number
name: string
}

type GetUser = (userId: number) => User

const getUser: GetUser = userId => {
// 从数据库或 API 请求用户数据
return {
id: userId,
name: '人猫神话'
}
}

防范 bug 的最佳方案是代码审查、TDD 和 lint 工具(比如 ESLint)。TS 并不能替代这些做法,因为类型正确性并无法保证程序的正确性。即使应用了所有其他质量措施后,TS 偶尔也会发现错误。但它的主要好处是通过 IDE 支持,提供改进的开发体验。


7. Web Components 是什么鬼物?


WC(Web 组件)是一组 Web 平台 API,允许我们创建新的自定义、可重用、封装的 HTML 标签,在网页和 Web App 中使用。WC 是使用 HTML、CSS 和 JS 等开放 Web 技术构建的。它们是浏览器的一部分,不需要外部库或框架。


WC 对于拥有一大坨可能使用不同框架的工程师的大型团队特别有用。WC 允许我们创建可在任何框架或根本没有框架中使用的可重用组件。举个栗子,Adobe(PS 那个公司)的某个设计系统是使用 WC 构建的,并与 React 等流行框架顺利集成。


WC 由来已久,但最近人气爆棚,尤其是在大型组织中。它们被所有主要浏览器支持,并且是 W3C 标准。


8. React Hook 是什么鬼物?


Hook 是让我们无需编写类即可使用状态和其他 React 功能的函数。Hook 允许我们通过调用函数而不是编写类方法,来使用状态、上下文、引用和组件生命周期事件。函数的额外灵活性使我们更好地组织代码,将相关功能分组到单个钩子调用中,并通过在单独的函数调用中实现不相关功能,分离不相关的功能。Hook 提供了一种给力且富有表现力的方式来在组件内编写逻辑。


重要的 React Hook 包括但不限于:



  • useState —— 允许我们向函数式组件添加状态。状态变量在重新渲染之间保留。

  • useEffect —— 允许我们在函数式组件中执行副作用。它将 componentDidMount/componentDidUpdate/componentWillUnmount 的功能组合到单个函数调用中,减少了代码,并创建了比类组件更好的代码组织。

  • useContext —— 允许我们使用函数式组件中的上下文。

  • useRef —— 允许我们创建在组件的生命周期内持续存在的可变引用。

  • 自定义 Hook —— 封装可重用逻辑。这使得在不同组件之间共享逻辑变得容易。


Hook 的规则:Hook 必须在 React 函数的顶层使用(不能在循环、条件或嵌套函数内),且能且只能在 React 函数式组件或自定义 Hook 中使用。


Hook 解决了类组件的若干常见痛点,比如需要在构造函数中绑定方法,以及需要将功能拆分为多个生命周期方法。它们还使得在组件之间共享逻辑以及重用有状态逻辑,而无需更改组件层次结构更容易。


9. 如何在 React 中创建点击计数器?


我们可以使用 useState 钩子在 React 中创建点击计数器,如下所示:


import React, { useState } from 'react'

const ClickCounter = () => {
const [count, setCount] = useState(0) // 初始化为 0

return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count => count + 1)}>Click me</button>
</div>

)
}

export default ClickCounter

粉丝请注意,当我们从现有状态派生新值时,将函数传递给 setCount 是最佳实践,确保我们始终使用最新状态。


10. TDD 是什么鬼物?


TDD(测试驱动开发)是一种软件开发方法,其中测试是在实际代码之前编写的。它围绕一个简短的重复开发周期,旨在确保代码满足指定的要求且没有错误。TDD 在提高代码质量、减少错误和提高开发者生产力方面,可以发挥至关重要的作用。


开发团队生产力最重要的衡量标准之一是部署频率。持续交付的主要障碍之一是对变化的恐惧。TDD 通过确保代码始终处于可部署状态,辅助减少这种恐惧。这使得部署新功能和错误修复更容易,提高了部署频率。


测试先行多了一大坨福利,包括但不限于:



  • 更好的代码覆盖率:测试先行更有可能覆盖所有极端情况。

  • 改进的 API 设计:测试迫使我们在编写代码之前考虑 API 设计,这有助于避免将实现细节泄漏到 API 中。

  • 更少的 bug:测试先行可以辅助在开发过程中尽早发现错误,这样更容易修复。

  • 更好的代码质量:测试先行迫使我们编写模块化、松耦合的代码,这样更容易维护和重用。


TDD 的关键步骤包括但不限于:



  1. 编写测试:此测试最初会失败,因为相应的功能尚不存在。

  2. 编写实现:足以通过测试。

  3. 自信重构:一旦测试通过,就可以自信重构代码。重构是在不改变其外部行为的情况下,重构现有代码的过程。其目的是清理代码、提高可读性并降低复杂性。测试到位后,如果我们犯错了,我们会立即因测试失败而收到警报。


重复:针对每个功能需求重复该循环,逐步构建软件,同时确保所有测试继续通过。


学习曲线:TDD 是一项需要相当长的时间才能培养的技能和纪律。经过大半年的 TDD 体验后,我们可能仍觉得 TDD 难如脱单,且妨碍了生产力。虽然但是,使用 TDD 两年后,我们可能会发现它已经成为第二天性,并且比以前更有效率。


耗时:为每个小功能编写测试一开始可能会感觉很耗时,但长远来看,这通常会带来回报,减少错误并简化维护。我常常告诫大家,“如果你认为自己没有时间进行 TDD,那么你真的没有时间跳过 TDD。”


本期话题是 —— 你遭遇灵魂拷问的回头率最高的面试题是哪一道?


欢迎在本文下方群聊自由言论,文明共享。谢谢大家的点赞,掰掰~


《前端 9 点半》每日更新,坚持阅读,自律打卡,每天一次,进步一点


26-cat.gif


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

集帅(美)们,别再写 :key = "index" 啦!

web
浅聊一下 灵魂拷问:你有没有在v-for里使用过:key = "index",如果有,我希望你马上改正过来并且给我点个赞,如果没有,来都来了,顺手给我点个赞... 开始 在向掘友们解释为什么不能使用 :key = "index" 之前,我想我还得向你们铺垫一点...
继续阅读 »

浅聊一下


灵魂拷问:你有没有在v-for里使用过:key = "index",如果有,我希望你马上改正过来并且给我点个赞,如果没有,来都来了,顺手给我点个赞...


开始


在向掘友们解释为什么不能使用 :key = "index" 之前,我想我还得向你们铺垫一点东西


虚拟DOM


什么是虚拟DOM呢?虚拟DOM是一个对象,没想到吧...我们来看看Vue是如何将template模板里面的东西交给浏览器来渲染的


image.png


首先通过 compiler 将 template模板变成一个虚拟DOM,再将虚拟DOM转换成HTML,最后再交给浏览器V8引擎渲染,那么虚拟DOM是什么样的呢?


html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Documenttitle>
<script src="https://unpkg.com/vue@3/dist/vue.global.js">script>
head>

<body>
<div id="app">
<ul id="item">
<li v-for="item in list" class="item">{{item}}li>
ul>
div>
<script>
const { createApp, ref } = Vue
createApp({
setup() {
const list = ref(['vue','js','html'])
return {
list
}
}
}).
mount('#app')
script>
body>

html>

在这里,template模板实际上是


 <ul>
<li v-for="item in list">{{item}}li>
ul>

通过v-for循环,渲染出来了3个li


<ul>
<li>vue<li>
<li>js<li>
<li>html<li>
ul>

我们的compiler会将这个模板转化成虚拟DOM


let oldDom = {
tagName = 'ul',
props:{
//存放id 和 class 等
id:'item'
},
children[
{
tagName = 'li',
props:{
class:'item'
},
children:['vue']
},
{
tagName = 'li',
props:{
class:'item'
},
children:['js']
},
{
tagName = 'li',
props:{
class:'item'
},
children:['html']
},
]
}

diff算法


给前面的例子来点刺激的,加上一个按钮和反转函数,点击按钮,list反转


html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Documenttitle>
<script src="https://unpkg.com/vue@3/dist/vue.global.js">script>
head>

<body>
<div id="app">
<ul>
<li v-for="item in list">{{item}}li>
ul>
<button @click="change">changebutton>
div>
<script>
const { createApp, ref } = Vue
createApp({
setup() {
const list = ref(['唱','跳','rap','篮球'])
const change = ()=>{
list.
value.reverse()
}
const add = ()=>{
list.
value.unshift('6')
}
return {
list,
change,
}
}
}).
mount('#app')
script>
body>

html>

点击change按钮,此时我们的DOM更改vue又是如何来更新DOM的呢?


image.png


众所周知,回流和重绘会消耗极大的性能,而当DOM发生变更的时候会触发回流重绘(可以去看我的文章(从输入4399.com到页面渲染之间的回流和重绘),那么vue3就有一个diff算法,用来优化性能


image.png


当DOM更改,compiler会生成一个新的虚拟DOM,然后通过diff算法来生成一个补丁包,用来记录旧DOM和新DOM的差异,然后再拿到html里面进行修改,最后再交给浏览器V8进行渲染


简单介绍一下diff算法的比较规则



  1. 同层比较,是不是相同的结点,不相同直接废弃老DOM

  2. 是相同结点,比较结点上的属性,产生一个补丁包

  3. 继续比较下一层的子节点,采用双端对列的方式,尽量复用,产生一个补丁包

  4. 同上


image.png


别再写 :key = "index"


要说别写 :key = "index" ,我们得先明白key是用来干什么的...如果没有key,那么在diff算法对新旧虚拟DOM进行比较的时候就没法比较了,你看这里有两个一样的vue,当反转顺序以后diff算法不知道哪个vue该对应哪个vue了


image.png


如果我们用index来充当key的话来看,当我们在头部再插入一个结点的时候,后面的index其实是改变了的,导致diff算法在比较的时候认为他们与原虚拟DM都不相同,那么diff算法就等于没有用...


image.png


可以用随机数吗?


  • for="item in list" :key="Math.random()">

  • 想出这种办法的,也是一个狠人...当然是不行的,因为在template模板更新时,会产生一个新的虚拟DOM,而这个虚拟DOM里面的key也是随机值,和原虚拟DOM里的key99.99999%是不一样的...


    结尾


    希望你以后再也不会写 :key = "index" 了


    作者:滚去睡觉
    来源:juejin.cn/post/7337513012394115111
    收起阅读 »

    图片转base64,实现图片上传?你学会了吗?

    web
    前言 前段时间在写我的VUE全栈项目的时候,遇到要把前端的照片上传到后端,再由后端存到数据库的问题,通过网上查找资料,看了一些其他写者的解决方法,最终采用转BASE64的方法。本人觉得把上传的图片转为BASE64格式相比其他是比较简单的。 什么是BASE64 ...
    继续阅读 »

    前言


    前段时间在写我的VUE全栈项目的时候,遇到要把前端的照片上传到后端,再由后端存到数据库的问题,通过网上查找资料,看了一些其他写者的解决方法,最终采用转BASE64的方法。本人觉得把上传的图片转为BASE64格式相比其他是比较简单的。


    什么是BASE64


    Base64是一种用64个字符来表示任意二进制数据的方法。它是一种编码方式,而非加密方式,即可以将一张图片数据编码成一串字符串,使用该字符串代替图像地址


    BASE64的优缺点


    优点: 减少一张图片的http请求


    缺点: 导致转换后的css文件体积增大,而CSS 文件的体积直接影响渲染,导致用户会长时间注视空白屏幕,而且转换后的数据是一大串字符串。



    注意:图片转BASE64格式的适合小图片或者极简单图片,大图片不划算。它的格式为:data:image/type;base64,xxxx...



    虽然说这种方式不适用于体积大的图片,但不得不说有时候还挺方便的。由于在我的vue项目中上传的图片都比较小,单一,为了方便我采用了这种方式来实现将前端上传的图片存到数据库中。


    话不多说,进入正题!下面以Vue+Koa框架、数据库为MYSQL为例。


    案例


    前端: 首先在把图片传给后端之前,前端对图片进行格式转换,转换成功后就可以照常调用后端给的接口,传进去就行。



    我这里就是点了提交按钮后,触发编写的点击事件函数,在函数里先对图片转base64,转成功后再调用后端给的接口,把此数据以及其他数据传进去就行。



    在前端编写转base64的函数(很重要)


    export function uploadImgToBase64 (file) {
    return new Promise((resolve, reject) => {
    const reader = new FileReader()//html5提供的一种异步文件读取机制
    reader.readAsDataURL(file)//将文件读取为Base64编码的数据URL
    reader.onload = function () { // 图片转base64完成后返回reader对象
    resolve(reader)
    }
    reader.onerror = reject
    })
    }

    前端要为转为base64的图片的相应操作


    const state = reactive({
    picture: [] //这个是用来装表单里选中的照片
    })


    const imgBroadcastListBase64 = [] //用来存放转base64后的照片,用数组是因为上传的图片可能不止一张
    console.log('图片转base64开始...')
    // 遍历每张图片picture 异步
    const filePromises = state.picture.map(async file => {
    //console.log(file);
    const response = await uploadImgToBase64(file.file) //调用函数 将图片转为base64
    //console.log(response,111);
    return response.result.replace(/.*;base64,/, '') // 去掉data:image/jpeg;base64,
    })
    // 按次序输出 base64图片
    for (const textPromise of filePromises) {
    imgBroadcastListBase64.push(await textPromise)
    }
    console.log('图片转base64结束..., ', imgBroadcastListBase64)


    //判断imgBroadcastListBase64是否<=1,是的话就是上传一张图片,否则上传的是多张图片
    if(imgBroadcastListBase64.length<=1){
    state.imgsStr = imgBroadcastListBase64.join()//转字符串
    }else{
    state.imgsStr = imgBroadcastListBase64.join(',')//转字符串并且每个值用','拼接,这样是为了方便后面从数据库拿到数据,将图片又转为之前的base64格式
    }
    //调用后端提供的接口,传数据到数据库里(这个只是自己编写的后端接口,主要是为了展示传数据)
    const res = await secondGoodsAdd({
    create_time: ti,
    content_goods: state.content,
    color: state.title2,
    price: state.title3,
    tel: state.title4,
    img: state.imgsStr,//转base64后的图片
    concat_num: 0,
    like_num: 0,
    name_goods: state.title1
    })
    if (res.code === '80000') {
    showSuccessToast('发布成功!')
    }
    router.push('/cicle')


    存到数据库中的图片路径是转为base64后的且删除前面data:image/jpeg;base64的字符串。



    这样数据就存到数据库中啦!存进去的是字符串。


    那么问题来了,数据是存进去了,但是我又想拿到这个数据到前端显示出来,好,那我就先直接拿到前端用,结果发现报错了!说是请求头太长?想办法解决下!后面我的解决办法是拿到这个转为了base64且去掉前面data...字段的的图片数据再转为正常的base64的格式。好,来转换吧!


    //我这里是在后端编写的接口,用于展示被添加到数据库中的所有数据
    router.get('/cirleLifeLook',async(ctx,next)=>{
    try {
    const result=await userService.cirleLifeLook()
    for(let i=0;i<result.length;i++){
    var imgData=result[i].img //获取每条数据中的照片字段
    if(imgData){
    if(imgData.indexOf(',')>-1){//存在','的话代表是多张图片的混合的字符串
    let ans=imgData.split(',') //切割 获得多张之前切掉data...后的base64字符串
    let s=[]
    for(let j=0;j<ans.length;j++){
    s.push("data:image/png;base64,"+ans[j])//还原每张图片初始的base64数据
    }
    result[i].img=s
    }else{
    result[i].img="data:image/png;base64,"+imgData //就一张图片直接在前面拼接"data:image/png;base64,"
    }
    }
    }//到此为止,在给前端传数据前就修改了其中每条数据里的照片地址,这样就可以正常显示啦
    if(result.length){
    ctx.body={
    code:'80000',
    data:result,
    msg:'获取成功'
    }
    }else{
    ctx.body={
    code:'80005',
    data:'null',
    msg:'还没有信息'
    }
    }
    } catch (error) {
    ctx.body={
    code:'80002',
    data:error,
    msg:'服务器异常'
    }
    }
    })

    OK,到此就结束啦~现在是不是觉得把图片转base64还是挺简单的?还挺有用的?快去实践下吧。记住此方法只适合小图片类型的,大点的文件可能会崩掉哈!


    结束语


    本篇文章就到此为止啦,由于本人经验水平有限,难免会有纰漏,对此欢迎指正。如觉得本文对你有帮助的话,欢迎点赞收藏❤❤❤,您的点赞是持续写作的动力,感谢支持。要是您觉得有更好的方法,欢迎评论,提出建议!


    作者:嗯嗯呢
    来源:juejin.cn/post/7255785481119727672
    收起阅读 »

    将一个图片地址转成文件流(File)再上传

    web
    写在开头 最近,小编在业务中遇到一个图片转存的场景。 领导🤠:大概过程就是,接口会给我返回一个图片列表数据,图片路径是全路径,但是路径中的域名是其他系统的,必须要在用户选择图片的时候将图片重新转存到自个的系统上,防止其他系统删除图片对此有影响。 我😃:Em....
    继续阅读 »

    写在开头


    最近,小编在业务中遇到一个图片转存的场景。


    领导🤠:大概过程就是,接口会给我返回一个图片列表数据,图片路径是全路径,但是路径中的域名是其他系统的,必须要在用户选择图片的时候将图片重新转存到自个的系统上,防止其他系统删除图片对此有影响。


    14DBA96E.gif


    我😃:Em...很合理的需求。


    (但是,和有什么关系?我只是一个前端小菜鸡呀,不祥的预感.......)


    我😃:(卑微提问)这个过程不是放后端做比较合理一点?


    后端大哥😡:前端不能做?


    我😣:可以可以,只是...这个好像会跨域?


    后端大哥😠:已经配置了请求头('Access-Control-Allow-Origin': '*')。


    我😖:哦,好的,我去弄一下。(*******此处省略几万字心理活动内容)


    14F03F73.jpg

    第一种(推荐)


    那么,迫于......不,我自愿的,我们来看看前端要如何完成这个转成过程,代码比较简单,直接贴上来瞧瞧:


    async function imageToStorage(path) {
    // 获取文件名
    const startIndex = path.lastIndexOf('/');
    const endIndex = path.indexOf('?');
    const imgName = path.substring(startIndex + 1, endIndex);
    // 获取图片的文件流对象
    const file = await getImgToFile(path, imgName);
    // TODO: 将File对象上传到其他接口中
    }

    /**
    * @name 通过fetch请求文件,将文件转成文件流对象
    * @param { string } path 文件路径全路径
    * @param { string } fileName 文件名
    * @returns { File | undefined }
    */

    function getImgToFile(path, fileName) {
    const response = await fetch(path);
    if (response) {
    const blob = await response.blob();
    const file = new File([blob], fileName, { type: blob.type });
    return file;
    }
    }

    上述方式,在后端配置了允许跨域后,正常是没有什么问题的,也是比较好的一种方式了。😃


    但是,在小编实际第一次编码测试后,却还是遇上了跨域。😓


    image.png


    一猜应该就是后端实际还没配置好,问了一下。


    后端大哥😑:还没部署,一会再自己试试。


    我😤:嗯嗯。


    第二种


    等待的过程,小编又在网上找了找了,找到了第二种方式,各位看官可以瞧瞧:


    /** @name 将图片的网络链接转成base64 **/
    function imageUrlToBase64(imageUrl: string, fileName: string): Promise<File> {
    return new Promise(resolve => {
    const image = new Image();
    // 让Image元素启用cors来处理跨源请求
    image.setAttribute('crossOrigin', 'anonymous');
    image.src = imageUrl + '&v=' + Math.random();
    image.onload = () => {
    const canvas = document.createElement('canvas');
    canvas.width = image.width;
    canvas.height = image.height;
    const context = canvas.getContext('2d')!;
    context.drawImage(image, 0, 0, image.width, image.height);
    // canvas.toDataURL
    const imageBase64 = canvas.toDataURL('image/jpeg', 1); // 第二个参数是压缩质量
    // 将图片的base64转成文件流
    const file = base64ToFile(imageBase64, fileName);
    resolve(file);
    };
    });
    }
    /** @name 将图片的base64转成文件流 **/
    function base64ToFile(base64: string, fileName: string) {
    const baseArray = base64.split(',');
    // 获取类型与后缀名
    const mime = baseArray[0].match(/:(.*?);/)![1];
    const suffix = mime.split('/')[1];
    // 转换数据
    const bstr = atob(baseArray[1]);
    let n = bstr.length;
    const u8arr = new Uint8Array(n);
    while (n--) {
    u8arr[n] = bstr.charCodeAt(n);
    }
    // 生成文件流
    const file = new File([u8arr], `${fileName}.${suffix}`, {
    type: mime,
    });
    return file;
    }

    这第二种方式由于要先把图片绘制到 canvas 再去转成 base64 再去转成文件流,小编用 console.time 稍微测了一下,每次转化过程都要几百毫秒,图片越大时间越长,挺影响性能的。


    所以,小编还是推荐使用第一种方式,当然,最稳妥的方案是后端去搞最好了。😉



    网上很多都说第二种方式可以直接绕过跨域,各种谈论。😪


    主要就是这个 crossOrigin 属性。MDN解释


    它原理是通过了 CORS


    image.png


    或者可以再看看这个解释:传送门










    至此,本篇文章就写完啦,撒花撒花。


    image.png


    希望本文对你有所帮助,如有任何疑问,期待你的留言哦。

    老样子,点赞+评论=你会了,收藏=你精通了。


    作者:橙某人
    来源:juejin.cn/post/7336756027385872424
    收起阅读 »

    NestJS 依赖注入DI与控制反转IOC

    web
    1. 前言 在《NestJS 快速入门》和《NestJS HTTP 请求与响应 》两篇文章中,主要使用了 NestJS 控制器处理简单的逻辑,对于复杂的业务逻辑,通常使用单独的 Service 服务进行处理,然后可以把 Service 作为依赖注入到 Cont...
    继续阅读 »

    1. 前言


    在《NestJS 快速入门》和《NestJS HTTP 请求与响应 》两篇文章中,主要使用了 NestJS 控制器处理简单的逻辑,对于复杂的业务逻辑,通常使用单独的 Service 服务进行处理,然后可以把 Service 作为依赖注入到 Controller 中。


    2. 概念


    2.1 依赖注入、控制反转、容器


    何为容器?容器是可以装一些资源的。举个例子,普通的容器,生活中的容器比如保温杯,只能用来存储东西,没有更多的功能。而程序中的容器则包括数组、集合Set、Map 等。


    而复杂的容器,生活中比如政府,政府管理我们的一生,生老病死和政府息息相关。


    AB7410FF-F52F-4C58-9171-DFB6303157DD.png


    程序中复杂的容器比如NestJS 容器,它能够管理 Controller、Service 等组件,负责创建组件的对象、存储 组件的对象,还要负责 调用 组件的方法让其工作,并在一定的情况下 销毁 组件。


    依赖注入(Dependency Injection)是实现控制反转的一种方式。控制反转又是什么呢?控制反转(Inversion of Control)是指从容器中获取资源的方式跟以往有所不同。


    2.2 为什么需要控制反转


    2.2.1 依赖关系复杂、依赖顺序约束


    后端系统中有多个对象:



    • Controller 对象: 处理 HTTP 请求,调用 Service,返回响应。

    • Service 对象: 实现业务逻辑。

    • Repository 对象: 实现对数据库的增删改查。


    此外,还包括数据库链接对象 DataSource、配置对象 Config 等等。这些对象之间存在复杂的关系:



    • Controller 依赖 Service 实现业务逻辑。

    • Service 依赖 Repository 进行数据库操作。

    • Repository 依赖 DataSource 建立连接,而 DataSource 则需要从 Config 对象获取用户名密码等信息。


    这导致对象的创建变得复杂,需要理清它们之间的依赖关系,确保正确的创建顺序。例如:


    const config = new Config({ username: 'xxx', password: 'xxx'});
    const dataSource = new DataSource(config);
    const repository = new Repository(dataSource);
    const service = new Service(repository);
    const controller = new Controller(service);

    这些对象需要一系列初始化步骤后才能使用。此外,像 config、dataSource、repository、service、controller 这些对象不需要每次都新建一个,可以保持单例。在应用初始化时,需要明确依赖关系,创建对象组合,并确保单例模式,这是后端系统常见的挑战。


    2.3.2 高层逻辑直接依赖低层逻辑,违反依赖倒置规范



    依赖倒置: 什么是依赖倒置原则(Dependency Inversion Principle)高层模块不应该依赖底层模块,二者都应该依赖抽象(例如接口)。  抽象不应该依赖细节,细节(具体实现)应该依赖抽象。 



    1.举一个工厂例子,初始化时有工人、车间、工厂。


    2FE7E55F-42A4-48FF-9A6B-8E987E386A7F.png


    1.工厂是容器,车间是消费者,依赖工人和工人的服务,工人是依赖,是生产者。


    // 工人
    class Worker {
      manualProduceScrew(){
        console.log('A screw is built')
      }
    }

    // 螺丝生产车间
    class ScrewWorkshop {
      private worker: Worker = new Worker()
     
      produce(){
        this.worker.manualProduceScrew() // 调用工人的方法
      }
    }

    // 工厂
    class Factory {
      start(){
        const screwWorkshop = new ScrewWorkshop()
        screwWorkshop.produce()
      }
    }

    const factory = new Factory()
    // 工厂开工啦!!!
    factory.start()



    2.现在要把工人制造改为机器制造,需要直接在车间把工人制造改为机器制造,麻烦。


    // 机器
    class Machine {
      autoProduceScrew(){
        console.log('A screw is built')
      }
    }

    class ScrewWorkshop {
      // 改为一个机器实例
      private machine: Machine = new Machine()
     
      produce(){
        this.machine.autoProduceScrew() // 调用机器的方法
      }
    }

    class Factory {
      start(){
        const screwWorkshop = new ScrewWorkshop()
        screwWorkshop.produce()
      }
    }

    const factory = new Factory()
    // 工厂开工啦!!!
    factory.start()


    3.此时考虑依赖倒置原则,通过实现生产者接口来处理(PS:这也是为什么像 Java 语言中,实现业务服务时需要定义接口类和实现类,遵循依赖倒置,方便切换不同的业务逻辑)


    // 定义一个生产者接口
    interface Producer {
      produceScrew: () => void
    }

    // 实现了接口的机器
    class Machine implements Producer {
      autoProduceScrew(){
        console.log('A screw is built')
      }
     
      produceScrew(){
        this.autoProduceScrew()
      }
    }

    // 实现了接口的工人
    class Worker implements Producer {
      manualProduceScrew(){
        console.log('A screw is built')
      }
     
      produceScrew(){
        this.manualProduceScrew()
      }
    }

    class ScrewWorkshop {
      // 依赖生产者接口,可以随意切换啦!!!
      // private producer: Producer = new Machine()
      private producer: Producer = new Worker()
     
      produce(){
        this.producer.produceScrew() // 工人和机器都提供了相同的接口
      }
    }

    class Factory {
      start(){
        const screwWorkshop = new ScrewWorkshop()
        screwWorkshop.produce()
      }
    }

    const factory = new Factory()
    // 工厂开工啦!!!
    factory.start()



    4.工厂改造后,螺丝生产车间的改造变得更容易了,只需要改变其属性中所新建的遵循Producer接口的实例即可。然而,这并没有完全改善我们与车间主任之间的关系,每次厂里在改造生产机器时我们还是需要麻烦车间主任。这是因为我们还是没有完全遵守依赖倒置原则,ScrewWorkshop 仍然依赖了 Worker/Machine的实例,只不过这种依赖相较之前少了一点罢了。


    要完全遵守依赖倒置原则,需要使用控制反转依赖注入


    2.3 控制反转思想


    2.3.1 获取资源的传统方式



    • 自己做饭:买菜、洗菜、择菜、切菜、炒菜,全过程参与,费时费力,必须了解资源创建整个过程中的全部细节并熟练掌握。

    • 在应用程序中,组件需要获取资源,传统的方式是主动从容器中获取所需资源,这样开发人员需要知道具体容器中特定资源的获取方式,增加了学习成本,也降低了开发效率。


    2.3.2 获取资源的控制反转方式



    • 点外卖:下单、等待、吃外卖,省时省力,不必关心资源创建过程的全部细节。

    • 控制反转的思想改变了应用程序组件获取资源的方式,容器会主动将资源推送给需要的组件,开发人员只需要提供接收资源的方式即可,这样可以降低学习成本,提高开发效率。这种方式被称为查找的被动方式。


    2.4 如何实现控制反转


    起源:许多应用程序的业务逻辑实现需要两个或多个类之间的协作,这种协作使得每个对象都需要获取与其合作的对象(即其所依赖的对象的引用)。如果这种获取过程由对象自身实现,那么将导致代码高度耦合,难以维护和调试。


    技术描述


    在 Class A 中,我们使用了 Class B 的对象 b。通常情况下,我们需要在 A 的代码中显式地使用 new 来创建 B 的对象。但是,如果采用依赖注入技术,A 的代码只需要定义一个 private 的 B 对象,而不需要直接 new 来获取这个对象。相反,我们可以通过相关的容器控制程序来在外部创建 B 对象,并将其注入到 A 类中的引用中。具体获取的方法以及对象被获取时的状态由配置文件(如 XML)来指定。这种方法可以使代码更加清晰和正式。


    loc 也可以理解为把流程的控制从应用程序转移到框架之中。以前,应用程序掌握整个处理流程;现在,控制权转移到了框架,框架利用一个引擎驱动整个流程的执行,框架会以相应的形式提供一系列的扩展点,应用程序则通过定义扩展的方式实现对流程某个环节的定制。“框架Call 应用”。基于 MVC 的 web 应用程序就是如此。


    实现方法


    实现控制反转主要有两种方式:依赖注入和依赖查找。两者的区别在于,前者是被动的接收对象,在类 A 的实例创建过程中即创建了依赖的 B对象,通过类型或名称来判断将不同的对象注入到不同的属性中,而后者是主动索取相应类型的对象,获得依赖对象的事件也可以在代码中自由控制。


    细说


    1.依赖注入:



    • 基于接口。实现特定接口以供外部容器注入所依赖类型的对象

    • 基于set方法。实现特定属性的publicSet方法,来让外部容器调用传入所依赖类型的对象

    • 基于构造函数。实现特定参数的构造函数,在新建对象时传入所依赖类型的对象

    • 基于注解。基于Java的注解功能,在私有变量前加“@Autowired”等注解,不需要显式的定义以上三种代码,便可以让外部容器传入对应的对象。该方案相当于定义了public的set方法,但是因为没有真正的set方法,从而不会为了实现依赖注入导致暴露了不该暴露的接口(因为set方法只想让容器访问来注入而并不希望其他依赖此类的对象访问)。


    2.依赖查找


    依赖查找更加主动,在需要的时候通过调用框架提供的方法来获取对象,获取时需要提供相关的配置文件路径、key等信息来确定获取对象的状态。


    2.4.1 工厂例子依赖注入改造


    通过以上学习,现在把工厂例子代码进一步改造,将底层类的依赖,由从中间类直接引用,变为高层类在构造时的依赖注入


    // ......Worker/Machine及其所遵循的接口Producer的实现与此前一致,此处省略

    class ScrewWorkshop
      private producer: Producer
     
      // 通过构造函数注入
      constructor(producer: Producer){
        this.producer = producer
      }
     
      produce(){
        this.producer.produceScrew()
      }
    }

    class Factory {
      start(){
        // 在Factory类中控制producer的实现,控制反转啦!!!
        // const producer: Producer = new Worker()
        const producer: Producer = new Machine()
        // 通过构造函数注入
        const screwWorkshop = new ScrewWorkshop(producer)
        screwWorkshop.produce()
      }
    }

    const factory = new Factory()
    // 工厂开工啦!!!
    factory.start()



    至此,回顾对这个车间的改造三步



    1. 依赖倒置: 解除ScrewWorkshop与Worker/Machine具体类之间的依赖关系,转为全部依赖Producer接口;

    2. 控制反转: 在Factory类中实例化ScrewWorkshop中需要使用的producer,ScrewWorkshop的对依赖项Worker/Machine的控制被反转了;

    3. 依赖注入: ScrewWorkshop中不关注具体producer实例的创建,而是通过构造函数constructor注入;


    3. NestJS 依赖注入


    在Nest的设计中遵守了控制反转的思想,使用依赖注入(包括构造函数注入、参数注入、Setter方法注入)解藕了Controller 与 Provider之间的依赖。


    我们将Nest中的元素与我们自己编写的工厂进行一个类比:



    1. Provider & Worker/Machine:真正提供具体功能实现的低层类。

    2. Controller & ScrewWorkshop:调用低层类来为用户提供服务的高层类。

    3. Nest框架本身 & Factory:控制反转容器,对高层类和低层类统一管理,控制相关类的新建与注入,解藕了类之间的依赖。


    IOC 机制是在 class 上标识哪些是可以被注入的,它的依赖是什么,然后从入口开始扫描这些对象和依赖,自动创建和组装对象。


    Nest 实现了 IOC 容器,会从入口模块开始扫描,分析 Module 之间的引用关系,对象之间的依赖关系,自动把 provider 注入到目标对象。


    Nest 里通过 @Controller 声明可以被注入的 controller,通过 @Injectable 声明可以被注入也可以注入别的对象的 provider,然后在 @Module 声明的模块里引入。并且Nest 还提供了 Module 和 Module 之间的 import,可以引入别的模块的 provider 来注入。


    provider 一般都是用 @Injectable 修饰的 class:


    2864AB0B-6A5F-4C29-AE1C-87AD610BA635.png


    在 Module 的 providers 里声明:


    27933B1A-306E-43E2-8203-598E1998B6B0.png


    上面是一种简写,完整的写法是这样的


    4CF33A34-F899-4AA0-9DA0-B93D177C9760.png


    构造函数或者属性注入


    E9561C4C-D02B-47DD-8B66-D0076666E2A8.png


    异步的注入对象


    24974844-A158-4FC1-8A14-33A1AF8033FB.png


    通常情况下,提供者通过使用 @Injectable 声明,然后在 @Moduleproviders 数组中注册类来实现。默认的 token 是类本身,因此不需要使用 @Inject 来指定注入的 token。


    但是,也可以使用字符串类型的 token,但在注入时需要单独指定 @Inject。除了可以使用 useClass 指定注入的类,还可以使用 useValue 直接指定注入的对象。如果想要动态生成对象,则可以使用 useFactory,它的参数也注入到 IOC 容器中的对象中,然后动态返回提供者的对象。如果想要为已有的 token 指定一个新的 token,可以使用 useExisting 来创建别名。通过灵活运用这些提供者类型,可以在 Nest 的 IOC 容器中注入任何对象。


    4.实践


    之前部门逻辑都是放在 controller 中的,现在可以把逻辑放 dept.service.ts  上来,添加 @Injectable 装饰器。


    9102DFFB-B32C-4ECE-8A5E-4D34135A6391.png


    在 DeptModule  模块中的 propviders 中引入 DeptService


    1397E621-39A4-4EF7-829A-8500C7B7B0B6.png


    最后在 dep.controller  使用部门服务,通过 @Inject() 装饰器注入。


    C63CF5D6-F08C-440C-9255-5688F35ADA41.png


    小结


    本文我们学习了什么是容器、依赖注入与控制反转,掌握了 Nest 依赖注入使用,并通过部门服务例子进行演示。


    参考资料



    作者:jecyu
    来源:juejin.cn/post/7336055070508843048
    收起阅读 »

    前端最全的5种换肤方案总结

    web
    最近一年很多客户要求更换主题色,而团队的项目基础框架不一,因此几乎使用了所有的更换主题的方案。以下总结了各方案的实现以及优缺点,希望能帮助有需要更换主题色的伙伴少踩坑。 方案一:硬编码 对一些老的前端基础库和业务库样式没有提取公共样式,当有更换主题的需求,实现...
    继续阅读 »

    最近一年很多客户要求更换主题色,而团队的项目基础框架不一,因此几乎使用了所有的更换主题的方案。以下总结了各方案的实现以及优缺点,希望能帮助有需要更换主题色的伙伴少踩坑。


    方案一:硬编码


    对一些老的前端基础库和业务库样式没有提取公共样式,当有更换主题的需求,实现的方法只能是全局样式替换,工作量比较大,需要更改form表单、按钮、表格、tab、容器等所有组件的各种状态,此外还需更换icon图标。


    以下是我们的一个老项目实现主题色更换,全局样式替换接近500行,如下图所示:


    image.png


    image.png


    image.png


    总结: 对于这种老项目只能通过硬编码的方式去更改,工作量较大,好在老项目依赖同一个基础库和业务库,所以在一个项目上实现了也可以快速推广到其它项目。


    方案二:sass变量配置


    团队的基础组件库Link-ui是基于Eelement-ui二次开发,因此可以采取类似于Element-ui的方式进行主题更改,只需要设计师提供6个主题色即可完成主题色的更改,如下所示。


    image.png



    • 配置基础色
      基础色一般需要设计师提供,也可以通过配置化的方式实现,


    $--color-primary-bold: #1846D1 !default;
    $--color-primary: #2664FD !default;
    $--color-primary-light: #4D85FD !default;
    $--color-primary-light-1: #9AC0FE !default;
    $--color-primary-light-2: #C1DBFF !default;
    $--color-primary-lighter: #E8F2FF !default;



    • 从基础库安装包引入基础色和库的样式源文件
      image.png


    @import "./common/base_var.scss";

    /* 改变 icon 字体路径变量,必需 */
    $--font-path: '~link-ui-web/lib/theme-chalk/fonts';

    @import "~link-ui-web/packages/theme-chalk/src/index";


    • 全局引入


    import '@/styles/link-variables.scss';


    • 更换主题色
      只需要更改上面的6个变量即可实现主题色的更改,比如想改成红色,代码如下


    $--color-primary-bold: #D11824 !default;
    $--color-primary: #FD268E !default;
    $--color-primary-light: #D44DFD !default;
    // $--color-primary-light-1: #9AC0FE !default;
    $--color-primary-light-2: #DCC1FF !default;
    $--color-primary-lighter: #F1E8FF !default;

    image.png


    总结: 对于基础库和样式架构设计合理的项目更改主题色非常的简单,只要在配置文件更换变量的值即可。它的缺点是sass变量的更改每次都需要编译,很难实现配置化。


    方案三、css变量+sass变量+data-theme


    代码结构如下:


    image.png



    • 设计三套主题分别定义不同的变量(包含颜色、图标和图片)


      // theme-default.scss
    /* 默认主题色-合作蓝色 */
    [data-theme=default] {
    --color-primary: #516BD9;
    --color-primary-bold: #3347B6;

    --color-primary-light: #6C85E1;
    --color-primary-light-1: #C7D6F7;
    --color-primary-light-2: #c2d6ff;
    --color-primary-lighter: #EFF4FE;

    --main-background: linear-gradient(90deg,#4e68d7, #768ff3);

    --user-info-content-background-image: url('../../assets/main/top-user-info-bg.png');
    --msg-tip-content-background-image: url('../../assets/main/top-user-info-bg.png');
    ...
    }


      // theme-orange.scss
    // 阳光黄
    [data-theme=orange] {
    --color-primary: #FF7335;
    --color-primary-bold: #fe9d2e;

    --color-primary-light: #FECB5D;
    --color-primary-light-1: #FFDE8B;
    --color-primary-light-2: #fcdaba;
    --color-primary-lighter: #FFF3E8;

    --main-background: linear-gradient(90deg,#ff7335 2%, #ffa148 100%);


    --user-info-content-background-image: url('../../assets/main/top-user-info-bg-1.png');
    --msg-tip-content-background-image: url('../../assets/main/top-user-info-bg-1.png');
    ...
    }


      // theme-red.scss
    /* 财富红 */
    [data-theme=red] {
    --color-primary: #DF291E;
    --color-primary-bold: #F84323;

    --color-primary-light: #FB8E71;
    --color-primary-light-1: #FCB198;
    --color-primary-light-2: #ffd1d1;
    --color-primary-lighter: #FFEEEE;


    --main-background: linear-gradient(90deg,#df291e 2%, #ff614c 100%);

    --user-info-content-background-image: url('../../assets/main/top-user-info-bg-2.png');
    --msg-tip-content-background-image: url('../../assets/main/top-user-info-bg-2.png');
    ...
    }



    • 把主题色的变量作为基础库的变量


    $--color-primary-bold: var(--color-primary-bold) !default;
    $--color-primary: var(--color-primary) !default;
    $--color-primary-light: var(--color-primary-light) !default;
    $--color-primary-light-1: var(--color-primary-light-1) !default;
    $--color-primary-light-2: var(--color-primary-light-2) !default;
    $--color-primary-lighter: var(--color-primary-lighter) !default;


    • App.vue指定默认主题色


    window.document.documentElement.setAttribute('data-theme', 'default')

    data-theme会注入到全局的变量上,所以我们可以在任何地方获取定义的css变量


    image.png


    实现效果如下:


    image.png


    image.png


    image.png


    总结: 该方案是最完美的方案,但是需对颜色、背景图、icon等做配置,需设计师设计多套方案,工作量相对较大,适合要求较高的项目或者标准产品上面,目前我们的标准产品选择的是该方案。


    方案四:滤镜filter


    filter CSS属性将模糊或颜色偏移等图形效果应用于元素。滤镜通常用于调整图像,背景和边框的渲染。


    它有个属性hue-rotate() 用于改变图整体色调,设定图像会被调整的色环角度值。值为0deg展示原图,大于360deg相当于又绕一圈。
    用法如下:


    body {
    filter: hue-rotate(45deg);
    }


    产品新建UI单元测试运行录制.gif


    总结: 成本几乎为0,实现简单。缺点是对于某些图片或者不想改的颜色需要特殊处理。


    方案五:特殊时期变灰



    • filter还有个属性 grayscale() 改变图像灰度,值在 0% 到 100% 之间,值为0%展示原图,值为100% 则完全转为灰度图像。


    body {
    filter: grayscale(1);
    }

    image.png


    总结: 成本小,可以将该功能做成配置项,比如配置它的生效开始时间和生效结束时间,便于运营维护也不用频繁发布代码。


    总结


    以上就是实现换肤的全部方案,我们团队在实际项目都有使用,比较好推荐的方案是方案一、方案三、方案五,对于要求不高的切换主题推荐方案四,它的技术零成本,对于标准产品推荐方案三。如有更好的方案欢迎评论区交流。


    作者:_无名_
    来源:juejin.cn/post/7329573754987462693
    收起阅读 »

    vue3的宏到底是什么东西?

    web
    前言 从vue3开始vue引入了宏,比如defineProps、defineEmits等。我们每天写vue代码时都会使用到这些宏,但是你有没有思考过vue中的宏到底是什么?为什么这些宏不需要手动从vue中import?为什么只能在setup顶层中使用这些宏? ...
    继续阅读 »

    前言


    vue3开始vue引入了宏,比如definePropsdefineEmits等。我们每天写vue代码时都会使用到这些宏,但是你有没有思考过vue中的宏到底是什么?为什么这些宏不需要手动从vueimport?为什么只能在setup顶层中使用这些宏?


    vue 文件如何渲染到浏览器上


    要回答上面的问题,我们先来了解一下从一个vue文件到渲染到浏览器这一过程经历了什么?


    我们的vue代码一般都是写在后缀名为vue的文件上,显然浏览器是不认识vue文件的,浏览器只认识html、css、jss等文件。所以第一步就是通过webpack或者vite将一个vue文件编译为一个包含render函数的js文件。然后执行render函数生成虚拟DOM,再调用浏览器的DOM API根据虚拟DOM生成真实DOM挂载到浏览器上。


    progress.png


    vue3的宏是什么?


    我们先来看看vue官方的解释:



    宏是一种特殊的代码,由编译器处理并转换为其他东西。它们实际上是一种更巧妙的字符串替换形式。



    宏是在哪个阶段运行?


    通过前面我们知道了vue 文件渲染到浏览器上主要经历了两个阶段。


    第一阶段是编译时,也就是从一个vue文件经过webpack或者vite编译变成包含render函数的js文件。此时的运行环境是nodejs环境,所以这个阶段可以调用nodejs相关的api,但是没有在浏览器环境内执行,所以不能调用浏览器的API


    第二阶段是运行时,此时浏览器会执行js文件中的render函数,然后依次生成虚拟DOM和真实DOM。此时的运行环境是浏览器环境内,所以可以调用浏览器的API,但是在这一阶段中是不能调用nodejs相关的api


    而宏就是作用于编译时,也就是从vue文件编译为js文件这一过程。


    举个defineProps的例子:在编译时defineProps宏就会被转换为定义props相关的代码,当在浏览器运行时自然也就没有了defineProps宏相关的代码了。所以才说宏是在编译时执行的代码,而不是运行时执行的代码。


    一个defineProps宏的例子


    我们来看一个实际的例子,下面这个是我们的源代码:


    <template>
    <div>content is {{ content }}div>
    <div>title is {{ title }}div>
    template>

    <script setup lang="ts">
    import {ref} from "vue"
    const props = defineProps({
    content: String,
    });
    const title = ref("title")
    script>

    在这个例子中我们使用defineProps宏定义了一个类型为String,属性名为contentprops,并且在template中渲染content的内容。


    我们接下来再看看编译成js文件后的代码,代码我已经进行过简化:


    import { defineComponent as _defineComponent } from "vue";
    import { ref } from "vue";

    const __sfc__ = _defineComponent({
    props: {
    content: String,
    },
    setup(__props) {
    const props = __props;
    const title = ref("title");
    const __returned__ = { props, title };
    return __returned__;
    },
    });

    import {
    toDisplayString as _toDisplayString,
    createElementVNode as _createElementVNode,
    Fragment as _Fragment,
    openBlock as _openBlock,
    createElementBlock as _createElementBlock,
    } from "vue";

    function render(_ctx, _cache, $props, $setup) {
    return (
    _openBlock(),
    _createElementBlock(
    _Fragment,
    null,
    [
    _createElementVNode(
    "div",
    null,
    "content is " + _toDisplayString($props.content),
    1 /* TEXT */
    ),
    _createElementVNode(
    "div",
    null,
    "title is " + _toDisplayString($setup.title),
    1 /* TEXT */
    ),
    ],
    64 /* STABLE_FRAGMENT */
    )
    );
    }
    __sfc__.render = render;
    export default __sfc__;

    我们可以看到编译后的js文件主要由两部分组成,第一部分为执行defineComponent函数生成一个 __sfc__ 对象,第二部分为一个render函数。render函数不是我们这篇文章要讲的,我们主要来看看这个__sfc__对象。


    看到defineComponent是不是觉得很眼熟,没错这个就是vue提供的API中的 definecomponent函数。这个函数在运行时没有任何操作,仅用于提供类型推导。这个函数接收的第一个参数就是组件选项对象,返回值就是该组件本身。所以这个__sfc__对象就是我们的vue文件中的script代码经过编译后生成的对象,后面再通过__sfc__.render = renderrender函数赋值到组件对象的render方法上面。


    我们这里的组件选项对象经过编译后只有两个了,分别是props属性和setup方法。明显可以发现我们原本在setup里面使用的defineProps宏相关的代码不在了,并且多了一个props属性。没错这个props属性就是我们的defineProps宏生成的。


    convert.png


    我们再来看一个不在setup顶层调用defineProps的例子:


    <script setup lang="ts">
    import {ref} from "vue"
    const title = ref("title")

    if (title.value) {
    const props = defineProps({
    content: String,
    });
    }
    script>

    运行这个例子会报错:defineProps is not defined


    我们来看看编译后的js代码:


    import { defineComponent as _defineComponent } from "vue";
    import { ref } from "vue";

    const __sfc__ = _defineComponent({
    setup(__props) {
    const title = ref("title");
    if (title.value) {
    const props = defineProps({
    content: String,
    });
    }
    const __returned__ = { title };
    return __returned__;
    },
    });

    明显可以看到由于我们没有在setup的顶层调用defineProps宏,在编译时就不会将defineProps宏替换为定义props相关的代码,而是原封不动的输出回来。在运行时执行到这行代码后,由于我们没有任何地方定义了defineProps函数,所以就会报错defineProps is not defined


    总结


    现在我们能够回答前面提的三个问题了。



    • vue中的宏到底是什么?


      vue3的宏是一种特殊的代码,在编译时会将这些特殊的代码转换为浏览器能够直接运行的指定代码,根据宏的功能不同,转换后的代码也不同。


    • 为什么这些宏不需要手动从vueimport


      因为在编译时已经将这些宏替换为指定的浏览器能够直接运行的代码,在运行时已经不存在这些宏相关的代码,自然不需要从vueimport


    • 为什么只能在setup顶层中使用这些宏?


      因为在编译时只会去处理setup顶层的宏,其他地方的宏会原封不动的输出回来。在运行时由于我们没有在任何地方定义这些宏,当代码执行到宏的时候当然就会报错。



    如果想要在vue中使用更多的宏,可以使用 vue macros。这个库是用于在vue中探索更多的宏和语法糖,作者是vue的团队成员 三咲智子


    作者:欧阳码农
    来源:juejin.cn/post/7335721246931189795
    收起阅读 »

    【如诗般写代码】你甚至连注释都没玩明白

    web
    引言要问我认为最难的事是什么,那只有维护前人的代码。我经常能看见一个文件里,写上百行,没有几个函数,没有注释,没有换行,一头扎进去,闷头写到天昏地暗的什么修复bug,提升性能之类的都不是事,会用调试工具和分时渲染就够了但是看屎山是真的难受注释篇利用注释在编辑器...
    继续阅读 »

    引言

    要问我认为最难的事是什么,那只有维护前人的代码。
    我经常能看见一个文件里,写上百行,没有几个函数,没有注释,没有换行,
    一头扎进去,闷头写到天昏地暗的

    什么修复bug,提升性能之类的都不是事,会用调试工具和分时渲染就够了
    但是看屎山是真的难受


    注释篇

    1. 利用注释在编辑器开启代码提示

      image.png

      看到区别了吗,左边用的是文档注释,鼠标悬浮能看到变量描述
      右边用的是行内注释,没有任何作用

      初步认识了文档注释的好处后,很多人可能还是不用,因为嫌麻烦。
      所以编辑器也为你着想了,以 VSCode为例,输入 /**,就会自动生成文档注释
      如果在函数上面,再按下回车,还能补齐函数参数文档,如下图所示

      comment.gif


    1. 利用文档注释描述函数功能

      当我鼠标悬浮在函数上时,就能看到他的各种描述,从其他文件导入也有效
      image.png


    1. 智能提示当前参数描述,以及类型等

      这里也能用快捷键呼出,详见我上篇文章:# 【最高效编码指南】也许你不会用VSCode | IDEA

      image.png


    1. 添加 JS 类型,实现类似 TS 的效果

      这里我呼出代码提示,但是他并没有给我补全任何方法,因为他不知道你的类型是什么

      image.png

      如果是强类型语言的话,那就会给你补全代码
      那么动态类型如何实现呢?以 JS 为例,使用文档注释即可,也就是前阵子沸沸扬扬的利用 JSDoc 代替 TS

      image.png

      不仅于此,连枚举都能实现,反正 TS 有的,他应该都有,我没有详细研究

      image.png

    2. 文档注释指令

      如下图所示,我想在文档注释里写上用法,但是他的格式十分丑陋,而且没有语法高亮

      image.png

      于是我使用 example 指令,告诉他这是一个示例,这时就有语法高亮了

      image.png

      指令还有很多,你们输入 @ 就会有提示了,比如 deprecated,标记已弃用
      这时你使用它就会有个提示,并且划上一根线

      image.png

    3. MarkDown 文档注释

      有时候,指令可能不够用,这时就可以使用 MarkDown 语法了

      image.png

    4. 结合 TS

      定义类型时,写上文档注释,当你鼠标悬浮时,就能查看对应注释

      image.png

      函数重载情况下,文档注释要写在类型上才行,下面这种无效

      image.png

      要写在类型定义的地方才行

      image.png

    5. 总结

      如果你用的是变量、函数或是 TS 定义类型,你要写注释,那就一定要写 文档注释,我跪下来求求你了 😭

    减少条件分支语句

    1. 策略模式,写个映射表即可。这个有一点开发经验的应该都知道吧

      如果遇到复杂情况,映射表里也可以写函数,执行后返回逻辑

      image.png

    2. 提前返回

      这里第 2 种提前返回就减少了一层嵌套,实际开发中,能减少更多嵌套语句

      image.png

    3. 多个相等判断,使用数组代替

      image.png

    代码七宗罪

    让我来细数一下这坨代码的罪行,然后引出另一个主题,美化代码
    下面这段,这简直是"甲级战犯",

    1. 一堆变量写了或者导入了不用,放那恶心谁呢
    2. 注释了的代码不删 (虽然可能有用,但是真丑)
    3. 都什么年代了,还在用var (坏处下面说)
    4. 用行内注释和没写区别不大,要写就写文档注释 (文档注释的优点上面解释了,不再赘述)
    5. 小学生流水账一般的代码,连个函数入口都没提供,想一句写一句
    6. 连个代码格式化都不会,多按几个回车,你的键盘不会烂掉;每个分段加个注释,你的速度慢不了多少
    7. 硬编码,所有类型用字符串直接区分,你万一要改怎么办?

    image.png

    语义化

    我经常能看见一个文件里,写上百行,没有几个函数,没有注释,没有换行
    一头扎进去,闷头写到天昏地暗的,比如下面这种

    image.png

    这玩意要我一行一行看?我是真的被恶心坏了
    写代码要突出一个重点,看个大概,然后才能快速排查,看第三方库源码也是如此

    我的习惯是写一个主入口,你叫 main | init | start 什么的都行,我只希望你能写上
    然后主入口按照逻辑,给每个函数命名,这样一眼就能看出来你在干什么
    如下图所示,这是我的偏好

    image.png

    我喜欢利用函数全局提升,把初始化函数放在文件顶部。这样每次打开一个文件,就能立刻看到大概逻辑
    所以我很少用匿名函数,像上面那种全部写一坨,还都是匿名函数,我真的很难看出来谁是函数,谁是变量

    这就引出一个新问题,函数的二义性

    函数二义性

    众所周知, JS 的类就是函数,里面有自己的 this,可以 new 一个函数

    image.png

    你要知道他是函数还是类,一般是通过首字母是否大写区分
    但是这仅仅是弱规范,人家爱咋写咋写,所以后来出现了匿名函数(主要还是为了解决 this)

    匿名函数没有自己的 this 指向,没有 arguments,如下图

    image.png

    而且用 const 定义,所以也就没了函数提升,严格来说,匿名函数才是真函数

    不过我觉得直接写匿名函数有点丑,而且写起来似乎繁琐一点,虽然我都是用代码片段生成的
    如果用了匿名函数,那么我就没了函数提升了

    所以我仅仅在以下情况使用匿名函数

    1. 作为回调函数
    2. 不需要 this
    3. 函数重载

    函数重载我来说说吧,应该挺多人不知道。
    比如下图,针对每一种情况,写一遍类型,这样就能更加清楚描述函数的所有参数情况

    image.png

    不过这样好麻烦,而且好丑啊,于是可以用接口,这时你用 function 就实现不了了

    image.png

    var 的坏处

    1. var 会变量提升,你可能拿到 undefined

      image.png

    2. var 没有块级作用域,会导致变量共享

      按照常识,下面代码应该输出 0,1,2,3,4

      image.png

      但是你里面是异步打印,于是等你打印时,i 以及加了5次了,又没有块级作用域,所以你拿到的是同一个东西

      在古时候,是用立即执行函数解决的,如下图。因为函数会把变量存起来传给内部

      image.png

      现在用 let 就行了

      image.png

      所以我求求你别用 var 了

    格式化

    这里可能有争议性,仅仅是我个人喜欢,看着舒服

    大多数写前端的,基本人手一个 Prettier 插件自动格式化,再来个 EsLint
    然后也懒得看配置,默认就是 2 格缩进,回车多了会被删掉什么的

    这样下来,整个文件就相当臃肿,密密麻麻的,我看着很难受

    我的风格如下

    • 用 4 格缩进
    • 代码按照语义类型分块,写上块级文档注释
    • import 语句下面空两行,这样更加直观
    • 每一段,用独特醒目的文档注释划分
    • 定义变量优先使用 const,并且只写一个 const
    • 函数参数过长,则一行放一个参数
    • 写行内样式以及较长字符串时( 比如函数作为字符串 ),用特殊的宽松格式书写,保持类似代码的格式化
    • if 分支语句,要多空一行,看着清爽
    • 三目运算,用三行来写
    • 条件判断尽量提前 return,减少分支缩进

    下面来用图演示一下,不然看着上面的描述抽象

    代码按照语义类型分块,写上块级文档注释

    每一段逻辑写完,用个醒目的、大块的文档注释分开。
    全部执行的逻辑,放在一个 init 函数中

    image.png

    定义变量优先使用 const,并且只写一个 const

    比如声明变量,我喜欢这么写
    按照分类,类型不同则换行,并且写上注释,仅用一个 const

    image.png

    来看看大众写法,可以说 99.9878987%的人都这么写,这种我一看就难受

    image.png

    如果你用 let,并且用 4 格缩进,那么你就刚好对齐了,能少写一个回车
    不过尽量使用 const

    image.png

    函数参数过长,则一行放一个参数

    如果你这么写,那我看完会头晕眼花,属实是又臭又长的参数列表

    image.png

    如果你这么写,我会夸你代码和人一样好看

    image.png

    三目运算格式化

    这俩,你说谁的可读性高,肯定是分三行写的好看啊

    image.png

    字符串以及对象格式化

    这俩,你说谁看得舒服,那肯定是 2 啊
    我看了身边的人和网上的很多代码,大多数都是 1 这种

    image.png

    不管你是用字符串,还是对象等方式表达,你都应该写 2 这种样式

    image.png

    分支语句

    这俩哪种好看还用说吗,肯定是左边的好啊。但是你用 Prettier 格式化的话,应该就变成右边的了
    同理 try catch 之类的也是一样

    image.png

    最后,多用换行,我跪下来求求你了 😭


    作者:寅时码
    来源:juejin.cn/post/7335277377621639219

    收起阅读 »

    前端实现excel_xlsx文件预览

    web
    使用的框架: React 要使用的库: exceljs、handsontable 1. 概述 接到一个任务,是要前端实现文件预览效果,百度了一圈,发现也没有什么好的方法可以完美的将表格渲染出来。在前端中有sheetjs和exceljs可以对xlsx文件进行解...
    继续阅读 »

    使用的框架: React


    要使用的库: exceljs、handsontable



    1. 概述


    接到一个任务,是要前端实现文件预览效果,百度了一圈,发现也没有什么好的方法可以完美的将表格渲染出来。在前端中有sheetjsexceljs可以对xlsx文件进行解析,本来一开始我用的是sheetjs,但是在样式获取上遇到了麻烦,所以我改用了exceljs,不过很难受,在样式获取时同样遇到了不小的麻烦,但是我懒得换回sheetjs了,那就直接使用exceljs吧。


    要实现xlsx文件预览效果,我的想法是使用一个库对xlsx文件进行解析,然后使用另一个库对解析出来的数据在页面上进行绘制,综上,我采用的方案是:exceljs+handsontable


    2. 实现步骤


    2.1 安装库


    使用命令: npm i exceljs handsontable @handsontable/react


    2.2 使用exceljs解析数据并使用handsontable进行渲染


    直接贴代码了:


    import Excel from 'exceljs'
    import { useState } from 'react';

    import { HotTable } from '@handsontable/react';
    import { registerAllModules } from 'handsontable/registry';
    import 'handsontable/dist/handsontable.full.min.css';
    import { textRenderer, registerRenderer } from 'handsontable/renderers';

    // 注册模块
    registerAllModules();

    export default function XLSXPreView() {
    const [data, setData] = useState([]);

    const handleFile = async (e) => {
    const file = e.target.files[0];

    const workbook = new Excel.Workbook();
    await workbook.xlsx.load(file)

    // 第一个工作表
    const worksheet = workbook.getWorksheet(1);

    // 遍历工作表中的所有行(包括空行)
    const sheetData = [];
    worksheet.eachRow({ includeEmpty: true }, function(row, rowNumber) {
    // console.log('Row ' + rowNumber + ' = ' + JSON.stringify(row.values));
    // 使用row.values获取每一行的值时总会多出一条空数据(第一条),这里我把它删除
    const row_values = row.values.slice(1);
    sheetData.push(row_values)
    });
    setData(sheetData);
    }

    return (
    <>
    <input type="file" onChange={handleFile}/>
    <div id='table_view'>
    <HotTable
    data={data}
    readOnly={true}
    rowHeaders={true}
    colHeaders={true}
    width="100vw"
    height="auto"
    licenseKey='non-commercial-and-evaluation'// 一定得加这个handsontable是收费的加了这个才能免费用
    />


    </div>
    </>

    )
    }

    到这里,已经实现了从xlsx文件中获取数据,并使用handsontable将表格中的数据渲染出来,示例结果如下,如果只需要将数据显示出来,并不需要将样式什么的一起复现了,那到这里就已经结束了!


    image.png


    但事实上,这并不是我要做到效果,我的xlsx里面还有样式什么的,也需要复现,头疼😔


    3. 其它的杂七杂八


    3.1 单元格样式


    事实上,在exceljs解析xlsx文件时,它顺带一起把样式获取到了,通过worksheet.getCell(1, 1).style可以获取对应单元格的样式,如下,背景色存放在fill.fgColor中,字体颜色存放在font.color中,这样的话只需要将这些样式一一赋值给handsontable组件再添加样式就好了。


    image.png


    但是实际操作的时候却遇到了问题,先说excel中的颜色,在选择颜色时,应该都会打开下面这个选项框吧,如果你选择的是标准色,它获取到的颜色就是十六进制,但是如果你选择主题中的颜色,那就是另一种结果了,并且还会有不同的深暗程度tint,这就很难受了!


    image.png


    随后在控制台中打印了workbook,发现它把主题返回了,可以通过work._themes.theme1获取,不过获取到的是xml格式的字符串,由于xml我没学,我不会,所以我就把它转换成json来进行处理了。


    第一步


    安装xml转json的库: npm i fast-xml-parser


    import {XMLParser} from 'fast-xml-parser'

    // 将主题xml转换成json
    const themeXml = workbook._themes.theme1;
    const options = {
    ignoreAttributes: false,
    attributeNamePrefix: '_'
    }
    const parser = new XMLParser(options);
    const json = parser.parse(themeXml)
    setThemeJson(json);


    其实它的theme好像是固定的,也可以在一些格式转换的网站中直接转换成json然后放到一个json文件中,读取就行,我这里就直接放到一个state中了!



    第二步


    接下来就是重头戏了!设置单元格样式...


    首先安装一个处理颜色的库color,用来根据tint获得不同明暗程度的颜色: npm i color


    下面是获取颜色的函数:


    // 根据主题和明暗度获取颜色
    const getThemeColor = (themeJson, themeId, tint) => {
    let color = '';
    const themeColorScheme = themeJson['a:theme']['a:themeElements']['a:clrScheme'];
    switch (themeId) {
    case 0:
    color = themeColorScheme['a:lt1']['a:sysClr']['_lastClr'];
    break;
    case 1:
    color = themeColorScheme['a:dk1']['a:sysClr']['_lastClr'];
    break;
    case 2:
    color = themeColorScheme['a:lt2']['a:srgbClr']['_val'];
    break;
    case 3:
    color = themeColorScheme['a:dk2']['a:srgbClr']['_val'];
    break;
    default:
    color = themeColorScheme[`a:accent${themeId-3}`]['a:srgbClr']['_val'];
    break;
    }
    // 根据tint修改颜色深浅
    color = '#' + color;
    const colorObj = Color(color);
    if(tint){
    if(tint>0){// 淡色
    color = colorObj.lighten(tint).hex();
    }else{ // 深色
    color = colorObj.darken(Math.abs(tint)).hex();
    }
    }
    return color;
    }
    // 获取颜色
    const getColor = (obj, themeJson) => {
    if('argb' in obj){ // 标准色
    // rgba格式去掉前两位: FFFF0000 -> FF0000
    return '#' + obj.argb.substring(2);
    }else if('theme' in obj){ // 主题颜色
    if('tint' in obj){
    return getThemeColor(themeJson, obj.theme, obj.tint);
    }else{
    return getThemeColor(themeJson, obj.theme, null);
    }
    }
    }

    然后设置handonsontable的单元格的一些样式:颜色、加粗、下划线、边框balabala...的


    顺带把行高和列宽一起设置了,这个还比较简单,就一笔带过了...


    3.2 合并单元格


    从获取到的sheet中有一个_meages属性,该属性中存放了表格中所有的合并单元格区域,所以只需要将它们重新渲染在handsontable中就好。


    image.png


    然后就实现了表格的一些基本功能的预览,结果如下图:


    image.png


    3. 总结(附全代码)


    其实这个的本质主要就是通过ecxeljs解析表格文件的数据,然后通过handsontable将它们重新绘制在页面上,个人觉得这种方法并不好,因为表格里的操作太多了要把它们一一绘制工作量实在是太大了,而且很麻烦,如有有更好的方案希望大佬们告诉我一下,我这里把表格的一些常用到的功能实现了预览,还有想表格里放图片什么的都没有实现,如果有需要,可以根据需求再进行进行写。



    我写的其实还有一点bug,单元格的边框样式我只设置了solid和dashed,但事实上excel中单元格的边框有12种样式,而且还有对角线边框,设置起来好麻烦,我就不弄了,大家用的时候注意一下哈,有需要的话可以自己修改一下!



    附上全部代码:


    /**
    * exceljs + handsontable
    */

    import Excel from 'exceljs'
    import { useState } from 'react';

    import { HotTable } from '@handsontable/react';
    import { registerAllModules } from 'handsontable/registry';
    import 'handsontable/dist/handsontable.full.min.css';
    import { textRenderer, registerRenderer } from 'handsontable/renderers';

    import {XMLParser} from 'fast-xml-parser'
    import Color from 'color';

    // 注册模块
    registerAllModules();

    // 根据主题和明暗度获取颜色
    const getThemeColor = (themeJson, themeId, tint) => {
    let color = '';
    const themeColorScheme = themeJson['a:theme']['a:themeElements']['a:clrScheme'];
    switch (themeId) {
    case 0:
    color = themeColorScheme['a:lt1']['a:sysClr']['_lastClr'];
    break;
    case 1:
    color = themeColorScheme['a:dk1']['a:sysClr']['_lastClr'];
    break;
    case 2:
    color = themeColorScheme['a:lt2']['a:srgbClr']['_val'];
    break;
    case 3:
    color = themeColorScheme['a:dk2']['a:srgbClr']['_val'];
    break;
    default:
    color = themeColorScheme[`a:accent${themeId-3}`]['a:srgbClr']['_val'];
    break;
    }
    // 根据tint修改颜色深浅
    color = '#' + color;
    const colorObj = Color(color);
    if(tint){
    if(tint>0){// 淡色
    color = colorObj.lighten(tint).hex();
    }else{ // 深色
    color = colorObj.darken(Math.abs(tint)).hex();
    }
    }
    return color;
    }
    // 获取颜色
    const getColor = (obj, themeJson) => {
    if('argb' in obj){ // 标准色
    // rgba格式去掉前两位: FFFF0000 -> FF0000
    return '#' + obj.argb.substring(2);
    }else if('theme' in obj){ // 主题颜色
    if('tint' in obj){
    return getThemeColor(themeJson, obj.theme, obj.tint);
    }else{
    return getThemeColor(themeJson, obj.theme, null);
    }
    }
    }
    // 设置边框
    const setBorder = (style) =>{
    let borderStyle = 'solid';
    let borderWidth = '1px';
    switch (style) {
    case 'thin':
    borderWidth = 'thin';
    break;
    case 'dotted':
    borderStyle = 'dotted';
    break;
    case 'dashDot':
    borderStyle = 'dashed';
    break;
    case 'hair':
    borderStyle = 'solid';
    break;
    case 'dashDotDot':
    borderStyle = 'dashed';
    break;
    case 'slantDashDot':
    borderStyle = 'dashed';
    break;
    case 'medium':
    borderWidth = '2px';
    break;
    case 'mediumDashed':
    borderStyle = 'dashed';
    borderWidth = '2px';
    break;
    case 'mediumDashDotDot':
    borderStyle = 'dashed';
    borderWidth = '2px';
    break;
    case 'mdeiumDashDot':
    borderStyle = 'dashed';
    borderWidth = '2px';
    break;
    case 'double':
    borderStyle = 'double';
    break;
    case 'thick':
    borderWidth = '3px';
    break;
    default:
    break;
    }
    // console.log(borderStyle, borderWidth);
    return [borderStyle, borderWidth];
    }

    export default function XLSXPreView() {
    // 表格数据
    const [data, setData] = useState([]);
    // 表格
    const [sheet, setSheet] = useState([]);
    // 主题
    const [themeJson, setThemeJson] = useState([]);
    // 合并的单元格
    const [mergeRanges, setMergeRanges] = useState([]);

    registerRenderer('customStylesRenderer', (hotInstance, td, row, column, prop, value, cellProperties) => {
    textRenderer(hotInstance, td, row, column, prop, value, cellProperties);
    // console.log(cellProperties);
    // 填充样式
    if('fill' in cellProperties){
    // 背景颜色
    if('fgColor' in cellProperties.fill && cellProperties.fill.fgColor){
    td.style.background = getColor(cellProperties.fill.fgColor, themeJson);
    }
    }
    // 字体样式
    if('font' in cellProperties){
    // 加粗
    if('bold' in cellProperties.font && cellProperties.font.bold){
    td.style.fontWeight = '700';
    }
    // 字体颜色
    if('color' in cellProperties.font && cellProperties.font.color){
    td.style.color = getColor(cellProperties.font.color, themeJson);
    }
    // 字体大小
    if('size' in cellProperties.font && cellProperties.font.size){
    td.style.fontSize = cellProperties.font.size + 'px';
    }
    // 字体类型
    if('name' in cellProperties.font && cellProperties.font.name){
    td.style.fontFamily = cellProperties.font.name;
    }
    // 字体倾斜
    if('italic' in cellProperties.font && cellProperties.font.italic){
    td.style.fontStyle = 'italic';
    }
    // 下划线
    if('underline' in cellProperties.font && cellProperties.font.underline){
    // 其实还有双下划线,但是双下划綫css中没有提供直接的设置方式,需要使用额外的css设置,所以我也就先懒得弄了
    td.style.textDecoration = 'underline';
    // 删除线
    if('strike' in cellProperties.font && cellProperties.font.strike){
    td.style.textDecoration = 'underline line-through';
    }
    }else{
    // 删除线
    if('strike' in cellProperties.font && cellProperties.font.strike){
    td.style.textDecoration = 'line-through';
    }
    }

    }
    // 对齐
    if('alignment' in cellProperties){
    if('horizontal' in cellProperties.alignment){ // 水平
    // 这里我直接用handsontable内置类做了,设置成类似htLeft的样子。
    //(handsontable)其实至支持htLeft, htCenter, htRight, htJustify四种,但是其是它还有centerContinuous、distributed、fill,遇到这几种就会没有效果,也可以自己设置,但是我还是懒的弄了,用到的时候再说吧
    const name = cellProperties.alignment.horizontal.charAt(0).toUpperCase() + cellProperties.alignment.horizontal.slice(1);
    td.classList.add(`ht${name}`);
    }
    if('vertical' in cellProperties.alignment){ // 垂直
    // 这里我直接用handsontable内置类做了,设置成类似htTop的样子。
    const name = cellProperties.alignment.vertical.charAt(0).toUpperCase() + cellProperties.alignment.vertical.slice(1);
    td.classList.add(`ht${name}`);
    }
    }
    // 边框
    if('border' in cellProperties){
    if('left' in cellProperties.border && cellProperties.border.left){// 左边框
    const [borderWidth, borderStyle] = setBorder(cellProperties.border.left.style);
    let color = '';
    // console.log(row, column, borderWidth, borderStyle);
    if(cellProperties.border.left.color){
    color = getColor(cellProperties.border.left.color, themeJson);
    }
    td.style.borderLeft = `${borderStyle} ${borderWidth} ${color}`;
    }
    if('right' in cellProperties.border && cellProperties.border.right){// 左边框
    const [borderWidth, borderStyle] = setBorder(cellProperties.border.right.style);
    // console.log(row, column, borderWidth, borderStyle);
    let color = '';
    if(cellProperties.border.right.color){
    color = getColor(cellProperties.border.right.color, themeJson);
    }
    td.style.borderRight = `${borderStyle} ${borderWidth} ${color}`;
    }
    if('top' in cellProperties.border && cellProperties.border.top){// 左边框
    const [borderWidth, borderStyle] = setBorder(cellProperties.border.top.style);
    let color = '';
    // console.log(row, column, borderWidth, borderStyle);
    if(cellProperties.border.top.color){
    color = getColor(cellProperties.border.top.color, themeJson);
    }
    td.style.borderTop = `${borderStyle} ${borderWidth} ${color}`;
    }
    if('bottom' in cellProperties.border && cellProperties.border.bottom){// 左边框
    const [borderWidth, borderStyle] = setBorder(cellProperties.border.bottom.style);
    let color = '';
    // console.log(row, column, borderWidth, borderStyle);
    if(cellProperties.border.bottom.color){
    color = getColor(cellProperties.border.bottom.color, themeJson);
    }
    td.style.borderBottom = `${borderStyle} ${borderWidth} ${color}`;
    }
    }

    });

    const handleFile = async (e) => {
    const file = e.target.files[0];

    const workbook = new Excel.Workbook();
    await workbook.xlsx.load(file)

    const worksheet = workbook.getWorksheet(1);

    // const sheetRows = worksheet.getRows(1, worksheet.rowCount);
    setSheet(worksheet)

    // console.log(worksheet.getCell(1, 1).style);

    // 遍历工作表中的所有行(包括空行)
    const sheetData = [];
    worksheet.eachRow({ includeEmpty: true }, function(row, rowNumber) {
    // console.log('Row ' + rowNumber + ' = ' + JSON.stringify(row.values));
    // 使用row.values获取每一行的值时总会多出一条空数据(第一条),这里我把它删除
    const row_values = row.values.slice(1);
    sheetData.push(row_values)
    });
    setData(sheetData);

    // 将主题xml转换成json
    const themeXml = workbook._themes.theme1;
    const options = {
    ignoreAttributes: false,
    attributeNamePrefix: '_'
    }
    const parser = new XMLParser(options);
    const json = parser.parse(themeXml)
    setThemeJson(json);

    // 获取合并的单元格
    const mergeCells = [];

    for(let i in worksheet._merges){
    const {top, left, bottom, right} = worksheet._merges[i].model;
    mergeCells.push({ row: top-1, col: left-1, rowspan: bottom-top+1 , colspan: right-left+1})
    }
    setMergeRanges(mergeCells)
    console.log(worksheet);
    }

    return (
    <>
    <input type="file" onChange={handleFile}/>
    <div id='table_view'>
    <HotTable
    data={data}
    readOnly={true}
    rowHeaders={true}
    colHeaders={true}
    width="100vw"
    height="auto"
    licenseKey='non-commercial-and-evaluation'
    rowHeights={function(index) {
    if(sheet.getRow(index+1).height){
    // exceljs获取的行高不是像素值事实上它是23px - 13.8 的一个映射所以需要将它转化为像素值
    return sheet.getRow(index+1).height * (23 / 13.8);
    }
    return 23;// 默认
    }}
    colWidths={function(index){
    if(sheet.getColumn(index+1).width){
    // exceljs获取的列宽不是像素值事实上它是81px - 8.22 的一个映射所以需要将它转化为像素值
    return sheet.getColumn(index+1).width * (81 / 8.22);
    }
    return 81;// 默认
    }}
    cells={(row, col, prop) =>
    {
    const cellProperties = {};
    const cellStyle = sheet.getCell(row+1, col+1).style

    if(JSON.stringify(cellStyle) !== '{}'){
    // console.log(row+1, col+1, cellStyle);
    for(let key in cellStyle){
    cellProperties[key] = cellStyle[key];
    }
    }
    return {...cellProperties, renderer: 'customStylesRenderer'};
    }}
    mergeCells={mergeRanges}
    />

    </div>
    </>

    )
    }

    作者:汤圆要吃咸的
    来源:juejin.cn/post/7264461721279774780
    收起阅读 »

    告别axios,这个库让你爱上前端分页!

    web
    嗨,我们又见面了! 今天咱们聊聊前端分页加载那些事儿。你有没有遇到过这样的烦恼:在做分页的时候,要手动维护各种状态,比如页码、每页显示数量、总数据量等等,还要处理各种边界情况,哎呀妈呀,真是太麻烦了! 那么,有没有什么好办法能让我们从这些繁琐的工作中解脱出来呢...
    继续阅读 »

    嗨,我们又见面了!


    今天咱们聊聊前端分页加载那些事儿。你有没有遇到过这样的烦恼:在做分页的时候,要手动维护各种状态,比如页码、每页显示数量、总数据量等等,还要处理各种边界情况,哎呀妈呀,真是太麻烦了!


    那么,有没有什么好办法能让我们从这些繁琐的工作中解脱出来呢?这时候,alovajs就派上用场了!


    alovajs:轻量级请求策略库


    alovajs是一个轻量级的请求策略库,它可以帮助我们轻松处理分页请求。它支持开发者使用声明式实现各种复杂的请求,比如请求共享、分页请求、表单提交、断点续传等等。使用alovajs,我们可以用很少的代码就实现高效、流畅的请求功能。比如,在Vue中,你可以这样使用alovajs进行分页请求:


    const alovaInstance = createAlova({
    // VueHook用于创建ref状态,包括请求状态loading、响应数据data、请求错误对象error等
    statesHook: VueHook,
    requestAdapter: GlobalFetch(),
    responded: response => response.json()
    });

    const { loading, data, error } = useRequest(
    alovaInstance.Get('https://api.alovajs.org/profile', {
    params: {
    id: 1
    }
    })
    );

    看到了吗?只需要几行代码,alovajs就帮我们处理了分页请求的各种细节,我们再也不用手动维护那些繁琐的状态了!


    对比axios,alovajs的优势


    和axios相比,alovajs有哪些优势呢?首先,alovajs与React、Vue等现代前端框架深度融合,可以自动管理请求相关数据,大大提高了开发效率。其次,alovajs在性能方面做了很多优化,比如默认开启了内存缓存和请求共享,这些都能显著提高请求性能,提升用户体验的同时还能降低服务端的压力。最后,alovajs的体积更小,压缩后只有4kb+,相比之下,axios则有11+kb。


    总之,如果你想在分页加载方面做得更轻松、更高效,alovajs绝对值得一试!



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

    indexOf的第二个参数你用过嘛🤔

    web
    大家好,我是哈默。indexOf 是我们非常熟悉的一个方法,它可以用来获取某一个元素在一个数组里的位置,我们一般就会使用 array.indexOf(element) 的方法来进行使用。 但是,大家有没有使用过 indexOf 的第二个参数呢?第二个参数的使用...
    继续阅读 »

    大家好,我是哈默。indexOf 是我们非常熟悉的一个方法,它可以用来获取某一个元素在一个数组里的位置,我们一般就会使用 array.indexOf(element) 的方法来进行使用。


    但是,大家有没有使用过 indexOf 的第二个参数呢?第二个参数的使用会经常出现在一些优秀的库的源码当中,用于依次分析(或者说扫描)某一个字符串。


    比如命令行美化输出的 chalk 库中就有此应用,因为 chalk 库的原理就是对于我们输出在终端的内容进行处理,然后将处理后的字符串显示在终端上。


    indexOf 基本用法


    首先,我们还是先来回顾一下 indexOf 的最基本用法。


    给定一个数组:[10, 20, 30],寻找这个数组中 30 的位置,是 2


    const arr = [10, 20, 30];
    const element = 30;
    const index = arr.indexOf(element);

    console.log(index); // 2

    indexOf 的第二个参数


    明确了 indexOf 的基本用法以后,它的第 2 个参数有什么用呢?


    其实是起到了一个调整从哪里开始查找的作用。


    我们来看一个例子:


    const arr = [10, 20, 30];
    const element = 10;
    const index = arr.indexOf(element);

    console.log(index); // 0

    const arr2 = [10, 20, 30, 10];
    const element2 = 10;
    const index2 = arr2.indexOf(element2, 1);

    console.log(index2); // 3

    可以看到,同样是查找 [10, 20, 30, 10] 当中 10 的位置,但是因为第一次是从数组第 1 个元素开始查找的,所以得到的结果是 0。


    而第二次是从数组的第 2 个元素开始查找的,所以得到的结果是 3。


    优秀库源码里的使用


    明确了 indexOf 第二个参数的使用之后,我们再来看一下在一些优秀的库的源码里面,它们是如何利用起这个第二个参数的作用的。



    ⚠️注意:我下面会以 String.prototype.indexOf 举例,而上面举的例子是以 Array.prototype.indexOf 为例,但是这两个 API 的第二个参数都是起到一个搜索位置的作用,所以在这里可以一起学习一下



    这里,我们只会分析它的思想,具体的实现在具体的源码里会存在差异,但思想是相同的。


    我们首先定义一个方法,addEmoji,接受三个参数:


    /**
    * 在一个 string 的 targetString 后面,加上一个 emoji
    * @param string 原始 string
    * @param targetString 加 emoji 的那个 string
    * @param emoji 加入的 emoji
    * @returns 处理后的最终结果
    */

    function addEmoji(string, targetString, emoji) {
    let result = "";

    // 一系列处理
    // ...

    return result;
    }

    我们最终会这样调用,在 大家好,我是哈默,今天是一个好天气。 这个字的后面,加上 👍 的 emoji:


    const res = addEmoji("大家好,我是哈默,今天是一个好天气。", "好", "👍");
    console.log(res);

    那么首先我们就可以使用 indexOf 方法来从输入的字符串里找到 的位置:


    function addEmoji(string, targetString, emoji) {
    // 找到 targetString 的位置
    let index = string.indexOf(targetString);

    let result = "";

    // 记录当前扫描到的位置,现在是在参数 string 的开头位置
    // 因为 string 当中,可能会存在多个 targetString,所以我们会跳着进行扫描,也就是会使用 indexOf 的第二个参数
    let currentScanIndex = 0;

    return result;
    }

    如果我们找到了 targetString,即 index !== -1,那么我们就在 targetString 后,加上一个 emoji:


    function addEmoji(string, targetString, emoji) {
    // 找到 targetString 的位置
    let index = string.indexOf(targetString);

    let result = "";

    // 记录当前扫描到的位置,现在是在参数 string 的开头位置
    // 因为 string 当中,可能会存在多个 targetString,所以我们会跳着进行扫描,也就是会使用 indexOf 的第二个参数
    let currentScanIndex = 0;

    // 如果找到了 targetString
    if (index !== -1) {
    // 在 targetString 后面增加 emoji
    result += string.slice(currentScanIndex, index) + targetString + emoji;
    // 将当前扫描位置,移动到 targetString 之后的那个位置上
    currentScanIndex = index + targetString.length;
    }

    // 将 targetString 之后的内容追加到 result 里
    result += string.slice(currentScanIndex);

    return result;
    }

    此时,我们在第一个 字后面,加上了 👍,得到的结果:


    res1.png


    但是,我们这个字符串中,还有一个 好天气,也就是存在多个 targetString,所以我们这里不能是 if 只执行一次,而是要做一个循环。


    我们可以使用一个 while 循环:


    function addEmoji(string, targetString, emoji) {
    // 找到 targetString 的位置
    let index = string.indexOf(targetString);

    let result = "";

    // 记录当前扫描到的位置,现在是在参数 string 的开头位置
    // 因为 string 当中,可能会存在多个 targetString,所以我们会跳着进行扫描,也就是会使用 indexOf 的第二个参数
    let currentScanIndex = 0;

    // 如果找到了 targetString
    while (index !== -1) {
    // 在 targetString 后面增加 emoji
    result += string.slice(currentScanIndex, index) + targetString + emoji;
    // 将当前扫描位置,移动到 targetString 之后的那个位置上
    currentScanIndex = index + targetString.length;
    + // 重点来了!!!我们要从当前扫描的位置开始,去寻找 targetString
    + index = string.indexOf(targetString, currentScanIndex);
    }

    // 将 targetString 之后的内容追加到 result 里
    result += string.slice(currentScanIndex);

    return result;
    }

    此时,我们便成功的给第二个 ,也加上了 emoji:


    res2.png


    这个地方我们就使用到了之前提到的 indexOf 的第二个参数:


    // 重点来了!!!我们要从当前扫描的位置开始,去寻找 targetString
    index = string.indexOf(targetString, currentScanIndex);

    我们是从当前扫描到的位置 currentScanIndex 开始,查找 targetString 的,这样我们就可以找到下一个 targetString 了。


    所以,这里的思想就是通过 indexOf 的第二个参数,帮助我们能够依次扫描一个字符串,依次找到我们想要找的那个元素的位置,然后做相应的处理。


    总结


    indexOf 的第二个参数,叫 fromIndex,看到这里,大家应该也能很好的理解这个 fromIndex 的作用了,就是从哪里开始找嘛!


    作者:我是哈默
    来源:juejin.cn/post/7332858431571230747
    收起阅读 »

    春晚刘谦魔术的模拟程序

    web
    昨晚春晚上刘谦的两个魔术表演都非常精彩,尤其是第二个魔术,他演绎了经典的约瑟夫环问题! 什么是约瑟夫环问题? 约瑟夫环(Josephus problem)是一个经典的数学问题,最早由古罗马历史学家弗拉维奥·约瑟夫斯提出,但它的名字是在19世纪由德国数学家约瑟夫...
    继续阅读 »

    昨晚春晚上刘谦的两个魔术表演都非常精彩,尤其是第二个魔术,他演绎了经典的约瑟夫环问题!


    什么是约瑟夫环问题?


    约瑟夫环(Josephus problem)是一个经典的数学问题,最早由古罗马历史学家弗拉维奥·约瑟夫斯提出,但它的名字是在19世纪由德国数学家约瑟夫·乔瑟夫斯(Josef Stein)命名的。


    问题的描述是这样的:假设有n个人(编号从1到n)站成一个圆圈,从第一个人开始报数,报到某个数字(例如k)的人就被杀死,然后从下一个人开始重新报数并继续这个过程,直到只剩下一个人留下来。


    问题的关键是找出存活下来的那个人的编号。


    结合扑克牌解释约瑟夫环问题


    1、考虑最简单的情况


    假设有2张牌,编号分别是1和2。


    首先将1放到后面,扔掉2。剩下的就是最开始放在最上边的那张1。


    2、稍微复杂一点的情况,牌的张数是2的n次方


    比如有8张牌,编号分别是1、2、3、4、5、6、7、8。


    第一轮会把2、4、6、8扔掉,剩下1、3、5、7按顺序放在后面,又退化成了4张牌的情况。


    第二轮会把3、7扔掉,剩下1、5按顺序放在后面,又退化成了2张牌的情况。


    第三轮把5扔掉,剩下1,就是最初在最前面的那张。


    结论:如果牌的张数是2^n,最后剩下的一定是最开始放在牌堆顶的那张。


    3、考虑任意的情况,牌的张数是2^n+m


    比如牌的张数是11,等于8+3。把1放到后面,把2扔掉,把3放到后面,把4扔掉,把5放到后面,把6扔掉,现在剩下的编号序列是7、8、9、10、11、1、3、5,这又是8张牌的情况!最后一定剩下的是现在牌堆顶的7!


    因此,只要提前知道牌的张数,就一定能马上推导出最终是剩下哪一张牌。一切的魔法都是数学!!都是算法!!


    见证奇迹的时刻!魔术的流程



    1. 4张牌对折后撕开,就是8张,叠放在一起就是ABCDABCD。注意,ABCD四个数字是完全等价的。

    2. 根据名字字数,把顶上的牌放到下面,但怎么放都不会改变循环序列的相对位置。譬如2次,最后变成CDABCDAB;譬如3次,最后换成DABCDABC。但无论怎么操作,第4张和第8张牌都是一样的。

    3. 把顶上3张插到中间任意位置。这一步非常重要!因为操作完之后必然出现第1张和第8张牌是一样的!以名字两个字为例,可以写成BxxxxxxB(这里的x是其他和B不同的牌)。

    4. 拿掉顶上的牌放到一边,记为B。剩下的序列是xxxxxxB,一共7张牌。

    5. 南方人/北方人/不确定,分别拿顶上的1/2/3张牌插到中间,但是不会改变剩下7张牌是xxxxxxB的结果。

    6. 男生拿掉1张,女生拿掉2张。也就是男生剩下6张,女生剩下5张。分别是xxxxxB和xxxxB。

    7. 循环7次,把最顶上的放到最底下,男生和女生分别会是xxxxBx和xxBxx。

    8. 最后执行约瑟夫环过程!操作到最后只剩下1张。当牌数为6时(男生),剩下的就是第5张牌;当牌数为5时(女生),剩下的就是第3张牌。Bingo!就是第4步拿掉的那张牌!


    下面是完整的 JavaScript 代码实现:


    // 定义一个函数,用于把牌堆顶n张牌移动到末尾
    function moveCardBack(n, arr) {
    // 循环n次,把队列第一张牌放到队列末尾
    for (let i = 0; i < n; i++) {
    const moveCard = arr.shift(); // 弹出队头元素,即第一张牌
    arr.push(moveCard); // 把原队头元素插入到序列末尾
    }
    return arr;
    }

    // 定义一个函数,用于把牌堆顶n张牌移动到中间的任意位置
    function moveCardMiddleRandom(n, arr) {
    // 插入在arr中的的位置,随机生成一个idx
    // 这个位置必须是在n+1到arr.length-1之间
    const idx = Math.floor(Math.random() * (arr.length - n - 1)) + n + 1;
    // 执行插入操作
    const newArr = arr.slice(n, idx).concat(arr.slice(0, n)).concat(arr.slice(idx));
    return newArr;
    }

    // 步骤1:初始化8张牌,假设为"ABCDABCD"
    let arr = ["A", "B", "C", "D", "A", "B", "C", "D"];
    console.log("步骤1:拿出4张牌,对折撕成8张,按顺序叠放。");
    console.log("此时序列为:" + arr.join('') + "\n---");

    // 步骤2(无关步骤):名字长度随机选取,这里取2到5(其实任意整数都行)
    const nameLen = Math.floor(Math.random() * 4) + 2;
    // 把nameLen张牌移动到序列末尾
    arr = moveCardBack(nameLen, arr);
    console.log(`步骤2:随机选取名字长度为${nameLen},把第1张牌放到末尾,操作${nameLen}次。`);
    console.log(`此时序列为:${arr.join('')}\n---`);

    // 步骤3(关键步骤):把牌堆顶三张放到中间任意位置
    arr = moveCardMiddleRandom(3, arr);
    console.log(`步骤3:把牌堆顶3张放到中间的随机位置。`);
    console.log(`此时序列为:${arr.join('')}\n---`);

    // 步骤4(关键步骤):把最顶上的牌拿走
    const restCard = arr.shift(); // 弹出队头元素
    console.log(`步骤4:把最顶上的牌拿走,放在一边。`);
    console.log(`拿走的牌为:${restCard}`);
    console.log(`此时序列为:${arr.join('')}\n---`);

    // 步骤5(无关步骤):根据南方人/北方人/不确定,把顶上的1/2/3张牌插入到中间任意位置
    // 随机选择1、2、3中的任意一个数字
    const moveNum = Math.floor(Math.random() * 3) + 1;
    arr = moveCardMiddleRandom(moveNum, arr);
    console.log(`步骤5:我${moveNum === 1 ? '是南方人' : moveNum === 2 ? '是北方人' : '不确定自己是哪里人'},\
    ${moveNum}张牌插入到中间的随机位置。`
    );
    console.log(`此时序列为:${arr.join('')}\n---`);

    // 步骤6(关键步骤):根据性别男或女,移除牌堆顶的1或2张牌
    const maleNum = Math.floor(Math.random() * 2) + 1; // 随机选择1或2
    for (let i = 0; i < maleNum; i++) { // 循环maleNum次,移除牌堆顶的牌
    arr.shift();
    }
    console.log(`步骤6:我是${maleNum === 1 ? '男' : '女'}生,移除牌堆顶的${maleNum}张牌。`);
    console.log(`此时序列为:${arr.join('')}\n---`);

    // 步骤7(关键步骤):把顶部的牌移动到末尾,执行7次
    arr = moveCardBack(7, arr);
    console.log(`步骤7:把顶部的牌移动到末尾,执行7次`);
    console.log(`此时序列为:${arr.join('')}\n---`);

    // 步骤8(关键步骤):执行约瑟夫环过程。把牌堆顶一张牌放到末尾,再移除一张牌,直到只剩下一张牌。
    console.log(`步骤8:把牌堆顶一张牌放到末尾,再移除一张牌,直到只剩下一张牌。`);
    while (arr.length > 1) {
    const luck = arr.shift(); // 好运留下来
    arr.push(luck);
    console.log(`好运留下来:${luck}\t\t此时序列为:${arr.join('')}`);
    const sadness = arr.shift(); // 烦恼都丢掉
    console.log(`烦恼都丢掉:${sadness}\t\t此时序列为:${arr.join('')}`);
    }
    console.log(`---\n最终结果:剩下的牌为${arr[0]},步骤4中留下来的牌也是${restCard}`);


    这段代码实现了昨晚春晚上刘谦的第二个魔术表演的过程,并提供了详细的解释。享受魔术的魅力吧!


    image-20240210161329783


    image-20240210161339317


    看到观看的人这么多,除了JavaScript,下面我补充了一些其他语言的实现


    import random

    # 定义一个函数,用于把牌堆顶n张牌移动到末尾
    def move_card_back(n, arr):
       for i in range(n):
           move_card = arr.pop(0)
           arr.append(move_card)
       return arr

    # 定义一个函数,用于把牌堆顶n张牌移动到中间的任意位置
    def move_card_middle_random(n, arr):
       idx = random.randint(n + 1, len(arr) - 1)
       new_arr = arr[n:idx] + arr[0:n] + arr[idx:]
       return new_arr

    # 步骤1:初始化8张牌,假设为"ABCDABCD"
    arr = ["A", "B", "C", "D", "A", "B", "C", "D"]
    print("步骤1:拿出4张牌,对折撕成8张,按顺序叠放。")
    print("此时序列为:" + ''.join(arr) + "\n---")

    # 步骤2(无关步骤):名字长度随机选取,这里取2到5(其实任意整数都行)
    name_len = random.randint(2, 5)
    move_card_back(name_len, arr)
    print("步骤2:随机选取名字长度为" + str(name_len) + ",把第1张牌放到末尾,操作" + str(name_len) + "次。")
    print("此时序列为:" + ''.join(arr) + "\n---")

    # 步骤3(关键步骤):把牌堆顶三张放到中间任意位置
    arr = move_card_middle_random(3, arr)
    print("步骤3:把牌堆顶3张放到中间的随机位置。")
    print("此时序列为:" + ''.join(arr) + "\n---")

    # 步骤4(关键步骤):把最顶上的牌拿走
    rest_card = arr.pop(0)
    print("步骤4:把最顶上的牌拿走,放在一边。")
    print("拿走的牌为:" + rest_card)
    print("此时序列为:" + ''.join(arr) + "\n---")

    # 步骤5(无关步骤):根据南方人/北方人/不确定,把顶上的1/2/3张牌插入到中间任意位置
    # 随机选择1、2、3中的任意一个数字
    move_num = random.randint(1, 3)
    arr = move_card_middle_random(move_num, arr)
    print("步骤5:我" + ("是南方人" if move_num == 1 else "是北方人" if move_num == 2 else "不确定自己是哪里人") + ",把" + str(move_num) + "张牌插入到中间的随机位置。")
    print("此时序列为:" + ''.join(arr) + "\n---")

    # 步骤6(关键步骤):根据性别男或女,移除牌堆顶的1或2张牌
    male_num = random.randint(1, 2)
    for i in range(male_num):
       arr.pop(0)
    print("步骤6:我是" + ("男" if male_num == 1 else "女") + "生,移除牌堆顶的" + str(male_num) + "张牌。")
    print("此时序列为:" + ''.join(arr) + "\n---")

    # 步骤7(关键步骤):把顶部的牌移动到末尾,执行7次
    for i in range(7):
       move_card = arr.pop(0)
       arr.append(move_card)
    print("步骤7:把顶部的牌移动到末尾,执行7次")
    print("此时序列为:" + ''.join(arr) + "\n---")

    # 步骤8(关键步骤):执行约瑟夫环过程。把牌堆顶一张牌放到末尾,再移除一张牌,直到只剩下一张牌。
    print("步骤8:把牌堆顶一张牌放到末尾,再移除一张牌,直到只剩下一张牌。")
    while len(arr) > 1:
       luck = arr.pop(0)
       arr.append(luck)
       print("好运留下来:" + luck + "\t\t此时序列为:" + ''.join(arr))
       sadness = arr.pop(0)
       print("烦恼都丢掉:" + sadness + "\t\t此时序列为:" + ''.join(arr))
    print("---\n最终结果:剩下的牌为" + arr[0] + ",步骤4中留下来的牌也是" + rest_card)


    java


    import java.util.ArrayList;
    import java.util.List;
    import java.util.Random;

    public class Main {
       public static void main(String[] args) {
           List<String> arr = new ArrayList<>();
           arr.add("A");
           arr.add("B");
           arr.add("C");
           arr.add("D");
           arr.add("A");
           arr.add("B");
           arr.add("C");
           arr.add("D");

           System.out.println("步骤1:拿出4张牌,对折撕成8张,按顺序叠放。");
           System.out.println("此时序列为:" + String.join("", arr));
           System.out.println("---");

           Random rand = new Random();

           int nameLen = rand.nextInt(4) + 2;
           moveCardBack(nameLen, arr);
           System.out.println("步骤2:随机选取名字长度为" + nameLen + ",把第1张牌放到末尾,操作" + nameLen + "次。");
           System.out.println("此时序列为:" + String.join("", arr));
           System.out.println("---");

           moveCardMiddleRandom(3, arr);
           System.out.println("步骤3:把牌堆顶3张放到中间的随机位置。");
           System.out.println("此时序列为:" + String.join("", arr));
           System.out.println("---");

           String restCard = arr.remove(0);
           System.out.println("步骤4:把最顶上的牌拿走,放在一边。");
           System.out.println("拿走的牌为:" + restCard);
           System.out.println("此时序列为:" + String.join("", arr));
           System.out.println("---");

           int moveNum = rand.nextInt(3) + 1;
           moveCardMiddleRandom(moveNum, arr);
           System.out.println("步骤5:我" + (moveNum == 1 ? "是南方人" : moveNum == 2 ? "是北方人" : "不确定自己是哪里人") + ",把" + moveNum + "张牌插入到中间的随机位置。");
           System.out.println("此时序列为:" + String.join("", arr));
           System.out.println("---");

           int maleNum = rand.nextInt(2) + 1;
           for (int i = 0; i < maleNum; i++) {
               arr.remove(0);
          }
           System.out.println("步骤6:我是" + (maleNum == 1 ? "男" : "女") + "生,移除牌堆顶的" + maleNum + "张牌。");
           System.out.println("此时序列为:" + String.join("", arr));
           System.out.println("---");

           for (int i = 0; i < 7; i++) {
               String moveCard = arr.remove(0);
               arr.add(moveCard);
          }
           System.out.println("步骤7:把顶部的牌移动到末尾,执行7次");
           System.out.println("此时序列为:" + String.join("", arr));
           System.out.println("---");

           System.out.println("步骤8:把牌堆顶一张牌放到末尾,再移除一张牌,直到只剩下一张牌。");
           while (arr.size() > 1) {
               String luck = arr.remove(0);
               arr.add(luck);
               System.out.println("好运留下来:" + luck + "\t\t此时序列为:" + String.join("", arr));
               String sadness = arr.remove(0);
               System.out.println("烦恼都丢掉:" + sadness + "\t\t此时序列为:" + String.join("", arr));
          }
           System.out.println("---\n最终结果:剩下的牌为" + arr.get(0) + ",步骤4中留下来的牌也是" + restCard);
      }

       private static void moveCardBack(int n, List<String> arr) {
           for (int i = 0; i < n; i++) {
               String moveCard = arr.remove(0);
               arr.add(moveCard);
          }
      }

       private static void moveCardMiddleRandom(int n, List<String> arr) {
           Random rand = new Random();
           int idx = rand.nextInt(arr.size() - n - 1) + n + 1;
           List<String> newArr = new ArrayList<>(arr.subList(n, idx));
           newArr.addAll(arr.subList(0, n));
           newArr.addAll(arr.subList(idx, arr.size()));
           arr.clear();
           arr.addAll(newArr);
      }
    }


    以及c++代码


    #include <iostream>
    #include <vector>
    #include <algorithm>
    #include <cstdlib>
    #include <ctime>

    void moveCardBack(int n, std::vector<std::string>& arr) {
       for (int i = 0; i < n; i++) {
           std::string moveCard = arr[0];
           arr.erase(arr.begin());
           arr.push_back(moveCard);
      }
    }

    void moveCardMiddleRandom(int n, std::vector<std::string>& arr) {
       int idx = rand() % (arr.size() - n - 1) + n + 1;
       std::vector<std::string> newArr;
       newArr.insert(newArr.end(), arr.begin() + n, arr.begin() + idx);
       newArr.insert(newArr.end(), arr.begin(), arr.begin() + n);
       newArr.insert(newArr.end(), arr.begin() + idx, arr.end());
       arr = newArr;
    }

    int main() {
       srand(time(0));

       std::vector<std::string> arr = {"A", "B", "C", "D", "A", "B", "C", "D"};
       std::cout << "步骤1:拿出4张牌,对折撕成8张,按顺序叠放。" << std::endl;
       std::cout << "此时序列为: ";
       for (const std::string& card : arr) {
           std::cout << card;
      }
       std::cout << std::endl;
       std::cout << "---" << std::endl;

       int nameLen = rand() % 4 + 2;
       moveCardBack(nameLen, arr);
       std::cout << "步骤2:随机选取名字长度为" << nameLen << ",把第1张牌放到末尾,操作" << nameLen << "次。" << std::endl;
       std::cout << "此时序列为: ";
       for (const std::string& card : arr) {
           std::cout << card;
      }
       std::cout << std::endl;
       std::cout << "---" << std::endl;

       moveCardMiddleRandom(3, arr);
       std::cout << "步骤3:把牌堆顶3张放到中间的随机位置。" << std::endl;
       std::cout << "此时序列为: ";
       for (const std::string& card : arr) {
           std::cout << card;
      }
       std::cout << std::endl;
       std::cout << "---" << std::endl;

       std::string restCard = arr[0];
       arr.erase(arr.begin());
       std::cout << "步骤4:把最顶上的牌拿走,放在一边。" << std::endl;
       std::cout << "拿走的牌为: " << restCard << std::endl;
       std::cout << "此时序列为: ";
       for (const std::string& card : arr) {
           std::cout << card;
      }
       std::cout << std::endl;
       std::cout << "---" << std::endl;

       moveCardMiddleRandom(rand() % 3 + 1, arr);
       std::cout << "步骤5:我" << (rand() % 2 == 0 ? "是南方人" : "是北方人") << ",把" << rand() % 3 + 1 << "张牌插入到中间的随机位置。" << std::endl;
       std::cout << "此时序列为: ";
       for (const std::string& card : arr) {
           std::cout << card;
      }
       std::cout << std::endl;
       std::cout << "---" << std::endl;

       int maleNum = rand() % 2 + 1;
       for (int i = 0; i < maleNum; i++) {
           arr.erase(arr.begin());
      }
       std::cout << "步骤6:我" << (maleNum == 1 ? "男" : "女") << "生,移除牌堆顶的" << maleNum << "张牌。" << std::endl;
       std::cout << "此时序列为: ";
       for (const std::string& card : arr) {
           std::cout << card;
      }
       std::cout << std::endl;
       std::cout << "---" << std::endl;

       for (int i = 0; i < 7; i++) {
           std::string moveCard = arr[0];
           arr.erase(arr.begin());
           arr.push_back(moveCard);
      }
       std::cout << "步骤7:把顶部的牌移动到末尾,执行7次" << std::endl;
       std::cout << "此时序列为: ";
       for (const std::string& card : arr) {
           std::cout << card;
      }
       std::cout << std::endl;
       std::cout << "---" << std::endl;

       std::cout << "步骤8:把牌堆顶一张牌放到末尾,再移除一张牌,直到只剩下一张牌。" << std::endl;
       while (arr.size() > 1) {
           std::string luck = arr[0];
           arr.erase(arr.begin());
           arr.push_back(luck);
           std::cout << "好运留下来: " << luck << "\t\t此时序列为: ";
           for (const std::string& card : arr) {
               std::cout << card;
          }
           std::cout << std::endl;

           std::string sadness = arr[0];
           arr.erase(arr.begin());
           std::cout << "烦恼都丢掉: " << sadness << "\t\t此时序列为: ";
           for (const std::string& card : arr) {
               std::cout << card;
          }
           std::cout << std::endl;
      }
       std::cout << "---\n最终结果: " << arr[0] << ", 步骤4中留下来的牌也是" << restCard << std::endl;

       return 0;
    }

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