pnpm 的崛起:如何降维打击 npm 和 Yarn🫡
今天研究了一下 pnpm
的机制,发现它确实很强大,甚至可以说对 yarn
和 npm
形成了降维打击
我们从包管理工具的发展历史,一起看下到底好在哪里?
npm2
在 npm 3.0 版本之前,项目的 node_modules
会呈现出嵌套结构,也就是说,我安装的依赖、依赖的依赖、依赖的依赖的依赖...,都是递归嵌套的
node_modules
├─ express
│ ├─ index.js
│ ├─ package.json
│ └─ node_modules
│ ├─ accepts
│ │ ├─ index.js
│ │ ├─ package.json
│ │ └─ node_modules
│ │ ├─ mime-types
| | | └─ node_modules
| | | └─ mime-db
| │ └─ negotiator
│ ├─ array-flatten
│ ├─ ...
│ └─ ...
└─ A
├─ index.js
├─ package.json
└─ node_modules
└─ accepts
├─ index.js
├─ package.json
└─ node_modules
├─ mime-types
| └─ node_modules
| └─ mime-db
└─ negotiator
设计缺陷
这种嵌套依赖树的设计确实存在几个严重的问题
- 路径过长问题: 由于包的嵌套结构 ,
node_modules
的目录结构可能会变得非常深,甚至可能会超出系统路径长度上限 ,毕竟 windows 系统的文件路径默认最多支持 256 个字符 - 磁盘空间浪费: 多个包之间难免会有公共的依赖,公共依赖会被多次安装在不同的包目录下,导致磁盘空间被大量浪费 。比如上面
express
和 A 都依赖了accepts
,它就被安装了两次 - 安装速度慢:由于依赖包之间的嵌套结构,
npm
在安装包时需要多次处理和下载相同的包,导致安装速度变慢,尤其是在依赖关系复杂的项目中
当时 npm 还没解决这些问题, 社区便推出了新的解决方案 ,就是 yarn。 它引入了一种新的依赖管理方式——扁平化依赖。
看到 yarn 的成功,npm 在 3.0 版本中也引入了类似的扁平化依赖结构
yarn
yarn 的主要改进之一就是通过扁平化依赖结构来解决嵌套依赖树的问题
具体来说铺平,yarn 尽量将所有依赖包安装在项目的顶层 node_modules
目录下,而不是嵌套在各自的 node_modules
目录中。
这样一来,减少了目录的深度,避免了路径过长的问题 ,也尽可能避免了依赖被多次重复安装的问题
我们可以在 yarn-example 看到整个目录,全部铺平在了顶层 node_modules
目录下,展开下面的包大部分是没有二层 node_modules
的
然而,有些依赖包还是会在自己的目录下有一个 node_modules
文件夹,出现嵌套的情况,例如 yarn-example 下的http-errors
依赖包就有自己的 node_modules
,原因是:
当一个项目的多个依赖包需要同一个库的不同版本时,yarn 只能将一个版本的库提升到顶层 node_modules
目录中。 对于需要这个库其他版本的依赖,yarn 仍然需要在这些依赖包的目录下创建一个嵌套的 node_modules
来存放不同版本的包
比如,包 A 依赖于 lodash@4.0.0
,而包 B 依赖于 lodash@3.0.0
。由于这两个版本的 lodash
不能合并,yarn
会将 lodash@4.0.0
提升到顶层 node_modules
,而 lodash@3.0.0
则被嵌套在包 B 的 node_modules
目录下。
幽灵依赖
虽然 yarn 和 npm 都采用了扁平化的方案来解决依赖嵌套的问题,但这种方案本身也有一些缺陷,其中幽灵依赖是一个主要问题。
幽灵依赖,也就是你明明没有在 package.json
文件中声明的依赖项,但在项目代码里却可以 require
进来
这个也很容易理解,因为依赖的依赖被扁平化安装在顶层 node_modules
中,所以我们能访问到依赖的依赖
但是这样是有隐患的,因为没有显式依赖,未来某个时候这些包可能会因为某些原因消失(例如新版本库不再引用这个包了,然后我们更新了库),就会引发代码运行错误
浪费磁盘空间
而且还有一个问题,就是上面提到的依赖包有多个版本的时候,只会提升一个,那其余版本的包不还是复制了很多次么,依然有浪费磁盘空间的问题
那社区有没有解决这俩问题的思路呢? pnpm 就是其中最成功的一个
pnpm
pnpm 通过全局存储和符号链接机制从根源上解决了依赖重复安装和路径长度问题,同时也避免了扁平化依赖结构带来的幽灵依赖问题
pnpm 的优势概括来说就是“快、准、狠”:
- 快:安装速度快
- 准:安装过的依赖会准确复用缓存,甚至包版本升级带来的变化都只 diff,绝不浪费一点空间
- 狠:直接废掉了幽灵依赖
执行 npm add express
,我们可以在 pnpm-example 看到整个目录,由于只安装了 express
,那 node_modules
下就只有 express
那么所有的(次级)依赖去哪了呢? binggo,在node_modules/.pnpm/
目录下,.pnpm/
以平铺的形式储存着所有的包
三层寻址
- 所有 npm 包都安装在全局目录
~/.pnpm-store/v3/files
下,同一版本的包仅存储一份内容,甚至不同版本的包也仅存储 diff 内容。 - 顶层
node_modules
下有.pnpm
目录以打平结构管理每个版本包的源码内容,以硬链接方式指向 pnpm-store 中的文件地址。 - 每个项目
node_modules
下安装的包以软链接方式将内容指向node_modules/.pnpm
中的包。
所以每个包的寻找都要经过三层结构:node_modules/package-a
> 软链接node_modules/.pnpm/package-a@1.0.0/node_modules/package-a
> 硬链接~/.pnpm-store/v3/files/00/xxxxxx
。
这就是 pnpm 的实现原理。官方给了一张原理图,可以搭配食用
前面说过,npm 包都被安装在全局
pnpm store
,默认情况下,会创建多个存储(每个驱动器(盘符)一个),并在项目所在盘符的根目录
所以,同一个盘符下的不同项目,都可以共用同一个全局
pnpm store
,绝绝子啊👏,大大节省了磁盘空间,提高了安装速度
软硬链接
也就是说,所有的依赖都是从全局 store 硬连接到了 node_modules/.pnpm
下,然后之间通过软链接来相互依赖。
那么,这里的软连接、硬链接到底是什么东西?
硬链接是指向磁盘上原始文件所在的同一位置 (直接指向相同的数据块)
软连接可以理解为新建一个文件,它包含一个指向另一个文件或目录的路径 (指向目标路径)
总结
npm2 的嵌套结构: 每个依赖项都会有自己的 node_modules
目录,导致了依赖被重复安装,严重浪费了磁盘空间💣;在依赖层级比较深的项目中,甚至会超出 windows 系统的文件路径长度💣
npm3+ 和 Yarn 的扁平化策略: 尽量将所有依赖包安装在项目的顶层 node_modules
目录下,解决了 npm2
嵌套依赖的问题。但是该方案有一个重大缺陷就是“幽灵依赖”💣;而且依赖包有多个版本时,只会提升一个,那其余版本依然会被重复安装,还是有浪费磁盘空间的问题💣
pnpm全局存储和符号链接机制: 结合软硬链和三层寻址,解决了依赖被重复安装的问题,更加变态的是,同一盘符下的不同项目都可以共用一个全局 pnpm store
。节省了磁盘空间,并且根本不存在“幽灵依赖”,安装速度还贼快💪💪💪
来源:juejin.cn/post/7410923898647461938
代码与蓝湖ui颜色值一致!但页面效果出现色差问题?
前言
最近在开发新需求,按照蓝湖的ui图进行开发,但是在开发完部署后发现做出来的页面部分元素的颜色和设计图有出入,有色差!经过一步步的排查最终破案,解决。仅以此篇记录自己踩坑、学习的过程,也希望可以帮助到其他同学。
发现问题
事情是这样的,那是一个愉快的周五的下午,和往常一样我开心的提交了代码后进行打包发版,然后通知负责人查看我的工作成果。
但是,过了不久后,负责人找到了我,说我做出来的效果和ui有点出入,有的颜色有点不一样。我一脸懵逼,心想怎么可能呢,我是根据ui图来的,ui的颜色可是手把手从蓝湖复制到代码中的啊。
随后他就把页面和ui的对比效果图发了出来:
上图中左侧是蓝湖ui图,右侧是页面效果图。我定睛一看,哇趣!!!好像是有点不一样啊。 感觉右侧的比左侧的更亮一些。于是我赶紧本地查看我的页面和ui,果然也是同样问题! 开发时真的没注意,没发现这个问题!!!
排查问题
于是,我迅速开始进行问题排查,看看到底是什么问题,是值写错了?还是那里的问题。
ui、页面、代码对比
下图中:最上面部分是蓝湖ui图、下面左侧是我的页面、右侧是我的页面代码样式
仔细检查后发现颜色的值没错啊,我的代码中背景颜色、边框颜色的值都和ui的颜色值是一致的! 但这是什么问题呢??? 值都一样为什么渲染到页面会出现色差?
起初,我想到的是屏幕的问题,因为不同分辨率下展示出来的页面效果是会有差距的。但是经过查看发现同事的win10笔记本、我的mac笔记本、外接显示器上都存在颜色有色差这个问题!!!
ui、页面、源文件对比
通过对比ui、页面、颜色值,不同设备展示效果可以初步确认:和显示器关系不大。当我在百思不解的时候,我突然想到了ui设计师!ui提供的ui图是蓝湖上切出来的,那么她的源文件颜色是什么呢?
于是我火急火燎的联系到了公司ui小姐姐,让她发我源文件该元素的颜色值,结果值确实是一样的,但是!!! 源文件展示出来的效果好像和蓝湖上的不太一样!
然后我进行了对比(左侧蓝湖、右上页面、右下源文件):
可以看到源文件和我页面的效果基本一致!到这一步基本可以确定我的代码是没问题的!
尝试解决
首先去网上找了半天没有找到想要的答案,于是我灵光一现,想到了蓝湖客服!然后就询问了客服,为什么上传后的ui图内容和源文件有色差?
沟通了很久,期间我又和ui小姐姐在询问她的软件版本、电脑版本、源文件效果、设置等内容就不贴了,最终得到如下解答:
解决方式
下载最新版蓝湖插件,由于我们的ui小姐姐用的 sketch
切图工具,然后操作如下:
1.下载安装最新版蓝湖插件: lanhuapp.com/mac?formHea…
2.安装新版插件后--插件重置
3.后台程序退出 sketch
,重新启动再次尝试打开蓝湖插件.
4.插件设置打开高清导出上传(重要!)
5.重新切图上传蓝湖
最终效果
左侧ui源文件、右侧蓝湖ui:
页面效果:
可以看到我的页面元素的border
好像比ui粗一些,感觉设置0.5px就可以了,字体效果的话是因为我还没来得及下载ui对应的字体文件。
但是走到这一步发现整体效果已经和ui图到达了95%以上相似了,不至于和开始有那么明显的色差。
总结
至此,问题已经基本是解决。遇到问题不能怕,多想一想,然后有思路后就一步一步排查、尝试解决问题。当解决完问题后会发现心情舒畅!整个人都好起来了,也会增加自信心!
来源:juejin.cn/post/7410712345226035200
数据可视化工具库比较与应用:ECharts、AntV、D3、Zrender
ECharts
ECharts是一个由百度开发的强大的数据可视化库,它提供了丰富的图表类型和灵活的配置选项。以下是一个简单的示例,展示如何使用Echarts创建一个折线图:
import * as echarts from 'echarts';
const chartDom = document.getElementById('main');
const myChart = echarts.init(chartDom);
const option = {
xAxis: {
type: 'category',
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
},
yAxis: {
type: 'value'
},
series: [
{
data: [150, 230, 224, 218, 135, 147, 260],
type: 'line'
}
]
};
option && myChart.setOption(option);
如上步骤,简单易用,难点都封装好了,只需要配置数据即可。如果需要在网页中快速展示图表信息,刚好这个图表是比较常规的,不需要过多地调整和配置,就可以采用ECharts。
Antv
Antv是蚂蚁金服开发的数据可视化库,它基于G2和G6,提供了一系列强大的图表和可视化组件。下面是一个使用Antv使用G2产品创建折线图的示例:
import { Chart } from "@antv/g2";
const chart = new Chart({ container: "container" });
chart.options({
type: "view",
autoFit: true,
data: [
{ year: "1991", value: 3 },
{ year: "1992", value: 4 },
{ year: "1993", value: 3.5 },
{ year: "1994", value: 5 },
{ year: "1995", value: 4.9 },
{ year: "1996", value: 6 },
{ year: "1997", value: 7 },
{ year: "1998", value: 9 },
{ year: "1999", value: 13 },
],
encode: { x: "year", y: "value" },
scale: { x: { range: [0, 1] }, y: { domainMin: 0, nice: true } },
children: [
{ type: "line", labels: [{ text: "value", style: { dx: -10, dy: -12 } }] },
{ type: "point", style: { fill: "white" }, tooltip: false },
],
});
chart.render();
Antv提供了简单易用的API和丰富的图表组件,可以帮助开发者快速构建各种类型的数据可视化图表。在官网可以看到由七个模块产品,分别是:
G2|G2Plot:可视化图形语法和通用图表库
S2:多维可视分析表格
G6|Graphin:关系数据可视化分析工具和图分析组件
X6|XFlow:流程图相关分图表和组件
L7|L7Plot:地理空间数据可视化框架和地理图表
F2|F6:移动端的可视化解决方案
AVA:可视分析技术框架
D3
import * as d3 from "d3";
import {useRef, useEffect} from "react";
export default function LinePlot({
data,
width = 640,
height = 400,
marginTop = 20,
marginRight = 20,
marginBottom = 30,
marginLeft = 40
}) {
const gx = useRef();
const gy = useRef();
const x = d3.scaleLinear([0, data.length - 1], [marginLeft, width - marginRight]);
const y = d3.scaleLinear(d3.extent(data), [height - marginBottom, marginTop]);
const line = d3.line((d, i) => x(i), y);
useEffect(() => void d3.select(gx.current).call(d3.axisBottom(x)), [gx, x]);
useEffect(() => void d3.select(gy.current).call(d3.axisLeft(y)), [gy, y]);
return (
<svg width={width} height={height}>
<g ref={gx} transform={`translate(0,${height - marginBottom})`} />
<g ref={gy} transform={`translate(${marginLeft},0)`} />
<path fill="none" stroke="currentColor" strokeWidth="1.5" d={line(data)} />
<g fill="white" stroke="currentColor" strokeWidth="1.5">
{data.map((d, i) => (<circle key={i} cx={x(i)} cy={y(d)} r="2.5" />))}
</g>
</svg>
);
}
D3不是传统意义上的图表库,是由30个离散库或者模块组成的套件。如果你对其它高级图表库不满意,想使用SVG或Canvas、甚至WebGL滚动自己的图表,那么可以使用D3工具库。
ZRender
ZRender是2D绘图引擎。它提供Canvas、SVG、等多种渲染方式,也是ECharts的渲染器。
import zrender from 'zrender';
var zr = zrender.init(document.getElementById('main'));
var circle = new zrender.Circle({
shape: {
cx: 150,
cy: 50,
r: 40
},
style: {
fill: 'none',
stroke: '#F00'
}
});
zr.add(circle);
console.log(circle.shape.r); // 40
circle.attr('shape', {
r: 50 // 只更新 r。cx、cy 将保持不变。
});
通过 a = new zrender.XXX
方法创建了图形元素之后,可以用 a.shape
等形式获取到创建时输入的属性,但是如果需要对其进行修改,应该使用 a.attr(key, value)
的形式修改,否则不会触发图形的重绘。
从代码规范看,Echarts和D3官网的案例有用到es5的语法,Antv遵循了es6的语法规范,更专业。从灵活程度和使用难易程度来看,ECharts<Antv<D3<ZRender。还有使用到其它图表工具库的,欢迎留言探讨📒
来源:juejin.cn/post/7345105846341648438
前端实现文件预览img、docx、xlsx、pptx、pdf、md、txt、audio、video
前言
最近有接到一个需求,要求前端支持上传制定后缀文件,且支持页面预览,上传简单,那么预览该怎么实现呢,尤其是不同类型的文件预览方案,那么下面就我这个需求的实现,分不同情况来讲解一下👇
具体的预览需求: 预览需要支持的文件类型有: png、jpg、jpeg、docx、xlsx、pptx、pdf、md、txt、audio、video
,另外对于不同文档还需要有定位的功能。例如:pdf
定位到页码,txt
和markdown
定位到文字并滚动到指定的位置,音视频定位到具体的时间等等。
⚠️ 补充: 我的需求是需要先将文件上传到后台,然后我拿到url
地址去展示,对于markdown
和txt
的文件需要先用fetch
获取,其他的展示则直接使用url
链接就可以。
不同文件的实现方式不同,下面分类讲解,总共分为以下几类:
- 自有标签文件:
png、jpg、jpeg、audio、video
- 纯文字的文件:
markdown 、txt
office
类型的文件:docx、xlsx、pptx
embed
引入文件:pdf
iframe
:引入外部完整的网站,例如:https://www.baidu.com/
自有标签文件:png、jpg、jpeg、audio、video
对于图片、音视频的预览,直接使用对应的标签即可,如下:
图片:png、jpg、jpeg
示例代码:
<img src={url} key={docId} alt={name} width="100%" />;
预览效果如下:
音频:audio
示例代码:
预览效果如下:
视频:video
示例代码:
预览效果如下:
关于音视频的定位的完整代码:
import React, { useRef, useEffect } from 'react';
interface IProps {
type: 'audio' | 'video';
url: string;
timeInSeconds: number;
}
function AudioAndVideo(props: IProps) {
const { type, url, timeInSeconds } = props;
const videoRef = useRef(null);
const audioRef = useRef(null);
useEffect(() => {
// 音视频定位
const secondsTime = timeInSeconds / 1000;
if (type === 'audio' && audioRef.current) {
audioRef.current.currentTime = secondsTime;
}
if (type === 'video' && videoRef.current) {
videoRef.current.currentTime = secondsTime;
}
}, [type, timeInSeconds]);
return (
{type === 'audio' ? (
) : (
)}
);
}
export default AudioAndVideo;
纯文字的文件: markdown & txt
对于
markdown、txt
类型的文件,如果拿到的是文件的url
的话,则无法直接显示,需要请求到内容,再进行展示。
markdown
文件
在展示
markdown
文件时,需要满足字体高亮、代码高亮
,如果有字体高亮,需要滚动到字体所在位置
,如果有外部链接,需要新开tab页面
再打开。
需要引入两个库:
marked
:它的作用是将markdown
文本转换(解析)为HTML
。
highlight
: 它允许开发者在网页上高亮显示代码。
字体高亮的代码实现:
高亮的样式,可以在行间样式定义
const highlightAndMarkFirst = (text: string, highlightText: string) => {
let firstMatchDone = false;
const regex = new RegExp(`(${highlightText})`, 'gi');
return text.replace(regex, (match) => {
if (!firstMatchDone) {
firstMatchDone = true;
return `id='first-match' style="color: red;">${match}`;
}
return `style="color: red;">${match}`;
});
};
代码高亮的代码实现:
需要借助
hljs
这个库进行转换
marked.use({
renderer: {
code(code, infostring) {
const validLang = !!(infostring && hljs.getLanguage(infostring));
const highlighted = validLang
? hljs.highlight(code, { language: infostring, ignoreIllegals: true }).value
: code;
return `class="hljs ${infostring}">${highlighted}
`;
}
},
});
链接跳转新tab
页的代码实现:
marked.use({
renderer: {
// 链接跳转
link(href, title, text) {
const isExternal = !href.startsWith('/') && !href.startsWith('#');
if (isExternal) {
return `href="${href}" title="${title}" target="_blank" rel="noopener noreferrer">${text}`;
}
return `href="${href}" title="${title}">${text}`;
},
},
});
滚动到高亮的位置的代码实现:
需要配合上面的代码高亮的方法
const firstMatchElement = document.getElementById('first-match');
if (firstMatchElement) {
firstMatchElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
完整的代码如下:
入参的
docUrl
是markdown
文件的线上ur
l地址,searchText
是需要高亮的内容。
import React, { useEffect, useState, useRef } from 'react';
import { marked } from 'marked';
import hljs from 'highlight.js';
const preStyle = {
width: '100%',
maxHeight: '64vh',
minHeight: '64vh',
overflow: 'auto',
};
// Markdown展示组件
function MarkdownViewer({ docUrl, searchText }: { docUrl: string; searchText: string }) {
const [markdown, setMarkdown] = useState('');
const markdownRef = useRef<HTMLDivElement | null>(null);
const highlightAndMarkFirst = (text: string, highlightText: string) => {
let firstMatchDone = false;
const regex = new RegExp(`(${highlightText})`, 'gi');
return text.replace(regex, (match) => {
if (!firstMatchDone) {
firstMatchDone = true;
return `${match}`;
}
return `${match}`;
});
};
useEffect(() => {
// 如果没有搜索内容,直接加载原始Markdown文本
fetch(docUrl)
.then((response) => response.text())
.then((text) => {
const highlightedText = searchText ? highlightAndMarkFirst(text, searchText) : text;
setMarkdown(highlightedText);
})
.catch((error) => console.error('加载Markdown文件失败:', error));
}, [searchText, docUrl]);
useEffect(() => {
if (markdownRef.current) {
// 支持代码高亮
marked.use({
renderer: {
code(code, infostring) {
const validLang = !!(infostring && hljs.getLanguage(infostring));
const highlighted = validLang
? hljs.highlight(code, { language: infostring, ignoreIllegals: true }).value
: code;
return `${infostring}">${highlighted}
`;
},
// 链接跳转
link(href, title, text) {
const isExternal = !href.startsWith('/') && !href.startsWith('#');
if (isExternal) {
return `${href}" title="${title}" target="_blank" rel="noopener noreferrer">${text}`;
}
return `${href}" title="${title}">${text}`;
},
},
});
const htmlContent = marked.parse(markdown);
markdownRef.current!.innerHTML = htmlContent as string;
// 当markdown更新后,检查是否需要滚动到高亮位置
const firstMatchElement = document.getElementById('first-match');
if (firstMatchElement) {
firstMatchElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}, [markdown]);
return (
<div style={preStyle}>
<div ref={markdownRef} />
div>
);
}
export default MarkdownViewer;
预览效果如下:
txt
文件预览展示
支持高亮和滚动到指定位置
支持文字高亮的代码:
function highlightText(text: string) {
if (!searchText.trim()) return text;
const regex = new RegExp(`(${searchText})`, 'gi');
return text.replace(regex, `style="color: red">$1`);
}
完整代码:
import React, { useEffect, useState, useRef } from 'react';
import { preStyle } from './config';
function TextFileViewer({ docurl, searchText }: { docurl: string; searchText: string }) {
const [paragraphs, setParagraphs] = useState<string[]>([]);
const targetRef = useRef<HTMLDivElement | null>(null);
function highlightText(text: string) {
if (!searchText.trim()) return text;
const regex = new RegExp(`(${searchText})`, 'gi');
return text.replace(regex, `$1`);
}
useEffect(() => {
fetch(docurl)
.then((response) => response.text())
.then((text) => {
const highlightedText = highlightText(text);
const paras = highlightedText
.split('\n')
.map((para) => para.trim())
.filter((para) => para);
setParagraphs(paras);
})
.catch((error) => {
console.error('加载文本文件出错:', error);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [docurl, searchText]);
useEffect(() => {
// 处理高亮段落的滚动逻辑
const timer = setTimeout(() => {
if (targetRef.current) {
targetRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
return () => clearTimeout(timer);
}, [paragraphs]);
return (
<div style={preStyle}>
{paragraphs.map((para: string, index: number) => {
const paraKey = para + index;
// 确定这个段落是否包含高亮文本
const isTarget = para.includes(`>${searchText}<`);
return (
<p key={paraKey} ref={isTarget && !targetRef.current ? targetRef : null}>
<div dangerouslySetInnerHTML={{ __html: para }} />
p>
);
})}
div>
);
}
export default TextFileViewer;
预览效果如下:
office
类型的文件: docx、xlsx、pptx
docx、xlsx、pptx
文件的预览,用的是office
的线上预览链接 + 我们文件的线上url
即可。
关于定位:用这种方法我暂时尝试是无法定位页码的,所以定位的功能我采取的是后端将
office
文件转成
示例代码:
预览效果如下:
embed
引入文件:pdf
在
embed
的方式,这个httpsUrl
就是你的
示例代码:
src={`${httpsUrl}`} style={preStyle} key={`${httpsUrl}`} />;
关于定位,其实是地址上拼接的页码sourcePage
,如下:
const httpsUrl = sourcePage
? `${doc.url}#page=${sourcePage}`
: doc.url;
src={`${httpsUrl}`} style={preStyle} key={`${httpsUrl}`} />;
预览效果如下:
iframe
:引入外部完整的网站
除了上面的各种文件,我们还需要预览一些外部的网址,那就要用到
iframe
的方式
示例代码:
< iframe
title="网址"
width="100%"
height="100%"
src={doc.url}
allow="microphone;camera;midi;encrypted-media;"/>
预览效果如下:
课后附加题:
有些网站设置了
X-Frame-Options
不允许其他网站嵌入,X-Frame-Options
是一个HTTP
响应头,用于控制浏览器是否允许一个页面在<frame>
、<iframe>
、<embed>
、 或<object>
中被嵌入。
X-Frame-Options
有以下三种配置:
- DENY:完全禁止该页面被嵌入到任何框架中,无论嵌入页面的来源是什么。
- SAMEORIGIN:允许同源的页面嵌入该页面。
- ALLOW-FROM uri:允许指定的来源嵌入该页面。这个选项允许你指定一个 URI,只有来自该 URI 的页面可以嵌入当前页面。
但是无论是哪种配置,我们作为非同源的网站,都无法将其嵌入到页面中,且在前端也是拿不到这个报错的信息。
此时我们的解决方案是:
当文档为网址时,由后端服务去请求,检测响应头里是否携带
X-Frame-Options
字段,由后端将是否携带的信息返回前端,前端再根据是否可以嵌入进行页面的个性化展示。
预览效果如下:
总结: 到这里我们支持的所有文件都讲述完了,有什么问题,欢迎评论区留言!
链接:juejin.cn/post/7366432628440924170
30分钟搞懂JS沙箱隔离
什么是沙箱环境
在计算机安全中,沙箱(Sandbox)是一种用于隔离正在运行程序的安全机制,通常用于执行未经测试或不受信任的程序或代码,它会为待执行的程序创建一个独立的执行环境,内部程序的执行不会影响到外部程序的运行。
其实在前端世界里,沙箱环境无处不在!
例如以下几个场景:
- Chrome本身就是一个沙箱环境
Chrome 浏览器中的每一个标签页都是一个沙箱(sandbox)。渲染进程被沙箱(Sandbox)隔离,网页 web 代码内容必须通过 IPC 通道才能与浏览器内核进程通信,通信过程会进行安全的检查。
- 在线代码编辑器(码上掘金、CodeSandbox、CodePen等)
在线代码编辑器在执行脚本时都会将程序放置在一个沙箱中,防止程序访问/影响主页面。
- Vue的 服务端渲染
在 Node.js 中有一个模块叫做 VM,它提供了几个 API,允许代码在 V8 虚拟机上下文中运行。
const vm = require('vm');
const sandbox = { a: 1, b: 2 };
const script = new vm.Script('a + b');
const context = new vm.createContext(sandbox);
script.runInContext(context);
vue的服务端渲染实现中,通过创建沙箱执行前端的bundle文件;在调用createBundleRenderer方法时候,允许配置runInNewContext为true或false的形式,判断是否传入一个新创建的sandbox对象以供vm使用。
- Figma 插件
出于安全和性能等方面的考虑,Figma将插件代码分成两个部分:main 和 ui。其中 main 代码运行在沙箱之中,ui 部分代码运行在 iframe 之中,两者通过 postMessage 通信。
- 微前端
典型代表是
Garfish
和qiankun
从0开始实现一个JS沙箱环境
1. 最简陋的沙箱(eval)
问题:
- 要求源程序在获取任意变量时都要加上执行上下文对象的前缀
- eval的性能问题
- 源程序可以访问闭包作用域变量
- 源程序可以访问全局变量
2. eval + with
问题:
- eval的性能问题
- 源程序可以访问闭包作用域变量
- 源程序可以访问全局变量
3. new Function + with
问题:
- 源程序可以访问全局变量
4. ES6 Proxy
我们先看Proxy的使用
Proxy
给 {}
设置了属性访问拦截器,倘若访问的属性为 a
则返回 1,否则走正常程序。
Proxy 支持的拦截操作,一共 13 种:
- get(target, propKey, receiver) :拦截对象属性的读取,比如
proxy.foo
和proxy['foo']
。 - set(target, propKey, value, receiver) :拦截对象属性的设置,比如
proxy.foo = v
或proxy['foo'] = v
,返回一个布尔值。 - has(target, propKey) :拦截
propKey in proxy
的操作,返回一个布尔值。 - deleteProperty(target, propKey) :拦截
delete proxy[propKey]
的操作,返回一个布尔值。 - ownKeys(target) :拦截
Object.getOwnPropertyNames(proxy)
、Object.getOwnPropertySymbols(proxy)
、Object.keys(proxy)
、for...in
循环,返回一个数组。该方法返回目标对象所有自身的属性的属性名,而Object.keys()
的返回结果仅包括目标对象自身的可遍历属性。 - getOwnPropertyDescriptor(target, propKey) :拦截
Object.getOwnPropertyDescriptor(proxy, propKey)
,返回属性的描述对象。 - defineProperty(target, propKey, propDesc) :拦截
Object.defineProperty(proxy, propKey, propDesc)
、Object.defineProperties(proxy, propDescs)
,返回一个布尔值。 - preventExtensions(target) :拦截
Object.preventExtensions(proxy)
,返回一个布尔值。 - getPrototypeOf(target) :拦截
Object.getPrototypeOf(proxy)
,返回一个对象。 - isExtensible(target) :拦截
Object.isExtensible(proxy)
,返回一个布尔值。 - setPrototypeOf(target, proto) :拦截
Object.setPrototypeOf(proxy, proto)
,返回一个布尔值。如果目标对象是函数,那么还有两种额外操作可以拦截。 - apply(target, object, args) :拦截 Proxy 实例作为函数调用的操作,比如
proxy(...args)
、proxy.call(object, ...args)
、proxy.apply(...)
。 - construct(target, args) :拦截 Proxy 实例作为构造函数调用的操作,比如
new proxy(...args)
。
在沙箱环境中,对本身不存在的变量会追溯到全局变量上访问,此时我们可以使用 Proxy "欺骗" 程序,告诉它这个「不存在的变量」是存在的。
报错了,因为我们阻止了所有全局变量的访问。
继续改造:
Symbol.unscopables
Symbol
是 JS 的第七种数据类型,它能够产生一个唯一的值,同时也具备一些内建属性,这些属性可以用来进行元编程(meta programming),即对语言本身编程,影响语言行为。其中一个内建属性 Symbol.unscopables
,通过它可以影响 with
的行为,从而造成沙箱逃逸。
对这种情况做一层加固,防止沙箱逃逸
到这一步,其实很多较为简单的场景就可以覆盖了(比如: Vue 的模板字符串)。
仍然有很多漏洞:
code
中可以提前关闭sandbox
的with
语境,如'} alert(this); {'
code
中可以使用eval
和new Function
直接逃逸code
中可以通过访问原型链实现逃逸- 更为复杂的场景,如何实现任意使用诸如
document
、location
等全局变量且不会影响主页面。
5. iframe是天然的优质沙箱
iframe
标签可以创造一个独立的浏览器原生级别的运行环境,这个环境由浏览器实现了与主环境的隔离。在 iframe
中运行的脚本程序访问到的全局对象均是当前 iframe
执行上下文提供的,不会影响其父页面的主体功能,因此使用 iframe
来实现一个沙箱是目前最方便、简单、安全的方法。
如果只考虑浏览器环境,可以用 With + Proxy + iframe 构建出一个比较好的沙箱:
- 利用
iframe
对全局对象的天然隔离性,将iframe.contentWindow
取出作为当前沙箱执行的全局对象 - 将上述沙箱全局对象作为
with
的参数限制内部执行程序的访问,同时使用Proxy
监听程序内部的访问。 - 维护一个共享状态列表,列出需要与外部共享的全局状态,在
Proxy
内部实现访问控制。
6. 基于ShadowRealm 提案的实现
ShadowRealm API 是一个新的 JavaScript 提案,它允许一个 JS 运行时创建多个高度隔离的 JS 运行环境(realm),每个 realm 具有独立的全局对象和内建对象。
这项特性提案时间为 2021 年 12 月,目前在Stage 3阶段 tc39.es/proposal-sh…
evaluate(sourceText: string)
同步执行代码字符串,类似 eval()importValue(specifier: string, bindingName: string)
异步执行代码字符串
7. Web Workers
Web Workers
代码运行在独立的进程中,通信是异步的,无法获取当前程序一些属性或共享状态,且有一点无法不支持 DOM 操作,必须通过 postMessage 通知 UI 主线程来实现。
以上就是实现JS沙箱隔离的一些思考点。在真实的业务应用中,没有最完美的方案,只有最合适的方案
,还需要结合自身业务的特性做适合自己的选型。
来源:juejin.cn/post/7410347763898597388
uniapp截取视频画面帧
前言
最近开发中遇到这么一个需求,上传视频文件的时候需要截取视频的一部分画面来供选择,作为视频的封面,截取画面可以使用canvas来实现,将视频画面画进canvas里,再通过canvas来生成文件对象和一个预览的url
逻辑层和视图层
想要将视频画进canvas里就需要操作dom,但是在uniapp中我们是无法操作dom的,uniapp的app端逻辑层和视图层是分离的,renderjs运行的层叫【视图层】,uniapp原生script叫【逻辑层】,会产生一个副作用就是是在造成了两层之间通信阻塞
所以uniapp推出了renderjs,renderjs
是一个运行在视图层的js,可以让我们在视图层操作dom,但是不能直接调用,需要在dom元素中绑定某个值,当值发生改变就会触发视图层的事件
// 视图层
// module为renderjs模块的名称,通过 模块名.事件 来调用事件
<script module="capture" lang="renderjs"></script>
// 逻辑层
// prop为绑定的值,名字可以随便起,但是要和change后面一致
// 当prop绑定的值发生改变就会触发capture模块的captures事件
<view style="display: none;" :prop="tempFilePath" :change:prop="capture.captures"></view>
<template>
<view class="container">
<view style="display: none;" :prop="tempFilePath" :change:prop="capture.captures"></view>
</view>
</template>
<script>
export default {
data() {
return {
tempFilePath: ''
}
},
mounted() {
this.tempFilePath = 'aaaaaaaaaaaaaaaa'
}
}
</script>
<script module="capture" lang="renderjs">
export default {
methods: {
captures(tempFilePath) {
console.log(tempFilePath);
},
}
}
</script>
截取画面帧
我们先获取到视频的信息,通过uni.chooseVideo(OBJECT)
这个api我们可以拿到视频的临时文件路径,然后再将临时路径交给renderjs去进行截取操作
定义一个captureFrame方法,方法接收两个参数:视频的临时文件路径和截取的时间。先创建video元素,设置video元素的currentTime属性(视频播放的当前位置)的值为截取的时间,由于video元素没有加到dom上,video播放到currentTime结束
并设置video元素的autoplay自动播放属性为true,但是由于浏览器的限制video无法自动播放,但是静音状态下可以自动播放,所以还要将video元素的muted属性设为true,最后再将src属性设置为视频的临时文件路径,当video元素可以播放的时候就可以将video元素画进canvas里了
captureFrame(vdoSrc, time = 0) {
return new Promise((resolve) => {
const vdo = document.createElement('video')
// video元素没有加到dom上,video播放到currentTime(当前帧)结束
// 定格时间,截取帧
vdo.currentTime = time
// 设置自动播放,不播放是黑屏状态,截取不到帧画面
// 静音状态下允许自动播放
vdo.muted = true
vdo.autoplay = true
vdo.src = vdoSrc
vdo.oncanplay = async () => {
const frame = await this.drawVideo(vdo)
resolve(frame)
}
})
},
然后再定义一个drawVideo方法用于绘制视频,接收一个video元素参数,在方法中创建一个canvas元素,将canvas元素的宽高设置为video元素的宽高,通过drawImage方法将视频画进canvas里,再通过toBlob方法创建Blob对象,然后通过URL.createObjectURL() 创建一个指向blob对象的临时url,blob对象可以用来上传,url可以用来预览
drawVideo(vdo) {
return new Promise((resolve) => {
const cvs = document.createElement('canvas')
const ctx = cvs.getContext('2d')
cvs.width = vdo.videoWidth
cvs.height = vdo.videoHeight
ctx.drawImage(vdo, 0, 0, cvs.width, cvs.height)
// 创建blob对象
cvs.toBlob((blob) => {
resolve({
blob,
url: URL.createObjectURL(blob),
})
})
})
}
最后我们就可以在触发视图层的事件里去使用这两个方法来截取视频画面帧了,最后将数据传递返回给逻辑层,通过this.$ownerInstance.callMethod() 向逻辑层发送消息并将数据传递过去
// 视图层
async captures(tempFilePath) {
let duration = await this.getDuration(tempFilePath)
let list = []
for (let i = 0; i < duration; i++) {
const frame = await this.captureFrame(tempFilePath, duration / 10 * i)
list.push(frame)
}
this.$ownerInstance.callMethod('captureList', {
list,
duration
})
},
getDuration(tempFilePath) {
return new Promise(resolve => {
const vdo = document.createElement('video')
vdo.src = tempFilePath
vdo.addEventListener('loadedmetadata', () => {
const duration = Math.floor(vdo.duration);
vdo.remove();
resolve(duration)
});
})
},
// 逻辑层
captureList({
list,
duration
}) {
// 操作......
}
运行
最后运行起来,发现报了一个错误:Failed to execute 'toBlob' on 'HTMLCanvasElement': Tainted canvases may not be exported,这是因为由于浏览器的安全考虑,如果在使用canvas绘图的过程中,使用到了外域的资源,那么在toBlob()时会抛出异常,设置video元素的crossOrigin属性值为anonymous就行了
app端 | h5 |
---|---|
![]() | ![]() |
来源:juejin.cn/post/7281912738863087656
面试必问,防抖函数的核心是什么?
防抖节流的作用是什么?
节流(throttle)与 防抖(debounce)都是为了限制函数的执行频次,以优化函数触发频率过高导致的响应速度跟不上触发频率,出现延迟,假死或卡顿的现象。
防抖:是指在一定时间内,在动作被连续频繁触发的情况下,动作只会被执行一次,也就是说当调用动作过n毫秒后,才会执行该动作,若在这n毫秒内又调用此动作则将重新计算执行时间,所以短时间内的连续动作永远只会触发一次,比如说用手指一直按住一个弹簧,它将不会弹起直到你松手为止。
节流:是指一定时间内执行的操作只执行一次,也就是说即预先设定一个执行周期,当调用动作的时刻大于等于执行周期则执行该动作,然后进入下一个新周期,一个比较形象的例子是人的眨眼睛,就是一定时间内眨一次。
防抖函数应用场景:
就比如说这段代码:
let btn = document.getElementById('btn')
btn.addEventListener('click', function() {
console.log('提交'); // 换成ajax请求
})
当你点击按钮N下,它就会打印N次“提交”,但如果把 console 换成 ajax 请求,可想而知后端接受到触发频率如此之高的请求,造成的页面卡顿甚至瘫痪的后果。
防抖函数的核心:
面对此种情形,我们必须在原有的基础上作出改进,做到在规定的时间内没有下一次的触发,才执行的效果。
那么首先我们要做的,就是创建一个防抖函数,这个函数的功能是设置一个定时器,每次点击都会触发一个定时器输出,但如果两次点击的间隔小于1s,则销毁上一个定时器,达到最后只有一个定时器输出的效果。
定时器:
在防抖节流中,最为重要的一个部分就是定时器,就比如下面这段代码,
setTimeout
的功能就是设置一个定时器,让setTimeout
内部的代码延迟执行在 1000 毫秒后。
setTimeout(function(){
console.log('提交');
}, 1000)
特别需要注意一点的是,定时器中回调函数里的 this 指向会更改成指向 window。
于是我们创建专门的debounce
函数用于实现防抖,把handle
交给debounce
处理,再在debounce
内部设置一个setTimeout
定时器,将handle
的执行推迟到点击事件发生的一秒后,这样一来,我们就实现了初步的想法。
let btn = document.getElementById('btn')
function handle(){
console.log('提交', this); // 换成ajax请求
}
// 创建专门的debounce函数用于防抖,把handle交给debounce处理
btn.addEventListener('click', debounce(handle))
// 将点击事件推迟一秒
function debounce(fn){
return function() {
// 设置定时器
setTimeout(fn, 1000)
}
}
那么关键来了,我们又在原基础上添加一个timer
用于接收定时器返回的值(通常称为定时器的ID),然后设置clearTimeout(timer)
通过timer
取消之前通过 setTimeout
创建的定时器。
通过这段代码,我们便实现了如果在 1s 内频繁点击的话,上一次点击的事件都会被下一次点击取消,从而达到规定的时间内没有下一次的触发,再执行的防抖目的!
let btn = document.getElementById('btn')
function handle(){
console.log('提交', this); // 换成ajax请求
}
// 创建专门的debounce函数用于防抖,把handle交给debounce处理
btn.addEventListener('click', debounce(handle))
// 防抖函数
function debounce(fn){
let timer = null; // 接收定时器返回的ID
return function() {
// 设置定时器
clearTimeout(timer); // 取消之前通过 `setTimeout` 创建的定时器
timer = setTimeout(fn, 1000);
}
}
但是别忘了,我们之前提到过,定时器改变了handle
中 this 指向,要做到尽善尽美,我们必须通过显示绑定修正 this 的指向。
同时别忘记还原原函数的参数。
利用箭头函数不承认 this 的特性,我们将代码修改成这样:
let btn = document.getElementById('btn')
function handle(e){
console.log('提交'); // 换成ajax请求
}
// 创建专门的debounce函数用于防抖,把handle交给debounce处理
btn.addEventListener('click', debounce(handle))
// 防抖函数
function debounce(fn){
let timer = null; // 接收定时器返回的ID
return function(e) {
// 设置定时器
clearTimeout(timer);
timer = setTimeout(() => {
fn.call(this,e); // 修正this的同时归还原函数的参数
}, 1000)
}
}
至此,大功告成!
防抖函数核心机制:
同时需要理解的是:防抖函数的核心机制就是闭包,当每一次点击会产生debounce
执行上下文,随后debounce
执行完其上下文又被反复销毁,但是其中的变量timer
又始终保持着对function
外部的引用,于是由此形成了闭包。
关于 this 的指向可以参考这篇文章:juejin.cn/post/739763…
关于闭包概念可以参考这篇文章:juejin.cn/post/739762…
最后:
那么现在我们可以总结出这个防抖函数的核心理念和四大要点。
核心理念:点击按钮后,做到在规定的时间内没有下一次的触发,才执行
- 其中
debounce
返回一个函数体,跟debounce
形成了一个闭包。 - 子函数体中每次先销毁上一个
setTimeout
,再创建一个新的setTimeout
。 - 最后需要 还原原函数的 this 指向。
- 最后需要 还原原函数的参数。
来源:juejin.cn/post/7400253623790272547
关于微信小程序(uniapp)的优化
前言
开篇雷击
好害怕
怎么办
不要慌
仔细看完文章,彻底解决代码包大小超过限制
提示:以下是本篇文章正文内容,下面案例可供参考
一、微信小程序上传时的规则
微信小程序官方规定主包大小不能超过2M,单个分包大小也不能超过2M,多个分包总大小不能超过8M,文件过大会增加启动耗时,对用户体验不友好。
官方解释:
二、分析、整理项目中的文件
1.正常来说一个小程序该有以下目录构成:
│
│——.hbuilderx
│
│——api // 接口路径及请求配置
│
│——components // 公共组件
│
│——config // 全局配置
│
│——node_modules // 项目依赖
│
│——pages // 项目主包
│
│——order // 项目分包
│
│——static // 静态资源
│ │
│ ├─scss // 主包css样式
│ │
│ ├─js // 全局js方法
│ │
│ └─image // tabBar图标目录
│
│——store // Vuex全局状态管理
│
│——utils // 封装的特定过滤器
│
│——error-log // 错误日志
│......
│
2.和自己本地的文件目录对比一下,分析后根据实际情况整理出规范的目录,相同文件规整至一起,删除多余的文件,检查每个页面中是否存在未使用的引用资源
三、按以下思路调整
1.图标资源建议只留下tabBar图标(注意:tabBar图标的大小,控制在30-40k左右最优)
,其余资源通过网络路径访问,有条件的就上个CDN加速好吧。
2.主包目录建议只留下tabBar相关的页面,其余文件按分包处理(注意:单个分包大小也不能超过2M,多个分包总大小不能超过8M,根据业务划分出合理的分包:例如:order、pay、login、setting、user...)
3.公共组件,公共方法的使用(建议:把分包理解成一个单独的项目,scss,js,components,小程序插件...这些都是仅限于这个分包内使用,后期也方便维护)
4.避免使用过大的js文件,或替换为压缩版或者mini版
5.检查是否存在冗余代码,抽出可复用的进行封装
6.小程序插件(建议:挂载在分包上使用,挂载主包上会影响体积)
{
// 分包order
"root": "order",
"pages": [{
"path": "index",
"style": {
"navigationStyle": "custom",
"usingComponents": {
"healthcode": "plugin://xxxxx"
}
}
}
],
//插件引入
"plugins": {
"healthcode-plugin": {
"version": "0.2.3",
"provider": "插件appid"
}
}
}
7.根据官方建议开启按需引入、分包优化
manifest.json-源码视图
"mp-weixin" : {
"appid" : "xxxxx",
"setting" : {
"urlCheck" : false,
"minified" : true
},
// 按需引入
"lazyCodeLoading" : "requiredComponents",
"permission" : {
"scope.userLocation" : {
"desc" : "获取您的位置信息,用于查询数据"
}
},
"requiredPrivateInfos" : [ "getLocation", "chooseLocation" ],
// 分包优化
"optimization" : {
"subPackages" : true
}
}
8.Hbuilderx工具点击发行-微信小程序 (注意:运行默认是不会压缩代码)
四、终极办法
如果按以上步骤下来,还是提示代码大小超过限制的话,不妨从微信开发工具上试试
按图勾选上相关配置(注意:不要勾选上传代码时样式自动补全,会增加代码体积)
五、写在最后
1.提升小程序首页渲染速度 官方给出的代码注入优化
首页代码避免出现复杂的逻辑,控制代码量,去除无用的引入,合理的接口数量
2.小程序加载分包时可能会遇到等待的情况,解决这个问题的办法:
pages.json文件开启分包预下载
"preloadRule": {
"pages/index": { // 访问首页的时候就开始下载order、pay分包的内容
"network": "all", // 网络环境 all全部网络,wifi仅wifi下预下载
"packages": ["order","pay"] // 要下载的分包
}
}
总结
本文介绍了开发微信小程序时,遇到的代码包大小超过限制的问题,希望可以帮助到你。
来源:juejin.cn/post/7325132133168185381
骚操作:如何让一个网页一直处于空白情况?
好了,周末闲来无事,突然有个诡异想法!
如题,惯性思路很简单,就是直接撸上一个空内容的html。
注:以下都是在现代浏览器中执行,主要为**Chrome 版本 120.0.6099.217(正式版本) (64 位)和Firefox123.0.1 (64 位) **
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>demo</title>
</head>
<body>
</body>
</html>
؏؏☝ᖗ乛◡乛ᖘ☝؏؏~
但是,要优雅~咱玩的花一点,如果这个HTML中加入一行文字,比如下面这样,如何让这行文字一直不显示出来呢?
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>demo</title>
</head>
<body>
<div>放我出去!!!</div>
</body>
</html>
思考几秒~有了,江湖一直传言,Javascrip代码执行不是影响Render树生成么,上循环!于是如下
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>demo</title>
</head>
<body>
<div>放我出去!!!</div>
<script>
while (1) {
let a;
}
// 或者这样
/*(function stop() {
var message = confirm("我不想让文字出来!");
if (message == true) {
stop()
} else {
stop()
}
})()*/
</script>
</body>
</html>
```一下一下
bingo,可以实现,那再换个思路呢?加载资源?
说干就干,在开发者工具上,设置上下载速度为1kb/s,测试了以下三种类型资源
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>demo</title>
</head>
<body>
<!-- <link rel="stylesheet" href="https://lf3-cdn-tos.bytescm.com/obj/static/xitu_juejin_web/static/bytedesign.min.css" as="style"/> -->
<!-- <img src="https://lf3-cdn-tos.bytescm.com/obj/static/xitu_juejin_web/static/bytedesign.min.css"/> -->
<div class="let-it-go">放我出去!!!</div>
<script src="https://lf3-cdn-tos.bytescm.com/obj/static/log-sdk/collect/5.1/collect.js"></script>
<style>
.let-it-go {
color: red;
}
</style>
</body>
</html>
总得来说,JS和CSS文件,需要排在.let-it-go元素前面或者样式前面,才会影响到渲染DOM或者CSSOM,图片或者影片之类的,不管放前面还是后面,都无影响。如果在css文件中,一直有import外部CSS,也是有很大影响!
但正如题目,这种只能影响一时,却不能一直影响,就算你在代码里写一个在头部不停插入脚本,也没有用,比如如下这么写,按,依旧无效:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>demo</title>
</head>
<body>
<link rel="stylesheet" href="https://lf3-cdn-tos.bytescm.com/obj/static/xitu_juejin_web/static/bytedesign.min.css"
as="style" />
<!-- <img src="https://lf3-cdn-tos.bytescm.com/obj/static/xitu_juejin_web/static/bytedesign.min.css"/> -->
<script>
// setInterval(()=>{
// 不停插入script脚本 或者css文件
let index = '';
(function fetchFile() {
var script = document.createElement('script');
script.src = `https://lf3-cdn-tos.bytescm.com/obj/static/log-sdk/collect/5.1/collect${index}.js`;
document.head.appendChild(script);
script.onload = () => {
fetchFile()
}
script.onerror = () => {
fetchFile()
}
index+=1
// 创建一个 link 元素
//var link = document.createElement('link');
// 设置 link 元素的属性
// link.rel = 'stylesheet';
// link.type = 'text/css';
// link.href = 'https://lf3-cdn-tos.bytescm.com/obj/static/xitu_juejin_web/app.f81e9f9${index}.css';
// 将 link 元素添加到文档的头部
//document.head.appendChild(link);
})()
// },1000)
</script>
<div class="let-it-go">放我出去!!!</div>
<style>
.let-it-go {
color: red;
}
</style>
<!-- <script src="https://lf3-cdn-tos.bytescm.com/obj/static/log-sdk/collect/5.1/collect.js"></script> -->
</body>
</html>
那么,还有别的方法吗?暂时没有啥想法了,等后续再在这篇上续接~
另外,在实验过程中,有一个方式让我很意外,以为以下代码也会造成页面一直空白,但好像不行。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>demo</title>
</head>
<body>
<div id="appp"></div>
<script>
(function createElement() {
var parentElement = document.getElementById('appp');
// 创建新的子元素
var newElement = document.createElement('div');
// 添加文本内容(可选)
newElement.textContent = '这是新的子元素';
// 将新元素添加到父元素的子元素列表的末尾
parentElement.appendChild(newElement);
createElement()
})()
</script>
<div class="let-it-go">放我出去!!!</div>
</body>
</html>
这可以很好的证明,插入DOM元素这个任务,会在主HTML渲染之后再执行。
祝周末愉快~
来源:juejin.cn/post/7344164779629985818
js运算精度丢失,用这个库试试?
简述
当js
进行算术运算时,有时候会遇到以下几个问题:
// 控制台可以尝试以下代码
0.1 + 0.2 // 0.30000000000000004
0.3 - 0.1 // 0.19999999999999998
19.9 * 100 // 1989.9999999999998
为什么会遇到这个问题呢?
由于在计算机运算过程中,十进制的数会被转化为二进制来运算,有些浮点数用二进制表示是无穷的,浮点数运算标准(IEEE 754)64位双精度的小数部分最多支持53位二进制位,运算过程中超出的二进制位会被截断。运算完后再转为十进制。所以产生了精度问题。
为了解决此问题,整理了一些第三方的js
库。
相关js
库推荐
js库名称 | 备注 |
---|---|
Math.js | JavaScript 和 Node.js 的扩展数学库 |
decimal.js | javaScript 任意精度的库 |
big.js | 一个轻量的任意精度库 |
big.js
版本介绍
本次用的big.js
版本为6.2.1
页面引入
下载big.js
:
访问以下页面,在网页中右键另存为即可
// 因为作为本地测试,就不下载压缩版本了
https://cdn.jsdelivr.net/npm/big.js@6.2.1/big.js
// 若需要压缩版本
https://cdn.jsdelivr.net/npm/big.js@6.2.1/big.min.js
引入到html
页面:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Big js</title>
</head>
<body>
<!-- 引入页面 -->
<script src="./js/big.js"></script>
<script>
// 尝试Big构造方法
console.log('Big', Big)
</script>
</body>
</html>
工程化项目
npm install big.js
在所需页面引入:
// 现在一般用 es 模块引入
import Big from 'big.js';
使用
基本演示:
// 加
let a = new Big(0.1)
a = a.plus(0.2)
// 由于运算结果是个对象,所以展示以下值
console.log('a', a) // {s: 1, e: -1, c: Array(1)}
// 可以使用 Number || toNumber() 转为我们需要的数值
console.log('a', a.toNumber) || console.log('a', Number(a)) // 0.3
可以链式调用:
x.div(y).plus(z).times(9)
参考文档
// big.js 项目 github地址
https://mikemcl.github.io/big.js
// big.js 官方文档地址
https://mikemcl.github.io/big.js/
// 这篇文档将api写的很全了
https://blog.csdn.net/a123456234/article/details/132305810
来源:juejin.cn/post/7356531073469825033
前端实现图片压缩方案总结
前文提要
在Web开发中,图片压缩是一个常见且重要的需求。随着高清图片和多媒体内容的普及,如何在保证图片质量的同时减少其文件大小,对于提升网页加载速度、优化用户体验至关重要。前端作为用户与服务器之间的桥梁,实现图片压缩的功能可以显著减轻服务器的负担,加快页面渲染速度。本文将探讨前端实现图片压缩的几种方法和技术。
1. 使用HTML5的<canvas>元素
HTML5的<canvas>元素为前端图片处理提供了强大的能力。通过JavaScript操作<canvas>,我们可以读取图片数据,对其进行处理(如缩放、裁剪、转换格式等),然后输出压缩后的图片。
步骤概述:
- 读取图片:使用
FileReader
或Image
对象加载图片。 - 绘制到<canvas>:将图片绘制到<canvas>上,通过调整<canvas>的尺寸或绘图参数来控制压缩效果。
- 导出图片:使用
canvas.toDataURL()
方法将<canvas>内容转换为Base64编码的图片,或使用canvas.toBlob()
方法获取Blob对象,以便进一步处理或上传。
示例代码:
function compressImage(file, quality, callback) {
const reader = new FileReader();
reader.onload = function(e) {
const img = new Image();
img.onload = function() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 设置canvas的尺寸,这里可以根据需要调整
const MAX_WIDTH = 800;
const MAX_HEIGHT = 600;
let width = img.width;
let height = img.height;
if (width > height) {
if (width > MAX_WIDTH) {
height *= MAX_WIDTH / width;
width = MAX_WIDTH;
}
} else {
if (height > MAX_HEIGHT) {
width *= MAX_HEIGHT / height;
height = MAX_HEIGHT;
}
}
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
// 转换为压缩后的图片
canvas.toBlob(function(blob) {
callback(blob);
}, 'image/jpeg', quality);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
// 使用示例
const fileInput = document.querySelector('input[type="file"]');
fileInput.addEventListener('change', function(e) {
const file = e.target.files[0];
compressImage(file, 0.7, function(blob) {
// 处理压缩后的图片,如上传或显示
console.log(blob);
});
});
2. 利用第三方库(推荐)
除了原生JavaScript和HTML5外,还有许多优秀的第三方库可以帮助我们更方便地实现图片压缩,如image-magic-adapter、compressorjs
、pica
等。这些库通常提供了更丰富的配置选项和更好的兼容性支持。
特别推荐的库: image-magic-adapter
这个三方库是国内开发者提供的,他集成许多图片处理能力,包括“图片压缩”、“图片格式转换”、“图片加水印”等等,非常方便,而且这个库还有官网也可以直接使用这些功能.
库官网:http://www.npmjs.com/package/ima…
在线图片处理工具官网:luckycola.com.cn/public/dist…
使用 image-magic-adapter示例:
// 引入image-magic-adapter
import ImageMagicAdapter from 'image-magic-adapter';
let ImageCompressorCls = ImageMagicAdapter.ImageCompressorCls;
const imageCompressor = new ImageCompressorCls(); // 默认压缩质量
// -----------------------------------------图片压缩-----------------------------------------
document.getElementById('quality').addEventListener('input', () => {
const quality = parseFloat(document.getElementById('quality').value);
imageCompressor.quality = 1 - quality; // 更新压缩质量
console.log('更新后的压缩质量:', imageCompressor.quality);
});
document.getElementById('compress').addEventListener('click', async () => {
const fileInput = document.getElementById('upload');
if (!fileInput.files.length) {
alert('请上传图片');
return;
}
const files = Array.from(fileInput.files);
const progress = document.getElementById('progress');
const outputContainer = document.getElementById('outputContainer');
const downloadButton = document.getElementById('download');
const progressText = document.getElementById('progressText');
outputContainer.innerHTML = '';
downloadButton.style.display = 'none';
progress.style.display = 'block';
progress.value = 0;
progressText.innerText = '';
// compressImages参数说明:
// 第一个参数: files:需要压缩的文件数组
// 第二个参数: callback:压缩完成后的回调函数
// 第三个参数: 若是压缩png/bmp格式,输出是否保留png/bmp格式,默认为true(建议设置为false)
// 注意:如果 第三个参数设置true压缩png/bmp格式后的输出的文件为原格式(png/bmp)且压缩效果不佳,就需要依赖设置scaleFactor来调整压缩比例(0-1);如果设置为false,输出为image/jpeg格式且压缩效果更好。
// 设置caleFactor为0-1,值越大,压缩比例越小,值越小,压缩比例越大(本质是改变图片的尺寸),例: imageCompressor.scaleFactor = 0.5;
await imageCompressor.compressImages(files, (completed, total) => {
const outputImg = document.createElement('img');
outputImg.src = imageCompressor.compressedImages[completed - 1];
outputContainer.appendChild(outputImg);
progress.value = (completed / total) * 100;
progressText.innerText = `已完成文件数: ${completed} / 总文件数: ${total}`;
if (completed === total) {
downloadButton.style.display = 'inline-block';
}
}, false);
downloadButton.onclick = () => {
if (imageCompressor.compressedImages.length > 0) {
imageCompressor.downloadZip(imageCompressor.compressedImages);
}
};
});
<h4>图片压缩Demoh4>
<input type="file" id="upload" accept="image/*" multiple />
<br>
<label for="quality">压缩比率:(比率越大压缩越大,图片质量越低)label>
<input type="range" id="quality" min="0" max="0.9" step="0.1" required value="0.5"/>
<br>
<button id="compress">压缩图片button>
<br>
<progress id="progress" value="0" max="100" style="display: none;">progress>
<br />
<span id="progressText">span>
<br>
<div id="outputContainer">div>
<br>
<button id="download" style="display: none;">下载已压缩图片button>
3. gif图片压缩(拓展)
GIF(Graphics Interchange Format)图片是一种广泛使用的图像文件格式,特别适合用于显示索引颜色图像(如简单的图形、图标和某些类型的图片),同时也支持动画。尽管GIF图片本身可以具有压缩特性,但在前端和后端进行压缩处理时,存在几个关键考虑因素,这些因素可能导致在前端直接压缩GIF不如在后端处理更为有效或合理。
下面提供一个厚后端通过node实现gif压缩的方案:
1、下载imagemin、imagemin-gifsicle和image-size库
2、注意依赖的库的版本,不然可能会报错
"image-size": "^1.1.1",
"imagemin": "7.0.1",
"imagemin-gifsicle": "^7.0.0",
node压缩gif实现如下:
const imagemin = require('imagemin');
const imageminGifsicle = require('imagemin-gifsicle');
const sizeOf = require('image-size');
// 压缩 GIF colors[0-256]
const compressGifImgFn = async (inputBase64, colors = 200, successFn = () => {}, failFn = () => {}) => {
try {
if (inputBase64.length <= 10) {
failFn && failFn('inputBase64 无效')
return;
}
// 获取输入 GIF 的尺寸
const originalSize = getBase64Size(inputBase64);
console.log('Original Size:', originalSize);
// 转换 Base64 为 Buffer
const inputBuffer = base64ToBuffer(inputBase64);
const outputBuffer = await imagemin.buffer(inputBuffer, {
plugins: [
imageminGifsicle({
// interlaced的作用 是,是否对 GIF 进行隔行扫描
interlaced: true,
// optimizationLevel的作用是,设置压缩的质量,0-3
optimizationLevel: 3,
// // progressive的作用是,是否对 GIF 进行渐进式压缩
// progressive: true,
// // palette的作用是,是否对 GIF 进行调色板优化
// palette: true,
// // colorspace的作用是,是否对 GIF 进行色彩空间转换
// colorspace: true,
colors
})
]
});
// 转换压缩后的 Buffer 为 Base64
const outputBase64 = bufferToBase64(outputBuffer);
// 获取压缩后 GIF 的尺寸
const compressedSize = getBase64Size(outputBase64);
console.log('Compressed Size:', compressedSize);
// 输出压缩后的 Base64 GIF
// console.log(outputBase64);
let gifCompressRes = {
outputBase64,
compressedSize,
originalSize
}
successFn && successFn(gifCompressRes);
} catch (error) {
console.error('Error compressing GIF:', error);
failFn && failFn(error)
}
};
// 将 Base64 字符串转换为 Buffer
function base64ToBuffer(base64) {
const base64Data = base64.split(',')[1]; // 如果是 data URL, 删除前缀
return Buffer.from(base64Data, 'base64');
}
// 将 Buffer 转换为 Base64 字符串
function bufferToBase64(buffer) {
return `data:image/gif;base64,${buffer.toString('base64')}`;
}
//获取base64图片大小,返回kb数字
function getBase64Size(base64url) {
try {
//把头部去掉
let str = base64url.replace('data:image/png;base64,', '');
// 找到等号,把等号也去掉
let equalIndex = str.indexOf('=');
if (str.indexOf('=') > 0) {
str = str.substring(0, equalIndex);
}
// 原来的字符流大小,单位为字节
let strLength = str.length;
// 计算后得到的文件流大小,单位为字节
let fileLength = parseInt(strLength - (strLength / 8) * 2);
// 由字节转换为kb
let size = "";
size = (fileLength / 1024).toFixed(2);
let sizeStr = size + ""; //转成字符串
let index = sizeStr.indexOf("."); //获取小数点处的索引
let dou = sizeStr.substr(index + 1, 2) //获取小数点后两位的值
if (dou == "00") { //判断后两位是否为00,如果是则删除00
return sizeStr.substring(0, index) + sizeStr.substr(index + 3, 2)
}
return size;
} catch (error) {
console.log('getBase64Size error:', error);
return 0;
}
};
注意事项
- 压缩质量与文件大小:压缩质量越高,图片质量越好,但文件大小也越大;反之亦然。需要根据实际需求调整。
- 兼容性:虽然现代浏览器普遍支持<canvas>和Blob等特性,但在一些老旧浏览器上可能存在问题,需要做好兼容性处理。
- 性能考虑:对于大图片或高频率的图片处理,前端压缩可能会占用较多CPU资源,影响页面性能。
来源:juejin.cn/post/7409869765176475686
vue3连接mqtt
什么是MQTT?
MQTT(Message Queuing Telemetry Transport)是一种轻量级的、基于发布/订阅模式的通信协议,通常用于连接物联网设备和应用程序之间的通信。它最初由IBM开发,现在由OASIS(Organization for the Advancement of Structured Information Standards)进行标准化。
MQTT的工作原理很简单:它采用发布/订阅模式,其中设备(称为客户端)可以发布消息到特定的主题(topics),而其他设备可以订阅这些主题以接收消息。这种模式使得通信非常灵活,因为发送者和接收者之间的耦合度很低。MQTT还支持负载消息(payload message)的传输,这意味着可以发送各种类型的数据,如传感器读数、控制指令等。
MQTT的轻量级设计使其在网络带宽和资源受限的环境中表现出色,因此在物联网应用中得到了广泛应用。它可以在低带宽、不稳定的网络环境下可靠地运行,同时保持较低的能耗。MQTT也有许多开源实现和客户端库,使得它在各种平台上都能方便地使用。
MQTT在项目的运用
官网使用指南:docs.emqx.com/zh/cloud/la…
(1)安装MQTT
npm install mqtt
(2)本项目Vite和Vue版本(包括但不限于)
"vue":"^3.3.11"
"vite": "^5.0.10"
(3)引入MQTT文件
import mqtt from "mqtt";
(4)MQTT的具体使用
本文将使用 EMQ X 提供的 免费公共 MQTT 服务器,该服务基于 EMQ X 的 MQTT 物联网云平台 创建。服务器接入信息如下:
Broker: broker.emqx.io
Port: 8083
export const connectMqtt = ({host, name, pwd, theme},onMessageArrived) => {
let client = null
let url = `${host}/mqtt`
let options={
username: name, // 用户名字
password: pwd, // 密码
// clientId: 'clientId'
}
try {
client = mqtt.connect(url, options)
}catch (error) {
console.log('mqtt.connect error', error)
}
// 订阅主题
client.subscribe(theme, (topic) => {
console.log(topic); // 此处打印出订阅的主题名称
});
// 推送消息
// client.publish(theme, JSON.stringify({one: '1', two: '2'}));
//接受消息
client.on("message", (topic, data) => {
// 这里有可能拿到的数据格式是Uint8Array格式,所以可以直接用toString转成字符串
let dataArr = data.toString();
console.log('mqtt收到的消息', dataArr);
onMessageArrived(data)
});
// 重连
client.on("reconnect", (error) => {
console.log("正在重连mqtt:", error);
});
// 错误回调
client.on("error", (error) => {
console.log("MQTT连接发生错误已关闭");
});
}
参考链接:
来源:juejin.cn/post/7410017851626913833
前端如何实现图片伪防盗链,保护页面图片
在前端开发中,实现图片防盗链通常涉及到与后端服务器的交互,因为防盗链机制主要是在服务器端实现的。然而,前端也可以采取一些措施来增强图片保护,并与服务器端的防盗链策略配合使用。以下是前端可以采用的一些方法:
一、使用 Token 保护图片资源
- 动态生成 Token
在用户请求图片时,可以在前端生成一个包含时间戳的 token,然后将其附加到图片 URL 中。这个 token 可以在服务器端验证。
前端代码示例(使用 JavaScript):
// 生成当前时间戳作为 token
function generateToken() {
return Date.now();
}
// 获取图片 URL
function getImageUrl() {
const token = generateToken();
return `https://example.com/images/photo.jpg?token=${token}`;
}
// 设置图片 src
document.getElementById('image').src = getImageUrl();
解释:
generateToken()
函数生成一个时间戳作为 token。getImageUrl()
函数将 token 附加到图片 URL 中,以便进行验证。
- 在图片请求中使用 Token
在图片加载时,确保 URL 中包含有效的 token。前端可以在页面加载时动态设置图片 URL。
前端代码示例(使用 Vue.js):
<template>
<img :src="imageUrl" alt="Protected Image" />
</template>
<script>
export default {
data() {
return {
imageUrl: ''
};
},
methods: {
generateToken() {
return Date.now(); // 或使用其他方法生成 token
}
},
created() {
const token = this.generateToken();
this.imageUrl = `https://example.com/images/photo.jpg?token=${token}`;
}
};
</script>
解释:
- 使用 Vue 的生命周期钩子
created
来生成 token 并设置图片 URL。
- 使用 Vue 的生命周期钩子
二、设置图片加载控制
- 防止右键下载
在前端,你可以通过 CSS 或 JavaScript 来禁用图片的右键菜单,从而防止用户通过右键菜单下载图片。
前端代码示例(使用 CSS):
<style>
.no-right-click {
pointer-events: none;
}
</style>
<img class="no-right-click" src="https://example.com/images/photo.jpg" alt="Protected Image" />
前端代码示例(使用 JavaScript):
document.addEventListener('contextmenu', function (e) {
if (e.target.tagName === 'IMG') {
e.preventDefault();
}
});
解释:
- 使用 CSS 属性
pointer-events: none
来禁用右键菜单。 - 使用 JavaScript 事件监听器来阻止右键菜单弹出。
- 使用 CSS 属性
- 使用水印
在图片上添加水印是另一种保护图片的方式。前端可以通过 Canvas 绘制水印,但通常这在图片生成或处理阶段进行更为合适。
前端代码示例(使用 Canvas):
<canvas id="myCanvas" width="600" height="400"></canvas>
<script>
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.src = 'https://example.com/images/photo.jpg';
img.onload = function() {
ctx.drawImage(img, 0, 0);
ctx.font = '30px Arial';
ctx.fillStyle = 'red';
ctx.fillText('Watermark', 10, 50);
};
</script>
解释:
- 使用 Canvas 绘制图片并添加水印文本。
三、与服务器端防盗链机制配合
- 验证 Referer
在前端代码中,可以通过设置
Referer
头(这通常由浏览器自动处理)来帮助服务器验证请求来源。
前端代码示例(使用 Fetch API):
fetch('https://example.com/images/photo.jpg', {
method: 'GET',
headers: {
'Referer': 'https://yourwebsite.com'
}
}).then(response => response.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
document.getElementById('image').src = url;
});
解释:
- 使用
fetch
请求图片,手动设置Referer
头部(尽管大多数浏览器自动设置)。
- 使用
总结
前端在实现图片防盗链方面,主要通过动态生成 Token、设置图片加载控制(如禁用右键菜单和添加水印)以及与服务器端防盗链机制配合来保护图片资源。虽然真正的防盗链逻辑通常是在服务器端实现,但前端可以采取这些措施来增强保护效果。结合前端和后端的策略,可以有效地防止未经授权的图片访问和盗用。
来源:juejin.cn/post/7410224960298041394
【算法】最小覆盖子串
难度:困难
给你一个字符串 s
、一个字符串 t
。返回 s
中涵盖 t
所有字符的最小子串。如果 s
中不存在涵盖 t
所有字符的子串,则返回空字符串 ""
。
注意:
- 对于
t
中重复字符,我们寻找的子字符串中该字符数量必须不少于t
中该字符数量。 - 如果
s
中存在这样的子串,我们保证它是唯一的答案。
示例 1:
输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。
示例 2:
输入:s = "a", t = "a"
输出:"a"
解释:整个字符串 s 是最小覆盖子串。
示例 3:
输入: s = "a", t = "aa"
输出: ""
解释: t 中两个字符 'a' 均应包含在 s 的子串中,
因此没有符合条件的子字符串,返回空字符串。
提示:
m == s.length
n == t.length
1 <= m, n <= 105
s
和t
由英文字母组成
解题思路:
- 初始化计数器:创建两个哈希表,一个用于存储字符串t中每个字符的出现次数,另一个用于存储当前窗口内每个字符的出现次数。
- 设定窗口:初始化窗口的左边界left和右边界right,以及一些辅助变量如required(表示t中不同字符的数量)、formed(表示当前窗口内满足t字符要求的数量)、windowCounts(表示当前窗口内字符的计数)。
- 扩展窗口:将right指针从0开始向右移动,直到窗口包含了t中的所有字符。在每次移动right指针时,更新窗口内的字符计数和formed变量。
- 收缩窗口:一旦窗口包含了t中的所有字符,开始移动left指针以尝试缩小窗口,同时更新窗口内的字符计数和formed变量。记录下最小覆盖子串的信息。
- 重复步骤3和4:继续移动right指针,重复上述过程,直到right指针到达s的末尾。
- 返回结果:最后返回最小覆盖子串。
JavaScript实现:
/**
* @param {string} s
* @param {string} t
* @return {string}
*/
var minWindow = function(s, t) {
const need = {}, windowCounts = {};
let left = 0, right = 0;
let valid = 0;
let start = 0, length = Infinity;
// Initialize the need counter with characters from t.
for(let c of t){
need[c] ? need[c]++ : need[c] = 1;
}
// Function to check if the current window satisfies the need.
const is_valid = () => Object.keys(need).every(c => (windowCounts[c] || 0) >= need[c]);
while(right < s.length){
const c = s[right];
right++;
// Increment the count in the windowCounts if the character is needed.
if(need[c]){
windowCounts[c] ? windowCounts[c]++ : windowCounts[c] = 1;
if(windowCounts[c] === need[c])
valid++;
}
// If the current window is valid, try to shrink it from the left.
while(valid === Object.keys(need).length){
if(right - left < length){
start = left;
length = right - left;
}
const d = s[left];
left++;
// Decrement the count in the windowCounts if the character is needed.
if(need[d]){
if(windowCounts[d] === need[d])
valid--;
windowCounts[d]--;
}
}
}
return length === Infinity ? '' : s.substring(start, start + length);
};、
来源:juejin.cn/post/7410299130280722470
如何去实现浏览器多窗口互动
前段时间看到了一张神奇的 gif,如下:
感觉特别不可思议,而且是本地运行的环境,于是想自己实现一个但是碍于自己太菜了缺乏对球体、粒子和物理的3D技能,然后去了解了一下如何使一个窗口对另一个窗口的位置做出反应。
于是我做了一个极简的丑陋的版本:
首先,我们看一下在多个客户端之间共享信息的所有方法:
1. 服务器
显然,拥有服务器(使用轮询或Websockets)会简化问题。然而,我们能不能在不使用服务器的情况下去实现呢?
2. 本地存储
本地存储本质上是一个浏览器键值存储,通常用于在浏览器会话之间保持信息的持久性。虽然通常用于存储身份验证令牌或重定向URL,但它可以存储任何可序列化的内容。可以在这里了解更多信息。
最近发现了一些有趣的本地存储API,包括storage
事件,该事件在同一网站的另一个会话更改本地存储时触发。
我们可以通过将每个窗口的状态存储在本地存储中来利用这一点。每当一个窗口改变其状态时,其他窗口将通过存储事件进行更新。
这是我最初的想法,但是后来发现还有其他的方式可以实现
3. 共享 Workers
简单来说,Worker本质上是在另一个线程上运行的第二个脚本。虽然它们没有访问DOM,因为它们存在于HTML文档之外,但它们仍然可以与您的主脚本通信。 它们主要用于通过处理后台作业来卸载主脚本,比如预取信息或处理诸如流式日志和轮询之类的较不重要的任务。
我这有一篇关于web Worker 的文章 没了解过的可以先去看看。
共享的 Workers 是一种特殊类型的 WebWorkers,可以与多个相同脚本的实例通信。
4. 建立 Workers
我使用的是Vite和TypeScript,所以我需要一个worker.ts
文件,并将@types/sharedworker
作为开发依赖进行安装。我们可以使用以下语法在我的主脚本中创建连接:
new SharedWorker(new URL("worker.ts", import.meta.url));
接下来需要考虑的就是以下几方面:
- 确定每个窗口
- 跟踪所有窗口的状态
- 当一个窗口改变其状态时,通知其他窗口重新绘制
type WindowState = {
screenX: number; // window.screenX
screenY: number; // window.screenY
width: number; // window.innerWidth
height: number; // window.innerHeight
};
最关键的信息是window.screenX
和window.screenY
,因为它们可以告诉我们窗口相对于显示器左上角的位置。
将有两种类型的消息:
- 每个窗口在改变状态时,将发布一个
windowStateChanged
消息,带有其新状态。 - 工作者将向所有其他窗口发送更新,以通知它们其中一个已更改。工作者将使用sync消息发送所有窗口的状态。
// worker.ts
let windows: { windowState: WindowState; id: number; port: MessagePort }[] = [];
onconnect = ({ ports }) => {
const port = ports[0];
port.onmessage = function (event: MessageEvent ) {
console.log("We'll do something");
};
};
我们与 SharedWorker
的基本连接将如下所示。我编写了一些基本函数来生成一个ID,并计算当前窗口状态,同时我对我们可以使用的消息类型进行了一些类型定义,称为 WorkerMessage
:
// main.ts
import { WorkerMessage } from "./types";
import {
generateId,
getCurrentWindowState,
} from "./windowState";
const sharedWorker = new SharedWorker(new URL("worker.ts", import.meta.url));
let currentWindow = getCurrentWindowState();
let id = generateId();
一旦启动应用程序,应该立即通知工作者有一个新窗口,因此需要发送一条消息:
// main.ts
sharedWorker.port.postMessage({
action: "windowStateChanged",
payload: {
id,
newWindow: currentWindow,
},
} satisfies WorkerMessage);
然后可以在工作者端监听此消息,并相应地更改 onmessage
。基本上,一旦接收到 windowStateChanged
消息,它要么是一个新窗口,我们将其追加到状态中,要么是一个旧窗口发生了变化。然后,我们应该通知所有窗口状态已经改变:
// worker.ts
port.onmessage = function (event: MessageEvent ) {
const msg = event.data;
switch (msg.action) {
case "windowStateChanged": {
const { id, newWindow } = msg.payload;
const oldWindowIndex = windows.findIndex((w) => w.id === id);
if (oldWindowIndex !== -1) {
// old one changed
windows[oldWindowIndex].windowState = newWindow;
} else {
// new window
windows.push({ id, windowState: newWindow, port });
}
windows.forEach((w) =>
// send sync here
);
}
break;
}
};
要发送同步消息,实际上我需要一个小技巧,因为“port
”属性无法被序列化,所以我将其转换为字符串,然后再解析回来。因为我比较懒,我不会只是将窗口映射到一个更可序列化的数组:
w.port.postMessage({
action: "sync",
payload: { allWindows: JSON.parse(JSON.stringify(windows)) },
} satisfies WorkerMessage);
接下来就是绘制内容了。
5. 使用Canvas 绘图
在每个窗口的中心画一个圆圈,并用一条线连接这些圆圈,将使用 HTML Canvas
进行绘制
const drawCenterCircle = (ctx: CanvasRenderingContext2D, center: Coordinates) => {
const { x, y } = center;
ctx.strokeStyle = "#eeeeee";
ctx.lineWidth = 10;
ctx.beginPath();
ctx.arc(x, y, 100, 0, Math.PI * 2, false);
ctx.stroke();
ctx.closePath();
};
要绘制线条,需要进行一些数学计算(我保证,不是很多 🤓),将另一个窗口中心的相对位置转换为当前窗口上的坐标。 基本上,正在改变基底。使用以下数学公式来实现这个功能。首先,将更改基底,使坐标位于显示器上,并通过当前窗口的 screenX/screenY
进行偏移。
const baseChange = ({
currentWindowOffset,
targetWindowOffset,
targetPosition,
}: {
currentWindowOffset: Coordinates;
targetWindowOffset: Coordinates;
targetPosition: Coordinates;
}) => {
const monitorCoordinate = {
x: targetPosition.x + targetWindowOffset.x,
y: targetPosition.y + targetWindowOffset.y,
};
const currentWindowCoordinate = {
x: monitorCoordinate.x - currentWindowOffset.x,
y: monitorCoordinate.y - currentWindowOffset.y,
};
return currentWindowCoordinate;
};
现在有了相同相对坐标系上的两个点,可以画线了!
const drawConnectingLine = ({
ctx,
hostWindow,
targetWindow,
}: {
ctx: CanvasRenderingContext2D;
hostWindow: WindowState;
targetWindow: WindowState;
}) => {
ctx.strokeStyle = "#ff0000";
ctx.lineCap = "round";
const currentWindowOffset: Coordinates = {
x: hostWindow.screenX,
y: hostWindow.screenY,
};
const targetWindowOffset: Coordinates = {
x: targetWindow.screenX,
y: targetWindow.screenY,
};
const origin = getWindowCenter(hostWindow);
const target = getWindowCenter(targetWindow);
const targetWithBaseChange = baseChange({
currentWindowOffset,
targetWindowOffset,
targetPosition: target,
});
ctx.strokeStyle = "#ff0000";
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(origin.x, origin.y);
ctx.lineTo(targetWithBaseChange.x, targetWithBaseChange.y);
ctx.stroke();
ctx.closePath();
};
现在,只需要对状态变化做出反应即可。
// main.ts
sharedWorker.port.onmessage = (event: MessageEvent ) => {
const msg = event.data;
switch (msg.action) {
case "sync": {
const windows = msg.payload.allWindows;
ctx.reset();
drawMainCircle(ctx, center);
windows
.forEach(({ windowState: targetWindow }) => {
drawConnectingLine({
ctx,
hostWindow: currentWindow,
targetWindow,
});
});
}
}
};
最后一步,只需要定期检查窗口是否发生了变化,如果是,则发送一条消息。
setInterval(() => {setInterval(() => {
const newWindow = getCurrentWindowState();
if (
didWindowChange({
newWindow,
oldWindow: currentWindow,
})
) {
sharedWorker.port.postMessage({
action: "windowStateChanged",
payload: {
id,
newWindow,
},
} satisfies WorkerMessage);
currentWindow = newWindow;
}
}, 100);
来源:juejin.cn/post/7329753721018269711
改进菜单栏动态展示样式,我被评上优秀开发!
精彩新文章:拿客户电脑,半小时完成轮播组件开发!被公司奖励500
背景
我们公司的导航菜单是动态可配置的,有的页面菜单数量比较多,有的比较少。
由于大多页面菜单都是比较少的,因此当菜单非常多时, 我们采用了朴实无华的滚动条:当横向超出的时候,滚动展示。
但很快,客户就打回来了:说我们的样式太丑,居然用滚动条!还质问我们产品这合理吗?产品斩钉截铁的告诉客户,我让开发去优化...
于是,领导让我们想解决方案。(我真谢谢产品!
)
很快,我想到一个方案(从其他地方看到的交互),我告诉领导:
我们可以做成动态菜单栏,如果展示不下了,出现一个更多按钮,多余的菜单都放到更多里面去:
领导说这个想法不错啊,那就你来实现吧!
好家伙,我只是随便说说,没想到,自己给自己挖了个大坑啊!
不过,我最后也是顺利的完成了这个效果的开发,还被评上了本季度优秀开发!分享一下自己的实现方案吧!
技术方案
基础组件样式开发
既然要开发这个效果,干脆就封装一个通用组件AdaptiveMenuBar.vue吧。我们先写一下基本样式,如图,灰色区域就是我们的组件内容,也就是我们菜单栏动态展示
的区域。
AdaptiveMenuBar.vue
<template>
<div class="adaptive-menu-bar">
</div>
</template>
<style lang="less" scoped>
.adaptive-menu-bar {
width: 100%;
height: 48px;
background: gainsboro;
display: flex;
position: relative;
overflow: hidden;
}
</style>
我们写点假数据
<template>
<div class="adaptive-menu-bar">
<div class="origin-menu-item-wrap">
<div v-for="(item, index) in menuOriginData" :key="index" class="menu-item">
{{ item.name }}
</div>
</div>
<div>更多</div>
</div>
</template>
<script setup>
const menuOriginData = [
{ name: '哆啦a梦', id: 1 },
{ name: '宇智波佐助', id: 1 },
{ name: '香蕉之王奥德彪', id: 1 },
{ name: '漩涡鸣人', id: 1 },
{ name: '雏田', id: 1 },
{ name: '大雄', id: 1 },
{ name: '源静香', id: 1 },
{ name: '骨川小夫', id: 1 },
{ name: '超级马里奥', id: 1 },
{ name: '自来也', id: 1 },
{ name: '孙悟空', id: 1 },
{ name: '卡卡罗特', id: 1 },
{ name: '万年老二贝吉塔', id: 1 },
{ name: '小泽玛丽', id: 1 }
];
</script>
<style lang="less" scoped>
.adaptive-menu-bar {
width: 100%;
height: 48px;
background: gainsboro;
display: flex;
position: relative;
overflow: hidden;
.origin-menu-item-wrap{
width: 100%;
display: flex;
}
}
</style>
如图,由于菜单数量比较多,一部分已经隐藏在origin-menu-item-wrap
这个父元素里面了。
实现思路
那我们要如何才能让多余的菜单出现在【更多】按钮里呢?原理很简单,我们只要计算出哪个菜单超出展示区域即可。假设如图所示,第12个菜单被截断了,那我们前11个菜单就可以展示在显示区域,剩余的菜单就展示在【更多】按钮里。
更多按钮的展示逻辑
更多按钮只有在展示区域空间不够的时候出现,也就是origin-menu-item-wrap元素的滚动区域宽度scrollWidth 大于其宽度clientWidth的时候。
用代码展示大致如下
<template>
<div ref="menuBarRef" class="origin-menu-item-wrap">
<div v-for="(item, index) in menuOriginData" :key="index" class="menu-item">
<m-button type="default" size="small">{{ item.name }}</m-button>
</div>
</div>
</template>
<script setup>
const menuOriginData = [
{ name: '哆啦a梦', id: 1 },
{ name: '宇智波佐助', id: 1 },
{ name: '香蕉之王奥德彪', id: 1 },
{ name: '漩涡鸣人', id: 1 },
{ name: '雏田', id: 1 },
{ name: '大雄', id: 1 },
{ name: '源静香', id: 1 },
{ name: '骨川小夫', id: 1 },
{ name: '超级马里奥', id: 1 },
{ name: '自来也', id: 1 },
{ name: '孙悟空', id: 1 },
{ name: '卡卡罗特', id: 1 },
{ name: '万年老二贝吉塔', id: 1 },
{ name: '小泽玛丽', id: 1 }
];
// 是否展示更多按钮
const showMoreBtn = ref(false);
onMounted(() => {
const menuWrapDom = menuBarRef.value;
if (menuWrapDom.scrollWidth > menuWrapDom.clientWidth) {
showMoreBtn.value = true;
}
});
</script>
截断位置的计算
要计算截断位置,我们需要先渲染好菜单。
然后开始对menu-item元素宽度进行加和,当相加的宽度大于菜单展示区域的宽度clientWidth时,计算终止,此时的menu-item元素就是我们要截断的位置。
菜单截断的部分,我们此时放到更多里面展示就可以了。
大致代码如下:
<template>
<div ref="menuBarRef" class="origin-menu-item-wrap">
<div v-for="(item, index) in menuOriginData" :key="index" class="menu-item">
<m-button type="default" size="small">{{ item.name }}</m-button>
</div>
</div>
</template>
<script setup>
const menuOriginData = [
{ name: '哆啦a梦', id: 1 },
{ name: '宇智波佐助', id: 1 },
{ name: '香蕉之王奥德彪', id: 1 },
{ name: '漩涡鸣人', id: 1 },
{ name: '雏田', id: 1 },
{ name: '大雄', id: 1 },
{ name: '源静香', id: 1 },
{ name: '骨川小夫', id: 1 },
{ name: '超级马里奥', id: 1 },
{ name: '自来也', id: 1 },
{ name: '孙悟空', id: 1 },
{ name: '卡卡罗特', id: 1 },
{ name: '万年老二贝吉塔', id: 1 },
{ name: '小泽玛丽', id: 1 }
];
// 是否展示更多按钮
const showMoreBtn = ref(false);
onMounted(() => {
const menuWrapDom = menuBarRef.value;
if (menuWrapDom.scrollWidth > menuWrapDom.clientWidth) {
showMoreBtn.value = true;
}
// 计算截断菜单的索引位置
let sliceIndex = 0
// 获取menu-item元素dom的集合
const menuItemNodeList = menuWrapDom.querySelectorAll('.menu-item');
// 将NodeList转换成数组
const nodeArray = Array.prototype.slice.call(menuItemNodeList);
let addWidth = 0;
for (let i = 0; i < nodeArray.length; i++) {
const node = nodeArray[i];
// clientWidth不包含菜单的margin边距,因此我们手动补上12px
addWidth += node.clientWidth + 12;
// 76是更多按钮的宽度,我们也要计算进去
if (addWidth + 76 > menuWrapDom.clientWidth) {
sliceIndex.value = i;
break;
} else {
sliceIndex.value = 0;
}
}
});
</script>
样式重整
当被截断的元素计算完毕时,我们需要重新进行样式渲染,但是注意,我们原先渲染的菜单列不能注销,因为每次浏览器尺寸变化时,我们都是基于原先渲染的菜单列进行计算的。
所以,我们实际需要渲染两个菜单列:一个原始的,一个样式重新排布后的。
如上图,黄色就是原始的菜单栏,用于计算重新排布的菜单栏,只不过,我们永远不在页面上展示给用户看!
<template>
<div class="adaptive-menu-bar">
<!-- 原始渲染的菜单栏 -->
<div ref="menuBarRef" class="origin-menu-item-wrap">
<div v-for="(item, index) in menuOriginData" :key="index" class="menu-item">
<m-button type="default" size="small">{{ item.name }}</m-button>
</div>
</div>
<!-- 计算优化显示的菜单栏 -->
<div v-for="(item, index) in menuList" :key="index" class="menu-item">
<m-button type="default" size="small">{{ item.name }}</m-button>
</div>
<div >更多</div>
</div>
</template>
代码实现
基础功能完善
为了我们的菜单栏能动态的响应变化,我们需要再每次resize事件触发时,都重新计算样式
const menuOriginData = [
{ name: '哆啦a梦', id: 1 },
{ name: '宇智波佐助', id: 1 },
{ name: '香蕉之王奥德彪', id: 1 },
{ name: '漩涡鸣人', id: 1 },
{ name: '雏田', id: 1 },
{ name: '大雄', id: 1 },
{ name: '源静香', id: 1 },
{ name: '骨川小夫', id: 1 },
{ name: '超级马里奥', id: 1 },
{ name: '自来也', id: 1 },
{ name: '孙悟空', id: 1 },
{ name: '卡卡罗特', id: 1 },
{ name: '万年老二贝吉塔', id: 1 },
{ name: '小泽玛丽', id: 1 }
];
// 是否展示更多按钮
const showMoreBtn = ref(false);
const setHeaderStyle = () => {
// ....
}
window.addEventListener('resize', () => setHeaderStyle());
onMounted(() => {
setHeaderStyle();
});
</script>
完整代码
完整代码剥离了一些第三方UI组件,便于大家理解。
<template>
<div class="adaptive-menu-bar">
<!-- 原始渲染的菜单栏 -->
<div ref="menuBarRef" class="origin-menu-item-wrap">
<div v-for="(item, index) in menuOriginData" :key="index" class="menu-item">
{{ item.name }}
</div>
</div>
<!-- 计算优化显示的菜单栏 -->
<div v-for="(item, index) in menuList" :key="index" class="menu-item">
{{ item.name }}
</div>
<!-- 更多按钮 -->
<div v-if="showMoreBtn" class="dropdown-wrap">
<span>更多</span>
<!-- 更多里面的菜单 -->
<div class="menu-item-wrap">
<div v-for="(item, index) in menuOriginData.slice(menuList.length)" :key="index">{{ item.name }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { IconMeriComponentArrowDown } from 'meri-icon';
const menuBarRef = ref();
const open = ref(false);
const menuOriginData = [
{ name: '哆啦a梦', id: 1 },
{ name: '宇智波佐助', id: 1 },
{ name: '香蕉之王奥德彪', id: 1 },
{ name: '漩涡鸣人', id: 1 },
{ name: '雏田', id: 1 },
{ name: '大雄', id: 1 },
{ name: '源静香', id: 1 },
{ name: '骨川小夫', id: 1 },
{ name: '超级马里奥', id: 1 },
{ name: '自来也', id: 1 },
{ name: '孙悟空', id: 1 },
{ name: '卡卡罗特', id: 1 },
{ name: '万年老二贝吉塔', id: 1 },
{ name: '小泽玛丽', id: 1 }
];
const menuList = ref(menuOriginData);
// 是否展示更多按钮
const showMoreBtn = ref(false);
const setHeaderStyle = () => {
const menuWrapDom = menuBarRef.value;
if (!menuWrapDom) return;
if (menuWrapDom.scrollWidth > menuWrapDom.clientWidth) {
showMoreBtn.value = true;
} else {
showMoreBtn.value = false;
}
const menuItemNodeList = menuWrapDom.querySelectorAll('.menu-item');
if (menuItemNodeList) {
let addWidth = 0,
sliceIndex = 0;
// 将NodeList转换成数组
const nodeArray = Array.prototype.slice.call(menuItemNodeList);
for (let i = 0; i < nodeArray.length; i++) {
const node = nodeArray[i];
addWidth += node.clientWidth + 12;
if (addWidth + 64 + 12 > menuWrapDom.clientWidth) {
sliceIndex = i;
break;
} else {
sliceIndex = 0;
}
}
if (sliceIndex > 0) {
menuList.value = menuOriginData.slice(0, sliceIndex);
} else {
menuList.value = menuOriginData;
}
}
};
window.addEventListener('resize', () => setHeaderStyle());
onMounted(() => {
setHeaderStyle();
});
</script>
<style lang="less" scoped>
.adaptive-menu-bar {
width: 100%;
height: 48px;
background: gainsboro;
display: flex;
position: relative;
align-items: center;
overflow: hidden;
.origin-menu-item-wrap {
width: 100%;
display: flex;
position: absolute;
top: 49px;
display: flex;
align-items: center;
left: 0;
right: 0;
bottom: 0;
height: 48px;
z-index: 9;
}
.menu-item {
margin-left: 12px;
}
.dropdown-wrap {
width: 64px;
display: flex;
align-items: center;
cursor: pointer;
justify-content: center;
height: 28px;
background: #fff;
border-radius: 4px;
overflow: hidden;
border: 1px solid #c4c9cf;
background: #fff;
margin-left: 12px;
.icon {
width: 16px;
height: 16px;
margin-left: 4px;
}
}
}
</style>
代码效果
可以看到,非常丝滑!
来源:juejin.cn/post/7384256110280802356
Cesium为军工助力!动态绘制各类交互式态势图
态势图(Situation Map)是一种用于表示空间环境中动态或静态信息的地图,它能够展示事件、资源、威胁和其他关键因素的地理位置及其变化情况
前言
什么是态势图
态势图(Situation Map)
是一种用于表示空间环境
中动态或静态信息的地图,它能够展示事件
、资源
、威胁
和其他关键因素
的地理位置及其变化情况。
通过可视化的方式,态势图帮助决策者
在复杂环境中迅速获取关键信息,从而做出及时而准确的决策。
随着地理信息系统(GIS)的不断发展,态势图在军事
、应急管理
和地理规划
等领域中扮演着越来越重要的角色。
军工领域
在军工领域,态势图是军事指挥
与控制系统
中的核心组件。
它们能够实时展示战场上的动态信息
,如部队的部署位置、敌军动向、武器系统状态等。这种可视化工具对于战术指挥、作战计划制定和战场态势感知至关重要。
应急管理
在应急管理领域,态势图能够帮助管理者协调资源
和人员应对自然灾害、山林火灾、事故或突发事件。通过态势图,可以清晰地看到灾害影响范围
、救援力量分布
、资源需求
,逃生路线
等关键信息,从而实现有效的应急响应和资源调配。
地理规划
在地理规划中,态势图用于展示和分析区域开发
、土地利用
、交通网络
等方面的信息。能帮助规划者更清晰的理解地理空间关系、评估环境影响,并做出科学的规划决策。
Cesium中绘制态势图
OK,接下来我们主要介绍一下在Cesium中如何绘制态势图,主要包括各种箭头类型的绘制,如直线箭头
、攻击箭头
、钳击箭头
等。
源码地址在文末。
箭头绘制的核心算法
algorithm.js
是实现复杂箭头绘制的核心脚本。
这里定义了多种箭头类型的绘制算法,如双箭头(doubleArrow
)、三箭头(threeArrow
)以及带尾攻击箭头(tailedAttackArrow
)。
这些算法通过接收用户点击的多个点,并计算出箭头的控制点和多边形点来实现箭头形状的生成。
以下是doubleArrow
函数的部分代码与解析:
xp.algorithm.doubleArrow = function (inputPoint) {
// 初始化结果对象
var result = {
controlPoint: null,
polygonalPoint: null
};
// 根据输入点数量决定不同的箭头形状
var t = inputPoint.length;
if (!(2 > t)) {
if (2 == t) return inputPoint;
// 获取关键点
var o = this.points[0],
e = this.points[1],
r = this.points[2];
// 计算连接点和临时点位置
3 == t ? this.tempPoint4 = xp.algorithm.getTempPoint4(o, e, r) : this.tempPoint4 = this.points[3];
3 == t || 4 == t ? this.connPoint = P.PlotUtils.mid(o, e) : this.connPoint = this.points[4];
// 根据点的顺序计算箭头的左右侧点位
P.PlotUtils.isClockWise(o, e, r)
? (n = xp.algorithm.getArrowPoints(o, this.connPoint, this.tempPoint4, !1), g = xp.algorithm.getArrowPoints(this.connPoint, e, r, !0))
: (n = xp.algorithm.getArrowPoints(e, this.connPoint, r, !1), g = xp.algorithm.getArrowPoints(this.connPoint, o, this.tempPoint4, !0));
// 生成最终的箭头形状并返回
result.controlPoint = [o, e, r, this.tempPoint4, this.connPoint];
result.polygonalPoint = Cesium.Cartesian3.fromDegreesArray(xp.algorithm.array2Dto1D(f));
}
return result;
};
该函数首先根据输入点的数量确定是否继续进行箭头的绘制,接着计算关键点的位置,并通过getArrowPoints
函数计算出箭头形状的多个控制点,最终生成一个包含箭头形状顶点的数组。
基于Cesium的箭头实体管理
arrowClass.js
定义了具体的箭头类(如StraightArrow
)和其行为管理。
通过结合Cesium的ScreenSpaceEventHandler
事件处理机制,开发者可以方便地在地图上绘制、修改和删除箭头实体。
以下是StraightArrow
类的部分代码与解析:
StraightArrow.prototype.startDraw = function () {
var $this = this;
this.state = 1;
// 单击事件,获取点击位置并创建箭头起点
this.handler.setInputAction(function (evt) {
var cartesian = getCatesian3FromPX(evt.position, $this.viewer);
if (!cartesian) return;
// 处理点位并开始绘制箭头
if ($this.positions.length == 0) {
$this.firstPoint = $this.creatPoint(cartesian);
$this.floatPoint = $this.creatPoint(cartesian);
$this.positions.push(cartesian);
}
if ($this.positions.length == 3) {
$this.firstPoint.show = false;
$this.floatPoint.show = false;
$this.handler.destroy();
$this.arrowEntity.objId = $this.objId;
$this.state = -1;
}
$this.positions.push(cartesian.clone());
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
// 鼠标移动事件,实时更新箭头形状
this.handler.setInputAction(function (evt) {
if ($this.positions.length < 1) return;
var cartesian = getCatesian3FromPX(evt.endPosition, $this.viewer);
if (!cartesian) return;
$this.floatPoint.position.setValue(cartesian);
if ($this.positions.length >= 2) {
if (!Cesium.defined($this.arrowEntity)) {
$this.positions.push(cartesian);
$this.arrowEntity = $this.showArrowOnMap($this.positions);
} else {
$this.positions.pop();
$this.positions.push(cartesian);
}
}
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
};
在startDraw
函数中,通过设置单击和鼠标移动事件,开发者可以实时捕获用户的操作,并根据点击位置动态绘制箭头。
最终的箭头形状会随着鼠标的移动而更新,当点击完成时箭头形状被确定。
工具类与辅助函数
plotUtil.js
提供了一些用于计算几何关系的实用工具函数。
例如,distance
函数计算两个点之间的距离,而getThirdPoint
函数根据给定的两个点和角度,计算出第三个点的位置。 这些工具函数被广泛用于箭头的绘制逻辑中,以确保箭头的形状符合预期。
以下是distance
和getThirdPoint
函数的代码示例:
P.PlotUtils.distance = function (t, o) {
return Math.sqrt(Math.pow(t[0] - o[0], 2) + Math.pow(t[1] - o[1], 2));
};
P.PlotUtils.getThirdPoint = function (t, o, e, r, n) {
var g = P.PlotUtils.getAzimuth(t, o),
i = n ? g + e : g - e,
s = r * Math.cos(i),
a = r * Math.sin(i);
return [o[0] + s, o[1] + a];
};
这些函数都是为复杂箭头形状的计算提供了基础,确保在地图上绘制的箭头具有精确的几何形态。
总结
以上主要介绍了在Cesium中实现态势图的一些关键代码以及解释,更多细节请参考项目源码,如果有帮助也请给一个免费的star
。
【项目开源地址】:github.com/tingyuxuan2…
来源:juejin.cn/post/7409181068597919781
什么是混入,如何正确地使用Mixin
在 Vue.js 中,mixins
是一种代码复用机制,允许我们将多个组件共享的逻辑提取到一个独立的对象中,从而提高代码的可维护性和重用性。下面将详细介绍 mixin
的概念,并通过示例代码来说明它的用法。
什么是 Mixin?
Mixin
是一种在 Vue 组件中复用代码的方式。我们可以将一个对象(即 mixin 对象)中的数据、方法和生命周期钩子等混入到 Vue 组件中。这样,多个组件就可以共享同一份逻辑代码。
Mixin 的基本用法
1. 定义 Mixin
首先,我们定义一个 mixin 对象,其中包含数据、方法、计算属性等。比如,我们可以创建一个 commonMixin.js
文件来定义一个 mixin:
// commonMixin.js
export default {
data() {
return {
message: 'Hello from mixin!'
};
},
methods: {
greet() {
console.log(this.message);
}
},
created() {
console.log('Mixin created hook called.');
}
};
2. 使用 Mixin
在 Vue 组件中,我们可以通过 mixins
选项来引入上述定义的 mixin。例如,我们可以在 HelloWorld.vue
组件中使用这个 mixin:
<template>
<div>
<p>{{ message }}</p>
<button @click="greet">Greet</button>
</div>
</template>
<script>
// 导入 mixin
import commonMixin from './commonMixin';
export default {
name: 'HelloWorld',
mixins: [commonMixin], // 使用 mixin
mounted() {
console.log('Component mounted hook called.');
}
};
</script>
在上面的示例中,HelloWorld
组件通过 mixins
选项引入了 commonMixin
。这意味着 HelloWorld
组件将拥有 commonMixin
中定义的数据、方法和生命周期钩子。
3. Mixin 的冲突处理
如果组件和 mixin 中都定义了相同的选项,Vue 将遵循一定的优先级规则来处理这些冲突:
- 数据:如果组件和 mixin 中有相同的
data
字段,组件中的data
会覆盖 mixin 中的data
。 - 方法:如果组件和 mixin 中有同名的方法,组件中的方法会覆盖 mixin 中的方法。
- 生命周期钩子:如果组件和 mixin 中有相同的生命周期钩子(如
created
),它们都会被调用,且 mixin 中的钩子会在组件中的钩子之前调用。
// commonMixin.js
export default {
data() {
return {
message: 'Hello from mixin!'
};
},
methods: {
greet() {
console.log('Mixin greet');
}
},
created() {
console.log('Mixin created hook called.');
}
};
// HelloWorld.vue
<template>
<div>
<p>{{ message }}</p>
<button @click="greet">Greet</button>
</div>
</template>
<script>
import commonMixin from './commonMixin';
export default {
name: 'HelloWorld',
mixins: [commonMixin],
data() {
return {
message: 'Hello from component!'
};
},
methods: {
greet() {
console.log('Component greet');
}
},
created() {
console.log('Component created hook called.');
}
};
</script>
在这个例子中,HelloWorld
组件中 message
的值会覆盖 mixin 中的值,greet
方法中的实现会覆盖 mixin 中的方法,created
钩子的调用顺序是 mixin 先调用,然后组件中的 created
钩子调用。
4. 使用 Mixin 的注意事项
- 命名冲突:为了避免命名冲突,建议使用明确且独特的命名方式。
- 复杂性:过度使用 mixin 可能会导致代码难以跟踪和调试。可以考虑使用 Vue 的组合式 API 来替代 mixin,以提高代码的可读性和可维护性。
在 Vue.js 开发中,mixin
主要用于以下场景,帮助我们实现代码复用和逻辑共享:
1. 共享功能和逻辑
当多个组件需要使用相同的功能或逻辑时,mixin
是一个有效的解决方案。通过将共享的逻辑提取到一个 mixin 中,我们可以避免重复代码。例如,多个组件可能都需要处理表单验证或数据格式化,这时可以将这些功能封装到一个 mixin 中:
// validationMixin.js
export default {
methods: {
validateEmail(email) {
const re = /^[^\s@]+@[^\s@]+.[^\s@]+$/;
return re.test(email);
}
}
};
// UserForm.vue
<template>
<form @submit.prevent="handleSubmit">
<input v-model="email" placeholder="Enter your email" />
<button type="submit">Submit</button>
</form>
</template>
<script>
import validationMixin from './validationMixin';
export default {
mixins: [validationMixin],
data() {
return {
email: ''
};
},
methods: {
handleSubmit() {
if (this.validateEmail(this.email)) {
alert('Email is valid!');
} else {
alert('Email is invalid!');
}
}
}
};
</script>
2. 封装重复的生命周期钩子
有时候,多个组件可能需要在相同的生命周期阶段执行某些操作。例如,所有组件都需要在 created
钩子中初始化数据或进行 API 请求。可以将这些操作封装到 mixin 中:
// dataFetchMixin.js
export default {
created() {
this.fetchData();
},
methods: {
async fetchData() {
// 假设有一个 API 请求
try {
const response = await fetch('https://api.example.com/data');
this.data = await response.json();
} catch (error) {
console.error('Failed to fetch data:', error);
}
}
},
data() {
return {
data: null
};
}
};
// DataComponent.vue
<template>
<div>
<pre>{{ data }}</pre>
</div>
</template>
<script>
import dataFetchMixin from './dataFetchMixin';
export default {
mixins: [dataFetchMixin]
};
</script>
3. 跨组件通信
在 Vue 2 中,mixin
可以用来管理跨组件通信。例如,多个子组件可以通过 mixin 共享父组件传递的数据或方法:
// communicationMixin.js
export default {
methods: {
emitEvent(message) {
this.$emit('custom-event', message);
}
}
};
// ParentComponent.vue
<template>
<div>
<ChildComponent @custom-event="handleEvent" />
</div>
</template>
<script>
import communicationMixin from './communicationMixin';
import ChildComponent from './ChildComponent.vue';
export default {
components: { ChildComponent },
mixins: [communicationMixin],
methods: {
handleEvent(message) {
console.log('Received message:', message);
}
}
};
</script>
// ChildComponent.vue
<template>
<button @click="sendMessage">Send Message</button>
</template>
<script>
import communicationMixin from './communicationMixin';
export default {
mixins: [communicationMixin],
methods: {
sendMessage() {
this.emitEvent('Hello from ChildComponent');
}
}
};
</script>
4. 封装组件的默认行为
对于有相似默认行为的多个组件,可以将这些默认行为封装到 mixin 中。例如,处理表单提交、数据清理等:
// formMixin.js
export default {
methods: {
handleSubmit() {
console.log('Form submitted');
// 处理表单提交逻辑
},
clearForm() {
this.$data = this.$options.data();
}
}
};
// LoginForm.vue
<template>
<form @submit.prevent="handleSubmit">
<!-- 表单内容 -->
<button type="submit">Login</button>
</form>
</template>
<script>
import formMixin from './formMixin';
export default {
mixins: [formMixin]
};
</script>
结论
Vue 的 mixin
机制提供了一种简单有效的方式来复用组件逻辑,通过将逻辑封装到独立的 mixin 对象中,可以在多个组件中共享相同的代码。理解和正确使用 mixin 可以帮助我们构建更清晰、可维护的代码结构。不过,随着 Vue 3 的引入,组合式 API 提供了更强大的功能来替代 mixin,在新的开发中,可以根据需要选择合适的方案。
来源:juejin.cn/post/7409110408991768587
uniapp 授权登录、页面跳转及弹窗问题
uniapp 弹框
主要介绍了 uniapp 弹框使用的一些问题,例如 uni.showModal 中的 content 换行显示实现、uni.showToast()字数超过 7 个显示问题、以及 uni-popup 自定义弹层处理
1. uni.showModal 中的 content 换行显示实现
// 注意:\r\n在微信模拟器中无效,真机才行
const content =
"学校名:光谷一小\r\n" +
"班级名:501\r\n" +
"教师名:张哈哈\r\n"
uni.showModal({
title: "确认操作吗?",
content: content,
success: (res) => {
if (res.confirm) {
} else if (res.cancel) {
}
},
});
2. uniapp 解决 showToast 字数超过 7 个显示问题
使用 uni-app 开发小程序,不管是使用微信小程序的 wx.showToast()
API 或 uni-app 的 uni.showToast()
API 显示消息提示框,显示图标 title 文本最多显示 7 个汉字长度
,在不显示图标的情况下,大于两行不显示。
解决方案一:如果要显示超过两行的文本,使用 uview-ui
框架的 Toast 消息提示组件。
// 先在html中引入组件
<u-toast ref="uToast" />;
// 然后调用
this.$refs.uToast.show({
title: "请学生绑定图书后再布置任务!",
duration: 1500,
});
解决方案二:不显示图标,就可以显示两行了
uni.showToast({
title: "请学生绑定图书后再布置任务!",
icon: "none",
duration: 1500,
});
3. uniapp 自定义弹框 uni-popup
复杂弹框,需要自定义内容的话,只能自己写了,可以使用 uni-popup 弹出层进行实现
<template>
<uni-popup ref="popup" class="lm-popup">
<view class="lm-popup-content">
<uni-icons
class="close"
type="closeempty"
size="24"
color="#ccc"
@click="hide"
></uni-icons>
<button class="btn confirm" @click="handleAuth">微信授权登录</button>
<button class="btn cancel" @click="handleLogin">手机验证码登录</button>
</view>
</uni-popup>
</template>
<script>
export default {
name: "login-popup",
methods: {
show() {
this.$refs.popup.open("center");
},
hide() {
this.$refs.popup.close();
},
},
};
</script>
uniapp 授权登录
主要介绍了uniapp授权的几种方式,分别为临时登录授权、手机号授权、用户信息授权
1. 微信登录授权
微信小程序的登录逻辑发生了变化,要求开发者使用静默登录
,即在用户无感知的情况下进行登录操作,不需要弹出授权窗口
了。
如下所示,获取微信的临时登录凭证 code,不会弹出授权窗口了。
uni.getProvider({
// 类型为oauth,用于获取第三方登录提供商
service: "oauth",
success: (res) => {
// 输出支持的第三方登录提供商列表
if (~res.provider.indexOf("weixin")) {
// 发起登录请求,获取临时登录凭证 code
uni.login({
// 登录提供商,如微信
provider: "weixin",
success: (loginRes) => {
// 获取用户登录凭证
this.handleLogin(loginRes.code);
},
});
}
},
});
2. 微信手机号授权
对于一般的用户信息,如头像、昵称等,被视为非敏感信息,以静默登录的方式进行获取。而用户的手机号等敏感信息,是需要授权的,可以通过 open-type="getPhoneNumber"
属性来触发获取手机号码的授权弹框。
getPhoneNumber
:获取用户手机号,可以从@getphonenumber
回调中获取到用户信息,该接口一直会弹出授权弹框,具体可查看官网:uniapp.dcloud.net.cn/component/b…
<button type="default" open-type="getPhoneNumber" @getphonenumber="getPhoneNumber">获取手机号</button>
// 打开获取用户手机号的授权窗口
getPhoneNumber(e) {
console.log('getPhoneNumber', e.detail)
console.log(e.detail.encryptedData); // 获取加密数据
console.log(e.detail.iv); // 获取加密算法的初始向量
const { detail:{ code, encryptedData, iv, errMsg } } = e;
if(errMsg === 'getPhoneNumber:ok') {
// 获取成功,做对应操作
}
}
2.1 拦截默认弹框
如下图所示:一般情况下,需要先勾选隐私协议,才能弹窗。
但是@getphonenumber事件弹窗无法拦截的,就算事件中写了判断,还是会先弹窗的,没找到拦截的方法。
目前的解决方法,用了两个按钮来判断实现
<button v-if="!agree" class="login-btn" hover-class='zn-btn-green-hover' @click="handleSubmit('auth')">手机号验证登录</button>
<button v-else class="login-btn" hover-class='zn-btn-green-hover' open-type="getPhoneNumber" @getphonenumber="(e) => handleSubmit('auth', e)">手机号验证登录</button>
handleSubmit(type, e) {
if (!this.agree) {
uni.showToast({ title: '请勾选用户服务协议', icon: 'none' });
return;
}
}
3. 微信用户信息(头像、昵称)授权
可以使用uni.getUserProfile获取用户信息,如头像、昵称
该 API 对于低版本(基础库 2.10.4-2.27.0 版本
),每次触发 uni.getUserProfile
才会弹出授权窗口;
我开发时,最新的基础库为 3.3.1,不会弹出授权窗口,直接获取到值了,也是静默授权状态。
<button type="default" size="mini" @click="getUserInfo">获取用户信息</button>
getUserInfo(e) {
// 获取用户信息
uni.getUserProfile({
desc: '获取你的昵称、头像、地区及性别',
success: res => {
console.log('获取你的昵称、头像',res);
},
fail: err => {
console.log("拒绝了", err);
}
});
}
uniapp 跳转
主要介绍了 uniapp 小程序跳转的三种方式,分别为内部页面跳转、外部链接跳转、其他小程序跳转。
1. 内部页面
内部页面的跳转,可以通过如下方式:navigateTo、reLaunch、switchTab
// 保留当前页面,跳转到应用内的某个页面
uni.navigateTo({ url: "/pages/home/home" });
// 关闭所有页面,打开到应用内的某个页面
uni.reLaunch({ url: "/pages/home/home" });
// 跳转到 tabBar 页面,并关闭其他所有非 tabBar 页面
uni.switchTab({ url: "/pages/home/home" });
2. 外部链接
通过 webview
打开外部网站链接,web-view
是一个 web 浏览器组件,可以用来承载网页的容器。
// 1. 新建pages/webview/webview.vue页面
<template>
<web-view :src="url"></web-view>
</template>
<script>
export default {
data() {
return {
url: ''
}
},
onLoad(e) {
// 使用web-view标签进行跳转
this.url = decodeURIComponent(e.url)
}
}
</script>
// 2. 外链跳转使用
uni.navigateTo({url: "https://www.taobao.com"})
3. 小程序 appId
通过navigateToMiniProgram
可以打开其他小程序
// 打开其他小程序
uni.navigateToMiniProgram({
appId: "AppId", // 其他小程序的AppId
path: "pages/index/index", // 其他小程序的首页路径
extraData: {}, // 传递给其他小程序的数据
envVersion: "release", // 其他小程序的版本(develop/trial/release)
success(res) {
// 打开其他小程序成功的回调函数
},
fail(err) {
// 打开其他小程序失败的回调函数
},
});
来源:juejin.cn/post/7331717626059817023
纯css实现无限循环滚动logo墙
一、需求
在许多网站的合作伙伴一栏,常常会看到一排排无限地循环滚动的logo墙。
不久前,接到一个类似的需求。需求如下:
1、无限循环滚动;
2、鼠标hover后,暂停滚动,鼠标离开后,继续滚动;
3、支持从左往右和从右往左滚动;
4、滚动速度需要可配置。
简单动画,我们先尝试只使用css实现。
二、实现
1、marquee标签
说到无限循环滚动,很久以前marquee标签可以实现类似的功能,它可以无限循环滚动,并且可以控制方向和速度。但是该标签在HTML5中已经被弃用,虽然还可以正常使用,但w3c不再推荐使用该特性。作为一个标签,只需要负责布局,而不应该有行为特性。
了解marquee标签:marquee标签
2、css3动画
说到无限循环滚动我们会想到使用css3动画。
把animation-iteration-count设置为infinite,代表无限循环播放动画。
为了使动画运动平滑,我们把animation-timing-function设置为linear,代表动画匀速运动。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
padding-top: 200px;
}
@keyframes scrolling {
to {
transform: translateX(100vw);
}
}
.wall-item {
height: 90px;
width: 160px;
background-image: linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%);
animation: scrolling 4s linear infinite;
}
</style>
</head>
<body>
<div class="wall-item"></div>
</body>
</html>
我们上面实现了一个元素的滚动,在多个元素的时候,我们只需为每个元素设置不同的动画延迟时间(animation-delay),让每一项错落开来,就可以实现我们想要的效果了。
至于鼠标hover后暂停动画,我们只需在滚动元素hover时把animation-play-state设置为暂停即可。
有了以上思路,我很快就可以写出一个纯css实现logo墙无限循环滚动的效果。
完整示例代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--wall-item-height: 90px;
--wall-item-width: 160px;
--wall-item-number: 9;
--duration: 16s;
}
body {
padding-top: 300px;
}
@keyframes scrolling {
to {
transform: translateX(calc(var(--wall-item-width) * -1));
}
}
.wall {
margin: 30px auto;
height: var(--wall-item-height);
width: 80vw;
position: relative;
mask-image: linear-gradient(90deg, hsl(0 0% 0% / 0),
hsl(0 0% 0% / 1) 20%,
hsl(0 0% 0% / 1) 80%,
hsl(0 0% 0% / 0));
}
.wall .wall-item {
position: absolute;
top: 0;
left: 0;
transform: translateX(calc(var(--wall-item-width) * var(--wall-item-number)));
height: var(--wall-item-height);
width: var(--wall-item-width);
background-image: linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%);
animation: scrolling var(--duration) linear infinite;
cursor: pointer;
}
.wall[data-direction="reverse"] .wall-item {
animation-direction: reverse;
background-image: linear-gradient(120deg, #84fab0 0%, #8fd3f4 100%);
}
.wall .wall-item:nth-child(1) {
animation-delay: calc((var(--duration) / var(--wall-item-number)) * (var(--wall-item-number) - 1) * -1);
}
.wall .wall-item:nth-child(2) {
animation-delay: calc((var(--duration) / var(--wall-item-number)) * (var(--wall-item-number) - 2) * -1);
}
.wall .wall-item:nth-child(3) {
animation-delay: calc((var(--duration) / var(--wall-item-number)) * (var(--wall-item-number) - 3) * -1);
}
.wall .wall-item:nth-child(4) {
animation-delay: calc((var(--duration) / var(--wall-item-number)) * (var(--wall-item-number) - 4) * -1);
}
.wall .wall-item:nth-child(5) {
animation-delay: calc((var(--duration) / var(--wall-item-number)) * (var(--wall-item-number) - 5) * -1);
}
.wall .wall-item:nth-child(6) {
animation-delay: calc((var(--duration) / var(--wall-item-number)) * (var(--wall-item-number) - 6) * -1);
}
.wall .wall-item:nth-child(7) {
animation-delay: calc((var(--duration) / var(--wall-item-number)) * (var(--wall-item-number) - 7) * -1);
}
.wall .wall-item:nth-child(8) {
animation-delay: calc((var(--duration) / var(--wall-item-number)) * (var(--wall-item-number) - 8) * -1);
}
.wall .wall-item:nth-child(9) {
animation-delay: calc((var(--duration) / var(--wall-item-number)) * (var(--wall-item-number) - 9) * -1);
}
.wall:has(.wall-item:hover) .wall-item {
animation-play-state: paused;
}
</style>
</head>
<body>
<div class="wall" style="--duration:10s">
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
</div>
<div class="wall" data-direction="reverse" style="--wall-item-number:9">
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
<div class="wall-item"></div>
</div>
</body>
</html>
- 可配置项:
--wall-item-height: 滚动项高度
--wall-item-width: 滚动项宽度
--wall-item-number: 滚动元素个数
--duration: 动画时长
我们也可以向上面示例代码一样,在.wall标签上,通过style属性,给每个滚动墙单独配置宽高、数量、动画时间。
- 滚动方向:
动画默认从左往右运动。我们可以在.wall标签上设置data-direction="reverse",让动画从右往左运动。
三、局限性
- 滚动元素(wall-item)太少,不足以填满包装元素(wall)时,会达不到预期效果;
(解决办法:用js把所有子元素复制一份push到最后面)
- --wall-item-number默认为9,每次子元素数量变化,需手动修改--wall-item-number值。
(解决办法:使用js计算赋值。说到要用js,那么这一点局限性或许就可以忍受了。)
3. 需要手动为每一个滚动元素设置动画延迟时间(animation-delay)。
(解决办法:可用js计算赋值。)
来源:juejin.cn/post/7408441793790410804
对象有顺序吗?
前言
对象有顺序吗?换句话说,如果我们遍历一个对象,获取属性的顺序是和属性添加时的顺序一样吗?这靠谱吗?这篇文章将为你揭晓答案。
JavaScript 对象基础
在 JavaScript 中,一个对象是一个无序的键值对集合,键通常是字符串或者 Symbol,而值可以是任何类型的数据。对象的基本创建方式如下:
const obj = {
key1: 'value1',
key2: 'value2',
}
虽然我们通常认为对象的属性是无序的,但实际上,JavaScript 对对象属性的排列有其特定的规则。
属性顺序的规则
根据 ECMAScript 规范,JavaScript 对象属性的顺序受以下规则的影响:
整数属性,会按照升序排列。
何为整数属性?指的是一个可以在不做任何更改的情况下与一个整数进行相互转换的字符串,也就是可以被解析为整数的字符串。
const obj = {
2: 'two',
1: 'one',
'3': 'three',
'a': 'alpha',
}
console.log(Object.keys(obj)); // -> ['1', '2', '3', 'a']
所有普通的字符串属性,按照其插入的顺序排列。
const obj = {
b: 'beta',
a: 'alpha',
c: 'gamma',
}
console.log(Object.keys(obj)); // -> ['b', 'a', 'c']
Symbol 类型的属性总是放在最后,并且保留其插入顺序。
const sym1 = Symbol('key1');
const sym2 = Symbol('key2');
const obj = {
[sym1]: 'value1',
1: 'one',
[sym2]: 'value2',
'a': 'alpha',
}
console.log(Object.keys(obj)); // -> ['1', 'a']
console.log(Object.getOwnPropertySymbols(obj)); // -> [sym1, sym2]
结合上述规则,我们再看一个综合示例。
const sym1 = Symbol("key1");
const sym2 = Symbol("key2");
const obj = {
[sym2]: "value2",
z: "last",
3: "three",
2: "two",
a: "alpha",
1: "one",
b: "beta",
[sym1]: "value1",
0: "zero",
};
console.log(Reflect.ownKeys(obj)); // -> ["0", "1", "2", "3", "z", "a", "b", Symbol("key2"), Symbol("key1")];
可以看到,整数属性按升序排列,普通属性按插入顺序排列,符号则放在最后并保留插入顺序。
最后
虽然 JavaScript 对象的属性被称为“无序”,但实际上它们有“特别的顺序”。整数属性会按升序排列,普通属性按插入顺序,Symbol 类型的属性总是排在最后并保留插入时的顺序。
如果文中有错误或者不足之处,欢迎大家在评论区指正。
你的点赞是对我最大的鼓励!感谢阅读~
来源:juejin.cn/post/7409668839199883314
常见呼吸灯闪烁动画
最近在需求里遇到了一个手指引导交互的动画需求。这篇文章就来讲讲如何CSS实现这个动画,如下图所示:
简单分析了一下效果,是一个手指移动到某处位置,然后会触发呼吸灯闪烁的效果,所有实现整个动画可以分两步:
呼吸灯闪烁动画
这里介绍下我遇到过得几种呼吸灯闪烁动画
第一种效果
@keyframes twinkling {
0% {
opacity: 0.2;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.12);
}
100% {
opacity: 0.2;
transform: scale(1);
}
}
.circle {
border-radius: 50%;
width: 12px;
height: 12px;
background: green;
position: absolute;
top: 36px;
left: 36px;
&::after {
content: "";
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
border-radius: 50%;
background: greenyellow;
animation: twinkling 1s infinite ease-in-out;
animation-fill-mode: both;
}
}
第二种效果
@keyframes scale {
0% {
transform: scale(1)
}
50%,
75% {
transform: scale(3)
}
78%,
100% {
opacity: 0
}
}
@keyframes scales {
0% {
transform: scale(1)
}
50%,
75% {
transform: scale(2)
}
78%,
100% {
opacity: 0
}
}
.circle {
position: absolute;
width: 12px;
height: 12px;
background-color: pink;
border-radius: 50%;
top: 36px;
left: 36px;
}
.circle:before {
content: '';
display: block;
width: 12px;
height: 12px;
border-radius: 50%;
opacity: .4;
background-color: pink;
animation: scale 1s infinite cubic-bezier(0, 0, .49, 1.02);
}
.bigcircle {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
opacity: .4;
background-color: pink;
top: 36px;
left: 36px;
animation: scales 1s infinite cubic-bezier(0, 0, .49, 1.02);
}
第三种效果
@keyframes scaless {
0% {
transform: scale(1)
}
50%,
75% {
transform: scale(3)
}
78%,
100% {
opacity: 0
}
}
.circle {
position: absolute;
width: 12px;
height: 12px;
background-color: transparent;
border-radius: 50%;
top: 36px;
left: 36px;
}
.circle:before {
content: '';
display: block;
width: 12px;
height: 12px;
border-radius: 50%;
opacity: .7;
border: 3px solid hotpink;
background-color: transparent;
animation: scaless 1s infinite cubic-bezier(0, 0, .49, 1.02);
}
小手移动动画
@keyframes animation-hand-move {
0% {
transform: translate(0, 0);
}
20% {
transform: translate(-80px, -50px);
}
25% {
transform: translate(-80px, -50px) scale(0.92) rotate(-3deg);
opacity: 1;
}
75% {
transform: translate(-80px, -50px) scale(0.92) rotate(-3deg);
opacity: 1;
}
90% {
transform: translate(-50px, -40px) scale(1) rotate(0deg);
opacity: 0.6;
}
100% {
opacity: 0;
}
}
.hard {
position: absolute;
top: 200px;
left: 318px;
width: 61px;
height: 69px;
background-size: contain;
background-repeat: no-repeat;
background-image: url('./img//174ba1f81a4d0d91a7dc45567b59fd8b.png');
animation: animation-hand-move 4s infinite linear;
}
完整动画代码
<div class="card">
<View class="round circle"></View>
<View class="round small-circle"></View>
<View class="round less-circle"></View>
<div class="hard"></div>
</div>
.card {
margin: 100px auto;
width: 480px;
height: 300px;
background-color: #333333;
border-radius: 16px;
position: relative;
}
.hard {
position: absolute;
top: 200px;
left: 318px;
width: 61px;
height: 69px;
background-size: contain;
background-repeat: no-repeat;
background-image: url('./img/174ba1f81a4d0d91a7dc45567b59fd8b.png');
animation: animation-hand-move 4s infinite linear;
}
.round {
position: absolute;
top: 144px;
left: 227px;
width: 32px;
height: 32px;
border-radius: 50%;
background-color: transparent;
&::before {
content: '';
position: absolute;
border-radius: 50%;
background: transparent;
width: 100%;
height: 100%;
top: 50%;
left: 50%;
border: 6px solid rgba(255, 255, 255, 0.5);
pointer-events: none;
transform: translate(-50%, -50%) scale(0);
opacity: 1;
}
}
.circle {
&::before {
animation: animation-wave 4s infinite linear;
}
}
.small-circle {
&::before {
animation: animation-small-wave 4s infinite linear;
}
}
.less-circle {
&::before {
animation: animation-less-wave 4s infinite linear;
}
}
@keyframes animation-wave {
0%,
20%,
100% {
opacity: 0;
}
25% {
width: 0px;
height: 0px;
transform: translate(-50%, -50%);
opacity: 1;
}
75% {
width: 64px;
height: 64px;
transform: translate(-50%, -50%);
opacity: 0;
}
}
@keyframes animation-small-wave {
0%,
20%,
100% {
opacity: 0;
}
42% {
width: 0px;
height: 0px;
transform: translate(-50%, -50%);
opacity: 1;
}
75% {
width: 64px;
height: 64px;
transform: translate(-50%, -50%);
opacity: 0;
}
}
@keyframes animation-less-wave {
0%,
20%,
100% {
opacity: 0;
}
59% {
width: 0px;
height: 0px;
transform: translate(-50%, -50%);
opacity: 1;
}
75% {
width: 64px;
height: 64px;
transform: translate(-50%, -50%);
opacity: 0;
}
}
@keyframes animation-hand-move {
0% {
transform: translate(0, 0);
}
20% {
transform: translate(-80px, -50px);
}
25% {
transform: translate(-80px, -50px) scale(0.92) rotate(-3deg);
opacity: 1;
}
75% {
transform: translate(-80px, -50px) scale(0.92) rotate(-3deg);
opacity: 1;
}
90% {
transform: translate(-50px, -40px) scale(1) rotate(0deg);
opacity: 0.6;
}
100% {
opacity: 0;
}
}
来源:juejin.cn/post/7408795408861921290
如何写出让同事崩溃的代码
废话不多说,咱直接进入主题。手把手教你如何写出让帮你维护代码的同时瞬间崩溃的代码。
一、方法或变量名字随便取
首先,让同事看不懂自己代码的第一步就是,想尽办法让他看不出来我定义的变量或者方法到底是干嘛用的。哎!对,就是让他去猜,哈哈哈。
来来来,空说没意思,举个栗子。
假设现在想要点击某个input框时,显示一个自定义的组件用于选择选择时间。
正常的写法如下:定义一个 toggleDatePicker 方法
这个一看就知道是时间选择器的显示切换方法。
但是呢,我偏不,定义成下面这样:让同事摸不着头脑,哈哈哈
当看到很多这样的方法名或变量名时,同事的表情估计时这样的。
接下来,第二招
二、方法体尽可能的长,长到不能在长
这一步至关重要,将所有逻辑全部放在一个方法中写完,坚决不分步骤,不按逻辑一步步拆分方法。让同事为我的超长方法体感到叹为观止,默默流泪。
老规矩,上栗子
假设现在有个方法需要处理比较复杂(需要递归,而且每层数据有不同的类型)的json格式的数据回显到页面上(这是用于拼一些条件)。数据格式大概是这样的
[
{
type: "group",
relation: "or",
conditions: [
{
type: "condition",
field: {
name: "员工状态",
id: 12345678
},
logic: "=",
val: 1,
relation: "and"
},
{
type: "condition",
field: {
name: "入职时间",
id: 12345678
},
logic: "<",
val: "2011-07-09",
relation: "and"
}
]
},
{
type: "condition",
field: {
name: "入职时间",
id: 12345678
},
logic: "<",
val: "2001-07-09",
relation: "and"
}
]
由于上面的 json 数组格式中是一个对象,对象都有 type 属性,一种是 group 类型(表示是一组条件),一种是 condition 类型(表示是真正的条件内容)。
因此,正常情况下,我们应该是遍历数组,不同的类型分别用不同的方法去处理,并且在处理条件中各个部分的时候分别处理。如下所示:
但是呢?咱主题是啥,让同时崩溃啊,怎么能把方法拆成这样清晰的逻辑呢。
来给你看看怎么让同事对你的代码叹为观止
怎么样,牛不牛,一个方法写完所以逻辑,从1825行一口气写到2103行,足足... 2103 - 1825 是多少来着,3减5 不够,向前借位 ,嗯。。。278 行。
****怎么样,有没有被哥的机智震惊到,如此代码。同事看到肯定心生敬佩,连连称绝。此时想到得到同事应该是这样的表情
同事还没进到方法体里面,就已经被我的 迷之方法名 和 超长方法体 所折服,接下来就让他在方法体里面快乐的遨游吧
接下来,继续让同时崩溃。
三、坚决不定义统一的变量
这个怎么说呢,就是因为有点懒,所有很多代码直接复制粘贴,多么的方便快捷。
正常情况下,如果某个我们需要的对象在是其他对象的属性,并且层次很深,我们先定义一个变量来接收这个对象,再对这个对象操作。
例如:
let a = {
b: {
c: {
d: {
name: "我是最里面的对象"
}
}
}
}
我们要对d对象进行很多次的操作时,一般先将d赋值给一个变量,然后对变量操作。如下:
var dOfA = a.b.c.d;
dOfA.name = "我现在被赋值给dOfA ";
dOfA.data = 1;
dOfA.other = false;
但是呢,我就不这么干,就是要写得整整齐齐
a.b.c.d.name = "就要这么干,你打我呀";
a.b.c.d.data = 1;
a.b.c.d.other = false;
老规矩,没有 实际的 栗子 怎么能说的形象呢,上图
正常写法:
我偏要这么写
多么的整齐划一,
全场动作必须跟我整齐划一
来左边儿 跟我一起画个龙
在你右边儿 画一道彩虹
来左边儿 跟我一起画彩虹...
咋突然哼起歌来了,不对,咱是要整同事的,怎么能偏题。
继续,此时同事应该是这个表情
然后,方法体里面只有这么点东西怎么够玩,继续 come on
四、代码能复制就复制,坚决不提成公用的方法
代码能 CV ,干嘛费劲封装成方法,而且这样不是显得我代码行数多吗?图片图片图片
就是玩儿,就是不封装
来,上栗子
看到没有,相同的代码。我在 1411行 - 1428行 写了一遍, 后面要用,在1459行-1476行复制一遍
这还不够?那我在1504-1521行再复制一遍
这下,爽了吧,哈哈哈
就是不提方法,就是玩儿,哎! 有意思
这个时候同事估计是这样的吧
怎么样,是不是很绝?不不不,这算啥
虽然以上这些会让看我代码的同事头疼,但是,只要我在公司,他们还会让我改啊。怎么能搞自己呢。
最后一步
五、离职
洋洋洒洒的写完代码,尽早离开。够不够绝,哈哈哈
六、申明:
以上场景纯属个人虚构的,单纯为了给文章增加点乐趣。写这个文章的目的是让各位程序员兄弟尽量避免写这种难以维护的代码。真的太痛苦了!代码质量、代码重构真的是编程过程中很重要的一个步骤。不能抱着能用就行的心态。还是要对自己有一定的要求。只有你看得起自己的代码,别人才有可能看得起你的代码。加油吧!各位
来源:juejin.cn/post/7293888785400856628
我花了一天时间,做了一个图片上传组件,看起来很酷实际上确实很酷
今天,我花了一天的时间做了一个图片上传组件。效果如下:
可能有人觉得,这个组件很简单,没什么技术含量,其实确实也啥技术含量。但是,我是想借这个组件,来表达一种封装的思想在里面,希望可以帮助到一些朋友。
简单的描述下这个组件的功能:
- 用户可以点击下面颜色比较绚丽的上传按钮,选择本地图片进行上传,也可以直接点击图片区域进行上传。
- 上传过程中,会有一个上传中的进度条,上传完成后,会有一个上传成功的提示,如果失败了,会有一个上传失败的提示,而且支持重试。
- 可以点击图片右上角的删除按钮,删除图片。
- 并发控制,最多只能同时上传 N 张图片,也就是所谓的限频,这里是 2 张。
是不是看了这么多功能之后,就开始有点头皮发麻了?哈哈,不要怕,这就带你了解下,如何拆解这种功能,而且,学会了这种拆解的办法,后面你遇到更加复杂的,也可以得心应手。
拆解功能,逐步实现
首先,我们思考,我们该使用自底向上的思路,还是自顶向下的思路来拆解这个功能呢?我的建议自顶向下的思路去思考架构,然后自底向上的去实现功能。
因为我们这个图片上传组件是支持多长图片同时上传的,而且,我们还需要支持上传失败重试的功能,所以,为了让功能更加聚焦,我们把关注点放在 PhotoItem 上,没一个 PhotoItem 就是一个图片上传的单元。他可以独立的上传,独立的删除,独立的重试。
那么,为了让 PhotoItem 这个组件更加简洁,我们把上传逻辑放在hooks useUpload
中,这样,PhotoItem 只需要关注自己的展示逻辑即可。
这样做的目的是做到关注点分离,通常来讲,也是符合单一职责原则的。写出来的组件维护性必定大大提升。
代码实现
我们先来看下 useUpload 的代码,因为PhotoItem 依赖他,我们先实现它。
"use client";
export const useUploader = (uploadAction) => {
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState(null);
const upload = useCallback(async (file) => {
setIsUploading(true);
setError(null);
try {
return await uploadAction(file);
} catch (err) {
setError(err.message || 'Upload failed');
} finally {
setIsUploading(false);
}
}, [uploadAction]);
const reset = useCallback(() => {
setIsUploading(false);
setError(null);
}, []);
return { upload, isUploading, error, reset };
};
可以看到,我们的 hooks 非常之简单,就是暴露了一个实现图片上传的狗子 upload,然而,他替我们的组件管理了上传中
,上传失败
,的状态,因此,接下来看,我们的PhotoItem 组件将会有多清晰。
export const PhotoItem = ({
file,
onRemove,
onUploadComplete,
onUploadError,
}) => {
const { upload, isUploading, error, reset } = useUploader();
const startUpload = useCallback(async () => {
try {
const url = await upload(file);
onUploadComplete(url);
} catch (err) {
onUploadError();
}
}, [file, upload, onUploadComplete, onUploadError]);
useEffect(() => {
startUpload();
}, [queueUpload, startUpload]);
const handleRetry = () => {
reset();
startUpload();
};
return (
<div className="relative w-full h-20">
<img
src={URL.createObjectURL(file)}
/>
{!isUploading && !error(
Uploaded
)}
{isUploading && (
<Progress />
)}
{error && (
<span>Failed</span>
)}
</div>
);
};
OK,到目前为止,还是极其简单的,但是我们貌似忘记了一个很核心的功能,限制并发数。为什么要限制并发数,因为我们自己的服务器或者三方的服务器,可能会有并发数的限制,如果我们不限制并发数,可能会导致一次传多张图片是卡住。
思考,如何限制并发数
我们想一样,是谁触发了上传的呢?是不是 PhotoItem 组件呢?是的,我们可以在 PhotoItem 组件中,去控制并发数,但是,这样做,会导致 PhotoItem 组件的逻辑变得复杂,因为他不仅要关注自己的展示逻辑,还要关注并发数的控制逻辑。这就显的不太合适了。所以,我们应该把他丢出去对吧,截止到目前为止,我们的PhotoUploader 这个组件似乎并没有干任何事情,我们思考下,并发控制的逻辑是否应该是他来呢?
答案是显然的,我们应该把并发控制的逻辑放在 PhotoUploader 组件中,因为他是整个上传组件的入口,他应该关注并发控制,而不是 PhotoItem 组件,而且最本质的原因是,PhotoItem 也不关心是否有其他的 PhotoItem 。
那么,问题来了,并发控制怎么写呢?使用什么数据结构较为合适呢?不卖关子了,我们知道,队列是最合适的数据结构,因为他是先进先出的,我们可以把上传任务放在队列中,然后,每次上传完成,就从队列中取出一个任务,继续上传。
好,我们改造一下,我们的 PhotoItem 组件,让他不要直接执行上传逻辑,而是把他做成一个任务,然后,把任务放在队列中,然后,我们在 PhotoUploader 组件中,去控制并发数。
export const PhotoItem = ({
file,
onRemove,
...
queueUpload // 加一个队列操作器
}) => {
const { upload, isUploading, error, reset } = useUploader();
...
useEffect(() => {
queueUpload(startUpload); // 修改这里
}, [queueUpload, startUpload]);
const handleRetry = () => {
reset();
queueUpload(startUpload);//修改这里
};
// .... 其他几乎不变
在来看看我们的 PhotoUploader 组件,他是如何控制并发数的。很简单,我们只需要维护一个队列,然后,每次上传完成,就从队列中取出一个任务,继续上传。
const processQueue = useCallback(() => {
while (activeUploadsRef.current < MAX_CONCURRENT_UPLOADS && uploadQueueRef.current.length > 0) {
const nextUpload = uploadQueueRef.current.shift();
activeUploadsRef.current++;
nextUpload();
}
}, []);
const queueUpload = useCallback((startUpload) => {
if (activeUploadsRef.current < MAX_CONCURRENT_UPLOADS) {
activeUploadsRef.current++;
startUpload();
} else {
uploadQueueRef.current.push(startUpload);
}
}, []);
这里,只给出最最核心的逻辑,实际上就是维护的了一个任务队列,然后,每次上传完成,就判断下队列中是否还有任务,并且是否超过了并发数,如果没有超过,并且队列中还有任务,就继续上传。仅此而已。
总结一下
这个图片上传组件,看似简单,但是,他涉及到了很多的知识点,比如并发控制,上传失败重试,组件拆解,自顶向下的架构设计,自底向上的功能实现。我们在实现这个组件的过程中。有过很多的思考,比如:
- 如何拆解功能,让组件更加聚焦,做到关注点分离。
- 控制并发数,使用队列是最合适的数据结构。
- 如何设计一个 hooks,让组件更加简洁。
- 以及自顶向下的架构设计,自底向上的功能实现。
只有建立起这些系统性的思维,我们才能在遇到更加复杂的问题时,得心应手。希望这篇文章对你有所帮助。
欢迎关注我老码沉思录,获取我最新的知识分享。
来源:juejin.cn/post/7394854112510951443
Taro搭建支付宝小程序实战
1. 引言
在当今多端应用开发的趋势下,许多开发者面临着需要在不同平台(如微信、支付宝、百度等小程序,以及H5和React Native应用)上编写和维护多套代码的挑战。为了解决这一问题,市场上涌现了多种跨端开发框架,旨在帮助开发者实现“一次编写,多端运行”的目标。Taro 是其中最为流行的框架之一,但在选择开发工具时,了解其他同类框架的优缺点非常重要。
1.1 类似框架的对比
以下是 Taro、Uniapp、WePY 和 MPX 四种多端开发框架的优缺点对比表:
框架 | 优点 | 缺点 |
---|---|---|
Taro | - 支持使用 React 语法,符合许多前端开发者的使用习惯。 - 广泛支持多端,包括微信小程序、支付宝小程序、H5、React Native 等。 - 活跃的社区和丰富的插件生态系统。 - 提供了完善的跨平台 API 兼容性,减少了平台差异的处理工作。 | - 构建时间相对较长,尤其是在多端同时输出时。 - 部分高级特性在某些平台上可能不完全兼容。 |
Uniapp | - 使用 Vue 语法,适合 Vue 开发者。 - 支持广泛的多端输出,包括小程序、H5、App(通过原生渲染)。 - 简单易上手,适合中小型项目。 | - 对复杂业务场景的支持有限,灵活性不如 Taro。 - 生态系统相对 Taro 较弱,插件丰富度不及 Taro。 |
WePY | - 针对微信小程序的开发框架,支持类 Vue 语法。 - 轻量级,专注于微信小程序的开发,简单直接。 | - 多端支持较弱,主要针对微信小程序,跨平台能力不足。 - 社区相对较小,更新频率较慢。 |
MPX | - 提供了增强的组件化编程能力,适合大型复杂小程序项目。 - 拥有更好的编译性能,构建速度较快。 | - 使用自定义语法,学习成本较高。 - 社区资源较少,生态系统不如 Taro 和 Uniapp 丰富。 |
通过这个对比表,我们可以根据项目需求和团队的技术栈选择最适合的多端开发框架。每个框架都有其独特的优势和局限性,因此选择时需要权衡各方面的因素。
1.2 为什么选择 Taro
Taro 作为一个基于 React 的多端开发框架,有以下几大优势使得它在众多选择中脱颖而出:
- 跨平台支持广泛:Taro 支持微信小程序、支付宝小程序、H5、React Native、快应用等多个平台,能够极大地提升开发效率,减少代码重复编写的成本。
- React 生态支持:Taro 使用 React 语法,这使得许多已有的 React 组件和库可以直接复用,开发者不需要学习新的开发模式,便能快速上手。
- 成熟的生态系统:Taro 拥有丰富的社区插件和第三方支持,提供了大量开箱即用的功能模块,如状态管理、路由管理等,这些都能帮助开发者更快地构建应用。
- 持续的更新与支持:Taro 由京东维护,得到了持续的更新与支持,具有较强的社区活力,能够及时响应开发者的需求和问题。
Taro 作为一个成熟且功能强大的多端开发框架,特别适合那些希望一次开发、多平台运行的项目需求。它不仅简化了跨平台开发的复杂性,还提供了丰富的功能支持,使得开发过程更加高效和愉悦。因此,我们选择 Taro 作为开发支付宝小程序的首选工具。
2. 环境搭建
2.1 安装 Taro CLI
首先,你需要安装 Taro 的 CLI 工具。确保你已经安装了 Node.js 环境,然后运行以下命令来全局安装 Taro CLI:
npm install -g @tarojs/cli
2.2 创建项目
安装完成后,可以通过以下命令来创建一个新的 Taro 项目:
taro init myApp
在创建过程中,Taro 会询问你一些选项,如选择框架(默认 React)、CSS 预处理器(如 Sass、Less)等。选择合适的选项后,Taro 会自动生成项目的目录结构和配置文件。
2.3 配置支付宝小程序
在 Taro 项目中,配置支付宝小程序的输出涉及多个方面,包括基本的项目配置、支付宝小程序特有的扩展配置、以及一些针对支付宝平台的优化设置。以下是详细的配置步骤和相关说明。
2.3.1 基本配置
首先,需要在项目的 config/index.js
文件中进行基础配置,确保 Taro 能够正确编译并输出支付宝小程序。
const config = {
projectName: 'myApp',
designWidth: 750,
deviceRatio: {
'640': 2.34 / 2,
'750': 1,
'828': 1.81 / 2
},
sourceRoot: 'src',
outputRoot: 'dist',
plugins: [],
defineConstants: {},
copy: {
patterns: [],
options: {}
},
framework: 'react',
compiler: 'webpack5',
cache: {
enable: true
},
mini: {
postcss: {
autoprefixer: {
enable: true,
config: {}
},
pxtransform: {
enable: true,
config: {}
},
url: {
enable: true,
config: {
limit: 10240 // 设置转换尺寸限制
}
}
}
},
h5: {
publicPath: '/',
staticDirectory: 'static',
esnextModules: [],
postcss: {
autoprefixer: {
enable: true,
config: {}
},
cssModules: {
enable: false, // 默认是 false,若需要支持 CSS Modules,设置为 true
config: {
namingPattern: 'module', // 转换模式,支持 global/module
generateScopedName: '[name]__[local]___[hash:base64:5]'
}
}
}
}
}
2.3.2 支付宝小程序特有的配置
在 config/index.js
文件中,需要在 mini
配置块中进一步设置支付宝小程序的特有配置。Taro 会自动生成支付宝小程序所需的 app.json
、project.config.json
等配置文件,但你可以根据需求自定义一些额外的配置。
mini: {
compile: {
exclude: ['src/lib/alipay-lib.js'] // 示例:排除不需要编译的文件
},
postcss: {
autoprefixer: {
enable: true,
config: {
browsers: ['last 3 versions', 'Android >= 4.1', 'ios >= 8']
}
}
},
// 支付宝小程序特有的配置
alipay: {
component2: true, // 启用支付宝小程序的基础组件规范 v2
axmlStrictCheck: true, // 严格的 axml 校验,帮助发现潜在问题
renderShareComponent: true, // 启用动态组件渲染
usingComponents: { // 注册全局自定义组件
'custom-button': '/components/custom-button/index'
},
// 支付宝扩展配置
plugins: {
myPlugin: {
version: '1.0.0',
provider: 'wx1234567890' // 插件提供者的AppID
}
},
window: {
defaultTitle: '支付宝小程序', // 设置小程序默认的标题
pullRefresh: true, // 支持下拉刷新
allowsBounceVertical: 'YES' // 支持竖向弹性滚动
},
pages: [
'pages/index/index',
'pages/detail/detail'
],
tabBar: {
color: '#000000',
selectedColor: '#1c1c1c',
backgroundColor: '#ffffff',
borderStyle: 'black',
list: [{
pagePath: 'pages/index/index',
text: '首页',
iconPath: 'assets/tabbar/home.png',
selectedIconPath: 'assets/tabbar/home-active.png'
}, {
pagePath: 'pages/detail/detail',
text: '详情',
iconPath: 'assets/tabbar/detail.png',
selectedIconPath: 'assets/tabbar/detail-active.png'
}]
}
}
}
2.3.3 支付宝小程序页面配置
每个页面的配置都在 pages.json
文件中进行定义,Taro 会自动处理这些配置。但你可以根据需要进一步自定义每个页面的表现,如是否启用下拉刷新、页面背景颜色等。
{
"pages": [
"pages/index/index",
"pages/detail/detail"
],
"window": {
"defaultTitle": "我的小程序",
"titleBarColor": "#ffffff",
"navigationBarBackgroundColor": "#000000",
"navigationBarTextStyle": "white",
"backgroundColor": "#ffffff",
"enablePullDownRefresh": true // 启用下拉刷新
}
}
2.3.4 引入支付宝小程序扩展组件
支付宝小程序支持扩展组件和插件,这些可以直接在 usingComponents
中进行引入。例如,如果你需要使用支付宝小程序特有的扩展组件如 RichText
或 Input
,可以在 alipay
配置中进行设置:
alipay: {
usingComponents: {
'rich-text': 'plugin://myPlugin/rich-text', // 引用插件中的组件
'custom-button': '/components/custom-button/index' // 引入自定义组件
}
}
2.3.5 设置支付宝小程序的分包加载
对于较大或复杂的支付宝小程序,你可能希望启用分包加载功能,以减少主包大小并提升加载速度。Taro 支持在配置文件中设置分包:
subPackages: [
{
root: 'packageA',
pages: [
'pages/logs/logs'
]
},
{
root: 'packageB',
pages: [
'pages/index/index'
]
}
],
2.3.6 环境变量配置
在开发和生产环境下,可能需要不同的配置。Taro 支持通过环境变量进行配置管理。你可以在项目根目录下创建 .env
文件,并定义不同环境下的变量:
// .env.development
TARO_ENV = 'alipay'
API_BASE_URL = 'https://api-dev.example.com'
// .env.production
TARO_ENV = 'alipay'
API_BASE_URL = 'https://api.example.com'
然后在代码中通过 process.env
访问这些变量:
const apiBaseUrl = process.env.API_BASE_URL
2.3.7 自定义 Webpack 配置
如果你需要更复杂的配置或优化,可以通过 config/index.js
中的 webpackChain
属性来自定义 Webpack 配置。例如,添加自定义的插件或优化构建过程:
mini: {
webpackChain (chain) {
chain.plugin('analyzer')
.use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin, [])
}
}
2.3.8 部署和调试
在配置完成后,可以通过以下命令生成支付宝小程序的构建文件:
taro build --type alipay
生成的代码会存放在 dist
目录下,然后可以通过支付宝开发者工具打开并进行调试。
2.3.9 常见问题及解决方法
- 自定义组件路径错误:确保
usingComponents
中的路径正确,特别是相对于dist
目录的相对路径。 - 跨域请求问题:在支付宝小程序中进行网络请求时,确保在
config/index.js
中配置了正确的sourceRoot
和outputRoot
。 - 调试模式不一致:在开发和生产环境中使用不同的 API 端点时,确保环境变量配置正确并在代码中正确使用。
以上是使用 Taro 搭建支付宝小程序的详细配置步骤,涵盖了从基础配置到扩展功能的方方面面。通过这些配置,你可以更加灵活地控制支付宝小程序的表现和功能,满足项目的多样化需求。
3. 重要的 API 和组件
3.1 基础组件
Taro 提供了许多常用的基础组件,如 View
、Text
、Button
等,这些组件与 React 组件类似,但 Taro 的组件具有跨平台能力。
import { View, Text, Button } from '@tarojs/components'
const MyComponent = () => (
<View>
<Text>Hello, Taro!</Text>
<Button onClick={() => alert('Clicked!')}>Click me</Button>
</View>
)
3.2 事件处理
Taro 事件处理机制与 React 类似,可以直接使用 onClick
、onChange
等事件属性。此外,Taro 支持跨平台的事件兼容处理,不需要担心事件名称的差异。
3.3 路由跳转
Taro 提供了 Taro.navigateTo
、Taro.redirectTo
等 API 用于在小程序中进行页面跳转。可以根据具体需求选择合适的跳转方式。
Taro.navigateTo({
url: '/pages/detail/index?id=123'
})
3.4 使用 Hooks
在 Taro 中可以使用 React 的 Hooks,如 useState
、useEffect
等,来管理组件的状态和生命周期。
import { useState, useEffect } from 'react'
import { View } from '@tarojs/components'
const Counter = () => {
const [count, setCount] = useState(0)
useEffect(() => {
Taro.setNavigationBarTitle({ title: `Count: ${count}` })
}, [count])
return (
<View>
<Button onClick={() => setCount(count + 1)}>Increment</Button>
<Text>{count}</Text>
</View>
)
}
4. 常见问题及坑点
4.1 样式问题
支付宝小程序的 CSS 支持度与微信小程序有所不同,某些高级的 CSS 特性可能无法生效。建议使用基础的 CSS 进行布局,并在项目中引入适当的 polyfill。
4.2 API 差异
在使用 Taro 开发支付宝小程序时,虽然 Taro 提供了跨平台的 API 兼容性,但是由于各个小程序平台的底层架构和能力不同,仍然存在一些需要特别注意的 API 差异。以下将详细说明一些与原生微信小程序不同的、并且在支付宝小程序中经常会用到的特殊 API。
4.2.1 支付宝特有的 API
1. my.request 与 wx.request
尽管 Taro 封装了 Taro.request
来统一请求接口,但在某些特殊情况下,开发者可能需要直接使用原生 API。支付宝小程序的 my.request
与微信的 wx.request
基本相同,但支持一些额外的配置选项,如 timeout
等。
示例:
// 支付宝小程序
my.request({
url: 'https://api.example.com/data',
method: 'GET',
timeout: 5000, // 设置请求超时时间
success: (res) => {
console.log(res.data);
},
fail: (err) => {
console.error(err);
}
});
在微信小程序中,wx.request
不支持 timeout
配置。
2. my.alert 和 wx.showModal
my.alert
是支付宝小程序中用来展示提示框的 API,而微信小程序使用 wx.showModal
来实现类似功能。my.alert
仅支持一个按钮,而 wx.showModal
可以支持两个按钮(确定和取消)。
示例:
// 支付宝小程序
my.alert({
title: '提示',
content: '这是一个提示框',
buttonText: '我知道了',
success: () => {
console.log('用户点击了确定按钮');
}
});
// 微信小程序
wx.showModal({
title: '提示',
content: '这是一个提示框',
showCancel: false, // 不显示取消按钮
success: (res) => {
if (res.confirm) {
console.log('用户点击了确定按钮');
}
}
});
3. my.getAuthCode 与 wx.login
支付宝小程序的 my.getAuthCode
用于获取用户的授权码,通常用于登录验证或支付场景。而微信小程序使用 wx.login
获取用户的登录凭证(code
),这两者在使用上有所不同。
示例:
// 支付宝小程序
my.getAuthCode({
scopes: 'auth_user',
success: (res) => {
console.log('用户授权码:', res.authCode);
},
fail: (err) => {
console.error('获取授权码失败:', err);
}
});
// 微信小程序
wx.login({
success: (res) => {
console.log('用户登录凭证:', res.code);
},
fail: (err) => {
console.error('获取登录凭证失败:', err);
}
});
4. my.navigateToMiniProgram 与 wx.navigateToMiniProgram
这两个 API 都用于跳转到其他小程序。虽然功能类似,但在支付宝小程序中,my.navigateToMiniProgram
有一些额外的参数,比如 extraData
,用于在跳转时传递数据。
示例:
// 支付宝小程序
my.navigateToMiniProgram({
appId: '2021000000000000',
path: 'pages/index/index',
extraData: {
foo: 'bar'
},
success: () => {
console.log('跳转成功');
},
fail: (err) => {
console.error('跳转失败:', err);
}
});
// 微信小程序
wx.navigateToMiniProgram({
appId: 'wx1234567890',
path: 'pages/index/index',
extraData: {
foo: 'bar'
},
success: () => {
console.log('跳转成功');
},
fail: (err) => {
console.error('跳转失败:', err);
}
});
5. my.tradePay 与 wx.requestPayment
my.tradePay
是支付宝小程序用于发起支付的 API,而微信小程序使用 wx.requestPayment
实现同样的功能。两者的参数配置有所不同,尤其是在支付方式和返回结果处理上。
示例:
// 支付宝小程序
my.tradePay({
tradeNO: '202408280000000000001',
success: (res) => {
if (res.resultCode === '9000') {
console.log('支付成功');
} else {
console.log('支付失败', res.resultCode);
}
},
fail: (err) => {
console.error('支付失败:', err);
}
});
// 微信小程序
wx.requestPayment({
timeStamp: '1609459200',
nonceStr: '5K8264ILTKCH16CQ2502SI8ZNMTM67VS',
package: 'prepay_id=wx20170101abc1234567890',
signType: 'MD5',
paySign: 'ABCD1234',
success: () => {
console.log('支付成功');
},
fail: (err) => {
console.error('支付失败:', err);
}
});
6. my.chooseCity 与 wx.chooseLocation
支付宝小程序提供了 my.chooseCity
API 用于选择城市,而微信小程序没有直接对应的 API,但 wx.chooseLocation
可以选择位置,且在选址过程中包含了城市信息。
示例:
// 支付宝小程序
my.chooseCity({
showLocatedCity: true, // 显示当前所在城市
success: (res) => {
console.log('选择的城市:', res.city);
}
});
// 微信小程序
wx.chooseLocation({
success: (res) => {
console.log('选择的位置:', res.name);
console.log('所在城市:', res.address);
}
});
4.2.2 差异化 API 使用的注意事项
- 功能测试:由于 API 存在差异,所以我们需要在不同平台上进行充分测试,确保应用逻辑在所有平台上都能正常运行。
- 代码隔离:对于平台特有的 API,建议通过
Taro.getEnv()
判断运行环境,并使用条件判断来分别调用不同平台的 API,从而实现代码的隔离与复用。 - 兼容性处理:某些 API 在不同平台上可能有不同的参数或返回值格式,因此需要根据平台特性进行兼容性处理。
通过对以上 API 差异的详细了解,希望我们可以更好地在 Taro 项目中处理支付宝小程序与微信小程序的不同需求,提升应用的跨平台兼容性和用户体验。
4.3 路由管理
在 Taro 中,路由管理是跨平台开发中非常重要的一环。尽管 Taro 封装了微信小程序和支付宝小程序的路由操作,但在使用过程中仍然存在一些差异。以下详细说明 Taro 在支付宝和微信小程序中的路由管理方式,包括不同的跳转方式及参数的获取。
4.3.1 路由跳转方式
Taro 中提供了多种路由跳转方式,主要包括 Taro.navigateTo
、Taro.redirectTo
、Taro.switchTab
、Taro.reLaunch
和 Taro.navigateBack
。这些方法封装了微信和支付宝小程序的原生跳转方式,适用于不同的使用场景。
1. Taro.navigateTo
Taro.navigateTo
用于跳转到应用内的指定页面,新的页面会被加入到页面栈中。
示例:
Taro.navigateTo({
url: '/pages/detail/index?id=123&name=abc'
});
- 微信小程序: 页面栈最大深度为10,超过后会自动释放栈顶页面。
- 支付宝小程序: 页面栈最大深度为10,同样超过后会自动释放栈顶页面。
2. Taro.redirectTo
Taro.redirectTo
用于关闭当前页面并跳转到指定页面,跳转后无法返回到原页面。
示例:
Taro.redirectTo({
url: '/pages/home/index'
});
- 微信小程序 和 支付宝小程序 都会将当前页面从栈中移除,不允许用户回退。
3. Taro.switchTab
Taro.switchTab
用于跳转到指定的 tabBar 页面,并关闭其他所有非 tabBar 页面。
示例:
Taro.switchTab({
url: '/pages/home/index'
});
- 微信小程序:
url
必须是 tabBar 页面,否则会报错。 - 支付宝小程序: 同样必须是 tabBar 页面,但支付宝小程序支持使用
extraData
传递额外数据。
4. Taro.reLaunch
Taro.reLaunch
用于关闭所有页面并跳转到指定页面,适用于需要重置应用状态的场景。
示例:
Taro.reLaunch({
url: '/pages/home/index'
});
- 微信小程序 和 支付宝小程序 行为一致,都会关闭所有页面并创建一个新的页面栈。
5. Taro.navigateBack
Taro.navigateBack
用于关闭当前页面并返回到上一级页面或多级页面。
示例:
Taro.navigateBack({
delta: 1 // 返回上一级页面
});
- 微信小程序 和 支付宝小程序 都支持通过
delta
指定返回的页面层级。
4.3.2 获取路由参数
无论是通过哪种方式跳转,页面之间通常需要传递参数。在 Taro 中,参数的传递和获取可以通过 this.$router.params
实现。以下是如何在页面中获取路由参数的详细说明。
1. URL 参数传递
当使用 Taro.navigateTo
或其他跳转方法时,可以在 url
中通过 query
传递参数。
示例:
// 页面跳转
Taro.navigateTo({
url: '/pages/detail/index?id=123&name=abc'
});
// 在目标页面获取参数
componentDidMount() {
const { id, name } = this.$router.params;
console.log('ID:', id); // 输出:ID: 123
console.log('Name:', name); // 输出:Name: abc
}
- 微信小程序: 参数会自动编码并附加到 URL 后。
- 支付宝小程序: 行为类似微信小程序,参数通过 URL query 传递。
2. extraData
参数传递
支付宝小程序允许通过 extraData
传递复杂对象,这在某些复杂场景下非常有用。
示例:
// 页面跳转
my.navigateTo({
url: '/pages/detail/index',
extraData: {
info: {
id: 123,
name: 'abc'
}
}
});
// 在目标页面获取参数
componentDidMount() {
const { info } = this.$router.params;
console.log('Info:', info); // 输出:Info: { id: 123, name: 'abc' }
}
- 微信小程序: 目前不支持
extraData
参数传递,但可以通过globalData
或其他全局状态管理工具如 Redux 实现类似效果。
3. 场景值与 scene
参数
在小程序的入口页面,通常会涉及到场景值(scene
)的获取。Taro 提供了 this.$router.params.scene
来获取微信小程序中的 scene
值,这在处理分享或扫码进入时非常重要。
示例:
componentDidMount() {
const scene = this.$router.params.scene;
console.log('Scene:', scene); // 输出对应的场景值
}
- 微信小程序: 支持
scene
参数传递,主要用于扫码进入或分享。 - 支付宝小程序: 不直接支持
scene
,但可以通过其他方式获取进入场景(如my.getLaunchOptionsSync
)。
4.3.3 注意事项
- 页面栈限制:无论是微信还是支付宝小程序,都有页面栈深度限制(通常为10层)。在开发复杂应用时,合理控制页面跳转的深度,避免栈溢出。
- 参数编码问题:确保传递的 URL 参数已经过适当的编码,避免特殊字符引发问题。
- 页面返回的数据传递:Taro 并未封装类似
onActivityResult
的机制,但可以通过全局状态管理或eventBus
模式来实现页面返回的数据传递。
4.3.4 其他事项
通过上面的一些介绍,就可以更加灵活地使用 Taro 进行路由管理,充分利用不同平台的特性,提升应用的导航体验和用户体验。在使用 Taro 开发多端应用时,除了路由管理和 API 差异之外,还有一些关键点和常见的坑需要注意,以确保应用的稳定性、性能和可维护性。以下是一些使用 Taro 过程中需要特别注意的事项:
1. 跨平台兼容性
尽管 Taro 旨在提供跨平台的开发体验,但不同平台在渲染引擎、组件行为和 API 支持上仍有差异。开发者需要在每个目标平台上进行充分测试,确保功能和表现一致。
注意事项:
- 组件兼容性:某些 Taro 组件在不同平台上的表现可能有所不同,如
scroll-view
的行为在微信和支付宝小程序中略有差异。 - 样式兼容性:不同平台的样式支持不尽相同,如支付宝小程序对部分 CSS 属性的支持较弱,需要进行适配。
- API 兼容性:Taro 提供的统一 API 在不同平台上可能会有细微的差异,建议使用
Taro.getEnv()
进行环境判断,以便针对特定平台编写适配代码。
2. 状态管理
Taro 支持使用多种状态管理工具,如 Redux、MobX、Recoil 等。根据项目的复杂度和团队的技术栈选择合适的状态管理方案。
注意事项:
- 全局状态管理:对于跨页面的数据共享,使用全局状态管理工具能有效避免组件之间直接传递数据的问题。
- 性能优化:在使用状态管理工具时,注意避免不必要的状态更新,尤其是在大规模应用中,应当进行性能调优以减少重渲染。
3. 性能优化
Taro 封装了小程序的框架,尽管提供了便捷性,但这也带来了一些性能开销。性能优化是确保 Taro 应用顺畅运行的关键。
注意事项:
- 懒加载:对不常用的组件或页面使用懒加载技术,减少初次渲染的压力。
- 分包加载:对于较大的应用,可以使用分包加载(特别是在微信小程序中)来优化启动速度。
- 减少组件嵌套:过多的组件嵌套会增加渲染负担,尽量保持组件结构的扁平化。
- 长列表优化:对长列表(如商品列表、评论列表)使用虚拟列表技术,避免一次性加载大量数据。
4. 开发工具与调试
Taro 提供了开发者工具来简化开发和调试过程,但在实际项目中,调试复杂问题时可能会遇到一些挑战。
注意事项:
- 使用 Taro CLI:Taro CLI 提供了丰富的命令行工具,帮助你快速生成项目、构建应用和调试代码。
- 跨平台调试:确保在每个平台的开发者工具中进行调试,并使用平台特有的工具,如微信小程序开发者工具和支付宝 IDE。
- 源代码映射:使用源代码映射(Source Map)功能来调试编译后的代码,方便追踪错误。
5. 小程序的限制
各个小程序平台都有其独特的限制,如包大小限制、API 速率限制、页面栈限制等。在开发过程中,必须遵守这些限制,以免在发布或运行时遇到问题。
注意事项:
- 包大小限制:微信和支付宝小程序对主包和分包的大小都有严格限制,尽量减少不必要的资源,压缩图片和代码。
- 页面栈限制:小程序的页面栈深度通常为 10 层,超出后可能会引发崩溃,需要合理设计页面的跳转逻辑。
- 请求速率限制:各平台对网络请求的速率和并发量都有要求,应当合并请求或使用请求队列来控制频率。
6. 国际化支持
如果应用需要支持多语言,Taro 提供了基础的国际化支持,但由于不同平台的特性,可能需要额外的配置和适配。
注意事项:
- 文本管理:使用统一的国际化管理工具,如 i18next 或自定义的国际化方案。
- 格式化问题:不同平台对日期、货币等格式化方式支持不同,使用第三方库(如
moment.js
)来统一格式化操作。 - 右到左(RTL)布局:如果应用需要支持 RTL 语言,确保在每个平台上都正确实现 RTL 布局。
7. 版本管理与更新
在多端开发中,版本管理和应用更新也是需要特别注意的地方。不同平台对更新机制的支持不尽相同,需要有针对性的处理策略。
注意事项:
- 小程序版本控制:在不同平台上发布新版本时,注意同步版本号,并在应用内做好版本控制。
- 热更新:Taro 目前不直接支持热更新,但可以通过后台配置管理、版本检测等方式实现相似的效果。
- 数据迁移:在更新过程中,可能需要进行数据迁移(如数据库结构变更),确保用户数据的完整性。
8. 社区与文档支持
Taro 社区活跃,文档也在不断完善,但在实际开发中遇到问题时,了解如何有效利用社区资源和官方文档也很重要。
注意事项:
- 官方文档:Taro 官方文档非常详细,建议在遇到问题时首先查阅文档,以获取官方推荐的解决方案。
- 社区支持:遇到文档未覆盖的问题,可以到 GitHub Issues、Gitter 或其他开发者社区寻求帮助。
- 示例项目:参考官方或社区提供的示例项目,可以帮助你快速上手并解决常见问题。
通过注意以上关键点,开发者可以更好地利用 Taro 的跨平台能力,同时避免常见的坑和问题,提升开发效率和应用质量。
官方文档地址: docs.taro.zone/docs
5. 结语
结语
Taro 的出现不仅解决了多端开发的复杂性问题,还大大提升了开发效率和代码的可维护性。通过统一的 API 和组件库,Taro 让开发者无需深入了解每个小程序平台的细节,即可快速构建和部署功能丰富的应用。
然而,正如任何工具或框架一样,Taro 并非完美无缺。跨平台开发固有的挑战仍然存在,包括平台间的差异、性能优化需求、状态管理复杂性,以及不同平台特有的限制。这些挑战提醒我们,尽管 Taro 能够极大地简化开发流程,但在开发过程中依然需要细致地进行测试、调优和适配工作。
从选择 Taro 作为开发框架,到深入了解其核心功能和最佳实践,再到避开潜在的坑和问题,需要全方位地掌握 Taro 的使用技巧。通过合理使用 Taro 的能力,结合自身项目的实际需求,开发者可以实现跨平台的一致用户体验,并保持代码库的可扩展性和维护性。
展望未来,随着小程序生态的不断发展和 Taro 框架的持续更新,开发者将会有更多的机会去探索和创新。Taro 的灵活性和强大的跨平台能力为我们提供了无限的可能,无论是在支付宝小程序、微信小程序,还是在更多的平台上,都能为用户带来一致、流畅的体验。
在使用 Taro 进行多端开发的过程中,我们不仅仅是编写代码,更是在打造一款能够适应多平台需求的高质量应用。通过不断学习和实践,我们能够充分发挥 Taro 的潜力,让每一个用户无论在哪个平台上使用我们的应用,都能感受到同样的便捷与愉悦。
最终,无论是初次使用 Taro 的新手,还是已经熟练掌握的老手,持续学习和优化始终是提升开发能力的关键。Taro 为我们提供了强大的工具,剩下的就是如何用好这些工具,创造出色的产品。相信随着更多开发者的加入和贡献,Taro 生态将会更加繁荣,为跨平台开发带来更多的可能性和惊喜。
作者:洞窝-海林
来源:juejin.cn/post/7408138735798616102
优雅实现任意形状的水球图,领导看了都说好
前言
翌日
我吃着早餐,划着水。
不一会,领导走了过来。
领导:小伙子,你去XX项目实现一个设备能源图,要求能根据剩余能量显示水波高低。
我: 啊?我?这个项目我没看过代码。
领导:任务有点急,你自己安排时间吧,好好搞,给你争取机会。
我:好吧。(谁叫咱只是一个卑微的打工人,只能干咯😎👌😭。)
分析
看到图,类似要实现这样一个立方体形状的东西,然后需要根据剩余电量显示波浪高低。
我心想,这不简单吗,这不就是一个水球图,恰好之前看过echarts中有一个水球图的插件。
想到这,我立马三下五除二,从echarts官网上下载了这个插件,心想下载好了就算搞定了。
波折
哪知,这个需求没有想象中的那么简单,UI设计的图其实是一个伪3D立方体,通过俯视实现立体效果。并且A面和B面都要有波浪。
这就让我犯了难,因为官方提供的demo没有这样的形状,最相近也就是最后一个图案。
那把两个最后一个图案拼接起来,组成A、B面,不就可以达到我们的效果了吗,然后最后顶上再放一个四边形C面,不就可以完美解决了。
想法是好的,但是具体思考实践方案起来就感觉麻烦了。根据我平时的解决问题的经验:如果方案实践起来,发现很麻烦就说明方法错了,需要换个方向思考。
于是我开始翻阅官方文档,找到了关于形状的定义shape属性。
救世主shape
它支持三种方式定义形状
- 最基础的是,直接编写属性内置的值,支持一些内置的常见形状如:
'circle'
,'rect'
,'roundRect'
,'triangle'
,'diamond'
,'pin'
,'arrow'
- 其次,它还支持根据容器形状填充
container
,具体来说就是可以填充满你的渲染容器。比如一个300X300的div,设置完shape:'container
'后,他的渲染区域就会覆盖这个div大小。此时,你可以调整div的形状实现想要的图案,比如
我们用两个div演示,我们将第二个div样式设置为
border-radius: 100%;
第一个图形就为方形,第二个就成为了经典圆形水球图。我们可以根据需要自行让div变成我们想要的形状。
- 最后,也就是我们这次要说的重点,他支持SVG
path://
路径。
我们可以看到第二种方式实现复杂的图形有局促性,第三种方式告诉我们他支持svg的path路径时,这就给了我们非常多的可能性,我们可以通过路径绘制任意想要的图形。
看到这个属性后,岂不是只需要将UI切的svg文件中的path传入进去就可以实现这个效果了?随后开始了分析。
我们的图形可以由三个四边形构成,每个四边形四个顶点,合计12个顶点。
从svg文件我们可以得到如下内容
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="362.74609375"
height="513.7080078125" viewBox="0 0 362.74609375 513.7080078125" fill="none">
<path d="M128.239 177.367L128.239 512.015L361.056 397.17L361.056 76.6519L128.239 177.367Z" stroke="rgba(0, 0, 0, 1)"
stroke-width="3.3802816901408472" stroke-linejoin="round" fill="#FFFFFF">
</path>
<path d="M1.69043 107.409L128.231 177.364L361.048 76.6482L229.656 1.69043L1.69043 107.409Z"
stroke="rgba(0, 0, 0, 1)" stroke-width="3.3802816901408472" stroke-linejoin="round" fill="#FFFFFF">
</path>
<path d="M1.69043 107.413L1.69043 442.06L128.231 512.015L128.231 177.368L1.69043 107.413Z" stroke="rgba(0, 0, 0, 1)"
stroke-width="3.3802816901408472" stroke-linejoin="round" fill="#FFFFFF">
</path>
</svg>
我们可以发现,它是由三个path路径构成,而我们的水球图只支持一个path://开头的path路径字符串。解决这个问题也很简单,我们只需要将三个路径给他合并在一起就可以了,我们就可以实现这种伪3D效果了。
如此,我们便得到了路径。
path://M128.239 177.367L128.239 512.015L361.056 397.17L361.056 76.6519L128.239 177.367Z M1.69043 107.409L128.231 177.364L361.048 76.6482L229.656 1.69043L1.69043 107.409Z M1.69043 107.413L1.69043 442.06L128.231 512.015L128.231 177.368L1.69043 107.413Z
效果如图:
哇瑟,真不赖,感觉已经实现百分之七八十了,内心已经在幻想,领导看到实现后大悦,说不愧是你呀,然后给我升职加薪,推举升任CTO,赢取白富美,翘着腿坐在库里南里的场景了。
等等!我的线条呢?整个水球图(也不能叫球了,水立方?)只有外轮廓,看不到线条棱角了,其实我觉得现在这种情况还蛮好看的,但是为了忠于UI设计的还原,还是得另寻办法,可不能让人家说菜,这么简单都实现不了。
好在,解决这个问题也很简单,官方提供了边框的配置项。(真是及时雨啊)
backgroundStyle: {
borderColor: "#000",// 边框的颜色
borderWidth: 2, // 边框线条的粗细
color: "#fff", // 背景色
},
配置完边框线的粗细后,啊哈!这不就是我们想要的效果吗?
最后还差一点,再将百分比显示出来,如下图,完美!
拓展
然后我们类比别的图案也是类似,也是只需要将多个path组合在一起就可以了。
悟空
某支
钢铁侠
是不是看起来非常的炫酷,实现方式也是一样,我们只需要将这些图案的path路径传入这个shape属性就行了,然后调整适当的颜色。
注意点:
- 如果图形中包含填充的区域,可以让UI小姐姐,把填充改成线条模拟,用多个线条组成一个面模拟,类似微积分。
- 图形的样式取决于path路径,水球图只支持路径,因此路径上的颜色不能单独设置了,只能通过配置项配置一个整体的颜色。
- 关于svg矢量图标来源,可以上素材网站寻找,如我比较喜欢用的字节图标库、阿里图标库等等
思考
上面实现的水球图有一点让我十分在意,就是图案的是怎么做到根据波浪是否遮挡文字,改变文字的颜色,它做到了即使水球图的波浪漫过了文字,文字会呈现白色,不会因为水漫过后,文字颜色与水球图颜色一致,导致文字不可见。
这个特性也太酷了吧,对于用户体验来说大大增强。
由于强烈的好奇心,我开始研究源码这个是怎么实现的。
看了源码后,恍然大悟,原来是这样的
- 绘制背景
- 绘制内层的文本
- 绘制波浪
- 设置裁剪区域,将波浪覆盖的内层文本部分裁剪掉,留下没有被裁减的地方。(上半部分绿字)
- 绘制外层文本,由于设置了裁剪区域,之后的绘图被限制在裁剪区域。(裁剪区域的下半红字部分)
这样,我们就完成了这个神奇的效果。
下面我提供了一个demo,大家可以通过注释draw中的函数,就能很快明白是怎么一回事了。
值得注意的是
- 内层文本与外层文本的位置需要在同一个位置。
- 裁剪区域位置、大小和波浪的位置、大小重合。
总结
完成这个需求后,领导果然非常高兴给我升了职、加了薪,就在我得意洋洋幻想当上CTO的时候,中午闹钟响了,原来只是中午做了个梦,想到下午还有任务,就继续搬砖去了🤣。
来源:juejin.cn/post/7407995254077767707
这可能是见过的最好用的弹幕库 🔥🔥
最近把五年前写的一个弹幕库给重构了一下,本来两年前就想着做这件事,但是其中有一段工作时间压力很大,所以就搁置了,导致没有时间来做这件事情,最近周末 + 晚上花一些时间重构了下,并把文档好好写了一下。言归正传,这篇文章会介绍部分这个弹幕库有的能力,如果正好符合你的需求或者感兴趣,可以帮忙点点 star 来支持一下。
我们有哪些能力
我们提供了灵活调整轨道,自定义弹幕和容器样式,弹幕运动算法等能力,还在提供非常丰富的钩子来让用户处理自定义的行为,只要你想要的,都能做到,本文档会简单介绍一些能力和一些功能的实现。
- 在线 demo: imtaotao.github.io/danmu/
- github: github.com/imtaotao/da…
- 官方文档:imtaotao.github.io/danmu/docum…
快速开始
对于一个开箱即用的 demo,可以非常简单的接入,如下所示:
import { create } from 'danmu';
const manager = create();
manager.mount('#root');
manager.startPlaying();
// 发送弹幕
manager.push('弹幕内容')
对轨道进行调整
我们对支持类似 CSS calc 表达式的能力,一些位置/宽高等信息都可以用表达式来计算。所以对于轨道来说可以很方便的进行调整。
number
:默认单位为px
。string
:表达式计算。支持(+
,-
,*
,/
)数学计算,只支持%
和px
两种单位。
// 例如,这里的 100% 是指容器宽度(如果是高度相关的配置 100% 就是容器的高度)
manager.setGap('(100% - 10px) / 5');
限制为顶部 3 条弹幕
// 如果我们希望轨道高度为 50px
manager.setTrackHeight('100% / 3');
// 如果不设置渲染区域,轨道的高度会根据默认的 container.height / 3 得到,
// 这可能导致轨道高度不是你想要的
manager.setArea({
y: {
start: 0,
// 3 条轨道的总高度为 150px
end: 150,
},
});
限制为中间 3 条弹幕
manager.setTrackHeight('100% / 3');
manager.setArea({
y: {
start: `50%`,
end: `50% + 150`,
},
});
限制为几条不连续的轨道
限制为几条不连续的轨道,除了要做和连续轨道的操作之外,还需要借助 willRender
这个钩子来实现。
// 如果我们希望轨道高度为 50px,并渲染 0,2,4 这几条轨道
manager.setTrackHeight('100% / 6');
// 设置容器的渲染区域
manager.setArea({
y: {
start: 0,
// 6 条轨道的总高度为 300px
end: 300,
},
});
manager.use({
willRender(ref) {
// 高级弹幕和轨道不强相关,没有 trackIndex 这个属性
if (ref.trackIndex === null) return ref;
// 如果为 1,3,5 这几条轨道就阻止渲染,并重新添加等待下次渲染
if (ref.trackIndex % 2 === 1) {
ref.prevent = true;
manager.unshift(ref.danmaku);
}
return ref;
},
});
自定义渲染
弹幕和容器都允许自定义的渲染样式,你可以很方便的做到。
自定义弹幕的样式
1. 通过 manager.setStyle
来设置
import { create } from 'danmu';
// 需要添加的样式
const styles = {
color: 'red',
fontSize: '15px',
// .
};
const manager = create();
// 后续渲染的弹幕和当前已经渲染的弹幕会设置上这些样式。
for (const key in styles) {
manager.setStyle(key, styles[key]);
}
2. 通过 danamaku.setStyle
来设置
import { create } from 'danmu';
// 需要添加的样式
const styles = {
color: 'red',
fontSize: '15px',
// .
};
// 初始化的时候添加钩子处理,这样当有新的弹幕渲染时会自动添加上这些样式
const manager = create({
plugin: {
$moveStart(danmaku) {
for (const key in styles) {
danmaku.setStyle(key, styles[key]);
}
// 你也可以在这里给弹幕 DOM 添加 className
danmaku.node.classList.add('className');
},
},
});
// 对当前正在渲染的弹幕添加样式
manager.asyncEach((danmaku) => {
for (const key in styles) {
danmaku.setStyle(key, styles[key]);
}
});
自定义容器样式
import { create } from 'danmu';
// 需要添加的样式
const styles = {
background: 'red',
// .
};
const manager = create({
plugin: {
// 你可以在初始化的时候添加钩子处理
init(manager) {
for (const key in styles) {
manager.container.setStyle(key, styles[key]);
}
// 你也可以在这里给容器 DOM 添加 className
manager.container.node.classList.add('className');
},
},
});
// 或者直接调用 api
for (const key in styles) {
manager.container.setStyle(key, styles[key]);
}
高级弹幕的示例
本章节将介绍如何将弹幕固定在某一位置,以 top
和 left
这两个位置举例。由于我们需要自定义位置,所以我们需要使用高级弹幕的能力。
将弹幕固定在顶部
// 这条弹幕将会居中距离顶部 10px 的位置悬停 5s
manager.pushFlexibleDanmaku('弹幕内容', {
duration: 5000,
direction: 'none',
position(danmaku, container) {
return {
x: `50% - ${danmaku.getWidth() / 2}`,
y: 10, // 具体容器顶部的距离为 10px
};
},
});
固定在顶部第 2 条轨道上
// 这条弹幕将会在第二条轨道居中的位置悬停 5s
manager.pushFlexibleDanmaku('弹幕内容', {
duration: 5000,
direction: 'none',
position(danmaku, container) {
// 渲染在第 3 条轨道中
const { middle } = manager.getTrackLocation(2);
return {
x: `50% - ${danmaku.getWidth() / 2}`,
y: middle - danmaku.getHeight() / 2,
};
},
});
将弹幕固定在左边
// 这条弹幕将会在容器中间距离左边 10px 的地方停留 5s
manager.pushFlexibleDanmaku('弹幕内容', {
duration: 5000,
direction: 'none',
position(danmaku, container) {
// 渲染在第 3 条轨道中
const { middle } = manager.getTrackLocation(2);
return {
x: 10,
y: `50% - ${danmaku.getHeight() / 2}`,
};
},
});
发送带图片的弹幕
要让弹幕里面能够携带图片,要在弹幕的节点内部添加自定义的内容,实际上不止图片,你可以往弹幕的节点里面添加任何的内容。
本章节的组件以 React 来实现演示。
开发弹幕组件
export function Danmaku({ danmaku }) {
return (
<div>
<img src="https://abc.jpg" />
{danmaku.data}
</div>
);
}
渲染弹幕
import ReactDOM from 'react-dom/client';
import { create } from 'danmu';
import { Danmaku } from './Danmaku';
const manager = create<string>({
plugin: {
// 将组件渲染到弹幕的内置节点上
$createNode(danmaku) {
ReactDOM.createRoot(danmaku.node).render(<Danmaku danmaku={danmaku} />);
},
},
});
编写一个插件
编写一个插件是很简单的,但是借助内核暴露出来的钩子
和 API
,你可以很轻松的实现强大且定制化的需求。由于内核没有暴露出来根据条件来实现过滤弹幕的功能,原因在于内核不知道弹幕内容的数据结构,这和业务的诉求强相关,所以我们在此通过插件来实现精简弹幕的功能用来演示。
编写一个插件
- 你编写的插件应当取一个
name
,以便于调试定位问题(注意不要和其他插件冲突了)。 - 插件可以选择性的声明一个
version
,这在你的插件作为独立包发到npm
上时很有用。
export function filter({ userIds, keywords }) {
return (manager) => {
return {
name: 'filter-keywords-or-user',
version: '1.0.0', // version 字段不是必须的
willRender(ref) {
const { userId, content } = ref.danmaku.data.value;
console.log(ref.type); // 可以根据此字段来区分是普通弹幕还是高级弹幕
if (userIds && userIds.includes(userId)) {
ref.prevent = true;
} else if (keywords) {
for (const word of keywords) {
if (content.includes(word)) {
ref.prevent = true;
break;
}
}
}
return ref;
},
};
};
}
注册插件
你需要通过 mananger.use()
来注册插件。
import { create } from 'danmu';
const manager = create<{
userId: number;
content: string;
}>();
manager.use(
filter({
userIds: [1],
keywords: ['菜'],
}),
);
发送弹幕
- ❌ 会被插件阻止渲染
manager.push({
userId: 1,
content: '',
});
- ❌ 会被插件阻止渲染
manager.push({
userId: 2,
content: '你真菜',
});
- ✔️ 不会被插件阻止渲染
manager.push({
userId: 2,
content: '',
});
- ✔️ 不会被插件阻止渲染
manager.push({
userId: 2,
content: '你真棒',
});
总结
本文档只是简单介绍了下现在的部分能力,更详细的文档在官网可以查看,如果对你的业务或者学习有帮助的,给个 star 支持一下作者,也欢迎大家评论探讨(不止弹幕,哈哈)。
来源:juejin.cn/post/7408364808607957002
别再用模板语法和'+'来拼接url了
在前端开发中,我们经常需要处理URL,例如在发起HTTP请求时构建API端点,或在页面导航时构建动态链接、拼接动态参数。
过去,我们习惯于使用模板语法和字符串拼接来构建这些URL,现在在代码中依然可以看到新的代码还在使用这种方法。
但这种方法不仅容易出错,而且在维护和阅读代码时也不够直观。本文将介绍更现代和更安全的URL构建方法,并展示如何在实际项目中应用它们。
传统上,我们常使用字符串拼接或模板语法来构建URL。例如:
const baseUrl = "https://api.example.com";
const userId = 12345;
const endpoint = baseUrl + "/users/" + userId + "/details";
console.log(endpoint); // "https://api.example.com/users/12345/details"
import { TYPE_EDIT } from '@/constants/type.ts'
const type = TYPE_EDIT
const url = 'https://api.example.com/userInfo'
const newUrl = url + '?type=' + type + '&model=1&share=1&fromModule=wechat'
console.log(urlUrl) // https://api.example.com/userInfo?type=TYPE_EDIT&model=1&share=1&fromModule=wechat
或使用ES6模板字符串:
const baseUrl = "https://api.example.com";
const userId = 12345;
const endpoint = `${baseUrl}/users/${userId}/details`;
console.log(endpoint); // "https://api.example.com/users/12345/details"
import { TYPE_EDIT } from '@/constants/type.ts'
const type = TYPE_EDIT
const url = 'https://api.example.com/userInfo'
const newUrl = url + `?type=${type}&model=1&share=1&fromModule=wechat`
console.log(urlUrl) // https://api.example.com/userInfo?type=TYPE_EDIT&model=1&share=1&fromModule=wechat
虽然模板字符串在一定程度上提高了可读性,但这种方法仍存在几个问题:
- 易读性差:当URL变得复杂时,拼接和模板字符串会变得难以阅读和维护(现阶段已经难以阅读和维护了)。
- 错误处理麻烦:拼接过程中如果有任何错误(例如漏掉斜杠),可能会导致难以排查的BUG。
- 缺乏类型安全:拼接字符串无法提供编译时的类型检查,容易引入错误。
使用URL构造器
为了解决这些问题,现代JavaScript引入了URL构造器,可以更优雅和安全地处理URL。URL构造器提供了一种更结构化和直观的方法来构建和操作URL。
基本用法
const baseUrl = "https://api.example.com";
const userId = 12345;
const url = new URL(`/users/${userId}/details`, baseUrl);
console.log(url.href); // "https://api.example.com/users/12345/details"
添加查询参数
URL构造器还提供了一种简便的方法来添加和操作查询参数:
const baseUrl = "https://api.example.com";
const userId = 12345;
const url = new URL(`/users/${userId}/details`, baseUrl);
url.searchParams.append('type', 'EDIT');
url.searchParams.append('module', 'wechat');
console.log(url.href); // "https://api.example.com/users/12345/details?type=EDIT&module=wechat"
拼接数组参数
假设我们有一个URL,需要将一个数组作为查询参数添加到URL中。
const baseUrl = 'https://example.com';
const url = new URL(baseUrl);
const arrayParam = ['value1', 'value2', 'value3'];
// 将数组转换为逗号分隔的字符串
url.searchParams.set('array', arrayParam.join(','));
console.log(url.toString()); // https://example.com/?array=value1,value2,value3
解析数组参数
当我们获取URL并需要解析其中的数组参数时,可以使用URLSearchParams
对象进行解析。
const urlString = 'https://example.com/?array=value1,value2,value3';
const url = new URL(urlString);
const arrayParamString = url.searchParams.get('array');
// 将逗号分隔的字符串转换回数组
const arrayParam = arrayParamString ? arrayParamString.split(',') : [];
console.log(arrayParam); // ['value1', 'value2', 'value3']
以下是一个完整示例,包括拼接和解析数组参数的操作:
// 拼接数组参数到URL
const baseUrl = 'https://example.com';
const url = new URL(baseUrl);
const arrayParam = ['value1', 'value2', 'value3'];
url.searchParams.set('array', arrayParam.join(','));
console.log(url.toString()); // https://example.com/?array=value1,value2,value3
// 解析数组参数从URL
const urlString = url.toString();
const parsedUrl = new URL(urlString);
const arrayParamString = parsedUrl.searchParams.get('array');
const parsedArrayParam = arrayParamString ? arrayParamString.split(',') : [];
console.log(parsedArrayParam); // ['value1', 'value2', 'value3']
处理多个同名参数
有时我们可能会遇到需要处理多个同名参数的情况,例如?array=value1&array=value2&array=value3
。可以使用URLSearchParams
的getAll
方法:
// 拼接多个同名参数到URL
const url = new URL(baseUrl);
const arrayParam = ['value1', 'value2', 'value3'];
arrayParam.forEach(value => url.searchParams.append('array', value));
console.log(url.toString()); // https://example.com/?array=value1&array=value2&array=value3
// 解析多个同名参数从URL
const urlString = url.toString();
const parsedUrl = new URL(urlString);
const parsedArrayParam = parsedUrl.searchParams.getAll('array');
console.log(parsedArrayParam); // ['value1', 'value2', 'value3']
通过这些方法,可以更加优雅和简便地处理URL中的数组参数,提升代码的可读性和可维护性。
但实际情况往往比上面的示例更复杂,比如参数是一个对象、根据实际情况来设置参数的值、要处理undefined
、'undefined'
、0
、'0'
、Boolean
、'true'
、NaN
等不同类型和异常的值,每次使用时都去处理显然是不合理的,这时候就可以将拼接和移除参数的函数封装成方法来使用。
/**
* 获取URL查询参数并返回一个对象,支持数组
* @param {string} urlString - 需要解析的URL字符串
* @returns {Object} - 包含查询参数的对象
*/
function getURLParams(urlString) {
const url = new URL(urlString);
const params = new URLSearchParams(url.search);
const result = {};
for (const [key, value] of params.entries()) {
if (result[key]) {
if (Array.isArray(result[key])) {
result[key].push(value);
} else {
result[key] = [result[key], value];
}
} else {
result[key] = value;
}
}
return result;
}
/**
* 设置URL的查询参数,支持对象和数组
* @param {string} urlString - 基础URL字符串
* @param {Object} params - 需要设置的查询参数对象
* @returns {string} - 带有查询参数的URL字符串
*/
function setURLParams(urlString, params) {
const url = new URL(urlString);
const searchParams = new URLSearchParams();
for (const key in params) {
if (params.hasOwnProperty(key)) {
const value = params[key];
if (Array.isArray(value)) {
value.forEach(val => {
if (val !== undefined && !Number.isNaN(val)) {
searchParams.append(key, val);
} else {
console.warn(`Warning: The value of "${key}" is ${val}, which is invalid and will be ignored.`);
}
});
} else if (value !== undefined && !Number.isNaN(value)) {
searchParams.append(key, value);
} else {
console.warn(`Warning: The value of "${key}" is ${value}, which is invalid and will be ignored.`);
}
}
}
url.search = searchParams.toString();
return url.toString();
}
// 测试用例
const baseUrl = 'https://example.com';
// 测试 getURLParams 方法
const testUrl = 'https://example.com/?param1=value1¶m2=value2¶m2=value3';
const parsedParams = getURLParams(testUrl);
console.log(parsedParams); // { param1: 'value1', param2: ['value2', 'value3'] }
// 测试 setURLParams 方法
const params = {
param1: 'value1',
param2: ['value2', 'value3'],
param3: undefined,
param4: NaN,
param5: 'value5',
param6: 0,
};
const newUrl = setURLParams(baseUrl, params);
console.log(newUrl); // 'https://example.com/?param1=value1¶m2=value2¶m2=value3¶m5=value5'
以上代码是根据掌握的知识编写的基本使用示例,像这种工作完全不用自己来写,现在已经有非常成熟的库可以直接使用。
qs
npmjs http://www.npmjs.com/package/qs
它是开源免费项目,每周下载量将近7千万,支持任意字符,对象进行解析和拼接,支持@types/qs
,导入后11.3k,建议打包编译时排除在打包文件外用cdn替代。
query-string
npmjs http://www.npmjs.com/package/que…
它是开源免费项目,每周下载量达千万,支持任意字符、对象进行解析和拼接,支持ts,导入后仅2.5k字节。
PC和H5如果使用了微前端,建议一开始打包时就将依赖排除在打包文件外,用cdn链接来替代,仅加载一次就可以缓存下来,可以加速页面加载、减小打包文件大小。
当然更多时候我们在编写h5、小程序项目的时候并不希望为了一个url解析参数和拼接参数的功能而引入一整个依赖。
这时候一个简单的解析和拼接的函数就可以搞定。
方法有多种实现方式,下面还有一种通过正则来实现的,但下面拼接的时候会忽略数字0,所以参数一定要用字符串。
/**
* 合并查询参数到 URL 的函数
* 将给定的查询对象 Query 合并到指定的 URL 中
*
* @param {Object} query - 要合并到 URL 中的查询对象
* @param {string} url - 作为基础的 URL,默认为当前页面的 URL
* @returns {string} 生成的合并查询参数后的新 URL
*/
export function getUrlMergeQuery(query = {}, url) {
url = url || window.location.href
const _orgQuery = getQueryObject(url)
const _query = {..._orgQuery,...query }
let _arr = []
for (let key in _query) {
const value = _query[key]
if (value) _arr.push(`${key}=${encodeURIComponent(_query[key])}`)
}
return `${url.split('?')[0]}${_arr.length > 0? `?${_arr.join('&')}` : ''}`
}
/**
* 从 URL 中提取查询参数对象
*
* @param {string} [url=window.location.href] - 要解析的 URL 字符串。如果未提供,则使用当前页面的 URL
* @returns {Object} - 包含提取的查询参数的对象
*/
export function getQueryObject(url = window.location.href) {
const search = url.substring(url.lastIndexOf('?') + 1);
const obj = {};
const reg = /([^?&=]+)=([^?&=]*)/g;
search.replace(reg, (rs, $1, $2) => {
const name = decodeURIComponent($1);
let val = decodeURIComponent($2);
val = String(val);
obj[name] = val;
return rs;
});
return obj;
}
你的项目中一定提供了合适的方法,不要在用字符串拼接的方法来拼接参数了。
来源:juejin.cn/post/7392788843097931802
如何访问数组最后一个元素
原文链接:blog.ignacemaes.com/the-easy-wa…
在JavaScript中,想要获取数组的最后一个元素并不是一件简单的事情,尤其是和一些其他编程语言相比。比如说,在Python里,我们可以通过负数索引轻松访问数组的最后一个元素。但是在JavaScript的世界里,负数索引这一招就不管用了,你必须使用数组长度减一的方式来定位最后一个元素。
比如说,我们有一个数组,里面装着一些流行的前端框架:
const frameworks = ['Nuxt', 'Remix', 'SvelteKit', 'Ember'];
如果我们尝试用负数索引去访问它:
frameworks[-1];// 这里是不会得到结果的
你会发现,这样做是行不通的,它不会返回任何东西。正确的做法是使用数组的长度减一来获取最后一个元素:
frameworks[frameworks.length - 1];// 这样就能拿到'Ember'了
at方法
为了让数组索引变得更加灵活,JavaScript引入了一个新方法——at
。这个方法可以让你通过索引来获取数组中的元素,并且支持负数索引。
frameworks.at(-1);// 这样就能直接拿到'Ember'了
不过,需要注意的是,at
方法只是一个访问器方法,它并不能用来改变数组的内容。如果你想要改变数组,还是得用传统的方括号方式。
// 这样是不行的
frameworks.at(-1) = 'React';
// 正确的改变数组的方法是这样的
frameworks[frameworks.length - 1] = 'React';
with方法
另外,如果你想要改变数组的元素并且得到一个新的数组,而不是改变原数组,JavaScript还提供了一个with
方法。这个方法可以帮你做到这一点,但是它会返回一个新的数组,原数组不会被改变。
// 这样会返回一个新的数组,原数组不变
frameworks.with(-1, 'React');
但是从2023年7月开始,它已经在主流浏览器中得到了支持。Node.js从20.0.0版本开始也支持了这个方法。
使用with
方法,你可以非常方便地修改数组中的元素,并且不用担心会影响到原始数组。这就好比是你在做饭的时候,想要尝尝味道,但又不想直接从锅里尝,于是你盛出一小碗来试味,锅里的菜还是原封不动的。
const updatedFrameworks = frameworks.with(-1, 'React');
// updatedFrameworks 就是 ['Nuxt', 'Remix', 'SvelteKit', 'React']
// 而 frameworks 仍然是原来的数组 ['Nuxt', 'Remix', 'SvelteKit', 'Ember']
兼容性
现在,我们来聊聊这两个方法在浏览器中的兼容性。at
方法从2022年开始已经在主流浏览器中得到了支持,Node.js的当前所有长期支持版本也都支持这个方法。
如果你需要在老旧的浏览器上使用这些方法,别担心,core-js
提供了相应的polyfill。
这样的设计思路,其实是在鼓励我们写出更加模块化和可维护的代码。你不需要担心因为修改了一个元素而影响到整个数组的状态,这对于编写清晰、可靠的代码是非常有帮助的。
如果你需要在一些比较老的浏览器上使用这些方法,你可能需要引入一个polyfill来填补浏览器的不足。core-js
这个库就提供了这样的功能,它可以让你的代码在不同的环境中都能正常运行。
总结
总结一下,at
方法和with
方法为我们在JavaScript中操作数组提供了更多的便利。它们让我们可以用一种更加直观和灵活的方式来访问和修改数组,同时也保持了代码的清晰和模块化。虽然这些方法是近几年才逐渐被引入的,但是它们已经在现代浏览器中得到了很好的支持。如果你的项目需要在老旧的浏览器上运行,记得使用polyfill来确保你的代码能够正常工作。这样,无论是新手还是经验丰富的开发者,都能够轻松地利用这些新特性来提升我们的编程体验。
来源:juejin.cn/post/7356446170477215785
用electron写个浏览器给自己玩
浏览器这种东西工程量很唬人,但是有了electron+webview我们就相当于只需要干组装的活就可以了,而且产品目标就是给自己玩,
成品的效果
😄本来想写成专业的技术博客,但是发现大家好像对那种密密麻麻,全是代码的技术博客不感兴趣,我就挑重点来写吧。
下载拦截功能
下载逻辑如果不做拦截处理的话,默认就是我们平常写web那种弹窗的方式,既然是浏览器肯定不能是那样的。
electron中可以监听BrowserWindow的页面下载事件,并把拿到的下载状态传给渲染线程,实现类似浏览器的下载器功能。
//这个global.WIN = global.WIN = new BrowserWindow({ ...})
global.WIN.webContents.session.on('will-download', (evt, item) => {
//其他逻辑
item.on('updated', (evt, state) => {
//实时的下载进度传递给渲染线程
})
})
页面搜索功能
当时做这个功能的时候我就觉得完了,这个玩意看起来太麻烦了,还要有一个的功能这不是头皮发麻啊。
查资料和文档发现这个居然是webview内置的功能,瞬间压力小了很多,我们只需要出来ctrl+f的时候把搜索框弹出来这个UI就可以了,关键字变色和下一个都是内部已经实现好了的。
function toSearch() {
let timer
return () => {
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
if (keyword.value) {
webviewRef.value.findInPage(keyword.value, { findNext: true })
} else {
webviewRef.value.stopFindInPage('clearSelection')
}
}, 200)
}
}
function closeSearch() {
showSearch.value = false
webviewRef.value.stopFindInPage('clearSelection')
}
function installFindPage(webview) {
webviewRef.value = webview
webviewRef.value.addEventListener('found-in-page', (e) => {
current.value = e.result.activeMatchOrdinal
total.value = e.result.matches
})
}
当前标签页打开功能
就是因为chrome和edge这些浏览器每次使用的时候开非常多的标签,挤在一起,所以我想这个浏览器不能主动开标签,打开了一个标签后强制所有的标签都在当前标签覆盖。
app.on('web-contents-created', (event, contents) => {
contents.setWindowOpenHandler((info) => {
global.WIN?.webContents.send('webview-url-is-change')
if (info.disposition === 'new-window') {
return { action: 'allow' }
} else {
global.WIN?.webContents.send('webview-open-url', info.url)
return { action: 'deny' }
}
})
})
渲染线程监听到webview-open-url后也就是tart="_blank"的情况,强制覆盖当前不打开新窗口
ipcRenderer.on('webview-open-url', (event, url) => {
try {
let reg = /http|https/g
if (webviewRef.value && reg.test(url)) {
webviewRef.value.src = url
}
} catch (err) {
console.log(err)
}
})
标签页切换功能
这里的切换是css的显示隐藏,借助了vue-router
这里我们看dom就能清晰的看出来。
地址栏功能
地址栏支持输入url直接访问链接、支持关键字直接打开收藏的网站、还支持关键字搜索。优先级1打开收藏的网页 2访问网站 3关键字搜索
function toSearch(keyword) {
if (`${keyword}`.length === 0) {
return false
}
// app搜索
if (`${keyword}`.length < 20) {
let item = null
const list = [...deskList.value, ...ALL_DATA]
for (let i = 0; i < list.length; i++) {
if (
list[i].title.toUpperCase().search(keyword.toUpperCase()) !== -1 &&
list[i].type !== 'mini-component'
) {
item = list[i]
break
}
}
if (item) {
goApp(item)
return false
}
}
// 网页访问
let url
if (isUrl(keyword)) {
if (!/^https?:\/\//i.test(keyword)) {
url = 'http://' + keyword
} else {
url = keyword
}
goAppNewTab(url)
return false
} else {
// 关键字搜索
let searchEngine = localStorage.getItem('searchEngine')
searchEngine = searchEngine || CONFIG.searchEngine
url = searchEngine + keyword
if (!router.hasRoute('search')) {
router.addRoute({
name: 'search',
path: '/search',
meta: {
title: '搜索',
color: 'var(--app-icon-bg)',
icon: 'search.svg',
size: 1
},
component: WebView
})
keepAliveInclude.value.push('search')
}
router.push({
path: '/search',
query: { url }
})
setTimeout(() => {
Bus.$emit('toSearch', url)
}, 20)
}
}
桌面图标任意位置拖动
这个问题困扰了我很久,因为它不像电脑桌面大小是固定的,浏览器可以全屏也可以小窗口,如果最开始是大窗口然后拖成小窗口,那么图标就看不到了。后来想到我干脆给个中间区域固定大小,就可以解决这个问题了。因为固定大小出来起来就方便多了。这个桌面是上下两层
//背景格子
<div v-show="typeActive === 'me'" class="bg-boxs">
<div
v-for="(item, i) in 224" //这里有点不讲究了直接写死了
:key="item"
class="bg-box"
@dragenter="enter($event, { x: (i % 14) + 1, y: Math.floor(i / 14) + 1 })"
@dragover="over($event)"
@dragleave="leave($event)"
@drop="drop($event)"
></div>
</div>
// 桌面层
// ...
import { ref, computed } from 'vue'
import useDesk from '@/store/deskList'
import { storeToRefs } from 'pinia'
export default function useDrag() {
const dragging = ref(null)
const currentTarget = ref()
const desk = useDesk()
const { deskList } = storeToRefs(desk)
const { setDeskList, updateDeskData } = desk
function start(e, item) {
e.target.classList.add('dragging')
e.dataTransfer.effectAllowed = 'move'
dragging.value = item
currentTarget.value = e
console.log('开始')
}
let timer2
function end(e) {
dragging.value = null
e.target.classList.remove('dragging')
setDeskList(deskList.value)
if (timer2) {
clearTimeout(timer2)
}
timer2 = setTimeout(() => {
updateDeskData()
}, 2000)
}
function over(e) {
e.preventDefault()
}
let timer
function enter(e, item) {
e.dataTransfer.effectAllowed = 'move'
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
if (item?.x) {
dragging.value.x = item.x
dragging.value.y = item.y
}
}, 100)
}
function leave(e) {}
function drop(e) {
e.preventDefault()
}
return { start, end, over, enter, leave, drop }
}
东西太多了就先介绍这些了
安装包地址
也可以到官网后aweb123.com 如何进入微软商店下载,mac版本因为文件大于100mb没有传上去所以暂时还用不了。
来源:juejin.cn/post/7395389351641612300
【前端缓存】localStorage是同步还是异步的?为什么?
🧑💻 写在开头
点赞 + 收藏 === 学会🤣🤣🤣
首先明确一点,localStorage是同步的
🥝 一、首先为什么会有这样的问题
localStorage
是 Web Storage API 的一部分,它提供了一种存储键值对的机制。localStorage
的数据是持久存储在用户的硬盘上的,而不是内存。这意味着即使用户关闭浏览器或电脑,localStorage
中的数据也不会丢失,除非主动清除浏览器缓存或者使用代码删除。
当你通过 JavaScript 访问 localStorage
时,浏览器会从硬盘中读取数据或向硬盘写入数据。然而,在读写操作期间,数据可能会被暂时存放在内存中,以提高处理速度。但主要的特点是它的持久性,以及它不依赖于会话的持续性。
🍉 二、硬盘不是io设备吗?io读取不都是异步的吗?
是的,硬盘确实是一个 IO 设备,而大部分与硬盘相关的操作系统级IO操作确实是异步进行的,以避免阻塞进程。不过,在 Web 浏览器环境中,localStorage
的API是设计为同步的,即使底层的硬盘读写操作有着IO的特性。
js代码在访问 localStorage
时,浏览器提供的API接口通常会处于js执行线程上下文中直接调用。这意味着尽管硬盘是IO设备,当一个js执行流程访问 localStorage
时,它将同步地等待数据读取或写入完成,该过程中js执行线程会阻塞。
这种同步API设计意味着开发者在操作 localStorage
时不需要考虑回调函数或者Promise等异步处理模式,可以按照同步代码的方式来编写。不过,这也意味着如果涉及较多数据的读写操作时,可能对性能产生负面影响,特别是在主线程上,因为它会阻塞UI的更新和其他js的执行。
🍑 三、完整操作流程
localStorage
实现同步存储的方式就是阻塞 JavaScript 的执行,直到数据的读取或者写入操作完成。这种同步操作的实现可以简单概述如下:
- js线程调用: 当 JavaScript 代码执行一个
localStorage
的操作,比如localStorage.getItem('key')
或localStorage.setItem('key', 'value')
,这个调用发生在 js 的单个线程上。 - 浏览器引擎处理: 浏览器的 js 引擎接收到调用请求后,会向浏览器的存储子系统发出同步IO请求。此时 js 引擎等待IO操作的完成。
- 文件系统的同步IO: 浏览器存储子系统对硬盘执行实际的存储或检索操作。尽管操作系统层面可能对文件访问进行缓存或优化,但从浏览器的角度看,它会进行一个同步的文件系统操作,直到这个操作返回结果。
- 操作完成返回: 一旦IO操作完成,数据要么被写入硬盘,要么被从硬盘读取出来,浏览器存储子系统会将结果返回给 js 引擎。
- JavaScript线程继续执行: js 引擎在接收到操作完成的信号后,才会继续执行下一条 js 代码。
在同步的 localStorage
操作期间,由于 js 的单线程性质,整个线程会阻塞,即不会执行其他任何js代码,也不会进行任何渲染操作,直到 localStorage
调用返回。
🍒 四、localStorage限制容量都是因为同步会阻塞的原因吗?
- 资源公平分享:同一用户可能会访问大量不同的网站,如果没有限制,随着时间的积累,每个网站可能会消耗大量的本地存储资源。这样会导致本地存储空间被少数几个站点占用,影响到用户访问其他网页的体验。限制大小可以确保所有网站都有公平的存储机会。
- 防止滥用:如果没有存储限制,网站可能会滥用
localStorage
,存储大量数据在用户的设备上,这可能导致设备存储空间迅速耗尽,也可能侵犯用户的隐私。 - 性能限制:如之前提到的,
localStorage
的操作是阻塞的。如果网站能够存储大量数据,就会加剧读写操作对页面性能的影响。 - 存储效率:
localStorage
存储的是字符串形式的数据,不是为存储大量或结构化数据设计的。当尝试存储过多数据时,效率会降低。 - 历史和兼容性:5MB 的限制很早就已经被大多数浏览器实现,并被作为一个非正式的标准被采纳。尽管现在有些浏览器支持更大的
localStorage
,但出于跨浏览器兼容性的考虑,开发者通常会假设这个限制。 - 浏览器政策:浏览器厂商可能会依据自己的政策来设定限制,可能是出于提供用户更一致体验的角度,或者是出于管理用户数据的方便。
🍐 五、那indexDB会造成滥用吗?
虽然它们提供了更大的存储空间和更丰富的功能,但确实潜在地也可能被滥用。但是与相比 localStorage
增加了一些特性用来降低被滥用的风险:
- 异步操作:
IndexedDB
是一个异步API,即使它被用来处理更大量的数据,也不会像localStorage
那样阻塞主线程,从而避免了对页面响应性的直接影响。 - 用户提示和权限:对于某些浏览器,当网站尝试存储大量数据时,浏览器可能会弹出提示,要求用户授权。这意味着用户有机会拒绝超出合理范围的存储请求。
- 存储配额和限制:尽管
IndexedDB
提供的存储容量比localStorage
大得多,但它也不是无限的。浏览器会为IndexedDB
设定一定的存储配额,这个配额可能基于可用磁盘空间的一个百分比或者是一个事先设定的限额。配额超出时,浏览器会拒绝更多的存储请求。 - 更清晰的存储管理:
IndexedDB
的数据库形式允许有组织的存储和更容易的数据管理。用户或开发者可以更容易地查看和清理占用的数据。 - 逐渐增加的存储:某些浏览器实现
IndexedDB
存储时,可能会在数据库大小增长到一定阈值时,提示用户是否允许继续存储,而不是一开始就分配一个很大的空间。
🤖 六、一个例子简单测试一下
其实也不用测,平时写的时候你也没用异步的方式写localStorage吧,我们这里简单写个例子
html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Documenttitle>
head>
<body>
<script>
const testLocalStorage = () => {
console.log("==========> 设置localStorage之前");
localStorage.setItem('testLocalStorage', '我是同步的');
console.log("==========> 获取localStorage之前");
console.log('=========获取localStorage', localStorage.getItem('testLocalStorage'))
console.log("==========> 获取localStorage之后");
}
testLocalStorage()
script>
body>
html>
来源:juejin.cn/post/7359405716090011659
高德API花式玩法:租房辅助工具
前言
做gis的同学肯定知道等时圈这个东西,即:在一定时间内通过某种出行方式能到达的范围
通过一些计算,我们可以做一些好玩的事情,比如上图通过调用mapbox等时圈接口,计算在蓝色点位附近骑车17分钟可以到达哪些公司或学校。
可能由于国内交通情况更为复杂,做实时性较差的等时圈意义不大,所以高德地图提供了比较稳当的API:公交地铁到达圈
,其概念和等时圈类似,你可以选择地铁或公交出行,或者两者兼可,高德接口将计算出可达范围。
高德公交到达圈的好玩应用
用了很久的高德,发现这个功能好像没有被很好的应用,其实高德早在1.4的API中就提供了此功能,最近正好要换个房子,突然想到这个东西正好可以拿来做一个很棒的辅助。
由于我跟我对象上班的地方离得比较远,所以找个折中的地方租房是是很重要的,我准备查询到两个到达圈后,再计算一下重合部分,在重合部分找房,就准确多了。
使用AMap.ArrivalRange画出到达圈
首先我做了一个简单的页面
左上角的面板用来设置参数,可选地铁+公交
、地铁
、公交
三种方式,出行耗时最大支持60分钟(超过了接口会报错),位置需要传入经纬度。
初始化地图的步骤就不用说了,我讲一下这个小应用的使用逻辑。
首先,点击地图时,拾取该位置的经纬度,并通过逆地理接口获取到位置文本
function handleMapClick(e: any) {
if (!e.lnglat) return
if (currPositionList.value.length >= 2) {
autolog.log("最多添加 2 个位置", 'error') // 你不会想三个人一起住吧?
return
}
var lnglat = e.lnglat;
geocoder.getAddress(lnglat, (status: string, result: {
regeocode: any; info: string;
}) => {
if (status === "complete" && result.info === "OK") {
currPositionList.value.push({ name: result.regeocode.formattedAddress, lnglat: [lnglat.lng, lnglat.lat] })
}
});
}
可以看到,我把点选的位置信息,暂时存放到了currPositionList
里面,比如你在西二旗上班,而你女朋友在国贸,则点选后效果是这样的
左侧面板新增了两个位置,点击查询时,我将依次查询这两个到达圈,并渲染到地图上
function getArriveRange() {
let loopCount = 0
for (let item of currPositionList.value) {
arrivalRange.search(item.lnglat, currTime.value, (_status: any, result: { bounds: any; }) => {
map.remove(polygons);
if (!result.bounds) return
let currPolygons = []
loopCount++
for (let item of result.bounds) {
let polygon = new AMap.Polygon(polygonStyle[`normal${loopCount}` as "normal1" | "normal2"]);
polygon.setPath(item);
currPolygons.push(polygon)
}
map.add(currPolygons);
polygons.push({
lnglat: item.lnglat,
polygon: currPolygons,
bounds: result.bounds
})
if (loopCount === currPositionList.value.length) {
map.setFitView();
}
}, { policy: currStrategy.value });
}
}
由于接口调用方式是以回调函数的形式返回的,所以我这里记录了一下回调次数,当次数满足后,再去调整视角。这段逻辑运行之后,将是如下结果:
很遗憾,你和你的女朋友,下班后的一个小时内见不成面了,这意味着,如果找一个折中的地方租房,你们上班单程通勤,无论如何都超过一个小时了,如果想尽量接近一个小时,那么看下两者的交汇处
大钟寺地铁站将会是个很好的选择。
这样仍需要我们手动去观察,那么能不能算一下两者的交集呢?
在高德地图中使用 turf.js 计算多多边形交集
在上面提到的getArriveRange
函数中,我新增了这样的逻辑
if (loopCount === currPositionList.value.length) {
let poly1 = turf.multiPolygon(toNumber(polygons[0].bounds));
let poly2 = turf.multiPolygon(toNumber(polygons[1].bounds));
var intersection = turf.intersect(turf.featureCollection([poly1, poly2]));
if (intersection) {
let geojson = new AMap.GeoJSON({
geoJSON: {
type: "FeatureCollection",
features: [intersection]
},
getPolygon: (_: any, lnglats: any) => {
return new AMap.Polygon({
path: lnglats,
...polygonStyle.overlap
});
}
});
polygons.push({
lnglat: [0, 0],
polygon: geojson,
bounds: intersection.geometry.coordinates
})
map.add(geojson);
} else {
autolog.log("暂无交集,请自行查找", 'error')
}
map.setFitView();
}
由于高德地图到达圈获取到的经纬度是字符串,放到 turf 里面会报错,所以这里写了一个简单的递归,将多维数组所有的数据都转化为数字。
// 递归的将多维数组内的字符串转为数字
function toNumber(arr: any) {
return arr.map((item: any) => {
if (Array.isArray(item)) {
return toNumber(item)
} else {
return Number(item)
}
})
}
使用turf.multiPolygon
将获取到的多维数组转化为标准的 geojson 格式,以便于 turf 处理,在turf7.x
中,turf.intersect的用法稍有改变,需要turf.featureCollection([poly1, poly2])
作为参数传入。
这一步操作 turf 将计算并返回两个多多边形的交集intersection
(geojson),但是在高德地图API2.0中,直接传入这个geojson会报错(1.4不会),看了下源码,发现高德有一个操作是直接取第 0 个features
,导致它识别不了这种格式的数据,所以我们手动处理下,即:
let geojson = new AMap.GeoJSON({
geoJSON: {
type: "FeatureCollection",
features: [intersection]
},
getPolygon: (_: any, lnglats: any) => {
return new AMap.Polygon({
path: lnglats,
...polygonStyle.overlap
});
}
});
这样,高德就可以正确渲染这个数据了,这里需要注意的是,geojson 虽然也是 AMap.Polygon 构造的,但是需要一个特殊参数:path,没有的话,也不会报错,但是渲染不出来。
渲染完成后是这样的:
使用绿色代表重合部分,说明在这之中找房都是可以的。
通过 AMap.PlaceSearch 搜索交集区域的小区
高德提供了通过多边形区域搜索POI的接口
placeSearch = new AMap.PlaceSearch({ //构造地点查询类
pageSize: 5, // 单页显示结果条数
pageIndex: 1, // 页码
map: map, // 展现结果的地图实例
autoFitView: true // 是否自动调整地图视野使绘制的 Marker点都处于视口的可见范围
});
placeSearch.searchInBounds('小区', intersection.geometry.coordinates);
效果如上图所示,这样就可以轻松租房啦!
但是由于此接口是 get 请求,如果交集区域过大,会超出 get 请求长度限制:
结语
这个就叫产品思维,一个简单的API可以延伸出很多有趣的应用。
此仓库已在 github 开源,地址:
番外
高德云镜(高德云镜三维重建平台)目前已向企业和政府开放使用(暂未对个人开发者开放)
在web端,高德开发了 Cesium 插件用作展示,但目前来看要求配置过高
- CesiumJS引擎:CesiumJS v1.117+(建议)
- 浏览器:Chrome v126+(建议)
- 显卡:16GB显存以上独立显卡,推荐 NVDIA RTX 4090
- CPU:2.5 GHz 以上(建议)
- 内存:32GB 以上(建议)
看起来类似谷歌地球的全量城市建模。
来源:juejin.cn/post/7403991780512399387
大文件分片上传
前言
大文件上传是项目中的一个难点和亮点,在面试中也经常会被面试官问到,所以今天蘑菇头来聊聊这个大文件上传。
什么样的文件算的上是大文件?
对于Web前端来说,当涉及到上传或下载操作时,通常认为任何超过10MB的文件都属于较大文件,尤其是对于HTTP POST上传操作。如果文件大小达到几十MB甚至更大,那么通常就需要考虑使用分块上传、断点续传等技术来优化传输过程,减少因网络不稳定导致的失败率,并提高用户体验。
当然了,这和你的网络带宽也有关系,当你的网络带宽很小时,即时在小的文件传输速率也很慢,也可以被称之为大文件了。
接下里我们来模拟一下如何使用分块上传技术来优化传输过程。
分片上传文件
分片上传技术是解决大文件上传问题的一种有效方法。它通过将大文件分割成多个较小的部分(称为“分片”或“片段”),然后分别上传这些部分,最后再由服务器端合并这些部分来重构原始文件。这种方法的优点包括能够更好地利用网络资源、支持断点续传以及提高上传效率。
主要思想
首先,前端获取到input框里输入的文件对象,通过slice方法将大文件对象进行切割得到小的Blob对象,由于后端无法识别Blob对象,所以需要转为前后端都能识别的对象FormData,然后将这个对象通过post请求发送给后端,将切片一个一个发送给后端。
后端接收到一个一个切割好的对象进行解析,将这些切片保存到一个文件夹下。当所有的切片都发送完毕之后,后端接收到合并这个信号,将文件夹下的切片排好顺序进行合并,创建可写流,将所有的切片读成流类型并汇入到可写流中得到完整的文件资源。
详细过程
有几个点需要我们注意
文件如何切割?用什么方法?
后端什么时候知道前端已经将所有的分片都发送过来了,然后才开始合并?
合并的过程中如何保证分片的顺序?
后端怎么将前端发送过来的分片文件进行合并?
前端
监听input框的change事件,获取文件对象。
使用slice将文件对象进行切片,返回一个数组。
使用FormData构造函数,将Bolb对象包装成formdata对象,以便后端能够识别,并且给这个对象添加文件名,分片名属性,以便后来分片进行排序。
使用Promise.all方法,当所有的分片请求都成功后,在all的then方法里面发送一个分片请求已完成的信号给后端,告诉后端可以开始合并分片了。
<input type="file" name="" id="input">
<button id="btn">上传</button>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
const input = document.getElementById('input');
const btn = document.getElementById('btn');
let fileObj = null
input.addEventListener('change', handleFileChange);
btn.addEventListener('click', handleUpload)
function handleFileChange(e) {//监听change事件,获取文件对象
// console.log(event.target.files);
const [file] = event.target.files;
fileObj = file;
}
function handleUpload() {//点击按钮上传文件到服务器
if (!fileObj) return;
const chunkList = createChunk(fileObj);
// console.log(chunkList);
const chunks = chunkList.map(({ file }, index) => {//创建切片对象
return {
file,
size: file.size,
percent: 0,
index,
chunkName: `${fileObj.name}-${index}`,
fileName: fileObj.name,
}
});
// 发请求
uploadChunks(chunks);
}
//切片
function createChunk(file, size = 5 * 1024 * 1024) {
const chunkList = [];
let cur = 0;
while (cur < file.size) {
chunkList.push({
file: file.slice(cur, cur + size),
})
cur += size;
}
return chunkList;
}
// 发请求到后端
function uploadChunks(chunks) {
console.log(chunks); //这个数组中的元素是对象,对象中有blob类型的文件对象,后端无法识别,所以需要转换成formData对象
const formChunks = chunks.map(({ file, fileName, index, chunkName }) => {
const formData = new FormData();
formData.append('file', file);
formData.append('fileName', fileName);
formData.append('chunkName', chunkName);
return { formData, index }
})
console.log(formChunks); // 后端能识别的类型
//发请求
const requestList = formChunks.map(({ formData, index }) => {//一个一个片段发
return axios.post('http://localhost:3000/upload', formData,()=>{
console.log(index + ' 上传成功');
})
.then(res => {
})
})
Promise.all(requestList).then(() => {
console.log('全部上传成功');
mergeChunks();
})
}
// 合并请求的信号
function mergeChunks(size=5*1024*1024){
axios.post('http://localhost:3000/merge',{
fileName:fileObj.name,
size
})
.then(res=>{
console.log(fileObj.name + '合并成功');
})
}
</script>
后端
使用第三方库multiparty对传输过来的formdata进行解析。
使用fse模块对解析完成的数据进行保存。
当所有的切片都完成时,后端接收到合并切片的请求信号时,使用fse模块读取所有的切片并进行排序。
排序完成之后使用fse模块进行合并。
const http = require('http');
const path = require('path');
const multiparty = require('multiparty');
const fse = require('fs-extra');
const server = http.createServer(async (req, res) => {
res.writeHead(200, {
'access-control-allow-origin': '*',
'access-control-allow-headers': '*',
'access-control-allow-methods': '*'
})
if (req.method === 'OPTIONS') { // 请求预检
res.status = 200
res.end()
return
}
if (req.url === '/upload') {
// 接收前端传过来的 formData
const form = new multiparty.Form();
form.parse(req, (err, fields, files) => {
// console.log(fields); // 切片的描述
// console.log(files); // 切片的二进制资源被处理成对象
const [file] = files.file
const [fileName] = fields.fileName
const [chunkName] = fields.chunkName
// 保存切片
const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`)
if (!fse.existsSync(chunkDir)) { // 该路径是否有效
fse.mkdirSync(chunkDir)
}
// 存入
fse.moveSync(file.path, `${chunkDir}/${chunkName}`)
res.end(JSON.stringify({
code: 0,
message: '切片上传成功'
}))
})
}
if (req.url === '/merge') {
const { fileName, size } = await resolvePost(req) // 解析post参数
const filePath = path.resolve(UPLOAD_DIR, fileName) // 完整文件的路径
// 合并切片
const result = await mergeFileChunk(filePath, fileName, size)
if (result) { // 切片合并完成
res.end(JSON.stringify({
code: 0,
message: '文件合并完成'
}))
}
}
})
// 存放切片的地方
const UPLOAD_DIR = path.resolve(__dirname, '.', 'qiepian')
// 解析post参数
function resolvePost(req) {
return new Promise((resolve, reject) => {
req.on('data', (data) => {
resolve(JSON.parse(data.toString()))
})
})
}
// 合并
function pipeStream(path, writeStream) {
return new Promise((resolve, reject) => {
const readStream = fse.createReadStream(path)
readStream.on('end', () => {
fse.removeSync(path) // 被读取完的切片移除掉
resolve()
})
readStream.pipe(writeStream)
})
}
// 合并切片
async function mergeFileChunk(filePath, fileName, size) {
// 拿到所有切片所在文件夹的路径
const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`)
// 拿到所有切片
let chunksList = fse.readdirSync(chunkDir)
// console.log(chunksList);
// 万一切片是乱序的
chunksList.sort((a, b) => a.split('-')[1] - b.split('-')[1])
const result = chunksList.map((chunkFileName, index) => {
const chunkPath = path.resolve(chunkDir, chunkFileName)
// !!!!!合并
return pipeStream(chunkPath, fse.createWriteStream(filePath, {
start: index * size,
end: (index + 1) * size
}))
})
// console.log(result);
await Promise.all(result)
fse.rmdirSync(chunkDir) // 删除切片目录
return true
}
server.listen(3000, () => {
console.log('listening on port 3000');
})
来源:juejin.cn/post/7407262746700365876
uniapp开发微信小程序,我踩了大家都会踩的坑
最近使用uniapp开发了一个微信小程序(本项目技术栈是uniapp + vue3 + ts,用了最近比较火的模板unibest。),踩了一些大家普遍都会踩的坑,下面做一些总结。文章多处引用到权威官方内容和一些比较可靠的文章。如有错误,欢迎指正。
1. 使用微信昵称填写能力遇到的问题
自 2022 年 10 月 25 日 24 时后,wx.getUserProfile
和 wx.getUserInfo
的接口被收回,要想获取微信的昵称头像需要使用微信的头像昵称填写能力。
我们的设计稿中没有编辑确认按钮,所以应该失焦后就调用后端的变更昵称接口:


但是失焦之后,微信会对昵称内容做合规性校验,导致失焦后不能立马获取到输入的内容:
<uv-input v-model="form.name" type="nickname" placeholder="请输入内容" @blur="handleSubmit"></uv-input>
async function handleSubmit() {
console.log('form.value.name', form.value.name) // 测试用户001
console.log('rawName', rawName) // 测试用户001
if (form.value.name === rawName)
return
// ...
}
因此最开始的想法是等待校验结束:
async function handleSubmit() {
// 微信会对type="nickname"的输入框失焦时进行昵称违规校验,这个校验是异步的,所以需要等待一下
await new Promise((resolve) => setTimeout(resolve, 0))
console.log('form.value.name', form.value.name) // Jude
console.log('rawName', rawName) // 测试用户001
if (form.value.name === rawName) {
return
}
// ...
}
但如果真的输入了违规昵称,微信将自动清空输入框内容,而在此之前我的提交请求已经发送:
因此需要用到官方新加的一个回调事件bindnicknamereview
(文档):
<uv-input v-model="form.name" type="nickname" placeholder="请输入内容" @nicknamereview="handleSubmit"></uv-input>
function onNickNameReview(e) {
console.log('onNickNameReview', e)
if (e.detail.pass) {
// 校验通过
handleSubmit()
} else {
form.value.name = rawName
}
}
但发现 uv-ui 并没有提供这个事件,还是没有生效,只能改node_modules
的uv-input
源码,并给uv-ui
提个pr
:
2. 自定义导航栏
原生导航栏配置方面有很多限制,比如不允许修改字体大小等。所以有的时候需要自定义导航栏。
首先注意,webview的页面无法自定义导航栏!
所以:
导航栏高度 = 状态栏到胶囊的间距(胶囊上坐标位置-状态栏高度) * 2 + 胶囊高度 + 状态栏高度
第一步:配置当前页面的json
文件
// pages.json
{ navigationStyle: "custom" }
第二步:获取状态栏和导航栏高度,只需要获取一次即可,获取到可以放到pinia
里
// 自定义导航栏
const statusBarHeight = ref(0)
const navBarHeight = ref(0)
statusBarHeight.value = uni.getSystemInfoSync().statusBarHeight
let menuButtonInfo = uni.getMenuButtonBoundingClientRect()
navBarHeight.value = menuButtonInfo.height + (menuButtonInfo.top - statusBarHeight.value) * 2
第三步:自定义导航栏
<view class="nav-bar">
<!-- 状态栏占位 -->
<view :style="{ height: statusBarHeight + 'px' }"></view>
<!-- 真正的导航栏内容 ,请按照自己的需求自行定义-->
<view class="nav-bar-content" style="font-size: 34rpx;" :style="{ height: navBarHeight + 'px' }">导航栏标题</view>
</view>
问题:微信小程序原生导航栏会根据微信设置(字体大小,是否开启深色模式)等变化,深色模式是页面是可以获取到的,但字体大小等目前没有开放接口,所以无法根据微信设置动态变化。
3. 自定义tabbar
由于原生底部tabbar的局限性,未能满足产品需求,所以需要自定义tabbar。
首先,自定义tabbar的第一步配置pages.json
:
// pages.json
tabBar: {
custom: true,
// ...
},
然后,我们只需要在项目根目录(src)创建custom-tab-bar目录,uniapp编译器会直接它拷贝到小程序中:
<!-- src/custom-tab-bar/index.wxml -->
<view class="tab-bar">
<view class="tab-bar-border"></view>
<view wx:for="{{list}}" wx:key="index" class="tab-bar-item" data-path="{{item.pagePath}}" data-index="{{index}}" bindtap="switchTab">
<image class="tab-bar-item-img" src="{{selected === index ? item.selectedIconPath : item.iconPath}}"></image>
<view class="tab-bar-item-text" style="color: {{selected === index ? selectedColor : color}}">{{item.text}}</view>
</view>
</view>
// src/custom-tab-bar/index.js
Component({
data: {
selected: 0,
color: "#8d939f",
selectedColor: "#e3eaf9",
list: [{
pagePath: "/pages/index/index",
iconPath: "../static/tabbar/home01.png",
selectedIconPath: "../static/tabbar/home02.png",
text: "首页"
}, {
pagePath: "/pages/my/my",
iconPath: "../static/tabbar/user01.png",
selectedIconPath: "../static/tabbar/user02.png",
text: "我的"
}]
},
attached() {
},
methods: {
switchTab(e) {
const data = e.currentTarget.dataset
const url = data.path
wx.switchTab({url})
this.setData({
selected: data.index
})
}
}
})
// src/custom-tab-bar/index.json
{
"component": true
}
// src/custom-tab-bar/index.wxss
.tab-bar {
position: fixed;
bottom: calc(16rpx + env(safe-area-inset-bottom));
left: 0;
right: 0;
height: 100rpx;
background: linear-gradient(180deg, rgba(13, 15, 26, 0.95) 0%, rgba(42, 50, 76, 0.95) 100%);
box-shadow: 0rpx 4rpx 16rpx 0px rgba(0, 0, 0, 0.12);
display: flex;
width: calc(100% - 2 * 36rpx);
border-radius: 36rpx;
margin: 0 auto;
}
.tab-bar-item {
flex: 1;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.tab-bar-item .tab-bar-item-img {
width: 32rpx;
height: 32rpx;
}
.tab-bar-item .tab-bar-item-text {
margin-top: 10rpx;
font-size: 20rpx;
}
最后,关键坑注意:每个tab页都有自己的tabbar实例:

因此需要每个tab页渲染时设置一下自定义tabbar组件的 activeIndex
(我这里变量名是selected
):
如果是原生小程序开发像官网那样写就好,如果是uniapp
开发,需要:
onShow(() => {
const currentPage = getCurrentPages()[0]; // 获取当前页面实例
const currentTabBar = currentPage?.getTabBar?.();
// 设置当前tab页的下标index
currentTabBar?.setData({ selected: 0 });
})
效果:
4. IOS适配安全距离
当用户使用圆形设备访问页面时,就存在“安全区域”和“安全距离”的概念。安全区域指的是一个可视窗口范围,处于安全区域的内容不受圆角(corners
)、齐刘海(sensor housing
)、小黑条(Home Indicator
)的影响。

上图来自designing-websites-for-iphone-x
uniapp适配:
uniapp适配安全距离有三个方法:
a. manifest.json配置安全距离
// manifest.json
{
"app-plus": {
"safearea": { //可选,JSON对象,安全区域配置
"background": "#RRGGBB", //可选,字符串类型,#RRGGBB格式,安全区域背景颜色
"backgroundDark": "#RRGGBB", //可选,字符串类型,#RRGGBB格式,暗黑模式安全区域背景颜色
"bottom": { //可选,JSON对象,底部安全区域配置
"offset": "auto" //可选,字符串类型,安全区域偏移值,可取值auto、none
},
"left": { //可选,JSON对象,左侧安全区域配置
"offset": "none" //可选,字符串类型,安全区域偏移值,可取值auto、none
},
"right": { //可选,JSON对象,左侧安全区域配置
"offset": "none" //可选,字符串类型,安全区域偏移值,可取值auto、none
}
},
}
}
问题: 这种方式显然不够灵活,它设置的是单独的背景色,如果需要下方一个区域是背景图,延伸到底部安全区就满足不了了。
所以,我是将以上的配置设置成none
,然后手动适配页面的安全距离:
b. js获取安全距离
let app = uni.getSystemInfoSync()
app.statusBarHeight // 手机状态栏的高度
app.bottom // 底部安全距离
c. 使用苹果官方推出的css函数env()、constant()适配
padding-bottom: constant(safe-area-inset-bottom); /*兼容 IOS<11.2*/
padding-bottom: env(safe-area-inset-bottom); /*兼容 IOS>11.2*/
注意: constant
和env
不能调换位置
可以配合calc
使用:
padding-bottom: calc(constant(safe-area-inset-bottom) + 20rpx); /*兼容 IOS<11.2*/
padding-bottom: calc(env(safe-area-inset-bottom) + 20rpx); /*兼容 IOS>11.2*/
h5适配
网页适配安全距离的前提是需要将<meta name="viewport">
标签设置viewport-fit:cover;
:
<meta name='viewport' content='initial-scale=1, viewport-fit=cover'>
直观一点就是:


上图来自移动端安全区域适配方案
然后再使用env
和constant
padding-bottom: constant(safe-area-inset-bottom); /*兼容 IOS<11.2*/
padding-bottom: env(safe-area-inset-bottom); /*兼容 IOS>11.2*/
5. 列表滚动相关问题
列表滚动如果使用overflow: auto;
在首次下拉时(即使触控点在列表内)也会使整个页面下拉:
解决这个问题只需要将内容使用 scroll-view 包裹即可:
<scroll-view scroll-y class="max-h-[800rpx] overflow-auto"></scroll-view>
下拉刷新将列表滚动到顶部:
小程序默认使用webview
渲染,如果需要Skyline
渲染引擎需要配置,而srcoll-view
标签在webview
中有个独有的属性enhanced
,启用后可通过 ScrollViewContext 操作 scroll-view
:
<scroll-view id="scrollview" :enhanced="true" scroll-y class="max-h-[800rpx] overflow-auto"></scroll-view>
/** 将scrollview滚动到顶部 */
function scrollToTop(id: string) {
wx.createSelectorQuery()
.select(id)
.node()
.exec((res) => {
const scrollView = res[0].node;
scrollView.scrollTo({
top: 0,
animated: true
});
})
}
onPullDownRefresh(async () => {
console.log('下拉刷新')
try {
await fetchList()
} catch (error) {
console.log(error)
} finally {
uni.stopPullDownRefresh()
scrollToTop('#scrollview')
}
})
6. 配置小程序用户隐私保护指引
文档:小程序隐私协议开发指南
什么时候要配置:
但凡你的小程序用到上图中任何一种用户信息就得配置,否则使用wx.authorize
来获取相应授权时直接会走到fail
回调,报 { "errMsg": "authorize:fail api scope is not declared in the privacy agreement", "errno": 112 }
配置的是什么:
配置的是将来你的程序打开让用户确认授权的隐私协议内容。
如何配置:
登录微信公众平台 -> 设置 -> 服务内容声明 -> 用户隐私保护指引 -> 修改
隐私弹框触发的流程是什么:
程序调用隐私相关接口 ——> 微信判断该接口是否需要隐私授权 ——> 如果需要隐私授权且开发者没有对其响应(注册onNeedPrivacyAuthorization的监听事件)则主动弹出官方弹框(此时隐私相关接口调用处于pending状态,如果用户拒绝将会报{ "errMsg":" getLocation:fail privacy permission is not authorized", "errno":104 }
)。
代码逻辑:
配置并等待审核通过后,进行以下步骤:
1. 配置 __usePrivacyCheck__: true
尽管官方文档说明2023年10月17日之后无论是否配置改字段,隐私相关功能都会启用,但是实际尝试后发现还是得配置上才生效。
// manifest.config.ts
'mp-weixin': {
__usePrivacyCheck__: true
},
2. 自定义隐私弹框组件
尽管官方提供了官方隐私弹框组件,但是真机上没有生效,于是还是使用了自定义隐私弹框。
我是直接在插件市场找了一个下载量最多的插件,兼容vue2和vue3。
在小程序对应的页面:
<WsWxPrivacy id="privacy-popup" @agree="onAgree" @disagree="onDisAgree"></WsWxPrivacy>
function onAgree() {}
function onDisAgree() {}
tip: 这部分逻辑相对于业务是几乎没有耦合的,甚至如果没有特殊需求agree
和disagree
事件都不用写。如果将来官方主动弹框没问题了,那这个逻辑可以直接删掉。
3. 业务代码
举个例子,我这里隐私相关接口是uni.getLocation
获取用户地理位置。
function handleCheckLocation() {
return new Promise((resolve, reject) => {
uni.getLocation({
type: 'gcj02',
success: async (res) => {
console.log('当前位置:', res)
try {
let r = await checkLocation({
lon: res.longitude.toString(),
lat: res.latitude.toString(),
})
// ...
resolve('success')
} catch (error) {
reject(error)
}
},
fail: (error) => {
console.log('获取位置失败:', error)
reject(error)
}
})
})
}
以上代码,在调用uni.getLocation
时,微信自动发起位置授权,发起位置授权之前又会自动发起隐私授权。到此,这一流程是ok的。但是,如果用户拒绝了隐私授权,或者拒绝了位置授权,该怎么办?
如果拒绝了隐私授权,下次调用隐私相关接口时还会再次弹出隐私授权弹框。
如果拒绝了位置授权,下次调用就不会弹出位置授权弹框,但可以通过uni.getSetting
来判断用户是否拒绝过,再通过wx.openSetting
让用户打开设置界面手动开启授权。代码如下:
function getLocationSetting() {
uni.getSetting({
success: (res) => {
console.log('获取设置:', res)
if (res.authSetting['scope.userLocation']) {
// 已经授权,可以直接调用 getLocation 获取位置
handleCheckLocation()
} else if (res.authSetting['scope.userLocation'] === false) {
// 用户已拒绝授权,引导用户到设置页面开启
wx.showModal({
title: '您未开启地理位置授权',
content: '请在设置中开启授权',
success: res => {
if (res.confirm) {
wx.openSetting({
success(settingRes) {
if (settingRes.authSetting['scope.userLocation']) {
// 用户打开了授权,再次获取地理位置
handleCheckLocation()
}
}
})
}
}
})
} else {
// 首次使用功能,请求授权
uni.authorize({
scope: 'scope.userLocation',
success() {
handleCheckLocation()
}
})
}
}
})
}
当然你也可以封装一下:
function getSetting(scopeName: string, cb: () => any) {
uni.getSetting({
success: (res) => {
console.log('获取设置:', res)
if (res.authSetting[scopeName]) {
// 已经授权,可以直接调用
cb()
} else if (res.authSetting[scopeName] === false) {
// 用户已拒绝授权,引导用户到设置页面开启
wx.showModal({
title: '您未开启相关授权',
content: '请在设置中开启授权',
success: res => {
if (res.confirm) {
wx.openSetting({
success(settingRes) {
if (settingRes.authSetting[scopeName]) {
// 用户打开了授权,再次获取地理位置
cb()
}
}
})
}
}
})
} else {
// 首次使用功能,请求授权
uni.authorize({
scope: scopeName,
success() {
cb()
}
})
}
}
})
}
这样,整个隐私协议指引流程就完整了。
来源:juejin.cn/post/7361688292351967259
面试官问我为什么 [] == ![] 为 true, 我表面冷静,实则内心慌的一批
前言
面试官问我,[] == ![] 的结果是啥,我:蒙一个true; 面试官:你是对的;我:内心非常高兴;
面试官:解释一下为什么; 我:一定要冷静,要不就说不会吧;这个时候,面试官笑了,同学,感觉你很慌的一批啊!
不必慌张,我们慢慢来!
在当今的编程领域,面试不仅是技术能力的考察,更是思维灵活性与深度理解的试金石。面试中偶遇诸如 [] == ![]
表达式这类题目,虽让人初感意外,实则深藏玄机,考验着我们对于JavaScript这类动态语言特性的透彻理解。这类问题触及了类型转换、逻辑运算以及语言设计的微妙之处,促使我们跳出日常编码的舒适区,深入探索编程语言的底层机制。接下来,我们将一步步揭开这道题目的神秘面纱,不仅为解答此类问题提供思路,更旨在通过这一过程,提升我们对JavaScript核心概念的掌握与应用能力。
首先我们来聊一下基础的东西。
1.原始值转布尔
首先是原始值转布尔
console.log(Boolean(1));//true
console.log(Boolean(0));//false
console.log(Boolean(-1));//true
console.log(Boolean(NaN));//false
console.log(Boolean('abc'));//true
console.log(Boolean(''));//false
console.log(Boolean(false));//flase
console.log(Boolean(undefined));//false
console.log(Boolean(null));//false
2.原始值转数字
console.log(Number('123'));//123
console.log(Number('hello'));//NaN
console.log(Number(true));//1
console.log(Number(false));//0
console.log(Number(''));//0
console.log(Number(' '));//0
console.log(Number(undefined));//NaN
console.log(Number(null));//0
3.原始值转字符串
console.log(String(123));//'123'
console.log(String(true));//'true'
console.log(String(false));//'false'
console.log(String(undefined));//'undefined'
console.log(String(null));//'null'
然后我们来了解一下与对象有关的转换逻辑
4. 原始值转对象
let a = new Number(1)
console.log(a);//[Number: 1]
其实也没有很特殊的,就是利用构造函数去进行显式转换即可。
5.对象转原始值
5.1 对象转布尔
首先我们来到这题,最后结果会被打印,说明对象在转换为布尔值的时候,不管什么对象,都是被转换为true。
5.2 + 一元运算符
我们先来了解一下,一元运算符的作用。查阅js官方文档,我们可以知道就是调用ToNumber()得到结果。而ToNumber()就是调用Number方式所调用的内置函数,因此就是强制转换为数字。我们也可以理解为+和Number()的作用是一样的。
5.3 + 二元运算符
二元运算符调用ToPrimitive()方法(ToNumber中的,转换方式有差异)。
5.4 ToNumber()方法
那么这个方法具体执行过程是什么呢?我们可以看到,如果是基本数据类型转数字,我们之前已经聊到,因此不必多聊,而面对对象转数字的时候,我们会先调用ToPrimitive方法。
5.5 ToPrimitive()方法
关于这个方法,我们要看是被ToNumber还是Totring方法给调用了。二者在返回值的顺序上会有所差异。
我们来聊一聊里面的valueOf()和toString()方法。
5.6 toString()和valueOf()方法
1. {}.toString() 得到由"[object class ] "组成字符串
2. [].toString() 返回由数组内部元素以逗号拼接的字符串
3. xx.toString() 返回字符串字面量
- valueOf 也可以将对象转成原始类型
1. 包装类对象
5.6 == 比较
我们首先引入官方文档
首先我们看二者类型相同时的比较, 里面有一点需要注意,只要有一个NaN就返回false,其他的我们应该都清楚。
二者类型不相等时,我们需要特别注意的是,null和undefined是相等的,字符串和数字则把字符串转数字,布尔和其他把布尔转数字,出现对象先把对象转原始值。
估计上面的大量干货已经把大家快搞懵逼了,此这里我们做个简单小结,这里面的方法前面都有提到哦。
5.7 小结(重点)
对象转数字
Number(obj) => ToNumber(obj) => ToNumber(ToPrimitive(obj,Number))
对象转字符串
String(obj) => ToString(obj) => ToString(ToPrimitive(obj,String))
5.8 大量实战练习
这一题我们知道+的作用和Number的方法是一样的。因此是转换为数字123.
那么这一题,我们考虑到
Number([]) => ToNumber([]) => ToNumber(ToPrimitive([], Number))=> ToNumber('') => 0
这里只要对象转布尔均为true
这里的底层原理(5.3里说了)是,我们首先两边都调用ToPrimitive方法,看看有没有字符串,有的话就把另一方转换为字符串,没有的话就全部调用ToNumber方法相加。
这里也是一样的原理。
我们先把两边转换为原始值,左边为' ',右边为'[object object]',发现存在字符串,因此相加。
这里只要我们看5.6就可以很轻松搞懂。
同上一个。
首先有对象,我们把对象转原始值,然后为NaN,为false.
最后回到我们最开始的题目,首先碰见![],我们先把[]转为布尔,为true,!true为false,然后把左边对象转原始值,为' ' == false,出现布尔和字符串,把布尔转数字,为' ' == 0,出现字符串和数字,把字符串转数字,为0 == 0,因此最后结果为true
来源:juejin.cn/post/7371312966364332042
前端比localStorage存储还大的本地存储方案
产品的原话就是“要又大又全”。既然存储量大,也要覆盖全多种设备多种浏览器。
方案选择
- 既然要存储的数量大,得排除cookie
- localStorage,虽然比cookie多,但是同样有上限(5M)左右,备选
- websql 使用简单,存储量大,兼容性差,备选
- indexDB api多且繁琐,存储量大、高版本浏览器兼容性较好,备选
既然罗列了一些选择,都没有十全十美的,那么有没有一种能够集合这多种方式的插件呢?渐进增强 or 优雅降级 的存在
冲着这个想法,就去github和谷歌找了一下,还真的有这么一个插件。
那就是 localforage
localforage
localForage 是一个 JavaScript 库,只需要通过简单类似 localStorage
API 的异步存储来改进你的 Web 应用程序的离线体验。它能存储多种类型的数据,而不仅仅是字符串。
关于兼容性
localForage 有一个优雅降级策略,若浏览器不支持 IndexedDB 或 WebSQL,则使用 localStorage。在所有主流浏览器中都可用:Chrome,Firefox,IE 和 Safari(包括 Safari Mobile)。下面是 indexDB、web sql、localStorage 的一个浏览器支持情况,可以发现,兼容性方面loaclForage基本上满足99%需求
使用
解决了兼容性和存储量的点,我们就来看看localforage的基础用法
安装
# 通过 npm 安装:
npm install localforage
// 直接引用
<script src="localforage.js"></script>
<script>console.log('localforage is: ', localforage);</script>
获取存储
getItem(key, successCallback)
从仓库中获取 key 对应的值并将结果提供给回调函数。如果 key 不存在,getItem() 将返回 null。
localforage.getItem('somekey').then(function(value) {
// 当离线仓库中的值被载入时,此处代码运行
console.log(value);
}).catch(function(err) {
// 当出错时,此处代码运行
console.log(err);
});
// 回调版本:
localforage.getItem('somekey', function(err, value) {
// 当离线仓库中的值被载入时,此处代码运行
console.log(value);
});
设置存储
setItem(key, value, successCallback)
将数据保存到离线仓库。你可以存储如下类型的 JavaScript 对象:
- Array
- ArrayBuffer
- Blob
- Float32Array
- Float64Array
- Int8Array
- Int16Array
- Int32Array
- Number
- Object
- Uint8Array
- Uint8ClampedArray
- Uint16Array
- Uint32Array
- String
localforage
.setItem("somekey", "some value")
.then(function (value) {
// 当值被存储后,可执行其他操作
console.log(value);
})
.catch(function (err) {
// 当出错时,此处代码运行
console.log(err);
});
// 不同于 localStorage,你可以存储非字符串类型
localforage
.setItem("my array", [1, 2, "three"])
.then(function (value) {
// 如下输出 `1`
console.log(value[0]);
})
.catch(function (err) {
// 当出错时,此处代码运行
console.log(err);
});
// 你甚至可以存储 AJAX 响应返回的二进制数据
req = new XMLHttpRequest();
req.open("GET", "/photo.jpg", true);
req.responseType = "arraybuffer";
req.addEventListener("readystatechange", function () {
if (req.readyState === 4) {
// readyState 完成
localforage
.setItem("photo", req.response)
.then(function (image) {
// 如下为一个合法的 <img> 标签的 blob URI
var blob = new Blob([image]);
var imageURI = window.URL.createObjectURL(blob);
})
.catch(function (err) {
// 当出错时,此处代码运行
console.log(err);
});
}
});
删除存储
removeItem(key, successCallback)
从离线仓库中删除 key 对应的值。
localforage.removeItem('somekey').then(function() {
// 当值被移除后,此处代码运行
console.log('Key is cleared!');
}).catch(function(err) {
// 当出错时,此处代码运行
console.log(err);
});
清空存储
clear(successCallback)
从数据库中删除所有的 key,重置数据库。
localforage.clear() 将会删除离线仓库中的所有值。谨慎使用此方法。
localforage.clear().then(function() {
// 当数据库被全部删除后,此处代码运行
console.log('Database is now empty.');
}).catch(function(err) {
// 当出错时,此处代码运行
console.log(err);
});
localforage是否万事大吉?
用上了localforage一开始我也以为可以完全满足万恶的产品了,然而。。。翻车了.。
内存不足的前提下,localforage继续缓存会怎么样?
在这种状态下,尝试使用localforage,不出意外,抛错了 QuotaExceededError 的 DOMErro
解决
存储数据的时候加上存储的时间戳和模块标识,加时间戳一起存储
setItem({
value: '1',
label: 'a',
module: 'a',
timestamp: '11111111111'
})
- 如果是遇到存储使用报错的情况,try/catch捕获之后,通过判断报错提示,去执行相应的操作,遇到内存不足的情况,则根据时间戳和模块标识清理一部分旧数据(内存不足的情况还是比较少的)
- 在用户手机上产生脏数据的情况,想要清理的这种情况的 处理方式是:
- 让后端在用户信息接口里面加上缓存有效期时间戳,当该时间戳存在,则前端会进行一次对本地存储扫描
- 在有效期时间戳之前的数据,结合模块标识,进行清理,清理完毕后调用后端接口上报清理日志
- 模块标识的意义是清理数据的时候,可以按照模块去清理(选填)
来源:juejin.cn/post/7273028474973012007
前端中 JS 发起的请求可以暂停吗
在前端中,JavaScript(JS)可以使用XMLHttpRequest对象或fetch API来发起网络请求。然而,JavaScript本身并没有提供直接的方法来暂停请求的执行。一旦请求被发送,它会继续执行并等待响应。
尽管如此,你可以通过一些技巧或库来模拟请求的暂停和继续执行。下面是一种常见的方法:
1. 使用XMLHttpRequest对象
你可以在发送请求前创建一个XMLHttpRequest对象,并将其保存在变量中。然后,在需要暂停请求时,调用该对象的abort()方法来中止请求。当需要继续执行请求时,可以重新创建一个新的XMLHttpRequest对象并发起请求。
var xhr = new XMLHttpRequest();
xhr.open('GET', 'https://example.com/api', true);
xhr.send();
// 暂停请求
xhr.abort();
// 继续请求
xhr = new XMLHttpRequest();
xhr.open('GET', 'https://example.com/api', true);
xhr.send();
2. 使用fetch API和AbortController
fetch API与AbortController一起使用可以更方便地控制请求的暂停和继续执行。AbortController提供了一个abort()方法,可以用于中止fetch请求。
var controller = new AbortController();
fetch('https://example.com/api', { signal: controller.signal })
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error));
// 暂停请求
controller.abort();
// 继续请求
controller = new AbortController();
fetch('https://example.com/api', { signal: controller.signal })
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error));
请注意,这些方法实际上是通过中止请求并重新发起新的请求来模拟暂停和继续执行的效果,并不能真正暂停正在进行的请求。
3. 曲线救国
模拟一个假暂停的功能,在前端的业务场景上,需要对这些数据进行处理之后渲染在界面上,如果我们能在请求发起之前增加一个控制器,在请求回来时,如果控制器为暂停状态则不处理数据,等待控制器恢复后再进行处理,也可以达到暂停的效果。
// 创建一个暂停控制器 Promise
function createPauseControllerPromise() {
const result = {
isPause: false, // 标记控制器是否处于暂停状态
resolveWhenResume: false, // 表示在恢复时是否解析 Promise
resolve(value) {}, // 解析 Promise 的占位函数
pause() { // 暂停控制器的函数
this.isPause = true;
},
resume() { // 恢复控制器的函数
if (!this.isPause) return;
this.isPause = false;
if (this.resolveWhenResume) {
this.resolve();
}
},
promise: Promise.resolve(), // 初始为已解决状态的 Promise
};
const promise = new Promise((res) => {
result.resolve = res; // 将解析函数与 Promise 关联
});
result.promise = promise; // 更新控制器中的 Promise 对象
return result; // 返回控制器对象
}
function requestWithPauseControl(request) {
const controller = createPauseControllerPromise(); // 创建暂停控制器对象
const controlRequest = request() // 执行请求函数
.then((data) => { // 请求成功回调
if (!controller.isPause) controller.resolve(); // 如果控制器未暂停,则解析 Promise
return data; // 返回请求结果
})
.finally(() => {
controller.resolveWhenResume = true; // 标记在恢复时解析 Promise
});
const result = Promise.all([controlRequest, controller.promise]).then(
(data) => {
controller.resolve(); // 解析控制器的 Promise
return data[0]; // 返回请求处理结果
}
);
result.pause = controller.pause.bind(controller); // 将暂停函数绑定到结果 Promise 对象
result.resume = controller.resume.bind(controller); // 将恢复函数绑定到结果 Promise 对象
return result; // 返回添加了暂停控制功能的结果 Promise 对象
}
为什么需要创建两个promise
在requestWithPauseControl函数中,需要等待两个Promise对象解析:一个是请求处理的Promise,另一个是控制器的Promise。通过使用Promise.all方法,可以将这两个Promise对象组合成一个新的Promise,该新的Promise会在两个原始Promise都解析后才会解析。这样做的目的是确保在处理请求结果之前,暂停控制器的resolve方法被调用,以便在恢复时解析Promise。
因此,将请求处理的Promise和控制器的Promise放入一个Promise数组,并使用Promise.all等待它们都解析完成,可以确保在两个Promise都解析后再进行下一步操作,以实现预期的功能。
使用
const requestFn = () => new Promise(resolve => {
setTimeout(() => resolve({ author: 'vincentzheng', msg: 'hello' }), 0)
})
const result = requestWithPauseControl(requestFn);
result.then((data) => {
console.log("返回结果", data);
});
if (Math.random() > 0.5) {
console.log('命中暂停')
result.pause();
}
setTimeout(() => {
result.resume();
}, 4000);
来源:juejin.cn/post/7310786521082560562
手撸一个精美简约loading加载功能,再也不怕复杂的网页效果了
我来看看怎么个事?
你们还记得自己为什么要做程序员吗?我先来说,就是看见别人有一个精美的网站。但是,现在很多人要么就是后端crud boy,要么就是前端vue渲染数据girl。没有现成的框架,现成的ui组件,就没法写代码了。好看的网页怎么来呢?有人会说是UI设计的,我前端只需要vue渲染数据就行了🤣(今天我们就不探讨后端技术🐶)。久而久之,自己就会慢慢变菜,最后想开发一个项目,发现无从下手,写个页面都费劲!!!所以,还是慢慢做一个全栈,这样既可以写好玩的工具,也可以提高自己的竞争力,强者恒强,没错就是我啦😅
1.loading实际效果图
字不重要,看图👉👉👉👉
pc端
移动端
2.准备css素材
这种loading的效果,网上有很多网站可以直接diy,几乎没有人手写一个。当然你也可以手写,如果觉得闲的话
推荐网站
国内也有很多,我使用的是国外网站(科学上网)
下载素材推荐svg格式,如果你的svg动图存在背景
如图,这种背景一定要去掉,给svg设置一个透明度,找到svg文件,background属性,设置rgb(255,255,255,0)就可以了,如下图:
3.loading隐藏与显示逻辑
思考🤔:
当我们点击按钮的时候,一般会触发请求,比如请求后台数据,这个时候中间就会有加载的样式。
总结就两个条件:
1.按钮要触发点击事件,开启loading效果
2.需要一个事件完成的状态(标记),关闭loading效果
4.编写looding组件,全局注册组件

<script setup>
</script>
<template>
<div class="loading">
<img src="./loading.svg" alt="loading"/>
</div>
</template>
<style scoped lang="scss">
.loading {
//通过定位,实现loading水平垂直居中
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
img {
width: 70px;
height: 70px;
}
}
</style>
全局注册loading组件
5.登录页面使用loading组件
<script setup>
import {reactive, ref, watch} from "vue";
//模拟请求数据,code===200表示事件完成
const result = reactive({data: [], code: 0})
//判断按钮是否触发点击事件,默认false,按钮没有触发点击
const clickFlag = ref(false)
//按钮提交方法
const submit = () => {
//重置code
result.code = 0
//标记请求触发
clickFlag.value = true
//模拟http请求
setTimeout(() => {
//模拟后台数据
result.data = [{'name': 'bobo', 'age': 12}]
//模拟请求完成
result.code = 200
}, 3000)
}
</script>
<template>
<div class="login-container">
<div class="login-box">
<div class="form">
<h2>用户登录</h2>
<div class="content">
<input class="input" type="text" placeholder="请输入账号">
<input class="input" type="password" placeholder="请输入密码">
<button @click="submit" class="button">登录</button>
<!--判断loading 1.有没有点击事件 2.有没有loading终止标记-->
<loading v-show="result.code!==200&&clickFlag"></loading>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.login-container {
.login-box {
.form {
position: relative;
padding: 24px;
text-align: center;
color: rgb(55 65 81);
line-height: 1.75rem;
}
}
}
</style>
具体页面布局的代码,请参考我之前的文章juejin.cn/post/738854…
这里不做过多叙述!能坚持✊看到这里,想必你一定很棒,给你个🍭🍭🍭
在线浏览网址http://www.codingfox.icu/#/dashboard (我不知道可以坚持到多久,靠爱发电,网站搬迁会尽量迁移)
来源:juejin.cn/post/7389178780437921803
首屏优化之:import 动态导入
前言
前面我们聊过可以通过不同的 script 属性比如 defer,async 来实现不同的加载效果,从而实现首屏优化。
今天我们来聊一下动态导入之 import
,当然 import 动态导入也不是一把梭的,也是需要根据项目情况进行使用,在面试以及实际工作当中都可以用到,一起来看看吧!
在了解动态导入之前,我们先来看一下什么是静态导入
。
静态导入
静态导入会在编译时解析所有导入的模块,并在程序开始执行之前加载
它们。这意味着所有被导入的模块在应用启动时就已经加载完毕
。
什么意思,我们先来看一下下面这段代码:
这段代码很简单,我在页面导入了 import.js,当点击按钮时打印输出语句。
我们来看一下浏览器初始化加载情况:
很明显,程序开始执行之前,import.js 就被加载了。
但是在某些时刻,我们不希望文件在没有被使用时就被加载,只希望在使用时加载,这样可以优化首屏的加载速度,这些时刻我们就可以使用动态导入。
动态导入
动态导入是一种在代码执行时按需加载模块的技术,而不是在应用程序初始化时加载所有模块。
默认不会一上来加载所有文件,只会在用到时加载,这样可以优化初始加载时间,提升页面响应速度。
动态导入与静态导入不同,动态导入使用 ES6 中的 import()
语法,可以在函数或代码块中调用,从而实现条件加载、按需加载或延迟加载。例如:
import('./import.js')
还是上面的代码,我们使用动态导入来进行实现一下:
我们再来看一下浏览器的加载情况:
可以看到一上来并没有加载 import.js
当点击按钮时,才加载了 import.js 文件,这就说明import导入的文件不会一上来就直接被加载,而是在相关代码被执行时才进行加载的。
一些应用
路由懒加载
在 react 中我们常常使用 lazy 和 Suspense 来实现路由的懒加载,这样做的好处就是初始化时不会一下加载所有的页面,而是当切换到相应页面时才会加载相应的页面,例如:
组件动态导入
对于一些不常用或者不需要直接加载的组件我们也可以采用动态导入,比如弹出框。
我们只需要在点击时进行加载显示即可。
分包优化
这里就简单说一下分包的优化,webpack 默认的分包规则有以下三点:
- 通过配置多个入口 entry,可以将不同的文件分开打包。
- 使用
import()
语法,Webpack 会自动将动态导入的模块放到单独的包中。‘ entry.runtime
单独组织成一个 chunk。
根据第二点,被动态导入的文件会被单独进行打包,不会被分包进同一个文件,也就不会在初始加载 bundle.js 时被一起进行加载。
通过将代码分割成多个小包,可以在用户需要时才加载特定的模块,从而显著减少初始加载的时间。
总结
在进行首屏优化时,可以采取动态导入的方式实现,使用 import('./文件路径')实现,虽然动态导入有一些优化首屏渲染的优势,但是也有一些缺点,比如首次加载延迟,不利于 SEO 优化等,所以在使用动态导入时应该好好进行规划,比如一些不常用的模块或者内容不太复杂,对加载速度无要求的文件可以进行动态导入,这个还是要根据项目的需求来进行使用的。
来源:juejin.cn/post/7400332893158391819
优雅的处理async/await错误
async/await使用
async/await
解决了Promise的链式调用(.then)造成的回调地狱,使异步回调变得像同步一样美观!
使用的方式如下:
// 异步函数1
let postFun1 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { resolve('postFun1') }, 2000)
})
}
// 异步函数2
let postFun2 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { resolve('postFun2') }, 1000)
})
}
// async/await
async function syncFun() {
let s1 = await postFun1()
console.log(s1)
let s2 = await postFun2()
console.log(s2)
console.log('s1、s2都获取到了,我才会执行')
}
syncFun()
可以看出,在syncFun函数中,我们获取异步信息,书写方式就跟同步一样,不用.then套.then,很美观!
不捕获错误会怎样
// 异步函数1
let postFun1 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { resolve('postFun1') }, 2000)
})
}
// 异步函数2
let postFun2 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject('err') }, 1000)
})
}
async function asyncFun() {
let s1 = await postFun1();
let s2 = await postFun2();
console.log('s1、s2都获取到了,我才会执行')
}
asyncFun();
控制台:
可以看出,控制台没有我们想要打印的信息console.log('s1、s2都获取到了,我才会执行')
try/catch捕获错误
我们日常开发中,都是使用try/catch捕获错误,方式如下:
let postFun1 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject('err1') }, 2000)
})
}
let postFun2 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject('err2') }, 1000)
})
}
async function asyncFun() {
try{
let s1 = await postFun1();
let s2 = await postFun2();
}catch(e){
console.log(e)
}
console.log('s1、s2都获取到了,我才会执行')
}
asyncFun();
控制台:
可以看出,我们抛出两个reject,但是只捕获到了一个错误!
那么捕获多个错误,我们就需要多个try/catch如此,代码便像现在这样:
let postFun1 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject('err1') }, 2000)
})
}
let postFun2 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject('err2') }, 1000)
})
}
async function asyncFun() {
try{
let s1 = await postFun1();
}catch(e){
console.log(e)
}
try{
let s2 = await postFun2();
}catch(e){
console.log(e)
}
console.log('s1、s2都获取到了,我才会执行')
}
asyncFun();
控制台:
仅仅是两个try/catch已经看起来很难受了,那么10个呢?
await-to-js
/**
* @param promise 传进去的请求函数
* @param errorExt 拓展错误信息
* @return 返回一个Promise
*/
function to(promise, errorExt) {
return promise
.then(res => [null, res])
.catch(err => {
if (errorExt) {
const parsedError = Object.assign({}, err, errorExt)
return [parsedError, undefined]
}
return [err, undefined]
})
}
await命令后面是一个 Promise 对象,返回该对象的结果。如果不是 Promise 对象,就直接返回对应的值
这里封装了一个
to
函数,接收promise和扩展的错误信息为参数,返回promise
。[err,res]
分别代表错误信息和成功结果,.then()成功时,[null,res]代表错误信息为null
;.catch()失败时,[err,undefined]代表,成功结果为undefined
。我们获取捕获的结果直接从返回的数组中取就行,第一个是失败信息,第二个是成功结果!
完整代码加使用
function to(promise, errorExt) {
return promise
.then(res => [null, res])
.catch(err => {
if (errorExt) {
const parsedError = Object.assign({}, err, errorExt)
return [parsedError, undefined]
}
return [err, undefined]
})
}
let postFun1 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject({err:'err1'}) }, 2000)
})
}
let postFun2 = function () {
return new Promise((resolve, reject) => {
setTimeout(() => { reject({err:'err2'}) }, 1000)
})
}
async function asyncFun() {
let [err1,res1] = await to(postFun1(), {msg:'抱歉1'});
let [err2,res2] = await to(postFun2(), {msg:'抱歉2'});
console.log(err1,err2)
console.log('s1、s2都获取到了,我才会执行')
}
asyncFun()
把这个学会,在面试官面前装一波,面试官定会直呼优雅!!!
来源:juejin.cn/post/7278280824846925861
threejs 搭建智驾自车场景
智能驾驶业务里常用web 3d来可视化地图和传感器数据、显示路径规划结果等方法协助算法调试和仿真,可以用threejs来做,毕竟在国内社区相对活跃,也比较容易上手,效果类似下图:
当然以上图片都是客户端的版本,web3d版本的ui其实并不会这么精致,毕竟只是服务于内部算法和研发。这个专栏纯属作者一时兴起并希望能产出一个麻雀虽小五脏俱全的行泊场景(简称人太闲),本文就先把自车的基础场景搭建起来
本文基于 three^0.167.1 版本
初始化项目
用 Vite 脚手架快速搭一个 react 项目用来调试
pnpm create vite autopilot --template react-ts
把 threejs 官网的例子稍微改下,加到项目里看看。新建一个 renderer 对象如下:
// src/renderer/index.ts
import * as THREE from "three";
class Renderer {
constructor() {
//
}
initialize() {
const container = document.getElementById("my-canvas")!;
const width = container.offsetWidth,
height = container.offsetHeight;
const camera = new THREE.PerspectiveCamera(70, width / height, 0.01, 10);
camera.position.z = 1;
const scene = new THREE.Scene();
const geometry = new THREE.BoxGeometry(0.2, 0.2, 0.2);
const material = new THREE.MeshNormalMaterial();
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setAnimationLoop(animate);
container.appendChild(renderer.domElement);
function animate(time: number) {
mesh.rotation.x = time / 2000;
mesh.rotation.y = time / 1000;
renderer.render(scene, camera);
}
}
}
export const myRenderer = new Renderer();
// App.tsx
import { useEffect } from "react";
import { myRenderer } from "./renderer";
import "./App.css";
function App() {
useEffect(() => {
myRenderer.initialize();
}, []);
return (
<>
<div id="my-canvas"></div>
</>
);
}
export default App;
加载自车
ok,跨出第一步了,接下来整辆自车(egoCar)
“自车”指的是自动驾驶汽车本身,它能够通过搭载的传感器、计算平台和软件系统实现自主导航和行驶
可以上 free3d 下载个免费的车辆模型,里面有很多种格式的,尽量找 gltf/glb 格式的(文件体积小,加载比较快)。
这里以加载 glb 格式的模型为例,可以先把模型文件放到 public 目录下,因为加载器相对网页的根路径(index.html)解析,而 public 目录在打包后会原封不动保存到根目录里
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
const gltfLoader = new GLTFLoader();
class Renderer {
scene = new THREE.Scene();
// ...
loadEgoCar() {
gltfLoader.load("./su7.glb", (gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
this.scene.add(car);
});
}
// ...
initialize() {
// ...
this.loadEgoCar();
}
}
但如果一定要放到 src/assets/models
目录里呢?然后通过import方式引入文件来用,那这么操作下来就会遇到这个报错(You may need to install appropriate plugins to handle the .glb file format, or if it's an asset, add "**/*.glb" to assetsInclude
in your configuration):
怎么解?在 vite.config.ts
文件加入 assetsInclude
。顺带把 vite 指定路径别名 alias 也支持一下
// vite .config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
// 指定路径别名
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
assetsInclude: ["**/*.glb"],
});
node:url 如果提示没有该模块,先安装下@types/node,可能要重启下vscode才能生效
pnpm i @types/node -D
接下来就可以直接用 import 导入 glb 文件来用了
import carModel from "@/assets/models/su7.glb";
class Renderer {
// ...
loadEgoCar() {
gltfLoader.load(carModel, (gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
this.scene.add(car);
});
}
}
OrbitControls
增加 OrbitControls 插件,便于调节自车视角,这个插件除了围绕目标点(默认是原点[0,0,0])旋转视角,还支持缩放(滚轮)和平移(鼠标右键,触摸板的话是双指长按)
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
class Renderer {
initialize() {
// ...
const controls = new OrbitControls(camera, renderer.domElement);
function animate() {
// ...
controls.update();
renderer.render(scene, camera);
}
}
}
光源设置
看起来场景和自车都比较暗,咱们调下光源,加一个环境光 AmbientLight 和平行光 DirectionalLight,平行光位置放自车后上方,沿着自车方向(也就是原点方向)发射光源
// ...
// 没有特定方向,影响整个场景的明暗
const ambient = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambient);
// 平行光
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(60, 80, 40);
scene.add(directionalLight);
地面网格
增加坐标网格,新建一个 Grid 对象,提供一个水平的基准面,便于观察
// ...
// 50表示网格模型的尺寸大小,20表示纵横细分线条数量
const gridHelper = new THREE.GridHelper(50, 20);
scene.add(gridHelper);
// 顺带调高下相机位置
camera.position.set(0, 1, 1.8);
// 设置场景背景色(颜色值,透明度)
renderer.setClearColor(0x000000, 0.85);
道路实现
这里先简单实现一段不规则道路,封装一个 freespace
对象,还要考虑它的不规则和带洞的可能,所以需要做好接口定义,其实数据主要是点集,一般这些点集都是地图上游发下来的,可能是 protobuf 或者 json 的格式
export interface IFreespace {
// 一般可以用于判断元素是否可复用
id: string;
position: IPos;
contour: IPos[];
// 洞可能有多个,所以这里应该设置成二维数组
holes?: IPos[][];
color?: IColor;
}
export interface IPos {
x: number;
y: number;
z?: number;
}
export interface IColor {
r: number;
g: number;
b: number;
a?: number;
}
因为只是一个平面形状,所以可以用 THREE.Shape
来实现,它可以和 ExtrudeGeometry
、ShapeGeometry
一起使用来创建二维形状
// src/renderers/freespace.ts
class Freespace {
scene = new THREE.Scene();
constructor(scene: THREE.Scene) {
this.scene = scene;
}
draw(data: IFreespace) {
const {
contour,
holes = [],
color = { r: 0, g: 0, b: 0 },
position,
} = data;
if (contour.length < 3) {
return;
}
const shape = new THREE.Shape();
// 先绘制轮廓
// 设置起点
shape.moveTo(contour[0].x, contour[0].y);
contour.forEach((item) => shape.lineTo(item.x, item.y));
// 绘制洞
holes.forEach((item) => {
if (item.length < 3) {
return;
}
const path = new THREE.Path();
path.moveTo(item[0].x, item[0].y);
item.forEach((subItem) => {
path.lineTo(subItem.x, subItem.y);
});
// 注意这一步
shape.holes.push(path);
});
const shapeGeometry = new THREE.ShapeGeometry(shape);
const material = new THREE.MeshPhongMaterial();
// 注意:setRGB传参颜色值需要介于0-1之间
material.color.setRGB(color.r / 255, color.g / 255, color.b / 255);
material.opacity = color.a || 1;
const mesh = new THREE.Mesh(shapeGeometry, material);
mesh.position.set(position.x, position.y, position.z || 0);
mesh.rotateX(-Math.PI / 2);
this.scene.add(mesh);
}
}
export default Freespace;
ok先用mock的数据画一段带洞的十字路口,加在 initialize
代码后就行,其实道路上还应该有一些交通标线,后面再加上吧
最后再监听下界面的 resize 事件,使其能根据容器实际大小变化动态调整场景
// ...
constructor() {
// 初始化渲染对象
this.renderers = {
freespace: new Freespace(this.scene),
};
}
initialize() {
// ...
this.loadEgoCar();
this.registerDefaultEvents();
// mock
this.mockData();
}
mockData() {
this.renderers.freespace.draw(freespaceData1);
}
// 监听resize事件
registerDefaultEvents() {
window.addEventListener("resize", this.onResize.bind(this), false);
}
unmountDefaultEvents() {
window.removeEventListener("resize", this.onResize.bind(this), false);
}
onResize() {
const container = document.getElementById("my-canvas")!;
const width = container.offsetWidth,
height = container.offsetHeight;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
}
最后
ok先到这了,主要是先把项目搭起来,后面会继续分享下更多地图和感知元素以及他车、行人、障碍物等效果的实现
来源:juejin.cn/post/7406643531697913867
想学 pinia ?一文就够了
有时候不得不承认,官方的总结有时就是最精简的:
Pinia 是 Vue 的存储库,它允许您跨组件/页面共享状态。
虽然作为Vuex的升级版,但为了尊重原作者,所以取名pinia,而没有取名Vuex,所以大家可以直接将pinia比作为Vue3的Vuex,同时,pinia提供了一种更简洁、更直观的方式来处理应用程序的状态,更为重要的是,pinia的学习成本更低,低到一篇文章就能涵盖pinia的全部。
Pinia的安装与配置:
首先自然是安装pinia,在基于Vue3的项目环境中,提供了npm
与yarn
两种安装方式:
npm install pinia
yarn add pinia
随后,通常就是在src
目录下新建一个专属的store
文件夹,在其中的js
文件中创建并抛出这个仓库。
import { createPinia } from 'pinia' // 引入pinia模块
const store = createPinia() // 创建一个仓库
export default store // 抛出这个仓库
既然把这个仓库抛出了,那么现在便是让它能在全局起作用,于是在Vue的主要应用文件中(通常为main.js),引入使用pinia
。
import { createApp } from 'vue'
import App from './App3.vue'
import store from './store' //引入这个仓库
createApp(App).use(store).mount('#app') // 再use一下
这样一来pinia
仓库就能全局生效了!
Pinia的主要功能:
在官方文档中,Pinia提供了四种功能,分别是:
- Store:在Pinia中,每个状态管理模块都被称为一个Store。开发者需要创建一个Store实例来定义和管理状态。
- State:在Store中定义状态。可以使用defineState函数来定义一个状态,并通过state属性来访问它。
- Getters:类似于Vuex中的getters,用于从State中派生出一些状态。可以使用
defineGetters
函数来定义getters。 - Actions:在Pinia中,Actions用于处理异步操作或执行一些副作用。可以使用
defineActions
函数来定义Actions。
那么接下来我会通过一个具体的实例来表现出这四个功能,如下图:
分别是充当仓库的Store功能。存储子组件User.vue
中数据的State功能。另一个子组件Update-user.vue
中,点击按钮后数据会实现更新,也就是修改State中数据的Actions功能。与无论点击多少次” 经过一年后按钮 ”,页面都会实现同步更新的Getters功能。
State:
简单来说,State的作用就是作为仓库的数据源。
就比如说,我想在仓库的数据源里面放上一个对象来进行使用,那我们只需在先前创建的store
文件夹中再创建一个js
文件,这里我给它起名为user
,然后再其中这样添加对象。
(第一行引入的defineStore
代表defineStore
是store
的一部分。)
import { defineStore } from 'pinia' // defineStore 是 store 的一部分
export const useUserStore = defineStore({
id: 'user',
state: () => ({ // 仓库数据源
userInfo: {
name: '小明',
age: 18,
sex:'boy'
}
})
})
那么现在,我们想使用仓库中的数据就成为了一件非常容易的事。
正如上图,这里有一个父组件App.vue
,两个子组件User.vue
、Update-user.vue
。
父组件不做任何动作,只包含对两个子组件的引用:
<template>
<User/>
<Updateuser/>
</template>
<script setup>
import User from './components/User.vue'
import Updateuser from './components/Update-user.vue'
</script>
<style lang="css" scoped>
</style>
子组件User.vue:
可以看到在这个子组件中,我们通过import { useUserStore } from '@/store/user'
引用仓库,从而获得了仓库中小明姓名、年龄、性别的数据。
由于接下来的Update-user.vue
组件中会添加几个按钮对这些数据进行修改,那么我们就要把这些数据设置成响应式。
正常情况下,store
自带响应性,但如果我们不想每次都写userStore.userInfo.name
这么长一大串,就可以尝试将这些值取出来赋给其他变量:
这里有两种方法,第一种是引入computed
模块,如第14行年龄的修改。另一种是引入storeToRefs
模块,这是一种属于Pinia
仓库的模块,将整个userInfo
变成响应式。
于是接下来,就轮到我们的Actions登场了
<template>
<ul>
<li>姓名:{{ userStore.userInfo.name }}</li>
<li>年龄:{{ age }}</li>
<li>性别;{{ userInfo.sex }}</li>
</ul>
</template>
<script setup>
import { useUserStore } from '@/store/user'
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const age = computed(() => userStore.userInfo.age) // 1. 计算属性使响应式能生效
const { userInfo } = storeToRefs(userStore) // 2. 专门包裹仓库中函数用来返回对象
</script>
<style lang="scss" scoped>
</style>
Actions:
简单来说,Actions的作用就是专门用来修改State,如果你想要修改仓库中的响应式元素,只需要进行两步操作:
第一步:在user.js
也就是我们的仓库中添加actions
,专门设置函数用来修改state对象中的值。例如changeUserName
作用是修改姓名, changeUserSex
作用是修改性别。
import { defineStore } from 'pinia' // defineStore 是 store的一部分
export const useUserStore = defineStore({
id: 'user',
state: () => ({ // 仓库数据源
userInfo: {
name: '小明',
age: 18,
sex:'boy'
}
}),
actions: { // 专门用来修改state
changeUserName(name) {
this.userInfo.name = name
},
changeUserSex(sex){
this.userInfo.sex = sex
}
}
})
子组件Update-user.vue:
第二步,在控制按钮的组件Update-user.vue
中触发这两个函数,就如第10与14行的两个箭头函数。
<template>
<button @click="changeName">修改仓库中用户姓名</button>
<button @click="changeSex">修改仓库中用户性别</button>
</template>
<script setup>
import { useUserStore } from '@/store/user' // 引入Pinia仓库
const userStore = useUserStore() // 声明仓库
const changeName = () => { // 触发提供的函数
userStore.changeUserName('小红')
}
const changeSex = () => {
userStore.changeUserSex('gril')
}
</script>
<style lang="css" scoped>
</style>
这样一来,依赖于Actions,我们就成功完成了响应式修改仓库中数据的功能,也就是前两个按钮的功能!
Getters:
简单来说Getters就是仓库中的计算属性。
现在我们来实现第三个按钮功能,首先就是在User.vue
组件中第5行,添加 “ 十年之后年龄 ” 一栏:
<template>
<ul>
<li>姓名:{{userStore.userInfo.name}}</li>
<li>年龄:{{ age }}</li>
<li>十年后年龄:{{ userStore.afterAge }}</li> // 添加的栏
<li>性别:{{ userInfo.sex }}</li>
</ul>
</template>
<script setup>
import { useUserStore } from '@/store/user'
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const age = computed(() => userStore.userInfo.age)
const { userInfo } = storeToRefs(userStore)
</script>
<style lang="scss" scoped>
</style>
那么现在你一定能注意到这一栏其中的userStore.afterAge
,这正是我们将在getters中返回的值。
那么关于getters,具体的使用方法就是继续在user.js
中添加进getters,我们在其中打造了一个afterAge
函数来返回userStore.afterAge
,正如第25行。
import { defineStore } from 'pinia' // defineStore 是 store的一部分
export const useUserStore = defineStore({
id: 'user',
state: () => ({ // 仓库数据源
userInfo: {
name: '小明',
age: 18,
sex:'boy'
}
}),
actions: { // 专门用来修改state
changeUserName(name) {
this.userInfo.name = name
},
changeUserSex(sex){
this.userInfo.sex = sex
},
changeUserAge(age){ // 新添加的一年后年龄计算方法
this.userInfo.age += age
}
},
getters: { // 仓库中的计算属性,所依赖的值改变会重新执行
afterAge(state) {
return state.userInfo.age + 10
}
}
})
准备工作完毕,现在就该在页面上添加这个按钮,于是在组件Update-user.vue
添加上按钮与执行函数。
<button @click="changeAge">经过一年后</button>
const changeAge = () => {
userStore.changeUserAge(1)
}
有了这些之后,这个项目的功能便彻底完善,无论点击多少次“ 经过一年后 ”按钮,在页面上显示的值都是正确且实时更新的,这就是Getters的功劳!
补充:数据持久化
关于整个项目的功能实现确实已经结束,但人的贪心却是不得满足的,如果我们想要在原有的基础上实现网页刷新数据却不刷新,也就是说数据的持久化,那又该怎么办呢?
很简单,也就是堪堪三步,便能实现。
第一步:安装persist
插件。
npm i pinia-plugin-persist
第二步:在store
的js
文件中引入这个插件。
import { createPinia } from 'pinia'
import piniaPluginPersist from 'pinia-plugin-persist' //引入插件
const store = createPinia()
store.use(piniaPluginPersist) // 使用插件
export default store
第三步:在我们前文user.js
的defineStore
库内继续添加上persist
功能。
persist: { // 持久化
enabled: true,
strategies: [ // 里面填想要持久化的数据
{
paths: ['userInfo'], // 指明持久化的数据
storage: localStorage // 指明存储
}
]
}
现在可以看到点击按钮后的数据都被存储到浏览器的存储空间中,无论多少次刷新都不会被重置!
最后:
至此,这样一个简简单单的项目,却解释清楚了Pinia功能的核心,读完这篇文章,相信每一个学习Pinia的人都能有所收获。
来源:juejin.cn/post/7407407711879807026
你知道为什么template中不用加.value吗?
Vue3 中定义的ref
类型的变量,在setup
中使用这些变量是需要带上.value
才可以访问,但是在template
中却可以直接使用。
询其原因,可能会说 Vue 自动进行ref
解包了,那具体如何实现的呢?
proxyRefs
Vue3 中有有个方法proxyRefs
,这属于底层 API 方法,在官方文档中并没有阐述,但是 Vue 里是可以导出这个方法。
例如:
<script setup>
import { onMounted, proxyRefs, ref } from "vue";
const user = {
name: "wendZzoo",
age: ref(18),
};
const _user = proxyRefs(user);
onMounted(() => {
console.log(_user.name);
console.log(_user.age);
console.log(user.age);
});
</script>
上面代码定义了一个普通对象user
,其中age
属性的值是ref
类型。当访问age
值的时候,需要通过user.age.value
,而使用了proxyRefs
,可以直接通过user.age
来访问。
这也就是为何template
中不用加.value
的原因,Vue3 源码中使用proxyRefs
方法将setup
返回的对象进行处理。
实现proxyRefs
单测
it("proxyRefs", () => {
const user = {
name: "jack",
age: ref(10),
};
const proxyUser = proxyRefs(user);
expect(user.age.value).toBe(10);
expect(proxyUser.age).toBe(10);
proxyUser.age = 20;
expect(proxyUser.age).toBe(20);
expect(user.age.value).toBe(20);
proxyUser.age = ref(30);
expect(proxyUser.age).toBe(30);
expect(user.age.value).toBe(30);
});
定义一个age
属性值为ref
类型的普通对象user
。proxyRefs
方法需要满足:
proxyUser
直接访问age
是可以直接获取到 10 。- 当修改
proxyUser
的age
值切这个值不是ref
类型时,proxyUser
和原数据user
都会被修改。 age
值被修改为ref
类型时,proxyUser
和user
也会都更新。
实现
既然是访问和修改对象内部的属性值,就可以使用Proxy
来处理get
和set
。先来实现get
export function proxyRefs(objectWithRefs) {
return new Proxy(objectWithRefs, {
get(target, key) {}
});
}
需要实现的是proxyUser.age
能直接获取到数据,那原数据target[key]
是ref
类型,只需要将ref.value
转成value
。
使用unref
即可实现,unref
的实现参见本专栏上篇文章,文章地址:mp.weixin.qq.com/s/lLkjpK9TG…
get(target, key) {
return unref(Reflect.get(target, key));
}
实现set
export function proxyRefs(objectWithRefs) {
return new Proxy(objectWithRefs, {
get(target, key) {
return unref(Reflect.get(target, key));
},
set(target, key, value) {},
});
}
从单侧中可以看出,我们是测试了两种情况,一种是修改proxyUser
的age
为ref
类型, 一种是修改成不是ref
类型的,但是结果都是同步更新proxyUser
和user
。那实现上也需要考虑这两种情况,需要判断原数据值是不是ref
类型,新赋的值是不是ref
类型。
使用isRef
可以判断是否为ref
类型,isRef
的实现参见本专栏上篇文章,文章地址:mp.weixin.qq.com/s/lLkjpK9TG…
set(target, key, value) {
if (isRef(target[key]) && !isRef(value)) {
return (target[key].value = value);
} else {
return Reflect.set(target, key, value);
}
}
当原数据值是ref
类型且新赋的值不是ref
类型,也就是单测中第 1 个情况赋值为 10,将ref
类型的原值赋值为value
,ref
类型值需要.value
访问;否则,也就是单测中第 2 个情况,赋值为ref(30)
,就不需要额外处理,直接赋值即可。
验证
执行单测yarn test ref
来源:juejin.cn/post/7303435124527333416
将html转化成图片
如何将指定html内容转化成图片保存?这个问题很值得深思,实际应用中也很有价值。最直接的想法就是使用
canvas
,熟悉canvas
的同学可以尝试一下。这里不做太多的说明,本文采用html2canvas
库来实现。
html2canvas
库的使用非常简单,只需要引入html2canvas
库,然后调用html2canvas
方法即可,官方地址。
接下来说一下简单的使用,以react
项目为例。
获取整个页面截图,可以使用底层IDroot
,这样下载的就是root
下的所有元素。

import html2canvas from "html2canvas";
const saveCanvas = () => {
// 画布基础元素,要绘制的元素
const canvas: any = document.getElementById("root");
const options: any = { scale: 1, useCORS: true };
html2canvas(canvas, options).then((canvas) => {
const type = "png";
// 返回值是一个数据url,是base64组成的图片的源数据
let imgDt = canvas.toDataURL(type);
let fileName = "img" + "." + type;
// 保存为文件
let a = document.createElement("a");
document.body.appendChild(a);
a.href = imgDt;
a.download = fileName;
a.click();
});
};
图片的默认背景色是#ffffff
,如果想要透明色可设置为null
,比如设置为红色。

const options: any = { scale: 1, useCORS: true, backgroundColor: "red" };
正常情况下网络图片是无法渲染的,可以使用useCORS
属性,设置为true
即可。

const options: any = { scale: 1, useCORS: true };
保存某块元素的截图

const canvas: any = document.getElementById("swiper");
如果希望将某些元素排除,可以将data-html2canvas-ignore
属性添加到这些元素中,html2canvas
将从渲染中排除这些元素。

<Button
data-html2canvas-ignore
color="primary"
fill="solid"
onClick={saveCanvas}
>
download
</Button>
完整代码
npm install html2canvas
// demo.less
.contentSwiper {
width: 710px;
height: 375px;
color: #ffffff;
display: flex;
justify-content: center;
align-items: center;
font-size: 48px;
user-select: none;
}
.swiper {
padding: 0 20px;
}
import React from "react";
import { Button, Space, Swiper } from "antd-mobile";
import html2canvas from "html2canvas";
import styles from "./demo.less";
export default () => {
const saveCanvas = () => {
// 画布基础元素,要绘制的元素
const canvas: any = document.getElementById("root");
const options: any = { scale: 1, useCORS: true, backgroundColor: "red"
};
html2canvas(canvas, options).then((canvas) => {
const type = "png";
// 返回值是一个数据url,是base64组成的图片的源数据
let imgDt = canvas.toDataURL(type);
let fileName = "img" + "." + type;
// 保存为文件
let a = document.createElement("a");
document.body.appendChild(a);
a.href = imgDt;
a.download = fileName;
a.click();
});
};
const colors: string[] = ["#ace0ff", "#bcffbd", "#e4fabd", "#ffcfac"];
const items = colors.map((color, index) => (
<Swiper.Item key={index}>
<div className={styles.contentSwiper} style={{ background: color }}>
{index + 1}
</div>
</Swiper.Item>
));
return (
<div className="content">
<div id="swiper" className={styles.swiper}>
<Swiper
style={{
"--track-padding": " 0 0 16px",
}}
defaultIndex={1}
>
{items}
</Swiper>
</div>
<div>
<img
width={200}
src="https://t7.baidu.com/it/u=2621658848,3952322712&fm=193&f=GIF"
/>
</div>
<Space>
<Button
data-html2canvas-ignore
color="primary"
fill="solid"
onClick={saveCanvas}
>
download
</Button>
<Button color="primary" fill="solid">
Solid
</Button>
<Button color="primary" fill="outline">
Outline
</Button>
<Button color="primary" fill="none">
</Button>
</Space>
</div>
);
};
来源:juejin.cn/post/7407457177483608118
Vue3中watch好用,但watchEffect、watchSyncEffect、watchPostEffect简洁
比较好奇vue项目中使用watch还是watchEffect居多,查看了element-plus、ant-design-vue两个UI库, 整体上看,watch使用居多,而watchEffect不怎么受待见,那这两者之间有什么关系?
API | watch | watchEffect | watchSyncEffect | watchPostEffect |
---|---|---|---|---|
element-plus | 198 | 28 | 0 | 0 |
ant-design-vue | 263 | 168 | 0 | 0 |
watchEffect是watch的衍生
为什么说watchEffect是watch的衍生?
- 首先,两者提供功能是有重叠。大部分监听场景,两者都能满足。
const list = ref([]);
const count = ref(0);
watch(
list,
(newValue) => {
count.value = newValue.length;
}
)
watchEffect(() => {
count.value = list.value.length;
})
- 其次,源码上两者也都是同一出处。以下是两者的函数定义:
export function watch(
source: T | WatchSource,
cb: any,
options?: WatchOptions,
): WatchStopHandle {
return doWatch(source as any, cb, options)
}
export function watchEffect(
effect: WatchEffect,
options?: WatchOptionsBase,
): WatchStopHandle {
return doWatch(effect, null, options)
}
两者内部都调用doWatch函数,并且返回都是WatchStopHandle类型。唯独入参上有比较大的区别,watch的source参数就像大杂烩,支持PlainObject、Ref、ComputedRef以及函数类型;而watchEffect的effect参数仅仅是一个函数类型。
watch早于watchEffect诞生,watch源代码有这样一句提示:
if (__DEV__ && !isFunction(cb)) {
warn(
`\`watch(fn, options?)\` signature has been moved to a separate API. ` +
`Use \`watchEffect(fn, options?)\` instead. \`watch\` now only ` +
`supports \`watch(source, cb, options?) signature.`,
)
}
也就是说历史的某一个版本,watch也是支持watch(fn, options?)
用法,但为了降低API复杂度,将这部分功能迁移至watchEffect函数。一个优秀框架的发展历程也不过如此,都是在不断的重构升级。
话又说回来,到目前,为什么大部分Vue开发者更偏向于使用watch,而不是watchEffect?
,带着这个问题,庖丁解牛式层层分析。
watch、watchEffect底层逻辑
当我们把watch、watchEffect底层逻辑看透,剩下的watchSyncEffect、watchPostEffect也就自然了解。
先回顾下watch、watchEffect内部调用doWatch的参数:
// watch
doWatch(source as any, cb, options)
// demo
watch(
list,
(newValue) => {
count.value = newValue.length;
}
)
// watchEffect
doWatch(effect, null, options)
// demo
watchEffect(() => {
count.value = list.value.length;
})
入参的区别,如下表所示:
API | arg1 | arg2 | arg3 |
---|---|---|---|
watch | T | WatchSource | cb | WatchOptions |
watchEffect | WatchEffect | null | WatchOptionsBase |
根据参数对比,先抛出两个问题:
1. doWatch为什么能自动监听WatchEffect函数内的数据变更,并且能重新执行?
2. 第三个参数WatchOptions
watchOptions
export interface WatchOptions<Immediate = boolean> extends WatchOptionsBase {
immediate?: Immediate
deep?: boolean
once?: boolean
}
export interface WatchOptionsBase extends DebuggerOptions {
flush?: 'pre' | 'post' | 'sync'
}
WatchOptionsBase仅提供了flush,因此watchEffect函数的第三个参数也只有flush一个选项。
flush包含pre
、post
、sync
三个值,缺省为pre。它明确了监听器的触发时机,pre和post比较明确,对应渲染前、渲染后。
sync官方定义为:在某些特殊情况下 (例如要使缓存失效),可能有必要在响应式依赖发生改变时立即触发侦听器
。简而言之,依赖的多个变量,只要其中一个有更新,监听器就会触发一次。
const list = ref([]);
const page = ref(1);
const message = ref('');
watchEffect(() => {
message.value = `总量${list.value.length}, 当前页:${page.value}`
console.log(message.value);
}, { flush: 'sync' })
例如上述的list、page任意一个有更新,则会输出一次console。sync模式得慎重使用,例如监听的是数组,其中一项有更新都会触发监听器,可能带来不可预知的性能问题。
post
也有明确的应用场景,例如:当页面侧边栏显示或隐藏后,需要容器渲染完成后再更新内部的图表等元素。不使用flush选项的解法,一般是监听visible变化并使用setTimeout延迟更新。有了post
,一个属性即可搞定。
watch(visible, (value) => {
setTimeout(() => {
// 更新容器内图表
}, 1000);
})
watch(visible, (value) => {
// 更新容器内图表
}, { flush: 'post' })
完成了第二个问题的解答, 要回答第一个问题,需要深入doWatch函数, 在上一篇《写Vue大篇幅的ref、computed,而reactive为何少见?》也有对doWatch做局部介绍,可以作为辅助参考。
doWatch源码
先从doWatch函数签名上,对其有概括性的认识:
function doWatch(
source: WatchSource | WatchSource[] | WatchEffect | object,
cb: WatchCallback | null,
{
immediate,
deep,
flush,
once,
onTrack,
onTrigger,
}: WatchOptions = EMPTY_OBJ,
): WatchStopHandle
由于我们主要目的是回答问题:doWatch为什么能自动监听WatchEffect函数内的数据变更,并且能重新执行?
因此仅分析source为WatchEffect
的情况,此时,cb为null, 第三个参数仅有flush选项。
WatchEffect
类型定义如下:
export type WatchEffect = (onCleanup: OnCleanup) => void
onCleanup
参数的作用是,在下一次监听器执行前被触发,通常用于状态清理。
doWatch函数实现,最核心的片段是ReactiveEffect的生成:
const effect = new ReactiveEffect(getter, NOOP, scheduler)
为什么ReactiveEffect是其核心?因为它起到了"中介"的作用,在监听器函数内,每一个可监听
的变量都对应有依赖项集合deps,当调用这些变量的getter时,ReactiveEffect会把自身注入到依赖集合deps中,这样每当执行变量的setter时,deps集合中的副作用都会触发,而每个副作用effect内部会调用scheduler, scheduler可理解为调度器,负责处理视图更新时机,scheduler内部选择合适的时机触发监听器。
接下来着重看getter、scheduler定义,当source为WatchEffect
类型时,getter定义片段如下:
// no cb -> simple effect
getter = () => {
if (cleanup) {
cleanup()
}
return callWithAsyncErrorHandling(
source,
instance,
ErrorCodes.WATCH_CALLBACK,
[onCleanup],
)
}
首先执行cleanup,也就是说如果参数传入有onCleanup回调,那么每次在获取新值前都会触发onCleanup。其次是return语句,调用callWithAsyncErrorHandling函数,从函数可探察之,一方面支持异步,另一方面处理异常错误。
支持异步:也就是我们传入的监听器可以是一个异步函数,那么我们可以在其中执行远程请求的调用,例如官方给的示例, 当id.value值变化,从远端请求数据await response
,并赋值给data.value。
watchEffect(async (onCleanup) => {
const { response, cancel } = doAsyncWork(id.value)
// `cancel` 会在 `id` 更改时调用
// 以便取消之前未完成的请求
onCleanup(cancel)
data.value = await response
})
上述示例中,如果id.value频繁更新,则会导致触发多次远端请求,要解决该问题,可调用onCleanup(cancel)
,将cancel传入到doWatch内部,并且每次执行cleanup
时被调用。onCleanup定义如下:
let cleanup: (() => void) | undefined
let onCleanup: OnCleanup = (fn: () => void) => {
cleanup = effect.onStop = () => {
callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP)
cleanup = effect.onStop = undefined
}
}
其中,fn即为上述示例中的cancel,这样就建立了cancel和cleanup的关联,因此每次更新前,先调用cancel中断上一次请求。
callWithAsyncErrorHandling
函数定义如下:
export function callWithAsyncErrorHandling(fn,instance,type,args?): any {
...
const res = callWithErrorHandling(fn, instance, type, args)
if (res && isPromise(res)) {
res.catch(err => {
handleError(err, instance, type)
})
}
return res
...
}
res为fn函数执行结果,由于支持同步、异步。如果fn为异步函数,那么res为Promise类型,并且对异常做了兜底处理。
当fn函数执行后,内部所有可监听变量的deps都会添加上当前effect,所以只要变量有更新,effect的scheduler就被触发。
watchEffect官方定义有:立即运行一个函数,同时响应式地追踪其依赖,并在依赖更改时重新执行。“立即运行一个函数”如何体现?
doWatch
函数的最后几行代码如下:
if (flush === 'post') {
queuePostRenderEffect(
effect.run.bind(effect),
instance && instance.suspense,
)
} else {
effect.run()
}
如果flush不为post
,那么立即执行effect.run()
, 而run函数会调用getter,因此会立即运行监听器函数一次;如果flush为post
,那么effect将会在vue下一次渲染前第一次执行effect.run()
。
至此,我们就分析完watchEffect
的底层逻辑,总结其特点:立即执行,支持异步,并且会自动监听变量更新。
为什么不能两者取一,而必须共存
再次回顾watch的定义:
export function watch(
source: T | WatchSource,
cb: any,
options?: WatchOptions,
) : WatchStopHandle {
return doWatch(source as any, cb, options)
}
其中WatchOptions包含的选项有:immediate、deep、once、flush。如果是watchEffect,选项仅有flush,并且immediate相当于true,剩下的deep、once不支持配置。
先说watchEffect的缺点:
- 不支持immediate为false,必须是立即执行。例如下面的代码,由于autoplay默认false,初始化时不需要立即执行。如果是watchEffect,则pauseTimer初始化会执行一次,完全没必要。
watch(
() => props.autoplay,
(autoplay) => {
autoplay ? startTimer() : pauseTimer()
}
)
- 不支持deep为true的场景,只能见监听当前使用的属性。但如果是调用
watch(source, cb, { deep: true })
, 则会通过traverse(source)
将source所有深度属性读取一次,和effect建立关联,达到自动监听所有属性的目的。 - 异步使用有坑,
watchEffect
仅会在其同步执行期间,才追踪依赖。在使用异步回调时,只有在第一个await
正常工作前访问到的属性才会被追踪。
再说watchEffect优点:
优点也是非常明显,写法非常简洁,无需显式声明监听哪些变量,一个回调函数搞定,并且默认为立即执行,我认为能满足开发中80%的应用场景。另一方面,由于只监听回调中使用的属性,相比于deep为true的一锅端方式,watchEffect则更加直观明了。
总结
watchSyncEffect、watchPostEffect和watchEffect唯一的区别是:flush分别固定为sync
、post
。所以,watchEffect为watch的衍生,而watchSyncEffect、watchPostEffect为watchEffect的衍生。
对于开发使用上:
- watchPostEffect、watchSyncEffect仅在极少数的特殊场景下才使用,完全可以用watchEffect(fn, { flush: 'sync' | 'post' })代替,多了反而对入门开发者来说是徒增干扰。
- 个人认为应优先使用watchEffect函数,毕竟代码写法上更加简洁,属性依赖上也更加明确。满足不了的场景,再考虑使用watch。
来源:juejin.cn/post/7401415643981185078
vue3为啥推荐使用ref而不是reactive
在 Vue 3 中,ref
和 reactive
都是用于声明响应式状态的工具,但它们的使用场景和内部工作机制有所不同。Vue 3 推荐使用 ref
而不是 reactive
的原因主要涉及到以下几个方面:
- 简单的原始值响应式处理:
ref
更适合处理简单的原始值(如字符串、数字、布尔值等),而reactive
更适合处理复杂的对象或数组。
- 一致性和解构:
- 使用
ref
时,解构不会丢失响应性,因为ref
会返回一个包含.value
属性的对象。而reactive
对象在解构时会丢失响应性。
- 使用
- 类型推导和代码提示:
ref
更容易与 TypeScript 配合使用,提供更好的类型推导和代码提示。
示例代码
以下是一个详细的代码示例,演示为什么在某些情况下推荐使用 ref
而不是 reactive
。
使用 ref
的示例
import { ref } from 'vue';
export default {
setup() {
// 使用 ref 声明响应式状态
const count = ref(0);
function increment() {
count.value++;
}
return {
count,
increment
};
}
};
使用 reactive
的示例
import { reactive } from 'vue';
export default {
setup() {
// 使用 reactive 声明响应式状态
const state = reactive({
count: 0
});
function increment() {
state.count++;
}
return {
state,
increment
};
}
};
解构问题
使用 ref
解构
import { ref } from 'vue';
export default {
setup() {
const count = ref(0);
function increment() {
count.value++;
}
// 解构时不会丢失响应性
const { value: countValue } = count;
return {
countValue,
increment
};
}
};
使用 reactive
解构
import { reactive } from 'vue';
export default {
setup() {
const state = reactive({
count: 0
});
function increment() {
state.count++;
}
// 解构时会丢失响应性
const { count } = state;
return {
count,
increment
};
}
};
代码解释
- 使用
ref
:
ref
返回一个包含.value
属性的对象,因此在模板中使用时需要通过.value
访问实际值。- 解构时,可以直接解构
.value
属性,不会丢失响应性。
- 使用
reactive
:
reactive
适用于复杂的对象或数组,返回一个代理对象。- 直接解构
reactive
对象的属性会丢失响应性,因为解构后得到的属性是原始值,不再是响应式的。
总结
- 简单值:对于简单的原始值(如字符串、数字、布尔值等),推荐使用
ref
,因为它更简洁,并且在解构时不会丢失响应性。 - 复杂对象:对于复杂的对象或数组,推荐使用
reactive
,因为它可以更方便地处理嵌套属性的响应性。 - 一致性:
ref
在解构时不会丢失响应性,而reactive
在解构时会丢失响应性,这使得ref
在某些情况下更为可靠。
通过理解 ref
和 reactive
的不同使用场景和内部工作机制,可以更好地选择适合的工具来管理 Vue 3 应用中的响应式状态。
来源:juejin.cn/post/7402869746175393807
Node拒绝当咸鱼,Node 22大进步
这几年,deno和bun风头正盛,大有你方唱罢我登场的态势,deno和bun的每一次更新版本,Node都会被拿来比较,比较结果总是Node落后了。
这种比较是不是非常熟悉,就像卖手机的跟iPhone比,卖汽车的跟特斯拉比,比较的时候有时候还得来个「比一分钱硬币还薄」的套路。
Node虽然没有落后了,但是确实有点压力了,所以20和22版本都大跨步前进,拒绝当咸鱼了。
因为Node官网对22版本特性的介绍太过简单,所以我决定来一篇详细介绍新特性的文章,让学习Node的朋友们知道,Node现在在第几层。
首先我把新特性分为两类,分别是:开发者可能直接用到的特性、开发者相对无感知的底层更新。本文重点介绍前者,简单介绍后者。先来一个概览:
开发者可能直接用到的特性:
- 支持通过
require()
引入ESM - 运行
package.json
中的脚本 - 监视模式(
--watch
)稳定化 - 内置 WebSocket 客户端
- 增加流的默认高水位线
- 文件模式匹配功能
开发者相对无感知的底层更新:
- V8 引擎升级至 12.4 版本
- Maglev 编译器默认启用
- 改进
AbortSignal
的创建性能
接下来开始介绍。
支持通过 require()
导入 ESM
以前,我们认为 CommonJS 与 ESM 是分离的。
例如,在 CommonJS里,我们用并使用 module.exports
导出模块,用 require()
导入模块:
// CommonJS
// math.js
function add(a, b) {
return a + b;
}
module.exports.add = add;
// useMath.js
const math = require('./math');
console.log(math.add(2, 3));
在 ECMAScript Modules (ESM) **** 里,我们使用 export
导出模块,用 import
导入模块:
// ESM
// math.mjs
export function add(a, b) {
return a + b;
}
// useMath.js
import { add } from './math.mjs';
console.log(add(2, 3));
Node 22 支持新的方式——用 require()
导入 ESM:
// Node 22
// math.mjs
export function add(a, b) {
return a + b;
}
// useMath.js
const { add } = require('./mathModule.mjs');
console.log(add(2, 3));
这么设计的原因是为了给大型项目和遗留系统提供一个平滑过渡的方案,因为这类项目难以快速全部迁移到 ESM,通过允许 require()
导入 ESM,开发者就可以逐个模块迁移,而不是一次性对整个项目进行修改。
目前这种写法还是实验性功能,所以使用是有“门槛”的:
- 启动命令需要添加
-experimental-require-module
参数,如:node --experimental-require-module app.js
- 模块标记:确保 ESM 模块通过
package.json
中的"type": "module"
或文件扩展名是.mjs
。 - 完全同步:只有完全同步的ESM才能被
require()
导入,任何含有顶级await
的ESM都不能使用这种方式加载。
运行package.json
中的脚本
假设我们的 package.json
里有一个脚本:
"scripts": {
"test": "jest"
}
在此之前,我们必须依赖 npm 或者 yanr 这样的包管理器来执行命令,比如:npm run test
。
Node 22 添加了一个新命令行标志 --run
,允许直接从命令行执行 package.json
中定义的脚本,可以直接使用 node --run test
这样的命令来运行脚本。
刚开始我还疑惑这是不是脱裤子放屁的行为,因为有 node 的地方一般都有 npm,我要这 node —run
有何用?
后来思考了一下,主要原因应该还是统一运行环境和提升性能。不同的包管理器在处理脚本时可能会有微小的差异,Node 提供一个标准化的方式执行脚本,有助于统一这些行为;而且直接使用 node 执行脚本要比通过 npm 执行脚本更快,因为绕过了 npm 这个中间层。
监视模式(--watch
)稳定化
在 19 版本里,Node 引入了 —watch
指令,用于监视文件系统的变动,并自动重启。22 版本开始,这个指令成为稳定功能了。
要启用监视模式,只需要在启动 Node 应用时加上 --watch
****参数。例如:
node --watch app.js
正在用 nodemon 做自动重启的朋友们可以正式转战 --watch
了~
内置 WebSocket 客户端
以前,要用 Node 开发一个 socket 服务,必须使用 ws、socket.io 这样的第三方库来实现。第三方库虽然稳如老狗帮助开发者许多年,但是终究是有点不方便。
Node 22 正式内置了 WebSocket,并且属于稳定功能,不再需要 -experimental-websocket
来启用了。
除此之外,WebScoket 的实现还遵循了浏览器中 WebSocket API 的标准,这意味着在 Node 中使用 WebSocket 的方式将与在 JavaScript 中使用 WebSocket 的方式非常相似,有助于减少学习成本并提高代码的一致性。
用法示例:
const socket = new WebSocket("ws://localhost:8080");
socket.addEventListener("open", (event) => {
socket.send("Hello Server!");
});
增加流(streams)的默认高水位线(High Water Mark)
streams 在 Node 中有举足轻重的作用,读写数据都得要 streams 来完成。而 streams 可以设置 highWaterMark
参数,用于表示缓冲区的大小。highWaterMark
越大,缓冲区越大,占用内存越多,I/O 操作就减少,highWaterMark
越小,其他信息也对应相反。
用法如下:
const fs = require('fs');
const readStream = fs.createReadStream('example-large-file.txt', {
highWaterMark: 1024 * 1024 // 设置高水位线为1MB
});
readStream.on('data', (chunk) => {
console.log(`Received chunk of size: ${chunk.length}`);
});
readStream.on('end', () => {
console.log('End of file has been reached.');
});
虽然 highWaterMark
是可配置的,但通常情况下,我们是使用默认值。在以前的版本里,highWaterMark
的默认值是 16k,Node 22 版本开始,默认值被提升到 64k 了。
文件模式匹配——glob 和 globSync
Node 22 版本在 fs 模块中新增了 glob
和 globSync
函数,它们用于根据指定模式匹配文件路径。
文件模式匹配允许开发者定义一个匹配模式,以找出符合特定规则的文件路径集合。模式定义通常包括通配符,如 *
(匹配任何字符)和 ?
(匹配单个字符),以及其他特定的模式字符。
glob 函数(异步)
glob
函数是一个异步的函数,它不会阻塞 Node.js 的事件循环。这意味着它在搜索文件时不会停止其他代码的执行。glob
函数的基本用法如下:
const { glob } = require('fs');
glob('**/*.js', (err, files) => {
if (err) {
throw err;
}
console.log(files); // 输出所有匹配的.js文件路径
});
在这个示例中,glob
函数用来查找所有子目录中以 .js
结尾的文件。它接受两个参数:
- 第一个参数是一个字符串,表示文件匹配模式。
- 第二个参数是一个回调函数,当文件搜索完成后,这个函数会被调用。如果搜索成功,
err
将为null
,而files
将包含一个包含所有匹配文件路径的数组。
globSync 函数(同步)
globSync
是 glob
的同步版本,它会阻塞事件循环,直到所有匹配的文件都被找到。这使得代码更简单,但在处理大量文件或在需要高响应性的应用中可能会导致性能问题。其基本用法如下:
const { globSync } = require('fs');
const files = globSync('**/*.js');
console.log(files); // 同样输出所有匹配的.js文件路径
这个函数直接返回匹配的文件数组,适用于脚本和简单的应用,其中执行速度不是主要关注点。
使用场景
这两个函数适用于:
- 自动化构建过程,如自动寻找和处理项目中的 JavaScript 文件。
- 开发工具和脚本,需要对项目目录中的文件进行批量操作。
- 任何需要从大量文件中快速筛选出符合特定模式的文件集的应用。
V8 引擎升级至 12.4 版本
从这一节开始,我们了解一下开发者相对无感知的底层更新,第一个就是 V8 引擎升级到 12.4 版本了,有了以下特性升级:
- WebAssembly 垃圾回收:这一特性将改善 WebAssembly 在内存管理方面的能力。
- Array.fromAsync:这个新方法允许从异步迭代器创建数组。
- Set 方法和迭代器帮助程序:提供了更多内建的Set操作和迭代器操作的方法,增强了数据结构的操作性和灵活性。
Maglev 编译器默认启用
Maglev 是 V8 的新编译器,现在在支持的架构上默认启用。它主要针对短生命周期的命令行程序(CLI程序)性能进行优化,通过改进JIT(即时编译)的效率来提升性能。这对开发者编写的工具和脚本将带来明显的速度提升。
改进AbortSignal
的创建性能
在这次更新中,Node 提高了 AbortSignal
实例的创建效率。AbortSignal
是用于中断正在进行的操作(如网络请求或任何长时间运行的异步任务)的一种机制。通过提升这一过程的效率,可以加快任何依赖这一功能的应用,如使用 fetch
进行HTTP请求或在测试运行器中处理中断的场景。
AbortSignal
的工作方式是通过 AbortController
实例来管理。AbortController
提供一个 signal
属性和一个 abort()
方法。signal
属性返回一个 AbortSignal
对象,可以传递给任何接受 AbortSignal
的API(如fetch
)来监听取消事件。当调用abort()
方法时,与该控制器关联的所有操作将被取消。
const controller = new AbortController();
const signal = controller.signal;
fetch(url, { signal })
.then(response => response.json())
.catch(err => {
if (err.name === 'AbortError') {
console.log('Fetch aborted');
} else {
console.error('Fetch error:', err);
}
});
// 取消请求
controller.abort();
总结
最后,我只替 Node 说一句:Node 没有这么容易被 deno 和 bun 打败~
来源:juejin.cn/post/7366185272768036883
用了这么久Vue,你用过这几个内置指令提升性能吗?
前言
Vue
的内置指令估计大家都用过不少,例如v-for
、v-if
之类的就是最常用的内置指令,但今天给大家介绍几个平时用的比较少的内置指令。毕竟这几个Vue
内置指令可用可不用,不用的时候系统正常跑,但在对的地方用了却能提升系统性能,下面将结合示例进行详细说明。
一、v-once
作用:在标签上使用v-once
能使元素或者表达式只渲染一次。首次渲染之后,后面数据再发生变化时使用了v-once
的地方都不会更新,因此用在数据不需要变化的地方就能进行性能优化。
v-once
指令实现原理: Vue
组件初始化时会标记上v-once
,首次渲染会正常执行,后续再次渲染时如果看到有v-once
标记则跳过二次渲染。
示例代码: 直接作用在标签上,可以是普通标签也可以是图片标签,当2S
后数据变化时标签上的值不会重新渲染更新。
<template>
<div>
<span v-once>{{ message }}</span>
<img v-once :src="imageUrl"></img>
</div>
</template>
<script setup>
import { ref } from 'vue';
let message = ref('Vue指令!');
let imageSrc = ref('/path/my/image.jpg');
setTimeout(() => {
message.value = '修改内容!';
imageUrl.value = '/new/path/my/images.jpg';
}, 2000);
</script>
注意: 作用v-once
会使属性失去响应式,要确保这个地方不需要响应式更新才能使用,否则会导致数据和页面视图对不上。
二、v-pre
作用: 在标签上使用v-pre
后,Vue
编译器会自动跳过这个元素的编译。使用此内置指令后会被视为静态内容。
v-pre
指令实现原理: Vue
初次编译时如果看到有v-pre
标记,那么跳过这部分的编译,直接当成原始的HTML
插入到DOM
中。
示例代码: 常规文本会正常编译成您好!
,但使用了v-pre
后会跳过编译原样输出{{ message }}
。
<template>
<div>
<h2>常规: {{ message }}</h2>
<h2 v-pre>使用v-pre后: {{ message }}</h2>
</div>
</template>
<script setup>
import { ref } from 'vue';
let message = ref('您好!');
</script>
注意: 要区分v-pre
和v-once
的区别,v-once
用于只渲染一次,而v-pre
是直接跳过编译。
这个指令可能很多人没想到应用场景有那些,其实最常见的用途就是要在页面上显示
Vue
代码,如果不用v-pre
就会被编译。如下所示使用v-pre
场景效果。
<template>
<div>
<pre v-pre>
<template>
<p>{{ message }}</p>
</template>
<script setup>
import { ref } from 'vue';
const message = ref('Hello Vue!');
</script>
</pre>
</div>
</template>
<script setup>
import { ref } from 'vue';
let message = ref('您好!');
</script>
页面上展示: 代码原始显示不会被编译。
三、v-memo(支持3.2+版本)
作用: 主要用于优化组件的渲染方面性能,能控制达到某个条件才重新当堂组件,否则不重新渲染。v-memo
会缓存 DOM
,只有当指定的数据发生变化时才会重新渲染,从而减少渲染次数提升性能。
v-memo
指令实现原理: Vue
初始化组件时会识别是否有v-memo
标记,如果有就把这部分vnode
缓存起来,当数据变化时会对比依赖是否变化,变化再重新渲染。
示例代码: 用v-memo
绑定了arr
,那么当arr
的值变化才会重新渲染,否则不会重新渲染。
<template>
<div>
<ul v-memo="arr">
<li v-for="(item, index) in arr" :key="index">
{{ item.text }}
</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue';
let arr = ref([
{ text: '内容1' },
{ text: '内容2' },
{ text: '内容3' }
]);
setInterval(() => {
arr.value[1].text = '修改2';
}, 2000);
</script>
注意: 用v-memo
来指定触发渲染的条件,但只建议在长列表或者说复杂的渲染结构才使用。
小结
总结了几个比较冷门的Vue
内置指令,平时用的不多,但用对了地方却能明显提升性能。如果那里写的不对或者有好建议欢迎大佬指出啊。
来源:juejin.cn/post/7407340295115767808
【在线聊天室😻】前端进阶全栈开发🔥
项目效果
登录注册身份认证、私聊、聊天室
项目前端React18仓库:github.com/mcmcCat/mmc…
项目后端Nestjs仓库:github.com/mcmcCat/mmc…
语雀上的笔记:http://www.yuque.com/maimaicat/t…
技术栈:
Nestjs企业级Node服务端框架+TypeOrm(Mysql)+JWT+Socket.IO🎉
React18/服务端渲染Nextjs+Redux-toolkit+styled-components🎉
登录注册身份认证、私聊、聊天室
项目前端React18仓库:github.com/mcmcCat/mmc…
项目后端Nestjs仓库:github.com/mcmcCat/mmc…
语雀上的笔记:http://www.yuque.com/maimaicat/t…
技术栈:
Nestjs企业级Node服务端框架+TypeOrm(Mysql)+JWT+Socket.IO🎉
React18/服务端渲染Nextjs+Redux-toolkit+styled-components🎉
前言
Nestjs 是一个用于构建高效可扩展的一个基于Node js 服务端 应用程序开发框架。本文不过多赘述,网上的教程有很多。
(注意:对于聊天中user模块和message模块的接口可参考仓库代码,在这里只分析登录注册的身份认证)
下面可以放张图稍微感受一下,用nest写接口很方便。 @Post('auth/register')
使用装饰器的方式,当你请求这个接口时,会自动调用下方函数AuthRegister
。另外还可以加一大堆装饰器,用于生成swagger接口文档,做本地验证、jwt验证等等。
import {
Body,
ClassSerializerInterceptor,
Controller,
Get,
Post,
Req,
Res,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { AppService } from './app.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth/auth.service';
import { CreateUserDto } from './user/dto/create-user.dto';
import { LoginDTO } from './auth/dto/login.dto';
import { AuthGuard } from '@nestjs/passport';
// @Controller装饰器来定义控制器,如每一个要成为控制器的类,都需要借助@Controller装饰器的装饰
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@ApiTags('JWT注册')
@Post('auth/register')
async AuthRegister(@Body() body: CreateUserDto) {
return await this.appService.authRegister(body);
}
@UseInterceptors(ClassSerializerInterceptor) //返回的数据中去除实体中被@Exclude()的字段
@UseGuards(AuthGuard('local')) //使用本地策略验证用户名和密码的正确性
@ApiTags('JWT登录')
@Post('auth/login')
async AuthLogin(@Body() body: LoginDTO, @Req() req) {
// 通过了本地策略证明身份验证通过
return await this.appService.authLogin(req.user);
}
}
Nestjs 是一个用于构建高效可扩展的一个基于Node js 服务端 应用程序开发框架。本文不过多赘述,网上的教程有很多。
(注意:对于聊天中user模块和message模块的接口可参考仓库代码,在这里只分析登录注册的身份认证)
下面可以放张图稍微感受一下,用nest写接口很方便。 @Post('auth/register')
使用装饰器的方式,当你请求这个接口时,会自动调用下方函数AuthRegister
。另外还可以加一大堆装饰器,用于生成swagger接口文档,做本地验证、jwt验证等等。
import {
Body,
ClassSerializerInterceptor,
Controller,
Get,
Post,
Req,
Res,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { AppService } from './app.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth/auth.service';
import { CreateUserDto } from './user/dto/create-user.dto';
import { LoginDTO } from './auth/dto/login.dto';
import { AuthGuard } from '@nestjs/passport';
// @Controller装饰器来定义控制器,如每一个要成为控制器的类,都需要借助@Controller装饰器的装饰
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@ApiTags('JWT注册')
@Post('auth/register')
async AuthRegister(@Body() body: CreateUserDto) {
return await this.appService.authRegister(body);
}
@UseInterceptors(ClassSerializerInterceptor) //返回的数据中去除实体中被@Exclude()的字段
@UseGuards(AuthGuard('local')) //使用本地策略验证用户名和密码的正确性
@ApiTags('JWT登录')
@Post('auth/login')
async AuthLogin(@Body() body: LoginDTO, @Req() req) {
// 通过了本地策略证明身份验证通过
return await this.appService.authLogin(req.user);
}
}
Nestjs中如何进行身份认证?
密码加密 和 生成token
我们可以跟着代码仓库,带有详细的注释,一步步地走
app.service.ts
负责定义注册authRegister
和登录authLogin
- 在注册时,拿到用户输入的密码,使用
**bcryptjs.hash()**
将其转换为 hash加密字符串,并存入数据库

- 在(身份认证的)登录时,先进行校验用户登录信息是否正确在这里我们使用的是
[passport-local](http://nestjs.inode.club/recipes/passport#%E5%AE%9E%E7%8E%B0-passport-%E6%9C%AC%E5%9C%B0%E7%AD%96%E7%95%A5)
本地策略来验证,@UseGuards(AuthGuard('local'))
这个装饰器会在此处的post请求@Post('auth/login')
后进行拦截,去local.strategy.ts
中进行validate
检索出该用户的信息,然后我们使用**bcryptjs.compareSync()**
将 **用户输入的密码 **与数据库中用 **hash加密过的密码 **进行解析对比,若登录信息正确则接着调用AuthLogin
,进而调用(认证成功的)登录接口authService.login()
,即向客户端发送登录成功信息并且是携带有**token**
的,
async login(user: any) {
// 准备jwt需要的负载
const payload = { username: user.username, sub: user.id };
return {
code: '200',
// 配合存储着用户信息的负载 payload 来生成一个包含签名的JWT令牌(access_token)。。
access_token: this.jwtService.sign(payload),
msg: '登录成功',
};
}
我们可以跟着代码仓库,带有详细的注释,一步步地走app.service.ts
负责定义注册authRegister
和登录authLogin
- 在注册时,拿到用户输入的密码,使用
**bcryptjs.hash()**
将其转换为 hash加密字符串,并存入数据库 - 在(身份认证的)登录时,先进行校验用户登录信息是否正确在这里我们使用的是
[passport-local](http://nestjs.inode.club/recipes/passport#%E5%AE%9E%E7%8E%B0-passport-%E6%9C%AC%E5%9C%B0%E7%AD%96%E7%95%A5)
本地策略来验证,@UseGuards(AuthGuard('local'))
这个装饰器会在此处的post请求@Post('auth/login')
后进行拦截,去local.strategy.ts
中进行validate
检索出该用户的信息,然后我们使用**bcryptjs.compareSync()**
将 **用户输入的密码 **与数据库中用 **hash加密过的密码 **进行解析对比,若登录信息正确则接着调用AuthLogin
,进而调用(认证成功的)登录接口authService.login()
,即向客户端发送登录成功信息并且是携带有**token**
的,
async login(user: any) {
// 准备jwt需要的负载
const payload = { username: user.username, sub: user.id };
return {
code: '200',
// 配合存储着用户信息的负载 payload 来生成一个包含签名的JWT令牌(access_token)。。
access_token: this.jwtService.sign(payload),
msg: '登录成功',
};
}
校验token合法性
那么这个token
我们在哪里去拦截它进行校验呢?
那就要提到我们 nest 的guard
(守卫)这个概念。其实就好比我们在vue项目中,封装路由前置守卫拦截路由跳转,去获取存储在localStorage
的token
一样。
在 nest 守卫中我们可以去获取到请求体req
,从而获取到请求头中的Authorization
字段,查看是否携带token
,然后去校验token
合法性,authService.verifyToken()
中调用jwtService.verify()
进行token
的令牌格式校验、签名验证、过期时间校验,确保令牌的完整性、真实性和有效性
import {
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from 'src/app.module';
import { AuthService } from './auth.service';
import { UserService } from 'src/user/user.service';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor() {
super();
}
async canActivate(context: ExecutionContext): Promise<any> {
const req = context.switchToHttp().getRequest();
// 如果是请求路由是白名单中的,则直接放行
if (this.hasUrl(this.whiteList, req.url)) return true;
try {
const accessToken = req.get('Authorization');
if (!accessToken) throw new UnauthorizedException('请先登录');
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const authService = app.get(AuthService);
const userService = app.get(UserService);
const tokenUserInfo = await authService.verifyToken(accessToken);
const resData = await userService.findOne(tokenUserInfo.username);
if (resData[0].id) return true;
} catch (e) {
console.log('1h 的 token 过期啦!请重新登录');
return false;
}
}
// 白名单数组
private whiteList: string[] = ['/auth/register','/auth/login'];
// 验证该次请求是否为白名单内的路由
private hasUrl(whiteList: string[], url: string): boolean {
let flag = false;
if (whiteList.indexOf(url) !== -1) {
flag = true;
}
return flag;
}
}
在guard
中,当我们return true
时,好比路由前置守卫的next()
,就是认证通过了放行的意思
当然,别忘了注册守卫,我们这里可以采用全局守卫的形式注册,在main.ts
中app.useGlobalGuards(new JwtAuthGuard());
那么这个token
我们在哪里去拦截它进行校验呢?
那就要提到我们 nest 的guard
(守卫)这个概念。其实就好比我们在vue项目中,封装路由前置守卫拦截路由跳转,去获取存储在localStorage
的token
一样。
在 nest 守卫中我们可以去获取到请求体req
,从而获取到请求头中的Authorization
字段,查看是否携带token
,然后去校验token
合法性,authService.verifyToken()
中调用jwtService.verify()
进行token
的令牌格式校验、签名验证、过期时间校验,确保令牌的完整性、真实性和有效性
import {
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from 'src/app.module';
import { AuthService } from './auth.service';
import { UserService } from 'src/user/user.service';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor() {
super();
}
async canActivate(context: ExecutionContext): Promise<any> {
const req = context.switchToHttp().getRequest();
// 如果是请求路由是白名单中的,则直接放行
if (this.hasUrl(this.whiteList, req.url)) return true;
try {
const accessToken = req.get('Authorization');
if (!accessToken) throw new UnauthorizedException('请先登录');
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const authService = app.get(AuthService);
const userService = app.get(UserService);
const tokenUserInfo = await authService.verifyToken(accessToken);
const resData = await userService.findOne(tokenUserInfo.username);
if (resData[0].id) return true;
} catch (e) {
console.log('1h 的 token 过期啦!请重新登录');
return false;
}
}
// 白名单数组
private whiteList: string[] = ['/auth/register','/auth/login'];
// 验证该次请求是否为白名单内的路由
private hasUrl(whiteList: string[], url: string): boolean {
let flag = false;
if (whiteList.indexOf(url) !== -1) {
flag = true;
}
return flag;
}
}
在guard
中,当我们return true
时,好比路由前置守卫的next()
,就是认证通过了放行的意思
当然,别忘了注册守卫,我们这里可以采用全局守卫的形式注册,在main.ts
中app.useGlobalGuards(new JwtAuthGuard());
Socket.IO如何实现即时聊天?
Nest中WebSocket网关的作用
使用 @WebSocketGateway 装饰器配置 WebSocket 网关在 Nest.js 应用中具有以下作用:
- 提供 WebSocket 的入口点:WebSocket 网关允许客户端通过 WebSocket 协议与后端建立实时的双向通信。通过配置网关,你可以定义用于处理 WebSocket 连接、消息传递和事件的逻辑。
- 处理跨域请求:在 WebSocket 中,默认存在跨域限制,即只能与同源的 WebSocket 服务进行通信。通过设置 origin 选项,WebSocket 网关可以解决跨域请求问题,允许来自指定源的请求进行跨域访问。
关于Socket.IO是怎么通讯的可以看看官网给出的图

socketIO
是通过事件监听的形式,我们可以很清晰的区分出消息的类型,方便对不同类型的消息进行处理,客户端和服务端双方事先约定好不同的事件,事件由谁监听,由谁触发,就可以把各种消息进行有序管理了

下面是一个简单的通讯事件示例:
import {
WebSocketGateway,
SubscribeMessage,
WebSocketServer,
MessageBody,
ConnectedSocket,
} from '@nestjs/websockets';
import { SocketService } from './socket.service';
import { CreateSocketDto } from './dto/create-socket.dto';
import { UpdateSocketDto } from './dto/update-socket.dto';
import { Socket } from 'socket.io';
const roomList = {};
let roomId = null;
let user = '';
@WebSocketGateway(3001, {
allowEIO3: true, // 开启后要求前后端使用的 Socket.io 版本要保持一致
//后端解决跨域
cors: {
// 允许具体源的请求进行跨域访问
origin: 'http://localhost:8080', //这里不要写*,要写 true 或者具体的前端请求时所在的域,否则会出现跨域问题
// 允许在跨域请求中发送凭据
credentials: true,
},
})
export class SocketGateway {
constructor(private readonly socketService: SocketService) {}
@SubscribeMessage('ToClient')
ToClient(@MessageBody() data: any) {
// 转发信息
const forwardMsg: string = '服务端=>客户端';
return {
//通过return返回客户端转发事件
event: 'forward',
data: forwardMsg, //data后面跟携带数据
};
}
//接收并处理来自客户端的消息
@SubscribeMessage('toServer')
handleServerMessage(client: Socket, data: string) {
console.log(data + ' (让我服务端来进行一下处理)');
client.emit('ToClient', data + '(处理完成给客户端)');
}
}
使用 @WebSocketGateway 装饰器配置 WebSocket 网关在 Nest.js 应用中具有以下作用:
- 提供 WebSocket 的入口点:WebSocket 网关允许客户端通过 WebSocket 协议与后端建立实时的双向通信。通过配置网关,你可以定义用于处理 WebSocket 连接、消息传递和事件的逻辑。
- 处理跨域请求:在 WebSocket 中,默认存在跨域限制,即只能与同源的 WebSocket 服务进行通信。通过设置 origin 选项,WebSocket 网关可以解决跨域请求问题,允许来自指定源的请求进行跨域访问。
关于Socket.IO是怎么通讯的可以看看官网给出的图socketIO
是通过事件监听的形式,我们可以很清晰的区分出消息的类型,方便对不同类型的消息进行处理,客户端和服务端双方事先约定好不同的事件,事件由谁监听,由谁触发,就可以把各种消息进行有序管理了
下面是一个简单的通讯事件示例:
import {
WebSocketGateway,
SubscribeMessage,
WebSocketServer,
MessageBody,
ConnectedSocket,
} from '@nestjs/websockets';
import { SocketService } from './socket.service';
import { CreateSocketDto } from './dto/create-socket.dto';
import { UpdateSocketDto } from './dto/update-socket.dto';
import { Socket } from 'socket.io';
const roomList = {};
let roomId = null;
let user = '';
@WebSocketGateway(3001, {
allowEIO3: true, // 开启后要求前后端使用的 Socket.io 版本要保持一致
//后端解决跨域
cors: {
// 允许具体源的请求进行跨域访问
origin: 'http://localhost:8080', //这里不要写*,要写 true 或者具体的前端请求时所在的域,否则会出现跨域问题
// 允许在跨域请求中发送凭据
credentials: true,
},
})
export class SocketGateway {
constructor(private readonly socketService: SocketService) {}
@SubscribeMessage('ToClient')
ToClient(@MessageBody() data: any) {
// 转发信息
const forwardMsg: string = '服务端=>客户端';
return {
//通过return返回客户端转发事件
event: 'forward',
data: forwardMsg, //data后面跟携带数据
};
}
//接收并处理来自客户端的消息
@SubscribeMessage('toServer')
handleServerMessage(client: Socket, data: string) {
console.log(data + ' (让我服务端来进行一下处理)');
client.emit('ToClient', data + '(处理完成给客户端)');
}
}
私聊模块中的 socket 事件
通过使用client.broadcast.emit('showMessage')
和 client.emit('showMessage')
,你可以实现多人实时聊天的功能。
当一个客户端发送一条消息时,通过 client.broadcast.emit('showMessage')
将该消息广播给其他客户端,让其他客户端可以接收到这条消息并进行相应的处理,从而实现多人实时聊天的效果。
同时,使用 client.emit('showMessage')
可以将消息发送给当前连接的客户端,这样当前客户端也会收到自己发送的消息,以便在界面上显示自己发送的内容。
@SubscribeMessage('sendMessage')
sendMessage(client: Socket) {
// 将该消息广播给其他客户端
client.broadcast.emit('showMessage');
// 将消息发送给当前连接的客户端
client.emit('showMessage');
return;
}
前端中会在UserList.tsx
监听该事件showMessage,并触发更新信息逻辑
useEffect(() => {
socket.on('showMessage', getCurentMessages)
return () => {
socket.off('showMessage')
}
})
通过使用client.broadcast.emit('showMessage')
和 client.emit('showMessage')
,你可以实现多人实时聊天的功能。
当一个客户端发送一条消息时,通过 client.broadcast.emit('showMessage')
将该消息广播给其他客户端,让其他客户端可以接收到这条消息并进行相应的处理,从而实现多人实时聊天的效果。
同时,使用 client.emit('showMessage')
可以将消息发送给当前连接的客户端,这样当前客户端也会收到自己发送的消息,以便在界面上显示自己发送的内容。
@SubscribeMessage('sendMessage')
sendMessage(client: Socket) {
// 将该消息广播给其他客户端
client.broadcast.emit('showMessage');
// 将消息发送给当前连接的客户端
client.emit('showMessage');
return;
}
前端中会在UserList.tsx
监听该事件showMessage,并触发更新信息逻辑
useEffect(() => {
socket.on('showMessage', getCurentMessages)
return () => {
socket.off('showMessage')
}
})
房间模块中的 socket 事件
@SubscribeMessage('sendRoomMessage')
sendRoomMessage(client: Socket, data) {
console.log('服务端接收到了');
// // 将消息发送给指定房间内的所有客户端
this.socketIO.to(roomId).emit('sendRoomMessage', data);
return;
}
在需要发送消息给指定房间时,即我们需要在全局中找到指定房间,所以我们需要整个 WebSocket 服务器的实例
@WebSocketServer()
socketIO: Socket; //它表示整个 WebSocket 服务器的实例。它可以用于执行全局操作,如向所有连接的客户端广播消息或将客户端连接到特定的房间。
@SubscribeMessage('sendRoomMessage')
sendRoomMessage(client: Socket, data) {
console.log('服务端接收到了');
// // 将消息发送给指定房间内的所有客户端
this.socketIO.to(roomId).emit('sendRoomMessage', data);
return;
}
在需要发送消息给指定房间时,即我们需要在全局中找到指定房间,所以我们需要整个 WebSocket 服务器的实例
@WebSocketServer()
socketIO: Socket; //它表示整个 WebSocket 服务器的实例。它可以用于执行全局操作,如向所有连接的客户端广播消息或将客户端连接到特定的房间。
加入和退出房间的 socket API
踩坑
- socket实例的创建写在了函数组件内,useState中的变量的频繁改变,导致的组件不断重新渲染,socket实例也被不断创建,形成过多的连接,让websocket服务崩溃!!!
解决:
把socket实例的创建拿出来放在单独的文件中,这样在各个函数组件中若使用的话只用引用这一共同的socket实例,仅与websocket服务器形成一个连接
- socket事件的监听没有及时的停止,导致对同一事件的监听不断叠加(如sys事件),当触发一次这一事件时,会同时触发到之前叠加的所有监听函数!!!
项目中的效果就是不断重新进入房间时,提示信息的渲染次数会递增的增加,而不是只提示一次
- socket实例的创建写在了函数组件内,useState中的变量的频繁改变,导致的组件不断重新渲染,socket实例也被不断创建,形成过多的连接,让websocket服务崩溃!!!
解决:
把socket实例的创建拿出来放在单独的文件中,这样在各个函数组件中若使用的话只用引用这一共同的socket实例,仅与websocket服务器形成一个连接
- socket事件的监听没有及时的停止,导致对同一事件的监听不断叠加(如sys事件),当触发一次这一事件时,会同时触发到之前叠加的所有监听函数!!!
项目中的效果就是不断重新进入房间时,提示信息的渲染次数会递增的增加,而不是只提示一次
解决:
在离开房间后要socket.off('sys');
要停止事件监听,另外最好是在组件销毁时停止所有事件的监听(此处为Next/React18,即项目前端的代码)
/* client */
useEffect(() => {
console.log('chat组件挂载');
// 连接自动触发
socket.on('connect', () => {
socket.emit('connection');
// 其他客户端事件和逻辑
});
return () => {
console.log('chat组件卸载');
socket.off();// 停止所有事件的监听 !!!
};
}, []);
/* server */
@SubscribeMessage('connection')
connection(client: Socket, data) {
console.log('有一个客户端连接成功', client.id);
// 断连自动触发
client.on('disconnect', () => {
console.log('有一个客户端断开连接', client.id);
// 处理断开连接的额外逻辑
});
return;
}
来源:juejin.cn/post/7295681529606832138
前端时间分片渲染
在经典的面试题中:”如果后端返回了十万条数据要你插入到页面中,你会怎么处理?”
除了像 useVirtualList 这样的虚拟列表来处理外,我们还可以通过 时间分片
来处理
通过 setTimeout
直接上一个例子:
<!--
* @Author: Jolyne
* @Date: 2023-09-22 15:45:45
* @LastEditTime: 2023-09-22 15:47:24
* @LastEditors: Jolyne
* @Description:
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>十万数据渲染</title>
</head>
<body>
<ul id="list-container"></ul>
<script>
const oListContainer = document.getElementById('list-container')
const fetchData = () => {
return new Promise(resolve => {
const response = {
code: 0,
msg: 'success',
data: [],
}
for (let i = 0; i < 100000; i++) {
response.data.push(`content-${i + 1}`)
}
setTimeout(() => {
resolve(response)
}, 100)
})
}
// 模拟请求后端接口返回十万条数据
// 渲染 total 条数据中的第 page 页,每页 pageCount 条数据
const renderData = (data, total, page, pageCount) => {
// base case -- total 为 0 时没有数据要渲染 不再递归调用
if (total <= 0) return
// total 比 pageCount 少时只渲染 total 条数据
pageCount = Math.min(pageCount, total)
setTimeout(() => {
const startIdx = page * pageCount
const endIdx = startIdx + pageCount
const dataList = data.slice(startIdx, endIdx)
// 将 pageCount 条数据插入到容器中
for (let i = 0; i < pageCount; i++) {
const oItem = document.createElement('li')
oItem.innerText = dataList[i]
oListContainer.appendChild(oItem)
}
renderData(data, total - pageCount, page + 1, pageCount)
}, 0)
}
fetchData().then(res => {
renderData(res.data, res.data.length, 0, 200)
})
</script>
</body>
</html>
上面的例子中,我们使用了 setTimeout
,在每一次宏任务中插入一页数据,然后设置多个这样地宏任务,直到把所有数据都插入为止。
但是很明显能看到的问题是,快速拖动滚动条时,数据列表中会有闪烁的情况
这是因为:
当使用
setTimeout
来拆分大量的 DOM 插入操作时,虽然我们将延迟时间设置为 0ms,但实际上由于 JavaScript 是单线程的,任务执行时会被放入到事件队列中,而事件队列中的任务需要等待当前任务执行完成后才能执行。所以即使设置了 0ms 延迟,setTimeout
的回调函数也不一定会立即执行,可能会受到其他任务的阻塞。
当
setTimeout
的回调函数执行的间隔超过了浏览器每帧更新的时间间隔(一般是 16.7ms),就会出现丢帧现象。丢帧指的是浏览器在更新页面时,没有足够的时间执行全部的任务,导致部分任务被跳过,从而导致页面渲染不连续,出现闪烁的情况
所以,我们改善一下,通过 requestAnimationFrame
来处理
通过 requestAnimationFrame
<!--
* @Author: Jolyne
* @Date: 2023-09-22 15:45:45
* @LastEditTime: 2023-09-22 15:47:24
* @LastEditors: Jolyne
* @Description:
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>直接插入十万条数据</title>
</head>
<body>
<ul id="list-container"></ul>
<script>
const oListContainer = document.getElementById('list-container')
const fetchData = () => {
return new Promise(resolve => {
const response = {
code: 0,
msg: 'success',
data: [],
}
for (let i = 0; i < 100000; i++) {
response.data.push(`content-${i + 1}`)
}
setTimeout(() => {
resolve(response)
}, 100)
})
}
// 模拟请求后端接口返回十万条数据
// 渲染 total 条数据中的第 page 页,每页 pageCount 条数据
const renderData = (data, total, page, pageCount) => {
// base case -- total 为 0 时没有数据要渲染 不再递归调用
if (total <= 0) return
// total 比 pageCount 少时只渲染 total 条数据
pageCount = Math.min(pageCount, total)
requestAnimationFrame(() => {
const startIdx = page * pageCount
const endIdx = startIdx + pageCount
const dataList = data.slice(startIdx, endIdx)
// 将 pageCount 条数据插入到容器中
for (let i = 0; i < pageCount; i++) {
const oItem = document.createElement('li')
oItem.innerText = dataList[i]
oListContainer.appendChild(oItem)
}
renderData(data, total - pageCount, page + 1, pageCount)
})
}
fetchData().then(res => {
renderData(res.data, res.data.length, 0, 200)
})
</script>
</body>
</html>
很明显,闪烁的问题被解决了
这是因为:
requestAnimationFrame
会在浏览器每次进行页面渲染时执行回调函数,保证了每次任务的执行间隔是稳定的,避免了丢帧现象。所以在处理大量 DOM 插入操作时,推荐使用requestAnimationFrame
来拆分任务,以获得更流畅的渲染效果
来源:juejin.cn/post/7282756858174980132