阅读视图

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

鸿蒙音频录制方式总结

HarmonyOS 音频录制「一条龙」指南

(ArkTS/C++/NDK C 三种方式一次看齐)


  1. 选型速览表
层级 核心 API 语言 输出 延迟 典型场景 代码量
应用层 AVRecorder / AudioCapturer ArkTS 文件 / Stream 高/中 UI直接调用、录音笔
C++层 oboe::AudioStreamBuilder C++17 PCM 回调 游戏、实时语音
NDK层 OH_AudioCapturer C PCM 回调 极低 编解码器、专业工具

  1. ArkTS(应用层)

2.1 普通文件录制(AVRecorder)

// FileRecordPage.ets
import media from '@ohos.multimedia.media';

@Entry @Component struct FileRecordPage {
  private recorder?: media.AVRecorder;
  private path = getContext().filesDir + '/audio.aac';

  async start() {
    this.recorder = await media.createAVRecorder();
    const fd = fileio.openSync(this.path, 0o100 | 0o2);
    const cfg: media.AVRecorderConfig = {
      audioSourceType: media.AudioSourceType.AUDIO_SOURCE_TYPE_MIC,
      profile: { audioBitrate: 128000, audioChannels: 2, audioCodec: media.CodecMimeType.AUDIO_AAC, audioSampleRate: 48000 },
      url: `fd://${fd}`
    };
    await this.recorder.prepare(cfg);
    await this.recorder.start();
  }

  async stop() {
    await this.recorder?.stop();
    await this.recorder?.release();
  }

  build() {
    Column({ space: 20 }) {
      Button('开始').onClick(() => this.start());
      Button('停止').onClick(() => this.stop());
    }.padding(20);
  }
}

2.2 低延迟内存流(AudioCapturer)

// StreamRecordPage.ets
import audio from '@ohos.multimedia.audio';

@Entry @Component struct StreamRecordPage {
  private capturer?: audio.AudioCapturer;
  private isCapturing = false;

  async start() {
    const stream: audio.AudioStreamInfo = {
      samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_44100,
      channels: audio.AudioChannel.CHANNEL_1,
      sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
      encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW
    };
    this.capturer = await audio.createAudioCapturer(stream, {
      source: audio.SourceType.SOURCE_TYPE_MIC
    });
    await this.capturer.start();
    this.isCapturing = true;

    while (this.isCapturing) {
      const data = await this.capturer.read(4096, true);
      // TODO: 实时发送或处理 data.buffer
    }
  }

  async stop() {
    this.isCapturing = false;
    await this.capturer?.stop();
    await this.capturer?.release();
  }

  build() { /* 同上 */ }
}

2.3 多轨混合录制

需要额外声明 CAPTURE_AUDIO_PLAYBACK 权限

读取麦克风 + 系统回环,两路 PCM 自行混音即可。


  1. C++ 层(Oboe)

3.1 CMakeLists.txt(模块级)

cmake_minimum_required(VERSION 3.16)
find_package(oboe REQUIRED PATHS ${OHOS_SYSROOT}/usr/lib)
add_library(record SHARED record.cpp)
target_link_libraries(record oboe log)

3.2 record.cpp

#include <oboe/Oboe.h>
#include <fstream>
#include <chrono>
#include <thread>

class Callback : public oboe::AudioStreamCallback {
public:
    explicit Callback(std::ofstream& f) : fout_(f) {}
    oboe::DataCallbackResult
    onAudioReady(oboe::AudioStream* s, void* data, int32_t frames) override {
        fout_.write(static_cast<char*>(data),
                    frames * s->getChannelCount() * sizeof(int16_t));
        return oboe::DataCallbackResult::Continue;
    }
private:
    std::ofstream& fout_;
};

extern "C" void start_oboe_record() {
    std::ofstream pcm("/data/storage/el2/base/hfiles/oboe.pcm", std::ios::binary);
    Callback cb(pcm);

    oboe::AudioStreamBuilder b;
    b.setDirection(oboe::Direction::Input)
     ->setSampleRate(48000)
     ->setChannelCount(oboe::ChannelCount::Mono)
     ->setFormat(oboe::AudioFormat::I16)
     ->setPerformanceMode(oboe::PerformanceMode::LowLatency)
     ->setCallback(&cb);

    oboe::AudioStream* stream = nullptr;
    if (b.openStream(&stream) == oboe::Result::OK) {
        stream->requestStart();
        std::this_thread::sleep_for(std::chrono::seconds(5));
        stream->requestStop();
        stream->close();
    }
}

  1. NDK C 层(OHAudio)

4.1 oh_record.c

#include <ohaudio/native_audio_capturer.h>
#include <stdio.h>
#include <unistd.h>

static FILE* gFile;
static OH_AudioCapturer* gCapturer;

static void OnData(OH_AudioCapturer* c, void* user, void* buffer, int32_t len) {
    fwrite(buffer, 1, len, gFile);
}

void start_oh_record() {
    OH_AudioCapturerBuilder* b;
    OH_AudioCapturer_CreateBuilder(&b);
    OH_AudioCapturerBuilder_SetSamplingRate(b, 48000);
    OH_AudioCapturerBuilder_SetChannelCount(b, 1);
    OH_AudioCapturerBuilder_SetSampleFormat(b, AUDIO_SAMPLE_FORMAT_S16LE);
    OH_AudioCapturerBuilder_SetCapturerInfoCallback(b, OnData, NULL);
    OH_AudioCapturerBuilder_GenerateCapturer(b, &gCapturer);
    OH_AudioCapturerBuilder_Destroy(b);

    gFile = fopen("/data/storage/el2/base/hfiles/oh.pcm", "wb");
    OH_AudioCapturer_Start(gCapturer);

    sleep(5);

    OH_AudioCapturer_Stop(gCapturer);
    OH_AudioCapturer_Destroy(gCapturer);
    fclose(gFile);
}

4.2 CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
project(oh_record)
add_library(oh_record SHARED oh_record.c)
target_link_libraries(oh_record ohaudio)

  1. 权限与编译

  2. module.json5(通用)

"reqPermissions": [
  "ohos.permission.MICROPHONE",
  "ohos.permission.CAPTURE_AUDIO_PLAYBACK" // 仅多轨/系统回环需要
]
  1. 编译
  • ArkTS:DevEco Studio 一键运行
  • C++ / NDK:
cd entry/src/main/cpp
cmake -B build -DOHOS_STL=c++_static
cmake --build build

生成的 .so 会自动打包进 hap。


  1. 一句话总结
  • 业务 App 直接 ArkTS → 文件 or Stream;
  • 游戏/实时 → C++ Oboe;
  • 极限延迟 → NDK OHAudio。

全部代码复制即可跑,至此「录制」全家桶完毕。

鸿蒙音频播放方式总结

方式 封装层级 输入源 延迟 典型场景 主类/函数
AVPlayer 高层 URI / fd / 网络 80-200 ms 音乐/视频 media.createAVPlayer()
AudioRenderer 低层 PCM 流 10-30 ms 游戏/实时合成 audio.createAudioRenderer()
SoundPool 高层 短音频文件 10-20 ms 按键/射击 media.createSoundPool()
OpenSL ES Native PCM 流 <15 ms 跨平台移植 slCreateEngine()
OHAudio 统一 Native PCM 流 <10 ms XR/低延迟 OH_AudioRenderer
FileDescriptor Playback 中高层 fd 同 AVPlayer 降拷贝 AVPlayer.fdSrc
RawFile Playback 中高层 rawfile 同 AVPlayer 应用内资源 AVPlayer.url = $rawfile()
多路混音 低层 多 PCM 流 10-30 ms 会议/直播 AudioRenderer混音

下面把 8 种播放方式全部拆成「一步一步、复制即跑」的 超详细代码示例(含 ArkTS + C++ 双版本、生命周期、错误处理、资源释放)。

每个示例都可单独在 DevEco Studio 5.0 / API 15 真机跑通。


① AVPlayer(网络/本地文件)完整示例

// AVPlayerPage.ets
import { media } from '@kit.MediaKit';
import { BusinessError } from '@kit.BasicServicesKit';

@Entry
@Component
struct AVPlayerPage {
  private player?: media.AVPlayer;
  private url = 'https://webfs.hwcloudtest.cn/Music/1.mp3'; // 可换成 $rawfile('demo.mp3')

  aboutToAppear() {
    this.player = media.createAVPlayer();
    // 1. 监听状态机
    this.player.on('stateChange', (state: string) => {
      console.info(`AVPlayer state -> ${state}`);
      if (state === 'prepared') {
        this.player!.play();
      }
    });
    // 2. 监听错误
    this.player.on('error', (err: BusinessError) => {
      console.error(`AVPlayer error ${err.code} ${err.message}`);
    });
    // 3. 监听播放结束
    this.player.on('playbackComplete', () => {
      console.info('播放完成');
      this.player!.stop();
    });
    // 4. 设置 URL / fd / rawfile
    this.player.url = this.url;
    this.player.prepare();
  }

  aboutToDisappear() {
    this.player?.stop();
    this.player?.release();
  }

  build() {
    Column({ space: 20 }) {
      Text('AVPlayer 完整示例').fontSize(24)
      Row({ space: 20 }) {
        Button('播放').onClick(() => this.player?.play())
        Button('暂停').onClick(() => this.player?.pause())
        Button('停止').onClick(() => this.player?.stop())
      }
      Slider({
        value: 50,
        min: 0,
        max: 100,
        step: 1
      }).onChange(v => this.player?.setVolume(v / 100))
    }.padding(30)
  }
}

② AudioRenderer(PCM 正弦波)

// AudioRendererPage.ets
import { audio } from '@kit.AudioKit';

@Entry
@Component
struct AudioRendererPage {
  private renderer?: audio.AudioRenderer;
  private phase = 0;          // 相位累加器,避免点击

  async startRenderer() {
    if (this.renderer) return;

    const opt: audio.AudioRendererOptions = {
      streamInfo: {
        samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_48000,
        channels: audio.AudioChannel.CHANNEL_2,
        sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
        encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW
      },
      rendererInfo: {
        usage: audio.StreamUsage.STREAM_USAGE_GAME,
        rendererFlags: 0
      }
    };

    this.renderer = await audio.createAudioRenderer(opt);
    // 1. 注册写入回调
    this.renderer.on('writeData', (buffer: ArrayBuffer) => {
      const pcm = new Int16Array(buffer);
      const freq = 440;           // A4
      const amp = 0.2 * 0x7fff;   // 20% 音量
      for (let i = 0; i < pcm.length; i += 2) {
        const val = Math.sin(this.phase * 2 * Math.PI / 48000) * amp;
        pcm[i] = val;             // L
        pcm[i + 1] = val;         // R
        this.phase += freq;
      }
    });

    // 2. 启动
    await this.renderer.start();
  }

  async stopRenderer() {
    if (!this.renderer) return;
    await this.renderer.stop();
    await this.renderer.release();
    this.renderer = undefined;
    this.phase = 0;
  }

  build() {
    Column({ space: 20 }) {
      Text('AudioRenderer 48000/16/2').fontSize(24)
      Row({ space: 20 }) {
        Button('启动').onClick(() => this.startRenderer())
        Button('停止').onClick(() => this.stopRenderer())
      }
    }.padding(30)
  }
}

③ SoundPool(短音效)

// SoundPoolPage.ets
import { media, audio } from '@kit.MediaKit';
import { resourceManager } from '@kit.LocalizationKit';

@Entry
@Component
struct SoundPoolPage {
  private sp?: media.SoundPool;
  private clickId = -1;

  async aboutToAppear() {
    this.sp = await media.createSoundPool(8, {
      usage: audio.StreamUsage.STREAM_USAGE_SYSTEM
    });
    // 1. 加载 rawfile
    const rawFd = await resourceManager.getContext().resourceManager.getRawFd('click.mp3');
    this.clickId = await this.sp.load(rawFd.fd, rawFd.offset, rawFd.length);
  }

  playClick() {
    if (this.sp && this.clickId >= 0) {
      this.sp.play(this.clickId, {
        loop: 0,
        leftVolume: 0.8,
        rightVolume: 0.8,
        rate: audio.AudioRendererRate.RENDER_RATE_NORMAL
      });
    }
  }

  aboutToDisappear() {
    this.sp?.release();
  }

  build() {
    Column({ space: 20 }) {
      Text('SoundPool 短音效').fontSize(24)
      Button('播放 click').onClick(() => this.playClick())
    }.padding(30)
  }
}

④ FileDescriptor 播放(零拷贝)

// FDPlayerPage.ets
import { media } from '@kit.MediaKit';
import { fileIo } from '@kit.CoreFileKit';

@Entry
@Component
struct FDPlayerPage {
  private player?: media.AVPlayer;

  async startFD() {
    // 1. 把 asset 拷贝到沙箱(仅演示)
    const ctx = getContext();
    const src = ctx.resourceManager.getRawFileContentSync('demo.mp3');
    const dst = ctx.filesDir + '/demo.mp3';
    fileIo.writeFileSync(dst, src);
    // 2. 以 fd 打开
    const fd = fileIo.openSync(dst, fileIo.OpenMode.READ_ONLY);
    this.player = media.createAVPlayer();
    this.player.fdSrc = { fd: fd.fd, offset: 0, length: -1 };
    this.player.on('stateChange', (s) => s === 'prepared' && this.player!.play());
    this.player.prepare();
  }

  build() {
    Column({ space: 20 }) {
      Text('FileDescriptor 播放').fontSize(24)
      Button('播放 fd').onClick(() => this.startFD())
    }.padding(30)
  }
}

⑤ RawFile 播放(资源目录)

// RawFilePage.ets
import { media } from '@kit.MediaKit';

@Entry
@Component
struct RawFilePage {
  private player?: media.AVPlayer;

  playRaw() {
    this.player?.release();
    this.player = media.createAVPlayer();
    this.player.url = $rawfile('bgm.mp3'); // 直接指向 resources/rawfile/bgm.mp3
    this.player.on('stateChange', (s) => s === 'prepared' && this.player!.play());
    this.player.prepare();
  }

  build() {
    Column({ space: 20 }) {
      Text('RawFile 播放').fontSize(24)
      Button('播放').onClick(() => this.playRaw())
    }.padding(30)
  }
}

⑥ 多路混音(三路正弦波)

// MixerPage.ets
import { audio } from '@kit.AudioKit';

@Entry
@Component
struct MixerPage {
  private mix?: audio.AudioRenderer;

  async startMixer() {
    const opts: audio.AudioRendererOptions = {
      streamInfo: {
        samplingRate: 48000,
        channels: 2,
        sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
        encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW
      },
      rendererInfo: { usage: audio.StreamUsage.STREAM_USAGE_GAME }
    };
    this.mix = await audio.createAudioRenderer(opts);

    const freqs = [220, 440, 660];
    let phase = [0, 0, 0];

    this.mix.on('writeData', (buf) => {
      const out = new Int16Array(buf);
      const amp = 0x7fff * 0.1;
      for (let i = 0; i < out.length; i += 2) {
        let l = 0, r = 0;
        for (let ch = 0; ch < 3; ++ch) {
          const v = Math.sin(phase[ch] * 2 * Math.PI / 48000) * amp;
          l += v; r += v;
          phase[ch] += freqs[ch];
        }
        out[i] = l; out[i + 1] = r;
      }
    });
    this.mix.start();
  }

  stopMixer() {
    this.mix?.stop().then(() => this.mix?.release());
    this.mix = undefined;
  }

  build() {
    Column({ space: 20 }) {
      Text('三音混音').fontSize(24)
      Row({ space: 20 }) {
        Button('启动').onClick(() => this.startMixer())
        Button('停止').onClick(() => this.stopMixer())
      }
    }.padding(30)
  }
}

⑦ OpenSL ES(Native C++ 完整)

// native-opensl.cpp
#include <SLES/OpenSLES.h>
#include <SLES/OpenSLES_Android.h>
#include <cstdlib>

static SLObjectItf engineObj = nullptr, mixObj = nullptr, playerObj = nullptr;
static SLPlayItf playItf = nullptr;
static SLBufferQueueItf bqItf = nullptr;

constexpr int kSampleRate = 48000;
constexpr int kBufSize = 1024;
int16_t pcmBuf[kBufSize];

void playerCallback(SLBufferQueueItf bq, void *context) {
  for (int i = 0; i < kBufSize; ++i)
    pcmBuf[i] = (int16_t)(sin(i * 2 * M_PI * 440 / kSampleRate) * 0x7fff * 0.2);
  (*bq)->Enqueue(bq, pcmBuf, sizeof(pcmBuf));
}

