普通视图

发现新文章,点击刷新页面。
今天 — 2025年4月15日技术

聊聊vue中的keep-alive

2025年4月15日 12:52

今天和大家聊聊vue中的keep-alive

keep-alive是什么?

Vue.js 中的一个内置组件,用于缓存动态组件的状态

代码示例

接下来我们做一个demo,目录结构为

src/
├── components/
│   ├── Home.vue
│   └── About.vue
├── App.vue
└── router/
    └── index.js
  • Home
<template>
  <div>
    <h1>Home</h1>
    <p>当前时间:{{ currentTime }}</p>
    <button @click="updateTime">更新时间</button>
  </div>
</template>

<script setup>
import { ref, onActivated, onDeactivated } from "vue";

const currentTime = ref(new Date().toLocaleTimeString());

const updateTime = () => {
  currentTime.value = new Date().toLocaleTimeString();
};

onActivated(() => {
  console.log("Home组件激活");
});
onDeactivated(() => {
  console.log("Home组件失活");
});
</script>
  • About
<template>
  <div>
    <h1>About</h1>
    <p>这里是关于页面</p>
  </div>
</template>

<script setup>
import { onActivated, onDeactivated } from "vue";
onActivated(() => {
  console.log("About组件激活");
});
onDeactivated(() => {
  console.log("About组件失活");
});
</script>

  • router
import { createRouter, createWebHistory } from "vue-router";
import Home from "../components/Home.vue";
import About from "../components/About.vue";

const routes = [
  { path: "/", name: "Home", component: Home },
  { path: "/about", name: "About", component: About },
];

const router = createRouter({
  history: createWebHistory(),
  routes,
});

export default router;

  • App
<template>
  <div id="app">
    <nav>
      <router-link to="/">Home</router-link>
      <router-link to="/about">About</router-link>
    </nav>
    <router-view v-slot="{ Component }">
      <keep-alive>
        <component :is="Component" />
      </keep-alive>
    </router-view>
  </div>
</template>

<script>
export default {};
</script>

<style>
nav {
  margin-bottom: 20px;
}
</style>

运行结果

首先我们进入到Home页面,打印结果为,并且我们切换的时候home页面显示的时间为12:47:44

image.png

接下来我们跳转到About页面就会发现,home组件失活,about组件激活

image.png

继续回到home组件,我们能够看到,在我们切换home页码的时间是什么时候,回来的时间还是一样的,可见,数据被缓存下来了

image.png

总结

使用 <keep-alive> 组件可以有效地缓存 Vue 组件,从而提高应用性能和用户体验。当一个组件被缓存时,它的状态和数据会被保留,用户在切换回该组件时能够看到之前的状态。

如果需要在组件消失时执行特定的操作(例如,清理资源、保存状态或停止定时器),可以将相关的方法定义在 onDeactivated 钩子中。这样,当组件被去激活时,这些方法会自动执行,确保组件在被移除前能够完成必要的清理工作。

这种方式不仅提高了代码的可读性,还能有效管理组件的生命周期,避免内存泄漏等问题。通过合理使用 onActivatedonDeactivated 钩子,可以更好地控制组件的行为和状态。

你以为你在封装组件,其实你在引入混乱

2025年4月15日 12:37

一、引子:封装的幻觉

“哎,这几个页面结构差不多,我封装一下好了。”

很多开发者都会在项目中产生这样的冲动,尤其是做中后台系统时,一旦遇到几个结构类似的表单或列表页,就想提取公共组件。看起来像是 DRY(Don’t Repeat Yourself)的最佳实践,实际上却很容易误入歧途:

你以为你在封装组件,其实你只是在用配置项、插槽、条件渲染造了个更复杂的黑盒。

真正的问题不是封装,而是封装的边界模糊、结构职责不清、状态逻辑耦合 UI

本文将结合真实案例,拆解初级、中级、高级组件封装的本质差异,帮助你理解——封装从不是目的,系统能力才是高级工程感的核心。


二、常见封装误区与案例拆解

1. 表单组件的封装错觉

我们先来看一个典型的错误封装案例:

<!-- 错误的Form封装 -->
<my-form :formData="form" :fields="fields" @submit="onSubmit">
  <template v-slot:extra>
    <button @click="onExport">导出</button>
  </template>
</my-form>

这段代码表面上很优雅,传了 fields 就能渲染表单,还有扩展插槽,甚至可能支持表单校验和重置逻辑。

问题在哪里?

  • 字段职责不清:字段只是表单结构吗?字段还可能影响接口参数格式、权限控制、默认值、展示方式。
  • 表单行为耦合 UI:你把逻辑写在组件内部的 @submit 回调里,这意味着每个使用这个组件的页面都得重复写一样的逻辑。
  • 扩展性差:当你需要根据字段值联动另一个字段,或某些字段有特殊的业务逻辑,组件就无法扩展。
正确做法:字段中心 + 表单驱动
const fieldSchema = [
  {
    key: 'status',
    label: '状态',
    component: 'select',
    options: getDict('STATUS'),
    rules: [{ required: true, message: '请选择状态' }],
    apiField: 'status_code',
  },
  // ...
];

然后由通用的 <SmartForm /> 组件自动识别字段类型、权限、字典等信息进行渲染与校验,同时支持联动配置。


2. 列表页状态的误封装

<!-- 错误封装:DataList.vue -->
<template>
  <el-table :data="list" />
</template>
<script>
export default {
  props: ['list'],
  mounted() {
    // fetch logic...
  }
}
</script>

你以为你抽象了一个 DataList 组件,便于多个页面复用。

实际你只是把 el-table 包了一层,不支持分页、不支持空状态、不支持 loading。

更重要的是:你没有管理状态本身。

改进方向:抽象 useSmartList()
const { searchForm, tableProps, onSearch, onReset } = useSmartList({
  api: fetchData,
  defaultQuery: {},
  transform: (data) => data.map(item => ({ ...item, statusLabel: mapStatus(item.status) })),
});

然后配合页面中的 schema、columns 自动渲染,逻辑统一、行为一致。


三、组件封装的三个阶段

等级 开发目标 特征
初级 页面能用,功能不报错 逻辑散乱、字段写死、重复逻辑多
中级 页面结构清晰,组件职责明确 有组件复用,但配置灵活性不足
高级 字段驱动 + 页面生成 + 状态抽象 形成系统能力,字段中心驱动页面

举个例子:表格字段

初级:

columns: [{ title: '状态', dataIndex: 'status', customRender: val => val === 1 ? '启用' : '禁用' }]

中级:

columns: [{ title: '状态', dataIndex: 'status', customRender: val => getDictLabel('STATUS', val) }]

高级:

fieldSchema: [{ key: 'status', label: '状态', dict: 'STATUS' }]
// 由表格组件根据 schema 自动渲染列标题、字典映射、权限控制

四、高级封装的本质:规则驱动 vs 配置堆砌

很多人误以为封装的程度越高,越通用越好。

但高级工程能力的核心在于:规则驱动而不是堆砌配置

  • 👎 低级组件:props 越来越多,写法越来越复杂,一堆 v-ifslot 判断
  • 👍 高级组件:抽象成规则,例如字段权限、字段格式、接口规范、字段联动都由统一 schema 表达

真正的高级封装:

// 一个字段定义
{
  key: 'status',
  label: '状态',
  dict: 'STATUS',
  permissions: ['view'],
  component: 'a-select',
  format: (val) => val === 1 ? '启用' : '禁用',
  visible: (form) => form.type === 'basic'
}

你不是在写“组件”,而是在写“字段规则”。字段就是视图,规则决定行为。


五、真正的系统构建力

想成为高级前端,不能只满足于组件层面的复用,而要走向“体系构建力”:

  • 字段结构中心:一份配置多端复用
  • 页面模式抽象:编辑、新增、详情、弹窗统一逻辑
  • 行为驱动设计:跳转、保存、校验不再散落各处
  • 状态统一方案:支持缓存、回填、持久化

最终实现:前端是领域规则的建模者,而不仅是功能的搬运工。


六、结语:别被“封装”二字骗了

封装本身并不高级。高级的是你能否看清哪些逻辑可以通用,哪些行为需要抽象,哪些状态需要统一,哪些配置应该中心管理。

很多人写了十几二十个组件,结果维护困难,逻辑混乱。

你以为你在封装组件,其实你在引入混乱。

真正的封装,是为系统构建秩序。

没有CICD,怎么自动化部署?

2025年4月15日 12:20

不久前写了一个全新项目,包括服务器也是新的,所以没有CICD流水线

写完项目需要自己部署更新到服务器上

一开始是直接build,再用XshellXftp连接服务器手动替换文件来实现更新部署

但是这样不仅容易替换错,还耗时耗力

所以想着自己弄一个简单的自动化部署SSH

image.png

主要是通过 node-ssh 进行 SSH 连接并执行命令,上传文件夹,完成部署工作。下面来逐步解析,并说明 SSH 的使用。

1. 引入相关模块

import { NodeSSH } from 'node-ssh';
import { deployConfig } from './config.js';
import path from 'path';
  • NodeSSH: 这是 node-ssh 库的核心模块,提供了与远程服务器通过 SSH 协议进行通信的功能。
  • deployConfig: 从外部的 config.js 文件导入配置信息,通常这个配置会包含 SSH 连接所需的信息(如服务器地址、用户名、私钥路径等)。
       export const deployConfig = {
           host: '服务器地址',
           username: '服务器名称',
           password: '服务器密码'
         }
    
  • path: Node.js 内建的模块,用于处理文件路径和目录。

2. 获取当前目录路径

const __dirname = path.dirname(new URL(import.meta.url).pathname);
  • 由于你的代码使用了 ESM 模块(通过 importexport),__dirname 在这种模式下并不存在。为了获取当前文件所在的目录路径,使用了 path.dirname()import.meta.url 来实现获取路径。

3. 部署函数 deploy

async function deploy() {
  const ssh = new NodeSSH();
  • 创建了一个 NodeSSH 实例 ssh,它将用于与远程服务器进行连接、执行命令以及文件上传等操作。

4. 连接到远程服务器

await ssh.connect(deployConfig);
console.log('SSH 连接成功!');
  • ssh.connect(deployConfig):这个方法会使用 deployConfig 中的配置来连接到远程服务器。deployConfig 包含了你连接 SSH 所需要的所有信息,如 host, username, privateKey 等。
  • 如果连接成功,打印出 "SSH 连接成功!"

5. 执行命令

const result = await ssh.execCommand('ls');
console.log('命令执行结果:', result);
  • execCommand('ls'):在远程服务器上执行 ls 命令,通常用来列出远程目录的文件。这个方法会返回一个包含命令执行结果的对象,包含 stdoutstderr 字段(分别表示标准输出和错误输出)。
  • 将命令的执行结果打印到控制台。

6. 执行部署脚本

const deployScript = `
  cd nginx下载地址;
`;
const deployResult = await ssh.execCommand(deployScript);
console.log('部署脚本执行结果:', deployResult);
  • 你定义了一个部署脚本,脚本的内容是进入 nginx的 目录。虽然这个脚本很简单,但通常部署脚本会包含更多的操作,比如下载代码、配置文件修改等。
  • 然后通过 execCommand 执行这个脚本,并打印执行结果。

7. 准备上传本地文件夹

let localDistPath = path.join(path.dirname(__dirname), 'dist');
localDistPath = localDistPath.replace(/^\\+/, '');
  • 这里使用了 path.join() 结合 __dirname 来确定本地 dist 文件夹的路径。path.dirname(__dirname) 取的是当前文件的父级目录,再通过 path.join() 加上 'dist',确定了本地的 dist 文件夹路径。
  • 然后使用 replace(/^\\+/, '') 来去除路径开头的多余反斜杠(\\+ 表示匹配一个或多个反斜杠)。

8. 定义远程路径

const remoteDistPath = '服务器默认打开文件夹地址';
  • 这是远程服务器的目标目录,你希望将本地的 dist 文件夹上传到该目录。

9. 上传文件夹到远程服务器

const uploadResult = await ssh.putDirectory(localDistPath, remoteDistPath, {
  recursive: true,
  concurrency: 10,
});
  • putDirectory(localDistPath, remoteDistPath, options):这是 node-ssh 提供的一个方法,用于将本地的整个目录上传到远程服务器。
    • localDistPath: 本地的 dist 文件夹路径。
    • remoteDistPath: 远程服务器上的目标路径。
    • options: 配置项,recursive: true 表示递归上传子目录,concurrency: 10 表示最多同时上传 10 个文件,优化上传速度。
  • 上传操作的结果存储在 uploadResult 中,这个对象包含上传过程中的信息。

10. 打印上传结果

console.log('上传结果:', uploadResult);
  • 将上传的结果打印到控制台。uploadResult 将包含上传文件的状态信息。

11. 错误处理

} catch (err) {
  console.error('SSH 连接或命令执行出错:', err);
}
  • 如果连接或命令执行中发生错误,catch 语句会捕获到错误,并打印出来。

12. 调用 deploy 函数

deploy();
  • 最后,调用 deploy 函数来启动部署过程。

至此,自动化部署的配置文件已经完成,接下来就是使用

  • 在package里配置运行指令:不需要单独打包和运行部署,使用&&直接一键打包并部署
    "deploy": "rimraf dist && NODE_OPTIONS=--max-old-space-size=8192 vite build && node scripts/node_ssh.js",
  • 🆗,直接快乐玩耍,pnpm run deploy 直接一键打包且部署到指定服务器

image.png

总结

  1. SSH 连接:通过 ssh.connect() 连接到远程服务器,成功连接后可以执行命令或上传文件。
  2. 命令执行:使用 execCommand() 执行远程命令,并获得结果。
  3. 上传文件夹:通过 putDirectory() 方法将本地目录上传到远程服务器。
  4. 错误处理:通过 try-catch 捕获可能发生的错误并输出日志。

这个流程适用于自动化部署的场景,可以将本地的构建产物上传到远程服务器,并执行必要的命令来完成部署。


至此,实现基础的自动化部署功能

image.png

漫画产业加密技术探索与实践:抵御盗版的创新之路

2025年4月15日 12:05

随着数字内容产业的蓬勃发展,盗版问题成为制约漫画产业健康发展的重大挑战。本文深入探讨了漫画内容在数字化传播过程中面临的盗版威胁,并提出了一套基于加密技术的解决方案。通过详细阐述加密规则同步、图片加密与解密流程、CDN 边缘函数应用、安全策略优化等关键技术环节,本文展示了如何有效提升漫画内容的安全性,减少盗版带来的损失。同时,结合实际应用案例,分析了该方案在提升用户体验、降低运营成本和增强版权保护方面的显著成效,为漫画产业及其他数字内容产业的版权保护提供了有益的参考和借鉴。

1. 前言

在数字时代,漫画产业的繁荣离不开网络平台的传播与推广。然而,盗版问题如同阴霾,严重侵蚀了漫画创作者和平台的合法权益,导致巨大的经济损失。盗版者利用技术手段非法获取漫画资源,并通过盗版网站、下载器等渠道进行传播,不仅破坏了正版市场的生态,还对创作者的积极性造成了打击。因此,探索有效的技术手段以抵御盗版,成为漫画产业亟待解决的问题。

本文将详细介绍一种基于加密技术的解决方案,旨在通过提升漫画图片盗取的技术门槛,增加破解难度,从而有效遏制盗版行为的泛滥。该方案结合了同步加密规则、CDN 边缘函数、椭圆曲线加密算法等多种技术手段,构建了一个安全、高效的漫画内容保护体系。

2. 盗版现状有多猖獗?

2.1 盗版对漫画产业的影响

盗版漫画的泛滥对漫画产业造成了深远的负面影响。据相关报道,某些顶级漫画作品因盗版而遭受的损失甚至超过了其通过正规渠道获得的收入。盗版网站如喵趣、拷贝漫画、包子漫画等,通过非法手段获取漫画资源,并提供免费阅读服务,吸引了大量用户,导致正版平台的用户流失严重。

2.2 盗版技术手段

盗版者利用的技术手段日益复杂,给版权保护带来了巨大的挑战。他们通过抓包等技术手段获取漫画图片的下载链接,并进行批量下载和保存。爬虫程序更是利用个人电脑(PC 端约占 80%)和移动应用(APP 端约占 20%)与服务器端的交互协议,以极低的成本非法爬取漫画图片。在这种情况下,仅靠传统的防御措施难以有效遏制盗版行为,必须通过技术创新来提升版权保护水平。

3. 加密技术方案设计

图片

3.1 加密规则同步

为了确保漫画内容在传输过程中的安全性,漫画服务器与内容分发网络(CDN)之间需要建立一个加密规则,并共享用于加解密的规则和算法。具体步骤如下

  1. 漫画服务器与 CDN 通过安全通道协商加密规则,包括加密算法、加密版本号、加密区域大小等参数。

  2. CDN 将协商好的加密规则存储在本地缓存中,以便后续使用。

  3. 漫画服务器在每次内容更新时,将加密规则的版本号嵌入到漫画内容的元数据中,确保 CDN 和客户端使用相同的加密规则。

3.2 图片令牌机制

为了防止盗版者直接获取漫画图片的下载链接,引入了图片令牌机制。具体流程如下:

  1.  Web 端或 App 端在请求漫画图片时,首先生成一个唯一的图片令牌(image token)。

  2. 服务端接收到图片令牌请求后,验证令牌的有效性,并返回对应的图片地址以及加密信息。

  3. Web 端或 App 端使用图片令牌向 CDN 发起图片请求,CDN 根据令牌中的信息进行加密处理,并返回加密后的图片。

3.3 边缘函数加密

CDN 边缘节点在接收到图片请求后,根据请求中携带的信息和预先同步的加密规则,对漫画图片进行加密处理。具体步骤如下:

  1. CDN 根据图片令牌中的 EncryptVersion 确定加密算法、加密区域大小和加密位置。

  2. 生成 CDN 的椭圆曲线公私钥对(svrPrivKey、svrPubKey),并通过 svrPrivKey 和客户端公钥(cliPubKey)生成共享密钥(shared_key)。

  3. 根据图片信息、时间信息等生成盐值(salt)和附加信息(info),并派生出最终的加密密钥(derived_shared_key)。

  4. 对图片的指定区域进行加密处理,将加密后的密文替换回原文件中,并将加密版本号、加密区域大小、CDN 公钥等信息附加到图片文件中。

  5. 将加密后的图片返回给 Web 端或 App 端。

3.4 客户端解密渲染

客户端接收到加密图片后,根据加密信息进行解密处理,恢复原始图片数据,并在界面上进行渲染显示。具体步骤如下:

  1. 从加密图片中解析出加密版本号、加密区域大小和 CDN 公钥。

  2. 根据加密版本号确定解密算法和参数,生成共享密钥(shared_key)。

  3. 使用共享密钥对图片的加密区域进行解密处理,恢复原始图片数据。

  4. 将解密后的图片渲染到界面上,供用户正常阅读。

4. CDN加密技术的整体实施

