普通视图

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

Threejs全球坐标分布效果

作者 谢小飞
2025年4月8日 23:31

  在浏览网页时,笔者发现华为云一个有趣的效果,就是将地球上布局的城市标注出来,当城市出现在地球正面视线范围内时,就显示出来,而在靠近边缘时,就慢慢的隐藏直至消失不见;那么这种效果是如何实现的呢?里面又包含了哪些的逻辑呢?本文我们就来看下这个效果的实现过程。

  我们先来看下效果示例:

全球坐标分布效果

由于gif文件太大了,这里就不截取动图效果了,感兴趣的小伙伴可以直接滑到文末查看实现的效果。

环境准备

  首先还是准备我们画布的基础环境,初始化场景、相机、渲染器、控制器四件套:

export default class Index {
  constructor() {
    // 初始化场景
    this.scene = initScene();
    this.scene.background = new Color(0xf7f7f7);

    // 初始化相机
    this.camera = initCamera(new Vector3(10, 0, 0), 55, 0.001, 20000);

    // 初始化渲染器
    this.renderer = initRenderer();

    // 初始化控制器哦
    this.controls = initOrbitControls(this.camera, this.renderer);
    // 禁止缩放
    this.controls.enableZoom = false;
    // 阻尼
    this.controls.enableDamping = true;
    // 自动旋转
    this.controls.autoRotate = true;
  }
}

  我们再向场景中添加一个地球,这里我们直接用一个地球的纹理贴图即可:

// 球体的半径大小
const SPHERE_RADIUX = 3;

initMesh() {
    this.loader = new TextureLoader();
    const mat = new MeshBasicMaterial({
        map: this.loader.load("ditu.jpg"),
    });
    const geo = new SphereGeometry(SPHERE_RADIUX);
    const sphere = new Mesh(geo, mat);
    sphere.position.set(0, 0, 0);
    this.scene.add(sphere);
}

  下面就需要在地球🌍上贴上一个个的城市定位了,这里用到一个新的渲染器:CSS2DRenderer,这个渲染器用于将Html元素嵌入到3D场景中去,用于在场景中展示一些额外的信息;比如VR看房时候的标签,使用html标签有更好的操控,比如使用CSS还可以实现点击、悬浮、激活等等效果。

  我们在初始化场景的时候添加一个CSS2DRenderer渲染器:

import {
  CSS2DRenderer,
  CSS2DObject,
} from "three/examples/jsm/renderers/CSS2DRenderer.js";

export default class Index {
  constructor() {
    // 其他场景代码
    const labelRenderer = new CSS2DRenderer();
    labelRenderer.setSize(window.innerWidth, window.innerHeight);
    labelRenderer.domElement.style.position = "absolute"; // 设置渲染器样式
    labelRenderer.domElement.style.top = "0";
    labelRenderer.domElement.style.left = "0";
    labelRenderer.domElement.style.zIndex = "1";
    this.labelRenderer = labelRenderer;
    document.body.appendChild(labelRenderer.domElement);

    // 这里修改
    this.controls = initOrbitControls(this.camera, this.labelRenderer, false);
  }
}

这里要注意的是,我们在初始化controls控制器的时候,需要修改将CSS2DRenderer传入控制器的构造函数中,否则就会出现画布无法转动的情况。

  下面我们就需要将Html标签添加到CSS2DRenderer中去,

const list = [
  { id: 0, name: "北京", lng: 116.39, lat: 39.9 },
  // 省略其他城市
];

const tagsList = [];
for (let i = 0; i < list.length; i++) {
  const { name, lng, lat } = list[i];

  const pos = this.latLongToVector3(lng, lat, SPHERE_RADIUX);

  const box = document.createElement("div");
  box.className = "global_position-box";
  box.innerHtml = name;

  const tag = new CSS2DObject(box);
  tag.position.copy(pos);
  this.scene.add(tag);
  tagsList.push(tag);
}

this.tagsList = tagsList;

  这里在循环列表的时候,需要将数据的经纬度坐标转换成在三维空间里的x、y、z坐标,我们用到一个latLongToVector3函数进行转换处理,我们下面会介绍到这个函数。

  通过CSS2DObject实例化标签后,设置标签的左边;但是将标签添加到页面上去后,我们会看到,如果是在我们视线背面的标签,同时也显现出来了。

CSS2DObject效果

  这样的效果肯定不是我们想要的,因此,我们需要在每次render的时候,就需要不断的去控制每个html标签的透明度,当标签在我们视线后面的时候就设置为0,这才是我们想要的效果;那么标签的透明怎么来计算呢?我们先来学习三个工具函数的使用。

经纬度vs球坐标系

  首先我们要学习的一个就是经纬度和三维球体坐标转换的一个关系函数,它也是Three.js中做三维地图经常会遇到的一个问题,下面我们就来看下它的原理和实现逻辑。

  我们知道在三维坐标系中,我们用xyz能很快确定一个点在空间上的坐标;但是在球体坐标系中,我们需要另外三个参数来确定一个点的位置,我们看下数学中是如何来表示的:

  • 径向距离:也就是我们常说的球体半径,是球面坐标点到球心的距离,用r表示。
  • 极角:是z轴与r的交角,一般用θ表示。
  • 方位角:是赤道面(由 x 轴与 y 轴确定的平面)上起始于 x 轴,沿逆时针方向量出的角度,通常用φ表示。

我们假设地球是一个完美的球体。

球体坐标

需要注意的是,Three.js中,y轴和z轴与数学描述中的位置是相反的,即y轴是纵向的,z轴是从后往前延伸的;这也导致了下面代码中y和z的计算方式与公式的计算方式互换。

  如果用公式来表示,直角坐标和球坐标的对应关系如下:

转换公式

  公式有了,我们下面就要来看公式中的极角θ和方位角φ分别如何来得到;我们知道,纬度是点相对于赤道平面的角度,从-90°的南极到90°的北极,而极角是Three.js中的Y轴和点之间的夹角,因此北极是0,赤道是90,而南极是180;因此我们需要用90减去纬度,再通过度数和弧度的转换即可得到如下代码:

const phi = (90 - latitude) * (Math.PI / 180);

  其次是方位角,由于是沿逆时针方向量出的角度,我们对其取反:

const theta = (longitude + 180) * (Math.PI / 180); 

但是我们实际开发中拿到的地图不一定是标准的地图,需要对方位角进行处理,不一定是加180。

  极角和方位角得到了,我们通过公式得到一个标准的经纬度转换三维空间坐标的函数如下:

/**
 * 将经纬度转换为三维空间坐标
 * @param {number} longitude - 经度(-180到180)
 * @param {number} latitude - 纬度(-90到90)
 * @param {number} radius - 球体半径
 * @returns {THREE.Vector3} 返回Three.js的三维向量坐标
 */
function latLongToVector3(longitude, latitude, radius): Vector3 {
  // 极角(从北极开始)
    const phi = (90 - latitude) * (Math.PI / 180); 
    // 方位角(从本初子午线开始)
    const theta = (longitude + 180) * (Math.PI / 180); 

    // 计算球体上的点坐标(Y轴向上)
    const x = -radius * Math.sin(phi) * Math.cos(theta);
    const y = radius * Math.cos(phi);
    const z = radius * Math.sin(phi) * Math.sin(theta);

    return new THREE.Vector3(x, y, z);
}

角度计算函数

  下面我们再来看一个数学问题:

在三维空间中,已知ABC三个点的坐标,求每个点的角度?

  经常学高数的朋友都知道,不要把它想象成三维,而是一个平面上的三角形;根据下面三角形的余弦定理:

余弦定理

  我们看上面的公式,根据任意三条边的长度,我们都可以计算出角度的余弦值;因此我们需要一个函数来计算三维空间下两个点之间的距离:

// 计算空间上的两个点之间的距离
export function calc3DPointDist(x1, y1, z1, x2, y2, z2) {
  const distX = x2 - x1;
  const distY = y2 - y1;
  const distZ = z2 - z1;
  return Math.sqrt(distX * distX + distY * distY + distZ * distZ);
}

  有了这个函数,我们就可以计算每个角对应边的长度了:

/**
 * 已经ABC三维坐标,求各个点的角度
 * @param {*} A
 * @param {*} B
 * @param {*} C
 * @param {String} pos
 * @returns
 */
export function calc3DAngle(A, B, C, pos = "A") {
  // 三角形每条边的长度
  const a = calc3DPointDist(B.x, B.y, B.z, C.x, C.y, C.z);
  const b = calc3DPointDist(A.x, A.y, A.z, C.x, C.y, C.z);
  const c = calc3DPointDist(A.x, A.y, A.z, B.x, B.y, B.z);
  // ...
}

  将上面的余弦定理继续推导一下,我们可以得到每个角度的cos计算公式:

余弦定理推导

  再利用Math.acos,我们就得到了ABC三个角的角度;再通过传入的参数pos,直接得到我们想要角的角度:

export function calc3DAngle(A, B, C, pos = "A") {
    // 省略上面a、b、c的计算
    let cosA = Math.acos((b * b + c * c - a * a) / (b * c * 2));
    let cosB = Math.acos((a * a + c * c - b * b) / (a * c * 2));
    let cosC = Math.acos((a * a + b * b - c * c) / (a * b * 2));
  
    return {
      A: (cosA * 180) / Math.PI,
      B: (cosB * 180) / Math.PI,
      C: (cosC * 180) / Math.PI,
    }[pos];
}

clamp函数

  clamp函数很多同学可能都没用过,一般在c++或者python中用的比较多,它的作用是;它需要传入三个值:

function clamp(num, min, max) {
  // ...
}

  三个参数分别代表如下含义:

  • num:需要判断的数值。
  • min:范围的最小值。
  • max:范围的最大值。

  为了方便大家理解,我们还是举几个例子🌰来简单看下:

// 返回10,小于最小值,返回最小值
clamp(5, 10, 20)


// 返回16,在返回内,返回原值
clamp(16, 10, 20)


// 返回20,超出最大值,返回最大值
clamp(36, 10, 20)

  通过三个demo,相信大家就能理解这个函数的作用了,函数的实现其实也非常简单:

function clamp(num, min, max) {
  return Math.min(Math.max(num, min), max);
}

显示还是隐藏标签

  对上面两个函数理解后,我们就可以回到地球上坐标的处理了;我们在threejs每次render的时候循环tagsList:

{
  render() {
    // 当前摄像头的位置
    const cameraPos = this.camera.position;

    if (this.tagsList && this.tagsList.length) {
      this.tagsList.map((tag) => {
        const { position, element } = tag;
      });
    }
  }
}

  首先我们将tag打印出来看下,里面有两个属性position和element,是我们所需要的;position属性是一个Vector3类型,表示tag当前的位置信息,element属性是一个dom节点,表示标签对应的dom元素。

  我们想象一下,在三维空间中,我们的相机位置Camera一直在旋转的,因为设置了autoRotate自动旋转;而标签的位置position是固定的;因此这两个点和原点之间就形成了一个特殊的三角形:

三维视角的位置

  临界情况就是以原点为顶点的角正好是90度,此时我们刚刚能看到标签;当相机位置不断旋转时,如果小于90度,我们还是可以看到标签的;但是如果大于90度,标签已经到了球体的后面了。

const originPoint = new Vector3(0, 0, 0)
this.tagsList.map((tag) => {
  const { position } = tag;
  // 以原点为顶点的角度
  const ang = calc3DAngle(cameraPos, originPoint, position, "B");
  // 省略其他
});

这里传入的顺序无所谓,我们只要计算以原点为顶点的角度即可。

  利用上面的calc3DAngle函数,我们将三个位置传入,就可以很轻松的得到角度ang;有了这个角度,我们就可以计算标签的透明度了;我们上面提到了,透明度的临界值就是90度,但是实际上由于视角和球体的缘故,这个角度不是很准确,笔者测试之后大概是在85度左右。

  同时,我们的标签也并不是一下子透明度就从1变到0的,我们需要给它一个缓冲范围,让它也缓缓,在这个范围内会进行变化;这个范围大致就是从80度到85度之间,透明度会从1到0逐渐的变化。

透明度映射

  看到这样的映射关系图,相信大家已经猜到了,没错,这里就要用到我们上面介绍的clamp函数了,我们将角度ang夹到80到85之间,然后使用scale函数进行映射后就得到了我们的透明度opacity,给element元素的样式赋值即可:

/**
 * 映射范围
 * @param {Number} number 需要映射的数值
 * @param {Number} inMin 映射入口的最小
 * @param {Number} inMax 映射入口的最大
 * @param {Number} outMin 映射出口的最小
 * @param {Number} outMax 映射出口的最大
 */
