O2OA 私有化部署

安装 mysql 数据库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# 安装服务
sudo apt install -y mysql-server

# 检查服务是否启动
systemctl status mysql

# 首次登录没有密码
sudo mysql -u root -p

# 创建对应的数据库

CREATE DATABASE my_database
CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
# 查看所有数据库
SHOW DATABASES;

# 删除数据库
DROP DATABASE database_name;
# 修改字符集和排序
ALTER DATABASE database_name
CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;

# 创建新用户
# 'localhost':表示该用户只能从本地机器连接到数据库。如果希望该用户可以从任何主机连接,可以使用 '%',如 'new_user'@'%'。
CREATE USER 'new_user'@'localhost' IDENTIFIED BY 'your_password';
# 查看所有用户
SELECT User, Host FROM mysql.user;


#ALL PRIVILEGES:赋予所有权限。
# my_database.*:指示该用户可以对 my_database 数据库中的所有表进行操作。
# 'localhost':表示该用户只能从本地连接。
GRANT ALL PRIVILEGES ON my_database.* TO 'new_user'@'localhost';

# 刷新权限
FLUSH PRIVILEGES;

# 查看端口
SHOW VARIABLES LIKE 'port';

cat /etc/mysql/mysql.conf.d/mysqld.cnf

下载安装包

下载 安装包

解压并复制到 /opt

1
2
3
unzip  o2server-8.2.3-linux-x64.zip

mv o2server /opt

配置服务器端口

O2OA 服务器端口配置文件所在位置:o2server/config/node_127.0.0.1.json

如果目录里没有该文件或者没有 config 目录,可以新建一个 config 目录,然后从 configSample 目录里 COPY 一个到新建的 config 目录下。

1
# 自行修改web端口配置

自启动服务

因为需要用 root 用户启动服务,所以先配置开机启动服务

1
2
3
4
./service_linux.sh o2server start_linux.sh

systemctl enable o2server
systemctl start o2server

配置数据库链接

1
jdbc:mysql://127.0.0.1:3306/o2oa_db?autoReconnect=true&allowPublicKeyRetrieval=true&useSSL=false&useUnicode=true&characterEncoding=UTF-8&useLegacyDatetimeCode=false&serverTimezone=GMT%2B8

Three.js 基础

模型类型

.fbx

FBX 是 FilmBoX 这套软件所使用的格式,后改称 Motionbuilder。因为 Motionbuilder 扮演的是动作制作的平台,所以在前端的 modeling 和后端的 rendering 也都有赖于其它软件的配合,所以 Motionbuilder 在档案的转换上自然下了一番功夫。

FBX 最大的用途是用在诸如在 Max、Maya、Softimage 等软件间进行模型、材质、动作和摄影机信息的互导,这样就可以发挥 Max 和 Maya 等软件的优势。可以说,FBX 方案是非常好的互导方案。

.glTF

glTF 是一种可以减少 3D 格式中与渲染无关的冗余数据并且在更加适合 OpenGL 簇加载的一种 3D 文件格式。glTF 的提出是源自于 3D 工业和媒体发展的过程中,对 3D 格式统一化的急迫需求。如果用一句话来描述:glTF 就是三维文件的 JPEG ,三维格式的 MP3。在没有 glTF 的时候,大家都要花很长的的时间来处理模型的载入。

很多的游戏引擎或者工控渲染引擎,都使用的是插件的方式来载入各种格式的模型。可是,各种格式的模型都包含了很多无关的信息。就 glTF 格式而言,虽然以前有很多 3D 格式,但是各种 3D 模型渲染程序都要处理很多种的格式。对于那些对载入格式不是那么重要的软件,可以显著减少代码量,所以也有人说,最大的受益者是那些对程序大小敏感的 3D Web 渲染引擎,只需要很少的代码就可以顺利地载入各种模型了。

此外,glTF 是对近二十年来各种 3D 格式的总结,使用最优的数据结构,来保证最大的兼容性以及可伸缩性。这就好比是本世纪初 xml 的提出。glTF 使用 json 格式进行描述,也可以编译成二进制的内容:bglTF。glTF 可以包括场景、摄像机、动画等,也可以包括网格、材质、纹理,甚至包括了渲染技术(technique)、着色器以及着色器程序。同时由于 json 格式的特点,它支持预留一般以及特定供应商的扩展。

.obj

OBJ 文件是 Alias|Wavefront 公司为它的一套基于工作站的 3D 建模和动画软件”Advanced Visualizer”开发的一种标准 3D 模型文件格式,很适合用于 3D 软件模型之间的互导。目前几乎所有知名的 3D 软件都支持 OBJ 文件的读写。OBJ 文件是一种文本文件,可以直接用写字板打开进行查看和编辑修改。

物体 位移/缩放/旋转

1
2
3
4
5
6
7
8
cube.position.x = 0;
cube.position.set(0, 0, 0);

cube.scale.y = 1;
cube.scale.set(0, 1, 0);

cube.rotate.z = Math.PI / 4;
cube.rotate.set(0, 0, Math.PI / 4);

一个物体的 position/scale/rotate 属性描述一个物体的 位置/缩放/旋转,当这个物体再世界坐标系中,那么他的 位移/缩放/旋转 相对于世界坐标系。

如果物体在另一个物体中,那么他的 位移/缩放/旋转 相对于父元素的位置。

1
2
3
4
// 由于物体位置相对于父元素,因此cube在坐标原点
parentCube.add(cube);
parentCube.position.x = -3;
cube.position.x = 3;

画布适应窗口的变化

1
2
3
4
5
6
7
8
window.addEventListener("resize", () => {
// 更新摄像头宽高比
camera.aspect = window.innerWidth / window.innerHeight;
// 更新摄像机的投影矩阵
camera.updateProjectionMatrix();
// 更新渲染器
renderer.setSize(window.innerWidth, window.innerHeight);
});

通过 GUI 快速调试参数

通过安装 dat.gui (不推荐)

1
import * as dat from "dat.gui";

