阅读视图

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

前端啊前端,AI浪潮下,你还能干点啥?

这个标题,我自己感觉有点爹味~

但属于开放式探讨,各位前端有任何想法,欢迎留言,请不吝赐教!🤘🤘🤘🤘🤘

image.png

起因是最近在阮老师发的网络日志中,看到一篇文章,文章的题目就是《The future of web development is AI. Get on or get left behind.》,有条件的可以戳进去看原文。

标题一看很唬人,而且像我这么愿意学习先进的科学理论知识和丰富的生产经验容易焦虑的人,一定会点进去看看。

全文如下:

Editor’s Note: previous titles for this article have been added here for posterity.

The future of web development is blockchain. Get on or get left behind.

The future of web development is CSS-inJS. Get on or get left behind.

The future of web development is Progressive Web Apps. Get on or get left behind.

The future of web development is Silverlight. Get on or get left behind

The future of web development is XHTML. Get on or get left behind.

The future of web development is Flash. Get on or get left behind.

The future of web development is ActiveX. Get on or get left behind.

The future of web development is Java applets. Get on or get left behind.

If you aren’t using this technology, then you are shooting yourself in the foot. There is no future where this technology is not dominant and relevant. If you are not using this, you will be unemployable. This technology solves every development problem we have had. I can teach you how with my $5000 course.

看完后,乐了。虽然是一个段子,但不得不承认,卖课的比我们前端佬本身,更关注前端的市场上的应用,虽然他们的目的😂😂😂~

于是乎,想在掘金这么浓厚的技术社区里,斗胆跟各位讨论下这个话题~主要角度是从技术来说。

简单回顾下WEB产品

我个人感觉是WEB技术的升级等同于WEB媒介的升级。说人话就是,从最初只能展示的文字到图片(2D、3D、矢量图)展示,再到如今富媒体(比如视频、音频、各种文档文件)展示,交互方式变的越来越花样百出。

在WEB产品上的体现就是,早期简单的黄页展示,到会布灵布灵闪的网页杀马特效果,再到划时代WEB应用谷歌地球。下面是我网络上找的图拼贴的,大家可以感受下:

image.png

以上是PC端的。那么移动端,从当初的功能机、到智能的塞班,再到安卓或iOS,变迁也很大:

1746882700010.jpg

web产品,经历很多的变化,对应技术,也经历很多的变化。

web技术

WEB产品对应的技术架构,就是我们熟悉的B/S架构,大致如下:

image.png

前端开发工程师这个职位,就是在表示层,语言为(HTML+CSS+Javascript)。

到了如今,前端开发工程师可以做什么呢?

小程序、网页就不说了。web应用很早之前就出现了谷歌地球那样的应用。近些年出现的WebAssemblyWeb Worker,更是进一步提高了web应用可操作的资源。

有了node,安卓应用、iOS的应用、window应用等可以搞!AI/AR/VR,也支持搞!而且,Node是跨平台的。意味着Linux系统也是可以的。

总结来说,目前一个纯粹以技术角度来说,前端佬,是完全可以全栈的。

3.gif

以下罗列的部分前端技术。

技术栈 产品名称/应用场景
LayUI 快速构建后台管理系统
ECharts 数据可视化平台(如商业报表、实时监控仪表盘)
Vite + React/Vue/Svelte 企业级Web应用
Next.js 全栈应用(如电商网站、内容管理系统)
Tauri + Rust 跨平台桌面应用
Astro 静态网站(如博客、文档站点、营销页面)
Three.js/WebGL 3D交互应用(如虚拟展厅、游戏、AR/VR场景)
Neditor/UEditor 富文本编辑器(如在线文档、CMS内容编辑)
Qwen Web Dev(AI生成) 自动生成网页应用(如个人网站、表单页面)

好,上面的对于一个前端佬说,就有点絮叨了。毕竟前端该干点啥,脱离不了

那么前端未来的前景,都可以干啥呢?以下几节是自己的看法,大佬随便拍砖~

利用客户端的资源减少服务器计算压力

这是前端固有的优势,调动客户端的资源。比如内存、CPU、GPU之类的。

对应前端的技术,比如硬件加速、本地缓存、WebAssembly,都是前端可利用的资源。

一些比较单一的计算,比如用canvas识别同类照片,或者有限数据下的计算和渲染(可视范围内的数据渲染),我个人觉的,目前技术下,是前端很大的优势。

但这种技术不是统一标准可言,需要根据业务自行评估。公式上就是:(客户端的消耗+数据保密需求)》服务端。

2.gif

大型应用上,nodejs干不过其它服务端语言

按照个人的理解,nodejs对于一些轻量的服务或者中间件,对于Javascript语言熟悉者,是比较优势的(用词比较谨慎,可以直接说就是前端佬)。

如果公司有这类业务,又没有其他后端干扰的情况下,可以直接干。

这里轻量,我给出一个自己的一个经验,那就是极限在在4000+ RPS。这个是极端值,也是苦思冥想的优化结果。

如果是实时通讯、流媒体、WebSocket之类IO密集型的,又不超过上面值的,请放心用nodej。

这里要强烈强烈强雷重点提下!!! 上面数值是自己的经验,毕竟前端大佬这么多,如果有超过这个的。欢迎拍砖。

好了,说原因。

原因在于nodejs在多线程上,还是不够。Node.js 的优点就是非阻塞、事件驱动的单线程模型,然后会Javascript的人多。

2fh47XSZ.gif

重提一次,web中渲染的优势

假如说以构建web应用的技术来对比,可以说前端的三项Javascript+CSS+HTML无其他技术可代替。我个人觉得从技术角度+维护+项目工程化,无出其右。

生态摆在那,浏览器优化方向摆在那,CSS强大的容错摆在那里。

反正牛就对了。

所以在web中,做一些狂在酷炫吊炸天的交互或者数据渲染,当仁不让。

结尾

总之上面都是一家之言,各位大佬随便喷,随便指点。

666.gif

一文读懂Python函数:基础、进阶与最佳实践

一、什么是函数?

函数(Function)是组织好的、可重复使用的、用来实现单一或相关联功能的代码段。它能让代码更简洁、可维护性更强。

1.1 为什么要用函数?

  • 复用代码:避免重复劳动
  • 提高可读性:让代码结构更清晰
  • 便于维护:修改时只需改一处

二、Python函数的基础用法

2.1 定义和调用函数

def greet():
    print("Hello, Python!")
    
greet()  # 输出:Hello, Python!

2.2 带参数的函数

def greet(name):
    print(f"Hello, {name}!")
    
greet("Alice")  # 输出:Hello, Alice!

2.3 返回值

def add(a, b):
    return a + b

result = add(3, 5)
print(result)  # 输出:8

三、进阶用法

3.1 默认参数

def greet(name="World"):
    print(f"Hello, {name}!")

greet()         # 输出:Hello, World!
greet("Bob")    # 输出:Hello, Bob!

3.2 关键字参数

def introduce(name, age):
    print(f"My name is {name}, and I'm {age} years old.")

introduce(age=25, name="Alice")

3.3 可变参数

  • *args:接收任意数量的位置参数
  • **kwargs:接收任意数量的关键字参数
def demo(*args, **kwargs):
    print("args:", args)
    print("kwargs:", kwargs)

demo(1, 2, 3, a=4, b=5)
# 输出:
# args: (1, 2, 3)
# kwargs: {'a': 4, 'b': 5}

四、函数的高级特性

4.1 匿名函数(lambda表达式)

square = lambda x: x * x
print(square(5))  # 输出:25

4.2 函数作为参数传递

def apply(func, value):
    return func(value)

print(apply(lambda x: x + 1, 10))  # 输出:11

4.3 闭包(Closure)

def outer(x):
    def inner(y):
        return x + y
    return inner

add_five = outer(5)
print(add_five(10))  # 输出:15

4.4 装饰器(Decorator)

def my_decorator(func):
    def wrapper():
        print("Something is happening before the function is called.")
        func()
        print("Something is happening after the function is called.")
    return wrapper

@my_decorator
def say_hello():
    print("Hello!")

say_hello()

五、最佳实践与常见误区

5.1 函数注释与类型提示

def add(a: int, b: int) -> int:
    """
    两数相加
    """
    return a + b

5.2 函数返回多个值其实是元组

def foo():
    return 1, 2

result = foo()
print(result)      # (1, 2)
a, b = foo()
print(a, b)        # 1 2

5.3 参数解包顺序错误

def func(a, b, c):
    print(a, b, c)

args = (1, 2, 3)
func(*args)  # 正确

kwargs = {'a': 1, 'b': 2, 'c': 3}
func(**kwargs)  # 正确

误区:*args和**kwargs不能乱用,顺序和名称要对应。


如果你觉得这篇文章有用,记得点赞、关注、收藏,学Python更轻松!!

前端骨架屏的三种运用方式

一、骨架屏的三大运用方式及案例

  1. 静态HTML/CSS方案
<!-- 电商商品详情页案例 -->
<div class="skeleton-container">
  <div class="skeleton-header"></div>
  <div class="skeleton-gallery">
    <div class="skeleton-image"></div>
    <div class="skeleton-thumbnails">
      <div class="skeleton-thumb"></div>
      <div class="skeleton-thumb"></div>
    </div>
  </div>
  <div class="skeleton-content">
    <div class="skeleton-line" style="width:80%"></div>
    <div class="skeleton-line" style="width:60%"></div>
  </div>
</div>

<style>
.skeleton-image {
  background: linear-gradient(90deg,#f2f2f2 25%,#e6e6e6 50%,#f2f2f2 75%);
  animation: shimmer 1.5s infinite;
  height: 300px;
}
@keyframes shimmer {
  to { background-position-x: -200%; }
}
</style>

关键点:通过CSS动画模拟加载效果,需精确匹配真实DOM结构

  1. 组件化方案(Vue/React)
// Vue动态骨架屏组件
<template>
  <div v-if="loading" class="skeleton-wrapper">
    <SkeletonItem v-for="i in 5" :key="i" 
      :width="i===1 ? '80%' : '100%'" 
      :height="i===1 ? '24px' : '16px'"/>
  </div>
  <div v-else>
    <ActualContent :data="contentData"/>
  </div>
</template>

<script>
export default {
  components: {
    SkeletonItem: {
      props: ['width','height'],
      template: `<div class="skeleton-item" :style="{width, height}"/>`
    }
  }
}
</script>

优势:可复用组件,动态控制显示状态

  1. 自动化生成方案
// 使用vue-skeleton-webpack-plugin
const SkeletonWebpackPlugin = require('vue-skeleton-webpack-plugin');

module.exports = {
  configureWebpack: {
    plugins: [
      new SkeletonWebpackPlugin({
        webpackConfig: {
          entry: './src/skeleton.js'
        },
        minimize: true,
        quiet: true
      })
    ]
  }
}

特点:通过路由匹配自动生成对应骨架屏

二、关键实现要点

  1. 视觉一致性原则
  • 占位元素需与真实内容布局1:1对应
  • 使用与UI风格协调的渐变色系
  • 动画持续时间控制在1-1.5秒
  1. 性能优化策略
  • 骨架屏代码应控制在5KB以内
  • 避免使用图片资源,纯CSS实现
  • 与PWA的预缓存策略结合使用
  1. 状态管理规范
// 鸿蒙APP中的状态管理案例
Page({
  data: {
    loading: true,
    error: false,
    empty: false
  },
  onLoad() {
    fetchData().then(res => {
      this.setData({ 
        loading: false,
        list: res.data || []
      });
    }).catch(() => {
      this.setData({ error: true });
    });
  }
})

最佳实践:需处理加载失败/数据为空等边界情况

前端首屏优化从这个几个方面入手就够了

一、资源加载优化:高速公路的ETC通道

核心思想:让关键资源像ETC通道一样快速通过

<!-- 预加载关键CSS -->
<link rel="preload" href="styles/main.css" as="style" onload="this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="styles/main.css"></noscript>

<!-- 异步加载非关键JS -->
<script>
  function loadScript(src) {
    var script = document.createElement('script');
    script.src = src;
    script.async = true;
    document.body.appendChild(script);
  }
  // 页面加载完成后再加载分析脚本
  window.addEventListener('load', function() {
    loadScript('https://example.com/analytics.js');
  });
</script>

代码注释

  1. rel="preload" 告诉浏览器优先下载这个资源
  2. as="style" 指明资源类型以便浏览器优化
  3. onload 事件确保CSS加载后立即应用
  4. async 属性使脚本异步加载不阻塞渲染

二、渲染路径优化:建筑工地的施工蓝图

核心思想:先搭好架子再精装修

// 使用Vue实现骨架屏
<template>
  <div class="product-detail">
    <!-- 真实内容 -->
    <div v-if="!loading" class="content">...</div>
    
    <!-- 骨架屏 -->
    <div v-else class="skeleton">
      <div class="skeleton-header"></div>
      <div class="skeleton-image"></div>
      <div class="skeleton-line"></div>
      <div class="skeleton-line"></div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      loading: true
    }
  },
  mounted() {
    // 模拟数据加载
    fetchData().then(() => {
      this.loading = false;
    });
  }
}
</script>

<style>
.skeleton {
  /* 骨架屏动画效果 */
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
  to { background-position: -200% 0; }
}
</style>

代码注释

  1. v-if/v-else 切换真实内容和骨架屏
  2. 骨架屏使用CSS动画模拟加载效果
  3. 数据加载完成后隐藏骨架屏

三、代码优化实践:行李箱的合理收纳

核心思想:像整理行李箱一样组织代码

// webpack.config.js 配置代码分割
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\/]node_modules[\/]/,
          name: 'vendors',
          chunks: 'all',
        },
      },
    },
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash].css',
    }),
  ],
};

// 路由懒加载示例 (React)
const Home = React.lazy(() => import('./views/Home'));
const About = React.lazy(() => import('./views/About'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </Suspense>
  );
}

代码注释

  1. splitChunks 将node_modules代码单独打包
  2. contenthash 实现长效缓存
  3. React.lazy + Suspense 实现路由懒加载

四、网络层优化:物流配送的智能调度

核心思想:建立高效的资源配送网络

# Nginx配置示例
server {
    listen 443 http2;  # 启用HTTP/2
    
    # 开启gzip压缩
    gzip on;
    gzip_types text/css application/javascript;
    
    # 静态资源缓存
    location ~* .(jpg|jpeg|png|gif|ico|css|js)$ {
        expires 365d;
        add_header Cache-Control "public, no-transform";
    }
    
    # HTTP/2服务端推送
    location = /index.html {
        http2_push /static/css/main.css;
        http2_push /static/js/main.js;
    }
}

代码注释

  1. http2 协议提升并发性能
  2. gzip 压缩减少传输体积
  3. expires 设置长期缓存
  4. http2_push 主动推送关键资源

完整项目优化方案

  1. 构建阶段
# 安装优化插件
npm install --save-dev purgecss-webpack-plugin compression-webpack-plugin
  1. React优化配置
// craco.config.js (用于覆盖create-react-app配置)
module.exports = {
  webpack: {
    plugins: {
      add: [
        new CompressionPlugin(), // gzip压缩
        new PurgeCSSPlugin({    // 清除无用CSS
          paths: glob.sync(`${paths.appSrc}/**/*`, { nodir: true }),
        }),
      ],
    },
  },
};
  1. 图片优化方案
<picture>
  <!-- 优先加载WebP格式 -->
  <source srcset="image.webp" type="image/webp">
  <!-- 兼容旧浏览器的回退方案 -->
  <source srcset="image.jpg" type="image/jpeg"> 
  <img src="image.jpg" loading="lazy" alt="示例图片">
