一种简单粗暴的大屏自适应方案,原理及案例
现状
现在最流行的大屏自适应手法: scale缩放
为了解决2d/3d的点击交互问题,通常设计成了2个层容器。图表层和2d/3d层。图表层负责缩放,2d/3d层保持100%显示,避免缩放引起的交互事件event问题。
下图是一个1920*1080的大屏示意图
使用常规的缩放方法,让大屏在窗口内最大化显示。大屏在不同的窗口中出现了空白区域,并没有充满整个屏幕。
新的方法
在缩放的基础上,对指定的要素进行贴边处理。我们希望上下吸附到窗口最上面和最下面。左右图表吸附到窗口的最左边和最右边。
这里面需要简单的计算,其中a是图表层 scale属性
var halftop = (window.innerHeight- (1080*a.scaleY))/2/a.scaleY;
var halfleft = (window.innerWidth- (1920*a.scaleX))/2/a.scaleX;
对指定id的容器,在resize事件中设置上下左右浮动。如下图
实战项目效果
注,下面图片中的数据指标、城市名、姓名、图像均为虚拟数据。
在实际应用中,一般1920*1080设计稿已宽屏为主,如果是竖屏大屏(下图6),需要设计竖屏UI。
你也可以下载该项目demo, 对窗口进行缩放查看效果 pan.baidu.com/s/1hE_C9x9i…
来源:juejin.cn/post/7444378390843768843
还在等后端接口?自己写得了
前言
前端:芜湖~静态页面写完,起飞
前端:接口能不能搞快点
后端:没空
前端:emmmmmm
迭代结束.....
老板:前端你怎么回事?搞这么慢
前端:
A:跳起来打老板
B:跳起来打后端
C:不干了
D:自己全干
E:继续挨骂
CABABABABABBABABABABBABD
当然是选择
Mock.js
啦(骗你的,我自己也不用)
Mock.js 的使用教程
一、什么是 Mock.js?
Mock.js 是一个用于生成随机数据的 JavaScript 库,它可以帮助开发者快速模拟后台接口返回的数据,常用于前端开发中的接口调试和数据展示。通过使用 Mock.js,前端开发者无需依赖后端接口就可以模拟真实的接口数据,提升开发效率。
Mock.js 支持的数据类型非常丰富,包括字符串、数字、日期、图片等,并且可以对数据进行自定义设置,模拟出不同的场景。
二、安装 Mock.js
Mock.js 是一个轻量级的库,可以通过 npm
或 yarn
安装:
# 使用 npm 安装
npm install mockjs --save
# 使用 yarn 安装
yarn add mockjs
如果你没有使用包管理工具,也可以直接在 HTML 页面中通过 <script>
标签引入 Mock.js:
<script src="https://cdn.jsdelivr.net/npm/mockjs@1.1.0/dist/mock.min.js"></script>
三、Mock.js 的基本使用
Mock.js 提供了一个全局的 Mock
对象,使用 Mock
对象,你可以轻松地创建模拟数据。
1. 使用 Mock.mock()
方法
Mock.mock()
是 Mock.js 的核心方法,用于创建模拟数据。它接受一个模板作为参数,根据这个模板生成相应的模拟数据。
示例:生成简单的随机数据
const Mock = require('mockjs');
// 模拟一个简单的用户数据对象
const userData = Mock.mock({
'name': '@name', // 随机生成姓名
'age|18-60': 25, // 随机生成 18-60 之间的年龄
'email': '@email', // 随机生成邮箱地址
});
console.log(userData);
在这个例子中,@name
、@email
等是 Mock.js 内置的随机数据生成规则,'age|18-60': 25
是一种范围随机生成规则,它会生成 18 到 60 之间的随机数。
模拟输出:
{
"name": "张三",
"age": 34,
"email": "example@example.com"
}
2. 模拟数组数据
Mock.js 还可以生成数组数据,支持定义数组长度以及每个元素的生成规则。
const Mock = require('mockjs');
// 模拟一个包含多个用户的数组
const userList = Mock.mock({
'users|3-5': [{ // 随机生成 3 到 5 个用户对象
'name': '@name',
'age|20-30': 25,
'email': '@email'
}]
});
console.log(userList);
模拟输出:
{
"users": [
{ "name": "李四", "age": 22, "email": "user1@example.com" },
{ "name": "王五", "age": 28, "email": "user2@example.com" },
{ "name": "赵六", "age": 25, "email": "user3@example.com" }
]
}
3. 使用自定义规则生成数据
Mock.js 还支持自定义规则,你可以定义数据生成的规则,或者通过函数来生成特定的数据。
const Mock = require('mockjs');
// 使用自定义函数生成随机数据
const customData = Mock.mock({
'customField': () => {
return Math.random().toString(36).substr(2, 8); // 返回一个随机的 8 位字符串
}
});
console.log(customData);
模拟输出:
{
"customField": "rkf7hbw8"
}
四、常用的 Mock.js 模板规则
Mock.js 提供了丰富的数据生成规则,下面列出一些常用的规则。
1. 字符串相关规则
@name
:生成一个随机的中文名字。@cname
:生成一个随机的中文全名。@word(min, max)
:生成一个随机的单词,min
和max
控制长度。@sentence(min, max)
:生成一个随机的句子,min
和max
控制单词数量。@email
:生成一个随机的邮箱地址。@url
:生成一个随机的 URL 地址。
2. 数字相关规则
@integer(min, max)
:生成一个随机整数,min
和max
控制范围。@float(min, max, dmin, dmax)
:生成一个随机浮点数,min
和max
控制范围,dmin
和dmax
控制小数点位数。@boolean
:生成一个随机布尔值。@date(format)
:生成一个随机日期,format
为日期格式,默认是yyyy-MM-dd
。@time(format)
:生成一个随机时间。
3. 其他类型
@image(size, background, foreground)
:生成一张图片,size
控制图片大小,background
控制背景色,foreground
控制前景色。@guid
:生成一个 GUID。@id
:生成一个随机的身-份-证号。@province
、@city
、@county
:生成随机的省、市、区名称。
五、Mock.js 用于模拟接口数据
Mock.js 常用于前端开发中模拟接口数据,帮助前端开发人员在没有后端接口的情况下进行开发和调试。可以通过 Mock.mock()
来拦截 HTTP 请求,并返回模拟的数据。
示例:模拟一个接口请求
假设我们有一个接口需要返回用户数据,我们可以使用 Mock.js 来模拟这个接口。
const Mock = require('mockjs');
// 模拟接口请求
Mock.mock('/api/users', 'get', {
'users|5-10': [{ // 随机生成 5 到 10 个用户数据
'id|+1': 1, // id 从 1 开始递增
'name': '@name',
'email': '@email',
'age|18-60': 25,
}]
});
console.log('接口已模拟,发送请求查看结果');
在上面的代码中,Mock.mock()
拦截了对 /api/users
的 GET 请求,并返回一个包含随机用户数据的对象。当前端代码请求 /api/users
时,Mock.js 会自动返回模拟的数据。
六、Mock.js 高级用法
1. 延迟模拟
有时你可能希望模拟网络延迟,Mock.js 支持使用 timeout
配置来延迟接口响应。
Mock.mock('/api/data', 'get', {
'message': '成功获取数据'
}).timeout = 2000; // 设置延迟时间为 2000ms (2秒)
2. 使用正则表达式生成数据
Mock.js 还支持通过正则表达式来生成数据。例如,生成一个特定格式的电话号码。
const phoneData = Mock.mock({
'phone': /^1[3-9]\d{9}$/ // 正则表达式生成一个中国大陆手机号
});
console.log(phoneData);
3. 动态修改数据
Mock.js 还允许你在数据生成后对其进行动态修改,可以通过调用 Mock.Random
对象来获取随机数据,并进一步自定义。
const random = Mock.Random;
const customData = {
name: random.name(),
email: random.email(),
phone: random.phone(),
};
console.log(customData);
七、总结
Mock.js 是一个强大的工具,可以帮助你快速生成模拟数据,尤其适用于前后端分离的开发模式,前端开发人员可以独立于后端接口进行开发和调试。Mock.js 提供了灵活的数据生成规则,支持随机数、日期、图片等多种类型,并且能够模拟 HTTP 接口请求,极大地提高了开发效率。
掌握 Mock.js 的基本用法,可以帮助你在开发过程中更加高效,减少对后端开发的依赖,提升整个项目的开发速度。
各位彦祖亦菲再见ヾ( ̄▽ ̄)ByeBye
来源:juejin.cn/post/7442515129173262351
threejs 仿抖音漂移停车特效
最近刷到了抖音的漂移停车2的视频,感觉还蛮有趣的
乍一看,实现这个漂移停车的效果需要一些东西:
- 一辆一直往前开的小车和一个停车点,这里就做成一个小车库吧
- 漂移停车逻辑。这个小游戏是通过往左往右触屏滑动来刹车,附带了转向
- 和车库的碰撞处理
- 停车后的计分逻辑
之前的文章实现了基于threejs的3d场景和一辆麻雀虽小五脏俱全的小车,咱们拿来接着用一下
行车物理模拟
其实之前自己实现的自车行驶超级简单,加减速、转弯都做的比较粗糙,这里引入物理库 cannon-es(cannon.js 的增强版)来帮忙做这块逻辑。物理库的作用其实就是模拟一些真实的物理效果,比如行车、物理碰撞、重力等。具体api文档 戳这里,不过只有英文文档
npm install cannon-es
先初始化一个物理世界,其实和 threejs 场景的初始化有点像,之后也是需要将物理世界的物体和 threejs 的物体一一对应地关联起来,比如这里的地面、小车和车库,这样后面物理库做计算后,再将作用后的物体的位置信息赋值到 threejs 对应物体的属性上,最后通过循环渲染(animate
)就能模拟行车场景了
import * as CANNON from "cannon-es";
// ...
const world = new CANNON.World();
// 物理世界预处理,这个可以快速排除明显不发生碰撞的物体对,提高模拟效率
world.broadphase = new CANNON.SAPBroadphase(world);
// 物理世界的重力向量
world.gravity.set(0, -9.8, 0);
// 刚体之间接触面的默认摩擦系数
world.defaultContactMaterial.friction = 0;
小车对象
cannon-es
的 RaycastVehicle 类可以辅助我们管理物理世界的小车对象,它提供了很多蛮好用的api,不仅可以帮助我们更好地管理车轮,而且能很好地根据地形运动
物理世界物体的基本要素有形状(常见的有Box长方体/Plane平面/Sphere球体
)、材质 Material
和刚体 Body
,类比 threejs 中的几何体、材质和 Mesh
。创建刚体后别忘了将它添加到物理世界里,和 threejs 将物体添加到 scene 场景里类似
// 创建小车底盘形状,这里就是一个长方体
const chassisShape = new CANNON.Box(new CANNON.Vec3(1, 0.3, 2));
// 创建质量为150kg的小车刚体。物理世界的质量单位是kg
const chassisBody = new CANNON.Body({ mass: 150 });
// 关联刚体和形状
chassisBody.addShape(chassisShape);
// 设定刚体位置
chassisBody.position.set(0, 0.4, 0);
// 基于小车底盘创建小车对象
const vehicle = new CANNON.RaycastVehicle({
chassisBody,
// 定义车辆的方向轴(0:x轴,1:y轴,2:z轴),让它符合右手坐标系
// 车辆右侧
indexRightAxis: 0,
// 车辆上方
indexUpAxis: 1,
// 车辆前进方向
indexForwardAxis: 2,
});
// 将小车添加到物理世界里,类比 threejs 的 scene.add()
vehicle.addToWorld(world);
四个车轮
接下来定义下车轮对象,用到了 Cylinder
这种圆柱体的形状,然后要注意做好旋转值 Quaternion
的调整。这部分会稍微复杂些,可以耐心看下注释:
// 车轮配置,详情配置参考 https://pmndrs.github.io/cannon-es/docs/classes/RaycastVehicle.html#addWheel
const options = {
radius: 0.4, // 轮子半径
directionLocal: new CANNON.Vec3(0, -1, 0), // 轮子方向向量,指轮子从中心点出发的旋转方向
suspensionStiffness: 45,
suspensionRestLength: 0.4,
frictionSlip: 5, // 滑动摩擦系数
dampingRelaxation: 2.3,
dampingCompression: 4.5,
maxSuspensionForce: 200000,
rollInfluence: 0.01,
axleLocal: new CANNON.Vec3(-1, 0, 0),
chassisConnectionPointLocal: new CANNON.Vec3(1, 1, 0),
maxSuspensionTravel: 0.25,
customSlidingRotationalSpeed: -30,
useCustomSlidingRotationalSpeed: true,
};
const axlewidth = 0.7;
// 设置第一个车轮的连接点位置
options.chassisConnectionPointLocal.set(axlewidth, 0, -1);
// 按指定配置给小车添加第一个车轮,其他车轮类似
vehicle.addWheel(options);
options.chassisConnectionPointLocal.set(-axlewidth, 0, -1);
vehicle.addWheel(options);
options.chassisConnectionPointLocal.set(axlewidth, 0, 1);
vehicle.addWheel(options);
options.chassisConnectionPointLocal.set(-axlewidth, 0, 1);
vehicle.addWheel(options);
// 四个车轮
const wheelBodies: CANNON.Body[] = [];
const wheelVisuals: THREE.Mesh[] = [];
vehicle.wheelInfos.forEach(function (wheel) {
const shape = new CANNON.Cylinder(
wheel.radius,
wheel.radius,
wheel.radius / 2,
20
);
const body = new CANNON.Body({ mass: 1, material: wheelMaterial });
// 刚体可以是动态(DYNAMIC)、静态(STATIC)或运动学(KINEMATIC)
body.type = CANNON.Body.KINEMATIC;
// 0表示这个刚体将与所有其他未设置特定过滤组的刚体进行碰撞检测
body.collisionFilterGr0up = 0;
// 使用setFromEuler方法将欧拉角转换为四元数,欧拉角的值为-Math.PI / 2(即-90度或-π/2弧度)
const quaternion = new CANNON.Quaternion().setFromEuler(
-Math.PI / 2,
0,
0
);
body.addShape(shape, new CANNON.Vec3(), quaternion);
wheelBodies.push(body);
// 创建3d世界的车轮对象
const geometry = new THREE.CylinderGeometry(
wheel.radius,
wheel.radius,
0.4,
32
);
const material = new THREE.MeshPhongMaterial({
color: 0xd0901d,
emissive: 0xaa0000,
flatShading: true,
side: THREE.DoubleSide,
});
const cylinder = new THREE.Mesh(geometry, material);
cylinder.geometry.rotateZ(Math.PI / 2);
wheelVisuals.push(cylinder);
scene.add(cylinder);
});
这一步很关键,需要在每次物理模拟计算结束后 (postStep
事件的回调函数) 更新车轮的位置和转角
// ...
world.addEventListener("postStep", function () {
for (let i = 0; i < vehicle.wheelInfos.length; i++) {
vehicle.updateWheelTransform(i);
const t = vehicle.wheelInfos[i].worldTransform;
// 更新物理世界车轮对象的属性
wheelBodies[i].position.copy(t.position);
wheelBodies[i].quaternion.copy(t.quaternion);
// 更新3d世界车轮对象的属性
wheelVisuals[i].position.copy(t.position);
wheelVisuals[i].quaternion.copy(t.quaternion);
}
});
车辆行驶和转向
监听键盘事件,按下上下方向键给一个前后的引擎动力,按下左右方向键给车轮一个转角值
// 引擎动力值
const engineForce = 3000;
// 转角值
const maxSteerVal = 0.7;
// 刹车作用力
const brakeForce = 20;
// ...
// 刹车
function brakeVehicle() {
// 四个车轮全部加刹车作用力
vehicle.setBrake(brakeForce, 0);
vehicle.setBrake(brakeForce, 1);
vehicle.setBrake(brakeForce, 2);
vehicle.setBrake(brakeForce, 3);
}
function handleNavigate(e: any) {
if (e.type != "keydown" && e.type != "keyup") {
return;
}
const isKeyup = e.type === "keyup";
switch (e.key) {
case "ArrowUp":
// 给第2/3个车轮加引擎动力
vehicle.applyEngineForce(isKeyup ? 0 : engineForce, 2);
vehicle.applyEngineForce(isKeyup ? 0 : engineForce, 3);
break;
case "ArrowDown":
vehicle.applyEngineForce(isKeyup ? 0 : -engineForce, 2);
vehicle.applyEngineForce(isKeyup ? 0 : -engineForce, 3);
break;
case "ArrowLeft":
// 设置车轮转角
vehicle.setSteeringValue(isKeyup ? 0 : -maxSteerVal, 2);
vehicle.setSteeringValue(isKeyup ? 0 : -maxSteerVal, 3);
break;
case "ArrowRight":
vehicle.setSteeringValue(isKeyup ? 0 : maxSteerVal, 2);
vehicle.setSteeringValue(isKeyup ? 0 : maxSteerVal, 3);
break;
}
brakeVehicle();
}
window.addEventListener("keydown", handleNavigate);
window.addEventListener("keyup", handleNavigate);
然后在每一帧里重新计算物体的物理值,并赋值给 3d 世界的小车属性,就可以实现行车效果
function updatePhysics() {
world.step(1 / 60);
egoCar.position.copy(chassisBody.position);
egoCar.quaternion.copy(chassisBody.quaternion);
}
// ...
const animate = () => {
stats.begin();
// ...
updatePhysics();
// ...
stats.end();
requestAnimationFrame(animate);
};
animate();
地面优化
地面看起来太光滑,显得有点假,咱们先给地面加上有磨砂质感的纹理贴图,同时隐藏掉辅助网格
// ...
// 加载纹理贴图
textureLoader.load("/gta/floor.jpg", (texture) => {
const planeMaterial = new THREE.MeshLambertMaterial({
// 将贴图对象赋值给材质
map: texture,
side: THREE.DoubleSide,
});
const plane = new THREE.Mesh(planeGeometry, planeMaterial);
// 地面接受阴影
plane.receiveShadow = true;
plane.rotation.x = Math.PI / 2;
scene.add(plane);
});
加载完贴图,生成3d场景的地面对象后,别忘了创建地面刚体并关联。这里还要定义地面刚体的物理材质,类比 threejs 的材质,会影响不同刚体之间摩擦和反弹的效果
// ...
// 定义地板的物理材质
const groundMaterial = new CANNON.Material("groundMaterial");
// 定义车轮的物理材质,其实之前代码用过了,可以留意下
const wheelMaterial = new CANNON.Material("wheelMaterial");
// 定义车轮和地板之间接触面的物理关联,在这里定义摩擦反弹等系数
const wheelGroundContactMaterial = new CANNON.ContactMaterial(
wheelMaterial,
groundMaterial,
{
// 摩擦系数
friction: 0.5,
// 反弹系数,0表示没有反弹
restitution: 0,
}
);
world.addContactMaterial(wheelGroundContactMaterial);
// ...
textureLoader.load("/gta/floor.jpg", (texture) => {
// ...
// 地面刚体
const q = plane.quaternion;
const planeBody = new CANNON.Body({
// 0说明物体是静止的,发生物理碰撞时不会相互移动
mass: 0,
// 应用接触面材质
material: groundMaterial,
shape: new CANNON.Plane(),
// 和3d场景的旋转值保持一致。在Cannon.js中,刚体的旋转可以通过四元数来表示,而不是传统的欧拉角或轴角表示法
quaternion: new CANNON.Quaternion(-q._x, q._y, q._z, q._w),
});
world.addBody(planeBody);
});
这回开起来可顺畅许多了,场景和自车旋转也变得更自然一些,感谢开源 ~
搭建车库
咱就搭个棚,一个背景墙、两个侧边墙、加一个屋顶和地板,其实都是些立方体,拼装成网格对象 Mesh 后,按照一定的位置和旋转拼在一起组成小车库,参考代码:
createParkingHouse() {
if (!this.scene || !this.world) return;
// 创建背景墙
const background = new THREE.Mesh(
new THREE.BoxGeometry(3, 4, 0.1),
new THREE.MeshBasicMaterial({ color: 0xcccccc })
);
background.position.set(0, 0, -53);
this.scene.add(background);
// 创建侧墙
const sider1 = new THREE.Mesh(
new THREE.BoxGeometry(6, 4, 0.3),
new THREE.MeshBasicMaterial({ color: 0xcccccc })
);
sider1.rotation.y = Math.PI / 2;
sider1.position.set(-1.5, 0.1, -50);
this.scene.add(sider1);
const sider2 = new THREE.Mesh(
new THREE.BoxGeometry(6, 4, 0.3),
new THREE.MeshBasicMaterial({ color: 0xcccccc })
);
sider2.rotation.y = Math.PI / 2;
sider2.position.set(1.5, 0.1, -50);
this.scene.add(sider2);
// 创建屋顶
const roof = new THREE.Mesh(
new THREE.BoxGeometry(3, 6, 0.1),
new THREE.MeshBasicMaterial({
color: 0xcccccc,
// 注意:这个值不为true的话,设置opacity是没用的
transparent: true,
opacity: 0.8,
})
);
roof.rotation.x = Math.PI / 2;
roof.position.set(0, 2, -50);
this.scene.add(roof);
// 创建地板
const floor = new THREE.Mesh(
new THREE.BoxGeometry(3, 6, 0.1),
new THREE.MeshBasicMaterial({ color: 0x666666 })
);
floor.rotation.x = Math.PI / 2;
floor.position.set(0, 0.1, -50);
this.scene.add(floor);
}
好了,一个稍微有点模样的小车库就大功告成
创建车库刚体
先加个背景墙的物理刚体
createParkingHouse() {
if (!this.scene || !this.world) return;
// 创建背景墙
const background = new THREE.Mesh(
new THREE.BoxGeometry(3, 4, 0.1),
new THREE.MeshBasicMaterial({ color: 0xcccccc })
);
background.position.set(0, 0, -53);
this.scene.add(background);
// 创建侧墙
// ...
// physic
const houseShape = new CANNON.Box(new CANNON.Vec3(1.5, 4, 0.1));
const houseBody = new CANNON.Body({ mass: 0 });
houseBody.addShape(houseShape);
houseBody.position.set(0, 0, -53);
this.world.addBody(houseBody);
}
// ...
其他的墙体类似的处理,屋顶先不管吧,小车应该也够不着。来,先撞一下试试
漂移停车
其实达到一定速度,通过方向键就能做一个甩尾漂移倒车入库
- 提供一个弹射的初始动力
// ...
animate();
setTimeout(() => {
// 给后轮上点动力
vehicle.applyEngineForce(2000, 2);
vehicle.applyEngineForce(2000, 3);
}, 100);
- 电脑端根据方向键触发漂移,这里注意要消除后轮的动力
// ...
case "ArrowLeft":
vehicle.setSteeringValue(keyup ? 0 : -maxSteerVal, 2);
vehicle.setSteeringValue(keyup ? 0 : -maxSteerVal, 3);
// 漂移停车游戏需要消除后轮动力,如果要正常行驶,需要去掉下面俩行
vehicle.applyEngineForce(0, 2);
vehicle.applyEngineForce(0, 3);
break;
case "ArrowRight":
vehicle.setSteeringValue(keyup ? 0 : maxSteerVal, 2);
vehicle.setSteeringValue(keyup ? 0 : maxSteerVal, 3);
// 漂移停车游戏需要消除后轮动力,如果要正常行驶,需要去掉下面俩行
vehicle.applyEngineForce(0, 2);
vehicle.applyEngineForce(0, 3);
break;
// ...
- 移动端根据触屏方向触发。需要注意此时要把相机控制器关掉,避免和触屏操作冲突。计算触发方向的逻辑参考
// 计算划过的角度
function getAngle(angx: number, angy: number) {
return (Math.atan2(angy, angx) * 180) / Math.PI;
}
// 计算触屏方向
function getDirection(
startx: number,
starty: number,
endx: number,
endy: number
): ESlideDirection {
const angx = endx - startx;
const angy = endy - starty;
let result = ESlideDirection.;
if (Math.abs(angx) < 2 && Math.abs(angy) < 2) {
return result;
}
const angle = getAngle(angx, angy);
if (angle >= -135 && angle <= -45) {
result = ESlideDirection.Top;
} else if (angle > 45 && angle < 135) {
result = ESlideDirection.Bottom;
} else if (
(angle >= 135 && angle <= 180) ||
(angle >= -180 && angle < -135)
) {
result = ESlideDirection.Left;
} else if (angle >= -45 && angle <= 45) {
result = ESlideDirection.Right;
}
return result;
}
let startx = 0;
let starty = 0;
document.addEventListener("touchstart", (e) => {
startx = e.touches[0].pageX;
starty = e.touches[0].pageY;
});
document.addEventListener("touchend", function (e) {
const endx = e.changedTouches[0].pageX;
const endy = e.changedTouches[0].pageY;
const direction = getDirection(startx, starty, endx, endy);
// 根据方向做转向和刹车的处理,和上面电脑侧左右键的逻辑一致就行了
// ...
})
计算分数
根据小车和车库角度偏差和中心点偏差来综合得分,这里就不细究了,浅浅定个规则:
- 不入库或没倒车:0分
- 其他情况:50分 + 角度分(20比例) + 中心分(30比例)
车停住后,先算出分数,再加个数字递增的效果,用 setInterval
实现就好了。不过这里要注意用回调函数的方式更新 state 值,避免闭包引起值不更新的问题
计分组件实现代码参考:
export const Overlay = observer(() => {
const [score, setScore] = useState(0);
useEffect(() => {
if (vehicleStore.score) {
// 计分动画
const timer = setInterval(() => {
// 回调方式更新state
setScore((score) => {
if (score + 1 === vehicleStore.score) {
clearInterval(timer);
}
return score + 1;
});
}, 10);
}
}, [vehicleStore.score]);
if (!vehicleStore.isStop) {
return null;
}
return (
<div className={styles["container"]}>
<div className={styles["score-box"]}>
<div className={styles["score-desc"]}>得分div>
<div>{score}div>
div>
div>
);
});
那么问题来了,怎么监听它停下了?可以加一个速度的阈值 velocityThreshold
,如果小车刚体的速度低于这个阈值就判定小车停下了。然后通过 mobx 状态库建立一个 vehicleStore
,主要是维护 isStop
(是否停止) 和 score
(分数) 这两个变量,变化后自动通知计分组件更新,这部分逻辑可以参考源码实现 ~
// ...
const velocityThreshold = 0.01;
function updatePhysics() {
world.step(1 / 60);
// ...
// 检查刚体的速度,小于阈值视为停止
if (
chassisBody.velocity.length() < velocityThreshold &&
// 停车标识
!vehicleStore.isStop
) {
console.log("小车已经停止");
vehicleStore.stop();
// 触发计分逻辑,自行参考源码
// ...
vehicleStore.setScore(score);
}
}
// ...
传送门
来源:juejin.cn/post/7331070678693380122
入职第一天,看了公司代码,牛马沉默了
入职第一天就干活的,就问还有谁,搬来一台N手电脑,第一分钟开机,第二分钟派活,第三分钟干活,巴适。。。。。。
打开代码发现问题不断
- 读取配置文件居然读取两个配置文件,一个读一点,不清楚为什么不能一个配置文件进行配置
一边获取WEB-INF下的配置文件,一边用外部配置文件进行覆盖,有人可能会问既然覆盖,那可以全在外部配置啊,问的好,如果全用外部配置,咱们代码获取属性有的加上了项目前缀(上面的两个put),有的没加,这样配置文件就显得很乱不可取,所以形成了分开配置的局面,如果接受混乱,就写在外部配置;不能全写在内部配置,因为
prop_c.setProperty(key, value);
value获取外部配置为空的时候会抛出异常;properties底层集合用的是hashTable
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
}
- 很多参数写死在代码里,如果有改动,工作量会变得异常庞大,举例权限方面伪代码
role.haveRole("ADMIN_USE")
- 日志打印居然sout和log混合双打
先不说双打的事,对于上图这个,应该输出包括堆栈信息,不然定位问题很麻烦,有人可能会说e.getMessage()最好,可是生产问题看多了发现还是打堆栈好;还有如果不是定向返回信息,仅仅是记录日志,完全没必要catch多个异常,一个Exception足够了,不知道原作者这么写的意思是啥;还是就是打印日志要用logger,用sout打印在控制台,那我日志文件干啥;
4.提交的代码没有技术经理把关,下发生产包是个人就可以发导致生产环境代码和本地代码或者数据库数据出现不一致的现象,数据库数据的同步是生产最容易忘记执行的一个事情;比如我的这家公司上传文件模板变化了,但是没同步,导致出问题时开发环境复现问题真是麻烦;
5.随意更改生产数据库,出不出问题全靠开发的职业素养;
6.Maven依赖的问题,Maven引pom,而pom里面却是另一个pom文件,没有生成的jar供引入,是的,我们可以在dependency里加上
<type>pom
来解决这个问题,但是公司内的,而且实际也是引入这个pom里面的jar的,我实在不知道这么做的用意是什么,有谁知道;求教
以上这些都是我最近一家公司出现的问题,除了默默接受还能怎么办;
那有什么优点呢:
- 不用太怎么写文档
- 束缚很小
- 学到了js的全局调用怎么写的(下一篇我来写,顺便巩固一下)
解决之道
怎么解决这些问题呢,首先对于现有的新项目或升级的项目来说,spring的application.xml/yml 完全可以写我们的配置,开发环境没必要整外部文件,如果是生产环境我们可以在脚本或启动命令添加 nohup java -Dfile.encoding=UTF-8 -Dspring.config.location=server/src/main/config/application.properties -jar xxx.jar &
来告诉jar包引哪里的配置文件;也可以加上动态配置,都很棒的,
其次就是规范代码,养成良好的规范,跟着节奏,不要另辟蹊径;老老实实的,如果原项目上迭代,不要动源代码,追加即可,没有时间去重构的;
我也曾是个快乐的童鞋,也有过崇高的理想,直到我面前堆了一座座山,脚下多了一道道坑,我。。。。。。!
来源:juejin.cn/post/7371986999164928010
金价大跳水?写一个金价监控脚本
说在前面
😶🌫️国庆过后,金价就大跳水,一直往下跌,看样子暂时是停不下来了,女朋友之前也入手了一点黄金,因此对黄金价格的变化比较关心,为了让她不用整天盯着实时金价,所以就搞了一个金价监控工具,超出设置的阈值就会发送邮件提醒✉。
一、金价信息获取方案
金价实时信息有两种方案可以获取到:
1、网页信息爬取
我们可以先找到一些官方的金价信息网站,然后直接利用爬虫直接爬取,比如:quote.cngold.org/gjs/jjs.htm…
2、通过接口获取
例如nowapi
中就有黄金数据信息接口,我们可以直接通过接口来获取:
二、提醒阈值设置
1、创建数据库
2、监控页面编写
简单编写一个页面用于添加和调整提醒内容。
三、修改配置信息
1、邮箱配置
这里我使用的qq邮箱作为发件账号,需要开启邮箱授权,获取授权码。
{
host: "smtp.qq.com", // 主机
secureConnection: true, // 使用 SSL
port: 465, // SMTP 端口
auth: {
user: "jyeontu@qq.com", // 自己用于发送邮件的账号
pass: "jyeontu", // 授权码(这个是假的,改成自己账号对应即可,获取方法: QQ邮箱-->设置-->账户-->POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务-->IMAP/SMTP开启 复制授权码)
}
}
- (1)打开pc端qq邮箱,点击设置,再点击帐户
- (2)往下拉 可开启POP3/SMTP服务 根据提示即可获取qq邮箱授权码
- (3)将获取到的授权码复制到配置信息里即可
2、数据库配置
填写数据库对应的配置信息。
{
host: "localhost",
user: "root", //数据库账号
password: "jyeontu", //数据库密码
database: "test", //数据库名称
}
3、nowapi配置
免费开通后将AppKey
和Sign
替换成自己的就可以了。
{
AppKey: AppKey,
Sign: "Sign",
}
四、脚本功能编写
1、获取金价信息
我直接使用nowapi
的免费试用套餐,配额是10 次/小时
。
const { nowapiConfig } = require("./config.js");
async function getGoldPrice() {
const result = await axios.get(
`https://sapi.k780.com/?app=finance.gold_price&goldid=1053&appkey=${nowapiConfig.AppKey}&sign=${nowapiConfig.Sign}&format=json`
);
return result.data.result.dtList["1053"];
}
获取到的数据如下:
2、获取消息提醒阈值
(1)连接数据库
使用填写好的数据库配置信息连接数据库
const mysql = require("mysql");
const { dbConfig } = require("./config.js");
const connection = mysql.createConnection(dbConfig);
function connectDatabase() {
return new Promise((resolve) => {
connection.connect((error) => {
if (error) throw error;
console.log("成功连接数据库!");
resolve("成功连接数据库!");
});
});
}
(2)查询数据
function mysqlQuery(sqlStr) {
return new Promise((resolve) => {
connection.query(sqlStr, (error, results) => {
if (error) throw error;
resolve(results);
});
});
}
async function getMessage() {
const sqlStr =
"select * from t_message where isShow = 1 and isActive = 1 and type = '金价监控';";
const res = await mysqlQuery(sqlStr);
return { ...res[0] };
}
获取到的数据如下:
3、发送提醒邮件
(1)创建邮件传输对象
使用填写好的邮箱配置信息,创建邮件传输对象
const nodemailer = require("nodemailer");
const { mail } = require("./config.js");
const smtpTransport = nodemailer.createTransport(mail);
const sendMail = (options) => {
return new Promise((resolve) => {
const mailOptions = {
from: mail.auth.user,
...options,
};
// 发送邮件
smtpTransport.sendMail(mailOptions, function (error, response) {
if (error) {
console.error("发送邮件失败:", error);
} else {
console.log("邮件发送成功");
}
smtpTransport.close(); // 发送完成关闭连接池
resolve(true);
});
});
};
module.exports = sendMail;
(2)阈值判断
判断获取到的金价信息是否超出阈值范围来决定是否发送邮件提醒
async function mail(messageInfo, goldInfo) {
let { minVal = -Infinity, maxVal = Infinity } = messageInfo;
let { buy_price } = goldInfo;
minVal = parseFloat(minVal);
maxVal = parseFloat(maxVal);
buy_price = parseFloat(buy_price);
if (minVal < buy_price && maxVal > buy_price) {
return;
}
const mailOptions = {
to: messageInfo.mail.replaceAll("、", ","), // 接收人列表,多人用','隔开
subject: "金价监控",
text: `当前金价为${buy_price.toFixed(2)}`,
};
await sendMail(mailOptions);
}
五、定时执行脚本
可以使用corn
编写一个定时任务来定时执行脚本即可。
* * * * * *
分别对应:秒、分钟、小时、日、月、星期。- 每个字段可以是具体的值、范围、通配符(*表示每一个)或一些特殊的表达式。
例如:
0 0 * * *:每天午夜 0 点执行。
0 30 9 * * 1-5:周一到周五上午 9:30 执行。
你可以根据自己的需求设置合适的 cron 表达式来定时执行特定的任务。
六、效果展示
如果金价不在我们设置的阈值内时,我们就会收到邮件告知当前金价:
七、脚本使用
1、源码下载
git clone https://gitee.com/zheng_yongtao/node-scripting-tool.git
- 源码已经上传到gitee仓库
- 具体目录如下:
2、依赖下载
npm install
3、配置数据填写
这里的配置信息需要修改为你自己的信息,数据库、gitee仓库、nowapi配置。
4、脚本运行
node index.js
更多脚本
该脚本仓库里还有很多有趣的脚本工具,有兴趣的也可以看看其他的:gitee.com/zheng_yongt…
🌟觉得有帮助的可以点个star~
🖊有什么问题或错误可以指出,欢迎pr~
📬有什么想要实现的工具或想法可以联系我~
公众号
关注公众号『前端也能这么有趣』,获取更多有趣内容。
说在后面
🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『
前端也能这么有趣
』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。
来源:juejin.cn/post/7437006854122815497
程序员设计不出精美的 UI 界面?让 V0 来帮你
大家好,我是双越,也是 wangEditor 作者。
今年我致力于开发一个 Node 全栈 AIGC 知识库 划水AI,包括 AI 写作、多人协同编辑。复杂业务,真实上线,大家可以去注册试用。
本文分享一下前端实用的 AI 工具 v0.dev 以及我在 划水AI 中的实际应用经验,非常推荐这款工具。
不同 AI 工具写代码
ChatGPT 不好直接写代码
去年 ChatGPT 发布,但它一直是一个聊天工具,直接让它来写代码,用一问一答的形式,体验其实并不是非常友好。
可以让它来生成一些单一的代码或工具,例如 生成一个 nodejs 发送 Email 的函数
。然后我们把生成的代码复制粘贴过来,自己调整一下。
它可以作为一个导师或助理,指导你如何写代码,但它没法直接帮你写,尤其是在一个项目环境中。
PS. 这里只是说 ChatGPT 这种问答方式不适合直接写代码,但 ChatGPT 背后的 LLM 却未后面各种 AI 写代码工具提供了支持。
Cursor 非专业程序员
Cursor 其实去年我就试用过,它算是 AI 工具 + VSCode ,付费试用。没办法,AI 接口服务现在都是收费的。
前段时间 Cursor 突然在社区中很火爆,国内外都有看过它的宣传资料,我记得看过一个国外的 8 岁小女孩,用 Cursor 写 AI 聊天工具的视频,非常有意思,我全程看完了。
Cursor 可能会更加针对于非专业编程人员,去做一些简单的 demo ,主要体验编程的逻辑和过程,不用关心其中的 bug 。
例如,对于公司的 PM UI 人员,或者创业公司的老板。它真的可以产生价值,所以它也可以收费。
Copilot 针对专业程序员
我们是专业程序员,我更加推荐 Copilot ,直接在 vscode 安装插件即可。
我一直在使用 Copilot ,而且我现在都感觉自己有点依赖它了,每次写代码的时候都会停顿下来等待它帮我生成。
在一些比较明确的问题上,它的生成是非常精准的,可以大大节省人力,提高效率。
如果你遇到 Copilot 收费的问题,可以试试 Amazon CodeWhisper ,同样的功能,目前是免费的,未来不知道是否收费。
UI 很重要!!!
对于一个前端人员,有 UI 设计稿让他去还原开发这并不难,但你让他从 0 设计一个精美的 UI 页面,这有点困难。别说精美,能做到 UI 的基本美观就已经很不容易了。
举个例子,这是我偶遇一个笔记软件,这个 UI 真的是一言难尽:左上角无端的空白,左侧不对齐,icon 间距过大,字号不统一,tab 间距过小 …… 这种比较随性的 UI 设计,让人看了就没有任何试用的欲望。
可以在对比看一下 划水AI 的 UI 界面,看颜色、字号、艰巨、icon 等这些基础的 UI ,会否更加舒适一些?专业一些?
PS. 无意攻击谁(所以打了马赛克),只是做一个对比,强调 UI 的重要性。
V0 专业生成 UI 代码
V0 也是专业写代码的,不过它更加专注于一个方向 —— 生成 UI 代码 ,能做到基本的美观、舒适、甚至专业。
给一个指令 a home page like notion.com
生成了右侧的 UI 界面,我觉得已经非常不错了。要让我自己设计,我可设计不出来。
这一点对于很多人来说都是极具价值的,例如中小公司、创业公司的前端人员,他们负责开发 UI 但是没有专业的 UI 设计师,或者说他们开发的是一些 toB 的产品,也不需要招聘一个专职的 UI 设计师。
你可以直接拷贝 React 代码,也可以使用 npx
命令一键将代码转移到你自己的项目中。
它甚至还会考虑到响应式布局和黑白主题,这一点很惊艳
再让 V0 生成一个登录页,看看能做到啥效果。在首页输入指令 A login form like Github login page
等待 1-2 分钟,生成了如下效果,我个人还是挺满意的。如果让我自己写,我还得去翻阅一些 UI 组件库文档,看 form 表单怎么写,怎么对齐,宽度多少合适 …… 光写 UI 也得搞半天。
划水AI 中“我的首页” 就是 V0 生成的,虽然这个页面很简洁,但是我个人对 UI 要求很高,没有工具帮助,我无法短时间做到满意。
最后
任何行业和领域,看它是否成熟、是否能发展壮大,一个很重要的特点就是:是否有庞大的细分领域。例如现代医学、现代制造业、计算机领域…… 专业细分及其周密,大家各司其职,整个领域才能欣欣向荣。
AI 领域也是一样,AI 编程将是一个细分领域,再往下还有更多细分领域,像针对 UI 的、针对数据库的、针对云服务的,未来会有更多这方面的发展。
来源:juejin.cn/post/7438647233219903542
用 vue 给女主播写了个工具,榜一大哥爱上了她,她爱上了我
用 vue 写了个直播助手,榜一大哥爱上了她,她爱上了我
这是一个什么样的程序?这是一个使用 sys-shim/vue3/vite 开发的一个 windows 程序。用于向网站注入自己的代码以实现一些自动化功能。
sys-shim 是什么?它是一个我开发的个人工具,力求前端人员无需了解其他语言的情况下快速制作轻量的 windows 程序,详情请移步 electron 和 tauri 都不想用,那就自己写个想用的吧 。
为什么要开发这样的程序
虽然已经过去了很久,但那天的场景还是历历在目。
那天是在周五晚上 23 点过,大楼的中央空调都关了,我搓了搓手,看着还未完成的工作,想了想再不回去公车就没了,到家的话饭店也关门了。
然后按了一下显示器的电源按钮,让电脑继续工作着,准备回家吃饭后远程继续工作。
在大楼电梯遇到一个长得很挺好看的女生,由于这一层我们公司,然后看样子好像是直播部门的同事,虽然平时也都不怎么遇见,更没什么交集,只是公司偶尔让大家去主播间刷下人气,有点印象,猜想应该是直播部门的吧啦吧啦**蕾
。
虽然是同事,却不熟悉,想打个招呼都不知道说啥,有点尴尬。然后我索性无所是事刷微信列表去了,虽然微信列表里一条消息也没有。。。
突然她给我来了句:“小哥哥你是我们公司的吧,你们平时下班都这么晚的吗?”一边哈气搓手。
我礼貌性笑了一下:“嗯,不是每天都这么晚。”,然后继续低头无所是事的刷微信列表。
大约一两秒之后,她说:“哦”。然后再停顿一会,好像又找到了什么话题:“那你们最近工作也很忙吗?但是我前两几天也没基本没遇到我们公司这么晚下班的人”。
这句话听起来好像传达了很多信息。但时间可不允许我慢慢思考每个信息应该如何正确应对,就像领导给的项目开发时间根据来不及完善好每一个细节一样。
我只能粗略回答:“没有特别忙,只是有时候我喜欢弄些小工具啥的,一不小心就已很晚了”。我心里想:感觉有点像面试,没有说公司不好,又显得自己爱学习,是一个能沉浸于思考的人,应该没问题吧。
“真好,看得出来你还十分热爱你这份职业。能够愿意花自己的时候去研究它们。”听语气好像是有一点羡慕我,感觉还有一点就是她不太喜欢她现在的工作。是呀,我也经常在想,做直播的那些人,有多少是喜欢整蛊自己,取悦别人,有多少是喜欢见人就哥哥好,哥哥帅,哥哥真的好可爱?
“只是觉得,能有一些工具,能帮助减少些重复劳动,就挺好”。
“对对对,小哥哥你说得太对了,就是因为有工具,减少了很多像机器一样的工作,人才可以去做更多有意义的,不像是机器那样的事情。”
当她说这句话的时候,我想,有一种不知道是不是错觉的错觉,真是个有想法的人!还有,这难道是在夸我们这些做工具的人吗?但是她说这句时的微笑,一下子就让人感到了她的热情和礼貌。
我心想,竟然这么有亲和力,很想有愿意继续沟通的想法。对!不然人家怎么能做主播?要换我上去做主播,绝对场也冷了,人也散了。
我一边告诉自己,因为能做主播,所以她本身就很有亲和力,所以你感觉她很热情,是因为这个热情是固有属性,并不是对于你热情。
一边竟开始好奇,这么漂亮又有亲和力的妹子,谁的下属?忍心让她上班这么晚?谁的女朋友?忍心让她上班这么晚?
好奇心害死猫。我竟然还是问出那句话:
“为什么你这么晚才下班呢?”
“最近销售量有点下滑,我想保住我销售额前一至少前二名的位置。”听到这句话的时候,我有点惊讶。我靠,居然是销冠。然后我不温不火的说到:“是啊,快过年了,得拿年终奖。”
“不是,就是想着让成绩保持着,马上快一年了。”尴尬,人家只是想保持成绩。是我肤浅了。等等!保持快一年?没记错的话她好像也才在公司直播一年不到吧!这就是传说中的入职即巅峰吗?我突然觉得我好菜!好想快点自觉走开,奈何地铁还没到!
“原来是销冠,这么厉害!我以为是年底为了冲年终奖,是我肤浅了~”我简单表达一下敬意和歉意。有颜值有能力,突然人与人之间的距离一下就拉开了。
“没有没有!钱也重要,钱也重要!”她噗呲一笑。然后用期盼的眼神看着我,“对了,你喜欢研究小工具来着,你有没有知道哪种可以在直播时做一些辅助的小工具?我网上找了好多,都是只能用在抖音斗鱼这些大公司的辅助工具,我们公司的这个直播平台的一直没有找到。哎呀!好烦~”
完犊子了,这题我不会。看着她好像是工具花了很久没有找到,焦急得好像就要跺脚的样子,我只感觉头皮发麻,要掉头发了!怎么办?怎么办?哪里有这种工具?
但话题还是要接着啊!我开始答非所问:“到没关注过这方面的小工具,但我听说现在有些自动直播的工具,可以克隆人像和声音二十四小时直播。”
“不需要不需要,我不需要这么高端的工具,而且那些自动播的很缺少粉丝互动的。我只要可以帮我定时上下架商品啥的就好。”
我心想,这不搞个脚本 setInterval 一下就行?虽然但是要做得方便他们使用的形式还是得折腾下。我们这个直播平台又不是大平台,网上肯定也没有现成的,不过要做一个没什么难度。
我回答说:“那我帮你找找。”
“谢谢谢谢小哥哥!你人真好!”看着她一边开心的笑着一边双手拜托的样子,我既感觉完犊子了入坑了,又恨不得现在就给她做一个出来!
车来了。她转头看了一下,然后又转过头来问我“小哥哥可以加下你微信吗?你有消息的话随随时通知我,我都在的。”
我:“行的。”
她:“我加你我加你~”
我竟然一下子没有找到我的微信二维码名片在哪,确实,从来就没有其他女生加过我,没什么经验倒也正常,是吧?她又转头看了看停车的车,我知道她是她的车,可她还告诉我没事的慢慢来。
她加上了我的微信,然后蹦上滴滴滴快要关门的列车,在窗口笑着向我挥手告别。在转角那一刻她指了指手机,示意我看微信。
“我叫李蕾^_^”。
“收到”。
功能设计
在上一节为什么要开发这样的程序
花费了一定量的与技术无关的笔墨,可能有些读者会反感。对此我表示:你以为呢?接到一个项目哪那么容易?手动狗头。
在功能方面,主要考虑以下特性:
开发时方便
不方便肯定会影响开发进度啦。热更新热部署啥的,如果没有这些开发体验那可是一点都不快乐~
使用时点开就能用
解压、下一步下一步安装了都不要了。
多设备下多平台下多配置支持
如果不做设备隔离,万一主播把这软件发给别人用,岂不是乱套了。多平台的考虑是因为反正都是注入脚本,就统一实现。多配置主要实现每个配置是不同的浏览器配置和数据隔离。
便于更新
减少文件发来发去、版本混乱等问题。
便于风控
如果改天主播说这软件不想用了,那我远程关闭就行。
看下总体界面
一个设备支持多个主配置
,每个主配置可以绑定密钥
进行验证。
主配置验证通过之后,才是平台配置
,平台配置表示系统已支持自动化的平台,例如疼训
、筷手
这些平台。这些每个平台对应自己的 logo、自动化脚本文件和状态。
自动化脚本文件在开发过程中可以修改,用户侧不可见,直接使用即可。
每个平台下有多个配置,比如疼训
这个平台下,创建配置A作为账号A的自动化程序,配置B作为账号B的自动化程序。因为每个配置启动的是不同的浏览器实例,所以疼训
理论上不会认为多个账号在同一浏览器下交叉使用。反正我司的平台肯定不会认为~
然后配置下有一些通用的功能:例如智能客服
可以按关键字进行文字或语音回复。
例如假设你配置了一个关键字列表为
keys = [`小*姐姐`, `漂亮`]
reply = [`谢谢大哥`, `大哥你好呀`, `你也好帅`]
当你进入直播间,发了一句小*姐姐真漂亮
时,就可能会自动收到小*姐姐的语音谢谢大哥, 你也好帅
。
在场控助手这边,根据场控
需求,直播间可以按指定规则进行自动发言,自动高亮评论(就是某个评论或系统设定的内容以很抢眼的形式展示在屏幕上),这是防止直播间被粉丝门把话题逐渐带偏的操作方法之一。
商品助手这边,有一些按指定规则、时间或顺序等配置展示商品的功能。
技术选型
- 使用 vue3/vite 进行界面开发。这部分众所周知是热更新的,并且可以在浏览器中进行调试。
- 使用 sys-shim 提供的 js api 进行浏览器窗口创建、读写操作系统的文件。当创建浏览器窗口后,需要关闭窗口。
- 使用 mockm 进行接口开发,自动实现多设备、平台、配置的 crud 逻辑支持。
在 vue3 进行界面开发的过程中,这个过程可以在浏览器里面也可以 sys-shim 的 app 容器中。因为界面与 sys-shim 进行通信主要是通过 websocket 。前端调用某个函数,例如打开计算器,然后这个函数在内部构造成 websocket 的消息体传给 sys-shim 去调用操作系统的 api 打开计算器。就像是前端调用后端提供的 api 让后端调用数据库查询数据,返回数据给前端。
在界面完成之后,把界面部署在服务器上,这样如果有更新的话,就像普通的前端项目一样上传 dist 中内容在服务器上即可。发给主播的 app 读取服务器内容进行界面展示和功能调用。
计划安排
- 周五加加班,用两小时完成数据模型、API实现
- 周六完成主要功能界面、交互开发
- 周日上午进行体验完善、发布测试
开发过程
由于我只是个做前端的,并且只是个实习生。所以用到的技术都很简单,下面是具体实现:
数据模型、API实现
由于是多设备、多平台、多配置,所以数据模型如下:
const db = util.libObj.mockjs.mock({
// 设备
'device|3-5': [
{
'id|+1': 1,
电脑名: `@cname`,
},
],
// 主配置
'config|10': [
{
'id|+1': 1,
deviceId() {
const max = 3
return this.id % max || 3
},
名称: `@ctitle`,
卡密: `@uuid`,
激活时间: `@date`,
过期时间: `@date`,
},
],
// 平台
platform: [
{
id: 1,
封面: [
{
label: `@ctitle`,
value: `@image().jpg`,
},
],
网址: `https://example.com/`,
状态: `可使用`,
脚本文件: [
{
label: `@ctitle().js`,
value: `@url().js`,
},
],
名称: `豆印`,
},
],
'devicePlatformConfig|1-3': [
{
'id|+1': 1,
名称: `默认`,
deviceId() {
const max = 3
return this.id % max || 3
},
platformId() {
const max = 3
return this.id % max || 3
},
configId() {
const max = 3
return this.id % max || 3
},
数据目录() {
return `data/${this.id}`
},
// 功能配置
action: {
智能客服: {
文字回复: {
频率: `@integer(1, 5)-@integer(6, 10)`,
启用: `@boolean`,
'配置|1-5': [
{
关键词: `@ctitle`,
回复: `@ctitle`,
},
],
},
// ... 省略更多配置
},
// ... 省略更多配置
},
},
],
}),
观察上面的数据模型, 例如主配置中有一个 deviceId
,由于这个字段是以驼峰后缀的 Id
结尾,所以会自动与 device
表进行关联。
platform
这张表由于没有与其他表有关联关系,所以无需添加含有 ...Id
的字段。
devicePlatformConfig
平台设备配置这张表,是某设备创建的针对于某一主配置下的某平台下的某一配置,所以会有 deviceId / platformId / configId
。
这样如何要查某设备下的所有平台的配置,直接 /devicePlatformConfig?deviceId=查某设备ID
即可。
由于上面这些表声明关联关系之后,模拟数据和接口都是自动生成的,所以这一块并没有其他步骤。
在 api 层面,有一个需要处理的小地方,就是类似于登录(token/用户标识)的功能。由于这个程序并不需要登录功能,所以使用设备ID作为用户标记。
const api = {
async 'use /'(req, res, next) {
// 不用自动注入用户信息的接口, 一般是系统接口, 例如公用字典
const publicList = [`/platform`]
const defaultObj =
!publicList.includes(req.path) &&
Object.entries({ ...req.headers }).reduce((acc, [key, value]) => {
const [, name] = key.match(/^default-(.*)$/) || []
if (name) {
const k2 = name.replace(/-([a-z])/g, (match, group) => group.toUpperCase())
acc[k2] = value
}
return acc
}, {})
if (req.method !== `GET`) {
req.body = {
...defaultObj,
...req.body,
}
}
req.query = {
...defaultObj,
...req.query,
}
next()
},
}
在后端 api 入口上,我们添加了一个拦截器,含有 default-
开头的键会被当成接口的默认参数。如果传设备 id 就相当于给每个接口带上设备标记,后面这个设备创建和修改、查询配置都会被限定在改设备下,实现了类似某用户只能或修改查看某用户的数据的功能
。对于之前提到的公用数据,例如 /platform
这个接口的数据是所有用户都能看到,那直接配置到上面的 publicList
中即可。
前端的请求拦截器是这样的:
http.interceptors.request.use(
(options) => {
options.headers[`default-device-id`] = globalThis.userId
return options
},
(error) => {
Promise.reject(error)
},
)
什么?并不严谨?啊对对对!
界面实现:首先做一个浏览器
由于只会一些简单的跑在浏览器里的 js/css ,所以我们要先做一个浏览器来显示我们的软件界面。
经常用 google chrome,用习惯了,并且听说它还不错。所以打算做一个和它差不多的浏览器。
它封装了 chromium 作为内核,那我们也封装 chromium 吧。
微软听说大家都想要做个基于 chromium 的的界面渲染程序,于是微软就给我们做好了,叫 microsoft-edge/webview2 。
听说大家都在用这个渲染引擎,那么微软干脆把它内置于操作系统中,目前 win10/win11 都有,win7/8 也可以在程序内自动在线安装或引用安装程序离线安装。
不知不觉的浏览器就做好了。
如何使用这个做好的浏览器
由于只会 js ,所以目前我使用 js 创建这个 webview 实例是这样的:
const hwnd = await hook.openUrl({
url: platformInfo.value.网址,
preloadScript,
userDataDir: row.数据目录 || `default`,
})
可以看到,上面的 js 方法支持传入一个网址、预加载脚本和数据目录。
在这个方法的内部,我们通过构造一个 aardio 代码片段来创建 winform 窗口嵌入 webview 实例。
至于要构造什么 aardio 片段,是 aardio 已经做好相关示例了。复制粘贴就能跑,需要传参的地方,底层是使用 ipc 或 rpc 进行通信的。
ipc 是进程之前通知,可以简单的理解为一个基于事件的发布订阅程序。
rpc 是远程调用,可以简单理解为我们前端经常调用的 api。服务端封装好的 api,暴露给前端,直接调用就好了。
aardio示意片段
var winform = win.form({text: `sys-shim-app`}) // 创建一个 windows 窗口
var wbPage = web.view(winform, arg.userDataDir, arg.browserArguments) // 使用指定配置启动一个浏览器示例
wbPage.external = { // 向浏览器注入全局变量
wsUrl: global.G.wsUrl;
}
wbPage.preloadScript(arg.preloadScript) // 向浏览器注入 js 脚本
wbPage.go(arg.url) // 使用创建的浏览器打开指定 url
winform.show() // 显示窗口
有了上面的代码,已经可以做很多事情了。因为向浏览器注入了一个全局变量 wsUrl,这是服务端的接口地址。然后在注入的脚本里去连接这个接口地址。
脚本由于是先于 url 被加载的,所以首先可以对页面上的 fetch 或者页面元素这些进行监听,实现拦截或代理。另外 webview 也提供了 cdp 层面实现的数据监听。
功能实现:让宿主与实现分离
这里的宿主是指除开 注入自定义脚本
的所有功能。根据之前的设计,网站地址是用户配置的,脚本也是用户上传的。所以一切都是用户行为,与平台无关?
啊对对对就这样!
把自动化这块功能分离出去,让其他人写(我不会!手动狗头)。然后我们在程序里为现有功能做一个事件发布。当用户开启了某个功能,脚本可以知道,并可以得到对应配置的值,然后去做对应功能的事。
const keyList = Object.keys(flatObj(getBase()))
keyList.forEach((key) => {
watch(
() => {
return deepGet(devicePlatformConfig.value, key)
},
(newVal, oldVal) => {
keyByValueUpdate(key, newVal, oldVal)
},
{
immediate: true,
},
)
})
getBase 是一个配置的基础结构对象。把这个对象扁平化,就能等到每个对象的 key,使用 vue 的 watch 监听每个 key 的变化,变化后分别发布 [key, 当前值, 占值, 整个配置对象]
。
这样在自动化脚本那边只需要订阅一下他关心的 key 即可。
例如:当 场控助手.直播间发言.频率
从 2 变成 6 。
ws.on(`action.场控助手.直播间发言.频率`, (...arg) => {
console.log(`变化了`, ...arg)
})
好了,接下来的内容就是在群里 v50 找人写写 js 模拟事件点击、dom监听啥的了(具体自动化脚本略,你懂的~手动狗头)。
测试过程
总算赶在了周一完成了功能,终于可以进行测试啦~
她同事进行功能测试的时候,提出了一些修改意见(还好是自己写的,不然真改不动一点),然后有个比较折腾的是原来我的配置窗口和平台直播页面是分别在不同的 windows 窗口下的,可以相互独立进行拖拽、最小化等控制,因为想着独立开来的话配置窗口就不会挡住直播页面的窗口了。
没想到她希望配置窗口可以悬浮在直播平台的页面上,并且可以展开折叠拖动。这对于之前设计的架构有一些差异,修改花了点时间。
最终结果
我很满意,手动狗头。
相关内容
- 源码地址: https://github.com/wll8/sys-shim/tree/main-open/store/demo/live-control-robot
- sys-shim 文档: https://wll8.github.io/sys-shim-doc/
- 视频(发布在的bilibili上,首次发视频,支持一下?)
- 安装、运行、开发 https://www.bilibili.com/video/BV1E5qbY2EbX/
- 打包、发布 https://www.bilibili.com/video/BV1J5qbY2EVX/
- 运行打包后的程序 https://www.bilibili.com/video/BV1jJqbYVEZx/
声明:本文仅作为 sys-shim 的程序开发技术交流,本人没有也不提供可以自动化操作某直播平台的脚本。
来源:juejin.cn/post/7448951076685119529
BOE(京东方)“向新2025”年终媒体智享会首站落地上海 六大维度创新开启产业发展新篇章
12月17日,BOE(京东方)以“向新2025”为主题的年终媒体智享会在上海启动。正值BOE(京东方)新三十年的开局之年,活动全面回顾了2024年BOE(京东方)在各领域所取得的领先成果,深度解读了六大维度的“向新”发展格局,同时详细剖析了BOE(京东方)在智能制造领域的领先实践。BOE(京东方)执行委员会委员、副总裁贠向南,BOE(京东方)副总裁、首席品牌官司达出席活动并发表主旨演讲。
经过三十年创新发展,秉持着对技术的尊重和对创新的坚持,在“屏之物联”战略指导下,BOE(京东方)从半导体显示领域当之无愧的领军巨擘迅速蝶变,成功转型为全球瞩目的物联网创新企业,并不断引领行业发展风潮。面对下一发展周期,BOE(京东方)将从战略、技术、应用、生态、模式、ESG六大方面全方位“向新”突破,以实现全面跃迁,并为产业高质发展注入强劲动力。
战略向新:自2021年“屏之物联”战略重磅发布以来,BOE(京东方)又于2024年京东方全球创新伙伴大会(BOE IPC·2024)上发布了基于“屏之物联”战略升维的“第N曲线”理论,以半导体显示技术、玻璃基加工、大规模集成智能制造三大核心优势为基础,精准布局玻璃基封装、钙钛矿光伏器件等前沿新兴领域,全力塑造业务增长新赛道。目前,玻璃基封装领域,BOE(京东方)已布局试验线,成立了玻璃基先进封装项目组,实现样机产出;钙钛矿领域,仅用38天就已成功产出行业首片2.4×1.2m中试线样品,标志着钙钛矿产业化迈出了重要一步。
技术向新:2021年,BOE(京东方 )发布了中国半导体显示领域首个技术品牌,开创了产业“技术+品牌”双价值驱动的新纪元。以技术品牌为着力点,BOE(京东方)深入赋能超5000家全球顶尖品牌厂商和生态合作伙伴,包括AOC、ROG、创维、华硕、机械师、雷神、联想等,助力行业向高价值增长的路径迈进,也为用户提供了众多行业领先、首发的更优选择。 BOE(京东方)还将全力深化人工智能与半导体显示技术以及产业发展的深度融合,并在AI+产品、AI+制造、AI+运营三大关键领域持续深耕,并依托半导体显示、物联网创新、传感器件三大技术策源地建设,与产业伙伴和产学研合作伙伴共同创新,为产业高质量可持续发展保驾护航。
应用向新: BOE(京东方)不仅是半导体显示领域的领军企业,也是应用场景创新领域的领跑者,BOE(京东方)秉持“屏之物联”战略,以全面领先的显示技术为基础,通过极致惊艳的显示效果、颠覆性的形态创新,为智慧座舱、电竞、视觉艺术、户外地标等场景注入了新鲜血液,带给用户更加美好智慧的使用体验。以智慧座舱为例,根据市场调研机构Omdia最新数据显示,2024年前三季度京东方车载显示出货量及出货面积持续保持全球第一,在此基础上BOE(京东方)还推出“HERO”车载场景创新计划,进一步描绘智能化时代汽车座舱蓝图。
生态向新: BOE(京东方)持续深化与电视、手机、显示器、汽车等众多品牌伙伴的合作,共同打造“Powered by BOE”产业生态集群,赢得众多客户的认可与赞誉。与此同时,BOE(京东方)还持续拓展跨产业生态,通过与上海电影集团、故宫博物院、微博等文化产业领先机构展开跨界合作,以创新技术赋能传统文化艺术与影像艺术。此外,通过战略直投、产业链基金等股权投资方式协同众多生态合作伙伴,通过协同合作、资源聚合共同构筑产业生态发展圈层。
模式向新: 为适配公司国际化、市场化、专业化的长远发展,BOE(京东方)持续深化“1+4+N+生态链”的业务发展架构,以及“三横三纵”组织架构和运营机制。在充分市场化和充分授权的机制保障下,形成了以半导体显示核心业务为牵引,传感、物联网创新、MLED业务、智慧医工四大高潜航道全面开花,聚焦包括智慧车联、工业互联、数字艺术、3D光场等规模化应用场景,生态链确保产业上下游合作伙伴协同跃迁的“万马奔腾”的发展图景。此外,BOE(京东方)还鼓励员工创新创业,通过激发人才创新热情,共同为集团发展注入强劲内生动力。
ESG向新: 2024年,BOE(京东方)承诺将在2050年实现自身运营碳中和,并通过坚持“Green+”、“Innovation+”、“Community+”可持续发展理念,推动全球显示产业高质永续发展。“Green+”方面,BOE(京东方)依托 16 家国家级绿色工厂、1 座灯塔工厂及1座零碳工厂,以绿色产品、制造与运营践行低碳路径;“Innovation+”方面,BOE(京东方)凭借全部为自主创新的9万件专利的行业佳绩,以及技术策源地、技术公益池等举措,携手产业上下游伙伴协同创新;“Community+”方面,BOE(京东方)在教育、医疗、环境等公益领域持续投入,积极履行社会责任,例如,在“照亮成长路”公益项目中,BOE(京东方)十年间在偏远地区建设的智慧教室已经突破120所。
BOE(京东方)智能制造:铸就行业新典范
BOE(京东方)智能制造在引领标准、数字化变革、AI+制造和可持续发展四个方面,树立了全球智能制造卓越标杆,并引领产业迈向智能化、绿色化新时代。在引领标准方面,BOE(京东方)已建立起遍布全球的智能制造体系,包括18条半导体显示生产线和6大全球智能终端服务平台,并荣膺全球智能制造最高荣誉——世界经济论坛“灯塔工厂”。为应对布局全球的产供销业务体系,BOE(京东方)已构建起设供产销集成管理系统,可实现业财一体的全生命周期智能决策;在数字化变革方面,BOE(京东方)正致力于打造“一个、数字化、可视的京东方”,包括流程、组织、IT、数据四大管理要素,通过建立一个基于流程的、端到端的、高效的数字化管理体系为智能制造赋能;在AI+制造方面,通过系统化运用AI、大数据等技术,BOE(京东方)结合生产制造痛点难点问题,聚焦效率领先、品质卓越,务实高效地执行AI+制造规划。在品质把控方面,BOE(京东方)打造的IDM质检平台(Intelligence defect management)是面向业务人员开发的系统,功能覆盖工业质检全场景及AI建模全流程,引入大模型标注、判定技术,打通场景、工序、代际壁垒,极大提升了人机协同下的复判效率和判定准确率,在确保产品高质量的同时实现成本的有效控制;在可持续发展方面,BOE(京东方)始终秉承“以绿色科技推动生产发展理念”,旗下16家工厂获得国家级“绿色工厂”称号,以绿色制造助力产业可持续升维发展。
“向新2025”年终媒体智享会,是BOE(京东方)2024创新营销的收官之作和全新实践,系统深化了大众对BOE(京东方)品牌和技术创新实力的认知与理解。近年来,BOE(京东方)通过多种创意独具的品牌破圈推广,包括“你好BOE”系列品牌线下活动、技术科普综艺《BOE解忧实验室》等生动鲜活地传递出BOE(京东方)以创新科技赋能美好生活的理念,为企业业务增长提供了强大动力,也为科技企业品牌推广打造了全新范式。据了解,BOE(京东方)该“向新2025”主题系列活动还将于12月20日和12月27日分别落地成都和深圳。
面向未来,BOE(京东方)将胸怀“Best on Earth”宏伟愿景,坚持“屏之物联”战略引领,持续推动显示技术和物联网、AI等前沿技术的深度融合。从提升产品视觉体验到优化产业生态协同,从升级智能制造体系到践行社会责任担当,BOE(京东方)将砥砺奋进、创新不辍,为全球用户呈献超凡科技体验,领航全球产业创新发展的新篇章。
收起阅读 »BOE(京东方)北京京东方医院主体结构开工 打造医工融合创新典范
12月12日,BOE(京东方)旗下北京京东方医院主体结构正式开工。北京京东方医院是2024年北京市“3个100”重点工程项目,定位为BOE(京东方)智慧物联网医院总院,位于房山区京东方生命科技产业基地,总占地面积约152亩, 总床位1500张,其中一期建设1000床,预计2026年建成开诊。北京京东方医院的建设将打造分级诊疗体系区域样板,为大众提供优质便捷高效的医疗服务,同时积极构建医工融合的产业集群,为区域医疗产业的智慧转型注入强劲动力。
京东方科技集团党委书记、副董事长冯强在致辞中表示,智慧医工业务作为BOE(京东方)在大健康领域的重要战略布局,历经十年发展,形成了以健康管理为核心,医工产品为牵引、数字医院为支撑的健康物联生态系统,并按照医工融合的发展理念,在房山布局了集“医教研产用”于一体的生命科技产业基地,北京京东方医院正是基地的核心支撑平台。医院以三级综合医院为基础,结合BOE(京东方)在显示、物联网、智慧医工等方面的核心优势,按照“补缺、合作、差异化”的原则,着力打造“技术领先、数字驱动、模式创新”的BOE(京东方)智慧物联网医院总院。
北京京东方医院将聚焦重症康复、急诊急救等重点学科,积极引进顶尖技术,推动诊疗技术向精准医学方向发展,做卓越医疗的践行者,并充分利用物联网和人工智能技术,建立数字孪生医院,做智慧医疗的引领者。同时,建立开放创新的平台化体系,汇聚专家资源,建立核心能力,将服务体系延伸到社区和家庭,做新型服务模式的创建者。作为生命科技产业基地的核心支撑,北京京东方医院将持续与全国一流高校、医疗机构、创新企业等有机协同,共同构建“从临床来,到临床去”的创新转化体系,推动生命科技产业新质生产力发展。
多年来,BOE(京东方)不断探索未来医疗健康产业的高潜发展方向,通过科技与医学融合创新,打通了“防治养”的全链条,实现了“医”为“工”提供需求来源和临床转化,“工”为“医”的技术提升持续赋能。目前,BOE(京东方)已在北京、合肥、成都、苏州四地建设并运营5家数字医院。面向未来,BOE(京东方)将坚持以“屏之物联”战略为牵引,积极推动医疗健康产业的智慧转型,以创新驱动和科技赋能开启医工融合发展的崭新篇章。
收起阅读 »狂肝两周,写了一个"本地化"的桌面端工具:超级待办
事情是这样的,上一年写了一个基于uTools的插件,超级待办,目前日活300+,插件累计下载量24000+,有很多用户期望能够开发独立版本且数据可控(在本地保存),所以在插件版的基础上,优化UI,交互,新增相关功能,开发了一个桌面端版本(win+mac)的超级待办。
应用特色:
一、快速记录待办
二、所有数据保存在本地
很多人在记了一两条就放弃了,很难坚持下来,所以先把待办记下来,后续使用拖拽的形式快速修改待办状态,在此场景下,设计了多个快速记录待办的方式:
一、使用快捷键Ctrl+Space快捷键(可自定义)快速呼出应用,自动聚焦输入待办
二、剪切板转待办,应用会记录剪切板记录,你可以查看记录并转为待办
该应用共分为任务主面板、日历、列表、随心记(富文本)、分类、剪切板等模块。
任务主面板模块:
在这里根据完成状态把任务分成未开始,进行中,已完成三个框体,不同状态之间支持拖拽,排序等快速操作;并且根据任务的紧急状态把每个任务分为紧急,高,中,低四个状态,方便标记任务,区分优先级;支持关联随心记,编辑,修改,删除,归档等操作。
日历模块:
在这里根据每个任务的时间,分布在日历视图上,日历包含节假日等信息,能够查看任务分布及完成情况,支持关键字搜索,编辑任务,删除任务等操作。
列表模块:
把任务汇总为列表模式,你可以在这里取消归档,按照任务状态进行筛选,关键字搜索,分类搜索,查看关联的随心记,删除,编辑等操作,并能直观的查看你所记录的待办事项。
随心记:
这里使用了富文本编辑器,支持上传图片,方便记录待办所关联的一些信息,例如:我完成了一个模块的开发,可以把具体的注意事项关联写到随心记里,方便日后查看。不仅如此,你可以记录你想记录的任何文本。
分类:
在这里你可以去维护应用的分类信息,在创建任务及随心记时,可以进行关联,方便归类管理。虽然模块名是分类,但在使用过程中,你可以把分类定义为项目名,日期归总等等。
剪切板:
我们在应用后台,实时记录剪切板内容并保存到本地,你可以在这里把剪切板某个内容转为待办,也可以根据关键词进行搜索,同时可以一键删除所有,删除单个内容等。
设置:
如果你觉得不希望每次都出现很大的框体,只希望在桌面上出现一个还未完成的任务面板并进行操作,你可以在设置中开启待办事项面板,开启后,你可以固定到桌面的任意位置。我们默认保存该面板的位置信息,在你重启电脑后,面板依旧在你设置的位置。
同时你可以在这里设置是否开机自启,是否显示快速添加待办的面板,启动时是否只显示一个输入框还是整个应用,以及呼出应用的快捷键,怎么使用,任你发挥。
为了极少的收集用户信息,我们采用微信绑定许可码(一机一码)形式,当应用安装后,可以按照操作申请试用许可码,每个微信支持绑定多个机器。
在公测期间,一共收获了300个用户,并提了很多宝贵的建议,后续迭代会持续的进行优化更新~
最后,如果你也有记录待办的需求,不妨体验一下呀~
来源:juejin.cn/post/7423583639081664564
真.i18n自动化翻译
背景
懒,不想因为文案的问题复制,所以做一个全自动翻译脚本(插件)
前置
想要的功能
- 开发者无感,不用做任何和翻译有关的工作,开过过程中只需要将文案写到标签中
- 不影响现存的文案
思路
- 通过husky,将脚本写在pre-commit
- 通过git diff,获取发生变动的文件
- 对文件做一层过滤, 只对.vue、js文件中的文案进行翻译
- fs读取发生变动的文件的内容,最好将其解析成ast
- 遍历行数提取待翻译的文本,需要过滤掉注释中的文案,将需要翻译的文案回写到源语言json中
- 将每个文件内容中的文案替换成对应的i18n键值
- 读取源语言json,和目标语言json对比找出需要翻译的文案,文案调用第三方翻译接口对进行翻译(不要用公开的,容易挂)
- 统一将新翻译的文案注入json中
准备
- 一个第三方接口
- npm i husky
- 框架接入i18n
代码
const fs = require('fs')
const crypto = require('crypto')
const path = require('path')
const { execSync } = require('child_process')
const fetch = require('node-fetch').default
const apiUrl = '自己的接口'
const scriptDirectory = __dirname
const projectRootDir = path.resolve(scriptDirectory, '../..')
const sourceFilePath = path.join(projectRootDir, './src/assets/lang/json/zh-CN.json')
const targetFilePath = path.join(projectRootDir, './src/assets/lang/json/en-US.json')
const source = require(sourceFilePath)
const target = require(targetFilePath)
function md5(text) {
return crypto.createHash('md5').update(text).digest('hex')
}
function containsChinese(text) {
return /[\u4e00-\u9fa5]/.test(text)
}
async function translate(data, languageCode) {
let sign = 'bwcode.'
sign += data.map(item => item.fieldName).sort().join('.')
sign = md5(sign)
const bodyRequest = {
sign,
languageCode,
translateList: data,
}
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(bodyRequest),
})
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`)
}
const resJSON = await response.json()
if (resJSON.code !== 0) {
throw new Error(`result error! Status: ${JSON.stringify(resJSON)}`)
}
return resJSON.data.translateList
} catch (error) {
console.error('Error during translation request:', error.message)
}
}
// 暂时只翻译英语,后面有需要再拓展
async function startTranslate(data) {
const res = await translate(data, 'en-US')
// 转成对象
const fileContent = target
res.map(item => {
const key = item.fieldName.split('-')
if (key.length === 2) {
if (!fileContent[key[0]]) {
fileContent[key[0]] = {}
}
fileContent[key[0]][key[1]] = item.translateContent
} else {
fileContent[key[0]] = item.translateContent
}
})
fs.writeFileSync(targetFilePath, JSON.stringify(fileContent, null, 2), 'utf-8')
}
// 获取在 Git 中修改的文件列表
function getModifiedFiles() {
try {
// const result = execSync('git diff --name-only --cached', { encoding: 'utf-8' }) // 获取暂存区的修改
const result = execSync('git diff --name-only', { encoding: 'utf-8' }) // 获取工作区的修改
return result.split('\n').filter(item => item.includes('views'))
} catch (error) {
console.error('Error getting modified files:', error)
return []
}
}
function extractTemplateChinese(node) {
// 在线ast tree解析:https://astexplorer.net/
if (node.children && node.children.length) {
// console.log('node parent ------------------', node)
node.children.forEach(item => {
extractTemplateChinese(item)
})
} else if (containsChinese(node.value)) {
const regex = /[\u4e00-\u9fa5\s]+/
const content = node.value.match(regex)
source[content] = content
const newValue = node.value.replace(regex, match => `{{ $t('${match}') }}`)
node.value = newValue
}
}
function scanAndReplace(fileDirectory) {
const filePath = path.join(projectRootDir, `/${fileDirectory}`)
const pageContent = fs.readFileSync(filePath, 'utf8')
// vue2好像不支持ast,没有相关的ast库,所以还是直接用文本替换吧
// extractTemplateChinese(ast.templateBody)
// 匹配非注释的中文
const translationRegex = /[\u4e00-\u9fa5\s]+/g
// todo: js文件、template标签、scripts标签中的文案替换格式会不一样的
const translateData = pageContent.match(translationRegex)
const replacedPageContent = pageContent.replace(translationRegex, match => `{{ $t('${match}') }}`)
if (translateData && translateData.length) {
// 将替换后的内容写回文件
fs.writeFileSync(filePath, replacedPageContent)
// 记录文案,等所有文件扫描完毕后再回填数据
translateData.forEach(item => { source[item] = item })
}
}
function main() {
console.log('-- start translate --')
// 获取git diff的文件,寻找需要翻译的文案,并将文案提取出来新增到json后,文案替换成i18n格式
// 提取文案的过程最好用ast的方法,否则很难判断哪些中文是需要提取,哪些是注释
// 然而vue2的库太少了,要自己写,后面升级到vue3再完善这个自动提取的过程吧。目前要翻译什么文案还是手动去提取吧
// const modifiedFiles = getModifiedFiles()
// console.log('need translate file:', modifiedFiles)
// modifiedFiles.forEach(item => {
// // 暂时只对.vue文件进行翻译
// if (item.includes('.vue')) scanAndReplace(item)
// })
// 读取json,批量进行翻译(需要过滤掉已经翻译的文案)
const flat = []
// const sourceEntries = Object.entries(source)
// const targetEntries = Object.entries(target)
const translate = {}
for (const key in source) {
if (source.hasOwnProperty(key) && !target.hasOwnProperty(key)) {
translate[key] = source[key]
}
}
const translateEntries = Object.entries(translate)
translateEntries.forEach(([sourceKey, sourceValue]) => {
flat.push({ fieldName: sourceKey, content: sourceValue })
// if (typeof sourceValue === 'object' && sourceValue !== null) {
// const entriesChild = Object.entries(sourceValue)
// entriesChild.forEach(([entriesChildKey, entriesChildValue]) => {
// flat.push({
// fieldName: sourceKey+'-'+entriesChildKey,
// content: entriesChildValue,
// })
// })
// } else {
// flat.push({ fieldName: sourceKey, content: sourceValue })
// }
})
if (flat && flat.length) startTranslate(flat)
else console.log('no translate data')
console.log('-- end translate --')
}
main()
后续
- 因为vue2支持的ast转化库太少了,没找到合适的,需要自己写,懒得写了,所以2~6步跳过,代码上面也有,无非就是递归遍历ast树,替换文案,再转回字符串会写到文件中
- 感觉写成webpack/vite的插件会更好。有空在做吧。
来源:juejin.cn/post/7316357622847782931
跟 Antfu 一起学习 CSS 渐入动画
周末无事,翻阅 Antfu 的博客,发现一篇很有意思的文章,用简单的 CSS animation 动画实现博客文章按照段落渐入,效果如下:
是不是很有意思呢?作为一名前端开发,如果产品给你提出这样的动画需求,你能否实现出来呢?在继续阅读之前,不妨先独立思考一下,如何用 CSS 来完整这种动画。
PS:什么,你问 Antfu 是谁?他可是前端圈里面的偶像级人物:
Antfu 是 Anthony Fu 的昵称,他是一位知名的开源软件开发者,活跃于前端开发社区。Anthony Fu 以其对 Vue.js 生态系统的贡献而著名,包括但不限于 Vite、VueUse 等项目。Antfu 也因为他在 GitHub 上的活跃参与和贡献而受到许多开发者的尊敬和认可。
首先用 CSS 写一个渐入动画,相信这个大家都看得懂:
@keyframes enter {
0% {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
上述代码定义了一个名为 enter 的关键帧动画,其效果使得元素从透明度为0(完全透明)逐渐变为透明度为1(完全不透明),同时元素会在垂直方向上从 10px 以上的位置移动到最终位置。具体来说,关键帧如下:
0%
:动画的起始状态(动画开始时刻)。在这个状态中,元素的透明度opacity
设置为0,表示元素是完全透明的,看不见的。同时,transform: translateY(10px);
属性表示元素在垂直方向上被推移了10px
,即元素的起始位置是它最终位置的上方10px
。
to
或100%
:动画的结束状态(动画结束时刻)。在这个状态中,元素的透明度opacity
设置为1,表示元素完全不透明,完全可见。transform: none;
表示取消了之前的变换效果,元素恢复到它的原始形态和位置。
难道这样就行了吗?当然不行,如果仅仅对内容添加上述动画,效果是文章整体渐入,效果如下:
然而我们想要的效果是一段一段渐入呀,那怎么办呢?思路很简单:
给每个段落分别添加上述动画,然后按照先后顺序延迟播放动画。
[data-animate] {
--stagger: 0;
--delay: 120ms;
--start: 0ms;
animation: enter 0.6s both;
animation-delay: calc(var(--stagger) * var(--delay) + var(--start));
}
上面的关键就是 animation-delay
这个属性,为了方便 HTML 编码,这里使用了 CSS 变量来进行控制,把元素的延迟时间总结到如下的公式里面:
calc(var(--stagger) * var(--delay) + var(--start));
其中变量的含义如下:
--stagger
是段落序号,值为1、2、3...--delay
是上下两个段落的延迟时间间隔--start
是初始延迟时间,即整片文章第一段的延迟偏移量
有了这些变量,就可以按照段落的前后顺序,写出如下 HTML 代码了:
<p style="--stagger: 1" data-animate>Block 1</p>
<p style="--stagger: 2" data-animate>Block 2</p>
<p style="--stagger: 3" data-animate>Block 3</p>
<p style="--stagger: 4" data-animate>Block 4</p>
<p style="--stagger: 5" data-animate>Block 5</p>
<p style="--stagger: 6" data-animate>Block 6</p>
<p style="--stagger: 7" data-animate>Block 7</p>
<p style="--stagger: 8" data-animate>Block 8</p>
实现的效果如下:
可以说相当棒了!但是这里还有个问题,就是 markdown 文章转成 HTML 的时候,不会总是 p
标签吧,也有可能是 div
和 pre
等其他标签,而且你还要手动给这些标签添加 --stagger
变量,这个简直不能忍啊。Antfu 最后给出的解决方案是这样的:
slide-enter-content > * {
--stagger: 0;
--delay: 150ms;
--start: 0ms;
animation: slide-enter 1s both 1;
animation-delay: calc(var(--start) + var(--stagger) * var(--delay));
}
.slide-enter-content > *:nth-child(1) { --stagger: 1; }
.slide-enter-content > *:nth-child(2) { --stagger: 2; }
.slide-enter-content > *:nth-child(3) { --stagger: 3; }
.slide-enter-content > *:nth-child(4) { --stagger: 4; }
.slide-enter-content > *:nth-child(5) { --stagger: 5; }
.slide-enter-content > *:nth-child(6) { --stagger: 6; }
.slide-enter-content > *:nth-child(7) { --stagger: 7; }
.slide-enter-content > *:nth-child(8) { --stagger: 8; }
.slide-enter-content > *:nth-child(9) { --stagger: 9; }
.slide-enter-content > *:nth-child(10) { --stagger: 10; }
.slide-enter-content > *:nth-child(11) { --stagger: 11; }
.slide-enter-content > *:nth-child(12) { --stagger: 12; }
.slide-enter-content > *:nth-child(13) { --stagger: 13; }
.slide-enter-content > *:nth-child(14) { --stagger: 14; }
.slide-enter-content > *:nth-child(15) { --stagger: 15; }
.slide-enter-content > *:nth-child(16) { --stagger: 16; }
.slide-enter-content > *:nth-child(17) { --stagger: 17; }
.slide-enter-content > *:nth-child(18) { --stagger: 18; }
.slide-enter-content > *:nth-child(19) { --stagger: 19; }
.slide-enter-content > *:nth-child(20) { --stagger: 20; }
只要给文章容器增加 slide-enter-content
样式,那么通过 nth-child()
就能为其直接子元素按照顺序设置 stagger
变量啦!
秒啊,实在是妙!不得不佩服大佬的脑洞,不过,杠精的你可能会说,我的文章又不止 20 个子元素,超过 20 怎么办呢?我说哥,你不会自己往后加嘛!
感兴趣的同学可以查看最终的样式代码,跟上述 demo 有一点点区别,相信你能从中学到不少东西,例如 Antfu 把 data-animate
属性关联的样式拆成了两段:
[data-animate] {
--stagger: 0;
--delay: 120ms;
--start: 0ms;
}
@media (prefers-reduced-motion: no-preference) {
[data-animate] {
animation: enter 0.6s both;
animation-delay: calc(var(--stagger) * var(--delay) + var(--start));
}
}
写前端这么多年,我是第一次见到 @media (prefers-reduced-motion: no-preference)
这个媒体查询的用法,一脸懵逼,赶紧恶补了一把才知道:
在 CSS 中,@media 规则用于包含针对不同媒体类型或设备条件的样式。
prefers-reduced-motion
是一个媒体查询的功能,该功能用于检测用户是否有减少动画和动态效果的偏好。一些用户可能对屏幕上的快速或复杂动作敏感,这可能会导致不适或干扰体验,因此他们在操作系统中设置了减少动画的选项。
因此,对于那些讨厌动画的用户,就不用展示这么花哨的效果,直接展示文章就行啦!
来源:juejin.cn/post/7338742634167205900
写了个自动化打包工具,大大滴解放了电脑性能
前段时间手底下的小伙伴跟我吐槽,说后端一点小改动就马上要包,电脑性能很差一旦run build
之后就得等好几分钟的空窗期,被迫摸鱼导致加班,我灵机一动,是不是可以利用服务器的性能,编写自动化构建从而实现让后端、测试点点点,就能得到他们想要的不同版本的包、或者不同分支的构建产物呢?
于是乎就有了我的设计并产出的开源:Sa-io https://github.com/LIAOJIANS/sa-io.git
Sa-io操作流程:新建项目(指定gitURL) => 内部执行(npm install)=> run build => SE(推送Sucesss日志) => publish(指定目标地址)=> dowl (下载专属产物)
项目架构
1、UI层
2、逻辑层
3、数据层
4、所需环境层
核心实现逻辑
1、技术清单
child_process
:创建子进程并执行构建脚本;chokidar
: 监听日志文件内容;scp2
:建立SSH连接并传输文件;Vue3
:UI界面采用VUE3 + TS
2、核心逻辑
Run Build
router.post('/build', [
(() =>
['shell', 'install', 'projectName'].map((fild) =>
body(fild)
.notEmpty()
.withMessage('username or token is null'),
))(),
], (req, res, next) => {
checkBeforRes(next, req, async () => {
const {
shell,
install,
removeNm,
shellContent,
branch,
projectName,
pull,
...onter
} = req.body
if (os.platform() !== 'linux' && shell) {
return new Result(null, 'Running shell scripts must be in a Linux environment!!!')
.fail(res)
}
const curTime = Date.now()
const id = `${projectName}-${curTime}`
const fileName = `${id}.log`
const logPath = path.resolve(__dirname, `../log/${fileName}`)
let status = 'success'
const getHistory = () => getFileContentByName('history', [])
// 生成构建历史
let data = [
...getHistory(),
{
id,
projectName,
buildTime: curTime,
status: '',
branch
}
]
// 生成日志文件
getFileContentByName(
'',
'',
logPath
)
// 写入history基本信息
setFileContentByName(
'history',
data,
true
)
if (removeNm) {
await rmDir(projectName, 'node_modules') // 删除node_modules 防止不同分支不同版本的依赖冲突
rmFile(`${projectName}/package-lock.json`) // 删除安装依赖日志,防止版本缓存
}
if (branch) { // 如果有分支,并且分支不能等于当前分支,否则切换分支并拉取最新
const projects = getFileContentByName('projects')
const project = projects.find(p => p.projectName === projectName)
if (project.branch !== branch) {
try {
if (install) {
rmFile(`${projectName}/package-lock.json`) // 删除安装依赖日志,防止版本缓存
}
await gitCheckoutPro(projectName, branch)
setFileContentByName('projects', [
...projects.map(p => {
if (p.projectName === projectName) {
p.branch = branch
}
return p
})
], true)
} catch (e) {
console.log(e)
setFileContentByName(
'history',
[
...data,
{
projectName,
buildTime: curTime,
status: 'error',
branch
}
],
true
)
res.status(500).send('checkout error!!! Please review the log output!!!!!!')
}
} else if (pull) { // 拉取最新
try {
await gitPullPro(projectName, logPath)
} catch (e) {
res.status(500).send('checkout error!!! Please review the log output!!!!!!')
}
}
}
new Result(`${id}`, 'building, Please review the log output!!!!!!').success(res)
const compressedPro = () => {
status = 'success'
compressed(`${projectName}-${curTime}`, projectName)
console.log('success')
copyFile(
path.resolve(__dirname, `../project/${projectName}/dist`),
path.resolve(__dirname, `../builds/${projectName}-${curTime}`)
)
const {
publish,
...left
} = onter
if (publish) {
publishTragetServer({
...left,
localPath: path.resolve(__dirname, `../builds/${projectName}-${curTime}`)
})
}
}
if (shell) { // 执行sh脚本
setFileContentByName(
projectName,
shellContent,
true,
path.resolve(__dirname, `../project/${projectName}/build.sh`)
)
await shellPro(projectName, logPath)
.then(compressedPro)
.catch(() => {
status = 'error'
console.log('error')
})
} else { // 执行打包工作流
(
await (install ? installAfterBuildPro : buildPro)(projectName, logPath)
.then(compressedPro)
.catch(() => {
status = 'error'
console.log('error')
})
)
}
let newData = getHistory()
newData = newData.map(c => {
if (c.id === id) {
c.status = status
}
return c
})
setFileContentByName(
'history',
newData,
true
)
})
})
UI界面展示
最后放个项目地址:github.com/LIAOJIANS/s…
来源:juejin.cn/post/7445098587808514082
一个js库就把你的网页的底裤🩲都扒了——import-html-entry
概述
import-html-entry
是一个用于动态加载和处理 HTML 和 JS 文件的库,主要用于微前端架构中。它能够从远程服务器拉取 HTML 内容,并对其中的 JS 和 CSS 进行处理,以便在主应用中加载和执行。这个库是 qiankun
微前端框架的核心依赖之一,提供了强大的动态加载和执行能力。在微前端框架 qiankun
中,import-html-entry
被用来解决 JS Entry
的问题,通过 HTML Entry
的方式,让用户接入微应用就像使用 iframe
一样简单。
使用方法
安装
首先,你需要通过 npm 或 yarn 安装 import-html-entry
:
npm install import-html-entry
或者
yarn add import-html-entry
基本使用
以下是一个简单的示例,展示如何使用 import-html-entry
加载一个远程的 HTML 文件,
我们看官网的例子
在index.html中
使用import-html-entry加载./template.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>index</title>
</head>
<body>
<script type="module">
window.onerror = e => {
console.log('error', e.message);
};
window.onunhandledrejection = (e) => {
console.log('unhandledrejection', e.reason.message);
};
import('./dist/index.js').then(({ importEntry }) => {
importEntry('./template.html').then(res => {
console.log(res);
return res.execScripts().then(exports => {
console.log(exports);
});
}).catch(e => {
console.log('importEntry failed', e.message);
});
});
</script>
</body>
</html>
template.html如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>test</title>
<link href="https://unpkg.com/antd@3.13.6/dist/antd.min.css" rel="stylesheet">
<link href="https://unpkg.com/bootstrap@4.3.1/dist/css/bootstrap-grid.min.css" rel="stylesheet">
</head>
<body>
<script src="./a.js"></script>
<script ignore>alert(1)</script>
<script src="./b.js"></script>
<script src="./c.js"></script>
<script src="https://unpkg.com/react@16.4.2/umd/react.production.min.js"></script>
<script src="https://unpkg.com/mobx@5.0.3/lib/mobx.umd.js"></script>
<script src="https://www.baidu.com"></script>
</body>
</html>
template.html被import-html-entry处理过后如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>test</title>
<style>
/* antd样式被内链进入 */
</style>
<style>
/* bootstrap样式被内链进入 */
</style>
</head>
<body>
<!-- script http://127.0.0.1:7001/a.js replaced by import-html-entry -->
<!-- ignore asset js file replaced by import-html-entry -->
<!-- script http://127.0.0.1:7001/b.js replaced by import-html-entry -->
<!-- script http://127.0.0.1:7001/c.js replaced by import-html-entry -->
<!-- script https://unpkg.com/react@16.4.2/umd/react.production.min.js replaced by import-html-entry -->
<!-- script https://unpkg.com/mobx@5.0.3/lib/mobx.umd.js replaced by import-html-entry -->
<!-- script https://www.baidu.com/ replaced by import-html-entry -->
</body>
</html>
可以发现html中的css被处理成为内链样式的了,其中的js代码script被注释掉了
importHTML
返回值有如下几个:
1、template---处理过后的html
2、assetPublicPath---资源路径
3、getExternalScripts---执行后返回脚本信息
4、getExternalStyleSheets---执行后返回样式信息
5、execScripts---js代码执行器,可以传入代理的window对象
我们可以看出来,经过import-html-entry
处理后能够拿到这个html中的js、css内容,其中css会被处理成为内链样式嵌入HTML中,js我们可以通过execScripts传入自己的代理window可以实现js沙箱隔离
qiankun中如何使用的?
我们观察qiankun
源码中是如何使用的import-html-entry
的
在src/loader.js中如下:
// 266行
const { template, execScripts, assetPublicPath, getExternalScripts } = await importEntry(entry, importEntryOpts);
// 347行
const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox, {
scopedGlobalVariables: speedySandbox ? cachedGlobals : [],
});
// get the lifecycle hooks from module exports
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
scriptExports,
appName,
global,
sandboxContainer?.instance?.latestSetProp,
);
可以看到和预期一样
1、使用import-html-entry
拿到js执行器
2、执行execScripts,并且传入自己的globalContext
3、根据导出,拿到生命周期函数lifecycle
源码解析
import-html-entry
的核心功能是通过 fetch
获取指定 URL 的 HTML 内容,然后解析并处理这个 HTML 模板,最终返回一个包含处理后的 HTML、CSS 和 JS 的 Promise
对象。具体步骤如下:
- 拉取 HTML 并处理:通过
fetch
获取到 URL 对应的全部内容(即 HTML 文件的字符串),然后解析出以下内容:经过初步处理后的 HTML(去掉外链 CSS 和外链 JS)、由所有script
组成的数组、由所有style
组成的数组。 - 嵌入 CSS:通过
fetch
拉取到上述style
数组里面对应的 CSS,然后将拉取到的每一个 href 对应的 CSS 通过<style>
包裹起来且嵌入到 HTML 中。 - 执行 JS 脚本:支持执行页级 JS 脚本以及拉取上述 HTML 中所有的外联 JS 并支持执行。因此,在微前端中,使用此依赖可以直接获取到子应用(某 URL)对应的 HTML 且此 HTML 上已经嵌好了所有的 CSS,同时还可以直接执行子应用的所有 JS 脚本且此脚本还为 JS 隔离(避免污染全局)做了预处理。
整体流程如下图所示:
execScripts
code = getExecutableScript()
通过function+with实现js沙箱
function getExecutableScript(scriptSrc, scriptText, opts = {}) {
const { proxy, strictGlobal, scopedGlobalVariables = [] } = opts;
const sourceUrl = isInlineCode(scriptSrc) ? '' : `//# sourceURL=${scriptSrc}\n`;
// 将 scopedGlobalVariables 拼接成变量声明,用于缓存全局变量,避免每次使用时都走一遍代理
const scopedGlobalVariableDefinition = scopedGlobalVariables.length ? `const {${scopedGlobalVariables.join(',')}}=this;` : '';
// 通过这种方式获取全局 window,因为 script 也是在全局作用域下运行的,所以我们通过 window.proxy 绑定时也必须确保绑定到全局 window 上
// 否则在嵌套场景下, window.proxy 设置的是内层应用的 window,而代码其实是在全局作用域运行的,会导致闭包里的 window.proxy 取的是最外层的微应用的 proxy
const globalWindow = (0, eval)('window');
globalWindow.proxy = proxy;
// TODO 通过 strictGlobal 方式切换 with 闭包,待 with 方式坑趟平后再合并
return strictGlobal
? (
scopedGlobalVariableDefinition
? `;(function(){with(this){${scopedGlobalVariableDefinition}${scriptText}\n${sourceUrl}}}).bind(window.proxy)();`
: `;(function(window, self, globalThis){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`
)
: `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`;
}
evalCode(scriptSrc, code)
通过eval执行代码
export function evalCode(scriptSrc, code) {
const key = scriptSrc;
if (!evalCache[key]) {
const functionWrappedCode = `(function(){${code}})`;
evalCache[key] = (0, eval)(functionWrappedCode);
}
const evalFunc = evalCache[key];
evalFunc.call(window);
}
processTpl
const { template, scripts, entry, styles } = processTpl(getTemplate(html), assetPublicPath, postProcessTemplate);
看一下执行结果。
通过processTpl实现。
1、替换HTML
2、导出js入口列表
3、style列表
4、找到入口文件
来源:juejin.cn/post/7445090940278276147
大屏适配方案--scale
CSS3的scale等比例缩放
宽度比率 = 当前网页宽度 / 设计稿宽度
高度比率 = 当前网页高度 / 设计稿高度
设计稿: 1920 * 1080
适配屏幕:1920 * 1080 3840 * 2160(2 * 2) 7680 * 2160(4 * 2)
方案一:根据宽度比率
进行缩放(超宽屏比如9/16的屏幕会出现滚动条)
方案二:动态计算网页的宽高比,决定根据宽度比率
还是高度比率
进行缩放
首先基于1920 * 1080进行基础的布局,下面针对两种方案进行实现
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
body,
ul {
margin: 0;
padding: 0;
}
body {
width: 1920px;
height: 1080px;
box-sizing: border-box;
/* 在js中添加translate居中 */
position: relative;
left: 50%;
/* 指定缩放的原点在左上角 */
transform-origin: left top;
}
ul {
width: 100%;
height: 100%;
list-style: none;
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
li {
width: 33.333%;
height: 50%;
box-sizing: border-box;
border: 2px solid rgb(198, 9, 135);
font-size: 30px;
}
</style>
</head>
<body>
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
<li>5</li>
<li>6</li>
</ul>
<script>
// ...实现适配方案
</script>
</body>
</html>
方案一:根据宽度比率
进行缩放
// 设计稿尺寸以及宽高比
let targetWidth = 1920;
// html的宽 || body的宽
let currentWidth =
document.documentElement.clientWidth || document.body.clientWidth;
console.log(currentWidth);
// 按宽度计算缩放比率
let scaleRatio = currentWidth / targetWidth;
// 进行缩放
document.body.style = `transform: scale(${scaleRatio})`;
实现效果如下:
这时我们发现在7680 * 2160尺寸下,屏幕根据宽度缩放会出现滚动条,为了解决这个问题,我们就要动态的选择根据宽度缩放还是根据高度缩放。
方案二:动态计算网页的宽高比,决定根据宽度比率
还是高度比率
进行缩放
// 设计稿尺寸以及宽高比
let targetWidth = 1920;
let targetHeight = 1080;
let targetRatio = 16 / 9; // targetWidth /targetHeight
// 当前屏幕html的宽 || body的宽
let currentWidth =
document.documentElement.clientWidth || document.body.clientWidth;
// 当前屏幕html的高 || body的高
let currentHeight =
document.documentElement.clientHeight || document.body.clientHeight;
// 当前屏幕宽高比
let currentRatio = currentWidth / currentHeight;
// 默认 按宽度计算缩放比率
let scaleRatio = currentWidth / targetWidth;
if (currentRatio > targetRatio) {
scaleRatio = currentHeight / targetHeight;
}
// 进行缩放
document.body.style = `transform: scale(${scaleRatio}) translateX(-50%);`;
效果如下:
这样就可以解决在超宽屏幕下出现滚动条的问题,另外我们做了居中的样式处理,这样在超宽屏幕时,两边留白,内容居中展示显得更加合理些。
来源:juejin.cn/post/7359077652416725018
前端实现画中画超简单,让网页飞出浏览器
Document Picture-in-Picture 介绍
今天,我来介绍一个非常酷的前端功能:文档画中画 (Document Picture-in-Picture, 本文简称 PiP)。你有没有想过,网页上的任何内容能悬浮在桌面上?😏
🎬 视频流媒体的画中画功能
你可能已经在视频平台(如腾讯视频
、哔哩哔哩
等网页)见过这种效果:视频播放时,可以点击画中画后。无论你切换页面,它都始终显示在屏幕的最上层,非常适合上班偷偷看电视
💻
在今天的教程中,不仅仅是视频,我将教你如何将任何 HTML 内容放入画中画
模式,无论是动态内容、文本、图片,还是纯炫酷的 div,统统都能“飞”起来。✨
一个如此有趣的功能,在网上却很少有详细的教程来介绍这个功能的使用。于是我决定写一篇详细的教程来教大家如何实现画中画 (建议收藏)😁
体验网址:Treasure-Navigation
📖 Document Picture-in-Picture 详细教程
🛠 HTML 基本代码结构
首先,我们随便写一个简单的 HTML 页面
,后续的 JS 和样式都会基于它实现。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document Picture-in-Picture API 示例</title>
<style>
#pipContent {
width: 600px;
height: 300px;
background: pink;
font-size: 20px;
}
</style>
</head>
<body>
<div id="container">
<div id="pipContent">这是一个将要放入画中画的 div 元素!</div>
<button id="clickBtn">切换画中画</button>
</div>
<script>
// 在这里写你的 JavaScript 代码
</script>
</body>
</html>
1️. 请求 PiP 窗口
PiP
的核心方法是 window.documentPictureInPicture.requestWindow
。它是一个 异步方法
,返回一个新创建的 window
对象。
PIP 窗口
可以将其看作一个新的网页,但它始终悬浮在屏幕上方。
document.getElementById("clickBtn").addEventListener("click", async function () {
// 获取将要放入 PiP 窗口的 DOM 元素
const pipContent = document.getElementById("pipContent");
// 请求创建一个 PiP 窗口
const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 200, // 设置窗口的宽度
height: 300 // 设置窗口的高度
});
// 将原始元素添加到 PiP 窗口中
pipWindow.document.body.appendChild(pipContent);
});
演示:
👏 现在,我们已经成功创建了一个画中画窗口!
这段代码展示了如何将网页中的元素放入一个新的画中画窗口,并让它悬浮在最上面。非常简单吧
关闭PIP窗口
可以直接点右上角关闭PIP窗口,如果我们想在代码中实现关闭,直接调用window上的api
就可以了
window.documentPictureInPicture.window.close();
2️. 检查是否支持 PiP 功能
一切不能兼容浏览器的功能介绍都是耍流氓,我们需要检查浏览器是否支持PIIP功能
。
实际就是检查documentPictureInPicture属性是否存在于window上 🔧
if ('documentPictureInPicture' in window) {
console.log("🚀 浏览器支持 PiP 功能!");
} else {
console.warn("⚠️ 当前浏览器不支持 PiP 功能,更新浏览器或者换台电脑吧!");
}
如果是只需要将视频实现画中画功能,视频画中画 (Picture-in-Picture)
的兼容性会好一点,但是它只能将元素放入画中画窗口。它与本文介绍的 文档画中画(Document Picture-in-Picture)
使用方法也是十分相似的。
3️. 设置 PiP 样式
我们会发现刚刚创建的画中画没有样式
,一点都不美观。那是因为我们只放入了dom元素,没有添加css样式。
3.1. 全局样式同步
假设网页中的所有样式如下:
<head>
<style>
#pipContent {
width: 600px;
height: 300px;
background: pink;
font-size: 20px;
}
</style>
<link rel="stylesheet" type="text/css" href="https://abc.css">
</head>
为了方便,我们可以直接把之前的网页的css样式全部赋值给画中画
。
// 1. document.styleSheets获取所有的css样式信息
[...document.styleSheets].forEach((styleSheet) => {
try {
// 转成字符串方便赋值
const cssRules = [...styleSheet.cssRules].map((rule) => rule.cssText).join('');
// 创建style标签
const style = document.createElement('style');
// 设置为之前页面中的css信息
style.textContent = cssRules;
console.log('style', style);
// 把style标签放到画中画的<head><head/>标签中
pipWindow.document.head.appendChild(style);
} catch (e) {
// 通过 link 引入样式,如果有跨域,访问styleSheet.cssRules时会报错。没有跨域则不会报错
const link = document.createElement('link');
/**
* rel = stylesheet 导入样式表
* type: 对应的格式
* media: 媒体查询(如 screen and (max-width: 600px))
* href: 外部样式表的 URL
*/
link.rel = 'stylesheet';
link.type = styleSheet.type;
link.media = styleSheet.media;
link.href = styleSheet.href ?? '';
console.log('error: link', link);
pipWindow.document.head.appendChild(link);
}
});
演示:
3.2. 使用 link
引入外部 CSS 文件
向其他普通html
文件一样,可以通过link
标签引入特定css
文件:
创建 pip.css
文件:
#pipContent {
width: 600px;
height: 300px;
background: skyblue;
}
js
引用:
// 其他不变
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = './pip.css'; // 引入外部 CSS 文件
pipWindow.document.head.appendChild(link);
pipWindow.document.body.appendChild(pipContent);
演示:
3.3. 媒体查询的支持
可以设置媒体查询 @media (display-mode: picture-in-picture)
。在普通页面中会自动忽略样式,在画中画模式会自动渲染样式
<style>
#pipContent {
width: 600px;
height: 300px;
background: pink;
font-size: 20px;
}
<!-- 普通网页中会忽略 -->
@media (display-mode: picture-in-picture) {
#pipContent {
background: lightgreen;
}
}
</style>
在普通页面中显示为粉色
,在画中画自动变为浅绿色
演示:
4️. 监听进入和退出 PiP 模式的事件
我们还可以为 PiP 窗口
添加事件监听
,监控画中画模式的 进入 和 退出。这样,你就可以在用户操作时,做出相应的反馈,比如显示提示或执行其他操作。
// 进入 PIP 事件
documentPictureInPicture.addEventListener("enter", (event) => {
console.log("已进入 PIP 窗口");
});
const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 200,
height: 300
});
// 退出 PIP 事件
pipWindow.addEventListener("pagehide", (event) => {
console.log("已退出 PIP 窗口");
});
演示
5️. 监听 PiP 焦点和失焦事件
const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 200,
height: 300
});
pipWindow.addEventListener('focus', () => {
console.log("PiP 窗口进入了焦点状态");
});
pipWindow.addEventListener('blur', () => {
console.log("PiP 窗口失去了焦点");
});
演示
6. 克隆节点画中画
我们会发现我们把原始元素传入到PIP窗口后,原来窗口中的元素就不见了。
我们可以把原始元素克隆后再传入给PIP窗口,这样原始窗口中的元素就不会消失了
const pipContent = document.getElementById("pipContent");
const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 200,
height: 300
});
// 核心代码:pipContent.cloneNode(true)
pipWindow.document.body.appendChild(pipContent.cloneNode(true));
演示
PIP 完整示例代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document Picture-in-Picture API 示例</title>
<style>
#pipContent {
width: 600px;
height: 300px;
background: pink;
font-size: 20px;
}
</style>
</head>
<body>
<div id="container">
<div id="pipContent">这是一个将要放入画中画的 div 元素!</div>
<button id="clickBtn">切换画中画</button>
</div>
<script>
// 检查是否支持 PiP 功能
if ('documentPictureInPicture' in window) {
console.log("🚀 浏览器支持 PiP 功能!");
} else {
console.warn("⚠️ 当前浏览器不支持 PiP 功能,更新浏览器或者换台电脑吧!");
}
// 请求 PiP 窗口
document.getElementById("clickBtn").addEventListener("click", async function () {
const pipContent = document.getElementById("pipContent");
// 请求创建一个 PiP 窗口
const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 200, // 设置窗口的宽度
height: 300 // 设置窗口的高度
});
// 将原始元素克隆并添加到 PiP 窗口中
pipWindow.document.body.appendChild(pipContent.cloneNode(true));
// 设置 PiP 样式同步
[...document.styleSheets].forEach((styleSheet) => {
try {
const cssRules = [...styleSheet.cssRules].map((rule) => rule.cssText).join('');
const style = document.createElement('style');
style.textContent = cssRules;
pipWindow.document.head.appendChild(style);
} catch (e) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.type = styleSheet.type;
link.media = styleSheet.media;
link.href = styleSheet.href ?? '';
pipWindow.document.head.appendChild(link);
}
});
// 监听进入和退出 PiP 模式的事件
pipWindow.addEventListener("pagehide", (event) => {
console.log("已退出 PIP 窗口");
});
pipWindow.addEventListener('focus', () => {
console.log("PiP 窗口进入了焦点状态");
});
pipWindow.addEventListener('blur', () => {
console.log("PiP 窗口失去了焦点");
});
});
// 关闭 PiP 窗口
// pipWindow.close(); // 可以手动调用关闭窗口
</script>
</body>
</html>
总结
🎉 你现在已经掌握了如何使用 Document Picture-in-Picture
API 来悬浮任意 HTML 内容!
希望能带来更灵活的交互体验。✨
如果你有什么问题,或者对 PiP 功能有更多的想法,欢迎在评论区与我讨论!👇📬
来源:juejin.cn/post/7441954981342036006
new Image() 预加载 为什么比 <img>直接加载要好?
<img>
直接加载对比 new Image()
预加载
1. 加载时机和页面渲染的差异
- 直接渲染到
<img>
标签: 当你直接在 HTML 中通过<img>
标签加载图片时,浏览器在遇到<img>
标签时会立即开始加载图片。这意味着浏览器在渲染页面的过程中,会同步进行图片请求。当页面需要渲染图片时,可能会导致图片显示之前页面的其它部分无法完全显示,或者图片加载的过程中页面会出现闪烁或布局跳动。
这种加载方式是 同步 的,即浏览器渲染页面时,图片的加载和显示是直接相关的。如果图片较大或者网络慢,用户可能会看到空白的占位符,直到图片加载完成。
- 使用
new Image()
和img.src = src
: 这种方式会在后台加载图片,不直接影响页面的渲染。也就是说,图片资源在浏览器缓存中已经加载好了,页面显示图片时,浏览器能快速地从缓存读取图片,而不必等待网络请求。浏览器不会因为加载图片而延迟页面的渲染。
关键点是:通过
new Image()
加载图片会提前发起请求,将图片缓存到浏览器中,这意味着你可以在用户滚动或需要展示图片时,直接从缓存加载,而不需要重新请求网络资源。这个过程是 异步 的。
2. 浏览器的资源管理和缓存
- 图片预加载的缓存: 当你通过
new Image()
加载图片时,图片会被缓存在浏览器的内存中(通常是浏览器的资源缓存),因此如果图片已经被加载过,后续使用该图片时会直接从缓存读取,而不需要重新请求网络资源。
而如果你直接用
<img>
标签来加载图片,浏览器同样会请求并缓存图片,但如果图片在初次加载时不可见(比如在页面下方),用户滚动到该位置时,可能会再次触发网络请求,尤其是在使用懒加载(lazy load)等技术时。如果图片已经预加载过,浏览器就可以从缓存中直接加载,避免了再次请求。
3. 避免页面阻塞
- 直接使用
<img>
:当浏览器在解析页面时遇到<img>
标签,会立即发起网络请求来加载图片。如果图片资源很大或者服务器响应很慢,浏览器可能需要等待这些资源加载完成,才能继续渲染其他部分。这会导致页面的 渲染阻塞,即页面内容渲染较慢,特别是在图片多的情况下。 - 使用
new Image()
预加载:通过new Image()
预加载图片,可以避免渲染时对页面的阻塞。浏览器在后台加载图片,直到需要展示图片时,图片已经准备好了,这样页面展示可以更快,用户体验也更好。
4. 适用场景
- 直接
<img>
标签加载:适用于图片较少且页面上几乎所有图片都需要立即展示的场景。例如,单一图片展示的页面。 new Image()
预加载:适用于图片较多或需要延迟加载的场景,例如动态加载的图片、长页面或者需要懒加载的图片库。它允许你提前将图片加载到浏览器缓存中,减少后续显示时的加载时间。
5. 加载速度和时间
如果从加载速度和时间上来看,两者的差别可能不大,因为它们最终都会发起一次网络请求去加载图片。但是,new Image()
的优势在于:
- 它允许你在图片真正需要显示之前就开始加载,这样当用户需要看到图片时,图片已经在浏览器缓存中,可以即时显示。
- 使用
new Image()
可以提前加载图片,而不会影响页面的渲染顺序和内容显示,不会造成页面的阻塞。
6. 网络请求优化
new Image()
还可以和 并发请求 进行优化。如果你有多个图片需要预加载,可以通过多个 new Image()
实例来并行加载这些图片,而不影响页面的渲染。并且,如果你知道某些图片很可能会被需要(例如图片懒加载场景中的下拉加载图片),你可以提前加载这些图片,确保用户滚动时能立刻看到图片。
7. 总结对比
特性 | <img> 标签加载 | new Image() 预加载 |
---|---|---|
渲染影响 | 直接渲染图片,可能导致页面闪烁或布局跳动 | 异步加载图片,不影响页面渲染 |
缓存 | 图片加载后会缓存,但可能会重复请求 | 图片预先加载到缓存中,避免重复请求 |
适用场景 | 单一图片,少量图片,图片快速加载 | 图片较多,懒加载,预加载 |
加载时机 | 页面渲染时加载,可能导致渲染延迟 | 提前加载,确保图片准备好时显示 |
结论
虽然从技术上讲,直接在 <img>
标签中加载图片和使用 new Image()
设置 src
都会触发相同的图片加载过程,但是 使用 new Image()
进行预加载 提供了更灵活的控制,使得你可以在页面渲染时避免图片加载阻塞,提升页面的加载速度和用户体验。
(补充:代码示例小demo传送门)
来源:juejin.cn/post/7441246880666107931
为了解决小程序tabbar闪烁的问题,我将小程序重构成了 SPA
(日落西山,每次看到此景,我总是会想到明朝(明朝那些事儿第六部的标题,日落西山))
前言
几个月前,因工作需求,我开发了一个小程序,当时遇到了一个需求,是关于tabbar权限的问题。小程序的用户分两种,普通用户和vip用户,普通用户tabbar有两个,vip用户小程序下面的tabbar有五个。
因为涉及自定义tabbar的问题,所以官方自带的tabbar肯定就不能用了,我们需要自定义tabbar。官方也提供了自定义tabbar的功能。
官网自定义tabbar
官网地址:基础能力 / 自定义 tabBar (qq.com)
{
"tabBar": {
"custom": true,
"list": []
}
}
就是需要在 app.json
中的 tabBar
项指定 custom
字段,需要注意的是 list
字段也需要存在。
然后,在代码根目录下添加入口文件:
custom-tab-bar/index.js
custom-tab-bar/index.json
custom-tab-bar/index.wxml
custom-tab-bar/index.wxss
具体代码,大家可以参考官网案例。
需要注意的是每个tabbar页面 / 组件
都需要在onshow / show
函数中执行以下函数,否则就会出现tabbar按钮切换两次,才会变成选中色的问题。
if (typeof this.getTabBar === 'function' &&
this.getTabBar()) {
this.getTabBar().setData({
selected: 0 // 第n个tabbar页面就填 n-1
})
}
接下来就是我的思路
我在 custom-tab-bar/index.js
中定义了一个函数,这个函数去判断当前登录人是否为vip,如果是就替换掉tabbar 的数据。
那么之前每个页面的代码就要写成这样
if (typeof this.getTabBar === 'function' &&
this.getTabBar()) {
this.getTabBar().change_tabbar_list()
this.getTabBar().setData({
selected: 0 // 第n个tabbar页面就填 n-1
})
}
ok,我们来看一下效果。注意看视频下方的tabbar,每个页面,第一次点击的时候,有明显的闪烁bug
。(大家也可以参考一下市面上的小程序,小部分的小程序有这个闪烁问题,大部分的小程序没有这个闪烁的问题(如:携程小程序))
bug产生原因
那么我们就要去思考了,为什么人家的小程序没有这个bug呢?
想这个问题前,要先去想这个bug是怎么产生的,我猜测是每个tabbar页面都有个初始化的过程,第一次渲染页面的时候要去重新渲染tabbar,每个页面的tabbar都是从0开始渲染,然后会缓存到每个页面上,所以第二次点击就没有这个bug了。
解决tabbar闪烁问题
为了解决这个问题,我想到了SPA ,也就是只留一个页面,其他的tabbar页面都弄成组件。
效果展示
已经解决,tabbar闪烁的问题。
代码思路,通过wx:if 控制组件的显示隐藏。
源码地址:gitlab.com/wechat-mini…
https克隆地址:gitlab.com/wechat-mini…
写在最后
1、我也是在网上见过别人的一些评论,说如果将小程序重构成这种单页面,会有卡顿问题,我目前没有发现这个问题,可能是我做的小程序功能比较少。
2、至于生命周期,将页面切换成组件后,页面的那些生命周期也肯定都不能使用了,只能用组件的生命周期,我之前开发使用组件的生命周期实现业务逻辑也没什么问题。 触底加载这些也只能换成组件去实现了。
3、小程序最上面的标题,也可以使用以下代码来实现。就是在每个组件初始化的时候要去执行下列代码。
wx.setNavigationBarTitle({
title: '',
});
来源:juejin.cn/post/7317281367111827475
优雅解决uniapp微信小程序右上角胶囊菜单覆盖问题
前言
大家好,今天聊一下在做uniapp多端适配项目,需要用到自定义导航时,如何解决状态栏塌陷及导航栏安全区域多端适配问题,下文只针对H5、APP、微信小程序三端进行适配,通过封装一个通用高阶组件包裹自定义导航栏内容,主要是通过设置padding来使内容始终保持在安全区域,达到低耦合,可复用性强的效果。
一、创建NavbarWrapper.vue组件
大致结构如下:
<template>
<view class="navbar-wrapper" :style="{
paddingTop: statusBarHeight,
paddingRight: rightSafeArea
}">
<slot/>
</view>
</template>
<script>
export default {
name: 'NavbarWrapper',
data() {
return {
// 像素单位
pxUnit: 'px',
// 默认状态栏高度
statusBarHeight: 'var(--status-bar-height)',
// 微信小程序右上角的胶囊菜单宽度
rightSafeArea: 0
}
}
}
</script>
<style scoped>
.navbar-wrapper {
/**
* 元素的宽度和高度包括了内边距(padding)和边框(border),
* 而不会被它们所占据的空间所影响
* 子元素继承宽度时,只会继承内容区域的宽度
*/
box-sizing: border-box;
}
</style>
目的
主要是动态计算statusBarHeight和rightSafeArea的值。
解决方案
在APP端只需一行css代码即可
.navbar-wrapper {
padding-top: var(--status-bar-height);
}
下面是关于--status-bar-height
变量的介绍:
从上图可以知道--status-bar-height
只在APP端是手机实际状态栏高度,在微信小程序是固定的25px
,并不是手机实际状态栏高度;
在微信小程序时,除了状态栏高度还需要获取右上角的胶囊菜单所占宽度,保持导航栏在安全区域。
以下使用uni.getWindowInfo()
和uni.getMenuButtonBoundingClientRect()
来分别获取状态栏高度和胶囊相关信息,api介绍如下图所示:
主要逻辑代码
在NavbarWrapper组件创建时,做相关计算
created() {
const px = this.pxUnit
// #ifndef H5
// 获取窗口信息
const windowInfo = uni.getWindowInfo()
this.statusBarHeight = windowInfo.statusBarHeight + px
// #endif
// #ifdef MP-WEIXIN
// 获取胶囊左边界坐标
const { left } = uni.getMenuButtonBoundingClientRect()
// 计算胶囊(包括右边距)占据屏幕的总宽度:屏幕宽度-胶囊左边界坐标
this.rightSafeArea = windowInfo.windowWidth - left + px
// #endif
}
用法
<NavbarWrapper>
<view class="header">header</view>
</NavbarWrapper>
二、多端效果展示
微信小程序
APP端
H5端
三、源码
NavbarWrapper.vue
<template>
<view class="navbar-wrapper" :style="{
paddingTop: statusBarHeight,
paddingRight: rightSafeArea
}">
<slot/>
</view>
</template>
<script>
export default {
name: 'NavbarWrapper',
data() {
return {
// 像素单位
pxUnit: 'px',
// 默认状态栏高度
statusBarHeight: 'var(--status-bar-height)',
// 微信小程序右上角的胶囊菜单宽度
rightSafeArea: 0
}
},
created() {
const px = this.pxUnit
// #ifndef H5
// 获取窗口信息
const windowInfo = uni.getWindowInfo()
this.statusBarHeight = windowInfo.statusBarHeight + px
// #endif
// #ifdef MP-WEIXIN
// 获取胶囊左边界坐标
const { left } = uni.getMenuButtonBoundingClientRect()
// 计算胶囊(包括右边距)占据屏幕的总宽度:屏幕宽度-胶囊左边界坐标
this.rightSafeArea = windowInfo.windowWidth - left + px
// #endif
}
}
</script>
<style scoped>
.navbar-wrapper {
/**
* 元素的宽度和高度包括了内边距(padding)和边框(border),
* 而不会被它们所占据的空间所影响
* 子元素继承宽度时,只会继承内容区域的宽度
*/
box-sizing: border-box;
background-color: deeppink;
}
</style>
往期文章回顾
来源:juejin.cn/post/7309361597556719679
uniapp微信小程序授权后得到“微信用户”
背景
近日在开发微信小程序的时候,发现数据库多了很多用户名称是"微信用户"的账号信息。接口的响应信息如下。
(nickName=微信用户, avatarUrl=https://thirdwx.qlogo.cn/mmopen/vi_32/POgEwh4mIHO4nibH0KlMECNjjGxQUq24ZEaGT4poC6icRiccVGKSyXwibcPq4BWmiaIGuG1icwxaQX6grC9VemZoJ8rg/132, gender=0, country=, province=, city=, language=), code=0e1abNFa1dBwRG0lnoJa18qT0i2abNFk)
经过排查,发现官方是对微信授权的接口做出了调整。小程序用户头像昵称获取规则调整公告
根据上面标红的字体说明,官方的意图就是只提供openid和unionid, 不暴露用户头像昵称数据。
基于此才会在新版的接口中返回"微信用户"的信息。
- 针对这个问题,官方提供的解决方案如下。
以上解决方案,表达的意思是新版用户授权的接口中, 官方只会给你提供unionid和openid.
至于用户的昵称和头像,开发者可以提供功能,以用户的意志去完成修改和更新。
tips: 建议授权接口生成用户名和昵称,采用系统默认的方式。
微信授权流程
uniapp代码实现
后端代码
异常分析
//如果你的接口出现如下信信息,该如何处理呢?
# {errMsg: “getUserProfile:fail api scope is not
declared in the privacy agreement“, errno: 112}
出现问题的原因: api 范围未在隐私协议中声明,建议大家更具公告,更新对应的隐私协议。
【设置-服务内容声明-用户隐私保护指引】,更新隐私协议,在第一条:开发者处理的信息中,点击【增加信息类型】,选择需要授权的信息,头像昵称我已经勾选了,所以列表中不显示了,根据需求选择和填写其他内容,最后确定并生成协议。等待隐私协议审核通过。
建议按需添加,以防审核不通过。
为了分辨用户,开发者将在获取你的明示同意后,收集你的微信昵称、头像。
为了显示距离,开发者将在获取你的明示同意后,收集你的位置信息。
开发者收集你的地址,用于获取位置信息。
开发者收集你的发票信息,用于维护消费功能。
为了用户互动,开发者将在获取你的明示同意后,收集你的微信运动步数。
为了通过语音与其他用户交流互动,开发者将在获取你的明示同意后,访问你的麦克风。
开发者收集你选中的照片或视频信息,用于提前上传减少上传时间。
为了上传图片或者视频,开发者将在获取你的明示同意后,访问你的摄像头。
为了登录或者注册,开发者将在获取你的明示同意后,收集你的手机号。
开发者使用你的通讯录(仅写入)权限,用于方便用户联系信息。
开发者收集你的设备信息,用于保障你正常使用网络服务。
开发者收集你的身-份-证号码,用于实名认证后才能继续使用的相关网络服务。
开发者收集你的订单信息,用于方便获取订单信息。
开发者收集你的发布内容,用于用户互动。
开发者收集你的所关注账号,用于用户互动。
开发者收集你的操作日志,用于运营维护。
为了保存图片或者上传图片,开发者将在获取你的明示同意后,使用你的相册(仅写入)权限。
为了用户互动,开发者将在获取你的明示同意后,收集你的车牌号。
开发者访问你的蓝牙,用于设备连接。
开发者使用你的日历(仅写入)权限,用于用户日历日程提醒。
开发者收集你的邮箱,用于在必要时和用户联系。
开发者收集你选中的文件,用于提前上传减少上传时间。
当你选择所需的接口后,需要您填写使用说明。 可以参考上面的内容进行填写。
给大家看一下我申请的接口。折腾半天终于把授权登录给整好了。
做完上述隐私设置后,需要你重新发布自己的小程序。 并且设置成采集用户隐私。
审核通过后就可以啦。如下图, 请一定注意!!!
参考文档
头像昵称填写-微信官方文档
uniapp头像昵称填写
getUserProfile:fail api scope is not declared in the privacy agreement
来源:juejin.cn/post/7332113324651610150
被antdesign的恐怖的scripts吓到了
近日无意中打开antdesign
的package.json,然后就看到一砣恐怖的scripts
"scripts": {
"api-collection": "antd-tools run api-collection",
"authors": "tsx scripts/generate-authors.ts",
"build": "npm run compile && cross-env NODE_OPTIONS='--max-old-space-size=4096' npm run dist",
"changelog": "npm run lint:changelog && tsx scripts/print-changelog.ts",
"check-commit": "tsx scripts/check-commit.ts",
"clean": "antd-tools run clean && rimraf es lib coverage locale dist report.html artifacts.zip oss-artifacts.zip",
"clean:lockfiles": "rimraf package-lock.json yarn.lock",
"precompile": "npm run prestart",
"compile": "npm run clean && antd-tools run compile",
"predeploy": "antd-tools run clean && npm run site && cp CNAME _site && npm run test:site",
"deploy": "gh-pages -d _site -b gh-pages -f",
"deploy:china-mirror": "git checkout gh-pages && git pull origin gh-pages && git push git@gitee.com:ant-design/ant-design.git gh-pages -f",
"predist": "npm run version && npm run token:statistic && npm run token:meta",
"dist": "antd-tools run dist",
"format": "biome format --write .",
"install-react-16": "npm i --no-save --legacy-peer-deps react@16 react-dom@16 @testing-library/react@12",
"install-react-17": "npm i --no-save --legacy-peer-deps react@17 react-dom@17 @testing-library/react@12",
"bun-install-react-16": "bun remove react react-dom @testing-library/react && bun add --no-save react@16 react-dom@16 @testing-library/react@12",
"bun-install-react-17": "bun remove react react-dom @testing-library/react && bun add --no-save react@17 react-dom@17 @testing-library/react@12",
"prelint": "dumi setup",
"lint": "npm run version && npm run tsc && npm run lint:script && npm run lint:biome && npm run lint:md && npm run lint:style && npm run lint:changelog",
"lint:changelog": "tsx scripts/generate-component-changelog.ts",
"lint:deps": "antd-tools run deps-lint",
"lint:md": "remark . -f -q",
"lint:script": "eslint . --cache",
"lint:biome": "biome lint",
"lint:style": "tsx scripts/check-cssinjs.tsx",
"prepare": "is-ci || husky && dumi setup",
"prepublishOnly": "tsx ./scripts/pre-publish.ts",
"prettier": "prettier -c --write . --cache",
"prettier-import-sort": "npm run prettier -- --plugin=@ianvs/prettier-plugin-sort-imports",
"biome": "biome check --write",
"pub": "echo 'Please use `npm publish` instead.'",
"postpublish": "tsx scripts/post-publish.ts",
"presite": "npm run prestart",
"site": "npm i --no-save --legacy-peer-deps react@18.3.0-canary-c3048aab4-20240326 react-dom@18.3.0-canary-c3048aab4-20240326 && dumi build && cp .surgeignore _site",
"size-limit": "size-limit",
"sort:api-table": "antd-tools run sort-api-table",
"sort:package-json": "npx sort-package-json",
"prestart": "npm run version && npm run token:statistic && npm run token:meta && npm run lint:changelog",
"start": "tsx ./scripts/set-node-options.ts cross-env PORT=8001 dumi dev",
"pretest": "npm run version",
"test": "jest --config .jest.js --no-cache",
"test:all": "sh -e ./scripts/test-all.sh",
"test:dekko": "node ./tests/dekko/index.test.js",
"test:image": "jest --config .jest.image.js --no-cache -i -u --forceExit",
"test:node": "npm run version && jest --config .jest.node.js --no-cache",
"test:package-diff": "antd-tools run package-diff",
"test:site": "jest --config .jest.site.js",
"test:site-update": "npm run site && npm run test:site -- -u",
"test:update": "jest --config .jest.js --no-cache -u",
"test:visual-regression": "tsx scripts/visual-regression/build.ts",
"token:meta": "tsx scripts/generate-token-meta.ts",
"token:statistic": "tsx scripts/collect-token-statistic.ts",
"tsc": "tsc --noEmit",
"tsc:old": "tsc --noEmit -p tsconfig-old-react.json",
"version": "tsx scripts/generate-version.ts"
},
面对如此复杂的scripts
,有没有被吓到。
相信我,在团队开发中,不要说团队成员,就算是开发者本身一段时间后也不一定能一眼就看到每一条脚本的作用了。
怎么呢?最好是加点注释
但是众所周知,package.json
是不支持注释了。
这里给大家推荐一个VSCODE
插件json_comments_extension,可以用来给任意JSON
文件添加注释.
效果如下:
开源推荐
以下是我的一大波开源项目推荐:
- 全流程一健化React/Vue/Nodejs国际化方案 - VoerkaI18n
- 极致优雅的状态管理库 - AutoStore
- 无以伦比的React表单开发库 - speedform
- 终端界面开发增强库 - Logsets
- 简单的日志输出库 - VoerkaLogger
- 装饰器开发 - FlexDecorators
- 有限状态机库 - FlexState
- 通用函数工具库 - FlexTools
- 小巧优雅的CSS-IN-JS库 - flexstyled
- 为JSON文件添加注释的VSCODE插件 - json_comments_extension
- 开发交互式命令行程序库 - mixcli
- 强大的字符串插值变量处理工具库 - flexvars
- 前端link调试辅助工具 - yald
- 异步信号 - asyncsignal
- React/Vue/WebComponent树组件 - LiteTree
来源:juejin.cn/post/7442573821444227109
Canvas 轻量图文编辑器的一些实践
1. 前言
简而言之,我们需要一个能够在 H5 端和桌面端使用的轻量级图文编辑器。具体的使用流程是在桌面端制作编辑模板(上传一张底图,指定编辑区域的大小),然后在 H5 端允许用户在模板的基础之上添加文本,图片,支持对文本图片的多种编辑等。
2. 核心问题和分析
主要诉求是需要自研一套商品图文定制编辑器,在 PC 上支持模板定制,在 H5 上支持图文编辑。模板定制主要是确定底图的编辑区域,图文编辑器则是在底图上添加图片和文字。
2.1 社区现状
在图文编辑器上,目前社区中各式各样的编辑器非常丰富:
- 专业的修图软件:PS、Pixelmator 等
- 手机 App:美图秀秀、Picsart 等,功能也非常完善且强大,不比 PS 差
- 轻量级编辑器:视频封面编辑、公众号图文排版、商品定制等面向业务场景
PhotoShop | Pixelmator |
---|---|
美图秀秀 | Picsart |
在 Web 上的编辑器种类也非常丰富,毕竟 canvas 能做的事情非常多。比如 miniPaint基本复刻了 ps,基于 farbic.js的 Pintura.和 tui.image-editor,基于 Konva的 polotno等等。这些编辑器也基本是个 app 级别的应用了。
miniPaint | tui.image-editor |
---|---|
polotno | pintura |
总结一下:
1、不论是软件型应用还是 Web 编辑器,一种是做得非常通用的编辑器,功能丰富且完善,另一种就是面向业务流程定制的轻量型编辑器,只有一些特定交互操作和属性配置能力,可操作内容很少;
2、上述的这些 Web 编辑器大部分都是在 PC 上被使用,在手机上的编辑器也基本是在 Native 容器里开发。所以可以参考的 H5 编辑器基本没有。
3、PC 和 H5 编辑器一个明显的不同是,在 PC 上编辑操作,是选中元素后,元素的属性在工具栏或侧边栏进行编辑,画布上的操作只有缩放和旋转。在 H5 上的编辑器,元素选中后的操作会主要放在四个锚点控制器上,添加自定义操作,其余一些次相关的操作放在底部操作栏。所以在设计和实现这个编辑器的过程中,我们参考了很多类似手机 App 的交互。
2.2 分析
操作流程
1、在 PC 设置模板,上传底图,并设置定制区域,定制区域可调整
2、在 H5 上基于模板进行图文编辑,可添加图片和文字,文字可修改字体 颜色 大小。同时可控制元素的缩放旋转、层级移动、删除和复制。
3、最后基于模板和元素,导出定制图。
我们这次的场景显然只需要一个轻量型的图文编辑器,技术上如何选型?
- 如果基于完整的第三方编辑类库(如 polotno),太重了,可能有现成的功能,但改造成本更高;
- 基于图形处理库(封装了 Cavnas 或者 SVG 的 API)直接开发会更容易管理,但可能需要从头实现一些功能。
我们准备基于 Konva 来实现这次的编辑器需求。也想借这次机会,沉淀一些通用的编辑能力,如元素锚点操作的控制、拖转限制的计算逻辑、蒙层遮罩的绘制逻辑、坐标转换的逻辑等等。
Why Konva?
Konva 和 Fabric 都是比较热门的开源 2D 图形库,封装了 Canvas 的一系列 API。
Farbic | Konva |
---|---|
比较老牌,比 Konva上线时间更早一些。 | 使用 TypeScript 编写,TS 原生支持 |
常用转换(放大、缩小、拖拽)都已经封装好,内置了丰富的笔刷,基本的对齐、标线都有,特别适合用 Canvas 写交互性的界面 | 渲染分层比较清晰,Stage -> Layer -> Gr0up -> Shape |
代码集成度比较高,内置了可交互富文本(纯 Canvas 实现) | 代码简洁、干净,易于阅读 |
代码使用 ES5开发,不能很好的支持 TypeScript,开发效率可能会有影响 | 文档清晰,容易上手 |
由于库本身集成了很多功能点,代码包的大小偏大(压缩后308 kB) | 核心代码精简,代码包较小(压缩后155 kB |
细节功能还需要完善,比如标线系统实现相对简单 | 部分功能实现基于 DOM(富文本) |
. | 后起之秀,周边生态还比较薄弱 |
2.3 编辑器设计思路
编辑器按照图层叠加的顺序自上而下是 底图 -> 蒙层 -> 元素 -> 控制器
3. 详细功能设计
3.1 数据
3.1.1 数据格式定制
目前支持两种编辑区域,圆形和矩形。编辑区域的数据类型为
export type EditAreaType = RectArea | CircleArea;
export interface RectArea {
/** 类型 */
type: 'Rect';
/** 属性 */
attrs: { x: number, y: number, width: number, height: number };
}
export interface CircleArea {
/** 类型 */
type: 'Circle';
/** 属性 */
attrs: { x: number, y: number, radius: number };
}
其中,x,y 均是相对于底图所在容器的坐标。
3.1.2 坐标转换
由于服务端考虑到数据流量成本,在PC和H5的底图会做分辨率的限制,例如在PC上传的底图是 1200x1200,在 H5 上提供的底图是 400x400(但最后合成的时候会用原图)。因此定义编辑器数据过程中,元素和蒙层的坐标不能相对于底图,需要相当于容器大小计算。同时能够互相转换。
如下图所示,用户可以再 PC 端定制编辑区域的大小和位置,然后将模板的数据导出到 h5。这里的问题就是 PC 端制作的模板数据(底图,编辑区域相对于容器的位置,宽高)如何做转换的问题。
但本质上也是三个坐标系之间的转换问题。第一个坐标系是 PC 端底图的容器,第二个坐标系是图片底图本身,第三个坐标系是 h5 端底图的容器。底图填充容器的逻辑为:保持宽高比,填满容器的宽或高,另一个方向上居中处理。
用户在定制编辑区域的时候其实是以底图为坐标系的,但为了方便处理,我们将编辑区域的数据保存为以容器为坐标系。这样在 h5 端加载编辑区域的时候需要一套转换逻辑。实际的转换过程如下图所示,我们只需要计算出将底图填充到两个容器的的变换的 ”差“,或者说两个变换结果之间的变换即可,然后就是将求出的变换应用到编辑区域或具体的元素上。
实际的代码可能更好理解一些
/**
* 映射编辑区域,将编辑区域从旧容器映射到新容器
* @param area 原始编辑区域数据
* @param ratio 底图比例
* @param containerSize 原始容器尺寸
* @param newContainerSize 新容器尺寸
* @returns 映射后的编辑区域 EditAreaType
*/
export const projectEditArea = (
area: EditAreaType,
ratio: number,
containerSize: Vector2,
newContainerSize: Vector2,
) => {
const { type, attrs } = area;
// 编辑区域相对于旧的容器的 transform
const transform = {
x: attrs.x,
y: attrs.y,
rotation: 0,
scaleX: 1,
scaleY: 1,
};
// 编辑区域相对于旧容器的 transform 转换为相对于 新容器的 transform
const newTransform = projectTransform(transform, ratio, containerSize, newContainerSize);
// 编辑区域是矩形
if (type === 'Rect') {
const { width, height } = attrs as { width: number, height: number };
return {
type,
attrs: {
x: newTransform.x,
y: newTransform.y,
width: width * newTransform.scaleX,
height: height * newTransform.scaleY,
},
};
}
// 编辑区域是圆形
if (type === 'Circle') {
attrs as { x: number, y: number, radius: number };
const { radius } = attrs as { radius: number };
return {
type,
attrs: {
x: newTransform.x,
y: newTransform.y,
radius: radius * newTransform.scaleX,
},
};
}
return area;
};
/**
* 映射元素的形变
* @param transform 原始容器下的形变
* @param ratio 底图比例
* @param containerSize 原始容器尺寸
* @param newContainerSize 新容器尺寸
* @returns { TransformAttrs } 新容器下的形变
*/
export const projectTransform = (
transform: TransformAttrs,
ratio: number,
containerSize: Vector2,
newContainerSize: Vector2,
) => {
const {
x, y, rotation, scaleX, scaleY,
} = transform;
const [oldContainerWidth, oldContainerHeight] = containerSize;
const oldContainerRatio = oldContainerWidth / oldContainerHeight;
// 底图相对于旧容器的位置,按比例缩放后居中
let origin: null | { x: number, y: number } = null;
// 底图在旧容器按比例缩放后的 size
let imgSize: null | { width: number, height: number } = null;
// 图片宽高比 < 旧容器宽高比 旧容器更宽,横向有空白
if (ratio < oldContainerRatio) {
imgSize = {
height: oldContainerHeight,
width: oldContainerHeight * ratio,
};
origin = {
x: (oldContainerWidth - oldContainerHeight * ratio) / 2,
y: 0,
};
} else {
// 图片宽高比 > 容器宽高比 旧容器更高,上下有空白
imgSize = {
width: oldContainerWidth,
height: oldContainerWidth / ratio,
};
origin = {
x: 0,
y: (oldContainerHeight - oldContainerWidth / ratio) / 2,
};
}
const [newContainerWidth, newContainerHeight] = newContainerSize;
const newContainerRatio = newContainerWidth / newContainerHeight;
let newOrigin: null | { x: number, y: number } = null;
let newImgSize: null | { width: number, height: number } = null;
// 底图比例小于新容器的宽高比,新容器更宽,缩放后横向有空白
if (ratio < newContainerRatio) {
newImgSize = {
width: newContainerHeight * ratio,
height: newContainerHeight,
};
newOrigin = {
y: 0,
x: (newContainerWidth - newContainerHeight * ratio) / 2,
};
} else {
// 底图比例大于新容器的宽高比,新容器更高,缩放后上下有空白
newImgSize = {
width: newContainerWidth,
height: newContainerWidth / ratio,
};
newOrigin = {
x: 0,
y: (newContainerHeight - newContainerWidth / ratio) / 2,
};
}
// 保持宽高比
// 计算旧容器内底图到新容器内底图的缩放比例
const scale = Math.min(newImgSize.width / imgSize.width, newImgSize.height / imgSize.height);
// 累积两次缩放,实现到新容器保持宽高比缩放效果
const newScaleX = scaleX * scale;
const newScaleY = scaleY * scale;
// 编辑区域相对于旧容器底图的位置转换为相对于新容器底图的位置
const newX = (x - origin.x) * scale + newOrigin.x;
const newY = (y - origin.y) * scale + newOrigin.y;
return {
x: newX, y: newY, rotation, scaleX: newScaleX, scaleY: newScaleY,
};
};
3.2 元素操作
3.2.1 缩放 && 旋转元素
缩放和旋转元素的功能如下图所示,要求按住元素右下角的 icon 的时候,可以绕元素中心旋转元素或缩放元素。
这里最好是有一些 2维 平面上仿射变换的知识,理解起来会更轻松,可以参考 闫令琪关于计算机图形学入门的课程中的介绍,这里就直接介绍解法了。
上面动图中所展示的一共有三种仿射变换,缩放,旋转,还有平移。缩放和旋转都很明显,但是为什么有平移 ?因为 Konva 默认的旋转是围绕 ”左上角“ 的,而实际位移的又是 “右下角”,所以如果想要一个围绕中心旋转的效果,就需要移动 “左上角” 把 “右下角”的位移抵消掉。举个例子,放大的时候,右下角向编辑器右下方移动,左上角向编辑器左上方移动,他们的位移方向总是相反且距离相等。
这里我们只需要在拖拽过程中计算出此刻 ”右下角“ 和元素中心构成的向量 和 上个时刻 ”右下角“ 和元素中心构成的向量,之间的比值,角度,和位移。然后再将这三中变换应用到元素上即可,如下图所示,具体的代码这里不再讲解。
3.2.2 拖拽区域限制
元素的拖拽范围限制是一个常见的问题,h5 上期望的效果为元素不可拖出蒙版所在区域,也就是 h5 上底图实际所在的区域。
实现拖拽范围限制功能的一个思路是在拖拽的回调函数中判断当前的元素坐标是否越界,如果越界则修改元素的坐标为不越界的合法坐标。拖动是一个连续的过程,元素在被拖出限定区域之前会有一个临界的时刻,在此之前元素完全在限定区域内,在此之后,元素开始被拖出限定区域。所以,将元素限制在编辑区域内就是要在元素将要离开的最后一刻,修改元素下一刻的位置把它拉回来。
Konva 也直接提供了一个元素的 dragBoundFunc(pos: Konva.vector2d) => Konva.vector2d
函数,其入参是下一个拖动过程中下一个时刻元素 “左上角” 本来的坐标,返回值是下一个时刻元素 “左上角” 最终的坐标。该函数会在拖动过程中不断执行,只需在此函数中填入限制逻辑即可。
需要注意的是,这里面有两个棘手的问题
- 由于元素自身支持旋转,元素的 “左上角” 并不一定一直处于左上角的位置
- 只有元素 “左上角” 下一时刻的坐标,无法计算下一个时刻元素是否越界
这两个问题的解决过程可谓是一波三折。这里需要注意两个点:一是,拖拽是一个连续的过程,拖拽的过程中只有位移,没有其他变换。二是,我们知道的不仅仅是 dragBoundFunc
传入的下一个时刻的 “左上角” 的坐标,我们还可以计算出当前时刻的元素的四个顶点的坐标。
所以,我们可以计算出下一个时刻 “左上角” 坐标和此刻 “左上角” 坐标的偏移量,从而计算出下一个时刻元素的四个顶点的坐标。然后检测,下个时刻的元素是否在限制区域内即可。如下图所示。
好的,现在我们找到了那个将要越界的时刻,我们该如何计算出一个合法的坐标作为下个时刻元素 “左上角” 的坐标 ?你不能直接把边界值,minX minY maxX maxY
这些值返回,因为“左上角”不一定在左上角。
那如果我找到越界的那个点,然后把对应的点和边界对齐,然后再通过三角函数计算呢 ?就像下图中画的这样。
当然可以 😂 ,但是这也太复杂,太不优雅了,你还要获取元素当前旋转的角度,还要判断到底是哪个点越界 ...
有没有更快更简单的方法,当然也有,这又不是在造火箭。如果精确解很困难,找到一个准确度还不错的近似解就是有价值的。 越界的上一刻还是合法的,我们可以“时间回溯”,用上一个时刻 左上角合法的坐标来返回就行了。
if(crossLeft || crossRight || crossTop || crossBottom){
pos = lastPos;
}else {
lastPos = pos;
}
到此为止就已经能实现开头动图中的效果了。
3.3 控制器
Konva 虽然提供了 Transfomer,可以用于实现拖拽缩放、旋转元素。但在 H5 上对操作功能做了定制,如调整层级,删除元素等等,仍然需要自己定义和实现一个元素控制器。
如下图所示,控制器主要包含虚线边框和四角的可点击 icon。要求点击 icon 分别实现弹窗调整层级,复制,删除,按住拖拽缩放大小的能力。
3.3.1 单例模式
控制器最开始是根据元素实例化的,即每添加一个元素都有一个控制器实例。元素被激活(点击)时会显示该元素的控制器 同时隐藏其他所有控制器,元素失焦之后会隐藏该元素的控制器。拖拽元素,缩放元素的过程中需要同步元素的大小到其自身的控制器。
如上图所示,每个 Shape 类都有一个控制器属性,绘制控制器的时候,会传入包含icon 的回调函数的配置。Shape 的拖拽,缩放过程中需要调用控制器提供的公有方法 updateByShape
来同步位置和缩放比例。
这种做法较为简单,易于理解,但会带来以下两个问题
- 画布上的 Shape 增多,难以区分不同元素的 Shape,对于调整元素之间的层级关系(zIndex)造成困难。
- 画布上的控制器的 Shape 增多,可能会造成性能变差。
- 控制器和 Shape 类混杂在一起,概念不清晰,代码上不好维护。
将控制器和 Shape 类拆分后,两个类的职责更单一。 Shape 类面相外部导出,可以做更多定制。控制器类只面相交互,实现编辑功能。
后面梳理后发现并不需要多个控制器实例子,同一时刻处于激活状态的元素只有一个,不会同时编辑(拖拽,缩放)两个元素。使用一个控制器实例,能够减少画布上的 Shape,便于控制元素的层级。后续的代码逐步演变成下图所示。
控制器通过 id 关联当前激活的 ShapeElement
, ShapeElement
类是对 Konva.Shape 类的简单包装,在其上添加了一些生命周期方法和导出方法等。 而控制器类中则实现了 缩放,拖拽等编辑能力,这种模式下,用户缩放和拖拽的其实是外层的控制器,然后控制器再将这些编辑操作通过 syncBorderRect
方法同步到当前激活的 ShapeElement
。
而为了实现点击不同的 ShapeElement
时切换控制器的效果,我们提供了 updateByShapeElement
方法,在 shape 的 onClick 回调中,只需要调用该方法即可。
在这种模式下,原来控制器位于蒙层之上的效果也容易实现了。如下图所示,画布上从下到上分别是:底图,文本/图片元素,蒙层,控制器。
3.3.2 判断当前选中元素
实现当前控制器的另一个难点在于,元素处于蒙版的遮盖的时候,点击元素如何唤起控制器。如上图所示,当元素完全被蒙版遮盖的时候,Konva 提供的元素的 onClick 事件是不会触发的。
这样只能回到在 canvas 上实现点击事件的思路,监听点击事件,根据点击事件的坐标和元素的位置关系来判断选中的元素。
具体的逻辑为:
- 获取点击事件中的坐标
- 通过
d3-polygon
提供的方法判断点击事件的坐标在不在元素的包围盒中。 - 排序找到命中的最上层的元素
- 激活对应元素,直接执行元素的 onClick 回调函数。
3.4 蒙层
3.4.1 蒙层绘制
蒙层的功能主要有两个:1. PC 端方便用户定制编辑区域的大小。2 H5 端起到编辑区域外起到半透明遮盖的效果,编辑区域内可视的效果。
蒙层的元素主要有三个部分,一是背景的半透明的黑色区域,二是拖拽编辑区域大小时外层的框所在的矩形,三是实现透明效果的矩形。可拖拽,缩放的透明矩形框的实现是 Konva Rect + Konva Transformer
,借助了 transfomer
提供的能力实现编辑区域的缩放。而透明效果的矩形主要是借助 Konva Shape
的 sceneFunc
定制形状的能力,通过 canvas 中的 clip 函数实现透明的矩形或者圆形的效果。
3.4.2 导出特定区域
导出图片时限定只导出编辑区域内的功能主要依赖 Konva 提供的 clipFunc
函数,该函数会传入 canvas2d 绘制上下文,只需要绘制出特定的区域,konva 会自动帮我们只导出区域内的内容。
4. 总结
本文介绍了基于 Konva 实现 H5端的轻量级图文编辑器的一种方法,在实现这个轻量级的图文编辑器的过程中我们总结了设计思路和常见的问题处理方案。当然,编辑器的实现是一个需要不断打磨交互和细节的过程,比如像拖拽过程中的辅助线提示、支持文本和图片更丰富的属性等等。篇幅所限,这里不再展开介绍了。希望本文对有志于动手实现编辑器的前端同学能有所助益。
来源:juejin.cn/post/7312243176835334196
three.js实现3D汽车展厅效果展示
今天简单实现一个three.js的小Demo,加强自己对three知识的掌握与学习,只有在项目中才能灵活将所学知识运用起来,话不多说直接开始。
相关源码和模型的下载链接地址 点击链接进行跳转
项目搭建
本案例还是借助框架书写three项目,借用vite构建工具搭建vue项目,搭建完成之后,用编辑器打开该项目,在终端执行 npm i 安装一下依赖,安装完成之后终端在安装 npm i three 即可。
因为我搭建的是vue3项目,为了便于代码的可读性,所以我将three.js代码单独抽离放在一个组件当中,在App根组件中进入引入该组件。具体如下:
<template>
<!-- 3D汽车展厅 -->
<CarShowroom></CarShowroom>
</template>
<script setup>
import CarShowroom from './components/CarShowroom.vue';
</script>
<style lang="less">
*{
margin: 0;
padding: 0;
}
</style>
初始化three.js代码
three.js开启必须用到的基础代码如下:
导入three库:
import * as THREE from 'three'
初始化场景:
const scene = new THREE.Scene()
初始化相机:
// 创建相机
const camera = new THREE.PerspectiveCamera(40,window.innerWidth / window.innerHeight,0.1,1000)
camera.position.set(4.25,1.4,-4.5)
初始化渲染器:
// 创建渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(window.innerWidth,window.innerHeight)
document.body.appendChild(renderer.domElement)
监听屏幕大小的改变,修改渲染器的宽高和相机的比例:
window.addEventListener("resize",()=>{
renderer.setSize(window.innerWidth,window.innerHeight)
camera.aspect = window.innerWidth/window.innerHeight
camera.updateProjectionMatrix()
})
导入轨道控制器:
// 添加轨道控制器
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
// 添加控制器
const controls = new OrbitControls(camera,renderer.domElement)
controls.enableDamping = true // 设置控制阻尼
设置渲染函数:
// 设置渲染函数
const render = (time) =>{
controls.update()
renderer.render(scene,camera)
requestAnimationFrame(render)
}
render()
ok,写完基础代码之后,接下来开始具体的Demo实操。
加载汽车模型
通过使用模型加载器GLTFLoader,然后使用DRACOLoader加载Draco压缩过的模型可以显著减小模型文件体积,从而加快加载速度和提高用户体验。代码如下:
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader";
// 加载汽车模型
const loader = new GLTFLoader()
const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath("/draco/")
loader.setDRACOLoader(dracoLoader)
loader.load("/public/model/Lamborghini.glb",(gltf)=>{
scene.add(gltf.scene)
})
模型加载完成,画面如下:
因为没有灯光,所以我们需要给一个灯光让模型展现出来,这里设置一下环境光源:
// 设置环境光源
const ambientLight = new THREE.AmbientLight('#fff',0.5)
scene.add(ambientLight)
设置展厅效果
这里通过three库中自带的一些模型来实现展厅的效果,如下:
设置地板样式
// 设置地板样式
const floorGeometry = new THREE.PlaneGeometry(20,20)
const floormaterial = new THREE.MeshPhysicalMaterial({
side: THREE.DoubleSide,
color: 0x808080,
metalness: 0, // 设置金属度
roughness: 0.1, // 设置粗糙度
wireframe: false // 关闭网格线
})
const mesh = new THREE.Mesh(floorGeometry,floormaterial)
mesh.rotation.x = Math.PI / 2
scene.add(mesh)
底部样式设置完,设置一个圆柱体将整个地板进行包裹:
// 设置圆柱体模拟展厅
const cylinder = new THREE.CylinderGeometry(12,12,20,32)
const cylindermaterial = new THREE.MeshPhysicalMaterial({
color: 0x6c6c6c,
side: THREE.DoubleSide
})
const cylinderMesh = new THREE.Mesh(cylinder,cylindermaterial)
scene.add(cylinderMesh)
接下来在圆柱体中设置一个聚光灯,让聚光灯偏垂直照射汽车模型,如下:
// 设置聚光灯(让汽车更具有立体金属感)
const spotLight = new THREE.SpotLight('#fff',2)
spotLight.angle = Math.PI / 8 // 散射角度,和水平线的夹角
spotLight.penumbra = 0.2 // 横向,聚光锥的半影衰减百分比
spotLight.decay = 2 // 纵向,沿着光照距离的衰减量
spotLight.distance = 30
spotLight.shadow.radius = 10
spotLight.shadow.mapSize.set(4096,4096)
spotLight.position.set(-5,10,1)
spotLight.target.position.set(0,0,0) // 光照射的方向
spotLight.castShadow = true
scene.add(spotLight)
为了不让展厅穿帮,这里将控制器的缩放以及旋转角度进行一个限制,让其只能在展厅中灵活查看而不能跑到展厅外面去:
controls.maxDistance = 10 // 最大缩放距离
controls.minDistance = 1 // 最小缩放距离
controls.minPolarAngle = 0 // 最小旋转角度
controls.maxPolarAngle = 85 / 360 * 2 * Math.PI // 最大旋转角度
设置GUI面板动态控制车身操作
这里我使用three.js库中自带的gui库,来动态的改变车身相关操作,因为我仅仅是控制车身材质和玻璃材质相关的数据操作,这里就线设置一下其相关的材质:
// 车身材质
let bodyMaterial = new THREE.MeshPhysicalMaterial({
color: 'red',
metalness: 1,
roughness: 0.5,
clearcoat: 1.0,
clearcoatRoughness: 0.03
})
// 玻璃材质
let glassMaterial = new THREE.MeshPhysicalMaterial({
color: '#793e3e',
metalness: 0.25,
roughness: 0,
transmission: 1.0 // 透光性
})
在glb模型中,通过traverse函数遍历场景中的所有对象(包括Mesh、Gr0up、Camera、Light等),并对这些对象进行相应操作或处理(这里的门操作后面会讲解到):
loader.load("/public/model/Lamborghini.glb",(gltf)=>{
const carModel = gltf.scene
carModel.rotation.y = Math.PI
carModel.traverse((obj)=>{
if(obj.name === 'Object_103' || obj.name === 'Object_64' || obj.name === 'Object_77'){
// 车身
obj.material = bodyMaterial
}else if(obj.name === 'Object_90'){
// 玻璃
obj.material = glassMaterial
}else if(obj.name === 'Empty001_16' || obj.name === 'Empty002_20'){
// 门
// doors.push(obj)
}else{
return true
}
})
scene.add(gltf.scene)
})
最后得到的结果如下:
接下来通过控制面板来动态的监视汽车模型的车身和玻璃材质:
// 设置gui模板控制
// 修改默认面板名称
gui.domElement.parentNode.querySelector('.title').textContent = '3D汽车动态操作'
const bodyChange = gui.addFolder("车身材质设置")
bodyChange.close() // 默认关闭状态
bodyChange.addColor(bodyMaterial,'color').name('车身颜色').onChange(value=>{
bodyMaterial.color.set(value)
})
bodyChange.add(bodyMaterial,'metalness',0,1).name('金属度').onChange(value=>{
bodyMaterial.metalness = value
})
bodyChange.add(bodyMaterial,'roughness',0,1).name('粗糙度').onChange(value=>{
bodyMaterial.roughness = value
})
bodyChange.add(bodyMaterial,'clearcoat',0,1).name('清漆强度').onChange(value=>{
bodyMaterial.clearcoat = value
})
bodyChange.add(bodyMaterial,'clearcoatRoughness',0,1).name('清漆层粗糙度').onChange(value=>{
bodyMaterial.clearcoatRoughness = value
})
const glassChange = gui.addFolder("玻璃设置")
glassChange.close() // 默认关闭状态
glassChange.addColor(glassMaterial,'color').name('玻璃颜色').onChange(value=>{
glassMaterial.color.set(value)
})
glassChange.add(glassMaterial,'metalness',0,1).name('金属度').onChange(value=>{
glassMaterial.metalness = value
})
glassChange.add(glassMaterial,'roughness',0,1).name('粗糙度').onChange(value=>{
glassMaterial.roughness = value
})
glassChange.add(glassMaterial,'transmission',0,1).name('透光性').onChange(value=>{
glassMaterial.transmission = value
})
车门操作与车身视角展示
这里依然用GUI控制面板来动态实现开关车门以及车内车外视角动态切换的操作,如下:
var obj = { carRightOpen,carLeftOpen,carRightClose,carLeftClose,carIn,carOut }
// 设置车身动态操作
const doChange = gui.addFolder("车身动态操作设置")
doChange.close() // 默认关闭状态
doChange.add(obj, "carLeftOpen").name('打开左车门')
doChange.add(obj, "carRightOpen").name('打开右车门')
doChange.add(obj, "carLeftClose").name('关闭左车门')
doChange.add(obj, "carRightClose").name('关闭右车门')
doChange.add(obj, "carIn").name('车内视角')
doChange.add(obj, "carOut").name('车外视角')
每个操作都对应一个函数,如下:
// 打开左车门
const carLeftOpen = () => {
setAnimationDoor({ x: 0 }, { x: Math.PI / 3 }, doors[1])
}
// 打开右车门
const carRightOpen = () => {
setAnimationDoor({ x: 0 }, { x: Math.PI / 3 }, doors[0])
}
// 关闭左车门
const carLeftClose = () => {
setAnimationDoor({ x: Math.PI / 3 }, { x: 0 }, doors[1])
}
// 关闭右车门
const carRightClose = () => {
setAnimationDoor({ x: Math.PI / 3 }, { x: 0 }, doors[0])
}
// 车内视角
const carIn = () => {
setAnimationCamera({ cx: 4.25, cy: 1.4, cz: -4.5, ox: 0, oy: 0.5, oz: 0 }, { cx: -0.27, cy: 0.83, cz: 0.60, ox: 0, oy: 0.5, oz: -3 });
}
// 车外视角
const carOut = () => {
setAnimationCamera({ cx: -0.27, cy: 0.83, cz: 0.6, ox: 0, oy: 0.5, oz: -3 }, { cx: 4.25, cy: 1.4, cz: -4.5, ox: 0, oy: 0.5, oz: 0 });
}
这里使用了补间动画tween.js,其github网址为 github.com/tweenjs/twe… ,终端安装其第三方插件之后,直接引入即可,如下(这里不再过多介绍该库的使用,想学习的可以自行寻找其官方文档学习):
接下来借助tween.js库实现补间动画,如下:
// 设置补间动画
const setAnimationDoor = (start, end, mesh) => {
const tween = new TWEEN.Tween(start).to(end, 1000).easing(TWEEN.Easing.Quadratic.Out)
tween.onUpdate((that) => {
mesh.rotation.x = that.x
})
tween.start()
}
const setAnimationCamera = (start, end) => {
const tween = new TWEEN.Tween(start).to(end, 3000).easing(TWEEN.Easing.Quadratic.Out)
tween.onUpdate((that) => {
// camera.postition 和 controls.target 一起使用
camera.position.set(that.cx, that.cy, that.cz)
controls.target.set(that.ox, that.oy, that.oz)
})
tween.start()
}
最终实现的效果如下:
点击查看车内视角的话,画面如下:
设置手动点击打开关闭车门
通过设置监听点击事件函数来动态实现打开关闭车门:
// 设置点击打开车门的动画效果
window.addEventListener('click', onPointClick);
function onPointClick(event) {
let pointer = {}
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = - (event.clientY / window.innerHeight) * 2 + 1;
var vector = new THREE.Vector2(pointer.x, pointer.y)
var raycaster = new THREE.Raycaster()
raycaster.setFromCamera(vector, camera)
let intersects = raycaster.intersectObjects(scene.children);
intersects.forEach((item) => {
if (item.object.name === 'Object_64' || item.object.name === 'Object_77') {
if (!carStatus || carStatus === 'close') {
carLeftOpen()
carRightOpen()
} else {
carLeftClose()
carRightClose()
}
}
})
}
然后给每个车门设置汽车状态,如下:
设置图片背景
为了让展厅更具有视觉效果,接下来设置一个画面背景让其更具有画面感,如下:
// 创建聚光灯函数
const createSpotlight = (color) => {
const newObj = new THREE.SpotLight(color, 2);
newObj.castShadow = true;
newObj.angle = Math.PI / 6;;
newObj.penumbra = 0.2;
newObj.decay = 2;
newObj.distance = 50;
return newObj;
}
// 设置图片背景
const spotLight1 = createSpotlight('#ffffff');
const texture = new THREE.TextureLoader().load('src/assets/imgs/奥特曼.jpg')
spotLight1.position.set(0, 3, 0);
spotLight1.target.position.set(-10, 3, 10)
spotLight1.map = texture
const lightHelper = new THREE.SpotLightHelper(spotLight1);
scene.add(spotLight1);
最终呈现的效果如下:
demo做完,本案例的完整代码获取 地址
来源:juejin.cn/post/7307146429004333094
three 写一个溶解特效,初探 three 着色系统
背景
溶解特效是一个在游戏里非常常见的特效,通常用来表示物体消失或者出现,它的原理也比较简单,这次就来实现一下这个效果,并且通过它来探究下 three.js 的着色器系统。
原理
使用一张噪波图,根据时间动态改变进度 progress
, 根据这个值与噪波图数值做比较,决定使用过渡色还是舍弃当前片元。
过渡色
为了使用过渡色,我们定义一个作用范围变量 edgeWidth
用来表示当前进度和 噪波数值(noiseValue)
之间的区域,这个区域填充 过渡色(edgeColor)
。
变化速度
progress
的变化通过变化速度(DissolveSpeed)
来控制。
类型
溶解可以分为 出现和消失 两种类型,两种类型可以互相转换,我们可以通过判断 progress
的边界来重新设置 progress
的增加量符号(加号变减号,减号变加号),并重新设置 progress
的值等于 0 || 1
来重新设置变化边界。
原理讲完了,接下来进入实践。
实践
先从最简单的 wavefront
格式说起,再拓展到其他更通用模型或者材质的用法。
波前 wavefront 格式
作为 3D 模型最早的格式之一,.obj
后缀的格式是由 wavefront 公司开发的,由于容易和其他常见类型的文件比如 gcc 编译的过程文件 .obj
混淆,将其表述为 wavefront
模型格式。
对于这个格式来说,几何数据和材质数据是分开加载的,你需要先加载 .obj
格式的文件,然后再去加载材质数据文件 .mtl
。对于我们的示例来说是需要使用 ShaderMaterial
来自定义着色效果,因而我们直接加载对应的 材质贴图 做原理展示,就不使用 .mtl
的加载器了。
需要做的其实只有两步:
- 读取的模型后用
Geometry
和ShaderMaterial
创建新的Mesh
。
- 读取的模型后用
ShaderMaterial
的unifroms.progress
在requestAnimationFrame
里做更新。
直接来看下着色器怎么写:
顶点着色器:
let vertexShader = /* glsl */`
varying vec2 vUv;
void main()
{
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
主要是定义了 vUv
这个可传递变量,为了把内置的纹理坐标传递到 fragmentShader
。
片元着色器:
let fragShader = /* glsl */`
uniform float progress;
uniform float edgeWidth;
uniform vec3 edgeColor;
uniform sampler2D mainTexture;
uniform sampler2D noiseTexture;
varying vec2 vUv;void main(void){
vec4 originalColor = texture2D(mainTexture, vUv);
float noiseValue = texture2D(noiseTexture, vUv).r;
vec4 finalColor = originalColor;
if(noiseValue > progress)
{
discard;
}
if(noiseValue + edgeWidth > progress){
finalColor = vec4(edgeColor, 1.0);
}
gl_FragColor = finalColor;
}
`;
其中 originColor
是原始材质贴图,类型是 vec4
,noiseValue
是读取的噪波贴图取 r
通道的值,事实上,噪波图是灰度图,所以取 rgb
任意通道的都可以。然后对于 noiseValue
,随着 progress
逐渐增大,小于 progress
数值的噪波片元越来越少,模型出现。下面那句 + edgeWidth
则是把 edgeColor
填充到里面,原理是一样的。最后输出颜色。
这是出现的逻辑,如果是要消失呢?控制下边界条件就可以了:
function render() {
requestAnimationFrame(render);
controller.update();
// 出现
if (uniforms.progress.value < 0) {
uniforms.progress = 0;
stride = dissolveSpeed;
}
// 消失
if (uniforms.progress.value > 1) {
uniforms.progress = 1;
stride = -dissolveSpeed;
}
uniforms.progress.value += stride;
renderer.render(scene, camera);
}
效果立竿见影:
再想一遍
写着色器和通用程序不大一样,单纯按上面这么讲可能不是很清晰,我们更深度地分析下,培养一下 rgb
思维。
已知出现和消失是互为逆过程,通过 CPU
端程序重新改变变化方向即可,我们按照一个状态,关注边界条件,分别从正向和逆向进行思考,给出两个版本分别的代码。
按照上面说的,我们关注,比如就 出现 的状态吧,边界条件是 阈值 和 噪波值 的比较结果。也就是 progress
和 noiseValue
。
用 Exclidraw 画下示意图:
考虑 出现 的情况,剩余的进度或者叫阈值(越来越小), 与当前片元噪声值比较大小,如果更大则舍弃掉表示还没出现的部分;与当前值往前剪掉的部分比较,如果更大则使用这个过渡色;其他情况是已经出现的部分,直接保留就可以了。
写成代码:
void main() {
...
float restProgress = 1.0 - dissolveProgress;
if(noiseValue < restProgress) {
discard;
}
if(noiseValue - edgeWidth < restProgress ) {
gl_FragColor = finalColor;
}
...
}
反向来思考,随着阈值增加,出现的图像越来多,往前减掉过渡值(edgeWidth
), 这部分呈现过渡色;小于当前 noiseValue
的部分舍弃,是还没出现的部分。
写成代码:
void main() {
...
if(noiseValue > dissolveProgress)
{
discard;
}
if(noiseValue + edgeWidth > dissolveProgress){
gl_FragColor = vec4(edgeColor, 1.0);
}
...
}
这样,我们就用两种等价的方法实现了同一效果,后面的章节我们使用 glsl
函数把 条件判断 语句去掉。
这里其实叫 edgeWidth
有歧义,换成 edgeThickness
可能比较符合,如果这个值过大,就会超出变化范围出现异常,所以还是要把其限制在一个比较小的范围,这里为了调试先让它最大值等于 1。
edgeWidth
值过大:
其他格式
我们拿更常用的其他格式来研究一下。通常 web
端会使用 gltf, fbx
等通用格式,我们这里拿 web
端最通用的 gltf
格式模型来说明,其他通用模型类型道理一样。
对于 gltf
格式来说,加载完模型就赋予了材质,可能的类型有 MeshStandardMaterial, MeshPhongMaterial, MeshBasicMaterial
等,我用封面的士兵模型,使用的是 MeshStandardMaterial
类型的材质,接下来看如何修改内置着色器而实现效果。
ShaderChunk 和 ShaderLib
来看下 three
的目录,较新版本的 three
把核心代码安排在 src
目录下,/examples/jsm
目录下则是以 插件addons
的形式引入的额外功能,比如 GLTFLoader
之类比较通用的功能。而内部着色器的实现在 src/renderers/shaders
目录下:
我们直接打开 ShaderLib.js
文件找下模型使用的 MeshStandardMaterial
的定义:
可以看到是复用了 meshphysical
的着色器,这对着色器还在 MeshPhysicalMaterial
材质里被使用,通过材质类定义的 defines
字段来开启相应的计算,这样的做法使得 MeshStandardMaterial
作为 MeshPhysicalMaterial
的回退选项。到 ShaderChunk
目录下打开 meshphysical.glsl.js
看下宏定义:
OK,已经了解了材质定义和对应着色器的关系了,接下来就是如何把我们的逻辑加到相应着色器字符串里了。
onBeforeCompile
官方文档约等于没写,还是去看 examples
的代码吧,关键字 onBeforeCompile
搜索下:
右下角点进去看代码:
这下就明白了,顾名思义,这个函数可以在编译着色器程序之前允许我们插入自己的代码, 我们可以根据功能对相应模块进行覆写或者添加功能,我们不希望修改修改默认着色器的内容,直接把溶解效果加到最后,接下来看下怎么做。
调试
按照这个做法,非常依赖 javascript
的 replace
方法,我们需要小心操作,经过实验,把所有代码放到同一串里是没问题的,这里需要反复打印调试,如果有问题请使用双引号来使用原始字符串。
如果没有处理格式,直接塞进去不会对齐的,很好辨认:
接下来直接移植代码:
看到注释的那句话了吗,如果注释掉,并把阈值开到最大覆盖全部范围,可以明显看到和设置的颜色不一样,原因是因为之前的 shader
代码处理结果是转化到线性输出显示的,我们在标准着色器最后处理,一样要做线性转化。这个线性转化的意思是 gamma
变换的逆变换, gamma
变换是由于人眼对于颜色的感知非线性,非线性的原因和视锥细胞,视杆细胞数量比例不一样有关,省略一万字,大家有兴趣自己去搜~
没有线性转换:
线性转换后颜色就正常了:
拓展
再换一种写法
之前我们用直接舍弃片元的方法来实现过渡,接下来我们使用更 shader
风格的写法来重写,因为这个效果显示和消失具有二值性(要么有颜色要么透明),可以用 step(x,y)
函数来写,这个函数比较 y > x
的结果,true
则返回 1,否则返回 0 , 正好可以来表达透明度。
看代码,只有 fragmentShader
不一样:
这里的想法是先控制是否显示颜色,找的边界就是 noiseValue - edgeWidth
,然后再判断使用原来的像素或者过渡色,如果大于 noiseValue
使用原来的像素,否则使用过渡的颜色,然后 mix
函数这里的第三个变量刚好是 step
函数的结果,所以就可以切换这两颜色了。
哦对,记得设置这个 material.transparent = true;
,否则会使用默认的混合颜色白色:
整活
昨天在沸点发了两张图,其实很简单,到这里把过渡色换成贴图采样就行了,比如这样:
学会了吗?赶紧搬到项目里惊艳领导吧。
思考
- 能否和环境做交互?
更新
在线 Demo: wwjll.github.io/three-pract…
写文章不易,点赞收藏是最好的支持~
来源:juejin.cn/post/7344958089429254182
手把手使用Blender+ThreeJS制作跨栏小游戏
效果展示
- 先录制的视频,再转化为GIF图片导致展示效果有点延迟,实际效果还是挺丝滑的,感兴趣的可以上手尝试一下
人物模型和动画获取
- 在mixamo.com网站,需要先登录一下,可以直接使用Google邮箱登录,然后来到Characters页,下方有100多种人物模型,点击左边卡片可以选择自己喜欢的人物模型,或者如下图和我选的一样
- 然后来到Animations页,默认如下图红框内展示的也是刚才我们选择的人物模型,如果右侧展示不对,需要回到Characters页重新选择人物模型
- 因为动画比较多,这里我们直接在左上角搜索框内搜索自己想要的动作动画即可,首先搜索Idle,我这里使用的动画是Happy Idle,还将右侧的Overdrive的值从50调整到75,值越大动画频率越快,调整好后直接点击右上方的DOWNLOAD
- 弹出的弹窗里的内容都不想要修改,直接点击如下图右下角的DOWNLOAD,等待一会后,选择本地文件夹下载即可
- 接着左上角搜索Running,选择Running卡片,并且勾选右侧的In Place,让人物模型在原地跑动;如果不勾选的话,因为动画本身带有位移,会影响我们使用ThreeJS改变人物模型的position控制人物位移的;设置好后直接点击DOWNLOAD,同样弹出的弹窗不需要修改,直接点击弹窗右下角的DOWNLOAD下载即可
- 继续左上角搜索Jump,选择Jump卡片,并且勾选右侧的In Place,让人物模型在原地跑动;设置好后直接点击DOWNLOAD,同样弹出的弹窗不需要修改,直接点击弹窗右下角的DOWNLOAD下载即可
- 继续左上角搜索Death,选择Falling Back Death卡片,这个动画不需要其他调整,直接点击DOWNLOAD,同样弹出的弹窗不需要修改,直接点击弹窗右下角的DOWNLOAD下载即可
- 这样就下载好了Idle(待机动作)、Running(跑步动作)、Jump(跳跃动作)、Death(死亡动作)的一组动作,以上动作的都可以根据个人喜好调整;如果打开mixamo.com网站比较慢或者下载有问题的话,也可以直接使用我下载好的actions里的动画模型
动画模型合并
- 打开Blender新建文件选择常规,我使用的版本是3.6.14的,不同版本可能存在差异
- 在右上角场景集合内,鼠标左键拖拽框选默认已有的Camera、Cube和Light,然后右键选择删除或者英文输入法下按下x键快速删除;后续在Blender里的所有操作,均需将输入法切换到英文输入;如果对Blender不是特别熟悉,可以优先阅读我之前整理的Blender学习整理这篇文章
- 选择左上角菜单栏里文件-->导入-->FBX(.fbx),就会弹出导入设置弹窗
- 弹窗内只需选中之前下载好的Idle.fbx文件,然后直接点击右下角导入FBX即可
- 文件导入后,在右上角场景集合内,将动画下一级的目录鼠标双击重命名成idle
- 在中间布局窗口,将右上角的视图着色方式切换到材质预览,然后把观察点切换到-X方向,将下方窗口切换到非线性动画
- 点击动画右侧的按键,作用是下推动作块(将动作块作为新的片段下推到NLA堆栈顶部),有点类似展开下拉菜单的效果,实际作用是创建NLA轨道,然后将idle动作放到一个单独的通道中
- 取消勾选通道对结果是否有影响(切换通道是否启用)
- 导入Running.fbx,导入流程和上述一致,导入后在右上角场景集合内重命名动画下一级目录为running
- 同样点击动画右侧的按键,下推动作块
- 同样取消勾选通道对结果是否有影响(切换通道是否启用)
- 鼠标点击选中idle动作,变成如下图颜色即被选中
- 选择上方添加-->添加动作片段
- 然后选中running
- 这时,在idle动作上方就添加了一个running动作通道,双击左侧如下图红框内重命名为running,左侧的名称就是我们后续使用ThreeJS控制人物模型切换动画时要使用的动作变量名
- 在右上角场景集合内,选中后面导入的模型右键后点击删除或者按下x键快速删除,将其删除
- 第一次删除可能删除不干净,场景集合内还有多余的文件,布局窗口也有遮挡物,这个遮挡物其实就是第二个模型里的人物建模,第一次只是把动画给删除掉了,所以需要再次如下图选中右侧红框内文件继续删除才能删除干净;注意删除的时候最好把导入的第一个模型收起来,防止误删
- 如上述操作后,就把idle动画和running动画合并到一个人物模型里,再重复上述操作,把Jump.fbx和Death.fbx文件依次导入进来,并且把jump动画和death动画添加到idle动画上方,每个动作一个通道;全部搞定后,可以点击如下图红框内的五角星,实心就代表被选中,依次选中每个动作,取消勾选通道影响,还可以按下键盘的空格播放各个动画
- 最后选择左上角文件-->导出-->glTF2.0(.glb/.gltf),会弹出导出设置弹窗
- 弹窗内选择本地合适的文件夹,直接点击右下角的导出glTF2.0即可
- 可以参考我仓库里的models里的actions.glb
跨栏和跑道模型获取
- 在sketchfab.com网站,首先需要先登录,同样可以使用Google邮箱登录,然后在搜索框输入hurdle,按下回车
- 然后可以在下方很多的跨栏模型中选择自己喜欢的,我这里选择的是Wii - Wii Play - Hurdle
- 点击模型左下角的Download 3D Model,会弹出下载选项弹窗
- 弹窗里选择下载GLB格式的文件,这个格式会将所有内容(包含模型的结构、材质、动画和其他元数据等)打包在一起,文件大小可能会更大,但管理方便,所有内容都在一个文件中
- 下载到本地后,重命名为hurdle.glb
- 同样的方式,搜索track,我这边使用的模型是Dusty foot path way in grass garden,忘记了当时的筛选条件了,怎么就在搜索track的时候找到了这个模型;因为需要游戏里面的跑道不间断的出现,所以需要这种能够重复拼接的跑道模型,大家也可以自行选择喜欢的模型,下载好后,本地重命名为track.glb
- 如果访问sketchfab.com网站比较慢,或者下载有问题的话,可以直接使用我仓库里的models里的hurdle.glb和track.glb
模型合并
- 为了和之前的内容不搅合,就不在之前的actions.blender文件里添加其他两个模型,这里使用Blender新建一个常规文件,同样删除默认的Camera、Cube和Light
- 选择左上角文件-->导入-->glTF2.0(.glb/.gltf),会弹出导入弹窗
- 选择之前保存的actions.glb,直接点击右下角的导入glTF2.0即可
- 导入后,同样把视图着色方式切换到材质预览,观察点切换到—X方向上
- 在右上角的场景集合内,将当前模型重命名为acitons
- 导入下载好的hurdle.glb,在右上角的场景集合内,刚导入的模型只有最里面的Object_2才是真的跨栏网格对象,外面两层结构没有作用,还可能出现外层结构的旋转缩放等属性和内部实际的网格对象的属性不一致,影响我们对实际网格对象的控制,所以最好删除掉
- 依次选中外面两层文件,按下x键删除,最后把Object_2重命名为hurdle
- hurdle模型尺寸比较大,旋转方向也不对,鼠标左键选中hurdle模型,然后在属性栏,将旋转的X调整成-1.0,将缩放的XYZ全部调整成0.1,这里不需要调整的特别精确,后续编码时还能使用ThreeJS继续调整模型的旋转和尺寸;如果布局窗口没有属性栏,可以按下n键显示/隐藏属性栏;输入值时,鼠标拖动三个输入框能够同时改变XYZ的值
- 继续选中hurdle模型,按下ctrl + a然后选择全部变换,需要把之前调整的旋转和缩放应用为自身数据
- 导入下载好的track.glb,可以切换右侧红框的眼睛图标显示/隐藏该对象来观察整体模型变化,会发现实际起作用的是Object_2和Object_3的两个网格对象,两个外层结构的显示/隐藏看似对模型的显示没有影响,其实它们是有属性是对模型有影响的,需要把它们的属性变换应用到自身
- 在右上角场景集合选中第一层结构,在属性栏会发现它有一些旋转和缩放,在布局窗口按下ctrl+a,然后选择应用全部变换;再选中第二层结构,同样按下ctrl+a应用全部变换
- 这时就会发现,外层的旋转和缩放都已经作用到Object_2和Object_3的两个网格对象上了,就可以依次选中两个外层结构,按下x键删除两个外层结构了;并且依次选中Object_2和Object_3的两个网格对象按下ctrl+a选择全部变换把外层结构的旋转和缩放应用到自身
- 然后在右上角场景集合内,鼠标先选中Object_3,按下shift键再选中Object_2,然后鼠标回到布局窗口,按下ctrl+p选择物体,这里发现Object_2变成了Object_3的子级了,理论上最后被选中的对象是父级,我想要的效果是Object_3变成了Object_2的子级,所以这里我又撤销(ctrl+z)重新先选中Object_2,按下shift键再选中Object_3,再按下ctrl+p选择物体绑定的父子关系;这里不清楚是因为我使用中文翻译的问题还是Blender有更新,有了解的大佬希望帮忙解释一下
- 将合并后的父级Object_2重命名为track
- 左键选中track模型,右键选择设置原点-->原点->几何中心
- 在选中track模型的情况下,继续按下shift+s选择选中项->游标,如果游标没有在世界原点,需要先将游标设置到世界原点
- 将观察点切换到顶视图,按下r+z绕着Z轴旋转,使跑道的长边和Y轴平行
- 再切换到-X方向,按下r+x绕着X轴旋转,使跑道的上面和Y轴平行
- 选择左上角文件-->导出-->glTF2.0(.glb/.gltf)
- 导出设置弹窗内,直接将合并后的模型导出到我们后续编码要用的文件夹内,其他无需设置,直接选择导出glTF2.0即可
编码渲染
- 使用vite搭建的项目工程,完整源码点这里code,整个渲染过程都在src/hooks/userDraw.js文件里
- 首先是ThreeJS的基础代码,放到了src/hooks/modules/base.js文件里,我这里只是往scene添加了一个背景纹理,最后就是把scene、camera、renderer暴露出来方便其他模块引用
import * as THREE from 'three';
/**
* 基础代码
*/
export default function () {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
camera.position.set(0, 0, 5); // 设置相机位置
const renderer = new THREE.WebGLRenderer({
antialias: true // 开启抗锯齿
});
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// 加载背景纹理
const textureLoader = new THREE.TextureLoader();
textureLoader.load('./bg.jpeg', function (texture) {
// 将纹理设置为场景背景
scene.background = texture;
});
// 适配窗口
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight; // 重置摄像机视锥体的长宽比
camera.updateProjectionMatrix(); // 更新摄像机投影矩阵
renderer.setSize(window.innerWidth, window.innerHeight); // 重置画布大小
});
return {
scene,
camera,
renderer
};
}
- 在src/hooks/modules/controls.js文件里添加控制器,并且需要禁用控制器
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
/**
* 控制器
*/
export default function (camera, renderer) {
const orbitControls = new OrbitControls(camera, renderer.domElement); // 轨道控制器
orbitControls.enabled = false; // 禁用控制器
orbitControls.update(); // 更新控制器
}
- 在src/hooks/modules/light.js文件里添加环境光,上下方各添加了一个平行光
import * as THREE from 'three';
/**
* 灯光
*/
export default function (scene) {
const ambientLight = new THREE.AmbientLight(0x404040, 20); // 环境光
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 5); // 平行光
directionalLight.position.set(0, 10, 5);
scene.add(directionalLight);
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 5); // 平行光
directionalLight2.position.set(0, -10, -5);
scene.add(directionalLight2);
}
- src/hooks/userDraw.js文件没有进一步优化,下面主要介绍几个核心部分
- 把之前合并好的模型,使用GLTFLoader加载进来,然后初始化人物、跨栏、跑道等模型;因为模型的XYZ轴向和ThreeJS的没有对应上,所以需要给各个模型创建一个Gr0up方便单独控;获取跑道宽度时,因为获取的不是特别准确,所以又减去了2,让跑道叠加在一起了,不是特别严丝合缝;然后就是创建动画混合器,把动作保存起来了,然后默认播放待机动作;最后开始帧循环渲染
// 加载人物模型
const loader = new GLTFLoader();
loader.load('./models/group.glb', function (gltf) {
const children = [...gltf.scene.children];
// 初始化人物模型
global.characterGr0up.add(children[0]);
global.characterGr0up.rotation.set(0, Math.PI / 2, 0); // 改变人物朝向
scene.add(global.characterGr0up);
// 初始化跨栏模型
global.hurdleGr0up.add(children[1]);
global.hurdleGr0up.scale.set(0.7, 0.7, 0.7); // 缩小跨栏
global.hurdleGr0up.rotation.set(0, Math.PI / 2, 0); // 改变跨栏朝向
global.hurdleGr0up.position.set(3, 0, 0); // 设置第一个跨栏位置
global.hurdleArr.push(global.hurdleGr0up); // 添加第一个跨栏触发碰撞检测
scene.add(global.hurdleGr0up);
// 初始化跑道模型
global.trackGr0up.add(children[2]);
global.trackGr0up.rotation.set(0, Math.PI / 2, 0); // 改变跑道朝向
scene.add(global.trackGr0up);
// 获取跑道宽度
const boundingBox = new THREE.Box3().setFromObject(global.trackGr0up); // 创建包围盒
const size = new THREE.Vector3(); // 计算包围盒的尺寸
boundingBox.getSize(size);
global.trackWidth = size.x - 2; // 跑道宽度不是特别准确,需要模糊计算
// 默认使用trackNum个跑道拼接
for (let i = 0; i < trackNum; i++) {
const newTrackModel = global.trackGr0up.clone(); // 克隆原始跑道模型
newTrackModel.position.x = i * global.trackWidth; // 按照宽度依次排列
scene.add(newTrackModel);
global.trackArr.push(newTrackModel); // 保存引用
}
// 创建动画混合器
global.animationMixer = new THREE.AnimationMixer(global.characterGr0up);
// 将每个动画剪辑存储在actions对象中
gltf.animations.forEach((clip) => {
global.actions[clip.name] = global.animationMixer.clipAction(clip);
});
// 播放默认的 idle 动作
global.currentAction = global.actions['idle'];
global.currentAction.play();
// 开始渲染循环
animate();
});
- animate函数里主要功能有开启帧循环;更新动画混合器;在跳跃动作结束后切换回跑步动作;当人物处于跑步和跳跃动作时,更新人物位置及让相机跟随人物移动,并且在移动过程中,在间隔帧数内生成新的跨栏;更新跑道的位置,如果最左侧的跑道超出屏幕后,把它移动到最右侧;当人物处于跳跃动作时,更新人物Y轴位置;如果人物和跨栏发生碰撞时,切换到死亡动作并且开启死亡状态,防止键盘按键还能继续触发;当播放完死亡动作后,提示游戏结束,并结束帧数循环;渲染场景
function animate() {
global.frame = requestAnimationFrame(animate); // 开启帧循环
global.animationMixer.update(global.clock.getDelta()); // 更新动画混合器
// 检查 jump 动作是否完成,并恢复到 running 动作
if (
global.currentAction === global.actions['jump'] &&
global.currentAction.time >= global.currentAction.getClip().duration
) {
switchAction('running', 0.3);
}
// 当处于 running 动作时,移动相机
if (
global.currentAction === global.actions['running'] ||
global.currentAction === global.actions['jump']
) {
global.characterGr0up.position.x += moveSpeed;
camera.position.x = global.characterGr0up.position.x;
// 间隔随机帧数生成跨栏
if (
global.hurdleCountFrame++ >
hurdleInterval + Math.random() * hurdleInterval
) {
generateHurdles(global.hurdleGr0up, global.hurdleArr, scene); // 生成跨栏
global.hurdleCountFrame = 0;
}
}
// 更新跑道位置
updateTrack(camera, global.trackArr, global.trackWidth);
// 当人物处于跳跃动作时,更新人物位置
updateCharacterPosition(
global.animationMixer,
global.clock,
global.currentAction,
global.actions,
global.characterGr0up
);
// 碰撞检测
if (
checkCollisions(
global.characterGr0up,
global.characterBoundingBox,
global.hurdlesBoundingBoxes,
global.hurdleArr
)
) {
switchAction('death');
global.isDeath = true;
}
// 如果 death 动作完成了,则停止帧动画
if (
global.currentAction === global.actions['death'] &&
!global.currentAction.isRunning()
) {
Modal.error({
title: 'Game Over',
width: 300
});
cancelAnimationFrame(global.frame);
}
// 渲染场景
renderer.render(scene, camera);
}
- 切换动作函数主要是在一定时间内淡出前一个动作,并且淡入新动作,如果是跳跃动作或者死亡动作的话只执行一次
function switchAction(newActionName, fadeDuration = 0.5) {
const newAction = global.actions[newActionName];
if (newAction && global.currentAction !== newAction) {
global.previousAction = global.currentAction; // 保留当前的动作
// 淡出前一个动作
if (global.previousAction) {
global.previousAction.fadeOut(fadeDuration);
}
// 如果切换到 jump 动作,设置播放一次并在结束后停止
if (newActionName === 'jump') {
newAction.loop = THREE.LoopOnce;
newAction.clampWhenFinished = true; // 停止在最后一帧
}
// 如果切换到 death 动作,设置播放一次并在结束后停止
if (newActionName === 'death') {
newAction.loop = THREE.LoopOnce;
newAction.clampWhenFinished = true; // 停止在最后一帧
}
global.currentAction = newAction; // 设置新的活动动作
// 复位并淡入新动作
global.currentAction.reset();
global.currentAction.setEffectiveTimeScale(1);
global.currentAction.setEffectiveWeight(1);
global.currentAction.fadeIn(fadeDuration).play();
}
}
- 键盘事件监听,给按键WSAD和方向键上下左右都添加了切换动作的功能,如果是死亡状态的,按键失效
window.addEventListener('keydown', (event) => {
if (global.isDeath) {
return;
}
switch (event.code) {
case 'keyD':
case 'ArrowRight':
switchAction('running');
break;
case 'keyA':
case 'ArrowLeft':
switchAction('idle');
break;
case 'keyW':
case 'ArrowUp':
switchAction('jump');
break;
}
});
- src/configs/index.js文件配置了一些常量,可以用来控制游戏状态
// 初始跑道数量
export const trackNum = 3;
// 跨栏之间的间隔帧数
export const hurdleInterval = 50; // 50~100帧之间
// 跨栏之间的间隔最小距离
export const hurdleMinDistance = 5; // 5~10距离之间
// 人物移动的速度
export const moveSpeed = 0.03;
- src/utils/index.js文件主要是一些辅助函数
import * as THREE from 'three';
import { hurdleMinDistance } from '../configs/index';
/**
* 生成新的跨栏
*
* @param {Object} oldModel - 要克隆的原始跨栏模型。
* @param {Array} hurdleArr - 现有跨栏模型的数组。
* @param {Object} scene - 要添加新跨栏模型的场景。
* @return {undefined}
*/
export function generateHurdles(oldModel, hurdleArr, scene) {
const newModel = oldModel.clone(); // 克隆原始跨栏模型
const nextPosition =
hurdleArr[hurdleArr.length - 1].position.x +
hurdleMinDistance +
Math.random() * hurdleMinDistance;
newModel.position.set(nextPosition, 0, 0);
hurdleArr.push(newModel);
scene.add(newModel);
}
/**
* 更新跑道位置
*
* @param {Object} camera - 具有位置属性的摄像机对象。
* @param {Array} trackArr - 具有位置属性的轨道段对象数组。
* @param {Number} trackWidth - 每个轨道段的宽度。
* @return {undefined}
*/
export function updateTrack(camera, trackArr, trackWidth) {
const cameraPositionX = camera.position.x; // 相机的 x 坐标
// 遍历所有跑道段
for (let i = 0; i < trackArr.length; i++) {
const trackSegment = trackArr[i];
// 提前检测跑道段是否即将超出视野(增加一个提前量,比如半个跑道段的宽度)
const threshold = cameraPositionX - trackWidth * 1.5;
if (trackSegment.position.x < threshold) {
// 找到当前最右边的跑道段
let maxX = -Infinity;
for (let j = 0; j < trackArr.length; j++) {
if (trackArr[j].position.x > maxX) {
maxX = trackArr[j].position.x;
}
}
// 将当前跑道段移动到最右边
trackSegment.position.x = maxX + trackWidth;
}
}
}
/**
* 人物跳跃时,更新人物Y轴位置
*
* @param {Object} animationMixer - 动画混合器对象。
* @param {Object} clock - 用于获取增量时间的时钟对象。
* @param {Object} currentAction - 当前正在执行的动作。
* @param {Object} action - 可用动作的集合。
* @param {Object} characterGr0up - 角色组对象。
* @return {undefined}
*/
export function updateCharacterPosition(
animationMixer,
clock,
currentAction,
actions,
characterGr0up
) {
// 更新动画混合器
animationMixer.update(clock.getDelta());
// 检查动画状态并调整位置
if (currentAction === actions['jump']) {
// 根据跳跃动画的时间调整人物位置
const jumpHeight = 0.8; // 你可以调整这个值
characterGr0up.position.y =
Math.sin(currentAction.time * Math.PI) * jumpHeight;
} else {
characterGr0up.position.y = 0; // 恢复到地面位置
}
}
/**
* 检测人物是否与跨栏发生了碰撞
*
* @param {Object} characterGr0up - 角色组对象。
* @param {Object} characterBoundingBox - 角色的边界框对象。
* @param {Array} hurdlesBoundingBoxes - 跨栏的边界框数组。
* @param {Array} hurdleArr - 跨栏对象数组。
* @return {Boolean} 是否发生了碰撞。
*/
export function checkCollisions(
characterGr0up,
characterBoundingBox,
hurdlesBoundingBoxes,
hurdleArr
) {
// 更新人物的边界框
if (characterGr0up) {
characterBoundingBox.setFromObject(characterGr0up);
}
// 更新跨栏的边界框
hurdlesBoundingBoxes = hurdleArr.map((hurdle) => {
const box = new THREE.Box3();
box.setFromObject(hurdle);
return box;
});
for (let i = 0; i < hurdlesBoundingBoxes.length; i++) {
if (characterBoundingBox.intersectsBox(hurdlesBoundingBoxes[i])) {
return true; // 检测到碰撞
}
}
return false; // 没有检测到碰撞
}
不足
- 跑道的纹理和材质没有渲染出来,不知道是否是导出的模型有问题,有懂的大佬可以帮忙看看
- 目前合并后的模型导出后体积比较大,还需要解决模型压缩的问题
来源:juejin.cn/post/7405153695506022451
这段时间 weapp-vite 的功能更新与优化
这段时间 weapp-vite
的功能更新与优化
自从上次宣布 weapp-vite
的发布,已经过去三个月;weapp-vite
也逐渐迭代至 1.7.6
版本。
在此期间,我对其进行了多项功能的增强和优化,接下来我将为大家详细介绍近期的阶段性成果。
下面列出的功能皆为增强特性,开发者可自由选择启用或关闭,不影响原生小程序的兼容性。
核心功能更新
1. 自动构建 npm
在项目启动时,weapp-vite
会自动构建 npm
依赖,无需再手动点击微信开发者工具中的 构建 npm
,提升了一定程度的开发体验。
详细信息请参考:自动构建 npm 文档。
2. 语法增强
2.1 JSON
文件增强
1. 支持注释
weapp-vite
支持在项目中的 JSON
文件中添加注释。例如:
{
/* 这是一个组件 */
"component": true,
"styleIsolation": "apply-shared",
"usingComponents": {
// 导航栏组件
"navigation-bar": "@/navigation-bar/navigation-bar"
}
}
这些注释会在最终产物内被去除。
注意:
project.config.json
和project.private.config.json
不支持注释,因为这些文件直接由微信开发者工具读取。
2. 智能提示
我生成了许多小程序的 $schema
文件,部署在 vite.icebreaker.top
上。
通过指定 JSON
的 $schema
字段,实现了配置文件的智能提示功能,优化了一点点开发体验。
详见:JSON 配置文件的智能提示。
3. 别名支持
可以在 vite.config.ts
中配置 jsonAlias.entries
字段, 在 usingComponents
中使用别名定义路径,这些在构建时会自动转化为相对路径。
例如:
import type { UserConfig } from 'weapp-vite/config'
import path from 'node:path'
export default <UserConfig>{
weapp: {
jsonAlias: {
entries: [
{
find: '@',
replacement: path.resolve(__dirname, 'components'),
},
],
},
},
}
那么就可以在 json
中这样编写:
{
"usingComponents": {
"navigation-bar": "@/navigation-bar/navigation-bar",
"ice-avatar": "@/avatar/avatar"
}
}
构建结果:
{
"usingComponents": {
"navigation-bar": "../../components/navigation-bar/navigation-bar",
"ice-avatar": "../../components/avatar/avatar"
}
}
4. 编程支持
weapp-vite
支持使用 JS/TS
文件来编写 JSON
,你需要将 component.json
更改为 component.json.ts
:
智能提示定义
API
都在weapp-vite/json
中导出
比如普通写法:
import { defineComponentJson } from 'weapp-vite/json'
export default defineComponentJson({
component: true,
styleIsolation: 'apply-shared',
usingComponents: {},
})
还支持引入异步数据、编译时变量或其他文件:
import type { Page } from 'weapp-vite/json'
import fs from 'node:fs/promises'
import path from 'node:path'
import shared0 from '@/assets/share'
import shared1 from './shared.json'
console.log('import.meta.env: ', import.meta.env)
console.log('import.meta.dirname: ', import.meta.dirname)
console.log('MP_PLATFORM: ', import.meta.env.MP_PLATFORM)
console.log(import.meta.env.DEV, import.meta.env.MODE, import.meta.env.PROD)
const key = await fs.readFile(
path.resolve(import.meta.dirname, 'x.txt'),
'utf8'
)
export default <Page>{
usingComponents: {
't-button': 'tdesign-miniprogram/button/button',
't-divider': 'tdesign-miniprogram/divider/divider',
'ice-avatar': '@/avatar/avatar',
},
...shared0,
...shared1,
key,
}
2.2 WXML
文件增强
事件绑定语法糖
weapp-vite
借鉴了 Vue
的事件绑定风格,为 WXML
增加了事件绑定语法糖:
这里我们以最常用的 tap
事件为例:
<!-- 原始代码 -->
<view @tap="onTap"></view>
<!-- 编译后 -->
<view bind:tap="onTap"></view>
支持的事件绑定增强规则如下:
源代码 | 编译结果 |
---|---|
@tap | bind:tap |
@tap.catch | catch:tap |
@tap.mut | mut-bind:tap |
@tap.capture | capture-bind:tap |
@tap.capture.catch / @tap.catch.capture | capture-catch:tap |
详见:事件绑定增强文档。
这部分还能做的更多,欢迎与我进行讨论!
2.3 WXS
增强
编程支持(实验性)
weapp-vite
为 WXS
提供了 JS/TS
编程支持,支持通过更改 wxs
后缀为 wxs.js
或 wxs.ts
文件定义逻辑:
比如 index.wxs.ts
:
export const foo = '\'hello world\' from hello.wxs.ts'
export const bar = function (d: string) {
return d
}
另外内联 WXS
也支持使用 lang="js"
或 lang="ts"
直接启用编译功能:
<view>{{test.foo}}</view>
<view @tap="{{test.tigger}}">{{test.abc}}</view>
<wxs module="test" lang="ts">
const { bar, foo } = require('./index.wxs.js')
const bbc = require('./bbc.wxs')
export const abc = 'abc'
export function tigger(value:string){
console.log(abc)
}
export {
foo,
bar,
bbc
}
</wxs>
详情请参考:Wxs 增强。
3. 生成脚手架
weapp-vite
内置了生成脚手架工具,可快速生成一系列文件(如 js
、wxml
、wxss
和 json
),用于提升开发效率。
最基础的用法只需要 weapp-vite g [outDir]
详情请参考:生成脚手架文档。
4. 分包支持
针对普通分包和独立分包的加载需求进行了优化,用户几乎无需额外配置即可实现分包加载。
尤其是独立分包的场景,创建了独立的编译上下文。
详情请参考:分包加载文档。
不忘初心,持续改进
weapp-vite
的初衷是实现对原生小程序的增强,现有原生小程序几乎可以零成本地迁移过来,并享受更高效的开发体验。
在此,希望各位开发者试用,欢迎反馈与参与。
如果您对文中的任何功能或增强有疑问、建议,欢迎到 Github Discussions 提出讨论!
来源:juejin.cn/post/7437876830487363599
JavaScript内存管理机制解析
前言
内存,作为计算机系统中存储数据和指令的关键资源,其管理效率直接影响着程序的性能和稳定性。在JavaScript
的世界里,理解内存机制并非遥不可及,每一位开发者必须面对并掌握的实用技能。无论是初涉开发的新手,还是经验丰富的老手,深入理解JavaScript
的内存机制都是通往更高层次编程能力的必经之路。
语言类型
静态语言
静态语言是指在编译时变量的数据类型就已经确定的语言,比如java
定义一个整数类型需要先用int
去定义一个变量。这类语言在编写程序时,要求开发者明确地声明变量的类型,并且在程序的整个生命周期内,该变量的类型都不能改变。换句话说,静态语言的类型检查是在编译阶段完成的,而不是在运行时,常见的静态语言包括Java、C++、C#、Go
等。
动态语言
动态语言(Dynamic Language),也称为动态编程语言或动态类型语言,与静态语言相反,是指在程序运行时可以改变其结构的语言。这种改变可能包括引进新的函数、删除已有的函数,或者在运行时确定变量的类型等。动态语言的特点使得它们通常具有更高的灵活性和表达能力。常见的动态语言有我们学的JavaScript,还有Python,PHP
等。
弱类型语言
弱类型语言是指变量的类型检查和转换方式相对宽松的一种编程语言。在弱类型语言中,变量可以在不明确声明类型的情况下直接使用,并且在运行时可以自动改变类型,或者可以在不同类型之间自由进行操作和转换,常见的弱类型语言包括JavaScript、Python
等。
强类型语言
强类型语言(Strongly Typed Language)是一种在编译时期就进行类型检查的编程语言。这类语言要求变量在使用前必须明确声明其类型,并且在使用过程中,变量的类型必须保持一致,不能随意更改,常见的强类型语言包括Java、C++、C#、Go
等。
数据类型
在每种语言里面都会有一个方法去查看数据的类型,js
也不例外,我们可以用typeof
去查看一个数据的类型,那我们来看看js
中所有的数据类型吧
let a = 1
// console.log(typeof a); //Number
a = 'hello'
// console.log(typeof a); //String
a = true
// console.log(typeof a); //boolean
a = null
// console.log(typeof a); //object
a = undefined
// console.log(typeof a); //undefined
a = Symbol(1)
// console.log(typeof a); //symbol
a = 123n
// console.log(typeof a); //bigint
a = []
// console.log(typeof a); // object
a = {}
// console.log(typeof a); //object
a = function () {}
// console.log(typeof a); // function
我们可以看到所有判断类型的结果,大部分还正常,可是数组和null
怎么也被判断成了object
类型呢?
那我们要来了解一下typeof
的判断原理,怎么给a
判断出来它的数据类型的呢,其实是通过转换为计算机能看懂的二进制,然后通过二进制的数据进行的分析,所有的引用类型转换成二进制前三位一定是零,然后数组是引用类型,而typeof
判断时如果前三位是零,那么就无脑认为它是object
类型,但是函数是一个特例,在js
中函数是一个对象,它做了一些特殊操作,所以能够判断出来,但是null
是原始数据类型,为什么也能被判断为object
类型呢,因为null
在被读取成二进制时,它会被读取为全是零。而这个不是编程语言能够决定的,在计算机创建出来时就是这样设定的,因此这是一个bug
,在设计这门语言的的bug
,这个bug
如果要修复并不困难,但是一旦修复,所有用js语言开发的项目都需要修复,影响太大,因此这个bug
就被默认为js
语言里面的规则。
内存空间
内存空间的分布
在v8引擎执行你写的代码时,会占用一部分的运行空间,而执行时占用的内存空间在v8的视角里会被分布成这个样子的
代码空间是专门存储你所写的代码,栈空间就是我们之前讲过的调用栈juejin.cn/post/743706…
用来存储函数被调用时,它的执行上下文,维护函数的调用关系,调用栈被分布的空间是比较小的。
堆空间(Heap Space)是内存管理的一个重要部分,它用于存储动态分配的对象和数据结构。
栈和堆之间的关系
让我们来看看栈和堆之间的关系
function foo() {
var a = 1
var b = a
var c = {name: '熊总'}
var d = c
}
foo()
此时foo函数
已经完成编译,且已经执行到了b=a
这一行,然后将一个对象赋值给c
的时候,并不会直接把这个对象存储在函数的执行上下文里面,而是会在旁边在创建一个堆空间,将对象存储在堆空间里面,而这个c
存储的就是对象在堆空间的地址值
然后在执行将c
的值赋给d其实就是将对象的地址值赋值给了d
,因此c
和d
的地址值指向的是同一个对象,并没有创建出一个新的对象,如果这个对象发生改变,那么c
和d
所代表的对象都会发生改变。
那为什么原始数据类型可以直接存储在栈当中,而引用数据类型却要存储在堆空间里面,因为原始类型数据所占的空间小,而引用数据类型所占的空间较大,比如一个对象,它可以有无数个属性,而原始类型,它就只有一个固定的值,所占内存不大,而栈被分布的空间比较小,堆被分布的空间比较大,因此原始数据类型可以直接存储在栈当中,而引用数据类型要存储在堆当中。
栈设计为什么这么小
首先我们要明白栈是用来维护函数的调用关系,而如果将栈设计的很大,那么程序员就可以写很长作用域链,并且不考虑代码的执行效率,写出不断嵌套的屎山代码。举个例子,栈就好比在你身上的空间,比如你的衣服裤子口袋,而堆就相当于一个分层的柜子,你把衣服上的口袋设计的很大,不要柜子,把你的东西全部装在口袋里面,首先看起来就十分丑陋,其次,你如果想将你想要的东西拿出来就要在口袋里翻来覆去的找,那样的效率是很低的
成果检验
function fn(person) {
person.age = 19
person = {
name: '庆玲',
age: 19
}
return person
}
const p1 = {
name: '凤如',
age: 18
}
const p2 = fn(p1)
console.log(p1);
console.log(p2);
请分析上面的代码中的p1
和p2
的输出结果
我们创建全局上下文进行编译执行,然后对函数fn进行编译,编译过程中形参和实参要进行统一,接下来,我们要开始执行函数fn
了,首先它将p1
所指向的对象age
修改为了19
,然后再函数中它将p1
的地址值修改指向为了新对象,并将新对象返回,然后在全局接着执行,将返回的地址值赋给了p2
,所以p2的值就是函数中新对象的地址值,接下来输出p1
,此时函数已经执行完毕,在调用栈中被销毁了,那我们就在全局中查找,在全局中p1
的指向就是#001
,但是函数销毁前他将地址值为#001
的对象age
属性修改为19
,所以p1
打印出来的只有age
改为了19
,而p2
就是返回的新对象的值,然我们看看结果是不是我们分析的那样
没错,p1
的name
为'凤如',age
为19
,p2
的name
为'庆玲',age
为19
最后来一道添加闭包的内存管理机制代码分析,如果不熟悉闭包的概念,可以先看看这篇文章
](juejin.cn/post/743814…)
function foo() {
var myname = '彭于晏'
let test1 = 1
const test2 = 2
var innerBar = {
setName: function (name) {
myname = name
},
getName: function () {
console.log(test1);
return myname
}
}
return innerBar
}
var bar = foo()
bar.setName('金城武')
console.log(bar.getName());
总结
本文探讨了JavaScript
的内存机制,包括语言类型(静态与动态、强类型与弱类型)、数据类型及typeof
的判断原理,并解析了内存空间的分布,特别是栈空间和堆空间的作用及它们之间的关系。通过示例代码,阐述了原始数据类型和引用数据类型在内存中的存储差异,以及栈为何设计得相对较小的原因。最后,通过实际代码演示和结果分析,检验了对JavaScript
内存机制的理解。本文是掌握JavaScript
编程能力的关键一步,适合各层次开发者阅读。
来源:juejin.cn/post/7440717815709057050
前端如何做截图?
一、 背景
页面截图功能在前端开发中,特别是营销场景相关的需求中, 是比较常见的。比如截屏分享,相对于普通的链接分享,截屏分享具有更丰富的展示、更多的信息承载等优势。最近在需求开发中遇到了相关的功能,所以调研了相关的实现和原理。
二、相关技术
前端要实现页面截图的功能,现在比较常见的方式是使用开源的截图npm库,一般使用比较多的npm库有以下两个:
- dom-to-image: github.com/tsayen/dom-…
- html2canvas: github.com/niklasvh/ht…
以上两种常见的npm库,对应着两种常见的实现原理。实现前端截图,一般是使用图形API重新绘制页面生成图片,基本就是SVG(dom-to-image)和Canvas(html2canvas)两种实现方案,两种方案目标相同,即把DOM转为图片,下面我们来分别看看这两类方案。
三、 dom-to-image
dom-to-image库主要使用的是SVG实现方式,简单来说就是先把DOM转换为SVG然后再把SVG转换为图片。
(一)使用方式
首先,我们先来简单了解一下dom-to-image提供的核心api,有如下一些方法:
- toSvg (dom转svg)
- toPng (dom转png)
- toJpeg (dom转jpg)
- toBlob (dom转二进制格式)
- toPixelData (dom转原始像素值)
如需要生成一张png的页面截图,实现代码如下:
import domtoimage from "domtoimage"
const node = document.getElementById('node');
domtoimage.toPng(node,options).then((dataUrl) => {
const img = new Image();
img.src = dataUrl;
document.body.appendChild(img);
})
toPng方法可传入两个参数node和options。
node为要生成截图的dom节点;options为支持的属性配置,具体如下:filter,backgroundColor,width,height,style,quality,imagePlaceholder,cacheBust。
(二)原理分析
dom to image的源码代码不是很多,总共不到千行,下面就拿toPng方法做一下简单的源码解析,分析一下其实现原理,简单流程如下:
整体实现过程用到了几个函数:
- toPng(调用draw,实现canvas=>png )
- Draw(调用toSvg,实现dom=>canvas)
- toSvg(调用cloneNode和makeSvgDataUri,实现dom=>svg)
- cloneNode(克隆处理dom和css)
- makeSvgDataUri(实现dom=>svg data:url)
- toPng
toPng函数比较简单,通过调用draw方法获取转换后的canvas,利用toDataURL转化为图片并返回。
function toPng(node, options) {
return draw(node, options || {})
.then((canvas) => canvas.toDataURL());
}
- draw
draw函数首先调用toSvg方法获得dom转化后的svg,然后将获取的url形式的svg处理成图片,并新建canvas节点,然后借助drawImage()方法将生成的图片放在canvas画布上。
function draw(domNode, options) {
return toSvg(domNode, options)
// 拿到的svg是image data URL, 进一步创建svg图片
.then(util.makeImage)
.then(util.delay(100))
.then((image) => {
// 创建canvas,在画布上绘制图像并返回
const canvas = newCanvas(domNode);
canvas.getContext("2d").drawImage(image, 0, 0);
return canvas;
});
// 新建canvas节点,设置一些样式的options参数
function newCanvas(domNode) {
const canvas = document.createElement("canvas");
canvas.width = options.width || util.width(domNode);
canvas.height = options.height || util.height(domNode);
if (options.bgcolor) {
const ctx = canvas.getContext("2d");
ctx.fillStyle = options.bgcolor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
return canvas;
}
}
- toSvg
- toSvg函数实现从dom到svg的处理,大概步骤如下:
- 递归去克隆dom节点(调用cloneNode函数)
- 处理字体,获取所有样式,找到所有的@font-face和内联资源,解析并下载对应的资源,将资源转为dataUrl给src使用。把上面处理完的css rules放入中,并把标签加入到clone的节点中去。
- 处理图片,将img标签的src的url和css中backbround中的url,转为dataUrl使用。
- 获取dom节点转化的dataUrl数据(调用makeSvgDataUri函数)
function toSvg(node, options) {
options = options || {};
// 处理imagePlaceholder、cacheBust值
copyOptions(options);
return Promise.resolve(node)
.then((node) =>
// 递归克隆dom节点
cloneNode(node, options.filter, true))
// 把字体相关的csstext放入style
.then(embedFonts)
// clone处理图片,将图片链接转换为dataUrl
.then(inlineImages)
// 添加options里的style放入style
.then(applyOptions)
.then((clone) =>
// node节点转化成svg
makeSvgDataUri(clone,
options.width || util.width(node),
options.height || util.height(node)));
// 处理一些options的样式
function applyOptions(clone) {
...
return clone;
}
}
- cloneNode
cloneNode函数主要处理dom节点,内容比较多,简单总结实现如下:
- 递归clone原始的dom节点,其中, 其中如果有canvas将转为image对象。
- 处理节点的样式,通过getComputedStyle方法获取节点元素的所有CSS属性的值,并将这些样式属性插入新建的style标签上面, 同时要处理“:before,:after”这些伪元素的样式, 最后处理输入内容和svg。
function cloneNode(node, filter, root) {
if (!root && filter && !filter(node)) return Promise.resolve();
return Promise.resolve(node)
.then(makeNodeCopy)
.then((clone) => cloneChildren(node, clone, filter))
.then((clone) => processClone(node, clone));
function makeNodeCopy(node) {
// 将canvas转为image对象
if (node instanceof HTMLCanvasElement) return util.makeImage(node.toDataURL());
return node.cloneNode(false);
}
// 递归clone子节点
function cloneChildren(original, clone, filter) {
const children = original.childNodes;
if (children.length === 0) return Promise.resolve(clone);
return cloneChildrenInOrder(clone, util.asArray(children), filter)
.then(() => clone);
function cloneChildrenInOrder(parent, children, filter) {
let done = Promise.resolve();
children.forEach((child) => {
done = done
.then(() => cloneNode(child, filter))
.then((childClone) => {
if (childClone) parent.appendChild(childClone);
});
});
return done;
}
}
function processClone(original, clone) {
if (!(clone instanceof Element)) return clone;
return Promise.resolve()
.then(cloneStyle)
.then(clonePseudoElements)
.then(copyUserInput)
.then(fixSvg)
.then(() => clone);
// 克隆节点上的样式。
function cloneStyle() {
...
}
// 提取伪类样式,放到css
function clonePseudoElements() {
...
}
// 处理Input、TextArea标签
function copyUserInput() {
...
}
// 处理svg
function fixSvg() {
...
}
}
}
- makeSvgDataUri
首先,我们需要了解两个特性:
- SVG有一个元素,这个元素的作用是可以在其中使用具有其它XML命名空间的XML元素,换句话说借助标签,我们可以直接在SVG内部嵌入XHTML元素,举个例子:
<svg xmlns="http://www.w3.org/2000/svg">
<foreignObject width="120" height="50">
<body xmlns="http://www.w3.org/1999/xhtml">
<p>文字。</p>
</body>
</foreignObject>
</svg>
可以看到标签里面有一个设置了xmlns=“http://www.w3.org/1999/xhtml”…标签,此时标签及其子标签都会按照XHTML标准渲染,实现了SVG和XHTML的混合使用。
- XMLSerializer对象能够把一个XML文档或Node对象转化或“序列化”为未解析的XML标记的一个字符串。
基于以上特性,我们再来看一下makeSvgDataUri函数,该方法实现node节点转化为svg,就用到刚刚提到的两个重要特性。
首先将dom节点通过
XMLSerializer().serializeToString() 序列化为字符串,然后在
标签 中嵌入转换好的字符串,foreignObject 能够在 svg
内部嵌入XHTML,再将svg处理为dataUrl数据返回,具体实现如下:
function makeSvgDataUri(node, width, height) {
return Promise.resolve(node)
.then((node) => {
// 将dom转换为字符串
node.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
return new XMLSerializer().serializeToString(node);
})
.then(util.escapeXhtml)
.then((xhtml) => `<foreignObject x="0" y="0" width="100%" height="100%">${xhtml}</foreignObject>`)
// 转化为svg
.then((foreignObject) =>
// 不指定xmlns命名空间是不会渲染的
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${
foreignObject}</svg>`)
// 转化为data:url
.then((svg) => `data:image/svg+xml;charset=utf-8,${svg}`);
}
四、 html2canvas
html2canvas库主要使用的是Canvas实现方式,主要过程是手动将dom重新绘制成canvas,因此,它只能正确渲染可以理解的属性,有许多CSS属性无法正确渲染。
支持的CSS属性的完整列表:
html2canvas.hertzen.com/features/
浏览器兼容性:
Firefox 3.5+ Google Chrome Opera 12+ IE9+ Edge Safari 6+
官方文档地址:
html2canvas.hertzen.com/documentati…
(一)使用方式
// dom即是需要绘制的节点, option为一些可配置的选项
import html2canvas from 'html2canvas'
html2canvas(dom, option).then(canvas=>{
canvas.toDataURL()
})
常用的option配置:
全部配置文档:
html2canvas.hertzen.com/configurati…
(二)原理分析
html2canvas的内部实现相对dom-to-image来说要复杂一些, 基本原理是读取DOM元素的信息,基于这些信息去构建截图,并呈现在canvas画布中。
其中重点就在于将dom重新绘制成canvas的过程,该过程整体的思路是:遍历目标节点和目标节点的子节点,遍历过程中记录所有节点的结构、内容和样式,然后计算节点本身的层级关系,最后根据不同的优先级绘制到canvas画布中。
由于html2canvas的源码量比较大,可能无法像dom-to-image一样详细的分析,但还是可以大致了解一下整体的流程,首先可以看一下源码中src文件夹中的代码结构,如下图:
简单解析一下:
- index:入口文件,将dom节点渲染到一个canvas中,并返回。
- core:工具函数的封装,包括对缓存的处理函数、Context方法封装、日志模块等。
- css:对节点样式的处理,解析各种css属性和特性,进行处理。
- dom:遍历dom节点的方法,以及对各种类型dom的处理。
- render:基于clone的节点生成canvas的处理方法。
基于以上这些核心文件,我们来简单了解一下html2canvas的解析过程, 大致的流程如下:
- 构建配置项
在这一步会结合传入的options和一些defaultOptions,生成用于渲染的配置数据renderOptions。在过程中会对配置项进行分类,比如resourceOptions(资源跨域相关)、contextOptions(缓存、日志相关)、windowOptions(窗口宽高、滚动配置)、cloneOptions(对指定dom的配置)、renderOptions(render结果的相关配置,包括生成图片的各种属性)等,然后分别将各类配置项传到下接下来的步骤中。
- clone目标节点并获取样式和内容
在这一步中,会将目标节点到指定的dom解析方法中,这个过程会clone目标节点和其子节点,获取到节点的内容信息和样式信息,其中clone dom的解析方法也是比较复杂的,这里不做详细展开。获取到目标节点后,需要把克隆出来的目标节点的dom装载到一个iframe里,进行一次渲染,然后就可以获取到经过浏览器视图真实呈现的节点样式。
- 解析目标节点
目标节点的样式和内容都获取到了之后,就需要把它所承载的数据信息转化为Canvas可以使用的数据类型。在对目标节点的解析方法中,递归整个DOM树,并取得每一层节点的数据,对于每一个节点而言需要绘制的部分包括边框、背景、阴影、内容,而对于内容就包含图片、文字、视频等。在整个解析过程中,对目标节点的所有属性进行解析构造,转化成为指定的数据格式,基础数据格式可见以下代码:
class ElementContainer {
// 所有节点上的样式经过转换计算之后的信息
readonly styles: CSSParsedDeclaration;
// 节点的文本节点信息, 包括文本内容和其他属性
readonly textNodes: TextContainer[] = [];
// 当前节点的子节点
readonly elements: ElementContainer[] = [];
// 当前节点的位置信息(宽/高、横/纵坐标)
bounds: Bounds;
flags = 0;
...
}
具体到不同类型的元素如图片、IFrame、SVG、input等还会extends ElementContainer拥有自己的特定数据结构,在此不详细贴出。
- 构建内部渲染器
把目标节点处理成特定的数据结构之后,就需要结合Canvas调用渲染方法了,Canvas绘图需要根据样式计算哪些元素应该绘制在上层,哪些在下层,那么这个规则是什么样的呢?这里就涉及到CSS布局相关的一些知识。
默认情况下,CSS是流式布局的,元素与元素之间不会重叠。不过有些情况下,这种流式布局会被打破,比如使用了浮动(float)和定位(position)。因此需要需要识别出哪些脱离了正常文档流的元素,并记住它们的层叠信息,以便正确地渲染它们。
那些脱离正常文档流的元素会形成一个层叠上下文。元素在浏览器中渲染时,根据W3C的标准,所有的节点层级布局,需要遵循层叠上下文和层叠顺序的规则,具体规则如下:
在了解了元素的渲染需要遵循这个标准后,Canvas绘制节点的时候,需要生成指定的层叠数据,就需要先计算出整个目标节点里子节点渲染时所展现的不同层级,构造出所有节点对应的层叠上下文在内部所表现出来的数据结构,具体数据结构如下:
// 当前元素
element: ElementPaint;
// z-index为负, 形成层叠上下文
negativeZIndex: StackingContext[];
// z-index为0、auto、transform或opacity, 形成层叠上下文
zeroOrAutoZIndexOrTransformedOrOpacity: StackingContext[];
// 定位和z-index形成的层叠上下文
positiveZIndex: StackingContext[];
// 没有定位和float形成的层叠上下文
nonPositionedFloats: StackingContext[];
// 没有定位和内联形成的层叠上下文
nonPositionedInlineLevel: StackingContext[];
// 内联节点
inlineLevel: ElementPaint[];
// 不是内联的节点
nonInlineLevel: ElementPaint[];
基于以上数据结构,将元素子节点分类,添加到指定的数组中,解析层叠信息的方式和解析节点信息的方式类似,都是递归整棵树,收集树的每一层的信息,形成一颗包含层叠信息的层叠树。
- 绘制数据
基于上面两步构造出的数据,就可以开始调用内部的绘制方法,进行数据处理和绘制了。使用节点的层叠数据,依据浏览器渲染层叠数据的规则,将DOM元素一层一层渲染到canvas中,其中核心具体源码如下:
async renderStackContent(stack: StackingContext): Promise<void> {
if (contains(stack.element.container.flags, FLAGS.DEBUG_RENDER)) {
debugger;
}
// 1. the background and borders of the element forming the stacking context.
await this.renderNodeBackgroundAndBorders(stack.element);
// 2. the child stacking contexts with negative stack levels (most negative first).
for (const child of stack.negativeZIndex) {
await this.renderStack(child);
}
// 3. For all its in-flow, non-positioned, block-level descendants in tree order:
await this.renderNodeContent(stack.element);
for (const child of stack.nonInlineLevel) {
await this.renderNode(child);
}
// 4. All non-positioned floating descendants, in tree order. For each one of these,
// treat the element as if it created a new stacking context, but any positioned descendants and descendants
// which actually create a new stacking context should be considered part of the parent stacking context,
// not this new one.
for (const child of stack.nonPositionedFloats) {
await this.renderStack(child);
}
// 5. the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks.
for (const child of stack.nonPositionedInlineLevel) {
await this.renderStack(child);
}
for (const child of stack.inlineLevel) {
await this.renderNode(child);
}
// 6. All positioned, opacity or transform descendants, in tree order that fall int0 the following categories:
// All positioned descendants with 'z-index: auto' or 'z-index: 0', in tree order.
// For those with 'z-index: auto', treat the element as if it created a new stacking context,
// but any positioned descendants and descendants which actually create a new stacking context should be
// considered part of the parent stacking context, not this new one. For those with 'z-index: 0',
// treat the stacking context generated atomically.
//
// All opacity descendants with opacity less than 1
//
// All transform descendants with transform other than none
for (const child of stack.zeroOrAutoZIndexOrTransformedOrOpacity) {
await this.renderStack(child);
}
// 7. Stacking contexts formed by positioned descendants with z-indices greater than or equal to 1 in z-index
// order (smallest first) then tree order.
for (const child of stack.positiveZIndex) {
await this.renderStack(child);
}
}
在renderStackContent方法中,首先对元素本身调用renderNodeContent和renderNodeBackgroundAndBorders进行渲染处理。
然后处理各个分类的子元素,如果子元素形成了层叠上下文,就调用renderStack方法,这个方法内部继续调用了renderStackContent,这就形成了对于层叠上下文整个树的递归。
如果子元素是正常元素没有形成层叠上下文,就直接调用renderNode,renderNode包括两部分内容,渲染节点内容和渲染节点边框背景色。
async renderNode(paint: ElementPaint): Promise<void> {
if (paint.container.styles.isVisible()) {
// 渲染节点的边框和背景色
await this.renderNodeBackgroundAndBorders(paint);
// 渲染节点内容
await this.renderNodeContent(paint);
}
}
其中renderNodeContent方法是渲染一个元素节点里面的内容,其可能是正常元素、文字、图片、SVG、Canvas、input、iframe,对于不同的内容也会有不同的处理。
以上过程,就是html2canvas的整体内部流程,在了解了大致原理之后,我们再来看一个更为详细的源码流程图,对上述流程进行一个简单的总结。
五、 常见问题总结
在使用html2canvas的过程中,会有一些常见的问题和坑,总结如下:
(一)截图不全
要解决这个问题,只需要在截图之前将页面滚动到顶部即可:
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
(二)图片跨域
插件在请求图片的时候会有图片跨域的情况,这是因为,如果使用跨域的资源画到canvas中,并且资源没有使用CORS去请求,canvas会被认为是被污染了,canvas可以正常展示,但是没办法使用toDataURL()或者toBlob()导出数据,详情可参考:developer.mozilla.org/en-US/docs/…
解决方案:在img标签上设置crossorigin,属性值为anonymous,可以开启CROS请求。当然,这种方式的前提还是服务端的响应头Access-Control-Allow-Origin已经被设置过允许跨域。如果图片本身服务端不支持跨域,可以使用canvas统一转成base64格式,方法如下。
function getUrlBase64_pro( len,url ) {
//图片转成base64
var canvas = document.createElement("canvas"); //创建canvas DOM元素
var ctx = canvas.getContext("2d");
return new Promise((reslove, reject) => {
var img = new Image();
img.crossOrigin = "Anonymous";
img.onload = function() {
canvas.height = len;
canvas.width = len;
ctx.drawImage(img, 0, 0, len, len);
var dataURL = canvas.toDataURL("image/");
canvas = null;
reslove(dataURL);
};
img.onerror = function(err){
reject(err)
}
img.src = url;
});
}
(三)截图与当前页面有区别
方式一:如果要从渲染中排除某些elements,可以向这些元素添加data-html2canvas-ignore属性,html2cnavas会将它们从渲染中排除,例如,如果不想截图iframe的部分,可以如下:
html2canvas(ele,{
useCORS: true,
ignoreElements: (element: any) => {
if (element.tagName.toLowerCase() === 'iframe') {
return element;
}
return false;
},
})
方式二:可以将需要转化成图片的部分放在一个节点内,再把整个节点,透明度设置为0, 其他部分层级设置高一些,即可实现截图指定区域。
六、 小结
本文针对前端截图实现的方式,对两个开源库dom-to-image和html2canvas的使用和原理进行了简单的使用方式、实现原理方面,进行介绍和分析。
参考资料:
1.dom-to-image原理
2.html2image原理简述
3.浏览器端网页截图方案详解
4.html2canvas
5.html2canvas实现浏览器截图的原理(包含源码分析的通用方法)
来源:juejin.cn/post/7400319811358818340
做了这么久前端,这些请求头和响应头的含义你都不知道啊
前言
如果你是一名开发,肯定对请求头和响应头这两个词听上去特别有亲切感,请求头和响应头顾名思义就是请求和响应相关的一些信息,但具体到请求头和响应头里面的某个参数是啥意思可能很多人就不知道了。
就像最近问到一些面试者响应头里面最常见的Cache-Control
和Content-Type
所代表的是什么意思,很多都回答的支支吾吾的。真要说在项目中这种面试者也肯定能正常搬砖干活,但一看就是基本功非常差,如果有对比选择的情况下非常容易被"pass"
掉。
因此这篇文章主要对比较常用的请求头和响应头进行解释,除了能应对面试官外还能对知识面进行扩展。
什么是请求头和响应头
简单说请求头和响应头就是HTTP
协议的组成部分,请求头和响应头用于在客户端(浏览器)和服务器之间携带传递额外的属性,这些属性内容会用于控制HTTP
请求和响应的行为。
其中请求头是客户端带给服务端,响应头是服务端带给客户端。
常见请求头含义
Accept:
含义:表示指定客户端能够接受哪些类型的内容。
当客户端用接口请求时,设置Accept
会告诉服务器要返回合适的类型格式。
示例
accept: application/json, text/plain,
Accept-Charset
含义: 表示指定客户端能够接受哪些类型的字符集。
Accept-Charset: utf-8, iso-8859-1;q=0.5
Cookie
含义: 表示用于存储用户特有信息,让用品去识别用户的具体身份。通过Cookie
传递用户ID
,让服务器端识别用户身份。
示例
Cookie: session=abPC9527; user=tty
Origin
含义: 表示跨域相关信息,用于设置CORS
的请求。通过Origin
头,防止陌生的域进行请求。
示例
Origin: https://tty.com
Referer
含义: 表示当前的请求是从哪个url
链接过来的。
示例
Referer: https://tty.com/pageone
User-Agent
含义: 表示包含发起请求的用户的一些代理信息,例如浏览器的具体版本和具体类型。
示例
User-Agent: Mozilla/3.0 (Windows NT 9.0; Win32; x64) AppleWebKit/517.36 (KHTML, like Gecko) Chrome/56.0.3029.110 Safari/517.3
If-Modified-Since
含义: 表示客户端在上次获取资源的具体时间。
示例
If-Modified-Since: Tue, 10 Oct 2021 11:01:01 GMT
Range
含义: 表示指定第一个字节到指定最后字节之间的位置,用于告诉服务器想取那个范围的数据。
示例
Range: bytes=0-255
常见响应头含义
Access-Control-Allow-Origin
含义: 表示用于配置CORS
跨域相关,指定允许访问资源的域名,如果配置为*
表示所有可访问。
示例
Access-Control-Allow-Origin: *
Cache-Control
含义: 表示缓存机制的缓存策略。
示例------这里面试重点
Cache-Control:public // 响应会被缓存
Cache-Control:must-revalidate // 指定条件下会缓存重用
Cache-Control:no-cache // 直接向服务器端请求最新资源,不缓存
Cache-Control:max-age=10 // 设置缓存的有效时间
Cache-Control:no-store // 在任何条件下,响应都不会被缓存
Content-Length
含义: 表示当前响应体的具体大小,具体单位为字节。
示例
Content-Length: 9527
Content-Type
含义: 表示响应体的具体数据格式是什么。
示例
Content-Type: application/json
Date
含义: 表示服务器开始对客户端发送响应的具体时间。
示例
Date: Tue, 10 Oct 2021 11:01:01 GMT
ETag
含义: 表示用于验证缓存,确保当前的资源未被修改过。如果没有更改过则返回304
状态码,减少不必要传输。
示例
ETag: "1234952790pc"
Location
含义: 表示用于重定向,指向一个新的URL
。
示例
Location: https://tty.com/new-page
Set-Cookie
含义: 表示服务器通过这个请求头把cookie
带到客户端。客户端会在后面请求中自动将这cookie
放在请求头中。
示例
Set-Cookie: session=pc9527; Path=/; HttpOnly; Secure
Server
含义: 表示告诉这个服务器软件的信息,例如版本。
示例
Server: Apache/1.4.38 (Ubuntu)
X-Powered-By
含义: 表示返回后端使用的具体框架或技术栈。
示例
X-Powered-By: Express
Content-Encoding
含义: 表示响应体的编码方式,例如gzip
压缩。
示例
Content-Encoding: gzip
Last-Modified
含义: 表示资源最后被修改的具体时间。
示例
Last-Modified: Tue, 10 Oct 2021 11:00:00 GMT
Expires
含义: 跟缓存相关,表示指定资源的过期时间,这个时间前都不过期。
示例
Expires: Wed, 21 Oct 2021 07:21:00 GMT
小结
这些内容看似好像日常写业务代码没咋用到,但其实是非常重要的,里面涉及到缓存、跨域和安全相关等等的内容。
这些内容足够验证一个开发知识面是否足够广。
好啦,以上就是比较常见的响应头和请求头的一些字段。如果哪里写的不对或者有更好有建议欢迎指出。
来源:juejin.cn/post/7438451242567319571
纯前端图片压缩神器 Compressor
点赞 + 关注 + 收藏 = 学会了
本文简介
现在大部分网站都会有图片,不管这个图片是用来展示的,还是需要上传到服务器的。
但图片的体积往往比文字大,会占用更多的服务器空间,也会消耗用户更多的流量。所以在适当范围内压缩一下图片是很有必要的。
今天介绍一款纯前端的图片压缩工具:compressor.js。
虽然这是一款有损的图片压缩工具,但压缩质量还是挺不错的,尤其是它可以在前端运行,对于要上传图片到服务器的业务,可以考虑一下用 compressor.js。
你也可以用 Compressor.js 做个图片压缩的工具网站,用户多了就开个百度或者谷歌的广告,也能赚点奶茶钱。
先体验一下 compressor.js 的效果:fengyuanchen.github.io/compressorj…
这是 compressor.js 的代码仓库:github.com/fengyuanche…
动手试试
安装 compressor
npm
npm 通过这条命令安装。
npm install compressorjs
然后在需要使用到 compressor.js 的页面中引入。
import Compressor from 'compressorjs';
CDN
如果你不使用打包工具,也可以直接通过 CDN 在 HTML 中引入 Compressor.js。
<script src="https://cdn.jsdelivr.net/npm/compressorjs@latest/dist/compressor.min.js"></script>
基础用法
要使用 compressor.js 压缩图片,首先通过 new Compressor
创建一个压缩实例,并传入文件和一些配置参数。成功后会返回一个压缩后的图片对象。
接下来我用一个小例子演示一下。这个例子通过上传一张图片,然后使用 compressor 压缩它,再返回一个下载链接。
<!-- 文件上传控件 -->
<input type="file" id="fileInput" accept="image/*">
<!-- 下载压缩后的图片 -->
<div id="downloadLink">
<a id="downloadCompressed" style="display:none;" download>点击下载压缩后的图片</a>
</div>
<!-- 引入 Compressor.js -->
<script src="https://cdn.jsdelivr.net/npm/compressorjs@latest/dist/compressor.min.js"></script>
<script>
// 获取 file input 和下载链接元素
const fileInput = document.getElementById('fileInput')
const downloadCompressed = document.getElementById('downloadCompressed')
// 当文件选择发生变化时触发
fileInput.addEventListener('change', function(event) {
const file = event.target.files[0] // 获取上传的文件
if (!file) {
return // 如果没有选择文件,则不继续执行
}
// 使用 Compressor.js 压缩图片
new Compressor(file, {
success(result) {
downloadCompressed.href = URL.createObjectURL(result)
// 显示下载链接
downloadCompressed.style.display = 'inline'
},
error(err) {
console.error('压缩失败:', err)
},
})
})
</script>
在这个例子中,使用了 <input type="file">
作为上传图片的元素,获取到用户上传的图片后,使用 new Compressor(file[, options])
来压缩图片,new Compressor
接收2个参数,第一个参数是图片文件,第二个参数是一系列参数,在本例中的所有参数都使用了默认值。最后通过 success()
处理压缩成功后的操作,用 error()
处理压缩失败后的操作。
当压缩成功后就进入 success(result)
里了,这里的 result
返回了压缩成功后的图片对象,通过 URL.createObjectURL(result)
的方式将返回压缩成功后的图片地址。将该地址赋值到 <a>
标签里就能给用户手动点击下载了。
挺简单吧~
配置压缩强度
在前面的例子中,我们通过 new Compressor(file[, options])
压缩图片,但压缩的强度默认是 80%,在压缩 JPG 时默认是 92%。如果你希望将图片体积压缩得更小(画质会更差),可以在 options
这个参数里配置一项 quality
。quality
接收的值是 0~1
,quality
的数值越小压缩出来的图片体积就越小,压缩力度就越大。
具体用法:
// 省略部分代码
new Compressor(file, {
quality: 0.6, // 设置压缩质量为 60%
success(result) {}, // 压缩成功后执行这里的代码
error(err) {} // 压缩失败后执行这里的代码
})
设置下载文件的文件名
在前面的例子中,我们下载压缩成功后的图片,文件名看上去是一堆乱码。
比如,我想将压缩后的图片名改成在原图的文件名后面拼上“-德育处主任”,可以这么做。
// 省略部分代码
new Compressor(file, {
quality: 0.6, // 设置压缩质量为 60%
success(result) {
// 获取文件名,并给压缩后的文件加上 "-德育处主任" 后缀
const originalName = file.name;
const extensionIndex = originalName.lastIndexOf('.');
const nameWithoutExtension = originalName.substring(0, extensionIndex);
const extension = originalName.substring(extensionIndex);
downloadCompressed.download = nameWithoutExtension + '-德育处主任' + extension;
downloadCompressed.href = URL.createObjectURL(result)
// 显示下载链接
downloadCompressed.style.display = 'inline'
},
error(err) {
console.error('压缩失败:', err)
}
})
压缩网络图片
compressor.js 的第一个参数必须是一个 File
对象(通常是通过文件上传获取的),它不支持直接传入网络图片的 URL。因为它需要操作的是一个本地的 File
或 Blob
对象,而不是通过 URL 获取的资源。
但我们可以先通过 JavaScript 将网络图片转换为一个 File
或 Blob
对象,然后再将其传递给 compressor.js。
我上传了一张图片到免费的图床上(这是将我公众号的url转成艺术二维码的图片): i.imgur.com/zyurGlf_d.w…
function urlToBlob(url) {
return fetch(url)
.then((response) => response.blob())
.then((blob) => {
// Step 2: 将 Blob 传递给 Compressor.js
new Compressor(blob, {
quality: 0.8, // 设置压缩质量
success(result) {
console.log('压缩后的图片:', result)
},
error(err) {
console.error('压缩出错:', err)
},
})
})
}
const imageUrl = 'https://i.imgur.com/zyurGlf_d.webp?maxwidth=760&fidelity=grand'
urlToBlob(imageUrl)
通过 fetch
读取这张图片,然后将读取回来的图片执行 .blob()
方法将其转换成 blob
再丢给 compressor.js 压缩。
以上就是本文的内容啦,如果你想在线体验一下 compressor.js 的压缩能力,可以试试这个网站 worklite.vip/
点赞 + 关注 + 收藏 = 学会了
来源:juejin.cn/post/7415912074993319976
uni-app 接入微信短剧播放器
前言
作为一个 uniapp 初学者,恰巧遇到微信短剧播放器接入的需求,在网上检索许久并没有发现傻瓜式教程。于是总结 uni-app 官网文档及微信开放文档,自行实践后,总结出几个步骤,希望为大家提供些帮助。实践后发现其实确实比较简单,大佬们可能也懒得写文档,那么就由我这个小白大概总结下。本文档仅涉及剧目提审成功后的播放器接入,其余相关问题请参考微信官方文档。
小程序申请插件
参考文档:developers.weixin.qq.com/miniprogram…
首先,需要在小程序后台,申请 appid 为 wx94a6522b1d640c3b 的微信插件,可以在微信小程序管理后台进行添加,路径是 设置 - 第三方设置 - 插件管理 - 添加插件,搜索 wx94a6522b1d640c3b 后进行添加:
uni-app 项目添加微信插件
参考文档:uniapp.dcloud.net.cn/tutorial/mp…
添加插件完成后,在 manifest.json 中,点击 源码视图,找到如下位置并添加红框内的代码,此步骤意在将微信小程序插件引入项目。
/* 添加微短剧播放器插件 */
"plugins" : {
"playlet-plugin" : {
"version" : "latest",
"provider" : "wx94a6522b1d640c3b"
}
}
manifest.json 中完成添加后,需要在 pages.json 中找一个页面(我这边使用的是一个新建的空白页面)挂载组件,挂载方式如下图红框中所示,需注意,这里的组件名称需要与 manifest.json 中定义的一致:
{
"path": "newPage/newPage",
"style": {
"navigationBarTitleText": "",
"enablePullDownRefresh": false,
"navigationStyle": "custom",
"app-plus": {
"bounce": "none"
},
"mp-weixin": {
"usingComponents": {
"playlet-plugin": "plugin://playlet-plugin/playlet-plugin"
}
}
}
}
挂载空页面是个笨办法,目前我这边尝试如果不挂载的话,会有些问题,有大神知道别的方法可以在评论区指点一下~
App.vue 配置
参考文档:developers.weixin.qq.com/miniprogram…
首先,找个地方新建一个 playerManager.js,我这边建在了 common 文件夹下。代码如下(代码参考微信官方文档给出的 demo):
var plugin = requirePlugin("playlet-plugin");
// 点击按钮触发此函数跳转到播放器页面
function navigateToPlayer(obj) {
// 下面的${dramaId}变量,需要替换成小程序管理后台的媒资管理上传的剧目的dramaId,变量${srcAppid}是提审方appid,变量${serialNo}是某一集,变量${extParam}是扩展字段,可通过
const { extParam, dramaId, srcAppid, serialNo } = obj
wx.navigateTo({
url: `plugin-private://wx94a6522b1d640c3b/pages/playlet/playlet?dramaId=${dramaId}&srcAppid=${srcAppid}&serialNo=${serialNo}&extParam=${extParam || ''}`
})
}
const proto = {
_onPlayerLoad(info) {
const pm = plugin.PlayletManager.getPageManager(info.playerId)
this.pm = pm
// encryptedData是经过开发者后台加密后(不要在前端加密)的数据,具体实现见下面的加密章节
this.getEncryptData({serialNo: info.serialNo}).then(res => {
// encryptedData是后台加密后的数据,具体实现见下面的加密章节
pm.setCanPlaySerialList({
data: res.encryptedData,
freeList: [{start_serial_no: 1, end_serial_no: 10}], // 1~10集是免费剧集
})
})
pm.onCheckIsCanPlay(this.onCheckIsCanPlay)
// 关于分享的处理
// 开启分享以及withShareTicket
pm.setDramaFlag({
share: true,
withShareTicket: true
})
// 获取分享参数,页面栈只有短剧播放器一个页面的时候可获取到此参数
// 例如从分享卡片进入、从投流广告直接跳转到播放器页面,从二维码直接进入播放器页面等情况
plugin.getShareParams().then(res => {
console.log('getLaunch options query res', res)
// 关于extParam的处理,需要先做decodeURIComponent之后才能得到原值
const extParam = decodeURIComponent(res.extParam)
console.log('getLaunch options extParam', extParam)
// 如果设置了withShareTicket为true,可通过文档的方法获取更多信息
// https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/share.html
const enterOptions = wx.getEnterOptionsSync()
console.log('getLaunch options shareTicket', enterOptions.shareTicket)
}).catch(err => {
console.log('getLaunch options query err', err)
})
// extParam除了可以通过在path传参,还可以通过下面的接口设置
pm.setExtParam('hellotest')
// 分享部分end
},
onCheckIsCanPlay(param) {
// TODO: 碰到不可以解锁的剧集,会触发此事件,这里可以进行扣币解锁逻辑,如果用户无足够的币,可调用下面的this.isCanPlay设置
console.log('onCheckIsCanPlay param', param)
var serialNo = param.serialNo
this.getEncryptData({serialNo: serialNo}).then(res => {
// encryptedData是后台加密后的数据,具体实现见下面的加密章节
this.pm.isCanPlay({
data: res.encryptedData,
serialNo: serialNo,
})
})
},
getEncryptData(obj) {
const { serialNo } = obj
// TODO: 此接口请求后台,返回下面的setCanPlaySerialList接口需要的加密参数
const { srcAppid, dramaId } = this.pm.getInfo()
console.log('getEncryptData start', srcAppid, dramaId, serialNo)
return new Promise((resolve, reject) => {
resolve({
encryptedData: '' // TODO: 此参数需从后台接口获取到
})
})
},
}
function PlayerManager() {
var newProto = Object.assign({}, proto)
for (const k in newProto) {
if (typeof newProto[k] === 'function') {
this[k] = newProto[k].bind(this)
}
}
}
PlayerManager.navigateToPlayer = navigateToPlayer
module.exports = PlayerManager
新建完成后,在 App.vue 中进行组件的配置和引用。
onLaunch: function() {
// playlet-plugin必须和上面的app.json里面声明的插件名称一致
const playletPlugin = requirePlugin('playlet-plugin')
const _onPlayerLoad = (info) => {
var PlayerManager = require('@/common/playerManager.js')
const playerManager = new PlayerManager()
playerManager._onPlayerLoad(info)
}
// 注册播放器页面的onLoad事件
playletPlugin.onPageLoad(_onPlayerLoad.bind(this))
},
_onPlayerLoad(info) {
var PlayerManager = require('@/common/playerManager.js')
const playerManager = new PlayerManager()
playerManager._onPlayerLoad(info)
},
页面使用
参考文档:developers.weixin.qq.com/miniprogram…
以上所有步骤完成后,就可以开心的使用短剧播放器了。 我这边临时写了个图片的 click 事件测试了一下:
clk() {
// 逻辑处理...获取你的各种参数
// 打开组件中封装的播放器页面
PlayerManager.navigateToPlayer({
srcAppid: 'wx1234567890123456', // 剧目提审方 appid
dramaId: '100001', // 小程序管理后台的媒资管理上传的剧目的 dramaId
serialNo: '1', // 剧目中的某一集
extParam: encodeURIComponent('a=b&c=d'), // 扩展字段,需要encode
})
},
写在最后:
总结完了,其实整体下来不是很难,对我这种前端小白来说检索和整合的过程是比较痛苦的,所以希望下一个接入的朋友可以少检索一些文档吧。
另附一个短剧播放器接口的文档: developers.weixin.qq.com/miniprogram…
文档主要介绍了短剧播放器插件提供的几个接口,在js代码里,插件接口实例通过下面的代码获取
// 名字playlet-plugin必须和app.json里面引用的插件名一致
const playletPlugin = requirePlugin('playlet-plugin')
读书越多越发现自己的无知,Keep Fighting!
欢迎友善交流,不喜勿喷~
Hope can help~
来源:juejin.cn/post/7373473695057428506
不能沉迷于无畏契约了,我要学axios-retry源码,以后遇到接口不响应就这么办!
前言
挺久没写文章,最近下班后都在打瓦罗兰特
,一直在黄金一和白银三徘徊,感觉已经要废了,所以也没啥时间写文章。工作上最近也是换了一个组,之前主要是干web
,现在是在写sass中台和h5
,然后也是负责一个小迭代,整体其实就是一个curd,但是也是遇到一些奇奇怪怪的坑,有一个我觉得还是很有含金量的,然后我是用了一个第三包解决的,然后也顺带去看了一下这个包的源码,也学到不少的东西,记录分享一下。
请求不响应后重新请求
在我现在这个项目中,对于一些请求,他的生命周期会比较长
。正常来说,我们只需要和一个服务端请求,服务端收到后就返回。但是这里是,前端对服务端a请求后,服务端a还要向服务端b去请求,服务器a只能等待服务器b响应后再给我们前端响应,所以就会存在请求不响应超时
的问题,如果是偶发性的还好,但是频率好像还挺高的,就是可能调同一个接口10次,有3次是不响应的。然后我这是将网络禁用去模拟的一个效果。
解决方案
解决方案也挺简单的,就是服务端a在5s内如果收不到服务端b的响应,就会给前端报timeout的错误,我这边如果收到timeout的错误就是重新请求
,指数型去重试请求5次,如果还是不成功就只能给用户提示“请求超时,请重新提交了”。
代码
axios-retry地址 http://www.npmjs.com/package/axi…
这里使用了axios-retry
,这个包就是可以二次封装axios实例去实现重新请求。正常来说,我们的项目中都会对axios进行封装,如下代码,去对请求拦截器和响应拦截器做一些公共处理。
import axios from 'axios'
const http = axios.create({
headers: {},
timeout: 5 * 1000 // 请求超时时间
})
// 请求拦截器
http.interceptors.request.use(
(config) => {
console.log(config)
return config
},
(err) => {
console.log(err)
return Promise.reject(err)
}
)
// 响应拦截器
http.interceptors.response.use(
(response) => {
return response
},
(error) => {
return Promise.reject(error)
}
)
export default http
这里我们就可以得到一个axios实例http,axios-retry就可以对这个实例进行封装实现重新请求
import axios from 'axios'
import axiosRetry from 'axios-retry'
const api = axios.create({
headers: {},
timeout: 5 * 1000 // 请求超时时间
})
// 请求拦截器
api.interceptors.request.use(
(config) => {
console.log(config)
return config
},
(err) => {
console.log(err)
return Promise.reject(err)
}
)
// 响应拦截器
api.interceptors.response.use(
(response) => {
// 将index变回0
index = 0
return response
},
(error) => {
return Promise.reject(error)
}
)
axiosRetry(api, {
retries: 5,
shouldResetTimeout: true,
retryDelay: (retryCount) => {
// retryCount为重试的次数
return retryCount * 1000
},
retryCondition: err => {
console.log(err)
index++
if (index === 5) {
// 超过五次进行提示就不进行请求
Toast('请求超时,请重新提交')
index = 0
return false
} else {
if (err.message.includes('timeout')) return true
return false
}
}
})
export default api
axiosRetry
对于axiosRetry来说,我们只需要去配置下面参数就行
- retries 重试次数
- retryCondition 重试条件,返回ture就允许重试,返回false就不允许重试
- shouldResetTimeout 是否重置超时,ture代表每次重试都重置超时时间 false则相反
- retryDelay 延迟重试时间,需要返回一个时间
- onRetry 每次重试时执行的回调函数
- onMaxRetryTimesExceeded 当达到最大重试次数后执行的回调函数
- validateResponse 用于验证响应是否有效的函数
可以看到,我代码中定义闭包了一个index变量,在retryCondition中去判断是否为等于5,如果等于5就不进行重试并提示给用户进行重新提交请求,这里要注意的是要维护好index这个变量,在请求成功后变回0。本来我是想在onMaxRetryTimesExceeded 这个配置项去写逻辑的,但是不知道为什么没有执行这里面的逻辑,我也没去研究了,能实现效果就行了。
源码
大家可以npm上这个位置去看源码,大家如果感兴趣,最好还是自己去看一下源码,我的分析可能比较片面,而且我本身技术也就那样。这个第三方包的源码相比vue的源码其实还算简单的,没有那么复杂。虽然简单,我看的也很头大。如果你要继续往下面看,就需要你对axios本身有一丢丢了解才行,就比如请求拦截器,响应拦截器这些。当然也能继续看,就是可能会有点迷迷糊糊的,但是肯定是能学到东西的!然后我是将代码的逻辑通过注释是写在代码里面了,所以要先看一下代码块里面的东西。
axiosRetry
这个方法就是这个第三方包的主函数,我先说下这个包整体上的实现逻辑,在请求拦截器和响应拦截器中维护一个对象,在响应拦截器中,通过这个对象中一些信息去判断要不要重新请求
。可以看到这个函数接收两个参数,一个是axiosInstance
axios实例,一个defaultOptions
也就是我在使用axios-retry
配置的配置项。可以看下面的代码,从大的方面来看就是一个请求拦截器,一个响应拦截器,最后将这两个拦截器给return了。
const axiosRetry = (axiosInstance, defaultOptions) => {
// 请求拦截器
const requestInterceptorId = axiosInstance.interceptors.request.use((config) => {
setCurrentState(config, defaultOptions, true);
// 这一段代码可以向不看,这个是为了实现配置项上的validateResponse的功能
// =====1
if (config[namespace]?.validateResponse) {
config.validateStatus = () => false;
}
// =====1
return config;
});
// 响应拦截器
const responseInterceptorId = axiosInstance.interceptors.response.use(null, async (error) => {
const { config } = error;
// 如果没有config,无法判断是否需要重新请求,直接返回错误
if (!config) {
return Promise.reject(error);
}
const currentState = setCurrentState(config, defaultOptions);
// 这一段可以先不看,为了实现配置项上的validateResponse的功能
// =====2
if (error.response && currentState.validateResponse?.(error.response)) {
// 如果响应没问题(通过 validateResponse 验证)则直接返回响应
return error.response;
}
// =====2
// 根据是否满足重试条件来决定是执行重试操作(调用 handleRetry 函数)
if (await shouldRetry(currentState, error)) {
return handleRetry(axiosInstance, currentState, error, config);
}
// 这一段可以先不看,为了实现配置项上的 onMaxRetryTimesExceeded 的功能
// =====3
// 在达到最大重试次数后执行相应回调(调用 handleMaxRetryTimesExceeded 函数)
await handleMaxRetryTimesExceeded(currentState, error);
// =====3
return Promise.reject(error);
});
return { requestInterceptorId, responseInterceptorId };
};
除了===之间的内容不看后,拦截器里面剩下的就很简单了,在请求拦截器中,就是调用了setCurrentState这个方法,要想理解setCurrentState这个方法,我们得先知道对于一个axois,发起请求是有一个config对象,这个对象里面包括像请求头,请求方式等等的一些字段,所以这个我们可以理解成一个给config对象中添加属性的方法,源码如下。前面我们说过,这个包的整体思路就是在请求拦截器和响应拦截器维护一个对象
,而这个对象就是config中的某一个属性,也就是config[namespace]。namespace是一个变量,变量值为axios-retry,也就是config中叫axios-retry的属性。
setCurrentState
这个方法接收三个参数,一个是axois请求的配置,一个用户的配置,一个是否需要重置上次请求时间。可以看下面的代码,一开始是调用了getRequestOption
s的这个方法,这个方法就一个合并对象的方法,合并的对象就是我们前面所说的在请求拦截器和响应拦截器维护的那个对象。它是将,我们axios-retry默认配置
,用户的配置
以及config[namespace]
(也就是维护的那个对象)合并成一个对象。整体去看setCurrentState这个方法,可以分为1,2,3步,分别对应着拿变量,改变量,存变量
,就和维护变量的操作一模一样。
function setCurrentState(config, defaultOptions, resetLastRequestTime = false) {
// 合并配置参数 getRequestOptions方法在下面----------------------------1
const currentState = getRequestOptions(config, defaultOptions || {});
// 初始化或更新重试次数,retryCount就是记录当前重试的次数
// 如果currentState中没有这个变量,就是第一次请求,有就使用这个变量--------2
currentState.retryCount = currentState.retryCount || 0;
// 更新上次请求时间
if (!currentState.lastRequestTime || resetLastRequestTime) {
currentState.lastRequestTime = Date.now();
}
// 赋值给config配置项 namespace就是一个变量,在下面的代码,这就是维护变量的操作---------3
config[namespace] = currentState;
return currentState;
}
// 合并默认配置,就是将默认的,用户设置的,和config中的配置合并
function getRequestOptions(config, defaultOptions) {
return { ...DEFAULT_OPTIONS, ...defaultOptions, ...config[namespace] };
}
// 下面这些代码可以先不看
//===============================================================
// 定义添加config对象中的属性名
export const namespace = 'axios-retry';
// 默认配置对象 isNetworkOrIdempotentRequestError和noDelay是一个默认方法,
// 大家感兴趣可以去看源码,因为如果用户有配置的话,就是使用用户配置的回调函数
export const DEFAULT_OPTIONS = {
retries: 3,
retryCondition: isNetworkOrIdempotentRequestError,
retryDelay: noDelay,
shouldResetTimeout: false,
onRetry: () => { },
onMaxRetryTimesExceeded: () => { },
validateResponse: null
};
我们结合上面的axiosRetry来看,在请求拦截器和响应拦截器都使用了这个方法,也就是说在每一次请求的时候都去更新维护config[namespace]对象
。这也就是为啥一直在说核心就是请求拦截器和响应拦截器维护一个对象,那为什么要维护这个对象呢?别急,马上就来了!我们再回去看响应拦截器,除开1,2,3段可以先不看,就剩下下面两行代码,这段代码也就是这个包的核心代码,这段代码主要使用了shouldRetry
和handleRetry
两个方法,可以看到这两个方法都使用了currentState
这个变量,这个变量就是我们一直强调的那个'维护的对象'
。shouldRetry方法是用来判断要不要重新的请求,而handleRetry是用来重新请求的方法。
// 根据是否满足重试条件来决定是执行重试操作(调用 handleRetry 函数)
if (await shouldRetry(currentState, error)) {
return handleRetry(axiosInstance, currentState, error, config);
}
维护的对象
说这么多,这个'维护的对象'
到底是什么,我们在请求拦截器中打印一下config这个对象,可能大家已经忘了config是啥,config就是我们在请求拦截器回调接收的那个参数
,也就是axois发起请求的配置。在控制台可以看到其中会有axios-retry
这样的一个属性,也就是namespace变量的值。我们一直在维护的也是这个axios-retry的值。这个对象里面有重试次数,上次请求时间,重试条件,重试回调等等,也就是我们所配置的那些东西。也就是形参currentState需要的值。
shouldRetry
async function shouldRetry(currentState, error) {
// 从currentState拿到retries, retryCondition
const { retries, retryCondition } = currentState;
// 如果没超过重试次数,然后通过retryCondition去判断,根据这两个去判断要不要重新请求
const shouldRetryOrPromise = (currentState.retryCount || 0) < retries && retryCondition(error);
// 这一段代码是为了兼容retryCondition可能是promise的值,就要去等待他执行完成
// =========1
if (typeof shouldRetryOrPromise === 'object') { // 这可能是一个promise
try {
const shouldRetryPromiseResult = await shouldRetryOrPromise;
// 保持 return true,除非 shouldRetryPromiseResult 返回 false 以实现兼容性
return shouldRetryPromiseResult !== false;
}
catch (_err) {
return false;
}
}
// ========1
return shouldRetryOrPromise;
}
这个方法其实很简单,就是通过重试次数,以及用户配置的retryCondition回调,去得到一个布尔值
。整体逻辑大家应该都能看得懂,这里需要给大家讲一下error是什么,error就是在响应拦截器中请求失败的回调的传参,也就是当axios请求失败报错的那个值
。下面这张图可以看到这个error中也是有config
属性的,也有axios-retry
的,这很重要!
handleRetry
这个方法就是实现重试的方法,接收四个参数,分别是axiosInstance
axios实例,currentState
就是config中的axios-retry属性,也就是维护的那个对象,error
就是上面那个error,config就是那个config,之前都有提过。
async function handleRetry(axiosInstance, currentState, error, config) {
// 重试次数加1
currentState.retryCount += 1;
const { retryDelay, shouldResetTimeout, onRetry } = currentState;
// 执行retryDelay,也就是用户配置的那个retryDelay
const delay = retryDelay(currentState.retryCount, error);
// 修复config======可以不看,为了兼容,感兴趣的可以去细看源码
fixConfig(axiosInstance, config);
// 这一段代码是为实现用户配置shouldResetTimeout是否重置超时时间的功能
// 如果是false,也就是不进行重置超时时间,所以这里要去更新config中的timeout。
// 如果是ture就不进入这个if,不对timeout做处理
if (!shouldResetTimeout && config.timeout && currentState.lastRequestTime) {
const lastRequestDuration = Date.now() - currentState.lastRequestTime;
const timeout = config.timeout - lastRequestDuration - delay;
if (timeout <= 0) {
return Promise.reject(error);
}
config.timeout = timeout;
}
// config.transformRequest是对请求数据进行处理,这里的意思就是传入了什么,就用什么数据。
// 这行代码是为了重置转换函数。
config.transformRequest = [(data) => data];
// 执行onRetry,也就是用户配置的onRetry
await onRetry(currentState.retryCount, error, config);
// config.signal是AbortController产生的,AbortController是提供取消异步操作的一个js接口。
// 这里所有关于config.signal都是为了兼容,兼容用户对请求进行主动取消的情况下。
// 他是去监听abort事件,因为如果用户需要主动取消请求,会去触发abort事件
// 这里是做了一个防抖以及监听事件和取消监听。
// 如果没有接触过,可以直接把这些相关代码(1,2,3,4)先删了再去看,
// 把这些删了之后发现就只剩下了一个定时器和axiosInstance(config)。
// axiosInstance(config)就是重新请求。
// =============1
if (config.signal?.aborted) {
return Promise.resolve(axiosInstance(config));
}
// =============1
return new Promise((resolve) => {
// =============2
const abortListener = () => {
clearTimeout(timeout);
resolve(axiosInstance(config));
};
// =============2
// delay是上面retryDelay得出的东西
const timeout = setTimeout(() => {
resolve(axiosInstance(config));
// =============3
if (config.signal?.removeEventListener) {
config.signal.removeEventListener('abort', abortListener);
}
// =============3
}, delay);
// =============4
if (config.signal?.addEventListener) {
config.signal.addEventListener('abort', abortListener, { once: true });
}
// =============4
});
}
梳理
看到这里大家可能明白了,可能很懵。因为我这是对核心源码一行一行的去注释,可能并不能将整条线连起来,所以我这用文字去总结一下。首先
,我们先将重试作为主线
,去看重试是怎么实现的。还是那个'维护的对象'
,这个对象串联了整条线,这个对象包括我们的重试条件,重试回调等等这些方法。我们先在请求拦截器中和响应拦截器中都是使用了setCurrentState
去维护这个对象,然后再响应拦截器中去通过shouldRetry
去判断该不该重试,再通过handleRetry
去重试。而这两个方法实现的前提就是这个'维护的对象'
。比如该不该重试,是通过用户配置的retryCondition和重试次数去判断的,再比如怎么去重试,是通过axios实例配合config参数再次请求。其次
,我们再通过我们配置的参数,去看重试这条主线的支线
,也就是retryDelay
重试延时时间,shouldResetTimeout
是否重置超时时间,onRetry
重试回调,onMaxRetryTimesExceeded
最大重试次数后执行的回调,validateResponse
验证响应内容。这些在上面的代码注释中,我都有标明在哪里实现的。
总结
axios-retry这个包是很不错的,可以在无响应报错的时候进行重新请求。在前后端交互的时候,或多或少都会遇到接口不响应超时的问题。而在一些很需要的接口响应的场景,是很实用的,然后源码看不看懂其实都无所谓,会用就行,而且我们这种底层前端的工作内容基本都是curd,根本不用去造轮子。不过看源码也是有很多好处的,比如怎么封装包能让用户有更多的扩展性,像这里的retryCondition和onRetry就不错。还有可以增加自己的自信心的,这包一周是有三百万人在使用的,感觉也没有多复杂,我又觉得我行了(手动狗头)。最后,来个赋能哥带我上分呗,我真打不上去啊。
来源:juejin.cn/post/7439654496694255670
大屏可视化效果实现记录
适配及响应式处理
效果实现
Echarts线图线条渐变色及区域渐变
- 效果图
- 关注点
- 线条颜色渐变
- 线条含有阴影
- 区域填充色渐变
- 配置项
series:[{
data: [820, 932, 901, 934, 1290, 1330, 1320],
type: 'line',
smooth: false,
lineStyle: {
normal: {
// 1. 设置线条渐变色
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{
offset: 0,
color: '#FDFDFF',
},
{
offset: 0.3,
color: '#6EA4F8',
},
{
offset: 0.6,
color: '#7DA0E0',
}, {
offset: 1,
color: '#679BF0',
},
]),
width: 3,
// 2. 设置线条阴影
shadowColor: '#2E4F84',
shadowOffsetY: 15,
shadowOffsetX: 5,
shadowBlur: 3,
},
},
// 3. 设置区域填充渐变:渐变色设置文档 https://echarts.apache.org/zh/option.html#color
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: 'rgba(36,173,254, 0.5)',
}, {
offset: 1,
color: 'rgba(52,112,252, 0.1)',
},
],
},
},
}]
- 效果图
- 关注点
- 线条颜色渐变
- 线条含有阴影
- 区域填充色渐变
- 配置项
series:[{
data: [820, 932, 901, 934, 1290, 1330, 1320],
type: 'line',
smooth: false,
lineStyle: {
normal: {
// 1. 设置线条渐变色
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{
offset: 0,
color: '#FDFDFF',
},
{
offset: 0.3,
color: '#6EA4F8',
},
{
offset: 0.6,
color: '#7DA0E0',
}, {
offset: 1,
color: '#679BF0',
},
]),
width: 3,
// 2. 设置线条阴影
shadowColor: '#2E4F84',
shadowOffsetY: 15,
shadowOffsetX: 5,
shadowBlur: 3,
},
},
// 3. 设置区域填充渐变:渐变色设置文档 https://echarts.apache.org/zh/option.html#color
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: 'rgba(36,173,254, 0.5)',
}, {
offset: 1,
color: 'rgba(52,112,252, 0.1)',
},
],
},
},
}]
Echarts外环饼图
- 效果图
- 关注点
- 内圈含有间隔数据
- 外圈效果
- 配置项
// 数据处理
// 间隔空白数据
const gapData = {
name: '',
value: 20,
itemStyle: {
color: 'transparent', // 颜色设置为透明数据
},
};
// 计算饼图渲染数据
const seriesData = [];
[
{ value: 1048, name: 'Search Engine' },
{ value: 735, name: 'Direct' },
{ value: 580, name: 'Email' },
{ value: 484, name: 'Union Ads' },
{ value: 300, name: 'Video Ads' }
].forEach((item) => {
seriesData.push(item);
seriesData.push(gapData);
});
// 图表配置项
series: [
// 内圆环配置项
{
data: seriesData,
roundCap: true,
center: ['50%', '50%'],
radius: ['50%', '60%'],
label: {
show: false,
position: 'center',
},
},
// 外圆环配置项
{
type: 'pie',
name: '旋转圆',
silent: true,
center: ['50%', '50%'],
radius: ['70%', '69%'],
hoverAnimation: false,
startAngle: 50,
// Notes:这里的数据根据要展示的外环段数及长短自定义设置
data:[120, 40, 120, 40, 120, 40].map((item, index) => ({
value: item,
name: '',
itemStyle: {
color: index % 2 === 0 ? '#5999E1' : 'transparent',
shadowBlur: 20,
shadowColor: '#86C6FD',
},
})),
label: {
normal: {
show: false,
},
},
labelLine: {
normal: {
show: false,
},
},
}
],
- 效果图
- 关注点
- 内圈含有间隔数据
- 外圈效果
- 配置项
// 数据处理
// 间隔空白数据
const gapData = {
name: '',
value: 20,
itemStyle: {
color: 'transparent', // 颜色设置为透明数据
},
};
// 计算饼图渲染数据
const seriesData = [];
[
{ value: 1048, name: 'Search Engine' },
{ value: 735, name: 'Direct' },
{ value: 580, name: 'Email' },
{ value: 484, name: 'Union Ads' },
{ value: 300, name: 'Video Ads' }
].forEach((item) => {
seriesData.push(item);
seriesData.push(gapData);
});
// 图表配置项
series: [
// 内圆环配置项
{
data: seriesData,
roundCap: true,
center: ['50%', '50%'],
radius: ['50%', '60%'],
label: {
show: false,
position: 'center',
},
},
// 外圆环配置项
{
type: 'pie',
name: '旋转圆',
silent: true,
center: ['50%', '50%'],
radius: ['70%', '69%'],
hoverAnimation: false,
startAngle: 50,
// Notes:这里的数据根据要展示的外环段数及长短自定义设置
data:[120, 40, 120, 40, 120, 40].map((item, index) => ({
value: item,
name: '',
itemStyle: {
color: index % 2 === 0 ? '#5999E1' : 'transparent',
shadowBlur: 20,
shadowColor: '#86C6FD',
},
})),
label: {
normal: {
show: false,
},
},
labelLine: {
normal: {
show: false,
},
},
}
],
Echarts 渐变色柱状图
- 效果图
- 关注点
- 柱体颜色渐变
- 配置项
series: [
{
data: [120, 200, 150, 80, 70, 110, 130],
type: 'bar',
// 设置柱体颜色渐变
itemStyle: {
normal: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 1,
color: '#20517E',
opacity: 0.85,
},
{
offset: 0,
color: '#3FC0F7',
opacity: 0.79,
},
]),
},
},
label: {
show: true,
color:'#3FC0F7',
fontSize: 12,
position: 'outside',
},
}
]
- 效果图
- 关注点
- 柱体颜色渐变
- 配置项
series: [
{
data: [120, 200, 150, 80, 70, 110, 130],
type: 'bar',
// 设置柱体颜色渐变
itemStyle: {
normal: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 1,
color: '#20517E',
opacity: 0.85,
},
{
offset: 0,
color: '#3FC0F7',
opacity: 0.79,
},
]),
},
},
label: {
show: true,
color:'#3FC0F7',
fontSize: 12,
position: 'outside',
},
}
]
Echarts含图片标签渐变色柱状图
- 效果图
- 关注项
- 渐变色柱体
- 高亮结尾
- 数据标签含背景图
- 配置项
option = {
backgroundColor:'#17243A',
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'value',
boundaryGap: [0, 0.01]
},
yAxis: [
{
inverse: true,
axisLabel: {
color: '#ADCBE9',
fontSize: 20,
formatter: (value) => {
if (value.length < 8) {
return value;
}
return `${value.substring(0, 8)}...`;
},
},
axisLine: {
lineStyle: {
color: 'transparent',
},
},
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
}, {
inverse: true,
axisTick: 'none',
axisLine: 'none',
axisLabel: {
show: true,
fontSize: 20,
fontWeight: 'bold',
color: '#BFD1E3',
padding: [5, 12, 5, 12],
backgroundColor: {
image: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAAAWCAYAAAA/45nkAAAACXBIWXMAAAsTAAALEwEAmpwYAAABAElEQVRoge3ZwQoBURTG8XOvKzGzmnQ3SpKJrMZb2HsQL+MRLCkb5RHsbNVspGwkRYamplipaSiUOodzZnl/d/FvZvPVqP5geoXMs18ve0l8ibLnfq1SDlebXfZc7LWZQtHxqq1x1g0AwDZcdKmG/4sl8SW6v+e7HfJ2qCnEcTaNHcDdNAAA1TgOlqv77TnVuH+3ODpO9LuXxb5vSXw+PXwAKnFcTH9yWez7JjMU2WSGYs9Q7ADuprEDuJvMUGSTGYpsMkORTWYooplCydW2EYwoxnEwr9ocaVDKpRjHxZ6uICpxHExmKLIZAADrd2bpwzD175Jq+C+abQRjUMpJ+w1co8ZyUwNCuQAAAABJRU5ErkJggg==',
},
},
data: [120, 200, 150, 80, 70, 110, 130],
},
],
series: [
{
type: 'pictorialBar',
symbol: 'image://data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADQAAAA1CAYAAAAOJMhOAAAACXBIWXMAAAsTAAALEwEAmpwYAAAI9ElEQVRogbWa244cxw2GP1b37EGSAztAAsNArvIYAfIEeetc5hlyZSlxACuCvV7NaqebzEUVq1mcXmlWUgoYzEx3VTV/Hn6SNSM//WZcMixME7l8LiAiWL7ve3zOvh+bVz6+xbOH5M8NjOzcj/M+AeXyMX+FPQSqBZ7QoN+XZhm/a3FtWNOvfc74LAsll/JvAlgTTNJ9aWuy8Ij0724p4wss9jVcrmvcBQuAB8FC3Fh698++8sxCdqHNLgV0FhvxWWza7W6VQSXLSXj1eWnNZ1lp3kP+BItktjqbFdjLYky063tM1+NIBMzq58iCzx2XkkKMDdsJfqMCduHJQoU1siPsYJ0dxV1MEp9yuWj6YeMdJsMMcxbbIYeSXBEqEKGCyEy35+aftNunAHmg7vl+37wJFSm5x1MTVoJL5fsZ3J587gF7ihrGs/KQu1FwLQmudaac5KaSLNDnNJbD54XrTvM5l9lTrLcHKOeCIWlGYOF7FGIQvAnVLZrWRjfuROLg9hSQ9jyDNecADZPjBl2TaW53sxgLRWDVem8qiFq9pnV+AdQV0B+SPgfgmpTgQLs08f6ZhWLyCxuXdq/7cnxPxaawMZloe6yOysixYcmVonVKuy4iT1YifWRAna1g8NtOy2meB6oHeZlKFb4Ik27ljriFzGAqsCpriEENSooCRlbN+eksFDKgqOlshQ7SQbiLBW0VqUKXUi1aApju71O1jTUwJoK2ueq0H+o+T7oxXmKM52sdUMwdBIE7y6SA70m0xcvUrhWrj3JA0sCJGlbq2rWix3SLD20KMUZ3i3Gym5CDRQWwaCH33+irmG2Wi/4bCGESoTQrFIQpgmmAaGBMjYlKCuqKCRr26y6PK7oTQSCoDFyihfqFXETmmqyN4rlHrbvU1L7PpYEqQimjWxrSBV/VWIuwNAWsLeY0PEPZRs5P7rJDeokud+arkcJDQnX3K7K5llvl0Kwyz6PbOdtZqVYyZbOeGlqq8EKKpwCoM2FIFR2IY+jVdlocu8shzxCIgS1GHNRchGne3O6qyKC0tQhrEU5LFd9z1EKNLTHrlL4G5VmKH4/tzIgyO7IopMdS0MIAuGlcGgGIGmUuzFQSm4twUONQhCtqjE1TpV0FlnbvqMbKVi/OwBJipYhU0oAhHLJV4jWbYzLLdVQEGR7iYMpUeqxMDUwBZjWuinAD3EyF6wawlMKqxoMYRzOY4bg0MmixOKtxSqmhWyK2LamF6TTeLeTv0c32WMQ108B0MmjAihozcCPCTRG+mQrfALdTdb3TJNwX45dFgWohbXtMwGMsdkVYvUAlULSnlFQCbaSww+9DQIaKAFoMNbYqbCwmakxFuC7CXIQXRfh2gj8A3xXhAHw4GT9TY+9kwsMMp8UqwxWpINiIZHUwMY726kqfN5Q+sVLYATLM8YKTjZm6tUQ4FOF2Fr6bCn8R4W9NqL8f4B8nWArcrVRXjHs0krEGRryYTZV4bymSzFZSdR2THL6AUI8lnwUqSaRmaAKugVcNzAvgVoS/ivD7CV6KcCPVkuJ7NCtbKmTPqurkaoN/FTZWMy89fHKi8r7Y5+vWYapuG8ecIg2Mj5sGdo7PafNMz1uX8eGBKOK1+LXAUJIPlB3vmdUEOGhjK2esgfIKQIEPKxx1WAFm/Ar8tiqPbW6k7ggSRuvk06OBpHxaZLkK+NxX43tkQgAcDGCzsC7GMsHDotwfJt7enyqfT6WaBvj3Cm+BoxmPWil7gVq8thYjKrK7f4rlAbjPnxvivnCP8ULRGmPMqO4lLbBNa7ZfV+WhCHerUn4+wu1c970qcHvgR+DtqvyyKA8OBrBV0dYbdWuFSqHnoh2ichbsFhqKvmSR2I2qtw0twWqr3dRZqQiPi6EzgHJ6cw83U31NAn/6Hf8y416N9woL7aVbTrKmxJ5notfEUii6oM/x4Iws102aKu8csGrGpNVKPs+871mMYzHW13dwmODlXN3uh1e8U+O0VMucaHFkxppqs+gdltnNQyJ1sWPHKuPRrDOakWIraGVpoISt7JceV4K+voPrubrbzQzLH2sNp8ZSpBOItRKsM62kZm/PGmn00ufsnDkh722Fm9ldLrTQtELVNeztt/3nWMFcTfWlVq0SwCg19rTtpbaB7BbJZBWuxZgms1yOIV+QN7EmgAuuQGkHH9ZqOrsqTO8XsKlO+rDCVaEsxmlRTm6RFpvWwJwl0iBbVKTtgOsCdc2nGikCHfJEqCA8kNemYVNDrwpyPXML8OpQme6kcD1zW1rQB8VEMNaauCHRN9ksAJbw6rL7eVvWRKwWBo3F+21z7y7VKuutRdC5ILPw4vU9HNet9puFF4eJMhVMamPXXc7BBMHjc6J3nMWZv4biNJcRyVIS3mOPIrKVOn3+VFBg/vE9fH8LDwu8uQdgKtKLTw3Cejz6wWPcP8sVC4Eh9iX8rN8FzsyWRi+RQqLt1XlbJy8PvLiZ+H4q/Bn4oa19syr/fFR+uj9xbIk0ap34HuNpp/eJwPoYjrEiqIR8+AU7MmDIE91yrXL+sMJ/izGJ8Gub+26Fd6vy2MD0g48IIlUHww/OIbn2FuIpQIOAAUTW0Mdcz6DWd8eFUxHuKDxOxluAFR5PK8eHlZNbJSomJdZoHbfg2YluHnu/sQ7ny/6g0OfHh8XWQ+MZhBp6XHgscJoL7327R0V11P6QQOPe7fmdrgPwJ8fe70Pd/HFhqp3OzJ/nAyxa531YWcP67UG2daJZ2FTHuUJzGfbRGNobcYP47sHvD41VeNZk13QCcyZMcrl+UivSma9f36n7LgIUk+heqRFPUjvlprn5ODczF2Gv4dlPpJFdID6e80+S7vO5qw0Pj/Qb/T+2JxEwYb/hVCe5ouW9nxoX/WicNRk/Jy0ONOtWcheJ39kUEVuW3SIZhs71ywE5Ls61lE9X+/WUCNm5F10sMlmPwbjmUiGfA2jP97eb4Y9IqVIfrLpXQae5BJe+GIiPZ/8bKwkrMXZiXBC0HPsozl3yqfFsMPBlfwA8e2Bwk6EhhA6qX/9/ja/xj8Y4csIbvst4cJmr+c+ySB7/A5p05mnftr1GAAAAAElFTkSuQmCC',
symbolOffset: [20, -5],
symbolSize: [40, 40],
symbolPosition: 'end',
z: 12,
data: [120, 200, 150, 80, 70, 110, 130],
}, {
name: '',
type: 'bar',
showBackground: true,
yAxisIndex: 0,
barWidth: 7,
barBorderRadius: 10,
data: [120, 200, 150, 80, 70, 110, 130].map((value, index) => ({
value,
itemStyle: {
normal: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 1,
y2: 0,
colorStops: [
{
offset: 0,
color: '#2F3E56',
},
{
offset: 1,
color:'#7BB1EE',
},
],
},
},
},
})),
},
]
};
- 效果图
- 关注项
- 渐变色柱体
- 高亮结尾
- 数据标签含背景图
- 配置项
option = {
backgroundColor:'#17243A',
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'value',
boundaryGap: [0, 0.01]
},
yAxis: [
{
inverse: true,
axisLabel: {
color: '#ADCBE9',
fontSize: 20,
formatter: (value) => {
if (value.length < 8) {
return value;
}
return `${value.substring(0, 8)}...`;
},
},
axisLine: {
lineStyle: {
color: 'transparent',
},
},
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
}, {
inverse: true,
axisTick: 'none',
axisLine: 'none',
axisLabel: {
show: true,
fontSize: 20,
fontWeight: 'bold',
color: '#BFD1E3',
padding: [5, 12, 5, 12],
backgroundColor: {
image: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAAAWCAYAAAA/45nkAAAACXBIWXMAAAsTAAALEwEAmpwYAAABAElEQVRoge3ZwQoBURTG8XOvKzGzmnQ3SpKJrMZb2HsQL+MRLCkb5RHsbNVspGwkRYamplipaSiUOodzZnl/d/FvZvPVqP5geoXMs18ve0l8ibLnfq1SDlebXfZc7LWZQtHxqq1x1g0AwDZcdKmG/4sl8SW6v+e7HfJ2qCnEcTaNHcDdNAAA1TgOlqv77TnVuH+3ODpO9LuXxb5vSXw+PXwAKnFcTH9yWez7JjMU2WSGYs9Q7ADuprEDuJvMUGSTGYpsMkORTWYooplCydW2EYwoxnEwr9ocaVDKpRjHxZ6uICpxHExmKLIZAADrd2bpwzD175Jq+C+abQRjUMpJ+w1co8ZyUwNCuQAAAABJRU5ErkJggg==',
},
},
data: [120, 200, 150, 80, 70, 110, 130],
},
],
series: [
{
type: 'pictorialBar',
symbol: 'image://data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADQAAAA1CAYAAAAOJMhOAAAACXBIWXMAAAsTAAALEwEAmpwYAAAI9ElEQVRogbWa244cxw2GP1b37EGSAztAAsNArvIYAfIEeetc5hlyZSlxACuCvV7NaqebzEUVq1mcXmlWUgoYzEx3VTV/Hn6SNSM//WZcMixME7l8LiAiWL7ve3zOvh+bVz6+xbOH5M8NjOzcj/M+AeXyMX+FPQSqBZ7QoN+XZhm/a3FtWNOvfc74LAsll/JvAlgTTNJ9aWuy8Ij0724p4wss9jVcrmvcBQuAB8FC3Fh698++8sxCdqHNLgV0FhvxWWza7W6VQSXLSXj1eWnNZ1lp3kP+BItktjqbFdjLYky063tM1+NIBMzq58iCzx2XkkKMDdsJfqMCduHJQoU1siPsYJ0dxV1MEp9yuWj6YeMdJsMMcxbbIYeSXBEqEKGCyEy35+aftNunAHmg7vl+37wJFSm5x1MTVoJL5fsZ3J587gF7ihrGs/KQu1FwLQmudaac5KaSLNDnNJbD54XrTvM5l9lTrLcHKOeCIWlGYOF7FGIQvAnVLZrWRjfuROLg9hSQ9jyDNecADZPjBl2TaW53sxgLRWDVem8qiFq9pnV+AdQV0B+SPgfgmpTgQLs08f6ZhWLyCxuXdq/7cnxPxaawMZloe6yOysixYcmVonVKuy4iT1YifWRAna1g8NtOy2meB6oHeZlKFb4Ik27ljriFzGAqsCpriEENSooCRlbN+eksFDKgqOlshQ7SQbiLBW0VqUKXUi1aApju71O1jTUwJoK2ueq0H+o+T7oxXmKM52sdUMwdBIE7y6SA70m0xcvUrhWrj3JA0sCJGlbq2rWix3SLD20KMUZ3i3Gym5CDRQWwaCH33+irmG2Wi/4bCGESoTQrFIQpgmmAaGBMjYlKCuqKCRr26y6PK7oTQSCoDFyihfqFXETmmqyN4rlHrbvU1L7PpYEqQimjWxrSBV/VWIuwNAWsLeY0PEPZRs5P7rJDeokud+arkcJDQnX3K7K5llvl0Kwyz6PbOdtZqVYyZbOeGlqq8EKKpwCoM2FIFR2IY+jVdlocu8shzxCIgS1GHNRchGne3O6qyKC0tQhrEU5LFd9z1EKNLTHrlL4G5VmKH4/tzIgyO7IopMdS0MIAuGlcGgGIGmUuzFQSm4twUONQhCtqjE1TpV0FlnbvqMbKVi/OwBJipYhU0oAhHLJV4jWbYzLLdVQEGR7iYMpUeqxMDUwBZjWuinAD3EyF6wawlMKqxoMYRzOY4bg0MmixOKtxSqmhWyK2LamF6TTeLeTv0c32WMQ108B0MmjAihozcCPCTRG+mQrfALdTdb3TJNwX45dFgWohbXtMwGMsdkVYvUAlULSnlFQCbaSww+9DQIaKAFoMNbYqbCwmakxFuC7CXIQXRfh2gj8A3xXhAHw4GT9TY+9kwsMMp8UqwxWpINiIZHUwMY726kqfN5Q+sVLYATLM8YKTjZm6tUQ4FOF2Fr6bCn8R4W9NqL8f4B8nWArcrVRXjHs0krEGRryYTZV4bymSzFZSdR2THL6AUI8lnwUqSaRmaAKugVcNzAvgVoS/ivD7CV6KcCPVkuJ7NCtbKmTPqurkaoN/FTZWMy89fHKi8r7Y5+vWYapuG8ecIg2Mj5sGdo7PafNMz1uX8eGBKOK1+LXAUJIPlB3vmdUEOGhjK2esgfIKQIEPKxx1WAFm/Ar8tiqPbW6k7ggSRuvk06OBpHxaZLkK+NxX43tkQgAcDGCzsC7GMsHDotwfJt7enyqfT6WaBvj3Cm+BoxmPWil7gVq8thYjKrK7f4rlAbjPnxvivnCP8ULRGmPMqO4lLbBNa7ZfV+WhCHerUn4+wu1c970qcHvgR+DtqvyyKA8OBrBV0dYbdWuFSqHnoh2ichbsFhqKvmSR2I2qtw0twWqr3dRZqQiPi6EzgHJ6cw83U31NAn/6Hf8y416N9woL7aVbTrKmxJ5notfEUii6oM/x4Iws102aKu8csGrGpNVKPs+871mMYzHW13dwmODlXN3uh1e8U+O0VMucaHFkxppqs+gdltnNQyJ1sWPHKuPRrDOakWIraGVpoISt7JceV4K+voPrubrbzQzLH2sNp8ZSpBOItRKsM62kZm/PGmn00ufsnDkh722Fm9ldLrTQtELVNeztt/3nWMFcTfWlVq0SwCg19rTtpbaB7BbJZBWuxZgms1yOIV+QN7EmgAuuQGkHH9ZqOrsqTO8XsKlO+rDCVaEsxmlRTm6RFpvWwJwl0iBbVKTtgOsCdc2nGikCHfJEqCA8kNemYVNDrwpyPXML8OpQme6kcD1zW1rQB8VEMNaauCHRN9ksAJbw6rL7eVvWRKwWBo3F+21z7y7VKuutRdC5ILPw4vU9HNet9puFF4eJMhVMamPXXc7BBMHjc6J3nMWZv4biNJcRyVIS3mOPIrKVOn3+VFBg/vE9fH8LDwu8uQdgKtKLTw3Cejz6wWPcP8sVC4Eh9iX8rN8FzsyWRi+RQqLt1XlbJy8PvLiZ+H4q/Bn4oa19syr/fFR+uj9xbIk0ap34HuNpp/eJwPoYjrEiqIR8+AU7MmDIE91yrXL+sMJ/izGJ8Gub+26Fd6vy2MD0g48IIlUHww/OIbn2FuIpQIOAAUTW0Mdcz6DWd8eFUxHuKDxOxluAFR5PK8eHlZNbJSomJdZoHbfg2YluHnu/sQ7ny/6g0OfHh8XWQ+MZhBp6XHgscJoL7327R0V11P6QQOPe7fmdrgPwJ8fe70Pd/HFhqp3OzJ/nAyxa531YWcP67UG2daJZ2FTHuUJzGfbRGNobcYP47sHvD41VeNZk13QCcyZMcrl+UivSma9f36n7LgIUk+heqRFPUjvlprn5ODczF2Gv4dlPpJFdID6e80+S7vO5qw0Pj/Qb/T+2JxEwYb/hVCe5ouW9nxoX/WicNRk/Jy0ONOtWcheJ39kUEVuW3SIZhs71ywE5Ls61lE9X+/WUCNm5F10sMlmPwbjmUiGfA2jP97eb4Y9IqVIfrLpXQae5BJe+GIiPZ/8bKwkrMXZiXBC0HPsozl3yqfFsMPBlfwA8e2Bwk6EhhA6qX/9/ja/xj8Y4csIbvst4cJmr+c+ySB7/A5p05mnftr1GAAAAAElFTkSuQmCC',
symbolOffset: [20, -5],
symbolSize: [40, 40],
symbolPosition: 'end',
z: 12,
data: [120, 200, 150, 80, 70, 110, 130],
}, {
name: '',
type: 'bar',
showBackground: true,
yAxisIndex: 0,
barWidth: 7,
barBorderRadius: 10,
data: [120, 200, 150, 80, 70, 110, 130].map((value, index) => ({
value,
itemStyle: {
normal: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 1,
y2: 0,
colorStops: [
{
offset: 0,
color: '#2F3E56',
},
{
offset: 1,
color:'#7BB1EE',
},
],
},
},
},
})),
},
]
};
Echarts立体柱状图
- 效果图
- 关注点
- 三面立体
- 柱体渐变
- 配置项
// 自定义图形
// 绘制左侧面
export const CubeLeft = echarts.graphic.extendShape({
shape: {
x: 0,
y: 0,
},
buildPath (ctx, shape) {
const xAxisPoint = shape.xAxisPoint;
const c0 = [shape.x, shape.y];
const c1 = [shape.x - offsetX, shape.y - offsetY];
const c2 = [xAxisPoint[0] - offsetX, xAxisPoint[1] - offsetY];
const c3 = [xAxisPoint[0], xAxisPoint[1]];
ctx.moveTo(c0[0], c0[1]).lineTo(c1[0], c1[1]).lineTo(c2[0], c2[1]).lineTo(c3[0], c3[1])
.closePath();
},
});
// 绘制右侧面
export const CubeRight = echarts.graphic.extendShape({
shape: {
x: 0,
y: 0,
},
buildPath (ctx, shape) {
const xAxisPoint = shape.xAxisPoint;
const c1 = [shape.x, shape.y];
const c2 = [xAxisPoint[0], xAxisPoint[1]];
const c3 = [xAxisPoint[0] + offsetX, xAxisPoint[1] - offsetY];
const c4 = [shape.x + offsetX, shape.y - offsetY];
ctx.moveTo(c1[0], c1[1]).lineTo(c2[0], c2[1]).lineTo(c3[0], c3[1]).lineTo(c4[0], c4[1])
.closePath();
},
});
// 绘制顶面
export const CubeTop = echarts.graphic.extendShape({
shape: {
x: 0,
y: 0,
},
buildPath (ctx, shape) {
const c1 = [shape.x, shape.y];
const c2 = [shape.x + offsetX, shape.y - offsetY]; // 右点
const c3 = [shape.x, shape.y - offsetX];
const c4 = [shape.x - offsetX, shape.y - offsetY];
ctx.moveTo(c1[0], c1[1]).lineTo(c2[0], c2[1]).lineTo(c3[0], c3[1]).lineTo(c4[0], c4[1])
.closePath();
},
});
function getRenderItem(param, type) {
const colorList = ['#66C9F2', '#80D1CD', '#9BD977'];
const color = colorList[param.dataIndex % 3];
const rgba = color16ToRGBA(color, type === 'top' ? 0.6 : 0.01);
return {
fill: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 1,
color: rgba,
},
{
offset: 0,
color,
},
]),
};
}
// 图表配置项
config = {
xAxis: {
axisLine: {
lineStyle: {
color: 'transparent',
},
},
axisLabel: {
color: '#B1CBD8',
fontSize: 20,
},
},
yAxis: {
show: false,
splitLine: {
show: false,
},
},
series:[
{
type: 'custom',
// 使用自定义的图形进行绘制
renderItem: (params, api) => {
const location = api.coord([api.value(0), api.value(1)]);
return {
type: 'group',
children: [
{
type: 'CubeLeft', // 绘制左侧面
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: api.coord([api.value(0), 0]),
},
style: {
...getRenderItem(params),
},
},
{
type: 'CubeRight', // 绘制右侧面
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: api.coord([api.value(0), 0]),
},
style: {
...getRenderItem(params),
},
},
{
type: 'CubeTop', // 绘制顶层
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: api.coord([api.value(0), 0]),
},
style: {
...getRenderItem(params, 'top'),
},
},
],
};
},
data: [120, 200, 150, 80, 70, 110, 130],
},
{
type: 'bar',
label: {
normal: {
show: true,
position: 'top',
formatter: e => `${e.value}%`,
fontSize: 15,
color: '#fff',
offset: [0, -15],
},
},
itemStyle: {
color: 'transparent',
},
tooltip: {},
data: [120, 200, 150, 80, 70, 110, 130],
},
]
}
- 效果图
- 关注点
- 三面立体
- 柱体渐变
- 配置项
// 自定义图形
// 绘制左侧面
export const CubeLeft = echarts.graphic.extendShape({
shape: {
x: 0,
y: 0,
},
buildPath (ctx, shape) {
const xAxisPoint = shape.xAxisPoint;
const c0 = [shape.x, shape.y];
const c1 = [shape.x - offsetX, shape.y - offsetY];
const c2 = [xAxisPoint[0] - offsetX, xAxisPoint[1] - offsetY];
const c3 = [xAxisPoint[0], xAxisPoint[1]];
ctx.moveTo(c0[0], c0[1]).lineTo(c1[0], c1[1]).lineTo(c2[0], c2[1]).lineTo(c3[0], c3[1])
.closePath();
},
});
// 绘制右侧面
export const CubeRight = echarts.graphic.extendShape({
shape: {
x: 0,
y: 0,
},
buildPath (ctx, shape) {
const xAxisPoint = shape.xAxisPoint;
const c1 = [shape.x, shape.y];
const c2 = [xAxisPoint[0], xAxisPoint[1]];
const c3 = [xAxisPoint[0] + offsetX, xAxisPoint[1] - offsetY];
const c4 = [shape.x + offsetX, shape.y - offsetY];
ctx.moveTo(c1[0], c1[1]).lineTo(c2[0], c2[1]).lineTo(c3[0], c3[1]).lineTo(c4[0], c4[1])
.closePath();
},
});
// 绘制顶面
export const CubeTop = echarts.graphic.extendShape({
shape: {
x: 0,
y: 0,
},
buildPath (ctx, shape) {
const c1 = [shape.x, shape.y];
const c2 = [shape.x + offsetX, shape.y - offsetY]; // 右点
const c3 = [shape.x, shape.y - offsetX];
const c4 = [shape.x - offsetX, shape.y - offsetY];
ctx.moveTo(c1[0], c1[1]).lineTo(c2[0], c2[1]).lineTo(c3[0], c3[1]).lineTo(c4[0], c4[1])
.closePath();
},
});
function getRenderItem(param, type) {
const colorList = ['#66C9F2', '#80D1CD', '#9BD977'];
const color = colorList[param.dataIndex % 3];
const rgba = color16ToRGBA(color, type === 'top' ? 0.6 : 0.01);
return {
fill: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 1,
color: rgba,
},
{
offset: 0,
color,
},
]),
};
}
// 图表配置项
config = {
xAxis: {
axisLine: {
lineStyle: {
color: 'transparent',
},
},
axisLabel: {
color: '#B1CBD8',
fontSize: 20,
},
},
yAxis: {
show: false,
splitLine: {
show: false,
},
},
series:[
{
type: 'custom',
// 使用自定义的图形进行绘制
renderItem: (params, api) => {
const location = api.coord([api.value(0), api.value(1)]);
return {
type: 'group',
children: [
{
type: 'CubeLeft', // 绘制左侧面
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: api.coord([api.value(0), 0]),
},
style: {
...getRenderItem(params),
},
},
{
type: 'CubeRight', // 绘制右侧面
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: api.coord([api.value(0), 0]),
},
style: {
...getRenderItem(params),
},
},
{
type: 'CubeTop', // 绘制顶层
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: api.coord([api.value(0), 0]),
},
style: {
...getRenderItem(params, 'top'),
},
},
],
};
},
data: [120, 200, 150, 80, 70, 110, 130],
},
{
type: 'bar',
label: {
normal: {
show: true,
position: 'top',
formatter: e => `${e.value}%`,
fontSize: 15,
color: '#fff',
offset: [0, -15],
},
},
itemStyle: {
color: 'transparent',
},
tooltip: {},
data: [120, 200, 150, 80, 70, 110, 130],
},
]
}
CSS旋转圆动画效果
- 效果图
- 关注点
- 背景图渐变
- 旋转动画
- 实现
<div class="value">
<span>{{ item.value }}span>
<span class="unit">%span>
div>
/** 定义旋转动画 **/
@keyframes rotate {
100% {
transform: rotate(360deg);
}
}
.value {
width: 9vh;
height: 9vh;
line-height: 9vh;
text-align: center;
position: relative;
margin: auto;
border-radius: 50%;
/** 设置元素背景径向渐变色 **/
background: radial-gradient(50% 50% at 50% 50%, rgba(12, 27, 48, 0.1) 0%, rgba(12, 27, 48, 0.1) 49%, rgba(116, 217, 229, 0.1) 98%);
text-align: center;
.unit {
font-size: 1.4vh;
position: absolute;
margin-top: 3px;
margin-left: 3px;
}
/** 添加外环元素 **/
&::before,
&::after {
content: "";
position: absolute;
top: -1.5vh;
left: -1.5vh;
bottom: -1.5vh;
right: -1.5vh;
border-radius: 50%;
border-top: 3px solid #58A7B4;
/** 为外环元素添加旋转动画 **/
animation: rotate 6s infinite linear;
}
/** 第二个半圆添加动画延迟3S,使两个动画可以交替执行 **/
&::after {
animation-delay: 3s;
}
}
- 效果图
- 关注点
- 背景图渐变
- 旋转动画
- 实现
<div class="value">
<span>{{ item.value }}span>
<span class="unit">%span>
div>
/** 定义旋转动画 **/
@keyframes rotate {
100% {
transform: rotate(360deg);
}
}
.value {
width: 9vh;
height: 9vh;
line-height: 9vh;
text-align: center;
position: relative;
margin: auto;
border-radius: 50%;
/** 设置元素背景径向渐变色 **/
background: radial-gradient(50% 50% at 50% 50%, rgba(12, 27, 48, 0.1) 0%, rgba(12, 27, 48, 0.1) 49%, rgba(116, 217, 229, 0.1) 98%);
text-align: center;
.unit {
font-size: 1.4vh;
position: absolute;
margin-top: 3px;
margin-left: 3px;
}
/** 添加外环元素 **/
&::before,
&::after {
content: "";
position: absolute;
top: -1.5vh;
left: -1.5vh;
bottom: -1.5vh;
right: -1.5vh;
border-radius: 50%;
border-top: 3px solid #58A7B4;
/** 为外环元素添加旋转动画 **/
animation: rotate 6s infinite linear;
}
/** 第二个半圆添加动画延迟3S,使两个动画可以交替执行 **/
&::after {
animation-delay: 3s;
}
}
CSS元素浮动漂浮效果
- 效果图
- 实现
/** 定义浮动动画 **/
@keyframes float {
0% {
transform: translateY(0);
}
50% {
transform: translateY(-20px);
}
100% {
transform: translateY(0);
}
}
/** 为元素整体添加动画 **/
.indicator{
...其他样式项
animation: float 3s infinite ease-in-out;
/** 往后每个元素的动画执行延迟2s,保证不同的漂浮幅度 **/
&.indicator2{
animation-delay: 2s;
}
&.indicator3{
animation-delay: 4s;
}
&.indicator4{
animation-delay: 6s;
}
}
- 效果图
- 实现
/** 定义浮动动画 **/
@keyframes float {
0% {
transform: translateY(0);
}
50% {
transform: translateY(-20px);
}
100% {
transform: translateY(0);
}
}
/** 为元素整体添加动画 **/
.indicator{
...其他样式项
animation: float 3s infinite ease-in-out;
/** 往后每个元素的动画执行延迟2s,保证不同的漂浮幅度 **/
&.indicator2{
animation-delay: 2s;
}
&.indicator3{
animation-delay: 4s;
}
&.indicator4{
animation-delay: 6s;
}
}
字体渐变色
- 效果
- 关注点
- 背景绘制区域:background-clip
- 实现
span {
/** 设置字体的背景色为径向渐变色 **/
background: linear-gradient(180deg, #F5F5F5 0%, #7EB8E6 100%);
/** 将背景作用区域更新为文本,背景被裁剪为文字的形状 **/
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
/** 文字本身设置为透明色 **/
color: transparent;
}
- 效果
- 关注点
- 背景绘制区域:background-clip
- 实现
span {
/** 设置字体的背景色为径向渐变色 **/
background: linear-gradient(180deg, #F5F5F5 0%, #7EB8E6 100%);
/** 将背景作用区域更新为文本,背景被裁剪为文字的形状 **/
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
/** 文字本身设置为透明色 **/
color: transparent;
}
其他
动态渲染大屏模块
- 背景
一般大屏页面会显示多个小模块,在不同的场景下分别配置显示哪几个。当前由接口提供一组要渲染的模块值,需要根据接口动态设置要渲染的内容
- 实现方式
由Vue component动态组件进行渲染
- 从接口获取一组模块名称:comList
- 对comList进行遍历,使用component :is 进行匹配渲染
- 注意组件的name名称,使用:is匹配时,需要字段值于之一致
<template v-for="name in comList">
<component :is="name" :key="name"/>
template>
- 背景
一般大屏页面会显示多个小模块,在不同的场景下分别配置显示哪几个。当前由接口提供一组要渲染的模块值,需要根据接口动态设置要渲染的内容
- 实现方式
由Vue component动态组件进行渲染
- 从接口获取一组模块名称:comList
- 对comList进行遍历,使用component :is 进行匹配渲染
- 注意组件的name名称,使用:is匹配时,需要字段值于之一致
<template v-for="name in comList">
<component :is="name" :key="name"/>
template>
定时器更新图表数据
- 背景
所有图表数据量较多,不宜一次性展示全部,而是分组进行循环展示。即每次展示5条数据,间隔一定时间后切换至下5条数据,以此循环。
- 实现方式
在顶层App.vue组件中,开启一个定时器,并使用 moduleTimerCount
字段记录当前的组别数,按时间间隔更新该字段。并在子组件中监听该字段,该字段变化时计算当前子组件需要显示的数据条数,并更细图表数据。
- 声明
moduleTimerCount
变量
data(){
return {
moduleTimerCount:0
}
}
- 开启一个定时器
- 背景
所有图表数据量较多,不宜一次性展示全部,而是分组进行循环展示。即每次展示5条数据,间隔一定时间后切换至下5条数据,以此循环。
- 实现方式
在顶层App.vue组件中,开启一个定时器,并使用
moduleTimerCount
字段记录当前的组别数,按时间间隔更新该字段。并在子组件中监听该字段,该字段变化时计算当前子组件需要显示的数据条数,并更细图表数据。- 声明
moduleTimerCount
变量
data(){
return {
moduleTimerCount:0
}
}
- 开启一个定时器
- 声明
使用 setTimeout
模拟 setInterval
定时器(相比于setInterval,setTimeout每次执行完当前次任务后才会执行下一次任务,不存在任务堆积问题,每次执行完后自行清理、独立调用,内存泄露的风险较低)。
```JavaScript
function openModuleRefresh(delay) {
const execute = () => {
this.moduleTimerCount += 1;
if (moduleRefreshTime) {
clearTimeout(moduleRefreshTime);
}
moduleRefreshTime = setTimeout(execute, delay * 1000);
};
setTimeout(execute, delay * 1000); // 首次延迟执行
},
```
3. 子组件监听字段变化
```JavaScript
watch: {
moduleTimerCount(value) {
if (dataList) {
// 当前接口数据的数据长度
const dataLength = dataList.length;
// 每5个分一组,计算组别数
const totalGr0up = Math.ceil(dataLength / 5);
// 计算当前组别数,使用 moduleTimerCount 值对组别数取余,保证获取的当前组别不会超过总组别数
this.chartGr0upIndex = value % totalGr0up;
// 计算当前的数据,由组别数获取当前组的数据索引
const startIndex = this.chartGr0upIndex * 5;
let endIndex = (this.chartGr0upIndex + 1) * 5;
if (endIndex >= echartsData.data.length) {
endIndex = echartsData.data.length;
}
// 根据索引截取数据
const renderChartData = echartsData.slice(startIndex, endIndex);
}
},
},
```
来源:juejin.cn/post/7439207153938317339
小程序头像昵称获取“一刀切”式调整,害苦开发者
💬 前言
正如标题所言,小程序的用户头像昵称获取规则从2022年的5月调整了,但是这一个改动却害苦了一众开发者。我遇到这个问题,是在9月份开发个人小程序的时候。
我开发的是一个 “微信头像加国旗” 类的小程序,叫做 “星点贴纸”。本以为开发会很顺利,因为几乎没有复杂业务,但是起步没多久微信 API 冷不丁地就给了我一顿暴击(红色警告),这个暴击就是 “小程序用户头像昵称获取规则调整”。
正如标题所言,小程序的用户头像昵称获取规则从2022年的5月调整了,但是这一个改动却害苦了一众开发者。我遇到这个问题,是在9月份开发个人小程序的时候。
我开发的是一个 “微信头像加国旗” 类的小程序,叫做 “星点贴纸”。本以为开发会很顺利,因为几乎没有复杂业务,但是起步没多久微信 API 冷不丁地就给了我一顿暴击(红色警告),这个暴击就是 “小程序用户头像昵称获取规则调整”。
💻 还原业务场景
要讲清楚当时开发过程遇到的问题,就要先代入到业务场景来说明。“星点贴纸” 主要提供给微信头像加各类贴纸的功能,那么这首先就需要获取到用户头像,当然这是功能最直接的实现路径。“星点贴纸” 小程序所能够提供并使用作为头像的方式有:
- 使用微信头像
- 从相册中选择
- 使用相机拍摄
是的,我就是这样规划业务功能的,于是我就实现了点击头像预览区时,弹出选项列表。
要讲清楚当时开发过程遇到的问题,就要先代入到业务场景来说明。“星点贴纸” 主要提供给微信头像加各类贴纸的功能,那么这首先就需要获取到用户头像,当然这是功能最直接的实现路径。“星点贴纸” 小程序所能够提供并使用作为头像的方式有:
- 使用微信头像
- 从相册中选择
- 使用相机拍摄
是的,我就是这样规划业务功能的,于是我就实现了点击头像预览区时,弹出选项列表。
弹出选项列表
编码实现如下
- 在 wxml 中给头像预览区添加点击事件
bind:tap="preAvatarTapped"
。
<view class="avatar-area" bind:tap="preAvatarTapped">
<image class="img-sticker"
mode="{{preAvatar.stickerMode}}"
style="{{preAvatar.stickerPosition}}"
src="{{preAvatar.sticker}}">
image>
<image class="img-sample" src="{{preAvatar.sample}}">image>
view>
- 在 js 中实现点击后调用
wx.showActionSheet()
,弹出选项列表。
Page({
preAvatarTapped(event) {
wx.showActionSheet({
itemList: ["使用微信头像", "从相册中选择", "使用相机拍摄"],
itemColor: "#FFBB66",
success: (res) => {
console.log(res.tapIndex);
},
fail: (err) => {
console.log(err.errMsg);
}
});
}
});
编码实现如下
- 在 wxml 中给头像预览区添加点击事件
bind:tap="preAvatarTapped"
。
<view class="avatar-area" bind:tap="preAvatarTapped">
<image class="img-sticker"
mode="{{preAvatar.stickerMode}}"
style="{{preAvatar.stickerPosition}}"
src="{{preAvatar.sticker}}">
image>
<image class="img-sample" src="{{preAvatar.sample}}">image>
view>
- 在 js 中实现点击后调用
wx.showActionSheet()
,弹出选项列表。
Page({
preAvatarTapped(event) {
wx.showActionSheet({
itemList: ["使用微信头像", "从相册中选择", "使用相机拍摄"],
itemColor: "#FFBB66",
success: (res) => {
console.log(res.tapIndex);
},
fail: (err) => {
console.log(err.errMsg);
}
});
}
});
获取用户头像
那么接下来,就是实现 “获取用户微信头像”,于是我在微信小程序 API 中找到了wx.getUserProfile
用于获取用户头像昵称。
Page({
preAvatarTapped(event) {
wx.showActionSheet({
itemList: ["使用微信头像", "从相册中选择", "使用相机拍摄"],
itemColor: "#FFBB66",
success: (res) => {
console.log(res.tapIndex);
if (res.tapIndex === 0) {
wx.getUserProfile({
desc: '用于处理图像',
success: (res) => {
console.log(res.tapIndex);
},
fail: (err) => {
console.log(err.errMsg);
}
});
}
},
fail: (err) => {
console.log(err.errMsg);
}
});
}
});
但是编译测试后发现控制台报错了,获取的头像也是灰色的默认头像。看着错误信息 “jsapi invalid request data”,我以为是请求的参数问题,实际上是接口本身的问题。
📖 阅读文档
起初我也看到了红色的 Tip,但是并没有理会,因为 API 调整我想大概率也就是字段变更之类的。但是找了一圈发现依然解决不了 “jsapi invalid request data” 的问题,索性点进去看看 Tip。
小程序用户头像昵称获取规则调整公告
不看不知道,一看才知道,自2022年10月25日之后基础库 2.27.1 版本以上通过wx.getUserProfile
接口获取用户头像将统一返回默认灰色头像,昵称将统一返回 “微信用户”。如业务需获取用户头像昵称,可以使用「头像昵称填写」能力。
头像昵称填写
Ok,既然官方文档也给出了解决方法,那就换成 “头像昵称填写” API 来实现获取用户微信头像。只不过,这又让我犯了难。
首先官方文档规定 “获取头像昵称” 的开放能力只能通过button
组件的open-type="chooseAvatar"
实现。而我前面所使用的wx.showActionSheet
并不支持给选项添加这样的参数,当然第三方的 UI 组件库是能够实现的。
其次就是 “弹出选项列表” 通过给view
组件添加点击事件实现的,
,而获取头像昵称的开放能力只能通过button
组件实现。
最开始我是循着业务功能的需求尝试解决问题,使用第三方的 UI 组件库来实现 “弹出选项列表”,但是因为 UI 样式及一些参数无法达到预期遂放弃。
于是想着如何给view
实现添加open-type="chooseAvatar"
,经过研究后终于实现了。这里面的难点是只能使用button
组件的前提下又要不影响原本页面设计的样式,如何对button
组件的样式作改动。
这里的思路如下:
- 将按钮组件作为一个遮罩层,覆盖在图片组件上面,这里需要用到定位以及
z-index
实现。 - 将按钮的样式改成透明,可以使用
plain="true"
将按钮镂空。 - 此时按钮的样式还需要去掉边框,使用
border: unset
。
<view class="avatar-area">
<button class="btn-mask" plain="true" open-type="chooseAvatar" bindchooseavatar="getUserAvatar">button>
<image class="img-sticker" mode="{{preAvatar.stickerMode}}" style="{{preAvatar.stickerPosition}}" src="{{preAvatar.sticker}}">
image>
<image class="img-sample" src="{{preAvatar.sample}}">image>
view>
.avatar-area .btn-mask {
position: absolute;
width: 100%;
height: 100%;
border: unset;
z-index: 1;
}
小程序页面实现效果如下:
🫠 放弃使用微信头像
也许大家都以为改用 “头像昵称填写” 后问题已经解决了,但是我却放弃了这个方案。原因很简单,“头像昵称填写” 所获取的头像是一个十分模糊的头像,根本不适用于 “微信头像” 图像处理类业务,包括 “微信头像加国旗” 这样的功能。至于有多糊,我也懒得去看图片尺寸了,因为当时也是被气到模糊了,根本没法用。一路下来就没办法实现 “使用微信头像”——前功尽弃。
🍉 个人见解
微信官方调整 API 虽然见怪不怪,但是却没想到废弃一个 API 也是想做就做。虽然在 “规则” 的调整背景中有一段话 “实践中发现有部分小程序,在用户刚打开小程序时就要求收集用户的微信昵称头像,或者在支付前等不合理路径上要求授权。如果用户拒绝授权,则无法使用小程序或相关功能。在已经获取用户的 openId 与 unionId 信息情况下,用户的微信昵称与头像并不是用户使用小程序的必要条件”,但是请问,在没有获取用户的 openId 与 unionId 信息情况下呢?
微信官方似乎是站在用户隐私的立场做的调整,但如此 “一刀切” 式的调整,那么《小程序用户隐私保护指引》的意义在哪里?如果用户觉得授权不合理自然会举报,而官方则应当要求违规的小程序整改,而不该想当然地废弃一个接口,又临时拿出一个替代接口,属实是又当又立。再退一步讲,即使是用替代接口,也总不该是个功能降级的接口......
言尽于此,最后还是希望微信官方有一天能把这个问题解决。
来源:juejin.cn/post/7436361280586366987
Angular 19 来了,一大波我看不懂的主版本升级!(长文警告)
00. 前言
Angular 19 主版本正式升级,亮点功能如下:
- 控制哪些路由在客户端、服务端或构建期渲染,且在预渲染期解析路由参数
- 核心响应性原语稳定,引入
linkedSignal
等新原语 - 增量水合预览版,支持追求极致性能的用例
- 生活质量提升 - 时间选择器组件、样式 HMR 等等!
01. 为速度而构建
01-1. 增量水合预览版
增量水合(incremental hydration)允许你使用 @defer
语法对模板局部进行注释,指示 Angular 在特定触发器上惰性加载和水合。
Angular 19 中,你可以在任何使用了 SSR 和完整应用水合的应用中尝试新的增量水合。
请在客户端 bootstrap 中指定:
要将增量水合应用到部分模板,请使用:
01-2. 默认启用事件重播
SSR 应用中,用户事件与下载并执行处理该事件代码的浏览器之间存在鸿沟。event dispatch
解决了这个问题。
event dispatch
在初始页面加载期间捕获事件,并在负责处理事件的代码可用时重播这些事件。
通过配置水合 provider,你可以启用事件重播功能:
今天,我们将事件重播升级到稳定版,并默认为所有 SSR 新应用启用此功能!
01-3. 路由级别的渲染模式
Angular 19 提供了一个 ServerRoute
新接口,允许你配置各个路由应该在服务器端渲染、预渲染还是在客户端渲染:
上述示例中,我们指定在服务端渲染 login
路由,在客户端渲染 dashboard
路由,并预渲染其他所有路由。
服务器路由配置是一个新的配置文件,但它使用 globs
组合现有的路由声明,因此你不必复制任何路由。
过去,没有符合人体工程学的方法可以在预渲染时解析路由参数。
现在,可以使用服务器路由配置无缝实现:
由于 Angular 在注入上下文中执行 getPrerenderPaths
,因此你可以使用 inject
在参数解析中重用业务逻辑。
01-4. SSR + Zoneless Angular
Angular 18 实验性支持 zoneless,允许 Angular 不依赖 zone.js 运行。
等待应用的主要原因是待处理的请求和导航,我们决定引入 HttpClient
和 Router
的原语,来延迟将页面发送给用户,直到应用准备就绪。你现在可以在 Angular 19 中尝试这两个包和 zoneless!
此外,我们还提供了一个 RxJS 运算符,用于通知服务堆栈 Angular 仍未完成渲染:
当 subscription
发出新值时,我们将稳定应用,且服务堆栈会将渲染的标记传递给客户端。
02. DX(开发者体验)
02-1. HMR + 即时编辑/刷新
Angular 19 支持开箱即用的样式 HMR(热模块替换),且实验性支持模板 HMR!
之前,每次更改组件的样式并保存文件时,Angular CLI 都会重建应用,并向通知浏览器刷新。
新 HMR 将编译你修改的样式,将结果发送到浏览器,且在不刷新页面和丢失任何状态的情况下修复应用。
Angular 19 默认启用样式 HMR!要尝试模板 HMR,请使用:
要禁用此功能,请将开发服务器选项指定为 "hmr": false
,或者使用:
02-2. standalone 默认为 true
Angular 19 提供了一个 schematic,它将作为 ng update
的一部分运行,并自动删除所有 standalone
指令、组件和管道的 standalone
组件元数据属性,且将所有 non-standalone 抽象的 standalone
设置为 false
。
02-3. 严格执行 standalone
为了帮助你在项目中实施现代 API,我们开发了一个编译器标志,如果发现不是 standalone
的组件、指令或管道,它就会报错。
要在项目中启用它,请配置 angular.json
:
03. 响应性的进化
03-1. 输入、输出和视图查询稳定
我们观察了新的输入、输出和视图查询 API,并将它们升级到稳定版!
为了简化新 API 的采用,我们开发了 schematics,它将转换你现有的输入、输出和视图查询:
请注意,与传统输入相比,signal 输入是只读的,因此如果要设置输入值,则可能需要手动迁移应用的某些部分。
要一次运行所有迁移,你可以使用:
03-2. 引入 linkedSignal
UI 通常仍需跟踪某些更高级状态的可变状态。举个栗子,选择 UI 具有“当前选择”状态,该状态会随着用户进行选择而变更,但如果选项列表变更,那也需要重置。
新增的 linkedSignal
实验性原语创建了一个可写 signal,捕获了这种类型的依赖关系:
linkedSignal
明确了 options
和 choice
之间的关系,而无需求助于 effect
。
新 API 有 2 种形式:一种是上述的简化形式,另一种是高级形式,开发者可以在其中访问之前的 options
和 choice
值。
它还有一个高级 API,允许使用更复杂的逻辑,比如只要用户的 choice
存在于新的 options
列表中,就可以维护用户的 choice
。
03-3. 引入 resource
目前,Angular 的 signals 主要集中在同步数据上:在 signals 中存储状态、computed 值等。
Angular 19 新增 resource()
实验性 API,这是 signals 与异步操作集成的第一步。
resource
是参与 signal 图的异步依赖,你可以将 resource
视为具有三个部分:
request
函数,它表示要根据 signals 发出的确切请求。比如,user
资源可能会计算依赖当前路由的用户 ID 参数的请求。loader
加载器,当请求更改时执行异步操作,并最终返回新值。- 生成的
Resource
实例,它暴露了与可用值通信的 signals 和resource
的加载中、已解析等当前状态。
因为现在许多 Angular 应用都使用 RxJS 来获取数据,我们还在 @angular/core/rxjs-interop
中添加了 rxResource
,它从基于 Observable 的 loader 创建 resource。
参考文献
[1] Angular 官方博客: blog.angular.dev/meet-angula…
来源:juejin.cn/post/7439721466499514418
前端如何优雅通知用户刷新页面?
前言
老板:新的需求不是上线了嘛,怎么用户看到的还是老的页面呀
窝囊废:让用户刷新一下页面,或者清一下缓存
老板:那我得告诉用户,刷新一下页面,或者清一下缓存,才能看到新的页面呀,感觉用户体验不好啊,不能直接刷新页面嘛?
窝囊废:可以解决(OS:一点改的必要没有,用户全是大聪明)
老板:新的需求不是上线了嘛,怎么用户看到的还是老的页面呀
窝囊废:让用户刷新一下页面,或者清一下缓存
老板:那我得告诉用户,刷新一下页面,或者清一下缓存,才能看到新的页面呀,感觉用户体验不好啊,不能直接刷新页面嘛?
窝囊废:可以解决(OS:一点改的必要没有,用户全是大聪明)
产品介绍
c端需要经常进行一些文案调整,一些老版的文字字眼可能会导致一些舆论问题,所以就需要更新之后刷新页面,让用户看到新的页面。
c端需要经常进行一些文案调整,一些老版的文字字眼可能会导致一些舆论问题,所以就需要更新之后刷新页面,让用户看到新的页面。
思考问题为什么产生
项目是基于vue的spa应用,通过nginx代理静态资源,配置了index.html协商缓存,js、css等静态文件Cache-Control
,按正常前端重新部署后, 用户重新
访问系统,已经是最新的页面。
但是绝大部份用户都是访问页面之后一直停留在此页面,这时候前端部署后,用户就无法看到新的页面,需要用户刷新页面。
项目是基于vue的spa应用,通过nginx代理静态资源,配置了index.html协商缓存,js、css等静态文件Cache-Control
,按正常前端重新部署后, 用户重新
访问系统,已经是最新的页面。
但是绝大部份用户都是访问页面之后一直停留在此页面,这时候前端部署后,用户就无法看到新的页面,需要用户刷新页面。
产生问题
- 如果后端接口有更新,前端重新部署后,用户访问老的页面,可能会导致接口报错。
- 如果前端部署后,用户访问老的页面,可能无法看到新的页面,需要用户刷新页面,用户体验不好。
- 出现线上bug,修复完后,用户依旧访问老的页面,仍会遇到bug。
- 如果后端接口有更新,前端重新部署后,用户访问老的页面,可能会导致接口报错。
- 如果前端部署后,用户访问老的页面,可能无法看到新的页面,需要用户刷新页面,用户体验不好。
- 出现线上bug,修复完后,用户依旧访问老的页面,仍会遇到bug。
解决方案
- 前后端配合解决
- WebSocket
- SSE(Server-Send-Event)
- 纯前端方案 以下示例均以vite+vue3为例;
- 轮询html Etag/Last-Modified
- 前后端配合解决
- WebSocket
- SSE(Server-Send-Event)
- 纯前端方案 以下示例均以vite+vue3为例;
在App.vue中添加如下代码
const oldHtmlEtag = ref();
const timer = ref();
const getHtmlEtag = async () => {
const { protocol, host } = window.location;
const res = await fetch(`${protocol}//${host}`, {
headers: {
"Cache-Control": "no-cache",
},
});
return res.headers.get("Etag");
};
oldHtmlEtag.value = await getHtmlEtag();
clearInterval(timer.value);
timer.value = setInterval(async () => {
const newHtmlEtag = await getHtmlEtag();
console.log("---new---", newHtmlEtag);
if (newHtmlEtag !== oldHtmlEtag.value) {
Modal.destroyAll();
Modal.confirm({
title: "检测到新版本,是否更新?",
content: "新版本内容:",
okText: "更新",
cancelText: "取消",
onOk: () => {
window.location.reload();
},
});
}
}, 30000);
- versionData.json
自定义plugin,项目根目录创建/plugins/vitePluginCheckVersion.ts
import path from "path";
import fs from "fs";
export function checkVersion(version: string) {
return {
name: "vite-plugin-check-version",
buildStart() {
const now = new Date().getTime();
const version = {
version: now,
};
const versionPath = path.join(__dirname, "../public/versionData.json");
fs.writeFileSync(versionPath, JSON.stringify(version), "utf8", (err) => {
if (err) {
console.log("写入失败");
} else {
console.log("写入成功");
}
});
},
};
}
在vite.config.ts中引入插件
import { checkVersion } from "./plugins/vitePluginCheckVersion";
plugins: [
vue(),
checkVersion(),
]
在App.vue中添加如下代码
const timer = ref()
const checkUpdate = async () => {
let res = await fetch('/versionData.json', {
headers: {
'Cache-Control': 'no-cache',
},
}).then((r) => r.json())
if (!localStorage.getItem('demo_version')) {
localStorage.setItem('demo_version', res.version)
} else {
if (res.version !== localStorage.getItem('demo_version')) {
localStorage.setItem('demo_version', res.version)
Modal.confirm({
title: '检测到新版本,是否更新?',
content: '新版本内容:' + res.content,
okText: '更新',
cancelText: '取消',
onOk: () => {
window.location.reload()
},
})
}
}
}
onMounted(()=>{
clearInterval(timer.value)
timer.value = setInterval(async () => {
checkUpdate()
}, 30000)
})
Use
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { webUpdateNotice } from '@plugin-web-update-notification/vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
webUpdateNotice({
logVersion: true,
}),
]
})
来源:juejin.cn/post/7439905609312403483
为什么可以通过process.env.NODE_ENV来区分环境
0.背景
通常我们在开发中需要区分当前代码的运行环境是dev、test、prod环境,以便我们进行相对应的项目配置,比如是否开启sourceMap,api地址切换等。而我们区分环境一般都是通过process.env.NODE_ENV,那么为什么process.env.NODE_ENV可以区分环境呢?是我们给他配置的,还是他可以自动识别呢?
1.什么是process.env.NODE_ENV
process.env
属性返回一个包含用户环境信息的对象。
在node环境中,当我们打印process.env
时,发现它并没有NODE_ENV
这一个属性。实际上,process.env.NODE_ENV
是在package.json的scripts
命令中注入的,也就是NODE_ENV
并不是node自带的,而是由用户定义的,至于为什么叫NODE_ENV
,应该是约定成俗的吧。
2.通过package.json来设置node环境中的环境变量
如下为在package.json文件的script命令中设置一个变量NODE_ENV
。
{
"scripts": {
"dev": "NODE_ENV=development webpack --config webpack.dev.config.js"
}
}
执行对应的webpack.config.js文件
// webpack.config.js
console.log("【process.env】", process.env.AAA);
但是在index.jsx
中也就是浏览器环境下的文件中打印process.env
就会报错,如下:
可以看到NODE_ENV
被赋值为development
,当执行npm run dev
时,我们就可以在 webpack.dev.config.js
脚本中以及它所引入的脚本中访问到process.env.NODE_ENV
,而无法在其它脚本中访问。原因就是前文提到的peocess.env
是Node环境的属性,浏览器环境中index.js文件不能够获取到。
3.使用webpack.DefinePlugin
插件在业务代码中注入环境变量
这个时候我们就存在一个解决方法,通过webpack中的DefinePlugin来设置一个全局变量,这样所有的打包的js文件都可以访问到这个全局变量了。
const webpack = require('webpack');
module.exports = {
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': '"development"'
})
]
}
使用DefinePlugin注意点
webpack.definePlugins
本质上是打包过程中的字符串替换,比如我们刚才定义的__WEBPACK__ENV:JSON.stringify('packages')
。
在打包过程中,如果我们代码中使用到了__WEPBACK__ENV
,webpack
会将它的值替换成为对应definePlugins
中定义的值,本质上就是匹配字符串替换,并不是传统意义上的环境变量process
注入。
如下图所示:
由上图可知:仔细对比这两段代码第一个问题的答案其实已经很明了了,针对definePlugin
这个插件我们使用它定义key:value
全局变量时,他会将value
进行会直接替换文本。所以我们通常使用JSON.stringify('pacakges')
或者"'packages'"
。
来源:juejin.cn/post/7345760019319390248
Unocss 写 border太费劲?试试这样
在css中, border 是高频使用的一个属性,但它的写法有非常非常多。
按属性分类,border 属性可以分为以下几类:
- border-width:设置边框的宽度。
- border-style:设置边框的样式。
- border-color:设置边框的颜色。
按方向分类,border 属性可以分为以下几类:
- border-top:设置上边框的宽度、样式和颜色。
- border-right:设置右边框的宽度、样式和颜色。
- border-bottom:设置下边框的宽度、样式和颜色。
- border-left:设置左边框的宽度、样式和颜色。
一般情况下我们会直接使用 border 属性,它是一个简写属性,可以同时设置边框的宽度、样式和颜色。
div {
border: 1px solid red;
}
如果我们要单独设置某个方向边框的某个属性,可以使用以下属性:
- border-top-width:设置上边框的宽度。
- border-top-style:设置上边框的样式。
- border-top-color:设置上边框的颜色。
div {
border-top-width: 1px;
border-top-style: solid;
border-top-color: red;
}
我们也可以单独设置某个方向的边框宽度、样式和颜色,可以使用以下属性:
- border-top:设置上边框的宽度、样式和颜色。
- border-right:设置右边框的宽度、样式和颜色。
- border-bottom:设置下边框的宽度、样式和颜色。
- border-left:设置左边框的宽度、样式和颜色。
div {
border-top: 1px solid red;
}
以上的写法,最常用的还是简写方式,如:
- 简写属性:border: 1px solid red;
- 单个方向的属性:border-top: 1px solid red;
在 unocss 中,我们怎么写边框呢?
可以使用 border 的预设,比如:
<div class="b">div>
<div class="b-2px">div>
<div class="b b-solid">div>
<div class="b b-red">div>
<div class="b b-dashed b-red">div>
为什么只设置 boder-width: 1px;
也能看到边框效果呢?这是因为浏览器为每个元素都设置了一个默认的边框样式,只是 boder-width
的默认值是 0px
,所以最少只需要设置 border-width 就能看到边框效果
当然 unocss 预设中边框的写法也可以单独定义每个方向的宽度、样式和颜色,比如
<div class="b-l">div>
<div class="b b-l-dashed">div>
<div class="b b-l-red">div>
<div class="b-l-2px b-l-red b-l-dashed">div>
由上可知 unocss 的 border 预设其实就是将 border-width 、 border-style 和 border-color 分别定义,然后又可以各自组合上 left、right、top 和 bottom,这样就可以控制每一个方向的边框
这样写当然没什么问题,也非常的灵活,但仔细想想是不是过于麻烦了呢,为什么会觉得麻烦呢?原因就是这样写没有利用到 border
的简写方式,比如 左边 2px red dashed 的边框
我们其实是可以简写成这样的:
div {
border-left: 2px dashed red;
}
甚至我们写行内样式也比 b-l-2px b-l-red b-l-dashed
这种写法更简洁易懂
<div style="border-left: 2px dashed red;">div>
那么,有没有办法不写 css 也能做到这么简洁呢,并且还不能损失它的灵活性
当然有,答案就是自定义rules
// unocss配置文件, uno.config.js|ts
import { defineConfig, presetUno } from 'unocss'
const DIRECTION_MAPPIINGS = { t: 'top', r: 'right', b: 'bottom', l: 'left' }
export default defineConfig({
presets: [
presetUno,
],
rules: [
[
/^b(t|r|b|l|d)-(.*)/,
([, d, c]) => {
const direction = DIRECTION_MAPPIINGS[d] || ''
const p = direction ? `border-${direction}` : 'border'
const attrs = c.split('_')
if (
// 属性中不包含 border-style 则默认 solid
!attrs.some((item) =>
/^(none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset)$/.test(item),
)
) {
attrs.push('solid')
}
// 属性中不包含 border-width 则默认 1px
if (!attrs.some((item) => /^\d/.test(item))) {
attrs.push('1px')
}
return {
[p]: attrs.join(' '),
}
},
],
],
})
怎么用呢?
- 完整的写法
<div class="bd-2px_dashed_red">div>
<div class="bl-2px_dashed_red">div>
<div class="br-2px_dashed_red">div>
- 缺省的写法
border-width 、 border-style 和 border-color 都可以缺省(但最少写一个),border-style 默认 solid,border-width 默认 1px,border-color 默认继承父容器的 color
<div class="bd-2px">div>
<div class="bd-red">div>
<div class="bd-dashed">div>
<div class="bl-2px">div>
<div class="bl-red">div>
<div class="bl-dashed">div>
<div class="bl-2px">div>
<div class="bl-red">div>
<div class="bl-dashed">div>
可以看出这种写法是不是更简洁、更容易理解呢!
为什么 border-width 、 border-style 和 border-color 最少得写一个,全部缺省不是更好吗?
答: unocss 的默认写法就是可以全缺省的,没必要多此一举了,如 b
b-r
b-l
b-t
b-b
为什么用
bd
表示 border 而不用b
?
主要是为了跟 unocss 的默认写法区分开来,其次 bd
也勉强符合 border
语义的简写。
以上就是本篇文章分享的所有内容了,希望对大家有帮助。
关注我,大脸怪将持续分享更多实用知识和技巧
来源:juejin.cn/post/7348473946582646784
hover后元素边框变粗,样式被挤压?一招帮你解决,快收藏备用!
背景简介
大家好,我是石小石!最近开发中遇到这样一个需求:
hover卡片后,边框由原来的1px变成2px,且颜色由灰色变为蓝色。
hover改变样式,这太easy了!
.work-order-card {
padding: 8px 16px 16px 16px;
border-radius: 8px;
border: 1px solid #e1e5eb;
width: 296px;
transition: all 0.2s ease;
&:hover {
border: 2px solid #64A6F7;
transition: all 0.2s ease;
}
}
但实际做完后,我们会发现一个问题,样式不够丝滑:
hover后元素的内边距发生变化,中间区域尺寸被挤压,从而导致过渡动画很生硬!
这个问题在前端开发中应该比较常见,我就简单分享一下自己的解决方案吧。
如何解决
要想解决这个问题,本质就是让hover前后,中间核心区域的位置不随边框、边距的变化而变化。
场景一:边框从无到有
最简单的场景,就是一开始没有边框,后来有边框。
这种最容易处理,我们只需要给盒子设置和hover后同样粗细的边框,颜色设置透明即可。
.work-order-card {
padding: 8px 16px 16px 16px;
border-radius: 8px;
border: 2px solid transparent;
width: 296px;
transition: all 0.2s ease;
&:hover {
border: 2px solid #64A6F7;
transition: all 0.2s ease;
}
}
场景二:边框粗细发生变化
比较麻烦的场景,如文章一开始说的场景,hover后,边框从1px变成2px。这种情况,hover盒子的padding一定会变化(注意大盒子尺寸是固定的),必然会导致内部元素被挤压,位置改变。
动态padding
当然,聪明的你可能计算hover后的padding
.work-order-card {
padding: 8px 16px 16px 16px;
border-radius: 8px;
border: 1px solid #E1E5EB;
width: 296px;
&:hover {
padding: 7px 15px 15px 15px;
border: 2px solid #64A6F7;
}
}
不加过渡动画时,看着挺不错
但加上transition过渡效果,那就原形毕露!
.work-order-card {
padding: 8px 16px 16px 16px;
border-radius: 8px;
border: 1px solid #E1E5EB;
width: 296px;
transition: all 0.2s ease;
&:hover {
padding: 7px 15px 15px 15px;
border: 2px solid #64A6F7;
transition: all 0.2s ease;
}
}
不设置padding,居中核心内容
如果盒子的尺寸都能确定,最好的方式,还是使用flex布局,让中间的核心区域(下图红色部分)永远居中!这样,无论边框怎么变,中间的位置永远不变,自然就解决了元素被挤压的问题!
<div class="work-order-card">
<div class="center-box">
<!-- 子元素 -->
</div>
</div>
.work-order-card {
border-radius: 8px;
border: 1px solid #E1E5EB;
width: 296px;
height: 214px;
transition: all 0.2s ease;
&:hover {
border: 2px solid #64A6F7;
transition: all 0.2s ease;
}
.center-box{
width: 264px;
}
}
注意:这种实现方式,要求最外层的盒子宽高是固定的,内部盒子宽度也需要固定。
总结
针对hover某个元素,其边框变粗导致内部元素被挤压的问题,这篇文章提供了三个解决方案:
- 边框从无到有,改变原始边框透明度即可
- 边框hover尺寸变化:
- 如果不要求过渡效果,hover后可以计算padding
- 如果需要过渡效果,使用felx布局居中核心区域即可
如果大家有更好的方案,可以评论区分享一下。
来源:juejin.cn/post/7431999862919921675
autoUno:最直觉的UnoCSS预设方案
起因
可能你跟我一样头一次听说原子化CSS时,觉得写预设 class 听起来是一件极蠢的事,感觉这是在开倒车,因为我们都经历过 Bootstrap(其实不属于原子化) 的时代。
于是在这个概念刚刚在国内爆火的时候,我对其是嗤之以鼻的,当时我想象中的原子化:
只有带鱼屏才装得下。
而实际上的原子化:
在实际使用中,我们往往不会将所有的样式都使用原子化实现(当然也可以这么干)。
举一个例子,在你开发时,你按照自己习惯,做了一个近乎完美的布局,你的 class 已经写的非常棒,页面看起来赏心悦目,而此时,产品告诉你要在某个按钮的下面加一句提示,为了不破坏你的完美代码,又或者是样式无需太多的 css,你可能会选择直接写行内样式。此时原子化的魅力就体现了出来,只需要简单的寥寥几字,就把准确的 css 表达出来了,而无需再抽出一个无意义的 class。
可能你跟我一样头一次听说原子化CSS时,觉得写预设 class 听起来是一件极蠢的事,感觉这是在开倒车,因为我们都经历过 Bootstrap(其实不属于原子化) 的时代。
于是在这个概念刚刚在国内爆火的时候,我对其是嗤之以鼻的,当时我想象中的原子化:
只有带鱼屏才装得下。
而实际上的原子化:
在实际使用中,我们往往不会将所有的样式都使用原子化实现(当然也可以这么干)。
举一个例子,在你开发时,你按照自己习惯,做了一个近乎完美的布局,你的 class 已经写的非常棒,页面看起来赏心悦目,而此时,产品告诉你要在某个按钮的下面加一句提示,为了不破坏你的完美代码,又或者是样式无需太多的 css,你可能会选择直接写行内样式。此时原子化的魅力就体现了出来,只需要简单的寥寥几字,就把准确的 css 表达出来了,而无需再抽出一个无意义的 class。
为什么是 UnoCSS
在 tailwindCSS、windiCSS 之后,一位长发飘飘的帅小伙,发布了一款国产原子化工具 UnoCSS。虽然大家可能很熟悉它,我还是想啰嗦几句。
在 tailwindCSS、windiCSS 之后,一位长发飘飘的帅小伙,发布了一款国产原子化工具 UnoCSS。虽然大家可能很熟悉它,我还是想啰嗦几句。
UnoCSS 的优势
CSS原子化在前端的长河中,可谓是一个婴儿:
“原子化 CSS”(Atomic CSS)的概念最早可以追溯到 2014 年,由 Nicolas Gallagher 在他的博客文章 “About HTML semantics and front-end architecture” 中提出。他在文章中提到了一种新的 CSS 方法论,即使用“单一功能类”(Single-purpose Classes)来替代传统的基于组件或块的样式管理方式。这种方法的核心思想是,将每一个 CSS 类设计为仅包含一种样式规则或一组简单的样式,以便更好地复用和组合样式,从而减少冗余代码。这一思想成为后来原子化 CSS 的基础。同年,第一个原子化框架 ACSS(Atomic CSS)发布了,由 Yahoo 团队开发。
ACSS 的推出激发了 Utility-First CSS 框架的兴起,最终在 Tailwind CSS 等项目中得到广泛应用。
Tailwind 和 Windi CSS 虽然也支持自定义,但它们的定制性主要体现在配置文件的扩展上,如自定义颜色、间距、字体、断点等,且在设计上仍然偏向于固定的原子类名体系。这两者可以通过配置文件生成新的实用类名,这种方式显然使他们有了不可避免的局限性。
而 UnoCSS 则有着高度定制化的特性,主要体现在它的灵活性和插件化设计,使其可以自由定义和扩展类名、行为,甚至能模拟其他 CSS 框架。相比之下,Tailwind CSS 和 Windi CSS 在设计上更偏向于固定的、基于配置的实用类体系,而 UnoCSS 则提供了更多自由度。
这样的设计也使得 UnoCSS 有着天然的性能优势,UnoCSS 支持基于正则表达式的动态类名解析,允许开发者定义自定义的样式规则。例如,可以通过简单的正则规则为特定样式创建动态的类,而不需要预先定义所有的类名。这使得 UnoCSS 的 CSS 小而精,据官网介绍,它无需解析,无需AST,无需扫描。它比Windi CSS或Tailwind CSS JIT快5倍!
CSS原子化在前端的长河中,可谓是一个婴儿:
“原子化 CSS”(Atomic CSS)的概念最早可以追溯到 2014 年,由 Nicolas Gallagher 在他的博客文章 “About HTML semantics and front-end architecture” 中提出。他在文章中提到了一种新的 CSS 方法论,即使用“单一功能类”(Single-purpose Classes)来替代传统的基于组件或块的样式管理方式。这种方法的核心思想是,将每一个 CSS 类设计为仅包含一种样式规则或一组简单的样式,以便更好地复用和组合样式,从而减少冗余代码。这一思想成为后来原子化 CSS 的基础。同年,第一个原子化框架 ACSS(Atomic CSS)发布了,由 Yahoo 团队开发。
ACSS 的推出激发了 Utility-First CSS 框架的兴起,最终在 Tailwind CSS 等项目中得到广泛应用。
Tailwind 和 Windi CSS 虽然也支持自定义,但它们的定制性主要体现在配置文件的扩展上,如自定义颜色、间距、字体、断点等,且在设计上仍然偏向于固定的原子类名体系。这两者可以通过配置文件生成新的实用类名,这种方式显然使他们有了不可避免的局限性。
而 UnoCSS 则有着高度定制化的特性,主要体现在它的灵活性和插件化设计,使其可以自由定义和扩展类名、行为,甚至能模拟其他 CSS 框架。相比之下,Tailwind CSS 和 Windi CSS 在设计上更偏向于固定的、基于配置的实用类体系,而 UnoCSS 则提供了更多自由度。
这样的设计也使得 UnoCSS 有着天然的性能优势,UnoCSS 支持基于正则表达式的动态类名解析,允许开发者定义自定义的样式规则。例如,可以通过简单的正则规则为特定样式创建动态的类,而不需要预先定义所有的类名。这使得 UnoCSS 的 CSS 小而精,据官网介绍,它无需解析,无需AST,无需扫描。它比Windi CSS或Tailwind CSS JIT快5倍!
原子化的通病
从原子化的概念本身出发,我们不难发现,这种做法有一种通病,就是我除了要知道基本的 CSS 之外,还需要知道原子化类库的预定义值,也就是说,我们需要提前知道写哪些 class 是有效的,哪些是无法识别的。
在现代化编辑器中,我们可以使用编辑器扩展来识别这些类名。
比如在 VSCode 中的 UnoCSS 扩展
它可以在 HTML 中提示开发者这个类名下将解析出的 css
也可以进行自动补全。
是的这很方便,但是我们依旧要大概知道这些 预设 class 的写法,对其不熟悉的的用户,可能还要翻阅文档来书写。
从原子化的概念本身出发,我们不难发现,这种做法有一种通病,就是我除了要知道基本的 CSS 之外,还需要知道原子化类库的预定义值,也就是说,我们需要提前知道写哪些 class 是有效的,哪些是无法识别的。
在现代化编辑器中,我们可以使用编辑器扩展来识别这些类名。
比如在 VSCode 中的 UnoCSS 扩展
它可以在 HTML 中提示开发者这个类名下将解析出的 css
也可以进行自动补全。
是的这很方便,但是我们依旧要大概知道这些 预设 class 的写法,对其不熟悉的的用户,可能还要翻阅文档来书写。
全自动的 UnoCSS
我就在想,为什么没有一个原子化库,可以支持智能识别呢,比如我想实现一个行高
按照上图中的预设,我需要依次打出 l、i、n、e、-,才匹配到了第一个和行高有关的属性,如果情况再搞笑一点,我根本不知道 line 怎么写怎么办?
我相信很多同学可能会有共情,因为我们在写传统 CSS 时,一般是打出我们自己熟悉的几个字母,依靠编辑器的自动补全(emmet)来做的,像这样:
嗯,看起来很舒服,只需要打出少量的字母,就可以识别到了。
先看一下传统的字面量 Uno 预设
我就在想,为什么没有一个原子化库,可以支持智能识别呢,比如我想实现一个行高
按照上图中的预设,我需要依次打出 l、i、n、e、-,才匹配到了第一个和行高有关的属性,如果情况再搞笑一点,我根本不知道 line 怎么写怎么办?
我相信很多同学可能会有共情,因为我们在写传统 CSS 时,一般是打出我们自己熟悉的几个字母,依靠编辑器的自动补全(emmet)来做的,像这样:
嗯,看起来很舒服,只需要打出少量的字母,就可以识别到了。
先看一下传统的字面量 Uno 预设
传统预设
我们可以自定义一些个人比较熟悉的简写。
或者写一些正则,来支持更复杂的数值插入等
好吧,看到这我都上不来气儿了,这我要写到什么时候去?
确实,一个一个的去自定义规则,花费了非常多的精力和时间,那我们看一下社区有没有提供相对通用的规则呢, UnoCSS社区预设
好吧,可能有,但是太多了,且大多是一些个性化的实现。
我们可以自定义一些个人比较熟悉的简写。
或者写一些正则,来支持更复杂的数值插入等
好吧,看到这我都上不来气儿了,这我要写到什么时候去?
确实,一个一个的去自定义规则,花费了非常多的精力和时间,那我们看一下社区有没有提供相对通用的规则呢, UnoCSS社区预设
好吧,可能有,但是太多了,且大多是一些个性化的实现。
autoUno 预设方案
于是我准备手动做一个类似 emmet 补全的预设,希望它可以做到识别任意写法,比如:
- line-height1px
- lh24px
- lh1
- lh1rem
- lineh1
- lihei1
- ...等等你习惯的写法
于是我准备手动做一个类似 emmet 补全的预设,希望它可以做到识别任意写法,比如:
- line-height1px
- lh24px
- lh1
- lh1rem
- lineh1
- lihei1
- ...等等你习惯的写法
正则拦截几乎所有写法
字母+数字
/^[a-zA-Z]+(\d+)$/
字母+数字+单位
/^[a-zA-Z]+(\d+)+(vh|vw|px|rem|em|%)$/
字母+颜色
/^[a-zA-Z-]+(#[a-zA-Z0-9]+)$/
字母+冒号+字母
/^[a-zA-Z]+:+[a-zA-Z]$/
也就是说,我们的 rules 会长这样:
rules: [
[
/^[a-zA-Z]+(\d+)$/,
([a, d]) => {
const [property, unit] = findBestMatch(a, customproperty)
if (!property) return
return { [property]: `${d || ''}${unit || ''}` }
}
],
[
/^[a-zA-Z]+(\d+)+(vh|vw|px|rem|em|%)$/,
([a, d, u]) => {
const [property] = findBestMatch(a, customproperty)
if (!property) return
return { [property]: `${d || ''}${u}` }
}
],
[
/^[a-zA-Z-]+(#[a-zA-Z0-9]+)$/,
([a, c]) => {
const [property] = findBestMatch(a, customproperty)
if (!property) return
return { [property]: c }
}
],
[
/^[a-zA-Z]+:+[a-zA-Z]$/,
([a]) => {
const [property] = findBestMatch(a, customproperty)
if (!property) return
const propertyName = property.split(':')[0]
const propertyValue = property.split(':')[1]
return { [propertyName]: propertyValue }
}
],
]
接下来,只要实现 findBestMatch 方法就好了。
正如刚刚提到的,我们需要模拟一个 emmet 的提示,规则大概是这样的
- 匹配顺序一致
- 至少命中 2 字符
- 可以自定义单位
字母+数字
/^[a-zA-Z]+(\d+)$/
字母+数字+单位
/^[a-zA-Z]+(\d+)+(vh|vw|px|rem|em|%)$/
字母+颜色
/^[a-zA-Z-]+(#[a-zA-Z0-9]+)$/
字母+冒号+字母
/^[a-zA-Z]+:+[a-zA-Z]$/
也就是说,我们的 rules 会长这样:
rules: [
[
/^[a-zA-Z]+(\d+)$/,
([a, d]) => {
const [property, unit] = findBestMatch(a, customproperty)
if (!property) return
return { [property]: `${d || ''}${unit || ''}` }
}
],
[
/^[a-zA-Z]+(\d+)+(vh|vw|px|rem|em|%)$/,
([a, d, u]) => {
const [property] = findBestMatch(a, customproperty)
if (!property) return
return { [property]: `${d || ''}${u}` }
}
],
[
/^[a-zA-Z-]+(#[a-zA-Z0-9]+)$/,
([a, c]) => {
const [property] = findBestMatch(a, customproperty)
if (!property) return
return { [property]: c }
}
],
[
/^[a-zA-Z]+:+[a-zA-Z]$/,
([a]) => {
const [property] = findBestMatch(a, customproperty)
if (!property) return
const propertyName = property.split(':')[0]
const propertyValue = property.split(':')[1]
return { [propertyName]: propertyValue }
}
],
]
接下来,只要实现 findBestMatch 方法就好了。
正如刚刚提到的,我们需要模拟一个 emmet 的提示,规则大概是这样的
- 匹配顺序一致
- 至少命中 2 字符
- 可以自定义单位
那么我们可以先列举一下可能用到的 CSS 属性(全部大概有350个左右)
const propertyCommon = [
"display: flex",
"display: block",
"display: inline",
"display: inline-block",
"display: grid",
"display: none",
// "...":"..." 还有更多
]
比如我希望 输入 d:f
就自动帮我匹配到 display: flex
。
那么逻辑应该是这样的:
获取到第一个字符 d
,让它分别去这些字符串中比较,比如 display: flex
将被分解成 d
、i
、s
...
首先匹配到第一个字符 d 发现一致,那么 display: flex
的可能性就 + 1,整个遍历下来,顺序一致,且命中字符数最多的,就是我们要找的,很显然 输入 d:f
命中最多的应该是 display: flex
,分别是 d
、:
、f
,此时函数返回就正确了。
findBestMatch 方法实现
除了刚刚列举的常用固定写法,还有一些带单位的属性,我选择用 $
符号分割,以便于在函数中提取
const propertyWithUnit = [
"animation-delay$ms",
"animation-duration$ms",
"border-bottom-width$px",
"border-left-width$px",
"border-right-width$px",
"border-top-width$px",
"border-width$px",
"bottom$px",
"box-shadow$px",
"clip$px",
// ... 更多
]
我们在预设属性中,使用 $ 符号隔断了一个默认单位,一会将在函数中提取它。
export function findBestMatch(input: string, customproperty: string[] = []) {
// 将输入字符串转换为字符数组
const inputChars = input.split('')
let bestMatch: any = null
let maxMatches = 0
// 遍历所有目标字符串
for (let keywordOrigin of customproperty.concat(propertyWithUnit.concat(propertyCommon))) {
const keyword = keywordOrigin.split('$')[0]
// 用来记录目标字符串的字符序列是否匹配
let matchCount = 0
let inputIndex = 0
// 遍历目标字符串
for (let i = 0; i < keyword.length; i++) {
// 如果第一个字符就不匹配,直接跳过
if (i === 0 && keyword[i] !== inputChars[0]) {
break
}
if (inputIndex < inputChars.length && keyword[i] === inputChars[inputIndex]
&& (input.includes(":") && keyword.includes(":") || (!input.includes(":")))) {
matchCount++
inputIndex++
}
}
// 如果找到的匹配字符数大于等于 2,且比当前最大匹配数多
if (matchCount >= 2 && matchCount > maxMatches) {
maxMatches = matchCount
bestMatch = keywordOrigin
}
}
let unit: any = ''
// 用正则匹配单位,最后一个数字的后面的字符
const unitMatch = input.match(/(\d+)([a-zA-Z%]+)/)
unit = unitMatch && unitMatch[2]
if (!unit && bestMatch && bestMatch.split('$')[1]) {
unit = bestMatch.split('$')[1]
}
return [bestMatch && bestMatch.split('$')[0], unit]
}
此函数使用了一种加分机制,去寻找最匹配的字符,当用户传入一个 class 时,将从第一个字符开始匹配,第一个不匹配直接跳过(遵循emmet规则,也有利于性能),接着,在是否加分的的 if 中,需要判断是否包含 :
,这是为了区分是否是带冒号的常用属性(区别于带单位的属性)。
在循环中,将找出最匹配的预设属性值,最后,判断用户输入的字符串是否带单位,如果带单位就使用用户单位,如果没有,就使用默认单位(预设属性中 $ 符号后面的字符)。
然后返回一个数组,它将是 [property,unit]
其实在上面的正则中,我将带单位和不带单位的匹配分开了,在写这篇文章时,findBestMatch 函数我还没想好怎么改😅,于是就先将就着讲给各位看,核心思想是一样的。
如此一来,我们无需自定义过多的固定 rules,只需要补充一些CSS属性就可以了,接下来你的UnoCSS 规则将长这样:
export default defineConfig({
presets: [
autoUno([
'border-radius$px',
"display:flex",
"...."
])],
})
只需列举你将用到的标准css属性即可,含有数值的,以$符号分隔默认单位,其实你也无须过多设置,因为我的 autoUno 预设中已经涵盖了大部分常用属性,只有你发现 autoUno 无法识别你的简写时,才需要手动传入。
接下来,隆重介绍
autoUno
autoUno 是 UnoCSS 的一个预设方案,它支持你以最直觉的方式设置 class 。
你认为对,它就对,再也不受任何预设的影响,再也不用记下任何别人定义的习惯。
此项目已在 github 开源:github.com/Auto-Plugin…
此项目在 NPM 可供下载:http://www.npmjs.com/package/aut…
官方网站(可在线尝试):auto-plugin.github.io/index/autou…
安装
pnpm i autouno
pnpm i autouno
使用
import { defineConfig } from 'unocss'
import autoUno from 'autouno'
export default defineConfig({
presets: [
autoUno([
"box-shadow:none",
])],
})
作者:德莱厄斯
来源:juejin.cn/post/7435653910252191754
import { defineConfig } from 'unocss'
import autoUno from 'autouno'
export default defineConfig({
presets: [
autoUno([
"box-shadow:none",
])],
})
来源:juejin.cn/post/7435653910252191754
小程序webview我爱死你了 小程序webview和H5通讯
webview 我 *
众所周知,将已上线的H5页面转换为小程序,最快的方法是通过WebView进行套壳。然而,在这个过程中,我们需要将H5页面的登录和支付功能迁移到小程序版本。这意味着H5页面需通过特定的方式与小程序进行通信,以实现如支付等关键功能。
因此需要了解H5与WebView之间的通讯方式,以确保数据的顺利传递和功能的无缝对接。
找了很久发现H5与WebView的通讯方式主要有两种:
- 小程序通过改变H5地址栏携带参数
- WebSocket实时通讯
而webview自带的bindmessage、bindload、binderror,触发条件只有小程序后退、组件销毁、分享、复制链接,给我卡的死死的,只好选择了第一种方式,WebSocket虽然可以实现实时通讯,但会增加额外的开销,不符合我的需求。
这里的URL域名必须添加到 小程序后台中-管理-业务域名内,否则会报无法打开 xxx 页面,个人小程序是没有这个选项的,需要申请成企业小程序
小程序向H5通讯
小程序端
<view class="content">
<web-view :src="url"></web-view>
</view>
H5端
// 判断当前页面的 URL 是否包含 'userInfo',用于识别是否来自小程序端
if (window.location.href.includes('userInfo')) {
// 匹配 URL 中的 userInfo 参数
const userInfoRegex = /userInfo=([^]*)/;
// 解码
const decodedUrl = decodeURIComponent(window.location.href);
// 使用正则表达式从解码后的 URL 中提取参数值
const userInfoMatch = decodedUrl.match(userInfoRegex);
let auth_token = userInfoMatch[1];
localStorage.setItem('loc_token', auth_token);
}
H5向小程序通讯
小程序端
onMounted(() => {
const paymentData = getCurrentPages().pop().options.paymentData // 获取当前页面参数
submitInfo(paymentData);
});
H5端
wx.miniProgram.navigateTo({
url: `/pagesMember/pay/pay?paymentData=${payInfo.value}`,
})
通讯限制也就算了,导航栏不能自定义,还不让去掉,这让自带导航栏显得极其突兀!我 * !!!
navigationStyle: custom对 web-view 组件无效
一句话干碎我的摸鱼梦,领导要把那块做成透明的,没办法只好把常用页面重构,
but小程序不支持elementPlus啊,太爽了家人们。
来源:juejin.cn/post/7440122922025058342
分不清Boolean和boolean,我被同事diss了!
背景
这几天写代码,遇到一个不确定的知识点:我在vue的props中如何给一个属性定义小写的bolean,代码就会报错
但是大写的Bolean就没问题
由于我在其他地方我看大小写都可以,有点疑惑,于是想去请教一下同事。然而,没想到同事上来就diss我:
这么基础的知识你都不清楚?这两个根本就不是一个东西!
我有点不开心,想反驳一下:
这两个不都是描述类型的东西吗?我给你看其他地方的代码,这两个都是可以混用的!
同事有点不耐烦,说道:大姐,boolean是TS中的类型声明,Boolean是JavaScript 的构造函数,根本不是一个东西吧!
行吧,我也刚入门不久,确实不了解这个东西,只能强忍委屈,对同事说了声谢谢,我知道了!
然后,我好好的学习了一下Boolean和boolean的知识,终于搞明白他们的区别了。
Boolean和boolean
本质区别
同事说的很对,他们两个的本质区别就是一个是JavaScript语法,一个是TypeScript语法,这意味着非TypeScript项目是不存在boolean这个东西的。
Boolean
是 JavaScript 的构造函数
Boolean
是 JavaScript 中的内置构造函数,用于布尔值的类型转换或创建布尔对象。
typeof Boolean; // "function"
boolean
是 TypeScript 的基本类型
- 如果使用了 TypeScript,
boolean
是 TypeScript 中的基本类型,用于静态类型检查。 - 在 JavaScript 的运行时上下文中,
boolean
并不存在,仅作为 TypeScript 的静态检查标识。
typeof boolean; // ReferenceError: boolean is not defined
TS中作为类型的Boolean和boolean
在TypeScript中,Boolean和boolean都可以用于表示布尔类型
export interface ActionProps {
checkStatus: Boolean
}
export interface RefundProps {
visible: boolean
}
但是,他们存在一些区别
boolean
boolean
是 TypeScript 的基本类型,用于定义布尔值。- 它只能表示
true
或false
。 - 编译后
boolean
不会存在于 JavaScript 中,因为它仅用于静态类型检查。
//typescript
let isActive: boolean; // 只能是 true 或 false
isActive = true; // 正确
isActive = false; // 正确
isActive = new Boolean(true); // 错误,不能赋值为 Boolean 对象
Boolean
Boolean
是 JavaScript 的内置构造函数,用于将值显式转换为布尔值或创建布尔对象(Boolean
对象)。- 它是一个引用类型,返回的是一个布尔对象,而不是基本的布尔值。
- 在 TypeScript 中,
Boolean
表示构造函数类型,而不是基本的布尔值类型。
//typescript
let isActive: Boolean; // 类型是 Boolean 对象
isActive = new Boolean(false); // 正确,赋值为 Boolean 对象
isActive = true; // 正确,基本布尔值也可以兼容
关键区别
特性 | boolean | Boolean |
---|---|---|
定义 | TypeScript 的基本类型 | JavaScript 的构造函数 |
值类型 | 只能是 true 或 false | 是一个布尔对象 |
推荐使用场景 | 用于定义基本布尔值类型 | 很少用,除非需要显式构造布尔对象 |
运行时行为 | 不存在,只在编译时有效 | 在运行时是 JavaScript 的构造函数 |
性能 | 高效,直接操作布尔值 | 对象包装,性能较差 |
为什么尽量避免使用 Boolean
?
类型行为不一致:Boolean
是对象类型,而不是基本值类型。这会在逻辑运算中导致混淆:
const flag: Boolean = new Boolean(false);
if (flag) {
console.log("This will run!"); // 因为对象始终为 truthy
}
性能开销更大:Boolean
会创建对象,而 boolean
是直接操作基本类型。
vue中的Boolean与boolean
Vue 的运行时框架无法识别 boolean
类型,它依赖的是 JavaScript 的内置构造函数(如 Boolean
、String
、Number
等)来检查和处理 props
类型。
因此,props的Type只能是Boolean
、String
或Number
。
但是如果vue中开启了ts语法,就可以使用boolean
表示类型了
<script lang="ts" setup>
interface IProps {
photoImages?: string[],
isEdit?: boolean
}
const props = withDefaults(defineProps<IProps>(), {
photoImages: () => [],
isEdit: true
})
</script>
来源:juejin.cn/post/7439576043223203892
TypeScript很麻烦💔,不想使用!
本文已经授权【稀土掘金技术社区】官方公众号独家原创发布。
前言
最近,我们部门在开发一个组件库时,我注意到一些团队成员对使用TypeScript表示出了抵触情绪,他们常常抱怨说:“TypeScript太麻烦了,我们不想用!”起初,我对此感到困惑:TypeScript真的有那么麻烦吗?然而,当我抽时间审查队伍的代码时,我终于发现了问题所在。在这篇文章中,我想和大家分享我的一些发现和解决方案。
一、类型复用不足
在代码审查过程中,我发现了大量的重复类型定义,这显著降低了代码的复用性。
进一步交流后,我了解到许多团队成员并不清楚如何在TypeScript中复用类型。TypeScript允许我们使用type
和interface
来定义类型。
当我询问他们type
与interface
之间的区别时,大多数人都表示不清楚,这也就难怪他们不知道如何有效地复用类型了。
type
定义的类型可以通过交叉类型(&
)来进行复用,而interface
定义的类型则可以通过继承(extends
)来实现复用。值得注意的是,type
和interface
定义的类型也可以互相复用。下面是一些简单的示例:
复用type
定义的类型:
type Point = {
x: number;
y: number;
};
type Coordinate = Point & {
z: number;
};
复用interface
定义的类型:
interface Point {
x: number;
y: number;
};
interface Coordinate extends Point {
z: number;
}
interface
复用type
定义的类型:
type Point = {
x: number;
y: number;
};
interface Coordinate extends Point {
z: number;
}
type
复用interface
定义的类型:
interface Point {
x: number;
y: number;
};
type Coordinate = Point & {
z: number;
};
二、复用时只会新增属性的定义
我还注意到,在类型复用时,团队成员往往只是简单地为已有类型新增属性,而忽略了更高效的复用方式。
例如,有一个已有的类型Props
需要复用,但不需要其中的属性c
。在这种情况下,团队成员会重新定义Props1
,仅包含Props
中的属性a
和b
,同时添加新属性e
。
interface Props {
a: string;
b: string;
c: string;
}
interface Props1 {
a: string;
b: string;
e: string;
}
实际上,我们可以利用TypeScript提供的工具类型Omit
来更高效地实现这种复用。
interface Props {
a: string;
b: string;
c: string;
}
interface Props1 extends Omit<Props, 'c'> {
e: string;
}
类似地,工具类型Pick
也可以用于实现此类复用。
interface Props {
a: string;
b: string;
c: string;
}
interface Props1 extends Pick<Props, 'a' | 'b'> {
e: string;
}
Omit
和Pick
分别用于排除和选择类型中的属性,具体使用哪一个取决于具体需求。
三、未统一使用组件库的基础类型
在开发组件库时,我们经常面临相似功能组件属性命名不一致的问题,例如,用于表示组件是否显示的属性,可能会被命名为show
、open
或visible
。这不仅影响了组件库的易用性,也降低了其可维护性。
为了解决这一问题,定义一套统一的基础类型至关重要。这套基础类型为组件库的开发提供了坚实的基础,确保了所有组件在命名上的一致性。
以表单控件为例,我们可以定义如下基础类型:
import { CSSProperties } from 'react';
type Size = 'small' | 'middle' | 'large';
type BaseProps<T> = {
/**
* 自定义样式类名
*/
className?: string;
/**
* 自定义样式对象
*/
style?: CSSProperties;
/**
* 控制组件是否显示
*/
visible?: boolean;
/**
* 定义组件的大小,可选值为 small(小)、middle(中)或 large(大)
*/
size?: Size;
/**
* 是否禁用组件
*/
disabled?: boolean;
/**
* 组件是否为只读状态
*/
readOnly?: boolean;
/**
* 组件的默认值
*/
defaultValue?: T;
/**
* 组件的当前值
*/
value?: T;
/**
* 当组件值变化时的回调函数
*/
onChange: (value: T) => void;
}
基于这些基础类型,定义具体组件的属性类型变得简单而直接:
interface WInputProps extends BaseProps<string> {
/**
* 输入内容的最大长度
*/
maxLength?: number;
/**
* 是否显示输入内容的计数
*/
showCount?: boolean;
}
通过使用type
关键字定义基础类型,我们可以避免类型被意外修改,进而增强代码的稳定性和可维护性。
四、处理含有不同类型元素的数组
在审查自定义Hook时,我发现团队成员倾向于返回对象,即使Hook只返回两个值。
虽然这样做并非错误,但它违背了自定义Hook的一个常见规范:当Hook返回两个值时,应使用数组返回。
团队成员解释说,他们不知道如何定义含有不同类型元素的数组,通常会选择使用any[]
,但这会带来类型安全问题,因此他们选择返回对象。
实际上,元组是处理这种情况的理想选择。通过元组,我们可以在一个数组中包含不同类型的元素,同时保持每个元素类型的明确性。
function useMyHook(): [string, number] {
return ['示例文本', 42];
}
function MyComponent() {
const [text, number] = useMyHook();
console.log(text); // 输出字符串
console.log(number); // 输出数字
return null;
}
在这个例子中,useMyHook
函数返回一个明确类型的元组,包含一个string
和一个number
。在MyComponent
组件中使用这个Hook时,我们可以通过解构赋值来获取这两个不同类型的值,同时保持类型安全。
五、处理参数数量和类型不固定的函数
审查团队成员封装的函数时,我发现当函数的参数数量不固定、类型不同或返回值类型不同时,他们倾向于使用any
定义参数和返回值。
他们解释说,他们只知道如何定义参数数量固定、类型相同的函数,对于复杂情况则不知所措,而且不愿意将函数拆分为多个函数。
这正是函数重载发挥作用的场景。通过函数重载,我们可以在同一函数名下定义多个函数实现,根据不同的参数类型、数量或返回类型进行区分。
function greet(name: string): string;
function greet(age: number): string;
function greet(value: any): string {
if (typeof value === "string") {
return `Hello, ${value}`;
} else if (typeof value === "number") {
return `You are ${value} years old`;
}
}
在这个例子中,我们为greet
函数提供了两种调用方式,使得函数使用更加灵活,同时保持类型安全。
对于箭头函数,虽然它们不直接支持函数重载,但我们可以通过定义函数签名的方式来实现类似的效果。
type GreetFunction = {
(name: string): string;
(age: number): string;
};
const greet: GreetFunction = (value: any): string => {
if (typeof value === "string") {
return `Hello, ${value}`;
} else if (typeof value === "number") {
return `You are ${value} years old.`;
}
return '';
};
这种方法利用了类型系统来提供编译时的类型检查,模拟了函数重载的效果。
六、组件属性定义:使用type
还是interface
?
在审查代码时,我发现团队成员在定义组件属性时既使用type
也使用interface
。
询问原因时,他们表示两者都可以用于定义组件属性,没有明显区别。
由于同名接口会自动合并,而同名类型别名会冲突,我推荐使用interface
定义组件属性。这样,使用者可以通过declare module
语句自由扩展组件属性,增强了代码的灵活性和可扩展性。
interface UserInfo {
name: string;
}
interface UserInfo {
age: number;
}
const userInfo: UserInfo = { name: "张三", age: 23 };
结语
TypeScript的使用并不困难,关键在于理解和应用其提供的强大功能。如果你在使用TypeScript过程中遇到任何问题,不清楚应该使用哪种语法或技巧来解决,欢迎在评论区留言。我们一起探讨,共同解决TypeScript中遇到的挑战。
来源:juejin.cn/post/7344282440725577765
只写后台管理的前端要怎么提升自己
本人写了五年的后台管理。每次面试前就会头疼,因为写的页面除了表单就是表格。抱怨过苦恼过也后悔过,但是站在现在的时间点回想以前,发现有很多事情可以做的更好,于是有了这篇文章。
写优雅的代码
一道面试题
大概两年以前,面试美团的时候,面试官让我写一道代码题,时间单位转换。具体的题目我忘记了。
原题目我没做过,但是我写的业务代码代码里有类似的单位转换,后端返回一个数字,单位是kb
,而我要展示成 KB
,MB
等形式。大概写一个工具函数(具体怎么写的忘记了,不过功能比这个复杂点):
function formatSizeUnits(kb) {
let units = ['KB', 'MB', 'GB', 'TB', 'PB'];
let unitIndex = 0;
while (kb >= 1024 && unitIndex < units.length - 1) {
kb /= 1024;
unitIndex++;
}
return `${kb.toFixed(2)} ${units[unitIndex]}`;
}
而在此之前,是一个后端同学写的代码(说起来,后台管理系统前端人力不够后端帮忙写好像是常规操作,后端总觉得写前端简单,而我只能说你们那代码写的,一言难尽……),如下:
function formatSizeUnits(kb) {
var result = '';
if (kb < 1024) {
result = kb + ' KB';
} else if (kb < 1024 * 1024) {
result = (kb / 1024).toFixed(2) + ' MB';
} else if (kb < 1024 * 1024 * 1024) {
result = (kb / 1024 / 1024).toFixed(2) + ' GB';
} else {
result = (kb / 1024 / 1024 / 1024).toFixed(2) + ' TB';
}
return result;
}
虽然功能都一样,但是我写在面试的时候写了上面的代码得到了面试官的赞扬,也因此非常顺利的通过了面试。而如果我写成了下面的样子,估计不会有这种效果。
如何提升代码质量
想提升代码质量,最理想的是靠 code review ,但实际上这玩意在大多数公司根本就不存在。其次就只能多看看大佬们写的代码,但是一来后台管理本来就没太大难度很少有大佬,二来很多时候一个后台管理系统就一两个人负责。
还好现在有了各种 AI 工具,可以非常方便的优化自己的代码了。
还是上面的问题,看看 GPT 给的答案
// 提问:写一个工具函数,对文件大小的单位进行转换,加注释
// 回答:下面是一个JavaScript工具函数,它接受文件大小、初始单位、目标单位和小数位数,然后返回目标单位下的文件大小。
/**
* 将文件大小从一个单位转换为另一个单位。
*
* @param {number} size 文件大小。
* @param {string} fromUnit 初始单位('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')。
* @param {string} toUnit 目标单位('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')。
* @param {number} [decimalPoint=2] 结果保留的小数位数,默认为2。
* @return {string} 转换后的文件大小,带单位。
*/
function convertFileSize(size, fromUnit, toUnit, decimalPoint = 2) {
// 定义单位与字节之间的转换关系
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
// 获取初始单位和目标单位的索引
const fromIndex = units.indexOf(fromUnit);
const toIndex = units.indexOf(toUnit);
// 如果单位不在列表中,抛出错误
if (fromIndex === -1 || toIndex === -1) {
throw new Error('Invalid units');
}
// 计算初始单位与目标单位之间的转换系数
const exponent = toIndex - fromIndex;
// 计算结果大小
const resultSize = size / Math.pow(1024, exponent);
// 返回格式化后的结果
return parseFloat(resultSize.toFixed(decimalPoint)) + ' ' + toUnit;
}
// 示例使用
console.log(convertFileSize(1, 'GB', 'MB')); // 输出: 1024.00 MB
console.log(convertFileSize(1, 'MB', 'KB')); // 输出: 1024.00 KB
console.log(convertFileSize(1, 'KB', 'B')); // 输出: 1024.00 B
console.log(convertFileSize(1, 'MB', 'GB', 5)); // 输出: 0.00098 GB
还能说什么,我只能说 NB!(经评论区提示,此段代码可能有问题,建议大家在使用AI工具时,重点是借鉴思路,直接使用代码需要谨慎~)
我现在每次写大段逻辑之后都习惯拿给 AI 看看,有什么更好的实现方式,或者用什么设计模式。AI 是非常低成本且高效提升代码质量的工具。
学会封装
一个功能用到了好多次,为什么不封装成组件?一个组件用到了好几个项目,为什么不单独写个npm包?差不多的项目创建了好几个,为什么不封装成脚手架?
你说,没时间,没必要,复制粘贴反而更快。
那你就完全没理解,这么做不一定是为了让工作更快完成,而是可以让你在年年终述职时更有话说(你就算写了一百个表单表格没有写一个脚手架更值得炫耀),如果不会写可以问问 AI。
而当你真正开始封装组件,开始写工具库了,你会发现你需要思考的确实比之前多了。
关注业务
对于前端业务重要吗?
相比于后端来说,前端一般不会太关注业务。就算出了问题大部分也是后端的问题。
但是就我找工作的经验,业务非常重要!
如果你做的工作很有技术含量,比如你在做低代码,你可以面试时讲一个小时的技术难点。但是你只是一个破写后台管理,你什么都没有的说。这个时候,了解业务就成为了你的亮点。
一场面试
还是拿真实的面试场景举例,当时前同事推我字节,也是我面试过N次的梦中情厂了,刚好那个组做的业务和我之前呆的组做的一模一样。
- 同事:“做的东西和咱们之前都是一样的,你随便走个过场就能过,我在前端组长面前都夸过你了!”
- 我:“好嘞!”
等到面试的时候:
- 前端ld:“你知道xxx吗?(业务名词)”
- 我:“我……”
- 前端ld:“那xxxx呢?(业务名词)”
- 我:“不……”
- 前端ld:“那xxxxx呢??(业务名词)”
- 我:“造……”
然后我就挂了………………
如何了解业务
- 每次接需求的时候,都要了解需求背景,并主动去理解
我们写一个表格简简单单,把数据展示出来就好,但是表格中的数据是什么意思呢?比如我之前写一个 kafka 管理平台,里面有表格表单,涉及什么
cluster
controller
topic
broker
partition
…… 我真的完全不了解,很后悔我几年时间也没有耐下心来去了解。 - 每次做完一个需求,都需要了解结果
有些时候,后台管理的团队可能根本没有PM,那你也要和业务方了解,这个功能做了之后,多少人使用,效率提高了吗?数据是怎样的?
- 理解需求,并主动去优化
产品要展示一千条数据,你要考虑要不要分页,不分页会不会卡,要不要上虚拟表格?
产品要做一个可拖拽表单,你要考虑是否需要拖动,是否需要配置。
其实很多时候,产品的思维可能会被局限在竞品的实现方式,而前端可以给TA更多选项。在和产品沟通的时候,你不仅是沟通页面的实现,也更能理解业务。
关注源码
说到源码, Vue,React 这些基本是每次必问,也有太多大佬们的总结我就不多说了。
除此之外,我想大家每次面试应该都会被问,你写了什么亮点的项目,困难的项目,你会说什么?哦,都是表单表格,都用 Element UI 实现的,没什么难度。那估计你面试也就到这里了。
那说什么,后台管理就这些啊?!
如果你用了 Element UI 你可以说写了什么自定义 Element UI 表单组件,Element UI 表单源码是什么原理,怎么通信的?用了 Axios 可以说 Axios 的原理,它的拦截器怎么实现的?用了编辑器 diff 那你知道 diff 算法是什么吗?用了 ECharts 那你知道他底层是用什么绘制的吗?用了 husky ?husky的原理是什么?用了Vite,它为什么快?是怎么打包的?写过插件吗?
可能有人觉得我离谱,事实上上面每一个问题都是我在面试中真实被问到的。当然,如果全部去了解,肯定不现实,每个人的精力有限,不可能每个用到的每个工具每个库都去了解原理,不过只要有一两个你去了解过,就可以在面试中引导面试官来问,要学会表现出你擅长的点。
至于怎么读源码?以前我不知道,现在嘛…… 为什么不问问神奇的 ChatGPT 呢?
讲一下 Axios 源码中,拦截器是怎么实现的?
Axios 的拦截器实现基于 Axios 的核心原理,即 Axios 实例是一个包含请求和响应拦截器堆栈的对象。当发出请求或接收响应时,Axios 会遍历这些拦截器,并按照添加的顺序执行请求拦截器,以及按照相反的顺序执行响应拦截器。
在 Axios 的源码中,拦截器是通过一个 AxiosInterceptorManager 实例来管理的,它维护了一个拦截器数组。每个拦截器都是一个包含
fulfilled
和rejected
函数的对象。这两个函数分别对应于拦截器成功处理和拦截器处理出错的情况。
以下是 Axios 拦截器管理器的一个简化版本,展示了其核心实现思路:
class InterceptorManager {
constructor() {
this.handlers = []; // 存储拦截器的数组
}
use(fulfilled, rejected) {
this.handlers.push({
fulfilled: fulfilled,
rejected: rejected
});
return this.handlers.length - 1; // 返回拦截器的ID
}
eject(id) {
if (this.handlers[id]) {
this.handlers[id] = null; // 移除拦截器
}
}
forEach(fn) {
this.handlers.forEach((h) => {
if (h !== null) {
fn(h);
}
});
}
}
在发送请求或接收响应时,Axios 会创建一个 promise 链,并通过
forEach
方法将拦截器中的fulfilled
和rejected
函数添加到这个链中。这样,每个拦截器都可以对请求或响应进行处理,然后将结果传递到链的下一个拦截器,或者在出错时结束链的执行。
axios.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
chain.unshift(interceptor.fulfilled, interceptor.rejected);
});
axios.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
chain.push(interceptor.fulfilled, interceptor.rejected);
});
在 Axios 的完整实现中,这个拦截器机制被集成到了 Axios 的请求发送和响应处理流程中。通过这种方式,Axios 可以在发送请求之前和接收响应之后,但在用户定义的
.then
或.catch
执行之前,插入自定义的逻辑。
请注意,这里提供的代码只是为了说明 Axios 拦截器的实现原理,并不是 Axios 源码的完整复制。如果你对 Axios 的拦截器实现细节感兴趣,建议查看 Axios 的官方 GitHub 仓库中的源码。
前端基建
当我们工作时间久了面试难免会遇到这些问题,前端工程化,前端监控,工作流,部署,性能等等。其实我们在工作中绝大部分时间都在写代码,对于这些不是所有人都有机会接触到,不过这些和所做的业务无关,是我们提升自己很好的一个思路。
技术选型
技术栈选 Vue 还是 React?Vue 选 Vue2 还是 Vue3?组件库选 ElementUI 还是 Ant Design?微前端有没有使用过?打包工具用 Vite 还是 Webpack?有那么多表单怎么实现的,有没有什么表单配置化方案,比如Formily?
对于我这种菜鸡,我这种只写简单的表单表格的人,这些都……无所谓……
不过为了应对面试我们还是需要了解下未选择技术栈的缺点,和已选择技术栈的优点(有点本末倒置…但是常规操作啦)
Vue 你可以说简单高效轻量级,面试必会问你为什么,你就开始说 Vue 的响应式系统,依赖收集等。
React 你可以说 JSX、Hooks 很灵活,那你必然要考虑 JSX 怎么编译, Hooks 实现方式等。
总体而言,对于技术选型,依赖于我们对所有可选项的理解,做选择可能很容易,给出合理的理由还是需要花费一些精力的。
开发规范
这个方面,在面试的时候我被问到的不多,我们可以在创建项目的时候,配置下 ESlint
,stylelint
, prettier
, commitlint
等。
前端监控
干了这么多年前端,前端监控我是……一点没做过。
前端监控,简单来说就是我们在前端程序中记录一些信息并上报,一般是错误信息,来方便我们及时发现问题并解决问题。除此之外也会有性能监控,用户行为的监控(埋点)等。之前也听过有些团队分享前端监控,为了出现问题明确责任(方便甩锅)。
对于实现方案,无论使用第三方库还是自己实现,重要的都是理解实现原理。
对于错误监控,可以了解一下 Sentry,原理简单来说就是通过 window.onerror
和 window.addEventListener('unhandledrejection', ...)
去分别捕获同步和异步错误,然后通过错误信息和 sourceMap
来定位到源码。
对于性能监控,我们可以通过 window.performance
、PerformanceObserver
等 API 收集页面性能相关的指标,除此之外,还需要关注接口的响应时间。
最后,收集到信息之后,还要考虑数据上报的方案,比如使用 navigator.sendBeacon
还是 Fetch、AJAX?是批量上报,实时上报,还是延迟上报?上报的数据格式等等。
CI/CD
持续集成(Continuous Integration, CI)和 持续部署(Continuous Deployment, CD),主要包括版本控制,代码合并,构建,单测,部署等一系列前端工作流。
场景的工作流有 Jenkins、 Gitlab CI 等。我们可以配置在合并代码时自动打包部署,在提交代码时自动构建并发布包等。
这块我了解不多,但感觉这些工具层面的东西,不太会涉及到原理,基本上就是使用的问题。还是需要自己亲自动手试一下,才能知道细节。比如在 Gitlab CI 中, Pipeline
、 Stage
和 Job
分别是什么,怎么配置,如何在不同环境配置不同工作流等。
了解技术动态
这个可能还是比较依赖信息收集能力,虽然我个人觉得很烦,但好像很多领导级别的面试很愿意问。
比如近几年很火的低代码,很多面试官都会问,你用过就问你细节,你没用过也会问你有什么设计思路。
还有最近的两年爆火的 AI,又或者 Vue React的最新功能,WebAssembly,还有一些新的打包工具 Vite Bun 什么的,还有鸿蒙开发……
虽然不可能学完每一项新技术,但是可以多去了解下。
总结
写了这么多,可能有人会问,如果能回到过去,你会怎么做。
啊,我只能说,说是一回事,做又是另一回事,事实上我并不希望回到过去去卷一遍,菜点没关系,快乐就好,一切都是最好的安排。
来源:juejin.cn/post/7360528073631318027
搭建一个快速开发油猴脚本的前端工程
一、需求起因
最近遇到一个问题:公司自用的 bug 管理工具太老了,网页风格还是上世纪的文字页面。虽然看习惯了还好,但是某些功能确实很不方便。比如,联系人都是邮箱或者英文名,没有中文名称,在流转 bug 时还得复制粘贴英文名去企业微信里搜索对应的人名。第二是人员比较多,在一堆邮箱里很难找到对应的人......
总之,诸如此类的问题让我有了对该网页进行改造的想法。
但是这种网页都是公司创业时期拿的开源产品私有化部署,网页源码能不能找到都不好说。再者,公司也不会允许此类的“小聪明”,这并不是我的主职工作,所以修改源码是非常不现实的。
那目前的思路,就是在原网页基础上进行脚本注入,修改网页内容和样式。方案无非就是浏览器插件或者脚本注入两种。
脚本的话就是利用油猴插件
的能力,写一个油猴脚本,在网页加载完成后注入我们书写的脚本,达到修改原网页的效果。
插件也是类似的原理,但是写插件要麻烦得多。
出于效率考虑,我选择了脚本的方案。这里其实也是想巩固下 js
的 DOM API
,框架写多了,很多原生的 API
反而忘得一干二净。
二、关于油猴脚本
先看一份 demo
:
// ==UserScript==
// @name script
// @namespace http://tampermonkey.net/
// @version 0.0.1
// @description 这是一段油猴脚本
// @author xxx
// @match *://baidu.com/*
// @run-at document-end
// @license MIT
// ==/UserScript==
(function () {
"use strict";
const script = document.createElement("script");
document.body.appendChild(script);
})();
油猴脚本由注释及 js
代码组成。注释需要包裹在
// ==UserScript==
// ==/UserScript==
两个闭合标签内。同时只能书写类似 @name
规定好的注释头,用于标明脚本的一些元信息。其中比较重要的是 @match
和 @run-at
。
@match
规定了该脚本所运行的域名,例如,只有当我打开了百度的网页时我才运行脚本,这个 @match
可以书写多个。@run-at
则规定了脚本的运行时机,一般是网页加载开始,网页加载结束。@run-at
只声明一次。
@run-at
有以下可选值:
图片看得不清晰也没关系,这种都是用到再查。
更多注释配置请参考:油猴脚本。
而代码部分是一个立即执行函数,所有的内容都需要写在这个立即执行函数内,否则无法生效。
三、问题显现
刚开始,我并没有工程化开发的想法,我想的是就是一个脚本,直接一梭子写到底即可,反正就是那样,就是个普通的 js
文件,一切都是那么原始,朴实无华。
但是当代码来到两千多行后(我是真的很爱加东西),绷不住了,每次写代码都需要在文件上下之间反复横跳,有时候有些变量定义了都不记得,写代码还得滚动半天才能到最底下。
加东西也变得越来越臃肿,越来越丑陋。
忍无可忍,我决定对这个脚本进行工程化改造。但是工程化之前有几个问题需要解决,或者说需要调研清楚。
四、关键点分析
1.构建工具
首先肯定是打包成 iife
的产物,很多工具都支持。既然工程化了,一般大家的选择就是 webpack
或者 vite
。这里因为涉及到开发模式,需要及时产出打包产物,且能够搭建 dev
服务器,方便访问本地打包后的资源,因此需要选择具备 dev
服务器的开发构建工具。
我选择 vite
。当然,webpack
也是不错的选择。
如果你对实时预览要求不高,能够接受复制粘贴到油猴再刷新页面预览,也可以选择纯粹的打包器,例如 rollup
。
2.css 预编译器
传统的添加样式的方式,一般就是生成一个 style
标签,然后修改其 innerHTML
:
export const addStyle = (css: string) => {
const style = document.createElement('style');
style.type = 'text/css';
style.innerHTML = css;
document.getElementsByTagName('head')[0].appendChild(style);
}
addStyle(`
body {
width: 100%;
height: 100%;
}
`);
这样就能实现往网页里添加自定义的样式。但是我现在不满足于书写传统的 css
,我既然都工程化了,肯定要把 less
或者 scss
用上。
我的目的,就是可以新建一个例如 style.less
的文件开心地书写 less
,打包时候编译一下这个 less
文件,并将其样式注入到目标 HTML
中。
但在传统模块化工程里,构建工具对 less
的支持,是直接在 HTML
中生成一个 style
标签,引入编译后的 less
产物(css
)。
也就是说,我需要手动实现 less
到 css
到 js
这个过程。
转变的步骤就是用 less
本身的编译能力,将其产物转变为一个 js
模块。
具体实现放到后面再聊。
3.实现类似热更新的效果
我们启动一个传统的 vite
工程时,我们更新了某个 js
文件或者相关文件后,工程会监听我们的文件被修改了,从而触发热更新,服务也会自动刷新,从而达到实时预览的效果。
这是因为工程会在本地启动一个开发服务器,最终产物也会实时构建,那网页每次去获取这个服务器上的资源,就会获取到最新的代码。根据这点,我们同样需要启动一个本地服务器,而这在 vite
中直接一个 vite
命令即可。
在油猴脚本中,我们新建一个 script
标签,将其 src
指向我们本地服务器的构建产物的地址,即可实现实时的脚本更新,而不用复制产物代码再粘贴到油猴。
代码如下:
// ==UserScript==
// @name script
// @namespace http://tampermonkey.net/
// @version 0.0.1
// @description 这是描述
// @author xxx
// @match *://baidu.com/*
// @run-at document-end
// @license MIT
// ==/UserScript==
(function () {
"use strict";
const script = document.createElement("script");
script.src = "http://localhost:6419/dist/script.iife.js";
document.body.appendChild(script);
})();
这里的 localhost:6419
、/dist/script.iife.js
都取决于你 vite.config.js
中的配置。
具体后面再聊。
五、开始搭建工程
1.使用 yarn create vite
或者 pnpm create vite
初始化一个 vite
模板工程
其他的你自己看着选就可以。
2.修改 vite.config.js
/**
* @type {import('vite').UserConfig}
*/
module.exports = {
server: {
host: 'localhost',
port: 6419,
},
build: {
minify: false,
outDir: 'dist',
lib: {
entry: 'src/main.ts',
name: 'script',
fileName: 'script',
formats: ['iife'],
},
},
resolve: {
alias: {
'@': '/src',
'@utils': '/src/utils',
'@enum': '/src/enum',
'@const': '/src/const',
'@style': '/src/style',
}
}
}
这里使用 cjs
是因为我们会实现一些脚本,脚本里可能会用到这里的某些配置,所以使用 cjs
导出也有利于外部的使用。
3.创建一个 tampermonkey.config
文件,将油猴注释放在这里
// ==UserScript==
// @name script
// @namespace http://tampermonkey.net/
// @version 0.0.1
// @description 这是描述
// @author xxx
// @match *://baidu.com/*
// @run-at document-end
// @license MIT
// ==/UserScript==
当然,你要觉得这样多余、没必要,也可以看自己喜好,只要最终产物里有这个注释即可。但是拆出来有利于我们维护,后续也会新增脚本,有利于工程化的整体性和可维护性。
4.使用 nodemon
监听文件修改
因为我们自己对 less
有特殊处理,加上未来可能会对需要监听的文件进行精细化管理,所以这里引入 nodemon
,如果你自己对工程化有自己的理解,也可以按照自己的理解配置。
执行 pnpm i nodemon -D
。
根目录新增 nodemon.json
:
{
"ext": "ts,less",
"watch": ["src"],
"exec": "pnpm dev:build && vite"
}
这里的 pnpm dev:build
还另有玄机,后面再展开。
到这里,我们的工程雏形已经具备了。但是还有一个最关键的点没有解决——那就是 less
的转换。
六、less 的转换以及几个脚本
首先,less
代码需要编译为 css
,但是我们需要的是 css
的字符串,这样才能通过 innerHTML
之类的方法注入到网页中。
使用 less.render
方法可以对 less
代码进行编译,其是一个 Promise
,我们可以在 then
中接收编译后的产物。
我们可以直接在根目录新建一个 script
文件夹,在 script
文件夹下新建一个 gen-style-string.js
的脚本:
const less = require('less');
const fs = require('fs');
const path = require('path');
const styleContent = fs.readFileSync(path.resolve(__dirname, '../src/style.less'), 'utf-8');
less.render(styleContent).then(output => {
if(output.css) {
const code = `export default \`\n${output.css}\``;
const relativePath = '../style/index.ts';
const filePath = path.resolve(__dirname, relativePath)
if(fs.existsSync(filePath)) {
fs.rm(filePath, () => {
fs.writeFileSync(path.resolve(__dirname, relativePath), code)
})
} else {
fs.writeFileSync(path.resolve(__dirname, relativePath), code)
}
}
})
我们将编译后的 css
代码结合 js
代码导出为一个模块,供外部使用。也就是说,这部分编译必须在打包之前执行,这样才能得到正常的 js
模块,否则就会报错。
这段脚本执行完后会在 style/index.ts
中生成类似代码:
export default `
body {
width: 100%;
height: 100%;
}
`
这样 less
代码就能够被外部引入并使用了。
这里多说一句,因为 style/index.ts
的内容是根据 less
编译来的,而我们的 nodemon
会监听 src
目录,因此这个 less
编译后的 js
产物,不能放在 src
下,因为假设将它放在 src
目录下,它在写入的过程中也会触发 nodemon
,会导致 nodemon
进入死循环。
除此之外,我们之前还将油猴注释拎出来单独放在一个文件里:tampermonkey.config
。
在最终产物中,我们需要将其合并进去,思路同上:
const fs = require('fs');
const path = require('path');
const prettier = require('prettier');
const codeFilePath = '../dist/script.iife.js';
const configFilePath = '../tampermonkey.config';
const codeContent = fs.readFileSync(path.resolve(__dirname, codeFilePath), 'utf-8');
const tampermonkeyConfig = fs.readFileSync(path.resolve(__dirname, configFilePath), 'utf-8');
if (codeContent) {
const code = `${tampermonkeyConfig}\n${codeContent}`;
prettier.format(code, { parser: 'babel' }).then((formatted) => {
fs.writeFileSync(path.resolve(__dirname, codeFilePath), formatted)
})
}
最后,因为我们的 tampermonkey.config
以及 vite.config.js
可能会更改配置,所以每次我们在开发模式时生成的临时油猴脚本,也需要变,我们不可能每次都去修改,而是应该跟随上面两个配置文件进行生成,我们再新建一个脚本:
const fs = require('fs');
const path = require('path');
const prettier = require('prettier');
const viteConfig = require('../vite.config');
const codeFilePath = '../tampermonkey.js';
const tampermonkeyConfig = fs.readFileSync(path.resolve(__dirname, '../tampermonkey.config'), 'utf-8');
const hostPort = `${viteConfig.server.host}:${viteConfig.server.port}`;
const codeContent = `
(function () {
'use strict'
const script = document.createElement('script');
script.src = 'http://${hostPort}/dist/${viteConfig.build.lib.name}.iife.js';
document.body.appendChild(script);
})()
`;
const code = `${tampermonkeyConfig}\n${codeContent}`;
prettier.format(code, { parser: 'babel' }).then((formatted) => {
if(fs.existsSync(path.resolve(__dirname, codeFilePath))) {
fs.rm(path.resolve(__dirname, codeFilePath), () => {
fs.writeFileSync(path.resolve(__dirname, codeFilePath), formatted);
});
}
else {
fs.writeFileSync(path.resolve(__dirname, codeFilePath), formatted);
}
})
稍微用 prettier
美化一下。
七、完善 package.json 中的 script
我们其实只有开发模式,新建一个命令:
"dev": "node script/gen-tampermonkey.js && nodemon"
优先生成 tampermonkey.js
,这时候会启动服务器,记得先将 tampermonkey.js
中的内容拷贝到油猴,才能方便热更新,不然又需要复制粘贴。
对于 build
命令:
"dev:build": "node script/gen-style-string.js && tsc && vite build && node script/gen-script-header-comment.js"
需要先将 less
编译为可用的 js
字符串模块,然后才能执行 build
,build
完还需要拼接油猴注释,这样最终产物才具备可用的能力。
开发完成后,就将打包产物替换掉之前粘贴进油猴的内容。
八、额外的补充
vite
命令会直接启动本地开发服务器,而我们的 script
命令中,使用 &&
时,下一个命令会等待上一个命令执行完成后再执行,所以 vite
需要放在最后执行,这是串行逻辑。当然,借助一些库我们可以实现并行 script
命令。但是我们这里需要的是串行,只是不完美的是,每次文件变更,都需要重新执行 pnpm dev:build && vite
,这样会重复新启一个服务器,但是不重启的话,始终使用最初的那个服务,最新编译的资源无法被油猴感知,资源没有得到更新。
所以,聪明的你有办法解决吗?
来源:juejin.cn/post/7437887483259584522
作为一个前端你连requestAnimationFrame的用法、优势和应用场景都搞不清楚?
前言
如果你是一名前端开发,那么你多少有了解过requestAnimationFrame
吧?如果没有也接着往下看,会有详细用法说明。
其实很多人会局限于把requestAnimationFrame
应用于一些纯动画相关的需求上,但其实在前端很多业务场景下requestAnimationFrame
都能用于性能优化,下面将细说一下requestAnimationFrame
的具体用法
和几种应用场景
。
requestAnimationFrame作用与用法
requestAnimationFrame简述
MDN
官方说法是这样的
基本示例
<script lang="ts" setup>
function init() {
console.log('您好,我是requestAnimationFrame');
}
requestAnimationFrame(init)
</script>
效果如下
但是例子上面是最基本的调用方式,并且只简单执行了一次,而对于动画是要一直执行的。
下面直接上图看看官方的文档对这个的说明,上面说具体用法应该要递归调用,而不是单次调用。
递归调用示例
<script lang="ts" setup>
function init() {
console.log('您好,递归调用requestAnimationFrame');
requestAnimationFrame(init)
}
requestAnimationFrame(init)
</script>
执行动图效果如下
requestAnimationFrame
会一直递归调用执行,并且调用的频率通常是与当前显示器的刷新率相匹配
(这也是这个API
核心优势),例如屏幕75hz
就1
秒执行75
次。
而且如果使用的是定时器实现此功能是无法适应各种屏幕帧率的。
回调函数
requestAnimationFrame
执行后的回调函数有且只会返回一个参数,并且返回的参数是一个毫秒数
,这个参数所表示是的上一帧渲染的结束时间,直接看看下面代码示例与打印效果。
<script lang="ts" setup>
function init(val) {
console.log('您好,requestAnimationFrame回调:', val);
requestAnimationFrame(init);
}
requestAnimationFrame(init);
</script>
注意: 如果我们同时调用了很多个requestAnimationFrame
,那么他们会收到相同的时间戳,因为与屏幕的帧率相同所以并不会不一样。
终止执行
终止此API
的执行,官方提供的方法是window.cancelAnimationFrame()
,语法如下
ancelAnimationFrame(requestID)
直接看示例更便于理解,用法非常类似定时器的clearTimeout()
,直接把 requestAnimationFrame
返回值传给 cancelAnimationFrame()
即可终止执行。
<template>
<div>
<button @click="stop">停止</button>
</div>
</template>
<script lang="ts" setup>
let myReq;
function init(val) {
console.log('您好,requestAnimationFrame回调:', val);
myReq = requestAnimationFrame(init);
}
requestAnimationFrame(init);
function stop() {
cancelAnimationFrame(myReq);
}
</script>
requestAnimationFrame优势
1、动画更丝滑,不会出现卡顿
对比传统的setTimeout
和 setInterval
动画会更流畅丝滑。
主要 原因 是由于运行的浏览器会监听显示器返回的VSync
信号确保同步,收到信号后再开始新的渲染周期,因此做到了与浏览器绘制频率绝对一致。所以帧率会相当平稳,例如显示屏60hz
,那么会固定1000/60ms
刷新一次。
但如果使用的是setTimeout
和 setInterval
来实现同样的动画效果,它们会受到事件队列宏任务、微任务影响会导致执行的优先级顺序有所差异,自然做不到与绘制同频。
所以使用setTimeout
和 setInterval
不但无法自动匹配显示屏帧率,也无法做到完全固定的时间去刷新。
2、性能更好,切后台会暂停
当我们把使用了requestAnimationFrame
的页面切换到后台运行时,requestAnimationFrame
会暂停执行从而提高性能,切换回来后会马上提着执行。
效果如下动图,隐藏后停止运行,切换回来接着运行。
应用场景:常规动画
用一个很简单的示例:用requestAnimationFrame
使一张图片动态也丝滑旋转,直接看示例代码和效果。
思路:首先在页面初始化时执行window.requestAnimationFrame(animate)
使动画动起来,实现动画一直丝滑转运。在关闭页面时用window.cancelAnimationFrame(rafId)
去终止执行。
<template>
<div class="container">
<div :style="imgStyle" class="earth"></div>
</div>
</template>
<script setup>
import { ref, onMounted, reactive, onUnmounted } from 'vue';
const imgStyle = reactive({
transform: 'rotate(0deg)',
});
let rafId = null;
// 请求动画帧方法
function animate(time) {
const angle = (time % 10000) / 5; // 控制转的速度
imgStyle.transform = `rotate(${angle}deg)`;
rafId = window.requestAnimationFrame(animate);
}
// 开始动画
onMounted(() => {
rafId = window.requestAnimationFrame(animate);
});
// 卸载时生命周末停止动画
onUnmounted(() => {
if (rafId) {
window.cancelAnimationFrame(rafId);
}
});
</script>
<style scoped>
body {
box-sizing: border-box;
background-color: #ccc;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.container {
position: relative;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.earth {
height: 100px;
width: 100px;
background-size: cover;
border-radius: 50%;
background-image: url('@/assets/images/about_advantage_3.png'); /* 替换为实际的路径 */
}
</style>
看看动图效果
应用场景:滚动加载
在滚动事件中用requestAnimationFrame
去加载渲染数据使混动效果更加丝滑。主要好久有几个
- 提高性能: 添加
requestAnimationFrame
之后会在下一帧渲染之前执行,而不是每次在滚动事件触发的时候就立即执行。这可以减少大量不必要的计算,提高性能。 - 用户体验更好:确保在绘制下一帧时再执行,使帧率与显示屏相同,视觉上会更丝滑。
代码示例和效果如下。
<template>
<div class="container" ref="scrollRef">
<div v-for="(item, index) in items" :key="index" class="item">
{{ item }}
</div>
<div v-if="loading" class="loading">数据加载中...</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
const loading = ref(false);
let rafId: number | null = null;
// 数据列表
const items = ref<string[]>(Array.from({ length: 50 }, (_, i) => `Test ${i + 1}`));
// 滚动容器
const scrollRef = ref<HTMLElement | null>(null);
// 模拟一个异步加载数据效果
const moreData = () => {
return new Promise<void>((resolve) => {
setTimeout(() => {
const newItems = Array.from({ length: 50 }, (_, i) => `Test ${items.value.length + i + 1}`);
items.value.push(...newItems);
resolve();
}, 1000);
});
};
// 检查是否需要加载更多数据
const checkScrollPosition = () => {
if (loading.value) return;
const container = scrollRef.value;
if (!container) return;
const scrollTop = container.scrollTop;
const clientHeight = container.clientHeight;
const scrollHeight = container.scrollHeight;
if (scrollHeight - scrollTop - clientHeight <= 100) {
startLoading();
}
};
// 加载数据
const startLoading = async () => {
loading.value = true;
await moreData();
loading.value = false;
};
// 监听滚动事件
const handleScroll = () => {
console.log('滚动事件触发啦');
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
rafId = window.requestAnimationFrame(checkScrollPosition);
};
// 添加滚动事件监听器
onMounted(() => {
if (scrollRef.value) {
scrollRef.value.addEventListener('scroll', handleScroll);
}
});
// 移除相关事件
onUnmounted(() => {
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
if (scrollRef.value) {
scrollRef.value.removeEventListener('scroll', handleScroll);
}
});
</script>
<style scoped>
.container {
padding: 20px;
max-width: 800px;
overflow-y: auto;
margin: 0 auto;
height: 600px;
}
.item {
border-bottom: 1px solid #ccc;
padding: 10px;
}
.loading {
padding: 10px;
color: #999;
text-align: center;
}
</style>
看看下面动图效果
小结
通过代码示例配合动图讲解后,再通过两个简单的事例可能大家会发现,只要在页面需要运动的地方其实都可以用到 requestAnimationFrame
使效果变的更加丝滑。
除了上面两个小示例其它非常多地方都可以用到requestAnimationFrame
去优化性能,比较常见的例如游戏开发、各种动画效果和动态变化的布局等等。
文章就写到这啦,如果文章写的哪里不对或者有什么建议欢迎指出。
来源:juejin.cn/post/7431004279819288613
前端:为什么 try catch 能捕捉 await 后 Promise 的错误?
一次代码CR引发的困惑
“你这块的代码,没有做异常捕获呀,要是抛出了异常,可能会影响后续的代码流程”。这是一段出自组内代码CR群的聊天记录。代码类似如下:
const asyncErrorThrow = () => {
return new Promise((resolve, reject) => {
// 业务代码...
// 假设这里抛出了错误
throw new Error('抛出错误');
// 业务代码...
})
}
const testFun = async () => {
await asyncErrorThrow();
console.log("async 函数中的后续流程"); // 不会执行
}
testFun();
在 testFun
函数中,抛出错误后,await
函数中后续流程不会执行。
仔细回想一下,在我的前端日常开发中,对于错误捕获,还基本停留在使用 Promise
时用 catch
捕获一下 Promise
中抛出的错误或者 reject
,或者最基本的,在使用 JSON.parse
、JSON.stringfy
等容易出错的方法中,使用 try..catch...
方法捕获一下可能出现的错误。
后来,这个同学将代码改成了:
const asyncErrorThrow = () => {
return new Promise((resolve, reject) => {
// 业务代码...
throw new Error('抛出错误');
// 业务代码...
})
}
const testFun = async () => {
try {
await asyncErrorThrow();
console.log("async 函数中的后续流程"); // 不会执行
} catch (error) {
console.log("若错误发生 async 函数中的后续流程"); // 会执行
}
}
testFun();
而这次不同的是,这段修改后的代码中使用了 try...catch...
来捕获 async...await...
函数中的错误,这着实让我有些困惑,让我来写的话,我可能会在 await 函数的后面增加一个 catch:await asyncErrorThrow().catch(error => {})
。因为我之前已经对 try..catch
只能捕获发生在当前执行上下文的错误(或者简单理解成同步代码的错误)有了一定的认知,但是 async...await...
其实还是异步的代码,只不过用的是同步的写法,为啥用在这里就可以捕获到错误了呢?在查阅了相当多的资料之后,才清楚了其中的一些原理。
Promise 中的错误
我们都知道,一个 Promise 必然处于以下几种状态之一:
- 待定(pending):初始状态,既没有被兑现,也没有被拒绝。
- 已兑现(fulfilled):意味着操作成功完成。
- 已拒绝(rejected):意味着操作失败。
当一个 Promise 被 reject 时,该 Promise 会变为 rejected 状态,控制权将移交至最近的 rejection 处理程序。最常见的 rejection 处理程序就是 catch handler
或者 then
函数的第二个回调函数。而如果在 Promise 中抛出了一个错误。这个 Promise 会直接变成 rejected 状态,控制权移交至最近的 error 处理程序。
const function myExecutorFunc = () => {
// 同步代码
throw new Error();
};
new Promise(myExecutorFunc);
Promise 的构造函数需要传入的 Executor 函数参数,实际上是一段同步代码。在我们 new 一个新的 Promise 时,这个 Executor 就会立即被塞入到当前的执行上下文栈中进行执行。但是,在 Executor 中 throw 出的错误,并不会被外层的 try...catch 捕获到。
const myExecutorFunc = () => {
// 同步代码
throw new Error();
};
try {
new Promise(myExecutorFunc);
} catch (error) {
console.log('不会执行: ', error);
}
console.log('会执行的'); // 打印
其原因是因为,在 Executor 函数执行的过程中,实际上有一个隐藏的机制,当同步抛出错误时,相当于执行了 reject 回调,让该 Promise 进入 rejected 状态。而错误不会影响到外层的代码执行。
const myExecutorFunc = () => {
throw new Error();
// 等同于
reject(new Error());
};
new Promise(myExecutorFunc);
console.log('会执行的'); // 打印
同理 then 回调函数也是这样的,抛出的错误同样会变成 reject。
在一个普通脚本执行中,我们知道抛出一个错误,如果没有被捕获掉,会影响到后续代码的执行,而在 Promise 中,这个错误不会影响到外部代码的执行。对于 Promise 没有被捕获的错误,我们可以通过特定的事件处理函数来观察到。
new Promise(function() {
throw new Error("");
}); // 没有用来处理 error 的 catch
// Web 标准实现
window.addEventListener('unhandledrejection', function(event) {
console.log(event);
// 可以在这里采取其他措施,如日志记录或应用程序关闭
});
// Node 下的实现
process.on('unhandledRejection', (event) => {
console.log(event);
// 可以在这里采取其他措施,如日志记录或应用程序关闭
});
Promise 是这样实现的,我们可以想一想为什么要这样实现。我看到一个比较好的回答是这个:
传送门。我也比较赞成他的说法,我觉得,Promise 的诞生是为了解决异步函数过多而形成的回调地狱,使用了微任务的底层机制来实现异步链式调用。理论上是可以将同步的错误向上冒泡抛出然后用 try...catch... 接住的,异步的一些错误用 catch handler 统一处理,但是这样做的话会使得 Promise 的错误捕获使用起来不够直观,如果同步的错误也进行 reject 的话,实际上我们处理错误的方式就可以统一成 Promise catch handler 了,这样其实更直观也更容易让开发者理解和编写代码。
async await 的问题
那么回到我们最开始的问题,在这个里面,为什么 try catch 能够捕获到错误?
const asyncErrorThrow = () => {
return new Promise((resolve, reject) => {
// 业务代码...
throw new Error('抛出错误');
// 业务代码...
})
}
const testFun = async () => {
try {
await asyncErrorThrow();
console.log("async 函数中的后续流程"); // 不会执行
} catch (error) {
console.log("若错误发生 async 函数中的后续流程"); // 会执行
}
}
testFun();
我思考了很久,最后还是从黄玄大佬的知乎回答中窥见的一部分原理。
这...难道就是浏览器底层帮我们处理的事儿吗,不然也没法解释了。唯一能够解释的事就是,async await 原本就是为了让开发者使用同步的写法编写异步代码,目的是消除过多的 Promise 调用链,我们在使用 async await 时,最好就是不使用 .catch 来捕获错误了,而直接能使用同步的 try...catch... 语法来捕获错误。即使 .catch 也能做同样的事情。只是说,代码编写风格统一性的问题让我们原本能之间用同步语法捕获的错误,就不需要使用 .catch 链式调用了,否则代码风格看起来会有点“异类”。
这就是为什么 async MDN 中会有这样一句解释:
参考文档:
《使用Promise进行错误治理》- zh.javascript.info/promise-err…
《为什么try catch能捕捉 await 后 promise 错误? 和执行栈有关系吗?》http://www.zhihu.com/question/52…
来源:juejin.cn/post/7436370478521991183
告别 "if-else",改用 "return"!
大家好,我是CodeQi! 一位热衷于技术分享的码仔。
在日常的开发中,很多人习惯于使用 if-else
语句来处理各种条件。但你有没有想过,层层嵌套的条件判断,可能会让代码变得难以维护且难以阅读?今天,我想分享一个让代码更清晰易读的技巧,那就是——return。✨
if-else 真的有必要吗?
初学编程时,我们都习惯通过 if-else
语句来处理分支逻辑。比如判断一个用户是否活跃,是否有折扣,代码通常会写成这样:
function getDiscountMessage(user) {
if (user.isActive) {
if (user.hasDiscount) {
return `折扣已应用于 ${user.name}!`;
} else {
return `${user.name} 不符合折扣条件。`;
}
} else {
return `用户 ${user.name} 已被停用。`;
}
}
你看,这段代码嵌套了多个 if-else
语句。如果我们继续在这种风格的代码上添加更多条件判断,会变得更加难以阅读和维护。过多的嵌套让人一眼难以理清逻辑。更严重的是,随着代码量增多,容易导致出错。
return:清晰与高效的代码编写方式
所谓的提前return,就是在遇到异常情况或不符合条件时,立即返回并结束函数。通过提前处理错误情况或边界情况,我们可以把代码的“理想情况”留到最后处理。这种写法可以让代码更清晰,逻辑更加直接。🎯
示例:用return优化代码
来看一看如何用return来重写上面的代码:
function getDiscountMessage(user) {
if (!user.isActive) {
return `用户 ${user.name} 已被停用。`;
}
if (!user.hasDiscount) {
return `${user.name} 不符合折扣条件。`;
}
// 理想情况:用户活跃且符合折扣条件
return `折扣已应用于 ${user.name}!`;
}
🌟 优势
- 每个条件只处理一次:每个
if
语句都提前处理好错误情况,让后面的代码不必考虑这些条件。 - 代码结构更扁平:没有嵌套的
if-else
块,更加一目了然。 - 更易维护:当我们想增加或修改判断逻辑时,只需在前面添加或修改条件判断,不会影响到“理想情况”的代码部分。
return vs if-else:一个真实场景
假设我们有一个需要检查多个条件的函数,validateOrder
,要确保订单状态有效、用户有权限、库存足够等情况:
function validateOrder(order) {
if (!order.isValid) {
return `订单无效。`;
}
if (!order.userHasPermission) {
return `用户无权限。`;
}
if (!order.hasStock) {
return `库存不足。`;
}
// 理想情况:订单有效,用户有权限,库存足够
return `订单已成功验证!`;
}
通过这种方式,我们将所有不符合条件的情况都提前处理掉,将主逻辑留到最后一行。这不仅让代码更易读,而且可以提高代码的运行效率,因为无须进入嵌套的条件分支。🎉
何时使用return
虽然提前return是优化代码的好方式,但并不是所有情况下都适用。以下是一些适用场景:
- 多条件判断:需要检查多个条件时,尤其是多个边界条件。
- 简单条件过滤:对于不符合条件的情况可以快速返回,避免执行复杂逻辑。
- 确保主要逻辑代码始终位于底部:这样可以减少逻辑处理的复杂性。
结语
当我们写代码时,保持代码简洁明了是一项重要的原则。通过采用提前return,我们可以减少嵌套层次,避免过度依赖 if-else
,让代码更直观、易维护。如果你还没有使用return,不妨从现在开始尝试一下!😎
下次写代码时,记得问自己一句:“这个 if-else
可以用return替换吗? ”
让我们一起追求清晰、优雅的代码!Happy Coding! 💻
来源:juejin.cn/post/7431120645981831194
一种纯前端的H5灰度方案
什么是灰度发布
在互联网领域,灰度发布是产品质量保障的重要一环,它可以让某次更新的产品,以一种平滑,逐步扩大的方式呈现给用户,在此过程中,产品和技术团队可以对功能进行验证,收集用户反馈,不断优化,从而减少线上问题的影响范围,完善产品功能。
在前端领域,APP和小程序天生就具有灰度的能力,一般基于发布平台来控制。但 H5 却缺少这种天生能力,而且 H5
一旦发布就会影响所有用户,更加需要一套灰度系统,来保证产品的稳定性。
灰度发布的本质
既然要让部分用户先使用新功能,就需要做好两件事情,这也是灰度的本质:
- 版本控制 同一个项目需要在线上同时发布至少两套页面,一套针对全量用户,一套针对灰度用户
- 分流控制 需要有一套规则,把用户按某种特征划分为不同的群体,可以是用户ID,门店、城市,也可以是年龄,亦或是随机。命中的用户访问灰度页面,未命中的访问全量页面。
那么想要实现灰度发布有哪些方案呢?
可选的灰度方案
Nginx+lua+redis
通过使用 Nginx
的反向代理特性,我们可以根据请求的特定属性(例如ip、请求头、cookie)等有选择性的将请求路由到全量或灰度版本。
同时在 Nginx
中嵌入 Lua
脚本,负责根据预定义的灰度发布策略处理请求,Lua
脚本可以从 Redis
中获取灰度配置。从而确定哪些用户可以访问新版本,那些用户应该可以访问旧版本。
Redis
用于存储灰度发布的配置数据。
通过这种方式可以实现基于 Ngnix
的灰度发布,但这种方式并不适合我们,为什么呢?
因为我们的C端H5页面连同HTML文件都是直接投放在 CDN
上,这就意味着我们没有中转服务层,无法使用第一套 Nginx
的方案,而且使用 Nginx
也会响应降低页面加载速度,虽然可能很轻微,但却是对所有用户都会有影响。
采用 Nginx
进行中转:
不采用 Nginx
中转:
如上两张图,可以很明显的看到,如果采用 Nginx
来作为中转并进行分流控制,将导致我们的 CDN
优势失效,所有的流量都可能回到上海的机房,再流转到上海的 CDN,这显然不是我们想看到的。
这也是我们放弃 Nginx+lua+redis
方案的原因。
基于 SSR 做灰度
如果我们的前端页面是通过服务端来进行渲染,可以把灰度控制继承在服务端渲染中,基于不同的用户放回不同的HTML,这样也就可以做到灰度发布。
不过这需要有一套完善的 SSR
系统,对于访问量大的产品,维持系统稳定性的难度远大于实现 SSR
本身的技术难度。由于我们是前后端分离,并且没有基于 Node
高可用的运维团队和经验,所以这个方案也就放弃了。
APP拦截灰度
基于APP的方案,是在用户点击H5资源位,创建webview时,拉取灰度配置,如果当前页面有灰度,则拉取灰度配置,判断是否命中灰度,如果命中,替换H5链接即可。
看过我其他文章的朋友,应该有了解到我们针对H5秒开有一套配置下发到APP,那么灰度配置,也可以集成到原有配置中,一并下发给APP,这套方案相对而言也比较简单,但是却有如下问题。
- 只能支持APP,APP外和小程序内打开的场景无法支持
- 依赖APP,公司其他业务线的APP,如果要使用也需要开发,工作量较大。
所以最后该方案也被排除。
纯前端方案
方案概览
基于如上的一些原因,于是我们采用了一套纯前端的方案,来解决灰度发布问题,虽然这套方案也有一点缺点。前面我们提到灰度发布的本质,其实包含两个方面,一是版本控制,二是分流控制。
版本控制比较好做,我们把全量的HTML代码发布到 index.html
文件,把灰度的HTML代码发布到 gray.html
文件,这样就做到了版本控制。
分流控制,可以被拆分为两部分,一部分只管获取配置、判定是否命中灰度并入在本地,另一部只管读取结果并执行跳转,这样整个系统就解耦了。
方案大体思路是:
- 在用户首次方式时,静默激活灰度计算逻辑,通过接口或其他条件判断用户是否命中灰度,把结果存储在
localStorage
中。 - 有别于全量版本时使用
index.html
,灰度时构建并修改html名称为gray.html
,并发布 - 当要灰度发布时,下载
index.html
,注入灰度判断代码到 head 中,注入GRAY_SWITCH
开关并开启 - 当用户再次访问时,执行灰度判断代码,如果命中,重定向到 gray.html 页面
- 对获取页面点击的地方,进行封装或拦截,确保灰度用户分享出去的链接,是全量链接
流程图:
时序图如下:
灰度版本控制
对于版本控制,我们通过提供了一个 webpack
插件集成到构建流程中,在构建时生成不同文件名的 html 文件。
通过构建命令参数,来区分各种发布情况
npm run build your_project_name -- --gray=open
# --gray 的值
# --gray=close 不打开灰度,默认值
# --gray=open 打开灰度
# --gray=full 灰度全量
# --gray=unpublish 撤销灰度
可以分为如下情况:
正式发布
构建时生成:
- index.html 全量页面
- index_backup.html 全量备份页面(用来做回归)
灰度发布
构建时生成:
- gray.html 灰度页面
- gray_backup.html 灰度备份页(用来在全量后替换 index_backup.html)
同时下载 index.html ,注入灰度重定向控制JS。
重定向控制代码如下:
// 标记是否打开灰度
window.__GRAY_SWITCH__ = 1
let graySwitchName = 'gray_switch_';
// 获取去除html后的pathname
const pathname = window.location.pathname.split('/').slice(0, -1).join('/');
graySwitchName = graySwitchName + pathname
const graySwitch = localStorage.getItem(graySwitchName)
if (graySwitch === '1') {
const grayUrl = window.location.href.replace('index.html', '_gray.html')
if(window.history.replaceState){
// 安卓 app 使用 location.replace 无效
window.history.replaceState(null, document.title, grayUrl);
}else{
window.location.replace(grayUrl);
}
}
修改输出的 HTML
文件名,是通过编写 webpack
的自定义插件来完成。
原理是通过 compiler.hooks.afterEmit.tapAsync
钩子函数,再 “输出” 阶段,对文件名进行修改。
撤销灰度
从云端下载 index_backup.html
重命名为 index.html
放在打包目录,之后再由发布系统上传。
全量发布
从云端下载 gray.html
和 gray_backup.html
,重命名为 index.html
和 index_backup.html
,发布后就会替换原有的全量HTML。
灰度分流控制
分流的重点是如何判断哪些用户能命中灰度。每个项目划分人员的策略都可能不同,比如C端页面更倾向于按useID随机划分。而B端拣货、配送等业务线,更需要按门店来进行划分,这样可以做到同门店员工体验一致,便于管理。所以这块这块必须要足够的灵活性。
我们这里采取了两种方式:
第一种是基于接口来做分流控制:把用户信息传给服务端,接口通过配置的灰度规则,计算是否命中,并返回前端。前端只管把结果存入本地。
第二种是把计算逻辑都放在前端,比较适合C端项目,因为C端项目大部分场景都是随机划分灰度用户。
灰度分流计算的JS代码是在用户每次打开后,静默运行,所以需要引入到业务代码中。
引入的代码如下:
import grayManager from '@cherry/grayManager'
import { getMemberId } from '../utils/index'
// 伪代码,说明GrayOptions 的类型
interface GrayOptions {
// 灰度比例控制 支持固定值和数组阶梯灰度,配置grayScale 后,grayComputeFn无效
grayScale: number | [number]
// 自定义灰度方法,在内可以请求接口等
grayCompute: () => (() => Promise<boolean>) | boolean
// 获取维护标识,比如以 shopId 为灰度标识,该函数就返回当前用户的 shopId
getGaryData: () => ()=> Promise<string>,
// 配置灰度白名单,白名单内的用户都会命中灰度
whiteData: string[]
}
// 初始化灰度计算逻辑
grayManagerInit({
grayScale: 10,
whiteData: ['123', '456']
})
前端计算分流
随机百分比
多数项目,我们一般使用的策略是随机,比如设置10%的用户命中灰度。
我们可以通过生成随机数来判断是否命中灰度,具体步骤如下:
- 在
grayManager.init()
时,随机生成一个uuid
,存在用户本地,不做清除,下次 init 时,先从本地取uuid
,存储 key 命名为__GRAY_UUID__
。 - 当使用预置灰度计算能力时,取
__GRAY_UUID__
每位转化为 asci 码并相加,除以100 求余数 - 用余数+1 和灰度比例(
grayScale
)对比,当余数+1 <= grayScale
时命中灰度
这样可以得到一个近似 10% 比例的灰度用户数。
基于门店和城市分流
如果想基于门店或城市分流,我们只需要配置两个参数, 一是如何获取门店和城市ID
另一个是需要灰度的门店和城市ID
import grayManager from '@cherry/grayManager'
import { getShopId } from '../utils/index'
grayManagerInit({
getGaryData: () => {
return await getCityId()
},
whiteData: ['123', '456']
})
可以通过 grayScale 配置数组来实现,起始时间为打灰度包构建的时间,我们会把构建时间注入到 HTML
中。
其他注意项
开头讲过,这套方案有一点缺点。可能大家也会发现,灰度时用户需要先进入打 HTML
,执行 head
中注入的重定向控制JS,对命中灰度的用户再次跳转到 gray.html
。
这样其实带来了两个问题:一是对灰度用户来说经过了两个HTML,白屏的时间会更长。二是灰度用户访问的URL变化了,如果此时用户把页面分享出去,被分享用户将直接打开灰度页面。
对于第一条,全量用户是不会被影响,只有灰度用户才会白屏更久,我们目前测试白屏的时长还能接受。
对于第二条,我们最初是系统通过 Object.defineProperty
来拦截 对 window.location.pathname
的获取,返回 index.html
。但window.location.pathname
是一个只读属性不可拦截。
最后只能提供统一的方法,来获取 pathname
。
结语
以上就是我们的灰度核心方案,整个方案会比较简单,几乎不依赖外部部门。无论是对于H5还是pcWeb,亦或是不同的容器,都无依赖,各个业务线都可以平滑使用。
来源:juejin.cn/post/7438840414239326227
用Three.js搞个炫酷风场图
风场图,指根据风速风向数据进行渲染,以表征空气流动方向、流动速度的一种动态流场图。接下来让我们学一下怎么实现炫酷的2D和3D风场图吧!
一、 获取风场数据
- 打开NCEP(美国气象环境预报中心)
- 查看Climate Models(气候模型)的部分
- 点击Climate Forecast System 3D Pressure Products(气候预报系统3D大气压产品)的grib fiter选择数据下载
4. 界面会有不同日期的数据提供下载,我们选择默认最新的那个日期就好
- 一堆看不懂的参数,没关系,我们只需要在Levels图层这里勾选max wind这个就好(因为我们要画风场图),不推荐Levels勾选all,数据太大,下载慢,并且看不懂,用不到。
- 点击Start download就可以下载了
二、处理风场数据
grib这个数据格式打不开,看不懂,需要转换成json,有位大牛A写了个java的grib处理工具(grib2json),然而我用maven打包失败了,然后发现有另一位大牛B封装了大牛A的jar包成node脚本,正好给前端开发者使用。
- 安装
@weacast/grib2json
pnpm add -D @weacast/grib2json
- 执行脚本,将grib转换成json
使用说明
Usage: grib2json (or node bin.js) [options]
-V, --version 输出版本号
-d, --data 输出GRIB记录数据
-c, --compact 压缩json
-fc, --filter.category 选择类目值
-fs, --filter.surface 选择表面类型
-fp, --filter.parameter 选择参数值
-fv, --filter.value 选择表面值
-n, --names 打印数字代码的名称
-o, --output 输出文件名
-p, --precision 使用小数点后几位数的精度(默认值:-1)
-v, --verbose 启用stdout日志记录
-bs, --bufferSize stdout或stderr上允许的最大数据量(以字节为单位)
-h, --help 使用帮助
pnpm exec grib2json -c --names --data --fp 2 --fs 103 --fv 10.0 -o output.json D:/code/wind/pgbf2024103000.01.2024103000.grb2
注意:
--fs 103
表面类型103(地面以上指定高度)--fv 10.0
距离GRIB2文件10.0米的表面值--fp 2
将参数2(U-component_of_wind)的记录输出到stdout- 需要转换的grib文件放在最后,文件路径要用完整的路径名称
- 数据格式说明
{
"header":{
//数据更新时间
"refTime":"2024-10-30T00:00:00.000Z",
"parameterCategory":2,//类目号,2表示风力
"parameterCategoryName":"Momentum",
"parameterNumber":2,//2表示u,3表示v
"parameterNumberName":"U-component_of_wind",
"numberPoints":65160,//点数量
"nx":360,//横向栅格数量
"ny":181, //纵向栅格数量
"lo1":0.0,//开始经度
"la1":-90.0,//开始纬度
"lo2":359.0,//结束经度
"la2":90.0,//结束纬度
"dx":1.0,//横向步长
"dy":1.0//纵向补偿
},
"data":[//方向数据,u数据,要搭配另一个v的数据使用
-7.8,
-7.9,
]
}
U表示横向风速,V表示纵向风速,UV的正负值表示风向
- output.json有2.25MB大,数据里面除了uv方向的数据,还包含了其他的数据,我们只需要有用的一个header和uv数据即可,可以用node处理一下,得到一个header信息数据info.json和风向数据wind.json
const fs = require('fs');
const output = require('./output.json');
let uData = [];
let vData = [];
let header = {};
for (let i = 0; i < output.length; i++) {
if (output[i].header.parameterNumber === 2) {//u的数据集
uData = output[i].data;
header = output[i].header;
} else if (output[i].header.parameterNumber === 3) {//v的数据集
vData = output[i].data;
}
}
const len = uData.length;
const list = [];
const info = {
minU: Number.MAX_SAFE_INTEGER,
maxU: Number.MIN_SAFE_INTEGER,
minV: Number.MAX_SAFE_INTEGER,
maxV: Number.MIN_SAFE_INTEGER,
...header
};
for (let i = 0; i < len; i++) {
//uv数据组合
list.push([uData[i], vData[i]]);
//计算最大最小边界值
info.minU = Math.min(uData[i], info.minU);
info.maxU = Math.max(uData[i], info.maxU);
info.minV = Math.min(vData[i], info.minV);
info.maxV = Math.max(vData[i], info.maxV);
}
fs.writeFileSync('./wind.json', JSON.stringify(list));
fs.writeFileSync('./info.json', JSON.stringify(info));
三、绘制2D风场图
重头戏来了!瞪大你的眼睛(0 v 0),看好了!
1. 创建风场网格
nx和ny对应横向纵向网格数量,然后uv数据按照nx行,ny列组装添加到二维数组里面就是网格了。
this.grid = [];
let index = 0;
for (let j = 0; j < header.ny; j++) {
const row = [];
for (let i = 0; i < header.nx; i++) {
const item = this.data[index++];
row.push(item);
}
this.grid.push(row);
}
2. 获取点xy对应的风向uv
根据风场网格获取该xy先在应的风向uv,点xy可能不是整数,那么这时候需要使用双线性插值(根据临近的周围四个点计算出插值)算出对应的风向uv。
- 根据xy获取风向uv
getUV(x, y) {
let x0 = Math.floor(x),
y0 = Math.floor(y);
//正好落在网格里
if (x0 === x && y0 === y) return this.getGrid(x, y);
let x1 = x0 + 1;
let y1 = y0 + 1;
//临近四周的点
let g00 = this.getGrid(x0, y0),
g10 = this.getGrid(x1, y0),
g01 = this.getGrid(x0, y1),
g11 = this.getGrid(x1, y1);
return this.bilinearInterpolation(x - x0, y - y0, g00, g10, g01, g11);
}
- 不落在整数网格里面的采用双线性插值计算出风向uv
/**双线性插值
* g00, g10, g01, g11对应临近可映射的四个点
* x为当前点与最近点x坐标差
* y为当前点与最近点y坐标差
* ***/
bilinearInterpolation(x, y, g00, g10, g01, g11) {
let rx = 1 - x;
let ry = 1 - y;
let a = rx * ry,
b = x * ry,
c = rx * y,
d = x * y;
let u = g00[0] * a + g10[0] * b + g01[0] * c + g11[0] * d;
let v = g00[1] * a + g10[1] * b + g01[1] * c + g11[1] * d;
return [u, v];
}
- 获取网格数值,需规整超出的边界值
getGrid(x, y) {
const h = this.header;
if (x < 0) {
x = 0;
} else if (x > h.nx - 1) {
x = h.nx - 1;
}
if (y < 0) {
y = 0;
} else if (y > h.ny - 1) {
y = h.ny - 1;
}
return this.grid[y][x];
}
3. 创建随机点
createRandParticle() {
//必须在风场网格范围内才能获取到风向uv
const x = Math.random() * this.header.nx;
const y = Math.random() * this.header.ny;
const uv = this.getUV(x, y);
return {
//起点位置
x,
y,
//终点位置=当前位置加上风向偏移
tx: x + this.speed * uv[0],
ty: y + this.speed * uv[1],
//生命周期,将生命周期归零的时候重新设置起点坐标
age: Math.floor(Math.random() * this.maxAge)
};
}
//重新设置随机点
setParticleRand(p) {
const newp = this.createRandParticle();
for (let k in p) {
p[k] = newp[k];
}
}
- 生成随机点
this.particles = [];
for (let i = 0; i < this.particlesCount; i++) {
this.particles.push(this.createRandParticle());
}
4. 绘制风场图
canvas绘制风场即用线段的起点和终点跟随着风向不断运动形成风场图。
- 设置canvas
//缓存canvas context之前的合成操作类型
const pre = ctx.globalCompositeOperation;
//'destination-in'仅保留现有画布内容和新形状重叠的部分。其他的都是透明的。
ctx.globalCompositeOperation = 'destination-in';
//之前绘制的保留重叠部分
ctx.globalAlpha = 0.5;
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
//还原合成操作类型
ctx.globalCompositeOperation = pre;
//设置线的全局透明度
ctx.globalAlpha = 0.8;
注意:cxt.fillRect
本来清空之前的画布内容,但采用了globalCompositeOperation='destination-in'
和globalAlpha=0.5
的透明度作为重叠标准,重叠部分以0.5的透明度重新绘制并保留下来,通过这种方式,可以形成很多连续点的感觉,如果设置为1的透明度则会全部保留,并且不停叠加,等价于没有清空画布的状态。
- 遍历随机点更新位置
this.particles.forEach((p) => {
if (p.age <= 0) {
//生命周期耗尽重新设置随机点值
this.setParticleRand(p);
} else {
if (!this.inBound(p.x, p.y)) {
//画出范围外重新设置随机点值
this.setParticleRand(p);
} else {
//根据下一个点的风向,计算出下一个点的位置
const uv = this.getUV(p.tx, p.ty);
const nextx = p.tx + this.speed * uv[0];
const nexty = p.ty + this.speed * uv[1];
//将起点换成之前的终点
p.x = p.tx;
p.y = p.ty;
//终点设置成计算出的下一个点
p.tx = nextx;
p.ty = nexty;
//生命周期递减
p.age--;
}
}
//起始点和终点转换成显示的画布大小
const start = this.getCanvasPos(p.x, p.y);
const end = this.getCanvasPos(p.tx, p.ty);
//渐变跟随线段的方向
const gradient = ctx.createLinearGradient(start[0], start[1], end[0], end[1]);
for (let k in this.color) {
gradient.addColorStop(+k, this.color[k]);
}
//绘制线段
ctx.beginPath();
ctx.strokeStyle = gradient;
ctx.moveTo(start[0], start[1]);
ctx.lineTo(end[0], end[1]);
ctx.stroke();
});
5. 使用封装类绘制
async function main() {
//风场信息数据
const header = await getData('./info.json');
//风场uv方向数据
const data = await getData('./wind.json');
const canvas = document.getElementById('canvas');
canvas.width = 1200;
canvas.height = 600;
const cw = new Windy({
header,
data,
canvas,
//运动速度
speed: 0.1,
//随机点数量
particlesCount: 1000,
//生命周期
maxAge: 120,
//1秒更新次数
frame: 10,
//线渐变
color: {
0: 'rgba(255,255,0,0)',
1: '#ffff00'
},
//线宽度
lineWidth: 3
});
}
效果非常好,线段顺着风向在运动!
- 上面的线段因为一段段渐变呈现出一个个小蝌蚪的样子,然而利用叠加保留的效果,可以自动将线段绘制渐变色。只需要改变一下绘制顺序就行
//线段绘制开始
ctx.beginPath();
//设置纯颜色
ctx.strokeStyle = this.color;
//遍历随机点更新位置
this.particles.forEach((p) => {
//同上面更新随机点的位置
//...
//起始点和终点转换成显示的画布大小
const start = this.getCanvasPos(p.x, p.y);
const end = this.getCanvasPos(p.tx, p.ty);
//通过moveTo和lineTo绘制多个线段
ctx.moveTo(start[0], start[1]);
ctx.lineTo(end[0], end[1]);
});
//最终统一绘制线段
ctx.stroke();
这样看上去流动线段连续性更强,不那么零散了!
6. 利用图片信息存储数据的优化
wind.json
风场uv方向数据有739KB接近1MB,这着实有点大,要是网络稍微有点卡都会很影响首屏加载时间!从webgl-wind中我看到了用Canvas的ImageData中颜色来存储与解析数值,这操作太优秀了!
实现逻辑:用nx*ny
与风场网格同样大小的canvas,获取到ImageData,将像素颜色四个数值中red红色和green绿色分别赋值成uv转换后的颜色值,注意透明度一定要置为不透明,然后put回canvas里面绘制,再利用canvas.toDataURL
导出图片。
async function createCanvas() {
const data = await getData('./wind.json');
const info = await getData('info.json');
const canvas = document.getElementById('theCanvas');
canvas.width = info.nx;
canvas.height = info.ny;
const minU = Math.abs(info.minU);
const minV = Math.abs(info.minV);
// uv风方向范围
const uSize = info.maxU - info.minU;
const vSize = info.maxV - info.minV;
const ctx = canvas.getContext('2d');
//获取imageData像素数据
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
data.forEach((item, i) => {
//值转换成正数
const u = item[0] + minU;
const v = item[1] + minV;
//转换成颜色值
const r = (u / uSize) * 255;
const g = (v / vSize) * 255;
imageData.data[i * 4] = r;
imageData.data[i * 4 + 1] = g;
//透明度默认255即不透明
imageData.data[i * 4 + 3] = 255;
});
//用imageData像素颜色值绘制图片
ctx.putImageData(imageData, 0, 0);
}
这样一张360px*181px
的图片存储了65,160
个点,但仅仅只需要86.6KB,压缩成原来数据的十分之一了。
- 如果改用风场方向图片,那么对应需要添加加载和解析数据的流程
加载风场方向数据图片
loadImageData() {
return new Promise((resolve) => {
const image = new Image();
image.src = this.imageUrl;
image.onload = () => {
const c = document.createElement('canvas');
c.width = image.naturalWidth;
c.height = image.naturalHeight;
const ctx = c.getContext('2d');
//绘制图片
ctx.drawImage(image, 0, 0, image.naturalWidth, image.naturalHeight);
//获取ImageData像素数据
const imageData = ctx.getImageData(0, 0, image.naturalWidth, image.naturalHeight);
resolve(imageData.data);
};
});
}
解析图片数据成uv,并组装成风场网格Grid
data = await this.loadImageData();
const minU = Math.abs(header.minU);
const minV = Math.abs(header.minV);
//uv风方向范围
const uSize = header.maxU - header.minU;
const vSize = header.maxV - header.minV;
let index = 0;
for (let j = 0; j < header.ny; j++) {
const row = [];
for (let i = 0; i < header.nx; i++) {
//将颜色数据转化成风向uv数据
const u = (data[index] / 255) * uSize - minU;
const v = (data[index + 1] / 255) * vSize - minV;
row.push([u, v]);
index = index + 4;
}
this.grid.push(row);
}
后面的绘制风场逻辑跟上面一样,只不过多了个加载图片解析的过程。
加上一张世界地图底图可以更清晰得看到风流动的方向!
四、绘制3D风场图
1.利用Canvas风场贴图绘制3D风场图
- 常规的顶点着色器
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.);
}
- 片元着色器,要将世界底图与风场图合并成一张图
varying vec2 vUv;
uniform sampler2D windTex;
uniform sampler2D worldTex;
void main() {
vec4 color = texture2D(windTex, vUv);
float a = color.a;
if(a < 0.01) {
a = 0.;
}
vec4 w = texture2D(worldTex, vUv);
//根据透明度合并世界贴图和风场贴图
vec4 c = w * (1. - a) + color * a;
gl_FragColor = c;
}
- 创建风场贴图
async createWindCanvas() {
const header = await getData('./info.json');
const canvas = document.createElement('canvas');
//要足够大,否则会贴图模糊
canvas.width = 4000;
canvas.height = 2000;
this.cw = new Windy({
header,
// data,
canvas,
//运动速度
speed: 0.1,
//随机点数量
particlesCount: 1000,
//生命周期
maxAge: 120,
//1秒更新次数
frame: 10,
//线渐变
// color: {
// 0: 'rgba(255,255,0,0)',
// 1: '#ffff00'
// },
color: '#ffff00',
//线宽度
lineWidth: 3,
imageUrl: 'wind.png'
//autoAnimate: true
});
const texture = new THREE.CanvasTexture(canvas);
//因为是动态canvas,所以要置为需要更新
texture.needsUpdate = true;
return texture;
}
- 添加球体
async createChart(that) {
this.windTex = await this.createWindCanvas();
const worldTex = new THREE.TextureLoader().load('../assets/world.jpg');
{
const material = new THREE.ShaderMaterial({
uniforms: {
worldTex: { value: worldTex },
windTex: { value: this.windTex }
},
vertexShader: document.getElementById('vertexShader').innerHTML,
fragmentShader: document.getElementById('fragmentShader').innerHTML,
side: THREE.DoubleSide,
transparent: true
});
const geometry = new THREE.SphereGeometry(2, 32, 16);
const sphere = new THREE.Mesh(geometry, material);
this.scene.add(sphere);
}
}
- 让canvas动起来
animateAction() {
if (this.windTex) {
if (this.cw) {
this.cw.render();
}
this.windTex.needsUpdate = true;
}
}
地球展开收起动画
- 将顶点着色器替换成下面的,根据uv计算出压平后球体表面点的位置,然后用mix来让原来球体表面的点过渡变化
注意球体半圆周长,对应球体压平后矩形的宽度,球体贴图正好是2:1,长度对应宽度的两倍。
uniform float time;
uniform float radius;
varying vec2 vUv;
float PI = acos(-1.0);
void main() {
vUv = uv;
//半圆周长
float w = radius * PI;
//随着时间压平或收起球体点位置
vec3 newPosition = mix(position, vec3(0.0, (uv.y - 0.5) * w, -(uv.x - 0.5) * 2.0 * w), sin(time * PI * 0.5));
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
}
- 展开或收起球体动画
openMap() {
const tw = new TWEEN.Tween({ time: 0.0 })
.to({ time: 1.0 }, 2000)
.onUpdate((obj) => {
if (this.mat) {
this.mat.uniforms.time.value = obj.time;
}
})
.start();
TWEEN.add(tw);
}
closeMap() {
const tw = new TWEEN.Tween({ time: 1.0 })
.to({ time: 0.0 }, 2000)
.onUpdate((obj) => {
if (this.mat) {
this.mat.uniforms.time.value = obj.time;
}
})
.start();
TWEEN.add(tw);
}
除了用贴图来实现,还能用three.js的BufferGeometry+LineSegments实现动态线段,进而实现3D风场图。
2.使用LineSegments绘制风场图
- 顶点着色器
uniform vec2 uResolution;//nx与ny网格大小
uniform vec2 uSize;//显示的宽高
varying vec2 vUv;
void main() {
vUv = vec2(position.z);
// 转换为经纬度坐标
vec2 p = vec2(position.x, -position.y) - vec2(180., 90.);
gl_Position = projectionMatrix * modelViewMatrix * vec4((p / uResolution) * uSize + vec2(0., uSize.y), 0.0, 1.);
}
注意:地球的经纬度是从下往上变大的,而平面的坐标是从上往下变大的的,因此随机点的y坐标取反才是正确位置,因为取反的问题,位置会偏移,对应也要将整体位置加上偏移量归位。
- 片元着色器
varying vec2 vUv;
uniform vec3 startColor;
uniform vec3 endColor;
void main() {
//渐变色
gl_FragColor = vec4(mix(startColor, endColor, vUv.y), 1.0);
}
- 绘制线段LineSegments 将随机点的开始结束两个点位置分别赋值到线段position里面,并添加索引。
//点索引
const points = new Float32Array(num * 6);
let i = 0;
pointCallback: (p) => {
// 线段开始位置
points[i] = p.x;
points[i + 1] = p.y;
points[i + 2] = 0;//开始点z坐标标识是0
// 线段结束位置
points[i + 3] = p.tx;
points[i + 4] = p.ty;
points[i + 5] = 1;//结束点z坐标标识是1
//递增索引
i += 6;
}
添加LineSegments,一定要用LineSegments,因为LineSegments是绘制的线段是gl.LINES
模式,就是每两个点一组,形成一个新线段,就是A,B,C,D四个点,就会变成AB一条线段,BC一条线段,就可以绘制多条线段了。
const material = new THREE.ShaderMaterial({
uniforms: {
//nx和ny网格大小
uResolution: { value: new THREE.Vector2(this.cw.header.nx, this.cw.header.ny) },
//显示宽高大小
uSize: { value: new THREE.Vector2(20, 10) },
//渐变开始颜色
startColor: { value: new THREE.Color('#ffff00') },
//渐变结束颜色
endColor: { value: new THREE.Color('#ff0000') }
},
vertexShader: document.getElementById('vertexShader1').innerHTML,
fragmentShader: document.getElementById('fragmentShader').innerHTML,
side: THREE.DoubleSide,
transparent: true
});
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(points, 3));
this.geometry = geometry;
this.mat = material;
//添加多个线段
const lines = new THREE.LineSegments(geometry, material);
this.scene.add(lines);
渲染的时候移动点的位置并给position属性赋值更新
if (this.frameCount % this.frame === 0 && this.cw && this.geometry) {
let i = 0;
const g = this.geometry;
this.cw.movePoints((p) => {
g.attributes.position.array[i] = p.x;
g.attributes.position.array[i + 1] = p.y;
g.attributes.position.array[i + 3] = p.tx;
g.attributes.position.array[i + 4] = p.ty;
i += 6;
});
//属性值改变一定要置true,通知更新
g.attributes.position.needsUpdate = true;
}
上面效果的风场图与canvas 2D风场图清空再绘制一样的效果,没有走destination-in
叠加保留的过程,点的数量可能看起来偏少,因此为了保证风流向的连续性,最好增加随机点个数。
- 将平面的LineSegments变成球体 修改一下定点着色器,经纬度坐标转换成三维坐标
float PI = 3.1415926;
float rad = 3.1415926 / 180.;
uniform vec2 uResolution;
uniform vec2 uSize;
//半径
uniform float radius;
//旋转翻过来
uniform mat4 rotateX;
varying vec2 vUv;
//经纬度坐标转为三维坐标
vec3 lnglat2pos(vec2 p) {
float lng = p.x * rad;
float lat = p.y * rad;
float x = cos(lat) * cos(lng);
float y = cos(lat) * sin(lng);
float z = sin(lat);
return vec3(x, z, y);
}
void main() {
vUv = vec2(position.z);
//转换成经纬度
vec2 p = vec2(position.x, -position.y) - vec2(180., 90.);
//经纬度转三维坐标
vec3 newPosition = radius * lnglat2pos(p);
gl_Position = projectionMatrix * modelViewMatrix *rotateX* vec4(newPosition, 1.);
}
注意
- three.js高度y轴坐标,那么对应三维坐标里面的z轴坐标,而three.js深度z轴坐标,那么对应三维坐标里面的y轴坐标,就是yz轴要对调一下,才是正确的点的位置,即
vec3(x, z, y)
。 - position转经纬度,同上面一样需要将y取反才是正确的位置。 3.地球贴图贴在球体x方向开始位置有PI的偏移,需要将贴图设置一下偏移值才能对上经纬度坐标。
const worldTex = new THREE.TextureLoader().load('../assets/world.jpg');
worldTex.offset.x = 0.5;
worldTex.wrapS = THREE.RepeatWrapping;
4.因为y取反了,但在球体不能用位置偏移量解决归位问题,就会导致整个风流向路径反过来了,所以需要添加一个矩阵翻转量,让风流向路径回归正确的样子,
const matrix = new THREE.Matrix4();
matrix.makeRotationX(Math.PI);
终于解决风场位置对齐的问题了!这点小细节调了好久!唉~
五、Github地址
https://github.com/xiaolidan00/my-earth
参考
来源:juejin.cn/post/7433055938418933787