新方法是使用 threejs 自带的 GUI (推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { GUI } from "three/examples/jsm/libs/lil-gui.module.min";

const gui = new GUI();
const handle = {
click: () => console.log("click"),
fullscreen: () => console.log("fullscreen"),
};
// 添加两个按钮,点击和全屏
gui.add(handle, "click").name("点击");
gui.add(handle, "fullscreen").name("全屏");

//修改数值,最小值-10,最大值10,步长1
gui.add(cube.position, "x").name("x轴位置").min(-10).max(10).step(1);

//使用 folder 分组
let folder = gui.addFolder("按钮组");
folder.add(handle, "click").name("点击");
folder.add(handle, "fullscreen").name("全屏");

//事件
gui.add(handle, "click").onChange(noop).onFinishChange(noop);

//颜色
const colors = {
cubeColor: "#ff0000",
};
gui
.addColor(colors, "cubeColor")
.name("颜色")
.onChange(() => cube.material.color.set(val));

Geometry 几何体

顶点/索引

threejs 中使用 position类型化数组描述顶点的位置信息。三个为一组,绘制时会将一组顶点绘制为一个三角形,复杂的几何体也是由多个三角形构成。

顶点的排列顺序(绕序)影响绘制的效果,逆时针 (Counter Clockwise, CCW) 排列的面视为正面, 顺时针排列视为反面。模型情况下,背面不会渲染以便提高性能。

Three.js 使用相机的视角和顶点位置之间的关系来判断一个面是正面还是反面。当从相机位置看时,顶点按照逆时针排列的面会被认为是正面;相反,顺时针排列的面会被认为是背面。顶点顺序的判断依赖于面法向量(normal vector)。法向量是垂直于面的一个矢量,它通过面顶点的排列顺序来决定。如果法向量指向相机方向,面被认为是正面,否则是反面。

1
2
3
4
5
6
7
8
const position = new Float32Array([
1, 1, 0, -1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0, -1, -1, 0,
]);
const geometry = new THREE.BufferGeometry();

const indices = new Uint16Array();
geometry.setAttribute("position", new THREE.BufferAttribute(position, 3));
const mesh = new THREE.Mesh(geometry, material);

但是查看 position 属性会发现有 6 个顶点,这时可以通过建立索引,共用顶点优化顶点数量。

1
2
3
4
5
6
// 去除掉可以公用的顶点,
const position = new Float32Array([1, 1, 0, -1, -1, 0, 1, -1, 0, -1, 1, 0]);

// 使用索引来描述
const indices = new Uint16Array([0, 1, 2, 0, 3, 1]);
geometry.setIndex(new THREE.BufferAttribute(indices, 1));
顶点分组

为几何体的顶点分组,可以为每个组设置单独的材质。

1
2
3
4
geometry.addGroup(0, 3, 0); // 开始位置,数量,材质索引
geometry.addGroup(3, 3, 1);

const mesh = new THREE.Mesh(geometry, [material, material2]);
顶点转换

对几何体的顶点操作(position, rotate, translate)通常是不推荐的,这会改变几何体原有的顶点信息(attribute.position), 通常在物体(Mesh)上修改。

UV

UV 决定了 2D 纹理如何映射到 3D 空间中,U V 分别代表 2D 纹理的横纵坐标(为了与 3D 中的 X Y Z 坐标区分)。在模型建好之后会将纹理展开到 2D 平面中,这一过程叫做 UV 展开。常用的算法有投影展开法(Projection Mapping),迭代展开法(Iterative Unwrapping),Least Squares Conformal Mapping (LSCM) 和 Angle-Based Flattening (ABF) 等。

如果一个物体缺失 UV 信息,那么纹理将无法映射到物体表面。如果 UV 坐标没有覆盖(1,1)区域,剩余位置颜色会自动按 UV 坐标边缘采集。

法线

法线是垂直与平面的向量,决定了光纤如何反射。

1
2
3
4
5
6
7
8
9
10
// 法线辅助器
import { VertexNormalsHelper } from "three/examples/jsm/helpers/VertexNormalsHelper.js";
const geometry = new THREE.BufferGeometry();
// ...
// 自动计算法线
geometry.computeVertexNormals();
// ...
// 添加法线辅助器
const helper = new VertexNormalsHelper(mesh, 0.2, 0x00ff00);
scene.add(helper);
包围盒

包围盒用于可视化的检视物体,或用于碰撞检测。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
fbxLoader.load(floorFbx, function (group) {
// 通过名称或ID查找到模型中指定的物体
const mesh = group.getObjectById(40);
// const mesh = group.getObjectByName('a23')

// 由于物体的父级可能存在几何变化导致与物体的尺寸不同,因此需要更新物体的世界矩阵
// 更新父级, 更新子集
mesh.updateWorldMatrix(true, true);

// 需要手动调用几何体包围盒计算函数
const geometry = mesh.geometry;
geometry.computeBoundingBox();

// 获取包围盒计算结果
const box = geometry.boundingBox;

// 使用世界矩阵更新包围盒
box.applyMatrix4(mesh.matrixWorld);

// 创建包围盒辅助器显示包围盒
const boxHelper = new THREE.Box3Helper(box, 0xff0000);

// 添加到场景中
scene.add(boxHelper);
});

多个物体包围盒

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const mesh = group.getObjectById(40);
const mesh1 = group.getObjectById(41);
const mesh2 = group.getObjectById(42);

const boxUnion = new THREE.Box3();
[mesh, mesh1, mesh2].forEach((mesh) => {
mesh.updateWorldMatrix(true, true);
const geometry = mesh.geometry;
geometry.computeBoundingBox();
const box = geometry.boundingBox;
box.applyMatrix4(mesh.matrixWorld);
const boxHelper = new THREE.Box3Helper(box, 0xff0000);
scene.add(boxHelper);

boxUnion.union(box);
});

const boxHelper = new THREE.Box3Helper(boxUnion, 0xff0000);
scene.add(boxHelper);

使用 setFromObject,自动计算和世界轴对齐的一个对象 Object3D (含其子对象)的包围盒,计算对象和子对象的世界坐标变换。

1
2
3
4
5
6
7
8
9
10
const boxUnion = new THREE.Box3();
[mesh, mesh1, mesh2].forEach((mesh) => {
const box = new THREE.Box3().setFromObject(mesh);
const boxHelper = new THREE.Box3Helper(box, 0xff0000);
scene.add(boxHelper);
boxUnion.union(box);
});

const boxHelper = new THREE.Box3Helper(boxUnion, 0xff0000);
scene.add(boxHelper);
几何体居中/获取中心
1
2
3
4
5
6
7
8
9
// 会将几何体的中心放到世界中心
geometry.center();

// 但是Mesh对象可能有几何变换,导致物体看上去仍然偏移
// 需要重置Mesh的几何信息
mesh.position.set(0, 0, 0);

// 获取包围盒的中心点
const vec3 = box.getCenter(new THREE.Vector3());

边缘几何体/线框几何体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 通过名称或ID查找到模型中指定的物体
const mesh = group.getObjectById(40);

const geometry = mesh.geometry;

// 创建边缘几何体
const edgeGeometry = new THREE.EdgesGeometry(geometry);

// 创建线框几何体
// const wireGeometry = new THREE.WireframeGeometry(geometry);

// 创建线段材质
const lineMaterial = new THREE.LineBasicMaterial({
color: 0xffffff,
});

// 创建线段物体
const lineMesh = new THREE.LineSegments(edgeGeometry, lineMaterial);

// 由于新创建的物体丢失了源物体的世界矩阵信息,因此位置大小可能表现不同
// 需要使用源物体的世界矩阵信息重新赋值

// 更新源物体的世界矩阵信息
// mesh.updateWorldMatrix();

// 应用源物体的世界矩阵
lineMesh.matrix.copy(mesh.matrixWorld);

//将矩阵信息解构到物体的变换信息上
lineMesh.matrix.decompose(
lineMesh.position,
lineMesh.quaternion,
lineMesh.scale
);
scene.add(lineMesh);

材质与纹理

three.js 中的材质就是几何体表面的材料。所有材质均继承自 Material。ThreeJS 的材质分为:基础材质、深度材质、法向量材质、琥珀材质、冯氏材质、标准材质、着色器材质、基础线材质以及虚线材质。材质就像物体的皮肤,让几何体看起来像金属还是木板,是否透明,什么颜色。

纹理的基类是 Texture,通过给其属性 Image 传入一个图片从而构造出一个纹理。纹理是材质的属性,材质和几何体 Gemotry 构成 Mesh

材质基类 Material
  • transparent: 定义此材质是否透明,可以配合 opacity 使用,控制透明程度。
纹理基类(Texture)
  • colorSpace

    1.THREE.NoColorSpace: 这意味着没有应用任何特定的颜色空间,纹理的颜色数据会被原样使用。这个选项通常用于已经处于需要的颜色空间中的纹理,或者那些不依赖于颜色空间的特定用途。

    2.THREE.SRGBColorSpace:在此颜色空间中,颜色数据以 SRGB 格式存储。SRGB 是一个 RGB 标准,它试图将色彩的表现和人眼感知到的颜色更好地匹配。相对于线性颜色空间,SRGB 颜色空间在暗区提供了更多的颜色级别。使用此颜色空间时,需要注意图像的颜色可能会被转换为非线性的 SRGB 格式。

    3.THREE.LinearSRGBColorSpace 这也是一个以 SRGB 格式存储颜色数据的颜色空间,但颜色数据被当作线线性性数据处理。在进行计算和处理时,这种颜色空间可以提供更精确的结果。但是由于人眼对光线的感知,50%感觉的亮度只需要 18%的发光强度,这可能导致物体颜色过浅。

  • needsUpdate 指定需要重新编译材质。如果动态设置纹理需要将此属性设置为true

基础材质 MeshBasicMaterial
  • map: 颜色贴图。默认会随几何体的大小拉伸。 开启 transparent 属性,会影响有透明通道图片的效果
MeshMatcapMaterial

由一个材质捕捉(MatCap,或光照球(Lit Sphere))纹理定义。mapcap 编码了光照,颜色等信息,因此不对光照做出反应。可以投射阴影,但是不会接受阴影。
是一个低成本实现光照效果得材质,缺点是固定了光照信息,不能对光照反应。

MeshLambertMaterial

该材质使用基于非物理的 Lambertian 模型来计算反射率。 这可以很好地模拟一些表面(例如未经处理的木材或石材),但不能模拟具有镜面高光的光泽表面(例如涂漆木材)

MeshPhongMaterial

该材质使用非物理的 Blinn-Phong 模型来计算反射率。可以模拟高光的或玻璃效果,但是由于不是物理模型,因此玻璃效果不能与场景中其他物体作用,只能通过透明度设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 实现玻璃效果,只限于与环境光线相互作用
// 加载环境贴图是必须的,映射模式需要修改为折射
envMap.mapping = THREE.EquirectangularRefractionMapping;
// scene.environment = envMap;
// scene.background = envMap;

const box = new THREE.BoxGeometry(1, 1, 1);
const bujianMatertal = new THREE.MeshPhongMaterial({
refractionRatio: 0.7, // 空气的折射|反射率
// 当设置为 THREE.EquirectangularRefractionMapping 值为折射系数
// 越高越像玻璃
reflectivity: 0.99,
envMap: envMap,
});

const boxMesh = new THREE.Mesh(box, bujianMatertal);

// 必须要有环境光,与环境光相互作用
const light = new THREE.AmbientLight(0xffffff, 1);
scene.add(light);

FOG 雾

通常会把背景颜色和雾的颜色设置成相同颜色,可以让雾融入到场景中

镭射光纤,选择物体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
window.addEventListener("pointermove", onPointerMove);
function onPointerMove(event) {
// 将鼠标位置归一化为设备坐标。x 和 y 方向的取值范围是 (-1 to +1)
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
}

function rayTest() {
raycaster.setFromCamera(pointer, camera);

const intersects = raycaster.intersectObjects(mesharr);
if (intersects.length === mesharr.length) return;

// intersects 是一个按深度排序的数组
for (let i = 0; i < intersects.length; i++) {}
}

function animate() {
rayTest();
//...
}

补间动画

THREE.js 集成了 Tween 动画库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { Tween, Easing } from "three/examples/jsm/libs/tween.module.js";

const tween = new Tween(mesh.position);
// 移动到某个位置
tween.to({ x: 2 }, 1000);
// 位置更新时回调
tween.onUpdate(() => {
// console.log(mesh.position.x);
});
//重复次数
tween.repeat(10);
// 是否往返
tween.yoyo(true);
// 是否延迟
tween.delay(1000);

// 设置缓动
tween.easing(Easing.Bounce.In);
tween.start();

// 动画链
// tween.chain(tween2);
// tween2.chain(tween);

// 更新动画
function animate() {
tween.update();
}

FocalBoard 私有化部署

最后更新

2024-12-13

官方安装包

安装 官方包, [官方文档]

1
2
3
wget https://github.com/mattermost/focalboard/releases/download/v0.15.0/focalboard-server-linux-amd64.tar.gz
tar -xvzf focalboard-server-linux-amd64.tar.gz
sudo mv focalboard /opt

安装数据库

1
sudo apt install postgresql postgresql-contrib

以 postgres 用户创建一个新的数据库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
sudo --login --user postgres
psql

CREATE DATABASE <your-db-user>;
CREATE USER <your-db-user> WITH PASSWORD <'your-password'>;

# 查看schema列表
\dn

# 查看用户列表
\du

# 查看数据库列表

\l+

# 赋予用于操作数据库的所有权限
GRANT ALL PRIVILEGES ON DATABASE <your-db> TO <your-db-user>;

GRANT ALL ON SCHEMA public TO <your-db-user>;

\c <your-db-user> postgres
# You are now connected to database "your-db-user" as user "postgres".

# 赋予有用操作public schema的权限
GRANT ALL ON SCHEMA public TO <your-db-user>;

# 退出 postgres 用户
\q

exit

配置链接信息

1
2
3
4
vi /opt/focalboard/config.json

"dbtype": "postgres",
"dbconfig": "postgres://your-db-user:your-db-password@localhost/boards?sslmode=disable&connect_timeout=10"

启动服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sudo vi /lib/systemd/system/focalboard.service

[Unit]
Description=Focalboard server

[Service]
Type=simple
Restart=always
RestartSec=5s
ExecStart=/opt/focalboard/bin/focalboard-server
WorkingDirectory=/opt/focalboard

[Install]
WantedBy=multi-user.target
1
2
3
4
5
sudo systemctl daemon-reload
sudo systemctl start focalboard.service
sudo systemctl enable focalboard.service

curl localhost:8000

nginx 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
server {
listen 443;
listen [::]:443;
server_name focal.iftrue.me;

location ~ /ws/* {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
client_max_body_size 50M;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Frame-Options SAMEORIGIN;
proxy_buffers 256 16k;
proxy_buffer_size 16k;
client_body_timeout 60;
send_timeout 300;
lingering_timeout 5;
proxy_connect_timeout 1d;
proxy_send_timeout 1d;
proxy_read_timeout 1d;
proxy_pass http://192.168.48.148:8000;
}

location / {
client_max_body_size 50M;
proxy_set_header Connection "";
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Frame-Options SAMEORIGIN;
proxy_buffers 256 16k;
proxy_buffer_size 16k;
proxy_read_timeout 600s;
proxy_cache_revalidate on;
proxy_cache_min_uses 2;
proxy_cache_use_stale timeout;
proxy_cache_lock on;
proxy_http_version 1.1;
proxy_pass http://192.168.48.148:8000;
}
}

FAQ

  • error [2025-03-17 04:28:23.400 Z] Table creation / migration failed caller=”sqlstore/sqlstore.go:75” error=”pq: permission denied for schema public”

    参照数据库配置步骤,需要赋予用户操作 public schema 的权限

Git 命令/配置

查看 git 详细命令描述

1
2
# 会在浏览器打开本地离线文档
git help [option]

查看仓库配置文件

查看本地仓库配置

1
2
cd [项目路径]
cat ./git/config

查看全局仓库配置

1
git config --global -l

设置用户名/邮箱

用户信息会体现在 commit 提交信息中

设置本地用户

1
2
git config user.name "xxx"
git config user.email "xxx@.com"

设置全局用户

1
2
git config --global user.name "xxx"
git config --global user.email "xxx@.com"

如果本地用户没有设置会优先使用全局的用户设置, 如果存在多个用户可以设置本地用户信息

git add 做了什么

执行 git add . 会将工作区的文件加入到暂存区,体现在 git objects 中会增加一个 .git/objects/[文件sh1前2位]/[文件sh1第3位到40位]] 的文件。[sha1] 的计算逻辑是:

1
2
3
h = sha1()
h.update("blob [文件长度]\0")
h.update('[文件内容]')

可以使用 cat-file 命令查看文件的 类型和内容,可以看见用户文件的类型的 blob,这是第一种 object 类型

1
2
3
4
5
6
7
8
9
10
git cat-file -t [sha1]  #查看类型
git cat-file -p [sha1] #查看内容
git cat-file -s [sha1] #查看文件长度

#查看当前目录有所文件的信息
git ls-files -s

# 100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0 a/aa.js
# 文件权限 对象名称 文件名称

解析 objects 文件的原始内容

1
2
3
4
5
6
7
8
9
10
11
12
const fs = require("fs");
const zlib = require("zlib");

// 创建解压流
const inflate = zlib.createInflate();
const readStream = fs.createReadStream(
"./c6dd0164fe0eb4fde767f9e731a6c8ade0b69f"
);
const writeStream = fs.createWriteStream("bbb.txt");

// 处理数据流
readStream.pipe(inflate).pipe(writeStream);

git commit 做了什么

查看执行 commit 之后新增文件的类型为 commit,commit 信息通过 object 文件保存,这是第二种 object 类型

1
git cat-file -t [sha1] #commit

查看 commit 后生成文件的内容

1
2
3
4
5
6
7
8
git cat-file -p [sha1]

#tree de9ca8d60d61e2dbff58571e243256f0d084e030 tree对象文件名字(sha1)
#parent ef911d46ddddb69cfb0149bbc7217f582c668f21 上一次提交的文件名字(sha1)
#author sunzhiqi <sunzhiqi@live.com> 1724649175 +0800 作者 邮箱 时间戳 时区
#committer sunzhiqi <sunzhiqi@live.com> 1724649175 +0800

#2 提交的文件内容

继续查看 tree 文件的内容, tree 类型描述提交的文件结构,这是第三个 object 类型

1
2
3
4
5
6
7
8
9
git cat-file -p [sha1]

#100644 blob b44836b41abbfc0640d4dd88fe587a9a145e5203 LICENSE
#100644 blob b9c2b056b82135218b26420e8479c56554b54afb README.md
#100644 blob 4b9a8a29f6956ea1742e42354c5ba36fe717a2ed a.txt
#100644 blob 9f478040b9109d4b1d25ad7f11528ed9a682f063 b.txt

# 如果提交内容中有文件夹,那么会有一个单独的 tree 描述文件夹的信息
#100644 tree xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx folder

另外 commit 操作会影响 HEAD 指针的指向

1
2
3
4
5
6
# 查看HEAD指向的分支
cat .git/HEAD #ref: refs/heads/master

# 查看分支对应的文件

cat .git/refs/heads/master #ref: f45e2d0b45ca63806d263ae31276a3fbe4a7229d

Git 通过 3 种类型的 object files 组织文件

  • commit 类型,记录了提交的快照信息
    提供 parent 指向了上一个提交 hash
    提供 tree 指向了本次提交的快照

  • tree 类型,记录了目录结构
    每次提交都会生成一个新的 tree 文件
    tree 文件记录了目录下的文件信息,可能是 blob 文件,也可能是另一个 tree

  • blob 类型,记录了文件内容
    blob 文件可以在不同的 tree 之间被复用

文件状态流转

  • Untracked 未跟踪, 未被提交到暂存区
    使用 git add . 将文件加入到暂存区

  • Modified 已修改,提交暂存区的文件在工作区被修改了
    用 git add . 将文件提交修改到暂存区

  • Staged 已暂存,提交到暂存区的文件
    git commit 将暂存区的文件提交到仓库区

  • Unmodified 代码仓库中的状态

diff 查看文件差异

1
2
3
4
5
# 对比工作区和暂存区
git diff

# 对比暂存区和仓库区
git diff --cached

branch operations

分支的本质是指向 commit 的指针, 使用自定义名称代指 commit hash

git checkout 可以用于切换分支,也可以用于切换到某个 commit, 当使用这个命令后会提示正在处于一个分离头指针的状态。

1
git checkout [hash]

如果相对当前 commit 修改需要依附于一个分支,可以使用 git switch -c [branchName], 在旧的版本中可以使用 git checkout -b [branchName]

fast-forward

从 master 切换到 feature 分支,如果 master commit 没有变化, 那么可以直接将 master 的指针指向 feature

如果分支已经合并,可以使用 git reset ORIG_HEAD 回到 merge 之前的 commit

commit 回滚后修改的文件会在本地显示为 untracked 或 modified 也就是在 feature 分支中修改或新增的文件。

如果需要丢弃这些修改可是使用 git restore [filename]git restore . 丢弃所有修改。

如果有添加在暂存区的文件,可以使用 git restore --staged . 丢弃暂存区的修改

3 way merge

如果 feature 分支落后于 master 分支,也就是在 feature 分支上提交了 commit 的同时, master 分支也提交了 commit, 那么在合并的时候会产生一个新的 commit 记录这两个分支的合并。查看这次新分支的提交内容

1
2
3
4
5
6
7
8
git cat-file -p 4ba28b350b0a8f0b

tree 444ca23d83cc70878ddb01e5288a4322ded5ac0c
parent 57086086b9bd92402227d58e7c6b87e285376910
parent 720f3a44023361a4bad5586fe769ccc50f1bc1ab
author sunzhiqi <sunzhiqi@live.com> 1744018132 +0800
committer sunzhiqi <sunzhiqi@live.com> 1744018132 +0800

可以看到有两个 parent 指向,分别是 feature 最新的 commit hash 和 master 分支的合并之前的 commit hash。

conflict 3 way merge

如果两个分支都修改了同一个文件,那么在合并的时候会产生冲突。

1
2
3
4
5
<<<<<< HEAD
master 的内容
=======
feature 的内容
>>>>>> feature

手动解决冲突后使用 git add . 提交暂存区, 然后执行 git commit 完成合并操作

rebase

rebase 会将当前分支落后的于 master 分支的 commit 整合到当前分支,这样当前分支包含了 master 分支的最新 commit, 所以再次在 master 分支上执行 merge 操作就可以实现 fast-forward 合并。

rebase 操作通常避免在 master 分支上执行,并且会修改已经提交的 commit hash,这可能会导致于已经提交到远程的 commit 冲突,多人协作中应该禁止。

1
2
3
4
git checkout feature
git rebase master
git checkout master
git merge feature

remote branch

1
2
3
4
5
6
git branch -r  # 查看远程分支
git branch -a # 查看本地和远程分支

git remote add origin [url] # 关联远程仓库
git remote -vv # 查看远程仓库
git remote show origin # 查看远程仓库详情
fetch

fetch 操作会将远程分支的最新 commit 信息下载到本地,但是不会自动合并到当前分支,也就是不会移动 HEAD 指针。

如果远程分支删除,需要本地同步可以使用以下命令:

1
2
3
4
git fetch --prune

# 或者
git remote prune

fetch 操作会使用一个非常重要的文件,**.git/FETCH_HEAD**, 它记录所有远程分支的最新 commit 信息。并且当 git fetch 执行的时候会将当前分支信息置于文件顶部,其余的分支信息排在后面,这样 git pull 命令会利用最前面的分支信息合并分支

pull

git pull 操作会修改/创建 ORIG_HEAD 指针,并且移动 HEAD 指向 merge 之前的 commit 可以用于回滚操作。

push
1
2
3
4
5
6
7
8
9
10
11
12
13
14
git push origin master

# 删除远程分支
git push origin --delete feature
git push origin -d feature

# 推送本地标签到远程
git push origin --tags

# 关联远程分支
git branch --set-upstream-to=origin/feature feature

git branch --set-upstream origin feature
git branch -u origin feature
cherry-pick

当分支无法合并,需要把某个分支的 commit 合并到另一个分支,可以使用 cherry-pick。

1
2
3
4
5
# 切换到目标分支
git cherry-pick [commitID]

# 合并多个 commit
git cherry-pick [commitID1] [commitID2]

tag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
git tag v1.0

# 创建带注释的标签 -a 表示创建带注释的标签
git tag -a v1.0 -m 'tag message'

# 指定任意commit打标签
git tag -a v1.0 [commitID]

# 查看标签
git tag

# 查看标签详情
git show v1.0

# 删除标签
git tag -d v1.0

git tag v1.0 创建轻量标签存放在 .git/refs/tags 目录下

git tag -a v1.0 -m 'tag message 除了保存在 .git/refs/tags 目录下,还会创建一个对象文件保存在 .git/objects 下,类型是 tag, 这是第四种类型的 object,同时保存信息还包括注释,时间,作者

submodule

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 添加子模块
git submodule add [url]
git submodule add ./path/to/submodule

# 首次 clone 项目不会包含子模块的内容
# 进入到子模块目录 执行
git submodule update --init --recursive

# 再次更新子模块
git submodule update --remote


# 删除子模块
git rm submodule
rm -rf .git/modules/submodule

# 从 .gitmodules 中移除该 submodule
vim .gitmodules

# 从 .git/config 中移除该 submodule
vim .git/config

我提交了什么

1
2
git show
git log -n1 -p

想修改最近的提交信息

提交信息后(commit),发现信息写错了,想修改最近一条提交信息(如果想修改当前提交以前的信息)。

1
2
3
4
git commit --amend --only [filename] -m 'update'

# 会弹出交互式命令行手动提交
git commit --amend --only [filename]

--only 参数非常重要,只对当前正在修改的文件进行提交,而不包括暂存区中的其他文件。

怎么看是否落后与主分支

1
2
git fetch origin
git status
1
2
3
4
5
6
7
8
9
10
11
12
13
# oneline  提交显示为一行
# graph 左侧显示一个 ASCII 图形,用于展示分支和合并的关系
# decorate 提交信息中附加上相关的分支、标签等引用名称

# 查看远程分支所有提交历史
git log --oneline --graph --decorate origin/master

# 查看所有分支的提价历史
git log --graph --all

# 指定分支

git log --graph origin/master master

从分支上可以看出,主分支并没有 fff 的提交,最终合并在本地的 master 分支

取消上次提交中的某个文件

一次提交中包括, a.txt, b.txt 现在想取消 a.txt 的提交,也就是恢复为上一次的状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 查看上一次提交文件的内容
git show [commitID]:a.txt

# 将工作区中的文件恢复为上一次提交的内容
git checkout HEAD^ a.txt

# 也可以使用 commitId
git checkout [commitId] a.txt

# 将恢复的文件加入暂存区 -A 参数可以将删除文件也从暂存区删除
git add .

# 提交修改到暂存区,沿用上一次的ID,并且不修改提交描述
git commit --amend --no-edit

如果当前的的历史已经提交到远程,由于并没有改变提交历史,所以可以安全的 git push -f

删除 上一次/多个 Commit

极不建议使用,虽然可以用 reset 重置为上一级的状态,但是会丢失当前的修改,而且会破坏提交历史。

1
2
3
# HEAD^ 上一个提交状态
# hard 改变暂存区和工作目录到指定的提交
git reset HEAD^ --hard

使用 soft 参数可以避免工作区被回滚,但提交历史已经被改变,如果你的分支还会被其他人使用,多人写作中会有安全问题。避免在公共分支中使用 git reset,

1
git reset HEAD^ --soft

对于公共分支唯一安全的做法是使用 revert

1
2
3
4
5
git revert [commitId]

# 如果有冲突,解决冲突后执行
git add .
git revert --continue

修改 commit 之后不能 push

  • 可能你已经将历史 push 到远程,但是又修改了本地的最近的 commit, 导致 git 理解为你的 commit 和 远程的不同需要先合并分支。
    尽可能避免修改一个已经推送到远程的历史,如果必须要这样做,只能使用 git push -f

reset 之后想找回

1
2
3
# 使用reflog查看头指针的移动历史
git reflog
git reset --hard [commitId]

提交文件的一部分/分别提交到两个 commit

1
2
3
4
5
6
7
8
9
10
# 使用交互式命令行操作
git add -p [filename]

#y - 将该 hunk 添加到暂存区
#n - 不将该 hunk 添加到暂存区
#s - 拆分当前 hunk 成更小的块
#e - 手动编辑当前 hunk
#q - 退出暂存模式
#a - 将当前文件的所有剩余 hunk 添加到暂存区
#d - 不将当前文件的任何剩余 hunk 添加到暂存区

使用 e 手动编辑的模式时:

  • 如果想要取消删除行的修改,需要将行前的 - 替换为 空字符
  • 如果想要取消新增行的修改, 需要将 + 行整体删除

添加到暂存区中后执行 commit 操作,接着再次执行一次 git add . 提交剩余的修改。

有临时工作又不想提交当前的修改

1
2
3
4
5
6
7
8
9
10
11
12
# 使用 stash 命令暂存当前修改
git stash
git stash push -m "描述" #为stash添加描述

# 切换到需要工作的分支进行开发
git checkout [branch]
# 在当前分支中想要合并最新的主分支,也可以使用暂存功能
git pull

# 工作结束后切换回原来的分支,并从暂存中取出内容
git checkout [main branch]
git stash pop

仅 stash 未暂存的更改,而保留暂存区的内容

1
2
3
4
5
6
# 已经git add 的内容不想放到stash中
# 只把刚在工作区修改的内容放到stash中
git stash --keep-index

# 如果有文件还没有git add添加过,想要stash这些文件
git stash -u

微前端 ④ qiankun

什么是 qiankun

qiankun 是一个微前端的解决方案,对 single-spa 进行了封装。

执行流程

CSS 沙箱如何实现

strictStyleIsolation 严格模式, 通过 ShadowDOM 实现,将子应用最外层的元素升级为 ShadowDOM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var containerElement = document.createElement("div");
containerElement.innerHTML = appContent;
var appElement = containerElement.firstChild;
if (strictStyleIsolation) {
if (supportShadowDOM) {
var innerHTML = appElement.innerHTML;
appElement.innerHTML = "";
var shadow;
if (appElement.attachShadow) {
shadow = appElement.attachShadow({
mode: "open",
});
} else {
shadow = appElement.createShadowRoot();
}
shadow.innerHTML = innerHTML;
}
}

另一种是开启了 experimentalStyleIsolation 实验性沙箱配置,原理是尝试通过遍历 sheet 样式表的每一条样式,为每条样式添加一个私有化的选择器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// 给子应用的最外层元素设置一个私有属性,用于添加选择器
// css.QiankunCSSRewriteAttr 默认私有化属性 data-qiankun
// appInstanceId 子应用的 name 属性
appElement.setAttribute(css.QiankunCSSRewriteAttr, appInstanceId);

// 创建私有化的选择器 prefix =>div[data-qiankun="setting"]
var prefix = ""
.concat(tag, "[")
.concat(QiankunCSSRewriteAttr, '="')
.concat(appName, '"]');

// 获取并便利所有的 style 标签
var styleNodes = appElement.querySelectorAll("style") || [];
_forEach(styleNodes, function (stylesheetElement) {
// 创建新的style标签,并将当前style中的样式复制到新标签中
var styleNode = document.createElement("style");
styleNode.appendChild(
document.createTextNode(stylesheetElement.textContent || "")
);

// 获取 CSSStyleSheet 对象,并将 cssRules 转为数组, 遍历并重写规则
var sheet = styleNode.sheet;
var rules = arrayify(sheet.cssRules);
var css = "";
rules.forEach(function (rule) {
switch (rule.type) {
case RuleType.STYLE:
/**
1 对于 html body :root 几个根元素
会直接用 prefix 替换掉,防止对基座应用样式污染

2 html + body 格式的演示qiankun认为是非标准的样式不做处理

3 html > body html body 性质的选择器会将 html 替换为空字符串

4 检查 a,html or *:not(:root) 根元素前插入了其他字符的选择器
同样需要替换为 a,[prefix] *:not([prefix]) 的形式

5 其他选择器会在选择器前添加前缀
[prefix] .text {}

最后拼接字符串
*/

css += ruleStyle(rule, prefix);
break;
case RuleType.MEDIA:
case RuleType.SUPPORTS:
/**
媒体查询 @media screen and (max-width: 300px) {}
能力检测 @supports (display: grid) {}

会递归调用重写方法,将中间的选择器重写

*/
css += ruleStyle(rule, prefix);
break;
}
});
});