export function scale(number, inMin, inMax, outMin, outMax) {
  return ((number - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
}

const ANG1 = 80;
const ANG2 = 85;

this.tagsList.map((tag) => {
  // 省略其他代码
  const opacity = scale(clamp(ang, ANG1, ANG2), ANG1, ANG2, 1, 0);
  tag.element.style.opacity = opacity;
});

  这样我们的标签就实现了一个过渡变化;最后,在vue页面卸载之后,别忘记还需要将CSS2DRenderer渲染器的dom节点删除,否则会导致页面会有问题:

{
  beforeDestroy() {
    if (this.labelRenderer) {
      document.body.removeChild(this.labelRenderer.domElement);
    }
  }
}

  我们来看下实现的效果,跟原页面的效果已经十分接近了。

本文所有源码敬请关注并在前端壹读后台回复关键词【全球坐标分布】即可获取。

总结

  我们发现很多3D场景下的问题,其本质都是一个个数学问题;本文我们研究了球体和经纬度转换函数、三个点之间的角度计算函数,这两个问题无不考验着我们的数学推理能力;笔者甚至发现,在完成这个案例的过程中,学习的数学知识,甚至比写代码的时间更长、更费时间。

如果觉得写得还不错,敬请关注我的掘金主页。更多文章敬请访问谢小飞的博客

刷刷题50(vue3)

2025年4月8日 23:17

1. Vue3 的响应式系统如何实现深度监听和自定义 Reactive 转换器?

  • 深度监听‌:reactive 默认递归代理嵌套对象,而 shallowReactive 仅代理第一层属性,适用于性能敏感场景‌。

  • 自定义转换器‌:通过 toRaw 获取原始对象,或使用 markRaw 标记对象跳过响应式处理,例如:

    const rawData = { id: 1 };
    const reactiveData = reactive(markRaw(rawData)); // rawData 不会被代理‌:ml-citation{ref="3,4" data="citationList"}。
    

2. 如何利用 effectScope 管理组合式 API 中的副作用(Side Effects)?

effectScope 用于集中管理 watchcomputed 等副作用,避免内存泄漏:

import { effectScope } from 'vue';
const scope = effectScope();
scope.run(() => {
  const count = ref(0);
  watch(count, () => console.log('值变化'));
});
scope.stop(); // 停止所有副作用‌:ml-citation{ref="2,4" data="citationList"}。

场景‌:动态组件卸载时自动清理关联副作用‌。


3. 如何实现 Vue3 响应式数据的“序列化安全”?

  • 问题‌:直接序列化 reactive 对象可能导致循环引用或冗余元数据。
  • 解决方案‌:使用 toRefs 解构响应式对象,或通过 JSON.stringify(reactiveObj) 前调用 toRaw 获取原始数据‌34。

4. Vue3 如何通过 v-bind 在 CSS 中绑定动态值?如何处理组件作用域样式的穿透问题?

  • 动态 CSS 值‌:在 <style> 中使用 v-bind 绑定响应式变量:

    <script setup>
    const color = ref('red');
    </script>
    <style>
    .text { color: v-bind(color); }
    </style>
    
  • 样式穿透‌:使用 :deep() 修改子组件样式:

    :deep(.child-class) { color: blue; } /* 穿透到子组件‌:ml-citation{ref="2,6" data="citationList"} */
    

5. 如何利用自定义渲染器实现 Vue3 在 Canvas 或 WebGL 中的渲染?

通过 createRenderer 自定义节点操作逻辑:

const { createApp } = createRenderer({
  createElement: (tag) => canvas.createShape(tag),
  patchProp: (el, key, val) => el.setAttribute(key, val),
});
createApp(MyComponent).mount('#canvas-container');

应用场景‌:游戏界面、数据可视化大屏‌。


6. Vue3 的编译时性能优化策略有哪些?如何利用 @vue/compiler-sfc 进行自定义模板编译?

  • 优化策略‌:

    • 静态节点提升(Hoist Static)为常量,跳过 Diff 比对‌。
    • 动态标记(Patch Flags)标识节点类型,减少运行时判断‌。
  • 自定义编译‌:通过 compiler-sfc 解析 .vue 文件,修改 AST 实现自定义逻辑(如添加全局样式)‌。


7. 如何通过 watchEffect 的 onInvalidate 参数实现副作用清理?

onInvalidate 在副作用重新执行或组件卸载时触发清理逻辑:

watchEffect((onInvalidate) => {
  const timer = setTimeout(() => {}, 1000);
  onInvalidate(() => clearTimeout(timer)); // 清理定时器‌:ml-citation{ref="3,4" data="citationList"}。
});

8. Vue3 如何通过 defineExpose 控制组件暴露的属性和方法?

在 <script setup> 中,defineExpose 指定父组件可访问的内容:

<script setup>
const internalData = ref('secret');
defineExpose({ publicData: 'exposed' }); // 仅暴露 publicData‌:ml-citation{ref="2,6" data="citationList"}。
</script>

9. 如何结合 TypeScript 泛型与 reactive 实现强类型响应式对象?

通过泛型定义响应式对象结构:

interface User {
  id: number;
  name: string;
}
const user = reactive<User>({ id: 1, name: 'Alice' }); // 类型安全‌:ml-citation{ref="2,4" data="citationList"}。

10. Vue3 的异步组件加载如何结合 Suspense 实现错误边界(Error Boundary)?

通过 Suspense 的 onErrorCaptured 钩子捕获异步组件错误:

vueCopy Code
<template>
  <Suspense @error-captured="handleError">
    <AsyncComponent />
    <template #fallback>Loading...</template>
  </Suspense>
</template>
<script setup>
const handleError = (err) => { console.error(err); };
</script>

场景‌:统一处理接口请求失败或组件加载异常‌。

字节面试题之如何取消一个正在发送的请求

2025年4月8日 22:40

在前端开发中,我们经常需要与后端进行数据交互,也就是发送请求。但在某些场景下,比如用户快速切换页面或者重复点击某个操作,可能需要取消正在发送的请求,避免不必要的资源浪费和潜在的错误。下面将结合不同的请求方式,介绍如何取消正在发送的请求。

在字节的面试中就有这道题目——如何取消一个正在发送的请求 我们如何回答能让面试官为你加分,下面我将带来讲解

屏幕截图 2024-12-29 112044.png

1. XMLHttpRequest

XMLHttpRequest 是早期用于在浏览器和服务器之间进行异步通信的 API。要取消一个使用 XMLHttpRequest 发送的请求,可以使用其 abort() 方法。

// 创建 XMLHttpRequest 实例
const xhr = new XMLHttpRequest();
// 打开请求
xhr.open('GET', 'https://example.com/api/data', true);
// 发送请求
xhr.send();

// 模拟一段时间后取消请求
setTimeout(() => {
    if (xhr.readyState!== 4) {
        xhr.abort();
        console.log('请求已取消');
    }
}, 2000);

在上述代码中,首先创建了一个 XMLHttpRequest 实例并发送了一个 GET 请求。然后使用 setTimeout 模拟在 2 秒后检查请求是否还未完成,如果未完成则调用 abort() 方法取消请求。

2. Fetch API

Fetch API 是现代浏览器提供的用于发起网络请求的新 API,它返回一个 Promise 对象。要取消 Fetch 请求,可以使用 AbortController

// 创建 AbortController 实例
const controller = new AbortController();
const signal = controller.signal;

// 发起 Fetch 请求并关联信号
fetch('https://example.com/api/data', { signal })
   .then(response => {
        if (!response.ok) {
            throw new Error('请求失败');
        }
        return response.json();
    })
   .then(data => {
        console.log('请求成功:', data);
    })
   .catch(error => {
        if (error.name === 'AbortError') {
            console.log('请求已取消');
        } else {
            console.error('请求出错:', error);
        }
    });

// 模拟一段时间后取消请求
setTimeout(() => {
    controller.abort();
}, 2000);

在这个例子中,首先创建了一个 AbortController 实例,并获取其 signal 对象。然后在发起 Fetch 请求时将 signal 作为选项传递进去。当调用 controller.abort() 方法时,Fetch 请求会被取消,Promise 会被拒绝并抛出一个 AbortError

3. Axios

Axios 是一个基于 Promise 的 HTTP 客户端,常用于浏览器和 Node.js 环境。从 Axios v0.22.0 开始,支持使用 AbortController 取消请求。

import axios from 'axios';

// 创建 AbortController 实例
const controller = new AbortController();
const signal = controller.signal;

// 发起 Axios 请求并关联信号
axios.get('https://example.com/api/data', { signal })
   .then(response => {
        console.log('请求成功:', response.data);
    })
   .catch(error => {
        if (axios.isCancel(error)) {
            console.log('请求已取消');
        } else {
            console.error('请求出错:', error);
        }
    });

// 模拟一段时间后取消请求
setTimeout(() => {
    controller.abort();
}, 2000);

这里使用 Axios 发起一个 GET 请求,并将 AbortController 的 signal 传递给请求配置。当调用 controller.abort() 时,请求会被取消,catch 块中可以通过 axios.isCancel(error) 来判断请求是否是被取消的。

4. jQuery.ajax

如果你还在使用 jQuery 进行开发,可以使用 jQuery.ajax 方法发送请求,通过返回的 jqXHR 对象的 abort() 方法来取消请求。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>jQuery AJAX 请求取消示例</title>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head>

<body>
    <script>
        // 发起 jQuery AJAX 请求
        const jqXHR = $.ajax({
            url: 'https://example.com/api/data',
            method: 'GET',
            success: function (data) {
                console.log('请求成功:', data);
            },
            error: function (error) {
                if (error.statusText === 'abort') {
                    console.log('请求已取消');
                } else {
                    console.error('请求出错:', error);
                }
            }
        });

        // 模拟一段时间后取消请求
        setTimeout(() => {
            jqXHR.abort();
        }, 2000);
    </script>
</body>

</html>

在这个 HTML 页面中,使用 jQuery.ajax 发起请求,并将返回的 jqXHR 对象保存起来。在需要取消请求时,调用 jqXHR.abort() 方法。在 error 回调中,可以通过检查 statusText 是否为 abort 来判断请求是否被取消。

在大厂面试中,还经常问道一个很重要但我们经常忽略的问题——垃圾回收机制

垃圾回收(Garbage Collection,简称 GC)是一种自动内存管理机制。在程序运行过程中,会不断地分配内存来存储数据,但当这些数据不再被使用时,就需要释放其所占用的内存,避免内存泄漏。垃圾回收机制就是负责自动检测并回收这些不再使用的内存空间,让开发者无需手动管理内存,从而降低了内存管理的复杂性和出错概率。

JavaScript 与其他企业级语言(以 Java、C# 为例)在垃圾回收方面的区别

1. 内存管理方式

  • JavaScript:JavaScript 通常是在浏览器环境或 Node.js 环境中运行。在浏览器中,不同的浏览器厂商可能会有不同的垃圾回收实现,例如 Chrome 浏览器使用 V8 引擎,它有自己的垃圾回收策略。而且 JavaScript 是一种动态类型语言,内存分配和释放相对灵活,但也可能导致一些难以预测的内存问题。
  • Java:Java 是一种静态类型语言,由 Java 虚拟机(JVM)负责内存管理和垃圾回收。JVM 提供了多种垃圾回收器,开发者可以根据应用的特点选择合适的垃圾回收器,如 Serial、Parallel、CMS、G1 等。
  • C# :C# 运行在.NET 平台上,由公共语言运行时(CLR)进行内存管理和垃圾回收。CLR 的垃圾回收器采用了分代回收的策略,并且提供了一些手动干预垃圾回收的方法,但不建议频繁使用。

2. 垃圾回收的触发时机

  • JavaScript:垃圾回收的触发时机通常由浏览器或 Node.js 环境决定,开发者无法精确控制。例如,V8 引擎会根据内存使用情况、对象分配速率等因素来决定何时进行垃圾回收。
  • Java:JVM 会根据堆内存的使用情况自动触发垃圾回收。当堆内存达到一定阈值时,就会触发相应的垃圾回收器进行回收。开发者也可以通过调用 System.gc() 方法来建议 JVM 进行垃圾回收,但这只是一个建议,JVM 不一定会立即执行。
  • C# :CLR 的垃圾回收器会在内存不足或达到一定的对象分配阈值时自动触发。开发者同样可以调用 GC.Collect() 方法来手动触发垃圾回收,但也不建议滥用。

3. 对性能的影响

  • JavaScript:由于 JavaScript 通常运行在浏览器中,垃圾回收可能会导致页面卡顿,影响用户体验。因此,现代的 JavaScript 引擎都在不断优化垃圾回收算法,以减少对性能的影响。
  • Java:Java 的垃圾回收器在设计上注重性能和吞吐量,不同的垃圾回收器适用于不同的场景。例如,Parallel 垃圾回收器适用于对吞吐量要求较高的场景,而 CMS 垃圾回收器则适用于对响应时间要求较高的场景。
  • C# :CLR 的垃圾回收器也在不断优化,以减少对应用性能的影响。分代回收策略可以有效地提高垃圾回收的效率,减少垃圾回收的时间。

常见的垃圾回收机制

1. 标记 - 清除算法(Mark - Sweep)

  • 原理:该算法分为两个阶段,标记阶段会从根对象(如全局变量、函数调用栈中的变量等)开始遍历所有可达对象,并将它们标记为活动对象;清除阶段会遍历整个堆内存,将未标记的对象视为垃圾对象,并释放它们所占用的内存空间。
  • 优缺点:优点是实现简单;缺点是会产生内存碎片,导致后续的内存分配效率降低。
  • 应用场景:JavaScript 的早期垃圾回收器以及一些简单的垃圾回收系统中会使用该算法。

2. 标记 - 整理算法(Mark - Compact)

  • 原理:在标记 - 清除算法的基础上,标记 - 整理算法在清除阶段会将所有活动对象移动到内存的一端,然后清除掉另一端的所有垃圾对象,从而消除内存碎片。
  • 优缺点:优点是可以解决内存碎片问题;缺点是移动对象的过程会消耗一定的性能。
  • 应用场景:Java 的一些垃圾回收器(如 Serial Old、Parallel Old)会使用该算法。

3. 复制算法(Copying)

  • 原理:将堆内存分为两个相等的区域,每次只使用其中一个区域。当该区域的内存用完时,会将所有活动对象复制到另一个区域,然后清空当前区域。
  • 优缺点:优点是不会产生内存碎片,且复制操作的效率较高;缺点是会浪费一半的内存空间。
  • 应用场景:常用于新生代的垃圾回收,如 Java 的新生代垃圾回收器(如 Serial、Parallel Scavenge)会使用该算法。

4. 分代回收算法(Generational Garbage Collection)

  • 原理:根据对象的存活时间将堆内存分为不同的代,通常分为新生代和老年代。新生代中存放存活时间较短的对象,老年代中存放存活时间较长的对象。不同的代采用不同的垃圾回收算法,例如新生代通常使用复制算法,老年代通常使用标记 - 清除或标记 - 整理算法。
  • 优缺点:优点是可以根据对象的特点选择合适的垃圾回收算法,提高垃圾回收的效率;缺点是需要维护不同代的内存区域,增加了管理的复杂性。
  • 应用场景:Java、C# 等语言的垃圾回收器都采用了分代回收算法。

垃圾回收需要注意的地方

1. 内存泄漏

  • 原因:如果代码中存在一些对象被错误地引用,导致这些对象无法被垃圾回收器回收,就会造成内存泄漏。例如,在 JavaScript 中,如果在事件处理函数中使用了闭包,并且闭包中引用了一些对象,而这些对象在事件处理函数执行完毕后仍然被引用,就可能导致内存泄漏。
  • 解决方法:在不需要使用对象时,及时解除对对象的引用;避免在事件处理函数中使用不必要的闭包;定期检查代码,找出可能存在的内存泄漏问题。

2. 性能问题

  • 原因:垃圾回收操作会占用一定的 CPU 时间,尤其是在进行大规模的垃圾回收时,可能会导致应用程序的性能下降。例如,在 JavaScript 中,如果频繁地创建和销毁对象,会导致垃圾回收器频繁工作,从而影响页面的响应速度。
  • 解决方法:尽量减少不必要的对象创建和销毁;合理使用对象池技术,复用对象,减少垃圾对象的产生;选择合适的垃圾回收器和配置参数,以提高垃圾回收的效率。

3. 跨代引用问题

  • 原因:在分代回收算法中,新生代和老年代之间可能存在对象的引用关系,这会增加垃圾回收的复杂性。例如,当新生代进行垃圾回收时,需要考虑老年代中对新生代对象的引用。
  • 解决方法:使用记忆集(Remembered Set)来记录跨代引用关系,减少垃圾回收时的扫描范围,提高垃圾回收的效率。

要是觉得作者的分享对你有帮助的话,请给作者点个赞吧

屏幕截图 2025-01-03 223802.png

昨天 — 2025年4月8日掘金 前端

SvelteKit 最新中文文档教程(18)—— 浅层路由和 Packaging

作者 冴羽
2025年4月8日 20:17

前言

Svelte,一个语法简洁、入门容易,面向未来的前端框架。

从 Svelte 诞生之初,就备受开发者的喜爱,根据统计,从 2019 年到 2024 年,连续 6 年一直是开发者最感兴趣的前端框架 No.1

image.png

Svelte 以其独特的编译时优化机制著称,具有轻量级高性能易上手等特性,非常适合构建轻量级 Web 项目

为了帮助大家学习 Svelte,我同时搭建了 Svelte 最新的中文文档站点。

如果需要进阶学习,也可以入手我的小册《Svelte 开发指南》,语法篇、实战篇、原理篇三大篇章带你系统掌握 Svelte!

欢迎围观我的“网页版朋友圈”、加入“冴羽·成长陪伴社群”,踏上“前端大佬成长之路”

浅层路由

当您在 SvelteKit 应用中导航时,您会创建历史记录条目。点击后退和前进按钮会遍历这个条目列表,重新运行所有 load 函数,并在必要时替换页面组件。

有时,在不导航的情况下创建历史条目是有用的。例如,您可能想要显示一个模态对话框,用户可以通过返回导航来关闭它。这在移动设备上特别有价值,因为滑动手势通常比直接与 UI 交互更自然。在这些情况下,没有关联历史记录条目的模态可能会令人沮丧,因为用户可能会尝试向后滑动来关闭它,却发现自己到了错误的页面。

SvelteKit 通过 pushStatereplaceState 函数使这成为可能,这些函数允许您在不进行导航的情况下将状态与历史记录条目关联。例如,要实现一个由历史驱动的模态:

<!--- file: +page.svelte --->
<script>
  import { pushState } from '$app/navigation';
  import { page } from '$app/state';
  import Modal from './Modal.svelte';

  function showModal() {
    pushState('', {
      showModal: true
    });
  }
</script>

{#if page.state.showModal}
  <Modal close={() => history.back()} />
{/if}

模态框可以通过返回导航(取消设置 page.state.showModal)或通过交互触发 close 回调运行来关闭。

API

pushState 的第一个参数是相对于当前 URL 的 URL。要保持在当前 URL,使用 ''

第二个参数是新的页面状态,可以通过 page 对象 作为 page.state 访问。您可以通过声明 App.PageState 接口(通常在 src/app.d.ts 中)来使页面状态类型安全。

要设置页面状态而不创建新的历史记录条目,请使用 replaceState 而不是 pushState

[!旧版说明] > $app/state 中的 page.state 是在 SvelteKit 2.12 中添加的。如果您使用的是较早版本或正在使用 Svelte 4,请使用 $app/stores 中的 $page.state

为路由加载数据

在进行浅层路由时,您可能想在当前页面内渲染另一个 +page.svelte。例如,点击照片缩略图可以弹出详细视图,而不需要导航到照片页面。

为此,您需要加载 +page.svelte 所需的数据。一个便捷的方法是在 <a> 元素的 click 处理程序中使用 preloadData。如果元素(或其父元素)使用 data-sveltekit-preload-data,数据将已经被请求,preloadData 将复用该请求。

<!--- file: src/routes/photos/+page.svelte --->
<script>
  import { preloadData, pushState, goto } from '$app/navigation';
  import { page } from '$app/state';
  import Modal from './Modal.svelte';
  import PhotoPage from './[id]/+page.svelte';

  let { data } = $props();
</script>

{#each data.thumbnails as thumbnail}
  <a
    href="/photos/{thumbnail.id}"
    onclick={async (e) => {
      if (innerWidth < 640        // 如果屏幕太小则退出
        || e.shiftKey             // 或链接在新窗口中打开
        || e.metaKey || e.ctrlKey // 或新标签页中打开 (mac: metaKey, win/linux: ctrlKey)
        // 也应考虑鼠标滚轮点击
      ) return;

      // 阻止导航
      e.preventDefault();

      const { href } = e.currentTarget;

      // 运行 `load` 函数(或者说,获取由于 `data-sveltekit-preload-data`
      // 而已经在运行的 `load` 函数的结果)
      const result = await preloadData(href);

      if (result.type === 'loaded' && result.status === 200) {
        pushState(href, { selected: result.data });
      } else {
        // 出现问题!尝试导航
        goto(href);
      }
    }}
  >
    <img alt={thumbnail.alt} src={thumbnail.src} />
  </a>
{/each}

{#if page.state.selected}
  <Modal onclose={() => history.back()}>
    <!-- 将页面数据传递给 +page.svelte 组件,
         就像 SvelteKit 在导航时那样 -->
    <PhotoPage data={page.state.selected} />
  </Modal>
{/if}

注意事项

在服务端渲染期间,page.state 始终是一个空对象。对于用户首次访问的页面也是如此 — 如果用户重新加载页面(或从另一个文档返回),状态将不会应用,直到他们进行导航。

浅层路由是一个需要 JavaScript 才能工作的功能。在使用它时要谨慎,并尝试考虑在 JavaScript 不可用时的合理后备行为。

Packaging

您可以使用 SvelteKit 来构建应用程序和组件库,使用 @sveltejs/package 包(npx sv create 提供了设置此功能的选项)。

在创建应用程序时,src/routes 的内容是对外公开的部分;src/lib 包含应用程序的内部库。

组件库的结构与 SvelteKit 应用程序完全相同,区别在于 src/lib 是对外公开的部分,而根目录下的 package.json 用于发布包。src/routes 可能是随库附带的文档或演示站点,也可能只是开发时使用的沙箱。

运行 @sveltejs/package 提供的 svelte-package 命令会将 src/lib 的内容生成到一个 dist 目录中(可以配置),其中包括以下内容:

  • src/lib 中的所有文件。Svelte 组件会被预处理,TypeScript 文件会被转译为 JavaScript。
  • 为 Svelte、JavaScript 和 TypeScript 文件生成类型定义(d.ts 文件)。您需要安装 typescript >= 4.0.0 来支持此功能。类型定义文件会被放置在实现文件旁边,手动编写的 d.ts 文件将原样复制。您可以禁用生成,但我们强烈建议不要这样做 —— 使用您库的用户可能会需要这些文件来支持 TypeScript。

[!注意] @sveltejs/package 的第 1 版会生成一个 package.json。现在不再如此,它会使用项目中的 package.json 并验证其正确性。如果您仍然使用第 1 版,请查看此 PR 获取迁移说明。

package.json 的结构

因为您现在正在为公共使用构建一个库,因此 package.json 的内容变得更为重要。通过它,您可以配置包的入口点、发布到 npm 的文件以及库的依赖。我们将逐一介绍最重要的字段。

name

这是您包的名称,其他人可以使用该名称安装您的包,并可在 https://npmjs.com/package/<name> 网站上看到它。

{
"name": "your-library"
}

此处阅读关于它的更多内容。

license

每个包都应有一个 license 字段,以告知人们如何使用它。目前非常流行的一种许可证是 MIT,它在分发和复用方面非常宽松且无需担保。

{
"license": "MIT"
}

此处阅读关于它的更多内容。请注意,应在包中包含一个 LICENSE 文件。

files

该字段告诉 npm 哪些文件将被打包并上传到 npm。它应包含输出文件夹(默认为 dist)。您的 package.jsonREADMELICENSE 文件会始终被包括在内,因此您不需要指定它们。

{
"files": ["dist"]
}

要排除不必要的文件(如单元测试,或者仅从 src/routes 导入的模块等)可以将它们添加到 .npmignore 文件中。这将导致包更小,安装速度更快。

此处阅读关于它的更多内容。

exports

"exports" 字段包含包的入口点。如果您通过 npx sv create 设置了一个新的库项目,它会设置为单一出口,即包的根目录:

{
"exports": {
".": {
"types": "./dist/index.d.ts",
"svelte": "./dist/index.js"
}
}
}

这告诉打包工具和工具链,您的包只有一个入口点,即根目录,所有内容应通过以下方式导入:

// @errors: 2307
import { Something } from 'your-library';

typessvelte 键是导出条件,它们告诉工具在查找 your-library 导入时应引入哪个文件:

  • TypeScript 看到 types 条件,会查找类型定义文件。如果您不发布类型定义,请忽略此条件。
  • 支持 Svelte 的工具会看到 svelte 条件,知道这是一个 Svelte 组件库。如果您发布的库不导出任何 Svelte 组件,并且也可以在非 Svelte 项目中使用(如 Svelte store 库),您可以将此条件替换为 default

[!注意] 早期版本的 @sveltejs/package 还添加了一个 package.json 导出。这不再是模板的一部分,因为所有工具都可以处理没有明确导出的 package.json

您可以根据需要调整 exports 并提供更多入口点。例如,如果您想直接暴露 src/lib/Foo.svelte 组件而不是通过 src/lib/index.js 文件重新导出组件,您可以创建以下导出映射……

{
"exports": {
"./Foo.svelte": {
"types": "./dist/Foo.svelte.d.ts",
"svelte": "./dist/Foo.svelte"
}
}
}

……然后您的库的使用者可以用如下方式导入该组件:

// @filename: ambient.d.ts
declare module 'your-library/Foo.svelte';

// @filename: index.js
// ---cut---
import Foo from 'your-library/Foo.svelte';

[!注意] 请注意,如果您提供类型定义,采用此方式可能需要额外处理。在此处阅读关于此问题的更多详细信息。

通常,exports 映射的每个键都是用户从您的包中导入某些内容的路径。而值则是将被导入的文件的路径或包含这些文件路径的导出条件映射。

此处阅读关于 exports 的更多内容。

svelte

这是一个遗留字段,用于让工具识别 Svelte 组件库。如果使用 svelte 导出条件,它已不再必要,但为了向尚未了解导出条件的过时工具提供兼容性,建议保留它。它应指向您的根入口点。

{
"svelte": "./dist/index.js"
}

sideEffects

package.json 中的 sideEffects 字段用于让打包工具判断模块是否可能包含副作用。如果模块在被导入时对其他脚本可见的行为产生变化(例如修改全局变量或内置 JavaScript 对象的原型),则视为有副作用。由于副作用可能会影响应用程序的其他部分,这些文件/模块无论其导出是否在应用程序中使用,都会被包括在最终的打包文件中。

sideEffects 字段中指定的模块会帮助打包工具更积极地从最终的打包文件中剔除未使用的导出(即 tree-shaking),从而生成更小更高效的打包文件。不同的打包工具以不同的方式处理 sideEffects。尽管 Vite 不需要此配置,但建议为库声明所有 CSS 文件具有副作用,以保持与 webpack 兼容。新创建的项目中的默认配置如下:

/// file: package.json
{
"sideEffects": ["**/*.css"]
}

如果您的库中的脚本存在副作用,请确保更新 sideEffects 字段。在新创建的项目中,所有脚本默认标记为无副作用。如果错误地将包含副作用的文件标记为没有副作用,可能会导致功能异常。

如果您的包中有副作用的文件,可以通过数组指定这些文件:

/// file: package.json
{
"sideEffects": ["**/*.css", "./dist/sideEffectfulFile.js"]
}

这样只会将指定的文件视为有副作用的文件。

TypeScript

即使您自己不使用 TypeScript,也应为您的库提供类型定义,这样使用您库的人可以获得正确的智能提示。@sveltejs/package 让生成类型的过程对您来说基本上是透明的。默认情况下,在打包您的库时,会为 JavaScript、TypeScript 和 Svelte 文件自动生成类型定义。您只需要确保 exports 映射中的 types 条件指向正确的文件。当通过 npx sv create 初始化库项目时,会自动设置为根导出。

然而,如果您除了根导出还有其他内容,例如提供 your-library/foo 导入,您需要额外注意提供类型定义。不幸的是,默认情况下 TypeScript 不会 为这种导出解析 types 条件,比如 { "./foo": { "types": "./dist/foo.d.ts", ... }}。相反,它会从库的根目录(即 your-library/foo.d.ts 而不是 your-library/dist/foo.d.ts)查找 foo.d.ts 文件。为了解决这个问题,您有两种选择:

第一种选择是要求使用您库的人在其 tsconfig.json(或 jsconfig.json)中将 moduleResolution 选项设置为 bundler(从 TypeScript 5 开始可用,未来是最佳推荐选项)、node16nodenext。这会使 TypeScript 实际查看 exports 映射并正确解析这些类型。

第二种选择是滥用 TypeScript 的 typesVersions 特性连接类型。typesVersionspackage.json 中的一个字段,TypeScript 根据 TypeScript 版本检查不同类型定义,同时也包含路径映射功能。我们利用该路径映射功能来满足需求。对于上面提到的 foo 导出,相应的 typesVersions 定义如下:

{
"exports": {
"./foo": {
"types": "./dist/foo.d.ts",
"svelte": "./dist/foo.js"
}
},
"typesVersions": {
">4.0": {
"foo": ["./dist/foo.d.ts"]
}
}
}

>4.0 表示如果使用的 TypeScript 版本大于 4,则 TypeScript 会检查内部映射。内部映射告诉 TypeScript your-library/foo 的类型定义在 ./dist/foo.d.ts 中,这实际上是对 exports 条件的复制。您还可以使用 * 通配符一次性提供多个类型定义而无需重复。如果选择使用 typesVersions,您需要通过它声明所有类型导入,包括根导入(定义为 "index.d.ts": [..])。

您可以在此处 阅读有关该功能的更多信息。

最佳实践

除非您计划将包仅供其他 SvelteKit 项目使用,否则应避免在包中使用 SvelteKit 特定模块(如 $app/environment)。例如,与其使用 import { browser } from '$app/environment',不如使用 import { BROWSER } from 'esm-env'参见 esm-env 文档)。您可能还希望将当前 URL 或导航操作作为 prop 传入,而不是直接依赖 $app/state$app/navigation 等。这种更通用的编写方式还会使测试、UI 演示等工具的设置变得更加容易。

svelte.config.js(而非 vite.config.jstsconfig.json)中通过 aliases 添加别名,以便它们被 svelte-package 处理。

应仔细考虑对包的更改是错误修复、新功能还是重大更改,并相应地更新包版本。注意,如果从现有库中移除任何 exports 路径或其内的任何 export 条件,应将其视为重大更改。

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
// 将 `svelte` 更改为 `default` 是重大更改:
---"svelte": "./dist/index.js"---
+++"default": "./dist/index.js"+++
    },
// 移除此项是重大更改:
---"./foo": {
      "types": "./dist/foo.d.ts",
      "svelte": "./dist/foo.js",
      "default": "./dist/foo.js"
    },---
// 添加此项是可以的:
+++"./bar": {
      "types": "./dist/bar.d.ts",
      "svelte": "./dist/bar.js",
      "default": "./dist/bar.js"
    }+++
  }
}

选项

svelte-package 接受以下选项:

  • -w/--watch — 监听 src/lib 的文件更改并重新构建包
  • -i/--input — 包含包所有文件的输入目录。默认为 src/lib
  • -o/--output — 处理后的文件写入的输出目录。您的 package.jsonexports 应指向该文件夹内的文件,files 数组也应包含该文件夹。默认为 dist
  • -t/--types — 是否创建类型定义(d.ts 文件)。我们强烈建议这样做,因为它有助于提升生态系统库的质量。默认为 true
  • --tsconfig — tsconfig 或 jsconfig 的路径。如果未提供,则会在工作区路径中搜索最近的 tsconfig/jsconfig。

发布

要发布生成的包:

npm publish

限制

所有的相对文件导入需要完全指定路径,遵守 Node 的 ESM 算法。这意味着对于像 src/lib/something/index.js 这样的文件,必须包括文件名和扩展名:

// @errors: 2307
import { something } from './something+++/index.js+++';

如果您使用 TypeScript,您需要以同样的方式导入 .ts 文件,但使用 .js 文件后缀而不是 .ts 文件后缀。(这是一个 TypeScript 的设计决策,超出我们的控制范围。)在您的 tsconfig.jsonjsconfig.json 中设置 "moduleResolution": "NodeNext" 将有助于解决这个问题。

除 Svelte 文件(预处理)和 TypeScript 文件(转换为 JavaScript)外,所有文件都按原样复制。

Svelte 中文文档

点击查看中文文档:

  1. SvelteKit 浅层路由
  2. SvelteKit Packaging

系统学习 Svelte,欢迎入手小册《Svelte 开发指南》。语法篇、实战篇、原理篇三大篇章带你系统掌握 Svelte!

此外我还写过 JavaScript 系列TypeScript 系列React 系列Next.js 系列冴羽答读者问等 14 个系列文章, 全系列文章目录:github.com/mqyqingfeng…

欢迎围观我的“网页版朋友圈”、加入“冴羽·成长陪伴社群”,踏上“前端大佬成长之路”

聊聊前端埋点 clarity:我会一直视监你... 永远...

作者 imoo
2025年4月8日 20:01

前言

最近想简单看一下项目的使用情况。做产品切忌自嗨,写了很久的模块,结果只有自己用,还更新的挺起劲,这就无意义了。

为了防止这种情况,我们一般会选择接入一个埋点平台,进行页面点击、曝光等操作的上报,最后对这些数据进行分析,就可以知道具体的情况了。

不过今天暂时不聊这种传统的埋点,介绍一下微软的埋点之作:clarity

展示

如图所示,这是一个正常的用户窗口,进行了一些简单的点击操作

与此同时,另外一边,我们在埋点 平台,居然能看到用户正在做什么?!

不是哥们?连轨迹都给你录下来了,看完这个功能,我脑子里只有这些表情包

同时也深深敬佩微软的工程师,如此复杂的监控页面,是怎么通过几个埋点事件就可以完美呈现的。只能想到个dom 快照,但深究起来真是半点不懂。

更夸张的是,接入只需要三行代码。是的,你只需要在项目里加入这三行代码,就可以视监你的用户了。

import Clarity from '@microsoft/clarity';
const projectId = 'xxxxxxxx';
Clarity.init(projectId);

完整接入流程

  1. 添加一个项目

clarity.microsoft.com/projects?sn…

  1. 选一种方案接入,一般就 npm 了

// 接入到起始文件中,index.js / main.js 等
import Clarity from '@microsoft/clarity';
const projectId = 'xxxxxxxx';
Clarity.init(projectId);
  1. 在设置中找到项目id,替换掉刚才的 projectId

  1. 到这就完工了,不过检查一下项目运行时有没有正常触发埋点(埋点为 collect)

搞定,非常简单,之后我们就可以直接在平台视监用户了

略微配置

更详细的笔者还没研究,且分享一个 case,我们在录制的时候往往需要用户的信息,来知道是谁进行了操作,我们可以通过设置标签来达成这一效果。

也就是调用了一下 setTag 方法

export const initClarity = (user: string) => {
  Clarity.init(projectId);
  Clarity.setTag('user', user);
};

over,今天的分享很简单,下班!

Expo 入门指南:让 React Native 开发更轻松(含环境搭建)

作者 墨渊君
2025年4月8日 19:53

什么是 Expo

  1. 类似于 NextJSReact, ExpoReact Native 版的 NextJS, 它是在 React Native 基础之上再建立一层框架, 解决了许多开发上的麻烦以及增加了许多 API
  2. 提供了基于文件的路由、高质量通用库等功能、以及编写无需管理本机文件即可修改本机代码的插件的功能
  3. 完整的工具生态系统, 可帮助您编写、构建、更新、提交和监控移动应用程序
  4. 使应用程序的开发变得更加容易, 例如基于文件的路由、本机模块的标准库等等
  5. Expo 提供了各种可以独立使用的工具和服务, 因此答案取决于您选择使用哪些工具

一、创建项目

开始前, 参考 Expo 官网, 我们先快速把项目搭建起来。在终端执行下列命令, 该命令将会在 当前目录 下创建一个 新的项目

npx create-expo-app@latest

image

上面 👆🏻 命令执行完毕, 则会在目录下多出来一个项目文件夹 [项目名]

image

于此同时, 官方其实给我们提供了许多模版, 这里我直接采用了默认的模版! 这里可以根据自己的需求, 采用适合自己的模版, 更多的信息可以 查阅官方文档

image

在官方文档中提供了多种 开发环境选择, 可根据需要自行配置! 下面则介绍几种 个人 认为比较重要的几种方式(仅 IOS)! 具体的文档可 查阅官方文档

image

二、 开发环境: 像 Web 一样进行开发调试

项目目录下, 执行下列命令, 启动项目:

npm start

image

如上图所示, 项目起来后, 在终端返回信息中, 会有对应 Wbe 页面地址, 可在浏览器中直接进行访问:

image

既然浏览器可以直接进行访问, 那我们自然也可以在开发阶段直接在浏览器中进行简单调试、开发...

三、开发环境: Expo Go

如果你需要再真机上快速访问, 那么使用 Expo Go 应该是最方便、快捷的。

首先先运行下列命令, 启动项目:

npm start

如下图所示, 项目起来后, 终端返回信息中, 你会发现有个二维码:

image

下面我们可以通过官方 App, 通过扫描该二维码来访问本地运行的项目:

  1. 首先需要先下载官方应用 Expo Go, 直接在 App Store 上检索、下载、安装即可。
  2. 然后打开 相机拍照功能, 扫描二维码, 相机画面中则会出现 「在 Expo Go 中打开」的提示按钮, 点击提示按钮即可访问项目 (注意: 这一步方法仅限 IOS, 至于安卓要怎么操作就不知道了)

image

手机访问效果如下:

image

四、开发环境: 模拟器(IOS)

常规开发阶段, 我们一般都是使用本地 IOS 模拟器来进行开发的, 本节就「如何在本地通过 IOS 模拟器运行项目?」进行展开说明....

4.1 配置 Xcode

第一步安装 Xcode: Mac 打开 App Store 搜索 Xcode 然后选择安装或更新

image

第二步安装 Xcode 命令行工具: 打开 Xcode 配置页, 切换到 Locations, 在 Command Line Tools 下拉菜单中选择最新版本进行安装。这里需要注意的是我们打开配置页时, 可能默认情况下它就选中最新的一个版本, 但是呢实际上我们并没有安装成功。这里我们需要手动打开下拉框点击一下那个版本(这样才会触发更新), 这时不出意外会出现 Mac 身份校验的弹窗, 根据提示完成安装就行...

image

第三步下载组件, 如下, 下载对应 IOS 组件

image

4.2 安装 Watchman

Watchman 主要用于监听文件变更并触发相应的操作, 它由 Meta(Facebook) 开发, 广泛用于前端开发、React Native 开发、BabelJest 等场景! 主要作用:

  1. 文件监控: 高效监听文件系统的变化 (增删改)
  2. 自动触发任务: 结合 watchman watch 规则, 可实现在文件变化时执行 自动构建热更新测试等一系列操作
  3. 高性能文件索引: 通过增量更新和缓存, 减少全量扫描的性能开销, 提高查询速度
  4. 配合 Jest 进行测试优化: Jest 依赖 Watchman 监听测试文件变化, 加速测试执行
  5. 提高 Git 操作效率: Mercurial(类似 Git 的版本管理工具) 使用 Watchman 来优化 statuslog 等操作,使代码仓库管理更高效

总之安装它将带来更好的性能

brew update
brew install watchman

4.3 在 IOS 模拟器上运行应用程序

先在项目的根目录中运行以下命令来安装 expo-dev-client:

npm start

项目运行后, 在终端输入 i, 稍等片刻, 即可通过模拟器启动项目:

image

五、开发环境: 真机调试(不走 Expo)

在产品开发、迭代过程中, 我们难免会遇到一些棘手的 BUG 需要再真机上进行调试, 本小节就简单走个流程来简单演示下, 如何使用真机运行我们本地项目!

5.1 配置 Xcode

这一步和上面 👆🏻 是一样的, 如何设置了, 可忽略!

打开 Mac App Store 搜索 Xcode 然后选择安装或更新

image

打开 Xcode 配置页, 切换到 Locations, 在 Command Line Tools 下拉菜单中选择最新版本进行安装。这里需要注意的是我们打开配置页时, 可能默认情况下它就选中最新的一个版本, 但是呢实际上我们并没有安装成功。这里我们需要手动打开下拉框点击一下那个版本(这样才会触发更新), 这时不出意外会出现 Mac 身份校验的弹窗, 根据提示完成安装就行...

image

5.2 安装 Watchman

这一步和上面 👆🏻 是一样的, 如何设置了, 可忽略!

brew update
brew install watchman

5.3 配置项目

在项目根目录下安装 expo-dev-client

npx expo install expo-dev-client

5.4 连接手机

通过 USBMacIPhone 连接起来, 并启用开发者模式:

  • 使用 USBLightning 线将 iOS 设备连接到 Mac。如果出现提示, 是否信任此电脑, 选择信任即可。
  • 打开 Xcode。从菜单栏中选择 Window > Devices and Simulators。您将在 Xcode 中看到一条启用开发者模式的警告, 请忽略即可。 image
  • 在您的 iOS 设备上, 打开 设置 > 隐私和安全, 向下滚动到 开发者模式, 点击开关以启用开发者模式。启用后,设置会显示一条警报,警告你开发者模式会降低设备的安全性。要继续启用开发者模式,请点击警报的重启按钮, 设备重启并解锁后,设备会显示一条警报,确认您要启用开发者模式。点击“打开”,然后在出现提示时输入设备密码。
  • image

5.5 配置证书

如下图所示, 在 XCode 中为账号添加一个证书:

image

开始前, 项目目录下我们需要先执行 npx expo prebuild -p ios 先把 IOS 项目给构建出来

image

然后在项目目录下执行 xed ios, 这时将会在 Xcode 中打开项目下的 IOS 项目

image

这时我们就可以为项目配置证书了

image

配置完成后, 项目下执行 npx expo run:ios --device

npx expo run:ios --device

image

如上命令运行完成后, IPhone 可能会出现下列提示:

image

这时只需要将应用添加至信任:

image

接下来我们需要设置下网络

image

之后, 在下图中填入 IOS 本地项目地址, 选择 Connect 即可:

image

成功运行结果:

image

六、参考

uni-app工程化实战:基于vue-i18n和i18n-ally的国际化方案

2025年4月8日 18:54

前言

嗨,我是不如摸鱼去,这里是我的《uni-app实践专栏》。

今天,我们将深入探讨uni-app项目的国际化实现,据统计,超过70%的全球用户更倾向于使用母语浏览内容,而支持多语言的应用在国际市场的转化率平均提升40%,换句人话来说就是看都看不懂我还买个锤子呢?所以一个产品想要出海,从全球市场获取收益,赚洋人的刀乐,完善的国际化方案是不可或缺的。

本教程将系统化地「拆解」uni-app项目中的国际化实现流程,从环境搭建到实战应用,手把手教你使用vue-i18n结合vscode插件i18n-allyWotUI构建灵活高效的多语言支持系统,包含了各类平台(H5、小程序、App)的适配要点与优化策略。准备好了吗?Let's go! 不对,应该是「出发吧」...哦等等,这不就是国际化的意义所在吗?😉

注意:本项目是基于Vue3和WotUI的uni-app cli框架开发的,如果你使用的是Vue2,请参考uni-app文档进行相应调整。

目录

  1. 安装和配置vue-i18n
  2. 语言文件组织
  3. 多语言切换
  4. 组件国际化
  5. 动态内容处理
  6. 组件库集成
  7. pages.json的国际化
  8. i18n-ally插件

1. 安装和配置vue-i18n

1.1 安装依赖

首先,我们需要安装vue-i18n:

# 使用npm
npm install vue-i18n@9.1.9

# 或者使用yarn
yarn add vue-i18n@9.1.9

# 或者使用pnpm
pnpm add vue-i18n@9.1.9

注意:根据uni-app官方文档建议,Vue3项目需要安装vue-i18n的固定版本9.1.9,和uni-app内部使用的vue-i18n保持一致。

1.2 创建i18n实例

在项目中创建一个专门的目录来存放国际化相关的文件,例如src/locale

// src/locale/index.ts
import { createI18n } from 'vue-i18n'
import zhCN from './zh-CN.json'
import enUS from './en-US.json'
import Locale from 'wot-design-uni/locale'
import WotEnUS from 'wot-design-uni/locale/lang/en-US'

Locale.add({ 'en-US': WotEnUS })

const messages = {
  'zh-CN': {
    ...zhCN
  },
  'en-US': {
    ...enUS
  }
}

// 创建i18n实例
const i18n = createI18n({
  locale: uni.getStorageSync('currentLang') || 'zh-CN', // 默认语言
  fallbackLocale: 'zh-CN',   // 回退语言
  messages,                  // 语言包
  legacy: false              // 启用Composition API模式
})

// 同步组件库语言
Locale.use(i18n.global.locale.value)
uni.setLocale(i18n.global.locale.value)

export default i18n

1.3 在main.ts中注册i18n

// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import i18n from './locale'

const app = createApp(App)
app.use(i18n)
app.mount('#app')

2. 语言文件的组织结构

2.1 基本结构

语言文件通常以JSON格式存储,每种语言一个文件:

src/locale/
  ├── index.ts          # i18n配置和实例
  ├── zh-CN.json        # 中文语言包
  └── en-US.json        # 英文语言包

2.2 语言文件内容

语言文件是键值对的集合,键是唯一标识符,值是对应语言的文本:

// zh-CN.json
{
  "hello": "你好",
  "welcome": "欢迎使用",
  "button": "按钮"
}

// en-US.json
{
  "hello": "Hello",
  "welcome": "Welcome to use",
  "button": "Button"
}

2.3 嵌套结构

对于复杂应用,可以使用嵌套结构组织语言文件:

// zh-CN.json
{
  "common": {
    "confirm": "确认",
    "cancel": "取消"
  },
  "home": {
    "title": "首页",
    "welcome": "欢迎回来"
  }
}

3. 使用useI18nSync钩子实现多语言切换

在项目中,我们实现了一个useI18nSync钩子来同步应用和组件库的语言设置:

// src/hooks/useI18nSync.ts
import { computed, onBeforeMount } from 'vue'
import { Locale } from 'wot-design-uni/locale'
import i18n from '../locale'

const SUPPORTED_LOCALES = [
  'zh-CN',
  'en-US',
]

function setLocale(locale: string, syncComponentLib: boolean = true) {
  if (!SUPPORTED_LOCALES.includes(locale)) {
    console.warn(`不支持的语言: ${locale},将使用默认语言 zh-CN`)
    locale = 'zh-CN'
  }
  uni.setLocale(locale)
  i18n.global.locale.value = locale
  uni.setStorageSync('currentLang', locale)
  if (syncComponentLib) {
    Locale.use(locale)
  }
  return locale
}

function initLocale(defaultLocale: string, syncComponentLib: boolean) {
  const storedLocale = uni.getStorageSync('currentLang') || defaultLocale
  setLocale(storedLocale, syncComponentLib)
}

interface I18nSyncOptions {
  /** 是否同步组件库语言设置 */
  syncComponentLib?: boolean
  /** 默认语言 */
  defaultLocale?: string
}

/**
 * 国际化同步hook
 * @param options 配置选项
 * @returns 国际化相关方法和状态
 */
export function useI18nSync(options?: I18nSyncOptions) {
  const { syncComponentLib = true, defaultLocale = 'zh-CN' } = options || {}
  const currentLang = computed(() => i18n.global.locale.value)
  onBeforeMount(() => {
    initLocale(defaultLocale, syncComponentLib)
  })

  return {
    currentLang,
    setLocale: (locale: string) => setLocale(locale, syncComponentLib),
    supportedLocales: SUPPORTED_LOCALES
  }
}

3.1 在App.vue中初始化语言

<script setup lang="ts">
import { useI18nSync } from './hooks/useI18nSync'

// 初始化国际化设置
const { currentLang, setLocale } = useI18nSync()
</script>

3.2 实现语言切换功能

<template>
  <view class="language-switcher">
    <view class="current-lang">{{ $t('dangQianYuYan') }}: {{ currentLang }}</view>
    <wd-button @click="switchLanguage('zh-CN')">中文</wd-button>
    <wd-button @click="switchLanguage('en-US')">English</wd-button>
  </view>
</template>

<script setup lang="ts">
import { useI18nSync } from '../hooks/useI18nSync'

const { currentLang, setLocale } = useI18nSync()

function switchLanguage(locale: string) {
  setLocale(locale)
}
</script>

4. 在组件中使用国际化文本

4.1 使用Composition API

在Vue3的Composition API中使用i18n:

<template>
  <view class="page">
    <view class="title">{{ t('hello') }}</view>
    <view class="content">{{ t('welcome') }}</view>
    <wd-button>{{ t('button') }}</wd-button>
  </view>
</template>

<script setup lang="ts">
import { useI18n } from 'vue-i18n'

const { t } = useI18n()
</script>

4.2 使用模板语法

直接在模板中使用$t函数:

<template>
  <view class="page">
    <view class="title">{{ $t('hello') }}</view>
    <view class="content">{{ $t('welcome') }}</view>
    <wd-button>{{ $t('button') }}</wd-button>
  </view>
</template>

4.3 动态计算属性

使用computed使内容响应语言变化:

<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'

const { t } = useI18n()

// 使用computed使list响应语言变化
const list = computed(() => [
  {
    id: 'widget',
    name: t('ji-chu'),
    pages: [
      {
        id: 'button',
        name: t('button-an-niu')
      },
      // 其他项...
    ]
  }
])
</script>

5. 处理动态内容的国际化

5.1 平台限制与解决方案

根据uni-app官方文档,由于运行平台限制,目前在小程序和App端不支持插值方式定义国际化。这意味着以下方式在小程序和App端无法正常工作:

// 这种方式在小程序和App端不支持
t('hello', { name: '小明' }) // 使用命名参数
t('hello', ['小明']) // 使用数组参数

为了解决这个问题,我们需要使用自定义的插值方法来处理带参数的翻译。

5.2 带参数的翻译

在小程序和App端,我们需要使用特殊的占位符格式和自定义插值方法来处理包含变量的文本:

// 在语言文件中定义带占位符的文本
// zh-CN.json
{
  "greeting": "你好,{0}!",
  "welcome": "欢迎{0}来到{1}"
}

// 使用时传入参数
t('greeting', ['小明'])
// 输出:你好,小明!

t('welcome', ['小明', 'wot-design-uni'])
// 输出:欢迎小明来到wot-design-uni

注意:我们使用{0}, {1}这样的数字索引占位符,而不是使用命名参数如{name}。这是因为小程序和App端不支持命名参数的插值方式。

5.3 实现插值工具函数

// src/locale/utils.ts
/**
 * 替换字符串中的占位符
 * @param template 模板字符串,如 "Hello {0}, welcome to {1}"
 * @param values 要替换的值数组
 * @returns 替换后的字符串
 */
export function interpolateTemplate(template: string, values: any[]): string {
  return template.replace(/{(\d+)}/g, (_, index) => values[index] ?? '')
}

5.4 扩展t函数支持数组参数

由于小程序和App端的限制,我们需要扩展vue-i18n的t函数,使其能够处理数组参数并应用我们的插值方法:

// src/locale/index.ts
import { createI18n } from 'vue-i18n'
import zhCN from './zh-CN.json'
import enUS from './en-US.json'
import { interpolateTemplate } from './utils'

// 创建i18n实例
const i18n = createI18n({
  locale: 'zh-CN',
  fallbackLocale: 'zh-CN',
  messages: {
    'zh-CN': zhCN,
    'en-US': enUS
  },
  legacy: false
})

// 扩展t函数,支持数组参数插值
// 这是解决小程序和App端不支持插值方式的关键步骤
const originalT = i18n.global.t
i18n.global.t = ((key: string | number, param1?: any, param2?: any) => {
  const result = originalT(key, param1, param2)
  // 检测是否传入了数组参数,如果是则使用我们的插值方法处理
  if (Array.isArray(param1)) {
    return interpolateTemplate(result, param1)
  }
  return result
}) as typeof i18n.global.t

export default i18n

这种扩展方式的优点是:

  1. 保持了与vue-i18n原有API的兼容性
  2. 在小程序和App端也能使用类似的参数传递方式
  3. 统一了不同平台的国际化使用体验

需要注意的是我们仅实现了数组参数的插值,如果需要支持更多参数类型,可以进一步扩展。

5.5 使用示例

<template>
  <!-- 在模板中使用 -->
  <view>{{ $t('greeting', [username]) }}</view>
  <view>{{ $t('welcome', [username, 'wot-design-uni']) }}</view>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'

const { t } = useI18n()
const username = ref('小明')

// 在JS/TS代码中使用
const message = t('greeting', [username.value])
</script>

注意:这种方法在H5、App和小程序等所有平台上都能正常工作,因为我们使用了自定义的插值方法来处理参数,绕过了小程序和App端的限制。

6. 组件库的国际化

WotUI组件库本身支持国际化,我们只需要参考国际化文档进行配置即可。

6.1 同步应用和组件库的语言

使用useI18nSync钩子可以同步应用和组件库的语言设置:

// 同步组件库语言设置
if (syncComponentLib) {
  Locale.use(locale)
}

7. pages.json的国际化

注意:建议仔细阅读uni-app国际化文档

7.1 页面标题国际化

在uni-app中,pages.json中的页面标题可以通过占位符实现国际化:

// pages.json
{
  "pages": [
    {
      "path": "pages/index/index",
      "style": {
        "navigationBarTitleText": "%index-title%"
      }
    }
  ]
}

7.2 语言文件配置

在语言文件中添加对应的翻译:

// zh-CN.json
{
  "index-title": "首页"
}

// en-US.json
{
  "index-title": "Home"
}

7.3 平台差异处理

  • H5平台:直接支持占位符方式
  • 其他平台:可以使用自定义Tabbar和Navbar实现

注意:小程序下不支持这种国际化方案,也可以使用设置tabbar和navigationbar的API来设置文字。或者废弃原生tabbar和navigationbar,使用自定义方式,详情参考uni-app官方文档

8. i18n-ally插件

8.1 插件概述

i18n-ally是VSCode的一款国际化插件,提供以下核心功能:

  • 自动检测项目中的硬编码文本
  • 一键提取待翻译内容
  • 多语言文件管理
  • 翻译辅助工具集成

8.2 安装与配置

  1. 安装插件

    • 在VSCode扩展市场搜索并安装i18n-ally
    • 安装完成后重启VSCode
  2. 基本配置

    • 在项目根目录创建.vscode/settings.json文件
    • 添加以下基础配置:
// .vscode/settings.json
{
  "i18n-ally.sourceLanguage": "zh-CN", // 项目的原始语言(基准语言),通常是开发时使用的默认语言(如 en 或 zh-CN)。
  "i18n-ally.displayLanguage": "zh-CN", // 插件界面中显示的语言(如悬浮提示、侧边栏预览等),方便开发者查看翻译结果。
  "i18n-ally.localesPaths": ["src/locale"],
  "i18n-ally.keystyle": "flat",
  "i18n-ally.sortKeys": true
}

EA778C3467B97ACC788B976AD0D3F805.png

8.3 核心功能详解

8.3.1 自动检测

  • 扫描范围:HTML标签内容、Vue模板、JS/TS代码
  • 支持配置检测规则:
"i18n-ally.extract.parsers.html": {
  "attributes": ["text", "title", "alt", "placeholder", "label", "aria-label"],
  "ignoredTags": ["script", "style"],
  "vBind": true,
  "inlineText": true
}

dbd26d8cf38a855d262267e0b9267bb0.png

8.3.2 一键提取

  • 自动生成翻译键
  • 保持语言文件结构一致
  • 支持批量处理

0cf8e09c-7035-41c7-9adf-c543db3370c1.png

注意:提取的翻译键会自动添加到语言文件中,无需手动添加,但是批量提取的翻译键值会丢失插值参数,例如:哈哈哈${232}应当生成为t('hahaha', [232]),但实际上生成的是t('ha-ha-ha-232-0'),可以选择手动添加插值参数,或者结合7.4.1 重构模板进行优化。

8.3.3 翻译辅助

i18n-ally内置翻译API支持,这里我们使用百度翻译API作为示例:

  1. 注册账号:访问百度翻译开放平台注册账号
  2. 创建应用:获取APP ID和密钥
  3. 配置插件
"i18n-ally.translate.engines": ["baidu"],
"i18n-ally.translate.baidu.appid": "YOUR_APP_ID",
"i18n-ally.translate.baidu.key": "YOUR_SECRET_KEY"

8b9d853f-66d4-48c1-92d9-8b15d09b2578.png

8.4 高级配置

8.4.1 重构模板

"i18n-ally.refactor.templates": [
  {
    "source": "html-inline",
    "template": "{{ $t('{key}'{args}) }}"
  },
  {
    "source": "html-attribute",
    "template": "$t('{key}'{args})"
  }
]

重构模板后,提取的翻译键会自动添加插值参数,例如:哈哈哈${232}${111}会生成为携带插值参数的格式t('hahaha', 232, 111),不过vue-i18nt方法不支持这种格式,所以我们需要再展t函数,支持可变参数,并将其作为数组参数插值。

// src/locale/index.ts
import { createI18n } from 'vue-i18n'
import zhCN from './zh-CN.json'
import enUS from './en-US.json'
import Locale from 'wot-design-uni/locale'
import WotEnUS from 'wot-design-uni/locale/lang/en-US'

Locale.add({ 'en-US': WotEnUS })

const messages = {
  'zh-CN': {
    ...zhCN
  },
  'en-US': {
    ...enUS
  }
}

// 创建i18n实例
const i18n = createI18n({
  locale: uni.getStorageSync('currentLang') || 'zh-CN',
  fallbackLocale: 'zh-CN',
  messages,
  legacy: false
})

Locale.use(i18n.global.locale.value)

uni.setLocale(i18n.global.locale.value)

const originalT = i18n.global.t
i18n.global.t = ((key: string | number, ...args: any[]) => {
  /**
   * 替换字符串中的占位符
   * @param template 模板字符串,如 "Hello {0}, welcome to {1}"
   * @param values 要替换的值数组
   * @returns 替换后的字符串
   */
  function interpolateTemplate(template: string, values: any[]): string {
    return template.replace(/{(\d+)}/g, (_, index) => values[index] ?? '')
  }

  // 处理对象参数场景: t(key, {key1: value1, key2: value2})
  if (args.length === 1 && typeof args[0] === 'object' && !Array.isArray(args[0])) {
    const result = originalT(key, ...args)
    return result
  }

  // 处理数组参数场景: t(key, [arg1, arg2])
  if (args.length === 1 && Array.isArray(args[0])) {
    const result = originalT(key, args[0])
    return interpolateTemplate(result, args[0])
  }

  // 处理可变参数场景: t(key, arg1, arg2, ...)
  if (args.length > 1 && args.every((arg) => typeof arg !== 'object')) {
    return interpolateTemplate(originalT(key, args), args)
  }

  // 处理默认场景: t(key) 或 t(key, defaultMessage) 或 t(key, plural) 等
  const result = originalT(key, ...args)

  return result
}) as typeof i18n.global.t

export default i18n

8.4.2 忽略规则

"i18n-ally.extract.ignored": [
  "特定文本",
  "正则表达式",
]

总结

至此,我们完成了国际化配置,并使用 i18n-ally 插件实现了对 Vue 组件的自动提取和翻译。如果本文对你有所帮助,请点赞、收藏、转发,让更多的人了解和使用国际化配置。

实现效果

实现效果.gif

参考资料

前端自动化测试一jest基础使用

作者 夜寒花碎
2025年4月8日 18:49

环境安装与配置

安装

安装jest

npm install --save-dev jest

配置package.json命令

{
  "scripts": {
    "test": "jest"
  }
}

配置

生成jest配置文件

npm init jest@latest

jsdom测试环境需要安装jest-environment-jsdom

npm install --save-dev jest-environment-jsdom

使用babel

安装babel依赖

npm install --save-dev babel-jest @babel/core @babel/preset-env

通过在项目的根目录中创建 babel.config.js 文件,将 Babel 配置为针对当前版本的 Node:

module.exports = {
  presets: [['@babel/preset-env', {targets: {node: 'current'}}]],
};

配置typescript支持

安装依赖

npm install --save-dev jest typescript @types/jest @babel/core @babel/preset-env @babel/preset-typescript
  • jest: Jest 测试框架本身。
  • typescript: TypeScript 编译器。
  • @types/jest: Jest 的类型定义文件。
  • Babel 相关依赖:用于将 TypeScript 转换为 JavaScript(Jest 默认不支持直接运行 TypeScript)。

配置

有两种主流的配置方式Babel ts-jest

方式一:使用 Babel

  1. 创建 .babelrc 文件:
{
  "presets": [
    ["@babel/preset-env", { "targets": { "node": "current" } }],
    "@babel/preset-typescript"
  ]
}
  1. jest.config.js 中配置 Babel:
module.exports = {
  transform: {
    "^.+\.tsx?$": "babel-jest",
  },
  testRegex: "(/__tests__/.*|(\.|/)(test|spec))\.(jsx?|tsx?)$",
  moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
};

方式二:使用 ts-jest

  1. 安装 ts-jest
npm install --save-dev ts-jest
  1. jest.config.js 中配置:
module.exports = {
  preset: "ts-jest",
  testEnvironment: "node",
  testRegex: "(/__tests__/.*|(\.|/)(test|spec))\.tsx?$",
};

配置 TypeScript

创建 tsconfig.json 文件(如果不存在):

{
  "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "esModuleInterop": true,
    "strict": true,
    "types": ["jest"]
  }
}

报错处理

You appear to be using a native ECMAScript module configuration file, which is only supported when running Babel asynchronously or when using the Node.js --experimental-require-module flag.

package.json 中包含 "type": "module",会强制项目使用 ES 模块,可能导致冲突:

{
  "type": "module", // 如果存在此行,考虑移除或调整配置
  "scripts": {
    "test": "jest"
  }
}

处理建议

  • 如果项目无需全局 ES 模块,移除 "type": "module"
  • 如需保留,将 Babel 配置文件名改为 babel.config.cjs(CommonJS 格式):

基础测试示例

基础断言/匹配器

常见匹配器

  • toBe:用于判断基本数据类型是否相等。
  • toEqual:用于判断对象或数组的内容是否相等。
  • toBeNull:判断值是否为 null
  • toBeUndefined:判断值是否为 undefined
  • toBeTruthy:判断值是否为真值。
  • toBeFalsy:判断值是否为假值。
  • toHaveLength:判断数组或字符串的长度是否符合预期。
  • toHaveProperty:判断对象是否具有某个属性。
test('matcher examples', () => {
    const num = 1;
    const obj = { a: 1, b: 2 };
    const arr = [1, 2, 3];

    expect(num).toBe(1);
    expect(obj).toEqual({ a: 1, b: 2 });
    expect(null).toBeNull();
    expect(undefined).toBeUndefined();
    expect(true).toBeTruthy();
    expect(false).toBeFalsy();
    expect(arr).toHaveLength(3);
    expect(obj).toHaveProperty('a', 1);
});

异步测试

pormise测试

test('Promise 返回数据', () => {
  return fetchData().then(data => {
    expect(data).toBe('peanut butter');
  });
});

async/await测试

test('Async/Await 测试', async () => {
  const data = await fetchData();
  expect(data).toBe('peanut butter');
});

mock函数

模拟 API 调用、模块依赖等

const mockFn = jest.fn();
mockFn.mockReturnValue(42); // 固定返回值

test('Mock 函数调用', () => {
  expect(mockFn()).toBe(42);
  expect(mockFn).toHaveBeenCalled(); // 验证是否被调用
});

钩子函数

在测试前后执行公共逻辑, 常用的钩子函数:beforeEach``afterEach``beforeAll``afterAll

let data;
beforeEach(() => {
  data = initializeData(); // 每个测试前重置数据
});

test('数据初始化', () => {
  expect(data).toBeDefined();
});

快照测试

捕获组件或数据结构输出,防止意外修改,首次运行生成快照文件,后续测试对比变化.

test('组件渲染快照', () => {
  const component = renderer.create(<Button />);
  expect(component.toJSON()).toMatchSnapshot();
});

型的快照测试用例渲染UI组件,拍摄快照,然后将其与测试旁边存储的参考快照文件进行比较。如果两个快照不匹配,则测试将失败

`ConstantPositionProperty` 的使用与应用

2025年4月8日 18:41

ConstantPositionProperty 的使用与应用

1. 什么是 ConstantPositionProperty

ConstantPositionProperty 是 Cesium 中用于表示实体位置的属性类。它表示一个实体在三维空间中的位置是固定的,不会随时间变化。与动态位置属性(如 SampledPositionProperty)不同,ConstantPositionProperty 适用于那些位置不会变化的实体。

2. 使用场景

ConstantPositionProperty 通常用于以下场景:

  • 标记固定点(如地标、建筑物等)。
  • 设置不会移动的实体(如静态模型、标记点等)。
  • 在需要固定位置的场景中,避免使用动态位置属性的性能开销。

3. 代码示例

以下是一个使用 ConstantPositionProperty 设置实体位置的示例:

import * as Cesium from 'cesium';

// 创建一个实体
const entity = {
  id: 'marker',
  position: new Cesium.ConstantPositionProperty(
    Cesium.Cartesian3.fromDegrees(-115.0, 37.0, 3000.0)
  ),
  point: {
    pixelSize: 10,
    color: Cesium.Color.RED,
  },
};

// 将实体添加到 Cesium Viewer
viewer.entities.add(entity);

4. 与动态位置属性的对比

ConstantPositionProperty 和动态位置属性(如 SampledPositionProperty)的主要区别在于:

  • 固定性ConstantPositionProperty 表示位置固定,而动态位置属性表示位置随时间变化。
  • 性能ConstantPositionProperty 的性能更高,因为它不需要进行时间插值或复杂计算。

5. 实际应用场景

  • 地标标记:在地图上标记固定地标,如埃菲尔铁塔、自由女神像等。
  • 静态模型:加载不会移动的 3D 模型,如建筑物、雕塑等。
  • 参考点:设置参考点或标记点,用于其他实体的定位。

通过使用 ConstantPositionProperty,可以高效地表示固定位置的实体,避免不必要的性能开销。

`ConstantProperty` 的使用与应用

2025年4月8日 18:41

ConstantProperty 的使用与应用

1. 什么是 ConstantProperty

ConstantProperty 是 Cesium 中用于表示实体属性的类。它表示一个实体的属性(如方向、颜色等)是固定的,不会随时间变化。与动态属性(如 SampledProperty)不同,ConstantProperty 适用于那些属性不会变化的实体。

2. 使用场景

ConstantProperty 通常用于以下场景:

  • 设置实体的固定方向(如无人机的初始朝向)。
  • 设置实体的固定颜色(如标记点的颜色)。
  • 在需要固定属性的场景中,避免使用动态属性的性能开销。

3. 代码示例

以下是一个使用 ConstantProperty 设置实体方向的示例:

import * as Cesium from 'cesium';

// 设置实体位置
const position = new Cesium.ConstantPositionProperty(
  Cesium.Cartesian3.fromDegrees(-115.0, 37.0, 3000.0)
);

// 设置实体方向
const hpr = new Cesium.HeadingPitchRoll(
  Cesium.Math.toRadians(45), // 朝向
  Cesium.Math.toRadians(0),  // 俯仰
  Cesium.Math.toRadians(0)   // 滚动
);

const orientation = new Cesium.ConstantProperty(
  Cesium.Transforms.headingPitchRollQuaternion(position.getValue(), hpr)
);

// 创建实体
const entity = {
  id: 'drone',
  position: position,
  orientation: orientation,
  model: {
    uri: 'models/drone.glb',
    scale: 1.0,
  },
};

// 将实体添加到 Cesium Viewer
viewer.entities.add(entity);

4. 与动态属性的对比

ConstantProperty 和动态属性(如 SampledProperty)的主要区别在于:

  • 固定性ConstantProperty 表示属性固定,而动态属性表示属性随时间变化。
  • 性能ConstantProperty 的性能更高,因为它不需要进行时间插值或复杂计算。

5. 实际应用场景

  • 无人机初始朝向:设置无人机的初始朝向,避免动态计算。
  • 标记点颜色:设置标记点的固定颜色,如红色标记点。
  • 参考方向:设置参考方向或标记方向,用于其他实体的定位。

vue哪些情况称作“销毁组件”

2025年4月8日 18:22

在 Vue.js 中,组件被销毁是指组件从 DOM 中被移除,并且相关的资源和引用被清理。以下是组件被销毁的几种常见情况:

1. 动态组件切换

当使用 v-ifv-for 等指令动态切换组件时,组件会被销毁。例如:

<template>
  <div>
    <button @click="showComponent = !showComponent">
      切换组件
    </button>
    <component-a v-if="showComponent" />
  </div>
</template>

<script>
import ComponentA from './ComponentA.vue';

export default {
  components: {
    ComponentA
  },
  data() {
    return {
      showComponent: true
    };
  }
};
</script>
  • showComponentfalse 时,ComponentA 会被销毁,其 beforeUnmount 钩子会被触发。

2. 路由切换

当使用 Vue Router 进行页面跳转时,当前路由组件会被销毁。例如:

<template>
  <div>
    <router-link to="/home">Home</router-link>
    <router-link to="/about">About</router-link>
    <router-view />
  </div>
</template>
  • 当从 /home 跳转到 /about 时,/home 对应的组件会被销毁,其 beforeUnmount 钩子会被触发。

3. 整个应用卸载

当整个 Vue 应用被卸载时,所有组件都会被销毁。例如:

const app = Vue.createApp(App);
const vm = app.mount('#app');

// 卸载整个应用
vm.unmount();
  • 调用 vm.unmount() 会触发所有组件的 beforeUnmount 钩子。

4. 关闭页面

当用户关闭浏览器标签页或刷新页面时,整个 Vue 应用会被销毁,所有组件的 beforeUnmount 钩子都会被触发。

5. 跳转页面

  • 单页面应用(SPA):如果使用 Vue Router 进行页面跳转,当前页面的组件会被销毁。
  • 传统页面跳转:如果使用 <a> 标签或 window.location.href 进行页面跳转,浏览器会加载新页面,旧页面的所有组件都会被销毁。

总结

组件被销毁的情况包括:

  • 动态组件切换(如 v-ifv-for)。
  • 路由切换(使用 Vue Router)。
  • 整个应用卸载。
  • 关闭页面或刷新页面。
  • 跳转页面(无论是 SPA 还是传统页面跳转)。

在这些情况下,Vue 会自动调用组件的 beforeUnmountunmounted 生命周期钩子,用于执行清理操作。

canvas签字+html2canvas将dom保存为图片避坑指南

2025年4月8日 18:16

1. 前言

如果你需要h5签字,并需要将相关dom保存成图片,那你算是 掏上了😂 这是一篇全方位避坑指南。至少可以给你节省 8小时。

技术栈: vue2.x js 一定要先看技术栈哦,避免出现未知ERROR

canvas 签字自己去实现其实也没啥难度,有更好的插件,可以帮助我们多争取一点摸鱼时间,那何尝不可呐。

之前也写过一篇小程序签字保存图片,您可以点击查看👉👉👉 微信小程序 canvas 签字连笔卡顿 如何解决

2. 使用的插件

npm i  html2canvas@1.3.2
npm i  vue-esign@1.1.4

说到这,我不得不夸一下 vue-esign 好用、非常好用👍 ,我需要的功能都做了配置,你们可以感受下demo,看看是不是符合你的需求再决定是否向下看,点击查看

3. 签字组件的使用

 <vue-esign 
     ref="esign" 
     class="mySign" 
     :height="300" 
     :isCrop="isCrop" 
     :lineWidth="lineWidth" 
     :lineColor="lineColor" 
     :bgColor="bgColor" 
     :isClearBgColor="false"
 />
// 重置事件
handleReset() {
    this.$refs.esign.reset()
}
// 校验是否签字
openLink() {
    try {
        // 生成签名结果
        const res = await this.$refs.esign.generate()
        console.log('res')
        // 处理其他业务逻辑
     }catch (error) {
        console.log('err', error)
        Toast('请先签名')
    }
}

简单吧,他把签字的动作都封装了,代码看着非常清爽。感谢大佬,如果你需要看其它的配置请看这个

ok到这 canvas签字就结束了,非常好用,接下来就说说 html2canvas将dom保存为图片的坑

4. html2canvas的使用

 <div class="confirm_content_clone" ref="htmlContent">
      <img style="width: 100%; height: 100%;"  :src="filePath" alt="" />
      <img style="width: 100%; height: 150px;" :src="resultImg" alt="" />
 </div>
 
 async openLink() { 
     // 别问直接把这些属性,放上
     const newCanvas = await html2canvas(this.$refs.htmlContent, {
        useCORS: true, // 处理跨域资源
        scale: 2, // 提高截图清晰度
        allowTaint: true,
        scale: 2, //按比例增加分辨率 (2=双倍).
        dpi: window.devicePixelRatio * 2, //设备像素
    })
    const imgData = newCanvas.toDataURL('image/png')
}

正常情况,那就是把类名为 confirm_content_clone的div 里面的两个图片,生成同一个拼接图片,不正常情况, 那恭喜你,我们开始走上了填坑之路

5. 坑one

图片不全,如下图所示

image.png

其实这里并没有太为难我, 我的处理方式是:页面展示是正常一套逻辑 ,需要生成的图片dom, 我采用绝对定位,使用 top:10000 让其消失在可视范围内 ,野蛮是野蛮了一点, 毕竟我和代码 有一个能跑就行

在以为解决的时候(开心鼓掌),你不知道的是,第二个坑向你迎面走来,在ios系统中,图片空白

6. 坑Two

第一个img 的src是一个图片url, 第二个src 图片是 base64格式, 在ios系统中截图如下图所示

image.png

也就是图片是url不是base64 在ios系统中 无法显示,这个比较坑的是,pc浏览器中正常显示,也就是开发的时候好好的 ,为啥到了你那就不行了,是不是你手机有问题 🤣

必坑指南第二式: 敲黑板,认真看,要不然够你找的 crossOrigin="anonymous"

// 添加  crossOrigin="anonymous" 
 <img  
     crossOrigin="anonymous" 
     style="width: 100%; height: 100%;" 
     src=""
     alt=""
 />

看,只要抵住最黑的暗,就能收获最光的亮

image.png

6. 坑Three

这个坑的现象是在ios系统的,涉及中文符号的时候,保存的图片 换行错乱,而且莫名其妙靠右: 如下图所示

image.png

其实这个解决方案已经在上面了,就是在浏览中,样式是正确的 如下图所示

image.png

终极一招: 先生成一张正确的图片,然后用图片替代文字,即可。是不是 脑回路清奇,好了 就这吧。

**野蛮的人生不需要解释, 下次见。 **

如何使用通义灵码学习JavaScript和DOM

2025年4月8日 18:13

如果你看到了本手册的页面数量,你就会发现JavaScript的API真的非常丰富,在MDN上专门有一大分类用于介绍JavaScript的API,但软件工程行业有一个著名法则叫2-8法则,意思是只有20%的内容会经常使用到,而80%的内容只在一些少数情况下会用到,以JavaScript的API为例,浏览器提供了一个剪切板API(Clipboard API),但并不是所有网站都需要用到这个API(一般只有需要编辑内容的网站才需要),但是当你需要用到的时候,你就需要在MDN或者我们这本手册中翻看查找,效率十分低下,那么有没有什么办法可以帮助我们呢?

AI会给你答案!

AI与环境搭建

本文采用通义灵码进行介绍,通义灵码通义灵码是由阿里云技术团队打造的智能编码助手。它基于通义大模型,能够提供:

  1. 代码续写与优化:根据已有代码基础,智能生成后续代码,助力开发者快速完成编码任务,并提供优化建议,提升代码性能与可读性。

  2. 自然语言描述转代码:将开发者用自然语言描述的功能需求,直接转化为可执行的JavaScript代码,降低编码门槛,加速开发进程。

  3. 注释生成与代码解释:为代码添加精准注释,帮助开发者理解代码逻辑;同时,对复杂代码进行详细解释,便于团队协作与知识传承。

  4. 单元测试生成:依据代码功能,自动生成相应的单元测试用例及测试代码,确保代码可靠性与稳定性,减少测试工作量。

  5. 研发智能问答:解答开发者在JavaScript学习与开发过程中遇到的各种问题,提供专业指导与建议。

  6. 代码问题修复:协助开发者定位并修复代码中的错误与漏洞,保障代码正常运行。

通义灵码官网:tongyi.aliyun.com/lingma/

通义灵码支持:JetBrains IDEs、Visual Studio Code、Visual Studio,及远程开发场景(Remote SSH、Docker、WSL、Web IDE),安装后登录账号即可开始使用。

开发环境则使用VSCode,具体配置内容可以前往JavaScript AI 通义灵码 VSCode插件安装与功能详解 查看!

问题:我需要什么技术方案?

正如前文所言,JavaScript的API(WebAPI,包括HTML DOM,这也是浏览器提供的WebAPI)非常丰富,我们并不知道哪些API可以实现,但AI可以回答我们的问题。

1.jpeg

问题:我理解了这个API,但代码还是写不出来怎么办?

你不仅可以问通义灵码有哪些API可以实现你的功能,也可以更直接点,让它帮你写一个案例参考:

2.jpeg

问题:这段代码用了些我没见到过的代码和API,怎么办?

通义灵码提供了代码注释和代码解释的功能,代码注释可以给代码逐行注释,你可以根据注释来理解这些代码。

3.jpeg

如果实在理解不了,还提供代码解释功能,告诉你这些代码是怎么写的:

4.jpeg

问题:我的代码还能不能优化?

哼哧哼哧写完代码后,并不代表高枕无忧,实际上代码可能存在一些问题,典型的比如边界值处理,异常捕获等等,通常情况下代码优化需要由另一个程序员或者小组进行代码评审,但现在AI可以帮我们做代码优化。

5.jpeg

问题:代码写完了,我怎么确定它的可靠性?

对于程序员来说,检验可靠性的方法就是测试。但测试用例最好是由另一个同事帮你设计,因为同一个人可能会有目光的局限性,现在可以让AI来帮忙生成测试用例,甚至是测试代码,只要代码通过了测试,就能证明这个代码带有一定的可靠性。

通义灵码可以足够详细的测试用例

6.jpeg

也会根据测试用例生成对应的测试代码:

7.jpeg

最终问题:其实我什么都不会,还有机会吗?

有的兄弟,有的,通义灵码提供了AI程序员,你可以当一回产品大爷,指挥AI程序员干活了!

8.jpeg

9.jpeg

你需要的只是确认是否根据AI提供的方案进行修改,怎么修改,方案怎么提出,那就是AI该考虑的事情了。

总结

通义灵码作为智能编码助手,为JavaScript学习与开发提供了全方位的支持。从技术方案选择到代码可靠性验证,它在各个环节都能发挥重要作用,帮助开发者提升效率、优化代码质量、加深知识理解。然而,开发者不应完全依赖工具,还需不断学习与实践,结合MDN等权威资源,逐步成长为具备独立思考与解决问题能力的优秀前端工程师。在技术快速迭代的今天,积极拥抱智能工具,持续提升自身技能,方能在软件开发领域不断前行,创造更多优质的应用与服务。

antd表格行hover效果性能处理

2025年4月8日 18:07

故事发生在2025/4/7,简单阐述一下故事背景吧,我们需要实现一个hover隔行的效果。当我们hover在任意一行的时候,与当前hover的这一行每相隔n行也需要有高亮的样式。

image.png

技术栈

在本次需求开发中,是react、antd pro、scss这三个核心技术

之前的写法

用到了两个状态 第一个状态:hoverSpace(决定隔多少行显示下个高亮),第二个状态:hoverRowIndex(当前鼠标所处的行的索引)

先介绍一下公司前面的人实现这个需求的思路吧,首先给每表格的每一行绑定两个事件,分别是onMouseEnter、onMouseLeave这两个,当鼠标移入表格行的时候,将当前行所处的索引记录下来,当移出当前行的时候将当前行所引设置为null,然后根据这两个变量计算出哪些行需要高亮,然后动态设置高亮类名。

代码展示

因为项目源代码较长,所以这里就只展示关键代码了,主要看思路

// table.tsx
import { ProTable } from "@ant-design/pro-components";

const Table = () => {
  const [hoverSpace, setHoverSpace] = useState(0);
  const [hoverRowIndex, setHoverRowIndex] = useState<number | null>(null);

  return (
    <ProTable
      rowClassName={(_, index) => {
        if (hoverSpace && hoverRowIndex !== null) {
          return (index - hoverRowIndex) % (hoverSpace + 1) === 0
            ? "ant-table-cell-row-hover"
            : "";
        }
      }}
      onRow={(_, index) => ({
        onMouseEnter: () => {
          setHoverRowIndex(index);
        },
        onMouseLeave: () => {
          setHoverRowIndex(null);
        },
      })}
    />
  );
};

export default Table;

卡顿原因分析

因为这个组件所挂载的dom比较多,所以这个页面渲染所需要的时间就比较长,而在react中当组件的props,或者组件中的状态更新了,那么这个组件就会重新渲染一次,当然,还有很多其他的原因也会导致组件重新渲染,这里就不一一列举了。而在这个案例中,当我们的鼠标不断在表格行中上下移动,导致hoverRowIndex这个状态频繁更新,进而导致组件频繁渲染。最终导致页面卡顿。

解决方案选择

根据上面已经分析出了原因,那么就针对这个问题进行优化解决。第一个方案:让组件渲染不要那么频繁,或者让组件的部分渲染,而不是整个重新渲染。第二个方案:不用第二个状态,用纯css完成,这个组件就不用重新渲染了。一开始就想用第一种方案,但是想了很久没想到一个比较好的解决方式,于是决定用第二种方式。

解决方式思路

在最开始的时候想到的是用:nth-child()这个伪类去实现,但是好像没不能满足hover时才出现的效果,后面想要兄弟选择器去选择(但是这个选择器只能选中当前这个元素后面的元素)

实现代码

//table.tsx

import { ProTable } from "@ant-design/pro-components";
import styles from "./index.module.scss";
const Table = () => {
  const [hoverSpace, setHoverSpace] = useState(0);

  return (
    <ProTable
      className={styles["table-container"]}
      rowClassName={(_, index) => {
        if (hoverSpace === 0) {
          return "";
        }
        return styles[`row-id-row-${index % (Number(hoverSpace) + 1)}`];
      }}
    />
  );
};

export default Table;


// index.module.scss

.table-container {
  @for $i from 0 through 11 {
    .row-id-row-#{$i}:hover,
    .row-id-row-#{$i}:hover ~ .row-id-row-#{$i} {
      td {
        background-color: #afdbfd !important;
      }
    }
  }
}

