普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月12日首页

AT 的人生未必比 MT 更好 - 肘子的 Swift 周报 #118

作者 Fatbobman
2026年1月12日 22:00

学车时我开的是手动挡,起初因为技术生疏,常搞得手忙脚乱,所以第一台车就直接选了自动挡。但开了几年,我开始追求那种完全掌控的驾驶感,于是又增购了一台手动挡。遗憾的是,随着交通日益拥堵,换挡的乐趣逐渐被疲惫抵消,最终这台车也被冷落。算起来,我已经快二十年没认真开过手动挡了,但内心深处,我仍会时不时地怀念那段“人车合一”的时光。

我用 Gemini 3 Pro 手搓了一个并发邮件群发神器(附源码)

作者 ErpanOmer
2026年1月12日 12:16
这个周末我失业了🤣。 起因很简单:公司项目原因,我需要给订阅列表里的几千个用户发一封更新通知。 市面上的邮件营销工具(Mailchimp 之类)死贵,还要一个个导入联系人;自己写脚本吧,以前得折腾半天

拿捏年终总结:自动提取GitLab提交记录

2026年1月12日 10:29

一、脚本功能概述

这是一个用于自动提取GitLab提交记录的Node.js脚本,专为年终总结设计。它可以:

  1. 根据指定的时间范围批量获取GitLab提交记录
  2. 过滤掉合并提交,只保留实际代码变更
  3. 按项目分组展示提交记录
  4. 生成Markdown格式的提交汇总报告

二、核心模块解析

1. 环境变量读取模块

javascript

function readEnvFile(envPath) {
  const content = fs.readFileSync(envPath, 'utf8');
  const lines = content.split(/\r?\n/).filter(Boolean);
  const env = {};
  for (const line of lines) {
    if (line.trim().startsWith('#')) continue;
    const idx = line.indexOf('=');
    if (idx === -1) continue;
    const key = line.slice(0, idx).trim();
    const value = line.slice(idx + 1).trim();
    env[key] = value;
  }
  return env;
}

功能说明:读取.env配置文件,解析为键值对。

配置说明

env

# GitLab服务器地址
GITLAB_URL=https://your.gitlab.server.com

# GitLab访问令牌(从GitLab个人设置中获取)
GITLAB_TOKEN=your_gitlab_access_token

# 可选:作者用户名(用于过滤提交)
GITLAB_AUTHOR_USERNAME=your_username

# 可选:指定项目ID(多个用逗号分隔)
GITLAB_PROJECT_IDS=123,456,789

2. 命令行参数解析模块

javascript

function parseArgs(argv) {
  const args = {};
  for (let i = 2; i < argv.length; i++) {
    const arg = argv[i];
    if (arg.startsWith('--')) {
      const [k, v] = arg.split('=');
      args[k.slice(2)] = v;
    }
  }
  return args;
}

功能说明:解析命令行参数,支持--since--until参数。

3. 时间范围处理模块

javascript

function ensureIsoRange(sinceInput, untilInput) {
  const sinceIsDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(sinceInput);
  const untilIsDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(untilInput);
  if (sinceIsDateOnly && untilIsDateOnly) {
    const { since } = toIsoRangeDayStartEnd(sinceInput);
    const { until } = toIsoRangeDayStartEnd(untilInput);
    return { since, until };
  }
  const since = new Date(sinceInput).toISOString();
  const until = new Date(untilInput).toISOString();
  return { since, until };
}

功能说明:将用户输入的时间范围转换为ISO标准格式,支持日期格式和完整时间格式。

4. API请求模块

javascript

function requestJson(urlStr, headers = {}) {
  return new Promise((resolve, reject) => {
    const u = new URL(urlStr);
    const { protocol, hostname, port, pathname, search } = u;
    const lib = protocol === 'https:' ? https : http;
    const options = {
      hostname,
      port: port || (protocol === 'https:' ? 443 : 80),
      path: `${pathname}${search}`,
      method: 'GET',
      headers,
    };
    const req = lib.request(options, (res) => {
      const { statusCode, headers: resHeaders } = res;
      const chunks = [];
      res.on('data', (c) => chunks.push(c));
      res.on('end', () => {
        const body = Buffer.concat(chunks).toString('utf8');
        if (statusCode >= 200 && statusCode < 300) {
          try {
            const json = JSON.parse(body);
            resolve({ json, headers: resHeaders, statusCode });
          } catch (e) {
            reject(new Error(`Invalid JSON ${statusCode}: ${body.slice(0, 200)}`));
          }
        } else {
          reject(new Error(`HTTP ${statusCode}: ${body.slice(0, 200)}`));
        }
      });
    });
    req.on('error', reject);
    req.end();
  });
}

功能说明:发送HTTP/HTTPS请求,返回JSON格式的响应。

5. GitLab API调用模块

javascript

async function fetchAllCommits(baseUrl, token, id, since, until, author) {
  const collected = [];
  let page = 1;
  for (;;) {
    const params = { since, until, per_page: 100, page, with_stats: false, author };
    const { commits, nextPage } = await fetchCommitsPage(baseUrl, token, id, params);
    collected.push(...commits);
    if (!nextPage) break;
    page = parseInt(nextPage, 10);
    if (!Number.isFinite(page) || page <= 0) break;
  }
  return collected;
}

功能说明:分页获取GitLab提交记录,支持作者过滤。

6. 提交记录过滤模块

javascript

function filterNonMerge(commits) {
  const filtered = [];
  for (const commit of commits) {
    const { parent_ids } = commit;
    const nonMerge = Array.isArray(parent_ids) ? parent_ids.length <= 1 : true;
    if (nonMerge) filtered.push(commit);
  }
  return filtered;
}

功能说明:过滤掉合并提交,只保留实际代码变更的提交。

7. 报告生成模块

javascript

function buildMarkdown(range, author, grouped) {
  const { since, until } = range;
  const { username, name } = author;
  const lines = [];
  lines.push(`# 提交汇总`);
  lines.push(`- 作者: ${name || username || ''}`);
  lines.push(`- 时间范围: ${since}${until}`);
  for (const project of grouped.projects) {
    const { name: projName } = project.meta;
    lines.push(`\n项目: ${projName}`);
    const commits = project.commits;
    for (const commit of commits) {
      lines.push(formatCommitLine(project.meta, commit));
    }
  }
  return `${lines.join('\n')}\n`;
}

功能说明:生成Markdown格式的提交汇总报告。

三、使用方法

  1. 安装依赖:无需额外依赖,使用Node.js内置模块。

  2. 配置.env文件:根据实际情况修改.env文件中的配置。

  3. 运行脚本

    bash

    node fetch_commits.js --since=2025-01-01 --until=2025-12-31
    node fetch_commits.js --since=2025-06-01 --until=2026-01-11 --author=你的提交用户名
    
  4. 查看报告:脚本会生成commits.md文件,包含指定时间范围内的提交记录。

四、完整代码 同级创建.env即可使用

javascript

const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');

function readEnvFile(envPath) {
  const content = fs.readFileSync(envPath, 'utf8');
  const lines = content.split(/\r?\n/).filter(Boolean);
  const env = {};
  for (const line of lines) {
    if (line.trim().startsWith('#')) continue;
    const idx = line.indexOf('=');
    if (idx === -1) continue;
    const key = line.slice(0, idx).trim();
    const value = line.slice(idx + 1).trim();
    env[key] = value;
  }
  return env;
}

function parseArgs(argv) {
  const args = {};
  for (let i = 2; i < argv.length; i++) {
    const arg = argv[i];
    if (arg.startsWith('--')) {
      const [k, v] = arg.split('=');
      args[k.slice(2)] = v;
    }
  }
  return args;
}

function toIsoRangeDayStartEnd(dateStr) {
  const start = new Date(`${dateStr}T00:00:00.000Z`);
  const end = new Date(`${dateStr}T23:59:59.999Z`);
  return { since: start.toISOString(), until: end.toISOString() };
}

function ensureIsoRange(sinceInput, untilInput) {
  const sinceIsDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(sinceInput);
  const untilIsDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(untilInput);
  if (sinceIsDateOnly && untilIsDateOnly) {
    const { since } = toIsoRangeDayStartEnd(sinceInput);
    const { until } = toIsoRangeDayStartEnd(untilInput);
    return { since, until };
  }
  const since = new Date(sinceInput).toISOString();
  const until = new Date(untilInput).toISOString();
  return { since, until };
}

function requestJson(urlStr, headers = {}) {
  return new Promise((resolve, reject) => {
    const u = new URL(urlStr);
    const { protocol, hostname, port, pathname, search } = u;
    const lib = protocol === 'https:' ? https : http;
    const options = {
      hostname,
      port: port || (protocol === 'https:' ? 443 : 80),
      path: `${pathname}${search}`,
      method: 'GET',
      headers,
    };
    const req = lib.request(options, (res) => {
      const { statusCode, headers: resHeaders } = res;
      const chunks = [];
      res.on('data', (c) => chunks.push(c));
      res.on('end', () => {
        const body = Buffer.concat(chunks).toString('utf8');
        if (statusCode >= 200 && statusCode < 300) {
          try {
            const json = JSON.parse(body);
            resolve({ json, headers: resHeaders, statusCode });
          } catch (e) {
            reject(new Error(`Invalid JSON ${statusCode}: ${body.slice(0, 200)}`));
          }
        } else {
          reject(new Error(`HTTP ${statusCode}: ${body.slice(0, 200)}`));
        }
      });
    });
    req.on('error', reject);
    req.end();
  });
}

function buildApiUrl(base, pathStr, query = {}) {
  const u = new URL(pathStr, base);
  const entries = Object.entries(query).filter(([, v]) => v !== undefined && v !== null);
  for (const [k, v] of entries) {
    u.searchParams.set(k, String(v));
  }
  return u.toString();
}

async function fetchProjectMeta(baseUrl, token, id) {
  const url = buildApiUrl(baseUrl, `/api/v4/projects/${encodeURIComponent(id)}`);
  const headers = { 'PRIVATE-TOKEN': token };
  const { json } = await requestJson(url, headers);
  const { name, path_with_namespace, web_url } = json;
  return { id, name, path_with_namespace, web_url };
}

async function fetchCommitsPage(baseUrl, token, id, params) {
  const url = buildApiUrl(
    baseUrl,
    `/api/v4/projects/${encodeURIComponent(id)}/repository/commits`,
    params
  );
  const headers = { 'PRIVATE-TOKEN': token };
  const { json, headers: resHeaders } = await requestJson(url, headers);
  const { ['x-next-page']: nextPage, ['x-page']: page, ['x-total-pages']: totalPages } = resHeaders;
  return { commits: json, nextPage, page, totalPages };
}

async function fetchAllCommits(baseUrl, token, id, since, until, author) {
  const collected = [];
  let page = 1;
  for (;;) {
    const params = { since, until, per_page: 100, page, with_stats: false, author };
    const { commits, nextPage } = await fetchCommitsPage(baseUrl, token, id, params);
    collected.push(...commits);
    if (!nextPage) break;
    page = parseInt(nextPage, 10);
    if (!Number.isFinite(page) || page <= 0) break;
  }
  return collected;
}

function filterNonMerge(commits) {
  const filtered = [];
  for (const commit of commits) {
    const { parent_ids } = commit;
    const nonMerge = Array.isArray(parent_ids) ? parent_ids.length <= 1 : true;
    if (nonMerge) filtered.push(commit);
  }
  return filtered;
}

function formatCommitLine(project, commit) {
  const { short_id, title, message, committed_date, author_name, author_email } = commit;
  const main = (title || message || '').replace(/\r?\n/g, ' ');
  const ts = formatDateLocal(committed_date);
  return `- ${ts} | ${short_id} | ${main} | ${author_name} <${author_email}>`;
}

function pad2(n) {
  return String(n).padStart(2, '0');
}