js 沙箱

快照沙箱

如果平台不支持 Proxy ,则使用快照沙箱。 通过记录 window 对象上的属性变化,会复或保存状态。

这种方式的沙箱只关注应用激活和卸载时的差异,并不能控制属性的来源,因为是在全局 window 对象上进行操作,所以全部应用的对全局属性的操作,都会反映在 window 对象上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
function iter(win, cb) {
for (const k in win) {
if (win.hasOwnProperty(k) || k === "clearInterval") {
cb(k);
}
}
}

class SnapshotSandBox {
deleteProps = new Set();
modifyPropsMap = {};
proxy = window;
snapshot = {};

active() {
this.snapshot = {};
// 激活时记录当前的window快照,不关心 window 上是否有其他应用的属性。
// 只关心本次激活和卸载时属性的变化, 就是子应用的属性变化。
iter(window, (k) => {
this.snapshot[k] = window[k];
});

// 之前卸载时检测出修改或添加的属性,本次激活时要添加回来
// 虽然添加和修改的属性都是在 window 上,但是应用卸载后可能被其他应用删除或重写
// 这里使用记录值恢复之前的状态

Object.keys(this.modifyPropsMap).forEach((k) => {
window[k] = this.modifyPropsMap[k];
});

// 删除卸载时记录的删除值
// 同样因为其他同框架的子应用可能会添加相同属性

this.deleteProps.forEach((k) => {
delete window[k];
});

this.sandboxRunning = true;
}

inactive() {
this.deleteProps.clear();
this.modifyPropsMap = {};

// 和刚激活应用时的快照对比,检查新增或修改的属性
iter(window, (k) => {
if (window[k] !== this.snapshot[k]) {
this.modifyPropsMap[k] = window[k];
}
});

// 快照中有,但是当前 window 上没有,那么属性被删除

iter(this.snapshot, (k) => {
if (!window.hasOwnProperty(k)) {
this.deleteProps.add(k);
// 恢复环境, 应用中删除的属性,退出应用是需要还原
window[k] = this.snapshot[k];
}
});
this.sandboxRunning = false;
}
}