到这里本篇文章就结束啦,如果本文有哪些错误,还请各位大佬指正,有什么更好的实现方法也欢迎大佬们分享出来,供我这个小菜鸡学习学习,非常感谢......

循环滚动列表浅析

作者 Ody
2025年4月8日 18:07

日常开发过程中,列表是比较常见的展示数据的一种形式,本文主要聚焦于列表的滚动以及循环滚动。

准备条件

由于存在动态数据,本文为了方便,基于 vue3 来实现的,当然原理是相通的,读者可以基于其他框架,或者直接对 dom 操作进行实现,在此不再赘述。

基本骨架

<template>
  <div class="list">
    <div class="list-item" v-for="(item) in items" :key="item.id">{{ item.name }}</div>
  </div>
</template>
<script setup>
import { ref, computed } from 'vue';

const datas = ref(
  [...Array(100).keys()].map((i) => ({ id: i, name: `item ${i}` }))
);

const items = computed(() => datas.value)
</script>
<style scoped>
.list {
  height: 180px;
  width: 500px;
  border: 1px solid #eee;
  overflow: hidden;
}
.list-item {
  height: 30px;
  line-height: 30px;
  box-sizing: border-box;
  padding: 0 12px;
  border-bottom: 1px solid #eee;
}
.list-item:last-child {
  border-bottom-color: transparent;
}
</style>