function formatDateLocal(iso) {
  const d = new Date(iso);
  const y = d.getFullYear();
  const m = pad2(d.getMonth() + 1);
  const day = pad2(d.getDate());
  const hh = pad2(d.getHours());
  const mm = pad2(d.getMinutes());
  const ss = pad2(d.getSeconds());
  return `${y}-${m}-${day} ${hh}:${mm}:${ss}`;
}

function buildMarkdown(range, author, grouped) {
  const { since, until } = range;
  const { username, name } = author;
  const lines = [];
  lines.push(`# 提交汇总`);
  lines.push(`- 作者: ${name || username || ''}`);
  lines.push(`- 时间范围: ${since}${until}`);
  for (const project of grouped.projects) {
    const { name: projName } = project.meta;
    lines.push(`\n项目: ${projName}`);
    const commits = project.commits;
    for (const commit of commits) {
      lines.push(formatCommitLine(project.meta, commit));
    }
  }
  return `${lines.join('\n')}\n`;
}

async function fetchMembershipProjects(baseUrl, token) {
  const headers = { 'PRIVATE-TOKEN': token };
  const projects = [];
  let page = 1;
  for (;;) {
    const url = buildApiUrl(baseUrl, '/api/v4/projects', {
      membership: true,
      simple: true,
      per_page: 100,
      page,
      order_by: 'last_activity_at',
    });
    const { json, headers: resHeaders } = await requestJson(url, headers);
    for (const item of json) {
      const { id, name, path_with_namespace, web_url } = item;
      projects.push({ id, name, path_with_namespace, web_url });
    }
    const nextPage = resHeaders['x-next-page'];
    if (!nextPage) break;
    page = parseInt(nextPage, 10);
    if (!Number.isFinite(page) || page <= 0) break;
  }
  return projects;
}

async function resolveAuthorQuery(baseUrl, token, username, override) {
  if (override) return override;
  if (!username) return null;
  const url = buildApiUrl(baseUrl, '/api/v4/users', { username });
  const headers = { 'PRIVATE-TOKEN': token };
  const { json } = await requestJson(url, headers);
  if (Array.isArray(json) && json.length > 0) {
    const { name } = json[0];
    return name || username;
  }
  return username;
}

function filterByAuthorName(commits, authorName) {
  if (!authorName) return commits;
  const out = [];
  for (const commit of commits) {
    const { author_name } = commit;
    if (author_name === authorName) out.push(commit);
  }
  return out;
}

async function main() {
  const cwd = process.cwd();
  const envPath = path.join(cwd, '.env');
  const env = readEnvFile(envPath);
  const {
    GITLAB_URL,
    GITLAB_TOKEN,
    GITLAB_AUTHOR_USERNAME,
  } = env;
  const args = parseArgs(process.argv);
  const { since: sinceRaw, until: untilRaw, author: authorArg } = args;
  if (!GITLAB_URL || !GITLAB_TOKEN || !sinceRaw || !untilRaw) {
    process.stderr.write(
      '缺少必要配置或参数。需要 GITLAB_URL, GITLAB_TOKEN, --since=YYYY-MM-DD, --until=YYYY-MM-DD\n'
    );
    process.exit(1);
  }
  const { since, until } = ensureIsoRange(sinceRaw, untilRaw);
  const desiredAuthor = authorArg || 'zhouzb';
  const authorQuery = await resolveAuthorQuery(GITLAB_URL, GITLAB_TOKEN, GITLAB_AUTHOR_USERNAME, desiredAuthor);
  const authorInfo = { username: GITLAB_AUTHOR_USERNAME, name: desiredAuthor };
  let metas = [];
  if (env.GITLAB_PROJECT_IDS) {
    const ids = env.GITLAB_PROJECT_IDS.split(',').map((s) => s.trim()).filter(Boolean);
    for (const id of ids) {
      const meta = await fetchProjectMeta(GITLAB_URL, GITLAB_TOKEN, id);
      metas.push(meta);
    }
  } else {
    metas = await fetchMembershipProjects(GITLAB_URL, GITLAB_TOKEN);
  }
  const grouped = { projects: [] };
  for (const meta of metas) {
    const { id } = meta;
    const all = await fetchAllCommits(GITLAB_URL, GITLAB_TOKEN, id, since, until, authorQuery || undefined);
    const filtered = filterByAuthorName(filterNonMerge(all), desiredAuthor);
    if (filtered.length > 0) grouped.projects.push({ meta, commits: filtered });
  }
  const md = buildMarkdown({ since, until }, authorInfo, grouped);
  fs.writeFileSync(path.join(cwd, 'commits.md'), md, 'utf8');
}

main().catch((e) => {
  const { message } = e;
  process.stderr.write(`${message}\n`);
  process.exit(1);
});

在 Vue3 中使用 LogicFlow 更新节点名称

作者 持续前行
2026年1月12日 09:57

在 Vue3 中更新 LogicFlow 节点名称有多种方式,下面我为你详细介绍几种常用方法。

🔧 核心更新方法

1. 使用 updateText方法(推荐)

这是最直接的方式,通过节点 ID 更新文本内容:

<template>
  <div>
    <div ref="container" style="width: 100%; height: 500px;"></div>
    <button @click="updateNodeName">更新节点名称</button>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import LogicFlow from '@logicflow/core';
import '@logicflow/core/dist/style/index.css';

const container = ref(null);
const lf = ref(null);
const selectedNodeId = ref('');

onMounted(() => {
  lf.value = new LogicFlow({
    container: container.value,
    grid: true,
  });

  // 示例数据
  lf.value.render({
    nodes: [
      {
        id: 'node_1',
        type: 'rect',
        x: 100,
        y: 100,
        text: '原始名称'
      }
    ]
  });

  // 监听节点点击,获取选中节点ID
  lf.value.on('node:click', ({ data }) => {
    selectedNodeId.value = data.id;
  });
});

// 更新节点名称
const updateNodeName = () => {
  if (!selectedNodeId.value) {
    alert('请先点击选择一个节点');
    return;
  }

  const newName = prompt('请输入新的节点名称', '新名称');
  if (newName) {
    // 使用 updateText 方法更新节点文本
    lf.value.updateText(selectedNodeId.value, newName);
  }
};
</script>

2. 通过 setProperties方法更新

这种方法可以同时更新文本和其他属性:

// 更新节点属性,包括名称
const updateNodeWithProperties = () => {
  if (!selectedNodeId.value) return;

  const newNodeName = '更新后的节点名称';
  
  // 获取节点当前属性
  const nodeModel = lf.value.getNodeModelById(selectedNodeId.value);
  const currentProperties = nodeModel.properties || {};
  
  // 更新属性
  lf.value.setProperties(selectedNodeId.value, {
    ...currentProperties,
    nodeName: newNodeName,
    updatedAt: new Date().toISOString()
  });
  
  // 同时更新显示文本
  lf.value.updateText(selectedNodeId.value, newNodeName);
};

🎯 事件监听与交互方式

1. 双击编辑模式

实现双击节点直接进入编辑模式:

// 监听双击事件
lf.value.on('node:dblclick', ({ data }) => {
  const currentNode = lf.value.getNodeModelById(data.id);
  const currentText = currentNode.text?.value || '';
  
  const newText = prompt('编辑节点名称:', currentText);
  if (newText !== null) {
    lf.value.updateText(data.id, newText);
  }
});

2. 右键菜单编辑

结合 Menu 插件实现右键菜单编辑:

import { Menu } from '@logicflow/extension';
import '@logicflow/extension/lib/style/index.css';

// 初始化时注册菜单插件
lf.value = new LogicFlow({
  container: container.value,
  plugins: [Menu],
});

// 配置右键菜单
lf.value.extension.menu.setMenuConfig({
  nodeMenu: [
    {
      text: '编辑名称',
      callback: (node) => {
        const currentText = node.text || '';
        const newText = prompt('编辑节点名称:', currentText);
        if (newText) {
          lf.value.updateText(node.id, newText);
        }
      }
    },
    {
      text: '删除',
      callback: (node) => {
        lf.value.deleteNode(node.id);
      }
    }
  ]
});

💡 自定义节点名称编辑

对于自定义节点,可以重写文本相关方法:

import { RectNode, RectNodeModel } from '@logicflow/core';

class CustomNodeModel extends RectNodeModel {
  // 自定义文本样式
  getTextStyle() {
    const style = super.getTextStyle();
    return {
      ...style,
      fontSize: 14,
      fontWeight: 'bold',
      fill: '#1e40af',
    };
  }
  
  // 初始化节点数据
  initNodeData(data) {
    super.initNodeData(data);
    // 确保文本格式正确
    this.text = {
      x: data.x,
      y: data.y + this.height / 2 + 10,
      value: data.text || '默认节点'
    };
  }
}

// 注册自定义节点
lf.value.register({
  type: 'custom-node',
  view: RectNode,
  model: CustomNodeModel
});

🚀 批量更新与高级功能

1. 批量更新多个节点

// 批量更新所有节点名称
const batchUpdateNodeNames = () => {
  const graphData = lf.value.getGraphData();
  const updatedNodes = graphData.nodes.map(node => ({
    ...node,
    text: `${node.text}(已更新)`
  }));
  
  // 重新渲染
  lf.value.render({
    nodes: updatedNodes,
    edges: graphData.edges
  });
};

// 按条件更新节点
const updateNodesByCondition = () => {
  const graphData = lf.value.getGraphData();
  const updatedNodes = graphData.nodes.map(node => {
    if (node.type === 'rect') {
      return {
        ...node,
        text: `矩形节点-${node.id}`
      };
    }
    return node;
  });
  
  lf.value.render({
    nodes: updatedNodes,
    edges: graphData.edges
  });
};

2. 实时保存与撤销重做

// 监听文本变化并自动保存
lf.value.on('node:text-update', ({ data }) => {
  console.log('节点文本已更新:', data);
  saveToBackend(lf.value.getGraphData());
});

// 实现撤销重做功能
const undo = () => {
  lf.value.undo();
};

const redo = () => {
  lf.value.redo();
};

// 启用历史记录
lf.value = new LogicFlow({
  container: container.value,
  grid: true,
  history: true, // 启用历史记录
  historySize: 100 // 设置历史记录大小
});

⚠️ 注意事项与最佳实践

  1. 文本对象格式:LogicFlow 中文本可以是字符串或对象格式 {value: '文本', x: 100, y: 100}
  2. 更新时机:确保在 lf.render()之后再进行更新操作
  3. 错误处理:更新前检查节点是否存在
  4. 性能优化:批量更新时考虑使用防抖
// 安全的更新函数
const safeUpdateNodeName = (nodeId, newName) => {
  if (!lf.value) {
    console.error('LogicFlow 实例未初始化');
    return false;
  }
  
  const nodeModel = lf.value.getNodeModelById(nodeId);
  if (!nodeModel) {
    console.error(`节点 ${nodeId} 不存在`);
    return false;
  }
  
  try {
    lf.value.updateText(nodeId, newName);
    return true;
  } catch (error) {
    console.error('更新节点名称失败:', error);
    return false;
  }
};

这些方法涵盖了 Vue3 中 LogicFlow 节点名称更新的主要场景,你可以根据具体需求选择合适的方式。

告别手写礼簿!一款开源免费的电子红白喜事礼簿系统!

作者 Java陈序员
2026年1月12日 09:31

大家好,我是 Java陈序员

无论是儿女结婚的喜宴,还是亲友离世的白事,礼金记账都是绕不开的环节。

传统手写礼簿,不仅考验书写速度和细心程度,还面临着“记重了、算错了、丢了账本”的风险,既费人力又不省心。

而市面上的电子记账工具,要么依赖网络,要么数据存在云端,总担心隐私泄露。

今天,给大家推荐一款纯本地运行的电子礼簿系统,不用连网、不用注册、数据加密存储、安全又好用,红白喜事都适配!

项目介绍

gift-book —— 一款纯本地、零后端、完全本地运行的单页 Web 应用,旨在为各类红白喜事提供一个现代化、安全、高效的礼金(份子钱)管理解决方案。