由于快照沙箱,对属性的操作都是在 window 上,因此多子应用的时候无法隔离子应用的状态,会导致冲突。只能适用于单实例。

proxy 沙箱
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
const createFakeWindow = (globalContext, speed) => {
const fakeWindow = {};
const propertiesWithGetter = new Map();
// 获取 window 静态属性
Object.getOwnPropertyNames(globalContext)
.filter((p) => {
// 属性描述符
// configurable 为假表示不能 修改,不能删除

const dec = Object.getOwnPropertyDescriptor(globalContext, p);
return dec?.configurable;
})
.forEach((p) => {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
if (descriptor) {
const hasGetter = Object.prototype.hasOwnProperty.call(
descriptor,
"get"
);

// FAQ window 引用属性,为什么需要要修改属性描述符
if (
p === "self" ||
p === "window" ||
p === "parent" ||
p === "top" ||
(speed && p === "document")
) {
descriptor.configurable = true;

// 属性访问器和, writable 属性描述符不能同时指定
if (!hasGetter) {
descriptor.writable = true;
}
}

// 记录所有所有访问器得属性
if (hasGetter) propertiesWithGetter.set(p, true);

// FAQ 所有 configurable 为假得属性,就一定是环境级别属性属性么
Object.defineProperties(fakeWindow, p, Object.freeze(descriptor));
}
});
// Object.keys(window).forEach(k=>{
// if( has)
// const propDescriptor = Object.getOwnPropertyDescriptor(window,k);
// c
// })
};