在此我们定义了一个尺寸为 500 * 100的列表,每条数据占据 30 的高度,我们不想让它支持鼠标滚动,所以设置了overflow:hidden,大致效果图如下

image.png

实现

准备工作已经完成,是时候实现我们的目标了,怎么做呢?

动起来

如何动起来呢,其实大体有两种方式

  1. 通过 js 来操作滚动条,这种方式虽然能达到目的,但动画效果都得自己去实现,或者调用浏览器的平滑滚动,其实效果也是一言难尽
  2. 有没有比较好方式呢,当然有,那就是通过 css 动画实现了,通过让内层元素慢慢向上平移不就达到了滚动的效果么

在之前得改造下结构,因为我们需要一个容器来做平移,改造后的 html 结构如下

<div class="list">
  <div class="list-wrapper" :style="{transform:`translateY(${ty})`}">
    <div class="list-item" v-for="(item) in items" :key="item.id">{{ item.name }}</div>
  </div>
</div>
const ty = ref(0)

现在就需要改变这个 ty,从而让列表动起来,但这也需要通过 js 去定时改变它,但我们不想要这么做,所以我们继续改造,使用 cssanimation去实现,由于列表长度不固定,我们需要用到css变量。

首先定义动画,--anim-max-yy方向最大平移距离,--anim-duration动画持续时间