边缘函数(EdgeRoutine,简称ER)是运行在DCDN边缘节点上的一种Serverless计算服务,可以有效减少延迟、提高响应速度和减轻中心服务器的负载。在漫画产业中,通过在CDN边缘节点部署加密功能,可以实现对漫画图片的动态加密和解密。同时,CDN边缘函数还可以结合内容缓存技术,进一步提高漫画内容的分发效率。

4.1 为什么要使用CDN边缘函数?

  • 靠近用户:边缘函数节点部署在离用户更近的地方,这样可以减少数据传输的距离,从而减少延迟。

  • 减少延迟:由于数据传输距离的减少,用户请求的处理时间会缩短。

  • 内容缓存:CDN边缘节点通常会缓存热门内容,这样当用户请求这些内容时,可以直接从最近的边缘节点提供,而不需要每次都从中心服务器获取。

  • 数据处理:边缘函数允许在数据源附近进行数据处理,在这里提前处理加解密事项可以减少需要传输到中心服务器的数据量,并且可以快速响应本地事件。

  • 安全性:边缘函数可以提供更好的数据安全性,因为敏感数据可以在cdn处理,不需要传输到远程服务器。

图片

4.2 边缘函数实际运用

4.2.1 动态加密

动态加密指的是在数据传输或存储过程中,采用算法不断变化的加密方式,以增强数据的安全性。与静态加密不同,动态加密能够根据特定条件(如时间、用户行为或随机数)生成不同的加密密钥或算法,从而提高抵御攻击的能力。

  • 加密算法要素
  • 椭圆曲线加密(ECC):椭圆曲线加密是一种高效的非对称加密算法,能够在较低的计算成本下提供高安全性。在本方案中,APP在每个时间段内生成一对椭圆曲线密钥,而CDN节点则可动态重新生成密钥,从而保证了加密的动态性。
  • 密钥派生:通过密钥派生函数(KDF),结合盐值(salt)和信息(info),从初始密钥材料中生成对称密钥(shared_key)。例如,HKDF算法通过多轮迭代生成安全的派生密钥。
  • 混合加密:采用AES变种与ECDH结合的混合加密方式,利用AES的高效性和ECDH的安全性,对漫画图片进行加密
  • 请求校验
  • 完成Token和时间校验,校验请求是否在时间限制范围内
  • 使用既定的盐值(salt)和cliPubKey信息,校验请求是否合格
  • 加密参数确定
  • 根据cliPubKey里的EncrypType字段确定是web端加密还是APP端加密,根据EncryptVersion确定EncryptSize、EncryptSizePos、EncryptMethod
  • 定义一个异步函数 decodeAndSplitCpx,对 cpx 参数进行解码,
  • 使用 atob 函数将cpx 转换为二进制字符串decodedCpx,并将结果转换成每个字符的 ASCII 码值。
  • 从生成的数据缓冲区中提取第x个字节,x个字节代表版本号EncryptVersion、isMd5(表示是否需要校验 MD5)、EncrypType(请求端类型)。
  • 根据 EncrypType 的值,选择 version.web 或 version.app 对象,并使用 EncryptVersion 作为索引来获取对应的信息。返回获取到的信息分配给变量 info, salt, iv, key, target, nonce, algorithm用于加密。
  • 密钥生成与非对称派生
  • CDN密钥对:生成CDN的椭圆曲线公私钥对,分别为svrPrivKey和svrPubKey。
  • Shared Key生成:通过svrPrivKey和cliPubKey生成共享密钥(shared_key)。
  • 图片分片加密
  • 图片划分区域读取:读取图片文件,对其分片字节进行加密。加密位置可随机偏移(可配置)。
  • 加密算法:利用EncryptMethod算法和derived_shared_key进行加密。
  • 密文替换:将加密后的密文替换回原文件中。
  • 密文返回
  • 密文组成:将salt、pos、svrPubKey和加密后的图片组成的密文返回给web端/客户端。

图片

4.2.2.版本控制

  • 版本号同步
  • 在漫画服务器与 CDN 边缘节点之间定期同步加密版本号(EncryptVersion)。该版本号用于标识当前使用的加密规则和算法配置。
  • 每次客户端请求图片时,服务器会将当前的 EncryptVersion 作为响应的一部分返回。客户端根据版本号选择对应的解密逻辑。
  • CDN 边缘节点也会根据 EncryptVersion 动态调整加密算法和参数,确保加密过程的一致性和安全性。
  • 版本配置管理
  • 在服务器端维护一个版本配置表,记录每个版本号对应的加密算法、加密区域大小(EncryptSize)、加密位置(EncryptSizePos)以及其他加密参数。
  • 版本配置表支持动态更新,允许运营人员根据安全需求随时调整加密策略。
  • 多版本共存
  • 为了确保系统的平滑过渡和兼容性,我们支持多版本加密算法共存。在版本更新期间,系统能够同时处理旧版本和新版本的加密图片。

4.2.3 算法升级策略

  • 算法选择与优化
  • 根据安全需求和性能测试结果,定期评估和选择更安全、更高效的加密算法。例如,从 AES-CBC 升级到 AES-CTR等升级。
  • 在算法升级过程中,充分考虑性能和安全性的平衡。例如,对于高频请求的超过 x MB的图片,优化加密算法的执行效率,减少加密和解密的延迟。
  • 算法迭代与混淆
  • 为了增加破解难度,我们采用算法迭代和混淆技术。每次版本更新时,不仅更换加密算法,还会对算法的实现逻辑进行混淆和变形。例如,通过引入随机的加密参数(如随机初始化向量 IV 或随机盐值 Salt),使得相同的图片在不同请求中生成不同的加密结果。
  • 快速迭代与灰度发布
  • 采用快速迭代策略,每 1-2 周更新一次加密版本。通过灰度发布机制,逐步将新版本推向生产环境,确保系统的稳定性和兼容性。

  • 在灰度发布期间,通过白名单测试评估新版本的性能和安全性,及时发现并修复潜在问题。

4.3 安全性与性能优化

  • 安全性提升
  • 通过 CDN 边缘加密技术,漫画图片在传输和存储过程中具备了极高的安全性。加密后的图片无法被直接读取,有效杜绝了非法站点的盗取行为,保护了版权所有者的合法权益。同时,复杂的加密算法和快速迭代的加密版本,使得非法站点难以突破防线,降低了版权侵犯事件的发生频率。
  • 定期更新盐值:为了防止盐值被破解,需要定期更新盐值。
  • 加密算法选择:不断更新安全的加密算法(如 AES等变种算法),确保图片内容的安全性。
  • 运行环境检测:在图片解密节点植入环境检测和爬虫检测代码,通过分析设备硬件信息、操作系统版本、浏览器类型等,构建多维度的环境画像,判断是否为异常请求。
  • 接口鉴权与魔改 MD5 加密:通过在接口层面进行加密处理,增加破解难度。
  • 性能优化成果

在性能方面,通过优化大图处理流程和配置境外加速服务,错误率从 0.34% 降低至 0.04%,显著提升了服务的可靠性和用户体验。具体数据如下:

  • 优化大图处理流程:由于 CDN 边缘函数对图片读取有限制,通过细粒度控制文件流的方式对图片进行加密,减少处理时间。

图片

  • 配置境外加速服务:解决境外节点回源超时问题,提高境外用户的访问速度和稳定性。

  • 公私钥阶段性生成:减轻客户端计算负担,分阶段生成公私钥对,避免频繁生成和存储大量密钥。

图片

4.4 优势与挑战

  • 优势
  • 高安全性:动态加密机制能够根据实际情况动态调整加密策略,有效应对各种安全威胁。
  • 灵活性:可以根据不同的场景和需求灵活选择加密算法和参数。
  • 抗攻击能力:定期更换加密算法和密钥,增加了破解难度。
  • 挑战
  • 性能开销:动态加密机制可能会带来一定的性能开销,尤其是在密钥管理和加密算法切换过程中。

  • 同步机制:加密和解密端之间的同步机制需要高度可靠,否则可能导致数据无法正确解密。

  • 复杂性:动态加密机制的设计和实现较为复杂,需要考虑多种因素。

5. WEB端解密与防御

在抵御盗版之路上,我们针对 Web 前端的防御技术进行了一系列的探索和实践,包括代码混淆、加密、运行环境检测以及爬虫检测,不断提升图片加解密的安全性与可靠性。

5.1 解密代码的 jsvmp 混淆

  • 原理:

JSVMP混淆将通过将 JavaScript 代码转换为一种复杂、难以理解的形式,增加逆向工程的难度。它利用虚拟机技术,将原始代码的逻辑抽象为虚拟指令集,对函数、变量名进行随机化重命名,改变代码结构,将线性代码转换为基于跳转表、控制流平坦化等复杂结构,使得攻击者难以直接从混淆后的代码中梳理出原始业务逻辑。

  • 设计思路:

构建自定义的混淆工具链,在代码编译或打包阶段介入。首先,对代码进行词法分析与语法解析,识别函数、变量、语句块等元素。然后,按照预设的混淆规则,如将局部变量名替换为无意义的短字符串序列,函数名采用哈希值表示,同时引入虚假的控制流分支,误导逆向分析人员。

确保混淆后的代码能正常运行,在虚拟环境中实现即时编译器(JIT),负责将虚拟指令转换为实际可执行的机器码,在运行时动态还原代码逻辑,同时隐藏真实的执行路径。

图片

  • 具体功能:
  1.  代码解析与转换工具:

a.  解析代码生成 AST,遍历 AST 节点进行混淆操作,将函数参数随机化模块,实现思路即在遍历函数参数节点时,记录原始参数顺序,然后按照随机序列重新赋值,同时在虚拟环境内部维护一个映射表;

  1.  虚拟指令生成器:

a.  在创建虚拟指令模块时,定义了一个虚拟指令对象,包含各类自定义操作码及对应的处理函数;

  1.  加密与解密代码模块:

a.  采用 crypto 模块实现 AES 和 RSA 混合加密。首先使用 AES 对代码文件进行对称加密,密钥随机生成并通过 RSA 公钥加密后存储在文件头部或单独的密钥管理文件中;

b.  运行时,虚拟环境内利用存储的 RSA 私钥解密获取 AES 密钥,进而解密加载代码;

c.  对于敏感数据编码隐藏,使用简单的异或编码作为示例,在特定上下文激活时解码;

  • 最终效果:

 盗版下载器的作者表示:

图片

应用 jsvmp 混淆后,成功抵御web端图片加解密的破解,暂未出现被破解的情况,有效保护了核心业务逻辑,减少因代码泄露导致的潜在风险。

5.1.1 接口鉴权以及魔改 md5 加密

  1.  原理:
  • MD5 通过合理魔改对输入数据进行预处理,如添加盐值(随机字符串)、对数据进行分段多次哈希、结合密钥进行异或操作等来增加了破解难度;
  1.  设计思路:
  • 在接口层面,针对传入和传出的数据进行加密处理。在服务端,定义加密模块,当发起web端请求时,按照魔改 MD5 规则进行加密作为新增的请求参数。
  • 在CDN层面会对该魔改后的MD5进行校验,防止有人模拟接口向CDN请求图片数据;
  1.  具体功能
  • 获取当前时间戳与接口参数组合成盐值,接着对原始数据与盐值拼接后的字符串进行简单的字符打乱预处理,最后送入 MD5 计算函数得到加密结果

  • 加密工具封装:将魔改后的 MD5 加密算法封装成独立的工具函数,并进行代码混淆,最终只对外提供简洁统一的接口。

5.1.2 运行环境检测和爬虫检测

  • 在图片解密节点,植入环境检测和爬虫检测代码。
  • 通过获取设备硬件信息(如 CPU 型号、GPU 标识、内存特征等)、操作系统版本、浏览器类型及版本、网络环境(IP 地址归属、网络延迟等),构建多维度的环境画像对比。
  • 分析 HTTP 请求头信息,如 User-Agent 是否伪造、Referer 是否异常,综合判断是否为爬虫流量
  • 若检测到异常,图片不应被解密成明文渲染,弹出警告但不提示具体信息,迷惑攻击人员;同时记录异常日志并上报至安全监控系统,以便后续分析与响应。

6. 实际应用与成果评估

6.1 实际应用

本方案实践已在bilibili漫画平台进行了实际应用,取得了显著的效果。通过部署 CDN 边缘函数和加密技术,有效遏制了盗版行为的泛滥。

稳定性:近一月数据请求数超60亿次,成功占比超99.99%,其他问题占比<0.01%,符合业务要求;

图片

请求返回状态码占比:

图片

6.2 成果评估

6.2.1 下载器治理结果

  • 通过图片加解密不断更新与攻防打击下载器以及浏览器插件

  • 用户惩罚: 截止12月11日,累计处理799个下载器用户,如下图所示

图片

  • 阅读数据: TOP100用户,By月天均阅读图片数大幅下降。间接导致各漫画售卖行为减少

  • 业界意识: 用户认知到,使用下载器即意味着被封禁;开发者认知到,B站不欢迎开发下载器,有法律风险

图片

6.2.2 打击盗版网站结果

以某Web站情况为样本

  • DAU:58.8W~88.2W,其内容被包子、拷贝、思思漫画等站点进一步搬运扩散,该站点12月4日停更。

  • 侵权规模:B漫具有独立版权且有明确权利文件的771部作品中,有580部在colamanga上出现,达到刑事立案标准。

  • 侵权追踪:上线加密后该站点上出现的盗版漫画,经过水印溯源到大部分账号。

  • 当前状态:基本完成侵权主体挖掘,能够主动做出防御手段。

6.2.3 总结

  • 提高安全性:加密后的图片文件在未经过特定解密程序的情况下无法被直接读取,有效杜绝了非法站点对图片资源的盗取和滥用。

  • 保护漫画生态:加密技术的应用维护了正版漫画平台的市场地位,保障了平台的商业利益,形成了良性循环的产业生态环境。

  • 打击盗版网站:通过加密技术的应用,显著降低了盗版网站的活跃度。例如,某盗版网站的 DAU 从 88.2万降至 58.8 万,侵权规模大幅减少。

  • 用户与开发者认知:用户意识到使用下载器将面临封禁风险,开发者也认识到开发下载器存在法律风险,从而有效遏制了盗版行为的滋生。

7. 加解密方案面临的挑战

7.1 技术破解风险

尽管加密技术可以有效提高漫画内容的安全性,但仍然存在被破解的风险。从捕获到的信息来看,盗版者仍在尝试破解我方加密传输协议,通过技术手段分析加密算法和密钥管理机制,可能找到破解的漏洞。在早期版本中因加密算法过于简单而被破解,导致盗版内容再次出现,我们仍保持复杂的加密算法和快速更新的加密版本,使得非法站点难以突破防线,降低了版权侵犯事件的发生频率。

7.2 性能与用户体验

加密技术的应用可能会对漫画内容的加载速度和用户体验产生影响。加密和解密过程需要消耗一定的计算资源,可能导致图片加载延迟。此外,加密技术的复杂性也可能增加客户端的计算负担,后续会尝试使用WebAssembly来优化性能。

8. 结论

本文介绍的加密技术方案在漫画产业中得到了成功应用,有效提升了漫画内容的安全性,减少了盗版带来的损失。通过同步加密规则、CDN 边缘函数、椭圆曲线加密算法等技术手段,构建了一个安全、高效的版权保护体系。同时,通过安全性与性能优化措施,确保了用户体验的提升。未来,我们将继续探索更先进的技术手段,进一步完善版权保护机制,为漫画产业的健康发展保驾护航。

-End-

作者丨雁回

Node.js 如何检测 script 脚本是在项目本身运行

作者 Legend80s
2025年4月15日 11:34

假设有一个脚本 check.mjs 如何检测它自己是在自身项目内运行?

💭 背景

postinstall 运行时机有两个:被其他项目安装 npm i foo 或自己项目运行 npm i。如果想让自己项目安装时不运行或者做一些特殊操作,则需要检测脚本是否被自身运行。

假设有 package.json

{
  "scripts": {
     "postinstall": "node ./scripts/check.mjs"
  }
}

check.mjs 的 isRunInItself 如何写?有三种方式:

📁 方式 1:cwd 和 dirname 比较

原理:cwd 为运行时路径,dirname 为其磁盘路径,如果脚本 check.mjs 被自身运行,则二者相同,否则不同。

假设 check.mjs 的路径为 /temp/foo/check.mjs,它被 bar 项目依赖,bar 路径为 /workspace/bar

自身运行
  • cwd: /temp/foo/
  • dirname: /temp/foo/
被安装后运行
  • cwd: /temp/foo/node_modules/foo
  • dirname: /workspace/bar

故代码可以这样写:

// check.mjs
/**
 * @returns {boolean}
 */
function isRunInItself() {
  // 获取当前工作目录
  const currentDir = process.cwd()

  // 获取 foo 包的根目录
  const __dirname = import.meta.dirname
  const fooRootDir = path.resolve(__dirname, '..')

  if (currentDir === fooRootDir) {
    log('正在本包目录安装,跳过检测...')

    return true
  }

  return false
}

📦 方式 2:检测当前项目 package.json name

原理:检查当前运行目录的 package.json name

/**
 * @returns {boolean}
 */
function isRunInItself() {
  // so if name in package.json in the current dir is @neural/utils then skip check
  if (fs.existsSync('./package.json')) {
    const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf-8'))

    if (packageJson.name === PACKAGE_NAME) {
      // log('Skip check because package name is @neural/utils')

      return true
    }
  }

  return false
}

读取 json 也可以直接用更高级的写法,利用 Node.js v18.20.0 引入的 import attributes

const { default: packageJson } = await import('./package.json'), { with: { type: 'json' } })

// 如果只需要 name 可以再解构下
const { default: { name } } = await import('./package.json'), { with: { type: 'json' } }) 

方式 3:🗝️ 环境变量获取 package.json name

原理:利用 Node.js 鲜为人知的一个隐藏知识点。Node.js 在运行时会将 package.json 的字段注入环境变量。

Node.js 在运行时会将 package.json 文件中的字段注入到环境变量中。例如,如果 package.json 文件中包含以下内容:

{
  "name": "foo",
  "version": "1.2.5"
}

在运行时,Node.js 会将以下环境变量设置为:

  • npm_package_name 为 "foo"
  • npm_package_version 为 "1.2.5"

这些环境变量可以在脚本中通过 process.env 访问[9]

—— 官方文档

// package.json
{
  "name": "foo"
  "scrpts": {
    "say:name": "echo What is the pkg name? $npm_package_name"
  }
}

npm run say:name

❯ npm run say:name 

> foo@0.0.20 say:name
> echo What is the pkg name? $npm_package_name

What is the pkg name? foo

注意:如果是 Windows 则环境变量需要改成 %npm_package_name%

bun 兼容性是真的好,Windows 下也无需改。bun say:name

❯ bun say:name 
$ echo What is the pkg name? $npm_package_name
What is the pkg name? foo

那检测逻辑怎么写呢。有两种写法。

  1. 直接在 package.json 中判断(但是兼容性不好)

Linux:

// package.json
"postinstall": "[ $npm_package_name = foo ] && echo '被自身运行无需 check' || echo 被其他项目安装后执行"

Windows:

// package.json
"postinstall": "[ %npm_package_name% = foo ] && echo '被自身运行无需 check' || echo 被其他项目安装后执行"
  1. 写到 Node.js 脚本。
