阅读视图

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

借助CSS实现自适应屏幕边缘的tooltip

欢迎关注我的公众号:前端侦探

tooltip是一个非常常见的交互,一般用于补充文案说明。比如下面这种(蓝色边框表示屏幕边缘)

image-20250523223912070

通常tooltip都会有一个固定的方向,比如top表示垂直居中向上。

但是,如果提示文案比较多,提示区域右比较靠近屏幕边缘,就可能出现这种情况

image-20250523224309231

直接超出屏幕!这很显然是不能接受的。

你可能会想到改变一下对齐方向,比如top-right,但是这里的文案可能是不固定的,也就是会出现这样

image-20250523224706671

嗯...感觉无论怎么对齐都会有局限。那么如何解决呢?一起看看吧

一、理想中的自适应对齐

我们先想想,最完美的对齐是什么样的。

其实没那么复杂,就分两种情况,一个居左,一个居右

1.居左

正常情况下,就是垂直居中朝上

image-20250523225707868

如果提示文本比较多,那就靠左贴近文本容器对齐

image-20250523225826347

如果提示文本继续增加,那就整行换行,并且不超过文本容器

image-20250523230041333

2. 居右

正常情况下,也是垂直居中朝上

image-20250523230849249

如果提示文本比较多,那就靠右贴近文本容器对齐

image-20250523230936187

如果提示文本继续增加,也是整行换行,并且不超过文本容器

image-20250523231035167

那么如何实现这样的对齐方式呢?

二、左自适应对齐的思路

我们先看第一种情况,看似好像有3种对齐方式,而且还要监测是否到了边界,好像挺复杂。其实换个角度,其实是这样一种规则

  1. 当内容较少时,居中对齐
  2. 当内容较多时,居左对齐
  3. 当内容多到换行时,有一个最大宽度

既然涉及到了对齐,那就有对齐的容器和被对齐的对象。

我们可以想象一个虚拟容器,以对齐中心(下图问号图标)向两边扩展,一直到边界处,如下所示(淡蓝色区域)

image-20250523233837924

假设HTML如下

<span class="tooltip" title="提示"></span>

当气泡文本比较少时,可以通过文本对齐实现居中,气泡可以直接通过伪元素实现

.tooltip{
  width: 50px; /*虚拟容器宽度,暂时先固定 */
  text-align:center;
}
.tooltip::before{
  content: attr(title);
  display: inline-block;
  color: #fff;
  background-color: #000;
  padding: .5em 1em;
  border-radius: 8px;
  box-sizing: border-box;
}
/*居中箭头*/
.tooltip::after{
  content: '';
  position: absolute;
  width: 1em;
  height: .6em;
  background: #000;
  clip-path: polygon(0 0, 100% 0, 50% 100%);
  top: 0;
  left:0;
  right:0;
  margin: 0 auto;
  transform: translateY(-150%)
}

使用文本居中,也就是text-align: center有个好处,当文本不超过容器时,居中展示,就如同上图展示一样。

当文本比较多时,默认会换行,效果如下

image-20250524112135696

这样应该很好理解吧。

我们需要气泡里的文本在多行时居左,可以直接给气泡设置居左对齐

.tooltip::before{
  /*...*/
  text-align: left;
}

效果如下

image-20250524112333960

这样就实现了单行居中,多行居左的效果了。

现在还有一个问题,如何在气泡文本较多时,不被对齐容器束缚呢?

首先可以想到的是禁止换行,也就是

.tooltip::before{
  /*...*/
  white-space: nowrap
}

这样在文本不超过一行时确实可以

image-20250524112641734

看,已经突破了容器束缚。但是文本继续增加时,也会出现无法换行的问题

image-20250524112800984

我们可以想一想,还有什么方式可以控制换行呢?

这里,我们需要设置宽度为最大内容宽度,相当于文本有多少,文本容器就有多宽

.tooltip::before{
  /*...*/
  width: max-content
}

看似好像和不换行一样

image-20250524112800984

实则不然,我们并没用禁止换行。只要给一个最大宽度,立马就换行了

.tooltip::before{
  /*...*/
  width: max-content;
  max-width: 300px;
}

效果如下

image-20250524113318010

是不是几乎实现了我们想要的效果了?

不过,这里涉及了两个需要动态计算的宽度,一个是虚拟容器宽度,还有一个是外层最大宽度,

image-20250524152008282

下面看如何实现

三、借助JS计算所需宽度

现如今,外层的最大宽度倒是可以通过容器查询获得,但内部的虚拟容器宽度还无法直接获取,只能借助JS了。

不过我们这里可以先只计算左侧偏移,也就是一半的宽度

image-20250524155257344

具体实现如下

//问号中心到左侧距离
const x = this.offsetLeft - 8
// 问号的宽度
const w = this.clientWidth
// 外层整行文本容器宽度
const W = this.offsetParent.clientWidth - 32
// 左侧偏移
this.style.setProperty('--x', x + 'px')
// 外层文本容器宽度(气泡最大宽度)
this.style.setProperty('--w', W + 'px')

然后给前面待定的宽度绑定这些变量就行了

.tooltip{
  /*...*/
  width: calc(var(--x) * 2);
}
.tooltip::before{
  /*...*/
  max-width: var(--w);
}

这样左侧就完全实现自适应了,无需实时计算,仅需初始化一次就好了

Kapture 2025-05-24 at 15.56.13转存失败,建议直接上传图片文件

四、完全自适应对齐

前面是左侧,那右侧如何判断呢?我们可以比较左侧距离的占比,如果超过一半,就表示现在是居右了

这里用一个属性表示

this.tooltip.dataset.left = x/W < 0.5 //是否居左

然后就右侧虚拟容器的宽度了,和左侧还有有点不一样

image-20250524160146516

前面我们已经算出了左侧距离,由于超过了一半,所以需要先减然后再乘以二

.tooltip[data-left="false"]::before{
  /*...*/
  width: calc( (var(--w) - var(--x)) * 2);
  max-width: var(--w);
}

其实这里还是有个小问题的,当气泡文字比较长时,仍然是朝右突破了边界,如下所示

image-20250524160531721

这是因为默认的语言流向造成的(从左往右),解决这个问题也非常简单,仅需改变语言方向就可以了,要用到direction:rtl,如下

.tooltip[data-left="false"]::before{
  /*...*/
  width: calc( (var(--w) - var(--x)) * 2);
  max-width: var(--w);
  direction: rtl;
}

这样就完美了

image-20250524160856055

现在来看一下所有边界情况的演示

Kapture 2025-05-24 at 16.10.06

你也可以访问在线demo真实体验:codepen.io/xboxyan/pen…

如果你是 vue3 项目,可以直接用这段封装好的组件(其实没几行代码,大部分自适应都是CSS完成的)

<!-- 极度自适应的tooltips -->
<script setup lang="ts">
const props = defineProps({
  text: String,
  gap: {
    type: Number,
    default: 12,
  },
})

const show = ref(false)
const pos = reactive({
  x: 0,
  w: 0,
  top: 0,
  gap: 0,
  isLeft: true,
})
const click = (ev: MouseEvent) => {
  // console.log()
  // if (ev.target) {
  //   ev.stopPropagation()
  // }
  const target = ev.target as Element | null
  console.log('xxxxxxxxxxx', target)
  if (target) {
    const { x, y, width } = target.getBoundingClientRect()
    pos.top = y + window.scrollY
    pos.gap = props.gap
    pos.x = x + width / 2 - props.gap
    pos.w = window.innerWidth - props.gap * 2
    show.value = true
  }
}

const wrap = ref<HTMLElement>()

document.body.addEventListener('touchstart', (ev) => {
  // 没有点击当前触发对象就隐藏tooltips
  if (!(wrap.value && ev.target && wrap.value.contains(ev.target as Node))) {
    show.value = false
  }
})
</script>

<template>
  <span class="wrap" ref="wrap" @click="click">
    <slot></slot>
  </span>
  <Teleport to="body">
    <div
      class="tooltip"
      v-show="show"
      :data-title="text"
      :data-left="pos.x / pos.w < 0.5"
      :style="{
        '--x': pos.x + 'px',
        '--top': pos.top + 'px',
        '--gap': pos.gap + 'px',
        '--w': pos.w + 'px',
      }"
    ></div>
  </Teleport>
</template>
<style>
.wrap {
  display: contents;
}
.tooltip {
  position: absolute;
  top: var(--top);
  text-align: center;
  pointer-events: none;
}
.tooltip[data-left='true'] {
  width: calc(var(--x) * 2);
  left: var(--gap);
}
.tooltip[data-left='false'] {
  width: calc((var(--w) - var(--x)) * 2);
  right: var(--gap);
  direction: rtl;
}

.tooltip::before {
  content: attr(data-title);
  display: inline-block;
  color: #fff;
  background-color: #191919;
  padding: 0.5em 0.8em;
  border-radius: 8px;
  transform: translateY(calc(-100% - 0.5em));
  width: max-content;
  max-width: var(--w);
  box-sizing: border-box;
  text-align: left;
}
.tooltip::after {
  content: '';
  position: absolute;
  width: 1.2em;
  height: 0.6em;
  background: #000;
  clip-path: polygon(0 0, 100% 0, 50% 100%);
  top: 0;
  left: 0;
  right: 0;
  margin: 0 auto;
  transform: translateY(calc(-100% - 0.2em));
}
</style>

五、推荐一个开源库

其实市面上有一个库可以完成类似的交互,叫做 float-ui

image-20250817104551464转存失败,建议直接上传图片文件

这个是专门做popover这类交互的,其中有一个shift属性,可以做这种跟随效果

image-20250817104816034

不过对于大部分情况,引入一个单独的库还是成本偏大,建议还是纯原生实现。

这样一个极度自适应的气泡组件,你学会了吗,赶紧在项目中用起来吧~最后,如果觉得还不错,对你有帮助的话,欢迎点赞、收藏、转发 ❤❤❤

iconfont 阿里巴巴免费矢量图标库超级好用!

前言

之前我介绍过一款非常好用的前端开发图标字体库FontAwesome

但是除了它还有一款非常好用并且也是免费的图标字体库也是非常不错,并且我自己开发时也是经常在用,那就是iconfont 阿里巴巴矢量图标库 毕竟也要支持国产嘛,你说对不对!

官网地址: www.iconfont.cn/

如图

在使用之前必须先登录一下

没有账号的可以自己注册一下,用手机直接注册就OK了, 然后登录!

使用教程

下载图标字体

登录之后,我们通常在菜单栏中选择素材库里面的图标库 根据需求自己选择!

如图

然后根据自己的需求找一组自己觉得合适的图标,这里都有很多作者自己设计的图标字体

单色图标彩色图标

如图

这里我们一般采用的是单色图标, 因为可以根据自己的需求修改颜色,彩色图标就固定好了的!

当我们选择好了一组之后,点击进去,然后鼠标放到某个图标字体上之后,会出现三个选项按钮

如图

具体意思如下:

  1. 加入购物车
  2. 收藏
  3. 直接下载

其中这里的直接下载就是直接把这个图标当成文件图片的形式下载到本地进行使用, 你也可以根据需求调整颜色和图标格式, 支持svg、ai、png

如图

但是这样使用太麻烦,相当于图片一样了,我们还是需要下载它的图标字体格式

所以我们先要把想要用的图标添加到购物车

如图

然后点击右上角的购物车小图标

如图

侧边栏会弹出一个购物车清单页面,我们选择的图标字体就在这里

因为阿里巴巴矢量图标库它这里是以项目为一个单位,所以我们添加的图标要打包成一个项目给我们使用!

所以这里就直接点击添加至项目

如图

然后自定义新建一个项目名称, 建议用英文

如图

接着会自动跳转到你自己账号的项目管理页面, 你所新建的项目和添加到项目中的图标字体都在这里!

如图

我们直接点击下载到本地 就可以得到一个zip压缩文件,至此图标的下载完成了!

使用本地图标字体

当我们下载好之后,解压,并且重命名一个你自己比较好记忆的名称!

然后你会得到一堆文件

如图

其实到这里,就跟我们之前使用FontAwesome是一样的道理

我们只需要把这些文件拷贝到我们项目文件夹下就可以了!

如图

关于具体如何使用到我们的html页面中, 在打包解压出来的文件中有一个叫demo_index.html的文件

你可以打开它,里面全部都是你刚刚所添加的图标字体和具体的使用方式

并且它这里提供了三种使用方式:Unicode实体编码方式、FontClass类名称调用、Symbol

如图

这里我就以Unicode实体编码方式、FontClass类名称调用方式演示一下

Unicode实体编码方式调用

Unicode是字体在网页端最原始的应用方式,特点如下:

  • 支持按字体的方式去动态调整图标大小,颜色等等。
  • 默认情况下不支持多色,直接添加多色图标会自动去色。

我们可以使用CSS中的@font-face在页面上引入图标字体

例如

@font-face {
  font-family: 'iconfont';
  src: url('iconfont.woff2?t=1706238093360') format('woff2'),
       url('iconfont.woff?t=1706238093360') format('woff'),
       url('iconfont.ttf?t=1706238093360') format('truetype');
}

也可以直接把iconfont.css文件通过link标签引入到我们的页面中

<link rel="stylesheet" type="text/css" href="iconfont/iconfont.css">

然后我们就可以使用了

调用方式

<span class="iconfont">Unicode实体编码</span>

这里在标签中一定要加上classiconfont才有效果

至于Unicode实体编码名称你可以在刚刚的案例文档中招到

举个栗子

<style type="text/css">
    #content {
        width: 300px;
        border: 1px dotted red;
        padding: 10px;
        margin: 100px auto;
        text-align: center;
    }
</style>


<div id="content">
    <span class="iconfont">&#xe614;</span>
    <span class="iconfont">&#xe615;</span>
    <span class="iconfont">&#xe616;</span>
    <span class="iconfont">&#xe617;</span>
    <span class="iconfont">&#xe618;</span>
    <span class="iconfont">&#xe619;</span>
</div>

效果如下

怎么样,是不是很简单,你再也不用担心图标的问题了!

FontClass类名称调用

FontClass类名称调用方式其实是 Unicode 使用方式的一种变种, 主要是解决Unicode书写不直观,语意不明确的问题

Unicode使用方式相比,具有如下特点:

  • 相比于Unicode语意明确,书写更直观,可以很容易分辨这个icon代表什么意思!
  • 因为使用 class 来定义图标,所以当要替换图标时,只需要修改 class 里面的 Unicode 引用。

其实如果你仔细打开观察一下iconfont.css这个源码文件,你就会知道,其实里面就是对Unicode的有种封装

如图

调用方式

<span class="iconfont icon-xxx"></span>

在文档页面中挑选相应图标并获取类名,应用页面元素就可以了!

举个栗子

<style type="text/css">

    #content {
        width: 300px;
        border: 1px dotted red;
        padding: 10px;
        margin: 100px auto;
        text-align: center;
    }


    #content>.icon-shujuzhanshi{
        color: yellow;
        font-size: 12px;
    }

    #content>.icon-xiangouhuodong{
        color: pink;
        font-size: 14px;
    }

    #content>.icon-pingfen{
        color: yellowgreen;
        font-size: 16px;
    }

    #content>.icon-dianpukanbanmoren{
        color: blue;
        font-size: 18px;
    }

    #content>.icon-youhuiquan{
        color: green;
        font-size: 20px;
    }

    #content>.icon-XyuanhuodongD{
        color: red;
        font-size: 22px;
    }

</style>

<div id="content">
    <span class="iconfont icon-shujuzhanshi"></span>
    <span class="iconfont icon-xiangouhuodong"></span>
    <span class="iconfont icon-pingfen"></span>
    <span class="iconfont icon-dianpukanbanmoren"></span>
    <span class="iconfont icon-youhuiquan"></span>
    <span class="iconfont icon-XyuanhuodongD"></span>
</div>

效果跟刚刚是一模一样!

并且你也可以通过CSS去自定义他们的颜色和大小,可以说非常方便!

如下

最后

总的来说iconfont 阿里巴巴矢量图标库还是很不错的,但是缺点可能就是版权问题,如果你是学习那应该没什么问题,但是如果是商用,那么最好在使用这些图标之前先咨询一下作者,以免版权纠纷!

这一点我个人感觉确实是没有FontAwesome做得好,搞得不清不楚的真麻烦! 嘿嘿嘿~~

事件委托的深层逻辑:当冒泡不够时⁉️

前言

在项目不断扩大之时,管理用户交互变的越来越重要,为每个交互元素附加一个事件监听器是一种糟糕的做法,因为它会导致代码混乱、内存消耗增加以及性能瓶颈。这时,事件委托就派上用场了。

认识dom事件传播

三个阶段

当事件在 DOM 元素上触发时,它不会简单地到达目标并停止。相反,它会经历以下阶段:

  1. 捕获阶段旅程从window级别开始,沿着 DOM 树向下移动,经过每个祖先元素,直到到达目标的父级。带有(中的第三个参数)的事件监听器在此触发useCapture = true``addEventListener
  2. 目标阶段在此阶段,事件到达预期的目标元素。所有直接附加到此元素的监听器都会被触发
  3. 冒泡阶段命中目标后,事件会沿着 DOM 向上“冒泡”,从目标的父元素到祖父元素,依此类推,直到到达目标window。默认情况下,大多数事件监听器都在此阶段运行