extern "C" void startOpenSL() {
  slCreateEngine(&engineObj, 0, nullptr, 0, nullptr, nullptr);
  (*engineObj)->Realize(engineObj, SL_BOOLEAN_FALSE);
  SLEngineItf engine;
  (*engineObj)->GetInterface(engineObj, SL_IID_ENGINE, &engine);

  (*engine)->CreateOutputMix(engine, &mixObj, 0, nullptr, nullptr);
  (*mixObj)->Realize(mixObj, SL_BOOLEAN_FALSE);

  SLDataLocator_AndroidSimpleBufferQueue locBufq = {
    SL_DATALOCATOR_ANDROIDSIMPLEBUFFERQUEUE, 2 };
  SLDataFormat_PCM fmt = {
    SL_DATAFORMAT_PCM, 2, kSampleRate * 1000,
    SL_PCMSAMPLEFORMAT_FIXED_16, SL_PCMSAMPLEFORMAT_FIXED_16,
    SL_SPEAKER_FRONT_LEFT | SL_SPEAKER_FRONT_RIGHT,
    SL_BYTEORDER_LITTLEENDIAN
  };
  SLDataSource audioSrc = { &locBufq, &fmt };
  SLDataLocator_OutputMix locOut = { SL_DATALOCATOR_OUTPUTMIX, mixObj };
  SLDataSink audioSnk = { &locOut, nullptr };

  const SLInterfaceID ids[2] = { SL_IID_BUFFERQUEUE, SL_IID_VOLUME };
  const SLboolean req[2] = { SL_BOOLEAN_TRUE, SL_BOOLEAN_TRUE };
  (*engine)->CreateAudioPlayer(engine, &playerObj, &audioSrc, &audioSnk,
                               2, ids, req);

  (*playerObj)->Realize(playerObj, SL_BOOLEAN_FALSE);
  (*playerObj)->GetInterface(playerObj, SL_IID_PLAY, &playItf);
  (*playerObj)->GetInterface(playerObj, SL_IID_BUFFERQUEUE, &bqItf);
  (*bqItf)->RegisterCallback(bqItf, playerCallback, nullptr);
  (*playItf)->SetPlayState(playItf, SL_PLAYSTATE_PLAYING);
  playerCallback(bqItf, nullptr); // kick start
}

extern "C" void stopOpenSL() {
  if (playItf) (*playItf)->SetPlayState(playItf, SL_PLAYSTATE_STOPPED);
  if (playerObj) (*playerObj)->Destroy(playerObj);
  if (mixObj) (*mixObj)->Destroy(mixObj);
  if (engineObj) (*engineObj)->Destroy(engineObj);
}

⑧ OHAudio(统一 Native,官方推荐)

// native-ohaudio.cpp
#include <ohaudio/native_audiorenderer.h>
#include <cmath>

OH_AudioRenderer *renderer = nullptr;
constexpr int kSampleRate = 48000;
int16_t pcmBuf[1024];

OH_AudioRenderer_Callbacks callbacks = {
  .onWriteData = [](OH_AudioRenderer *r, void *userData, void *buffer,
                    int32_t length) {
    int16_t *out = (int16_t *)buffer;
    for (int i = 0; i < length / 2; ++i)
      out[i] = (int16_t)(sin(i * 2 * M_PI * 440 / kSampleRate) * 0x7fff * 0.2);
  }
};

extern "C" void startOHAudio() {
  OH_AudioRenderer_Create(&renderer);
  OH_AudioRenderer_SetSampleRate(renderer, kSampleRate);
  OH_AudioRenderer_SetChannelCount(renderer, 2);
  OH_AudioRenderer_SetCallback(renderer, &callbacks, nullptr);
  OH_AudioRenderer_Start(renderer);
}

extern "C" void stopOHAudio() {
  OH_AudioRenderer_Stop(renderer);
  OH_AudioRenderer_Destroy(renderer);
}

✅ 总结速查表

场景 首选方案 关键优势说明
音乐/长音频 AVPlayer 支持多种输入源(URI/fd/网络),完善的播放控制
游戏/低延迟 AudioRenderer 底层PCM流处理(10-30ms延迟),实时响应强
按键/提示音 SoundPool 短音频优化(加载快、延迟低至10-20ms)
会议/多路 多 AudioRenderer 混音 支持多路PCM流独立控制与同步混音
需要 Native 开发 OHAudio > OpenSL ES OHAudio为统一新方案(延迟<10ms,面向XR)

下面给出一份 “8 合一超级播放器” 单例

UltraAudioPlayer.ts,把 8 种播放方式全部封装在 一个文件、一个类里。

拷贝到 src/main/ets/services/UltraAudioPlayer.ts 即可使用,零依赖外部代码。


✅ 功能总览

接口方法 播放方式 参数说明 一句话用途
playAV(url) AVPlayer 媒体路径/URI 播放网络/本地长音频(音乐/视频)
playPCM(freq) AudioRenderer 频率值(Hz) 低延迟播放指定频率的正弦波(游戏音效)
playSFX(name) SoundPool 音效资源名 快速触发短音效(按键/射击声)
playFD(path) FileDescriptor 文件路径 零拷贝播放文件(降低资源消耗)
playRaw(name) RawFile 应用内资源名 直接播放打包在应用内的资源文件
startMixer(freqs[]) 多路混音 频率数组[f1, f2..] 实时混合多路PCM流(会议/合成音效)
startOpenSL(freq) OpenSL ES 频率值(Hz) C++层原生低延迟音频开发(跨平台移植)
startOHAudio(freq) OHAudio 频率值(Hz) 新一代统一原生音频接口(XR/极致延迟)

面向接口工厂化播放器

下面给出 工厂模式 的完整落地示例:

  • 一个工厂类负责 按需创建/销毁 8 种播放器实例;
  • 每种播放器实现 独立的生命周期(init / play / stop / release),互不干扰;
  • 使用方 只持有接口,无需关心实现细节。

✅ 1. 通用接口(IPlayer.ts)

// src/main/ets/players/IPlayer.ts
export interface IPlayer {
  init(payload?: any): Promise<void>;  // 初始化
  play(): void;                         // 开始播放
  pause?(): void;                      // 可选暂停
  stop(): void;                        // 停止
  release(): void;                     // 彻底释放
}

✅ 2. 8 种具体播放器(节选 3 个,其余同结构)

① AVPlayerImpl.ts

import { media } from '@kit.MediaKit';
import { IPlayer } from './IPlayer';

export class AVPlayerImpl implements IPlayer {
  private player?: media.AVPlayer;
  private url: string;

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

  async init() {
    this.player = media.createAVPlayer();
    this.player.url = this.url;
    this.player.on('stateChange', (s) => s === 'prepared' && this.player!.play());
    await this.player.prepare();
  }

  play() {
    this.player?.play();
  }

  pause() {
    this.player?.pause();
  }

  stop() {
    this.player?.stop();
  }

  release() {
    this.player?.release();
    this.player = undefined;
  }
}

② AudioRendererImpl.ts

import { audio } from '@kit.AudioKit';
import { IPlayer } from './IPlayer';

export class AudioRendererImpl implements IPlayer {
  private renderer?: audio.AudioRenderer;
  private freq: number;

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

  async init() {
    const opt: audio.AudioRendererOptions = {
      streamInfo: {
        samplingRate: 48000,
        channels: 2,
        sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
        encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW
      },
      rendererInfo: { usage: audio.StreamUsage.STREAM_USAGE_GAME }
    };
    this.renderer = await audio.createAudioRenderer(opt);
    let phase = 0;
    this.renderer.on('writeData', (buf) => {
      const pcm = new Int16Array(buf);
      const amp = 0x7fff * 0.2;
      for (let i = 0; i < pcm.length; i += 2) {
        const v = Math.sin(phase * 2 * Math.PI / 48000) * amp;
        pcm[i] = pcm[i + 1] = v;
        phase += this.freq;
      }
    });
    await this.renderer.start();
  }

  play() { /* 已在 init 里开始 */ }
  stop() {
    this.renderer?.stop();
  }
  release() {
    this.renderer?.release();
    this.renderer = undefined;
  }
}

③ SoundPoolImpl.ts

import { media, audio } from '@kit.MediaKit';
import { resourceManager } from '@kit.LocalizationKit';
import { IPlayer } from './IPlayer';

export class SoundPoolImpl implements IPlayer {
  private sp?: media.SoundPool;
  private soundId = -1;
  private name: string;

  constructor(name: string) {
    this.name = name;
  }

  async init() {
    this.sp = await media.createSoundPool(8, {
      usage: audio.StreamUsage.STREAM_USAGE_SYSTEM
    });
    const rawFd = await resourceManager.getContext().resourceManager.getRawFd(this.name);
    this.soundId = await this.sp.load(rawFd.fd, rawFd.offset, rawFd.length);
  }

  play() {
    this.sp?.play(this.soundId, {
      loop: 0,
      leftVolume: 1,
      rightVolume: 1,
      rate: audio.AudioRendererRate.RENDER_RATE_NORMAL
    });
  }

  stop() { /* SoundPool 无需 stop */ }
  release() {
    this.sp?.release();
    this.sp = undefined;
  }
}

其余 5 种(RawFile / FD / Mixer / OpenSL / OHAudio)按同样结构实现即可。


✅ 3. 工厂类(PlayerFactory.ts)

// src/main/ets/factory/PlayerFactory.ts
import { AVPlayerImpl } from '../players/AVPlayerImpl';
import { AudioRendererImpl } from '../players/AudioRendererImpl';
import { SoundPoolImpl } from '../players/SoundPoolImpl';
import { IPlayer } from '../players/IPlayer';

export type PlayerType =
  | 'AV'
  | 'PCM'
  | 'SFX'
  | 'RAW'
  | 'FD'
  | 'MIXER'
  | 'OPENSL'
  | 'OHAUDIO';

export class PlayerFactory {
  static create(type: PlayerType, payload?: any): IPlayer {
    switch (type) {
      case 'AV':
        return new AVPlayerImpl(payload.url);
      case 'PCM':
        return new AudioRendererImpl(payload.freq);
      case 'SFX':
        return new SoundPoolImpl(payload.name);
      // 其余同理
      default:
        throw new Error(`Unsupported player type: ${type}`);
    }
  }
}

✅ 4. 使用示例(完全独立)

// Index.ets
import { PlayerFactory, PlayerType } from '../factory/PlayerFactory';

@Entry
@Component
struct HomePage {
  private player?: IPlayer;

  async onClick(type: PlayerType) {
    // 1. 释放旧实例
    this.player?.release();

    // 2. 按需创建新实例
    this.player = PlayerFactory.create(type, this.payload(type));

    // 3. 初始化 & 播放
    await this.player.init();
    this.player.play();
  }

  payload(type: PlayerType) {
    switch (type) {
      case 'AV':   return { url: 'https://xxx.mp3' };
      case 'PCM':  return { freq: 440 };
      case 'SFX':  return { name: 'click.mp3' };
      default:     return {};
    }
  }

  onStop() {
    this.player?.stop();
    this.player?.release();
    this.player = undefined;
  }

  build() {
    Column({ space: 15 }) {
      Text('工厂模式独立播放器').fontSize(24)
      ForEach(['AV', 'PCM', 'SFX'] as PlayerType[], (t) =>
        Button(`${t}`).onClick(() => this.onClick(t))
      )
      Button('停止并释放').onClick(() => this.onStop())
    }
    .padding(20)
  }
}

✅ 5. 目录结构

src/main/ets/
 ├─ factory/
 │   └─ PlayerFactory.ts
 ├─ players/
 │   ├─ IPlayer.ts
 │   ├─ AVPlayerImpl.ts
 │   ├─ AudioRendererImpl.ts
 │   ├─ SoundPoolImpl.ts
 │   ├─ RawFileImpl.ts
 │   ├─ FDImpl.ts
 │   ├─ MixerImpl.ts
 │   ├─ OpenSLImpl.ts
 │   └─ OHAudioImpl.ts

✅ 6. 好处

  1. 完全独立:各播放器互不干扰,可并行存在。
  2. 易扩展:新增一种播放器只需实现 IPlayer + 在工厂注册。
  3. 易测试:单元测试可单独实例化任意播放器。
  4. 内存安全:使用方持有接口即可,生命周期自己掌握。

每日一题-6 和 9 组成的最大数字🟢

给你一个仅由数字 6 和 9 组成的正整数 num

你最多只能翻转一位数字,将 6 变成 9,或者把 9 变成 6 。

请返回你可以得到的最大数字。

 

示例 1:

输入:num = 9669
输出:9969
解释:
改变第一位数字可以得到 6669 。
改变第二位数字可以得到 9969 。
改变第三位数字可以得到 9699 。
改变第四位数字可以得到 9666 。
其中最大的数字是 9969 。

示例 2:

输入:num = 9996
输出:9999
解释:将最后一位从 6 变到 9,其结果 9999 是最大的数。

示例 3:

输入:num = 9999
输出:9999
解释:无需改变就已经是最大的数字了。

 

提示:

  • 1 <= num <= 10^4
  • num 每一位上的数字都是 6 或者 9 。

来看看Trae生成的粒子效果是怎么样的

前言

你是否看见过有些网站的背景是动态粒子效果,今天我们就来问问Trae是如何实现这样的效果。

看看Trae理解的动态粒子效果是什么,以及他是如何实现的,下面是Trae的理解。

image.png

先来看看最终的效果,非常适合夜店,啊哈哈哈哈 image.png

看看Trae是怎么理解科技感的例子背景的

  1. 采用赛博朋克风格的霓虹色彩:青色、品红、绿色、橙色等,并且是随机变化的发光效果
  2. 多层次视觉效果,粒子层、节点层、网络层、连线层
  3. 还有科技元素,模拟数据中心的脉冲节点,非常适合眼前一亮的感觉 image.png

代码是怎么实现的

动态粒子效果

动态粒子效果,就是让网页中的元素像粒子一样,在网页中随机运动,形成一种动态的效果。这种效果可以让网页看起来更加生动,增加用户的体验,往往是随机生成的。

Trae的实现思路

实现动态粒子效果,需要以下几个步骤:

  1. 创建一个包含多个粒子的数组。
  2. 为每个粒子设置初始位置、速度和颜色。
  3. 在网页中创建一个画布,用于绘制粒子。
  4. 使用JavaScript的定时器,每隔一段时间更新粒子的位置,并重新绘制画布。
  5. 在画布上绘制每个粒子的形状和颜色。
  6. 为粒子添加运动效果,例如改变位置、速度或颜色。
  7. 重复步骤4和5,使粒子持续运动。
  8. 为了增加效果,还可以添加粒子之间的交互效果,例如碰撞检测和 repulsion。

绘制动态例子的方法,包括轨迹、边界相关的反弹

drawParticles() {
                this.particles.forEach((particle, index) => {
                    // 更新位置
                    particle.x += particle.vx;
                    particle.y += particle.vy;

                    // 边界反弹
                    if (particle.x <= 0 || particle.x >= this.canvas.width) {
                        particle.vx *= -1;
                    }
                    if (particle.y <= 0 || particle.y >= this.canvas.height) {
                        particle.vy *= -1;
                    }

                    // 鼠标交互
                    const dx = this.mouse.x - particle.x;
                    const dy = this.mouse.y - particle.y;
                    const distance = Math.sqrt(dx * dx + dy * dy);
                    
                    if (distance < 100) {
                        const force = (100 - distance) / 100;
                        particle.vx -= (dx / distance) * force * 0.5;
                        particle.vy -= (dy / distance) * force * 0.5;
                    }

                    // 添加轨迹
                    particle.trail.push({ x: particle.x, y: particle.y, color: particle.color });
                    if (particle.trail.length > particle.maxTrail) {
                        particle.trail.shift();
                    }

                    // 绘制轨迹
                    this.drawTrail(particle);

                    // 绘制粒子
                    this.ctx.beginPath();
                    this.ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
                    this.ctx.fillStyle = particle.color;
                    this.ctx.shadowBlur = 10;
                    this.ctx.shadowColor = particle.color;
                    this.ctx.fill();
                    this.ctx.shadowBlur = 0;
                });
            }

绘制节点层,主要是外圈光晕和内圈的节点组合

drawNodes() {
                this.nodes.forEach(node => {
                    node.pulse += node.pulseSpeed;
                    const scale = 1 + Math.sin(node.pulse) * 0.3;
                    
                    // 外圈光晕
                    this.ctx.beginPath();
                    this.ctx.arc(node.x, node.y, node.size * scale * 2, 0, Math.PI * 2);
                    this.ctx.fillStyle = `rgba(0, 255, 255, 0.1)`;
                    this.ctx.fill();
                    
                    // 内圈节点
                    this.ctx.beginPath();
                    this.ctx.arc(node.x, node.y, node.size * scale, 0, Math.PI * 2);
                    this.ctx.fillStyle = '#00ffff';
                    this.ctx.shadowBlur = 15;
                    this.ctx.shadowColor = '#00ffff';
                    this.ctx.fill();
                    this.ctx.shadowBlur = 0;
                });
            }

动画函数,每帧调用,确保用户看到动态效果

animate() {
                this.ctx.fillStyle = 'rgba(10, 10, 10, 0.1)';
                this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);

                this.drawConnections();
                this.drawNodes();
                this.drawParticles();

                requestAnimationFrame(() => this.animate());
            }

总结

动态粒子效果是一种非常有趣的效果,可以让网页看起来更加生动。实现动态粒子效果需要一定的编程技巧,但是通过一些简单的步骤,就可以实现出这种效果。

现在可以借助ai帮你快速实现,但是要实现你要的效果,可能要花点时间去跟Trae沟通,一步步实现。

希望这篇文章能够帮助你理解动态粒子效果,并实现你自己的效果。

前端实现自动检测项目部署更新

概述