/**
 * @returns {boolean}
 */
function isRunInItself() {
  return process.env.npm_package_name === 'foo'
}

🧠 总结

最严谨是用方法1,最简单则使用方法3。

面试官:说一下什么是 BFC 吧

作者 JacksonChen
2025年4月15日 11:27

前言

在前端开发中,bfc可能大多数人都听说过,但是真正去表述这个概念时,总是描述的不是很清楚,我也有一样的困惑。于是带着这个困惑下了这篇文章,希望可以帮助到大家,一起来看看吧~

定义

区块格式化上下文(Block Formatting Context,BFC)是 Web 页面的可视 CSS 渲染的一部分,是块级盒子的布局过程发生的区域,也是浮动元素与其他元素交互的区域。

定义估计看不懂,我们直接看下面的栗子

好处

首先,我们先做一个demo,来形成以下的三个好处,然后使用bfc来解决这些问题

<style>
      .outer {
        width: 300px;
        background-color: #333;
      }
      .inner {
        width: 100px;
        height: 100px;
      }
      .inner:nth-child(1) {
        background-color: #f00;
      }
      .inner:nth-child(2) {
        background-color: #0f0;
      }
      .inner:nth-child(3) {
        background-color: #00f;
      }
    </style>
  </head>
  <body>
    <div class="outer">
      <div class="inner"></div>
      <div class="inner"></div>
      <div class="inner"></div>
    </div>
  </body>

我们看到正常情况是这个样式,然后开启margin塌陷,给inner加上 margin: 20px;,如图所示,造成了margin 塌陷,然后我们分别使用下面的方法来解决一下,剩余两个好处一样,就不展开举例了。大家可以自己下去试试。

好处1. 开启bfc后,不会产生margin塌陷问题,什么是margin塌陷可以看我的这篇文章(父元素开启)

好处2. 开启bfc后,自己不会被其他浮动元素所覆盖(自身开启)

//自身开启
.inner:nth-child(1) {
  background-color: #f00;
  float: left;
}
//其他浮动元素
.inner:nth-child(2) {
  background-color: #0f0;
  float: left;
}

好处3. 开启bfc后,就算其子元素开启浮动,自身高度也不会塌陷(父元素开启)

//父元素开启
.outer {
  width: 300px;
  background-color: #333;
  float: left;
}
//子元素浮动
.inner {
  width: 100px;
  height: 100px;
  margin: 20px;
  float: left;
}

如何开启

根元素

html 元素,自带开启 bfc

浮动元素

.outer {
        width: 300px;
        background-color: #333;
        float: left;
      }

给元素增加float之后也开启了bfc,如图所示

绝对定位、固定定位的元素

.outer {
        width: 300px;
        background-color: #333;
        position: absolute;
      }

给元素增加定位之后也开启了bfc,如图所示

行内块元素

.outer {
        width: 300px;
        background-color: #333;
        display: inline-block;
      }

元素增加行内块之后也开启了bfc,如图所示

表格单元格

table、thead、tbody....

overflow的值不为visible的块元素

flex伸缩项目

给outer父元素设置display:flex即可

所列容器

设置 column-count: 1;即可

Colum-span为all的元素

disable的值设置为flow-root(最推荐)

怎么回答这个问题

每每面试问道这个问题时,确实不好回答,大家可以按这个思路来回答:

解释:可以理解为是元素的一个特殊功能,默认情况下是关闭的,当满足特定条件下将会被激活,也就是该元素开启了bfc,所以现在有两个问题,一是激活有什么用,二是具体怎么激活,我们继续来看!

  1. 然后可以说一下开启bfc的好处,就是上面说的三点好处。
  2. 最后说一下解决方案就行了,其余的不用说太多了。

总结

在众多的bfc的解决办法中,都有一些副作用,display:flow-root是最为推荐的,在实际工作中也不用过于追求实现bfc,就可以在遇到问题的时候再使用bfc的解决方案来解决即可,更多的大家可以理解bfc的概念,也方便在面试中可以更好的去回答这个问题。

Vue 3 + quasar 2 E2E测试

作者 __M__
2025年4月15日 11:05

框架与库

  • 使用 TypeScript 作为主要开发语言
  • 使用 Vue 3 + Composition API
  • 使用 quasar 2.15.1
  • 测试工具库:cypress

需要安装的依赖

  • @quasar/quasar-app-extension-testing-e2e-cypresst // quasar测试集成
  • @cypress/code-coverage // 覆盖率
  • cypress // 测试库
  • eslint-plugin-cypress // TS

根目录配置(cypress.config.ts)

import registerCodeCoverageTasks from '@cypress/code-coverage/task'
import { injectQuasarDevServerConfig } from '@quasar/quasar-app-extension-testing-e2e-cypress/cct-dev-server'
import { defineConfig } from 'cypress'

export default defineConfig({
  fixturesFolder: 'test/cypress/fixtures', // 测试数据存储路径
  screenshotsFolder: 'test/cypress/screenshots',  // 截图存储路径
  videosFolder: 'test/cypress/videos', // 视频存储路径
  projectId: 'ecb187bc-5198-45a2-8da8-1c418e87363d', // Cypress Cloud项目标识符
  video: true, // 启用测试过程录屏
  e2e: {
    setupNodeEvents(on, config) { // 注册代码覆盖率检测插件
      registerCodeCoverageTasks(on, config)
      return config
    },
    baseUrl: 'http://localhost:8080/', // 被测应用基础地址(本地开发服务器)
    supportFile: 'test/cypress/support/e2e.ts', // 测试支撑文件路径
    specPattern: 'test/cypress/e2e/**/*.cy.{js,jsx,ts,tsx}', // 测试文件匹配模式(匹配test/cypress/e2e目录下的.cy.ts文件)
  },
  component: { // 组件测试(component)配置
    setupNodeEvents(on, config) { // 注入Quasar框架的Vite开发服务器配置
      registerCodeCoverageTasks(on, config)
      return config
    },
    supportFile: 'test/cypress/support/component.ts',
    specPattern: 'src/**/*.cy.{js,jsx,ts,tsx}', // 组件测试文件直接扫描src目录下的.cy.ts文件
    indexHtmlFile: 'test/cypress/support/component-index.html', // 自定义组件测试的HTML模板
    devServer: injectQuasarDevServerConfig(),
  },
})

测试用例编写

目录结构
├─ test
│  ├─ cypress
│  │  ├─ e2e // 测试文件
│  │  │  ├─ home.cy.ts
│  │  │  └─ order.cy.ts
│  │  ├─ fixtures // 环境
│  │  │  └─ example.json
│  │  ├─ screenshots // 截图
│  │  ├─ support // 支撑文件存放位置
│  │  │  ├─ commands.ts
│  │  │  ├─ component-index.html
│  │  │  ├─ component.ts
│  │  │  └─ e2e.ts
│  │  ├─ tsconfig.json
│  │  ├─ videos // 视频
│  │  └─ wrappers
│  │     ├─ DialogWrapper.vue
│  │     └─ LayoutContainer.vue

常用API

一、元素操作

cy.get(selector)

通过选择器获取元素

cy.get('#submit-btn')

cy.contains(text)

获取包含文本的元素

cy.contains('登录')

.click()

点击元素

cy.get('button').click()

.type(text)

输入文本

cy.get('input').type('Hello')

.clear()

清空输入框

cy.get('input').clear()

.check() / .uncheck()

勾选/取消复选框

cy.get('[type="checkbox"]').check()

.select(value)

选择下拉框选项

cy.get('select').select('option1')

.trigger(event)

触发DOM事件

cy.get('div').trigger('mouseover')

二、导航与路由

cy.visit(url)

访问页面

cy.visit('/login')

cy.go(direction)

浏览器前进/后退

cy.go('back')

cy.reload()

重新加载页面

cy.reload(true)

cy.intercept(method, url)

拦截网络请求

cy.intercept('GET', '/api/data').as('getData')
cy.wait('@getData').its('response.statusCode').should('eq', 200)

三、断言与验证

.should(chainers)

断言元素状态

cy.get('h1').should('have.text', 'Welcome')
cy.get('.list').should('have.length', 5)

cy.url()

验证当前URL

cy.url().should('include', '/dashboard')

cy.title()

验证页面标题

cy.title().should('eq', 'Home')

cy.wrap(value)

包装对象进行断言

cy.wrap({ name: 'John' }).its('name').should('eq', 'John')

四、调试与日志

cy.log(message)

输出日志

cy.log('正在执行登录操作')

cy.pause()

暂停测试

cy.pause()

cy.debug()

进入调试模式

cy.get('input').debug()

cy.screenshot()

截取屏幕

cy.screenshot('login-page')

五、文件与数据

cy.fixture(filePath)

加载测试数据

cy.fixture('user.json').then((user) => {
  cy.get('input').type(user.name)
})

cy.readFile(path)

读取本地文件

cy.readFile('cypress/fixtures/data.txt')

cy.writeFile(path, content)

写入文件

cy.writeFile('logs.txt', '测试完成')

六、浏览器控制

cy.viewport(width, height)

设置视口尺寸

cy.viewport('iphone-6') // 预设设备
cy.viewport(1024, 768) // 自定义尺寸

cy.scrollTo(position)

滚动页面

cy.scrollTo('bottom')

cy.clearCookies()

清除Cookies

cy.clearCookies()

七、钩子函数

before(() => {})

所有测试前执行

before(() => cy.resetDatabase())

beforeEach(() => {})

每个测试前执行

beforeEach(() => cy.login())

afterEach(() => {})

每个测试后执行

afterEach(() => cy.screenshot())

after(() => {})

所有测试后执行

after(() => cy.clearCookies())

常用技巧

  • 链式调用

    cy.get('form') .find('input') .first() .type('test@example.com')

  • 自定义命令

    Cypress.Commands.add('login', (email, password) => { cy.visit('/login') cy.get('#email').type(email) cy.get('#password').type(password) cy.get('form').submit() }) // 测试中使用 cy.login('user@test.com', 'pass123')

  • 动态等待

    cy.get('.loading', { timeout: 10000 }).should('not.exist')

一个前端面试官的思考:当Promise遇上真实业务场景

作者 叶小秋
2025年4月15日 10:56

前言

作为一名前端面试官,我经常遇到这样的候选人:聊 Promise API 的时候说的很溜,我感觉我都没他这么溜,然后再问实际的 Promise 业务场景的时候就不会说了。今天我想分享一个常见的面试题:如何实现并发控制?

面试现场

"你对Promise熟悉吗?" "当然!Promise.all、Promise.race、Promise.any..." "好的,那我们来看一个实际场景..."

场景:批量上传文件

"假设用户要上传100张图片,但是服务器限制同时最多只能处理5个请求,你会怎么处理?"

很多候选人开始慌了:

  • "用Promise.all?"
  • "for循环发请求?"
  • "递归调用?"

问题的本质

其实这类问题的核心是:并发控制。

不是考察对Promise API的记忆,而是考察:

  1. 对异步任务的理解
  2. 对并发控制的认知
  3. 对实际业务场景的处理能力

怎么解决

让我们实现一个通用的并发控制队列:

export class TaskQueue {
  private queue: (() => Promise<any>)[] = [];
  private activeCount = 0;
  private maxConcurrent: number;

  constructor(maxConcurrent: number) {
    this.maxConcurrent = maxConcurrent;
  }

  public get pending(): number {
    return this.queue.length;
  }

  public get active(): number {
    return this.activeCount;
  }

  public clear(): void {
    this.queue = [];
  }

  private next() {
    if (this.queue.length === 0 || this.activeCount >= this.maxConcurrent) {
      return;
    }
    const task = this.queue.shift();
    if (task) {
      this.activeCount++;
      task().finally(() => {
        this.activeCount--;
        this.next();
      });
    }
  }

  public add<T>(fn: () => Promise<T>): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      const task = async () => {
        try {
          resolve(await fn());
        } catch (error) {
          reject(error);
        }
      };
      this.queue.push(task);
      this.next();
    });
  }
}

代码解析

  • 类的属性
    • queue : 存储待执行的任务队列
    • activeCount : 当前正在执行的任务数量
    • maxConcurrent : 最大并发数
  • 核心方法
    • add : 添加新任务到队列
    • next : 执行下一个任务
    • clear : 清空任务队列
    • pending : 获取待执行任务数量
    • active : 获取当前执行中的任务数量

示例

// 创建队列实例,最大并发数为2
const queue = new TaskQueue(2);

// 模拟异步任务
const createTask = (id: number) => {
  return () => new Promise<string>((resolve) => {
    const duration = Math.random() * 2000;
    setTimeout(() => {
      console.log(`Task ${id} completed`);
      resolve(`Task ${id} result`);
    }, duration);
  });
};

// 添加任务
async function runTasks() {
  console.log('开始执行任务');
  
  // 添加5个任务
  for (let i = 1; i <= 5; i++) {
    queue.add(createTask(i))
      .then(result => console.log(result));
    
    console.log(`Task ${i} added, pending: ${queue.pending}, active: ${queue.active}`);
  }
}

runTasks();

总结

通过这个例子,我们可以看到: 知道API的使用,并不意味着你就会用。但更重要的是理解它能解决什么实际问题

希望这篇文章对你有帮助!如果你有任何问题或建议,欢迎在评论区讨论。

father-build支持sourceMap

2025年4月15日 10:56

仅个人记录

简易版father-build支持sourceMap

rollup方式打包

image.png

image.png

import { ModuleFormat, rollup, watch } from "rollup";
import signale from "signale";
import chalk from "chalk";
import getRollupConfig from "./getRollupConfig";
import { Dispose, IBundleOptions } from "./types";
import normalizeBundleOpts from "./normalizeBundleOpts";

interface IRollupOpts {
  cwd: string;
  rootPath?: string;
  entry: string | string[];
  type: ModuleFormat;
  log: (string) => void;
  bundleOpts: IBundleOptions;
  watch?: boolean;
  dispose?: Dispose[];
  importLibToEs?: boolean;
}

async function build(entry: string, opts: IRollupOpts) {
  const { cwd, rootPath, type, log, bundleOpts, importLibToEs, dispose } = opts;
  const rollupConfigs = getRollupConfig({
    cwd,
    rootPath: rootPath || cwd,
    type,
    entry,
    importLibToEs,
    bundleOpts: normalizeBundleOpts(entry, bundleOpts),
  });

  for (const rollupConfig of rollupConfigs) {
    if (opts.watch) {
      const watcher = watch([
        {
          ...rollupConfig,
          watch: {},
        },
      ]);
      await new Promise<void>((resolve) => {
        watcher.on("event", (event) => {
          // 每次构建完成都会触发 BUNDLE_END 事件
          // 当第一次构建完成或出错就 resolve
          if (event.code === "ERROR") {
            signale.error(event.error);
            resolve();
          } else if (event.code === "BUNDLE_END") {
            log(
              `${chalk.green(`Build ${type} success`)} ${chalk.gray(
                `entry: ${entry}`
              )}`
            );
            resolve();
          }
        });
      });
      process.once("SIGINT", () => {
        watcher.close();
      });
      dispose?.push(() => watcher.close());
    } else {
      const { output, ...input } = rollupConfig;
      const bundle = await rollup(input); // eslint-disable-line
      await bundle.write({
        ...output,
        sourcemap: true,
      }); // eslint-disable-line
      log(
        `${chalk.green(`Build ${type} success`)} ${chalk.gray(
          `entry: ${entry}`
        )}`
      );
    }
  }
}

export default async function(opts: IRollupOpts) {
  if (Array.isArray(opts.entry)) {
    const { entry: entries } = opts;
    for (const entry of entries) {
      await build(entry, opts);
    }
  } else {
    await build(opts.entry, opts);
  }
  if (opts.watch) {
    opts.log(chalk.magentaBright(`Rebuild ${opts.type} since file changed 👀`));
  }
}

babel打包方式

image.png

image.png

image.png

image.png

image.png

import { join, extname, relative } from "path";
import { existsSync, readFileSync, statSync } from "fs";
import vfs from "vinyl-fs";
import signale from "signale";
import lodash from "lodash";
import rimraf from "rimraf";
import through from "through2";
import slash from "slash2";
import * as chokidar from "chokidar";
import * as babel from "@babel/core";
import gulpTs from "gulp-typescript";
import gulpLess from "gulp-less";
import gulpPlumber from "gulp-plumber";
import gulpIf from "gulp-if";
import chalk from "chalk";
import getBabelConfig from "./getBabelConfig";
import { Dispose, IBundleOptions } from "./types";
import * as ts from "typescript";
import * as sourcemaps from "gulp-sourcemaps";

interface IBabelOpts {
  cwd: string;
  rootPath?: string;
  type: "esm" | "cjs";
  target?: "browser" | "node";
  log?: (string) => void;
  watch?: boolean;
  dispose?: Dispose[];
  importLibToEs?: boolean;
  bundleOpts: IBundleOptions;
}

interface ITransformOpts {
  file: {
    contents: string;
    path: string;
  };
  type: "esm" | "cjs";
}