事件在dom树中流动过程

< div id = "grandparent" >
< div id = "parent" >
< button id = "child" >点击我</ button > 
</ div > 
</ div > 

如果您单击,则事件流程如下:<button id="child"> click

  1. 捕获 -window -> document -> <html> -> <body> -> <div id="grandparent"> -> <div id="parent">
  2. 目标<button id="child">
  3. 冒泡 - <button id="child"> -> <div id="parent"> -> <div id="grandparent"> -> <body> -> <html> ->document -> window

什么是事件委托

事件委托是一种将事件监听器添加到多个子元素的父元素上,而不是分别添加到每个子元素上的方法。当子元素上发生事件时,它会触发父元素上的监听器,父元素会检查是哪个子元素触发了该事件。

假设一个<ul>包含<li>以下项目的简单列表:

< ul id = "myList" > 
< li >项目 1 </ li > 
< li >项目 2 </ li >
< li >项目 3 </ li >
< li >项目 4 </ li > 
</ ul > 
  

而不是为每个添加一个点击监听器<li>

const listItems = document . querySelectorAll ( '#myList li' ); 
listItems . forEach ( item => { 
  item . addEventListener ( 'click' , ( event ) => { console . log ( `点击于: $ { event . target . textContent } ` );
  });
 });           

通过事件委托,你可以将一个监听器附加到<ul>父级:

onst myList =文档. getElementById ( 'myList' ); 

myList . addEventListener ( 'click' , ( event ) => { 
// 检查点击的元素是否为 <li>
if ( event . target . tagName === 'LI' ) {
console . log ( ` Clicked on : $ { event . target . textContent } ` );
} 
});   
  

在此示例中,当<li>点击任意一个时,click事件都会冒泡到。然后,myList上的单个事件监听器会检查是否是触发了该事件,并采取相应的措施:myList``event.target.tagName``<li>

为什么事件委托如此重要

  • 无需添加数百或数千个监听器,只需几个父容器就足够了,从而大大减少内存占用
  • 更少的监听器可以提高浏览器整体系统内存的使用率,并减少 JavaScript 引擎在事件管理和调度方面的工作量
  • 它支持动态创建元素,这非常实用。假设在页面加载后(例如,在 API 调用后)<li>添加了新元素,监听器仍然有效。无需重新连接监听器。#myList``#myList

事件委托中常见的误区

event.target vs event.currentTarget
  • event.target 是触发事件的特定元素。
  • event.currentTarget 是事件监听器实际附加到的元素。
stopPropagation ()和stopImmediatePropagation () 
  • event.stopPropagation() – 此方法仅允许事件停止沿 DOM 树向上或向下冒泡或捕获。如果在子元素的事件处理程序中执行此方法,则其祖先元素上的任何委托监听器都将无法访问该事件
  • event.stopImmediatePropagation() 这不stopPropagation()的复制粘贴。它的相似之处仅限于添加了这个效果:它阻止进一步的事件传播,并阻止绑定到同一元素的任何其他监听器被执行。

在某些情况下,它们会破坏委托处理程序,例如:子元素的事件处理程序调用stopPropagation将导致位于 DOM 层次结构中更高层级的任何委托监听器的功能失效。委托监听器将无法接收事件。这对于分析、集中式 UI 逻辑或可访问的自定义控件功能尤其麻烦。

非冒泡事件

最突出的非冒泡事件包括:

  • focus– 当元素获得焦点时触发
  • blur– 当元素失去焦点时触发
  • mouseenter– 当指针进入元素时触发
  • mouseleave– 当指针离开元素时触发

为什么它们不起泡

由于浏览器的工作方式以及过去的兼容性问题,通常无法触发此类事件。focusblur旨在在获得或失去焦点的特定元素上触发,因此不存在冒泡。mouseentermouseleavemouseover 和 mouseout配对(它们会产生冒泡);但是与mouseover 和 mouseout不同,mouseenter 和 mouseleave仅在指针位于元素上(而不是其子元素上)时触发。

对于非冒泡事件只能通过自定义冒泡事件来替代

总结

事件委托通过将单个监听器附加到父元素来简化事件处理。当子元素触发事件时,它会向上冒泡到父元素,从而减少内存占用并简化代码。

这种技术在管理大量相似元素(例如列表项或按钮)时非常有效,尤其是在它们动态生成的情况下。父级监听器无需额外配置即可处理新添加元素的事件。

并非所有事件都会冒泡 focusblurmouseleave 等是例外。对于这些事件,可以用 focusinfocusout 或自定义冒泡事件等替代方法。

🐙 Git 从入门到面试能吹的那些事

“会用 Git 不稀奇,能讲明白才值钱。”
本文让你从 git add . 的机械工人,变成能聊底层原理 + 面试加分的 Git 社交达人。


1. Git 是什么?

先来个官方说法:

Git 是一个分布式版本控制系统,用来记录代码变更历史,方便多人协作。

翻译成人话:

  • 它是代码界的时光机
  • 支持你随时穿越回过去(reflog 就像游戏存档)
  • 多人协作时,它就像一群厨子一起炒菜,每个人有自己的灶台,最后再把菜端到一张桌上。

2. Git 的三大区域

  • 工作区(Working Directory) :你正在写的代码文件。
  • 暂存区(Staging Area) :已经打包好,等着快递的改动。
  • 本地仓库(Local Repository) :正式存档的历史版本。

命令速记:

git add .       # 把工作区改动送到暂存区
git commit -m "fix: 修复登录 Bug"  # 把暂存区的改动送进历史

3. 常用 Git 命令(带脑洞解释)

命令 作用 脑洞版记忆
git status 看当前状态 “摸一摸脉搏”
git log 看历史记录 “翻家谱”
git diff 看改了啥 “照镜子对比一下”
git branch 看分支 “看看我开了几条平行世界”
git switch / git checkout 切换分支 “从一个世界跳到另一个世界”
git merge 合并分支 “两个世界融合”
git rebase 变基 “时空线性整理”

4. 分支:Git 的平行宇宙

  • 主分支(main/master) :上线版本的世界线。
  • 功能分支(feature/xxx) :新功能试验田。
  • 修复分支(hotfix/xxx) :紧急修 bug 的世界。
# 创建并切换
git switch -c feature/login

# 合并回主分支
git switch main
git merge feature/login

小贴士

  • 合并用 merge 安全可靠
  • 想要历史好看,可以 rebase(别在别人用的分支上乱 rebase)

5. 远程协作的日常

git clone <url>         # 拿到别人的代码副本
git fetch               # 拉取最新改动(不影响你当前文件)
git pull                # 拉取 + 合并(或 rebase)
git push origin main    # 推送你的改动

脑补场景:

  • fetch:去看快递柜里有什么新快递,但先不取
  • pull:看完直接取回家
  • push:你把自己的菜送到团队大锅里

6. 史诗级救命技能

  1. 撤销最近一次提交(保留改动)

    git reset --soft HEAD~1
    
  2. 回到某个提交

    git reset --hard <commit-id>
    
  3. 找回“丢失”的提交

    git reflog
    git reset --hard <reflog-id>
    
  4. 挑一个提交到当前分支

    git cherry-pick <commit-id>
    

记住reset --hard 像是核弹,一定确认无误再按。


7. 面试常考 Git 题

Q1: Git pull 和 Git fetch 有什么区别?

  • fetch:只下载远程最新记录,本地不动。
  • pull:相当于 fetch + merge(或 rebase)。
    面试加分:有时先 fetch 再手动合并更安全。

Q2: merge 和 rebase 的区别?

  • merge:保留分支的合并历史,可能有多叉结构。
  • rebase:将提交“搬到”目标分支顶部,历史线性更清爽。
    加分点:团队协作时,在共享分支用 merge,自己分支可以 rebase 保持整洁。

Q3: 如何撤销已经 push 上去的错误提交?

  • 如果要保留历史:用 git revert 生成一个反向提交。
  • 如果可以改历史(风险大):用 git reset --hard + git push --force-with-lease,但要确保没人基于你的提交工作。

Q4: .gitignore 是干嘛的?

  • 用来指定 Git 不跟踪的文件(如 node_modules/dist/.env)。
  • 注意 .gitignore 只能忽略未被追踪的文件,已经提交过的需要用 git rm --cached 移除追踪。

Q5: Git rebase -i 有什么用?

  • -i 是交互式变基,可以合并提交(squash)、修改提交信息(reword)、删除提交(drop)、调整顺序等。
  • 面试加分:常用来清理杂乱的历史提交,让 PR 更优雅。

8. 总结 & 面试吹法

  • 会基本命令:加、提、切、合、推、拉。

  • 会救命操作:reset、reflog、stash、cherry-pick。

  • 理解原理:三大区域 + 分支模型。

  • 面试吹点:

    1. 团队分支策略(Git Flow / GitHub Flow)
    2. 规范化提交(Conventional Commits)
    3. 在 CI/CD 流程中结合 Git Hooks 提升质量

9. 送你一份 Git 冷笑话

面试官:你会 Git 吗?
我:会啊,我是 Git 大师。
面试官:那帮我 reset 一下刚才问的问题。
我:git reset --hard
面试官:……你回家等通知吧。

鸿蒙音频编码

【HarmonyOS 音频编码开发速览】

文档地址:developer.huawei.com/consumer/cn…

  1. 能力总览

    • 将任意来源的 PCM 数据编码为 AAC、FLAC、MP3、G.711μ、OPUS、AMR-NB/WB 等格式。

    • 典型使用场景:录音后封装、PCM 编辑后导出文件。

    • AAC 默认使用 VBR,码率可能与设定值存在偏差。

  2. 核心流程(12 步)

    1. 引入头文件。

    2. 创建编码器(OH_AVCodec):可按 MIME 或编解码器名称创建。

    3. 注册回调:

      • onError、onNeedInputBuffer、onNewOutputBuffer(onStreamChanged 暂不支持)。
    4. 配置参数:

      必须项:采样率、声道数、采样格式;

      可/必须项:码率、声道布局、ADTS、Profile 等,各格式差异见表。

    5. Prepare → 6) Start → 7) 逐帧输入 PCM(需按格式计算单次样点数) →

    6. 取出码流 → 9) FreeOutputBuffer →

    10)(可选)Flush / Reset → 11) Stop → 12) Destroy。

  3. 关键参数表

    • 采样率/声道数范围:AAC-LC 8–96 kHz、1–8 ch;FLAC 8–96 kHz、1–8 ch;MP3 8–48 kHz、1–2 ch;OPUS 8–48 kHz、1–2 ch;AMR-NB 8 kHz/1 ch,AMR-WB 16 kHz/1 ch。

    • 单次输入样点数:AAC-LC=1024;HE-AAC=2048;FLAC 按采样率查表(如 44.1 kHz→4608)。

  4. 开发注意

    • 必须按顺序调用 API,否则可能异常。

    • 最后一帧需置 AVCODEC_BUFFER_FLAGS_EOS。

    • 调用 Flush/Reset/Stop 后需重新 Start 并可能重新输入数据。

以下 Mermaid 流程图对应「最开始回答」中的 12 步开发顺序,可直接复制到 Mermaid Live Editor 渲染。

图中:

  • 实线框为「必选步骤」
  • 虚线框为「可选步骤」
  • 箭头旁文字为调用接口名