</picture>

通过这四个维度的系统优化,可将典型SPA应用的首屏加载时间从4-5秒降至2秒以内,大幅提升用户体验。

如何优雅的 debug template

今天组内技术分享,有位大佬分享了一个 debug template解析的官方方式,debug 得很优雅,本篇文章分享下这个方式,这个方式在 vue/core 中有介绍

像是 vue3 的 github 仓库我们找到 contribution 那儿,进入到

Contributing Guide

1.png

然后来到 Development Setup 侧,跟着说明执行指令即可

pnpm i

在执行之前一定要规范自己的 node 版本是否和 .node-version 的版本一致

pnpm 安装完依赖后,直接

pnpm run dev-compiler

然后打开浏览器,输入 url

http://localhost:3000/packages-private/template-explorer/#eyJzcmMiOiI8ZGl2PkhlbGxvIFdvcmxkPC9kaXY+Iiwib3B0aW9ucyI6e319

这其实就是 template playgroundvapor 其实也有一个 这样的 playground ,后续 debug vapor 应该也可以采用这个方式

随后我们可以自己在 本地启动的 playground 更改 template 代码,比如我希望看 v-if 如何被解析的,我就在 template 写一个 v-if demo

2.png

随后定位到 compiler-core 这个 package 中插入 debugger,就可以随意在浏览器中调试

这个调试方法相比较自己启动一个 vue 项目然后 在 plugin 导入那儿打一个断点会更加专注

更加专注于 vue/package 中的代码逻辑实现,并且这种方式看到的是 ts ,你可以很清晰地看到 interface 结构

当然,若你希望看到 vite 如何解析 vue 的还是建议用那套方式

另外,debug 源码时,一定要先预测当前 debug 的函数逻辑是否为预期的,因为像是这种大型框架的源码,函数都是层层嵌套,所以可以先观测这个函数的入参,然后直接跳过函数的执行看返回结果,看这个返回结果是否预期

来个 demo

比如 我写了个 v-if ,我们可以先在 package/compiler-core/src/transform.ts 中针对 transform 这个统一的函数插入一个 debugger

3.png

随后刷新浏览器界面,进入断点

4.png

其实 if 这个指令具体对应着 transformIf 这个函数,这个函数位于 packages\compiler-core\src\transforms\vIf.ts

我们针对这个函数打一个断点,然后阅读 processIf 的具体逻辑

那个深夜,她说二维码扫不出来,我说我们还有办法,论Vue3 项目中的二维码何去何从

背景

2025年5月的晚风裹着槐花香,轻轻掀动窗帘的一角。办公室的灯光像散落在深海里的星星,零星亮着。我的电脑屏幕还泛着蓝光,我揉了揉发酸的眼睛,瞥见隔壁工位的妹子小语正对着屏幕叹气。

“又卡住了?”我端着半杯凉透的咖啡走过去。

小语抬起头,头发松松垮垮地挽在发顶,眼睛亮得像刚充完电的手机:“用户说活动二维码扫不出来,但后台显示数据正常……你看这图,中间的Logo放大后直接糊成一团,扫码器也不认。”

她指着屏幕上灰扑扑的二维码,像在抱怨一个不听话的孩子。


痛点:二维码的“感冒”时刻

小语的项目需要生成大量带品牌Logo的二维码,用于线下活动推广。起初用的是最简单的Canvas方案,但Logo一加,用户反馈“扫出来全是乱码”。后来改用SVG格式,结果在低端手机上加载卡顿,甚至导致页面崩溃。

“就像给二维码穿了件不合身的衣服,”小语苦笑着敲了敲键盘,“它自己难受,看的人也难受。”

我凑近看了看代码,发现数据请求倒是正常,问题全出在渲染环节——Logo覆盖破坏了二维码的容错率,而低配设备扛不住动态生成的计算量。

“之前是直接用原生qrcode库生成Canvas,再用drawImage把Logo贴上去。”小语调出历史代码,像展示一件失败的手工艺品,“但Logo太大时,遮住的模块太多,容错率就崩了。”

她指着一行注释:“试过限制Logo大小,但设计师觉得比例太小‘不够品牌感’。”

我点点头,想起上周组会时,前端大佬阿洛吐槽:“手动拼图就像拿针线补高铁轨道,不是不能用,是太容易翻车。”


启发时刻:茶水间的灵光

午夜茶水间里,阿洛捏着马克杯晃进来:“你们还在手动生成二维码?试试qrcode.vue,人家连Logo留白都算好了。”

他掏出手机比划:“这个库内部会自动把Logo区域的模块预留出来,再用高容错模式(level='H')补全数据。就像织毛衣时留出纽扣孔,不用事后打补丁。”

我突然想起文档里那句“支持Reed-Solomon算法”,这不就是地铁票上那个能被刮花还能扫的纠错技术?

优化过程:温柔的代码手术

第二天,我帮小语换了方案:

pnpm add qrcode.vue

代码变成了这样:

<template>
  <qrcode-vue
    :value="qrText"
    :size="200"
    level="H"
    :logo="{
      src: '/logo.png',
      logoSize: 0.2,
      borderSize: 5,
      bgColor: '#fff'
    }"
  />
</template>
<script setup>
import { ref } from 'vue'
import QrcodeVue from 'qrcode.vue'

const qrText = ref('https://xxxxx.com')
</script>

level='H'能承受30%的损坏,”我边写边解释,“Logo控制在25%以内,加上白色背景缓冲,相当于给二维码戴了口罩——遮住脸,但不影响呼吸。”

小语眨眨眼:“那性能呢?”

“库内部做了防抖,数据更新时不会频繁重绘。大尺寸的话,用resizeObserver动态加载。”我敲了敲键盘,“就像空调自动调温,不用人盯着。”

我还提醒她,logo 路径要放在 public 目录,确保访问稳定;如果二维码内容是动态的,比如后端接口返回链接,只要修改 qrText 绑定的值,二维码会自动刷新

后来,小语实测了一下页面加载时间,从原来卡顿的 300ms 降到了不到 100ms,体验明显流畅许多。她也遇到了一个小坑:初次加载时 logo 图片路径写错导致二维码显示异常,后来才意识到图片资源必须正确引用。

不过也踩了坑:

  • 初版Logo用的是带透明通道的PNG,在部分安卓机上变成黑块。后来加了bgColor参数,像给图片加个素描框。
  • 动态URL更新时偶尔闪白屏,加了v-if懒加载后缓解。

结局

小语关掉浏览器时,窗外的月光已经移到了工位边缘。她看着新生成的二维码,Logo恰到好处地嵌在中央,像枚精致的徽章。

渐渐地,办公室只剩下小语和我两盏灯。我眼睛望向窗外的星空,灯光下两人彼此的背影拉长,那一刻,工作不只是代码,更是默契和温柔的守护。

类型体操入门 —— 解决Pick等初级问题的套路

在讲解Pick等初级类型体操如何实现之前,我想有几个前置知识点是要知道的。

  • keyof 运算符
  • in 运算符
  • 泛型与条件约束
  • 鸭子类型

前置内容

keyof运算符

keyof运算符可以取出对象类型的键名的联合类型

type MyObj = {
  foo: number
  bar: string
}

type Keys = keyof MyObj // 'foo'|'bar'

in运算符

in运算符用来取出联合类型的每一个成员类型。可以理解成针对类型的遍历操作。

type U = 'a' | 'b' | 'c'

type Foo = {
  [Prop in U]: number
}
// 等同于
type Foo = {
  a: number
  b: number
  c: number
}

结合keyof 和 in

keyofin运算符经常结合起来用,keyof负责取出对象类型的键名,in负责遍历这些键名。

我们可以做一个简单的练习,假如有一个对象类型,里面有属性a、b、c...,现在我想要一个如下的对象类型,应该怎么做呢?

type O = {
  a: 1
  b: 2
  c: 3
  ...
}

//如何通过O获得R?
type R = {
  a: true
  b: true
  c: true
}

答案如下,先写一个大括号,因为返回的肯定是对象类型。

然后左边是中括号,因为我们不可能一个个的把O的属性名写出来。

右边是写死的true。

中括号里写什么呢?先写keyof操作符提取O的所有键名,再用in操作符做遍历,大功告成!

type R = {
  [key in keyof O]: true
}

泛型与约束

泛型简单的说就是类型参数,当我们拿不准一个地方应该用什么类型,但又要对其有后续操作的时候,就可以用泛型做一个临时指代。

上面这句话有点抽象,如果看不懂的话推荐你来这里看泛型介绍。我就不再赘述了。

泛型的约束才是我要说的重点。泛型可以被约束,比如你希望传入的值必须有length属性,也就是想限制这个变量可能是数组或者可能是字符串。那应该怎么办呢?

比如下面这个例子,相比较两个参数的length属性大小,就必须限制参数的类型有length属性。注释里,通过<T extends {length:number}>,限制了T必须有length属性。

function comp(a, b) {
  if (a.length >= b.length) {
    return a
  }
  return b
}
// function comp <T extends {length:number}>(a:T ,b:T){
// ...
// }

你可能想反驳,如果有一个对象有属性length,也有可能既不是数组也不是字符串啊,它也能通过类型校验不是吗?似乎这么写似乎不是很严谨。

这是因为涉及到了鸭子类型的思想。什么是鸭子类型呢?有一句经典的话你可能听过:

“当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。”

所以重点不是这个参数究竟是什么类型,而是它是否具有你想要的属性。

我们可以看看Typescript是如何实现Promise类型的。

// 简化版,详见lib.es5.d.ts
interface Promise {
  then(...)
  catch(...)
}

也是基于鸭子类型的思想,简单的讲,如果这个对象可以点then,点catch,那么我就认定你是Promise。

类似的还有一个类型叫PromiseLike的类型,它的定义更简单,这个对象能点then,我就认为你是PromiseLike,一个类似Promise的对象。

// 简化版,详见lib.es5.d.ts
interface PromiseLike{
    then(...)
}

回到Pick

所以Pick要如何实现呢,如果你看懂上面的讲解,那么答案呼之欲出了。

type Pick<T, K> = ...

答案如何呢?

  1. 先写一个大括号,因为返回的是对象类型
  2. 左边中括号,因为不可能写死每个属性,所以中括号里面是key in K以遍历每个属性名。
  3. K这个泛型需要约束,所以补充上 K extends keyof T
  4. 右边是T[key],表示取值
type Pick<T, K extends keyof T> = {
  [key in K]: T[key]
}

完美~

举一反三

所以我们回到类型体操这个项目中来,像Readonly这道题自然也就不在话下。

如何实现一个内置的Readonly<T>

只需要遍历对象类型每一项的时候加上readonly前缀即可。

type Readonly<T> = {
  readonly [key in keyof T]: T[key]
}

还有像这道题,Tuple To Object,如何实现把元组转为对象类型?

// 例如
const tuple = ['tesla', 'model 3', 'model X', 'model Y'] as const

type result = TupleToObject<typeof tuple> // expected { 'tesla': 'tesla', 'model 3': 'model 3', 'model X': 'model X', 'model Y': 'model Y'}

如果你知道tuple[number]可以获取元组中每一项的联合类型的话,那么答案也将呼之欲出。

type TupleToObject<T extends readonly any[]> = {
  [value in T[number]]: value
}

值得注意的是,因为要把元组转成对象,包括对象的key和value,所以需要对元组做一个约束,要求它每一项都是可以拿来做key的类型。也就是string | number | symbol

最终答案是

type TupleToObject<T extends readonly (string | number | symbol)[]> = {
  [value in T[number]]: value
}

推荐阅读

震惊!原来 const 对象可以修改?深入理解 ES6 变量声明的内存机制

引言

JavaScript 的变量声明机制经历了从 ES5 到 ES6 的重大变革。在 ES6 之前,我们只能使用 var 来声明变量,这带来了变量提升、作用域混乱等问题。随着 JavaScript 在企业级应用中的广泛应用,ES6 引入了 letconst 关键字,这不仅解决了历史遗留问题,还引入了块级作用域的概念。本文将深入探讨这些新特性,并通过实例分析其工作原理。

一、ES6 变量声明的新特性

1.1 const 的本质

const 是 ES6 引入的常量声明关键字,它的特点是:

  • 简单数据类型:值不可改变
  • 复杂数据类型:值可以改变,但指向的内存地址不能发生改变

让我们通过一个具体的例子来理解:

const age = 18;
const friends = [
    {
        name: '吴彦祖',
        hometown: '上饶',
        collage: '湖南工业大学',
    },
    {
        name: '谢尔顿',
        hometown: '赣州',
    },      
];
// 可以修改对象内容
friends.push({
    name: '邓超',
    hometown: '宜春',
});

注意:这里有一个常见的误解,很多人认为 const 声明的对象完全不能修改,实际上它只是不能重新赋值,对象的内容是可以修改的。

1.2 引用式赋值

在 JavaScript 中,复杂数据类型采用引用式赋值(地址传递),而简单数据类型采用值传递:

const newFriends = friends;
newFriends.push({
    name: '宋仲基',
    hometown: '宜春',
});
newFriends.push({
    name: '严老板'
});

这里展示了 JavaScript 中一个重要的概念:引用式赋值。当我们使用 const newFriends = friends 时,实际上是将 friends 的引用地址赋值给了 newFriends,它们指向同一个内存空间。

二、内存管理机制

2.1 内存栈和堆

JavaScript 中的内存分为两种:

  • 内存栈:连续的内存空间,空间小,访问速度快
  • 内存堆:不连续的内存空间,空间大,访问速度慢

理解内存栈和堆的区别对于掌握 JavaScript 的变量机制至关重要。栈内存主要用于存储简单数据类型和引用地址,而堆内存则用于存储复杂数据类型。

2.2 const 的内存表现

无论是简单数据类型还是复杂数据类型,使用 const 声明时,内存栈中的值都不会发生改变:

  • 简单数据类型:值不可以修改
  • 复杂数据类型:引用的地址不可以改变

三、块级作用域的革命

3.1 解决变量提升问题

ES6 之前,JavaScript 只有函数作用域,这导致了变量提升(TDZ)的问题,影响了代码的可读性。letconst 的引入解决了这个问题:

// 循环中的块级作用域
for (let i = 0; i < 10; i++) {
    setTimeout(() => {
        console.log(i);
    }, 1000);
}

这个例子展示了块级作用域在循环中的应用。使用 let 声明的变量 i 在每次循环中都会创建一个新的作用域,这解决了使用 var 时常见的闭包问题。

3.2 避免全局污染

var 不同,letconst 声明的变量不会挂载到 window 对象上,这避免了全局变量的污染:

var globalVar = '全局变量';
let localVar = '局部变量';
console.log(window.globalVar); // '全局变量'
console.log(window.localVar); // undefined

四、ES6 的历史意义

ES6 的引入标志着 JavaScript 的一个重要转折点:

  • 早期 JS:主要用于设计页面交互,功能简单
  • ES6 之后:使 JS 成为企业级别的大型应用开发语言
  • 拥抱其他语言开发者:使 JS 的开发体验更接近 Java、C++ 等语言

这个转变不仅体现在语法特性上,更体现在 JavaScript 的定位和用途上。ES6 的引入使 JavaScript 真正成为了一门企业级的开发语言。

五、实际应用场景

5.1 循环中的变量声明

在循环中使用 let 可以创建独立的块级作用域,解决闭包问题:

for (let i = 0; i < 10; i++) {
    setTimeout(() => {
        console.log(i);
    }, 1000);
}

5.2 常量声明的最佳实践

使用 const 声明不会改变的引用,提高代码的可维护性:

