做了这么久前端还不会手写瀑布流?(H5 & 小程序)
前言
做了7年前端我一直不知道瀑布流是什么(怪设计师不争气啊,哈哈哈),我一直以为就是个普通列表,几行css解决的那种。
当然瀑布流确实有css解决方案,但是这个方案对于分页列表来说完全不能用,第二页内容一出来位置都变了。
我看了一下掘金的一些文章,好长啊,觉得还是自己想一下怎么写吧。就自己实现了一遍。希望思路给大家一点帮助。
分析瀑布流
以小红书的瀑布流为例,相同宽度不同高度的卡片堆叠在一起形成瀑布流。
这里有两个难点:
- 卡片高度如何确定?
- 堆叠布局如何实现?
卡片的高度 = padding + imageHeight + textHeight....
不固定的内容包括:图片高度、标题行数
也就是说当我们解决了图片和标题的高度问题,那么瀑布流的第一个问题就解决了。(感觉已经写好代码了一样)
堆叠问题——因为css没有这样的布局方式,所以肯定得用js实现。最简单的解决方案就是对每一个盒子进行绝对定位。
这个问题就转换成计算现有盒子的定位问题。
从问题到代码
第一个问题——图片高度
无论是企业业务场景还是个人开发,通过后端返回图片的width、height都是合理且轻松的。
前端去获取图片信息,无疑让最重要的用户体验变得糟糕。前端获取图片信息并不困难,但是完全没有必要。
所以我直接考虑后端返回图片信息的情况。
const realImageHeight = imageWidth / imageHeight * cardContentWidth;
图片高度轻松解决,无平台差异
第二个问题——文字高度
从小红书可以看出,标题有些两行有些一行,也有些三行。
如果你固定一行,这个问题完全可以跳过。
- 方案一:我们可以用字数和宽度来计算可能得行数
优势:速度快,多平台复用
劣势:不准确(标题包括英文中文) - 方案二:我们可以先渲染出来再获取行数
优势:准确
劣势:相对而言慢,不同平台方法不同
准确是最重要的!选择方案二
其实方案二也有两种方案,一种是用canvas模拟,这样可以最大限度摆脱平台(h5、小程序)的限制,
然而我试验后,canvas还没找到准确的计算的方法(待后续更新)
第二种就是用div渲染一遍,获取行数或者高度。
创建一个带有指定样式的 div 元素
function createDiv(style: string): HTMLDivElement {
const div = document.createElement('div');
div.style.cssText = style;
document.body.appendChild(div);
return div;
}
计算文本数组在指定字体大小和容器宽度下的行数
/**
* 计算文本数组在指定字体大小和容器宽度下的行数
* @param texts - 要渲染的文本数组
* @param fontSize - 字体大小(以像素为单位)
* @param lineHeight - 字体高度(以像素为单位)
* @param containerWidth - 容器宽度(以像素为单位)
* @param maxLine - 最大行数(以像素为单位)
* @returns 每个文本实际渲染时的行数数组
*/
export function calculateTextLines(
texts: string[],
fontSize: number,
lineHeight: number,
containerWidth: number,
maxLine?: number
): number[] {
// 创建一个带有指定样式的 div 元素
const div = createDiv(`font-size: ${fontSize}px; line-height: ${lineHeight}px; width: ${containerWidth}px; white-space: pre-wrap;`);
const results: number[] = [];
texts.forEach((text) => {
div.textContent = text;
// 获取 div 的高度,并根据字体大小计算行数
const divHeight = div.offsetHeight;
const lines = Math.ceil(divHeight / lineHeight);
maxLine && lines > maxLine ? results.push(maxLine) : results.push(lines);
});
// 清理 div
removeElement(div);
return results;
}
这个问题小程序如何解决放在文末
第三个问题——每个卡片的定位问题
解决了上面的问题,就解决了盒子高度的问题,这个问题完全就是相同宽度不同高度盒子的堆放问题了
问题的完整描述是这样的:
写一个ts函数实现将一堆小盒子,按一定规则顺序推入大盒子里
函数输入:小盒子高度列表
小盒子:不同小盒子高度不一致,宽度为stackWidth,彼此间隔gap
大盒子:高度无限制,宽度为width
堆放规则:优先放置高度低的位置,高度相同时优先放在左侧
返回结果:不同盒子的高度和位置信息
如果你有了这么清晰的描述,接下去的工作你只需要交给gpt来写你的函数
// 返回的盒子信息
export interface Box {
x: number;
y: number;
height: number;
}
// 盒子堆叠的方法类
export class BoxPacker {
// 返回的小盒子信息列表
private boxes: Box[] = [];
// 大盒子宽度
private width: number;
// 小盒子宽度
private stackWidth: number;
// 小盒子间隔
private gap: number;
constructor(width: number, stackWidth: number, gap: number) {
this.width = width;
this.stackWidth = stackWidth;
this.gap = gap;
this.boxes = [];
}
// 添加单个盒子
public addBox(height: number): Box[] {
return this.addBoxes([height]);
}
// 添加多个盒子(一般用这个方法)
public addBoxes(heights: number[], isReset?: boolean): Box[] {
isReset && (this.boxes = [])
console.log('this.boxes—————— ', JSON.stringify(this.boxes) )
for (const height of heights) {
const position = this.findBestPosition();
const newBox: Box = { x: position.x, y: position.y, height };
this.boxes.push(newBox);
}
return this.boxes;
}
// 查找定位函数
private findBestPosition(): { x: number; y: number } {
let bestX = 0;
let bestY = Number.MAX_VALUE;
for (let x = 0; x <= this.width - this.stackWidth; x += this.stackWidth + this.gap) {
const currentY = this.getMaxHeightInColumn(x, this.stackWidth);
if (currentY < bestY || (currentY === bestY && x < bestX)) {
bestX = x;
bestY = currentY;
}
}
return { x: bestX, y: bestY };
}
private getMaxHeightInColumn(startX: number, width: number): number {
return this.boxes
.filter(box => box.x >= startX && box.x < startX + width)
.reduce((maxHeight, box) => Math.max(maxHeight, box.y + box.height + this.gap), 0);
}
}
这样我们就实现了根据高度获取定位的功能了
来实现一波
核心的代码就是获取每个盒子的定位、宽高信息
// 实例
const boxPacker = useMemo(() => {
return new BoxPacker(width, stackWidth, gap)
}, []);
const getCurrentPosition = (currentData: DataItem[], reset?: boolean) => {
// 获取标题文本行数列表
const textLines = calculateTextLines(currentData.map(item => item.title),card.fontSize,card.lineHeight, cardContentWidth)
// 获取图片高度列表
const imageHeight = currentData.map(item => (item.imageHeight / item.imageWidth * cardContentWidth))
// 获取小盒子高度列表
const cardHeights = imageHeight.map((h, index) => (
h + textLines[index] * card.lineHeight + card.padding * 2 + (card?.otherHeight || 0)
)
);
// 获取盒子定位信息
const boxes = boxPacker.addBoxes(
cardHeights,
reset
)
// 返回盒子列表信息
return boxes.map((box, index) => ({
...box,
title: currentData[index]?.title,
url: currentData[index]?.url,
imageHeight: imageHeight[index],
}))
}
set获取到的盒子信息
const [boxPositions, setBoxPositions] = useState<(Box & Pick<DataItem, 'url' | 'title' | 'imageHeight'>)[]>([]);
useEffect(() => {
// 首次和刷新
if (page === 1) {
setBoxPositions(getCurrentPosition(data, true))
} else {
// 加载更多
setBoxPositions(getCurrentPosition(data.slice((page - 1) * pageSize, page * pageSize)))
}
}, [])
效果如下
小程序获取文本高度
从上面的分析可以看出来只有文本高度实现是不同的,如果canvas方案实验成功,说不定还能做到大一统。
目前没成功大家就先看看我的目前方案:先实际渲染文字然后读取信息,然后获取实际高度
import React, {useEffect, useMemo, useState} from 'react'
import { View } from '@tarojs/components'
import Taro from "@tarojs/taro";
import './index.less'
import {BoxPacker} from "./flow";
const data = [
'vwyi这是一个标题,这是一个标题,这是一个标题,这是一个标题',
'这是一个标题',
'这是一个标题,这是一个标题,这是一个标题,这是一个标题',
'这是一个标题',
'这是一个标题,这是一个标题,这是一个标题,一个标题',
'这是一个标题,这是一个标题,这是一个标题,这题',
'这是一个标题,这是一个标题,这是一',
'这是一个标题,这是一个标题,这是一',
];
function Index() {
const boxPacker = useMemo(() => new BoxPacker(320, 100, 5), []);
const [boxPositions, setBoxPositions] = useState<any[]>([])
function getTextHeights() {
return new Promise((resolve, reject) => {
Taro.createSelectorQuery()
.selectAll('#textContainer .text-item')
.boundingClientRect()
.exec(res => {
if (res && res[0]) {
const heights = res[0].map(item => item.height);
resolve(heights);
} else {
reject('No buttons found');
}
});
});
}
useEffect(() => {
getTextHeights().then(h => {
setBoxPositions(boxPacker.addBoxes(h))
})
}, [])
return (
<View className="flow-container">
<View id="textContainer">
{
data.map((item, index) => (<View key={index} className="text-item">{item}</View>))
}
</View>
<View className="text-box-container">
{boxPositions.map((position, index) => (
<View
key={index}
className="text-box"
style={{
left: `${position.x}px`,
top: `${position.y}px`,
height: `${position.height}px`,
width: '100px', // 假设盒子的宽度固定为100px
}}
>
{`${data[index]}`}
</View>
))}
</View>
</View>
)
}
export default Index
来源:juejin.cn/post/7397278180644372521
面试官:假如有几十个请求,如何去控制并发?
面试官:看你简历上做过图片或文件批量下载,那么假如我一次性下载几十个,如何去控制并发请求的?
让我想想,额~, 选中ID,循环请求?,八嘎!肯定不是那么沙雕的做法,这样做服务器直接崩溃啦!突然灵光一现,请求池!!!
我:利用Promise模拟任务队列,从而实现请求池效果。
面试官:大佬!
废话不多说,正文开始:
众所周知,浏览器发起的请求最大并发数量一般都是6~8
个,这是因为浏览器会限制同一域名下的并发请求数量,以避免对服务器造成过大的压力。
首先让我们来模拟大量请求的场景
const ids = new Array(100).fill('')
console.time()
for (let i = 0; i < ids.length; i++) {
console.log(i)
}
console.timeEnd()
一次性并发上百个请求,要是配置低一点,又或者带宽不够的服务器,直接宕机都有可能,所以我们前端这边是需要控制的并发数量去为服务器排忧解难。
什么是队列?
先进先出就是队列,push
一个的同时就会有一个被shift
。我们看下面的动图可能就会更加的理解:
我们接下来的操作就是要模拟上图的队列行为。
定义请求池主函数函数
export const handQueue = (
reqs // 请求数量
) => {}
接受一个参数reqs
,它是一个数组,包含需要发送的请求。函数的主要目的是对这些请求进行队列管理,确保并发请求的数量不会超过设定的上限。
定义dequeue函数
const dequeue = () => {
while (current < concurrency && queue.length) {
current++;
const requestPromiseFactory = queue.shift() // 出列
requestPromiseFactory()
.then(() => { // 成功的请求逻辑
})
.catch(error => { // 失败
console.log(error)
})
.finally(() => {
current--
dequeue()
});
}
}
这个函数用于从请求池中取出请求并发送。它在一个循环中运行,直到当前并发请求数current
达到最大并发数concurrency
或请求池queue
为空。对于每个出队的请求,它首先增加current
的值,然后调用请求函数requestPromiseFactory
来发送请求。当请求完成(无论成功还是失败)后,它会减少current
的值并再次调用dequeue
,以便处理下一个请求。
定义返回请求入队函数
return (requestPromiseFactory) => {
queue.push(requestPromiseFactory) // 入队
dequeue()
}
函数返回一个函数,这个函数接受一个参数requestPromiseFactory
,表示一个返回Promise的请求工厂函数。这个返回的函数将请求工厂函数加入请求池queue
,并调用dequeue
来尝试发送新的请求,当然也可以自定义axios,利用Promise.all
统一处理返回后的结果。
实验
const enqueue = requestQueue(6) // 设置最大并发数
for (let i = 0; i < reqs.length; i++) { // 请求
enqueue(() => axios.get('/api/test' + i))
}
我们可以看到如上图所示,请求数确实被控制了,只有有请求响应成功的同时才会有新的请求进来,极大的降低里服务器的压力,后端的同学都只能喊6。
整合代码
import axios from 'axios'
export const handQueue = (
reqs // 请求总数
) => {
reqs = reqs || []
const requestQueue = (concurrency) => {
concurrency = concurrency || 6 // 最大并发数
const queue = [] // 请求池
let current = 0
const dequeue = () => {
while (current < concurrency && queue.length) {
current++;
const requestPromiseFactory = queue.shift() // 出列
requestPromiseFactory()
.then(() => { // 成功的请求逻辑
})
.catch(error => { // 失败
console.log(error)
})
.finally(() => {
current--
dequeue()
});
}
}
return (requestPromiseFactory) => {
queue.push(requestPromiseFactory) // 入队
dequeue()
}
}
const enqueue = requestQueue(6)
for (let i = 0; i < reqs.length; i++) {
enqueue(() => axios.get('/api/test' + i))
}
}
实战文章
之前写过一篇关于web-worker
大文件切片的案例文章,就是利用了此特性感兴趣的小伙伴可以看看web-worker的基本用法并进行大文件切片上传(附带简易node后端)
来源:juejin.cn/post/7356534347509645375
文件上传你会吧?那帮我做个文件下载功能
大家好,又是我,大聪明,立志做个早起吃草的马儿。话说上回解决完部署的问题(部署完了,样式不生效差点让我这个前端仔背锅),我又感觉回到了眼神清澈的大聪明状态,直到今天产品跟我说:“听说你是文件上传高手?做过大文件上传?切片?断点续传?”,听完我一脸戒备和紧张,“难道我面试吹的牛皮被他发现了,现在要捅破了?”我正在犹豫要不要跟他摊牌说,我面试掺了水的时候,他又来了一句,“那帮我做个 文件下载 的功能吧”,我突然放松下来了,原来是要加需求呀,害我白担心一场。作为CVT工程师,这点事根本难不倒我。
好了下面开始我的CV大法。
首先找到后端协调,他让我返回一个file_id
,该file_id是我在文件上传到服务器存储的时候,后端返回给我的,通过此file_id,来找到对应的文件,很好很简单。
接着,看后端提供的文件下载接口,咱就是说,经历的少,不知道对不对,后端直接就是返回文件的字节流(bytes),除此之外没有任何信息,没有文件名,没有文件类型,去问了一下说就是这样的,咱也不敢多问
天无绝人之路,还好我在前端获取文件的时候能找到总的文件列表,通过遍历出来也能拿到文件的信息
下面是判断文件类型方法
getFileTypeMime (key) {
let mimeType = ''
switch (key) {
case 'png':
mimeType = 'image/png'
break
case 'jpeg':
mimeType = 'image/jpeg'
break
case 'pdf':
mimeType = 'application/pdf'
break
case 'xlsx':
mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
break
case 'docx':
mimeType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
break
default:
mimeType = 'text/plain'
break
}
return mimeType
},
下面是文件下载的方法 (错误的)
downLoad (value, key) {
const type = key.split('.')[1] // 文件类型
const name = key.split('.')[0] // 文件名
axiosBase({
url: `/ass/download?alert_id=${this.alert_id}&file_id=${value}`,
method: 'get',
}).then(res => {
const blob = new Blob([res], { type: this.getFileTypeMime(type) }) // 文件类型
const link = document.createElement('a')
const href = window.URL.createObjectURL(blob) // 创建下载的链接
link.href = href
link.download = name // 下载后文件名
document.body.appendChild(link)
link.click() // 点击下载
document.body.removeChild(link) // 下载完成移除元素
window.URL.revokeObjectURL(href) // 释放掉blob对象
}).catch((err) => { console.log(err) })
}
为什么是错误的呢?点了效果也确实是实现了 文件的下载,但是打开,然后格式错误了??
又是百思不得其解的问题,直接打开度娘,搜索又找到了这篇(神文)解决
downLoad (value, key) {
const type = key.split('.')[1] // 文件类型
const name = key.split('.')[0] // 文件名
axiosBase({
url: `/download?alert_id=${this.alert_id}&file_id=${value}`,
method: 'get',
responseType: 'blob'
}).then(res => {
const blob = new Blob([res], { type: this.getFileTypeMime(type) }) // 文件类型
const link = document.createElement('a')
const href = window.URL.createObjectURL(blob) // 创建下载的链接
link.href = href
link.download = name // 下载后文件名
document.body.appendChild(link)
link.click() // 点击下载
document.body.removeChild(link) // 下载完成移除元素
window.URL.revokeObjectURL(href) // 释放掉blob对象
}).catch((err) => { console.log(err) })
}
又是有惊无险的一天
来源:juejin.cn/post/7389913027654434857
部署完了,样式不生效差点让我这个前端仔背锅
大家好,作为今年刚毕业的大聪明(小🐂🐎),知道今年不好就业,费心费力的面试终于进了个小公司,然后我的奇妙之旅开始了。
叮!闹钟响了!牙都不刷了,直接起床出门去上班,身为🐂🐎就要有早起吃草干活的觉悟,所以我是个很合格的食草动物。只不过,下面的信息,让我一天当动物的好心情都没了。
部署?因为该公司人数比较少,也没有自动化部署操作,只能让前端build一下dist包然后丢给后端,让后端部署,然后该项目也比较急,我刚来,也不好意思@后端,恰巧跟我对接的后端也是刚来不熟悉,所以我就跟负责人继续扯皮拖延时间了。(真是不好意思,假的)↓↓↓
终于经过不断努力(后端),部署好了,但是,我的样式乱了,为什么呢?看了一下网络和源代码,样式文件都请求到了,问了后端,一致说是我前端的问题,我懵了,然后快马加鞭查找问题
解决
ok,那我先看打包的dist文件有样式有问题吗,使用npm安装个 http-server 运行打包后的index.html,运行打开运行后的地址,欧克样式没乱,文件引用也没问题,那到底是什么鬼影响的呢?
在我百思不得其解的时候,我打开了神器--csdn,搜索打包dist部署后样式文件不生效,就遇到了这个神文t.csdnimg.cn/WbQyq
欧克,按照博主说的Nginx没有配置这两个东西而导致的,我有点不确定是不是,有点犹犹豫豫的找到后端说帮我在Nginx配置文件的http加下这两个配置,然后样式就好了,完美!后端由于疏忽,没加上就把问题推给我,结果被我扳回一城。
include mime.types;
default_type application/octet-stream;
- include mime.types;
- 这个指令告诉Nginx去包含一个文件,这个文件通常包含了定义了MIME类型(Multipurpose Internet Mail Extensions)的配置。MIME类型指定了文件的内容类型,例如文本文件、图像、视频等。通过包含
mime.types
文件,Nginx可以识别不同类型的文件并正确地处理它们。 - 示例:假设
mime.types
文件中定义了.html
文件为text/html
类型,Nginx在处理请求时会根据这个定义设置正确的HTTP响应头。
- 这个指令告诉Nginx去包含一个文件,这个文件通常包含了定义了MIME类型(Multipurpose Internet Mail Extensions)的配置。MIME类型指定了文件的内容类型,例如文本文件、图像、视频等。通过包含
- default_type application/octet-stream;
- 这个指令设置了默认的MIME类型。如果Nginx无法根据文件扩展名或其他方式确定响应的MIME类型时,就会使用这个默认类型。
application/octet-stream
是一个通用的MIME类型,表示未知的二进制数据。当服务器无法识别文件类型时,会默认使用这个类型。例如,如果请求的文件没有合适的MIME类型或没有被mime.types
文件中列出,Nginx就会返回application/octet-stream
类型。- 这种设置对于确保未知文件类型的安全传输很有用,因为浏览器通常会下载这些文件而不是尝试在浏览器中打开它们。
总之,添加 include mime.types;
和 default_type application/octet-stream;
配置后,Nginx能够正确地识别和处理CSS文件的MIME类型,从而确保浏览器能够正确加载和应用CSS样式。
所以,前端仔不能只当前端仔,还是要好好学点服务端的知识,不然锅给你,你还认为这是你该背的锅。
以上是开玩笑的描述,只是为了吸引增加阅读量
来源:juejin.cn/post/7388696625689051170
前端更新部署后通知用户刷新
前言
周五晚上组里说前端有bug,正在吃宵夜的我眉头一紧,立即打开了钉钉(手贱...),看了一下这不是前几天刚解决的吗,果然,使用刷新大法就解决,原因不过是用户一直停留在页面上,新的版本发布后,没有刷新拿不到新的资源。
现在大部分的前端系统都是SPA,用户在使用中对系统更新无感知,切换菜单等并不能获取最新资源,如果前端是覆盖性部署,切换菜单请求旧资源,这个旧资源已经被覆盖(hash打包的文件),还会出现一直无响应的情况。
那么,当前端部署更新后,提示一直停留在系统中的用户刷新系统很有必要。
解决方案
- 在public文件夹下加入manifest.json文件,记录版本信息
- 前端打包的时候向manifest.json写入当前时间戳信息
- 在入口JS引入检查更新的逻辑,有更新则提示更新
- 路由守卫router.beforeResolve(Vue-Router为例),检查更新,对比manifest.json文件的响应头Etag判断是否有更新
- 通过Worker轮询,检查更新,对比manifest.json文件的响应头Etag判断是否有更新。当然你如果不在乎这点点开销,可不使用Worker另开一个线程
Public下的加入manifest.json文件
{
"timestamp":1706518420707,
"msg":"更新内容如下:\n--1.添加系统更新提示机制"
}
这里如果是不向用户提示更新内容,可不填,前段开发者也无需维护manifest.json的msg内容,这里主要考虑到如果用户在填长表单的时候,填了一大半,你这时候给用户弹个更新提示,用户无法判断是否影响当前表单填写提交,如果将更新信息展示出来,用户感知更新内容,可判断是否需要立即刷新,还是提交完表单再刷新。
webpack向manifest.json写入当前时间戳信息
// 版本号文件
const filePath = path.resolve(`./public`, 'manifest.json')
// 读取文件内容
readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error('读取文件时出错:', err)
return
}
// 将文件内容转换JSON
const dataObj = JSON.parse(data)
dataObj.timestamp = new Date().getTime()
// 将修改后的内容写回文件
writeFile(filePath, JSON.stringify(dataObj), 'utf8', err => {
if (err) {
console.error('写入文件时出错:', err)
return
}
})
})
如果你无需维护更新内容的话,可直接写入timestamp
// 生成版本号文件
const filePath = path.resolve(`./public`, 'manifest.json')
writeFileSync(filePath, `${JSON.stringify({ timestamp: new Date().getTime() })}`)
检查更新的逻辑
入口文件main.js处引入
我这里检查更新的文件是放在utils/checkUpdate
// 检查版本更新
import '@/utils/checkUpdate'
checkUpdate文件内容如下
import router from '@/router'
import { Modal } from 'ant-design-vue'
if (process.env.NODE_ENV === 'production') {
let lastEtag = ''
let hasUpdate = false
let worker = null
async function checkUpdate() {
try {
// 检测前端资源是否有更新
let response = await fetch(`/manifest.json?v=${Date.now()}`, {
method: 'head'
})
// 获取最新的etag
let etag = response.headers.get('etag')
hasUpdate = lastEtag && etag !== lastEtag
lastEtag = etag
} catch (e) {
return Promise.reject(e)
}
}
async function confirmReload(msg = '', lastEtag) {
worker &&
worker.postMessage({
type: 'pause'
})
try {
Modal.confirm({
title: '温馨提示',
content: '系统后台有更新,请点击“立即刷新”刷新页面\n' + msg,
okText: '立即刷新',
cancelText: '5分钟后提示我',
onOk() {
worker.postMessage({
type: 'destroy'
})
location.reload()
},
onCancel() {
worker &&
worker.postMessage({
type: 'recheck',
lastEtag: lastEtag
})
}
})
} catch (e) {}
}
// 路由拦截
router.beforeEach(async (to, from, next) => {
next()
try {
await checkUpdate()
if (hasUpdate) {
worker.postMessage({
type: 'destroy'
})
location.reload()
}
} catch (e) {}
})
// 利用worker轮询
worker = new Worker(
/* webpackChunkName: "checkUpdate.worker" */ new URL('../worker/checkUpdate.worker.js', import.meta.url)
)
worker.postMessage({
type: 'check'
})
worker.onmessage = ({ data }) => {
if (data.type === 'hasUpdate') {
hasUpdate = true
confirmReload(data.msg, data.lastEtag)
}
}
}
这里因为缺换路由本来就要刷新页面,用户可无需感知系统更新信息,直接通过请求头的Etag即可,这里的Fetch方法就用head获取相应头就好了。
checkUpdate.worker.js文件如下
let lastEtag
let hasUpdate = false
let intervalId = ''
async function checkUpdate() {
try {
// 检测前端资源是否有更新
let response = await fetch(`/manifest.json?v=${Date.now()}`, {
method: 'get'
})
// 获取最新的etag和data
let etag = response.headers.get('etag')
let data = await response.json()
hasUpdate = lastEtag !== undefined && etag !== lastEtag
if (hasUpdate) {
postMessage({
type: 'hasUpdate',
msg: data.msg,
lastEtag: lastEtag,
etag: etag
})
}
lastEtag = etag
} catch (e) {
return Promise.reject(e)
}
}
// 监听主线程发送过来的数据
addEventListener('message', ({ data }) => {
if (data.type === 'check') {
// 每5分钟执行一次
// 立即执行一次,获取最新的etag,避免在setInterval等待中系统更新,第一次获取的etag是新的,但是lastEtag还是undefined,不满足条件,错失刷新时机
checkUpdate()
intervalId = setInterval(checkUpdate,5 * 60 * 1000)
}
if (data.type === 'recheck') {
// 每5分钟执行一次
hasUpdate = false
lastEtag = data.lastEtag
intervalId = setInterval(checkUpdate, 5 * 60 * 1000)
}
if (data.type === 'pause') {
clearInterval(intervalId)
}
if (data.type === 'destroy') {
clearInterval(intervalId)
close()
}
})
如果不使用worker直接讲轮询逻辑放在checkUpdate即可
Worker引入
从 webpack 5 开始,你可以使用 Web Workers 代替 worker-loader
。
new Worker(new URL('./worker.js', import.meta.url));
以下版本的就只能用worker-loader
咯
也可以逻辑写成字符串,然后通过ToURL给new Worker,如下:
function createWorker(f) {
const blob = new Blob(['(' + f.toString() +')()'], {type: "application/javascript"});
const blobUrl = window.URL.createObjectURL(blob);
const worker = new Worker(blobUrl);
return worker;
}
createWorker(function () {
self.addEventListener('message', function (event) {
// 消费信息
self.postMessage('send message')
}, false);
})
worker数据通信
// 主线程
var uInt8Array = new Uint8Array(new ArrayBuffer(10));
for (var i = 0; i < uInt8Array.length; ++i) {
uInt8Array[i] = i * 2; // [0, 2, 4, 6, 8,...]
}
worker.postMessage(uInt8Array);
// Worker 线程
self.onmessage = function (e) {
var uInt8Array = e.data;
postMessage('Inside worker.js: uInt8Array.toString() = ' + uInt8Array.toString());
postMessage('Inside worker.js: uInt8Array.byteLength = ' + uInt8Array.byteLength);
};
但是,拷贝方式发送二进制数据,会造成性能问题。比如,主线程向 Worker 发送一个 500MB 文件,默认情况下浏览器会生成一个原文件的拷贝。为了解决这个问题,JavaScript 允许主线程把二进制数据直接转移给子线程,但是一旦转移,主线程就无法再使用这些二进制数据了,这是为了防止出现多个线程同时修改数据的麻烦局面。这种转移数据的方法,叫做Transferable Objects。这使得主线程可以快速把数据交给 Worker,对于影像处理、声音处理、3D 运算等就非常方便了,不会产生性能负担。
如果要直接转移数据的控制权,就要使用下面的写法。
// Transferable Objects 格式
worker.postMessage(arrayBuffer, [arrayBuffer]);
// 例子
var ab = new ArrayBuffer(1);
worker.postMessage(ab, [ab]);
Web Worker 使用教程 - 阮一峰的网络日志 (ruanyifeng.com)
然而,并不是所有的对象都可以被转移。只有那些被设计为可转移的对象(用[ Transferable ] IDL 扩展属性修饰),比如ArrayBuffer、MessagePort,ImageBitmap,OffscreenCanvas,才能通过这种方式来传递。转移操作是不可逆的,一旦对象被转移,原始上下文中的引用将不再有效。转移对象可以显著减少复制数据所需的时间和内存。
---------------------------------更新-------------------------------------
如果只考虑是否更新,无需告知内容,无需创建manifest.json,直接通过index.html的Etag判断即可,因为打包工具会自动在filename添加hash,如果有内容修改,入口文件的js引入资源文件hash会改变,通过Etag判断即可。
async function checkUpdate() {
try {
// 检测前端资源是否有更新
let response = await fetch(`/?v=${Date.now()}`, {
method: 'head',
cache: 'no-cache'
})
// 获取最新的etag
let etag = response.headers.get('etag')
hasUpdate = lastEtag && etag !== lastEtag
lastEtag = etag
} catch (e) {
return Promise.reject(e)
}
}
来源:juejin.cn/post/7329280514628534313
🎲选择困难症的福音-基于threejs+cannonjs的扔骰子小游戏
在一个美好的周末,闲来无事,约上朋友一起在家打麻将,奈何尘封已久的麻将包里翻来翻去也没找到骰子的踪影,于是想在网上找一个骰子模拟器来代替,找了半天都没有发现一款合适好用的软件,于是心血来潮,打算自己做一个🎲模拟器。
1.制定需求设计以及技术方案
1.骰子模型
- 用户可以选择多种骰子模型,如六面 15面 20面等不同风格的供选择
- 并且可以选择要投掷骰子的数量 暂定1-10
2.分数计算规则
- 每次投掷完后会自动计算总点数并显示在屏幕上
- 可以保存历史摇骰子记录 最多支持历史100条记录
- 可以自定义用户参与摇骰子,投掷完成后会显示:luke摇出了xx点。。并且保存在历史记录中
- 支持多人参与摇骰子比赛:比如先加入4名玩家,开始后会依次提示轮到哪位玩家来开始投掷,并且可以选择每局大家需要投掷的次数,最后会统计总点数以及排名。
3.动画特效
- 每次投掷时对骰子随机一个角度以及投掷方向,如果同时投掷多个骰子则随机每一个骰子的角度,给骰子施加一个向赌盘中心的力,让骰子随机落在赌盘中部,在赌盘周围增加一道空气墙来阻止骰子移动到牌桌外。
- 模拟不同骰子的落地音效
4.技术实现
- 利用threejs来实现webgl相关渲染。如骰子模型、场景渲染、相机、棋盘等。
- 利用cannosjs来模拟物理引擎。如骰子碰撞检测、抛出坠落动画、重力加速等物理效果。
2.效果展示
3.开始实现
准备3d骰子模型和棋盘素材
首先是找到合适的gltf模型,这里我们在sketchfab上找到了一款质感很真实的骰子模型。下载下来的模型可以通过gltf-viewer来查看模型效果。
棋盘的话其实是一张图片平铺起来的,这里我用了一张木地板图片。
引入资源以及初始化webgl场景
import * as CANNON from "cannon-es";
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
//创建物理世界对象
const world = new CANNON.World();
// 设置物理世界重力加速度
world.gravity.set(0, -100, 0); //重力加速度: 单位:m/s²
创建物理模型、地面以及网格模型地面
这里简单叙述一下物理世界和webgl世界的联系以及如何在webgl场景里模拟出真实的物理效果。
- 首先在three创建的webgl场景是无法直接创建并感知到物理世界的,threejs只负责实时渲染物理的状态并展示在画布上。而cannos恰好相反,它不负责渲染,只负责创建一个物理世界以及具备物理引擎的物体,并根据物体状态实时计算物体的位置、角度等。并把这些信息实时同步给webgl场景中的模型,把模型渲染到页面上实现物理世界的可视化。
- 所以我们创建骰子、地面等模型都需要创建两份。一份在webgl中创建,一份在物理世界中创建,并且保持同样的尺寸。
//创建骰子网格模型(gltf模型)
const loader = new GLTFLoader();
const gltf = await loader.loadAsync('./assets/model/dice_model.glb');
const meshModel = gltf.scene;//获取箱子网格模型
meshModel.position.set(50,30,50);
meshModel.scale.set(5,5,5);
scene.add(meshModel);
//包围盒计算
const box3 = new THREE.Box3();
box3.expandByObject(meshModel);//计算模型包围盒
const size = new THREE.Vector3();
box3.getSize(size);//包围盒计算箱子的尺寸
// 创建骰子物理模型
const sphereMaterial = new CANNON.Material()//碰撞体材质
// 物理正方体
const bodyModel = new CANNON.Body({
mass: 0.3, // 碰撞体质量0.3kg
position: new CANNON.Vec3(50,30,50), // 位置
shape: new CANNON.Box(new CANNON.Vec3(size.x/2, size.y/2, size.z/2)),
material: sphereMaterial
});
// 物理地面
const groundMaterial = new CANNON.Material()
const groundBody = new CANNON.Body({
mass: 0, // 质量为0,始终保持静止,不会受到力碰撞或加速度影响
shape: new CANNON.Plane(),
material: groundMaterial,
});
// 改变平面默认的方向,法线默认沿着z轴,旋转到平面向上朝着y方向
groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);//旋转规律类似threejs 平面
world.addBody(groundBody)
//设置物理世界参数
const contactMaterial = new CANNON.ContactMaterial(groundMaterial, sphereMaterial, {
restitution: 0.5, //反弹恢复系数
})
// 把关联的材质添加到物理世界中
world.addContactMaterial(contactMaterial)
// 网格地面
const planeGeometry = new THREE.PlaneGeometry(1000, 1000);
const texture = new THREE.TextureLoader().load('./assets/textures/hardwood2_diffuse.jpg');
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(10, 10);
const planeMaterial = new THREE.MeshLambertMaterial({
// color:0x777777,
map: texture,
});
const planeMesh = new THREE.Mesh(planeGeometry, planeMaterial);
planeMesh.rotateX(-Math.PI / 2);
scene.add(planeMesh);
创建好模型后,我们在创建光源,相机等,这里就不再赘述。接下来我们开始设计物理世界的骰子抛出后坠落效果,并将物理世界和webgl渲染同步。
//点击屏幕后,设置物理骰子的角度和速度,物理会向上抛出并随着重力下落,触碰到地面后则会发生碰撞反弹
renderer.domElement.addEventListener('click', function (event) {
start_throw = true;
clearPoints();
const randomEuler = Math.random()*3;
bodyModel.quaternion.setFromEuler(Math.PI / randomEuler, Math.PI / randomEuler, Math.PI / randomEuler);
bodyModel.position.set(0,50,0);//点击按钮,body回到下落的初始位置
// 为物体设置初始速度(用以产生抛物线效果)
bodyModel.velocity.set(option.x,option.y,option.z); // x, y, z 方向上的速度
// 选中模型的第一个模型,开始下落
world.addBody(bodyModel);
})
function render() {
world.step(1/60);//更新物理计算
meshModel.position.copy(bodyModel.position); //渲染循环中,同步物理球body与网格球mesh的位置
meshModel.quaternion.copy(bodyModel.quaternion); //同步姿态角度
locateView(); //相机跟随物体移动
requestAnimationFrame(render);
renderer.render(scene, camera);
if (isBodyStopped(bodyModel)&&start_throw) {
showPoints(); //停止运动后,显示点数
start_throw = false;
}
}
render();//根据帧数渲染
接下来,我们再添加骰子点数计算相关逻辑。
//获取朝上面的点数
function getUpperFace(mesh) {
// 定义每个面的局部法线向量(手动定义)
const localNormals = [
new THREE.Vector3(0, 1, 0), // 面1
new THREE.Vector3(0, 0, -1), // 面2
new THREE.Vector3(-1, 0, 0), // 面3
new THREE.Vector3(1, 0, 0), // 面4
new THREE.Vector3(0, 0, 1), // 面5
new THREE.Vector3(0, -1, 0), // 面6
];
let maxDot = -Infinity;
let faceValue = 0;
for (let i = 0; i < localNormals.length; i++) {
// 将局部法线向量转化为世界空间
const worldNormal = localNormals[i].clone().applyQuaternion(mesh.quaternion);
// 与全局上方向的点积
const dot = worldNormal.dot(new THREE.Vector3(0, 1, 0));
// 检查点积,找到最大值
if (dot > maxDot) {
maxDot = dot;
faceValue = i + 1; // 面的点数即为索引加1
}
}
return faceValue;
}
//判断物体是否停止运动
function isBodyStopped(body, linearVelocityThreshold = 0.1, angularVelocityThreshold = 0.1) {
// 获取物体的线性速度和角速度
const linearVelocityMagnitude = body.velocity.length();
const angularVelocityMagnitude = body.angularVelocity.length();
// 判断速度是否低于设定的阈值
return linearVelocityMagnitude < linearVelocityThreshold && angularVelocityMagnitude < angularVelocityThreshold;
}
//展示点数
function showPoints() {
let res = getUpperFace(meshModel);
let point = document.getElementById("points");
point.innerHTML = `点数:${res}`
}
//清空点数
function clearPoints() {
let point = document.getElementById("points");
point.innerHTML = ``
}
实现这些逻辑后,我们已经可以模拟出骰子抛出坠落并触碰地面后反弹,在停止运动后计算点数的效果。已经实现基础功能,但是我们发现如果随机速度过大的时候会移动很远才停下,于是我们增加一个空气墙来限制骰子在固定范围内。并且增加一个碰撞检测来触发撞地声音的效果。
//添加空气墙
const wallShape = new CANNON.Box(new CANNON.Vec3(100, 100, 0.1));
const wall1 = new CANNON.Body({ mass: 0 });
wall1.addShape(wallShape);
wall1.position.set(0, 100, -100); // Back wall
world.addBody(wall1);
const wall2 = new CANNON.Body({ mass: 0 });
wall2.addShape(wallShape);
wall2.position.set(0, 100, 100); // Front wall
world.addBody(wall2);
const wall3 = new CANNON.Body({ mass: 0 });
wall3.addShape(wallShape);
wall3.quaternion.setFromEuler(0, Math.PI / 2, 0); // Rotate for side walls
wall3.position.set(100, 100, 0); // Right wall
world.addBody(wall3);
const wall4 = new CANNON.Body({ mass: 0 });
wall4.addShape(wallShape);
wall4.quaternion.setFromEuler(0, Math.PI / 2, 0); // Rotate for side walls
wall4.position.set(-100, 100, 0); // Left wall
world.addBody(wall4);
//监听物体碰撞回调
const audio = new Audio('./assets/audio/peng.mp3');
bodyModel.addEventListener('collide', (event) => {
const contact = event.contact;
//获得沿法线的冲击速度
const ImpactV = contact.getImpactVelocityAlongNormal();
// 碰撞越狠,声音越大
if(ImpactV/35>1) {
audio.volume = 1;
} else {
audio.volume = ImpactV/35>0?ImpactV/35:0;
}
audio.currentTime = 0;
audio.play();
})
这样我们就初步完成了抛掷一个骰子并获取点数的功能,看似简单的一个场景实际上设计起来并不容易,要考虑很多因素。后续我会继续增加多个骰子同时抛掷的场景,以及比赛模式。源码也会贡献出来供大家一起学习参考,如果有更好的idea也可以在评论区留言或私信,大家一起在webgl中感受物理世界的魅力!
附完整代码:
import * as CANNON from "cannon-es";
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// 实例化一个gui对象
// const gui = new GUI();
// //改变交互界面style属性
// gui.domElement.style.right = '0px';
// gui.domElement.style.width = '300px';
const option = {
z: -24,
x: -36,
y: -17,
z1: 1,
x1: 1,
y1: 1,
}
// //gui控制参数
// const folder_position = gui.addFolder('速度方向');
// folder_position.add(option, 'z', -100, 100);
// folder_position.add(option, 'x', -100, 100);
// folder_position.add(option, 'y', -100, 100);
// const folder_rotation = gui.addFolder('角度');
// folder_rotation.add(option, 'z1', -10, 10).step(0.1);
// folder_rotation.add(option, 'x1', -10, 10).step(0.1);
// folder_rotation.add(option, 'y1', -10, 10).step(0.1);
// CANNON.World创建物理世界对象
const world = new CANNON.World();
// 设置物理世界重力加速度
// world.gravity.set(0, -1000, 0); //重力加速度: 单位:m/s²
world.gravity.set(0, -100, 0);
//网格球体(gltf模型)
const loader = new GLTFLoader();
const gltf = await loader.loadAsync('./assets/model/dice_model.glb');
const meshModel = gltf.scene;//获取箱子网格模型
meshModel.position.set(50,30,50);
meshModel.scale.set(5,5,5);
scene.add(meshModel);
//包围盒计算
const box3 = new THREE.Box3();
box3.expandByObject(meshModel);//计算模型包围盒
const size = new THREE.Vector3();
box3.getSize(size);//包围盒计算箱子的尺寸
// 物理球体
const sphereMaterial = new CANNON.Material()//碰撞体材质
// 物理箱子
const bodyModel = new CANNON.Body({
mass: 0.3, // 碰撞体质量0.3kg
position: new CANNON.Vec3(50,30,50), // 位置
shape: new CANNON.Box(new CANNON.Vec3(size.x/2, size.y/2, size.z/2)),
material: sphereMaterial
});
// 骨骼辅助显示
const skeletonHelper = new THREE.SkeletonHelper(meshModel);
scene.add(skeletonHelper);
// world.addBody(bodyModel);
//添加空气墙
// Create air walls
const wallShape = new CANNON.Box(new CANNON.Vec3(100, 100, 0.1));
const wall1 = new CANNON.Body({ mass: 0 });
wall1.addShape(wallShape);
wall1.position.set(0, 100, -100); // Back wall
world.addBody(wall1);
const wall2 = new CANNON.Body({ mass: 0 });
wall2.addShape(wallShape);
wall2.position.set(0, 100, 100); // Front wall
world.addBody(wall2);
const wall3 = new CANNON.Body({ mass: 0 });
wall3.addShape(wallShape);
wall3.quaternion.setFromEuler(0, Math.PI / 2, 0); // Rotate for side walls
wall3.position.set(100, 100, 0); // Right wall
world.addBody(wall3);
const wall4 = new CANNON.Body({ mass: 0 });
wall4.addShape(wallShape);
wall4.quaternion.setFromEuler(0, Math.PI / 2, 0); // Rotate for side walls
wall4.position.set(-100, 100, 0); // Left wall
world.addBody(wall4);
camera.position.set(42,85,21)
camera.lookAt(0,10,0);
// 物理地面
const groundMaterial = new CANNON.Material()
const groundBody = new CANNON.Body({
mass: 0, // 质量为0,始终保持静止,不会受到力碰撞或加速度影响
shape: new CANNON.Plane(),
material: groundMaterial,
});
// 改变平面默认的方向,法线默认沿着z轴,旋转到平面向上朝着y方向
groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);//旋转规律类似threejs 平面
world.addBody(groundBody)
//设置物理世界参数
const contactMaterial = new CANNON.ContactMaterial(groundMaterial, sphereMaterial, {
restitution: 0.5, //反弹恢复系数
})
// 把关联的材质添加到物理世界中
world.addContactMaterial(contactMaterial)
//光源设置
const directionalLight = new THREE.DirectionalLight(0xffffff, 2);
directionalLight.position.set(20, 100, 10);
scene.add(directionalLight);
// 网格地面
const planeGeometry = new THREE.PlaneGeometry(1000, 1000);
const texture = new THREE.TextureLoader().load('./assets/textures/hardwood2_diffuse.jpg');
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(10, 10);
const planeMaterial = new THREE.MeshLambertMaterial({
// color:0x777777,
map: texture,
});
const planeMesh = new THREE.Mesh(planeGeometry, planeMaterial);
planeMesh.rotateX(-Math.PI / 2);
scene.add(planeMesh);
// 添加一个辅助网格地面
// const gridHelper = new THREE.GridHelper(50, 50, 0x004444, 0x004444);
// scene.add(gridHelper);
var controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // 允许阻尼效果
controls.dampingFactor = 0.25; // 阻尼系数
let start_throw = false;
renderer.domElement.addEventListener('click', function (event) {
start_throw = true;
clearPoints();
const randomEuler = Math.random()*3;
bodyModel.quaternion.setFromEuler(Math.PI / randomEuler, Math.PI / randomEuler, Math.PI / randomEuler);
bodyModel.position.set(0,50,0);//点击按钮,body回到下落的初始位置
// 为物体设置初始速度(用以产生抛物线效果)
bodyModel.velocity.set(option.x,option.y,option.z); // x, y, z 方向上的速度
// 选中模型的第一个模型,开始下落
world.addBody(bodyModel);
})
const audio = new Audio('./assets/audio/peng.mp3');
bodyModel.addEventListener('collide', (event) => {
const contact = event.contact;
//获得沿法线的冲击速度
const ImpactV = contact.getImpactVelocityAlongNormal();
// 碰撞越狠,声音越大
if(ImpactV/35>1) {
audio.volume = 1;
} else {
audio.volume = ImpactV/35>0?ImpactV/35:0;
}
audio.currentTime = 0;
audio.play();
})
//判断物体是否停止运动
function isBodyStopped(body, linearVelocityThreshold = 0.1, angularVelocityThreshold = 0.1) {
// 获取物体的线性速度和角速度
const linearVelocityMagnitude = body.velocity.length();
const angularVelocityMagnitude = body.angularVelocity.length();
// 判断速度是否低于设定的阈值
return linearVelocityMagnitude < linearVelocityThreshold && angularVelocityMagnitude < angularVelocityThreshold;
}
function showPoints() {
let res = getUpperFace(meshModel);
let point = document.getElementById("points");
point.innerHTML = `点数:${res}`
}
//相机跟随物体移动
function locateView() {
camera.position.x = meshModel.position.x;
camera.position.y = meshModel.position.y + 30;
camera.position.z = meshModel.position.z + 20;
camera.lookAt(meshModel.position)
}
function clearPoints() {
let point = document.getElementById("points");
point.innerHTML = ``
}
//获取朝上面的点数
function getUpperFace(mesh) {
// 定义每个面的局部法线向量(手动定义)
const localNormals = [
new THREE.Vector3(0, 1, 0), // 面1
new THREE.Vector3(0, 0, -1), // 面2
new THREE.Vector3(-1, 0, 0), // 面3
new THREE.Vector3(1, 0, 0), // 面4
new THREE.Vector3(0, 0, 1), // 面5
new THREE.Vector3(0, -1, 0), // 面6
];
let maxDot = -Infinity;
let faceValue = 0;
for (let i = 0; i < localNormals.length; i++) {
// 将局部法线向量转化为世界空间
const worldNormal = localNormals[i].clone().applyQuaternion(mesh.quaternion);
// 与全局上方向的点积
const dot = worldNormal.dot(new THREE.Vector3(0, 1, 0));
// 检查点积,找到最大值
if (dot > maxDot) {
maxDot = dot;
faceValue = i + 1; // 面的点数即为索引加1
}
}
return faceValue;
}
function render() {
world.step(1/60);//更新物理计算
meshModel.position.copy(bodyModel.position); //渲染循环中,同步物理球body与网格球mesh的位置
meshModel.quaternion.copy(bodyModel.quaternion); //同步姿态角度
locateView(); //相机跟随物体移动
requestAnimationFrame(render);
renderer.render(scene, camera);
if (isBodyStopped(bodyModel)&&start_throw) {
showPoints(); //停止运动后,显示点数
start_throw = false;
}
}
render();
来源:juejin.cn/post/7394993393125064704
谈谈国内前端的三大怪啖
因为工作的原因,我和一些外国前端开发有些交流。他们对于国内环境不了解,有时候会问出一些有趣的问题,大概是这些问题的启发,让我反复在思考一些更为深入的问题。
今天聊三个事情:
- 小程序
- 微前端
- 模块加载
小程序
每个行业都有一把银座,当坐上那把银座时,做什么便都是对的。
“我们为什么需要小程序?”
第一次被问到这个问题,是因为一个法国的同事。他被派去做一个移动端业务,刚好那个业务是采用小程序在做。于是一个法国小哥就在被痛苦的中文文档和黑盒逻辑中来回折磨着 🤦。
于是,当我们在有一次交流中,他问出了我这个问题:我们为什么需要小程序?
说实话,我试图解释了 19 年国内的现状,以及微信小程序推出时候所带来的便利和体验等等。总之,在我看来并不够深刻的见解。
即便到现在为止,每次当我使用小程序的时候,依旧会复现这个问题。在 ChatGPT 11 月份出来的时候,我也问了它这个很有国内特色的问题:
看起来它回答的还算不错,至少我想如果它来糊弄那些老外,应该会比我做的更好些。
但如果扪心自问,单从技术上来讲。以上这些事情,一定是有其他方案能解决的。
所以从某种程度上来看,这更像是一场截胡的商业案例:
应用市场
全世界的互联网人都知道应用市场是非常有价值的事情,可以说操作系统最值钱的部分就在于他们构建了自己的应用市场。
只要应用在这里注册发行,雁过拔毛,这家公司就是互联网世界里的统治阶级,规则的制定者。
反之则需要受制于人,APP 做的再大,也只是应用市场里的一个应用,做的好与坏还得让应用商店的评判。
另外,不得不承认的是,一个庞大如苹果谷歌这样的公司,他们的应用商店对于普通国内开发者来说,确实是有门槛的。
在国内海量的 APP 需求来临之前,能否提供一个更低成本的解决方案,来消化这些公司的投资?
毕竟不是所有的小企业都需要 APP,其实他们大部分需求 Web 就可以解决,但是 Web 没牌面啊,做 Web 还得砸搜索的钱才有流量。(某度搜索又做成那样...)
那做操作系统?太不容易,那么多人都溺死在水里了,这水太深。
那有没有一种办法可以既能构建生态,又有 APP 的心智,还能给入驻企业提供流量?
于是,在 19 年夏天,滨海大厦下的软件展业基地里,每天都在轮番播放着,做 XX小程序,拥抱下一个风口...
全新体验心智
小程序用起来挺方便的。
你有没有想过,这些美妙感觉的具体都来自哪些?以及这些真的是 Web 技术本身无法提供的吗?
- 靠谱感,每个小程序都有约束和规范,于是你可以将他们整整齐齐的陈放在你的列表里,仿佛你已经拥有了这一个个精心雕琢的作品,相对于一条条记不住的网页地址和鱼龙混杂的网页内容来说,这让你觉得小程序更加的有分量和靠谱。
- 安全感,沉浸式的头部,没有一闪而过的加载条,这一切无打扰的设计,都让你觉得这是一个在你本地的 APP,而不是随时可能丢失网页。你不会因为网速白屏而感到焦虑,尽管网络差的时候,你的 KFC 依旧下不了单 😂
- 沉浸感,我不知道是否打开网页时顶部黑黑的状态栏是故意留下的,还是不小心的... 这些限制都让用户非常强烈的意识到这是一个网页而不是 APP,而小程序中虽然上面也存在一个空间的空白,但是却可以被更加沉浸的主题色和氛围图替代。网页这个需求做不了?我不信。
H5 | 小程序 |
---|---|
- 顺滑感,得益于 Native 的容器实现,小程序在所有的视图切换时,都可以表现出于原生应用一样的顺滑感。其实这个问题才是在很多 Hybrid 应用中,主要想借助客户端来处理和解决的问题。类似容器预开、容器切换等技术是可以解决相关问题的,只是还没有一个标准。
我这里没有提性能,说实话我不认为性能在小程序中是有优势的(Native 调用除外,如地图等,不是一个讨论范畴)。作为普通用户,我们感受到的只是离线加载下带来的顺滑而已。
而上述提到的许多优势,这对于一个高品质的 Web 应用来说是可以做得到的,但注意这里是高品质的 Web 应用。而这种“高品质”在小程序这里,只是入驻门槛而已。
心智,这个词,听起来很黑话,但却很恰当。当小程序通过长期这样的筛选,所沉淀出来一批这样品质的应用时。就会让每个用户即便在还没打开一个新的小程序之前,也有不错体验的心理预期。这就是心智,一种感觉跟 Web 不一样,跟 APP 有点像的心智。
打造心智,这件事情好像就是国内互联网企业最擅长做的事情,总是能从一些细微的差别中开辟一条独立的领域,然后不断强化灌输本来就不大的差异,等流量起来再去捞钱变现。
我总是感觉现在的互利网充斥着如何赚钱的想法,好像永远赚不够。“赚钱”这个事情,在这些公司眼里就是圈人圈地抢资源,看看谁先占得先机,别人有的我也得有,这好像是最重要的事情。
很少有企业在思考如何创造些没有的市场,创造些真正对技术发展有贡献,对社会发展有推动作用的事情。所以在国内互联网圈也充斥着一种奇怪的价值观,有技术的不一定比赚过钱的受待见。
管你是 PHP 还是 GO,管你是在做游戏还是直播带货,只要赚到钱就是高人。并且端的是理所应当、理直气壮,有些老板甚至把拍摄满屋子的程序员为自己打工作为一种乐趣。什么情怀、什么优雅、什么愿景,人生就俩字:搞钱。
不是故意高雅,赚钱这件事情本身不寒碜,只是在已经赚到盆满钵满、一家独大的时候还在只是想着赚更多的钱,好像赚钱的目的就是为了赚钱一样,这就有点不合适。企业到一定程度是要有社会责任的,龙头企业每一个决定和举措,都有会影响接下来的几年里这个行业的价值观走向。
当然也不是完全没有好格局的企业,我非常珍惜每一个值得尊重的中国企业,来自一个蔚来车主。
小程序在商业上固然是成功的,但吃的红利可以说还是来自 网页 到 应用 的心智变革。将本来流向 APP 的红利,截在了小程序生态里。
但对于技术生态的发展却是带来了无数条新的岔路,小程序的玩法就决定了它必须生长在某个巨型应用里面,不论是用户数据获取、还是 API 的调用,其实都是取决于应用容器的标准规范。
不同公司和应用之间则必然会产生差异,并且这种差异是墒增式的差异,只会随着时间的推移越变越大。如果每个企业都只关注到自己业务的增长,无外部约束的话,企业必然会根据自己的业务发展和政策需要,选择成本较低的调整 API,甚至会故意制造一些壁垒来增加这种差异。
小程序,应该是 浏览器 与 操作系统 的融合,这本应该是推动这两项技术操刀解决的事情。
微前端
qiankun、wujie、single-spa 是近两年火遍前端的技术方案,同样一个问题:我们为什么需要微前端?
我不确定是否每个在使用这项技术的前端都想清楚了这个问题,但至少在我面试过的候选人中,我很少遇到对自己项目中已经在使用的微前端,有很深的思考和理解的人。
先说下我的看法:
- 微前端,重在解决项目管理而不在用户体验。
- 微前端,解决不了该优化和需要规范的问题。
- 微前端,在挽救没想清楚 MPA 的 SPA 项目。
没有万能银弹
银色子弹(英文:Silver Bullet),或者称“银弹”“银质子弹”,指由纯银质或镀银的子弹。在欧洲民间传说及19世纪以来哥特小说风潮的影响下,银色子弹往往被描绘成具有驱魔功效的武器,是针对狼人、吸血鬼等超自然怪物的特效武器。后来也被比喻为具有极端有效性的解决方法,作为杀手锏、最强杀招、王牌等的代称。
所有技术的发展都是建立在前一项技术的基础之上,但技术依赖的选择过程中一定需要保留第一性原理的意识。
当 React、Vue 兴起,当 打包技术(Webpack) 兴起,当 网页应用(SPA) 兴起,这些杰出的技术突破都在不同场景和领域中给行业提供了新的思路、新的方案。
不知从何时开始,前端除了 div 竟说不出其他的标签(还有说 View 的),项目中再也不会考虑给一个通用的 class 解决通用样式问题。
不知从何时开始,有没有权限需要等到 API 请求过后才知道,没有权限的话再把页面跳转过去申请。
不知从何时开始,大家的页面都放在了一个项目里,两个这样的巨石应用融合竟然变成了一件困难的事。
上面这些不合理的现状,都是在不同的场景下,不思考适不适合,单一信奉 “一招吃遍天” 下演化出的问题。
B 端应用,是否应该使用 SPA? 这其实是一个需要思考的问题。
微前端从某种程度上来讲,是认准 SPA 了必须是互联网下一代应用标准的产物,好像有了 SPA 以后,MPA 就变得一文不值。甭管页面是移动端的还是PC的;甭管页面是面对 C 端的还是 B 端的;甭管一个系统是有 20 个页面还是 200 个页面,一律行这套逻辑。
SPA 不是万能银弹,React 不是万能银弹,Tailwind 不是万能银弹。在新技术出现的时候,保持热情也要保持克制。
ps. 我也十分痛恨 React 带来的这种垄断式的生态,一个 React 组件将 HTML 和 Style 都吃进去,让你即使在做一个移动端的纯展示页面时,也需要背上这些称重的负担。
质疑 “墨守成规”,打开视野,深度把玩,理性消费。
分而治之
分治法,一个很基本的工程思维。
在我看来在一个正常商业迭代项目中的主要维护者,最好不要超过 3 个人,注意是主要维护者(Maintainer) 。
你应该让每个项目都有清晰的责任人,而不是某行代码,某个模块。责任人的理解是有归属感,有边界感的那种,不是口头意义上的责任人。(一些公司喜欢搞这种虚头巴脑的事情,什么连坐…)
我想大部分想引入微前端的需求都是类似 如何更好的划分项目边界,同时保留更好的团队协同。
比如 导航菜单 应该是独立收口独立管理的,并且当其更新时,应该同时应用于所有页面中。类似的还有 环境变量、时区、主题、监控及埋点。微前端将这些归纳在主应用中。
而具体的页面内容,则由对应的业务进行开发子应用,最后想办法将路由关系注册进主应用即可。
当然这样纯前端的应用切换,还会出现不同应用之间的全局变量差异、样式污染等问题,需要提供完善的沙箱容器、隔离环境、应用之间通信等一系列问题,这里不展开。
当微前端做到这一部分的时候,我不禁在想,这好像是在用 JavaScript 实现一个浏览器的运行容器。这种本应该浏览器本身该做的事情,难道 JS 可以做的更好?
只是做到更好的项目拆分,组织协同的话,引入后端服务,由后端管控路由表和页面规则,将页面直接做成 MPA,这个方案或许并不比引入微前端成本高多少。
体验差异
从 SPA 再回 MPA,说了半天不又回去了么。
所以不防想想:在 B端 业务中使用 SPA 的优势在哪里?
流畅的用户体验:
这个话题其实涵盖范围很广,对于 SPA 能带来的 “流畅体验”,对于大多数情况下是指:导航菜单不变,内容变化 发生变化,页面切换中间不出现白屏。
但要做到这个点,其实对于 MPA 其实并没有那么困难,你只需要保证你的 FCP 在 500ms 以内就行。
以上的页面切换全部都是 MPA 的多页面切换,我们只是简单做了导航菜单的 拆分 和 SWR,并没有什么特殊的 preload、prefetch 处理,就得到了这样的效果。
因为浏览器本身在页面切换时会在 unload 之前先 hold 当前页面的视图不变,发起一下一个 document 的请求,当页面的视图渲染做到足够快和界面结构稳定就可以得到这样的效果。
这项浏览器的优化手段我找了很久,想找一篇关于它的博文介绍,但确实没找到相关内容,所以 500ms 也是我的一个大致测算,如果你知道相关的内容,可以在评论区补充,不胜感激。
所以从这个角度来看,浏览器本身就在尽最大的努力做这些优化,并且他们的优化会更底层、更有效的。
离线访问 (PWA)
SPA 确实会有更好的 PWA 组织能力,一个完整的 SPA 应用甚至可以只针对编译层做改动就可以支持 PWA 能力。
但如果看微前端下的 SPA 应用,需要支持 PWA 那就同样需要分析各子应用之间的元数据,定制 Service Worker。这种组织关系和定制 SW,对于元数据对于数据是来自前端还是后端,并不在意。
也就是说微前端模式下的 PWA,同样的投入成本,把页面都管理在后端服务中的 MPA 应用也是可以做到相同效果的。
项目协同、代码复用
有人说 SPA 项目下,项目中的组件、代码片段是可以相互之间复用的,在 MPA 下就相对麻烦。
这其实涉及到项目划分的领域,还是要看具体的需求也业务复杂度来定。如果说整个系统就是二三十个页面,这做成 SPA 使用前端接管路由高效简单,无可厚非。
但如果你本身在面对的是一个服务于复杂业务的 B 端系统,比如类似 阿里云、教务系统、ERP 系统或者一些大型内部系统,这种往往需要多团队合作开发。这种时候就需要跟完善的项目划分、组织协同和系统整合的方案。
这个时候 SPA 所体现出的优势在这样的诉求下就会相对较弱,在同等投入的情况下 MPA 的方案反而会有更少的执行成本。
也不是所有项目一开始就会想的那么清楚,或许一开始的时候就是个简单的 SPA 项目,但是随着项目的不断迭代,才变成了一个个复杂的巨石应用,现在如果再拆出来也会有许多迁移成本。引入微前端,则可以...
这大概是许多微前端项目启动的背景介绍,我想说的是:对于屎山,我从来不信奉“四两拨千斤”。
如果没有想好当下的核心问题,就引入新的“银弹”解决问题,只会是屎山雕花。
项目协同,抽象和复用这些本身不是微前端该解决的问题,这是综合因素影响下的历史背景问题。也是需要一个个资深工程师来把控和解决的核心问题,就是需要面对不同的场景给出不同的治理方案。
这个道理跟防沙治沙一样,哪有那么多一蹴而就、立竿见影的好事。
模块加载
模块加载这件事情,从玉伯大佬的成名作 sea.js 开始就是一个非常值得探讨的问题。在当时 jQuery 的时代里,这是一个绝对超前的项目,我也在实际业务中体会过在无编译的环境下 sea.js 的便捷。
实际上,不论是微前端、低代码、会场搭建等热门话题离不开这项技术基础。
import * from *
我们每天都在用,但最终的产物往往是一个自运行的 JS Bundle,这来自于 Webpack、Vite 等编译技术的发展。让我们可以更好的组织项目结构,以构建更复杂的前端应用。
模块的概念用久了,就会自然而然的在遇到浏览器环境中,遇到动态模块加载的需求时,想到这种类似模块加载的能力。
比如在遇到会场千奇百怪的个性化营销需求时,能否将模块的 Props 开放出来,给到非技术人员,以更加灵活的方式让他们去做自由组合。
比如在低代码平台中,让开发者自定义扩展组件,动态的将开发好的组件注册进低代码平台中,以支持更加个性的需求。
在万物皆组件的思想影响下,把一整个完整页面都看做一个组件也不是不可以。于是在一些团队中,甚至提倡所有页面都可以搭建、搭建不了的就做一个大的页面组件。这样及可以减少维护运行时的成本,又可以统一管控和优化,岂不美哉。
当这样大一统的“天才方案”逐渐发展成为标准后,也一定会出现一些特殊场景无法使用,但没关系,这些天才设计者肯定会提供一种更加天才的扩展方案出来。比如插件,比如扩展,比如 IF ELSE。再后来,就会有性能优化了,而优化的 追赶对象 就是用原来那种简单直出的方案。
有没有发现,这好像是在轮回式的做着自己出的数学题,一道接一道,仿佛将 1 + 1的结论重新演化了一遍。
题外话,我曾经自己实现过一套通过 JSON Schema 描述 React 结构的 “库” ,用于一个低代码核心层的渲染。在我的实现过程中,我越发觉得我在做一件把 JSX 翻译成 JS 的事情,但 JSX 或者 HTML 本身不就是一种 DSL 么。为什么一定要把它翻译成 JSON 来传输呢?或者说这样的封装其本身有意义么?这不就是在做 PHP、.Net 直接返回带有数据的 HTML Ajax 一样的事情么。
传统的浏览器运行环境下要实现一个模块加载器,无非是在全局挂载一个注册器,通过 Script 插入一段新的 JS,该 JS 通过特殊的头尾协议,将运行时的代码声明成一个函数,注册进事先挂载好的注册器。
但实际的实现场景往往要比这复杂的多,也有一些问题是这种非原生方式无法攻克的问题。比如全局注册器的命名冲突;同模块不同版本的加载冲突;并发加载下的时序问题;多次模块加载的缓存问题 等等等等等...
到最后发现,这些事情好像又是在用 JS 做浏览器该做的事情。然而浏览器果然就做了,,Vite 就主要采用这种模式实现了它 1 年之内让各大知名项目切换到 Vite 的壮举。
“但我们用不了,有兼容性问题。”
哇哦,当我看着大家随意写出的 display: grid
样式定义,不禁再次感叹人们对未知的恐惧。
import.meta
的兼容性是另外一个版本,是需要 iOS12 以上,详情参考:caniuse.com/?search=imp…
试想一下,现在的低代码、会场搭建等等各类场景的模块加载部分,如果都直接采用 ESM 的形式处理,这对于整个前端生态和开发体验来说会有多么大的提升。
模块加载,时至今日,本来就已经不再需要 loader。 正如 seajs 中写的:前端模块化开发那点历史
历史不是过去,历史正在上演。随着 W3C 等规范、以及浏览器的飞速发展,前端的模块化开发会逐步成为基础设施。一切终究都会成为历史,未来会更好。
结语
文章的结尾,我想感叹另外一件事,国人为什么一定要有自己的操作系统?为什么一定需要参与到一些规范的制定中?
因为我们的智慧需要有开花的土壤,国内这千千万开发者的抱负需要有地方释放。
如果没有自己掌握核心技术,就是只能在问题出现的时候用另类的方式来解决。最后在一番折腾后,发现更底层的技术只要稍稍一改就可以实现的更好。这就像三体中提到的 “智子” 一样,不断在影响着我们前进的动力和方向。
不论是小程序、微前端还是模块加载。试想一下,如果我们有自己的互联网底蕴,能决定或者影响操作系统和浏览器的底层能力。这些 “怪啖” 要么不会出现,要么就是人类的科技创新。
希望未来技术人不用再追逐 Write Once, Run Everywhere 的事情...
来源:juejin.cn/post/7267091810366488632
为什么删除node_modules文件夹那么慢
Windows系统 为什么删除node_modules文件夹那么慢?
在Windows系统中删除node_modules
文件夹可能会比较慢的原因有以下几点:
- 文件数量过多:
node_modules
文件夹通常包含大量的文件和文件夹,如果其中文件数量过多,系统需要逐一扫描并删除每个文件,这会导致删除过程变得缓慢。 - 文件路径过长:在Windows系统中,文件路径的长度有限制,如果
node_modules
文件夹中存在过长的文件路径,系统在删除这些文件时可能会变得缓慢。 - 文件占用:
node_modules
文件夹中可能包含一些被其他程序占用的文件,这会导致系统无法立即删除这些文件,从而延长删除时间。 - 磁盘速度:如果
node_modules
文件夹位于机械硬盘上而非固态硬盘,机械硬盘的读写速度相对较慢,也会影响删除操作的速度。 - 杀软扫描:有些杀毒软件在删除文件时会对文件进行扫描,以确保文件不包含恶意代码。这个额外的扫描过程也会增加删除文件的时间。
为什么在苹果系统上删除node_modules文件夹就很快?
- 文件系统差异:Windows采用的是NTFS文件系统,而macOS使用的是APFS文件系统,APFS 在快速复制、文件元数据管理、空间分配等方面具有优势,支持快速文件复制、快速目录大小计算、快速空间释放等功能,而 NTFS 和 exFAT 在某些方面可能不如 APFS 那么快速和高效。
- 文件路径处理:Windows对文件路径长度有限制,而macOS对文件路径长度的限制相对较宽松。如果
node_modules
文件夹中存在过长的文件路径,Windows系统在处理这些文件时可能会变得缓慢。 - 文件锁定:Windows系统在处理被其他程序占用的文件时,可能会出现文件锁定的情况,导致删除操作变得缓慢。而macOS系统在这方面可能更加灵活。
- 文件系统碎片:Windows系统在长时间使用后可能会产生文件系统碎片,这会影响文件的读写和删除速度。而macOS对文件系统碎片的处理可能更加高效。
Windows中删除慢解决方案
为了加快在Windows系统中删除文件夹的速度,可以尝试使用命令行删除、关闭占用文件的程序、使用专门的删除工具等方法,以提高删除效率。
- 在删除前关闭占用文件的程序:确保
node_modules
文件夹中的文件没有被其他程序占用,可以提前关闭相关程序再进行删除操作。 - 使用固态硬盘:如果可能的话,将
node_modules
文件夹放在固态硬盘上,可以显著提高文件的读写速度。 - 使用命令行删除:在命令行中使用
rd /s /q node_modules
命令可以快速删除node_modules
文件夹,避免Windows资源管理器中的删除操作。 - 使用专门的删除工具:例如 npm 全局安装 rimraf,以后直接使用删除命令即可。
npm install rimraf -g
~
rimraf node_modules/
来源:juejin.cn/post/7350107540325875721
Fuse.js一个轻量高效的模糊搜索库
最近逛github的时候发现了一个非常好用的轻量工具库,Fuse.js,支持模糊搜索。感觉还是非常好用的,所以有了此篇博客,这篇文章主要是介绍Fuse的使用,同样,我对这个开源项目的实现也非常感兴趣。后续会出一篇Fuse源码解析的文章来分析其实现原理。
Fuse.js是什么?
强大、轻量级的模糊搜索库,没有任何依赖关系。
什么是模糊搜索?
一般来说,模糊搜索(更正式的名称是近似字符串匹配)是查找与给定模式近似相等(而不是完全相等)的字符串的技术。
通常我们项目中的的模糊搜索大多数情况下有几种方案可用:
- 前端工程通过正则表达式或者字符串匹配来实现
- 调用后端接口去匹配搜索
- 使用搜索引擎如:ElasticSearch或Algolia等
但是这些方案都有各自的缺陷,比如正则表达式和字符串匹配的效率较低,且无法处理复杂的搜索需求,而调用后端接口和搜索引擎虽然效率高,但是需要额外的服务器资源,且需要维护一套搜索引擎。
所以,Fuse.js的出现就是为了解决这些问题,它是一个轻量级的模糊搜索库,没有依赖关系,支持复杂的搜索需求,且效率高,当然Fuse并不适用于所有场景。
Fuse.js的使用场景
它可能不适用于所有情况,但根据您的搜索要求,它可能是最理想的。例如:
- 当您想要对小型到中等大型数据集进行客户端模糊搜索时
- 当您无法证明设置专用后端只是为了处理搜索时
- ElasticSearch 或 Algolia 虽然都是很棒的服务,但对于您的特定用例来说可能有些过度
Fuse.js的使用
安装
Fuse支持多种安装方式
NPM
npm install fuse.js
Yarn
yarn add fuse.js
CDN 引入
<script src="https://cdn.jsdelivr.net/npm/fuse.js@7.0.0">script>
引入
ES6 模块语法
import Fuse from 'fuse.js'
CommonJS 语法
const Fuse = require('fuse.js')
Tips: 使用npm或者yarn引入,支持两种模块语法引入,如果是使用cdn引入,那么Fuse将被注册为全局变量。直接使用即可
使用
以下是官网一个最简单的例子,只要简单的构造new Fuse对象,就能模糊搜索匹配到你想要的结果
// 1. List of items to search in
const books = [
{
title: "Old Man's War",
author: {
firstName: 'John',
lastName: 'Scalzi'
}
},
{
title: 'The Lock Artist',
author: {
firstName: 'Steve',
lastName: 'Hamilton'
}
}
]
// 2. Set up the Fuse instance
const fuse = new Fuse(books, {
keys: ['title', 'author.firstName']
})
// 3. Now search!
fuse.search('jon')
// Output:
// [
// {
// item: {
// title: "Old Man's War",
// author: {
// firstName: 'John',
// lastName: 'Scalzi'
// }
// },
// refIndex: 0
// }
// ]
从上述代码中可以看到我们要通过Fuse 对books的这个数组进行模糊搜索,构建的Fuse对象中,模糊搜索的key定义为['title', 'author.firstName'],支持对title及author.firstName这两个字段进行搜索。然后执行fuse的search API就能过滤出我们的期望结果。整体代码还是非常简单的。
高级配置
Demo示例只是提供了一个基础版本的模糊搜索。如果用户想获得更灵活的搜索能力,比如搜索结果排序、权重控制、搜索结果高亮等,那么就需要对Fuse进行一些高级配置。
Fuse的所有配置都是通过new Fuse时传入的参数来配置的,下面列举一些常用的配置项:
const options = {
keys: ['title', 'author'], // 指定搜索key值,可多选
isCaseSensitive: false, //是否区分大小写 默认为false
includeScore: false, //结果集中是否展示匹配项的分数字段, 分数越大代表匹配程度越低,区间值为0-1,注意:当此项为true时,会返回完整的结果集,只不过每一项中携带了score分数字段
includeMatches: false, //匹配项是否应包含在结果中。当时true,结果的每条记录都包含匹配项的索引。这个通常我们用来对搜索内容做高亮处理
threshold: 0.6, // 阈值控制匹配的敏感度,默认值为0.6,如果要完全匹配这里要设置为0
shouldSort: true, // 是否对结果进行排序
location: 0, // 匹配的位置,0 表示开头匹配
distance: 100, // 搜索的最大距离
minMatchCharLength: 2, // 最小匹配字符长度
};
出了上述常用的一些配置项之外,Fuse还支持更高阶模糊搜索,如权重搜索,嵌套搜索,运算符拓展搜索,具体高阶用法可以参考官方文档。
Fuse的主要实现原理是通过改写Bitap 算法(近似字符串匹配)算法的内部实现来支撑其模糊搜索的算法依据,后续会出一篇文章看一下作者源码的算法实现。
总结
Fuse的文章到此就结束了,你没看错就这么一点介绍就基本能支撑我们在项目中的应用,谢谢阅读,如果哪里有不对的地方请评论博主,会及时进行改正。
来源:juejin.cn/post/7393172686115569705
微信小程序 折叠屏适配
最近维护了将近的一年的微信小程序(某知名企业),突然提出要兼容折叠屏,这款小程序主要功能一些图表汇总展示,也就是专门给一些领导用的,也不知道为啥领导们为啥突然喜欢用折叠屏手机了,一句话需求,苦的还是咱们程序员,但没办法,谁让甲方是爸爸呢,硬着头皮改吧,好在最后解决了,因为是甲方内部使用的小程序,这里不便贴图,但有官方案例图片,以供参考
启用大屏模式
从小程序基础库版本 2.21.3 开始,在 Windows、Mac、车机、安卓 WMPF 等大屏设备上运行的小程序可以支持大屏模式。可参考小程序大屏适配指南。方法是:在 app.json 中添加 "resizable": true
看到这里我心里窃喜,就加个配置完事了?这也太简单了,但后面证明我想简单了,
主要有两大问题:
- 1 尺寸不同的情况下内容展示效果兼容问题
- 2 预览版和体验版 大屏模式冷启动会生效,但热启动 和 菜单中点击重新进入小程、授权操作,会失效变成窄屏
解决尺寸问题
因为css的长度单位大部分用的 rpx,窄屏和宽屏展示差异出入较大,别说客户不认,自己这关就过不了,简直都不忍直视,整个乱成一片,尤其登录页,用了定位,更是乱上加乱。
随后参考了官方的文档 小程序大屏适配指南和自适应布局,方案对于微信小程序原生开发是可行的,但这个项目用的 uni-app开发的,虽然uni-app 也有对应的响应式布局组件,再加上我是个比较爱偷懒的人(甲方给的工期事件也有限制),不可能花大量时间把所有也页面重新写一遍布局,这是不现实的。
于是又转战到uni-app官网寻找解决方案 uni-app宽屏适配指南
内容缩放拉伸的处理 这一段中提出了两个策略
- 1.局部拉伸:页面内容划分为固定区域和长宽动态适配区域,固定区域使用固定的px单位约定宽高,长宽适配区域则使用flex自动适配。当屏幕大小变化时,固定区域不变,而长宽适配区域跟着变化
- 2.等比缩放:根据页面屏幕宽度缩放。rpx其实属于这种类型。在宽屏上,rpx变大,窄屏上rpx变小。
随后看到这句话特别符合我的需求,哈哈 省事 省事 省事
策略2省事,设计师按750px屏宽出图,程序员直接按rpx写代码即可。但策略2的实际效果不如策略1好。程序员使用策略1,分析下界面,设定好局部拉伸区域,这样可以有更好的用户体验
具体实现
1.配置 pages.json 的 globeStyle
{
"globalStyle": {
"rpxCalcMaxDeviceWidth": 1200, // rpx 计算所支持的最大设备宽度,单位 px,默认值为 960
"rpxCalcBaseDeviceWidth": 375, // rpx 计算使用的基准设备宽度,设备实际宽度超出 rpx 计算所支持的最大设备宽度时将按基准宽度计算,单位 px,默认值为 375
"rpxCalcIncludeWidth": 750 // rpx 计算特殊处理的值,始终按实际的设备宽度计算,单位 rpx,默认值为 750
},
}
2.单位兼容
还有一点官方也提出来了很重要,那就很多时候 会把宽度750rpx 当成100% 使用,这在宽屏的设备上就会有问题, uniapp给了两种解决方案
- 750rpx 改为100%
- 另一种是配置rpxCalcIncludeWidth,设置某个特定数值不受rpxCalcMaxDeviceWidth约束
想要用局部拉伸:页面内容划分为固定区域和长宽动态适配区域”的策略,单位必须用px
添加脚本
项目根目录新增文件 postcss.config.js 内容如下。则在编译时,编译器会自动转换rpx单位为px。
// postcss.config.js
const path = require('path')
module.exports = {
parser: 'postcss-comment',
plugins: {
'postcss-import': {
resolve(id, basedir, importOptions) {
if (id.startsWith('~@/')) {
return path.resolve(process.env.UNI_INPUT_DIR, id.substr(3))
} else if (id.startsWith('@/')) {
return path.resolve(process.env.UNI_INPUT_DIR, id.substr(2))
} else if (id.startsWith('/') && !id.startsWith('//')) {
return path.resolve(process.env.UNI_INPUT_DIR, id.substr(1))
}
return id
}
},
'autoprefixer': {
overrideBrowserslist: ["Android >= 4", "ios >= 8"],
remove: process.env.UNI_PLATFORM !== 'h5'
},
// 借助postcss-px-to-viewport插件,实现rpx转px,文档:https://github.com/evrone/postcss-px-to-viewport/blob/master/README_CN.md
// 以下配置,可以将rpx转换为1/2的px,如20rpx=10px,如果要调整比例,可以调整 viewportWidth 来实现
'postcss-px-to-viewport': {
unitToConvert: 'rpx',
viewportWidth: 200,
unitPrecision: 5,
propList: ['*'],
viewportUnit: 'px',
fontViewportUnit: 'px',
selectorBlackList: [],
minPixelValue: 1,
mediaQuery: false,
replace: true,
exclude: undefined,
include: undefined,
landscape: false
},
'@dcloudio/vue-cli-plugin-uni/packages/postcss': {}
}
}
大屏模式失效问题
下面重头戏来了,这期间经历 蜿蜒曲折 ,到头来发现都是无用功,我自己都有被wx蠢到发笑,唉,
样式问题解决后 开始着重钻研 大屏失效的问题,但看了官方的多端适配示例demo,人家的就是好的,那就应该有解决办法,于是转战github地址 下项目,谁知这项目暗藏机关,各种报错,让你跑不起来。。。。,让我一度怀疑腾讯也这么拉跨
还好issues 区一位大神有解决办法 感兴趣的老铁可以去瞅瞅
另外 微信小程序开发工具需要取消这两项,最后当项目跑起来后我还挺开心,模拟器上没有问题,但用真机预览的时候我啥眼了,还是窄屏,偶尔可以大屏,后面发现 冷启动是大屏,热启动和点击右上角菜单中的重新进入小程序按钮都会自己变成窄屏幕
这是官方的项目啊,为啥人家的可以,我本地跑起来却不可以,让我一度怀疑这里有内幕,经过几轮测试还是不行,于是乎,我开始了各种询问查资料,社区、私聊、评论、github issues,最后甚至 统计出来了 多端适配示例demo 开发者的邮箱 挨个发了邮件,但都结果无一例外,全部石沉大海
结果就是,没有办法了,想看看是不是只有预览和体验版有问题,后面发布到正式版后,再看居然没问题了,就是这么神奇,也是无语!!!! 原来做了这么多无用功。。。。
来源:juejin.cn/post/7273764921456492581
前端项目公共组件封装思想(Vue)
1. 通用组件(表单搜索+表格展示+分页器)
在项目当中我们总会遇到这样的页面:页面顶部是一个表单筛选项,下面是一个表格展示数据。表格下方是一个分页器,这样的页面在我们的后台管理系统中经常所遇到,有时候可能不止一个页面,好几个页面的结构都是这种。如图:
本人记得,在react中的高级组件库中有这么一个组件,就实现了这么一个效果。就拿这个页面来说我们实现一下组件封装的思想:1.首先把每个页面的公共部分抽出来,比如标题等,用props或者插槽的形式传入到组件中进行展示 2. 可以里面数据的双向绑定实现跟新的效果 3. 设置自定义函数传递给父组件要做上面事情
1.将公共的部分抽离出来
TableContainer组件
<template>
<div class="container">
<slot name="navbar"></slot>
<div class="box-detail">
<div class="detail-box">
<div class="box-left">
<div class="left-bottom">
<div class="title-bottom">{{ title }}</div>
<div class="note">
<div class="note-detail">
<slot name="table"></slot>
</div>
</div>
</div>
</div>
</div>
</div>
<el-backtop style="width: 3.75rem; height: 3.75rem" :bottom="10" :right="5">
<div
style="
{
width: 5.75rem;
flex-shrink: 0;
border-radius: 2.38rem;
background: #fff;
box-shadow: 0 0.19rem 1rem 0 #2b4aff14;
}
"
>
<i class="el-icon-arrow-up" style="color: #6e6f74"></i>
</div>
</el-backtop>
</div>
</template>
这里的话利用了具名插槽插入了navbar、table组件,title通过props的属性传入到子组件当中。进行展示,
父组件
<TableContainer title="资源审核">
<template v-slot:navbar>
<my-affix :offset="0">
<Navbar/>
</my-affix>
</template>
<template v-slot:table>
<SourceAuditTable/>
</template>
</TableContainer>
当然这是一个非常非常简单的组件封装案例
接下来我们看一个高级一点的组件封装
父组件
<template>
<div>
<hr>
<HelloWorld :page.sync="page" :limit.sync="limit" />
</div>
</template>
<script>
import HelloWorld from './components/HelloWorld.vue';
export default {
data() {
return {
page: 1,
limit: 5
}
},
components: {
HelloWorld
},
}
</script>
父组件传递给子组件各种必要的属性:total(总共多少条数据)、page(当前多少页)、limit(每页多少条数据)、pageSizes(选择每页大小数组)
子组件
<template>
<el-pagination :current-page.sync="currentPage" :page-size.sync="pageSize" :total="20" />
</template>
<script>
export default {
name: 'HelloWorld',
props: {
page: {
default: 1
},
limit: {
default: 5
},
},
computed: {
currentPage: {
get() {
return this.page
},
set(val) {
//currentPage 这里对currentPage做出来改变就会走这里
//这边更新数据走这里
console.log('currentPage', this.currentPage)
this.$emit('update:page', val)
}
},
pageSize: {
get() {
return this.limit
},
set(val) {
this.$emit('update:limit', val)
}
}
},
methods: {
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
这里的page.sync、limit.sync目的就是为了实现数据的双向绑定,computed中监听page和limit的变化,子组件接收的数据通过computed生成的currentPage通过sync绑定到了 el-pagination中, 点击分页器的时候会改变currentPage 此时会调用set函数设置新的值,通过代码 this.$emit(update:page,value) 更新父组件中的值,实现双向的数据绑定
本文是作者在闲暇的时间随便记录一下, 若有错误请指正,多多包涵。感谢支持!
来源:juejin.cn/post/7312353213347708940
小程序和h5有什么差别
差别
微信小程序和H5应用在实现原理上的差异主要体现在架构、渲染方式、数据通信、运行环境和API接口等方面。以下是详细的对比:
1. 架构和运行环境
微信小程序:
架构:微信小程序主要分为逻辑层(JavaScript)和视图层(WXML、WXSS)。逻辑层运行在小程序的JSCore中,而视图层运行在WebView(它是基于浏览器内核重构的内置解析器,它并不是一个完整的浏览器,官方文档中重点强调了脚本内无法使用浏览器中常用的
window
对象和document
对象,就是没有DOM
和BOM
的相关的API
,这一条就干掉了JQ
和一些依赖于BOM
和DOM
的NPM包)中,两者通过平台提供的桥接机制进行通信。运行环境:逻辑层在微信提供的JS引擎中运行,视图层在微信内置的WebView中渲染。
H5 应用:
架构:H5应用是一个整体。HTML、CSS和JavaScript共同构成了一个Web页面。
运行环境:H5应用在浏览器中运行,所有代码都在浏览器的环境中解析和执行。
2. 渲染方式
微信小程序:
微信小程序采用双线程模型,将逻辑层和视图层分离,分别运行在不同的线程中(两者通过平台提供的桥接机制进行通信):
逻辑层:运行在小程序的JSCore环境中,负责处理业务逻辑、数据计算和API调用。
视图层:运行在WebView中,负责渲染用户界面和处理用户交互。( 性能提升:由于小程序的渲染过程并不依赖于JS,因此即使JS线程发生阻塞,页面的渲染也不会受到影响。这种机制有利于提高渲染效率,减少卡顿,提升用户体验。)
通信桥接机制
逻辑层和视图层之间不能直接访问和操作对方的数据和界面,因此需要通过微信小程序框架提供的桥接机制来进行通信。这种通信机制通常包括以下几个方面:
1. 数据绑定和响应式更新(逻辑层--->视图层)
逻辑层通过数据绑定的方式将数据传递给视图层,视图层根据数据变化自动更新界面。数据绑定的过程如下:
设置数据:逻辑层通过
Page
或Component
实例的setData
方法,将数据传递给视图层。更新视图:视图层接收到数据变化的消息后,根据新的数据重新渲染界面。
2. 事件处理(视图层--->逻辑层)
视图层中的用户交互(如点击、输入等)会触发事件,这些事件通过桥接机制传递给逻辑层进行处理。事件处理的过程如下:
事件绑定:在视图层(WXML)中定义事件处理函数。
事件触发:用户在界面上进行交互时,触发相应的事件。
事件传递:视图层将事件信息通过桥接机制传递给逻辑层。
事件处理:逻辑层的事件处理函数接收到事件信息,执行相应的业务逻辑。
3. 消息传递
逻辑层和视图层之间的通信实际是通过消息传递的方式实现的。微信小程序框架负责在两个层之间传递消息,包括:
逻辑层到视图层的消息:如数据更新、视图更新等。
视图层到逻辑层的消息:如用户交互事件、视图状态变化等
通信桥接机制具体实现
依赖于微信小程序框架内部的设计和优化,开发者无需直接接触底层的通信细节。以下是桥接机制的一些关键点:
消息队列:逻辑层和视图层之间维护一个消息队列,用于存储待传递的消息。
消息格式:消息以JSON格式进行编码,包含消息类型、数据内容等信息。
消息处理:逻辑层和视图层各自维护一个消息处理器,负责接收、解析和处理消息。
异步通信:消息传递通常是异步进行的,以确保界面和逻辑的流畅性和响应性
H5 应用:
H5应用的逻辑层和视图层通常是在同一线程(主线程)中运行,直接通过JavaScript代码操作DOM来更新界面。主要的通信方式包括:
直接DOM操作:通过JavaScript直接操作DOM元素,更新界面。
事件监听和处理:通过JavaScript监听DOM事件(如点击、输入等)并处理。
数据绑定:使用现代前端框架(如Vue.js、React.js)的数据绑定和响应式机制,实现视图的自动更新。
3. 数据通信
微信小程序:
通信机制:逻辑层和视图层之间的通信通过小程序框架提供的机制来实现,通常是通过事件和数据绑定。
后台通信:可以通过小程序提供的API与服务器通信,例如wx.request等。
H5 应用:
通信机制:页面内的通信可以通过DOM事件、JavaScript函数调用等方式实现。
后台通信:可以使用标准的AJAX请求、Fetch API、WebSocket等方式与服务器通信。
4. 运行机制
微信小程序
启动
如果用户已经打开过某小程序,在一定时间内再次打开该小程序,此时无需重新启动,只需将后台态的小程序切换到前台,整个过程就是所谓的
热启动
如果用户首次打开或小程序被微信主动销毁后再次打开的情况,此时小程序需要重新加载启动,就是
冷启动
销毁
当小程序进入后台一定时间,或系统资源占用过高,或者是你手动销毁,才算真正的销毁
h5:解析HTML CSS形成DOM树和CSSOM树,两者结合形成renderTree,js运行,当然中间存在一系列的阻塞问题,还有同源策略等等
5. 系统权限方面(特定功能)
微信小程序依托于微信平台,能够利用微信提供的特有功能和API,实现许多H5应用无法直接实现或不易实现的功能,如微信支付、微信登录、硬件接口(如摄像头、麦克风、蓝牙、NFC等)、微信特有功能等。
6.更新机制
h5更新后访问地址即可
微信小程序需要审核
开发者在发布新版本之后,无法立刻影响到所有现网用户,要在发布之后 24 小时之内才下发新版本信息到用户
小程序每次
冷启动
时,都会检查有无更新版本,如果发现有新版本,会异步下载新版本代码包,并同时用客户端本地包进行启动,所以新版本的小程序需要等下一次冷启动
才会应用上,当然微信也有wx.getUpdateManager
可以做检查更新
7. 开发工具和调试
微信小程序:
开发工具:微信提供了专门的开发者工具,集成了调试、预览、上传等功能,方便开发者进行开发和测试。
调试:可以使用微信开发者工具进行实时调试,并提供丰富的日志和调试信息。
H5 应用:
开发工具:可以使用任何Web开发工具和IDE(如VS Code、WebStorm等),以及浏览器的开发者工具进行调试。
调试:依赖浏览器的开发者工具(如Chrome DevTools),可以进行断点调试、查看网络请求、分析性能等。
总结来说,微信小程序和H5应用在实现原理上的差异主要是由于它们的架构设计、运行环境和生态系统的不同。小程序依托于微信平台,提供了许多平台专属的优化和功能,而H5应用则更加开放和灵活,依赖于浏览器的标准和特性。
小程序为什么使用双层架构
微信小程序采用双线程架构的原因主要是为了优化性能和用户体验。双线程架构将逻辑层和视图层分离,使得业务逻辑处理和视图渲染在不同的线程中进行,从而提高了小程序的运行效率和响应速度。以下是采用双线程架构的具体原因和优势:
提高性能:
将逻辑处理和页面渲染分离到不同的线程中,可以避免互相干扰,提高整体性能。例如,在复杂的业务逻辑计算过程中,视图层仍然可以保持流畅的界面更新和响应。
逻辑层和视图层通过消息机制进行异步通信,可以避免阻塞和卡顿。这样即使逻辑层的操作较为耗时,也不会影响界面的即时响应。
安全性: 视图层无法直接操作逻辑层的数据和代码,这样可以避免一些潜在的安全风险和漏洞。
XSS
由于逻辑层和视图层分离,视图层不能直接执行逻辑层的JavaScript代码。这种隔离使得即使视图层(WXML)中存在注入的恶意代码,也不能直接影响逻辑层的数据和操作。
逻辑层和视图层之间的通信通过统一的API进行,传递的数据会经过平台的安全检查和过滤,进一步减少了XSS攻击的风险。
CSRF
小程序通过平台的统一API进行请求,这些请求包含了平台自动添加的安全令牌(如
session_key
等),确保请求的合法性。由于逻辑层和视图层的分离,用户在视图层进行操作时,逻辑层的业务逻辑和数据处理经过平台的校验,减少了CSRF攻击的风险。
DOM篡改:视图层的DOM结构由WXML和WXSS定义,不能直接通过逻辑层的JavaScript代码进行操作,这种隔离减少了DOM篡改的可能性。
安全权限管理:小程序的API权限由平台统一管理和控制,开发者需要申请和用户授权后才能使用特定的API。
用户体验: 微信小程序在启动时可以并行加载逻辑层和视图层资源,减少初始加载时间,提升启动速度。同时,微信平台会对小程序进行预加载和缓存优化,进一步提升加载性能。
rpx
微信的自适应单位,可以根据屏幕宽度进行自适应。
在微信小程序中,1 rpx 表示屏幕宽度的 1/750,因此 rpx
和 px
的换算关系是动态的,基于设备的实际屏幕宽度。
作者:let_code
来源:juejin.cn/post/7389168680747614245
展开收起的箭头动画应该怎么做?
背景
我们在日常开发中,一定经常遇到折叠面板的开发,为了美观,我们经常会添加展开收起按钮,并且带有箭头旋转动画。
比如下面的几种情况
- 文字点击变化,且有箭头旋转动画
- 只有箭头动画
这几种情况的核心其实就是:点击箭头开始旋转,再点击箭头恢复初始位置。
如何实现
思路分析
要实现展开和收起箭头的旋转动画,我们可以使用 CSS 和 JavaScript。我们在点击按钮时,通过添加和移除 CSS 类,实现箭头的旋转动画。并且添加transition属性实现过渡效果。
代码实现
我们以第一种动画效果为例,先写基础代码
<template>
<div @click="open = !open">
<span>{{ open ? '收起' : '展开' }}</span>
<span>▼</span>
</div>
</template>
<script>
const open = ref(false)
</script>
现在我们点击按钮,只有文字会变化,箭头不会旋转
我们给按钮加一个动态类
<template>
<div @click="open = !open">
<span>{{ open ? '收起' : '展开' }}</span>
<span :class="{ rotate: open }">▼</span>
</div>
</template>
<script>
const open = ref(false)
</script>
<style scoped>
.rotate {
transform: rotate(180deg);
transition: transform 0.3s linear;
}
</style>
可以看到,展开的时候有动画,但是收起的时候是没有过渡效果的。
我们只需要加一个transition属性即可
<template>
<div @click="open = !open">
<span>{{ open ? '收起' : '展开' }}</span>
<span :class="{ rotate: open }" class="arrow">▼</span>
</div>
</template>
<script>
const open = ref(false)
</script>
<style scoped>
.arrow {
transition: transform 0.3s linear;
}
.rotate {
transform: rotate(180deg);
transition: transform 0.3s linear;
}
</style>
现在样式就ok了
html版本
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Arrow Rotation</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<button id="toggleButton">
<span id="arrow" class="arrow">▼</span>
</button>
</div>
<script src="script.js"></script>
</body>
</html>
css
/* styles.css */
.container {
text-align: center;
margin-top: 50px;
}
#toggleButton {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
outline: none;
}
.arrow {
display: inline-block;
transition: transform 0.3s ease;
}
.arrow.rotate {
transform: rotate(180deg);
}
js
// script.js
document.getElementById('toggleButton').addEventListener('click', function() {
const arrow = document.getElementById('arrow');
arrow.classList.toggle('rotate');
});
这种方式可以实现箭头在点击时的旋转动画效果。在实际项目中使用,我们也可以根据具体需求调整样式和逻辑。
来源:juejin.cn/post/7385132403025149989
如果iconfont停止服务了,我们怎么办
前言
个人一直都比较喜欢阿里大佬提供的一些组件服务什么的,这里就只说图标管理吧,最开始的时候我是使用的icomoon.io 后面发现http://www.iconfont.cn/ 在以前开发是时候我们为了图片的请求少点,提升网站的性能会把这些小图标做到一张图上面专业叫法是雪碧图,后来随着前端发展有了字体图标,不啰嗦了吧,回到正题,由于上次icofont官网挂了之后,导致我的项目无法进行正常迭代,我就决定要自己弄个简单的图标管理。
需求
一般使用需要的是把设计做好的图标或者其他地方下载的svg图标上传到服务器然后可以查看,并且打包成一个js文件加载到项目中。
准备
都说天下文章一大抄,我也是去抄iconfont,我把iconfont的使用demo打开研究了一下。
我这里只说Symbol,通过分析看到就是我们需要在项目中引入./iconfont.js
iconfont把我们上传的图标进行了删减优化,最外层就一个svg标签里面使用了一个symbol标签来包裹之前的图标内容,每一个symbol的id都对应之前的svg图标名称。
iconfont也没有开源,具体怎么做的咱也不知道,只能知道目前这些信息了,然后就按照自己理解开始开发实现了
前端开发
通过上面我们可以知道,现在我们前端需要的是把设计稿的svg图变成symbol里面的内容,打开svg图 可以简单测试下,直接把内容复制然后粘贴带了iconfont.js中,然后运行demo图标正常显示就说明是ok的,不然就是这样做是不行的。
通过图片我们可以看到,现在需要的就是把svg里面的path改成用symblo来包裹,在开发中svg图我们是通过文档流上传上来的,这个时候我们就需要对文档流进行操作,先把文档流变成字符串,然后js操作字符串拼接达到目的。
- 使用到FileReader和readAsText获取到字符串
const fileParse = (file) => {
return new Promise((resolve) => {
const fileReader = new FileReader();
fileReader.readAsText(file, "UTF-8");
fileReader.onload = (e) => {
resolve({ content: e.target?.result, name: file.name })
}
})
}
- 字符操作拼接我使用的是cheerio
const handleUploadSvg = ($, result) => {
let index = result.indexOf('
通过上面的操作我们可以得到一个新的svg源码,图标显示还是flow
然后我把这个字符串传送给后端就行了
后端开发
后端要做的就是把我上传上来的svg字符串拼接到一起,然后以iconfont.js一样通过一个get接口返回给我就可以了, 通过浏览器可以访问到就说明ok了
其他两种需要用到些第三方库当时也有顺便研究看到了,如果有需要我再写一篇。
文章中会有些不怎么清晰或者有问题的地方还望各位大佬见谅,刚刚开始决定写一些技术相关文章,还很生疏
来源:juejin.cn/post/7340197367515578378
谁说forEach不支持异步代码,只是你拿不到异步结果而已
在前面探讨 forEach 中异步请求后端接口时,很多人都知道 forEach 中 async/await 实际是无效的,很多文章也说:forEach 不支持异步,forEach 只能同步运行代码,forEach 会忽略 await 直接进行下一次循环...
当时我的理解也是这样的,后面一细想好像不对,直接上我前面一篇文章用到的示例代码:
async function getData() {
const list = await $getListData()
// 遍历请求
list.forEach(async (item) => {
const res = await $getExtraInfo({
id: item.id
})
item.extraInfo = res.extraInfo
})
// 打印下最终处理过的额外数据
console.log(list)
}
上面 $getListData、$getExtraInfo 都是 promise 异步方法,按照上面说的 forEach 会直接忽略掉 await,那么循环体内部拿到的 res 就应该是 undefined,后面的 res.extraInfo 应该报错才对,但是实际上代码并没有报错,说明 await 是有效的,内部的异步代码也是可以正常运行的,所以 forEach 肯定是支持异步代码的。
手写版 forEach
先从自己实现的简版 forEach 看起:
Array.prototype.customForEach = function (callback) {
for (let i = 0; i < this.length; i++) {
callback(this[i], i, this)
}
}
里面会为数组的每个元素执行一下回调函数,实际拿几组数组测试和正宗的 forEach 方法效果也一样。可能很多人还是会有疑问你自己实现这到底靠不靠谱,不瞒你说我也有这样的疑问。
MDN 上关于 forEach 的说明
先去 MDN 上搜一下 forEach,里面的大部分内容只是使用层面的文档,不过里面有提到:“forEach() 期望的是一个同步函数,它不会等待 Promise 兑现。在使用 Promise(或异步函数)作为 forEach 回调时,请确保你意识到这一点可能带来的影响”。
ECMAScript 中 forEach 规范
继续去往 javascript 底层探究,我们都知道执行 js 代码是需要依靠 js 引擎,去将我们写的代码解释翻译成计算机能理解的机器码才能执行的,所有 js 引擎都需要参照 ECMAScript 规范来具体实现,所以这里我们先去看下 ECMAScript 上关于 forEach 的标准规范:
添加图片注释,不超过 140 字(可选)
谷歌 V8 的 forEach 实现
常见的 js 引擎有:谷歌的 V8、火狐 FireFox 的 SpiderMonkey、苹果 Safari 的 JavaScriptCore、微软 Edge 的 ChakraCore...后台都很硬,这里我们就选其中最厉害的谷歌浏览器和 nodejs 依赖的 V8 引擎,V8 中对于 forEach 实现的主要源码:
transitioning macro FastArrayForEach(implicit context: Context)(
o: JSReceiver, len: Number, callbackfn: Callable, thisArg: JSAny): JSAny
labels Bailout(Smi) {
let k: Smi = 0;
const smiLen = Cast<Smi>(len) otherwise goto Bailout(k);
const fastO = Cast<FastJSArray>(o) otherwise goto Bailout(k);
let fastOW = NewFastJSArrayWitness(fastO);
// Build a fast loop over the smi array.
for (; k < smiLen; k++) {
fastOW.Recheck() otherwise goto Bailout(k);
// Ensure that we haven't walked beyond a possibly updated length.
if (k >= fastOW.Get().length) goto Bailout(k);
const value: JSAny = fastOW.LoadElementNoHole(k)
otherwise continue;
Call(context, callbackfn, thisArg, value, k, fastOW.Get());
}
return Undefined;
}
源码是 .tq 文件,这是 V8 团队开发的一个叫 Torque 的语言,语法类似 TypeScript,所以对于前端程序员上面的代码大概也能看懂,想要了解详细的 Torque 语法,可以直接去 V8 的官网上查看。
从上面的源码可以看到 forEach 实际还是依赖的 for 循环,没有返回值所以最后 return 的一个 Undefined。看完源码是不是发现咱上面的手写版也大差不差,只不过 V8 里实现了更多细节的处理。
结论:forEach 支持异步代码
最后的结论就是:forEach 其实是支持异步的,循环时并不是会直接忽略掉 await,但是因为 forEach 没有返回值,所以我们在外部没有办法拿到每次回调执行过后的异步 promise,也就没有办法在后续的代码中去处理或者获取异步结果了,改造一下最初的示例代码:
async function getData() {
const list = await $getListData()
// 遍历请求
list.forEach(async (item) => {
const res = await $getExtraInfo({
id: item.id
})
item.extraInfo = res.extraInfo
})
// 打印下最终处理过的额外数据
console.log(list)
setTimeout(() => {
console.log(list)
}, 1000 * 10)
}
你会发现 10 秒后定时器中是可以按照预期打印出我们想要的结果的,所以异步代码是生效了的,只不过在同步代码中我们没有办法获取到循环体内部的异步状态。
如果还是不能理解,我们对比下 map 方法,map 和 forEach 很类似,但是 map 是有返回值的,每次遍历结束之后我们是可以直接 return 一个值,后续我们就可以接收到这个返回值。这也是为什么很多文章中改写 forEach 异步操作时,使用 map 然后借助 Promise.all 来等待所有异步操作完成后,再进行下面的逻辑来实现同步的效果。
参考文档
- MDN forEach 文档:developer.mozilla.org/zh-CN/docs/…
- ECMAScript 中 forEach 规范:tc39.es/ecma262/#se…
- 谷歌 V8 中 forEach 源码:chromium.googlesource.com/v8/v8.git/+…
- 谷歌 V8 中 map 源码:chromium.googlesource.com/v8/v8.git/+…
- 谷歌 V8 官网:v8.dev
- 谷歌 V8 源码:github.com/v8/v8
来源:juejin.cn/post/7389912354749087755
js如何实现当文本内容过长时,中间显示省略号...,两端正常展示
前一阵做需求时,有个小功能实现起来废了点脑细胞,觉得可以记录一下。
产品的具体诉求是:用户点击按钮进入详情页面,详情页内的卡片标题内容过长时,标题的前后两端正常展示,中间用省略号...表示,并且鼠标悬浮后,展示全部内容。
关于鼠标悬浮展示全部内容的代码就不放在这里了,本文主要写关于实现中间省略号...的代码。
实现思路
- 获取标题盒子的真实宽度, 我这里用的是clientWidth;
- 获取文本内容所占的实际宽度;
- 根据文字的大小计算出每个文字所占的宽度;
- 判断文本内容的实际宽度是否超出了标题盒子的宽度;
- 通过文字所占的宽度累加之和与标题盒子的宽度做对比,计算出要截取位置的索引;
- 同理,文本尾部的内容需要翻转一下,然后计算索引,截取完之后再翻转回来;
代码
html代码
<div class="title" id="test">近日,银行纷纷下调大额存单利率,但银行定期存款仍被疯抢。银行理财经理表示:有意向购买定期存款要尽快,不确定利率是否会再降。</div>
css代码: 设置文本不换行,同时设置overflow:hidden
让文本溢出盒子隐藏
.title {
width: 640px;
height: 40px;
line-height: 40px;
font-size: 14px;
color: #00b388;
border: 1px solid #ddd;
overflow: hidden;
/* text-overflow: ellipsis; */
white-space: nowrap;
/* box-sizing: border-box; */
padding: 0 10px;
}
javascript代码:
获取标题盒子的宽度时要注意,如果在css样式代码中设置了padding, 就需要获取标题盒子的左右padding值。 通过getComputedStyle
属性获取到所有的css样式属性对应的值, 由于获取的padding值都是带具体像素单位的,比如: px
,可以用parseInt特殊处理一下。
获取盒子的宽度的代码,我当时开发时是用canvas计算的,但计算的效果不太理想,后来逛社区,发现了嘉琪coder
大佬分享的文章,我这里就直接把代码搬过来用吧, 想了解的掘友可以直接滑到文章末尾查看。
判断文本内容是否超出标题盒子
// 标题盒子dom
const dom = document.getElementById('test');
// 获取dom元素的padding值
function getPadding(el) {
const domCss = window.getComputedStyle(el, null);
const pl = Number.parseInt(domCss.paddingLeft, 10) || 0;
const pr = Number.parseInt(domCss.paddingRight, 10) || 0;
console.log('padding-left:', pl, 'padding-right:', pr);
return {
left: pl,
right: pr
}
}
// 检测dom元素的宽度,
function checkLength(dom) {
// 创建一个 Range 对象
const range = document.createRange();
// 设置选中文本的起始和结束位置
range.setStart(dom, 0),
range.setEnd(dom, dom.childNodes.length);
// 获取元素在文档中的位置和大小信息,这里直接获取的元素的宽度
let rangeWidth = range.getBoundingClientRect().width;
// 获取的宽度一般都会有多位小数点,判断如果小于0.001的就直接舍掉
const offsetWidth = rangeWidth - Math.floor(rangeWidth);
if (offsetWidth < 0.001) {
rangeWidth = Math.floor(rangeWidth);
}
// 获取元素padding值
const { left, right } = getPadding(dom);
const paddingWidth = left + right;
// status:文本内容是否超出标题盒子;
// width: 标题盒子真实能够容纳文本内容的宽度
return {
status: paddingWidth + rangeWidth > dom.clientWidth,
width: dom.clientWidth - paddingWidth
};
}
通过charCodeAt返回指定位置的字符的Unicode
编码, 返回的值对应ASCII码表对应的值,0-127包含了常用的英文、数字、符号等,这些都是占一个字节长度的字符,而大于127的为占两个字节长度的字符。
截取和计算文本长度
// 计算文本长度,当长度之和大于等于dom元素的宽度后,返回当前文字所在的索引,截取时会用到。
function calcTextLength(text, width) {
let realLength = 0;
let index = 0;
for (let i = 0; i < text.length; i++) {
charCode = text.charCodeAt(i);
if (charCode >= 0 && charCode <= 128) {
realLength += 1;
} else {
realLength += 2 * 14; // 14是字体大小
}
// 判断长度,为true时终止循环,记录索引并返回
if (realLength >= width) {
index = i;
break;
}
}
return index;
}
// 设置文本内容
function setTextContent(text) {
const { status, width } = checkLength(dom);
let str = '';
if (status) {
// 翻转文本
let reverseStr = text.split('').reverse().join('');
// 计算左右两边文本要截取的字符索引
const leftTextIndex = calcTextLength(text, width);
const rightTextIndex = calcTextLength(reverseStr, width);
// 将右侧字符先截取,后翻转
reverseStr = reverseStr.substring(0, rightTextIndex);
reverseStr = reverseStr.split('').reverse().join('');
// 字符拼接
str = `${text.substring(0, leftTextIndex)}...${reverseStr}`;
} else {
str = text;
}
dom.innerHTML = str;
}
最终实现的效果如下:
上面就是此功能的所有代码了,如果想要在本地试验的话,可以在本地新建一个html文件,复制上面代码就可以了。
下面记录下从社区内学到的相关知识:
- js判断文字被溢出隐藏的几种方法;
- JS获取字符串长度的几种常用方法,汉字算两个字节;
1、 js判断文字被溢出隐藏的几种方法
1. Element-plus这个UI框架中的表格组件实现的方案。
通过document.createRange
和document.getBoundingClientRect()
这两个方法实现的。也就是我上面代码中实现的checkLength
方法。
2. 创建一个隐藏的div模拟实际宽度
通过创建一个不会在页面显示出来的dom元素,然后把文本内容设置进去,真实的文本长度与标题盒子比较宽度,判断是否被溢出隐藏了。
function getDomDivWidth(dom) {
const elementWidth = dom.clientWidth;
const tempElement = document.createElement('div');
const style = window.getComputedStyle(dom, null)
const { left, right } = getPadding(dom); // 这里我写的有点重复了,可以优化
tempElement.style.cssText = `
position: absolute;
top: -9999px;
left: -9999px;
white-space: nowrap;
padding-left:${style.paddingLeft};
padding-right:${style.paddingRight};
font-size: ${style.fontSize};
font-family: ${style.fontFamily};
font-weight: ${style.fontWeight};
letter-spacing: ${style.letterSpacing};
`;
tempElement.textContent = dom.textContent;
document.body.appendChild(tempElement);
const obj = {
status: tempElement.clientWidth + right + left > elementWidth,
width: elementWidth - left - right
}
document.body.removeChild(tempElement);
return obj;
}
3. 创建一个block元素来包裹inline元素
这种方法是在UI框架acro design vue
中实现的。外层套一个块级(block)元素,内部是一个行内(inline)元素。给外层元素设置溢出隐藏的样式属性,不对内层元素做处理,这样内层元素的宽度是不变的。因此,通过获取内层元素的宽度和外层元素的宽度作比较,就可以判断出文本是否被溢出隐藏了。
// html代码
<div class="title" id="test">
<span class="content">近日,银行纷纷下调大额存单利率,但银行定期存款仍被疯抢。银行理财经理表示:有意向购买定期存款要尽快,不确定利率是否会再降。</span>
</div>
// 创建一个block元素来包裹inline元素
const content = document.querySelector('.content');
function getBlockDomWidth(dom) {
const { left, right } = getPadding(dom);
console.log(dom.clientWidth, content.clientWidth)
const obj = {
status: dom.clientWidth < content.clientWidth + left + right,
width: dom.clientWidth - left - right
}
return obj;
}
4. 使用canvas中的measureText方法和TextMetrics对象来获取元素的宽度
通过Canvas 2D渲染上下文(context)可以调用measureText方法,此方法会返回TextMetrics对象,该对象的width
属性值就是字符占据的宽度,由此也能获取到文本的真实宽度,此方法有弊端,比如说兼容性,精确度等等。
// 获取文本长度
function getTextWidth(text, font = 14) {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")
context.font = font
const metrics = context.measureText(text);
return metrics.width
}
5. 使用css实现
这种方式来自评论区的掘友@S_mosar
提供的思路。
先来看下效果:
代码如下:
css部分
.con {
font-size: 14px;
color: #666;
width: 600px;
margin: 50px auto;
border-radius: 8px;
padding: 15px;
overflow: hidden;
resize: horizontal;
box-shadow: 20px 20px 60px #bebebe, -20px -20px 60px #ffffff;
}
.wrap {
position: relative;
line-height: 2;
height: 2em;
padding: 0 10px;
overflow: hidden;
background: #fff;
margin: 5px 0;
}
.wrap:nth-child(odd) {
background: #f5f5f5;
}
.title {
display: block;
position: relative;
background: inherit;
text-align: justify;
height: 2em;
overflow: hidden;
top: -4em;
}
.txt {
display: block;
max-height: 4em;
}
.title::before{
content: attr(title);
width: 50%;
float: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl;
}
html部分
<ul class="con">
<li class="wrap">
<span class="txt">CSS 实现优惠券的技巧 - 2021-03-26</span>
<span class="title" title="CSS 实现优惠券的技巧 - 2021-03-26">CSS 实现优惠券的技巧 - 2021-03-26</span>
</li>
<li class="wrap">
<span class="txt">CSS 测试标题,这是一个稍微有点长的标题,超出一行以后才会有title提示,标题是 实现优惠券的技巧 - 2021-03-26</span>
<span class="title" title="CSS 测试标题,这是一个稍微有点长的标题,超出一行以后才会有title提示,标题是 实现优惠券的技巧 - 2021-03-26">CSS
测试标题,这是一个稍微有点长的标题,超出一行以后才会有title提示,标题是 实现优惠券的技巧 - 2021-03-26</span>
</li>
<li class="wrap">
<span class="txt">CSS 拖拽?</span>
<span class="title" title="CSS 拖拽?">CSS 拖拽?</span>
</li>
<li class="wrap">
<span class="txt">CSS 文本超出自动显示title</span>
<span class="title" title="CSS 文本超出自动显示title">CSS 文本超出自动显示title</span>
</li>
</ul>
思路解析:
- 文字内容的父级标签li设置
line-height: 2;
、overflow: hidden;
、height: 2em;
,因此 li 标签的高度是当前元素字体大小的2倍,行高也是当前字体大小的2倍,同时内容若溢出则隐藏。 - li 标签内部有两个 span 标签,二者的作用分别是:类名为
.txt
的标签用来展示不需要省略号时的文本,类名为.title
用来展示需要省略号时的文本,具体是如何实现的请看第五步。 - 给
.title
设置伪类before
,将伪类宽度设置为50%,搭配浮动float: right;
,使得伪类文本内容靠右,这样设置后,.title
和伪类就会各占父级宽度的一半了。 .title
标签设置text-align: justify;
,用来将文本内容和伪类的内容两端对齐。- 给伪类
before
设置文字对齐方式direction: rtl;
,将伪类内的文本从右向左流动,即right to left
,再设置溢出省略的css样式就可以了。 .title
标签设置了top: -4em
,.txt
标签设置max-height: 4em;
这样保证.title
永远都在.txt
上面,当内容足够长,.txt
文本内容会换行,导致高度从默认2em变为4em,而.title
位置是-4em
,此时正好将.txt
覆盖掉,此时显示的就是.title
标签的内容了。
知识点:text-align: justify;
- 文本的两端(左边和右边)都会与容器的边缘对齐。
- 为了实现这种对齐,浏览器会在单词之间添加额外的空间。这通常意味着某些单词之间的间距会比其他单词之间的间距稍大一些。
- 如果最后一行只有一个单词或少数几个单词,那么这些单词通常不会展开以填充整行,而是保持左对齐。
需要注意的是,
text-align: justify;
主要用于多行文本。对于单行文本,这个值的效果与text-align: left;
相同,因为单行文本无法两端对齐。
2、JS获取字符串长度的几种常用方法
1. 通过charCodeAt判断字符编码
通过charCodeAt获取指定位置字符的Unicode
编码,返回的值对应ASCII码表对应的值,0-127包含了常用的英文、数字、符号等,这些都是占一个字节长度的字符,而大于127的为占两个字节长度的字符。
function calcTextLength(text) {
let realLength = 0;
for (let i = 0; i < text.length; i++) {
charCode = text.charCodeAt(i);
if (charCode >= 0 && charCode <= 128) {
realLength += 1;
} else {
realLength += 2;
}
}
return realLength;
}
2. 采取将双字节字符替换成"aa"的做法,取长度
function getTextWidth(text) {
return text.replace(/[^\x00-\xff]/g,"aa").length;
};
参考文章
4. canvas绘制字体偏上不居中问题、文字垂直居中后偏上问题、measureText方法和TextMetrics对象
来源:juejin.cn/post/7329967013923962895
只会Vue的我,一入职就让用React,用了这个工具库,我依然高效
由于公司最近项目周期紧张,还有一个项目因为人手不够排不开,时间非常紧张,所以决定招一个人来。这不,经过一段时间紧张的招聘,终于招到了一个前端妹子。妹子也坦白过,自己干了3年,都是使用的Vue开发,自己挺高效的。但如果入职想用React的话,会稍微费点劲儿。我说,没事,来就是了,我们都可以教你的。
但入职后发现,这个妹子人家一点也不拖拉,干活很高效。单独分给她的项目,她比我们几个干的还快,每天下班准时就走了,任务按时完成。终于到了分享会了,组长让妹子准备准备,分享一下高效开发的秘诀。
1 初始化React项目
没想到妹子做事还挺认真,分享并没有准备个PPT什么的,而是直接拿着电脑,要给我们手动演示她的高效秘诀。而且是从初始化React项目开发的,这让我们很欣慰。
首先是初始化React项目的命令,这个相信大家都很熟悉了:
第一步:启动终端
第二步:npm install -g create-react-app
第三步:create-react-app js-tool-big-box-website
(注意:js-tool-big-box-website是我们要创建的那个项目名称)
第四步:cd js-tool-big-box-website
(注意:将目录切换到js-tool-big-box-website项目下)
第五步:npm start
然后启动成功后,可以看到这样的界面:
2 开始分享秘诀
妹子说,自己不管使用Vue,还是React,高效开发的秘诀就是 js-tool-big-box 这个前端JS库
首先需要安装一下: npm install js-tool-big-box
2.1 注册 - 邮箱和手机号验证
注册的时候,需要验证邮箱或者手机号,妹子问我们,大家平时怎么验证?我们说:不是有公共的正则验证呢,就是验证一下手机号和邮箱的格式呗,你应该在utils里加了公共方法了吧?或者是加到了表单验证里?
妹子摇摇头,说,用了js-tool-big-box工具库后,会省事很多,可以这样:
import logo from './logo.svg';
import './App.css';
import { matchBox } from 'js-tool-big-box';
function App() {
const email1 = '232322@qq.com';
const email2 = '232322qq.ff';
const emailResult1 = matchBox.email(email1);
const emailResult2 = matchBox.email(email2);
console.log('emailResult1验证结果:', emailResult1); // true
console.log('emailResult2验证结果:', emailResult2); // false
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
js-tool-big-box,使React开发更加高效
</header>
</div>
);
}
export default App;
2.2 验证密码强度值
验证密码强度值的时候呢,妹子问我们,大家平时怎么验证?我们说:不就是写个公共方法,判断必须大于几位,里面是否包含数字,字母,大写字母,特殊符号这样子吗?
妹子摇摇头,说,不是,我们可以这样来验证:
const pwd1 = '12345';
const pwd1Strength = matchBox.checkPasswordStrength(pwd1);
console.log('12345的密码强度值为:', pwd1Strength); // 0
const pwd2 = '123456';
const pwd2Strength = matchBox.checkPasswordStrength(pwd2);
console.log('123456的密码强度值为:', pwd2Strength); // 1
const pwd3 = '123456qwe';
const pwd3Strength = matchBox.checkPasswordStrength(pwd3);
console.log('123456qwe的密码强度值为:', pwd3Strength); // 2
const pwd4 = '123456qweABC';
const pwd4Strength = matchBox.checkPasswordStrength(pwd4);
console.log('123456qweABC的密码强度值为:', pwd4Strength); // 3
const pwd5 = '123@456qwe=ABC';
const pwd5Strength = matchBox.checkPasswordStrength(pwd5);
console.log('123@456qwe=ABC的密码强度值为:', pwd5Strength); // 4
2.3 登录后存localStorage
登录后,需要将一些用户名存到localStorage里,妹子问,我们平时怎么存?我们说:就是直接拿到服务端数据后,存呗。妹子问:你们加过期时间不?我们说:有时候需要加。写个公共方法,传入key值,传入value值,传个过期时间,大家不都是这样?
妹子摇摇头,说,不是,我们可以这样来存:
import { storeBox } from 'js-tool-big-box';
storeBox.setLocalstorage('today', '星期一', 1000*6);
2.4 需要判断是否手机端浏览器
我们市场需要判断浏览器是否是手机端H5浏览器的时候,大家都怎么做?我们说:就是用一些内核判断一下呗,写好方法,然后在展示之处判断一下,展示哪些组件?不是这样子吗?
妹子又问:我这个需求,老板比较重视微信内置的浏览器,这样大家写的方法是不是就比较多了?我们说,那再写方法,针对微信内置浏览器的内核做一下判断呗。
妹子摇摇头,说,那样得写多少方法啊,可以用这个方法,很全面的:
如果你单纯的只是想判断一下是否是手机端浏览器,可以这样:
import { browserBox } from 'js-tool-big-box';
const checkBrowser = browserBox.isMobileBrowser();
console.log('当前是手机端浏览器吗?', checkBrowser);
如果你需要更详细的,根据内核做一些判断,可以这样:
const info = browserBox.getBrowserInfo();
console.log('=-=-=', info);
这个getBrowserInfo方法,可以获取更详细的ua,浏览器名字,以及浏览器版本号
2.5 日期转换
妹子问,大家日常日期转换怎么做?如果服务端给的是一个时间戳的话?我们说:不就是引入一个js库,然后就开始使用呗?
妹子问:这次产品的要求是,年月日中间不是横岗,也不是冒号,竟然要求我显示这个符号 “~” ,也不是咋想的?然后我们问:你是不是获取了年月日,然后把年月日中间拼接上了这个符号呢?
妹子摇摇头,说,你可以这样:
import { timeBox } from 'js-tool-big-box';
const dateTime2 = timeBox.getFullDateTime(1719220131000, 'YYYY-MM-DD', '~');
console.log(dateTime2); // 2024~06~24
2.6 获取数据的详细类型
妹子问,大家日常获取数据的类型怎么获取?我们说,typeof呀,instanceof呀,或者是
Object.prototype.toString.call 一下呗,
妹子摇摇头,说,你可以这样:
import { dataBox } from 'js-tool-big-box';
const numValue = 42;
console.log('42的具体数据类型:', dataBox.getDataType(numValue)); // [object Number]
const strValue = 'hello';
console.log('hello的具体数据类型:', dataBox.getDataType(strValue)); // [object String]
const booleanValue = true;
console.log('true的具体数据类型:', dataBox.getDataType(booleanValue)); // [object Boolean]
const undefinedValue = undefined;
console.log('undefined的具体数据类型:', dataBox.getDataType(undefinedValue)); // [object Undefined]
const nullValue = null;
console.log('null的具体数据类型:', dataBox.getDataType(nullValue)); // [object Null]
const objValue = {};
console.log('{}的具体数据类型:', dataBox.getDataType(objValue)); // [object Object]
const arrayValue = [];
console.log('[]的具体数据类型:', dataBox.getDataType(arrayValue)); // [object Array]
const functionValue = function(){};
console.log('function的具体数据类型:', dataBox.getDataType(functionValue)); // [object Function]
const dateValue = new Date();
console.log('date的具体数据类型:', dataBox.getDataType(dateValue)); // [object Date]
const regExpValue = /regex/;
console.log('regex的具体数据类型:', dataBox.getDataType(regExpValue)); // [object RegExp]
2.8 更多
估计妹子也是摇头摇的有点累了,后来演示的就快起来了,我后来也没听得太仔细,大概有,
比如我们做懒加载的时候,判断某个元素是否在可视范围内;
比如判断浏览器向上滚动还是向下滚动,距离底部和顶部的距离;
比如某个页面,需要根据列表下载一个excel文件啦;
比如生成一个UUID啦;
比如后面还有将小写数字转为大写中文啦,等等等等
3 最后
分享完了第二天,妹子就没来,我们还准备请教她具体js-tool-big-box的使用心得呢。据说是第一天分享的时候,摇头摇得把脖子扭到了,希望妹子能早日康复,早点来上班。
最后告诉你个消息:
js-tool-big-box的npm地址是(js-tool-big-box 的npm地址)
js-tool-big-box的git仓库地址(js-tool-big-box的代码仓库地址)
来源:juejin.cn/post/7383650248265465867
绑定大量的的v-model,导致页面卡顿的解决方案
绑定大量的的v-model,导致页面卡顿的解决方案
设计图如下:
页面布局看着很简单使用element组件,那就完蛋了,因为是大量的数据双向绑定,所以使用组件,延迟非常高,高到什么程度,请求100条数据到渲染到页面上,要10-12s,特别是下拉选择的时候,延迟都在2-3s,人麻了老铁!!!
卡顿的原因很长一段时间都是在绑定v-model,为什么绑定v-model会很卡呢,请求到的每一条数据有14个数据需要绑定v-model,每次一请求就是100个打底,那就是1400个数据需要绑定v-model;而且组件本身也有延迟,所以这个方案不能采用,那怎么做呢?
我尝试采用原生去写,写着写着,哎解决了!!!惊呆了
做完后100条数据页面渲染不超过2s,毕竟还是需要绑定v-model,能在2s内,我还是能接受的吧;选择和输入延迟基本没有
下面就来展示一下我的代码,写的不好看着玩儿就好了:
请求到的数据:
methods这两个事件做的什么事儿呢,就是手动将数据绑定到数据上去也就是row上如图:
当然还有很多解决方案
来源:juejin.cn/post/7392248233222881316
无框架,跨框架!时隔两年,哈啰Quark Design迎来重大特性升级!
引言
历经1年多迭代,Quarkd 2.0 版本正式发布,这是自 Quarkd 开源以来第二个重大版本。本次升级主要实现了组件外部可以穿透影子Dom,修改组件内部元素的任何样式。
- (迁移后)最新官网:quark-ecosystem.github.io/quarkd-docs
- Github 地址:github.com/hellof2e/qu…
Quark Design 介绍
Quark(夸克) Design 是由哈啰平台 UED 和增长&电商前端团队联合打造的一套面向移动端的跨框架 UI 组件库。与业界第三方组件库不一样,Quark Design 底层基于 Web Components 实现,它能做到一套代码,同时运行在各类前端框架/无框架中。
前端各类框架技术发展多年,很多公司存量前端项目中必定存在各类技术栈。为了解决各类不同技术栈下UI交互统一,我们开发了这套UI组件库。
之前技术瓶颈
熟悉 quarkd 的开发者都知道其底层基因是 Web Components,从而实现了跨技术栈使用。但Web Components 中的 shadow dom 特性决定了其“孤岛”的特性,组件内部是个独立于外部的小世界,外部无法修改组件内部样式,若要修改内部样式,我们在 quarkd 1.x 版本中采用了 CSS 变量的方式来支援这种做法。
但这种做法依旧局限性非常大,你只能修改预设css变量的指定样式,比如你要修改 Dialog 内容中的字体大小/颜色:
// 使用组件
<quark-dialog class=“dialog” content="生命远不止连轴转和忙到极限,人类的体验远比这辽阔、丰富得多。"></quark-dialog>
// 内部css源码
:host .quark-dialog-content {
font-size: var(--dialog-content-font-size, 14px);
color: var(--dialog-content-color, "#5A6066");
// ... 其它样式
}
这时候,你需要在组件外部书写:
.dialog {
--dialog-content-font-size: 36px;
--dialog-content-color: red;
}
这种做法会带来一些问题,比如当源码中没有指定的css变量,就意味着你无法通过css变量从外面渗透进入组件内部去修改,比如 dialog conent 内的 font-style
。
升级后
得益于 ::part
CSS 伪元素的特性, 我们将 Quarkd 主要 dom 节点进行改造,升级后,你可以通过如下方式来自定义任何组件样式。
custom-element::part(foo) {
/* 样式作用于 `foo` 部分 */
}
::part
可以用来表示在阴影树中任何匹配 part
属性的元素。
该特性已兼容主流浏览器,详情见:mozilla.org # ::part()
用法示例:
// 使用组件
<quark-dialog class=“dialog” content="生命远不止连轴转和忙到极限,人类的体验远比这辽阔、丰富得多。"></quark-dialog>
.dialog::part(body) {
font-size: 24px;
color: #666;
}
.dialog::part(footer) {
font-size: 14px;
color: #333;
}
其它DEMO地址:stackblitz.com/edit/quarkd…
关于升级
Quarkd 2.x 向下兼容所有 1.x 功能及特性,之前的css变量也被保留,所以使用者可以从1.x直接升级到2.x!
One more thing
假如你也想利用 quarkd 底层能力构建属于自己的跨技术栈组件,欢迎使用:
github.com/hellof2e/qu…
最后
感谢在Quarkd迭代期间作出贡献的朋友们,感谢所有使用quarkd的开发者!
来源:juejin.cn/post/7391753478123864091
zero-privacy——uniapp小程序隐私协议弹窗组件
一. 引言
为规范开发者的用户个人信息处理行为,保障用户的合法权益,自2023年9月15日起,对于涉及处理用户个人信息的小程序开发者,微信要求,仅当开发者主动向平台同步用户已阅读并同意了小程序的隐私保护指引等信息处理规则后,方可调用微信提供的隐私接口。
公告地址:关于小程序隐私保护指引设置的公告
developers.weixin.qq.com/miniprogram…
接下来我们将打造一个保姆级的隐私协议弹窗组件
二. 开发调试基础
划重点,看文档,别说为什么没有效果,没有弹窗
1. 更新用户隐私保护指引
小程序管理员或开发者可以根据具体小程序涉及到的隐私相关接口来更新微信小程序后台的用户隐私保护指引,更新并审核通过后就可以进行相关的开发调试工作。仅有在指引中声明所处理的用户信息,才可以调用平台提供的对应接口或组件。若未声明,对应接口或组件将直接禁用。
- ���知道怎么填写隐私协议,看看文档:用户隐私保护指引设置developers.weixin.qq.com/miniprogram…
- 哪些api需要用户点击同意隐私协议才可以使用的看这里:小程序用户隐私保护指引内容介绍developers.weixin.qq.com/miniprogram…
审核时间有人说十几分钟,我自己的给大家参考一下。
审核通过!审核通过!审核通过后才可以开发调试。
2.配置调试字段 "__usePrivacyCheck__": true
- 在 2023 年 9 月 15 号之前,在 app.json 中配置
"__usePrivacyCheck__": true
后,会启用隐私相关功能,如果不配置或者配置为 false 则不会启用。 - 在 2023 年 9 月 15 号之后,不论 app.json 中是否有配置 usePrivacyCheck,隐私相关功能都会启用。
- 所以在基于uni-app开发时,我们在 2023 年 9 月 15 号之前进行相关开发调试则需要在manifest.json文件mp-weixin中添加
"__usePrivacyCheck__": true
- manifest.json文件源码视图
"mp-weixin" : {
"__usePrivacyCheck__": true
},
3. 配置微信开发工具基础库
将调试基础库改为3.0.0以上。具体路径为:
微信开发者工具->详情->本地设置->调试基础库
以上配置完成后,即可看看效果,我在小程序后台设置了剪切板的隐私接口,果然,已经提示没有隐私授权不能使用了。
三. zero-privacy组件介绍
组件下载地址:ext.dcloud.net.cn/plugin?name…
组件的功能和特点
- 支持 居中弹出,底部弹出
- 不依赖第三方弹窗组件,内置轻量动画效果
- 支持自定义触发条件
- 支持自定义主题色
- 组件中最重要的4个api(只需用到前3个):
- wx.getPrivacySetting 查询隐私授权情况 官方链接
- wx.onNeedPrivacyAuthorization 监听隐私接口需要用户授权事件。 官方链接
- wx.openPrivacyContract 跳转至隐私协议页面 官方链接
- wx.requirePrivacyAuthorize 模拟隐私接口调用,并触发隐私弹窗逻辑 官方链接
四. zero-privacy组件使用方法
在uniapp插件市场直接下载导入 uni_modules
后使用即可
- 最直接看到弹窗效果的测试方法
<template>
<view class="container">
<zero-privacy :onNeed='false'></zero-privacy>
</view>
</template>
注意以上是测试方案,不建议实际开发中按上面的方法使用,推荐以下两种方法
- 在小程序首页等tabbar页面直接处理隐私弹窗逻辑
<template>
<view class="container">
<zero-privacy :onNeed='false' :hideTabBar='true'></zero-privacy>
</view>
</template>
- 在页面点击某些需要用到隐私协议后处理隐私弹窗逻辑
<template>
<view class="container">
<view class="btn" @click="handleCopy">
复制
</view>
<zero-privacy></zero-privacy>
</view>
</template>
- 自定义内容使用
<template>
<view class="container">
<zero-privacy title="测试自定义标题" predesc="协议前内容" privacy-contract-name-custom="<自定义名称及括号>" subdesc="协议后内容协议后内容协议后内容. 主动换行"></zero-privacy>
</view>
</template>
五. zero-privacy组件参数说明
参数 | 类型 | 默认值 | 描述 |
---|---|---|---|
position | String | center | 可选 bottom ,从底部弹出 |
color | String | #0396FF | 主颜色: 协议名和同意按钮的背景色 |
bgcolor | String | #ffffff | 弹窗背景色 |
onNeed | Boolean | true | 使用到隐私相关api时触发弹窗,设置为false时初始化弹窗将判断是否需要隐私授权,需要则直接弹出 |
hideTabBar | Boolean | false | 是否需要隐藏tabbar,在首页等tabbar页面使用改弹窗时建议改为true |
title | String | #ffffff | 用户隐私保护提示 |
predesc | String | 使用前请仔细阅读 Ï | 协议名称前的内容 |
subdesc | String | 当您点击同意后,即表示您已理解并同意该条款内容,该条款将对您产生法律约束力。如您拒绝,将无法使用该服务。 | 协议名称后的内容 |
privacyContractNameCustom | String | '' | 自定义协议名称,不传则由小程序自动获取 |
predesc
和 subdesc
的自定义内容,需要主动换行时在内容中添加实体字符
即可
六. zero-privacy组件运行效果
来源:juejin.cn/post/7273803674790150183
领导让前端实习生在网页上添加一个长时间不操作锁定电脑的功能
前情提要
大约一个月前,公司的医疗管理系统终于完工上线。后面一个周一,领导叫大家开会,说后面没有项目进来了,用不了这么多开发人员,原地宣布裁员。再后一周后,花 2000 招了个实习生,工作内容为系统维护。
工作内容
领导:由于我们工作内容很简单,事情轻松,基本就在页面上加加按钮就行,所以工资相对较少一些,是否接受?
实习生小李:能开实习证明吗?
领导:能的。
实习生小李:好的,谢谢老板。
领导:什么时候能入职?
实习生小李:现在。
工作来源
医疗系统是一个比较数据敏感的系统,现在医院那边需要添加一个十分钟时间没有在系统进行操作,则锁定电脑的功能,使用者再次使用时,必须输入密码。客户那边在系统对接群里发出需求时,并没有人回复(PS:人都裁完了),然后老板回复到:好的。
工作安排
领导:小李,我们有个医疗系统,需要添加锁屏功能,你处理一下,两天时间应该没问题吧?
实习生小李:(思索片刻)好的,有代码吗
(4小时之后)
领导:有的,我找下
(第二天10点)
实习生小李:王总,代码找到了没有
(第二天12点)
领导:没代码改不了吗?
实习生小李:(瑟瑟发抖)我试试
(第二天14点)
实习生小李:王总,是那种长时间不操作就锁定系统的功能吗
领导:是的
实习生小李:多久不操作才锁
领导:十分钟,锁了需要输入密码才能使用
实习生小李:但是我们医疗系统没有密码功能
领导:客户电脑有密码啊
实习生小李:是锁电脑系统吗
领导:对
实习生小李:(若有所思)我试试
实现过程
实习生小李:魔镜魔镜,我们有个医疗系统,需要做一个十分钟不操作电脑,就锁定用户电脑系统的功能,在没有源代码的情况下如何实现?
魔镜:好的,在没有源代码的情况下为医疗系统添加十分钟不操作电脑就锁定用户电脑系统的功能,可以使用 sys-shim 实现。
第一步,创建一个目录例如 medical-system
,目录里有以下两个文件:
package.json
文件用来配置 sys-shimpreload.js
用来向医疗系统添加功能
第二步
在 package.json 中编写内容如下
{
"browserArguments": "--disable-web-security --allow-running-insecure-content ",
"form": {
"right": "1300",
"bottom": "800"
},
"page": "https://www.baidu.com/"
}
- browserArguments 用来指定浏览器参数,这里配置为允许跨域以方便注入代码
- form 用来控制窗口如何显示,这里表示窗口大小
- page 表示医疗系统的页面
在 preload.js 中编写内容如下
new Promise(async function () {
window.main = await new window.Sys({ log: true })
// 设置倒计时时间,为了测试方便,这里改为 30 秒
const TIMEOUT = 0.5 * 60 * 1000;
// 声明一个变量来存储 setTimeout 的引用
let timeoutId = null;
// 定义一个函数来重置倒计时并在2分钟后打印日志
function startInactivityCheck() {
// 清除之前的倒计时(如果有的话)
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
// 设置一个新的倒计时
timeoutId = setTimeout(function() {
// 锁定系统
window.main.native.sys.lock()
}, TIMEOUT);
}
// 为 body 元素添加点击事件监听器
document.body.addEventListener('click', function() {
console.log("检测到点击事件,重新开始计时。");
// 重置倒计时
startInactivityCheck();
});
// 初始化倒计时
startInactivityCheck();
})
sys.lock()
方法用于锁定操作系统。
第三步,生成应用程序
npx sys-shim pack --input medical-system
运行该命令后,会在当前目录生成一个名为 medical-system.exe 的可执行文件。它封装了医疗系统这个 web 程序,并在里面添加了锁屏功能。
pack
指定表示打包应用--input
参数表示要打包的目录
--input 参数也可以是线上的网页,比如:
npx sys-shim pack --input https://www.baidu.com/
即可获取一个可以调用操作系统 api 的 web 应用。
交付反馈
用户:以前我们还需要进入浏览器输入网址才能进入系统,现在直接在桌面上就能进入,并且还有安全锁屏功能,非常好!
领导:小李干得不错,但没有在规定的时间内完成,但由于客户反馈不错,就不扣你的考核分了。
实习生小李:(不得其解)谢谢老板。
后记
不知不觉,又到了周五,这是公司技术分享会的时候。当前公司技术人员只有实习生小李,由小李负责技术分享。
宽旷的会议室里,秘书、领导、小李三人面面相觑,小李强忍住尴尬,开始了自己的第一次技术分享:
实习生小李:感谢领导给我的工作机会,在这份工作里,我发现了 sys-shim 这个工具,它可以方便的在已有的 web 页面中添加系统 api,获取调用操作系统层面功能的能力,比如关机、锁屏。
领导:(好奇)那他可以读取电脑上的文件吗?
实习生小李:可以的,它可以直接读取电脑上的文件,例如电脑里面的文档、照片、视频等。
突然领导脸色一黑,看了一眼秘书,并关闭了正在访问的医疗系统,然后在技术分享考核表上写下潦潦草草的几个字:考核分-5
。
续集:托领导大福!前端实习生用 vue 随手写了个系统修复工具,日赚 300
提示
大家可以直接运行这个命令生成 app 体验:
npx sys-shim pack --input https://www.baidu.com/
生成后的 app 可以右键解压,看到内部结构。如果遇到问题,可以在这里提交,方便追溯,我会及时解答的。
参考
来源:juejin.cn/post/7373831659470880806
领导被我的花式console.log吸引了!直接写入公司公共库!
文章的效果,大家可以直接只用云vscode实验一下:juejin.cn/post/738875…
背景简介
这几天代码评审,领导无意中看到了我本地代码的控制台,被我花里胡哨的console打印
内容吸引了!
老板看见后,说我这东西有意思,花里胡哨的,他喜欢!
但是随即又问我,这么花里胡哨的东西,上生产会影响性能吧?我自信的说:不会,代码内有判断的,只有开发环境会打印
!
老板很满意,于是让我给其他前端同事分享一下,讲解下实现思路!最终,这个方法还被写入公司的公用utils库里,供大家使用!
console简介
console 是一个用于调试和记录信息的内置对象, 提供了多种方法,可以帮助开发者输出各种信息,进行调试和分析。
console.log()
用于输出一般信息,大家应该在熟悉不过了。
console.info() :
输出信息,与 console.log 类似,但在某些浏览器中可能有不同的样式。
console.warn() :
输出警告信息,通常会以黄色背景或带有警告图标的样式显示。
console.error() :
输出错误信息,通常会以红色背景或带有错误图标的样式显示。
console.table() :
以表格形式输出数据,适用于数组和对象。
例如:
const users = [
{ name: '石小石', age: 18 },
{ name: '刘亦菲', age: 18 }
];
console.table(users);
通过上述介绍,我们可以看出,原生的文本信息、警告信息、错误信息、数组信息打印出来的效果都很普通,辨识度不高!现在我们通过console.log来实现一些花里花哨的样式!
技术方案
console.log()
console.log() 可以接受任何类型的参数,包括字符串、数字、布尔值、对象、数组、函数等。最厉害的是,它支持占位符!
常用的占位符:
- %s - 字符串
- %d or %i - 整数
- %f - 浮点数
- %o - 对象
- %c - CSS 样式
格式化字符串
console.log() 支持类似于 C 语言 printf 函数的格式化字符串。我们可以使用占位符来插入变量值。
const name = 'Alice';
const age = 30;
console.log('Name: %s, Age: %d', name, age); // Name: Alice, Age: 30
添加样式
可以使用 %c 占位符添加 CSS 样式,使输出内容更加美观。
console.log('%c This is a styled message', 'color: red; font-size: 20px;');
自定义样式的实现,其实主要是靠%c 占位符添加 CSS 样式实现的!
实现美化的信息打印
基础信息打印
我们创建一个prettyLog方法,用于逻辑编写
// 美化打印实现方法
const prettyLog = () => {
const isEmpty = (value: any) => {
return value == null || value === undefined || value === '';
};
const prettyPrint = (title: string, text: string, color: string) => {
console.log(
`%c ${title} %c ${text} %c`,
`background:${color};border:1px solid ${color}; padding: 1px; border-radius: 2px 0 0 2px; color: #fff;`,
`border:1px solid ${color}; padding: 1px; border-radius: 0 2px 2px 0; color: ${color};`,
'background:transparent'
);
};
// 基础信息打印
const info = (textOrTitle: string, content = '') => {
const title = isEmpty(content) ? 'Info' : textOrTitle;
const text = isEmpty(content) ? textOrTitle : content;
prettyPrint(title, text, '#909399');
};
return {
info
};
};
上述代码定义了一个 prettyLog 函数,用于美化打印信息到控制台。通过自定义样式,输出信息以更易读和美观的格式呈现。
我们使用一下看看效果
// 创建打印对象
const log = prettyLog();
// 不带标题
log.info('这是基础信息!');
//带标题
log.info('注意看', '这是个男人叫小帅!');
info 方法用于输出信息级别的日志。它接受两个参数:textOrTitle 和 content。如果只提供一个参数,则视为内容并设置默认标题为 Info;如果提供两个参数,则第一个参数为标题,第二个参数为内容。最后调用 prettyPrint 方法进行输出。
错误信息打印
const prettyLog = () => {
const isEmpty = (value: any) => {
return value == null || value === undefined || value === '';
};
const prettyPrint = (title: string, text: string, color: string) => {
// ...
};
const info = (textOrTitle: string, content = '') => {
// ...
};
const error = (textOrTitle: string, content = '') => {
const title = isEmpty(content) ? 'Error' : textOrTitle;
const text = isEmpty(content) ? textOrTitle : content;
prettyPrint(title, text, '#F56C6C');
};
// retu;
return {
info,
error,
};
};
// 创建打印对象
const log = prettyLog();
log.error('奥德彪', '出来的时候穷 生活总是让我穷 所以现在还是穷。');
log.error('前方的路看似很危险,实际一点也不安全。');
成功信息与警告信息打印
// 美化打印实现方法
const prettyLog = () => {
const isEmpty = (value: any) => {
return value == null || value === undefined || value === '';
};
const prettyPrint = (title: string, text: string, color: string) => {
console.log(
`%c ${title} %c ${text} %c`,
`background:${color};border:1px solid ${color}; padding: 1px; border-radius: 2px 0 0 2px; color: #fff;`,
`border:1px solid ${color}; padding: 1px; border-radius: 0 2px 2px 0; color: ${color};`,
'background:transparent'
);
};
const info = (textOrTitle: string, content = '') => {
const title = isEmpty(content) ? 'Info' : textOrTitle;
const text = isEmpty(content) ? textOrTitle : content;
prettyPrint(title, text, '#909399');
};
const error = (textOrTitle: string, content = '') => {
const title = isEmpty(content) ? 'Error' : textOrTitle;
const text = isEmpty(content) ? textOrTitle : content;
prettyPrint(title, text, '#F56C6C');
};
const warning = (textOrTitle: string, content = '') => {
const title = isEmpty(content) ? 'Warning' : textOrTitle;
const text = isEmpty(content) ? textOrTitle : content;
prettyPrint(title, text, '#E6A23C');
};
const success = (textOrTitle: string, content = '') => {
const title = isEmpty(content) ? 'Success ' : textOrTitle;
const text = isEmpty(content) ? textOrTitle : content;
prettyPrint(title, text, '#67C23A');
};
// retu;
return {
info,
error,
warning,
success
};
};
// 创建打印对象
const log = prettyLog();
log.warning('奥德彪', '我并非无路可走 我还有死路一条! ');
log.success('奥德彪', '钱没了可以再赚,良心没了便可以赚的更多。 ');
实现图片打印
// 美化打印实现方法
const prettyLog = () => {
// ....
const picture = (url: string, scale = 1) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const c = document.createElement('canvas');
const ctx = c.getContext('2d');
if (ctx) {
c.width = img.width;
c.height = img.height;
ctx.fillStyle = 'red';
ctx.fillRect(0, 0, c.width, c.height);
ctx.drawImage(img, 0, 0);
const dataUri = c.toDataURL('image/png');
console.log(
`%c sup?`,
`font-size: 1px;
padding: ${Math.floor((img.height * scale) / 2)}px ${Math.floor((img.width * scale) / 2)}px;
background-image: url(${dataUri});
background-repeat: no-repeat;
background-size: ${img.width * scale}px ${img.height * scale}px;
color: transparent;
`
);
}
};
img.src = url;
};
return {
info,
error,
warning,
success,
picture
};
}
// 创建打印对象
const log = prettyLog();
log.picture('https://nimg.ws.126.net/?url=http%3A%2F%2Fdingyue.ws.126.net%2F2024%2F0514%2Fd0ea93ebj00sdgx56001xd200u000gtg00hz00a2.jpg&thumbnail=660x2147483647&quality=80&type=jpg');
上述代码参考了其他文章:Just a moment...
url可以传支持 base64,如果是url链接,图片链接则必须开启了跨域访问才能打印
实现美化的数组打印
打印对象或者数组,其实用原生的console.table比较好
const data = [
{ id: 1, name: 'Alice', age: 25 },
{ id: 2, name: 'Bob', age: 30 },
{ id: 3, name: 'Charlie', age: 35 }
];
console.table(data);
当然,我们也可以伪实现
const table = () => {
const data = [
{ id: 1, name: 'Alice', age: 25 },
{ id: 2, name: 'Bob', age: 30 },
{ id: 3, name: 'Charlie', age: 35 }
];
console.log(
'%c id%c name%c age',
'color: white; background-color: black; padding: 2px 10px;',
'color: white; background-color: black; padding: 2px 10px;',
'color: white; background-color: black; padding: 2px 10px;'
);
data.forEach((row: any) => {
console.log(
`%c ${row.id} %c ${row.name} %c ${row.age} `,
'color: black; background-color: lightgray; padding: 2px 10px;',
'color: black; background-color: lightgray; padding: 2px 10px;',
'color: black; background-color: lightgray; padding: 2px 10px;'
);
});
};
但是,我们无法控制表格的宽度,因此,这个方法不太好用,不如原生。
仅在开发环境使用
// 美化打印实现方法
const prettyLog = () => {
//判断是否生产环境
const isProduction = import.meta.env.MODE === 'production';
const isEmpty = (value: any) => {
return value == null || value === undefined || value === '';
};
const prettyPrint = (title: string, text: string, color: string) => {
if (isProduction) return;
// ...
};
// ...
const picture = (url: string, scale = 1) => {
if (isProduction) return;
// ...
};
// retu;
return {
info,
error,
warning,
success,
picture,
table
};
};
我们可以通过import.meta.env.MODE 判断当前环境是否为生产环境,在生产环境,我们可以禁用信息打印!
完整代码
// 美化打印实现方法
const prettyLog = () => {
const isProduction = import.meta.env.MODE === 'production';
const isEmpty = (value: any) => {
return value == null || value === undefined || value === '';
};
const prettyPrint = (title: string, text: string, color: string) => {
if (isProduction) return;
console.log(
`%c ${title} %c ${text} %c`,
`background:${color};border:1px solid ${color}; padding: 1px; border-radius: 2px 0 0 2px; color: #fff;`,
`border:1px solid ${color}; padding: 1px; border-radius: 0 2px 2px 0; color: ${color};`,
'background:transparent'
);
};
const info = (textOrTitle: string, content = '') => {
const title = isEmpty(content) ? 'Info' : textOrTitle;
const text = isEmpty(content) ? textOrTitle : content;
prettyPrint(title, text, '#909399');
};
const error = (textOrTitle: string, content = '') => {
const title = isEmpty(content) ? 'Error' : textOrTitle;
const text = isEmpty(content) ? textOrTitle : content;
prettyPrint(title, text, '#F56C6C');
};
const warning = (textOrTitle: string, content = '') => {
const title = isEmpty(content) ? 'Warning' : textOrTitle;
const text = isEmpty(content) ? textOrTitle : content;
prettyPrint(title, text, '#E6A23C');
};
const success = (textOrTitle: string, content = '') => {
const title = isEmpty(content) ? 'Success ' : textOrTitle;
const text = isEmpty(content) ? textOrTitle : content;
prettyPrint(title, text, '#67C23A');
};
const table = () => {
const data = [
{ id: 1, name: 'Alice', age: 25 },
{ id: 2, name: 'Bob', age: 30 },
{ id: 3, name: 'Charlie', age: 35 }
];
console.log(
'%c id%c name%c age',
'color: white; background-color: black; padding: 2px 10px;',
'color: white; background-color: black; padding: 2px 10px;',
'color: white; background-color: black; padding: 2px 10px;'
);
data.forEach((row: any) => {
console.log(
`%c ${row.id} %c ${row.name} %c ${row.age} `,
'color: black; background-color: lightgray; padding: 2px 10px;',
'color: black; background-color: lightgray; padding: 2px 10px;',
'color: black; background-color: lightgray; padding: 2px 10px;'
);
});
};
const picture = (url: string, scale = 1) => {
if (isProduction) return;
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const c = document.createElement('canvas');
const ctx = c.getContext('2d');
if (ctx) {
c.width = img.width;
c.height = img.height;
ctx.fillStyle = 'red';
ctx.fillRect(0, 0, c.width, c.height);
ctx.drawImage(img, 0, 0);
const dataUri = c.toDataURL('image/png');
console.log(
`%c sup?`,
`font-size: 1px;
padding: ${Math.floor((img.height * scale) / 2)}px ${Math.floor((img.width * scale) / 2)}px;
background-image: url(${dataUri});
background-repeat: no-repeat;
background-size: ${img.width * scale}px ${img.height * scale}px;
color: transparent;
`
);
}
};
img.src = url;
};
// retu;
return {
info,
error,
warning,
success,
picture,
table
};
};
// 创建打印对象
const log = prettyLog();
来源:juejin.cn/post/7371716384847364147
fabric.js实战
一、业务需求
- 给定一个图片作为参考
- 可配置画笔颜色、粗细
- 可通过手势生成实际轨迹
- 可通过手势来圈选轨迹
- 可撤销、删除轨迹
- 可操作轨迹
- 可缩放、拖动
- 背景网格,且背景网格可缩放和拖动
- 参考图片可缩放、偏移
- 手势处理系统,单指绘制、双指缩放、三指拖动
- 需要一个禁止绘制区域,方便用户在平板上有手掌的支撑区域
二、技术选型
因为涉及到轨迹
、操作轨迹
两点,
svg无法满足大量且复杂的轨迹,canvas没法操作轨迹,所以从 fabricjs/konva 中选型,
因 fabricjs 使用人数更多,所以采取了其作为技术选型。
三、fabric 原理
- 通过其内置的几何对象来创建图形
- 维护一个对象树
- 将对象树通过 canvas-api 绘制在实际的 canvas 上
因此,fabric 能做非常多的优化手段
- 已渲染的节点可通过
子canvas
做缓存 - 对比新旧对象树能做差值更新
- 虚拟画布,类似于虚拟滚动,只渲染可视化的区域
四、模块拆分
- header:与业务相关
- toolBar:工具栏
- sidebar:与业务相关
- canvas:绘制画板
五、架构设计
六、问题收集
6.1 性能问题
- 圈选是实时的,即判断一个多边形是否相交于或包含于一条复杂轨迹,因为使用了射线法,当遇到大量轨迹的时候,可能会卡顿。目前做了多重优化手段,比如函数节流、先稀疏复杂轨迹的点、然后判断图形的占位区域是否相交、然后判断图形的线段之间是否相交、然后判断是否包含关系。
- 轨迹的实时生成,在一长串touchmove事件中,使用一个初始化的polyline,后续更改其点集,这样只需要实例化一个对象,性能高。
- touchmove回调里执行复杂的逻辑,这会阻塞touchmove的触发频率,我们将touchmove里的回调通过settimeout放到异步队列中,这样就剥离了touchmove
事件层
和 回调函数处理层
,这样touchmove的触发频率就不会被影响
6.2 禁止绘制区域
该需求无法实现,因为当我们手掌放在平板上时,会触发系统级别的误触识别算法,阻止所有的触摸事件,所以我们没办法在页面上实现该功能。
touch事件
一个屏幕上可以有多个touch触摸点,这些触摸点绑定的target是可以多个的
touchEvent对象有如下重要属性
- targetTouches:只在当前target(比如某div)上触发的touch触摸点
- touches:在屏幕上触发的所有touch触目点
特别注意点:
- 没有类似鼠标的mouseout事件,所以你一开始是点在div上的,然后移出div的范围后,依旧触发touchmove事件
平板调试手段-chrome浏览器
- 平板安装chrome移动版
- 电脑安装chrome + chrome插件:inspect devices
- 平板开启开发者选项,然后允许usb调试
- usb链接平板和chrome
- 平板和电脑都打开chrome
- 电脑启动插件,然后就能控制平板的chrome,并且对该chrome访问的网页进行调试
- 很好用哇
来源:juejin.cn/post/7278931998650744869
实现小红书响应式瀑布流
前言
瀑布流布局,不管是在pc端还是手机端都很常见,但是我们通常都是列固定。今天来实现一下小红书的响应式瀑布流。后面有仓库地址。
正文
还是先来看看效果
原理:
对每一个item都使用绝对定位,left和top都是0,最后根据容器大小、item的height通过计算来确定item的transform值
接下来从易到难来解析一下实现
初始化数据
列表怎么可以没有数据,先来初始化一下数据
确定列数及列大小
由于是响应式,我们要去监听列表容器的大小变化并记录容器宽度,这样才能做出相应的处理
根据监听得到的容器大小信息,我们可以确定每行个数
和每一个item的宽度
确定列表中item位置
确定item的位置,那么我们只需要确定transform
值就可以了,这也是整个实现的核心。我们还需要解决几个问题
- 对还不知道item的高度,怎么确定
- 我们希望把新的item放置在最低高度的旧item下方,这样全部渲染完每一列的高度才不会相差很多。
item放置的原理图,放置在当前最低高度的下面
更新item高度
当我们第一次运行的时候,每一个item的高度一定都是随机生成的,现在我们要确定item的实际高度。在这里我们还可优化一下,使用懒加载和底部加载,提升性能
。这两个在这里就不讲了,不懂的可以去搜一下。
下面代码一共两个作用
- 记录容器滚动值,传递给每一个
item
,用于判断是否加载图片。 - 判断是否请求添加数据
根据滚动值判断是否加载图片,加载图片后触发父亲更新高度函数
父亲接受到新的高度并更新高度,然后去重新计算transform
值和item高度
完整代码
结语
感兴趣的可以去试试
来源:juejin.cn/post/7270160291411886132
明明 3 行代码即可轻松实现,Promise 为何非要加塞新方法?
给前端以福利,给编程以复利。大家好,我是大家的林语冰。
00. 观前须知
地球人都知道,JS 中的异步编程是 单线程 的,和其他多线程语言的三观一龙一猪。因此,虽然其他语言的异步模式彼此互通有无,但对 JS 并不友好,比如 Actor 模型等。
这并不是说 JS 被异步社区孤立了,只是因为 JS 天生和多线程八字不合。你知道的,要求 JS 使用多线程,就像要求香菜恐惧症患者吃香菜一样离谱。本质上而言,这是刻在 JS 单线程 DNA 里的先天基因,直接决定了 JS 的“异步性状”。有趣的是,如今 JS 也变异出若干多线程的使用场景,只是比较非主流。
ES6 之后,JS 的异步编程主要基于 Promise
设计,比如人气爆棚的 fetch
API 等。因此,最新的 ES2024 功能里,又双叒叕往 Promise
加塞了新型静态方法 Promise.withResolvers()
,也就见怪不怪了。
问题在于,我发现这个新方法居然只要 3 行代码就能实现!奥卡姆剃刀原则告诉我们, 若无必要,勿增实体。那么这个鸡肋的新方法是否违背了奥卡姆剃刀原则呢?我决定先质疑、再质疑。
当然,作为应试教育的漏网之鱼,我很擅长批判性思考,不会被第一印象 PUA。经过三天三夜的刻意练习,机智如我发现新方法果然深藏不露。所以,本期我们就一起来深度学习 Promise
新方法的技术细节。
01. 静态工厂方法
Promise.withResolvers()
源自 tc39/proposal-promise-with-resolvers
提案,是 Promise
类新增的一个 静态工厂方法。
静态的意思是,该方法通过 Promise
类调用,而不是通过实例对象调用。工厂的意思是,我们可以使用该方法生成一个 Promise
实例,而无须求助于传统的构造函数 + new
实例化。
可以看到,这类似于 Promise.resolve()
等语法糖。区别在于,传统构造函数实例化的对象状态可能不太直观,而这里的 promise
显然处于待定状态,此外还“买一送二”,额外附赠一对用于改变 promise
状态的“变态函数” —— resolve()
和 reject()
。
ES2024 之后,该方法可以作为一道简单的异步笔试题 —— 请你在一杯泡面的时间里,实现一下 Promise.withResolvers()
。
如果你是我的粉丝,根本不慌,因为新方法的基本原理并不复杂,参考我下面的实现,简单给面试官表演一下就欧了。
可以看到,这个静态工厂方法的实现难点在于,如何巧妙地将变态函数暴露到外部作用域,其实核心逻辑压缩后有且仅有 3 行代码。
这就引发了本文开头的质疑:新方法是否多此一举?难道负责 JS 标准化的 tc39 委员会也有绩效考核,还是确实存在某些不为人知的极端情况?
02. 技术细节
通过对新方法进行苏格拉底式的“灵魂拷问”和三天三夜的深度学习,我可以很有把握地说,没人比我更懂它。
首先,与传统的构造函数实例化不同,新方法支持无参构造,我们不需要在调用时传递任何参数。
可以看到,构造函数实例化要求传递一个执行器回调,偷懒不传则直接报错,无法顺利实例化。
其次,变态函数的设计更加自由。
可以看到,传统的构造函数中,变态函数能且仅能作为局部变量使用,无法在构造函数外部调用。而新方法同时返回实例及其变态函数,这意味着实例和变态函数处于同一级别的作用域。
那么,这个设计上的小细节有何黑科技呢?
假设我们想要一个 Promise
实例,但尚未知晓异步任务的所有细节,我们期望先将变态函数抽离出来,再根据业务逻辑灵活调用,请问阁下如何应对?
ES2024 之前,我们可以通过 作用域提升 来“曲线救国”,举个栗子:
可以看到,这种方案的优势在于,诉诸作用域提升,我们不必把所有猫猫放在一个薛定谔的容器里,在构造函数中封装一大坨“代码屎山”;其次,变态函数不被限制在构造函数内部,随时随地任你调用。
该方案的缺陷则在于,某些社区规范鼓励“const
优先”的代码风格,即 const
声明优先,再按需修改为 let
声明。
这里的变态函数被迫使用 let
声明,这意味着存在被愣头青意外重写的隐患,但为了缓存赋值,我们一开始就不能使用 const
声明。从防御式编程的角度,这可能不太鲁棒。
因此,Promise.withResolvers()
应运而生,该静态工厂方法允许我们:
- 无参构造
const
优先- 自由变态
03. 设计动机
在某些需要封装 Promise
风格的场景中,新方法还能减少回调函数的嵌套,我把这种代码风格上的优化称为“去回调化”。
举个栗子,我们可以把 Node 中回调风格的 API 转换为 Promise
风格,以 fs
模块为例:
可以看到,由于使用了传统的构造函数实例化,在封装 readFile()
的时候,我们被迫将其嵌套在构造函数内部。
现在,我们可以使用新方法来“去回调化”。
可以看到,传统构造函数嵌套的一层回调函数就无了,整体实现更加扁平,减肥成功!
粉丝请注意,很多 Node API 现在也内置了 Promise
版本,现实开发中不需要我们手动封装,开箱即用就欧了。但是这种封装技巧是通用的。
举个栗子,瞄一眼 MDN 电子书搬运过来的一个更复杂的用例,将 Node 可读流转换为异步可迭代对象。
可以看到,井然有序的代码中透露着一丝无法形容的优雅。我脑补了一下如何使用传统构造函数来实现上述功能,现在还没缓过来......
04. 高潮总结
从历史来看,Promise.withResolvers()
并非首创,bluebird 的 Promise.defer()
或 jQuery 的 $.defer()
等库就提供了同款功能,ES2024 只是换了个名字“新瓶装旧酒”,将其标准化为内置功能。
但是,Promise.withResolvers()
的标准化势在必行,比如 Vite 源码中就自己手动封装了同款功能。
无独有偶,Axios、Vue、TS、React 等也都在源码内部“反复造轮子”,像这种回头率超高的代码片段我们称之为 boilerplate code(样板代码)。
重复乃编程之大忌,既然大家都要写,不如大家都别写,让 JS 自己写,牺牲小我,成全大家。编程里的 DRY 原则就是让我们不要重复,因为很多 bug 就是重复导致的,而且不好统一管理和维护,《ES6 标准入门教程》科普的 魔术字符串 就是其中一种反模式。
兼容性方面,我也做过临床测试了,主流浏览器广泛支持。
总之,Promise.withResolvers()
通过将样板代码标准化,达到了消除重复的目的,原生实现除了性能更好,是一个性价比较高的静态工厂方法。
参考文献
- GitHub:github.com/tc39/propos…
- MDN:developer.mozilla.org/en-US/docs/…
- bluebird:bluebirdjs.com/docs/deprec…
粉丝互动
本期话题是:你觉得新方法好评几颗星,为什么?你可以在本文下方自由言论,文明科普。
欢迎持续关注“前端俱乐部”,给前端以福利,给编程以复利。
坚持阅读的小伙伴可以给自己点赞!谢谢大家的点赞,掰掰~
来源:juejin.cn/post/7391745629876469760
终于找到一个比较好用的前端国际化方案了
在开发Vue/React应用,一直对现有的多语言方案不是很满足,现在终于出了一个比较满意好用的了。
本节以标准的Nodejs
应用程序为例,简要介绍VoerkaI18n
国际化框架的基本使用。
vue
或react
应用的使用流程也基本相同,可以参考Vue集成和React集成。
myapp
|--package.json
|--index.js
在本项目的所有支持的源码文件中均可以使用t
函数对要翻译的文本进行包装,简单而粗暴。
// index.js
console.log(t("中华人民共和国万岁"))
console.log(t("中华人民共和国成立于{}",1949))
t
翻译函数是从myapp/languages/index.js
文件导出的翻译函数,但是现在myapp/languages
还不存在,后续会使用工具自动生成。voerkai18n
后续会使用正则表达式对提取要翻译的文本。
第一步:安装命令行工具
安装@voerkai18n/cli
到全局。
> npm install -g @voerkai18n/cli
> yarn global add @voerkai18n/cli
> pnpm add -g @voerkai18/cli
第二步:初始化工程
在工程目录中运行voerkai18n init
命令进行初始化。
> voerkai18n init
上述命令会在当前工程目录下创建languages/settings.json
文件。如果您的源代码在src
子文件夹中,则会创建在src/languages/settings.json
settings.json
内容如下:
{
"languages": [
{
"name": "zh",
"title": "zh"
},
{
"name": "en",
"title": "en"
}
],
"defaultLanguage": "zh",
"activeLanguage": "zh",
"namespaces": {}
}
上述命令代表了:
- 本项目拟支持
中文
和英文
两种语言。 - 默认语言是
中文
(即在源代码中直接使用中文) - 激活语言是
中文
(代表当前生效的语言)
注意:
- 可以修改该文件来配置支持的语言、默认语言、激活语言等。可支持的语言可参阅语言代码列表。
voerkai18n init
是可选的,voerkai18n extract
也可以实现相同的功能。- 一般情况下,您可以手工修改
settings.json
,如定义名称空间。 voerkai18n init
仅仅是创建languages
文件,并且生成settings.json
,因此您也可以自己手工创建。- 针对
js/typescript
或react/vue
等不同的应用,voerkai18n init
可以通过不同的参数来配置生成ts
文件或js
文件。 - 更多的
voerkai18n init
命令的使用请查阅这里
第三步:标识翻译内容
接下来在源码文件中,将所有需要翻译的内容使用t
翻译函数进行包装,例如下:
import { t } from "./languages"
// 不含插值变量
t("中华人民共和国")
// 位置插值变量
t("中华人民共和国{}","万岁")
t("中华人民共和国成立于{}年,首都{}",1949,"北京")
t
翻译函数只是一个普通函数,您需要为之提供执行环境,关于t
翻译函数的更多用法见这里
第四步:提取文本
接下来我们使用voerkai18n extract
命令来自动扫描工程源码文件中的需要的翻译的文本信息。 voerkai18n extract
命令会使用正则表达式来提取t("提取文本")
包装的文本。
myapp>voerkai18n extract
执行voerkai18n extract
命令后,就会在myapp/languages
通过生成translates/default.json
、settings.json
等相关文件。
- translates/default.json : 该文件就是从当前工程扫描提取出来的需要进行翻译的文本信息。所有需要翻译的文本内容均会收集到该文件中。
- settings.json: 语言环境的基本配置信息,包含支持的语言、默认语言、激活语言等信息。
最后文件结构如下:
myapp
|-- languages
|-- settings.json // 语言配置文件
|-- translates // 此文件夹是所有需要翻译的内容
|-- default.json // 默认名称空间内容
|-- package.json
|-- index.js
如果略过第一步中的voerkai18n init
,也可以使用以下命令来为创建和更新settings.json
myapp>voerkai18n extract -D -lngs zh en de jp -d zh -a zh
以上命令代表:
- 扫描当前文件夹下所有源码文件,默认是
js
、jsx
、html
、vue
文件类型。 - 支持
zh
、en
、de
、jp
四种语言 - 默认语言是中文。(指在源码文件中我们直接使用中文即可)
- 激活语言是中文(即默认切换到中文)
-D
代表显示扫描调试信息,可以显示从哪些文件提供哪些文本
第五步:人工翻译
接下来就可以分别对language/translates
文件夹下的所有JSON
文件进行翻译了。每个JSON
文件大概如下:
{
"中华人民共和国万岁":{
"en":"<在此编写对应的英文翻译内容>",
"de":"<在此编写对应的德文翻译内容>",
"jp":"<在此编写对应的日文翻译内容>",
"$files":["index.js"] // 记录了该信息是从哪几个文件中提取的
},
"中华人民共和国成立于{}":{
"en":"<在此编写对应的英文翻译内容>",
"de":"<在此编写对应的德文翻译内容>",
"jp":"<在此编写对应的日文翻译内容>",
"$files":["index.js"]
}
}
我们只需要修改该文件翻译对应的语言即可。
重点:如果翻译期间对源文件进行了修改,则只需要重新执行一下voerkai18n extract
命令,该命令会进行以下操作:
- 如果文本内容在源代码中已经删除了,则会自动从翻译清单中删除。
- 如果文本内容在源代码中已修改了,则会视为新增加的内容。
- 如果文本内容已经翻译了一部份了,则会保留已翻译的内容。
总之,反复执行voerkai18n extract
命令是安全的,不会导致进行了一半的翻译内容丢失,可以放心执行。
第六步:自动翻译
voerkai18n
支持通过voerkai18n translate
命令来实现调用在线翻译服务进行自动翻译。
>voerkai18n translate --appkey <在百度翻译上申请的密钥> --appid <在百度翻译上申请的appid>
在项目文件夹下执行上面的语句,将会自动调用百度的在线翻译API
进行翻译,以现在的翻译水平而言,您只需要进行少量的微调即可。关于voerkai18n translate
命令的使用请查阅后续介绍。
第七步:编译语言包
当我们完成myapp/languages/translates
下的所有JSON语言文件
的翻译后(如果配置了名称空间后,每一个名称空间会对应生成一个文件,详见后续名称空间
介绍),接下来需要对翻译后的文件进行编译。
myapp> voerkai18n compile
compile
命令根据myapp/languages/translates/*.json
和myapp/languages/settings.json
文件编译生成以下文件:
|-- languages
|-- settings.json // 语言配置文件
|-- idMap.js // 文本信息id映射表
|-- index.js // 包含该应用作用域下的翻译函数等
|-- storage.js
|-- zh.js // 语言包
|-- en.js
|-- jp.js
|-- de.js
|-- formatters // 自定义扩展格式化器
|-- zh.js
|-- en.js
|-- jp.js
|-- de.js
|-- translates // 此文件夹包含了所有需要翻译的内容
|-- default.json
|-- package.json
|-- index.js
第八步:导入翻译函数
第一步中我们在源文件中直接使用了t
翻译函数包装要翻译的文本信息,该t
翻译函数就是在编译环节自动生成并声明在myapp/languages/index.js
中的。
import { t } from "./languages"
因此,我们需要在需要进行翻译时导入该函数即可。
但是如果源码文件很多,重次重复导入t
函数也是比较麻烦的,所以我们也提供了一个babel/vite
等插件来自动导入t
函数,可以根据使用场景进行选择。
第九步:切换语言
当需要切换语言时,可以通过调用change
方法来切换语言。
import { i18nScope } from "./languages"
// 切换到英文
await i18nScope.change("en")
// 或者VoerkaI18n是一个全局单例,可以直接访问
await VoerkaI18n.change("en")
i18nScope.change
与VoerkaI18n.change
两者是等价的。
一般可能也需要在语言切换后进行界面更新渲染,可以订阅事件来响应语言切换。
import { i18nScope } from "./languages"
// 切换到英文
i18nScope.on("change",(newLanguage)=>{
// 在此重新渲染界面
...
})
//
VoerkaI18n.on("change",(newLanguage)=>{
// 在此重新渲染界面
...
})
@voerkai18n/vue和@voerkai18n/react提供了相对应的插件和库来简化重新界面更新渲染。
第十步:语言包补丁
一般情况下,多语言的工程化过程就结束了,voerkai18n
在多语言实践考虑得更加人性化。有没有经常发现这样的情况,当项目上线后,才发现:
- 翻译有误
- 客户对某些用语有个人喜好,要求你更改。
- 临时要增加支持一种语言
一般碰到这种情况,只好重新打包构建工程,重新发布,整个过程繁琐而麻烦。 现在voerkai18n
针对此问题提供了完美的解决方案,可以通过服务器来为应用打语言包补丁
和动态增加语言
支持,而不需要重新打包应用和修改应用。
方法如下:
- 注册一个默认的语言包加载器函数,用来从服务器加载语言包文件。
import { i18nScope } from "./languages"
i18nScope.registerDefaultLoader(async (language,scope)=>{
return await (await fetch(`/languages/${scope.id}/${language}.json`)).json()
})
- 将语言包补丁文件保存在Web服务器上指定的位置
/languages/<应用名称>/<语言名称>.json
即可。 - 当应用启动后会自动从服务器上加载语言补丁包合并,从而实现动为语言包打补丁的功能。
- 利用该特性也可以实现动态增加临时支持一种语言的功能
来源:juejin.cn/post/7275944565885485116
使用Tauri快速搭建桌面项目
什么是 Tauri
Tauri 是一个跨平台的 GUI
框架,与 Electron
的理念相似。Tauri 的前端部分同样基于 Web 技术,但它的后端则采用了 Rust
语言。Tauri 可以创建体积更小、运行更快且更加安全的跨平台桌面应用。
与 Electron
不同,Tauri 并没有内置 Chromium
,因此打包后的应用体积要比 Electron
小很多,启动速度更快,内存和 CPU 占用率也更低。
然而,由于没有内置 Chromium
,Tauri 使用系统原生的 WebView 来渲染网页,这可能导致不同系统之间的页面表现存在差异。同时,Tauri 的后端需要使用 Rust
进行开发,这对前端开发人员来说可能会有一定的上手成本。
好在 Tauri 已经为我们封装了大部分 API,即使不懂 Rust
,也可以开发出一款简单的应用。
预先准备
我们以 macOS 为例:
1. 首先安装 Xcode 命令行工具
在终端中执行以下命令:
xcode-select --install
如果已经安装过 Xcode 命令行工具,则可以直接进行下一步。
2. 安装 Rust
在 macOS 上安装 Rust,请打开终端并输入以下命令:
curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
安装成功后,终端将显示以下内容:
Rust is installed now. Great!
请确保重新启动终端以使更改生效。
快速开始
创建项目
Tauri 官方提供了多种项目模板,可以快速搭建项目:
# pnpm
pnpm create tauri-app
# npm
npm create tauri-app
# yarn
yarn create tauri-app
按照提示选择自己喜欢的模板。
这里我们选择 react
开发前端页面。
一路回车后,打开项目文件夹。执行安装依赖命令:pnpm i
。依赖安装完成后,执行 pnpm tauri dev
命令启动项目。这时便会启动一个应用,如下图所示:
开发
Tauri 的开发非常容易上手,我们先来看下项目文件结构:
是不是和 Vite 的目录结构一样?
没错,这就是一个常规的 Vite 目录结构,唯一的区别是增加了一个 src-tauri
文件夹,这里面是 Rust
部分的代码,也就是后端代码。
打包
首先,我们需要修改默认的包标识符,位置在 src-tauri > tauri.conf.json > tauri > bundle > identifier
。
这里我们随便填写一个标识符 com.example.app
,保存,然后执行命令:pnpm tauri build
就可以正常打包了。
tauri.conf.json
文件是我们的应用配置文件,包含了应用的基本信息。
打包完成后,就可以在 tauri-app/src-tauri/target/release/bundle
目录下找到我们的应用。
现在我们只构建了 macOS 下的应用。
打开之后就可以看到我们的应用了。
参考文档
来源:juejin.cn/post/7388842078798823433
学会Grid之后,我觉得再也没有我搞不定的布局了
说到布局很多人的感觉应该都是恐惧,为此很多人都背过一些很经典的布局方案,例如:圣杯布局
、双飞翼布局
等非常耳熟的名词;
为了实现这些布局我们有很多种实现方案,例如:table布局
、float布局
、定位布局
等,当然现在比较流行的肯定是flex布局
;
flex布局
属于弹性布局,所谓弹性也可以理解为响应式布局
,而同为响应式布局的还有Grid布局
;
Grid布局
是一种二维布局,可以理解为flex布局
的升级版,它的出现让我们在布局方面有了更多的选择,废话不多说,下面开始全程高能;
本篇不会过多介绍
grid
的基础内容,更多的是一些布局的实现方案和一些小技巧;
常见布局
所谓的常见布局只是我们在日常开发中经常会遇到的布局,例如:圣杯布局
、双飞翼布局
这种名词我个人觉得不用太过于去在意;
因为这类布局最后的解释都会变成几行几列
,内容在哪一行哪一列,而这些就非常直观的对标了grid
的特性;
接下来我们来一起看看一些非常常见的布局,并且用grid
来实现;
1. 顶部 + 内容
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
html, body {
margin: 0;
padding: 0;
}
body {
display: grid;
grid-template-rows: 60px 1fr;
height: 100vh;
}
.header {
background-color: #039BE5;
}
.content {
background-color: #4FC3F7;
}
.header,
.content {
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
</style>
</head>
<body>
<div class="header">Header</div>
<div class="content">Content</div>
</body>
</html>
2. 顶部 + 内容 + 底部
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
html, body {
margin: 0;
padding: 0;
}
body {
display: grid;
grid-template-rows: 60px 1fr 60px;
height: 100vh;
}
.header {
background-color: #039BE5;
}
.content {
background-color: #4FC3F7;
}
.footer {
background-color: #039BE5;
}
.header,
.content,
.footer {
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
</style>
</head>
<body>
<div class="header">Header</div>
<div class="content">Content</div>
<div class="footer">Footer</div>
</body>
</html>
这里示例和上面的示例唯一的区别就是多了一个
footer
,但是我们可以看到代码并没有多少变化,这就是grid
的强大之处;
可以看
码上掘金
的效果,这里的内容区域是单独滚动的,从而实现了header
和footer
固定,内容区域滚动的效果;
实现这个效果也非常简单,只需要在
content
上加上overflow: auto
即可;
3. 左侧 + 内容
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
html, body {
margin: 0;
padding: 0;
}
body {
display: grid;
grid-template-columns: 240px 1fr;
height: 100vh;
}
.left {
background-color: #039BE5;
}
.content {
background-color: #4FC3F7;
}
.left,
.content {
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
</style>
</head>
<body>
<div class="left">Left</div>
<div class="content">Content</div>
</body>
</html>
这个示例效果其实和第一个是类似的,只不过是把
grid-template-rows
换成了grid-template-columns
,这里就不提供码上掘金
的示例了;
4. 顶部 + 左侧 + 内容
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
html, body {
margin: 0;
padding: 0;
}
body {
display: grid;
grid-template-rows: 60px 1fr;
grid-template-columns: 240px 1fr;
height: 100vh;
}
.header {
grid-column: 1 / 3;
background-color: #039BE5;
}
.left {
background-color: #4FC3F7;
}
.content {
background-color: #99CCFF;
}
.header,
.left,
.content {
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
</style>
</head>
<body>
<div class="header">Header</div>
<div class="left">Left</div>
<div class="content">Content</div>
</body>
</html>
这个示例不同点在于
header
占据了两列,这里我们可以使用grid-column
来实现,grid-column
的值是start / end
,例如:1 / 3
表示从第一列到第三列;
如果确定这一列是占满整行的,那么我们可以使用
1 / -1
来表示,这样如果后续变成顶部 + 左侧 + 内容 + 右侧
的布局,那么header
就不需要修改了;
5. 顶部 + 左侧 + 内容 + 底部
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
html, body {
margin: 0;
padding: 0;
}
body {
display: grid;
grid-template-areas:
"header header"
"left content"
"left footer";
grid-template-rows: 60px 1fr 60px;
grid-template-columns: 240px 1fr;
height: 100vh;
}
.header {
grid-area: header;
background-color: #039BE5;
}
.left {
grid-area: left;
background-color: #4FC3F7;
}
.content {
grid-area: content;
background-color: #99CCFF;
}
.footer {
grid-area: footer;
background-color: #6699CC;
}
.header,
.left,
.content,
.footer {
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
</style>
</head>
<body>
<div class="header">Header</div>
<div class="left">Left</div>
<div class="content">Content</div>
<div class="footer">Footer</div>
</body>
</html>
这个示例的小技巧是使用了
grid-template-areas
,使用这个属性可以让我们通过代码来直观的看到布局的样式;
这里的值是一个字符串,每一行代表一行,每个字符代表一列,例如:
"header header"
表示第一行的两列都是header
,这里的header
是我们自己定义的,可以是任意值;
定义好了之后就可以在对应的元素上使用
grid-area
来指定对应的区域,这里的值就是我们在grid-template-areas
中定义的值;
在
码上掘金
中的效果可以看到,左侧的菜单和内容区域都是单独滚动的,这里的实现方式和第二个示例是一样的,只需要需要滚动的元素上加上overflow: auto
即可;
响应式布局
响应式布局指的是页面的布局会随着屏幕的大小而变化,这里的变化可以是内容区域大小可以自动调整,也可以是页面布局随着屏幕大小进行自动调整;
这里我就用掘金的页面来举例,这里只提供一个思路,所以不会像上面那样提供那么多示例;
1. 基础布局实现
移动端布局
以移动端的效果开始,掘金的移动端的布局就是上面的效果,这里我简单的将页面分为了三个部分,分别是
header
、navigation
、content
;
注:这里不是要
100%
还原掘金的页面,只是为了演示grid
布局,具体页面结构和最后实现的效果会有非常大的差异,这里只会实现一些基础的布局;
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
html, body {
margin: 0;
padding: 0;
}
body {
display: grid;
grid-template-areas:
"header"
"navigation"
"content";
grid-template-rows: 60px 48px 1fr;
height: 100vh;
}
.header {
grid-area: header;
background-color: #039BE5;
}
.navigation {
grid-area: navigation;
background-color: #4FC3F7;
}
.content {
grid-area: content;
background-color: #99CCFF;
}
.header,
.navigation,
.content {
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
</style>
</head>
<body>
<div class="header">Header</div>
<div class="navigation">Navigation</div>
<div class="content">Content</div>
</body>
</html>
iPad布局
这里是需要借助媒体查询来实现的,在媒体查询中只需要调整一下
grid-template-rows
和grid-template-columns
的值即可;
由于这里的效果是上面一个的延伸,为了阅读体验会移除上面相关的
css
代码,只保留需要修改的代码;
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
.right {
display: none;
background-color: #6699CC;
}
@media (min-width: 1000px) {
body {
grid-template-areas:
"header header"
"navigation navigation"
"content right";
grid-template-columns: 1fr 260px;
}
.right {
grid-area: right;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
}
</style>
</head>
<body>
<div class="header">Header</div>
<div class="navigation">Navigation</div>
<div class="content">Content</div>
<div class="right">Right</div>
</body>
</html>
PC端布局
和上面处理方式相同,由于
Navigation
移动到了左侧,所以还要额外的修改一下grid-template-areas
的值;
这里就可以体现
grid
的强大之处了,我们可以简单的修改grid-template-areas
就可以实现一个完全不同的布局,而且代码量非常少;
为了居中显示内容,我们需要在左右两侧加上一些空白区域,可以简单的使用
.
来实现,这里的.
表示一个空白区域;
由于内容的宽度基本上是固定的,所以留白区域简单的使用
1fr
进行占位即可,这样就可以平均的分配剩余的空间;
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
@media (min-width: 1220px) {
body {
grid-template-areas:
"header header header header header"
". navigation content right .";
grid-template-columns: 1fr 180px minmax(0, 720px) 260px 1fr;
grid-template-rows: 60px 1fr;
}
}
</style>
</head>
<body>
<div class="header">Header</div>
<div class="navigation">Navigation</div>
<div class="content">Content</div>
<div class="right">Right</div>
</body>
</html>
完善一些细节
最终的布局大概就是上图这样,这里主要处理的各个版块的间距和响应式内容区域的大小,这里的处理方式主要是使用
column-gap
和一个空的区域进行占位来实现的;
这里的
column-gap
表示列与列之间的间距,值可以是px
、em
、rem
等基本的长度属性值,也可以使用计算函数,但是不能使用弹性值fr
;
空区域进行占位留间距其实我并不推荐,这里只是演示
grid
布局可以实现的一些功能,具体的实现方式还是要根据实际情况来定,这里我更推荐使用margin
来实现;
完整代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
html, body {
margin: 0;
padding: 0;
}
body {
display: grid;
grid-template-areas:
"header header header"
"navigation navigation navigation"
". . ."
". content .";
grid-template-columns: 1fr minmax(0, 720px) 1fr;
grid-template-rows: 60px 48px 10px 1fr;
column-gap: 10px;
height: 100vh;
}
.header {
grid-area: header;
background-color: #039BE5;
}
.navigation {
grid-area: navigation;
background-color: #4FC3F7;
}
.content {
grid-area: content;
background-color: #99CCFF;
}
.right {
display: none;
background-color: #6699CC;
}
@media (min-width: 1000px) {
body {
grid-template-areas:
"header header header header"
"navigation navigation navigation navigation"
". . . ."
". content right .";
grid-template-columns: 1fr minmax(0, 720px) 260px 1fr;
}
.right {
grid-area: right;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
}
@media (min-width: 1220px) {
body {
grid-template-areas:
"header header header header header"
". . . . ."
". navigation content right .";
grid-template-columns: 1fr 180px minmax(0, 720px) 260px 1fr;
grid-template-rows: 60px 10px 1fr;
}
}
.header,
.navigation,
.content {
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
</style>
</head>
<body>
<div class="header">Header</div>
<div class="navigation">Navigation</div>
<div class="content">Content</div>
<div class="right">Right</div>
</body>
</html>
简单复刻版
以
码上掘金
上的效果来说已经完成了大部分的布局和一些效果,目前来说就是还差一些交互,还有一些细节上的处理,感兴趣的同学可以自行完善;
异型布局
异性布局指的是页面中的元素不是按照常规的流式布局进行排版,又或者说不规则的布局,这里我简单的列出几个布局,来看看grid
是如何实现的;
1. 照片墙
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
html, body {
margin: 0;
padding: 0;
background: #f2f3f5;
overflow: auto;
}
body {
display: grid;
grid-template-columns: repeat(12, 100px);
grid-auto-rows: 100px;
place-content: center;
gap: 6px;
height: 100vh;
}
.photo-item {
width: 200px;
height: 200px;
clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%);
}
</style>
</head>
<body>
</body>
<script>
function randomColor() {
return '#' + Math.random().toString(16).substr(-6);
}
let row = 1;
let col = 1;
for (let i = 0; i < 28; i++) {
const div = document.createElement('div');
div.className = 'photo-item';
div.style.backgroundColor = randomColor();
div.style.gridRow = `${row} / ${row + 2}`;
div.style.gridColumn = `${col} / ${col + 2}`;
document.body.appendChild(div);
col += 2;
if (col > 11) {
row += 1;
col = row % 2 === 0 ? 2 : 1;
}
}
</script>
</html>
这是一个非常简单的照片墙效果,如果不使用
grid
的话,我们大概率是会使用定位去实现这个效果,但是换成grid
的话就非常简单了;
而且代码量是非常少的,这里就不提供
码上掘金
的 demo 了,感兴趣的同学可以将代码复制下来自行查看效果;
2. 漫画效果
在漫画中有很多类似这种不规则的漫画框,如果使用定位的话,那么代码量会非常大,而且还需要计算一些位置,使用
grid
的话就非常简单了;
可以看到这里还有一个气泡文字显示的效果,按照页面书写顺序,气泡是不会显示的,这里我们可以使用
z-index
来实现,这里的z-index
值越大,元素就越靠前;
而且气泡文字效果也是通过
grid
来进行排版的,并没有使用其他的布局来实现,代码量也并不多,感兴趣的同学可以自行查看;
3. 画报效果
在一个画报中,我们经常会看到文字和图片混合排版的效果,由于这里直接使用的是渐变的背景,而且我的文字都是随意进行排列的,没有什么规律,所以看起来会比较混乱;
在画报效果中看似文字排版非常混乱不规则,但是实际上设计师在设计的时候也是会划分区域的,当然用定位也是没问题的,但是使用
grid
的话就会简单很多;
我这里将页面划分为
12 * 12
区域的网格,然后依次对不同的元素进行单独排列和样式的设置;
流式布局
流式布局指的是页面的内容会随着屏幕的大小而变化,流式布局也可以理解为响应式布局;
但是不同于响应式布局的是,流式布局的布局不会像响应式布局那样发生变化,只是内容会随着轴进行流动;
通常这种指的是grid-template-columns: repeat(auto-fit, minmax(0, 1fr))
这种;
直接看效果:
这里有两个关键字,一个是
auto-fit
,还有一个是auto-fill
,在行为上它们是相同的,不同的是它们在网格创建的不同,
就像上面图中看到的一样,使用
auto-fit
会将空的网格进行折叠,可以看到他们的结束colum
的数字都是6
;
像我们上面的实例中不会出现这个问题,因为我们使用了响应式单位
fr
,只有使用固定单位才会出现这个现象;
感兴趣的同学可以将
minmax(200px, 1fr)
换成200px
尝试;
对比 Flex 布局
在我上面介绍了这么多的布局场景和案例,其实可以很明显的发现一件事,那就是我使用grid
进行的布局基本上都是大框架;
当然上面也有一些布局使用flex
也是可以实现的,但是我们再换个思路,除了flex
可以做到上面的一些布局,float
布局、table
布局、定位布局其实也都能实现;
不同的是float
布局、table
布局、定位布局基本上都是一些hack
的方案,就拿table
布局来说,table
本身就是一个html
标签,作用就是用来绘制表格,被拿来当做布局的一种方案也是迫不得已;
而web布局
发展到现在的我们有了正儿八经可以布局的方案flex
,为什么又要出一个grid
呢?
grid
的出现绝对不是用来替代flex
的,在我上面的实现的一些布局案例中,也可以看到我还会使用flex
;
我个人理解的是使用grid
进行主体的大框架的搭建,flex
作为一些小组件的布局控制,两者搭配使用;
flex
能实现一些grid
不好实现的布局,同样grid
也可以实现flex
实现困难的布局;
本身它们的定位就不痛,flex
作为一维布局的首选,grid
定位就是比flex
高一个维度,它的定位是二维布局,所以他们之间没有必要进行对比,合理使用就好;
总结
上面介绍的这么多基于grid
布局实现的布局方案,足以看出grid
布局的强大;
grid
布局的体系非常庞大,本文只是梳理出一些常见的布局场景,通过grid
布局去实现这些布局,来体会grid
带来的便利;
可能需要完全理解我上面的全部示例需要对grid
有一定的了解才可以,但是都看到这里了,不妨去深挖一下;
grid
布局作为一项强大的布局技术,有望在未来继续发展,除了我上面说到的布局,grid
还有很多小技巧来实现非常多的布局场景;
碍于我的见识和文笔的限制,我这次介绍grid
肯定是有很多不足的,但是还是希望这篇文章能为你对于布局相关能有新的认识;
来源:juejin.cn/post/7310423470546354239
我为什么选择Next.js+Supabase做全栈开发
作为一名前端工程师,选择合适的技术栈对项目的成功至关重要,我最近一个星期尝试了下这两个技术栈的组合,大概在一个星期就写了一个小 SAAS,总共 10 多个页面。在本文中,我将分享为什么我选择Next.js 14和Supabase作为全栈开发的首选组合,并通过最新的代码示例和比较数据,直观地展示这个选择带来的诸多优势。
Next.js 14: 现代React应用的革新框架
默认服务器组件的优势
Next.js 14默认使用服务器组件,这对于提升性能和开发体验至关重要。
例如,一个简单的服务器组件如下:
// app/page.js
async function getData() {
const res = await fetch('https://api.example.com/data')
return res.json()
}
export default async function Home() {
const data = await getData()
return <div>Welcome to {data.name}div>
}
在这个例子中,Home
组件是一个异步的服务器组件,它可以直接进行数据获取,而无需使用useEffect或getServerSideProps。
App Router: 更强大的路由系统
Next.js 14采用了新的App Router,提供了更灵活和直观的路由方式:
app/
page.js // 对应路由 /
about/
page.js // 对应路由 /about
posts/
[id]/
page.js // 对应路由 /posts/1, /posts/2, 等
Server Actions: 无需API路由的表单处理
Next.js 14引入了Server Actions,允许我们直接在服务器上处理表单提交,无需单独的API路由:
// app/form.js
export default function Form() {
async function handleSubmit(formData) {
'use server'
// 在服务器上处理表单数据
const name = formData.get('name')
// ...处理逻辑
}
return (
<form action={handleSubmit}>
<input type="text" name="name" />
<button type="submit">Submitbutton>
form>
)
}
这个能力好用到哭,不用再写API路由了,直接在页面上处理表单提交。代码简单了不止一点点。
Supabase: 开源Firebase替代品的崛起
数据库即服务的便利性
Supabase提供了PostgreSQL数据库即服务,使用起来非常简单:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient('YOUR_SUPABASE_URL', 'YOUR_SUPABASE_KEY')
// 插入数据
const { data, error } = await supabase
.from('users')
.insert({ name: 'John', email: 'john@example.com' })
实时功能的强大支持
Supabase的实时订阅功能让实现实时更新变得轻而易举:
import { useEffect } from 'react'
import { createClient } from '@supabase/supabase-js'
const supabase = createClient('YOUR_SUPABASE_URL', 'YOUR_SUPABASE_KEY')
function RealtimeData() {
useEffect(() => {
const channel = supabase
.channel('*')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'users' }, payload => {
console.log('New user:', payload.new)
})
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [])
return <div>Listening for new users...div>
}
身份认证和授权的简化
Supabase内置的身份认证系统大大简化了用户管理:
const { data, error } = await supabase.auth.signUp({
email: 'example@email.com',
password: 'example-password',
})
Next.js 14 + Supabase: 完美的全栈开发组合
开发效率的显著提升
结合Next.js 14和Supabase,我们可以快速构建全功能的Web应用。以下是一个简单的例子,展示了如何在Next.js 14的服务器组件中使用Supabase:
// app/posts/page.js
import { createClient } from '@supabase/supabase-js'
const supabase = createClient('YOUR_SUPABASE_URL', 'YOUR_SUPABASE_KEY')
export default async function Posts() {
const { data: posts } = await supabase.from('posts').select('*')
return (
<div>
{posts.map(post => (
<div key={post.id}>{post.title}div>
))}
div>
)
}
这个例子展示了Next.js 14服务器组件如何与Supabase无缝集成,直接在服务器端获取数据并渲染。
与其他技术栈的对比
为了更直观地展示Next.js 14+Supabase的优势,我们来看一个更新后的比较表格:
特性 | Next.js 14+Supabase | MERN Stack | Firebase | Django |
---|---|---|---|---|
默认服务器组件 | ✅ | ❌ | ❌ | N/A |
App Router | ✅ | ❌ | ❌ | ❌ |
Server Actions | ✅ | ❌ | ❌ | ✅ |
实时数据库 | ✅ | 需配置 | ✅ | 需配置 |
SQL支持 | ✅ (PostgreSQL) | ❌ (默认NoSQL) | ❌ (NoSQL) | ✅ |
身份认证 | ✅ | 需配置 | ✅ | ✅ |
学习曲线 | 中 | 中 | 低 | 高 |
全栈JavaScript | ✅ | ✅ | ✅ | ❌ |
开源 | ✅ | ✅ | ❌ | ✅ |
选型优势的直观感受
- 开发速度:使用Next.js 14+Supabase,你可以在几小时内搭建起一个包含用户认证、数据库操作和实时更新的全栈应用。
- 代码量减少:得益于Next.js 14的服务器组件和Supabase的简洁API,代码量可以减少40%-60%。
- 性能提升:通过Next.js 14的默认服务器组件和自动代码分割,页面加载速度可以提升40%-70%。
- 学习成本:虽然新概念(如服务器组件)需要一定学习时间,但整体学习曲线比传统全栈开发更平缓,2-3周即可上手。
- 维护简化:单一语言(TypeScript)贯穿全栈,加上Next.js的文件约定和Supabase的声明式API,大大减少了维护的复杂度。
- 可扩展性:Supabase基于PostgreSQL,为未来的扩展提供了更多可能性,而Next.js的渐进式框架特性也允许逐步采用高级功能。
一些想法
Next.js 14和Supabase是现代全栈开发的最佳选择,它们的结合提供了前所未有的开发体验和性能优势。如果你正在寻找一个全栈开发的新方向,不妨试试Next.js 14和Supabase,相信你会爱上这个组合。而且 supabase 学了也很划算,即便你想做 react native,Flutter,他都可以作为你坚实的后端。
来源:juejin.cn/post/7389925676520226825
大厂都在”偷偷“用语义化标签,你却还在div?
引言
在我们日常浏览网页的时候,通常会看到各种各样的内容,如文字、图片、视频等。这些内容背后都有一个共同的语言,那就是HTML(超文本标记语言)。HTML是构建网页的基础,它就像建筑物的框架,决定了网页的基本结构和布局。
然而,仅仅有结构是不够的。如果网页只是简单地用一些基础标签堆砌而成,那么浏览器、搜索引擎甚至我们自己在后期维护时,都会感到非常吃力。
这时候,HTML的语义化标签就显得尤为重要。语义化标签不仅能使网页结构更加清晰,还能帮助搜索引擎更好地理解和索引网页内容。
什么是HTML语义化标签?
HTML语义化标签,就是那些带有特定含义的标签,它们告诉浏览器和搜索引擎,每一部分内容是什么。这就好比是给每个内容部分都贴上了一个清晰的标签,让所有人都能明白这个部分是用来做什么的。
举个例子,假设你在看一本书,书的封面、目录、章节标题等都是明确标示出来的,这样你就能快速找到自己想看的部分。同样,HTML语义化标签也是为了让网页的内容更加明晰易懂。比如:
<header>
标签用来定义网页的头部内容,通常包含导航栏、Logo等信息;<nav>
标签专门用于定义导航链接,这样搜索引擎就能更好地理解网站的结构;<article>
标签用于定义独立的内容,比如一篇新闻文章或者博客帖子。- ……
通过使用这些语义化标签,不仅提高了网页的可读性和维护性,还能帮助搜索引擎更准确地抓取和排名内容,从而提升网站的SEO效果。
为什么使用语义化标签?
使用HTML语义化标签有很多好处,它们不仅能让代码更清晰,还能带来实际的效果和便利。
- 提高网页的可读性和结构化
- 语义化标签让HTML代码更加直观,其他开发者在阅读和维护代码时,可以快速理解每个部分的作用。这有助于团队合作和项目的长期维护。
- 有助于搜索引擎优化(SEO)
- 搜索引擎通过爬虫程序抓取网页内容,并根据网页的结构和内容进行索引。使用语义化标签可以帮助搜索引擎更好地理解网页的层次和重点内容,从而提升网站在搜索结果中的排名。
- 无障碍支持
- 语义化标签对辅助技术(如屏幕阅读器)非常重要,它们可以帮助视障用户更好地理解网页内容。例如,使用
<nav>
标签可以让屏幕阅读器快速跳转到导航部分。 - 还记得浏览器内置的“沉浸阅读器”吗?它们也大多基于语义化标签提供服务。例如掘金的文章都是用
<article>
标签包裹的,所以你可以在掘金文章页面启用沉浸阅读器,而且精准的获取了文章的主体内容。
- 语义化标签对辅助技术(如屏幕阅读器)非常重要,它们可以帮助视障用户更好地理解网页内容。例如,使用
另外不得不说,目前苹果对语义化标签的使用是最炉火纯青的。怪不得都说苹果优雅,现在算是在前端上见识到了这个细节怪……
其实还有很多大厂都在使用,但都是偷偷地使用。它们没有全局使用语义化标签,而是在特定的关键位置使用语义化标签来 “谄媚” 一下搜索引擎或浏览器提供的无障碍功能。
所以我相信很多人还是非常支持div一把梭的,只要老板不限制,想怎么做就怎么做。不过如果你也能学习大厂,在漫天div下加一点语义化标签的小巧思,骗过搜索引擎和浏览器,这不是很香吗?
所以,本文着重介绍那些搜索引擎和浏览器有特别支持的语义化标签,搞定他们就搞定了一大半!
常用的语义化标签
搜索引擎钟爱的语义化标签
搜索引擎(如Google、Bing等)特别关注某些HTML语义化标签,因为这些标签能够帮助它们更好地理解网页的结构和内容,从而改进搜索结果的质量。
以下是一些被搜索引擎特别关注的语义化标签:
<header>
- 搜索引擎会识别
<header>
标签中的内容,通常包括页面的标题、导航链接等,有助于理解网页的整体结构和主要部分。
- 搜索引擎会识别
<nav>
<nav>
标签标示出导航链接区域,帮助搜索引擎理解网站的链接结构和页面之间的关系,有助于内部链接的优化。
<article>
<article>
标签表示独立的内容块,如新闻文章、博客帖子等。搜索引擎会特别关注这些标签,认为其包含主要的内容。
<footer>
<footer>
标签包含页脚内容,通常包括版权信息、联系信息等,搜索引擎会利用这些信息来补充网页的相关性数据。
<main>
<main>
标签标示出页面的主要内容区域,帮助搜索引擎更快地定位和抓取主要内容,而忽略导航栏、页脚等次要部分。
浏览器的无障碍功能
现代浏览器具备许多无障碍功能(accessibility features),这些功能可以帮助有特殊需求的用户更好地浏览网页。
以下是一些关键的无障碍功能:
- 屏幕阅读器支持
- 屏幕阅读器是一种软件工具,可以将网页内容转换为语音或盲文,帮助视障用户浏览网页。语义化标签可以极大地提升屏幕阅读器的效率和准确性。例如,
<nav>
标签可以让用户快速跳转到导航部分,而<article>
标签则可以帮助用户找到主要的文章内容。
- 屏幕阅读器是一种软件工具,可以将网页内容转换为语音或盲文,帮助视障用户浏览网页。语义化标签可以极大地提升屏幕阅读器的效率和准确性。例如,
- 键盘导航
- 无障碍浏览器允许用户通过键盘进行导航,语义化标签如
<header>
、<nav>
、<main>
、<footer>
等,可以帮助键盘用户快速跳转到页面的不同部分,提高浏览效率。
<header>
<h1>网站标题</h1>
</header>
<nav>
<!-- 导航内容 -->
</nav>
<main>
<h2>主要内容标题</h2>
<p>这是主要内容区域。</p>
</main>
<footer>
<p>版权所有 © 2024 公司名称</p>
</footer>
- 无障碍浏览器允许用户通过键盘进行导航,语义化标签如
- 高对比度模式
- 一些浏览器提供高对比度模式,帮助视觉有障碍的用户更容易阅读内容。使用正确的语义化标签和良好的结构,可以确保在高对比度模式下内容的可读性和可访问性。
<section>
<h2>章节标题</h2>
<article>
<h3>文章标题</h3>
<p>文章内容...</p>
</article>
<aside>
<h3>附加内容</h3>
<p>例如广告或链接...</p>
</aside>
</section>
- ARIA(可访问性富互联网应用)标签
- 虽然ARIA标签不是HTML语义化标签的一部分,但它们可以补充HTML标签,提供更多的无障碍信息。例如,
aria-label
、aria-labelledby
等属性可以为非文本元素提供文本描述,帮助辅助技术更好地解释内容。
<button aria-label="关闭">X</button>
<div role="dialog" aria-labelledby="dialogTitle" aria-describedby="dialogDescription">
<h2 id="dialogTitle">对话框标题</h2>
<p id="dialogDescription">对话框内容描述。</p>
</div>
- 虽然ARIA标签不是HTML语义化标签的一部分,但它们可以补充HTML标签,提供更多的无障碍信息。例如,
语义化标签的实际应用
为了更好地理解语义化标签的使用方法,让我们通过一个具体的案例来展示它们的实际应用。
假设我们要创建一个简单的博客页面,包含标题、导航栏、文章内容、侧边栏和页脚。下面是一个示例代码:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>我的博客</title>
<style>
body { font-family: Arial, sans-serif; }
header, nav, article, aside, footer { margin: 20px; padding: 10px; border: 1px solid #ccc; }
nav ul { list-style-type: none; padding: 0; }
nav ul li { display: inline; margin-right: 10px; }
aside { float: right; width: 30%; }
article { float: left; width: 65%; }
</style>
</head>
<body>
<header>
<h1>我的博客</h1>
<nav>
<ul>
<li><a href="#home">首页</a></li>
<li><a href="#about">关于我</a></li>
<li><a href="#contact">联系我</a></li>
</ul>
</nav>
</header>
<section>
<article>
<h2>文章标题</h2>
<p>这里是文章的正文内容。</p>
</article>
<aside>
<h2>侧边栏</h2>
<p>这里是一些附加内容,比如广告或链接。</p>
</aside>
</section>
<footer>
<p>版权所有 Dikkoo; 2024 我的博客</p>
</footer>
</body>
</html>
回顾一下
在这个案例中,我们使用了多个语义化标签来组织页面内容:
<header>
包含网站的标题和导航栏。<nav>
用于定义导航链接区域。<section>
用于分隔主要内容区域,包含文章和侧边栏。<article>
定义了独立的文章内容。<aside>
包含附加内容,如侧边栏。<footer>
包含页面的底部信息。
怎样合理运用语义化标签?
为了充分发挥HTML语义化标签的优势,以下是一些最佳实践建议:
- 规划页面结构,提前设计
- 在编写HTML之前,先绘制页面的结构图,明确各部分的功能和内容。根据设计选择合适的语义化标签,这样可以避免在编写过程中频繁修改结构。
- 保持代码简洁
- 语义化标签旨在使代码更清晰,因此应尽量避免过度嵌套标签和使用多余的标签。使用语义化标签替代大量的
<div>
和<span>
,使代码更加简洁和易读。
- 语义化标签旨在使代码更清晰,因此应尽量避免过度嵌套标签和使用多余的标签。使用语义化标签替代大量的
- 合理嵌套标签
- 语义化标签应按照其语义进行嵌套。例如,将
<nav>
放在<header>
内,表示导航是头部的一部分;将<section>
和<article>
合理地嵌套在一起,表示内容的层次结构。
- 语义化标签应按照其语义进行嵌套。例如,将
- 遵循HTML规范
- 确保使用语义化标签时符合HTML规范,不要滥用标签。例如,不要将
<header>
标签用在每个段落中,而应仅用于页面或章节的头部。
- 确保使用语义化标签时符合HTML规范,不要滥用标签。例如,不要将
来源:juejin.cn/post/7388056946121113637
怎样实现每次页面打开时都清除本页缓存?
"```markdown
每次页面加载时清除本页缓存可以通过多种方式实现,具体方法取决于需要的粒度和数据类型。以下是一些常见的技术:
使用meta标签(HTML):
<meta http-equiv=\"cache-control\" content=\"no-cache, no-store, must-revalidate\">
<meta http-equiv=\"pragma\" content=\"no-cache\">
<meta http-equiv=\"expires\" content=\"0\">
使用JavaScript:
// 清除整个页面缓存
window.location.reload(true);
// 清除特定资源的缓存
const url = 'https://example.com/style.css';
fetch(url, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
}).then(response => {
// 处理响应
});
// 清除localStorage
localStorage.clear();
// 清除sessionStorage
sessionStorage.clear();
使用HTTP头信息(服务端设置):
// Express.js 示例
app.use((req, res, next) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
next();
});
使用框架或库功能:
例如,React中可以通过key属性强制重新渲染组件来清除缓存:
function App() {
const [key, setKey] = useState(0);
const resetPage = () => {
setKey(prevKey => prevKey + 1);
};
return (
<div key={key}>
{/* 页面内容 */}
<button onClick={resetPage}>重置页面</button>
</div>
);
}
清除浏览器缓存:
用户可以手动清除浏览器缓存来达到相同的效果。这通常通过浏览器设置或开发者工具的Network面板来实现。
综上所述,实现每次页面加载时清除本页缓存可以根据具体情况选择合适的方法。无论是通过HTML标签、JavaScript代码、服务器端设置还是框架功能,都可以有效地控制和管理页面的缓存行为,确保用户获得最新和最准确的内容。
来源:juejin.cn/post/7389643363160965130
百亿补贴为什么用 H5?H5 未来会如何发展?
23 年 11 月末,拼多多市值超过了阿里。我想写一篇文章《百亿补贴为什么用 H5》,没有动笔;24 年新年,我想写一篇《新的一年,H5 会如何发展》,也没有动笔。
眼看着灵感就要烂在手里,我决定把两篇文章合为一篇,与大家分享。当然,这些分析预测只是个人观点,如果你有不同的意见,欢迎在评论区讨论交流。
拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的 H5、小程序武学。
百亿补贴为什么用 H5
我们先看两张图,在 Android 手机开发者模式下,开启显示布局边界,可以看到「百亿补贴」是一个完整大框,说明「百亿补贴」在 App 内是 H5;拷贝分享链接,在浏览器打开,可以看到资源中有 react 名字的 js 文件,说明「百亿补贴」技术栈大概率是 React。
不只是拼多多,我特地确认了,京东、淘宝的的「百亿补贴」技术栈也是 H5。
那么,为什么电商巨头会在「百亿补贴」这种重要活动上选择 H5 呢?用 H5 有什么好处呢?
H5 技术已经成熟
第一个原因,也是最基础的原因,就是 H5 技术已经成熟,能够完整地实现功能。具体来说:
浏览器兼容性不断提高
自 2008 年 HTML5 草案发布以来,截止 2024 年,HTML5 已有 16 年历史。16 年间,主流浏览器对 HTML5、CSS3 和 JavaScript 的标准语法兼容性一直持续改进,22 年微软更是亲手盖上了 IE 棺材板。虽然 Safari(iOS 浏览器)的兼容性仍然备受诟病,但总体来说兼容成本已经变得可以接受。
主流框架已经成熟
前端最主流的两大框架 Vue 和 React 已经成熟。它们的成熟体现在多个方面:
- 从时间的角度看,截止 2024 年,React 已经发布了 11 年,而 Vue 已经发布了 10 年。经过多年的发展,前端开发者已经非常熟悉 React 和 Vue,能熟练地应用它们进行开发。
- 从语法的角度看,自 React16.8 发布 Hooks,以及 Vue3 发布 Composition API 以来,两大框架语法基本稳定,不再有大的变化。前端开发者可以更加专注于业务逻辑,无需过多担心框架语法的变动。
- 从未来发展方向看,React 目前致力于推广 React Server Component 1;Vue 则在尝试着无 VDom 的 Vapor 方向,并计划利用 Rust 重写 Vite 2。这表明旧领域不再有大的颠覆,两大框架已经正寻求新的发展领域。
混合开发已经成熟
混合开发是指将原生开发(Android 和 iOS)和 Web 开发结合起来的一种技术。简而言之,它将 H5 嵌入到移动应用内部运行。近些年,业界对混合开发的优势和缺陷已经有清晰的认识,并针对其缺陷进行了相应的优化。具体来说:
- 混合开发的优势包括开发速度快、一套代码适配 Android 和 iOS,以及实现代码的热更新。这意味着程序员能更快地编写跨平台应用,及时更新应用、修复缺陷;
- 混合开发的缺陷则是性能较差、加载受限于网络。针对这个缺陷,各大 App、以及云服务商如阿里云 3 和腾讯云 4 都推出了自己的离线包方案。离线包方案可以将网页的静态资源(如 HTML、CSS、JS、图片等)缓存到本地,用户访问 H5 页面时,可以直接读取本地的离线资源,从而提升页面的加载速度。可以说,接入离线包后,H5 不再有致命缺陷。
前端基建工具已经成熟
近些年来,业界最火的技术话题之一,就是用 Rust 替代前端基建,包括:用 Rust 替代 Webpack 的 Rspack;用 Rust 替代 Babel 的 SWC;用 Rust 替代 Eslint 的 OxcLint 等等。
前端开发者对基建工具抱怨,已经从「这工具能不能用」,转变为「这工具好不好用」。这种「甜蜜的烦恼」,只有基建工具成熟后才会出现。
综上所述,浏览器的兼容性提升、主流框架的成熟、混合开发的发展和前端基建工具的完善,使 H5 完全有能力承载「百亿补贴」业务。
H5 开发成本低
前文我们已经了解到,成熟的技术让 H5 可以实现「百亿补贴」的功能。现在我们介绍另一个原因——H5 开发成本低。
「百亿补贴」需要多个 H5
「百亿补贴」的方式,是一个常住的 H5,搭配上多个流动的 H5。(「常住」和「流动」是我借鉴「常住人口」和「流动人口」造的词)
- 常住 H5 链接保持不变。站外投放的链接基本都是常住 H5 的,站内首页入口链接也是常住 H5 的,这样方便用户二次访问。
- 流动 H5 链接位于常住 H5 的不同位置,比如头图、侧边栏等。时间不同、用户不同、算法不同,流动 H5 的链接都会不同,流动 H5 可以区分用户,方便分发流量。
具体来看,拼多多至少有三个流量的分发点,第一个是可点击的头图,第二个是列表上方的活动模块,第三个是右侧浮动的侧边栏,三者可以投放不同的链接。最近就分别投放 3.8 女神节链接、新人链接和品牌链接:
「百亿补贴」需要及时更新
不难想到,每到一个节日、每换一个品牌,「百亿补贴」就需要更新一次。
有时还需要为一些品牌定制化 H5 代码。如果使用其他技术栈,排期跟进通常会比较困难,但是使用 H5 就能够快速迭代并上线。
H5 投放成本低
我们已经「百亿补贴」使用 H5 技术栈的两个原因,现在来看第三个原因——H5 适合投放。
拼多多的崛起过程中,投放到其他 App 的链接功不可没。早期它通过微信等社交平台「砍一刀」的模式,低成本地吸引了大量用户。如今,它通过投放「百亿补贴」策略留住用户。
H5 的独特之处,在于它能够灵活地在多个平台上进行投放,其他技术栈很难有这样的灵活性。即使是今天,抖音、Bilibili 和小红书等其他 App 中,「百亿补贴」的 H5 链接也随处可见。
拼多多更是将 H5 这种灵活性发挥到极致,只要你有「百亿补贴」的链接,你甚至可以在微信、飞书、支付宝等地方直接查看「百亿补贴」 H5 页面。
综上所述,能开发、能快速开发、且开发完成后能大量投放,是「百亿补贴」青睐 H5 的原因。
H5 未来会如何发展
了解「百亿补贴」选择 H5 的原因后,我们来看看电商巨头对 H5 未来发展的影响。我认为有三个影响:
H5 数量膨胀,定制化要求苛刻
C 端用户黏性相对较低,换一个 App 的成本微不足道。近年 C 端市场增长缓慢,企业重点从获取更多的新客变成留住更多的老客,很难容忍用户丢失。因此其他企业投放活动 H5 时,企业必须也投放活动 H5,电商活动 H5 就变得越来越多。
这个膨胀的趋势不仅仅存在于互联网巨头的 App 中,中小型应用也不例外,甚至像 12306、中国移动、招商银行这种工具性极强的应用也无法幸免。
随着市场的竞争加剧,定制化要求也变得越来越苛刻,目的是让消费者区分各种活动。用互联网黑话来说,就是「建立用户心智」。在可预见的未来,尽管电商活动 H5 结构基本相同,但是它们的外观将变得千差万别、极具个性。
SSR 比例增加,CSR 占据主流
在各家 H5 数量膨胀、竞争激烈的情况下,一定会有企业为提升 H5 的秒开率接入 SSR,因此 SSR 的比例会增加。
但我认为 CSR 依然会是主流,主要是因为两个原因:
- SSR 需要额外的服务器费用,包括服务器的维护、扩容等。这对于中小型公司来说是一个负担。
- SSR 对程序员技术水平要求比 CSR 更高。SSR 需要程序员考虑更多的问题,例如内存泄露。使用 CSR 在用户设备上发生内存泄露,影响有限;但是如果在服务器上发生内存泄露,则是会占用公司的服务器内存,增加额外的成本和风险。
因此,收益丰厚、技术雄厚的公司才愿意使用 SSR。
Monorepo 比例会上升,类 Shadcn UI 组件库也许会兴起
如前所述,H5 的数量膨胀,代码复用就会被着重关注。我猜测更多企业会选择 Monorepo 管理方式。所谓 Monorepo,简单来说,就是将原本应该放到多个仓库的代码放入一个仓库,让它们共享相同的版本控制。这样可以降低代码复用成本。
定制化要求苛刻,我猜测社区中类似 Shadcn UI 的 H5 组件库或许会兴起。现有的 H5 组件库样式太单一,即使是 Shadcn UI,也很难满足国内 H5 的定制化需求。然而,Shadcn UI 的基本思路——「把源码下载到项目」,是解决定制化组件难复用的问题的好思路。因此,我认为类似 Shadcn 的 H5 组件库可能会逐渐兴起。
总结
本文介绍了我认为「百亿补贴」会选用 H5 的三大原因:
- H5 技术已经成熟
- H5 开发成本低
- H5 投放成本低
以及电商巨头对 H5 产生的三个影响:
- 数量膨胀,定制化要求苛刻
- SSR 比例增加,CSR 占据主流
- Monorepo 比例增加,类 Shadcn UI 组件库也许会兴起
总而言之,H5 开发会越来越专业,对程序员要求会越来越高。至于这种情况是好是坏,仁者见仁智者见智,欢迎大家在评论区沟通交流。
拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的 H5、小程序武学。
Footnotes
来源:juejin.cn/post/7344325496983732250
前端开发中过度封装的现象与思考
前言
作为公司内的一名高级前端码喽,大大小小也封装过了不少组件和功能,我逐渐意识到封装并非全是优点,也会存在一些不可忽视的潜在劣势。
在项目中,我们急切地对各种功能和 UI 进行封装,却在不经意间忽略了封装可能带来的额外成本与潜在问题。比如,在之前的一个项目中,为了实现一个看似简单的列表展示功能,我将数据获取、渲染逻辑以及交互处理都塞进了一个繁杂的组件中。后续当需要对列表的某一特定功能进行细微调整时,由于封装的过度复杂,修改工作变得极为棘手,耗费了大量时间去梳理内部的逻辑关系。
还有一次我在对一个表单验证功能的封装时,为追求过高的通用性,添加了过多的配置选项和繁杂的验证规则。这不但增加了代码量,还使得新加入团队的成员在使用时感到困惑,理解和运用这个封装的成本大幅提高。如果让我在写标准代码和学习过度封装的组件之间做选择,我绝对毫不犹豫的选择写标准代码。
一、前端功能封装的优势
- 可以提高代码复用性
在众多项目中,常碰到类似的数据请求、表单验证等功能需求。将这些功能封装成独立的函数或模块,能极大提升代码的复用程度。例如,我们成功封装了一个通用的数据获取函数,在不同页面中仅需传入各异的参数,就能顺利获取所需数据,无需反复编写请求逻辑。 - 有效增强代码的可维护性
封装后的功能代码相对独立,当需要对功能进行修改或优化时,只需在封装的模块内操作,不会对其他使用该功能的部分产生任何影响。如此一来,代码的维护工作变得更加清晰、易于掌控。 - 大幅提升代码的可读性
通过为封装的功能赋予清晰、有意义的函数名和详尽的参数说明,其他开发者能够迅速理解其功能和使用方式,这样也极大提高团队协作的效率。写到这里我突然想起曾经在一个屎山项目中看到过的aaaa、Areyouok、jiashizheng等变量和函数名,我花了好久的时间才把它们修改正常...
二、前端功能封装的劣势
- 事极必反
有时为追求极致的封装效果,可能会对一些简单且复用频率不高的功能进行封装,这反倒会增加代码的复杂程度和理解成本。例如,一个仅仅用于计算两个数之和的简单功能,若过度封装,可能会令后续的开发者感到迷茫。
代码示例:
function add(a, b) {
return a + b;
}
// 过度封装
function complexAdd(a, b) {
if (typeof a!== 'number' || typeof b!== 'number') {
throw new Error('输入必须为数字');
}
const result = a + b;
// 一些额外的复杂逻辑
return result;
}
在一个小型项目中,仅仅为了计算两个数字的和,使用了复杂的封装函数
complexAdd
,导致新同事在理解和使用时花费了过多时间,而原本简单的add
函数就能满足需求。 - 可能隐藏底层实现细节
过度封装或许会让使用功能的开发者对其内部实现一无所知。当问题出现时,可能需要耗费更多时间去理解封装内部的逻辑,进而影响问题的排查和解决效率。
三、UI 二次封装的优势
- 成功统一风格和交互
在大型项目中,保障 UI 的一致性至关重要。通过对基础 UI 组件进行二次封装,能够明确统一的样式、交互行为和响应式规则。例如,对按钮组件进行二次封装,设定不同状态下的颜色、尺寸和点击效果。
- 显著提高开发效率
开发人员能够直接运用封装好的 UI 组件,迅速搭建页面,无需在样式和交互的调整上耗费大量时间。
- 方便后期维护和更新
当需要对 UI 进行整体风格的调整或优化时,只需修改封装的组件,所有使用该组件的页面都会自动更新,大幅减少了维护的工作量。
四、UI 二次封装的劣势
- 过度封装的危害
- 增加不必要的代码量和复杂度,致使应用的加载性能降低。例如,一个简单的输入框组件,如果过度封装了很多复杂的逻辑和样式,可能会使代码体积过大。
- 可能引入过多的抽象层次,让代码变得难以理解和调试。复杂的封装结构可能让开发者在排查 UI 问题时感到无从下手。
- 过度复杂的封装在频繁的渲染和更新操作中,可能会导致性能瓶颈,影响用户体验。
代码示例:
// 过度封装的输入框组件
class OverlyComplexInput extends React.Component {
constructor(props) {
super(props);
this.state = { value: '' };
}
handleChange = (e) => {
// 复杂的处理逻辑
this.setState({ value: e.target.value });
// 更多的额外操作
}
render() {
return (
<input
value={this.state.value}
onChange={this.handleChange}
// 过多的样式和属性设置
/>
);
}
}
在一个性能要求较高的页面中,使用了过度封装的输入框组件,导致页面加载缓慢,用户输入时出现明显的卡顿。
- 灵活性受限
过于严格的封装可能限制了开发者在特定场景下对 UI 进行个性化定制的能力。有时候,某些页面可能需要独特的样式或交互效果,而过度封装的组件无法满足这些特殊需求。 - 版本兼容性问题
当对封装的 UI 组件进行更新时,可能会与之前使用该组件的页面产生兼容性问题。新的版本可能改变了组件的行为、样式或接口,导致使用旧版本组件的页面出现显示异常或功能失效。
所以在实际的开发过程中,我们需要权衡封装带来的好处和潜在的问题。封装应该是有针对性的,基于实际的复用需求和项目的规模。同时,要保持封装的适度性,避免过度封装带来的负面影响。只有这样,才能真正提高前端开发的效率和质量。
来源:juejin.cn/post/7387731346733121551
部署完了,样式不生效差点让我这个前端仔背锅
大家好,作为今年刚毕业的大聪明(小🐂🐎),知道今年不好就业,费心费力的面试终于进了个小公司,然后我的奇妙之旅开始了。
叮!闹钟响了!牙都不刷了,直接起床出门去上班,身为🐂🐎就要有早起吃草干活的觉悟,所以我是个很合格的食草动物。只不过,下面的信息,让我一天当动物的好心情都没了。
部署?因为该公司人数比较少,也没有自动化部署操作,只能让前端build一下dist包然后丢给后端,让后端部署,然后该项目也比较急,我刚来,也不好意思@后端,恰巧跟我对接的后端也是刚来不熟悉,所以我就跟负责人继续扯皮拖延时间了。(真是不好意思,假的)↓↓↓
终于经过不断努力(后端),部署好了,但是,我的样式乱了,为什么呢?看了一下网络和源代码,样式文件都请求到了,问了后端,一致说是我前端的问题,我懵了,然后快马加鞭查找问题
解决
ok,那我先看打包的dist文件有样式有问题吗,使用npm安装个 http-server 运行打包后的index.html,运行打开运行后的地址,欧克样式没乱,文件引用也没问题,那到底是什么鬼影响的呢?
在我百思不得其解的时候,我打开了神器--csdn,搜索打包dist部署后样式文件不生效,就遇到了这个神文t.csdnimg.cn/WbQyq
欧克,按照博主说的Nginx没有配置这两个东西而导致的,我有点不确定是不是,有点犹犹豫豫的找到后端说帮我在Nginx配置文件的http加下这两个配置,然后样式就好了,完美!后端由于疏忽,没加上就把问题推给我,结果被我扳回一城。
include mime.types;
default_type application/octet-stream;
- include mime.types;
- 这个指令告诉Nginx去包含一个文件,这个文件通常包含了定义了MIME类型(Multipurpose Internet Mail Extensions)的配置。MIME类型指定了文件的内容类型,例如文本文件、图像、视频等。通过包含
mime.types
文件,Nginx可以识别不同类型的文件并正确地处理它们。 - 示例:假设
mime.types
文件中定义了.html
文件为text/html
类型,Nginx在处理请求时会根据这个定义设置正确的HTTP响应头。
- 这个指令告诉Nginx去包含一个文件,这个文件通常包含了定义了MIME类型(Multipurpose Internet Mail Extensions)的配置。MIME类型指定了文件的内容类型,例如文本文件、图像、视频等。通过包含
- default_type application/octet-stream;
- 这个指令设置了默认的MIME类型。如果Nginx无法根据文件扩展名或其他方式确定响应的MIME类型时,就会使用这个默认类型。
application/octet-stream
是一个通用的MIME类型,表示未知的二进制数据。当服务器无法识别文件类型时,会默认使用这个类型。例如,如果请求的文件没有合适的MIME类型或没有被mime.types
文件中列出,Nginx就会返回application/octet-stream
类型。- 这种设置对于确保未知文件类型的安全传输很有用,因为浏览器通常会下载这些文件而不是尝试在浏览器中打开它们。
总之,添加 include mime.types;
和 default_type application/octet-stream;
配置后,Nginx能够正确地识别和处理CSS文件的MIME类型,从而确保浏览器能够正确加载和应用CSS样式。
所以,前端仔不能只当前端仔,还是要好好学点服务端的知识,不然锅给你,你还认为这是你该背的锅。
以上是开玩笑的描述,只是为了吸引增加阅读量
来源:juejin.cn/post/7388696625689051170
axios中的那些天才代码!看完我实力大涨!
axios的两种调用方式
经常调接口的同学一定非常熟悉aixos下面的两种使用方式:
- axios(config)
// 配置式请求
axios({
method: 'post',
url: '/user/12345',
});
- axios.post(url, config)
// 简洁的写法
axios.post('/user/12345')
不知道各位大佬有没有思考过这样的问题:
axios到底是个什么东西?我们为什么可以使用这两种方式请求接口呢?axios是怎么设计的?
axios原理简析
为了搞明白上面的问题,我们先按照传统思路仿照axios源码实现一个简单的axios。
手写一个简单的axios
创建一个构造函数
function Axios(config){
this.defaults = config; // 配置对象
this.interceptors = { // 拦截器对象
request:{},
response:{}
}
}
上面的代码中,我们实现了一个基本的Axios类,但它还不具备任何功能。我们现在给它添加功能。
原型上添加方法
Axios.prototype.request = function(config){
console.log('发送Ajax 请求 type:' +config.method)
}
Axios.prototype.get = function(){
return this.request({method:'GET'})
}
Axios.prototype.post = function(){
return this.request({method: 'POST'})
}
上面的代码中,我们在request属性上创建了一个通用的接口请求方法,get和post实际都调用了request,但内部传递了不同的参数,这和axios(config)、axios.post()有异曲同工之妙。
参考aixos的用法, 现在,我们需要创建实例对象
let aixos = new Axios(config)
创建后的axios包含defaults
和interceptors
属性,其对象原型__proto__
上(指向Axios的prototype)包含request、get及post方法,因此,我们现在可以使用aixos.post()
的方法模拟调用接口了。
但注意,此时aixos只是一个实例对象,不是一个函数!我们似乎也没办法做到改造代码使用aixos(config)
的形式调用接口!
aixos是如何实现的呢?
aixos中的天才想法
为了即能使用axios(config)又能使用axios.get(),axios的核心伪逻辑如下
function Axios(config){
this.defaults = config; // 配置对象
this.interceptors = { // 拦截器对象
request:{},
response:{}
}
}
Axios.prototype.request = function(config){
console.log('发送Ajax 请求 type:' +config.method)
}
Axios.prototype.get = function(){
return this.request({method:'GET'})
}
Axios.prototype.post = function(){
return this.request({method: 'POST'})
}
function createInstance(config) {
//注意instance是函数
const instance = Axios.prototype.request;
instance.get = Axios.prototype.get
instance.post = Axios.prototype.post
return instance;
}
let axios = createInstance();
通过上述的伪代码,我们可以知道axios是createInstance()函数的返回值instance。
- instance 是一个函数,因此,axios也是一个函数,可以使用axios(config);
- instance也是一个对象(js万物皆对象),其原型上有get方法和post方法,因此,我们可以使用axios.post()。
我们看看aixos的源码
aixos的源码实现
function createInstance(config) {
//实例化一个对象
var context = new Axios(config); //但是不能直接当函数使用
var instance = Axios.prototype.request.bind(context);
//instance 是一个函数,并且可以 instance({}),
//将Axios.prototype 对象中的方法添加到instance函数中,让instance拥有get、post、request等方法属性
Object.keys(Axios.prototype).forEach(key => {
// console.log(key); //修改this指向context
instance[key] = Axios.prototype[key].bind(context);
})
//总结一下,到此instance自身即相当于Axios原型的request方法,
//然后又给instance的属性添加了上了Axios原型的request、get、post方法属性
//然后调用instance自身或instance的方法属性时,修改了this指向context这个Axios实例对象
//为instance函数对象添加属性 default 与 intercetors
Object.keys(context).forEach(key => {
instance[key] = context[key];
})
return instance;
}
可以说,上面的代码真的写的精妙绝伦啊!
注意这里,为什么要修改this的指向
var instance = Axios.prototype.request.bind(context);
首先,requset 是Axios原型对象上的方法,其方法内部的this指向的是其实例化对象context!
Axios.prototype.request = function request(config) {
/*eslint no-param-reassign:0*/
// Allow for axios('example/url'[, config]) a la fetch API
if (typeof config === 'string') {
config = arguments[1] || {};
config.url = arguments[0];
} else {
config = config || {};
}
config = mergeConfig(this.defaults, config);
// Set config.method
if (config.method) {
config.method = config.method.toLowerCase();
} else if (this.defaults.method) {
config.method = this.defaults.method.toLowerCase();
} else {
config.method = 'get';
}
// Hook up interceptors middleware
var chain = [dispatchRequest, undefined];
var promise = Promise.resolve(config);
this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
chain.unshift(interceptor.fulfilled, interceptor.rejected);
});
this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
chain.push(interceptor.fulfilled, interceptor.rejected);
});
while (chain.length) {
promise = promise.then(chain.shift(), chain.shift());
}
return promise;
};
因此,如果我们直接使用Axios.prototype.request()
就会出现问题,因为这事reques方法内部的this会指向错误,导致函数不能运行,因此,我们必须将this重新指向其实例化对象。
来源:juejin.cn/post/7387029190620184611
后端同事下班早,前端排序我来搞
写掘金博客有一小段时间了,我发现了一个秘密。文章阅读量小了吧,很心烦,阅读量大了吧,更心烦。很多特别懂特别会的大哥们就会来“指点”我。感谢这些大哥们的“指点”,使我进步。后来我和群里的小伙伴们讨论了一下,为什么掘金文章会有那么多大哥们来“开心指点”呢?大概是这么几种情况:
1 他们爱学习,爱分享,但是即将要被裁员了,所以心情不是太好,怎么办呢?去掘金上指点他们去,让他们知道自己很会很懂;
2 他们在团队中就是翘楚,指点完了团队的人,然后不放心,怕世界不足够完美,反正自己也要被裁员了,有时间,然后补偿没拿够,心情不太好,去给他们指点指点去;
3 他们一直觉得自己不太懂,但是看见文章又想指点指点,所以呢,在家找了3个月工作这段时间,一边学习,然后看大家的文章,学习过程中呢,看哪里觉得不太合适,指点指点,希望趁着阅读量高的文章,好希望有人能发现他们,直接邀约他们入职;
这种人呢,我们总结了一下,他们可以叫“黑哥会”,意思就是黑哥们,比较会,啥都会那种,嗯,希望黑哥会的成员们早日找到心仪的工作,在家闲着不好的。
好啦,在文章正式开始之前呢,告诉大家个好消息,本文点赞,友善评论,友善建议的大哥大姐们,2024年的后半年,一定能够心想事成,工作顺利,家庭和睦,一直到永久。
321... 文本正式开始。
1 未排序的数据
今天早上来了公司,我赶紧喊老张,问:新来的前端妹子这么快就被你搞定啦?听说昨晚你俩10点一起出的公司?是不是,快说。 老张,猛地抬头,问:你咋知道的?我保密工作做这么好。 我说:门口的李大爷说的。你快说说什么情况啊。
老张说:别瞎说,昨天后端下班早,把接口就给妹子了,妹子本来以为调一调接口,传几个参数完事,结果发现后端给的数据没有排序,但看了产品文档,发现,又要根据学生姓名按字母排序,又要根据分数排序,又要根据年龄排序,又要根据日期排序,直接把妹子气的快哭了,所以我就帮他弄了弄。然后就弄到10点了呗,一起出的公司而已,别瞎想。
但是妹子为了感谢我,告诉了我一个好消息,过会儿和你说。我说:你快点说。老张说:你先听我把功能说完,我再告诉你。
你看,后端就一个接口,给的数据大概是这样子:
const users = [
{"name": "小张伟", "age": 19, "score": 55, "dateTime": '2021-03-03 15:33:10'},
{"name": "张三", "age": 22, "score": 65, "dateTime": '2023-03-03 10:10:10'},
{"name": "李四", "age": 30, "score": 87, "dateTime": '2024-04-03 10:10:10'},
{"name": "阿斌", "age": 50, "score": 90, "dateTime": '2021-03-03 10:10:10'},
{"name": "曹小操", "age": 1300, "score": 23, "dateTime": '1021-05-08 10:10:10'},
{"name": "小张灰", "age": 31, "score": 15, "dateTime": '1994-03-04 08:33:10'},
];
2 根据属性排序
这是一个杂乱的json型数组,但是要根据属性进行排序。我们目前做了3种类型的实现
2.1 引入工具库
这里说一个高效便捷功能丰富的前端JS库,首先引入js-tool-big-box工具库。
执行安装命令:
npm install js-tool-big-box
引入dataBox对象,排序的这些公共方法被放到了这个对象下面:
import { dataBox } from 'js-tool-big-box';
2.2 数值型排序
数值型排序呢,就是,你看,age 和 score 都是数值型的,我们把这些归结为一类进行排序。
2.2.1 根据age从小到大的排序
代码如下:
const ageResult1 = dataBox.sortByNumber(users, 'age');
console.log('age是数值型,从小到大,排序后的值为:', ageResult1);
结果如下:
2.2.2 根据age从大到小的排序
代码如下:
const ageResult2 = dataBox.sortByNumber(users, 'age', 1);
console.log('age是数值型,从大到小,排序后的值为:', ageResult2);
结果如下:
2.2.3 根据score从低到高的排序
代码如下:
const ageResult3 = dataBox.sortByNumber(users, 'score');
console.log('score是数值型,从低到高,排序后的值为:', ageResult3);
结果如下:
2.2.4 根据score从高到低的排序
代码如下:
const ageResult4 = dataBox.sortByNumber(users, 'score', 1);
console.log('score是数值型,从大到小,排序后的值为:', ageResult4);
结果如下:
2.3 中文按字母排序
比如我们的姓名,很多时候需要按字母从A到Z来展示,这个时候就可以用下面这个方法来快速实现:
2.3.1 按字母从A到Z排序
代码如下:
const nameResult1 = dataBox.sortByletter(users, 'name');
console.log('比如name,我们按照字母顺序排序后为:', nameResult1);
结果如下:
2.3.2 按字母从Z到A排序
代码如下:
const nameResult2 = dataBox.sortByletter(users, 'name', 1);
console.log('比如name,我们按照字母顺序倒序排序后为:', nameResult2);
结果如下:
2.3.3 注意
需要注意的是,我们这里只是传入了name的属性,如果这个json中有其他中文属性,也是可以使用这个方法进行按字母排序的,很灵活。
2.4 按日期时间排序
比如我们例子中的时间,按时间排序也是非常实用且常见的需求,
2.4.1 按时间从早到晚排序
代码如下:
const timeResult1 = dataBox.sortByTime(users, 'dateTime');
console.log('以时间从早到晚排序后的值为:', timeResult1);
结果如下:
2.4.2 按时间从晚到早排序
代码如下:
const timeResult2 = dataBox.sortByTime(users, 'dateTime', 1);
console.log('以时间从晚到早排序后的值为:', timeResult2);
结果如下:
2.4.3 注意
需要注意的是,我们例子中只是传入了dateTime属性,如果json对象中有其他的是时间格式的属性值,也可以把属性传入,就可以进行字段的属性排序啦,很便捷。
3 最后
把效果展示完了,我赶紧催促老张说:你刚才跟我说的好消息呢?老张悄声说:妹子和门口老大爷,还有咱们公司老板都姓李,你品去吧。妹子跟我说了,她跟她爸爸说:这个季度的优秀就是我。我一听也跟着高兴起来,希望看到这篇文章的大哥大姐们,也都能像老张一样,升职加薪,变得越来越优秀。
最后告诉你个消息:
js-tool-big-box的npm地址是(js-tool-big-box 的npm地址)
js-tool-big-box的git仓库地址(js-tool-big-box的代码仓库地址)
来源:juejin.cn/post/7384419675073789991
因为打包太慢,我没吃上午饭
事情的起因是这样的:
鄙人呢,在公司负责一个小小的后台管理系统。
这天中午临近饭点的时候,测试小哥坐我工位旁边现场监督我改一个Bug。
Bug本身它倒是不复杂,甚至时隔几天之后我已不记清具体内容是什么。当时只见我是三下C两下V、提交、合并测试分支、登录Jekins点击deploy,一顿操作如行云流水一般丝滑。
说时迟,那时快。公司给 极品廉价劳动力们 我们安排的午饭送到了,一人一份。但你也知道,这两年经济下行,无人幸免;有的人食不果腹,有的人衣不蔽体, 保不齐谁饿的紧,拿了两份饭,那可就意味着有一个人要饿肚子,我可万万不希望那个倒霉蛋是我。
看着Jekins的deploy进度条,我对测试小哥说:
“你先回去,等会发完了你再看一下,应该没有问题,我先去干饭了”
说罢,我转头看向测试小哥:他面无表情盯着屏幕,似乎是默许了我的提议。于是我便准备起身——
只见他头也不回一手把我按住,缓缓吐出四个字:
“看完再吃”
...
...
...
大约半个小时后,KFC。
我:“我都告诉你了,不会有问题,先干饭,你非不听”
测试小哥:“......”
我:“这下好了吧,上个月的工资还没发,现在又来付费上班”
测试小哥:“我就问你,星期四的这个辣翅,它香不香”
我:“香”
罪魁祸首
所以,项目到底deploy了多久?
Jekins的记录中可以看到,编译打包环节耗时基本在五六分钟,最近几次成功构建的整体耗时平均在5分50秒。
这个项目本身呢,说大不大,说小也不算小。是个普通CRUD
页面居多的管理后台,没有太多其他乱七八糟的东西。如果以页面、组件数量的维度来看:
使用资源管理器在项目的/src
目录下通配*.vue
可以看到有561个文件
说实话,这样的体量打包5-6分钟,属实有点过分。
我又找来公司的一个巨石应用来对比:由于巨石应用历史比较悠久,横跨了多个技术栈(HandleBars模板引擎、使用jQuery的原生HTML、Vue2),不能只看SFC
的数量,所以这次就来比较一下,用来存放页面文件的文件夹的体积。
我的项目
巨石应用
先不算其他的资源,单就页面文件体积已经接近5倍,如果其他东西都算上,打包时间就算没有5倍,一两倍总是要有的吧?
结果呢,时间甚至更短
好好好,有活干了。为了避免下次面试官问我对webpack做过什么优化时复述那些网上千篇一律的答案,现在就来实际操作一下。
本文真实记录了一次对项目构建耗时、产物体积优化的过程。没有对知识点系统的梳理,主要突出的是思路:面对问题时的解决思路。
日志分析
曾经有位技术能力超强的架构师说过:遇到问题不要慌,先看日志。
既然这么慢的构建过程是发生在Jekins上,那就先来捋一捋Jekins的log,有没有什么值得注意的地方。
这个项目的构建脚本中,抛开(Jekins)工具的准备、从git上拉取代码以及最后的部署这些动作,只看跟前端的打包有关的部分,命令很简单,只有四行:
rm -rf node_modules
rm package-lock.json
npm i
npm run build
在日志中体现如下图:
开局就是一记暴击!
11:22:08 + npm i
11:25:59 + added 1863 packages from 1199 contributors balabala...
...
...
11:25:59 + npm run build
...
...
合着这五六分钟的打包时间,安装依赖就占了一大半,阿西巴!
在继续往下进行之前,请允许我先介绍些项目的其他背景:
deploy脚本拉取代码这一步简单来说就是:cd进项目目录 -> git pull -> 切换至要构建的分支
项目的开发人员较少,算我在内三个人
项目的依赖变动频率十分低,以月或数月为单位
背景铺垫完了,开始研究npm i
为什么这么耗时,相关的命令有三句:
rm -rf node_modules
rm package-lock.json
npm i
其中npm i
这句是必须的,没什么好说的;rm -rf node_modules
和rm package-lock.json
这两句是变量,挨个做耗时的对比测试。
首先,我在本地使用跟Jekins上相同的node版本(14.16.1),使用相同的npm源(官方源),新建一个目录clone项目代码:
- 完整执行三行命令,耗时与Jekins上相差无几
- (此时已经有了
package-lock.json
文件)执行rm -rf node_modules
+npm i
,耗时极短 - (此时已经有了
node_modules
目录)执行rm package-lock.json
+npm i
,耗时也极短
第三步其实没什么意义,npm文档中有提到,这种情况就相当于梳理了node_modules的结构并生成了package-lock.json
,并没有安装任何东西。
而步骤一和步骤二之间为何耗时相差比较大,可以参考这篇我觉得npm install流程写的比较好的文章:简单来说就是花费了大量时间去远端获取包信息。
结合项目背景一,我们的package-lock.json
会提交到仓库,每次肯定都是最新的,所以Jekins deploy脚本中rm package-lock.json
这一步属实是没有必要,本地计算完了到Jekins上又来一遍,纯纯的浪费时间。
联系运维哥把测试环境的deploy脚本修改一下,去掉了rm package-lock.json
这一句,测试下耗时:
如图中下边两次构建,【编译打包】环节时间都去到了2分30秒左右,直接缩短了一半多。部署成功后,在测试环境的页面咔咔一顿点,似乎也没有什么依赖包引起的报错。
效果是不是很显著,你以为这就完了?不不不,图中那条49秒的构建我可不是大意截进去的,伪装成不经意的失误,就是为了丝滑的承上启下
既然这个项目的开发人员很少,而且依赖的添加/更新频率又极低,也就意味着每次npm i
所安装的东西,基本都是一样的,既然都一样,为什么我还要rm -rf node_modules
再安装?
想象一下你日常本地开发时,如果某次需求要用到一个新的npm包,你一定是先npm install xxx
,除非碰到了依赖冲突,否则不会清除node_moduels
重新安装。
明明npm
提供了梳理依赖树、只做局部更新的逻辑,我们却偏偏每次清除node_modules
再install
,这种行为吧,我感觉就像明明是个Vue
项目,却在里边到处使用Document API
。
哎嘿,我就不用你的响应式,就是玩~
冒着被打的风险又私聊了运维哥,把rm -rf node_modules
去掉,再发布了一次看看效果
优秀!打包时间从5分多直接干到了50秒,优化率80%+!
本文结束!
在正式结束前,觉得还是有必要补充两点
- 各位读者在做打包优化时,部署脚本是否清除
package-lock.json
和node_modules
还是主要取决于项目实际情况和团队协作模式,不能因为一味的追求构建速度而导致频繁的构建失败/安装依赖失败[滑稽]
- 如果您经过深思熟虑后觉得还是有必要清除
package-lock.json
和node_modules
,山人还有一计可供大王优化构建速度:打包时离不开babel,但babel又是个老大难,好在它能缓存转换结果。一般情况下缓存会放在项目目录下的/node_modules/.cache/
,那我们把删除node_modules
的命令稍微改那么亿点:
find node_modules/ -mindepth 1 -maxdepth 1 ! -name '.cache' -exec rm -rf {} +
删除
node_modules
里面除了/.cache
目录以外的其他内容,这样在构建过程中babel还是能使用到之前的缓存。那速度,体验过的都说好!(看babel-loader的缓存文件有多大)
全面升级
如果是本着以后不影响吃午饭的目的,那现在确实可以结束了。但我自幼便深受中国四大名句之一来都来了的兄弟句式——干都干了的文化熏陶:既然已经开始了,那就干脆给项目做个全套大保健!
不过此时我和在座的各位都一样,对打包优化这块着实没什么经验,可以说是毫无头绪。
浅浅百度了下webpack打包优化,有两个工具基本每篇文章都有提到:打包耗时分析speed-measure-webpack-plugin
、打包体积分析webpack-bundle-analyzer
(vue-cli内置)
目前的痛点是慢,那就先来个耗时分析试试水。
使用方法还是老样子,自己去查,别人都写的我就不再重复写了。
效果如图:
此时因为babel
和eslint
还没有缓存,耗时多是意料之中的;其他的loader或多或少的三两组合,展示了一个module计数和耗时小计,我从中并没有办法获得什么有用的信息;并且多次构建对比发现loader组合的规律和耗时的排名也无迹可寻。在我的认知里:所有被命中的文件会按照loader
配置的顺序依次处理,所以面对这样的结果我实在是无计可施。(有会看的朋友可以补充一下)
翻看speed-measure-webpack-plugin
的文档,发现有可以打印耗时top N文件的配置项,但开启后再次构建得到的这些文件,同样令我摸不着头脑:一个寥寥数十行的SFC
小组件,css-loader
耗时竟然能用四到五秒!要知道里边只有一条scoped
的样式规则。
无奈只好放弃,看了下项目用的是vue-cli@4.x
创建的,对应的webpack@4.x
,那就去webpack
的文档里逛逛碰碰运气吧!
可惜,福无双至祸不单行。文档里翻了半天,耳熟能详、配置简单的路子,例如babel-loader、eslint-loader的编译缓存
、多线程打包
、chunk分割
、代码混淆压缩
、tree shaking
这些,要么是之前已经被配置过了,要么是webpack
内置了。而复杂、高级一些的优化方式,我的项目又用不到...
直到我看到了这里:
升级webpack
简单(呸),npm upgrade webpack
嘛,先来搞这个~~
回到项目的package.json
里,咦,好奇怪,没有webpack
,也没有vue-cli
。
vue-cli
是装在全局的,而webpack
是作为依赖的依赖安装的,没有体现在package.json
中,所以直接npm upgrade webpack
应该是不行的。vue-cli
的文档提供了一个升级的命令:vue upgrade
既然要升级,干脆全上新的!Node
也给他干到20!(我也不知道我当时为什么要这么做,但这为后来的事情埋下了伏笔。。。)
vue-cli
升级完,扫了一遍webpack升级指南,发现我项目里的配置文件也没什么好改的,Nice!
本地浅浅的run了一下server
、run了一下build
,发现也都OK!那就提交上去在Jekins上试试Node V14
o不ok
emmmm...
报错倒是没报错,只是...
本地build
的时候没注意,Jekins上跑才发现,怎么慢了这么多!说好的更新到最新版本均有助于提高性能呢?
再看看这构建物的体积
Hà的我赶紧又本地build
了一次,还真让我发现了些东西:似乎build
了两次
按理来说应该只有下边这个print,那上边的legacy bundle
又是什么东西?百度上随便那么一搜,应该是不少人都被这么坑过,很容易搜到:这是一种兼容性的构建产物,主要是为了兼容一些很古老版本的浏览器/客户端。想控制也很容易,改package.json
里的browserslist
字段即可。
这就好办了,这项目是我们公司的内部项目,考虑兼容性?不存在的。
{
"browserslist": [
"> 1%",
"last 2 versions",
"not dead",
"not ie 11"
]
}
配置了之后又试了下,基本恢复到了升级webpack
之前的水平,但还是慢一点点...
构建速度的优化这块,实在是没头绪了,明明升级了webpack
版本,构建速度却变慢了。
不过刚才提到的两个工具,构建耗时分析的用过了,还有个vue-cli
内置的构建体积分析工具没体验;如果需要打包的东西变少了,那构建速度应该也能快一点吧!(吧?)
塑形瘦身
在正式瘦身前,有一个小插曲:
不知道在座的各位,项目里有没有这样的东西
console.log(123123)
// or
console.log('asdfasdf')
我是一个崇尚极简的人,我能接受的底线也就是
console.log('list data: ', data);
仅此而已
你要打印接口返回数据,Network
里能看
你要打印函数中某个变量的值,可以打断点
我实在是想不出什么必须console.log
的场景
如果你说为了方便线上调试
我能接受的最多也就是按规范打印有意义的log
更别提项目首屏就要翻好几页的无意义log,要知道,大量的console.log
也是会影响首屏加载性能的
在之前,我通过husky + lint-staged
进行过限制,但还是有人以我这个有用、这之前不是我写的等等诸多借口绕过了eslint
检测,提交了无意义的log。所以这次我最终还是决定,你不仁就休怪我不义,TerserPlugin
drop_console
走起,本地开发你随便log,只要发到线上我就删掉。
{
plugins: [
new TerserPlugin({
terserOptions: {
compress: {
drop_console: true,
pure_funcs: ["console.log", "console.info"],
},
},
})
]
}
毕竟删掉几句console.log
,也算瘦身
接着就webpack-bundle-analyzer
走起,vue-cli
内置的使用方式是
vue-cli-service build --report
打包后会在你输出的目录里边生成一个report.html
,当时的截图找不到了,用语言描述一下就是:从node_moduels
里打进去的依赖包,面积直接占了整个屏幕的大概三分之一。那个图网上很容易找到,内容就是打包产物按照体积和来源绘制成一个个的矩形在页面里。
这其实是好事,打进去的依赖包多,我们的可操作空间就大,先拿Vue
开个刀。
// vue.config.js
module.exports = {
// ...
configureWebpack: {
externals: {
vue: "Vue",
},
},
// ...
}
也不要忘了把package.json
里的vue
依赖删除掉、在/public
的模版HTML
中,通过<script>
引入CDN文件。
再打个包看看效果:
可以看到vue
确实咩有了
但在调试的过程中,发现第三方CDN不稳定,时而获取超时
为了保险起见,只得把CDN文件copy到本地/public
里来(我司没有自己的CDN或者依赖私仓,正在筹备中)
暂时没什么问题了,下一个就是我们的UI组件库ant-design-vue@1.7.8
,按照相同的方式配置一下,不过这次运行后有报错了:
可以看到报错是和moment
有关系,在antd的文档也找到了原因:如果使用已经构建好的文件,要自行引入moment
为什么antdv不做按需引入?原因有二:
- 项目的入口
main.js
中全量导入了antdv
进行注册,页面中直接使用。如果要改成按需引入,要么每个页面里新增按需引入的语句,要么统计使用了哪些组件在main.js
里改为按需引入(似乎有plugin解决这个问题,记不太清了)
- 按需的这个需,基本等于全量了。。。粗略的扫了一下文档,除了像
Comment
和Mentions
这种带有互动性质的组件,其他的基本都用上了,所以改按需好像意义也不大
改moment
的时候国际化有一个小问题,CDN网站一般会提供以下几种文件:
- 无国际化的
moment
主体文件 - 带全部语言包的
moment
主体文件 - 单个语言包文件(无功能)
如果没有国际化的需求,那是万万没有必要引入全部语言包的moment
。但moment
默认是英语,至少需要引入一个中文语言包。碰巧antdv
也需要做国际化处理,是相同的问题。
moment
和antdv
的国际化方式很相似:
<a-config-provider :locale="antdLocale" />
moment.locale(momentLocale);
data() {
return {
antdLocale,
momentLocale
}
}
我们只需要知道这个locale
运行时的值,把它提取出来就行了。打印后发现其实就是个很简单的key-value
对象(不是JSON
),在node_modules
中的源码里找到 它们复制出来在/public
下新建zhCN
文件:
window.momentLocale = xxx /* 复制出来的对象 */
window.antdLocale = xxx /* 复制出来的对象 */
使用时:
<a-config-provider :locale="antdLocale" />
moment.locale(momentLocale);
data() {
return {
antdLocale: window.antdLocale,
momentLocale: window.momentLocale
}
}
以后如果有别的依赖也有类似的国际化需求,继续向zhCN.js
里添加就行。只需要新增一个http
请求,就解决了所有依赖的国际化问题。
剔除了antdv
和moment
之后的report.html
:
惊喜的发现,antdv
的icons
也被一起干掉了。
少了这么几个大家伙,此时必须要Jekins上build一波看看效果!
还记得之前把Node
给升到20了吗
于是就...报错了...Node
版本太低...
本地切回NodeV14
,发现连server
也起不来了。。
摸黑前行
预警:这将是一段枯燥且艰难的黑暗时光
搞过的都知道,处理Node
版本兼容问题时,如果是需要升级还好;如果是要降级,Node
内置的各种包会出现稀奇古怪的报错,而且这些报错还难以trace
...
由于这趴的问题实在过于稀奇,甚至在google上都搜不到有用的信息,所以基本都没有截图,但我会尽可能的描述出我对问题的看法。看文字也许你觉得云淡风轻解决起来很轻松,但实际上花费了我接近一整天的时间以及一撮撮掉落的头发...
1. npm run server
出现大量的.vue
单文件报错
具体的报错信息记不太清,但报错顺序与路由表注册的顺序相符(和动态路由懒加载是两码事,路由懒加载是在运行时访问到页面才会加载对应的chunk
,但编译打包时,只要是代码中webpack
能trace
到的文件,都会被处理)。目测是所有的.vue
都有报错,那问题就应该不是出在代码上,而是整体配置上。
翻看vue-loader
文档时看到了这个
升级vue-cli
时确实也升级了vue-loader
,按照指引配置了下,resolve
2. jsx语法报错
这个问题就有点奇怪了,在升级前,是没有给webpack
做过什么支持jsx
语法的配置的。升级后,却都报错了。
翻阅了一些资料和支持jsx
的解决方案,大部分都是说把SFC
的<script>
加上lang="jsx"
,里边的内容全部当作jsx
解析。这种方式对eslint
和babel
的配置改动比较大,曾数次尝试无法成功,最后都把所有配置还原重新开始。
后来灵光一闪,不如直接用刚更新的vue-cli
创建一个新项目,看是否支持,如果可行的话,直接把各个配置文件照搬即可。
结果还真可以。babel.config.js
、vue.config.js
以及package.json
里eslintConfig
字段,先全部按照官方脚手架的配置改掉,成功启动之后再挨个把我们自定义的配置添加回去。这过程当中没有出现什么问题,且按下不表,resolve
3. 启动之后,页面白屏报错:(0 , vue__WEBPACK_IMPORTED_MODULE_0__.resolveComponent) is not a function
其中resolveComponent
也有可能是其他一些Vue3
暴露出来的Api,通过打断点观察,推测是Vue
内部在初始化的时候出了问题。
不确定是哪里出了问题,但在把之前删除的Vue
依赖安装回来(只是开发环境会用到,打包不会打进去)以及把添加的VueLoaderPlugin
去掉以后,resolve
迎接黎明
以上这些问题解决以后,已经可以正常启动、打包项目了。但刚才的bundle analyzer
进行到一半还没结束,图中的第三方依赖库应该还有一些可以剔除掉的,比如隐藏在一个业务代码chunk
里的echarts
检索了代码后,发现有按需引入的:
import {xxx, xxx} from "echarts"
也有全量引入的:
import * as echarts from 'echarts';
在分析代码后整理了所使用到的echarts
Api和组件,把全量引入改为按需引入,重新打包后发现包体积没有变。我好奇难道echarts
只要有一个地方使用了按需引入,其他地方也能自动分析把全量引入改为按需引入?遂把按需引入的也反向改为全量引入重新打包,结果:
第二次的改动体积变化了,那就只能说明....
问了写那段代码的同事,果然,在需求迭代的过程当中技术方案变更了,所以那个文件废弃掉没有用了,改了个寂寞...
此时还剩下jquery
和lodash
计划剔除掉,其他的依赖包有一部分已经是比较规范的按需引入,剔除掉改为cdn引入带来的收益不大,当然主要的原因还是因为懒
jquery
:这个npm包有点意思
打进来的是非压缩版本,因为package.json
中设置的main
就确实是这个,但dist
包中明明提供了压缩后的版本。两个版本的体积差距在三倍多,不知包作者的意图是什么
但最后还是把jquery
这个依赖彻底放弃了:整个项目中只有一个远古时期添加的图片预览组件依赖了它,而我们现在开发了样式、功能更为强大的新组件,所以把所有使用到这个组件的地方都改为使用新组件,然后顺带把jquery uninstall
了。
lodash
:官网本身提供了可按需引入的版本lodash-es
,但项目中太多地方都是全量引入的方式在使用
import * as _ from "lodash"
暂且先改成CDN的方式全量引入
至此,bundle analyzer
的分析图变成了这样:
三方依赖的chunk
已经比包含了echarts
的那个业务代码chunk
体积还要小。瘦身瘦到这里感觉差不多了,那些更小的依赖包本身体积不大,换成一个http
请求也未必是一件划算的事。
然后就还是回到webpack
的配置上来,前边一直在琢磨怎么添加配置去做优化,但vue-cli
本身已经封装了一套久经考验的配置,不如从这个配置着手,看能否针对我们项目的实际情况做一些修改
获取配置命令(融合了自定义的配置)
vue inspect --mode=production > file-name.js
mode
不传的话默认是development
。下载下来打开,1400多行猛的一看似乎有点唬人,但实际上有1000行左右都是对样式文件的loader
配置。
粗略的看下vue-cli@5.0.8
中有哪些值得注意的配置
- 解析文件的优先级
// 导入模块时如果不提供文件后缀,同名文件 后缀名的优先级
extensions: [".mjs", ".js", ".jsx", ".vue", ".json", ".wasm"]
- Hash
optimization: {
realContentHash: false, // 使用非严格的hash计算,减少耗时
}
- 代码压缩:
css
使用的是CssMinimizerPlugin
,js
使用的是TerserPlugin
minimizer: [
// 已经内置了js压缩工具terser
new TerserPlugin({
terserOptions: {
compress: {
arrows: false,
collapse_vars: false,
comparisons: false,
computed_props: false,
hoist_funs: false,
hoist_props: false,
hoist_vars: false,
inline: false,
loops: false,
negate_iife: false,
properties: false,
reduce_funcs: false,
reduce_vars: false,
switches: false,
toplevel: false,
typeofs: false,
booleans: true,
if_return: true,
sequences: true,
unused: true,
conditionals: true,
dead_code: true,
evaluate: true,
},
mangle: {
safari10: true, // 代码混淆时兼容使用`let`关键字声明的循环迭代器变量可能会出现无法重复声明let变量的错误。
},
},
parallel: true, // 多进程打包
extractComments: false, // 不将注释单独提取到一个文件中
}),
new CssMinimizerPlugin({
parallel: true,
minimizerOptions: {
preset: [
"default",
{
mergeLonghand: false,
cssDeclarationSorter: false,
},
],
},
}),
]
- Loader
- 大量的篇幅编排不同样式文件相关的
Loader
,分别有css
、postcss
、scss
、sass
、less
、stylus
,按照css moduels in SFC
->SFC style
->normal css modules
->normal css
的顺序依次处理。 - 对于脚本文件,已经开启了多线程转译以及babel缓存功能
- 大量的篇幅编排不同样式文件相关的
{
test: /\.m?jsx?$/,
exclude: [
function () {
/* omitted long function */
},
],
use: [
{
loader:
"path-to-your-project/node_modules/thread-loader/dist/cjs.js",
},
{
loader:
"path-to-your-project/node_modules/babel-loader/lib/index.js",
options: {
cacheCompression: false,
cacheDirectory:
"path-to-your-project/node_modules/.cache/babel-loader",
cacheIdentifier: "1d489a9c",
},
},
],
}
- Plugin
VueLoaderPlugin
:已经内置了DefinePlugin
:注入编译时的全局配置CaseSensitivePathsPlugin
:路径的大小写严格匹配FriendlyErrorsWebpackPlugin
:优化报错信息MiniCssExtractPlugin
HtmlWebpackPlugin
CopyPlugin
:配置了info.minimized = true
,copy的同时也会压缩ESLintWebpackPlugin
:同样开启了缓存
得,不仅没找到有啥可优化的地方,甚至还污染了人自带的配置:
已经内置了TerserPlugin
,前边为了打包时去除console
在plugin
里边又配置了一次,通过speed-measure-webpack-plugin
分析时发现似乎是走了两遍TerserPlugin
。
只好通过webpack-chain
去注入一下,顺便把项目中其他修改webpack
配置的地方也改为注入的形式。(使用ConfigureWebpack
去改,无法改到已有的TerserPlugin
配置):
chainWebpack: (config) => {
config.when(process.env.NODE_ENV === "production", (config) => {
config.devtool(false);
config.optimization.minimizer("terser").tap((args) => {
const compress = args[0].terserOptions.compress;
args[0].terserOptions.compress = {
...compress,
drop_console: true,
pure_funcs: ["console.log", "console.info"],
};
return args;
});
});
config
.externals({
vue: "Vue",
moment: "moment",
"moment/locale/zh-cn": "moment.local",
"ant-design-vue": "antd",
lodash: "_",
})
.resolve.alias
.set("@", path.join(__dirname, "src"))
.set("@worker", path.resolve(__dirname, "public/worker.js"))
.end();
config.plugin("speed-measure").use(SpeedMeasurePlugin);
}
如果使用ConfigureWebpack
:
configureWebpack: {
minimizer: [
new TerserPlugin({
terserOptions: {
compress: {
drop_console: true,
pure_funcs: ["console.log", "console.info"],
},
},
}),
],
},
集成的配置最下方会出现一个新的minimizer
数组,不是我们想要的效果
截止到目前,构建速度变成这样(果然还是没有变更快)
从项目剔出去的第三方依赖,体积是这么多
不过虽然没打进chunk里去,但还是作为静态依赖在构建产物中,下一步就是搭一个公司内部的简易缓存服务器(有关缓存的内容可以看我上一篇文章)。届时这部分体积才算真正从项目里移除了,不过此时我们还是可以把它视作优化的成果,由于没有对别的类型的资源做什么优化处理,也压根就没什么别的资源,所以只看打包后的js体积的话:
优化前
优化后
数据也基本对的上,所以综合来看:
- 平均构建速度(average full time):从5分50秒减少到54秒,优化率84.48%(
1 - 54秒 / 3分50秒
) - 脚本构建体积(script size):从5.5M减少到3.6M,优化率35.55%(
1 - 3.6M / 5.5M
)
先这样吧,至少下次被问到webpack
,多少有点自己的东西能讲,近期可能也不会再更新文章了,原因嘛,你们懂的
欢迎真诚交流,但如果你来抬杠?阿,对对对~ 你说的都对~
来源:juejin.cn/post/7389044903940603945
无插件实现一个好看的甘特图
效果
预览地址 code.juejin.cn/pen/7272286…
前言
刚好看到这么一个东西,甘特图,然后又发现好像echarts 里面没有这个图形渲染,也去找了一下插件,都不符合我的要求,然后自己就想着看能不能实现一个,然后说干就干,过程还挺复杂的,不过一想清楚了,也就还行。
逻辑
刚开始看着这个图,想到肯定是用表格来实现的,后面开始渲染的时候,发现是我想简单了,这表格是渲染不出来的,也或者是我技术还没够,反正我找了很多相关的插件,都不是用表格去实现的,后面就改变了一些思路,用div去渲染,然后去定位,这么一想,发现事情就简单了许多。
为什么不用表格实现
每点击一个视图切换,表头就需要重新渲染,表头有索引,名称,负责人,如果是表格实现的话,就是如下代码,一看就知道了id=“tableYear”的不好渲染,因为上面有可能是年,也有可能是月,所以肯定是有循环的,但如果一循环,它们没有共同的父容器,怎么渲染呢,也想过表格里面套表格,但那样,样式又实现不了我要的效果。
<table>
<thead>
<tr>
<th rowspan="2">id</th>
<th rowspan="2">任务名称</th>
<th rowspan="2">负责人</th>
<th colspan="4" id="tableYear">2023-8</th>
</tr>
<tr id="tableDay">
<th>1</th>
<th>2</th>
<th>3</th>
<th>4</th>
</tr>
</thead>
</table>
第一个难点
日期渲染,因为我这表头是动态的,所以要复杂一些,有天数,月数,季度和年度显示的
点击日视图,就是按天去显示任务,月视图就是按月去显示任务,季视图和年视图同理,
比如说日视图,
先获取当前日期,年月日,然后是想渲染前后几年的
var currentDate=new Date;//当前日期
var currentYear = currentDate.getFullYear();//当前年份
var yearRange = 1; // 前后1年
var startDate = currentYear - yearRange;//前1年
var endDate = currentYear + yearRange;//后1年
var today = currentDate.getDate(); // 获取今天是几号
var currentYear = currentDate.getFullYear();//年
var currentMonth = currentDate.getMonth();//月
var displayedYears = {}; // 用于记录已显示的年份
开始渲染第一排是年月,一年的月份是固定的,所以都是可以写死的,这里有要注意一下的是,就是年月的宽度,因为要根据当月有多少天去计算宽度,所以我要知道,这一年这一月是多少天然后乘以40 下面是相关代码
for (var year = startDate; year <= endDate; year++) {
for (var month = 0; month < 12; month++) {
var lastDay = new Date(year, month + 1, 0).getDate();
var monthElement = $("<p>" + (month + 1) + "月: </p>"); // 创建表示月份的 <p> 元素
for (var day = 1; day <= lastDay; day++) {
dateRange.push(new Date(year, month, day));
}
// 在 .tableYear 中添加年份和月份信息
var yearMonthStr = year + "-" + (month + 1 < 10 ? "0" : "") + (month + 1);
var width = (lastDay * 40)-1 + "px"; // 计算宽度
$(".tableYear").append($("<p class='Gantt-table_header' style='width: " + width + "'>" + yearMonthStr + "</p>"));
}
}
渲染完成,年月以后就是日,我这里也做了一些小的显示,比如说周末深颜色表示,今天也深颜色表示,并且视图要显示在当前,而不是在2022-1-1号这里。
天数渲染
for (var i = 0; i < dateRange.length; i++) {
var currentDate = dateRange[i];
var dayNumber = currentDate.getDay(); // 获取星期几 (0 = 星期日, 1 = 星期一, ...)
var isWeekend = dayNumber === 0 || dayNumber === 6;
var dayText = currentDate.getDate();//获取日
var dayYear = currentDate.getFullYear(); // 获取年份
var dayMonth = currentDate.getMonth(); // 获取月份(注意:月份是从 0 到 11,0 表示一月,11 表示十二月)
var tableCell = $("<p>" + dayText + "</p>");
if (isWeekend) {
tableCell = $("<p class='Gantt-table_weekend'>" + dayText + "</p>");
}
//获取当前时间的年月日,与循环出来的年月日进行循环匹配,
if (dayText === today && dayYear === currentYear && dayMonth === currentMonth ) {
tableCell.addClass("today");
}
$(".tableDay").append(tableCell);
}
视口显示代码
// 将视口滚动到今天所在的位置
var todayElement = $(".today");
if (todayElement.length > 0) {
var viewportHeight = window.innerHeight || document.documentElement.clientHeight;
var elementHeight = todayElement[0].clientHeight;
var offset = (viewportHeight - elementHeight) / 2;
//平滑滚动参数 smooth auto不滚动
todayElement[0].scrollIntoView({ behavior: 'auto', block: 'center', inline: 'center' });
}
第二个难点
如果不用表格,怎么去实现这个网格呢,刚开始是想着去渲染,表格对应有多少天,就渲染有多少个p标签,但一想这怎么行,如果任务很多,一条任务就要渲染上千个p标签,太浪费资源了,任务一多,那不直接挂壁。
后面在css里面找到了解决办法
background: repeating-linear-gradient(to right, rgb(221, 221, 221), rgb(221, 221, 221)
1px, transparent 1px, transparent 40px) 0% 0% / 40px 100%;
ChatGPT是这样解释的
然后完美解决问题,不用渲染这么多,一个任务一个div就可以了。
第三个难点
甘特图的核心,那个柱状图的东西。
柱状图渲染,比如说我提了一个任务,是2023年8月20号开始的,然后到2023年8月25号要完成,那这样就只有五天时间,那渲染肯定是从2023年8月20号开始的,然后到8月25号结束。
我的思路是这样的,显示的宽度是五天,然后一天的宽度是40px,那么这个任务的总宽度就是200px,然后定位,获取到这个任务开始的时候,我这里显示是前后一年的也就是2022-1-1号开始的,然后相减,知道中间相差了多少天,然后再乘以40,得到left的距离,就实现了这个效果。后面的描述也是类似的效果,这样甘特图差不多就完成了。下面是日期相减代码
function getOffsetDays(startDate, endDate){
var startDateArr = startDate.split("-");
var checkStartDate = new Date();
checkStartDate.setFullYear(startDateArr[0], startDateArr[1], startDateArr[2]);
var endDateArr = endDate.split("-");
var checkEndDate = new Date();
checkEndDate.setFullYear(endDateArr[0], endDateArr[1], endDateArr[2]);
var days = (checkEndDate.getTime() - checkStartDate.getTime())/ 3600000 / 24;
if(startDateArr[0]!=endDateArr[0]){
flag = true;
}
return days;
}
结语
虽然三言两语就讲解完了,但其中还是有很多逻辑的问题,我只是讲解了一下我的一个实现的思路,居体代码请看这。
以上就是本篇文章的全部内容,希望对大家的学习有所帮助,以上就是关于无插件实现一个好看的甘特图的详细介绍,如果有什么问题,也希望各位大佬们能提出宝贵的意见。当然也有很多需要优化的地方,我这只是给了一个思路,你们可以去实现很多的功能。
来源:juejin.cn/post/7272174836336132132
一次低端机 WebView 白屏的兼容之路
问题
项目:Vite4 + Vue3,APP WebView 项目
页面在 OPPO A5 手机上打不开,页面空白。
最开始是客户端在看,然后发现一个警告,大概也因为没看出什么问题,给到 Web 前端。
相关背景
为了方便描述过程的行为,先做一些相关背景的介绍。知道这些背景才能更好的了解问题的复杂。这些在解决问题的过程中始终是干扰因素,在反复调试试错的过程中才梳理总结出来,这里把它们列出来。
使用测试 App,其中有两个入口,一个是本地调试,这个地址是写在 App 里的,也就是要修改这个地址需要客户端重新出包;一个是项目的测试地址,这个地址测试可以进行配置。
修改客户端,重新出包,是很麻烦的,所以尽量避免。
项目配置了 HTTPS 支持,所以开发地址是 https 开头。但是也能启动 http 的地址。
关于项目支持 HTTPS,可以参考之前写的这篇:juejin.cn/post/732783…。
之所以要支持 HTTPS 是因为 iOS WebView 只支持 HTTPS 的地址。
而安卓 App WebView 却需要 HTTP 打开,原因是安卓 WebView 反馈不支持本地地址 HTTPS 的方式。但是在本机上用 MuMu 模拟器打开 App,是能打开本地 HTTPS 地址的,之前也尝试过给安卓手机安装根证书,但是还是不行,得到以上反馈。
所以我本地开发安卓在电脑 MuMu 上调试,iOS 可以用手机调试,如果要安卓真机本地调试,需要去掉本地 HTTPS 的支持,使用 HTTP 的地址(自然,iOS 本地调试就不能同时进行了)
快速尝试
拿到问题之后,快速进行问题验证,在 OPPO A5 上,进入 APP 中,打开本地调试,用 HTTP 的方式。发现确实白屏,查看了客户端相关的日志,发现一个警告:
[INFO:CONSOLE(9)] "The key "viewport-fit" is not recognized and ignored.", source: xxx
于是修改 viewport-fit,发现并没有区别,这只是一个提示,应该没有影响。
于是采用最简代码法,排除法,用最简单的页面进行测试,看是否能正常打开,确定 WebView 没有问题。直到确定 script type="module"
引入的 main.ts
的代码没有起作用。
于是,基本上确定 Vite 的开发模式在 OPPO A5 WebView 中有问题。那不支持 ESM,就是兼容性问题?
快速查看解决方案,引入官方插件:@vitejs/plugin-legacy。但是怎么测试呢?要验证兼容性是否生效,只能验证打包构建后的代码,而不是通过本地调试进行测试。那只能发布到测试了,但这样岂不是要改一点就要发布一次,这是没办法进行的。但是第一次,还是发布一下看有没有生效。
不出意料,没那么容易解决!测试地址依然白屏。
如何调试
确定如何方便的调试是解决问题的必要条件。
几天后又开始看这个问题。
浏览器是否能打开页面?
首先在 App 中进行调试是比较麻烦的,需求启动 App,那么能否在浏览器中进行测试呢?很遗憾,期间用手机系统浏览器打开测试地址是正常的,后面打开本地地址也是正常的。所以浏览器和 App WebView 是有区别的。
启动本地服务查看构建后的页面
兼容插件只是解决打包后的构建产物,想要看打包后的效果,于是我想到将打包后的文件起一个 Web 服务,这样就可以打开打包后的页面 index.html,而且手机访问同一网络,扫码就可以打开这个页面。
找了 Chrome 插件 Web Server for Chrome,发现已经不能用了- 找了 VS code 插件 Live Server,服务启了,但是有个报错。
- 换用 http-server,启动服务 xxx:8080。正常打开页面,手机也能访问。
那么考虑我们的实际问题,如何在手机调试呢?将本地调试改成本地起的服务 xxx:8080,看 WebView 能否打开,这样每次修改、打包,生成新的打包后文件,刷新 WebView 就可以了。
但是前面说了,找 APP 出包很麻烦,改一个地址要出个包,费时。还有其他的办法吗?如果把本地启的服务端口改成 5173 不就不用改 App 了吗,可以直接用本地调试来进行测试,突然又想到本地调试的地址是 HTTPS,可是启动的本地服务好像没法改成 HTTPS。
通过测试地址增加本地调试入口
又想到 App 中的本地调试入口本应该做成一个公共页面,里面放上很多可能的入口。这样只需要 APP 改一次,之后想要什么入口,可以自己添加。改这个调试入口还是需要 App 改动,还是麻烦。我可以在项目中增加一个页面 debug.html(因为项目是多页面应用),这样我增加页面\增加调试入口,发布一下测试就生效了,在测试入口就能看到,这样更快。于是做了一个公共页面。
Vite preview
而且突然想到根本不需要自己起一个服务,Vite 项目,Vite preview 就是把打包后的页面启动服务。地址是:xxx:4173。
修改测试地址为本地预览
然而,OPPO A5 WebView 本来就是打不开我们的系统,那么 WebView 打不开测试地址自然也就没法打开我的本地预览了。但是,测试地址是可以配置的,所以为了快速调试,让测试配置了我的本地预览地址 xxx:4173。
这样,终于在 APP WebView 中打开了我本地预览的页面。
如何查看 App WebView 的日志
手机连接电脑,adb 日志:
看起来这几个报错是正常的,报错信息也说了:
vite: loading legacy chunks, syntax error above and the same error below should be ignored
但是页面没有加载,不知道 WebView 打开页面和页面加载之间发生了什么。
Vite 兼容插件的原理
这期间,反复详细理解原理,是否是插件的使用不对。
用一句话说就是,Vite 兼容插件让构建打包的产物多了传统版本的 chunk 和对应 ES 语言特性的 polyfill,来支持传统浏览器的运行。兼容版的 chunk 只会在传统浏览器中运行。它是如何做到的呢?
- 通过 script type="module" 中的代码,判断当前浏览器是否是现代浏览器。如果是,设置全局变量:window.__vite_is_modern_browser = true。判断的依据是:
- import.meta.url;
- import("_").catch(() => 1);
- async function* g() { }
- 通过 script type="module",如果是现代浏览器,直接退出;如果不是,加载兼容文件:
- 通过 script type="nomodule",加载兼容 polyfill 文件;
- 通过 script type="nomodule",加载兼容入口文件;
传统浏览器不执行 type="module" 的代码,执行 type="nomodule" 的代码。
现代浏览器执行 type="module" 的代码,不执行 type="nomodule" 的代码。
为什么需要 type="module" 的代码?这里是针对浏览器支持 ESM 却不支持以上 3 个语法的情况,仍然使用兼容模式。
详细可以看参考文章,以及查看打包构建产物。
除了知乎那篇文章,我几乎翻遍了搜到的 vite 兼容 空白 白屏 相关的文章,参考相关的配置。这个插件就是很常规的使用,几乎没有看到有任何特殊的配置或处理。就是生成了兼容的代码,低版本浏览器就能使用而已,似乎没人碰到过我的问题。
尝试解决
前面说了,用手机系统浏览器打开页面,竟然正常。怀疑是不是 WebView 的问题。
WebView 的内核版本
借了几个低端机型,几个安卓 5.x 6.x 的系统,结果手机浏览器都能正常打开。
打印 console.log(navigator.appVersion)
,WebView 中:
5.0 (Linux; Android 8.1.0; PBAT00 Build/OPM1.171019.026; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/62.0.3202.84 Mobile Safari/537.36 uniweb/ma75 uniweb-apk-version/0.6.0 uniweb-script-version/0.6.0 uniweb-channel/netease Unisdk/2.1 NetType/wifi os/android27 ngwebview/4.1 package_name/com.netease.sky udid/944046b939d510b1 webview_orbit/1.2(1)
而手机浏览器版本为 Chrome 90,其他手机有 Chrome70。总之,OPPO A5 WebView 的内核 Chrome 版本较低。
Vite 文档对于构建生产版本浏览器兼容性的介绍:
用于生产环境的构建包会假设目标浏览器支持现代 JavaScript 语法。默认情况下,Vite 的目标是能够 支持原生 ESM script 标签、支持原生 ESM 动态导入 和 import.meta 的浏览器
原生 ESM script 标签的支持:
原生 ESM 动态导入的支持:
import.meta 的支持:
所以,原来 Chrome 62 支持 ESM,但是不支持其他 2 个。通过日志,也可以知道不支持兼容插件尝试的 3 个语法,因为打印了那句警告,来自 module 的代码位置,window.__vite_is_modern_browser 不为 true。
从 Android 4.4 开始,系统 WebView 使用 Chrome 内核。
手机系统浏览器内核和系统 WebView 不一样,手机系统的 WebView 也可能不是安卓默认的。
兼容生效了吗?
但还是不知道低版本的浏览器兼容性是否生效,当前我们只能确定 Chrome 62 的 WebView 中兼容有问题,那是否在浏览器中就正常呢?或者更低的版本兼容是否生效?(毕竟不支持 ESM 的浏览器版本的代码执行又不一样)
target 配置不对?
target 配置的是目标浏览器,针对这些浏览器生成对应的兼容代码,期间我一直调整 target 的配置,使用 .broswerlistrc 文件配置,target 直接配置,参考不同的配置方案,确保包含了 Chrome 62。
又是如何调试?
想要下载安卓 Chrome 62 进行测试,但是搜了一圈也没找到。
后来想到不一定要手机浏览器进行测试,Chrome 也行。这里面有点思维上的转换,之前我测试只能通过 WebView 进行调试,因为浏览器上没有问题。现在确定了是浏览器 Chrome 版本的问题,那么我们还是可以通过 PC 浏览器进行测试。
于是范围更大一些,找到了 Chrome 的所有历史版本,不得不说,Chrome 提供的下载真是太有用了,对于测试兼容性非常有帮助! 而且我所担心的覆盖现有浏览器版本的问题完全不存在,下载之后直接运行。
安装 Chrome 62,打开页面,果然空白。终于在浏览器复现,确定就是兼容的问题,而不是 WebView 的问题。安装安卓版本的 Chrome 62,也同样复现。
下载 win 的 Chrome 62,虽然在 refs 里找到同版本的记录:xxx。但是没找到同版本的下载,不过也都是 62,应该没问题。下打开页面,打开控制台,和在 adb 中看到的报错一样,只是这里是红色的:
安装更低版本的 Chrome,同样复现,说明不支持 ESM 的兼容也出现问题。同时可以看到那句提示没有了:
过程当然也没那么顺利,下载 Chrome 的过程中,Chrome 62 直接可以运行,下载 Chrome 59 却没法打开。于是又下载了 Chrome 55,mini exe 文件,可以直接打开。
报错到底要不要处理?
通过 adb日志可以看到报错,也可以从打包后的代码看到:对于语法报错是可以忽略的,因为那是预期中的行为。可是之后的代码为什么没执行了呢?
回到现在的问题,这个报错不是不需要处理吗?但是加载了兼容的 js,页面却没有渲染元素。此时隐隐觉得报错可能还是要处理,至少可能最后一个报错有点问题?
但是这个报错实在难以查看,之前我把它当作和前两个报错一样的来源。现在只剩这个报错了,问题是这是打包压缩后的代码,完全不知道真正的问题是什么。
通过请教网友,做了一些尝试:
通过对插件配置:renderModernChunks: false,只生成兼容代码,依然报错。
通过修改 Vite 配置:build.minify: false 不压缩代码,尝试查看报错位置。新的报错:
升级 Vite。新的报错:
所以每次的报错都不一样,越来越奇怪。不过看起来似乎是同一个原因导致的。
在构建源码中调试
通过在构建后的源码中打印,其中 excute 函数中,有两个参数 exports module,但是在其中使用 module.meta 报错,说明其他文件在使用这个方法是并没有传参。看起来像是模块规范的问题(commonJS 和 ES Module)。
ChatGPT
在这期间,也在 ChatGPT 搜素方法:
就尝试了一下 format: 'es',顺便看到有个配置 compact: true
,好像也是压缩,就顺手改成 false,这样全部不要压缩,方便看报错。
结果竟然 OK 了,页面打开,没有报错!
是这个配置生效的吗?通过排除,发现竟然是 compact 的原因。这个配置不是 Vite 本身的,是 Vite 使用的 rollup 的配置:
果然是插件冲突的结果。
再搜素 execute
,已经没有带参数了:
再次感叹 Webpack 配置工程师
build.sourcemap
后来想到开启 sourcemap 来定位报错的原始文件位置,未开启:
开启 sourcemap:
如果在打包过程中对代码进行了混淆或压缩,可能会导致 Source Map 无法准确映射到原始代码位置。
这就完了?
中午去吃个饭,下午回来,本以为打包发布验证一下就完了,结果测试地址能打开页面了,却和我在电脑浏览器上看到的一样,没有正常加载页面。晴天霹雳!
说明一下:我们这个项目是 App Hybrid 应用,Web 前端和服务端的通信通过客户端中转,所以在非客户端环境是拿不到服务端的数据的,联调测试都是在 App 中进行的。
但为什么前面一直用浏览器测试呢?虽然数据获取不到,但是如果页面加载出来了,说明打包代码是没问题,所以在前面页面显示了背景图等元素,id="app"
中有了内容以后,就是兼容版本的 js 正常执行了。但是再说一次,真实的环境是 WebView,最终的结果还是要看 WebView。
目前在 App 中还是没有显示完整的网页加载过程,又陷入了困境。页面已经没有了报错(除了 Vite 那个可以被忽略的),难道生成的兼容 js 有问题? 如果是具体的兼容代码有问题,那整个项目的代码又如何排查呢?
虽然没有头绪,但是隐隐觉得应该再坚持一下。继续分析,如果已经显示了页面,兼容的 js 已经执行了,那么数据接口请求了吗?
于是联系服务端,查看服务器日志,确定页面加载没有请求接口,说明请求接口的代码没执行。我用高版本 Chrome 查看 Preview(之前没做这一步),果然除了看到页面元素,还进行了请求尝试。于是又运用排除法,一步步打印首页加载的过程。
在这之前,又是如何调试的问题,我们把测试入口改为我本地的 preview 地址。
加入打印日志,本地 build,Preview,打印了日志信息,果然接口请求的部分未执行。排查发现是之前调起 WebView 导航的代码出问题,可能是 jsBridge 还未加载。
但是为什么其他设备没有出现这个问题呢,可能是设备性能较差。于是把相关代码放到 jsBridge 加载完成后执行,修复了这样一个隐藏的 bug。
但是为什么不报错呢??这真的是很不好的体验,之前 Vue router 不报错的问题也类似。
总结
同样,我们再回头看那个最初的报错:
vite: loading legacy chunks, syntax error above and the same error below should be ignored
上面的报错、项目相同的报错可以被忽略。很容易让人忽略了报错,明确提到了下面的报错,但是 Vite 打包中下面没有出现同样的报错了(我目前的打包和其他文章中提到的不一样,比如知乎文章提到的代码);而且相同的报错到底是什么相同,同样是语法报错而已啊。
这句提示值得商榷。
function __vite_legacy_guard() {
import.meta.url;
import("_").catch(() => 1);
async function* g() {};
};
主要是因为出问题的恰恰就是中间的版本,Chrome 62,Vite 让它报错又能正常运行。遇到这种情况少之又少,从权衡上来说,好像也没有问题
本质上,只是在解决一个打包后的文件报错的问题,问题是一开始并没有定位到这个问题,其次是打包后的报错仍然难以定位具体错误位置。然后这其中还涉及到项目自身环境的各种干扰。
几点感悟:
- 坚持不懈,这是解决问题的唯一原因。
- 总结熟练调试很重要,要快速找到方便调试的方法。
- 没有报错是开发的一大痛点。
- 针对当前的问题更深入的分析原因,更广泛的尝试。
- 多用 ChatGPT,ChatGPT 的强大在于它没有弱点,没有缺项。
说明
通过这个案例,希望能给大家一点解决问题的启发。是遇到类似的问题时:
- 了解相关的问题
- 熟悉相关的概念
- 学习解决问题的方法
- 学习调试的方法
- 坚持的重要性
参考
【原理揭秘】Vite 是怎么兼容老旧浏览器的?你以为仅仅依靠 Babel?
来源:juejin.cn/post/7386493910820667418
谈谈前端如何防止数据泄漏
最近突然发现了一个好玩的事情,部分网站进去的时候几乎都是死的,那种死是区别于我们常见的网站的死:
- 不能选中文字
- 不能复制粘贴文字
- 不能鼠标右键显示选项
- 不能打开控制台
- ……
各种奇葩的操作应接不暇,像极了我最初接触的某库。shigen
的好奇心直接拉满,好家伙,这是咋做的呀。一顿操作之后,发现这种是为了防止网站的数据泄露(高大上)。在我看来,不是为了装X就是为了割韭菜。
咱废话也不多说,就手动来一个,部分代码参考文章:如何防止网站信息泄露(复制/水印/控制台)。
那shigen
实现的效果是这样的:
用魔法生成了一个页面,展示的是李白的《将进酒》。我需要的功能有尽可能的全面,禁止复制、选择、调试……
找了很多的方式,最后能自豪的展示出来的功能有:
- 禁止选择
- 禁止鼠标右键
- 禁止复制粘贴
- 禁止调试资源(刷新页面的方式)
- 常见的页面水印
那其实也没有特别的技术含量,我就在这里展示了,希望能作为工具类供大家使用。
页面部分
html5+css,没啥好讲的。
html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: "Microsoft YaHei", sans-serif;
line-height: 1.6;
padding: 20px;
text-align: center;
background-color: #f8f8f8;
}
.poem-container {
max-width: 600px;
margin: 0 auto;
background-color: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
h1 {
font-size: 1.5em;
margin-bottom: 20px;
}
p {
text-indent: 2em;
font-size: 1.2em;
}
style>
<title>李白《将进酒》title>
head>
<body>
<div class="poem-container">
<h1>将进酒h1>
<p>君不见,黄河之水天上来,奔流到海不复回。p>
<p>君不见,高堂明镜悲白发,朝如青丝暮成雪。p>
<p>人生得意须尽欢,莫使金樽空对月。p>
<p>天生我材必有用,千金散尽还复来。p>
<p>烹羊宰牛且为乐,会须一饮三百杯。p>
<p>岑夫子,丹丘生,将进酒,杯莫停。p>
<p>与君歌一曲,请君为我倾耳听。p>
<p>钟鼓馔玉不足贵,但愿长醉不复醒。p>
<p>古来圣贤皆寂寞,惟有饮者留其名。p>
<p>陈王昔时宴平乐,斗酒十千恣欢谑。p>
<p>主人何为言少钱,径须沽取对君酌。p>
<p>五花马,千金裘,呼儿将出换美酒,与尔同销万古愁。p>
div>
body>
js部分
禁止选中
// 防止用户选中
function disableSelect() {
// 方式:给body设置样式
document.body.style.userSelect = 'none';
// 禁用input的ctrl + a
document.keyDown = function(event) {
const { ctrlKey, metaKey, keyCode } = event;
if ((ctrlKey || metaKey) && keyCode === 65) {
return false;
}
}
};
禁止复制、粘贴、剪切
document.addEventListener('copy', function(e) {
e.preventDefault();
});
document.addEventListener('cut', function(e) {
e.preventDefault();
});
document.addEventListener('paste', function(e) {
e.preventDefault();
});
禁止鼠标右键
// 防止右键
window.oncontextmenu = function() {
event.preventDefault()
return false
}
禁止调试资源
这个我会重点分析。
let threshold = 160 // 打开控制台的宽或高阈值
window.setInterval(function() {
if (window.outerWidth - window.innerWidth > threshold ||
window.outerHeight - window.innerHeight > threshold) {
// 如果打开控制台,则刷新页面
window.location.reload()
}
}, 1000)
这个代码的意思很好理解,当我们F12的时候,页面的宽度肯定会变小的,我们这个时候和屏幕的宽度比较,大于我们设置的阈值,我们就算用户在调试页面了。这也是我目前找到的比较好的方式了。但是,但是,认真思考一下以下问题需要你考虑吗?
- 页面频繁加载,流量的损失大吗
- 页面刷新,后端接口频繁调用,接口压力、接口幂等性
所以,我觉得这种方式不优雅,极度的不优雅,但是有没有别的好的解决办法。
加水印
// 生成水印
function generateWatermark(keyword = 'shigen-demo') {
// 创建Canvas元素
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
// 设置Canvas尺寸和字体样式
canvas.width = 100;
canvas.height = 100;
context.font = '10px Arial';
context.fillStyle = 'rgba(0,0,0,0.1)';
// 绘制文字到Canvas上
context.fillText(keyword, 10, 50);
// 生成水印图像的URL
const watermarkUrl = canvas.toDataURL();
// 在页面上显示水印图像(或进行其他操作)
const divDom = document.createElement('div');
divDom.style.cssText = `
position: fixed;
z-index: 99999;
top: -10000px;
bottom: -10000px;
left: -10000px;
right: -10000px;
transform: rotate(-45deg);
pointer-events: none;
background-image: url(${watermarkUrl});
`;
document.body.appendChild(divDom);
}
代码不需要理解,部分的参数去调整一下,就可以拿来就用了。
我一想,我最初接触到这种页面水印的时候,是在很老的OA办公系统,到后来用到了某书,它的app页面充满了水印,包括浏览器端的页面。
所以,我也实现了这个。but,but,有一种技术叫做OCR,大白话讲就是文字识别。我把图片截个图,让某信、某书识别以下,速度和效果那叫一个nice,当然也可能把水印也识别出来了。聪敏的开发者会把水印的颜色和文字的颜色设置成一种,这个时候需要准确的文字那可得下一番功夫了。换句话说,不是定制化的OCR,准确的识别出信息,真的够呛。
还有的很多页面实现了js的数据加密、接口数据加密。但是道高一尺,魔高一丈,各种都是在一种相互进步的。就看实际的业务场景和系统的设计了。
来源:juejin.cn/post/7300102080903675915
没用的东西,你连个内存泄漏都排查不出来!!
背景 (书接上回)
- ui妹子的无理要求,我通通满足了。但是不出意外的话,意外就出来了。
- 此功能在上线之后,我们的业务在客户app内使用刷脸的时候会因为内存过高导致app将webview杀死。
- 然后我们leader爆了,让我排查问题。可是可是,我哪里会排查内存泄漏呀。
- 我:我不会。你自己不会上吗?你tm天天端个茶,抽个烟,翘个二郎腿,色眯眯的看着ui妹妹。
- 领导:污蔑,你纯粹就是污蔑。我tm现在就可以让你滚蛋,你信吗?
- 我:我怕你个鸟哦,我还不知道你啥水平,你tm能写出来个防抖节流,我就给你磕头。
- 领导:hi~ui妹妹,今天过的好吗,来,哥哥这里有茶喝。(此时ui妹妹路过)。你赶快给我干活,以后ui妹妹留给你。
- 艹!你早这么说不就好了。
开始学习
Chrome devTools查看内存情况
- 打开
Chrome
的无痕模式,这样做的目的是为了屏蔽掉Chrome
插件对我们之后测试内存占用情况的影响
- 打开开发者工具,找到
Performance
这一栏,可以看到其内部带着一些功能按钮,例如:开始录制按钮;刷新页面按钮;清空记录按钮;记录并可视化js内存、节点、事件监听器按钮;触发垃圾回收机制按钮等
简单录制一下百度页面,看看我们能获得什么,如下动图所示:
从上图中我们可以看到,在页面从零到加载完成这个过程中
JS Heap
(js堆内存)、documents
(文档)、Nodes
(DOM节点)、Listeners
(监听器)、GPU memory
(GPU
内存)的最低值、最高值以及随时间的走势曲线,这也是我们主要关注的点
看看开发者工具中的Memory
一栏,其主要是用于记录页面堆内存的具体情况以及js堆内存随加载时间线动态的分配情况
堆快照就像照相机一样,能记录你当前页面的堆内存情况,每快照一次就会产生一条快照记录
如上图所示,刚开始执行了一次快照,记录了当时堆内存空间占用为
33.7MB
,然后我们点击了页面中某些按钮,又执行一次快照,记录了当时堆内存空间占用为32.5MB
。并且点击对应的快照记录,能看到当时所有内存中的变量情况(结构、占总占用内存的百分比...)
在开始记录后,我们可以看到图中右上角有起伏的蓝色与灰色的柱形图,其中
蓝色
表示当前时间线下占用着的内存;灰色
表示之前占用的内存空间已被清除释放
在得知有内存泄漏的情况存在时,我们可以改用Memory
来更明确得确认问题和定位问题
首先可以用Allocation instrumentation on timeline
来确认问题,如下图所示:
内存泄漏的场景
- 闭包使用不当引起内存泄漏
- 全局变量
- 分离的
DOM
节点 - 控制台的打印
- 遗忘的定时器
1. 闭包使用不当引起内存泄漏
使用Performance
和Memory
来查看一下闭包导致的内存泄漏问题
<button onclick="myClick()">执行fn1函数button>
<script>
function fn1 () {
let a = new Array(10000) // 这里设置了一个很大的数组对象
let b = 3
function fn2() {
let c = [1, 2, 3]
}
fn2()
return a
}
let res = []
function myClick() {
res.push(fn1())
}
script>
在退出
fn1
函数执行上下文后,该上下文中的变量a
本应被当作垃圾数据给回收掉,但因fn1
函数最终将变量a
返回并赋值给全局变量res
,其产生了对变量a
的引用,所以变量a
被标记为活动变量并一直占用着相应的内存,假设变量res
后续用不到,这就算是一种闭包使用不当的例子
设置了一个按钮,每次执行就会将fn1
函数的返回值添加到全局数组变量res
中,是为了能在performacne
的曲线图中看出效果,如图所示:
- 在每次录制开始时手动触发一次垃圾回收机制,这是为了确认一个初始的堆内存基准线,便于后面的对比,然后我们点击了几次按钮,即往全局数组变量
res
中添加了几个比较大的数组对象,最后再触发一次垃圾回收,发现录制结果的JS Heap曲线刚开始成阶梯式上升的,最后的曲线的高度比基准线要高,说明可能是存在内存泄漏的问题 - 在得知有内存泄漏的情况存在时,我们可以改用
Memory
来更明确得确认问题和定位问题 - 首先可以用
Allocation instrumentation on timeline
来确认问题,如下图所示:
- 在我们每次点击按钮后,动态内存分配情况图上都会出现一个
蓝色的柱形
,并且在我们触发垃圾回收后,蓝色柱形
都没变成灰色柱形,即之前分配的内存并未被清除 - 所以此时我们就可以更明确得确认内存泄漏的问题是存在的了,接下来就精准定位问题,可以利用
Heap snapshot
来定位问题,如图所示:
- 第一次先点击快照记录初始的内存情况,然后我们多次点击按钮后再次点击快照,记录此时的内存情况,发现从原来的
1.1M
内存空间变成了1.4M
内存空间,然后我们选中第二条快照记录,可以看到右上角有个All objects
的字段,其表示展示的是当前选中的快照记录所有对象的分配情况,而我们想要知道的是第二条快照与第一条快照的区别在哪,所以选择Object allocated between Snapshot1 and Snapshot2
即展示第一条快照和第二条快照存在差异的内存对象分配情况,此时可以看到Array的百分比很高,初步可以判断是该变量存在问题,点击查看详情后就能查看到该变量对应的具体数据了
以上就是一个判断闭包带来内存泄漏问题并简单定位的方法了
2. 全局变量
全局的变量一般是不会被垃圾回收掉的当然这并不是说变量都不能存在全局,只是有时候会因为疏忽而导致某些变量流失到全局,例如未声明变量,却直接对某变量进行赋值,就会导致该变量在全局创建,如下所示:
function fn1() {
// 此处变量name未被声明
name = new Array(99999999)
}
fn1()
- 此时这种情况就会在全局自动创建一个变量
name
,并将一个很大的数组赋值给name
,又因为是全局变量,所以该内存空间就一直不会被释放 - 解决办法的话,自己平时要多加注意,不要在变量未声明前赋值,或者也可以
开启严格模式
,这样就会在不知情犯错时,收到报错警告,例如
function fn1() {
'use strict';
name = new Array(99999999)
}
fn1()
3. 分离的DOM
节点
假设你手动移除了某个dom
节点,本应释放该dom节点所占用的内存,但却因为疏忽导致某处代码仍对该被移除节点有引用,最终导致该节点所占内存无法被释放,例如这种情况
<div id="root">
<div class="child">我是子元素div>
<button>移除button>
div>
<script>
let btn = document.querySelector('button')
let child = document.querySelector('.child')
let root = document.querySelector('#root')
btn.addEventListener('click', function() {
root.removeChild(child)
})
script>
该代码所做的操作就是点击按钮后移除
.child
的节点,虽然点击后,该节点确实从dom
被移除了,但全局变量child
仍对该节点有引用,所以导致该节点的内存一直无法被释放,可以尝试用Memory
的快照功能来检测一下,如图所示
同样的先记录一下初始状态的快照,然后点击移除按钮后,再点击一次快照,此时内存大小我们看不出什么变化,因为移除的节点占用的内存实在太小了可以忽略不计,但我们可以点击第二条快照记录,在筛选框里输入
detached
,于是就会展示所有脱离了却又未被清除的节点对象
解决办法如下图所示:
<div id="root">
<div class="child">我是子元素div>
<button>移除button>
div>
<script>
let btn = document.querySelector('button')
btn.addEventListener('click', function() {
let child = document.querySelector('.child')
let root = document.querySelector('#root')
root.removeChild(child)
})
script>
改动很简单,就是将对
.child
节点的引用移动到了click
事件的回调函数中,那么当移除节点并退出回调函数的执行上文后就会自动清除对该节点的引用,那么自然就不会存在内存泄漏的情况了,我们来验证一下,如下图所示:
结果很明显,这样处理过后就不存在内存泄漏的情况了
4. 控制台的打印
<button>按钮button>
<script>
document.querySelector('button').addEventListener('click', function() {
let obj = new Array(1000000)
console.log(obj);
})
script>
我们在按钮的点击回调事件中创建了一个很大的数组对象并打印,用performance
来验证一下
开始录制,先触发一次垃圾回收清除初始的内存,然后点击三次按钮,即执行了三次点击事件,最后再触发一次垃圾回收。查看录制结果发现
JS Heap
曲线成阶梯上升,并且最终保持的高度比初始基准线高很多,这说明每次执行点击事件创建的很大的数组对象obj
都因为console.log
被浏览器保存了下来并且无法被回收
接下来注释掉console.log
,再来看一下结果:
<button>按钮button>
<script>
document.querySelector('button').addEventListener('click', function() {
let obj = new Array(1000000)
// console.log(obj);
})
script>
可以看到没有打印以后,每次创建的obj
都立马被销毁了,并且最终触发垃圾回收机制后跟初始的基准线同样高,说明已经不存在内存泄漏的现象了
其实同理 console.log
也可以用Memory
来进一步验证
未注释 console.log
注释掉了console.log
最后简单总结一下:在开发环境下,可以使用控制台打印便于调试,但是在生产环境下,尽可能得不要在控制台打印数据。所以我们经常会在代码中看到类似如下的操作:
// 如果在开发环境下,打印变量obj
if(isDev) {
console.log(obj)
}
这样就避免了生产环境下无用的变量打印占用一定的内存空间,同样的除了
console.log
之外,console.error
、console.info
、console.dir
等等都不要在生产环境下使用
5. 遗忘的定时器
定时器也是平时很多人会忽略的一个问题,比如定义了定时器后就再也不去考虑清除定时器了,这样其实也会造成一定的内存泄漏。来看一个代码示例:
<button>开启定时器button>
<script>
function fn1() {
let largeObj = new Array(100000)
setInterval(() => {
let myObj = largeObj
}, 1000)
}
document.querySelector('button').addEventListener('click', function() {
fn1()
})
script>
这段代码是在点击按钮后执行fn1
函数,fn1
函数内创建了一个很大的数组对象largeObj
,同时创建了一个setInterval
定时器,定时器的回调函数只是简单的引用了一下变量largeObj
,我们来看看其整体的内存分配情况吧:
按道理来说点击按钮执行fn1
函数后会退出该函数的执行上下文,紧跟着函数体内的局部变量应该被清除,但图中performance
的录制结果显示似乎是存在内存泄漏问题的,即最终曲线高度比基准线高度要高,那么再用Memory
来确认一次:
- 在我们点击按钮后,从动态内存分配的图上看到出现一个蓝色柱形,说明浏览器为变量
largeObj
分配了一段内存,但是之后这段内存并没有被释放掉,说明的确存在内存泄漏的问题,原因其实就是因为setInterval
的回调函数内对变量largeObj
有一个引用关系,而定时器一直未被清除,所以变量largeObj
的内存也自然不会被释放 - 那么我们如何来解决这个问题呢,假设我们只需要让定时器执行三次就可以了,那么我们可以改动一下代码:
<button>开启定时器button>
<script>
function fn1() {
let largeObj = new Array(100000)
let index = 0
let timer = setInterval(() => {
if(index === 3) clearInterval(timer);
let myObj = largeObj
index ++
}, 1000)
}
document.querySelector('button').addEventListener('click', function() {
fn1()
})
script>
现在我们再通过performance
和memory
来看看还不会存在内存泄漏的问题
performance
这次的录制结果就能看出,最后的曲线高度和初始基准线的高度一样,说明并没有内存泄漏的情况
memory
这里做一个解释,图中刚开始出现的蓝色柱形是因为我在录制后刷新了页面,可以忽略;然后我们点击了按钮,看到又出现了一个蓝色柱形,此时就是为fn1
函数中的变量largeObj
分配了内存,3s
后该内存又被释放了,即变成了灰色柱形。所以我们可以得出结论,这段代码不存在内存泄漏的问题
简单总结一下: 大家在平时用到了定时器,如果在用不到定时器后一定要清除掉,否则就会出现本例中的情况。除了
setTimeout
和setInterval
,其实浏览器还提供了一个API
也可能就存在这样的问题,那就是requestAnimationFrame
- 好了好了,学完了,ui妹妹我来了
- ui妹妹:去你m的,滚远点
好了兄弟们,内存泄漏学会了吗?
来源:juejin.cn/post/7309040097936474175
一种适合H5屏幕适配方案
一、动态rem适配方案:适合H5项目的适配方案
1. @media媒体查询适配
首先,我们需要设置一个根元素的基准值,这个基准值通常根据视口宽度进行计算。可以在项目的 CSS 文件中,通过媒体查询动态调整根元素的 font-size
。
html {
font-size: 16px; /* 默认基准值 */
}
...
@media (min-width: 1024px) {
html {
font-size: 14px; /* 适配较大屏幕 */
}
}
@media (min-width: 1440px) {
html {
font-size: 16px; /* 适配超大屏幕 */
}
}
2. PostCSS 插件(自动转换)实现 px2rem
手动转换 px
为 rem
可能很繁琐,因此可以使用 PostCSS
插件 postcss-pxtorem
来自动完成这一转换。
2.1 安装 postcss-pxtorem
首先,在项目中安装 postcss-pxtorem 插件:
npm install postcss-pxtorem --save-dev
2.2 配置 PostCSS
然后,在项目根目录创建或编辑 postcss.config.js 文件,添加 postcss-pxtorem 插件配置:
/* postcss.config.cjs */
module.exports = {
plugins: {
'postcss-pxtorem': {
rootValue: 16, // 基准值,对应于根元素的 font-size
unitPrecision: 5, // 保留小数点位数
propList: ['*', '!min-width', '!max-width'], // 排除 min-width 和 max-width 属性
selectorBlackList: [], // 忽略的选择器
replace: true, // 替换而不是添加备用属性
mediaQuery: false, // 允许在媒体查询中转换 px
minPixelValue: 0 // 最小的转换数值
}
}
};
/* vite */
export default defineConfig({
css: {
postcss: './postcss.config.cjs',
}
})
3. 在 CSS/SCSS 中使用 px
在编写样式时,依然可以使用 px
进行布局:
.container {
width: 320px;
padding: 16px;
}
.header {
height: 64px;
margin-bottom: 24px;
}
4. 构建项目
通过构建工具(如 webpack/vite
)运行项目时,PostCSS
插件会自动将 px
转换为 rem
。
5. 可以不用@media媒体查询,动态动态调整font-size
为了实现更动态的适配,可以通过 JavaScript
动态设置根元素的 font-size
:
/**utils/setRootFontSize**/
function setRootFontSize(): void {
const docEl = document.documentElement;
const clientWidth = docEl.clientWidth;
if (!clientWidth) return;
const baseFontSize = 16; // 基准字体大小
const designWidth = 1920; // 设计稿宽度
docEl.style.fontSize = (baseFontSize * (clientWidth / designWidth)) + 'px';
}
export default setRootFontSize;
/**utils/setRootFontSize**/
/**APP**/
import setRootFontSize from '../utils/setRootFontSize';
import { useEffect } from 'react';
export default function App() {
useEffect(() => {
// 设置根元素的字体大小
setRootFontSize();
// 窗口大小改变时重新设置
window.addEventListener('resize', setRootFontSize);
// 清除事件监听器
return () => {
window.removeEventListener('resize', setRootFontSize);
};
}, []);
return (
<>
<div>
<MyRoutes />
</div>
</>
)
}
/**APP**/
这样,无论视口宽度如何变化,页面元素都会根据基准值动态调整大小,确保良好的适配效果。
通过上述步骤,可以实现布局使用 px
,并动态转换为 rem
的适配方案。这个方案不仅使得样式编写更加简洁,还提高了适配的灵活性。
注:如果你使用了 setRootFontSize 动态调整根元素的 font-size
,就不再需要使用 @media 查询来调整根元素的字体大小了。这是因为 setRootFontSize
函数已经根据视口宽度动态调整了 font-size,从而实现了自适应。
- 动态调整根元素
font-size
的优势:
- 更加灵活:可以实现更加平滑的响应式调整,而不是依赖固定的断点。
- 统一管理:所有的样式都依赖根元素的 font-size,维护起来更加简单。
@media
媒体查询的优势:
- 尽管不再需要用
@media
查询来调整根元素的font-size
,但你可能仍然需要使用@media
查询来处理其他的响应式设计需求,比如调整布局、隐藏或显示元素等。
- 尽管不再需要用
这种方式简化了响应式设计,使得样式统一管理更加简单,同时保留了灵活性和适应性。
6. 效果对比(非H5界面)
图一为界面px
适配,效果为图片,文字等大小固定不变。
图二为动态rem
适配:整体随界面扩大而扩大,能够保持相对比例。
7. Tips
- 动态
rem
此方案比较适合H5屏幕适配 - 注意:
PostCSS
转换rem
应排除min-width
、max-width
、min-height
和max-height
,以免影响整体界面
二、其他适配
1. 弹性盒模型(Flexbox)
Flexbox
是一种布局模型,能够轻松地实现响应式布局。它允许元素根据容器的大小自动调整位置和大小。
.container {
display: flex;
flex-wrap: wrap;
}
.item {
flex: 1 1 100%; /* 默认情况下每个元素占满一行 */
}
@media (min-width: 600px) {
.item {
flex: 1 1 50%; /* 在较宽的屏幕上,每个元素占半行 */
}
}
@media (min-width: 1024px) {
.item {
flex: 1 1 33.33%; /* 在更宽的屏幕上,每个元素占三分之一行 */
}
}
2. 栅格系统(Grid System)
栅格系统是一种常见的响应式布局方案,广泛应用于各种框架(如 Bootstrap
)。通过定义行和列,可以轻松地创建复杂的布局。
.container {
display: grid;
grid-template-columns: 1fr; /* 默认情况下每行一个列 */
gap: 10px;
}
@media (min-width: 600px) {
.container {
grid-template-columns: 1fr 1fr; /* 在较宽的屏幕上,每行两个列 */
}
}
@media (min-width: 1024px) {
.container {
grid-template-columns: 1fr 1fr 1fr; /* 在更宽的屏幕上,每行三个列 */
}
}
3. 百分比和视口单位
使用百分比(%
)、视口宽度(vw
)、视口高度(vh
)等单位,可以根据视口尺寸调整元素大小。
/* 示例:百分比和视口单位 */
.container {
width: 100%;
height: 50vh; /* 高度为视口高度的一半 */
}
.element {
width: 50%; /* 宽度为容器的一半 */
height: 10vw; /* 高度为视口宽度的 10% */
}
4. 响应式图片
根据设备分辨率和尺寸加载不同版本的图片,以提高性能和视觉效果。可以使用 srcset 和 sizes 属性。
<!-- 示例:响应式图片 -->
<img
src="small.jpg"
srcset="medium.jpg 600w, large.jpg 1024w"
sizes="(max-width: 600px) 100vw, (max-width: 1024px) 50vw, 33.33vw"
alt="Responsive Image">
5. CSS Custom Properties(CSS变量)
使用 CSS 变量可以更灵活地定义和调整样式,同时通过 JavaScript
动态改变变量值实现响应式设计。
:root {
--main-padding: 20px;
}
.container {
padding: var(--main-padding);
}
@media (min-width: 600px) {
:root {
--main-padding: 40px;
}
}
来源:juejin.cn/post/7384265691162886178
利用高德地图API实现实时天气
前言
闲来无事,利用摸鱼时间实现实时天气的小功能
目录
效果图
这里样式我就不做处理了,地图可以不用做展示,只需要拿到获取到天气的结果,结合自己的样式展示就可以了,未来天气可以结合echarts进行展示,页面效果更佳
实现
- 登录高德开放平台控制台
- 创建 key
这里应用名称可以随便取(个人建议功能名称或者项目称)
3.获取 key 和密钥
4.获取当前城市定位
首先,先安装依赖
npm install @amap/amap-jsapi-loader --save
或者
pnpm add @amap/amap-jsapi-loader --save
页面使用时引入即可
import AMapLoader from "@amap/amap-jsapi-loader"
/**在index.html引入密钥,不添加会导致某些API调用不成功*/
<script type="text/javascript">window._AMapSecurityConfig =
{securityJsCode: "安全密钥"}</script>
/** 1. 调用AMapLoader.load方法,通过传入一个对象作为参数来指定加载地图时的配置信息。
* - key: 申请好的Web端开发者Key,是必填项,用于授权您的应用程序使用高德地图API。
* - version: 指定要加载的JSAPI版本,不指定时默认为1.4.15。
* - plugins: 需要使用的插件列表,如比例尺、缩放控件等。
*/
function initMap() {
AMapLoader.load({
key: "申请好的Web端开发者Key", // 申请好的Web端开发者Key,首次调用 load 时必填
version: "2.0", // 指定要加载的 JSAPI 的版本,缺省时默认为 1.4.15
plugins: [
"AMap.ToolBar",
"AMap.Scale",
"AMap.HawkEye",
"AMap.MapType",
"AMap.Geolocation",
"AMap.Geocoder",
"AMap.Weather",
"AMap.CitySearch",
"AMap.InfoWindow",
"AMap.Marker",
"AMap.Pixel",
], // 需要使用的的插件列表,如比例尺'AMap.Scale'等
})
.then((AMap) => {
map.value = new AMap.Map("container", {
//设置地图容器id
resizeEnable: true,
viewMode: "3D", //是否为3D地图模式
zoom: 10, //初始化地图级别
center: locationArr.value, //初始化地图中心点位置
});
getGeolocation(AMap);
getCitySearch(AMap, map.value);
})
.catch((e) => {
console.log(e);
});
}
// 浏览器定位
const getGeolocation = (AMap: any) => {
const geolocation = new AMap.Geolocation({
enableHighAccuracy: true, //是否使用高精度定位,默认:true
timeout: 1000, //超过10秒后停止定位,默认:5s
position: "RB", //定位按钮的停靠位置
offset: [10, 20], //定位按钮与设置的停靠位置的偏移量,默认:[10, 20]
zoomToAccuracy: true, //定位成功后是否自动调整地图视野到定位点
});
map.value.addControl(geolocation);
geolocation.getCurrentPosition(function (status: string, result: any) {
if (status == "complete") {
onComplete(result);
} else {
onError(result);
}
});
};
// IP定位获取当前城市信息
const getCitySearch = (AMap: any, map: any) => {
const citySearch = new AMap.CitySearch();
citySearch.getLocalCity(function (
status: string,
result: {
city: string;
info: string;
}
) {
if (status === "complete" && result.info === "OK") {
console.log(
"🚀 ~ file: map-container.vue:88 ~ getCitySearch ~ result:",
result
);
// 查询成功,result即为当前所在城市信息
getWeather(AMap, map, result.city);
}
});
};
onMounted(() => {
initMap();
});
5.通过定位获取城市名称、区域编码,查询目标城市/区域的实时天气状况
const getWeather = (AMap: any, map: any, city: string) => {
const weather = new AMap.Weather();
weather.getLive(
city,
function (
err: any,
data: {
city: string;
weather: string;
temperature: string;
windDirection: string;
windPower: string;
humidity: string;
reportTime: string;
}
) {
if (!err) {
const str = [];
str.push("<h4 >实时天气" + "</h4><hr>");
str.push("<p>城市/区:" + data.city + "</p>");
str.push("<p>天气:" + data.weather + "</p>");
str.push("<p>温度:" + data.temperature + "℃</p>");
str.push("<p>风向:" + data.windDirection + "</p>");
str.push("<p>风力:" + data.windPower + " 级</p>");
str.push("<p>空气湿度:" + data.humidity + "</p>");
str.push("<p>发布时间:" + data.reportTime + "</p>");
const marker = new AMap.Marker({
map: map,
position: map.getCenter(),
});
const infoWin = new AMap.InfoWindow({
content:
'<div class="info" style="position:inherit;margin-bottom:0;background:#ffffff90;padding:10px">' +
str.join("") +
'</div><div class="sharp"></div>',
isCustom: true,
offset: new AMap.Pixel(0, -37),
});
infoWin.open(map, marker.getPosition());
marker.on("mouseover", function () {
infoWin.open(map, marker.getPosition());
});
}
}
);
}
完整代码
<template>
<div id="container"></div>
</template>
<script setup lang="ts">
import AMapLoader from "@amap/amap-jsapi-loader";
import { ref, onMounted, watch, reactive } from "vue";
const props = defineProps({
search: {
type: String,
default: "杭州市",
},
});
const isFalse = ref(false);
const map = ref<any>(null);
let locationArr = ref<any>();
watch(
() => props.search,
(newValue) => {
console.log("search", newValue);
initMap();
}
);
function initMap() {
AMapLoader.load({
key: "申请好的Web端开发者Key", // 申请好的Web端开发者Key,首次调用 load 时必填
version: "2.0", // 指定要加载的 JSAPI 的版本,缺省时默认为 1.4.15
plugins: [
"AMap.ToolBar",
"AMap.Scale",
"AMap.HawkEye",
"AMap.MapType",
"AMap.Geolocation",
"AMap.Geocoder",
"AMap.Weather",
"AMap.CitySearch",
"AMap.InfoWindow",
"AMap.Marker",
"AMap.Pixel",
], // 需要使用的的插件列表,如比例尺'AMap.Scale'等
})
.then((AMap) => {
map.value = new AMap.Map("container", {
//设置地图容器id
resizeEnable: true,
viewMode: "3D", //是否为3D地图模式
zoom: 10, //初始化地图级别
center: locationArr.value, //初始化地图中心点位置
});
getGeolocation(AMap);
getCitySearch(AMap, map.value);
})
.catch((e) => {
console.log(e);
});
}
// 浏览器定位
const getGeolocation = (AMap: any) => {
const geolocation = new AMap.Geolocation({
enableHighAccuracy: true, //是否使用高精度定位,默认:true
timeout: 1000, //超过10秒后停止定位,默认:5s
position: "RB", //定位按钮的停靠位置
offset: [10, 20], //定位按钮与设置的停靠位置的偏移量,默认:[10, 20]
zoomToAccuracy: true, //定位成功后是否自动调整地图视野到定位点
});
map.value.addControl(geolocation);
geolocation.getCurrentPosition(function (status: string, result: any) {
if (status == "complete") {
onComplete(result);
} else {
onError(result);
}
});
};
// IP定位获取当前城市信息
const getCitySearch = (AMap: any, map: any) => {
const citySearch = new AMap.CitySearch();
citySearch.getLocalCity(function (
status: string,
result: {
city: string;
info: string;
}
) {
if (status === "complete" && result.info === "OK") {
console.log(
"🚀 ~ file: map-container.vue:88 ~ getCitySearch ~ result:",
result
);
// 查询成功,result即为当前所在城市信息
getWeather(AMap, map, result.city);
}
});
};
// 天气
const getWeather = (AMap: any, map: any, city: string) => {
const weather = new AMap.Weather();
weather.getLive(
city,
function (
err: any,
data: {
city: string;
weather: string;
temperature: string;
windDirection: string;
windPower: string;
humidity: string;
reportTime: string;
}
) {
console.log("🚀 ~ file: map-container.vue:96 ~ .then ~ data:", data);
if (!err) {
const str = [];
str.push("<h4 >实时天气" + "</h4><hr>");
str.push("<p>城市/区:" + data.city + "</p>");
str.push("<p>天气:" + data.weather + "</p>");
str.push("<p>温度:" + data.temperature + "℃</p>");
str.push("<p>风向:" + data.windDirection + "</p>");
str.push("<p>风力:" + data.windPower + " 级</p>");
str.push("<p>空气湿度:" + data.humidity + "</p>");
str.push("<p>发布时间:" + data.reportTime + "</p>");
const marker = new AMap.Marker({
map: map,
position: map.getCenter(),
});
const infoWin = new AMap.InfoWindow({
content:
'<div class="info" style="position:inherit;margin-bottom:0;background:#ffffff90;padding:10px">' +
str.join("") +
'</div><div class="sharp"></div>',
isCustom: true,
offset: new AMap.Pixel(0, -37),
});
infoWin.open(map, marker.getPosition());
marker.on("mouseover", function () {
infoWin.open(map, marker.getPosition());
});
}
}
);
// 未来4天天气预报
weather.getForecast(
city,
function (err: any, data: { forecasts: string | any[] }) {
console.log(
"🚀 ~ file: map-container.vue:186 ~ getWeather ~ data:",
data
);
if (err) {
return;
}
var strs = [];
for (var i = 0, dayWeather; i < data.forecasts.length; i++) {
dayWeather = data.forecasts[i];
strs.push(
`<p>${dayWeather.date}  ${dayWeather.dayWeather}  ${dayWeather.nightTemp}~${dayWeather.dayTemp}℃</p><br />`
);
}
}
);
};
function onComplete(data: any) {
console.log("🚀 ~ file: map-container.vue:107 ~ onComplete ~ data:", data);
const lngLat = [data.position.lng, data.position.lat];
locationArr.value = lngLat;
}
function onError(data: any) {
console.log("🚀 ~ file: map-container.vue:113 ~ onError ~ data:", data);
// 定位出错
}
onMounted(() => {
initMap();
});
</script>
<style scoped lang="less">
#container {
padding: 0px;
margin: 0px;
width: 100%;
height: 100%;
}
</style>
来源:juejin.cn/post/7316746866040619035
离职前同事将下载大文件功能封装成了npm包,赚了145块钱
这几天有个同事离职了,本来那是last day,还有半个小时,但他还在那里勤勤恳恳的写着代码。我很好奇,就问:老张,你咋还不做好准备,都要撤了,还奋笔疾书呐。他说:等会儿和你说。
等了半个小时,他说:走,一起下班。我跟你说个好东西。
我说:好的。
老张一边走一边跟我说:公司的下载大文件代码不好。
我说哪里不好了,不是都用了很久了。
他说,那些代码,每次项目需要的时候,还得拷过来拷过去的,有时候拷着拷着就拷丢了,还得去网上现找代码,很不好。
我问:那然后呢?
他说:我这两天把这段代码封装了一下,封装成了npm包。以后,大家就直接调用就可以了,不用重复造轮子,或者担心轮子走丢了。我说那太好了。
他说:我把这个npm包给你,以后你就说自己写的,下个季度晋升的时候,你就说,为公司解决了代码冗余,重复造轮子的问题,而且让下载大文件功能更加便捷,节省开发时间,提升了开发效率。
我说:那怎么好啊,得请你吃个饭啊,你都要走了。不过,你先跟我说说,怎么用这个npm包啊。
下载大文件版
比如我们现有的成形的项目,大家使用axios或者fetch,一定在项目里已经封装好了请求,所以直接调用服务端给的请求地址,获取到blob数据流信息就可以了。但是拿到blob数据流以后,这段代码得四处拷贝,重复造轮子,很不好。所以可以这样使用,高效、便捷。
下载js-tool-big-box工具包
执行安装命令
npm install js-tool-big-box
项目中引入ajaxBox对象,下载文件的公共方法,downFile 在这个对象下面。
import { ajaxBox } from 'js-tool-big-box';
调用实现下载
比如你在项目中已经封装好了axios或者fetch的实现,那么只需要正常发送请求,然后调用方法即可,使用非常方便。
fetch('https://test.aaa.com/getPDF').then(res => res.blob()).then((blob) => {
ajaxBox.downFile(blob, '优乐的美.pdf');
});
在这个方法中,你只要将接口返回的信息流转为blob流,然后传入 downFile 方法中,然后再传入一个参数做为下载后的文件名即可。
fetch请求 + 下载实现版本
我又问他,的确是很多项目里,请求都已经封装好了。但我之前做过一个项目,功能很简单,大部分都是展示类的。但产品在一个详情页,让我加下载功能,我的请求并没有做封装。
然后呢,服务端告诉我,这个下载文件的接口,还需要传入参数params,需要传入headers,你这个方法就不适用了吧?
他想了一下,说。也是可以的,你听我说啊。
定义请求参数们
const url = 'https://test.aaaa.com/getPDF';
const headers = {
'Content-Type':'application/x-www-form-urlencoded;charset=UTF-8',
'CCC-DDD': 'js-tool-big-box-demo-header'
}
const params = {
name: '经海路大白狗',
startDate: '2024-03-05',
endDate: '2024-04-05',
}
你看这些参数了吗?url就是下载文件需要的那个接口,如果是get请求呢,你就按照get形式把参数拼接上去,如果是post形式呢,你就需要后面的这个params变量做为入参数据。如果服务端需要headers呢,你就再将headers定义好,准备往过传。
调用实现
ajaxBox.downFileFetch(url, '相的约奶的茶.mp4', 'get', headers, dataParams);
你看到这个 downFileFetch 方法了吧,他也在 ajaxBox 对象下面。
第一个参数呢,表示服务端接口,如果是get请求呢,就把参数拼接上去;
第二个参数呢,表示下载后文件名,比如 down.pdf 这样;
第三个参数呢,默认是get请求,如果不想写get呢,你就写个null,但是你得写进去,如果服务端要求是个post请求呢,你就写post;
第四个参数呢,就是headers啦,服务端需要你就传过去,不需要你就写个null;
第五个参数呢,如果是psot请求,你就传入json对象过去,如果没有参数,你就不写也行,写个null也行。
我说:你这个工具库真是棒,js-tool-big-box,就是前端JS的一个大盒子啊。他说:是的,里面还有很多特别实用的方法,用了这个工具库后,前端项目可以少些很多公共方法,少引很多第三方库,很不错的。我也要离职了,你在公司就说这是你开发的。
我说:那我得请你吃饭啊。于是,我去买了一瓶茅台王子酒,花了260元,定了两份炒饼,花了30元。
等吃完,我说,你这个工具库可以啊,直接从我这里挣了290元。
他说:看你说的,酒你喝了一半,炒饼你吃了一份。我这顶多也就是145元啊。
看完不过瘾?这里有更全的js-tool-big-box使用指南哦,掘金链接直达(只会Vue的我,一入职就让用React,用了这个工具库,我依然高效 - 掘金 (juejin.cn))
最后告诉你个消息:
js-tool-big-box的npm地址是(js-tool-big-box 的npm地址)
js-tool-big-box的git仓库地址(js-tool-big-box的代码仓库地址)
来源:juejin.cn/post/7379524605104848946
掘金滑块验证码安全升级,继续破解
去年发过一篇文章,《使用前端技术破解掘金滑块验证码》,我很佩服掘金官方的气度,不但允许我发布这篇文章,还同步发到了官方公众号。最近发现掘金的滑块验证码升级了,也许是我那篇文章起到了一些作用,逼迫官方加强了安全性,这是一个非常好的现象。
不过,这并不是终点,我们还是可以继续破解。验证码的安全性是在用户体验和安全性之间的一个平衡,如果安全性太高,用户体验就会变差,如果用户体验太好,安全性就会变差。掘金的滑块验证码是一个很好的例子,它的安全性和用户体验之间的平衡做得非常好,并且我们破解的难度体验也非常好。 😄
本次升级的内容
掘金的滑块验证码升级了,主要有以下几个方面的改进:
- 首先验证码不再是掘金自己的验证码了,而是使用了字节的校验服务,可以看到弹窗是一个 iframe,并且域名是
bytedance.com
。
我们都知道掘金被字节收购了,可以猜测验证码的升级是字节跳动的团队做的。
- 验证码的图形不再是拼图,而是随机的不同形状,比如爱心、六角星、圆环、月亮、盾牌等。
- 增加了干扰缺口,主要是大小或旋转这种操作。
下面看一下改版后的滑块验证码:
我在文章的评论区看到了一些关于这次升级或相关的讨论:
本文将继续破解这次升级后的滑块验证码,看看这次升级对破解的难度有多大影响,如果你还没有了解过如何破解滑块验证码,请先看我之前的文章。
iframe
这次升级,整个滑块都掉用的是外部链接,使用 iframe 呈现,那么在 puppeteer 中如何处理呢?
await page.waitForSelector('iframe');
const elementHandle = await page.$('iframe');
const frame = await elementHandle.contentFrame();
实际上,我们只需要等待 iframe 加载完成,然后获取 iframe 的内容即可。
Frame 对象和 Page 对象有很多相似的方法,比如 frame.$
、frame.evaluate
等,我们可以直接使用这些方法来操作 iframe 中的元素。
验证码的识别
上一篇文章采用比较简单的判断方式,当时缺口处有明显的白边,所以只需要找到这个白边即可。
但是本次升级后,缺口不再是白边,而是阴影的效果,并且缺口的形状也不再是拼图,大概率都是曲线的边,所以再判断缺口的方式就不再适用了。
现在我们可以采用一种新的方式,通过对比滑块图片和缺口区域的像素值相似程度来判断缺口位置。
首先还是二值化处理,将图片转换为黑白两色:
可以看到左侧缺口和右侧缺口非常相似,只是做了一点旋转作为干扰。
再看一下,iframe 中还有一个很重要的东西,就是校验的图片:
它是一个 png 图片,所以我们可以把它也转换成二值化,简单的方式就是将透明色转换为白色,非透明色转换为黑色,如果想提高识别精度,可以与背景图一样,通过灰度、二值化的转换方式。
// 获取缺口图像
const captchaVerifyImage = document.querySelector(
'#captcha-verify_img_slide',
) as HTMLImageElement;
// 创建一个画布,将 image 转换成canvas
const captchaCanvas = document.createElement('canvas');
captchaCanvas.width = captchaVerifyImage.width;
captchaCanvas.height = captchaVerifyImage.height;
const captchaCtx = captchaCanvas.getContext('2d');
captchaCtx.drawImage(
captchaVerifyImage,
0,
0,
captchaVerifyImage.width,
captchaVerifyImage.height,
);
const captchaImageData = captchaCtx.getImageData(
0,
0,
captchaVerifyImage.width,
captchaVerifyImage.height,
);
// 将像素数据转换为二维数组,同样处理灰度、二值化,将像素点转换为0(黑色)或1(白色)
const captchaData: number[][] = [];
for (let h = 0; h < captchaVerifyImage.height; h++) {
captchaData.push([]);
for (let w = 0; w < captchaVerifyImage.width; w++) {
const index = (h * captchaVerifyImage.width + w) * 4;
const r = captchaImageData.data[index] * 0.2126;
const g = captchaImageData.data[index + 1] * 0.7152;
const b = captchaImageData.data[index + 2] * 0.0722;
if (r + g + b > 30) {
captchaData[h].push(0);
} else {
captchaData[h].push(1);
}
}
}
为了对比图形的相似度,二值化后的数据我们页采用二维数组的方式存储,这样可以方便的对比两个图形的相似度。
如果想观测二值化后的真是效果,可以把二位数组转换为颜色,并覆盖到原图上:
// 通过 captchaData 0 黑色 或 1 白色 的值,绘制到 canvas 上,查看效果
for (let h = 0; h < captchaVerifyImage.height; h++) {
for (let w = 0; w < captchaVerifyImage.width; w++) {
captchaCtx.fillStyle =
captchaData[h][w] == 1 ? 'rgba(0,0,0,0)' : 'black';
captchaCtx.fillRect(w, h, 1, 1);
}
}
captchaVerifyImage.src = captchaCanvas.toDataURL();
数据拿到后,我们可以开始对比两个图形的相似度,这里就采用非常简单的对比方式,从左向右,逐个像素点对比,横向每个图形的像素一致的点数量纪录下来,然后取最大值,这个最大值就是缺口的位置。
这里我们先优化一下要对比的数据,我们只需要对比缺口的顶部到底部这段的数据,截取这一段,可以减少对比的性能消耗。
// 获取captchaVerifyImage 相对于 .verify-image 的偏移量
const captchaVerifyImageBox = captchaVerifyImage.getBoundingClientRect();
const captchaVerifyImageTop = captchaVerifyImageBox.top;
// 获取缺口图像的位置
const imageBox = image.getBoundingClientRect();
const imageTop = imageBox.top;
// 计算缺口图像的位置,top 向上取整,bottom 向下取整
const top = Math.floor(captchaVerifyImageTop - imageTop);
// data 截取从 top 列到 top + image.height 列的数据
const sliceData = data.slice(top, top + image.height);
然后循环对比两个图形的像素点,计算相似度:
// 循环对比 captchaData 和 sliceData,从左到右,每次增加一列,返回校验相同的数量
const equalPoints = [];
// 从左到右,每次增加一列
for (let leftIndex = 0; leftIndex < sliceData[0].length; leftIndex++) {
let equalPoint = 0;
// 新数组 sliceData 截取 leftIndex - leftIndex + captchaVerifyImage.width 列的数据
const compareSliceData = sliceData.map((item) =>
item.slice(leftIndex, leftIndex + captchaVerifyImage.width),
);
// 循环判断 captchaData 和 compareSliceData 相同值的数量
for (let h = 0; h < captchaData.length; h++) {
for (let w = 0; w < captchaData[h].length; w++) {
if (captchaData[h][w] === compareSliceData[h][w]) {
equalPoint++;
}
}
}
equalPoints.push(equalPoint);
}
// 找到最大的相同数量,大概率为缺口位置
return equalPoints.indexOf(Math.max(...equalPoints));
对比时像素较多,不容易直接看到效果,这里写一个简单的二位数组对比,方便各位理解:
[
[0, 1, 0],
[1, 0, 1],
[0, 1, 0],
]
[
[0, 0, 0, 1, 0, 0],
[0, 0, 1, 0, 1, 0],
[0, 0, 0, 1, 0, 0],
]
循环对比,那么第3列开始,匹配的数量可以达到9,所以返回 3,这样就是滑块要移动的位置。
干扰缺口其实对我们这个识别方式没什么影响,最多可能会增加一些失败的概率,我个人测试了一下,识别成功率有 95% 左右。
总结
这次升级后,掘金的滑块验证码的安全性有了一定的提升,还是可以继续破解的,只是难度有所增加。最后再奉劝大家不要滥用这个技能,这只是为了学习和研究,不要用于非法用途。如果各位蹲局子,可不关我事啊。 🤔️
来源:juejin.cn/post/7376276140595888137
队友升职,被迫解锁 Jenkins(所以,前端需要学习Jenkins吗?🤔)
入坑 Jenkins
作为一个前端,想必大家都会有这个想法:“Jenkins 会用就行了,有啥好学的”。
我一直都是这么想的,不就会点个开始构建
就行了嘛!
可是碰巧我们之前负责 Jenkins 的前端同事升了职,碰巧这个项目组就剩了两个人,碰巧我比较闲,于是这个“活”就落在我的头上了。
压力一下就上来了,一点不懂 Jenkins 可咋整?
然而现实是没有一点儿压力。
刚开始的时候挺轻松,也就是要发版的流程到我这了,我直接在对应项目上点击开始构建
,so easy!可是某一天,突然遇到一个 bug:我们每次 web 端项目发完后,桌面端的 hybrid 包需要我手动改 OSS 上配置文件的版本号,正巧那天忘记更新版本号了,导致桌面端应用本地的 hybrid 没有更新。。。
领导:你要不就别手动更新了,弄成自动化的
我:😨 啊!什么,我我我不会,是不可能的
小弟我之前没有接触过 Jenkins,看着那一堆配置着实有点费脑,于是就只能边百度学习边输出,从 Jenkins 安装开始到配置不同类型的构建流程,踩过不少坑,最后形成这篇文章。如果有能帮到大家的点,我就很开心了,毕竟我也是刚接触的!
说说我经历过的前端部署流程
按照我的经历,我把前端部署流程分为了以下几个阶段:即原始时代 -> 脚本化时代 -> CI/CD 时代。
原始时代
最开始的公司运维是一个小老头,他只负责管理服务器资源,不管各种项目打包之类的。我们就只能自己打包,再手动把构建的文件丢到服务器上。
整体流程就是:本地合并代码 --> 本地打包 --> 上传服务器;
上传服务器可以分为这几个小步骤:打开 xshell --> 连接服务器 --> 进入 tomcat 目录 --> 通过 ftp 上传本地文件。
可能全套下来需要 5 分钟左右。
脚本化时代
为了简化,我写了一个 node 脚本,通过ssh2-sftp-client
将上传服务器
这一步骤脚本化:
const chalk = require('chalk')
const path = require('path')
const fs = require('fs')
const Client = require('ssh2-sftp-client')
const sftp = new Client()
const envConfig = require('./env.config')
const defalutConfig = {
port: '22',
username: 'root',
password: '123',
localStatic: './dist.tar.gz',
}
const config = {
...defalutConfig,
host: envConfig.host,
remoteStatic: envConfig.remoteStatic,
}
const error = chalk.bold.red
const success = chalk.bold.green
function upload(config, options) {
if (!fs.existsSync('./dist') && !fs.existsSync(options.localStatic)) {
return
}
// 标志上传dist目录
let isDist = false
sftp
.connect(config)
.then(() => {
// 判断gz文件存在时 上传gz 不存在时上传dist
if (fs.existsSync(options.localStatic)) {
return sftp.put(options.localStatic, options.remoteStatic)
} else if (fs.existsSync('./dist')) {
isDist = true
return sftp.uploadDir('./dist', options.remoteStatic.slice(0, -12))
}
})
.then(() => {
sftp.end()
if (!isDist) {
const { Client } = require('ssh2')
const conn = new Client()
conn
.on('ready', () => {
// 远程解压
const remoteModule = options.remoteStatic.replace('dist.tar.gz', '')
conn.exec(
`cd ${remoteModule};tar xvf dist.tar.gz`,
(err, stream) => {
if (err) throw err
stream
.on('close', (code) => {
code === 0
conn.end()
// 解压完成 删除本地文件
fs.unlink(options.localStatic, (err) => {
if (err) throw err
})
})
.on('data', (data) => {})
}
)
})
.connect(config)
}
})
.catch((err) => {
sftp.end()
})
}
// 上传文件
upload(config, {
localStatic: path.resolve(__dirname, config.localStatic), // 本地文件夹路径
remoteStatic: config.remoteStatic, // 服务器文件夹路径器
})
最后只要通过执行yarn deploy
即可实现打包并上传,用了一段时间,队友也都觉得挺好用的,毕竟少了很多手动操作,效率大大提升。
CI/CD 时代
不过用了没多久后,来了个新的运维小年轻,一上来就整了个 Jenkins ,取代了我们手动打包的过程,只要我们点击部署就可以了,当时就感觉 Jenkins 挺方便的,但又觉得和前端没多大关系,也就没学习。
不过也挺烦
Jenkins 的,为啥呢?
当时和测试说的最多的就是“我在我这试试.....我这没问题啊,你刷新一下”,趁这个时候,赶紧打包重新部署下。有了 Jenkins 后,打包都有记录了,测试一看就知道我在哄她了 🙄
Jenkins 解决了什么问题
我觉得在了解一个新事物前,应该先了解下它的出现解决了什么问题。
以我的亲身经历来看,Jenkins 的出现使得 拉取代码 -> 打包 -> 部署 -> 完成后工作(通知、归档、上传CDN等)
这一繁琐的流程不需要人为再去干预,一键触发 🛫。
只需要点击开始构建即可,如何你觉得还得每次打开 jenkins 页面去点击构建,可以通过设置代码提交到 master 或合并代码时触发构建,这样就不用每次手动去点击构建了,省时更省力 🚴🏻♂️。
Jenkins 部署
Jenkins 提供了多种安装方式,我的服务器是 Centos,按照官方教程进行部署即可。
官方提供两种方式进行安装:
方式一:
sudo wget -O /etc/yum.repos.d/jenkins.repo https://pkg.jenkins.io/redhat-stable/jenkins.repo
sudo rpm --import https://pkg.jenkins.io/redhat-stable/jenkins.io.key
yum install jenkins
方式二:
直接下载 rpm 包进行安装,地址:mirrors.jenkins-ci.org/redhat/
wget https://pkg.jenkins.io/redhat/jenkins-2.449-1.1.noarch.rpm
rpm -ivh jenkins-2.449-1.1.noarch.rpm
安装过程
我是使用方式二进行安装的,来看下具体过程。
首先需要安装 jdk17 以上的版本
- 下载对应的 jdk
wget https://download.oracle.com/java/17/latest/jdk-17_linux-x64_bin.tar.gz
- 解压并放到合适位置
tar xf jdk-17_linux-x64_bin.tar.gz
mv jdk-17.0.8/ /usr/lib/jvm
- 配置 Java 环境变量
vim /etc/profile
export JAVA_HOME=/usr/lib/jvm/jdk-17.0.8
export CLASSPATH=$JAVA_HOME/lib:$JRE_HOME/lib:$CLASSPATH
export PATH=$JAVA_HOME/bin:$JRE_HOME/bin:$PATH
- 验证
java -version
接着安装 Jenkins,需要注意:Jenkins 一定要安装最新版本,因为插件要求最新版本,最新的 2.449。
- 下载 rpm 包
cd /usr/local/jenkins
wget https://mirrors.jenkins-ci.org/redhat/jenkins-2.449-1.1.noarch.rpm
- 安装 Jenkins
rpm -ivh jenkins-2.449-1.1.noarch.rpm
- 启动 Jenkins
systemctl start jenkins
你以为就这么简单?肯定会报错的,通过百度报错信息,报错原因是:Java 环境不对,百度到的解决方法:
修改/etc/init.d/jenkins
文件,添加 JDK,但是目录下并没有这个文件,继续百度得知:
使用 systemctl
启动 jenkins 时,不会使用 etc/init.d/jenkins
配置文件,而是使用 /usr/lib/systemd/system/jenkins.service
文件。
于是修改:
vim /usr/lib/systemd/system/jenkins.service
搜索 Java,找到上面这一行,打开注释,修改为对应的 JDK 位置:
Environment="JAVA_HOME=/usr/lib/jvm/jdk-17.0.10"
重新启动 Jenkins:
systemctl restart jenkins
查看启动状态,出现如下则说明 Jenkins 启动完成:
接着在浏览器通过 ip:8090
访问,出现如下页面,说明安装成功。
此时需要填写管理员密码,通过 cat /var/lib/jenkins/secrets/initialAdminPassword
即可获取。
Jenkins 配置
出现上述界面,填写密码成功后等待数秒,即可出现如下界面:
选择 安装推荐的插件
这个过程稍微有点慢,可以整理整理文档,等待安装完成。
安装完成后,会出现此页面,需要创建一个管理员用户。
点击开始使用 Jenkins,即可进入 Jenkins 首页。
至此,Jenkins 安装完成 🎉🎉🎉。
安装过程遇到的问题
- 没有经验第一次安装,参考网上文档推荐的是 JDK8,结果安装的 Jenkins 至少需要 JDK 11,导致安装失败;
- 第二次安装,按照网上的文档安装,不是最新版本,导致部分插件安装失败;
- 配置修改问题
- Jenkins 默认的配置文件位于
/usr/lib/systemd/system/jenkins.service
- 默认目录安装在
/var/lib/jenkins/
- 默认工作空间在
/var/lib/jenkins/workspace
- Jenkins 默认的配置文件位于
- 修改端口号为
8090
vim /usr/lib/systemd/system/jenkins.service
修改
Environment="JENKINS_PORT=8090"
,修改完后执行:
systemctl daemon-reload
systemctl restart jenkins
如何卸载 Jenkins
安装过程遇到了不少坑,基本都是卸载了重新安装,于是就总结了以下卸载的命令。
# 查找是否存在 Jenkins 安装包
rpm -ql jenkins
# 卸载 Jenkins
rpm -e jenkins
# 再次查看 此时会提示:未安装软件包 jenkins
rpm -ql jenkins
# 删除所有 Jenkins 相关目录
find / -iname jenkins | xargs -n 1000 rm -rf
Jenkins 版本更新
Jenkins 发布版本很频繁,基本为一周一次,参考 Jenkins 更新
项目创建
点击 + 新建Item
,输入名称,选择类型:
有多种类型可供选择,这里我们主要讲这两种:Freestyle project 和 Pipeline。
Freestyle project
选择这种类型后,就可以通过各种 web 表单(基础信息、源码、构建步骤等),配置完整的构建步骤,对于新手来说,易上手且容易理解,如果第一次接触,创建项目就选择 Freestyle project 即可。
总共有以下几个环节需要配置:
- General
- 源码管理
- 构建触发器
- 构建环境
- Build Steps
- 构建后操作
此时我们点击 OK,创建完如下所示都是空白的,也可以通过创建时的复制
选项,复制之前项目的配置:
接着就如同填写表单信息,一步步完成构建工作。
General
项目基本信息也就是对所打包项目的描述信息:
比如描述这里,可以写项目名称、描述、输出环境等等。
Discard old builds 丢弃旧的构建
可以理解为清初构建历史,Jenkins 每打包一次就会产生一个构建历史记录,在构建历史
中可以看到从第一次到最新的构建信息,这会导致磁盘空间消耗。
点击配置名称或勾选,会自动展开配置项。这里我们可以设置保持构建的最大个数
为5
,则当前项目的构建历史记录只会保留最新的 5 个,并自动删除掉最老的构建。
这个可以按照自己的需求来设置,比如保留 7 天的构建记录或保留最多 100 个构建记录。
Jenkins 的大多数配置都有 高级
选项,在高级选项中可以做更详细的配置。
This project is parameterized
可以理解为此构建后续过程可能用到的参数,可以是手动输入或选项等,如:git 分支、构建环境、特定的配置等等。通过这种方式,项目可以更加灵活和可配置,以适应不同的构建需求和环境。
默认有 8 种参数类型:
- Boolean Parameter: checkbox 选择,如果需要设置 true/false 值时,可以添加此参数类型
- Choice Parameter:选择,多个选项
- Credentials Parameter:账号证书等参数
- File Parameter:文件上传
- Multi-line String parameters:多行文本参数
- Password Parameter:密码参数
- Run Parameter:用于选择执行的 job
- String Parameter:单行文本参数
Git Parameter
需要在 系统管理 -> 插件管理
搜索 Git Parameter
插件进行安装,安装完成后重启才会有这个参数。
通过 添加参数
来设置后续会用到的参数,比如设置名称为 delopyTag
的 Git Parameter
参数来指定要构建的分支,设置名称为 DEPLOYPATH
的 Choice Parameter
参数来指定部署环境等等。
源码管理
Repositories
一般公司项目都是从 gitlab 上拉代码,首先设置 Repository URL
,填写 git 仓库地址,比如:https://gitlab.com/xxx/xxx.git
填写完后会报错如下:
可以通过添加 Credentials 凭证解决,在 Jenkins 中,Git 的 Credentials 是用于访问 Git 仓库的认证信息,这些凭据可以是用户名和密码、SSH 密钥或其他认证机制,以确保 Jenkins 能够安全的与 Git 仓库进行交互,即构建过程中自动拉取代码、执行构建任务等。
方式一:在当前页面填写帐号、密码
选择添加 -> Jenkins -> 填写 git 用户名、密码
等信息生成一个新的 Credentials,然后重新选择我们刚刚添加的 Credentials,报错信息自动消失
这样添加会有一个问题,就是如果有多个项目时,每次都需要手动填写 Git 账户和密码信息。
方式二:Jenkins 全局凭证设置
在 Global Credentials 中设置全局的凭证。
然后在项目中配置时可以直接选择我们刚刚添加的 Credentials,报错信息自动消失。
Branches to build
这里构建的分支,可以设置为我们上面设置的 delopyTag
参数,即用户自己选择的分支进行构建。
构建触发器
特定情况下出发构建,如定时触发、代码提交或合并时触发、其他任务完成时触发等。
如果没有特殊的要求时,这一步完全可以不用设置,在需要构建时我们只需要手动点击开始构建即可。
构建环境
构建环境是在构建开始之前的准备工作,如清除上次构建、指定构建工具、设置 JDK 、Node 版本、生成版本号等。
Provide Node & npm bin/folder to PATH
默认是没有这一项的,但前端部署需要 Node 环境支持,所以需要在 系统管理 -> 插件管理
搜索 nodejs
插件进行安装,安装完成后重启才会展示这项配置。
但此时还是不能选择的,需要在 系统管理 -> 全局工具配置
中先安装 NodeJs,根据不同环境配置,可同时安装多个 NodeJs 版本。
之后在 Provide Node
处才有可供选择的 Node 环境。
Create a formatted version number
这个就是我用来解决了一开始问题的配置项,也就是把每次打包的结果上传到 OSS 服务器上时生成一个新的版本号,在 Electron 项目中通过对比版本号,自动更新对应的 hybrid 包,领导都爱上我了 😜。
首先需要安装插件 Version Number Plugin
,在 系统管理 -> 插件管理
中搜索安装,然后重启 Jenkins 即可
- Environment Variable Name
类似于第一步的构建参数,可以在其他地方使用。
- Version Number Format String
用于设置版本号的格式,如
1.x.x
,Jenkins 提供了许多内置的环境变量:
- BUILD_DAY:生成的日期
- BUILD_WEEK:生成年份中的一周
- BUILD_MONTH:生成的月份
- BUILD_YEAR:生成的年份
- BUILDS_TAY:在此日历日期完成的生成数
- BUILDS_THIS_WEEK:此日历周内完成的生成数
- BUILDS_THIS_MONTH:此日历月内完成的生成数
- BUILDS_THIS_YEAR:此日历年中完成的生成数
- BUILDS_ALL_TIME:自项目开始以来完成的生成数
- 勾选 Build Display Name Use the formatted version number for build display name 后
此时每次构建后就会生成一个个版本号:
- 把这个参数传递到后续的 OSS 上传的 Shell 脚本中即可。
如果想要重置版本号,只要设置Number of builds since the start of the project
为 0 即可,此时就会从 1.7.0
重新开始。
Build Steps
这是最为重要的环节,主要用于定义整个构建过程的具体任务和操作,包括执行脚本、编译代码、打包应用等。
我们可以通过 Shell 脚本来完成前端项目常见的操作:安装依赖、打包、压缩、上传到 OSS 等。
点击 增加构建步骤 -> Execute shell
,在上方输入 shell 脚本,常见的如下:
#环境变量
echo $PATH
#node版本号
node -v
#npm版本号
npm -v
#进入jenkins workspace的项目目录
echo ${WORKSPACE}
cd ${WORKSPACE}
#下载依赖包
yarn
#开始打包
yarn run build
#进入到打包目录
cd dist
#删除上次打包生成的压缩文件
rm -rf *.tar.gz
#上传oss,如果没有需要可删除此段代码
ossurl="xxx"
curl "xxx" > RELEASES.json
node deploy-oss.cjs -- accessKeyId=$OSS_KEY accessKeySecret=$OSS_SECRET zipDir=tmp.zip ossUrl=xxx/v${BUILD_VERSION}.zip
node deploy-oss.cjs -- accessKeyId=$OSS_KEY accessKeySecret=$OSS_SECRET zipDir=RELEASES.json ossUrl=xxx/RELEASES.json
#把生成的项目打包成压缩包方便传输到远程服务器
tar -zcvf `date +%Y%m%d%H%M%S`.tar.gz *
#回到上层工作目录
cd ../
构建后操作
通过上面的构建步骤,我们已经完成了项目的打包,此时我们需要执行一些后续操作,如部署应用、发送通知、触发其他 Job等操作。
Send build artifacts over SSH
通过 Send build artifacts over SSH,我们可以将构建好的产物(一般是压缩后的文件)通过 ssh 发送到指定的服务器上用于部署,比如 Jenkins 服务器是 10.10,需要将压缩文件发送到 10.11 服务器进行部署,需要以下步骤:
- 安装插件
在
系统管理 -> 插件管理
中搜索插件Publish over SSH
安装,用于处理文件上传工作; - 配置服务器信息
在
系统管理 -> System
中搜索Publish over SSH
进行配置。
需要填写用户名、密码、服务器地址等信息,完成后点击
Test Configuration
,如果配置正确,会显示Success
,否则会出现报错信息。
这里有两种方式连接远程服务器,第一种是密码方式,输入服务器账户密码等信息即可;
第二种是秘钥方式,在服务器生成密钥文件,并且将私钥全部拷贝,记住是全部,要携带起止标志-----BEGIN RSA PRIVATE KEY-----或-----END RSA PRIVATE KEY----,粘贴在
高级 -> key
即可。
此处的
Remote Directory
是远程服务器接收 Jenkins 打包产物的目录,必须在对应的服务器手动创建目录,如/home/jenkins
。 - 项目配置
选择需要上传的服务器,接着设置需要传输的文件,执行脚本,移动文件到对应的目录。
Transfer Set 参数配置
Source files
:需要传输的文件,也就是通过上一步 Build Steps 后生成的压缩文件,这个路径是相对于“工作空间”的路径,即只需要输入dist/*.tar.gz
即可Remove prefix
:删除传输文件指定的前缀,如Source files
设置为dist/*.tar.gz
,此时设置Remove prefix
为/dist
,移除前缀,只传输*.tar.gz
文件;如果不设置酒会传输dist/*.tar.gz
包含了 dist 整个目录,并且会自动在上传后的服务器中创建/dist
这个路径。如果只需要传输压缩包,则移除前缀即可Remote directory
:文件传输到远程服务器上的具体目录,会与 Publish over SSH 插件系统配置中的Remote directory
进行拼接,如我们之前设置的目录是/home/jenkins
,此处在写入qmp_pc_ddm
,那么最终上传的路径为/home/jenkins/qmp_pc_ddm
,与之前不同的是,如果此路径不存在时会自动创建,这样设置后,Jenkins 服务器构建后的产物会通过 ssh 上传到此目录,供下一步使用。Exec command
文件传输完成后执行自定义 Shell 脚本,比如移动文件到指定目录、解压文件、启动服务等。
#!/bin/bash
#进入远程服务器的目录
project_dir=/usr/local/nginx/qmp_pc_ddm/${DEPLOYPATH}
cd $project_dir
#移动压缩包
sudo mv /home/jenkins/qmp_pc_ddm/*.tar.gz .
#找到新的压缩包
new_dist=`ls -ltr *.tar.gz | awk '{print $NF}' |tail -1`
echo $new_dist
#解压缩
sudo tar -zxvf $new_dist
#删除压缩包
sudo rm *.tar.gz
这一步可以使用之前定义的参数,如
${DEPLOYPATH}
,以及 Jenkins 提供的变量:如${WORKSPACE}
来引用 Jenkins 的工作空间路径等。
Build other projects
添加 Build other projects,在项目构建成功后,触发相关联的应用开始打包。
另外还可以配置企业微信通知、生成构建报告等工作。
此时,所有的配置都设置完成,我们点击保存
配置,返回到构建页。
构建
点击 Build with parameters
选择对应的分支和部署环境,点击开始构建
在控制台输出中,可以看到打包的详细过程,
可以看到我们在Build Steps
中执行的 Shell 脚本的输出如下:
以及我们通过 Publish Over SSH 插件将构建产物传输的指定服务器的输出:
最终需要部署的服务器就有了以下文件:
Pipeline
对于简单的构建需求或新手用户来说,我们可以直接选择 FreeStyle project。而对于复杂的构建流程或需要更高灵活性和扩展性的场景来说,Pipeline 则更具优势。
通过 新建任务 -> 流水线
创建一个流水线项目。
开始配置前请先阅读下流水线章节。
生成方式
首先,Jenkins 流水线是一套插件,在最开始的插件推荐安装时会自动安装,如果选择自定义安装时,需要手动安装这一套插件。
Jenkins 流水线的定义有两种方式:Pipeline script
和 Pipeline script from SCM
。
Pipeline script
Pipeline script 是直接在 Jenkins 页面的配置中写脚本,可直接定义和执行,比较直观。
Pipeline script from SCM
Pipeline script from SCM 是将脚本文件和项目代码放在一起,即 Jenkinsfile
,也可自定义名称。
当 Jenkins 执行构建任务时,会从 git 中拉取该仓库到本地,然后读取 Jenkinsfile
的内容执行相应步骤,通常认为在 Jenkinsfile
中定义并检查源代码控制是最佳实践。
当选择 Pipeline script from SCM
后,需要设置 SCM 为 git
,告诉 Jenkins 从指定的 Git 仓库中拉取包含 Pipeline 脚本的文件。
如果没有对应的文件时,任务会失败并发出报错信息。
重要概念
了解完上面的基础配置,我们先找一段示例代码,粘贴在项目的配置中:
pipeline {
agent any
stages {
stage('Build') {
steps {
echo 'Build'
}
}
stage('Test') {
steps {
echo 'Test'
}
}
stage('Deploy') {
steps {
echo 'Deploy'
}
}
}
}
看下它的输出结果:
接着看一下上面语法中几个重要的概念。
流水线 pipline
定义了整个项目的构建过程, 包括:构建、测试和交付应用程序的阶段。
流水线顶层必须是一个 block,pipeline{},作为整个流水线的根节点,如下:
pipeline {
/* insert Declarative Pipeline here */
}
节点 agent
agent 用来指定在哪个代理节点上执行构建,即执行流水线,可以设置为 any
,表示 Jenkins 可以在任何可用的代理节点上执行构建任务。
但一般在实际项目中,为了满足更复杂的构建需求,提高构建效率和资源利用率,以及确保构建环境的一致性,会根据项目的具体需求和资源情况,设置不同的代理节点来执行流水线。
如:
pipeline {
agent {
node {
label 'slave_2_34'
}
}
...
}
可以通过 系统管理 -> 节点列表
增加节点,可以看到默认有一个 master 节点,主要负责协调和管理整个 Jenkins 系统的运行,包括任务的调度、代理节点的管理、插件的安装和配置等。
阶段 stage
定义流水线的执行过程,如:Build、Test 和 Deploy,可以在可视化的查看目前的状态/进展。
注意:参数可以传入任何内容。不一定非得 Build
、Test
,也可以传入 打包
、测试
,与红框内的几个阶段名对应。
步骤 steps
执行某阶段具体的步骤。
语法
了解上述概念后,我们仅仅只能看懂一个 Pipeline script 脚本,但距离真正的动手写还有点距离,此时就需要来了解下流水线语法。
我将上面通过 Freestyle project 的脚本翻译成 Pipeline script 的语法:
pipeline {
agent any
triggers {
gitlab(triggerOnPush: true, triggerOnMergeRequest: true, branchFilterType: 'All')
}
parameters {
gitParameter branchFilter: 'origin/(.*)', defaultValue: 'master', name: 'delopyTag', type: 'PT_BRANCH'
}
stages {
stage('拉取代码') {
steps {
git branch: "${params.delopyTag}", credentialsId: 'xxx', url: 'https://xxx/fe/qmp_doc_hy.git'
}
}
stage('安装依赖') {
steps {
nodejs('node-v16.20.2') {
sh '''
#!/bin/bash
source /etc/profile
echo "下载安装包"
yarn config set registry https://registry.npmmirror.com
yarn
'''
}
sleep 5
}
}
stage('编译') {
steps {
sh '''
#!/bin/bash
source /etc/profile
yarn run build
sleep 5
if [ -d dist ];then
cd dist
rm -rf *.tar.gz
tar -zcvf `date +%Y%m%d%H%M%S`.tar.gz *
fi
'''
sleep 5
}
}
stage('解压') {
steps {
echo '解压'
sshPublisher(
publishers: [
sshPublisherDesc(
configName: 'server(101.201.181.27)',,
transfers: [
sshTransfer(
cleanRemote: false,
excludes: '',
execCommand: '''#!/bin/bash
#进入远程服务器的目录
project_dir=/usr/local/nginx/qmp_pc_ddm_${DEPLOYPATH}/${DEPLOYPATH}
if [ ${DEPLOYPATH} == "ddm" ]; then
project_dir=/usr/local/nginx/qmp_pc_ddm/dist
fi
cd $project_dir
sudo mv /home/jenkins/qmp_pc_ddm/*.tar.gz .
#找到新的压缩包
new_dist=`ls -ltr *.tar.gz | awk \'{print $NF}\' |tail -1`
#解压缩
sudo tar -zxvf $new_dist
#删除压缩包
sudo rm *.tar.gz
#发布完成
echo "环境发布完成"
''',
execTimeout: 120000,
flatten: false,
makeEmptyDirs: false,
noDefaultExcludes: false,
patternSeparator: '[, ]+',
remoteDirectory: 'qmp_pc_ddm',
remoteDirectorySDF: false,
removePrefix: 'dist/',
sourceFiles: 'dist/*.tar.gz'
)
],
usePromotionTimestamp: false,
useWorkspaceInPromotion: false,
verbose: false
)
]
)
}
}
}
post {
success {
echo 'success.'
deleteDir()
}
}
}
接下来,我们一起来解读下这个文件。
首先,所有的指令都是包裹在 pipeline{}
块中,
agent
enkins 可以在任何可用的代理节点上执行构建任务。
environment
用于定义环境变量,它们会保存为 Groovy 变量和 Shell 环境变量:定义流水线中的所有步骤可用的环境变量 temPath
,在后续可通过 $tmpPath
来使用;
环境变量可以在全局定义,也可在 stage 里单独定义,全局定义的在整个生命周期里可以使用,在 stage 里定义的环境变量只能在当前步骤使用。
Jenkins 有一些内置变量也可以通过 env 获取(env 也可以读取用户自己定义的环境变量)。
steps {
echo "Running ${env.BUILD_ID} on ${env.JENKINS_URL}"
}
这些变量都是 String 类型,常见的内置变量有:
- BUILD_NUMBER:Jenkins 构建序号;
- BUILD_TAG:比如 jenkins-{BUILD_NUMBER};
- BUILD_URL:Jenkins 某次构建的链接;
- NODE_NAME:当前构建使用的机器
parameters
定义流水线中可以接收的参数,如上面脚本中的 gitParameter,只有安装了 Git Parameters 插件后才能使用,name 设置为delopyTag
,在后续可通过 ${params.delopyTag}
来使用;
还有以下参数类型可供添加:
parameters {
booleanParam(name: 'isOSS', defaultValue: true, description: '是否上传OSS')
choice(name: 'select', choices: ['A', 'B', 'C'], description: '选择')
string(name: 'temp', defaultValue: '/temp', description: '默认路径')
text(name: 'showText', defaultValue: 'Hello\nWorld', description: '')
password(name: 'Password', defaultValue: '123', description: '')
}
triggers
定义了流水线被重新触发的自动化方法,上面的配置是:当 Git 仓库有新的 push 操作时触发构建
stages 阶段
- 阶段一:拉取代码
git:拉取代码,参数
branch
为分支名,我们使用上面定义的${params.delopyTag}
,credentialsId
以及url
,如果不知道怎么填,可以在流水线语法 -> 片段生成器
中填写对应信息后,自动生成,如下:
再复制到此处即可。
- 阶段二:安装依赖
在
steps
中,sh
是 Jenkins pipeline 的语法,通过它来执行 shell 脚本。
#!/bin/bash
表示使用 bash 脚本;
source /etc/profile
用于将指定文件中的环境变量和函数导入当前 shell。
执行
yarn
安装依赖。 - 阶段三:编译
执行
yarn build
打包,
if [ -d dist ];
是 shell 脚本中的语法,用于测试dist
目录是否存在,通过脚本将打包产物打成一个压缩包。 - 阶段四:解压
将上步骤生成的压缩包,通过
Publish over SSH
发送到指定服务器的指定位置,执行 Shell 命令解压。
不会写
Publish over SSH
怎么办?同样,可以在流水线语法 -> 片段生成器
中填写对应信息后,自动生成,如下:
post
当流水线的完成状态为 success
,输出 success。
deleteDir() 函数用于删除当前工作目录中的所有文件和子目录。这通常用于清理工作区,确保在下一次构建之前工作区是干净的,以避免由于残留文件或目录引起的潜在问题。
构建看看效果
可以直接通过 Console Output
查看控制台输出,当然在流水线项目中自然要通过流水线去查看了。
- 效果一
Pipeline Overview 中记录了每个步骤的执行情况、开始时间和耗时等信息,但是没有详细信息,详细信息就要在 Pipeline Console 中进行查看。
- 效果二
安装插件
Blue Ocean
,相当于同时结合了 Pipeline Overview 和 Pipeline Console,可以同时看到每个步骤的执行情况等基本信息,以及构建过程中的详细信息。
通过 Blue Ocean 也可以直接创建流水线,选择代码仓库,然后填写对应的字段,即可快速创建流水线项目,如创建 gitlab 仓库:
或者直接连接 github 仓库,需要 token,直接点击红框去创建即可:
通过项目中的 Jenkinsfile 构建
再把对应的 Pipeline script 代码复制到对应代码仓库的 Jenkinsfile
文件,设置为 Pipeline script from SCM,填写 git 信息。
正常情况下,Jenkins 会自动检测代码仓库的 Jenkinsfile
文件,如果选择的文件没有 Jenkinsfile 文件时就会报错,如下:
正常按照流水线的执行流程,打开 Blue Ocean,查看构建结果,如下:
片段生成器
如果你觉得上述代码手写麻烦,刚开始时又不会写,那么就可以使用片段代码生成器来帮助我们生成流水线语法。
进入任务构建页面,点击 流水线语法
进入:
配置构建过程遇到的问题
- Jenkins 工作空间权限问题
修复:
chown -R jenkins:jenkins /var/lib/jenkins/workspace
- Git Parameters 不显示问题
当配置完 Git Parameters 第一次点击构建时,会报如下错误,找了很久也没有找到解决方法,于是就先使用 master 分支构建了一次,构建完成之后再次点击构建这里就正常显示了,猜测是没构建前没有 git 仓库的信息,构建完一次后就有了构建信息,于是就正常显示了。
总结
本文对 Jenkins 的基本教程就到此为止了,主要讲了 Jenkins 的安装部署,FreeStyle project 和 Pipeline 的使用,以及插件安装、配置等。如果想要学,跟着我这个教程实操一遍,Jenkins 就基本掌握了,基本工作中遇到的问题都能解决,剩下的就只能在实际工作中慢慢摸索了。
再说回最初的话题,前端需不需要学习 Jenkins。我认为接触新的东西,然后学习并掌握,拓宽了技术面,虽然是一种压力,也是得到了成长的机会,在这个前端技术日新月异的时代,前端们不仅要熟练掌握前端技术,还需要具备一定的后端知识和自动化构建能力,才能不那么容易被大环境淘汰。
以上就是本文的全部内容,希望这篇文章对你有所帮助,欢迎点赞和收藏 🙏,如果发现有什么错误或者更好的解决方案及建议,欢迎随时联系。
来源:juejin.cn/post/7349561234931515433
Nest:常用 15 个装饰器知多少?
nest 很多功能基于装饰器实现,我们有必要好好了解下有哪些装饰器:
创建 nest 项目:
nest new all-decorator -p npm
@Module({})
这是一个类装饰器,用于定义一个模块。
模块是 Nest.js 中组织代码的单元,可以包含控制器、提供者等:
@Controller() 和 @Injectable()
这两个装饰器也是类装饰器,前者控制器负责处理传入的请求和返回响应,后者定义一个服务提供者,可以被注入到控制器或其他服务中。
通过 @Controller
、@Injectable
分别声明 controller 和 provider:
@Optional、@Inject
创建可选对象(无依赖注入),可以用 @Optional
声明一下,这样没有对应的 provider 也能正常创建这个对象。
注入依赖也可以用 @Inject 装饰器。
@Catch
filter 是处理抛出的未捕获异常,通过 @Catch
来指定处理的异常:
@UseXxx、@Query、@Param
使用 @UseFilters 应用 filter 到 handler 上:
除了 filter 之外,interceptor、guard、pipe 也是这样用:
@Body
如果是 post、put、patch** **请求,可以通过 @Body 取到 body 部分:
我们一般用 dto 定义的 class 来接收验证请求体里的参数。
@Put、@Delete、@Patch、@Options、@Head
@Put、@Delete、@Patch、@Options、@Head 装饰器分别接受 put、delete、patch、options、head 请求:
@SetMetadata
通过 @SetMetadata
指定 metadata,作用于 handler 或 class
然后在 guard 或者 interceptor 里取出来:
@Headers
可以通过 @Headers 装饰器取某个请求头或者全部请求头:
@Ip
通过 @Ip 拿到请求的 ip,通过 @Session 拿到 session 对象:
@HostParam
@HostParam 用于取域名部分的参数。
下面 host 需要满足 xxx.0.0.1 到这个 controller,host 里的参数就可以通过 @HostParam 取出来:
@Req、@Request、@Res、@Response
前面取的这些都是 request 里的属性,当然也可以直接注入 request 对象:
@Req 或者 @Request 装饰器,这俩是同一个东西。
使用 @Res 或 @Response 注入 response 对象,但是注入 response 对象之后,服务器会一直没有响应。
因为这时候 Nest 就不会把 handler 返回值作为响应内容了。我们可以自己返回响应:
Nest 这么设计是为了避免相互冲突。
如果你不会自己返回响应,可以设置 passthrough 为 true 告诉 Nest:
@Next
除了注入 @Res 不会返回响应外,注入 @Next 也不会。
当你有两个 handler 来处理同一个路由的时候,可以在第一个 handler 里注入 next,调用它来把请求转发到第二个 handler。
@HttpCode
handler 默认返回的是 200 的状态码,你可以通过 @HttpCode 修改它:
@Header
当然,你也可以修改 response header,通过 @Header 装饰器:
来源:juejin.cn/post/7340554546253611023
还在使用 iconfont,上传图标审核好慢,不如自己做一个
之前使用 iconfont 是非常方便的,上传之后立马生效,项目里面直接引用即可,但是现在因为政策的收紧,每次上传图标都要等待十几分钟二十分钟的审核时间,这怎么能忍,有这个时间我都能写一个页面了好吧。
忍受不了就自己做,说干就干,于是我写了一个 svg 转图标字体的脚手架,所有的内容都自己维护,不再受制于人,感觉就是爽。
svg2font: 一个高效的 SVG 图标字体生成工具
在现代 Web 开发中,使用图标是一种常见的做法。图标不仅能美化界面,还能提高可用性和可访问性。传统上,我们使用图片文件(如 PNG、JPG 等)来显示图标,但这种方式存在一些缺陷,例如图片文件较大、不能任意缩放、无法通过 CSS 设置颜色等。相比之下,使用字体图标具有许多优势,如文件体积小、可无限缩放、可通过 CSS 设置颜色和阴影等。
svg2font 就是一个用于将 SVG 图标转换为字体图标的工具,它可以帮助我们轻松地在项目中集成和使用字体图标。本文将详细介绍 svg2font 的使用方法、应用场景和注意事项。
安装
svg2font 是一个基于 Node.js 的命令行工具,因此需要先安装 Node.js 环境。安装完成后,可以使用 npm 或 yarn 在项目中安装 svg2font:
# 使用npm
npm install @tenado/svg2font -D
# 使用yarn
yarn add @tenado/svg2font -D
初始化配置
安装完成后,需要初始化 svg2font 的配置文件。在项目根目录执行以下命令:
npx svg2font init
该命令会在项目根目录下生成一个 svg2font.config.js 文件,内容如下:
module.exports = {
inputPath: "src/assets/svgs", // SVG图标文件夹路径
outputPath: "src/assets/font", // 生成字体文件的输出路径
fontFamily: "tenadoIcon", // 字体名称
fontPrefix: "", // 字体前缀
};
你可以根据实际需求修改这些配置项。
生成字体图标
配置完成后,就可以执行以下命令生成字体图标了:
npx svg2font sync
该命令会读取 inputPath 指定的 SVG 图标文件夹,将其中的 SVG 文件转换为字体文件(包括.eot、.ttf、.woff、.woff2 等格式),并输出到 outputPath 指定的路径下。同时,它还会生成一个 config.json 文件,记录了每个图标的 Unicode 编码和 CSS 类名。
在项目中使用字体图标
生成字体文件后,需要在项目中引入相应的 CSS 文件,才能正常使用字体图标。svg2font 会自动生成一个 index.min.css 文件,包含了所有字体图标的 CSS 定义。你可以在项目的入口文件(如 main.js)中导入该 CSS 文件:
import "./src/assets/font/index.min.css";
之后,你就可以在 HTML 中使用字体图标了。例如,如果你有一个名为 ticon-color-pick 的图标,可以这样使用:
<span class="ticon-color-pick"></span>
查看图标列表
如果你想查看当前项目包含的所有图标,可以执行以下命令:
npx svg2font example
该命令会根据 config.json 文件生成一个静态 HTML 页面,列出了所有图标及其对应的 CSS 类名和 Unicode 编码。它还会启动一个本地服务器,方便你在浏览器中预览这个页面。
注意事项
使用 svg2font 时,需要注意以下几点:
1.SVG 文件命名: 确保 SVG 文件名不包含特殊字符或空格,否则可能会导致生成字体时出错。
2.SVG 文件优化: 在将 SVG 文件转换为字体之前,建议先对 SVG 文件进行优化,以减小文件大小。你可以使用工具如 SVGO 或 SVG Optimizer 来优化 SVG 文件。
3.字体支持:不同浏览器和操作系统对字体格式的支持程度不同。为了最大程度地兼容各种环境,svg2font 会生成多种字体格式(.eot、.ttf、.woff、.woff2 等)。
4.字体缓存: 浏览器会缓存字体文件,因此在更新字体图标时,需要确保浏览器加载了最新的字体文件。你可以在 CSS 文件中为字体文件添加版本号或时间戳,以强制浏览器重新加载字体文件。
总结
svg2font 是一个功能强大且易于使用的 SVG 图标字体生成工具。它可以帮助你轻松地将 SVG 图标转换为字体格式,并在 Web 应用程序、跨平台应用程序或图标库中使用这些字体图标。通过使用 svg2font,你可以提高页面性能、确保图标显示一致性,并享受字体图标带来的诸多优势。
无论你是 Web 开发人员、移动应用程序开发人员,还是 UI 设计师,svg2font 都值得一试。它简单易用,且具有丰富的功能和配置选项,可以满足不同项目的需求。快来试试 svg2font,让你的项目与众不同吧!
来源:juejin.cn/post/7384808085348483087
时隔5年重拾前端开发,却倒在了环境搭建上
背景
去年不是降本增“笑”,“裁员”广进来着吗,公司有个项目因此停止了,最近又说这个项目还是很有必要的,就又重新启动这个项目了,然后让我这个“大聪明”把环境重新跑起来。让我无奈的是,原项目的团队成员都已经被增“笑”了,只留下了一堆不知从哪开始着手的文档。
后端还好,前端我心里就犯嘀咕了,毕竟已经5年没有关注过前端了,上次写前端代码用的还是一个基于Angular构建的移动框架inoic,不知道大家用过没有。
好在这个项目前端也用的Angular框架,本以为整个过程会很顺利,然而,结果总是事与愿违。果不其然,在搭建前端开发环境时就给我上了一课,整个过程让我抓耳挠腮,遂特此记录。
环境搭建心路历程
跟着文档操作
前端文档中对环境搭建有进行说明,一共有4个步骤,大概是这样的:
- 确认node环境,需要某个及以上版本。
- 安装@angular/cli。
- 安装依赖。
- 启动项目。
看到这里,我第一反应是“啊?现在前端这么麻烦的吗?”,我记得以前在浏览器直接打开页面就可以访问了。咱也不懂,跟着说明操作就行。
- 我本地不知道啥时候装了nodejs,执行node -v后输出v18.13.0,符合要求。ok
- @angular/cli这是啥,咋也不懂,执行安装命令就行,输出看上去是没有问题。ok
- 安装依赖我理解跟Maven的依赖管理一样,先不管,执行。ok
- 到这一步,我觉得应该可以顺利启动,看一看这个项目的庐山真面目了,结果执行 npm start 后报下面这个错。
出现问题一:nodeJS版本过高
Error: error:0308010C:digital envelope routines::unsupported
......
......
{
'opensslErrorStack': [ 'error:03000086:digital envelope routines::initialization error' ],
'library': 'digital envelope routines',
'reason': 'unsupported',
'code': 'ERR_OSSL_EVP_UNSUPPORTED'
}
......
......
百度一看,原因是node 17版本之后,OpenSSL3.0对算法和密钥大小增加了严格的限制。
解决呗,降版本呗,node官网 下载了v14.12.0。
出现问题二:nodeJS版本低于Angular CLI版本
降版本之后重新运行npm start
,您猜猜怎么着
Node.js version v14.12.0 detected.
The Angular CLI requires a minimum Node.js version of v18.13.
Please update your Node.js version or visit https://nodejs.org/ for additional instructions.
很明显,新老版本冲突了,又是版本问题,又是一顿百度之后,发现知乎上的一个帖子跟我这问题现象是一样的:“node是最新版,npm启动项目使用的不是最新版的node,请问这个怎么解决?”
跟着下面的评论又安装了nvm(Node Version Manager),最后一顿操作后,莫名其妙的启动了。
事后才反应过来,这个问题的根本原因是:Angular CLI是在node版本为18.3时安装的,版本更新到14.12.0后需要删除依赖重新安装。
不过nvm确实好用,至少不用担心node和npm版本问题,比如下面的命令:
[xxx % ] nvm use --delete-prefix v18.13.0
Now using node v18.13.0 (npm v8.19.3)
学到的第一个知识:nvm
这里记录下nvm安装过程
- clone this repo in the root of your user profile
- cd ~/ from anywhere then git clone github.com/nvm-sh/nvm.… .nvm
- cd ~/.nvm and check out the latest version with git checkout v0.39.7
- activate nvm by sourcing it from your shell: . ./nvm.sh
配置环境变量
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion
引发的思考
技术发展日新月异
早在几年前,程序员是要前后端一起开发的,不分什么前后端,我从最开始的HTML、JavaScript开始用到AngularJS这些框架,印象最深刻的是还要解决兼容IE浏览器。没想到现在的前端也会有版本管理、组件化等等,可见技术更新迭代速度之快。
前端的重要性
当初在选择后端的时候认为前端技术无非就那些,没有什么挑战。事实上,前后端没有分离之前,市场上的应用页面也是极其简洁的,前后端一起兼顾是没有精力写出那么好看的界面和交互的。所以“前端已死”的观点我是不认可的。
降本增“笑”被迫全栈
前几天参加了开发者社区的线下聚会,聊了一下行情。有小伙伴吐槽,因为在降本增“笑”的原因,现在他们被公司要求要写前端,被迫向全栈发展,竟意外发现开发效率极其高。还有小伙伴说“前端被裁的剩下几个人,一个前端对接十个后端。”。是呀,在降本增“笑”之后,老板恨不得让一个人干十个人的活。
与时俱进
不论是几年前的前后端分离还是降本增“笑”带来的被迫全栈,还是最近“前端已死”的观点,一切都是行业发展所需要的。我们需要做到的是:不断学习和更新自己的知识和技能,以适应行业的发展和变化。
来源:juejin.cn/post/7327599804325052431
cesium 鼠标动态绘制墙及墙动效
实现在cesium中基于鼠标动态绘制墙功能
1. 基本架构设计
绘制墙的交互与绘制线的交互几乎一模一样,只是一些生成wall实体的计算方法不一样,所以可以看这篇文章 cesium 鼠标动态绘制线及线动效 juejin.cn/post/728826… 了解相关的架构设计
2. 关键代码实现
2.1 绘制线交互相关事件
事件绑定相关与动态绘制线一样,这里不再重复代码
绘制形状代码有区别:
为了实现墙贴地,要实时计算minimumHeights,maximumHeights的值,min中算出地形高度,max中再地形高度的基础上再加上墙的高度
/**
* 绘制形状,用于内部临时画墙
* @param positionData 位置数据
* @param config 墙的配置项
* @returns
*/
private drawShape(positionData: Cartesian3[], config?: WallConfig) {
const wallConfig = config || new WallConfig();
const material = this.createMaterial(wallConfig);
// @ts-ignore
const pArray = positionData._callback();
const shape = this.app.viewerCesium.entities.add({
wall: {
positions: positionData,
material: material,
maximumHeights: new CallbackProperty(() => {
let heights: number[] = [];
for (let i = 0; i < pArray.length; i++) {
const cartographic = Cartographic.fromCartesian(pArray[i]);
const height = cartographic.height;
heights.push(height);
}
const data = Array.from(heights, (x) => x + wallConfig.height);
return data;
}, false),
minimumHeights: new CallbackProperty(() => {
let heights: number[] = [];
for (let i = 0; i < pArray.length; i++) {
const cartographic = Cartographic.fromCartesian(pArray[i]);
const height = cartographic.height;
heights.push(height);
}
const data = Array.from(heights);
return data;
}, false)
}
});
return shape;
}
2.2 创建材质相关
/**
* 创建材质
* @param config 墙的配置项
* @returns
*/
private createMaterial(config: WallConfig) {
let material = new ColorMaterialProperty(Color.fromCssColorString(config.style.color));
if (config.style.particle.used) {
material = new WallFlowMaterialProperty({
image: config.style.particle.image,
forward: config.style.particle.forward ? 1.0 : -1.0,
horizontal: config.style.particle.horizontal,
speed: config.style.particle.speed,
repeat: new Cartesian2(config.style.particle.repeat, 1.0)
});
}
return material;
}
创建WallFlowMaterialProperty.js(具体为何如此请看这篇文章,cesium自定义材质 juejin.cn/post/728795…
import { Color, defaultValue, defined, Property, createPropertyDescriptor, Material, Event, Cartesian2 } from 'cesium';
const defaultColor = Color.TRANSPARENT;
import defaultImage from '../../../assets/images/effect/line-color-yellow.png';
const defaultForward = 1;
const defaultHorizontal = false;
const defaultSpeed = 1;
const defaultRepeat = new Cartesian2(1.0, 1.0);
class WallFlowMaterialProperty {
constructor(options) {
options = defaultValue(options, defaultValue.EMPTY_OBJECT);
this._definitionChanged = new Event();
// 定义材质变量
this._color = undefined;
this._colorSubscription = undefined;
this._image = undefined;
this._imageSubscription = undefined;
this._forward = undefined;
this._forwardSubscription = undefined;
this._horizontal = undefined;
this._horizontalSubscription = undefined;
this._speed = undefined;
this._speedSubscription = undefined;
this._repeat = undefined;
this._repeatSubscription = undefined;
// 变量初始化
this.color = options.color || defaultColor; //颜色
this.image = options.image || defaultImage; //材质图片
this.forward = options.forward || defaultForward;
this.horizontal = options.horizontal || defaultHorizontal;
this.speed = options.speed || defaultSpeed;
this.repeat = options.repeat || defaultRepeat;
}
// 材质类型
getType() {
return 'WallFlow';
}
// 这个方法在每次渲染时被调用,result的参数会传入glsl中。
getValue(time, result) {
if (!defined(result)) {
result = {};
}
result.color = Property.getValueOrClonedDefault(this._color, time, defaultColor, result.color);
result.image = Property.getValueOrClonedDefault(this._image, time, defaultImage, result.image);
result.forward = Property.getValueOrClonedDefault(this._forward, time, defaultForward, result.forward);
result.horizontal = Property.getValueOrClonedDefault(this._horizontal, time, defaultHorizontal, result.horizontal);
result.speed = Property.getValueOrClonedDefault(this._speed, time, defaultSpeed, result.speed);
result.repeat = Property.getValueOrClonedDefault(this._repeat, time, defaultRepeat, result.repeat);
return result;
}
equals(other) {
return (
this === other ||
(other instanceof WallFlowMaterialProperty &&
Property.equals(this._color, other._color) &&
Property.equals(this._image, other._image) &&
Property.equals(this._forward, other._forward) &&
Property.equals(this._horizontal, other._horizontal) &&
Property.equals(this._speed, other._speed) &&
Property.equals(this._repeat, other._repeat))
);
}
}
Object.defineProperties(WallFlowMaterialProperty.prototype, {
isConstant: {
get: function get() {
return (
Property.isConstant(this._color) &&
Property.isConstant(this._image) &&
Property.isConstant(this._forward) &&
Property.isConstant(this._horizontal) &&
Property.isConstant(this._speed) &&
Property.isConstant(this._repeat)
);
}
},
definitionChanged: {
get: function get() {
return this._definitionChanged;
}
},
color: createPropertyDescriptor('color'),
image: createPropertyDescriptor('image'),
forward: createPropertyDescriptor('forward'),
horizontal: createPropertyDescriptor('horizontal'),
speed: createPropertyDescriptor('speed'),
repeat: createPropertyDescriptor('repeat')
});
Material.WallFlowType = 'WallFlow';
Material._materialCache.addMaterial(Material.WallFlowType, {
fabric: {
type: Material.WallFlowType,
uniforms: {
// uniforms参数跟我们上面定义的参数以及getValue方法中返回的result对应,这里值是默认值
color: defaultColor,
image: defaultImage,
forward: defaultForward,
horizontal: defaultHorizontal,
speed: defaultSpeed,
repeat: defaultRepeat
},
// source编写glsl,可以使用uniforms参数,值来自getValue方法的result
source: `czm_material czm_getMaterial(czm_materialInput materialInput)
{
czm_material material = czm_getDefaultMaterial(materialInput);
vec2 st = materialInput.st;
vec4 fragColor;
if (horizontal) {
fragColor = texture(image, fract(vec2(st.s - speed*czm_frameNumber*0.005*forward, st.t)*repeat));
} else {
fragColor = texture(image, fract(vec2(st.t - speed*czm_frameNumber*0.005*forward, st.t)*repeat));
}
material.emission = fragColor.rgb;
material.alpha = fragColor.a;
return material;
}`
},
translucent: true
});
export { WallFlowMaterialProperty };
2.3 添加wall实体
/**
* 根据已知数据添加一个墙
* @param config 墙的配置项
*/
add(config: WallConfig) {
const configCopy = cloneDeep(config);
const positions = configCopy.positions;
const material = this.createMaterial(configCopy);
let distance = new DistanceDisplayCondition();
if (configCopy.distanceDisplayCondition) {
distance = new DistanceDisplayCondition(
configCopy.distanceDisplayCondition.near,
configCopy.distanceDisplayCondition.far
);
}
let heights: number[] = [];
for (let i = 0; i < positions.length; i++) {
const cartographic = Cartographic.fromCartesian(positions[i]);
const height = cartographic.height;
heights.push(height);
}
this.app.viewerCesium.entities.add({
id: 'wallEntity_' + configCopy.id,
wall: {
positions: positions,
maximumHeights: Array.from(heights, (x) => x + configCopy.height),
minimumHeights: Array.from(heights),
material: material,
distanceDisplayCondition: distance
}
});
this._wallConfigList.set('wallEntity_' + configCopy.id, config);
}
3. 业务端调用
调用方式与动态绘制线一样,是同一种架构设计,这里不再重复代码
4. 效果
来源:juejin.cn/post/7288606110335565883