%% HarmonyOS 音频编码 12 步调用流程(对应官方文档顺序)
flowchart TD
    A([开始]) --> B[1. 添加头文件\n#include]
    B --> C[2. 创建编码器\nOH_AudioCodec_CreateByMime / CreateByName]
    C --> D[3. 注册回调\nOH_AudioCodec_RegisterCallback]
    D --> E[4. 配置参数\nOH_AudioCodec_Configure]
    E --> F[5. 就绪\nOH_AudioCodec_Prepare]
    F --> G[6. 启动\nOH_AudioCodec_Start]
    G --> H[7. 循环输入 PCM\nOH_AudioCodec_PushInputBuffer]
    H --> I[8. 取出码流\nOH_AudioCodec_FreeOutputBuffer]

    I --> J{是否继续?}
    J -->|是| H
    J -->|否| K[11. 停止\nOH_AudioCodec_Stop]

    %% 可选分支
    H -.->|9. 刷新队列| L[OH_AudioCodec_Flush]
    L -.-> G
    K -.->|10. 重置| M[OH_AudioCodec_Reset]
    M -.-> E
    K --> N[12. 销毁\nOH_AudioCodec_Destroy]

    N --> Z([结束])

下面给出

  1. 用 Mermaid 绘制的「音频编码状态/调用流程图」
  2. 一份可直接在 DevEco Studio 4.0(API 10+)里跑的 C++ 最小完整示例(CMake 工程),演示把 44.1 kHz/16-bit/立体声 PCM 编码为 AAC-LC(ADTS)并写入本地文件。

  1. Mermaid 图
%% 音频编码器状态机 + 调用时序(HarmonyOS AVCodec)
stateDiagram-v2
    [*] --> Init: OH_AudioCodec_CreateByMime(...)
    Init --> Configured: Configure(params)
    Configured --> Prepared: Prepare()
    Prepared --> Running: Start()

    Running --> Running: PushInputBuffer() ➜ FreeOutputBuffer()
    Running --> Flushed: Flush()        %% 可选
    Flushed --> Running: Start()

    Running --> Stopped: Stop()
    Stopped --> Prepared: Start()      %% 可再次启动
    Stopped --> Reset: Reset()         %% 可选
    Reset --> Configured: Configure()

    Running --> [*]: Destroy()
    Stopped --> [*]: Destroy()
    Reset --> [*]: Destroy()

  1. 最小可编译示例

目录结构

AudioEncoderDemo/
 ├─ entry/
 │   ├─ src/main/cpp/
 │   │   ├─ native_audio_encoder.cpp (下面代码)
 │   │   └─ CMakeLists.txt
 │   └─ src/main/resources/rawfile/
 │       └─ test_44k_16bit_2ch.pcm   (原始 PCM, 任意长度)

2.1 CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
project(audio_encoder_demo)

set(CMAKE_CXX_STANDARD 17)

# HarmonyOS NDK
find_library(hilog-lib hilog_ndk.z)
find_library(native-buffer-lib native_buffer)
find_library(avcodec-lib libavcodec_base.z)

add_library(entry SHARED native_audio_encoder.cpp)
target_link_libraries(entry
        ${hilog-lib}
        ${native-buffer-lib}
        ${avcodec-lib}
        ohaudio
)

2.2 native_audio_encoder.cpp

#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <fcntl.h>

#include "napi/native_api.h"
#include "multimedia/audio_codec/audio_codec_api.h"
#include "hilog/log.h"

#undef LOG_DOMAIN
#undef LOG_TAG
#define LOG_DOMAIN 0x0001
#define LOG_TAG "AudioEncoder"

static const int SAMPLE_RATE   = 44100;
static const int CHANNEL_COUNT = 2;
static const int BIT_RATE      = 128000; // 128 kbps
static const int PCM_FRAME_SAMPLES = 1024; // AAC-LC 每帧 1024 样点
static const int PCM_FRAME_BYTES =
        PCM_FRAME_SAMPLES * CHANNEL_COUNT * sizeof(int16_t);

static OH_AVCodec *g_encoder = nullptr;
static int32_t g_fd_out = -1;          // 输出 ADTS 文件描述符
static bool g_input_done = false;

/* ---------- 工具 ---------- */
static void write_adts_header(uint8_t *buf, int frameLen) {
    const int profile = 2;     // AAC-LC
    const int freqIdx = 4;     // 44.1 kHz
    const int chanCfg = 2;     // 2 ch

    int fullLen = frameLen + 7;
    buf[0] = 0xFF;
    buf[1] = 0xF1;
    buf[2] = (profile - 1) << 6 | (freqIdx << 2) | (chanCfg >> 2);
    buf[3] = ((chanCfg & 3) << 6) | (fullLen >> 11);
    buf[4] = (fullLen >> 3) & 0xFF;
    buf[5] = ((fullLen & 7) << 5) | 0x1F;
    buf[6] = 0xFC;
}

/* ---------- 回调 ---------- */
static void OnError(OH_AVCodec *codec, int32_t errorCode, void *userData) {
    OH_LOG_ERROR(LOG_APP, "Encoder error %{public}d", errorCode);
}

static void OnOutputFormatChanged(OH_AVCodec *codec, OH_AVFormat *format, void *userData) {
    // AAC 暂未支持
}

static void OnNeedInputBuffer(OH_AVCodec *codec,
                              uint32_t index,
                              OH_AVBuffer *buffer,
                              void *userData) {
    if (g_input_done) return;

    uint8_t *addr = OH_AVBuffer_GetAddr(buffer);
    int32_t capacity = OH_AVBuffer_GetCapacity(buffer);
    ssize_t bytes = read(0, addr, capacity); // 从 stdin 读 PCM
    if (bytes <= 0) {
        OH_AudioCodec_Stop(codec);
        g_input_done = true;
        return;
    }
    OH_AVCodec_PushInputBuffer(codec, index);
}

static void OnNewOutputBuffer(OH_AVCodec *codec,
                              uint32_t index,
                              OH_AVBuffer *buffer,
                              OH_AVFormat *attr,
                              void *userData) {
    uint8_t *data = OH_AVBuffer_GetAddr(buffer);
    int32_t size  = OH_AVBuffer_GetSize(buffer);

    uint8_t adts[7];
    write_adts_header(adts, size);
    write(g_fd_out, adts, 7);
    write(g_fd_out, data, size);

    OH_AudioCodec_FreeOutputBuffer(codec, index);
}

static OH_AVCodecCallback g_callback = {
        .onError = OnError,
        .onStreamChanged = OnOutputFormatChanged,
        .onNeedInputBuffer = OnNeedInputBuffer,
        .onNewOutputBuffer = OnNewOutputBuffer,
};

/* ---------- NAPI 接口 ---------- */
static napi_value EncodeFile(napi_env env, napi_callback_info info) {
    g_encoder = OH_AudioCodec_CreateByMime(OH_AVCODEC_MIMETYPE_AUDIO_AAC, true);
    if (!g_encoder) {
        OH_LOG_ERROR(LOG_APP, "Create encoder failed");
        return nullptr;
    }

    OH_AVFormat *fmt = OH_AVFormat_Create();
    OH_AVFormat_SetIntValue(fmt, OH_MD_KEY_AUD_SAMPLE_RATE, SAMPLE_RATE);
    OH_AVFormat_SetIntValue(fmt, OH_MD_KEY_AUD_CHANNEL_COUNT, CHANNEL_COUNT);
    OH_AVFormat_SetIntValue(fmt, OH_MD_KEY_AUDIO_SAMPLE_FORMAT, SAMPLE_S16LE);
    OH_AVFormat_SetLongValue(fmt, OH_MD_KEY_BITRATE, BIT_RATE);
    OH_AVFormat_SetIntValue(fmt, OH_MD_KEY_AAC_IS_ADTS, 1); // 输出 ADTS

    OH_AudioCodec_RegisterCallback(g_encoder, &g_callback, nullptr);
    OH_AudioCodec_Configure(g_encoder, fmt);
    OH_AVFormat_Destroy(fmt);

    OH_AudioCodec_Prepare(g_encoder);

    g_fd_out = open("/data/storage/el2/base/haps/entry/files/out.aac",
                    O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (g_fd_out < 0) {
        OH_LOG_ERROR(LOG_APP, "open output failed");
        return nullptr;
    }

    OH_AudioCodec_Start(g_encoder);
    while (!g_input_done) {
        usleep(10 * 1000); // 简单阻塞等待
    }

    OH_AudioCodec_Stop(g_encoder);
    OH_AudioCodec_Destroy(g_encoder);
    close(g_fd_out);
    OH_LOG_INFO(LOG_APP, "encode done");
    return nullptr;
}

/* ---------- 注册 NAPI ---------- */
EXTERN_C_START
static napi_value Init(napi_env env, napi_value exports) {
    napi_property_descriptor desc[] = {
        {"encodeFile", nullptr, EncodeFile, nullptr, nullptr, nullptr, napi_default, nullptr}
    };
    napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
    return exports;
}
EXTERN_C_END

static napi_module demoModule = {
    .nm_version = 1,
    .nm_flags = 0,
    .nm_filename = nullptr,
    .nm_register_func = Init,
    .nm_modname = "entry",
    .nm_priv = nullptr,
    .reserved = {0},
};

extern "C" __attribute__((constructor)) void RegisterEntryModule() {
    napi_module_register(&demoModule);
}

2.3 使用方式

  1. test_44k_16bit_2ch.pcm 推送到 /data/storage/el2/base/haps/entry/files/in.pcm
  2. ArkTS 侧调用:
import entry from '@ohos.entry';
entry.encodeFile();
  1. 运行后可在同目录得到 out.aac(ADTS 封装,可直接播放验证)。

至此,完整的 Mermaid 流程图 + 可跑代码实现已给出,可直接集成到 HarmonyOS 工程中。

鸿蒙音频解码

以下是对鸿蒙音频解码模块的总结,包含核心流程、API设计与使用要点,以及Mermaid示意图:

一、鸿蒙音频解码核心流程

以下内容基于官方文档,用一张 Mermaid 时序图 把「鸿蒙音频解码(AVCodec Kit)」的核心流程、API 设计与使用要点一次性梳理出来。可直接复制到 mermaid.live 预览。

仅保留开发者最常调用的接口与顺序,省略了可选项与异常分支,方便快速上手。

%% 鸿蒙音频解码(AVCodec Kit)API 调用时序
sequenceDiagram
    participant App as 应用层
    participant Codec as OH_AVCodec* 解码器实例
    participant PCM as 音频输出(PCM)

    Note over App,PCM: 1. 准备阶段
    App ->> App: 1.1 添加头文件 & CMake 链接 libnative_media_codecbase.so
    App ->> Codec: 1.2 OH_AudioCodec_CreateByMime("audio/mp4a-latm") 或通过 codecName
    App ->> Codec: 1.3 OH_AudioCodec_RegisterCallback(cb) 注册 4 个回调
    App ->> Codec: 1.4 OH_AudioCodec_Configure(cfg) 配置采样率/声道/格式
    App ->> Codec: 1.5 OH_AudioCodec_Prepare()
    App ->> Codec: 1.6 OH_AudioCodec_Start()

    Note over App,PCM: 2. 运行阶段
    loop 解码循环
        Codec -->> App: OH_AVCodecOnNeedInputBuffer(idx, buf)
        App ->> Codec: OH_AudioCodec_PushInputBuffer(idx, buf, size, flags)
        Codec -->> App: OH_AVCodecOnNewOutputBuffer(idx, info, buf)
        App ->> PCM: 取走 PCM 数据
        App ->> Codec: OH_AudioCodec_FreeOutputBuffer(idx)
    end

    Note over App,PCM: 3. 结束阶段
    App ->> Codec: OH_AudioCodec_Stop()
    App ->> Codec: OH_AudioCodec_Destroy()
  1. 支持的输入格式

    AAC、FLAC、MP3、Vorbis、G711、AMR、APE、Opus、Audio Vivid 等,具体采样率/声道范围见文档表格。

  2. PCM 输出格式

    通过 OH_MD_KEY_AUDIO_SAMPLE_FORMAT 可选 SAMPLE_S16LESAMPLE_F32LE,默认 S16LE。

  3. 线程模型

    所有回调都在内部工作线程,请勿阻塞;应用需要保证线程安全。

  4. DRM 解密

    若内容加密,需在 Prepare 前调用 OH_AudioCodec_SetDecryptionConfig,并在 PushInputBuffer 时把 cencInfo 通过 OH_AVCencInfo_SetAVBuffer 写入。

  5. EOS 处理

    输入最后一包数据时把 flags 设为 AVCODEC_BUFFER_FLAGS_EOS,解码器会在回调中同样给出 EOS,应用即可进入停止流程。

  6. CMake 链接

   target_link_libraries(xxx
       native_media_codecbase.so
   )

一句话总结
「鸿蒙音频解码」遵循 创建→配置→启动→循环喂数据→取 PCM→停止销毁 的极简五步模型;所有细节都围绕 OH_AVCodec* 句柄与 4 个回调完成,无需额外线程或同步,直接嵌入现有播放 / 编辑管线即可。

audio-decode.png

二、API设计与使用要点

1. 核心API组件

组件类型 关键API 作用说明
解码器实例 OH_AudioCodec_CreateByMime() 通过MIME类型创建解码器
OH_AudioCodec_CreateByName() 通过编解码器名称创建
回调注册 OH_AudioCodec_RegisterCallback() 注册错误/数据流/缓冲区回调
参数配置 OH_AudioCodec_Configure() 设置采样率/声道数等参数
运行时控制 OH_AudioCodec_Start() 启动解码器
OH_AudioCodec_Flush() 刷新缓冲区(可选)
OH_AudioCodec_Reset() 重置解码器(可选)
数据操作 OH_AudioCodec_PushInputBuffer() 送入压缩数据
OH_AudioCodec_FreeOutputBuffer() 释放PCM输出数据
资源管理 OH_AudioCodec_Destroy() 销毁解码器实例

2. 关键开发步骤

  1. 创建实例

    // 通过MIME创建
    OH_AVCodec *decoder = OH_AudioCodec_CreateByMime(OH_AVCODEC_MIMETYPE_AUDIO_AAC, false);
    
    // 或通过名称创建
    const char *name = OH_AVCapability_GetName(capability);
    OH_AVCodec *decoder = OH_AudioCodec_CreateByName(name);
    
  2. 注册回调

    OH_AVCodecCallback cb = {OnError, OnOutputFormatChanged, 
                            OnInputBufferAvailable, OnOutputBufferAvailable};
    OH_AudioCodec_RegisterCallback(decoder, cb, userData);
    
  3. 配置参数

    OH_AVFormat *format = OH_AVFormat_Create();
    OH_AVFormat_SetIntValue(format, OH_MD_KEY_AUD_SAMPLE_RATE, 44100);
    OH_AVFormat_SetIntValue(format, OH_MD_KEY_AUD_CHANNEL_COUNT, 2);
    OH_AudioCodec_Configure(decoder, format);
    
  4. **数据流处理

    • 输入:在OnInputBufferAvailable回调中填充压缩数据

      OH_AVBuffer_SetBufferAttr(buffer, &attr); // 设置PTS/flag等属性
      OH_AudioCodec_PushInputBuffer(decoder, index);
      
    • 输出:在OnOutputBufferAvailable中获取PCM数据

      OH_AVBuffer_GetBufferAttr(data, &attr); // 获取解码数据属性
      OH_AudioCodec_FreeOutputBuffer(decoder, index); // 释放缓冲区
      

3. 特殊功能支持

  • DRM解密

    OH_AudioCodec_SetDecryptionConfig(decoder, session, false);
    OH_AVCencInfo_SetAVBuffer(cencInfo, buffer); // 设置加密信息
    
  • Audio Vivid元数据

    OH_AVFormat_GetBuffer(format, OH_MD_KEY_AUDIO_VIVID_METADATA, &metadata, &metaSize);
    

三、重要注意事项

  1. 调用顺序强制要求

    创建 → 配置 → 准备 → 启动必须顺序执行,否则引发异常

  2. 解码格式限制

    • 支持AAC/FLAC/MP3/Vorbis等主流格式
    • 不同格式有特定参数要求(如Vorbis需ID Header)
  3. 资源释放

    OH_AudioCodec_Stop(decoder);   // 先停止
    OH_AudioCodec_Destroy(decoder); // 再销毁
    
  4. 动态库依赖

    target_link_libraries(sample 
        libnative_media_codecbase.so
        libnative_media_core.so
        libnative_media_acodec.so)
    

文档链接音频解码-音视频编解码-AVCodec Kit(音视频编解码服务)-媒体 - 华为HarmonyOS开发者

鸿蒙视频解码

%% 鸿蒙 AVCodec Kit – 视频解码全景图
%% 开发者:Kimi 2024-08-16
%% 说明:一张图读懂“如何调用 Native API 完成视频解码”

flowchart TD
    subgraph 应用侧
        A([应用]) -->|1. 创建| B[OH_VideoDecoder_CreateByMime]
        A -->|2. 注册回调| C[OH_VideoDecoder_RegisterCallback]
        A -->|3. 配置| D[OH_VideoDecoder_Configure]
        A -->|4. 设 Surface| E[OH_VideoDecoder_SetSurface]:::surface
        A -->|5. 就绪| F[OH_VideoDecoder_Prepare]
        A -->|6. 启动| G[OH_VideoDecoder_Start]
    end

    subgraph 输入线程
        H([码流线程]) -->|7. 送入| I[OH_VideoDecoder_PushInputBuffer]
        I -->|携带 SPS/PPS| J{{AnnexB 格式}}
    end

    subgraph 解码内核
        K([解码器]) -->|8. 解码| L[YUV Frame]
        K -->|事件| M>回调]
        M -->|OnNeedInputBuffer| N[继续送码流]
        M -->|OnNewOutputBuffer| O[拿到输出 buffer]
        M -->|OnStreamChanged| P[分辨率变化]
        M -->|OnError| Q[错误处理]
    end

    subgraph 输出线程
        direction TB
        R([渲染线程]) -->|9a. Surface 送显| S[OH_VideoDecoder_RenderOutputBuffer]:::surface
        R -->|9b. 丢弃帧| T[OH_VideoDecoder_FreeOutputBuffer]
        R -->|9c. Buffer 保存| U[写 YUV 文件]
        style U fill:#f9f,stroke:#333
    end

    subgraph 生命周期
        V([Flush]) -->|清缓存| W[重新 Start]
        X([Reset]) -->|回初始态| Y[重新 Configure]
        Z([Stop]) -->|暂停| G
        AA([Destroy]) -->|释放| BB([Released])
    end

    %% 状态机泳道
    subgraph 状态机
        direction LR
        I1((Initialized)) -->|Configure| C1((Configured))
        C1 -->|Prepare| P1((Prepared))
        P1 -->|Start| E1((Executing))
        E1 -->|Flush| F1((Flushed))
        E1 -->|EOS| EOS((End-of-Stream))
        E1 -->|Error| ERR((Error))
        ERR -->|Reset| I1
        ERR -->|Destroy| R1((Released))
    end

    %% 样式
    classDef surface fill:#c2e0c6,stroke:#006100
    classDef buffer  fill:#fce8b4,stroke:#8f7f00
    classDef life    fill:#d5e8d4,stroke:#82b366

核心 API 设计速查表

阶段 关键函数 说明
创建 OH_VideoDecoder_CreateByMime()/ CreateByName() 按指定格式(MIME类型)或解码器名称创建解码器实例
配置 OH_VideoDecoder_Configure() 设置视频宽高、色彩空间、低时延模式等核心参数
绑定 OH_VideoDecoder_SetSurface() 关联 OHNativeWindow(通常来自XComponent或OpenGL渲染表面)
就绪 OH_VideoDecoder_Prepare() 内部预分配编解码资源,完成初始化准备
启动 OH_VideoDecoder_Start() 启动解码器,进入运行(Running)状态
送码流 OH_VideoDecoder_PushInputBuffer() 输入AnnexB格式的码流数据,需整帧一次性送入
取帧 OH_VideoDecoder_RenderOutputBuffer() 将解码帧送入绑定的Surface进行渲染(Surface模式)
OH_VideoDecoder_FreeOutputBuffer() 释放内存或存储YUV数据(Buffer模式)
生命周期 OH_VideoDecoder_Flush() 清空缓存中的输入/输出数据
OH_VideoDecoder_Reset() 重置解码器至初始状态
OH_VideoDecoder_Stop() 暂停解码操作,保留资源配置
OH_VideoDecoder_Destroy() 彻底释放解码器实例资源

模式差异一句话

  • Surface 模式:直接送显,性能高;支持动态切换 Surface;支持 DRM 安全通路。
  • Buffer 模式:拿到共享内存 YUV,可二次处理;仅非安全通路;需手动拷贝对齐数据。

常见坑提示

  1. Flush/Reset/Stop 后需重新送 SPS/PPS。
  2. Destroy 不能在回调线程里调。
  3. Buffer 模式必须 FreeOutputBuffer,否则阻塞后续输出。
  4. 多解码器共用同一 NativeWindow 时,先释放再启动,避免画面卡死。

鸿蒙视频编码

以下为对 HarmonyOS 视频编码 Native API 的核心内容进行的结构化总结,包括API 设计与使用流程的Mermaid 图示,便于开发者快速理解与落地。


✅ 一、能力概览

能力简述 运行时参数配置动态设置帧率、码率、QP 等 随帧 QP 设置每帧可设置 QPMin/QPMax 分层编码(LTR)支持时域可分层视频编码 获取编码信息每帧可获取 QPAverage 和 MSE 变分辨率Surface 模式支持输入分辨率变化 HDR Vivid 编码API 11+,需使用 H265(HEVC)


✅ 二、输入模式对比

模式数据来源适用场景性能接口差异 SurfaceOHNativeWindow相机等实时流高使用 OH_VideoEncoder_GetSurface Buffer预分配内存文件读取等中使用 OH_VideoEncoder_PushInputBuffer


✅ 三、状态机设计(Mermaid)

stateDiagram-v2
    [*] --> Initialized: 创建/Reset
    Initialized --> Configured: Configure
    Configured --> Prepared: Prepare
    Prepared --> Executing: Start
    Executing --> Prepared: Stop
    Executing --> Flushed: Flush
    Executing --> EndOfStream: EOS
    Executing --> Error: 异常
    Error --> Initialized: Reset
    Error --> [*]: Destroy
    Executing --> [*]: Destroy

✅ 四、开发流程(Surface 模式)

sequenceDiagram
    participant App
    participant Encoder
    participant Surface
    participant File

    App->>Encoder: OH_VideoEncoder_CreateByMime
    App->>Encoder: RegisterCallback
    App->>Encoder: Configure
    App->>Encoder: GetSurface
    App->>Encoder: Prepare
    App->>Encoder: Start
    Surface-->>Encoder: 输入图像流
    Encoder-->>App: OnNewOutputBuffer
    App->>File: 写入编码数据
    App->>Encoder: FreeOutputBuffer
    App->>Encoder: NotifyEndOfStream
    App->>Encoder: Stop/Flush/Reset/Destroy

✅ 五、Buffer 模式差异点

步骤差异说明 输入数据使用 OH_VideoEncoder_PushInputBuffer 结束标志设置 AVCODEC_BUFFER_FLAGS_EOS 跨距处理需手动处理 stride 与 width 不一致的情况


✅ 六、CMake 链接库

target_link_libraries(sample PUBLIC libnative_media_codecbase.so)
target_link_libraries(sample PUBLIC libnative_media_core.so)
target_link_libraries(sample PUBLIC libnative_media_venc.so)

✅ 七、关键 API 一览

功能API 创建编码器OH_VideoEncoder_CreateByMime / CreateByName 注册回调OH_VideoEncoder_RegisterCallback 配置参数OH_VideoEncoder_Configure 获取 SurfaceOH_VideoEncoder_GetSurface 启动编码OH_VideoEncoder_Start 动态设置OH_VideoEncoder_SetParameter 通知结束OH_VideoEncoder_NotifyEndOfStream 释放输出OH_VideoEncoder_FreeOutputBuffer 销毁实例OH_VideoEncoder_Destroy


✅ 八、注意事项

  • Buffer 模式不支持 10bit 图像
  • 编码器资源有限,必须调用 Destroy 释放
  • 不能在回调中调用 Destroy
  • Flush/Reset/Stop/Destroy 会等待所有回调完成
  • 跨距不一致时需手动拷贝数据

✅ 九、建议封装结构

  • 使用 CodecBufferQueue 管理输入输出队列
  • 使用 shared_mutex 保证线程安全
  • 使用 OH_AVFormat 配置编解码参数
  • 使用 OH_AVBuffer 操作内存与属性

如需进一步落地开发,建议直接参考官方提供的示例工程与API 文档。

完整的api使用案例

下面给出一个可直接编译运行的最小完整示例(Surface 模式,H.264 硬编码)。

代码仅保留主干逻辑,省略了异常处理的细节,方便你先把流程跑通;真正工程化时再加上日志、异常检查、线程同步等即可。

────────────────────────────

  1. 目录结构
encode_demo/
 ├─ CMakeLists.txt
 └─ main.cpp

────────────────────────────
2. CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
project(encode_demo)

set(CMAKE_CXX_STANDARD 17)

# HarmonyOS 标准 NDK 环境变量已配置好
add_executable(encode_demo main.cpp)

target_link_libraries(encode_demo
    libnative_media_codecbase.so
    libnative_media_core.so
    libnative_media_venc.so
    libnative_window.so          # OHNativeWindow
)

────────────────────────────
3. main.cpp(Surface 模式完整示例)

#include <chrono>
#include <fstream>
#include <iostream>
#include <thread>

#include <condition_variable>
#include <mutex>
#include <queue>
#include <shared_mutex>

#include <multimedia/player_framework/native_avcodec_videoencoder.h>
#include <multimedia/player_framework/native_avcapability.h>
#include <multimedia/player_framework/native_avcodec_base.h>
#include <multimedia/player_framework/native_avformat.h>
#include <multimedia/player_framework/native_avbuffer.h>
#include <native_window/external_window.h>

using namespace std;

/* ---------- 1. 公共数据结构 ---------- */
struct CodecBufferInfo {
    uint32_t index;
    OH_AVBuffer *buffer;
    bool isValid;
    CodecBufferInfo(uint32_t i, OH_AVBuffer *b) : index(i), buffer(b), isValid(true) {}
};

class CodecBufferQueue {
public:
    void Enqueue(shared_ptr<CodecBufferInfo> info) {
        unique_lock<mutex> lock(mtx_);
        q_.push(info);
        cv_.notify_all();
    }
    shared_ptr<CodecBufferInfo> Dequeue(int ms = 1000) {
        unique_lock<mutex> lock(mtx_);
        cv_.wait_for(lock, chrono::milliseconds(ms), [this] { return !q_.empty(); });
        if (q_.empty()) return nullptr;
        auto ret = q_.front();
        q_.pop();
        return ret;
    }
    void Flush() {
        unique_lock<mutex> lock(mtx_);
        while (!q_.empty()) {
            q_.front()->isValid = false;
            q_.pop();
        }
    }
private:
    mutex mtx_;
    condition_variable cv_;
    queue<shared_ptr<CodecBufferInfo>> q_;
};

/* ---------- 2. 全局变量 ---------- */
OH_AVCodec *g_enc = nullptr;
OHNativeWindow *g_window = nullptr;
shared_mutex g_mtx;
CodecBufferQueue g_outQ;
bool g_run = true;

/* ---------- 3. 回调函数 ---------- */
void OnError(OH_AVCodec *, int32_t, void *) { cout << "Encoder error\n"; }
void OnStreamChanged(OH_AVCodec *, OH_AVFormat *, void *) {}
void OnNeedInputBuffer(OH_AVCodec *, uint32_t, OH_AVBuffer *, void *) {}
void OnNewOutputBuffer(OH_AVCodec *, uint32_t index, OH_AVBuffer *buf, void *) {
    g_outQ.Enqueue(make_shared<CodecBufferInfo>(index, buf));
}

/* ---------- 4. 工具函数 ---------- */
void Release() {
    unique_lock<shared_mutex> lock(g_mtx);
    if (g_enc) OH_VideoEncoder_Destroy(g_enc);
    g_enc = nullptr;
    if (g_window) OH_NativeWindow_DestroyNativeWindow(g_window);
    g_window = nullptr;
}

/* ---------- 5. 主流程 ---------- */
int main() {
    /* 5.1 创建编码器 */
    OH_AVCapability *cap =
        OH_AVCodec_GetCapabilityByCategory(OH_AVCODEC_MIMETYPE_VIDEO_AVC, true, HARDWARE);
    const char *codecName = OH_AVCapability_GetName(cap);
    g_enc = OH_VideoEncoder_CreateByName(codecName);

    /* 5.2 注册回调 */
    OH_AVCodecCallback cb{OnError, OnStreamChanged, OnNeedInputBuffer, OnNewOutputBuffer};
    OH_VideoEncoder_RegisterCallback(g_enc, cb, nullptr);

    /* 5.3 配置编码参数 */
    OH_AVFormat *fmt = OH_AVFormat_Create();
    int w = 1280, h = 720;
    OH_AVFormat_SetIntValue(fmt, OH_MD_KEY_WIDTH, w);
    OH_AVFormat_SetIntValue(fmt, OH_MD_KEY_HEIGHT, h);
    OH_AVFormat_SetIntValue(fmt, OH_MD_KEY_PIXEL_FORMAT, AV_PIXEL_FORMAT_NV12);
    OH_AVFormat_SetDoubleValue(fmt, OH_MD_KEY_FRAME_RATE, 30.0);
    OH_AVFormat_SetLongValue(fmt, OH_MD_KEY_BITRATE, 4'000'000);
    OH_AVFormat_SetIntValue(fmt, OH_MD_KEY_I_FRAME_INTERVAL, 1000);
    OH_VideoEncoder_Configure(g_enc, fmt);
    OH_AVFormat_Destroy(fmt);

    /* 5.4 获取 Surface */
    OH_VideoEncoder_GetSurface(g_enc, &g_window);

    /* 5.5 准备与启动 */
    OH_VideoEncoder_Prepare(g_enc);
    OH_VideoEncoder_Start(g_enc);

    /* 5.6 打开输出文件 */
    ofstream out("out.h264", ios::binary);

    /* 5.7 模拟相机送帧线程(此处用随机数据填充) */
    thread producer([&] {
        int bufSize = w * h * 3 / 2;
        while (g_run) {
            OHNativeWindowBuffer *nwBuf;
            int releaseFence;
            OH_NativeWindow_NativeWindowRequestBuffer(g_window, &nwBuf, &releaseFence);
            void *virAddr;
            OH_NativeWindow_NativeWindowBufferHandle handle =
                OH_NativeWindow_GetBufferHandleFromNative(nwBuf);
            OH_NativeWindow_NativeWindowBufferMap(nwBuf, &virAddr);
            memset(virAddr, 0, bufSize); // 实际应填 NV12 数据
            OH_NativeWindow_NativeWindowBufferUnmap(nwBuf);
            OH_NativeWindow_NativeWindowFlushBuffer(g_window, nwBuf, -1,
                OH_NativeWindow_FlushMode::FLUSH_MODE_SYNC);
            this_thread::sleep_for(chrono::milliseconds(33));
        }
    });

    /* 5.8 接收编码码流 */
    while (g_run) {
        auto info = g_outQ.Dequeue();
        if (!info || !info->isValid) continue;
        OH_AVCodecBufferAttr attr;
        OH_AVBuffer_GetBufferAttr(info->buffer, &attr);
        uint8_t *data = reinterpret_cast<uint8_t *>(OH_AVBuffer_GetAddr(info->buffer));
        out.write(reinterpret_cast<char *>(data), attr.size);
        OH_VideoEncoder_FreeOutputBuffer(g_enc, info->index);
    }

    /* 5.9 结束清理 */
    OH_VideoEncoder_Stop(g_enc);
    g_run = false;
    producer.join();
    Release();
    return 0;
}

────────────────────────────
4. 编译 & 运行(在 DevEco Studio 或 OHOS NDK 命令行均可)

cd encode_demo
mkdir build && cd build
cmake .. -DOHOS_STL=c++_shared -DCMAKE_TOOLCHAIN_FILE=$OHOS_NDK/build/cmake/ohos.toolchain.cmake
make -j

把生成的 encode_demo 推送到设备:

hdc file send encode_demo /data/local/tmp/
hdc shell chmod +x /data/local/tmp/encode_demo
hdc shell /data/local/tmp/encode_demo   # 运行

运行结束后,在 /data/local/tmp/out.h264 拿到编码结果,可 hdc file recv 回 PC 用 VLC/ffplay 播放验证。

────────────────────────────
5. 如何替换成自己的 YUV 数据

producer 线程里的 memset 换成:

fread(virAddr, 1, bufSize, yuvFp);

即可把本地 input.yuv 逐帧喂给 Surface,实现离线文件编码。

至此,一个可运行的完整 Surface 模式视频编码示例就完成了。

前端新手常踩的坑:方法一改全站崩

我最早写外卖下单页面的 OrderManager 时,把业务逻辑都塞成三个方法直接调:

manager.placeOrder('Pad Thai''1234')
manager.trackOrder('1234')
manager.cancelOrder('1234')

后来 PM 一句话「把 placeOrder 改成 addOrder 吧!」我就得全项目全局搜索替换,加班到凌晨两点。于是我痛定思痛,把这套逻辑拆成「命令对象」,以后改名字再也不用心惊胆战。

下面就是我现在的写法,你可以直接抄走:

1. 把订单列表和通用执行器留下

只留下公共数据 this.orders 与万能执行器 execute,其他方法全都挪走:

  class OrderManager {
  constructor() {
    this.orders = []
  }
  // 命令全部走这里
  execute(command, ...args) {
    return command.execute(this.orders, ...args)
  }
}

2. 给每个动作建一份命令对象

用一个高阶函数生成 Command,把真正的业务逻辑塞进去:

// 基础母板
class Command {
  constructor(execute) {
    this.execute = execute
  }
}

function PlaceOrderCommand(order, id) {
  return new Command(orders => {
    orders.push(id)
    return `成功下单 ${order} (${id})`
  })
}

function CancelOrderCommand(id) {
  return new Command(orders => {
    orders.splice(orders.indexOf(id), 1)   // 注意是值而不是对象,别写错条件
    return `已取消订单 ${id}`
  })
}

function TrackOrderCommand(id) {
  return new Command(() => `订单 ${id} 还有 20 分钟抵达`)
}

3. 调用姿势变成「告诉执行器你要执行哪个命令」:

const manager = new OrderManager()

manager.execute(new PlaceOrderCommand('Pad Thai''1234'))
manager.execute(new TrackOrderCommand('1234'))
manager.execute(new CancelOrderCommand('1234'))

此时如果想把 PlaceOrderCommand 改成 AddOrderCommand,只需要改这一条命令对象的函数名,其余代码丝滑零改动。

踩过的坑对比

场景 直接方法调用 命令模式改造后
方法改名 全局查找替换,漏一处就炸 只改一处函数名
要加「撤销」功能 无现成钩子,得重写 在 Command 里加 .undo 就行
队列/延时执行 自己写定时器 命令对象天然可排队

适用面

  • 小项目 CRUD 真的别上,代码变多显得啰嗦
  • 大项目 / 需撤销、队列、批量回放等功能,命令模式能让扩展像拼图一样顺滑

全面解析this-理解this指向的原理

参考资料

  • 《你不知道的JavaScript》- this全面解析

this 是什么

  • this 是一个代词,代指一个对象
  • this 提供了一种更优雅的方式来隐式的传递一个对象引用,可以让代码更加简洁易于复用

调用位置

调用位置就是函数在代码中被调用的位置(而不是声明的位置)。调用位置决定了 this 的绑定

比如下面代码:

function baz() {
// 当前调用栈是:baz
// 因此,当前调用位置是全局作用域
console.log("baz");
bar(); // <-- bar 的调用位置
}
function bar() {
// 当前调用栈是 baz -> bar
// 因此,当前调用位置在 baz 中
console.log("bar");
foo(); // <-- foo 的调用位置
}
function foo() {
// 当前调用栈是 baz -> bar -> foo // 因此,当前调用位置在 bar 中
console.log("foo");
}
baz(); // <-- baz 的调用位置

可以使用浏览器的开发工具查看调用栈




this的绑定规则

this的指向有以下四条特性/规则

  • 默认绑定
  • 隐式绑定
  • 显式绑定
  • new 绑定

默认绑定

当函数被独立调用时,函数的 this 指向 window

独立调用就是像这样:foo() 的调用

比如

function foo() {
console.log(this.a);
}
var a = 2;
foo(); // 2

如果使用严格模式(strict mode),那么全局对象将无法使用默认绑定,因此 this 会绑定到 undefined

隐式绑定

当函数引用有上下文对象 且被该对象调用时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象

function foo() {
console.log(this.a);
}
var obj = {
  a: 2,
  foo: foo
};
obj.foo(); // 2
// 这里就是隐式绑定,foo函数的this绑定到了obj上

需要注意的是,对象属性引用链中只有最顶层或者说最后一层会影响调用位置

比如

function foo() {
console.log(this.a);
}
var obj2 = {
a: 42,
foo: foo,
};
var obj1 = {
a: 2,
obj2: obj2,
};
obj1.obj2.foo(); // 42
// 相当于 obj2.foo(),因为只有最后一层会影响调用位置

隐式丢失

隐式绑定的函数可能会丢失绑定对象,也就是说它会应用默认绑定

隐式丢失的几种情况:

  1. 函数别名
function foo() {
console.log(this.a);
}
var obj = {
a: 2,
foo: foo,
};

// 注意这里的函数别名,会导致隐式绑定丢失,导致foo函数的this指向全局
var bar = obj.foo; // 函数别名!
var a = "oops, global"; // a 是全局对象的属性
bar(); // "oops, global"
  1. 函数作为参数传入,并调用时
function foo() {
console.log(this.a);
}
function doFoo(fn) {
// fn 其实引用的是 foo
fn(); // <-- 调用位置!
}
var obj = {
a: 2,
foo: foo,
};
var a = "oops, global"; // a 是全局对象的属性
doFoo(obj.foo); // "oops, global"

参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上一个例子一样

显式绑定

  • fn.call(obj, x, x, ...) 将fn函数的this指向obj,并调用,call的剩余参数是fn需要的参数
function foo(aram) {
console.log("foo", param.a); // foo 1

function bar(x, y) {
console.log("bar", this.a, x, y); // bar 1 2 3
}
bar.call(param, 2, 3);
}

foo({ a: 1 });
  • fn.apply(obj, [x, x, ...]) 将fn函数的this指向obj,并调用,apply的第二个参数是一个数组
function foo(param) {
console.log("foo", param.a); // foo 1

function bar(x, y) {
console.log("bar", this.a, x, y); // bar 1 2 3
}
bar.apply(param, [2, 3]);
}

foo({ a: 1 });
  • fn.bind(obj, x, x, ...)(x, x, ...) 将fn函数的this指向obj,bind会返回一个新的函数,新的函数也可以传递参数
function foo(param) {
console.log("foo", param.a); // foo 1

function bar(x, y) {
console.log("bar", this.a, x, y); // bar 1 2 3
}
return bar.bind(param, 2);
}

const bar = foo({ a: 1 });
bar(3);

new 绑定

new 绑定 - this会绑定到新创建的对象上

function Person(name, age) {
  // this 指向新创建的对象
  this.name = name;
  this.age = age;

  // 通过new调用构造函数时,this指向新创建的对象
// 直接调用构造函数时,应用默认绑定规则,this指向全局或undefined
  console.log(this);
}

// 使用 new 关键字调用构造函数
const person1 = new Person("张三", 25);
console.log(person1.name); // "张三"
console.log(person1.age);  // 25

// 不使用 new 调用,this 会指向全局对象(非严格模式)或 undefined(严格模式)
const person2 = Person("李四", 30); // this 不会指向新对象
console.log(person2); // undefined (因为没有显式返回)

箭头函数

  1. 箭头函数没有自己的this 指向,它需要继承外层函数的this指向
  2. 箭头函数即使是new也无法改变this指向,因此箭头函数不能用于编写构造函数
var a = 1;
function foo() {
var obj1 = {
a: 2,
bar: function () {
console.log("bar", this.a);
var obj2 = {
a: 3,
baz: () => {
console.log("baz", this.a);
},
};
// 箭头函数不会创建自己的 this,它会捕获外层函数的 this
obj2.baz(); // baz 2
},
};
console.log("foo", this.a);
obj1.bar(); // bar 2
}
foo(); // foo 1

设计师到前端不再有墙:Figma + VS Code 自动出码实践

过去我们总说“设计与实现之间有鸿沟”。设计师在 Figma 中交付精美的 UI,前端开发却要手动把它们翻译成 HTML/CSS,费时又容易跑偏。

但今天,借助 Figma Dev Mode + VS Code MCP(Model Context Protocol)+ GitHub Copilot agent mode,我们可以直接在编辑器里还原设计稿为 HTML 页面,几乎零差别。本文带你走通这条“自动化落地”的实践路线。

工程结构 与 VS Code 配置

首先,我们要让 VS Code 具备“理解 Figma”的能力。这里用到的是 MCP 插件机制

新建项目目录

**mkdir figma-html-demo && cd figma-html-demo**

目录结构

figma-html-demo/
  ├── .vscode/
  │    ├── settings.json     # 用户层配置
  ├── index.html             # 输出的 HTML 文件
  └── README.md

增加 mcp server

在vs code 编辑器内,使用快捷键 cmd + shift + p , 输入 mcp 然后选择 MCP:Add Server 打开如图二的界面。 选择 HTTP 选项。 输入 http:127.0.0.1:3845/mcp 。回车。

image.png

image 1.png

选择作用域。我倾向于对项目进行配置。 所以选 workspace.

image 2.png

做完这一步。 我们就能看到,工程目录下新增的mcp.json 文件。 大概如下图。

image 3.png

Figma Dev Mode MCP 配置

目前 Figma Dev Mode MCP 处于Beta 阶段。 并且,只有桌面客户端和付费用户才能使用。 如果,你没有付费或者是在网页端是看不到,MCP server 这个选项的。

打开我们的figma 桌面端软件。 在左上角的图标处开启 MCP Server 服务。 如下图。

image 4.png

第二 ,确认好当前需要的设计稿处于 Dev mode 模式。

Dev Mode 会暴露清晰的层级、属性和标注,MCP 插件依赖这个结构来生成 HTML。

image 5.png

Github copilot 生成页面

前边基础配置,我们都做完以后。 现在进入 主要的生成阶段。 首先,我们要在 Figma 中,选择要生成的页面 或者组件,右键选择 复制链接。

image 6.png

打开 github Copilot 将模式从 ask (问答模式) 切换到 Agent 模式。

image 7.png

提示词 示例:

{figma Url}
帮我 实现如上的 Figma 页面。 命名为 index.html 要保留全部细节。采用 tailwind css。 遇到图片素材存储为png 格式,并存放到根目录的assets/imgs 目录下。
这是一个响应式的html5 页面。

输入提示词后, 大概会走这么一个流程 。

image 8.png

结果对比示例

image 9.png

总结

用 VS Code + Figma MCP + GitHub Copilot,我们把过去设计到实现之间的鸿沟,压缩到一条命令。

通过我这个测试项目,目前的还原度已经达到了 一个三四年经验的 前端工程师能做的样子了。

虽然目前无法做到只用一条提示词。 就做到完全的一模一样。 但距离这个目标越来越近了 。程序员以后如果不会 vibe coding 可能会越来越难混了。

神奇魔法类:使用 createMagicClass 增强你的 JavaScript/Typescript 类

什么是神奇魔法类?

神奇魔法类(Magic Class)是一种特殊的类包装器,它通过createMagicClass函数创建,能够赋予普通 JavaScript/TypeScript 类超能力。这个工具让你的类既可以作为构造函数使用,又可以作为函数调用来配置选项,大大增强了类的灵活性和可用性。

神奇特性

1. 双重身份

魔法类具有双重身份,可以:

  • 作为普通类使用用于继承:class MyClass extends MagicClass
  • 作为函数调用来传入选项用于构建类:class MyClass extends MagicClass({<options>})

2. 生命周期钩子

提供完整的生命周期钩子,让你能够精确控制实例的创建过程:

  • onBeforeInstance: 实例创建前触发,可以阻止实例创建或修改类
  • onAfterInstance: 实例创建后触发,可以对实例进行后处理
  • onErrorInstance: 实例创建出错时触发,可以进行错误处理

使用示例

基本用法

import { createMagicClass } from "flex-tools/classs";
type UserCreateOptions = {
  prefix?: string;
  x?: number;
};
// 定义一个普通类
class User {
  name: string;
  prefix: string = "";
  constructor(name: string) {
    this.name = name;
    this.prefix = getMagicClassOptions<UserCreateOptions>(this)?.prefix!;
  }
  get title() {
    return `${this.prefix}${this.name}`;
  }
  toString() {
    return `${this.constructor.name}<${this.name}>`;
  }
}

// 创建魔术类
const MagicUser = createMagicClass<typeof User, UserCreateOptions>(User, {
  prefix: "Hi,", // 默认配置
  x: 1,
  onBeforeInstance: (cls, args, _options) => {},
  onAfterInstance: (inst, _options) => {},
});
//  直接作为类使用
class Admin extends MagicUser {}
class Guest extends MagicUser({ x: 2, prefix: "欢迎," }) {}
class Customer extends MagicUser({ prefix: "尊贵的" }) {}

const user = new User("用户");
const admin = new Admin("管理员");
const guest = new Guest("访客");
const customer = new Customer("客户");

高级用法:拦截实例创建

const ValidatedPerson = createMagicClass(Person, {
  onBeforeInstance: (cls, args, options) => {
    const name = args[0];

    // 验证名称
    if (!name || name.length < 2) {
      throw new Error("Name must be at least 2 characters long");
    }

    // 可以修改参数
    args[0] = name.charAt(0).toUpperCase() + name.slice(1);

    // 返回false会阻止实例创建
    // 返回一个对象会使用该对象作为实例
    // 返回一个类会使用该类创建实例
  },
  onErrorInstance: (error, cls, options) => {
    console.error("Failed to create person:", error.message);
  },
});

class SitePerson extends ValidatedPerson {}

// 这将抛出错误,因为名称太短
try {
  const invalid = new SitePerson("A");
} catch (e) {
  console.log(e.message); // "Name must be at least 2 characters long"
}

// 这将成功,并且名称首字母会被自动大写
const valid = new SitePerson("bob"); // 实际名称将是 "Bob"

获取实例配置

你可以使用getMagicClassOptions函数获取实例的配置选项:

import { createMagicClass, getMagicClassOptions } from "flex-tools/classs";

const MagicPerson = createMagicClass(Person, { version: "1.0" });
const ConfiguredPerson = MagicPerson({ theme: "dark" });
const person = new ConfiguredPerson("Alice");

const options = getMagicClassOptions(person);
console.log(options); // { version: '1.0', theme: 'dark' }

总结

神奇魔法类通过createMagicClass函数提供了一种优雅而强大的方式来增强 JavaScript/TypeScript 类的能力。它不仅保留了原始类的所有功能,还添加了配置选项、生命周期钩子和灵活的实例化方式,使你的代码更加灵活、可配置且易于维护。

无论你是构建复杂的 UI 组件、可配置的工具类,还是需要精细控制实例创建过程的系统,神奇魔法类都能为你提供强大而灵活的解决方案。

详见flex-tools

GeoTools 结合 OpenLayers 实现叠加分析

前言

叠加分析是地理信息系统(GIS)空间分析的核心功能之一。它通过两个或者两个以上的图层进行叠加,揭示要素间的空间关联与交互规律。提取出目标结果进行分析,在项目选址、土地占用方面特别有用。叠加分析不仅能够提取多源数据的复合信息,还可通过逻辑运算(如交集、并集)生成新的空间特征,为科学决策提供关键的空间关系支撑。

本篇教程在之前一系列文章的基础上讲解如何将使用GeoTools工具结合OpenLayers实现空间数据的空间缓冲区分析功能。

  • GeoTools 开发环境搭建[1]
  • 将 Shp 导入 PostGIS 空间数据的五种方式(全)[2]
  • GeoTools 结合 OpenLayers 实现空间查询[3]

如果你还没有看过,建议从那里开始。

本文实现流程大致如下。

1. 开发环境

本文使用如下开发环境,以供参考。

时间:2025年

GeoTools:v34-SNAPSHOT

IDE:IDEA2025.1.2

JDK:v17

OpenLayers:v9.2.4

Layui:v2.9.14

2. 搭建后端服务

当前在GeoTools 结合 OpenLayers 实现缓冲区分析[4]的基础上进行改造,具体修改如下。

在项目控制层SpatialAnalyseController创建叠加分析方法overlapAnalyse,该方法接收一个GeoJSON字符串参数。

package com.example.geotoolsboot.controller;

import com.example.geotoolsboot.service.ISpatialAnalyseService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

/**
 * @name: SpatialAnalyseController
 * @description: 空间分析控制器
 * @author: gis_road
 * @date: 2025-08-05
 */
@CrossOrigin(origins = "*") // 允许跨域
@RestController
public class SpatialAnalyseController {

    @Autowired
    private ISpatialAnalyseService spatialAnalyseService;

    @GetMapping("/overlapAnalyse")
    public Map<String,Object> overlapAnalyse(@RequestParam() String geoJSON) throws Exception {

        return spatialAnalyseService.overlapAnalyse(geoJSON);
    }
}

在服务层创建ISpatialAnalyseService接口并定义缓冲分析方法。

package com.example.geotoolsboot.service;

import java.util.Map;

/**
 * @name: ISpatialAnalyseService
 * @description: 空间分析管理层
 * @author: gis_road
 * @date: 2025-08-05
 */
public interface ISpatialAnalyseService {
    Map<String,ObjectoverlapAnalyse(String geoJSON) throws Exception;
}

在服务层中实现ISpatialAnalyseService接口。首先使用方法geoJsonToGeometry将前端传递过来Geomery类型的GeoJSON字符串对象转换为GeoTools中的Geometry对象,然后使用buildSchema方法构造返回要素数据结构。

需要注意的是在判断数据相交时至少要得考虑几何对象类型为Polygon和MultiPolygon两种情况。在代码中sourceGeometry instanceof MultiPolygon使用instanceof关键字很容易判断出几何数据类型。

在分析完成之后,使用方法feature2JSON将结果数据转换为GeoJSON字符串返回给前端。

package com.example.geotoolsboot.service.impl;

import com.example.geotoolsboot.service.ISpatialAnalyseService;
import org.geotools.api.data.SimpleFeatureSource;
import org.geotools.api.feature.simple.SimpleFeature;
import org.geotools.api.feature.simple.SimpleFeatureType;
import org.geotools.api.feature.type.AttributeDescriptor;
import org.geotools.api.feature.type.GeometryDescriptor;
import org.geotools.api.referencing.crs.CoordinateReferenceSystem;
import org.geotools.data.shapefile.ShapefileDataStore;
import org.geotools.data.simple.SimpleFeatureCollection;
import org.geotools.data.simple.SimpleFeatureIterator;
import org.geotools.feature.DefaultFeatureCollection;
import org.geotools.feature.simple.SimpleFeatureBuilder;
import org.geotools.feature.simple.SimpleFeatureTypeBuilder;
import org.geotools.geojson.feature.FeatureJSON;
import org.geotools.geojson.geom.GeometryJSON;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.MultiPolygon;
import org.locationtech.jts.geom.Polygon;
import org.springframework.stereotype.Service;

import java.io.File;
import java.io.StringReader;
import java.io.StringWriter;
import java.nio.charset.Charset;
import java.util.*;

/**
 * @name: SpatialAnalyseServiceImpl
 * @description: 空间分析实现层
 * @author: gis_road
 * @date: 2025-08-05
 */

@Service
public class SpatialAnalyseServiceImpl implements ISpatialAnalyseService {
    /**叠加分析**/
    @Override
    public Map<String,Object>  overlapAnalyse(String geoJSON) throws Exception {

        // 保存输出结果
        List<Object> resultFeatures = new ArrayList<>();

        // 叠加分析数据
        String shpPath = "E:\data\scland_4326.shp";
        File shpFile = new File(shpPath);

        ShapefileDataStore shapefileDataStore = new ShapefileDataStore(shpFile.toURI().toURL());
        // 设置中文字符集,防止乱码
        shapefileDataStore.setCharset(Charset.forName("GBK"));

        // 获取分析数据结构
        String typeName = shapefileDataStore.getTypeNames()[0];
        SimpleFeatureType sourceSchema = shapefileDataStore.getSchema(typeName);

        SimpleFeatureSource featureSource = shapefileDataStore.getFeatureSource();

        // 目标数据
        SimpleFeatureCollection sourceFeatureCollection = featureSource.getFeatures();
        CoordinateReferenceSystem crs = sourceSchema.getCoordinateReferenceSystem();
        // 解析字符串分析参数为JSON对象
        Map<String,Object> resultMap = new HashMap<>();

        // 将GeoJSON数据转换为Geometry
        Geometry analyseGeometry = geoJsonToGeometry(geoJSON);

        // 创建结果要素类型
        SimpleFeatureType resultSchema = buildSchema(sourceSchema, crs);

        // 相交分析,获取两个图层的相交集合
        try(SimpleFeatureIterator iterator = sourceFeatureCollection.features()){
            while(iterator.hasNext()){
                SimpleFeature sourceFeature = iterator.next();
                Geometry sourceGeometry = (Geometry)sourceFeature.getDefaultGeometry();

                if(sourceGeometry instanceof Polygon){
                    // 执行相交操作
                    Geometry intersection = sourceGeometry.intersection(analyseGeometry);
                    if(!intersection.isEmpty()){
                        resultFeatures.add(intersection);
                    }
                } else if (sourceGeometry instanceof MultiPolygon) {
                    MultiPolygon multiPolygon = (MultiPolygon) sourceGeometry;
                    for (int i = 0; i < multiPolygon.getNumGeometries(); i++) {
                        Geometry subGeom = multiPolygon.getGeometryN(i);
                        if (subGeom instanceof Polygon) {
                            Geometry intersection = subGeom.intersection(analyseGeometry);
                            if (!intersection.isEmpty()) {
                                List<Object> attributes = new ArrayList<>();
                                attributes.add(null); // 预留几何位置
                                // 添加要素的属性
                                for(AttributeDescriptor attr:sourceSchema.getAttributeDescriptors()) {
                                    if (!(attr instanceof GeometryDescriptor)) {
                                        attributes.add(sourceFeature.getAttribute(attr.getName()));
                                    }
                                }
                                SimpleFeature resultFeature = SimpleFeatureBuilder.build(
                                        resultSchema,
                                        attributes,
                                        null
                                );
                                resultFeature.setDefaultGeometry(intersection);
                                String feature =  (String)feature2JSON(resultFeature);
                                resultFeatures.add(feature);
                            }
                        }
                    }
                }
            }

        }catch (Exception e){
            e.printStackTrace();
        }

        resultMap.put("data",resultFeatures);
        return resultMap;
    }

    /**
     * 创建要素结构
     * @param schema
     * @param crs
     * @return
     */
    private static SimpleFeatureType buildSchema(SimpleFeatureType schema,CoordinateReferenceSystem crs) {
        SimpleFeatureTypeBuilder typeBuilder = new SimpleFeatureTypeBuilder();
        typeBuilder.setName(schema.getName().getLocalPart());
        typeBuilder.setCRS(crs);

        // 添加几何字段(保留主图层的几何类型)
        GeometryDescriptor geomDescriptor = schema.getGeometryDescriptor();
        typeBuilder.add(geomDescriptor.getLocalName(), geomDescriptor.getType().getBinding(), crs);
        for (AttributeDescriptor attr : schema.getAttributeDescriptors()) {
            // 跳过几何字段(已单独添加)
            if (attr instanceof GeometryDescriptor) continue;

            String attrName = attr.getLocalName();

            // 添加属性(保留原始类型和约束)
            typeBuilder.add(attrName, attr.getType().getBinding());

            // 复制属性约束(如是否可为空、默认值等)
            if (!attr.isNillable()) {
                typeBuilder.nillable(false);
            }
            if (attr.getDefaultValue() != null) {
                typeBuilder.defaultValue(attr.getDefaultValue());
            }
        }
        return typeBuilder.buildFeatureType();
    }
    /**
     * 将GeoJSON字符串转换为Geometry对象
     * @param geoJson
     * @return
     */
    private static Geometry geoJsonToGeometry(String geoJson) throws Exception {
        GeometryJSON geometryJson = new GeometryJSON(6); // 保留6位小数
        try (StringReader reader = new StringReader(geoJson)) {
            return geometryJson.read(reader);
        }
    }

    /**
     * 将Feature转换为GeoJSON字符串
     * @param feature
     * @return
     */
    private Object feature2JSON(SimpleFeature feature) throws Exception {
        FeatureJSON featureJSON = new FeatureJSON();
        StringWriter writer = new StringWriter();
        featureJSON.writeFeature(feature,writer);
        return writer.toString();
    }

}

3. OpenLayers 加载叠加服务

前端实现逻辑首先加载叠加分析图层,然后绘制面对象,在绘制完成之后将面Geometry对象转换为GeoJSON数据,之后在点击提交按钮时,将几何数据传递到后端。

后端分析完成将叠加成果返回到前端之后,使用一个列表展示分析结果,点击当前行缩放到目标数据范围,图层高亮显示并打开信息弹窗。

前端CSS结构:

.query-wrap {
    position: absolute;
    padding10px;
    top80px;
    left90px;
    background#ffffff;
    width250px;
    border-radius2.5px;
}

.table-div {
    position: absolute;
    padding10px;
    top250px;
    left90px;
    max-height350px;
    overflow-y: scroll;
    background#ffffff;
    width350px;
    border-radius2.5px;
    display: none;
}

前端HTML结构:

<body>
    <div id="top-content">
        <span>GeoTools 结合 OpenLayers 实现叠加分析</span>
    </div>
    <div id="map" title=""></div>
    <div class="query-wrap">
        <form class="layui-form layui-form-pane" action="">
            <div class="layui-form-item">
                <label class="layui-form-label">绘制对象</label>
                <div class="layui-input-block">
                    <select name="condition" lay-filter="draw-select-filter">
                        <option value="None">请选择绘制类型</option>
                        <option value="Point"></option>
                        <option value="LineString">线</option>
                        <option value="Polygon"></option>
                    </select>
                </div>
            </div>
            <div class="layui-form-item">
                <button lay-submit lay-filter="clearAll" class="layui-btn layui-btn-primary">清除</button>
                <button class="layui-btn" lay-submit lay-filter="spatialAnalyse">确认</button>
            </div>
        </form>
    </div>
    <div class="table-div">
        <table id="feature-data"></table>
    </div>
</body>

前端JS实现,其他逻辑大体不变,这次主要使用了parseFeatureFromService方法统一将后端叠加成功数据转换为Feature数组对象进行展示。

layui.use(['form''table'], function () {
        const form = layui.form;
        const layer = layui.layer;
        const table = layui.table;

        // 绘制事件
        form.on('select(draw-select-filter)', function (data) {
            removeInteraction()
            const value = data.value// 获得被选中的值
            drawShape(value)
        });

        // 清除事件
        form.on("submit(clearAll)", function (data) {
            // 清除绘制事件
            removeInteraction()
            document.querySelector(".table-div").style.display = "none"
            // 清除图形
            removeAllLayer(map)
            return false// 阻止默认 form 跳转
        })

        let properties = []
        // 提交事件
        form.on('submit(spatialAnalyse)', function (data) {
            if (!geoJSON) {
                layer.msg("请绘制缓冲区域")
                return false
            }
            const queryParam = encodeURIComponent(geoJSON)
            // 后端服务地址
            const spatial = `http://127.0.0.1:8080/overlapAnalyse?geoJSON=${queryParam}`

            fetch(spatial).then(response => response.json()
                .then(result => {

                    const { resultFeatures: features, tabelData } = parseFeatureFromService(result.data)
                    properties = tabelData
                    const vectorSource = new ol.source.Vector({
                        features: features,
                        format: new ol.format.GeoJSON()
                    })

                    // 分析结果图层
                    const resultLayer = new ol.layer.Vector({
                        source: vectorSource,
                        style: new ol.style.Style({
                            fill: new ol.style.Fill({
                                color"#e77b7e8f"
                            }),
                            stroke: new ol.style.Stroke({
                                color"red",
                                width2.5,
                            }),
                        })
                    })
                    resultLayer.set("layerName""resultLayer")
                    resultLayer.setZIndex(999)
                    map.addLayer(resultLayer)

                    if (properties.length) {
                        document.querySelector(".table-div").style.display = "block"

                        // 已知数据渲染
                        let inst = table.render({
                            elem'#feature-data',
                            url: spatial,
                            cols: [[ //标题栏
                                { field'SICHUAN_ID'title'ID'width30 },
                                { field'dlbm'title'地类编码'width100 },
                                { field'dlmc'title'地类名称'width100 },
                                { field'AREA'title'面积(平方米)'width80sort: true }
                            ]],
                            parseData: (res) => {
                                const { tabelData } = parseFeatureFromService(res.data)
                                const newTable = tabelData.map(feature => {
                                    let table = {}
                                    table = feature.properties
                                    table.geometry = feature.geometry
                                    return table
                                })
                                return {
                                    "code"0// 解析接口状态
                                    "msg"""// 解析提示文本
                                    "count"""// 解析数据长度
                                    "data": newTable // 解析数据列表
                                }
                            }
                        })

                        // 行单击事件
                        table.on('row(feature-data)', function (obj) {
                            removeLayerByName("highlightLayer")
                            const data = obj.data; // 得到当前行数据
                            const geom = new ol.format.GeoJSON().readGeometry(data.geometry)
                            const source = new ol.source.Vector({
                                features: [
                                    new ol.Feature({
                                        geometry: geom,
                                        properties: data
                                    })
                                ],
                                format: new ol.format.GeoJSON()
                            })

                            const overLayer = new ol.layer.Vector({
                                source: source,
                                style: new ol.style.Style({
                                    stroke: new ol.style.Stroke({
                                        color"#00bcd4",
                                        width2.5
                                    }),
                                    fill: new ol.style.Fill({
                                        color"#ffffff00"
                                    })
                                })
                            })

                            overLayer.set("layerName""highlightLayer")
                            overLayer.setZIndex(9999)
                            map.addLayer(overLayer)

                            const extent = geom.getExtent()
                            map.getView().fit(extent)

                            // Popup 模板
                            const popupColums = [
                                {
                                    name"SICHUAN_ID",
                                    comment"要素编号"
                                },
                                {
                                    name"dlbm",
                                    comment"地类编码"
                                },
                                {
                                    name"dlmc",
                                    comment"地类名称"
                                },
                                {
                                    name"AREA",
                                    comment"面积(平方米)"
                                }
                            ]

                            // 获取中心点
                            const center = ol.extent.getCenter(extent)
                            openPopupTable(data, popupColums, center)
                        });

                    }
                })

            )

            return false// 阻止默认 form 跳转
        });
    });
    // 解析GeoJSON字符串
    const parseFeatureFromService = (result) => {
        const tabelData = []
        let features = []
        let resultFeatures = []
        if (!Array.isArray(result)) {
            features.push(result)
        } else {
            features = result
        }
        if (features.length) {
            resultFeatures = features.map(current => {
                const resultFeature = JSON.parse(current)
                tabelData.push(resultFeature)
                return new ol.format.GeoJSON().readFeature(resultFeature)
            })
        }
        return {
            resultFeatures,
            tabelData
        }
    }

4. 实现效果

参考资料

[1]GeoTools 开发环境搭建

[2]将 Shp 导入 PostGIS 空间数据的五种方式(全)

[3]GeoTools 结合 OpenLayers 实现空间查询

[4]GeoTools 结合 OpenLayers 实现缓冲区分析

一个前端开发者的救赎之路-JS基础回顾(三)-Function函数

函数的声明

  1. 赋值式:var func = function() {}
  2. 声明式:function func() {}
  3. 箭头函数:()=>{}
  4. 三者之间的区别
    a. 声明式可以在函数声明前去调用,赋值式不可以
    b. 箭头函数,他们从定义自己的环境继承上下文,而不是像以其他方式定义的函数那样定义自己的调用上下文
    c. 箭头函数没有prototype属性,所以它不能作为新类的构造函数

函数的调用

  • 作为函数:
    • 注意这个说法,函数是通过调用表达式被作为函数或方法调用的。
    • 调用表达式包括求值为函数对象的函数表达式,后跟一对圆括号,圆括号中是逗号分隔的零或多个参数表达式列表
    • this指向:
      • 非严格模式:全局对象
      • 严格模式:undefined
      • 注意 箭头函数又有不同:它们总是继承自身定义所在环境的this值(这里即使是严格模式,this也是window
      • 下面的代码可以用来判断是不是处于严格模式
      // 定义并调用函数,以确定当前是不是严格模式
      const strict = (function() { return !this }())
      
      • 我有一篇文章,这里面有一个自己遇到的有趣的this指向和作用域的问题
    • 这里注意条件式调用:
      • 在Es2020中,可以在函数表达式的后面、圆括号的前面插入?.,从而只在函数不是null或undefined的情况下调用。
      • 在没有副作用的前提下:f?.()<=>(f !== null && f !== undefined ? f() : undefined)
  • 作为方法:
    • this关键字不具有变量那样的作用域机制,除了箭头函数,嵌套函数不会继承包含函数的this值。如果嵌套函数被当做方法来用,那它的this就是调用它的对象。如果嵌套函数(不是箭头函数)被当做函数来调用,则它的this值要么是全局对象(非严格模式),要么是undefined(严格模式)

      let o = {
          a: 1,
          b: 2,
          m: function() {
              let self = this;
              console.log(this.a); // 1
              f();
      
              function f() {
                  console.log(this.b); // undefined
              }
              
              const g = () => {
                  console.log(this.b); // 2
              }
              
              g();
          }
      }
      o.m();
      
  • 作为构造函数
    • 作为构造函数调用,上下文是这个新对象
  • 通过call()或apply()方法调用
    • 这两个方法允许我们指定调用时的this,这意味着可以将任意函数作为任意对象的方法来调用
    • call()方法使用自己的参数列表作为函数的参数,而apply()方法则期待数组值作为参数。
  • 通过JavaScript语言隐式调用
    • 这里在开发中排查bug,很关键,就是我们引用了别人的三方库或者代码都有可能出现这个隐式调用

函数的参数

  • 函数调用时所传的实参,可以少于形参也可以多于形参,
  • 函数定义式可以用一个剩余参数来接收多余的参数,剩余参数必须作为最后一个参数,并且是...args这种形式,剩余参数永远是一个数组,不会是undefined,即使没有传对应的实参,因此不需要给剩余参数默认值

JS预解析

  • 对于函数的预解析和普通变量不一样,函数预解析是直接把整个函数提到顶部作用域,在预解析时会提前定义,只是不会立即执行。
  • 普通变量只是把声明提升到所在作用域顶部,而不进行初始化。
  • 因此,上面函数定义的时候,如果使用字面量的方式会把这个变量提升到顶部作用域并赋值undefined,所以在定义前调用的时候会报错。
  • let 和 const 会提升(hoisting),但由于 TDZ(暂时性死区) 机制,在声明前访问会报错

作用域

  • 作用域在定义的时候,一个函数就会形成一个内部作用域,后来引入了let/const又有了块级作用域
  • 作用域在访问的时候,是从下往上找,一直找到顶级作用域,找不到就报is not defined
  • 在赋值的时候不是,如果一直到最顶级都未声明,那他就是直接在全局定义域声明并且赋值这个变量。所以即使在vue中的某一个函数直接使用xxx=来进行操作,也是会在全局作用域添加一个全局变量xxx(内存泄漏),千万不要这样干

函数的方法和属性

  1. length属性:返回声明时声明的形参的个数(不包含剩余参数),只读
  2. name属性:表示定义函数时使用的名字(如果是用名字定义的),如果是未命名的函数,表示在第一次创建这个函数时赋给该函数的变量名或属性名。这个属性主要用于记录调试或排错信息。只读
  3. prototype属性:箭头函数没有
  4. call()和apply()方法:箭头函数的this不会被修改
  5. bind()方法:bind()方法主要目的是把函数绑定到对象,箭头函数不起作用
  6. toString()方法:
    • ECMAScript规定返回一个符合函数声明语句的字符串
    • 实际上,多数(不是全部),都是返回函数完整源代码
    • 内置函数返回的字符串中通常包含“[native code]”,表示函数体
  7. Function()构造函数:
    • Function()构造函数可以接收任意多个字符串参数,其中最后一个参数函数体的文本。这个函数体 文本中可以包含任意JavaScript语句,相互以分号分隔。传给构造函数的其他字符串参数,将作为这个新函数的的形参。
    • 注意:Function()构造函数不接受任何指定新函数名字的参数。与函数字面量一样,Function()构造函数创建的也是匿名函数

探究js继承实现方式-js面向对象的基础

继承

  • 是什么
    • 继承是面向对象三大特性:继承、封装、多态 之一
    • 具体来说就是让子类可以访问到父类的方法
  • 实现方式有?
    • 原型链继承
    • 构造函数继承
    • 组合继承
    • 原型式继承
    • 寄生式继承
    • 寄生组合式继承
    • 类的继承

原型链继承

  • 将父类的实例作为子类的原型来实现继承
  • 缺点:多个实例对象共享一个原型对象
// 原型链继承
function Parent() {
  this.name = 'parent';
  this.colors = ['red', 'blue', 'green'];
}

Parent.prototype.getName = function() {
  return this.name;
};

function Child() {
  this.age = 18;
}

// 关键:将父类的实例作为子类的原型
Child.prototype = new Parent();
Child.prototype.constructor = Child; // 修复constructor指向

// 测试
const child1 = new Child();
const child2 = new Child();
console.log(child1.getName()); // parent
child1.colors.push('black');
console.log(child1.colors); // ['red', 'blue', 'green', 'black']
console.log(child2.colors); // ['red', 'blue', 'green', 'black'] - 引用类型被所有实例共享

构造函数继承

  • 在子类构造函数中调用父类构造函数来实现属性继承
  • 缺点:无法继承父类原型上的方法
// 构造函数继承
function Parent() {
  this.name = 'parent';
  this.colors = ['red', 'blue', 'green'];
}

Parent.prototype.getName = function() {
  return this.name;
};

function Child() {
  // 关键:调用父类构造函数
  Parent.call(this);
  this.age = 18;
}

// 测试
const child1 = new Child();
const child2 = new Child();
child1.colors.push('black');
console.log(child1.colors); // ['red', 'blue', 'green', 'black']
console.log(child2.colors); // ['red', 'blue', 'green'] - 引用类型不共享
console.log(child1.getName); // undefined - 无法继承原型方法

组合继承

  • 利用原型继承和构造函数继承的特点,刚好可以解决他们的缺点
  • 使用父类构造函数继承属性
  • 设置子构造函数原型为父构造函数实例,继承原型
  • 缺点:父类构造函数重复执行两次,导致实例对象和原型上都有属性,造成浪费
// 组合继承
function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue', 'green'];
}

Parent.prototype.getName = function() {
  return this.name;
};

function Child(name, age) {
  // 继承属性
  Parent.call(this, name); // 第一次调用父类构造函数
  this.age = age;
}

// 继承方法
Child.prototype = new Parent(); // 第二次调用父类构造函数
Child.prototype.constructor = Child;

// 测试
const child1 = new Child('child1', 18);
const child2 = new Child('child2', 20);
child1.colors.push('black');
console.log(child1.colors); // ['red', 'blue', 'green', 'black']
console.log(child2.colors); // ['red', 'blue', 'green']
console.log(child1.getName()); // child1
console.log(child2.getName()); // child2

原型式继承

  • 利用object.create继承对象
  • 缺点:多个实例对象共享一个原型对象
const parent = {
  name: "parent",
  colors: ["red", "blue", "green"],
  getName: function () {
    return this.name;
  },
};

const  child1 = Object.create(parent)
const  child2 = Object.create(parent)

// 等同于 Object
// const  child1 = Object(parent)
// const  child2 = Object(parent)

console.log(child1.getName(), child1.colors); // parent [ 'red', 'blue', 'green' ]
console.log(child2.getName(), child2.colors); // parent [ 'red', 'blue', 'green' ]

child1.name = "child1";
child1.colors.push("black");

child2.name = "child2";

console.log(child1.getName(), child1.colors); // child1 [ 'red', 'blue', 'green', 'black' ]
console.log(child2.getName(), child2.colors); // child2 [ 'red', 'blue', 'green', 'black' ]

寄生式继承

  • 原型式继承的基础上添加方法,增强对象
// 寄生式继承
function createAnother(original) {
  const clone = Object.create(original); // 创建一个新对象
  clone.sayHi = function() { // 增强这个对象
    console.log('hi');
  };
  return clone; // 返回这个对象
}

const parent = {
  name: 'parent',
  colors: ['red', 'blue', 'green']
};

const child = createAnother(parent);
child.sayHi(); // hi
console.log(child.colors); // ['red', 'blue', 'green']

寄生组合式继承

  • 结合了组合继承和寄生继承的优点
  • 解决了继承的所有问题,是最优解
// 组合继承
function Parent(name) {
  this.name = name;
  this.colors = ["red", "blue", "green"];
}

Parent.prototype.getName = function () {
  return this.name;
};

function Child(name, age) {
  // 继承属性
  Parent.call(this, name); // 第一次调用父类构造函数
  this.age = age;
}

// 寄生组合式继承 解决两次调用构造函数的方式
Child.prototype = Object.create(Parent.prototype); // 创建一个新的对象,作为Child的原型,如果后续修改Child的原型不会影响Parent的原型
Child.prototype.constructor = Child;

// 测试
const child1 = new Child("child1", 18);
const child2 = new Child("child2", 20);
child1.colors.push("black");
console.log(child1.colors); // ['red', 'blue', 'green', 'black']
console.log(child2.colors); // ['red', 'blue', 'green']
console.log(child1.getName()); // child1
console.log(child2.getName()); // child2

类的继承

  • ES6推出了class关键字,可以像一般面向对象语言一样使用class关键字定义对象,但本质上class是一种语法糖,底层还是使用原型实现的实例对象和继承
  • 实现的效果和寄生组合式继承一样,实例属性不会相互影响
class Parent {
  constructor() {
    this.name = "parent";
    this.colors = ["red", "blue", "green"];
  }

  getName() {
    return this.name;
  }
}

class Child extends Parent {
  constructor(name) {
    super(); // 继承时必须调用父类构造函数
    this.name = name;
  }

  // 静态方法,相当于直接在Child构造函数上挂方法 Child.sayHi
  static sayHi() {
    console.log("hi child");
  }
}

const child1 = new Child('child1', 18);
const child2 = new Child('child2', 20);
child1.colors.push('black');
console.log(child1.colors); // ['red', 'blue', 'green', 'black']
console.log(child2.colors); // ['red', 'blue', 'green']
console.log(child1.getName()); // child1
console.log(child2.getName()); // child2

Trae 辅助下的 uni-app 跨端小程序工程化开发实践分享

大家好,我是不如摸鱼去,欢迎来到我的AI编程分享专栏。

这次来分享一下,我使用 Trae 作为主要AI编程工具,开发 uni-app 跨平台小程序的完整实践经验。我在实际的开发过程中,探索了 Trae 辅助开发的具体应用场景和效果,整理出一套相对完整的开发流程和工具链集成方案。

在这个项目中,我们使用Trae作为主要AI编程工具,集成FigmaAlova 网络请求框架、WotUI 组件库等现代化工具,通过 AI 辅助实现了开发效率的显著提升,在本案例中整体开发时间从传统的 40 人日缩短至 22 人日,效率提升约 45%。根据团队的实际体验,相比传统开发方式,开发体验有了明显改善。

本文使用的 Trae 为国际版,目前 Trae 已经内置了 Figma 插件,可以尝试直接使内置的 Figma 插件来还原 UI,本文所写项目为使用 Figma MCP 实现。

相关文章

本文可以结合以下几篇往期文章食用,它们中很多是本文的基础,想要AI编程真正达到提效目的,前置工作还是有必要的:

当年偷偷玩小霸王,现在偷偷用 Trae Solo 复刻坦克大战

告别 HBuilderX,拥抱现代化!这个模板让 uni-app 开发体验起飞

uni-app 还在手写请求?alova 帮你全搞定!

llms.txt:让 AI 更好地理解你的文档

设计师画个图,AI直接写出代码!AI + Figma MCP让前端开发效率暴增80%

用 AI 驱动 wot-design-uni 开发小程序


为什么选择 AI 辅助开发?

传统开发遇到的痛点

在传统开发场景下,存在很多场景可以使用 AI 来接管:

  1. 设计稿还原耗时:UI 设计到代码实现需要大量手工转换
  2. API 集成重复工作:接口类型定义、Mock 数据生成等重复性工作
  3. 测试用例的编写:测试用例编写非常耗时

我们的解决思路

核心理念:让 AI 处理重复性工作,人专注于业务逻辑

具体策略:

  • 利用 Figma MCP 实现设计稿到代码的半自动化转换
  • 基于 Swagger 文档自动生成 API 类型和调用代码
  • 通过 trae + llms.txt 文档集成让 AI 理解项目的组件库和开发规范
  • 等等

上游依赖对 AI 编程效果的关键影响

在我们的实践中发现,以下上游依赖的完整性和规范性直接影响 AI 辅助开发的效果,大致分为:

  • 设计稿完整性
  • 需求文档完整性
  • API 文档完整性
  • 组件库对 AI 支持的完整性

技术选型与工具链

开发工具选择

  • Trae 国际版:核心开发环境,提供 AI 代码生成和智能提示
  • Figma MCP:设计稿到代码的转换工具
  • @alova/wormhole:API 工程化和编辑器集成

技术栈选择

小程序开发当然要选个好的基础模板,这次我选用我在维护的 uni-app vue3 模板 wot-demo github.com/wot-ui/wot-… 来作为基础项目进行开发,这样更加贴合实际开发场景。 wot-demo 主要由以下开源包组成:

配置指南

我们首先对 Trae 进行一些配置:包括figma mcp、文档集、规则等,使 Trae 的开发思路更加符合我们的要求。

1. 配置 figma mcp

figma mcp 有多种配置方式,可以参考文章:设计师画个图,AI直接写出代码!AI + Figma MCP让前端开发效率暴增80%。这里我们直接使用 figma 官方提供的 Figma Dev Mode MCP。

目前也可以使用 trae 提供的内置的 figma 插件,即可不进行此配置。

前置要求

  • 计划要求:Professional、Organization 或 Enterprise 计划
  • 席位要求:Dev 或 Full 席位
  • 应用要求:必须使用 Figma beta 版桌面应用,下载链接:www.figma.com/downloads/

步骤 1:启用 Figma 桌面应用的 MCP 服务

  1. 更新应用:确保 Figma 桌面应用为最新的beta版本
  2. 打开设计文件:创建或打开一个 Figma Design 文件
  3. 访问菜单:点击左上角的 Figma 菜单
  4. 启用服务:在 Preferences 下选择 Enable Dev Mode MCP Server
  5. 确认运行:底部应显示确认消息,表明服务器已启用并运行

📍 重要:服务器将在本地运行于 http://127.0.0.1:3845/mcp,请记住此地址用于下一步配置。

步骤 2:在 Trae 中配置

按照如下操作,打开AI功能管理->MCP 选择手动配置 填入以下配置

{
  "mcpServers": {
    "Figma Dev Mode MCP": {
      "type": "sse",
      "url": "http://127.0.0.1:3845/mcp"
    }
  }
}

看到如下图,Figma MCP 即可使用了,其他 MCP 接入方案也大同小异

2. 配置 Trae 规则

还是AI功能管理设置界面,我们选中“规则”页签,设置项目规则即可。 编辑项目规则,填入以下规则,然后保存即可

# 项目开发规则

## 项目概述
本项目是基于 uni-app + Vue 3 + TypeScript 的跨平台应用,使用 wot-design-uni 组件库构建。

## 核心技术栈
- **框架**: uni-app (Vue 3 + TypeScript)
- **UI组件库**: wot-design-uni
- **请求库**: alova
- **路由**: uni-mini-router + @uni-helper/vite-plugin-uni-pages (文件路由)
- **状态管理**: pinia
- **样式**: UnoCSS + @uni-helper/unocss-preset-uni
- **构建工具**: Vite
- **代码规范**: ESLint + Prettier + husky

## 目录结构规范

### src/api/ - API管理
- `core/` - Alova核心配置
  - [instance.ts](mdc:src/api/core/instance.ts) - Alova实例配置
  - [handlers.ts](mdc:src/api/core/handlers.ts) - 请求处理器
  - [middleware.ts](mdc:src/api/core/middleware.ts) - 中间件
- `mock/` - Mock数据
  - `modules/` - 按模块分类的Mock数据
  - `utils/` - Mock工具函数
- [createApis.ts](mdc:src/api/createApis.ts) - API生成配置
- [index.ts](mdc:src/api/index.ts) - API导出

### src/components/ - 全局组件
- 全局通用组件目录
- 包含 GlobalToast、GlobalLoading、GlobalMessage 等全局交互组件

### src/composables/ - 组合式函数
- 可复用的逻辑函数
- 命名格式: `use[功能名称].ts`

### src/layouts/ - 布局模板
- [default.vue](mdc:src/layouts/default.vue) - 默认布局
- [tabbar.vue](mdc:src/layouts/tabbar.vue) - 底部导航布局

### src/pages/ - 页面文件
- 基于文件的路由系统
- 每个页面目录包含 `index.vue` 文件
- 支持 `<route>` 自定义块配置路由元数据

### src/store/ - 状态管理
- Pinia store 文件
- [persist.ts](mdc:src/store/persist.ts) - 持久化配置

### src/utils/ - 工具函数
- 通用工具函数库

## 配置文件
- [pages.config.ts](mdc:pages.config.ts) - 页面和tabbar配置
- [alova.config.ts](mdc:alova.config.ts) - Alova配置
- [uno.config.ts](mdc:uno.config.ts) - UnoCSS配置
- [theme.json](mdc:src/theme.json) - 主题配置
- [vite.config.ts](mdc:vite.config.ts) - Vite构建配置

## 开发规范
1. 页面文件必须放在 `src/pages/` 目录下
2. 组件按通用性分类存放在 `src/components/``src/business/`
3. API 接口使用 Alova 生成,通过 `pnpm alova-gen` 命令生成
4. 样式优先使用 UnoCSS,支持响应式设计和主题切换
5. 使用 TypeScript 提供完整的类型定义

3. 配置文档集

还是AI功能管理界面,我们定位到上下文页签,选择添加文档集 填入https://wot-design-uni.cn/llms-full.txt即可

开发流程

整个项目的开发大致可以总结为以下五个阶段:

项目初始化

原则:架构设计靠人工,标准实现靠 AI

项目结构设计(人工主导)

这里我们直接选择 wot-demo 作为项目基础架构,代码结构如下

src/
├── components/              # 通用基础组件
├── business/               # 业务组件
├── composables/            # 组合式 API
├── store/                  # Pinia 状态管理
├── utils/                  # 工具函数
├── api/                    # API 层(Alova 自动生成)
├── pages/                  # 主包页面
├── pagesBase/             # 基础功能页面(子包)
├── pagesSubA/             # 模块A功能页面(子包)
├── pagesSubB/              # 模块B功能页面(子包)
└── static/                # 静态资源

基础组件开发(AI 擅长)

适合 AI 开发的组件类型:

  • 基于设计图的 UI 组件
  • 逻辑简单的展示组件
  • 纯函数工具方法

示例:基础卡片组件

<script setup lang="ts">
interface Props {
  type?: 'default' | 'primary' | 'success' | 'warning' | 'danger'
  size?: 'small' | 'medium' | 'large'
  title?: string
  subtitle?: string
  shadow?: boolean
  bordered?: boolean
}

withDefaults(defineProps<Props>(), {
  type: 'default',
  size: 'medium',
  shadow: true,
  bordered: false
})
</script>

<template>
  <view
    class="base-card"
    :class="[
      `card--${type}`,
      `card--${size}`,
      { 'card--shadow': shadow },
      { 'card--bordered': bordered },
    ]"
  >
    <view v-if="$slots.header || title" class="card-header">
      <slot name="header">
        <text class="card-title">
          {{ title }}
        </text>
        <text v-if="subtitle" class="card-subtitle">
          {{ subtitle }}
        </text>
      </slot>
    </view>

    <view class="card-content">
      <slot />
    </view>

    <view v-if="$slots.footer" class="card-footer">
      <slot name="footer" />
    </view>
  </view>
