一文搞懂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
uni-app微信小程序动态切换tabBar,根据不同用户角色展示不同的tabBar
前言
在UniApp的开发小程序过程中,为了针对不同角色用户登录后的个性化需求。通过动态权限配置机制,能够根据用户的角色展示不同的TabBar。此项目是通过Uni-App命令行的方式搭建的
Vue3+Vite+Ts+Pinia+Uni-ui
的小程序项目
最终效果
- 1、司机角色:
- 2、供应商角色:
- 3、司机且供应商角色:
目前常规的实现方式,大多数都是封装一个tabbar
组件,在需要显示tabbar的页面添加这个组件,在根据一个选中的index值来切换选中效果。
而我的实现方式:把所有有
tabbar
的页面全部引入在一个tabbarPage
页面,根据角色userType
,来动态显示页面
实现思路
1、常规登录:通过微信登录获取code
2、根据code获取openId
3、根据openId获取token,若token存在表:此用户已经登陆/绑定过,则根据token获取用户信息,根据角色直接进入项目页面;若token不存在,则跳转到登录页面
4、登录成功后,调用用户信息接口,根据角色直接进入项目页面
1、以下是封装了一个useLogin的hooks
export const useLogin = () => {
const { proxy } = getCurrentInstance() as any
//常规登录
const judgmentLogin = () => {
uni.login({
provider: 'weixin', //使用微信登录
success: async (loginRes) => {
// 根据微信登录的code获取openid
const res = await proxy.$api.getOpenid({ code: loginRes.code })
if (res.success) {
// console.log('res.data.openid----', res.data.openId)
// 根据openid获取token
openidLogin(res.data.openId)
// 存储openid
uni.setStorageSync('openId', res.data.openId)
}
}
});
}
// 登录过的用户再次进入根据openid获取token,有token则直接进入当前用户的页面,没有则进入登录页面
const openidLogin = (async (openId: string) => {
// console.log('openId----', openId)
const res = await proxy.$api.openIdLogin({ openId })
if (res.success) {
if (res.data) {
// 存储token
uni.setStorageSync('token', res.data)
userInfo(openId)
} else {
uni.navigateTo({
url: '/pages/login/login'
})
}
}
})
// 登录成功后(有token后)根据openid获取用户信息
const userInfo = (async (openId: any) => {
const res = await proxy.$api.getUserInfo({ openId })
if (res.success) {
console.log('获取登陆用户信息', res.data)
uni.setStorageSync('userInfo', JSON.stringify(res.data))
const userTypeList = ['scm_driver', 'scm_supplier', 'supplier_and_driver']
// 遍历角色数组来存储当前用户的角色。此角色为userTypeList中的某一个并且此数组只能存在一个userTypeList里面的角色,不会同时存在两个
res.data.roles.map((item: any) => {
if (userTypeList.includes(item.roleKey)) {
uni.setStorageSync('userType', item.roleKey)
}
})
// 判断角色数组中只要有一个角色在userTypeList中,则进入当前用户的角色页面,否则进入无权限页面
const flag = res.data.roles.some((item: any) => {
return userTypeList.includes(item.roleKey)
})
// console.log('flag----', flag)
if (flag && userTypeList.includes(uni.getStorageSync('userType'))) {
setTimeout(() => {
uni.reLaunch({
url: '/pages/tabbarPage/tabbarPage'
})
}, 500)
} else {
uni.showToast({
icon: 'none',
title: '当前用户角色没有权限!'
})
}
}
})
return {
judgmentLogin,
userInfo
}
}
2、修改page.json中的tabBar
"tabBar": {
"color": "#a6b9cb",
"selectedColor": "#355db4",
"list": [
{
"pagePath": "pages/supplierMyorder/supplierMyorder"
},
{
"pagePath": "pages/driverMyorder/driverMyorder"
},
{
"pagePath": "pages/mycar/mycar"
},
{
"pagePath": "pages/driverPersonal/driverPersonal"
}
]
},
3、关键页面tabbarPage.vue
<template>
<div class="tabbar_page flex-box flex-col">
<div
class="page_wrap"
v-if="userType === 'scm_supplier'"
v-show="active === 'supplierMyorder'"
>
<supplier-myorder
ref="supplierMyorder"
:show="active === 'supplierMyorder'"
/>
div>
<div
class="page_wrap"
v-if="userType === 'scm_supplier'"
v-show="active === 'supplierPersonal'"
>
<supplier-personal
ref="supplierPersonal"
:show="active === 'supplierPersonal'"
/>
div>
<div
class="page_wrap"
v-if="userType === 'scm_driver'"
v-show="active === 'driverMyorder'"
>
<driver-myorder ref="driverMyorder" :show="active === 'driverMyorder'" />
div>
<div
class="page_wrap"
v-if="userType === 'scm_driver'"
v-show="active === 'mycar'"
>
<mycar ref="mycar" :show="active === 'mycar'" />
div>
<div
class="page_wrap"
v-if="userType === 'scm_driver'"
v-show="active === 'driverPersonal'"
>
<driver-personal
ref="driverPersonal"
:show="active === 'driverPersonal'"
/>
div>
<div
class="page_wrap"
v-if="userType === 'supplier_and_driver'"
v-show="active === 'supplierMyorder'"
>
<supplier-myorder
ref="supplierMyorder"
:show="active === 'supplierMyorder'"
/>
div>
<div
class="page_wrap"
v-if="userType === 'supplier_and_driver'"
v-show="active === 'driverMyorder'"
>
<driver-myorder ref="driverMyorder" :show="active === 'driverMyorder'" />
div>
<div
class="page_wrap"
v-if="userType === 'supplier_and_driver'"
v-show="active === 'mycar'"
>
<mycar ref="mycar" :show="active === 'mycar'" />
div>
<div
class="page_wrap"
v-if="userType === 'supplier_and_driver'"
v-show="active === 'supplierPersonal'"
>
<supplier-personal
ref="supplierPersonal"
:show="active === 'supplierPersonal'"
/>
div>
<view class="tab">
<view
v-for="(item, index) in tabbarOptions"
:key="index"
class="tab-item"
@click="switchTab(item, index)"
>
<image
class="tab_img"
:src="currentIndex == index ? item.selectedIconPath : item.iconPath"
>image>
<view
class="tab_text"
:style="{ color: currentIndex == index ? selectedColor : color }"
>{{ item.text }}
view>
view>
div>
template>
<script lang="ts" setup>
import supplierMyorder from '@/pages/supplierMyorder/supplierMyorder.vue'
import supplierPersonal from '@/pages/supplierPersonal/supplierPersonal.vue'
import driverMyorder from '@/pages/driverMyorder/driverMyorder.vue'
import mycar from '@/pages/mycar/mycar.vue'
import driverPersonal from '@/pages/driverPersonal/driverPersonal.vue'
let color = ref('#666666')
let selectedColor = ref('#355db4')
let currentIndex = ref(0)
const active = ref('')
const switchTab = (item: any, index: any) => {
// console.log('tabbar----switchTab-----list', item, index)
currentIndex.value = index
active.value = item.name
}
onLoad((option: any) => {
currentIndex.value = option.index || 0
active.value = option.name || tabbarOptions.value[0].name
})
onShow(() => {
active.value = active.value || tabbarOptions.value[0].name
currentIndex.value = currentIndex.value || 0
})
const userType = computed(() => {
return uni.getStorageSync('userType')
})
const tabbarOptions = computed(() => {
return {
scm_supplier: [
{
name: 'supplierMyorder',
pagePath: '/pages/supplierMyorder/supplierMyorder',
iconPath: '/static/tabbar/order.png',
selectedIconPath: '/static/tabbar/order_active.png',
text: '我的订单'
},
{
name: 'supplierPersonal',
pagePath: '/pages/supplierPersonal/supplierPersonal',
iconPath: '/static/tabbar/my.png',
selectedIconPath: '/static/tabbar/my_active.png',
text: '个人中心'
}
],
scm_driver: [
{
name: 'driverMyorder',
pagePath: '/pages/driverMyorder/driverMyorder',
iconPath: '/static/tabbar/waybill.png',
selectedIconPath: '/static/tabbar/waybill_active.png',
text: '我的运单'
},
{
name: 'mycar',
pagePath: '/pages/mycar/mycar',
iconPath: '/static/tabbar/car.png',
selectedIconPath: '/static/tabbar/car_active.png',
text: '我的车辆'
},
{
name: 'driverPersonal',
pagePath: '/pages/driverPersonal/driverPersonal',
iconPath: '/static/tabbar/my.png',
selectedIconPath: '/static/tabbar/my_active.png',
text: '个人中心'
}
],
supplier_and_driver: [
{
name: 'supplierMyorder',
pagePath: '/pages/supplierMyorder/supplierMyorder',
iconPath: '/static/tabbar/order.png',
selectedIconPath: '/static/tabbar/order_active.png',
text: '我的订单'
},
{
name: 'driverMyorder',
pagePath: '/pages/driverMyorder/driverMyorder',
iconPath: '/static/tabbar/order.png',
selectedIconPath: '/static/tabbar/order_active.png',
text: '我的运单'
},
{
name: 'mycar',
pagePath: '/pages/mycar/mycar',
iconPath: '/static/tabbar/car.png',
selectedIconPath: '/static/tabbar/car_active.png',
text: '我的车辆'
},
{
name: 'supplierPersonal',
pagePath: '/pages/supplierPersonal/supplierPersonal',
iconPath: '/static/tabbar/my.png',
selectedIconPath: '/static/tabbar/my_active.png',
text: '个人中心'
}
]
}[userType.value]
})
script>
<style lang="scss" scoped>
.tabbar_page {
height: 100%;
.page_wrap {
height: calc(100% - 84px);
&.hidden {
display: none;
}
}
.tab {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 100rpx;
background: white;
display: flex;
justify-content: center;
align-items: center;
padding-bottom: env(safe-area-inset-bottom); // 适配iphoneX的底部
.tab-item {
flex: 1;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
.tab_img {
width: 45rpx;
height: 45rpx;
}
.tab_text {
font-size: 25rpx;
margin: 9rpx 0;
}
}
}
}
.flex-box {
display: -webkit-box;
display: -webkit-flex;
display: flex;
}
.flex-col {
flex-direction: column
}
style>
来源:juejin.cn/post/7372366198099886090
聊聊 CSS 的 ::marker
::marker
是一个 CSS 的另一个伪元素,有点类似于 CSS 的 ::before
和 ::after
伪元素。只不过,它常用于给列表标记框定制样式。简而言之,使用::marker
伪元素,可以对列表做一些有趣的事情,在本文中,我们将深入的聊聊该伪元素。
初识 CSS 的 ::marker
::marker
是 CSS 的伪元素,现在被纳入到 CSS Lists Module Level 3 规范中。在该规范中涵盖了列表和计算数器相关的属性,比如我们熟悉的list-style-type
、list-style-position
、list-style
、list-item
、counter-increment、counter-reset、counter()和counters()
等属性。
在 CSS 中 display 设置 list-item
值之后就会生成一个 Markers 标记以及控制标记位置和样式的几个属性,而且还定义了计数器(计数器是一种特殊的数值对象),而且该计数器通常用于生成标记(Markers)的默认内容。
一时之间,估计大家对于Markers标记并不熟悉,但对于一个列表所涉及到的相关属性应该较为熟悉,对于一个CSS List,它可以涵盖了下图所涉及到的相关属性:
如果你对CSS List中所涉及到的属性不是很了解的话,可以暂时忽略,随着后续的知识,你会越来越清楚的。
解构一个列表
虽然我们在 Web 的制作中经常会用到列表,但大家可能不会过多的考虑列表相关的属性或使用。就 HTML语义化出发,如果遇到无序列表的时候会使用
- ,遇到有序列表的时候会使用
- 非列表项
li
元素需要显式的设置display:list-item
(内联列表项需要使用display: inline list-item
) - 需要显式设置
list-style-type
为none
- 使用
content
添加内容(也可以通过attr()
配合data-*
来添加内容) counter-reset
:设置一个计数器,定义计数器名称,用来标识计数器作用域counter-set
:将计数器设置为给定的值。它操作现有计数器的值,并且只在元素上还没有给定名称的计数器时才创建新的计数器counter-increment
:用来标识计数器与实际关联元素范围,可接受两个值,第一个值必须是counter-reset
定义的标识符,第二个值是可选值,是一个整数值(正负值都可以),用来预设递增的值counter()
:主要配合content
一起使用,用来调用定义好的计数器标识符counters()
:支持嵌套计数器,如果有指定计数器的当前值,则返回一个表示这些计数器的当前值的串联字符串。counters()
有两种形式counters(name, string)
和counters(name, string, style)
。通常和伪元素一起使用,但理论上可以支持
值的任何地方使用- 调整HTML结构
- 伪元素
::before
和content
- 伪元素
::marker
和content
,但在有些场景(或不追求语义化的同学)会采用其他的标签元素,比如说
。针对这个场景,会采用 display
设置为list-item
。如此一来会创建一个块级别的框,以及一个附加的标记框。同时也会自动增加一个隐含列表项计算数器。ul
和 ol
元素默认情况之下会带有list-style-type
、list-style-image
和list-style-position
属性,可以用来设置列表项的标记样式。同样的,带有display:list-item
的元素生成的标记框,也可以使用这几个属性来设置标记项样式。
list-style-type
的属性有很多个值:
取值不同时,列表符号(也就是Marker标识符)会有不同的效果,比如下面这个示例所示:
Demo 地址:codepen.io/airen/full/…
在 CSS 中给列表项设置类型的样式风格可以通过 list-style-type
和 list-style-image
来实现,但这两个属性设置列表项的样式风格会有所限制。比如要实现类似下图的列表项样式风格:
值得庆幸的是,CSS 的 ::marker
给予我们更大的灵活性,可以让我们实现更多的列表样式风格,而且灵活性也更大。
创建 marker 标记框
HTML 中的 ul
和 ol
元素会自动创建 marker
标记框。如果通过浏览器调试器来查看的话,你会发现,不管是 ul
还是 ol
的子元素 li
会自带 display:list-item
属性设计(客户端默认属性),另外会带有一个默认的list-style-type
样式设置:
这样一来,它自身就默认创建了一个 marker
标记框,同时我们可以通过 ::marker
伪元素来设置列表项的样式风格,比如下面这个示例:
ul ::marker,
ol ::marker {
font-size: 200%;
color: #00b7a8;
font-family: "Comic Sans MS", cursive, sans-serif;
}
你会看到效果如下所示:
Demo 地址:codepen.io/airen/full/…
对于非列表元素,可以通过display: list-item
来创建 Marker 标记,这样就可以在元素上使用 ::marker
伪元素来设置项目符号的样式。虽然通过display:list-item
在形式上看上去像列表项,但在语义化上并没有起到任何的作用。
在深入探讨 ::marker
使用之前,大家要知道,元素必须要具备一个Marker标记框,对于非列表项的元素需要显式的使用 display:list-item
来创建Marker标记框。
CSS的display属性是一个非常重要的属性,现在被纳入在CSS Display Module Level 3中。CSS的display
属性可以改变任何一个元素的框模型。而且在Level 3规范中给display
引用了两个值的语法,比如使用display: inline list-item
可以创建一个内联列表项。
::marker
的基本使用
前面的小示例中,其实我们已经领略到了::marker
的魅力。在列表项li
中,其实已经带有Marker标记框,可以借助::marker
伪元素来设置列表标记的样式。
我们先来回忆一下,CSS的::marker
还未出现(或者说不支持的浏览器)时,要对列表项设置不同的样式,都是通过li
上来控制(看上去继承了li
上的样式)。虽然能设置列表样式,但还是具有一定的局限性,灵活度不够大 —— 特别是当列表项标记样式和内容要区分时。
CSS的::marker
会让我们变得容易的多。从前面的示例中我们可以了解到, ::marker
伪元素和列表项内容是分开的,正因此,我们可以独立为两个部分设计不同的样式。这在以前的CSS版本中是不可能的(除非借助::before
伪元素来模拟,稍后也会介绍这一部分)。比如说,我们更改ul
或li
的color
或font-size
时也会更改标记的color
和font-size
。为了达到两者的区分,往往需要在HTML中做一些结构上的调整,比如列表项用一个子元素来包裹(比如span
元素或::before
伪元素)。
更了大家更易于理解::marker
的作用,我们在上面的示例基础上做一些调整:
.box:nth-child(odd) li {
font-size: 200%;
color: #00b7a8;
font-family: "Comic Sans MS", cursive, sans-serif;
}
.box:nth-child(even) ::marker {
font-size: 200%;
color: #00b7a8;
font-family: "Comic Sans MS", cursive, sans-serif;
}
代码中的具体作用不做介绍,很简单的代码,但效果却有很大的差异性,如下图所示:
很神奇吧!在浏览器中查看源码,你会发现使用::marker
和未使用::marker
的差异性:
虽然::marker
易于帮助我们控制标记的样式风格,但有一点需要特别注意,如果显式的设置了list-style-type: none
时,::marker
标记内容就会丢失不可见。在这个时候,不管是否显式的设置了::marker
的样式都将会看不到。比如:
大家是否还记得,在::marker
还没有出现之前,要对列表项设置别的标记符,比如Emoji。我们就需要通过别的方式来完成,最为常见的是修改HTML的结构,或者借助CSS伪元素::before
和CSS的content
属性,例如下面这个示例:
Demo 地址:codepen.io/airen/full/…
事实上,CSS的::marker
和伪元素::before
类似,也可以通过content
和attr()
一起来控制Marker标记的效果。需要记住,生成个性化Marker标记内容需要做到几下几点:
来看一个小示例:
li::marker {
content: attr(data-emoji);
}
::marker
伪元素自从可以使用content
来添加内容之后,让我们可操作的空间更大了。对于列表标记(即,带有Marker标记)的元素再也不需要额外的通过::before
伪元素和content
来生成标记内容。而且,我们还可以结合计算数器相关的特性,让列表标记可造性空间更大。如果你感兴趣的话,请继续往下阅读。
::marker
与计数器的结合
对于无序列表,或者说统一使用同样的标记符,那么::marker
和content
结合就可以解决。但是如果面对的是一个有顺列表,那么我们就需要用到CSS计数器的相关特性。
先来回忆一下CSS的计数器相关的特性。在CSS中计数器有三个属性:
以及两个相关的函数:
一般情况之下,
counter-reset
、counter-increment
和counter()
即可满足一个计数器的需求。
CSS的计数器使用非常的简单。在元素的父元素上显式设置:
body {
counter-reset: section
}
使用counter-reset
声明了一个计数器标识符叫section
。然后再需要使用计算器的元素上(一般配合伪元素::before
)使用counter-increment
来调用counter-reset
已声明的计数器标识符,然后使用counter(section)
来计数:
h3::before {
counter-increment: section
content: "Section " counter(section) ": "
}
下图会更详尽一些,把计数器可能会用的值都罗列出来了,可供参考:
回到我们的列表设置中来。::marker
还没有得到浏览器支持之前,一般都是使用CSS的计数器来实现一些带有个性化的有顺序列表,比如下面这样的效果:
也可以借助计数器做一些其他的效果比如:
Demo 地址:codepen.io/snookca/ful…
更为厉害的是,CSS的计数器配合复选框或单选按钮还可以做一些小游戏,比如 @una教程中向我们展示的一个效果:
Demo 地址:codepen.io/jak_e/full/…
@kizmarh使用同样的原理,做了一个黑白棋的小游戏:
是不是很有意思,有关于CSS计数器相关的特性暂且搁置。我们回到::marker
的世界中来。
::marker
配合content
可以定制个性化Marker标记风格。借助CSS计数器,可以更轻易的构建带有顺序的Marker标记。同样可以让Marker标记和内容分离。更易于实现可定制化的样式风格。
接下来,我们来看一个简单的示例,看看::marker
生成的标记符和以往生成的标记符效果上有何差异没。
结果很简单,这里使用的是一个无序列表:
<ul>
<li>
Item1
<ul>
<li>Item 1-1li>
<li>Item 1-2li>
<li>Item 1-3li>
ul>
li>
<li>Item2li>
<li>Item3li>
<li>Item4li>
<li>Item5li>
ul>
你可以根据自己的爱好来选择标签元素。先来看::before
和content
配合counter()
和counters()
的一个效果:
/* counter() */
.box:nth-child(1) {
ul {
counter-reset: item;
}
li {
counter-increment: item;
&::before{
content: counter(item);
/* ... */
}
}
}
/* counters() */
.box:nth-child(2) {
ul {
counter-reset: item;
}
li {
counter-increment: item;
&::before{
content: counters(item, '.');
/* ... */
}
}
}
对于上面的效果,大家可能也猜到了。我们再来看一下::marker
的使用:
/* counter() */
.box:nth-child(3) {
ul {
counter-reset: item;
}
li {
counter-increment: item;
}
::marker {
content: counter(item);
/* ... */
}
}
/* counters() */
.box:nth-child(4) {
ul {
counter-reset: item;
}
li {
counter-increment: item;
}
::marker {
content: counters(item, '.');
/* ... */
}
}
可以看到::marker
和前面::before
效果是一样的:
另外使用::marker
还有一特殊之处。不管是列表元素还是设置了display:list-item
的非列表元素,不需要显式的使用counter-reset
声明计数器标识符,也无需使用counter-increment
调用已声明的计数器标识符。它可以直接在 ::marker
伪元素的 content
中使用 counter(list-item)
或 counters(list-item, '.')
。
但是非列表元素,哪怕是设置了display:list-item
,直接在::marker
的content
中使用counters(list-item, '.')
所起的效果和我们预期的有所不同。如果在非列表元素的::marker
的content
中使用counters()
达到我们想要的效果,需要使counter-reset
先声明计数器标识符,然后counter-increment
调用已声明的计数器标识符(回归到以前::before
的使用)。具本的可以看下面的示例代码:
::marker {
content: counter(list-item);
padding: 5px 30px 5px 12px;
background: linear-gradient(to right, #f36, #f09);
font-size: 2rem;
clip-path: polygon(0% 0%, 75% 0, 75% 51%, 100% 52%, 75% 65%, 75% 100%, 0 100%);
border-radius: 5px;
color: #fff;
text-shadow: 1px 1px 1px rgba(#09f, .5);
}
.box:nth-child(2n) ::marker {
content: counters(list-item, '.');
}
.box:nth-child(3) {
section {
counter-reset: item;
}
article {
counter-increment: item;
}
::marker {
content: counters(item, '.');
}
}
具体效果如下:
是不是觉得::marker
非常有意思,特别是给元素添加Marker标记的时候。换句话说,就是在定制个性化列表符号时,使用::marker
伪元素要比::before
之类的较为方便。而且::marker
是元素原生的列表标记符(::marker
)。
一旦::marker
伪元素得到所有浏览器支持之后,我们要让列表标记符和内容分离就会多了一种方案:
前面也向大家展示了,::marker
也可以像::before
一样,借助CSS计数器属性,可以更好的实现有序列表,甚至是嵌套的列表。
写在最后
虽然 ::marker
的出现允许我们为列表标记定制样式,但它也有一定的限制性,至少到目前为止是这样。比如,我们在 ::marker
伪元素上可控样式还是有限,要实现下面这样的个性化效果是不可能的:
庆幸的是,CSS 中除了 ::marker
伪元素之外,还可以使用 ::before
或 ::after
来生成内容,然后通过 CSS 来实现更具个性化的列表标记样式。
来源:juejin.cn/post/7358348786843959336
用Three.js搞个炫酷雷达扩散和扫描特效
1.画点建筑模型
添加光照,开启阴影
//开启renderer阴影
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
//设置环境光
const light = new THREE.AmbientLight(0xffffff, 0.6); // soft white light
this.scene.add(light);
//夜晚天空蓝色,假设成蓝色的平行光
const dirLight = new THREE.DirectionalLight(0x0000ff, 3);
dirLight.position.set(50, 50, 50);
this.scene.add(dirLight);
平行光设置阴影
//开启阴影
dirLight.castShadow = true;
//阴影相机范围
dirLight.shadow.camera.top = 100;
dirLight.shadow.camera.bottom = -100;
dirLight.shadow.camera.left = -100;
dirLight.shadow.camera.right = 100;
//阴影影相机远近
dirLight.shadow.camera.near = 1;
dirLight.shadow.camera.far = 200;
//阴影贴图大小
dirLight.shadow.mapSize.set(1024, 1024);
- 平行光的阴影相机跟正交相机一样,因为平行光的光线是平行的,就跟视线是平行一样,切割出合适的阴影视角范围,用于计算阴影。
- shadow.mapSize设置阴影贴图的宽度和高度,值越高,阴影的质量越好,但要花费计算时间更多。
增加建筑
//添加一个平面
const pg = new THREE.PlaneGeometry(100, 100);
//一定要用受光材质才有阴影效果
const pm = new THREE.MeshStandardMaterial({
color: new THREE.Color('gray'),
transparent: true,//开启透明
side: THREE.FrontSide//只有渲染前面
});
const plane = new THREE.Mesh(pg, pm);
plane.rotateX(-Math.PI * 0.5);
plane.receiveShadow = true;//平面接收阴影
this.scene.add(plane);
//随机生成建筑
this.geometries = [];
const helper = new THREE.Object3D();
for (let i = 0; i < 100; i++) {
const h = Math.round(Math.random() * 15) + 5;
const x = Math.round(Math.random() * 50);
const y = Math.round(Math.random() * 50);
helper.position.set((x % 2 ? -1 : 1) * x, h * 0.5, (y % 2 ? -1 : 1) * y);
const geometry = new THREE.BoxGeometry(5, h, 5);
helper.updateWorldMatrix(true, false);
geometry.applyMatrix4(helper.matrixWorld);
this.geometries.push(geometry);
}
//长方体合成一个形状
const mergedGeometry = BufferGeometryUtils.mergeGeometries(this.geometries, false);
//建筑贴图
const texture = new THREE.TextureLoader().load('assets/image.jpg');
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
const material = new THREE.MeshStandardMaterial({ map: texture,transparent: true });
const cube = new THREE.Mesh(mergedGeometry, material);
//形状产生阴影
cube.castShadow = true;
//形状接收阴影
cube.receiveShadow = true;
this.scene.add(cube);
效果就是很多高楼大厦的样子,为什么楼顶有窗?别在意这些细节,有的人就喜欢开天窗呢~
2.搞个雷达扩散和扫描特效
改变建筑材质shader,计算建筑的俯视uv
material.onBeforeCompile = (shader, render) => {
this.shaders.push(shader);
//范围大小
shader.uniforms.uSize = { value: 50 };
shader.uniforms.uTime = { value: 0 };
//修改顶点着色器
shader.vertexShader = shader.vertexShader.replace(
'void main() {',
` uniform float uSize;
varying vec2 vUv;
void main() {`
);
shader.vertexShader = shader.vertexShader.replace(
'#include <fog_vertex>',
`#include <fog_vertex>
//计算相对于原点的俯视uv
vUv=position.xz/uSize;`
);
//修改片元着色器
shader.fragmentShader = shader.fragmentShader.replace(
'void main() {',
`varying vec2 vUv;
uniform float uTime;
void main() {`
);
shader.fragmentShader = shader.fragmentShader.replace(
'#include <dithering_fragment>',
`#include <dithering_fragment>
//渐变颜色叠加
gl_FragColor.rgb=gl_FragColor.rgb+mix(vec3(0,0.5,0.5),vec3(1,1,0),vUv.y);`
);
};
然后你将同样的onBeforeCompile函数赋值给平面的时候,没有对应的效果。
因为平面没有z,只有xy,而且经过了-90度旋转后,坐标位置也要对应反转,由此可以得出平面的uv计算公式
vUv=vec2(position.x,-position.y)/uSize;
至此,建筑和平面的俯视uv一致了。
雷达扩散特效
- 雷达扩散就是一段渐变的环,随着时间扩大。
- 顶点着色器不变,改一下片元着色器,增加扩散环颜色uColor,对应shader.uniforms也要添加
shader.uniforms.uColor = { value: new THREE.Color('#00FFFF') };
const fragmentShader1 = `varying vec2 vUv;
uniform float uTime;
uniform vec3 uColor;
uniform float uSize;
void main() {`;
const fragmentShader2 = `#include <dithering_fragment>
//计算与中心的距离
float d=length(vUv);
if(d >= uTime&&d<=uTime+ 0.1) {
//扩散圈
gl_FragColor.rgb = gl_FragColor.rgb+mix(uColor,gl_FragColor.rgb,1.0-(d-uTime)*10.0 )*0.5 ;
}`;
shader.fragmentShader = shader.fragmentShader.replace('void main() {', fragmentShader1);
shader.fragmentShader = shader.fragmentShader.replace(
'#include <dithering_fragment>',
fragmentShader2);
//改变shader的时间变量,动起来
animateAction() {
if (this.shaders?.length) {
this.shaders.forEach((shader) => {
shader.uniforms.uTime.value += 0.005;
if (shader.uniforms.uTime.value >= 1) {
shader.uniforms.uTime.value = 0;
}
});
}
}
噔噔噔噔,完成啦!是立体化的雷达扩散,看起来很酷的样子。
雷达扫描特效
跟上面雷达扩散差不多,只要修改一下片元着色器
- 雷达扫描是通过扇形渐变形成的,还要随着时间旋转角度
const fragmentShader1 = `varying vec2 vUv;
uniform float uTime;
uniform vec3 uColor;
uniform float uSize;
//旋转角度矩阵
mat2 rotate2d(float angle)
{
return mat2(cos(angle), - sin(angle),
sin(angle), cos(angle));
}
//雷达扫描渐变扇形
float vertical_line(in vec2 uv)
{
if (uv.y > 0.0 && length(uv) < 1.2)
{
float theta = mod(180.0 * atan(uv.y, uv.x)/3.14, 360.0);
float gradient = clamp(1.0-theta/90.0,0.0,1.0);
return 0.5 * gradient;
}
return 0.0;
}
void main() {`;
const fragmentShader2 = `#include <dithering_fragment>
mat2 rotation_matrix = rotate2d(- uTime*PI*2.0);
//将雷达扫描扇形渐变混合到颜色中
gl_FragColor.rgb= mix( gl_FragColor.rgb, uColor, vertical_line(rotation_matrix * vUv)); `;
GitHub地址
来源:juejin.cn/post/7349837128508964873
震惊!🐿浏览器居然下毒!
发生什么事了
某天,我正在愉快的摸鱼,然后我看到测试给我发了条消息,说我们这个系统在UC浏览器中有问题,没办法操作,点了经常没反应(测试用得iPhone14,是一个h5的项目)。我直接懵了,这不是都测了好久了吗,虽然不是在uc上测得,chrome、safari、自带浏览器等,都没这个问题,代码应该是没问题的,uc上为啥会没有反应呢?难道是有什么隐藏的bug,需要一定的操作顺序才能触发?我就去找了测试,让他重新操作一下,看看是啥样的没反应。结果就是,正常进入列表页(首页),正常点某一项,正常进入详情页,然后点左上角返回,没反应。我上手试了下,确实,打开vconsole看了下,也没有报错。在uc上看起来还是必现的,我都麻了,这能是啥引起的啊。
找问题
在其他浏览器上都是好好的,uc上也不报错,完全看不出来代码有啥bug,完全没有头绪啊!那怎么办,刷新看看:遇事不决,先刷新
,还不行就清空缓存刷新
。刷新了之后,哎,好了!虽然不知道是什么问题,但现在已经好了,就当作遇到了灵异事件,我就去做其他事了。
过了一会,测试来找我了,说又出现了,不止是详情页,进其他页面也返回不了。这就难受住了呀,说明肯定是有问题的,只是还没找到原因。我就只好打开vconsole,一遍一遍的进入详情页点返回;刷新再进;清掉缓存,再进。
然后,我就发现,network中,出现了一个没有见过的请求
根据track、collect
这些单词来判断,这应该是uc在跟踪、记录某些操作,requestType还是个ping;我就在想,难道是这个请求的问题?但是请求为啥会导致,我页面跳转产生问题?然后我又看到了intercept(拦截)、pushState(history添加记录)
,拦截了pushState?
这个项目确实使用的是history路由,问题也确实出在路由跳转的时候;而且出现问题的时候,路由跳转,浏览器地址栏中的地址是没有变化的,返回就g了(看起来是后退没有反应,实际是前进时G了)
。这样看,uc确实拦截了pushState的操作。那它是咋做到的?
原来如此
然后,我想起来,前段时间在掘金上看到了一篇,讲某些第三方cdn投毒的事情,那么uc是不是在我们不知情的情况下,改了我们的代码。然后我切到了vconsole的element,展开head,发现了一个不属于我们项目的script,外链引入了一段js,就挂在head的最顶上。通过阅读,发现它在window的history上加了点料,覆写了forward和pushState(forward和pushState是继承来的方法)
正常的history应该是这样:
复写的类似这样:
当然,有些系统或框架,为了实现某些功能,比如实现触发popstate的效果,也会复写
但uc是纯纯的为了记录你的操作,它这玩意主要还有bug,会导致路由跳转出问题,真是闹麻了
如何做
删掉就好了,只要删掉uc添加的,当我们调用相关方法时,history就会去继承里找
// 判断是否是uc浏览器
if (navigator.userAgent.indexOf('UCBrowser') > -1) {
if (history.hasOwnProperty('pushState')) {
delete window.history.forward
delete window.history.pushState
}
// 找到注入的script
const ucScript = document.querySelector('script[src*="ucbrowser_script"]')
if (ucScript) {
document.head.removeChild(ucScript)}
}
}
吐槽
你说你一个搞浏览器的,就不能在底层去记录用户行为吗,还不容易被发现。主要是你这玩意它有bug呀,这不是更容易被发现吗。(这是23年11月份遇到的问题,当时产品要求在qq/百度/uc这些浏览器上也都测一下才发现的,现在记录一下,希望能帮助到其他同学)
来源:juejin.cn/post/7411358506048766006
CSS 终于在 2024 年增加了垂直居中功能
本文翻译自 CSS finally adds vertical centering in 2024,作者:James Smith, 略有删改。
在 2024 年的 CSS 原生属性中允许使用 1 个 CSS 属性 align-content: center
进行垂直居中。
<div style="align-content: center; height: 100px;">
<code>align-content</code> 就是这么简单!
</div>
支持情况:
Chrome: 123 | Firefox: 125 | Safari: 17.4 |
---|
CSS 对齐一般会使用 flexbox
或 grid
布局,因为 align-content
在默认的流式布局中不起作用。在 2024 年,浏览器实现了 align-content
。
- 你不需要 flexbox 或 grid,只需要 1 个 CSS 属性就可以进行对齐。
- 因此内容不需要包裹在 div 中。
<!-- 有效 -->
<div style="display: grid; align-content: center;">
内容。
</div>
<!-- 失败!-->
<div style="display: grid; align-content: center;">
包含 <em>多个</em> 节点的内容。
</div>
<!-- 包装div有效 -->
<div style="display: grid; align-content: center;">
<div> <!-- 额外的包装器 -->
包含 <em>多个</em> 节点的内容。
</div>
</div>
<!-- 无需包装div即可工作 -->
<div style="align-content: center;">
包含 <em>多个</em> 节点的内容。
</div>
令人惊讶的是,经过几十年的发展,CSS 终于有了 一个属性 来控制垂直对齐!
垂直居中的历史
浏览器很有趣,像对齐这样的基本需求长期以来都没有简单的答案。以下是在浏览器中垂直居中的方法(水平居中是另一个话题):
方法 1: 表格单元格
星级:★★★☆☆
有 4 种主要布局:流(默认)、表格、flexbox、grid。如何对齐取决于容器的布局。Flexbox 和 grid 相对较晚添加,所以表格是第一种方式。
<div style="display: table;">
<div style="display: table-cell; vertical-align: middle;">
内容。
</div>
</div>
方法 2: 绝对定位
星级:☆☆☆☆☆
通过绝对定位间接的方式来实现这个效果。
<div style="position: relative;">
<div style="position: absolute; top: 50%; transform: translateY(-50%);">
内容。
</div>
</div>
这个方式通过绝对定位来绕过流式布局:
- 用
position: relative
标记参考容器。 - 用
position: absolute; top: 50%
将内容的边缘放置在中心。 - 用
transform: translateY(-50%)
将内容中心偏移到边缘。
方法 3: 内联内容
星级:☆☆☆☆☆
虽然流布局对内容对齐没有帮助。它允许在一行内进行垂直对齐。那么为什么不使一行和容器一样高呢?
<div class="container">
::before
<div class="content">内容。</div>
</div>
.container::before {
content: '';
height: 100%;
display: inline-block;
vertical-align: middle;
}
.content {
display: inline-block;
vertical-align: middle;
}
这个方式有一个缺陷,需要额外创建一个伪元素。
方法 4: 单行 flexbox
星级:★★★☆☆
现在布局中的 Flexbox 变得广泛可用。它有两种模式:单行和多行。在单行模式(默认)中,行填充垂直空间,align-items
对齐行内的内容。
<div style="display: flex; align-items: center;">
<div>内容。</div>
</div>
或者调整行为列,并用 justify-content
对齐内容。
<div style="display: flex; flex-flow: column; justify-content: center;">
<div>内容。</div>
</div>
方法 5: 多行 flexbox
星级:★★★☆☆
在多行 flexbox 中,行不再填充垂直空间,所以行(只有一个项目)可以用 align-content
对齐。
<div style="display: flex; flex-flow: row wrap; align-content: center;">
<div>内容。</div>
</div>
方法 6: grid
星级:★★★★☆
Grid 出来的更晚,对齐变得更简单。
<div style="display: grid; align-content: center;">
<div>内容。</div>
</div>
方法 7: grid 单元格
星级:★★★★☆
注意与前一个方法的微妙区别:
align-content
将单元格居中到容器。align-items
将内容居中到单元格,同时单元格拉伸以适应容器。
<div style="display: grid; align-items: center;">
<div>内容。</div>
</div>
似乎有很多方法可以做同一件事。
方法 8: margin:auto
星级:★★★☆☆
在流布局中,margin:auto
可以水平居中,但不是垂直居中。使用 margin-block: auto
可以设置垂直居中。
<div style="display: grid;">
<div style="margin-block: auto;">
内容。
</div>
</div>
方法 9: 这篇文章的开头
星级:★★★★★
为什么浏览器最初没有添加这个?
<div style="align-content: center;">
<code>align-content</code> 就是这么简单!
</div>
总结
CSS 的新特性 align-content
提供了一个简单且直接的方式来实现垂直居中,无需使用额外的div包装或复杂的布局模式即可完成垂直居中。但注意这个属性还存在一定的浏览器兼容性,在线上使用需谨慎。
看完本文如果觉得有用,记得点个赞支持,收藏起来说不定哪天就用上啦~
专注前端开发,分享前端相关技术干货,公众号:南城大前端(ID: nanchengfe)
来源:juejin.cn/post/7408097468796551220
写css没灵感,那是你没用到这几个开源库
你是否遇到过写css没灵感,写不出酷炫的效果,那这篇文章你一定要看完。知道这几个开源库,它能让你写出炸天的效果并且有效地增加你的摸鱼时长。
1.CSS Inspiration
网址:
chokcoco.github.io/CSS-Inspira…
CSS Inspiration 上面有各种天马行空的css教程,涵盖了css的许多常见的特效。以分类的形式展示不同的css属性或者不同的课题,例如布局方式、border、伪元素、滤镜、背景3D等。这些都是css里面十分重要的知识点,不管是用于学习还是项目中实际运用都是不错的选择。
当然你也可以用来巩固基础知识,可以利用此项目来制作一些常用的特效,可以看到有上百个经典案例供我们参考,重点是提供源代码,复制粘贴即可使用。
2.Neumorphism
地址:
Neumorphism属于新拟态ui风格,是目前比较新颖的一种前端css设计风格。它的格调简单,基本颜色比较浅,如米白、浅灰、浅蓝等。再利用阴影呈现出凹凸效果,看起来很简单舒适且有3D效果,因此我们可以通过拟态设计出很多优美的页面,拖动效果控制条即可秒生成css样式。
3.AnimXYZ
地址:
如果说你热衷于动画,那animxyz绝对是你的不二之选。你可以使用animxyz组合和混合不同的动画来创建自己的高度可定制的css动画,而无需编写一个单一的关键帧。
相比于animate css,它的强大之处在于你可以在这里根据自己的想法来手动配置动画。实现的动画代码实例,我们可以复制迁移到项目中使用。
4.CodePen
最后要推荐的则是我最常用也是我最推荐的,它就是codepen。codepen是一个完全免费的前端代码托管服务,上面云集了各路大神,拥有全世界前端达人经典项目进行展示,让你从中获取到很多的创作灵感。
它可以实现即时预览,你甚至可以在线修改并及时预览别人的作品。支持多种主流预处理器,快速添加外部资源文件,只需在输入框里输入库名,codepen就会从cdn上寻找匹配的css或js库。
来源:juejin.cn/post/7278238985448177704
为什么 2!=false 和 2!=true 返回的都是true
前言
今天突然想起一个奇怪的问题,记录一下,我在控制台执行内容如下:
由上图可见,2 != false
和 2 != true
返回的值竟然都是true
,那么为什么呢,请看下文:
1 !=
操作符的作用
!=
是“不等于”操作符。它会在比较前执行类型转换,然后再比较两个值是否不相等。
在 JavaScript 中,
2 != false
和2 != true
返回true
的原因涉及到 JavaScript 中的类型转换和比较规则。
2 类型转换
当使用 !=
进行比较时,JavaScript 会尝试将比较的两个值转换为相同的类型,然后再进行比较。以下是 2 != false
和 2 != true
的过程:
2 != false
false
会被转换为数字类型。根据 JavaScript 的转换规则,false
被转换为0
。- 现在表达式变成了
2 != 0
。 2
和0
不相等,因此返回true
。
2 != true
true
会被转换为数字类型。根据 JavaScript 的转换规则,true
被转换为1
。- 现在表达式变成了
2 != 1
。 2
和1
不相等,因此返回true
。
总结
2 != false
返回true
是因为2
和0
不相等。2 != true
返回true
是因为2
和1
不相等。
这就是为什么 2 != false
和 2 != true
都会返回 true
。
来源:juejin.cn/post/7411168461500563468
哦!该死的您瞧瞧这箭头
今天和大家分享一个小思路,用于实现箭头步骤条效果。
在我们项目中,有一个需求,想实现一个步骤条,默认的时候是 边框和文字 需要有特定的颜色,但是选中时,背景需要有特定颜色,边框颜色消失,文字显示白色,具体效果如下图:
可以看到,步骤一是默认样式,步骤二是选中样式,即选中背景颜色需要变成默认样式的边框颜色
使用div思路(无法实现默认效果)
当时第一次想的是使用div来实现这个逻辑,因为看到elementui有个差不多的(但是实现不了上面的效果,实现一个箭头倒是可以的,下面为大家简单介绍div的实现思路)
-- 饿了么效果图
搭建dom结构
首先我们先创建一个矩形
然后像这样使用一个伪元素,盖在矩形的开头,并修改其border-color的颜色即可,操作方式如下图
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
height: 100vh;
width: 100vw;
display: grid;
place-content: center;
overflow: hidden;
}
.arrow {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-width: 100px;
max-width: max-content;
height: 30px;
background: salmon;
}
.arrow::after {
position: absolute;
content: "";
left: 0px;
border: 15px solid transparent;
border-left-color: white;
}
.arrow::before {
position: absolute;
content: "";
right: 0px;
border: 15px solid transparent;
border-top-color: white;
border-right-color: white;
border-bottom-color: white;
}
.content {
text-align: center;
padding: 0px 20px;
width: 140px;
box-sizing: border-box;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.content-inner {
display: inline-block;
width: 100%;
transform: translateX(-5px);
}
</style>
</head>
<body>
<div class="arrow">
<div class="content">
<span class="content-inner">1</span>
</div>
</div>
</body>
</html>
这样就实现了一个箭头啦。
但是使用div实现箭头,并不太好实现我们开头想要的那种效果,如果非要实现也要费很大劲,得不偿失,所以接下来,介绍第二种方案
使用SVG标签(可缩放矢量图形)
实现思路即标签介绍
polyline
polyline
元素是 SVG 的一个基本形状,用来创建一系列直线连接多个点。
使用到的属性:
- stroke-width: 用于设置绘制的线段宽度
- fill: 填充色
- stroke: 线段颜色,
- points:绘制一个元素点的数列 (
0,0 22,22
)
接下来我们尝试使用该元素绘制一个箭头,先看看需要多少点位(如下图需6个,但是元素需要闭合,所以需要7个)
所以我们就可以很轻松的绘制出一个箭头,具体代码如下
<svg
:viewBox="0 0 130 26"
width="130px"
height="26"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<polyline
class="polyline"
points="0,0 115,0 130,13 115,26 0,26 15,13 0,0"
fill="green"
stroke-width="1"
></polyline>
</svg>
此时我们得到了类似选中后的颜色,那默认颜色呢,只需要修改其 fill, stroke属性即可,具体逻辑如下
<svg
:viewBox="0 0 130 26"
width="130px"
height="26"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<polyline
class="polyline"
points="0,0 115,0 130,13 115,26 0,26 15,13 0,0"
fill="transparent"
stroke="red"
stroke-width="1"
></polyline>
</svg>
此时那文中的内容怎么办,没法直接放标签内,此时需要借助另一个标签。
foreignObject
SVG中的
<foreignObject>
元素允许包含来自不同的 XML 命名空间的元素。在浏览器的上下文中,很可能是 XHTML / HTML。
所以我们可以使用该标签来作为放置内容的容器
属性介绍:
- x:设置 foreignObject 的 x 坐标
- y:设置 foreignObject 的 y 坐标
- width:设置 foreignObject 的宽度
- height:设置 foreignObject 的高度
具体代码如下
<svg
:viewBox="0 0 130 26"
width="130px"
height="26"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<polyline
class="polyline"
points="0,0 115,0 130,13 115,26 0,26 15,13 0,0"
fill="transparent"
stroke="red"
stroke-width="1"
>
</polyline>
<foreignObject
x="0"
y="0"
width="130"
height="26"
>
<span
style="line-height: 26px; transform: translateX(14px); display: inline-block;"
>
步骤1111111
</span>
</foreignObject>
</svg>
这样就实现了默认样式,文字颜色可以自己调整
完整代码
由于需要遍历数据,所以完整代码是 vue3 风格
<template>
<div class="next-step-item" @click="stepClick">
<svg
:viewBox="`0 0 ${arrowStaticData.width} ${arrowStaticData.height}`"
:width="arrowStaticData.width"
:height="arrowStaticData.height"
:style="{
transform:
index === 0
? 'translate(0px,0px)'
: `translate(${arrowStaticData.offsetLeft * index}px,0)`,
}"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<polyline
class="polyline"
:points="points"
v-bind="color"
stroke-width="1"
></polyline>
<foreignObject
x="0"
y="0"
:width="arrowStaticData.width"
:height="arrowStaticData.height"
>
<span
class="svg-title"
:style="{
color: fontColor,
lineHeight: arrowStaticData.height + 'px',
}"
:title="title"
>
{{ title }}
</span>
</foreignObject>
</svg>
</div>
</template>
<script lang="ts" setup>
import { computed } from "vue";
const defaultFontColor = "#fff";
const defaultColor = "transparent";
// 主题颜色
const colorObj = Object.freeze({
finish: {
default: {
stroke: "#16BB60",
fill: defaultColor,
color: "#16BB60",
},
active: {
stroke: "#16BB60",
fill: "#16BB60",
color: defaultFontColor,
},
}, // 绿色
await: {
default: {
stroke: "#edf1f3",
fill: defaultColor,
color: "#333",
},
active: {
stroke: "#edf1f3",
fill: "#edf1f3",
color: "#333",
},
}, // 灰色
process: {
default: {
stroke: "#0A82E5",
fill: defaultColor,
color: "#0A82E5",
},
active: {
stroke: "#0A82E5",
fill: "#0A82E5",
color: defaultFontColor,
},
}, // 蓝色
});
const arrowStaticData = Object.freeze({
width: 130,
height: 26,
hornWidth: 15, // 箭头的大小
offsetLeft: -7, // step离左侧step的距离,-15则左间距为0
});
const emits = defineEmits(["stepClick"]);
const props = defineProps({
title: {
type: String,
default: "",
},
// 类型名称
typeName: {
type: String,
default: "",
},
// 是否点中当前的svg
current: {
type: Boolean,
default: false,
},
// 当前是第几个step
index: {
type: Number,
default: 0,
},
});
const points = computed(() => {
const { width, hornWidth, height } = arrowStaticData;
return props.index === 0
? `0,0 ${width - hornWidth},0
${width},${height / 2}
${width - hornWidth},${height}
0,${height} 0,0`
: `0,0 ${width - hornWidth},0
${width},${height / 2}
${width - hornWidth},${height}
0,${height}
${hornWidth},${height / 2} 0,0`;
});
const color = computed(() => {
let color = {};
const currentStyleConfig: any = colorObj[props.typeName];
// 如果当前是被选中的,颜色需要区分
if (props.current) {
color = {
fill: currentStyleConfig.active.fill,
stroke: currentStyleConfig.active.stroke,
};
} else {
color = {
stroke: currentStyleConfig.default.stroke,
fill: currentStyleConfig.default.fill,
};
}
return color;
});
const fontColor = computed(() => {
const currentStyleConfig: any = colorObj[props.typeName];
let fontColor = "";
if (props.current) {
fontColor = currentStyleConfig.active.color;
} else {
fontColor = currentStyleConfig.default.color;
}
return fontColor;
});
const stepClick = () => {
emits("stepClick", props.index);
};
</script>
<style lang="scss" scoped>
.next-step-item {
cursor: pointer;
.polyline {
transition: 0.3s;
}
.svg-title {
padding: 0 15px;
display: block;
position: relative;
width: 100%;
text-align: center;
font-weight: bold;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: 0.3s;
box-sizing: border-box;
}
}
</style>
使用方式
<template>
<div>
<div class="arrow-container">
<arrow
v-for="item of arrowList"
:key="item.index"
v-bind="item"
:current="arrowCurrent === item.index"
@stepClick="changeStepCurrent"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import Arrow from "components/Arrow/index.vue";
const arrowCurrent = ref<number>(0);
const arrowList = [
{
index: 0,
title: "步骤一一一一一一一一一",
typeName: "process",
},
{
index: 1,
title: "步骤二一一一一一一一一一",
typeName: "finish",
},
{
index: 2,
title: "步骤三",
typeName: "await",
},
];
</script>
<style lang="scss">
.arrow-container {
padding: 30px;
display: flex;
width: 800px;
height: 400px;
border: 1px solid #ccc;
margin-top: 100px;
box-sizing: border-box;
}
</style>
完整效果
来源:juejin.cn/post/7350695708074344498
uniapp实现背景颜色跟随图片主题色变化(多端兼容)
最近做uniapp项目时遇到一个需求,要求模仿腾讯视频app首页的背景颜色跟随banner图片的主题颜色变化,并且还要兼容H5、APP、微信小程序三端。
由于项目的技术栈为uniapp,所以以下使用uni-ui的组件库作为栗子。
需求分析
腾讯视频app效果如下:
从上图看出,大致分为两步:
1.获取图片主题色
2.设置从上到下的
主题色
to白色
的渐变:
background: linear-gradient(to bottom, 主题色, 白色)
获取主题色主要采用canvas
绘图,绘制完成后获取r、g、b
三个通道的颜色像素累加值,最后再分别除以画布大小,得到每个颜色通道的平均值即可。
搭建页面结构
page.vue
<script>
import {
getImageThemeColor
} from '@/utils/index'
export default {
data() {
return {
// 图片列表
list: [],
// 当前轮播图索引
current: 0,
// 缓存banner图片主题色
colors: [],
// 记录当前提取到第几张banner图片
count: 0
}
},
computed: {
// 动态设置banner主题颜色背景
getStyle() {
const color = this.colors[this.current]
return {
background: color ? `linear-gradient(to bottom, rgb(${color}), #fff)` : '#fff'
}
}
},
methods: {
// banner改变
onChange(e) {
this.current = e.target.current
},
getList() {
this.list = [
'https://img.zcool.cn/community/0121e65c3d83bda8012090dbb6566c.jpg@3000w_1l_0o_100sh.jpg',
'https://img.zcool.cn/community/010ff956cc53d86ac7252ce64c31ff.jpg@900w_1l_2o_100sh.jpg',
'https://img.zcool.cn/community/017fc25ee25221a801215aa050fab5.jpg@1280w_1l_2o_100sh.jpg',
]
},
// 获取主题颜色
getThemColor() {
getImageThemeColor(this, this.list[this.count], 'canvas', (color) => {
const colors = [...this.colors]
colors[this.count] = color
this.colors = colors
this.count++
if (this.count < this.list.length) {
this.getThemColor()
}
})
}
},
onLoad() {
this.getList()
// banner图片请求完成后,获取主题色
this.getThemColor()
}
}
script>
<style>
.box {
display: flex;
flex-direction: column;
background-color: deeppink;
padding: 10px;
}
.tabs {
height: 100px;
color: #fff;
}
.swiper {
width: 95%;
height: 200px;
margin: auto;
border-radius: 10px;
overflow: hidden;
}
image {
width: 100%;
height: 100%;
}
style>
封装获取图片主题颜色函数
先简单讲下思路 (想直接看源码可直接跳到下面) 。先通过request请求图片地址,获取图片的二进制数据,再将图片资源其转换成base64,调用drawImage
进行绘图,最后调用draw
方法绘制到画布上。
CanvasContext.draw介绍
更多api使用方法可参考:uniapp官方文档
getImageThemeColor.js
/**
* 获取图片主题颜色
* @param path 图片的路径
* @param canvasId 画布id
* @param success 获取图片颜色成功回调,主题色的RGB颜色值
* @param fail 获取图片颜色失败回调
*/
export const getImageThemeColor = (that, path, canvasId, success = () => {}, fail = () => {}) => {
// 获取图片后缀名
const suffix = path.split('.').slice(-1)[0]
// uni.getImageInfo({
// src: path,
// success: (e) => {
// console.log(e.path) // 在安卓app端,不管src路径怎样变化,path路径始终为第一次调用的图片路径
// }
// })
// 由于getImageInfo存在问题,所以改用base64
uni.request({
url: path,
responseType: 'arraybuffer',
success: (res) => {
let base64 = uni.arrayBufferToBase64(res.data);
const img = {
path: `data:image/${suffix};base64,${base64}`
}
// 创建canvas对象
const ctx = uni.createCanvasContext(canvasId, that);
// 图片绘制尺寸
const imgWidth = 300;
const imgHeight = 150;
ctx.drawImage(img.path, 0, 0, imgWidth, imgHeight);
ctx.save();
ctx.draw(true, () => {
uni.canvasGetImageData({
canvasId: canvasId,
x: 0,
y: 0,
width: imgWidth,
height: imgHeight,
fail: fail,
success(res) {
let data = res.data;
let r = 1,
g = 1,
b = 1;
// 获取所有像素的累加值
for (let row = 0; row < imgHeight; row++) {
for (let col = 0; col < imgWidth; col++) {
if (row == 0) {
r += data[imgWidth * row + col];
g += data[imgWidth * row + col + 1];
b += data[imgWidth * row + col + 2];
} else {
r += data[(imgWidth * row + col) * 4];
g += data[(imgWidth * row + col) * 4 + 1];
b += data[(imgWidth * row + col) * 4 + 2];
}
}
}
// 求rgb平均值
r /= imgWidth * imgHeight;
g /= imgWidth * imgHeight;
b /= imgWidth * imgHeight;
// 四舍五入
r = Math.round(r);
g = Math.round(g);
b = Math.round(b);
success([r, g, b].join(','));
},
}, that);
});
}
});
}
主题色计算公式
计算图片主题色的公式主要有两种常见的方法:平均法和主成分分析法。
平均法:
平均法是最简单的一种方法,它通过对图片中所有像素点的颜色进行平均来计算主题色。具体步骤如下:
- 遍历图片的每个像素点,获取其RGB颜色值。
- 将所有像素点的R、G、B分量分别求和,并除以像素点的总数,得到平均的R、G、B值。
- 最终的主题色即为平均的R、G、B值。
主成分分析法
主成分分析法是一种更复杂但更准确的方法,它通过对图片中的颜色数据进行降维处理,提取出最能代表整个图片颜色分布的主要特征。具体步骤如下:
- 将图片的所有像素点的颜色值转换为Lab颜色空间(Lab颜色空间是一种与人眼感知相关的颜色空间)。
- 对转换后的颜色数据进行主成分分析,找出相应的主成分。
- 根据主成分的权重,计算得到最能代表整个图片颜色分布的主题色。
需要注意的是,计算图片主题色的方法可以根据具体需求和算法的实现方式有所不同,上述方法只是其中的两种常见做法。
结语
大家有更好的实现方式,欢迎评论区留言哦!
来源:juejin.cn/post/7313979304513044531
和妹子逛完街,写了个 AI 智能穿搭系统
想直接看成品演示的可以直接划到文章底部
背景
故事起源在和一个妹子去逛衣服店的时候,试来试去的难以取舍,最终消耗了我一个小时。虽然这个时间不多,
但这个时间黑神话悟空足矣让我打完虎先锋
回家我就灵光一闪,是不是可以搞一个AI智能穿搭,只需要上传自己的照片和对应的衣服图片就能实现在线试衣服呢?
说干就干,我就开始构思方案,画原型。
俗话说万事开头难,事实上这个构思到动工就耗费了我一个礼拜,因为一直在构思怎么样的交互场景会让用户使用起来比较丝滑,并且容易上手。
目前实现的功能有:
- ✅ 用户信息展示
- ✅ AI 生成穿搭
- ✅ 风格大厅
待完成:
- 私人衣柜
- AI 换鞋
经过
1. 画产品原型
起初第一个版本的产品原型由于是自己构思没有任何参考,直接上手撸代码的,想到啥就画啥,所以布局非常传统,配色也非常普通(蚂蚁蓝),所以感觉没有太多的时尚气息(个人觉得丑的一逼,不像是互联网的产物)。因为重构掉了,老的现在没有了,我懒就不重新找回来截图了,直接画个当时的样子,大概长成下面这样:

丑的我忍不了,我就去设计师专门用的网站参(chao)考(xi)了一下,找来找去,终于有了下面的最终版原型图

2. 配色选择
大家知道,所有的UI设计,都离不开主题色的选择,比如:淘宝橙、飞猪橙、果粒橙...,目的一方面是为了打造品牌形象,另一方面也是为了提升品牌辨识度,让你看到这个颜色就会想起它
那我必须也得跟上时代的潮流,选了 #c1a57b 这款低调而又不失奢华的色值作为主题色,英雄不问出处,问就是借鉴。
3. 技术选型
我对技术的定义是:技术永远服务于产品,能高效全面帮助我开发出一款应用,并且能保证后续的稳定性和可维护性,啥技术我都行。当然如果这门技术我优先会从我属性的板块去找。
经过各种权衡和比较,最后敲定下来了技术选型方案:
- 前端:taro (为了后续可能会有小程序端做准备)
- 后端:koajs (实际使用的是midway,基于koajs,主要是比较喜欢koa的轻量化架构)
- 数据库:mongodb (别问,问就是简单易上手)
- 代码仓库:gitea
- CI:gitea-runner
- 部署工具:pm2
- 静态文件托管:阿里云OSS
4. 撸代码
这里我只挑一些个人感觉相对需要注意的地方展开讲讲
4.1 图片转存
由于我生成图片的API图片链接会在一天之后失效,所以我需要在调用任务详情的时候,把这个文件转存到我自己的oss服务器,这里我总结出来的思路是:【1. 保存在本地暂存文件夹】-【2. 调用node流式读取接口】-【3. 保存到oss】-【4. 返回替换原来的链接】
具体代码参考如下:
const tempDir = path.join(tmpdir(), 'temp-upload-files')
const link = url.parse(src);
const fileName = path.basename(link.pathname)
const localPath = path.join(tempDir, `/${fileName}`); // 生成保存路径
let request
if (link.protocol === 'https:') {
request = https
} else {
request = http
}
request.get(src, async (response) => {
const fileStream = await fs.createWriteStream(localPath); // 保存到本地暂存路径
await response.pipe(fileStream);
fileStream.on("error", (error) => {
console.error("保存图片出错:", error);
reject(error)
});
fileStream.on('finish', async res => {
console.log('暂存完成,开始上传:', res)
let result = await this.ossService.put(`/${params.saveDir || 'tmp'}/${fileName}`, localPath);
if (!result) return
resolve(result)
});
});
这里的request因为我不想引入其它的库所以这样写,如果有更好的方案,可以在评论区告知一下。
这里需要注意的一个地方是,上传的这个 localPath 最好是自己做一下处理,我这边没有处理,因为可能两个用户同时上传,他们的文件名称相同的时候,可能会出现覆盖的情况,包括后面的oss保存也是。
4.2 文件流式上传中间件
因为默认的接口处理是不处理流式调用的,所以需要自己创建一个中间件来拦截处理一下,下面给出我的参考代码:
class SSE {
ctx: Context
constructor(ctx: Context) {
ctx.status = 200;
ctx.set('Content-Type', 'text/event-stream');
ctx.set('Cache-Control', 'no-cache');
ctx.set('Connection', 'keep-alive');
ctx.res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Transfer-Encoding': 'chunked'
});
ctx.res.flushHeaders();
this.ctx = ctx;
}
send(data: any) {
// string
if (typeof data === "string") {
this.push(data);
} else if (data.id) {
this.push(`id: ${data.id}\n`);
} else if (data.event) {
this.push(`event: ${data.event}\n`);
} else {
const text = JSON.stringify(data)
this.push(`data: ${text}\n\n`);
}
}
push(data: any) {
this.ctx.res.write(data);
this.ctx.res.flushHeaders();
}
close() {
this.ctx.res.end();
}
}
@Middleware()
export class StreamMiddleware implements IMiddleware<Context, NextFunction> {
// ?------------ 中间件处理逻辑 -----------------
resolve() {
return async (ctx: Context, next: NextFunction) => {
if (ctx.res.headersSent) {
if (!ctx.sse) {
console.error('[sse]: response headers already sent, unable to create sse stream');
}
return await next();
}
const sse = new SSE(ctx);
ctx.sse = sse;
await next();
if (!ctx.body) {
ctx.body = ctx.sse;
} else {
ctx.sse.send(ctx.body);
ctx.body = sse;
}
};
}
public match(ctx: Context): boolean {
// ?------------ 不带 stream 前缀默认都不是流式接口 -----------------
if (ctx.path.indexOf('stream') < 0) return false
}
static getName(): string {
return 'stream';
}
}
4.3 mongodb 数据库的权限
这里尽量不要使用root权限的数据库角色,可以创建一个只有当前数据库权限的角色,具体可以网上找相关文档,怎么为某个collection创建账户。
实机演示
1. 提交素材,创建任务