const API_URL = 'https://api.example.com';
const DEFAULT_CONFIG = {
    timeout: 5000,
    retry: 3
};

六、性能优化建议

  1. 优先使用 const,除非变量需要重新赋值
  2. 使用块级作用域限制变量作用范围
  3. 避免在全局作用域使用 var
  4. 合理使用内存栈和堆的特性
  5. 注意复杂数据类型的引用式赋值特性

这些优化建议不仅能够提高代码的性能,还能提升代码的可维护性和可读性。

总结

ES6 的 letconst 不仅解决了 JavaScript 的历史遗留问题,还为大型应用开发提供了更好的支持。通过理解其内存管理机制和作用域特性,我们可以写出更安全、更高效的代码。这些新特性的引入,使 JavaScript 真正成为了一门企业级的开发语言。

参考资料

  1. ECMAScript 6 入门 - 阮一峰
  2. JavaScript 高级程序设计(第4版)
  3. MDN Web Docs - let 和 const

页面的loading没有立即生效的引申了解vue的更新机制和js事件循环(1)

思考:为啥如下代码没有立马看到loading的效果

loading.value = true
tableData.value.push(...data)  // 大数据量操作
setTimeout(() => {
  loading.value = false
  dialogVisible.value = false
}, 2000)

而这样写,可以立马看到loading

loading.value = true
setTimeout(() => {
  tableData.value.push(...data)
  setTimeout(() => {
    loading.value = false
    dialogVisible.value = false
  }, 0)
}, 0)

这个现象与 JavaScript 的事件循环和 Vue 的响应式更新机制有关:

第一种情况的问题:

  1. 当你设置 loading.value = true 时,Vue 会标记这个变化,但不会立即更新 DOM。
  2. 接着执行 tableData.value.push(...data),这是一个同步的、耗时的操作(因为数据量大)。
  3. 在 JavaScript 执行这个耗时操作期间,浏览器的主线程被阻塞,无法进行 UI 渲染。
  4. 只有当同步代码全部执行完后,浏览器才会进行 UI 更新,此时用户才能看到 loading 状态。
  5. 但此时已经进入了 setTimeout 的异步流程,很快又会将 loading 设为 false。

第二种情况为什么有效:

  1. loading.value = true 设置后,通过 setTimeout(..., 0) 将大数据操作放入任务队列。
  2. 这样浏览器有机会在同步代码执行完后立即进行 UI 渲染,显示出 loading 状态。
  3. 然后才会执行队列中的大数据操作。

根本原因:

JavaScript 是单线程的,同步的耗时操作会阻塞 UI 渲染。Vue 的响应式更新也需要等到同步代码执行完后才会批量处理。

更好的解决方案:

  1. 使用 Vue.nextTick
this.$nextTick().then(() => {
  tableData.value.push(...data)
  this.$nextTick().then(() => {
    loading.value = false
    dialogVisible.value = false
  })
})

Shader 入门 (1) —— 什么是 Shader?

一、Shader 简介

Shader(着色器)是一段运行在 GPU 上的程序,开发者通过编写程序,可以在图形渲染的过程中实现高度自定义的图形效果(如光照、材质、后处理等),因此 Shader 是游戏开发中必备的一项重要技能。

示例图(一个使用 Shader 实现的溶解效果):

May-20-2025 10-42-07.gif

二、为何需要 Shader?

因为 Shader 是利用 GPU 来执行渲染图像的程序,比起走 CPU 绘制图像的形式,GPU 渲染图形的效率会高得多。

对于一帧图像来说,它会由非常多的像素点构成,每个像素点的位置、颜色都需要经过计算处理并绘制到屏幕上。

CPU 的计算能力自然是非常强大的,如果仅有少数的像素需要进行计算,CPU 可以非常快速地得到结果。但问题在于一个图像需要计算的像素数量实在太庞大了,而 CPU 的核心数量较少,导致绘制完一帧图像的总时长会非常高。

而 GPU 专为大规模并行计算设计,其包含数千个小型计算核心(如 NVIDIA RTX4090 具备 16,384 个 CUDA 核心),尤其适合处理高度重复、可并行的任务(如顶点变换、片元着色)。

英伟达的专家曾通过机器串行/并行喷射绘画的展示,来科普 GPU 高效绘图的能力:

首先以串行喷射的绘画,来模拟 CPU 绘图的形式:

111111111111.gif

接着是以万箭齐发喷射的绘画,来模拟 GPU 绘图的形式:

22222.gif

因此在图像处理领域,擅长与 GPU 打交道的 Shader 是一门必备的技能。

三、如何编写 Shader?

3.1 GLSL

GLSL(OpenGL Shading Language) 是当下最主流的 Shader 编程语言,它基于 OpenGL ES / WebGL 标准。

下方是一段简单的 GLSL 代码示例:

attribute vec2 a_position;
attribute vec4 a_color;

varying vec4 v_color;

void main() {
   v_color = a_color;
   gl_Position = vec4(a_position, 0.0, 1.0);
}

我们会在后续的文章里仔细介绍 GLSL 的语法。

前文提到 “GLSL 基于 OpenGL ES/WebGL 标准”,这是因为:

  • GLSL 是基于 OpenGL ES 的着色器编程语言;
  • WebGL 是基于 OpenGL ES 的 Web 图形标准(留意是标准,不是语言);
  • WebGL 是基于 javascript + GLSL 的组合来开发和应用 Shader 效果的,其中 GLSL 是实现着色器编程的核心语言(javascript 仅起到一个桥接作用)。

下方是一个以 WebGL 标准来开发 Shader 的简单示例:

        // 将 GLSL 代码段赋值给 js 变量 vertexSource
        const vertexSource = `
                attribute vec2 a_position;
                attribute vec4 a_color;

                varying vec4 v_color;

                void main() {
                    v_color = a_color;
                    gl_Position = vec4(a_position, 0.0, 1.0);
                }
        `;
        
        // 创建着色器
        const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexSource);

在后续文章中我们将经常看到这种 javascript + GLSL 的混搭式代码,故本系列也可以作为 WebGL 的知识点来学习。

💡 本系列后续将在游戏引擎 Cocos Creator 中实现各种 Shader 效果(Cocos Creator 的 Shader 底层支持 GLSL ES 标准)。

Cocos Creator 可将项目构建为跨平台的游戏(而不是纯 Web 平台),故本系列主题为 Shader 而非 WebGL。

3.2 其它 Shader 开发语言(仅做了解)

除了 GLSL,还可以使用如下语言来开发 Shader:

  • HLSL (High-Level Shading Language)
    微软 DirectX 的 Shader 语言,需通过工具转换为 GLSL 来跨平台。
  • MSL (Metal Shading Language)
    苹果 Metal 的 Shader 语言。
  • WGSL (WebGPU Shading Language)
    下一代的跨平台 WebGPU Shader 语言,未来可能成为 Web 标准,截止本文发布时还处于草案阶段

💡 其中使用 WGSL 开发的 WebGPU Shader,在 Web 端(例如浏览器)具备直接调用 GPU 的能力,相较基于 GLSL 书写的 WebGL Shader 会有更高的性能。

但是因为 WebGPU 的兼容性较差(特别是移动端),故暂时按下不表。

四、渲染管线

渲染管线(Rendering Pipeline)是图形渲染的核心流程,它描述了从 数据 到最终 屏幕像素图像 的完整处理过程,而 Shader 程序用于控制渲染管线中的特定阶段,因此理解渲染管线阶段和协作机制,是掌握 Shader 编程、性能优化和高级渲染效果的基础。

4.1 渲染管线的核心阶段

一个经典的渲染管线会按顺序依次执行如下几个阶段:

1. 应用阶段(Application Stage)

  • 输入:2D Sprite 数据,或者 3D 模型、纹理、材质、灯光等数据。

  • 处理

    • CPU 准备数据(如模型矩阵、摄像机参数)。
    • 调用图形 API(如 gl.drawElements)将数据提交给 GPU。

2. 顶点处理阶段(Vertex Processing)

  • 输入:顶点数据(Vertex Data)。

  • 处理:通过顶点着色器(Vertex Shader) 计算和设置每个顶点的位置、尺寸等属性。

3. 图元装配(Primitive Assembly)

  • 输入:顶点数据。

  • 处理

    • 将顶点组装成基本图元(点、线、三角形)。
    • 执行裁剪(Clipping):剔除屏幕外的图元。

4. [可选] 几何着色器处理(Geometry Shading)

  • 输入:图元数据。

  • 处理:接收完整的图元(如一个三角形、一条线段),可以修改、丢弃或生成新的图元。

5. 光栅化(Rasterization)

  • 输入:图元(如三角形)。

  • 处理

    • 将连续的几何图形离散化为片元(Fragment) ,即像素候选。
    • 计算每个片元的位置、深度、插值后的属性(如 UV、颜色)。

💡 进一步了解片元 image.png

  • 片元是光栅化阶段的产物
    当几何图形(如三角形)被光栅化时,它会被分割成许多小单元,每个单元对应一个携带了信息的候选像素,即片元。
  • 片元携带的信息
    包括颜色、深度(Z值)、纹理坐标、模板值等。
  • 片元 ≠ 最终像素
    片元只是潜在的像素 (所以才叫候选像素),后续可能被丢弃(如被遮挡)或被修改(如混合半透明颜色)。

6. 片元处理阶段(Fragment Processing)

  • 输入:光栅化后的片元数据。

  • 处理:通过片元着色器(Fragment Shader) 计算每个片元的颜色(纹理采样、光照计算等)。

7. 测试与混合(Testing & Blending)

  • 输入:片元颜色和深度信息。

  • 处理

    • 执行深度测试(Depth Test)、模板测试(Stencil Test)。
    • 将通过测试的片元进行混合(Blending):与帧缓冲区中已有的颜色按混合方程(如 alpha 混合)合成。
    • 写入帧缓冲区(Frame Buffer)。

8. 输出到屏幕

  • 处理:帧缓冲区的像素数据最终显示在屏幕上。

4.2 Shader 在渲染管线中的作用

上述的渲染管线流程中,顶点处理阶段和片元处理阶段都需要 Shader 的参与:

  • 在顶点处理阶段,使用 Vertex Shader(顶点着色器) 对顶点进行处理;
  • 在几何着色器处理阶段,使用 Geometry Shader(几何着色器) 对已组装的图元进行进一步增/删处理(进而生成新图元),留意此阶段并非渲染管线必经阶段,且移动端对几何着色器的支持有限
  • 在片元处理阶段,使用 Fragment Shader(片元着色器) 对片元数据进行处理。

我们可以将渲染管线流程简化为:

数据 → Vertex Shader(顶点着色器)→ 图元装配 → [可选] Geometry Shader(几何着色器)→ 光栅化 → Fragment Shader(片元着色器)→ 测试与混合

该流程示意图如下(蓝色模块表示可通过 GLSL 编程的 Shader 处理模块):

6.jpg

在下篇文章,我们会编写顶点着色器和片元着色器,来绘制最简单的一个点。

文件分片上传前后端全过程(js+express保姆级教程喂宝宝版)

按照惯例,在正式开始之前要吟唱一段前言,这也算是武林高手里开打前的起手式了。本篇文章所用代码写在两年前,不过非常可惜这篇代码在我手里到现在还没有实际的应用场景。在开始前得提前声明一下免责事项,因为是两年前写的demo,故代码的时效性不做保证,仅供各位看官参考。废话少说,锣鼓一响好戏开场。

原理

还是按照惯例(别怪我话多,平时没人跟我说话憋的,大家见谅)开始介绍一下该功能的实现原理,文件的分片上传及续传,核心就是文件的分片,而前端能做到文件的分片依靠的就是blob对象下的slice方法。当然了,前端没办法直接获取blob文件了,我们通过type=file的input拿到的是file对象。不过File是继承于Blob对象的,mdn还特地标注了一句***File 接口还继承了 Blob 接口的方法。** *所以我们可以直接拿到file对象,然后通过file对象的slice方法对文件进行切片。

那么后端呢?这里以Node.js为例哈。后端也是可以对分片的文件进行拼接的,这里卖个关子,用到的是啥API后面会介绍,不过原理都是读取文件然后按顺序粘在一起组合成一个完整的文件。

怎么样,原理是不是非常简单,是不是已经跃跃欲试,那我们就正式开始吧。

前端部分

本来是打算做线性叙事的,对部分需要注意的点可能更好理解,但是这样步骤标题可能让人有点晕头转向,所以还是按前后端的部分划分。

step1:选择文件

既然要做文件的分片上传,第一步当然是获取文件了

     <head>
         <meta charset="UTF-8" />
         <meta name="viewport" content="width=device-width, initial-scale=1.0" />
         <title>Document</title>
         <style>
           .progress {
             width: 300px;
             height: 10px;
             background-image: linear-gradient(90deg, pink, skyblue);
             background-size: 0%;
             background-repeat: no-repeat;
             border: 1px solid red;
           }
         </style>
       </head>
       <body>
         <input type="file" id="file" />
         <div class="progress"></div>
         <span class="progress-num">0</span>
         <button id="btn">上传按钮</button>
       </body>

通过文件输入框去选择文件,progress用于显示上传的进度,progress-num显示上传的进度数字,按钮用于触发上传。页面搭建完毕接下来就是逻辑了。接下来我们对文件进行处理。

step2: 获取文件

本来这快觉得没必要写的,想了想既然是喂给宝宝的,还是加上吧

         const fileDom = document.querySelector("#file");
         const btn = document.querySelector("#btn");
         const progress = document.querySelector(".progress");
         const progressNum = document.querySelector(".progress-num");
     
     
         let formData = new FormData();
         let files = null;
         let percent = 0;
     
         fileDom.addEventListener("change", (e) => {
           files = e.target.files;
         });
     
         btn.addEventListener("click", function (e) {
           if (files.length === 1) {
             sliceUpload(files[0], 10 * 1024 * 1024);
           }
         });

没错,本次示例只做单文件的分片上传,至于多文件就需要大家自己开动脑筋补上了。sliceUpload方法是后面我们的重点,对文件分片及上传。

step3:文件分片及上传

文件分片及上传这里放一起单纯是因为我懒,这里其实是可以分为两个部分的——分片和上传。

1.分片

首先说分片吧,还记得上面的sliceUpload方法吗,有两个参数,一个是file用于分片的文件,还有一个是分片的大小,这里我定的是10M。首先我们获取一下要分多少片

     let pieces = Math.ceil(file.size / maxChunkSize);

其实文件的分片很好做的,但是告诉后端,这个分片是不是这个分片(有点哲学那味儿了)是比较麻烦的,所以我们需要给每个分片取一个名字,类似于我们的身份证ID。这里我们用到的是spark-md5库,这个库可以根据MD5算法来根据文件生成一个唯一的hash值。这里我用的是原生的HTML,所以直接下载了js文件然后引入。

     const spark = new SparkMD5.ArrayBuffer();
     let index = 0; // 当前操作的文件切片索引
     let tempFile = file.slice(maxChunkSize * index, maxChunkSize * ++index); //当前操作的文件切片
     let fileChunkList = []; // 存放切片后文件

这里用的是SparkMD5的ArrayBuffer方法,所以我们需要使用FileReader将切片后的临时文件读取为ArrayBuffer,然后生成hash。因为这里的切片文件一定是多个,所以这里我封装了一个递归方法用于递归生成。

       const loadNext = () => {
         let tempFile = file.slice(maxChunkSize * index, maxChunkSize * ++index);
         const reader = new FileReader();
         reader.readAsArrayBuffer(tempFile);
         reader.onload = (e) => {
           spark.append(e.target.result);
           fileChunkList.push({
             file: tempFile,
             hash: SparkMD5.ArrayBuffer.hash(e.target.result),
             index,
           });
           // 递归计算下一个切片
           loadNext();
         };
       };