export default async function(opts: IBabelOpts) {
  const {
    cwd,
    rootPath,
    type,
    watch,
    dispose,
    importLibToEs,
    log,
    bundleOpts: {
      target = "browser",
      runtimeHelpers,
      extraBabelPresets = [],
      extraBabelPlugins = [],
      browserFiles = [],
      nodeFiles = [],
      nodeVersion,
      disableTypeCheck,
      cjs,
      lessInBabelMode,
    },
  } = opts;
  const srcPath = join(cwd, "src");
  const targetDir = type === "esm" ? "es" : "lib";
  const targetPath = join(cwd, targetDir);

  log(chalk.gray(`Clean ${targetDir} directory`));
  rimraf.sync(targetPath);

  function transform(opts: ITransformOpts, source) {
    const { file, type } = opts;
    const { opts: babelOpts, isBrowser } = getBabelConfig({
      target,
      type,
      typescript: true,
      runtimeHelpers,
      filePath: slash(relative(cwd, file.path)),
      browserFiles,
      nodeFiles,
      nodeVersion,
      lazy: cjs && cjs.lazy,
      lessInBabelMode,
      cwd,
    });
    if (importLibToEs && type === "esm") {
      babelOpts.plugins.push(require.resolve("../lib/importLibToEs"));
    }
    babelOpts.presets.push(...extraBabelPresets);
    babelOpts.plugins.push(...extraBabelPlugins);

    const relFile = slash(file.path).replace(`${cwd}/`, "");
    log(
      `Transform to ${type} for ${chalk[isBrowser ? "yellow" : "blue"](
        relFile
      )}`
    );

    const content = file.contents;
    const path = file.path;
    if (source) {
      return JSON.stringify(
        babel.transform(content, {
          ...babelOpts,
          sourceMaps: true,
          filename: file.path,
        }).map
      );
    }

    let filename = path.split("/").pop();
    filename = filename.replace(".ts", ".js");
    let code = babel.transform(content, {
      ...babelOpts,
      filename: path,
    }).code;

    code = code + "\n" + `//# sourceMappingURL=${filename}.map`;

    console.log(path);
    return code;

    return babel.transform(file.contents, {
      ...babelOpts,
      filename: file.path,
      // 不读取外部的babel.config.js配置文件,全采用babelOpts中的babel配置来构建
      configFile: false,
    }).code;
  }

  /**
   * tsconfig.json is not valid json file
   * https://github.com/Microsoft/TypeScript/issues/20384
   */
  function parseTsconfig(path: string) {
    const readFile = (path: string) => readFileSync(path, "utf-8");
    const result = ts.readConfigFile(path, readFile);
    if (result.error) {
      return;
    }
    const pkgTsConfig = result.config;
    if (pkgTsConfig.extends) {
      const rootTsConfigPath = slash(relative(cwd, pkgTsConfig.extends));
      const rootTsConfig = parseTsconfig(rootTsConfigPath);
      if (rootTsConfig) {
        const mergedConfig = {
          ...rootTsConfig,
          ...pkgTsConfig,
          compilerOptions: {
            ...rootTsConfig.compilerOptions,
            ...pkgTsConfig.compilerOptions,
          },
        };
        return mergedConfig;
      }
    }
    return pkgTsConfig;
  }

  function getTsconfigCompilerOptions(path: string) {
    const config = parseTsconfig(path);
    return config ? config.compilerOptions : undefined;
  }

  function getTSConfig() {
    const tsconfigPath = join(cwd, "tsconfig.json");
    const templateTsconfigPath = join(__dirname, "../template/tsconfig.json");
    console.log("cwd===", cwd, "rootPath===", rootPath);

    if (existsSync(tsconfigPath)) {
      return getTsconfigCompilerOptions(tsconfigPath) || {};
    }

    if (rootPath && existsSync(join(rootPath, "tsconfig.json"))) {
      return getTsconfigCompilerOptions(join(rootPath, "tsconfig.json")) || {};
    }
    return getTsconfigCompilerOptions(templateTsconfigPath) || {};
  }

  function createStream(src, sourcemap) {
    const tsConfig = getTSConfig();
    const babelTransformRegexp = disableTypeCheck ? /\.(t|j)sx?$/ : /\.jsx?$/;

    function isTsFile(path) {
      return /\.tsx?$/.test(path) && !path.endsWith(".d.ts");
    }

    function isTransform(path) {
      return babelTransformRegexp.test(path) && !path.endsWith(".d.ts");
    }

    return vfs
      .src(src, {
        allowEmpty: true,
        base: srcPath,
      })
      .pipe(watch ? gulpPlumber() : through.obj())
      .pipe(
        gulpIf((f) => !disableTypeCheck && isTsFile(f.path), gulpTs(tsConfig))
      )
      .pipe(
        gulpIf(
          (f) => lessInBabelMode && /\.less$/.test(f.path),
          gulpLess(lessInBabelMode || {})
        )
      )
      .pipe(
        gulpIf(
          (f) => isTransform(f.path),
          through.obj((file, env, cb) => {
            try {
              file.contents = Buffer.from(
                transform(
                  {
                    file,
                    type,
                  },
                  sourcemap
                )
              );
              // .jsx -> .js
              if (sourcemap) {
                file.path = file.path.replace(extname(file.path), ".js.map");
              } else {
                file.path = file.path.replace(extname(file.path), ".js");
              }
              cb(null, file);
            } catch (e) {
              signale.error(`Compiled faild: ${file.path}`);
              console.log(e);
              cb(null);
            }
          })
        )
      )
      .pipe(vfs.dest(targetPath));
  }

  return new Promise((resolve) => {
    const patterns = [
      join(srcPath, "**/*"),
      `!${join(srcPath, "**/fixtures{,/**}")}`,
      `!${join(srcPath, "**/demos{,/**}")}`,
      `!${join(srcPath, "**/__test__{,/**}")}`,
      `!${join(srcPath, "**/__tests__{,/**}")}`,
      `!${join(srcPath, "**/*.mdx")}`,
      `!${join(srcPath, "**/*.md")}`,
      `!${join(srcPath, "**/*.+(test|e2e|spec).+(js|jsx|ts|tsx)")}`,
      `!${join(srcPath, "**/tsconfig{,.*}.json")}`,
      `!${join(srcPath, ".umi{,-production,-test}{,/**}")}`,
    ];
    createStream(patterns, false).on("end", () => {
      if (watch) {
        log(
          chalk.magenta(
            `Start watching ${slash(srcPath).replace(
              `${cwd}/`,
              ""
            )} directory...`
          )
        );
        const watcher = chokidar.watch(patterns, {
          ignoreInitial: true,
        });

        const files = [];
        function compileFiles() {
          while (files.length) {
            createStream(files.pop());
          }
        }

        const debouncedCompileFiles = lodash.debounce(compileFiles, 1000);
        watcher.on("all", (event, fullPath) => {
          const relPath = fullPath.replace(srcPath, "");
          log(
            `[${event}] ${slash(join(srcPath, relPath)).replace(`${cwd}/`, "")}`
          );
          if (!existsSync(fullPath)) return;
          if (statSync(fullPath).isFile()) {
            if (!files.includes(fullPath)) files.push(fullPath);
            debouncedCompileFiles();
          }
        });
        process.once("SIGINT", () => {
          watcher.close();
        });
        dispose?.push(() => watcher.close());
      }
      resolve();
    });
    createStream(patterns, true);
  });
}

UniApp HTTP 请求封装:优雅实现 503 自动重试

作者 wordbaby
2025年4月15日 10:41

介绍说明:

这是一个为 UniApp 框架设计的 增强型 HTTP 请求封装模块 (基于 uni.request),使用 TypeScript 编写。它的核心目标是简化网络请求的处理,并自动、优雅地处理后端服务临时不可用 (HTTP 503) 的情况,从而提高应用的健壮性和用户体验。

当你调用后端接口时,偶尔可能会遇到服务器因为维护、过载或其他临时性问题而返回 503 状态码。通常,这意味着稍后重试请求可能会成功。本模块会自动捕获 503 错误,并根据预设(或自定义)的次数和延迟时间进行自动重试,避免了在每个请求中手动编写重试逻辑的繁琐工作。

主要特性:

  • 🚀 503 自动重试: 无需手动干预,自动处理 503 Service Unavailable 错误,并在指定延迟后重试。
  • 🔧 配置灵活: 可以全局设置默认的重试次数和延迟时间,也可以在单次请求中覆盖这些配置。
  • ✨ Promise 化: 完全基于 Promise,完美支持 async/await 语法,让异步代码更清晰。
  • 💡 辅助函数: 提供 httpGet, httpPost, httpPut, httpDelete 等常用方法的快捷方式,简化调用。
  • 🛡️ 结构化错误处理: 失败时 reject 一个包含详细信息的 HttpRequestError 对象(包括消息、原始错误、状态码、是否网络错误等),便于精细化错误处理。
  • 💬 可控错误提示: 请求失败时默认弹出 uni.showToast 提示,但可通过选项 (hideErrorToast: true) 禁用,方便自定义UI反馈。
  • 🔒 类型安全: 使用 TypeScript 泛型 <T>,允许你指定期望的响应数据类型,在编译时提供更好的类型检查。
  • 🧩 独立纯净: 不依赖外部状态管理库(如 Pinia/Vuex),易于集成到任何 UniApp 项目中。

适用场景:

  • 任何需要与后端 API 交互的 UniApp 项目。
  • 希望提高应用对后端临时故障容错能力的应用。
  • 寻求更简洁、统一的网络请求处理方式的开发者。

如何使用:

将此模块(例如 request.ts)放置在你的项目工具(utils)目录下,然后在需要发起网络请求的页面或组件中导入 http 或其辅助函数 (httpGet, httpPost 等) 进行调用即可。

// 示例:
import { http } from '@/utils/request'; // 假设你放在 utils 目录下

async function fetchData() {
  try {
    const data = await http.get<MyDataType>('/api/data');
    console.log(data);
  } catch (error) {
    console.error('请求失败:', error);
    // 处理错误...
  }
}

完整代码:

// src/utils/request.ts (或其他你喜欢的文件路径)

// --- 配置常量 ---
const DEFAULT_MAX_RETRIES = 2; // 默认最大重试次数 (总尝试次数 = 1 + 重试次数)
const DEFAULT_RETRY_DELAY = 1000; // 默认两次重试之间的延迟时间 (毫秒)

// --- TypeScript 接口定义 ---

/**
 * 扩展 UniApp 的 RequestOptions,增加自定义的重试配置
 */
interface RetryRequestOptions extends UniApp.RequestOptions {
  /** 针对 503 错误的最大重试次数 (默认: 2) */
  retries?: number;
  /** 每次重试之间的延迟时间,单位毫秒 (默认: 1000) */
  retryDelay?: number;
  /** 设置为 true 可隐藏自动弹出的错误提示 Toast (默认: false) */
  hideErrorToast?: boolean;
}

/**
 * 定义一个结构化的 HTTP 请求错误对象,用于 Promise 的 reject
 */
interface HttpRequestError {
  /** 错误消息文本 */
  message: string;
  /** uni-app 返回的原始错误对象或请求失败的响应对象 */
  originalError: UniApp.GeneralCallbackResult | UniApp.RequestSuccessCallbackResult;
  /** 标志是否为网络连接错误 (即 fail 回调触发) */
  isNetworkError?: boolean;
  /** HTTP 状态码 (如果是非 2xx 的成功响应) */
  statusCode?: number;
}

// --- 核心 HTTP 请求函数 ---

/**
 * 发起一个 HTTP 请求 (基于 uni.request),并带有 503 自动重试逻辑。
 *
 * @template T - 期望的响应数据 `data` 的类型,默认为 `any`。
 * @param {RetryRequestOptions} options - 请求配置,包含 uni.request 的标准选项以及自定义的重试选项。
 * @returns {Promise<T>} - 返回一个 Promise。成功时 resolve 响应的 `data` 部分;失败时 reject 一个 `HttpRequestError` 对象。
 */
export const http = <T = any>(options: RetryRequestOptions): Promise<T> => {
  // 解构选项,分离出重试配置和标准的 uni.request 配置
  const {
    retries = DEFAULT_MAX_RETRIES,        // 获取重试次数,若未提供则使用默认值
    retryDelay = DEFAULT_RETRY_DELAY,     // 获取重试延迟,若未提供则使用默认值
    hideErrorToast = false,             // 获取是否隐藏错误提示的标志
    ...requestOptions                   // 剩余的选项作为 uni.request 的标准参数
  } = options;

  /**
   * 内部函数,用于执行单次请求尝试
   * @param currentAttempt - 当前是第几次尝试 (从 0 开始)
   */
  const attemptRequest = (currentAttempt: number): Promise<T> => {
    // 返回一个新的 Promise 来包装单次 uni.request 调用
    return new Promise<T>((resolve, reject) => {
      uni.request({
        ...requestOptions, // 传入标准的 uni.request 选项 (url, method, data, header 等)

        // 请求成功的回调 (HTTP 状态码不一定是 2xx)
        success(res) {
          // --- 成功情况 (状态码 200-299) ---
          if (res.statusCode >= 200 && res.statusCode < 300) {
            // 假设服务器返回的有效数据在 res.data 中
            resolve(res.data as T); // 使用 T 类型断言
            return; // 成功,退出回调
          }

          // --- 需要重试的情况 (状态码 503 且 未达到最大重试次数) ---
          if (res.statusCode === 503 && currentAttempt < retries) {
            const attemptNum = currentAttempt + 1; // 计算下一次尝试的序号
            console.warn(
              `[HTTP] 请求失败,状态码 503。将在 ${retryDelay}ms 后进行第 ${attemptNum}/${retries} 次重试... URL: ${requestOptions.url}`
            );
            // 设置延迟执行下一次尝试
            setTimeout(() => {
              // 递归调用 attemptRequest,并将结果通过 then/catch 传递给外层 Promise
              attemptRequest(attemptNum).then(resolve).catch(reject);
            }, retryDelay);
            return; // 等待重试,退出当前回调
          }

          // --- 其他失败情况 (非 2xx 状态码,或 503 重试次数已用尽) ---
          // 构造错误消息
          const errorMessage = `请求失败 [状态码: ${res.statusCode}]`;
          console.error(`[HTTP] 请求错误 ${res.statusCode}: ${requestOptions.url}`, res);
          // 如果用户没有选择隐藏提示,则显示 Toast
          if (!hideErrorToast) {
            uni.showToast({
              icon: 'none',
              // 尝试从响应体中获取更具体的错误信息,否则使用通用信息
              title: (res.data as any)?.message || errorMessage,
              duration: 2000,
            });
          }
          // 用结构化的错误对象 reject Promise
          reject({
            message: errorMessage,
            originalError: res, // 保留原始响应对象
            statusCode: res.statusCode, // 记录状态码
          } as HttpRequestError);
        },

        // 请求失败的回调 (网络层面的错误,例如 DNS 解析失败、网络不可达等)
        fail(err) {
          // --- 网络错误情况 ---
          const errorMessage = '网络连接错误,请检查网络设置或稍后重试';
          console.error(`[HTTP] 网络错误: ${requestOptions.url}`, err);
          // 如果用户没有选择隐藏提示,则显示 Toast
          if (!hideErrorToast) {
            uni.showToast({
              icon: 'none',
              title: errorMessage,
              duration: 2000,
            });
          }
          // 用结构化的错误对象 reject Promise
          reject({
            message: errorMessage,
            originalError: err, // 保留原始错误对象
            isNetworkError: true, // 标记为网络错误
          } as HttpRequestError);
        },

        // complete 回调在 success 或 fail 后都会执行,此处 Promise 的 resolve/reject 已处理最终状态,故无需操作
        // complete() { }
      });
    });
  };

  // 发起第一次请求尝试 (currentAttempt = 0)
  return attemptRequest(0);
};

// --- 便捷的辅助函数 ---

/** 定义辅助函数的选项类型,排除掉 url, method, data 这三个由辅助函数自身处理的参数 */
type HttpHelperOptions = Omit<RetryRequestOptions, 'url' | 'method' | 'data'>;

/**
 * 发起 GET 请求 (带 503 重试)
 * @template T - 期望的响应数据类型
 * @param url - 请求地址
 * @param data - 查询参数 (uni.request 中 GET 请求的 query 参数通常放在 data 对象里)
 * @param options - 其他配置,如 headers, retries, hideErrorToast 等
 */
export const httpGet = <T = any>(
  url: string,
  data?: UniApp.RequestOptions['data'],
  options?: HttpHelperOptions
): Promise<T> => {
  return http<T>({
    url,
    data, // 对于 GET, uni.request 将 data 作为 query 参数附加到 URL
    method: 'GET',
    ...options, // 合并其他选项
  });
};

/**
 * 发起 POST 请求 (带 503 重试)
 * @template T - 期望的响应数据类型
 * @param url - 请求地址
 * @param data - 请求体数据
 * @param options - 其他配置
 */
export const httpPost = <T = any>(
  url: string,
  data?: UniApp.RequestOptions['data'],
  options?: HttpHelperOptions
): Promise<T> => {
  return http<T>({
    url,
    data, // 请求体
    method: 'POST',
    ...options,
  });
};

/**
 * 发起 PUT 请求 (带 503 重试)
 * @template T - 期望的响应数据类型
 * @param url - 请求地址
 * @param data - 请求体数据
 * @param options - 其他配置
 */
export const httpPut = <T = any>(
  url: string,
  data?: UniApp.RequestOptions['data'],
  options?: HttpHelperOptions
): Promise<T> => {
  return http<T>({
    url,
    data, // 请求体
    method: 'PUT',
    ...options,
  });
};

/**
 * 发起 DELETE 请求 (带 503 重试)
 * @template T - 期望的响应数据类型
 * @param url - 请求地址
 * @param data - 查询参数或请求体 (根据后端 API 设计,DELETE 有时也用 data 传递参数)
 * @param options - 其他配置
 */
export const httpDelete = <T = any>(
  url: string,
  data?: UniApp.RequestOptions['data'],
  options?: HttpHelperOptions
): Promise<T> => {
  return http<T>({
    url,
    data,
    method: 'DELETE',
    ...options,
  });
};

// (可选) 将辅助函数附加到主 http 函数上,提供类似 axios 的调用风格
http.get = httpGet;
http.post = httpPost;
http.put = httpPut;
http.delete = httpDelete;

// --- 使用示例 ---
/*
// 定义一个接口来描述期望获取的数据结构
interface UserInfo {
  id: number;
  name: string;
  email?: string;
}

async function fetchUserData() {
  console.log('开始获取用户数据...');
  try {
    // 使用 http.get,并指定泛型类型为 UserInfo
    // 第二个参数是 GET 请求的查询参数
    // 第三个参数可以传递额外的配置,比如自定义请求头
    const userInfo = await http.get<UserInfo>(
      '/api/user/profile', // 假设的 API 地址
      { userId: 123 },     // 查询参数: /api/user/profile?userId=123
      {
        headers: { 'Authorization': 'Bearer YOUR_TOKEN' },
        // retries: 1, // 可以覆盖默认的重试次数
        // hideErrorToast: true, // 可以在单次调用中禁用 Toast
      }
    );
    console.log('成功获取用户数据:', userInfo);
    // 可以安全地访问 userInfo.id, userInfo.name 等属性

  } catch (error: any) {
    // 捕获错误,类型断言为我们定义的 HttpRequestError
    const err = error as HttpRequestError;
    console.error('获取用户数据失败:', err.message); // 输出友好的错误信息

    // 可以根据错误类型做进一步处理
    if (err.isNetworkError) {
      console.log('失败原因:网络连接问题。');
    } else {
      console.log(`失败原因:服务器返回状态码 ${err.statusCode}。`);
      // 可以查看原始错误对象获取更多细节
      // console.log('原始错误详情:', err.originalError);
    }
  }
}

async function updateUserProfile() {
  console.log('开始更新用户资料...');
  try {
    // 使用 http.post 发送数据
    const result = await http.post<{ success: boolean; message: string }>(
      '/api/user/update', // 假设的更新 API
      { name: '新名字', email: 'new@example.com' }, // 请求体数据
      { headers: { 'Authorization': 'Bearer YOUR_TOKEN' } }
    );
    console.log('更新结果:', result); // 查看服务器返回的结果

  } catch (error: any) {
    const err = error as HttpRequestError;
    console.error('更新用户资料失败:', err.message);
    // 处理更新失败的情况...
  }
}

// 调用示例函数
fetchUserData();
// updateUserProfile();

*/
  1. 核心 http 函数:

    • 封装了 UniApp 的 uni.request API。
    • 内置 503 重试: 当请求收到 503 Service Unavailable 状态码时,会自动进行重试。
    • 可配置: 你可以通过 options 对象自定义重试次数 (retries) 和重试间的延迟时间 (retryDelay)。
    • Promise 化: 返回 Promise,方便使用 async/await.then()/.catch() 处理异步操作。
    • 错误处理: 网络错误或最终失败(包括重试用尽后的 503 或其他 HTTP 错误)会 reject 一个包含详细信息的 HttpRequestError 对象。
    • 错误提示: 默认情况下,请求失败时会弹出 uni.showToast 提示,但可以通过 hideErrorToast: true 选项禁用。
    • 泛型支持: 使用 <T> 泛型,允许你指定期望的响应数据 data 的类型,增强代码类型安全。
  2. 辅助函数 (httpGet, httpPost, httpPut, httpDelete):

    • 基于核心 http 函数,为常见的 HTTP 方法(GET, POST, PUT, DELETE)提供了便捷的快捷方式。
    • 简化了调用,只需传入 URL、数据(可选)和额外配置(可选)。
  3. 独立性:

    • 这个封装是自包含的,不依赖外部的状态管理库(如 Pinia/Vuex)或全局计数器。
  4. 结构化错误对象 (HttpRequestError):

    • 当请求失败时,Promise 会被拒绝(reject),并传递一个 HttpRequestError 对象。这个对象包含了错误信息 (message)、原始的 uni-app 错误或响应对象 (originalError)、可选的状态码 (statusCode) 以及是否为网络错误的标志 (isNetworkError),方便进行更细致的错误处理。

