方寸之间窥万象——这样的Tooltip,你会开发吗?
序言
提示信息(tooltip)是一种常见的 GUI 元素。在可视化领域,tooltip 通常指用户将鼠标悬停在图元上或者图表区域时弹出的明细数据信息框。如果是桌面环境,通常会在用户将指针悬停在元素上而不单击它时显示 tooltip;如果是移动环境,通常会在长按(即点击并按住)元素时显示 tooltip。
这样一个小小的组件,却可以十分有效地丰富图表的数据展现能力和图表交互效果,同时在实际业务领域的用途也非常广泛。
近些年来,业界主要图表库(如ECharts、G2等)都提供了 tooltip 的配置能力和默认渲染能力,以达到开箱即用的效果。VChart 更不例外,提供了更加灵活的 tooltip 展示与配置方案。
通过使用 VChart,你既可以显示图表中任何系列的图元所携带的数据信息(mark tooltip):
也可以显示某个特定维度项下的所有图元的数据信息(dimension tooltip):
乃至可以灵活地自定义 tooltip,甚至在其中插入子图表,拓展交互的边界:
本文将通过一些实战案例,详细讲述 VChart 提示信息的重点用法、自定义方式以及设计细节。
示例一:可触及的 tooltip,与 Amazon 的安全三角形
为了不对用户的鼠标交互进行干扰,VChart 的 tooltip 默认不会响应鼠标事件。但是在某些情况,用户却希望鼠标可以移到 tooltip 中进行一些额外的交互行为,比如点击 tooltip 中的按钮和链接,或者选取并复制一些数据。
为了满足这类需求,tooltip 支持在 spec 中配置 enterable
属性。如果不配置或者配置 enterable: false
,默认效果是这样的,鼠标无法移到 tooltip 元素内:
而如果配置 enterable: true
,效果如以下截图所示:
图表简化版 spec 为:
const spec = {
type: 'waterfall',
data: [], // 数据略
legends: { visible: true, orient: 'bottom' },
xField: 'x',
yField: 'y',
seriesField: 'type',
total: {
type: 'field',
tagField: 'total'
},
title: {
visible: true,
text: 'Chinese quarterly GDP in 2022'
},
tooltip: {
enterable: true // TOOLTIP SPEC
}
};
const vchart = new VChart(spec, { dom: CONTAINER_ID });
vchart.renderSync();
简单对比两个 tooltip 的效果,可以发现后者在鼠标靠近 tooltip 时,tooltip 便适时地停住了。
这个小小的交互细节里却有些文章,灵感来源直接来自 Amazon 的官网实现。
这个例子也许已经为人熟知,我们简单回顾一下。这首先要从普通网站的下拉菜单开始讲起:
在一个设计欠佳的菜单组件里(如 bootstrap),鼠标从一级菜单移入二级菜单往往是很困难的,很容易触发二级菜单的隐藏策略,从而变成一场无聊的打地鼠游戏。
但是 Amazon 早期官网的菜单,由于用户使用频率高,根本无法接受这样的体验。于是他们完美地解决了这个问题,并成为一个交互优化的经典案例。
其思路的核心,便是检测鼠标移动的方向。如果鼠标移动到下图的蓝色三角形中,当前显示的子菜单将继续打开一小会儿:
在鼠标的每个位置,你可以想象鼠标当前位置与下拉菜单的右上角和右下角之间形成一个三角形。如果下一个鼠标位置在该三角形中,说明用户可能正在将鼠标移向当前显示的子菜单。Amazon 利用这一点实现了很好的效果。只要鼠标停留在该蓝色三角形中,当前子菜单就会保持打开。如果鼠标移出该三角形,他们会立即切换子菜单,使其感觉非常敏捷。
整体效果类似于下图所示:
正所谓,上帝在细节中(God is in the details)。从这个交互优化里,我们看到的不仅是一个精妙的算法,而是一个科技巨头对于产品和用户体验的态度。Amazon 的数百亿市值有多少是从这些很小很小,但是明显很用心的产品细节中积累起来的呢?
VChart 的 tooltip 也一样,着重参考了 Amazon 的交互优化。如果配置 enterable: true
,在每个时刻,都会存在一个这样的“安全三角形”,三个顶点分别是鼠标光标以及 tooltip 的两个端点,取面积最大的三角形:
如果鼠标在下一刻滑到这个三角形区域中, tooltip 便为鼠标“停留一会儿”,直到鼠标移到 tooltip 区域内。
但是在鼠标移到 tooltip 区域之前,tooltip 并不会永远停下来等待鼠标。如果鼠标过于缓慢地靠近 tooltip,tooltip 还是会离开的(变成一场失败、却又在意料之中的奔赴)。这样便可以同时保证用户鼠标有足够的行动自由度。以下示例特地将鼠标移动速度放慢,便可以实现既进入三角形区域,又不会被 tooltip “挡路”:
作为对比,ECharts 的 tooltip 虽然同样支持 enterable
属性,但是 ECharts 主要通过简单的 tooltip 缓动来支持鼠标移入,鼠标仍需要不停地“追逐” tooltip 才能移至其中,灵活性便打了折扣。以下为 ECharts 的效果:
示例二:灵活的 pattern,内容与样式的自由配置
为了尽最大可能满足更多业务方的需求,VChart 的 tooltip 支持比较灵活的内容和样式配置。下文将以官网 demo(http://www.visactor.io/vchart/demo…
在这个图表中,用户配置了一条 y=10000
的标注线。同时要求在 dimension tooltip 中实现:
- 数据项从大到小排序;
- 比标注线高的数据项标红(条件格式);
- 在 tooltip 内容的最后一行加上标注线所代表的数据。
同时,这个 tooltip 的位置还拥有以下特征:
- dimension tooltip 的位置固定在光标上方;
- mark tooltip 的位置固定在数据项下方。
如以下动图所示:
这个示例实际上代表了很多不同类型的业务需求。下面拆解来看一下:
基本 tooltip 内容配置
首先,剥去自定义内容和样式的部分,这个图表的最简 spec 和基本 tooltip 配置如下:
const markLineValue = 10000;
const spec = {
type: 'line',
data: {
values: [
{ type: 'Nail polish', country: 'Africa', value: 4229 },
// 其他数据略
]
},
stack: false,
xField: 'type',
yField: 'value',
seriesField: 'country',
legends: [{ visible: true, position: 'middle', orient: 'bottom' }],
markLine: [
{
y: markLineValue,
endSymbol: { visible: false },
line: { style: { /* 样式配置略 */ }}
}
],
tooltip: { // TOOLTIP SPEC
mark: {
title: {
value: datum => datum.type
},
content: [
{
key: datum => datum.country,
value: datum => datum.value
}
]
},
dimension: {
title: {
value: datum => datum.type
},
content: [
{
key: datum => datum.country,
value: datum => datum.value
}
]
}
}
};
const vchart = new VChart(spec, { dom: CONTAINER_ID });
vchart.renderSync();
显示效果如下:
观察 spec 不难发现,mark tooltip 和 dimension tooltip 分别用回调方式配置了 tooltip 的显示内容。其中:
- title.value 显示的是数据项中对应于
xField
的内容; - content.key 显示的是数据项中对应于
seriesField
(也是区分出图例项的 field)的内容; - content.value 显示的是数据项中对应于
yField
的内容。
回调是 tooltip 内容的基本配置方式,用户可以在 title 和 content 中自由配置回调函数来实现数据绑定和字符串的格式化。
Tooltip 内容的排序、增删、条件格式
我们再来看一下 dimension tooltip 的 spec:
{
tooltip: { // TOOLTIP SPEC
mark: { /* ...略 */ },
dimension: {
title: {
value: datum => datum.type
},
content: [
{
key: datum => datum.country,
value: datum => datum.value
}
]
}
}
}
不难发现,content 配置的数组只包含 1 个对象,但是上图显示出来却有 4 行内容。为什么呢?
其实在 dimension tooltip 中发生了和折线图元类似的 data join 过程:由于在数据中,seriesField
划分出了 4 个数据组(图例项有 4 个),因此在经过笛卡尔积后,真实 tooltip 内容行数为 content 数组成员数量乘以 4。在数据组装过程中,每个数据组都要依次走一遍 content 数组成员里的回调。
我们把 spec 中的 tooltip 内容配置称为 TooltipPattern,tooltip 所需数据称为 TooltipData,最终的 tooltip 结构称为 TooltipActual。数据组装过程可以表示为:
在本例中,经过这个过程,TooltipPattern 中的回调在 TooltipActual 中消失(回调已被执行 4 次),且由 1 行变成了 4 行。
这个过程完整的执行流程如下:
那么回到示例中的用户需求,用户希望将 tooltip 内容行由大到小排序。那么这个步骤自然要在 TooltipActual 生成之后执行,也就是上图中的 “updateTooltipContent” 过程。
Tooltip spec 中支持配置 updateContent
回调来对 TooltipActual 的 content 部分进行操作。排序可以这样写:
{
tooltip: { // TOOLTIP SPEC
mark: { /* ...略 */ },
dimension: {
title: {
value: datum => datum.type
},
content: [
{
key: datum => datum.country,
value: datum => datum.value
}
],
updateContent: prev => {
// 排序
prev.sort((a, b) => b.value - a.value);
}
}
}
}
updateContent
回调的第一个参数为已经计算好的 TooltipActual。加上回调以后,排序生效:
在 tooltip 中实现条件格式以及新增一行也是一样的方法,可以直接在 updateContent
回调中处理:
{
updateContent: prev => {
// 排序
prev.sort((a, b) => b.value - a.value);
// 条件格式:比标注线高的数据项标红
prev.forEach(item => {
if (item.value >= markLineValue) {
item.valueStyle = {
fill: 'red'
};
}
});
// 新增一行
prev.push({
key: 'Mark Line',
value: markLineValue,
keyStyle: { fill: 'orange' },
valueStyle: { fill: 'orange' },
// 自定义 shape 的 svg path
shapeType: 'M44.3,22.1H25.6V3.3h18.8V22.1z M76.8,3.3H58v18.8h18.8V3.3z M99.8,3.3h-9.4v18.8h9.4V3.3z M12.9,3.3H3.5v18.8h9.4V3.3z',
shapeColor: 'orange',
hasShape: true
});
}
}
调试 spec,回调生效,最后效果如下:
Tooltip 样式和位置
VChart tooltip 支持将 tooltip 固定于某图元附近或者鼠标光标附近。在本例中,mark tooltip 固定于图元的下方,而 dimension tooltip 固定于鼠标光标的上方,可以这样配置:
{
tooltip: { // TOOLTIP SPEC
mark: {
// 其他配置略
position: 'bottom' // 显示在下方
positionAt: 'mark' // 固定在图元附近,由于这是默认值,这行可以删掉
},
dimension: {
// 其他配置略
position: 'top', // 显示在上方
positionAt: 'pointer' // 固定在鼠标光标附近
}
}
}
而样式配置可以在 tooltip spec 上的 style
配置项下进行自定义。style
支持配置 tooltip 组件各个组成部分的统一样式,详细配置项可参考官网文档(http://www.visactor.io/vchart/opti…
最后效果如下,完整 spec 可见官网 demo(http://www.visactor.io/vchart/demo…
示例三:锦上添花,可按需修改的 tooltip dom 树
VChart 的 tooltip 共支持两种渲染模式:
- Dom 渲染,适用于桌面或移动端浏览器环境;
- Canvas 渲染,适用于移动端小程序、node 环境等非浏览器环境。
对于 dom 版本的 tooltip,为了更好的支持业务方的自定义需求,VChart 开放了对 tooltip dom 树的修改接口。下文将以官网 demo(http://www.visactor.io/vchart/demo…
在这个示例中,用户要求在 tooltip 的底部增加一个超链接,用户点击链接后,便可以自动跳转到 Google,对 tooltip 标题进行进一步搜索。这个示例要求两个能力:
- 示例一介绍的 enterable 能力,开启后鼠标会被允许滑入 tooltip 区域,这是在 tooltip 中实现交互的前提;
- 在默认 tooltip 上绘制自定义的 dom 元素。
为了实现第二个能力,tooltip 支持了回调 updateElement
,这个回调配在 tooltip spec 顶层。这个示例的 tooltip 配置如下:
{
tooltip: { // TOOLTIP SPEC
enterable: true,
updateElement: (el, actualTooltip, params) => {
// 自定义元素只加在 dimension tooltip 上
if (actualTooltip.activeType === 'dimension') {
const { changePositionOnly, dimensionInfo } = params;
// 判断本次 tooltip 显示是否仅改变了位置,如果是的话退出
if (changePositionOnly) { return; }
// 改变默认 tooltip dom 的宽高策略
el.style.width = 'auto';
el.style.height = 'auto';
el.style.minHeight = 'auto';
el.getElementsByClassName('value-box')[0].style.flex = '1';
for (const valueLabel of el.getElementsByClassName('value')) {
valueLabel.style.maxWidth = 'none';
}
// 删除上次执行回调添加的自定义元素
if (el.lastElementChild?.id === 'button-container') {
el.lastElementChild.remove();
}
// 添加新的自定义元素
const div = document.createElement('div');
div.id = 'button-container';
div.style.margin = '10px -10px 0px';
div.style.padding = '10px 0px 0px';
div.style.borderTop = '1px solid #cccccc';
div.style.textAlign = 'center';
div.innerHTML = `href="https://www.google.com/search?q=${dimensionInfo[0]?.value}"
style="text-decoration: none"
target="_blank"
>Search with Google`;
el.appendChild(div);
} else {
// 对于 mark tooltip,删除上次执行回调添加的自定义元素
if (el.lastElementChild?.id === 'button-container') {
el.lastElementChild.remove();
}
}
}
}
}
updateElement
在每次 tooltip 被激活或者更新时触发,在触发时,TooltipActual 已经计算完毕,且 dom 节点也已经准备好。回调的第一个参数便是本次将要显示的 tooltip dom 根节点。目前不支持替换该节点,只支持对该节点以及其孩子进行修改。
这个配置的设计最大限度地复用了 VChart tooltip 的内置逻辑,同时提供了足够自由的自定义功能。你可以随心所欲地定制 tooltip 显示内容,并且复用任何你没有覆盖的逻辑。
比如,你可以对 tooltip 的大小进行重新定义,而不用关心窗口边界的躲避策略是否会出问题。事实上 VChart 会自动把 tooltip 定位逻辑复用在修改过的 dom 上:
这个回调还可以进一步封装,比如在 react-vchart 中将用户侧的 react 组件插入 tooltip。目前这个封装主要由业务侧自主进行,后续 VChart 也有计划提供官方支持。
示例四:完全自定义,由业务托管 tooltip 渲染
若要更进一步,VChart tooltip 最高级别的自定义,便是让 VChart 完全将 tooltip 渲染交给用户。有以下两种方式可以选择:
- 用户自定义 tooltip handler
- 用户使默认 tooltip 失效,监听 tooltip 事件
再结合示例二、示例三的铺垫,便可以带出整个 tooltip 模块的设计架构。熟悉了架构便更容易了解每条渲染路径以及各个层级的关系。
由上图可见,示例三对应的是 “Custom DOM Render” 的自定义,示例二对应了 “Custom TooltipActual” 部分的自定义。而示例四,便是对应整个 “Tooltip Events” 以及 “Custom TooltipHandler” 的自定义。
由于上图中,“Tooltip Events” 和 “Custom TooltipHandler” 纵跨了多个层级,因此它覆盖的默认逻辑是最多的,体现在:
- 当给图表设置了自定义 tooltip handler 后,内置的 tooltip 将不再起作用。
- VChart 不感知、不托管自定义 tooltip 的渲染,需要自行实现 tooltip 渲染,包括处理原始数据、tooltip 内容设计,以及根据项目环境创建组件并设置样式。
- 当图表删除时会调用当前 tooltip handler 的
release
函数,需要自行实现删除。
目前,火山引擎 DataWind 正是使用自定义 tooltip handler 的方式实现了自己的图表 tooltip。DataWind 支持用户对tooltip 进行富文本渲染,甚至支持了 tooltip 内渲染图表的能力。
另外,也可以参考官网示例(http://www.visactor.io/vchart/demo…
自定义 tooltip handler 的核心是调用 VChart 实例方法 setTooltipHandler
,部分示例代码如下:
vchart.setTooltipHandler({
showTooltip: (activeType, tooltipData, params) => {
const tooltip = document.getElementById('tooltip');
tooltip.style.left = params.event.x + 'px';
tooltip.style.top = params.event.y + 'px';
let data = [];
if (activeType === 'dimension') {
data = tooltipData[0]?.data[0]?.datum ?? [];
} else if (activeType === 'mark') {
data = tooltipData[0]?.datum ?? [];
}
tooltipChart.updateData(
'tooltipData',
data.map(({ type, value, month }) => ({ type, value, month }))
);
tooltip.style.visibility = 'visible';
},
hideTooltip: () => {
const tooltip = document.getElementById('tooltip');
tooltip.style.visibility = 'hidden';
},
release: () => {
tooltipChart.release();
const tooltip = document.getElementById('tooltip');
tooltip.remove();
}
});
其他特性一览
VChart tooltip 包含一些其他的高级特性,下文将简要介绍。
在任意轴上触发 dimension tooltip
Dimension tooltip 一般最适合用于离散轴,ECharts 同时支持连续轴上的 dimension tooltip(axis tooltip)。而 VChart 支持了在连续轴、时间轴乃至在一个图表中的任意一个轴上触发 dimension tooltip。
以下示例展示了 dimension tooltip 在连续轴(时间轴)上汇总离散数据的能力(这个 case 和一般的 dimension tooltip 刚好相反):
一般的 dimension tooltip 会在离散轴(纵轴)触发 tooltip,汇总连续数据(对应于时间轴)。而 VChart 同时支持这两种方式的 tooltip。
Demo 地址:http://www.visactor.io/vchart/demo…
长内容支持:换行和局部滚动
过长的内容在 tooltip 上一般是 bad case。但是为了使长内容的浏览体验更好,VChart tooltip 可以配置多行文本以及内容区域局部滚动。如以下示例:
局部滚动 Demo 地址:http://www.visactor.io/vchart/demo…
多行文本配置项:http://www.visactor.io/vchart/opti…
结语
Tooltip 在提升用户浏览图表的体验中扮演着重要的角色。本文介绍了 VChart tooltip 的基本使用方法、技术设计以及多层面的自定义方案。然而为了保证行文清晰,VChart tooltip 还有一些其他的用法细节本文没有涉及,想了解更多可以查阅官网 demo 以及文档。
然而需要提醒的是,虽然 tooltip 能够有效传递数据与信息、以及增加图表的互动能力,但过分依赖它们可能会导致用户体验下降。合理地利用 tooltip,让它们在需要时出现而不干扰用户的主要任务,是设计和开发中应保持的平衡。
希望本文能为你在配置 VChart tooltip 时提供有用的指导。愿你在图表中创造更加直观、轻松且愉快的用户体验时,VChart 能成为你强大的伙伴。
github:github.com/VisActor/VC…
相关参考:
来源:juejin.cn/post/7337963242416422924
总算体会到jsx写法为啥灵活
前言
大家好,我是你不会困
,写代码就不会困,今天分享的是总算体会到jsx写法为啥灵活
什么是jsx写法?
当谈到JavaScript中的JSX写法时,人们往往会想到React和Vue这样的流行前端框架。JSX作为一种在JavaScript中编写类似于HTML的语法,为前端开发者提供了更灵活和直观的方式来构建用户界面。
JSX的灵活性体现在多个方面。首先,JSX允许开发者在JavaScript中嵌入HTML标记,使得代码更易读和维护。通过使用JSX,开发者可以在同一个文件中编写JavaScript逻辑和界面布局,而无需频繁切换不同的文件。这种混合编程风格提高了开发效率,同时也方便了代码的组织和调试。
其次,JSX支持在标记中使用JavaScript表达式,这使得动态生成界面变得更加简单。开发者可以在JSX中直接使用JavaScript变量、函数调用和逻辑控制语句,从而动态地渲染页面内容。这种灵活性使得开发者能够根据不同的数据状态和条件来动态展示内容,提升了用户体验。
另外,JSX还支持在标记中使用循环和条件语句,比如map
函数和条件渲染,从而实现列表展示、条件展示等常见的UI需求。这种功能使得开发者可以更方便地处理复杂的UI逻辑,同时简化了代码的编写和维护。
此外,JSX的组件化特性也为前端开发带来了很多好处。通过将UI拆分成独立的组件,开发者可以更好地组织和管理代码,提高代码的重用性和可维护性。JSX中的组件可以嵌套使用,形成复杂的UI结构,同时每个组件可以单独管理自己的状态和逻辑,使得代码更加清晰和可扩展。
今天在开发的时候发现,这两个即可开启总计列
show-summary
:summary-method="getSummaries"
但是产品的需求比较麻烦,需要渲染多行,查了相关的文档,好像没有这种渲染的demo,翻看项目的代码,有一部分代码的实现比较巧妙,使用的是jsx写法,然后就尝试着去实现
要在vue里面使用jsx写法,在script标签使用<script lang="jsx">
,即可使用
getSummaries(param) {
const { columns } = param
const sums = []
const nullHtml = '-'
columns.forEach((column, index) => {
if (index === 0) {
sums[index] = '总计'
return
}
if (this.totalSum.summaryReceivableComparisons) {
sums[index] = (
<div>
{this.totalSum.summaryReceivableComparisons.map((item) => (
<div class='cell-item' key={item.invoiceCurrency}>
<p>
{this.formatValue(
item[column.property],
column.property.includes('Ratio')
? 'percentage'
: 'thousandth'
)}
</p>
</div>
))}
</div>
)
} else {
sums[index] = nullHtml
return
}
})
return sums
},
上面的代码使用了map来遍历,将对应的html返回,el-table的总计列即可生效,来应对不同的需求
总结
总的来说,JSX作为JavaScript中的一种扩展语法,为前端开发带来了更灵活、直观和高效的开发体验。通过使用JSX,开发者可以更轻松地构建交互丰富、动态变化的用户界面,同时提高了代码的可读性和可维护性。JSX的灵活性和表现力使其成为现代前端开发中不可或缺的一部分。
来源:juejin.cn/post/7410672790020800548
Electron实现静默打印小票
Electron实现静默打印小票
静默打印流程
1.渲染进程通知主进程打印
//渲染进程 data是打印需要的数据
window.electron.ipcRenderer.send('handlePrint', data)
2.主进程接收消息,创建打印页面
//main.ts
/* 打印页面 */
let printWindow: BrowserWindow | undefined
/**
* @Author: yaoyaolei
* @Date: 2024-06-07 09:27:22
* @LastEditors: yaoyaolei
* @description: 创建打印页面
*/
const createPrintWindow = () => {
return new Promise<void>((resolve) => {
printWindow = new BrowserWindow({
...BASE_WINDOW_CONFIG,
title: 'printWindow',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
nodeIntegration: true,
contextIsolation: false
}
})
printWindow.on('ready-to-show', () => {
//打印页面创建完成后不需要显示,测试时可以调用show查看页面样式(下面有我处理的样式图片)
// printWindow?.show()
resolve()
})
printWindow.webContents.setWindowOpenHandler((details: { url: string }) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
printWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/print.html`)
} else {
printWindow.loadFile(join(__dirname, `../renderer/print.html`))
}
})
}
ipcMain.on('handlePrint', (_, obj) => {
//主进程接受渲染进程消息,向打印页面传递数据
if (printWindow) {
printWindow!.webContents.send('data', obj)
} else {
createPrintWindow().then(() => {
printWindow!.webContents.send('data', obj)
})
}
})
3.打印页面接收消息,拿到数据渲染页面完成后通知主进程开始打印
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>打印</title>
<style>
</style>
</head>
<body>
</body>
<script>
window.electron.ipcRenderer.on('data', (_, obj) => {
//这里是接受的消息,处理完成后将html片段放在body里面完成后就可以开始打印了
//样式可以写在style里,也可以内联
console.log('event, data: ', obj);
//这里自由发挥
document.body.innerHTML = '处理的数据'
//通知主进程开始打印
window.electron.ipcRenderer.send('startPrint')
})
</script>
</html>
这个是我处理完的数据样式,这个就是print.html
4,5.主进程接收消息开始打印,并且通知渲染进程打印状态
ipcMain.on('startPrint', () => {
//这里如果不指定打印机使用的是系统默认打印机,如果需要指定打印机,
//可以在初始化的时候使用webContents.getPrintersAsync()获取打印机列表,
//然后让用户选择一个打印机,打印的时候将打印机名称传过来
printWindow!.webContents.print(
{
silent: true,
margins: { marginType: 'none' }
//deviceName:如果要指定打印机传入打印机名称
},
(success) => {
//通知渲染进程打印状态
if (success) {
mainWindow.webContents.send('printStatus', 'success')
} else {
mainWindow.webContents.send('printStatus', 'error')
}
}
)
})
完毕~
来源:juejin.cn/post/7377645747448365091
我的 Electron 客户端被第三方页面入侵了...
问题描述
公司有个内部项目是用 Electron
来开发的,有个功能需要像浏览器一样加载第三方站点。
本来一切安好,但是某天打开某个站点的链接,导致 整个客户端直接变成了该站点的页面。
这一看就是该站点做了特殊的处理,经排查网页源码后,果然发现了有这么一句代码。
if (window.top !== window.self) {
window.top.location = window.location;
}
翻译一下就是:如果当前窗口不是顶级窗口的话,将当前窗口设置为顶级窗口。
奇怪的是两者不是 跨域 了吗,为什么 iframe
还可以影响顶级窗口。
先说一下我当时的一些解决办法:
- 用
webview
替换iframe
- 给
iframe
添加sandbox
属性
后续内容就是一点复盘工作。
场景复现(Web端)
一开始怀疑是客户端的问题,所以我用在纯 Web 上进行了一次对比验证。
这里我们新建两个文件:1.html
和 2.html
,我们称之为 页面A 和 页面B。
然后起了两个本地服务器来模拟同源与跨域的情况。
页面A:http://127.0.0.1:5500/1.html
页面B:http://127.0.0.1:5500/2.html
和 http://localhost:3000/2.html
符合同源策略
<body>
<h1>这是页面A</h1>
<!-- 这是同源的情况 -->
<iframe id="iframe" src="http://127.0.0.1:5500/2.html" />
<script>
iframe.onload = () => {
console.log('iframe loaded..')
console.log('子窗口路径', iframe.contentWindow.location.href)
}
</script>
</body>
<body>
<h2>这是页面B</h2>
<script>
console.log('page2...')
console.log(window === window.top)
console.log('顶部窗口路径', window.top.location.href)
</script>
</body>
我们打开控制台可以看到 页面A 和 页面B 是可以 互相访问 到对方窗口的路径。
如果这个时候在 页面B 加上文章开头提到的 代码片段,那么显然页面将会发生变化。
跨域的情况
这时候我们修改 页面A 加载 页面B 的地址,使其不符合同源策略。
理所应当的是,两个页面不能够相互访问了,这才是正常的,否则内嵌第三方页面可以互相修改,那就太不安全了。
场景复现(客户端)
既然 Web 端是符合预期的,那是不是 Electron
自己的问题呢?
我们通过 electron-vite 快速搭建了一个 React模板的electron应用
,版本为:electron@22.3.27
,并且在 App 中也嵌入了刚才的 页面B。
function App(): JSX.Element {
return (
<>
<h1>这是Electron页面</h1>
<iframe id="iframe" src="http://localhost:3000/2.html"/>
</>
)
}
export default App
对不起,干干净净的 Electron
根本不背这个锅,在它身上的表现如同 Web端 一样,也受同源策略的限制。
那么肯定是我的项目里有什么特殊的配置,通过对比主进程的代码,答案终于揭晓。
new BrowserWindow({
...,
webPreferences: {
...,
webSecurity: false // 就是因为它
}
})
Electron 官方文档 里是这么描述 webSecurity
这个配置的。
webSecurity
boolean (可选) - 当设置为false
, 它将禁用同源策略 (通常用来测试网站), 如果此选项不是由开发者设置的,还会把allowRunningInsecureContent
设置为true
. 默认值为true
。
也就是说,Electron
本身是有一层屏障的,但当该属性设置为 false
的时候,我们的客户端将会绕过同源策略的限制,这层屏障也就消失了,因此 iframe
的行为表现得像是嵌套了同源的站点一样。
解决方案
把这个配置去掉,确实是可以解决这个问题,但考虑到可能对其他功能造成的影响,只能采取其他方案。
如文章开头提到的,用 webview
替换 iframe
。
webview
是 Electron
的一个自定义元素(标签),可用于在应用程序中嵌入第三方网页,它默认开启安全策略,直接实现了主应用与嵌入页面的隔离。
因为目前这个需求是仅作展示,不需要与嵌套页面进行交互以及复杂的通信,因此在一开始的开发过程中,并没有使用它,而是直接采用了 iframe
。
而 iframe
也能够实现类似的效果,只需要添加一个 sandbox
属性可以解决。
MDN 中提到,sandbox
控制应用于嵌入在 <iframe>
中的内容的限制。该属性的值可以为空以应用所有限制,也可以为空格分隔的标记以解除特定的限制。
如此一来,就算是同源的,两者也不会互相干扰。
总结
这不是一个复杂的问题,发现后及时修复了,并没有造成很大的影响(还好是自己人用的平台)。
写这篇文章的主要目的是为了记录这次事件,让我意识到在平时开发过程中,把注意力过多的放在了 业务
、样式
、性能
等这些看得见的问题上,可能很少关注甚至忽略了 安全
这一要素,以为前端框架能够防御像 XSS
这样的攻击就能安枕无忧。
谨记,永远不要相信第三方,距离产生美。
如有纰漏,欢迎在评论区指出。
来源:juejin.cn/post/7398418805971877914
如何将用户输入的名称转成艺术字体-fontmin.js
写在开头
日常我们在页面中使用特殊字体,一般操作都是直接由前端来全量引入设计师提供的整个字体包即可,具体操作如下:
<template>
<div class="font">橙某人</div>
</template>
<style scoped>
@font-face {
font-family: "orange";
src: url("./orange.ttf");
}
.font {
font-family: "orange";
}
</style>
很简单吧🤡,但有时应用场景不同,可能需要我们考虑一下性能问题。
一般来说,我们常见的字体包整个是非常大的,小的有几M到十几M,大的可能去到上百M都有,特别是中文类的字体包会相对英文类的要更大一些。
如本文案例,我们仅需在用户输入完后加载对应的字体包即可,这样能避免性能的损耗。
为此,我们需要把整个字体包拆分、细致化、子集化,让它能达到按需引入的效果。
那么这要如何来做这个事情呢?这个方案单单前端可做不了,我们需要配合后端一起,下面就来看看具体的实现过程吧。😗
前端
前端小编用 Vue
来编写,具体如下:
<template>
<div>
<input v-model="name" />
<button @click="handleClick">生成</button>
<div v-if="showName" class="font">{{ showName }}</div>
</div>
</template>
<script>
export default {
data() {
return {
name: "",
showName: "",
};
},
methods: {
handleClick() {
// 创建link标签
const linkElement = document.createElement("link");
linkElement.setAttribute("rel", "stylesheet");
linkElement.setAttribute("type", "text/css");
linkElement.href = `http://localhost:3000?name=${encodeURIComponent(this.name)}`;
document.body.appendChild(linkElement);
// 优化显示效果
setTimeout(() => {
this.showName = this.name;
}, 300);
},
},
};
</script>
<style>
.font {
font-family: orange;
font-size: 50px;
}
</style>
应该都能看懂吧,主要就是生成了一个 <link />
标签并插入到文档中,标签的请求地址指向我们服务端,至于服务端会返回什么你可以先猜一猜。👻
服务端
服务端小编选择用 Koa2 来编写,你也可以选择 Express
或者 Egg
,甚至 Node
也是可以的,差异不大,具体逻辑如下:
const koa = require("koa2");
const fs = require("fs");
const FontMin = require("fontmin");
const app = new koa();
/** @name 优化,缓存已经加载过的字体包进内存 **/
const fontFamilyMap = {};
/** @name 加载字体包 **/
function loadFontLibrary(fontPath, fontFamily) {
if (fontFamilyMap[fontFamily]) return fontFamilyMap[fontFamily];
return new Promise((resolve, reject) => {
fs.readFile(fontPath, (error, file) => {
if (error) {
reject(new Error(error.message));
} else {
fontFamilyMap[fontFamily] = file;
resolve(file);
}
});
});
}
app.use(async (ctx) => {
const { name } = ctx.query;
// 设置返回文件类型
ctx.set("Content-Type", "text/css");
const fontPath = "./font/orange.ttf";
const fontFamily = "orange";
if (!fs.existsSync(fontPath)) return (ctx.body = "字体包读取失败");
const fontMin = new FontMin();
const fontFile = await loadFontLibrary(fontPath, fontFamily);
fontMin.src(fontFile);
const getFontCSS = () => {
return new Promise((resolve) => {
fontMin
.use(FontMin.glyph({ text: name }))
.use(FontMin.css({ base64: true, fontFamily }))
.run((error, files) => {
if (error) {
console.log("error", error.message);
} else {
const fontContent = files?.[1]?.contents;
resolve(fontContent);
}
});
});
};
const fontCSS = await getFontCSS();
ctx.body = fontCSS;
});
app.listen(3000);
console.log("服务器开启: http://localhost:3000/");
我们主要是采用了 Fontmin 库来完成整个字体包的按需加载功能,这个库是第一个纯 JavaScript
字体子集化方案。
可能有后端是
Java
或者其他技术栈的小伙伴,你们也不用担心,据小编和公司后端同事了解,不同技术栈也是有对应的库可以解决的,需要的可以自行查查看。
至此,本篇文章就写完啦,撒花撒花。
希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。
来源:juejin.cn/post/7293151700869038099
登录问题——web端
问题描述:
在集成环信SDK的过程中,大家可能会遇到一个令人困惑的问题:明明已经通过open登录成功了,但是在调用api时却总是报错,错误类型为type28或者type700或者type39 not login。本文将详细分析这个问题的原因,并提供相应的解决方案。
原因分析:
要解决这个问题,我们首先需要了解环信SDK的登录机制。登录过程实际上分为两个步骤:
1. 请求Token:这是open登录操作的第一步,即在open.then或者success回调中返回token。
2. 建立长连接:即建立WebSocket连接,触发onOpened或者onConnected回调。只有当onOpened或者onConnected回调被触发,才算是真正与环信服务器建立了连接。
SDK在拿到token后,会将其设置进入SDK并尝试建立连接。如果在onOpened或者onConnected回调触发之前就执行了api的调用,那么token可能还没有被正确设置进入SDK,从而导致后续的HTTP请求报token无效的错误。也就是出现type28或者type700或者type 39的报错。
解决方案:
为了避免这个问题,我们需要调整代码逻辑,确保在onOpened或者onConnected回调触发后再去请求一系列的接口。以下是具体的调整步骤:
1. 监听连接状态:在SDK初始化后,监听onOpened或者onConnected回调。
2. 延迟调用api操作:不要在open.then或者success回调中立即执行api的调用,而是等待onOpened或者onConnected回调触发后再执行。
3. 检查SDK状态:调用api前检查SDK是否已经成功建立连接。
可以用以下三种方法中的一种判断检查SDK是否已经成功建立连接~
1、WebIM.conn方法下有一个logOut字段,该字段为true时表明未登录状态,该字段为false时表明登录;
2、WebIM.conn.isOpened () 方法有三个状态,undefined为未登录状态,true为已登录状态,false为未登录状态,可以根据这三个状态去判断是否登录;
3、通过onOpened 这个回调来判断,只要执行了就说明登录成功了,输出的话,输出的是undefined
183天打造行业新标杆!BOE(京东方)国内首条第8.6代AMOLED生产线提前全面封顶
2024年9月25日,BOE(京东方)投建的国内首条第8.6代AMOLED生产线全面封顶仪式在成都市高新区举行,该生产线从开工到封顶仅用183天,以科学、高效、高质的速度再树行业新标杆。这不仅是BOE(京东方)创新突破、打造新质生产力的又一重大举措,也是OLED领域的里程碑事件,极大推动OLED显示产业快速迈进中尺寸发展阶段,对促进半导体显示产业优化升级、引领行业高质量发展具有重要意义。京东方科技集团董事长陈炎顺出席并宣布仪式启动,项目总指挥刘晓东、项目执行总指挥杨国波等领导及中建三局集团有限公司、中国建筑一局(集团)有限公司、中国电子工程设计院股份有限公司、四川华凯工程项目管理有限公司等相关单位领导共同出席封顶仪式。
BOE(京东方)第8.6代AMOLED生产线项目总指挥刘晓东在致辞中表示:“BOE(京东方)第8.6代AMOLED生产线自今年初正式开工以来,始终秉持‘五同时、五确保、五典范’建设原则,以坚韧不拔的意志和团结协作的精神,历时183天,提前达成全面封顶目标,标志着该生产线正式迈入新阶段。BOE(京东方)第8.6代AMOLED生产线必将成为行业标杆工程,为企业发展注入新的活力与动力。我们有信心、有能力打造全球最具竞争力的第8.6代AMOLED生产线,为全球显示产业进步贡献重要力量。”
BOE(京东方)第8.6代AMOLED生产线总投资630亿元,是四川省迄今投资体量最大的单体工业项目,设计产能每月3.2万片玻璃基板(尺寸2290mm×2620mm),主要生产笔记本电脑、平板电脑等智能终端高端触控OLED显示屏。BOE(京东方)通过采用低温多晶硅氧化物(LTPO)背板技术与叠层发光器件制备工艺,使OLED屏幕实现更低的功耗和更长的使用寿命,也将带动下游笔记本及平板电脑产品的迭代升级。目前,BOE(京东方)已在成都、重庆、绵阳投建了三条第6代柔性AMOLED生产线,再加上国内首条第8.6代AMOLED生产线的投建,全面展现了其全球领先的技术实力和行业影响力。值得关注的是,截至2023年,BOE(京东方)柔性OLED出货量已连续多年稳居国内第一,全球第二(数据来源:Omdia),柔性OLED相关专利申请超3万件。BOE(京东方)柔性显示技术不仅应用于手机领域,还持续拓展笔记本、车载、可穿戴等领域,折叠屏、滑卷屏、全面屏等柔性显示解决方案已覆盖国内外众多头部终端品牌,进一步确立BOE(京东方)在OLED领域的全球领先地位。
2024年,BOE(京东方)面向下一个三十年的新征程全新出发,公司将始终坚持“传承、创新、发展”的企业文化内核,坚定信念、创新变革,持续探索契合市场需求的企业发展“第N曲线”。BOE(京东方)第8.6代AMOLED生产线也将汇聚新型显示产业人才,发挥引擎作用,打造以柔性显示为核心的“世界柔谷”,在持续提升竞争力的同时,谱写行业高质发展的新篇章。
收起阅读 »iframe的基本使用与注意点
iframe
(Inline Frame)是一种在网页中嵌套其他网页的 HTML 元素。通过 iframe
,开发者可以在一个页面中加载另一个页面的内容,提升用户体验和功能性。下面将详细探讨 iframe
的原理、使用场景以及注意事项,并提供相应的代码示例。
一、iframe 的原理
iframe
是一种 HTML 标签,其基本语法如下:
<iframe src="https://example.com" width="600" height="400" frameborder="0"></iframe>
- src:指定要加载的网页地址。
- width 和 height:定义
iframe
的宽度和高度。 - frameborder:控制边框显示(在 HTML5 中不推荐使用)。
当浏览器遇到 iframe
标签时,会发起一个独立的网络请求来加载指定的 URL。这使得嵌入的内容在主文档之外独立渲染。
二、使用场景
- 广告展示:
iframe
经常用于展示广告内容,允许网站在不影响主页面的情况下,灵活更新广告。
<iframe src="https://ad.example.com" width="300" height="250" frameborder="0"></iframe>
- 第三方内容集成:
- 嵌入社交媒体帖子、视频播放器或地图等内容。例如,嵌入 YouTube 视频:
<iframe src="https://www.youtube.com/embed/VIDEO_ID" width="560" height="315" frameborder="0" allowfullscreen></iframe>
- 内容隔离:
- 当需要展示用户生成的内容(如评论或论坛)时,可以使用
iframe
进行内容隔离,避免对主页面造成影响。
- 当需要展示用户生成的内容(如评论或论坛)时,可以使用
- 安全性:
使用
sandbox
属性,可以限制iframe
的功能,增加安全性。
allow-forms
允许
iframe
内部的表单提交。默认情况下,表单提交被禁止。allow-same-origin
允许
iframe
中的文档以相同来源访问其父页面。这允许脚本与同源的内容交互。allow-scripts
允许
iframe
中的脚本执行。默认情况下,脚本执行被禁止。allow-top-navigation
允许
iframe
中的内容导航到父页面。这使得嵌入页面可以改变主页面的 URL。allow-popups
允许
iframe
中的内容打开新窗口或标签页。默认情况下,这种操作被禁止。allow-modals
允许
iframe
显示模态对话框,例如alert
、prompt
和confirm
。allow-presentation
允许
iframe
进入展示模式,例如全屏模式。
<iframe src="https://example.com" width="600" height="400" sandbox="allow-scripts"></iframe>
三、注意点
- 安全性问题:
- 由于跨站点脚本攻击(XSS)的风险,很多网站设置了
X-Frame-Options
或Content-Security-Policy
来限制iframe
的嵌入。这会导致“拒绝了我们的连接请求”的错误提示。
- 由于跨站点脚本攻击(XSS)的风险,很多网站设置了
```http
X-Frame-Options: DENY
```
- 性能影响:
- 嵌套多个
iframe
会增加页面的加载时间和复杂性,影响性能。因此,建议合理使用。
- 嵌套多个
- 跨域限制:
- 由于同源策略,
iframe
中加载的页面不能与主页面进行直接交互。这意味着无法访问嵌入页面的 DOM 或 JavaScript。
- 由于同源策略,
- SEO 考虑:
- 搜索引擎可能不会索引
iframe
内的内容,从而影响整体的 SEO 表现。避免将重要内容仅放在iframe
中。
- 搜索引擎可能不会索引
- 响应式设计:
- 确保
iframe
在不同设备和屏幕尺寸下表现良好,可以通过 CSS 设置其宽度为百分比。例如:
iframe {
width: 100%;
height: auto;
}
- 确保
四、示例代码
以下是一个综合示例,展示了如何使用 iframe
加载一个 YouTube 视频并应用响应式设计:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Iframe Example</title>
<style>
.responsive-iframe {
position: relative;
padding-bottom: 56.25%; /* 16:9 Aspect Ratio */
height: 0;
overflow: hidden;
}
.responsive-iframe iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<h1>嵌入 YouTube 视频</h1>
<div class="responsive-iframe">
<iframe src="https://www.youtube.com/embed/VIDEO_ID" frameborder="0" allowfullscreen></iframe>
</div>
</body>
</html>
结论
iframe
是一种强大的网页嵌入技术,能够增强网页功能和用户体验。在使用时,需要充分考虑安全性、性能和跨域问题,以确保良好的用户体验。通过合理配置和使用,iframe
可以为网页增加更多的互动性和功能性。
---09/19ヾ( ̄▽ ̄)ByeBye
来源:juejin.cn/post/7415914059106533439
get请求参数放在body中?
1、背景
与后端对接口时,看到有一个get
请求的接口,它的参数是放在body
中的
******get
请求参数可以放在body
中??
随即问了后端,后端大哥说在postman上是可以的,还给我看了截图
可我传参怎么也调不通!
下面就来探究到底是怎么回事
2、能否发送带有body参数的get请求
项目中使用axios
来进行http
请求,使用get
请求传参的基本姿势:
// 参数拼接在url上
axios.get(url, {
params: {}
})
如果想要将参数放在body
中,应该怎么做呢?
查看axios的文档并没有看到对应说明,去github上翻看下axios源码看看
在lib/core/Axios.js
文件中
可以看到像delete
、get
、head
、options
方法,它们只接收两个参数,不过在config
中有一个data
熟悉的post
请求,它接收的第二个参数data
就是放在body
的,然后一起作为给this.request
作为参数
所以看样子get
请求应该可以在第二个参数添加data
属性,它会等同于post
请求的data
参数
顺着源码,再看看lib/adapters/xhr.js
,上面的this.request
最终会调用这个文件封装的XMLHttpRequest
export default isXHRAdapterSupported && function (config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
let requestData = config.data
// 将config.params拼接在url上
request.open(config.method.toUpperCase(),
buildURL(fullPath, config.params, config.paramsSerializer), true);
// 省略若干代码
...
// Send the request
request.send(requestData || null);
});
}
最终会将data
数据发送出去
所以只要我们传递了data
数据,其实axios
会将其放在body
发送出去的
2.1 实战
本地起一个koa
服务,弄一个简单的接口,看看后端能否接收到get
请求的body
参数
router.get('/api/json', async (ctx, next) => {
console.log('get请求获取body: ', ctx.request.body)
ctx.body = ctx.request.body
})
router.post('/api/json', async (ctx, next) => {
console.log('post请求获取body: ', ctx.request.body)
ctx.body = ctx.request.body
})
为了更好地比较,分别弄了一个get
和post
接口
前端调用接口:
const res = await axios.get('/api/json', {
data: {
id: 1,
type: 'GET'
}
})
const res = await axios.post('/api/json', {
data: {
id: 2,
type: 'POST'
}
})
console.log('res--> ', res)
在axios
的send
处打一个断点
可以看到数据已经被放到body中了
后端已经接收到请求了,但是get
请求无法获取到body
!
结论:
- 前端可以发送带
body
参数的get
请求,但是后端接收不到 - 这就是接口一直调不通的原因
3、这是为何呢?
我们查看WHATGW
标准,在XMLHttpRequest
中有这么一个说明:
大概意思:如果请求方法是GET
或HEAD
,那么body
会被忽略的
所以我们虽然传递了,但是会被浏览器给忽略掉
这也是为什么使用postman
可以正常请求,但是前端调不通的原因了
因为postman
并没有遵循WHATWG
的标准,body
参数没有被忽略
3.1 fetch
是否可以?
fetch.spec.whatwg.org/#request-cl…
答案:也不可以,fetch
会直接报错
总结
- 结论:浏览器并不支持
get
请求将参数放在body
中 XMLHTTPRequest
会忽略body
参数,而fetch
则会直接报错
来源:juejin.cn/post/7283367128195055651
Systeminformation.js: 为什么不试试最强的系统信息获取工具?
大家好,我是徐徐。今天跟大家分享一款获取系统信息的工具库:systeminformation。
前言
在现代开发环境中,跨平台获取系统信息已经成为许多应用程序的重要需求。无论你是在开发需要详细系统信息的应用,还是需要获取硬件和软件的状态信息,一个强大且灵活的工具库可以显著提升你的开发效率。今天,我们要分享的是systeminformation
这个 Node.js 库,可以帮你轻松获取到你想要的各种系统信息。
基本信息
- 官网:systeminformation.io
- GitHub:github.com/sebhildebra…
- Star:2.7 K
- 类别:系统工具
什么是 systeminformation?
systeminformation 是一个轻量级的 Node.js 库,旨在提供跨平台的系统信息获取功能。无论是在 Windows、macOS 还是 Linux 上,它都能为你提供一致的接口,获取系统的硬件和软件信息。自2015年发布以来,systeminformation 已经成为开发者们获取系统信息的首选工具之一。
它提供了超过 50 个函数,用于检索详细的硬件、系统和操作系统信息。该库支持 Linux、macOS、部分 Windows、FreeBSD、OpenBSD、NetBSD、SunOS 以及 Android 系统,并且完全无依赖。无论你需要全面了解系统状况,还是仅仅想获取特定的数据,systeminformation
都能满足你的需求,帮助你在各个平台上轻松获取系统信息。
主要特点
- 跨平台支持:支持 Windows、macOS 和 Linux 系统,提供一致的接口。
- 全面的信息获取:能够获取 CPU、内存、磁盘、网络、操作系统等详细信息。
- 实时监控:支持获取实时的系统性能数据,如 CPU 使用率、内存使用率、网络速度等。
- 易于集成:通过简单的 API 调用即可获取所需信息,便于集成到各种应用程序中。
使用场景
- 服务器监控:实时监控服务器性能,获取 CPU、内存、磁盘等硬件信息。
- 桌面应用:获取本地系统信息,展示系统状态和性能数据。
- IoT 设备:在物联网设备上获取系统信息,进行设备管理和监控。
快速上手
要在你的 Node.js 项目中使用 systeminformation,只需以下简单步骤:
- 安装 systeminformation
npm install systeminformation
- 获取系统信息示例
const si = require('systeminformation');
// 获取 CPU 信息
si.cpu()
.then(data => console.log(data))
.catch(error => console.error(error));
// 获取内存信息
si.mem()
.then(data => console.log(data))
.catch(error => console.error(error));
// 获取操作系统信息
si.osInfo()
.then(data => console.log(data))
.catch(error => console.error(error));
- 实时监控示例
const si = require('systeminformation');
// 实时监控 CPU 使用率
setInterval(() => {
si.currentLoad()
.then(data => console.log(`CPU Load: ${data.currentload}%`))
.catch(error => console.error(error));
}, 1000);
// 实时监控内存使用情况
setInterval(() => {
si.mem()
.then(data => console.log(`Memory Usage: ${data.used / data.total * 100}%`))
.catch(error => console.error(error));
}, 1000);
结语
systeminformation 是一个功能强大且灵活的 Node.js 库,能够帮助你轻松获取系统的各种信息。无论你是需要实时监控服务器性能,还是需要获取本地系统的详细信息,systeminformation 都能为你提供稳定且易用的解决方案。
希望这篇文章能帮助你了解 systeminformation 的强大功能,并激发你在项目中使用它的灵感。赶快分享给你的朋友们吧!
来源:juejin.cn/post/7413643760771072015
axios VS alova.js,谁是真正的通信王者?
新年快乐!在这个快速发展的前端世界里,咱们工程师面临的挑战也是一个接一个。今天,咱们就来聊聊前端实时通信这个话题。
想想看,你在使用那些传统的HTTP客户端时,比如axios,是否遇到过这样的问题:与React、Vue等框架的结合不够紧密,导致开发效率低下;在性能方面表现不佳,尤其是在处理频繁或重复的请求时;还有那略显臃肿的体积,以及混乱的响应数据类型定义?
哎呀妈呀,这些问题听着就让人头大。但别急,有个叫做alovajs的工具,可能会让你眼前一亮。
alovajs是一个轻量级的请求策略库,它不仅提供了与axios相似的API设计,让你能更快上手,还解决了上述的那些问题。它如何解决?咱们来一探究竟。
首先,alovajs能够与UI框架深度融合,自动管理请求相关的数据。这意味着你在Vue或React等框架中使用alovajs时,不再需要手动创建和维护请求状态,大大提高了开发效率。
其次,alovajs默认开启了内存缓存和请求共享,这些功能可以在提高请求性能的同时,提升用户体验并降低服务端的压力。比如,当你实现一个列表页,用户点击列表项进入详情页时,alovajs可以智能地使用缓存数据,避免不必要的重复请求。
最后,alovajs的体积只有4kb+,仅是axios的30%左右,而且它提供了更加直观的响应数据TS类型定义,对于重度使用Typescript的同学来说,这绝对是个福音。
说了这么多,是不是有点心动了?如果你对alovajs感兴趣,可以访问它的官网查看更多详细信息:alovajs官网。也欢迎你在评论区分享你对alovajs的看法和使用经验,让我们一起交流学习吧!
有任何问题,你可以加入以下群聊咨询,也可以在github 仓库中发布 Discussions,如果遇到问题,也请在github 的 issues中提交,我们会在最快的时间解决。
来源:juejin.cn/post/7334503381200437299
一文搞懂JS类型判断的四种方法
前言
在JavaScript中,类型判断是一个非常基础但也十分重要的知识点。不同的类型判断方法适用于不同的场景,掌握这些方法可以帮助我们更好地理解和使用JavaScript。本文将详细介绍typeof
、instanceof
、Object.prototype.toString
以及Array.isArray
这四种常用的类型判断方法,并通过实例代码帮助大家加深理解。
正文
typeof
typeof操作符可以用来判断基本数据类型,如string
、number
、boolean
、undefined
、symbol
、bigint
等。它对于null
和所有引用类型的判断会返回"object"
,而对于函数则会返回"function"
。
特点:
- 可以判断除
null
之外的所有原始类型。 - 除了
function
,其他所有的引用类型都会被判断成object
。 - typeof是通过将值转换为二进制后判断其二进制前三位是否为0,是则为object
示例代码:
let s = '123'; // string
let n = 123; // number
let f = true; // boolean
let u = undefined; // undefined
let nu = null; // null
let sy = Symbol(123); // Symbol
let big = 1234n; // BigInt
console.log(typeof s); // "string"
console.log(typeof n); // "number"
console.log(typeof f); // "boolean"
console.log(typeof u); // "undefined"
console.log(typeof sy); // "symbol"
console.log(typeof big); // "bigint"
console.log(typeof nu); // "object" - 特殊情况
let obj = {};
let arr = [];
let fn = function() {};
let date = new Date();
console.log(typeof obj); // "object"
console.log(typeof arr); // "object"
console.log(typeof date); // "object"
console.log(typeof fn); // "function"
function isObject(o) {
if (typeof o === 'object' && o !== null) {
return true;
}
return false;
}
let res = isObject({a: 1});
console.log(res); // true
instanceof
instanceof
用于检测构造函数的prototype
属性是否出现在某个实例对象的原型链上。因此,它主要用于判断引用类型
。
特点:
- 只能判断引用类型。
- 通过原型链查找来判断类型。
示例代码:
let obj = {};
let arr = [];
let fn = function() {};
let date = new Date();
console.log(obj instanceof Object); // true
console.log(arr instanceof Array); // true
console.log(fn instanceof Function); // true
console.log(date instanceof Date); // true
console.log(arr instanceof Object); // true
console.log(arr instanceof String); // false
console.log(n instanceof Number); // false
因为原始类型
没有原型而引用类型有原型,所有instanceof
主要用于判断引用类型
,那么根据这个我们是不是可以手写一个instanceof
。
手写·instanceof
实现:
首先我们要知道v8创建对象自变量
是这样的,拿let arr = []举例子:
function createArray() {
// 创建一个新的对象
let arr = new Array();
// 设置原型
arr.__proto__ = Array.prototype;
// 返回创建的数组对象
return arr;
}
V8 引擎会调用 Array
构造函数来创建一个新的数组对象,Array
构造函数的内部实现会创建一个新的空数组对象,并初始化其内部属性并且将新创建的数组对象的 __proto__
属性设置为 Array.prototype
,这意味着数组对象会继承 Array.prototype
上的所有方法和属性,最后,新创建的数组对象会被赋值给变量 arr
。
那么我们是不是可以通过实例对象的隐式原型
等于其构造函数的显式原型
来判断类型,代码如下:
function myInstanceOf(L,R){
if(L.__proto__ === R.prototype){
return true;
}
return false;
}
但是我们看到console.log([] instanceof Object); // true,所有还要改进一下:
我们要知道这么一件事情:
- 内置构造函数的原型链:
- 大多数内置构造函数(如
Array
、Function
、Date
、RegExp
、Error
、Number
、String
、Boolean
、Map
、Set
、WeakMap
、WeakSet
等)的原型(Constructor.prototype
)都会直接或间接地继承自Object.prototype
。 - 这意味着这些构造函数创建的对象的原型链最终会指向
Object.prototype
。
- 大多数内置构造函数(如
- Object.prototype 的原型:
Object.prototype
的隐式原型(即__proto__
)为null
。这是原型链的终点,表示没有更多的原型可以继承。
所以我们是不是可以这样:
function myinstanceof(L, R) {
while (L !== null) {
if (L.__proto__ === R.prototype) {
return true;
}
L = L.__proto__;
}
return false;
}
console.log(myinstanceof([], Array)); // true
console.log(myinstanceof([], Object)); // true
console.log(myinstanceof({}, Array)); // false
所以就完美实现了。
Object.prototype.toString.call
Object.prototype.toString.call
是一个非常有用的工具,可以用来获取任何 JavaScript 值的类型
信息。它结合了 Object.prototype.toString
和 Function.prototype.call
两个方法的功能。
特点:
- 可以判断任何类型
代码示例
console.log(Object.prototype.toString.call(null)); // [object Null]
console.log(Object.prototype.toString.call(123)); // [object Number]
console.log(Object.prototype.toString.call([])); // [object Array]
console.log(Object.prototype.toString.call({})); // [object Object]
console.log(Object.prototype.toString.call('hello')); // [object String]
console.log(Object.prototype.toString.call(true)); // [object Boolean]
console.log(Object.prototype.toString.call(undefined)); // [object Undefined]
console.log(Object.prototype.toString.call(Symbol())); // [object Symbol]
console.log(Object.prototype.toString.call(123n)); // [object BigInt]
Object.prototype.toString
底层逻辑
根据官方文档,Object.prototype.toString
方法的执行步骤如下:
- 如果此值未定义,则返回
"[object undefined]"
。 - 如果此值为
null
,则返回"[object Null]"
。 - 定义
O
是调用ToObject
(该方法作用是把O
转换为对象) 的结果,将this
值作为参数传递。 - 定义
class
是O
的[[Class]]
内部属性的值。 - 返回
"[object"
和class
和"]"
组成的字符串的结果。
关键点解释
ToObject
方法:将传入的值转换为对象。对于原始类型(如string
、number
、boolean
),会创建对应的包装对象(如String
、Number
、Boolean
)。对于null
和undefined
,会有特殊处理。[[Class]]
内部属性:每个对象都有一个[[Class]]
内部属性,表示对象的类型。例如,数组的[[Class]]
值为"Array"
,对象的[[Class]]
值为"Object"
。
console.log(Object.prototype.toString(123));//[object Object]
console.log(Object.prototype.toString('123'));//[object Object]
console.log(Object.prototype.toString({}));//[object Object]
console.log(Object.prototype.toString([]));//[object Object]
为什么需要 call
?
Object.prototype.toString
方法默认的 this
值是 Object.prototype
本身。如果我们直接调用 Object.prototype.toString(123)
,this
值仍然是 Object.prototype
,而不是我们传入的值。因此,我们需要使用 call
方法来改变 this
值,使其指向我们传入的值。
手写call
obj = {
a:1,
}
function foo(){
console.log(this.a);
}
//我们需要将foo中的this指向obj里面
Function.prototype.myCall = function(context){
if(!(this instanceof Function)){ 在构造函数原型上,this指向的是实例对象,这里即foo
return new TypeError(this+'is not function')
}
const fn = Symbol('key'); //使用symbol作为key是因为可能会同名
context[fn] = this;//添加变量名为fn,值为上面的,context={Symbol('key'): foo}
context[fn](); // 触发了隐式绑定
delete context[fn]; //删除这个方法
}
foo.myCall(obj) // 1
console.log(obj); // {a:1}
我们知道call方法
是将函数里面的this强行掰弯到我们传入的对象里面去,它的原理是这样的,首先判断你传入的参数是不是一个函数,因为只有函数身上才有call方法,函数调用然后通过隐式绑定规则,将this指向这个对象,那么不就强行更改了this的指向,[不知道this的可以看这篇文章](你不知道的JavaScript(核心知识点概念详细整理-掘金 (juejin.cn))
Array.isArray
Array.isArray
是一个静态方法,用于检测给定的值是否为数组。
示例代码:
let arr = [];
let obj = {};
console.log(Array.isArray(arr)); // true
console.log(Array.isArray(obj)); // false
手写Array.isArray
实现:
function myIsArray(value) {
return Object.prototype.toString.call(value) === '[object Array]';
}
console.log(myIsArray(arr)); // true
console.log(myIsArray(obj)); // false
总结
typeof
适合用于检查基本数据类型,但对于null
和对象类型的判断不够准确。instanceof
用于检查对象的构造函数,适用于引用类型的判断。Object.prototype.toString
提供了一种更通用的方法来判断所有类型的值。Array.isArray
专门用于判断一个值是否为数组。
希望这篇文章能够帮助你更好地理解和使用JavaScript中的类型判断方法,感谢你的阅读!
来源:juejin.cn/post/7416657615369388084
大屏页面崩溃排查(Too many active WebGL contexts. Oldest context will be lost)
1 问题背景
顾问反馈大屏在反复切换的过程中,页面会出现白屏,导致后续的操作无法进行,只能通过刷新页面才能恢复正常。
- 我们的页面类似于这样的布局(下方的是直接从网络上找的截图)
- 点击下方红线框住的区域,可以展示不同的图表(echarts图表)
- 区别在于我们的主区域不是图片,用的是基于cesium封装的地图(webgl)
2 问题复现
测试同事经过几分钟的快速切换导航后,复现了,报错了如下内容
问题如果复现了,其实就解决了一半了
3 查找问题
经过复现后,发现除了上面的报错,每当页面崩溃前,chrome总会有下方的warning。然后基于cesium封装的地图就会崩溃。
翻译成中文:警告:目前有了太多激活的webgl上下文,最早的上下文将会丢失
4 排查问题
经过和地图组的人沟通,得到一个结论WebGL一个页面上最多有16个实例
- 怀疑echarts在下方菜单切换过程中,没有进行销毁
检查了代码中的echats的页面在销毁的时候,发现都进行了dispose,排除了这个的原因
- 怀疑起echarts的3d的饼状图
之前设计师设计了一个3d的饼状图,参考了 3d柄图环图,一个页面上有多这个组件。
效果如下:
5 锁定组件进行验证
- 先把一个页面上的所有组件改为上方的饼状图,然后点击导航栏,频繁进行切换,
- 页面很快就崩溃了,然后检查这个组件在页面销毁的时候,是否进行dispose
检查后,发现没有,添加后进行测试,问题依旧 - 继续检查发现这个组件导入了echarts-gl,就去ecahrts的github的issues进行搜索,终于找到了一个类似的问题
github.com/ecomfe/echa…
加入了类似的代码,进行验证后解决了此问题
6 总结
- chrome浏览器中最多有16个webgl的实例。当过多的时候,会把最早创建的实例销毁
- 当使用echarts在页面销毁的时候及时进行dispose,释放上下文
- 当使用echarts-gl的时候,调用dispose的时候是不生效的,需要找到页面上的canvas,然后手动将上下文释放,类似下方的代码
const canvasArr = myChart.getDom().getElementsByTagName('canvas');
for(let i=0; i<canvasArr.length; i++){
canvasArr[i].getContext('webgl').getExtension('WEBGL_lose_context').loseContext()
}
7 参考文档
来源:juejin.cn/post/7351712561672798260
谁也别拦我们,网页里直接增删改查本地文件!
欢迎来到 Jax 的专栏「Web 玩转文件操作」,快来了解 Web 端关于文件操作的方方面面!
转载请联系作者 Jax。
先来玩玩这个 Demo —— 一个网页端的本地文件管理器。
在上面的 Demo 中,点击「打开文件夹」按钮后,你可以选择电脑本地的某个文件夹来打开。然后就能对其中的文件做各种操作了,包括新建、右键打开、编辑、删除等等。你会发现,在网页上的操作会立刻在本地文件上实际生效。
如果你感觉是:”哟?有点儿意思!“,那么这篇文章就是专门为你而写的,读下去吧。
正所谓「边牧是边牧、狗是狗」,Web 应用早已不是当初的简陋网页了,它进化出了令人眼花缭乱的新能力,同时也在迅速追齐系统原生能力。如 Demo 所示,我们甚至可以把文件管理系统搬进网页,并且简单的 JavaScript 代码就能实现。Web 应用能有这样的威力,得益于浏览器对文件系统 API 的实现。
文件系统(File System Access)API 目前还处于社区草稿状态,但早在 2016 年,WHATWG 社区就已经开始着手设计这一标准,并且八年来一直在持续不断地打磨,迄今已经被各大主流浏览器所实现。
这套 API 足够简单易用,你很快就能掌握它,并且让它成为你的得力助手。但由于涉及到文件管理这个课题,它有很多方面都可以挖掘得很深很深,比如读写流、OPFS、安全策略等等。咱们这个专栏本着少吃多餐的原则,不会一次性囊括那么多内容。咱们的这第一铲,就先浅浅地挖一个「憨豆」 —— FileSystemHandle
。
FileSystemHandle
在文件系统中,文件和文件夹是一个个实体,是所有状态和交互的核心。相对应地,在文件系统 API 中,我们用「FileSystemHandle
」这个对象来对实体进行抽象。为了能够简洁地描述,我们在下文中将称之为「憨豆」。我们用对象属性来保存实体的名称、类型,通过执行对象方法来操作实体。
那么 FileSystemHandle
从何而来呢?一般是从用户操作而来。以文件为例,当用户通过文件窗口选择了某个本地文件之后,我们就能从代码层面获取到通向这个文件的入口,从而可以对文件进行管理操作。除此之外,拖拽文件进入也能得到憨豆
属性:name 和 kind
name
:无论是文件还是文件夹,必然都有一个名字。
kind
:实体的类型,值为 ‘file’
代表文件;值为 ‘directory’
代表文件夹。
校验方法 isSameEntry()
用于判断两个憨豆是否代表相同的实体。例如用户上传图片时,先后两次选择的是同一张图片,那么两个憨豆指向的其实是同一个文件。
const [handle1] = await showOpenFilePicker() // 第一次上传
const [handle2] = await showOpenFilePicker() // 第二次选择了同一个文件
const isSame = await handle1.isSameEntry(handle2)
console.log(isSame) // true
该方法也同样适用于文件夹校验。
我们可以借此来检测重复性。
删除方法 remove()
用于删除实体。比如,执行下列代码会现场抽取一个幸运文件来删除:
const [handle] = await showOpenFilePicker()
handle.remove()
但如果我们想要选中文件夹来删除,像上面那样直接调用是会报错并删除失败的。我们需要额外传入配置参数:
handle.remove({ recursive: true })
传参后的删除,是对文件夹里面的嵌套结构进行递归删除,这个设计理念旨在让开发者和用户在删除前知悉操作后果。
权限方法 queryPermission() 和 requestPermission()
用于向用户查询和获取读写权限,可以传入一个参数对象来细分权限类型。
const permission = await handle.queryPermission({ mode: 'read' }) // mode 的值还可以是 readwrite
console.log(permission)// 若值为 'granted',则代表有足够权限
我们在对实体进行操作前,最好总是先查询权限。因此一个最佳实践是把这两个方法封装进通用操作逻辑中。
其他特性
除此之外,FileSystemHandle
还具有一些其他特性,比如可以转化为 indexDB 实例、可以通过 postMessage
传输到 Web Workers 里进行操作等等。我们会在后续的专栏文章中继续顺藤摸瓜地了解它们。
两个子类
到目前为止,FileSystemHandle
所具有的属性和方法都在上面了。你可能也意识到了,单靠这三招两式是不可能实现像 Demo 里那么丰富完备的文件操作的。
没错,这个憨豆只是一个抽象父类,它还有两个子类 FileSystemFileHandle
、FileSystemDirectoryHandle
,这两位才是文件和文件夹实体的真正代言人。对实体的常规操作,几乎都是通过这两个紫憨豆来执行的。
除了从父类继承而来的基因,两个紫憨豆都各自具有独特的属性和方法,满足了开发者对文件和文件夹两种不同实体的不同操作需求。
FileSystemFileHandle
在本专栏的上一篇文章《绕过中间商,不用 input 标签也能搞定文件选择》中,我们曾与文件憨豆有过一面之缘。那时候,我们通过 showOpenFilePicker
获取了文件憨豆,并调用它的 getFile
方法拿到了 文件 Blob
。
此外,文件憨豆还具有的方法如下:
createSyncAccessHandle()
:用于同步读写文件,但是仅限于在 Web Workers 中。createWritable
:创建一个写入流对象,用于向文件写入数据。
FileSystemDirectoryHandle
文件夹憨豆的特有方法如下:
getDirectoryHandle()
:按名称查找子文件夹。getFileHandle()
:按名称查找子文件。removeEntry()
:按名称移除子实体。resovle()
:返回指向子实体的路径。
经过上述罗列,我们对两种憨豆的能力有了一个大概的印象。下面,我们将回到 Demo 的交互场景,从具体操作上手,看看文件系统 API 是如何在 JavaScript 中落地的。
操作 & 用法
载入文件夹
我们首先需要载入一个文件夹,然后才能对其中的子实体进行操作。
如果你碰巧读过上一篇文章,知道如何用 showOpenFilePicker()
选择文件,那你一定能举一反三推断出,选择文件夹的方法是 showDirectoryPicker()
:
const dirHandle = await showDirectoryPicker()
showDirectoryPicker
方法也接收一些参数,其中 id
、startIn
这两个参数与 showOpenFilePicker
方法 的同名参数完全对应。另外还支持一个参数 mode
,其值可以是 read
或 readwrite
,用于指定所需的权限。
用户选择文件夹后得到的 dirHandle
,就是一个 FileSystemDirectoryHandle
格式的对象。我们可以遍历出它的子实体:
for await (const sub of dirHandle.values()) {
const { name, kind } = sub
console.log(name, kind)
}
从子实体中取出名称和类别属性值,就可以对文件夹内容一目了然了。
读取文件内容
在上一步中,我们已经读取到了子实体的名字和类型,那么对于文件类型,我们可以先按名称检索到对应的憨豆:
// if sub.kind === 'file'
const fileHandle = await dirHandle.getFileHandle(sub.name)
再从文件憨豆中掏出文件 Blob,进一步读取到文件的内容:
const file = await fileHandle.getFile()
const content = file.text()
如果你用来调试的文件是文本内容的文件,那么打印 content
的值,你就可以看到内容文本了。
同理,获取子文件夹的方式是 dirHandle.getDirectoryHandle(sub.name)
。
新建文件、文件夹
除了指定名称参数,getFileHandle
和 getDirectoryHandle
这两个方法还支持第二个参数,是一个一个配置对象 { create: true/false }
,用于应对指定名称的实体不存在的情况。
例如,我们对一个文件夹实体执行 dirHandle.getFileHandle('fileA')
,但该文件夹中并没有名为 fileA 的文件。此时第二个参数为空,等同于 create
的默认值为 false
,那么此时会抛出一个 NotFoundError
错误,提示我们文件不存在。
而如果我们这样执行:dirHandle.getFileHandle('fileA', { create: true })
,那么就会在当前文件夹中新建一个名为 fileA 的空文件。
同理,我们也可以用 dirHandle.getDirectoryHandle('dirA', { create: true })
新建一个名为 dirA 的空文件夹。
在 Demo 中,我们实现了让用户在新建文件时自定义文件名,这其实是使用了 prompt
方法:
const fileName = prompt('请输入文件名')
await dirHandle.getFileHandle(fileName, { create: true })
在当下这个 AI 时代,Prompt 更多是以「LLM 提示词」被大家初识。在更早时候,它是浏览器实现的一个组件。其实这两种形态的含义是一致的,都是在人机交互中,从一端到另一端的输入输出流程。
编辑文件内容
刚刚我们读取了文件的内容,现在我们来对文件内容进行修改,然后再存回去。
我们已经能够通过 getFile()
方法拿到文本内容,那应该把内容放到哪里进行编辑呢?你有很多种选择:富文本编辑器、给 div 设置 contenteditable
、唤起 VS Code…… 但本着最(能)小(用)原(就)则(行),我们还有更便捷的选项 —— prompt!
prompt()
方法也支持第二个参数,我们把文本内容传入,弹出弹窗后,你就会看到内容已经填在了输入框中,现在就可以随意编辑里面的字符了。
const file = await fileHandle.getFile()
const fileContent = await file.text()
const newContent = prompt('', fileContent) // newContent 即为修改后的文件内容
但是点击 Prompt 的确认按钮并不会让新内容自动写入文件,这里就需要用到上面提到的 createWritable
了。下面是一个完整的写入流流程:
const writable = await fileHandle.createWritable() // 对待修改文件开启写入流
await writable.write(newContent) // 把新内容写入到文件
await writable.close() // 关闭写入流
至此,新的内容就已经被保存到文件中了。你可以在 Demo 中再次右键、打开文件查看内容,你会发现确实读取到了修改后的内容。
文件重命名
修改文件名也是文件管理中的常规操作,文件系统 API 也提供了对应的方法来实现。可能手快的同学已经在尝试 fileHandle.rename()
方法了。但 API 中还真没有这个方法,我们其实是要用一个 move()
方法。惊不惊喜意不意外?
因为从底层视角看,重命名文件和移动文件的处理过程类型,都是需要先新建一个文件(使用新的命名或者放到新的位置),再把源文件的数据复制过去,最后把源文件删除。目前在 Web 端还没有更高效的处理方式。
我们只需从 Prompt 获取新名称,再传给 move()
方法即可:
const newName = prompt('请输入新的文件名')
await fileHandle.move(newName)
这样,文件重命名就搞定了。
删除文件、文件夹
删除实体就没有那么多幺蛾子了,我们甚至不用对文件和文件夹做逻辑上的区分,简单直接地调用 currentHandle.remove({ recursive: true })
就行了。
但越是方便,就越要谨慎,因为涉及到删除用户的文件,所以如果要用于生产环境,最好给用户提供二次确认的机会。
写在结尾
恭喜你读完了本文,你真棒!
这次我们通过实现一个 Web 版文件管理器 demo,对文件管理 API 进行了较为深入的理解和实践。我再嘱咐两句:
- 涉及到操作用户文件,请务必谨慎。
- 为了保障安全性,文件系统 API 仅支持 https。
我是 Jax,在畅游 Web 技术领域的第 7 年,我仍然是坚定不移的 JavaScript 迷弟,Web 开发带给我太多乐趣。如果你也喜欢 Web 技术,或者想讨论本文内容,欢迎来聊!你可以通过下列方式找到我:
GitHub:github.com/JaxNext
微信:JaxNext
来源:juejin.cn/post/7416933490136252452
微信小程序避坑scroll-view,用tween.js实现吸附动画
背景
在开发一个小程序项目时,遇到一个需要列表滚动,松手时自动吸附的动画需求,如下(最终效果):
很自然用了scroll-view组件,但使用过程中发现scroll-view 里包含'position: fixed;top:0;'的元素时,应用scroll-with-animation=true,搭配更改scroll-top时,松手后fixed的元素会抖一下......
于是决定不用组件内置的scroll-with-animation,改用手动控制scroll-top实现吸附的效果。
思路
通常,要做动画,我们就得确定以下信息,然后用代码实现:
- 初始状态
- 结束状态
- 动画时长
- 动画过程状态如何变化(匀速/先加速后减速/...)
这四个信息一般从UI/交互那里确认,前三个代码实现很简单,第四个在css动画里(如transition/animation)用 timing-function
指定:
在js动画里,改变css的属性可通过 Web Animations API 里的 easing 属性指定:
而如果需要动画的状态不是css的属性呢(例如上面的scrollTop)?这就要用到补间/插值工具了,tween.js登场!
关于 tween.js
tween翻译有‘补间‘的意思
补间(动画)(来自 in-between)是一个概念,允许你以平滑的方式更改对象的属性。你只需告诉它哪些属性要更改,当补间结束运行时它们应该具有哪些最终值,以及这需要多长时间,补间引擎将负责计算从起始点到结束点的值。
简单点就是tweenjs可以指定状态从初始值到结束值该怎么变化,下面是简单的使用例子:
const box = document.getElementById('box') // 获取我们想要设置动画的元素。
const coords = {x: 0, y: 0} // 从 (0, 0) 开始
const tween = new TWEEN.Tween(coords, false) // 创建一个修改“坐标”的新 tween。
.to({x: 300, y: 200}, 1000) // 在 1 秒内移动到 (300, 200)。
.easing(TWEEN.Easing.Quadratic.InOut) // 使用缓动函数使动画流畅。
.onUpdate(() => {
// 在 tween.js 更新“坐标”后调用。
// 使用 CSS transform 将 'box' 移动到 'coords' 描述的位置。
box.style.setProperty('transform', 'translate(' + coords.x + 'px, ' + coords.y + 'px)')
})
.start() // 立即开始 tween。
// 设置动画循环。
function animate(time) {
tween.update(time)
requestAnimationFrame(animate)
}
requestAnimationFrame(animate)
- 完整用法请看tween.js 用户指南
在微信小程序里使用tween.js
导入适配
下载 github.com/tweenjs/twe… 文件,修改下‘now’的实现,把performance.now()
改成Date.now()
即可在小程序里使用:
动画循环
小程序里没有直接支持requestAnimationFrame
,这个可以用canvas组件的requestAnimationFrame方法代替:
// wxml
// ...
<canvas type="2d" id="canvas" style="width: 0; height: 0; pointer-events: none; position: fixed"></canvas>
// ...
// js
wx.createSelectorQuery()
.select("#canvas")
.fields({
node: true,
})
.exec((res) => {
this.canvas = res[0].node;
});
// ...
// ...
const renderLoop = () => {
TWEEN.update();
this.canvas.requestAnimationFrame(renderLoop);
};
renderLoop();
其他
锁帧
手动改scrolltop还是得通过setData方法,频繁调用可能会导致动画卡顿,requestAnimationFrame一般1s跑60次,也就是60fps,根据需要可以增加锁帧逻辑:
const fps = 30; // 锁30fps
const interval = 1000 / fps;
let lastTime = Date.now();
const renderLoop = () => {
this.canvas.requestAnimationFrame(renderLoop);
const now = Date.now();
if(now - lastTime > interval){
// 真正的动作在这里运行
TWEEN.update();
lastTime = now;
}
};
renderLoop();
官方支持?
要是 scrollView 组件支持 wxs 更改scrollTop就好了
developers.weixin.qq.com/community/d…
来源:juejin.cn/post/7300771357523820594
前端滑块旋转验证登录
效果图如下
实现: 封装VerifyImg组件
<template>
<el-dialog v-model="dialogShow" width="380px" top="24vh" class="verifyDialog">
<div class="verify-v">
<div
class="check"
@mousedown="onMouseDown"
@mouseup="onMouseUp"
@mousemove="onMouseMove"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<p>拖动滑块使图片角度为正</p>
<div class="img-con">
<img :src="imgUrl" :style="{ transform: imgAngle }" />
<div v-if="showError" class="check-state">验证失败</div>
<div v-else-if="showSuccess" class="check-state">验证成功</div>
<div v-else-if="checking" class="check-state">验证中</div>
</div>
<div
ref="sliderCon"
class="slider-con"
:class="{ 'err-anim': showError }"
:style="{ '--percent': percent, '--bgColor': showError ? bgError : bgColor }"
>
<div ref="slider" class="slider" id="slider" :class="{ sliding }" :style="{ '--move': `${slidMove}px` }">
<el-icon size="22"><Right id="slider" /></el-icon>
</div>
</div>
</div>
</div>
</el-dialog>
</template>
<script>
export default {
data() {
return {
imgUrl: '',
dialogShow: false,
showError: false,
showSuccess: false,
checking: false,
sliding: false,
slidMove: 0,
percent: 0,
sliderConWidth: 0,
bgColor: 'rgba(25, 145, 250, 0.2)',
bgError: 'rgba(255,78,78,0.2)',
imgList: [
new URL(`../../assets/images/verify/fn1.png`, import.meta.url).href,
new URL(`../../assets/images/verify/fn2.png`, import.meta.url).href,
new URL(`../../assets/images/verify/fn3.png`, import.meta.url).href
]
}
},
computed: {
angle() {
let sliderConWidth = this.sliderConWidth ?? 0
let sliderWidth = this.sliderWidth ?? 0
let ratio = this.slidMove / (sliderConWidth - sliderWidth)
return 360 * ratio
},
imgAngle() {
return `rotate(${this.angle}deg)`
}
},
mounted() {
this.imgUrl = this.imgList[this.rand(0, 2)]
},
methods: {
onTouchMove(event) {
console.log('onTouchMove')
if (this.sliding && this.checking === false) {
// 滑块向右的平移距离等于鼠标移动事件的X坐标减去鼠标按下时的初始坐标。
let m = event.touches[0].clientX - this.sliderLeft
if (m < 0) {
// 如果m小于0表示用户鼠标向左移动超出了初始位置,也就是0
// 所以直接等于 0,以防止越界
m = 0
} else if (m > this.sliderConWidth - this.sliderWidth) {
// 滑块向右移动的最大距离是滑槽的宽度减去滑块的宽度。
// 因为css的 translateX 函数是以元素的左上角坐标计算的
// 所以要减去滑块的宽度,这样滑块在最右边时,才不会左上角和滑槽右上角重合。
m = this.sliderConWidth - this.sliderWidth
}
this.slidMove = m
console.log(this.slidMove)
this.percent = ((this.slidMove / document.querySelector('.slider-con').offsetWidth) * 100).toFixed(2) + '%'
}
},
onTouchEnd() {
console.log('onTouchEnd')
if (this.sliding && this.checking === false) {
this.checking = true
this.validApi(this.angle)
.then(isok => {
if (isok) {
this.showSuccess = true
} else {
this.showError = true
}
return new Promise((resolve, reject) => {
// setTimeout(() => {
if (isok) {
resolve(Math.round(this.angle))
} else {
reject()
}
setTimeout(() => {
this.resetSlider()
}, 1000)
// }, 1500)
})
})
.then(angle => {
// 处理业务,或者通知调用者验证成功
console.log(angle)
this.$emit('toLogin')
})
.catch(e => {
console.log(e)
// alert('旋转错误')
})
}
},
onTouchStart(event) {
console.log('onTouchStart', event)
// 设置状态为滑动中
this.sliding = true
// 下面三个变量不需要监听变化,因此不放到 data 中
this.sliderLeft = event.touches[0].clientX // 记录鼠标按下时的x位置
this.sliderConWidth = this.$refs.sliderCon.clientWidth // 记录滑槽的宽度
this.sliderWidth = this.$refs.slider.clientWidth // 记录滑块的宽度
console.log(this.sliderLeft, this.sliderConWidth, this.sliderWidth)
},
rand(m, n) {
return Math.ceil(Math.random() * (n - m + 1) + m - 1)
},
showVerify() {
this.imgUrl = this.imgList[this.rand(0, 2)]
this.dialogShow = true
},
closeVerify() {
//1.5s后关闭弹框
setTimeout(() => {
this.dialogShow = false
}, 1500)
},
// 重置滑块
resetSlider() {
this.sliding = false
this.slidMove = 0
this.checking = false
this.showSuccess = false
this.showError = false
this.percent = 0
},
//拖拽开始
onMouseDown(event) {
console.log(event.target.id, this.checking)
if (event.target.id !== 'slider') {
return
}
if (this.checking) return
// 设置状态为滑动中
this.sliding = true
// 下面三个变量不需要监听变化,因此不放到 data 中
this.sliderLeft = event.clientX // 记录鼠标按下时的x位置
this.sliderConWidth = this.$refs.sliderCon.clientWidth // 记录滑槽的宽度
this.sliderWidth = this.$refs.slider.clientWidth // 记录滑块的宽度
},
//拖拽停止
onMouseUp(event) {
if (this.sliding && this.checking === false) {
this.checking = true
this.validApi(this.angle)
.then(isok => {
if (isok) {
this.showSuccess = true
} else {
this.showError = true
}
return new Promise((resolve, reject) => {
// setTimeout(() => {
if (isok) {
resolve(Math.round(this.angle))
} else {
reject()
}
setTimeout(() => {
this.resetSlider()
}, 1000)
// }, 1500)
})
})
.then(angle => {
// 处理业务,或者通知调用者验证成功
console.log(angle)
this.$emit('toLogin')
})
.catch(e => {
console.log(e)
// alert('旋转错误')
})
}
},
//拖拽进行中
onMouseMove(event) {
if (this.sliding && this.checking === false) {
// 滑块向右的平移距离等于鼠标移动事件的X坐标减去鼠标按下时的初始坐标。
let m = event.clientX - this.sliderLeft
if (m < 0) {
// 如果m小于0表示用户鼠标向左移动超出了初始位置,也就是0
// 所以直接等于 0,以防止越界
m = 0
} else if (m > this.sliderConWidth - this.sliderWidth) {
// 滑块向右移动的最大距离是滑槽的宽度减去滑块的宽度。
// 因为css的 translateX 函数是以元素的左上角坐标计算的
// 所以要减去滑块的宽度,这样滑块在最右边时,才不会左上角和滑槽右上角重合。
m = this.sliderConWidth - this.sliderWidth
}
this.slidMove = m
this.percent = ((this.slidMove / document.querySelector('.slider-con').offsetWidth) * 100).toFixed(2) + '%'
}
},
// 验证角度是否正确
validApi(angle) {
return new Promise((resolve, reject) => {
// 模拟网络请求
setTimeout(() => {
// 图片已旋转的角度
const imgAngle = 90
// 图片已旋转角度和用户旋转角度之和
let sum = imgAngle + angle
// 误差范围
const errorRang = 20
// 当用户旋转角度和已旋转角度之和为360度时,表示旋转了一整圈,也就是转正了
// 但是不能指望用户刚好转到那么多,所以需要留有一定的误差
let isOk = Math.abs(360 - sum) <= errorRang
resolve(isOk)
}, 1000)
})
}
}
}
</script>
<style lang="scss">
.verifyDialog {
.el-dialog__body {
padding: 15px !important;
}
}
</style>
<style lang="scss" scoped>
.verify-v {
display: flex;
justify-content: center;
align-items: center;
}
.check {
--slider-size: 40px;
width: 300px;
background: white;
box-shadow: 0px 0px 12px rgb(0 0 0 / 8%);
border-radius: 5px;
padding: 10px 0;
display: flex;
flex-direction: column;
align-items: center;
.img-con {
position: relative;
overflow: hidden;
width: 120px;
height: 120px;
border-radius: 50%;
margin-top: 20px;
img {
width: 100%;
height: 100%;
user-select: none;
}
.check-state {
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
color: white;
position: absolute;
top: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
}
}
}
.check .slider-con {
width: 80%;
height: var(--slider-size);
border-radius: 3px;
margin-top: 1rem;
position: relative;
background: #f5f5f5;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1) inset;
background: linear-gradient(to right, var(--bgColor) 0%, var(--bgColor) var(--percent), #fff var(--percent), #fff 100%);
.slider {
&:hover {
background: #1991fa;
color: #fff;
}
background: #fff;
width: var(--slider-size);
height: var(--slider-size);
border-radius: 3px;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
cursor: move;
display: flex;
justify-content: center;
align-items: center;
--move: 0px;
transform: translateX(var(--move));
.sliding {
background: #4ed3ff;
}
}
}
.slider-con.err-anim {
animation: jitter 0.5s;
.slider {
background: #ff4e4e;
}
}
body {
padding: 0;
margin: 0;
background: #fef5e0;
}
@keyframes jitter {
20% {
transform: translateX(-5px);
}
40% {
transform: translateX(10px);
}
60% {
transform: translateX(-5px);
}
80% {
transform: translateX(10px);
}
100% {
transform: translateX(0);
}
}
</style>
使用
<VerifyImg ref="verifyRef" @to-login="handleLogin"></VerifyImg>
handleLogin(){
...
}
来源:juejin.cn/post/7358004857889275958
API接口超时,网络波动,不要一直弹Alert了!
前言
前段时间,我司的IoT平台的客户大量上新了一堆设备后,在使用过程中,出现了网络连接超时
,服务器错误
等问题,客户小题大做,抓着这个尾巴不放手,喷我司系统不健壮,要求我们杜绝API错误
。
由于IoT多是日志、设备、测试、Action、图表、通信类的功能,API请求&返回数据量大、时间长
。在后端添加索引的情况下,接口仍然达不到秒级,接口普遍都是1~3s
,部分接口甚至达到小10s。我司把服务器的硬盘和CPU都进行了一波升级,效果都不是很理想。
这个烫手的山芋,落在了前端的头上。我们被迫进行了一波系统升级优化
前段时间,我司的IoT平台的客户大量上新了一堆设备后,在使用过程中,出现了网络连接超时
,服务器错误
等问题,客户小题大做,抓着这个尾巴不放手,喷我司系统不健壮,要求我们杜绝API错误
。
由于IoT多是日志、设备、测试、Action、图表、通信类的功能,API请求&返回数据量大、时间长
。在后端添加索引的情况下,接口仍然达不到秒级,接口普遍都是1~3s
,部分接口甚至达到小10s。我司把服务器的硬盘和CPU都进行了一波升级,效果都不是很理想。
这个烫手的山芋,落在了前端的头上。我们被迫进行了一波系统升级优化
解决方案
我们结合这个需求,制定了以下几条标准:
- 不能入侵其他的功能
- 对系统的破坏尽可能的小
- 杜绝或者尽可能的减少弹框问题
- 保证数据的正确展示,对于错误要正确的暴露出来
根据以上几条标准,于是方案就自然的确定了:
我们结合这个需求,制定了以下几条标准:
- 不能入侵其他的功能
- 对系统的破坏尽可能的小
- 杜绝或者尽可能的减少弹框问题
- 保证数据的正确展示,对于错误要正确的暴露出来
根据以上几条标准,于是方案就自然的确定了:
API请求时间
拉长API的请求时间
,将超时时间由30s,更新为60s
const service = axios.create({
baseURL: '/xxx',
timeout: 60 * 1000 // 请求超时时间
})
拉长API的请求时间
,将超时时间由30s,更新为60s
const service = axios.create({
baseURL: '/xxx',
timeout: 60 * 1000 // 请求超时时间
})
重发机制
- API请求超时: 当新页面大量接口请求,某个接口请求时间过长,总时间
>60s
时,我们会对这个接口进行至多重发3次
,用180s的时间去处理这个接口,当请求成功后,关闭请求
重发的接口不能超过timeout时长,否则一直会重发失败。也可以自定义重发的时间
- 偶发的服务器异常: 当接口出现50X时,重发一次
可以使用axois自带的方法,也可以使用axios-retry
插件,axios-retry
插件更简单,底层也是axois方法实现的。相比较而言,axios-retry更简单,但不知为什么,我没有实现
// request.js
// 默认重发3次,每次的间隔时间为3s
service.defaults.retry = 3;
service.defaults.retryDelay = 3000;
export function againRequest(
error,
axios,
time = error.config.retry
) {
const config = error.config;
if (!config || !config.retry) return Promise.reject(error);
// 设置用于记录重试计数的变量 默认为0
config.__retryCount = config.__retryCount || 0;
// 判断是否超过了重试次数
if (config.__retryCount >= time) {
// alert
return Promise.reject(error);
}
config.__retryCount += 1;
const backoff = new Promise(resolve => {
setTimeout(() => {
resolve();
}, config.retryDelay || 1);
});
return backoff.then(() => {
/*
以下三行,根据项目实际调整
这三行是处理重发请求接口变成string,并且重定向的问题
*/
if (config.data && isJsonStr(config.data)) {
config.data = JSON.parse(config.data);
}
config.baseURL = "/";
return axios(config);
});
}
export let isLoop = config => {
if (
config.isLoop &&
config.isLoop.count >= 0 &&
config.url == config.isLoop.url
) {
return true;
}
return false;
};
export let isJsonStr = str => {
if (typeof str == "string") {
try {
var obj = JSON.parse(str);
if (typeof obj == "object" && obj) {
return true;
} else {
return false;
}
} catch (e) {
console.log("error: " + str + "!!!" + e);
return false;
}
}
};
注意到是: axois不能是0.19.x
issue中提到使用0.18.0。经过实践,0.20.x也是可以的,但需要进行不同的配置,具体请看issue。参考资料中有提到 axios issues github
- API请求超时: 当新页面大量接口请求,某个接口请求时间过长,总时间
>60s
时,我们会对这个接口进行至多重发3次
,用180s的时间去处理这个接口,当请求成功后,关闭请求
重发的接口不能超过timeout时长,否则一直会重发失败。也可以自定义重发的时间
- 偶发的服务器异常: 当接口出现50X时,重发一次
可以使用axois自带的方法,也可以使用axios-retry
插件,axios-retry
插件更简单,底层也是axois方法实现的。相比较而言,axios-retry更简单,但不知为什么,我没有实现
// request.js
// 默认重发3次,每次的间隔时间为3s
service.defaults.retry = 3;
service.defaults.retryDelay = 3000;
export function againRequest(
error,
axios,
time = error.config.retry
) {
const config = error.config;
if (!config || !config.retry) return Promise.reject(error);
// 设置用于记录重试计数的变量 默认为0
config.__retryCount = config.__retryCount || 0;
// 判断是否超过了重试次数
if (config.__retryCount >= time) {
// alert
return Promise.reject(error);
}
config.__retryCount += 1;
const backoff = new Promise(resolve => {
setTimeout(() => {
resolve();
}, config.retryDelay || 1);
});
return backoff.then(() => {
/*
以下三行,根据项目实际调整
这三行是处理重发请求接口变成string,并且重定向的问题
*/
if (config.data && isJsonStr(config.data)) {
config.data = JSON.parse(config.data);
}
config.baseURL = "/";
return axios(config);
});
}
export let isLoop = config => {
if (
config.isLoop &&
config.isLoop.count >= 0 &&
config.url == config.isLoop.url
) {
return true;
}
return false;
};
export let isJsonStr = str => {
if (typeof str == "string") {
try {
var obj = JSON.parse(str);
if (typeof obj == "object" && obj) {
return true;
} else {
return false;
}
} catch (e) {
console.log("error: " + str + "!!!" + e);
return false;
}
}
};
注意到是: axois不能是0.19.x
issue中提到使用0.18.0。经过实践,0.20.x也是可以的,但需要进行不同的配置,具体请看issue。参考资料中有提到 axios issues github
也可以使用axios-retry
npm install axios-retry
// ES6
import axiosRetry from 'axios-retry';
axiosRetry(axios, { retries: 3 });
取消机制
当路由发生变化时,取消上一个路由正在请求的API接口
监控路由页面: 调用cancelAllRequest方法
// request.js
const pendingRequests = new Set();
service.cancelAllRequest = () => {
pendingRequests.forEach(cancel => cancel());
pendingRequests.clear();
};
轮询
轮询有2种情况,一种是定时器不停的请求,一种是监听请求N次后停止。
比如: 监听高低电平的变化 - 如快递柜的打开&关闭。
- 一直轮询的请求:
- 使用WebSocket
- 连续失败N次后,谈框。
- 轮询N次的请求:
- 连续失败N次后,谈框。
- 连续失败N次后,谈框。
export function api(data, retryCount) {
return request({
url: `/xxx`,
method: "post",
isLoop: {
url: "/xxx",
count: retryCount
},
data: { body: { ...data } }
});
}
自定义api url的原因是:
同一个页面中,有正常的接口和轮询的接口,url是区分是否当前的接口是否是轮询的接口
监听滚动
对于图表类的功能,监听滚动事件,根据不同的高度请求对应的API
节流机制
- 用户连续多次请求同一个API
- 按钮loading。最简单有效
- 保留最新的API请求,取消相同的请求
- 用户连续多次请求同一个API
- 按钮loading。最简单有效
- 保留最新的API请求,取消相同的请求
错误码解析
网络错误 & 断网
if (error.toString().indexOf("Network Error") !== -1) {
if (configData.isLoop && configData.isLoop.count >= 0) {
networkTimeout();
// 关闭所有的定时器
let t = setInterval(function() {}, 100);
for (let i = 1; i <= t; i++) {
clearInterval(i);
}
return;
}
networkTimeout();
}
if (error.toString().indexOf("Network Error") !== -1) {
if (configData.isLoop && configData.isLoop.count >= 0) {
networkTimeout();
// 关闭所有的定时器
let t = setInterval(function() {}, 100);
for (let i = 1; i <= t; i++) {
clearInterval(i);
}
return;
}
networkTimeout();
}
404
else if (error.toString().indexOf("404") !== -1) {
// 404
}
else if (error.toString().indexOf("404") !== -1) {
// 404
}
401
else if (error.toString().indexOf("401") !== -1) {
// 清除token,及相应的数据,返回到登录页面
}
else if (error.toString().indexOf("401") !== -1) {
// 清除token,及相应的数据,返回到登录页面
}
超时
else if (error.toString().indexOf("Error: timeout") !== -1) {
if (!axios.isCancel(error) && !isLoop(configData)) {
return againRequest(error, service);
}
if (configData.isLoop && configData.isLoop.count === 3) {
requestTimeout();
}
}
else if (error.toString().indexOf("Error: timeout") !== -1) {
if (!axios.isCancel(error) && !isLoop(configData)) {
return againRequest(error, service);
}
if (configData.isLoop && configData.isLoop.count === 3) {
requestTimeout();
}
}
50X
else if (error.toString().indexOf("50") !== -1) {
if (!axios.isCancel(error) && !isLoop(configData)) {
return againRequest(error, service, 1, 2);
}
if (configData.isLoop && configData.isLoop.count === 1) {
requestTimeout();
}
}
else if (error.toString().indexOf("50") !== -1) {
if (!axios.isCancel(error) && !isLoop(configData)) {
return againRequest(error, service, 1, 2);
}
if (configData.isLoop && configData.isLoop.count === 1) {
requestTimeout();
}
}
未知错误
else {
// 未知错误,等待以后解析
}
else {
// 未知错误,等待以后解析
}
总结
结果将状态码梳理后,客户基本看不到API错误了,对服务的稳定性和可靠性非常满意,给我们提出了表扬和感谢。我们也期待老板升职加薪!
参考资料
作者:高志小鹏鹏
来源:juejin.cn/post/7413187186131533861
来源:juejin.cn/post/7413187186131533861
Video.js:视频播放的全能解决方案
大家好,我是徐徐。今天跟大家分享一款多媒体类前端工具库:Video.js。
前言
在现代网页开发中,视频播放功能已经成为用户体验的一个重要组成部分。无论你是开发一个视频分享平台还是一个简单的博客,选择合适的视频播放器都至关重要。今天,我们要介绍的 Video.js
是一个强大且灵活的 HTML5 视频播放器,它能够满足你对视频播放的所有需求。
基本信息
- 官网:videojs.com
- GitHub:github.com/videojs/vid…
- Star:37.8K
- 类别:多媒体
什么是 Video.js?
Video.js
是一个从零开始为 HTML5 世界打造的网页视频播放器。它不仅支持 HTML5 视频和现代流媒体格式,还支持 YouTube 和 Vimeo。自2010年中期项目启动以来,Video.js
已经发展成为一个拥有数百名贡献者并广泛应用于超过** 80 **万个网站的播放器。
主要特点
- 全能播放:
Video.js
支持传统的视频格式,如 MP4 和 WebM,同时也支持自适应流媒体格式,如 HLS 和 DASH。对于直播流,Video.js
还提供了专门的用户界面,使直播体验更加流畅。 - 易于定制:虽然
Video.js
自带的播放器界面已经非常美观,但它的设计也考虑到了可定制性。通过简单的 CSS 你可以轻松地为播放器增添个人风格,使其更符合你的网页设计需求。 - 丰富的插件生态:当你需要额外功能时,
Video.js
的插件架构能够满足你的需求。社区已经开发了大量的插件和皮肤,包括 Chromecast、IMA 以及 VR 插件,帮助你快速扩展播放器的功能。
使用场景
Video.js
适用于各种视频播放场景:
- 视频分享平台:无论是播放本地视频还是流媒体内容,
Video.js
都能提供稳定的播放体验。 - 直播应用:通过专用的直播流 UI,
Video.js
能够实现高质量的实时视频播放。 - 教育和培训平台:支持多种格式和流媒体,确保你的教学视频能够在不同设备上顺畅播放。
快速上手
要在你的网页中使用 Video.js
,只需以下简单步骤:
- 引入
Video.js
的库:
<link href="https://unpkg.com/video.js/dist/video-js.min.css" rel="stylesheet">
<script src="https://unpkg.com/video.js/dist/video.min.js">script>
<link href="https://unpkg.com/video.js@8.17.3/dist/video-js.min.css" rel="stylesheet">
<script src="https://unpkg.com/video.js@8.17.3/dist/video.min.js">script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/video.js/8.17.3/video-js.min.css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/video.js/8.17.3/video.min.js">script>
- 添加视频播放器元素:
<video
id="my-player"
class="video-js"
controls
preload="auto"
poster="//vjs.zencdn.net/v/oceans.png"
data-setup='{}'>
<source src="//vjs.zencdn.net/v/oceans.mp4" type="video/mp4">source>
<source src="//vjs.zencdn.net/v/oceans.webm" type="video/webm">source>
<source src="//vjs.zencdn.net/v/oceans.ogv" type="video/ogg">source>
<p class="vjs-no-js">
To view this video please enable JavaScript, and consider upgrading to a
web browser that
<a href="https://videojs.com/html5-video-support/" target="_blank">
supports HTML5 video
a>
p>
video>
- 初始化播放器:
var player = videojs('my-video');
就这样,你就可以在网页上嵌入一个功能丰富的视频播放器了。
该videojs
函数还接受一个options
对象和一个回调:
var options = {};
var player = videojs('my-player', options, function onPlayerReady() {
videojs.log('Your player is ready!');
// In this context, `this` is the player that was created by Video.js.
this.play();
// How about an event listener?
this.on('ended', function() {
videojs.log('Awww...over so soon?!');
});
});
结语
Video.js
是一个功能强大且灵活的视频播放器,它支持多种视频格式和流媒体协议,并且具有丰富的插件生态和良好的定制性。无论你是构建一个视频分享平台还是需要实现高质量的直播播放,Video.js
都能为你提供稳定且可扩展的解决方案。
希望这篇文章能帮助你了解 Video.js
的强大功能,并激发你在项目中使用它的灵感,这么好的东西,赶快分享给你的朋友们吧!
来源:juejin.cn/post/7411046020840964131
文档协同软件是如何解决编辑冲突的?
前言
本文将介绍在线协同文档编辑器是如何解决冲突的,大部分公司在解决冲突上目前用的都是 OT 算法,与之对应,也有一个 CRDT 算法实现。接下来,我们将深入这两种算法的实现及原理。
解决冲突的方案
在线协同文档编辑器通常使用不同的算法来解决冲突,具体算法取决于编辑器的实现和设计。以下是一些常见的解决冲突的算法:
- OT(Operational Transformation,操作转换):这是一种常见的解决冲突的算法,用于实现实时协同编辑。OT算法通过将用户的编辑操作转换为操作序列,并在多个用户同时编辑时进行
操作转换
,以确保最终的文档状态一致。 - CRDT(Conflict-free Replicated Data Type,无冲突复制数据类型):
这是一种基于数据结构的解决冲突的算法
,它允许多个用户在不同的副本上进行并发
编辑,并最终将编辑结果合并为一致的文档状态。CRDT算法通过设计特定的数据结构和操作规则来实现冲突自由的复制。
这些算法都有各自的优缺点,并且在不同的场景和需求下可能更适合不同的编辑器实现。
接下来,我们先聊聊 OT 算法。
OT 算法
当多个用户同时编辑同一文档时,OT算法通过操作转换来保持文档状态的一致性。下面是一个简单的示例,帮助你理解OT算法的工作原理。
假设有两个用户 A 和 B 同时编辑一段文本,初始文本为 "Hello, world!"。
用户 A
在文本末尾添加了字符 " How are you?"。
用户 B
在文本末尾添加了字符 " I'm fine."。
在 OT 算法中,每个操作都由一个操作类型和操作内容组成。在这个例子中,添加字符操作的操作类型为 "insert",操作内容为被插入的字符。
用户 A 的操作序列为:[insert(" How are you?")]
用户 B 的操作序列为:[insert(" I'm fine.")]
首先,服务器收到用户 A 的操作序列,将其应用到初始文本上,得到 "Hello, world! How are you?"。然后,服务器收到用户 B 的操作序列,将其应用到初始文本上,得到 "Hello, world! I'm fine."。
接下来,服务器需要进行操作转换,将用户 B 的操作序列转换为适应用户 A 的文本状态。在这个例子中,用户 B 的操作 "insert(" I'm fine.")" 需要转换为适应 "Hello, world! How are you?" 的操作。
操作转换
的过程如下:
- 用户 A 的操作 "insert(" How are you?")" 在用户 B 的操作 "insert(" I'm fine.")"
之前
发生,因此用户 B 的操作不会受到影响。 - 用户 B 的操作 "insert(" I'm fine.")" 在用户 A 的操作 "insert(" How are you?")"
之后
发生,因此用户 B 的操作需要向后移动。 - 用户 B 的操作 "insert(" I'm fine.")"
向后移动
到 "Hello, world! How are you? I'm fine."。
最终,服务器将转换后的操作序列发送给用户 A 和用户 B,他们将其应用到本地文本上,最终得到相同的文本状态 "Hello, world! How are you? I'm fine."。
这个例子展示了 OT 算法如何通过操作转换来保持多个用户同时编辑时文档状态的一致性。在实际应用中,OT 算法需要处理更复杂的操作类型和情况,并进行更详细的操作转换。
接下来,我们聊聊 CRDT 算法:
CRDT 算法
当多个用户同时编辑同一文档时,CRDT(Conflict-free Replicated Data Type,无冲突复制数据类型)算法通过设计特定的数据结构和操作规则来实现冲突自由的复制。下面是一个简单的示例,帮助你理解CRDT算法的工作原理。
在CRDT算法中,文档被表示为一个数据结构,常见的数据结构之一是有序列表(Ordered List)。每个用户的编辑操作会被转换为操作序列,并应用到本地的有序列表上。
假设有两个用户 A 和 B 同时编辑一段文本,初始文本为空。用户 A 在文本末尾添加了字符 "Hello",用户 B 在文本末尾添加了字符 "World"。
在CRDT算法中,每个字符都被赋予一个唯一的标识符,称为标记
(Marker)。在这个例子中,我们使用递增的整数作为标记。
用户 A 的操作序列为:[insert("H", 1), insert("e", 2), insert("l", 3), insert("l", 4), insert("o", 5)]
用户 B 的操作序列为:[insert("W", 6), insert("o", 7), insert("r", 8), insert("l", 9), insert("d", 10)]
每个操作都包含要插入的字符以及对应的标记。
当用户 A 应用自己的操作序列时,有序列表变为 "Hello"。同样地,当用户 B 应用自己的操作序列时,有序列表变为 "World"。
接下来,服务器需要将两个用户的操作序列合并为一致的文本状态。在CRDT算法中,合并的过程是通过比较标记的大小来确定字符的顺序,并确保最终的文本状态一致。
合并的过程如下:
- 用户 A 的操作序列中的标记小于用户 B 的操作序列中的标记,因此 "H" 在 "W" 之前。
- 用户 A 的操作序列中的标记小于用户 B 的操作序列中的标记,因此 "e" 在 "o" 之前。
- 用户 A 的操作序列中的标记小于用户 B 的操作序列中的标记,因此 "l" 在 "r" 之前。
- 用户 A 的操作序列中的标记小于用户 B 的操作序列中的标记,因此 "l" 在 "l" 之前。
- 用户 A 的操作序列中的标记小于用户 B 的操作序列中的标记,因此 "o" 在 "d" 之前。
最终,合并后的有序列表为 "HelloWorld"。
这个例子展示了CRDT算法如何通过设计特定的数据结构和操作规则,以及比较标记的大小来实现冲突自由的复制。在实际应用中,CRDT算法可以应用于各种数据结构和操作类型,并进行更复杂的合并过程。
CRDT 的标记实现方案
- 递增整数标记:最简单的标记实现方式是使用递增的整数作为标记。每个操作都会分配一个唯一的整数标记,标记的大小可以用来比较操作的发生顺序。
- 时间戳标记:另一种常见的标记实现方式是使用时间戳作为标记。每个操作都会被分配一个时间戳,可以使用系统时间或逻辑时钟来生成时间戳。时间戳可以用来比较操作的发生顺序。
- 向量时钟标记:向量时钟是一种用于标记并发事件的数据结构。每个操作都会被分配一个向量时钟标记,向量时钟由多个节点的时钟组成,每个节点维护自己的逻辑时钟。向量时钟可以用来比较操作的发生顺序,并检测并发操作之间的关系。
- 哈希值标记:有些情况下,可以使用操作内容的哈希值作为标记。每个操作的内容都会被哈希为一个唯一的标记,这样可以通过比较哈希值来比较操作的发生顺序。
方案选型
OT算法和CRDT算法都是用于实现实时协同编辑的算法,但它们有不同的优点、缺点和工作原理。
OT算法的优点:
- 简单性:OT算法相对较简单,易于理解和实现。
- 实时协同编辑:OT算法可以实现实时协同编辑,多个用户可以同时编辑文档,并通过操作转换保持文档状态的一致性。
OT算法的缺点:
- 操作转换复杂性:OT算法的操作转换过程相对复杂,需要考虑多种情况和操作类型,实现和测试上有一定的挑战。
- 需要服务器参与:OT算法需要中央服务器来处理操作转换,这可能会导致一定的延迟和依赖性。
CRDT算法的优点:
- 冲突自由:CRDT算法设计了特定的数据结构和操作规则,可以实现冲突自由的复制,多个用户同时编辑时不会发生冲突。
- 去中心化:CRDT算法可以在去中心化的环境中工作,不依赖于中央服务器进行操作转换,每个用户可以独立地进行编辑和合并。
CRDT算法的缺点:
- 数据结构复杂性:CRDT算法的数据结构和操作规则相对复杂,需要更多的设计和实现工作。
- 数据膨胀:CRDT算法可能会导致数据膨胀,特别是在复杂的编辑操作和大规模的协同编辑场景下。
OT算法和CRDT算法的区别:
- 算法原理:OT算法通过操作转换来保持文档状态的一致性,而CRDT算法通过设计特定的数据结构和操作规则来实现冲突自由的复制。
- 中心化 vs. 去中心化:OT算法需要中央服务器进行操作转换,而CRDT算法可以在去中心化的环境中工作,每个用户都有完整的本地数据,可以将操作发送给服务器,然后网络分发到其他客户端。
- 复杂性:OT算法相对较简单,但在处理复杂操作和转换时可能会变得复杂;CRDT算法相对复杂,需要更多的设计和实现工作。
选择使用哪种算法取决于具体的应用场景和需求。OT算法适用于需要实时协同编辑的场景,而CRDT算法适用于去中心化和冲突自由的场景。
总结
本文介绍了在线协同文档编辑器中解决冲突的算法,主要包括OT算法和CRDT算法。OT算法通过操作转换来保持文档状态的一致性,而CRDT算法通过设计特定的数据结构和操作规则实现冲突自由的复制
。OT算法需要中央服务器进行操作转换,适用于需要实时协同编辑的场景。CRDT算法可以在去中心化环境中工作,每个用户都有完整的本地数据,适用于去中心化和冲突自由的场景。选择使用哪种算法取决于具体的应用场景和需求。
来源:juejin.cn/post/7283018190593785896
audio自动播放为什么会失败
背景
某天客户报了一个问题,说是大屏的声音不能自动播放了,我们的大屏应用是有个报警的,当有报警的时候,自动会播放警报的声音
复线步骤
测试后发现如下结论
- 当刷新页面后,audio不会自动播放
- 当从另外的一个页面进入到当前页面,可以直接播放声音
如果你想测试,可以点我进行测试
你可以先点击上方链接的 尝试一下 ,下方为截图
这个时候你会听到一声马叫声
然后,你刷新下马叫声的页面,这个时候声音的自动播放将不会生效
报错问题排查
打开控制台,不出意外看到了一个报错信息。
翻译为中文的意思为
不允许的错误。播放失败,因为用户没有先与文档交互。goo.gl/xX8pDD
尝试解决
那我给通过给body添加点击事件,自动触发点击事件,在点击的事件后自动播放声音。
(当是我的想法是,这个大概率是不行的,chrome应该不会忽略这一点,不然这个功能就相当于不存在)
经过测试后,发现确实还不行,在意料中。
参考别人的网站,用抖音测试
想到了我们可以参考抖音,我用抖音的进行测试,当你不做任何的操作,页面应该如下
我们从这里得出结论,这个应该是浏览器的限制,需要查看官方文档,看看原因
查阅官方文档
我截取了一些关键的信息
注意浏览器会有一个媒体互动指数,这是浏览器自动计算的,该分越高,才会触发自动播放
查看电脑的媒体互动指数
在url上输入 about://media-engagement,你会看到如下的截图,
经过测试后 当网站变成了is High,音频会自动播放,不会播放失败。
这就解释了为什么有的网站可以自动播放声音,有的网站不可以自动播放声音
ok,我们继续往下看,这个时候看到了一些关键的信息。
作为开发者,我们不应该相信音频/视频会播放成功,要始终在播放的回掉中来进行判断
看到这些,我们来模仿抖音的实现.在播放声音的catch的时候,显示一个错误的弹窗,提示用户,当用户点击的时候,自动播放声音
this.alarmAudio = new Audio(require("@/assets/sound/alarm.mp3"));
this.alarmAudio
.play()
.then(() => {
this.notifyId && this.notifyId.close();
})
.catch((error) => {
if (error instanceof DOMException) {
// 这里可以根据异常类型进行相应的错误处理
if (error.name === "NotAllowedError") {
if (this.notifyId) return;
this.notifyId = Notification({
title: "",
duration: 0,
position: "bottom-left",
dangerouslyUseHTMLString: true,
onClick: this.onAudioNotifyConfirm,
showClose: false,
customClass: "audio-notify-confirm",
message:
"<div style='color:#fff;font-size:18px;cursor:pointer'>因浏览器限制,需<span style='color:#ff2c55'>点击打开声音</span></div>",
});
}
}
});
实现效果如下
总结
- 在用到video或者audio的时候,要始终不相信他会播放声音成功,并且添加catch处理异常场景,给用户友好的提示
- video或者audio的自动播放跟媒体互动指数有关(MEI),当媒体指数高,会自动播放,否则需要用户先交互后,audio才可以自动播放。
- 从一个页面window.open另外一个页面可以自动播放声音,当刷新页面后,需要有高的MEI,audio才会自动播放,如果你需要在后台打开一个大屏的页面,刚好可以这样设计,不要用页面跳转
来源:juejin.cn/post/7412505754383007744
Vue3真的不需要用pinia!!!
前言
之前使用vue3都是在公司的基建项目中,为了快速达到目的,把以前vue2的模板拿来简单改改就直接用了,所以项目中用法特别乱,比如:状态管理依旧用的vuex
,各种类型定义全是any
,有些代码是选项式API,有些代码是组合式API...
最近终于有时间推动一下业务项目使用vue3
了。作为极简主义的我,始终奉行少即是多,既然是新场景,一切从新,从头开始写模版:
- 使用最新的vue3版本
v3.5.x
。 - 所有使用的内部库全部生成
ts
类型并引入到环境中。 - 将所有的
mixins
重写,包装成组合式函数。 - 将以前的
vue
上的全局变量挂载到app.config.globalProperties
。 - 全局变量申明类型到
vue-runtime-core.d.ts
中,方便使用。 - 全部使用
setup
语法,使用标签<script setup lang="ts">
- 使用
pinia
作为状态管理。
pinia使用
等等,pinia
?好用吗?打开官方文档研究了下,官方优先推荐的是选项式API的写法。
调用defineStore
方法,添加属性state, getters, actions
等。
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0, name: 'Eduardo' }),
getters: {
doubleCount: (state) => state.count * 2,
},
actions: {
increment () {
this.count++
},
},
})
使用的时候,调用useCounterStore
即可。
import { useCounterStore } from '@/stores/counter'
import { computed } from 'vue'
const store = useCounterStore()
setTimeout(() => {
store.increment()
}, 1000)
const doubleValue = computed(() => store.doubleCount)
看上去还不错,但是我模版中全部用的是组合式写法,肯定要用组合式API,试着写了个demo
,ref
就是选项式写法中的state
,computed
就是选项式中的getters
,function
就是actions
。
// useTime.ts
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import vueConfig from '../../../common/config/vueConfig'
import * as dayjs from 'dayjs'
export default defineStore('time', () => {
const $this = vueConfig()
const time = ref<number>()
const timeFormat = computed(() => dayjs(time.value).format('YYYY-MM-DD HH:mm:ss'))
const getSystemTime = async () => {
const res = await $this?.$request.post('/system/time')
time.value = Number(res.timestamp)
}
return { timeFormat, getSystemTime }
})
调用时解构赋值,就可以直接用了。
// index.vue
<script setup lang="ts">
import { onMounted } from 'vue'
import useTime from './use/useTime'
const { timeFormat, getSystemTime } = useTime()
onMounted(async () => {
// 请求
await getSystemTime()
console.log('当前时间:', timeFormat)
})
</script>
优雅了很多,之前用vuex
时还有个问题,storeA
中的state、actions
等,会在storeB
中使用,这一点pinia
文档也有说明,直接在storeB
调用就好了,比如我想在另一个组件中调用上文中提到的timeFormat
。
defineStore('count', () => {
const count = ref<number>(0)
const { timeFormat } = useTime()
return {
count,
timeFormat,
}
})
怎么看着这么眼熟呢,这不就是组合式函数吗?为什么我要用defineStore
再包一层呢?试一试不用pinia
,看能不能完成状态管理。
组合式函数
直接添加一个useCount.ts
文件,申明一个组合式函数。
// useCount.ts
import { computed, ref } from 'vue'
const useCount = () => {
const count = ref<number>(0)
const doubleCount = computed(() => {
return count.value * 2
})
const setCount = (v: number) => {
count.value = v
}
return {
count,
doubleCount,
setCount,
}
}
export default useCount
使用时直接解构申明,并使用。
import useCount from './use/useCount'
const { count, setCount } = useCount()
onMounted(async () => {
console.log('count', count.value) // 0
setCount(10)
console.log('count', count.value) // 10
})
最大的问题来了,如何在多个地方共用count
的值呢,这也是store
最大的好处,了解javascript
函数机制的我们知道useCount
本身是一个闭包,每次调用,里面的ref
就会重新生成。count
就会重置。
import useCount from './use/useCount'
const { count, setCount } = useCount()
const { doubleCount } = useCount()
onMounted(async () => {
console.log('count', count.value, doubleCount.value) // 0 0
setCount(10)
console.log('count', count.value, doubleCount.value) // 10 0
})
这个时候doubleCount
用的并不是第一个useCount
中的count
,而是第二个重新生成的,所以setCount
并不会引起doubleCount
的变化。
怎么办呢?简单,我们只需要把count
的声明暴露在全局环境中,这样在import
时就会申明了,调用函数时不会被重置。
import { computed, ref } from 'vue'
const count = ref<number>(0)
const useCount = () => {
const doubleCount = computed(() => {
return count.value * 2
})
const setCount = (v: number) => {
count.value = v
}
return {
count,
doubleCount,
setCount,
}
}
export default useCount
当我们多次调用时,发现可以共享了。
import useCount from './use/useCount'
const { count, setCount } = useCount()
const { doubleCount } = useCount()
onMounted(async () => {
console.log('count', count.value, doubleCount.value) // 0 0
setCount(10)
console.log('count', count.value, doubleCount.value) // 10 20
})
但是这个时候count
是比较危险的,store
应该可以保护state
不被外部所修改,很简单,我们只需要用readonly
包裹一下返回的值即可。
import { computed, readonly, ref } from 'vue'
const count = ref<number>(0)
const useCount = () => {
const doubleCount = computed(() => {
return count.value * 2
})
const setCount = (v: number) => {
count.value = v
}
return {
// readonly可以确保引用对象不会被修改
count: readonly(count),
doubleCount,
setCount,
}
}
export default useCount
总结
经过我的努力,vue3
又减少了一个库的使用,我就说不需要用pinia
,不过放弃pinia
也就意味着放弃了它自带的一些方法store.$state
,store.$patch
等等,这些方法实现很简单,很轻松就可以手写出来,如果你是这些方法的重度用户,保留pinia
也没问题,如果你也想代码更加精简,赶紧尝试下组合式函数吧。
来源:juejin.cn/post/7411328136740847654
拖拽神器:Pragmatic-drag-and-drop!
哈喽,大家好 我是
xy
👨🏻💻。今天给大家分享一个开源
的前端最强
拖拽组件 —pragmatic-drag-and-drop
!
前言
在前端开发中,拖拽功能
是一种常见的交互方式,它能够极大提升用户体验。
今天,我们要介绍的是一个开源的前端拖拽组件 — pragmatic-drag-and-drop
,它以其轻量级
、高性能
和强大的兼容性
,成为了前端开发者的新宠。
什么是 pragmatic-drag-and-drop?
pragmatic-drag-and-drop
是由 Atlassian
开源的一款前端拖拽组件。
Atlassian
,作为全球知名的软件开发公司,其核心产品 Trello
、Jira
和 Confluence
都采用了 pragmatic-drag-and-drop
组件。
这不仅证明了该组件的实用性和可靠性,也反映了 Atlassian
对前端交互体验的极致追求。
组件的作者:Alex Reardon
,也是流行 React
开源拖拽组件 react-beautiful-dnd
的开发者。
pragmatic-drag-and-drop
继承了作者对拖拽交互的深刻理解,支持多种拖拽场景,包括列表
、面板
、表格
、树
、网格
、绘图
和调整大小
等。
为什么选择 pragmatic-drag-and-drop?
- 轻量化:核心包大小仅为
4.7KB
,轻量级的体积使得它在加载速度上具有优势。 - 灵活性:提供
无头
(headless)解决方案,开发者可以完全自定义视觉效果和辅助技术控制。 - 框架无关性:适用于
所有主流前端框架
,如 React、Svelte、Vue 和 Angular。 - 高性能:支持
虚拟化
,适应各种复杂的用户体验,确保拖拽操作流畅。 - 全平台覆盖:在
所有主流浏览器
和移动设备
上运行良好,包括 Firefox、Safari、Chrome 以及 iOS 和 Android 设备。 - 无障碍支持:为
非鼠标操作
用户提供友好体验,确保所有用户都能享受拖拽体验。
应用场景
pragmatic-drag-and-drop
功能适用于多种场景,包括但不限于:
- 任务管理应用:通过拖放操作,轻松实现卡片式任务列表的排序与整理。
- 文档管理系统:简化文件夹和文件的移动与组织过程,提高工作效率。
- 在线编辑器:提供直观的内容布局调整体验,增强用户自定义能力。
- 数据可视化工具:允许用户动态调整图表元素位置,实现更丰富的信息展示。
- 设计工具:在组件库中轻松排列组合元素,激发创意无限可能。
案例演示
列表拖拽排序:
面板拖拽:
表格拖拽排序:
树形节点拖拽:
绘图功能鼠标拖动:
可拖动棋子的棋盘:
在线演示地址:https://atlassian.design/components/pragmatic-drag-and-drop/examples
最后
如果觉得本文对你有帮助,希望能够给我点赞支持一下哦 💪 也可以关注wx公众号:
前端开发爱好者
回复加群,一起学习前端技能 公众号内包含很多实战
精选资源教程,欢迎关注
来源:juejin.cn/post/7406139000265752639
「滚动绽放」页面滚动时逐渐展示/隐藏元素
本文将介绍如何使用HTML
、CSS
和JavaScript
代码实现页面在滚动时元素逐渐出现/隐藏。这个动画效果会在用户滚动/隐藏页面时从不同方向逐渐显示出一组彩色方块🏳️🌈
HTML结构
首先,HTML
部分包含了一个<section>
元素和一个名为container
的容器,其中包含了多个box
元素。别忘了引入外部CSS和JS文件;
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="./style.css">
<title>Scroll To Reveal Animation</title>
</head>
<body>
<section>
<h2>Scroll To Reveal</h2>
</section>
<div class="container">
<!-- 调试CSS样式阶段 -->
<!-- <div class="box"></div>
<div class="box"></div>
<div class="box"></div>
<div class="box"></div> -->
</div>
<script src="./index.js"></script>
</body>
</html>
CSS样式
接着,设置一些基本的全局样式和居中布局、背景颜色和文字颜色;
- 关于
container
容器,使用grid布局三列。 - 对于
box
容器,这部分CSS
伪类代码定义了元素在动画中的位置和缩放变换。解释一下每个选择器的作用:
.box:nth-child(3n + 1)
:选择容器中每隔3个元素的第一个方块元素(这里表示第一列)。沿X轴向左平移400像素,缩放为0,即隐藏起来。.box:nth-child(3n + 2)
:选择容器中每隔3个元素的第二个方块元素(这里表示第二列)。沿Y轴向下平移400像素,缩放为0。.box:nth-child(3n + 3)
:选择容器中每隔3个元素的第三个方块元素(这里表示第三列)。沿X轴向右平移400像素,缩放为0。
这些选择器定义了方块元素的初始状态,使它们在页面加载时处于隐藏状态。并且预设了.box.active
激活状态的样式。
- 将其平移到原始位置并恢复为原始尺寸,即显示出来。当滚动触发相应的事件时,方块元素将根据添加或移除
active
类来决定是逐渐显示或隐藏。
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: Arial, Helvetica, sans-serif;
}
body {
display: flex;
flex-direction: column;
justify-content:center;
align-items: center;
background-color: #111;
color: #fff;
overflow-x: hidden;
}
section {
min-height: 100vh;
display: flex;
justify-content:center;
align-items: center;
}
section h2 {
font-size: 8vw;
font-weight: 500;
}
.container {
width: 700px;
position: relative;
top: -200px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 30px;
}
.container .box {
width: 200px;
height: 200px;
background-color: #fff;
border-radius: 10px;
position: relative;
top: 50vh;
transition: .5s;
}
.container .box:nth-child(3n + 1) {
transform: translate(-400px, 0) scale(0);
}
.container .box:nth-child(3n + 2) {
transform: translate(0, 400px) scale(0);
}
.container .box:nth-child(3n + 3) {
transform: translate(400px, 0) scale(0);
}
.container .box.active {
transform: translate(0, 0) scale(1);
}
表现

JavaScript实现
最后,使用JavaScript
生成每个方块并设置了随机的背景颜色,随后将它们添加到container
容器中,通过监听滚动事件,使方块在用户滚动页面时根据位置添加类名否,应用CSS样式实现逐渐显示或隐藏;
- 定义
randomColor
函数,用于生成随机的颜色值。这个函数会从一组字符中随机选择6个字符(每次循环随机取一个)作为颜色代码,并将其拼接成一个十六进制颜色值返回。 - 获取container容器元素,并创建一个文档片段
fragment
用于存储循环创建出来带有背景色的.box
方块元素,最后将文档片段附加到container中。 - 定义
scrollTrigger
函数,绑定到窗口的滚动事件上。在这个函数中,遍历每个方块,检查相对于窗口顶部的偏移量,如果小于等于当前滚动的距离,则添加active类,显示方块。反之,则移除active类,隐藏方块。
/**创建随机色 */
const randomColor = () => {
const chars = "1234567890abcdef",
colorLegh = 6;
let color = '#';
for (let i = 0; i < colorLegh; i++) {
const p = Math.floor(Math.random() * chars.length);
color += chars.substring(p, p + 1);
};
return color;
};
/**创建DOM */
const container = document.querySelector('.container'),
fragment = document.createDocumentFragment();
for (let i = 0; i < 60; i++) {
const box = document.createElement('div');
box.style.backgroundColor = randomColor();
box.classList.add('box');
fragment.appendChild(box);
};
container.appendChild(fragment);
/**创建动画 */
const randomColorBlock = document.querySelectorAll('.box');
const scrollTrigger = () => {
randomColorBlock.forEach((box) => {
if (box.offsetTop <= window.scrollY) {
box.classList.add('active')
} else {
box.classList.remove('active')
}
});
};
window.addEventListener('scroll', scrollTrigger);
总结
通过本篇文章的详细介绍,相信能够帮助你更好地使用CSS
和JavaScript
来创建一个滚动显示元素动画,从而理解掌握和应用这个效果。通过设置合适的样式和脚本来控制元素的显示和隐藏为网页提供了生动和吸引力。
希望这篇文章对你在开发类似交互动画效果时有所帮助!如果你对这个案列还有任何问题,欢迎在评论区留言或联系(私信)我。码字不易🥲,不要忘了三连鼓励🤟,谢谢阅读,Happy Coding🎉!
源码我放在了GitHub,里面还有一些酷炫的效果、动画案列,喜欢的话不要忘了 starred
不迷路!
来源:juejin.cn/post/7280926568854781987
前端中的 File 和 Blob两个对象到底有什么不同❓❓❓
JavaScript 在处理文件、二进制数据和数据转换时,提供了一系列的 API 和对象,比如 File、Blob、FileReader、ArrayBuffer、Base64、Object URL 和 DataURL。每个概念在不同场景中都有重要作用。下面的内容我们将会详细学习每个概念及其在实际应用中的用法。
接下来的内容中我们将来了解 File和 Blob 这两个对象。
blob
在 JavaScript 中,Blob(Binary Large Object)对象用于表示不可变的、原始的二进制数据。它可以用来存储文件、图片、音频、视频、甚至是纯文本等各种类型的数据。Blob 提供了一种高效的方式来操作数据文件,而不需要将数据全部加载到内存中,这在处理大型文件或二进制数据时非常有用。
我们可以使用 new Blob() 构造函数来创建一个 Blob 对象,语法如下:
const blob = new Blob(blobParts, options);
- blobParts: 一个数组,包含将被放入 Blob 对象中的数据,可以是字符串、数组缓冲区(ArrayBuffer)、TypedArray、Blob 对象等。
- options: 一个可选的对象,可以设置 type(MIME 类型)和 endings(用于表示换行符)。
例如:
const blob = new Blob(["Hello, world!"], { type: "text/plain" });
Blob 对象主要有以下几个属性:
- size: 返回 Blob 对象的大小(以字节为单位)。
console.log(blob.size); // 输出 Blob 的大小
- type: 返回 Blob 对象的 MIME 类型。
console.log(blob.type); // 输出 Blob 的 MIME 类型
Blob 对象提供了一些常用的方法来操作二进制数据。
slice([start], [end], [contentType])
该方法用于从 Blob 中提取一部分数据,并返回一个新的 Blob 对象。参数 start 和 end 表示提取的字节范围,contentType 设置提取部分的 MIME 类型。
const blob = new Blob(["Hello, world!"], { type: "text/plain" });
const partialBlob = blob.slice(0, 5);
text()
该方法将 Blob 的内容读取为文本字符串。它返回一个 Promise,解析为文本数据。
blob.text().then((text) => {
console.log(text); // 输出 "Hello, world!"
});
arrayBuffer()
该方法将 Blob 的内容读取为 ArrayBuffer 对象,适合处理二进制数据。它返回一个 Promise,解析为 ArrayBuffer 数据。
const blob = new Blob(["Hello, world!"], { type: "text/plain" });
blob.arrayBuffer().then((buffer) => {
console.log(buffer);
});
stream()
该方法将 Blob 的数据作为一个 ReadableStream 返回,允许你以流的方式处理数据,适合处理大文件。
const stream = blob.stream();
Blob 的使用场景
Blob 对象在很多场景中非常有用,尤其是在 Web 应用中处理文件、图片或视频等二进制数据时。以下是一些常见的使用场景:
- 生成文件下载
你可以通过 Blob 创建文件并生成下载链接供用户下载文件。
const blob = new Blob(["This is a test file."], { type: "text/plain" });
const url = URL.createObjectURL(blob); // 创建一个 Blob URL
const a = document.createElement("a");
a.href = url;
a.download = "test.txt";
a.click();
URL.revokeObjectURL(url); // 释放 URL 对象
当我们刷新浏览器的时候发现是可以自动给我们下载图片了:
- 上传文件
你可以通过 FormData 对象将 Blob 作为文件上传到服务器:
const formData = new FormData();
formData.append("file", blob, "example.txt");
fetch("/upload", {
method: "POST",
body: formData,
}).then((response) => {
console.log("File uploaded successfully");
});
- 读取图片或其他文件
通过 FileReader API 可以将 Blob 对象读取为不同的数据格式。举例来说,你可以将 Blob 读取为图片并显示在页面上:
html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Documenttitle>
head>
<body>
<input type="file" id="fileInput" accept="image/*" />
<div id="imageContainer">div>
<script>
const fileInput = document.getElementById("fileInput");
const imageContainer = document.getElementById("imageContainer");
fileInput.addEventListener("change", function (event) {
const file = event.target.files[0];
if (file && file.type.startsWith("image/")) {
const reader = new FileReader();
reader.onload = function (e) {
const img = document.createElement("img");
img.src = e.target.result;
img.style.maxWidth = "500px";
img.style.margin = "10px";
imageContainer.innerHTML = "";
imageContainer.appendChild(img);
};
reader.readAsDataURL(file);
} else {
alert("请选择一个有效的图片文件。");
}
});
script>
body>
html>
- Blob 和 Base64
有时你可能需要将 Blob 转换为 Base64 编码的数据(例如用于图像的内联显示或传输)。可以通过 FileReader 来实现:
const reader = new FileReader();
reader.onloadend = function () {
const base64data = reader.result;
console.log(base64data); // 输出 base64 编码的数据
};
reader.readAsDataURL(blob); // 将 Blob 读取为 base64
File
File 是 JavaScript 中代表文件的数据结构,它继承自 Blob 对象,包含文件的元数据(如文件名、文件大小、类型等)。File 对象通常由用户通过 选择文件时创建,也可以使用 JavaScript 构造函数手动创建。
<input type="file" id="fileInput" />
<script>
document.getElementById("fileInput").addEventListener("change", (event) => {
const file = event.target.files[0];
console.log("文件名:", file.name);
console.log("文件类型:", file.type);
console.log("文件大小:", file.size);
});
script>
最终输出结果如下图所示:
我们可以使用 File 的方式来访问用户上传的文件,我们也可以手动创建 File 对象:
const file = new File(["Hello, world!"], "hello-world.txt", {
type: "text/plain",
});
console.log(file);
File 对象继承了 Blob 对象的方法,因此可以使用一些 Blob 对象的方法来处理文件数据。
- slice(): 从文件中获取一个子部分数据,返回一个新的 Blob 对象。
const blob = file.slice(0, 1024); // 获取文件的前 1024 个字节
- text(): 读取文件内容,并将其作为文本返回(这是 Blob 的方法,但可以用于 File 对象)。
file.text().then((text) => {
console.log(text); // 输出文件的文本内容
});
- arrayBuffer(): 将文件内容读取为 ArrayBuffer(用于处理二进制数据)。
file.arrayBuffer().then((buffer) => {
console.log(buffer); // 输出文件的 ArrayBuffer
});
- stream(): 返回一个 ReadableStream 对象,可以通过流式读取文件内容。
const stream = file.stream();
总结
Blob 是纯粹的二进制数据,它可以存储任何类型的数据,但不具有文件的元数据(如文件名、最后修改时间等)。
File 是 Blob 的子类,File 对象除了具有 Blob 的所有属性和方法之外,还包含文件的元数据,如文件名和修改日期。
你可以将 File 对象看作是带有文件信息的 Blob。
const file = new File(["Hello, world!"], "hello.txt", { type: "text/plain" });
console.log(file instanceof Blob); // true
二者在文件上传和二进制数据处理的场景中被广泛使用。Blob 更加通用,而 File 更专注于与文件系统的交互。
来源:juejin.cn/post/7413921824066551842
uni-app小程序超过2M怎么办?
一、开发版
开发版可以调整上限为4M
开发者工具 -> 详情 -> 本地设置 -> 预览及真机调试时主包、分包体积上限调整为4M -> 勾选
二、体验版、正式版
上传代码时,主包必须在2M以内。
小程序tabbar页面必须放在主包。
推荐除了tabbar页面以外,其余的都放在分包。其实只要这样做了,再复杂的小程序,主包代码都很难超过2M,但如果是uni-app开发的,那就不一定了。
uni-app优化
开发环境压缩代码
使用cli创建的项目
在package.json
,script
中设置压缩:在命令中加入--minimize
"dev:mp-weixin": "cross-env NODE_ENV=development UNI_PLATFORM=mp-weixin vue-cli-service uni-build --watch --minimize",
使用hbuilderx创建的项目
顶部菜单栏点击运行 -> 运行到小程序模拟器 -> 运行时是否压缩代码 -> 勾选
开启压缩后,开发环境的小程序代码体积会大大降低
uni.scss优化
uni-app项目创建后会自带一个uni.scss
文件,这个文件无需手动引入,会自动引入到每一个页面文件,所以尽量不要在这个文件内写公共css代码。
我接手的一个uni-app小程序项目,随着功能迭代,打包代码主包体积越来越接近2M,终于有一天写完一个功能,突然就达到了2.2M,无法上传了。参考小程序提供的代码依赖分析,发现wxss
文件占用了大部分体积,于是我就去一个个搜,看某个class有没有被用到,没用到的就删掉,可是再怎么优化冗余代码,也无法降到2M以下。
直到我看到了uni.scss
文件,除了里面自带的一些颜色变量代码,另外还加了700行的公共class,然后我在根目录新建一个assets/common.scss
文件,把那700行代码移出去,在App.vue
内引入
@import './assets/common.scss'
主包体积瞬间降到了1.41M
总结
重要的事情说三遍
- 不要在uni.scss文件内写公共css代码
- 不要在uni.scss文件内写公共css代码
- 不要在uni.scss文件内写公共css代码
来源:juejin.cn/post/7411334549739733018
2024 前端趋势:全栈也许已经是必选项
《2023 的 Javascript 星趋势》在 1 月就出来了,当时略略看了一下,并没有想太多。
过年期间,回想起来,似乎感觉到这次有一点不一样:也许,全栈的时代已经到来了。
React 与 Vue 生态对比
首先,我们来看看 React 与 Vue 生态的星趋势对比:
上图中,React 整个生态的星星数远超于 Vue,第十名都要比 Vue 第一名的多。我们将其做一个分类:
排名 | React | Vue |
---|---|---|
1 | UI | 全栈 |
2 | 白板 | 演示文稿 |
3 | 全栈 | 后台管理系统 |
4 | 状态管理 | hook |
5 | 后台管理系统 | UI |
6 | 文档 | 文档 |
7 | 全栈框架集成 | UI |
8 | 全栈框架UI | 框架 |
9 | 后台管理系统 | UI |
10 | 无服务栈 | 状态管理 |
可以看到 React 这边的生态链基本成熟,几乎每一个分类都有一个上榜的库,不再像 Vue 那样还在卷 UI 框架。
在全栈方面,Vue 的首位就是全栈 Nuxt。
React 的 Next.js 虽然不在首位,但是服务端/全栈相关的内容就占了 4 个,其中包含第 10 名的无服务栈。另外值得注意的是,React 这边还有服务端组件的概念。Shadcn/ui 能占到第一位,因为它基于无头 UI Radix 实现的,在服务端组件也能运用。所以,服务端/全栈在 React 中占的比重相当大的。
这样看来,前端往服务端进发已经成为一个必然趋势。
htmx 框架的倒退
再看看框架这边,htmx 在星趋势里,排行第二位,2023增长的星星数为 15.6K,与第一位的 React 颇为相近。
而 htmx 也是今年讨论度最高的。
在我经历过前后端不分离的阶段中,使用 jsp 生成前端页面,js 更多是页面炫技的工具。然后在 jQuery + Ajax 得到广泛应用之后,才真正有前后端分离的项目。
htmx 的出现,不了解的人,可能觉得是倒退到 Java + jQuery + Ajax 的前后端分离状态。但是,写过例子之后,我发现,它其实是倒退到了前后端不分离的阶段。
用 java 也好,世界上最好的 php 也好,或者用现在的 nodejs 服务,都能接入 htmx。你只要在服务端返回 html 即可。
/** nodejs fastity 写的一个例子 **/
import fastify from 'fastify'
import fastifyHtml from 'fastify-html'
import formbody from '@fastify/formbody';
const app = fastify()
await app.register(fastifyHtml)
await app.register(formbody);
// 省略首页引入 htmx
// 首页的模板,提供一个按钮,点击后请求 html,然后将请求返回的内容渲染到 parent-div 中
app.get('/', async (req, reply) => {
const name = req.query.name || 'World'
return reply.html`
Hello ${name}
`, reply
})
// 请求返回 html
app.post('/clicked', (req, reply) => {
reply.html`Clicked!
`;
})
await app.listen({ port: 3000 })
也许大家会觉得离谱,但是很显然,事情已经开始发生了变化,后端也来抢前端饭碗了。
htmx 在 github 上已经有不少跟随者,能搜出前端代码已有不少,前三就有基于 Python 语言的 Django 服务端框架。
jQuery 见势头不错,今年也更新了 4.0 的 beta 版本,对现代浏览器提供了更好的支持。这一切似乎为旧架构重回大众视野做好了准备。
企业角度
站在企业角度来看,一个人把前后端都干了不是更好吗?
的确如此。前后端一把撸更符合企业的利益。国外的小公司更以全栈作为首选项。
也许有人觉得国情不同,但是在我接触的前端群里,这两年都有人在群里说他们公司前后端分离的情况。
还有的人还喜欢大厂那一套,注意分工合作,但是其实大厂里遗留项目也不少,有的甚至是 php;还有新的实验项目,如果能投入最少人力,快速试错,这种全栈的框架自然也是最优选择。
我并不是说,前后端分离不值得。但是目前已经进入 AI 赛道,企业对后台系统的开发,并不愿意投入更多了。能用就行已经成为当前企业的目标,自然我们也应该跟着变化。
全栈破局
再说说前端已死的论调。我恰恰觉得这是最好做改变的时机。
在浏览器对新技术支持稳定,UI 框架趋同,UI 组件库稳定之后,前端不再需要为浏览器不兼容素手无策了,不再需要苦哈哈地为1个像素争辩不停了,也不再需要为产品莫名其妙的交互焦头烂额了。
这并不意味着前端已死,反而可能我们某个阶段的任务完成了,后面有更重要的任务交给我们。也许,全栈就是一个破局。
在云服务/云原生如此普遍的情况下,语言不再是企业开发考虑的主要因素,这也为 nodejs 全栈铺平了道路。
前端一直拣最苦最脏的话来做,从 UI 中拿到了切图的工作,然后接手了浏览器兼容的活,后来又从后端拿到了渲染页面的工作。
那我们为何不再进一步,主动把 API 开发的工作也拿过来?
来源:juejin.cn/post/7340603873604599843
8个小而美的前端库
前端有很多小而美的库,接入成本很低又能满足日常开发需求,同时无论是 npm 方式引入还是直接复制到本地使用都可以。
2024 年推荐以下小而美的库。
radash
实用的工具库,相比与 lodash,更加面向现代,提供更多新功能(tryit,retry 等函数),源码可读性高,如果不想安装它,大部分函数可以直接复制到本地使用。
use-debounce
React Hook Debouce 库,让你不再为使用防抖烦恼。库的特点:体积小 < 1 Kb、与 underscore / lodash impl 兼容 - 一次学习,随处使用、服务器渲染友好。
timeago.js
格式化日期时间库,比如:“3 hours ago”,支持多语言,仅 2Kb 大小。同时提供了 React 版本 timeago-react。
timeage.format(1544666010224, 'zh_CN') // 输出 “5 年前”
timeage.format(Date.now() - 1000, 'zh_CN') // 输出 “刚刚”
timeage.format(Date.now() - 1000 * 60 * 5, 'zh_CN') // 输出 “5 分钟前”
react-use
实用 Hook 大合集 - 内容丰富,从跟踪电池状态和地理位置,到设置收藏夹、防抖和播放视频,无所不包。
dayjs
Day.js 是一个简约的 JavaScript 库,仅 2 Kb 大小。它可以使用基本兼容 Moment.js,为你提供日期的解析、处理和显示,支持多语言能力。
filesize
filesize.js 提供了一种简单方法,便于从数字(浮点数或整数)或字符串转换成可读性高的文件大小,filesize.min.js 大小为 2.94 kb。
import {filesize} from "filesize";
filesize(265318, {standard: "jedec"}); // "259.1 KB"
driver.js
driver.js 是一款用原生 js 实现的页面引导库,上手非常简单,体积在 gzip 压缩下仅仅 5kb。
@formkit/drag-and-drop
FormKit DnD 是一个小型拖拽库,它简单、灵活、与框架无关,压缩后只有 4Kb 左右,设计理念为数据优先。
小结
前端小而美的库使用起来一般都比较顺手,欢迎在评论区推荐你们开发中的使用小而美的库。
来源:juejin.cn/post/7350140676615798824
登录页面一些有趣的css效果
前言
今天无意看到一个登录页,input
框focus
时placeholder
上移变成label
的效果,无聊没事干就想着自己来实现一下,登录页面能做文章的,普遍的就是按钮动画,title
的动画,以及input
的动画,这是最终的效果图(如下), 同时附上预览页以及实现源码。
title 的动画实现
首先描述一下大概的实现效果, 我们需要一个镂空的一段白底文字,在鼠标移入时给一个逐步点亮的效果。
文字镂空我们可以使用text-stroke
, 逐步点亮只需要使用filter
即可
text-stroke
text-stroke
属性用于在文本的边缘周围添加描边效果,即文本字符的外部轮廓。这可以用于创建具有描边的文本效果。text-stroke
属性通常与-webkit-text-stroke
前缀一起使用,因为它目前主要在WebKit浏览器(如Chrome和Safari)中支持。
text-stroke
属性有两个主要值:
- 宽度(width) :指定描边的宽度,可以是像素值、百分比值或其他长度单位。
- 颜色(color) :指定描边的颜色,可以使用颜色名称、十六进制值、RGB值等。
filter
filter
是CSS属性,用于将图像或元素的视觉效果进行处理,例如模糊、对比度调整、饱和度调整等。它可以应用于元素的背景图像、文本或任何具有视觉内容的元素。
filter
属性的值是一个或多个滤镜函数,这些函数以空格分隔。以下是一些常见的滤镜函数和示例:
- 模糊(blur) : 通过
blur
函数可以实现模糊效果。模糊的值可以是像素值或其他长度单位。
.blurred-image {
filter: blur(5px);
}
- 对比度(contrast) : 通过
contrast
函数可以调整对比度。值为百分比,1表示原始对比度。
.high-contrast-text {
filter: contrast(150%);
}
- 饱和度(saturate) : 通过
saturate
函数可以调整饱和度。值为百分比,1表示原始饱和度。
.desaturated-image {
filter: saturate(50%);
}
- 反色(invert) : 通过
invert
函数可以实现反色效果。值为百分比,1表示完全反色。
.inverted-text {
filter: invert(100%);
}
- 灰度(grayscale) : 通过
grayscale
函数可以将图像或元素转换为灰度图像。值为百分比,1表示完全灰度。
.gray-text {
filter: grayscale(70%);
}
- 透明度(opacity) : 通过
opacity
函数可以调整元素的透明度。值为0到1之间的数字,0表示完全透明,1表示完全不透明。
.semi-transparent-box {
filter: opacity(0.7);
}
- 阴影(drop-shadow) :用于在图像、文本或其他元素周围添加阴影效果。这个属性在 CSS3 中引入,通常用于创建阴影效果,使元素看起来浮在页面上或增加深度感
drop-shadow(<offset-x> <offset-y> <blur-radius>? <spread-radius>? <color>?)
各个值的含义如下:
<offset-x>
: 阴影在 X 轴上的偏移距离。<offset-y>
: 阴影在 Y 轴上的偏移距离。<blur-radius>
(可选): 阴影的模糊半径。默认值为 0。<spread-radius>
(可选): 阴影的扩散半径。默认值为 0。<color>
(可选): 阴影的颜色。默认值为当前文本颜色。
filter
属性的支持程度因浏览器而异,因此在使用时应谨慎考虑浏览器兼容性。
实现移入标题点亮的效果
想实现移入标题点亮的效果我们首先需要两个通过定位重叠的span
元素,一个做镂空用于展示,另一个作为
hover
时覆盖掉镂空元素,并通过filter: drop-shadow
实现光影效果,需要注意的是这里需要使用inline
元素实现效果。
input 的动画实现
input
的效果比较简单,只需要在focus
时span(placeholder)
上移变成span(label)
同时给input
的border-bottom
做一个底色的延伸,效果确定了接着就看看实现思路。
input placeholder 作为 label
使用div
作为容器包裹input
和span
, span
首先绝对定位到框内,伪装为placeholder
, 当input
状态为focus
提高span
的top
值,即可伪装成label
, 这里有两个问题是:
- 当用户输入了值的时候,
span
并不需要恢复为之前的top
值, 这里我们使用css
或者js
去判断都可以,js
就是拿到输入框的值,这里不多做赘述,css
有个比较巧妙的做法, 给input required
属性值设置为required
, 这样可以使用css:valid
伪类去判断input
是否有值。 - 由于span层级高于input,当点击span时无法触发input的聚焦,这个问题我们可以使用
pointer-events: none;
来解决。pointer-events
是一个CSS属性,用于控制元素是否响应用户的指针事件(例如鼠标点击、悬停、触摸等)。这个属性对于控制元素的可交互性和可点击性非常有用。
pointer-events
具有以下几个可能的值:
- auto(默认值):元素会按照其正常行为响应用户指针事件。这是默认行为。
- none:元素不会响应用户的指针事件,就好像它不存在一样。用户无法与它交互。
- visiblePainted:元素在绘制区域上响应指针事件,但不在其透明区域上响应。这使得元素的透明部分不会响应事件,而其他部分会。
- visibleFill:元素在其填充区域上响应指针事件,但不在边框区域上响应。
- visibleStroke:元素在其边框区域上响应指针事件,但不在填充区域上响应。
- painted:元素会在其绘制区域上响应指针事件,包括填充、边框和透明区域。
- fill:元素在其填充区域上响应指针事件,但不在边框区域上响应。
- stroke:元素在其边框区域上响应指针事件,但不在填充区域上响应。
pointer-events
属性非常有用,特别是在创建交互性复杂的用户界面时,可以通过它来控制元素的响应区域。例如,你可以使用它来创建自定义的点击区域,而不仅仅是元素的边界。它还可以与其他CSS属性和JavaScript事件处理程序结合使用,以创建特定的交互效果。
input border bottom 延伸展开效果
效果比较简单,input被聚焦的时候,一个紫色的边从中间延伸覆盖白色的底边即可。 在使用一个span
作为底部的边, 初始不可见, focus
时从中间向两边延伸直至充满, 唯一头痛的就是怎么从中间向两边延伸,这里可以使用transform
变形,首先使用transform: scaleX(0);
达到不可见的效果, 然后设置变形原点为中间transform-origin: center;
,这样效果就可以实现了
input 的动画实现效果
按钮的动画实现
关于按钮的动画很多,我们这里就实现一个移入的散花效果,移入时发散出一些星星,这里需要使用到动画去实现了,首先通过伪类创建一些周边元素,这里需要用到 background-image(radial-gradient)
background-image(radial-gradient)
background-image
属性用于设置元素的背景图像,而 radial-gradient
是一种 CSS 渐变类型,可用于创建径向渐变背景。这种径向渐变背景通常以一个中心点为基础,然后颜色渐变向外扩展,形成一种放射状的效果。
radial-gradient
的语法如下:
background-image: radial-gradient([shape] [size] at [position], color-stop1, color-stop2, ...);
[shape]
: 可选,指定渐变的形状。常用的值包括 "ellipse"(椭圆)和 "circle"(圆形)。[size]
: 可选,指定渐变的大小。可以是长度值或百分比值。at [position]
: 可选,指定渐变的中心点位置。color-stopX
: 渐变的颜色停止点,可以是颜色值、百分比值或长度值。
按钮移入动画效果实现
结尾
css 能实现的效果越来越多了,遇到有趣的效果,可以自己想想实现方式以及动手实现一下,思路毕竟是思路,具体实现起来说不定会遇到什么坑,逐步解决问题带来的成就感满足感还是很强的。
来源:juejin.cn/post/7294908459002331171
日历表格的制作,我竟然选择了这样子来实现...
前言
最近有个日历表格的需求,具体效果如下所示,鼠标经过时表格还有一个十字高亮的效果,在拿到这个设计图的时候,就在想应该用什么来实现,由于我所在的项目用的是vue3 + element
,所以我第一时间想到的就是饿了么里面的表格组件,但是经过一番调式之后,发现在饿了么表格的基础上想要调整我要的样式效果太复杂太麻烦了,所以我决定用原生的div循环来实现!
第一步 初步渲染表格
由于表格的表头是固定的,我们可以先渲染出来
<script setup lang="ts">
const tableFileds = Array.from({ length: 31 }, (_, i) =>
String(i + 1).padStart(2, '0')
)
</script>
<template>
<div class="table">
<div class="tabble-box">
<div class="table-node">
<div class="table-item table-header">
<div class="table-item-content diagonal-cell">
<span class="top-content">日</span>
<span class="bottom-content">月</span>
</div>
<div
class="table-item-content"
v-for="(item, _index) in tableFileds"
:key="item"
:style="{
background: '#EFF5FF'
}"
>
{{ item }}
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.table {
flex: 1;
display: flex;
flex-direction: column;
background-color: #fff;
padding: 1.8519vh 0.83vw 2.1296vh 1.09vw;
.tabble-box {
display: flex;
flex: 1;
box-sizing: border-box;
overflow: hidden;
color: #666666;
.table-node {
display: flex;
flex-direction: column;
flex: 1;
.table-header {
.table-item-content {
background-color: #eff5ff;
}
.diagonal-cell {
position: relative; /* 使伪元素相对于此单元格定位 */
padding-bottom: 8px; /* 为对角线下方留出空间以显示内容 */
width: 4.64vw !important;
&::before {
content: ''; /* 必须有内容才能显示伪元素 */
position: absolute;
top: 0;
left: 1px;
width: 5.16vw;
right: 0;
height: 1px; /* 对角线的高度 */
background-color: #e8e8e8; /* 对角线的颜色,可自定义 */
transform-origin: top left;
transform: rotate(30.5deg); /* 斜切角度,可微调 */
}
.top-content {
position: absolute;
top: 0.2778vh;
left: 2.67vw;
font-size: 0.83vw;
}
.bottom-content {
position: absolute;
top: 2.2222vh;
left: 0.83vw;
font-size: 0.83vw;
}
}
}
.table-item {
display: flex;
.table-item-content:first-child {
width: 4.64vw;
padding-top: 1.9444vh;
padding-bottom: 1.2037vh;
}
.table-item-content {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
// width: calc((100% - 9.53vw) / 15);
padding: 0.1vw;
text-align: center;
font-size: 0.78vw;
border-top: 0.05vw solid #eeeeee;
border-right: 0.05vw solid #eeeeee;
width: 2.48vw;
// flex-grow: 1
}
}
.table-header {
.table-item-content {
padding-top: 1.9444vh;
padding-bottom: 1.5741vh;
}
}
}
}
}
看一下页面效果:
表格的表头初步完成!
第二步 确认接口返回的数据格式
这是接口返回的格式数据 就例如第一个对象代表着3月9号有数据
{
"3": {
"9": 1
},
"4": {
"12": 2
},
"5": {
"11": 1,
"12": 2,
"21": 1
},
"6": {
"6": 5,
"8": 1,
"9": 2,
"10": 1,
"12": 2,
"17": 1,
"20": 1
},
"7": {
"1": 8,
"4": 1,
"7": 1,
"6": 1,
"13": 1,
"22": 1,
"25": 1,
"26": 1,
"27": 1,
"29": 6,
"30": 1
},
"8": {
"1": 1,
"2": 2,
"7": 1,
"20": 1,
"24": 1,
"27": 1,
"31": 1
},
"9": {
"15": 1,
"17": 9,
"21": 2
},
"10": {
"23": 1
}
}
接着我们需要对返回的数据做处理,由于表格的表头已经渲染出来,这意味着表格的每一列都有了,接下来我们就需要渲染表格的每一行与其对应就可以了.十二个月份我们需要十二行,同时每一行的第一个单元格表示的是月份,那我们可以定义一个月份的数据,然后再根据接口数据做处理,返回一个带有对应月份数据的数组.
代码如下:
const tableDataList = [
'一月',
'二月',
'三月',
'四月',
'五月',
'六月',
'七月',
'八月',
'九月',
'十月',
'十一月',
'十二月'
]
// 把接口数据转换为对应的月份数组对象
const parseData = (data: any) => {
const parsedData = new Array(12).fill({}) // 初始化一个包含12个空对象的数组
console.log('parsedData', parsedData)
for (let month = 1; month <= 12; month++) {
// 确保每次循环创建一个新的空对象
parsedData[month - 1] = {} // 从0开始索引
if (data[month]) {
Object.entries(data[month]).forEach(([day, value]) => {
parsedData[month - 1][parseInt(day)] = value
})
}
}
return parsedData
}
const tableData = ref<any[]>([])
onMounted(() => {
tableData.value = parseData(data)
console.log('tableData.value', tableData.value)
})
我们可以看一下控制台,此时的tableData的数据格式是怎么样的
接下来就可以开始渲染表格的内容了,给有数据的单元格做个高亮,同时固定31天,所以可以先遍历出每一行31个单元格出来
<div class="table-list">
<div
class="table-item"
v-for="(item, rowIndex) in tableData"
:key="item"
>
<div
class="table-item-content"
:key="item"
:style="{
background: '#EFF5FF'
}"
>
{{ tableDataList[rowIndex] }}
</div>
<div
class="table-item-content"
:data-col-index="index"
v-for="index in 31"
:key="rowIndex"
:style="{
background: item[index] ? '#6fa7ea' : ''
}"
>
<span v-if="item[index]" style="color: #fff">
{{ item[index] }}
</span>
<span v-else>0</span>
</div>
</div>
</div>
到这里基本就完成了,还差一个鼠标经过表格十字高亮的需求
我们可以给每个单元格加上鼠标的移入移出事件,移入事件函数传两个参数,一个就是行一个就是列,行可以从一开始的tableData那里拿到,列就是遍历31长度的当前项;这样子就可以拿到当前单元格的坐标,再封装一个辅助函数进行判断是否为当前单元格所在的行所在的列就可以了
高亮的时候记住判断的样式需要在之前的有数据高亮的样式的后面,这样子就不会被覆盖,可以保证有数据高亮的样式会一直存在,哪怕鼠标经过也不会被覆盖!
// 表格十字高亮
const highlightedRow = ref<any>()
const highlightedColumn = ref<any>()
const isCurrentCellHighlighted = (rowIndex: number, columnIndex: number) => {
return (
(highlightedRow.value !== null && highlightedRow.value === rowIndex) ||
(highlightedColumn.value !== null &&
highlightedColumn.value === columnIndex)
)
}
//鼠标移入
const onCellMouseOver = (rowIndex: any, columnIndex: any) => {
console.log('坐标', rowIndex, columnIndex)
highlightedRow.value = rowIndex
highlightedColumn.value = columnIndex
}
// 在鼠标移出事件(onCellMouseLeave)触发时,恢复所有单元格的原始背景色
const onCellMouseLeave = () => {
highlightedRow.value = null
highlightedColumn.value = null
}
<div
class="table-item-content"
:data-col-index="index"
v-for="index in 31"
:key="rowIndex"
:style="{
background: item[index]
? '#6fa7ea'
: isCurrentCellHighlighted(rowIndex + 1, index)
? '#EFF5FF'
: ''
}"
@mouseover="onCellMouseOver(rowIndex + 1, index)"
@mouseout="onCellMouseLeave"
>
<span v-if="item[index]" style="color: #fff">
{{ item[index] }}
</span>
<span v-else>0</span>
</div>
最终的效果就是:
以下就是完整的代码:
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const tableFileds = Array.from({ length: 31 }, (_, i) =>
String(i + 1).padStart(2, '0')
)
const tableDataList = [
'一月',
'二月',
'三月',
'四月',
'五月',
'六月',
'七月',
'八月',
'九月',
'十月',
'十一月',
'十二月'
]
const parseData = (data: any) => {
const parsedData = new Array(12).fill({}) // 初始化一个包含12个空对象的数组
console.log('parsedData', parsedData)
for (let month = 1; month <= 12; month++) {
// 确保每次循环创建一个新的空对象
parsedData[month - 1] = {} // 从0开始索引
if (data[month]) {
Object.entries(data[month]).forEach(([day, value]) => {
parsedData[month - 1][parseInt(day)] = value
})
}
}
return parsedData
}
const data = {
'3': {
'9': 1
},
'4': {
'12': 2
},
'5': {
'11': 1,
'12': 2,
'21': 1
},
'6': {
'6': 5,
'8': 1,
'9': 2,
'10': 1,
'12': 2,
'17': 1,
'20': 1
},
'7': {
'1': 8,
'4': 1,
'7': 1,
'6': 1,
'13': 1,
'22': 1,
'25': 1,
'26': 1,
'27': 1,
'29': 6,
'30': 1
},
'8': {
'1': 1,
'2': 2,
'7': 1,
'20': 1,
'24': 1,
'27': 1,
'31': 1
},
'9': {
'15': 1,
'17': 9,
'21': 2
},
'10': {
'23': 1
}
}
const tableData = ref<any[]>([])
// 表格十字高亮
const highlightedRow = ref<any>()
const highlightedColumn = ref<any>()
const isCurrentCellHighlighted = (rowIndex: number, columnIndex: number) => {
return (
(highlightedRow.value !== null && highlightedRow.value === rowIndex) ||
(highlightedColumn.value !== null &&
highlightedColumn.value === columnIndex)
)
}
const onCellMouseOver = (rowIndex: any, columnIndex: any) => {
console.log('坐标', rowIndex, columnIndex)
highlightedRow.value = rowIndex
highlightedColumn.value = columnIndex
}
// 在鼠标移出事件(onCellMouseLeave)触发时,恢复所有单元格的原始背景色
const onCellMouseLeave = () => {
highlightedRow.value = null
highlightedColumn.value = null
}
onMounted(() => {
tableData.value = parseData(data)
console.log('tableData.value', tableData.value)
})
</script>
<template>
<div class="table">
<div class="tabble-box">
<div class="table-node">
<div class="table-item table-header">
<div class="table-item-content diagonal-cell">
<span class="top-content">日</span>
<span class="bottom-content">月</span>
</div>
<div
class="table-item-content"
v-for="(item, _index) in tableFileds"
:key="item"
:style="{
background: '#EFF5FF'
}"
>
{{ item }}
</div>
</div>
<div class="table-list">
<div
class="table-item"
v-for="(item, rowIndex) in tableData"
:key="item"
>
<div
class="table-item-content"
:key="item"
:style="{
background: '#EFF5FF'
}"
>
{{ tableDataList[rowIndex] }}
</div>
<div
class="table-item-content"
:data-col-index="index"
v-for="index in 31"
:key="rowIndex"
:style="{
background: item[index]
? '#6fa7ea'
: isCurrentCellHighlighted(rowIndex + 1, index)
? '#EFF5FF'
: ''
}"
@mouseover="onCellMouseOver(rowIndex + 1, index)"
@mouseout="onCellMouseLeave"
>
<span v-if="item[index]" style="color: #fff">
{{ item[index] }}
</span>
<span v-else>0</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.table {
flex: 1;
display: flex;
flex-direction: column;
background-color: #fff;
padding: 1.8519vh 0.83vw 2.1296vh 1.09vw;
.tabble-box {
display: flex;
flex: 1;
box-sizing: border-box;
overflow: hidden;
color: #666666;
.table-node {
display: flex;
flex-direction: column;
flex: 1;
.table-header {
.table-item-content {
background-color: #eff5ff;
}
.diagonal-cell {
position: relative; /* 使伪元素相对于此单元格定位 */
padding-bottom: 8px; /* 为对角线下方留出空间以显示内容 */
width: 4.64vw !important;
&::before {
content: ''; /* 必须有内容才能显示伪元素 */
position: absolute;
top: 0;
left: 1px;
width: 5.16vw;
right: 0;
height: 1px; /* 对角线的高度 */
background-color: #e8e8e8; /* 对角线的颜色,可自定义 */
transform-origin: top left;
transform: rotate(30.5deg); /* 斜切角度,可微调 */
}
.top-content {
position: absolute;
top: 0.2778vh;
left: 2.67vw;
font-size: 0.83vw;
}
.bottom-content {
position: absolute;
top: 2.2222vh;
left: 0.83vw;
font-size: 0.83vw;
}
}
}
.table-item {
display: flex;
.table-item-content:first-child {
width: 4.64vw;
padding-top: 1.9444vh;
padding-bottom: 1.2037vh;
}
.table-item-content {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
// width: calc((100% - 9.53vw) / 15);
padding: 0.1vw;
text-align: center;
font-size: 0.78vw;
border-top: 0.05vw solid #eeeeee;
border-right: 0.05vw solid #eeeeee;
width: 2.48vw;
// flex-grow: 1
}
}
.table-header {
.table-item-content {
padding-top: 1.9444vh;
padding-bottom: 1.5741vh;
}
}
}
}
}
</style>
如果对你有帮助的话,欢迎点赞留言收藏🌹
来源:juejin.cn/post/7413311432971141160
贼好用!五分钟搭建一个美观且易用的导航页面!
大家好,我是 Java陈序员
。
今天,给大家介绍一个贼好用的导航网站搭建工具,只需通过几步操作,就能搭建出个性化导航网站!
关注微信公众号:【Java陈序员】,获取开源项目分享、AI副业分享、超200本经典计算机电子书籍等。
项目简介
Pintree
是一个开源项目,旨在将浏览器书签导出成导航网站。通过简单的几步操作,就可以将书签转换成一个美观且易用的导航页面。
Pintree
支持使用 GitHub Pages 进行部署,无需购买服务器、域名等资源!
因此,只要有一个 Github 账号,就能快速搭建一个导航网站。接下来我们就来部署实现下!
项目部署
步骤一:Fork 项目
1、访问 pintree
项目地址
https://github.com/Pintree-io/pintree
2、Fork
项目到自己的仓库中
步骤二:启用 Github Pages
1、打开 GitHub 账号中 Fork
的 pintree
项目
2、切换到仓库的 Settings
标签页,点击 Pages
,在 Source
下拉菜单中,选择 gh-pages
分支,然后点击 Save
3、几分钟后,静态导航网站将会在 https://yourusername.github.io/pintree
上可用
yourusername
是你的 Github 账号,如https://chenyl8848.github.io/pintree
.
这样,一个美观且易用的导航网站就搭建好了!
这时,好奇的小明就会问,要怎么个性化修改配置网站内容呢?别急,继续看步骤三。
步骤三:替换 JSON 文件自定义导航内容
1、pintree
渲染的导航网站内容是基于 json/pintree.json
文件里面的配置信息,我们可以通过修改 pintree.json
文件来自定义导航网站内容
2、打开 pintree.json
文件,并点击修改按钮进入编辑模式
3、在修改前,我们需要先了解下具体的语法规则,一个最小化的规则配置如下:
[
{
"//": "folder 表示是一个文件夹,可以配置子模块信息",
"type": "folder",
"//": "添加的时间信息",
"addDate": 1718526477999,
"//": "标题",
"title": "Java 陈序员",
"//": "子模块",
"children": [
{
"//": "link 表示是一个网站链接,最小化的配置单元",
"type": "link",
"//": "添加的时间信息",
"addDate": 1718526687700,
"//": "网站标题",
"title": "个人博客网站",
"//": "网站图标",
"icon": "https://chencoding.top:8090/_media/logo.png",
"//": "网站地址",
"url": "https://chencoding.top/"
},
"//": "依此类推",
{
"type": "folder",
"addDate": 1718526865665,
"title": "编程网站",
"children": [
{
"type": "link",
"addDate": 1718526707006,
"title": "CSDN",
"icon": "https://img-home.csdnimg.cn/images/20201124032511.png",
"url": "https://www.csdn.net/"
},
{
"type": "link",
"addDate": 1718526707006,
"title": "掘金",
"icon": "https://lf-web-assets.juejin.cn/obj/juejin-web/xitu_juejin_web/e08da34488b114bd4c665ba2fa520a31.svg",
"url": "https://juejin.cn/"
},
{
"type": "link",
"addDate": 1718526707006,
"title": "博客园",
"icon": "https://www.cnblogs.com/images/logo.svg?v=2SMrXdIvlZwVoB1akyXm38WIKuTHVqvGD0CweV-B6cY",
"url": "https://www.cnblogs.com/"
}
]
}
]
}
]
4、文件修改完后,点击 Commit changes
保存
5、过几分钟后,再访问 https://yourusername.github.io/pintree
可以看到,网站的内容变成了个性化的配置信息了。
由于浏览器有缓存的原因,如一开始没有变化,可以使用无痕模式访问或者用其他浏览器访问。
浏览器书签导航
通过前面的内容,我们知道 pintree
只需要一个 JSON
文件,就能搭建出一个导航网站。因此我们可以将浏览器中收藏的书签导出成 JSON
文件,再生成一个静态导航网站!
步骤一:导出浏览器书签
1、安装 Pintree Bookmarks Exporter
插件
安装地址:https://chromewebstore.google.com/detail/pintree-bookmarks-exporte/mjcglnkikjidokobpfdcdmcnfdicojce
2、使用插件导出浏览器书签,并保存 JSON
文件到本地
步骤二:替换 JSON 文件
将 JSON
文件替换到 Fork
项目的 json/pintree.json
文件中,保存成功后过几分钟再访问。
pintree
通过简单的配置,只需要几分钟就能快速搭建出一个导航网站,而且不用提供服务器、域名等资源,是一个非常优秀的开源项目!如果你想搭建一个静态导航网站可以去试试哈。
项目地址:https://github.com/Pintree-io/pintree
最后
推荐的开源项目已经收录到 GitHub
项目,欢迎 Star
:
https://github.com/chenyl8848/great-open-source-project
或者访问网站,进行在线浏览:
https://chencoding.top:8090/#/
大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!
来源:juejin.cn/post/7413187186132631589
flex 布局中更巧妙的布局方案!比 justify-content 和 align-items 好用多了!
在前端开发中,实现水平垂直居中一直是个热门话题。随着 CSS Flexbox 布局的普及,开发者们开始更多地使用
justify-content
和align-items
这两个属性来解决这个问题。
然而,还有一种更加简洁、灵活的方式——使用
margin: auto;
来实现居中以及更多实际场景下的特定效果。让我们一起回顾一下常见方式:justify-content
和align-items
,然后再来探讨一下使用:margin
的优势,以及如何在实际项目中使用它。
一、常见方式:justify-content
和 align-items
1.1 justify-content
(用于水平对齐)
justify-content
决定主轴(通常是水平方向)上子元素如何分配空间。常见的取值有:
flex-start
:元素排列在容器的起始位置(默认值)。flex-end
:元素排列在容器的末尾。center
:元素在容器内水平居中。space-between
:第一个元素与容器起点对齐,最后一个元素与容器终点对齐,其他元素之间均匀分布空间。space-around
:每个元素左右两侧都分配均等的空白区域(元素两边的空隙会有一半分布在两端)。space-evenly
:所有元素之间、以及与容器两端的空隙都相等。
1.2 align-items
(用于垂直对齐)
align-items
决定交叉轴(通常是垂直方向)上子元素如何对齐。常见的取值有:
stretch
:子元素在交叉轴上填满整个容器高度(默认值,前提是子元素没有设置具体的高度)。flex-start
:子元素在交叉轴的起始位置对齐。flex-end
:子元素在交叉轴的末端对齐。center
:子元素在交叉轴上垂直居中对齐。baseline
:子元素以其文本基线对齐。
1.3 flexbox
的常见用法
下面给出一些常见的 flexbox
的使用案例:
示例 : 公共样式
.container {
width: 800px;
height: 200px;
margin: 50px auto;
display: flex;
border: 1px solid black;
padding: 10px;
box-sizing: border-box;
}
.box {
width: 50px;
height: 50px;
background-color: lightblue;
text-align: center;
line-height: 50px;
border: 1px solid #333;
}
示例 1: 水平居中 + 垂直居中
<div class="container example-1">
<div class="box">1</div>
<div class="box">2</div>
<div class="box">3</div>
</div>
.example-1 {
justify-content: center;
align-items: center;
}
如上图所示,元素在水平和垂直方向都居中了。
示例 2: 水平居中 + 垂直靠顶
<div class="container example-2">
<div class="box">1</div>
<div class="box">2</div>
<div class="box">3</div>
</div>
.example-2 {
justify-content: center;
align-items: flex-start;
}
如上图所示,
justify-content: center;
使元素在水平方向居中;align-items: flex-start;
使元素垂直方向靠近顶部。
示例 3: 水平等间距 + 垂直居中
<div class="container example-3">
<div class="box">1</div>
<div class="box">2</div>
<div class="box">3</div>
</div>
.example-3 {
justify-content: space-between;
align-items: center;
}
如上图所示,
justify-content: space-between;
使元素在垂直方向居中;align-items: center;
使元素在水平方向两端对齐。
示例 4: 水平左对齐 + 垂直底部对齐
<div class="container example-4">
<div class="box">1</div>
<div class="box">2</div>
<div class="box">3</div>
</div>
.example-4 {
justify-content: flex-start;
align-items: flex-end;
}
如上图所示,
justify-content: flex-start;
使元素在水平方向居左;align-items: flex-end;
使元素在垂直方向靠底。
示例 5: 水平等间距 + 垂直拉伸
<div class="container example-5">
<div class="box">1</div>
<div class="box">2</div>
<div class="box">3</div>
</div>
.example-5 {
height: auto;
justify-content: space-evenly;
align-items: stretch;
}
如上图所示,
justify-content: space-evenly;
会使元素会在水平方向等间距;如果不设置元素的高度,使其自适应,align-items: stretch;
会使其垂直方向拉伸铺满。
1.4 思考与延伸
但你有没有想过,这些写法是否是最简洁的?能否实现我们日常开发的需求呢?有没有更优雅、更轻量的方案呢?
实际上在很多情况下这两个属性并不能够满足我们的开发需求。
比如我需要实现子元素部分集中的布局:
单纯依靠
justify-content
和align-items
,很难让几个子元素集中在一起。比如我们希望某些元素靠近并且与其他元素保持一定的间距就会比较麻烦了。
此时为了实现这种布局,通常需要结合
flex-grow
、margin
或者space-between
,甚至需要使用嵌套的flex
布局,增加了复杂性。
又或者是等宽子项的平均分布问题:
比如在导航菜单或展示商品卡片时,可能要求子项无论数量多少,都要从左向右均匀分布,并且保持等宽。
通过
justify-content: space-between
或space-around
可以部分解决这个问题,但是往往会出现无法保证元素从左向右,或者是无法等分的问题。
以及一些其他的情况,如垂直排列的固定间距、复杂的网格布局、混合布局等,justify-content
和 align-items
都无法简洁、优雅的解决问题。
二、更优雅的方式:margin
2.1 下使用 margin: auto
使元素居中
其实,Flexbox 布局下还有另一种更加简洁的方法使元素居中——直接使用 margin: auto;
。你可能会问,这怎么能居中呢?让我们先看一个例子:
<div class="box">
<div class="item"></div>
</div>
.box {
width: 200px;
height: 100px;
border: 2px solid #ccc;
display: flex; /* 启用 Flex 布局 */
margin: 100px auto;
}
.item {
background: red;
width: 50px;
height: 50px;
margin: auto; /* 自动分配外边距 */
}
在这个例子中,我们没有使用 justify-content
和 align-items
,仅通过设置 .item
元素的 margin: auto;
,就实现了水平和垂直居中。
它的工作原理是:在 Flexbox 布局中,
margin: auto;
会根据父容器的剩余空间自动调整元素的外边距,直到子元素居中。
在传统布局中,margin: auto;
主要用于水平居中对齐,不适用于垂直居中。因为普通流布局的垂直方向是由文档流控制的,不支持类似 Flexbox 中的自动调整行为。
.container {
width: 500px;
}
.element {
width: 200px;
margin: 0 auto; /* 左右外边距自动分配,实现水平居中 */
}
相比之下,在 Flexbox 布局中,margin: auto;
具有更多的灵活性,可以同时实现水平和垂直居中对齐。
它不仅可以处理水平居中,还可以在 Flexbox 布局下根据剩余空间自动调整外边距,实现完全的居中对齐。
2.2 实现更多实际开发中的布局
示例 1:实现子元素部分集中
在实际开发中,我们常遇到这样一种需求:将元素水平分布在容器内,其中某些元素需要靠近在一起,与其他元素保持一定的自适应距离。
在这种情况下使用
justify-content: space-between
是一种常见的办法,但这种方法也有一定的局限性:每个元素之间平等分配剩余空间,无法实现特定元素之间紧密靠拢。
代码实现:
<div class="container c2">
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
</div>
* {
margin: 0;
padding: 0;
}
.container {
width: 500px;
background: #eee;
margin: 50px auto;
padding: 10px;
display: flex;
}
.item {
width: 50px;
height: 50px;
border: 1px solid #333;
box-sizing: border-box;
}
.item:nth-child(odd) {
background: #046f4e;
}
.item:nth-child(even) {
background: #d53b3b;
}
.c2 .item:nth-child(2){
margin: 0 0 0 auto; /* 第二个 item 右对齐 */
}
.c2 .item:nth-child(4){
margin: 0 auto 0 0; /* 第四个 item 左对齐 */
}
在上述代码中,其实除掉一些基本样式的设置,实现了这个布局的关键代码就2行。
具体来说,
.c2 .item:nth-child(2)
的margin: 0 0 0 auto;
使得第二个.item
紧贴容器的右边缘,而.c2 .item:nth-child(4)
的margin: 0 auto 0 0;
使得第四个.item
紧贴容器的左边缘。这样就使第二个元素的左侧和第四个元素的右侧将会自适应边距间隔。
因此,我们可以使用
margin
巧妙地通过调整子元素的外边距,实现元素的部分集中和对齐布局。
示例 2:实现等宽子项的平均分布
在很多情况下,我们需要将商品卡片或其他内容等宽地分布在每一行中,使每个子项都具有相同的宽度并且平均分布,每一行都是从左到右。
这种布局通常用于网格展示或商品列表等场景,确保每个子项在视觉上统一且整齐。
在这种情况下直接使用
justify-content
和align-items
可能会出现以下问题:
- 使用
space-between
时如果最后一行的元素数量不足以填满整行,剩余的元素会分散到两侧,留出较大的空白区域,导致布局不整齐。
- 使用
space-around
时如果最后一行的元素数量不满,元素会在行中均匀分布,导致它们集中在中间,而不是靠左或对齐其他行。
大家在遇到这些情况时是不是就在考虑换用grid
布局了呢?先别急,我们其实直接通过margin
就可以直接实现的!
在这里我们可以使用 margin
的动态计算来实现等宽子项的平均分布。
代码实现:
<div class="container c3">
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
</div>
* {
margin: 0;
padding: 0;
}
.container {
width: 500px;
background: #eee;
margin: 50px auto;
padding: 10px;
display: flex;
flex-wrap: wrap;
}
.item {
width: 50px;
height: 50px;
border: 1px solid #333;
box-sizing: border-box;
}
.item:nth-child(odd) {
background: #046f4e;
}
.item:nth-child(even) {
background: #d53b3b;
}
.c3 .item {
--n: 5; /* 每行显示的子项数量 */
--item-width: 50px; /* 子项宽度 */
--space: calc(100% / var(--n) - var(--item-width)); /* 计算子项之间的间距 */
--m: calc(var(--space) / 2); /* 左右间距的一半 */
margin: 10px var(--m); /* 动态计算左右的间距 */
}
在在上述代码中,除掉基础的样式,实现了这个布局的关键代码仅仅5行。通过动态计算 margin,我们能够简单而有效地实现等宽子项的平均分布,使布局更加简洁明了。
三、总结
在前端开发中,实现各种页面布局一直是一个常见的需求。
传统的做法如使用 justify-content
和 align-items
属性已经被广泛采用,但这种方法有时可能显得不够简洁或灵活。
在适当的情况下直接使用 margin
进行布局是一种更优雅、简洁的替代方案,可以在 Flexbox 布局中有效地实现居中对齐和一些复杂的布局需求。掌握并运用这种方法,可以提高开发效率,并使布局更加优雅。快来玩起来吧!
来源:juejin.cn/post/7413222778855964706
告别繁琐的 try-catch:JavaScript 安全赋值运算符 (?= ) 来了!
你是否厌倦了代码中难以阅读和维护的冗长 try-catch
代码块?全新的 ECMAScript 安全赋值运算符 (?=
) 将彻底改变游戏规则!这一突破性的特性简化了错误处理,让你的代码更简洁、更高效。让我们深入了解 ?=
运算符如何彻底改变你的编码体验!
简化代码,轻松处理错误
告别嵌套的 try-catch
混乱
问题: 传统的 try-catch
代码块会导致代码深度嵌套,难以理解和调试。
解决方案: 使用 ?=
运算符,你可以将函数结果转换为一个元组,更优雅地处理错误。如果出现错误,你将得到 [error, null]
,如果一切正常,你将得到 [null, result]
。你的代码将会感谢你!
使用 ?=
之前:
async function fetchData() {
try {
const response = await fetch("https://api.example.com/data");
try {
const data = await response.json();
return data;
} catch (parseError) {
console.error('Failed to parse JSON:', parseError);
}
} catch (networkError) {
console.error('Network error:', networkError);
}
}
使用 ?=
之后:
async function fetchData() {
const [networkError, response] ?= await fetch("https://api.example.com/data");
if (networkError) return console.error('Network error:', networkError);
const [parseError, data] ?= await response.json();
if (parseError) return console.error('Failed to parse JSON:', parseError);
return data;
}
提升代码清晰度:保持代码线性,简洁易懂
问题: try-catch
代码块会打断代码流程,降低可读性。
解决方案: ?=
运算符使错误处理变得简单直观,保持代码线性,易于理解。
示例:
const [error, result] ?= await performAsyncTask();
if (error) handleError(error);
标准化错误处理:跨 API 保持一致性
问题: 不同的 API 通常需要不同的错误处理方法,导致代码不一致。
解决方案: ?=
运算符提供了一种统一的错误处理方式,使你的代码在各种 API 中保持一致。
提升安全性:每次都捕获所有错误
问题: 漏掉错误会导致 bug 和潜在的安全问题。
解决方案: ?=
运算符确保始终捕获错误,降低漏掉关键问题的风险。
Symbol.result
背后的奥秘
自定义错误处理变得简单
概述: 实现 Symbol.result
方法的对象可以使用 ?=
运算符定义自己的错误处理逻辑。
示例:
function customErrorHandler() {
return {
[Symbol.result]() {
return [new Error("Custom error message"), null];
},
};
}
const [error, result] ?= customErrorHandler();
轻松处理嵌套错误:平滑处理复杂场景
概述: ?=
运算符可以处理包含 Symbol.result
的嵌套对象,使复杂错误场景更容易管理。
示例:
const complexObj = {
[Symbol.result]() {
return [
null,
{ [Symbol.result]: () => [new Error("Nested error"), null] }
];
},
};
const [error, data] ?= complexObj;
与 Promise 和异步函数无缝集成
概述: ?=
运算符专门设计用于与 Promise 和 async/await
无缝协作,简化异步错误处理。
示例:
const [error, data] ?= await fetch("https://api.example.com/data");
使用 using
语句简化资源管理
概述: 将 ?=
运算符与 using
语句结合使用,可以更有效地管理资源。
示例:
await using [error, resource] ?= getResource();
优先处理错误:先处理错误,后处理数据
概述: 将错误放在 [error, data] ?=
结构的第一个位置,确保在处理数据之前先处理错误。
示例:
const [error, data] ?= someFunction();
让你的代码面向未来:简化填充
概述: 虽然无法直接填充 ?=
运算符,但你可以使用后处理器在旧环境中模拟其行为。
示例:
const [error, data] = someFunction[Symbol.result]();
汲取灵感:从 Go、Rust 和 Swift 中学习
概述: ?=
运算符借鉴了 Go、Rust 和 Swift 等语言的先进错误处理实践,这些语言以其强大的错误管理功能而闻名。
当前限制和未来方向
仍在发展: ?=
运算符仍在开发中。改进领域包括:
- 命名: 为实现
Symbol.result
的对象提供更好的术语。 - finally 代码块: 没有新的
finally
代码块语法,但传统用法仍然有效。
总结
安全赋值运算符 (?=
) 将通过使 JavaScript 错误处理更加直观和简洁来彻底改变 JavaScript 错误处理。随着该提案的不断发展,它将有望成为每个 JavaScript 开发人员工具箱中的必备工具。准备迎接更干净、更安全的代码吧!🚀
来源:juejin.cn/post/7413284830945493001
两个月写完的校园社交小程序,这是篇uniapp踩坑记录
人员搭配和两个舍友,一前两后,从开发到最终上线,耗时两个月,此篇文章记录一下整个开发流程中踩的坑......
前置准备:
- 资质核验。需要企业认证才能发布社交类的小程序。需要上传营业执照、法人信息等
- 类目选择。小程序中有个类目选择,选择自己小程序涉及的类目,但这里就比较抽象了(下文讲)
- 微信认证。300元,腾子的吃相很难看,但奈何寄人篱下
- 小程序备案。在前面流程完成之后才能进行小程序的备案
审核流程
整个审核流程给我的感觉就是跟便秘一样,交一次吐一句,交一次吐一句,然后打回来重改 这里记录一下几个需要注意的点,如果你和我一样也是做UGC类的小程序的话
- 微信审核。所有但凡涉及言论的,能展示出你的思想的、个性的,对不起,请通通接入微信审核。包括但不限于用户昵称、头像;发布的帖子内容、图片
文字审核算比较快,图片审核就有点慢,且图片审核不是很精准。为了避免等待检测而让用户在界面等待过久的问题,后面无奈自己搭了个后台管理。以致于流程就变成了:用户发点什么东西 -> 后端调用微信检测接口 -> 检测完甩到后台 -> 后台管理员做个二次审核 -> 通过后在小程序获取
- 不能使用微信icon图标。之前做微信快捷登录,想着搞个微信的icon图标好看点,结果给我审核失败的原因就是不能使用微信图标
- 请预留足够的时间和审核慢慢耗。以上讲到的还只是真正发布之前踩的坑,等所有条件都符合后,算可以真正的发布,然后提示你首次提交一般需要7个自然日,然后就真的是7*24小时
5. 第一次代码真正预上线之后,此后更新代码再发布新的包,一半就只需要1~2天
开发过程
- 文件上传。以往网页开发中涉及文件上传的业务都是
new FormData
,然后再append
必要的字段。但是,小程序中使用FormData
会报错,所以,得使用uniapp
自带的uni.uoloadFile
- 消息提示。在帖子发布成功之后等场景都会涉及到消息提示,一般涉及消息提示、页面回退,执行顺序请按
navigateBack
再uni.showToast
,如果二者调用顺序反了的话,只会页面回退,不会显示提示内容 - 分享功能。小程序的分享功能需要在
onShareAppMessage
(分享至好友)或者onShareTimeline
(分享至朋友圈)调用。这两个是和onLoad
同级的,如果你的技术选型也是Vue3的话,可以从@dcloudio/uni-app
中导入 - 消息订阅。小程序涉及到一个需求:当用户发布帖子,微信审核+后台审核都通过之后会通知用户,此时就需要进行消息订阅
先在微信公众号平台开通消息订阅模板,拿到模板ID,前端中再调用 uni.requestSubscribeMessage 传入拿到的模板ID就可以实现消息订阅
- webSocket。小程序中的树洞评论功能我们选用的是
webSocket
,小程序中我们没有使用三方库,调用的是uniapp的uni.connectSocket
,创建一个webSocket实例,然后处理对应的回调。由于用户一段时间内如果不发送消息,服务端也就没东西推送过来,webSocket自己会断掉。所以我们引入了心跳机制和断线重连机制。但是在做短信重连的时候就发现一个问题:断线重连之后
确实是会创建新的实例,心跳包也正常推送给服务端,但是就是接收不到服务端反推回来的东西,后面经过排查,是webSocket实例的onMessage
事件应当写在onOpen
中,而不是独立写到外面
独立写到外面进行处理就会出现:断线重连之后死活接不到最新的实例返回的消息
这里再次吐槽微信的内容审核机制。原先选用webSocket的原因就是看中了它的实时推送,但是接入了内容审核就变得很抽象,时而秒通过,时而得等一下,这也搞得失去了选用webSocket的意义
- 请求池。小程序中使用到了tabs组件,当tab在切换过程中,比如tabA切换至tabB,由于需要请求数据,所以页面会有短暂的白屏时间,这里采用的是请求池,在获取第一个tab的列表数据的时候,由请求池顺便把之后的tab的内容也请求回来,此后在进行tab切换时就可以避免白屏,优化用户体验
- 响应式布局。小程序在个人页模仿了小红书个人页的实现形式,也就是随着页面的滚动,页面的布局(主要是用户头像缩小并由透明度为0逐渐变为1)发生变化。一开始采用的是监听页面
scroll
事件。但是,scroll
涉及大量的计算;后面采用Intersection Observer
。但是注意,uniapp不能直接使用这个API,得调用uni.createIntersectionObserver
,二者语法差不多 - 防抖节流的使用。页面滚动加载下一页使用防抖,按钮点击进行节流,常规操作。
大概暂时先能想到这么多,后面有想到再接着补充......
后记
其实校园小程序这个题材市面上早已烂大街,说不上有任何的创新。此前决定搞这个的原因有以下几点:
- 很多东西只停留于理论,没有实操。就像webSocket的心跳和断线重连,博客教你怎样怎样,始终不如你自己去亲手实现一下,这个算主要驱动原因
- 这个程序刚好拿去参加学校的比赛,拿奖有钱doge
- ......
然后整一个项目跟下来吧,给我的感觉就是:技术重要吗?重要;但也不太重要,技术的重要性就跟我这句废话一样。因为一个东西,推广不起来,没人用,你就没动力去更新、去维护。当然,有没有人用对我们此次的开发来说还不算是必选项。
大家觉得校园社交类的程序还能集成什么功能,也欢迎提出您的宝贵意见
来源:juejin.cn/post/7412665439501844490
利用CSS延迟动画,打造令人惊艳的复杂动画效果!
动画在前端开发中是经常遇到的场景之一,加入动画后页面可以极大的提升用户体验。
绝大多数简单的动画场景可以直接通过CSS实现,对于一些特殊场景的动画可能会使用到JS计算实现,通过本文的学习,可以让你在一些看似需要使用JS实现的动画场景,使用纯CSS一样可以实现,并且更方便快捷。
先看一个简单的例子:一个方块的位置随着滑条滑动的位置改变
这个场景实现起来很简单,滑条值改变后,使用JS计算方块应该移动的距离,然后将方块定位到指定位置即可。代码如下:
.box {
height: 50px;
width: 50px;
background-color: aquamarine;
}
<div class="box">div>
<input type="range" min="0" max="1" step="0.01"/>
现在稍微增加一些动画效果:
- 方块在中间位置时缩放为原来的一半大小
- 方块在中间位置时变成球形
- 方块从红色变为绿色
对于大小和圆角,同样可以使用简单的JS进行计算实现,但是对于颜色变化,使用JS计算将会是一个非常复杂的过程。
先抛开动画跟随滑条运动这个要求,如果使用CSS实现上面从0-1的动画过程是一个很简单的事:
.box {
height: 50px;
width: 50px;
background-color: aquamarine;
transform: translateX(0);
animation: run 1s linear forwards;
}
@keyframes run {
0% {
transform: translateX(0) scale(1);
border-radius: 0%;
background: red;
}
50% {
transform: translateX(100px) scale(.5);
border-radius: 50%;
}
100% {
transform: translateX(200px) scale(1);
border-radius: 0%;
background: green;
}
}
利用CSS动画帮我们可以很轻松的计算出每个时间点时的状态,现在的问题就变成如何让动画停留在指定的时间点,这就需要使用到动画的两个属性:
annimation-play-state:设置动画是运行还是暂停,有两个属性值runing、paused
annimation-delay:设置动画开始时间的偏移量,如果是正值,则动画会延迟开始;如果是负值(-d),动画会立即开始,开始位置在动画(d)s时所处的位置。
有了这两个属性,现在将上面的动画停留在50%的位置
假设整个动画过程需要1s,50%的位置则需要将延迟值设置为-0.5s,这样动画就会停留在0.5s的位置。
.box {
height: 50px;
width: 50px;
background-color: aquamarine;
transform: translateX(0);
animation: run 1s -0.5s linear forwards infinite paused;
}
接下来只需要将滑条的值与动画延迟的值关联起来即可,这里可以通过CSS变量来实现:
.box {
--duration: -0.5s; // 定义延迟变量
height: 50px;
width: 50px;
background-color: aquamarine;
transform: translateX(0);
animation: run 1s var(--duration) linear forwards infinite paused;
}
@keyframes run {
0% {
transform: translateX(0) scale(1);
border-radius: 0%;
background: red;
}
50% {
transform: translateX(100px) scale(.5);
border-radius: 50%;
}
100% {
transform: translateX(200px) scale(1);
border-radius: 0%;
background: green;
}
}
应用场景
利用CSS延迟动画可以轻松实现很多交互场景,例如:跟随鼠标滚动界面发生反馈动画、根据当天时间界面从日出到日落、根据不同分值出现不同表情变化等等。
来源:juejin.cn/post/7363094767557378099
实现 height: auto 的高度过渡动画
对于一个 height
设置为 auto
的元素,当它的高度发生了不由样式引起的改变时,并不会触发 transition
过渡动画。
容器元素的高度往往是由其内容决定的,如果一个容器元素的内容高度突然发生了改变,而无法进行过渡动画,有时会显得比较生硬,比如下面的登录框组件:
那么这种非样式引起的变化如何实现过渡效果呢?可以借助 FLIP
技术。
FLIP
是什么
FLIP
是 First
,Last
,Invert
,Play
的缩写,其含义是:
First
- 获取元素变化之前的状态Last
- 获取元素变化后的最终状态Invert
- 将元素从Last
状态反转到First
状态,比如通过添加transform
属性,使得元素变化后,看起来仍像是处于First
状态一样Play
- 此时添加过渡动画,再移除Invert
效果(取消transform
),动画就会开始生效,使得元素看起来从First
过渡到了Last
需要用到的 Web API
要实现一个基本的 FLIP
过渡动画,需要使用到以下一些 Web API
:
- Resize Observer API - Web API 接口参考 | MDN (mozilla.org)
- Element.getBoundingClientRect() - Web API 接口参考 | MDN (mozilla.org)
- Window:requestAnimationFrame() 方法 - Web API 接口参考 | MDN (mozilla.org)
基本过渡效果实现
使用以上 API
,就可以初步实现一个监听元素尺寸变化,并对其应用 FLIP
动画的函数 useBoxTransition
,代码如下:
/**
*
* @param {HTMLElement} el 要实现过渡的元素 DOM
* @param {number} duration 过渡动画持续时间,单位 ms
* @returns 返回一个函数,调用后取消对过渡元素尺寸变化的监听
*/
export default function useBoxTransition(el: HTMLElement, duration: number) {
// boxSize 用于记录元素处于 First 状态时的尺寸大小
let boxSize: {
width: number
height: number
} | null = null
const elStyle = el.style // el 的 CSSStyleDeclaration 对象
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
// 被观察的 box 发生尺寸变化时要进行的操作
// 获取当前回调调用时,box 的宽高
const borderBoxSize = entry.borderBoxSize[0]
const writtingMode = elStyle.getPropertyValue('writing-mode')
const isHorizontal =
writtingMode === 'vertical-rl' ||
writtingMode === 'vertical-lr' ||
writtingMode === 'sideways-rl' ||
writtingMode === 'sideways-lr'
? false
: true
const width = isHorizontal
? borderBoxSize.inlineSize
: borderBoxSize.blockSize
const height = isHorizontal
? borderBoxSize.blockSize
: borderBoxSize.inlineSize
// 当 box 尺寸发生变化时,使用 FLIP 动画技术产生过渡动画,使用过渡效果的是 scale 形变
// 根据 First 和 Last 计算出 Inverse 所需的 scale 大小
// box 首次被观察时会触发一次回调,此时 boxSize 为 null,scale 应为 1
const scaleX = boxSize ? boxSize.width / width : 1
const scaleY = boxSize ? boxSize.height / height : 1
// 尺寸发生变化的瞬间,要使用 scale 变形将其保持变化前的尺寸,要先将 transition 去除
elStyle.setProperty('transition', 'none')
elStyle.setProperty('transform', `scale(${scaleX}, ${scaleY})`)
// 将 scale 移除,并应用 transition 以实现过渡效果
setTimeout(() => {
elStyle.setProperty('transform', 'none')
elStyle.setProperty('transition', `transform ${duration}ms`)
})
// 记录变化后的 boxSize
boxSize = { width, height }
}
})
resizeObserver.observe(el)
const cancelBoxTransition = () => {
resizeObserver.unobserve(el)
}
return cancelBoxTransition
}
效果如下所示:
效果改进
目前已经实现了初步的过渡效果,但在一些场景下会有些瑕疵:
- 如果在过渡动画完成前,元素有了新的状态变化,则动画被打断,无法平滑过渡到新的状态
FLIP
动画过渡过程中,实际上发生变化的是transform
属性,并不影响元素在文档流中占据的位置,如果需要该元素影响周围的元素,那么周围元素无法实现平滑过渡
如下所示:
对于动画打断问题的优化思路
- 使用
Window.requestAnimationFrame()
方法在每一帧中获取元素的尺寸 - 这样做可以实时地获取到元素的尺寸,实时地更新
First
状态
对于元素在文档流中问题的优化思路
- 应用过渡的元素外可以套一个
.outer
元素,其定位为relative
,过渡元素的定位为absolute
,且居中于.outer
元素 - 当过渡元素尺寸发生变化时,通过
resizeObserver
获取其最终的尺寸,将其宽高设置给.outer
元素(实例代码运行于Vue 3
中,因此使用的是Vue
提供的ref api
将其宽高暴露出来,可以方便地监听其变化;如果在React
中则可以将设置.outer
元素宽高的方法作为参数传入useBoxTransition
中,在需要的时候调用),并给.outer
元素设置宽高的过渡效果,使其在文档流中所占的位置与过渡元素的尺寸同步 - 但是也要注意,这样做可能会引起浏览器高频率的重排,在复杂布局中慎用!
改进后的useBoxTransition
函数如下:
import throttle from 'lodash/throttle'
import { ref } from 'vue'
type BoxSize = {
width: number
height: number
}
type BoxSizeRef = globalThis.Ref<BoxSize>
/**
*
* @param {HTMLElement} el 要实现过渡的元素 DOM
* @param {number} duration 过渡动画持续时间,单位 ms
* @param {string} mode 过渡动画缓动速率,同 CSS transition-timing-function 可选值
* @returns 返回一个有两个项的元组:第一项为 keyBoxSizeRef,当元素大小发生变化时会将变化后的目标尺寸发送给 keyBoxSizeRef.value;第二项为一个函数,调用后取消对过渡元素尺寸变化的监听
*/
export default function useBoxTransition(
el: HTMLElement,
duration: number,
mode?: string
) {
let boxSizeList: BoxSize[] = [] // boxSizeList 表示对 box 的尺寸的记录数组;为什么是使用列表:因为当 box 尺寸变化的一瞬间,box 的 transform 效果无法及时移除,此时 box 的尺寸可能是非预期的,因此使用列表来记录 box 的尺寸,在必要的时候尽可能地将非预期的尺寸移除
const keyBoxSizeRef: BoxSizeRef = ref({ width: 0, height: 0 }) // keyBoxSizeRef 是暴露出去的 box 的实时目标尺寸
let isObserved = false // box 是否已经开始被观察
let frameId = 0 // 当前 animationFrame 的 id
let isTransforming = false // 当前是否处于变形过渡中
const elStyle = el.style // el 的 CSSStyleDeclaration 对象
const elComputedStyle = getComputedStyle(el) // el 的只读动态 CSSStyleDeclaration 对象
// 获取当前 boxSize 的函数
function getBoxSize() {
const rect = el.getBoundingClientRect() // el 的 DOMRect 对象
return { width: rect.width, height: rect.height }
}
// 更新 boxSizeList
function updateBoxsize(boxSize: BoxSize) {
boxSizeList.push(boxSize)
// 只保留前最新的 4 条记录
boxSizeList = boxSizeList.slice(-4)
}
// 定义 animationFrame 的回调函数,使得当 box 变形时可以更新 boxSize 记录
const animationFrameCallback = throttle(() => {
// 为避免使用了函数节流后,导致回调函数延迟触发使得 cancelAnimationFrame 失败,因此使用 isTransforming 变量控制回调函数中的操作是否执行
if (isTransforming) {
const boxSize = getBoxSize()
updateBoxsize(boxSize)
frameId = requestAnimationFrame(animationFrameCallback)
}
}, 20)
// 过渡事件的回调函数,在过渡过程中实时更新 boxSize
function onTransitionStart(e: Event) {
if (e.target !== el) return
// 变形中断的一瞬间,boxSize 的尺寸可能是非预期的,因此在变形开始时,将最新的 3 个可能是非预期的 boxSize 移除
if (boxSizeList.length > 1) {
boxSizeList = boxSizeList.slice(0,1)
}
isTransforming = true
frameId = requestAnimationFrame(animationFrameCallback)
// console.log('过渡开始')
}
function onTransitionCancel(e: Event) {
if (e.target !== el) return
isTransforming = false
cancelAnimationFrame(frameId)
// console.log('过渡中断')
}
function onTransitionEnd(e: Event) {
if (e.target !== el) return
isTransforming = false
cancelAnimationFrame(frameId)
// console.log('过渡完成')
}
el.addEventListener('transitionstart', onTransitionStart)
el.addEventListener('transitioncancel', onTransitionCancel)
el.addEventListener('transitionend', onTransitionEnd)
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
// 被观察的 box 发生尺寸变化时要进行的操作
// 获取当前回调调用时,box 的宽高
const borderBoxSize = entry.borderBoxSize[0]
const writtingMode = elStyle.getPropertyValue('writing-mode')
const isHorizontal =
writtingMode === 'vertical-rl' ||
writtingMode === 'vertical-lr' ||
writtingMode === 'sideways-rl' ||
writtingMode === 'sideways-lr'
? false
: true
const width = isHorizontal
? borderBoxSize.inlineSize
: borderBoxSize.blockSize
const height = isHorizontal
? borderBoxSize.blockSize
: borderBoxSize.inlineSize
const boxSize = { width, height }
// 当 box 尺寸发生变化时以及初次触发回调时,将此刻 box 的目标尺寸暴露给 keyBoxSizeRef
keyBoxSizeRef.value = boxSize
// box 首次被观察时会触发一次回调,此时不需要应用过渡,只需将当前尺寸记录到 boxSizeList 中
if (!isObserved) {
isObserved = true
boxSizeList.push(boxSize)
return
}
// 当 box 尺寸发生变化时,使用 FLIP 动画技术产生过渡动画,使用过渡效果的是 scale 形变
// 根据 First 和 Last 计算出 Inverse 所需的 scale 大小
const scaleX = boxSizeList[0].width / width
const scaleY = boxSizeList[0].height / height
// 尺寸发生变化的瞬间,要使用 scale 变形将其保持变化前的尺寸,要先将 transition 去除
elStyle.setProperty('transition', 'none')
const originalTransform =
elStyle.transform || elComputedStyle.getPropertyValue('--transform')
elStyle.setProperty(
'transform',
`${originalTransform} scale(${scaleX}, ${scaleY})`
)
// 将 scale 移除,并应用 transition 以实现过渡效果
setTimeout(() => {
elStyle.setProperty('transform', originalTransform)
elStyle.setProperty('transition', `transform ${duration}ms ${mode}`)
})
}
})
resizeObserver.observe(el)
const cancelBoxTransition = () => {
resizeObserver.unobserve(el)
cancelAnimationFrame(frameId)
}
const result: [BoxSizeRef, () => void] = [keyBoxSizeRef, cancelBoxTransition]
return result
}
相应的 vue
组件代码如下:
<template>
<div class="outer" ref="outerRef">
<div class="card-container" ref="cardRef">
<div class="card-content">
<slot></slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import useBoxTransition from '@/utils/useBoxTransition'
type Props = {
transition?: boolean
duration?: number
mode?: string
}
const props = defineProps<Props>()
const { transition, duration = 200, mode = 'ease' } = props
const cardRef = ref<HTMLElement | null>(null)
const outerRef = ref<HTMLElement | null>(null)
let cancelBoxTransition = () => {} // 取消 boxTransition 效果
onMounted(() => {
if (cardRef.value) {
const cardEl = cardRef.value as HTMLElement
const outerEl = outerRef.value as HTMLElement
if (transition) {
const boxTransition = useBoxTransition(cardEl, duration, mode)
const keyBoxSizeRef = boxTransition[0]
cancelBoxTransition = boxTransition[1]
outerEl.style.setProperty(
'--transition',
`weight ${duration}ms ${mode}, height ${duration}ms ${mode}`
)
watch(keyBoxSizeRef, () => {
outerEl.style.setProperty('--height', keyBoxSizeRef.value.height + 'px')
outerEl.style.setProperty('--width', keyBoxSizeRef.value.width + 'px')
})
}
}
})
onUnmounted(() => {
cancelBoxTransition()
})
</script>
<style scoped lang="less">
.outer {
position: relative;
&::before {
content: '';
display: block;
width: var(--width);
height: var(--height);
transition: var(--transition);
}
.card-container {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
--transform: translate(-50%, -50%);
transform: var(--transform);
box-sizing: border-box;
background-color: rgba(255, 255, 255, 0.7);
border-radius: var(--border-radius, 20px);
overflow: hidden;
backdrop-filter: blur(10px);
padding: 30px;
box-shadow: var(--box-shadow, 0 0 15px 0 rgba(0, 0, 0, 0.3));
}
}
</style>
优化后的效果如下:
注意点
过渡元素本身的 transform
样式属性
useBoxTransition
函数中会覆盖应用过渡的元素的 transform
属性,如果需要额外为元素设置其它的 transform
效果,需要使用 css
变量 --transform
设置,或使用内联样式设置。
这是因为,useBoxTransition
函数中对另外设置的 transform
效果和过渡所需的 transform
效果做了合并。
然而通过 getComputedStyle(Element)
读取到的 transform
的属性值总是会被转化为 matrix()
的形式,使得 transform
属性值无法正常合并;而 CSS
变量和使用 Element.style
获取到的内联样式中 transform
的值是原始的,可以正常合并。
如何选择获取元素宽高的方式
Element.getBoundingClientRect()
获取到的 DOMRect
的宽高包含了 transform
变化,而 Element.offsetWidth
/ Element.offsetHeight
以及 ResizeObserverEntry
对象获取到的宽高是元素本身的占位大小。
因此在需要获取 transition
过程中,包含 transform
效果的元素大小时,使用 Element.getBoundingClientRect()
,否则可以使用 Element.offsetWidth
/ Element.offsetHeight
或 ResizeObserverEntry
对象。
获取元素高度时遇到的 bug
测试案例中使用了 elementPlus
UI
库的 el-tabs
组件,当元素包含该组件时,无论是使用 Element.getBoundingClientRect()
、Element.offsetHeight
还是使用 Element.Style
、getComputedStyle(Element)
获取到的元素高度均缺少了 40px
;而使用 ResizeObserverEntry
对象获取到的高度则是正确的,但是它无法脱离 ResizeObserver API
独立使用。
经过测试验证,缺少的 40px
高度来自于 el-tabs
组件中 .el-tabs__header
元素的高度,也就是说,在获取元素高度时,将 .el-tabs__header
元素的高度忽略了。
测试后找出的解决方法是,手动将 .el-tabs__header
元素样式(注意不要写在带 scoped
属性的 style
标签中,会被判定为局部样式而无法生效)的 height
属性指定为 calc(var(--el-tabs-header-height) - 1px)
,即可恢复正常的高度计算。
至于为什么这样会造成高度计算错误,希望有大神能解惑。
来源:juejin.cn/post/7307894647655759911
精准倒计时逻辑:揭秘前端倒计时逻辑的实现策略
在业务运营中,倒计时功能是常见的需求,尤其是在限时秒杀等促销活动中。为了确保时间的精确性和一致性,推荐使用服务器时间作为倒计时的基准。那么,如何在前端实现一个既准确又用户友好的倒计时组件的计时逻辑呢?
传统计时器实现
传统计时器实现倒计时的核心原理很简单,它使用了 setInterval
或 setTimeout
的对计时信息进行更新。类似于如下代码:
import React, { useState, useEffect } from 'react';
const CountdownTimer: React.FC<{ duration: number }> = ({ duration }) => {
const [secondsRemaining, setSecondsRemaining] = useState(duration);
useEffect(() => {
const intervalId = setInterval(() => {
if (secondsRemaining > 0) {
setSecondsRemaining(secondsRemaining - 1);
} else {
clearInterval(intervalId);
}
}, 1000);
// 清理计时器
return () => clearInterval(intervalId);
}, [secondsRemaining]);
return (
<div>
倒计时: {secondsRemaining} 秒
</div>
);
};
export default CountdownTimer;
上述代码实现很好地实现了倒计时逻辑,但是,还是存在一些问题。我们先来讨论一下浏览器事件循环关于延时队列的优先级。我们知道,为了有效地管理任务和事件,事件循环使用了一个队列系统。事件循环主要包含如下两个队列:
- 宏任务队列(Macro Task Queue) :包括如
setTimeout
、setInterval
、I/O、UI 事件等。 - 微任务队列(Micro Task Queue) :包括Promise回调、
MutationObserver
等。
在事件循环中,当一个宏任务执行完毕后,JavaScript 引擎会先清空所有微任务队列中的所有任务,然后再去检查是否需要执行下一个宏任务。这意味着微任务的优先级高于宏任务。
setTimeout
或 setInterval
任务会在指定的延时后被加入到宏任务队列的末尾。当当前的宏任务执行完毕后,如果微任务队列不为空,JavaScript 引擎会先执行完所有微任务,然后才会执行下一个宏任务,也就是 setTimeout
或 setInterval
中的回调函数。因此,setTimeout
或 setInterval
的优先级是相对较低的,因为它们必须等待当前宏任务执行完毕以及所有微任务执行完毕后才能执行。
这种机制可能导致一个问题:如果页面上的其他微任务执行时间较长,倒计时显示可能会出现“跳秒”现象。例如,倒计时可能从 60 秒直接跳到 58 秒,而不是平滑地递减。
requestAnimationFrame 实现
针对上述“跳秒”问题,我们可以改用 requestAnimationFrame
去进行时间的更新逻辑执行。我们将上述代码修改为如下代码:
import React, { useState, useEffect } from 'react';
const CountdownTimer: React.FC<{ duration: number }> = ({ duration }) => {
const [secondsRemaining, setSecondsRemaining] = useState(duration);
useEffect(() => {
let animationFrameId: number;
const updateTimer = () => {
if (secondsRemaining > 0) {
setSecondsRemaining(prev => prev - 1);
animationFrameId = requestAnimationFrame(updateTimer);
} else {
cancelAnimationFrame(animationFrameId);
}
};
// 启动动画帧
animationFrameId = requestAnimationFrame(updateTimer);
// 清理动画帧
return () => cancelAnimationFrame(animationFrameId);
}, [secondsRemaining]);
return (
<div>
倒计时: {secondsRemaining} 秒
</div>
);
};
export default CountdownTimer;
在编写倒计时功能的代码时,我们应当确保在每次更新倒计时秒数后重新启动动画帧。这样做可以避免在动画帧完成后,倒计时逻辑停止更新,导致倒计时在减少一秒后不再继续。同时,为了确保资源的有效管理,我们还需要提供一个函数来清理动画帧,这样当组件不再需要时,可以停止执行动画帧,避免不必要的性能消耗。通过这些措施,我们可以保证倒计时功能的准确性和组件的高效卸载。
优势
要深入理解 requestAnimationFrame
在实现倒计时中的优势,我们首先需要探讨一个问题:在 requestAnimationFrame
中直接修改 DOM 是否合适?requestAnimationFrame
是一个专为动画效果设计的 Web API,它通过在浏览器的下一次重绘之前调用回调函数,帮助我们创建更加流畅且高效的动画。与传统的定时器方法(如 setTimeout
和 setInterval
)相比,requestAnimationFrame
提供了更优的性能和更少的资源消耗。
在 requestAnimationFrame
中修改 DOM 是合适的,尤其是当涉及到动画和视觉更新时。这是因为 requestAnimationFrame
的设计初衷就是为了优化动画性能,确保动画的流畅性和效率。总结来说,requestAnimationFrame
相较于传统的计时器方法,具有以下显著优势:
- 性能优化:通过在浏览器的下一次重绘前调用回调,确保动画的流畅性。
- 节能高效:当浏览器标签页不处于活跃状态时,
requestAnimationFrame
会自动暂停,从而减少 CPU 的使用,延长设备电池寿命。 - 同步刷新:能够与浏览器的刷新率同步,有效避免动画中的跳帧现象。
因此,requestAnimationFrame
不仅适用于复杂的动画场景,也非常适合实现需要精确时间控制的倒计时功能,提供了一种更加高效和节能的解决方案。
劣势
尽管 requestAnimationFrame
在动画制作方面表现出色,但在实现倒计时功能时,它也存在一些局限性:
- 精确度问题:
requestAnimationFrame
并不适用于需要严格时间控制的场景。因为它的调用时机依赖于浏览器的重绘周期,这可能导致时间间隔的不稳定性。 - 管理复杂性:使用
requestAnimationFrame
需要开发者手动管理动画状态和进行资源清理,这增加了实现的复杂度。
正因如此,许多现代前端框架和库,如 ahook 等,在选择实现倒计时功能时,倾向于采用传统的定时器(如 setTimeout
或 setInterval
),而非 requestAnimationFrame
。这些传统方法虽然可能不如 requestAnimationFrame
在动画性能上优化,但它们提供了更稳定和可预测的时间控制,这对于倒计时这类功能来说至关重要。
总结
实现一个倒计时组件的计时逻辑,我们有如下的一些建议:
- 动画与浏览器同步:对于涉及动画或需要与浏览器重绘周期同步的任务,
requestAnimationFrame
是一个理想的选择。它能够确保动画的流畅性和性能优化。 - 体验优化:为了进一步提升用户体验,可以利用
performance.now()
来提高时间控制的精度。这个高精度的时间戳 API 可以帮助你更准确地计算时间间隔,从而优化倒计时的显示效果。 - 时间控制与简易任务:如果你的应用场景需要精确的时间控制或涉及到简单的定时任务,传统的
setTimeout
和setInterval
方法可能更加方便和直观。它们提供了稳定的时间间隔,易于理解和实现。
总结来说,选择最合适的技术方案取决于你的具体需求。无论是 requestAnimationFrame
还是传统的定时器方法,都有其适用场景和优势。关键在于根据项目需求,做出明智的选择,以实现最佳的用户体验。
来源:juejin.cn/post/7412951456549175306
多人开发小程序设置体验版的痛点
抛出痛点
在分配任务时,我们将需求分为三个分支任务,分别由前端A、B、C负责:
- 前端A: HCC-111-实现登录功能
- 前端B: HCC-112-实现用户注册
- 前端C: HCC-113-实现用户删除
相应地,我们创建三个功能分支:
- feature_HCC-111-实现登录功能
- feature_HCC-112-实现用户注册
- feature_HCC-113-实现用户删除
当所有的前端都开发完成了他们的任务,我们就要开始测试小程序了。但是如果按照以往体验版的测试方式,我们就需要排个顺序。比如,前端 A 先将他的小程序设置为体验版,测试把他的功能测试完成之后,再把前端 B 的设置为体验版,以此类推。可以看出真的很麻烦,而且浪费开发时间,我想你肯定不想在开发的时候突然被叫把你的小程序版本设置为体验版。
解决方案
小程序开发助手 这是一个官方提供的小程序,里面有多个版本的小程序可供选择,很方便测试人员的测试,并且也会节省开发人员的时间。点击版本查看就可以看到所有开发人员提交的最近的一次版本了。这样也不用设置体验版就可以测试最新的提交了。
再次抛出痛点
如果前端 A 头上有三个任务单呢?任务单:HCC-121-实现框架搭建,HCC-122-实现在线录屏,HCC-123-实现画板。此时你可能想说, 为啥前端 A 这么多的任务单呢?他命苦啊!
这个时候就需要配合微信的机器人了,我们可以创建多个机器人作为我们提交版本的媒介,这样我们就不受限于微信账号了。
可以在微信的官方文档看到 robot
参数有30个机器人可供选择。
接下来看下微信的机器人的使用方式。
微信官方是这样介绍这个工具的; miniprogram-ci 是从微信开发者工具中抽离的关于小程序/小游戏项目代码的编译模块。它其实是一个自动上传代码的工具,可以帮助我们自动化的编译代码并且上传到微信。
下面是一个大概得使用的示例,具体还是要参考官方文档。
const ci = require('miniprogram-ci');
(async () => {
const project = new ci.Project({
appid: 'wxsomeappid',
type: 'miniProgram',
projectPath: 'the/project/path',
privateKeyPath: 'the/path/to/privatekey',
ignores: ['node_modules/**/*'],
})
const previewResult = await ci.preview({
project,
desc: 'hello', // 此备注将显示在“小程序助手”开发版列表中
setting: {
es6: true,
},
qrcodeFormat: 'image',
qrcodeOutputDest: '/path/to/qrcode/file/destination.jpg',
onProgressUpdate: console.log,
// pagePath: 'pages/index/index', // 预览页面
// searchQuery: 'a=1&b=2', // 预览参数 [注意!]这里的`&`字符在命令行中应写成转义字符`\&`
})
console.log(previewResult)
})()
当我们使用这个脚本上传完代码就可以在小程序开发助手或者小程序管理平台看到以下内容。
微信管理后台
小程序开发助手页面
最后
我们可以使用 miniprogram-ci 配合 Jenkins 实现自动化部署,提交完成代码就可以自动部署了。以下是一个 github 的 actions 示例。当然也可以使用别的方式,例如本地提交,Jenkins提交等。
name: Feature Branch CI
on:
workflow_dispatch:
push:
branches: ['feature_*'] # 使用通配符匹配所有feature分支
jobs:
build_and_deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'yarn'
- name: Install dependencies
run: |
npm install -g miniprogram-ci cross-env
yarn install --frozen-lockfile
- name: Build Package
run: yarn cross-env ENV=PROD uni build -p mp-weixin --mode PROD
- name: Create private key file
run: echo "${{ secrets.PRIVATE_KEY }}" > private.key
- name: Deploy Package
env:
APP_ID: ${{ secrets.APP_ID }}
run: |
COMMIT_MESSAGE=$(git log --format=%B -n 1 ${{ github.sha }})
if [[ $COMMIT_MESSAGE =~ VERSION-([A-Za-z0-9_]+-[A-Za-z0-9_-]+)_DEV ]]; then
VERSION=${BASH_REMATCH[1]}
echo "Extracted Version: $VERSION"
miniprogram-ci preview \
--pp ./dist/build/mp-weixin \
--pkp ./private.key \
--appid $APP_ID \
--uv "${VERSION}" \
-r 7 \
--desc "${COMMIT_MESSAGE}" \
--upload-description "${COMMIT_MESSAGE}" \
--enable-es6 true \
--enable-es7 true \
--enable-minifyJS true \
--enable-minifyWXML true \
--enable-minifyWXSS true \
--enable-minify true \
--enable-bigPackageSizeSupport true \
--enable-autoPrefixWXSS true
else
echo "No Version found in commit message. Skipping upload."
fi
来源:juejin.cn/post/7412854873439027240
仿树木生长开花的动画效果
效果介绍
使用 canvas 进行绘制树木生长的效果,会逐渐长出树干,长出树叶,开出花。当窗口大小发生变化时进行重新渲染。
实现效果展示
实现步骤
创建画布
import React, { useEffect, useRef } from 'react'
function TreeCanvas(props: {
width: number;
height: number;
}) {
const { width = 400, height = 400 } = props;
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const context = canvas?.getContext('2d');
if (!canvas || !context) return;
context.strokeStyle = '#a06249';
}, [])
return (
<canvas ref={canvasRef} width={width} height={height} />
)
}
export default TreeCanvas
封装创建树枝的方法
- 树枝需要起点,终点,树枝宽度
function lineTo(p1: PointType, p2: PointType, lineWidth: number) {
context?.beginPath();
context?.moveTo(p1.x, p1.y);
context?.lineTo(p2.x, p2.y);
context.lineWidth = lineWidth;
context?.stroke();
}
绘制树叶和花朵的方法封装
- 提前生成图片实例
- 传递图片和坐标进行绘制
// 花的实例
const image = new Image();
image.src ='https://i.postimg.cc/D0LLWwKy/flower1.png';
// 叶子的实例
const imageLeaves = new Image();
imageLeaves.src = 'https://i.postimg.cc/PJShQmH6/leaves.png';
function drawTmg(imageUrl: any, p1: PointType) {
context?.drawImage(imageUrl, p1.x, p1.y, 20 * Math.random(), 20 * Math.random());
}
封装绘制处理
- 提供绘制的起点,计算绘制的终点
- 根据起点和终点进行绘制
// 计算终点
function getEnd(b: BranchType) {
const { start, theta, length } = b;
return {
x: start.x + Math.cos(theta) * length,
y: start.y + Math.sin(theta) * length
};
}
// 绘制整理
function drawBranch(b: BranchType) {
// 绘制树干
lineTo(b.start, getEnd(b), b.lineWidth);
if (Math.random() < 0.4) { // 绘制花朵的密度
drawTmg(image, getEnd(b));
}
if (Math.random() < 0.4) {
drawTmg(imageLeaves, b.start); // 绘制树叶的密度
}
}
绘制树的方法
- 起点和终点的计算及绘制数的角度计算
- 绘制左边树和右边树
- 随机绘制
function step(b: BranchType, depth: number = 0) {
const end = getEnd(b);
drawBranch(b);
if (depth < depthNum || Math.random() < 0.5) {
step(
{
start: end,
theta: b.theta - 0.3 * Math.random(),
length: b.length + (Math.random() * 10 - 5),
lineWidth: depthNum - depth
},
depth + 1
);
}
if (depth < depthNum || Math.random() < 0.5) {
step(
{
start: end,
theta: b.theta + 0.3 * Math.random(),
length: b.length + (Math.random() * 10 - 5),
lineWidth: depthNum - depth
},
depth + 1
);
}
}
动画处理
- 把所有绘制添加到动画处理中
const pendingTasks: Function[] = []; // 动画数组
function step(b: BranchType, depth: number = 0) {
const end = getEnd(b);
drawBranch(b);
if (depth < depthNum || Math.random() < 0.5) {
pendingTasks.push(() => { // 添加左侧动画
step(
{
start: end,
theta: b.theta - 0.3 * Math.random(), // 角度变化
length: b.length + (Math.random() * 10 - 5), // 长度变化
lineWidth: depthNum - depth
},
depth + 1
);
});
}
if (depth < depthNum || Math.random() < 0.5) {
pendingTasks.push(() => { // 添加右侧动画
step(
{
start: end,
theta: b.theta + 0.3 * Math.random(), // 角度变化
length: b.length + (Math.random() * 10 - 5),// 长度变化
lineWidth: depthNum - depth
},
depth + 1
);
});
}
}
function frame() {
const tasks = [...pendingTasks];
pendingTasks.length = 0;
tasks.forEach((fn) => fn());
}
let framesCount = 0;
function satrtFrame() {
requestAnimationFrame(() => {
framesCount += 1;
// if (framesCount % 10 === 0) {
frame();
satrtFrame();
// }
});
}
封装执行方法
useEffect(() => {
function init() {
step(startBranch);
}
satrtFrame();
init();
}, []);
添加常用场景封装
- 宽高获取当前屏幕大小
- 屏幕发生变化时进行重新渲染
export const TreeCanvasInner = () => {
const [innerSize, setInnerSize] = useState({ x: window.innerWidth, y: window.innerHeight });
useEffect(() => {
const resizeFunc = () => {
setInnerSize({ x: window.innerWidth, y: window.innerHeight });
};
window.addEventListener('resize', resizeFunc);
return () => {
window.removeEventListener('resize', resizeFunc);
};
}, []);
return (
<TreeCanvas
key={JSON.stringify(innerSize)}
width={innerSize.x}
height={innerSize.y}
startBranch={{ start: { x: 0, y: 0 }, theta: 20, length: 25, lineWidth: 3 }}
/>
);
};
完整代码
来源:juejin.cn/post/7309061655095361571
前端纯css实现-一个复选框交互展示效果
纯手工写一个复选框前端交互,这样可以自由定制自己想要展示的字段和设计风格
写这篇文章主要是抛砖引玉,可能存在槽点,大家多多担待!
1.交互效果展示
用码上掘金在线简单的写了一下:
2.简要说明
$primary-color:#1e80ff;
// 主题色-掘金蓝
$primary-disable: #7ab0fd;
// 只读或禁用色
可以根据实际需求更改主题色,这里的禁用变量色忘记使用了,sorry!!!
3.布局代码部分
<!-- page start -->
<div class="ui-layout-page">
<h1>请选择关注类型</h1>
<div class="ui-checkbox">
<!-- 复选框 item start -->
<div
:class="{'ui-item-box':true,'ui-item-check': i.isCheck,'ui-item-disable':i.disable}"
v-for="(i,index) in list"
:key="index"
@click="doCheck(i)">
<img :src="i.icon"/>
<span class="span-bar">
<p class="label-bar">{{i.label}}</p>
<p class="desc-bar">{{i.desc}}</p>
</span>
<!-- 选中标识 start -->
<span
v-if="i.isCheck"
class="icon-check">
</span>
<!-- 选中标识 end -->
</div>
<!-- 复选框 item end -->
</div>
<p style="font-size:12px;color:#333">当前选择ids:{{ this.checked.join(',') }}</p>
</div>
<!-- page end -->
4.方法和数据结构部分
checked:['1','2'],
list:[
{
label:'JYM系统消息',
id:'1',
desc:'关注掘金系统消息',
isCheck:true,
icon:'https://gd-hbimg.huaban.com/6f3e3ff111c6c98be6785d9eddd5b13f8979ef9d1719e-Xwo8QB_fw658webp',
disable:true,
},{
label:'JYM后端',
id:'2',
isCheck:true,
desc:'关注后端讨论区新消息',
icon:'https://gd-hbimg.huaban.com/e2622fe339d655bd17de59fed3b0ae0afb9a16c31db25-YNpnGV_fw658webp',
disable:false,
},{
label:'JYM前端',
id:'3',
isCheck:false,
desc:'关注前端讨论区新消息',
icon:'https://gd-hbimg.huaban.com/80765200aa4ffb7683ddea51c3063b0801874fb86324-3OVCQN_fw1200',
disable:false,
},{
label:'JYM开发工具',
id:'4',
isCheck:false,
desc:'关注开发工具讨论区新消息',
icon:'https://gd-hbimg.huaban.com/ef1c0e1fb2eae73d674aae791526a331b45b26d2b78e-r4p1aq_fw1200',
disable:false,
}
]
/**
* 复选点击事件
* el.disable 禁用状态
* */
doCheck(el){
if(el.disable) return
if(this.checked.includes(el.id)){
el.isCheck = false
this.checked=this.checked.filter(item => item !== el.id);
} else{
el.isCheck = true
this.checked.push(el.id)
}
this.checked.join(',')
}
5.样式控制部分
.ui-layout-page{
padding:20px;
h1{
font-size:16px;
}
// 个性化复选框 css start -------------
.ui-checkbox{
width:100%;
$primary-color:#1e80ff; // 主题色-掘金蓝
$primary-disable: #7ab0fd; // 只读或禁用色
// 选中状态css
.ui-item-check{
border:1px solid $primary-color !important;
background:rgba($primary-color,0.05) !important;
}
// 禁用状态css
.ui-item-disable{
border:1px solid #d3d3d3 !important;
background: #f3f3f3 !important;
cursor:not-allowed !important;
.icon-check{
border-top:20px solid #ccc !important;
}
.label-bar{
color:#777 !important;
}
.desc-bar{
color:#a3a3a3 !important;
}
}
// 常规状态css
.ui-item-box{
position:relative;
display:inline-flex;
align-items: center;
width:220px;
height:70px;
border:1px solid #ccc;
cursor: pointer;
margin:0px 8px 8px 0px;
border-radius:4px;
overflow:hidden;
&:hover{
border:1px solid $primary-color;
background:rgba($primary-color,0.05);
}
img{
width:38px;
height:38px;
margin-left:15px;
}
p{
margin:0px;
}
.span-bar{
width:0px;
flex:1 0 auto;
padding:0px 10px;
.label-bar{
font-size:14px;
font-weight:700;
margin-bottom:4px;
color:#333;
}
.desc-bar{
font-size:12px;
color:#999;
}
}
// 绘制圆角斜三角形
.icon-check{
position:absolute;
width:0px;
height:0px;
top:2px;
right:2px;
border-top:20px solid $primary-color;
border-left:25px solid transparent;
border-radius: 5px 3px 5px 0px;
&:after{
content:'✓';
position: relative;
color:#fff;
font-size:12px;
left: -12px;
top: -26px;
}
}
}
}
// 个性化复选框 css end -------------
}
来源:juejin.cn/post/7412545166539128841
CSS 实现呼吸灯
引言
在现代前端开发中,为网站添加吸引人的动画效果是提高用户体验的一种常见方式。其中,呼吸灯效果是一种简单而又引人注目的动画,适用于各种应用场景。本文将深入研究如何使用 CSS 来实现呼吸灯效果,包括基本的实现原理、动画参数调整、以及一些实际应用案例。
第一部分:基本的呼吸灯效果
1. 使用关键帧动画
呼吸灯效果的实现依赖于 CSS 的关键帧动画。我们可以使用 @keyframes
规则定义一个简单的呼吸灯动画。
@keyframes breathe {
0% {
opacity: 0.5;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.2);
}
100% {
opacity: 0.5;
transform: scale(1);
}
}
.breathing-light {
animation: breathe 3s infinite;
}
在这个例子中,我们定义了一个名为 breathe
的关键帧动画,包含三个关键帧(0%、50%、100%)。在不同的关键帧,我们分别调整了透明度和缩放属性,从而形成了呼吸灯效果。
2. 应用到元素
接下来,我们将这个动画应用到一个元素上,例如一个 div
。
<div class="breathing-light"></div>
通过给这个元素添加 breathing-light
类,我们就能够观察到呼吸灯效果的实现。可以根据实际需求调整动画的持续时间、缓动函数等参数。
第二部分:调整动画参数
1. 调整动画持续时间
通过调整 animation
属性的第一个值,我们可以改变动画的持续时间。例如,将动画持续时间改为 5 秒:
.breathing-light {
animation: breathe 5s infinite;
}
2. 调整缓动函数
缓动函数影响动画过渡的方式。可以通过 animation-timing-function
属性来调整。例如,使用 ease-in-out
缓动函数:
.breathing-light {
animation: breathe 3s ease-in-out infinite;
}
3. 调整动画延迟时间
通过 animation-delay
属性,我们可以设置动画的延迟时间。这在创建多个呼吸灯效果不同步的元素时很有用。
.breathing-light {
animation: breathe 3s infinite;
animation-delay: 1s;
}
第三部分:实际应用案例
1. 页面标题的动态效果
在页面的标题上应用呼吸灯效果,使其在页面加载时引起用户的注意。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Breathing Light Title</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<h1 class="breathing-light">Welcome to Our Website</h1>
</body>
</html>
@keyframes breathe {
0% {
opacity: 0.5;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.2);
}
100% {
opacity: 0.5;
transform: scale(1);
}
}
.breathing-light {
animation: breathe 3s infinite;
}
2. 图片边框的动感效果
通过为图片添加呼吸灯效果,为静态图片增加一些生动感。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Breathing Light Image</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="image-container">
<img src="example-image.jpg" alt="Example Image" class="breathing-light">
</div>
</body>
</html>
@keyframes breathe {
0% {
opacity: 0.5;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.2);
}
100% {
opacity: 0.5;
transform: scale(1);
}
}
.breathing-light {
animation: breathe 3s infinite;
}
.image-container {
display: inline-block;
overflow: hidden;
border: 5px solid #fff; /* 图片边框 */
}
结语
通过本文,我们深入探讨了如何使用 CSS 实现呼吸灯效果。从基本原理、动画参数调整到实际应用案例,希望读者能够深刻理解呼吸灯效果的制作过程,并能够在实际项目中灵活运用这一技术,为用户呈现更加生动有趣的页面效果。不仅如此,这也是提升前端开发技能的一种乐趣。
来源:juejin.cn/post/7315314479204581391
文本美学:text-image打造视觉吸引力
当我最近浏览 GitHub 时,偶然发现了一个项目,它能够将文字、图片和视频转化为文本,我觉得非常有趣。于是我就花了一些时间了解了一下,发现它的使用也非常简单方便。今天我打算和家人们分享这个发现。
项目介绍
话不多说,我们先看下作者的demo效果:
_20240420194201.jpg
text-image可以将文字、图片、视频进行「文本化」
只需要通过简单的配置即可使用。
虽然这个项目star数很少,但确实是一个很有意思的项目,使用起来很简单的项目。
_20240420194537.jpg
github地址:https://github.com/Sunny-117/text-image
我也是使用这个项目做了一个简单的web页面,感兴趣的家人可以使用看下效果:
web地址:http://h5.xiuji.mynatapp.cc/text-image/
_20240420211509.jpg
项目使用
这个项目使用起来相对简单,只需按作者的文档使用即可,虽然我前端属于小白的水平,但还是在ai的帮助下做了一个简单的html页面,如果有家人需要的话可以私信我,我发下文件。下边我们就介绍下:
- 文字「文本化」
先看效果:
_20240420195701.jpg
我们在这儿是将配置的一些参数在页面上做了一个可配置的表单,方便我们配置。
家人们想自己尝试的话可以试下以下这个demo。
demo.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Document</title>
</head>
<body>
<canvas id="demo"></canvas>
<script src="http://h5.xiuji.mynatapp.cc/text-image/text-image.iife.js"></script>
<script>
textImage.createTextImage({
canvas: document.getElementById('demo'),
replaceText: '123',
source: {
text: '修己xj',
},
});
</script>
</body>
</html>
- 图片「文本化」
_20240420200651.jpg
demo.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Document</title>
</head>
<body>
<canvas id="demo"></canvas>
<script src="http://h5.xiuji.mynatapp.cc/text-image/text-image.iife.js"></script>
<script>
textImage.createTextImage({
canvas: document.getElementById('demo'),
raduis: 7,
isGray: true,
source: {
img: './assets/1.png',
},
});
</script>
</body>
</html>
- 视频「文本化」
1.gif
demo.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Document</title>
</head>
<body>
<canvas id="demo"></canvas>
<script src="http://h5.xiuji.mynatapp.cc/text-image/text-image.iife.js"></script>
<script>
textImage.createTextImage({
canvas: document.getElementById('demo'),
raduis: 8,
isGray: true,
source: {
video: './assets/1.mp4',
height: 700,
},
});
</script>
</body>
</html>
需要注意的是:作者在项目中提供的视频的demo这个属性值有错误,我们需要改正后方可正常显示:
_20240420211124.jpg
总结
text-image 是一个强大的前端工具,可以帮助用户快速、轻松地将文本、图片、视频转换成文本化的图片,增强文本内容的表现力和吸引力。
来源:juejin.cn/post/7359510120248786971
排行榜--实现点击视图自动滚动到当前用户所在位置.
需求
我们今天来实现一下,点击当前用户的div, 自动滚动到用户在排行榜中的位置.
效果
大家可以先看一下下面的GIF, 所实现的效果.
实现
1. 准备DOM 结构
首先,我们在进行列表建设的时候, 需要准备好一个数据. 因为此处我们是使用的vue3
来进行编写. 对于列表我们使用的是v-for列表渲染
来做的. 在渲染的时候, 我们需要给每一个列表项(当前就是每一个用户项
)添加一个自定义属性. 具体的话, 可以看下 下方的关键代码.
核心代码就是
<div v-for="(item, index) in rankingData" :key="item.user.id" :data-key="item.user.id"
</div>
因为数据是后端返回的, 是包含的user_id,而且这个user_id 是不可能重复的. 我们只要保证每个列表的自定义的属性是唯一的即可.
2. 绑定方法,实现方法
接下来,我们需要考虑的是,在点击的时候,如何获取到当前的dom. 这对我们目前来说就很容易了, 因为我们可以根据据user_id
拿到我们当前点击的dom.
添加一个方法
<!-- 当前用户排名情况 -->
<div class="text-white w-[100%] ...." @click="scrollToCurrentRankingPosition(userId)">
实现方法.
第一步: 拿到rankingList的dom实例.
这里我们通过vue3提供ref拿到dom. 可以看下
模板引用
<div v-else class=" overflow-auto bg-white" ref="rankingList">
const rankingList = ref(null);
第二步: 根据userId获取到具体的DOM
const currentItem = rankingList.value.querySelector(`[data-key="${id}"]`);
第三步: 使用scrollIntoView方法滚动视图到当前选中的元素
// 平滑滚动到当前元素
currentItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
scrollIntoView方法 讲解:
Element
接口的scrollIntoView()
方法会滚动元素的父容器,使被调用scrollIntoView()
的元素对用户可见。
简单来讲就是被调用的者的元素出现在用户的视线里面.
scrollIntoView()
方法有三种调用形式:
scrollIntoView()
:无参数调用,元素将滚动到可视区域顶部,如果它是第一个可见元素。scrollIntoView(alignToTop)
:接受一个布尔值参数,决定元素是与滚动区的顶部还是底部对齐。scrollIntoView(scrollIntoViewOptions)
:接受一个对象作为参数,提供了更多的滚动选项。
参数
alignToTop
(可选):布尔值,控制元素滚动到顶部还是底部对齐。默认为true
(顶部对齐)。scrollIntoViewOptions
(可选实验性):对象,包含以下属性:
behavior
:定义滚动行为是平滑动画还是立即发生。可取值有smooth
(平滑动画)、instant
(立即发生)或auto
(由CSS的scroll-behavior
属性决定)。block
:定义垂直方向的对齐方式,可取值有start
、center
、end
或nearest
。默认为start
。inline
:定义水平方向的对齐方式,可取值有start
、center
、end
或nearest
。默认为nearest
。
目前我们实现了效果.
但是我们发现,还可以继续改进, 目前我们虽然滚动到了屏幕的中间, 但是我们很难去发现. 所以我们可以继续完善一下这个方法. 就是滚动到视图的中间的同时高亮选中的DOM.
3. 额外扩展, 高亮当前的元素
定义一个两个方法,一个用于应用样式
, 一个应用于移除样式
.
const applyHighlightStyles = (element) => {
element.style.transition = 'background-color 1s ease, border-color 1s ease';
element.style.border = '1px solid transparent'; // 预定义边框样式
element.style.borderColor = '#006cfe'; // 设置边框颜色
element.style.backgroundColor = '#cfe5ff'; // 设置背景色为浅蓝色
};
const removeHighlightStyles = (element) => {
element.style.backgroundColor = ''; // 移除背景色
element.style.borderColor = 'transparent'; // 移除边框颜色
};
然后再在我们之前的方法的后面加入代码
// 设置高亮显示的样式
applyHighlightStyles(currentItem);
// 清除之前的定时器(如果有)
if (currentItem._highlightTimer) {
clearTimeout(currentItem._highlightTimer);
}
// 设置定时器,2秒后移除高亮显示
currentItem._highlightTimer = setTimeout(() => {
removeHighlightStyles(currentItem);
currentItem._highlightTimer = null;
}, 2000);
然后在组件卸载前记得清除定时器.
onUnmounted(() => {
if (rankingList.value) {
// 遍历所有项目,清除定时器
rankingList.value.querySelectorAll('[data-key]').forEach(item => {
if (item._highlightTimer) {
clearTimeout(item._highlightTimer);
item._highlightTimer = null;
}
});
}
});
效果:
总结
整体下来的思路就是:
- v-for的时候, 给每个循环的元素添加一个自定义的属性.(value:user_id), 不重复且能标识每个元素.
- 点击之后,拿到id,透传给方法,然后通过id获取到当前的元素.
- 使用
Element.scrollIntoView()
, 将当前的选中的DOM自动滚动视图的中间. - 高亮显示当前的元素之后(2s)进行取消高亮.
来源:juejin.cn/post/7403576996393910308
开箱即用的web打印和下载,自动分页不截断
哈喽哈喽🌈
哈喽大家好!我是小周🤪🤪🤪。相信各位前端小伙伴都知道可以用window.print()
这个方法来调用打印机实现打印功能,但是直接下载功能window.print()
还是无法实现的。今天我来介绍另外一种实现方式,真正的开箱即用,既可以实现打印和直接下载,也可以防止内容截断。
技术栈
1、html2canvas
html2canvas
一个可以将html转换成canvas的三方库
2、jsPDF
jsPDF
生成pdf文件的三方库
一些用到的方法介绍
1、canvas.getContext('2d').getImageData()
canvas.getContext('2d').getImageData()
是 HTML5 Canvas API 中用于获取指定区域的像素数据的方法。它返回一个 ImageData
对象,该对象包含了指定区域中每个像素的颜色和透明度信息。
canvas.getContext('2d').getImageData(x, y, width, height)
参数说明:
x
: 采集的图像区域的左上角的水平坐标。y
: 采集的图像区域的左上角的垂直坐标。width
: 采集的图像区域的宽度(以像素为单位)。height
: 采集的图像区域的高度(以像素为单位)。
返回值:
返回一个 ImageData
对象,包含以下属性:
width
: 图像数据的宽度。height
: 图像数据的高度。data
: 一个Uint8ClampedArray
类型的数组,存储了区域中每个像素的颜色和透明度信息。每个像素占用 4 个元素,分别对应:
data[n]
: 红色通道的值(0 到 255)data[n+1]
: 绿色通道的值(0 到 255)data[n+2]
: 蓝色通道的值(0 到 255)data[n+3]
: 透明度(alpha)通道的值(0 到 255),255 表示完全不透明,0 表示完全透明。
代码实现
1、设置 DomToPdf 类
export class DomToPdf {
_rootDom = null
_title = 'pdf' //生成的pdf标题
_a4Width = 595.266
_a4Height = 841.89
_pageBackground = 'rgba(255,255,255)' //页面背景色
_hex = [0xff, 0xff, 0xff] //用于检测分页的颜色标识
//初始化状态
constructor(rootDom, title, color = [255, 255, 255]) {
this._rootDom = rootDom
this._title = title
this._pageBackground = `rgb(${color[0]},${color[1]},${color[2]})`
this._hex = color
}
}
2、设置 pdf的生成函数
async savePdf() {
const a4Width = this._a4Width
const a4Height = this._a4Height
const hex = this._hex
return new Promise(async (resolve, reject) => {
try {
const canvas = await html2canvas(this._rootDom, {
useCORS: true,
allowTaint: true,
scale: 0.8,
backgroundColor: this._pageBackground,
})
const pdf = new jsPDF('p', 'pt', 'a4')
let index = 1,
canvas1 = document.createElement('canvas'),
height
let leftHeight = canvas.height
let a4HeightRef = Math.floor((canvas.width / a4Width) * a4Height)
let position = 0
let pageData = canvas.toDataURL('image/jpeg', 0.7)
pdf.setDisplayMode('fullwidth', 'continuous', 'FullScreen')
function createImpl(canvas) {
if (leftHeight > 0) {
index++
let checkCount = 0
if (leftHeight > a4HeightRef) {
let i = position + a4HeightRef
for (i = position + a4HeightRef; i >= position; i--) {
let isWrite = true
for (let j = 0; j < canvas.width; j++) {
let c = canvas.getContext('2d').getImageData(j, i, 1, 1).data
if (c[0] !== hex[0] || c[1] !== hex[1] || c[2] !== hex[2]) {
isWrite = false
break
}
}
if (isWrite) {
checkCount++
if (checkCount >= 10) {
break
}
} else {
checkCount = 0
}
}
height =
Math.round(i - position) || Math.min(leftHeight, a4HeightRef)
if (height <= 0) {
height = a4HeightRef
}
} else {
height = leftHeight
}
canvas1.width = canvas.width
canvas1.height = height
let ctx = canvas1.getContext('2d')
ctx.drawImage(
canvas,
0,
position,
canvas.width,
height,
0,
0,
canvas.width,
height,
)
if (position !== 0) {
pdf.addPage()
}
pdf.setFillColor(hex[0], hex[1], hex[2])
pdf.rect(0, 0, a4Width, a4Height, 'F')
pdf.addImage(
canvas1.toDataURL('image/jpeg', 1.0),
'JPEG',
0,
0,
a4Width,
(a4Width / canvas1.width) * height,
)
leftHeight -= height
position += height
if (leftHeight > 0) {
setTimeout(createImpl, 500, canvas)
} else {
resolve(pdf)
}
}
}
if (leftHeight < a4HeightRef) {
pdf.setFillColor(hex[0], hex[1], hex[2])
pdf.rect(0, 0, a4Width, a4Height, 'F')
pdf.addImage(
pageData,
'JPEG',
0,
0,
a4Width,
(a4Width / canvas.width) * leftHeight,
)
resolve(pdf)
} else {
try {
pdf.deletePage(0)
setTimeout(createImpl, 500, canvas)
} catch (err) {
reject(err)
}
}
} catch (error) {
reject(error)
}
})
}
3、设置承接pdf的方法
//直接下载pdf
async downToPdf(setLoadParent) {
const newPdf = await this.savePdf()
const title = this._title
newPdf.save(title + '.pdf')
setLoadParent(false)
}
//通过构造链接的形式去跳转打印页面
async printToPdf(setLoadParent) {
const newPdf = await this.savePdf()
const pdfBlob = newPdf.output('blob')
const pdfUrl = URL.createObjectURL(pdfBlob)
setLoadParent(false)
window.open(pdfUrl)
}
完整代码
import html2canvas from 'html2canvas'
import jsPDF from 'jspdf'
export class DomToPdf {
_rootDom = null
_title = 'pdf'
_a4Width = 595.266
_a4Height = 841.89
_pageBackground = 'rgba(255,255,255)'
_hex = [0xff, 0xff, 0xff]
constructor(rootDom, title, color = [255, 255, 255]) {
this._rootDom = rootDom
this._title = title
this._pageBackground = `rgb(${color[0]},${color[1]},${color[2]})`
this._hex = color
}
async savePdf() {
const a4Width = this._a4Width
const a4Height = this._a4Height
const hex = this._hex
return new Promise(async (resolve, reject) => {
try {
const canvas = await html2canvas(this._rootDom, {
useCORS: true,
allowTaint: true,
scale: 0.8,
backgroundColor: this._pageBackground,
})
const pdf = new jsPDF('p', 'pt', 'a4')
let index = 1,
canvas1 = document.createElement('canvas'),
height
let leftHeight = canvas.height
let a4HeightRef = Math.floor((canvas.width / a4Width) * a4Height)
let position = 0
let pageData = canvas.toDataURL('image/jpeg', 0.7)
pdf.setDisplayMode('fullwidth', 'continuous', 'FullScreen')
function createImpl(canvas) {
if (leftHeight > 0) {
index++
let checkCount = 0
if (leftHeight > a4HeightRef) {
let i = position + a4HeightRef
for (i = position + a4HeightRef; i >= position; i--) {
let isWrite = true
for (let j = 0; j < canvas.width; j++) {
let c = canvas.getContext('2d').getImageData(j, i, 1, 1).data
if (c[0] !== hex[0] || c[1] !== hex[1] || c[2] !== hex[2]) {
isWrite = false
break
}
}
if (isWrite) {
checkCount++
if (checkCount >= 10) {
break
}
} else {
checkCount = 0
}
}
height =
Math.round(i - position) || Math.min(leftHeight, a4HeightRef)
if (height <= 0) {
height = a4HeightRef
}
} else {
height = leftHeight
}
canvas1.width = canvas.width
canvas1.height = height
let ctx = canvas1.getContext('2d')
ctx.drawImage(
canvas,
0,
position,
canvas.width,
height,
0,
0,
canvas.width,
height,
)
if (position !== 0) {
pdf.addPage()
}
pdf.setFillColor(hex[0], hex[1], hex[2])
pdf.rect(0, 0, a4Width, a4Height, 'F')
pdf.addImage(
canvas1.toDataURL('image/jpeg', 1.0),
'JPEG',
0,
0,
a4Width,
(a4Width / canvas1.width) * height,
)
leftHeight -= height
position += height
if (leftHeight > 0) {
setTimeout(createImpl, 500, canvas)
} else {
resolve(pdf)
}
}
}
if (leftHeight < a4HeightRef) {
pdf.setFillColor(hex[0], hex[1], hex[2])
pdf.rect(0, 0, a4Width, a4Height, 'F')
pdf.addImage(
pageData,
'JPEG',
0,
0,
a4Width,
(a4Width / canvas.width) * leftHeight,
)
resolve(pdf)
} else {
try {
pdf.deletePage(0)
setTimeout(createImpl, 500, canvas)
} catch (err) {
reject(err)
}
}
} catch (error) {
reject(error)
}
})
}
async downToPdf(setLoadParent) {
const newPdf = await this.savePdf()
const title = this._title
newPdf.save(title + '.pdf')
setLoadParent(false)
}
async printToPdf(setLoadParent) {
const newPdf = await this.savePdf()
const pdfBlob = newPdf.output('blob')
const pdfUrl = URL.createObjectURL(pdfBlob)
setLoadParent(false)
window.open(pdfUrl)
}
结束
好喽,开箱即用的打印和下载功能的实现就完成了。欢迎大家阅读,我是小周🤪🤪🤪
来源:juejin.cn/post/7412672713376497727
基于 Letterize.js + Anime.js 实现炫酷文本特效
如上面gif动图所示,这是一个很炫酷的文字动画效果,文字的每个字符呈波浪式的扩散式展开。本次文章将解读如何实现这个炫酷的文字效果。
基于以上的截图效果可以分析出以下是本次要实现的主要几点:
- 文案呈圆环状扩散开,扩散的同时文字变小
- 文字之间的间距从中心逐个扩散开,间距变大
- 文案呈圆环状扩散开,扩散的同时文字变大
- 文字之间的间距从中心逐个聚拢,间距变小
- 动画重复执行以上4个步骤
实现过程
核心代码实现需要基于一下两个库:
Letterize.js
是一个轻量级的JavaScript库,它可以将文本内容分解为单个字母,以便可以对每个字母进行动画处理。这对于创建复杂的文本动画效果非常有用。使用Letterize.js,你可以轻松地将一个字符串或HTML元素中的文本分解为单个字母,并为每个字母创建一个包含类名和数据属性的新HTML元素。这使得你可以使用CSS或JavaScript来控制每个字母的样式和动画。
anime.js
是一个强大的JavaScript动画库,它提供了一种简单而灵活的方式来创建各种动画效果。它可以用于HTML元素、SVG、DOM属性和JavaScript对象的动画。
通过使用Letterize.js
以便可以对每个字母进行动画处理,再结合anime.js
即可创建各种动画效果。本文不对这两个库做更多的详细介绍,只对本次特效实现做介绍,有兴趣的可以看看官网完整的使用文档。
界面布局
html
就是简单的本文标签,也不需要额外的样式,只需要在外层使用flex
布局将内容居中,因为本文的长度都是一样的,所以完成后的文本内容就像一个正方形。
<div>
<div class="animate-me">
letterize.js&anime.js
div>
<div class="animate-me">
anime.js&letterize.js
div>
......
<div class="animate-me">
letterize.js&anime.js
div>
<div class="animate-me">
anime.js&letterize.js
div>
div>
动画实现
- 初始化
Letterize.js
,只需要传入targets
目标元素,元素即是上面的.animate-me
文本标签。返回的letterize
是包含所有选中的.animate-me
元素组数。
const letterize = new Letterize({
targets: ".animate-me"
});
- 接下来初始化
anime
库的使用,下面的代码即创建了一个新的anime.js
时间线动画。目标是Letterize
对象的所有字母。动画将以100毫秒的间隔从中心开始,形成一个网格。loop: true
动画将无限循环。
const animation = anime.timeline({
targets: letterize.listAll,
delay: anime.stagger(100, {
grid: [letterize.list[0].length, letterize.list.length],
from: "center"
}),
loop: true
});
- 开始执行动画,首先设置 「文案呈圆环状扩散开,扩散的同时文字变小」,这里其实就是将字母的大小缩小。
animation
.add({
scale: 0.5
})
此时的效果如下所示:
- 继续处理下一步动画,「文字之间的间距从中心逐个扩散开,间距变大」,这里处理的其实就是将字母的间距加大,通过设置
letterSpacing
即可,代码如下:
animation
.add({
scale: 0.5
})
.add({
letterSpacing: "10px"
})
此时的效果如下所示:
- 后面还有2个步骤,「文案呈圆环状扩散开,扩散的同时文字变大;文字之间的间距从中心逐个聚拢,间距变小」,换做上面的思路也就是将文字变大和将文字间距变小,增加相应的代码如下:
.add({
scale: 1
})
.add({
letterSpacing: "6px"
});
在线预览
码上掘金地址:
最后
本文通过 Letterize.js + Anime.js 实现了一个很炫酷的文字动画效果,文字的每个字符呈波浪式的扩散式展开和收起。anime.js
还有很多的参数可以尝试,有兴趣的朋友可以尝试探索看看~
看完本文如果觉得有用,记得点个赞支持,收藏起来说不定哪天就用上啦~
专注前端开发,分享前端相关技术干货,公众号:南城大前端(ID: nanchengfe)
参考
动画效果发布者 Wojciech Krakowiak
:https://codepen.io/WojciechWKROPCE/pen/VwLePLy
来源:juejin.cn/post/7300847292974071859
CSS萤火虫按钮特效
如图所示,这是一个很炫酷的按钮悬浮特效,鼠标悬停时,按钮呈现发光的效果,周边还出现类型萤火虫的效果。本文将解析如何实现这个按钮特效,基于这个动图可以分析出需要实现的要点:
- 有一个跟随鼠标移动的圆点
- 按钮悬停时有高亮发光的效果
- 悬停时按钮周边的萤火中效果
实现过程
跟随鼠标移动的圆点
这个部分需要基于JS实现,但不是最主要的实现代码
如果单纯做一个跟随鼠标移动的点很简单,只需要监听鼠标事件获取坐标实时更新到需要移动的元素上即可。但是仔细看这里的效果并不是这样,圆点是跟随在鼠标后面,鼠标移动停止后圆点才会和鼠标重合。这里是使用了一个名为 Kinet
的库来实现的这个鼠标移动动画效果,具体实现如下:
- 创建 Kinet 实例,传入了自定义设置:
- acceleration: 加速度,控制动画的加速程度。
- friction: 摩擦力,控制动画的减速程度。
- names: 定义了两个属性 x 和 y,用于表示动画的两个维度。
var kinet = new Kinet({
acceleration: 0.02,
friction: 0.25,
names: ["x", "y"],
});
- 通过 document.getElementById 获取页面中 ID 为
circle
的元素,以便后续进行动画处理。
var circle = document.getElementById('circle');
- 设置 Kinet 的
tick
事件处理:
- 监听
tick
事件,每当 Kinet 更新时执行该函数。 instances
参数包含当前的 x 和 y 值及其速度。- 使用
style.transform
属性来更新圆形元素的位置和旋转: translate3d
用于在 3D 空间中移动元素。rotateX
和rotateY
用于根据当前速度旋转元素。
kinet.on('tick', function(instances) {
circle.style.transform = `translate3d(${ (instances.x.current) }px, ${ (instances.y.current) }px, 0) rotateX(${ (instances.x.velocity/2) }deg) rotateY(${ (instances.y.velocity/2) }deg)`;
});
- 听 mousemove 事件,
kinet.animate
方法用于更新 x 和 y 的目标值,计算方式是将鼠标的当前位置减去窗口的中心位置,使动画围绕窗口中心进行。
document.addEventListener('mousemove', function (event) {
kinet.animate('x', event.clientX - window.innerWidth/2);
kinet.animate('y', event.clientY - window.innerHeight/2);
});
随着鼠标的移动这个圆点元素将在页面上进行平滑的动画。通过 Kinet 库的加速度和摩擦力设置,动画效果显得更加自然,用户体验更加生动。有兴趣的可以尝试调整参数解锁其他玩法,此时我们的页面效果如下:
按钮悬停时发光效果
这里主要通过悬停时设置transition
过渡改变按钮的内外阴影效果,阴影效果通过伪元素实现,默认透明度为0,按钮样式代码如下:
.button {
z-index: 1;
position: relative;
text-decoration: none;
text-align: center;
appearance: none;
display: inline-block;
}
.button::before, .button::after {
content: "";
position: absolute;
top: 0;
right: 0;
left: 0;
bottom: 0;
border-radius: 999px;
opacity: 0;
transition: opacity 0.3s;
}
.button::before {
box-shadow: 0px 0px 24px 0px #FFEB3B;
}
.button::after {
box-shadow: 0px 0px 23px 0px #FDFCA9 inset, 0px 0px 8px 0px #FFFFFF42;
}
当鼠标悬停在按钮上时,通过改变伪元素的透明度,使发光效果在鼠标悬停时变得可见:
.button-wrapper:hover .button::before,
.button-wrapper:hover .button::after {
opacity: 1;
}
此时的按钮效果如下:
悬停时萤火中效果
如头部图片所展示,萤火虫效果是有多个圆点散开,所以这里我们添加多个圆点元素。
class="dot dot-1">
<span class="dot dot-2">span>
<span class="dot dot-3">span>
<span class="dot dot-4">span>
<span class="dot dot-5">span>
<span class="dot dot-6">span>
<span class="dot dot-7">span>
设置元素样式,这里的CSS变量(如 --speed, --size, --starting-x, --starting-y, --rotatation)用于控制圆点的动画速度、大小、起始位置和旋转角度。
.dot {
display: block;
position: absolute;
transition: transform calc(var(--speed) / 12) ease;
width: var(--size);
height: var(--size);
transform: translate(var(--starting-x), var(--starting-y)) rotate(var(--rotatation));
}
给圆点设置动画效果,使用 @keyframes
定义了两个动画:dimFirefly
和 hoverFirefly
,为圆点添加了闪烁和移动效果:
@keyframes dimFirefly {
0% { opacity: 1; }
25% { opacity: 0.4; }
50% { opacity: 0.8; }
75% { opacity: 0.5; }
100% { opacity: 1; }
}
@keyframes hoverFirefly {
0% { transform: translate(0, 0); }
12% { transform: translate(3px, 1px); }
24% { transform: translate(-2px, 3px); }
37% { transform: translate(2px, -2px); }
55% { transform: translate(-1px, 0); }
74% { transform: translate(0, 2px); }
88% { transform: translate(-3px, -1px); }
100% { transform: translate(0, 0); }
}
在圆点的伪元素上关联动画效果:
.dot::after {
content: "";
animation: hoverFirefly var(--speed) infinite, dimFirefly calc(var(--speed) / 2) infinite calc(var(--speed) / 3);
animation-play-state: paused;
display: block;
border-radius: 100%;
background: yellow;
width: 100%;
height: 100%;
box-shadow: 0px 0px 6px 0px #FFEB3B, 0px 0px 4px 0px #FDFCA9 inset, 0px 0px 2px 1px #FFFFFF42;
}
给每个圆点设置不同的动画参数,通过使用 CSS 变量,开发者可以灵活地控制每个 .dot
元素的旋转角度,进一步丰富视觉效果。
.dot-1 {
--rotatation: 0deg;
--speed: 14s;
--size: 6px;
--starting-x: 30px;
--starting-y: 20px;
top: 2px;
left: -16px;
opacity: 0.7;
}
.dot-2 {
--rotatation: 122deg;
--speed: 16s;
--size: 3px;
--starting-x: 40px;
--starting-y: 10px;
top: 1px;
left: 0px;
opacity: 0.7;
}
...
此时只要在父元素.button-wrapper
悬停时,则触发 .dot
元素的旋转效果,并使其伪元素的动画开始运行,此时萤火中悬停效果就会开始运行。
.button-wrapper:hover {
.dot {
transform: translate(0, 0) rotate(var(--rotatation));
}
.dot::after {
animation-play-state: running;
}
}
最后完成的悬停效果如下:
在线预览
最后
通过以上步骤,结合现代 CSS 的强大功能,我们实现了一个发光的萤火虫圆点悬停按钮效果。这样的效果不仅提升了视觉吸引力,还增强了用户的交互体验。利用 CSS 变量和动画,设计师可以灵活地控制每个元素的表现,使得网页更加生动和引人注目。有兴趣的可以调整相关参数体验其他的视觉效果。
来源:juejin.cn/post/7401144423563444276
我的第一个独立产品,废了,大家来看看热闹哈哈
产品想法萌生背景:
我孩子4岁,很喜欢画画,平常在家里,画在纸上,墙的画板上,学习机上,每一次画都很专注,而外出时,有时候会无聊,比如就餐等位,长时间坐高铁,等爸爸剪头发等等场景,一时之间也不好找东西给他玩,于是有了做一个画画小程序给他的想法,同时也觉得会有同样需求的家长,尽管需求场景很小,用的频率很低,但这小程序也许是可以救急的
产品实施:
1.梳理初步想要实现的功能
2.开发实现
需求想要的效果都实现了,可随意改变颜色在白板上随意画画,效果如下
3.更多的想法
实现了画画功能,感觉太单一,于是想到涂色卡和字帖功能,具体如下
其实都是“画”这个功能的延伸,实现起来也比较顺利,实现效果如下
4.为什么废了?
- 想要在外出时画画,可以买一个小小的画板,一键清除那种,这样既能画画,还不用看手机,蛮多家长介意看手机的
- 需要场景很小,很多替代方案,各种小型的益智玩具,绘本等
- 字帖功能,一般是打印纸质版,练习握笔和书写习惯
- 欢迎补充哈哈哈
最后想说
虽然产品废了,但从0到1实现了自己的想法,收获还是很多的,我从事Java开发,因为实现这个想法,自学了小程序开发,AI抠图等,在开发过程中,解决了一个又一个开发障碍,最终达到想要的效果,对这个产品实现有任何疑问都可以留言哈,我都会解答!对了,搜索小程序“小乐画板”,就可以体验这款小程序
来源:juejin.cn/post/7412505754382696448
人人都可配置的大屏可视化
大屏主要是为了展示数据和酷炫的效果,布局大部分是9宫格,或者在9宫格上做的延伸,现在介绍下 泛积木-低代码 提供的大屏可视化配置。
首先查看效果展示:泛积木-低代码大屏展示,泛积木-低代码大屏展示 此页面注册登录之后可编辑(会定期恢复至演示版本)。
创建页面之后,点击进入编辑页面,在可视化编辑器左侧组件往下翻,找到自定义组件中的 大屏布局组件 ,将 大屏布局组件 拖入页面,可以看到下面的成果:
拖入的 大屏布局组件 将使用基础配置,并且已经自带了缩放容器组件。
缩放容器组件
缩放容器组件主要用于适配不同的尺寸大小,实现原理:缩放容器组件是以该组件的宽度和设计稿的宽度求比例,然后等比例缩放。
缩放容器组件支持配置 设计稿宽度、设计稿高度、样式名称、背景颜色,当要适配不同尺寸的屏幕时,我们只需要修改 设计稿宽度、设计稿高度 为对应尺寸即可。样式名称是添加您需要设置的 样式 或添加唯一的className
,className
作用的元素将作为后续全屏按钮点击时全屏的元素。
全屏按钮组件
全屏按钮组件主要用于配置全屏按钮加全屏元素等。在全屏元素中配置 缩放容器组件 的 唯一className
。
全屏按钮组件还支持配置 样式名称、字体颜色、字体大小、间距。字体颜色未配置时,会默认从 大屏布局组件 的字体颜色继承。
说完上述两个小组件之后,我们再来说说关键的 大屏布局组件。
大屏布局组件
大屏布局组件的配置项可以概括为两部分:
- 总体配置:
- 总体内容:
- 样式名称;
- 字体颜色;
- 背景颜色;
- 背景图片(不想写链接,也可以直接上传);
- 是否显示头部;
- 模块样式模板;
- 样式覆盖;
- 页面内容:
- 样式名称;
- 内间距;
- 总体内容:
- 头部配置:
- 头部总体配置:
- 标题名称;
- 头部背景图片(支持上传);
- 样式名称;
- 头部左侧:
- 左侧内容;
- 样式名称;
- 头部右侧:
- 右侧内容;
- 样式名称;
- 头部时间:
- 是否显示;
- 字体大小;
- 显示格式。
- 头部总体配置:
样式覆盖 填入 css 之后,会自动在组件内创建 style
标签添加样式,这个时候需要使用 css 优先级去覆盖默认展示内容,例如:
.large-screen-layout .large-screen-layout-header {
height: 100px;
}
此时页面头部的高度将由默认的 80px 调整为 100px 。
头部背景图片 未设置时,头部高度默认为 80px ,设置之后,高度为背景图片按照宽度整体缩放之后的高度。
头部左/右侧内容 是配置 SchemaNode , SchemaNode 是指每一个 amis 配置节点的类型,支持模板、Schema(配置)以及SchemaArray(配置数组)三种类型。
例如:
{
...
"headerLeft": {
"type": "tpl",
"tpl": "公司名称",
"id": "u:3dc2c3411ae1"
},
"headerRight": {
"type": "fan-screenfull-button",
"targetClass": "largeScreenLayout",
"fontSize": "22px",
"id": "u:be46114da702"
},
...
}
模块样式模板 用于统一设置 大屏单块模板组件 的样式模板,样式模板是事先定义好的一些简单样式。
大屏单块模板组件
大屏单块模板组件 是用于配置大屏每块内容,大屏布局组件 和 大屏单块模板组件 之间还有一层 grid-2d 组件。
grid-2d 组件 是使用 grid 布局,支持配置 外层 Dom 的类名、格子划分、格子垂直高度、格子间距、格子行间距,建议 大屏布局组件 -> 总体配置 -> 页面内容 -> 内边距 和格子间距设置一致,格子划分 指定 划分为几列,格子间距统一设置横向和纵向的间距,格子行间距可以设置横向间距,优先级高于格子间距。
格子垂直高度 = (缩放容器组件的设计稿高度 - 大屏布局组件头部高度 - 大屏布局组件头部高度页面内容内边距 * 2 - (格子行间距 || 格子间距) * 2) / 3
例如默认的: (1080 - 80 - 20 * 2 - 20 * 2) / 3 = 306.667px
大屏单块模板组件 支持如下配置:
- 总体内容:
- 样式名称;
- 样式模板;
- 位置配置;
- 起始位置X;
- 起始位置Y;
- 宽度W;
- 高度H;
- 是否显示头部;
- 样式覆盖;
- 模块标题:
- 标题名称;
- 标题样式;
- 字体颜色;
- 模块头部右侧:
- 右侧内容;
- 样式名称;
- 模块内容:
- 样式名称;
- 内边距。
样式覆盖 填入 css 之后,会自动在组件内创建 style
标签添加样式,这个时候需要使用 css 优先级去覆盖默认展示内容,例如:
.fan-screen-card .fan-screen-card-header {
height: 80px;
}
此时模块头部的高度将由默认的 50px 调整为 80px 。 css 会作用于符合 css 的所有DOM元素,如果需要唯一设置,请在前面添加特殊的前缀,例如:
.fan-screen-card-1.fan-screen-card .fan-screen-card-header {
height: 80px;
}
样式模板 可单独设置每个模块的样式。
模块头部右侧内容 是配置 SchemaNode , SchemaNode 是指每一个 amis 配置节点的类型,支持模板、Schema(配置)以及SchemaArray(配置数组)三种类型。
位置配置 每项的值都是数值,比如默认的 9 宫格就是 3 * 3,此时设置的值就是 1/2/3 ,宽度1就代表一列,高度1就代表一行。可以调整初始位置、宽度、高度等配置出多种布局方式。
大屏单块模板内容首先嵌套 Service 功能型容器 用于获取数据,再使用 Chart 图表 进行图表渲染。
如果需要轮流高亮 Chart 图表的每个数据,例如 大屏动态展示 可以使用如下配置:
- 在 Chart 图表 上添加唯一的
className
; - 配置 Chart 图表的
config
; - 配置 Chart 图表的
dataFilter
。
dataFilter
:
const curFlag = 'lineCharts';
if (window.fanEchartsIntervals && window.fanEchartsIntervals.get(curFlag)) {
clearInterval(window.fanEchartsIntervals.get(curFlag)[0]);
window.fanEchartsIntervals.get(curFlag)[1] && window.fanEchartsIntervals.get(curFlag)[1].dispose();
}
const myChart = echarts.init(document.getElementsByClassName(curFlag)[0]);
let currentIndex = -1;
myChart.setOption({
...config,
series: [
{
...config.series[0],
data: data.line
}
]
});
const interval = setInterval(function () {
const dataLen = data.line.length;
// 取消之前高亮的图形
myChart.dispatchAction({
type: 'downplay',
seriesIndex: 0,
dataIndex: currentIndex
});
currentIndex = (currentIndex + 1) % dataLen;
// 高亮当前图形
myChart.dispatchAction({
type: 'highlight',
seriesIndex: 0,
dataIndex: currentIndex
});
// 显示 tooltip
myChart.dispatchAction({
type: 'showTip',
seriesIndex: 0,
dataIndex: currentIndex
});
}, 1000);
if (window.fanEchartsIntervals) {
window.fanEchartsIntervals.set(curFlag, [interval, myChart]);
} else {
window.fanEchartsIntervals = new Map();
window.fanEchartsIntervals.set(curFlag, [interval, myChart]);
}
return config;
修改高亮行 1 curFlag
设置为对应的 Chart 图表的 className
,12-17 行是插入数据,22-39 为对应数据的切换展示方式。
当添加第二个 大屏单块模板 时,直接把第一个复制一份,调整位置、service组件的接口、dataFilter配置等。
至此大屏就配置完成了。
更详细的使用文档可以查看 泛积木-低代码 。
来源:juejin.cn/post/7329767824200810534
前端实现:页面滚动时,元素缓慢上升效果
效果
实现方式
- 自定义指令
- 封装组件
两种方式均可以在SSR页面中使用
方式1:自定义指令实现
import Vue from 'vue';
const DISTANCE = 100; // y轴移动距离
const DURATION = 1000; // 动画持续时间
const THRESHOLD_FOR_TRIGGERING_ANIMATION = 0.1; // 当元素一部分可见时触发动画
const animationMap = new WeakMap();
function handleIntersection(entries, observer) { // IntersectionObserver 回调函数, 处理元素的可见性变化
for (const entry of entries) { // 遍历所有观察目标
if (entry.isIntersecting) { // 如果目标可见
const animation = animationMap.get(entry.target); // 获取动画对象
if (animation) {
animation.play(); // 播放动画
} else {
// 如果不支持 Web Animations API,则使用 CSS 动画回退方案
entry.target.classList.add('active');
}
observer.unobserve(entry.target); // 播放一次后停止监听
}
}
}
let ob;
if ('IntersectionObserver' in window) { // 如果浏览器支持 IntersectionObserver
ob = new IntersectionObserver(handleIntersection, { // 创建 IntersectionObserver 对象
threshold: THRESHOLD_FOR_TRIGGERING_ANIMATION // 当元素一部分可见时触发动画
});
} else {
// 回退机制:如果不支持 IntersectionObserver
ob = {
observe(el) { // IntersectionObserver 接口的 observe 方法
el.__onScroll__ = () => { // 监听元素的滚动事件
if (isInViewport(el)) { // 如果元素在视窗内
const animation = animationMap.get(el); // 获取动画对象
if (animation) {
animation.play();
} else {
// 如果不支持 Web Animations API,则使用 CSS 动画回退方案
el.classList.add('active');
}
window.removeEventListener('scroll', el.__onScroll__); // 停止监听
}
};
window.addEventListener('scroll', el.__onScroll__); // 监听元素的滚动事件
},
unobserve(el) { // IntersectionObserver 接口的 unobserve 方法
if (el.__onScroll__) { // 如果元素有滚动事件监听
window.removeEventListener('scroll', el.__onScroll__); // 停止监听
delete el.__onScroll__; // 清理引用
}
}
};
}
function isBelowViewport(el) { // 判断元素是否在视窗下方
const rect = el.getBoundingClientRect();
return rect.top > window.innerHeight;
}
function isInViewport(el) { // 判断元素是否在视窗内
const rect = el.getBoundingClientRect();
return rect.top < window.innerHeight && rect.bottom > 0;
}
const directive = {
name: 'slide-in',
inserted(el, binding) { // 元素插入到 DOM 时触发
if (!isBelowViewport(el)) { // 如果元素在视窗下方,则不执行动画
console.log('Element is not below viewport');
return;
}
const duration = binding.value && binding.value.duration ? binding.value.duration : DURATION; // 动画持续时间
const animationOptions = { // 动画选项: 目标位置、持续时间、缓动函数
duration: duration,
easing: binding.value && binding.value.easing ? binding.value.easing : 'ease'
};
// 检查是否支持 Web Animations API
let animation;
if (el.animate) { // 如果支持 Web Animations API
animation = el.animate([ // 创建动画对象
{
transform: `translateY(${DISTANCE}px)`,
opacity: 0.5
},
{
transform: 'translateY(0)',
opacity: 1
}
], animationOptions);
animation.pause(); // 初始化时暂停动画
animationMap.set(el, animation); // 保存动画对象
} else {
// 如果不支持 Web Animations API,则添加 CSS 动画回退类
el.classList.add('animate-fallback'); // animate-fallback在下面SCSS中有定义
}
ob.observe(el); // 开始监听元素的可见性变化
},
unbind(el) { // 元素从 DOM 中移除时触发
ob.unobserve(el); // 停止监听元素的可见性变化
}
};
Vue.directive(directive.name, directive);
注册指令
directives/index.js
import './slide-in' // 元素缓慢上升效果
main.js
import './directives'
在页面中使用
<template>
<div class="boxs .scroll-container">
<div class="slide-box" v-slide-in="{ duration: 500, easing: 'ease-in-out' }">0 - slide-directives</div>
<div class="slide-box" v-slide-in>1 - slide-directives</div>
<div class="slide-box" v-slide-in>2 - slide-directives</div>
<div v-slide-in>3 - slide-directives</div>
<div v-slide-in="{ duration: 500, easing: 'linear' }">4 - slide-directives</div>
<div v-slide-in>5 - slide-directives</div>
<div v-slide-in="{ duration: 500 }">6 - slide-directives</div>
</div>
</template>
<style lang="scss" scoped>
.boxs {
div {
text-align: center;
width: 800px;
height: 300px;
background-color: #f2f2f2;
margin: 0 auto;
margin-top: 20px;
}
}
<!-- 兼容性处理(可放到全局style中) -->
.animate-fallback {
opacity: 0;
transform: translateY(100px);
transition: transform 1s ease, opacity 1s ease;
}
.animate-fallback.active {
opacity: 1;
transform: translateY(0);
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(100px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fallback-keyframes {
opacity: 0;
animation: slideIn 1s ease forwards;
}
</style>
方式2: 封装为组件
<template>
<div ref="animatedElement" :style="computedStyle">
<slot></slot>
</div>
</template>
<script>
export default {
name: 'slideIn',
props: {
duration: { // 动画持续时间
type: Number,
default: 1000
},
easing: { // 动画缓动效果
type: String,
default: 'ease'
},
distance: { // 动画距离
type: Number,
default: 100
}
},
data() {
return {
hasAnimated: false // 是否已经动画过
}
},
computed: {
computedStyle() {
return {
opacity: this.hasAnimated ? 1 : 0,
transform: this.hasAnimated ? 'translateY(0)' : `translateY(${this.distance}px)`,
transition: `transform ${this.duration}ms ${this.easing}, opacity ${this.duration}ms ${this.easing}`
}
}
},
mounted() {
if (typeof window !== 'undefined' && 'IntersectionObserver' in window) { // 检测是否支持IntersectionObserver
this.createObserver() // 创建IntersectionObserver
} else {
// 如果不支持IntersectionObserver,则使用scroll事件来实现动画
this.observeScroll()
}
},
methods: {
createObserver() {
const observer = new IntersectionObserver(entries => { // IntersectionObserver回调函数
entries.forEach(entry => { // 遍历每个观察目标
if (entry.isIntersecting && !this.hasAnimated) { // 如果目标进入视口并且没有动画过
this.hasAnimated = true // 标记动画过
observer.unobserve(entry.target) // 停止观察
}
})
}, { threshold: 0.1 }) // 观察阈值,表示目标在视口的百分比
observer.observe(this.$refs.animatedElement) // 观察目标
},
observeScroll() {
const onScroll = () => { // scroll事件回调函数
if (this.isInViewport(this.$refs.animatedElement) && !this.hasAnimated) { // 如果目标在视口并且没有动画过
this.hasAnimated = true // 标记动画过
window.removeEventListener('scroll', onScroll) // 停止监听scroll事件
}
}
window.addEventListener('scroll', onScroll) // 监听scroll事件
},
isInViewport(el) { // 判断目标是否在视口
const rect = el.getBoundingClientRect()
return rect.top < window.innerHeight && rect.bottom > 0
}
}
}
</script>
页面使用
<div class="text-slide-in-vue">
<slide-comp v-for="(s ,idx) in list" :key="idx">
<p>{{ s.text }} - slide-comp</p>
</slide-comp>
</div>
<div class="level-slide">
<slide-comp v-for="(s, idx) in list" :key="idx" :duration="500 * idx + 500">
<p>{{ s.text }} - slide-comp</p>
</slide-comp>
</div>
<style>
.text-slide-in-vue {
p {
text-align: center;
width: 400px;
height: 200px;
background-color: goldenrod;
margin: 0 auto;
margin-top: 20px;
}
}
.level-slide {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 20px;
p {
text-align: center;
width: 200px;
height: 200px;
background-color: blueviolet;
margin: 0 auto;
margin-top: 20px;
}
}
</style>
来源:juejin.cn/post/7401042923490836480
手把手教你打造一个“蚊香”式加载
前言
这次给大家带来的是一个类似蚊香加载一样的效果,这个效果还是非常具有视觉欣赏性的,相比前几篇文章的CSS
特效,这一次的会比较震撼一点。
效果预览
从效果上看感觉像是一层层蚊香摞在一起,通过动画来使得它们达到3D金钟罩的效果。
HTML布局
首先我们通过15个span
子元素来实现金钟罩的每一层,用于创建基本结构。从专业术语上讲,每个span
元素都代表加载动画中的一个旋转的小点。通过添加多个span
元素,可以创建出一串连续旋转的小点,形成一个加载动画的效果。
<div class="loader">
<span></span>
// 以下省略15个span元素
</div>
CSS设计
完成了基本的结构布局,接下来就是为它设计CSS
样式了。我们一步一步来分析:
首先是类名为loader
的CSS
类,相关代码如下。
.loader{
position: relative;
width: 300px;
height: 300px;
transform-style: preserve-3d;
transform: perspective(500px) rotateX(60deg);
}
我们将元素的定位方式设置为相对定位,使其相对于其正常位置进行定位。然后定义好宽度和高度之后,设置元素的变换样式为preserve-3d
,这样可以元素的子元素也会受到3D变换的影响。除此之外,还需要transform
属性来设置元素的变换效果。这里的perspective(500px)
表示以500像素的视角来观察元素,rotateX(60deg)
则表示绕X
轴顺时针旋转60度。
这样就将一个宽高都定义好的元素进行了透视效果的3D旋转,使其以60度角度绕X
轴旋转。
loader
类可以理解为父容器,接下来就是loader
类中的子元素span
。
.loader span{
position: absolute;
display: block;
border: 5px solid #fff;
box-shadow: 0 5px 0 #ccc,
inset 0 5px 0 #ccc;
box-sizing: border-box;
border-radius: 50%;
animation: animate 3s ease-in-out infinite;
}
通过以上样式,我们可以创建一个圆形的动画效果,边框有阴影效果,并且以动画的方式不断旋转。关于CSS
部分大部分都是一样的,这里主要介绍一下这里定义的动画效果。名称为animate
,持续时间为3秒,缓动函数为ease-in-out
,并且动画无限循环播放。
@keyframes animate {
0%,100%{
transform: translateZ(-100px);
}
50%{
transform: translateZ(100px);
}
}
这是一个关键帧动画。关键帧是指动画在不同时间点上的状态或样式。首先该动画名为animate
,它包含了三个时间点的样式变化:
在0% 和100% 的时间点,元素通过transform: translateZ(-100px)
样式将在Z
轴上向后移动100像素,这将使元素远离视图。
在50% 的时间点,元素通过transform: translateZ(100px)
样式将在Z
轴上向前移动100像素。这将使元素靠近视图。
通过应用这个动画,span
元素将在动画的持续时间内以一定的速率来回移动,从而产生一个视觉上的动态效果。
最后就是单独为每个子元素span
赋予样式了。
.loader span:nth-child(1){
top: 0;
left: 0;
bottom: 0;
right: 0;
animation-delay: 1.4s;
}
.loader span:nth-child(2){
top: 10px;
left: 10px;
bottom: 10px;
right: 10px;
animation-delay: 1.3s;
}
......
以下省略到第15个span元素
第一个span
元素的样式设置了top、left、bottom和right
属性为0,这意味着它会填充父元素的整个空间。它还设置了animation-delay
属性为1.4秒,表示在加载动画开始之后1.4秒才开始播放动画。
后面14个span
元素都是按照这个道理,以此类推即可。通过给span
元素的动画延迟属性的不同设置,可以实现加载动画的错落感和流畅的过渡效果。
总结
以上就是整个效果的实现过程了,通过设计的动画来实现这个蚊香式加载,整体还是比较简单的。大家可以去码上掘金看看完整代码,然后自己去尝试一下,如果有什么创新的地方或者遇到了什么问题欢迎在评论区告诉我~
来源:juejin.cn/post/7291951762948259851
2024 年排名前 5 的 Node.js 后端框架
自 2009 年以来,Node.js 一直是人们谈论的话题,大多数后端开发人员都倾向于使用 Node.js。在过去的几年里,它的受欢迎程度有所增加。它被认为是美国最受欢迎的网络开发工具,包括 Netflix 和 PayPal 等客户。
受欢迎程度增加的原因是加载时间的减少和性能的提高。因此,分析 2024 年排名前 5 的 Node.js 后端框架至关重要。本文将介绍 2024 年排名前 5 的 Node.js 后端框架、它们的功能和常见用例。
Express.js:久经考验的冠军
Express.js 是 Node.js 最著名的后端框架之一。它是一个开源 Web 应用程序框架,可免费使用并构建在 Node.js 平台上。由于它是一个最小的框架,新手和经验丰富的 Web 开发人员都倾向于使用 Express.js。它主要用于创建 Web 应用程序和 RESTful API。
高效路由:Express.js 提供了一种干净、简单的方法来管理各种 HTTP 请求并将它们分配给特定任务,让我们看一个例子。
中间件支持:Express.js 允许中间件支持处理 HTTP 请求。让我们看一个创建用于记录 HTTP 请求详细信息的中间件的简单示例。
轻松的数据库集成:Express.js 与数据库无关。它不强制执行特定的数据库选择。开发者可以选择自己喜欢的数据库。将数据库与 Express.js 集成很容易,因为它具有模块化和灵活的特性,以及提供数据库连接的丰富的 npm 包生态系统。
简单易学:Express.js 以其简单和简约的设计而闻名,这使得开发人员很容易学习,特别是如果他们已经熟悉 JavaScript 和 Node.js。
另外,您可以使用 Bit 等工具轻松开始使用 Express.js 。如果您以前没有使用过 Bit,那么它是可组合软件的下一代构建系统。Express.js 本身本质上是可组合的,您可以在应用程序中的任何位置即插即用组件。
Nest.js:现代且结构化的方法
Nest.js 是一个以构建可扩展且高效的 Node.js 服务器端应用程序而闻名的框架。它使用渐进式 JavaScript,并具有用 TypeScript 编写代码的能力。尽管它完全支持 TypeScript,但它可以用纯 JavaScript 编写代码,包括面向对象编程、函数式编程和函数式响应式编程。
模块化:Nest.js 允许将代码分解为单独的可管理模块,从而使其更易于维护。让我们看一下下面的模块。
这个 PaymentModule 可以导出到其他模块。在此示例中,我们在该模块内导出了通用的缓存模块。由于 Nest.js 具有模块结构,因此易于维护。
可扩展:Nest.js 通过将应用程序分解为可管理的模块、支持灵活的组件替换以及通过微服务和异步操作容纳高流量来实现无缝扩展。它确保有效处理增加的工作量,同时保持可靠性。
依赖注入:依赖注入只是向类添加外部依赖项的方法,而不是在类本身中创建它。让我们看一个例子。
我们创建 PaymentService 并添加了 @Injectable()
注释以使其可注入。我们可以使用创建的服务,如下所示。
类型安全:Nest.js 使用 TypeScript 提供类型安全,可用于捕获开发过程中潜在的错误并提高代码的可维护性。
Koa.js:优雅且轻量
Koa.js 是一个更小、更具表现力的 Web 框架,也是由 Express.js 团队设计的。它允许您通过利用异步函数来放弃回调并处理错误。
上下文对象(ctx):Koa.js 包含一个名为 ctx 的功能来捕获请求和响应详细信息。该上下文被传递给每个中间件。在此示例中,我们从 ctx 对象记录了method 和 request。
中间件组成:与 Express.js 非常相似,Koa 支持处理 HTTP 请求和响应的中间件功能。在此示例中,我们创建了一个简单的中间件。
async/await 支持:Koa 使用 async/await 语法以更同步的方式编写异步代码。下面的示例包含使用 async/await 关键字。
Hapi.js
Hapi.js 是 Http-API 的缩写,是一个用于开发可扩展 Web 应用程序的开源框架。Hapi.js 最基本的用例之一是构建 REST API。沃尔玛实验室创建了 hapi js 来处理黑色星期五等活动的流量,黑色星期五是美国日历上在线购物最繁忙的日子之一。
配置驱动设计:使用配置对象,我们可以在 Hapi.js 中配置路由、设置和插件。
强大的插件系统:Hapi.js 允许轻松集成插件,让我们看一个例子。在这个例子中,我们集成了两个插件,可以使用 key 将选项传递给插件 options
。
认证与授权:Hapi.js 提供了对各种身份验证策略的内置支持,并允许开发人员轻松定义访问控制策略。
输入验证:输入验证是 Hapi.js 的另一个重要方面。在 options 路由的对象中,我们可以定义哪些输入需要验证。默认 validate 对象由以下值组成。
Adonis.js
Adonis.js 是 Node.js 的全功能 MVC 框架。它具有构建可扩展且可维护的应用程序的能力。 Adonis.js 遵循与 Laravel 类似的结构,并包含 ORM、身份验证和开箱即用的路由等功能。
全栈 MVC 框架:Adonis.js 遵循 MVC 架构模式。拥有 MVC 框架有助于组织代码并使其更易于维护和扩展。
数据库集成 ORM:Adonis.js 有自己的 ORM,称为 Lucid。 Lucid 提供了一个富有表现力的查询构建器并支持各种数据库系统。在 Lucid 中,我们可以创建模型来读取和写入数据库。让我们看下面的例子。
认证系统:Adonis.js 内置了对用户身份验证和授权的支持。它提供了一组用于处理用户会话、密码散列和访问控制的方法和中间件。
结论
2024年,上述后端框架在市场上屹立不倒。无论您选择 Express.js 是为了简单,Nest.js 是为了结构,Adonis.js 是为了生产力,还是 Koa.js 是为了优雅,选择正确的框架至关重要。这始终取决于您的要求。
了解您的项目需求至关重要,并在此基础上选择合适的框架。此外,寻找最新趋势、现有框架的新功能和新框架对于 2024 年后端开发之旅的成功至关重要。
来源:juejin.cn/post/7350581011262373928