2.web-worker优化处理

这里生成文件hash的操作比较耗时的,如果文件大一点的情况下尤为明显,这个时候我就想到了Web Worker。我们可以将这一步操作放在webworker中进行,等到读取完毕了以后传递消息给页面然后让用户上传:

     // filemd5.js
     self.importScripts("../spark-md5.min.js");
     
     self.onmessage = (e) => {
       let { file, maxChunkSize } = e.data;
       let pieces = Math.ceil(file.size / maxChunkSize);
       let index = 0;
       let fileChunkList = [];
       const spark = new SparkMD5.ArrayBuffer();
       const loadNext = () => {
         let tempFile = file.slice(maxChunkSize * index, maxChunkSize * ++index);
         const reader = new FileReader();
         reader.readAsArrayBuffer(tempFile);
         reader.onload = (e) => {
           spark.append(e.target.result);
           fileChunkList.push({
             file: tempFile,
             hash: SparkMD5.ArrayBuffer.hash(e.target.result),
             index,
           });
           if (index === pieces) {
             // 切片处理完毕后发送消息给页面
             self.postMessage({
               hash: spark.end(),
               fileChunkList,
             });
             self.close();
           }
           // 递归计算下一个切片
           loadNext();
         };
       };
       loadNext();
     };
     

然后在页面中我们定义一个方法去使用这个worker

         const getFileMd5 = (maxChunkSize) => {
           return new Promise((resolve, reject) => {
             let worker = new Worker("./web-worker/fileMd5.js");
             worker.postMessage({ file: files[0], maxChunkSize });
             worker.onmessage = (e) => {
               resolve(e.data);
             };
             worker.onerror = (err) => {
               reject(err);
             };
           });
         };

这样我们就可以得到这个文件的切片列表以及整个文件的hash。

3.上传切片

切片处理好以后我们就可以将切片文件上传了。

           let successCount = 0;
           let { fileChunkList, hash } = await getFileMd5(maxChunkSize);
           fileChunkList.forEach((item) => {
             let formData = new FormData();
             formData.append("pieceHash", item.hash + "_" + item.index); // 分片的MD5 索引用于后端进行分片合成
             formData.append("fileHash", hash); // 文件MD5
             formData.append("file", item.file);
             formData.append("fileName", file.name); // 后端合成后的文件名称
             formData.append("totalCount", pieces); // 分片总数,用于后端判断是否进行合成文件分片
             const controller = new AbortController();
             fileChunkList.abortController = controller;
             new Promise((resolve, reject) => {
               // 这里换成自己的服务地址就好了
               fetch("http://10.0.0.0:9527/upload/cutFile", {
                 method: "post",
                 mode: "cors",
                 body: formData,
                 signal: controller.signal,
               })
                 .then((res) => {
                   res
                     .json()
                     .then((result) => {
                       resolve(result);
                     })
                     .catch((err) => {
                       resolve(err);
                     });
                 })
                 .catch((err) => {
                   reject(err);
                 });
             }).then((res) => {
               successCount += 1;
               percent = (100 / pieces) * successCount;
               progressNum.innerHTML = percent;
               progress.style.backgroundSize = percent + "%";
             });
           });

这里要注意的是我这里测试用的切片数量不多,所以可以直接用循环去上传,大家在具体使用的时候要注意浏览器的最大请求并发数。这里其实可以用一个队列去做请求的控制,虽然后面我写了一个请求队列控制的方法,但是这里就暂时这样吧,正如我上面说的,我很懒。后面有机会再写一个关于请求控制的方法吧。

前端部分到这里就差不多了,是不是非常简单?接下来我们转到后端部分。

后端部分

后端我用的是express,虽然是保姆级教程,但是搭建一个基础的express服务也实在是没有必要再写了,我们直接看代码吧。

step1:接收切片文件

首先我们需要创建一个路由,然后在这里拿到前端传过来的文件切片。这里我用的是multer中间件来处理formdata的数据(也可以用别的),注意这个中间件只能处理formdata的数据。顺便说一下,用了cors中间件来设置跨域(也可以自己写)

     const multer = require("multer");
     const cors = require("cors");
     
     // 跨域配置
     const corsOptions = {
       // origin: "http://10.0.0.0:5500/", // 指定允许的来源
     };
     
     app.use(cors(corsOptions)); //利用cors中间件设置跨域
     app.use("/static", express.static("public")); //开启静态目录
     
     app.post("/upload/cutFile", uploadPieceHandler.array("file"), async (req, res) => {
       ...
     });

step2: formdata数据处理

上面可以看到我们在路由的第二个参数使用到了一个中间件配置,这个配置就是multer返回的一个配置,现在我们看看这个配置是如何创建的。

     const uploadPieceHandler = multer({
       limits: {
         fileSize: 1024 * 1024 * 10.5, // 限制文件大小为10M(0.5M误差)
       },
       fileFilter: (req, file, cb) => {
         // 切片后的文件类型
         const allowedTypes = ["application/octet-stream"];
         if (!allowedTypes.includes(file.mimetype)) {
           cb(new Error("Invalid file type."));
           return;
         }
         cb(null, true);
       },
       // 自定义文件存储设置
       storage: multer.diskStorage({
         destination: (req, file, cb) => {
           let defaultRootDirPath = __dirname + "/uploads/"; // 分片文件存放地址
           // 如果根目录没有uploads文件夹就创建
           if (!fs.existsSync("uploads")) {
             fs.mkdirSync("uploads");
           }
           let fileDirPath = defaultRootDirPath + req.body.fileHash; // 使用整体文件hash作为文件夹名
           if (!fs.existsSync(fileDirPath)) {
             fs.mkdirSync(fileDirPath);
           }
           cb(null, fileDirPath);
         },
         // 使用文件hash作为单个切片的文件名
         filename: (req, file, cb) => {
           cb(null, `${req.body.pieceHash}`);
         },
       }),
     });

如果有需要其他配置或者有不了解的也可以看看Multer的文档

step3:合并文件

合并文件一共有两种方法——使用Buffer和使用流,这里更推荐使用流。

1.Buffer处理

虽然Buffer的处理方式有很多问题,但是这里还是给大家介绍下吧。使用Buffer时间我们依赖的是Buffer的concat方法,这个方法处理起来比较简单,只要我们按照顺序不停的调用Buffer的concat方法将切片文件拼在一起即可。Buffer的方式较为适合文件小的场景(文件小还分片干什么。。。),因为心智负担不重。

使用buffer;(分片大小不宜超过100kb,实测超过1M时读取的数据不全,buffer长度不正确)

     // 这里传入的文件名列表是已经按之前索引排序过的,这也是为什么前端传递的时候需要在hash后面加上一个索引
     const pasteFileHandle = (fileNameList, hash, fileName) => {
       // 根据文件hash读取指定的文件
       let dirPath = __dirname + "/uploads/" + hash;
        let len = 0;
        let bufferList = [];
        for (let i = 0; i < fileNameList.length; i++) {
          const tempBuffer = fs.readFileSync(dirPath + "/" + fileNameList[i]);
          console.log(i, tempBuffer.length);
          len += tempBuffer.length;
          bufferList.push(tempBuffer);
        }
        const resultBuffer = Buffer.concat(bufferList, len);
     };
2.流式处理

文件除了使用Buffer的处理方式我们还可以使用流的方式处理,而我们对切片文件的处理就是使用fs下的createWriteStream方法,这个方法会创建一个可写流。接着我们使用fs.createReadStream方法来创建可读流,然后我们再通过管道pipe进行两个流管道的链接,这样文件就被合并完成了。

     const pasteFileHandle = (fileNameList, hash, fileName) => {
       let dirPath = __dirname + "/uploads/" + hash;
       // 使用流 (实测10M分片大小无影响)
       // 创建目标写入流(这里的路径就是文件路径,最后的就是保存的文件名称)
       const writeStream = fs.createWriteStream(`${dirPath}/${fileName}`);
       // 递归消费每个切片并创建可读流
       const streamMergeRecursive = (fileNameList) => {
         if (!fileNameList.length) {
           // 消费完毕关闭可写流,防止内存泄漏
           writeStream.end();
           console.log("写入完成");
           return;
         }
         // 创建单个分片的可读流
         const pieceStream = fs.createReadStream(dirPath + "/" + fileNameList.shift());
         // 将可读流连接到可写流中
         pieceStream.pipe(writeStream, { end: false });
         // 完毕以后开启下一次递归
         pieceStream.on("end", function () {
           streamMergeRecursive(fileNameList);
         });
         pieceStream.on("error", function (error) {
           // 监听错误事件,关闭可写流,防止内存泄漏
           console.error(error);
           writeStream.close();
         });
       };
       streamMergeRecursive(fileNameList);
     };

step4:逻辑处理

以上三步都算是预备工作,咱们路由的回调方法内部还是空空如也,所以接下来我们就需要根据上面的方法来完成文件的合并,接下来我们就补足路由回调的内部方法逻辑。

     // 拿到参数
     const param = req.body;
     // 随便写的,这里最好还是逻辑处理了再返回
     res.send({
       code: 200,
       content: {},
       message: "请求成功",
     });
     // 因为上传顺序可能错乱,所以这里根据文件夹里的文件数量来判断是否上传完毕
     const checkNeedPaste = (hash, totalCount) => {
       let path = __dirname + "/uploads/" + hash;
       return _fs.readdir(path);
     };
     let fileNameList = await checkNeedPaste(param.fileHash);
     if (fileNameList.length == param.totalCount) {
       // 合并前先根据索引排个序
       fileNameList.sort((a, b) => {
         let prevNum = +a.split("_")[1];
         let nextNum = +b.split("_")[1];
         return prevNum - nextNum;
       });
       pasteFileHandle(fileNameList, param.fileHash, param.fileName);
     }

验证

到这里基本就大功告成了,我们来看看效果(不会以为我还要上传一个演示视频吧?给个图片看看差不多得了)

image.png

image.png

源文件大概是4M多,我这里的切片大小是1M,可以看到文件大小数量也是能对应上的,并且合并后的音频文件也是能正常播放的(嗯,天籁~~)。

文件续传即秒传

提前说好,这俩我都没写代码(不是因为懒,主要是为了省电环保不写闲置代码),单纯就是说下思路。

1.文件续传

文件续传一般出现在这些场景下:

  1. 用户关闭了上传页面
  2. 用户网络中断
  3. 系统崩溃

在这些场景中,用户的某一个文件的部分分片可能上传了部分,也可能都没开始上传。如果还没开始上传那就好说了,直接按流程走就完事了。那如果是部分分片上已经上传了,用户已经上传的这部分切片就没必要发送到服务器了。所以我们可能根据文件hash来通知客户端,哪些切片是已经上传了的。

这个时候我们可以在服务端创建一个检测路由,客户端将文件hash传递过来,然后服务端检测这个文件hash是否已经创建了文件夹。如果创建了服务端就返回该文件夹下的文件名称列表,如果没有创建就返回一个空数组。

     // 随便写写,不保证能跑
     const _fs = require("node:fs/promises");
     
     app.post("/checkFile", uploadHandler.single("file"), (req, res) => {
     const {hash} = req.body
      if (!fs.existsSync(fileDirPath)) {
        res.send({
         code: 200,
         content: {result:[]},
         message: "请求成功",
       });
      }else{
       res.send({
         code: 200,
         content: {result:_fs.readdir(__dirname + "/uploads/" + hash)},
         message: "请求成功",
       });
      }
     });

至于前端就简单了,根据接口返回的数组来判断哪些接口不需要上传即可,这里就不写了。

2.秒传

说的吓人,其实就是检查一下文件是不是已经上传过了,如果上传过了服务端直接通知客户端不用上传了,就这么回事。那这里就更好写了,我们不能直接判断hash,因为只要有一个切片这个hash都是会存在的。因为这里我们没用数据库,如果有数据库保存一个状态即可,如果这个文件hash的状态是已上传过自己返回就行,但是这里我们没用数据库咋办嘞,我们可以用文件名来读取合并后的文件,如果能读取到说明文件已经合并。

当然了,文件MD5都读取了,你用切片数量来判断是否上传完毕也是可以的。

这里的判断依据都是每次的切片大小不变、文件名不变以及使用文件名作为服务器合并后的文件名

     // 客户端传文件MD5和名称
     app.post("/checkFile", uploadHandler.single("file"), (req, res) => {
     const {hash, fileName} = req.body
      if (!fs.existsSync(fileDirPath)) {
        res.send({
         code: 200,
         content: {result:[]},
         message: "请求成功",
       });
      }else{
        try {
          const data = fs.readFileSync(filePath);
          res.send({
            code: 200,
            content: {result:'done'}, // 不用传了
             message: "请求成功",
          });
        } catch (error) {
          if (error.code === 'ENOENT') {
            console.log('文件不存在');
          } else {
            console.error('读取错误:', error);
          }
          const fileNameList = _fs.readdir(__dirname + "/uploads/" + hash)
          res.send({
            code: 200,
            content: {result:fileNameList},
            message: "请求成功",
          });
        }
      }
     });

注意事项(免责声明)

以上的代码是仅做学习交流使用的demo,代码健壮性不足以上生产,还有很多地方需要注意随便列举几项:

  1. 文件路径不要使用字符串拼接,最好使用path.join
  2. 客户端的请求并发
  3. 递归深度限制及引用的及时释放
  4. 部分分片可能在上传阶段处理不当而损坏
  5. 对错误的捕获处理
  6. .....

当然了,在实际业务中的处理肯定是更麻烦的,例如用户控制上传的暂停及恢复、断网上传中断及恢复等等,这些实现就不在这里过多赘述了,有兴趣的可以自行实现或查询相关文档。

【CodeBuddy】三分钟开发一个实用小功能之:数字华容道拼图

前言

想象一下,您正面临一个复杂的编程挑战,需要快速实现一个功能丰富的数字华容道游戏。然而,繁琐的编码工作让您感到力不从心。这时,codebuddy智能编程助手登场了。您只需简单描述需求,codebuddy就能为您生成高质量的代码,让编程工作变得轻松高效。本文将通过数字华容道游戏的实现,介绍codebuddy的AI编程魅力。


以下是实际操作中的开发界面与最终呈现效果(文末附完整代码):


应用场景

数字华容道是一款经典的益智游戏,要求玩家通过滑动数字块,将打乱的数字重新排列成1到15的顺序。这个项目不仅考验玩家的逻辑思维和策略规划能力,还涉及前端开发中的DOM操作、事件监听、动画效果等多个方面。codebuddy能够根据需求,自动生成完整的游戏代码,包括HTML结构、CSS样式和JavaScript逻辑,让开发者从繁琐的编码工作中解脱出来,专注于游戏的设计和优化。

核心功能

  1. 智能代码生成:codebuddy能够根据用户的描述或需求文档,自动生成符合要求的代码。在数字华容道项目中,codebuddy生成了包含游戏逻辑、界面渲染和事件处理的完整JavaScript代码。

  2. 代码优化与调试:生成的代码不仅功能完整,还经过codebuddy的智能优化,确保代码的高效性和可读性。同时,codebuddy还提供了调试工具,帮助开发者快速定位和解决代码中的问题。

  3. 持续学习与进化:codebuddy通过不断学习和进化,能够不断提升代码生成的质量和效率。它可以根据用户的反馈和最新的编程技术,不断优化自身的算法和模型。