2. 获取生成图片

3. 展示大厅(待完善)

结语
当然现在目前这个还是内测版本,功能还不够健全,还有很多地方需要打磨,包括用户信息页面的展示是否合理,UI的排版,数据库表的设计等等
通过观察生活用现有的技术创造一些价值,对我来说就是一种幸福且有意义的事儿。
如果想要体验的可以后台私信我。如果你也有很棒的想法想交流一下,也可以私我。
我是dev,下期见(太懒了我,更新频率太低)
来源:juejin.cn/post/7407374655109283851
多语言翻译你还在一个个改?我不允许你不知道这个工具
最近在做项目的多语言翻译,由于是老项目,里面需要翻译的文本太多了,如果一个个翻译的话,一个人可能一个月也做不完,因此考虑使用自动化工具实现。
我也从网上搜索了很多现有的自动化翻译工具,有vsc插件、webpack或vite插件、webpack loader等前人实现的方案,但是安装之后发现,要不脱离不了一个个点击生成的繁琐,要不插件安装太麻烦,安装报错依赖报错等等问题层出不穷。因此,决定自己写一个,它需要满足:
1.不需要手动改代码,自动运行生成
2.不需要查翻译,自动调用翻译接口生成翻译内容
3.不需要安装,避免安装问题、环境问题等
i18n-cli 工具的产生
经过一个星期左右的开发和调试,i18n-cli自动化翻译工具实现了,详情可以看@tenado/i18n-cli。先来看看使用效果:
转换前:
<template>
<div class="empty-data">
<div class="name">{{ name }}</div>
<template>
<div class="empty-image-wrap">
<img class="empty-image" :src="emptyImage" />
</div>
<div class="empty-title">暂无数据</div>
</template>
</div>
</template>
<script lang="js">
import Vue from "vue";
export default Vue.extend({
data(){
return {
name: "测试"
}
},
});
</script>
转换后:
<template>
<div class="empty-data">
<div class="name">{{ name }}</div>
<template>
<div class="empty-image-wrap">
<img class="empty-image" :src="emptyImage" />
</div>
<div class="empty-title">{{ $t("zan-wu-shu-ju") }}</div>
</template>
</div>
</template>
<script lang="js">
import { i18n } from 'i18n';
import Vue from "vue";
export default Vue.extend({
data() {
return {
name: i18n.t('ce-shi')
};
}
});
</script>
@tenado/i18n-cli翻译,不受语言类型限制,目前vue、react、vue3等代码都能完美的支持,它通过分析语法树,自动匹配中文内容,并生成翻译后的代码。
如何使用 i18n-cli 工具
1.下载@tenado/i18n-cli项目代码,例如存作i18n-cli
2.将需要翻译的代码文件,拷贝到i18n-cli项目下的目录下
3.修改 i18n.config.js 配置,修改入口entry为你刚复制的文件的位置,修改你需要翻译的语言列表langs,例如英文、繁体['en-US', 'zh-TW'],修改引入i18n的方法i18nImport、i18nObject、i18nMethod,修改翻译的类型和秘钥,一个简单的配置如下:
module.exports = {
// 入口位置
entry: ['example/transform-i-tag'],
// 翻译后的文件存放位置
localPath: './example/transform-i-tag/locales',
// 需要翻译的语言列表
langs: ['en-US'],
// 引入i18n
i18nImport: "import { t } from 'i18n';",
i18nObject: '',
i18nMethod: 't',
// 翻译配置,例如百度
translate: {
type: 'baidu',
appId: '2023088292121',
secretKey: 'J1ArqOof1s8kree',
interval: 1000,
},
};
4.在i18n-cli项目下执行命令,npm run sync
,将会修改你刚复制的文件里面的代码,并在locales下生成翻译内容,这里如果没有百度翻译api key,那你可以先收集,后面在翻译,先执行 npm run extract
,再执行 npm run translate
5.将修改后的文件复制回你的项目下
当然,i18n-cli 的配置不是仅仅这些,更多配置你可以去 @tenado/i18n-cli 对应的 github 仓库上查看
i18n-cli 是怎么实现的
1、收集文件
根据入口,获取需要处理的文件列表,主要代码如下:
// 根据入口获取文件列表
const getSourceFiles = (entry, exclude) => {
return glob.sync(`${entry}/**/*.{js,ts,tsx,jsx,vue}`, {
ignore: exclude || [],
})
}
// 例如 getSourceFiles('src/components')
// 结果 ['src/components/Select/index.vue', 'src/components/Select/options.vue', 'src/components/Select/index.js']
2、转换文件
根据文件类型,生成不同文件的语法树,例如.vue文件分别解析vue的template、style、script三个部分,例如.ts、.tsx文件,例如html,解析成ast语法树后,针对不同类型的中文分别处理,如下是babel转换ast时候里面的一部分核心代码:
const { declare } = require("@babel/helper-plugin-utils");
const generate = require("@babel/generator").default;
module.exports = declare((api, options) => {
return {
visitor: {
// 针对不同类型的中文,进行转换
// 代码太多,这里不贴全部,具体的可以去github上查看源码
DirectiveLiteral() {},
StringLiteral() {},
TemplateLiteral() {},
CallExpression() {},
ObjectExpression() {},
},
};
});
3、调用接口翻译
根据locales文件存放位置,把收集到的中文都存在./locales/zh-CN.json
里面,收集中文和key是在文件转换过程处理的。
这个过程,会根据生成的中文json,去请求接口,拿到中文对应语言的翻译,实现代码如下:
const fetch = require("node-fetch");
const md5 = require('md5');
const createHttpError = require('http-errors');
const langMap = require("./langMap.js");
const defaultOptions = {
from: "auto",
to: "en",
appid: "",
salt: "wgb236hj",
sign: "",
}
module.exports = async (text, lang, options) => {
const hostUrl = "http://api.fanyi.baidu.com/api/trans/vip/translate";
let _options = {
q: text,
...defaultOptions,
}
const { local } = options ?? {};
const { appId, secretKey } = options?.translate ?? {};
if(local) {
_options.from = langMap('baidu', local);
}
if(lang) {
_options.to = langMap('baidu', lang);
}
_options.appid = appId;
const str = `${_options.appid}${_options.q}${_options.salt}${secretKey}`;
_options.sign = md5(str);
const buildBody = () => {
return new URLSearchParams(_options).toString();
}
const buildOption = () => {
const opt = {};
opt.method = 'POST';
opt.headers = {
'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
}
opt.body = buildBody();
return opt;
}
const buildError = async (res) => {
const extractTooManyRequestsInfo = (html) => {
const ip = html.match(/IP address: (.+?)<br>/)?.[1] || '';
const time = html.match(/Time: (.+?)<br>/)?.[1] || '';
const url = (html.match(/URL: (.+?)<br>/)?.[1] || '').replace(/&/g, '&');
return { ip, time, url };
}
if (res.status === 429) {
const text = await res.text();
const { ip, time, url } = extractTooManyRequestsInfo(text);
const message = `${res.statusText} IP: ${ip}, Time: ${time}, Url: ${url}`;
return createHttpError(res.status, message);
} else {
return createHttpError(res.status, res.statusText);
}
}
const buildText = ({ error_code, error_msg, trans_result }) => {
if(!error_code) {
return trans_result?.map(item => item.dst);
} else {
console.error(`百度翻译报错: ${error_code}, ${error_msg}`)
return '';
}
}
const fetchOption = buildOption();
const res = await fetch(hostUrl, fetchOption)
if(!res.ok) {
throw await buildError(res)
}
const raw = await res.json();
const _text = buildText(raw);
return _text;
}
总结
i18n-cli 是一个全自动的国际化插件,可以一键翻译多国语言,同时不会影响项目的业务代码,对于国际化场景是一个很强大的工具。
使用 i18n-cli 可以大大减少项目多语言翻译的工作量,这个插件已经在我们项目中使用很久了,是一个成熟的方案,欢迎大家使用,欢迎提交issues,欢迎star。
来源:juejin.cn/post/7327969921309065216