功能特色

  • 无需联网:纯 HTML 单页应用,不依赖服务器,单页 Web 应用拔网线也能正常记账,数据 100% 存储在本地设备
  • 数据金融级加密保护:全量数据采用 AES-256 加密落库,管理密码通过 SHA-256 哈希保护,即使设备丢失、文件被拷贝,数据也无法破解
  • 秒级记账:姓名、金额、渠道(微信/支付宝/现金)全键盘操作,回车即录,支持实时检测重名、重复金额,并提供语音播报核对功能
  • 双色主题:内置 “喜庆红”(喜事)、“肃穆灰”(白事)两套皮肤,完美适配不同场景的氛围需求
  • 双屏互动:支持开启副屏页面,实时投射数据到外接屏幕/电视,副屏自动开启隐私模式,且支持自定义上传展示收款码
  • 专业级报表与归档:内置专业 PDF 引擎,生成的电子礼簿支持自定义字体、封面图、背景纹理,支持导出加密数据文件,跨设备可全量恢复
  • 开箱即用:普通用户免部署,无需安装任何环境,双击即可运行,同时可部署到服务器上,通过浏览器在线访问

快速上手

gift-book 由纯静态文件组成,无需安装任何环境。

1、打开下载地址,下载 Windows 预编译应用(gift-book.exe)

https://github.com/jingguanzhang/gift-book/releases

2、双击运行 gift-book.exe

3、初始化:创建新事项

设置事项名称及管理密码(请务必牢记,丢失无法找回)。

4、记账:录入数据

5、归档:活动结束后,务必导出 Excel 或 PDF 文件到电脑,微信收藏或云盘永久保存

功能体验

  • 礼金录入

  • 副屏

  • 礼簿

  • 礼金统计详情

本地开发

需要依赖代码编辑器(推荐 VS Code)和浏览器(Chrome/Edge)。

1、克隆或下载项目源码

git clone https://github.com/jingguanzhang/gift-book.git

2、在 VS Code 中打开项目代码

3、代码目录结构

gift-book
├── index1.html             # v1.1 专业版主入口(核心代码均内嵌于此,方便单文件分发)
├── index.html              # v1.0 基础版主入口
├── static/                 # 静态资源目录
    ├── tailwindcss.js      # 样式引擎
    ├── xlsx.full.min.js    # Excel 导出库
    ├── pdf-lib.min.js      # PDF 生成引擎
    ├── crypto-js.min.js    # 加密库
    └── fontkit & .ttf      # 字体文件(用于 PDF 生成)
└── guest-screen.html       # 副屏显示页面

4、右键 index.html 并选择 "Open with Live Server" 运行程序

需要在 VS Code 中提前安装插件 Live Server.

5、部署上线:无需编译,直接将所有文件上传至 GitHub Pages、Vercel、Nginx 或任何静态文件服务器即可

可以说,gift-book 这款纯本地电子礼簿,没有复杂的操作门槛,没有数据泄露的顾虑,只用简单的方式把账记准、记清、存好。快去试试吧~

项目地址:https://github.com/jingguanzhang/gift-book

最后

推荐的开源项目已经收录到 GitHub 项目,欢迎 Star

https://github.com/chenyl8848/great-open-source-project

或者访问网站,进行在线浏览:

https://chencoding.top:8090/#/

大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!


LeetCode 274. H 指数:两种高效解法全解析

作者 Wect
2026年1月12日 09:30

在科研成果评价领域,H 指数是一个非常经典的指标,而 LeetCode 274 题正是围绕 H 指数的计算展开。这道题看似简单,但背后藏着两种思路迥异的高效解法。今天我们就来深入剖析这道题,把两种解法的逻辑、实现和优劣讲透。

一、题目回顾与 H 指数定义

首先明确题目要求:给定一个整数数组 citations,其中 citations[i] 表示研究者的第 i 篇论文被引用的次数,计算并返回该研究者的 H 指数。

核心是理解 H 指数的定义(划重点):一名科研人员的 H 指数是指他至少发表了 h 篇论文,并且这 h 篇论文每篇的被引用次数都大于等于 h。如果存在多个可能的 h 值,取最大的那个。

举个例子帮助理解:若 citations = [1,3,1],H 指数是 1。因为研究者有 3 篇论文,其中至少 1 篇被引用 ≥1 次,而要达到 h=2 则需要至少 2 篇论文被引用 ≥2 次(实际只有 1 篇3次,不满足),所以最大的 h 是 1。

二、解法一:计数排序思路(时间 O(n),空间 O(n))

先看第一种解法的代码,这是一种基于计数排序的优化方案,适合对时间效率要求较高的场景。


function hIndex_1(citations: number[]): number {
  const ciLen = citations.length;
  const count = new Array(ciLen + 1).fill(0);
  for (let i = 0; i < ciLen; i++) {
    if (citations[i] > ciLen) {
      count[ciLen]++;
    } else {
      count[citations[i]]++;
    }
  }
  let total = 0;
  for (let i = ciLen; i >= 0; i--) {
    total += count[i];
    if (total >= i) {
      return i;
    }
  }
  return 0;
};

2.1 核心思路

H 指数的最大值不可能超过论文总数 n(因为要至少 h 篇论文,h 最多等于论文数)。所以对于引用次数超过 n 的论文,我们可以统一视为引用次数为 n(不影响 H 指数的计算)。

基于这个特点,我们可以用一个计数数组 count 统计每个引用次数(0 到 n)对应的论文数量,然后从后往前累加计数,找到第一个满足「累加总数 ≥ 当前引用次数」的数值,这个数值就是最大的 H 指数。

2.2 步骤拆解(以 citations = [3,0,6,1,5] 为例)

  1. 初始化变量:论文总数 ciLen = 5,计数数组 count 长度为 ciLen + 1 = 6,初始值全为 0(count = [0,0,0,0,0,0])。

  2. 统计引用次数分布:遍历 citations 数组,将每篇论文的引用次数映射到 count 中:

     最终`count` 含义:引用 0 次的 1 篇、1 次的 1 篇、3 次的 1 篇、5 次及以上的 2 篇。
    
    • 3 ≤ 5 → count[3]++ → count = [0,0,0,1,0,0]

    • 0 ≤ 5 → count[0]++ → count = [1,0,0,1,0,0]

    • 6 > 5 → count[5]++ → count = [1,0,0,1,0,1]

    • 1 ≤ 5 → count[1]++ → count = [1,1,0,1,0,1]

    • 5 ≤ 5 → count[5]++ → count = [1,1,0,1,0,2]

  3. 倒序累加找 H 指数:从最大可能的 h(即 ciLen=5)开始,累加 count[i](表示引用次数 ≥i 的论文总数),直到累加和 ≥i:

    • i=5:total = 0 + 2 = 2 → 2 < 5 → 继续

    • i=4:total = 2 + 0 = 2 → 2 < 4 → 继续

    • i=3:total = 2 + 1 = 3 → 3 ≥ 3 → 满足条件,返回 3

最终结果为 3,符合预期(3 篇论文被引用 ≥3 次:3、6、5)。

2.3 优缺点

优点:时间复杂度 O(n),只需要两次遍历数组,效率极高;空间复杂度 O(n),仅需一个固定长度的计数数组。

缺点:需要额外的空间存储计数数组,对于论文数量极少的场景,空间开销不明显,但思路相对排序法更难理解。

三、解法二:排序思路(时间 O(n log n),空间 O(1))

第二种解法是基于排序的思路,逻辑更直观,容易理解,也是很多人首先会想到的方案。


function hIndex(citations: number[]): number {
  // 思路:逆序排序
  citations.sort((a, b) => b - a);
  let res = 0;
  for (let i = 0; i < citations.length; i++) {
    if (citations[i] >= i + 1) {
      res = i + 1;
    }
  }
  return res;
};

3.1 核心思路

将引用次数数组逆序排序(从大到小),此时排序后的数组第 i 个元素(索引从 0 开始)表示第 i+1 篇论文的引用次数。如果该元素 ≥ i+1,说明前 i+1 篇论文的引用次数都 ≥ i+1,此时 H 指数至少为 i+1。遍历完数组后,最大的这个 i+1 就是最终的 H 指数。

3.2 步骤拆解(同样以 citations = [3,0,6,1,5] 为例)

  1. 逆序排序数组:排序后 citations = [6,5,3,1,0]

  2. 遍历数组找最大 h:初始化 res = 0,依次判断每个元素:

    • i=0:citations[0] = 6 ≥ 0+1=1 → res = 1

    • i=1:citations[1] = 5 ≥ 1+1=2 → res = 2

    • i=2:citations[2] = 3 ≥ 2+1=3 → res = 3

    • i=3:citations[3] = 1 ≥ 3+1=4 → 不满足,res 不变

    • i=4:citations[4] = 0 ≥ 4+1=5 → 不满足,res 不变

  3. 返回结果:最终 res = 3,与解法一结果一致。

3.3 优缺点

优点:逻辑直观,容易理解和实现;空间复杂度低,若允许原地排序(如 JavaScript 的 sort 方法),空间复杂度为 O(log n)(排序的递归栈空间),否则为 O(1)。

缺点:时间复杂度由排序决定,为 O(n log n),对于大规模数据(如论文数量极多),效率不如解法一。

四、两种解法对比与适用场景

解法 时间复杂度 空间复杂度 核心优势 适用场景
计数排序法 O(n) O(n) 时间效率极高,两次线性遍历 大规模数据,对时间要求高
逆序排序法 O(n log n) O(1) 逻辑直观,空间开销小 小规模数据,追求代码简洁易读

五、常见易错点提醒

  1. 混淆 H 指数的定义:容易把「至少 h 篇论文 ≥h 次」写成「h 篇论文 exactly h 次」,导致判断条件错误(如之前有同学把解法一的 total ≥ i 写成 total === i)。

  2. 排序方向错误:解法二必须逆序排序(从大到小),若正序排序会导致逻辑混乱,无法正确统计。

  3. 忽略边界情况:如 citations = [0](H 指数 0)、citations = [100](H 指数 1),需确保两种解法都能覆盖这些场景。

六、总结

LeetCode 274 题的两种解法各有优劣:计数排序法以空间换时间,适合大规模数据;逆序排序法逻辑简洁,适合小规模数据。理解这两种解法的核心在于吃透 H 指数的定义——「至少 h 篇论文 ≥h 次引用」,所有的逻辑都是围绕这个定义展开的。

建议大家在练习时,先尝试自己实现逆序排序法(容易上手),再深入理解计数排序法的优化思路,通过对比两种解法的差异,加深对「时间复杂度」和「空间复杂度」权衡的理解。

Zustand 入门:React Native 状态管理的正确用法

作者 wayne214
2026年1月12日 08:34

一、Zustand 是什么,适合什么场景

Zustand 是一个轻量级、基于 hooks 的状态管理库,核心特点是:

  • 无 Provider(无需 Context 包裹)
  • API 极简(create + hooks)
  • 按需订阅(避免无关组件重渲染)
  • 对 React Native 友好(无额外平台依赖)
  • 可渐进式引入

非常适合以下 RN 场景:

  • 中小规模应用
  • RN Hybrid / Module 化工程
  • UI 状态 + 业务状态混合管理
  • 替代部分 Redux 的场景

二、安装

yarn add zustand
# 或
npm install zustand

React Native 无需额外配置。


三、最基础用法(核心必会)

1. 创建 Store

// store/useCounterStore.ts
import { create } from 'zustand';

type CounterState = {
  count: number;
  inc: () => void;
  dec: () => void;
};

export const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  inc: () => set((state) => ({ count: state.count + 1 })),
  dec: () => set((state) => ({ count: state.count - 1 })),
}));

2. 在组件中使用

import React from 'react';
import { View, Text, Button } from 'react-native';
import { useCounterStore } from './store/useCounterStore';

export default function Counter() {
  const count = useCounterStore((state) => state.count);
  const inc = useCounterStore((state) => state.inc);

  return (
    <View>
      <Text>Count: {count}</Text>
      <Button title="+" onPress={inc} />
    </View>
  );
}

关键点

  • selector 模式useStore(state => state.xxx)
  • 只订阅使用到的字段,避免全量刷新