将来可以优化升级的地方

  1. 增强代码可读性:虽然生成的代码功能强大,但在某些情况下,代码的可读性仍有待提高。未来,codebuddy可以进一步优化代码生成算法,生成更加简洁、易读的代码。

  2. 支持更多编程语言和框架:目前,codebuddy主要支持前端开发相关的编程语言和框架。未来,它可以扩展支持更多的编程语言和框架,满足更广泛的开发需求。

  3. 提升代码生成的智能化水平:通过引入更先进的自然语言处理和机器学习技术,codebuddy可以进一步提升代码生成的智能化水平,更好地理解用户的需求和意图,生成更加符合用户期望的代码。

总结感悟

codebuddy的AI编程魅力在于它能够将繁琐的编码工作自动化,让开发者从重复劳动中解脱出来,专注于更有价值的工作。通过数字华容道项目的实现,我们深刻感受到了codebuddy在提升开发效率、降低开发门槛方面的巨大潜力。随着技术的不断进步和应用场景的不断拓展,相信codebuddy将在未来发挥更加重要的作用,开启智能编码的新时代。

index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>数字华容道</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="game-container">
        <h1>数字华容道</h1>
        <div class="game-board" id="board"></div>
        <div class="controls">
            <button id="start-btn">开始游戏</button>
            <button id="reset-btn">重新开始</button>
        </div>
        <div class="stats">
            <span>步数: <span id="moves">0</span></span>
            <span>时间: <span id="time">0</span></span>
        </div>
    </div>
    <script src="script.js"></script>
</body>
</html>

style.css

body {
    margin: 0;
    padding: 0;
    font-family: 'Arial', sans-serif;
    background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
    display: flex;
    justify-content: center;
    align-items: center;
    min-height: 100vh;
    color: #333;
}

.game-container {
    width: 600px;
    padding: 20px;
    text-align: center;
    background-color: rgba(255, 255, 255, 0.8);
    border-radius: 15px;
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}

h1 {
    margin-bottom: 20px;
    color: #4a6baf;
}

.game-board {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    grid-gap: 10px;
    margin: 20px auto;
    width: 400px;
    height: 400px;
}

.tile {
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: 32px;
    font-weight: bold;
    background: #4a6baf;
    color: white;
    border-radius: 8px;
    cursor: pointer;
    transition: all 0.3s ease;
    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}

.tile:hover {
    transform: translateY(-2px);
    box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
}

.empty {
    background: transparent;
    box-shadow: none;
    cursor: default;
}

.controls {
    margin: 20px 0;
}

button {
    padding: 10px 20px;
    margin: 0 10px;
    font-size: 16px;
    background: #4a6baf;
    color: white;
    border: none;
    border-radius: 5px;
    cursor: pointer;
    transition: background 0.3s;
}

button:hover {
    background: #3a5a9f;
}

.stats {
    display: flex;
    justify-content: space-around;
    margin-top: 20px;
    font-size: 18px;
}

.win-message {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 0.8);
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    color: white;
    font-size: 24px;
    z-index: 100;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.5s;
}

.win-message.show {
    opacity: 1;
    pointer-events: all;
}

.win-message button {
    margin-top: 20px;
    background: #4CAF50;
}

.win-message button:hover {
    background: #45a049;
}

script.js

document.addEventListener('DOMContentLoaded', () => {
    const board = document.getElementById('board');
    const startBtn = document.getElementById('start-btn');
    const resetBtn = document.getElementById('reset-btn');
    const movesDisplay = document.getElementById('moves');
    const timeDisplay = document.getElementById('time');
    const winMessage = document.querySelector('.win-message') || createWinMessage();
    
    let tiles = [];
    let emptyIndex = 15;
    let moves = 0;
    let seconds = 0;
    let timer;
    let isPlaying = false;

    // 初始化游戏
    function initGame() {
        // 创建1-15的数字数组并打乱顺序
        tiles = Array.from({length: 15}, (_, i) => i + 1);
        shuffleArray(tiles);
        tiles.push(0); // 0代表空白格
        
        // 确保拼图可解
        if (!isSolvable(tiles)) {
            // 如果不可解,交换前两个非空数字使其可解
            [tiles[0], tiles[1]] = [tiles[1], tiles[0]];
        }
        
        emptyIndex = tiles.indexOf(0);
        moves = 0;
        seconds = 0;
        movesDisplay.textContent = moves;
        timeDisplay.textContent = seconds;
        isPlaying = true;
        
        renderBoard();
        startTimer();
    }

    // 渲染棋盘
    function renderBoard() {
        board.innerHTML = '';
        tiles.forEach((num, index) => {
            const tile = document.createElement('div');
            tile.className = num === 0 ? 'tile empty' : 'tile';
            tile.textContent = num === 0 ? '' : num;
            tile.dataset.index = index;
            
            if (num !== 0) {
                tile.addEventListener('click', () => moveTile(index));
            }
            
            board.appendChild(tile);
        });
    }

    // 移动数字块
    function moveTile(index) {
        if (!isPlaying) return;
        
        // 检查是否可以移动(是否与空白格相邻)
        if (isAdjacent(index, emptyIndex)) {
            // 交换位置
            [tiles[index], tiles[emptyIndex]] = [tiles[emptyIndex], tiles[index]];
            emptyIndex = index;
            moves++;
            movesDisplay.textContent = moves;
            
            renderBoard();
            
            // 检查是否完成
            if (isSolved()) {
                gameOver();
            }
        }
    }

    // 检查两个位置是否相邻
    function isAdjacent(index1, index2) {
        const row1 = Math.floor(index1 / 4);
        const col1 = index1 % 4;
        const row2 = Math.floor(index2 / 4);
        const col2 = index2 % 4;
        
        return (
            (Math.abs(row1 - row2) === 1 && col1 === col2) ||
            (Math.abs(col1 - col2) === 1 && row1 === row2)
        );
    }

    // 检查拼图是否完成
    function isSolved() {
        for (let i = 0; i < 15; i++) {
            if (tiles[i] !== i + 1) {
                return false;
            }
        }
        return tiles[15] === 0;
    }

    // 游戏结束
    function gameOver() {
        clearInterval(timer);
        isPlaying = false;
        showWinMessage();
    }

    // 显示胜利消息
    function showWinMessage() {
        const winMessage = document.querySelector('.win-message');
        const winTime = document.getElementById('win-time');
        const winMoves = document.getElementById('win-moves');
        
        if (!winTime) {
            const message = document.createElement('div');
            message.className = 'win-message';
            message.innerHTML = `
                <h2>恭喜你完成了!</h2>
                <p>用时: <span id="win-time">${seconds}</span>秒</p>
                <p>步数: <span id="win-moves">${moves}</span>步</p>
                <button id="play-again">再玩一次</button>
            `;
            document.body.appendChild(message);
            
            document.getElementById('play-again').addEventListener('click', () => {
                message.remove();
                initGame();
            });
        } else {
            winTime.textContent = seconds;
            winMoves.textContent = moves;
            winMessage.classList.add('show');
            
            document.getElementById('play-again').addEventListener('click', () => {
                winMessage.classList.remove('show');
                initGame();
            });
        }
    }

    // 创建胜利消息元素
    function createWinMessage() {
        const message = document.createElement('div');
        message.className = 'win-message';
        message.innerHTML = `
            <h2>恭喜你完成了!</h2>
            <p>用时: <span id="win-time">0</span>秒</p>
            <p>步数: <span id="win-moves">0</span>步</p>
            <button id="play-again">再玩一次</button>
        `;
        document.body.appendChild(message);
        return message;
    }

    // 开始计时器
    function startTimer() {
        clearInterval(timer);
        seconds = 0;
        timeDisplay.textContent = seconds;
        timer = setInterval(() => {
            seconds++;
            timeDisplay.textContent = seconds;
        }, 1000);
    }

    // 打乱数组
    function shuffleArray(array) {
        for (let i = array.length - 1; i > 0; i--) {
            const j = Math.floor(Math.random() * (i + 1));
            [array[i], array[j]] = [array[j], array[i]];
        }
    }

    // 检查拼图是否可解
    function isSolvable(arr) {
        let inversions = 0;
        const flatArr = arr.filter(num => num !== 0);
        
        for (let i = 0; i < flatArr.length - 1; i++) {
            for (let j = i + 1; j < flatArr.length; j++) {
                if (flatArr[i] > flatArr[j]) {
                    inversions++;
                }
            }
        }
        
        const blankRow = Math.floor(emptyIndex / 4);
        return (inversions % 2 === 0) === (blankRow % 2 === 1);
    }

    // 事件监听
    startBtn.addEventListener('click', initGame);
    resetBtn.addEventListener('click', () => {
        clearInterval(timer);
        isPlaying = false;
        initGame();
    });

    // 初始化空白棋盘
    initGame();
});



🌟 让技术经验流动起来

▌▍▎▏ 你的每个互动都在为技术社区蓄能 ▏▎▍▌
点赞 → 让优质经验被更多人看见
📥 收藏 → 构建你的专属知识库
🔄 转发 → 与技术伙伴共享避坑指南

点赞 ➕ 收藏 ➕ 转发,助力更多小伙伴一起成长!💪

💌 深度连接
点击 「头像」→「+关注」
每周解锁:
🔥 一线架构实录 | 💡 故障排查手册 | 🚀 效能提升秘籍

JavaScript 中的 this 指向

在 JavaScript 中,this 是一个非常重要的关键字。this 的指向并不是固定的,而是根据函数的调用方式动态决定的。理解 this 的指向规则对于编写清晰、可维护的代码至关重要。

一、this 指向的基本规则

(一)全局环境中的 this

在全局环境中,this 指向全局对象。在浏览器环境中,全局对象是 window;在 Node.js 环境中,全局对象是 global

console.log(this === window); // true

在严格模式下,全局环境中的 this 指向 undefined

'use strict';
console.log(this); // undefined

(二)函数调用中的 this

1. 简单调用

当函数以简单的方式调用时(即不通过对象或构造函数调用),this 的指向取决于是否处于严格模式:

  • 非严格模式下,this 指向全局对象。
  • 严格模式下,this 指向 undefined
function f1() {
    console.log(this);
}

function f2() {
    'use strict';
    console.log(this);
}

f1(); // window or global
f2(); // undefined

2. 作为对象方法调用

当函数作为对象的方法调用时,this 指向该对象。

const obj = {
    name: 'zhangsan',
    greet: function () {
        console.log(`Hello, my name is ${this.name}`);
    }
};

obj.greet(); // Hello, my name is zhangsan

3. 通过 new 调用

当函数通过 new 关键字调用时,this 指向新创建的对象。

function Person(name) {
    this.name = name;
}

const person = new Person('zhangsan');
console.log(person.name); // zhangsan

4. 使用 callapplybind 调用

callapplybind 方法可以显式地指定函数调用时的 this 指向。

const obj = { name: 'zhangsan' };

function greet() {
    console.log(`Hello, my name is ${this.name}`);
}

greet.call(obj); // Hello, my name is zhangsan
greet.apply(obj); // Hello, my name is zhangsan

const boundGreet = greet.bind(obj);
boundGreet(); // Hello, my name is zhangsan

(三)箭头函数中的 this

箭头函数的 this 指向是根据定义时所在的作用域决定的,而不是根据调用方式决定的。箭头函数不绑定自己的 this,而是继承自外层作用域。

const obj = {
    name: 'zhangsan',
    greet: () => {
        console.log(`Hello, my name is ${this.name}`);
    }
};

obj.greet(); // Hello, my name is undefined

在上面的例子中,箭头函数的 this 指向全局对象,而不是 obj

(四)事件处理函数中的 this

在事件处理函数中,this 指向绑定事件的 DOM 元素。

<button id="myButton">Click me</button>
document.getElementById('myButton').addEventListener('click', function () {
    console.log(this); // <button id="myButton">Click me</button>
});

二、this 指向的常见问题

(一)this 指向的丢失

在某些情况下,this 的指向可能会丢失,导致代码运行时出现错误。例如,当函数被赋值给另一个变量时,this 的指向可能会改变。

const obj = {
    name: 'zhangsan',
    greet: function () {
        console.log(`Hello, my name is ${this.name}`);
    }
};

const greet = obj.greet;
greet(); // Hello, my name is undefined

在上面的例子中,greet 函数被赋值给了全局变量 greet,因此 this 指向了全局对象。

(二)解决方法

  1. 使用 bind 方法

bind 方法可以显式地指定函数的 this 指向。

const obj = {
    name: 'zhangsan',
    greet: function () {
        console.log(`Hello, my name is ${this.name}`);
    }
};

const boundGreet = obj.greet.bind(obj);
boundGreet(); // Hello, my name is zhangsan
  1. 使用箭头函数

箭头函数的 this 指向是根据定义时所在的作用域决定的,因此不会受到调用方式的影响。

const obj = {
    name: 'zhangsan',
    greet: () => {
        console.log(`Hello, my name is ${this.name}`);
    }
};

const greet = obj.greet;
greet(); // Hello, my name is zhangsan

三、总结

this 是 JavaScript 中一个非常重要的关键字,它的指向规则取决于函数的调用方式。理解 this 的指向规则,可以帮助你更好地编写清晰、可维护的代码。希望本文能帮助你更好地理解和应用 this 的指向规则。

在react19中使用react-vant组件库导致表单出现的问题

最近开发了一个项目,使用了react19 + react-vant,在做表单功能的时候,发现日期选择组件不能用了,问题代码如下:

 <Form.Item
  name="birthday"
  label="出生日期"
  isLink
  trigger="onConfirm"
  onClick={(_, action) => {
    //!!! 这里的action.current为空,所以导致日期组件不能弹出
    action.current?.open();
  }}
>
  <DatetimePicker
    popup={{ round: true }}
    type="year-month"
  >
    {(val: Date) => (val ? dayjs(val).format("YYYY年MM月DD日") : "请选择")}
  </DatetimePicker>
</Form.Item>

原因是因为React19中已经废弃了forwardRef,从React19开始,将ref作为prop进行访问,例如如下这样使用:

function MyInput({placeholder, ref}) {  
    return <input placeholder={placeholder} ref={ref} />  
}
//...  
<MyInput ref={ref} />

于是根据这个优化对react-vant的Form.Item组件做了下封装,封装代码如下:

function FormItem({ children, onClick, ...props }) {
  const ref = useRef(null);
  return (
    <Form.Item
      {...props}
      onClick={(e) => {
        if (onClick) {
          onClick(e, ref);
        }
      }}
    >
      {!!children && cloneElement(children, { ref })}
    </Form.Item>
  );
}

然后在使用到Form.Item的地方使用FormItem就可以了,属性方法还是一样的用法

 // 这里将 Form.Item 替换成 FormItem 即可
 <FormItem
  name="birthday"
  label="出生日期"
  isLink
  trigger="onConfirm"
  onClick={(_, action) => {
    // 这里就能正确的拿到ref了
    action.current?.open();
  }}
>
  <DatetimePicker
    popup={{ round: true }}
    type="year-month"
  >
    {(val: Date) => (val ? dayjs(val).format("YYYY年MM月DD日") : "请选择")}
  </DatetimePicker>
</FormItem>

总结: 在使用新版本时,还得先了解下新版本都做了什么优化,评估下可能潜在的问题和风险。

参考资料

threejs 实现3D游戏(13) —— 高效多人同屏:数据压缩

概述

上次我们实现多人同屏的功能时,主要实现了一个大的框架,仅仅同步了玩家的位置(通过本地运算2次位置的差值来获取方向、判断玩家动画)。

这种最简实现是为了尽快的熟悉同步操作的流程,这次我们正式来优化这块逻辑。