@keyframes scroll-up {
  to {
    transform: translateY(var(--anim-max-y));
  }
}
.list-wrapper {
  animation: scroll-up var(--anim-duration) linear infinite;
}

我们的列表可视区域元素为 6 个,每行占 30px,排除掉可视区域的高度,就是我们最大的滚动距离,持续时间设置为元素个数/2对应的秒数

<div class="list">
  <div
    class="list-wrapper"
    :style="{
      '--anim-max-y': `-${(items.length - 6) * 30}px`,
      '--anim-duration': `${items.length / 2}s`,
    }">
    <div
      class="list-item"
      v-for="item in items"
      :key="item.id">
      {{ item.name }}
    </div>
  </div>
</div>

至此我们已经大体实现了我们的需求,看起来效果还不错。为啥说是大体呢,因为我们既然说是循环滚动,那么衔接是不是应该平滑呢,仔细一看,当我们列表滚动到底部的时候,会看到一下子跳到头部,这就是需要我们改进的地方。

其实解决方案比较简单,就是往列表底部附加点可视区域内的数据,这样就能完美的解决问题了

const visibleCount = 6
const items = computed(() => {
  if (datas.value.length <= visibleCount) {
    return datas.value;
  }
  return datas.value.concat(datas.value.slice(0, visibleCount))
});

