将B站作为曲库,实现一个畅听全网歌曲的音乐客户端
仓库地址
前言
在很久之前做了一个能够免费听周杰伦歌曲的网页,经历了各种歌曲源失效的问题之后,换了一种实现思路,将B站作为曲库,开发一个应用,这样只要B站不倒,就可以一直白嫖歌曲了。
实现思路
- B 站上有很多的音乐视频,相当于一种超级全的音乐聚合曲库(索尼直接将 B 站当做网盘,传了 15w 个视频)
- 对这些视频进行收集制作成歌单
- 无需登录即可完整播放,无广告
- 使用 SocialSisterYi 整理的 B 站接口文档,直接就可以获取和搜索 B 站视频数据
功能
- 播放器
- 基础功能(播放,暂停,上一首,下一首)
- 播放列表
- 单曲循环,列表循环,随机播放
- 进度拖动
- 计时播放
- 搜索
- 名称关键字搜索
- 歌单
- 歌单同步
- 歌单广场(由用户贡献分享自己的歌单)
技术栈
- Flutter
缺陷
- 没有 IOS 版本(上架太贵了)
- 没有歌词
- 云同步配置麻烦一些,(隐私与便利不可兼得)
UI
警告
此项目仅供个人学习使用,请勿用于商业用途,否则后果自负。
鸣谢致敬
- SocialSisterYi 感谢这个库的作者和相关贡献者
- 感谢广大 B 站网友们提供的视频资源
来源:juejin.cn/post/7414129923633905675
THREE.JS——让你的logo切割出高级感
灵感图
每次都根据灵感图写代码,我都快成灵感大王了,本文较长,跨度较大,效果较好,请耐心看完,本文阶段代码有tag可以分部查看
前言
这是B站的一段视频,用3D渲染的方式表达各个大厂的logo如何制作出来的,其中提取出一小段,用于本文的灵感,就是这个图的切割效果,下文不包含激光的圆圈和工作平台,只有切割的光线、切割效果和分离动画,灵感图中切割的部分是超过logo的,如果有UI设计师,可以让设计师给提供分段的svg,我孤军奋战没有那么些资源,文中的点位都是从logo的svg文件获取的,场景创建就不赘述了,以前的文章也讲过很多次,那么我们开始吧
准备工作
- threejs
- ts
- vite
找一个这个小鸟的svg文件。
将svg文件的点位获取出来并将svg加入到场景中
渲染svg
// 加载模型
const loadModel = async () => {
svgLoader.load('./svg/logo.svg', (data) => {
const material = new THREE.MeshBasicMaterial({
color: '#000',
});
for (const path of data.paths) {
const shapes = SVGLoader.createShapes(path);
for (const shape of shapes) {
const geometry = new THREE.ShapeGeometry(shape);
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh)
}
}
renderer.setAnimationLoop(render)
})
}
loadModel()
渲染结果
svg加载出来后的shape
就是组成当前logo的所有关键点位信息,接下来要做的是将这个logo以正确的角度放置在场景,再将这些关键点位生成激光运动路径,比如一个圆弧,是一个贝塞尔曲线,有两个定点,几个手柄,通过不同的角度组成曲线,而我们要做的是一条布满点位的曲线作为运动路径
获取曲线点位
这里用到的api是# CubicBezierCurve
贝塞尔曲线的基类Curve对象提供的方法getPoints
.getPoints ( divisions : Integer ) : Array
divisions -- 要将曲线划分为的分段数。默认是 5.
为了更方便的查看我们创建的点位,我们将生成的点位信息创建一个cube
// 加载模型
const loadModel = async () => {
...
for (const curve of shape.curves) {
/*
* .getPoints ( divisions : Integer ) : Array
* divisions -- 要将曲线划分为的分段数。默认是 5.
*/
const points = curve.getPoints(100);
console.log(points);
for (const v2 of points) {
const geometry = new THREE.BoxGeometry(10, 10, 10);
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
cube.position.set(v2.x, v2.y, 0)
scene.add(cube);
}
}
...
}
}
renderer.setAnimationLoop(render)
})
}
loadModel()
从图中可以看出,现在cube已经绕着logo围成一圈了,但是有一个现象,就是路径长的地方cube比较稀疏,而路径比较短的曲线cube比较密集,上面代码创建的关键点位信息都是以100的数量创建,所以会导致这种情况,曲线的疏密程度决定将来激光的行走速度,为了保证不管多长的路径,他们的行走速度是一样的,那么我们需要动态计算一下到底该以多少个点位来生成这条路径
...
const length = curve.getLength ();
const points = curve.getPoints(Math.floor(length/10));
...
在遍历curve的时候,通过getLength
获取曲线的长度,根据长度的不同,决定分段的点位数量,这样就保证了点位之间的距离是一样的,将来激光行走的速度也是可以控制成一样的,速度一样,距离越短,越先完成,当然你想让所有激光都同时完成,那是不需要让分割的点位分布均匀的。
提取点位信息
由于之前我们获取到了所有的点位信息,那么是不要加载原有的svg生成的logo,所以我们现在要将获取到的分割点,改为vector3,并缩小一下logo,这样方便以后操作
// 新建一个二维数组用于收集组成logo的点位信息
// 用于计算box3的点位合集
let divisionPoints: THREE.Vector2[] = []
// 用于计算box3的点位合集
let divisionPoints: THREE.Vector3[] = []
// 将遍历贝塞尔曲线的地方再改造一下
let list: THREE.Vector3[] = []
/*
* .getPoints ( divisions : Integer ) : Array
* divisions -- 要将曲线划分为的分段数。默认是 5.
*/
const length = curve.getLength();
const points = curve.getPoints(Math.floor(length / 20));
for (const v2 of points) {
// logo 太大了,缩小一下,这里不建议用scale缩svg,直接缩向量
v2.divideScalar(20)
const v3 = new THREE.Vector3(v2.x, 0, v2.y)
list.push(v3)
divisionPoints.push(v2)
}
paths.push(list)
制作底板并将logo和底板统一放在视图中心
在此之前需要先定义几个变量,用于之后的使用
const logoSize = new THREE.Vector2()
const logoCenter = new THREE.Vector2()
// 底板厚度
const floorHeight = 3
let floor: THREE.Mesh | null
// 底板比logo的扩张尺寸
let floorOffset = 8
根据点位信息收集logo 的信息
根据之前收集的点位信息创建出底板和logo
const handlePaths = () => {
const box2 = new THREE.Box2();
box2.setFromPoints(divisionPoints)
box2.getSize(logoSize)
box2.getCenter(logoCenter)
createFloor()
}
创建地板和logo
const createFloor = () => {
const floorSize = logoSize.clone().addScalar(floorOffset)
const geometry = new THREE.BoxGeometry(floorSize.width, floorHeight, floorSize.height);
const material = new THREE.MeshLambertMaterial({ color: 0x6ac3f7 });
floor = new THREE.Mesh(geometry, material);
scene.add(floor);
createLine()
}
const createLine = () => {
const material = new THREE.LineBasicMaterial({
color: 0x0000ff
});
const points: THREE.Vector3[] = [];
divisionPoints.forEach(point => {
points.push(new THREE.Vector3(point.x, floorHeight, point.y))
})
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(geometry, material);
const linePos = logoSize.clone().divideScalar(-2)
line.position.set(linePos.x, 0, linePos.y)
scene.add(line);
}
我们之前加载的svg已经没有用了,只是为了提供点位信息,所以需要再根据整理后的点位信息创建一个logo的Line
对象
效果图
绘制激光
创建4(可自定)条激光,起点从底板上方30的位置,结束于logo,然后结束的点位随着logo的点位进行改变,从而实现激光运动的效果,提前先确定一下激光起点,
判断起点
由于激光数量可以自定,那么我们需要自定义一个激光的数量,当前用的数量是10,而要配置不同数量的激光,位置就需要有一定的规则,下面代码是创建了一个圆弧,以激光数量为基础,在圆弧上获取相应的点位,这样不管多少个激光,都可以从这个圆弧上取起点位置,圆弧的半径是以logo为基础向内缩进的,而结束点,目前定在底板的下面。
// 激光组
const buiGr0up = new THREE.Gr0up()
// 激光起点相对于logo缩进的位置
const buiDivide = 3
// 决定激光起点距离场景中心的距离
const buiOffsetH = 30
// 决定有几条激光
const buiCount = 10
const createBui = () => {
// 创建一个圆弧,将来如果有很多激光,那么起点就从圆弧的点位上取
var R = Math.min(...logoSize.toArray()) / buiDivide; //圆弧半径
var N = buiCount * 10; // 根据激光的条数生成圆弧上的点位数量
// 批量生成圆弧上的顶点数据
const vertices: number[] = []
for (var i = 0; i < N; i++) {
var angle = 2 * Math.PI / N * i;
var x = R * Math.sin(angle);
var y = R * Math.cos(angle);
vertices.push(x, buiOffsetH, y)
}
// 创建圆弧的辅助线
initArc(vertices)
for (let i = 0; i < buiCount; i++) {
const startPoint = new THREE.Vector3().fromArray(vertices, i * buiCount * 3)
const endPoint = new THREE.Vector3()
endPoint.copy(startPoint.clone().setY(-floorHeight))
// 创建cube辅助块
const color = new THREE.Color(Math.random() * 0xffffff)
initCube(startPoint, color)
initCube(endPoint, color)
}
}
效果图
每两个相同的颜色就是当前激光一条激光的两段
line2
下面该创建激光biu~
,原理上是一条可控制宽度的线,虽然threejs中的线条材质提供的linewidth来控制线宽,但是属性下面有说明,无论怎么设置,线宽始终是1,所以我们要用另一种表现形式:Line2
.linewidth : Float
控制线宽。默认值为 1。
由于OpenGL Core Profile与 大多数平台上WebGL渲染器的限制,无论如何设置该值,线宽始终为1。
import { Line2 } from "three/examples/jsm/lines/Line2.js";
import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
...
const createLine2 = (linePoints: number[]) => {
const geometry = new LineGeometry();
geometry.setPositions(linePoints);
const matLine = new LineMaterial({
linewidth: 0.002, // 可以调整线宽
dashed: true,
opacity: 0.5,
color: 0x4cb2f8,
vertexColors: false, // 是否使用顶点颜色
});
let biu = new Line2(geometry, matLine);
biuGr0up.add(biu);
}
调用initBiu~
createLine2([...startPoint.toArray(),...endPoint.toArray()])
效果图
准备工作大致就到此结束了,接下来要实现的效果是激光运动
、激光发光
、logo切割
。
激光效果
首先先把激光的数量改为4,再将之前收集到的logo坐标点位分成四份,每根激光负责切割其中一份,切割的过程就是将激光的endpoint进行改变。
激光运动
计算激光结束点位置
在创建好激光后调用biuAnimate
方法,这个方法更新了激光的结束点,遍历之前从svg上获取的点位信息,将这些点位以激光的数量等分,再将这些点位信息作为Line2的顶点信息,通过setInterval的形式更新到激光的Line2
const biuAnimate = () => {
console.log('paths', paths, divisionPoints);
// biuCount
// todo 这里要改成points这样的 每次切割完 收缩一下激光,再伸展出来
const allPoints = [...divisionPoints]
const len = Math.ceil(allPoints.length / biuCount)
for (let i = 0; i < biuCount; i++) {
const s = (i - 1) * len
const points = allPoints.splice(0, len);
const biu = biuGr0up.children[i] as Line2;
const biuStartPoint = biu.userData.startPoint
let j = 0;
const interval = setInterval(() => {
if (j < points.length) {
const point = points[j]
const attrPosition = [...biuStartPoint.toArray(), ...new THREE.Vector3(point.x, floorHeight/2, point.y).add(getlogoPos()).toArray()]
uploadBiuLine(biu, attrPosition)
j++
} else {
clearInterval(interval)
}
}, 100)
}
}
// 更新激光信息
const uploadBiuLine = (line2: Line2, attrPosition) => {
const geometry = new LineGeometry();
line2.geometry.setPositions(attrPosition);
}
效果图
根据激光经过的路径绘制logo
首先隐藏掉原有的logo,以每一条激光为维度,创建一个THREE.Line
,这样我们就有了4条曲线,在每次激光经过的点作为这条曲线的节点,去更新BufferGeometry
。
创建激光的部分代码
for (let i = 0; i < biuCount; i++) {
...
// 创建线段
const line = createLine()
scene.add(line)
const interval = setInterval(() => {
if (j < points.length) {
const point = points[j]
const endArray = new THREE.Vector3(point.x, floorHeight / 2, point.y).add(getlogoPos()).toArray()
const attrPosition = [...biuStartPoint.toArray(), ...endArray]
...
// 获取原有的点位信息
const logoLinePointArray = [...(line.geometry.attributes['position']?.array||[])];
logoLinePointArray.push(...endArray)
// 更新线段
line.geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(logoLinePointArray), 3))
j++
} else {
clearInterval(interval)
}
}, 100)
}
从图中可以看到,每根曲线之间的衔接做的并不是很到位,所以稍微改造一下代码,将上一根线的最后一个点位给到当前的线,
const points = allPoints.splice(0, len);
// allPoints是截取到上一轮点位的其余点位,所以第一个就是当前激光相邻的第一个点
if(i<biuCount-1) {
points.push(allPoints[0])
} else {
//最后一条曲线需要加的点是第一条线的第一个点
points.push(divisionPoints[0])
}
logo分离
激光切割完毕后,logo和底板将分离,之前想用的是threeBSP
进行布尔运算进行裁切,但是对于复杂的logo使用布尔运算去裁切太消耗资源了,简单的几何形状可以。
创建裁切的多余部分
创建裁切的过程其实就是新增和删除的过程,新增一个logo和多余部分,再将原有的底板删除掉
这里多余的部分使用shape的孔洞,底板尺寸生成的形状作为主体,logo作为孔洞,结合起来后,将得到的shape进行挤压
创建logo和多余部分的几何体
在外部创建logo和多余部分的shape
// 用于创建logo挤压模型的形状Shape
const logoShape = new THREE.Shape()
// 用于创建多余部分的挤压模型形状
const moreShape = new THREE.Shape()
loadModel
方法新增代码,用于收集logoShape的点位信息
// 加载模型
const loadModel = async () => {
...
for (let i = 0; i < points.length - 1; i++) {
const v2 = points[i]
if (v2.x !== 0 && v2.x && v2.y !== 0 && v2.y) {
// logo 太大了,缩小一下,这里不建议用scale缩svg,直接缩向量,后面依赖向量的元素都需要重新绘制
v2.divideScalar(20)
const v3 = new THREE.Vector3(v2.x, 0, v2.y)
list.push(v3)
divisionPoints.push(v2)
if (i === 0) {
logoShape.moveTo(v2.x, v2.y)
} else {
logoShape.lineTo(v2.x, v2.y)
}
}
}
...
}
createFloor
方法创建moreMesh多余部分的挤压几何体
const createFloor = () => {
const floorSize = logoSize.clone().addScalar(floorOffset)
const geometry = new THREE.BoxGeometry(floorSize.width, floorHeight, floorSize.height);
floor = new THREE.Mesh(geometry, logoMaterial);
// scene.add(floor);
moreShape.moveTo(floorSize.x / 2, floorSize.y / 2);
moreShape.lineTo(-floorSize.x / 2, floorSize.y / 2);
moreShape.lineTo(-floorSize.x / 2, -floorSize.y / 2);
moreShape.lineTo(floorSize.x / 2, -floorSize.y / 2);
const path = new THREE.Path()
const logoPos = new THREE.Vector3(logoCenter.x, floorHeight / 2, logoCenter.y).negate()
// logo实例
logoMesh = createLogoMesh(logoShape)
logoMesh.position.copy(logoPos.clone().setY(floorHeight))
logoMesh.material = new THREE.MeshLambertMaterial({ color: 0xff0000, side: THREE.DoubleSide });
scene.add(logoMesh);
// 孔洞path
divisionPoints.forEach((point, i) => {
point.add(logoCenter.clone().negate())
if (i === 0) {
path.moveTo(point.x, point.y);
} else {
path.lineTo(point.x, point.y);
}
})
// 多余部分添加孔洞
moreShape.holes.push(path)
// 多余部分实例
moreMesh = createLogoMesh(moreShape)
// moreMesh.visible = false
scene.add(moreMesh)
}
经过以上的改造,画面总共分为三个主要部分,激光、多余部分、logo。
大概效果就是这样的,再加上动画,让激光有收起和展开,再加上切割完以后,多余部分的动画,那这篇教程基本上就完事儿了,下面优化的部分就不一一展示了,可以看最终的效果动图,也可以从gitee上将代码下载下来自行运行
推特logo
抖音 logo
github logo
动图比较大,可以保存在本地查看
项目地址
来源:juejin.cn/post/7337169269951283235
BOE(京东方)首度全面解读ADS Pro液晶显示技术众多“真像” 倡导以创新推动产业高价值增长
10月28日,BOE(京东方)“真像 只有一个”ADS Pro技术品鉴会在上海举行。BOE(京东方)通过打造“光影显真”、“万像归真”、“竞速见真”三大场景互动区,以及生动鲜活的实例和现场体验,揭示了众多“真像”,解读了行业误区以及消费者认知偏差,不仅全面展示了ADS Pro技术在高环境光对比度、全视角无色偏、高刷新率和动态画面优化等方面的卓越性能表现,以及液晶显示技术蓬勃的生命力,更是极大推动了全球显示产业的良性健康发展。活动现场,BOE(京东方)高级副总裁、首席技术官刘志强,京东集团副总裁、京东零售家电家居生活事业群总裁李帅等出席并发表致辞,并在行业嘉宾、媒体伙伴的见证下,共同发起“产业高价值增长倡议”,标志着中国显示产业开启从价格竞争走向价值竞争的高质发展新时代。
BOE(京东方)高级副总裁、首席技术官刘志强表示,人类对真相的探究永无止境,而显示技术的“真像”也需要还原最真实的色彩和场景。作为中国大陆液晶显示产业的先行者和领导者,BOE(京东方)在市场规模、出货量、技术、应用等方面遥遥领先,如今,有屏的地方就有京东方,如何选好屏,也成为当下消费者最关注的议题之一。作为三大技术品牌之一,BOE(京东方)自主研发的ADS Pro是应用最广的主流液晶显示技术,搭载ADS Pro技术的产品目前全球出货量最高。BOE(京东方)通过不断技术创新,依托ADS Pro技术的超高环境光对比度、超广视角、超高刷新率等优势,不断迭代升级并推出包括BD Cell、UB Cell、以及ADS Pro+Mini LED背光等创新显示解决方案,引领显示行业技术发展潮流,带领中国屏幕走向全球。
京东集团副总裁、京东零售家电家居生活事业群总裁李帅表示,作为BOE(京东方)自主研发的高端显示技术,ADS Pro在高对比度、更广色域、超广全视角、超低反射率等方面的技术特性,极大程度满足了用户对于高端电视的消费需求,今年618期间,ADS Pro电视在京东的成交额同比增长超过3倍。京东与BOE(京东方)共同打造了全域内容营销矩阵,通过整合京东站内外内容,用好内容积攒产品口碑。未来,“双京”将持续强强联手,让更多人了解ADS Pro技术、选购ADS Pro技术赋能的高端电视,让更好的视听体验走进千家万户。
在品鉴会现场,BOE(京东方)带领行业探寻了一系列ADS Pro的技术真相:
真相一:在环境光对比度方面,通常传统液晶显示技术所呈现的对比度多数用暗室对比度进行测试,脱离用户真实使用场景的数值是毫无意义的。在真实应用场景中,室内常规照明情况下的环境光对比度对用户更有意义,也是决定用户真实体验的关键指标,BOE(京东方)创新升级环境光对比度(ACR)这一更加适配当前屏幕使用场景的测试指标,更准确地表征人眼真实感知的对比度。作为中国唯一拥有自主知识产权的高端液晶显示技术,BOE(京东方)ADS Pro技术对比度测试标准从用户体验出发,在近似真实的使用场景下进行数据测试,ACR数值高达1400:1,与其他同类技术相比具有显著优势。同时,通过像素结构优化、新材料开发、表面处理等多种减反技术,大幅降低了显示屏幕光线反射率,达到远超常规显示技术的超高环境光对比度,实现更高的光线适应性和更佳的画质表现。在BOE(京东方)ACR技术的加持下,能够让消费者在观看屏幕时无需受到环境光干扰。
真相二:在广视角方面,传统测量标准下,几乎所有产品都能达到所谓的“广视角”,但实际观看效果有很大区别,“色偏视角”才能更客观反馈广视角显示效果。大屏观看时,产品色偏问题显得尤为突出。色偏是指屏幕在侧视角观看时,亮度/色彩的变化与正视角观看时的差异,色偏视角能真实呈现色彩的“本真”。在所有显示技术中,ADS Pro在广视角观看时显示效果最能够达到真实还原,实现接近180°的超广视角,且全视角无色偏、无褪色,让消费者实现家庭观影处处都是“C位”,这也成为ADS Pro技术的另一大重要优势。
真相三:高端LCD显示技术依然是大屏电视产品的主流,并实现了媲美OLED的显示效果。基于BOE(京东方)ADS Pro技术进一步升级的高端LCD解决方案UB Cell,所呈现的完美画质可以媲美OLED,甚至超越它的显示效果,这是LCD显示技术领域发展的重要里程碑。BOE(京东方)UB Cell技术在感知画质、信赖性、能耗等方面相较于其他技术更具优势。由于采用了多种减反技术,UB Cell显示屏的表面反射率大幅降低,使其环境光对比度远高于市场旗舰机型中应用其他技术的产品,从而极大提升屏幕的亮态亮度和暗态亮度的比值,让画面显示的暗态部分更暗、亮态部分更亮,画质更加细腻逼真。同时,BOE(京东方)通过开发新型光学相位补偿技术,实现了超宽视角,使得UB Cell技术的大视角色彩表现能力进一步提升。此外,借助ADS Pro技术的特有像素结构,配合宽频液晶材料体系和驱动算法,可以全灰阶满足G-sync 标准,完全无屏闪,更护眼的同时也让显示画面更稳定更流畅。
真相四:ADS Pro广视角和高刷的优势,结合Mini LED在HDR和极致暗态画质的优异表现,让二者做到最完美的优势互补,这样的组合才是画质提升的最佳拍档!BOE(京东方)采用高光效LED+高对比度ADS Pro面板+画质增强算法方案,相比其他显示技术有更出众的表现,不仅实现更宽的亮度范围,还进一部拓展了高亮度灰阶,扩充暗场景灰阶层次感。此外,随着刷新率的不断提升,通过ADS Pro+Mini LED实现分区动态差黑,可以极大提升高速运动画面清晰度,显著减少卡顿、延迟等现象,树立电竞显示的性能画质新标杆。目前,ADS Pro+Mini LED解决方案已成为全品类产品的应用趋势。
真相五:作为目前全球领先的主流液晶显示技术,BOE(京东方)ADS Pro是唯一可以覆盖从手机、平板电脑、笔记本、显示器到电视所有产品类型的技术。ADS Pro技术在大屏上的优势更加明显,并且通过专业级高端画质、极致的视觉享受及一系列健康护眼技术,为各行业客户打开新的增长空间。目前ADS Pro技术在显示器、笔记本、平板电脑、大屏高端电视等领域市场份额逐年攀升,已成为全球各大一线终端品牌高端机型的首选。群智咨询总经理李亚琴表示,五年后,LCD面板出货面积较当前水平将达到两位数增幅。在用户需求和技术升级的双重驱动下,ADS/IPS技术在IT市场将位居绝对主流地位并逐年提升份额;尤其在电竞领域,用户对高分辨率、高刷新率、快速响应时间、高色域、更大尺寸屏幕等显示性能提升有很高的期待。
当前,显示无处不在的时代已经到来,显示技术的持续迭代升级,背后的“真像”是中国科技力量在全球发挥着越来越重要的价值。作为全球半导体显示行业龙头企业,BOE(京东方)携手行业伙伴共同发起倡议,呼吁行业各界合作伙伴打破内卷,以技术升维不断提升用户体验,从聚焦价格的“零和博弈”走向聚焦价值的“融合共生”,开辟新技术、新赛道、新模式,共同发展高科技、高效益、高质量的新质生产力!未来,以BOE(京东方)为代表的中国科技企业也将持续创新,为消费带来更真实、更智能、更时尚、更节能的显示技术和产品,引领中国屏幕走向全球,为产业高质升维发展注入源源不断的新动能。
收起阅读 »微信的消息订阅,就是小程序有通知,可以直接发到你的微信上
给客户做了一个信息发布的小程序,今天客户提要求说希望用户发布信息了以后,他能收到信息,然后即时给用户审核,并且要免费,我就想到了微信的订阅消息。之前做过一次,但是忘了,这次记录一下,还是有一些坑的。
一 先申请消息模版
先去微信公众平台,申请消息模版
在uni-app 里面下载这个插件uni-subscribemsg
我的原则就是有插件用插件,别自己造轮子。而且这个插件文档很好
根据文档定义一个message.js 云函数
这个其实文档里面都有现成的代码,但我还是贴一下自己的吧。
'use strict';
const uidObj = require('uni-id');
const {
Controller
} = require('uni-cloud-router');
// 引入uni-subscribemsg公共模块
const UniSubscribemsg = require('uni-subscribemsg');
// 初始化实例
let uniSubscribemsg = new UniSubscribemsg({
dcloudAppid: "填你的应用id",
provider: "weixin-mp",
});
module.exports = class messagesController extends Controller {
// 发送消息
async send() {
let response = { code: 1, msg: '发送消息失败', datas: {} };
const {
openid,
data,
} = this.ctx.data;
// 发送订阅消息
let resdata = await uniSubscribemsg.sendSubscribeMessage({
touser: openid,// 就是用户的微信id,决定发给他
template_id: "填你刚刚申请的消息模版id",
page: "pages/tabbar/home", // 小程序页面地址
miniprogram_state: "developer", // 跳转小程序类型:developer为开发版;trial为体验版;formal为正式版;默认为正式版
lang: "zh_CN",
data: {
thing1: {
value: "信息审核通知"// 消息标题
},
thing2: {
value: '你有新的内容需要审核' // 消息内容
},
number3: {
value: 1 // 未读数量
},
thing4: {
value: '管理员' // 发送人
},
time7: {
value: data.time // 发送时间
}
}
});
response.code = 0;
response.msg = '发送消息成功';
response.datas = resdata;
return response;
}
}
四 让用户主动订阅消息
微信为了防止打扰用户,需要用户订阅消息,并且每次订阅只能发送一次,不过我取巧,在用户操作按钮上偷偷加订阅方法,让用户一直订阅,我就可以一直发
// 订阅
dingYue() {
uni.requestSubscribeMessage({
tmplIds: ["消息模版id"], // 改成你的小程序订阅消息模板id
success: (res) => {
if (res['消息模版id'] == 'accept') {
}
}
});
},
五 讲一下坑
我安装了那个uni-app 的消息插件,但是一直报错找不到那个模块。原来是unicloud 云函数要主动关联公共模块,什么意思呢,直接上图。
来源:juejin.cn/post/7430353222685048859
WebGL实现soul星球效果
WebGL实现soul星球效果
最近在研究webGL,觉得soul app的星球挺有意思的,于是就实现了一下,中间涉及的细节和知识点挺多的,写篇博客分享一下
soul原版
WebGL实现的
主要技术要点
1.使用黄金分割数螺旋分配
使小球在球表面均匀分布
使用不同的goldenRatio可以得到非常多分布效果,采用黄金分割数在视觉上最匀称、舒服
const goldenRatio = (1 + Math.sqrt(5)) / 2
const y = 1 - (i / (numPoints - 1)) * 2
const radiusAtY = Math.sqrt(1 - y * y)
const theta = (2 * Math.PI * i) / goldenRatio
const x = Math.cos(theta) * radiusAtY
const z = Math.sin(theta) * radiusAtY
2.自由转动
因为要解决万向锁的问题,所以不能使用rotateX
、rotateY
、rotateZ
来旋转,应当使用四元数THREE.Quaternion
3.背面小球变暗
这里通过内部放置了一个半透明的黑色小球来实现
// 创建半透明球体
const sphereGeometry = new THREE.SphereGeometry(4.85, 16, 16)
为了使小球从正面转动的背面的过程中可以平滑的变暗,这里还需要把半透明小球的边沿处理成高斯模糊
,具体实现就是使用GLSL的插值函数smoothstep
fragmentShader: `
uniform vec3 color;
uniform float opacity;
varying vec3 vNormal;
void main() {
float alpha = opacity * smoothstep(0.5, 1.0, vNormal.z);
gl_FragColor = vec4(color, alpha);
}
但是需要注意的是需要关闭小球的深度测试
,否则会遮挡小球
side: THREE.FrontSide,
depthWrite: false,
4.使用THREE.Sprite
创建小球标签
5.标签位置计算
for (let i = 0; i < numPoints; i++) {
const y = 1 - (i / (numPoints - 1)) * 2
const radiusAtY = Math.sqrt(1 - y * y)
const theta = (2 * Math.PI * i) / goldenRatio
const x = Math.cos(theta) * radiusAtY
const z = Math.sin(theta) * radiusAtY
const smallBallMaterial = new THREE.MeshBasicMaterial({
color: getRandomBrightColor(),
depthWrite: true,
depthTest: true,
side: THREE.FrontSide,
})
const smallBall = new THREE.Mesh(smallBallGeometry, smallBallMaterial)
smallBall.position.set(x * radius, y * radius, z * radius)
6.超出长度的标签采用贴图采样位移
来实现跑马灯效果
7.滚动阻尼,鼠标转动球体之后速度能衰减到转动旋转的速率
8.自动旋转需要保持上一次滚动的方向
9.使用射线拾取
来实现点击交互
完整代码
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<title>3D 半透明球体与可交互小球</title>
<style>
body {
margin: 0;
background-color: black;
touch-action: none;
}
canvas {
display: block;
}
</style>
</head>
<body>
<script type="module">
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js'
// 创建场景
const scene = new THREE.Scene()
// 创建相机
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
)
camera.position.set(0, 0, 14)
camera.lookAt(0, 0, 0)
// 创建渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.setPixelRatio(window.devicePixelRatio)
renderer.setClearColor(0x000000, 0)
document.body.appendChild(renderer.domElement)
// 创建半透明球体
const sphereGeometry = new THREE.SphereGeometry(4.85, 16, 16)
const sphereMaterial = new THREE.ShaderMaterial({
uniforms: {
color: { value: new THREE.Color(0x000000) },
opacity: { value: 0.8 },
},
vertexShader: `
varying vec3 vNormal;
void main() {
vNormal = normalize(normalMatrix * normal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0);
}
`,
fragmentShader: `
uniform vec3 color;
uniform float opacity;
varying vec3 vNormal;
void main() {
float alpha = opacity * smoothstep(0.5, 1.0, vNormal.z);
gl_FragColor = vec4(color, alpha);
}
`,
transparent: true,
side: THREE.FrontSide,
depthWrite: false,
})
const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial)
scene.add(sphere)
// 创建小球体和标签数组
const smallBallGeometry = new THREE.SphereGeometry(0.15, 16, 16)
const smallBalls = []
const labelSprites = []
const radius = 5
const numPoints = 88
const goldenRatio = (1 + Math.sqrt(5)) / 2
const maxWidth = 160
const textSpeed = 0.002
// 创建射线投射器
const raycaster = new THREE.Raycaster()
const mouse = new THREE.Vector2()
function createTextTexture(text, parameters = {}) {
const {
fontSize = 24,
fontFace = 'PingFang SC, Microsoft YaHei, Noto Sans, Arial, sans-serif',
textColor = 'white',
backgroundColor = 'rgba(0,0,0,0)',
maxWidth = 160,
} = parameters
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
context.font = `${fontSize}px ${fontFace}`
const textMetrics = context.measureText(text)
const textWidth = Math.ceil(textMetrics.width)
const textHeight = fontSize * 1.2
const needMarquee = textWidth > maxWidth
let canvasWidth = maxWidth
if (needMarquee) {
canvasWidth = textWidth + 60
}
canvas.width = canvasWidth
canvas.height = textHeight
context.font = `${fontSize}px ${fontFace}`
context.clearRect(0, 0, canvas.width, canvas.height)
context.fillStyle = backgroundColor
context.fillRect(0, 0, canvas.width, canvas.height)
context.fillStyle = textColor
context.textAlign = needMarquee ? 'left' : 'center'
context.textBaseline = 'middle'
if (needMarquee) {
context.fillText(text, 0, canvas.height / 2)
} else {
context.fillText(text, maxWidth / 2, canvas.height / 2)
}
const texture = new THREE.CanvasTexture(canvas)
texture.needsUpdate = true
if (needMarquee) {
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.ClampToEdgeWrapping
texture.repeat.x = maxWidth / canvas.width
} else {
texture.wrapS = THREE.ClampToEdgeWrapping
texture.wrapT = THREE.ClampToEdgeWrapping
}
texture.minFilter = THREE.LinearFilter
texture.magFilter = THREE.LinearFilter
texture.generateMipmaps = false
return { texture, needMarquee, HWRate: textHeight / maxWidth }
}
for (let i = 0; i < numPoints; i++) {
const y = 1 - (i / (numPoints - 1)) * 2
const radiusAtY = Math.sqrt(1 - y * y)
const theta = (2 * Math.PI * i) / goldenRatio
const x = Math.cos(theta) * radiusAtY
const z = Math.sin(theta) * radiusAtY
const smallBallMaterial = new THREE.MeshBasicMaterial({
color: getRandomBrightColor(),
depthWrite: true,
depthTest: true,
side: THREE.FrontSide,
})
const smallBall = new THREE.Mesh(smallBallGeometry, smallBallMaterial)
smallBall.position.set(x * radius, y * radius, z * radius)
sphere.add(smallBall)
smallBalls.push(smallBall)
const labelText = getRandomNickname()
const { texture, needMarquee, HWRate } = createTextTexture(labelText, {
fontSize: 28,
fontFace: 'PingFang SC, Microsoft YaHei, Noto Sans, Arial, sans-serif',
textColor: '#bbbbbb',
maxWidth: maxWidth,
})
const spriteMaterial = new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthWrite: true,
depthTest: true,
blending: THREE.NormalBlending,
})
const sprite = new THREE.Sprite(spriteMaterial)
sprite.scale.set(1, HWRate, 1)
labelSprites.push({ sprite, smallBall, texture, needMarquee, labelText })
scene.add(sprite)
}
// 添加灯光
const light = new THREE.AmbientLight(0xffffff, 0.5)
scene.add(light)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1)
directionalLight.position.set(5, 5, 5)
scene.add(directionalLight)
// 定义自动旋转速度和轴
const autoRotationSpeed = 0.0005
let autoRotationAxis = new THREE.Vector3(0, 1, 0).normalize()
let currentAngularVelocity = autoRotationAxis.clone().multiplyScalar(autoRotationSpeed)
let isDragging = false
let previousMousePosition = { x: 0, y: 0 }
let lastDragDelta = { x: 0, y: 0 }
const decayRate = 0.92
const increaseRate = 1.02
// 鼠标事件处理
const onMouseDown = (event) => {
isDragging = true
previousMousePosition = {
x: event.clientX,
y: event.clientY,
}
}
const onMouseMove = (event) => {
if (isDragging) {
const deltaX = event.clientX - previousMousePosition.x
const deltaY = event.clientY - previousMousePosition.y
lastDragDelta = { x: deltaX, y: deltaY }
const rotationFactor = 0.005
const angleY = deltaX * rotationFactor
const angleX = deltaY * rotationFactor
const quaternionY = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 1, 0),
angleY
)
const quaternionX = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(1, 0, 0),
angleX
)
const deltaQuat = new THREE.Quaternion().multiplyQuaternions(quaternionY, quaternionX)
sphere.quaternion.multiplyQuaternions(deltaQuat, sphere.quaternion)
const dragRotationAxis = new THREE.Vector3(deltaY, deltaX, 0).normalize()
const dragRotationSpeed = Math.sqrt(deltaX * deltaX + deltaY * deltaY) * rotationFactor
if (dragRotationAxis.length() > 0) {
currentAngularVelocity.copy(dragRotationAxis).multiplyScalar(dragRotationSpeed)
}
previousMousePosition = {
x: event.clientX,
y: event.clientY,
}
}
}
const onMouseUp = () => {
if (isDragging) {
isDragging = false
const deltaX = lastDragDelta.x
const deltaY = lastDragDelta.y
if (deltaX !== 0 || deltaY !== 0) {
const newAxis = new THREE.Vector3(deltaY, deltaX, 0).normalize()
if (newAxis.length() > 0) {
autoRotationAxis.copy(newAxis)
}
const dragSpeed = currentAngularVelocity.length()
if (dragSpeed > autoRotationSpeed) {
// 维持当前旋转速度
} else {
currentAngularVelocity.copy(autoRotationAxis).multiplyScalar(autoRotationSpeed)
}
}
}
}
// 触摸事件处理
const onTouchStart = (event) => {
isDragging = true
const touch = event.touches[0]
previousMousePosition = {
x: touch.clientX,
y: touch.clientY,
}
}
const onTouchMove = (event) => {
event.preventDefault()
if (isDragging) {
const touch = event.touches[0]
const deltaX = touch.clientX - previousMousePosition.x
const deltaY = touch.clientY - previousMousePosition.y
lastDragDelta = { x: deltaX, y: deltaY }
const rotationFactor = 0.002
const angleY = deltaX * rotationFactor
const angleX = deltaY * rotationFactor
const quaternionY = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 1, 0),
angleY
)
const quaternionX = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(1, 0, 0),
angleX
)
const deltaQuat = new THREE.Quaternion().multiplyQuaternions(quaternionY, quaternionX)
sphere.quaternion.multiplyQuaternions(deltaQuat, sphere.quaternion)
const dragRotationAxis = new THREE.Vector3(deltaY, deltaX, 0).normalize()
const dragRotationSpeed = Math.sqrt(deltaX * deltaX + deltaY * deltaY) * rotationFactor
if (dragRotationAxis.length() > 0) {
currentAngularVelocity.copy(dragRotationAxis).multiplyScalar(dragRotationSpeed)
}
previousMousePosition = {
x: touch.clientX,
y: touch.clientY,
}
}
}
const onTouchEnd = (event) => {
if (isDragging) {
isDragging = false
const deltaX = lastDragDelta.x
const deltaY = lastDragDelta.y
if (deltaX !== 0 || deltaY !== 0) {
const newAxis = new THREE.Vector3(deltaY, deltaX, 0).normalize()
if (newAxis.length() > 0) {
autoRotationAxis.copy(newAxis)
}
const dragSpeed = currentAngularVelocity.length()
if (dragSpeed > autoRotationSpeed) {
// 维持当前旋转速度
} else {
currentAngularVelocity.copy(autoRotationAxis).multiplyScalar(autoRotationSpeed)
}
}
}
// 检查点击事件
if (event.changedTouches.length > 0) {
const touch = event.changedTouches[0]
mouse.x = (touch.clientX / window.innerWidth) * 2 - 1
mouse.y = -(touch.clientY / window.innerHeight) * 2 + 1
checkIntersection()
}
}
// 事件监听
window.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
window.addEventListener('touchstart', onTouchStart)
window.addEventListener('touchmove', onTouchMove)
window.addEventListener('touchend', onTouchEnd)
document.addEventListener('gesturestart', function (e) {
e.preventDefault()
})
// 添加点击事件监听
window.addEventListener('click', onMouseClick)
// 处理窗口大小调整
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
})
function onMouseClick(event) {
event.preventDefault()
mouse.x = (event.clientX / window.innerWidth) * 2 - 1
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
console.log(event.clientX, mouse.x, mouse.y)
checkIntersection()
}
function checkIntersection() {
raycaster.setFromCamera(mouse, camera)
const intersects = raycaster.intersectObjects(smallBalls)
if (intersects.length > 0) {
const intersectedBall = intersects[0].object
const index = smallBalls.indexOf(intersectedBall)
if (index !== -1) {
const labelInfo = labelSprites[index]
showLabelInfo(labelInfo)
}
}
}
function showLabelInfo(labelInfo) {
alert(`点击的小球标签:${labelInfo.labelText}`)
}
// 动画循环
function animate() {
requestAnimationFrame(animate)
if (!isDragging) {
const deltaQuat = new THREE.Quaternion().setFromEuler(
new THREE.Euler(
currentAngularVelocity.x,
currentAngularVelocity.y,
currentAngularVelocity.z,
'XYZ'
)
)
sphere.quaternion.multiplyQuaternions(deltaQuat, sphere.quaternion)
const currentSpeed = currentAngularVelocity.length()
if (currentSpeed > autoRotationSpeed) {
currentAngularVelocity.multiplyScalar(decayRate)
if (currentAngularVelocity.length() < autoRotationSpeed) {
currentAngularVelocity.copy(autoRotationAxis).multiplyScalar(autoRotationSpeed)
}
} else if (currentSpeed < autoRotationSpeed) {
currentAngularVelocity.multiplyScalar(increaseRate)
if (currentAngularVelocity.length() > autoRotationSpeed) {
currentAngularVelocity.copy(autoRotationAxis).multiplyScalar(autoRotationSpeed)
}
} else {
currentAngularVelocity.copy(autoRotationAxis).multiplyScalar(autoRotationSpeed)
}
}
// 更新标签的位置和跑马灯效果
labelSprites.forEach(({ sprite, smallBall, texture, needMarquee }) => {
smallBall.updateMatrixWorld()
const smallBallWorldPos = new THREE.Vector3()
smallBall.getWorldPosition(smallBallWorldPos)
const upOffset = new THREE.Vector3(0, 0.3, 0)
sprite.position.copy(smallBallWorldPos).add(upOffset)
if (needMarquee) {
texture.offset.x += textSpeed
if (texture.offset.x > 1) {
texture.offset.x = 0
}
}
})
renderer.render(scene, camera)
}
animate()
function getRandomBrightColor() {
const hue = Math.floor(Math.random() * 360)
const saturation = Math.floor(Math.random() * 40 + 10)
const lightness = Math.floor(Math.random() * 40 + 40)
const rgb = hslToRgb(hue, saturation, lightness)
return (rgb.r << 16) | (rgb.g << 8) | rgb.b
}
function hslToRgb(h, s, l) {
s /= 100
l /= 100
const c = (1 - Math.abs(2 * l - 1)) * s
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
const m = l - c / 2
let r, g, b
if (h >= 0 && h < 60) {
r = c
g = x
b = 0
} else if (h >= 60 && h < 120) {
r = x
g = c
b = 0
} else if (h >= 120 && h < 180) {
r = 0
g = c
b = x
} else if (h >= 180 && h < 240) {
r = 0
g = x
b = c
} else if (h >= 240 && h < 300) {
r = x
g = 0
b = c
} else {
r = c
g = 0
b = x
}
return {
r: Math.round((r + m) * 255),
g: Math.round((g + m) * 255),
b: Math.round((b + m) * 255),
}
}
function getRandomNickname() {
const adjectives = [
'Cool',
'Crazy',
'Mysterious',
'Happy',
'Silly',
'Brave',
'Smart',
'Swift',
'Fierce',
'Gentle',
]
const nouns = [
'Tiger',
'Lion',
'Dragon',
'Wizard',
'Ninja',
'Pirate',
'Hero',
'Ghost',
'Phantom',
'Knight',
]
const randomAdjective = adjectives[Math.floor(Math.random() * adjectives.length)]
const randomNoun = nouns[Math.floor(Math.random() * nouns.length)]
const nickname = `${randomAdjective} ${randomNoun}`
if (nickname.length < 2) {
return getRandomNickname()
} else if (nickname.length > 22) {
return nickname.slice(0, 22)
}
return nickname
}
</script>
</body>
</html>
来源:juejin.cn/post/7425249244850913280
当前端遇到了自动驾驶
这是一篇用ThreeJS开发自动驾驶点云标注系统的实战记录,也是《THREEJS无师自通》的第一篇。通常情况,一个系列文章开篇应该是Quick Start或者Guide之类的简单口水文,而我选择以此开篇,最主要的原因还是因为这次经历足够有趣。
公众号|沐洒(ID:musama2018)
前端开发,大家熟啊,有很多亲(bi)切(shi)的称谓,诸如“切图仔”,“Bug路由器”。自动驾驶,大家更熟了吧,最近几年但凡新能源汽车,谁要是不说自己搭配点自动驾驶(或辅助驾驶)功能,都不好意思拿出来卖。那么,当前端和自动驾驶碰到了一起,会发生什么有意思的事呢?
有点云标注相关背景的可以跳过背景普及,直接看方案。
背景
去年9月,我们业务因为某些原因(商业机密)开始接触自动驾驶领域的数据处理,经过仔细一系列调研和盘算,我们最终决定从零开始,独立自研一套自动驾驶点云数据标注系统。你可能要说了,自动驾驶我知道啊,但是“点云”是个啥?呐,就是这玩意儿:
点云的学术定义比较复杂,大家可以自行搜索学习,这里我简单贴一个引述:
点云是指目标表面特性的海量点集合。
根据激光测量原理得到的点云,包括三维坐标(XYZ)和激光反射强度(Intensity)。
根据摄影测量原理得到的点云,包括三维坐标(XYZ)和颜色信息(RGB)。
结合激光测量和摄影测量原理得到点云,包括三维坐标(XYZ)、激光反射强度(Intensity)和颜色信息(RGB)。
在获取物体表面每个采样点的空间坐标后,得到的是一个点的集合,称之为“点云”(Point Cloud)。
看不懂?没事,不重要,你只需要知道,我们周围的世界,都是点构成的,而点云只不过是用一些仪器(比如激光雷达),对真实世界进行了采样(且只对部分属性进行采样)。
好了,假设你已经知道“点云”是啥了,但你心里肯定还有十万个为什么:
你不是说自动驾驶么?前端呢?这仨有啥关联么?这东西自研成本很高么?
别急,容我慢慢解释,先快速普及一下啥叫“数据标注”:
人工智能数据标注是对文本、视频、图像等元数据进行标注的过程,标记好的数据将用于训练机器学习的模型。常见的数据标注类型有文本标注、语义分割和图像视频标注。
这些经标注的训练数据集可用于训练自动驾驶、聊天机器人、翻译系统、智能客服和搜索引擎等人工智能应用场景之中
假设你懒得看,或者看不懂,我再给你翻译翻译,什么叫数据标注:
一个婴儿来到这个世界,你在它面前放两张卡片,一张红色,一张绿色,你问它,这是什么颜色,它必然是不知道的(我们假设它能听懂并理解你的话)。只有当你一遍又一遍的,不断的告诉它,这是红色,这是绿色,它才会记住。等下次你带它过马路时,它就能准确地识别出红绿灯,并在你面前大声喊出来 “红色(的灯)!”没错,你应该猜到了,那两张卡片本身没有标签(元数据),是你给它们“打上了标”(分别标注了红色和绿色),然后把这个“结构化的数据”,“喂养”给你的宝宝,久而久之,这个宝宝就学会了分辨世间万物,成为一个“智人”。
(图片来源于网络)
你的“喂养”,就是人工;宝宝的成长,就是智能。人工智能(AI,Artificial Intelligence),就是数据喂养的成果,没有数据标注,就没有人工智能。
从这个意义上聊,你和我,都是别人(父母,老师,朋友…)用成千上万的标注数据喂养出来的AI。
扯远了,收!我们说回自动驾驶。
大家都知道现在自动驾驶很火啊,那自动驾驶的“智能”是怎么训练的呢?当然是算法工程师用模型训练出来的啦,而自动驾驶模型需要喂养的数据,就是点云。仪器扫描回来的点云数据里,仅仅只是包含了所有点的基本信息(位置,颜色,激光强度等),模型怎么知道这个点是人身上采的,还是出租车上采的呢?!
(图片来源于网络)
于是这些点就需要被加工(标注),被我们用一系列手段(包括人工和机器)给点赋予更多的信息,区分出每一个点的含义(语义分割)。在自动驾驶领域的点云标注里,我们需要通过2D+3D工具,把物体识别出来。本文重点讲3D的部分。可以先看下3D框的效果:
(图中黄色高亮的点,就是被3D框圈中的点云)
挑战
以往我们较为常见的数据标注,主要集中在文本,图片,视频等类型,例如文本翻译,音频转写,图片分类等等,涉及的工具基本上都是传统web开发知识可以搞定的,而点云标注则完全不同,点云需要作为3D数据渲染到立体空间内,这就需要使用到3D渲染引擎。我们使用的是ThreeJS,这是一个基于WebGL封装的3D引擎。
写了10年的web前端代码,能有机会把玩一下3D技术,还真是挺令人兴奋的。于是我们吭哧吭哧把基本的3D拉框功能做出来了,效果是这样的:
(3D拉框 - 人工调整边缘:2倍速录制)
动图是我加了2倍速的效果,真实情况是,要标出图上这辆小汽车,我需要先拉出一个大概的2D矩形区域,然后在三视图上不断的人工调整边缘细节,确保把应该纳入的点都框进去(未框入的点呈白色,框体垂直方向未框入则呈现蓝色,框入的呈现黄色)
看起来好像也还行?
no,no,no!你知道一份完整的点云标注任务需要标多少个框么?也不吓唬大家,保守点,一般情况一份连续帧平均20帧左右,每帧里要标注的框体保守点,取100个吧,而这一份连续帧标注,必须同一个标注员完成,那么20帧至少有2000个框体需要标注!
按照上面实现的这种人工调节边缘的方式来拉框,一个框需要22秒(GIF共11秒,2倍速),熟练工可能能在10秒内调整完成。那么2000个框体,单纯只是拉框这一件小事,不包括其他工序(打标等),就需要耗费20000秒,约等于5.5小时!
这是什么概念?通常情况标注员都是坐班制,平均一天有效工作时长不超过6小时,也就是说,一个标注员,在工位上一动不动,大气都不敢喘一下的工作一天,就只能把一条点云数据标完,哦不对,仅仅只是拉完框!没错,只是拉框而已。
这种低效的重复性工作,哪个组织受得了?怎么办呢?
方法比较容易想,不就是引入自动化能力么,实现自动边缘检测,嗯,想想倒是挺简单的,问题是怎么实现呢?
以下进入干货区,友情提示:货很干,注意补水。
方案
点云分类
基本思路就是进行边缘探测:
找出三个坐标轴(XYZ)方向上的框体边缘点,计算出边缘点之间的距离,调整框体的长宽高,进而将框体贴合到边缘点。
边缘的定义:
某方向上的同值坐标点数大于某个设定值(可配置,默认3,三者为众)
找出边缘点的核心算法:
遍历框体内的点,分别将XYZ方向的坐标值存入数组,加权,排序,取第一个满足边缘定义的点,作为该方向极限值。
进行边缘判定之前,我们得先找出存在于框体内的点,这就涉及到第一个核心问题:点云和3D框的相对位置判断。
为了更好的管理与框体“强相关”的点云,我们先对点云进行一个基本分类:
从俯视图看,把3D图降维成2D图,立方体则看作矩形,如下图:
则点与框的相对位置可以降维等效为:
第一类(点在立方体内)
点在矩形内,且点的Z值在立方体[Zmin, Zmax]范围内
第二类(点在立方体垂直方向)
点在矩形内,且Z值在立方体[Zmin, Zmax]范围外
第三类(点在立方体周围)
点在延展矩形(向外延展N个距离)内,且不属于第二类。
我们先按这个思路实现一版代码:
// 判断点是否位于框体XY平面区域内
function isPointInXYPlane(gap: IGap, distance = 0) {
const { gapL, gapR, gapB, gapU } = gap;
// 在框体XY平面区域内
return gapL > - distance && gapR < distance && gapU < distance && gapB > - distance;
}
// 在框体垂直方向上下边界内
function isPointInVerticalBoundry(up: number, bottom: number, z: number) {
return z >= bottom && z <= up;
}
// 位于框体XY平面向外延伸NEAR_DISTANCE距离的区域内
if (isPointInXYPlane(posInfo.gap, NEAR_DISTANCE)) {
const isInVerticalBoundry = isPointInVerticalBoundry(posInfo.up, posInfo.bottom, posInfo.z);
// 位于框体XY平面区域内
if (isPointInXYPlane(posInfo.gap)) {
// 在框体内
if (isInVerticalBoundry) {
isInside = true;
} else {
// 在框体外的垂直方向上
isVertical = true;
}
}
// 在框体上下边界内
if (isInVerticalBoundry) {
isNearBy = true;
}
}
通过以上逻辑,我们就拿到了与框体“相关”的点云(正确与否先按下不表,后面会说),我们先存起来,后面做极值寻找(即边缘检测)时候使用。
第一版效果
看起来好像还行,基本实现了贴合,但是……我们旋转一下看看:
好家伙,旋转后框体边界没更新!所以点云高亮也没变化。
这个问题其实也好理解,我们在处理边界的时候,只采用position和scale计算,并没有使用rotation属性,所以当框体的旋转分量发生变化,我们计算边界时没有及时调整,程序就会认为框体此时仍然留在原地未动呢。
我们来优化一下。我先尝试用三角函数来计算旋转后的新坐标点,类似这样
折腾了很久的三角函数,有点变化了,但是效果却成了这样:
已经接近真相了,只需要把待判定点放到三角函数判定公式里,就可以知道该点是否在旋转后的框体内了,不过到这里我突然意识到问题被我搞复杂了,是不是可以有更简单的方法来判定矩形内部点呢?
我们回到最初的问题:判断一个点,与一个立方体的相对位置
对这个原始问题进行逻辑拆解,可以拆为3个子问题:
- 如何判断一个点位于立方体内部?
- 如何判断一个点位于立方体的垂直方向(排除体内点)?
- 如何判断一个点位于立方体的周围(排除垂直方向点)?
关于问题1,第一反应还是立体几何,而且我笃定这是个非常成熟的几何问题,没必要自己硬憋。于是我就上网搜索:How to determine a point is inside or outside a cube? 结果如下:
上面是stackoverflow上大神给的两种数学方法,一看就知道能解,奈何我看图是看懂了,公式没有完全吸收透,于是最终没有采纳(尽量不干不求甚解的事,写成代码就要求自己得是真的懂)
于是我进一步思考:
几种数学方法确实都很虎,但我是不是把问题搞复杂了?能不能没事踩踩别人的肩膀呢?
看看ThreeJS 是否有相应的API……果然有:
这不正好就是我想要的效果么?踏破铁鞋无觅处,得来全不费功夫啊!
直接拿来用,搞定!
但问题来了,人家是怎么做到的呢?带着这个疑问,我开始翻相关源码。
首先看到containsPoint,其实就和我们用的方法是一样的:
// https://github.com/mrdoob/three.js/blob/4503ef10b81a00f5c6c64fe9a856881ee31fe6a3/src/math/Box3.js#L243
containsPoint( point ) {
return point.x < this.min.x || point.x > this.max.x ||
point.y < this.min.y || point.y > this.max.y ||
point.z < this.min.z || point.z > this.max.z ? false : true;
}
而核心问题还是得想办法计算出box.min和box.max,那ThreeJS是怎么计算的呢?继续看:
// https://github.com/mrdoob/three.js/blob/4503ef10b81a00f5c6c64fe9a856881ee31fe6a3/src/core/BufferGeometry.js#L290
computeBoundingBox() {
// ..... 省略部分代码 ....
const position = this.attributes.position;
if ( position !== undefined ) {
this.boundingBox.setFromBufferAttribute(position);
}
// ..... 省略部分代码 ....
}
看起来boundingBox的属性来自于attributes.position,这个position就是box在世界坐标里的具体位置,是我们在创建box时候设定的。再继续深挖下setFromBufferAttribute:
// https://github.com/mrdoob/three.js/blob/4503ef10b81a00f5c6c64fe9a856881ee31fe6a3/src/math/Box3.js#L56
setFromBufferAttribute( attribute ) {
// ..... 省略部分代码 ....
for ( let i = 0, l = attribute.count; i < l; i ++ ) {
const x = attribute.getX( i );
const y = attribute.getY( i );
const z = attribute.getZ( i );
if ( x < minX ) minX = x;
if ( y < minY ) minY = y;
if ( z < minZ ) minZ = z;
if ( x > maxX ) maxX = x;
if ( y > maxY ) maxY = y;
if ( z > maxZ ) maxZ = z;
}
this.min.set( minX, minY, minZ );
this.max.set( maxX, maxY, maxZ );
return this;
}
平平无奇啊这代码,几乎和我们自己写的边界判定代码一模一样啊,也没引入rotation变量,那到底怎么是在哪处理的旋转分量呢?
关键点在这里:
我尝试给你解释下:
在调用containsPoint之前,我们使用box的转换矩阵,对point使用了一次矩阵逆变换,从而把point的坐标系转换到了box的坐标系,而这个转换矩阵,是一个Matrix4(四维矩阵),而point是一个Vector3(三维向量)。
使用四维矩阵对三维向量进行转换的时候,会逐一提取出矩阵的position(位置),scale(缩放)和rotation(旋转)分量,分别对三维向量做矩阵乘法。
也就是这么一个操作,使得该point在经过矩阵变换之后,其position已经是一个附加了rotation分量的新的坐标值了,然后就可以直接拿来和box的8个顶点的position做简单的边界比对了。
这里涉及大量的数学知识和ThreeJS底层知识,就不展开讲了,后面找机会单独写一篇关于转换矩阵的。
我们接着看点与框体相对位置判断的第二个问题:如何判断一个点位于立方体的垂直方向(排除体内点)?
首先,我们置换下概念:
垂直方向上的点 = Z轴方向上的点 = 从俯视图看,在XY平面上投射的点 - 框内点
那么,如何判断一个点在一个矩形内,这个问题就进一步转化为:
(AB X AE ) * (CD X CE) >= 0 && (DA X DE ) * (BC X BE) >= 0
这里涉及到的数学知识是向量点乘和叉乘的几何意义,也不展开了,感兴趣的朋友可以自行搜索学习下。
还剩最后一个问题:如何判断一个点位于立方体的周围(排除垂直方向点)?
这个问题我们先放一放,周围点判断主要用来扩展框体的,并不影响本次的边界探测结果,以后再找机会展开讲,这里先跳过了。
到此为止,我们就至少拿到了两类点(框内点,和框体垂直方向的点),接下来就可以开始探测边缘了。
边缘探测
边缘探测的核心逻辑其实也不复杂,就是:
遍历框体内的点,分别将X,Y,Z方向的坐标值存入数组,加权,排序,取第一个满足边缘定义的点,作为该方向极限值。
这里我们可以拆分位两个Step。
Step 1:点位排序
基本思路如下:
选择一个方向,遍历点云,取到该方向上点云的坐标值,放入一个map中,key为坐标值,value为出现次数。同时对该坐标进行排序,并返回有序数组。**
那么问题来了,点云的坐标值多半精确到小数点七八位,如果直接以原值作为key,那么这个map很难命中重复坐标,那map的意义就不大了,难以聚合坐标。
于是这里对原坐标取2个精度后作为key来聚合点云,效果如下:
可以明显看到已经有聚合了。这是源码实现:
Step 2:夹逼探测
拿到了点云坐标的聚合map,和排序数组,那么现在要检测边缘就很简单了,基本思路就是:
从排序数组的两头开始检查,只要该点的聚合度大于DENSE_COUNT(根据需要设置,默认为3),我们就认为这个点是一个相对可信的边缘点。
从这个算法描述来看,这不就是个夹逼算法么,可以一次遍历就拿到两个极值。
到这里,某方向的两个极值(low 和 high)就拿到手了,那么剩下的工作无非就是分别计算XYZ三个方向的极值就好了。
我们来看下效果,真的是“啪”一下,就贴上去了:
上面的案例录制的比较早,有点模糊,再来看个高清带色彩的版本:
这个体验是不是很丝滑?就这效率,拉框速度提升了10倍有吧?(22秒 -> 2秒)
读到这里,不知道大家还记不记得前面,我们刻意跳过了一个环节的介绍,就是“框体周围点位”这一部分,这里简单补充两句吧。
在实际的场景里,有很多物体是靠得很近的,还有很多物体的点云并没有那么整齐,会有一些离散点在物体周围。那么这些点就可能会影响到你的边缘极限值的判断。
因此我在这里引入了两个常量:
附近点判定距离 NEAR_DISTANCE(框体紧凑的场景,NEAR_DISTANCE就小一点,否则就大一点)!
密集点数 DENSE_COUNT(点云稀少的场景,就可以把DENSE_COUNT设置小一点,而点云密集厚重的场景,DENSE_COUNT就适当增加。)
通过在不同的场景下,调整这两个常量的取值,就可以使得边缘探测更加的准确。
遗留问题
其实在3D的世界里,多一个维度之后,很多问题都会变得更加的麻烦起来。上面的方案,在处理大部分场景的时候都能work,但实际上依然有一些小众场景下存在问题,比如:
平时多半都是物体都是围绕Z轴旋转,但如果有上下坡路,物体围绕XY轴旋转,那垂直方向就需要进行矫正。
再比如:
用户移动了镜头方位,在屏幕上拉2D框的时候,就需要对2D框采集到的坐标进行3D投射,拿到真实的世界坐标,才能创建合适的立方体。
当然,这些问题在后面的版本都已经完善修复了,之所以放在遗留问题,是想说明,仅仅依照正文部分的方法去实现的话,还会有这些个遗留的问题需要单独处理。
如果大家感兴趣的话可以留言告诉我,我再决定要不要接着写。
来源:juejin.cn/post/7422338076528181258
2024:写 TypeScript 必须改掉的 10 个坏习惯
大家好,我是CodeQi! 一位热衷于技术分享的码仔。
在过去的几年里,TypeScript 已经逐渐成为了前端开发的首选语言,尤其是那些追求更高代码质量和类型安全的开发者。不过,正如所有编程语言一样,随着时间的推移和技术的进步,我们的编程习惯也应该与时俱进。
👋 你有没有想过,自己在写 TypeScript 时是否养成了一些“坏习惯”?
随着 TypeScript 生态系统的进一步成熟,有些你以前觉得合理的做法,现在可能不太合理。接下来,我将分享10 个常见的 TypeScript 坏习惯,并告诉你如何改进它们,确保你的代码更健壮、性能更高、并且更加易于维护。
1. 不使用 strict 模式
当开发者为了减少“麻烦”而禁用 TypeScript 的 strict
模式时,往往是在给自己埋雷。💣
为什么不好?
strict
模式通过强制进行更严格的类型检查,帮助我们避免潜在的错误。如果你关掉它,TypeScript 就变得更像是 JavaScript,失去了静态类型带来的种种好处。短期内你可能会觉得更自由,但未来的重构和维护将变得更加棘手。
怎么改进?
在 tsconfig.json
中启用 strict
模式,这样你的代码在未来的迭代中会更加稳健:
{
"compilerOptions": {
"strict": true
}
}
2. 依赖 any 类型
any
可能是 TypeScript 中最具“争议”的类型之一,因为它违背了我们使用 TypeScript 的初衷:类型安全。
为什么不好?
any
让 TypeScript 失去意义。它让代码回归到“JavaScript 模式”,绕过了类型检查,最终可能导致各种运行时错误。
怎么改进?
使用 unknown
替代 any
,并在实际使用前对类型进行检查。unknown
更安全,因为它不会自动允许任何操作:
let data: unknown;
if (typeof data === "string") {
console.log(data.toUpperCase());
}
3. 过度使用类型断言
你是否经常用 as
关键字来“消除”编译错误?🙈 这种做法短期内看似有效,但可能会隐藏更多问题。
为什么不好?
类型断言会绕过 TypeScript 的安全机制,告诉编译器“别管了,我知道自己在做什么”。问题是,当你其实并不完全确定时,它会导致难以追踪的运行时错误。
怎么改进?
减少类型断言,使用类型保护函数代替:
function isString(value: unknown): value is string {
return typeof value === 'string';
}
if (isString(data)) {
console.log(data.toUpperCase());
}
4. 忽视联合类型和交叉类型
联合类型 (|
) 和交叉类型 (&
) 是 TypeScript 中极其强大的工具,但它们经常被忽视。🚫
为什么不好?
没有联合和交叉类型,代码容易变得冗长而难以维护。你可能会写大量的冗余代码,而这些类型可以帮你更简洁地表达逻辑。
怎么改进?
使用联合类型来处理不同情况,交叉类型来组合多个类型:
type Admin = { isAdmin: true; privileges: string[] };
type User = { isAdmin: false; email: string };
type Person = Admin | User;
function logUser(person: Person) {
if (person.isAdmin) {
console.log(person.privileges);
} else {
console.log(person.email);
}
}
5. 使用非特定的返回类型
不为函数指定精确的返回类型,可能会让使用者摸不着头脑。🤔
为什么不好?
模糊的返回类型增加了代码的不确定性,调试难度也会增加。你失去了静态类型的优势,最终使代码变得不可靠。
怎么改进?
始终为函数指定明确的返回类型,哪怕它是一个联合类型:
function fetchData(): Promise<{ id: number; name: string }> {
return fetch("/data").then(response => response.json());
}
6. 忽视 null 和 undefined
一些开发者在处理 null
和 undefined
时掉以轻心,结果导致一堆潜在的运行时错误。
为什么不好?
JavaScript 允许变量为 null
或 undefined
,TypeScript 也有相应的工具帮助处理这些值。如果忽视它们,代码可能会在运行时崩溃。
怎么改进?
使用可选链 (?.
) 和空值合并操作符 (??
) 处理 null
和 undefined
:
const name = user?.profile?.name ?? "Guest";
7. 过度使用 Enums
在 TypeScript 中,Enums
有时会被滥用。尽管它们有其应用场景,但并不总是必要。
为什么不好?
Enums
会增加复杂性,尤其是在简单常量足够的情况下。
怎么改进?
考虑用 const
或字面量类型来替代枚举:
type Role = "Admin" | "User" | "Guest";
let userRole: Role = "Admin";
8. 不使用 readonly
如果不使用 readonly
来防止对象或数组的意外修改,代码中的副作用将难以控制。
为什么不好?
可变性会导致对象在不经意间被修改,造成难以调试的问题。
怎么改进?
尽可能使用 readonly
来确保不变性:
const data: readonly number[] = [1, 2, 3];
9. 忽视自定义类型保护
依赖隐式类型检查而非明确的类型保护,可能导致你错过一些重要的类型问题。
为什么不好?
没有自定义类型保护,你可能会在运行时错过一些类型错误,最终导致不可预期的行为。
怎么改进?
编写明确的类型保护函数:
function isUser(user: any): user is User {
return typeof user.email === "string";
}
10. 没有充分利用 unknown 类型
许多开发者默认使用 any
来处理未知类型,其实 unknown
是一个更好的选择。
为什么不好?
any
禁用了类型检查,而这正是使用 TypeScript 的初衷。unknown
则要求你在使用前对类型进行明确的验证。
怎么改进?
用 unknown
代替 any
,并在使用前进行类型缩小:
let input: unknown;
if (typeof input === "string") {
console.log(input.toUpperCase());
}
总结
2024 年,是时候告别这些坏习惯了!通过启用 strict
模式、避免使用 any
、掌握联合和交叉类型等高级特性,你的 TypeScript 代码将变得更强大、更灵活、更具维护性。希望这些建议能够帮助你在 TypeScript 之路上走得更远,写出更加优雅的代码!✨
来源:juejin.cn/post/7426298029286916146
你小子,一个bug排查一整天,你在🐟吧!
楔子
在每日的例行会议上,空气中弥漫着紧张的气息。一位实习组员语速略急地说道:“昨天我主要的工作是排查一个线上bug
,目前还没有得到解决,今天我得继续排查。”。
组长眉头微皱,冷冷地盯了他一眼:“你小子,一个bug
排查一整天,怕是在摸鱼吧!到底是什么问题?说来听听,我稍后看看。”。
组员无奈地摊了摊手,耸了耸肩,长叹一口气:“前两天,订单表格新增定制信息匹配失败情况的展示。自己没有经过仔细的测试,就直接发布上线了,导致现在整个订单列表渲染缓慢。这个bug
超出了我的能力范围,我排查了一天也排查不出来,摸鱼是404
的。”。
组长深吸一口气,眼神中露出几分聪慧:“那不就是你编写的组件有问题吗?你最好没有摸鱼!不然你就等着吃鱼吧!”。
组员按捺不住心中的窃喜:“我如果不说一天,又怎么能请动你这尊大神呢?”。
排查
果不其然,控制台果真报错了。组长看了眼报错信息,摇了摇头,面色凝重:“你小子,居然都不看控制台的报错信息?这bug怎么排查的?”。组员下意识地捏紧了拳头,声音也不自觉地低了几分,结结巴巴道:“我、我真的不知道控制台还有这操作!学废了。”。
组长怀着忐忑不安的心情打开vsCode
, 只见一大串代码赫然映入眼帘:
<template>
<div class="design-wrapper">
<designProducts v-if="showBtn" btnText="设计" class="mr10" :data="data" @success="success" />
<el-tooltip v-if="showStatus" trigger="click" placement="right" :disabled="disabled">
<baseTable1 class="hide-tabs" :data="tableData" :option="option">
<template #content="{ row }">
<defaultImg v-if="imageType(row)" :src="image(row)" :size="100" />
<span v-else>{{ text(row) }}</span>
</template>
<template #mapping="{ row }">
<i :class="icon(row)"></i>
</template>
<template #importLabelCode="{ row }">
<span v-if="!row.hide">{{ row.importLabelCode }}</span>
</template>
<template #matchStatus="{ row }">
{{ matchSuccess(row) ? '已匹配' : '未匹配' }}
</template>
<template #design="{ row }">
<defaultImg
v-if="!row.hide && imageType(row)"
:disabled="row.disabled"
:src="row.importContent"
:size="100"
@error="error(row)"
>
<template #defaultImg>
<div class="flex-middle">{{ row.importContent || '无' }}</div>
</template>
</defaultImg>
<div v-else-if="!row.hide && !imageType(row)">{{ row.importContent || '无' }}</div>
</template>
</baseTable1>
<color-text-btn slot="reference" @click="toDesign">{{ status }}</color-text-btn>
</el-tooltip>
<span v-else></span>
</div>
</template>
当扫到el-tooltip (文字提示), 组长拍案而起,额头上暴起的青筋在不断颤抖。急切的声音,仿佛要撕裂虚空:“你小子,短短几十行代码,至少犯了2个致命错误!”。
问题分析
1. 从代码源头分析el-tooltip
(控制台报错的原因)
el-tooltip
组件主要是针对文字提示,而el-popover
才是针对组件进行展示。这两者是截然不同的,不然element
也不会分出两套组件,去分别处理这两种情况。- 我们的项目之所以能正常使用
vueX
和router
,是因为我们在main.js
中引入并挂载了
同理,分析
el-tooltip
组件的代码实现,它只挂载了data
属性。因此,当强行在el-tooltip
组件中使用自定义组件:如果组件内部使用的是非国际语言(i18n
)的纯文本,控制台不会报错;如果在该组件中使用了诸如vueX
、路由跳转等在内的变量或者方法时,控制台就会疯狂报错,因为这些并没有在初始化时注入到el-tooltip
组件中。
2. 如何在el-tooltip
中使用i18n
?
假定有一个非常执拗的人,他看到el-tooltip
组件描述的功能是文字提示。他就不想在el-popover
中使用$t
, 而想在el-tooltip
组件中使用i18n
。那么可以做到吗?答案是肯定的。
我们可以直接通过继承法则:封装一个base-tooltip
组件,继承el-tooltip
组件。并根据继承规则:先执行el-tooltip
组件的生命周期钩子方法,再执行base-tooltip
组件里面的生命周期钩子方法。通过这种方式,我们成功挂载了i18n
。此时,在base-tooltip
组件中使用$t
,就不会报错了。
<script>
import { Tooltip } from 'element-ui'
import i18n from '@/i18n'
import Vue from 'vue'
export default {
extends: Tooltip,
beforeCreate() {
this.popperVM = new Vue({
data: { node: '' },
i18n,
render(h) {
return this.node;
}
}).$mount()
}
}
</script>
3. el-tooltip
的局限性(订单列表渲染缓慢的原因)
前文提及,我们可以继承el-tooltip
组件。那么,我们如果通过按需引入的方式,将所需要的资源全部挂载到vue
中。这样,就算在base-tooltip
组件中使用vueX
和$route
变量,也不会在控制台上报错。的确如此,但是我们需要注意到el-tooltip
和el-popover
的局限性: 悬浮框内容是直接渲染的,不是等你打开悬浮框才渲染。
这也就意味着,如果我们在表格的每一行都应用了el-tooltip
或el-popover
组件,而且在el-tooltip
或el-popover
的生命周期钩子函数中请求了异步数据。就会导致页面初始化渲染数据的同时,会请求N
个接口(其中,N
为当前表格的数据条数)。一次性请求大于N + 1
个接口,你就说页面会不会卡顿就完事了!
但是,el-popover
这个组件不一样。在它的组件内部,提供了一个show
方法,这个方法在trigger
触发后才执行。于是,我们可以在show
方法中,去请求我们需要的异步数据。 同时注意一个优化点:在悬浮框打开之后,才渲染Popover内嵌的html文本
,避免页面加载时就渲染数据。
由于el-popover
的内容是在弹窗打开后才异步加载的,弹窗可能会在内容完全加载之前就开始计算和渲染位置,导致弹出的位置不对。但是我们遇到事情不要慌,el-popover
组件的混入中提供了一个方法updatePopper
,用于矫正popover
的偏移量,以期获取正确的popover
布局。
解决方法
将上述所有思路结合在一起,我们就能够封装一个公共组件,兼容工作中的大多数场景。
<template>
<el-popover
ref="popover"
@show="onShow"
v-bind="$attrs"
v-on="$listeners"
>
<template v-if="isOpened">
<slot></slot>
</template>
<template slot="reference">
<slot name="reference"></slot>
</template>
</el-popover>
</template>
<script>
import agentMixin from '@/mixins/component/agentMixin'
export default {
// 方便我们直接调用popover组件中的方法
mixins: [agentMixin({ ref: 'popover', methods: ['updatePopper', 'doClose'] })],
props: {
// 方便在打开悬浮框之前,做一些前置操作,比如数据请求等
beforeOpen: Function
},
data() {
return {
isOpened: false
}
},
methods: {
async onShow() {
if(!this.beforeOpen) {
return this.isOpened = true
}
const res = await this.beforeOpen()
if(!res) return this.isOpened = false
this.isOpened = true
await this.$nextTick()
this.updatePopper()
}
}
}
</script>
/* eslint-disable */
import { isArray, isPlainObject } from 'lodash'
export default function ({ ref, methods } = {}) {
if (isArray(methods)) {
methods = methods.map(name => [name, name])
// 如果传入是对象,可以设置别名,防止方法名重复
} else if (isPlainObject(methods)) {
methods = Object.entries(methods)
}
return {
methods: {
...methods.reduce((prev, [name, alias]) => {
prev[alias] = function (...args) {
return this.$refs[ref][name](...args)
}
return prev
}, {})
}
}
}
<template>
<div class="design-wrapper">
<designProducts v-if="showBtn" btnText="设计" class="mr10" :data="data" @success="success" />
<basePopover v-if="showStatus" trigger="click" placement="right" :beforeOpen="beforeOpen" :disabled="disabled">
<baseTable1 class="hide-tabs" :data="tableData" :option="option">
<template #content="{ row }">
<defaultImg v-if="imageType(row)" :src="image(row)" :size="100" />
<span v-else>{{ text(row) }}</span>
</template>
<template #mapping="{ row }">
<i :class="icon(row)"></i>
</template>
<template #importLabelCode="{ row }">
<span v-if="!row.hide">{{ row.importLabelCode }}</span>
</template>
<template #matchStatus="{ row }">
{{ matchSuccess(row) ? '已匹配' : '未匹配' }}
</template>
<template #design="{ row }">
<defaultImg
v-if="!row.hide && imageType(row)"
:disabled="row.disabled"
:src="row.importContent"
:size="100"
@error="error(row)"
>
<template #defaultImg>
<div class="flex-middle">{{ row.importContent || '无' }}</div>
</template>
</defaultImg>
<div v-else-if="!row.hide && !imageType(row)">{{ row.importContent || '无' }}</div>
</template>
</baseTable1>
<color-text-btn slot="reference" @click="toDesign">{{ status }}</color-text-btn>
</basePopover>
<span v-else></span>
</div>
</template>
<script>
methods: {
async beforeOpen() {
const res = await awaitResolveDetailLoading(
microApi.getMatchInfo({
id: this.data.id
})
)
if (!res) return false
this.tableData = res
return true
}
}
</script>
反思
在组长的悉心指导下,组员逐渐揭开了问题的真相。回想起自己在面对bug
时的轻率和慌乱,他不禁感到一阵羞愧。组长平静而富有耐心的声音再次在耳边响起:“排查问题并非一朝一夕之功。急于上线而忽视测试,只会让问题愈加复杂。”这一番话如同醍醐灌顶,瞬间点醒了他,意识到自己的错误不仅在于代码的疏漏,更在于对整个工作流程的轻视。
“编写代码不是一场竞赛,速度永远无法替代质量。”组长边调试代码,边语重心长地说道。组长的语气虽然平淡,却蕴含着深邃的力量。组员心中的敬佩之意油然而生,细细回味着这番话,顿时明白了面对复杂bug
时,耐心与细致才是解决问题的最强利器。组长的话语简洁而富有哲理,令他意识到,曾经追求的“快速上线”与开发中的严谨要求完全背道而驰。
不久之后,组员陷入了沉思,轻声开口:“起初,我还真觉得自己运气不好,偏偏遇上如此棘手的bug
。但现在看来,这更像是一场深刻的教训。若能在上线前认真测试,这个问题本是可以避免的。”他的声音中透出几分懊悔,眼中闪烁着反思的光芒。
组长微微一笑,点头示意:“每一个bug
都是一次学习的契机,能意识到问题的根源,已是进步。”他稍作停顿,眼神愈加坚定:“编程的速度固然重要,但若未经过深思熟虑的测试与分析,那无疑只是纸上谈兵。写代码不仅需要实现功能,更需要经得起时间的考验。”这番话语透着无可辩驳的真理,给予了组员莫大的启迪。
组员感慨道:“今天的排查让我真正领悟到耐心与细致的重要性。排查bug
就像走出迷宫,急躁只会迷失方向,而冷静思考才能找到出路。”他不禁回忆起自己曾经的粗心大意,心中暗自发誓,今后在每一次提交前都要更加谨慎,绝不再犯同样的错误。
“你小子,倒也不算愚钝。”组长调侃道,嘴角勾起一丝轻松的笑意,“但记住,遇到问题时要先冷静分析错误信息,找出原因再行动。不要盲目修改,开发不仅仅是写代码,更需要学会深思熟虑。”他轻轻拍了拍组员的肩膀,那一拍似乎传达着无限的关心与期望。
这一拍虽轻,却如雷霆般震动着组员的心灵。他明白,这不仅是组长对他的鼓励,更是一份期待与责任的传递。心中顿时涌起一股暖流,他暗自立誓:今后的每一次开发,必将怀揣严谨的态度,赋予每一行代码以深刻的责任感,而不再仅仅是为了完成任务。
在回家的路上,组员默默在心中念道:“这次bug
排查,不仅修复了代码,更矫正了我对待开发工作的态度。感谢组长,给予我如此宝贵的经验和鼓励。”他深知,从这次经历中所学到的,绝不仅是技术层面的知识,更需要以一种成熟与稳重的心态,来面对未来的每一个挑战。
怀着这样的领悟,组员的内心充满了期待。他坚信,这必将成为他在开发道路上迈向更高境界的起点。
来源:juejin.cn/post/7423378897381130277
浅谈“过度封装”
干了很多很多所谓的“敏捷”开发的项目之后,对于封装组件有了新的看法,在这里和大家分享一下
为什么要封装组件
封装组件可以复用共通的代码,增加可读性,可以统一UI样式,可以十分方便的管理代码结构。这是所有同学都知道的封装代码的好处,特别是当公司遇到需要“敏捷”开发一个项目,封装组件可以帮助我们提高效率(为了绩效)
往往我们就会选择开源的成熟的好用的组件库(element-ui、ant design等)这些组件库帮助我们开发的同时,也帮助我们更加高效的完成任务。
但是每个人对使用组件库的理解都不一样,很多可以使用组件库中的组件的地方自己反而会手动实现,虽然看上去像是实现了效果,但是严重的破坏了代码结构,极大的增加了后续的维护工作量,对于这些封装往往都是“过度封装”
浅谈“过度封装”
“过度封装”在不同的项目组同学中都有不一样的理解,但是很难有一个标准,我封装的这个组件到底算不算“过度封装”呢?
- 对与项目中已有的组件做二次封装的封装可以算是“过度封装”
- 手动实现一个组件库中存在的类似的组件在项目中使用可以算是“过度封装”
以上是我对一个组件是否是“过度封装”的理解,也可以判断一个方法是不是“过度封装”
对与项目中已有的组件做二次封装的封装可以算是“过度封装”
当我作为后续开发接手一个快要离职的同事的代码时,往往会在components文件夹里看到很多针对element-ui(antd、等其他的组件库)的table组件做的二次封装
这类的封装往往伴随着一些不够灵活的问题。当一些特殊的页面需要不一样的table设置时,往往需要修改组件的带啊才能支持使用,当这个table支持了很多不同页面的个性化需求之后,大量的props没有一个文档说明后续开发人员要阅读这个封装的组件的源码并熟悉之后快速使用。后续维护产生了大量的工作量,十分不友好。
手动实现一个组件库中存在的类似的组件在项目中使用可以算是“过度封装”
有时候设计稿中出现一个组件和组件库中的很像,但是存在差别的时候,我们需要思考一下,组件库中的组件是否完全支持我们的功能(可以多看看文档上的props,或者打开在线编辑器,调试一下看看),而不是看着有一点点的差异就手动实现一个,比如:tag标签组件、image图像组件等等在项目中基本不去使用,往往直接使用原生标签就手动开发了。
不仅仅是组件当中存在这类问题,封装方法的时候也存在这里问题,明明项目导入了lodash、momentjs、dayjs等库,反而在utils中手动实现formatDate、formatTime、节流防抖、深拷贝等方法,实在令人费解。
关于样式封装
关于组件的样式是最难封装的地方,往往开发到最后每一个文件里面就会出现一大堆的修改样式的代码
就算是在统一的样式文件中同意修改还是不免出现超长的修改颜色的各种代码
对于element-ui实在硬伤,摊牌了、我不会了🤷🏻♀️
所以我推荐使用naiveUI开发,对于样式的处理绝对的一流,加之vue3使用hooks配合组件,开发体验也很不错😎,(arco Design、antd 现在处理统一的样式风格也是很棒了)
总结
简单聊了一下“过度封装”,希望这种代码不会出现在大家的代码当中,不要去封装 my-button、my-table 这种组件,世界会更加美好。(^▽^)
来源:juejin.cn/post/7426643406305296419
还在用轮询、websocket查询大屏数据?sse用起来
常见的大屏数据请求方式
1、http请求轮询:使用定时器每隔多少时间去请求一次数据。优点:简单,传参方便。缺点:数据更新不实时,浪费服务器资源(一直请求,但是数据并不更新)
2、websocket:使用websocket实现和服务器长连接,服务器向客户端推送大屏数据。优点:长连接,客户端不用主动去请求数据,节约服务器资源(不会一直去请求数据,也不会一直去查数据库),数据更新及时,浏览器兼容较好(web、h5、小程序一般都支持)。缺点:有点大材小用,一般大屏数据只需要查询数据不需要向服务端发送消息,还要处理心跳、重连等问题。
3、sse:基于http协议,将一次性返回数据包改为流式返回数据。优点:sse使用http协议,兼容较好、sse轻量,使用简单、sse默认支持断线重连、支持自定义响应事件。缺点:浏览器原生的EventSource不支持设置请求头,需要使用第三方包去实现(event-source-polyfill)、需要后端设置接口的响应头Content-Type: text/event-stream
sse和websocket的区别
- websocket支持双向通信,服务端和客户端可以相互通信。sse只支持服务端向客户端发送数据。
- websocket是一种新的协议。sse则是基于http协议的。
- sse默认支持断线重连机制。websocket需要自己实现断线重连。
- websocket整体较重,较为复杂。sse较轻,简单易用。
Websocket和SSE分别适用于什么业务场景?
根据sse的特点(轻量、简单、单向通信)更适用于大屏的数据查询,业务应用上查询全局的一些数据,比如消息通知、未读消息等。
根据websocket的特点(双向通信)更适用于聊天功能的开发
前端代码实现
sse的前端的代码非常简单
const initSse = () => {
const source = new EventSource(`/api/wisdom/terminal/stats/change/notify/test`);
// 这里的stats_change要和后端返回的数据结构里的event要一致
source.addEventListener('stats_change', function (event: any) {
const types = JSON.parse(event.data).types;
});
// 如果event返回的是message 数据监听也可以这样监听
// source.onmessage =function (event) {
// var data = event.data;
// };
// 下面这两个监听也可以写成addEventListener的形式
source.onopen = function () {
console.log('SSE 连接已打开');
};
// 处理连接错误
source.onerror = function (error: any) {
console.error('SSE 连接错误:', error);
};
setSseSource(source);
};
// 关闭连接
sseSource.close();
这种原生的sse连接是不能设置请求头的,但是在业务上接口肯定是要鉴权需要传递token的,那么怎么办呢? 我们可以使用event-source-polyfill这个库
const source = new EventSourcePolyfill(`/api/wisdom/terminal/stats/change/notify/${companyId}`, {
headers: {
Authorization: sessionStorage.get(StorageKey.TOKEN) || storage.get(StorageKey.TOKEN),
COMPANYID: storage.get(StorageKey.COMPANYID),
COMPANYTYPE: 1,
CT: 13
}
});
//其它的事件监听和原生的是一样
后端代码实现
后端最关键的是设置将响应头的Content-Type设置为text/event-stream、Cache-Control设置为no-cache、Connection设置为keep-alive。每次发消息需要在消息体结尾用"/n/n"进行分割,一个消息体有多个字段每个字段的结尾用"/n"分割。
var http = require("http");
http.createServer(function (req, res) {
var fileName = "." + req.url;
if (fileName === "./stream") {
res.writeHead(200, {
"Content-Type":"text/event-stream",
"Cache-Control":"no-cache",
"Connection":"keep-alive",
"Access-Control-Allow-Origin": '*',
});
res.write("retry: 10000\n");
res.write("event: connecttime\n");
res.write("data: " + (new Date()) + "\n\n");
res.write("data: " + (new Date()) + "\n\n");
interval = setInterval(function () {
res.write("data: " + (new Date()) + "\n\n");
}, 1000);
req.connection.addListener("close", function () {
clearInterval(interval);
}, false);
}
}).listen(8844, "127.0.0.1");
其它开发中遇到的问题
我在开发调试中用的是umi,期间遇到个问题就是sse连接上了但是在控制台一直没有返回消息,后端那边又是正常发出了的,灵异的是在后端把服务干掉的一瞬间可以看到控制台一下接到好多消息。我便怀疑是umi的代理有问题,然后我就去翻umi的文档,看到了下面的东西:
一顿操作之后正常
来源:juejin.cn/post/7424908830902042658
为什么JQuery会被淘汰?Vue框架就一定会比JQuery好吗?
前言
曾经面试时碰到过一个问题:为什么现有的Vue框架开发可以淘汰之前的JQuery?
我回答:Vue框架无需自己操作DOM,可以避免自己频繁的操作DOM
面试官接着反问我:Vue框架无需自己操作DOM,有什么优势吗,不用操作DOM就一定是好的吗?
我懵了,在我的认知里Vue框架无需自己操作DOM性能是一定优于自己来操作DOM元素的,其实并不是的.....
声明式框架与命令式框架
首先我们得了解声明式框架和命令式框架的区别
命令式框架关注过程
JQuery就是典型的命令式框架
例如我们来看如下一段代码
$( "button.continue" ).html( "Next Step..." ).on('click', () => { alert('next') })
这段代码的含义就是先获取一个类名为continue的button元素,它的内容为 Next Step...,并为它绑定一个点击事件。可以看到自然语言描述与代码是一一对应的,这更符合我们做事的逻辑
声明式框架更关注结果
现有的Vue,React都是典型的声明式框架
接着来看一段Vue的代码
<button class="continue" @click="() => alert('next')">Next Step...</button>
这是一段类HTML模板,它更像是直接提供一个结果。至于怎么实现这个结果,就交给Vue内部来实现,开发者不用关心
性能比较
首先告诉大家结论:声明式代码性能不优于命令式代码性能
即:声明式代码性能 <= 命令式代码性能
为什么会这样呢?
还是拿上面的代码举例
假设我们要将button的内容改为 pre Step,那么命令式的实现就是:
button.textContent = "pre Step"
很简单,就是直接修改
声明式的实现就是:
<!--之前 -->
<button class="continue" @click="() => alert('next')">Next Step...</button>
<!--现在 -->
<button class="continue" @click="() => alert('next')">pre Step</button>
对于声明式框架来说,它需要找到更改前后的差异并只更新变化的地方。但是最终更新的代码仍然是
button.textContent = "pre Step"
假设直接修改的性能消耗为 A, 找出差异的性能消耗为 B,
那么就有:
- 命令式代码的更新性能消耗 = A
- 声明式代码的更新性能消耗 = A + B
可以看到声明式代码永远要比命令式代码要多出找差异的性能消耗
那既然声明式代码的性能无法超越命令式代码的性能,为什么我们还要选择声明式代码呢?这就要考虑到代码可维护性的问题了。当项目庞大之后,手动完成dom的创建,更新与删除明显需要更多的时间和精力。而声明式代码框架虽然牺牲了一点性能,但是大大提高了项目的可维护性,降低了开发人员的心智负担
那么,有没有办法能同时兼顾性能和可维护性呢?
有!那就是使用虚拟dom
虚拟Dom
首先声明一个点,命令式代码只是理论上会比声明式代码性能高。因为在实际开发过程中,尤其是项目庞大之后,开发人员很难写出绝对优化的命令式代码。
而Vue框架内部使用虚拟Dom + 内部封装Dom元素操作的方式,能让我们不用付出太多精力的同时,还能保证程序的性能下限,甚至逼近命令式代码的性能
在讨论虚拟Dom的性能之前,我们首先要说明一个点:JavaScript层面的计算所需时间要远低于Dom层面的计算所需时间 看过浏览器渲染与解析机制的同学应该很明白为什么会这样。
我们在使用原生JavaScript编写页面时,很喜欢使用innerHTML,这个方法非常特殊,下面我们来比较一下使用虚拟Dom和使用innerHTML的性能差异
创建页面时
我们在使用innerHTML创建页面时,通常是这样的:
const data = "hello"
const htmlString = `<div>${data}</div>`
domcument.querySelect('.target').innerHTML = htmlString
这个过程需要先通过JavaScript层的字符串运算,然后是Dom层的innerHTML的Dom运算 (将字符串赋值给Dom元素的innerHTML属性时会将字符串解析为Dom树)
而使用虚拟Dom的方式通常是编译用户编写的类html模板得到虚拟Dom(JavaScript对象),然后遍历虚拟Dom树创建真实Dom对象
两者比较:
innerHTML | 虚拟Dom | |
---|---|---|
JavaScript层面运算 | 计算拼接HTML字符串 | 创建JavaScript对象(虚拟Dom) |
Dom层面运算 | 新建所有Dom元素 | 新建所有Dom元素 |
可以看到两者在创建页面阶段的性能差异不大。尽管在JavaScript层面,创建虚拟Dom对象貌似更耗时间,但是总体来说,Dom层面的运算是一致的,两者属于同一数量级,宏观来看可认为没有差异
更新页面时
使用innerHTML更新页面,通常是这样:
//更新
const newData = "hello world"
const newHtmlString = `<div>${newData}</div>`
domcument.querySelect('.target').innerHTML = newHtmlString
这个过程同样是先通过JavaScript层的字符串运算,然后是Dom层的innerHTML的Dom运算。但是它在Dom层的运算是销毁所有旧的DOM元素,再全量创建新的DOM元素
而使用虚拟Dom的方式通常是重新创建新的虚拟Dom(JavaScript对象),然后比较新旧虚拟Dom,找到需要更改的地方并更新Dom元素
两者比较:
innerHTML | 虚拟Dom | |
---|---|---|
JavaScript层面运算 | 计算拼接HTML字符串 | 创建JavaScript对象(虚拟Dom)+ Diff算法 |
Dom层面运算 | 销毁所有旧的Dom元素,新建所有新的DOM元素 | 必要的DOM更新 |
可以看到虚拟DOM在JavaScript层面虽然多出一个Diff算法的性能消耗,但这毕竟是JavaScript层面的运算,不会产生数量级的差异。而在DOM层,虚拟DOM可以只更新差异部分,对比innerHTML的全量卸载与全量更新性能消耗要小得多。所以模板越大,元素越多,虚拟DOM在更新页面的性能上就越有优势
总结
现在我们可以回答这位面试官的问题了:JQuery属于命令式框架,Vue属于声明式框架。在理论上,声明式代码性能是不优于命令式代码性能的,甚至差于命令式代码的性能。但是声明式框架无需用户手动操作DOM,用户只需关注数据的变化。声明式框架在牺牲了一点性能的情况下,大大降低了开发难度,提高了项目的可维护性,且声明式框架通常使用虚拟DOM的方式,使其在更新页面时的性能大大提升。综合来说,声明式框架仍旧是更好的选择
来源:juejin.cn/post/7425121392738615350
老板想集成地图又不想花钱,于是让我...
前言
在数字化时代,地图服务已成为各类应用的标配,无论是导航、位置分享还是商业分析,地图都扮演着不可或缺的角色。然而,高质量的地图服务往往伴随着不菲的授权费用。公司原先使用的是国内某知名地图服务,但随着业务的扩展和成本的考量,老板决定寻找一种成本更低的解决方案。于是,我们的目光转向了免费的地图服务——天地图。
天地图简介
天地图(lbs.tianditu.gov.cn/server/guid…
是中国领先的在线地图服务之一,提供全面的地理信息服务。它的API支持地理编码、逆地理编码、周边搜索等多种功能,且完全免费。这正是我们需要的。
具体实现代码
为了将天地图集成到我们的系统中,我们需要进行一系列的开发工作。以下是实现过程中的关键代码段。
1. 逆地理编码
逆地理编码是将经纬度转换为可读的地址。在天地图中,这一功能可以通过以下代码实现:
public static MapLocation reverseGeocode(String longitude, String latitude) {
Request request = new Request();
LocateInfo locateInfo = GCJ02_WGS84Utils.gcj02_To_Wgs84(Double.valueOf(latitude), Double.valueOf(longitude));
longitude = String.valueOf(locateInfo.getLongitude());
latitude = String.valueOf(locateInfo.getLatitude());
String postStr = String.format(REVERSE_GEOCODE_POST_STR, longitude, latitude);
String encodedPostStr = null;
try {
encodedPostStr = URLEncoder.encode(postStr, "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
String url = REVERSE_GEOCODE_URL + "?tk=" + TK + "&type=" + GEOCODE + "&postStr=" + encodedPostStr;
request.setUrl(url);
Response<String> response = HttpClientHelper.getWithoutUserAgent(request, null);
if (response.getSuccess()) {
String body = response.getBody();
JSONObject jsonObject = JSON.parseObject(body);
String status = jsonObject.getString("status");
if (!"0".equals(status)) {
return null;
}
JSONObject resultObject = jsonObject.getJSONObject("result");
MapLocation mapLocation = new MapLocation();
String formattedAddress = resultObject.getString("formatted_address");
mapLocation.setAddress(formattedAddress);
String locationStr = resultObject.getString("location");
JSONObject location = JSON.parseObject(locationStr);
String lon = location.getString("lon");
String lat = location.getString("lat");
locateInfo = GCJ02_WGS84Utils.wgs84_To_Gcj02(Double.valueOf(lat), Double.valueOf(lon));
lon = String.valueOf(locateInfo.getLongitude());
lat = String.valueOf(locateInfo.getLatitude());
mapLocation.setLongitude(lon);
mapLocation.setLatitude(lat);
JSONObject addressComponent = resultObject.getJSONObject("addressComponent");
String address = addressComponent.getString("address");
mapLocation.setName(address);
mapLocation.setCity(addressComponent.getString("city"));
return mapLocation;
}
return null;
}
2. 周边搜索
周边搜索允许我们根据一个地点的经纬度搜索附近的其他地点。实现代码如下:
public static List<MapLocation> nearbySearch(String query, String longitude, String latitude, String radius) {
LocateInfo locateInfo = GCJ02_WGS84Utils.gcj02_To_Wgs84(Double.valueOf(latitude), Double.valueOf(longitude));
longitude = String.valueOf(locateInfo.getLongitude());
latitude = String.valueOf(locateInfo.getLatitude());
Request request = new Request();
String longLat = longitude + "," + latitude;
String postStr = String.format(NEARBY_SEARCH_POST_STR, query, Integer.valueOf(radius), longLat);
String encodedPostStr = null;
try {
encodedPostStr = URLEncoder.encode(postStr, "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
String url = SEARCH_URL + "?tk=" + TK + "&type=" + QUERY + "&postStr=" + encodedPostStr;
request.setUrl(url);
Response<String> response = HttpClientHelper.getWithoutUserAgent(request, null);
List<MapLocation> list = new ArrayList<>();
if (response.getSuccess()) {
String body = response.getBody();
JSONObject jsonObject = JSON.parseObject(body);
JSONObject statusObject = jsonObject.getJSONObject("status");
String infoCode = statusObject.getString("infocode");
if (!"1000".equals(infoCode)) {
return new ArrayList<>();
}
String resultType = jsonObject.getString("resultType");
String count = jsonObject.getString("count");
if (!"1".equals(resultType) || "0".equals(count)) {
return new ArrayList<>();
}
JSONArray poisArray = jsonObject.getJSONArray("pois");
for (int i = 0; i < poisArray.size(); i++) {
JSONObject poiObject = poisArray.getJSONObject(i);
MapLocation mapLocation = new MapLocation();
mapLocation.setName(poiObject.getString("name"));
mapLocation.setAddress(poiObject.getString("address"));
String lonlat = poiObject.getString("lonlat");
String[] lonlatArr = lonlat.split(",");
locateInfo = GCJ02_WGS84Utils.wgs84_To_Gcj02(Double.valueOf(lonlatArr[1]), Double.valueOf(lonlatArr[0]));
String lon = String.valueOf(locateInfo.getLongitude());
String lat = String.valueOf(locateInfo.getLatitude());
mapLocation.setLongitude(lon);
mapLocation.setLatitude(lat);
list.add(mapLocation);
}
}
return list;
}
3. 文本搜索
文本搜索功能允许用户根据关键词搜索地点。实现代码如下:
public static List<MapLocation> searchByText(String query, String mapBound) {
Request request = new Request();
String postStr = String.format(SEARCH_BY_TEXT_POST_STR, query, mapBound);
String encodedPostStr = null;
try {
encodedPostStr = URLEncoder.encode(postStr, "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
String url = SEARCH_URL + "?tk=" + TK + "&type=" + QUERY + "&postStr=" + encodedPostStr;
request.setUrl(url);
Response<String> response = HttpClientHelper.getWithoutUserAgent(request, null);
List<MapLocation> list = new ArrayList<>();
if (response.getSuccess()) {
String body = response.getBody();
JSONObject jsonObject = JSON.parseObject(body);
JSONObject statusObject = jsonObject.getJSONObject("status");
String infoCode = statusObject.getString("infocode");
if (!"1000".equals(infoCode)) {
return new ArrayList<>();
}
String resultType = jsonObject.getString("resultType");
String count = jsonObject.getString("count");
if (!"1".equals(resultType) || "0".equals(count)) {
return new ArrayList<>();
}
JSONArray poisArray = jsonObject.getJSONArray("pois");
for (int i = 0; i < poisArray.size(); i++) {
JSONObject poiObject = poisArray.getJSONObject(i);
MapLocation mapLocation = new MapLocation();
mapLocation.setName(poiObject.getString("name"));
mapLocation.setAddress(poiObject.getString("address"));
String lonlat = poiObject.getString("lonlat");
String[] lonlatArr = lonlat.split(",");
LocateInfo locateInfo = GCJ02_WGS84Utils.wgs84_To_Gcj02(Double.valueOf(lonlatArr[1]), Double.valueOf(lonlatArr[0]));
String lon = String.valueOf(locateInfo.getLongitude());
String lat = String.valueOf(locateInfo.getLatitude());
mapLocation.setLongitude(lon);
mapLocation.setLatitude(lat);
list.add(mapLocation);
}
}
return list;
}
4. 坐标系转换
由于天地图使用的是WGS84坐标系,而国内常用的是GCJ-02坐标系,因此我们需要进行坐标转换。以下是坐标转换的工具类:
/**
* WGS-84:是国际标准,GPS坐标(Google Earth使用、或者GPS模块)
* GCJ-02:中国坐标偏移标准,Google Map、高德、腾讯使用
* BD-09:百度坐标偏移标准,Baidu Map使用(经由GCJ-02加密而来)
* <p>
* 这些坐标系是对真实坐标系统进行人为的加偏处理,按照特殊的算法,将真实的坐标加密成虚假的坐标,
* 而这个加偏并不是线性的加偏,所以各地的偏移情况都会有所不同,具体的内部实现是没有对外开放的,
* 但是坐标之间的转换算法是对外开放,在网上可以查到的,此算法的误差在0.1-0.4之间。
*/
public class GCJ02_WGS84Utils {
public static double pi = 3.1415926535897932384626;//圆周率
public static double a = 6378245.0;//克拉索夫斯基椭球参数长半轴a
public static double ee = 0.00669342162296594323;//克拉索夫斯基椭球参数第一偏心率平方
/**
* 从GPS转高德
* isOutOfChina 方法用于判断经纬度是否在中国范围内,如果不在中国范围内,则直接返回原始的WGS-84坐标。
* transformLat 和 transformLon 是辅助函数,用于进行经纬度的转换计算。
* 最终,wgs84ToGcj02 方法返回转换后的GCJ-02坐标系下的经纬度。
*/
public static LocateInfo wgs84_To_Gcj02(double lat, double lon) {
LocateInfo info = new LocateInfo();
if (isOutOfChina(lat, lon)) {
info.setChina(false);
info.setLatitude(lat);
info.setLongitude(lon);
} else {
double dLat = transformLat(lon - 105.0, lat - 35.0);
double dLon = transformLon(lon - 105.0, lat - 35.0);
double radLat = lat / 180.0 * pi;
double magic = Math.sin(radLat);
magic = 1 - ee * magic * magic;
double sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * pi);
dLon = (dLon * 180.0) / (a / sqrtMagic * Math.cos(radLat) * pi);
double mgLat = lat + dLat;
double mgLon = lon + dLon;
info.setChina(true);
info.setLatitude(mgLat);
info.setLongitude(mgLon);
}
return info;
}
//从高德转到GPS
public static LocateInfo gcj02_To_Wgs84(double lat, double lon) {
LocateInfo info = new LocateInfo();
LocateInfo gps = transform(lat, lon);
double lontitude = lon * 2 - gps.getLongitude();
double latitude = lat * 2 - gps.getLatitude();
info.setChina(gps.isChina());
info.setLatitude(latitude);
info.setLongitude(lontitude);
return info;
}
// 判断坐标是否在国外
private static boolean isOutOfChina(double lat, double lon) {
if (lon < 72.004 || lon > 137.8347)
return true;
if (lat < 0.8293 || lat > 55.8271)
return true;
return false;
}
//转换
private static LocateInfo transform(double lat, double lon) {
LocateInfo info = new LocateInfo();
if (isOutOfChina(lat, lon)) {
info.setChina(false);
info.setLatitude(lat);
info.setLongitude(lon);
return info;
}
double dLat = transformLat(lon - 105.0, lat - 35.0);
double dLon = transformLon(lon - 105.0, lat - 35.0);
double radLat = lat / 180.0 * pi;
double magic = Math.sin(radLat);
magic = 1 - ee * magic * magic;
double sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * pi);
dLon = (dLon * 180.0) / (a / sqrtMagic * Math.cos(radLat) * pi);
double mgLat = lat + dLat;
double mgLon = lon + dLon;
info.setChina(true);
info.setLatitude(mgLat);
info.setLongitude(mgLon);
return info;
}
//转换纬度所需
private static double transformLat(double x, double y) {
double ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y
+ 0.2 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * pi) + 20.0 * Math.sin(2.0 * x * pi)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(y * pi) + 40.0 * Math.sin(y / 3.0 * pi)) * 2.0 / 3.0;
ret += (160.0 * Math.sin(y / 12.0 * pi) + 320 * Math.sin(y * pi / 30.0)) * 2.0 / 3.0;
return ret;
}
//转换经度所需
private static double transformLon(double x, double y) {
double ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1
* Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * pi) + 20.0 * Math.sin(2.0 * x * pi)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(x * pi) + 40.0 * Math.sin(x / 3.0 * pi)) * 2.0 / 3.0;
ret += (150.0 * Math.sin(x / 12.0 * pi) + 300.0 * Math.sin(x / 30.0 * pi)) * 2.0 / 3.0;
return ret;
}
}
结论
通过上述代码,我们成功地将天地图集成到了我们的系统中,不仅满足了功能需求,还大幅降低了成本。这一过程中,我们深入理解了地图服务的工作原理,也提升了团队的技术能力。
注意事项
- 确保在使用天地图API时遵守其服务条款,尤其是在商业用途中。
- 由于网络或其他原因,天地图API可能存在访问延迟或不稳定的情况,建议在生产环境中做好异常处理和备用方案。
- 坐标系转换是一个复杂的过程,确保使用可靠的算法和工具进行转换,以保证定位的准确性。
通过这次集成,我们不仅为公司节省了成本,还提升了系统的稳定性和用户体验。在未来的开发中,我们将继续探索更多高效、低成本的技术解决方案。
来源:juejin.cn/post/7419524888041472009
我开发的一些开发者小工具
在 2020 年,我辞职在家,每天都有大把时间。于是,我开始开发一些与开发相关的小工具,目的是解决开发中遇到的问题,或者帮助更深入地理解某些技术概念。
每天写写小工具,时间就这样一天天过去,回想起来,这段经历其实挺有意思的。
刚开始时,这些工具的 UI 确实比较简陋。不过随着时间推移,我也在不断改进它们的外观。虽然现在看来可能还是不够精美,但已经有了很大进步。
说实话,这些工具的用户引导和文档都很少,更像是我自己的一个小天地。通过 Google Analytics 的数据,我发现有些工具的使用者可能只有我自己,比如微图床。但正因为我自己在用,即使最近添加新工具的频率减少了,我也一直在维护它们。
令我感到欣慰的是,我把其中一些工具提交到了阮一峰老师的博客,很多小工具都得到了他的推荐。这对我来说是一种莫大的鼓励。
一些与深入原理相关的工具
这些工具旨在帮助开发者更深入地理解一些基础概念和底层原理。
IEEE754 浮点数转换
这个工具可以帮助你理解 IEEE 754 标准中双精度浮点数的内部表示。它能将十进制数转换为对应的二进制表示,并清晰地展示符号位、指数位和尾数位。这对于理解计算机如何处理浮点数非常有帮助。
根据 IEEE754 标准,Infinity
的浮点数转换为:指数位全为 1,尾数位全为 0。
以下是 Infinity 的浮点数转换:
根据 IEEE754 标准,0
的浮点数转换为:符号位为 0,指数位全为 0,尾数位全为 0。
以下是 0 的浮点数转换:
UTF-8 编码转换
UTF-8 是一种可变长度的字符编码,这个工具可以帮助你理解 Unicode 字符是如何被编码成 UTF-8 的。你可以输入任何 Unicode 字符,工具会显示其 UTF-8 编码的二进制表示,让你直观地看到编码过程。
base64 编码转换
Base64 是一种常用的编码方式,特别是在处理二进制数据时。这个工具不仅可以帮助你理解 Base64 编码的原理,还提供了便捷的编码和解码功能。它对于处理需要在文本环境中传输二进制数据的场景特别有用。
文件类型检测
这个工具可以帮助你理解如何通过文件的魔数(magic number)来判断文件类型。你可以上传一个文件,工具会读取文件的二进制数据,并根据魔数判断文件类型。这在处理未知文件或验证文件类型时非常有用。
比如,JPEG
是因为它的 Magic Number 为 FF D8 FF DB
图片相关
图片处理是 Web 开发中的一个重要方面,以下是一些与图片处理相关的工具。
微图
这是一个快速的图片压缩工具,可以帮助你减小图片文件的大小,而不会显著降低图片质量。
它支持多种图片格式,并且没有文件大小或数量的限制。这个工具对于优化网站加载速度特别有帮助。
最主要的是它借助于前端实现,无需服务器成本,所以你不需要担心隐私问题。它的实现方式与 squoosh 类似,都是借助于 WebAssembly 实现。
微图床
这是一个个人图床工具,允许你将 GitHub 仓库用作个人图床。它提供了简单的上传和管理功能,让你可以方便地在文章或网页中引用图片。对于经常需要在线分享图片的开发者来说,这是一个非常实用的工具。
图片分享
这个工具可以帮助你快速生成带有文字的图片,适合用于社交媒体分享或创建简单的海报。它简化了图文组合的过程,让你无需使用复杂的图像编辑软件就能创建吸引人的图片。
图片占位符
这是一个图片占位符生成工具,可以快速创建自定义尺寸和颜色的占位图片,非常适合在开发过程中使用。它可以帮助你在实际图片还未准备好时,保持页面布局的完整性。
编码与加密
在 Web 开发中,我们经常需要处理各种编码和加密。以下是一些相关的工具:
URL 编码
这个工具可以帮助你进行 URL 编码和解码,对于处理包含特殊字符的 URL 非常有用。它可以确保你的 URL 在各种环境中都能正确传输和解析。
HTML 实体编码
HTML 实体编码工具可以帮助你将特殊字符转换为 HTML 实体,确保它们在 HTML 中正确显示。这对于防止 XSS 攻击和确保 HTML 文档的正确渲染都很重要。
哈希生成器
这个工具可以生成多种常用的哈希值,包括 MD5、SHA1、SHA256 等。它在数据完整性验证、密码存储等场景中非常有用。
颜色工具
颜色是 Web 设计中的重要元素,以下是一些与颜色相关的工具:
颜色转换
这个工具可以在 RGB、HSL、CMYK 等不同颜色模型之间进行转换。它可以帮助设计师和开发者在不同的颜色表示方法之间自如切换。
调色板生成器
这个工具可以帮助你生成颜色的色调和阴影,非常适合创建一致的颜色主题。它可以让你快速构建和谐的配色方案,提高设计效率。
对比度计算器
这个工具可以计算两种颜色之间的对比度,帮助你确保文本在背景上的可读性。它对于创建符合可访问性标准的设计非常重要。
结语
虽然有些工具可能只有我自己在用,但正是这种持续的学习和创造过程让我感到充实和快乐。
我会继续维护和改进这些工具,也欢迎大家使用并提供反馈。
来源:juejin.cn/post/7426151241470476298
尤雨溪成立VoidZero,Rust要一统JavaScript工具链?
尤雨溪在Vite Conf 2024
上宣布成立公司Void Zero,目前已经完成$460万种子轮融资,由Accel
领头,并且有Amplify
以及在dev tools领域有丰富经验的创始人参与。 主要目标是搭建下一代JavaScript
工具链,实现一套工具覆盖从源码到最终产物的中间过程,例如semantic analysis、transformer、linter、formatter、minifier、boundler等。
好的工具链不外乎快
、好用
, 本文将结合尤雨溪在Vite Conf 2024
上分享的内容来介绍什么是下一代JavaScript工具链
, 以及快
、好用
体现在哪些方面, 最后再上手试验下相关工具看是否真的快
。
Vite工具现状
相信现在大多数前端开发人员的构建工具首选一定是Vite
,Vite确实易上手并且快,涵盖了Vue、React、Preact等主流前端框架,也支持TypeScript、Nuxt等。Vite仅需简单的几个指令即可运行起项目:
// 运行create指令,选择前端框架和语言,例如Vue、TypeScript
npm create vite@latest
// Done. Now run:
cd vite-project
npm install
npm run dev
Vite为什么快?
有对比才能体现快,几年前构建前端项目还是使用webpack、Rollup、Parcel等工具,当项目代码指数级增长,这些工具的性能瓶颈愈发明显,动不动需要几分钟才能启动dev server,即使是模块热更新(HMR),文件修改后也需要几秒钟才能反馈到浏览器,这严重影响开发者工作幸福指数
。
浏览器的快速发展造就了Vite
,Vite的优势在两个方面:首先是支持了Native ES Modules,其次是build过程接入了编译型语言(如go、rust)开发的工具。这些优势体现在服务器启动和热更新两个阶段:
- 服务器启动: Vite将应用中的模块区分为依赖、源码两种,改进了开发服务器启动时间。
- 依赖:开发时不会变动的纯JavaScript,或者是较大的依赖(上百个模块的组件库),这些代码的处理代价比较高,Vite会使用
esbuild预构建
这些依赖,由于esbuild使用Go编写,因此比以JavaScript编写的打包器预构建快10-100倍。 - 源码:对于Vue、JSX等频繁变动的代码文件,Vite以原生ESM方式提供源码,让浏览器接管了打包程序的部分工作,Vite只需要在浏览器请求源码时进行转换并安需提供,也就是需安需导入、安需加载。
- 依赖:开发时不会变动的纯JavaScript,或者是较大的依赖(上百个模块的组件库),这些代码的处理代价比较高,Vite会使用
- 热更新(HMR)
在Vite中,HMR是在原生ESM上执行的。当编辑一个文件时,Vite只需要精确地使已编辑Module与其最近的HMR边界之间的链失活,使得无论应用大小如何,HMR能保持快速更新。
Vite同时利用HTTP头来加速整个页面的重新加载:源码模块请求根据
304 Not Modified
协商缓存,而预构建的依赖模块请求则通过Cache-Control:max-age=31536000,immutable
进行强缓存,因此一旦被缓存将不需要再次请求。
Vite也有缺陷
Vite当前架构底层依赖于esbuild
、Rollup
、SWC
,三者的作用如下:
- esbuild: Vite使用esbuild执行依赖项预构建,转化TypeScript、JSX,并且作为生成环境构建的默认minifier。
- Rollup: Rollup直接基于ES6模块格式,因此能够实现除屑优化(Tree Shaking),然后基于插件生态来支持打包过程的扩展。Vite基于Rollup的插件模板实现插件生态,构建生产环境的bundling chunk和静态资源。
- SWC: SWC使用Rust语言实现,号称
super-fast
JavaScript编译器,能够将TypeScript、JSX编译为浏览器支持的JavaScript,编译速度比babel快20倍。Vite主要使用SWC来打包React代码以及实现React代码的HMR。
Vite充分利用esbuild、Rollup、SWC各自的优势来组合成一套打包工具链,虽然对使用者来说是无感的,但对于Vite内部,三套打包框架组合在一起本身就显得比较臃肿。
接下来我们就分析下这一套组合拳会有哪些缺陷:
- 两套bundling
虽然esbuild构建非常快,但它的tree shaking以及代码切分不像rollup的配置化那样灵活,插件系统设计的也不尽如人意,因此Vite仅在DEV环境使用esbuild预构建依赖项。rollup正好拟补了ebuild的缺点,比较好的chunck control,以及易配置的tree shaking,因此适合在生成环境打包代码。
- 生产环境构建速度慢
由于Rollup基于JavaScript实现,虽然比Webpack快很多,但相比于native工具链,速度就相形见绌了。
- 比较大的二进制包
SWC的二进制包有多大?在Mac系统下,达到37MB,比Vite和其依赖项文件总和大了2倍。
- SWC虽然快,但缺少bundler能力
SWC有比较完善的transform和minifier,但没有提供可用的bundler,这也就说明了SWC不能直接作为打包工具。
- 不一致的bundler行为
DEV环境使用esbuild预构建依赖项,而PROD环境使用rollup构建包,在包含ESM、CJS多模块形式场景下,esbuild和rullup的构建行为存在差异,导致一些仅在线上出现的问题。
- 低效率的构建管道
由于混合了JavaScript、Go、Rust三种程序,同一代码片段可能会在不同进程重复执行AST、transform、serialize,并将结果在不同进程间传递。另一方面,在进程间传递大量的代码块本身也会有比较大的开销,特别是传递source map此类文件时开销更大。
总结这些问题,就三点:碎片化、不兼容、低效率。 为了解决这些种种问题,统一的打包工具迫在眉睫,这也是尤雨溪提出Rolldown
的主要原因。
基于Rust的下一代工具链
VoidZero
提出的下一代工具链是什么?下图为 VoieZero
规划蓝图,不管是Vue、React、Nuxt还是其他前端框架,一个Vite
统统给你搞定,测试框架仅需Vitest
即可。Vite
底层依赖Rolldown
打包器,而打包过程完全交由工具链Oxc
负责。实际干活的Rolldown
和Oxc
都基于Rust实现,因此够快。
Oxc
、Rolldown
离正式使用还有一段距离,预计是2025年初投入使用,但这也不妨碍我们先了解下这两个工具让人惊掉下巴的牛,毕竟值460万美金。
Oxc
Oxc
作为统一的语言工具链,将提供包含代码检查Linter
、代码格式化Formatter
、代码打包的组合式NPM包或者Rust包。代码打包过程分为Transformer
、Minifier
、Resolver
、Parser
、Semantic Analysis
。
Oxc
官网地址: oxc.rs/ , 目前已经发布了oxlint v0.9.9
、oxc-transform alpha 版本
。
oxlint v0.9.9
:检查速度比eslint快50-100倍。oxlint已经在Shopify
投入使用,之前使用eslint检查代码需要75分钟/CI,而使用oxlint
仅需要10秒钟,你就说快吧!oxc-transform alpha
:转换速度是SWC的3到5倍, 并且内存占用减少20%, 更小的包体积(2MB vs SWC的37MB). 已实现的主要3个功能:
- 转换TypeScript至ESNext;
- 转换React JSX至ESNext,并支持React Refresh;
- TypeScript DTS 声明;
Oxc
目前已完成Parser
Linter
Resolver
,正在快马加鞭地完善Transformer
。
Rolldown
Rolldown
是基于Rust实现的JavaScript快速打包器,与Rollup API兼容。作为打包器应包含的功能有:
- Bundling:
- 阶段1:使用Rolldown替换esbuild,执行依赖项预生成,主要包括多entry的代码切分、cjs/esm混合打包、基础插件支持;
- 阶段2:Rolldown能够支持生成环境构建,包括命令行和配置文件支持、Treeshaking、Source map、Rollup插件兼容等;
- Transform: 使用
oxc
代替esbuild的Transform,尽可能使用同一套AST
。核心功能模块Typescript/JSX转换、代码缩减(minification)以及语法降级; - 与Vite深度集成: 替换esbuild和rollup,Vite内部核心插件使用Rust实现,提升构建效率。
Rolldown
在性能方面表现如何?官方给出了两个测试。
测试一:打包数量为19k的模块文件,其中10k为React JSX组件,9k为图标js文件。不同打包框架的耗时如下:
- rolldown: 0.63s
- rolldown-vite: 1.49s
- esbuild: 1.23s
- fram: 2.08s
- rsbuild: 3.14s
rolldown打包速度比esbuild快了近2倍。
测试二:打包Vue Core代码,基于TypeScript的多包仓库,包含11个包/62个dist bundles,耗时如下:
Vue版本 | 构建框架 | 构建耗时 |
---|---|---|
Vue3.2 | Rollup + rullup-plugin-typescript2+ terser tsx | 114s |
Vue3.5(main branch) | Rollup + rollup-plugin-esbuild + swc minify tsc | 8.5s |
Vue3.5(rolldown branch) | Rolldown(tranform+minify) + oxc-transform | 1.11s |
基于
rolldown
的Vue3.5源代码比Rollup
构建快了近8倍。
单从测试数据来看,基于Rust开发的rolldown
,打包速度确实带来惊人的提升。以下为下一代Vite的架构概览,预计2025年初发布。
总结
VoidZero
宣称的下一代JavaScript工具链,价值460万美金,其商业价值可见一斑,对于研发个体来说没有明显的感受,但对于大型企业来说,VoidZero
能实打实的为企业节省每年几百万的CI构建成本。
VoidZero
将清一色的使用Rust来搭建底层构建逻辑,如果能够成型,也证明了Rust在前端构建领域的地位。这也让我们反思,借助于Rust独特的性能和安全性优势,它还能够为前端带来哪些价值?例如WASM支持,基于Tauri、Electon.rs框架的桌面应用,支持Flutter和Dart语言的移动端应用。
究竟VoidZero
会为前端领域带来怎样的变革,Vite能不能一统JavaScript工具链,让我们拭目以待吧。
我是
前端下饭菜
,原创不易,各位看官动动手,帮忙关注、点赞、收藏、评轮!
来源:juejin.cn/post/7422404598360948748
2024年全面的多端统一开发解决方案推荐!
前言
最近在DotNetGuide技术社区交流群看到有不少小伙伴问:有没有一套代码能够同时在多个平台运行的框架推荐?
今天大姚给大家分享8个多端统一开发框架其中语言包括C#、C++、Vue、React、Dart、Kotlin等等(一套代码,可以运行到多个平台从而大幅减轻开发者的开发与维护负担),同学们可以按需选择对应框架(排名不分先后,适合自己的才是最好的)。
使用情况投票统计
微信使用情况投票统计: mp.weixin.qq.com/s/9DNgjTIUX…
uni-app
uni-app 是一个使用 Vue.js 开发所有前端应用的框架,开发者编写一套代码,可发布到iOS、Android、Web(响应式)、以及各种小程序(微信/支付宝/百度/头条/飞书/QQ/快手/钉钉/淘宝)、快应用等多个平台。
- 开源地址:github.com/dcloudio/un…
- 在线文档:uniapp.dcloud.net.cn
功能框架图
从下面uni-app功能框架图可看出,uni-app在跨平台的过程中,不牺牲平台特色,可优雅的调用平台专有能力,真正做到海纳百川、各取所长。
为什么要选择uni-app?
uni-app在开发者数量、案例、跨端抹平度、扩展灵活性、性能体验、周边生态、学习成本、开发成本等8大关键指标上拥有更强的优势。
Taro
Taro是一个开放式跨端跨框架解决方案,支持使用 React/Vue/Nerv 等框架来开发微信/京东/百度/支付宝/字节跳动/ QQ 小程序/H5/React Native 等应用。
- 开源地址:github.com/NervJS/taro
- 在线文档:taro.zone
多端转换支持
Flutter
Flutter是由Google开发的一款开源、跨平台的UI(用户界面)框架,一份代码兼顾 Android、iOS、Web、Windows、macOS 和 Linux 六个平台,编译为原生机器代码,助力提升应用的流畅度并实现优美的动画效果。
- 开源地址:github.com/flutter/flu…
- 在线文档:flutter.cn/dev
主要特性
React Native
React Native由Facebook开发,允许开发者使用JavaScript和React来构建原生体验的移动应用,支持iOS和Android平台。
React Native不仅适用于 Android 和 iOS - 还有社区支持的项目将其应用于其他平台,例如:
- 开源地址:github.com/facebook/re…
- 在线文档:reactnative.cn
Avalonia
Avalonia是一个强大的框架,使开发人员能够使用.NET创建跨平台应用程序。它使用自己的渲染引擎绘制UI控件,确保在Windows、macOS、Linux、Android、iOS和WebAssembly等不同平台上具有一致的外观和行为。这意味着开发人员可以共享他们的UI代码,并在不同的目标平台上保持统一的外观和感觉。
Avalonia 已经成熟且可用于生产,并被 Schneider Electric、Unity、JetBrains 和 GitHub 等公司使用。
.NET MAUI
.NET 多平台应用 UI (.NET MAUI) 是一个跨平台框架,用于使用 C# 和 XAML 创建本机移动和桌面应用。使用 .NET MAUI,您可以从单个共享代码库开发可在 Android、iOS、iPadOS、macOS 和 Windows 上运行的应用程序。
Uno
Uno平台是一个开源平台,用于快速构建单一代码库原生移动、Web、桌面和嵌入式应用程序。它允许 C# 和 WinUI XAML 和/或 C# 代码在所有目标平台上运行,同时允许您控制每个像素。它支持开箱即用的 Fluent、Material 和 Cupertino 设计系统。Uno 平台实现了越来越多的 WinRT 和 WinUI API,例如 Microsoft.UI.Xaml,使 WinUI 应用程序能够以本机性能在所有平台上运行。
Eto.Forms
Eto.Forms是一个.NET开源、跨平台的桌面和移动应用的统一框架,该框架允许开发者使用单一的UI代码库构建在多个平台上运行的应用程序,并利用各自平台的原生工具包,从而使应用程序在所有平台上看起来和工作都像原生应用一样。
支持的平台:支持Windows Forms、WPF、MonoMac和GTK#等桌面平台,以及正在开发中的iOS(使用Xamarin.iOS)和Android(使用Xamarin.Android)移动平台支持(尽管目前尚不完整)。
- 开源地址:github.com/picoe/Eto
- 在线文档:github.com/picoe/Eto/w…
来源:juejin.cn/post/7426554951349747762
在我硬盘安监控了?纯 JS 监听本地文件的一举一动
💰 点进来就是赚到知识点!本文带你用 JS 代码监控本地文件,点赞、收藏、评论更能促进消化吸收!
🚀 想解锁更多 Web 文件系统的技能吗?快来订阅专栏「Web 玩转文件操作」!
📣 我是 Jax,在畅游 Web 技术海洋的又一年,我仍然是坚定不移的 JavaScript 迷弟,Web 技术带给我太多乐趣。如果你也和我一样,欢迎关注、私聊!
开门见 demo
先来玩玩这个 demo —— 在 Chrome 中监控本地文件夹。
在上面的 demo 中,点击按钮选择一个本地文件夹后,无论是在该文件夹中新增、修改还是删除内容,网页端都能够感知到每一步操作的细节,包括操作时间、操作对象是文件还是文件夹、具体执行了什么操作等等。
如果你的感觉是:”哟?有点儿意思!“ 那么这篇文章就是专门为你而写的,读下去吧。
在本专栏的前几篇文章中,我们已经知道,Web 应用能对本地文件进行各种花式操作,例如选择文件/文件夹、增/删/改/查文件等等。网页好像能伸出长长的手臂,穿过浏览器触摸到了用户的本地文件。但你可能还不知道,网页也能长出千里眼、顺风耳,本地文件有什么风吹草动,都能被网页端监控到。如此灵通的耳目,它的名字就是 File System Observer API(文件系统观察者)。
API 简介
现在想象我们要开发一个 Web 端相册应用,展示用户本地文件夹中的图片。我们希望这个相册能实时响应用户的操作,例如增加/删掉几张图片后,无需用户手动在 Web 端刷新,就能自动更新到最新状态。
如果请你来实现自动刷新,阁下又该如何应对?
经典思路可能会是以短时间间隔轮询文件夹状态,读取并缓存每个文件的 lastModified
时间戳,如果前后两次轮询的时间戳发生了变化,再把前后差异更新到 Web 视图中。这种实现方式能达到效果,但还是有一些缺点,比如不能真正做到即时响应,且会有很大的性能问题等。
其实咱们都知道,最优雅高效的做法是仅在文件被操作时触发更新。原生操作系统如 WIndows 和 MacOS 都有这样的文件监听机制,但显然目前 Web 端还无法享受其便利性。除了在用户端,Node.js 应用也面临这样的问题。开发者苦此久矣。
直到 2023 年 6 月,来自谷歌的贡献者们开始推进一项 W3C 提案 —— File System Observer(为方便叙述,下文将简称其为 FSO),旨在从浏览器层面向 Web 应用提供跨平台的文件监听支持。如果这项提案能够顺利进入 ECMAScript 标准,那么 Web 文件系统的又一块重要功能版图将得以补全,Web 生态将会变得更友好、更强大。
解锁尝鲜:加入 Origin Trial
FSO 还是一套崭新的 API,有多新呢?MDN 和 CanIUse 中还没有建立关于它的词条。但这并不意味着我们完全无法用于生产环境 —— 正如你在本文开头的 demo 中体验到的,我已经用到线上功能中了。只要做一点配置工作,你和你的用户就能成为全球第一批享受到 FSO 的人 😎。
Chrome 已经对 FSO 开启了试用,版本范围是 129 到 134,你可以为你的 Web App 域名注册一个试用 token,你可以跟着我一步一步操作:
首先我们访问 developer.chrome.com/origintrial… 并登录账号。
点击界面下方的「REGISTER」按钮,进入表单页:
按照上图的标注填写信息。每一个域名都需要单独注册一次。例如我本地开发调试时用的是localhost:3000,而线上域名是 rejax.fun,那么就需要给这两个域名分别走一遍 REGISTER 流程。
填写信息后提交表单,你会得到一串字符串 token:
将 token 复制出来,写到 Web App 的 html 文件中,像这样:
<meta http-equiv="origin-trial" content="把 token 粘贴到这里" />
或者用 JavaScript 动态插入:
const meta = document.createElement('meta')
meta.httpEquiv = 'origin-trial'
meta.content = token
document.head.appendChild(meta)
最后,在 Chrome 中打开你注册的域名所在的页面,在 Console 中输入 FileSystemObserver
并回车:
如果打印出了「native code」而不是「undefined」,那么恭喜,你已经成功解锁了 FSO 试用!
监听一个文件
有了试用资格,我们来监听一个文件,边调试代码边研究 FSO 的设计和实现。
实例化
上一小节的最后,我们用来测试是否解锁成功的 FileSystemObserver
就是 FSO 的构造函数,它接收一个回调函数作为参数。我们可以像这样实例化一个观察者:
function callback (params) {
console.log(params)
}
const observer = new FileSystemObserver(callback)
callback
函数会在被监听的文件发生变动时被执行,所以我们可以把响应变动的业务处理逻辑放在其中。
绑定目标文件
实例 observer
有一个 observe
方法,它接收两个参数。第二个参数暂且按下不表,我们先专心看第一个参数。
这个参数是一个 FileSystemHandle
格式的对象,代表着本地文件在 JavaScript 运行时中的入口。我们可以通过 showOpenFilePicker
来选择一个文件(假如我们选择了文件 a.js
),并获取到对应的 FileSystemHandle
。
const [fileHandle] = await window.showOpenFilePicker()
observer.observe(fileHandle)
如果你想看
FileSystemHandle
和showOpenFilePicker
的详解,可以移步至本专栏的上一篇文章《谁也别拦我们,网页里直接增删改查本地文件! 》。
调用 observe
方法后,这个文件就算是进入了我们的监控区域 📸 了,直到我们主动解除监听或者网页被关闭/刷新。
监听文件操作
当我们编辑文件 a.js
的内容时,给 observe()
传入的回调函数被调用,并且会接收到两个参数,第一个是本次的变动记录 records
,第二个是实例 observer
本身。我们打印 records
可以看到如下结构:
records
是一个数组,其元素是 FileSystemChangeRecord
类型的对象,我们重点关注以下几个属性:
changedHandle
:可以理解为这就是我们绑定的文件。type
:变动类型,可取值及对应含义如下:
type 值 含义 appeared 新建文件,或者移入被监听的根目录 disappeared 文件被删除,或者移出被监听的根目录 modified 文件内容被编辑 moved 文件被移动 unknown 未知类型 errored 出现报错
一般情况下,如果我们监听的是单个文件而不是一个目录,那么无论是把文件移走、重命名、删除, record
中的 type
值都会是 disappeared。
监听一个文件夹
监听文件夹的方式和监听文件类似,我们先用 showDirectoryPicker
选择一个文件夹(以文件夹 foo
为例),再把 DirectoryHandle
传入 observe
方法。
为方便描述,我们假设文件夹 foo 的结构如下:
/foo
├── 文件夹 dir1
├── 文件夹 **dir2**
└── 文件 a.js
const dirHandle = await window.showDirectoryPicker()
observer.observe(dirHandle)
与文件有所不同的是,文件夹会有子文件夹和子文件,这是一个树形结构。如果我们只想监听 foo
下面的一级子内容,那么使用像上方代码块那样的调用方式就可以了。但如果我们想密切掌控每一子级的变动,就需要额外的配置参数,也就是前文提到的第二个参数:
observer.observe(dirHandle, {
recursive: true
})
此时你可以在 foo 文件夹里面任意增、删、改子文件或文件夹,一切操作都能在回调函数里以 record
的形式被捕获到。子文件和子文件夹所支持的操作类型,record
值也具有相同结构,因此接下来我们从监听子文件的视角来观察 FSO。
监听子文件
创建和移入、删除和移出 a.js
的情况,record.type
的值分布如下:
文件移入 foo | 在 foo 中创建文件 | 文件从 foo 中移出 | 删除文件 |
---|---|---|---|
appeared | appeared | disappeared | disappeared |
其中移出和删除的表现,与监听单文件的情况是相同的。
我们来试试把 a.js
移到与它同级的文件夹 dir1
中,看看会得到怎样的 record
。
有几个点值得我们注意:
type
的值是 moved,说明只要a.js
还在foo
内,不管处于第几层,都不会触发type: appeared/disappeared
relativePathMovedFrom
是一个单元素数组,它代表移动前a.js
的文件路径relativePathComponents
有两个数组元素,代表被移动文件的新路径是dir1/a.js
但重命名子文件和监听单文件时不同。例如我们将 a.js
更名为 b.js
,会监听到如下 record
:
我们本以为 type
的值是 renamed,但其实是 moved,确实有点反直觉。从 record 上来看,与真正的移动操作相比,重命名的不同之处在于:
changedHandle
指向了重命名后的新文件b.js
relativePathMovedFrom
和relativePathComponents
分别包含的是旧名和新名
FSO 在状态设计上并没有直接定义一个重命名状态,但我们可以自己来区分。重命名的响应数据有这样的特征:
relativePathMovedFrom
和relativePathComponents
这两个数组的 length 一定相等- 且除了最后一个元素,两个数组的其他元素一定是一一对应相等的
因此我们可以这样判断重命名操作:
const { oldList: relativePathMovedFrom, newList: relativePathComponents } = recors[0]
let operation = '是常规的移动操作'
// 重命名前后,文件的目录路径没变,只是文件名变了
if (oldList.length === newList.length) {
const len = newList.length
for (let i = 0; i < len; i++) {
// 相同序号的新旧路径是否一样
const isEqual = newList[i] === oldList[i]
if (i < len - 1) {
if (!isEqual) break
} else if (!isEqual) {
operation = '是重命名操作,不是移动操作'
}
}
}
至此,我们已经摸清了如何监听子文件上的不同操作,除了监听单文件部分已经覆盖到的内容,增量知识点仅有移动和重命名这两块。
监听子文件夹
对子文件夹的操作,也不外乎新建、删除、移动、重命名,和子文件在逻辑上基本一致,我们可以直接复用子文件的监听逻辑,再加上用 record.changedHandle.kind === ‘directory’
来判断是否是文件夹即可。
解除监听
当我们想主动解除对文件或文件夹的监听时,只需要调用对应 observer
的 disconnect
即可:
observer.disconnect()
结语
恭喜你读完了本文,你真棒!
这一次,我们勇敢地品尝了一只新鲜生猛的螃蟹,对 File System Observer API 进行了较为深入的理解和实践。如果你之前一直苦于 JS 无法监听文件,无法带给用户完备的功能和极致的体验,那么从现在开始,你可以开始着手准备升级你的 Web App 了!
这套船新版本的 API 有力地补齐了 Web 文件系统 API 的短板,增强了 Web App 的实现能力,提升了开发者和用户的体验。它还在不断修改完善中,非常需要我们开发者积极参与到标准的制定中来,让 Web 技术栈变得更高效、更易用!
来源:juejin.cn/post/7422275840069615652
如何实现一个稳如老狗的 websocket?
前言
彦祖们,前端开发中对于 setTimeout
setInterval
一定用得烂熟于心了吧?
但你知道你的定时器并没那么靠谱吗?
本文涉及技术栈(非必要)
- vue2
场景复现
今天笔者在开发业务的时候就遇到了这样一个场景
前后端有一个 ws
通道,我们暂且命名为 channel
前后端约定如下:
- 前端每隔
5000ms
发送一个ping
消息 - 后端收到
ping
后回复一个pong
消息 - 后端如果
15000ms
未收到ping
,则视为channel
失活,直接kill
kill
后前端会主动发起重连
文章还没写两分钟,一只暴躁的测试老哥说道:"你们的 ws 也太不稳定了,几十秒就断开一次?废物?"
骂骂咧咧的甩过来一张截图
笔者心想:"为什么我的界面稳如老狗?浏览器问题,绝对是浏览器问题..."
起身查看,遂发现毫无问题,和笔者一模一样的 chrome 版本...
静心而坐,对着浏览器屏幕茶颜悦色(哦,察言观色)...
10 分钟过去了,半小时过去了...还是稳如老狗,根本不断
问题分析
那么问题到底出在哪里呢?
笔者坐在测试妹纸身边仔细观察了她的操作后!
发现她不断得切屏,此时已初步心虚,不禁问道 GPT
当浏览器标签页变为非活动状态时,
setInterval
和setTimeout
的执行频率通常会被降级。大多数现代浏览器将其执行频率限制在 1 秒(1000 毫秒)或更高,以减少 CPU 和电池的消耗。
问题原因大致是这样了
问题复现
此时笔者在本地写了个 demo
let prev = performance.now()
setInterval(()=>{
const offset = performance.now() - prev
prev = performance.now()
console.log('__SY__🎄 ~ setInterval ~ offset:', offset)
},1000)
理想的情况,我们这个 offset
应该是一直维持在 1000ms
左右
那么后续我们就要看页面激活 | 失活时候的情况了
页面激活时
我们先看下页面激活时的打印数据
没什么问题,符合我们的期望值
页面失活时
接下来我们,切换到其他浏览器标签,保持几分钟,几分钟后我们看下打印数据
明显发现有些数据不符合我们的期望值
甚至有些夸张到长达 41003ms
,将近 40 倍
,不靠谱!
寻找方案
用 setTimeout 模拟 setInterval
其实网上最多的方案就是说用 setTimeout
模拟 setInterval
但是很可惜,笔者亲自模拟下来,也是同样的结果,我们看截图
而且发现更加不靠谱了...错误的概率明显更高了...
其实可想而知,setInterval
和 setTimeout
在事件循环中都属于 Task
事件循环的优先级是一样的,同样都属于主线程任务
(标记起来,后面考重点)
Web Worker
其实网上还有类似于 requestAnimationFrame
的方案
但是测试下来更离谱,就不浪费彦祖们的时间了
进入正题吧
其实上文说了,主线程任务
的优先级会被降低,那么我们思考一下子线程任务
呢?
子线程任务
在前端领域,我们不就能想到 Web Worker
吗?
当然除了 Web Worker
,还有 SharedWorker
Service Worker
非本文重点,不做赘述
什么是 Web Worker
首先我们来认识下什么是 Web Worker
Web Worker 是一种运行在浏览器后台的独立 JavaScript 线程,允许我们在
不阻塞主线程
(即不影响页面 UI 和用户交互的情况下)执行一些耗时的任务,比如数据处理、文件操作、复杂计算等。
不阻塞主线程
这恰恰是我们的所需要的!
使用 Web Worker
其实 Web Worker
在常规使用
和 vue
中还是有一定的区别的
常规使用
常规使用其实非常简单,我们还是以上文中的 demo 为例
改造一下
- index.html
const worker = new Worker('./worker.js');
我们还需要一个 worker.js
文件
- worker.js
let prev = performance.now()
setInterval(() => {
const offset = performance.now() - prev
prev = performance.now()
console.log('__SY__🎄 ~ setInterval ~ offset:', offset)
}, 1000)
切换 tab 几分钟后让我们来看看打印结果
非常完美,几乎都保持在 1000ms
左右
在 vue 中使用 Web Worker
在 vue 中使用就和常规使用有所不同了
这也是笔者今天踩坑比较多的地方
网上很多文中配置了 webpack
的 worker-loader
,然后改造 vue.config.js
但是笔者多次尝试,还是各种报错(如果有大佬踩过坑,请在评论区留言)
最后笔者翻到了之前的笔记,其实早在多年之前就记录了在 vue 中使用 Web Worker
的文章
使用方式非常简单
我们只需要把 worker.js
放置于 public
目录即可!
看下我们此时的代码
- App.vue
// 此处注意要访问 根路径 /
const myWorker = new Worker('/worker.js')
- public
- worker.js
let prev = performance.now()
setInterval(() => {
const offset = performance.now() - prev
prev = performance.now()
console.log('__SY__🎄 ~ setInterval ~ offset:', offset)
}, 1000)
测试一下
非常完美!
解决业务问题
彦祖们此时可能要问道,你只是证明了 Web Worker
不会阻塞主进程
和你的业务有什么关系吗?
其实这还得依赖于Web Worker
的通信机制
我们继续改造
- App.vue
const myWorker = new Worker('/worker.js')
myWorker.postMessage('createPingInterval') //向 worker 发送开启定时器的指令
// 接收 Web Worker 的执行指令,执行对应业务
myWorker.onmessage = function (event) {
console.log('__SY__🎄 ~ event:', event)
}
- worker.js
// 接收到主进程 `开启定时器的指令` 处理定时器逻辑
self.onmessage = function(event) {
const interval = setInterval(() => {
self.postMessage('executor') // 定时向主进程发送定时器执行指令
}, 1000)
}
封装一个 setWorkerInterval
其实有了以上的代码模型,我们就能封装一个不受主进程阻塞
的定时器了
我们暂且命名它为 setWorkerInterval
函数设计
首先设计一下我们的函数
为了减少开发者心智负担
我们需要把函数设计成和 setInterval
一样的用法
我们在使用 setInterval
的时候,日常最常用的参数就是 callback
和 delay
它的返回值是一个 intervalID
由此可见我们的函数签名如下
function setWorkerInterval(callback,delay){
const intervalID = xxx(callback,delay) // 定时执行
return intervalID
}
动手实现
有了上面的函数设计,我们就开始来实现
目前我们遇到一个问题,那就是上文中的 xxx
具体是个啥?
这其实就是 Web Worker
中的 setInterval
我们只需要把 Web Worker
中的 setInterval
的功能暴露给主线程不就完事了吗?
来看 代码
- setWorkerInterval.js
export default function(callback, delay) {
//创建一个 worker
const worker = new Worker('/worker.js')
worker.postMessage('') // 开启定时器
// 接收 Web Worker 的消息
worker.onmessage = function(event) {
// 收到 worker 的 setInterval 触发,触发对应业务逻辑
}
}
- worker.js
// 处理定时器逻辑
self.onmessage = function(event) {
const interval = setInterval(() => {
self.postMessage({}) // 定时通知主线程,即上文中的 xxx
}, 1000)
}
这样我们就初步完成了以上 xxx
的逻辑
但随之而来又有两个问题
1.如何触发对应业务逻辑?
2.如何清除定时器?
触发对应业务逻辑
其实第一个问题非常容易解决,我们不是传递了一个 callback
吗?
这不就是我们的业务逻辑吗
改造一下
- setWorkerInteraval.js
export default function(callback, delay) {
const worker = new Worker('/worker.js')
worker.postMessage('') // 开启定时器
// 接收 Web Worker 的消息
worker.onmessage = function(event) {
callback() // 定时执行业务 callback
}
}
清除定时器
这个问题还是踩了坑的,刚开始以为 intervalID
的来源不就在 worker.js
吗?
那我们只需要把它通知给主线程即可,后来发现不可行,主线程的 clearInteravl
对于 worker
的 intervalID
并不生效...
那我们换个思路,在主线程发送一个 clear
指令不就行了吗? 说干就干,思路有了,直接看代码
- worker.js
// 处理定时器逻辑
self.onmessage = function(event) {
const intervalID = setInterval(() => {
self.postMessage({}) // 定时通知主线程,即上文中的 xxx
}, 1000)
/// 收到clear消息后, 清除定时器
if (event.data === 'clear') {
clearInterval(intervalID)
}
}
- setWorkerInteraval
export default function(callback, delay) {
const worker = new Worker('/worker.js');
// 因为 onmessage 是异步的, 所以我们要抛出一个 promise
return new Promise((resolve) => {
worker.postMessage('') // 开启定时器
// 接收 Web Worker 的消息
worker.onmessage = function(event) {
callback() // 执行业务逻辑
}
})
const clear = () => {worker.postMessage('clear')}
return clear // 返回一个函数, 用于关闭定时器
}
让我们看下使用方式
let prev = performance.now()
const clear = setWorkerInteraval(function(){
const offset = performance.now() - prev
console.log('__SY__🎄 ~ setWorkerInteraval ~ offset:', offset)
prev = performance.now()
},1000)
setTimeout(clear,5000) // 5000ms 后清除
以上代码看似没问题,但是使用下来并不生效,也就是定时器并未被清除
问题出在哪里呢?
其实我们在发送 clear
指令的时候,也会进入 self.onmessage
函数
那么此时又会新建一个 interval
,而我们清空的只是当前 interval
而已
那么我们必须想个方法,使得 interval
在当前实例是唯一的
其实非常简单,借助于 JS 万物皆对象
的思想,我们的 self
不也是一个对象吗?
那我们在它上面挂载一个 interval
有何不可呢?说干就干
- worker.js
// 处理定时器逻辑
self.onmessage = function (event) {
// 返回一个非零值 所以我们可以大胆使用 ||=
self.intervalID ||= setInterval(() => {
self.postMessage({}) // 定时通知主线程,即上文中的 xxx
}, 1000)
/// 收到clear消息后, 清除定时器
if (event.data === 'clear') {
clearInterval(self.intervalID)
}
}
测试后,非常完美,至此,一个靠谱的定时器我们就完成了!
当然我们还可以把上文中的 1000ms
改成 delay
传参,直接看完成代码吧
完整代码
- worker.js (vue项目 需要放在 public 中)
// 处理定时器逻辑
self.onmessage = function (event) {
/// 收到clear消息后, 清除定时器
if (event.data === 'clear') {
clearInterval(self.intervalID)
} else {
const delay = event.data
self.intervalID ||= setInterval(() => {
self.postMessage({}) // 定时通知主线程,即上文中的 xxx
}, delay)
}
}
- setWorkerInteraval
export default function(callback, delay) {
const worker = new Worker('/worker.js');
worker.postMessage(delay) // 传递 delay 延时参数
// 接收 Web Worker 的消息
worker.onmessage = function(event) {
callback() // 执行业务逻辑
}
const clear = () => {worker.postMessage('clear')}
return clear
}
写在最后
技术服务于业务,但最怕局限于业务
希望彦祖们在开发业务中,能获取更多更深层次的思考和能力!共勉✨
感谢彦祖们的阅读
个人能力有限
如有不对,欢迎指正🌟 如有帮助,建议小心心大拇指三连🌟
彩蛋
宁波团队还有一个资深前端
hc, 带你实现海鲜自由
。 欢迎彦祖们私信😚
来源:juejin.cn/post/7418391732163182607
css3+vue做一个带流光交互效果的功能入口卡片布局
前言
该案例主要用到了css的新特性 @property
来实现交互流光效果
@property
是CSS Houdini API的一部分,通过它,开发者可以在样式表中直接注册自定义属性,而无需运行任何JavaScript代码。
Demo在线预览
@propert语法说明
@property --自定义属性名 {
syntax: '语法结构';
initial-value: '初始值';
inherits: '是否允许该属性被继承';
}
自定义属性名:需要以--
开头,这是CSS自定义属性的标准命名方式。
syntax:描述该属性所允许的语法结构,是必需的。它定义了自定义属性可以接受的值的类型,如颜色、长度、百分比等。
initial-value:用于指定自定义属性的默认值。它必须能够按照syntax
描述符的定义正确解析。在syntax
为通用语法定义时,initial-value
是可选的,否则它是必需的。
inherits:用于指定该自定义属性是否可以被其他元素所继承,通过布尔值true
或false
赋值。
Html代码部分
<div class="flex-x-box">
<!-- 内容 start -->
<div class="card" v-for="(i,index) in list" :key="index">
<img :src="i.url"/>
<div class="info-box">
<h1>{{ i.label +'管理' }}</h1>
<p>{{ i.desc }}</p>
</div>
<div class="details-box">
<span>
<h2>{{ i.total }}</h2>
<p>{{i.label +'总数'}}</p>
</span>
<span>
<h2>{{ i.add }}</h2>
<p>今日新增</p>
</span>
</div>
</div>
<!-- 内容 end -->
</div>
数据结构部分
list:[
{
label:'报表',
desc:'深度数据挖掘,多维度报表生成,实时监控业务动态,为企业决策提供有力数据支撑',
url:'图标地址',
total:'108',
add:'12'
},{
label:'产品',
desc:'全生命周期管理,从研发到销售无缝衔接,优化产品组合,提升市场竞争力',
url:'图标地址',
total:'267',
add:'25'
},{
label:'文档',
desc:'高效知识存储,智能检索体系,确保信息准确传递,助力团队协作与知识共享',
url:'图标地址',
total:'37',
add:'2'
}
]
css样式控制部分
.flex-x-box{
display:flex;
}
/* 卡片宽度 */
$card-height:280px;
$card-width:220px;
div,span,p{
box-size:border-box;
}
.card {
position: relative;
width: $card-width;
height: $card-height;
border-radius: 12px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
color: rgba(88,199,250,0%);
cursor: pointer;
box-size:border-box;
background: linear-gradient(210deg,#203656,#426889);
margin:0px 15px;
img{
width:90%;
margin-top:-80px;
transition:200ms linear;
}
.info-box{
width:100%;
}
h1{
font-size:14px;
color:#f3fbff;
}
p{
font-size:12px;
color:#a1c4de;
padding: 0px 28px;
line-height: 1.6;
}
.details-box{
position: absolute;
display:flex;
align-items:center;
width: 100%;
height:120px;
box-sizing: border-box;
bottom: 0px;
padding-top: 45px;
z-index: 10;
background: linear-gradient(0deg, #152d4a 70%, transparent);
border-radius: 0px 0px 12px 12px;
transition:200ms linear;
opacity:0;
span{
width:0px;
flex-grow:1;
}
h2{
margin:0px;
font-size:14px;
line-height:1.8;
color:#fff;
}
p{
margin:0px;
}
}
}
.card:hover{
&::before{
background-image: linear-gradient(var(--rotate) , #5ddcff, #3c67e3 43%, #ffddc5);
animation: spin 3s linear infinite;
}
&::after{
background-image: linear-gradient(var(--rotate), #5ddcff, #3c67e3 43%, #ff8055);
animation: spin 3s linear infinite;
}
.details-box{
opacity:1;
}
img{
width:96%;
margin-top:-60px;
}
}
.card::before {
content: "";
width:$card-width +2px;
height:$card-height +2px;
border-radius: 13px;
background-image: linear-gradient(var(--rotate) , #435b7c, #5f8cad 50%, #729bba);
position: absolute;
z-index: -1;
top: -1px;
left: -1px;
}
.card::after {
position: absolute;
content: "";
top: calc($card-height / 6);
left: 0;
right: 0;
z-index: -1;
height: 100%;
width: 100%;
margin: 0 auto;
transform: scale(0.9);
filter: blur(20px);
opacity: 1;
transition: opacity .5s;
}
/* CSS 自定义属性 */
@property --rotate {
syntax: "<angle>";
initial-value: 90deg;
inherits: false;
}
@keyframes spin {
0% {
--rotate: 0deg;
}
100% {
--rotate: 360deg;
}
}
来源:juejin.cn/post/7423708823428055081
入职N天的我,终于接到了我的第一个需求
我是9月份中下旬入职的,入职了之后,每天的工作就是熟悉代码和框架,就这样过了几天之后,迎来了我在新公司的第一个节日:国庆节。
每年国庆节都是农民最开心的日子,因为要秋收了,秋收就意味着赚钱了,但是今年农作物的行情就跟程序员的行情一样,超级不好,各种粮食的价格都很便宜,辛辛苦苦一年只能算个收支平衡(来自一个农二代的吐槽)。
节后第一天,由于领导请假了,所以我还是在自己看代码(是的,还在看)。等到了第二天,领导来上班了,我迫不及待的问领导有没有什么需求给我,领导说快有了,然后我就只能默默地等待,好在中午快吃饭的时候,领导跟我说有个功能需要修改一下,让我来做(激动地心,颤抖的手,终于有需求可做了)
项目排序功能
需求:根据汉字拼音进行排序。
有以下几种实现方案
方式一:通过String.prototype.localCompare()
const arr = ['我十分', '喜欢', '写', '代码'];
function sortArr(data) {
return data.sort((a, b) => a.localeCompare(b, 'zh', { sensitivity: 'base' }))
}
sortArr(arr)
如果大家有对localeCompare不熟悉的可以继续往下看,如果有比较熟悉的,那么可以直接跳过。
localeCompare
返回一个数字,用于表示引用字符串(调用者referenceString)在比较字符串(第一个参数compareString)的前面、后面、或者相等
参数
- compareString: 要与referenceString进行比较的字符串,所有值都会被强制转换为字符串。如果不传或者传入undefined会出现你不想要的情况
- locals:表示语言类型,比如zh就是中文
- options: 一堆属性
- localeMather:区域匹配算法
- usage:比较是用于排序还是用于搜索匹配的字符串,支持sort和search,默认sort
- sensitivity:字符串哪些差异导致非0
- base:字母不同的字符串比较不相等,a≠b,a=A
- accent:字母不同 || 重音符号和其他变音符号的不同字符串比较不相等, a ≠ b、a ≠ á、a = A。
- case:字母不同 或者 大小写的字母表示不同 a ≠ b,a ≠ A
- variant:字符串的字母、重音和其他变音符号,或者不同大小写比较不相等,a ≠ b、a ≠ á、a ≠ A
注意:当useage的字为sort时,sensitivity的字默认为variantignore
- Punctuation:是否忽略标点符号,默认为falsen
- umeric:是否使用数字对照,使得“1”<“2”<“10”,默认是false
- caseFirst:是否个根据大小写排序,可能的值是upper、lower、false(默认设置)
- collation:区域的变体
- pinyin:汉语
- compat:阿拉伯语
locals中的配置会和options内的配置会发生重叠,如果有重叠,options的优先级更高
注意:某些浏览器中可能不支持locals或者options,那么此时会忽略这些属性
返回值
返回一个数字
- 如果引用字符串在比较字符串前面,返回负数
- 如果引用字符串在比较字符串后面,返回正数
- 如果两者相同,那么返回0
warning:不要依赖具体的返回值去做一些事情,因为在不同的浏览器、或者相同浏览器的不同版本中,返回的具体数值可能是不一样的,W3C只要求返回值是正数或者负数,而不规定具体的值。
方式2:通过pinyin库
- 需要安装
pinyin
库,在命令行中执行
npm install pinyin
- 实现排序逻辑
const { pinyin } = require('pinyin')
const arr = [ '我十分', '喜欢', '写', '代码' ]
const sortedWords = arr.sort((a, b) => {
const pinyinA = pinyin(a, { style: pinyin.STYLE_NORMAL }).flat().join('')
const pinyinB = pinyin(b, { style: pinyin.STYLE_NORMAL }).flat().join('')
return pinyinA.localeCompare(pinyinB)
})
console.log(sortedWords)
方式3:自己实现一套映射
因为我们的文案不是确定的,且可以随意修改,所以这种方案不提倡,但是如果只有几个固定的文案,这样可以自己实现一套映射
const pinyinMap = {
我十分: 'woshifen',
喜欢: 'xihuan',
写: 'xie',
代码: 'daima',
}
const arr = [ '我十分', '喜欢', '写', '代码' ]
const sortedWords = arr.sort((a, b) => pinyinMap[a].localeCompare(pinyinMap[b]))
console.log(sortedWords)
上面三种方式,可以看的出来,第一种还是存在一定的误差,但是我还是选择了第一种实现方式,有以下几个原因
- 不需要额外的引入库
- 我们的文案是随时可以修改的
- 我们对于排序的要求没有那么强烈,只要排一个大致的顺序即可
以上就是我对根据汉字拼音排序实现方案的理解,欢迎大家补充,希望大家一起进步!
来源:juejin.cn/post/7423573726400299027
几种神秘鲜为人知但却有趣的前端技术
测定网速
navigator.connection.downlink
是一个用于表示当前连接下行速度的属性,它是 JavaScript 的 Network Information
API 的一部分。此属性返回一个数值,单位为 Mbps(Megabits per second),表示网络的下行带宽。
例如,你可以通过以下方式使用它:
if (navigator.connection) {
const downlink = navigator.connection.downlink;
console.log(`当前下行速度为: ${downlink} Mbps`);
} else {
console.log("当前浏览器不支持Network Information API");
}
需要注意的是,Network Information
API 并不是所有浏览器都支持,因此在使用时最好进行兼容性检查。
在智能手机上启用振动
window.navigator.vibrate
是一个用于触发设备震动的 Web API,通常用于移动设备上。这个方法允许开发者控制设备的震动模式,它接受一个数字或一个数组作为参数。
- 如果传入一个数字,这个数字表示震动的时长(以毫秒为单位)。
- 如果传入一个数组,可以定义震动和静止的模式,例如
[200, 100, 200]
表示震动200毫秒,静止100毫秒,再震动200毫秒。
以下是一个简单的示例:
// 使设备震动 500 毫秒
if (navigator.vibrate) {
navigator.vibrate(500);
} else {
console.log("当前浏览器不支持震动 API");
}
// 使用数组来创建震动模式
if (navigator.vibrate) {
navigator.vibrate([200, 100, 200, 100, 200]);
}
请注意,并不是所有的设备和浏览器都支持震动 API,因此在使用时最好确认设备的兼容性。
禁止插入文字
你可能不希望用户在输入字段中粘贴从其他地方复制的文本(请仔细考虑清楚是否真的要这样做)。通过跟踪事件paste
并调用其方法preventDefault()
就很容易完成了。
<input type="text"></input>
<script>
const input = document.querySelector('input');
input.addEventListener("paste", function(e){
e.preventDefault()
})
</script>
好了,现在你无法复制粘贴,必须得手动编写和输入所有的内容了
快速隐藏dom
要隐藏DOM元素,不是非得用到JavaScript。原生的HTML属性完全可以实现hidden。效果类似于添加样式display: none;
。元素就从页面上消失了。
<p hidden>我在页面看不到了</p>
注意,这个技巧不适用于伪元素
快速使用定位
你知道CSS的inset
属性吗?这是我们所熟悉的top
,left
,right
和bottom
的缩写版本。通过类比短语法margin
或属性padding
,只要一行代码就可以设置元素的所有偏移量。
/* 普通写法 */
div {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
/* inset写法 */
div {
position: absolute;
inset: 0;
}
使用简短的语法可以大大减小CSS文件的体积,这样代码看起来更干净。但是,可别忘了inset
是一个布尔属性,它考虑了内容排版方向。换句话说,如果站点使用的是具有rtl
方向(从右到左)的语言,那么left
要变成right
,反之亦然。
你不知道的Console的用法
通常我们用的最多的console.log(xxx)
,其实在 JavaScript 中,console
对象提供了一些很有用的方法用于调试和查看信息。以下是一些可能不太常见的 console
用法:
- console.table() : 可以用来以表格的格式输出数组或对象,非常适合查看数据结构。
const data = [
{ name: 'Alice', age: 25 },
{ name: 'Bob', age: 30 }
];
console.table(data);
- console.group() 和 console.groupEnd() : 可以将相关日志信息分组,方便查看和组织输出。
console.group('Gr0up Label');
console.log('这是一条 log');
console.log('这是一条 log 2');
console.groupEnd();
- console.time() 和 console.timeEnd() : 用于测量代码块的执行时间。
console.time('myTimer');
// 执行一些操作
console.timeEnd('myTimer'); // 输出所用的时间
- console.error() 和 console.warn() : 用于输出错误和警告信息,通常会以不同的颜色高亮显示。
console.error('这是一个错误信息');
console.warn('这是一个警告信息');
- console.assert() : 用于在条件为 false 时输出错误信息。
const condition = false;
console.assert(condition, '条件为 false,输出这条信息');
- console.clear() : 清空控制台的输出。
console.clear();
- console.dir() : 用于打印对象的可枚举属性,方便查看对象的详细结构。
const obj = { name: 'Alice', age: 25 };
console.dir(obj);
禁止下拉刷新
下拉刷新是当前流行的移动开发模式。如果你不喜欢这样做,只需将overscroll-behavior-y
属性的值设为contains
即可达到此效果。
body {
overscroll-behavior-y: contain;
}
这个属性对于组织模态窗口内的滚动也非常有用——它可以防止主页在到达边框时拦截滚动
使整个网页的 <body>
内容可编辑
document.body.contentEditable='true';
是一段 JavaScript 代码,用于使整个网页的 <body>
内容可编辑。这意味着用户可以直接在浏览器中点击并编辑文本,就像在文本编辑器中一样。
以下是一些相关的要点:
- 启用编辑模式:将
contentEditable
属性设置为'true'
,浏览器会允许用户更改页面的内容。
document.body.contentEditable = 'true';
- 禁用编辑模式:如果希望用户无法编辑页面,您可以将该属性设置为
'false'
。
document.body.contentEditable = 'false';
- 注意事项:
- 这种做法在很多场景中很方便,比如在展示一些信息并希望用户能快速修改的时候。例如,创建自定义的富文本编辑器。
- 但是,使用
contentEditable
也可能会带来一些不便,比如用户修改了页面的结构,甚至可能影响脚本的运行。因此在使用时要谨慎,并确保有合适的方法来处理用户的输入。 - 启用
contentEditable
后,如果网页中有表单元素,用户的输入可能与表单的默认行为产生冲突。
- 样式和功能:在启用编辑模式后,你可能还想添加一些 CSS 来改变光标样式,或者结合 JavaScript 进一步增强编辑体验,比如自动保存用户的修改。
示例代码:
// 启用编辑功能
document.body.contentEditable = 'true';
// 禁用编辑功能
// document.body.contentEditable = 'false';
这种功能可以非常方便地用于快速原型设计或需要快速内容编辑的应用,但在生产环境中要慎重使用。
带有Id属性的元素,会创建全局变量
在一张HTML页面中,所有设置了ID属性的元素会在JavaScript的执行环境中创建对应的全局变量,这意味着document.getElementById像人的智齿一样显得多余了。但实际项目中最好还是老老实实该怎么写就怎么写,毕竟常规代码出乱子的机会要小得多。
<div id="test"></div>
<script>
console.log(test)
</script>
网站平滑滚动
在 <html>
元素中添加 scroll-behavior: smooth
,以实现整个页面的平滑滚动。
html{
scroll-behavior: smooth;
}
:empty 表示空元素
此选择器定位空的 <p>
元素并隐藏它们
p:empty{
display: none;
}
来源:juejin.cn/post/7423314983884292134
threejs渲染高级感可视化风力发电车模型
本文使用threejs开发一款风力发电机物联可视化系统,包含着色器效果、动画、补间动画和开发过程中使用模型材质遇到的问题,内含大量gif效果图,
视频讲解及源码见文末
技术栈
- three.js 0.165.0
- vite 4.3.2
- nodejs v18.19.0
效果图
一镜到底动画
切割动画
线稿动画
外壳透明度动画
展开齿轮动画
发光线条动画
代码及功能介绍
着色器
文中用到一个着色器,就是给模型增加光感的动态光影
创建顶点着色器 vertexShader:
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
创建片元着色器 vertexShader:
varying vec2 vUv;
uniform vec2 u_center; // 添加这一行
void main() {
// 泡泡颜色
vec3 bubbleColor = vec3(0.9, 0.9, 0.9); // 乳白色
// 泡泡中心位置
vec2 center = u_center;
// 计算当前像素到泡泡中心的距离
float distanceToCenter = distance(vUv, center);
// 计算透明度,可以根据实际需要调整
float alpha = smoothstep(0.1, 0.0, distanceToCenter);
gl_FragColor = vec4(bubbleColor, alpha);
创建着色器材质 bubbleMaterial
export const bubbleMaterial = new THREE.ShaderMaterial({
vertexShader: vertexShader,
fragmentShader: fragmentShader,
transparent: true, // 开启透明
depthTest: true, // 开启深度测试
depthWrite: false, // 不写入深度缓冲
uniforms: {
u_center: { value: new THREE.Vector2(0.3, 0.3) } // 添加这一行
},
});
从代码中可以看到 uniform
声明了一个变量u_center
,目的是为了在render方法中动态修改中心位置,从而实现动态光效的效果,
具体引用 render 方法中
// 更新中心位置(例如,每一帧都改变)
let t = performance.now() * 0.001;
bubbleMaterial.uniforms.u_center.value.x = Math.sin(t) * 0.5 + 0.5; // x 位置基于时间变化
bubbleMaterial.uniforms.u_center.value.y = Math.cos(t) * 0.5 + 0.5; // y 位置基于时间变化
官网案例 # Uniform,详细介绍了uniform的使用方法,支持通过变量对着色器材质中的属性进行改变
从模型上可能看不出什么,下面的图是在一个圆球上加的这个效果
着色器中有几个参数可以自定义也可以自己修改, float alpha = smoothstep(0.6, 0.0, distanceToCenter);
中的smoothstep
是一个常用的函数,用于在两个值之间进行平滑插值。具体来说,smoothstep(edge0, edge1, x)
函数会计算 x
在 edge0
和 edge1
之间的平滑过渡值。当 x
小于 edge0
时,返回值为 0;当 x
大于 edge1
时,返回值为 1;而当 x
在 edge0
和 edge1
之间时,它返回一个在 0 和 1 之间的平滑过渡值。
切割动画
切割动画使用的是数学库平面THREE.Plane
和属性 constant
,通过修改constant值即可实现动画,从normal
法向量起至constant的距离为可展示内容。
从原点到平面的有符号距离。 默认值为 0.
constant取模型的box3包围盒的min值,至max值做补间动画,以下是代码示意
const wind = windGltf.scene
const boxInfo = wind.userData.box3Info;
const max = boxInfo.worldPosition.z + boxInfo.max.z
const min = boxInfo.worldPosition.z + boxInfo.min.z
let tween = new TWEEN.Tween({ d: min - 0.2 })
.to({ d: max + 0.1 }, 1000 * 2)
.start()
.onUpdate(({ d }) => {
clippingPlane.constant = d
})
详看切割效果图
图中添加了切割线的辅助线,可以通过右侧的操作面板显示或隐藏。
模型材质需要注意的问题
由于齿轮在风车的内容部,并且风车模型开启了transparent=true
,那么计算透明度深度就会出现问题,首先要设置 depthWrite = true
,开启深度缓存区,renderOrder = -1
,
这个值将使得scene graph(场景图)中默认的的渲染顺序被覆盖, 即使不透明对象和透明对象保持独立顺序。 渲染顺序是由低到高来排序的,默认值为0。
threejs的透明材质渲染和不透明材质渲染的时候,会互相影响,而调整renderOrder
顺序则可以让透明对象和不透明对象相对独立的渲染。
depthWrite
对比
renderOrder
对比
自定义动画贝塞尔曲线
众所周知,贝塞尔曲线通常用于调整关键帧动画,创建平滑的、曲线的运动路径。本文中使用的tweenjs就内置了众多的运动曲线easing(easingFunction?: EasingFunction): this;
类型,虽然有很多内置,但是毕竟需求是无限的,接下来介绍的方法就是可以自己设置动画的贝塞尔曲线,来控制动画的执行曲线。
具体使用
// 使用示例
const controlPoints = [ { x: 0 }, { x: 0.5 }, { x: 2 }, { x: 1 }];
const cubicBezier = new CubicBezier(controlPoints[0], controlPoints[1], controlPoints[2], controlPoints[3]);
let tween = new TWEEN.Tween(edgeLineGr0up.scale)
.to(windGltf.scene.scale.clone().set(1, 1, 1), 1000 * 2)
.easing((t) => {
return cubicBezier.get(t).x
})
.start()
.onComplete(() => {
lineOpacityAction(0.3)
res({ tween })
})
在tween的easing
的回调中添加一个方法,方法中调用了cubicBezier
,下面就介绍一下这个方法
源码
[p0] – 起点
[p1] – 第一个控制点
[p2] – 第二个控制点
[p3] – 终点
export class CubicBezier {
private p0: { x: number; };
private p1: { x: number; };
private p2: { x: number; };
private p3: { x: number; };
constructor(p0: { x: number; }, p1: { x: number; }, p2: { x: number; }, p3: { x: number; }) {
this.p0 = p0;
this.p1 = p1;
this.p2 = p2;
this.p3 = p3;
}
get(t: number): { x: number; } {
const p0 = this.p0;
const p1 = this.p1;
const p2 = this.p2;
const p3 = this.p3;
const mt = 1 - t;
const mt2 = mt * mt;
const mt3 = mt2 * mt;
const t2 = t * t;
const t3 = t2 * t;
const x = mt3 * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t3 * p3.x;
return { x };
}
}
类CubicBezier
支持get
方法,通过四个关键点位信息,绘制三次贝塞尔曲线,参数t
在0到1之间变化,当t
从0变化到1时,曲线上的点从p0
平滑地过渡到p3
。
mt = 1 - t;
:这是t
的补数(1减去t
)。
mt2 = mt * mt;
和 mt3 = mt2 * mt;
:计算mt
的平方和立方。
t2 = t * t;
和 t3 = t2 * t;
:计算t
的平方和立方。
这是通过取四个点的x
坐标的加权和来完成的,其中权重是基于t
的幂的。具体来说,p0
的权重是(1-t)^3
,p1
的权重是3 * (1-t)^2 * t
,p2
的权重是3 * (1-t) * t^2
,而p3
的权重是t^3
。
{ x: 0 },{ x: 0.5 },{ x: 2 },{ x: 1 }
这组数据形成的曲线效果是由start参数到end的两倍参数再到end参数
具体效果如下
齿轮
齿轮动画
模型中自带动画
源码中有一整套的动画播放类方法,HandleAnimation
,其中功能包含播放训话动画,切换动画,播放一次动画,绘制骨骼,镜头跟随等功能。
具体使用方法:
// 齿轮动画
/**
*
* @param model 动画模型
* @param animations 动画合集
*/
motorAnimation = new HandleAnimation(motorGltf.scene, motorGltf.animations)
// 播放动画 take 001 是默认动画名称
motorAnimation.play('Take 001')
在render中调用
motorAnimation && motorAnimation.upDate()
齿轮展开(补间动画)
补间动画在齿轮展开时调用,使用的tweenjs,这里讲一下定位运动后的模型位置,使用# 变换控制器(TransformControls),代码中有封装好的完整的使用方法,在TransformControls.ts
中,包含同时存在轨道控制器时与变换控制器对场景操作冲突时的处理。
使用方法:
/**
* @param mesh 受控模型
* @param draggingChangedCallback 操控回调
*/
TransformControls(mesh, ()=>{
console.log(mesh.position)
})
齿轮发光
发光效果方法封装在utls/index.ts
中的unreal
方法,使用的是threejs提供的虚幻发光通道RenderPass
,UnrealBloomPass
,以及合成器EffectComposer
,方法接受参数如下
// params 默认参数
const createParams = {
threshold: 0,
strength: 0.972, // 强度
radius: 0.21,// 半径
exposure: 1.55 // 扩散
};
/**
*
* @param scene 渲染场景
* @param camera 镜头
* @param renderer 渲染器
* @param width 需要发光位置的宽度
* @param height 发光位置的高度
* @param params 发光参数
* @returns
*/
调用方法如下:
const { finalComposer: F,
bloomComposer: B,
renderScene: R, bloomPass: BP } = unreal(scene, camera, renderer, width, height, params)
finalComposer = F
bloomComposer = B
renderScene = R
bloomPass = BP
bloomPass.threshold = 0
除了调用方法还有一些需要调整的地方,比如发光时模型什么材质,又或者不发光时又是什么材质,这里需要单独定义,并在render渲染函数中调用
if (guiParams.isLight) {
if (bloomComposer) {
scene.traverse(darkenNonBloomed.bind(this));
bloomComposer.render();
}
if (finalComposer) {
scene.traverse(restoreMaterial.bind(this));
finalComposer.render();
}
}
在scene.traverse
的回调中,检验模型是否为发光体,再进行材质的更换,这里用的标识是 object.userData.isLight
为true
时,判定该物体为发光物体。其他物体则不发光
回调方法
function darkenNonBloomed(obj: THREE.Mesh) {
if (bloomLayer) {
if (!obj.userData.isLight && bloomLayer.test(obj.layers) === false) {
materials[obj.uuid] = obj.material;
obj.material = darkMaterial;
}
}
}
function restoreMaterial(obj: THREE.Mesh) {
if (materials[obj.uuid]) {
obj.material = materials[obj.uuid];
// 用于删除没必要的渲染
delete materials[obj.uuid];
}
}
再场景的右上角我们新增了几个参数,用来调整线条的发光效果,下面通过动图看一下,图片有点大,请耐心等待加载
好啦,本篇文章到此,如看源码有不明白的地方,可私信~
最近正在筹备工具库,以上可视化常用的方法都将涵盖在里面
历史文章
three.js+物理引擎——跨越障碍的汽车 可操作 可演示
源码及讲解
源码 http://www.aspiringcode.com/content?id=…
来源:juejin.cn/post/7379906492038889512
AI 治好了我的 CSS 框架恐惧症
00. 写在前面
大家好,我是大家的林语冰。
前端工程中,苦于“前 CSS3 时代”相对落后的原生语法,CSS 架构一直是前端架构师的痛点之一。
因此,我们一般会在项目里引入更先进的 CSS 框架或预处理器,比如国内比较火的 SCSS/LESS,或者海外人气爆棚的 Tailwind CSS/UnoCSS 等。
问题在于,当我们学习从原生 CSS 升级到 SCSS,或者老板要求从 SCSS 迁移到人气更高的 Tailwind 框架时,不同 CSS 框架的学习成本也不容小觑。
本质上而言,这些 CSS 框架提供的高级语法最终都会被转译为原生 CSS,而这种语法转换工作恰恰是 AI 编程助手的拿手好戏。
所以,本期我想分享如何利用 VSCode 和 MarsCode AI 插件,在原生 CSS 和不同 CSS 框架中无缝衔接,直接让 AI 解放我们的双手,不必再因为不同的 CSS 框架而头大。
01. 前期准备
本文的示例代码是用原生 CSS 实现一个仿真的 iPhone 手机,类似的产品模型网页预览效果在很多电商网站都比较常见,最终实现效果如下所示:
上述手机模型对应的原生的 HTML 结构和 CSS 代码如下:
(PS:此处代码仅供参考,大家可以用自己的样式代码进行后续测试,不需要关注这里的代码细节)
02. VSCode AI 插件
假设上述示例是项目遗留的旧代码,我们想要使用其他 CSS 框架重构为可维护的高级样式代码,就需要和 AI 助手联动,让 AI 帮我们写代码。
首先,我们需要可以使用手机号或邮箱注册一个账号,然后在 VSCode 里搜索和安装 MarsCode 扩展插件,登录后就可以在 VSCode 里直接使用 AI 编程助手。
另外,豆包 MarsCode 使用的是字节跳动的国产大模型,所以我们不需要考虑科学上网等复杂问题。
接着就可以让 AI 干活了,我们可以把原生 CSS 抽离到单独的样式文件中,然后让 AI 把它转译为 SCSS 版本,只需要通过聊天的方式命令 AI 执行任务即可,不需要我们手动敲一行代码。
MarsCode 比较人性化的一点是,生成的代码可以直接一键保存到新文件中,然后我们可以测试生成的 SCSS 代码是否和原生版本等效,如果效果有偏差,可以尝试多生成几次。
我这里生成的 SCSS 代码也可以正常工作,因为样式逻辑并不复杂,但所有原生 CSS 都被重构为 SCSS 的嵌套语法。
毋庸置疑,在代码编译或重构方面,AI 可以明显提高我们的生产力,哪怕是复杂的样式代码也不例外。
03. 样式构建
目前前端工程中,大部分项目可能会依赖 Vite 工具链构建,因此我们也可以引入 Vite,再集成需要的 CSS 框架。
Vite 配置在官方文档有具体介绍,以 SCSS 为例,我们需要安装模块,然后更改配置文档。
实际测试中,我偷懒不看文档,而是直接询问 AI 助手如何配置,MarsCode 虽然给出了答案,但是答案未必有效,可能出现配置失败,或者配置生效,但不是最佳配置的情况,我猜可能跟目前 MarsCode 的预训练模型的局限性有关。
这也说明和 AI 编程助手一起使用时,我们最好还是有对应 CSS 框架的知识储备,才能放心地偷懒,遇到 bug 也能了然于胸。
另外,在 CSS 框架选型方面,目前我更推荐 UnoCSS,因为它是一个同构引擎,这意味着,UnoCSS 默认兼容 Tailwind 同款语法,也能够支持类似 SCSS 的功能,更加通用。
在 AI 生成代码过程中,不同 CSS 框架语法本身不会给 AI 带来太大负担,我们同样只需要通过对话,就能生成对应框架的代码。
比如我让 MarsCode 生成的 UnoCSS/Tailwind 代码,也能一键实现相同的样式效果。
高潮总结
CSS 框架或预处理器的本质是提供了某些比原生 CSS 高级的语法,方便我们在前端工程中实现可维护的样式架构,但它们最终还是要编译为原生 CSS。
一般而言,在不同的 CSS 框架中迁移,我们需要重新学习和手动重构,AI 编程助手可以辅助我们一键迁移。
在 VSCode 中,我们可以借助 MarsCode 插件,轻松地将原生 CSS 代码重构为不同 CSS 框架的代码,无需手动敲一行代码,这提高了我们的开发效率,但同时也要注意 AI 工具的局限性。
目前 AI 无法淘汰程序员,但 AI 会淘汰不懂 AI 的程序员。你可以注册和安装 VSCode 插件,在 VSCode 中提前尝试 AIGC 时代的低代码编程方式。
官方链接和二维码在这里分享给大家:http://www.marscode.cn/events/s/ik…
#豆包MarsCode 双节创意征文话题 #豆包 MarsCode 放码过来
来源:juejin.cn/post/7424016262094012443
纠结多年终于决定彻底放弃Tailwindcss
上图来源Fireship
团队代价=深入成本
Tailwindcss很容易火,因为从看到它的的第一眼就会觉得超级直观、超级简单、超级方便,所以无论是写文章 出视频 都很容易吸引一波流量。
但是真要用在企业级应用我们需要考虑全部的复杂性,你至少要吃透官方文档的大部分内容,最好是写几个稍微复杂点的Demo,光是吃透文档就需要至少10小时以上的成本你才能彻底在企业级应用all in tailwind,如果你的团队有10名前端同学,你们将会付出100个小时的代价,这些代价不光是个人的,更是企业的损失,而花了这100小时掌握之后能够靠那一点点便捷提速弥补损失吗?不能。或许100小时早就用以前的方式写完了全部样式。团队还会扩大,新招进来的同学还得培训一下。
范式强偏好
Tailwindcss是非常opinionated强偏好的,他会鼓励一种他们特定的范式和规则,这种规则不是通用的,是tw创新的。那scss less是不是强偏好呢?不是,因为你还是以标准的css范式书写,只是scss less给你提供了额外的语法和工具方法 你的范式没有改变。
tw强偏好的范式包括不限于:
- tailwindconfig 配置文件
- 默认主题、工具类
- 行内class书写规则
- IDE插件
强偏好本身没有对错之分,通常我们使用UI组件库就是强偏好的,但是对于样式的书写,这种强偏好会缺少一定的规范一致性,说白了就是潜规则太多了。
强IDE插件依赖
没有IDE的插件提示,tw基本不可用,因为你可用的类名强依赖与上面说的范式中的tailwindconfig配置文件。
但是这好像也没什么问题,装个插件也很轻松,事情没这么简单,你失去了静态类型检查 和可测试性。假设你写了个错误的类名shadows,tailwind插件可不会给你报红,而传统的css样式文件是会报红的。
既然静态阶段无法发现这个错误,那编译时能不能发现呢?也不能,tw会将主题中未定义的类名 当成你自己的类名,所以对tw来说不是错误。
单元测试?很遗憾,也不行,这个范式最大的好处也是最大的缺点,样式全部在类目中,你不可能去equal所有的类名 这样就没有用tw的意义了。
所以tw最方便的地方,也是最容易出错且难以被发现的地方。
完全错误的主题范式
官方文档提供了Dark Mode暗色主题切换的方式,但是如果现在客户提个需求,需要增加4套颜色主题 和亮暗色无关 就是额外的主题,你会发现tw根本没有考虑到这点(或者说很难实现,网上几乎没有解决方案,我有但我不说😝(下面补充解释了)
tw是通过类名中以dark:
前缀开头来表示暗色下的样式,默认不加就是亮色, 所以你根本无法增加这两种主题以外的更多主题,你只能在亮色暗色这两之间切换,这就是tw官方强偏好导致的弊端。
我们假设,即使tw实现了可以增加主题前缀比如 onedark:
monokai:
...,那么你需要在每一个元素类名上 书写所有这些前缀的样式
<div
className="
bg-blue-500 //亮色
dark:bg-blue-700 //暗色
onedark:bg-black-900 //onedark主题
monokai:bg-gold-600 //monokai主题
kanagawa:bg-green-200 //kanagawa主题
"
></div>
真的你会写疯掉,因为每增加一个主题,意味着你要在源码中所有元素身上加上新的主题和样式类名,想象一下如果有20个主题,你一个标签的类名可能就占了100行。
并且你无法动态增加主题,因为tw是编译时的,生产环境下,你无法实现让用户自己任意配置主题、持久、载入这样的功能。
总结
文章还会更新,想当什么补充什么。以上几个最大的痛点是导致我对这个库关注多年,尝试多次,却迟迟没有投入使用,最终决定放弃的原因。我相信肯定很多同学会有同感,也会有很多持反对意见,非常欢迎评论区讨论,如果真能解决这几个大痛点,我会毅然决然All in tw。
↓↓↓↓🆕 以下为更新内容 时间升序↓↓↓↓
2024-08-22 17:45:21
难以调试
实现复杂UI会让类名又臭又长,无法根据类名理解样式,影响对html结构的浏览
来对比看一下传统类名的可读性,这是MDN网站的类名,干净整洁,一眼就知道每一个标签块代表什么内容
类名即样式导致dev tool中无法通过style面板根据特定一类元素修改样式,因为你改的是工具类名 而不是一类元素的类名,例如修改.text-left {text-align: right} 会将所有元素的样式修改完全不符合预期
菜就多练?
好吧我猜到评论区会有此类不和谐的声音,怪我没有事先叠甲,但是文章的开始其实已经说的很清楚了,个人能力再强是没用的,开发从来不是一个人的事,在公司需要跟同事配合,在开源社区需要和世界各地的开源爱好者协作。
如果你是组长、技术经理、CTO甚至老板,你一定要站在团队的角度对新兴技术栈评估收益比,因为对于企业来说商业价值永远是第一位的,所以你不能只考虑自己的效率,还要考虑团队整体的效率和质量。
如果你是开源作者,你也要为贡献者的参与门槛考虑,如果你的技术栈不是主流 只是一小挫人会用 甚至难度极高,那么你很难收获世界各地的爱心,只能自己一个人默默发电。你甚至要考虑技术栈的可替换性,因为我们大部分的依赖库都是开源的,人家也是为爱发电,意味着人家很有可能哪天累了不再维护了,你要留足能够用其他库或框架平滑替换的可能,否则为了某个库的废弃你可能需要做大量的重构工作甚至Breaking Change破坏性升级,再甚至你也没办法坚持下去了,因为你花了大量的时间在填坑而不是专注于自己项目的开发。
复杂性守恒原理
泰斯勒定律 复杂性守恒,临界复杂性不能凭空消失 只能被转移。我经常用这个原理审视各种新兴技术栈的两面性,因为你们懂得-前端娱乐圈,经常会出现很多让你快乐的新东西,而往往容易忽视背后的代价。当我们收获巨大的简化后,一定要思考 曾经的复杂性被转移到哪里去了呢?如果你能搞清楚两面性,仔细评估后再做决定,会走的更顺。
就如上面所述,tw在简化的背后,牺牲了静态类型检查、单元测试、调试、运行时动态主题载入、文档强依赖、IDE插件强依赖、构建工具强依赖等等诸多缺点。
2024-08-23 17:53:51
关于tw错误主题范式的补充
掘友还是很多大佬的,评论区发表了很多关于解决主题受限于默认和dark这两种的局限,这里补充一下我自己的方式
tw配置文件 theme中不配置darkMode,将所有主题值绑定css变量如 colors: {primary: 'var(--primary)'}
,然后依然是动态控制css变量来切换主题。
坏处很多: classname不再允许使用dark变量;tw配置麻烦,变量套变量需要两边维护,css变量文件 和tw配置要保持同步;
我为什么前面不把解决办法说出来?因为不重要。
tw是范式强偏好的,首先理解什么是范式?我的理解是很有fan的方式就是范式,有fan的前提是流行、统一。
所以我们在使用强偏好的库时,一定要在范式之内,不要自己创新方式,否则你会脱离流行、统一、一致性,并且会因为库的迭代升级导致适配问题,并且会与其他人无法达成共识 你们之间的技术经验产生越来越大的偏差 为合作带来困扰。
如果我遵循tw范式不魔改,与别人协作只需要告诉别人“看tw文档就行了”;但如果我不遵循,魔改了主题范式,我需要格外提醒别人“注意! 我们的主题不是按tw用的 请不要写dark:xxx”
这还只是一例,如果项目中有10例你自己创新的范式别人就很难快速上手了
2024-08-24 00:36:52
与tw同样方便的CSS in JS用法
上图是react中使用styled-component(后面简称sc)结合style工具写的一段样式,它既能拥有sc组件即样式的好处 又能拥有类似vue中样式分离且scoped的方便。
还能倒过来,jsx在上面,style在下面
通用组件通常用sc组件即样式来定义
const Button = styled.button`...`
view组件(业务域)通常结合sc组件和style来灵活使用
...
return style`font-size: 12px;`(
<div>...</div>
)
我们还能将常用样式抽离出来,达到如同tw的方便程度
return style`
${[button.md, bg.200, text.color.primary]}
`(<Button>...</Button>)
如果样式很长,你可以抽离,也可以直接折叠,完全不需要像tw那样还需要vscode插件
篇幅关系,只是简单介绍,后续可能单独出个文章细讲css in js
2024-08-27 12:52:07
@apply解决类名过多的问题?
评论区出现多个建议用@apply
在css复用类名的样式来减少class中书写类名,因此觉得有必要单独拿出来讲一下给大家避坑。
结论是千万万万不要这么用!这就是文档没看完,上手就用犯的错误,10小时的学习成本你是逃不掉的,不然以后麻烦就会找上你。
在tw官方文档中明确强调不要以减少类名为目的使用@apply
,鼓励你就把所有类名写在class中。
上图来源 tailwindcss.com/docs/reusin…
除此之外,@apply语法其实是一个被废弃的css标准语法,曾经在chromium内核中被短暂实现过,后来废弃掉了,废弃的原因也是因为会破坏常规书写类名+样式的范式,会导致用户无节制的重用类名样式,最终无法溯源,修改困难。
来源:juejin.cn/post/7405449753741328393
前端大佬都在用的useFetcher究竟有多强?
useFetcher:让数据管理变得如此简单
大家好,今天我要和你们分享一个让我惊喜万分的小工具——useFetcher。说实话,第一次用它的时候,我感觉自己像是发现了新大陆!它彻底改变了我处理数据预加载和跨组件更新的方式。
alovajs简介
在介绍useFetcher之前,我们先来聊聊alovajs。它是一个革命性的新一代请求工具,可以大大简化我们的API集成流程。 与react-query和swrjs等hooks库不同,alovajs提供了针对各种请求场景的完整解决方案。
alovajs的强大之处在于:
- 它将API的集成从7个步骤降低为只需要1个步骤
- 提供了15+个针对特定场景的"请求策略"
- 不仅能在客户端使用,还提供了服务端的请求策略
如果你想深入了解alovajs,强烈推荐去官网 alova.js.org 看看。相信你会像我一样,被它的强大功能所吸引。
useFetcher的妙用
现在,让我们聚焦到今天的主角——useFetcher。这个小工具真的太棒了,它让我轻松实现了一些以前觉得很复杂的功能。
数据预加载
想象一下,你正在开发一个分页列表,希望在用户浏览当前页面时就预加载下一页的数据。useFetcher可以轻松实现这一点:
const { fetch } = useFetcher({ updateState: false });
const currentPage = ref(1);
const { data } = useWatcher(() => getTodoList(currentPage.value), [currentPage], {
immediate: true
}).onSuccess(() => {
fetch(getTodoList(currentPage.value + 1));
});
这段代码会在当前页加载成功后,自动预加载下一页的数据。是不是感觉很简单?我第一次实现这个功能时,都被自己的效率惊到了!
跨组件更新
另一个让我惊喜的功能是跨组件更新。假设你在一个组件中修改了todo数据,想要在另一个组件中更新列表。useFetcher配合method快照匹配器可以轻松实现:
const { fetch } = useFetcher();
const handleSubmit = () => {
// 提交数据...
const lastMethod = alovaInstance.snapshots.match({
name: 'todoList',
filter: (method, index, ary) => index === ary.length - 1
}, true);
if (lastMethod) {
await fetch(lastMethod);
}
};
这段代码会在提交数据后,自动找到最后一个名为'todoList'的method实例并重新获取数据,从而更新列表。这种优雅的数据管理方式,让我的代码结构变得更加清晰了。
总结
useFetcher真的改变了我对数据管理的看法。它不仅可以帮我们实现数据预加载,还能轻松处理跨组件更新的问题。使用它,我们可以写出更加高效、更加优雅的代码。
你们平时是怎么处理这些数据管理的问题的呢?有没有遇到过什么困难?我很好奇大家的经验和想法,欢迎在评论区分享。如果这篇文章对你有帮助,别忘了点个赞哦!让我们一起探讨,一起进步!
来源:juejin.cn/post/7425225508613816320
解析vue中nextTick
在 Vue.js 中,nextTick
是一个非常重要的函数,它用于延迟回调函数的执行,直到下次 DOM 更新循环之后执行。这是因为 Vue 的数据绑定系统需要确保在数据变化后 DOM 被更新之前不执行某些依赖于 DOM 状态的操作。
实现原理
Vue 的 nextTick
主要是为了处理异步更新队列的问题。当数据发生变化时,并不是立刻更新 DOM,而是将这个更新放入一个队列中,等到当前执行栈清空时(也就是当前同步任务执行完毕),Vue 会进行一次更新,将所有需要更新的内容一次性渲染到 DOM 中,这样做是为了减少 DOM 操作次数,提高性能。
在 Vue 2 中的实现
在 Vue 2 中,nextTick
的实现在 src/core/util/next-tick.js
文件里。它利用了 setTimeout
, setImmediate
(如果支持的话),以及 MessageChannel
这几种不同的异步方法来实现。如果浏览器环境支持 Promise
,那么它也会使用 Promise.then
来实现异步操作。
Javascript
深色版本
function nextTick (cb, ctx) {
var _resolve
callbacks.push(function () {
if (cb) {
try {
cb.call(ctx)
} catch (e) {
handleError(e, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
if (!pending) {
pending = true
if (typeof Promise !== 'undefined' && isNative(Promise)) {
// 使用 Promise 的话,创建一个 resolve 函数,并立即调用它。
var resolve = Promise.resolve
// 使用 then 回调来执行
resolve.call(Promise, function () { _resolve = $internal; })
} else {
// 如果没有 Promise 支持,就退而求其次使用 setImmediate 或 setTimeout
setTimeout(_flushCallbacks, 0)
}
}
}
这里的关键在于 Promise.resolve()
后面跟着的 .then()
会放到 JavaScript 引擎的微任务队列中,等待当前执行栈清空后执行。这样可以确保 nextTick
的回调是在所有同步任务完成后被执行。
在 Vue 3 中的实现
Vue 3 中使用了更现代的方法来实现 nextTick
,主要是基于 queueMicrotask
API。这个 API 可以让回调函数在当前任务完成后的下一个微任务时机执行。如果没有 queueMicrotask
支持,则退回到 Promise
的 .then()
方法。
Javascript
深色版本
export function nextTick(cb?: Function, delay = 0): PromiseLike<any> | void {
if (cb) {
// 如果提供了回调函数,则注册回调
registerFlushSyncCallback(() => {
queueMicrotask(() => {
// 使用 queueMicrotask 来确保回调是在微任务时机执行
// 这样可以保证在任何其他异步任务之前执行
callWithAsyncErrorHandling(cb, activeInstance, SchedulerGuards.NEXT_TICK_HANDLER);
});
});
} else {
// 如果没有提供回调,则返回一个 promise
return new Promise((res) => {
queueMicrotask(res);
});
}
}
总结来说,nextTick
的主要作用是确保某些操作在 DOM 更新之后进行,从而避免了由于数据变化但 DOM 尚未更新导致的问题。通过使用异步机制(如微任务或宏任务),nextTick
能够确保在适当的时机执行回调。
来源:juejin.cn/post/7426206782022074431
关于WebView的秒开实战
通过这篇文章你可以了解到:
- 性能优化的思维方式
- WebView进阶知识
写在前面
最近组里做新的Web容器的,一次承载多个H5页面,以实现左右切换,默认展示主会场页,并要达到提升打开率的目标。要达到这个目标,那势必要从加载优化入手,缩短页面的打开时间。优化的点包括但不限于,Activity初始化、ViewPager和Fragment的初始化、WebView的初始化等等。
上一片文章给大家分享了我在ViewPager上面做的优化,本篇文章再接着给大家分享下WebView秒开的尝试。
优化效果
我们以提升打开率为目标,口径是资源位点击到WebView的onPageFinished。
使用新的容器之后,打开率提升了大约10%-20%(65%—>85%),在低端机上的提升较为明显。为了让各位同学更加直观的感受到优化后的效果,这里用两张图简化的流程图来表示:
以上是我们的容器简略的加载过程需经过6个步骤,加载时长从Activity的onCreate开始计算到WebView的onPageFinished大约需要3000ms(低端机)。很显然,在如今这个快节奏的社会,用户是不会等待这么长时间的。为此我们对它进行了一场手术,把它整成了下边的样子:
???what's the ****?玩俄罗斯方块吗?
同学憋急,你现在只需要关注的是:它由6个冗长的步骤,变成了两个步骤(Na组件放在了WebView初始化完成后加载),大大缩减了我们首页的加载时间。关于你的疑问,我会在下边的章节解释。
过程分析
上一节我们讲到,一次完整的打开过程需要经过6个步骤,经过了我们大刀阔斧的改造后,只需要两个步骤。这节接着给大家剖析我们这么做的底层逻辑。
Native优化
资源预加载
WebView组件加载过程有三处网络耗时分别是主文档HTML的加载、JS/CSS的加载和内容数据的加载,串行的流程是效率及其低下的。那么我们是不是改成并行的?当然不能!
- 主文档HTML其实就是一个H5的框架,一个页面内所有的资源都是先通过主文档来触发加载,在主文档被加载之前我们是不能知道有哪些JS和CSS文件的。
- 内容数据(包括图片)是由我们的业务方决定的,涵盖了各个营销场景,不像新闻浏览类的页面有固定的排版样式。由于页面不统一,单独对它进行下载再注入的改造成本有点大。(后续的离线化方案可实现)
基于上述两点,可取的做法是:先把主文档数据预取后缓存,待WebView loadUrl之后,通过WebViewClient的监听去拦截主文档的请求
@Nullable
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
for (RequestInterceptor interceptor : Interceptors) {
//拦截请求,去缓存中取出主文档数据
WebResourceResponse response = interceptor.intercept(view, request);
if (response != null) {
return response;
}
}
return super.shouldInterceptRequest(view, request);
}
预加载时机
预加载放在点击资源位(资源位在首页区域)之前是最理想的,这也是我们的初步设想。但是涉及到首页模块的改造,需要对应组件方的配合支持,会导致开发周期的延长。所以我们决定在第一版以HOOK Instumentation的方式,在点击资源位之后,Activity的onCreate之前去开启子线程对主文档进行预加载。
同时跟首页组件方协调方案:在首页的T2阶段(不影响其它优先级更高的任务)对资源位进行预加载,点击后如果首页预加载成功则直接打开Activity,否则继续Instrumentation加载逻辑。
JS/CSS预加载
主文档加载完成了之后,可以对缓存的数据进行识别查找到需要加载的JS/CSS文件,紧接着开始进行JS/CSS的预加载。
下面时查找JS文件的伪代码:
private static final String JS_PATTERN = "<script\\s+[^>]*?src=[\"']([^\"']+)[\"'][^>]*>(?:<\\/script>)?";
/**
*@param htmlData 将主文档的二进制文件转换成的String类型
*@param JSPattern 用于从主文档内匹配JS文件的正则表达式
*/
private void recognitionJS(String htmlData, String JSPattern) {
try {
Pattern scriptPattern = Pattern.compile(JSPattern, Pattern.CASE_INSENSITIVE);
Matcher scriptMatcher = scriptPattern.matcher(htmlData);
while (scriptMatcher.find()) {
String link = scriptMatcher.group(1) + "";
if (TextUtils.isEmpty(link)) {
continue;
}
mResSet.add(link);
}
} catch (Exception e) {
}
}
这样一来我们的流程在第二版就变成了:
到这里我们在数据请求这一块所做的优化就结束了,那么我们的矛头接下来该指向哪里?
WebView预热
首次创建耗时较长
我从埋点的数据中发现,容器冷启打开的时间比热启要长的多,从Activity onCreate到WebView loadUrl之前的耗时比起热启大约慢了200多ms。这个过程中初始化的组件除了WebView还有有ViewPager和Fragment,通过再次细分阶段的埋点统计耗时发现,启动方式对这两者的初始化时间影响不大,WebView初始化时间自然就成了我们攻克的对象。
我们找来了其它几个机型重复上述的步骤,高端机上表现并不明显,但也存在差异(大约80ms)。进一步确定了是WebView自身的原因,可以得出结论:WebView第一次初始化的时间会比后续创建的时间长,具体差异取决于机型性能。
WebView Pool
利用前面得出的结论,可以在App启动时开始WebView的第一次初始化再销毁,以减少后续使用过程的创建时间。但还是避免不了往后创建带来的时间开销,这个时候池化技术就呼之欲出了。
我们可以将创建好的WebView放入容器中,可以一个也可以多个,取决于业务。由于创建WebView需要和Context绑定,而预创建WebView是无法提前获知所需要挂载的Activity的,为此我们找到了MutableContextWrappe。引用官方对它的介绍:
Special version of ContextWrapper that allows the base context to be modified after it is initially set. Change the base context for this ContextWrapper. All calls will then be delegated to the base context. Unlike ContextWrapper, the base context can be changed even after one is already set.
翻译成人话:它允许在运行时动态地更改 Context。这在某些特定场景下非常有用,例如,当您需要在不同的 Context 之间灵活切换或修改 Context 时。真是完美的解决了我们预创建绑定Context的问题!
//预创建WebView,存入缓存池
MutableContextWrapper contextWrapper = new MutableContextWrapper(getAppContext());
mWebViewPool.push(new WebView(contextWrapper));
//取出WebView,替换我们所需要的Context
WebView webView = mWebViewPool.pop();
MutableContextWrapper contextWrapper = (MutableContextWrapper) webView.getContext();
contextWrapper.setBaseContext(activityContext);
看到这里,如果你是不是以为WebView的池化就这样结束了?
那是不可能滴
那是不可能滴
那是不可能滴
子进程承接
众所周知,一个亿级DAU的商业化App是非常庞杂的。在App启动时,有许多的任务需要初始化,势必会带来很大的性能开销。如果在这个阶段进行WebView的创建和池化的操作。前者可能会引出ANR,后者则是会面临内存溢出的风险。一波刚平,一波又起!
怎么办?再开个线程?WebView不能在子线程初始化,即使可以也解决不了内存开销的问题。PASS!
线程不行,进程呢?Bingo!
我们可以在App启动时开启一个子进程,在子进程进行WebView的初始化和池化的任务。系统会为子进程重新开辟内存空间,同时在子进程创建WebView也不会阻塞主进程的主线程,顺带也可以提高我们主进程的稳定性,可谓是一举多得。整个加载流程也就变成了三个大步骤。
组件懒加载
上一篇文章里有讲到我们容器的页面结构,没看过的请点击这里。在开始WebView加载之前会经过ViewPager和Fragment的初始化,经过线下实验统计,省去这两玩意儿大约可以提升67%(口径:Activity onCreate到WebView loadUrl,也就是说ViewPager和Fragment占这个过程的67%)。这不,优化点又来了。
打开容器的第一阶段,只需加载一个页面。因此我们可以将WebView直接放在Activity上显示,无需ViewPager和Fragment的介入,等到首页加载完成后再初始化这两组件,并开始缓存其它页面。
到这里我们的加载流程就变成了开头的样子了:
不要抬杠:你开头画的也不是这个样子的啊?。
咱这不是为了更方便的理解,所以在开头小小的抽象了一下吗。手动狗头
其它优化
剩下还有一些前端的通用优化方式、网络通用优化方式在网上有同学总结的很清楚,在这里我就不一一列举。感兴趣的可以跳转对应文章进行查阅
来源:juejin.cn/post/7364283070869028899
如何开发一个chrome 扩展
前言
最近开发一个涉及到很多颜色转换的工作,每次都搜索打开一个新页面在线转换,十分麻烦,于是想着开发一个颜色转换的浏览器插件,每次点击即可使用。
查看Chrome插件开发的文档developer.chrome.com/docs/extens… ,从头开始开发一个插件还是比较麻烦且原始的。搜索网上资料,发现了2个工具
- CRXJS: github.com/crxjs/chrom…
- Plasmo: github.com/PlasmoHQ/pl…
- WXT: github.com/wxt-dev/wxt
最后选择了WXT,因为它用起来更方便,且支持多浏览器。
WXT是什么
WXT号称下一代浏览器扩展开发框架
,免费、开源、易用且支持多种浏览器。
这段文字是关于WXT框架的介绍,它是一个用于构建浏览器扩展的开源框架。下面是对文中提到的几个关键点的解释:
- WXT有自己一套约定的框架,为开发者提供了一套标准化的做法,有助于保持项目的一致性,使得新手能够更容易地理解和接手项目。
- 基于项目文件结构自动生成manifest。manifest是浏览器扩展的配置文件,定义了扩展的名称、版本、权限等信息。WXT框架能够根据项目结构自动创建这个文件,简化开发过程。
- 单文件配置Entrypoint,比如背景脚本或内容脚本,这样可以更直观地管理和维护代码。
- WXT提供了开箱即用的TypeScript支持,并且改进了浏览器API的类型定义。TypeScript是一种强类型语言,它在JavaScript的基础上增加了类型系统,有助于在开发过程中捕捉到潜在的错误。
- 输出文件的路径最小化,这意味着WXT在构建扩展时会优化文件路径,减少runtime的path长度,可以提高扩展的加载速度和性能。
WXT 安装&开发
我们直接脚手架开一个项目
pnpm dlx wxt@latest init wxt-demo
cd wxt-demo
pnpm install
套用官网的一个图
不过我选的react,生成的工作目录文件如下
调试运行pnpm dev
,WXT直接开了一个无头浏览器,可以实时看到效果
颜色转换开发
因为我的需求比较简单,实现各种颜色的转换,页面UI就直接使用antd,样式直接Inline。代码如下:
<>
<header style={pageLayoutStyle}>
<p style={{ fontSize: '1.5rem', textAlign: 'center', fontWeight: 'medium' }}>Color Converter</p>
<p>This tool helps you convert colors between different color formats.</p>
</header>
<main style={pageLayoutStyle}>
<div>
<p style={{ fontSize: '1.2rem', textAlign: 'left' }}>Enter a color:</p>
</div>
<Input
suffix={
<ColorPicker
defaultValue={defaultColor}
value={hex === '' ? defaultColor : hex}
styles={{ popupOverlayInner: { position: 'absolute', left: '50%', transform: 'translate(-100%, -50%)' } }}
onChangeComplete={(color) => {
const str = (color.toRgbString())
}} />
}
placeholder={defaultColor}
autoFocus={true}
onChange={(e) => {
const str = (e.target.value)
}} />
<div>
<p style={{ fontSize: '1.2rem', textAlign: 'left' }}>Results</p>
</div>
{contextHolder}
<Input addonBefore="RGB" value={rgb} suffix={<CopyOutlined onClick={() => { copyToClipboard(rgb) }} />} readOnly={true} defaultValue="" />
<Input addonBefore="HEX" value={hex} suffix={<CopyOutlined onClick={() => { copyToClipboard(hex) }} />} readOnly={true} defaultValue="" style={{ marginTop: '8px' }} />
<Input addonBefore="HSL" value={hsl} suffix={<CopyOutlined onClick={() => { copyToClipboard(hsl) }} />} readOnly={true} defaultValue="" style={{ marginTop: '8px' }} />
<Input addonBefore="HSV" value={hsv} suffix={<CopyOutlined onClick={() => { copyToClipboard(hsv) }} />} readOnly={true} defaultValue="" style={{ marginTop: '8px' }} />
<Input addonBefore="CMYK" value={cmyk} suffix={<CopyOutlined onClick={() => { copyToClipboard(cmyk) }} />} readOnly={true} defaultValue="" style={{ marginTop: '8px' }} />
</main>
</>
新建color.ts,定义几个变量名称和方法名称,一路按tab,AI自动补全了代码。代码太长就不贴出来了,主要是颜色的正则匹配和转换。同上面UI绑定后,最终实现效果如下:
在调试firefox时,遇到一个小坑:content.ts中需要至少有一个匹配matches,否则会直接退出提示插件invalid。
发布
WXT发布也比较简单,直接运行 pnpm zip
就会构建chrome的扩展压缩包,发布firefox只需要pnpm zip:firefox
。在ouput目录下就会生成对应产物。
不过记得在打包前修改wxt.config.ts,添加名称、版本、描述等。如:
export default defineConfig({
modules: ['@wxt-dev/module-react'],
manifest: {
version: '1.0.0',
name: 'color-converter',
description: 'A color converter tool',
}
});
最后完整代码见github: github.com/xckevin/col…
现在插件也已经上架了市场,欢迎下载:
- chrome: chromewebstore.google.com/detail/colo…
- firefox: addons.mozilla.org/en-US/firef…
来源:juejin.cn/post/7425803259443019815
课表拖拽(一)拖拽实现
最近接到一个任务,要求实现一个课表拖拽的功能,支持快速修改个人日程时间。项目采用taro框架。
基于性能的抉择
container采用grid布局,7colum+12row,共84个单元格
拖拽的方式有两种
盒子跟随手指,并实时显示松手后落入的位置,松手时寻找一个离手指最近的单元格放入
盒子实时在格子之内,根据手指位置实时计算填入的格子,将盒子放入
哪一种性能更高??????
显然第一种方案在第二种方案上多了盒子实时跟随手指这个额外操作,性能不占优势。
catch-move避免滑动穿透
因为课表支持左右滑动查看自己每一周的课程安排,采用了一个Swiper
包裹在container之外,在滑动时会带动Swiper的滑动,那该怎么办?????????
不妨请教学长,经过学长的指导,告诉了我一个api
不得不感慨阅读官方文档的重要性(老实了,以后必须多看官方文档)
如何根据手指的位置,计算所在单元格
const unitwith = 350 / 7;
const unitheight = 600 / 12;
先得到了每一个单元格的宽高
然后通过滑动的事件对象可以获取当前的(x,y),那么动态设置grid样式就可以实现
const getGridPositionByXY = (xp, yp) => {
return `gridColumn:${Math.floor(xp / unitwith)} ;gridRow:${Math.floor(
yp / unitheight
)}/${Math.floor(yp / unitheight) + 2}`;
};
handleTouchMove函数的实现
我们需要两个响应式的变量x,y
,通过在handleTouchMove
函数中修改x,y
来带动style的修改
const [x, setX] = useState(350 / 7);
const [y, setY] = useState(600 / 12);
const handleTouchMove =(e) => {
setX(e.changedTouches[0].clientX + 50);
setY(e.changedTouches[0].clientY + 50);
};
性能优化(节流)
节流:在一定时间内,无论函数被触发多少次,函数只会在固定的时间间隔内执行一次
为防止handleTouchMove
的触发频率太高,我们采用节流函数来让它在固定时间内只执行一次
function Throttle(fn, delay) {
let timer = null;
return function () {
if (timer) return;
timer = setTimeout(() => {
fn.apply(this, arguments);
timer = null;
}, delay);
};
}
const handleTouchMove = Throttle((e) => {
setX(e.changedTouches[0].clientX + 50);
setY(e.changedTouches[0].clientY + 50);
}, 10);
提升交互性
我们可以让用户长按激活,随后才能滑动,并且在激活的时候触发震动
直接贴完整代码在这里
import { View, Swiper, SwiperItem } from "@tarojs/components";
import { useState, useRef } from "react";
import Taro, { useLoad } from "@tarojs/taro";
import "./index.css";
function Throttle(fn, delay) {
let timer = null;
return function () {
if (timer) return;
timer = setTimeout(() => {
fn.apply(this, arguments);
timer = null;
}, delay);
};
}
export default function Index() {
const [isLongPress, setIsLongPress] = useState(false);
useLoad(() => {
console.log("Page loaded.");
});
const timer = useRef(null);
const [x, setX] = useState(350 / 7);
const [y, setY] = useState(600 / 12);
// const [StartPosition, setStartPosition] = useState({ x: 0, y: 0 });
const unitwith = 350 / 7;
const unitheight = 600 / 12;
const getGridPositionByXY = (xp, yp) => {
return `gridColumn:${Math.floor(xp / unitwith)} ;gridRow:${Math.floor(
yp / unitheight
)}/${Math.floor(yp / unitheight) + 2}`;
};
const handleTouchMove = Throttle((e) => {
if (!isLongPress) return;
setX(e.changedTouches[0].clientX + 50);
setY(e.changedTouches[0].clientY + 50);
}, 10);
return (
<View className='index'>
<Swiper circular style={{ width: "100vw", height: "100vh" }}>
<SwiperItem>
<view className='container'>
<view
style={getGridPositionByXY(x, y)}
className={`items-1 ${isLongPress ? "pressActive" : ""}`}
catch-move
onTouchStart={() => {
timer.current = setTimeout(() => {
setIsLongPress(true);
Taro.vibrateShort();
// console.log("长按");
}, 1000);
}}
onTouchMove={handleTouchMove}
onTouchEnd={() => {
clearTimeout(timer.current);
setIsLongPress(false);
}}
></view>
<view className="items-2">2</view>
</view>
</SwiperItem>
<SwiperItem>
<view className="container">
<view className="items-1">no</view>
</view>
</SwiperItem>
<SwiperItem>
<view className="container">
<view className="items-1" catch-move></view>
</view>
</SwiperItem>
<SwiperItem>
<view className="container">
<view className="items-1" catch-move></view>
</view>
</SwiperItem>
<SwiperItem>
<view className="container">
<view className="items-1" catch-move></view>
</view>
</SwiperItem>
</Swiper>
</View>
);
}
//index.css
.container {
width: 700px;
height: 1200px;
background-color: #ccc;
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: repeat(12, 1fr);
}
.griditems-1 {
border: 1px solid #fff;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background-color: cadetblue;
}
.griditems-2 {
border: 1px solid #fff;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background-color: aquamarine;
}
.griditems-3 {
border: 1px solid #fff;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background-color: burlywood;
}
.griditems-4 {
border: 1px solid #fff;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background-color: darkcyan;
}
.griditems-5 {
border: 1px solid #fff;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background-color: darkgoldenrod;
}
.items-1 {
grid-column: 1; /* 从第1列开始,到第2列结束 */
grid-row: 1 / 4;
border-radius: 10px;
border: 1px solid #fff;
background-color: burlywood;
}
.items-2 {
grid-column: 3;
grid-row: 1 / 4;
border-radius: 10px;
border: 1px solid #fff;
background-color: burlywood;
}
.pressActive {
border-radius: 10px;
border: 1px solid #fff;
background-color: #fff;
opacity: 0.5;
}
下一期将介绍如何控制方块不重合,以及在展开后方块的处理和对多方块的情况怎么单独管理每一个方块的情况
来源:juejin.cn/post/7425562027412815882
你是否遇到过断网检测的需求?
你也碰到断网检测的需求啦?
一般的断网检测需求,大部分都是在用户网络状态不好或者网络掉线时,我们给用户一个提示或者引导界面,防止用户不知道自己卡了在那里一直等待。
方案1,轮询请求
直接说最有效的方案,我们通过轮训请求来检测网络是否可用,比如加载图片或者访问接口等...
下面我以加载图片为例,搞个小demo:
- 首先尽可能的找一个小的图片,不要让图片的请求堵塞我们的其他功能使用。
推荐一个图片在线的压缩的网站: https://www.yalijuda.com/ ,然后把图片上到内部的服务器上
- 既然搞了,就搞个通用的,使用
tsup
我们搞个npm包,然后想一想我们要搞的功能,先把入口函数的出入参类型定了。我们既然要做轮训请求图片,我们首先需要一个图片地址,然后请求后的回调事件,甚至可能需要一些控制参数,那我们的入口代码也就有了。
const request = (imgUrl) => {
// do something
};
type CheckNetworkOptionsType = {
interval: number; // 循环时间 单位ms
};
type CheckNetworkType = (
imgUrl: string, // 图片url
callback: (isOnline: boolean) => void, // 回调,返回网络是否在线
options?: CheckNetworkOptionsType, // 配置项
) => void;
const checkNetwork: CheckNetworkType = (imgUrl, callback, options) => {
const { interval = 30_000 } = options || {};
const timer = setInterval(() => {
request(imgUrl)
}, interval);
return timer
};
export default checkNetwork
- 接下来我们要考虑一下如何进行请求,我们需要一个创一个
promise
和img标签
,resove
出去onload
和onerror
对应的在线和离线状态。
const request = (imgUrl) => {
return new Promise((resolve) => {
let imgRef = null;
let isRespond = false;
imgRef = document.createElement('img');
imgRef.onerror = () => {
isRespond = true;
resolve(false);
return '';
};
imgRef.onload = () => {
isRespond = true;
resolve(true);
return '';
};
imgRef.src = `${imgUrl}?time=${new Date().toLocaleString()}`;
});
};
type CheckNetworkOptionsType = {
interval: number; // 循环时间 单位ms
};
type CheckNetworkType = (
imgUrl: string, // 图片url
callback: (isOnline: boolean) => void, // 回调,返回网络是否在线
options?: CheckNetworkOptionsType, // 配置项
) => void;
const checkNetwork: CheckNetworkType = (imgUrl, callback, options) => {
const { interval = 30_000 } = options || {};
const timer = setInterval(async () => {
const status = (await request(imgUrl)) as boolean;
callback(status);
}, interval);
return timer
};
export default checkNetwork
- 这样基本的功能似乎就差不多了,但是感觉好像少点什么?比如服务器就是返回图片资源慢?那我们是不是可以加个超时时间?又或者是不是可以可以让用户手动取消循环?
const request = (imgUrl) => {
return new Promise((resolve) => {
let imgRef = null;
let isRespond = false;
imgRef = document.createElement('img');
imgRef.onerror = () => {
isRespond = true;
resolve(false);
return '';
};
imgRef.onload = () => {
isRespond = true;
resolve(true);
return '';
};
// 加个参数,防止浏览器缓存
imgRef.src = `${imgUrl}?time=${new Date().toLocaleString()}`;
});
};
type CheckNetworkOptionsType = {
interval: number; // 循环时间 单位ms
};
type CheckNetworkType = (
imgUrl: string, // 图片url
callback: (isOnline: boolean) => void, // 回调,返回网络是否在线
options?: CheckNetworkOptionsType, // 配置项
) => void;
const checkNetwork: CheckNetworkType = (imgUrl, callback, options) => {
const { interval = 30_000 } = options || {};
const timer = setInterval(async () => {
const status = (await request(imgUrl)) as boolean;
callback(status);
}, interval);
return timer
};
export default checkNetwork
- 完整的代码就完整了,具体用的时间还是建议大家关键模块来进行断网检测。不要一个后台配置表单都弄检测,这种完全可以在提交表单的时候接口响应进行处理,断网检测一般都是用在需要实时监控之类的。不多说了,我们来体验下:
6. 没问题,一切ok,发个包,就叫network-watcher
吧,欢迎大家star!
github.com/waltiu/netw…
方案2,直接调api
首先说下这种方案不推荐,其一浏览器兼容性有问题,其二只能检测到是否网络有连接,但是不能检测是否可用,这种实用性真的很差。
浏览器也提供了navigator.onLine
和 navigator.connection
可以直接查询网络状态,我们可以监听网络状态的变化。
window.addEventListener('online',function () {
alert("正常上网");
})
window.addEventListener('offline',function () {
alert('无网络');
})
这种实用性真的很差,用户网络连接但是没有网或者网很慢,实际上都会影响用户体验!!
用户的体验永远是NO.1
来源:juejin.cn/post/7299671709476700212
VirtualList虚拟列表
首先感谢
Vue3 封装不定高虚拟列表 hooks,复用性更好!这篇文章提供的一些思路,在此基础作者进一步对相关代码进行了一些性能上的优化(解决了通过鼠标操作滚动条时的卡顿)。因为项目没有用到ts,就先去掉了。
hooks
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
export default function useVirtualList(config) {
// 获取元素
let actualHeightContainerEl = null,
translateContainerEl = null,
scrollContainerEl = null;
// 数据源,便于后续直接访问
let dataSource = [];
onMounted(() => {
actualHeightContainerEl = document.querySelector(
config.actualHeightContainer
);
scrollContainerEl = document.querySelector(config.scrollContainer);
translateContainerEl = document.querySelector(config.translateContainer);
});
// 数据源发生变动
watch(
() => config.data.value,
(newValue) => {
// 更新数据源
dataSource = newValue;
// 计算需要渲染的数据
updateRenderData();
}
);
/*
更新相关逻辑
*/
// 更新实际高度
let flag = false;
const updateActualHeight = (oldValue, value) => {
let actualHeight = 0;
if (flag) {
// 修复偏差
actualHeight =
actualHeightContainerEl.offsetHeight -
(oldValue || config.itemHeight) +
value;
} else {
// 首次渲染
flag = true;
for (let i = 0; i < dataSource.length; i++) {
actualHeight += getItemHeightFromCache(i);
}
}
actualHeightContainerEl.style.height = `${actualHeight}px`;
};
// 缓存已渲染元素的高度
const RenderedItemsCache = {};
const RenderedItemsCacheProxy = new Proxy(RenderedItemsCache, {
get(target, key, receiver) {
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const oldValue = target[key];
const result = Reflect.set(target, key, value, receiver);
// 更新实际高度
updateActualHeight(oldValue, value);
return result;
},
});
// 更新已渲染列表项的缓存高度
const updateRenderedItemCache = (index) => {
// 当所有元素的实际高度更新完毕,就不需要重新计算高度
const shouldUpdate =
Reflect.ownKeys(RenderedItemsCacheProxy).length < dataSource.length;
if (!shouldUpdate) return;
nextTick(() => {
// 获取所有列表项元素(size条数)
const Items = Array.from(document.querySelectorAll(config.itemContainer));
// 进行缓存(通过下标作为key)
for (let i = 0; i < Items.length; i++) {
const el = Reflect.get(Items, i);
const itemIndex = index + i;
if (!Reflect.get(RenderedItemsCacheProxy, itemIndex)) {
Reflect.set(RenderedItemsCacheProxy, itemIndex, el.offsetHeight);
}
}
});
};
// 获取缓存高度,无缓存,取配置项的 itemHeight
const getItemHeightFromCache = (index) => {
const val = Reflect.get(RenderedItemsCacheProxy, index);
return val === void 0 ? config.itemHeight : val;
};
// 实际渲染的数据
const actualRenderData = ref([]);
// 更新实际渲染数据
const updateRenderData = (scrollTop = 0) => {
let startIndex = 0;
let offsetHeight = 0;
for (let i = 0; i < dataSource.length; i++) {
offsetHeight += getItemHeightFromCache(i);
// 第几个以上进行隐藏
if (offsetHeight >= scrollTop - (config.offset || 0)) {
startIndex = i;
break;
}
}
// 计算得出的渲染数据
actualRenderData.value = dataSource
.slice(startIndex, startIndex + config.size)
.map((data, idx) => {
return {
key: startIndex + idx + 1, // 为了在vue的for循环中绑定唯一key值
data,
};
});
// 缓存最新的列表项高度
updateRenderedItemCache(startIndex);
updateOffset(offsetHeight - getItemHeightFromCache(startIndex));
};
// 更新偏移值
const updateOffset = (offset) => {
translateContainerEl.style.transform = `translateY(${offset}px)`;
};
/*
注册事件、销毁事件
*/
// 滚动事件
const handleScroll = (e) =>
// 渲染正确的数据
updateRenderData(e.target.scrollTop);
// 注册滚动事件
onMounted(() => {
scrollContainerEl?.addEventListener("scroll", handleScroll);
});
// 移除滚动事件
onBeforeUnmount(() => {
scrollContainerEl?.removeEventListener("scroll", handleScroll);
});
return { actualRenderData };
}
vue
<script setup>
import { ref } from "vue";
import useVirtualList from "../utils/useVirtualList.js"; // 上面封装的hooks文件
import list from "../json/index.js"; // 造的数据模拟
const tableData = ref([]);
// 模拟异步请求
setTimeout(() => {
tableData.value = list;
}, 0);
const { actualRenderData } = useVirtualList({
data: tableData, // 列表项数据
scrollContainer: ".scroll-container", // 滚动容器
actualHeightContainer: ".actual-height-container", // 渲染实际高度的容器
translateContainer: ".translate-container", // 需要偏移的目标元素,
itemContainer: ".item", // 列表项
itemHeight: 400, // 列表项的大致高度
size: 10, // 单次渲染数量
offset: 200, // 偏移量
});
</script>
<template>
<div>
<h2>virtualList 不固定高度虚拟列表</h2>
<ul class="scroll-container">
<div class="actual-height-container">
<div class="translate-container">
<li
v-for="item in actualRenderData"
:key="item.key"
class="item"
:class="[{ 'is-odd': item.key % 2 }]"
>
<div class="item-title">第{{ item.key }}条:</div>
<div>{{ item.data }}</div>
</li>
</div>
</div>
</ul>
</div>
</template>
<style scoped>
* {
list-style: none;
padding: 0;
margin: 0;
}
.scroll-container {
border: 1px solid #000;
width: 1000px;
height: 200px;
overflow: auto;
}
.item {
border: 1px solid #ccc;
padding: 20px;
display: flex;
flex-wrap: wrap;
word-break: break-all;
}
.item.is-odd {
background-color: rgba(0, 0, 0, 0.1);
}
</style>
来源:juejin.cn/post/7425598941859102730
聊聊try...catch 与 then...catch
处理错误的两种方式:try...catch
与 then
、catch
在前端编程中,错误和异常处理是保证代码健壮性和用户体验的重要环节。JavaScript 提供了多种方式来处理错误,其中最常见的两种是 try...catch
和 Promise 的 then
、catch
,但是什么时候该用try...catch
,什么时候该用then
、catch
呢,下面将详细探讨这两种机制的区别及其适用场景。
一、try...catch
try...catch
是一种用于捕获和处理同步代码中异常的机制。其基本结构如下:
try {
// 可能会抛出异常的代码
} catch (error) {
// 处理异常
}
使用场景:
- 主要用于同步代码,尤其是在需要处理可能抛出的异常时。
- 适用于函数调用、操作对象、数组等传统代码中。
示例:
function divide(a, b) {
try {
if (b === 0) {
throw new Error("Cannot divide by zero");
}
return a / b;
} catch (error) {
console.error(error.message);
}
}
divide(4, 0); // 输出: Cannot divide by zero
在这个例子中,如果 b
为零,则会抛出一个错误,并被 catch
块捕获。
二、then
和 catch
在处理异步操作时,使用 Promise 的 then
和 catch
方法是更加常见的做法。其结构如下:
someAsyncFunction()
.then(result => {
// 处理成功的结果
})
.catch(error => {
// 处理错误
});
使用场景:
- 主要用于处理异步操作,例如网络请求、文件读取等。
- 可以串联多个 Promise 操作,清晰地处理成功和错误。
示例:
function fetchData() {
return new Promise((resolve, reject) => {
// 模拟异步操作
setTimeout(() => {
const success = Math.random() > 0.5; // 随机决定成功或失败
if (success) {
resolve("Data fetched successfully");
} else {
reject("Failed to fetch data");
}
}, 1000);
});
}
fetchData()
.then(result => {
console.log(result);
})
.catch(error => {
console.error(error);
});
在这个示例中,fetchData
函数模拟了一个异步操作,通过 Promise 来处理结果和错误。
三、async/await
与 try...catch
为了使异步代码更具可读性,JavaScript 引入了 async/await
语法。结合 try...catch
,可以让异步错误处理更加简洁:
async function fetchDataWithAwait() {
try {
const result = await fetchData();
console.log(result);
} catch (error) {
console.error(error);
}
}
fetchDataWithAwait();
总结
try...catch
:适合于同步代码,能够捕获代码块中抛出的异常。then
和catch
:用于处理 Promise 的结果和错误,适合异步操作。async/await
结合try...catch
:提供了清晰的异步错误处理方式,增强了代码的可读性。
在实际开发中,选择哪种方式取决于代码的性质(同步或异步)以及个人或团队的编码风格。
往期推荐
怎么进行跨组件通信,教你如何使用provide 和 inject🔥
来源:juejin.cn/post/7418133347543121939
用零宽字符来隐藏代码
什么是零宽度字符
一种不可打印的Unicode字符,在浏览器等环境不可见,但是真是存在,获取字符串长度时也会占位置,表示某一种控制功能的字符。
常见的零宽字符有:
空格符:格式为U+null00B,用于较长字符的换行分隔;
非断空格符:格式为U+FEFF,用于阻止特定位置的换行分隔;
连字符:格式为U+null00D,用于阿拉伯文与印度语系等文字中,使不会发生连字的字符间产生连字效果;
断字符:格式为U+200C,用于阿拉伯文、德文、印度语系等文字中,阻止会发生连字的字符间的连字效果;
左至右符:格式为U+200E,用于在混合文字方向的多种语言文本中,规定排版文字书写方向为左至右;
右至左符:格式为U+200F : 用于在混合文字方向的多种语言文本中,规定排版文字书写方向为右至左;
使用零宽字符给信息加密
(function(window) {
var rep = { // 替换用的数据,使用了4个零宽字符代理二进制
'00': '\u200b',
'0null': '\u200c',
'null0': '\u200d',
'nullnull': '\uFEFF'
};
function hide(str) {
str = str.replace(/[^\x00-\xff]/g, function(a) { // 转码 Latin-null 编码以外的字符。
return escape(a).replace('%', '\\');
});
str = str.replace(/[\s\S]/g, function(a) { // 处理二进制数据并且进行数据替换
a = a.charCodeAt().toString(2);
a = a.length < 8 ? Array(9 - a.length).join('0') + a : a;
return a.replace(/../g, function(a) {
return rep[a];
});
});
return str;
}
var tpl = '("@code".replace(/.{4}/g,function(a){var rep={"\u200b":"00","\u200c":"0null","\u200d":"null0","\uFEFF":"nullnull"};return String.fromCharCode(parseInt(a.replace(/./g, function(a) {return rep[a]}),2))}))';
window.hider = function(code, type) {
var str = hide(code); // 生成零宽字符串
str = tpl.replace('@code', str); // 生成模版
if (type === 'eval') {
str = 'eval' + str;
} else {
str = 'Function' + str + '()';
}
return str;
}
})(window);
var code = hider('测试一下');
console.log(code);
直接复制到项目中可以使用,我们现在来试试
var code = hider('测试一下');
console.log(code);
结果如下:
实际用法
功能用途
这个技术可以应用到很多领域,非常具有实用性。
比如:代码加密、数据加密、文字隐藏、内容保密、隐形水印,等等。
原理介绍
实现字符串隐形,技术原理是“零宽字符”。
在编程实现隐形字符功能时,先将字符串转为二进制,再将二进制中的1转换为\u200b;0转换为\u200c;空格转换为\u200d,最后使用\ufeff 零宽度非断空格符作分隔符。这几种unicode字符都是不可见的,因此最终转化完成并组合后,就会形成一个全不可见的“隐形”字符串。
功能源码
function text_2_binary(text){
return text.split('').map(function(char){ return char.charCodeAt(0).toString(2)}).join(' ');
}
function binary_2_hidden_text(binary){
return binary.split('').map(function (binary_num){
var num = parseInt(binary_num, 10);
if (num === 1) {
return '\u200b';
} else if(num===0) {
return '\u200c';
}
return '\u200d';
}).join('\ufeff')
}
var text = "jshaman是专业且强大的JS代码混淆加密工具";
var binary_text = text_2_binary(text);
var hidden_text = binary_2_hidden_text(binary_text);
console.log("原始字符串:",text);
console.log("二进制:",binary_text);
console.log("隐藏字符:",hidden_text,"隐藏字符长度:",hidden_text.length);
隐型还原
接下来介绍“隐形”后的内容如何还原。
在了解上文内容之后,知道了字符隐形的原理,再结合源代码可知:还原隐形内容,即进行逆操作:将隐形的unicode编码转化成二进制,再将二进制转成原本字符。
直接给出源码:
function hidden_text_2_binary(string){
return string.split('\ufeff').map(function(char){
if (char === '\u200b') {
return '1';
} else if(char === '\u200c') {
return '0';
}
return ' ';
}).join('')
}
function binary_2_Text(binaryStr){
var text = ""
binaryStr.split(' ').map(function(num){
text += String.fromCharCode(parseInt(num, 2));
}).join('');
return text.toString();
}
console.log("隐形字符转二进制:",hidden_text_2_binary(hidden_text));
console.log("二进制转原始字符:",binary_2_Text(hidden_text_2_binary(hidden_text)));
运行效果
如果在代码中直接提供“隐形”字符内容,比如ajax通信时,将“隐形”字符由后端传给前端,并用以上解密方法还原,那么这种方式传递的内容会是非常隐秘的。
但还是存在一个安全问题:他人查看JS源码,能看到解密函数,这可能引起加密方法泄露、被人推导出加密、解密方法。
前端的js想做到纯粹的加密目前是不可能的,因为 JavaScript 是一种在客户端执行的脚本语言,其代码需要在浏览器或其他 JavaScript 运行环境中解释和执行,由于需要将 JavaScript 代码发送到客户端,并且在客户端环境中执行,所以无法完全避免代码的逆向工程和破解。
来源:juejin.cn/post/7356208563101220915
前端如何生成临时链接?
前言
前端基于文件上传需要有生成临时可访问链接的能力,我们可以通过URL.createObjectURL
和FileReader.readAsDataUR
API来实现。
URL.createObjectURL()
URL.createObjectURL()
静态方法会创建一个 DOMString
,其中包含一个表示参数中给出的对象的URL。这个 URL 的生命周期和创建它的窗口中的 document
绑定。这个新的URL 对象表示指定的 File
对象或 Blob
对象。
1. 语法
let objectURL = URL.createObjectURL(object);
2. 参数
用于创建 URL 的 File
对象、Blob
对象或者 MediaSource
对象。
3. 返回值
一个DOMString
包含了一个对象URL,该URL可用于指定源 object的内容。
4. 示例
"file" id="file">
document.querySelector('#file').onchange = function (e) {
console.log(e.target.files[0])
console.log(URL.createObjectURL(e.target.files[0]))
}
将上方console控制台打印的blob文件资源地址粘贴到浏览器中
blob:http://localhost:8080/1ece2bb1-b426-4261-89e8-c3bec43a4020
URL.revokeObjectURL()
在每次调用 createObjectURL()
方法时,都会创建一个新的 URL 对象,即使你已经用相同的对象作为参数创建过。当不再需要这些 URL 对象时,每个对象必须通过调用 URL.revokeObjectURL()
方法来释放。
浏览器在 document 卸载的时候,会自动释放它们,但是为了获得最佳性能和内存使用状况,你应该在安全的时机主动释放掉它们。
1. 语法
window.URL.revokeObjectURL(objectURL);
2. 参数 objectURL
一个 DOMString
,表示通过调用 URL.createObjectURL()
方法返回的 URL 对象。
3. 返回值
undefined
4. 示例
"file" id="file">
<img id="img1" style="width: 200px;height: auto" />
<img id="img2" style="width: 200px;height: auto" />
document.querySelector('#file').onchange = function (e) {
const file = e.target.files[0]
const URL1 = URL.createObjectURL(file)
console.log(URL1)
document.querySelector('#img1').src = URL1
URL.revokeObjectURL(URL1)
const URL2 = URL.createObjectURL(file)
console.log(URL2)
document.querySelector('#img2').src = URL2
}
与FileReader.readAsDataURL(file)区别
1. 主要区别
- 通过
FileReader.readAsDataURL(file)
可以获取一段data:base64
的字符串 - 通过
URL.createObjectURL(blob)
可以获取当前文件的一个内存URL
2. 执行时机
createObjectURL
是同步执行(立即的)FileReader.readAsDataURL
是异步执行(过一段时间)
3. 内存使用
createObjectURL
返回一段带hash
的url
,并且一直存储在内存中,直到document
触发了unload
事件(例如:document close
)或者执行revokeObjectURL
来释放。FileReader.readAsDataURL
则返回包含很多字符的base64
,并会比blob url
消耗更多内存,但是在不用的时候会自动从内存中清除(通过垃圾回收机制)
4. 优劣对比
- 使用
createObjectURL
可以节省性能并更快速,只不过需要在不使用的情况下手动释放内存 - 如果不在意设备性能问题,并想获取图片的
base64
,则推荐使用FileReader.readAsDataURL
来源:juejin.cn/post/7333236033038778409
小程序海报绘制方案(原生,Uniapp,Taro)
背景
- 小程序海报绘制方案有很多,但是大多数都是基于canvas的,而且都是自己封装的,不够通用,不够灵活,不够简单,不够好用。
- 本方使用一个开源的小程序海报绘制,非常灵活,扩展性非常好,仅布局就能得到一张海报。
准备工作
安装依赖,也可以把源码下载到本地,源码地址。
npm install wxml2canvas
布局
无论哪种方案,布局都是一致的,需要注意一些暂未支持的属性:
- 变形:transform,但是节点元素使能读取此属性,但是库不支持,所以不要使用
- 圆角,border-radius,同上,不要使用,圆形图片有特定的属性去实现,除此之外无法实现其他类型的圆角
布局示例:
注意,除了uniapp,原生和Taro要使用原生组件的方式绘制canvas,因为Taro不支持data-xx的属性绑定方式,这一点很糟糕
<!-- 外层wrap用于fixed定位,使得整个布局离屏,离屏canvas暂未支持 -->
<view class='wrap'>
<!-- canvas id,一会 new 的时候需要 -->
<canvas canvas-id="poster-canvas"></canvas>
<view class="container">
<view data-type="text" data-text="测试文字绘制" class='text'>测试文字绘制</view>
<image data-type="image" data-src="https://img.yzcdn.cn/vant/cat.jpeg" class='image'></image>
<image data-type="radius-image" data-src="https://img.yzcdn.cn/vant/cat.jpeg" class='radius-image'></image>
</view>
</view>
原生小程序
import Wxml2Canvas from 'wxml2canvas'
Component({
methods: {
paint() {
wx.showLoading({ title: '生成海报' });
// 创建绘制实例
const drawInstance = new Wxml2canvas({
// 组件的this指向,组件内使用必传
obj: this,
// 画布宽高
width: 275,
height: 441,
// canvas-id
element: 'poster-canvas',
// 画布背景色
background: '#f0f0f0',
// 成功回调
finish: (url) => {
console.log('生成的海报url,开发者工具点击可预览', url);
wx.hideLoading();
},
// 失败回调
error: (err) => {
console.error(err);
wx.hideLoading();
},
});
// 节点数据
const data = {
list: [
{
// 此方式固定 wxml
type: 'wxml',
class: '.text', // draw_canvas指定待绘制的元素
limit: '.container', // 限定绘制元素的范围,取指定元素与它的相对位置计算
}
{
// 此方式固定 wxml
type: 'wxml',
class: '.image', // draw_canvas指定待绘制的元素
limit: '.container', // 限定绘制元素的范围,取指定元素与它的相对位置计算
}
{
// 此方式固定 wxml
type: 'wxml',
class: '.radius-image', // draw_canvas指定待绘制的元素
limit: '.container', // 限定绘制元素的范围,取指定元素与它的相对位置计算
}
]
}
// 调用绘制方法
drawInstance.draw(data);
}
}
})
Uniapp
uniapp 主要讲Vue3的版本,因为涉及 this,需要获取 this 以及时机
import { getCurrentInstance} from 'vue';
// 调用时机 setup内,不能在其他时机
// @see https://github.com/dcloudio/uni-app/issues/3174
const instance = getCurrentInstance();
function paint() {
uni.showLoading({ title: '生成海报' });
const drawInstance = new Wxml2Canvas({
width: 290, // 宽, 以iphone6为基准,传具体数值,其他机型自动适配
height: 430, // 高
element: 'poster-canvas', // canvas-id
background: '#f0f0f0',
obj: instance,
finish(url: string) {
console.log('生成的海报url,开发者工具点击可预览', url);
uni.hideLoading();
},
error(err: Error) {
console.error(err);
uni.hideLoading();
},
});
// 节点数据
const data = {
list: [
{
// 此方式固定 wxml
type: 'wxml',
class: '.text', // draw_canvas指定待绘制的元素
}
{
// 此方式固定 wxml
type: 'wxml',
class: '.image', // draw_canvas指定待绘制的元素
}
{
// 此方式固定 wxml
type: 'wxml',
class: '.radius-image', // draw_canvas指定待绘制的元素
}
]
}
// 调用绘制方法
drawInstance.draw(data);
}
Taro
Taro 比较特殊,框架层面的设计缺陷导致了 Taro 组件增加的 data-xx
属性在编译的时候是会清除的,因此Taro要使用这个库要用原生小程序的方式编写组件。
代码和原生的一样,只是要用原生的方式编写组件,然后在 Taro 中使用。
参考原生的代码,原生小程序js参考这
假设原生组件名为 draw-poster
,那么首先需要再Taro的页面中引入这个组件,然后在页面中使用这个组件,然后在组件中使用这个库。
export default {
navigationBarTitleText: '',
usingComponents: {
'draw-poster': '../../components/draw-poster/index',
},
};
const draw = useCallback(() => {
const { page } = Taro.getCurrentInstance();
// 拿到目标组件实例调用里面的方法
const instance = page!.selectComponent('#draw_poster');
// 调用原生组件绘制方法
instance.paint();
}, []);
return <draw-poster id="draw_poster"/>
总结
对比原生的canvas绘制方案,布局的方式获取节点的方式都是一样的,只是绘制的时候不一样,原生的是直接绘制到canvas上,而这个库是先把布局转换成canvas,然后再绘制到canvas上,所以这个库的性能会比原生的差一些,但是这个库的优势在于布局的方式,不需要自己去计算位置,只需要布局,然后调用绘制方法就可以了,非常方便,而且扩展性非常好,可以自己扩展一些布局方式,比如说flex布局,grid布局等等,这些都是可以的,只需要在布局的时候把布局转换成canvas的布局就可以了,这个库的布局方式是参考的微信小程序的布局方式,所以布局的时候可以参考微信小程序的布局方式,这样就可以很方便的布局了。
来源:juejin.cn/post/7300460850010521654
还在用轮询、websocket查询大屏数据?sse用起来
常见的大屏数据请求方式
1、http请求轮询:使用定时器每隔多少时间去请求一次数据。优点:简单,传参方便。缺点:数据更新不实时,浪费服务器资源(一直请求,但是数据并不更新)
2、websocket:使用websocket实现和服务器长连接,服务器向客户端推送大屏数据。优点:长连接,客户端不用主动去请求数据,节约服务器资源(不会一直去请求数据,也不会一直去查数据库),数据更新及时,浏览器兼容较好(web、h5、小程序一般都支持)。缺点:有点大材小用,一般大屏数据只需要查询数据不需要向服务端发送消息,还要处理心跳、重连等问题。
3、sse:基于http协议,将一次性返回数据包改为流式返回数据。优点:sse使用http协议,兼容较好、sse轻量,使用简单、sse默认支持断线重连、支持自定义响应事件。缺点:浏览器原生的EventSource不支持设置请求头,需要使用第三方包去实现(event-source-polyfill)、需要后端设置接口的响应头Content-Type: text/event-stream
sse和websocket的区别
- websocket支持双向通信,服务端和客户端可以相互通信。sse只支持服务端向客户端发送数据。
- websocket是一种新的协议。sse则是基于http协议的。
- sse默认支持断线重连机制。websocket需要自己实现断线重连。
- websocket整体较重,较为复杂。sse较轻,简单易用。
Websocket和SSE分别适用于什么业务场景?
根据sse的特点(轻量、简单、单向通信)更适用于大屏的数据查询,业务应用上查询全局的一些数据,比如消息通知、未读消息等。
根据websocket的特点(双向通信)更适用于聊天功能的开发
前端代码实现
sse的前端的代码非常简单
const initSse = () => {
const source = new EventSource(`/api/wisdom/terminal/stats/change/notify/test`);
// 这里的stats_change要和后端返回的数据结构里的event要一致
source.addEventListener('stats_change', function (event: any) {
const types = JSON.parse(event.data).types;
});
// 如果event返回的是message 数据监听也可以这样监听
// source.onmessage =function (event) {
// var data = event.data;
// };
// 下面这两个监听也可以写成addEventListener的形式
source.onopen = function () {
console.log('SSE 连接已打开');
};
// 处理连接错误
source.onerror = function (error: any) {
console.error('SSE 连接错误:', error);
};
setSseSource(source);
};
// 关闭连接
sseSource.close();
这种原生的sse连接是不能设置请求头的,但是在业务上接口肯定是要鉴权需要传递token的,那么怎么办呢? 我们可以使用event-source-polyfill这个库
const source = new EventSourcePolyfill(`/api/wisdom/terminal/stats/change/notify/${companyId}`, {
headers: {
Authorization: sessionStorage.get(StorageKey.TOKEN) || storage.get(StorageKey.TOKEN),
COMPANYID: storage.get(StorageKey.COMPANYID),
COMPANYTYPE: 1,
CT: 13
}
});
//其它的事件监听和原生的是一样
后端代码实现
后端最关键的是设置将响应头的Content-Type设置为text/event-stream、Cache-Control设置为no-cache、Connection设置为keep-alive。每次发消息需要在消息体结尾用"/n/n"进行分割,一个消息体有多个字段每个字段的结尾用"/n"分割。
var http = require("http");
http.createServer(function (req, res) {
var fileName = "." + req.url;
if (fileName === "./stream") {
res.writeHead(200, {
"Content-Type":"text/event-stream",
"Cache-Control":"no-cache",
"Connection":"keep-alive",
"Access-Control-Allow-Origin": '*',
});
res.write("retry: 10000\n");
res.write("event: connecttime\n");
res.write("data: " + (new Date()) + "\n\n");
res.write("data: " + (new Date()) + "\n\n");
interval = setInterval(function () {
res.write("data: " + (new Date()) + "\n\n");
}, 1000);
req.connection.addListener("close", function () {
clearInterval(interval);
}, false);
}
}).listen(8844, "127.0.0.1");
其它开发中遇到的问题
我在开发调试中用的是umi,期间遇到个问题就是sse连接上了但是在控制台一直没有返回消息,后端那边又是正常发出了的,灵异的是在后端把服务干掉的一瞬间可以看到控制台一下接到好多消息。我便怀疑是umi的代理有问题,然后我就去翻umi的文档,看到了下面的东西:
一顿操作之后正常
来源:juejin.cn/post/7424908830902042658
啊?两个vite项目怎么共用一个端口号啊
问题:
最近在业务开发中遇到一个问题,问题是这样的,当前有一个主项目和一个子项目,主项目通过微前端wujie来嵌套这个子项目,其中呢为了方便项目之间进行通信,所以规定该子项目的端口号必须为5173,否则通信失败,但是这时候发现一个问题,当我启动了该子项目后:
该项目的端口号为5173,但是此时我再次通过vite的官方搭建一个react+ts+vite项目:npm create vite@latest react_demos -- --template react-ts
,之后通过npm run dev
启动项目,发现端口号并没有更新
:
这是什么原因呢?
寻因:
查阅官方文档,我发现:
那么我主动在vite.config.ts中添加这个配置:
正常来说,会出现这个报错:
但是此时结果依然为:
我百思不得不得其解,于是再次查阅官方文档:
我寻思这也与文档描述不一致啊,于是我再次尝试,思考是不是vite版本号的问题
,两个项目的版本号分别为:
我决定创建一个4版本的项目npm create vite@^4.1.4 react_demos3 -- --template react-ts
结果发现,还是有这个问题,跟版本号没有关系
,于是我又耐心继续看官方文档,看到了这个配置:
我抱着试试的态度,在其中一个vite项目中添加这个配置:
发现,果然是这个配置的锅,当其中一个项目host配置为0.0.0.0时,vite不会自动尝试更新端口号
难道vite的端口监测机制与host也有关?
结果:
不甘心的我再次进行尝试,将两个项目的host都设置成:
vite会自动尝试更新端口号
原来如此,vite的端口号检测机制在对比端口号之前,会先对比host,由于我的微前端项目中设置了host,而新建的项目中没有设置host,新建的项目host默认值为localhost对比不成功,vite不会自动尝试下一个可用端口,而是共用一个端口
总结:
在遇到问题时,要多多去猜,去想各种可能,并且最重要的是去尝试各种可能,还要加上积极去翻阅官方文档,问题一定会得到解决的;哪怕不能解决,那也会在尝试中,学到很多东西
来源:juejin.cn/post/7319699173740363802
还搞不明白浏览器缓存?
一:前言
浏览器缓存与浏览器储存是不一样的,友友们不要混淆,关于浏览器储存,具体可以看这篇文章 : 一篇打通浏览器储存
这里大概介绍一下:
cookies | localStorage | sessionStorage | IndexedDB |
---|---|---|---|
服务端设置 | 一直存在 | 页面关闭就消失 | 一直存在 |
4K | 5M | 5M | 无限大 |
自动携带在http请求头中 | 不参与后端 | 不参与后端 | 不参与后端 |
默认不允许跨域,但可以设置 | 可跨域 | 可跨域 | 可跨域 |
二:强缓存
强缓存是指浏览器在请求资源时,如果本地有符合条件的缓存,那么浏览器会直接使用缓存而不会向服务器发送新的请求。这可以通过设置 Cache-Control
或 Expires
响应头来实现。
2.1:Cache-Control 头详解
Cache-Control
是一个非常强大的HTTP头部字段,它包含多个指令,用以控制缓存的行为:
- max-age:指定从响应生成时间开始,该资源可被缓存的最大时间(秒数)。
- s-maxage:类似于
max-age
,但仅对共享缓存(如代理服务器)有效。 - public:表明响应可以被任何缓存存储,即使响应通常是私有的。
- private:表明响应只能被单个用户缓存,不能被共享缓存存储。
- no-cache:强制缓存在使用前必须先验证资源是否仍然新鲜。
- no-store:禁止缓存该响应,每次请求都必须获取最新数据。
- must-revalidate:一旦资源过期,必须重新验证其有效性。
例如,通过设置 Cache-Control: max-age=86400
,可以告诉浏览器这个资源可以在本地缓存24小时。在这段时间内,如果再次访问相同URL,浏览器将直接使用缓存中的副本,而不与服务器通信。
2.2:Expires 头
Expires
是一个较旧的头部字段,用于设定资源过期的具体日期和时间。尽管现在推荐使用 Cache-Control
,但在某些情况下,Expires
仍然是有效的。Expires
的值是一个绝对的时间点,而不是相对时间。例如:
Expires: Wed, 09 Oct 2024 18:29:00 GMT
2.3:浏览器默认行为
当用户通过地址栏直接请求资源时,浏览器通常会自动添加 Cache-Control: no-cache
到请求头中。这意味着即使资源已经存在于缓存中,浏览器也会尝试重新验证资源新鲜度,以确保用户看到的是最新的内容。
三:协商缓存
协商缓存发生在资源的缓存条目已过期或设置了 no-cache
指令的情况下。这时,浏览器会向服务器发送请求,并携带上次请求时收到的一些信息,以便服务器决定是否返回完整响应或只是确认没有更新。
3.1:Last-Modified/If-Modified-Since
后端服务器可以为每个资源设置 Last-Modified
头部,表示资源最后修改的时间。当下一次请求同一资源时,浏览器会在请求头中加入 If-Modified-Since
字段,其值为上次接收到的 Last-Modified
值。服务器检查这个时间戳,如果资源自那以后没有改变,则返回304 Not Modified状态码,指示浏览器使用缓存中的版本。
3.2:ETag/If--Match
ETag 提供了一种更精确的方法来检测资源是否发生变化。它是基于文件内容计算出的一个唯一标识符。当客户端请求资源时,服务器会在响应头中提供一个 ETag
值。下次请求时,浏览器会发送 If--Match
头部,包含之前接收到的 ETag
。如果资源未改变,服务器同样返回304状态码;如果有变化,则返回完整的资源及新的 ETag
值。
3.3:比较 Last-Modified 和 ETag
虽然 Last-Modified
简单易用,但它基于时间戳,可能会受到时钟同步问题的影响。相比之下,ETag
更加准确,因为它依赖于资源的实际内容。然而,ETag
计算可能需要更多的服务器处理能力。
四:缓存选择
合理的缓存策略能够显著提升网站性能和用户体验。例如,静态资源(如图片、CSS、JavaScript文件)适合设置较长的缓存时间,而动态内容则需谨慎对待,避免缓存不适当的信息。
- 使用工具如 Chrome DevTools 来分析页面加载时间和缓存效果。
- 对不同类型的资源设置合适的
Cache-Control
参数。 - 注意安全性和隐私保护,确保敏感数据不会被错误地缓存。
五:使用示例
- 引入必要的模块:导入
http
,path
,fs
和mime
模块。 - 创建HTTP服务器:使用
http.createServer
创建一个HTTP服务器。 - 处理请求:
- 根据请求的URL生成文件路径。
- 检查文件是否存在。
- 如果是目录,指向该目录下的
index.html
文件。
- 处理协商缓存:
- 获取请求头中的
If-Modified-Since
字段。 - 比较
If-Modified-Since
与文件的最后修改时间。
- 获取请求头中的
- 读取文件并发送响应:
- 读取文件内容。
- 设置响应头(包括
Content-Type
,Cache-Control
,Last-Modified
,ETag
)。 - 发送响应体。
- 启动服务器:监听3000端口并启动服务器。
server.js:
const http = require('http'); // 引入HTTP模块
const path = require('path'); // 引入路径处理模块
const fs = require('fs'); // 引入文件系统模块
const mime = require('mime'); // 引入MIME类型模块
// 创建一个HTTP服务器
const server = http.createServer((req, res) => {
// console.log(req.url); // /index.html // /assets/image/logo.png
// 根据请求的URL生成文件路径
let filePath = path.resolve(__dirname, path.join('www', req.url));
// 检查文件或目录是否存在
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath); // 获取该路径对应的资源状态信息
// console.log(stats);
const isDir = stats.isDirectory(); // 判断是否是文件夹
const { ext } = path.parse(filePath); // 获取文件扩展名
if (isDir) {
// 如果是目录,则指向该目录下的 index.html 文件
filePath = path.join(filePath, 'index.html');
}
// +++++ 获取前端请求头中的if-modified-since
const timeStamp = req.headers['if-modified-since']; // 获取请求头中的 If-Modified-Since 字段
let status = 200; // 默认响应状态码为200
if (timeStamp && Number(timeStamp) === stats.mtimeMs) { // 如果 If-Modified-Since 存在且与文件最后修改时间相同
status = 304; // 设置响应状态码为304,表示资源未变更
}
// 如果不是目录且文件存在
if (!isDir && fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath); // 读取文件内容
res.writeHead(status, {
'Content-type': mime.getType(ext), // 设置 Content-Type 头
'cache-control': 'max-age=86400', // 设置缓存控制为一天
// 'last-modified': stats.mtimeMs, // 资源最新修改时间(可选)
// 'etag': '由文件内容生成的hash' // 文件指纹(可选)
});
res.end(content); // 发送文件内容作为响应体
}
}
});
// 启动服务器,监听3000端口
server.listen(3000, () => {
console.log('listening on port 3000');
});r.listen(3000, () => {
console.log('listening on port 3000');
});
index.html:
<body>
<h1>midsummer</h1>
<img src="assets/image/1.png" alt="">
</body>
项目结构如下图,友友们自行准备一张图片,将项目npm init -y
初始化为后端项目,之后下载mime@3包,在终端输入npx nodemon server.js
运行起来,在浏览器中查看http://localhost:3000/index.html ,观察效果。在检查中的网络里看缓存效果,同时友友们可以更改图片或者缓存方式,体验下不同的浏览器缓存方式
来源:juejin.cn/post/7423298788873142326
告别axios,这个库让你爱上前端分页!
嗨,我们又见面了!
今天咱们聊聊前端分页加载那些事儿。你有没有遇到过这样的烦恼:在做分页的时候,要手动维护各种状态,比如页码、每页显示数量、总数据量等等,还要处理各种边界情况,哎呀妈呀,真是太麻烦了!
那么,有没有什么好办法能让我们从这些繁琐的工作中解脱出来呢?这时候,alovajs就派上用场了!
alovajs:轻量级请求策略库
alovajs是一个轻量级的请求策略库,它可以帮助我们轻松处理分页请求。它支持开发者使用声明式实现各种复杂的请求,比如请求共享、分页请求、表单提交、断点续传等等。使用alovajs,我们可以用很少的代码就实现高效、流畅的请求功能。比如,在Vue中,你可以这样使用alovajs进行分页请求:
const alovaInstance = createAlova({
// VueHook用于创建ref状态,包括请求状态loading、响应数据data、请求错误对象error等
statesHook: VueHook,
requestAdapter: GlobalFetch(),
responded: response => response.json()
});
const { loading, data, error } = useRequest(
alovaInstance.Get('https://api.alovajs.org/profile', {
params: {
id: 1
}
})
);
看到了吗?只需要几行代码,alovajs就帮我们处理了分页请求的各种细节,我们再也不用手动维护那些繁琐的状态了!
对比axios,alovajs的优势
和axios相比,alovajs有哪些优势呢?首先,alovajs与React、Vue等现代前端框架深度融合,可以自动管理请求相关数据,大大提高了开发效率。其次,alovajs在性能方面做了很多优化,比如默认开启了内存缓存和请求共享,这些都能显著提高请求性能,提升用户体验的同时还能降低服务端的压力。最后,alovajs的体积更小,压缩后只有4kb+,相比之下,axios则有11+kb。
总之,如果你想在分页加载方面做得更轻松、更高效,alovajs绝对值得一试!
来源:juejin.cn/post/7331924057925533746
我为什么要搓一个useRequest
背景
- 在日常开发网络请求过程中,为了维护loading和error状态开发大量重复代码
- 对于竞态问题,要么不处理,要么每个需要请求的地方都要写重复逻辑
- 图表接口数据量大,甚至单接口响应就足以达到数十兆字节,而一个页面有数十个这样的请求,响应时间长,需要能够取消网络请求
以上逻辑,每个人的解法各不相同。为了解决上述问题,统一处理逻辑,需要一个能够统一管理网络请求状态的工具。
调研
首先想到的当然不是自己搓轮子,而是在社区上寻找是否已有解决方案。果不其然,找到了一些方案。
对于React,有像react-query这样的老前辈,功能全面,大而重;有像SWR这样的中流砥柱,受到社区广泛追捧;有像ahooks的useRequest这样的小清新,功能够用,小而美。
而对于Vue,一开始还真没让我找到类似的解决方案,后续进一步查找,发现有一个外国哥们仿造react-qeury仿写了一个vue-query,同时了解到雷达团队正是用的这一套解决方案,便又更深入了解了一下,发现这个库已经不维护了......进而了解到@tanstack/query,好家伙,这玩意胃口大得把react-query和vue-query都吃进去了,甚至svelte也不放过。继续找,发现有个哥们写了一个vue-request库,差不多类似于ahooks的useRequest,不错。然后经典的vue-use库也看了下,有一个useFetch方法,比较鸡肋,只适用于Fetch请求。
上述的社区库都相当不错,但对于我来说都太重了,功能繁多,而且在使用上,几个query都需要花费大量心智在缓存key上,太难用了。而ahooks和vue-request提供的useRequest的高阶函数,是比较符合我的胃口的,但是我还是嫌他们功能太多了。最关键的是,上述所有方案都没有达到我最主要的目的,能够真正取消网络请求。
因此,自己动手,丰衣足食。
动手
说干就干,搓一个咱自己的useRequest。
首先,定义useRequest的接口:
export declare const useRequest: <P extends unknown[], R>(request: (signal: AbortSignal, ...args: P) => Promise<R>, options?: IUseRequestOptions<R> | undefined) => {
result: ShallowRef<R | null>;
loading: ShallowRef<boolean>;
error: ShallowRef<Error | null>;
run: (...args: P) => Promise<Error | R>;
forceRun: (...args: P) => Promise<Error | R>;
cancel: () => boolean;
};
然后定义三个响应式状态,这里之所以用shallowRef,是考虑到部分请求结果可能很深,如果用ref会导致性能很差。
const result = shallowRef<IResult | null>(null);
const loading = shallowRef(false);
const error = shallowRef<Error | null>(null);
定义普通变量,在useRequest内部使用,不要在内部实现读取响应式变量(PS:踩过坑了,有个页面用watchEffect,loading状态一变就发请求,导致无线循环):
let abortController = new AbortController();
let isFetching = false;
然后定义run函数,如果有进行中的请求就取消掉:
const run = async (...args: IParams) => {
if (mergedOptions.cancelLastRequest && isFetching) {
cancel();
}
abortController = new AbortController();
setLoadingState(true);
const res = await runRequest(...args);
return res;
};
const runRequest = async (...args: IParams) => {
const currentAbortController = abortController;
try {
const res = await request(currentAbortController.signal, ...args);
if (currentAbortController.signal.aborted) {
return new Error('canceled');
}
handleSuccess(res);
return res;
} catch (error) {
if (currentAbortController.signal.aborted) {
return new Error('canceled');
}
handleError(error as Error);
return error as Error;
}
};
另外暴露出cancel方法:
const cancel = () => {
if (isFetching) {
mergedOptions.onCancel?.();
setLoadingState(false);
abortController.abort('cancel request');
return true;
}
return false;
};
在组件卸载时也取消掉未完成的请求:
onScopeDispose(() => {
if (mergedOptions.cancelOnDispose && isFetching) {
cancel();
}
});
以上,就是最基础版的useRequest实现,想要了解更多,欢迎直接阅读useRequest源码,核心代码一共也就一百来行。看完再把star一点,诶嘿,美滋滋。
产出
- useRequest源码
- useRequest使用文档
- 本次文章分享
收益
业务贡献
- 提供响应式的result、loading、error状态
- 内置缓存逻辑
- 内置错误重试逻辑
- 内置竞态处理逻辑
- 兼容 Vue 2 & 3
- 兼容 Axios & Fetch
- 取消网络请求
个人成长
- 学会如何编写一个基本的Vue工具库
- 了解如何用vite打包,并且带上类型文件
- 学会如何使用vue-demi兼容Vue2 & Vue3
- 学会如何用VuePress编写文档,过程中没少看源码
- 学会如何在npm上发包并维护
- 之前用jest写过测试,这次尝试了一下vitest,体感不错,过程中暴露不少代码问题
- 通过这个项目将以往所学的部分知识串联起来
参考
来源:juejin.cn/post/7293786784126255131
shadcn/ui 一个真·灵活的组件库
当前主流组件库的问题
我之前使用过很多组件库,比如 MUI,AntDesign,ElementUI,等等。他们都是很出名的组件库。
优点就不说了。他们的缺点是不灵活。
不灵活有 2 个原因。
生态不开放
第 1 个不灵活的原因是我感觉选了一家之后,就得一用到底,没有办法使用其他派系的组件了,比如我觉得 MUI 中的表格不好,Ant Design 的表格好,但是我无法在 MUI 中使用 AntDesign 的表格组件,因为在一个项目中无法同时使用 Mui 和 AntDesign。
无法使用的原因组件库把样式和组件库绑定在一起了,MUI 和 AntD 的样式又是不兼容的。使用了一个组件库,就需要套一个 ConfigProvider 或 ThemeProvider, 套上之后,就把地盘占领了,其他组件库就没法再套了。
修改不方便
第 2 个不灵活的原因要修改定制二次开发一个组件时感觉很麻烦,成本很高。有时需要看很多文档才能找到怎么修改,有时直接就无法修改。
Headless UI
为了解决组件库不灵活的问题,出现了无头组件库( headless ui ),不过 headless ui 虽然灵活却不方便。
如果要写一个按钮,按钮的各种状态都需要自己来关心,那还不如直接用大而全的组件库。大部分场景中,方便的优先级还是大于灵活的。
这也是为什么 radix-ui 一开始做了一个 headless 组件库,http://www.radix-ui.com/primitives , 后来又做了一个带主题的组件库
shadcn/ui
shadcn/ui 的优势正是解决了上面两个问题,同时又尽量保留了组件库使用方便的优势。
真·灵活
shadcn/ui 给人的感觉没有什么负担,因为 shadcn/ui ,主打一个按需加载,真·按需加载,加载组件的方式是通过命令把代码加到你的项目中,而不是从依赖中引用代码,所以代码不在外部依赖 node_modules 中,直接在自己的项目源代码中,不会有依赖了重重的一坨代码的感觉。因为都是源代码,这样你可以直接随意修改,二次开发。实际场景中,通常是不需要修改的,在偶尔需要修改是也很灵活,大不了复制粘贴一份,总比“明明知道怎么实现却无法实现强”。
拥抱 Tailwindcss
shadcn/ui 使用了 tailwindcss 作为样式方案,这样可以复制 tailwindcss 生态中的其他代码,比如 tailspark.co/ 和 flowbite.com/ ,一下子生态就大了很多,而且修改起来也方便。
方便
通过官方封装组件 + CLI 命令,灵活的同时并没有明显降低效率
比如要使用按钮组件,ui.shadcn.com/docs/compon… , 直接通过一行命令添加后就可以使用了 npx shadcn-ui@latest add button
总结
总结 shancn/ui 的优点
- 组件代码直接在项目源代码中,将灵活做到极致
- 拥抱 tailwindcss 生态
- 灵活的同时并没有明显降低效率
来源:juejin.cn/post/7382747688112783360
小红书路由处理大揭秘
起因
前两天看到小红书网页版的这个效果,感觉挺神奇的:
就是它同一个url对应了两种不同的页面。
上面这个是从列表页点开一个文章的时候,浏览器的路由变了,但是页面没有发生跳转,而是以一个弹窗的模式显示文章,底下我们还能看到列表。
但是当我们把这个url发送给别人,或者刷新浏览器后,同一个url会显示为下面这一个文章详情页,这样就避免了查看详情的时候还需要加载背后的列表。并且小红书的列表和详情是有对应关系(hero效果),但是列表页是随机排列的,如果要加载列表后再加载详情,就很难定位到文章在列表中的位置(随机推荐逻辑就很难改),而且还会影响性能。
前两天看到小红书网页版的这个效果,感觉挺神奇的:
就是它同一个url对应了两种不同的页面。
上面这个是从列表页点开一个文章的时候,浏览器的路由变了,但是页面没有发生跳转,而是以一个弹窗的模式显示文章,底下我们还能看到列表。
但是当我们把这个url发送给别人,或者刷新浏览器后,同一个url会显示为下面这一个文章详情页,这样就避免了查看详情的时候还需要加载背后的列表。并且小红书的列表和详情是有对应关系(hero效果),但是列表页是随机排列的,如果要加载列表后再加载详情,就很难定位到文章在列表中的位置(随机推荐逻辑就很难改),而且还会影响性能。
思考
解决方案我跟小伙伴思考了很久(基于vue-router),一开始我想的是通过路由守卫来控制,如果from来自列表,to就不跳转;如果from不是列表,则to跳转。但是这个方案会导致路由出现问题,因为如果没有跳转,则路由也不会变化。
另一个小伙伴想的是在路由表上,复用相同的组件,并使用keepAlive控制,来达到组件重用的目的。但是这个逻辑页有问题,keepAlive是路由的重用,其实不是组件的重用。
但当真正写起代码,才发现我们根本是想太多,其实解决方案简单到不足100行。
解决方案我跟小伙伴思考了很久(基于vue-router),一开始我想的是通过路由守卫来控制,如果from来自列表,to就不跳转;如果from不是列表,则to跳转。但是这个方案会导致路由出现问题,因为如果没有跳转,则路由也不会变化。
另一个小伙伴想的是在路由表上,复用相同的组件,并使用keepAlive控制,来达到组件重用的目的。但是这个逻辑页有问题,keepAlive是路由的重用,其实不是组件的重用。
但当真正写起代码,才发现我们根本是想太多,其实解决方案简单到不足100行。
代码
第一步:搭建项目
这里我采用vite来搭建项目,其实小红书这种网站需要考虑SEO的需求,应该会采用nuxt或者next等同构解决方案,这里我们简化了一下,只考虑路由的变化,所以也就不使用nuxt来搭建项目了。
这里我采用vite来搭建项目,其实小红书这种网站需要考虑SEO的需求,应该会采用nuxt或者next等同构解决方案,这里我们简化了一下,只考虑路由的变化,所以也就不使用nuxt来搭建项目了。
第二步,加入vue-router
routes.ts
import { RouteRecordRaw } from "vue-router";
export const routes: RouteRecordRaw[] = [
{
path: "/",
redirect: '/home'
},
{
path: "/home",
name: "Home",
component: () => import("./Home.vue"),
children: [
{
path: ':id',
name: "Detail",
component: () => import('./Detail.vue'),
}
]
},
]
router.ts
import {createRouter, createWebHistory} from "vue-router";
import { routes } from './routes.ts'
export const router = createRouter({
history: createWebHistory(),
routes,
})
文件结构:
我习惯吧routes和router分开两个文件,一个专心做路由表的编辑,另一个就可以专门做路由器(router)和路由守卫的编辑。
代码结构其实很简单,为了缩减代码量,我直接把page组件跟router放在一起了。
简单解释一下:
routes.ts 文件中我写了三个路由,一个是根路由/
,一个是列表/home
,一个是详情页Detail,这里使用了一个相对路由:id
的小技巧,待会你们就会知道为什么要这样了。
routes.ts
import { RouteRecordRaw } from "vue-router";
export const routes: RouteRecordRaw[] = [
{
path: "/",
redirect: '/home'
},
{
path: "/home",
name: "Home",
component: () => import("./Home.vue"),
children: [
{
path: ':id',
name: "Detail",
component: () => import('./Detail.vue'),
}
]
},
]
router.ts
import {createRouter, createWebHistory} from "vue-router";
import { routes } from './routes.ts'
export const router = createRouter({
history: createWebHistory(),
routes,
})
文件结构:
我习惯吧routes和router分开两个文件,一个专心做路由表的编辑,另一个就可以专门做路由器(router)和路由守卫的编辑。
代码结构其实很简单,为了缩减代码量,我直接把page组件跟router放在一起了。
简单解释一下:
routes.ts 文件中我写了三个路由,一个是根路由/
,一个是列表/home
,一个是详情页Detail,这里使用了一个相对路由:id
的小技巧,待会你们就会知道为什么要这样了。
第三步,编写Home.vue
<template>
<div>
<div class="text-red-700">Homediv>
<div class="w-full flex flex-wrap gap-3">
<router-link v-for="item in dataList" :to="`/home/${item.id}`">
<img :src="item.url" alt="">
router-link>
div>
<el-dialog title="Detail" v-model="dialogVisible">
<router-view>router-view>
el-dialog>
div>
template>
<script setup lang="ts">
import {computed, ref} from "vue";
import {useRoute, useRouter} from "vue-router";
import axios from "axios";
import {randomSize} from "../utils/randomSize.ts";
const route = useRoute()
const router = useRouter()
const lastRoute = computed(() => route.matched[route.matched.length - 1])
const dialogVisible = computed({
get() {
return lastRoute.value.name == 'Detail'
},
set(val) {
if (!val) {
router.go(-1)
}
},
})
const dataList = ref([])
const loading = ref(false)
function getList() {
loading.value = true
const data = localStorage.getItem('imageData')
if (!data) {
axios.get('https://picsum.photos/v2/list')
.then(({data}) => setDataList(data))
.then(data => localStorage.setItem('imageData', JSON.stringify(data)))
.finally(() => {
loading.value = false
})
} else {
setDataList(JSON.parse(data))
}
}
getList()
function setDataList(data) {
dataList.value = data.map(item => ({
id: item.url.split('/').pop(),
url: randomSize(item.download_url)
}))
return data
}
script>
这里重点看两个地方:
- template里需要有显示
detail
视图的地方,因为Home.vue除了要显示列表,还需要显示弹窗中的Detail,所以我把列表做成了router-link,并且把router-view放在了dialog里。(这里借助了tailwindcss和element-plus)
- 为了控制弹窗的显隐,我定义了一个dialogVisible计算对象,他的get来自router.matched列表中最后一个路由(最终命中的路由)是否为Detail,如果为Detail,就true,否则为false;它的set我们只需要处理false的情况,当false的时候,路由回退1。(其实是用push/replace还是用go我是有点纠结的,但是我看到小红书这里是用的回退,所以我也就用回退了,虽然回退在这种使用场景中存在一定的隐患)
<template>
<div>
<div class="text-red-700">Homediv>
<div class="w-full flex flex-wrap gap-3">
<router-link v-for="item in dataList" :to="`/home/${item.id}`">
<img :src="item.url" alt="">
router-link>
div>
<el-dialog title="Detail" v-model="dialogVisible">
<router-view>router-view>
el-dialog>
div>
template>
<script setup lang="ts">
import {computed, ref} from "vue";
import {useRoute, useRouter} from "vue-router";
import axios from "axios";
import {randomSize} from "../utils/randomSize.ts";
const route = useRoute()
const router = useRouter()
const lastRoute = computed(() => route.matched[route.matched.length - 1])
const dialogVisible = computed({
get() {
return lastRoute.value.name == 'Detail'
},
set(val) {
if (!val) {
router.go(-1)
}
},
})
const dataList = ref([])
const loading = ref(false)
function getList() {
loading.value = true
const data = localStorage.getItem('imageData')
if (!data) {
axios.get('https://picsum.photos/v2/list')
.then(({data}) => setDataList(data))
.then(data => localStorage.setItem('imageData', JSON.stringify(data)))
.finally(() => {
loading.value = false
})
} else {
setDataList(JSON.parse(data))
}
}
getList()
function setDataList(data) {
dataList.value = data.map(item => ({
id: item.url.split('/').pop(),
url: randomSize(item.download_url)
}))
return data
}
script>
这里重点看两个地方:
- template里需要有显示
detail
视图的地方,因为Home.vue除了要显示列表,还需要显示弹窗中的Detail,所以我把列表做成了router-link,并且把router-view放在了dialog里。(这里借助了tailwindcss和element-plus)
- 为了控制弹窗的显隐,我定义了一个dialogVisible计算对象,他的get来自router.matched列表中最后一个路由(最终命中的路由)是否为Detail,如果为Detail,就true,否则为false;它的set我们只需要处理false的情况,当false的时候,路由回退1。(其实是用push/replace还是用go我是有点纠结的,但是我看到小红书这里是用的回退,所以我也就用回退了,虽然回退在这种使用场景中存在一定的隐患)
剩下的代码就是获取数据相关的,我借用了picsum的接口(获取demo图片),并且我也没有做小红书的瀑布流(毕竟还是有点难度的,等有空了再做个仿小红书瀑布流来水一篇文章)。
Detail.vue
的代码就不贴了,它没有太多技术含量。
大概的页面效果是这样的:这里我就没有做数据加载优化之类功能了。(代码尽量简短)
我们可以看到,当点击详情的时候,浏览器右下角是有显示对应的路由,点开之后浏览器地址栏也变化了,详情内容在弹窗中出现,是我们想要的效果。
但是此时如果刷新页面,页面还是会一样先加载列表页,然后以Dialog显示详情。
刷新只显示详情
怎么做到刷新的时候只显示Detail页面而不显示列表页呢?我很快有一个想法:在路由表(routes.ts)的下面再增加一个路由,让它的路由路径跟详情的一样,这样刷新的时候会不会能够匹配到这个新路由呢?
// route.ts
export const routes = [
...
{
path: '/home/:id',
name: "DetailId",
component: () => import('./Detail.vue')
}
]
这个路由跟Home是同级的,使用了绝对路径来标记path(这就是上面detail采用相对路径的原因),同时为了避免name冲突,我换了一个name,component还是使用Detail.vue(这里我后来发现其实也可以使用其他的组件,其实真正起作用的是path,而不是component)。
但是不行,不论是将这个路由放在Home前面还是Home后面,都没法做到小红书的那种效果,放在home前面会导致从列表页直接跳转到详情页,不会在弹窗中显示;放在home后面又会因为匹配优先级的问题,匹配不到底下的DetailId
解决方案
但是前面的思考还是给了我灵感,添加一个路由守卫
是不是就可以解决问题呢?于是我添加了这样一个全局路由守卫:
// router.ts
router.beforeEach((to, from) => {
if (to.name === 'Detail') {
if (from.name === 'Home') {
return true
} else {
return { name: 'DetailId', params: to.params }
}
}
})
这个守卫的作用是,当发生路由跳转时,如果to为Detail,则判断from是否为Home,如果from为Home,则可以正常跳转,如果from不为Home,则说明是刷新或者链接打开,这时跳转至DetailId页面,并且params保持不变。
短短十行代码,就解决了问题。
可以看到,正常从列表显示详情还是会正常从弹窗中显示,而如果此时刷新页面,就会直接进入到详情页面。
如此我们成功的模仿了小红书的路由逻辑。
总结
其实做完效果才会发现代码非常简单无非就是一个路由守卫,一个弹窗显示,加一起不到一百行代码。代码地址我贴在下方了,希望对大家有帮助。
来源:juejin.cn/post/7343883765540962355
入职2个月,我写了一个VSCode插件解决团队遗留的any问题
背景
团队项目用的是React Ts,接口定义使用Yapi
。
但是项目中很多旧代码为了省事,都是写成 any
,导致在使用的时候没有类型提示,甚至在迭代的时候还发现了不少因为传参导致的bug。
举个例子
表格分页接口定义的参数是 pageSize
和 offset
,但是代码里传的却是 size
和 offset
,导致每次都是全量拉数据,然而因为测试环境数据量少,完全没测出来。
在这种项目背景下,大致过了一个月,结合自己试用期的目标(我也不想搞啊......),想通过一个工具来快速解决这类问题。
团队项目用的是React Ts,接口定义使用Yapi
。
但是项目中很多旧代码为了省事,都是写成 any
,导致在使用的时候没有类型提示,甚至在迭代的时候还发现了不少因为传参导致的bug。
举个例子
表格分页接口定义的参数是 pageSize
和 offset
,但是代码里传的却是 size
和 offset
,导致每次都是全量拉数据,然而因为测试环境数据量少,完全没测出来。
在这种项目背景下,大致过了一个月,结合自己试用期的目标(我也不想搞啊......),想通过一个工具来快速解决这类问题。
目标
把代码中接口的 any
替换成 Yapi
上定义的类型,减少因为传参导致的bug数量。
把代码中接口的 any
替换成 Yapi
上定义的类型,减少因为传参导致的bug数量。
交互流程
设计
鉴于当前项目中接口数量庞大(eslint扫出来包含any的接口有768个),手动逐一审查并替换类型显得既不现实又效率低下。
显然需要一种更加高效且可靠的方法来解决。
因为组内基本上都是使用 VSCode
开发,因此最终决定开发一个 VSCode
插件来实现类型的替换。
考虑到直接扫描整个项目进行替换风险较大,因此最终是 按文件维度,针对当前打开的文件执行替换。
整个插件分为3个命令:
- 单个接口替换
- 整个文件所有接口替换
- 新增接口
鉴于当前项目中接口数量庞大(eslint扫出来包含any的接口有768个),手动逐一审查并替换类型显得既不现实又效率低下。
显然需要一种更加高效且可靠的方法来解决。
因为组内基本上都是使用 VSCode
开发,因此最终决定开发一个 VSCode
插件来实现类型的替换。
考虑到直接扫描整个项目进行替换风险较大,因此最终是 按文件维度,针对当前打开的文件执行替换。
整个插件分为3个命令:
- 单个接口替换
- 整个文件所有接口替换
- 新增接口
整体设计
插件按功能划分为6个模块:
插件按功能划分为6个模块:
环境检测
Easy Yapi需要和Yapi服务器交互,需要用户提供Yapi Project相关的信息,因此需要填写配置文件(由使用者提供)。
插件执行命令时会对配置文件内的信息进行检测。
Easy Yapi需要和Yapi服务器交互,需要用户提供Yapi Project相关的信息,因此需要填写配置文件(由使用者提供)。
插件执行命令时会对配置文件内的信息进行检测。
缓存接口列表
从性能上考虑,一次批量替换后,会缓存当前Yapi项目所有接口的定义到cache文件中,下次替换不会重新请求。
从性能上考虑,一次批量替换后,会缓存当前Yapi项目所有接口的定义到cache文件中,下次替换不会重新请求。
接口捕获
不管是单个接口替换还是整个文件接口替换都需要先捕获接口,这里是通过将代码转成AST来实现。
不管是单个接口替换还是整个文件接口替换都需要先捕获接口,这里是通过将代码转成AST来实现。
类型生成
将接口定义转化成TS类型,通过循环+递归拼接字符串生成类型。
为什么不直接使用Yapi自带的ts类型?
- 命名问题,Yapi自带的ts类型命名过于简单粗暴,就是直接把接口路径拼接起来
- 有的字段因为粗心带了空格,最后还需要手动修改一遍类型
- 实际项目中有一层请求中间件,可能最终需要的类型只有data那一层,而Yapi定义的是整个类型
将接口定义转化成TS类型,通过循环+递归拼接字符串生成类型。
为什么不直接使用Yapi自带的ts类型?
- 命名问题,Yapi自带的ts类型命名过于简单粗暴,就是直接把接口路径拼接起来
- 有的字段因为粗心带了空格,最后还需要手动修改一遍类型
- 实际项目中有一层请求中间件,可能最终需要的类型只有data那一层,而Yapi定义的是整个类型
代码插入
- 将生成的类型插入文件中
// 检查文件是否存在
if (fs.existsSync(targetFilePath)) {
const currentContent = fs.readFileSync(targetFilePath);
if (!currentContent.includes(typeName)) { // 判断类型是否已存在
try {
fs.appendFileSync(targetFilePath, content); // 追加内容
editor.document.save(); // 调用vscode api保存文件
return true;
} catch (err: any) {
......
return false;
}
} else {
......
return false;
}
} else { // 文件不存在,创建并写入类型
try {
fs.writeFileSync(targetFilePath, content);
editor.document.save();
return true;
} catch (err: any) {
......
}
}
- 替换原有函数字符串
const nextFnStr = functionText
.replace(/(\w+:\s*)(any)/, (_, $1) => {
if (query.apiReq) {
return `${$1}${query.typeName}`;
}
// 没参数
else {
return "";
}
})
.replace(/Promise<([a-zA-Z0-9_]+|any)>/ , (_, $1) => {
if (res?.apiRes) {
return `Promise<${res?.typeName}>`;
}
return `Promise` ;
})
.replace(/,\s*\{\s*params\s*\}/, (_) => {
// 对于没有参数的case, 应该删除参数
if (!query.apiReq) {
return "";
}
return _;
});
- 调用vscode api替换函数字符串
const startPosition = new vscode.Position(functionStartLine - 1, 0); // 减1因为VS Code的行数是从0开始的
const endPosition = new vscode.Position(
functionEndLine - 1,
document.lineAt(functionEndLine - 1).text.length
);
const textRange = new vscode.Range(startPosition, endPosition);
const editApplied = await editor.edit((editBuilder) => {
editBuilder.replace(textRange, nextFnStr);
});
......
- 引入类型, 插入import语句
const document = editor.document;
const fullText = document.getText(); // 调用vscode api 拿到当前文件字符串
// 匹配单引号或双引号,并确保结束引号与开始引号相匹配
const importRegex =
/(import\s+(type\s+)?\{\s*[^}]*)(}\s+from\s+(['"])\.\/types(\.ts)?['"]);?/g;
let matchIndex = fullText.search(importRegex); // 使用search得到全局匹配的起始索引
if (matchIndex !== -1) {
// 已经有类型语句
let matchText = fullText.match(importRegex)?.[0]; // 获取完整的匹配文本
// 去重,如果 import { a, b } from './types'中已经有typeNames中的类型,则不需要重复引入
// existingTypes = ['a', 'b']
const existingTypes = (
/\{\s*([^}]+)\s*\}/g.exec(matchText)?.[1] as string
)
.split(",")
.map((v) => v.trim());
const uniqueTypeNames = typeNames.filter(
(v) => !existingTypes.includes(v)
);
// 将生成的类型插入原有的import type语句中
// 例如: import { a } from './types'
// 生成了类型 b c 则变成 import { a, b, c } from './types'
let updatedImport = matchText?.replace(
importRegex,
(_, group1, group2, group3) => {
// group1 对应 $1,即 import 语句到第一个 "}" 之前的所有内容
// group3 对应 $3,即 "}" 到语句末尾的部分
return `${
(group1.trim() as string).endsWith(",") ? group1 : `${group1}, `
}${uniqueTypeNames.join(", ")} ${group3}`;
}
);
// 计算确切的起始和结束位置
let startPos = document.positionAt(matchIndex);
let endPos = document.positionAt(matchIndex + matchText.length);
let range = new vscode.Range(startPos, endPos);
// 替换
await editor.edit((editBuilder) => {
editBuilder.replace(range, updatedImport as string);
});
} else {
// 直接插入import type
await editor.edit((editBuilder) => {
editBuilder.insert(
new vscode.Position(0, 0),
`import type { ${typeNames.join(",")} } from './types';\n`
);
});
}
// importStr导入语句需要进行判断再导入
// 例如:import request from '@service/request';
if (importStr && requestName) {
const importStatementRegex = new RegExp(
`import\\s+(?:\\{\\s*${requestName}\\s*\\}|${requestName})\\s+from\\s+['"]([^'"]+)['"];?`
);
const match = importStatementRegex.exec(editor.document.getText());
// 当前文件没有这个语句,插入
if (!match) {
await editor.edit((editBuilder) => {
editBuilder.insert(new vscode.Position(0, 0), `${importStr};\n`);
});
}
}
- 将生成的类型插入文件中
// 检查文件是否存在
if (fs.existsSync(targetFilePath)) {
const currentContent = fs.readFileSync(targetFilePath);
if (!currentContent.includes(typeName)) { // 判断类型是否已存在
try {
fs.appendFileSync(targetFilePath, content); // 追加内容
editor.document.save(); // 调用vscode api保存文件
return true;
} catch (err: any) {
......
return false;
}
} else {
......
return false;
}
} else { // 文件不存在,创建并写入类型
try {
fs.writeFileSync(targetFilePath, content);
editor.document.save();
return true;
} catch (err: any) {
......
}
}
- 替换原有函数字符串
const nextFnStr = functionText
.replace(/(\w+:\s*)(any)/, (_, $1) => {
if (query.apiReq) {
return `${$1}${query.typeName}`;
}
// 没参数
else {
return "";
}
})
.replace(/Promise<([a-zA-Z0-9_]+|any)>/ , (_, $1) => {
if (res?.apiRes) {
return `Promise<${res?.typeName}>`;
}
return `Promise` ;
})
.replace(/,\s*\{\s*params\s*\}/, (_) => {
// 对于没有参数的case, 应该删除参数
if (!query.apiReq) {
return "";
}
return _;
});
const startPosition = new vscode.Position(functionStartLine - 1, 0); // 减1因为VS Code的行数是从0开始的
const endPosition = new vscode.Position(
functionEndLine - 1,
document.lineAt(functionEndLine - 1).text.length
);
const textRange = new vscode.Range(startPosition, endPosition);
const editApplied = await editor.edit((editBuilder) => {
editBuilder.replace(textRange, nextFnStr);
});
......
const document = editor.document;
const fullText = document.getText(); // 调用vscode api 拿到当前文件字符串
// 匹配单引号或双引号,并确保结束引号与开始引号相匹配
const importRegex =
/(import\s+(type\s+)?\{\s*[^}]*)(}\s+from\s+(['"])\.\/types(\.ts)?['"]);?/g;
let matchIndex = fullText.search(importRegex); // 使用search得到全局匹配的起始索引
if (matchIndex !== -1) {
// 已经有类型语句
let matchText = fullText.match(importRegex)?.[0]; // 获取完整的匹配文本
// 去重,如果 import { a, b } from './types'中已经有typeNames中的类型,则不需要重复引入
// existingTypes = ['a', 'b']
const existingTypes = (
/\{\s*([^}]+)\s*\}/g.exec(matchText)?.[1] as string
)
.split(",")
.map((v) => v.trim());
const uniqueTypeNames = typeNames.filter(
(v) => !existingTypes.includes(v)
);
// 将生成的类型插入原有的import type语句中
// 例如: import { a } from './types'
// 生成了类型 b c 则变成 import { a, b, c } from './types'
let updatedImport = matchText?.replace(
importRegex,
(_, group1, group2, group3) => {
// group1 对应 $1,即 import 语句到第一个 "}" 之前的所有内容
// group3 对应 $3,即 "}" 到语句末尾的部分
return `${
(group1.trim() as string).endsWith(",") ? group1 : `${group1}, `
}${uniqueTypeNames.join(", ")} ${group3}`;
}
);
// 计算确切的起始和结束位置
let startPos = document.positionAt(matchIndex);
let endPos = document.positionAt(matchIndex + matchText.length);
let range = new vscode.Range(startPos, endPos);
// 替换
await editor.edit((editBuilder) => {
editBuilder.replace(range, updatedImport as string);
});
} else {
// 直接插入import type
await editor.edit((editBuilder) => {
editBuilder.insert(
new vscode.Position(0, 0),
`import type { ${typeNames.join(",")} } from './types';\n`
);
});
}
// importStr导入语句需要进行判断再导入
// 例如:import request from '@service/request';
if (importStr && requestName) {
const importStatementRegex = new RegExp(
`import\\s+(?:\\{\\s*${requestName}\\s*\\}|${requestName})\\s+from\\s+['"]([^'"]+)['"];?`
);
const match = importStatementRegex.exec(editor.document.getText());
// 当前文件没有这个语句,插入
if (!match) {
await editor.edit((editBuilder) => {
editBuilder.insert(new vscode.Position(0, 0), `${importStr};\n`);
});
}
}
总结
开发这个插件自己学到了不少东西,团队也用上了,有同学给了插件使用的反馈。
最后,试用期过了。
不过,新公司ppt文化是真的很重!!!
来源:juejin.cn/post/7423649211190591488
未登录也能知道你是谁?浏览器指纹了解一下!
引言
大多数人都遇到过这种场景,我在某个网站上浏览过的信息,但我并未登录,可是到了另一个网站发现被推送了类似的广告,这是为什么呢?
本文将介绍一种浏览器指纹的概念,以及如何利用它来判断浏览者身份。
浏览器指纹
浏览器指纹是指通过浏览器的特征来唯一标识用户身份的一种技术。
它通过记录用户浏览器的一些基本信息,包括操作系统、浏览器类型、浏览器版本、屏幕分辨率、字体、颜色深度、插件、时间戳等,通过这些信息,可以唯一标识用户身份。
应用场景
其实浏览器指纹这类的技术已经被运用的很广泛了,通常都是用在一些网站用途上,比如:
- 资讯等网站:精准推送一些你感兴趣的资讯给你看
- 购物网站: 精确推送一些你近期浏览量比较多的商品展示给你看
- 广告投放: 有一些网站是会有根据你的喜好,去投放不同的广告给你看的,大家在一些网站上经常会看到广告投放吧?
- 网站防刷: 有了浏览器指纹,就可以防止一些恶意用户的恶意刷浏览量,因为后端可以通过浏览器指纹认得这些恶意用户,所以可以防止这些用户的恶意行为
- 网站统计: 通过浏览器指纹,网站可以统计用户的访问信息,比如用户的地理位置、访问时间、访问频率等,从而更好的为用户提供服务
如何获取浏览器指纹
指纹算法有很多,这里介绍一个网站 https://browserleaks.com/
上面介绍了很多种指纹,可以根据自己的需要选择。
这里我们看一看canvas,可以看到光靠一个canvas的信息区分,就可以做到15万用户只有7个是重复的,如果结合其他信息,那么就可以做到更精准的识别。
canvas指纹
canvas
指纹的原理就是通过 canvas
生成一张图片,然后将图片的像素点信息记录下来,作为指纹信息。
不同的浏览器、操作系统、cpu、显卡等等,画出来的 canvas 是不一样的,甚至可能是唯一的。
具体步骤如下:
- 用canvas 绘制一个图像,在画布上渲染图像的方式可能因web浏览器、操作系统、图形卡和其他因素而异,从而生成可用于创建指纹的唯一图像。在画布上呈现文本的方式也可能因不同web浏览器和操作系统使用的字体渲染设置和抗锯齿算法而异。
- 要从画布生成签名,我们需要通过调用
toDataURL()
函数从应用程序的内存中提取像素。此函数返回表示二进制图像文件的base64
编码字符串。然后,我们可以计算该字符串的MD5
哈希来获得画布指纹。或者,我们可以从IDAT块
中提取CRC校验和
,IDAT块
位于每个PNG
文件末尾的16到12个字节处,并将其用作画布指纹。
我们来看看结果,可以知道,无论是否在无痕模式下,都可以生成相同的 canvas
指纹。
换台设备试试
其他浏览器指纹
除了canvas
,还有很多其他的浏览器指纹,比如:
WebGL 指纹
WebGL(Web图形库)
是一个 JavaScript API
,可在任何兼容的 Web
浏览器中渲染高性能的交互式 3D
和 2D
图形,而无需使用插件。
WebGL
通过引入一个与 OpenGL ES 2.0
非常一致的 API
来做到这一点,该 API
可以在 HTML5
元素中使用。
这种一致性使 API
可以利用用户设备提供的硬件图形加速。
网站可以利用 WebGL
来识别设备指纹,一般可以用两种方式来做到指纹生产:
WebGL 报告
——完整的 WebGL
浏览器报告表是可获取、可被检测的。在一些情况下,它会被转换成为哈希值以便更快地进行分析。
WebGL 图像
——渲染和转换为哈希值的隐藏 3D
图像。由于最终结果取决于进行计算的硬件设备,因此此方法会为设备及其驱动程序的不同组合生成唯一值。这种方式为不同的设备组合和驱动程序生成了唯一值。
可以通过 Browserleaks test
检测网站来查看网站可以通过该 API
获取哪些信息。
产生 WebGL
指纹原理是首先需要用着色器(shaders)
绘制一个梯度对象,并将这个图片转换为Base64
字符串。
然后枚举 WebGL
所有的拓展和功能,并将他们添加到 Base64
字符串上,从而产生一个巨大的字符串,这个字符串在每台设备上可能是非常独特的。
例如 fingerprint2js
库的 WebGL
指纹生产方式:
HTTP标头
每当浏览器向服务器发送请求时,它会附带一个HTTP标头,其中包含了诸如浏览器类型、操作系统、语言偏好等信息。
这些信息可以帮助网站优化用户体验,但同时也能用来识别和追踪用户。
屏幕分辨率
屏幕分辨率指的是浏览器窗口的大小和设备屏幕的能力,这个参数因用户设备的不同而有所差异,为浏览器指纹提供了又一个独特的数据点。
时区
用户设备的本地时间和日期设置可以透露其地理位置信息,这对于需要提供地区特定内容的服务来说是很有价值的。
浏览器插件
用户安装的插件列表是非常独特的,可以帮助形成识别个体的浏览器指纹。
音频和视频指纹
通过分析浏览器处理音频和视频的方式,网站可以获取关于用户设备音频和视频硬件的信息,这也可以用来构建用户的浏览器指纹。
那么如何防止浏览器指纹呢?
先讲结论,成本比较高,一般人不会使用。
现在开始实践,根据上述的原理,我们知道了如何生成一个浏览器指纹,我们只需要它在获取toDataURL
时,修改其中的内容,那么结果就回产生差异,从而无法通过浏览器指纹进行识别。
那么,我们如何修改toDataURL
的内容呢?
我们不知道它会在哪里调用,所以我们只能通过修改它的原型链来修改。
又或者使用专门的指纹浏览,该浏览器可以随意切换js版本等信息来造成无序的随机值。
修改 toDataURL
第三方指纹库
FingerprintJS
FingerprintJS
是一个源代码可用的客户端浏览器指纹库,用于查询浏览器属性并从中计算散列访问者标识符。
与cookie
和本地存储不同,指纹在匿名/私人模式下保持不变,即使浏览器数据被清除。
ClientJS Library
ClientJS
是另一个常用的JavaScript
库,它通过检测浏览器的多个属性来生成指纹。
该库提供了易于使用的接口,适用于多种浏览器指纹应用场景。
来源:juejin.cn/post/7382344353069088803
如何为上传文件取一个唯一的文件名
作者:陈杰
背景
古茗内部有一个 CDN 文件上传平台,用户在平台上传文件时,会将文件上传至阿里云 OSS 对象存储,并将 OSS 链接转换成 CDN 链接返回给用户,即可通过 CDN 链接访问到文件资源。我们对 CDN 文件的缓存策略是持久化强缓存(Cache-Control: public, max-age=31536000
),这就要求所有上传文件的文件名都是唯一的,否则就有文件被覆盖的风险。有哪些方式可以保证文件名全局唯一?
唯一命名方式
方式一:使用时间戳+随机数
这是我们最容易想到的一种方式:
const name = Date.now() + Math.random().toString().slice(2, 6);
// '17267354922380490'
使用时间戳,加上 4 位随机数,已经可以 99.99999% 保证不会存在文件名重复。可以稍微优化一下:
const name = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
// 'm191x7bii63s'
将时间戳和随机数分别转换成 36 进制,以减少字符串长度。通过上面一步优化可以将字符长度从 17 位减少至 12 位。
使用时间戳+随机数作为文件名的优势是简单粗暴,基本上可以满足述求;但是有极小概率存在文件名冲突的可能。
方式二:使用文件 MD5 值
生成文件的 MD5 Hash 摘要值,在 node 中代码示例如下:
const crypto = require('crypto');
const name = crypto.createHash('md5').update([file]).digest('hex');
// 'f668bd04d1a6cfc29378e24829cddba9'
文件的 MD5 Hash 值可以当成文件指纹,每个文件都会生成唯一的 hash 值(有极小的概率会 hash 碰撞,可以忽略)。使用 MD5 Hash 值作为文件名还可以避免相同文件重复上传;但是缺点是文件名较长。
方式三:使用 UUID
UUID (通用唯一识别码) 是用于计算机体系中以识别信息的一个标识符,重复的概率接近零,可以忽略不计。生成的 UUID 大概长这样:279e573f-c787-4a84-bafb-dfdc98f445cc。
使用 UUID 作为文件名的缺点也是文件名较长。
最终方案
从上述的几种命名方式可以看出,每种方式都有各种的优缺点,直接作为 OSS 的文件命名都不是很满意(期望 CDN 链接尽可能简短)。所以我们通过优化时间戳+随机数方式来作为最终方案版本。
本质上还是基于时间戳、随机数 2 部分来组成文件名,但是有以下几点优化:
- 由于 CDN 链接区分大小写,可以充分利用 数字+大写字母+小写字母(一共 62 个字符),也就是可以转成 62 进制,来进一步缩短字符长度
- 时间戳数字的定义是,当前时间减去 1970-01-01 的毫秒数。显然在 2024 年的今天,这个数字是非常大的。对此,可以使用 当前时间减去 2024-01-01 的毫秒数 来优化,这会大幅减少时间戳数字大小(2024-01-01 这个时间点是固定的,而且必须是功能上线前的一个时间点,确保不会减出负数)
示例代码如下:
/**
* 10 进制整数转 62 进制
*/
function integerToBase62(value) {
const base62Chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const base62 = base62Chars.length;
value = parseInt(value);
if (isNaN(value) || !value) {
return String(value);
}
let prefix = '';
if (value < 0) {
value = -value;
prefix = '-';
}
let result = '';
while (value > 0) {
const remainder = value % base62;
result = base62Chars[remainder] + result;
value = Math.floor(value / base62);
}
return prefix + result || '0';
}
const part1 = integerToBase62(Date.now() - new Date('2024-01-01').getTime()); // 'OkLdmK'
const part2 = integerToBase62(Math.random() * 1000000).slice(-4); // '3hLT'
const name = part1 + part2; // 'OkLdmK3hLT'
最终文件名字符长度减少到 10 位。但是始终感觉给 4 位随机数太浪费了,于是想着能否在保证唯一性的同时,还能减少随机数的位数。那就只能看看时间戳部分还能不能压榨一下。
只要能保证同一毫秒内只生成一个文件的文件名,就可以保证这个文件名是唯一的,这样的话,随机数部分都可以不要了,所以可以做如下优化:
// 伪代码
async function getFileName() {
// 等待锁释放,并发调用时保证至少等待 1ms
await waitLockRelease();
return integerToBase62(Date.now() - new Date('2024-01-01').getTime());
}
const name = await getFileName();
// 'OkLdmK'
由于 node 服务线上是多实例部署,所以 waitLockRelease
方法是基于 Redis 来实现多进程间加锁,保证多进程间创建的文件名也是唯一的。与此同时,还额外加上了一位随机数,来做冗余设计。最终将文件名字符长度减少至 7 位,且可以 100% 保证唯一性!
总结
看似非常简单的一个问题,想要处理的比较严谨和完美,其实也不太容易,甚至引入了 62 进制编码及加锁逻辑的处理。希望本文的分享能给大家带来收获!
来源:juejin.cn/post/7424901430378545164
签字板很难吗?纯 JS 实现一个!
前段时间有位同学问我:“公司项目中需要增加一个签字板的功能”,问我如何进行实现。
我说:“这种功能很简单呀,目前市面上有很多开源的库,比如:signature_pad
就可以直接引入实现”。
但是,该同学说自己公司的项目比较特殊,尽量不要使用 第三方的库,所以想要自己实现,那怎么办呢?
没办法!只能帮他实现一个了.
签字板实现逻辑
签字板的功能实现其实并不复杂,核心是 基于 canvas 的 2d
绘制能力,监听用户 鼠标 或者 手指 的移动行为,完成对应的 线绘制。
所以,想要实现签字板那么必须要有一个 canvas
,先看 html 的实现部分:
html
<body>
<canvas id="signature-pad" width="400" height="200">canvas>
<div class="controls">
<select id="stroke-style">
<option value="pen">钢笔option>
<option value="brush">毛笔option>
select>
<button id="clear">清空button>
div>
<script src="script.js">script>
body>
我们可以基于以上代码完成 HTML 布局,核心是两个内容:
canvas
画布:它是完成签字板的关键controls
控制器:通过它可以完成 画笔切换 以及 清空画布 的功能
css
css 相对比较简单,大家可以根据自己的需求进行调整就可以了,以下是 css 大家可以作为参考:
* {
margin: 0;
padding: 0;
}
body {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
height: 100vh;
width: 100vw;
background-color: #f0f0f0;
overflow: hidden;
}
canvas {
border: 1px solid #000;
background-color: #fff;
}
.controls {
margin-top: 10px;
display: flex;
gap: 10px;
}
button,
select {
padding: 5px 10px;
cursor: pointer;
}
js
js 部分是整个签字板的核心,我们需要在这里考虑较多的内容,比如:
- 为了绘制更加平滑,我们需要使用
ctx.quadraticCurveTo
方法完成平滑过渡 - 为了解决移动端手指滑动滚动条的问题,我们需要在
move
事件中通过e.preventDefault()
取消默认行为 - 为了完成画笔切换,我们需要监听
select
的change
事件,从而修改ctx.lineWidth
画笔
最终得到的 js 代码如下所示(代码中提供的详细的注释):
document.addEventListener('DOMContentLoaded', function () {
// 获取 canvas 元素和其 2D 上下文
var canvas = document.getElementById('signature-pad')
var ctx = canvas.getContext('2d')
var drawing = false // 标志是否正在绘制
var lastX = 0,
lastY = 0 // 保存上一个点的坐标
var strokeStyle = 'pen' // 初始笔画样式
// 开始绘制的函数
function startDrawing(e) {
e.preventDefault() // 阻止默认行为,避免页面滚动
drawing = true // 设置为正在绘制
ctx.beginPath() // 开始新路径
// 记录初始点的位置
const { offsetX, offsetY } = getEventPosition(e)
lastX = offsetX
lastY = offsetY
ctx.moveTo(offsetX, offsetY) // 移动画笔到初始位置
}
// 绘制过程中的函数
function draw(e) {
e.preventDefault() // 阻止默认行为,避免页面滚动
if (!drawing) return // 如果不是在绘制,直接返回
// 获取当前触点位置
const { offsetX, offsetY } = getEventPosition(e)
// 使用贝塞尔曲线进行平滑过渡绘制
ctx.quadraticCurveTo(
lastX,
lastY,
(lastX + offsetX) / 2,
(lastY + offsetY) / 2
)
ctx.stroke() // 实际绘制路径
// 更新上一个点的位置
lastX = offsetX
lastY = offsetY
}
// 停止绘制的函数
function stopDrawing(e) {
e.preventDefault() // 阻止默认行为
drawing = false // 结束绘制状态
}
// 获取事件中触点的相对位置
function getEventPosition(e) {
// 鼠标事件或者触摸事件中的坐标
const offsetX = e.offsetX || e.touches[0].clientX - canvas.offsetLeft
const offsetY = e.offsetY || e.touches[0].clientY - canvas.offsetTop
return { offsetX, offsetY }
}
// 鼠标事件绑定
canvas.addEventListener('mousedown', startDrawing) // 鼠标按下开始绘制
canvas.addEventListener('mousemove', draw) // 鼠标移动时绘制
canvas.addEventListener('mouseup', stopDrawing) // 鼠标抬起停止绘制
canvas.addEventListener('mouseout', stopDrawing) // 鼠标移出画布停止绘制
// 触摸事件绑定
canvas.addEventListener('touchstart', startDrawing) // 触摸开始绘制
canvas.addEventListener('touchmove', draw) // 触摸移动时绘制
canvas.addEventListener('touchend', stopDrawing) // 触摸结束时停止绘制
canvas.addEventListener('touchcancel', stopDrawing) // 触摸取消时停止绘制
// 清除画布的功能
document.getElementById('clear').addEventListener('click', function () {
ctx.clearRect(0, 0, canvas.width, canvas.height) // 清空整个画布
})
// 修改笔画样式的功能
document
.getElementById('stroke-style')
.addEventListener('change', function (e) {
strokeStyle = e.target.value // 获取选中的笔画样式
updateStrokeStyle() // 更新样式
})
// 根据 strokeStyle 更新笔画样式
function updateStrokeStyle() {
if (strokeStyle === 'pen') {
ctx.lineWidth = 2 // 细线条
ctx.lineCap = 'round' // 线条末端圆角
} else if (strokeStyle === 'brush') {
ctx.lineWidth = 5 // 粗线条
ctx.lineCap = 'round' // 线条末端圆角
}
}
// 初始化默认的笔画样式
updateStrokeStyle()
})
以上就是 纯JS实现签字板的完整代码,大家可以直接组合代码进行使用,最终展示的结果如下:
来源:juejin.cn/post/7424498500890705935
蓝牙耳机丢了,我花几分钟写了一个小程序,找到了!
你是否曾经经历过蓝牙耳机不知道丢到哪里去的困扰?特别是忙碌的早晨,准备出门时才发现耳机不见了,整个心情都被影响。幸运的是,随着技术的进步,我们可以利用一些简单的小程序和蓝牙技术轻松找到丢失的耳机。今天,我要分享的是我如何通过一个自制的小程序,利用蓝牙发现功能,成功定位自己的耳机。这不仅是一次有趣的技术尝试,更是对日常生活中类似问题的一个智能化解决方案。
1. 蓝牙耳机丢失的困扰
现代生活中,蓝牙耳机几乎是每个人的必备品。然而,耳机的体积小、颜色常常与周围环境融为一体,导致丢失的情况时有发生。传统的寻找方式依赖于我们对耳机放置地点的记忆,但往往不尽人意。这时候,如果耳机还保持在开机状态,我们就可以借助蓝牙技术进行定位。然而,市场上大部分设备并没有自带这类功能,而我们完全可以通过编写小程序实现。
2. 蓝牙发现功能的原理
蓝牙发现功能是通过设备之间的信号传输进行连接和识别的。当一个蓝牙设备处于开机状态时,它会周期性地广播自己的信号,周围的蓝牙设备可以接收到这些信号并进行配对。这个过程的背后其实是信号的强度和距离的关系。当我们在手机或其他设备上扫描时,能够检测到耳机的存在,但并不能直接告诉我们耳机的具体位置。此时,我们可以通过信号强弱来推测耳机的大概位置。
3. 实现步骤:从构想到实践
有了这个想法后,我决定动手实践。首先,我使用微信小程序作为开发平台,利用其内置的蓝牙接口实现设备扫描功能。具体步骤如下:
- • 环境搭建:选择微信小程序作为平台主要因为其开发简便且自带蓝牙接口支持。
- • 蓝牙接口调用:调用
wx.openBluetoothAdapter
初始化蓝牙模块,确保设备的蓝牙功能开启。 - • 设备扫描:通过
wx.startBluetoothDevicesDiscovery
函数启动设备扫描,并使用wx.onBluetoothDeviceFound
监听扫描结果。 - • 信号强度分析:通过读取蓝牙信号强度(RSSI),结合多次扫描的数据变化,推测设备的距离,最终帮助定位耳机。
在代码的实现过程中,信号强度的变化尤为重要。根据RSSI值的波动,我们可以判断耳机是在靠近还是远离,并通过走动测试信号的变化,逐渐缩小搜索范围。
下面是我使用 Taro 实现的全部代码:import React, { useState, useEffect } from "react";
import Taro, { useReady } from "@tarojs/taro";
import { View, Text } from "@tarojs/components";
import { AtButton, AtIcon, AtProgress, AtList, AtListItem } from "taro-ui";
import "./index.scss";
const BluetoothEarphoneFinder = () => {
const [isSearching, setIsSearching] = useState(false);
const [devices, setDevices] = useState([]);
const [nearestDevice, setNearestDevice] = useState(null);
const [isBluetoothAvailable, setIsBluetoothAvailable] = useState(false);
const [trackedDevice, setTrackedDevice] = useState(null);
useEffect(() => {
if (isSearching) {
startSearch();
} else {
stopSearch();
}
}, [isSearching]);
useEffect(() => {
if (devices.length > 0) {
const nearest = trackedDevice
? devices.find((d) => d.deviceId === trackedDevice.deviceId)
: devices[0];
setNearestDevice(nearest || null);
} else {
setNearestDevice(null);
}
}, [devices, trackedDevice]);
const startSearch = () => {
const startDiscovery = () => {
setIsBluetoothAvailable(true);
Taro.startBluetoothDevicesDiscovery({
success: () => {
Taro.onBluetoothDeviceFound((res) => {
const newDevices = res.devices.map((device) => ({
name: device.name || "未知设备",
deviceId: device.deviceId,
rssi: device.RSSI,
}));
setDevices((prevDevices) => {
const updatedDevices = [...prevDevices];
newDevices.forEach((newDevice) => {
const index = updatedDevices.findIndex(
(d) => d.deviceId === newDevice.deviceId
);
if (index !== -1) {
updatedDevices[index] = newDevice;
} else {
updatedDevices.push(newDevice);
}
});
return updatedDevices.sort((a, b) => b.rssi - a.rssi);
});
});
},
fail: (error) => {
console.error("启动蓝牙设备搜索失败:", error);
Taro.showToast({
title: "搜索失败,请重试",
icon: "none",
});
setIsSearching(false);
},
});
};
Taro.openBluetoothAdapter({
success: startDiscovery,
fail: (error) => {
if (error.errMsg.includes("already opened")) {
startDiscovery();
} else {
console.error("初始化蓝牙适配器失败:", error);
Taro.showToast({
title: "蓝牙初始化失败,请检查蓝牙是否开启",
icon: "none",
});
setIsSearching(false);
setIsBluetoothAvailable(false);
}
},
});
};
const stopSearch = () => {
if (isBluetoothAvailable) {
Taro.stopBluetoothDevicesDiscovery({
complete: () => {
Taro.closeBluetoothAdapter({
complete: () => {
setIsBluetoothAvailable(false);
},
});
},
});
}
};
const getSignalStrength = (rssi) => {
if (rssi >= -50) return 100;
if (rssi <= -100) return 0;
return Math.round(((rssi + 100) / 50) * 100);
};
const getDirectionGuide = (rssi) => {
if (rssi >= -50) return "非常接近!你已经找到了!";
if (rssi >= -70) return "很近了,继续朝这个方向移动!";
if (rssi >= -90) return "正确方向,但还需要继续寻找。";
return "信号较弱,尝试改变方向。";
};
const handleDeviceSelect = (device) => {
setTrackedDevice(device);
Taro.showToast({
title: `正在跟踪: ${device.name}`,
icon: "success",
duration: 2000,
});
};
return (
"bluetooth-finder">
{isSearching && (
"loading-indicator">
"loading-3" size="30" color="#6190E8" />
"loading-text">搜索中...Text>
View>
)}
{nearestDevice && (
"nearest-device">
"device-name">{nearestDevice.name}Text>
{getSignalStrength(nearestDevice.rssi)}
status="progress"
isHidePercent
/>
"direction-guide">
{getDirectionGuide(nearestDevice.rssi)}
Text>
View>
)}
"device-list">
{devices.map((device) => (
{device.deviceId}
title={device.name}
note={`${device.rssi} dBm`}
extraText={
trackedDevice && trackedDevice.deviceId === device.deviceId
? "跟踪中"
: ""
}
arrow="right"
onClick={() => handleDeviceSelect(device)}
/>
))}
AtList>
View>
"action-button">
type="primary"
circle
onClick={() => setIsSearching(!isSearching)}
>
{isSearching ? "停止搜索" : "开始搜索"}
AtButton>
View>
View>
);
};
export default BluetoothEarphoneFinder;
嘿嘿,功夫不负苦心人,我最终通过自己的小程序找到了我的蓝牙耳机。
我将我的小程序发布到了微信小程序上,目前已经通过审核,可以直接使用了。搜索老码宝箱 即可体验。
顺带还加了非常多的小工具,而且里面还有非常多日常可能会用到的工具,有些还非常有意思。
比如
绘制函数图
每日一言
汇率转换(实时)
BMI 计算
简易钢琴
算一卦
这还不是最重要的
最重要的是,这里的工具是会不断增加的,而且,更牛皮的是,你还可以给作者提需求,增加你想要的小工具,作者是非常欢迎一起讨论的。有朝一日,你也希望你的工具也出现在这个小程序上,被千万人使用吧。
4. 实际应用与优化空间
这个小程序的实际效果超出了我的预期。我能够通过它快速找到丢失的耳机,整个过程不到几分钟时间。然而,值得注意的是,由于蓝牙信号会受到环境干扰,例如墙体、金属物等,导致信号强度并不总是精确。在后续的优化中,我计划加入更多的信号处理算法,例如利用三角定位技术,结合多个信号源来提高定位精度。此外,还可以考虑在小程序中加入可视化的信号强度图,帮助用户更直观地了解耳机的大致方位。
一些思考:
蓝牙耳机定位这个小程序的开发,展示了技术在日常生活中的强大应用潜力。虽然这个项目看似简单,但背后的原理和实现过程非常具有教育意义。通过这次尝试,我们可以看到,借助开源技术和简单的编程能力,我们能够解决许多日常生活中的实际问题。
参考资料:
- 微信小程序官方文档:developers.weixin.qq.com
- 蓝牙信号强度(RSSI)与距离关系的研究:http://www.bluetooth.com
- 个人开发者经验分享: 利用蓝牙发现功能定位设备
来源:juejin.cn/post/7423610485180727332
opentype.js 使用与文字渲染
大家好,我是前端西瓜哥。
opentype.js 是一个 JavaScript 库,支持浏览器和 Node.js,可以解析字体文件,拿到字体信息,并提供一些渲染方法。
虽然名字叫做 opentype.js,但除了可以解析 OpenType,也可以解析 TrueType。
支持常见的字体类型,比如 WOFF, OTF, TTF。像是 AutoCAD 的 shx 就不支持了。
本文使用的 opentype.js 版本为 1.3.4
加载文字
加载文件字体为二进制数据,然后使用 opentype.js 解析:
import opentype from 'opentype.js'
const buffer = await fetch('./SourceHanSansCN-Normal.otf').then(
(res) => res.arrayBuffer(),
);
const font = opentype.parse(buffer);
需要注意的是,woff2 字体是用 Brotli 压缩过的文件,需要你额外用解压库做解压。
opentype.js 没有提供对应解压 Brotli 的能力,倒是提供了 Inflate 解压能力,所以可以解析 woff 字体。
font 这个对象保存了很多属性。
比如所有的 glyph(字形)、一些 table(表)、字体的信息(字体名、设计师等)等等。
获取字形(glyph)信息
glyph 为单个需要渲染的字形,是渲染的最小单位。
const glyph = font.charToGlyph('i')
另外 stringToGlyphs
方法会返回一个 glyph 数组。
const glyphs = font.stringToGlyph('abcd');
获取文字轮廓(path)
getPaths 计算得到一段字符串中每个 glyph 的轮廓数据。传入的坐标值为文字的左下角位置和文字大小。
const x = 60;
const y = 60;
const fontSize = 24;
const text = '前端西瓜哥/Ab1';
const textPaths = font.getPaths(text, x, y, fontSize);
textPaths 是一个 path 数组。
字符串长度为 9,产生了 9 个 glyph(字形),所以一共有 9 个 path 对象。
形状的表达使用了经典的 SVG 的 Path 命令,对应着 command 属性。
TrueType 字体的曲线使用二阶贝塞尔曲线(对应 Q 命令);而 OpenType 支持三阶贝塞尔曲线(对应 C 命令)。
渲染
我们有了 Path 数据,就能渲染 SVG 或 Canvas。
当然这个 OpenType.js 专门暴露了方法给我们,不用自己折腾做这层转换实现。
Canvas
基于 Canvas 2D 的方式绘制文字。
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
// ...
font.draw(ctx, text, x, y, fontSize);
渲染效果:
如果使用的字体找不到对应的字形,比如只支持英文的字体,但是却想要渲染中文字符。
此时 opentype.js 会帮你显示一个 豆腐块(“tofu” glyph)。豆腐块是取自字体自己设定的 glyph,不同字体的豆腐块还长得不一样,挺有意思的。
辅助点和线
字体是基于直线和贝塞尔曲线控制点形成的轮廓线进行表达的,我们可以绘制字体的控制点:
font.drawPoints(ctx, text, x, y, fontSize);
对文字做度量(metrics)得到尺寸数据。蓝色线为包围盒,绿色线为前进宽度。
font.drawMetrics(ctx, text, x, y, fontSize);
SVG
Path 实例有方法可以转为 SVG 中 Path 的 pathData 字符串。(Glyph 对象也支持这些方法)
path 长这个样子:
"M74.5920 47.6640L74.5920 57.5040L76.1040 57.5040L76.1040 47.6640ZM79.4640 46.9200L79.4640 59.8080C79.4640 60.1440 79.3440 60.2400 78.9600 60.2640C78.5520 60.2880 77.2320 60.2880 75.7440 60.2400C75.9840 60.6720 76.2480 61.3440 76.3200 61.7760Z"
拿到一段字符串对应的 path。
const textPath = font.getPath(text, x, y, fontSize);
const pathData = textPath.toPathData(4); // 4 为小数精度
// 创建 path 元素,指定 d 属性,添加到 SVG 上...
渲染结果。
另外还有一个 getPaths 方法,会返回一个 path 数组,里面是每个 glyph 的 path。
也可以直接拿到一个 path 元素的字符串形式。
path.toSVG(4)
会返回类似这样的字符串:
<path d="M74.5920 47.6640L74.5920 57.5040L76.1040 57.5040L76.1040 47.6640ZM79.4640 46.9200L79.4640 59.8080C79.4640 60.1440 79.3440 60.2400 78.9600 60.2640C78.5520 60.2880 77.2320 60.2880 75.7440 60.2400C75.9840 60.6720 76.2480 61.3440 76.3200 61.7760Z"/>
连字(ligature)
连字(合字、Ligatrue),指的是能够将多个字符组成成一个字符(glyph)。如:
像是 FiraCode 编程字体,该字体会将一些符号进行连字。
opentype.js 虽然说自己支持连字(Support for ligatures),但实际测试发现 API 好像并不会做处理。
用法为:
const textPath = font.getPath(text, x, y, fontSize, {
features: { liga: true },
});
字距(kerning)
两个 glyph 的距离如果为 0,会因为负空间不均匀,导致视觉上的失衡。
此时字体设计师就会额外调整特定 glyph 之间的字距(kerning),使其空间布局保持均衡。如下图:
opentype.js 可以帮我们获取两个 glyph 之间的字距。
const leftGlyph = font.charToGlyph('A');
const rightGlyph = font.charToGlyph('V');
font.getKerningValue(leftGlyph, rightGlyph)
// -15
返回值为 -15。代表右侧的字形 V 需往左移动 15 的距离。
结尾
本文简单介绍了 opentype.js 的一些用法,更多用法可以阅读官方文档。
不过你大概发现里面有某些方法对不上号,应该是迟迟未发布的 2.0.0 版本的文档。所以正确做法是切为 1.3.4 分支阅读 README.md 文档。
我是前端西瓜哥,关注我学习更多前端知识。
来源:juejin.cn/post/7424906244215455780
setTimeout是准时的吗?
引言
最近在一些论坛上,有人讨论 setTimeout
的准确性。因此,我进行了探索,以解答这个问题。结果发现,setTimeout
并不完全可靠,因为它是一个宏任务。所指定的时间实际上是将任务放入主线程队列的时间,而不是任务实际执行的时间。
`setTimeout(callback, 进入主线程的时间)`
因此,何时执行回调取决于主线程上待处理的任务数量。
演示
这段代码使用一个计数器来记录每次 setTimeout
的调用。设定的间隔时间乘以计数次数,理想情况下应等于预期的延迟。通过以下示例,可以检查我们计时器的准确性。
function time () {
var speed = 50, // 间隔
count = 1 // 计数
start = new Date().getTime();
function instance() {
var ideal=(count * speed),
real = (new Date().getTime() - start);
count++;
console.log(count + '理想值------------------------:', ideal); // 记录理想值
console.log(count + '真实值------------------------:', real); // 记录理想值
var diff = (real - ideal);
console.log(count + '差值------------------------:', diff); // 差值
// 小于5执行
if (count < 5) {
window.setTimeout(function(){
instance();
}, speed);
};
};
window.setTimeout(function () {
instance();
}, speed);
};
打印1
我们可以在 setTimeout
执行之前加入额外的代码逻辑,然后再观察这个差值。
...
window.setTimeout(function(){
instance();
}, speed);
for(var a = 1, i = 0; i < 10000000; i++) {
a *= (i + 1);
};
...
打印2
可以看出,这大大增加了误差。随着时间的推移,setTimeout
实际执行的时间与理想时间之间的差距会不断扩大,这并不是我们所期望的结果。在实际应用中,例如倒计时和动画,这种时间偏差会导致不理想的效果。
如何实现更精准的 setTimeout
?
requestAnimationFrame
window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。
该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行,回调函数执行次数通常是每秒60次,也就是每16.7ms 执行一次,但是并不一定保证为 16.7 ms。
我们用requestAnimationFrame
模拟 setTimeout
function setTimeout2(cb, delay) {
const startTime = Date.now();
function loop() {
const now = Date.now();
if (now - startTime >= delay) {
cb();
} else {
requestAnimationFrame(loop);
}
}
requestAnimationFrame(loop);
};
打印3
貌似误差问题还是没有得到解决,因此这个方案还是不行。
while
想得到准确的,我们第一反应就是如果我们能够主动去触发,获取到最开始的时间,以及不断去轮询当前时间,如果差值是预期的时间,那么这个定时器肯定是准确的,那么用while
可以实现这个功能。
function time2(time) {
const startTime = Date.now();
function checkTime() {
const now = Date.now();
if (now - startTime >= time) {
console.log('误差', now - startTime - time);
} else {
setTimeout(checkTime, 1); // 每毫秒检查一次
}
}
checkTime();
}
time2(5000);
误差存在是 2
, 甚至为 0
, 但使用 while(true)
会导致 CPU 占用率极高,因为它会持续循环而不进行任何等待,会使得页面进入卡死状态,这样的结果显然是不合适的。
setTimeout 系统时间补偿
这个方案是在 Stack Overflow
看到的一个方案,我们来看看此方案和原方案的区别。
当每一次定时器执行时后,都去获取系统的时间来进行修正,虽然每次运行可能会有误差,但是通过系统时间对每次运行的修复,能够让后面每一次时间都得到一个补偿。
function time () {
var speed = 50, // 间隔
count = 1 // 计数
start = new Date().getTime();
function instance() {
var ideal=(count * speed),
real = (new Date().getTime() - start);
count++;
console.log(count + '理想值------------------------:', ideal); // 记录理想值
console.log(count + '真实值------------------------:', real); // 记录理想值
var diff = (real - ideal);
console.log(count + '差值------------------------:', diff); // 差值
// 5次后不再执行
if (count < 5) {
window.setTimeout(function(){
instance();
}, (speed - diff));
};
};
window.setTimeout(function () {
instance();
}, speed);
};
打印4
结论
多次尝试后,是非常稳定的,误差微乎其微,几乎可以忽略不计,因此通过系统的时间补偿,能使 setTimeout
变得更加准时。
来源:juejin.cn/post/7420059840971980834
CSS实现一个故障时钟效果
起因
最近公司事情不是太多,我趁着这段时间在网上学习一些Cool~
的效果。今天我想和大家分享一个故障时钟的效果。很多时候,一个效果开始看起来很难,但是当你一步步摸索之后,就会发现其实它们只是由一些简单的效果组合而成的。
什么是故障效果(Glitch)
"glitch" 效果是一种模拟数字图像或视频信号中出现的失真、干扰或故障的视觉效果。它通常表现为图像的一部分或整体闪烁、抖动、扭曲、重叠或变形。这种效果常常被用来传达技术故障、数字崩溃、未来主义、复古风格等主题,也经常在艺术作品、音乐视频、电影、广告和网页设计中使用。Glitch 效果通常通过调整图像、视频或音频的编码、解码或播放过程中的参数来实现。 来自ChatGPT
可以看到关键的表现为一部分或整体闪烁、抖动、扭曲、重叠或变形
,所以我们应该重点关注用CSS
实现整体闪烁、抖动、扭曲、重叠或变形
CSS
实现闪烁
Glitch 闪烁通常是指图像或视频中出现的突然的、不规则的、瞬间的明暗变化或闪烁效果
那么我们有没有办法通过CSS
来实现上述的效果,答案是通过随机不规则的clip-path
来实现!
我们先来看看clip-path
的定义与用法
clip-path
CSS 属性使用裁剪方式创建元素的可显示区域。区域内的部分显示,区域外的隐藏。
/* <basic-shape> values */
clip-path: inset(100px 50px);
clip-path: circle(50px at 0 100px);
clip-path: ellipse(50px 60px at 0 10% 20%);
clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
clip-path: path(
"M0.5,1 C0.5,1,0,0.7,0,0.3 A0.25,0.25,1,1,1,0.5,0.3 A0.25,0.25,1,1,1,1,0.3 C1,0.7,0.5,1,0.5,1 Z"
);
再想想所谓的Glitch
故障闪烁时的效果是不是就是部分画面被切掉了~
span {
display: block;
position: relative;
font-size: 128px;
line-height: 1;
animation: clock 1s infinite linear alternate-reverse;
}
@keyframes clock {
0%{
clip-path: inset(0px 0px calc(100% - 10px) 0);
}
100%{
clip-path: inset(calc(100% - 10px) 0px 0px 0);
}
}
此时的效果如下:
啥啥啥,这看着是什么呀根本不像闪烁效果嘛,先别急,想想我们闪烁效果的定义突然的、不规则的、瞬间的明暗变化
,此时因为我们是在切割整体元素,如果我们再后面再重叠一个正常元素!
span {
display: block;
position: relative;
font-size: 128px;
line-height: 1;
//animation: clock 1s infinite linear alternate-reverse;
&:before{
display: block;
content: attr(data-time);
position: absolute;
top: 0;
color: $txt-color;
background: $bg-color;
overflow: hidden;
width: 720px;
height: 128px;
}
&:before {
left: -2px;
animation: c2 1s infinite linear alternate-reverse;
}
}
@keyframes c2 {
0%{
clip-path: inset(0px 0px calc(100% - 10px) 0);
}
100%{
clip-path: inset(calc(100% - 10px) 0px 0px 0);
}
}
可以看到通过手动偏移了-2px
后然后不断剪裁元素已经有了一定的闪烁效果,但是目前的闪烁效果过于呆滞死板,我们通过scss
的随机函数优化一下效果。
@keyframes c2 {
@for $i from 0 through 20 {
#{percentage($i / 20)} {
$y1: random(100);
$y2: random(100);
clip-path: polygon(0% $y1 * 1px, 100% $y1 * 1px, 100% $y2 * 1px, 0% $y2 * 1px);
}
}
23% {
transform: scaleX(0.8);
}
}
此时效果如下
可以看到闪烁的效果已经很强烈了,我们依葫芦画瓢再叠加一个元素上去使得故障效果再强烈一些。
span {
display: block;
position: relative;
font-size: 128px;
line-height: 1;
&:before,
&:after {
display: block;
content: attr(data-time);
position: absolute;
top: 0;
color: $txt-color;
background: $bg-color;
overflow: hidden;
width: 720px;
height: 128px;
}
&:before {
left: calc(-#{$offset-c2});
text-shadow: #{$lay-c2} 0 #{$color-c2};
animation: c2 1s infinite linear alternate-reverse;
}
&:after {
left: #{$offset-c1};
text-shadow: calc(-#{$lay-c1}) 0 #{$color-c1};
animation: c1 2s infinite linear alternate-reverse;
}
}
此时我们已经通过:before
和:after
叠加了相同的元素并且一个设置蓝色一个设置红色,让故障效果更真实!
CSS
实现扭曲效果
上述的效果已经非常贴近我们传统意义上理解的Glitch
效果了,但是还差了一点就是通常表现为图像的一部分或整体闪烁、抖动、扭曲、重叠或变形
中的扭曲
和变形
,碰巧的是CSS
实现这个效果非常容易,来看看~
skewX()
函数定义了一个转换,该转换将元素倾斜到二维平面上的水平方向。它的结果是一个<transform-function>
数据类型。
Cool~
最后一块拼图也被补上了~~
@keyframes is-off {
0%, 50%, 80%, 85% {
opacity: 1;
}
56%, 57%, 81%, 84% {
opacity: 0;
}
58% {
opacity: 1;
}
71%, 73% {
transform: scaleY(1) skewX(0deg);
}
72% {
transform: scaleY(3) skewX(-60deg);
}
91%, 93% {
transform: scaleX(1) scaleY(1) skewX(0deg);
color: $txt-color;
}
92% {
transform: scaleX(1.5) scaleY(0.2) skewX(80deg);
color: green;
}
}
来看看完整的效果和代码吧!
结语
春风若有怜花意,可否许我再少年。
感谢
Glitch Clock
来源:juejin.cn/post/7355302255409184807
解决小程序web-view两个恶心问题
1.web-view覆盖层问题
问题由来
web-view
是一个 web 浏览器组件,可以用来承载网页的容器,会自动铺满整个页面。
所以这得多恶心。。。不仅铺满,还覆盖了普通的标签,调z-index都无解。
解决办法
web-view内部使用cover-view
,调整cover-view的样式即可覆盖在web-view上。
cover-view
覆盖在原生组件上的文本视图。
app-vue和小程序框架,渲染引擎是webview的。但为了优化体验,部分组件如map、video、textarea、canvas通过原生控件实现,原生组件层级高于前端组件(类似flash层级高于div)。为了能正常覆盖原生组件,设计了cover-view。
支持的平台:
App | H5 | 微信小程序 | 支付宝小程序 | 百度小程序 |
---|
具体实现
<template>
<view>
<web-view :src="viewUrl" v-if="viewUrl" >
<cover-view class="close-view" @click="closeView()">
<cover-image class="close-icon" src="../../static/design/close-icon.png"></cover-image>
</cover-view>
</web-view>
</view>
</template>
.close-view{
position: fixed;
z-index: 99999;
top: 30rpx;
left: 45vw;
.close-icon{
width: 100rpx;
height: 80rpx;
}
}
代码说明:
这里的案例是一个关闭按钮图标悬浮在webview上,点击图标可以关闭当前预览的webview。
注意
仅仅真机上才生效,开发者工具上是看不到效果的,如果要调整覆盖层的样式,可以先把web-view标签注释了,写完样式没问题再释放web-view标签。
2.web-view导航栏返回
问题由来
- 小程序端 web-view 组件一定有原生导航栏,下面一定是全屏的 web-view 组件,navigationStyle: custom 对 web-view 组件无效。
场景
用户在嵌套的webview里填写表单,不小心按到导航栏的返回了,就全没了。
解决办法
使用page-container容器,点击到返回的时候,给个提示。
page-container
页面容器。
小程序如果在页面内进行复杂的界面设计(如在页面内弹出半屏的弹窗、在页面内加载一个全屏的子页面等),用户进行返回操作会直接离开当前页面,不符合用户预期,预期应为关闭当前弹出的组件。 为此提供“假页”容器组件,效果类似于 popup
弹出层,页面内存在该容器时,当用户进行返回操作,关闭该容器不关闭页面。返回操作包括三种情形,右滑手势、安卓物理返回键和调用 navigateBack
接口。
具体实现
<template>
<view>
<web-view :src="viewUrl" v-if="viewUrl" >
<cover-view class="close-view" @click="closeView()">
<cover-image class="close-icon" src="../../static/design/close-icon.png"></cover-image>
</cover-view>
</web-view>
<!--这里这里,就这一句-->
<page-container :show="isShow" :overlay="false" @beforeleave="beforeleave"></page-container>
</view>
</template>
export default {
data() {
return {
isShow: true
}
},
methods: {
beforeleave(){
this.isShow = false
uni.showToast({
title: '别点这里',
icon: 'none',
duration: 3000
})
}
}
}
结语
算是小完美的解决了吧,这里记录一下,看看就行,勿喷。
连夜更新安卓cover-view失效问题
由于之前一直用ios测试的,今晚才发现这个问题
解决办法
cover-view, cover-image{
visibility: visible!important;
z-index: 99999;
}
继续连夜更新cover-view在安卓上的问题
如果cover-view的展示是通过v-if控制的,后续通过v-if显示时会出现问题
解决方案
将v-if换成v-show,一换一个不吱声,必然好使!
来源:juejin.cn/post/7379960023407198220