总之,这个模块旨在提供一个更健壮、更易于使用的 HTTP 请求层,特别是在需要处理后端服务临时不可用(503)的情况下。

第七篇:【React 实战项目】从零构建企业级应用完全指南

2025年4月15日 10:39

手把手打造专业级 React 应用,产品经理再也挑不出毛病!

嘿,React 开发者们!经过前面六篇的系统学习,你已经掌握了 React 开发的核心知识和技巧。但理论终归是理论,今天我们要将所有这些知识点融会贯通,打造一个真正企业级的 React 应用!

让我们从零开始,一步步构建一个功能完整、架构优雅的项目管理系统,这将是你简历上的亮点项目!

1. 项目架构与技术选型

首先,让我们确定项目的技术栈和架构:

# 项目结构
project-management-system/
├── public/                 # 静态资源
├── src/
│   ├── assets/            # 图片、字体等资源
│   ├── components/        # 通用UI组件
│   │   ├── common/        # 基础组件
│   │   ├── layout/        # 布局组件
│   │   └── features/      # 业务组件
│   ├── config/            # 配置文件
│   ├── hooks/             # 自定义钩子
│   ├── pages/             # 页面组件
│   ├── services/          # API服务
│   ├── stores/            # 状态管理
│   ├── types/             # TypeScript类型
│   ├── utils/             # 工具函数
│   ├── App.tsx            # 应用入口
│   ├── index.tsx          # 渲染入口
│   └── routes.tsx         # 路由配置
├── .env.development       # 开发环境变量
├── .env.production        # 生产环境变量
├── .eslintrc.js           # ESLint配置
├── jest.config.js         # 测试配置
├── package.json           # 依赖管理
├── tsconfig.json          # TypeScript配置
└── vite.config.ts         # Vite配置

技术选型

// package.json 主要依赖
{
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router-dom": "^6.14.0",        // 路由管理
    "@tanstack/react-query": "^4.29.15",  // 服务端状态管理
    "zustand": "^4.3.8",                  // 客户端状态管理
    "axios": "^1.4.0",                    // HTTP请求
    "react-hook-form": "^7.45.0",         // 表单管理
    "zod": "^3.21.4",                     // 数据验证
    "dayjs": "^1.11.8",                   // 日期处理
    "antd": "^5.6.3",                     // UI组件库
    "styled-components": "^6.0.0",        // CSS-in-JS
    "react-error-boundary": "^4.0.10",    // 错误边界
    "i18next": "^23.2.0",                 // 国际化
    "react-i18next": "^13.0.0"            // React国际化
  },
  "devDependencies": {
    "typescript": "^5.1.3",               // TypeScript
    "vite": "^4.3.9",                     // 构建工具
    "vitest": "^0.32.2",                  // 单元测试
    "cypress": "^12.16.0",                // E2E测试
    "@testing-library/react": "^14.0.0",  // 组件测试
    "eslint": "^8.43.0",                  // 代码检查
    "prettier": "^2.8.8"                  // 代码格式化
  }
}

2. 认证与权限系统实现

所有企业级应用的第一步是构建完善的认证与权限系统:

// src/stores/authStore.ts - Zustand认证状态管理
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { loginApi, logoutApi, refreshTokenApi } from "../services/authService";

interface AuthState {
  user: User | null;
  token: string | null;
  refreshToken: string | null;
  isAuthenticated: boolean;
  permissions: string[];
  login: (credentials: LoginCredentials) => Promise<void>;
  logout: () => Promise<void>;
  refreshAccessToken: () => Promise<string>;
  hasPermission: (permission: string) => boolean;
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set, get) => ({
      user: null,
      token: null,
      refreshToken: null,
      isAuthenticated: false,
      permissions: [],

      async login(credentials) {
        const { user, token, refreshToken, permissions } = await loginApi(
          credentials
        );
        set({
          user,
          token,
          refreshToken,
          permissions,
          isAuthenticated: true,
        });
      },

      async logout() {
        await logoutApi();
        set({
          user: null,
          token: null,
          refreshToken: null,
          permissions: [],
          isAuthenticated: false,
        });
      },

      async refreshAccessToken() {
        const { refreshToken } = get();
        if (!refreshToken) throw new Error("No refresh token");

        const { token: newToken } = await refreshTokenApi(refreshToken);
        set({ token: newToken });
        return newToken;
      },

      hasPermission(permission) {
        return get().permissions.includes(permission);
      },
    }),
    {
      name: "auth-storage",
      partialize: (state) => ({
        token: state.token,
        refreshToken: state.refreshToken,
        user: state.user,
        permissions: state.permissions,
      }),
    }
  )
);
// src/components/common/ProtectedRoute.tsx - 权限控制路由
import { Navigate, useLocation } from "react-router-dom";
import { useAuthStore } from "../../stores/authStore";

interface ProtectedRouteProps {
  children: React.ReactNode;
  requiredPermission?: string;
}

export function ProtectedRoute({
  children,
  requiredPermission,
}: ProtectedRouteProps) {
  const { isAuthenticated, hasPermission } = useAuthStore();
  const location = useLocation();

  if (!isAuthenticated) {
    // 重定向到登录页,保留原始访问路径
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  // 检查是否有必要的权限
  if (requiredPermission && !hasPermission(requiredPermission)) {
    return <Navigate to="/unauthorized" replace />;
  }

  return <>{children}</>;
}
// src/services/axiosInstance.ts - 请求拦截器与认证令牌刷新
import axios from "axios";
import { useAuthStore } from "../stores/authStore";

const apiClient = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10000,
  headers: {
    "Content-Type": "application/json",
  },
});

// 请求拦截器添加认证令牌
apiClient.interceptors.request.use(
  (config) => {
    const token = useAuthStore.getState().token;
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// 响应拦截器处理认证失败
apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    // 如果是401错误且不是刷新token的请求,尝试刷新令牌
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        const newToken = await useAuthStore.getState().refreshAccessToken();
        originalRequest.headers.Authorization = `Bearer ${newToken}`;
        return apiClient(originalRequest);
      } catch (refreshError) {
        // 刷新令牌失败,登出用户
        await useAuthStore.getState().logout();
        window.location.href = "/login";
        return Promise.reject(refreshError);
      }
    }

    return Promise.reject(error);
  }
);

export default apiClient;

3. 数据获取与 React Query 集成

高效的数据获取是企业级应用的关键:

// src/services/projectService.ts - API服务封装
import apiClient from "./axiosInstance";
import { Project, CreateProjectDto, UpdateProjectDto } from "../types";

export const projectService = {
  async getAll(): Promise<Project[]> {
    const { data } = await apiClient.get("/projects");
    return data;
  },

  async getById(id: string): Promise<Project> {
    const { data } = await apiClient.get(`/projects/${id}`);
    return data;
  },

  async create(project: CreateProjectDto): Promise<Project> {
    const { data } = await apiClient.post("/projects", project);
    return data;
  },

  async update(id: string, project: UpdateProjectDto): Promise<Project> {
    const { data } = await apiClient.put(`/projects/${id}`, project);
    return data;
  },

  async delete(id: string): Promise<void> {
    await apiClient.delete(`/projects/${id}`);
  },
};
// src/hooks/useProjects.ts - React Query钩子封装
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { projectService } from "../services/projectService";
import { Project, CreateProjectDto, UpdateProjectDto } from "../types";

// 获取项目列表
export function useProjects() {
  return useQuery({
    queryKey: ["projects"],
    queryFn: projectService.getAll,
    staleTime: 5 * 60 * 1000, // 5分钟缓存
  });
}

// 获取单个项目
export function useProject(id: string) {
  return useQuery({
    queryKey: ["projects", id],
    queryFn: () => projectService.getById(id),
    enabled: !!id, // 只有id存在时才请求
  });
}

// 创建项目
export function useCreateProject() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (newProject: CreateProjectDto) =>
      projectService.create(newProject),
    onSuccess: (data) => {
      // 创建成功后更新项目列表缓存
      queryClient.setQueryData<Project[]>(["projects"], (old = []) => [
        ...old,
        data,
      ]);
      queryClient.invalidateQueries({ queryKey: ["projects"] });
    },
  });
}

// 更新项目
export function useUpdateProject() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, data }: { id: string; data: UpdateProjectDto }) =>
      projectService.update(id, data),
    onSuccess: (updatedProject) => {
      // 更新项目缓存
      queryClient.setQueryData<Project>(
        ["projects", updatedProject.id],
        updatedProject
      );

      // 更新项目列表中的项目
      queryClient.setQueryData<Project[]>(["projects"], (old = []) =>
        old.map((project) =>
          project.id === updatedProject.id ? updatedProject : project
        )
      );
    },
  });
}

// 删除项目
export function useDeleteProject() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (id: string) => projectService.delete(id),
    onSuccess: (_, id) => {
      // 从缓存中移除项目
      queryClient.removeQueries({ queryKey: ["projects", id] });

      // 更新项目列表
      queryClient.setQueryData<Project[]>(["projects"], (old = []) =>
        old.filter((project) => project.id !== id)
      );
    },
  });
}

4. 表单处理与数据验证

企业应用中表单处理至关重要,让我们实现一个完善的表单系统:

// src/components/features/ProjectForm.tsx - 项目表单组件
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, Form, Input, DatePicker, Select, message } from "antd";
import { useCreateProject, useUpdateProject } from "../../hooks/useProjects";
import { Project } from "../../types";

// 使用Zod定义表单验证模式
const projectSchema = z
  .object({
    name: z.string().min(3, "项目名至少3个字符").max(100),
    description: z.string().optional(),
    startDate: z.date({
      required_error: "请选择开始日期",
    }),
    endDate: z
      .date({
        required_error: "请选择结束日期",
      })
      .optional(),
    status: z.enum(["planning", "active", "completed", "on-hold"]),
    priority: z.enum(["low", "medium", "high", "urgent"]),
  })
  .refine((data) => !data.endDate || data.startDate <= data.endDate, {
    message: "结束日期必须晚于开始日期",
    path: ["endDate"],
  });

// 表单数据类型
type ProjectFormData = z.infer<typeof projectSchema>;

interface ProjectFormProps {
  project?: Project;
  onSuccess?: () => void;
}

export function ProjectForm({ project, onSuccess }: ProjectFormProps) {
  // 表单处理
  const {
    control,
    handleSubmit,
    formState: { errors },
    reset,
  } = useForm<ProjectFormData>({
    resolver: zodResolver(projectSchema),
    defaultValues: project
      ? {
          ...project,
          startDate: new Date(project.startDate),
          endDate: project.endDate ? new Date(project.endDate) : undefined,
        }
      : {
          status: "planning",
          priority: "medium",
          startDate: new Date(),
        },
  });

  // API调用钩子
  const createMutation = useCreateProject();
  const updateMutation = useUpdateProject();

  // 表单提交处理
  const onSubmit = async (data: ProjectFormData) => {
    try {
      if (project) {
        // 更新现有项目
        await updateMutation.mutateAsync({
          id: project.id,
          data,
        });
        message.success("项目已更新");
      } else {
        // 创建新项目
        await createMutation.mutateAsync(data);
        message.success("项目已创建");
        reset(); // 清空表单
      }

      onSuccess?.();
    } catch (error) {
      message.error("操作失败,请重试");
      console.error(error);
    }
  };

  const isSubmitting = createMutation.isPending || updateMutation.isPending;

  return (
    <Form layout="vertical" onFinish={handleSubmit(onSubmit)}>
      <Form.Item
        label="项目名称"
        validateStatus={errors.name ? "error" : undefined}
        help={errors.name?.message}
      >
        <Controller
          name="name"
          control={control}
          render={({ field }) => <Input {...field} disabled={isSubmitting} />}
        />
      </Form.Item>

      <Form.Item label="项目描述">
        <Controller
          name="description"
          control={control}
          render={({ field }) => (
            <Input.TextArea {...field} rows={4} disabled={isSubmitting} />
          )}
        />
      </Form.Item>

      <div style={{ display: "flex", gap: 16 }}>
        <Form.Item
          label="开始日期"
          validateStatus={errors.startDate ? "error" : undefined}
          help={errors.startDate?.message}
          style={{ flex: 1 }}
        >
          <Controller
            name="startDate"
            control={control}
            render={({ field: { value, onChange } }) => (
              <DatePicker
                value={value ? dayjs(value) : null}
                onChange={(date) => onChange(date?.toDate())}
                style={{ width: "100%" }}
                disabled={isSubmitting}
              />
            )}
          />
        </Form.Item>

        <Form.Item
          label="结束日期"
          validateStatus={errors.endDate ? "error" : undefined}
          help={errors.endDate?.message}
          style={{ flex: 1 }}
        >
          <Controller
            name="endDate"
            control={control}
            render={({ field: { value, onChange } }) => (
              <DatePicker
                value={value ? dayjs(value) : null}
                onChange={(date) => onChange(date?.toDate())}
                style={{ width: "100%" }}
                disabled={isSubmitting}
              />
            )}
          />
        </Form.Item>
      </div>

      <div style={{ display: "flex", gap: 16 }}>
        <Form.Item label="状态" style={{ flex: 1 }}>
          <Controller
            name="status"
            control={control}
            render={({ field }) => (
              <Select {...field} disabled={isSubmitting}>
                <Select.Option value="planning">规划中</Select.Option>
                <Select.Option value="active">进行中</Select.Option>
                <Select.Option value="completed">已完成</Select.Option>
                <Select.Option value="on-hold">已搁置</Select.Option>
              </Select>
            )}
          />
        </Form.Item>

        <Form.Item label="优先级" style={{ flex: 1 }}>
          <Controller
            name="priority"
            control={control}
            render={({ field }) => (
              <Select {...field} disabled={isSubmitting}>
                <Select.Option value="low"></Select.Option>
                <Select.Option value="medium"></Select.Option>
                <Select.Option value="high"></Select.Option>
                <Select.Option value="urgent">紧急</Select.Option>
              </Select>
            )}
          />
        </Form.Item>
      </div>

      <Form.Item>
        <Button type="primary" htmlType="submit" loading={isSubmitting} block>
          {project ? "更新项目" : "创建项目"}
        </Button>
      </Form.Item>
    </Form>
  );
}

5. 国际化与主题管理

企业级应用常需要支持多语言和主题切换:

// src/config/i18n.ts - 国际化配置
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";

// 导入翻译文件
import enTranslation from "../locales/en.json";
import zhTranslation from "../locales/zh.json";

i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources: {
      en: {
        translation: enTranslation,
      },
      zh: {
        translation: zhTranslation,
      },
    },
    fallbackLng: "en",
    interpolation: {
      escapeValue: false, // React已处理XSS
    },
  });

export default i18n;
// src/hooks/useTheme.ts - 主题管理Hook
import { create } from "zustand";
import { persist } from "zustand/middleware";

type ThemeMode = "light" | "dark" | "system";

interface ThemeState {
  mode: ThemeMode;
  setMode: (mode: ThemeMode) => void;
  isDarkMode: boolean;
}

export const useThemeStore = create<ThemeState>()(
  persist(
    (set, get) => ({
      mode: "system",

      setMode: (mode) => set({ mode }),

      get isDarkMode() {
        const { mode } = get();
        if (mode === "system") {
          return window.matchMedia("(prefers-color-scheme: dark)").matches;
        }
        return mode === "dark";
      },
    }),
    {
      name: "theme-storage",
    }
  )
);

// 主题应用Hook
export function useTheme() {
  const { mode, setMode, isDarkMode } = useThemeStore();

  // 监听系统主题变化
  useEffect(() => {
    if (mode !== "system") return;

    const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
    const handleChange = () => {
      // 强制组件重新渲染
      setMode("system");
    };

    mediaQuery.addEventListener("change", handleChange);
    return () => mediaQuery.removeEventListener("change", handleChange);
  }, [mode, setMode]);

  // 应用主题到文档
  useEffect(() => {
    const root = window.document.documentElement;
    root.classList.remove("light-theme", "dark-theme");
    root.classList.add(isDarkMode ? "dark-theme" : "light-theme");
  }, [isDarkMode]);

  return { mode, setMode, isDarkMode };
}

下一篇预告:《【React 性能调优】从优化实践到自动化性能监控》

在系列的下一篇中,我们将深入探讨如何对 React 应用进行全方位的性能优化:

  • React 开发者工具与性能分析
  • 代码分割与懒加载进阶技巧
  • 服务端渲染(SSR)与静态生成(SSG)
  • 自动化性能监控方案
  • 实际项目优化案例分析

优秀的 React 应用不只是功能完善,更要体验流畅。下一篇,我们将带你的应用性能再上一个台阶!

敬请期待!

关于作者

Hi,我是 hyy,一位热爱技术的全栈开发者:

  • 🚀 专注 TypeScript 全栈开发,偏前端技术栈
  • 💼 多元工作背景(跨国企业、技术外包、创业公司)
  • 📝 掘金活跃技术作者
  • 🎵 电子音乐爱好者
  • 🎮 游戏玩家
  • 💻 技术分享达人

加入我们

欢迎加入前端技术交流圈,与 10000+开发者一起:

  • 探讨前端最新技术趋势
  • 解决开发难题
  • 分享职场经验
  • 获取优质学习资源

添加方式:掘金摸鱼沸点 👈 扫码进群

Opnelayers:海量图形渲染之聚合

作者 Carlos_sam
2025年4月15日 10:34

最近由于在工作中涉及到了海量图形渲染的问题,因此我开始研究相关的解决方案。在这个过程中聚合也是一个经常会被人提到的解决方案,我一直对聚合是怀有疑虑的,我认为其无法有效的解决我所面临的问题。

一、基本使用方式

聚合的基本使用方式,可以参考我之前写的这篇文章:Openlayers:实现聚合-CSDN博客

二、尝试使用聚合来优化河流三角网

这篇文章我主要还是想要探讨一下是否能使用聚合来解决我在开发过程中遇到的实际。

我需要渲染一个像这样的河流三角网,由于三角网中的图形数量过多导致无法直接进行渲染。