这次不仅要同步玩家的位置、状态、旋转方向,还要根据同步数据来设计二进制压缩协议来对数据进行压缩,同时对同步数据驱动的显示其他玩家的组件进行重构。

回顾

我们使用 react-three-fiber 搭建了整体框架。
使用 @react-three/rapier 实现物理效果。
使用 ecctrl 库进行人物控制。

使用socket来进行数据同步,后端使用nodejs来对socket进行消息转发具体看这篇多人同屏

image.png

获取玩家的四元数

使用ecctrl库的矛盾点

该库有可以方便实现玩家控制,但当只有使用autoBalance时,才能获得玩家的旋转信息,否则获取不到。但是其自动平衡实现有问题:很容易受到环境影响,导致人物疯狂旋转,截至发文为止,它的最新版本中疯狂旋转有所好转,但实测偶尔会触发方向失调。所以以防万一关闭autoBalance

必须找到在autoBalance关闭的情况下获取人物旋转的方法。

查看ecctrl源代码

查看ecctrl库的源代码,发现它在autoBalance打开时旋转的是RigidBody,关闭时直接旋转的人物模型。

image.png 如上图,图中的characterRef绑定的是RigidBody,这就是为什么当autoBalance关闭时无法获取旋转,因为它直接禁用了旋转。

image.png 上图中,characterModelRef绑定的是包裹玩家模型的gruop元素。也就是说它通过旋转gruop元素来旋转玩家。

获取玩家的旋转

这样我们就可以直接从玩家模型上获得旋转了,使用getWorldQuaternion获取其相对于世界坐标系的四元数(因为其是作为group的子元素一起旋转的,自身没有旋转信息)。

...
  const { scene, animations } = useGLTF(PATH);
  const rigidRef = useRef<RapierRigidBody>(null); //  玩家所在刚体
  const modelRef = useRef<THREE.Group>(null); // 人物模型
  
  // 核心代码
    useFrame(() => {
      const quat = new THREE.Quaternion();
      modelRef.current?.getWorldQuaternion(quat);
      console.log('quat',quat)
  });
  
  return <Ecctrl
      autoBalance={false}
      name="player"
      ref={rigidRef}
    >
      <Suspense fallback={null} >
        <Animation animations={animations} animationSet={ANIMATION_MAP}>
          // 人物模型
          <primitive
            castShadow
            object={scene} 
            ref={modelRef}
          />
        </Animation>
      </Suspense>
      <PositionalAudio ref={stepsRef} url={AUDIOS.steps} distance={1} loop />
    </Ecctrl>

旋转数据

在同步数据以前,我们必须要了解一些概念,以便后续进行数据压缩时使用。

空间方向表示

在三维空间中,方向可以用球坐标系表示:

  • (r,θ,φ)其中:
    • r 是半径,表示向量的长度(在单位方向向量中 r=1)。
    • θ 是方位角,表示在 xy 平面上与 x 轴正向的夹角,范围 [0,2π)。
    • φ 是极角,表示与 z 轴正向的夹角,范围 [0,π]。

3D_Spherical.svg

注意:球坐标只能表示方向,无法描述绕该方向的旋转角度。
它定义了一个指向方向的向量,但没有描述绕这个方向的旋转角度。

当你的角色只需要同步朝向(如面向目标)时,可以只同步方向(球坐标)。

空间旋转与四元数

为了表示一个物体在三维空间中的完整旋转,我们需要描述三个要素:

  1. 旋转轴(方向) :可以使用球坐标描述。
  2. 旋转角度:绕旋转轴旋转的角度。
  3. 旋转顺序:旋转是依次应用的,这在欧拉角中尤为重要。

v2-5480232b0c74e8d236044529bd170834_b.webp

一种高效表示旋转的方式是四元数

  • 四元数 q=(w,x,y,z)是一种不会发生万向节死锁的旋转表示方法。
  • 它同时描述了旋转轴和旋转角度,能够高效且稳定地处理三维旋转。
  • 单位四元数可以表述为:w^2+x^2+y^2+z^2=1

高效数据压缩

我们使用socket来同步数据,为了减轻多人在线时的服务器压力,必须对数据进行压缩。为了让消息体尽可能的小,我们二进制数据来编码。这里我们将所有要同步的数据的精度统一定为0.01。

  • 一个字节(8 位):可以表示 256 个数值
  • 两个字节(16 位):可以表示 65,536 个数值。

压缩位置数据

  • 坐标参数:x, y, z,均为必需。
  • 取值范围:[-300.00, 300.00],精度 0.01,覆盖 60,000 个可能数值。
  • 数据类型:每个坐标使用 2 字节(Int16)。
  • 总开销:3 × Int16 = 6 字节。

压缩旋转

旋转是四元数,是为了完整表达旋转,共有四个值。 但当前我们的角色仅仅有绕y轴(和unity一样threejs y轴向上)的旋转,所以 x=0,z=0。 而w可以通过公式计算获得:w^2=1-y^2。所以我们只需要同步单位四元数中的y值即可。

  • 旋转模型:单位四元数(仅同步 y 轴旋转)。
  • 取值范围:[-1.00, 1.00],精度 0.01,共 200 个可能值。
  • 数据类型:1 字节(Int8)。
  • 未来扩展:如需完整四元数,添加x,z的值。修改协议添加一个字节。
  • 总开销:1 字节。

压缩玩家状态

  • 状态种类:如静止、行走、奔跑、跳跃、攻击、防御等。
  • 种类限制:不超过 256 种。
  • 数据类型:1 字节(Int8)。

压缩玩家id

服务端会在二进制头部添加id,再分发给所有人,方便本地更新其他在线玩家的数据。

  • 取值范围:使用自增id,[1-999]
  • 数据类型:2 字节(Int16)

总开销

  • 玩家id:2字节
  • 位置数据:6 字节
  • 旋转数据:1 字节
  • 状态数据:1 字节
  • 总开销:每个玩家 上传 8 字节,同步 10 字节。

二进制编码协议

我们在每次同步数据到服务端时调用压缩协议,从服务端获取其他玩家的数据时调用解压协议。

/**
 * socket消息体压缩协议
 */
export function encodeMsg(
  pos: Vector,
  quatY: number,
  anim: number
): ArrayBuffer {
  const buffer = new ArrayBuffer(8);
  const view = new DataView(buffer);

  // 位置(Int16 x3,±327.67)
  [pos.x, pos.y, pos.z].forEach((v, i) => {
    view.setInt16(i * 2, Math.round(v * 100), true);
  });

  // Y旋转([-1.00, 1.00])
  view.setInt8(6, Math.round(quatY * 100));

  // 动画状态: [0-255]
  view.setInt8(7, anim & 0x0f);

  return buffer;
}

/**
 * socket消息体解压缩协议
 */
export function decodeMsg(buffer: ArrayBuffer): ServerMessage {
  if (buffer.byteLength !== 10) {
    throw new Error(`无效数据长度: ${buffer.byteLength} 字节`);
  }
  const view = new DataView(buffer);
  
  // 获取服务端id(0-1)
  const id = view.getUint16(0, true);

  // 解析位置(2-7)
  const pos = {
    x: view.getInt16(2, true) / 100,
    y: view.getInt16(4, true) / 100,
    z: view.getInt16(6, true) / 100,
  };
  // 解析旋转(8)
  const quatY = view.getInt8(8) / 100;
  const quat = { x: 0, y: quatY, z: 0, w: Math.sqrt(1 - quatY * quatY) };

  // 解析动画(9)
  const anim = view.getInt8(9) & 0x0f;

  return { id, pos, quat, anim };
}

image.png

去除冗余依赖

我之前使用的是socket.io的库,可是这个库有个问题,当传递二进制数据时它需要一个占位消息用于携带二进制数据,猜测应该和它的事件监听功能有关。这个占位消息长度是45B是我们的消息体的近6倍。

image.png

我反复尝试也不能去掉这个占位消息,所以直接去除了socket.io库,改用原生socket,并简单的封装,后续再慢慢完善其功能,代码如下。

主要是要设置 binaryType= "arraybuffer", 其他和一般的socket没有差别,只是接受和发送的都是二进制消息。

type EventCallback = (data: ArrayBuffer) => void;
class WebSocketClient {
  private ws!: WebSocket;
  private listeners: { [key: string]: EventCallback[] } = {};
  private reconnectInterval = 5000;

  constructor(url: string) {
    this.connect(url);
  }

  private connect(url: string) {
    this.ws = new WebSocket(url);
    this.ws.binaryType = "arraybuffer";

    this.ws.onopen = () => {
      this.emit("open");
    };

    this.ws.onmessage = (event) => this.handleMessage(event.data);

    this.ws.onclose = () => {
      this.emit("disconnect");
      console.log("socket连接已断开,尝试重连...");
      setTimeout(() => this.connect(url), this.reconnectInterval);
    };

    this.ws.onerror = (e) => {
      console.error("WebSocket 错误:", e);
      this.emit("error", e);
    };
  }

  sendMessage(buffer: Uint8Array | ArrayBuffer) {
    if (this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(buffer);
    }
  }
  private handleMessage(data: ArrayBuffer) {
    if (data.byteLength === 2) {
      this.emit("disconnect", data);
    } else {
      this.emit("message", data);
    }
  }
  on(event: string, callback: EventCallback) {
    if (!this.listeners[event]) this.listeners[event] = [];
    this.listeners[event].push(callback);
  }

  off(event: string, callback: EventCallback) {
    if (this.listeners[event]) {
      this.listeners[event] = this.listeners[event].filter(
        (cb) => cb !== callback
      );
    }
  }

  private emit(event: string, data?: unknown) {
    if (this.listeners[event]) {
      this.listeners[event].forEach((cb) => cb(data as ArrayBuffer));
    }
  }
}

socket后续可优化点

  1. 完全使用二进制数据,需要添加上socket事件的字节,我当前的socket事件很少,通过字节长度的判断就够了。如果你有很多事件的话,最好在协议中添加一个字节用来存放socket事件,然后在封装的socket中解码协议拿到相关的事件,再将消息传递给事件对应的callback函数。
  2. 可以实现模拟正常的api请求和返回功能。
  3. 实现socket单例化

服务端驱动的人物组件

我们之前由服务数据驱动的组件,基本是将自己本地的玩家角色组件修改了下使用,它的好处在于保留了物理效果,如果后续有相关处理的话,该组件上有大量的集成功能。坏处就是额外的性能开销。
我们保留这个组件,开发一个新的仅仅用于视觉展示的组件,它没有任何物理效果,仅仅由服务端传来的数据进行驱动。

它是一个纯组件,接受3个参数:位置、旋转和状态。只有当位置、旋转或状态改变时才会更新组件。

这里的模型资源是克隆的,react-three-fiber不会自动管理,因为不是从useGLTF上获取的,所以要手动执行资源回收。

const Actor = memo(
  function Actor({ position, status, rotation }: UserStatus) {
    const { scene, animations } = useGLTF(CH_PATH);
    const clone = useMemo(() => SkeletonUtils.clone(scene), [scene]);
    const { ref, actions } = useAnimations(animations);
    const groupRef = useRef<THREE.Group>(null);
    useAction(actions, status); // 实现的动画播放的hook
    useDispose(clone) // 手动回收资源的hook

    useFrame((_, delta) => {
      if (!groupRef.current || delta > 0.1) return;
      
      // 使用更平滑的插值方式
      groupRef.current.position.lerp(position, 0.34);
      groupRef.current.quaternion.slerp(rotation, 0.34);
    });

    return (
      <group dispose={null} ref={groupRef}>
        <primitive
          ref={ref}
          object={clone}
          // 坐标中心在脚底的模型,下移半个身位
          position={[0, -CHARACTER.height / 2, 0]}
          scale={0.5}
        />
      </group>
    );
  },
  (prev, next) =>
    prev.position.equals(next.position) && 
    prev.rotation.equals(next.rotation)&&
    prev.status === next.status
);

为了方便将来管理这种需要手动回收的资源,我们可以实现一个hook专门处理这种情况,具体实现可以去我的代码中看。

服务数据渲染远端玩家

这块和之前的逻辑一样,几乎没有变化,只是添加了解码的步骤。我们使用Map数据结构 用玩家的id作为键,记录和更新远端玩家的数据。

function Remotes() {
  const [actors, setActors] = useState<Map<number, UserStatus>>(new Map());

  useEffect(() => {
    if (!socket) return;
    socket.on(SocketEvents.MSG, handleMove);
    socket.on(SocketEvents.OFF, handleOffline);
    return () => {
      socket.off(SocketEvents.MSG, handleMove);
      socket.off(SocketEvents.OFF, handleOffline);
    };
  }, []);

  function handleMove(buffer: ArrayBuffer) {
    const { id, pos, quat, anim } = decodeMsg(buffer);
    const status = PLAYER_STATUS[anim] as keyof typeof PLAYER_STATUS;
    setActors((prev) => {
      const newMap = new Map(prev);
      const cur = newMap.get(id);

      // 重用 Vector3 实例避免内存抖动
      if (cur?.position) {
        cur.position.copy(pos);
        cur.rotation.copy(quat);
        cur.status = status;
      } else {
        newMap.set(id, {
          position: new THREE.Vector3(pos.x, pos.y, pos.z),
          rotation: new THREE.Quaternion(quat.x, quat.y, quat.z, quat.w),
          status,
        });
      }
      return newMap;
    });
  }
  function handleOffline(buffer: ArrayBuffer) {
    const view = new DataView(buffer);
    const id = view.getUint16(0, true);
    setActors((prev) => {
      const newMap = new Map(prev);
      newMap.delete(id);
      return newMap;
    });
  }

  return Array.from(actors.entries()).map(([id, player]) => (
    <Actor
      key={id}
      position={player.position}
      status={player.status}
      rotation={player.rotation}
    />
  ));
}

尾声

后记

之前为了压缩数据,我们在传递旋转信息时仅仅使用了y的分量,w是用公式: Math.sqrt(1 - quatY * quatY) }计算,但是这个计算有一个小问题,那就是有该运算有根号,所以取值有±2种情况,因为没法确定正负,所以其在传递旋转时,当w为负数时,因计算问题,会导致旋转角度错误。

所以最后调整协议,把w分量也传递过来。二进制编码数据的长度在原来的基础上+1,同时为了平衡这一字节的开销,我将玩家id的字节压缩到了1字节(id取值在1-256之间)。所以后端不会支持超过256多个玩家同时在线,如果超过,就会踢人。

效果

压缩.gif

源码地址

本次的代码在game分支中,如果想看到以前的海岛场景,需要切换路由到/island

🔗 源码地址

结语

最近看到丁真一夜而起的视频,甚为感慨,作诗一首,供诸位品鉴!

断壁垣中新风吹,蝉鸣须弥只余蜕。
天下英雄岂我辈,夜风萧瑟落叶堆。

vite6迁移H5脚手架(二) —— 动态代理插件

背景

  • 以系列一为背景
  • 当前每一个entry都有自己的代理路径,如果直接写到vite的 proxy配置里,可太难维护了,而且还容易出现冲突

image.png

实现思路

vite plugin中有个configureServer的钩子可以拿到我们的dev server实例,可以通过自定义的中间件形式去实现动态设置代理。通过遍历views, 去尝试查找entry所在的目录下是否存在代理设置文件

async function importFile(modName: string) {
  const mod = await import('file://' + modName + '?t=' + Date.now())
  return mod.default || mod
}

function getEntryConfig(proxyFile: string): EntryRuleConfig {
  return {
    file: proxyFile,
    ruleMap: {}
  }
}