至此,大功告成~~~

完整代码

<template>
<div class="list">
  <div
    class="list-wrapper"
    :style="{
      '--anim-max-y': `-${(items.length - visibleCount) * 30}px`,
      '--anim-duration': `${items.length / 2}s`,
    }">
    <div
      class="list-item"
      v-for="item in items"
      :key="item.id">
      {{ item.name }}
    </div>
  </div>
</div>
</template>
<script setup>
import { ref, computed } from "vue";

const datas = ref(
  [...Array(100).keys()].map((i) => ({ id: i, name: `item ${i}` }))
);

const visibleCount = 6
const items = computed(() => {
  if (datas.value.length <= visibleCount) {
    return datas.value;
  }
  return datas.value.concat(datas.value.slice(0, visibleCount))
});
</script>
<style scoped>
.list {
  height: 180px;
  width: 500px;
  border: 1px solid #eee;
  overflow: hidden;
}
.list-item {
  line-height: 30px;
  height: 30px;
  box-sizing: border-box;
  padding: 0 12px;
  border-bottom: 1px solid #eee;
}
.list-item:last-child {
  border-bottom-color: transparent;
}
@keyframes scroll-up {
  to {
    transform: translateY(var(--anim-max-y));
  }
}
.list-wrapper {
  animation: scroll-up var(--anim-duration) linear infinite;
}
</style>