</template>

<style scoped>
.base-card {
  @apply bg-white rounded-lg overflow-hidden;
}
.card--shadow {
  @apply shadow-sm;
}
.card--bordered {
  @apply border border-gray-200;
}
</style>

复杂组件开发(人工主导)

需要人工开发的组件特征:

  • 涉及全局状态管理
  • 复杂的交互逻辑
  • 性能敏感的组件

阶段二:UI 还原

通过 Figma MCP 插件,Trae 可以直接读取设计稿并生成对应的的小程序代码,操作步骤如下:

  1. 获取设计链接

    • 右键点击 Figma 中的 Frame 或图层
    • 选择 Copy/Paste AsCopy Link to Selection
    • 或使用快捷键 ⌘ + L (macOS) / Ctrl + L (Windows)
  2. 在 Trae 中使用: 打开trae的对话框,选择Builder with MCP,粘贴 Figma 链接,然后输入提示词即可。

  3. 产出效果
    由于项目UI不方便展示,这里贴一个 Trae 生成的叮咚决策器的效果,还原度还是比较高的。

阶段三:API 工程化

Alova + @alova/wormhole 集成

基于项目的 Swagger 文档,实现 API 的自动化集成:

// alova.config.ts
export default {
  generator: [{
    input: 'http://your-api-domain/v2/api-docs',
    platform: 'swagger',
    output: 'src/api',
    responseMediaType: 'application/json',
    bodyMediaType: 'application/json',
    version: 3,
    type: 'typescript',
    global: 'Apis',

    handleApi: (apiDescriptor) => {
      // 过滤废弃的 API
      if (apiDescriptor.deprecated) {
        return undefined
      }
      return apiDescriptor
    }
  }],

  autoUpdate: {
    launchEditor: true,
    interval: 5 * 60 * 1000 // 每5分钟检查更新
  }
}