最简单的方式当然还是用点作为三角网的聚合图形,这种效果实现起来很简单,并且性能上也有保障。

  const polygonSource = new VectorSource({
    url: "src/data/河流三角网/BJ.json",
    format: new GeoJSON({
      dataProjection: "EPSG:4547",
      featureProjection: "EPSG:4326",
    }),
  });

  const clusterSource = new Cluster({
    distance: 40,
    source: polygonSource,
    geometryFunction: function (feature) {
      return feature.getGeometry().getPolygon(0).getInteriorPoint();
    }
  });

  const polygonLayer = new VectorLayer({
    properties: {
      name: "多边形图层",
      id: "polygonLayer",
    },
    source: clusterSource,
  });

  window.map.addLayer(polygonLayer);

但是上面的这种效果是没有办法满足我的需求的,我是希望在聚合后渲染的图形依旧可以保持河流的轮廓。

为了实现这样的效果,我筹划了一个方案。就是借助turf.js中的union方法将参与聚合的三角网格进行合并,将合并后的大多边形作为渲染图形。

  const polygonSource = new VectorSource({
    url: "src/data/河流三角网/BJ.json",
    format: new GeoJSON({
      dataProjection: "EPSG:4547",
      featureProjection: "EPSG:4326",
    }),
  });

  const clusterSource = new Cluster({
    distance: 40,
    source: polygonSource,
    geometryFunction: function (feature) {
      // 我这里使用的多边形几何类型是MultiPolygon
      return feature.getGeometry().getPolygon(0).getInteriorPoint();
    },
    createCluster: function (point, features) {
      if (features.length > 1) {
        const polygons = turf.featureCollection(
          features.map(f =>
            turf.polygon(f.getGeometry().getPolygon(0).getCoordinates())
          )
        );
        const unionPolygon = turf.union(polygons);
        return new Feature({
          geometry: new Polygon(unionPolygon.geometry.coordinates),
          features: features,
        });
      } else {
        return features[0];
      }
    },
  });

  const polygonLayer = new VectorLayer({
    properties: {
      name: "多边形图层",
      id: "polygonLayer",
    },
    source: clusterSource,
  });

  window.map.addLayer(polygonLayer);

很可惜,最终呈现出来的结果非常的糟糕,主要有以下的几个问题:

  1. 当初始渲染和切换缩放级别时,聚合计算量很大,导致加载速度慢。
  2. 合并后的多边形多是不规则的,其实也不甚美观。

3.合并之后出现了很多空隙,上不知晓是什么原因导致的。

三、总结

最后在我想对聚合方法做一个总结,首先必须承认的是聚合确实一种有效的解决海量图形渲染问题的方案。

但是聚合也是存在着明显的局限性,它主要适用于两种情况:

  1. 在渲染效果上允许使用一个标识来代表一个区域内的图形。
  2. 在计算聚合的过程中没有太过复杂的计算。

因此如果要求较为简单,那么我还是比较建议使用聚合去渲染海量图形的,反之则应该去寻求其它的解决办法。

参考资料

  1. OpenLayers v10.5.0 API - Class: Cluster
  2. Turf.js中文网 | Turf.js中文网

纯前端调用deepseek v3模型,流式返回,支持md文本、table、代码等

作者 三小河
2025年4月15日 10:26

背景

大模型如火如荼,作为公司的边角料,一个不起眼得小渣渣前端切图仔,我们如何在不启动后端服务得情况下,快如入门调用大模型,本文就是来扫盲的,文章末尾有源码可以自行查看

技术栈

我制作的demo比较简陋,也就不到半天时间,基于 react@18 tailwindcss@3 vite ract-marndown等来搞的,代码简单,主要是熟悉流程,先看实现的截图及视频,如果不满足要求,可以直接关闭文章,免得浪费时间

demo功能介绍

  1. 流式调用deeepseek v3
  2. 增加会话历史主页面等布局
  3. 支持md文本展示
  4. 支持table ul link等特殊 md展示
  5. 支持html结构回显
  6. 封装了包括chat-input 用户问答 AI回复 md展示等组件
  7. 优化了支持html的空白间隙问题
  8. 代码的 copy 功能 话不多说,看下面的截图及视频展示

demo展示

微信截图_20250415094058.png


微信截图_20250415094108.png


微信截图_20250415094120.png


实现步骤

注册deepseek api key

deepseek API开放平台

充值至少十块钱

image.png

写入api key

clone代码后,将 .env文件 的 VITE_DEEPSEEK_KEY 替换成你自己的

项目启动

npm i
npm run dev

测试问答

你可以在问答框输入以下问题 看支持的情况如何

  1. 你是什么模型 (测试md纯文本的)
  2. 写一段js去重的代码
  3. 帮我生成一个三行三列的表格,用于计算姓名和成绩

提示

deepseek有提示

image.png,请创建后,很好的保存你得key,不要外泄

核心代码介绍

openai SDK

任何大模型 的接口都兼容 openai的规范,我们可以借助 openai sdk来实现deepseek的模型请求,打开 dangerouslyAllowBrowser属性,可以在浏览器中调用,这种做法仅作demo,后期实际项目需要后端去调用,纯前端调用 api key会暴露

import OpenAI from "openai";
 const initOpenAI = () => {
        openaiRef.current = new OpenAI({
            baseURL: "https://api.deepseek.com",
            apiKey: GlobalAPI.config?.VITE_DEEPSEEK_KEY,
            dangerouslyAllowBrowser: true,
        });
    };

markdown-ai.tsx组件

import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import rehypeHighlight from 'rehype-highlight'; // 代码高亮
import 'highlight.js/styles/github.css';

 <ReactMarkdown
        remarkPlugins={[remarkGfm, remarkMath]}
        rehypePlugins={[rehypeRaw, rehypeHighlight, removeExtraWhitespace]}
        components={components as any}
      >
        {markdownstr}
      </ReactMarkdown>

采用react-markdown及配套实现了样式,md文本,table,html标签等支持,详情请看具体代码

代码copy组件

借助react-markdown自定义标签的方式,自定义了<pre>标签,借助 rehype-highlight copy-to-clipboard来实现代码的高亮及复制

import PreWithCopy from "./copycode.tsx"
// 自定义渲染器
  const components = {
    pre: PreWithCopy,
  };
  
   <ReactMarkdown
        remarkPlugins={[remarkGfm, remarkMath]}
        rehypePlugins={[rehypeRaw, rehypeHighlight, removeExtraWhitespace]}
        components={components as any}
      >
        {markdownstr}
      </ReactMarkdown>
  

html渲染空白行处理

react-markdown开启 rehypePlugins rehypeRaw的时候,大多数情况由于模型返回的\n太多,页面会间距过大,尤其是表格等,我们需要单独空白行处理下

 // 消除空格的 解决html 渲染的问题
  const removeExtraWhitespace = () => {
    return (tree: any) => {
      const removeWhitespace = (node: any) => {
        if (node.tagName === "pre") {
          return;
        }
        if (node.type === "text") {
          node.value = node.value.replace(/\s+/g, " ");
        }
        if (node.children) {
          node.children = node.children.filter((child: any) => {
            if (child.type === "text") {
              return child.value.trim() !== "";
            }
            removeWhitespace(child);
            return true;
          });
        }
      };
      removeWhitespace(tree);
      return tree;
    };
  };

总结

以上就是demo版本的主要功能和技术实现细节,详情请查看代码 gitee仓库地址

我是基于react写的,如果需要vue版本的,可以留言,抽时间做一套vue的。

TypeScript 基础学习

2025年4月15日 10:25

TypeScript简介

  1. TypeScript由微软开发,基于JS的扩展语言
  2. TS包含了JS的所有内容,即TS是JS的超集
  3. TS增加了静态类型检查、接口,泛型等很多现代开发特性,因此适合大型项目的开发。
  4. TS需要编译为JS,然后交给浏览器或者其他JS运行环境执行。

为什么需要TS

今非昔比的JS

  • JS当年诞生的定位是浏览器的脚本语言,用于在网页中嵌入一些简单的逻辑,而且代码量很少,随着时间的推移,JS变得越来越流行,如今的JS已经可以全栈编程了。
  • 现如今的JS的应用场景比当年丰富的多,代码量也比当年大的多,随便一个JS项目的代码量,可以轻松的达到几万行,甚至十几万行。
  • 然而JS当年“出生简陋”,没考虑到如今的应用场景和代码量,逐渐的就出现了很多困扰。

JS的困扰

不清不楚的数据类型

let welcome = ‘hello’
welcome() // 此时报错 TypeError:welcome is not a function

有漏洞的逻辑

const str = Date.now() % 2 ? '奇数' :‘偶数’
if (str !== '奇数'){
    alert('hello')
}else if (str === '偶数'){
    alert('world')
}

访问不存在的属性

const obj = {width:10,height:15}
const area = obj.width * obj.heigth // heigth 不存在,应该为height

低级的拼写错误

const message = 'hello,world'
message.toUperCase() // toUperCase少写了一个p,应该是toUpperCase

【静态类型】检查

  • 在代码运行前进行检查,发现代码的错误或不合理之处,减小运行时异常的出现的几率,此种检查叫【静态类型检查】,TypeScript和核心就是【静态类型检查】,简言之就是把运行时的错误前置。
  • 同样的功能,TypeScript的代码量要大于JS,但由于TS的代码结构更加清晰,在后期代码的维护中TS却远胜于JS。

编译TypeScript

浏览器不能直接运行TypeScript代码,需要编译为JavaScript再交由浏览器解析器执行。

类型声明

let a: String
let b: number
let c: boolean

a = 'hello'
b = -99
c = true

function count(x:number,y:number):number{
   return x + y
}

let result = count(1,2)
console.log(result)

类型推断

TS会根据我们的代码,进行类型推导,例如下面代码中的变量d,只能存储数字

let d =  -99 // TypeScript 会推断出变量的d的类型是数字
d = false // 警告:不能将类型‘boolean’分配给类型‘number’

但要注意,类型推断不是万能的,面对复杂类型时推断容易出问题,所以尽量还是明确的编写类型声明!

类型总揽

JS中的数据类型

string,number,boolean,null,undefined,bigint,symbol,object

其中object包含 Array,Function,Date,Error等。。。

TypeScript中的数据类型

  1. 上述所有的JavaScript类型
  2. 六个新类型 any,unknown,never,void,tuple,enum
  3. 两个用于自定义类型的方式 type,interface
let str1: string // TS官方推荐的写法,只能值类型的字符串,不能写字符串形式的包装对象。
str1 = ‘hello’
str1 = new String('hello')

let str2: String // 可以接受值类型的字符串,也能写字符串形式的包装对象。
str2 = ‘hello’
str2 = new String('hello')

注意点⚠️

在JS中这些内置构造函数:Number、String、Boolean,它们用于创建对应的包装对象,在日常开发时很少使用,在TypeScript中也是同理,所以在TypeScript中进行类型声明时,通常都是写小写的number、string、boolean。

  1. 原始类型VS包装对象
  • 原始类型:如number、string、boolean,在JS中是简单数据类型,它们在内存中占用空间少,处理速度快
  • 包装对象:如Number对象,String对象,Boolean对象,是复杂类型,在内存中占用更多空间,在日常开发时很少由开发人员自己创造包装对象。
  1. 自动包装:JS在必要时会自动将原始类型包装成对象,以调用方法或访问属性。
// 原始类型字符串
let str = 'hello'
//当访问str.length时,JS引擎做了一下工作:
let size = (function(){
    //1.自动装箱:创建一个临时的String对象包装原始字符串
    let tempStringObject = new String(str)
    
    //2.访问String对象的lenth属性
    let lengthValue = tempStringObject.length
    
    //3.销毁临时对象,返回长度值
    //(JS引擎自动处理对象销毁,开发者无感知)
    return lengthValue;
})

常用类型

any

any的含义是:任意类型,一旦将变量类型限制为any,那就意味着放弃了对改变量的类型检查

//明确的表示a的类型时any --- 【显式的any】
let a:any
//以下对a的赋值,均无警告
a = 100
a = '你好'
a = false

//没有明确的表示b的类型是any,但TS主动推断出来b是any -- 隐式的any
let b 
//以下对b的赋值,均无警告
b = 100
b = '你好'
b = false

注意点;any类型的变量,可以赋值给任意类型的变量

/*注意点;any类型的变量,可以赋值给任意类型的变量*/
let c:any
c = 9

let x:string
x = c // 无警告 x = 9

unknown

unknown 的含义是:未知类型

  1. unknown可以理解为一个类型安全的any,适用于:不确定数据的具体类型
//设置a的类型unknown
let a:unknown

//以下对a的赋值,均正常
a = 100
a = false
a = '你好'

// 设置x的数据类型为string
let x:string
x = a // 警告:不能将类型‘unknown’分配给类型‘string’
  1. unknown会强制开发者在使用之前进行类型检查,从而提供更强的类型安全性
// 设置x的数据类型为string
let a:unknown
a = ‘hello’

// 第一种
if(typeof a === 'string'){
    x = a
}

// 第二种(断言)
x = a as string

// 第三种方式:加断言
x = <string>a
  1. 读取any类型数据的任何属性都不会报错,而unknown正好与之相反
let str1:string
str1 = 'hello'
str1.toUpperCase() // 无警告

let str2:any
str2 = 'hello'
str2.toUpperCase() // 无警告

let str3:unknown
str3 = 'hello'
str3.toUpperCase() // 警告,‘str3’的类型为‘未知’

// 使用断言强制指定str3的类型为string
(str3 as string).toUpperCase() // 无警告

never

never的含义是:任何值都不是,简言之就是不能有值,undefined、 null 、‘’、0 都不行

  1. 几乎不用never去直接限制变量,因为没有意义,例如:
//指定a的类型为never,那就意味着a以后不能存任何的数据了
let a:never

// 以下对a的所有赋值都会有警告
a = 1
a = true
a = undefined
a = null
  1. never一般是TypeScript主动推断出来的,例如
// 指定a的类型为string
let a:string
// 给a设置一个值
a ='hello'

if (typeof a === 'string'){
    console.log(a.toUpperCase())
}else {
    console.log(a) // TS会推断出此处的a是never,因为没有任何一个值符合此处的逻辑
}
  1. never也可用于限制函数的返回值(一直调用循环,或者抛出异常的函数)
//限制throwError函数不需要有任何返回值,任何值都不行,像undefined、null都不行
function throwError(str:string):never {
    throw new Error('程序异常退出:'+ str)
}

void

  1. void通常用于函数返回值声明,含义:【函数返回值为空,调用者也不应依赖其返回值进行任何操作】
function logMessage(msg:string):void {
    console.log(msg)
}
logMessage('你好')

注意:编码着没有编写return去指定函数的返回值,所以logMessage函数是没有显式返回值的,但是会有一个隐式返回值,就是undefined;即:虽然函数返回类型为void,但也是可以接受undefined的,简单记:undedined 是void可以接受的一种‘空’ 以下写法均符合规范

//无警告
function logMessage(msg:string):void{
    console.log(msg)
}
//无警告
function logMessage(msg:string):void{
    console.log(msg)
    return;
}
//无警告
function logMessage(msg:string):void{
    console.log(msg)
    return undefined;
}

那限制函数返回值时,是不是undefined和void就没区别呢? --- 有区别,因为还有句话说:【返回值类型void的函数,调用者不应依赖其返回值进行任何操作!】对比下面两段代码:

fuction logMessage(msg:string):void{
    console.log(msg)
}
let result = logMessage('你好')
if (result){ // 此行报错,无法测试‘void’类型的表达式的真实性
    console.log('logMessage有返回值')
}
fuction logMessage(msg:string):undefined{
    console.log(msg)
}
let result = logMessage('你好')
if (result){ // 此行无警告
    console.log('logMessage有返回值')
}

理解void与undefined

  • void是一个广泛的概念,用来表达‘空’,而undefined则是这种‘空’的具体实现之一。
  • 因此可以说undefined是void能接受“空”的状态的一种具体形式
  • 换句话说:void包含undefined,但void表达的语义超越了单纯的unedined,它是一种意图上的约定,而不仅仅是特定值的限制

总结:若函数返回类型为void,那么;

  1. 从语法上讲:函数是可以返回undefined的,至于显式返回,还是隐式返回,这无所谓!
  2. 从语义上讲:函数调用者不应关心函数返回的值,也不应依赖返回值进行任何操作!即使返回了undefined值。

object

关于object与Object,直接说结论;实际开发中用的相对较少,因为范围太大了。

object(小写)

object 小写的含义是:所有的非原始类型,可存储:对象、函数、数组等,由于限制的范围比较宽泛,在实际开发中使用的相对较少。

let a:object // a的值可以是任何【非原始类型】,包括,对象,函数,数组等

//以下代码,是将【非原始类型】赋给a,所以均符合要求
a = {}
a = {name:'张三'}
a = [1,3,5,7,9]
a = new String('123')
class Person{}
a = new Person()

// 以下代码,是将【原始类型】赋给a,有警告
a = 1 // 警告:不能将类型‘number’分配给类型‘object’
a = true // 警告:不能将类型‘boolean’分配给类型‘object’
a = '你好' // 警告:不能将类型‘string’分配给类型‘object’
a = null // 警告:不能将类型‘null’分配给类型‘object’
a = undefiend // 警告:不能将类型‘undefined’分配给类型‘object’

Object(大写)

  • 官方描述:所有可以调用Object方法的类型
  • 简单记忆:除了undefined和null的任何值
  • 由于限制的范围实在太大了!所以实际开发中使用频率极低
let a:Object // b能存储的类型是可以调用到Object方法的类型
b = {}
b = {name:'张三'}
b = [1,3,5,7,9]
b = new String('123')
class Person{}
b = new Person()
b = 1
b = true
b = '你好'

// 这两个是存不了的
// b = null
// b = undeinfed

声明对象类型

  1. 实际开发中,限制一般对象,通常使用以下形式
//限制person1对象必须有name属性,age为可选属性
let person1:{name :string,age?:number}

// 含义同上,也能用分号做分割
let person1:{name :string;age?:number}

// 含义同上,也能用换行做分割
let person3:{
    name:string
    age?:number
}

//如下赋值均可以
person1 = {name:'李四',age:18}
person2 = {name:'张三'}
person3 = {name:'王五'}

// 如下赋值不合法,因为person3的类型限制中,没有对gender属性的说明
person4 = {name:'王五',gender:'男'}
  1. 索引签名:允许定义对象可以具有任意数量的属性,这些属性的类型可变的,常用于:描述类型不确定的属性(具有动态属性的对象)
//限制person对象必须有name属性,可选age属性但值必须是数字,同时可以有任意数量、任意类型。
let person:{
    name:string,
    age?:number,
    [key:string]:any // 索引签名,完全可以不用key这个单词,换成其他的也可以。
}

//赋值合法
person = {
    name:'张三',
    age:18,
    gender:'男'
}

声明函数类型

let count:(a:number,b:number) => number

count = function(x,y){
    return x + y
}

备注:

  • TypeScript中的 => 在函数类型声明时表示函数类型,描述其参数类型和返回类型
  • JavaScript中的 => 是一种定义函数的语法,是具体的函数实现
  • 函数类型还可以使用:接口、自定义类型等方式,下文中会详细讲解

声明数组类型

let arr1:string[]
let arr2:Array<string>

arr1 = ['a','b','c']
arr2 = ['hello','world']