后记

上面实现的滚动列表只是一种实现方式,当然大家可以进一步封装成组件,每一项的高度也可以通过变量进行设置,这一块就靠读者自由发挥。

闭包、原型链与继承、手写Promise

作者 zirk
2025年4月8日 18:02

Tips:为了拿掘金的矿石写的文章,水一下;可以随便看看。

闭包与变量保存

闭包在 JavaScript 中是一个十分常见但容易被滥用的概念。一个典型的面试问题是:如何利用闭包实现私有变量?
以下代码展示了一个简单的闭包实现私有变量的例子:

function createCounter() {
  let count = 0;  // 私有变量,外部无法直接访问
  return {
    increment: function() {
      count += 1;
      return count;
    },
    decrement: function() {
      count -= 1;
      return count;
    }
  };
}

const counter = createCounter();
console.log(counter.increment()); // 输出: 1
console.log(counter.decrement()); // 输出: 0

这个例子中,createCounter 返回一个对象,该对象的方法内部能访问到 createCounter 作用域内的 count 变量。面试官可能会追问闭包的内存泄漏风险以及如何正确管理作用域问题,这要求候选人不仅理解语法,还需要关注代码在大规模项目下的性能和可维护性。

原型链与继承

面试中往往会要求手写实现一个继承机制或者解释原型链的工作原理。理解原型链对于深入掌握 JavaScript 对象模型至关重要。以下代码示例展示了构造函数、原型对象及其相互关联的基本模型:

function Person(name) {
  this.name = name;
}
Person.prototype.sayHi = function() {
  console.log(`Hi, I'm ${this.name}`);
};

function Student(name, school) {
  Person.call(this, name); // 继承属性
  this.school = school;
}

// 继承方法
Student.prototype = Object.create(Person.prototype);
Student.prototype.constructor = Student;

Student.prototype.study = function() {
  console.log(`${this.name} is studying at ${this.school}`);
};

const student = new Student('Alice', 'MIT');
student.sayHi();    // 输出: Hi, I'm Alice
student.study();    // 输出: Alice is studying at MIT

这里展示了如何用 Object.create 实现对父类 Person 原型的继承,同时正确设置构造函数属性。面试中可能还会问及 ES6 中 class 语法的实现原理,候选人需要对语法糖和底层原型链机制之间的关系有清晰认识。

异步编程与 Promise 实现

异步编程是前端工程中的难点之一,面试官常常会询问如何手写一个简单的 Promise。下面提供一个 Promise 简易实现的片段:

class MyPromise {
  constructor(executor) {
    this.status = 'pending';
    this.value = null;
    this.reason = null;
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];

    const resolve = (value) => {
      if (this.status === 'pending') {
        this.status = 'fulfilled';
        this.value = value;
        this.onFulfilledCallbacks.forEach(fn => fn());
      }
    };

    const reject = (reason) => {
      if (this.status === 'pending') {
        this.status = 'rejected';
        this.reason = reason;
        this.onRejectedCallbacks.forEach(fn => fn());
      }
    };

    try {
      executor(resolve, reject);
    } catch (error) {
      reject(error);
    }
  }

  then(onFulfilled, onRejected) {
    if (this.status === 'fulfilled') {
      onFulfilled(this.value);
    } else if (this.status === 'rejected') {
      onRejected(this.reason);
    } else {
      this.onFulfilledCallbacks.push(() => onFulfilled(this.value));
      this.onRejectedCallbacks.push(() => onRejected(this.reason));
    }
    // 简化未返回新的 Promise 的实现
  }
}

// 测试 MyPromise 实现
new MyPromise((resolve, reject) => {
  setTimeout(() => resolve('Success!'), 1000);
}).then(
  value => console.log(value),    // 一秒后输出: Success!
  error => console.error(error)
);

通过这个简单的 Promise 实现,面试官旨在验证候选人对异步处理流程、错误捕获以及状态管理的理解。候选人可以进一步讨论如链式调用、异常冒泡等更深入的实现问题。

【性能优化黑科技·动态篇】每秒数万条!矢量数据动态更新如何兼顾性能与实时性?

作者 Mapmost
2025年4月8日 17:59

我们在日常生活中使用手机地图导航时,都会发现,地图上的拥堵路段被标注成了红色,行驶缓慢是黄色,畅通路段是绿色——这就是动态矢量数据的典型应用。它不再是传统地图上固定不变的道路线条,而是像一条“会呼吸的血管”,随着车流变化实时更新,通过颜色的不同,我们可以即时得知道路拥堵情况

实时路况

类似的场景还有很多,比如通过热力图实时显示演唱会人群密度,通过轨迹线实时显示风场路径等等。与上面导航路径不同的是,这些场景每秒可能有数万条数据变化,页面很容易卡成“PPT”。如何既能保证画面流畅,又能实时更新,是对渲染引擎巨大的考验。

动图封面

风场可视化

针对海量矢量数据的动态更新,Mapmost SDK for WebGL基于浏览器原生3D渲染能力,直接调用显卡(GPU)加速,数据更新时可以只传输“变化的部分”,而非重新加载整个地图,并且支持动态LOD加载,平衡效果与性能,实现高性能渲染的能力。

_一、_矢量拉伸体数据动态更新

#园区大数据平台案例背景

在园区大数据平台中,管理者需要实时掌握园区的建设与发展情况,其中一个重要需求是动态展示园区不同年份新增的建筑。通过矢量拉伸体技术,可以将二维矢量数据以三维建筑物的形式呈现,并根据其建成年份按照不同颜色进行动态更新和可视化。

#矢量拉伸体数据动态更新技术解析

在动态展示不同年份新增建筑时,Mapmost SDK for WebGL 采用增量更新机制。当用户切换年份时,平台仅更新新增或变更的建筑物数据,而不是重新加载整个数据集。同时,利用局部刷新技术,仅对发生变化的区域进行重新渲染,避免了全屏刷新带来的性能开销。

// 示例代码:矢量拉伸体动态更新

// 示例代码:矢量拉伸体动态更新
map.setFeatureState({
  source: 'extrusion-source',
  id: 'extrusion-feature'
}, {
  color: dynamicColor // 动态设置颜色
});

动图封面

Mapmost SDK for WebGL实现园区不同年份建筑增长情况

_二、_热力图数据动态更新

#园区大数据平台案例背景

在城市大数据平台中,管理者需要实时掌握城市房地产市场的动态变化,其中一个重要需求是通过热力图展示不同年份的房地产交易量分布。通过动态网格状热力图,用户可以直观地看到各区域在不同年份的交易活跃度,从而为城市规划、房地产市场调控和投资决策提供数据支持。

#热力图数据动态更新技术解析

热力图是一种通过颜色渐变来表示数据密度、强度或分布情况的可视化工具,能够直观地显示数据在空间或时间上的分布情况,帮助用户快速识别数据的模式和趋势。传统热力图生成依赖后台计算,延迟较高,高密度区域的热力渲染可能导致浏览器崩溃,Mapmost SDK for WebGL将城市划分为“网格”,直接在浏览器中将房地产交易数据按网格进行聚合,无需等待服务器响应,实现热力图更新低延迟

map.addLayer({
  id: 'hexagon-layer', // 加载图层的id,定义图层的唯一标识符
  type: 'hexagon', // 定义加载图层的类型,图层 type 为 `hexagon`
  data: '<your data>', // 蜂窝图数据,经纬度形式传入,形如 [[lng,lat],[lng,lat],......]
  heightMultiplier: 1.5, // 高度倍数,默认 1
  radius: 4, // 蜂窝半径,单位为米,默认 100
  coverage: 1, // 每个蜂窝团覆盖率,主要影响单个蜂窝的大小,取值在 0~1,默认 0.5
  colorRange: {0: "#1a9850",25: "#9fd568",30: "#ffffbf",40: "#fd9d62",45: "#d73027",}, // 颜色区间,根据所设置的点的数量进行分配
  ambientFactor: 0.22,
  directionalFactor: 0.1,
})

动图封面

Mapmost SDK for WebGL实现园区不同年份住房交易情况

_三、_人流轨迹数据动态更新

#轨交项目案例背景

在轨交项目中,实时监控地铁站内人流密度和分析乘客行走轨迹是核心需求。通过AI处理监控视频,生成乘客点位数据,根据点位数据实现人流轨迹可视化

#人流轨迹数据动态更新技术解析

乘客轨迹数据量庞大,对轨迹数据进行压缩,将乘客的移动路径简化为关键转折点(比如从A到B只需记录起点、终点和拐角),可以减少数据传输量,在浏览器中Mapmost SDK for WebGL 通过对稀疏点位进行插值计算实现轨迹线光滑过渡,通过GPU加速渲染,大幅提升渲染效率。

let lineOptions = {
  id: 'track',
  type: 'model',
  callback: function (group, layer) {
    let options = {
      resolution: 2048, // 画布分辨率
      range: coordinates, // 显示轨迹线区域的坐标
      data: corrdsArr, // 轨迹线的位置数据
      height: 13.5, // 显示轨迹线区域平面的高度
      trackColor: "#63E8FF" // 轨迹线的颜色
    }
    // 添加轨迹线
    layer.addTrackLines(options, function (updateTrackLines) {
      var updateTrack = updateTrackLines;
    })
  }
};
map.addLayer(lineOptions)

动图封面

Mapmost SDK for WebGL实现地铁站人流轨迹监测

动态矢量数据就像一条永不停息的河流——数据每分每秒都在变化,动态更新后的矢量数据需要支持高效的空间查询和渲染,但复杂的查询和渲染可能导致性能瓶颈。

Mapmost SDK for WebGL 依靠强大的海量数据加载与渲染能力通过多种优化策略,能够兼顾性能与实时性,高效处理和渲染百万量级的矢量数据,为用户提供流畅的可视化体验。点击此处跳转官网体验Mapmost SDK for WebGL产品!

关注 Mapmost,持续更新 GIS、三维美术、计算机技术干货

Mapmost 是一套以三维地图和时空计算为特色的数字孪生底座平台,包含了空间数据管理工具(Studio)、应用开发工具(SDK)、应用创作工具(Alpha)。平台能力已覆盖城市时空数据的集成、多源数据资源的发布管理,以及数字孪生应用开发工具链,满足企业开发者用户快速搭建数字孪生场景的切实需求,助力实现行业领先。

欢迎进入官网体验使用:Mapmost——让人与机器联合创作成为新常态

HTML 开发者的智能助手:通义灵码在 VSCode 中的应用

2025年4月8日 17:50

引言

在 HTML 开发领域,提高编码效率和质量是每位开发者追求的目标。通义灵码,作为一款由阿里云技术团队开发的智能编码助手,能够通过其强大的 AI 能力,为 HTML 开发者提供包括代码自动补全、智能注释、代码优化等多方面的支持。本文将指导您如何在 VSCode 中配置和使用通义灵码,以优化您的 HTML 开发流程。

安装与配置

HTML 开发可用的工具很多,Jet brains 系列的大部分 ide 都可以开发 html,而 webStorm 是他家专门用于 web 页面开发的。此外微软的 VS 和 VSCode , 还有一些别的公司研发的比如 atomhbuilder,notepad++,eclipse 等都可以用于 html 开发。

但大多数程序员在开发前端页面时仍会选择使用 VSCode,因为相比于 jetbrain 和 vs 以及 eclipse 这种重量级的 IDE,VSCode 更加轻巧,所以本文以 VSCode 为主进行介绍!

前提条件

确保您的开发环境中已安装 VSCode。

如果尚未安装,可以从 VSCode官网 下载并安装。

安装步骤

  1. 通过 VSCode 插件市场安装
    打开 VSCode,进入扩展视图(快捷键:`Ctrl+Shift+X`)。
    - 搜索“TONGYI Lingma”。
    - 找到插件后,点击安装。

1.png

  1. 使用 VSIX 文件安装
    访问通义灵码官网下载 VSIX 安装包。
    在 VSCode 中,选择“扩展”视图,点击“从 VSIX 安装”,然后选择下载的 VSIX 文件进行安装。

2.png

  1. 安装完成后,VSCode 界面左侧会显示通义灵码的图标,点击图标并按照提示登录即可开始使用。

主要功能应用

代码自动补全

虽然通义灵码也提供了 html 的代码自动补全,但前端开发设计样式一般由美工决定,使用代码补全并不能生成我们真正需要的代码(但或许还是会有猜对的机会)。

3.jpeg

我们可以通过禁用行间生成来关闭该功能。

智能注释生成

通义灵码确实提供了对 html 代码的注释生成和代码解释,你可以像其他编程语言那样使用代码注释和代码解释。

但对于 html 而言,代码的注释应该由开发者自己根据结构进行确定,而不是生成对每个 html 标签都注释的 html 代码,这样注释会让 html 文件变大,影响页面加载!

4.jpeg

代码解释

通义灵码也提供了代码解释功能,但 html 是一种结构化的标记语言,代码解释其实必要性不大。

5.jpeg

代码优化建议