let activeSandboxCount = 0;

class ProxySandBox {
sandboxRunning = true;
activeSandboxCount = 0;
document = document;
constructor(name, globalContext = window, opts) {
this.name = name;
this.globalContext = globalContext;
this.type = "proxy";
const { updatedValueSet } = this;
const { speedy } = opts || {};
const { fakeWindow, propertiesWithGetter } = createFakeWindow(
globalContext,
!!speedy
);

const proxy = new Proxy(fakeWindow, {
get(target, p) {
// this.registerRunningApp()

// 处理对一些特殊属性值得处理

//Symbol.unscopables 提供了一种机制,允许对象指定哪些属性在 with 语句中不应该被暴露为局部变量。如果一个属性在对象的 Symbol.unscopables 列表中,它将不会出现在 with 语句的作用域中。
// const obj = {
// a: 1,
// b: 2,
// [Symbol.unscopables]: {
// b: true // 使 'b' 在 with 语句中不可见
// }
// };

if (p === Symbol.unscopables) return unscopables;
if (p === "window" || p === "self" || p === "globalThis") {
return proxy;
}

// 如果获取得上级 window, 允许逃逸
if (p === "top" || p === "parent") {
if (globalContext === globalContext.parent) {
return proxy;
}
return globalContext[p];
}

if (p === "hasOwnProperty") {
// proxy.hasOwnProperty.call({a},'a')
return function hasOwnProperty(key) {
if (this !== proxy && this !== null && typeof this === "object") {
return Object.prototype.hasOwnProperty.call(this, key);
}

// proxy.hasOwnProperty("a")
return (
fakeWindow.hasOwnProperty(key) ||
globalContext.hasOwnProperty(key)
);
};
}

if (p === "document") {
return this.document;
}

if (p === "eval") {
return eval;
}
// customProp {configurable:false} => {configurable:true}
const actualTarget = propertiesWithGetter.has(p)
? globalContext
: p in target
? target
: globalContext;
const value = actualTarget[p];

// 校验了是不是frozen的属性, 如果是需要直接返回
// propertyDescriptor.configurable === false
// && (propertyDescriptor.writable === false || (propertyDescriptor.get && !propertyDescriptor.set)),
if (isPropertyFrozen(actualTarget, p)) {
return value;
}
// 非原生全局属性直接返回 addEventListener
// isNativeGlobalProp 枚举了 所有的全局属性
// useNativeWindowForBindingsProps 记录所有执行时需要绑定原生window方法 [fetch,true]
if (!isNativeGlobalProp(p) && !useNativeWindowForBindingsProps.has(p)) {
return value;
}

// 如果不处理 fetch.bind(this) 实际时绑定的proxyWindow 导致报错
const boundTarget = useNativeWindowForBindingsProps.get(p)
? nativeGlobal
: globalContext;

// 仅绑定 isCallable && !isBoundedFunction && !isConstructable 的函数对象,如 window.console、window.atob 这类,不然微应用中调用时会抛出 Illegal invocation 异常
// 目前没有完美的检测方式,这里通过 prototype 中是否还有可枚举的拓展方法的方式来判断
// @warning 这里不要随意替换成别的判断方式,因为可能触发一些 edge case(比如在 lodash.isFunction 在 iframe 上下文中可能由于调用了 top window 对象触发的安全异常)
const rebindTarget2Fn = (target, fn) => {
const isCallable = (fn) =>
typeof fn === "function" && fn instanceof Function;
// bind 函数的名称会以 bound开头 "bound fn"
const isBoundedFunction = (fn) =>
fn.name.indexOf("bound ") === 0 && !fn.hasOwnProperty("prototype");

const isConstructable = () => {
const hasPrototypeMethods =
fn.prototype &&
// class有constructor属性
fn.prototype.constructor === fn &&
// 构造器需要有原型链
Object.getOwnPropertyNames(fn.prototype).length > 1;
if (hasPrototypeMethods) return true;
// 假设以下视为构造函数
// 1. 有 prototype 并且 prototype 上有定义一系列非 constructor 属性
// 2. 函数名大写开头
// 3. class 函数

let constructable = hasPrototypeMethods;
if (!constructable) {
const fnString = fn.toString();
const constructableFunctionRegex = /^function\b\s[A-Z].*/;
const classRegex = /^class\b/;
constructable =
constructableFunctionRegex.test(fnString) ||
classRegex.test(fnString);
}

return constructable;
};

if (
isCallable(fn) &&
!isBoundedFunction(fn) &&
!isConstructable(fn)
) {
const boundValue = Function.prototype.bind.call(fn, target);

// 拷贝原有方法的静态属性
Object.getOwnPropertyNames(fn).forEach((key) => {
// boundValue might be a proxy, we need to check the key whether exist in it
if (!boundValue.hasOwnProperty(key)) {
Object.defineProperty(
boundValue,
key,
Object.getOwnPropertyDescriptor(fn, key)
);
}
});

// 如果原方法 prototype 属性设置了不可枚举,会导致原型链丢失
// 赋值的时候不能使用 = 或 Object.assign, 因为赋值操作会向上查询原型链
// 如果描述符被设 writable =false 或没有 set 属性访问器,会抛出错误
// Cannot assign to read only property 'prototype' of function
if (
fn.hasOwnProperty("prototype") &&
!boundValue.hasOwnProperty("prototype")
) {
Object.defineProperty(boundValue, "prototype", {
value: fn.prototype,
enumerable: false,
writable: true,
});
}

// 有一些库会使用 /native code/.test(fn.toString()) 检测是不是原生的方法
// 如果不特殊处理,所有toString 返回的都是 [object fakeWindow] 或 [object Window]]
if (typeof fn.toString === "function") {
const valueHasInstanceToString =
fn.hasOwnProperty("toString") &&
!boundValue.hasOwnProperty("toString");
const boundValueHasPrototypeToString =
boundValue.toString === Function.prototype.toString;

if (valueHasInstanceToString || boundValueHasPrototypeToString) {
// valueHasInstanceToString? 有自定义的 toString 使用原方法
// : 使用原生的 toString
const originToStringDescriptor =
Object.getOwnPropertyDescriptor(
valueHasInstanceToString ? fn : Function.prototype,
"toString"
);

Object.defineProperty(
boundValue,
"toString",
Object.assign(
{},
originToStringDescriptor,
originToStringDescriptor?.get
? null
: { value: () => fn.toString() }
)
);
}
}
}
};
return rebindTarget2Fn(boundTarget, value);
},
});
}

active() {
if (!this.activeSandboxCount) activeSandboxCount += 1;
this.sandboxRunning = true;
}
inActive() {
this.sandboxRunning = false;
}
patchDocument(doc) {
this.document = doc;
}
}