自动生成的 API 使用

import { usePagination, useRequest } from 'alova'
import { Apis } from '@/api'

// 单次请求 - 自动类型推导
const { data: userInfo, loading: userLoading } = useRequest(
  Apis.user.getUserInfo()
)

// 分页请求 - 支持参数类型检查
const {
  data: orderList,
  loading: listLoading,
  loadNext,
  refresh
} = usePagination(
  (page, size) => Apis.order.getOrderList({
    params: {
      page,
      size,
      status: 'pending' // TypeScript 自动检查状态值
    }
  }),
  {
    initialData: [],
    initialPageSize: 20
  }
)

阶段四:业务开发(智能组合)

基于前面的基础设施,Trae 能够智能地组合组件和 API:

<script setup lang="ts">
import { usePagination } from 'alova'
import { Apis } from '@/api'

const searchText = ref('')
const activeTab = ref('all')

// 使用 Alova 进行分页请求
const {
  data: orderList,
  loading,
  loadNext
} = usePagination(
  (page, size) => Apis.getOrderList({
    page,
    size,
    status: activeTab.value,
    keyword: searchText.value
  }),
  {
    initialData: [],
    initialPageSize: 20
  }
)
</script>

<template>
  <view class="order-list-page">
    <!-- Trae 优先推荐项目组件 -->
    <NavSearchBar
      v-model="searchText"
      placeholder="搜索订单号"
      @search="handleSearch"
    />

    <!-- 自动推荐合适的组件库组件 -->
    <wd-tabs v-model="activeTab">
      <wd-tab title="全部" name="all" />
      <wd-tab title="待付款" name="pending" />
      <wd-tab title="已完成" name="completed" />
    </wd-tabs>

    <!-- 智能组合业务组件 -->
    <view class="order-list">
      <template v-if="!loading">
        <view
          v-for="order in orderList"
          :key="order.id"
          class="order-item"
          @click="navigateToDetail(order.id)"
        >
          <!-- 订单内容 -->
        </view>
      </template>
      <SalesListSkeleton v-else />
    </view>

    <!-- 空状态处理 -->
    <EmptyStatus v-if="!loading && !orderList.length" />
  </view>