通义灵码提供了对html的代码优化建议,对于前端开发工作者而言,只用divspan标签就能开发一个页面,但这种方式是缺乏语义化的,对搜索引擎不友好,有些元素可能被废弃,有些属性可能不能使用了,对于程序员来说记住哪些是废弃的元素需要一定的知识积累,而现在使用代码优化它可以帮助你快速完成这些工作!

6.jpeg

虽然它会提供优化后的代码,但前端开发工作者应该更多的参考其给出的建议自行修改,而不是直接合并!

单元测试自动生成

HTML 并不是编程语言(是标记语言),它并不像其他编程语言一样可以有最小分隔单元(函数或者类)可以进行测试,但软件测试中也有针对页面进行的 ui 测试,一般使用 Python 结合一些 Python 工具库对html 页面进行解析。

比如可以使用 Python 的 unittest(单元测试框架)和 bs4(html 解析框架)进行页面的单元测试

7.jpeg

与代码优化相同,这里也会提供测试代码

8.jpeg

智能问答

在开发过程中遇到问题,可以直接向通义灵码提问,它将提供解决方案或相关的代码示例。

9.jpeg

AI 程序员

AI 程序员是用来帮助开发者进行编码工作的,它可以通过对话的方式根据你的需求描述和要求,对整个项目进行代码开发,代码审查和代码变更。

10.jpeg

实用技巧

会话管理:

与其他的对话型 AI 相同,通义灵码也有上下文的概念,通义给出的答案会更倾向于之前的提问与回答,也就是说如果你一直在问img的标签,当你突然询问target属性时通义灵码可能会认为你询问的是imgtarget属性,但实际上这是一个a标签特有的属性!

言下之意就是,如果你想询问新的内容,要么下够足够多的描述(定语),要么新开一个会话,或者清除现在的会话。

可以通过会话创建和清理来清理大模型记录的提问信息,你可以选择会话创建和清理,也可以选择会话清理。

清理会话:在对话框中输入`/clearContext`,然后点击确定即可。

创建新会话:在对话框中中输入`/newTask`,然后点击确定即可。

如何用一行代码实现全链路用户行为捕获?

作者 11在上班
2025年4月8日 17:50

前言

本篇文章继续日志系列,前两篇已经讲述 日志上报如何调度为什么要上报错误日志,感兴趣可以翻看之前的文章。本篇文章想聊一下前端交互日志的实现原理。

工作中,大部分业务都关注收益转化路径上的交互行为,通常由业务打点来统计,开发过ToC的同学大概都了解,业务打点的代码入侵很高、维护效率低,像我们项目中光点位文件就有几十KB。

在这种情况下,如果PM哪天和我说:“程序员,你把用户行为都上报一下,我们想看看用户在页面上都做了什么”,但凡PM提了这种需求,光维护点位就够干走好几个程序员了,很不合理!

image.png

但是如果有个工具,只要你接入并初始化,就可以自动帮你上报全局交互呢?🤔

这篇长文将揭秘如何通过无痕埋点SDK,在零代码入侵的前提下实现用户行为全链路监控。从H5到小程序跨端兼容,打造开发者友好的埋点基础设施~

image.png

一、如何定义用户交互

在定义交互类型时,先想一下常见交互有哪些。像我常见的交互有:点击表单的提交按钮,完成购物订单的支付;图文页面的滚动、下拉加载;播放器或者语音标签的播放、暂停控制;再复杂一点的话还有Canvas绘制,满足用户随手记的诉求。列举出来的这些常见交互,根据复杂程度可以分成三大类,下面展开聊聊。

基础交互

交互单一,属于原子性操作,可以通过事件监听完成捕获,包含的事件类型如下:

类型 触发方式 典型场景案例 数据维度
点击事件 DOM元素点击/小程序组件触发 按钮点击、卡片跳转 坐标位置、元素尺寸
输入事件 键盘输入/语音输入/扫码 表单填写、搜索框内容变更 输入内容、输入时长
滚动事件 页面/容器滚动 商品列表浏览、长文章阅读 滚动速度、可视区域占比
媒体控制 播放/暂停/音量调节 视频播放器、音频组件 播放进度、缓冲时长

常见的点击事件监听如:

// 标准实现模式
window.addEventListener('click', (e) => {
  analytics.track('button_click', {
    elementId: e.target.id,
    pageUrl: window.location.pathname
  });
});

复合交互

之前在掘金看到一篇文章,通过监听鼠标的移动,配合CSS和JS计算,实现一场鼠标上的烟花舞蹈,看完文章的我也迅速写了一个,此处找不到那篇文章了,有看过的可以评论区贴一下链接,还是蛮有意思的。

复合交互,主要指多动作协同,常见的业务场景中的复合交互例子,比如拖拽排序,需要检测用户的按下、移动和释放动作,并实时更新元素位置;双指缩放需要处理多点触控事件,计算距离变化并调整视图;表单验证可能在用户输入时实时检查并给出反馈,涉及多个输入事件和状态管理。这些都是常见的复合交互的例子。

简单概括:仅凭一次触发,无法构成一次完整的交互

这样就很清晰了,基础交互就是,一次触发可以构成一次完整的交互

下面以双指缩放为例:

class PinchZoomer {
  constructor() {
    this.startDistance = null;
  }

  handleTouchMove(e) {
    if (e.touches.length === 2) {
      const currentDistance = this._getDistance(e.touches[0], e.touches[1]);
      if (this.startDistance) {
        const scale = currentDistance / this.startDistance;
        this._applyZoom(scale);
      }
      this.startDistance = currentDistance;
    }
  }

  _getDistance(touch1, touch2) {
    return Math.hypot(
      touch2.clientX - touch1.clientX,
      touch2.clientY - touch1.clientY
    );
  }
}

业务级交互

业务级交互强调流程与业务交付质量,业务级交互核心特征如下:

  • 多系统协同
  • 事务性保障
  • 复杂状态流转

不明白也没事,我们以电商平台「购物车结算」为例,从功能的开始到完成流程如下:

image.png

整个流程涉及前端交互层 → 业务逻辑层 → 后端微服务,在结算模块里,不同的结算结果映射不同的结果:

[待支付] --支付成功--> [已支付]
[待支付] --支付失败--> [支付异常]
[支付异常] --人工处理--> [已处理]

这种映射处理也称为事务性保障,确保跨系统、跨服务的操作要么全部成功完成,要么全部失败回滚的能力。 这种处理在支付相关的业务中很常见。

二、开始动手实现

明确上报范围

第一章中已经明确交互事件的类型有哪些,第二章主要讲如何实现一个SDK完成全局交互上报。

首先明确我们要上报的交互事件范围是什么?

一个工具在设计时一定要有清晰的功能边界,拒绝过度设计,贯彻执行单一原则。

下面是一个交互日志流程图: image.png 划分日志范围时,由日志特征倒推范围,以结果为导向的梳理目标。在这个工具里我期望数据无关业务、无关流程,只关注交互本身。明确关注点后,很容易划分清晰范围:即只上报基础交互和页面生命周期,接下来具体介绍这两种交互的内容有哪些。

基础交互事件

包含常见的点击、输入、滚动、悬停、拖拽、导航变化、媒体控制等。

类型 技术实现 典型场景案例
点击行为 click/touchstart 按钮点击、卡片选择
输入行为 input/change 表单填写、搜索框实时查询
滚动行为 scroll 无限滚动加载、页面滚动分析
悬停反馈 mouseover/mouseout 商品悬停预览、按钮悬停状态变化
拖拽操作 dragstart/drop 文件上传、列表排序
页面导航 popstate/hashchange 前进后退、路由切换
媒体控制 play/pause 视频播放控制、音频交互

页面生命周期

页面生命周期的触发与用户的交互行为也有关联,比如用户将APP暂时挂起,Old Tab触发隐藏,或者用户打开New Tab,这时Old Tab也会触发隐藏。

在小程序中,有专门的生命周期钩子可以利用,比如:

生命周期 触发时机 典型应用场景
onLoad 页面实例创建时触发(仅首次加载) 初始化数据、获取 URL 参数
onShow 页面显示时触发(包括从后台切回、新窗口打开) 恢复页面状态、数据刷新
onReady 页面初次渲染完成时触发 DOM 操作、第三方 SDK 初始化
onHide 页面隐藏时触发(切换页面、下拉刷新、打开抽屉导航等) 保存表单数据、暂停音视频
onUnload 页面卸载销毁时触发(调用 redirectTo/reLaunch 等路由方法时) 清理定时器、释放资源

H5则可以使用事件监听,对页面状态进行监听,如:

// 页面可见性变化
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') {
    // onShow 逻辑
  } else {
    // onHide 逻辑
  }
});

// 页面加载完成
window.addEventListener('load', () => {
  // 初始化操作
});

// 页面卸载(关闭/刷新)
window.addEventListener('beforeunload', (e) => {
  // 保存数据
  e.preventDefault();
  e.returnValue = '';
});

其中主流框架比如React、Vue,会提供独立生命周期体系:

框架 生命周期函数 对应能力
Vue created → mounted → destroyed 初始化 → DOM 挂载 → 销毁清理
React componentDidMount → useEffect 组件挂载 → 更新/副作用管理

定义上报内容

在行为日志上报中,需要精准定义上报数据以平衡分析价值性能开销隐私合规。以下是经过验证的日志数据定义方案,分为核心字段、扩展字段和策略性字段。

1. 基础属性

字段名 类型 必传 说明
event_type string 事件类型(如 click/input/page_view
timestamp number 时间戳(毫秒级,建议使用 performance.now()
session_id string 用户唯一会话标识(通过 localStorage 生成)
user_id string ⚠️ 用户ID(需脱敏,如 user_1234 而非手机号)
page_url string 当前页面完整URL(含查询参数)

2. 行为属性

字段名 类型 必传 说明
target_id string 触发事件的DOM元素ID(如 btn-submit
target_type string 元素类型(如 button/input/link
action string 具体行为(如 click/change/scroll
position object ⚠️ 坐标信息({ x: 100, y: 200 }),用于分析点击热区

3. 环境属性

字段名 类型 必传 说明
device_type string 设备类型(mobile/pc/tablet
os string 操作系统(如 iOS 16.6/Windows 11
browser string 浏览器类型及版本(如 Chrome 119.0.0
network string ⚠️ 网络状态(wifi/4g/5g

为进一步提升数据的精准率,这里建议上报traceID,每当用户进入系统后,会在RPC调用网络的第一层生成一个全局唯一的traceId,并且会随着每一层的RPC调用,不断往后传递,通过traceId可以把一次用户请求在系统中调用的路径串联起来。对交互日志来说,traceID也可以将行为路径串联起来,从而更好的还原用户行为。

封装上报类

期望工具使用简洁、无代码入侵,只需要初始化即可完成接入。

在应对这种诉求时,用单例模式来封装再合适不过了,可以避免多个日志实例重复占用内存,下面是一个实现demo:

class FullAutoTracker {
  constructor() {
    if (FullAutoTracker.INSTANCE) {
      return FullAutoTracker.INSTANCE;
    }
    
    this._initialized = false;
    this._queue = [];
    this._retryQueue = [];
    this._config = {
    };
    
    this._initEventListeners();
    this._initPersistence();
    FullAutoTracker.INSTANCE = this;
  }

  static getInstance() {
    if (!FullAutoTracker.INSTANCE) {
      FullAutoTracker.INSTANCE = new FullAutoTracker();
    }
    return FullAutoTracker.INSTANCE;
  }

  // 初始化事件代理
  _initEventListeners() {
  }

  // 通用事件处理器
  _handleEvent(e) {
  }

  // 节流滚动处理
  _throttleScrollHandler() {
  }

  // 页面可见性变化
  _handleVisibilityChange() {
  }

  // 核心上报方法
  async _track(eventType, data) {
  }

  // 批量处理队列
  async _processQueue() {
  }

  // 发送日志
  async _sendLogs(logs) {
  }

  // 持久化初始化
  _initPersistence() {
  }

  // 移除实例方法
  static destroy() {
    FullAutoTracker.INSTANCE = null;
  }
}

通用事件处理器

_handleEvent中,将拦截用户的基础交互,如果页面不涉及小程序,只有H5、PC,完全可以用DOM的API去实现,充分利用document.addEventListener,能监听的事件范围基本覆盖了基础交互,具体实现可参考官方文档

但是!但是!国内大部分公司,都很会~迎合用户的活跃平台,比如微信,很多产品都有微信小程序,还有些有钱的公司会自己维护APP。像这种多端场景下,需要检测一下环境,根据不同环境来实现全局监听。

🧠 理论设想:是否能“劫持”点击事件?

你可能是想尝试类似:

const originalBindTap = Component.prototype.bindTap; // 伪代码,不存在这个方法
Component.prototype.bindTap = function(e) {
  reportClick(e);
  return originalBindTap.call(this, e);
};

遗憾的是:

  • 小程序并不开放事件系统内部 API(没有 DOM、没有 event bubbling);
  • App() 实例并不能 hook 到页面上的点击;
  • 自定义组件的事件机制是封装好的,你无法在 App 层面进行 patch 或拦截

image.png

✅ 实际可行方案

如果想实现 “无需每个元素都写 bindtap”,可以尝试:

✅ 使用 页面级 onTouchStart/onTouchEnd 来代理点击行为:
js
复制编辑
// page.js
Page({
  onTouchStart(e) {
    const x = e.touches[0].pageX;
    const y = e.touches[0].pageY;
    this._touchStart = { x, y, time: Date.now() };
  },
  onTouchEnd(e) {
    const { x, y, time } = this._touchStart || {};
    const duration = Date.now() - time;
    
    // 假设这是点击(没有大幅移动和长按)
    if (duration < 300) {
      this.reportGlobalTap({ x, y, duration });
    }
  },
  reportGlobalTap(data) {
    // 这里你可以将 x/y 映射到某些元素区域(比如通过预设的坐标)
    console.log('全局点击模拟上报:', data);
  }
});

⚠️ 缺点:不能精确知道用户点击了哪个元素,除非事先定义了元素的点击区域或布局。

✅ 如果使用的是组件化体系(如自定义组件、Taro、UniApp)

封装一个高阶组件(HOC)或 Mixin,把点击事件包装进去:

复制编辑
Component({
  methods: {
    __reportClick(e) {
      const id = e.currentTarget.dataset.eventId;
      // 自动打点
    }
  },
  attached() {
    // 自动给某些元素加事件
  }
});

但是在我们这个工具中,更期望的是实现全局上报交互,不需要手动上报,🤔 如果你使用的是Taro框架,那么巧了,我用的也是Taro框架,那么我就有一套很不错的方案可以提供给你哦~

首先,Taro 3是一个重运行时架构,兼容处理大部分都在运行时进行

image.png

在实现跨端兼容时,Taro主要在抽象层去处理各种兼容问题,所以事件派发及生命周期等,也都是通过抽象层去处理的,引用一张关于Taro运行时的工作内容图:

image.png

根据Taro运行时的内容,打印Taro runtime观察,可以发现runtime.hooks中包含dispatchTaroEvent事件派发机制,通过代理该机制,在元素被触发时,自动执行代理的方法,整体流程如下:

image.png

页面生命周期处理

页面生命周期代理与事件代理的原理基本一致,难点同样也是如何处理多端。

在H5环境中中,主要还是依赖事件监听去执行一些页面的生命周期,比如:

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
      ...
  } else {
      ...
  }
});

window.addEventListener('pageshow', (e) => {
  if (e.persisted) {
      ...
  }
});
window.addEventListener('DOMContentLoaded', () => {
});

window.addEventListener('load', () => {
});

window.addEventListener('beforeunload', (e) => {
});

原生小程序,可以通过APP/Page实现全局代理 初始化时传入APP,在SDK内部进行代理,比如:

export function createApp(appOptions) {
  const onLaunch = appOptions.onLaunch
  appOptions.onLaunch = function(options) {
    console.log('[App Launch]', options)
    // 你自己的逻辑
    onLaunch && onLaunch.call(this, options)
  }

  // 可以代理其他生命周期:onShow, onHide, onError 等
  App(appOptions)
}

如果是Taro的话可以参考事件代理方案,生命周期代理的实现原理基本一致(除了这个原因外,还有一点其实是有点写不动了)。

image.png

结语

让埋点回归本质

期待这套方案能成为你构建用户行为分析体系的基石,让技术回归服务业务的本质。喜欢的话可以点个关注,你的喜欢也是我更新的一种动力~下篇文章见!

❌
❌