普通视图

发现新文章,点击刷新页面。
今天 — 2025年5月23日掘金 前端

Git大小写引发的问题:从v1到V1引发的血案

2025年5月23日 17:32

作者:程序员平

最近在开发过程中遇到了的一个诡异的问题:我们的项目中有一个目录原本命名为v1,已经添加并纳入了Git管理。后来由于规范调整,我们将目录名从v1改为V1,修改了代码命令空间并提交代码Action自动化部署后却发现服务器上的代码运行出现了问题——系统依然尝试访问v1目录,导致各种路径错误。最近我就在一次部署中因为这个问题踩了坑,本文记录这个问题的复现过程、根因分析以及解决,希望能帮到你。

最近在开发过程中遇到了一个诡异的问题:我们的项目中有一个目录原本命名为v1,已经添加并纳入了Git管理。后来由于规范调整,我们将目录名从v1改为V1,提交代码后却发现服务器上的代码运行出现了问题——系统依然尝试访问v1目录,导致各种路径错误。

一、背景描述

这个示例我就新建一个测试的仓库进行演示!

项目结构中原本有一个目录为 v1/(小写),已经被添加到 Git 仓库中。

图片

后来为了统一风格,我将目录名改成了 V1/(首字母大写),并正常修改代码执行提交/push

图片

一切看起来都没问题,直到部署到服务器后,服务报错找不到 V1 目录。登录服务器后发现目录仍然是 v1,并未变成大写。此时我才意识到,这不是部署的问题,而是 Git 根本没有识别到目录名的大小写变化。

其实单独修改目录, 不修改代码就会发现, 将目录名改为大写的V1之后, 其实是没有要提交的内容的

二、 为什么会出现这个问题?

1. Git 默认配置下不区分大小写

Git在默认配置下对文件名是大小写不敏感的。这意味着当你把v1改为V1时,Git可能不会将其识别为一个真正的更改,特别是如果你没有明确配置Git为大小写敏感。

这意味着:在 macOS 或 Windows 上将目录 v1 改名为 V1,文件系统会认为这不是“真正的重命名”,Git 也就不会记录这个变动。

如果你是在本地开发(Windows/macOS),然后部署到 Linux 服务器(大小写敏感),问题就暴露了。

2. Git的工作机制

  • 当你在本地重命名文件夹时:
    • 本地文件系统会反映这个更改
    • Git可能不会将此识别为重命名操作,而是认为你删除了v1并添加了V1
    • 如果Git配置为不区分大小写,它可能完全忽略这个更改

3. 文件系统的差异

不同操作系统对文件名大小写的处理方式不同:

  • Windows/macOS(默认):文件系统通常不区分大小写
  • Linux/Unix:文件系统通常区分大小写

三、 如何解决?

方法一

使用 git mv 强制重命名(临时)

# 先改成一个临时名称(不同名)git mv v1 tempgit commit -m "Temp rename"
# 再改为最终名称git mv temp V1git commit -m "Rename v1 to V1"git push

这样就能强制 Git 识别为两个变更,从而在使仓库中都能正确更新。

图片

方法二

修改 Git 配置以区分大小写(永久)

配置Git为大小写敏感:

git config core.ignorecase false

这个配置告诉 Git 让它在本地也“强制”区分大小写。

四、 总结****

Git的大小写不敏感特性在跨平台开发中可能成为隐形杀手, 特别是在一开始就没有统一文件命名规范的时候。通过了解其工作原理并采取适当的预防措施,可以避免这类问题的发生。记住,在重命名文件或目录时,最好使用git mv命令,并确保团队中每个人都配置了正确的大小写敏感设置。

运行一个已有的 Vue 项目

作者 Emma歌小白
2025年5月23日 17:21

运行一个已有的 Vue 项目通常包括以下几个步骤。以下以典型 Vue CLI 项目为例(也适用于 vite 项目,下面会分别说明):


🧭 你要确认的项目结构(例如)

假设你 Vue 项目的目录是 open_site_vue

open_site_vue/
├── package.json
├── node_modules/
├── src/
├── public/
├── vite.config.js 或 vue.config.js
└── ...

✅ 1. 安装依赖

打开终端(PowerShell、CMD 或 Git Bash),进入 Vue 项目目录:

cd open_site_vue

然后执行:

npm install

如果你用的是 pnpmyarn,可以改成:

yarn    # 或 pnpm install

✅ 2. 启动开发服务器

Vue CLI 项目(vue.config.js)

npm run serve

Vite 项目(vite.config.js)

npm run dev

运行成功后会看到:

App running at:
  - Local:   http://localhost:5173/ (或 http://localhost:8080)

点击链接或复制到浏览器中即可预览。


✅ 3. Vue 编译后用于 Django 的方式(如你是前后端分离项目)

通常你会:

(1)编译 Vue 项目:

npm run build

这会生成一个 dist/ 目录(Vite)或 dist/(Vue CLI),你可以将该目录部署到 Django 的 static 路径下,或用 nginx/Apache 进行反向代理。

(2)把 dist/index.html 改成 Django 模板 或 iframe 嵌入也可以。


❗常见问题排查

问题类型 可能原因 解决方案
npm install 报错 没装 Node.js nodejs.org 安装
启动后空白页 路由模式是 history 要在服务器配置 fallback 或 Django 中处理 404
编译出错 模块找不到 确保运行过 npm install,并检查 Vue 版本

新手易混淆的TS配置:paths、types、include对比

作者 火车叼位
2025年5月23日 17:08

在TypeScript项目中,tsconfig.json的配置直接决定了代码编译、类型检查和模块解析的底层行为。尽管官方文档对各项参数有基础说明,但实践中开发者常对pathstypesinclude三个配置产生混淆。本文将通过原理剖析、对比表格与真实案例,彻底解析它们的差异与配合技巧。


一、三大配置核心定位速览

1.1 配置对比表

配置项 作用域 典型应用场景 关联配置
paths 模块解析路径映射 简化长导入路径、实现多环境路径切换 baseUrl
types 全局类型声明管控 避免类型污染、加速类型检查 typeRoots
include 编译范围控制 排除测试文件、限定源码目录 exclude/files

二、配置项深度解析

2.1 paths:模块路径的导航仪

核心作用

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@utils/*": ["src/core/utils/*"],
      "type/*": ["types/*"] 
    }
  }
}
  • 路径别名:将冗长的import '../../core/utils/logger'简化为@utils/logger
  • 环境适配:通过不同配置实现开发/生产环境的路径切换
  • 多包管理:在monorepo中跨package引用时消除路径混乱

注意事项

  • 必须与baseUrl配合使用
  • 仅影响TypeScript类型检查,需配合Webpack/Vite等构建工具实现运行时解析

2.2 types:全局类型的守门员

典型配置

{
  "compilerOptions": {
    "types": ["node", "jest"],
    // typeRoots默认值:["node_modules/@types"]
  }
}
  • 类型隔离:仅允许声明的类型包参与全局类型推导
  • 性能优化:减少不必要的类型扫描(如禁用未使用的Lodash类型)
  • 冲突解决:当多个@types包存在命名冲突时选择性加载

常见误区

  • ❌ 误认为types用于声明项目自定义类型(实际应使用include包含声明文件)
  • ❌ 在已有typeRoots配置时重复声明@types路径

2.3 include:编译范围的边界线

标准用法

{
  "include": [
    "src/**/*.ts",
    "types/**/*.d.ts",
    "configs/*.ts"
  ],
  "exclude": ["**/__tests__"]
}
  • 精准控制:仅编译业务代码,排除测试文件/脚本工具
  • 声明文件管理:明确包含自定义类型声明目录
  • 增量编译:通过范围限定提升tsc --watch性能

高级技巧

  • 使用!否定符实现复杂过滤:["src/**/*", "!src/experimental"]
  • files配置搭配使用,实现"白名单+黑名单"双保险

三、配置间的协同效应

3.1 典型项目结构配置

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "types/*": ["typings/*"]
    },
    "types": ["vite/client"],
    "typeRoots": ["./typings", "./node_modules/@types"]
  },
  "include": ["src", "typings", "vite.config.ts"]
}
  • 路径映射@/components/Buttonsrc/components/Button
  • 类型管理:仅加载vite客户端类型,防止React/Vue类型冲突
  • 编译范围:包含业务代码+自定义类型声明

3.2 常见问题排查指南

问题1:类型声明未生效

  • ✅ 检查include是否包含声明文件目录
  • ✅ 确认types未过滤掉必要类型包
  • ✅ 确保声明文件格式为.d.ts且无语法错误

问题2:模块路径解析失败

  • ✅ 验证baseUrl是否指向正确根目录
  • ✅ 在构建工具中同步配置路径别名(如vite.config.ts)
  • ✅ 使用tsc --traceResolution查看详细解析过程

四、最佳实践推荐

  1. 路径管理三板斧

    • 基础路径:baseUrl: "."
    • 别名映射:paths: { "@/*": ["src/*"] }
    • 构建工具联动:在Webpack/Vite中配置相同别名
  2. 类型安全双保险

    {
      "types": ["vite/client"], // 显式声明环境类型
      "include": ["src", "typings"] // 包含自定义类型
    }
    
  3. 编译优化组合拳

    • 使用include限定src目录
    • 通过exclude排除node_modules
    • 启用incremental: true提升编译速度

五、总结

理解paths、types、include的差异需要抓住三个关键维度:

维度 paths types include
控制目标 模块解析路径 全局类型范围 文件处理范围
配置层级 compilerOptions compilerOptions 根级属性
影响阶段 编译时类型检查 类型推导阶段 编译输入阶段

通过精准配置这三个参数,开发者可以实现:
✅ 更清晰的模块导入路径
✅ 更可控的全局类型环境
✅ 更高效的编译过程

vue3+vite更优雅的svg图标方案使用方案

作者 Htaozi
2025年5月23日 16:43

一、前言

不说废话,直接上干货!

65eedc7895fbc2912a07a50baed9dcd2.jpg

二、方案1: 配置插件预处理

1、整体流程图解

image.png

2、核心模块拆解

// 插件主体结构
export default function svgBuilder(): Plugin {
  return {
    name: 'svg-builder',
    buildStart() { /* 生产构建 */ },
    configureServer() { /* 开发服务配置 */ },
    transformIndexHtml() { /* HTML注入 */ },
    handleHotUpdate() { /* 热更新处理 */ }
  }
}

3、关键代码实现解析

3.1 SVG 预处理引擎