四、推荐的工程化写法(重要)

❌ 不推荐

const store = useStore();

这样会导致任意状态变更都触发重渲染


✅ 推荐:拆分 selector

const count = useCounterStore((s) => s.count);
const inc = useCounterStore((s) => s.inc);

或:

const { count, inc } = useCounterStore(
  (s) => ({ count: s.count, inc: s.inc })
);

五、Zustand 在 React Native 中的常见模式

1. 全局 UI 状态(Loading / Modal)

type UIState = {
  loading: boolean;
  showLoading: () => void;
  hideLoading: () => void;
};

export const useUIStore = create<UIState>((set) => ({
  loading: false,
  showLoading: () => set({ loading: true }),
  hideLoading: () => set({ loading: false }),
}));
const loading = useUIStore((s) => s.loading);

2. 业务状态(登录信息)

type User = {
  id: string;
  name: string;
};

type AuthState = {
  user?: User;
  login: (u: User) => void;
  logout: () => void;
};

export const useAuthStore = create<AuthState>((set) => ({
  user: undefined,
  login: (user) => set({ user }),
  logout: () => set({ user: undefined }),
}));

3. 异步 Action(非常自然)

type ListState = {
  list: string[];
  loading: boolean;
  fetchList: () => Promise<void>;
};

export const useListStore = create<ListState>((set) => ({
  list: [],
  loading: false,
  fetchList: async () => {
    set({ loading: true });
    const res = await fetch('https://example.com/list');
    const data = await res.json();
    set({ list: data, loading: false });
  },
}));

RN 中无需 thunk / saga。


六、性能优化(RN 场景非常关键)

1. 使用 shallow 避免对象对比

import { shallow } from 'zustand/shallow';

const { count, inc } = useCounterStore(
  (s) => ({ count: s.count, inc: s.inc }),
  shallow
);

2. 将高频 UI 状态拆分 Store

store/
 ├── useAuthStore.ts
 ├── useUIStore.ts
 ├── useListStore.ts

避免一个大 Store。


七、持久化(AsyncStorage)

RN 常用:zustand + persist

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';

export const useAuthStore = create(
  persist(
    (set) => ({
      token: '',
      setToken: (token: string) => set({ token }),
      clearToken: () => set({ token: '' }),
    }),
    {
      name: 'auth-storage',
      storage: {
        getItem: AsyncStorage.getItem,
        setItem: AsyncStorage.setItem,
        removeItem: AsyncStorage.removeItem,
      },
    }
  )
);

八、Zustand vs Redux Toolkit(RN 实战视角)

维度 Zustand Redux Toolkit
学习成本 极低
样板代码 极少
Provider 不需要 必须
异步 原生支持 thunk / saga
DevTools
大型团队 一般 更适合

个人建议

  • RN 业务页面、模块级状态:Zustand
  • 复杂全局状态、多人协作:RTK
  • 二者可以共存

九、常见坑位总结

  1. 不要整 store 订阅
  2. 不要把所有状态塞进一个 store
  3. RN 中慎用大对象(列表分页要拆分)
  4. persist + AsyncStorage 要注意冷启动恢复时机

Underscore.js 整体设计思路与架构分析

作者 Anita_Sun
2026年1月12日 07:50

源码分析: bgithub.xyz/lessfish/un…

官网中所带注释的源码:

整体分析:underscorejs.org/docs/unders…

模块分析:underscorejs.org/docs/module…

核心架构模式

模块结构

Underscore.js 采用了 立即执行函数表达式 (IIFE) 作为核心模块结构,创建了一个封闭的作用域,避免了全局变量污染:

这种设计方式能够让 Underscore.js :

  • 支持多种模块系统(CommonJS、AMD、全局变量)
  • 提供 noConflict 方法,避免命名冲突
  • 在不同环境中(浏览器、Node.js)正常工作
(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? 
  // 场景1:CommonJS 环境(Node.js)
  module.exports = factory() :
  typeof define === 'function' && define.amd ? 
  // 场景2:AMD 环境(如 RequireJS)
  define('underscore', factory) :
  // 场景3:无模块化的浏览器全局环境(兜底)
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, (function () {
    var current = global._; // 保存当前全局的 _ 变量
    var exports = global._ = factory(); // 把 underscore 挂载到全局 _
    // 解决命名冲突的 noConflict 方法
    exports.noConflict = function () { global._ = current; return exports; };
  }()));
}(this, (function () {
  // 核心实现...
})));

双模式 API 设计

Underscore.js 同时支持两种调用方式:

函数式调用

_.map([123], function(num) { return 
num * 2; });

面向对象调用(链式)

_([123]).map(function(num) { return 
num * 2; }).value();

这种设计通过以下核心构造函数实现:

function _$1(obj) {
  if (obj instanceof _$1) return obj;
  if (!(this instanceof _$1)) return new 
  _$1(obj);
  this._wrapped = obj;
}

方法挂载机制

函数定义与收集

Underscore.js 首先将所有功能实现为独立函数,然后通过 allExports 对象统一收集:

var allExports = {
  __proto__: null,
  VERSION: VERSION,
  restArguments: restArguments,
  isObject: isObject,
  // ... 其他函数
};

方法挂载

通过 mixin 方法,将所有函数同时挂载到构造函数和原型链上:

function mixin(obj) {
  each(functions(obj), function(name) {
    var func = _$1[name] = obj[name];
    _$1.prototype[name] = function() {
      var args = [this._wrapped];
      push.apply(args, arguments);
      return chainResult(this, func.apply
      (_$1, args));
    };
  });
  return _$1;
}

// 执行挂载
var _ = mixin(allExports);

这种设计使得:

  • 所有函数既可以通过 _.func() 方式调用
  • 也可以通过_().func() 链式调用

数组方法集成

Underscore.js 还集成了原生数组的方法,分为两类:

变更方法(Mutator)

pop/push/reverse/shift/sort/splice/unshift 这些方法的核心是修改原数组(比如 push 往原数组加元素,shift 从原数组删第一个元素),执行后原数组本身变了,方法返回值只是 “操作结果”(比如 pop 返回删除的元素),而非新数组。

// 假设包装类实例:_([1,2,3])
const arrWrapper = _([1,2,3]);

// 调用mutator方法push
arrWrapper.push(4);
console.log(arrWrapper._wrapped); // [1,2,3,4](原数组被修改)
each(['pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift'], function(name) {
  // 从数组原型(ArrayProto是Array.prototype的简写)获取对应的原生方法
  var method = ArrayProto[name];

  // 为包装类原型添加当前遍历的方法(如pop/push等)
  _$1.prototype[name] = function() {
    // 获取包装类实例中包裹的原始数组(_wrapped是包装类存储原始数据的核心属性)
    var obj = this._wrapped;

    // 仅当原始数组不为null/undefined时执行(避免空指针错误)
    if (obj != null) {
      // 调用原生数组方法,将当前方法的参数透传给原生方法,直接修改原数组
      // apply作用:绑定方法执行上下文为原始数组obj,参数以数组形式传递
      method.apply(obj, arguments);

      // 特殊边界处理:修复shift/splice清空数组后可能残留的索引0问题
      // 原因:部分场景下shift/splice把数组清空(length=0)后,obj[0]仍可能残留undefined
      // 删除索引0可保证空数组的结构完全干净,符合原生数组的预期行为
      if ((name === 'shift' || name === 'splice') && obj.length === 0) {
        delete obj[0];
      }
    }

    // 返回链式调用结果:保证调用该方法后仍能继续调用包装类的其他方法
    // chainResult会根据是否开启链式调用,返回包装类实例(this)或修改后的原数组(obj)
    return chainResult(this, obj);
  };
});

function chainResult(instance, obj) {
    return instance._chain ? _$1(obj).chain() : obj;
}

function chain(obj) {
    var instance = _$1(obj);
    instance._chain = true;
    return instance;
}

访问方法(Accessor)

concat/join/slice 这些方法的核心是返回新结果,原数组完全不变(比如 concat 拼接后返回新数组,原数组还是原样;slice 截取后返回新子数组)。

// 假设包装类实例:_([1,2,3])
const arrWrapper = _([1,2,3]);

// 2. 调用accessor方法concat
const newWrapper = arrWrapper.concat([5,6]);
console.log(arrWrapper._wrapped); // [1,2,3,4](原数组仍不变)
console.log(newWrapper._wrapped); // [1,2,3,4,5,6](新结果)
// 批量为自定义数组包装类的原型挂载数组非可变方法(不会修改原数组,返回新值)
// 目标:让自定义包装类实例调用这些方法时,获取原生方法的返回结果,并保持链式调用特性
each(['concat', 'join', 'slice'], function(name) {
  // 从数组原型(ArrayProto)中获取对应的原生方法(如Array.prototype.concat)
  // ArrayProto是Array.prototype的简写,常见于Underscore/Lodash等工具库
  var method = ArrayProto[name];

  // 为自定义数组包装类(_$1)的原型挂载当前遍历的方法
  _$1.prototype[name] = function() {
    // 获取包装类实例中包裹的原始数组/值(_wrapped是包装类存储原始数据的核心属性)
    var obj = this._wrapped;

    // 仅当原始数据不为null/undefined时执行原生方法(避免空指针错误)
    if (obj != null) {
      // 调用原生方法,透传参数并接收返回值
      // 核心差异:这类方法不修改原数组,而是返回新值,因此需要用新值覆盖obj
      obj = method.apply(obj, arguments);
    }

    // 返回链式调用结果:将新的返回值(obj)传入chainResult,保证链式调用的正确性
    // 若开启链式则返回包装类实例,未开启则返回新的数组/值
    return chainResult(this, obj);
  };
});

链式调用实现

Underscore.js 的链式调用是其一大特色,通过以下机制实现:

调用 _.chain() 后,所有方法执行完都会通过 chainResult 返回「新的包装对象」(而非原始数据),因此可以继续调用原型上的方法;直到调用 value() 方法(需补充实现),取出 _wrapped 里的原始数据,结束链式。

举个栗子

看下链式调用如何工作:

// 链式调用示例:过滤出大于2的数,再乘以2,最后获取结果
var finalResult = _.chain([1, 2, 3, 4])
  .filter(function(x) { return x > 2; })
  .map(function(x) { return x * 2; })
  .value();

console.log(finalResult); // 输出:[6, 8]

代码实现

下面看下核心代码是怎么实现的吧 ~

// 核心包装类 _$1(对应 Underscore 的 _ 函数)
function _$1(obj) {
  // 如果是 _$1 实例,直接返回
  if (obj instanceof _$1) return obj;
  // 如果不是实例,创建实例并存储原始数据
  if (!(this instanceof _$1)) return new _$1(obj);
  this._wrapped = obj; // 存储被包装的原始数据(数组/对象)
  this._chain = false; // 链式标记,默认关闭
}

// 结束链式:获取最终结果
_$1.prototype.value = function() {
  return this._wrapped; // 取出包装对象里的原始数据
};

function chain(obj) {
  var instance = _$1(obj);
  instance._chain = true; // 开启链式标记
  return instance;
}

function chainResult(instance, obj) {
  // 关键判断:如果开启链式,返回新的包装对象(继续链式);否则返回原始数据
  return instance._chain ? _$1(obj).chain() : obj;
}

// 模拟 Underscore 的 each/functions 工具函数(简化版)
function each(arr, callback) {
  for (var i = 0; i < arr.length; i++) callback(arr[i], i);
}
function functions(obj) {
  return Object.keys(obj).filter(key => typeof obj[key] === 'function');
}

function mixin(obj) {
  each(functions(obj), function(name) {
    var func = _$1[name] = obj[name]; // 挂载到 _$1 静态方法
    _$1.prototype[name] = function() {
      // 1. 构造参数:第一个参数是包装的原始数据 this._wrapped,后续是方法入参
      var args = [this._wrapped];
      push.apply(args, arguments);
      // 2. 执行工具函数,得到结果
      var result = func.apply(_$1, args);
      // 3. 调用 chainResult,决定返回包装对象(链式)还是原始数据
      return chainResult(this, result);
    };
  });
  return _$1;
}