/**
 * 异步函数:更新入口代理规则映射
 * 该函数用于更新特定入口点的代理规则映射,根据给定的代理文件路径导入规则,并映射到该入口点
 *
 * @param proxyMap 代理文件映射,是一个映射表,用于存储不同入口点的代理配置
 * @param entry 入口点标识符,用于在代理映射中唯一标识一个入口点
 * @param proxyFilePath 代理文件路径,指向包含代理规则配置的文件
 */
async function updateEntryProxyRuleMap(
  proxyMap: ProxyFileMap,
  entry: string,
  proxyFilePath: string
) {
  // 获取入口点的配置,如果不存在,则从代理文件路径中获取
  const entryMap = proxyMap.get(entry) || getEntryConfig(proxyFilePath)

  // 从代理文件路径中导入所有的规则
  const rules = await importFile(proxyFilePath)

  // 清空入口点配置中的原有规则映射,准备更新
  entryMap.ruleMap = {}

  // 遍历导入的规则,将每个规则添加到入口点的规则映射中
  rules.forEach((rule: Obj) => {
    // 解构规则对象,分离出上下文数组和其他规则属性
    const { contexts, ...ruleObj } = rule

    // 如果规则是启用状态且包含上下文数组,则遍历每个上下文,创建规则配置并添加到映射中
    if (rule.enable && rule.contexts?.length) {
      rule.contexts.forEach((context: string) => {
        // 将规则配置与上下文一起存储在入口点的规则映射中
        entryMap.ruleMap[context] = {
          ...ruleObj,
          context
        } as RuleConfig
      })
    }
  })

  // 更新代理映射,将新的入口点配置存储回去
  proxyMap.set(entry, entryMap)
}

/**
 * 异步函数,通过视图数组生成带有代理映射的条目
 * 该函数旨在为每个视图创建一个代理文件映射,便于后续处理网络请求的代理规则
 *
 * @param views 视图数组,包含需要生成代理映射的文件信息
 * @returns 返回一个Promise,解析为ProxyFileMap实例,映射了每个视图的代理文件路径
 */
async function getEntryWithProxyMap(views: View[]) {
  // 初始化一个新的Map对象,用于存储视图路径与其对应的代理文件路径的映射
  const newMap: ProxyFileMap = new Map()

  // 遍历视图数组,为每个视图创建代理映射
  for (let i = 0; i < views.length; i++) {
    // 获取当前视图项
    const item = views[i]
    // 构造视图路径的键值,用于Map中映射
    const key = `/${item.filename}`
    // 拼接当前视图的代理文件完整路径
    const proxyFile = path.join(process.cwd(), item.entryDir, 'proxyRules.js')
    // 检查代理文件是否存在,如果存在则更新到Map中
    if (fs.existsSync(proxyFile)) {
      // 调用异步函数updateEntryProxyRuleMap,更新Map中的代理规则
      await updateEntryProxyRuleMap(newMap, key, proxyFile)
    }
  }
  // 返回构造完成的代理文件映射Map
  return newMap
}

如果存在,这读取该文件内容,维护到proxyMap中,方便后续读取规则,避免反复读取文件。 然后我们插件的入口可以这么引入中间件

function devServerPlugin(pages: View[]): Plugin {
  return {
    name: 'dev-server',
    apply: 'serve',
    enforce: 'post',
    async configureServer(server: ViteDevServer) {
      server.middlewares.use(await serviceProxyMiddleware({ pages, server }))
    }
  }
}

中间件

async function serviceProxyMiddleware(options: ProxyOptions): Promise<Connect.NextHandleFunction> {
  proxyFnMap.clear()
  // 获取entry与代理配置文件路径的映射
  const proxyFileMap: ProxyFileMap = await getEntryWithProxyMap(options.pages)
  watchProxyRuleFile(options, proxyFileMap)

  return function proxy(req, res, next) {
    if (
      !req.headers.referer ||
      /@vite|@react|node_modules|src|@|css|js|ts|vue|\.html/g.test(req.url || '')
    ) {
      return next()
    }

    // 请求来源页面
    const refererUrl = new URL(req.headers.referer)
    if (!proxyFileMap.has(refererUrl.pathname)) {
      return next()
    }
    const proxyRuleConfig = proxyFileMap.get(refererUrl.pathname) as EntryRuleConfig

    for (const context in proxyRuleConfig.ruleMap) {
      if (!contextMatch(context, req.originalUrl || req.url || '')) {
        continue
      }
      // 运行跨域代理
      return getProxyFn(proxyRuleConfig.ruleMap[context], proxyRuleConfig.file)(req, res, next)
    }
    next()
  }
}

通过拿到req.url命中proxyFileMap中配置后,核心方法getProxyFn,使用http-proxy-middleware这个包实现

import { createProxyMiddleware } from 'http-proxy-middleware'
/**
 * 运行proxy配置规则
 * @param proxyRule 配置规则
 * @param filename 配置文件
 * @param opts 更多配置
 */
function getProxyFn(
  ruleConfig: RuleConfig,
  filename: string
): RequestHandler<
  http.IncomingMessage,
  http.ServerResponse<http.IncomingMessage>,
  (err?: any) => void
> {
  const { context, target, ...opts } = ruleConfig
  const cacheKey = `${context}||${target}`
  const cacheFn = proxyFnMap.get(cacheKey)
  if (cacheFn) {
    return cacheFn
  }

  const proxyOptions: ProxyOptionsOptions = {
    pathFilter: context,
    target,
    changeOrigin: true,
    ws: true,
    on: {
      proxyRes(proxyRes, req) {
        const reqURl = req.url || ''
        const url = new URL(target + reqURl)
        proxyRes.headers['service-proxy-middleware-filename'] = filename
        proxyRes.headers['service-proxy-middleware-context'] = context
        proxyRes.headers['service-proxy-middleware-pathname'] = url.pathname
        proxyRes.headers['service-proxy-middleware-target'] = target
        proxyRes.headers['service-proxy-middleware-match'] = target + reqURl
      }
    },
    ...opts
  }

  const newProxyFn = createProxyMiddleware<http.IncomingMessage, http.ServerResponse>(proxyOptions)
  proxyFnMap.set(cacheKey, newProxyFn)

  return newProxyFn
}

这样我们就完成了接口代理,但是代理文件内容变更时候怎么自动重启,watchProxyRuleFile

/**
 * 监听代理配置文件
 * 该函数使用chokidar库监视代理规则文件的更改,以便在文件变化时动态更新代理配置
 *
 * @param options - 包含代理选项的对象,包括是否实时日志记录的配置
 * @param proxyFileMap - 一个映射,将代理规则与其对应的文件路径关联起来
 */
function watchProxyRuleFile(options: ProxyOptions, proxyFileMap: ProxyFileMap) {
  // 创建一个唯一的代理规则文件路径数组
  const arrProxyRules = [
    ...new Set(
      [...proxyFileMap].reduce((prev, curr) => {
        return prev.concat(curr[1].file)
      }, [] as string[])
    )
  ] as string[]

  // 初始化文件监视器
  const watcher = chokidar.watch(arrProxyRules, {
    persistent: true
  })

  // 监听文件变化事件
  watcher.on('all', async (event, path) => {
    // 忽略某些事件类型
    if (['unlinkDir', 'addDir', 'ready', 'error', 'raw', 'add'].includes(event)) {
      return
    }

    // 遍历代理文件映射,查找并更新受影响的代理规则
    for (const proxyItem of proxyFileMap) {
      if (proxyItem[1].file === path) {
        await updateEntryProxyRuleMap(proxyFileMap, proxyItem[0], path)
        break
      }
    }
  })
}

使用chokidar轻松实现文件监听

结语

这样我们还可以使用cli-table3终端内表格打印启动的entry下的代理信息。最后核心就是在pugin中通过configureServer钩子拿到server实例,自定义中间件拿到req后,res.send随你怎么操作

dart3.0中令人迷糊的各种类修饰符与组合,我真的没搞清楚!

abstract、interface、final、base、mixin、以及它们的各种组合,各种代表啥?有啥用?

先上一个令人迷糊的,这是官网的示例

image.png 它说Car不能继承Vehicle,但是我复制代码到一个测试文件里,它并没有报错:

image.png

我有那么一瞬间认为官网的搞错了!后来才发现,它这个跟_是一个玩法:都要区分文件。这样改为2个文件就能正常报错了:

image.png

image.png

回到标题,鉴于太懒不想写,直接贴官网的表格:

Declaration Construct? Extend? Implement? Mix in? Exhaustive?
class Yes Yes Yes No No
base class Yes Yes No No No
interface class Yes No Yes No No
final class Yes No No No No
sealed class No No No No Yes
abstract class No Yes Yes No No
abstract base class No Yes No No No
abstract interface class No No Yes No No
abstract final class No No No No No
mixin class Yes Yes Yes Yes No
base mixin class Yes Yes No Yes No
abstract mixin class No Yes Yes Yes No
abstract base mixin class No Yes No Yes No
mixin No No Yes Yes No
base mixin No No No Yes No

这表格如此之大咱也不能硬背啊(主要是咱人老了记忆力不行了)!!!这里我只做几个总结帮助大家快速记忆和区分:

  1. 只要带有interface的就表示他是一个接口(废话),它就能被实现且不能被继承!
  2. 只要带有abstract的就不能有构造方法,同时也就拥有了添加抽象成员的能力。
  3. 只有带mixin的才能混入。
  4. 封闭类有且只有一个:sealed class

interface classabstract interface class的区别是:后者是一个更纯粹的接口,意思是说它更像Java之前的对接口含义的定义。前者不能定义抽象成员,所有的成员都必须自己实现一遍(可空实现),感觉失去了接口定义的含义了。

再看一下mixinmixin class

先看代码↓

void main(List<String> arguments) {
  final aBu = ABu();
  aBu.hello();

  final aNiu = ANiu();
  aNiu.hello();
}

abstract interface class Animal {
  String get name;
}

mixin CatMixin implements Animal {
  void yell() {
    print('$name is yelling');
  }
}

mixin class CatMixinClass implements Animal {
  /// 可以有一个无参构造函数
  CatMixinClass();

  /// 但是必须要实现这个Animal的name getter
  @override
  String get name => 'Cat';

  void yell() {
    print('$name is yelling');
  }
}

class ABu with CatMixin {
  @override
  String get name => 'A Bu';

  void hello() {
    yell();
  }
}

class ANiu with CatMixinClass {
  void hello() {
    yell();
  }
}

运行后输出:

A Bu is yelling
Cat is yelling

简单总结:虽然2者都可以都可以被混入,不过概念上还是有点区别:

  • mixin是一个纯粹的混入,它是无状态的
  • mixin class本质上还是一个,但是它经过mixin的修饰后,也约束了它一些能力。比如:
    • 只能是一个无参的构造函数(这个函数默认也有,可以不用显示定义)
    • 不能有成员变量
    • 它仍然能被继承(个人感觉理解上有点怪)

从0到1构建开源 vue-uniapp-template

创建项目

初始化项目

按照 uni-app 官方文档 的步骤,通过 vue-cli 创建 uni-app + vue + typescript 脚手架:

npx degit dcloudio/uni-preset-vue#vite-ts uniapp-vite-vue3-ts-template

image.png

启动项目

创建完成后,使用 VSCode 打开项目并启动:

# 安装依赖
pnpm install
# 启动项目
pnpm  dev:h5

8187a445-983a-498f-96ef-36650cdb2751.png

项目启动后,访问 http://localhost:5173 预览效果:

a588eeeb-e835-451f-b03e-f02d5d692913.png

代码规范配置

为了保证项目代码的规范性和一致性,可以为项目配置 ESLintStylelintPrettier 以及 Husky,从而确保代码质量和开发流程的一致性。

集成 ESLint

ESLint 是一款 JavaScript 和 TypeScript 的代码规范工具,能够帮助开发团队保持代码风格一致并减少常见错误。

ESLint 中文网eslint.nodejs.cn/

安装插件

VSCode 插件市场搜索 ESLint 插件并安装

4d820c14-fc06-44e7-be93-526691e34ad5.png

并且添加到工作区:

fd2be039-3352-468f-955f-54f43b2654fc.png

配置 ESLint

通过以下命令快速生成 ESLint 配置文件:

npx eslint --init

79332862-1cd7-4b58-ba57-92fcbc7d090a.png

执行该命令后,ESLint 会通过交互式问题的方式,帮助生成配置文件。针对 9.x 版本,默认会生成基于 Flat Config 格式的 `eslint.config.mjs import globals from "globals"; // 全局变量 import js from "@eslint/js"; // JavaScript 的推荐配置 import tseslint from "typescript-eslint"; // TypeScript 的推荐配置 import pluginVue from "eslint-plugin-vue"; // Vue 的推荐配置 import { defineConfig } from "eslint/config";

export default defineConfig([ { files: ["/*.{js,mjs,cjs,ts,vue}"], plugins: { js }, extends: ["js/recommended"], }, { files: ["/.{js,mjs,cjs,ts,vue}"], languageOptions: { globals: globals.browser }, }, tseslint.configs.recommended, pluginVue.configs["flat/essential"], { files: ["**/.vue"], languageOptions: { parserOptions: { parser: tseslint.parser } }, },

// 添加忽略的目录或文件 { ignores: [ "/dist", "/public", "/node_modules", "/*.min.js", "/.config.mjs", "**/.tsbuildinfo", "/src/manifest.json", ], },

// 自定义规则 { rules: { quotes: ["error", "double"], // 强制使用双引号 "quote-props": ["error", "always"], // 强制对象的属性名使用引号 semi: ["error", "always"], // 要求使用分号 indent: ["error", 2], // 使用两个空格进行缩进 "no-multiple-empty-lines": ["error", { max: 1 }], // 不允许多个空行 "no-trailing-spaces": "error", // 不允许行尾有空格

  // TypeScript 规则
  "@typescript-eslint/no-explicit-any": "off", // 禁用 no-explicit-any 规则,允许使用 any 类型
  "@typescript-eslint/explicit-function-return-type": "off", // 不强制要求函数必须明确返回类型
  "@typescript-eslint/no-empty-interface": "off", // 禁用 no-empty-interface 规则,允许空接口声明
  "@typescript-eslint/no-empty-object-type": "off", // 允许空对象类型

  // Vue 规则
  "vue/multi-word-component-names": "off", // 关闭多单词组件名称的限制
  "vue/html-indent": ["error", 2], // Vue 模板中的 HTML 缩进使用两个空格
  "vue/no-v-html": "off", // 允许使用 v-html (根据实际项目需要)
},

}, ]);  文件,与之前的 .eslintrc` 格式有所不同。

默认生成的 eslint.config.mjs 文件如下所示:

16d0bb73-2779-4200-b443-5665128f125c.png

在此基础上,可以根据项目的需求进行一些定制化配置,例如添加忽略规则或自定义的特殊规则。

import globals from "globals"; // 全局变量
import js from "@eslint/js"; // JavaScript 的推荐配置
import tseslint from "typescript-eslint"; // TypeScript 的推荐配置
import pluginVue from "eslint-plugin-vue"; // Vue 的推荐配置
import { defineConfig } from "eslint/config";