用户在访问单页面网站时,如果生产环境已经发布了新的版本(有功能上的变化),由于单页面中路由特性或浏览器缓存的原因,并不会重新加载前端资源,此时用户浏览器所并非加载是最新的代码,从而可能遇到一些 bug。因此,部署之后,需要提醒用户版本更新,并引导用户刷新页面。

方案

使用轮询的方式请求index.html文件,从中解析里面的js文件,由于工程项目打包后每个js文件都有指纹标识(打包工具自动处理了文件hash,打包后没有需要配置文件hash),因此对比每次打包后的指纹,分析文件是否存在变动,如果有变动则提示用户更新,流程如下:

deepseek_mermaid_20250815_409482.png

实现

新建auto-update.ts,内容如下


import { ElMessageBox } from 'element-plus'

// 上一次获取到的script地址列表,用于比较是否有更新
let initScriptSrcList: string[] = [];

// 正则表达式用于匹配HTML中的script标签的src属性
const scriptReg = /<script.*src=["'](?<src>[^"']+)/gm;

/**
 * 从当前页面HTML中提取所有script标签的src地址
 * @returns {Promise<string[]>} 返回包含所有script src的数组
 */
const extractNewScripts = async () => {
    // 添加时间戳参数避免缓存
    const param = Date.now()
    // 获取当前页面HTML内容
    const html = await fetch('/?_time=' + param).then((resp) => resp.text());
    
    // 重置正则表达式匹配位置
    scriptReg.lastIndex = 0;
    let result = [];
    let match: RegExpExecArray
    
    // 遍历匹配所有script标签的src属性
    while ((match = scriptReg.exec(html) as RegExpExecArray) {
        result.push(match.groups?.src)
    }
    return result;
}

/**
 * 检查是否有新的脚本更新
 * @returns {Promise<boolean>} 返回true表示有更新,false表示无更新
 */
const isUpdate = async () => {
    // 获取当前页面所有script的src
    const newScripts: string[] = await extractNewScripts();
    
    // 如果是第一次检查,初始化脚本列表并返回无更新
    if (!initScriptSrcList.length) {
        initScriptSrcList = newScripts;
        return false;
    }
    
    let res = false;
    
    // 比较脚本数量是否有变化
    if (initScriptSrcList.length !== newScripts.length) {
        res = true;
    }
    
    // 逐个比较脚本地址是否有变化
    for (let i = 0; i < initScriptSrcList.length; i++) {
        if (initScriptSrcList[i] !== newScripts[i]) {
            res = true;
            break
        }
    }
    
    // 更新初始脚本列表为当前最新
    initScriptSrcList = newScripts;
    return res;
}

// 检查间隔时间(5秒)
const DELAY_TIME = 5000;

/**
 * 自动刷新功能入口
 * 每隔指定时间检查一次脚本更新,有更新时提示用户刷新页面
 */
export const autoRefresh = () => {
    setTimeout(async () => {
        // 检查是否需要更新
        const needUpdate = await isUpdate();
        
        if (needUpdate) {
            console.log('检测到页面有内容更新,自动刷新');
            
            // 使用Element Plus的MessageBox提示用户
            ElMessageBox.confirm('检测到页面有内容更新,是否立即刷新?', '更新提示', {
                confirmButtonText: '确认',
                showCancelButton: false,  // 不显示取消按钮
                type: 'warning'          // 警告类型提示框
            }).then(() => {
                // 用户确认后刷新页面
                location.reload();
            })
        }
        
        // 递归调用自身,实现持续检查
        autoRefresh();
    }, DELAY_TIME)
}

使用

我这里使用的是vite+vue的形式,在app.vue文件中

<script setup lang="ts">
import HelloWorld from "./components/HelloWorld.vue";
import { onMounted } from "vue";
import { autoRefresh } from "./utils/auto-update";

onMounted(() => {
//生产环境开启检测
  if (import.meta.env.MODE == "production") {
    autoRefresh();
  }
});
</script>

监听设备网络状态

概述

在前端应用中监听网络状态是一个常见的需求,可以用于优化离线体验、提示用户网络变化等。

场景

  • 移动端项目,有大量的图片需要访问,在4g或者wifi的网络状态下做不同的优化
  • 在后台系统访问,有时候会遇到断网或者网络不好得时候,所以这里需求是在请求发送之前判断网络环境,如果断网给出提示,弱网超过一定时间提示请求超时

统一封装网络状态监控函数

image.png

属性概念

downlink

当前网络连接的估计下行速度(单位为 Mbps)

effectiveType

当前网络连接的估计速度类型(如 slow-2g、2g、3g、4g 等)

rtt

当前网络连接的估计往返时间(单位为毫秒),表示设备当前的往返延迟时间(Round-Trip Time),以毫秒为单位。它是从设备发送数据到服务器并返回的时间。

saveData

是否处于数据节省模式,表示用户设备当前是否处于节省数据模式。可能的取值为 true(用户启用了节省数据模式)或 false(用户未启用节省数据模式)

实现

function getNetWorkInfo() {
    let info;
    if (navigator.onLine) {
        info = {
            status: "online",
            type: navigator.connection.effectiveType,
            rtt: navigator.connection.rtt,
            downlink: navigator.connection.downlink,
        };
    } else {
        info = {
            status: "offline",
        };
    }
    return info;
}

window.addEventListener("online", () => {
    getNetWorkInfo();
});
window.addEventListener("offline", () => {
    getNetWorkInfo();
});
navigator.connection.addEventListener("change", () => {
    getNetWorkInfo();
});

两种方法:用字符串/不用字符串(Python/Java/C++/C/Go/JS/Rust)

分析

要想把数字变大,只能把 $6$ 改成 $9$。

比如 $\textit{num}=9669$:

  • 改高位的 $6$,得到 $9969$。
  • 改低位的 $6$,得到 $9699$。
  • $9969 > 9699$。

改高位的 $6$ 比改低位的 $6$ 更好。由于至多改一次,所以改最高位的 $6$。若 $\textit{num}$ 无 $6$,则 $\textit{num}$ 不变。

方法一:用字符串

###py

class Solution:
    def maximum69Number(self, num: int) -> int:
        s = str(num).replace('6', '9', 1)  # 替换第一个 6
        return int(s)

###py

class Solution:
    def maximum69Number(self, num: int) -> int:
        s = str(num)
        i = s.find('6')
        if i < 0:
            return num
        return int(s[:i] + '9' + s[i + 1:])

###java

class Solution {
    public int maximum69Number(int num) {
        String s = String.valueOf(num).replaceFirst("6", "9"); // 替换第一个 6
        return Integer.parseInt(s);
    }
}

###java

class Solution {
    public int maximum69Number(int num) {
        String s = Integer.toString(num);
        int i = s.indexOf('6');
        if (i < 0) {
            return num;
        }
        s = s.substring(0, i) + "9" + s.substring(i + 1);
        return Integer.parseInt(s);
    }
}

###cpp

class Solution {
public:
    int maximum69Number(int num) {
        string s = to_string(num);
        int i = s.find('6');
        if (i == string::npos) {
            return num;
        }
        s[i] = '9';
        return stoi(s);
    }
};

###c

int maximum69Number(int num) {
    char s[6];
    sprintf(s, "%d", num);
    char* p = strchr(s, '6');
    if (p == NULL) {
        return num;
    }
    *p = '9';
    return atoi(s);
}

###go

func maximum69Number(num int) int {
s := strconv.Itoa(num)
s = strings.Replace(s, "6", "9", 1) // 替换第一个 6
ans, _ := strconv.Atoi(s)
return ans
}

###js

var maximum69Number = function(num) {
    const s = String(num).replace('6', '9'); // 替换第一个 6
    return parseInt(s);
};

###rust

impl Solution {
    pub fn maximum69_number(num: i32) -> i32 {
        num.to_string()
           .replacen("6", "9", 1) // 替换第一个 6
           .parse()
           .unwrap()
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(\log \textit{num})$。
  • 空间复杂度:$\mathcal{O}(\log \textit{num})$。

方法二:不用字符串

可以不用转成字符串处理,而是不断取最低位(模 $10$),去掉最低位(除以 $10$),直到数字为 $0$。

例如 $\textit{num}=9669$:

  1. 初始化 $x=\textit{num}$。
  2. 通过 $x\bmod 10$ 取到个位数 $9$,然后把 $x$ 除以 $10$(下取整),得到 $x=966$。
  3. 再次 $x\bmod 10$ 取到十位数 $6$,然后把 $x$ 除以 $10$(下取整),得到 $x=96$。
  4. 再次 $x\bmod 10$ 取到百位数 $6$,然后把 $x$ 除以 $10$(下取整),得到 $x=9$。
  5. 最后 $x\bmod 10$ 取到千位数 $9$,然后把 $x$ 除以 $10$(下取整),得到 $x=0$。此时完成了遍历 $\textit{num}$ 的每个数位,退出循环。

在这个过程中,维护一个变量 $\textit{base}$,初始值为 $1$,在循环末尾把 $\textit{base}$ 乘以 $10$。每当我们遍历到一个等于 $6$ 的数位,就保存此刻的 $\textit{base}$,即更新 $\textit{maxBase} = \textit{base}$。其中 $\textit{maxBase}$ 初始值为 $0$。由于我们从低位往高位遍历,所以最终的 $\textit{maxBase}$ 就是最高位的 $6$ 对应的 $\textit{base}$。在上面的例子中,我们可以得到 $\textit{maxBase} = 100$。

最终答案为

$$
\textit{num} + \textit{maxBase}\cdot 3
$$

在上面的例子中,答案为 $9669 + 100\cdot 3 = 9969$。

注:如果 $\textit{num}$ 中没有 $6$,由于我们初始化 $\textit{maxBase}=0$,最终答案为 $\textit{num}$。

###py

class Solution:
    def maximum69Number(self, num: int) -> int:
        max_base = 0
        base = 1
        x = num
        while x:
            x, d = divmod(x, 10)
            if d == 6:
                max_base = base
            base *= 10
        return num + max_base * 3

###java

class Solution {
    public int maximum69Number(int num) {
        int maxBase = 0;
        int base = 1;
        for (int x = num; x > 0; x /= 10) {
            if (x % 10 == 6) {
                maxBase = base;
            }
            base *= 10;
        }
        return num + maxBase * 3;
    }
}

###cpp

class Solution {
public:
    int maximum69Number(int num) {
        int max_base = 0;
        int base = 1;
        for (int x = num; x > 0; x /= 10) {
            if (x % 10 == 6) {
                max_base = base;
            }
            base *= 10;
        }
        return num + max_base * 3;
    }
};

###c

int maximum69Number(int num) {
    int max_base = 0;
    int base = 1;
    for (int x = num; x > 0; x /= 10) {
        if (x % 10 == 6) {
            max_base = base;
        }
        base *= 10;
    }
    return num + max_base * 3;
}

###go

func maximum69Number(num int) int {
maxBase := 0
base := 1
for x := num; x > 0; x /= 10 {
if x%10 == 6 {
maxBase = base
}
base *= 10
}
return num + maxBase*3
}

###js

var maximum69Number = function(num) {
    let maxBase = 0;
    let base = 1;
    for (let x = num; x > 0; x = Math.floor(x / 10)) {
        if (x % 10 === 6) {
            maxBase = base;
        }
        base *= 10;
    }
    return num + maxBase * 3;
};

###rust

impl Solution {
    pub fn maximum69_number(num: i32) -> i32 {
        let mut max_base = 0;
        let mut base = 1;
        let mut x = num;
        while x > 0 {
            if x % 10 == 6 {
                max_base = base;
            }
            base *= 10;
            x /= 10;
        }
        num + max_base * 3
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(\log \textit{num})$。
  • 空间复杂度:$\mathcal{O}(1)$。

思考题

  1. 改成求最小数字。
  2. 额外输入一个正整数 $k$,至多翻转 $k$ 个数,返回可以得到的最大数字。
  3. 给定正整数 $\textit{low}$ 和 $\textit{high}$,计算闭区间 $[\textit{low},\textit{high}]$ 中的所有整数 $x$ 的 $\texttt{maximum69Number}(x)$ 之和。

欢迎在评论区分享你的思路/代码。

专题训练

见下面贪心题单的「§3.1 字典序最小/最大」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

6 和 9 组成的最大数字

方法一:贪心 + 字符串

思路与算法

此题要求将一个由数字 $6$ 和 $9$ 构成的十进制数 $\textit{num}$ 进行至多一次 $6$ 和 $9$ 之间的翻转得到的最大数字。由十进制数的性质易知:贪心的选择数位最高的一个 $6$ 变成 $9$,得到的答案就是最大的。如果不存在这样的 $6$,则说明这个数字全由数字 $9$ 构成。根据题意,此时不对 $\textit{num}$ 做任何更改即为最优解。

对于算法实现,我们使用字符数组来处理 $\textit{num}$ 较为方便。首先将 $\textit{num}$ 转为字符数组,然后从左到遍历字符,按上述算法处理。最后将修改后的字符数组转回数字即为所求。

代码

###C++

class Solution {
public:
    int maximum69Number (int num) {
        string s = to_string(num);
        for (char &c : s) {
            if (c == '6') {
                c = '9';
                break;
            }
        }
        return stoi(s);
    }
};

###Java

public class Solution {
    public int maximum69Number (int num) {
        char[] chars = Integer.toString(num).toCharArray();
        for (int i = 0; i < chars.length; i++) {
            if (chars[i] == '6') {
                chars[i] = '9';
                break;
            }
        }
        return Integer.parseInt(new String(chars));
    }
}

###C#

public class Solution {
    public int Maximum69Number (int num) {
        char[] chars = num.ToString().ToCharArray();
        for (int i = 0; i < chars.Length; i++) {
            if (chars[i] == '6') {
                chars[i] = '9';
                break;
            }
        }
        return int.Parse(new string(chars));
    }
}

###Go

func maximum69Number(num int) int {
    s := strconv.Itoa(num)
    chars := []byte(s)
    for i := 0; i < len(chars); i++ {
        if chars[i] == '6' {
            chars[i] = '9'
            break
        }
    }
    result, _ := strconv.Atoi(string(chars))
    return result
}

###Python

class Solution:
    def maximum69Number (self, num: int) -> int:
        s = list(str(num))
        for i in range(len(s)):
            if s[i] == '6':
                s[i] = '9'
                break
        return int(''.join(s))

###C

int maximum69Number(int num) {
    char s[20];
    sprintf(s, "%d", num);
    for (int i = 0; s[i] != '\0'; i++) {
        if (s[i] == '6') {
            s[i] = '9';
            break;
        }
    }
    return atoi(s);
}

###Rust

impl Solution {
    pub fn maximum69_number (num: i32) -> i32 {
        let mut s = num.to_string().chars().collect::<Vec<char>>();
        for i in 0..s.len() {
            if s[i] == '6' {
                s[i] = '9';
                break;
            }
        }
        s.iter().collect::<String>().parse().unwrap()
    }
}

###JavaScript

var maximum69Number = function (num) {
    let charArray = [...num.toString()];

    for (let i = 0; i < charArray.length; i++) {
        if (charArray[i] === '6') {
            charArray[i] = '9';
            break;
        }
    }

    return Number(charArray.join(''));
};

###TypeScript

function maximum69Number(num: number): number {
    let charArray = [...num.toString()];

    for (let i = 0; i < charArray.length; i++) {
        if (charArray[i] === '6') {
            charArray[i] = '9';
            break;
        }
    }

    return Number(charArray.join(''));
};

复杂度分析

  • 时间复杂度:$O(\log \textit{num})$。

  • 空间复杂度:$O(\log \textit{num})$。

方法二:贪心 + 数学

思路与算法

思想同方法一,但是不依赖字符串操作,而是通过纯数学的方式找到最高位的 $6$ 并更改为 $9$。

首先初始化一个基数 $\textit{digitBase} = 10^{\lfloor \log_{10}(\textit{num}) \rfloor}$,这个基数代表了 $\textit{num}$ 的最高位。然后从高位向低位遍历,每次将 $\textit{digitBase}$ 除以 $10$。在每一次循环中,通过 $\lfloor \textit{num} \div \textit{digitBase} \rfloor \bmod 10$ 来获取当前基数 $\textit{digitBase}$ 所在的十进制位上的数字。一旦这个数字等于 $6$,我们就可以确定这就是需要修改的最高位的 $6$。此时,我们将 $\textit{num}$ 加上 $3 \times \textit{digitBase}$,即可将该位的 $6$ 修改为 $9$,结果即为所求。

代码

###C++

class Solution {
public:
    int maximum69Number (int num) {
        int digitBase = pow(10, (int)log10(num));
        while (digitBase > 0) {
            if ((num / digitBase) % 10 == 6) {
                num += 3 * digitBase;
                return num;
            }
            digitBase /= 10;
        }
        
        return num;
    }
};

###Java

public class Solution {
    public int maximum69Number (int num) {
        int digitBase = (int)Math.pow(10, (int)Math.log10(num));
        while (digitBase > 0) {
            if ((num / digitBase) % 10 == 6) {
                num += 3 * digitBase;
                return num;
            }
            digitBase /= 10;
        }
        
        return num;
    }
}

###C#

public class Solution {
    public int Maximum69Number (int num) {
        int digitBase = (int)Math.Pow(10, (int)Math.Log10(num));
        while (digitBase > 0) {
            if ((num / digitBase) % 10 == 6) {
                num += 3 * digitBase;
                return num;
            }
            digitBase /= 10;
        }
        
        return num;
    }
}

###Go

func maximum69Number(num int) int {
    digitBase := int(math.Pow10(int(math.Log10(float64(num)))))
    for digitBase > 0 {
        if (num / digitBase) % 10 == 6 {
            num += 3 * digitBase
            return num
        }
        digitBase /= 10
    }
    
    return num
}

###Python

class Solution:
    def maximum69Number (self, num: int) -> int:
        digit_base = 10 ** int(math.log10(num)) if num != 0 else 0
        while digit_base > 0:
            if (num // digit_base) % 10 == 6:
                num += 3 * digit_base
                return num
            digit_base = digit_base // 10
        
        return num

###C

int maximum69Number(int num) {
    int digitBase = pow(10, (int)log10(num));
    while (digitBase > 0) {
        if ((num / digitBase) % 10 == 6) {
            num += 3 * digitBase;
            return num;
        }
        digitBase /= 10;
    }
    
    return num;
}

###Rust

impl Solution {
    pub fn maximum69_number (num: i32) -> i32 {
        let mut digit_base = 10i32.pow((num as f32).log10() as u32);
        let mut num = num;
        while digit_base > 0 {
            if (num / digit_base) % 10 == 6 {
                num += 3 * digit_base;
                return num;
            }
            digit_base /= 10;
        }
        
        num
    }
}

###JavaScript

var maximum69Number = function (num) {
    let digitBase = Math.pow(10, Math.trunc(Math.log10(num)));

    while (digitBase > 0) {
        if (Math.trunc(num / digitBase) % 10 === 6) {
            num += 3 * digitBase;
            return num;
        }
        digitBase = Math.trunc(digitBase / 10);
    }

    return num;
};

###TypeScript

function maximum69Number(num: number): number {
    let digitBase = Math.pow(10, Math.trunc(Math.log10(num)));

    while (digitBase > 0) {
        if (Math.trunc(num / digitBase) % 10 === 6) {
            num += 3 * digitBase;
            return num;
        }
        digitBase = Math.trunc(digitBase / 10);
    }

    return num;
};

复杂度分析

  • 时间复杂度:$O(\log \textit{num})$。

  • 空间复杂度:$O(1)$。

C 不转换字符串

解题思路

循环看看哪个6最靠前,然后加上3的x次幂即可
(4ms, 61.64%; 6.8MB, 100.00%)

代码

###c

#include <Math.h>
int maximum69Number (int num){
    int count = 0, th = 0;          // count 记录除了多少次,th记录最大的6在第几位
    int re = num;
    while(re){
        count++;
        if(re%10==6)
           th = count;
        re /= 10;
    }
    return num+3*pow(10,th-1);
}

为何我的figma-developer-mcp不可用?

问题诊断结果

figma-developer-mcp 包要求的最低版本是:

  • ^18.17.0 或 ^20.3.0 或 >=21.0.0

我的版本 18.16.0 低于要求的 18.17.0,所以无法安装和运行。

figma MCP 的配置

这个windows的配置

{
  "mcpServers": {
    "Figma": {
      "command": "cmd",
      "args": [
        "/c",
        "npx",
        "-y",
        "figma-developer-mcp",
        "--figma-api-key=你的tokne",
        "--stdio"
      ]
    }
  }
}

解决方案

方案1:升级Node.js到18.17.0+(推荐)

  1. 访问 Node.js官网 下载最新的LTS版本(推荐20.x或更高)
  1. 安装新版本Node.js
  1. 重启命令行工具
  1. 重启Cursor

方案2:使用nvm-windows管理Node.js版本

如果你需要管理多个Node.js版本:

  1. 安装 nvm-windows
  1. 运行命令:

    bash

       nvm install 20.11.0

       nvm use 20.11.0

升级完成后

  1. 重启Cursor
  1. 验证配置 - 查看MCP状态是否显示可用工具
  1. 测试功能 - 在Cursor中粘贴Figma链接试试

升级Node.js后,你的Figma MCP应该就能正常工作了!记得升级完重启Cursor哦。

p5.js 3D盒子的基础用法

点赞 + 关注 + 收藏 = 学会了

如果你刚接触 p5.js,想尝试 3D 绘图,那么box()函数绝对是你的入门首选。它能快速绘制出 3D 长方体(或正方体),配合简单的交互就能做出酷炫的 3D 效果。本文会从基础到进阶,带你吃透这个实用 API。

box

box()是 p5.js 中专门用于绘制 3D 立方体的函数。它具有以下几个特点:

  • 自带 6 个面,每个面都和相邻面成 90° 角(直角);
  • 必须在WebGL 模式下使用(3D 绘图的基础模式);
  • 参数灵活,可通过调整参数控制大小和细节。

基础用法

要使用box(),首先得创建支持 3D 的画布。记住:必须用WEBGL模式,否则盒子不会显示!

01.gif

function setup() {
  // 创建300x300的WebGL画布(支持3D)
  createCanvas(300, 300, WEBGL);
}

function draw() {
  background(220); // 灰色背景
  orbitControl();  // 允许鼠标拖动旋转视角(必加!否则3D效果看不出来)
  box(); // 绘制默认盒子
}

运行后,你会看到一个灰色背景上的白色盒子。拖动鼠标可以 360° 旋转查看。

box()有 5 个可选参数,分别控制盒子的尺寸和表面细节。记住,参数是按顺序生效的。

box([width], [height], [depth], [detailX], [detailY])

width(宽度)

  • 作用:控制盒子沿 X 轴的长度;
  • 默认值:50;
  • 示例:box(100) → 宽度为 100,其他尺寸默认(高度 = 宽度,深度 = 高度)。

height(高度)

  • 作用:控制盒子沿 Y 轴的长度;
  • 默认值:等于 width(所以只传 1 个参数时是正方体);
  • 示例:box(100, 200) → 宽 100,高 200,深度默认等于高度(200)。

depth(深度)

  • 作用:控制盒子沿 Z 轴的长度(3D 的 "厚度");
  • 默认值:等于 height;
  • 示例:box(100, 200, 50) → 宽 100,高 200,深 50(扁平状)。

detailX(X 轴细分)

  • 作用:控制盒子表面沿 X 轴的三角形细分数量(细分越多,表面越平滑,但性能消耗略高);
  • 默认值:1(最基础的细分,棱角明显);
  • 示例:box(100, 100, 100, 5) → X 轴用 5 个细分,表面更细腻。

detailY(Y 轴细分)

  • 作用:控制盒子表面沿 Y 轴的三角形细分数量;
  • 默认值:1;
  • 示例:box(100, 100, 100, 5, 8) → X 轴 5 细分,Y 轴 8 细分,表面更平滑。

上色和动画

一个彩色的长方体,会缓慢旋转,颜色随时间从红→绿→蓝循环变化,拖动鼠标可从任意角度观察。

02.gif

function setup() {
  createCanvas(400, 400, WEBGL);
}

function draw() {
  background(0); // 黑色背景
  orbitControl();
  
  // 随时间旋转(X和Y轴同时转)
  rotateX(frameCount * 0.01); // frameCount是当前帧数,让旋转随时间变化
  rotateY(frameCount * 0.01);
  
  // 彩色盒子(HSL颜色模式:色相随时间变化)
  fill(frameCount % 360, 100, 50); // 色相0-360循环,饱和度100,亮度50
  
  // 尺寸:宽150,高100,深80,细分3(表面更平滑)
  box(150, 100, 80, 3);
}

跳动的彩色盒子阵列

box()做一个酷炫的特效:多个盒子组成阵列,随鼠标位置和时间跳动,颜色也动态变化。

03.gif

let spacing = 120; // 盒子间距
let boxSize = 60; // 基础大小

function setup() {
  createCanvas(800, 600, WEBGL);
  noFill(); // 无填充,只显示边框
  strokeWeight(2); // 边框粗细
}

function draw() {
  background(0);
  orbitControl();
  
  // 旋转整个场景,增强3D感
  rotateX(-0.3);
  rotateY(frameCount * 0.005);
  
  // 绘制3x3阵列的盒子
  for (let x = -spacing; x <= spacing; x += spacing) {
    for (let y = -spacing; y <= spacing; y += spacing) {
      for (let z = -spacing; z <= spacing; z += spacing) {
        push(); // 保存当前状态
          translate(x, y, z); // 移动到目标位置
          
          // 随时间和鼠标位置变化大小(跳动效果)
          let size = boxSize * 0.5 + sin(frameCount * 0.05 + x*0.1 + mouseX*0.01) * 20;
          
          // 颜色随位置变化
          stroke(x + 200, y + 200, z + 200);
          
          box(size); // 绘制盒子
        pop(); // 恢复状态
      }
    }
  }
}

以上就是本文的全部内容啦,想了解更多 P5.js 用法欢迎关注 《P5.js中文教程》

也可以➕我 green bubble 吹吹水咯

qrcode.jpeg

点赞 + 关注 + 收藏 = 学会了

Threejs源码系列- Object3D

Object3D

three.js核心类

const _v1 = /*@__PURE__*/ new Vector3();
const _q1 = /*@__PURE__*/ new Quaternion();
const _m1 = /*@__PURE__*/ new Matrix4();
const _target = /*@__PURE__*/ new Vector3();

const _position = /*@__PURE__*/ new Vector3();
const _scale = /*@__PURE__*/ new Vector3();
const _quaternion = /*@__PURE__*/ new Quaternion();

const _xAxis = /*@__PURE__*/ new Vector3( 1, 0, 0 );
const _yAxis = /*@__PURE__*/ new Vector3( 0, 1, 0 );
const _zAxis = /*@__PURE__*/ new Vector3( 0, 0, 1 );
  • _v1:V通用向量临时存储,例如在 translateOnAxis 方法中计算平移向量,或在坐标转换时临时存储中间结果。
  • _q1:临时存储旋转四元数,例如在 rotateOnAxis 和 rotateOnWorldAxis 方法中生成旋转四元数,避免每次调用时新建对象。
  • _m1:临时存储变换矩阵,例如在 worldToLocal 方法中计算逆矩阵,或在 lookAt 方法中生成视图矩阵。
  • _target:在 lookAt 方法中存储目标点坐标(世界空间中的注视点),统一处理参数为向量或单独坐标的情况。
  • _position:临时存储位置信息,例如在 lookAt 方法中从世界矩阵提取当前对象的位置。
  • _scale:临时存储缩放信息,主要在矩阵分解(如 applyMatrix4 中从矩阵提取缩放分量)时使用。
  • _quaternion:临时存储旋转信息,例如在矩阵分解或父对象旋转提取时使用(如 lookAt 中处理父对象旋转)。
  • _xAxis_yAxis_zAxis:预定义的 X、Y、Z 轴单位向量((1,0,0)、(0,1,0)、(0,0,1)),在 rotateX/rotateY/rotateZ 等方法中作为旋转轴使用,避免重复创建轴向量。
class Object3D extends EventDispatcher {
    constructor() {}

    onBeforeShadow() {}
    onAfterShadow() {}
    onBeforeRender() {}
    onAfterRender() {}

    applyMatrix4(matrix) {}
    applyQuaternion(q) {}

    setRotationFromAxisAngle(axis, angle) {}
    setRotationFromEuler(euler) {}
    setRotationFromMatrix(m) {}
    setRotationFromQuaternion(q) {}

    rotateOnAxis(axis, angle) {}
    rotateOnWorldAxis(axis, angle) {}
    rotateX(angle) {}
    rotateY(angle) {}
    rotateZ(angle) {}

    translateOnAxis(axis, distance) {}
    translateX(distance) {}
    translateY(distance) {}
    translateZ(distance) {}

    localToWorld(vector) {}
    worldToLocal(vector) {}

    lookAt(x,y,z) {}

    add(object) {}
    remove(object) {}
    removeFromParent() {}
    clear() {}
    attach(object) {}

    getObjectById(id) {}
    getObjectByName(name) {}
    getObjectByProperty(name, value) {}
    getObjectsByProperty(name, value, result=[]) {}
    getWorldPosition(target) {}
    getWorldQuaternion(target) {}
    getWorldScale(target) {}
    getWorldDirection(target) {}

    raycast() {}

    traverse(callback) {}
    traverseVisible(callback) {}
    traverseAncestors(callback) {}

    updateMatrix() {}
    updateMatrixWorld(force) {}
    updateWorldMatrix(updateParents, updateChildren){}

    toJSON(meta) {}
    clone(recursive) {}
    copy(source, recursive=true) {}

}

updateMatrixWorld 函数、updateWorldMatrix 函数对比

相同点

  • 核心目标一致
    最终都是为了计算对象的世界矩阵(matrixWorld),该矩阵表示对象在全局坐标系中的位置、旋转和缩放的综合变换,计算公式相同:

    • 若对象无父级(parent === null),则 matrixWorld 直接等于本地矩阵(matrix)。
    • 若有父级,则 matrixWorld 是父级世界矩阵与自身本地矩阵的乘积(parent.matrixWorld × matrix)。
  • 依赖本地矩阵更新
    两者都会先检查 matrixAutoUpdate 标志,若为 true,则先调用 updateMatrix() 确保本地矩阵(matrix)是最新的(基于当前 position、rotation、scale 计算)。

  • 支持层级更新
    都能触发子对象(或父对象)的矩阵更新,确保整个层级结构的世界矩阵保持一致。

不同点

维度 updateMatrixWorld(force) updateWorldMatrix(updateParents, updateChildren)
更新方向 从当前对象向下递归更新(子对象)。 可灵活控制:向上更新父对象(updateParents)、向下更新子对象(updateChildren)。
触发条件 仅当 matrixWorldNeedsUpdatetrueforcetrue 时,才更新自身 matrixWorld 无条件计算自身 matrixWorld(只要 matrixWorldAutoUpdatetrue),不受 matrixWorldNeedsUpdate 影响。
参数作用 force:强制更新自身及所有子对象的世界矩阵(忽略 matrixWorldNeedsUpdate)。 updateParents:是否先更新所有父对象的世界矩阵;updateChildren:是否更新所有子对象的世界矩阵。
递归逻辑 自身更新后,强制子对象以 force = true 递归更新(若自身被更新过)。 仅在 updateChildrentrue 时,才递归更新子对象(子对象的 updateParents 固定为 false)。
典型使用场景 自动更新流程(如渲染前触发),依赖 matrixWorldNeedsUpdate 标志优化性能。 手动精确控制更新范围(如获取世界位置前,确保父级矩阵已更新)。

总结

  • updateMatrixWorld自动更新管道的核心,依赖状态标志(matrixWorldNeedsUpdate)优化性能,适合渲染循环等自动触发的场景。
  • updateWorldMatrix手动控制工具,允许精确指定更新范围(父级/子级),适合需要手动同步矩阵的场景(如获取世界位置、姿态前)。

34岁老前端的一周学习总结(2025/8/15)

DDD设计模式和落地

学习了DDD设计模式,并以该模式对项目中的复杂逻辑块进行了重构。

对于一个通用项目,DDD模式下的项目结构可能如下:

image.png

DDD模式的核心是模块隔离和事件驱动,有较多的编程规范,比如:

  1. 跨模块不直接依赖,通过事件交互
  2. service处理业务逻辑,model处理对象数据逻辑
  3. service层调用repo处理数据存储,model层提供存储entity或value object
  4. 禁止跨对象的属性使用,对象属性都保持为private
  5. 以事件启动各模块的实例化

实现的其中一个难点是IOC容器和DI注解,所有实例化出来的单例对象,都需要放入 application context。

采样算法研究

研究了一些采样算法,包括实时的系统采样(固定步长保留相对极值)和MinMax-LTTB算法。顺便借鉴了下echarts的lttb采样源码。

流式传输

研究了sse和streamable http,将echarts的曲线改为流式传输和增量渲染。

浏览器原生EventSource不支持post请求,最终方案是用fetch请求,用streamReader解析,用AbortController中断。

另外webpack devserver有个小坑,会缓冲数据干扰流式传输,需要配置compress为false。

RxJS

引入了RxJS来处理多种消息订阅,复习了下 merge, of, switchMap, takeUntil, mergeMap的用法。

虽然RxJS很强大,但是多渠道订阅情况下,想通过单subject完成某个数据流所有环节的处理并不是好的做法。

最终方案为自行维护细分订阅,由不同subject完成不同阶段的数据处理,总subject的订阅中根据渠道手动分发,这样能降低subject销毁创建的复杂度,也容易分渠道/分阶段取消订阅和终止消息。

css 列组合选择符

The following example makes cells C, E, and G gray.


col.selected || td {
  background: gray;
  color: white;
  font-weight: bold;
}

<table>
  <col span="2">
  <col class="selected">
  <tr><td>A <td>B <td>C
  <tr><td colspan="2">D <td>E
  <tr><td>F <td colspan="2">G
</table>

重学Vue3《Vue Watch 监听器深度指南:场景、技巧与底层优化原理剖析》

Vue Watch 监听器深度指南:场景、技巧与底层优化原理剖析

一、引言:响应式监听的核心价值

在 Vue 的响应式系统中,监听器是处理异步逻辑和复杂状态变更的核心工具。watchwatchEffect 提供了两种不同的响应模式,让开发者能够精确控制数据变化的处理逻辑。理解它们的差异和底层原理,是构建高性能 Vue 应用的关键。

坚持住创作不易,记得点赞、评论、关注。

二、watch:精准监听与灵活控制

1. 基础用法

import { ref, watch } from 'vue'
const count = ref(0)
// 基础监听
watch(count, (newVal, oldVal) => {
  console.log(`计数从 ${oldVal} 变为 ${newVal}`)
})
// 触发变更
count.value++ // 输出: "计数从 0 变为 1"

2. 监听多种来源

const state = reactive({ user: { name: 'Alice' } })
const age = ref(25)

// 监听多个源
watch([age, () => state.user.name], ([newAge, newName]) => {
  console.log(`年龄或用户名变更: ${newAge}, ${newName}`)
})

3. 深度监听与立即执行

const nestedObj = reactive({
  data: {
    items: [1, 2, 3],
  },
})
watch(
  () => nestedObj.data,
  (newData) => {
    console.log('嵌套数据变化:', newData.items)
  },
  {
    deep: true, // 深度监听嵌套属性
    immediate: true, // 立即执行一次
  },
)
// 触发变更
nestedObj.data.items.push(4) // 深度监听可捕获此变更

4. 一次性侦听器

watch(
  source,
  (newValue, oldValue) => {
    // 当 `source` 变化时,仅触发一次
  },
  { once: true },
)

三、watchEffect:自动依赖收集的响应式"副作用"

1. 基本用法

import { watchEffect, ref } from 'vue'
const count = ref(0)
const multiplier = ref(2)
// 自动收集依赖
watchEffect(() => {
  console.log(`计算结果: ${count.value * multiplier.value}`)
})
count.value++ // 输出: "计算结果: 2"
multiplier.value = 3 // 输出: "计算结果: 3"

2. 依赖自动收集机制

watchEffect 在首次执行时自动追踪函数内访问的所有响应式依赖。当任何依赖变更时,副作用函数会重新执行。

const A = ref(1)
const B = ref(2)
const useA = ref(true)
watchEffect(() => {
  // 动态依赖: 根据 useA 的值决定依赖 A 或 B
  console.log(useA.value ? A.value : B.value)
})
useA.value = false // 触发执行,输出: 2
B.value = 3 // 触发执行,输出: 3
A.value = 10 // 不会触发,因为当前依赖只有 B

四、watch vs watchEffect:核心差异与场景选择

特性 watch watchEffect
依赖声明 显式指定监听源 自动收集函数内依赖
初始执行 immediate: true 触发 立即执行
新旧值获取 可访问 newVal / oldVal 仅能获取当前值
适用场景 精准响应特定数据变化 依赖复杂或动态变化的副作用
性能优化 可跳过不必要更新 依赖变更即触发

场景选择指南

  • 使用 watch 当:
    • 需要访问旧值进行对比
    • 需要精确控制监听源
    • 需要在特定条件触发回调
  • 使用 watchEffect 当:
    • 依赖关系复杂或动态变化
    • 需要立即执行初始化逻辑
    • 构建与 DOM 相关的副作用(如自动调整元素尺寸)
// watch 典型场景:路由参数变化
watch(
  () => route.params.id,
  (newId) => {
    fetchUserData(newId)
  },
)
// watchEffect 典型场景:DOM 更新后操作
watchEffect(
  () => {
    // DOM 更新后执行
    const element = document.getElementById('my-element')
    if (element) {
      element.scrollIntoView()
    }
  },
  { flush: 'post' },
)

五、深度使用技巧与最佳实践

1. 停止监听与清理资源

const stopWatch = watch(/* ... */)
// 组件卸载时停止监听
onUnmounted(stopWatch)
// watchEffect 清理副作用
watchEffect((onCleanup) => {
  const timer = setTimeout(() => {
    // 执行操作
  }, 1000)

  // 清理函数
  onCleanup(() => clearTimeout(timer))
})

2. 性能优化技巧

2.1 防抖与节流优化高频操作

场景说明:搜索框输入时实时请求API,需要避免频繁触发

import { ref, watch } from 'vue'
import { debounce, throttle } from 'lodash-es'
// 防抖方案:等待用户停止输入300ms后执行
const searchQuery = ref('')
watch(
  searchQuery,
  debounce((query) => {
    fetchResults(query)
  }, 300),
)
// 节流方案:最多每500ms执行一次
const scrollPosition = ref(0)
watch(
  scrollPosition,
  throttle((position) => {
    saveScrollPosition(position)
  }, 500),
)
// 手动实现简易防抖
function customDebounce(fn, delay) {
  let timer = null
  return function (...args) {
    clearTimeout(timer)
    timer = setTimeout(() => {
      fn.apply(this, args)
    }, delay)
  }
}
2.2 避免深度监听的性能陷阱

场景说明:监听大型对象时,避免不必要的深度监听

const largeObj = reactive({
  data: {
    /* 包含数千个属性的对象 */
  },
  meta: {
    /* ... */
  },
})
// 优化前:整个对象深度监听(性能差)
watch(
  largeObj,
  (newVal) => {
    // 处理逻辑
  },
  { deep: true },
)
// ✅ 优化方案1:精确监听特定属性
watch(
  () => largeObj.data.criticalProp,
  (newVal) => {
    // 仅监听关键属性
  },
)
// ✅ 优化方案2:使用浅层监听+手动检查
watch(
  () => largeObj.data,
  (newData, oldData) => {
    if (newData.criticalProp !== oldData?.criticalProp) {
      // 执行操作
    }
  },
  { flush: 'sync' },
) // 同步获取最新值
2.3 计算属性替代监听器

场景说明:当需要派生状态时,优先使用计算属性

const items = ref([/* 大型列表 */])
const filterText = ref('')
// ❌ 低效方案:使用 watch 处理派生状态
const filteredItems = ref([])
watch([items, filterText], ([newItems, newFilter]) => {
  filteredItems.value = newItems.filter(item =>
    item.name.includes(newFilter)
})
// ✅ 高效方案:使用计算属性
const optimizedFilteredItems = computed(() => {
  return items.value.filter(item =>
    item.name.includes(filterText.value)
})

watch(optimizedFilteredItems, (newValue, oldValue) => {
  // 业务代码
})

性能对比

  • 计算属性:仅在依赖变化时重新计算,有缓存机制
  • watch 方案:每次变化都要执行完整过滤逻辑
2.4 控制监听器执行时机

场景说明:DOM 更新后操作需要确保元素已完成渲染

// 场景:根据数据变化更新图表
const chartData = ref([...])
let chartInstance = null
// ❌ 错误时机:可能在 DOM 更新前执行
watch(chartData, (newData) => {
  if (chartInstance) {
    chartInstance.update(newData)
  } else {
    chartInstance = initChart(document.getElementById('chart'), newData)
  }
})
// ✅ 正确方案:使用 flush: 'post'
watch(chartData, (newData) => {
  if (chartInstance) {
    chartInstance.update(newData)
  } else {
    // 确保 DOM 已更新
    nextTick(() => {
      chartInstance = initChart(document.getElementById('chart'), newData)
    })
  }
}, { flush: 'post' }) // DOM 更新后执行
// ✅ watchEffect 替代方案
watchEffect((onCleanup) => {
  const chartEl = document.getElementById('chart')
  if (!chartEl) return

  chartInstance = initChart(chartEl, chartData.value)

  onCleanup(() => {
    if (chartInstance) {
      chartInstance.destroy()
    }
  })
}, { flush: 'post' })
2.5 内存泄漏预防与资源清理

场景说明:异步操作中的资源释放

// 监听路由变化加载数据
watch(
  () => route.params.id,
  (newId) => {
    let isActive = true

    fetchUserData(newId).then((data) => {
      if (isActive) {
        userData.value = data
      }
    })

    // 清理函数
    return () => {
      isActive = false
      // 可在此取消 Axios 请求
    }
  },
)
// watchEffect 中的清理
watchEffect((onCleanup) => {
  const socket = new WebSocket('wss://api.example.com')

  socket.onmessage = (event) => {
    // 处理消息
  }

  onCleanup(() => {
    socket.close() // 清理时关闭连接
  })
})
2.6 条件监听优化策略

场景说明:只在特定条件下激活监听器

const isEditing = ref(false)
const formData = reactive({ name: '', email: '' })
// 条件监听:只在编辑模式下验证表单
watch(
  () => (isEditing.value ? formData : null),
  (newData) => {
    if (newData) {
      validateForm(newData)
    }
  },
  { deep: true },
)
// 动态开关监听器
let stopWatch = null
watchEffect(() => {
  if (isEditing.value) {
    // 启用时创建监听
    stopWatch = watch(formData, validateForm, { deep: true })
  } else {
    // 关闭时停止监听
    stopWatch?.()
    stopWatch = null
  }
})

六、watch 监听器优化原理剖析

1. 依赖追踪与惰性执行

Vue 3 使用 Proxy 实现响应式系统。当创建 watch 时:

// 伪代码实现
function watch(source, callback, options) {
  const getter = isFunction(source) ? source : () => traverse(source)

  let oldValue
  const job = () => {
    const newValue = getter()
    if (!isSame(newValue, oldValue)) {
      callback(newValue, oldValue)
      oldValue = newValue
    }
  }

  // 建立依赖关系
  const runner = effect(getter, {
    lazy: true,
    scheduler: () => queueJob(job),
  })

  oldValue = runner()
}

2. 缓存与比对优化

Vue 使用 Object.is 进行新旧值比对,避免不必要的回调执行:

// 简化版值比对逻辑
function isSame(a, b) {
  // 处理 NaN 情况
  if (Number.isNaN(a) && Number.isNaN(b)) return true
  return Object.is(a, b)
}

3. 异步更新队列

Vue 将多个同步变更合并为单次更新:

// 更新队列处理
const queue = []
let isFlushing = false
function queueJob(job) {
  if (!queue.includes(job)) {
    queue.push(job)
  }
  if (!isFlushing) {
    isFlushing = true
    Promise.resolve().then(flushJobs)
  }
}
function flushJobs() {
  queue.sort((a, b) => a.id - b.id) // 确保父组件优先更新
  for (const job of queue) {
    job()
  }
  queue.length = 0
  isFlushing = false
}

4. 深度监听的优化策略(Vue 3.4+)

Vue 3.4 对深度监听进行了重要优化:

function traverse(value, seen = new Set()) {
  if (seen.has(value)) return value
  seen.add(value)

  if (isObject(value)) {
    // 仅追踪访问过的属性
    for (const key in value) {
      traverse(value[key], seen)
    }
  }
  return value
}

5. 惰性求值与缓存优化(源码解析)

Vue 监听器的核心优化逻辑:

// 简化的 watch 实现核心
function createWatcher(source, cb, options = {}) {
  let getter = () => {}
  let oldValue = undefined
  let cleanup = null

  // 处理不同来源的数据
  if (isRef(source)) {
    getter = () => source.value
  } else if (isReactive(source)) {
    getter = () => source
    options.deep = true // 自动深度监听
  } else if (isFunction(source)) {
    getter = source
  }

  // 深度监听处理
  if (options.deep) {
    const baseGetter = getter
    getter = () => traverse(baseGetter())
  }

  // 实际执行函数
  const job = () => {
    if (!runner.active) return

    const newValue = runner.run()

    // 重要:值变化检测优化
    if (hasChanged(newValue, oldValue)) {
      // 执行清理函数
      if (cleanup) cleanup()

      // 执行回调(传递清理函数)
      cb(newValue, oldValue, (cleanupFn) => {
        cleanup = cleanupFn
      })

      oldValue = newValue
    }
  }

  // 创建响应式 effect
  const runner = effect(getter, {
    lazy: true,
    scheduler: () => queueJob(job),
  })

  // 初始值获取
  oldValue = runner.run()

  return () => runner.stop()
}
// 值变化检测优化逻辑
function hasChanged(value, oldValue) {
  // 处理 NaN 情况
  if (Number.isNaN(value) && Number.isNaN(oldValue)) {
    return false
  }

  // 引用类型浅比较
  if (isObject(value) && isObject(oldValue)) {
    // 对简单对象进行浅层属性比较
    if (Object.keys(value).length < 50) {
      return !shallowEqual(value, oldValue)
    }
  }

  // 默认严格相等
  return !Object.is(value, oldValue)
}
// 浅层对象比较优化
function shallowEqual(objA, objB) {
  if (Object.is(objA, objB)) return true

  const keysA = Object.keys(objA)
  const keysB = Object.keys(objB)

  if (keysA.length !== keysB.length) return false

  for (let i = 0; i < keysA.length; i++) {
    if (!Object.is(objA[keysA[i]], objB[keysA[i]])) {
      return false
    }
  }

  return true
}

6. 深度监听优化策略(Vue 3.4+)

Vue 3.4 对深度监听进行了重大改进:

function traverse(value, depth = 0, seen = new Set()) {
  // 避免循环引用
  if (seen.has(value)) return value
  seen.add(value)

  // 深度限制(默认10层)
  if (depth > 10) return value

  // 只处理对象类型
  if (!isObject(value)) return value

  // 特殊处理数组
  if (Array.isArray(value)) {
    for (let i = 0; i < value.length; i++) {
      traverse(value[i], depth + 1, seen)
    }
  }
  // 处理普通对象
  else {
    // 使用 Object.keys 而非 for...in 提高性能
    const keys = Object.keys(value)
    for (let i = 0; i < keys.length; i++) {
      traverse(value[keys[i]], depth + 1, seen)
    }
  }

  return value
}

优化效果

  1. 限制递归深度(默认10层)
  2. 避免循环引用导致的无限递归
  3. 使用 Object.keys 替代 for...in 提高性能
  4. 跳过非对象类型的遍历

7. 性能优化实战演示

大型列表渲染优化

<template>
  <div>
    <input v-model="filterText" placeholder="搜索..." />
    <VirtualList :items="filteredItems" />
  </div>
</template>
<script setup>
  import { ref, computed, watch } from 'vue'
  import VirtualList from './VirtualList.vue'
  // 大型数据集(10,000+ 项)
  const rawItems = ref(/* 从API获取的大型数据集 */)
  // 优化1:使用计算属性进行过滤
  const filterText = ref('')
  const filteredItems = computed(() => {
    return rawItems.value.filter(item =>
      item.name.includes(filterText.value)
  })
  // 优化2:使用虚拟滚动组件
  // 优化3:避免不必要的深度监听
  watch(filterText, debounce(() => {
    // 仅记录分析数据,不影响主线程
    logSearchEvent(filterText.value)
  }, 1000))
  // 优化4:非关键操作使用requestIdleCallback
  watch(() => rawItems.value.length, (newCount) => {
    requestIdleCallback(() => {
      trackItemCount(newCount)
    })
  })
</script>

七、总结:选择与优化之道

  1. 精准控制选 watch,简化依赖用 watchEffect
    根据场景选择:需要旧值比较时用 watch,复杂依赖关系用 watchEffect
  2. 性能关键点
    • 避免在监听器中执行昂贵操作
    • 使用 debouncethrottle 控制触发频率
    • 合理使用 flush: 'post' 优化 DOM 操作
  3. 深度监听优化
    • Vue 3.4+ 的深度监听只追踪实际访问的属性
    • 嵌套层级过深时考虑数据扁平化
  4. 内存管理
    • 组件卸载时及时停止监听器
    • 使用 onCleanup 清理异步资源

性能优化总结表

优化技巧 适用场景 核心收益 代码示例
防抖/节流 高频事件(输入、滚动) 减少函数执行次数 watch(input, debounce(fn, 300))
精确监听属性 大型对象/深层嵌套结构 避免不必要的深度遍历 watch(() => obj.key, handler)
计算属性替代 派生状态 自动缓存,高效更新 computed(() => ...)
flush: 'post' DOM 依赖操作 确保DOM更新完成 watch(..., { flush: 'post' })
条件监听 特定模式下才需要监听 减少非必要监听开销 watch(isActive ? data : null)
资源清理 异步操作、事件监听 避免内存泄漏 onCleanup(() => socket.close())
虚拟滚动 大型列表渲染 减少DOM节点数量 <VirtualList :items="data" />
requestIdleCallback 非关键后台任务 避免阻塞主线程 requestIdleCallback(backgroundTask)

关键原理图示说明:

graph TD
    A[数据变更] --> B{监听类型}
    B -->|watch| C[精确检查指定源]
    B -->|watchEffect| D[自动收集依赖]
    C --> E[新旧值比较]
    D --> F[立即执行副作用]
    E -->|有变化| G[执行回调]
    F --> H[执行副作用]
    G --> I[异步更新队列]
    H --> I
    I --> J[批量执行回调]

理解 Vue 监听器背后的优化机制,能够帮助开发者在复杂应用中避免性能陷阱,构建更高效的响应式交互。根据具体场景选择合适的监听策略,是 Vue 高级开发的必备技能。

NAS上使用Docker部署网页版双人对战五子棋

镜像是我(汉化)制作的,欢迎关注我B站账号 秦曱凧 (秦曱凧 读作 qín yuē zhēng)

有需要帮忙部署这个项目的朋友,一杯奶茶,即可程远程帮你部署,需要可联系。
微信号 E-0_0-
闲鱼搜索用户 明月人间
或者邮箱 firfe163@163.com

原项目地址

我汉化和构建docker镜像的代码仓库地址

欢迎start

介绍&玩法

一款支持双人在线对战的五子棋游戏,可通过 Docker 私有化部署,可以内网访问,无需外网连接,不发送任何数据,确保用户隐私安全。

两名玩家同时在浏览器打开项目链接,一名玩家创建房间,获得房间号,另一名玩家通过输入房间号,加入房间,一起玩。

镜像

镜像位于国内,在华为云或阿里云,方便拉取。

容器内部端口5124,可通过设置环境变量PORT的值来指定监听端口。

镜像地址

swr.cn-north-4.myhuaweicloud.com/firfe/gomoku_pvp_1_zh-cn:2025.06.28

如果端口冲突,就将下面左边的3000改成其他的端口

docker run 部署

docker run -d \
--name gomoku_pvp_1_zh-cn \
--network bridge \
--restart always \
--log-opt max-size=1m \
--log-opt max-file=1 \
-p 5124:5124 \
swr.cn-north-4.myhuaweicloud.com/firfe/gomoku_pvp_1_zh-cn:2025.06.28

compose 文件部署 👍推荐

#version: '3'
services:
  gomoku_pvp_1_zh-cn:
    container_name: gomoku_pvp_1_zh-cn
    image: swr.cn-north-4.myhuaweicloud.com/firfe/gomoku_pvp_1_zh-cn:2025.06.28
    network_mode: bridge
    restart: always
    logging:
      options:
        max-size: 1m
        max-file: '1'
    ports:
      - 5124:5124

效果图

PixPin_2025-06-29_12-00-36.png

PixPin_2025-06-29_12-01-13.png

flutter学习第 18 节:设备功能调用

在移动应用开发中,调用设备原生功能(如相机、相册、定位等)是提升用户体验的关键。Flutter 提供了丰富的第三方插件,让我们可以轻松实现这些功能。本节课将详细讲解设备功能调用的核心知识点,包括权限管理、相机 / 相册调用、定位获取,并通过综合实例演示实际应用。

一、权限管理:permission_handler 插件

调用设备功能前必须获得用户授权,permission_handler 是 Flutter 中最常用的权限管理插件,支持 Android 和 iOS 平台的几乎所有权限类型。

1. 安装与配置

步骤 1:添加依赖
在 pubspec.yaml 中添加插件:

dependencies:
  permission_handler: ^12.0.1  # 建议使用最新版本

执行 flutter pub get 安装。

步骤 2:平台权限配置
权限需要在原生配置文件中声明,否则调用时会直接失败。

  • Android 配置
    编辑 android/app/src/main/AndroidManifest.xml,添加需要的权限(根据功能添加):
<!-- 相机权限 -->
<uses-permission android:name="android.permission.CAMERA"/>
<!-- 读写存储权限 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<!-- 定位权限 -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>

iOS 配置
编辑 ios/Runner/Info.plist,添加权限描述(用户会看到这些说明):

<!-- 相机权限描述 -->
<key>NSCameraUsageDescription</key>
<string>需要相机权限用于拍照</string>
<!-- 相册权限描述 -->
<key>NSPhotoLibraryUsageDescription</key>
<string>需要相册权限用于选择图片</string>
<!-- 定位权限描述 -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>需要定位权限用于获取当前位置</string>

2. 权限操作核心方法

permission_handler 提供了简洁的 API 用于权限检查和请求:

import 'package:permission_handler/permission_handler.dart';

// 检查权限状态
Future<bool> checkPermission(Permission permission) async {
  PermissionStatus status = await permission.status;
  return status.isGranted; // 返回是否已授权
}

// 请求权限
Future<bool> requestPermission(Permission permission) async {
  PermissionStatus status = await permission.request();
  return status.isGranted;
}

// 打开应用权限设置页面(当用户拒绝且勾选"不再询问"时使用)
Future<void> openAppSet() async {
  await openAppSettings();
}

3. 常用权限常量

Permission 类包含所有支持的权限,常用的有:

  • Permission.camera:相机权限
  • Permission.photos / Permission.storage:相册 / 存储权限
  • Permission.locationWhenInUse:使用中定位权限
  • Permission.locationAlways:始终允许定位权限


二、调用相机 / 相册:image_picker 插件

image_picker 是 Flutter 官方推荐的媒体选择插件,支持从相机拍照或从相册选择图片 / 视频。

1. 安装与配置

步骤 1:添加依赖

dependencies:
  image_picker: ^1.1.2  # 建议使用最新版本

执行 flutter pub get 安装。

步骤 2:平台额外配置(部分场景需要)

  • iOS 视频选择:如需选择视频,需在 Info.plist 中添加 NSCameraUsageDescription(同相机权限)
  • Android 10+ 存储:如需保存图片到公共目录,需在 AndroidManifest.xml 中添加:
<application ...>
  <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
  <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
  <!-- Android 10+ 需添加 -->
  <application ... android:requestLegacyExternalStorage="true">
</application>

2. 核心功能实现

(1)从相机拍照

import 'package:image_picker/image_picker.dart';

// 从相机获取图片
Future<XFile?> takePhoto() async {
  final ImagePicker picker = ImagePicker();
  // 调用相机,返回XFile(包含图片路径等信息)
  final XFile? photo = await picker.pickImage(
    source: ImageSource.camera, // 来源为相机
    imageQuality: 80, // 图片质量(0-100)
    maxWidth: 1080, // 最大宽度
  );
  return photo;
}

(2)从相册选择图片

// 从相册选择图片
Future<XFile?> pickImageFromGallery() async {
  final ImagePicker picker = ImagePicker();
  final XFile? image = await picker.pickImage(
    source: ImageSource.gallery, // 来源为相册
    imageQuality: 80,
  );
  return image;
}

(3)显示选中的图片

获取 XFile 后,可通过 Image.file 显示图片:

import 'dart:io';

XFile? selectedImage; // 存储选中的图片

// 拍照后更新图片
void _onTakePhoto() async {
  XFile? photo = await takePhoto();
  if (photo != null) {
    setState(() {
      selectedImage = photo;
    });
  }
}

// 界面中显示图片
Widget buildImagePreview() {
  if (selectedImage == null) {
    return Text("未选择图片");
  }
  return Image.file(
    File(selectedImage!.path),
    width: 300,
    height: 300,
    fit: BoxFit.cover,
  );
}


三、定位功能:geolocator 插件

geolocator 提供了跨平台的定位服务,支持获取经纬度、海拔、速度等信息,还能监听位置变化。

1. 安装与配置

步骤 1:添加依赖

dependencies:
  geolocator: ^14.0.2  # 建议使用最新版本

执行 flutter pub get 安装。

步骤 2:平台权限配置
定位功能需要额外的权限配置(已在 permission_handler 部分添加,这里补充细节):

  • Android
    除了 ACCESS_FINE_LOCATION(精确定位)和 ACCESS_COARSE_LOCATION(粗略定位),如需后台定位,需添加:
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>

iOS
如需后台定位,需在 Info.plist 中添加:

<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>需要始终允许定位权限用于后台定位</string>
<key>UIBackgroundModes</key>
<array>
  <string>location</string>
</array>

2. 核心功能实现

(1)获取当前位置

import 'package:geolocator/geolocator.dart';

// 获取当前经纬度
Future<Position?> getCurrentLocation() async {
  // 检查定位服务是否开启
  bool isLocationEnabled = await Geolocator.isLocationServiceEnabled();
  if (!isLocationEnabled) {
    // 提示用户开启定位服务
    return null;
  }

  // 检查定位权限
  LocationPermission permission = await Geolocator.checkPermission();
  if (permission == LocationPermission.denied) {
    // 请求权限
    permission = await Geolocator.requestPermission();
    if (permission != LocationPermission.whileInUse && 
        permission != LocationPermission.always) {
      // 权限被拒绝
      return null;
    }
  }

  // 获取当前位置(最多等待10秒)
  try {
    Position position = await Geolocator.getCurrentPosition(
      desiredAccuracy: LocationAccuracy.high, // 高精度
      timeLimit: Duration(seconds: 10),
    );
    return position; // 包含latitude(纬度)和longitude(经度)
  } catch (e) {
    print("获取位置失败:$e");
    return null;
  }
}

(2)监听位置变化

// 监听位置变化(每移动10米或30秒更新一次)
StreamSubscription<Position>? positionStream;

void startListeningLocation() {
  positionStream = Geolocator.getPositionStream(
    locationSettings: LocationSettings(
      accuracy: LocationAccuracy.high,
      distanceFilter: 10, // 移动10米以上才更新
      intervalDuration: Duration(seconds: 30), // 至少30秒更新一次
    ),
  ).listen((Position position) {
    print("当前位置:${position.latitude}, ${position.longitude}");
  });
}

// 停止监听
void stopListeningLocation() {
  if (positionStream != null) {
    positionStream!.cancel();
    positionStream = null;
  }
}

四、综合实例:拍照上传与获取位置

下面实现一个完整页面,包含以下功能:

  1. 检查并请求相机、定位权限
  2. 拍照或从相册选择图片
  3. 获取当前位置经纬度
  4. 模拟图片上传(显示上传状态)

完整代码实现

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:image_picker/image_picker.dart';
import 'package:geolocator/geolocator.dart';

class DeviceDemoPage extends StatefulWidget {
  const DeviceDemoPage({super.key});

  @override
  State<DeviceDemoPage> createState() => _DeviceDemoPageState();
}

class _DeviceDemoPageState extends State<DeviceDemoPage> {
  XFile? _selectedImage; // 选中的图片
  Position? _currentPosition; // 当前位置
  bool _isUploading = false; // 是否正在上传

  // 检查并请求权限
  Future<bool> _checkAndRequestPermission(Permission permission) async {
    bool isGranted = await checkPermission(permission);
    if (!isGranted) {
      isGranted = await requestPermission(permission);
    }
    return isGranted;
  }

  // 拍照
  void _takePhoto() async {
    bool hasCameraPermission = await _checkAndRequestPermission(Permission.camera);
    if (!hasCameraPermission) {
      _showSnackBar("请授予相机权限");
      return;
    }

    XFile? photo = await ImagePicker().pickImage(
      source: ImageSource.camera,
      imageQuality: 80,
    );
    if (photo != null) {
      setState(() => _selectedImage = photo);
    }
  }

  // 从相册选择
  void _pickFromGallery() async {
    bool hasStoragePermission = await _checkAndRequestPermission(Permission.photos);
    if (!hasStoragePermission) {
      _showSnackBar("请授予相册权限");
      return;
    }

    XFile? image = await ImagePicker().pickImage(
      source: ImageSource.gallery,
      imageQuality: 80,
    );
    if (image != null) {
      setState(() => _selectedImage = image);
    }
  }

  // 获取当前位置
  void _getCurrentLocation() async {
    bool hasLocationPermission = await _checkAndRequestPermission(Permission.locationWhenInUse);
    if (!hasLocationPermission) {
      _showSnackBar("请授予定位权限");
      return;
    }

    Position? position = await getCurrentLocation();
    if (position != null) {
      setState(() => _currentPosition = position);
      _showSnackBar("已获取位置信息");
    } else {
      _showSnackBar("获取位置失败,请检查定位服务");
    }
  }

  // 模拟上传图片
  void _uploadImage() async {
    if (_selectedImage == null) {
      _showSnackBar("请先选择图片");
      return;
    }

    setState(() => _isUploading = true);
    // 模拟网络请求(2秒后完成)
    await Future.delayed(Duration(seconds: 2));
    setState(() => _isUploading = false);
    _showSnackBar("图片上传成功");
  }

  // 显示提示
  void _showSnackBar(String message) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text(message)),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("设备功能演示")),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            // 图片预览
            _selectedImage == null
                ? const Text("未选择图片", style: TextStyle(fontSize: 16))
                : Image.file(
                    File(_selectedImage!.path),
                    width: 300,
                    height: 300,
                    fit: BoxFit.cover,
                  ),
            const SizedBox(height: 20),

            // 操作按钮
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton.icon(
                  onPressed: _takePhoto,
                  icon: const Icon(Icons.camera_alt),
                  label: const Text("拍照"),
                ),
                const SizedBox(width: 16),
                ElevatedButton.icon(
                  onPressed: _pickFromGallery,
                  icon: const Icon(Icons.photo_library),
                  label: const Text("相册"),
                ),
              ],
            ),
            const SizedBox(height: 16),

            // 定位信息
            ElevatedButton.icon(
              onPressed: _getCurrentLocation,
              icon: const Icon(Icons.location_on),
              label: const Text("获取当前位置"),
            ),
            if (_currentPosition != null)
              Padding(
                padding: const EdgeInsets.all(16),
                child: Text(
                  "当前位置:\n纬度:${_currentPosition!.latitude}\n经度:${_currentPosition!.longitude}",
                  textAlign: TextAlign.center,
                  style: const TextStyle(fontSize: 16),
                ),
              ),
            const SizedBox(height: 16),

            // 上传按钮
            _isUploading
                ? const CircularProgressIndicator()
                : ElevatedButton.icon(
                    onPressed: _uploadImage,
                    icon: const Icon(Icons.upload),
                    label: const Text("上传图片"),
                  ),
          ],
        ),
      ),
    );
  }
}

// 权限检查与请求的工具方法(可抽离到工具类)
Future<bool> checkPermission(Permission permission) async {
  return (await permission.status).isGranted;
}

Future<bool> requestPermission(Permission permission) async {
  return (await permission.request()).isGranted;
}

// 定位工具方法(可抽离到工具类)
Future<Position?> getCurrentLocation() async {
  // 检查定位服务是否开启
  if (!await Geolocator.isLocationServiceEnabled()) {
    return null;
  }

  // 检查权限
  LocationPermission permission = await Geolocator.checkPermission();
  if (permission == LocationPermission.denied) {
    permission = await Geolocator.requestPermission();
    if (permission != LocationPermission.whileInUse &&
        permission != LocationPermission.always) {
      return null;
    }
  }

  // 获取位置
  try {
    return await Geolocator.getCurrentPosition(
      desiredAccuracy: LocationAccuracy.high,
      timeLimit: const Duration(seconds: 10),
    );
  } catch (e) {
    return null;
  }
}

代码说明

  1. 权限管理:通过封装的 checkPermission 和 requestPermission 方法,统一处理权限逻辑,确保调用设备功能前已获得授权。
  2. 图片处理:使用 image_picker 分别实现拍照和相册选择功能,并通过 Image.file 显示选中的图片。
  3. 定位功能:通过 geolocator 获取经纬度,包含定位服务检查和权限处理,确保定位功能可靠。
  4. 用户体验:添加加载状态(上传时显示进度条)和提示信息(SnackBar),提升交互友好度。


五、扩展知识:其他常用设备功能插件

除了本节课讲解的功能,以下插件也常用于设备功能调用:

  • url_launcher:调用系统浏览器、拨打电话、发送邮件等
  • share_plus:分享文本、图片到其他应用
  • flutter_local_notifications:本地通知功能
  • connectivity_plus:网络连接状态监听
  • package_info_plus:获取应用版本、名称等信息

vue3+TypeScript 实现一个图片占位符生成器

图片占位符生成器

一个专业的图片占位符生成工具,基于 Vue 3 + Vite 构建,提供直观的界面和强大的功能。

image.png

image.png

项目概述

核心功能

  • 自定义尺寸:支持 50-2000px 范围内的任意宽高设置
  • 快速预设:提供 16:9、4:3、1:1 等常用比例预设
  • 颜色自定义:支持背景色和文字色的自由搭配
  • 颜色预设:内置多种常用颜色方案
  • 文字定制:可添加自定义文字或显示默认尺寸信息
  • 字体调节:12-72px 范围内的字体大小调节
  • 实时预览:所有修改实时反映在预览区域
  • 一键下载:直接下载 PNG 格式图片
  • 链接复制:复制图片 Data URL 到剪贴板
  • 使用模板:提供产品卡片、用户头像、横幅广告等场景模板

技术特色

  • 现代化架构:Vue 3 Composition API + Vite
  • 组件化设计:高度模块化的组件结构
  • 响应式布局:完美适配桌面和移动设备
  • 企业级代码:详细注释、类型安全、错误处理
  • 用户体验:流畅的动画效果和交互反馈

技术架构

技术栈

  • 前端框架:Vue 3 (Composition API)
  • 构建工具:Vite
  • 样式方案:原生 CSS + CSS Grid/Flexbox
  • 图形处理:HTML5 Canvas API
  • 工具库:@vueuse/core (响应式工具)

项目结构

src/
├── components/
│   ├── PlaceholderGenerator.vue    # 主容器组件
│   ├── ControlPanel.vue           # 控制面板组件
│   ├── PreviewCanvas.vue          # 预览画布组件
│   ├── UsageExamples.vue          # 使用示例组件
│   └── NotificationToast.vue      # 通知提示组件
├── App.vue                        # 根组件
├── main.js                        # 应用入口
└── style.css                      # 全局样式

核心实现

1. Canvas 图片生成

使用 HTML5 Canvas API 实现图片的动态生成:

const drawPlaceholder = async () => {
  const canvas = canvasRef.value
  const ctx = canvas.getContext('2d')
  const { width, height, backgroundColor, textColor, customText, fontSize } = props.config
  
  // 清空画布
  ctx.clearRect(0, 0, width, height)
  
  // 绘制背景
  ctx.fillStyle = backgroundColor
  ctx.fillRect(0, 0, width, height)
  
  // 绘制文字(居中对齐)
  const displayText = customText || `${width} × ${height}`
  ctx.fillStyle = textColor
  ctx.font = `${fontSize}px Inter, sans-serif`
  ctx.textAlign = 'center'
  ctx.textBaseline = 'middle'
  
  // 添加文字阴影效果
  ctx.shadowColor = 'rgba(0, 0, 0, 0.3)'
  ctx.shadowBlur = 4
  ctx.fillText(displayText, width / 2, height / 2)
}

实现难点

  • Canvas 文字居中对齐的精确计算
  • 不同字体大小下的视觉平衡
  • 高分辨率屏幕的适配处理

2. 响应式数据管理

使用 Vue 3 Composition API 实现响应式状态管理:

const imageConfig = reactive({
  width: 400,
  height: 300,
  backgroundColor: '#6366f1',
  textColor: '#ffffff',
  customText: '',
  fontSize: 24
})

// 监听配置变化,实时重绘
watch(
  () => props.config,
  () => drawPlaceholder(),
  { deep: true, immediate: true }
)

技术优势

  • 深度响应式监听确保实时更新
  • 组件间数据流清晰可控
  • 性能优化的批量更新机制

3. 文件下载实现

通过 Canvas toDataURL 方法实现图片下载:

const downloadImage = () => {
  const canvas = canvasRef.value
  const dataUrl = canvas.toDataURL('image/png', 1.0)
  
  const link = document.createElement('a')
  link.download = `placeholder-${imageConfig.width}x${imageConfig.height}.png`
  link.href = dataUrl
  document.body.appendChild(link)
  link.click()
  document.body.removeChild(link)
}

实现细节

  • 动态生成文件名包含尺寸信息
  • PNG 格式确保图片质量
  • 兼容性处理支持各种浏览器

4. 剪贴板集成

使用现代 Clipboard API 实现链接复制:

const copyDataUrl = async (dataUrl) => {
  try {
    await navigator.clipboard.writeText(dataUrl)
    showNotification('链接已复制到剪贴板!', 'success')
  } catch (error) {
    showNotification('复制失败,请手动复制', 'error')
  }
}

兼容性考虑

  • 检测 Clipboard API 支持情况
  • 提供降级方案和错误处理
  • 用户友好的反馈机制

设计系统

1. 颜色系统

建立了完整的颜色体系:

  • 主色调:蓝紫渐变 (#667eea → #764ba2)
  • 功能色:成功绿色、警告橙色、错误红色
  • 中性色:多层级灰色系统
  • 语义化:每种颜色都有明确的使用场景

2. 间距系统

采用 8px 基础间距系统:

  • 微间距:4px, 8px (组件内部)
  • 小间距:12px, 16px (相关元素)
  • 中间距:20px, 24px (组件间)
  • 大间距:30px, 40px (区块间)

3. 字体系统

使用 Inter 字体族:

  • 标题:700 weight, 1.2 行高
  • 正文:400 weight, 1.5 行高
  • 辅助:300 weight, 1.4 行高
  • 代码:等宽字体,用于数值显示

用户体验设计

1. 交互反馈

  • 悬停效果:按钮和卡片的微妙变化
  • 点击反馈:按下状态的视觉响应
  • 加载状态:操作过程中的状态指示
  • 错误处理:友好的错误提示和恢复建议

2. 动画系统

  • 过渡动画:0.2s 缓动过渡
  • 弹性效果:卡片悬停的轻微缩放
  • 通知动画:滑入滑出的流畅效果
  • 性能优化:使用 transform 而非位置属性

3. 响应式设计

  • 断点系统:768px, 1024px 关键断点
  • 布局适配:Grid 到 Flexbox 的优雅降级
  • 触摸优化:移动端的触摸友好设计
  • 内容优先:确保核心功能在所有设备上可用

性能优化

1. 渲染优化

  • 按需重绘:只在配置变化时重绘 Canvas
  • 防抖处理:输入框变化的防抖优化
  • 虚拟滚动:大量预设选项的性能优化
  • 懒加载:非关键组件的延迟加载

2. 内存管理

  • Canvas 清理:及时清理 Canvas 上下文
  • 事件监听器:组件卸载时的清理工作
  • 图片缓存:合理的图片缓存策略
  • 内存泄漏:定期检查和修复内存泄漏

3. 代码分割

  • 组件分割:按功能模块分割组件
  • 路由分割:支持未来的多页面扩展
  • 第三方库:按需引入外部依赖
  • Tree Shaking:移除未使用的代码

开发难点与解决方案

1. Canvas 文字渲染

难点:不同字体大小下的文字居中对齐

解决方案

  • 使用 textAlign: 'center'textBaseline: 'middle'
  • 精确计算文字位置为画布中心点
  • 添加文字阴影增强视觉效果

2. 响应式布局

难点:复杂的多栏布局在不同屏幕下的适配

解决方案

  • 使用 CSS Grid 实现灵活的栅格系统
  • 关键断点处的布局重构
  • 移动端优先的设计思路

3. 颜色选择器集成

难点:原生颜色选择器的样式定制

解决方案

  • 结合原生 input[type="color"] 和文本输入
  • 自定义样式覆盖浏览器默认样式
  • 提供颜色预设作为快捷选项

4. 文件下载兼容性

难点:不同浏览器的下载行为差异

解决方案

  • 使用标准的 <a> 标签下载方式
  • 动态创建和销毁下载链接
  • 添加错误处理和用户反馈

产品化考虑

1. 商业价值

  • 开发效率:显著提升前端开发中的占位图片制作效率
  • 设计一致性:确保项目中占位图片的统一性
  • 成本节约:减少对第三方占位图片服务的依赖
  • 定制化:满足特定项目的个性化需求

2. 市场定位

  • 目标用户:前端开发者、UI/UX 设计师、产品经理
  • 使用场景:原型设计、开发调试、演示展示
  • 竞争优势:本地化、定制化、无依赖
  • 扩展性:支持更多格式和高级功能

3. 功能扩展

  • 批量生成:一次生成多个不同尺寸的图片
  • 模板系统:更丰富的行业模板库
  • 云端同步:配置和模板的云端存储
  • API 接口:提供程序化调用接口

4. 技术演进

  • PWA 支持:离线使用能力
  • WebAssembly:更高性能的图像处理
  • AI 集成:智能推荐和自动优化
  • 协作功能:团队共享和协作

部署与维护

1. 构建优化

npm run build
  • 代码压缩和混淆
  • 资源优化和缓存
  • 静态资源 CDN 部署
  • 性能监控集成

2. 质量保证

  • 代码规范:ESLint + Prettier
  • 类型检查:TypeScript 集成
  • 单元测试:Vue Test Utils
  • E2E 测试:Cypress 自动化测试

3. 监控运维

  • 错误监控:Sentry 错误追踪
  • 性能监控:Web Vitals 指标
  • 用户分析:使用行为统计
  • A/B 测试:功能优化验证

总结

这个图片占位符生成器项目展示了现代前端开发的最佳实践:

  1. 技术选型合理:Vue 3 + Vite 提供了优秀的开发体验
  2. 架构设计清晰:组件化、模块化的代码结构
  3. 用户体验优秀:直观的界面和流畅的交互
  4. 代码质量高:详细注释、错误处理、性能优化
  5. 扩展性强:为未来功能扩展预留了空间

项目的每个细节都经过深思熟虑,从技术实现到用户体验,都体现了工程化和产品化的思维。 在线体验:vitejs-vite-duplicat-yh4q.bolt.host/ gitee地址:

Vue Vapor 事件机制深潜:从设计动机到源码解析

基于 vue@3.6alpha 阶段)及 Vapor 的最新进展撰写;Vapor 仍在演进中,部分实现可能继续优化。

TL;DR(速览)

  • 传统(≤3.5) :事件以元素为中心绑定;每个元素用 el._vei 保存 invoker,运行时通过 addEventListener 直绑;调用走 callWithErrorHandling,有错误上报链路。
  • Vapor(3.6 引入)全局事件委托;首次遇到某个事件类型,只在 document 绑定一次统一处理器;元素仅存 $evt{type} 句柄(可能是数组);非冒泡或带 once/capture/passive 的事件改为直绑。
  • 注意.stopVapor 里只能阻断 Vapor 自己的委托分发,阻断不了你手动 addEventListener 的原生监听;且 Vapor 的统一处理器默认不包 try/catch,异常可能中断委托链。

一、为什么 Vue 要引入 Vapor 模式?

1. 虚拟 DOM 的局限

虚拟 DOMVDOM)带来抽象、跨平台与统一渲染接口的好处,但不是“零成本”:

  • 每次更新往往重建整棵 VNodeJS 对象创建与 GC 压力显著)。
  • 需要递归 diff 比较,天然多了一层计算。
  • 大规模、频繁更新UI(如复杂表格、拖拽、实时刷新的仪表盘)中,这层开销会积累成瓶颈。