nginx 证书自动续期

目标使用 acme.sh 提供的自动化工具实现证书自动续期。

updete 2025-11-05

安装, 参考 acme.sh 官方文档

自动 DNS Api 集成,准备对应 DNS 厂商的 认证信息

注意 腾讯云 DNS 认证需要使用 DnsPod token, 而不是 Api key

设置 DNS 厂商的环境变量, 以腾讯云为例:

1
2
export DP_Id="id"
export DP_Key="token"

执行生成证书命令

1
2
3

# 注意根域名必须填写,不填写自动化部署的时候会找不到路径
/root/.acme.sh/acme.sh --issue  --dns dns_dp -d '*.iftrue.me' -d 'iftrue.me' 

证书创建成功后使用自动化部署命令可以实现自动续期

1
2
# 使用以下命令,自动部署到 nginx 文件路径,在到期前会自动续期
/root/.acme.sh/acme.sh --install-cert -d *.iftrue.me  --key-file /etc/nginx/conf.d/_club.key --fullchain-file /etc/nginx/conf.d/_club.crt --reloadcmd "service nginx reload"

域名授权/申请证书 (域名厂商)

整个过程参考 ACME 自动化快速入门

https://freessl.cn/ 选择 ACME 自动化 注册账号并登录

首先添加域名并在购买的云服务器上验证,验证通过后申请证书。

选择相关域名后会提示申请证书命令。 例如:

1
bash /root/acme.sh/.acme.sh --issue -d <你的域名> --dns dns_dp --server https://acme.freessl.cn/v2/DV90/directory/xxxx-xxx

找到合适的目录执行脚本,证书文件会在当前执行目录中生成。 需要注意 .acme.sh 的路径是否可以访问, 如果无法找到该命令,可以切换为相对根路径的地址。

执行后会生成证书文件,如下

1
2
3
4
[Thu Aug  1 22:23:17 CST 2024] Your cert is in: /root/.acme.sh/home.iftrue.me_ecc/home.iftrue.me.cer             # 证书文件 对应 nginx 中 cert.pem
[Thu Aug 1 22:23:17 CST 2024] Your cert key is in: /root/.acme.sh/home.iftrue.me_ecc/home.iftrue.me.key # 私钥文件 对应 nginx 中 cert.key
[Thu Aug 1 22:23:17 CST 2024] The intermediate CA cert is in: /root/.acme.sh/home.iftrue.me_ecc/ca.cer
[Thu Aug 1 22:23:17 CST 2024] And the full-chain cert is in: /root/.acme.sh/home.iftrue.me_ecc/fullchain.cer

自动续期

执行 ACME 自动化快速入门中提示的脚本, 执行命令的位置需要在生成证书的路径下。

1
2
3
4
bash /root/acme.sh/.acme.sh --install-cert -d <你的域名>
--key-file /etc/nginx/conf.d/cert.key # nginx 配置路径中证书的路径
--fullchain-file /etc/nginx/conf.d/cert.pem # nginx 配置路径中私钥的路径
--reloadcmd "nginx -s reload" # 重启 nginx 命令

acme.sh 会在证书还有 30 天到到期时尝试自动续期。

微前端 ② single-spa

single-spa 是一个将多个 JavaScript 微前端整合到一个前端应用程序中的框架。下面是一些优点:

  • 在同一个页面上使用多个框架而不刷新页面
  • 独立部署微前端
  • 使用新框架编写代码,而无需重写现有的应用程序

single-spa 比较好的解决了以下问题:

  • 应用加载顺序和依赖管理
  • 路由管理
  • 版本管理和升级
  • 调试和监控

依赖管理

single-spa 通过 systemjs 实现了应用资源的依赖管理

webpack 配置

微前端应用需要适当改造,将输出的模式更改为 systemjs, 并且实现 single-spa 指定的接口。single-spa 官方提供了配置工具,用来降低配置的成本。

webpack-config-single-spa 用于输出 webpack 打包文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
output: {
// 输出格式改为 systemjs
libraryTarget: "system",
},
devServer: {
// 任意路径下都返回 spa 应用入口文件
historyApiFallback: true
// 允许对子应用的跨域调用
headers: {
"Access-Control-Allow-Origin": "*",
},
},
// 会增加一个入口文件,动态指定 __webpack_public_path__
// 避免子应用中异步加载的资源请求了主应用的路径
new SystemJSPublicPathPlugin(
//...
),
// 处理开发模式下独立启动子应用显示页面
!isProduction &&
new StandaloneSingleSpaPlugin({
//...
}),
};

前端项目配置

项目的入口需要使用 single-spa-react 改造, 帮助实现 single-spa 要求的生命周期函数。

1
2
3
4
5
6
7
8
9
10
const lifecycles = singleSpaReact({
React,
ReactDOM,
rootComponent: Root,
domElementGetter: () => document.querySelector("#aa"),
errorBoundary(err, info, props) {
// Customize the root error boundary for your microfrontend here.
return null;
},
});

下面是 singleSpaReact 的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const opts = {
/** 接收到的参数*/
};
const SingleSpaContext = opts.React.createContext();

opts.SingleSpaRoot = createSingleSpaRoot(opts);

// 将传入的新组件包装成新的 Root 组件

