阅读视图
「Ant Design 组件库探索」五:Tabs组件
🧩 深入浅出讲解:analyzeScriptBindings —— Vue 如何分析 <script> 里的变量绑定
一、这段代码是干什么的?
Vue 组件有两种写法:
| 类型 | 示例 | 特点 |
|---|---|---|
普通 <script>
|
export default { data(){...}, props:{...} } |
传统写法 |
<script setup> |
顶层直接写 const count = ref(0)
|
Vue3 新写法 |
而 Vue 编译器在处理 .vue 文件时,需要知道:
每个变量来自哪里?它是 props 吗?data 吗?methods 吗?
这段源码的作用就是:
👉 当我们用“普通写法”时,分析出每个变量的“来源类型”。
二、运行结果长什么样?
假设我们有个组件:
export default {
props: ['title'],
data() {
return { count: 0 }
},
methods: {
inc() { this.count++ }
}
}
经过这段分析函数后,会得到这样的结果对象:
{
title: 'props',
count: 'data',
inc: 'options',
__isScriptSetup: false
}
这就告诉 Vue:
-
title来自 props; -
count来自 data; -
inc是 methods; - 不是
<script setup>。
三、从头到尾一步步看源码逻辑
Step 1️⃣:找到 export default { ... }
export function analyzeScriptBindings(ast: Statement[]): BindingMetadata {
for (const node of ast) {
if (
node.type === 'ExportDefaultDeclaration' &&
node.declaration.type === 'ObjectExpression'
) {
return analyzeBindingsFromOptions(node.declaration)
}
}
return {}
}
🧠 意思:
-
AST 是整段脚本的语法树。
-
遍历每个语句,找到:
export default { ... } -
然后调用
analyzeBindingsFromOptions()来分析里面的对象内容。
Step 2️⃣:创建结果对象并标记类型
const bindings: BindingMetadata = {}
Object.defineProperty(bindings, '__isScriptSetup', {
enumerable: false,
value: false,
})
📘 这一步干嘛?
- 初始化一个结果对象;
- 加一个隐藏属性
__isScriptSetup=false,告诉系统“这是普通 script”。
Step 3️⃣:逐个分析对象里的属性
例如:
export default {
props: ['foo'],
data() { return { msg: 'hi' } },
methods: { sayHi() {} }
}
程序就会循环每个属性(props、data、methods...),判断它是哪种类型。
Step 4️⃣:不同类型的属性,分别分析
(1) props 分析
if (property.key.name === 'props') {
for (const key of getObjectOrArrayExpressionKeys(property.value)) {
bindings[key] = BindingTypes.PROPS
}
}
🧠 支持两种写法:
props: ['foo', 'bar']props: { foo: String }
结果:
{ foo: 'props', bar: 'props' }
(2) inject 分析
else if (property.key.name === 'inject') {
for (const key of getObjectOrArrayExpressionKeys(property.value)) {
bindings[key] = BindingTypes.OPTIONS
}
}
对应:
inject: ['token']
👉 结果 { token: 'options' }
(3) methods / computed 分析
else if (
property.value.type === 'ObjectExpression' &&
(property.key.name === 'computed' || property.key.name === 'methods')
)
📘 当 methods: { sayHi(){} } 或 computed: { total(){} } 时,
把每个函数名记录下来:
{ sayHi: 'options', total: 'options' }
(4) data / setup 分析
else if (
property.type === 'ObjectMethod' &&
(property.key.name === 'setup' || property.key.name === 'data')
)
这时要进入函数体里查找 return 的内容:
data() {
return { count: 0 }
}
setup() {
return { foo: ref(0) }
}
📘 分析结果:
- data 返回的变量 →
BindingTypes.DATA - setup 返回的变量 →
BindingTypes.SETUP_MAYBE_REF
四、辅助函数们(简化理解)
1️⃣ 获取对象的键名
function getObjectExpressionKeys(node) {
// 从 { foo: 1, bar: 2 } 中提取出 ['foo', 'bar']
}
2️⃣ 获取数组的键名
function getArrayExpressionKeys(node) {
// 从 ['foo', 'bar'] 中提取出 ['foo', 'bar']
}
3️⃣ 自动判断是对象还是数组
export function getObjectOrArrayExpressionKeys(value) {
// 根据类型选择上面的函数
}
五、整体运行逻辑图
AST语法树
↓
找到 export default {}
↓
进入 analyzeBindingsFromOptions()
↓
循环每个属性:
- props → PROPS
- inject → OPTIONS
- methods/computed → OPTIONS
- data → DATA
- setup → SETUP_MAYBE_REF
↓
返回 BindingMetadata
六、为什么这么做?
因为 Vue 在模板编译时,需要知道哪些名字是:
- 响应式变量(data、setup)
- 只读输入(props)
- 普通函数(methods)
这样模板里写的:
<p>{{ count }}</p>
才能被编译成正确的访问代码:
_ctx.count
或者:
_props.title
七、你可以怎么用它?
如果你想做:
- 自定义 Vue 编译工具;
- 分析
.vue文件中定义的变量; - 或者写一个 ESLint 规则来检测组件结构;
就可以直接复用这段逻辑,让它帮你快速“读懂” Vue 组件结构。
八、潜在问题
| 问题 | 说明 |
|---|---|
| 不支持动态 key |
[foo]: value 这种会被忽略 |
| 不识别 TS 类型 | 如果写 props: { foo: String as PropType<number> } 不会处理 |
| 无法分析复杂 setup 返回逻辑 | 例如条件 return 不被识别 |
✅ 总结一句话
这段代码的作用就是让编译器“看懂”一个普通 Vue 组件里的变量来源,区分哪些是 props、data、methods、setup 返回的。
本文部分内容借助 AI 辅助生成,并由作者整理审核。
[Python3/Java/C++] 一题一解:并查集+有序集合(清晰题解)
方法一:并查集 + 有序集合
我们可以使用并查集(Union-Find)来维护电站之间的连接关系,从而确定每个电站所属的电网。对于每个电网,我们使用有序集合(如 Python 中的 SortedList、Java 中的 TreeSet 或 C++ 中的 std::set)来存储该电网中所有在线的电站编号,以便能够高效地查询和删除电站。
具体步骤如下:
- 初始化并查集,处理所有连接关系,将连接的电站合并到同一个集合中。
- 为每个电网创建一个有序集合,初始时将所有电站编号加入对应电网的集合中。
- 遍历查询列表:
- 对于查询 $[1, x]$,首先找到电站 $x$ 所属的电网根节点,然后检查该电网的有序集合:
- 如果电站 $x$ 在线(存在于集合中),则返回 $x$。
- 否则,返回集合中的最小编号电站(如果集合非空),否则返回 -1。
- 对于查询 $[2, x]$,找到电站 $x$ 所属的电网根节点,并将电站 $x$ 从该电网的有序集合中删除,表示该电站离线。
- 对于查询 $[1, x]$,首先找到电站 $x$ 所属的电网根节点,然后检查该电网的有序集合:
- 最后,返回所有类型为 $[1, x]$ 的查询结果。
###python
class UnionFind:
def __init__(self, n):
self.p = list(range(n))
self.size = [1] * n
def find(self, x):
if self.p[x] != x:
self.p[x] = self.find(self.p[x])
return self.p[x]
def union(self, a, b):
pa, pb = self.find(a), self.find(b)
if pa == pb:
return False
if self.size[pa] > self.size[pb]:
self.p[pb] = pa
self.size[pa] += self.size[pb]
else:
self.p[pa] = pb
self.size[pb] += self.size[pa]
return True
class Solution:
def processQueries(
self, c: int, connections: List[List[int]], queries: List[List[int]]
) -> List[int]:
uf = UnionFind(c + 1)
for u, v in connections:
uf.union(u, v)
st = [SortedList() for _ in range(c + 1)]
for i in range(1, c + 1):
st[uf.find(i)].add(i)
ans = []
for a, x in queries:
root = uf.find(x)
if a == 1:
if x in st[root]:
ans.append(x)
elif len(st[root]):
ans.append(st[root][0])
else:
ans.append(-1)
else:
st[root].discard(x)
return ans
###java
class UnionFind {
private final int[] p;
private final int[] size;
public UnionFind(int n) {
p = new int[n];
size = new int[n];
for (int i = 0; i < n; ++i) {
p[i] = i;
size[i] = 1;
}
}
public int find(int x) {
if (p[x] != x) {
p[x] = find(p[x]);
}
return p[x];
}
public boolean union(int a, int b) {
int pa = find(a), pb = find(b);
if (pa == pb) {
return false;
}
if (size[pa] > size[pb]) {
p[pb] = pa;
size[pa] += size[pb];
} else {
p[pa] = pb;
size[pb] += size[pa];
}
return true;
}
}
class Solution {
public int[] processQueries(int c, int[][] connections, int[][] queries) {
UnionFind uf = new UnionFind(c + 1);
for (int[] e : connections) {
uf.union(e[0], e[1]);
}
TreeSet<Integer>[] st = new TreeSet[c + 1];
Arrays.setAll(st, k -> new TreeSet<>());
for (int i = 1; i <= c; i++) {
int root = uf.find(i);
st[root].add(i);
}
List<Integer> ans = new ArrayList<>();
for (int[] q : queries) {
int a = q[0], x = q[1];
int root = uf.find(x);
if (a == 1) {
if (st[root].contains(x)) {
ans.add(x);
} else if (!st[root].isEmpty()) {
ans.add(st[root].first());
} else {
ans.add(-1);
}
} else {
st[root].remove(x);
}
}
return ans.stream().mapToInt(Integer::intValue).toArray();
}
}
###cpp
class UnionFind {
public:
UnionFind(int n) {
p = vector<int>(n);
size = vector<int>(n, 1);
iota(p.begin(), p.end(), 0);
}
bool unite(int a, int b) {
int pa = find(a), pb = find(b);
if (pa == pb) {
return false;
}
if (size[pa] > size[pb]) {
p[pb] = pa;
size[pa] += size[pb];
} else {
p[pa] = pb;
size[pb] += size[pa];
}
return true;
}
int find(int x) {
if (p[x] != x) {
p[x] = find(p[x]);
}
return p[x];
}
private:
vector<int> p, size;
};
class Solution {
public:
vector<int> processQueries(int c, vector<vector<int>>& connections, vector<vector<int>>& queries) {
UnionFind uf(c + 1);
for (auto& e : connections) {
uf.unite(e[0], e[1]);
}
vector<set<int>> st(c + 1);
for (int i = 1; i <= c; i++) {
st[uf.find(i)].insert(i);
}
vector<int> ans;
for (auto& q : queries) {
int a = q[0], x = q[1];
int root = uf.find(x);
if (a == 1) {
if (st[root].count(x)) {
ans.push_back(x);
} else if (!st[root].empty()) {
ans.push_back(*st[root].begin());
} else {
ans.push_back(-1);
}
} else {
st[root].erase(x);
}
}
return ans;
}
};
时间复杂度 $O((c + n + q) \log c)$,空间复杂度 $O(c)$。其中 $c$ 是电站数量,而 $n$ 和 $q$ 分别是连接数量和查询数量。
有任何问题,欢迎评论区交流,欢迎评论区提供其它解题思路(代码),也可以点个赞支持一下作者哈😄~
从字符串到像素:深度解析 HTML/CSS/JS 的页面渲染全过程
JavaScript 异步循环踩坑指南
1. 前言
在循环中使用 await,代码看似直观,但运行时要么悄无声息地停止,要么运行速度缓慢,这是为什么呢?
本篇聊聊 JavaScript 中的异步循环问题。
2. 踩坑 1:for 循环里用 await,效率太低
假设要逐个获取用户数据,可能会这样写:
const users = [1, 2, 3];
for (const id of users) {
const user = await fetchUser(id);
console.log(user);
}
代码虽然能运行,但会顺序执行——必须等 fetchUser(1) 完成,fetchUser(2) 才会开始。若业务要求严格按顺序执行,这样写没问题;但如果请求之间相互独立,这种写法就太浪费时间了。
3. 踩坑 2:map 里直接用 await,拿到的全是 Promise
很多人会在 map() 里用 await,却未处理返回的 Promise,结果踩了坑:
const users = [1, 2, 3];
const results = users.map(async (id) => {
const user = await fetchUser(id);
return user;
});
console.log(results); // 输出 [Promise, Promise, Promise],而非实际用户数据
语法上没问题,但它不会等 Promise resolve。若想让请求并行执行并获取最终结果,需用 Promise.all():
const results = await Promise.all(users.map((id) => fetchUser(id)));
这样所有请求会同时发起,results 中就是真正的用户数据了。
4. 踩坑 3:Promise.all 一错全错
用 Promise.all() 时,只要有一个请求失败,整个操作就会报错:
const results = await Promise.all(
users.map((id) => fetchUser(id)) // 假设 fetchUser(2) 出错
);
如果 fetchUser(2) 返回 404 或网络错误,Promise.all() 会直接 reject,即便其他请求成功,也拿不到任何结果。
5. 更安全的替代方案
5.1. 用 Promise.allSettled(),保留所有结果
使用 Promise.allSettled(),即便部分请求失败,也能拿到所有结果,之后可手动判断成功与否:
const results = await Promise.allSettled(users.map((id) => fetchUser(id)));
results.forEach((result) => {
if (result.status === "fulfilled") {
console.log("✅ 用户数据:", result.value);
} else {
console.warn("❌ 错误:", result.reason);
}
});
5.2. 在 map 里加 try/catch,返回兜底值
也可在请求时直接捕获错误,给失败的请求返回默认值:
const results = await Promise.all(
users.map(async (id) => {
try {
return await fetchUser(id);
} catch (err) {
console.error(`获取用户${id}失败`, err);
return { id, name: "未知用户" }; // 兜底数据
}
})
);
这样还能避免 “unhandled promise rejections” 错误——在 Node.js 严格环境下,该错误可能导致程序崩溃。
6. 现代异步循环方案,按需选择
6.1. for...of + await:适合需顺序执行的场景
若下一个请求依赖上一个的结果,或需遵守 API 的频率限制,可采用此方案:
// 在 async 函数内
for (const id of users) {
const user = await fetchUser(id);
console.log(user);
}
// 不在 async 函数内,用立即执行函数
(async () => {
for (const id of users) {
const user = await fetchUser(id);
console.log(user);
}
})();
- 优点:保证顺序,支持限流
- 缺点:独立请求场景下速度慢
6.2. Promise.all + map:适合追求速度的场景
请求间相互独立且可同时执行时,此方案效率最高:
const usersData = await Promise.all(users.map((id) => fetchUser(id)));
- 优点:网络请求、CPU 独立任务场景下速度快
- 缺点:一个请求失败会导致整体失败(需手动处理错误)
6.3. 限流并行:用 p-limit 控制并发数
若需兼顾速度与 API 限制,可借助 p-limit 等工具控制同时发起的请求数量:
import pLimit from "p-limit";
const limit = pLimit(2); // 每次同时发起 2 个请求
const limitedFetches = users.map((id) => limit(() => fetchUser(id)));
const results = await Promise.all(limitedFetches);
- 优点:平衡并发和控制,避免压垮外部服务
- 缺点:需额外引入依赖
7. 注意:千万别在 forEach() 里用 await
这是个高频陷阱:
users.forEach(async (id) => {
const user = await fetchUser(id);
console.log(user); // ❌ 不会等待执行完成
});
forEach() 不会等待异步回调,请求会在后台乱序执行,可能导致代码逻辑出错、错误被遗漏。
替代方案:
- 顺序执行:用 for...of + await
- 并行执行:用 Promise.all() + map()
8. 总结:按需选择
JavaScript 异步能力很强,但循环里用 await 要“按需选择”,核心原则如下:
| 需求场景 | 推荐方案 |
|---|---|
| 需保证顺序、逐个执行 | for...of + await |
| 追求速度、独立请求 | Promise.all() + map() |
| 需保留所有结果(含失败) | Promise.allSettled()/try-catch |
| 需控制并发数、遵守限流 | p-limit 等工具 |
9. 参考链接
凡泰极客亮相香港金融科技周,AI助力全球企业构建超级应用
commonjs 和 ES Module
Three.js 工业 3D 可视化:生产线状态监控系统实现方案
在工业数字化转型过程中,3D 可视化监控系统凭借直观、沉浸式的优势,成为车间管理的重要工具。本文将详细介绍如何使用 Three.js 构建一套生产线 3D 状态监控系统,实现设备状态展示、产能数据可视化、交互式操作等核心功能
一、项目背景与技术选型
1. 项目需求
- 3D 可视化展示生产线布局及设备状态
- 实时显示生产线运行参数(产能、产量、状态等)
- 支持多生产线切换查看
- 设备状态可视化(运行 / 维护 / 停机)
- 交互式操作(视角旋转)
2. 技术栈选型
-
3D 核心库:Three.js(Web 端 3D 图形渲染引擎)
-
辅助库:
- GLTFLoader(3D 模型加载)
- OrbitControls(相机控制)
- CSS3DRenderer/CSS2DRenderer(3D/2D 标签渲染)
-
UI 框架:Element UI(进度条、样式组件)
-
动画库:animate-number(数值动画)
-
样式预处理:SCSS(样式模块化管理)
二、核心功能实现
1. 3D 场景基础搭建
场景初始化是 Three.js 项目的基础,需要完成场景、相机、渲染器三大核心对象的创建。
init() {
// 1. 创建场景
this.scene = new THREE.Scene();
// 2. 创建网格模型(生产线底座)
const geometry = new THREE.BoxGeometry(640, 1, 70);
const material = new THREE.MeshLambertMaterial({
color: 0xffffff,
transparent: true,
opacity: 1
});
this.mesh = new THREE.Mesh(geometry, material);
this.mesh.position.set(0, -140, 0);
this.scene.add(this.mesh);
// 3. 光源设置(点光源+环境光)
const pointLight = new THREE.PointLight(0xffffff, 0.5);
pointLight.position.set(0, 200, 300);
this.scene.add(pointLight);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.3);
this.scene.add(ambientLight);
// 4. 相机设置(正交相机,适合工业场景展示)
const container = document.getElementById("container");
const width = container.clientWidth;
const height = container.clientHeight;
const aspectRatio = width / height;
const scale = 230; // 场景显示范围系数
this.camera = new THREE.OrthographicCamera(
-scale * aspectRatio,
scale * aspectRatio,
scale,
-scale,
1,
1000
);
this.camera.position.set(-100, 100, 500);
this.camera.lookAt(this.scene.position);
// 5. 渲染器设置
this.renderer = new THREE.WebGLRenderer({
antialias: true, // 抗锯齿
preserveDrawingBuffer: true // 保留绘制缓存
});
this.renderer.setSize(width, height);
this.renderer.setClearColor(0xffffff, 0); // 透明背景
container.appendChild(this.renderer.domElement);
// 6. 控制器设置(支持鼠标交互)
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.addEventListener("change", () => {
this.renderer.render(this.scene, this.camera);
});
// 初始渲染
this.renderer.render(this.scene, this.camera);
}
2. 3D 模型加载与生产线构建
(1)外部模型加载
使用 GLTFLoader 加载生产线设备 3D 模型(glb 格式),并设置模型位置:
loadGltf() {
const loader = new GLTFLoader();
loader.load("../model/cj.glb", (gltf) => {
gltf.scene.position.set(16, -139, 140); // 调整模型位置适配场景
this.scene.add(gltf.scene);
this.renderer.render(this.scene, this.camera);
});
}
(2)生产线围墙构建
通过 BufferGeometry 自定义几何体,创建生产线边界围墙,并用纹理贴图美化:
addWall() {
// 围墙顶点坐标
const vertices = [-320, 35, 320, 35, 320, -35, -320, -35, -320, 35];
const geometry = new THREE.BufferGeometry();
const posArr = [];
const uvArr = [];
const height = -40; // 围墙高度
// 构建围墙三角面
for (let i = 0; i < vertices.length - 2; i += 2) {
// 两个三角形组成一个矩形面
posArr.push(
vertices[i], vertices[i+1], -140,
vertices[i+2], vertices[i+3], -140,
vertices[i+2], vertices[i+3], height,
vertices[i], vertices[i+1], -140,
vertices[i+2], vertices[i+3], height,
vertices[i], vertices[i+1], height
);
// UV贴图坐标
uvArr.push(0,0, 1,0, 1,1, 0,0, 1,1, 0,1);
}
// 设置几何体属性
geometry.attributes.position = new THREE.BufferAttribute(new Float32Array(posArr), 3);
geometry.attributes.uv = new THREE.BufferAttribute(new Float32Array(uvArr), 2);
geometry.computeVertexNormals(); // 计算法线
// 加载纹理并创建材质
this.texture = new THREE.TextureLoader().load("../images/linearGradient.png");
this.mesh = new THREE.Mesh(geometry, new THREE.MeshLambertMaterial({
color: this.dict_color[this.progress.state],
map: this.texture,
transparent: true,
side: THREE.DoubleSide, // 双面渲染
depthTest: false
}));
this.mesh.rotation.x = -Math.PI * 0.5; // 旋转适配场景
this.scene.add(this.mesh);
}
4. 状态可视化与数据面板
(1)多状态颜色映射
定义生产线三种状态(运行中 / 维护中 / 停机中)的颜色映射,实现状态可视化:
dict_color: {
运行中: "#32e5ad", // 绿色
维护中: "#fb8d1c", // 橙色
停机中: "#e9473a" // 红色
}
(2)数据面板设计
通过 CSS2DRenderer 将数据面板作为 2D 标签添加到 3D 场景中,实时展示生产线参数:
<div id="tooltip">
<div class="title">DIP 2-1涂覆线</div>
<div class="progress">
<p class="state">
<span class="icon" :style="{ backgroundColor: dict_color[progress.state] }"></span>
{{ progress.state }}
</p>
<p class="value">
<animate-number
from="0"
:key="progress.value"
:to="progress.value"
duration="2000"
easing="easeOutQuad"
:formatter="formatter"
></animate-number>
%
</p>
<el-progress :percentage="progress.value" :show-text="false" :color="dict_color[progress.state]"></el-progress>
</div>
<ul class="infoList">
<li v-for="(item, index) in infoList" :key="index">
<label>{{ item.label }}:</label>
<span>{{ item.value }}</span>
</li>
</ul>
</div>
addTooltip() {
const tooltipDom = document.getElementById("tooltip");
const tooltipObject = new CSS2DObject(tooltipDom);
tooltipObject.position.set(0, 120, 0); // 面板在3D场景中的位置
this.scene.add(tooltipObject);
this.labelRenderer2D.render(this.scene, this.camera);
}
5. 多生产线切换功能
支持切换查看多条生产线状态,通过点击标签切换数据和状态颜色:
changeType(index) {
this.typeIndex = index;
// 根据索引切换不同生产线的状态数据
if (index % 3 === 0) {
this.progress = this.progress1; // 运行中
} else if (index % 3 === 1) {
this.progress = this.progress2; // 维护中
} else {
this.progress = this.progress3; // 停机中
}
}
// 监听progress变化,更新3D模型颜色
watch: {
progress: {
handler() {
this.mesh.material.color.set(this.dict_color[this.progress.state]);
this.renderer.render(this.scene, this.camera);
},
deep: true
}
}
6. 响应式适配
处理窗口大小变化,确保 3D 场景自适应调整:
onWindowResize() {
const container = document.getElementById("container");
const width = container.clientWidth;
const height = container.clientHeight;
// 更新渲染器尺寸
this.renderer.setSize(width, height);
this.labelRenderer.setSize(width, height);
this.labelRenderer2D.setSize(width, height);
// 更新相机参数
const aspectRatio = width / height;
const scale = 230;
this.camera.left = -scale * aspectRatio;
this.camera.right = scale * aspectRatio;
this.camera.top = scale;
this.camera.bottom = -scale;
this.camera.updateProjectionMatrix();
// 重新渲染
this.renderer.render(this.scene, this.camera);
}
三、关键技术
1. 3D 与 2D 融合渲染
通过 CSS3DRenderer 和 CSS2DRenderer 实现 3D 场景与 2DUI 的无缝融合:
- CSS2DRenderer:用于数据面板等需要始终面向相机的 2D 元素
- CSS3DRenderer:用于生产线节点标签等需要 3D 空间定位的元素
2. 状态可视化设计
- 颜色编码:用不同颜色区分设备状态,符合工业监控的视觉习惯
- 动态更新:状态变化时实时更新 3D 模型颜色和数据面板
- 图标标识:通过图标和文字结合,增强状态辨识度
3. 性能优化
- 抗锯齿设置:提升 3D 模型显示清晰度
- 双面渲染:确保围墙等几何体正反面都能正常显示
- 纹理复用:减少重复纹理加载,提升性能
- 事件监听优化:仅在必要时重新渲染场景
Electron 应用商店:开箱即用工具集成方案
在Node.js中分析内存占用
第2章:第一个Flutter应用 —— 2.4 路由管理
浏览器&Websocket&热更新
前端工程化实战:手把手教你构建项目脚手架
面对如今丰富的前端生态,开启新项目时你是否经常陷入这样的纠结:
- 在选择构建工具、UI框架、要不要TS等技术选型时,是不是都要重新研究最新的最佳实践?
- 当团队需要内部的代码规范、工具链配置、私有依赖等总要手动添加,而影响开发效率?
- 当新成员加入时,是否需要大量时间理解项目结构、配置规范,导致配置不一致导致各种奇怪问题?
- 当团队项目需要添加特定的中后台、组件库等场景,总要重复的基建代码的
Copy
以上烦恼都可以通过前端脚手架搞定,从而不再重复造轮子,而是打造专属自身团队的最佳实践。
本文将从0到1带你构建一个简单的脚手架,以抛砖引玉的方式带了解脚手架的开发。
前端脚手架
前端脚手架本质上是一个Node.js命令程序,它通常有以下功能:
- 交互式询问用户 通过命令行交互,如确定项目名称、选择框架
- 模板管理 根据命令行交互的结果远程拉取的项目模板
- 交互式配置 根据命令行让用户自行选择具体配置
- 依赖安装 自动安装项目依赖(npm/yarn/pnpm)
- 命令扩展 支持插件化或自定义命令(可选,进阶功能)
在开发脚手架过程中,使用到一些第三方依赖来帮助我们完成脚手架开发:
-
commander命令行处理工具 -
chalk命名行输出美化工具 -
inquirer命名行交互工具 -
ora终端loading美化工具 -
git-clone下载项目模板工具, -
figlet终端生成艺术字 -
fs-extra操作本地目录 -
ejs/handlebars动态渲染模板文件
前端脚手架实现
1. 初始化项目
mkdir case-cli && cd case-cli
npm init -y
2.配置命令入口
{
"name": "case-cli",
"version": "0.0.1",
"main": "index.js",
"bin": "/bin/index.js",
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"chalk": "^4.1.2",
"commander": "^14.0.2",
"fs-extra": "^11.3.2",
"git-clone": "^0.2.0",
"inquirer": "^8.2.7",
"ora": "^5.4.1"
}
}
📢 注意
package.json中多数依赖包的最新版本都采用ESM模块化,如果采用Common.js模块化方式,需要适当降级
3. 编写入口文件
#!/usr/bin/env node
const ora = require("ora"); // loading 美化工具
const chalk = require("chalk"); // 命令行美化工具
const inquirer = require("inquirer"); // 命令行交互
const fs = require("fs-extra"); // 操作本地目录
const path = require("path");
const gitClone = require("git-clone"); // 拉取github模板
const packageJson = require("../package.json"); // 获取package.json
const { program } = require("commander"); // 命令行处理工具
console.log(chalk.blue("学习脚手架工具已启动!"));
📢 注意
必须在文件开头添加#!/usr/bin/env node,告知操作系统 该文件是通过Node执行
现在我们就可以在命令行中输入case-cli后回车:
然后我们再添加一行代码,通过commander的program解析命令行参数:
#!/usr/bin/env node
/* 依赖引入就此省略 */
console.log(chalk.blue("学习脚手架工具已启动!"));
// 解析命令行参数
program.parse(process.argv);
输入case-cli -h命令:
添加获取版本的指令
#!/usr/bin/env node
/* 依赖引入就此省略 */
console.log(chalk.blue("学习脚手架工具已启动!"));
program.version(chalk.green.bold(packageJson.version))
// 解析命令行参数
program.parse(process.argv);
输入case-cli -V将显示脚手架版本号,而且case-cli -h也有变化
一般情况下脚手架类似vue create [project name]来创建项目,在没有输入任何指令时如case-cli将会执行case-cli --help命令显示该脚手架有哪些命令操作。可以如下实现:
program.action(() => program.help());
注册命令
program
.command("create <project-name>") // <project-name> 表示必填参数,如果不填写将会报错
.description("创建新项目")
.action(async (projectName) => {
console.log(projectName);
});
添加交互配置
program
.command("create <project-name>") // <project-name> 表示必填参数,如果不填写将会报错
.description("创建新项目")
.action(async (projectName) => {
inquirer.prompt([
{
type: "list",
name: 'framework',
message: '请选择框架',
choices: ["vue", "react"],
}
]).then(async (answers) => {
const { framework } = answers;
console.log(chalk.green(`正在创建项目 ${projectName}`));
console.log(chalk.green(`正在创建 ${framework} 项目`));
})
});
当我们输入case-cli create app时,将呈现如下画面:
任意选择一项后:
检查项目名称是否重复
脚手架是以项目名称为目录名称,在当前输入指令的目录下创建的,因此需要检查是否有相同的目录名。并给出提示。
program
.command("create <project-name>") // <project-name> 表示必填参数,如果不填写将会报错
.description("创建新项目")
.action(async (projectName) => {
inquirer.prompt([
{
type: "list",
name: 'framework',
message: '请选择框架',
choices: ["vue", "react"],
}
]).then(async (answers) => {
const { framework } = answers;
// 拼接创建项目目录地址
const projectPath = path.join(process.cwd(), projectName);
// 检查是否存在相同目录
const isExist = fs.existsSync(projectPath);
if (isExist) {
// 提供交互选择 覆盖则删除之前目录,反之则退出此次命令
const result = await inquirer.prompt([
{
type: "confirm",
message: "当前目录下已存在同名项目,是否覆盖?",
name: "overwrite",
default: false,
},
]);
if (result.overwrite) {
fs.removeSync(projectPath);
console.log(chalk.green("已删除同名项目"));
} else {
console.log(chalk.yellow("请重新创建项目"));
return;
}
}
})
});
拉取远程模板
const spinner = ora(chalk.magenta("正在创建项目...")).start();
const remoteUrl = `https://github.com/gardenia83/${framework}-template.git`;
gitClone(remoteUrl, projectPath, { checkout: "main" }, function (err) {
if (err) {
spinner.fail(chalk.red("拉取模板失败"));
} else {
spinner.color = "magenta";
// 由于拉取会将他人的.git,因此需要移除
fs.removeSync(path.join(projectPath, ".git")); // 删除.git文件
spinner.succeed(chalk.cyan("项目创建成功"));
console.log("Done now run: \n");
console.log(`cd ${projectName}`);
console.log("npm install");
console.log("npm run dev");
}
});
拉取远程模板项目:
拉取完成后:
小结
通过本文,我们完成了一个基础但功能完整的前端脚手架,实现了项目创建、模板拉取、冲突处理等核心功能。这个简单的脚手架已经能够解决文章开头提到的部分痛点:
✅ 统一技术选型 - 通过预设模板固化团队最佳实践
✅ 快速初始化 - 一键生成项目结构,告别手动配置
✅ 规范团队协作 - 新成员无需理解复杂配置,开箱即用
但这仅仅是一个开始! 你可以基于这个基础版本,根据团队实际需求进行深度定制:
🛠 模板动态化 - 集成 ejs 等模板引擎,根据用户选择动态生成配置文件
🛠 生态集成 - 添加 ESLint、Prettier、Husky 等工程化工具链
🛠 场景扩展 - 针对中后台、组件库、H5 等不同场景提供专属模板
🛠 插件机制 - 设计插件系统,让团队成员也能贡献功能模块
最好的脚手架不是功能最全的,而是最适合团队工作流的。 希望本文能成为你打造团队专属工具链的起点,让重复的配置工作成为历史,把宝贵的时间留给更有价值的创新!
【Nextjs】为什么server action中在try/catch内写redirect操作会跳转失败?
为 CI/CD 装上“眼睛”:App 包大小监控的实践
包大小直接影响下载转化与用户留存。传统人工统计效率低、难溯源。本文将分享如何将包大小监控嵌入 CI/CD 流程,实现自动化采集与分析,为应用性能装上“眼睛”。
一、为什么必须重视包大小监控?数据告诉你答案
包大小与业务增长直接挂钩,谷歌开发者大会 2019 年数据揭示了核心关联:
- 转化率敏感:包体每上升 6MB,应用下载转化率下降 1%;减少 10MB 时,全球平均转化率提升 1.75%,印度、巴西等新兴市场提升超 2.0%,美国、德国等高端市场提升 1.5%。
- 用户决策影响:包体超 200MB(2019 年标准)时,App Store 会弹窗提醒流量费用,蜂窝网络下用户放弃率显著上升;同时,下载时长过长、网络波动导致的安装失败,本质都是包大小引发的连锁问题。
这些数据证明,包大小监控不是“可选优化”,而是保障用户转化的“基础工程”。
二、核心目标:让监控实现“三自动化”
基于业务需求,包大小监控需达成三大核心目标,解决传统人工统计的痛点:
- 数据采集自动化:覆盖 iOS/Android 双端、全渠道(应用商店/官网/第三方),自动抓取每个版本的包大小数据,无需人工干预。
- 数据分析自动化:数据实时同步至神策数据分析平台(这里的平台可以按实际情况进行选择,这里以神策为例进行讲解),支持按版本趋势、端内对比、渠道差异多维度拆解,快速定位变化原因。
- 流程集成自动化:嵌入 CI/CD 环节(Jenkins/GitLab CI/Fastlane),每一次构建自动触发监控,确保数据一致性与可追溯性。
三、架构设计:从构建到分析的全链路闭环
整个监控体系遵循“轻量集成、无侵入”原则,核心架构流程如下:
无需新增复杂组件,仅通过脚本工具与 API 调用,即可实现从包体构建到数据呈现的全自动化,不影响原有 CI/CD 流程效率。
四、落地实现:分步骤搭建自动化监控体系
4.1 适用环境
- 兼容主流 CI/CD 工具链:Jenkins、GitLab CI、Fastlane;
- 支持双端包体格式:Android(APK/AAB)、iOS(IPA)。
4.2 核心实现逻辑
在构建流程结束后,通过脚本完成“信息采集→大小计算→数据上报”闭环,具体步骤:
- 获取构建元信息:自动读取版本号、构建号、渠道、Git 分支/提交记录等关键数据(从配置文件或环境变量中提取)。
- 计算包体大小:定位构建产物路径,统一计算文件大小并转换为 MB 单位(确保双端数据标准一致)。
- 生成结构化数据:将包大小、文件 MD5、文件名、构建时间等信息整理为 JSON 格式,便于后续分析。
- 实时上报数据:调用神策 API 推送数据,内置重试机制,避免网络波动导致数据丢失。
4.3 双端具体实现方案
(1)Android 端(APK/AAB)
- 构建工具:Fastlane + Gradle + Shell/Python 脚本
-
关键步骤:
- 信息提取:从
app/build.gradle或 CI 环境变量中读取versionName(版本号)、versionCode(构建号),通过 Fastlane 命令入参获取渠道信息。 - 路径定位:APK 默认路径为
app/build/outputs/apk/${channel}/${buildType}/,AAB 默认路径为app/build/outputs/bundle/${channel}Release/。项目具体路径以当前项目为准。 - 大小计算:通过 Python 脚本封装计算逻辑,传入文件路径即可获取字节数,转换为 MB 并保留 2 位小数(确保精度)。
- 信息提取:从
(2)iOS 端(IPA)
- 构建工具:Fastlane + Xcode + CI 脚本
-
关键步骤:
- 信息提取:从
Info.plist中读取CFBundleShortVersionString(版本号)、CFBundleVersion(构建号),通过 Fastlane 参数或CHANNEL环境变量指定渠道。 - 路径定位:使用 Fastlane 的
gym工具构建后,通过lane_context[SharedValues::IPA_OUTPUT_PATH]直接获取 IPA 路径,无需手动配置。 - 大小计算:复用 Android 端 Python 脚本,统一计算逻辑,保证双端数据一致性。
- 信息提取:从
4.4 核心脚本:通用上报工具app_size_reporter.py
脚本封装了文件大小计算、MD5 校验、神策 API 上报等核心功能,支持命令行参数配置,适配不同场景。关键功能拆解:
-
文件大小计算:通过
os.path.getsize获取字节数,转换为 MB 单位(size_mb = round(size_bytes / (1024 * 1024), 2))。 - MD5 校验:读取文件 4096 字节分片,计算 MD5 值,确保包体完整性可追溯。
-
数据上报:集成神策 Python SDK,支持 Debug/生产环境切换,测试环境使用
DebugConsumer进行逐个数据验证,生产环境可启用批量上报(BatchConsumer)优化性能。 -
灵活调用:支持通过命令行传入
--app-path(包体路径)、--build-version(版本号)、--channel(渠道)等参数,示例如下:
🚀上报脚本🚀
#!/usr/bin/env python3
"""
神策数据上报脚本 - App 包大小监控
用于 Jenkins CI/CD 流程中上报应用包大小相关数据
"""
import os
import sys
import json
import hashlib
import sensorsanalytics
from datetime import datetime
# 发送数据的超时时间,单位秒
SA_REQUEST_TIMEOUT = 10
# Debug 模式下,是否将数据导入神策分析
SA_DEBUG_WRITE_DATA = True
# 神策项目名称
SA_PROJECT = 'default'
# 神策接收地址(通过 SA_PROJECT 动态拼接)
SA_SERVER_URL = f"https://xxx/sa?project={SA_PROJECT}"
class AppSizeReporter:
def __init__(self, project='xxx'):
"""
初始化神策上报器
Args:
project: 项目名称
"""
try:
# 初始化神策 SDK
self.sa = sensorsanalytics.SensorsAnalytics(
sensorsanalytics.DebugConsumer(SA_SERVER_URL, SA_DEBUG_WRITE_DATA, SA_REQUEST_TIMEOUT)
# 生产环境建议使用以下方式:
# sensorsanalytics.BatchConsumer(SA_SERVER_URL, 1, 1)
)
self.project = project
print(f"✅ 神策 SDK 初始化成功,项目: {project}")
except Exception as e:
print(f"❌ 神策 SDK 初始化失败: {e}")
sys.exit(1)
def get_file_size(self, file_path):
"""获取文件大小(MB)"""
if not os.path.exists(file_path):
raise FileNotFoundError(f"文件不存在: {file_path}")
size_bytes = os.path.getsize(file_path)
size_mb = round(size_bytes / (1024 * 1024), 2)
return size_mb
def get_file_md5(self, file_path):
"""计算文件的 MD5 值"""
hash_md5 = hashlib.md5()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_md5.update(chunk)
return hash_md5.hexdigest()
def collect_app_size_info(self, app_path, build_info=None):
"""
收集 App 包大小信息
Args:
app_path: App 文件路径(可以是 .apk, .ipa, .aab 等)
build_info: 构建信息字典
"""
file_name = os.path.basename(app_path)
file_extension = os.path.splitext(file_name)[1].lower()
# 基础文件信息
size_mb = self.get_file_size(app_path)
file_md5 = self.get_file_md5(app_path)
# 构建默认的构建信息
if build_info is None:
build_info = {}
default_build_info = {
'build_user': os.environ.get('BUILD_USER_ID', os.environ.get('USER', 'unknown'))
}
# 合并构建信息
build_info = {**default_build_info, **build_info}
# 组装上报数据
event_data = {
'size_mb': size_mb,
'file_name': file_name,
'file_type': file_extension,
'file_md5': file_md5,
'report_time': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
**build_info
}
return event_data
def report_app_size(self, distinct_id, app_path, build_info=None, event_name='AppPackageSize'):
"""
上报 App 包大小数据
Args:
distinct_id: 用户标识(可以是项目名、构建ID等)
app_path: App 文件路径
build_info: 构建信息
event_name: 事件名称
"""
try:
# 收集数据
event_data = self.collect_app_size_info(app_path, build_info)
# 上报数据
self.sa.track(distinct_id, event_name, event_data)
# 立即提交数据(对于 DebugConsumer 会自动提交,BatchConsumer 需要 flush)
if hasattr(self.sa, 'flush'):
self.sa.flush()
print(f"✅ 成功上报 {event_name} 事件")
print(f" 环境: {SA_PROJECT}")
print(f" 文件: {event_data['file_name']}")
print(f" 大小: {event_data['size_mb']} MB")
print(f" MD5: {event_data['file_md5']}")
return True
except Exception as e:
print(f"❌ 数据上报失败: {e}")
return False
def close(self):
"""关闭神策 SDK"""
if hasattr(self.sa, 'close'):
self.sa.close()
print("🔚 神策 SDK 已关闭")
def main():
"""主函数 - 用于命令行调用"""
import argparse
parser = argparse.ArgumentParser(description='上报 App 包大小数据到神策')
parser.add_argument('--app-path', required=True, help='App 文件路径')
parser.add_argument('--project', default='xxx', help='项目名称')
parser.add_argument('--distinct-id', required=True, help='唯一标识(建议使用项目名)')
parser.add_argument('--event-name', default='AppPackageSize', help='事件名称')
parser.add_argument('--build-version', help='构建版本号')
parser.add_argument('--build-number', help='构建号')
parser.add_argument('--build-type', help='构建类型(fat/uat/pro)')
parser.add_argument('--git-branch', help='Git 分支')
parser.add_argument('--git-commit', help='Git 提交 ID')
parser.add_argument('--build-user', help='构建用户')
parser.add_argument('--channel', help='渠道')
parser.add_argument('--link', help='下载链接')
parser.add_argument('--extra', help='额外信息')
args = parser.parse_args()
# 构建信息
build_info = {}
if args.build_version:
build_info['build_version'] = args.build_version
if args.build_type:
build_info['build_type'] = args.build_type
if args.build_number:
build_info['build_number'] = args.build_number
if args.git_branch:
build_info['git_branch'] = args.git_branch
if args.git_commit:
build_info['git_commit'] = args.git_commit
if args.build_user:
build_info['build_user'] = args.build_user
if args.channel:
build_info['channel'] = args.channel
if args.link:
build_info['link'] = args.link
if args.extra:
build_info['extra'] = args.extra
# 创建上报器并执行上报
reporter = AppSizeReporter(project=args.project)
try:
success = reporter.report_app_size(
distinct_id=args.distinct_id,
app_path=args.app_path,
build_info=build_info,
event_name=args.event_name
)
sys.exit(0 if success else 1)
finally:
reporter.close()
if __name__ == "__main__":
main()
🚀CLI 中调用测试🚀
python3 scripts/app_size_reporter.py \
--app-path "/Users/xxx/xxx_20251031.apk" \
--distinct-id "xxx-app-android" \
--build-version "2.3.1" \
--build-type "pro" \
--channel "google_play" \
--git-branch "main" \
--git-commit "$(git rev-parse --short HEAD)"
五、数据接入与分析:让包大小变化“有据可查”
5.1 神策上报数据结构
上报数据包含“补充信息、构建信息、产物信息”三大类字段,支持多维度筛选,核心字段如下表:
| 字段名 | 类型 | 分类 | 含义 | 示例值 |
|---|---|---|---|---|
event_name |
string | 补充信息 | 事件名称(固定) | AppPackageSize |
extra |
string | 额外信息(debug 标识) | 测试过程传 debug,正式传空,用于过滤测试数据 |
|
build_user |
string | 构建信息 | 构建用户 | jenkins |
report_time |
datetime | 构建时间(毫秒级) | 上报内容为1762157537000 格式化后2025-11-03 16:12:17
|
|
build_version |
string | App 版本号 | 1.2.3 |
|
build_number |
string | 构建号(迭代标识) | 2025102701 |
|
build_type |
string | 构建类型 | 取 bundle exec fastlane ios/android 后面的变量dev/stg/prd/release
|
|
git_branch |
string | 构建代码的 git 分支信息 | main |
|
git_commit |
string | 构建代码的 git 提交信息 | b6208a101b8f94049d69ea4b38f6d232f19e84de | |
channel |
string | 产物信息 | 渠道名称 |
app_store/google_play
|
file_name |
string | 文件名称 |
xxx.apk xxx.ipa
|
|
file_type |
string | 文件类型 |
.ipa/.apk/.aab
|
|
file_md5 |
string | 文件 MD5 | 792f6395012401d981f3239ebd68b1ab | |
link |
string | 包地址 | 安装包地址 | |
size_mb |
number | 包大小(MB) | 25.00 |
5.2 可视化与查询
- Dashboard 展示:在神策平台配置版本趋势图、双端对比表、渠道差异图,直观呈现包大小变化。
- 数据查询:通过 SQL 快速筛选目标数据,例如查询调试环境近 1000 条记录:
SELECT
date,
SUBSTRING(CAST(time AS STRING), 1, 19) as fmt_time,
extra,
distinct_id,
report_time,
build_user,
build_type,
build_version,
build_number,
channel,
size_mb,
file_name,
file_type,
file_md5,
link,
git_branch,
git_commit
FROM
events
WHERE
event = 'AppPackageSize'
AND extra = 'debug'
ORDER BY
`time` DESC
LIMIT
1000;
六、落地价值:从“被动应对”到“主动管控”
集成 CI/CD 后,包大小监控实现了三大关键转变:
- 效率提升:从人工统计 10 分钟/版本,变为构建完成自动上报,效率提升 100%。
- 数据可靠:统一计算逻辑与单位(MB),避免人工误差,数据一致性达 100%。
- 响应及时:异常增长可快速定位到分支、提交记录或渠道,例如某版本第三方渠道包体突增 50MB,排查发现是渠道 SDK 未按需打包,及时优化后恢复正常。
七、总结
包大小监控的核心,是将“隐性指标”转化为“显性数据”。通过嵌入 CI/CD 流程,无需额外开发成本,即可实现全链路自动化,为应用下载转化与用户体验保驾护航。未来可进一步增加阈值预警(如增长超 10%触发告警)、冗余资源检测,让包大小优化从“被动排查”升级为“主动预防”。