2. Vue 已有的优化手段

  • 静态提升(Static Hoisting :将不变节点提取出渲染循环。
  • Patch Flags:编译时给动态片段打标,运行时只检查标记处。
  • 事件/插槽缓存:减少重复创建。

这些措施让 VDOM 更高效,但结构性开销仍在。

3. Vapor 的设计动机

Vapor 是一种更激进的编译驱动策略:

  • 绕过运行时 VDOM,模板编译为“直接 DOM 操作”的最小更新程序。
  • 依赖图直达 DOM:每个响应式依赖对应精准更新逻辑。
  • 减少遍历、对象创建与比较,贴近原生性能与体积。

4. 对事件机制的影响

  • 传统模式:是否增删事件监听通常在 VNode diff 过程中决策。
  • Vapor 模式:编译期即可分析并决定“委托或直绑”,因此引入了全局事件委托方案来降低监听器数量与运行时成本。

二、传统事件机制回顾(≤3.5)

1. invoker_vei

  • 每个 DOM 元素挂载一个 el._vei = {},保存不同事件名的 invoker
  • 绑定:若已有同名 invoker仅改 .value;否则 addEventListener 新增。
  • 卸载:找到 invoker 后移除监听并清理。

源码节选(文件:packages/runtime-dom/src/modules/events.ts)——入口 patchEvent_vei 缓存

const veiKey = Symbol('_vei')

export function patchEvent(
  el: Element & { [veiKey]?: Record<string, Invoker | undefined> },
  rawName: string,
  prevValue: EventValue | null,
  nextValue: EventValue | null,
  instance: ComponentInternalInstance | null = null
) {
  const invokers = el[veiKey] || (el[veiKey] = {})
  const existingInvoker = invokers[rawName]
  if (nextValue && existingInvoker) {
    existingInvoker.value = nextValue // 直接改 invoker.value
  } else {
    const [name, options] = parseName(rawName)
    if (nextValue) {
      const invoker = (invokers[rawName] = createInvoker(nextValue, instance))
      addEventListener(el, name, invoker, options)
    } else if (existingInvoker) {
      removeEventListener(el, name, existingInvoker, options)
      invokers[rawName] = undefined
    }
  }
}

源码节选(同文件)——.once/.passive/.capture 的解析

const optionsModifierRE = /(?:Once|Passive|Capture)$/
function parseName(name: string): [string, EventListenerOptions | undefined] {
  let options: EventListenerOptions | undefined
  if (optionsModifierRE.test(name)) {
    options = {}
    let m
    while ((m = name.match(optionsModifierRE))) {
      name = name.slice(0, name.length - m[0].length)
      ;(options as any)[m[0].toLowerCase()] = true
    }
  }
  const event = name[2] === ':' ? name.slice(3) : hyphenate(name.slice(2))
  return [event, options]
}

源码节选(同文件)——.once/.passive/.capture 的解析

const optionsModifierRE = /(?:Once|Passive|Capture)$/
function parseName(name: string): [string, EventListenerOptions | undefined] {
  let options: EventListenerOptions | undefined
  if (optionsModifierRE.test(name)) {
    options = {}
    let m
    while ((m = name.match(optionsModifierRE))) {
      name = name.slice(0, name.length - m[0].length)
      ;(options as any)[m[0].toLowerCase()] = true
    }
  }
  const event = name[2] === ':' ? name.slice(3) : hyphenate(name.slice(2))
  return [event, options]
}

源码节选(同文件)——createInvoker(纳入 error handling 链路)

function createInvoker(initialValue: EventValue, instance: ComponentInternalInstance | null) {
  const invoker: Invoker = (e: Event & { _vts?: number }) => {
    if (!e._vts) e._vts = Date.now()
    else if (e._vts <= invoker.attached) return
    callWithAsyncErrorHandling(
      patchStopImmediatePropagation(e, invoker.value),
      instance,
      ErrorCodes.NATIVE_EVENT_HANDLER,
      [e]
    )
  }
  invoker.value = initialValue
  invoker.attached = Date.now()
  return invoker
}

源码节选(同文件)——成组处理函数与 stopImmediatePropagation 的打补丁

function patchStopImmediatePropagation(e: Event, value: EventValue): EventValue {
  if (isArray(value)) {
    const originalStop = e.stopImmediatePropagation
    e.stopImmediatePropagation = () => {
      originalStop.call(e)
      ;(e as any)._stopped = true
    }
    return value.map(fn => (e: Event) => !(e as any)._stopped && fn && fn(e))
  } else {
    return value
  }
}

这几段充分证明“传统模式=元素直绑+统一 invoker 缓存+错误上报”的链路。

2. 错误处理链

事件处理调用通过 callWithErrorHandling / callWithAsyncErrorHandling,触发 app.config.errorHandler / errorCaptured

源码节选(文件:packages/runtime-core/src/errorHandling.ts)——同步/异步错误封装

// 同步封装:统一 try/catch 并转交给 handleError
export function callWithErrorHandling(
  fn: Function,
  instance: ComponentInternalInstance | null | undefined,
  type: ErrorTypes,
  args?: unknown[],
): any {
  try {
    return args ? fn(...args) : fn()
  } catch (err) {
    handleError(err, instance, type)
  }
}

// 异步封装:在同步封装之上,对 Promise 结果做 catch 并转交 handleError
export function callWithAsyncErrorHandling(
  fn: Function | Function[],
  instance: ComponentInternalInstance | null,
  type: ErrorTypes,
  args?: unknown[],
): any {
  if (isFunction(fn)) {
    const res = callWithErrorHandling(fn, instance, type, args)
    if (res && isPromise(res)) {
      res.catch(err => { handleError(err, instance, type) })
    }
    return res
  }
  if (isArray(fn)) {
    return fn.map(f => callWithAsyncErrorHandling(f, instance, type, args))
  }
}

作用:无论是同步回调还是返回 Promise 的异步回调,最终都会被包进统一的错误处理通道。

源码节选(文件:packages/runtime-core/src/errorHandling.ts)——错误分发流程核心

export function handleError(
  err: unknown,
  instance: ComponentInternalInstance | null | undefined,
  type: ErrorTypes,
  throwInDev = true,
): void {
  const contextVNode = instance ? instance.vnode : null
  const { errorHandler, throwUnhandledErrorInProduction } =
    (instance && instance.appContext.config) || EMPTY_OBJ

  if (instance) {
    // 1) 自底向上调用父组件的 errorCaptured 钩子
    let cur = instance.parent
    const exposedInstance = instance.proxy
    const errorInfo = __DEV__
      ? ErrorTypeStrings[type]
      : `https://vuejs.org/error-reference/#runtime-${type}`
    while (cur) {
      const hooks = cur.ec
      if (hooks) {
        for (let i = 0; i < hooks.length; i++) {
          if (hooks[i](err, exposedInstance, errorInfo) === false) return
        }
      }
      cur = cur.parent
    }
    // 2) 应用级 errorHandler(app.config.errorHandler)
    if (errorHandler) {
      pauseTracking()
      callWithErrorHandling(errorHandler, null, ErrorCodes.APP_ERROR_HANDLER, [
        err, exposedInstance, errorInfo
      ])
      resetTracking()
      return
    }
  }
  // 3) 最终兜底(开发环境抛出、生产环境 console.error 或按配置抛出)
  logError(err, type, contextVNode, throwInDev, throwUnhandledErrorInProduction)
}

作用:组件级 errorCaptured → 应用级 errorHandler → 最终兜底 的三段式链路,正是你文中“错误处理链”的核心。

源码节选(文件:packages/runtime-core/src/errorHandling.ts)——错误类型标识(节选)

export enum ErrorCodes {
  SETUP_FUNCTION,
  RENDER_FUNCTION,
  NATIVE_EVENT_HANDLER = 5, // 重点:原生事件处理出错会标记为此
  COMPONENT_EVENT_HANDLER,
  /* ... */
  APP_ERROR_HANDLER,
  /* ... */
}

export const ErrorTypeStrings: Record<ErrorTypes, string> = {
  /* ... */
  [ErrorCodes.NATIVE_EVENT_HANDLER]: 'native event handler',
  [ErrorCodes.COMPONENT_EVENT_HANDLER]: 'component event handler',
  /* ... */
}

作用:当原生事件处理抛错时,会以 NATIVE_EVENT_HANDLER 类型进入上面的 handleError 流程,从而被 errorCaptured / app.config.errorHandler 捕获。这也能和你文中“传统模式下 invoker 会通过 callWithAsyncErrorHandling 进入错误链路”的描述首尾呼应。


三、Vapor 事件机制:只在 document 绑一次

1. 设计要点

  • 首次遇见某个事件类型(如 click),在 documentaddEventListener 一次统一处理器,之后不解绑。
  • 元素不再直绑处理函数,而是在节点对象上存一个私有字段,如 $evtclick$evtmousedown 等。
  • 统一处理器根据事件的真实冒泡路径,自下而上查找每个节点的 $evt{type} 并触发。
  • 若同一节点同一事件多次绑定(如不同修饰符),会把 $evt{type} 从单值升级为数组,依序执行。

2. Vapor 委托流程图

flowchart TD
  A[用户触发事件] --> B{document 是否已绑定该事件}
  B -- 否 --> C[调用 delegateEvents]
  C --> C2[在 document 绑定全局监听]
  B -- 是 --> D[统一处理函数 delegatedEventHandler]
  C2 --> D
  D --> E[确定起始节点 为 composedPath 首元素 或 target]
  E --> F{当前节点是否存在}
  F -- 否 --> Z[结束]
  F -- 是 --> G{当前节点是否有事件句柄}
  G -- 否 --> H[移动到父节点 或 host]
  G -- 是 --> I{处理函数是否为数组}
  I -- 是 --> J[依次调用 若 cancelBubble 为真 则返回]
  I -- 否 --> K[调用处理函数 若 cancelBubble 为真 则返回]
  J --> H
  K --> H
  H --> F

源码节选(运行时等价实现示意)——统一注册与统一分发

const delegatedEvents = Object.create(null)
const delegateEvents = (...names: string[]) => {
  for (const name of names) {
    if (!delegatedEvents[name]) {
      delegatedEvents[name] = true
      document.addEventListener(name, delegatedEventHandler)
    }
  }
}

const delegatedEventHandler = (e: Event) => {
  let node = (e as any).composedPath?.()[0] || (e.target as Node)

  if (e.target !== node) Object.defineProperty(e, 'target', { configurable: true, value: node })
  Object.defineProperty(e, 'currentTarget', {
    configurable: true,
    get() { return node || document }
  })

  while (node) {
    const handlers = (node as any)[`$evt${e.type}`]
    if (handlers) {
      if (Array.isArray(handlers)) {
        for (const h of handlers) { if (!(node as any).disabled) { h(e); if ((e as any).cancelBubble) return } }
      } else {
        handlers(e); if ((e as any).cancelBubble) return
      }
    }
    node = (node as any).host instanceof Node && (node as any).host !== node
      ? (node as any).host
      : (node as any).parentNode
  }
}

3. 同一事件多处理函数如何合并为数组

当同一元素的同一事件多次注册(例如不同修饰符)时,编译器多次调用 delegate(el, 'click', handler),把已有单值升级为数组:

function delegate(el: any, event: string, handler: Function) {
  const key = `$evt${event}`
  const existing = el[key]
  if (existing) {
    el[key] = Array.isArray(existing) ? (existing.push(handler), existing) : [existing, handler]
  } else {
    el[key] = handler
  }
}

四、编译期“委托 or 直绑”的判定条件

Vapor 不会对所有事件都用委托;编译器(compiler-vaporvOn transform)规则大致是:

  1. 静态事件名(不是 @[name] 动态);
  2. 没有事件选项修饰符once / capture / passive
  3. 事件在可委托清单内(常见如 clickinputkeydownpointer*touch*focusin/outbeforeinput 等)。

决策流程图

flowchart LR
  A[监听表达式] --> B{事件名是否为静态}
  B -- 否 --> X[直接绑定 addEventListener]
  B -- 是 --> C{是否包含 once capture passive}
  C -- 是 --> X
  C -- 否 --> D{是否在可委托清单}
  D -- 否 --> X
  D -- 是 --> Y[使用 Vapor 委托 元素记录句柄 document 统一分发]

源码节选(文件:packages/compiler-vapor/src/transforms/vOn.ts)——判定条件与清单(示意)

const delegatedEvents = /*#__PURE__*/ makeMap(
  'beforeinput,click,dblclick,contextmenu,focusin,focusout,input,keydown,' +
  'keyup,mousedown,mousemove,mouseout,mouseover,mouseup,pointerdown,' +
  'pointermove,pointerout,pointerover,pointerup,touchend,touchmove,touchstart'
)

const delegate =
  arg.isStatic && !eventOptionModifiers.length && delegatedEvents(arg.content)

五、事件修饰符的实现与 .stop 的“混用陷阱”

1. 修饰符由 withModifiers 包装

编译器将监听函数包到 withModifiers(fn, ['stop','alt',...]) 中。运行时先执行“守卫”,不满足条件则直接 return,否则再调用原处理函数。

源码节选(位置:runtime-dom 暴露 helpers;示意实现)

const systemModifiers = ['ctrl', 'shift', 'alt', 'meta'] as const
const modifierGuards = {
  stop: (e: Event) => e.stopPropagation(),
  prevent: (e: Event) => e.preventDefault(),
  self: (e: Event) => e.target !== (e as any).currentTarget,
  ctrl:  (e: KeyboardEvent) => !e.ctrlKey,
  shift: (e: KeyboardEvent) => !e.shiftKey,
  alt:   (e: KeyboardEvent) => !e.altKey,
  meta:  (e: KeyboardEvent) => !e.metaKey,
  left:   (e: MouseEvent) => 'button' in e && e.button !== 0,
  middle: (e: MouseEvent) => 'button' in e && e.button !== 1,
  right:  (e: MouseEvent) => 'button' in e && e.button !== 2,
  exact: (e: any, mods: string[]) => systemModifiers.some(m => e[`${m}Key`] && !mods.includes(m))
}

export const withModifiers = (fn: Function, modifiers: string[]) => {
  const cache = (fn as any)._withMods || ((fn as any)._withMods = {})
  const key = modifiers.join('.')
  return cache[key] || (cache[key] = (event: Event, ...args: any[]) => {
    for (const m of modifiers) {
      const guard = (modifierGuards as any)[m]
      if (guard && guard(event, modifiers)) return
    }
    return fn(event, ...args)
  })
}

2. .stop 的边界

  • .stope.stopPropagation()统一处理器阶段发生;它能阻断 Vapor 的委托分发
  • 阻断不了你在元素或祖先上手写的原生 addEventListener(那些在真实冒泡阶段就触发了);
  • 混用传统与 Vapor 时,容易出现:“子节点 @click.stop 了,但父节点原生监听仍被触发”的现象。

示例:

<script setup lang="ts" vapor>
import { onMounted, useTemplateRef } from 'vue'
const elRef = useTemplateRef('elRef')
const add1 = () => console.log('add1 clicked')
onMounted(() => {
  elRef.value?.addEventListener('click', () => {
    console.log('native parent listener')
  })
})
</script>

<template>
  <div @click="add1" ref="elRef">
    <div class="div1" @click.stop="add1">add1 按钮</div>
  </div>
</template>

建议

  • 需要 .stop 的路径上,尽量不要并行存在手写原生监听。
  • 或将外层也改为 Vapor 统一委托体系,维持一致的冒泡控制。

六、非冒泡事件:直接绑定

blurmouseenter不冒泡事件,在 Vapor不走委托,直接绑到目标元素上(运行时 _on / addEventListener2)。

源码节选(等价实现)

源码节选(等价实现)

function addEventListener2(el: Element, event: string, handler: any, options?: any) {
  el.addEventListener(event, handler, options)
  return () => el.removeEventListener(event, handler, options)
}
function on(el: Element, event: string, handler: any, options: any = {}) {
  addEventListener2(el, event, handler, options)
  if (options.effect) {
    onEffectCleanup(() => {
      el.removeEventListener(event, handler, options)
    })
  }
}

七、组件事件:仍按“传统”处理

  • 组件事件视作 props 回调(如 onClick)传入子组件;不走文档委托;
  • 若父层写了 @click 但子组件未声明此事件,单根组件会透传到其根 DOM;多根且未 v-bind="$attrs" 时会被丢弃
  • 组件自定义事件不冒泡(区别于 DOM 事件)。

源码节选(编译输出形态示意)

const root = t0()
const vnode = _createComponent(_ctx.DemoVue, {
  onClick: () => _ctx.handleClick,
  'onCustom-click': () => _ctx.handleCustomClick
})

八、自定义原生事件:跨层传递的一把好钥匙

用浏览器的 CustomEvent 可创建会冒泡的原生事件,便于跨层传递(避免层层 props/emit):

Demo.vue

<script setup lang="ts" vapor>
import { useTemplateRef } from 'vue'
const catFound = new CustomEvent('animalfound', {
  detail: { name: '猫' },
  bubbles: true
})
const elRef = useTemplateRef('elRef')
setTimeout(() => elRef.value?.dispatchEvent(catFound), 3000)
</script>

<template>
  <div ref="elRef">I am demo.vue</div>
</template>

App.vue

<script setup lang="ts" vapor>
import DemoVue from './Demo.vue'
const demoAnimalFound = (ev: CustomEvent<{ name: string }>) => console.log(ev)
const divAnimalFound = (ev: CustomEvent<{ name: string }>) => console.log(ev)
</script>

<template>
  <div @animalfound="divAnimalFound">
    <demo-vue @animalfound="demoAnimalFound" />
  </div>
</template>

目前这类事件通常仍按普通原生事件直绑处理。若未来支持 .delegate 修饰符,开发者可“强制”走委托路径,让自定义冒泡事件同样享受监听器数量优化。


九、Vapor vs 传统:关键差异

  • 绑定位置:传统直绑在元素;Vapordocument 绑一次、元素只存句柄。
  • 寻找处理函数:传统靠 VNode diff 决策增删;Vapor 由统一处理器沿真实 DOM 冒泡路径查找 $evt{type}
  • 修饰符:两者都有;Vapor 修饰符实质是“守卫包装”。
  • .stop 行为:传统能阻断原生冒泡;Vapor 仅阻断 Vapor 的委托分发,对并行原生监听无能为力。
  • 错误处理:传统有 callWithErrorHandling 链路;Vapor 统一处理器默认不包 try/catch,需要业务自兜底。
  • 非冒泡事件:两者都直绑。
  • 组件事件:两者都按 props 回调处理;未声明的事件透传行为一致(单根透传、多根未 $attrs 丢弃)。

十、实践建议

  1. 优先场景:大列表/表格/密集交互页面,冒泡事件多、节点多,Vapor 委托能显著减少监听器数量。
  2. 避免混用陷阱:同一路径尽量不要混用 Vapor 委托与手写原生监听;确需混用时,明确 .stop 的边界。
  3. 修饰符与选项:需要 once/capture/passive 的监听会强制直绑;仅在必要时使用这些选项。
  4. 非冒泡事件:按直绑心智处理即可。
  5. 异常兜底:关键处理函数加 try/catch 并上报,避免异常中断委托链且无人感知。
  6. 组件事件:遵循传统心智;多根组件注意 $attrs 透传。

十一、最小示例

1. Vaporclick 编译要点(示意)

<!-- Vapor SFC:<script setup vapor> -->
<div class="row" @click="handleRow"></div>

<!-- 编译核心结果(示意) -->
_delegateEvents('click')            // document 只绑一次
n0.$evtclick = _ctx.handleRow       // 元素只存句柄

2. .stop 与原生监听混用的表现

<script setup lang="ts" vapor>
import { onMounted, useTemplateRef } from 'vue'
const refEl = useTemplateRef('refEl')
const inner = () => console.log('inner clicked')
onMounted(() => {
  // 原生监听:Vapor 的 .stop 无法阻断它
  refEl.value?.addEventListener('click', () => console.log('native parent'))
})
</script>

<template>
  <div ref="refEl">
    <button @click.stop="inner">Click me</button>
  </div>
</template>

结语

Vapor全局事件委托将“把监听器绑在每个元素上”的老路,升级为“在 document一次注册、统一分发”的新路,显著降低监听器数量和运行时开销;同时也带来与传统模式不同的错误传播路径.stop 边界。在 Vapor 与传统并存的阶段,建议你统一事件策略避免混用陷阱,并在关键路径做好异常兜底。当你的页面以大量冒泡事件为主、且节点规模庞大时,Vapor 能带来切实的性能与体积收益。

@scqilin/phone-ui手机外观组件库

@scqilin/phone-ui 手机外观组件库

image.png 写在最前面

最近开发一个移动端编辑器,我需要一个非常轻量的“手机壳”来把内容包起来用于预览与截图。于是做了 @scqilin/phone-ui:一个零依赖、用原生 TypeScript 写的手机外观渲染库,核心目标是简单、可复用、易适配。我的目标很明确:

  • 零依赖:能用在任何项目里,不管是 Vue、React 还是原生 JS。
  • 纯粹:只做“手机外观”这一件事,保持轻量。
  • 易用:API 简单,上手快。

✨ 特点

  • 🚀 零依赖,纯 TypeScript + 动态样式注入
  • 📱 内置 iPhone 16 全系列机型(16/Plus/Pro/Pro Max)
  • 🎨 支持完全自定义外观(尺寸、颜色、按钮等)
  • 💻 适用于任何前端项目
  • 🎯 在线演示 可直接体验

📦 安装

npm install @scqilin/phone-ui

🚀 快速开始

把下面这段代码扔进你的项目,就能看到效果。

HTML 结构:

<div id="phone-demo" style="width: 500px; height: 900px;"></div>

JS 调用:

import { createPhoneUI } from '@scqilin/phone-ui';

const container = document.getElementById('phone-demo');

// 方式一:使用预设机型
createPhoneUI({
  container,
  phoneType: 'iphone16pro'
});

// 方式二:完全自定义
createPhoneUI({
  container,
  width: 400,
  height: 800,
  frameColor: '#1a1a1a',
  screenColor: '#ffffff',
  showButtons: true
});

🤔 设计思路与权衡

1. 为什么是原生库,而不是 Vue/React 组件?

最开始我也想直接写个 Vue 组件,但很快发现,把核心做成原生库更灵活。

  • 框架无关:原生实现意味着最大程度的复用。
  • 关注点分离:核心库只管渲染,框架适配(如 Vue 组件)只管生命周期和数据流。这样结构更清晰,维护也更容易。

2. 样式隔离

为了避免污染宿主页面的样式,我没有让用户引入 CSS 文件,而是通过 JS 动态创建 <style> 标签并注入到 head 中。所有样式都带上了 phone-ui- 前缀,并通过 CSS 变量暴露定制接口,比如 --phone-ui-frame-color

📖 API 文档

createPhoneUI(options)

参数 类型 必填 说明
container HTMLElement 渲染目标容器
phoneType string 机型名称,支持 'iphone16', 'iphone16plus', 'iphone16pro', 'iphone16promax'
width number 自定义宽度(phoneType 存在时无效)
height number 自定义高度(phoneType 存在时无效)
frameColor string 边框颜色,默认 '#1a1a1a'
screenColor string 屏幕颜色,默认 '#ffffff'
showButtons boolean 是否显示侧边按钮,默认 true
borderRadius number 圆角大小,默认 30

📱 支持的机型

机型 phoneType 尺寸 (宽×高)
iPhone 16 'iphone16' 402×874
iPhone 16 Plus 'iphone16plus' 440×950
iPhone 16 Pro 'iphone16pro' 402×874
iPhone 16 Pro Max 'iphone16promax' 440×950

💡 常见问题

  • 屏幕看不见/高度为0:请确保传入的 container 元素有自己的尺寸(宽高)。
  • 样式被覆盖:检查宿主页面是否有更高优先级的选择器。可以尝试通过 CSS 变量覆盖颜色,或用 !important
  • 按钮能点吗:不能,按钮仅为装饰。

📦 关于 Vue 的适配

我最初尝试在 Vue 项目里直接用这个原生库,但发现不太“顺手”:

  1. 插槽不好用:想用 Vue 的 <slot> 功能把内容放进“手机屏幕”里,操作起来很别扭。
  2. 生命周期问题:必须在 onMounted 之后手动调用,在 onBeforeUnmount 手动销毁,很繁琐。

为了解决这个问题,我另外写了一个轻量的 Vue 3 适配包,它做的事情很简单:

  • 把原生库的 API 封装成一个真正的 Vue 组件。
  • onMountedonBeforeUnmount 管理生命周期。
  • 把组件的默认插槽内容正确地渲染到手机屏幕里。

现在,在 Vue 里可以这样用:

<template>
  <PhoneUi phoneType="iphone16pro">
    <!-- 这里的内容会自动放进手机屏幕 -->
    <h1>Hello, Vue!</h1>
  </PhoneUi>
</template>

<script setup>
// 假设你已经安装并配置了 vue 适配包
import PhoneUi from '@scqilin/phone-ui-vue'; 
</script>

这样就舒服多了。这个 Vue 适配包是独立的项目,你可以在这里找到它:

🎉 结语

如果你对这个小项目感兴趣,或者希望支持其他机型,欢迎在 GitHub 上提 Issue 或 PR!

❌