// 挂载常用工具方法(模拟 Underscore 的 filter/map)
mixin({
  filter: function(arr, fn) {
    return arr.filter(fn);
  },
  map: function(arr, fn) {
    return arr.map(fn);
  }
});

// 给 _$1 原型挂载 chain 方法(对应用户代码里的 instance.chain())
_$1.prototype.chain = function() {
  return chain(this._wrapped);
};

核心设计特点

函数式编程风格

Underscore.js 采用函数式编程范式,提供了大量高阶函数:

  • 纯函数 :如 map 、 filter 等,不修改原数据,避免污染原数据

  • 函数工厂 :如 tagTester 、 createPredicateIndexFinder 等。会返回一个新函数的函数,用于复用函数逻辑,减少重复代码

    // 函数工厂:生成检测特定类型的函数
    const tagTester = function(tag) {
      // 返回新函数(检测类型)
      return function(obj) {
        return Object.prototype.toString.call(obj) === `[object ${tag}]`;
      };
    };
    
    // 生产具体的检测函数
    _.isArray = tagTester('Array');
    _.isObject = tagTester('Object');
    _.isFunction = tagTester('Function');
    
    // 使用
    console.log(_.isArray([1,2])); // true
    console.log(_.isObject({a:1})); // true
    
  • 函数组合 :如 compose 函数,将多个函数组合成一个新函数,执行顺序为 “从右到左”,前一个函数的输出作为后一个函数的输入

    // 函数组合核心实现
    _.compose = function(...funcs) {
      return function(...args) {
        // 从右到左执行函数
        return funcs.reduceRight((result, func) => [func.apply(this, result)], args)[0];
      };
    };
    
    // 示例:先过滤大于2的数,再乘以2,最后求和
    const filterBig = arr => _.filter(arr, x => x > 2);
    const double = arr => _.map(arr, x => x * 2);
    const sum = arr => _.reduce(arr, (a, b) => a + b, 0);
    
    // 组合函数:sum(double(filterBig(arr)))
    const process = _.compose(sum, double, filterBig);
    
    console.log(process([1,2,3,4])); // (3,4)→[6,8]→14
    
  • 函数柯里化 :如 partial 函数,将多参数函数拆解为单参数函数链,可分步传参,延迟执行。

    // 柯里化核心实现(简化版)
    _.partial = function(func, ...fixedArgs) {
      return function(...remainingArgs) {
        // 合并固定参数和剩余参数,执行原函数
        return func.apply(this, fixedArgs.concat(remainingArgs));
      };
    };
    
    // 示例:固定乘法的第一个参数为2(创建“乘以2”的函数)
    const multiply = (a, b) => a * b;
    const double = _.partial(multiply, 2);
    
    // 分步传参:先传2,后传3/4
    console.log(double(3)); // 6
    console.log(double(4)); // 8
    

跨环境兼容性

Underscore.js 设计了完善的跨环境兼容机制,核心是 先检测、后适配、再降级 的策略:

  • 环境检测 :自动检测运行环境(浏览器、Node.js)
  • 特性检测 :检测原生方法是否存在
  • 优雅降级 :当原生方法不可用时,使用自定义实现
  • IE 兼容性 :特别处理了 IE < 9 的兼容性问题

性能优化

Underscore.js 在设计中融入了多种性能优化策略:

  • 缓存 :如 memoize 函数,缓存计算结果

    // memoize 核心实现(简化版)
    _.memoize = function(func, hashFunction) {
      const cache = {}; // 缓存容器
      hashFunction = hashFunction || function(args) {
        return args[0]; // 默认用第一个参数作为缓存key
      };
    
      return function(...args) {
        const key = hashFunction.apply(this, args);
        // 缓存存在则直接返回,否则执行函数并缓存
        if (!cache.hasOwnProperty(key)) {
          cache[key] = func.apply(this, args);
        }
        return cache[key];
      };
    };
    
    // 示例:缓存斐波那契计算结果(避免重复递归)
    const fib = _.memoize(function(n) {
      return n < 2 ? n : fib(n - 1) + fib(n - 2);
    });
    
    console.log(fib(10)); // 55(首次计算,缓存结果)
    console.log(fib(10)); // 55(直接取缓存,无需计算)
    
  • 延迟执行 :如 debounce 、 throttle 函数

    • debounce(防抖) :延迟执行函数,若短时间内重复触发,重置延迟(如搜索框输入、窗口 resize);
    • throttle(节流) :限制函数在指定时间内仅执行一次(如滚动事件、按钮点击)。
    // 防抖核心实现(简化版)
    _.debounce = function(func, wait) {
      let timeoutId;
      return function(...args) {
        clearTimeout(timeoutId); // 重置延迟
        timeoutId = setTimeout(() => {
          func.apply(this, args);
        }, wait);
      };
    };
    
    // 示例:搜索框输入后500ms执行搜索
    const search = _.debounce(function(keyword) {
      console.log('搜索:', keyword);
    }, 500);
    
    // 快速输入时,仅最后一次输入后500ms执行
    search('a');
    search('ab');
    search('abc'); // 仅执行这一次
    
  • 惰性求值 :通过链式调用实现 链式调用时,并非每一步都立即计算,而是延迟到最后一步 value () 才执行最终计算,减少中间临时数据的生成:

    // 惰性求值示例:链式调用仅在value()时执行最终逻辑
    const result = _.chain([1,2,3,4])
      .filter(x => x > 2) // 暂存逻辑,不立即执行
      .map(x => x * 2)    // 暂存逻辑,不立即执行
      .value();           // 执行所有逻辑,返回结果 [6,8]
    
  • 原生方法优先 :当原生方法可用时,优先使用原生方法,JavaScript 原生方法(如 Array.prototype.mapObject.keys)由引擎底层实现(C++),比纯 JS 实现快得多。

可扩展性

Underscore.js 设计了良好的扩展机制:

  • mixin 方法 :允许用户添加自定义函数,可将自定义函数挂载到 Underscore 原型上,支持链式调用:

    // 示例:自定义一个“求平方和”的方法,通过mixin挂载
    _.mixin({
      sumOfSquares: function(arr) {
        return _.reduce(arr, (sum, x) => sum + x * x, 0);
      }
    });
    
    // 直接调用 + 链式调用都支持
    console.log(_.sumOfSquares([1,2,3])); // 1+4+9=14
    
    const result = _.chain([1,2,3])
      .filter(x => x > 1) // [2,3]
      .sumOfSquares()     // 4+9=13
      .value();
    console.log(result); // 13
    
  • 自定义 iteratee :允许用户自定义迭代器行为

    // 示例:自定义迭代器,处理对象数组的特定属性
    const users = [
      { name: '张三', age: 20 },
      { name: '李四', age: 30 }
    ];
    
    // 自定义迭代器:提取age属性并判断是否大于25
    const ageIterator = user => user.age > 25;
    const result = _.filter(users, ageIterator);
    console.log(result); // [{ name: '李四', age: 30 }]
    
  • 模板系统 :支持自定义分隔符、变量插值规则,适配不同场景

    // 示例:自定义模板分隔符(默认是<% %>,改为{{ }})
    _.templateSettings = {
      evaluate: /{{(.+?)}}/g,    // 执行代码:{{ code }}
      interpolate: /{{=(.+?)}}/g // 插值:{{= value }}
    };
    
    // 使用自定义模板
    const template = _.template('Hello {{= name }}! {{ if (age > 18) { }}成年{{ } else { }}未成年{{ } }}');
    const html = template({ name: '张三', age: 20 });
    console.log(html); // Hello 张三! 成年
    

缺点

性能层面的损耗

链式调用的额外开销

Underscore 链式调用依赖每次方法调用创建新的 _$1 包装对象,且需通过 value() 触发最终计算:

  • 对象创建成本:每一步链式操作(如 map()/filter())都会实例化新的包装对象,频繁操作大型数据集时,内存分配和垃圾回收开销显著;
  • 原型链查找损耗:包装对象的方法挂载在 _$1.prototype 上,每次调用需遍历原型链,效率低于原生方法的直接调用;
  • 对比示例_.chain([1,2,3]).map(x=>x*2).value() 比原生 [1,2,3].map(x=>x*2) 多了「包装对象创建→原型链查找→结果重新包装」三层开销。

具体函数实现的效率瓶颈

  • 类型检测冗余:早期版本未优先使用原生 Array.isArray(),而是通过 Object.prototype.toString.call() 做类型判断,效率比原生 API 低 30% 以上;
  • 遍历策略不优:统一用通用遍历逻辑处理数组 / 对象,未针对数组使用更高效的 for 循环(而非 for...in),对象遍历未优先用 Object.keys() 过滤原型属性;
  • 高阶函数调用栈开销map/filter 等方法的迭代器需通过 optimizeCb 封装闭包,每次迭代都会产生函数调用栈损耗,而原生方法由引擎内联优化,无此开销。

闭包与内存占用

Underscore 基于 IIFE 封装核心逻辑,闭包会长期持有内部变量(如 _ 构造函数、mixin 缓存、工具函数):

  • 即使仅使用 _.isArray() 一个方法,整个闭包内的所有变量也无法被垃圾回收,造成内存冗余;
  • 非模块化环境下,全局挂载的 _ 变量常驻内存,进一步增加无意义的内存占用。

API 设计层面:一致性与易用性缺陷

双模式调用的混淆性

同时支持「函数式调用(_.map())」和「对象链式调用(_().map())」,带来双重问题:

  • 学习成本高:新手需理解两种模式的底层差异(如链式模式依赖 _wrapped 包装数据,函数式模式直接传参);
  • 行为不一致风险:部分方法在两种模式下参数传递有细微差异(如 _.reduce() 链式调用时 this 指向包装对象,函数式调用时需手动传 context)。

参数与行为的不一致性

  • 参数顺序混乱_.reduce(collection, iteratee, [accumulator]) 与原生 Array.prototype.reduce(callback, [initialValue]) 参数顺序相反,用户切换使用时易出错;
  • 边界处理不统一:对 null/undefined/ 空对象的处理逻辑混乱(如 _.map(null) 返回 []_.keys(null) 抛出错误);
  • 可选参数模糊_.defaults()/_.extend() 对默认值、浅拷贝的规则未明确标注,导致相同输入可能产生不同预期结果。

功能覆盖的冗余与缺失

  • 冗余覆盖:部分方法(如 _.each())仅对原生方法做简单封装,无额外价值却增加调用层级;
  • 核心功能缺失:早期版本无原生的深拷贝方法(_.cloneDeep() 为后期补充),需手动嵌套 _.extend() 实现,易用性差。

生态兼容层面:与现代开发体系脱节

模块化适配严重不足

  • 无原生 ES 模块支持:仅通过 IIFE 兼容 CommonJS/AMD,无法直接使用 import { map } from 'underscore' 按需导入;
  • 树摇(Tree-shaking)失效:模块结构设计导致现代打包工具(Webpack/Rollup)无法移除未使用的函数,即使仅用 _.isArray(),也会打包整个库(约 5KB),而原生 Array.isArray() 无体积成本;
  • 对比 Lodash-eslodash-es/map 可按需导入,体积仅几百字节,Underscore 无此能力。

现代语法与工具链适配差

  • 旧语法的陈旧性:依赖构造函数 + 原型链实现包装对象(_$1.prototype[name] = ...),与 ES6 class 语法脱节,现代开发者可读性差;
  • 箭头函数冲突:链式调用依赖 this 指向包装对象,而箭头函数的词法 this 会导致 this._wrapped 报错,增加使用复杂度;
  • 框架集成不契合:在 React/Vue 等现代框架中,其函数式风格与框架响应式系统(如 Vue 的 ref/reactive)适配性差,不如 Lodash/Ramda 灵活。