</template>

阶段五:测试与文档(AI 辅助总结)

Trae 能够分析代码并生成功能总结和测试建议:

/**
 * 订单管理模块功能总结 (AI 辅助生成)
 *
 * 核心功能:
 * 1. 订单列表查询和筛选
 * 2. 订单详情查看
 * 3. 订单状态变更
 * 4. 订单统计分析
 *
 * 主要组件:
 * - OrderList.vue: 订单列表页面
 * - OrderDetail.vue: 订单详情页面
 * - OrderStats.vue: 订单统计组件
 */

实际开发效果

引入 AI 进行工程化开发后,开发体验得到明显改善,可以体现在以下阶段:

开发阶段 传统方式体验 AI 辅助体验 主要改善
项目初始化 大量重复性基础设施搭建 简单组件和工具函数快速生成 基础设施搭建更高效
UI 还原 手工对照设计稿编写样式 基于设计稿智能生成 设计还原速度显著加快
API 集成 手工编写类型定义和调用代码 自动生成类型安全的代码 API 开发效率大幅提升
业务开发 需要记忆和查找组件 API 智能提示和组件推荐 开发流畅度明显改善
测试总结 手工编写测试用例和文档 AI 辅助生成测试点 文档生成更便利

最佳实践建议

项目启动前(上游依赖质量检查):

  • 评估设计稿质量:确保设计系统完整、命名规范、状态齐全
  • 检查 PRD 完整性:业务流程清晰、异常处理明确、数据结构完整
  • 验证 API 文档:Swagger 文档完整、示例详细、错误码齐全
  • 确认组件库文档:API 文档完整、支持 llms.txt、类型定义完整
  • 配置 Trae Rules:根据项目实际,建立项目开发规范

