阅读视图

发现新文章,点击刷新页面。

🧩 深入浅出讲解: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. 初始化并查集,处理所有连接关系,将连接的电站合并到同一个集合中。
  2. 为每个电网创建一个有序集合,初始时将所有电站编号加入对应电网的集合中。
  3. 遍历查询列表:
    • 对于查询 $[1, x]$,首先找到电站 $x$ 所属的电网根节点,然后检查该电网的有序集合:
      • 如果电站 $x$ 在线(存在于集合中),则返回 $x$。
      • 否则,返回集合中的最小编号电站(如果集合非空),否则返回 -1。
    • 对于查询 $[2, x]$,找到电站 $x$ 所属的电网根节点,并将电站 $x$ 从该电网的有序集合中删除,表示该电站离线。
  4. 最后,返回所有类型为 $[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$ 分别是连接数量和查询数量。


有任何问题,欢迎评论区交流,欢迎评论区提供其它解题思路(代码),也可以点个赞支持一下作者哈😄~

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. 参考链接

  1. allthingssmitty.com/2025/10/20/…

commonjs 和 ES Module

1. CommonJS (CJS) 是什么? CommonJS 是为 服务器端 设计的模块化规范,最著名的实现就是 Node.js。它的诞生远早于浏览器原生支持模块化。 核心思想: 通过 requir

Three.js 工业 3D 可视化:生产线状态监控系统实现方案

在工业数字化转型过程中,3D 可视化监控系统凭借直观、沉浸式的优势,成为车间管理的重要工具。本文将详细介绍如何使用 Three.js 构建一套生产线 3D 状态监控系统,实现设备状态展示、产能数据可视化、交互式操作等核心功能

联想截图_20251106173926.jpg

一、项目背景与技术选型

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 模型显示清晰度
  • 双面渲染:确保围墙等几何体正反面都能正常显示
  • 纹理复用:减少重复纹理加载,提升性能
  • 事件监听优化:仅在必要时重新渲染场景

在Node.js中分析内存占用

在Node.js中分析内存占用(尤其是排查内存泄漏、优化内存使用)时,有很多实用工具和方法。以下是常用的内存分析工具及使用方式,按场景分类整理: 一、内置基础工具(简单监控) Node.js自带了一些

浏览器&Websocket&热更新

热更新基本流程图 一、先明确:什么是热更新(HMR)? 热更新是指:在开发过程中,当代码发生修改并保存后,浏览器无需刷新整个页面,仅更新修改的模块(如组件、样式、逻辑等),同时保留页面当前状态(如表单

前端工程化实战:手把手教你构建项目脚手架

面对如今丰富的前端生态,开启新项目时你是否经常陷入这样的纠结:

  1. 在选择构建工具、UI框架、要不要TS等技术选型时,是不是都要重新研究最新的最佳实践?
  2. 当团队需要内部的代码规范、工具链配置、私有依赖等总要手动添加,而影响开发效率?
  3. 当新成员加入时,是否需要大量时间理解项目结构、配置规范,导致配置不一致导致各种奇怪问题?
  4. 当团队项目需要添加特定的中后台、组件库等场景,总要重复的基建代码的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后回车:

image.png

然后我们再添加一行代码,通过commanderprogram解析命令行参数:

#!/usr/bin/env node
/* 依赖引入就此省略 */ 
console.log(chalk.blue("学习脚手架工具已启动!"));
// 解析命令行参数
program.parse(process.argv);

输入case-cli -h命令:

image.png

添加获取版本的指令

#!/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也有变化

image.png

一般情况下脚手架类似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时,将呈现如下画面:

image.png 任意选择一项后: image.png

检查项目名称是否重复

脚手架是以项目名称为目录名称,在当前输入指令的目录下创建的,因此需要检查是否有相同的目录名。并给出提示。

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");
  }
});

拉取远程模板项目: image.png 拉取完成后:

image.png

小结

通过本文,我们完成了一个基础但功能完整的前端脚手架,实现了项目创建、模板拉取、冲突处理等核心功能。这个简单的脚手架已经能够解决文章开头提到的部分痛点:

✅ 统一技术选型 - 通过预设模板固化团队最佳实践
✅ 快速初始化 - 一键生成项目结构,告别手动配置
✅ 规范团队协作 - 新成员无需理解复杂配置,开箱即用

但这仅仅是一个开始!  你可以基于这个基础版本,根据团队实际需求进行深度定制:

🛠 模板动态化 - 集成 ejs 等模板引擎,根据用户选择动态生成配置文件
🛠 生态集成 - 添加 ESLint、Prettier、Husky 等工程化工具链
🛠 场景扩展 - 针对中后台、组件库、H5 等不同场景提供专属模板
🛠 插件机制 - 设计插件系统,让团队成员也能贡献功能模块

最好的脚手架不是功能最全的,而是最适合团队工作流的。  希望本文能成为你打造团队专属工具链的起点,让重复的配置工作成为历史,把宝贵的时间留给更有价值的创新!

为 CI/CD 装上“眼睛”:App 包大小监控的实践

包大小直接影响下载转化与用户留存。传统人工统计效率低、难溯源。本文将分享如何将包大小监控嵌入 CI/CD 流程,实现自动化采集与分析,为应用性能装上“眼睛”。

一、为什么必须重视包大小监控?数据告诉你答案

包大小与业务增长直接挂钩,谷歌开发者大会 2019 年数据揭示了核心关联:

  • 转化率敏感:包体每上升 6MB,应用下载转化率下降 1%;减少 10MB 时,全球平均转化率提升 1.75%,印度、巴西等新兴市场提升超 2.0%,美国、德国等高端市场提升 1.5%。

  • 用户决策影响:包体超 200MB(2019 年标准)时,App Store 会弹窗提醒流量费用,蜂窝网络下用户放弃率显著上升;同时,下载时长过长、网络波动导致的安装失败,本质都是包大小引发的连锁问题。

这些数据证明,包大小监控不是“可选优化”,而是保障用户转化的“基础工程”。

二、核心目标:让监控实现“三自动化”

基于业务需求,包大小监控需达成三大核心目标,解决传统人工统计的痛点:

  1. 数据采集自动化:覆盖 iOS/Android 双端、全渠道(应用商店/官网/第三方),自动抓取每个版本的包大小数据,无需人工干预。
  2. 数据分析自动化:数据实时同步至神策数据分析平台(这里的平台可以按实际情况进行选择,这里以神策为例进行讲解),支持按版本趋势、端内对比、渠道差异多维度拆解,快速定位变化原因。
  3. 流程集成自动化:嵌入 CI/CD 环节(Jenkins/GitLab CI/Fastlane),每一次构建自动触发监控,确保数据一致性与可追溯性。

三、架构设计:从构建到分析的全链路闭环

整个监控体系遵循“轻量集成、无侵入”原则,核心架构流程如下:

无需新增复杂组件,仅通过脚本工具与 API 调用,即可实现从包体构建到数据呈现的全自动化,不影响原有 CI/CD 流程效率。

四、落地实现:分步骤搭建自动化监控体系

4.1 适用环境

  • 兼容主流 CI/CD 工具链:Jenkins、GitLab CI、Fastlane;
  • 支持双端包体格式:Android(APK/AAB)、iOS(IPA)。

4.2 核心实现逻辑

在构建流程结束后,通过脚本完成“信息采集→大小计算→数据上报”闭环,具体步骤:

  1. 获取构建元信息:自动读取版本号、构建号、渠道、Git 分支/提交记录等关键数据(从配置文件或环境变量中提取)。
  2. 计算包体大小:定位构建产物路径,统一计算文件大小并转换为 MB 单位(确保双端数据标准一致)。
  3. 生成结构化数据:将包大小、文件 MD5、文件名、构建时间等信息整理为 JSON 格式,便于后续分析。
  4. 实时上报数据:调用神策 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 后,包大小监控实现了三大关键转变:

  1. 效率提升:从人工统计 10 分钟/版本,变为构建完成自动上报,效率提升 100%。
  2. 数据可靠:统一计算逻辑与单位(MB),避免人工误差,数据一致性达 100%。
  3. 响应及时:异常增长可快速定位到分支、提交记录或渠道,例如某版本第三方渠道包体突增 50MB,排查发现是渠道 SDK 未按需打包,及时优化后恢复正常。

七、总结

包大小监控的核心,是将“隐性指标”转化为“显性数据”。通过嵌入 CI/CD 流程,无需额外开发成本,即可实现全链路自动化,为应用下载转化与用户体验保驾护航。未来可进一步增加阈值预警(如增长超 10%触发告警)、冗余资源检测,让包大小优化从“被动排查”升级为“主动预防”。

❌