类型系统支持缺失

  • 无内置 TypeScript 类型:完全依赖第三方 @types/underscore,存在类型覆盖不全(如链式调用返回类型推断错误)、版本不匹配(库更新后类型定义滞后)等问题;
  • 类型安全不足:方法参数 / 返回值无类型约束,运行时易因类型错误导致 bug,而现代库(如 Lodash)原生支持 TS 类型。

扩展性与维护层面:原型链设计的硬伤

扩展机制的局限性

  • 原型污染风险mixin 方法直接挂载函数到 _$1.prototype,若自定义方法名与内置方法冲突(如自定义 map),会覆盖原生逻辑,导致意外行为;
  • 无结构化插件系统:扩展方式仅依赖 mixin,无法像 Vue/React 那样通过插件注册、生命周期管理复杂扩展,生态扩展性差。

代码结构与维护成本

  • 闭包嵌套复杂:核心逻辑通过多层闭包封装,早期版本包含大量 “魔法逻辑”(如 optimizeCb 优化迭代器),代码可读性极低;
  • 测试覆盖不全面:虽有基础测试用例,但跨环境(如旧版 IE)、边界场景(如空值 / 超大数组)的测试覆盖不足,修复 bug 易引入新问题;
  • 兼容负担重:为适配 IE6+ 等老旧环境,保留大量冗余的兼容代码,无法精简核心逻辑。

功能设计层面:能力不足与场景覆盖不全

异步操作支持缺失

  • 无原生支持 Promise/async/await,处理异步数据流(如接口请求→数据处理)时,需手动封装 _.map + Promise.all,代码冗余;
  • 防抖 / 节流函数(debounce/throttle)仅支持同步逻辑,无法处理异步回调的时序问题。

对象操作能力有限

  • 浅拷贝局限_.extend()/_.defaults() 仅支持浅拷贝,深度拷贝需手动实现或依赖第三方扩展,而原生 structuredClone() 或 Lodash _.cloneDeep() 已原生支持;
  • 对象遍历低效:无针对嵌套对象的遍历方法(如 _.deepKeys),处理复杂对象需多层嵌套调用。

函数式特性不完整

  • 柯里化 / 组合能力弱:仅通过 _.partial() 模拟柯里化(无法自动柯里化多参数函数),_.compose() 仅支持同步函数组合,无异步组合能力;
  • 惰性求值不彻底:链式调用虽有惰性特征,但仅在 value() 时执行,无法像 Ramda 那样实现 “按需计算”,处理超大数据集时效率低。

安全性层面:潜在的风险隐患

原型污染风险

早期版本的 _.extend()/_.defaults() 未过滤 __proto__ 属性,若传入包含 __proto__: { evil: true } 的用户输入,会修改 Object.prototype,导致全局原型污染:

// 原型污染示例(旧版本 Underscore)
const obj = {};
_.extend(obj, { __proto__: { test: 123 } });
console.log({}.test); // 123(全局原型被污染)

模板注入隐患

_.template() 方法默认使用 eval 执行模板中的代码,若未过滤用户输入的模板字符串,易引发代码注入攻击:

// 模板注入风险
const userInput = "{{= alert('XSS') }}";
const template = _.template(userInput);
template(); // 执行恶意代码

时代适配层面:原生 ES6+ 的全面替代

核心功能被原生方法覆盖

ES6+ 引入的原生 API 完全覆盖 Underscore 核心能力,且性能更优(引擎级优化):

Underscore 方法 原生替代方案 优势
_.map() Array.prototype.map() 无包装对象开销,引擎内联优化
_.keys() Object.keys() 原生实现,效率更高
_.extend() Object.assign() 原生支持,无需额外依赖
_.debounce() 浏览器原生 requestIdleCallback(或框架内置) 更贴合现代浏览器调度机制

旧语法与现代开发习惯脱节

  • 依赖 arguments 对象处理参数(如 _.partial()),而现代 JS 已支持剩余参数(...args),代码更简洁;
  • 构造函数 + 原型链的实现方式,与现代开发者熟悉的 class 语法相悖,学习和维护成本高。

核心总结

Underscore.js 的所有缺点本质是 “早期设计无法适配现代 JavaScript 生态”

  1. 性能层面:链式调用、闭包、低效实现带来多维度损耗,无法与原生引擎优化的 API 竞争;
  2. 生态层面:模块化、类型系统、现代工具链适配不足,无法满足现代工程化开发需求;
  3. 功能层面:异步、深拷贝、函数式特性的缺失,无法覆盖复杂业务场景;
  4. 安全 / 维护层面:原型污染、代码复杂、测试不足,增加生产环境风险。

深入解析LoRaWAN协议架构与核心组件:为什么NS服务器如此关键?

作者 赵明飞
2026年1月12日 06:17

在物联网(IoT)飞速发展的今天,低功耗、远距离、广连接的通信技术成为实现大规模设备联网的关键。其中,LoRaWAN(Long Range Wide Area Network)凭借其长距离、低功耗、大容量和高安全性等优势,已成为全球主流的低功耗广域网(LPWAN)标准之一,广泛应用于智慧城市、工业物联网、农业监测、环境监控等多个领域。

本文将带你全面了解 LoRaWAN 的协议分层、网络架构、数据传输流程以及其安全机制,并重点解析一个常被忽视却至关重要的核心组件——网络服务器(NS),为何它是整个系统不可或缺的“大脑”。

同时,我们也将介绍如何借助成熟的 LoRaWAN 平台快速搭建自己的物联网系统,助力开发者和企业高效落地项目。


一、LoRaWAN 协议分层:从物理层到应用层

LoRaWAN 是基于 LoRa 调制技术 构建的上层通信协议,由 LoRa 联盟制定并维护,专为物联网场景优化设计。其协议栈分为两层:

1. 物理层(PHY Layer)

  • 使用 LoRa 扩频调制技术,采用线性调频扩频(Chirp Spread Spectrum),具备极强的抗干扰能力和穿透性。
  • 工作频段根据地区不同而异:
    • 欧洲:868MHz
    • 北美:915MHz
    • 中国:CN470–510MHz(主要使用)、CN779–787MHz(功率受限)
  • 在城市环境中通信距离可达 3~5公里,郊区甚至可达 15公里以上,某些理想条件下可突破25公里。

2. 数据链路层(MAC Layer)

  • 遵循 LoRaWAN 协议规范,定义了终端接入、数据传输、安全认证等机制。
  • 采用改进型 ALOHA 协议,终端无需监听信道即可发送数据,简化了通信流程。
  • 支持 ADR(自适应数据速率):网络服务器可根据信号质量动态调整终端的数据速率和发射功率,平衡覆盖范围与电池寿命。

二、LoRaWAN 网络架构:星型拓扑的四层结构

LoRaWAN 采用典型的星型网络结构,主要包括以下四个部分:

1. 终端设备(End Devices)

如温湿度传感器、智能电表、门磁、水浸报警器等,通常由电池供电,具有超低功耗特性。它们通过 LoRa 无线方式将采集的数据发送给网关。

示例:农田中的土壤湿度传感器定时上报数据,用于自动灌溉控制。

2. 网关(Gateway)

又称集中器,是连接终端与后台系统的桥梁。它能同时接收多个终端的数据,并通过以太网或4G/5G回传至网络服务器。

  • 支持多通道并发接收,单个网关可连接数千个终端。
  • 室外网关适合大面积覆盖,室内网关 则适用于楼宇内部部署。

3. 网络服务器(Network Server, NS)

这是整个 LoRaWAN 系统的“中枢神经”,负责处理所有来自网关的数据,进行去重、解码、路由选择、频率管理、速率调控等关键操作。

4. 应用服务器(Application Server)

执行具体业务逻辑,比如数据分析、可视化展示、告警推送、远程控制指令下发等。例如,在智慧园区中,应用服务器可根据温湿度数据自动调节空调系统。


三、各组件之间的协作关系

  1. 终端 ↔ 网关 终端通过 LoRa 发送数据包,网关接收后添加 RSSI(信号强度)、SNR(信噪比)等信息,转发至 NS。
  2. 网关 ↔ 网络服务器 多个网关可能接收到同一终端的数据包,NS 负责判断最优路径、去除重复包,并决定是否需要确认回复。
  3. NS ↔ 应用服务器 NS 将有效数据解密并转发给对应的应用服务器;反之,下行指令也需经 NS 路由至目标终端。

正是因为这种“多对一”的汇聚结构,NS 成为了保障数据完整性、网络效率和安全性的核心节点。


四、LoRaWAN 的加密机制:端到端安全保障

安全性是物联网不可忽视的一环。LoRaWAN 提供双重加密保护:

  • NwkSKey(网络会话密钥):保护网络层数据,防止非法设备接入或篡改控制命令。
  • AppSKey(应用会话密钥):实现终端与应用服务器之间的 端到端加密,即使 NS 被攻击,原始业务数据依然无法被窃取。

密钥在设备入网时通过 OTAA(Over-The-Air Activation)或 ABP(Activation By Personalization)方式生成,确保每次通信的安全性。


五、为什么 NS(网络服务器)必不可少?

很多人误以为只要有了终端和网关就能完成通信,但实际上,没有 NS,LoRaWAN 网络根本无法正常运行。以下是 NS 的三大核心作用:

5.1 数据处理与去重

当一个终端发出的数据被多个网关接收到时,NS 必须从中选出最佳数据包(基于信号质量),剔除冗余信息,避免应用服务器重复处理。

5.2 网络管理与资源调度

  • 动态启用 ADR,优化终端速率与功耗;
  • 管理设备入网、退网状态;
  • 分配频率与信道,防止冲突;
  • 控制 MAC 命令下发(如请求确认、关闭接收窗口等)。

5.3 安全控制中心

  • 执行设备身份验证;
  • 管理会话密钥的生命周期;
  • 解密上行数据,加密下行指令;
  • 防止重放攻击、中间人攻击等安全威胁。

可以说,NS 是 LoRaWAN 网络的大脑与心脏,决定了整个系统的稳定性、扩展性和安全性。


六、LoRaWAN 的核心优势总结

优势 说明
✅ 长距离通信 城市数公里,郊区超10公里
✅ 超低功耗 电池寿命可达5~10年
✅ 大规模连接 单网关支持数千终端
✅ 高安全性 AES-128 加密 + 双重密钥机制
✅ 灵活部署 星型结构易扩展,支持私有部署
✅ 成本低廉 设备与基础设施成本低

七、典型应用场景

  • 🌆 智慧城市:智能路灯、垃圾满溢检测、停车管理、抄表系统
  • 🌾 智慧农业:土壤墒情监测、气象站、灌溉控制
  • 🏭 工业物联网:设备状态监控、资产追踪、预测性维护
  • 🏠 智能家居:门窗传感器、温控系统、安防报警
  • 🌍 环境监测:空气质量、水质、噪声实时采集
  • 🏥 医疗健康:可穿戴设备远程监护、药品冷链追踪

八、如何快速搭建自己的 LoRaWAN 系统?

对于中小企业或开发者而言,自研 NS 不仅成本高、周期长,还需应对复杂的协议兼容问题。更高效的方案是使用成熟的 LoRaWAN 平台。

门思科技(Manthink) 提供了一站式 LoRaWAN 解决方案:

  • DTU产品系列:支持 RS-485/M-Bus/4-20mA 接口,即插即用,无需开发即可接入 LoRaWAN。
  • LoRaWAN 温湿度传感器:IP65防护等级,8年电池寿命,适用于仓储、农业等场景。
  • 室外网关 GDO51:企业级 Ubuntu 系统,支持 ChirpStack、TTN、Basic Station 和 ThinkLink 协议。
  • ThinkLink:门思科技自主研发的 LoRaWAN NS 平台,支持全球标准,提供规则引擎、数据卡片、API 对接等功能。

结语:选择开放平台,加速 IoT 落地

LoRaWAN 技术正在重塑万物互联的方式。而作为网络核心的 NS 服务器,不仅是数据流转的枢纽,更是保障系统稳定与安全的基石。

如果你正计划开展一个小规模物联网项目,不妨试试 门思科技的 ThinkLink 平台 —— 它不仅免费支持 1000个设备接入,还兼容任何品牌的 LoRaWAN 网关与终端,真正实现“开箱即用”。