// 核心转换逻辑
const processSvgContent = (content: string, filename: string, prefix: string) => {
  let width = 0;
  let height = 0;

  return content
    .replace(/(\r|\n)/g, '')
    .replace(/<svg([^>]*)>/g, (_, attrs) => {
      // 解析原始属性
      const attributeMap = new Map<string, string>();
      let viewBox = '';

      // 专业属性解析
      attrs.replace(/(\w+)=("([^"]*)"|'([^']*)')/g, (_: any, key: string, __: any, val1: string, val2: string) => {
        const value = val1 || val2;
        switch (key.toLowerCase()) {
          case 'width':
            width = parseFloat(value) || 0;
            break;
          case 'height':
            height = parseFloat(value) || 0;
            break;
          case 'viewbox':
            viewBox = value;
            break;
          default:
            attributeMap.set(key, value);
        }
        return '';
      });

      // 构建标准化属性
      const attrsArray = Array.from(attributeMap.entries())
        .filter(([key]) => !['xmlns', 'xmlns:xlink'].includes(key))
        .map(([key, val]) => `${key}="${val}"`);

      // 自动生成 viewBox
      if (!viewBox && width && height) {
        viewBox = `0 0 ${width} ${height}`;
      }
      if (viewBox) {
        attrsArray.push(`viewBox="${viewBox}"`);
      }

      return `<symbol id="${prefix}-${filename}" ${attrsArray.join(' ')}>`;
    })
    .replace(/<\/svg>/, '</symbol>')
    .replace(/(fill|stroke)=["']#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})["']/gi, '$1="currentColor"');
};

3.2 文件监听机制

// 开发环境配置
configureServer(server) {
  watcher = watch(iconPath, { 
    recursive: true,
    persistent: true 
  }, (_, filename) => {
    if (filename?.endsWith('.svg')) {
      // 防抖处理(500ms)
      clearTimeout(updateTimer);
      updateTimer = setTimeout(() => {
        generateSprite();
        server.ws.send({ type: 'full-reload' });
      }, 500);
    }
  });
}

3.3 HTML注入逻辑

transformIndexHtml() {
  return [
    {
      tag: 'svg',
      attrs: {
        xmlns: 'http://www.w3.org/2000/svg',
        'xmlns:xlink': 'http://www.w3.org/1999/xlink',
        style: 'display:none',
      },
      children: cachedSvgContent || '',
      injectTo: 'body-prepend',
    },
  ];
},

4、完整插件配置

4.1 插件代码

// svg-builder.ts
import { readFileSync, readdirSync, watch } from 'fs';
import { join, parse } from 'path';
import type { Plugin } from 'vite';
import { normalizePath } from 'vite';

interface SvgBuilderOptions {
  path?: string;
  prefix?: string;
}

const DEFAULT_OPTIONS: SvgBuilderOptions = {
  prefix: 'icon',
};
// 缓存SVG内容
let cachedSvgContent: string | null = null;

// 文件监听器
let watcher: ReturnType<typeof watch> | null = null;

let updateTimer: NodeJS.Timeout | null = null;

// 移除多余转义
const processSvgContent = (content: string, filename: string, prefix: string) => {
  let width = 0;
  let height = 0;

  return content
    .replace(/(\r|\n)/g, '')
    .replace(/<svg([^>]*)>/g, (_, attrs) => {
      // 解析原始属性
      const attributeMap = new Map<string, string>();
      let viewBox = '';

      // 专业属性解析
      attrs.replace(/(\w+)=("([^"]*)"|'([^']*)')/g, (_: any, key: string, __: any, val1: string, val2: string) => {
        const value = val1 || val2;
        switch (key.toLowerCase()) {
          case 'width':
            width = parseFloat(value) || 0;
            break;
          case 'height':
            height = parseFloat(value) || 0;
            break;
          case 'viewbox':
            viewBox = value;
            break;
          default:
            attributeMap.set(key, value);
        }
        return '';
      });

      // 构建标准化属性
      const attrsArray = Array.from(attributeMap.entries())
        .filter(([key]) => !['xmlns', 'xmlns:xlink'].includes(key))
        .map(([key, val]) => `${key}="${val}"`);

      // 自动生成 viewBox
      if (!viewBox && width && height) {
        viewBox = `0 0 ${width} ${height}`;
      }
      if (viewBox) {
        attrsArray.push(`viewBox="${viewBox}"`);
      }

      return `<symbol id="${prefix}-${filename}" ${attrsArray.join(' ')}>`;
    })
    .replace(/<\/svg>/, '</symbol>')
    // 填充色自动转换成 currentColor
    .replace(/(fill|stroke)=["']#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})["']/gi, '$1="currentColor"');
};

// 遍历文件
const findSvgFiles = (dir: string, prefix: string) => {
  const results: string[] = [];

  const traverse = (currentDir: string) => {
    const dirents = readdirSync(currentDir, { withFileTypes: true });

    for (const dirent of dirents) {
      const fullPath = join(currentDir, dirent.name);

      if (dirent.isDirectory()) {
        traverse(fullPath);
      } else if (dirent.name.endsWith('.svg')) {
        try {
          const content = readFileSync(fullPath, 'utf-8');
          const filename = parse(dirent.name).name;
          results.push(processSvgContent(content, filename, prefix));
        } catch (e) {
          console.warn(`[svg-builder] 文件处理失败: ${fullPath}`, e);
        }
      }
    }
  };

  traverse(dir);
  return results;
};

// 生成SVG符号内容
const generateSprite = (options: SvgBuilderOptions) => {
  try {
    const symbols = findSvgFiles(options.path!, options.prefix!);
    cachedSvgContent = symbols.join('');
  } catch (e) {
    console.error('[svg-builder] 雪碧图生成失败', e);
  }
};

export default function svgBuilder(userOptions: SvgBuilderOptions): Plugin {
  const options = { ...DEFAULT_OPTIONS, ...userOptions };

  if (!options.path) {
    throw new Error('[svg-builder] SVG 路径未配置');
  }

  return {
    name: 'svg-builder',
    buildStart() {
      // 生产构建时生成 SVG 内容
      generateSprite(options);
    },
    // 配置开发服务器
    configureServer(server) {
      // 初始化生成
      generateSprite(options);
      // 标准化路径
      const normalizedPath = normalizePath(options.path!);
      // 设置文件监听
      watcher = watch(
        normalizedPath,
        {
          recursive: true,
          persistent: true,
        },
        (_, filename) => {
          if (filename?.endsWith('.svg')) {
            // 防抖处理(500ms)
            updateTimer && clearTimeout(updateTimer);
            updateTimer = setTimeout(() => {
              // 重新生成SVG内容
              generateSprite(options);
              // 通知客户端热更新
              server.ws.send({
                type: 'custom',
                event: 'svg-update',
                data: cachedSvgContent,
              });

              console.log('[svg-builder] 文件有更新,reloading...');
              // 触发页面更新
              server.ws.send({ type: 'full-reload' });
            }, 500);
          }
        }
      );
    },
    // 关闭时清除监听
    closeBundle() {
      if (watcher) {
        watcher.close();
        console.log('[svg-builder] 文件监听已关闭');
        watcher = null;
      }
    },
    // 转换HTML逻辑
    transformIndexHtml() {
      return [
        {
          tag: 'svg',
          attrs: {
            xmlns: 'http://www.w3.org/2000/svg',
            'xmlns:xlink': 'http://www.w3.org/1999/xlink',
            style: 'display:none',
          },
          children: cachedSvgContent || '',
          injectTo: 'body-prepend',
        },
      ];
    },
  };
}

插件配置

// vite.config.ts
import svgBuilder from './plugins/svg-builder';
import Components from 'unplugin-vue-components/vite';

export default defineConfig(({ mode }) => {
  return {
    // ...
    plugins: [
      // 自动导入 (如果需要自动导入的话)
      Components({
        // ...
        dirs: ['src/components'],
      }),
      svgBuilder({
        path: './src/assets/icons',
        prefix: 'icon',
      })
    ]
  }
})

5、SvgIcon组件实现

// src/components/SvgIcon.vue
<template>
  <i class="svg-icon" :class="[`svg-icon-${props.name}`]" v-bind="$attrs">
    <svg aria-hidden="true">
      <use :xlink:href="`#icon-${props.name}`" />
    </svg>
  </i>
</template>

<script lang="ts" setup>
const props = defineProps<{
  name: string;
}>();
</script>

<style lang="scss" scoped>
.svg-icon {
  display: inline-flex;
  width: 1em;
  height: 1em;
  line-height: 1;
  vertical-align: middle;
  color: inherit;
  transition: color 0.2s;

  :deep(svg) {
    width: 100%;
    height: 100%;
    fill: currentColor;
    stroke: currentColor;
    pointer-events: none;
    display: block;
  }
}
</style>

6、使用示例

6.1 使用

// 颜色和字体大小都可从父级集成,也可直接使用class或style定义,当成文字用就完事儿了。
<SvgIcon name="edit" style="font-size: 16px;color: red;" />

6.2 效果

image.png

7、展示所有图标(最好是在开发环境下)

7.1 文件代码

// src/views/icons/index.vue
<template>
  <div class="all-icons">
    <div class="font-[18px] mb-8">目前系统中的所有图标(点击复制):</div>
    <div class="icons-wrapper">
      <div v-for="icon in icons" :key="icon" @click="onCopy(icon)">
        <SvgIcon :name="icon" />
        <label>{{ icon }}</label>
      </div>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { copyContent } from '@/utils';

const modules: any = import.meta.glob('@/assets/icons/**/*.svg', {
  eager: true,
});
const svgs = Object.keys(modules).reduce<Record<string, string>>((map, key) => {
  map[key.split('/').pop()!.split('.svg').shift()!] = modules[key].default;
  return map;
}, {});
const icons = markRaw(
  Object.keys(svgs)
    .map(key => key.split('/').pop()!.split('.').shift()!)
    .sort()
);

const onCopy = (icon: string) => {
  copyContent(`<SvgIcon :name="${icon}" />`);
  ElMessage.success(`复制成功:${icon}`);
};
</script>

<style lang="scss" scoped>
.all-icons {
  padding: 24px;

  .icons-wrapper {
    display: flex;
    flex-wrap: wrap;
    user-select: none;

    & > div {
      display: flex;
      flex-direction: column;
      font-size: 16px;
      padding: 15px 0;
      align-items: center;
      min-width: 100px;

      cursor: pointer;

      label {
        margin-top: 5px;
      }

      .svg-icon {
        font-size: 30px;
        color: var(--color-primary);
      }
    }
  }
}
</style>

7.2展示效果

image.png

三、方案2: 按需加载处理

使用时进行按需加载处理,无需插件处理

<template>
  <i class="svg-icon" :class="[`svg-icon-${props.name}`]" v-bind="$attrs" v-html="svgStr"> </i>
</template>

<script lang="ts" setup>
const props = defineProps<{
  name: string;
}>();

const svgStr = ref<string>();

const svgModules = import.meta.glob<string>('../../assets/icons/*.svg', {
  eager: true,
  query: '?raw',
  import: 'default',
});

// svg内容处理
const processSvgContent = (content: string) => {
  return content
    .replace(/(\r|\n)/g, '')
    .replace(/(fill|stroke)=["']#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})["']/gi, '$1="currentColor"');
};
const getPath = () => {
  const targetPath = `../../assets/icons/${props.name}.svg`;
  svgStr.value = processSvgContent(svgModules[targetPath]);
};

getPath();
</script>

<style lang="scss" scoped>
.svg-icon {
  display: inline-flex;
  width: 1em;
  height: 1em;
  line-height: 1;
  vertical-align: middle;
  color: inherit;
  transition: color 0.2s;

  :deep(svg) {
    width: 100%;
    height: 100%;
    fill: currentColor;
    stroke: currentColor;
    pointer-events: none;
    display: block;
  }
}
</style>

四、对比

  • 1、第一种方案在编译时进行全量编译,会在html中插入svg标签和一大堆内容(实际不影响使用);
  • 2、第二种方案在运行时进行编译,但是会使用v-html插入,潜在XSS风险(一般也不会有这个问题,因为图标是UI给的);
  • 3、经1200个图标全量渲染测试,二者的性能差异基本不大,可自由选择方案。

五、用法

二者用法完全一致,可放心替换。

从懵圈到通透:我是如何啃下 JS 闭包这块硬骨头的?

作者 归于尽
2025年5月23日 16:34

作为刚学 JS 三个月的新手,我最近被闭包折腾得够呛。刷面经时发现这是高频考点,查文档又被 “函数与词法环境的组合” 这种抽象描述绕晕。直到上周用闭包实现了一个小需求 ——“记住用户上一次输入的搜索词”,才突然打通任督二脉。今天就用最接地气的方式,分享我梳理的闭包知识体系。

一、闭包到底是个啥?先别急着看定义

刚学的时候,我总把闭包想象成什么 “高级结构”,直到翻到《你不知道的 JavaScript》里的一句话: “闭包是当函数可以记住并访问其词法作用域,即使函数是在当前词法作用域之外执行时” 。 翻译成人话就是:
一个函数在定义它的作用域之外被调用时,仍然能访问原作用域的变量,这对 “函数 + 变量” 的组合就是闭包。 举个最常见的例子:

function outer() {
    let count = 0; 
    function inner() {
        count++; 
        console.log(count); 
    }
    return inner; 
}

const counter = outer(); 
counter(); // 1
counter(); // 2

这里inner就是闭包。它被outer返回后,在全局作用域执行(脱离了定义时的作用域),但依然能访问outer里的count变量。每次调用countercount都会累加, 这说明count没有被销毁,而是被闭包 “保存” 了。

问题:为什么count没被销毁?

  1. JS 的垃圾回收机制
    当函数执行完毕,其作用域内的变量本应被回收(如outer中的count)。但闭包inner引用了count,导致 **outer的作用域对象无法被释放 **,形成一个「持久化的作用域链」。

    用形象的比喻:inner就像一个「背包客」,离开outer的「老家」时,把count装进了背包。即使走到全局作用域,背包里的count依然存在。

  2. 词法环境的结构
    每个函数创建时都会生成一个「词法环境」,包含:

    • 环境记录:保存变量(如count
    • 外部环境引用:指向外层作用域(outer的词法环境指向全局,inner的词法环境指向outer)。
      inner被返回时,它的词法环境被保留,形成闭包的核心 ——跨作用域的变量引用通道

划重点:闭包形成的三个条件

  1. 存在函数嵌套(外层函数包裹内层函数);
  2. 内层函数引用了外层函数的变量 / 函数;
  3. 内层函数逃逸到外部作用域(如被返回、赋值给全局变量、回调函数等)。

再看一个颠覆认知的例子

let x = 10;
function foo() {
  console.log(x); // 输出10,而非20
}
function bar() {
  let x = 20;
  foo(); // 为什么不输出20?
}
bar();

这里foo定义在全局作用域,它的词法作用域链固定为 全局作用域。即使在bar中调用,foo依然访问的是全局的x。这说明闭包的作用域链是静态绑定的,与调用位置无关。

解析

  1. foo的作用域链在定义时确定
    foo定义在全局作用域,其词法作用域链为:全局作用域 → null(顶层作用域)。无论在哪里调用foo(如bar内部),它永远只能访问定义时的作用域链。

  2. bar的作用域链与foo无关
    bar的作用域链为:bar函数作用域 → 全局作用域。 foobar中调用时,只会沿着自己的作用域链向上查找,不会进入调用者bar的作用域。

二、为什么需要闭包?它能解决什么问题?

我们可能会疑惑:JS 有作用域链,直接用全局变量不行吗?为啥非得用闭包?
举个真实需求:做一个搜索框,需要 “记住用户上一次输入的内容”。如果用全局变量:

let lastInput = ''; 
function saveInput(input) {
    lastInput = input; 
}
// 问题:lastInput暴露在全局,可能被其他代码意外修改

这时候闭包的优势就体现了 ——它能创建私有变量,避免污染全局

function createInputSaver() {
    let lastInput = ''; 
    return function saveInput(input) {
        lastInput = input; 
        console.log(lastInput)
        return lastInput; 
    };
}

const saver = createInputSaver(); 
saver('第一次输入'); // '第一次输入'
saver('第二次输入'); // '第二次输入'

这里lastInput是 “私有” 的,只有saveInput函数能修改它,完美解决了全局变量的隐患。

闭包的典型应用场景

  • 数据私有:如上面的输入记忆功能、模块模式(封装工具库);
  • 函数记忆:缓存计算结果(比如斐波那契数列的记忆化优化);
  • 事件绑定:在循环中为元素绑定事件时保留当前循环的值(经典面试题);

三、闭包的 “坑”:内存泄漏?其实是你用错了

刚学闭包时,总听人说 “闭包会导致内存泄漏”。后来查 MDN 才知道:闭包本身不会导致内存泄漏,不合理使用才会。 比如,如果你在全局作用域里创建了一个闭包,且这个闭包一直被引用(比如作为事件监听函数),那么它的作用域就不会被垃圾回收。如果这个作用域里有大量无用数据,才会导致内存占用过高。 举个反面案例:

function badClosure() {
    const bigData = new Array(10000).fill('数据'); // 大数组
    return function() {
        console.log(bigData.length); 
    };
}

const fn = badClosure(); 
console.log('结束')

正确做法是:当闭包不再使用时,解除对它的引用(比如将变量设为null),这样闭包的作用域就会被回收。

function badClosure() {
    const bigData = new Array(10000).fill('数据'); // 大数组
    return function() {
        console.log(bigData.length); 
    };
}

const fn = badClosure(); 
fn = null; // 手动解除引用,让垃圾回收机制回收

关键点分析:

  1. 闭包的作用域保留

    • badClosure 函数内部创建了一个大数组 bigData,并返回一个闭包函数。
    • 闭包会保留其外部词法环境(即 bigData 的引用),因此即使 badClosure 执行完毕,bigData 也不会被回收,只要闭包存在。
  2. 手动解除引用

    • 当执行 fn = null 时,原本由 fn 引用的闭包函数失去了所有引用。
    • 此时闭包本身成为垃圾回收的候选对象,闭包被回收后,其保留的 bigData 引用也会被释放,最终 bigData 被垃圾回收。

四、闭包与 this 指向:最容易懵的组合拳

学闭包时,我发现它经常和this混在一起考。比如下面这段代码:

const obj = {
    name: '对象',
    getClosure() {
        return function() {
            console.log(this.name); 
        };
    }
};

const closure = obj.getClosure(); 
closure(); // 输出undefined

这里closure是闭包吗?是的,它定义在getClosure的作用域里,被返回后在全局执行。但this.name输出undefined,是因为this的指向和闭包无关!

划重点:闭包保存的是词法作用域中的变量,而this是动态绑定的
上面的例子中,closure在全局执行时,this指向全局对象(浏览器里是window)。如果window没有name属性,就会输出undefined

那怎么让this指向obj?有两种常见方法:

  1. 箭头函数(继承定义时的this
const obj = {
    name: '对象',
    getClosure() {
        return () => { 
            console.log(this.name); // 输出'对象'
        };
    }
};

const closure = obj.getClosure();
closure();

箭头函数没有自己的this,它的this是定义时外层作用域的this(这里getClosurethis指向obj)。

  1. 普通函数手动绑定
const obj = {
    name: '对象',
    getClosure() {
        const self = this; 
        return function() {
            console.log(self.name); // 输出'对象'
        };
    }
};

const closure = obj.getClosure();
closure();

显式捕获this值,转化为闭包可访问的变量self

五、闭包面试题:从 “循环绑定事件” 到 “函数柯里化”

闭包是面试高频考点,常见题目有:

题目 1:循环中绑定事件,点击按钮输出当前索引
错误代码:

// HTML:5个按钮,class都是btn
const btns = document.querySelectorAll('.btn');
for (var i = 0; i < btns.length; i++) {
    btns[i].addEventListener('click', function() {
        console.log(i); // 点击所有按钮都输出5
    });
}

原因:var声明的i是全局变量,循环体每次迭代修改的是同一个变量,循环结束后i的值是 5。点击事件触发时,闭包访问的i已经是 5 了。

正确解法(用闭包保存当前i的值):

方法1:立即执行函数创建闭包

for (var i = 0; i < btns.length; i++) {
    (function(j) { // j是当前循环的i值
        btns[i].addEventListener('click', function() {
            console.log(j); 
        });
    })(i); 
}

方法2:用let声明i

for (let i = 0; i < btns.length; i++) {
    btns[i].addEventListener('click', function() {
        console.log(i); 
    });
}

let声明的i在每次循环中都是新的变量,相当于为每个事件处理函数创建了独立的闭包。

题目 2:实现一个加法函数,支持add(1)(2)(3)输出 6
这是典型的柯里化问题,核心是利用闭包保存参数:

function add(a) {
    return function(b) { 
        return function(c) { 
            return a + b + c; 
        };
    };
}
console.log(add(1)(2)(3)); // 6

执行流程拆解

  1. 第一次调用add(1)

    • 进入add函数,参数a=1存入当前作用域。
    • 返回内部函数function(b),该函数的闭包捕获a=1
    • 此时形成第一个闭包:{ a: 1, b: undefined }
  2. 第二次调用(2)

    • 进入function(b),参数b=2存入作用域。
    • 返回内部函数function(c),闭包捕获a=1b=2
    • 形成第二个闭包:{ a: 1, b: 2, c: undefined }
  3. 第三次调用(3)

    • 进入function(c),参数c=3存入作用域。
    • 计算a+b+c=6,闭包使命结束。

进阶柯里化(任意参数)

function curriedAdd(...initialArgs) {
    const args = [...initialArgs];
    function inner(...newArgs) {
        return newArgs.length === 0
            ? args.reduce((sum, num) => sum + num, 0)
            : curriedAdd(...args, ...newArgs);
    }
    return inner;
}
const add = curriedAdd();
console.log(add(1)(2)(3)()) //6

这里inner函数通过闭包保存了args数组,每次调用时将新参数存入数组,直到无参数时计算总和。

六、总结:闭包的正确打开方式

学完闭包,我总结了三个关键点:

  1. 闭包的本质:函数对其词法作用域的 “记忆”,即使函数在作用域外执行;
  2. 核心价值:创建私有变量、隔离作用域,避免全局污染;
  3. 注意事项:合理管理闭包的生命周期(不用时解除引用),避免内存占用过高。

最后我想说的是:刚开始理解闭包时,别被 “词法环境”“作用域链” 这些术语吓退。多写小 demo(比如计数器、输入记忆),观察变量的变化,慢慢就能找到感觉。毕竟,编程不是背定义,而是 “用代码和计算机对话”。闭包,不过是我们和 JS 沟通的一种方式而已。

互动话题:你学闭包时遇到过哪些坑?欢迎在评论区分享你的故事~(悄悄说:我第一次写闭包时,把return写错位置,导致函数没返回,调试了好长时间😂)

Vue 3 ——初识Vue.js

作者 詹铅
2025年5月23日 16:16

一、前言

在前端开发中,一个优秀的框架可以帮助用户解决一些常见的问题,有助于高效地完成工作。Vue.js(简称Vue)作为前端开发常用的框架之一,不仅可以提高项目的开发效率,而且可以改善开发体验。

二、什么是Vue

Vue (发音为 /vjuː/,类似 view) 是一款用于构建用户界面的 JavaScript 渐进式框架。它基于标准 HTML、CSS 和 JavaScript 构建,并提供了一套声明式的、组件化的编程模型,帮助你高效地开发用户界面。无论是简单还是复杂的界面,Vue 都可以胜任。其中,“渐进式”是指在使用Vue核心库时,可以在1核心库的基础上根据实际需要逐步增加功能。

使用Vue开发的优势:

  1. 轻量级。
  2. 基于JS开发,降低开发门槛。
  3. 使用灵活,可开发一个全新的Vue项目,也可以将Vue引入现有项目。
  4. 通过虚拟DOM技术减少对DOM的直接操作,使用尽可能简单的API来实现响应式的数据绑定。
  5. 支持组件化开发,可提高项目的开发效率和可维护性,提高代码复用性。
  6. 可结合前端开发中用到的一系列工具以及各种支持库结合使用,以实现前端工程化开发,从而提高项目开发效率,降低开发难度。

Vue是基于MVVM模式的框架。

MVVM主要包含Model(数据模型)、View(视图)和ViewModel(视图模型)

  • Model是指数据部分,负责业务数据的处理;
  • View是指视图部分,即用户界面,负责视图处理;
  • ViewModel用于连接视图与数据模型,负责监听Model或者View的改变。

基本工作原理如下:

image.png

Vue的4个特性

  • 数据驱动视图:在使用Vue的页面中,Vue会监听数据变化,当页面数据发生变化时,Vue会自动重新渲染页面结构,如下图所示。

屏幕截图 2025-05-23 161139.png

  • 双向数据绑定:即数据发生变化时,视图也会发生变化;当视图发生变化时,数据也会跟着同步变化。
  • 指令:主要包括内置指令和自定义指令,内置指令是Vue本身自带的指令,而自定义指令是由用户自己定义的指令。
  • Vue支持插件,通过加载插件可以实现更多的功能。

三、什么是Vite

vite是一个轻量级、运行速度快的前端构建工具,它支持模块热替换(Hot Module Replacement,HMR),可以即时、准确地更新模块,当代码修改时无须重新加载页面或清除应用程序状态。

四、创建Vue 3项目

Vite提供了两种创建项目的命令。

  • 手动创建项目的命令
  • 通过模板自动创建项目的命令

1.手动创建项目的命令

使用npm或yarn包管理工具都可以搭配Vite手动创建,具体命令如下。

#使用npm create 命令创建项目
npm create vite@latest
#使用yarn create 命令创建项目
yarn create vite

vite@latest表示在npm 中安装最新版本的vite。

命令执行后,会让其选择所用的语言以及设置项目名称等,创建好后使用cd进入创建好的文件夹,进行下一步的编写。

2.通过模板自动创建项目的命令

这种方式相对简单,通过附加的命令行选项直接指定项目名称和模板,省去了填写项目名称、选择框架和变体等环节。Vite提供了许多模板预设,可以创建Vite+React+TS、Vite+Vue、Vite+Svelte等类型的项目。通过附加的命令行选项直接指定项目名称和模板的基本语法格式如下。

#使用npm 6或更低版本创建项目
npm create vite@latest<项目名称> --template<模板名称>
#使用npm 7或更高版本创建项目
npm create vite@latest<项目名称> -- --template<模板名称>
#使用yarn create命令创建项目
yarn create vite<项目名称> --template<模板名称>

例:创建一个基于Vite+Vue模板且项目名称为Hello-vite的项目。

yarn create vite Hello-vite --template vue

创建好的项目,使用cd进入,在命令行输入yarn安装所有依赖,输入yarn dev运行。

五、Vue 3项目的目录结构

vue经典目录结构

Hello-vue/
├── node_modules/
├── public/
│   ├── favicon.ico
├── src/
│   ├── assets/
│   │   └── logo.png
│   ├── components/
│   │   └── HelloWorld.vue
│   ├── views/
│   │   └── Home.vue
│   ├── App.vue
│   ├── main.js
│   └── router/
│       └── index.js
├── .gitignore
├── babel.config.js
├── package.json
├── README.md
├── index.html
├── vue.config.js
└── yarn.lock or package-lock.json

由此可看出,项目主要分为4部分

  • 项目根目录
  • node_modules目录
  • public目录
  • src目录

接下来详细介绍这四个部分的含义。

1.项目根目录

根目录配置文件
.gitignore Git 忽略文件列表,指定哪些文件和目录不被包含在版本控制中。
babel.config.js Babel 配置文件,指定 Babel 的编译规则。
package.json 项目的依赖、脚本和其他元数据。
README.md 项目的说明文件,通常用于描述项目、如何安装和使用等信息。
vue.config.js Vue CLI 的配置文件,用于修改默认配置。
yarn.lock 或 package-lock.json 锁定安装的依赖版本,确保项目依赖的一致性。

2.node_modules 目录

目录用于存放利用包管理工具下载安装的包的文件夹。

3.public 目录

静态文件目录,里面的文件不会被 Webpack 处理,最终会原样复制到打包目录下。

4.src目录

src/ 源代码目录,存放应用的主要代码。
src/assets/ 存放静态资源,如图像、字体等。这些文件会由 Webpack 处理,可以通过相对路径引用。
src/assets/logo.png 示例图像文件。
src/components/ 存放 Vue 组件,每个组件都是一个独立的 .vue 文件。
src/components/HelloWorld.vue 默认生成的示例组件。
src/views/ 存放视图组件,通常对应路由,每个视图都是一个独立的 .vue 文件。
src/views/Home.vue 默认生成的主页组件。
src/router/ 存放路由配置文件。
src/router/index.js 路由的配置文件,定义了应用的路由规则。
src/App.vue 根组件,整个应用的入口组件。
src/main.js 应用的入口文件,负责创建 Vue 实例并挂载到 DOM 上。

在将Vue 应用于实际项目开发之后,我才真正体会到 React 背后的设计哲学

2025年5月23日 16:00

从毕业开始我就一直使用 React 做前端开发,经历了从类组件到函数组件的转变。说实话,我一开始对 Vue 并没有太多兴趣,直到去年换了个公司,才真正沉下心来使用它开发完整的业务系统。

网上关于 Vue 和 React 的对比很多,但大多数是从 API、技术实现或者生态来分析的。而我想聊的是一个更贴近实际开发者视角的问题:用起来,到底哪一个更舒服?

这篇文章不会列大量语法对比,而是从“写代码的直觉体验”和“实际维护的方便程度”来聊聊我真实的感受。


写起来更直观 —— React 的逻辑和 UI 紧密结合

React 的组件结构非常直接,上面写 JS,下面是 JSX(可以理解为 HTML),逻辑和视图绑定得非常紧密。对我来说,它带来的是一种**“心里有数”**的感觉。

  • 想改一个功能:往上找逻辑代码
  • 想改 UI 或布局:往下找 JSX 结构

整个组件是一个闭环,关注点集中,不用跳来跳去。

而 Vue 则是 template、script、style 三段式的结构,表面看起来清晰,逻辑、样式、模板各归一类。但当代码变多之后,问题就暴露了:

比如我要改一个按钮的行为,我可能需要:

  • 滚到 template 看按钮绑定了什么方法
  • 再滚到 methods 找方法定义
  • 再切换到 style scoped 去改样式
  • 如果有条件渲染,还得找 computed

这在小项目中可能没什么,但当一个 .vue 文件超过 1000 行时,你就会开始怀念 React 那种“一页到底”的直觉式查找。


查找代码更高效 —— React 更利于长代码文件管理

在 React 中,特别是函数组件加上 Hooks,把逻辑通过 useStateuseEffect、自定义 hooks 组织在一起,每个组件的功能都是集中封装的。

尤其是对大文件来说,React 的结构更利于“按功能查找”,而不是“按结构查找”。

Vue 中因为逻辑被拆散在不同的属性中(methods、computed、watch),加上响应式的特性有时让逻辑难以追踪,维护和排查问题的成本会更高一点。

我曾经花了半小时去追一个字段值是怎么变化的,结果是在 watch 里触发另一个方法,这个方法又间接改了 data,整个过程像绕圈。

你真正用过,才知道差异在哪

每个框架都有它的适合场景。Vue 的上手门槛低,生态配套完整,非常适合中小团队快速开发;React 的自由度高,更适合熟练开发者定制大型项目架构。

但如果你从没在 Vue 或 React 中写过一个超过 2000 行的组件文件,你可能并不会真正体会到那些“写代码的舒适度”、“查找维护的效率”到底差在哪。

我只是想记录下自己的真实感受:作为一个从 React 转向 Vue 的开发者,在写了几个 Vue 大项目之后,才更加理解 React 的设计哲学,也更能体会两者在实际开发中那种微妙而巨大的差异。

打包出来的apk文件有病毒,但aab、ipa文件没病毒,这该怎么解决

2025年5月23日 15:56

背景

某天我和往常一样把需要测试的uniapp代码打包成apk、aab、ipa文件,然后把apkaab文件的压缩包上传到google云盘ipa上传到Testfilght,方便测试人员进行测试。

然而测试人员告诉我,你的apk压缩包有病毒提示(google硬盘在下载时会进行病毒分析),请解决完,再让我测试。

我一想,什么病毒?

这里有个小点

  • google硬盘上传apk文件后,再去下载也会报未知风险,所以压缩一下就解决了
  • 其次文件夹的名字不能大写

image.png

技术栈

  • uniapp + vue3 + vite + unocss + ts + uview-plus

查看问题

我心想怎么会这样,于是本着查看与复现问题的心,我也尝试下载,结果发现,apk压缩包确实有病毒提示,但aab文件没有病毒,这就匪夷所思了呀

因为apk、aab文件的代码与打包的协议的都是一模一样,就是打包后的渠道包不一样,怎么会导致打包出来,一个有病毒,一个没病毒,这就很奇怪

image.png

前期的猜测与尝试

  • 使用病毒分析工具,看看有没有什么异常的,VirusTotal
  • 版本回退,使用上一次打包的版本的代码,然后对比分析。因为上一次打包出来的apk可没有报错
  • uniapp官方可能有些病毒,需要去社区里面进行查看
  • 某些依赖有病毒

实践与验证

  • 使用病毒分析工具,只分析出了一个可能的误报的病毒:Acronis (Static ML);这不太好排查,主要核心的package.json、vite.config.js、mainfest.json,这些都没有变化;唯一变化的不过是某些页面的代码
  • 版本回退,发现使用了之前的版本打包出来的apk也有病毒,这就奇怪了,为什么?
  • uniapp官方申明过,他们没有任何病毒,参考
  • 考虑到我的package.json没有变化,所以依赖病毒可以略过

image.png

绕过与解决病毒的尝试

看到上图中的一个很关键的词语——投诉,于是我就想把自己的问题反馈给google云盘,希望能有点结果(实际上真没啥结果,都不回复你)

经过以上俩个测试,我并没有发现很关键的点,直接尬住了

此时的想法是绕过病毒检查,先让测试人员测apk,毕竟aab文件是用来上传到google play的,而且不好在手机上测试。

那怎么办呢,如果测试要正常继续下去,可以先让apk绕过病毒检查,毕竟aab没有病毒,我的做法是加密apk的压缩包,这样就能绕过病毒检查了

僵局

但是吧,测试人员还是不依不饶说,不能测试,等到病毒问题解决了在测试。此时就陷入了一个漫长的僵局,我无法解决这个问题,只能先暂时搁置,但仍然被催,即使说,这是google的误报也不行

转机

有时候需要大胆尝试

我突然想到,aab文件可以转apk的,如果aab文件没病毒,那理论上转换出来的apk也是没有病毒的,而且不是很复杂,我之前有研究过,具体流程如下:

  • 下载,bundletool jar,这是一个google官方给的工具,下载地址
  • 转换,在有bundletool jar文件夹下执行转换命令
  • 解压,因为转换后有个.apks的产物,这个产物其实压缩包,需要用命令进行解压

然后我就开始了我的大胆尝试

转换命令

# 如果你是mac可能要加,window不清楚,禁用 mac 的 Zsh 的 history expansion(临时)
# 因为如果你的证书的密码有!,命令会被截断
set +H 

# 转换命令模版
java -jar bundletool.jar build-apks
  --bundle=aab文件路径 # /xxx/xxx/xxx.aab
  --output=转换产物目标路径 # /xxx/xxx/xxx.apks
  --mode=universal
  --ks=证书路径
  --ks-pass='pass:证书密码' # 前缀pass: ,如果有!,需要引号
  --key-pass='pass:证书密码' # 前缀pass: ,如果有!,需要引号
  --ks-key-alias=证书别名

# 转换命令需要一行
java -jar bundletool.jar build-apks --bundle=/xxx/xxx.aab --output=/xxx/xxx.apks --mode=universal --ks=/xxx/xxx.keystore --ks-pass='pass:证书密码' --key-pass='pass:证书密码' --ks-key-alias=证书别名

转换命令解释

参数 意义 来源 是否必须
-jar bundletool.jar 执行 bundletool JAR 文件 你下载的 bundletool-all.jar 文件
build-apks 命令类型,表示构建 .apks 文件 固定写法
--bundle=app-release.aab 要转换的 App Bundle 文件(AAB)路径 Gradle 构建或手动打包生成的 .aab
--output=app.apks 输出 .apks 文件的路径 你定义的任意文件名,后缀 .apks
--ks=your-release-key.jks 指定签名的 keystore 文件路径 你生成的签名证书(用 keytool 创建) ✅(发布用)
--ks-key-alias=your-key-alias keystore 中的别名(alias) 创建 keystore 时设置的名称 ✅(发布用)
--ks-pass=pass:your-keystore-password keystore 文件的密码 你设置的密码(推荐用 env 变量代替) ✅(发布用)
--key-pass=pass:your-key-password 密钥条目的密码 和 alias 相关联的密码 ✅(发布用)

解压命令

unzip 目标路径 -d 解压路径

解压完就能得到2个文件,其中一个是unviersal.apk,这个就是转换出来的apk,另外一个是toc.pb

注意点

  • 上述命令我是在mac上运行的,window没试过,但思路是一样的
  • 证书密码有!字符,在mac端会被截断,需要set +H,并且用引号包围
  • 前缀的pass:,不能忘记加
  • 需要在bundletool jar的父级文件夹运行转换命令,简单来说那个目录有这个,就在那个目录下运行转换命令

结果

在经过我的转换后,我运行了下新的apk,发现能够运行,并且也没有任何病毒提示,我大喜过望,赶紧让测试人员测试,此事就暂时告一段落了,反正得让测试进行测试

尾声

apk的病毒还是没有解决,但新的apk没有病毒,我正在对比2者的差异化,希望能找出一些蛛丝马迹

WebRTC 文件传输 / 共享桌面

背景介绍: WebRTC 文件传输在 P2P、成本节省、安全性等方面具有显著优势,特别适合实时性要求高且数据保密性要求强的场景,在公司局域网中传输是一个很好的方式,但其在处理大文件、多用户连接以及在复杂网络环境下的可靠性方面存在不足。

优缺点

WebRTC 优点 缺点
P2P,不走中继服务器,减少了延迟 浏览器可处理的文件数量和大小有限,对于大文件传输需要分块处理。
带宽相对较便宜 同时连接两个以上的用户不切实际
默认对所有通信进行加密 自建存储,存储节点的硬件可能会出现故障,运维成本较高
WebRTC支持多种浏览器和设备 高质量的文件传输,可能会消耗大量的带宽
不需要固定的IP地址 性能受网络质量影响较大
无需复杂的服务器配置

展示结果

文字 image.png

文件 image.png

开发目的及功能点

  1. 基于 WebRTC 局域网内 P2P 实现文件快传
  2. 用户登录及发生信令交换
image.png
  1. 发文件 --(分片+存储) --> 接收下载
image.png
  1. 新用户 预览历史记录下载文件 --> 发起请求 --> 接收通知 --> 接收下载
image.png
  1. 共享桌面 --> 浏览器启动桌面捕获 --> 转帧 --> 传输 --> 解码渲染

文件传输 - 关键代码部分

信令交换(socket.io代替过程)

服务端代码

image.png

前端代码

image.png

图例

image.png

文件接发 + 存储indexDB(省略)

服务端代码

image.png

前端代码

image.png

image.png

桌面分析 - 关键代码部分

展示结果

image.png

信令交换 (同上)

前端代码 image.png

结语

虽然完成了这两个Demo,但是在同一局域网内P2P文件只有 16kb,速率上不去,共享桌面也是只有300KB上传,把能配置的都配置了,当然JYM,如果有其他的配置方式也可以请教一下,这个项目最后还是给毙了,当作自己的一个练手demo吧。

AI Copilot 是敌是友?教你正确使用 AI 写前端

2025年5月23日 15:36

在这个“人类 + AI”并肩作战的时代,写代码的方式正在悄然改变。你是否也遇到过这样的场景:明明你还在想怎么写,Copilot 已经“贴心”地给出了实现?是欣喜,还是担忧?


🧠 AI Copilot 到底改变了什么?

过去,我们在开发过程中会大量依赖搜索引擎、技术博客甚至官方文档。而现在,有了 AI 工具(如 GitHub Copilot、ChatGPT、Cursor 等),我们逐渐从 “主动搜索” 转向 “对话式编程”

这种改变非常微妙但深远:

  • 📝 写模板代码的效率大幅提升
  • 🚫 对底层机制的思考被弱化(很多人 copy 就完事)
  • 🔧 工具决定了思维方式 —— 越依赖,越缺乏锻炼

⚖ 它是敌还是友?关键在于你怎么用

AI 工具本身没有对错,它只是你手中的一把刀,关键在于你怎么使用它。

✅ 正确的使用方式:

1. 让 AI 做“苦活累活”

  • 构建重复组件模板(如表单、CRUD 页面)
  • 生成 API 请求封装、类型定义(如 TypeScript 接口)
  • 快速搭建脚手架、项目目录结构

2. 用 AI 做思路引导,不是思维替代

  • 让它写思路,不要直接 copy 实现
  • 多问“为什么”,看它怎么解释 —— 学习过程才最宝贵

3. 调试 + 优化的得力助手

  • 代码报错时,让 AI 解释异常含义、给出解决方案
  • 性能瓶颈时,让它分析代码逻辑并建议优化方案

❌ 错误的使用方式:

  • 把 AI 当成“答案机”,自己不再深入理解代码
  • 依赖它生成复杂逻辑,而不验证其可行性
  • 把它当成生产力,而不是学习助手

🛠️ 实战演示:用 AI 快速搭建一个 React + Tailwind 登录页

在 Copilot 中只需键入:

function LoginForm() {

它很可能就自动为你补全:

const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
return (
  <form className="space-y-4">
    <input type="email" value={email} onChange={e => setEmail(e.target.value)} />
    <input type="password" value={password} onChange={e => setPassword(e.target.value)} />
    <button type="submit">登录</button>
  </form>
);

你可以继续对它说:

“请帮我加入 Tailwind 样式,并在输入框下显示校验提示。”

它会继续优化样式并添加错误提示逻辑。这种模式极大提升了 UI 构建速度。


🔍 Copilot 不会告诉你的事:背后这些你还是得懂!

别被它写的“理直气壮”的代码骗了,作为前端开发者,你仍需掌握:

  • 状态管理的设计(如 useReducer、zustand 的适用场景)
  • 组件拆分与复用逻辑
  • 性能优化:memo、lazy、React DevTools 的使用
  • 可访问性与语义化标签
  • 可维护性与团队规范(命名、注释、测试)

🔮 展望:你要成为“AI 驯兽师”,而不是“被驯服者”

未来的程序员,一定是那些能把 AI 工具用得“炉火纯青”的人。他们不仅写代码快,而且还能看穿 AI 的“思维短板”,主动引导它走向正确路径。

也许你的竞争对手不是 AI,而是那些 比你更会用 AI 的程序员


✅ 写在最后

AI Copilot 是你的朋友,不是敌人。但前提是,你得有能力驾驭它,而不是依赖它。

学会用它辅助你构思、调试、优化,而不是接管你的思考。

工具越先进,开发者越需要具备“批判性技术思维”。


如果你觉得这篇文章对你有启发,欢迎点赞、评论、转发。我会持续分享关于「前端 + AI 工具实践」的实战经验。我们下篇见!

MCP Server 实践之旅第 3 站:MCP 协议亲和性的技术内幕

2025年5月23日 15:16

作者:柳下,西流

背景

在分布式架构设计中,请求亲和性是实现有状态服务高可用的核心技术,通过将具备相同会话标识的请求智能路由至固定计算节点,保障会话连续性及缓存有效性。然而在 Serverless 范式下,函数计算服务因其瞬时实例生命周期、自动弹性扩缩容、无状态化运行等特性,在亲和性保障层面面临原生架构冲突。本文将以 MCP Server 在函数计算平台的深度集成为研究载体,解构基于 SSE 长连接通信模型,剖析会话亲和、优雅升级等关键技术,揭示 Serverless 架构在 MCP 场景中的亲和性创新实践。

概念介绍

在系列文章首篇 MCP Server 实践之旅第 1 站:MCP 协议解析与云上适配 我们深入解析了 MCP 以及 SSE 协议,为了方便本文的阅读, 这里再简单介绍下 MCP 以及 SSE 协议。

MCP:作为开放标准协议,为 AI 应用构建了通用化上下文交互框架。可以将 MCP 想象成 AI 应用程序的 USB-C 接口。就像 USB-C 为设备连接各种外设和配件提供了标准化方式一样,MCP 为 AI 模型连接不同的数据源和工具提供了标准化方式。

MCP Server&Client:MCP 通过 Server+Client 在 AI 应用程序和数据之间搭起了一座桥梁,MCP Server 负责打通 data、tools,AI 应用程序通过 MCP Client 连接 MCP Server。其中 Client 与 Server 的通信基于 SSE 协议实现,而 MCP 亲和奥秘也恰恰隐藏在 SSE 协议之中,下文将详细介绍。

SSE协议:作为 HTTP/1.1扩展协议,SSE(Server-Sent Events)定义了结构化流式传输规范,允许服务器实时向客户端推送事件或数据更新。在接收到订阅请求后,服务器会保持连接开启,并通过 HTTP 流向客户端推送事件,其中每个流数据格式如下:

event: <event-type>       // 事件类型(endpoint/message/close)
data: <payload>           // 通过数据字段传递实际内容
id: <message-id>          // 用于唯一标识事件
retry: <milliseconds>     // 重连退避策略

函数计算:函数计算是事件驱动的全托管计算服务。使用函数计算,您无需采购与管理服务器等基础设施,只需编写并上传代码或镜像。函数计算为您准备好计算资源,弹性地、可靠地运行任务,并提供日志查询、性能监控和报警等功能。

MCP交互协议解析

在解析 MCP 亲和性实现原理前,需重点解析其基于 SSE(Server-Sent Events)构建的通信框架。该协议通过定义标准化事件类型,实现了客户端-服务端的交互控制及会话保持机制,具体流程如下:

  1. 会话建立阶段:
    • 客户端发起初始 SSE 连接请求;
    • 服务端通过 event:'endpoint' 事件响应,在 data 字段中嵌入唯一会话标识(Session ID);
  2. 请求保持机制
    • 后续所有客户端请求必须携带该 Session ID;
    • 服务端通过该标识验证请求来源的合法性;
    • 实现客户端与服务端实例的绑定关联(Session Affinity);
  3. 实例绑定校验:
    • 当 messages 请求的路由目标实例与SSE连接绑定实例不一致时;
    • 服务端将触发安全校验失败机制,返回 4xx Conflict 错误代码;

该设计通过事件驱动架构确保了会话状态的连续性,同时通过实例绑定校验机制保障了分布式环境下的请求一致性。需要注意的是,Session ID 的有效期与 SSE 连接生命周期严格绑定,连接中断后需重新进行会话协商,一个简单的交互流程示例如下:

  1. Client 端发起一个 GET 请求,建立 SSE 长连接。(Connection1)
  2. Server 端回复event:endpoint类型的事件,将 sessionId 信息放入 data 中返回。(Connection1)
  3. Client 端使用第2步返回的 sessionId 信息发起首个 HTTP POST 请求。(Connection2)
  4. Server 端迅速响应202,但无内容。(Connection2)
  5. Server 端返回第3步请求的实际消息。(Connection1)
  6. Client 端使用第2步返回的 sessionId 发起 HTTP POST 请求initialized作为确认。(Connection3)
  7. Server 端迅速响应202,无内容。(Connection3)
  8. Client 端使用第2步返回的 sessionId 发起 HTTP POST 请求list tools。(Connection4)
  9. Server 端迅速响应202,无内容。(Connection4)
  10. Server 端返回第8步请求的实际消息,即工具列表。(Connection1)
  11. Client 端使用第2步返回的 sessionId 发起 HTTP POST 请求call tool。(Connection5)
  12. Server 端迅速响应202,无内容。(Connection5)
  13. Server 端返回第11步请求的实际消息,即工具调用结果。(Connection1)

亲和性机制解析

宏观分类与核心价值

系统亲和性主要分为两大维度:

  1. 节点亲和性:面向资源调度场景,确保工作负载优先部署至符合标签规则的节点。
  2. 会话亲和性:面向请求路由场景,保障客户端流量持续定向到特定后端实例。

两类机制均通过属性一致性调度实现核心价值:

  • 提升局部资源复用率(如缓存命中)
  • 保障有状态业务连续性
  • 满足合规性数据路由要求

会话亲和性实现范式

而常见的会话亲和性主要有以下几类:

  1. Cookie 植入模式:首请求时 LB、网关类服务注入含后端标识的 Set-Cookie 头,后续请求基于 Cookie 值进行会话绑定。适用 HTTP 无状态协议场景,且客户端缺乏显式标识信息。
  2. 源 IP 哈希模式:基于 ClientIP 哈希值映射到后端特定节点,满足 TCP/UDP 四层流量及需要客户端级会话保持的场景。
  3. Header 字段路由模式:预定义 Header 字段值提取(如 X-Session-ID),并哈希计算生成目标映射,支持多客户端标识共存场景,满足细粒度路由策略。

MCP SSE 亲和性架构特性

MCP SSE 采用双阶段协商机制:

  1. 会话建立阶段:MCP Server 生成全局唯一 SessionID 并同步至客户端
  2. 请求路由阶段:网关通过专有协议实时获取 Session-Node 映射关系

该模式需网关层与 MCP Server 间实现会话信息同步。相较于传统会话亲和方案,在获得精确路由控制能力的同时,需权衡协议交互带来的系统复杂度提升。

MCP ON FC 亲和调度设计

函数计算支持一键托管 MCP Server,并通过深度适配 MCP SSE 协议,提供了一种即开即用的 Serverless 亲和调度能力,帮助您实现 MCP 服务的 Serverless 托管能力,下面将详细介绍函数计算的亲和策略机制。

亲和策略

函数计算作为集调度、计算托管、免运维等特性于一身的 Serverless 服务,可将函数计算核心组件抽象为三部分:

  1. Gateway:网关层,用户流量入口,负责接收用户请求、鉴权、流控等功能。
  2. Scheduler:调度引擎层,负责将用户的请求调度到合适的节点和实例。
  3. VMS:资源层,函数执行环境。

当客户通过函数计算托管 MCP 服务并通过 MCP Client 发起请求时,可将用户请求分为两类:SSE 管控链路和 Message 数据链路。
SSE 管控链路(会话初始化)

  1. Client 发起首个 SSE 请求路由到一台函数计算网关节点 Gateway1,网关节点权限校验通过后转发至调度模块 Scheduler。
  2. 调度模块根据特定标识识别出请求类型为 SSE 时,将调度到一台可用实例。
  3. 当请求和实例绑定时,实例将启动用户代码。
  4. 用户代码启动完成后,会通过event:endpoint事件将 sessionId 放入 data 中,返回第一个数据包。
  5. 在 response 返回经过 Gateway 网关层时,网关层将拦截 SSE 请求的首个回包,解析 SessionID 信息,并将 SessionID 和实例的映射关系持久化到DB。

Message 数据链路(请求处理)

  1. Client 完成SSE请求后,将发起多个 Message 请求,由于函数计算网关节点无状态,Message 请求将打散到多个网关节点。
  2. 当 Gateway 收到 Message 请求,将检查网关节点 cache 中是否存在 Message 请求携带的 SessionID 亲和信息,如果 cache 中无记录,将回源到 DB 获取相关数据。
  3. Gateway 通过 cache 或 DB 拿到 SessionID 和实例的绑定关系时,将携带相关信息转发至调度模块。
  4. 调度模块根据特定标识识别出请求类型为 Message 时,解析携带的实例信息,将请求定向调度到特定实例。
  5. 当请求和实例绑定时,MCP Server 校验请求通过,将返回202通知 Client 请求接收成功,实际数据将通过 SSE 请求建立的连接返回。

函数计算通过无状态网关层与智能调度层的协同设计,在 Serverless 架构下创新实现了 MCP SSE 会话亲和性保障。SSE 管控链路借助首包拦截实现 SessionID 与实例的动态绑定,Message 数据链路则通过多级缓存与持久化存储确保请求精准路由。该架构既保留了函数计算的弹性优势,又攻克了无状态服务处理有状态请求的难题,为 MCP 场景提供了高可靠、低延迟的 Serverless 化解决方案,同时通过冷启动优化与智能扩缩容机制,实现资源效率与性能的最佳平衡。

MCP 场景会话配额控制体系

配额冲突建模分析

在 MCP 会话资源需求模型中,单个 Session 生命周期内存在两类并发需求:

而这种并发需求在传统配额分配中存在严重缺陷,需要引入一种动态预留的配额分配策略:

动态配额分配策略

为避免上述问题,函数计算引入 Session Quota 策略,即结合函数实例的并发度配置,限制每个实例最多绑定 Round (函数单实例多并发配置 / 10) 个 Session。如下流程所示:

  1. 当函数配置了20并发时,可服务 20/10=2 个 Session 请求。
  2. Client1 发起 SSE 请求时,分配了VM1实例,并占用1 Session Quota。
  3. Client2 发起 SSE 请求时,Scheduler 计算 VM1 仍有1 Session Quota,成功和 VM1 再次完成绑定。
  4. Client3 发起 SSE 请求时,Scheduler 计算 VM1 2个并发 Session Quota 已被2个 SSE 请求占用,无法再次绑定,则调度到新实例 VM2,完成实例绑定。

MCP 会话场景灰度优雅升级方案

函数计算支持 UpdateFunction 操作更新函数配置,在用户更新函数后,新的请求将路由到新配置拉起的实例,旧实例不再接收新请求,在处理完存量请求后后台自动销毁。如下图在 UpdateFunction 前,请求1-n路由到 VM1,UpdateFunction 后新请求路由到 VM2,VM1 在处理完存量请求后自动销毁。

在 MCP 场景下,数据请求从请求级无状态变为会话级绑定,在 UpdateFunction后,如果存量 Session 关联的请求路由到新实例,则新增无法识别到 SessionID 信息,返回错误。为解决这类问题,函数计算优雅更新能力从升级至有状态 Session 级别,在用户更新函数后,存量 Session 关联的请求仍路由到旧实例,新建 Session 请求路由至新实例,优雅实现 MCP 亲和场景下的升级需求。

压测

压测准备

我们以一个简单的 MCP Server 服务托管到函数计算作为压测对象, 函数代码如下:

import random
import asyncio
from mcp.server.fastmcp import FastMCP
from starlette.applications import Starlette
from starlette.routing import Mount

mcp = FastMCP("My App")

@mcp.tool()
async def add(a: int, b: int) -> int:
    """计算两个整数的和(含150-1000ms随机延迟)"""
    delay = random.uniform(0.15, 1.0)
    await asyncio.sleep(delay) 

    print(f"add工具被调用,延迟 {delay:.3f}s")
    return a + b

app = Starlette(
    routes=[
        Mount("/", app=mcp.sse_app()),
    ]
)

压测脚本 load_test.py 如下:

# 并发 100 个 mcp client
# 每次 client 建立一条 SSE 连接, 执行 call tool
# 并且校验 call tool 的结果是符合预期的

import asyncio
from mcp.client.sse import sse_client
from mcp import ClientSession
import logging
import time
import random
import traceback

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("LoadTestClient")

class ErrorCounter:
    def __init__(self):
        self.count = 0
        self._lock = asyncio.Lock()

    async def increment(self):
        async with self._lock:
            self.count += 1

err_counter = ErrorCounter()

async def robust_client_instance(instance_id: int):
    try:
        async with sse_client(
            "https://xxx-yyy-zzz.cn-hangzhou.fcapp.run/sse",
            headers={
                "Authorization": "Bearer YOUR_API_KEY",
                "X-Instance-ID": str(instance_id),
            },
            timeout=10
        ) as streams:
            if streams is None:
                raise ConnectionError("SSE连接失败")

            async with ClientSession(
                read_stream=streams[0],
                write_stream=streams[1],
            ) as session:
                # 初始化及调用逻辑
                start = time.time()
                await asyncio.wait_for(session.initialize(), timeout=10.0)
                logger.info(f"实例{instance_id}; initialize 耗时: {time.time() - start}")

                try:
                    # 设置call_tool超时
                    start = time.time()
                    random_number = random.randrange(1, 51)
                    result = await asyncio.wait_for(
                        session.call_tool(
                            "add", {"a": instance_id, "b": random_number}
                        ),
                        timeout=10.0,
                    )
                    logger.info(
                        f"实例{instance_id};  call_tool 耗时: {time.time() - start}"
                    )

                    if result.isError:
                        logger.error(
                            f"实例{instance_id}, call_tool 调用失败: {result.content}"
                        )
                        raise Exception(
                            f"实例{instance_id}, call_tool 调用失败: {result.content}"
                        )
                    else:
                        logger.info(
                            f"实例{instance_id}, call_tool 调用成功: {result.content}"
                        )
                        assert (
                            int(result.content[0].text)
                            == instance_id + random_number
                        )
                except asyncio.TimeoutError:
                    logger.error(f"实例{instance_id} 操作超时")


    except asyncio.TimeoutError:
        await err_counter.increment()
        logger.error(f"实例{instance_id} 操作超时")
    except Exception as e:
        await err_counter.increment()
        logger.error(f"实例{instance_id} 失败: {str(e)}")
        logger.debug(traceback.format_exc())

async def main():
    BATCH_SIZE = 100
    tasks = [robust_client_instance(j) for j in range(0, BATCH_SIZE)]
    await asyncio.gather(*tasks, return_exceptions=True)

    print(f"总错误数: {err_counter.count}")

if __name__ == "__main__":
    asyncio.run(main())

压测结果:

执行压测命令, 并行启动 3 个压测进程, 每次压测进程并发启动 100 mcp client, 每次 client 建立一条 SSE 连接, 执行 call tool, 并且校验 call tool 的结果是符合预期的

python load_test.py & python load_test.py & python load_test.py & wait

3 个压测进程, 每个进程中的 100 个并发的 mcp client 全部成功执行

表示 SSE 长连接和后续配合该会话的 HTTP 请求(call tool)在同一个函数实例,实现亲和行为

MCP Server 函数实例从 0 毫秒级扩容到 15 个实例,实现有状态实例水平横向扩展

根据官方文档 MCP SSE亲和性调度 描述,该函数设置的单实例并发度为 200,那么单个实例支持的 session 数目(即 SSE 长连接个数为 20个), 300/20 = 15 实例

测试更新函数, MCP 会话灰度优雅升级

我们尝试在持续压测过程中,中途更新函数, 所有的 mcp client 的行为都符合预期无报错。

我们使用如下脚本模拟持续压测:

...
# 压测(分50批)
BATCH_SIZE = 100
for i in range(0, 5000, BATCH_SIZE):
    tasks = [robust_client_instance(j) for j in range(0, BATCH_SIZE)]
    await asyncio.gather(*tasks, return_exceptions=True)
    await asyncio.sleep(1)  # 批次间间隔

总结

通过系列技术方案的精妙设计,函数计算为 MCP 场景构筑了 Serverless 化的完整技术基座。在 Serverless 服务范式与有状态业务需求之间架起智能桥梁,通过会话亲和调度引擎、优雅会话升级、动态配额熔断等机制,让 Serverless 的极致弹性与有状态服务的强一致性实现了深度融合。

更多内容关注 Serverless 微信公众号(ID:serverlessdevs),汇集 Serverless 技术最全内容,定期举办 Serverless 活动、直播,用户最佳实践。

Hono、tRPC、ElysiaJS:让前端仔写后端爽到飞起的神器

作者 醒来明月
2025年5月23日 15:15

前言

作为一个从前端入行的开发者,你是不是在想写全栈项目时一筹莫展,经常在选技术栈时纠结到秃头?Express 太老、NestJS 太重、Fastify 不够潮? 别担心,今天给你介绍三个使用 js/ts 的现代后端(和前后端通信)框架:Hono、tRPC、ElysiaJS,它们各有绝活,能让你写代码时笑出声。

Hono:边缘计算的轻量刺客,代码比你的待办事项还短

它是什么?

  • 一个超轻量 Web 框架,能在 Cloudflare Workers、Deno、Bun、Node.js 上运行;
  • 压缩后只有 14KB

核心功能

多平台兼容:一份代码,到处运行(边缘函数、Serverless、传统服务器全支持)。
极简 API:语法像 Express,但更干净,没有历史包袱。
超快路由:比 Express 快,甚至比 Fastify 还快(Fastify:??)。

适用场景

  • 你想在 Cloudflare Workers 上部署 API,享受全球超低延迟;
  • 你需要一个超轻量后端,比如 Serverless 函数、CDN 边缘逻辑;
  • 你讨厌臃肿的框架,喜欢“够用就行”的哲学。

优点

  • 极低资源占用,适合边缘计算场景;
  • 语法类似 Express,迁移成本低;
  • 支持 TypeScript 类型推断(如路径参数推导)。

缺点

  • 生态不如 Express等传统框架丰富,某些场景得自己造轮子;
  • 文档虽然够用,但不如传统框架那样“全网都是教程”。

tRPC:全栈开发的类型安全胶水

它是什么?

  • 一个类型安全的 RPC 框架,让你像调用本地函数一样调后端 API;
  • 前后端通信的终极优化方案

核心功能

无缝类型同步:改后端接口?前端类型自动更新,再也不会“接口又变了!”
零 API 设计:不用写 REST 路径,直接调用 getUser({ id: 1 }),像魔法一样。
React/Next.js 深度集成:配合 @trpc/react-query,状态管理都省了。

适用场景

  • 你受够了手动维护 api.tsswagger.json,想要自动类型
  • 你在用 Next.js,想要一体化全栈开发(API 路由 + 前端直接调用);
  • 你希望前后端联调时间从 3 天缩短到 3 分钟。

优点

  • 极简的 API 设计与开发体验;
  • 全栈类型安全,减少联调成本;
  • 适合微服务架构和全栈 TypeScript 项目。

缺点

  • 只适合 TypeScript 项目,JavaScript 用户只能干瞪眼;
  • 不适合超大分布式系统(这时候请上 gRPC)。

ElysiaJS:Bun 生态的性能怪兽,类型安全狂魔

它是什么?

  • 一个超快、类型安全的后端框架,专为 Bun 运行时优化(但也能跑在 Node.js 上);
  • 官方号称比 Express 快 21 倍(Express:你礼貌吗?)。

核心功能

自动类型推导:连路由参数都能给你推导成 TypeScript 类型,再也不用写 as any 了!
Zod 集成:请求校验直接和类型绑定,写 API 像写合同一样严谨。
Bun 原生优化:如果你用 Bun,ElysiaJS 就是你的涡轮增压引擎。

适用场景

  • 你需要一个独立的高性能后端(比如实时数据处理 API);
  • 你受够了手动写 if (!req.body.name) throw Error("名字呢?"),想要自动校验
  • 你在玩 Bun,想试试“未来 Web 开发”是什么感觉。

优点

  • 极致的类型安全与开发体验;
  • 高性能和轻量化设计;
  • 支持 Bun 原生生态,适合现代全栈项目。

缺点

  • 生态还在成长,不像 Express 等传统 js 服务端框架有海量中间件;
  • 如果你不用 TypeScript,它的超能力就废了一半。

架构设计:怎么搭配最爽?

场景 1:Next.js 全栈项目(个人项目 MVP)

🏆 推荐组合:Next.js + tRPC

  • 优点:一体化开发,类型安全,部署简单(Vercel 一把梭)。
  • 代码示例
    // 后端(Next.js API 路由)
    export const appRouter = router({
      hello: publicProcedure.query(() => "world!"),
    });
    
    // 前端(直接调用)
    const { data } = trpc.hello.useQuery(); // data = "world!"
    
  • 适合:博客、工具类 SaaS、个人项目。

场景 2:高性能独立后端 + 边缘优化

🏆 推荐组合:Next.js + ElysiaJS(Bun) + Hono(边缘)

  • 优点:Next.js 负责前端,ElysiaJS 处理核心业务,Hono 做边缘缓存/鉴权,前后端完全解耦。
  • 适合:高并发 API、全球分布式应用、大型应用(如电商平台)。

场景 3:React/Vue 前端 + 轻量 API

🏆 推荐组合:React/Vue + Hono(Cloudflare Workers) + 自动类型生成

  • 优点:超低成本、超低延迟,适合轻量级应用。
  • 适合:静态网站 + 简单 API(比如天气查询、备忘录)。

结论:怎么选?

  • 如果你用 Next.jstRPC 是亲爹级体验,爽到飞起。
  • 如果你需要高性能后端ElysiaJS + Bun,速度拉满。
  • 如果你玩边缘计算Hono 是你的轻量刺客,代码少到笑出声。
维度 ElysiaJS Hono tRPC
核心定位 高性能类型安全后端框架 多运行时轻量 Web 框架 类型安全 RPC 通信框架
性能 ⭐⭐⭐⭐⭐(Bun 优化) ⭐⭐⭐⭐(边缘计算优化) ⭐⭐⭐(侧重开发效率)
类型安全 ⭐⭐⭐⭐⭐ ⭐⭐⭐(基础推导) ⭐⭐⭐⭐⭐
适用场景 全栈 TypeScript 服务 边缘计算、轻量 API 全栈类型安全通信
生态成熟度 ⭐⭐(较新) ⭐⭐⭐(增长中) ⭐⭐⭐⭐(社区活跃)

最后忠告:

  • 别纠结,先选 Next.js + tRPC 试试,你会回来感谢我的。
  • 如果你非要问“Express 还能不能战?”——当然能,就是有点费手。

现在,选个框架,去写代码吧!

🎄JavaScript的ES5实现继承

作者 MariaH
2025年5月23日 15:06

写在前面


面向对象有三大特征:封装,继承,多态,封装我们在上篇文章中将属性和方法封装到一个类里面就可以称之为是一个封装的过程,继承是面向对象中非常重要的,不仅仅可以减少重复代码的数量,也是多态的前提(纯面向对象中),多态指的是不同的对象在执行时候表现出不同的形态,这篇文章我们主要是使用 ES5 的语言通过原型链来实现继承的功能,如果你对原型和原型链不了解,请先看下JavaScript系列中的原型和原型链相关文章好了 🫨,老规矩,废话不多说,让我们开始吧!

我们在对 ES5 语法实现继承的学习中将会按照如上思维导图的顺序来讲解,分别包括原型链实现的继承``借用构造函数继承``寄生组合实现继承``对象的方法补充四个部分内容。

一.利用原型链实现继承


对于继承而言其实我们主要关注两方面的继承,一个是属性的继承,另外一个是方法的继承,我们使用原型链来实现一下。

方式一:父类原型直接赋值给子类的原型

function Person(name, age) {
  this.name = name
  this.age = age
}
Person.prototype.running = function () {
  // 通过函数的原型来实现函数
  console.log('running~')
}

Person.prototype.eating = function () {
  console.log('eating~')
}

// 定义学生类
function Student(name, age, sno, score) {
  this.name = name
  this.age = age
  this.sno = sno
  this.score = score
}

Student.prototype = Person.prototype

Student.prototype.studying = function () {
  console.log('studying~')
}

// 创建学生对象
let stu1 = new Student('芒果', 12, '12345', 98)
stu1.running()

当我们想要实现继承,我们可以利用原型直接将父类的原型赋值给子类的原型,然后子类就拥有了父类的原型对象,进而可以使用父类的属性和方法,在一定程度上就实现了继承,但是这种继承的方式是有缺点的,直接将父类的原型对象赋值给了子类的原型对象这样会造成,父类和子类的原型对象是共用的更改一方另外一方也会修改。

方式二:创建一个父类的实例对象(new Person)用这个实例对象来作为子类的原型对象。

function Person(name, age) {
  this.name = name
  this.age = age
}
Person.prototype.running = function () {
  console.log('running~')
}

Person.prototype.eating = function () {
  console.log('eating~')
}

// 定义学生类
function Student(name, age, sno, score) {
  this.name = name
  this.age = age
  this.sno = sno
  this.score = score
}

Student.prototype = new Person() // 将父类的实例化对象作为子类的原型对象。

Student.prototype.studying = function () {
  console.log('studying~')
}

// 创建学生对象
let stu1 = new Student('芒果', 12, '12345', 98)
stu1.running()

这个方式就可以解决方式一中的共用原型对象的问题了,但是不足之处在于我们并没有将父类的属性继承过来,必须使用子类的属性,需要注意的是必须再将父类的实例化对象赋值给子类原型对象后再进行子类自己属性方法的实现,他的内存图如下:

二.原型链继承的弊端


虽然我们在上面的代码中实现了原型链的继承,但是这样其实有很大的弊端,某些属性其实是保存在 P 对象上的

第一:我们直接打印对象是看不到nameage属性的,因为加入我么将上述的代码更改为下面的代码其实当我们去通过[[get]]进行获取的时候其实是可以获取到对应的属性的,但是我们直接打印实例化对象的时候是看不到对应的属性的,这个非常好理解,因为对于属性的查找其实nameage是在原型链上查找的。

第二:属性nameage会被多个对象共享,如果这个对象是一个引用类型,那么就会造成问题,和上述的问题一样当我们将this.namethis.age进行删除的时候我们在实例化对象中是无法查询到对应的属性的,这样就没有自己的属性了,但是如果我们在子类中增加对应的属性的话其实这些属性是重复的。

第三:不能给 Person 传递参数(让每个 stu 有自己的属性)因为这个对象是一次性创建的(没办法定制)

三.借用构造函数继承


其实我们主要面临的问题是属性继承的问题,我么使用原型链的继承可以来解决方法的继承问题,但是属性的继承总会有问题,直接使用父类的属性,我们在子类打印子类实例化对象的时候看不到使用父类的属性,如果不使用父类的属性的话,在子类自己编写又会造成重复的问题,在 JavaScript 社区中经过了长期的总结,提出了借用构造函数的继承方式,也叫做组合继承,他的代码实现如下:

function Person(name, age) {
  this.name = name
  this.age = age
}
Person.prototype.running = function () {
  console.log('running~')
}

Person.prototype.eating = function () {
  console.log('eating~')
}

// 定义学生类
function Student(name, age, sno, score) {
  Person.call(this, name, age) // 借用父类的构造函数
  this.sno = sno
  this.score = score
}

Student.prototype = new Person()

Student.prototype.studying = function () {
  console.log('studying~')
}

// 创建学生对象
let stu1 = new Student('芒果', 12, '12345', 98)
console.log(stu1) // Person { name: '芒果', age: 12, sno: '12345', score: 98 }
console.log(stu1.name, stu1.age) // 芒果 12

当然其实借用构造函数的继承方式依然存在一点问题,如果你理解到这里,点到为止,那么组合实现继承只能说问题不大,但是它依然不是特别的完美,但是基本已经没有问题了,那么不完美的地方是什么哪?其实我们可以看到上述的父类被实例化了两次,所有的子类实例实际上会拥有两份父类属性,一份在原型对象上面,一份在类自身。

四.寄生组合继承


原型式继承的渊源,这种模式要从道格拉斯.克罗克福德(著名的前端大师,JSON 的创立者)在 2006 年写的一篇文章说起Prototypal Inheritance in JavaScript(在 JavaScript 中使用原型式继承),在这篇文章中它介绍了一种继承方法,而且这种继承方法不是通过构造函数来实现的,从上述的内存图我们可以看出来实现继承我们需要创建一个p对象,那么既然这样我们就来总结一下这个对象的特点:

  1. 必须创建出来一个对象
  2. 这个对象的隐式原型必须指向父类的显式原型
  3. 将这个对象赋值给子类的显式原型
function object(o) {
  function F() {}
  F.prototype = 0
  return new F()
}

function inheritPrototype(subType, superType) {
  subType.prototype = object(superType.prototype)
  subType.prototype.constructor = subType
}

inheritPrototype(Student, Person)

上述的代码是一种兼容性写法,如果担心代码可能出现兼容性问题的时候可以使用这种方式,如果不担心兼容性问题的时候其实可以直接这样写

function inherit(SubType, SuperType) {
  SubType.prototype = Object.create(SuperType.prototype)
}

一般情况下我们会将这个代码进行封装起来在使用的时候直接进行调用,那么我们通过 ES5 实现继承的最终写法就是如下的代码。

function object(o) {
  function F() {}
  F.prototype = 0
  return new F()
}

function inheritPrototype(subType, superType) {
  subType.prototype = object(superType.prototype)
  subType.prototype.constructor = subType
}
import inherit from './inherit.js'

function Person(name, age, height) {
  this.name = name
  this.age = age
  this.height = height
}
Person.prototype.running = function () {
  console.log('running~')
}

Person.prototype.eating = function () {
  console.log('eating~')
}

function Student(name, age, height, sno, score) {
  Person.call(this, name, age, height)
  this.sno = sno
  this.score = score
}

inherit(Student, Person)

Student.prototype.studying = function () {
  console.log('studying~')
}

// 创建实例对象
let stu1 = new Student('芒果', 12, 123, 4444, 56)

五.对象方法的补充


hasOwnProperty :对象是否有某一个属于自己的属性(不是在原型上的属性)

let obj = {
  name: '芒果',
  age: 12,
}
obj.__proto__ = {
  message: 'msg~',
}
console.log(obj.hasOwnProperty('name')) // true
console.log(obj.hasOwnProperty('message')) // false

in/for in 操作符:判断某个属性是否在某个对象或者对象的原型上

let obj = {
  name: '芒果',
  age: 12,
}
obj.__proto__ = {
  message: 'msg~',
}
console.log('name' in obj) // true
console.log('message' in obj) // true

instanceof :用于检测构造函数(Person、Student 类)的 pototype,是否出现在某个实例对象的原型链上,本质上这个方法的比较是通过判断p.__proto__.constructor指向的是否是后边的这个构造函数,如果是就返回true否则就返回false

function Person(name, age) {
  this.name = name
  this.age = age
}

let p = new Person('招财', 23)

console.log(p instanceof Person) // true

isPrototypeOf :用于检测某个对象,是否出现在某个实例对象的原型链上,主要用来判断前边的是否在后边的原型链上,功能比较强大,不仅仅可以用来判断父类,还可以判断对象的上下级关系,但是平时开发使用的比较少。

function Person(name, age) {
  this.name = name
  this.age = age
}
let p = new Person('招财', 23)
console.log(Person.prototype.isPrototypeOf(p)) // true

然后我们再创建一个对象,来使用下这个方法,你会发现这个方法用来判断对象的上下级关系也是可以的。

let p = {
  name: 'aaa',
  age: 12,
}

function object(o) {
  function F() {}
  F.prototype = 0
  return new F()
}

let p2 = object(p)
console.log(p.__proto__.isPrototypeOf(p2)) // true

六.总结与扩展


这篇文章我们到这里就结束了,这篇文章我们介绍了晦涩难懂的 ES5 继承的内容,我们首先通过原型链进行继承,并且展示了通过原型链继承的缺陷有哪些,之后我们通过了寄生组合继承来解决了这些问题,其实在 ES6 中我们直接使用extends关键字来实现继承通过babel转换之后的核心代码也是类似的,当然我们目前在平时的开发中一般不会直接写这些代码,但是 ES5 的继承是需要我们去理解的。

扩展知识: 其实道格拉斯.克罗克福德最初使用这个function object这个方法主要是来创建上下级对象的,我们在上述的内容中也进行了使用,但是其实这个方法也不完美,因为如果上级的对象中有引用数据类型,比如方法,就会造成方法共享的问题。

React 的 “大脑” 与 “双手”:Render 阶段(大脑)算好怎么改,Commit 阶段(双手)动手改 DOM

2025年5月23日 15:04

React 的更新流程分为 Render 阶段(协调阶段)和 Commit 阶段(提交阶段),两者共同完成组件更新。以下是每个阶段的核心职责和实现细节:


一、Render 阶段(协调阶段)

目标:生成更新计划(副作用链表),不直接操作 DOM
特性:可中断、异步执行(并发模式核心)

核心流程

  1. 触发更新

    • setStateuseState、父组件渲染等触发更新
    • 创建 Update 对象,加入 Fiber 的更新队列
  2. 调度优先级

    • 根据更新来源(用户交互、网络响应等)分配优先级(如 ImmediateUserBlocking
    • 通过 scheduler 调度任务
  3. 协调算法(Reconciliation)

    graph TD
        A[根节点开始] --> B{是否还有子节点?}
        B -->|是| C[处理当前Fiber节点]
        C --> D[比较新旧props/state]
        D --> E[标记副作用Tag]
        E --> B
        B -->|否| F[完成协调]
    
    • 深度优先遍历:从根节点开始遍历 Fiber 树
    • Diff 算法:对比新旧 ReactElement,生成子 Fiber 树
    • 副作用标记:对需要 DOM 操作的节点打标记(如 PlacementUpdateDeletion
  4. 收集副作用

    • 构建副作用链表(effectList),链表节点包含所有需要 DOM 操作的 Fiber

关键代码

function performUnitOfWork(fiber) {
  // 1. 执行组件渲染(生成子Fiber)
  const children = reconcileChildren(fiber, fiber.props.children);
  
  // 2. Diff算法比较新旧子节点
  reconcileChildFibers(fiber, children);
  
  // 3. 返回下一个工作单元(深度优先)
  if (fiber.child) return fiber.child;
  let nextFiber = fiber;
  while (nextFiber) {
    if (nextFiber.sibling) return nextFiber.sibling;
    nextFiber = nextFiber.return;
  }
}

二、Commit 阶段(提交阶段)

目标:将 Render 阶段的更新计划同步应用到 DOM
特性:同步执行、不可中断(防止页面状态不一致)

核心流程

  1. 预处理

    • 调用 getSnapshotBeforeUpdate 生命周期(获取 DOM 更新前的状态)
  2. DOM 操作

    graph TD
        A[遍历effectList] --> B{操作类型}
        B -->|Placement| C[插入DOM]
        B -->|Update| D[更新DOM属性]
        B -->|Deletion| E[删除DOM]
        C --> F
        D --> F
        E --> F[完成所有DOM操作]
    
    • 实际执行 DOM 增删改操作
    • 处理 ref 的绑定/解绑
  3. 生命周期与副作用

    • 同步执行:componentDidMountcomponentDidUpdate
    • 异步调度:useEffect 副作用(通过 scheduleCallback 调度)
  4. 状态清理

    • 重置 Fiber 树的副作用标记
    • 准备下一次更新的 current

关键代码

function commitRoot(root) {
  // 1. 处理DOM插入/更新/删除
  commitMutationEffects(root.effectList);
  
  // 2. 执行生命周期
  commitLayoutEffects(root.effectList);
  
  // 3. 调度useEffect
  schedulePendingEffects();
}

三、阶段对比

特性 Render 阶段 Commit 阶段
操作对象 Fiber 树(虚拟节点) 真实 DOM
可中断性 是(并发模式)
副作用 收集副作用(effectList) 执行副作用(DOM操作/生命周期)
优先级 支持优先级插队 无优先级,必须同步完成
生命周期 componentDidMount/Update
主要耗时 计算 Diff DOM 操作

四、示例流程

假设组件树更新:

// 更新前
<div>
  <span key="1">Old</span>
</div>

// 更新后
<div>
  <span key="1">New</span>
  <p key="2">New</p>
</div>
  1. Render 阶段

    • 比较新旧节点,标记 <span>Update<p>Placement
    • 生成 effectList[spanFiber, pFiber]
  2. Commit 阶段

    • 更新 <span> 的文本内容
    • 插入新 <p> 节点到 <div>

五、设计意义

  1. 并发模式基础:Render 阶段可中断,确保高优先级任务(如用户输入)及时响应
  2. 性能优化:分离计算和 DOM 操作,避免计算阻塞渲染
  3. 一致性保证:Commit 阶段一次性提交,防止中间状态暴露

通过这种两阶段设计,React 平衡了性能与一致性,为复杂应用的流畅体验提供底层保障。

从文字到创意:我的「会说话的便签」新玩法:MoneyPrinterTurbo+cpolar的另类用法

作者:不认真就是潇洒活 cpolar商业版用户

为什么我需要告别枯燥的文字?

以前发朋友圈或给朋友留言时,总觉得文字太干巴巴,配图又麻烦——比如:

  • 生日祝福:只能写“生日快乐”,朋友都说没新意;
  • 工作汇报:用PPT截图+文字,同事总问“能不能直接看视频?”;
  • 创意灵感:灵光一现的脑洞,打字记录总觉得少了点什么……

直到我发现了 MoneyPrinterTurbo + cpolar 的组合!从此我的留言、便签甚至日常分享都变成了 会动、有声音、有音乐的短视频,朋友都说:“哇!这比表情包还有趣!”

核心玩法:把文字变成「活」视频的三步魔法

🎬 第一步:写一句想说的话 → 自动生成创意视频

举个栗子:

  • 输入文案今天终于攻克了这个项目!就像拆解了一座由代码搭建的金字塔!

  • MoneyPrinterTurbo立刻生成:

    • 自动匹配古埃及风格素材(来自Pexels库);
    • 添加背景音乐(比如史诗感BGM);
    • 用微软TTS合成声音念出文案,字幕同步弹出。

🔗 第二步:内网穿透让视频“秒上云” → cpolar的妙用

  • 问题:生成的视频在本地电脑,怎么快速分享到手机?
  • 解决:通过cpolar将本地文件夹映射为可访问的公网链接,直接复制链接发微信/QQ!
  • 效果:朋友点开链接就能看你的创意视频,比传大文件快10倍!

📲 第三步:一键分享 → 收获尖叫式反馈!

  • 朋友圈案例: 输入今晚做了番茄炒蛋,但锅突然爆炸了…… → 自动生成搞笑片段(配菜飞溅的素材+欢快BGM),朋友疯狂@我:“你家厨房在拍《生活大爆炸》吗?!”

真实场景应用 & 效果

1. 生日惊喜

  • 输入:祝小明30岁生日快乐!愿你的代码永远不报错,咖啡永远不凉!
  • 输出:
    • 素材库自动匹配程序员工作照;
    • 字幕用荧光绿显示(调整后更炫酷);
    • TTS声音切换成“萌妹音”,朋友笑到打滚:“这比我收到的实体礼物还暖!”

2. 工作汇报

  • 输入:这个月用户增长30%,主要归功于新功能优化和社群运营。
  • 输出:
    • 自动生成数据可视化图表动效;
    • 背景音乐切换成商务风格的轻音乐;
    • 直接发到QQ工作群,领导秒回:“下次汇报都这么做!”

3. 创意灵感存档

  • 输入:突然想到一个APP创意:用AI生成你的‘虚拟宠物’,每天陪聊还能写诗!
  • 输出:
    • 配合萌宠素材和科技感BGM;
    • 将视频存在云盘,方便随时调取或分享投资人。

为什么选择这个组合?

省时间:从文字到视频全程自动化,5分钟搞定传统1小时的工作; ✅ 低成本:Pexels素材和免费版cpolar足够日常使用; ✅ 高互动:朋友点开率提升200%,毕竟谁不喜欢看有趣的短视频呢!

你的「会说话的便签」准备好了吗?

1️⃣ 点击下方链接获取 MoneyPrinterTurbo安装教程 → [🔗 安装指南](此处替换为实际链接) 2️⃣ 跟着我的步骤玩起来,下次发朋友圈让朋友惊呼:“这人是不是买了个AI公司?!”

P.S. 试试切换不同的TTS声音或文案模型,比如选“幽默模式”吐槽老板,效果更上头哦! 😄

快来让你的文字开口说话吧! 🚀

MoneyPrinterTurbo+cpolar安装的方法已经整理完毕,快去安装吧!

1、部署安装

首先打开项目release页面发布 ·harry0703/MoneyPrinterTurbo,找到一键启动包提取

image-20250520172614362

保存到自己的网盘里后,解压缩到本地

image-20250520172654931

接着我们点击这个start运行它

image-20250520172712685

软件启动后就可以看到这个web UI操作界面了

image-20250520172720230

2、简单使用MoneyPrinterTurbo

首先我们需要配置一下参数

展开基础设置,大模型设置这里作者建议使用Deepseek和moonshot

image-20250520172758596

我这里使用moonshot,点击这里跳转到官网申请API key

image-20250520172854019

登录成功后能看到账号赠送很多额度,够用

image-20250520172913864

点击左侧API key管理,我们申请一个,保留好

image-20250520172931872

返回软件界面粘贴上去,下面这些都保持默认就可以

image-20250520172954466

我们再来设置视频素材源网站的API key

image-20250520173032753

我这里点击获取第二个,因为我觉得里面的素材更多

点击sign up,登录成功后发现看不到API key链接了,别担心

image-20250520173110298

关闭,返回软件界面重新点击获取,能看到API key了复制它

image-20250520173145773

然后粘贴过来就可以了

image-20250520173207818

接着在这里设置你想生成的视频主题

image-20250520173226600

比如说我这里生成一个关于人生哲理的视频,我们就填写:人生哲理。下面默认自动,接着点击这里。

image-20250520173309259

就自动生成了这个视频内容的文案,和下面的关键词,文案这里你也可以自己多编辑一些

image-20250520173328610

视频来源选择pixabay,视频拼接模式和专场模式保持默认,视频比例按照你喜好来选择,我这里选择16:4。这里最大片段时长是一个素材的镜头时长,不是视频总时长。

image-20250520173417700

这里是朗读声音,就是视频最后生成的配音。

image-20250520173440367

然后下面的配置和字幕配置按照你喜好来,我这里选择默认。

image-20250520173504213

设置完成后点击生成视频就可以了

image-20250520173526522

下面可以看到输出信息,视频合成完会显示在这

image-20250520173650158

也会自动打开输出目录窗口,这个final就是最终视频,往前翻还有视频所用的所有素材

image-20250520173615466

我看了一下合成结果视频确实是不错的,而且省去很多找素材的时间

3、介绍以及安装cpolar

访问cpolar官网: www.cpolar.com 点击免费使用注册一个账号,并下载最新版本的Cpolar。

登录成功后,点击下载Cpolar到本地并安装(一路默认安装即可)本教程选择下载Windows版本。

Cpolar安装成功后,在浏览器上访问http://localhost:9200,使用cpolar账号登录,登录后即可看到Cpolar web 配置界面,结下来在web 管理界面配置即可。

4、使用cpolar远程使用MoneyPrinterTurbo

登录cpolar web UI管理界面后,点击左侧仪表盘的隧道管理——创建隧道:

  • 隧道名称:可自定义,本例使用了:money,注意不要与已有的隧道名称重复
  • 协议:http
  • 本地地址:8501
  • 域名类型:随机域名
  • 地区:选择China Top

点击创建:image-20250520174556072

创建成功后,打开左侧在线隧道列表,可以看到刚刚通过创建隧道生成了两个公网地址,接下来就可以在其他电脑或者移动端设备(异地)上,使用任意一个地址在浏览器中访问即可。

image-20250520174940551

现在就已经成功实现使用cpolar生成的公网地址异地远程访问本地部署的MoneyPrinterTurbo啦!

小结

为了方便演示,我们在上边的操作过程中使用cpolar生成的HTTP公网地址隧道,其公网地址是随机生成的。这种随机地址的优势在于建立速度快,可以立即使用。然而,它的缺点是网址是随机生成,这个地址在24小时内会发生随机变化,更适合于临时使用。

如果有长期使用MoneyPrinterTurbo,或者异地访问与使用其他本地部署的服务的需求,但又不想每天重新配置公网地址,还想让公网地址好看又好记并体验更多功能与更快的带宽,那我推荐大家选择使用固定的二级子域名方式来配置公网地址。

5、设置二级子域名

使用cpolar为其配置二级子域名,该地址为固定地址,不会随机变化。

注意需要将cpolar套餐升级至基础套餐或以上,且每个套餐对应的带宽不一样。【cpolar.cn已备案】

点击左侧的预留,选择保留二级子域名,地区选择china top,然后设置一个二级子域名名称,我这里演示使用的是money,大家可以自定义。填写备注信息,点击保留。

image-20250520174734519

保留成功后复制保留的二级子域名地址:

image-20250520174750057

登录cpolar web UI管理界面,点击左侧仪表盘的隧道管理——隧道列表,找到所要配置的隧道money,点击右侧的编辑

image-20250520174836720

修改隧道信息,将保留成功的二级子域名配置到隧道中

  • 域名类型:选择二级子域名
  • Sub Domain:填写保留成功的二级子域名
  • 地区: China Top

点击更新

image-20250520175021069

更新完成后,打开在线隧道列表,此时可以看到随机的公网地址已经发生变化,地址名称也变成了保留和固定的二级子域名名称。

image-20250520175050607

最后,我们使用固定的公网地址在任意设备的浏览器中访问,可以看到成功访问本地部署的MoneyPrinterTurbo页面,这样一个永久不会变化的二级子域名公网网址即设置好了。

image-20250520175136083

6、结尾

有了MoneyPrinterTurbocpolar的强强联合,你不仅能在短时间内轻松生成高质量的短视频,还能在任何地方远程访问和使用这些工具。快来试试吧,让视频制作变得简单又高效!

教你利用rust给electron提升性能

作者 smallzip
2025年5月23日 14:43

背景

在Electron项目中,对文件进行MD5计算是一个常见的需求,特别是在处理大型文件时,计算速度的性能问题可能会对用户体验产生影响。为了提升性能,通常会尝试多种方法,包括纯Node.js实现、调用系统命令以及尝试其他语言如Rust的实现。

通过对三种方式的尝试发现rust生成md5的速度是最快的。

于是乎,便萌生了node中调用rust的想法。

实现

可以利用none和napi-rs来构建rust,用来给node访问。

对比两者,最后选择了napi-rs,文档更健全。

文档:napi.rs/cn

项目初始化

按照官网的流程,安装napi-rs,并初始化项目。

yarn global add @napi-rs/cli
# 或者
npm install -g @napi-rs/cli
# 或者
pnpm add -g @napi-rs/cli

1.新建项目

napi new

根据项目需要选择要构建的平台。

我的项目只需要5个平台和架构

  • darwin-arm64
  • darwin-x64
  • win32-x64
  • win32-ia32
  • linux-x64

所以只需要选择下面7项

"aarch64-apple-darwin",
"x86_64-apple-darwin",
"x86_64-pc-windows-msvc",
"i686-pc-windows-msvc",
"aarch64-pc-windows-msvc",
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl"

后面按照提示选择好配置项,创建项目。

编写md5程序

找到根目录的Cargo.toml文件,需要添加一个md5依赖,在dependencies下面添加md-5 = "0.10.6"

回到根目录 src/lib.rs,开始写md5生成的函数,我们需要计算md5生成的耗时时长,代码如下:

use std::fs::File;
use std::io::{BufReader, Read};
use std::time::Instant;
use md5::{Md5, Digest};

fn main() -> std::io::Result<()> {
    let start = Instant::now();

    let file = File::open("/Users/用户/Desktop/rust/test1/test.dmg")?;
    // 增大缓冲区大小
    let mut reader = BufReader::with_capacity(65536, file);
    let mut hasher = Md5::new();
    let mut buffer = [0; 65536];

    while let Ok(n) = reader.read(&mut buffer) {
        if n == 0 {
            break;
        }
        hasher.update(&buffer[..n]);
    }

    let result = hasher.finalize();
    let duration = start.elapsed();

    println!("MD5: {:x}", result);
    println!("计算 MD5 耗时: {:?}", duration);

    Ok(())
}

需要用File打开本地的大文件,我这里已经保存了一个dmg安装包,路径为/Users/用户/Desktop/rust/test1/test.dmg

通过File的open函数访问本地文件,并创建缓存区读取流。

最后计算并打印得出md5的值,和消耗的时长。

访问md5程序

代码写完之后,构建重新可访问的包。

执行命令:

yarn build

现在文件夹结构会多出三个文件

cool.darwin-x64.node 是 Node.js addon 二进制文件, index.js 自动生成的 JavaScript 绑定文件,它帮你从 addon 二进制中 export 出所有的东西,并且保证对 esm 与 CommonJS 的兼容。index.d.ts 是生成的 TypeScript 定义文件。

执行rust程序,输入命令:

node index.js
import test from 'ava';
import { md5 } from '../index.js';
import { writeFile, unlink } from 'fs/promises';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';

// 获取当前模块的文件路径和目录路径
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

test.serial('md5 应该正确计算本地文件内容的 MD5 哈希值', async (t) => {
  const tempFilePath = join(__dirname, 'temp_test_file.txt');
  const fileContent = 'hello world';
  const expected = '5eb63bbbe01eeed093cb22bb8f5acdc3';

  try {
    // 创建临时文件
    await writeFile(tempFilePath, fileContent);

    // 读取文件内容并计算 MD5
    const result = md5(tempFilePath);
    t.is(result, expected);
  } catch (error) {
    t.fail(`测试过程中出现错误: ${error.message}`);
  } finally {
    // 删除临时文件
    try {
      await unlink(tempFilePath);
    } catch (error) {
      console.warn(`删除临时文件时出错: ${error.message}`);
    }
  }
});

执行:

yarn test

electron调用rust

要将构建的.node包给electron,我们优化一下代码,去除打印内容,并将md5生成的值输出:

#![deny(clippy::all)]

#[macro_use]
extern crate napi_derive;

use std::fs::File;
use std::io::{BufReader, Read};
use md5::{Md5, Digest};

#[napi]
pub fn md5(path: String) -> napi::Result<String> {
    let file = File::open(path).map_err(|e| napi::Error::from_reason(format!("文件打开失败: {}", e)))?;
    let mut reader = BufReader::with_capacity(65536, file);
    let mut hasher = Md5::new();
    let mut buffer = [0; 65536];

    while let Ok(n) = reader.read(&mut buffer) {
        if n == 0 {
            break;
        }
        hasher.update(&buffer[..n]);
    }

    let result = hasher.finalize();
    Ok(format!("{:x}", result))
}

重新构建:

yarn build

单平台使用

项目根目录下是根据当前平台+架构构建出来的。

我们把三个文件拷贝到electro项目中

运行electron程序,并执行md5,代码如下:(根据自己实际的electron项目进行代码导入测试)

import rustMD5 from './md5/index'

class TestWindow {

  createWindow() {
    this.window = new BrowserWindow({
      width: VIDEO_WINDOW_DEFAULT_WIDTH,
      height: VIDEO_WINDOW_DEFAULT_HEIGHT,
      frame: false,
      resizable: false,
      transparent: true,
      alwaysOnTop: false,
      titleBarStyle: "hiddenInset", // 隐藏title-bar-style样式,把操作按钮嵌入到窗口
      center: true,
      show: false,
      backgroundColor: systemThemeMap[store.get('systemTheme')],
      // parent: MainWindow.getInstance().mainWindow,
      webPreferences: {
        nodeIntegration: true,
        preload: VIDEO_WINDOW_PRELOAD_WEBPACK_ENTRY
      },
    });

    if (process.platform === "darwin") {
      // 设置左上角按钮位置
      this.window.setWindowButtonPosition({
        x: 10,
        y: 10,
      });
    }

    this.window.loadURL(VIDEO_WINDOW_WEBPACK_ENTRY);

    this.window.webContents.on("did-finish-load", () => {
      // 加载完成后显示窗口
      this.window?.show?.();
      this.window?.focus?.();

      const startTime = process.hrtime.bigint();
      
      // ----------------使用Rust生成文件的MD5值---------------------
      try {
        rustMD5.md5("/Users/用户/Desktop/rust/test1/test.dmg")
        const durationMilliseconds = Number(process.hrtime.bigint() - startTime) / 1e6;
        Logger.info(`【TestWindow】⌛️ 生成MD5成功!总耗时:${durationMilliseconds.toFixed(3)} 毫秒`);
      } catch (error) {
        Logger.error(`【TestWindow】 使用Rust生成文件的MD5值失败,错误信息:${error}`);
      }
    });
  }
}

执行:

pnpm dev

输出:

【TestWindow】⌛️ 生成MD5成功!总耗时:398.23 毫秒

兼容多平台

electron项目我们需要发布到多个平台,要做适配多平台。

有两个方案:

  1. 依赖模式
  2. 多平台架构包放在项目中

依赖模式

安装:

pnpm install @small-zip/md5

项目使用:

import { md5 as rustMd5 } from "@small-zip/md5"

/**
 * 使用 Rust 生成文件的 MD5 值
 * @param filePath 文件路径
 * @returns 返回文件的MD5值
 */
function generateMD5(filePath: string): string {
  const startTime = process.hrtime.bigint();
  
const res = rustMd5?.(filePath)

  const durationMilliseconds = Number(process.hrtime.bigint() - startTime) / 1e6;
Logger.info(`【getFileMd5】⌛️ ${fileName} 生成MD5成功!总耗时:${durationMilliseconds.toFixed(3)} 毫秒`);
}

@small-zip/md5安装的时候会检查当前平台+架构,并下载对应的node包

多平台架构包放在项目中

安装:

把多个平台架构的包下载到本地直接引用。构建electron程序全部将其打包进去。

创建index.ts,根据当前运行的环境动态引入。

const Logger = require("electron-log")

let nativeBinding = null

try {
  nativeBinding = require(`./native_modules/md5.${process.platform}-${process.arch}.node`)
} catch (e) {
  Logger.error(`加载本地模块失败: ${e.message}`)
}

const { sum, md5 } = nativeBinding || {}

export { md5, sum }

给md5编写一个声明文件index.d.ts

/* tslint:disable */
/* eslint-disable */

/* auto-generated by NAPI-RS */

export declare function md5(path: string): string

项目使用:

import { md5 as rustMd5 } from "@/modules/rustMd5/index"

/**
 * 使用 Rust 生成文件的 MD5 值
 * @param filePath 文件路径
 * @returns 返回文件的MD5值
 */
function generateMD5(filePath: string): string {
  const startTime = process.hrtime.bigint();
  
const res = rustMd5?.(filePath)

  const durationMilliseconds = Number(process.hrtime.bigint() - startTime) / 1e6;
Logger.info(`【getFileMd5】⌛️ ${fileName} 生成MD5成功!总耗时:${durationMilliseconds.toFixed(3)} 毫秒`);
}

基于我们项目架构需要,进行技术调研和测试之后,选择了方案2。

发布rust到npm

前面讲解了如何使用@small-zip/md5

现在我来发布已经创建好的项目。

注册npm账号

访问:www.npmjs.com/

选择sign up注册一个账号。

注册完成之后,点击个人头像,选择account

新建组织

找到左边的组织,点击+号,创建一个scope名称。

创建一个自己喜欢的组织名称,这里我使用组织名称是small-zip

确定好组织名称,选项Unlimited public packages共用包,这个免费的。

如果要创建私有包,则需要付费$7(土豪请随意)。

创建完毕之后回到account账户中心页面,可以看到已经多出来一个组织。

新建github仓库

打开github:github.com/

创建一个项目仓库,后面CI自动化需要用到。

修改项目名称为当前组织

接下来需要按照napi-rs官网的发布流程,将项目发布到npm中:napi.rs/cn/docs/int…

需要注意的点:

  1. repository必须要填写为前面创建的github项目仓库地址,否则CI流程会报错。
  2. 必须要创建NEW_TOKEN,否则CI流程中publish发布会没有权限。

创建NEW_TOKEN

  1. 打开npm
  2. 进入access tokens页面
  3. 选择Classic Token
  4. 输入密码,名称设置为NEW_TOKEN,全大写,拷贝生成的密钥
  5. 回到access token会看到密钥已经生成成功,并存在有效期
  6. 回到github创建的仓库
  7. 进入settings设置
  8. 找到Scerets and variables -> Actions
  9. 选择New repoository secret新增一个密钥,名称设置为NEW_TOKEN

跑通github actions

前面步骤全部做完,在项目根目录初始化git,并将代码提交到github仓库中

git init
git remote add origin git@github.com/yourname/cool.git
git add .
git commit -m "Init"
git push

在github仓库的Actions中查看CI的进度。

发布到npm

前面是一个测试 CI,让我们来发布它吧:

# 更新版本号
npm version patch
# 推送
git push --follow-tags

等待CI流程完成后,我们打开npm -> packages,就可以看到依赖包已经全部上传完成。

项目调用

CI流程执行完成,会根据我们设定的平台架构生成相应的包,可以在Release中查看

下载依赖

多平台测试

性能对比

  • nodejs
  • nodejs调用系统命令
  • nodejs调用rust

node转换文件为md5耗时情况

文件大小:253.6M

平均耗时:599.974 毫秒

测试数据:

Sky.dmg 生成MD5成功!总耗时:498.041 毫秒

Sky.dmg 生成MD5成功!总耗时:519.740 毫秒

Sky.dmg 生成MD5成功!总耗时:635.938 毫秒

Sky.dmg 生成MD5成功!总耗时:507.406 毫秒

Sky.dmg 生成MD5成功!总耗时:732.356 毫秒

Sky.dmg 生成MD5成功!总耗时:675.367 毫秒

Sky.dmg 生成MD5成功!总耗时:630.782 毫秒

代码实现:

/**
 * 获取文件的MD5值
 *
 * @param filePath 文件路径
 * @returns 返回文件的MD5值
 */
export async function getFileMd5(filePath: string): Promise<string> {
    const startTime = process.hrtime.bigint();
    const fileName = path.basename(filePath)
    const blockSize = await getHighWaterMarkBlockSize(filePath) // 获取合适背压值

    // 使用Node.js生成文件的MD5值
    return new Promise((resolve, reject) => {
        try {
            // 创建一个MD5哈希对象
            const hash = crypto.createHash('md5')
            // 根据文件路径创建可读流
            const stream = fs.createReadStream(filePath, { highWaterMark: blockSize })

            // 当可读流有数据可读时,触发该事件
            stream.on('data', (chunk: any) => {
                // 将数据块更新到哈希对象中
                hash.update(chunk, 'utf8');
            });
            // 当可读流读取完所有数据后,触发该事件
            stream.on('end', () => {
                // 获取哈希值的十六进制表示,即MD5值
                const md5 = hash.digest('hex');
                // 将MD5值通过Promise的resolve方法返回
                const durationMilliseconds = Number(process.hrtime.bigint() - startTime) / 1e6;
                Logger.info(`【getFileMd5】⌛️ ${fileName} 生成MD5成功!总耗时:${durationMilliseconds.toFixed(3)} 毫秒`);
                resolve(md5)
            });
            // 当可读流发生错误时,触发该事件
            stream.on('error', (err: any) => {
                Logger.error(`【getFileMd5】 获取文件 ${filePath} 的MD5值失败,错误信息:${err}`);
                reject(err)
                // 关闭可读流
                stream.destroy()
                // 关闭可读流
                stream.destroy()
            });
    } catch (error) {
            reject(error);
            Logger.error(
                `【getFileMd5】 获取文件 ${filePath} 的MD5值失败,错误信息:${error}`
            );
        }
    })
}

node调用系统命令生成md5耗时情况

文件大小:253.6M

平均耗时:522.4 毫秒

测试数据:

Sky.dmg 生成MD5成功!总耗时:484.683 毫秒

Sky.dmg 生成MD5成功!总耗时:507.766 毫秒

Sky.dmg 生成MD5成功!总耗时:628.693 毫秒

Sky.dmg 生成MD5成功!总耗时:523.963 毫秒

Sky.dmg 生成MD5成功!总耗时:483.997 毫秒

Sky.dmg 生成MD5成功!总耗时:503.721 毫秒

代码实现:

/**
 * 使用系统命令生成文件的 MD5 值
 * @param filePath 文件路径
 * @returns 返回文件的 MD5 值
 */
export const getFileMd5BySystemCommand = (filePath: string): Promise<string> => {
  return new Promise((resolve, reject) => {
    let command: string;
    const currentPlatform = platform();

    switch (currentPlatform) {
      case 'win32':
        // Windows 系统使用 PowerShell 的 Get-FileHash 命令
        command = `powershell -Command "Get-FileHash -Path '${filePath}' -Algorithm MD5 | Select-Object -ExpandProperty Hash"`;
        break;
      case 'darwin':
        // Mac 系统使用 md5 命令
        command = `md5 '${filePath}'`;
        break;
      case 'linux':
        // Linux 系统使用 md5sum 命令
        command = `md5sum '${filePath}'`;
        break;
      default:
        reject(new Error(`不支持的操作系统: ${currentPlatform}`));
        return;
    }

    exec(command, (error, stdout, stderr) => {
      if (error) {
        Logger.error(`【getFileMd5BySystemCommand】 执行命令失败: ${error.message}`);
        reject(error);
        return;
      }
      if (stderr) {
        Logger.error(`【getFileMd5BySystemCommand】 命令执行错误: ${stderr}`);
        reject(new Error(stderr));
        return;
      }

      // 解析输出结果
      let md5 = stdout.trim();
      if (currentPlatform === 'linux') {
        // Linux 的 md5sum 输出格式为 "md5 文件名",需要提取 MD5 值
        md5 = md5.split(' ')[0];
      } else if (currentPlatform === 'darwin') {
        // Mac 的 md5 输出格式为 "MD5 (文件名) = md5",需要提取 MD5 值
        md5 = md5.split(' = ')[1];
      }

      resolve(md5);
    });
  });
}

rust转换文件为md5耗时情况

文件大小:253.6M

平均耗时:426.79 毫秒

测试数据:

Sky.dmg 生成MD5成功!总耗时:402.289 毫秒

Sky.dmg 生成MD5成功!总耗时:439.736 毫秒

Sky.dmg 生成MD5成功!总耗时:397.680 毫秒

Sky.dmg 生成MD5成功!总耗时:402.192 毫秒

Sky.dmg 生成MD5成功!总耗时:431.477 毫秒

Sky.dmg 生成MD5成功!总耗时:410.053 毫秒

Sky.dmg 生成MD5成功!总耗时:445.648 毫秒

Sky.dmg 生成MD5成功!总耗时:451.843 毫秒

Sky.dmg 生成MD5成功!总耗时:461.168 毫秒

代码实现:

use std::fs::File;
use std::io::{BufReader, Read};
use std::time::Instant;
use md5::{Md5, Digest};

fn main() -> std::io::Result<()> {
    let start = Instant::now();

    let file = File::open("/Users/用户/Desktop/rust/test1/test.dmg")?;
    // 增大缓冲区大小
    let mut reader = BufReader::with_capacity(65536, file);
    let mut hasher = Md5::new();
    let mut buffer = [0; 65536];

    while let Ok(n) = reader.read(&mut buffer) {
        if n == 0 {
            break;
        }
        hasher.update(&buffer[..n]);
    }

    let result = hasher.finalize();
    let duration = start.elapsed();

    println!("MD5: {:x}", result);
    println!("计算 MD5 耗时: {:?}", duration);

    Ok(())
}

小结

1.耗时

node:

  • Node.js 使用 V8 引擎,运行在 JavaScript 虚拟机上。
  • 本地模块(如 crypto 和 fs)是 C++ 实现,但调度仍有事件循环和 JS 层的额外开销。

rust:

  • Rust 编译为本地机器码,无虚拟机开销。
  • Zero-cost abstraction,内存分配和复制控制非常精细。
  • 使用 BufReader 和大缓冲区可以最大限度利用 I/O 带宽。
  • 300MB MD5 通常只需 几十到几百毫秒(视 CPU 和磁盘速度)。

2.内存占用

  • Rust
    • 流式处理:固定缓冲区(如 4KB-1MB),内存占用稳定在 1-10 MB
    • 无 GC:无垃圾回收开销,内存控制精准。
  • Node.js
    • 流式处理:默认 highWaterMark 为 16KB,内存占用约 10-50 MB
    • GC 开销:V8 垃圾回收可能短暂增加内存占用。

结论:Rust 内存占用更低且更稳定,Node.js 因 V8 引擎设计略高。

CPU使用率

  • Rust
    • 单核 100% :计算密集型任务充分利用单核性能。
    • 无运行时开销:无事件循环或 JIT 编译干扰。
  • Node.js
    • 单核 90%-100% :C++ 层计算(OpenSSL)高效,但事件循环调度有轻微损耗。
    • 上下文切换:流式读取可能触发微任务队列处理。

结论:两者均能充分利用单核,Rust 的 CPU 时间更短(因总耗时更少)。

总结

以上就是napi-rs编写rust给electron使用的完整流程。

记录一次修改 element-plus 的bug全过程

作者 普通码农
2025年5月23日 14:36

起因,过程,结果

起因,自己遇到的问题

有一天公司项目遇到 el-tree 的过滤数据不正确现象,之前过滤数据是没问题的,最近产生的问题,我先查看项目代码,发现代码并没有改动,

再深入排查之后,发现是 element-plus 的版本问题,原版本 2.9.0,升级到 2.9.7 后产生的 bug,对照了一下源码, 发现原来是 el-tree 的源码发生了修改,然后在 element 的 issues 中开始查找有没有人提出问题,找到了一个issues,

然后在下面评论了一下,没过几天,发现有人解决了这个问题,查看解决代码以后发现只是加了一句很简单的 await,想到原来有些问题并不是很难,只不过我没有想过去处理,早知道我来解决好了哈哈

过程,如何找到bug出现的原因并且修复bug

1.找到bug

于是我就开始在 issues 中查看别人提出的 bug,找到一个关于table 的 bug,

描述是这样的,el-table-column type 为"selection"加上 fixed 以后,只能放在表格中的第一项才能显示,没有 fixed 的时候,任何位置都可以展示,

我根据这些关键信息,去看 element-plus 的源码,找了许久才终于找到问题的出现点,就是在 updateColumns 方法中,拿 columns.value[0]来判断是否为 selection,

如果是,则判断他的 fixed 属性是否为 left 或者 true,以及是否有固定列并且 selection 列的 fixed 属性不为 right,bug 的出现就是这里导致了.

下面为原始代码:

 // 更新列
  const updateColumns = () => {
    _columns.value.forEach((column) => {
      updateChildFixed(column)
    })
    fixedColumns.value = _columns.value.filter(
      (column) =>
        column.type !== 'selection' && [true, 'left'].includes(column.fixed)
    )

    let selectColFixLeft
    if (_columns.value?.[0]?.type === 'selection') {
      const selectColumn = _columns.value[0]
      selectColFixLeft =
        [true, 'left'].includes(selectColumn.fixed) ||
        (fixedColumns.value.length && selectColumn.fixed !== 'right')
      if (selectColFixLeft) {
        fixedColumns.value.unshift(selectColumn)
      }
    }

    rightFixedColumns.value = _columns.value.filter(
      (column) => column.fixed === 'right'
    )

    const notFixedColumns = _columns.value.filter(
      (column) =>
        (selectColFixLeft ? column.type !== 'selection' : true) && !column.fixed
    )

    originColumns.value = []
      .concat(fixedColumns.value)
      .concat(notFixedColumns)
      .concat(rightFixedColumns.value)
    const leafColumns = doFlattenColumns(notFixedColumns)
    const fixedLeafColumns = doFlattenColumns(fixedColumns.value)
    const rightFixedLeafColumns = doFlattenColumns(rightFixedColumns.value)

    leafColumnsLength.value = leafColumns.length
    fixedLeafColumnsLength.value = fixedLeafColumns.length
    rightFixedLeafColumnsLength.value = rightFixedLeafColumns.length

    columns.value = []
      .concat(fixedLeafColumns)
      .concat(leafColumns)
      .concat(rightFixedLeafColumns)
    isComplex.value =
      fixedColumns.value.length > 0 || rightFixedColumns.value.length > 0
  }

2.第一版修改

理解完代码以后,就开始着手修复问题,我想的是先把select列给提出来,然后判断有没有select列,再判断是放在左边还是右边.

第一版修改代码如下:

image.png

3.提交代码的问题

因为是第一次在github上修改开源代码,我仔细的看了一下提交代码的规范流程,但是在提交代码的时候报没有权限,

我的做法是这样的,在element-plus上clone一份到本地,创建一个新分支"fix#xxx",然后提交,

一直提交不上去以后我就开始在网上找办法,发现网上说的也是一样的做法,

还好之前混迹掘金也加入了一个"varletjs"开源项目微信社群,我就在群里请教了一下大佬们.

image.png

我就是缺少了fork这一步,于是我先fork,然后在开新分支,再提交代码,提交pr就静等审查了

4.第二版修改

github中有大佬指出了我第一版代码的问题:

我把selection取出来以后,要么固定左边要么固定右边,selection列如果不固定的时候,就会把他给追加在不固定列的最后一列.

image.png

我意识到问题以后继续修改,修改后的代码:

先找到非selection的其它左侧固定列,在找到固定列的selection,再判断selection是否该追加到左侧固定列中,当时写的时候没发现写的这么粗糙,

现在看起来第二版写的很差.

image.png

5.关于test案例

image.png 第二版提交以后,疑似element-plus审查人员(btea大佬)跟我说让我提供一个test案例,然后github的机器人再下面评论了一个playground地址,

我当时理解的是在element-plus的playground写一个测试代码来验证,写完以后就提交了.

6.与审查人员的交流

btea大佬提出了个建议,selection只保留一列就可以了 image.png

我提出了我的异议,我觉得此次修改bug的目的是为了解决selection列不在第一个就无法显示的问题,而不是优化selection应该显示几列,

而且element-plus之前是可以显示多列的,其它UI库例如ant-design-vue也是可以显示多列的,这应该是由用户来选择显示几列,而不是开发者来限制.

image.png

7.修改测试用例

btea大佬说到因为我的修改导致测试用例不通过,这个时候我还不懂什么是测试用例,因为之前没有接触过.

(我才意识到大佬上面说让我添加一个test用例,我理解成playground代码演示了......) image.png

我按照btea大佬说的本地执行pnpm test以后,发现报错了,看了一下报错的地方 image.png

然后找到该文件,找到了报错的地方 image.png

上面的测试用例我看不明白,然后我就在掘金上搜索"vue的test用例",

看到了乘风gg大佬写的完全掌握vue全家桶单元测试,看了1-4篇文章,

又回过头来看代码,理解了这段代码以后,我发现这个问题是因为此test案例依赖于之前那版代码的逻辑实现,现我已修改了代码,所以这个test案例也应该修改

  初版代码
 let selectColFixLeft
    if (_columns.value?.[0]?.type === 'selection') {
      const selectColumn = _columns.value[0]
      selectColFixLeft =
        [true, 'left'].includes(selectColumn.fixed) ||
        (fixedColumns.value.length && selectColumn.fixed !== 'right')
      if (selectColFixLeft) {
        fixedColumns.value.unshift(selectColumn)
      }
    }

于是我直接把test案例中的这段代码给删除,发现果然不报错了,我就把修改后的test案例代码给提交上去了 image.png

8.第三版修改:

btea大佬又提出了他的想法,说测试案例没错,我应该兼容测试案例. image.png

此时我真的不知道该如何修改了,我觉得当前测试案例是依赖于之前的逻辑代码,之前的逻辑代码是错误的,我已经修改了,所以我也应该修改测试案例,逻辑没毛病啊,于是我从github中找到了btea大佬的qq,请求添加好友,大佬通过以后我发出了我的疑问,

大佬是这样回答我的:"因为原来的逻辑是默认selection列在第一个,所以导致selection列放在其他位置时没有渲染,我觉得直接把selection列筛选出来,然后走原来的逻辑就可以了".

大佬在github中还给我指出了应该如何修改,我把test案例给还原,然后按照他指出的方案修改,发现又有新的问题,我告诉他以后,他觉得这是合理的,虽然我觉得这个隐藏逻辑不太合理,我不理解为什么要在错误的基础上继续修改,这样怎么修改都还是错误,但是因为这一个bug已经耽误了我很多时间,我有点不想再这个bug上一直纠结了,于是我按照他的修改以后就提交了.

image.png

9d8adf4ad19df8ef3c8d5237858a388c.png

9.第四版修改:

我以为此次bug应该在上面那版提交就结束了,直到大佬又找到了我,跟我聊了一下,跟他交流以后我决定还是按照我想的方式去修改, image.png

改完以后呢,问题又回到了test案例报错,这个时候我跟大佬吐槽: image.pngimage.pngimage.pngimage.png

这个时候我才真正理解了btea大佬的意思.

"这段代码确实有问题,但是这段有问题的代码已经有很多人在用了,用户使用的时候并不觉得是一个问题,可能当成了隐式逻辑使用,如果这一版把这个有问题的代码给修复,可能会导致原来正常使用的用户出现问题",

这时我才意识到element-plus并不是一个小项目,而是很多前端同学在用的UI组件库.

于是我专心的把代码又从头到尾细看了一遍,然后再修改了一遍,最终版代码如下:

我的逻辑是:

先找到左侧固定列(不包含selection列),再找到右侧固定列(不包含selection列),再找到需要固定的selection列(固定的selection列兼容之前如果有固定列并且selection列为第一列,把selection也放在固定列的逻辑),然后再判断固定列需要追加到左侧还是右侧,然后在判断不固定列是否需要显示selection列.

image.png

10.第五版修改:

btea大佬审查了代码以后,进行了修改,修改后的代码如下:

image.png

我看了一遍,理解到我修改的地方还是有不足,我不应该在修改bug的情况下,去动到原本的代码逻辑.

1.我把selection固定列提取出来,放到最左侧或者最右侧,我理解起来没问题,但是放到最右侧又与原先的代码逻辑不同了.

2.我修改了非固定列是否显示selection列的逻辑,根据是否有selection固定列来判断,原本是根据是否有左侧固定列来判断的.

结果,bug成功修复

最后bug成功修复,我的代码也已经合并到了element-plus仓库当中,

更值得高兴的是我的github主页还多了一个贡献者徽章,

经过这次的 element-plus bug修复,也让我从中学到了很多东西,代码之路还长,希望我能一直学习,一直进步!

image.png

KMP算法实现-找出字符串中第一个匹配项的下标

作者 一涯
2025年5月23日 14:25

看leetCode没看懂,然后google+chatGPT基本把我讲明白

1. 思路

  1. 求取模板字符串的LPS(Longest Prefix Suffix)

// 构建部分匹配表
function buildLPS(pattern) {
  const lps = new Array(pattern.length).fill(0);
  let length = 0, i = 1;// length 为已知的最长“签证 == 后缀”长度

  while(i < pattern.length) {
    if (pattern[i] === pattern[length]) {
      length++;
      lps[i] = length;
      i++;
    } else {  
      if (length != 0) {
        // !!!! 这是关键关键!!!!
        length = lps[length - 1];//反复退回前面最长相等前后缀的方式
      } else {  
        lps[i] = 0;
        i++;
      }
        
    }
  }
}

🔍 以 "ABCDABD" 为例,打印输出:

console.log(buildLPS("ABCDABD")); 
// 输出: [0, 0, 0, 0, 1, 2, 0]

你可以逐个对应:

  • A: 无前后缀,共同长度 0
  • AB: 前[A],后[B] → 0
  • ABC: 前[A, AB],后[BC, C] → 0
  • ABCD: … → 0
  • ABCDA: 前[A, AB, ABC, ABCD],后[BCDA, CDA, DA, A] → "A" → 1
  • ABCDAB: … → "AB" → 2
  • ABCDABD: … → 无 → 0

2. 按题目求解

var strStr = function(haystack, needle) {
  if (needle.length === 0) return 0;
  let lps = buildLPS(needle);
  let i = 0, j = 0;
  while (i < haystack.length) {
    if (haystack[i] === needle[j]){
      i++;
      j++;
      if (j === needle.length) return i - j;
    } else {
      if (j != 0) {
        j = lps[j - 1]; 
      } else {
        i++;
      }
    }
  }
  return -1;
};

3. 算法复杂度

O(m+n)

vivo 官网 APP 首页端智能业务实践

2025年5月23日 14:25

作者:vivo 互联网客户端团队- Li Quanlong

本文介绍端智能技术在vivo官网APP的落地实践,通过抽象问题、提出端智能解决方案、方案落地这三大块内容逐步递进地展开端智能技术的应用过程。

一、前言

vivo官网APP首页是流量最大的页面,承载着新品、活动、商品、其他入口等流量分发的重任。在流量分发上,云端针对首页的主要场景建设了算法支撑。通过梳理首页的场景发现,智能硬件楼层场景的商品配置还是运营纯手工动态配置,而非算法推荐。为此,我们探索了端智能技术,将其运用在智能硬件楼层场景,用于提升商品分发效率,进而提升智能硬件楼层场景的点击率。

端智能广义上来说,是指将人工智能算法部署到端侧设备中,使端侧设备具备感知、理解和推理能力;狭义上来说,端智能就是将机器/深度学习算法集成到端侧设备中,通过算法模型处理端侧感知的数据从而实时得到推理结果。而所谓的"端"实际上是相对于"云"的概念,是一些带有计算能力的个体设备,如手机、家庭路由器、网络的边缘节点等。因此,可以看到端智能的应用离不开这几个关键点:数据、算法模型及计算能力。

二、抽象问题

端智能是如何提高vivo官网APP首页智能硬件楼层场景的商品分发效率的呢?在回答这个问题之前,我们先了解下智能硬件楼层场景,如下图所示:

1.jpg

图1:vivo官网APP首页-智能硬件楼层

智能硬件楼层场景,有4个商品展示资源位,由运营在众多的智能硬件商品中挑选出4个商品进行配置。所以,不同的用户群体进入到vivo官网APP首页看到该场景下的商品都是相同的。而引入端智能技术要解决的问题是:不同的用户群体看到的商品推荐是不一致的,是更加符合该用户群体的商品,做到推荐的精准匹配,如下图所示:

2.jpg

图2:智能硬件楼层商品分发

端智能推荐分发,就是在智能硬件资源池中推荐最适合的4个商品展示在智能硬件楼层场景中。我们抽象下问题,也就是在N个商品中选取前K个商品(K<=N)进行展示,进一步思考下,如何选取前K个商品呢?其实本质是将N个商品按照推荐的概率值进行排序,选取概率值较大的前K个商品。因此,问题就可以进一步被抽象为设计一个算法模型,通过对用户群体的特征分析,输出该用户群体对N个商品感兴趣的概率值。

三、端智能方案

为什么是使用端智能技术,而不是使用人为约束规则或者云端模型来解决"针对某个用户群体,N个商品被推荐的概率值"的问题呢,是因为端智能技术的优势是:

  • 推理计算是在端侧进行的,可以有效地节约云端计算资源及带宽;

  • 因为在端侧进行,响应速度相较于网络请求会更快;

  • 端侧的数据处理是本地的,数据隐私更安全;

  • 算法模型是使用深度学习算法通过训练样本学习出来的,而非人工规则约束,因此可以应对复杂的场景,做精细化的推荐。

3.1 整体架构

vivo官网APP端智能整体架构设计如下:

3.jpg

图3:vivo官网APP端智能整体架构

端智能整体架构主要由模型离线训练、云端配置、APP端执行三大模块构成,离线训练主要负责算法模型的训练生成、模型转换以及模型发布;云端主要负责模型版本管理和模型运行及埋点监控;APP端在业务调用的时候主要负责计算推理,通过设备感知数据,将其通过特征工程处理后输入到算法模型中,模型在TensorFlow-Lite的基础上充分调用设备计算资源进行推理得到结果后反馈给业务使用。

**离线训练:**将原始数据进行数据清洗后,送入到设计的特征工程中处理为算法模型能够处理的特征。然后使用TensorFlow深度学习语言搭建网络模型,并使用处理后的数据进行训练得到后缀为.h5的模型文件,需要将该模型文件通过模型转换为后缀为.tflite的文件,因为.tflite的文件是可以在Android上通过TensorFlow-Lite工具库加载并执行推理。

**云端:**每次模型的训练都有相应的版本控制及监控能力,这样方便做模型的ABTest实验,也可以动态的升级和回退线上模型版本。

**APP端:**Google提供了TensorFlow-Lite机器学习工具库,在此库上可以加载后缀为.tflite的模型文件,并提供了执行前向计算推理能力。因此,在此基础上,APP端侧通过实时地感知数据,并通过特征工程处理后得到特征数据后,可以在端侧利用TensorFlow-Lite提供的能力进行加载模型并运行模型,进而实时得到计算推理结果。

3.2 原始数据

无论是离线的训练模型,还是APP端侧的计算推理都离不开"数据",因为数据是整个架构的灵魂所在。所以,在明确了要解决问题的前提下,就需要全面梳理埋点上报信息,查看下当前拥有的数据信息,如下图所示:

4.jpg

图4:原始数据特征

从上图可以看到能够获取到很多纬度的数据信息,但是在能够被利用之前,要先进行数据清洗工作。并不是每个信息都会包含这些纬度的信息、也并不是每个纬度的信息都同样重要,所以要结合场景来做数据的清洗。例如,在智能硬件楼层场景下,基础信息设备型号很重要,因为这个信息代表着当前用户使用的是哪款手机型号,所以在数据清洗时,若此信息采集时是为空,则需要设计默认值的方式进行处理。

在完成了数据清洗工作后,就可以进行下一步动作了,将设备型号、性别、所在城市等语义信息,通过特征工程处理为能够输入到算法模型处理的特征信息。在本文案例中,我们以基础信息为例子来展开介绍,基础信息如手机设备型号、当前用户所在城市、用户性别等。举例,若一个男性用户在使用x200手机访问vivo官网APP,并且授予vivo官网APP定位权限,获取的定位在南京,则当前获取一条原始数据为"vivo x200,南京,男"。

3.3 特征工程

获取的原始数据是自然语言的特征,需要将其处理为算法模型能够处理的数字化表示形式的离散特征。现在AI大模型层出不穷,可以很好地处理自然语言,输入的自然语言可以通过Embedding模型或者Word2Vec进行特征处理,将自然语言处理为模型能处理的"0.1 0.6 ... 0.4"形式,如下图所示:

5.jpg

图5:语义特征

通过Embedding模型处理的特征是具有语义相似性的,但回归本文,我们并不需要这种具有语义相似性的特征。输入的特征纬度越大,则模型的复杂度就越高,而需要的计算资源也就越高。在端侧做计算推理,在满足业务需求的前提下,算法模型要足够小,因此我们化繁为简,采用简单的方式来处理原始数据。这个时候,我们回顾下原始数据"vivo X200,南京,男",这几个特征实际上是并列关系,我们可以逐个地处理为One-Hot编码特征表示,然后再组装。

3.3.1 位置特征

我们以地理位置信息为例,用户可以分布在全国的各个地方,如在北京、上海、南京、重庆等,因此,我们需要找到一定的规律来处理地理位置信息。这个时候需要通过大数据的方式,大致了解到城市消费的平均价位信息的分布情况,像北京、上海等城市消费趋势相当;像武汉、合肥等城市消费相当。因此,我们可以根据此反映出来的现象,将消费趋势一致的城市归为一个聚类。例如,可以简单划分为三个聚类,分别是聚类A、聚类B、聚类C,通过这样的方式,就可以将不同的城市划分到这三个聚类中去。如"南京"属于聚类A,"扬州"属于聚类B等。再进行抽象下,可以使用三位数字(000)这种形式表示聚类。第一位数字0表示不是聚类A,数字是1则表示是聚类A,这样就可以通过三位数字来表示地理位置信息了。如,可以通过"100"来代表"南京"。

**默认值处理:**如果此时获取不到地理位置信息,可以按一定策略处理获取不到的信息进行填充,如,新增一位聚类D表示是没有获取到地理位置信息,亦或者将其都笼统归纳为聚类C。

3.3.2 设备型号

我们再看下,如何处理手机设备名称特征的。随着手机的更新迭代,市场上的手机名称也枚不胜举。但是,每款手机都是有其定位的,而这个定位实际上是可以通过手机发布的系列能够得知的。如,vivo X200手机是旗舰机型,属于vivo的X系列。参考地理位置信息通过聚类的分类方法,我们也可以简单地将设备机型名称通过系列来做区分。在vivo官网APP的选购页面,可以看到vivo品牌下有三个系列机型,分别是X系列、S系列和Y系列。因此,我们如法炮制,也可以使用三位数字(000)这种形式来表示设备机型名称。例如:第一位数字0表示不是X系列手机,1表示是X系列手机;同理第二位数字0表示不是S系列手机,1表示是S系列手机等。因为,vivo X200属于X系列,所以可以将其vivo X200原始数据处理为"100"来表示,同理如果此时获取手机设备是vivo S20 Pro,则被转换为抽象的特征"010"来表示。

**默认值处理:**参考位置特征的处理方式,可以新增一位One-Hot特征表示UnKnow,即未获取手机设备名称特征时,UnKnow这一列数字为1,反之为0。

3.3.3 性别特征

性别可以使用3位数字(000)表示,第一位表示女性,第二位表示男性,使用第三位表示未获取性别时的默认值。如,"男"可以通过"010"表示。

3.3.4 特征组合

因此,通过上述的处理规则,就可以将一条原始数据"vivo x200,南京,男"处理为"1001000010"来表示了,如下图所示:

6.jpg

图6:特征组合

特别需要注意的是,自然语言特征组合的顺序和处理后的数字化特征顺序一定是保持一致的。

3.4 算法模型

3.4.1 算法模型整体架构

算法模型整体架构如下图所示:

7.jpg

图7:算法模型整体架构

由三部分构成:分别是输入层、隐藏层及输出层。

  • **输入层:**是经过特征工程处理后的特征,主要是用户基础信息和实时上下文信息。

  • **隐藏层:**由多层全连接神经网络组成,使其拥有非线性变化能力,可以在更高纬度空间中逼近寻找拟合特征的最优解。

  • **输出层:**第一个输出层是将每个分类商品打上标签,输出该标签概率集合;第二个输出层是在第一个输出层的基础上直接通过模型中存入的标签和商品SkuId映射关系,直接输出商品SkuId集合。

在设计算法模型的时候,要选择适合贴近要处理的场景,而非盲目地选择大模型做基座或者搭建很深的模型。不仅需要考虑模型的预测准确率,还需要考虑端侧的计算资源。

3.4.2 模型代码示例

算法模型的核心代码,如下示例:

# 输入层
input_data = keras.layers.Input(shape=(input_size,), name="input")
output = tf.keras.layers.Dense(64, activation="relu")(input_data)
output = tf.keras.layers.Dense(128, activation="relu")(output)
output = tf.keras.layers.Dense(output_size, activation="softmax")(output)
# 输出层:top k index
output = NewSkuAndFilterPredictEnvSkuIdLayer(index_groups=acc.index_groups, )(output)
# 输出层:index 映射 skuid 直接出
output = SkuTopKCategoricalLayer(acc.skuid_list, spu_count=len(acc.spuid_list))(output)

3.4.3 模型训练

**原始数据:**训练模型的原始数据是来源于大数据提供的埋点信息数据,例如获取当前日期前3个月的埋点信息数据。

**数据清洗:**并不是所有的原始数据都可以拿来直接使用,有部分数据是不符合约束条件的,称之为脏数据,即将不满足约束规则的脏数据清理后,则可以获得真正的用于训练模型的数据。

**2:8分割原则:**为了验证模型训练的Top5的准确率,将清洗后的数据,80%分为训练数据、20%分为测试数据;需要注意的是,训练数据在真正训练的时候,需要使用shuffle来打散,可以增强模型的鲁棒性。

代码示例:

train_dataset = tf.data.Dataset.from_tensor_slices((train_data, train_labels))test_dataset = tf.data.Dataset.from_tensor_slices((train_data, train_labels))# 使用shuffle方法对数据集进行随机化处理# 参数 buffer_size 指定了用于进行随机化处理的元素数量,通常设置为大于数据集大小的值以确保充分随机化、batch() 则指定了每个批次的数据量。train_dataset = train_dataset.shuffle(buffer_size=datasets.train_len).batch(config.BATCH_SIZE)test_dataset = test_dataset.shuffle(buffer_size=datasets.test_len).batch(config.BATCH_SIZE) # 获取模型输入、输出纬度大小acc_input_size = datasets.acc_columns - 1acc_out_size = datasets.acc_output # 构建模型model = build_parts_model(acc_input_size, acc_out_size)optimizer = tf.keras.optimizers.legacy.RMSprop(learning_rate=config.LEARN_RATIO)model.compile(optimizer=optimizer, loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=False),              metrics=[tf.keras.metrics.SparseTopKCategoricalAccuracy(k=5, name="top5_acc_accuracy")])model.fit(train_dataset, epochs=config.TRAIN_EPOCH)

3.4.4 保存模型

通过13轮epoch的训练后,获得TensorFlow模型:

train_dataset = tf.data.Dataset.from_tensor_slices((train_data, train_labels))
test_dataset = tf.data.Dataset.from_tensor_slices((train_data, train_labels))
# 使用shuffle方法对数据集进行随机化处理
# 参数 buffer_size 指定了用于进行随机化处理的元素数量,通常设置为大于数据集大小的值以确保充分随机化、batch() 则指定了每个批次的数据量。
train_dataset = train_dataset.shuffle(buffer_size=datasets.train_len).batch(config.BATCH_SIZE)
test_dataset = test_dataset.shuffle(buffer_size=datasets.test_len).batch(config.BATCH_SIZE)
 
# 获取模型输入、输出纬度大小
acc_input_size = datasets.acc_columns - 1
acc_out_size = datasets.acc_output
 
# 构建模型
model = build_parts_model(acc_input_size, acc_out_size)
optimizer = tf.keras.optimizers.legacy.RMSprop(learning_rate=config.LEARN_RATIO)
model.compile(optimizer=optimizer, loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=False),
              metrics=[tf.keras.metrics.SparseTopKCategoricalAccuracy(k=5, name="top5_acc_accuracy")])
model.fit(train_dataset, epochs=config.TRAIN_EPOCH)

3.4.5 模型转换

通过上面的步骤,可以得到在一个后缀名为.h5文件的模型文件,但我们需要将模型部署在APP端侧运行,端侧运行环境是依赖TensorFlow-Lite工具库,因此需要将.h5文件的模型转换为.tflite文件模型。

1.TensorFlow-Lite工具库

TensorFlow Lite学习指南中的官方介绍表明:"TensorFlow Lite 是一组工具,可帮助开发者在移动设备、嵌入式设备和 loT 设备上运行模型,以便实现设备端机器学习"。简而言之,端侧设备引入TensorFlow Lite工具库后,就可以加载机器学习模型,并且执行前向计算推理能力。若不使用该工具库,需要团队自行研发可以运行在端侧的机器学习模型的执行环境,如:裁剪python,保留其核心能力,像字节的pitaya框架。因此,引入该工具库,对我们团队来说就可以减少在端侧构建执行环境的巨大开发工作量。

2.算子兼容

使用TensorFlow-Lite工具库的时候,需要考虑算子兼容性问题。TensorFlow Lite内置算子 TensorFlow 核心库的算子的一部分,可以见下面图的包含关系:

8.png

图8:TensorFlow算子兼容

(图片来源:TensorFlow官网)

因此,在使用TensorFlow深度学习语言设计算法模型时,要考虑到算子的兼容性,避免出现在本地可以工作的模型,在端侧因为算子不兼容而导致部署失败。

3.模型转换

(1)非TensorFlow深度学习语言模型转换

使用其他深度学习框架训练的话,可以参考使用如下两个方式转换为.tflite文件模型,以Pytorch深度学习框架语言为例

根据Pytorch的代码,使用TensorFlow重写,得到TFLite文件

使用工具ONNX转换

若使用ONNX工具,则转换的链路是这样的:PyTorch -> ONNX -> TensorFlow -> TFLite

(2)TensorFlow深度学习语言模型转换

使用TensorFlow深度学习语言训练的话,可以直接使用下述代码,转换为Android端侧可以加载的模型文件.tflite

9.png

图9:TensorFlow模型转换

(图片来源:TensorFlow官网)

模型转换代码示例如下:

1、Keras Model
训练过程中,直接将model转成tflite
converter = tf.lite.TFLiteConverter.from_keras_model(model)
# 转换模型
tflite_float_model = converter.convert()
# 保存模型
with open('mnist_lenet5.tflite', 'wb') as f:
  f.write( tflite_float_model)
 
2、SavedModel
model.save('mnist_lenet5_save_model', save_format='tf')
# 转换模型
converter = tf.lite.TFLiteConverter.from_saved_model('mnist_lenet5_save_model') tflite_model = converter.convert()
 
# 保存模型
with open('mnist_lenet5_saved_model_dir.tflite', 'wb') as f:
  f.write(tflite_model)

3.5 计算推理

Google提供了TensorFlow Lite库,可以加载TFLite文件,并在端侧利用本地计算资源完成推理,注意这里只是有推理能力,而不提供训练。如下所示:

dependencies {
    implementation 'org.tensorflow:tensorflow-lite:2.14.0'
}

通过添加模块依赖项将TensorFlow Lite库引入到APP应用中。

3.5.1 加载模型

假设现在模型文件是放在assets文件目录下,那么首先先加载模型,构建Interpreter对象,如下代码:

private Interpreter mTfLite = new Interpreter(loadModelFileFromAssets("文件名"))

3.5.2 模型运行推理

加载好模型文件后,就可以直接调用推理API了,推理API提供了两个,分别是:

// 通过API的名称其实就能够看出区别了,run()是单个输入调用,runForMultipleInputsOutputs是多个输入调用,
// run方法实际执行时也是调用runForMultipleInputsOutputs
// 实际上mTfLite .run()底层
mTfLite .run()
mTfLite .runForMultipleInputsOutputs()

因此,只需要准备好输入和输出数组即可直接调用,如下代码所示:

// 输入数组
float[][] inputDataArray = new float[][]{inputDataArrayLen};
// 输出数组
float[][] outputDataArray = new float[1][outputDataLen];
// 运行推理
mTfLite .run( inputDataArray ,  outputDataArray);

四、方案落地

4.1 模型配置

在设计算法模型之初,需要考虑输入和输出纬度大小,尽量将输入和输出的纬度固定下来,这样的话当不断迭代算法模型时,就不会因为输入和输出纬度不一致导致不能兼顾到之前的APP版本,进而做到模型可动态升级。在模型配置中心管理模型版本、下发策略,在端侧下载模型时可通过模型版本号及文件MD5值校验模型文件的完整性。

4.2 运行监控

模型上线后,需要监控线上执行的效果。主要关注三个指标

  • **模型运行成功率:**加载模型、执行模型上报是否成功

  • **模型版本分布:**模型版本升级情况,便于分析数据

  • **模型各版本的平均运行时长:**关注运行时长,进而指导模型设计时考虑模型的复杂程度

10.png

图10:模型运行监控

如上图所示,通过建立完善的线上监控,一方面清楚了解模型运行情况,另一方面可以提供设计算法模型的方向,进而更好地迭代出一个模型,可以在端侧设备计算资源及收益效果达到一个各方都还不错的平衡点。

五、总结

要使用端智能能力,首先要知道解决什么问题。如,本文是解决的"重排序"问题,其实本质就是"多分类"问题。知道了具体解决的问题后,要进一步抽象出输入是什么、输出是什么。此时,还需要盘点当前能够获取哪些原始数据,并设计特征工程去处理好原始数据,将其转换为算法模型能够接受的特征。再紧接着,就是要结合应用场景设计好算法模型即可。

本文通过利用端智能重排序云端返回的商品信息,进而不同人群展示不同商品信息,实现千人千面效果。从获取原始数据开始,到特征工程的设计,设计符合该业务场景的算法模型,然后进行训练获取模型,再进行模型转换为TFLite文件格式,通过端侧加载该模型后,进行计算推理获取重排序后的结果。

在端智能的道路上,一方面我们继续探索更多的落地场景,另一方面继续挖掘丰富的端侧数据,更新迭代特征工程及算法模型,更好地为业务创造价值。

❌
❌