备注:上述代码中的Array<string>属于泛型,下文会详细讲解

tuple

元祖(Tuple)是一种特殊的数组类型,可以存储固定数量的元素,并且每个元素的类型是已知的且可以不同。元祖用于精确描述一组值的类型, ?表示可选元素。

// 第一个元素必须是string类型,第二个元素必须是number类型
let arr1:[string,number]

//第一个元素必须是number类型,第二个元素是可选的,如果存在,必须是boolean类型
let arr2:[number,boolean?]

//第一个元素必须是number类型,后面的元素可以是任意数量的string类型
let arr3:[number,...string]

// 可以赋值
arr1 = ['hello',123]
arr2 = [100,false]
arr3 = [200]
arr3 = [100,'hello','world']
arr3 = [100] 

//不可以赋值,arr1声明时是两个元素,赋值的是三个
arr1 = ['hello',123,false]

enum

枚举(enum)可以定义一组命名常量,它能增强代码的可读性,也让代码更好维护

数字枚举

数字枚举一种最常见的枚举类型,其成员的值会自动递增,且数字枚举还具备反向映射的特点,在下面代码的打印中,不难发现:可以通过来获取对应的枚举成员名称

//定义一个描述【上下左右】方向的枚举Direction
enum Direction {
    Up,
    Down,
    Left,
    Right
}
cosole.log(Direction) // 打印Direction 会看到如下内容
/*
  0:'Up',
  1:'Down',
  2:'Left',
  3:'Right',
  Up:0,
  Down:1,
  Left:2,
  Right:3
*/

//反向映射
console.log(Direction.Up)
console.log(Direction[0])

//此行代码报错,枚举中的属性是只读的
Direction.Up = 'shang'

也可以指定枚举成员的初始值,其后的成员值会自动递增

enum Direction {
    Up = 6,
    Down,
    Left,
    Right
}
console.log(Direction.Up) // 6
console.log(Direction[1]) // 7

使用数字枚举完成刚才walk函数中的逻辑,此时我们发现:代码更加直观易读,而且类型安全,同时也更易于维护

enum Direction { 
    Up,
    Down,
    Left,
    Right
}

function walk(n:Direction){
    if (n === Direction.Up){
        console.log('向上走')
    }else if (n == Direction.Down){
       console.log('向下走')
    }else if (n == Direction.Left){
       console.log('向左走')
    }else if (n == Direction.Right){
       console.log('向右走')
    }
}
walk(Direction.Up)
walk(Direction.Down)

字符串枚举

enum Direction {
    Up = 'up',
    Down = 'down',
    Left = 'left',
    Right = 'right'
}
let dir:Direction = Direction.Up
console.log(dir) // 输出;‘up’

常量枚举

官方描述:常量枚举是一种特殊枚举类型,它使用const 关键字定义,在编译时会被內联,避免生成额外的代码

type

type可以为任意类型创建别名,让代码更简洁、可读性更强,同时能更方便地进行类型复用和扩展。

1. 基本用法

类型别名使用type 关键字定义,type后跟类型名称,例如下面代码中 num 时类型别名

type num = number

let price:num
price = 100

2. 联合类型

联合类型是一种高级类型,它表示一个值可以是几种不同类型之一。

type Status = number | string
type Gender = '男' | ‘女’

function printStatus(status:Status){
    console.log(status)
}
function logGender(str:Gender){
    console.log(str)
}

3. 交叉类型

交叉类型(Intersection Types)允许将多个类型合并为一个类型,合并后的类型将拥有所有被合并类型的成员。交叉类型通常用于对象类型。

type Area = {
    height:number; // 高
    width:number;// 宽
}

//地址
type Address = {
    num:number // 楼号
    cell:number // 单元号
    room:string // 房间号
}

type House = Area & Address

const house:House = {
    height:100, // 高
    width:100, // 宽
    num:3, // 楼号
    cell:4, // 单元号
    room:'702' // 房间号
}

属性修饰符

修饰符 含义 具体规则
public 公开的 可以被:类内部、子类、类外部访问
protected 受保护的 可以被:类内部、子类访问
private 私有的 可以被:类内部访问
readonly 只读属性 属性无法修改

public修饰符

class Person {
    //name 写了public修饰符,age没写修饰符,最终都是public修饰符
    public name:string
    age:number
    constructor(name:string,age:number){
        this.name = name 
        this.age = age
    }
    speak(){
        //类的内部可以访问public修饰的name和age
        console.log('我叫:${this.name},今年${this.age}岁')
    }
}
const p1 = new Person('张三',18)
//类的【外部】可以访问public修饰的属性
console.log(p1.name)
class Student extends Person {
    constructor(name:string,age:number){
        super(name,age)
    }
    study(){
    // 【子类中】可以访问父类中的public修饰的:name属性,age属性
       console.log('今年${this.age}岁的${this.name}正在努力学习')
    }
}

属性的简写形式

简写前

class Person {
    //name 写了public修饰符,age没写修饰符,最终都是public修饰符
    public name:string
    age:number
    constructor(name:string,age:number){
        this.name = name 
        this.age = age
    }
}

简写后

class Person {
    constructor(public name:string,public age:number){}
}

protected修饰符

class Person {
    constructor(protected name:string,protected age:number){}
    protected getDetails(){
        return `我叫:${this.name},今年${this.age}岁`
    }
    introduce(){
        console.log(this.getDetails())
    }
}
const p1 = new Person('张三',18)
p1.introduce() // public 可以在外部访问
// p1.name 在外部不能访问
// p1.getDetails 在外部不能访问
class Student extends Person {
    constructor(name:string,age:number){
        super(name,age)
    }
    study(){
       this.getDetails()
       console.log(‘${this.name}正在努力学习’)
    }
}

const s1 = new Student('tom',1)
s1.study()

private修饰符

class Person {
    constructor(name:string,age:number,privated IDCard;string){}
    private getPrivateInfo(){
        return `身份证号码为:${this.IDCard}`
    }
    getInfo(){
         return `我叫:${this.name},今年${this.age}岁`
    }
    getFullInfo(){
         return this.getInfo() + ',' + this.getPrivateInfo()
    }
}

readonly修饰符

class Person {
    constructor(name:string,readonly age:number){}
}

抽象类

抽象类不能实例化可以被继承,抽象类里有普通方法,也可以有抽象方法

abstract class Package {
    // 构造方法
    constructor(public weight:number){}
    // 抽象方法
    abstract calcuate():number
    //具体方法
    printPackage(){
        console.log('包裹重量为:${this.weight}kg,运费为:${
        this.calcuate()}元')
    }
}
class StandardPackage extends Package {
    constructor(weight:number,public unitPrice:number){super(weight)}
    calcuate():number {
        return this.weight * this.uniPrice
    }
}

const s1 = new StandardPackage(10,5)
s1.printPackage()
class ExpressPackage extends Package {
    constructor(weight:number,public unitPrice:number,public additional:number){super(weight)}
    calcuate():number {
        if (this.weight > 10){
            return 10 * this.uniPrice + (this.weight - 10) * this.uniPrice
        }else {
            return this.weight * this.uniPrice
        }
        
    }
}

const s1 = new ExpressPackage(13,8,2)
s1.printPackage()

总结:何时使用抽象类

  1. 定义通用接口:为一组相关的类定义通用的行为(方法或属性)时。
  2. 提供基础实现:在抽象类中提供某些方法或为其提供基础实现,这样派生类就可以继承这些实现。
  3. 确保关键字实现:强制派生类实现一些关键行为
  4. 共享代码和逻辑:当多个类需要共享部分代码时,抽象类可以避免代码重复。

interface 接口

interface是一种定义结构的方式,主要作用是为:类、对象、函数等规定一种契约,这样可以确保代码的一致性和类型安全,但要注意interface只能定义格式不能包含任何实现

定义类结构

// PersonInterface接口,用于限制Person类的格式
interface PersonInterface接口 {
    name:string
    age:number
    speak(n:number):void
}

// 定义一个类Person,实现PersonInterface接口
class Person implements PersonInterface {
    constructor(public name:string,public age:number){}
    //实现接口的speak方法,注意:实现speak时参数个数可以少于接口中的规定,但不能多
    speak(n:number):void {
        for (let i = 0;i < n ;i++) {
            //打印出包含名字和年龄的问候语句
            console.log(`我叫:${this.name},今年${this.age}岁`)
        }
    }
}

// 创建一个Person类的实例p1,传入名字‘tom’和年龄18
const p1 = new Person('tom',18)
p1.speak(3)

定义对象结构

interface UserInterface {
    name:string
    readonly gender:string // 只读属性
    age?:number // 可选属性
    run:(n:number) => void
}
const user:UserInterface = {
    name:'张三',
    gender:'男',
    age:18,
    run(n) {
        console.log('奔跑了${n}米')
    }
}

定义函数结构

interface CountInterface {
    (a:number,b:number):number;
}
const count:CountInterface = (x,y) => {
    return x + y
}

接口之间的继承

一个interface 继承另一个interface,从而实现代码的复用

interface PersonInterface {
    name:string // 姓名
    age:number // 年龄
}
interface StudentInterface extends PersonInterface {
    grade:string // 年级
}
const stu:StudentInterface = {
    name:'张三',
    age:17,
    grade:'高三'
}

接口自动合并(可重复定义)

interface PersonInterface {
    name:string // 姓名
    age:number // 年龄
}
interface PersonInterface {
    grade:string // 年级
}

总结:何时使用接口?

  1. 定义对象的格式:描述数据模型,API响应格式、配置对象。。。等等你,是开发中用的最多的场景。
  2. 类的契约;规定一个类需要实现哪些属性和方法
  3. 自动合并:一般用于扩展第三方库的类型,这种特性在大型项目中可能用到。

一些相似概念的区别

interface 与 type 的区别

  • 相同点:interface 和 type 都可以用于定义对象结构,两者在许多场景中是可以互换的。
  • 不同点:
    1. interface:更关注于定义对象的结构,支持继承、合并
    2. type:可以定义类型别名、联合类型、交叉类型,但不支持继承和自动合并。

interface 和 type 都可以用于定义对象结构

// 使用 interface 定义 Person对象
interface PersonInterface {
    name:string; // 姓名
    age:number; // 年龄
    speak():void;
}

// 使用type 定义 Person 对象
type PersonType = {
    name:string;
    age:number;
    speak():void;
}

let p1:PersonInterface = {
    name:'tom',
    age:18,
    speak(){
        cosole.log(this.name)
    }
}

interface 可以继承、合并

interface PersonInterface {
    name:string // 姓名
    age:number // 年龄
}
interface PersonInterface {
    speak:() => void 
}

type 的交叉类型

interface 与抽象类的区别

  • 相同点:都用于定义一个类的格式(应该遵循的契约)
  • 不同点:
    1. interface:只能描述结构。不能有任何实现代码,一个类可以实现多个接口,用逗号‘,’分隔。
    2. 抽象类:既可以包含抽象方法,也可以包含具体方法,一个类只能继承一个抽象类

泛型

泛型允许我们在定义函数、类或接口时,使用类型参数来表示未指定的类型,这些参数在具体使用时,才被指定具体的类型,泛型能让同一代码适用于多种类型,同时仍然保持类型的安全性

泛型函数

function logData<T>(data:T):T {
    console.log(data)
    return data
}
logData<number>(100)
logData<string>('hello')

泛型可以有多个

function logData<T,U>(data:T,data2:U) : T | U {
    console.log(data1,data2)
    return Date.now() % 2 ? data1 :data2
}
logData<number,string>(100,'hello')
logData<string,boolean>('ok','false')

泛型接口

interface PersonInterface<T> {
    name:string,
    age:number,
    extraInfo:T
}

let p1:PersonInterface<string>
let p2:PersonInterface<number>

p1 = {name:'张三',age:18,extraInfo:'一个好人'}
p2 = {name:'李四',age:18,extraInfo:250}

泛型约束

interface PersonInterface<T> {
    name:string,
    age:number
}
function logPerson<T extends PersonInterface>(info:T):void {
     console.log(`我叫:${this.name},今年${this.age}
}

logPerson({name:'张三',age:18})

泛型类

class Person<T> {
    constructor(
        public name:string,
        public age:number,
        public extraInfo:T,
    ){}
    speak(){
       console.log(`我叫:${this.name},今年${this.age}
       console.log(this.extraInfo)
    }
}
// 测试代码1
const p1 = new Person<number>('tom',30,250)

// 测试代码2
type JobInfo = {
    title:string,
    company:string
}
const p2 = new Person<JobInfo>('tom',30,{title:'研发总监',company:'xxxx科技公司'})

类型声明文件

类型声明文件是TypeScript中的一种特殊文件,通常以.d.ts作为扩展名,它的主要作用是为现有的JavaScript代码提供类型信息,使得TypeScript能够在使用这些JavaScript库或模块时进行类型检查和提示

demo.js

export function add(a,b){ 
    return a + b
}
export function mul(a,b){
    return a * b
}

demo.d.ts

declare function add(a:number,b:number):number;
declare function mul(a:number,b:number):number;
export {add,mul}

Vue3源码解析(四):ref原理与原始值的响应式方案

作者 小航哥Sir
2025年4月15日 10:08

本文介绍了vue3中的ref的实现原理,还介绍了响应丢失(toRef、toRefs)的情况,以及自动脱ref是如何实现的,参考《Vue.js设计与实现》 更多Vue源码文章:

1. Vue3 源码解析(一):响应式数据和副作用函数、计算属性原理、侦听器原理

2. Vue3源码解析(二):响应式原理,如何拦截对象

3. Vue3源码解析(三):响应式原理,如何拦截数组

4. Vue2源码解析(一):响应式原理,如何拦截对象

5. Vue2源码解析(二):响应式原理,如何拦截数组

6. Vue3源码解析(三):如何代理Set和Map数据结构

快速回答版

  1. 介绍vue当中的ref的作用和内部原理
  • 将原始数据类型如数字、字符串、布尔值等转化为响应式数据
  • 内部实现是用一个对象包裹原始数据,属性为value,将对象传递给reactive函数
  • ref能够解决响应丢失问题,toRefs和toRef函数内部的实现原理和ref一致,借助了get拦截器
  1. vue3如何处理自动脱ref
  • 自动脱ref的定义:当访问ref响应式数据时,希望能够直接使用数据,不通过.value,在模版中常见
  • 实现方式:
    • vue3封装了proxyRefs函数,判断数据如果是ref数据(借助__v_isRef标识),则直接返回他的.value的内容。
    • setup里面返回的变量会自动传递给这个函数
  • reactive也能够实现自动脱ref

1. ref原理

Proxy无法对原始数据类型(包括number string boolean null undefined bigInt symbol)做代理,所以ref的实现必须得嵌套一层对象。

其内部实现如下:

function ref (val) {
    const wrapper = {
        value: val
    }
    Object.defineProperty(wrapper, '__v_ifRef', {
        value: true
    })
    return reactive(wrapper)
}
  • 第一,必须用wrapper包裹val的值,属性是value,将该值传递给reactive函数转化为响应式数据。这也是使用ref的数据必须用.value来访问的原因
  • 给wrapper创建了一个__v_ifRef属性,用来区分是原始数据类型还是引用数据类型

2. 解决响应丢失问题

如下解构obj对象的值赋值给newObj对象,并在副作用函数中访问,期待修改obj.foo为10以后副作用函数重新执行,但是如下并不能执行。因为newObj只是一个普通的对象,不会建立响应联系

const obj = reactive({
    foo: 1,
    bar: 2
})
const newObj = {
    ...obj
}
effect(() => {
    console.log(newObj.foo, 'newObj.foo');
});
obj.foo = 10

toRef函数toRefs函数内部实现原理:

  • toRef的返回值就是ref,借助ref来实现响应丢失问题
  • toRefs是批量调用toRef
function toRef(obj, key) {
    const wrapper = {
        get value () {
            // 访问器函数里面,访问obj[key],访问obj这个proxy对象的某个属性
            return obj[key]
        } 
    }
    return wrapper
}
function toRefs(obj) {
    const ret = {}
    // 针对每个属性调用toRef,整个对象都转化为响应式
    for (const key in obj) {
        ret[key] = toRef(obj, key)
    }
    return ret
}

此时修改数据能够触发响应

const obj = reactive({
    foo: 1,
    bar: 2
})
const newObj = {
    ...toRefs(obj)
}
effect(() => {
    console.log(newObj.foo.value, 'newObj.foo');
});

3. 自动脱ref

定义:定义一个ref响应式数据,在某些场合希望能够使用数据不用通过.value

举例:

  • 模版当中,直接访问newObj.foo,而不是newObj.foo.value
  • reactive包裹一个ref数据,也能够给他脱ref

这样写就会很麻烦

<p>{{ newObj.foo.value }}</p>

希望能够这样写

<p>{{ newObj.foo }}</p>

reactive函数的脱ref能力:

const count = ref(0)
const obj = reactive({ count })
obj.count // 0

实现原理:

  • vue3会把setup函数里面返回的响应式数据传递给proxyRefs函数,进行自动脱ref
  • 借助__v_isRef标识来判断是否是响应式数据
function proxyRefs (target) {
    return new Proxy(target, {
        get (target, key, receiver) {
            const value = Reflect.get(target, key, receiver)
            return value.__v_isRef ? value.value : value
        }
    })
}

4. 请看源码

/packages/reactivity/src/ref.ts
export function ref(value?: unknown) {
  return createRef(value, false)
}
function createRef(rawValue: unknown, shallow: boolean) {
  if (isRef(rawValue)) {
    return rawValue
  }
  return new RefImpl(rawValue, shallow)
}

class RefImpl<T = any> {
  _value: T
  private _rawValue: T

  dep: Dep = new Dep()

  public readonly [ReactiveFlags.IS_REF] = true
  public readonly [ReactiveFlags.IS_SHALLOW]: boolean = false

  constructor(value: T, isShallow: boolean) {
    this._rawValue = isShallow ? value : toRaw(value)
+    this._value = isShallow ? value : toReactive(value) // 这里调用了toReactive
    this[ReactiveFlags.IS_SHALLOW] = isShallow
  }
  ……省略下面的代码
}

/packages/reactivity/src/reactive.ts
export const toReactive = <T extends unknown>(value: T): T =>
  isObject(value) ? reactive(value) : value

proxyRefs函数

export function proxyRefs<T extends object>(
  objectWithRefs: T,
): ShallowUnwrapRef<T> {
  return isReactive(objectWithRefs)
    ? objectWithRefs
+    : new Proxy(objectWithRefs, shallowUnwrapHandlers) // 请关注这个shallowUnwrapHandlers
}


const shallowUnwrapHandlers: ProxyHandler<any> = {
  get: (target, key, receiver) =>
    key === ReactiveFlags.RAW
      ? target
+      : unref(Reflect.get(target, key, receiver)), // 这里执行了unref方法
  set: (target, key, value, receiver) => {
    const oldValue = target[key]
    if (isRef(oldValue) && !isRef(value)) {
      oldValue.value = value
      return true
    } else {
      return Reflect.set(target, key, value, receiver)
    }
  },
}