开发过程中:

  • 优先开发基础组件和工具函数
  • 及时更新 Trae Rules 中的组件库
  • 保持 API 文档的同步更新

代码审查时:

  • 重点关注 AI 生成代码的业务逻辑正确性
  • 验证类型安全和错误处理
  • 确保代码符合项目规范

小小总结一下

在实际的项目中,我们验证了 Trae 在 uni-app 项目开发跨端小程序的应用价值,通过合理应用 AI 辅助开发,我们相信可以显著提升开发效率和代码质量,让开发者能够将更多精力投入到产品创新和用户体验优化上,同时期待 Trae 等 AI 编程工具能够提供更好的氛围编程体验。

感谢阅读!欢迎评论区沟通讨论👇。希望这份实践指南能为您的团队在 AI 辅助开发道路上提供有价值的参考。

实现AI聊天-核心技术点详解

实现chatGpt的效果,包含以下几个技术点:

1. 流式获取

要实现像chatgpt一个字一个字回复的效果,涉及到流式获取数据SSE(Server - Sent Events)。

sse利用向客户端声明,接下来要发送的是流信息的机制,向浏览器连续不断地推送信息

这完全不同于与平常我们所用的一次性加载方式。在传统方式中,前端发起请求后,需要等待后端准备好完整数据才能一次性返回;而流式方式则是后端逐步返回数据片段(chunk),前端逐块接收和处理,实现"边接收边处理"的效果。