无需自建服务器,无需复杂配置,注册即用,极大降低初期投入和技术门槛。


🔗 推荐阅读与资源链接:

📌 关注我们,获取更多 LoRa、LoRaWAN、网关、NS 相关的技术干货与行业案例!

#LoRa #LoRaWAN #物联网 #网关 #NS #NetworkServer #智慧城市 #低功耗广域网 #门思科技 #Manthink #ThinkLink #工业物联网 #无线通信 #CSDN #微信公众号 #技术科普

前端存储与离线应用实战:Cookie、LocalStorage、PWA 及 Service Worker 核心知识点

2026年1月12日 10:02

1. 前言

该文章围绕浏览器存储及相关技术展开,核心涵盖Cookie、LocalStorage、SessionStorage、IndexedDB 四种浏览器存储方式(各有存储大小、使用场景等差异),同时介绍了 PWA(渐进式 Web 应用) 的特性与相关工具,以及 Service Worker 的作用、运行机制和调试方式,最终通过案例分析与实战帮助学习者掌握各类技术的概念、使用及选择逻辑。

2.思维导图(mindmap)

image.png

3.浏览器存储方式详情(核心对比)

存储方式 核心定位 存储大小 关键特性 典型用途
Cookie 维持 HTTP 无状态的客户端状态存储 约 4KB 1. 生成方式:HTTP 响应头 set-cookie、JS 的 document.cookie;2. 关联对应域名(存在 CDN 流量损耗);3. 支持 httponly 属性;4. 可设置 expire 过期时间 辨别用户、记录客户基础信息
LocalStorage HTML5 专用浏览器本地存储 约 5M 1. 仅客户端使用,不与服务端通信;2. 接口封装更友好;3. 持久化存储(除非主动清除) 浏览器本地缓存方案
SessionStorage 会话级浏览器存储 约 5M 1. 仅客户端使用,不与服务端通信;2. 接口封装更友好;3. 会话结束后数据清除 临时维护表单信息
IndexedDB 客户端大容量结构化数据存储 -(无明确限制,支持大量数据) 1. 低级 API,支持索引;2. 高性能数据搜索;3. 弥补 Web Storage 大容量存储短板 为应用创建离线版本