export function unref<T>(ref: MaybeRef<T> | ComputedRef<T>): T {
+  return isRef(ref) ? ref.value : ref // 如果是ref数据,直接返回他.value的值
}

🐞 Electron + Puppeteer 打包后报错:Could not find Chrome?一文彻底解决!

作者 前端九哥
2025年4月15日 10:06

在使用 Electron + Puppeteer 开发桌面应用时,我们常常会用 electron-builder 将项目打包成 exe 文件。然而,很多人在打包后运行应用时遇到了如下报错:

❌ Could not find Chrome (ver. 115.0.5790.98).
This can occur if either

  1. you did not perform an installation before running the script (e.g. npm install) or
  2. your cache path is incorrectly configured (which is: C:\Users\Administrator.cache\puppeteer)

这个报错看似是 Puppeteer 的问题,实则和打包配置密切相关。本文将带你深入理解这个问题的本质,并提供一套稳定可靠的解决方案,确保你的 Electron 应用打包后能顺利运行 Puppeteer。


📌 问题复现

我们在 Electron 项目中使用 Puppeteer,如下:

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('https://example.com');
  await browser.close();
})();

开发环境运行一切正常,但使用 electron-builder 打包后,双击 exe 文件运行,报如下错误:

Error: Could not find Chrome (ver. 115.0.5790.98).
This can occur if either
 1. you did not perform an installation before running the script (e.g. npm install) or
 2. your cache path is incorrectly configured (which is: C:\Users\Administrator\.cache\puppeteer)

🧠 问题分析

Puppeteer 默认在安装时会自动下载 Chromium 浏览器,并缓存到如下路径:

  • Windows: C:\Users\用户名.cache\puppeteer
  • macOS: ~/Library/Caches/puppeteer

但 electron-builder 打包时,并不会自动将这个缓存目录打包进 exe 文件。

也就是说,打包后的应用在运行时无法访问 Puppeteer 所需的 Chromium 浏览器,就会报错 “Could not find Chrome”。

所以,我们的目标是:

✅ 在打包时,将 Chromium 浏览器一并打包进应用中
✅ 在代码中手动指定 Chromium 的路径,避免使用默认缓存路径


✅ 解决方案:使用 puppeteer-core + 自定义 Chromium 路径

推荐使用 puppeteer-core,它是 Puppeteer 的轻量版本,不会自动下载 Chromium,需要你手动指定路径,更适合打包到 Electron 应用中。

步骤 1:卸载 puppeteer,安装 puppeteer-core

npm uninstall puppeteer
npm install puppeteer-core

两者的 API 是一致的,迁移成本非常低。

步骤 2:手动下载 Chromium 浏览器

你可以通过 Puppeteer 提供的命令下载 Chromium:

npx puppeteer browsers install chrome@115.0.5790.98

下载完成后,在缓存目录找到对应的 chrome 可执行文件:

Windows 示例路径:

C:\Users\你的用户名\.cache\puppeteer\chrome\win64-115.0.5790.98\chrome-win64\chrome.exe

将整个 chrome-win64 文件夹复制到你的项目中,例如:

/public/chrome-win64/chrome.exe

步骤 3:在代码中指定 Chromium 路径

使用 puppeteer-core 启动浏览器时,手动指定 executablePath:

const puppeteer = require('puppeteer-core');
const path = require('path');
const isDev = require('electron-is-dev');
// 注意要安装npm install electron-is-dev@1.2.0  ,只有1.2.0版本以下的才支持cmj导入

function getChromiumPath() {
  if (isDev) {
    // 开发环境下路径
    return path.join(__dirname, 'chrome-win64', 'chrome.exe');
  } else {
    // 打包后路径
    return path.join(process.resourcesPath, 'chrome-win64', 'chrome.exe');
  }
}

(async () => {
  const browser = await puppeteer.launch({
    executablePath: getChromiumPath(),
    headless: true,
    args: ['--no-sandbox', '--disable-setuid-sandbox']
  });

  const page = await browser.newPage();
  await page.goto('https://example.com');
  await browser.close();
})();

步骤 4:配置 electron-builder,打包 Chromium

在 package.json 中添加 build 配置,确保将 Chromium 一并打包:

"build": {
  "extraResources": [
    {
      "from": "public/chrome-win64",
      "to": "chromium"
    }
  ]
}

这样打包后,Chromium 会被复制到 app/resources/chromium 目录下,代码中指定的路径就能正确找到它。


🧪 可选方案:设置环境变量(不推荐)

你也可以设置环境变量 PUPPETEER_EXECUTABLE_PATH 来指定 Chromium 路径:

process.env.PUPPETEER_EXECUTABLE_PATH = path.join(...);

但 puppeteer 本体仍然可能访问默认缓存目录,容易出错。推荐使用 puppeteer-core。


📦 打包测试

完成上述配置后,运行打包命令:

npm run build

electron-builder --win

打包后的 exe 文件运行时就不会再报 “Could not find Chrome” 的错误啦!


📚 总结

Electron + Puppeteer 打包后运行报错 “Could not find Chrome”,是因为 puppeteer 默认使用缓存路径查找 Chromium,但打包时并未将 Chromium 一并打包。

推荐方案是使用 puppeteer-core,并手动指定 Chromium 路径,同时通过 electron-builder 的 extraResources 配置将其打包进去。

✅ puppeteer-core 不会下载 Chromium,适合打包
✅ 自定义 executablePath 更灵活
✅ extraResources 确保 Chromium 被打包进应用


🔗 参考资料

如何实现前端大文件上传(切片上传+断点续传)?

作者 小鬼大白
2025年4月15日 10:02

应用背景

在现代Web应用中,文件上传是一个常见的功能需求,尤其是大文件上传(如视频、高清图片、大型文档等)。传统的文件上传方式在处理大文件时存在以下问题:

  1. 网络不稳定:大文件上传时间长,网络波动可能导致上传失败,用户需要重新上传。
  2. 服务器压力:一次性上传大文件会占用大量服务器资源,可能导致服务器性能下降甚至崩溃。
  3. 用户体验差:上传失败后需要重新上传,用户等待时间长,体验不佳。

为了解决这些问题,前端大文件上传通常采用分片上传断点续传的技术:

  • 分片上传:将大文件分割成多个小文件(分片),逐个上传,减少单次上传的压力。
  • 断点续传:如果上传中断,可以从断点处继续上传,避免重新上传整个文件。

为什么需要大文件上传?

  1. 提高上传成功率:分片上传可以减少单次上传的文件大小,降低因网络波动导致的上传失败概率。
  2. 减轻服务器压力:分片上传可以分散服务器负载,避免一次性处理大文件。
  3. 提升用户体验:断点续传功能可以让用户在上传中断后继续上传,减少等待时间。
  4. 支持大文件上传:传统上传方式对文件大小有限制,分片上传可以突破这一限制。

代码实现

以下是基于前端技术(如 Vue.js 和 Axios)实现大文件上传的代码示例。

1. 文件分片

将大文件分割成多个小文件(分片)。

const CHUNK_SIZE = 5 * 1024 * 1024; // 每个分片大小为 5MB

function createFileChunks(file: File, chunkSize: number = CHUNK_SIZE) {
  const chunks = [];
  let start = 0;
  while (start < file.size) {
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end);
    chunks.push(chunk);
    start = end;
  }
  return chunks;
}

2. 上传分片

使用 Axios 上传每个分片。

import axios from "axios";

async function uploadChunk(chunk: Blob, index: number, fileHash: string) {
  const formData = new FormData();
  formData.append("chunk", chunk);
  formData.append("index", index.toString());
  formData.append("fileHash", fileHash);

  try {
    const response = await axios.post("/api/upload-chunk", formData, {
      headers: { "Content-Type": "multipart/form-data" },
    });
    return response.data;
  } catch (error) {
    console.error("上传分片失败:", error);
    throw error;
  }
}

3. 计算文件哈希

为文件生成唯一标识(哈希值),用于断点续传和文件校验。

import { md5 } from "js-md5";

async function calculateFileHash(file: File): Promise<string> {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onload = (e) => {
      const hash = md5(e.target?.result as string);
      resolve(hash);
    };
    reader.readAsBinaryString(file);
  });
}

4. 合并分片

所有分片上传完成后,通知服务器合并分片。

async function mergeChunks(fileName: string, fileHash: string) {
  try {
    const response = await axios.post("/api/merge-chunks", {
      fileName,
      fileHash,
    });
    return response.data;
  } catch (error) {
    console.error("合并分片失败:", error);
    throw error;
  }
}

5. 断点续传

在上传前检查服务器是否已存在部分分片,避免重复上传。

async function checkUploadedChunks(fileHash: string): Promise<number[]> {
  try {
    const response = await axios.get("/api/uploaded-chunks", {
      params: { fileHash },
    });
    return response.data.uploadedChunks || [];
  } catch (error) {
    console.error("检查已上传分片失败:", error);
    return [];
  }
}

6. 完整上传流程

将上述功能整合为一个完整的上传流程。

async function uploadFile(file: File) {
  const fileHash = await calculateFileHash(file);
  const chunks = createFileChunks(file);
  const uploadedChunks = await checkUploadedChunks(fileHash);

  for (let i = 0; i < chunks.length; i++) {
    if (uploadedChunks.includes(i)) continue; // 跳过已上传的分片
    await uploadChunk(chunks[i], i, fileHash);
  }

  await mergeChunks(file.name, fileHash);
  console.log("文件上传完成");
}

7. 前端调用

在 Vue.js 组件中调用上传功能。

<template>
  <div>
    <input type="file" @change="handleFileChange" />
    <button @click="handleUpload">上传</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
import { uploadFile } from "./uploadUtils";

const selectedFile = ref<File | null>(null);

function handleFileChange(event: Event) {
  const target = event.target as HTMLInputElement;
  if (target.files && target.files[0]) {
    selectedFile.value = target.files[0];
  }
}

async function handleUpload() {
  if (selectedFile.value) {
    await uploadFile(selectedFile.value);
  } else {
    alert("请先选择文件");
  }
}
</script>

服务器端实现(Node.js 示例)

1. 接收分片

const express = require("express");
const multer = require("multer");
const fs = require("fs");
const path = require("path");

const app = express();
const upload = multer({ dest: "uploads/" });

app.post("/api/upload-chunk", upload.single("chunk"), (req, res) => {
  const { index, fileHash } = req.body;
  const chunkPath = path.join("uploads", `${fileHash}-${index}`);
  fs.renameSync(req.file.path, chunkPath);
  res.send({ success: true });
});

2. 合并分片

app.post("/api/merge-chunks", (req, res) => {
  const { fileName, fileHash } = req.body;
  const chunkDir = path.join("uploads");
  const chunks = fs.readdirSync(chunkDir).filter((name) => name.startsWith(fileHash));
  chunks.sort((a, b) => parseInt(a.split("-")[1]) - parseInt(b.split("-")[1]));

  const filePath = path.join("uploads", fileName);
  const writeStream = fs.createWriteStream(filePath);
  chunks.forEach((chunk) => {
    const chunkPath = path.join(chunkDir, chunk);
    const chunkData = fs.readFileSync(chunkPath);
    writeStream.write(chunkData);
    fs.unlinkSync(chunkPath); // 删除分片
  });
  writeStream.end();
  res.send({ success: true });
});

3. 检查已上传分片

app.get("/api/uploaded-chunks", (req, res) => {
  const { fileHash } = req.query;
  const chunkDir = path.join("uploads");
  const uploadedChunks = fs.readdirSync(chunkDir)
    .filter((name) => name.startsWith(fileHash))
    .map((name) => parseInt(name.split("-")[1]));
  res.send({ uploadedChunks });
});

总结

通过分片上传和断点续传技术,可以有效解决大文件上传中的网络不稳定、服务器压力大和用户体验差等问题。前端将文件分片并逐个上传,服务器接收分片并最终合并,同时支持断点续传功能。

CSS篇:彻底搞懂CSS百分比边距:margin-top和padding-top的计算原理

2025年4月15日 10:02

🎓 作者简介前端领域优质创作者

🚪 资源导航: 传送门=>

🎬 个人主页:  江城开朗的豌豆

🌐 个人网站:    江城开朗的豌豆 🌍

📧 个人邮箱: YANG_TAO_WEB@163.com 📩

💬 个人微信:     y_t_t_t_ 📱

📌  座  右 铭: 生活就像心电图,一帆风顺就证明你挂了 💔

👥 QQ群:  906392632 (前端技术交流群) 💬

一、一个容易忽视的CSS细节

在日常开发中,我们经常使用百分比值来设置元素的margin和padding。但你是否遇到过这样的情况:给元素设置margin-top: 10%后,实际效果却和预期不符?这背后隐藏着CSS一个重要的计算规则。今天,我们就来深入剖析这个看似简单却容易让人困惑的特性。

二、百分比边距的计算基准

1. 核心规则

无论是margin-top还是padding-top当使用百分比值时,都是相对于包含块的宽度来计算的。这个规则看似反直觉,但确实是W3C标准中明确规定的。

.container {
  width: 800px;
}

.child {
  margin-top: 10%; /* 实际计算值为800px的10%,即80px */
  padding-top: 5%;  /* 实际计算值为800px的5%,即40px */
}

2. 为什么是宽度而不是高度?

这种设计主要有两个原因:

  • CSS早期需要避免循环依赖(高度依赖margin,margin又依赖高度)
  • 保持水平垂直方向计算基准的一致性

三、实际应用场景分析

1. 响应式等比例间距

.card {
  width: 90%;
  margin-top: 5%; /* 基于父容器宽度的5% */
}

效果:当页面宽度变化时,上下边距会等比例缩放

2. 实现固定宽高比元素

.aspect-ratio-box {
  width: 100%;
  padding-top: 56.25%; /* 16:9宽高比 (9/16=0.5625) */
  position: relative;
}

.content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

四、特殊情况处理

1. 绝对定位元素的计算基准

对于绝对定位的元素,百分比边距是相对于定位祖先的padding box宽度计算。

.parent {
  position: relative;
  width: 400px;
}

.child {
  position: absolute;
  margin-top: 10%; /* 40px */
}

2. Flex/Grid布局中的表现

在Flex和Grid布局中,百分比边距仍然基于包含块的宽度计算,而不是flex/grid容器。

五、常见误区与解决方案

1. 误区:期望基于高度计算

错误预期

.banner {
  height: 300px;
  margin-top: 10%; /* 期望30px */
}

实际结果:基于宽度计算

2. 解决方案:使用视口单位

如果需要基于视口高度:

.banner {
  margin-top: 10vh; /* 视口高度的10% */
}

3. 解决方案:使用CSS变量

:root {
  --base-size: 16px;
}

.element {
  margin-top: calc(var(--base-size) * 5);
}

六、最佳实践建议

  1. 明确计算基准:始终记住百分比边距基于包含块宽度
  2. 响应式设计:善用这个特性创建真正的响应式间距
  3. 替代方案:需要基于高度时考虑使用vh单位或JavaScript计算
  4. 调试技巧:使用开发者工具检查计算后的具体像素值

七、浏览器兼容性说明

这个特性在所有现代浏览器中都得到了完美支持,包括:

  • Chrome 1+
  • Firefox 1+
  • Safari 3+
  • Edge 12+
  • Opera 7+

结语

理解CSS百分比边距的计算原理,可以帮助我们避免很多布局上的困惑,也能让我们更精准地控制页面元素的空间关系。记住这个简单的规则:在CSS中,垂直方向的百分比边距(margin/padding-top/bottom)都是基于包含块的宽度计算的

下次当你的margin-top表现不如预期时,不妨检查一下包含块的宽度设置。你在项目中遇到过哪些关于百分比边距的有趣案例?欢迎在评论区分享你的经验!

CSS篇: 探索CSS3新特性:这些炫酷特性你都用过了吗?

2025年4月15日 10:01

🎓 作者简介前端领域优质创作者

🚪 资源导航: 传送门=>

🎬 个人主页:  江城开朗的豌豆

🌐 个人网站:    江城开朗的豌豆 🌍

📧 个人邮箱: YANG_TAO_WEB@163.com 📩

💬 个人微信:     y_t_t_t_ 📱

📌  座  右 铭: 生活就像心电图,一帆风顺就证明你挂了 💔

👥 QQ群:  906392632 (前端技术交流群) 💬

一、CSS3带来的变革

还记得那个只能用float布局的年代吗?CSS3的出现彻底改变了前端开发的方式。作为CSS的最新标准,CSS3不仅带来了视觉效果的飞跃,更大幅提升了开发效率。今天,我们就来全面盘点那些改变游戏规则的CSS3新特性

二、布局革命:Flexbox与Grid

1. Flex弹性布局

.container {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

优势:简单实现垂直居中、等高等复杂布局

2. Grid网格布局

.layout {
  display: grid;
  grid-template-columns: 200px 1fr;
  gap: 20px;
}

场景:实现杂志般的复杂版面设计

三、视觉增强:边框与背景

1. 圆角边框

.element {
  border-radius: 10px;
  /* 单独设置每个角 */
  border-radius: 10px 5px 20px 0;
}

2. 盒阴影与文字阴影

.card {
  box-shadow: 2px 2px 10px rgba(0,0,0,0.1);
}

.title {
  text-shadow: 1px 1px 3px #ccc;
}

3. 渐变背景

.header {
  background: linear-gradient(to right, #ff9966, #ff5e62);
}

四、动画与过渡

1. 过渡效果

.button {
  transition: all 0.3s ease;
}

2. 关键帧动画

@keyframes spin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

五、响应式设计利器

1. 媒体查询

@media (max-width: 768px) {
  .menu { display: none; }
}

2. 视口单位

.banner {
  height: 100vh;
  font-size: 5vw;
}

六、选择器增强

1. 属性选择器

input[type="email"] {
  border-color: blue;
}

2. 结构伪类

li:nth-child(odd) {
  background: #f5f5f5;
}

七、文字与排版

1. 自定义字体

@font-face {
  font-family: 'MyFont';
  src: url('myfont.woff2') format('woff2');
}

2. 文字效果

.text {
  text-overflow: ellipsis;
  white-space: nowrap;
  overflow: hidden;
}

八、变形与3D效果

1. 2D变换

.element {
  transform: rotate(15deg) scale(1.2);
}

2. 3D变换

.card {
  transform: perspective(500px) rotateY(30deg);
}

九、CSS变量

:root {
  --primary-color: #4285f4;
}

.button {
  background: var(--primary-color);
}

十、实战建议

  1. 渐进增强:确保在不支持CSS3的浏览器中基本功能可用
  2. 性能优化:避免过度使用耗性能的特性如阴影和渐变
  3. 工具链:使用PostCSS等工具增强兼容性
  4. 移动优先:优先考虑移动设备的支持情况

结语

CSS3为现代Web开发带来了无限可能,从简单的圆角边框到复杂的3D变换,这些特性让我们能够创造出更加丰富、更具交互性的用户体验。建议开发者:

  1. 根据项目需求合理选择CSS3特性
  2. 持续关注新特性的浏览器支持情况
  3. 在实际项目中多实践、多尝试
  4. 关注CSS Houdini等未来特性

你最喜欢哪个CSS3特性?在评论区分享你的使用心得吧!

❌
❌