如何实现:

  1. 后端实现

后端设置响应头:(记得添加charset=utf-8,声明编码格式,防止中文乱码

text/event-stream;charset=utf-8

image.png

响应体格式:

每一次发送的信息,由若干个message组成,每个message之间用\n\n分隔。每个message内部由若干行组成,每一行都是如下格式。

[field]: value\n

上面的field可以取四个值。

  • data
  • event
  • id
  • retry

返回示例:

event: userconnect
data: {"username": "bobby", "time": "02:33:48"}

data: {"username": "bobby", "time": "02:34:11", "text": "Hi everyone."}

data: {"username": "bobby", "time": "02:34:23"}

data: {"username": "sean", "time": "02:34:36", "text": "Bye, bobby."}

# Server-Sent Events 教程-阮一峰- 4.1 数据格式

MDN-使用服务器发送事件-事件流格式

用nodejs,express实现一个简易的sse接口:

const express = require('express');
const cors = require('cors');
const app = express();
const port = 3000;

// 配置CORS选项
const corsOptions = {
  origin: true, // 或指定域名如 'http://example.com'
  methods: ['GET', 'OPTIONS'], // SSE通常使用GET
  allowedHeaders: ['Content-Type'],
  credentials: true, // 如果需要凭证
};

// 应用CORS中间件
app.use(cors(corsOptions));

app.get('/sse', (req, res) => {
  const text = '随便写一句话,看看效果';
  const speed = 100;

  res.writeHead(200, {
    'Content-Type': 'text/event-stream;charset=utf-8',
    'Cache-Control': 'no-cache',
    Connection: 'keep-alive',
    // 添加cors
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
    'Access-Control-Allow-Credentials': true,
  });

  let index = 0;
  const timer = setInterval(() => {
    if (index < text.length) {
      res.write(`data: ${text[index]}\n\n`);
      index++;
    } else {
      clearInterval(timer);
      res.write('event: end\ndata: stream ended\n\n');
      res.end();
    }
  }, speed);

  req.on('close', () => {
    clearInterval(timer);
    res.end();
  });
});

app.listen(port, () => {
  console.log(`SSE Typewriter server running at http://localhost:${port}`);
});

如果是nginx部署,需注意添加接口配置

关闭代理缓冲 proxy_buffering off;详见处理 EventStream 不能流式返回的问题:Nginx 配置优化

  1. 前端实现:

利用fetch api处理,示例如下:

<!DOCTYPE html>
<html>
  <head>
    <title>获取流式数据 Demo</title>
    <style>
      #output {
        font-family: monospace;
        font-size: 24px;
        min-height: 100px;
        border: 1px solid #ccc;
        padding: 10px;
        white-space: pre-wrap;
      }
    </style>
  </head>
  <body>
    <button id="startBtn">获取流式数据</button>
    <div id="output"></div>

    <script>
      document
        .getElementById('startBtn')
        .addEventListener('click', async () => {
          const output = document.getElementById('output');
          output.innerHTML = ''; // 清空输出

          try {
            const response = await fetch('http://localhost:3000/sse', {
              method: 'GET',
              headers: {
                'Content-Type': 'application/json',
              },
            });

            if (!response.ok) {
              throw new Error(`HTTP error! status: ${response.status}`);
            }

            // 获取可读流
            const reader = response.body.getReader();
            const decoder = new TextDecoder();

            // 递归读取流
            const readChunk = async () => {
              const { done, value } = await reader.read();

              if (done) {
                return;
              }

              // 解码并显示字符
              const chunk = decoder.decode(value);
              const text = chunk.split('data: ')[1].replace('\n\n', '');
              console.log(text);

              output.innerHTML += text || '';

              // 继续读取下一个字符
              readChunk();
            };

            readChunk();
          } catch (error) {
            console.error('Error:', error);
            output.textContent = 'Error: ' + error.message;
            cursor.remove();
          }
        });
    </script>
  </body>
</html>

这里前端处理方式还有eventSoure对象,但有局限,只支持get请求。以及相关库实现@microsoft/fetch-event-source。流式处理前端方案对比详见:juejin.cn/post/747810… juejin.cn/post/747849…

2. 体验优化

  1. 暂停控制:通过AbortController中断流

示例:

const controller = new AbortController(); //创建AbortController对象

async function streamData() {
  try {
    const response = await fetch('/stream-endpoint', {
      signal: controller.signal // 添加signal
    });
    
    const reader = response.body.getReader();
    
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      console.log('收到数据块:', value);
    }
  } catch (err) {
    if (err.name === 'AbortError') {
      console.log('流已中断');
    } else {
      console.error('流错误:', err);
    }
  }
}

// 中断流
function cancelStream() {
  controller.abort(); // 在合适时机触发abort,取消请求
}
  1. 智能滚动:仅当用户未手动滚动时自动滚动到底部
// 智能滚动:仅当用户未手动滚动时生效
if (isNearBottom()) container.scrollTop = container.scrollHeight;

 // 判断是否接近底部
  isNearBottom() {
    const { scrollTop, scrollHeight, clientHeight } = this.container;
    return scrollHeight - (scrollTop + clientHeight) < this.threshold;
  }
  
this.threshold = 10; // 距离底部的阈值(px),10,为举例

深入理解 TypeScript 的 /// <reference /> 注释及其用途

在 Angular 项目中,出现的 index.d.ts 文件中包含了以下代码:

/// <reference path="./lib.app.d.ts" />
/// <reference path="./lib.page.d.ts" />
/// <reference path="./lib.component.d.ts" />
/// <reference path="./lib.mixin.d.ts" />
/// <reference path="./lib.shared.d.ts" />
/// <reference path="./lib.global.d.ts" />

这些代码的作用及其语法含义,涉及到 TypeScript 的编译器如何解析类型声明文件,以及如何通过 /// <reference /> 注释建立模块或类型之间的依赖关系。

以下将逐步分析这些代码的每个部分,详细解释其功能和语法规则,并提供相关的运行示例来演示其应用。

什么是 /// <reference />

/// <reference /> 是 TypeScript 中一种特殊的三斜杠注释(Triple-Slash Directive)。这类注释提供了一种方式,允许在文件之间显式声明依赖关系,指导编译器加载特定的类型定义文件。它们通常用于 .d.ts 类型声明文件。

三斜杠注释的语法如下:

/// <reference path="relative-or-absolute-path" />

这里的 path 表示要引用的文件的路径,可以是相对路径或绝对路径。路径指向一个 TypeScript 声明文件(以 .d.ts 为扩展名)。

逐个拆解代码片段的含义

///

这部分表示三斜杠注释的起始标志。三斜杠注释是一种特殊的注释类型,只能出现在文件的顶部或注释之前没有其他语句。

<reference />

这是三斜杠注释的核心指令部分,表明这是一个引用指令。指令用于引入外部文件中的类型信息。

path="./lib.app.d.ts"

path 是一个属性,指定要引用的文件路径。在这个示例中,路径为 ./lib.app.d.ts,表示当前目录下的 lib.app.d.ts 文件。

文件路径支持以下形式:

  1. 相对路径:以 ./../ 开头,指向相对于当前文件的路径。
  2. 绝对路径:在某些项目中可以使用项目根目录的绝对路径,但这通常需要与 tsconfig.json 配合。

为什么需要 /// <reference />

在现代 TypeScript 中,/// <reference /> 的使用场景较为有限,因为大多数项目依赖模块系统(如 ES Modules 或 CommonJS)自动处理文件之间的依赖关系。然而,在以下情况下仍然需要使用这种语法:

  1. 全局类型声明文件:如果一个类型定义文件中定义了全局变量、类型或接口,其他文件需要显式引用它以确保类型安全。
  2. 非模块化项目:当项目没有采用模块系统时,可以通过三斜杠注释建立文件间的依赖关系。
  3. 特定工具链或框架:某些工具或框架可能要求使用这种语法来声明类型依赖。

提供一个完整的运行示例

以下示例演示如何使用 /// <reference /> 在项目中引入全局类型声明。

文件结构

project/
  |-- tsconfig.json
  |-- index.ts
  |-- types/
        |-- lib.app.d.ts
        |-- lib.page.d.ts

tsconfig.json

配置文件用于告诉 TypeScript 编译器如何处理项目。

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "CommonJS",
    "outDir": "dist",
    "rootDir": "./",
    "typeRoots": ["./types"],
    "strict": true
  }
}

types/lib.app.d.ts

declare namespace App {
  interface Config {
    appName: string;
    version: string;
  }
}

types/lib.page.d.ts

declare namespace Page {
  interface Metadata {
    title: string;
    description: string;
  }
}

index.ts

在主文件中使用三斜杠注释引用这些全局声明文件:

/// <reference path="./types/lib.app.d.ts" />
/// <reference path="./types/lib.page.d.ts" />

const appConfig: App.Config = {
  appName: `MyApp`,
  version: `1.0.0`
};

const pageMetadata: Page.Metadata = {
  title: `Home Page`,
  description: `Welcome to the home page of MyApp.`
};

console.log(appConfig, pageMetadata);

编译与运行

执行以下命令进行编译和运行:

tsc
node dist/index.js

输出结果为:

{ appName: 'MyApp', version: '1.0.0' } { title: 'Home Page', description: 'Welcome to the home page of MyApp.' }

注意事项

  1. 路径有效性:确保引用的路径正确,并且文件存在。
  2. 模块系统的替代方案:对于模块化项目,优先使用 importexport,而非三斜杠注释。
  3. tsconfig.json 配合:在配置文件中设置 typeRootsinclude,可以减少手动引用的需求。

总结

三斜杠注释是 TypeScript 的一种显式依赖声明机制,用于特定场景下的类型声明管理。虽然在现代项目中应用范围有限,但它在处理全局声明和非模块化项目时依然具有重要作用。通过合理使用 /// <reference />,可以有效组织和管理大型项目的类型定义。

面试官的 JS 继承陷阱,你能全身而退吗?🕳️

继承,是 JS 面试绕不开的灵魂拷问。本文将带你一网打尽 JS 继承的所有姿势,配合代码实例和细致讲解,助你面试不再慌张!

一、什么是继承?

继承,就是让子类可以访问到父类的属性和方法。JS 继承的实现方式多如牛毛,面试官最爱考察各种细节和坑点。

二、原型链继承

原理

子类的原型指向父类的实例。所有子类实例共享同一个父类实例。

代码演示

function Parent() {
    this.name = 'parent'
    this.like = ['a', 'b', 'c']
}
Child.prototype = new Parent()
function Child() {
    this.age = 18
}
let c = new Child()
let d = new Child()
c.like.push('d')
console.log(c.like) // ['a', 'b', 'c', 'd']
console.log(d.like) // ['a', 'b', 'c', 'd']

优缺点

  • 优点:实现简单,能访问父类属性和方法。
  • 缺点:引用类型属性会被所有实例共享,互相影响,容易踩坑。

面试官小贴士

"你能说说原型链继承的缺陷吗?为什么 like 属性会被所有实例共享?"

三、构造函数继承

原理

在子类构造函数中调用父类构造函数,this 指向子类实例。

代码演示

Parent.prototype.say = function () {
    console.log('hello')
}
function Parent() {
    this.name = 'parent'
    this.like = ['a', 'b', 'c']
}
function Child() {
    this.age = 18
    Parent.call(this)
}
let c = new Child()
console.log(c.say) // undefined

优缺点

  • 优点:每个实例独立拥有父类属性,引用类型不再共享。
  • 缺点:无法继承父类原型上的方法(如 say),只能继承构造函数里的属性。

面试官小贴士

"为什么 c.say 是 undefined?如何让子类也能继承父类原型上的方法?"

四、组合继承

原理

原型链继承 + 构造函数继承,双管齐下。

代码演示

Parent.prototype.say = function () {
    console.log('hello')
}
function Parent() {
    this.name = 'parent'
    this.like = ['a', 'b', 'c']
}
Child.prototype = new Parent()
Child.prototype.constructor = Child
function Child() {
    this.age = 18
    Parent.call(this)
}
let c = new Child()
let d = new Child()
d.like.push('d')
console.log(d.like); // ['a', 'b', 'c', 'd']
console.log(c.like); // ['a', 'b', 'c']
console.log(c.say); // function
console.log(c.constructor); // Child

优缺点

  • 优点:既能继承父类属性,又能继承父类原型方法,引用类型不共享。
  • 缺点:父类构造函数会执行两次(一次给原型,一次给实例),有点浪费性能。

面试官小贴士

"组合继承为什么会调用两次父类构造函数?有没有更优的方案?"

五、原型式继承

原理

用 Object.create 或类似方式,以某对象为原型创建新对象。

代码演示

let parent = {
    name: 'parent',
    like: ['a', 'b', 'c']
}
let child1 = Object.create(parent)
let child2 = Object.create(parent)
child1.like.push('d')
console.log(child1.like); // ['a', 'b', 'c', 'd']
console.log(child2.like); // ['a', 'b', 'c', 'd']

优缺点

  • 优点:实现简单,适合对象克隆。
  • 缺点:引用类型属性依然共享。

面试官小贴士

"Object.create(parent) 和 new Object(parent) 有什么区别?"

六、寄生式继承

原理

在原型式继承基础上,增强返回的新对象。

代码演示

let parent = {
    name: 'parent',
    like: ['a', 'b', 'c']
}
function clone(origin) {
    let cloneObj = Object.create(origin)
    cloneObj.getLike = function() {
        return this.like
    }
    return cloneObj
}
let child1 = clone(parent)
let child2 = clone(parent)
child1.like.push('d')
console.log(child1.like); // ['a', 'b', 'c', 'd']
console.log(child2.like); // ['a', 'b', 'c', 'd']
console.log(child1.getLike()); // ['a', 'b', 'c', 'd']
console.log(child2.getLike()); // ['a', 'b', 'c', 'd']

优缺点

  • 优点:可以扩展新对象。
  • 缺点:引用类型属性依然共享。

面试官小贴士

"寄生式继承和原型式继承的本质区别是什么?"

七、寄生组合式继承(最优解)

原理

只继承父类原型,不调用父类构造函数,避免性能浪费。

代码演示

Parent.prototype.getName = function() {
    return this.Name
}
function Parent() {
    this.Name = 'parent'
    this.like = ['a', 'b', 'c']
}
Child.prototype = Object.create(Parent.prototype)
Child.prototype.constructor = Child
function Child() {
    this.age = 18
    Parent.call(this)
}
let c1 = new Child()
console.log(c1.getName()); // 'parent'
console.log(c1.constructor); // Child

优缺点

  • 优点:只调用一次父类构造函数,性能最佳,继承属性和方法都不落下。
  • 缺点:实现稍复杂,但值得!

面试官小贴士

"为什么寄生组合式继承被称为 JS 继承的终极方案?"

八、ES6 类继承

原理

用 class 和 extends 语法糖,优雅实现继承。

代码演示

class Parent {
    constructor() {
        this.Name = 'parent'
        this.like = ['a', 'b', 'c']
    }
    getName() {
        return this.Name
    }
    static say() {
        console.log('hello');
    }
}
class Child extends Parent {
    constructor() {
        super()
        this.age = 18
    }
}
let p = new Parent()
console.log(p.getName()); // 'parent'
let c = new Child()
console.log(c.getName()); // 'parent'

优缺点

  • 优点:语法简洁,继承关系清晰,原型链自动处理。
  • 缺点:底层依然是原型链,只是语法糖。

面试官小贴士

"class 继承和传统原型链继承的本质区别是什么?"

九、知识点总结与面试答题模板

继承方式对比表

方式 是否共享引用类型 是否继承原型方法 构造函数调用次数 优缺点
原型链继承 1 引用类型共享
构造函数继承 1 不能继承原型方法
组合继承 2 性能浪费
原型式继承 0 引用类型共享
寄生式继承 0 引用类型共享
寄生组合式继承 1 性能最佳
ES6 类继承 1 语法糖

面试高频问题

  • 说说 JS 继承的实现方式及优缺点?
  • 为什么原型链继承会导致引用类型属性共享?
  • 如何实现一个既能继承属性又能继承方法的子类?
  • ES6 的 class 继承和传统继承有什么区别?

十、幽默收尾

JS 继承就像家庭聚会,谁家锅碗瓢盆都能借来用,但有时候大家都用同一个锅,炒出来的菜味道就不一样了!面试官最爱问的那些继承细节,你现在都能用段子和代码轻松拿下!


祝大家面试不再慌张,继承全家桶一把梭!🎉

❌