三、PWA(Progressive Web Apps)相关

  1. 定义:并非单一技术,而是通过一系列 Web 新特性 + 优秀 UI 交互设计,渐进式增强 Web App 用户体验的新模型

  2. 核心特性:

    • 可靠:无网络环境下可提供基本页面访问,避免 “未连接到互联网” 提示

    • 快速:针对网页渲染和网络数据访问做了专项优化

    • 融入:可添加到手机桌面,支持全屏显示、推送等原生应用类似特性

  3. 相关工具:lighthouse(下载地址:lavas.baidu.com/doc-assets/…

四、Service Worker 相关

  1. 定义:独立于当前网页,在浏览器后台运行的脚本,为无页面 / 无用户交互场景的特性提供支持
  2. 核心能力:
    • 首要特性:拦截和处理网络请求,编程式管理缓存响应
    • 未来特性:推送消息、背景同步、地理围栏定位(geofencing)
  3. 生命周期:Installing(安装中)→ Activated(激活)→ Idle(闲置)/ Terminated(终止),过程中可能出现 Error(错误)
  4. 调试地址:
    • chrome://serviceworker-internals/
    • chrome://inspect/#service-workers

4. 关键问题

问题 1:Cookie 与 LocalStorage 作为浏览器存储方式,核心差异体现在哪些方面?

答案:两者核心差异集中在 4 点:1. 存储大小:Cookie 约 4KB,LocalStorage 约 5M;2. 通信特性:Cookie 会随 HTTP 请求发送至服务端(关联域名导致 CDN 流量损耗),LocalStorage 仅在客户端使用,不与服务端通信;3. 核心定位:Cookie 侧重维持 HTTP 无状态的客户端状态,LocalStorage 是 HTML5 设计的专用本地缓存方案;4. 附加特性:Cookie 支持 expire 过期时间和 httponly 属性,LocalStorage 无过期时间(需主动清除)且无 httponly 相关设置。

问题 2:PWA 能提供 “可靠、快速、融入” 的用户体验,其背后依赖的关键技术支撑是什么?

答案:PWA 的核心体验依赖两大关键技术:1. Service Worker:通过后台运行的脚本拦截网络请求、管理缓存响应,实现无网络环境下的基本页面访问(支撑 “可靠” 特性),同时优化网络数据访问效率(辅助 “快速” 特性);2. IndexedDB:提供客户端大容量结构化数据存储能力,为 PWA 离线版本提供数据支撑(强化 “可靠” 特性);此外,Web 新特性与优化的 UI 交互设计共同保障了 “快速” 和 “融入”(如桌面添加、全屏显示)特性的实现。

问题 3:在实际开发中,如何根据需求选择合适的浏览器存储方式?

答案:需结合存储数据量、使用场景、是否与服务端交互等需求判断:1. 若需存储少量用户标识、会话状态(需随请求发送至服务端),选择 Cookie(约 4KB,支持过期时间);2. 若需在客户端持久化存储中等容量数据(不与服务端交互),如本地缓存配置、用户偏好,选择 LocalStorage(约 5M);3. 若需临时存储会话期间的表单数据、页面临时状态(会话结束后无需保留),选择 SessionStorage(约 5M);4. 若需存储大量结构化数据(如离线应用的本地数据库),支撑应用离线使用,选择 IndexedDB(无明确容量限制,支持索引和高性能搜索)。

视频播放弱网提示实现

2026年1月12日 09:55

作者:陈盛靖

一、背景

业务群里面经常反馈,视频播放卡顿,视频播放总是停留在某一时刻就播放不了了。后面经过排查,发现这是因为弱网导致的。然而,用户数量众多,隔三差五总有人在群里反馈,有时问题一时半会好不了,用户就会怀疑不是网络,而是我们的系统问题。因此,我们希望能在弱网的时候展示提示,这样用户体验会更友好,同时也能减少一定的客诉。

二、现状分析

我们使用的播放器是chimee(www.chimee.org/index.html)。遗憾的是,chimee并没有视频播放卡顿自动展示loading的功能,不过我们可以通过其插件能力,来编写一个自定义video-loading的插件。

三、方案设计

使用NetworkInformation

常见的方法就是我们通过设定一个标准,然后检测用户设备的网络速度,在到达一定阈值时展示弱网提示。这里需要确定一个重要的点:什么情况下才算弱网?

我们的应用是h5,这里我们可以使用window对象中的NetworkInformation(developer.mozilla.org/zh-CN/docs/…),我们可以通过浏览器的debug工具,打印window.naviagtor.connection,这个对象内部就存储着网络信息:

其中各个属性含义如下表所示:

属性 含义
downlink 返回以兆比特每秒为单位的有效带宽估计,四舍五入到最接近的 25 千比特每秒的倍数。
downlinkMax 返回底层连接技术的最大下行速度,以兆比特每秒(Mbps)为单位。
effectiveType 返回连接的有效类型(意思是“slow-2g”、“2g”、“3g”或“4g”中的一个)。此值是使用最近观察到的往返时间和下行链路值的组合来确定的。
rtt 返回当前连接的有效往返时间估计,四舍五入到最接近的 25 毫秒的倍数。
saveData 如果用户在用户代理上设置了减少数据使用的选项,则返回 true。
type 返回设备用于网络通信的连接类型。它会是以下值之一:
bluetooth
cellular
ethernet
none
wifi
wimax
other
unknown
onchange 接口的 change 事件在网络连接信息发生变化时被触发,并且该事件由 NetworkInformation(developer.mozilla.org/zh-CN/docs/…) 对象接收。

其中,我们可以通过effectiveType判断当前网络的大体情况,并且可以拿到一个预估的网络带宽(downlink)。我们可以通过监听onchange事件,在网络变差的时候,展示对应的弱网提示。

这个方案的优点是:

  • 浏览器环境原生支持
  • 实现相对简单

但缺点却十分明显:

  • 网络状态变化非实时

effectiveType的变化可能是分钟级别的,对于短暂的网络波动,状态没办法做更精细的把控

  • 存在兼容性问题

对于不同一些主流浏览器不支持,例如Firefox、Safari等

  • 不同设备间存在差异

不同的设备和浏览器,由于其差异,在不同的网络情况下,视频的播放情况是不一样的,如果我们固定一个标准,可能会导致在不同设备下,同一个网络速度,有人明明正常播放视频,但是却提示网络异常,这样用户会感到疑惑。

那有没有更好的方法呢?

监听Video元素事件

chimee底层也是在html video上进行的二次封装,我们可以在插件的生命周期中,拿到对应的video元素节点。而在video标签中,存在这样两个事件:waiting和canplay。

其事件描述如下图所示:

当视频播放卡顿时,会触发waiting事件;而当视频播放恢复正常时,会触发canplay事件。只要监听这两个事件,我们就可以实现对应的功能了。

四、功能拓展

我们知道,现在大多数网站的视频在提示弱网的时候,都会展示当前设备的网络速度是多少。因此我们也希望在展示对应的信息。那么怎么实现网络速度的检测呢?

一个简单的方法是,我们可以通过获取一张固定大小的图片资源(不一定是图片,也可以是别的类型的资源),并统计请求该资源的请求速度,从而计算当前网络的带宽是多少。当然,图片大小要尽可能小一点,一是为了节省用户流量,二是为了避免在网络不好的情况下,图片请求太慢导致一直计算不出来。

具体代码如下:

funtion calculateSpeed() {
  // 图片大小772Byte
  const fileSize = 772;
  // 拼接时间戳,避免缓存
  const imgUrl = `https://xxx.png?timestamp=${new Date().getTime()}`;

  return new Promise((resolve, reject) => {
    let start = 0;
    let end = 1000;
    let img = document.createElement('img');
    start = new Date().getTime();
    img.onload = function (e) {
        end = new Date().getTime();
        // 计算出来的单位为 B/s
        const speed = fileSize / (end > start ? end - start : 1000) * 1000;
        resolve(speed);
    }
    img.src = imgUrl;
  }).catch(err => { throw err });
}
function translateUnit(speed) {
  if(speed === 0) return '0.00 B/s';
  if(speed > 1024 * 1024) return `${(speed / 1024 / 1024).toFixed(2)} MB/s`;
  if(speed > 1024) return `${(speed / 1024).toFixed(2)} KB/s`;
  else return `${speed.toFixed(2)} B/s`;
}

我们可以通过setInterval来轮询调用该函数,从而实时展示当前网络情况。系统流程图如下:

画板

五、总结

我们可以通过Chrome浏览器开发者工具中的Network中的网络配置来模拟弱网情况 具体效果如下:

成功实现视频弱网提示,完结撒花🎉🎉🎉🎉🎉🎉。

Vben Admin管理系统集成qiankun微服务(二)

作者 go_caipu
2026年1月11日 23:05

继上篇

上篇Vben Admin管理系统集成qiankun微服务(一)遗留的三个问题:

  1. 子应用鉴权使用主应用鉴权,如果系统鉴权过期要跳转到登录页面。
  2. 主应用和子应用保持主题风格一致,主应用调整子应用同步调整。
  3. 支持多个应用动态加载。

下面分步完成以上相关内容

1. 主应用和子应用主题同步

主应用

主应用和子应用的数据传递主要使用props实现,上篇文章已经实现了部分没有详细解释,本篇补充以上内容。 通过props.userInfo和props.token 传递登录信息和授权信息,

vue-vben-admin/apps/web-antd/src/qiankun/config.ts

/**  本地应用测试微服务架构 */
export default {
  subApps: [
    {
      name: 'basic', // 子应用名称,跟package.json一致
      // entry: import.meta.env.VITE_API_BASE_URL, // 子应用入口,本地环境下指定端口
      entry: 'http://localhost:5667', // 子应用入口,本地前端环境下指定端口'http://localhost:5174',发布可以调整为主系统:/app/workflow-app/= /app/插件名称/
      container: '#sub-container', // 挂载子应用的dom
      activeRule: '/app/basic', // 路由匹配规则
      props: {
        userInfo: [],
        token: '',
      }, // 主应用与子应用通信传值
      sandbox: {
        strictStyleIsolation: true, // 启用严格样式隔离
      },
    },
  ],
};

vue-vben-admin/apps/web-antd/src/qiankun/index.ts文件,实现代码主要是在beforeLoad函数

// 参考项目:https://github.com/wstee/qiankun-web
import { useAccessStore, useUserStore } from '@vben/stores';

import { registerMicroApps } from 'qiankun';

import config from './config';

const { subApps } = config;

export async function registerApps() {
  try {
    // 如果子应用是不定的,可以这里定义接口从后台获取赋值给subApps,动态添加

    registerMicroApps(subApps, {
      beforeLoad: [
        (app: any) => {
          // eslint-disable-next-line no-console
          console.log('[主应用] beforeLoad', app.name);
          const useStore = useUserStore();
          const accessStore = useAccessStore();
          app.props.token = accessStore.accessToken;
          app.props.userInfo = useStore.userInfo;
         
        },
      ],
      // 生命周期钩子
      loader: (loading: any) => {
        // 可以在这里处理加载状态
        // eslint-disable-next-line no-console
        console.log('子应用加载状态:', loading);
      },
      beforeMount: [
        (app) => {
          // eslint-disable-next-line no-console
          console.log('[主应用] beforeMount', app.name);
          const container = document.querySelector(app.container);
          if (container) container.innerHTML = '';
        },
      ],
      afterUnmount: [
        (app) => {
          // eslint-disable-next-line no-console
          console.log('count: %s', app);
        },
      ],
    });
  } catch (error) {
    // eslint-disable-next-line no-console
    console.log('count: %s', error);
  }
}

子应用调整

修改代码读取主应用传递的参数,调整mount函数 caipu-vben-admin/apps/app-antd-child/web-demo/src/main.ts

   async mount(props: any) {
      const { container, token, userInfo } = props;
      await initApplication(container);
      const useStore = useUserStore();
      const accessStore = useAccessStore();
      console.log('[子应用]  mounting', props);
      console.log('[子应用]  token:', token);
      console.log('[子应用]  userInfo:', userInfo);

      useStore.setUserInfo(userInfo);
      accessStore.setAccessToken(token);
      // 监听主应用的主题事件
      window.addEventListener('qiankun-theme-update', handleThemeUpdate);
      // 移除并销毁loading
      unmountGlobalLoading();
    }

如果操作子应用时登录信息失效了呢,要让应用跳转到登录,可以修改setupAccessGuard函数,按照如下修改直接跳转到系统登录页。

caipu-vben-admin/apps/app-antd-child/src/router/guard.ts

 // 没有访问权限,跳转登录页面
      if (to.fullPath !== LOGIN_PATH) {
        // return {
        //   path: LOGIN_PATH,
        //   // 如不需要,直接删除 query
        //   query:
        //     to.fullPath === preferences.app.defaultHomePath
        //       ? {}
        //       : { redirect: encodeURIComponent(to.fullPath) },
        //   // 携带当前跳转的页面,登录后重新跳转该页面
        //   replace: true,
        // };
        window.location = 'http://localhost:5666/#/login';
      }

这样就实现主应用和子应用的信息同步了。

2. 主应用与子应用主题同步

vben主题相关配置是在'@vben/preferences'包中,要调整的动态配置主要是在preferences.theme当中,所以实现主题同步只要把配置信息同步到子应用即可。

未通过props传递原因是加载子应用之后再调整偏好设置和主题 子应用不生效,所以考虑只能通另外一种方式实现,最终选择 window.dispatchEvent事件监听的方式实现。

image.png

主应用调整

调整 vue-vben-admin/apps/web-antd/src/layouts/basic.vue

# 引用包
import { preferences } from '@vben/preferences';

# 合适位置增加主题监听
watch(
  () => ({
    theme: preferences.theme,
  }),
  async ({ theme }) => {
    alert('handler qiankun-theme  start', theme);
    // 子应用会监听这个事件并更新响应式对象
    window.dispatchEvent(
      new CustomEvent('qiankun-theme-update', {
        detail: preferences,
      }),
    );
  },
  {
    immediate: true,
  },
);

子应用调整

如果细心的话,在上述子应用调整的main.ts,mount函数要已有说明,主要是增加事件监听qiankun-theme-update 和监听处理事件handleThemeUpdate,完整代码如下 caipu-vben-admin/apps/app-antd-child/web-demo/src/main.ts

import { initPreferences, updatePreferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import '@vben/styles';
import '@vben/styles/antd';
import { unmountGlobalLoading } from '@vben/utils';

import {
  qiankunWindow,
  renderWithQiankun,
} from 'vite-plugin-qiankun/dist/helper';

import { bootstrap } from './bootstrap';
import { overridesPreferences } from './preferences';

let app: any = null;
/**
 * 应用初始化完成之后再进行页面加载渲染
 */
async function initApplication(container: any = null) {
  // name用于指定项目唯一标识
  // 用于区分不同项目的偏好设置以及存储数据的key前缀以及其他一些需要隔离的数据
  const env = import.meta.env.PROD ? 'prod' : 'dev';
  const appVersion = import.meta.env.VITE_APP_VERSION;
  const namespace = `${import.meta.env.VITE_APP_NAMESPACE}-${appVersion}-${env}`;

  // app偏好设置初始化
  await initPreferences({
    namespace,
    overrides: overridesPreferences,
  });
  // 启动应用并挂载
  // vue应用主要逻辑及视图
  app = await bootstrap(namespace, container);
  // 移除并销毁loading
  unmountGlobalLoading();
}

const initQianKun = async () => {
  renderWithQiankun({
    async mount(props: any) {
      const { container, token, userInfo } = props;
      await initApplication(container);
      const useStore = useUserStore();
      const accessStore = useAccessStore();
      console.log('[子应用]  mounting', props);
      console.log('[子应用]  token:', token);
      console.log('[子应用]  userInfo:', userInfo);

      useStore.setUserInfo(userInfo);
      accessStore.setAccessToken(token);

      window.addEventListener('qiankun-theme-update', handleThemeUpdate);
      // 移除并销毁loading
      unmountGlobalLoading();
    },
    bootstrap() {
      return new Promise((resolve, reject) => {
        // eslint-disable-next-line no-console
        console.log('[qiankun] app bootstrap');
        resolve();
      });
    },
    update(props: any) {
      // eslint-disable-next-line no-console
      console.log('[子应用]  update');
      const { container } = props;
      initApplication(container);
    },
    unmount(props) {
      // 移除事件监听
      if (handleThemeUpdate) {
        // eslint-disable-next-line no-console
        console.log('remove sub apps theme handle:', app.name);
        window.removeEventListener('qiankun-theme-update', handleThemeUpdate);
      }
      // eslint-disable-next-line no-console
      console.log('[子应用] unmount', props);
      app?.unmount();
      app = null;
    },
  });
};
// 判断是否为乾坤环境,否则会报错iqiankun]: Target container with #subAppContainerVue3 not existed while subAppVue3 mounting!
qiankunWindow.__POWERED_BY_QIANKUN__
  ? await initQianKun()
  : await initApplication();

const handleThemeUpdate = (event: any) => {
  const newTheme = event.detail;
  if (newTheme) {
    // 更新响应式对象,由于是响应式的,Vue 会自动更新视图
    console.log('子应用主题已更新(通过 props + 事件):', newTheme);
    updatePreferences(newTheme);
  }
};

3. 支持多个应用动态加载

子应用如果不是固定subApps,要从后台加载那如何实现呢,比如我的程序实现子应用动态插拔,后台安装子应用之后前台就要支持展示。 代码逻辑是:本地调试从config.ts获取固定配置,发布环境读取后台配置。主要看registerApps()。 核心代码是下面这段:

 if (import.meta.env.PROD) {
      const data = await GetMicroApp();
      // 将获取的子应用数据转换为qiankun需要的格式
      subApps = data.map((app: MicroApp) => ({
        name: app.name, // 子应用名称
        entry: app.entry, // 子应用入口地址
        container: '#sub-container', // 子应用挂载节点
        activeRule: app.activeRule, // 子应用激活规则
        props: {
          userInfo: [],
          token: '',
        }, // 主应用与子应用通信传值
        sandbox: {
          strictStyleIsolation: true, // 启用严格样式隔离
        },
      }));
    }

完整文件代码是:

import type { MicroApp } from '#/api/apps/model';

import { useAccessStore, useUserStore } from '@vben/stores';

// 参考项目:https://github.com/wstee/qiankun-web
import { registerMicroApps } from 'qiankun';

import { GetMicroApp } from '#/api/apps';

import config from './config';

let { subApps } = config;

export async function registerApps() {
  try {
    // 判断是否是发布环境,发布环境从后台获p取subApps
    if (import.meta.env.PROD) {
      const data = await GetMicroApp();
      // 将获取的子应用数据转换为qiankun需要的格式
      subApps = data.map((app: MicroApp) => ({
        name: app.name, // 子应用名称
        entry: app.entry, // 子应用入口地址
        container: '#sub-container', // 子应用挂载节点
        activeRule: app.activeRule, // 子应用激活规则
        props: {
          userInfo: [],
          token: '',
        }, // 主应用与子应用通信传值
        sandbox: {
          strictStyleIsolation: true, // 启用严格样式隔离
        },
      }));
    }

    registerMicroApps(subApps, {
      beforeLoad: [
        (app: any) => {
          // eslint-disable-next-line no-console
          console.log('[主应用] beforeLoad', app.name);
          const useStore = useUserStore();
          const accessStore = useAccessStore();
          app.props.token = accessStore.accessToken;
          app.props.userInfo = useStore.userInfo;
          // app.props.publicKey = import.meta.env.VITE_PUBLIC_KEY;
        },
      ],
      // 生命周期钩子
      loader: (loading: any) => {
        // 可以在这里处理加载状态
        // eslint-disable-next-line no-console
        console.log('子应用加载状态:', loading);
      },
      beforeMount: [
        (app) => {
          // eslint-disable-next-line no-console
          console.log('[主应用] beforeMount', app.name);
          // const container = document.querySelector(app.container);
          // if (container) container.innerHTML = '';
          // 仅隐藏容器,不删除 DOM
          if (app.container.style) {
            app.container.style.display = 'none';
          }
        },
      ],
      beforeUnmount: (app) => {
        // 重新显示容器
        if (app.container.style) {
          app.container.style.display = 'none';
        }
      },
      afterUnmount: [
        (app) => {
          // eslint-disable-next-line no-console
          console.log('count: %s', app);
        },
      ],
    });
  } catch (error) {
    // eslint-disable-next-line no-console
    console.log('count: %s', error);
  }
}

GetMicroApp()返回数据结构json结果如下,主要是data的内容:

{
    "code": 200,
    "data": [
        {
            "name": "caipu-site",
            "entry": "/app/caipu-site/",
            "activeRule": "/app/caipu-site"
        },
        {
            "name": "email",
            "entry": "/app/email/",
            "activeRule": "/app/email"
        },
        {
            "name": "ip2region",
            "entry": "/app/ip2region/",
            "activeRule": "/app/ip2region"
        },
        {
            "name": "testdata",
            "entry": "/app/testdata/",
            "activeRule": "/app/testdata"
        }
    ],
    "msg": "",
    "success": true,
    "timestamp": 1768140865000
}

最后

  1. 上文有小伙伴回复是否可以支持主应用多页签切换不同子应用的页面状态保持,抱歉多次尝试未在vben实现此功能,作为一名后端人员技术有限如您有实现方案,请不吝指教。

  2. 抽时间也会尝试下wujie微前端方案完善相关功能,基于以上浅显内容,欢迎大积极尝试和分享。 如你有更好的建议内容分享请给评论。

如有幸被转载请注明出处: go-caipu

❌
❌