export default defineConfig([
  {
    files: ["**/*.{js,mjs,cjs,ts,vue}"],
    plugins: { js },
    extends: ["js/recommended"],
  },
  {
    files: ["**/*.{js,mjs,cjs,ts,vue}"],
    languageOptions: { globals: globals.browser },
  },
  tseslint.configs.recommended,
  pluginVue.configs["flat/essential"],
  {
    files: ["**/*.vue"],
    languageOptions: { parserOptions: { parser: tseslint.parser } },
  },

  // 添加忽略的目录或文件
  {
    ignores: [
      "/dist",
      "/public",
      "/node_modules",
      "**/*.min.js",
      "**/*.config.mjs",
      "**/*.tsbuildinfo",
      "/src/manifest.json",
    ],
  },

  // 自定义规则
  {
    rules: {
      quotes: ["error", "double"], // 强制使用双引号
      "quote-props": ["error", "always"], // 强制对象的属性名使用引号
      semi: ["error", "always"], // 要求使用分号
      indent: ["error", 2], // 使用两个空格进行缩进
      "no-multiple-empty-lines": ["error", { max: 1 }], // 不允许多个空行
      "no-trailing-spaces": "error", // 不允许行尾有空格

      // TypeScript 规则
      "@typescript-eslint/no-explicit-any": "off", // 禁用 no-explicit-any 规则,允许使用 any 类型
      "@typescript-eslint/explicit-function-return-type": "off", // 不强制要求函数必须明确返回类型
      "@typescript-eslint/no-empty-interface": "off", // 禁用 no-empty-interface 规则,允许空接口声明
      "@typescript-eslint/no-empty-object-type": "off", // 允许空对象类型

      // Vue 规则
      "vue/multi-word-component-names": "off", // 关闭多单词组件名称的限制
      "vue/html-indent": ["error", 2], // Vue 模板中的 HTML 缩进使用两个空格
      "vue/no-v-html": "off", // 允许使用 v-html (根据实际项目需要)
    },
  },
]);


添加 ESLint 脚本

为了方便使用 ESLint,可以在 package.json 中添加 lint 脚本命令:

{ "scripts": {  "lint:fix": "eslint --fix  ./src"} }

095f36bd-a829-4dd5-a558-ce94a4bec602.png 此脚本会自动修复符合 ESLint 规则的代码问题,并输出检查结果。

测试效果

在 App.vue 文件中声明一个未使用的变量,并运行 pnpm run lint:fix,可以看到 ESLint 提示该变量未使用。如下图所示:

![2bfa6589-d89f-43a5-bfa3-ffcf855d44f6.png](p0-xtjj-private.juejin.cn/tosh



#### 集成 Prettier
Prettier 是一个代码格式化工具,能够和 ESLint 配合使用,确保代码风格统一。

**prettier 中文网**:<https://prettier.nodejs.cn/>

##### 安装插件
VSCode 插件市场搜索 `Prettier - Code formatter` 插件安装

![1bb8a470-5d65-4e1d-ab64-ffbc4140c982.png](https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/48fef1018acf4a78a06578ceef3662d8~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5bKp5p-P:q75.awebp?rk3s=f64ab15b&x-expires=1748339004&x-signature=py6y3UxnFR%2FIUbVVGkY%2F%2F%2BaLXZo%3D)

也需要把这个插件添加到工作区

##### 安装依赖

```bash
pnpm install -D prettier eslint-config-prettier eslint-plugin-prettier
  • prettier:主要的 Prettier 格式化库。
  • eslint-plugin-prettier:将 Prettier 的规则作为 ESLint 的规则来运行。
  • eslint-config-prettier:禁用所有与格式相关的 ESLint 规则,以避免和 Prettier 的冲突。
配置 Prettier

项目根目录下新建配置文件 prettier.config.mjs,添加常用规则:

export default {
  printWidth: 100, // 每行最多字符数量,超出换行(默认80)
  tabWidth: 2, // 缩进空格数,默认2个空格
  useTabs: false, // 指定缩进方式,空格或tab,默认false,即使用空格
  semi: true, // 使用分号
  singleQuote: false, // 使用单引号 (true:单引号;false:双引号)
  trailingComma: 'all', // 末尾使用逗号
};
配置忽略文件

项目根目录新建 .prettierignore 文件指定 Prettier 不需要格式化的文件和文件夹

# .prettierignore
node_modules
dist
public
*.min.js
添加格式化脚本

在 package.json 文件中添加:

{ "scripts": { "format": "prettier --write ./src" } }

集成 Stylelint

Stylelint 一个强大的 CSS linter(检查器),可帮助您避免错误并强制执行约定。

Stylelint 官网stylelint.io/

安装插件

VSCode 插件搜索 Stylelint 并安装

fba31566-3759-47b6-9211-6980e98d72c5.png

也需要添加到工作区

安装依赖
pnpm install -D postcss postcss-html postcss-scss stylelint stylelint-config-recommended stylelint-config-recommended-scss stylelint-config-recommended-vue stylelint-config-recess-order stylelint-config-html stylelint-prettier

依赖 说明 备注
postcss CSS 解析工具,允许使用现代 CSS 语法并将其转换为兼容的旧语法
postcss-html 解析 HTML (类似 HTML) 的 PostCSS 语法 postcss-html 文档
postcss-scss PostCSS 的 SCSS 解析器 postcss-scss 文档,支持 CSS 行类注释
stylelint stylelint 核心库 stylelint
stylelint-config-standard Stylelint 标准共享配置 stylelint-config-standard 文档
stylelint-config-recommended
stylelint-config-recommended-scss 扩展 stylelint-config-recommended 共享配置并为 SCSS 配置其规则 stylelint-config-recommended-scss 文档
stylelint-config-recommended-vue 扩展 stylelint-config-recommended 共享配置并为 Vue 配置其规则 stylelint-config-recommended-vue 文档
stylelint-config-recess-order 提供优化样式顺序的配置 CSS 书写顺序规范
stylelint-config-html 共享 HTML (类似 HTML) 配置,捆绑 postcss-html 并对其进行配置 stylelint-config-html 文档
stylelint-prettier
配置 Stylelint

根目录新建 .stylelintrc.json 文件,配置如下:

{
  "extends": [
    "stylelint-config-recommended",
    "stylelint-config-recommended-scss",
    "stylelint-config-recommended-vue/scss",
    "stylelint-config-html/vue",
    "stylelint-config-recess-order"
  ],
  "plugins": ["stylelint-prettier"],
  "overrides": [
    {
      "files": ["**/*.{vue,html}"],
      "customSyntax": "postcss-html"
    },
    {
      "files": ["**/*.{css,scss}"],
      "customSyntax": "postcss-scss"
    }
  ],

  "rules": {
    "import-notation": "string",
    "selector-class-pattern": null,
    "custom-property-pattern": null,
    "keyframes-name-pattern": null,
    "no-descending-specificity": null,
    "no-empty-source": null,
    "unit-no-unknown": [
      true,
      {
        "ignoreUnits": ["rpx"]
      }
    ],
    "selector-type-no-unknown": [
      true,
      {
        "ignoreTypes": ["page"]
      }
    ],
    "selector-pseudo-class-no-unknown": [
      true,
      {
        "ignorePseudoClasses": ["global", "export", "deep"]
      }
    ],
    "property-no-unknown": [
      true,
      {
        "ignoreProperties": []
      }
    ],
    "at-rule-no-unknown": [
      true,
      {
        "ignoreAtRules": ["apply", "use", "forward"]
      }
    ]
  }
}


配置忽略文件

根目录创建 .stylelintignore 文件,配置忽略文件如下:

*.min.js 
dist
public
node_modules
添加 Stylelint 脚本

package.json 添加 Stylelint 检测指令:

"scripts": { "lint:stylelint": "stylelint \"**/*.{css,scss,vue,html}\" --fix" }
保存自动修复

项目根目录下.vscode/settings.json 文件添加配置:

{ "editor.codeActionsOnSave": { "source.fixAll.stylelint": true }, "stylelint.validate": ["css", "scss", "vue", "html"] }

Git提交规范配置

配置 Husky 的 pre-commit 和 commit-msg 钩子,实现代码提交的自动化检查和规范化。

  • pre-commit: 使用 Husky + Lint-staged,在提交前进行代码规范检测和格式化。确保项目已配置 ESLint、Prettier 和 Stylelint。
  • commit-msg: 结合 Husky、Commitlint、Commitizen 和 cz-git,生成规范化且自定义的 Git commit 信息。
集成 Husky

Husky 是 Git 钩子工具,可以设置在 git 各个阶段(pre-commitcommit-msg 等)触发。

Husky官网typicode.github.io/husky/

image.png

安装依赖
pnpm add --save-dev husky
初始化

init 命令简化了项目中的 husky 设置。它会在 .husky/ 中创建 pre-commit 脚本,并更 新 package.json 中的 prepare 脚本。

pnpm exec husky init

通过 pre-commit 钩子,可以自动运行各种代码检查工具,在提交代码前强制执行代码质量和样式检查。常见的工具包括:

  • eslint:用于检查和修复 JavaScript/TypeScript 代码中的问题。
  • stylelint:用于检测和修复 CSS/SCSS 样式问题。

接下来,集成 lint-staged 和 commitlint 来进一步完善开发体验。

集成 lint-staged

lint-staged 是一个工具,专门用于只对 Git 暂存区的文件运行 lint 或其他任务,确保只检查和修复被修改或新增的代码部分,而不会影响整个代码库。这样可以显著提升效率,尤其是对于大型项目

安装依赖

使用以下命令安装 lint-staged

pnpm add -D lint-staged
配置 lint-staged

在 package.json 中添加 lint-staged 配置,确保在 pre-commit 阶段自动检测暂存的文件:

{
  
  "lint-staged": {
    "*.{js,ts}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{cjs,json}": [
      "prettier --write"
    ],
    "*.{vue,html}": [
      "eslint --fix",
      "prettier --write",
      "stylelint --fix"
    ],
    "*.{scss,css}": [
      "stylelint --fix",
      "prettier --write"
    ],
    "*.md": [
      "prettier --write"
    ]
  }
}

在 package.json 的 scripts 部分中,添加用于运行 lint-staged 的命令:

"scripts": { "lint:lint-staged": "lint-staged" }
添加 Husky 钩子

在项目根目录的 .husky/pre-commit 中添加以下命令,确保在提交代码前执行 lint-staged: pnpm run lint:lint-staged

从0到1构建开源 vue-uniapp-template:使用 UniApp + Vue3 + TypeScript 和 VSCoe、CLI 开发跨平台移动端脚手架 - 有来技术 - 博客园

eslint+prettier+husky+commitlint 项目工程化管理

关于eslint 和 prettier 对于项目的管理

本次node版本为 v20.18.2

本次预计使用的插件有

  1. eslint 代码检查
  2. perttier 美化代码
  3. husky 提交检查
  4. lint-staged 提交前钩子配置
  5. commitlint 提交文本的检查 例如feat: xxxx

创建demo代码

pnpm create vite

eslint

初始化eslint配置

npx eslint --init

image-20250520160656056.png

创建完成后生成会自动生成eslint.config.js文件

文件内容如下

import js from "@eslint/js" // 校验js规范(推荐)
import globals from "globals"
import tseslint from "typescript-eslint" // 推荐的ts规范
import pluginVue from "eslint-plugin-vue" // 校验vue规范(推荐)
import { defineConfig } from "eslint/config"

export default defineConfig([
  {
    files: ["**/*.{js,mjs,cjs,ts,vue}"],
    plugins: { js },
    extends: ["js/recommended"]
  },
  {
    files: ["**/*.{js,mjs,cjs,ts,vue}"],
    languageOptions: { globals: { ...globals.browser, ...globals.node } }
  },
  tseslint.configs.recommended,
  pluginVue.configs["flat/essential"],
  {
    files: ["**/*.vue"], // 校验vue中的ts代码
    languageOptions: { parserOptions: { parser: tseslint.parser } }
  },
  {
    // 那些文件不通过 eslint校验
    ignores: [".css", "*.d.ts"],
    // 细节的校验规则
    rules: {
      semi: "error",
      "no-console":"error"
    }
  }
])

ignores 和 rules 自行配置

可以暂时配置一些规则

比如代码中写一些
var a = '123';

console.log('xxxxx');


命令行 执行 eslint

image-20250520162146566.png

代码会提示eslint的报错信息 证明eslint已经生效了

可以运行eslint --fix 代码将按照eslint的规则去适当的修复报错信息

prettier

pnpm install prettier eslint-plugin-prettier eslint-config-prettier   -D
  1. prettier 美化代码
  2. eslint-plugin-prettier 在eslint中使用prettier
  3. eslint-config-prettier 代码合并 如果eslint和prettier格式有冲突 已prettier为准

项目中创建 prettier.config.js 配置一些规则


export default {
  singleQuote: false, // 默认单引号
  semi: false, // 末尾添加分号
  tabWidth: 2, // 缩进
  trailingComma: "none", // 默认没有尾逗号
  useTabs: false, // 默认不使用tab
  endOfLine: "auto", // 默认自动换行
  jsxSingleQuote: false // 默认jsx单引号
}

在eslint.config.js 中使用 eslint-config-prettier

import js from "@eslint/js" // 校验js规范(推荐)
import globals from "globals"
import tseslint from "typescript-eslint" // 推荐的ts规范
import pluginVue from "eslint-plugin-vue" // 校验vue规范(推荐)
import { defineConfig } from "eslint/config"
import prettierRecommended from "eslint-plugin-prettier/recommended"

export default defineConfig([
  {
    files: ["**/*.{js,mjs,cjs,ts,vue}"],
    plugins: { js },
    extends: ["js/recommended"]
  },
  {
    files: ["**/*.{js,mjs,cjs,ts,vue}"],
    languageOptions: { globals: { ...globals.browser, ...globals.node } }
  },
  tseslint.configs.recommended,
  pluginVue.configs["flat/essential"],
  {
    files: ["**/*.vue"], // 校验vue中的ts代码
    languageOptions: { parserOptions: { parser: tseslint.parser } }
  },
  {
    // 那些文件不通过 eslint校验
    ignores: [".css", "*.d.ts"],
    //  细节的校验规则
    rules: {
      semi: "error"
    }
  },
  prettierRecommended //使用prettier的推荐规则覆盖eslint规则
])

vscode 配置

vscode插件 安装 eslint 插件识别错误的时候可以自动识别 不需要运行 eslint命令行了

vscode插件 安装 prettier prettier eslint

勾选,保存自动格式化

image-20250520162956148.png

设置采用prettier进行格式化代码

image-20250520165221488.png

支持基础的eslint+prettier配置基本完成

editorConfig

可以安装vscode插件editorconfig 用于给编辑器识别文件的基本格式

image-20250520165700993.png

安装后根目录增加文件.editorconfig内容如下

root = true

[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf

husky

用于git提交代码的时候进行 代码的检查工作 未通过不允许提交

lint-staged 用于 优化对比 第二次提交的时候提交到缓存区 在缓存区对比

步骤

  1. 初始化代码 git init 记得创建.gitignore

  2. pnpm install husky lint-staged -D

  3. 在package.json中配置 代码如下

image-20250520170446031.png

  1. 在git中添加钩子 运行npx husky init 会在根目录增加.husky文件夹

  2. 在.husky中的pre-commit 输入npx lint-staged 这样每次提交之前就会去检测代码

image-20250520171003919.png

commitlint

用于提交代码的文本校测 是否标准 例如feat: xxx doc: xxxx fix: xxxx

  1. 安装插件 pnpm install @commitlint/cli @commitlint/config-conventional -D

  2. 项目根目录下新增配置文件commitlint.config.cjs 内容如下

    module.exports = {
      extends: ["@commitlint/config-conventional"]
    }
    
  1. 在.husky文件夹中新增commit-msg文件(文本文件) 输入

    npx commitlint --edit $1
    

配成成功后提交代码会提示

image-20250520172212917.png

到此为止全部配置完成

❌