function createSingleSpaRoot() {
// 为了支持 React 15 所以 single-spa 并没有使用 useEffect
// 并且使用 函数 模拟 ES6 class 实现
function SingleSpaRoot() {}
SingleSpaRoot.prototype = Object.create(opts.React.Component.prototype);

// 绑定自定义钩子函数
SingleSpaRoot.prototype.componentDidMount = function () {
setTimeout(this.props.mountFinished);
};

// 绑定自定义钩子函数
SingleSpaRoot.prototype.render = function () {
return this.props.children;
};

opts.SingleSpaRoot = createSingleSpaRoot(opts);

const lifecycles = {
bootstrap: bootstrap.bind(null, opts),
mount: mount.bind(null, opts),
unmount: unmount.bind(null, opts),
};

// 暴露接口方法
return lifecycles;
}

实现 bootstrap 接口方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
function mount(opts, props) {
const whenMounted = function () {
resolve(this);
};

const elementToRender = getElementToRender(opts, props, whenMounted);

// 包装 rootComponent 传入额外的 props
function getElementToRender(opts, props, mountFinished) {
const elementToRender = opts.React.createElement(opts.rootComponent, props);

// 绑定 context
// ...

// 绑定 errorBoundary
// ...

elementToRender = opts.React.createElement(
opts.SingleSpaRoot,
{
...props,
mountFinished,
updateFinished() {
//...
},
unmountFinished() {
//...
},
},
elementToRender
);

return elementToRender;
}
const domElement = opts.domElementGetter();

if (opts.ReactDOMClient?.createRoot) {
opts.renderType = "createRoot";
} else {
opts.renderType = "render";
}
const renderResult = reactDomRender({
elementToRender,
domElement: opts.domElementGetter(),
reactDom: opts.ReactDOMClient || opts.ReactDOM,
renderType: opts.renderType,
});

// 挂载组件
function reactDomRender({
reactDom,
renderType,
elementToRender,
domElement,
}) {
const renderFn = reactDom[renderType];

switch (renderType) {
case "createRoot":
case "unstable_createRoot":
case "createBlockingRoot":
case "unstable_createBlockingRoot": {
const root = renderFn(domElement);
root.render(elementToRender);
return root;
}
case "hydrateRoot": {
const root = renderFn(domElement, elementToRender);
return root;
}
case "hydrate":
default: {
renderFn(elementToRender, domElement);
// The renderRoot function should return a react root, but ReactDOM.hydrate() and ReactDOM.render()
// do not return a react root. So instead, we return null which indicates that there is no react root
// that can be used for updates or unmounting
return null;
}
}
}

opts.domElements[props.name] = domElement;
opts.renderResults[props.name] = renderResult;
}

实现 unmount 接口方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function unmount(opts, props) {
return new Promise((resolve) => {
opts.unmountResolves[props.name] = resolve;

const root = opts.renderResults[props.name];

if (root && root.unmount) {
// React >= 18
const unmountResult = root.unmount();
} else {
// React < 18
(opts.ReactDOMClient || opts.ReactDOM).unmountComponentAtNode(
opts.domElements[props.name]
);
}
delete opts.domElements[props.name];
delete opts.renderResults[props.name];
});
}

实现 update 接口方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function update(opts, props) {
return new Promise((resolve) => {
if (!opts.updateResolves[props.name]) {
opts.updateResolves[props.name] = [];
}

opts.updateResolves[props.name].push(resolve);

const elementToRender = getElementToRender(opts, props, null);
const renderRoot = opts.renderResults[props.name];
if (renderRoot && renderRoot.render) {
// React 18 with ReactDOM.createRoot()
renderRoot.render(elementToRender);
} else {
// React 16 / 17 with ReactDOM.render()
const domElement = chooseDomElementGetter(opts, props)();
// This is the old way to update a react application - just call render() again
getReactDom(opts).render(elementToRender, domElement);
}
});
}

singleSpa 源码

  • 首先定义子应用加载数组,以及各个阶段的状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const apps = [];

const NOT_LOADED = "NOT_LOADED";
const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE";
const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED";
const BOOTSTRAPPING = "BOOTSTRAPPING";
const NOT_MOUNTED = "NOT_MOUNTED";
const MOUNTING = "MOUNTING";
const MOUNTED = "MOUNTED";
const LOAD_ERROR = "LOAD_ERROR";
const SKIP_BECAUSE_BROKEN = "SKIP_BECAUSE_BROKEN";

// 主应用启动状态
let isStart = false;
  • singleSpa 提供 registerApplication 方法用于注册子应用,记录子应用必要信息, 同时会执行 reroute 方法,如何路径匹配,会加载子应用, 但是不会渲染子应用,子应用渲染需要等待 start 方法执行 isStart 标记为 true 后才会挂载子应用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function registerApplication(appNameOrConfig) {
// app 必须是返回 promise 的函数
// customProps 必须是对象
appNameOrConfig.customProps = appNameOrConfig.customProps || {};
// activeWhen 是一个路径匹配函数的数组

appNameOrConfig.activeWhen = Array.isArray(appNameOrConfig.activeWhen)
? appNameOrConfig.activeWhen
: [appNameOrConfig.activeWhen];
appNameOrConfig.activeWhen = appNameOrConfig.activeWhen.map((path) =>
typeof path === "function" ? path : pathToActiveWhen(path)
);

apps.push({
loadErrorTime: null,
status: NOT_LOADED,
...appNameOrConfig,
});

reroute();
}
  • singleSpa 提供 start 方法用于在注册后启动主应用, 在 start 方法中重写路由监听事件,可以让 singleSpa 响应路由的变化。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
function start() {
isStart = true;
patchHistoryApi(opts);
reroute();
}

let urlRerouteOnly;
let originalReplaceState;
function createPopStateEvent(state, originalMethodName) {
// https://github.com/single-spa/single-spa/issues/224 and https://github.com/single-spa/single-spa-angular/issues/49
// We need a popstate event even though the browser doesn't do one by default when you call replaceState, so that
// all the applications can reroute. We explicitly identify this extraneous event by setting singleSpa=true and
// singleSpaTrigger=<pushState|replaceState> on the event instance.
let evt;
try {
evt = new PopStateEvent("popstate", { state });
} catch (err) {
// IE 11 compatibility https://github.com/single-spa/single-spa/issues/299
// https://docs.microsoft.com/en-us/openspecs/ie_standards/ms-html5e/bd560f47-b349-4d2c-baa8-f1560fb489dd
evt = document.createEvent("PopStateEvent");
evt.initPopStateEvent("popstate", false, false, state);
}
evt.singleSpa = true;
evt.singleSpaTrigger = originalMethodName;
return evt;
}

function patchedUpdateState(updateState, methodName) {
return function () {
const urlBefore = window.location.href;
const result = updateState.apply(this, arguments);
const urlAfter = window.location.href;

if (urlBefore !== urlAfter) {
// 手动触发 popstate 事件,让不同的子应用可以响应路由的变化。
window.dispatchEvent(
createPopStateEvent(window.history.state, methodName)
);
}
return result;
};
}

function patchHistoryApi() {
urlRerouteOnly = true;
originalReplaceState = window.history.replaceState;
function urlReroute() {
reroute([], arguments);
}
window.addEventListener("hashchange", urlReroute);
//调用 history.pushState() 或者 history.replaceState() 不会触发 popstate 事件。popstate 事件只会在浏览器某些行为下触发
//比如点击后退按钮(或者在 JavaScript 中调用 history.back() go()方法)。即,在同一文档的两个历史记录条目之间导航会触发该事件。
window.addEventListener("popstate", urlReroute);

const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;

// history 改变的时候需要主动触发事件
window.history.pushState = patchedUpdateState(
window.history.pushState,
"pushState"
);
window.history.replaceState = patchedUpdateState(
originalReplaceState,
"replaceState"
);
}
  • reroute 方法控制了子应用生命周期的执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
function reroute(
pendingPromises = [],
eventArguments,
silentNavigation = false
) {
const { appsToUnload, appsToUnmount, appsToLoad, appsToMount } =
getAppChanges();

let appsThatChanged,
cancelPromises = [];

if (start) {
appsThatChanged = appsToUnload.concat(
appsToLoad,
appsToUnmount,
appsToMount
);
return performAppChanges();
}
if (!start) {
appsThatChanged = appsToLoad;
return loadApps();
}

function loadApps() {
return Promise.resolve().then(() => {
const loadPromises = appsToLoad.map((app) => {
if (app.loadPromise) return app.loadPromise;
if (app.status !== NOT_LOADED && app.status !== LOAD_ERROR) {
return app;
}

app.status = LOADING_SOURCE_CODE;

return (app.loadPromise = Promise.resolve()
.then((val) => {
// val 获取到子项目暴露的接口
return (loadPromise = app.app(app));
})
.then((val) => {
app.status = NOT_BOOTSTRAPPED;
app.bootstrap = val.bootstrap;
app.mount = val.mount;
app.unmount = val.unmount;
app.unload = val.unload;
delete app.loadPromise;
}));
});
let succeeded;

return Promise.all(loadPromises);
});
}

function performAppChanges() {
return Promise.resolve().then(() => {
return Promise.all(cancelPromises).then(() => {
appsToUnmount.map((app) =>
app.unmount({ name: app.name }).then(() => (app.status = NOT_MOUNTED))
);

loadApps().then((res) => {
const loadThenMountPromises = appsToLoad.map((app) => {
console.log(app);
app.status = BOOTSTRAPPING;

return Promise.resolve(app.loadPromise)
.then((res) => {
return app.bootstrap();
})
.then(() => {
return Promise.resolve().then(() => {
app.status = MOUNTING;
return app
.mount({
name: app.name,
})
.then(() => {
app.status = MOUNTED;
});
});
});
});
});

appsToMount.map((app) => {
app.mount({ name: app.name }).then(() => (app.status = MOUNTED));
});
});
});
}
}

function getAppChanges() {
const appsToUnload = [],
appsToUnmount = [],
appsToLoad = [],
appsToMount = [];

let appsThatChanged,
cancelPromises = [];
// We re-attempt to download applications in LOAD_ERROR after a timeout of 200 milliseconds
const currentTime = new Date().getTime();

console.log(apps);
apps.forEach((app) => {
const appShouldBeActive =
app.status !== SKIP_BECAUSE_BROKEN && app.activeWhen[0](window.location);

switch (app.status) {
case LOAD_ERROR:
if (appShouldBeActive && currentTime - app.loadErrorTime >= 200) {
appsToLoad.push(app);
}
break;
case NOT_LOADED:
case LOADING_SOURCE_CODE:
if (appShouldBeActive) {
appsToLoad.push(app);
}
break;
case NOT_BOOTSTRAPPED:
case NOT_MOUNTED:
if (!appShouldBeActive) {
appsToUnload.push(app);
} else if (appShouldBeActive) {
appsToMount.push(app);
}
break;
case MOUNTED:
if (!appShouldBeActive) {
appsToUnmount.push(app);
}
break;
// all other statuses are ignored
}
});

return { appsToUnload, appsToUnmount, appsToLoad, appsToMount };
}

SVG角向渐变进度条

实现基本环形进度条

利用 circle 元素, 设置 stroke-width 来实现环形。

1
2
3
4
5
6
7
8
9
<circle
class="animate-item"
fill="none"
stroke="red"
stroke-width="20"
cx="50"
cy="50"
r="40"
></circle>

如果想实现部分环形, 可以使用 stroke-dasharray stroke-dashoffset, 偏移描边,来实现部分圆环

1
2
3
4
5
6
7
8
9
10
11
12
13
<circle
class="animate-item"
fill="none"
stroke="red"
stroke-width="20"
cx="50"
cy="50"
r="40"
stroke-dasharray="314 1000"
stroke-dashoffset="200"
stroke-linecap="round"
>
</circle>

因为 circle 的半径是 50,因此描边的路径长度是 Math.PI * 2 * 50 一个大于等于这个数值的描边就会可以覆盖整个圆环, 后面设置一个大一点的空白长度为 1000。

dashoffset 设置为 200 意味着描边向后偏移了 200, 那个对于第一段描边只剩下 314 - 200 的描边长度。

圆形绘制的起始点是 0 度角, 因此最终的效果如下。

一般环形位置是从 90 度角开始的, 所以可以给 circle 加上 transform 变换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<circle
class="animate-item"
fill="none"
stroke="red"
stroke-width="20"
cx="50"
cy="50"
r="40"
stroke-dasharray="314 1000"
stroke-dashoffset="200"
stroke-linecap="round"
transform="rotate(-90 50 50)"
>
</circle>

使用渐变色

SVG 支持线性渐变和镜像渐变, 可以创建一个线性渐变元素。 作为圆环的描边属性值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<svg
height="100"
width="100"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.1"
>
<defs>
<linearGradient
x1="77.8982925%"
y1="11.2647007%"
x2="24.5398068%"
y2="89.7549679%"
id="linearGradient"
>
<stop stop-color="#002974" stop-opacity="0.21446132" offset="0%"></stop>
<stop stop-color="#4DD7FF" offset="99.9125874%"></stop>
</linearGradient>
</defs>
<circle
class="animate-item"
fill="none"
stroke="url(#linearGradient)"
stroke-width="20"
cx="50"
cy="50"
r="40"
stroke-dasharray="314 1000"
stroke-dashoffset="200"
stroke-linecap="round"
transform="rotate(-90 50 50)"
></circle>
</svg>

这里使用了一个有倾斜角度的线性渐变模拟, 色环的角向渐变。

实现角向渐变色

虽然 SVG 不支持角向渐变, 但是可以使用 [pattern] 作为描边属性值。

首先准备一张角向渐变的图片。

在 pattern 中定义并作为描边属性值使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<svg
height="100"
width="100"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.1"
>
<defs>
<pattern
id="fill-img"
patternUnits="userSpaceOnUse"
width="100"
height="100"
>
<image xlink:href="./01.png" x="0" y="0" width="100" height="100"></image>
</pattern>
</defs>
<circle
class="animate-item"
fill="none"
stroke="url(#fill-img)"
stroke-width="20"
stroke-miterlimit="1"
cx="50"
cy="50"
r="40"
stroke-dasharray="314 1000"
stroke-dashoffset="200"
stroke-linecap="round"
transform="rotate(-74 50 50)"
></circle>
</svg>

由于 line-cap 占据了一定的 stroke 长度,所以 rotate 中并没有旋转到 90 度, 为 line-cap 占据的位置空出一点空间。

但是当 dashoffset 为 0 时,效果如下, 因此需要给 pattern 一个 transform 属性,用于抵消 circle 旋转的偏移。

1
2
3
4
5
6
7
8
9
10
11
<pattern id="fill-img" patternUnits="userSpaceOnUse" width="100" height="100">
<image
xlink:href="./01.png"
x="0"
y="0"
width="100"
height="100"
transform="rotate(74 50 50)"
>
</image>
</pattern>

最后添加动画属性, 初始时 dashoffset 在 314 的位置, 动画开始后将会回退到 0 的位置, 由此实现环形进度条动画效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<style>
.animate-item {
transition: stroke-dashoffset 1.5s ease;
}
</style>
<svg
height="100"
width="100"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.1"
>
<defs>
<pattern
id="fill-img"
patternUnits="userSpaceOnUse"
width="100"
height="100"
>
<image
xlink:href="./01.png"
x="0"
y="0"
width="100"
height="100"
transform="rotate(74 50 50)"
></image>
</pattern>
</defs>
<circle
class="animate-item"
fill="none"
stroke="url(#fill-img)"
stroke-width="20"
stroke-miterlimit="1"
cx="50"
cy="50"
r="40"
stroke-dasharray="314 1000"
stroke-dashoffset="314"
stroke-linecap="round"
transform="rotate(-74 50 50)"
></circle>
</svg>
<script>
setTimeout(function () {
document
.querySelector(" .animate-item")
.setAttribute("stroke-dashoffset", 0);
}, 1000);
</script>

SVG 属性

stroke-dasharray

stroke 作为一个动词,有一画,划的意思。

在 SVG 中 stroke 作为一个描边属性,通过 stroke-dasharray 可以配置描边中的点和线的范式。换句话说它可以控制,描边中的线段与空白的规则。

作为一个外观属性,它也可以直接在写在 CSS 中。

1
2
3
4
5
6
/* 虚线长10,间距10,后面重复,虚线长10,间距10 */
stroke-dasharray = '10'
/* 虚线长10,间距5,后面重复,虚线长10,间距5 */
stroke-dasharray = '10, 5'
/* 虚线长20,间距10,虚线长5 后面是 间距20,虚线10,间距5 一直到下一个重复周期*/
stroke-dasharray = '20, 10, 5'

stroke-dashoffset

stroke-dashoffset 属性指定了 dash 模式到路径开始的距离, 它是一个偏移量,可以向前或向后偏移描边。

另外这是一个数值型的属性,因此可以使用 css 动画控制, 来实现路径动画。 这是一个角向渐变环形进度条的例子。

pattern

使用预定义的图形对一个对象进行填充或描边,就要用到 pattern 元素。pattern 元素让预定义图形能够以固定间隔在 x 轴和 y 轴上重复(或平铺)从而覆盖要涂色的区域。先使用 pattern 元素定义图案,然后在给定的图形元素上用属性 fill 或属性 stroke 引用用来填充或描边的图案。

CSS Grid 布局

grid 网格布局

可以设置元素 display 属性为 grid ,inline-grid,inline-grid 可以让 grid 元素保持内联的特性。[CSS 新世界中相关章节]

应用于容器的属性
网格数量与尺寸

grid-template-columnsgrid-template-rows属性主要用来指定网格的数量和尺寸等信息。[CSS 新世界 6.3.1]

grid-template: [grid-template-rows] / [grid-template-columns] 是缩写。

尺寸可用的 9 种数据类型,其中 fr 是单词 fraction 的缩写表示分数,按比例划分可自动分配的尺寸。

  • Copyrights © 2015-2026 SunZhiqi

此时无声胜有声!

支付宝
微信