普通视图

发现新文章,点击刷新页面。
今天 — 2025年7月3日首页

IEEE 754 双精度浮点数标准,最大整数和最大的数字

2025年7月3日 16:02

基础:

  1. 所有的数字,都是用 64位双精度浮点数 表示,其内存结构分为3部分

[1位符号位][11位指数位][52位尾数位] 来存储的

符号位:决定数字的正负(0是正数,1是负数)

指数位:表示2的幂次(采用偏移码表示,实际指数 = 存储值 - 1023)

范围:-1022 到 1023(特殊值 0 和 2047用于表示0和无穷大)

尾数位/有效数字(52 bits + 隐含位)

  • 关键点:实际精度是 53 bits(52位显式存储 + 1位隐含的"1") 采用"隐含前导1"的表示法(normalized numbers)

综上: 数值的计算公式为:

image.png

最大整数Number.MAX_SAFE_INTEGER

2^53 -1

最大值:Number.MAX_VALUE

image.png

老板突然发问 公司OA系统突然无法登录 我慌了

作者 poloma
2025年7月3日 15:40

前言

有一天,老板突然登录后台管理系统,说系统卡住了,无法登录,让我马上和运维排查问题

从点击登录到卡住,返回请求错误码足足等待了超过30S,老板挂面,我可能有被裁风险

image.png

排查问题

点击登录以后,前端请求后端的接口,这里的流程是前端通过跨域请求实现的。 方式是通过 nginx 服务器代理请求后端的接口的。

我们定位到 nginx 处理跨域请求的时候的配置上。

Nginx 在使用 proxy_pass 进行域名代理时,可能会出现缓存问题,导致后端服务更新后客户端无法及时获取最新内容。这通常由多种因素引起,包括 Nginx 自身缓存、上游服务器缓存头设置或浏览器缓存策略等诸多因素,我们一个个进行排查

一,确认缓存的来源

在解决问题前,需先确定缓存位置:

  1. 浏览器缓存:通过开发者工具(如 Chrome 的 Network 面板)检查响应头中的 Cache-Control 和 Expires
  2. Nginx 代理缓存:检查 Nginx 配置是否启用了 proxy_cache 或相关指令。
  3. 上游服务器缓存:检查后端应用是否设置了缓存头。

很容易我们就排除了浏览器的缓存和上游服务器后端的缓存,前端浏览器这边 Cache-Control Expires 已经设置好响应头,同时后端应用也设置好了缓存头

接下来我们将问题定位到运维的位置

二、禁用 Nginx 代理缓存

在处理跨域请求(CORS)时,Nginx 需要同时解决两个关键问题:跨域头配置禁用缓存。以下是针对这两个需求的配置方案:

一、基础配置:跨域头 + 禁用缓存

server {
    listen 80;
    server_name your-domain.com;
    
    # 处理跨域请求
    location /api/ {
        # 代理配置
        proxy_pass http://backend-api-server/;  # 后端 API 服务器地址
        
        # 跨域头配置
        add_header 'Access-Control-Allow-Origin' '*' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
        add_header 'Access-Control-Allow-Headers' 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
        
        # 允许携带凭证(如 cookies)
        add_header 'Access-Control-Allow-Credentials' 'true' always;
        
        # OPTIONS 请求直接返回 204
        if ($request_method = 'OPTIONS') {
            return 204;
        }
        
        # 禁用缓存配置
        proxy_no_cache $cookie_nocache $arg_nocache $arg_comment;
        proxy_cache_bypass $cookie_nocache $arg_nocache $arg_comment;
        
        add_header Cache-Control "no-cache, no-store, must-revalidate" always;
        add_header Pragma "no-cache" always;
        add_header Expires "0" always;
        
        # 其他代理常用配置
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

二、高级配置:针对不同请求类型优化

1. 静态资源与动态 API 差异化处理
server {
    # 静态资源(允许缓存)
    location /static/ {
        proxy_pass http://backend-static/;
        # 默认缓存策略(根据需要调整)
        expires 7d;
    }
    
    # API 请求(禁用缓存)
    location /api/ {
        proxy_pass http://backend-api/;
        
        # 跨域头
        add_header 'Access-Control-Allow-Origin' '*' always;
        # ... 其他 CORS 头
        
        # 禁用缓存
        proxy_no_cache 1;
        proxy_cache_bypass 1;
        add_header Cache-Control "no-cache, no-store, must-revalidate" always;
        add_header Pragma "no-cache" always;
        add_header Expires "0" always;
    }
}
2. 仅对特定 API 禁用缓存
location /api/no-cache/ {
    proxy_pass http://backend/;
    
    # 禁用缓存
    proxy_no_cache $arg_nocache;
    proxy_cache_bypass $arg_nocache;
    
    # 动态添加 nocache 参数来控制是否缓存
    # 例如:/api/no-cache/data?nocache=1
}
3. 针对常用写死的域名,让每次请求都进行 DNS 查询

企业微信截图_17514472935097.png

http {
    resolver 8.8.8.8 valid=10s;

    server {
        listen 80;

        location / {
            set $upstream_host a.b.com;
            proxy_pass https://$upstream_host;
            proxy_set_header Host $upstream_host;
        }
    }
}

三、验证配置是否生效

1. 检查响应头
curl -I http://your-domain.com/api/data

# 预期输出:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE
Access-Control-Allow-Headers: DNT,X-Mx-ReqToken,...
Cache-Control: no-cache, no-store, must-revalidate
Pragma: no-cache
Expires: 0
2. 浏览器开发者工具验证

在 Chrome/Firefox 中:

  1. 打开开发者工具 → Network 面板
  2. 发送请求并检查响应头
  3. 确认没有 (from cache) 标记

通过以上配置,可确保 Nginx 在处理跨域请求时正确禁用缓存,同时满足 CORS 安全要求。根据具体业务需求调整配置细节,平衡性能与安全性。

三、 常见问题与解决方案

缓存仍未完全禁用

  • 问题原因

    • 上游服务器设置了缓存头,覆盖了 Nginx 的配置
    • CDN 缓存未清除
    • 浏览器强缓存
  • 解决方案

    • 使用 proxy_ignore_headers Cache-Control Expires 忽略上游缓存头
    • 若使用 CDN,在 CDN 控制台刷新缓存
    • 要求用户使用 Ctrl+F5(Windows)或 Command+Shift+R(Mac)强制刷新

Nginx 配置生效部分

修改配置后需重启或重载 Nginx:

sudo nginx -s reload

总结

前端针对这种问题需要提前做预防措施,为了避免

预防措施

  1. 开发阶段
  • 使用代理服务器处理跨域(如 Vite 的 server.proxy
  • 在浏览器开发者工具中始终启用「Disable cache」
  1. 生产环境
  • 对静态资源使用版本化(如 app.js?v=1.0.1
  • 为 API 请求设置合理的缓存策略(如频繁更新的数据使用 no-cache
  • 定期审查服务器和 CDN 的缓存配置
解决方案总结
问题类型 解决方案
跨域错误 1. 修改服务器 Nginx 配置,添加正确的 CORS 头
2. 使用代理服务器(如 Vite/Webpack 代理)
3. 确保请求配置与服务器允许的范围一致
浏览器缓存 1. 在开发者工具中禁用缓存
2. 添加响应头 Cache-Control: no-cache
3. 请求 URL 添加随机参数
服务器缓存 1. 修改后端代码,禁用缓存头
2. 检查 Nginx 配置,确保正确设置 proxy_no_cache
CDN 缓存 1. 在 CDN 控制台刷新缓存
2. 调整 CDN 缓存规则(如设置 Cache-Control: no-cache

至此完结

常见简单的知识点

2025年7月3日 15:37

在编程中,?? 是一个 空值合并运算符(Nullish Coalescing Operator) ,主要用于提供默认值。它的作用如下:

语法:

leftExpression ?? rightExpression

行为:

  • 如果 leftExpression 的值为 null 或 undefined,则返回 rightExpression(即默认值)。
  • 否则,直接返回 leftExpression 的值。

示例:

const value1 = null ?? 'default';      // 输出: 'default'(因为左侧是 null)
const value2 = undefined ?? 'fallback'; // 输出: 'fallback'(因为左侧是 undefined)
const value3 = 0 ?? 42;                // 输出: 0(因为左侧不是 null/undefined)
const value4 = '' ?? 'hello';          // 输出: ''(因为左侧不是 null/undefined)

与 || 的区别:

  • || 运算符会对左侧的 假值(falsy) (如 0''falsenullundefined)触发默认值。
  • ?? 仅对 null 或 undefined 触发默认值,更精确。
const a = 0 || 42;   // 输出: 42(因为 0 是假值)
const b = 0 ?? 42;   // 输出: 0(因为 0 不是 null/undefined)

在 Vue 3 中,unref 是一个 响应式工具函数,用于获取一个响应式引用(Ref)的 内部值。如果传入的参数本身不是 Ref,则直接返回该参数。

作用

unref 的作用可以理解为:

  • 如果传入的是 Ref 对象(如 ref() 创建的),则返回它的 .value
  • 如果传入的不是 Ref,则直接返回原值。

源码实现

function unref<T>(ref: T | Ref<T>): T {
  return isRef(ref) ? ref.value : ref;
}

使用场景

  1. 简化 Ref 和普通值的访问

    • 在不确定某个变量是 Ref 还是普通值时,可以用 unref 安全地获取值。
    • 避免手动判断 isRef 再取值。
  2. 在组合式函数(Composables)中处理参数

    • 允许函数同时接受 Ref 或普通值,提高灵活性。

示例

基本用法
import { ref, unref } from 'vue';

const count = ref(1);
const num = 2;

console.log(unref(count)); // 输出: 1(相当于 count.value)
console.log(unref(num));   // 输出: 2(直接返回 num)
在组合式函数中使用
import { ref, unref, computed } from 'vue';

// 该函数可以接受 Ref<number> 或 number
function double(value) {
  const unwrapped = unref(value); // 安全取值
  return unwrapped * 2;
}

const a = ref(3);
const b = 4;

console.log(double(a)); // 输出: 6(a 是 Ref)
console.log(double(b)); // 输出: 8(b 是普通值)
与 toRef 对比
  • toRef:将响应式对象的属性转换为 Ref
  • unref:从 Ref 中提取值(反向操作)。
import { reactive, toRef, unref } from 'vue';

const state = reactive({ foo: 1 });
const fooRef = toRef(state, 'foo');

console.log(unref(fooRef)); // 输出: 1

注意事项

  • unref 不会解除深层响应式(如 reactive 对象),它仅处理 Ref
  • 如果需要深度解包(如嵌套 Ref),可以使用 toRaw 或第三方工具(如 vue-utils 的 deepUnref)。

总结

unref 是 Vue 3 响应式系统中一个轻量级的工具函数,主要用于:

  1. 统一处理 Ref 和普通值。

  2. 在组合式函数中增加参数灵活性。
    它的存在让代码更简洁,避免重复的 isRef 判断。

在 Vue 3 的响应式系统中,toRaw 和 toRef 是两个用途完全不同的工具函数,它们的核心区别如下:

1. toRaw:获取原始非响应式对象

作用
  • 返回一个响应式对象(reactive 或 readonly 创建的)的 原始普通对象(剥离所有响应式特性)。
  • 对 ref 对象,返回其 .value 的原始值(如果 .value 是响应式对象)。
使用场景
  • 需要直接操作原始数据,避免响应式开销(如性能敏感场景)。
  • 临时修改数据但不想触发响应式更新。
示例
import { reactive, toRaw } from 'vue';

const obj = reactive({ foo: 1 });
const rawObj = toRaw(obj); // 原始对象 { foo: 1 }

console.log(rawObj === obj); // false(rawObj 是非响应式的普通对象)

// 修改原始对象不会触发响应式更新
rawObj.foo = 2; 
console.log(obj.foo); // 2(值变化,但不会触发视图更新)
注意事项
  • 对 ref 对象,toRaw(ref) 等价于 toRaw(ref.value)

2. toRef:将响应式对象的属性转换为 Ref

作用
  • 为响应式对象(reactive)的某个属性创建一个 关联的 Ref 引用
  • 修改 Ref 会同步到原始对象,反之亦然。
使用场景
  • 需要将响应式对象的某个属性单独作为 Ref 传递,保持响应式关联。
  • 在组合式函数中解构属性时保持响应性。
示例
import { reactive, toRef } from 'vue';

const state = reactive({ foo: 1 });
const fooRef = toRef(state, 'foo'); // 创建 foo 的 Ref

// 修改 Ref 会同步到原对象
fooRef.value = 2;
console.log(state.foo); // 2

// 修改原对象也会更新 Ref
state.foo = 3;
console.log(fooRef.value); // 3
与 ref 的区别
  • ref(1) 创建一个独立的 Ref,与原对象无关。
  • toRef(state, 'foo') 创建的 Ref 和原对象的 foo 属性保持双向绑定。

核心区别对比

函数 作用对象 返回值类型 是否保持响应式关联 典型用途
toRaw reactive/readonly 原始普通对象 ❌ 完全剥离响应式 获取原始数据,避免响应式开销
toRef reactive 对象的属性 Ref ✅ 双向同步 解构属性并保持响应性

结合使用的场景

import { reactive, toRef, toRaw } from 'vue';

const state = reactive({ foo: { bar: 1 } });

// 将响应式对象的属性转为 Ref
const fooRef = toRef(state, 'foo'); 

// 获取 Ref 的原始值(非响应式)
const rawFoo = toRaw(fooRef.value); 
console.log(rawFoo); // { bar: 1 }(普通对象)

总结

  • toRaw:用于“降级”响应式对象,获取原始数据(非响应式)。
  • toRef:用于“升级”响应式对象的属性为 Ref,保持响应式关联。
  • 两者互补,分别处理响应式系统的不同层级需求。

在 JavaScript 中,不同类型的循环有不同的 停止(中断)方式,它们的用途和特性也有显著区别。以下是详细对比和完整示例:

1. 循环类型对比

循环方法 适用对象 能否被停止? 停止方式 返回值 特点
forEach 数组 ❌ 不能直接停止 抛出异常(不推荐) undefined 简洁,但无法中断
map 数组 ❌ 不能停止 新数组 返回新数组,不改变原数组
for...in 对象(枚举属性) ✅ 可以用 break break / return - 遍历键名(包括原型链属性)
for...of 可迭代对象(数组、字符串等) ✅ 可以用 break break / return - 遍历值(忽略原型链属性)
for 通用 ✅ 可以用 break break / return - 灵活控制循环条件

2. 完整示例代码

(1) forEach:无法直接停止

const arr = [1, 2, 3];
arr.forEach(item => {
  console.log(item);
  if (item === 2) {
    // 无法直接停止!只能通过抛出异常(不推荐)
    throw new Error('强行停止');
  }
});
// 输出: 1, 2, Error

(2) map:无法停止,始终返回新数组

const arr = [1, 2, 3];
const newArr = arr.map(item => {
  console.log(item);
  return item * 2;
});
console.log(newArr); // [2, 4, 6]

(3) for...in:遍历对象键名,可用 break 停止

const obj = { a: 1, b: 2, c: 3 };
for (const key in obj) {
  console.log(key, obj[key]); // 输出键名和值
  if (key === 'b') break; // 停止循环
}
// 输出: a 1, b 2

(4) for...of:遍历可迭代对象的值,可用 break 停止

const arr = [1, 2, 3];
for (const item of arr) {
  console.log(item);
  if (item === 2) break; // 停止循环
}
// 输出: 1, 2

(5) for 循环:经典循环,完全可控

const arr = [1, 2, 3];
for (let i = 0; i < arr.length; i++) {
  console.log(arr[i]);
  if (arr[i] === 2) break; // 停止循环
}
// 输出: 1, 2

3. 如何选择循环方式?

场景 推荐循环方式 原因
需要中途停止循环 for / for...of / for...in 支持 break 和 return
遍历数组并返回新数组 map 简洁,自动返回新数组
遍历数组但不需要返回值 forEach 语法简单,但无法中断
遍历对象属性(包括继承属性) for...in 遍历键名,但需用 hasOwnProperty 过滤原型链属性
遍历可迭代对象(数组、字符串等) for...of 直接遍历值,比 for...in 更适合数组

4. 特殊情况处理

forEach 模拟中断(不推荐)

const arr = [1, 2, 3];
try {
  arr.forEach(item => {
    console.log(item);
    if (item === 2) throw new Error('Stop');
  });
} catch (e) {
  if (e.message !== 'Stop') throw e;
}
// 输出: 1, 2

for...of + return(在函数中使用)

function findTarget(arr, target) {
  for (const item of arr) {
    if (item === target) return item; // 直接返回并停止循环
  }
  return null;
}
console.log(findTarget([1, 2, 3], 2)); // 2

总结

  • 需要中断循环:优先使用 forfor...of 或 for...in + break
  • 遍历数组并返回新数组:用 map
  • 遍历对象属性:用 for...in(注意过滤原型链属性)。
  • forEach 和 map 无法中断,但 map 会返回新数组。

Reflect 在 Vue 3 中的作用及常用 API 方法

Reflect 是 ES6 引入的一个内置对象,它提供拦截 JavaScript 操作的方法,这些方法与 Proxy 处理器方法一一对应。在 Vue 3 的响应式系统中,Reflect 被广泛使用来实现代理行为。

Reflect 的核心作用

  1. 提供操作对象的标准方法:替代一些传统的 Object 方法
  2. 与 Proxy 配合使用:Proxy 的 trap 通常需要调用对应的 Reflect 方法来完成默认行为
  3. 更规范的返回值:相比传统方法,Reflect 方法有更一致的返回值(如成功返回 true,失败返回 false)

Vue 3 中常用的 Reflect API

1. Reflect.get(target, propertyKey[, receiver])

获取对象属性的值

const obj = { foo: 42 };
console.log(Reflect.get(obj, 'foo')); // 42

2. Reflect.set(target, propertyKey, value[, receiver])

设置对象属性的值

const obj = {};
Reflect.set(obj, 'foo', 123);
console.log(obj.foo); // 123

3. Reflect.has(target, propertyKey)

检查对象是否具有某属性

const obj = { foo: 1 };
console.log(Reflect.has(obj, 'foo')); // true
console.log(Reflect.has(obj, 'bar')); // false

4. Reflect.deleteProperty(target, propertyKey)

删除对象属性

const obj = { foo: 1, bar: 2 };
Reflect.deleteProperty(obj, 'foo');
console.log(obj); // { bar: 2 }

5. Reflect.ownKeys(target)

获取对象所有自身属性键(包括不可枚举和Symbol属性)

const obj = {
  [Symbol('id')]: 123,
  name: 'John'
};
console.log(Reflect.ownKeys(obj)); // ['name', Symbol(id)]
const obj = {
  [Symbol('id')]: 123,
  name: 'John'
};
console.log(Reflect.ownKeys(obj)); // ['name', Symbol(id)]

Vue 3 中使用 Reflect 的案例

案例1:响应式系统中的使用

Vue 3 的响应式系统大量使用 Reflect 与 Proxy 配合:

const reactiveHandler = {
  get(target, key, receiver) {
    track(target, key); // 依赖追踪
    return Reflect.get(target, key, receiver);
  },
  set(target, key, value, receiver) {
    const oldValue = target[key];
    const result = Reflect.set(target, key, value, receiver);
    if (result && oldValue !== value) {
      trigger(target, key); // 触发更新
    }
    return result;
  }
  // 其他trap...
};

function reactive(obj) {
  return new Proxy(obj, reactiveHandler);
}

const state = reactive({ count: 0 });

案例2:组合式API中的使用

import { reactive, watchEffect } from 'vue';

const user = reactive({
  name: 'Alice',
  age: 25
});

// 使用Reflect进行属性操作
function updateUser(key, value) {
  if (Reflect.has(user, key)) {
    Reflect.set(user, key, value);
  } else {
    console.warn(`Property ${key} does not exist`);
  }
}

watchEffect(() => {
  console.log('User updated:', Reflect.ownKeys(user).map(k => `${k}: ${user[k]}`));
});

updateUser('age', 26); // 触发更新
updateUser('email', 'alice@example.com'); // 警告

案例3:自定义Ref实现

import { customRef } from 'vue';

function useDebouncedRef(value, delay = 200) {
  let timeout;
  return customRef((track, trigger) => {
    return {
      get() {
        track();
        return value;
      },
      set(newValue) {
        clearTimeout(timeout);
        timeout = setTimeout(() => {
          value = newValue;
          trigger();
        }, delay);
      }
    };
  });
}

// 使用
const text = useDebouncedRef('hello');

案例4:响应式工具函数

import { reactive, toRefs } from 'vue';

function useFeature() {
  const state = reactive({
    x: 0,
    y: 0,
    // 计算属性
    get distance() {
      return Math.sqrt(this.x ** 2 + this.y ** 2);
    }
  });

  function updatePosition(newX, newY) {
    // 使用Reflect批量更新
    Reflect.set(state, 'x', newX);
    Reflect.set(state, 'y', newY);
  }

  return {
    ...toRefs(state),
    updatePosition
  };
}

// 使用
const { x, y, distance, updatePosition } = useFeature();

Reflect 在 Vue 3 中的优势

  1. 与 Proxy 完美配合:每个 Proxy trap 都有对应的 Reflect 方法
  2. 更安全的操作:相比直接操作对象,Reflect 方法提供了更规范的错误处理
  3. 元编程能力:为 Vue 的响应式系统提供了底层支持
  4. 一致性:所有 Reflect 方法都返回布尔值表示操作是否成功

总结

Vue 3 的响应式系统深度依赖 Reflect API 来实现其核心功能。通过 Reflect 与 Proxy 的组合,Vue 能够:

  • 拦截对象操作
  • 跟踪依赖关系
  • 触发更新通知
  • 提供一致的响应式行为

理解 Reflect 的这些用法有助于更好地理解 Vue 3 的响应式原理,并在需要时实现更高级的自定义响应式逻辑。

🧠“一次奇怪的 JS/TS 报错,背后竟是分号惹的祸”

2025年7月3日 15:29

引子:看似无害的一行代码,却让整个程序崩溃

在学习 TypeScript 的时候,我曾因为少写了一个分号,触发了一个让人摸不着头脑的编译错误:

类型“Card[]”不能分配给类型“number”

更诡异的是,这行代码乍看之下完全没有问题:

  shuffle() {
    for(let i = 0; i < this.cards.length; i++) {
      const targetIndex:number = this.getRandom(0, this.cards.length)
      [this.cards[i], this.cards[targetIndex]] = [this.cards[targetIndex], this.cards[i]]
    }
  }

但是 TypeScript 却报错了,怎么回事?明明语法也没问题,我们不是常说 JS 的分号是“可选”的嘛?

这次,它可真的不是。


一、JS 的分号真的是“可选”的吗?

很多人(包括以前的我)都习惯不写分号。因为在 JavaScript 中,确实有一种机制叫做 ASI(Automatic Semicolon Insertion,自动分号插入) ,它会在大多数情况下帮我们补上遗漏的分号。

像下面这样的代码,在不加分号的情况下也能正常运行:

let a = 1
let b = 2
console.log(a + b)

JavaScript 会在每一行后面“想当然地”补上分号。但这并不意味着它总能理解你的意图。 事实上,在某些情况下,ASI 会失效,甚至让代码逻辑彻底跑偏。


二、数组解构 + 缺失分号 = 地狱级 Bug

我们再回到那段引起错误的代码:

const targetIndex: number = this.getRandom(0, this.cards.length)
[this.cards[i], this.cards[targetIndex]] = [this.cards[targetIndex], this.cards[i]]

表面上是没问题的,但 JS 引擎的解释却可能出乎意料:

const targetIndex: number = this.getRandom(...) [this.cards[i], ...]

没错,JavaScript 把第二行开头的 [ 误以为是上一行函数调用的下标访问。它以为你在写:

this.getRandom(...)[...]

而不是一个新的解构赋值语句。

这就是 ASI 的一个坑点:当下一行以 [ 开头时,它不会自动插入分号


三、除了 [,还有哪些坑?

除了数组开头,JS 的 ASI 机制还有一些常见的“踩雷点”:

  • 下一行以 [ 开头(数组、解构)
  • 下一行以 ( 开头(函数调用、立即执行函数)
  • 上一行以 ++-- 结尾(自增/自减)

比如

const x = 123
[x, y] = [y, x]

JS 实际上会尝试当成一行执行:

const x = 123[x, y] = [y, x]

这当然是非法语法,也会抛出莫名其妙的错误。


四、怎么避免这种问题?

其实很简单:不要赌 JS 会自动帮你加分号,关键地方自己加!

写代码的时候,我们往往觉得“少个分号没什么大不了”,但有时候,这一丢,就像埋下了一颗“定时炸弹”,在你最不希望出错的时候,炸了出来。

规范很重要。少些分号看起来“优雅”,但出了问题你可是要多花两小时去 debug 的。

写好每一行代码,从一个分号开始 ✨

从XHR到Fetch:Promise封装Ajax的奇幻之旅

作者 FogLetter
2025年7月3日 14:51

大家好,我是你们的前端小伙伴Fogletter!今天我要和大家分享一个前端开发中非常经典的话题——Ajax请求的演进史,以及如何用Promise封装传统的XHR对象来模拟现代Fetch API的效果。

一、Ajax:前端开发的里程碑

还记得2005年,Google在Gmail和Google Maps中大规模使用Ajax技术时,整个Web开发界为之震撼的场景吗?Ajax(Asynchronous JavaScript and XML)彻底改变了Web应用的交互方式,让我们告别了整页刷新的时代。

1.1 传统XHR的"原始社会"

在ES6之前,我们只能使用XMLHttpRequest对象(简称XHR)来进行异步请求。看看这段"考古代码":

const xhr = new XMLHttpRequest(); // 实例化
xhr.open('GET', 'https://api.github.com/users/fogletter/repos');
xhr.send(); // 发送请求

xhr.onreadystatechange = function() {
    if(xhr.readyState == 4){
        const data = JSON.parse(xhr.responseText);
        document.getElementById('repos').innerHTML = 
            data.map(item => `<li>${item.name}</li>`).join('');
    }
}

这段代码有几个痛点:

  1. 回调地狱:当有多个依赖请求时,代码会形成金字塔形状
  2. 状态管理:需要手动检查readyState
  3. 错误处理:需要额外代码处理网络错误

1.2 readyState的五个阶段

XHR对象的状态变化很有意思,它经历了五个阶段:

  • 0 (UNSENT): 代理被创建,但尚未调用 open() 方法
  • 1 (OPENED): open() 方法已经被调用
  • 2 (HEADERS_RECEIVED): send() 方法已经被调用,并且头部和状态已经可获得
  • 3 (LOADING): 下载中;responseText 属性已经包含部分数据
  • 4 (DONE): 下载操作已完成

我们通常只关心阶段4,也就是请求完成的时候。

二、Promise:异步编程的救星

随着前端应用越来越复杂,回调地狱问题日益严重。ES6引入的Promise成为了解决这一问题的利器。

2.1 Promise的三种状态

Promise对象代表一个异步操作的最终完成(或失败)及其结果值。它有三种状态:

  1. pending: 初始状态,既不是成功,也不是失败
  2. fulfilled: 意味着操作成功完成
  3. rejected: 意味着操作失败

2.2 用Promise封装XHR

让我们用Promise来改造传统的XHR:

const getJSON = (url) => {
    return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open('GET', url);
        xhr.send();
        xhr.onreadystatechange = function() {
            if(xhr.readyState == 4){
                if(xhr.status === 200) {
                    resolve(JSON.parse(xhr.responseText));
                } else {
                    reject(new Error(xhr.statusText));
                }
            }
        }
        xhr.onerror = function() {
            reject(new Error('Network Error'));
        }
    })
}

这样封装后,我们就可以像这样使用:

getJSON('https://api.github.com/users/fogletter/repos')
    .then(data => {
        document.getElementById('repos').innerHTML = 
            data.map(item => `<li>${item.name}</li>`).join('');
    })
    .catch(error => {
        console.error('请求失败:', error);
    });

是不是清爽多了?这种链式调用的方式让代码更加线性化,易于理解和维护。

三、Fetch API:现代浏览器的原生支持

ES6不仅带来了Promise,还引入了更现代的Fetch API。Fetch基于Promise设计,提供了更强大、更灵活的功能。

3.1 Fetch的基本用法

fetch('https://api.github.com/users/fogletter/repos')
    .then(res => res.json())
    .then(data => {
        document.getElementById('repos').innerHTML = 
            data.map(item => `<li>${item.name}</li>`).join('');
    });

Fetch API的优点:

  1. 语法简洁,更符合现代JavaScript风格
  2. 内置Promise支持,无需额外封装
  3. 提供了Request和Response对象,功能更强大
  4. 默认不会接收或发送cookies,安全性更好

3.2 结合async/await使用

ES8引入的async/await语法让异步代码看起来像同步代码一样直观:

document.addEventListener('DOMContentLoaded', async() => {
    try {
        const result = await fetch('https://api.github.com/users/fogletter/repos');
        const data = await result.json();
        document.getElementById('repos').innerHTML = 
            data.map(item => `<li>${item.name}</li>`).join('');
    } catch (error) {
        console.error('请求失败:', error);
    }
});

这种写法几乎消除了所有回调,代码可读性大大提高。

四、为什么还要学习XHR?

虽然Fetch API已经很优秀了,但学习XHR和Promise封装仍然很有必要:

  1. 兼容性考虑:一些老旧项目或浏览器可能需要XHR
  2. 理解底层原理:了解XHR有助于深入理解网络请求机制
  3. 特殊需求:如上传进度监控等,Fetch API支持还不够完善
  4. 面试必备:很多面试官喜欢考察对底层原理的理解

八、最佳实践建议

  1. 现代项目优先使用Fetch API:语法简洁,功能强大
  2. 需要兼容性时使用Promise封装XHR:保证代码风格一致
  3. 始终处理错误:不要忽略.catch或try/catch
  4. 合理设置超时:避免请求长时间挂起
  5. 考虑使用拦截器:统一处理请求和响应

九、总结

从前端的异步请求发展史中,我们可以看到JavaScript语言的不断进化:

  1. XHR时代:回调地狱,手动管理状态
  2. Promise封装:链式调用,代码更清晰
  3. Fetch API:原生Promise支持,语法更现代
  4. async/await:同步写法,异步效果

理解这个演进过程不仅能帮助我们写出更好的代码,还能在面试中展现出对前端技术的深刻理解。记住,技术总是在不断进步的,今天的Fetch API也许明天就会被更优秀的方案取代,但核心的异步编程思想是不变的。

希望这篇笔记能帮助大家更好地理解Ajax和Promise封装!如果有任何问题,欢迎在评论区留言讨论。

tauri项目在windows上的c盘没有权限写入文件

作者 1024小神
2025年7月3日 10:46

在使用 Tauri 开发时,如果尝试在 C:\Program Files\ 这样的受保护系统目录写入或读取文件,Windows 会阻止操作(除非以管理员权限运行)。以下是解决方案:


1. 避免写入 Program Files(推荐)

Windows 对 Program Files 有严格的权限控制,普通应用不应在此目录写入数据。改用以下目录:

  • 用户数据目录(推荐):

    <pre>
    

    use tauri::api::path::{app_data_dir, resolve}; let app_data_path = app_data_dir(&tauri::Config::default()).unwrap(); std::fs::create_dir_all(&app_data_path).unwrap(); let icon_path = app_data_path.join("app.ico");

    <ul>
    <li>路径示例:<code>C:\Users\&lt;用户名&gt;\AppData\Roaming\&lt;你的应用名&gt;\app.ico</code></li>
    </ul>
    </li>
    <li>
    <p><strong>临时目录</strong></p>
    
    <pre>
    

    let temp_dir = std::env::temp_dir(); let icon_path = temp_dir.join("app.ico");


2. 如果必须写入 Program Files(不推荐)

方法 1:以管理员权限运行应用

  • 在 tauri.conf.json 中启用管理员权限:
    <pre>
    

    { "tauri": { "windows": [{ "webviewInstallMode": { "type": "offline" }, "runAsAdmin": true // 以管理员运行 }] } } 缺点:用户每次启动都会看到 UAC 弹窗,体验差。

方法 2:安装时修改目录权限

  • 用 NSIS 或 WiX 安装包脚本,在安装时赋予 Program Files\YourApp 可写权限(仍需谨慎)。

3. 检查文件是否存在(错误处理)

在读取文件前,先检查路径是否存在:

use std::path::Path;
if !Path::new(&icon_path).exists() {
  // 提供默认图标或报错
}

4. 开发时调试路径

在 main.rs 或事件处理中打印路径,确认是否正确:

println!("当前路径: {:?}", icon_path);

总结

  • 推荐方案:改用 %APPDATA% 或用户目录存储数据(Tauri 的 app_data_dir 已封装)。
  • 临时方案:以管理员运行(不推荐长期使用)。
  • 调试技巧:检查路径是否存在,打印日志定位问题。

如果仍有问题,可以提供更多代码片段(如 tauri.conf.json 和文件操作部分),我会进一步分析!

前端真的需要懂算法吗?聊聊感受

作者 ErpanOmer
2025年7月3日 10:39

image.png 在公司干了几年,带个小团队,零零总总也面试了上百个前端候选人了。说实话,有时候面完一天,感觉人都是麻的。

最让我头疼的是什么?就是“算法题”这个环节。

我经常遇到两种候选人。一种是一听算法题,就两手一摊,表情痛苦,说“哥,我天天写业务,真没准备这个”。另一种呢,正好相反,题目一出,眼睛一亮,不出三十秒,就把LeetCode上背得滚瓜烂熟的最优解,一字不差地敲了出来,然后一脸期待地看着我。

说实话,这两种,都不是我最想看到的。

这就引出了一个很多候选人都想问,但不敢问的问题:“你们这些面试官,到底怎么想的?你们明知道我们前端平时工作中,99%的时间都用不上这些,为什么非要折磨我们?”

今天,我就想站在桌子对面,跟大伙掏心窝子地聊聊,我们问算法题,到底图个啥。


首先,我得承认一件事:我们知道你工作中不怎么写算法

对,你没看错。

我心里门儿清,我团队里的小伙伴们,每天的工作是跟产品经理“吵架”,是跟UI设计师对像素,是封装React/Vue组件,是处理浏览器兼容性,是调CSS。我招你进来,也不是为了让你用动态规划来给按钮加border-radius的。

我们不会天真地以为,前端开发就是算法竞赛。如果你能把一个复杂的业务表单组件写得清晰、可维护、可扩展,在我眼里,这远比你徒手写一个红黑树要来得有价值。

所以,请你先放轻松。我们不是在考察你是不是一个“算法大神”。


那我们到底在看什么?——思路远比答案重要

既然不是看你会不会背最优解,那我们花这宝贵的20分钟,到底在考察什么?

其实,算法题只是一个“载体”,一个“媒介”。通过这个载体,我想看到的是这几样东西:

1. 你是怎么“解读”问题的(沟通与理解能力)

一个靠谱的工程师,拿到需求不会立刻动手。他会先问问题,搞清楚所有的边界和约束。

我出一道题:“写个函数,找出数组中第二大的数。”

  • 普通候选人:埋头就开始写代码。
  • 我欣赏的候选人:会先问我,“这个数组里会有重复的数字吗?会是无序的吗?会有负数吗?如果数组长度小于2怎么办?”

你看,这就是差距。我能通过这些问题,看出你是否严谨,是否有处理边界情况的意识。这个能力,在你将来面对产品经理那些模糊的需求时,至关重要。

2. 你的“思路”是否清晰(逻辑思维)

我最喜欢看到的,不是你直接写出最优解,而是你告诉我你的思考过程。

比如,你可以说:“我首先想到的,是一个最笨的办法,先排序,然后取倒数第二个。这个时间复杂度是O(n log n)。但感觉可以优化,我再想想……也许我只需要遍历一遍,用两个变量来维护最大值和第二大值,这样时间复杂度就降到O(n)了。”

这个“先暴力,再优化”的思考过程,在我看来,比你直接默写出最优解要加分得多。因为它展示了你的逻辑推理能力优化意识

3. 你的代码“品味”(工程素养)

算法题的代码量不大,但足以管中窥豹,看出一个人的代码“品味”。

你的变量是怎么命名的?a, b, c 还是 max, secondMax, current?

你有没有处理我刚才提到的那些边界情况?

你的代码有没有基本的缩进和格式?

这些细节,都反映了你平时的编码习惯。一个连算法题都写得乱七八糟的人,我很难相信他在业务项目里能写出整洁的代码。

4. 当你卡住时,你会怎么办?(抗压与学习能力)

我有时候会故意出一些有点难度的题。我不是为了让你难堪,而是想看看你卡住的时候,会有什么反应。

是直接放弃,说“不会”?还是会尝试跟我沟通,说“我卡在xxx了,能不能给点提示?”

我非常乐意给提示。我更想招一个能和我一起“协作”解决问题的人,而不是一个遇到困难就“躺平”的人。你面对一道题的态度,很可能就是你未来面对一个技术难题的态度。


给求职者的一些真心话

所以,聊了这么多:

  • 别光背题,没用。 我只要稍微改动一下题目条件,或者问你为什么这么写,背题的同学马上就露馅了。
  • 多练习“说” 。刷题的时候,试着把你的思路说出来,录下来自己听听,或者讲给朋友听。面试时的口头表达,和自己闷头做题是两回事。
  • 重点理解“为什么” 。不要满足于“这道题这么解”,要去理解它为什么要用双指针,为什么要用哈希表。理解了思路,才能举一反三。
  • 面试时,心态放平。 没做出最优解,真没关系。把你思考的过程、你的尝试、你的权衡都清晰地表达出来,你已经赢了很多人了。

我知道,让前端去卷算法,这个“游戏规则”本身就不那么公平。我们想找的是一个会思考、会沟通、有工程素养的“解决问题的人”。

算法题,只是恰好成了当前最方便、成本最低的考察工具而已。

希望这些“面试官的牢骚”,能让你稍微不那么焦虑一点。 你们怎么看?

JavaScript 数据扁平化方法大全

作者 绅士玖
2025年7月3日 10:31

前言

数据扁平化是指将多维数组转换为一维数组的过程。由于嵌套数据结构增加了访问和操作数据的复杂度,所以·我们可以将嵌套数据变成一维的数据结构,下面就是我搜集到的一些方法,希望可以给你带来帮助!!

1. 使用 Array.prototype.flat()(推荐)

ES2019 引入的专门方法:

const nestedArr = [1, [2, [3, [4]], 5]];

// 默认只扁平化一层
const flattened1 = nestedArr.flat();
console.log(flattened1); // [1, 2, [3, [4]], 5]

// 指定深度为2
const flattened2 = nestedArr.flat(2);
console.log(flattened2); // [1, 2, 3, [4], 5]

// 完全扁平化
const fullyFlattened = nestedArr.flat(Infinity);
console.log(fullyFlattened); // [1, 2, 3, 4, 5]

解析

  • flat(depth) 方法创建一个新数组,所有子数组元素递归地连接到指定深度
  • 参数 depth 指定要提取嵌套数组的结构深度,可选的参数,默认为1
  • 使用 Infinity 可展开任意深度的嵌套数组,Infinity 是一个特殊的数值,表示无穷大

2. 使用 reduce() 和 concat() 递归

function flatten(arr) {
  // 使用 reduce 方法遍历数组元素
  return arr.reduce((acc, val) => {
    // 如果当前元素是数组,则递归调用 flatten 继续展开,并拼接到累积数组 acc
    if (Array.isArray(val)) {
      return acc.concat(flatten(val));
    } 
    // 如果当前元素不是数组,直接拼接到累积数组 acc
    else {
      return acc.concat(val);
    }
  }, []); // 初始累积值是一个空数组 []
}

// 测试用例
const nestedArr = [1, [2, [3, [4]], 5]];
console.log(flatten(nestedArr)); // 输出: [1, 2, 3, 4, 5]

解析

  1. 递归处理嵌套数组

    • 遇到子数组时,递归调用 flatten(val) 继续展开,直到所有层级都被展开为单层。
  2. reduce 方法的作用

    • 遍历数组,通过 acc(累积值)逐步拼接结果,初始值设为 [](空数组)。
  3. Array.isArray(val) 检查

    • 判断当前元素是否为数组,决定是否需要递归展开。
  4. concat 拼接结果

    • 将非数组元素或递归展开后的子数组拼接到累积数组 acc 中。

3. 使用 concat() 和扩展运算符递归

function flatten(arr) {
  // 使用扩展运算符 (...) 展开数组的第一层,并合并成一个新数组
  const flattened = [].concat(...arr);

  // 检查当前展开后的数组中是否仍然包含嵌套数组
  // 如果存在嵌套数组,则递归调用 flatten 继续展开
  // 如果所有元素都是非数组类型,则直接返回展开后的数组
  return flattened.some(item => Array.isArray(item)) 
    ? flatten(flattened) 
    : flattened;
}

// 测试用例
const nestedArr = [1, [2, [3, [4]], 5]];
console.log(flatten(nestedArr)); // 输出: [1, 2, 3, 4, 5]

解析

  1. [].concat(...arr) 展开一层数组

    • 使用扩展运算符 ... 展开 arr 的最外层,并通过 concat 合并成一个新数组。
    • 例如:[].concat(...[1, [2, [3]]]) → [1, 2, [3]](仅展开一层)。
  2. flattened.some(Array.isArray) 检查嵌套

    • 使用 Array.prototype.some() 检查当前数组是否仍然包含子数组。
    • 如果存在,则递归调用 flatten 继续展开。
  3. 递归终止条件

    • 当 flattened 不再包含任何子数组时,递归结束,返回最终结果。

4. 使用 toString() 方法(仅适用于数字数组)

const nestedArr = [1, [2, [3, [4]], 5]];
const flattened = nestedArr.toString().split(',').map(Number);
console.log(flattened); // [1, 2, 3, 4, 5]

解析

  1. toString() 的隐式转换

    • JavaScript 的 Array.prototype.toString() 会自动展开嵌套数组,并用逗号连接所有元素。
    • 例如:[1, [2, [3]]].toString() → "1,2,3"
  2. split(',') 分割字符串

    • 将字符串按逗号拆分成字符串数组,但所有元素会是字符串类型(如 "2")。
  3. map(Number) 类型转换

    • 通过 Number 构造函数将字符串元素转换为数字类型。
    • 注意:如果原数组包含非数字(如 ['a', [2]]),结果会变成 [NaN, 2]

优缺点

  • 优点:代码极其简洁,适合纯数字的嵌套数组。

  • 缺点

    • 仅适用于数字数组(其他类型会被强制转换,如 true → 1null → 0)。
    • 无法保留原数据类型(如字符串 '3' 会被转成数字 3)。

适用场景

  • 快速展开纯数字的嵌套数组,且不关心中间过程的性能损耗(toString 和 split 会有临时字符串操作)。

5. 使用 JSON.stringify() 和正则表达式

function flatten(arr) {
  // 1. 使用 JSON.stringify 将数组转换为字符串表示
  //    例如:[1, [2, [3]], 'a'] → "[1,[2,[3]],\"a\"]"
  const jsonString = JSON.stringify(arr);

  // 2. 使用正则表达式移除所有的 '[' 和 ']' 字符
  //    例如:"[1,[2,[3]],\"a\"]" → "1,2,3,\"a\""
  const withoutBrackets = jsonString.replace(/[\[\]]/g, '');

  // 3. 按逗号分割字符串,生成字符串数组
  //    例如:"1,2,3,\"a\"" → ["1", "2", "3", "\"a\""]
  const stringItems = withoutBrackets.split(',');

  // 4. 尝试将每个字符串解析回原始数据类型
  //    - 数字会变成 Number 类型(如 "1" → 1)
  //    - 字符串会保留(如 "\"a\"" → "a")
  //    - 其他 JSON 可解析类型也会被正确处理
  return stringItems.map(item => {
    try {
      // 尝试 JSON.parse 解析(处理字符串、数字等)
      return JSON.parse(item);
    } catch (e) {
      // 如果解析失败(如空字符串或非法 JSON),返回原始字符串
      return item;
    }
  });
}

// 测试用例
const nestedArr = [1, [2, [3, [4]], 5, 'a', { b: 6 }];
console.log(flatten(nestedArr)); 
// 输出: [1, 2, 3, 4, 5, "a", { b: 6 }]

解析

  1. JSON.stringify 的作用

    • 将整个数组(包括嵌套结构)转换为 JSON 字符串,保留所有数据类型信息。
  2. 正则替换 /[[]]/g

    • 移除所有方括号字符 [ 和 ],只保留逗号分隔的值。
  3. split(',') 分割字符串

    • 生成一个字符串数组,但每个元素可能仍是被 JSON 字符串化的(如 ""a"")。
  4. JSON.parse() 尝试恢复数据类型

    • 通过 JSON.parse 将字符串转换回原始类型(数字、字符串、对象等)。
    • 使用 try-catch 处理不合法的 JSON 字符串(如空字符串或格式错误的情况)。

优缺点

  • 优点

    • 支持任意数据类型(数字、字符串、对象等)。
    • 能正确处理嵌套对象(如 { b: 6 })。
  • 缺点

    • 性能较低(涉及 JSON 序列化、正则替换、解析等操作)。
    • 如果原始数组包含特殊字符串(如 "[1]" ,可能会被错误解析。

适用场景

  • 需要处理混合数据类型(非纯数字)的嵌套数组。
  • 对性能要求不高,但需要代码简洁的场景。

6. 使用堆栈的非递归实现

function flatten(arr) {
  // 创建栈并初始化(使用扩展运算符浅拷贝原数组)
  const stack = [...arr];
  const result = [];
  
  // 循环处理栈中的元素
  while (stack.length) {
    // 从栈顶取出一个元素
    const next = stack.pop();
    
    if (Array.isArray(next)) {
      // 如果是数组,展开后压回栈中(保持顺序)
      stack.push(...next);
    } else {
      // 非数组元素,添加到结果数组前端(保持原顺序)
      result.unshift(next);
    }
  }
  
  return result;
}

const nestedArr = [1, [2, [3, [4]], 5]];
console.log(flatten(nestedArr)); // [1, 2, 3, 4, 5]

解析

  1. 栈结构初始化

    • 使用扩展运算符 [...arr] 创建原数组的浅拷贝作为初始栈
    • 避免直接修改原数组
  2. 栈处理循环

    • 使用 while 循环处理栈直到为空
    • 每次从栈顶 pop() 一个元素进行处理
  3. 元素类型判断

    • 使用 Array.isArray() 检查元素是否为数组
    • 如果是数组则展开后重新压入栈
    • 非数组元素则添加到结果数组
  4. 顺序保持

    • 使用 unshift() 将元素添加到结果数组前端,当然这样比较费性能,可以改用 push() + reverse() 替代 unshift()
    • 确保最终结果的顺序与原数组一致

优缺点

  • 优点

    • 支持任意数据类型(不限于数字)
    • 可以处理深层嵌套结构(无递归深度限制)
    • 相比递归实现,不易导致栈溢出
  • 缺点

    • 使用 unshift() 导致时间复杂度较高(O(n²))
    • 需要额外空间存储栈结构
    • 相比原生 flat() 方法性能稍差
    • 无法控制扁平化深度(总是完全扁平化)

适用场景

  • 需要处理混合数据类型的深层嵌套数组
  • 需要避免递归导致的栈溢出风险

7. 使用 Array.prototype.some() 和扩展运算符

function flatten(arr) {
  // 循环检测数组中是否还包含数组元素
  while (arr.some(item => Array.isArray(item))) {
    // 使用扩展运算符展开当前层级的所有数组
    // 并通过concat合并为一层
    arr = [].concat(...arr);
  }
  return arr;
}

const nestedArr = [1, [2, [3, [4]], 5]];
console.log(flatten(nestedArr)); // [1, 2, 3, 4, 5]

解析

  1. 循环条件检测

    • 使用 arr.some() 方法检测数组中是否还存在数组元素
    • Array.isArray(item) 判断每个元素是否为数组
  2. 层级展开

    • 使用扩展运算符 ...arr 展开当前层级的数组
    • 通过 [].concat() 将展开的元素合并为新数组
  3. 迭代处理

    • 每次循环处理一层嵌套
    • 重复直到没有数组元素存在

性能比较

对于大多数现代应用:

  1. 优先使用 flat(Infinity)(最简洁且性能良好)
  2. 对于深度嵌套的大数组,考虑非递归的堆栈实现
  3. 递归方法在小数据集上表现良好且代码简洁
  4. 避免 toString() 方法除非确定只有数字数据

总结

JavaScript 提供了多种扁平化数组的方法,从简单的内置 flat() 方法到各种手动实现的递归、迭代方案。选择哪种方法取决于:

  • 运行环境是否支持 ES2019+
  • 数据结构的复杂程度
  • 对性能的要求
  • 代码可读性需求

在大多数现代应用中,flat(Infinity) 是最佳选择,因为它简洁、高效且语义明确。

如何丝滑使用JavaScript的装饰器?

作者 best666
2025年7月3日 10:21

在 JavaScript 里,装饰器(Decorators)是一种能对类、方法、属性的行为进行扩展或者修改的语法。它的核心原理是借助元编程,在不改变原有代码结构的前提下,为目标添加新功能。

基本概念

直接show code,现有如下代码,用来记录log日志:

function log(target, name, descriptor) {
  const original = descriptor.value;
  descriptor.value = function(...args) {
    console.log(`调用 ${name} 方法,参数:${JSON.stringify(args)}`);
    const result = original.apply(this, args);
    console.log(`方法 ${name} 返回:${result}`);
    return result;
  };
  return descriptor;
}

class Calculator {
  @log
  add(a, b) {
    return a + b;
  }
}

// 使用示例
const calc = new Calculator();
calc.add(3, 4); // 控制台会输出调用信息和返回结果

装饰器函数参数解析

在 JavaScript 装饰器中,log 函数的三个参数分别代表:

  1. target:被装饰的类或原型对象。

    • 若装饰的是类方法,target 就是类的原型(prototype)。
    • 若装饰的是类,target 就是类本身。
  2. name:被装饰的方法或属性的名称(字符串类型)。

  3. descriptor:属性描述符对象(与 Object.defineProperty 中的描述符相同),包含以下属性:

    • value:被装饰的方法或属性的值(即原始函数)。
    • writable:是否可修改(布尔值)。
    • enumerable:是否可枚举(布尔值)。
    • configurable:是否可配置(布尔值)。

函数实现原理详解

log 装饰器的核心逻辑是替换原始方法,在执行前后添加日志:


    function log(target, name, descriptor) {
      // 1. 保存原始方法的引用
      const original = descriptor.value;

      // 2. 修改 descriptor.value 为新函数
      descriptor.value = function(...args) {
        // 3. 执行前置逻辑(打印入参)
        console.log(`调用 ${name} 方法,参数:${JSON.stringify(args)}`);
        
        // 4. 执行原始方法并保存结果
        const result = original.apply(this, args);
        
        // 5. 执行后置逻辑(打印返回值)
        console.log(`方法 ${name} 返回:${result}`);
        
        // 6. 返回原始结果
        return result;
      };

      // 7. 返回修改后的描述符
      return descriptor;
    }

为什么要这样实现?

这种写法的关键点在于:

  1. 不改变原始方法的核心逻辑:通过包装原始方法,在不修改其代码的前提下添加新功能。

  2. 保留上下文(this

    • 使用 original.apply(this, args) 确保原始方法在调用时的 this 指向不变。
    • 若直接调用 original(args),可能导致 this 指向全局对象(非严格模式)或 undefined(严格模式)。
  3. 支持任意参数

    • 使用剩余参数 ...args 收集所有传入参数。
    • 使用 JSON.stringify(args) 将参数序列化为字符串(需注意无法处理函数或 undefined 类型的参数)。
  4. 遵循装饰器规范

    • 装饰器必须返回一个描述符对象(或新类)。
    • 通过修改 descriptor.value 替换原始方法。

应用示例

使用该装饰器的类方法会自动添加日志功能:


    class Calculator {
      @log
      add(a, b) {
        return a + b;
      }
    }

    const calc = new Calculator();
    calc.add(3, 4);

    // 输出:
    // 调用 add 方法,参数:[3,4]
    // 方法 add 返回:7

注意事项

  1. 参数序列化限制

    • JSON.stringify 无法处理函数或 undefined 参数,可能导致日志不完整。
    • 改进方案:使用 args.map(arg => String(arg)).join(', ') 或自定义序列化函数。
  2. 异步方法处理

    • 若原始方法返回 Promise,需使用 await 等待结果:

      
          descriptor.value = async function(...args) {
            // ...
            const result = await original.apply(this, args);
            // ...
          };
      
  3. 兼容性

    • 装饰器语法需 Babel 或 TypeScript 支持。

    • 确保项目配置中启用了装饰器(如 @babel/plugin-proposal-decorators)。

通过这种方式,装饰器实现了 ** 横切关注点(Cross-cutting Concerns)** 的分离,让日志、权限等功能与核心业务逻辑解耦。

下面介绍装饰器常见的应用场景:

1. 日志记录

装饰器能够在方法执行的前后添加日志,这样可以对函数的调用情况进行监控。

function log(target, name, descriptor) {
  const original = descriptor.value;
  descriptor.value = function(...args) {
    console.log(`调用 ${name} 方法,参数:${JSON.stringify(args)}`);
    const result = original.apply(this, args);
    console.log(`方法 ${name} 返回:${result}`);
    return result;
  };
  return descriptor;
}

class Calculator {
  @log
  add(a, b) {
    return a + b;
  }
}

// 使用示例
const calc = new Calculator();
calc.add(3, 4); // 控制台会输出调用信息和返回结果

2. 权限验证

可以在执行方法前对用户权限进行检查,防止未授权的访问。


    function auth(requiredRole) {
      return function(target, name, descriptor) {
        const original = descriptor.value;
        descriptor.value = function(...args) {
          if (this.userRole !== requiredRole) {
            throw new Error("权限不足");
          }
          return original.apply(this, args);
        };
        return descriptor;
      };
    }

    class AdminPanel {
      userRole = "admin";

      @auth("admin")
      deleteUser() {
        return "用户已删除";
      }
    }

3. 性能分析

装饰器能够对函数的执行时间进行测量,有助于性能优化。


    function benchmark(target, name, descriptor) {
      const original = descriptor.value;
      descriptor.value = async function(...args) {
        const start = performance.now();
        const result = await original.apply(this, args);
        const end = performance.now();
        console.log(`${name} 方法执行耗时:${end - start}ms`);
        return result;
      };
      return descriptor;
    }

    class DataService {
      @benchmark
      async fetchData() {
        await new Promise(resolve => setTimeout(resolve, 1000));
        return { data: "大量数据" };
      }
    }

4. 自动绑定

在 React 等框架中,装饰器可以解决方法上下文丢失的问题。


    function autobind(target, name, descriptor) {
      const original = descriptor.value;
      return {
        configurable: true,
        get() {
          const bound = original.bind(this);
          Object.defineProperty(this, name, {
            value: bound,
            configurable: true,
            writable: true
          });
          return bound;
        }
      };
    }

    class Component {
      constructor() {
        this.state = { count: 0 };
      }

      @autobind
      increment() {
        this.state.count++;
      }
    }

5. 单例模式实现

装饰器可以确保一个类仅有一个实例。


    function singleton(constructor) {
      let instance;
      return function(...args) {
        if (!instance) {
          instance = new constructor(...args);
        }
        return instance;
      };
    }

    @singleton
    class AppState {
      constructor() {
        this.data = {};
      }
    }

    const state1 = new AppState();
    const state2 = new AppState();
    console.log(state1 === state2); // 输出 true

6. 类型检查

在运行时对函数参数的类型进行验证。


    function validateTypes(target, name, descriptor) {
      const original = descriptor.value;
      descriptor.value = function(...args) {
        const paramTypes = Reflect.getMetadata("design:paramtypes", target, name);
        args.forEach((arg, i) => {
          if (arg && paramTypes[i] && !(arg instanceof paramTypes[i])) {
            throw new TypeError(`参数 ${i} 类型错误,期望 ${paramTypes[i].name}`);
          }
        });
        return original.apply(this, args);
      };
      return descriptor;
    }

    class MathUtils {
      @validateTypes
      add(a: number, b: number) {
        return a + b;
      }
    }

7. 缓存机制

对函数的计算结果进行缓存,避免重复计算。


    function memoize(target, name, descriptor) {
      const original = descriptor.value;
      const cache = new Map();
      descriptor.value = function(...args) {
        const key = args.toString();
        if (cache.has(key)) {
          return cache.get(key);
        }
        const result = original.apply(this, args);
        cache.set(key, result);
        return result;
      };
      return descriptor;
    }

    class Fibonacci {
      @memoize
      calculate(n) {
        return n <= 1 ? n : this.calculate(n - 1) + this.calculate(n - 2);
      }
    }

装饰器使用注意要点

  • 要启用装饰器语法,需要在 Babel 或者 TypeScript 中进行配置。

  • 装饰器的执行顺序是从下往上,例如:

    
        @a
        @b
        method() {} // 先执行 b,再执行 a
    
    
  • 装饰器可以返回一个新的类或者修改原有的描述符(descriptor)。

装饰器的主要价值在于它遵循了开放 - 封闭原则,即对扩展开放,对修改封闭。它能让代码变得更加简洁,同时增强代码的可复用性。

贝塞尔曲线:让计算机画出丝滑曲线的魔法

作者 LeonGao
2025年7月3日 10:12

想象一下,如果你让计算机画一条曲线,它可能会像个刚学画画的孩子,画出的线条要么僵硬得像铁丝,要么歪歪扭扭如同毛毛虫。但有了贝塞尔曲线,计算机突然就像掌握了绘画技巧的艺术家,能画出从字体轮廓到动画路径的各种丝滑线条。今天我们就来揭开这个让计算机变身为 "曲线大师" 的秘密。

从点到线:贝塞尔曲线的底层逻辑

贝塞尔曲线的核心原理其实很简单:用几个控制点 "拉扯" 出一条平滑曲线。就像你用手指捏住绳子的几个点,轻轻一拉就能得到自然的弧线。这背后藏着一种叫 "插值" 的数学思想 —— 通过已知的点,算出中间该有的样子。

最基础的是一次贝塞尔曲线,说穿了就是直线。取两个点,比如 (0,0) 和 (100,100),连接它们的线段就是一次贝塞尔曲线。这时候你可能会说:"这有什么了不起?" 别急,精彩的在后面。

当我们增加到三个点时,就得到了二次贝塞尔曲线。想象中间那个点是个 "磁铁",它会把直线段往自己这边吸,形成一条优美的抛物线。三个点分工明确:起点和终点固定曲线的两端,中间的控制点则决定了曲线的弯曲程度 —— 离直线越远,曲线弯得越厉害,就像有人在中间用力拽了一把。

让曲线更灵活:高阶贝塞尔曲线

三次贝塞尔曲线是应用最广泛的,它有四个控制点:起点、终点和两个中间控制点。这两个中间控制点就像两个方向舵,能让曲线做出更复杂的转弯。你可以把它想象成一条被两个人从不同方向拉扯的绳子,最终形成的形状取决于两人用力的方向和大小。

更高阶的贝塞尔曲线原理类似,只是增加了更多控制点。但有趣的是,在实际应用中,我们很少用到五阶以上的曲线。这就像做菜,加太多调料反而会破坏原本的味道,三个到四个控制点已经能满足绝大多数设计需求了。

数学背后的小秘密

贝塞尔曲线的数学表达其实是一系列多项式的组合,但我们可以用更形象的方式理解:曲线上每个点的位置,都是由所有控制点按一定比例 "混合" 而成的

以三次贝塞尔曲线为例,想象有一辆小车从起点开往终点,行驶过程中会受到两个中间控制点的 "引力" 影响。刚出发时,起点的引力最大,小车几乎直线冲向第一个控制点;随着前进,第一个控制点的引力逐渐减弱,第二个控制点的引力逐渐增强;快到终点时,终点的引力变成主导,小车会从第二个控制点的方向平滑地驶入终点。整个过程就像一场精心编排的舞蹈,每个控制点都在特定时刻发挥着恰到好处的作用。

这种 "混合" 比例遵循着类似二项式展开的规律,每个控制点的影响力随曲线位置呈现平滑的增减变化,这正是曲线能保持连续光滑的关键。

用代码画出贝塞尔曲线

让我们用 JavaScript 来实践一下,通过 Canvas 绘制一条三次贝塞尔曲线:

// 获取画布元素
const canvas = document.getElementById('bezierCanvas');
const ctx = canvas.getContext('2d');
// 设置画布尺寸
canvas.width = 600;
canvas.height = 400;
// 定义四个控制点
const startPoint = { x: 50, y: 200 };         // 起点
const controlPoint1 = { x: 200, y: 50 };      // 第一个控制点
const controlPoint2 = { x: 400, y: 350 };     // 第二个控制点
const endPoint = { x: 550, y: 200 };          // 终点
// 绘制辅助线和控制点(帮助理解)
ctx.strokeStyle = '#cccccc';
ctx.beginPath();
ctx.moveTo(startPoint.x, startPoint.y);
ctx.lineTo(controlPoint1.x, controlPoint1.y);
ctx.lineTo(controlPoint2.x, controlPoint2.y);
ctx.lineTo(endPoint.x, endPoint.y);
ctx.stroke();
// 绘制控制点标记
[startPoint, controlPoint1, controlPoint2, endPoint].forEach((point, index) => {
    ctx.fillStyle = index === 0 || index === 3 ? 'green' : 'red';
    ctx.beginPath();
    ctx.arc(point.x, point.y, 6, 0, Math.PI * 2);
    ctx.fill();
});
// 绘制贝塞尔曲线(这才是主角!)
ctx.strokeStyle = '#3366ff';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(startPoint.x, startPoint.y);
// 核心API:绘制三次贝塞尔曲线
ctx.bezierCurveTo(
    controlPoint1.x, controlPoint1.y,
    controlPoint2.x, controlPoint2.y,
    endPoint.x, endPoint.y
);
ctx.stroke();

运行这段代码,你会看到一条蓝色的平滑曲线,旁边还有灰色的辅助线连接着四个控制点。绿色的是起点和终点,红色的是中间控制点。试着修改控制点的坐标值,你会发现曲线的形状会随之发生奇妙的变化 —— 这就是贝塞尔曲线的魅力所在。

动画中的贝塞尔魔法

在动画领域,贝塞尔曲线更是不可或缺的工具。当你看到一个物体先加速后减速的自然运动,或者一个元素平滑地转弯绕行时,很可能就是贝塞尔曲线在背后默默工作。

比如下面这个简单的动画示例,让一个小球沿着贝塞尔曲线运动:

const ball = document.getElementById('ball');
let time = 0;
function updateBallPosition() {
    // 计算当前时间在动画中的比例(0到1之间)
    time += 0.01;
    if (time > 1) time = 0;
    
    const t = time;
    // 三次贝塞尔曲线的位置计算公式(简化版)
    const cx = 3 * (1 - t) * (1 - t) * t * controlPoint1.x 
             + 3 * (1 - t) * t * t * controlPoint2.x 
             + t * t * t * endPoint.x 
             + (1 - t) * (1 - t) * (1 - t) * startPoint.x;
             
    const cy = 3 * (1 - t) * (1 - t) * t * controlPoint1.y 
             + 3 * (1 - t) * t * t * controlPoint2.y 
             + t * t * t * endPoint.y 
             + (1 - t) * (1 - t) * (1 - t) * startPoint.y;
             
    // 更新小球位置
    ball.style.left = `${cx}px`;
    ball.style.top = `${cy}px`;
    
    requestAnimationFrame(updateBallPosition);
}
// 开始动画
updateBallPosition();

这段代码通过不断计算小球在贝塞尔曲线上的位置,让它看起来像是沿着一条平滑的路径运动。你可以调整控制点的位置,让小球做出各种有趣的轨迹 —— 直线、弧线、S 形曲线,甚至是看似不可能的急转弯。

无处不在的贝塞尔曲线

贝塞尔曲线的应用远不止于此:从你手机上的图标设计到汽车的流线型车身,从字体的优美轮廓到地图上的路线规划,都能看到它的身影。每当你在屏幕上画出一条平滑的线条,或者看到一个自然流畅的动画时,不妨想一想:这背后是不是有贝塞尔曲线在施展魔法?

下次当你再看到那些令人赞叹的数字设计时,或许会对它们多一份理解和欣赏 —— 因为你知道,那些看似复杂的曲线背后,其实是几个控制点和一段精妙的数学逻辑共同谱写的优雅篇章。

CommonJS 与 ESM

作者 G等你下课
2025年7月3日 09:47

CommonJS 与 ESM

bible-2778631_1280.jpg

Node.js 自诞生以来发展出了两套模块系统:CommonJS (CJS) 和 ECMAScript Modules (ESM)。这两套系统在设计理念、语法和使用方式上都有显著差异,理解它们的特性和适用场景对现代 Node.js 开发至关重要。

一、CommonJS:Node.js 的传统模块系统

  1. 基本特性

    CommonJS是2009年随 Node.js 一起出现,其设计目的是问了满足服务端JavaScript的模块化需求

  2. 语法示例

    使用require() 导入模块,使用 module.exports 或 exports 导出

    // 导入模块
    const fs = require('fs');
    const { funcA } = require('./moduleA');
    
    // 导出模块
    module.exports = {
      foo: 'bar',
      doSomething: function() {}
    };
    
    // 或者
    exports.doSomething = function() {};
    
  3. 特性

    • 同步加载:模块在require()调用时立即加载并执行
    • 动态依赖:可根据条件动态引入不同模块
    • 缓存机制:模块首次加载后会被缓存,后续require()调用返回缓存实例,可以通过require.cache可访问缓存

二、ECMAScript Modules (ESM):JavaScript 标准模块系统

  1. 基本特性

    ECMAScript Modules (ESM),ES6 (2015) 语言标准的一部分,其目的是为了统一浏览器和服务端的模块系统

  2. 语法示例

    使用 import / export 语法 导入导出

    // 导入模块
    import fs from 'fs';
    import { funcA } from './moduleA.js';
    import * as utils from './utils.js';
    
    // 导出模块
    export const foo = 'bar';
    export function doSomething() {}
    
    // 默认导出
    export default function() {}
    
  3. 特性

    • 静态结构:导入导出必须在顶层,不能动态导入(除 import()
    • 异步加载:模块加载过程异步
    • 严格模式:默认启用严格模式
    • 文件扩展名:必须明确指定(如 .js.mjs

初识XPath——了解XML与HTML文档中的导航神器

作者 烛阴
2025年7月3日 09:08

引言

在Web开发和自动化测试中,常常需要定位和操作页面中的元素。传统上,我们用CSS选择器,但在某些复杂场景下,XPath是一种更强大、更灵活的工具。本文将带你由浅入深,了解XPath的基本概念和用法。


什么是XPath?

XPath(XML Path Language)是一种用于在XML文档中查找信息的语言。由于HTML是HTML5的标准变体,可视为XML的一种,只要遵守标准,同样适用XPath。

XPath的用途

  • 选择特定元素或一组元素
  • 计算元素的路径
  • 提取元素的内容或属性
  • 在自动化测试框架(如Selenium)中定位元素

XPath的基本结构

XPath表达式类似路径,用于从文档的根节点开始,逐层筛选目标。

例子

<html>
  <body>
    <div id="main">
      <h1>标题</h1>
      <p class="text">这是一段文字。</p>
    </div>
  </body>
</html>

对应的XPath:

  • 选择<h1>//h1
  • 选择<p>//p[@class='text']
  • 选择<div id="main">//div[@id='main']

常用的XPath表达式

表达式 描述 示例
/ 从根节点开始,绝对路径 /html/body/div
// 在文档中查找匹配的元素,不考虑层级 //p
. 当前节点 ./span
.. 父节点 ../div
@属性名 指定属性 //a[@href='https://']
* 任意元素 //*/a

结合条件过滤

  • [条件]:筛选出满足条件的元素
  • 例://div[@class='main']:选择class为main的div
  • 叠加过滤://ul/li[1]:第一个li元素

实战演练:用XPath定位元素

如果你安装了谷歌浏览器,可以安装Xpath测试器进行实战演练

screenshot_2025-07-02_19-02-00.png


小结

XPath是网页元素定位的重要工具,掌握其基础语法可以帮助你更高效地进行网页自动化、数据抓取与测试验证。


如果你喜欢本教程,记得点赞+收藏!关注我获取更多JavaScript开发干货。

跨域问题解决方案:开发代理

2025年7月3日 08:39

由于浏览器的同源策略,当开发环境中的前端应用试图与后端服务进行通信时,经常会遇到跨域问题,开发代理为我们提供了一种简单而有效的解决方案。

一、开发代理的概念与适用场景

(一)开发代理

开发代理是一种在开发环境中使用的代理机制,它允许前端应用在开发阶段绕过浏览器的同源策略。开发代理的核心思想是,将前端应用的请求转发到后端服务,从而解决跨域问题。

(二)适用场景

开发代理适用于以下场景:

  • 生产环境不发生跨域,但开发环境发生跨域:在生产环境中,前端应用和后端服务通常部署在同一域名下,不会发生跨域问题。但在开发环境中,前端应用和后端服务通常运行在不同的端口或域名下,会发生跨域问题。
  • 开发阶段的快速迭代:开发代理可以帮助开发者在开发阶段快速迭代,无需担心跨域问题。

二、如何配置开发代理

(一)Vue.js开发服务器代理配置

在Vue.js项目中,可以通过vue.config.js文件配置开发服务器的代理。以下是一个示例配置:

// vue.config.js
module.exports = {
  devServer: { // 配置开发服务器
    proxy: { // 配置代理
      "/api": { // 若请求路径以 /api 开头
        target: "http://dev.taobao.com", // 将其转发到 http://dev.taobao.com
        changeOrigin: true, // 允许跨域
      },
    },
  },
};

(二)配置说明

  • /api:这是代理的路径前缀。当请求路径以/api开头时,开发服务器会将请求转发到指定的target地址。
  • target:这是后端服务的地址。开发服务器会将请求转发到这个地址。
  • changeOrigin:这是一个可选配置项,当设置为true时,允许跨域。

(三)其他框架的代理配置

其他前端框架(如React.js、Angular.js)也提供了类似的代理配置功能。例如,在React.js项目中,可以通过package.json文件配置代理:

{
  "proxy": "http://dev.taobao.com"
}

三、开发代理的优势

(一)简单易用

开发代理的配置非常简单,只需在开发服务器的配置文件中添加几行代码即可。

(二)无需修改后端代码

开发代理在前端应用和后端服务之间起到了中间人的作用,无需修改后端代码即可解决跨域问题。

(三)适用于开发环境

开发代理主要适用于开发环境,不会影响生产环境的部署。

四、总结

开发代理是一种简单而有效的解决前端开发中跨域问题的方法。通过在开发服务器中配置代理,可以轻松解决开发环境中的跨域问题,无需修改后端代码。

Vue路由模式大揭秘:选对模式,页面跳转不再"迷路"!

2025年7月3日 08:26

大家好,我是小杨,一个干了6年前端的老兵。今天咱们聊聊Vue路由的模式问题,很多新手在配置路由时,往往对hashhistory模式傻傻分不清楚,结果部署上线后各种404、页面刷新白屏,甚至SEO不友好。今天我就带大家彻底搞懂Vue路由的几种模式,让你在项目里游刃有余!


一、Vue路由的两种核心模式

Vue Router默认支持两种路由模式:

  1. Hash模式mode: 'hash'
  2. History模式mode: 'history'

此外,还有Memory模式(主要用于非浏览器环境,比如SSR或移动端),但今天我们主要讨论前两种。


二、Hash模式:带#号的URL

1. 特点

  • URL里带#,比如 http://example.com/#/home
  • 不依赖服务器配置,刷新不会404
  • 兼容性好,IE9+都能跑

2. 原理

Hash模式利用的是浏览器的锚点(hash) 特性,#后面的变化不会触发页面刷新,但会触发hashchange事件,Vue Router监听这个事件来实现路由切换。

3. 适用场景

  • 静态网站托管(GitHub Pages、Netlify等)
  • 不想折腾服务器配置的情况
  • 需要兼容老浏览器的项目

4. 代码示例

const router = new VueRouter({
  mode: 'hash', // 默认就是hash,可以不写
  routes: [...]
})

三、History模式:优雅的URL

1. 特点

  • URL干净,比如 http://example.com/home
  • 依赖服务器配置,否则刷新会404
  • 需要后端支持(Nginx/Apache/Node.js等)

2. 原理

History模式利用HTML5的history.pushStatehistory.replaceStateAPI,让URL变化但不刷新页面,同时能记录浏览历史。

3. 适用场景

  • 需要SEO友好的项目
  • 企业级应用,追求专业URL风格
  • 能控制服务器配置的情况

4. 代码示例

const router = new VueRouter({
  mode: 'history', // 使用history模式
  routes: [...]
})

5. 服务器配置(避免刷新404)

Nginx配置

location / {
  try_files $uri $uri/ /index.html; # 所有路径都回退到index.html
}

Node.js(Express)

app.get('*', (req, res) => {
  res.sendFile(path.resolve(__dirname, 'dist', 'index.html'))
})

四、Memory模式:无URL变化的路由

1. 特点

  • URL不会变,完全由前端JS控制
  • 适用于非浏览器环境(如React Native、Electron、SSR)
  • 不会影响SEO,因为压根没有URL变化

2. 代码示例

const router = new VueRouter({
  mode: 'abstract', // Vue 2叫abstract,Vue 3叫memory
  routes: [...]
})

五、如何选择路由模式?

模式 适用场景 是否需要服务器配置 SEO友好 兼容性
Hash 静态托管、兼容老浏览器 ❌ 不需要 ❌ 不友好 IE9+
History 企业级应用、SEO优化 ✅ 需要 ✅ 友好 IE10+
Memory 非浏览器环境(SSR、Electron) ❌ 不需要 ❌ 不适用 所有环境

我的经验总结

  1. 个人博客/静态网站 → Hash模式(省事)
  2. 企业后台/电商网站 → History模式(专业)
  3. React Native/Electron → Memory模式(无URL需求)

六、常见坑点 & 解决方案

1. History模式刷新404?

  • 原因:服务器没正确配置回退到index.html
  • 解决:参考上面的Nginx/Node.js配置

2. Hash模式SEO差?

  • 解决:用服务端渲染(SSR)或预渲染(Prerender)

3. 微信内置浏览器兼容性问题?

  • 解决:强制使用Hash模式,避免微信的奇葩history兼容问题

七、总结

  • Hash模式:简单省事,适合静态网站
  • History模式:专业优雅,但需要服务器支持
  • Memory模式:非浏览器环境专属

选对模式,能让你的项目少踩很多坑!如果你在项目里遇到过路由的奇葩问题,欢迎在评论区分享,我帮你分析~

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

路由守卫通关秘籍:这些钩子函数让你的页面跳转稳如老狗!

2025年7月3日 08:21

大家好,我是小杨,一个做了6年前端的老司机。今天我们来聊聊路由守卫这个在前端开发中特别实用的功能,它能帮我们控制页面的跳转流程,就像给网站请了个尽职的保安。

一、什么是路由守卫?

简单说,路由守卫就是页面跳转时的"安检门"。比如:

  • 用户没登录想进会员中心?拦住!
  • 页面数据没保存就想离开?弹窗提醒!
  • 普通员工想访问管理员页面?门都没有!

二、Vue路由的三大守卫钩子

1. 全局守卫 - 整个网站的保安队长

// 全局前置守卫(每次跳转前都会触发)
router.beforeEach((to, from, next) => {
  if (to.meta.requiresAuth && !我.store.state.isLogin) {
    next('/login') // 去登录页
  } else {
    next() // 放行
  }
})

// 全局解析守卫(适合做权限校验)
router.beforeResolve(async to => {
  if (to.meta.requiresAdmin) {
    await 我.checkAdminRole() // 异步检查权限
  }
})

// 全局后置钩子(跳转完成后触发)
router.afterEach((to, from) => {
  sendToAnalytics(to.fullPath) // 可以在这里做页面统计
})

2. 路由独享守卫 - 特定页面的专属安检

const routes = [
  {
    path: '/dashboard',
    component: Dashboard,
    beforeEnter: (to, from, next) => {
      if (!我.store.state.userInfo.vip) {
        next('/upgrade') // 非VIP跳转到升级页
      } else {
        next()
      }
    }
  }
]

3. 组件内守卫 - 组件自己的小门卫

export default {
  beforeRouteEnter(to, from, next) {
    // 注意!这里还不能用this
    next(vm => {
      vm.initData() // 通过vm访问组件实例
    })
  },
  
  beforeRouteUpdate(to, from, next) {
    // 当前路由改变但组件被复用时触发
    this.fetchData(to.params.id)
    next()
  },
  
  beforeRouteLeave(to, from, next) {
    if (this.hasUnsavedChanges) {
      if (confirm('有未保存的修改,确定离开吗?')) {
        next()
      } else {
        next(false) // 取消导航
      }
    } else {
      next()
    }
  }
}

三、React路由守卫实现方案

React Router没有内置守卫,但我们可以自己实现:

1. 高阶组件方式

function PrivateRoute({ component: Component, ...rest }) {
  return (
    <Route
      {...rest}
      render={props =>
        我.isAuthenticated ? (
          <Component {...props} />
        ) : (
          <Redirect to="/login" />
        )
      }
    />
  )
}

2. 自定义Hook方式

function useAuthGuard() {
  const history = useHistory()
  
  useEffect(() => {
    if (!我.store.getState().auth.isLogin) {
      history.replace('/login')
    }
  }, [history])
}

// 在需要守卫的组件中使用
function AdminPage() {
  useAuthGuard()
  return <div>管理员页面</div>
}

四、实战中的骚操作

  1. 动态路由加载:在beforeEach中按需加载路由
router.beforeEach(async (to) => {
  if (to.meta.requiresAdmin && !我.hasAdminRoute) {
    await 我.loadAdminRoutes() // 动态添加路由
    return to.fullPath // 重定向到目标页
  }
})
  1. 页面离开确认
beforeRouteLeave(to, from, next) {
  window.onbeforeunload = () => "数据可能丢失!" // 浏览器原生提示
  // ...其他逻辑
}
  1. 滚动行为控制
router.afterEach((to) => {
  if (to.meta.scrollToTop) {
    window.scrollTo(0, 0)
  }
})

五、常见坑点指南

  1. 死循环陷阱
// 错误示范!会导致无限循环
router.beforeEach((to, from, next) => {
  if (!isLogin) next('/login')
})

// 正确做法
router.beforeEach((to, from, next) => {
  if (!isLogin && to.path !== '/login') next('/login')
  else next()
})
  1. 异步操作处理
// 记得要调用next!
beforeRouteEnter(to, from, next) {
  fetchData().then(() => next()) // 别忘了next
}
  1. meta字段妙用
{
  path: '/admin',
  meta: {
    requiresAuth: true,
    requiredRole: 'admin'
  }
}

六、总结

路由守卫用得好,能帮我们实现:

  • ✅ 登录状态验证
  • ✅ 权限精细控制
  • ✅ 数据变更提示
  • ✅ 页面访问统计
  • ✅ 动态路由加载

记住守卫钩子的执行顺序:全局beforeEach → 路由beforeEnter → 组件beforeRouteEnter → 全局beforeResolve → 全局afterEach

希望这篇能帮到大家!如果有问题欢迎在评论区交流,我会把6年踩过的坑都分享出来~

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

JavaScript 闭包作用域、原型链继承面试题解析教程

作者 天涯学馆
2025年7月2日 21:42

说到 JavaScript 面试,闭包、作用域、原型链、继承这些关键词几乎是“必考题”。很多同学一刷题就头大,明明看过好几遍原理,结果一到面试官面前,还是词不达意、思路混乱。这是为什么?

其实不是你不懂,而是没能用“讲故事”的方式把它讲清楚。面试不只是考概念,更是在考你能不能把复杂问题讲“简单”。比如,闭包到底是“函数里面套函数”这么简单吗?作用域链和执行上下文到底谁先谁后?原型链继承又是怎么一层一层传下去的?

在这篇文章里,我会带你一口气理清这些高频知识点,不讲花哨术语,只用最通俗的例子和最常见的面试题,帮你把零散的知识点串成“系统的知识树”。看完这篇,下一次再遇到相关题目,不仅能答对,还能讲得漂亮!

闭包与作用域

闭包的定义与原理

闭包(Closure)是 JavaScript 的核心特性,指一个函数能够“记住”并访问其定义时所在的作用域,即使该函数在其他作用域中执行。闭包由两部分组成:

  • 函数本身:定义的函数体。
  • 词法环境(Lexical Environment):函数定义时绑定的变量环境。

理论背景

  • JavaScript 使用词法作用域(Lexical Scoping),变量的作用域在代码编写时静态确定。
  • 每个函数创建时,会绑定其定义时的作用域链(Scope Chain),包含外部变量引用。
  • 闭包通过维持对外部变量的引用,延长变量的生命周期。

简单示例

function outer() {
    let count = 0;
    function inner() {
        count++;
        console.log(count);
    }
    return inner;
}

const counter = outer();
counter(); // 输出: 1
counter(); // 输出: 2

逐步分析

  1. outer 定义了变量 count 和函数 inner
  2. inner 引用了外部的 count,形成闭包。
  3. outer 返回 innercountinner 捕获,保存在内存中。
  4. 每次调用 counter()inner 更新并访问 count,实现计数器功能。

闭包的内存机制

  • count 存储在 inner 的词法环境中,不会因 outer 执行结束而销毁。
  • 垃圾回收器(GC)无法回收闭包引用的变量,可能导致内存泄漏,需谨慎管理。

作用域与作用域链

作用域(Scope)定义了变量的可见性和生命周期。JavaScript 有以下作用域类型:

  • 全局作用域:全局变量,生命周期贯穿整个程序。
  • 函数作用域:函数内定义的变量,仅在函数内可见。
  • 块级作用域:使用 letconst{} 内定义的变量(ES6 引入)。

作用域链

  • 当访问变量时,JavaScript 引擎从当前作用域开始,沿作用域链向上查找,直到全局作用域。
  • 作用域链由函数定义时的词法环境决定。

示例

let globalVar = "global";
function outer() {
    let outerVar = "outer";
    function inner() {
        let innerVar = "inner";
        console.log(innerVar, outerVar, globalVar);
    }
    inner();
}
outer();

输出

inner outer global

逐步分析

  1. inner 访问 innerVar(本地),outerVar(外层函数),globalVar(全局)。
  2. 作用域链:inner -> outer -> global
  3. 查找顺序:先本地作用域,再逐级向上。

闭包的常见面试题

面试题 1:闭包计数器

问题:修改以下代码,使每次调用返回不同的计数器实例。

function createCounter() {
    let count = 0;
    return function() {
        return count++;
    };
}
const counter = createCounter();
console.log(counter()); // 0
console.log(counter()); // 1

答案

function createCounter() {
    let count = 0;
    return function() {
        return count++;
    };
}

const counter1 = createCounter();
const counter2 = createCounter();
console.log(counter1()); // 0
console.log(counter1()); // 1
console.log(counter2()); // 0
console.log(counter2()); // 1

分析

  • 每次调用 createCounter 创建新的闭包,count 是独立的。
  • counter1counter2 引用不同的词法环境。

面试题 2:循环中的闭包

问题:以下代码输出什么?如何修复?

for (var i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 1000);
}

输出

3
3
3

原因

  • var 具有函数作用域,i 是全局变量,setTimeout 回调执行时,i 已变为 3。
  • 闭包捕获的是变量引用,而非值。

修复方法 1:使用 let(块级作用域):

for (let i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 1000);
}

输出

0
1
2

修复方法 2:使用 IIFE(立即执行函数表达式):

for (var i = 0; i < 3; i++) {
    (function(j) {
        setTimeout(() => console.log(j), 1000);
    })(i);
}

分析

  • let 为每次循环创建新的绑定。
  • IIFE 每次循环创建新的作用域,捕获当前 i 的值。

面试题 3:私有变量

问题:实现一个带有私有变量的模块。

function createPerson(name) {
    let _age = 0; // 私有变量
    return {
        getName: () => name,
        getAge: () => _age,
        setAge: (age) => { _age = age; }
    };
}

const person = createPerson("Alice");
console.log(person.getName()); // Alice
console.log(person.getAge()); // 0
person.setAge(25);
console.log(person.getAge()); // 25
console.log(person._age); // undefined

分析

  • _age 是闭包中的私有变量,无法直接访问。
  • 通过返回对象的方法控制访问,模拟封装。

闭包的应用场景

  1. 数据封装:如上例的私有变量。
  2. 状态维护:如计数器、事件处理。
  3. 函数柯里化
function curryAdd(a) {
    return function(b) {
        return a + b;
    };
}

const add5 = curryAdd(5);
console.log(add5(3)); // 8
  1. 事件处理
function setupButton(id) {
    let count = 0;
    document.getElementById(id).addEventListener('click', () => {
        console.log(`Clicked ${++count} times`);
    });
}
setupButton('myButton');

分析

  • 闭包维护 count,确保按钮点击次数持久化。
  • 避免全局变量污染。

原型链与继承

原型链的定义与原理

JavaScript 使用原型链(Prototype Chain)实现继承。每个对象有一个内部 [[Prototype]] 属性(通过 __proto__Object.getPrototypeOf 访问),指向其原型对象。原型链是对象查找属性的路径。

核心概念

  • 原型对象:每个函数有一个 prototype 属性,指向原型对象。
  • 构造函数:通过 new 创建对象时,对象的 [[Prototype]] 指向构造函数的 prototype
  • 属性查找:访问对象属性时,若对象本身没有,则沿原型链向上查找。

示例

function Person(name) {
    this.name = name;
}

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

const alice = new Person("Alice");
alice.sayHello(); // Hello, I'm Alice
console.log(alice.__proto__ === Person.prototype); // true

逐步分析

  1. Person 是一个构造函数,其 prototype 属性指向原型对象。
  2. new Person("Alice") 创建对象 alice,其 [[Prototype]] 指向 Person.prototype
  3. alice.sayHello() 查找 sayHello,在 alice 自身找不到,沿原型链找到 Person.prototype.sayHello

原型链的继承

JavaScript 通过原型链实现继承,子类原型指向父类实例。

示例

function Animal(type) {
    this.type = type;
}

Animal.prototype.eat = function() {
    console.log(`${this.type} is eating`);
};

function Dog(name, type) {
    Animal.call(this, type); // 继承属性
    this.name = name;
}

Dog.prototype = Object.create(Animal.prototype); // 继承方法
Dog.prototype.constructor = Dog; // 修复构造函数
Dog.prototype.bark = function() {
    console.log(`${this.name} barks`);
};

const dog = new Dog("Max", "Dog");
dog.eat(); // Dog is eating
dog.bark(); // Max barks

逐步分析

  1. Animal.call(this, type) 调用父类构造函数,继承 type 属性。
  2. Object.create(Animal.prototype) 创建新对象,继承 Animal.prototype 的方法。
  3. 修复 constructor 确保 dog instanceof Dog 正确。
  4. 原型链:dog -> Dog.prototype -> Animal.prototype -> Object.prototype

原型链的常见面试题

面试题 1:原型链查找

问题:以下代码输出什么?

function Foo() {}
Foo.prototype.x = 1;

const foo = new Foo();
console.log(foo.x); // 1
foo.x = 2;
console.log(foo.x); // 2
console.log(Foo.prototype.x); // 1

分析

  • foo.x 初始查找 Foo.prototype.x,输出 1。
  • foo.x = 2foo 自身创建属性 x,不影响原型。
  • Foo.prototype.x 仍为 1。

面试题 2:继承实现

问题:实现一个继承方法,支持多级继承。

function inherit(Child, Parent) {
    Child.prototype = Object.create(Parent.prototype);
    Child.prototype.constructor = Child;
}

function Animal(type) {
    this.type = type;
}
Animal.prototype.eat = function() {
    console.log(`${this.type} eats`);
};

function Dog(name, type) {
    Animal.call(this, type);
    this.name = name;
}
inherit(Dog, Animal);
Dog.prototype.bark = function() {
    console.log(`${this.name} barks`);
};

const dog = new Dog("Max", "Dog");
dog.eat(); // Dog eats
dog.bark(); // Max barks

分析

  • inherit 函数封装原型链继承,复用性高。
  • Object.create 避免直接修改父类原型。

面试题 3:instanceof 原理

问题:以下代码输出什么?

console.log(dog instanceof Dog); // true
console.log(dog instanceof Animal); // true
console.log(dog instanceof Object); // true

分析

  • instanceof 检查对象原型链是否包含构造函数的 prototype
  • dog 的原型链:Dog.prototype -> Animal.prototype -> Object.prototype

ES6 Class 继承

ES6 引入 class 语法,简化继承:

class Animal {
    constructor(type) {
        this.type = type;
    }
    eat() {
        console.log(`${this.type} eats`);
    }
}

class Dog extends Animal {
    constructor(name, type) {
        super(type);
        this.name = name;
    }
    bark() {
        console.log(`${this.name} barks`);
    }
}

const dog = new Dog("Max", "Dog");
dog.eat(); // Dog eats
dog.bark(); // Max barks

分析

  • class 是原型继承的语法糖,super 调用父类构造函数。
  • 更直观,但底层仍是原型链。

数据结构与算法在前端面试中的重要性

为什么重要

数据结构与算法(DSA)在前端面试中至关重要,原因如下:

  • 性能优化:高效算法减少 DOM 操作、渲染时间,提升用户体验。
  • 逻辑能力:算法题考察逻辑思维和问题解决能力。
  • 跨领域应用:前端与后端(如 Node.js)、机器学习(如 CNN 可视化)交互需要 DSA 知识。
  • 竞争力:顶级公司(如 Google、Meta)要求扎实的算法基础。

前端场景

  • 数组操作:过滤、排序、去重(如用户列表处理)。
  • 树结构:DOM 树遍历、组件树优化。
  • 图算法:依赖解析(如 Webpack 模块依赖)。
  • 时间复杂度:优化大数据量渲染(如虚拟列表)。

常见数据结构与算法

数组与字符串

面试题:反转字符串

问题:编写函数反转字符串,不使用内置方法。

function reverseString(s) {
    let arr = s.split('');
    let left = 0, right = arr.length - 1;
    while (left < right) {
        [arr[left], arr[right]] = [arr[right], arr[left]];
        left++;
        right--;
    }
    return arr.join('');
}

console.log(reverseString("hello")); // "olleh"

分析

  • 时间复杂度:O(n),空间复杂度:O(n)。
  • 使用双指针交换字符,避免额外空间。

链表

面试题:反转链表

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

function reverseList(head) {
    let prev = null, curr = head;
    while (curr) {
        let next = curr.next;
        curr.next = prev;
        prev = curr;
        curr = next;
    }
    return prev;
}

const list = new ListNode(1, new ListNode(2, new ListNode(3)));
const reversed = reverseList(list);
console.log(reversed.val); // 3

分析

  • 时间复杂度:O(n),空间复杂度:O(1)。
  • 链表在前端用于事件队列、历史记录。

面试题:二叉树前序遍历

class TreeNode {
    constructor(val, left = null, right = null) {
        this.val = val;
        this.left = left;
        this.right = right;
    }
}

function preorderTraversal(root) {
    const result = [];
    function traverse(node) {
        if (!node) return;
        result.push(node.val);
        traverse(node.left);
        traverse(node.right);
    }
    traverse(root);
    return result;
}

const tree = new TreeNode(1, new TreeNode(2), new TreeNode(3));
console.log(preorderTraversal(tree)); // [1, 2, 3]

分析

  • 时间复杂度:O(n),空间复杂度:O(h)(h 为树高)。
  • 前端应用:DOM 树遍历、组件树解析。

面试题:深度优先搜索(DFS)

function dfs(graph, start) {
    const visited = new Set();
    function traverse(node) {
        visited.add(node);
        console.log(node);
        for (let neighbor of graph[node]) {
            if (!visited.has(neighbor)) {
                traverse(neighbor);
            }
        }
    }
    traverse(start);
}

const graph = {
    A: ['B', 'C'],
    B: ['A', 'D', 'E'],
    C: ['A', 'F'],
    D: ['B'],
    E: ['B', 'F'],
    F: ['C', 'E']
};
dfs(graph, 'A'); // A, B, D, E, F, C

分析

  • 时间复杂度:O(V + E),空间复杂度:O(V)。
  • 应用:依赖解析、组件关系图。

算法在前端的实际应用

虚拟列表优化

处理大数据量列表(如 10,000 条记录):

function createVirtualList(container, items, itemHeight, visibleHeight) {
    let startIndex = 0;
    let endIndex = Math.ceil(visibleHeight / itemHeight);
    
    function render() {
        container.innerHTML = '';
        for (let i = startIndex; i < endIndex; i++) {
            const div = document.createElement('div');
            div.style.height = `${itemHeight}px`;
            div.textContent = items[i];
            container.appendChild(div);
        }
    }
    
    container.addEventListener('scroll', () => {
        startIndex = Math.floor(container.scrollTop / itemHeight);
        endIndex = startIndex + Math.ceil(visibleHeight / itemHeight);
        render();
    });
    
    render();
}

const items = Array.from({ length: 10000 }, (_, i) => `Item ${i}`);
createVirtualList(document.getElementById('list'), items, 50, 500);

分析

  • 仅渲染可视区域,降低 DOM 操作开销。
  • 时间复杂度:O(k),k 为可视项数。

CNN 结果可视化

结合 Python CNN 项目,前端可视化训练结果:

fetch('/api/cnn_results')
    .then(response => response.json())
    .then(data => {
        const ctx = document.getElementById('chart').getContext('2d');
        new Chart(ctx, {
            type: 'line',
            data: {
                labels: data.epochs,
                datasets: [{
                    label: '验证准确率',
                    data: data.val_accuracy,
                    borderColor: '#007bff',
                    fill: false
                }]
            }
        });
    });

分析

  • 使用 Chart.js 绘制 CNN 训练曲线。

  • 后端(Node.js 或 Python Flask)提供数据:

    from flask import Flask, jsonify
    app = Flask(__name__)
    
    @app.route('/api/cnn_results')
    def cnn_results():
        return jsonify({
            'epochs': list(range(1, 51)),
            'val_accuracy': [0.65, 0.70, 0.75, ...]
        })
    

企业级实践

Node.js 与 Python 交互

前端通过 Node.js 调用 Python CNN 模型:

const { spawn } = require('child_process');

function runPythonScript(scriptPath, args) {
    return new Promise((resolve, reject) => {
        const python = spawn('python', [scriptPath, ...args]);
        let output = '';
        python.stdout.on('data', (data) => {
            output += data.toString();
        });
        python.stderr.on('data', (data) => {
            reject(data.toString());
        });
        python.on('close', () => {
            resolve(output);
        });
    });
}

runPythonScript('cifar10_project/scripts/predict.py', ['image.jpg'])
    .then(result => console.log(result))
    .catch(err => console.error(err));

Python 脚本 (predict.py):

import sys
import tensorflow as tf
import numpy as np

model = tf.keras.models.load_model('cifar10_project/models/cifar10_model.h5')
image = tf.keras.preprocessing.image.load_img(sys.argv[1], target_size=(32, 32))
image = tf.keras.preprocessing.image.img_to_array(image) / 255.0
image = np.expand_dims(image, axis=0)
prediction = model.predict(image)
print(np.argmax(prediction[0]))

分析

  • Node.js 使用 child_process 调用 Python 脚本。
  • 适合前端展示 CNN 预测结果。

Docker 部署

部署前端与 CNN 后端:

echo 'FROM node:16
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
CMD ["node", "server.js"]' > Dockerfile
docker build -t frontend_app .

server.js:

const express = require('express');
const { runPythonScript } = require('./utils');

const app = express();
app.use(express.static('public'));

app.get('/api/predict', async (req, res) => {
    const result = await runPythonScript('predict.py', ['image.jpg']);
    res.json({ prediction: result });
});

app.listen(3000, () => console.log('Server running on port 3000'));

分析

  • 前端通过 Express 提供静态文件和 API。
  • Docker 容器化部署,确保环境一致。

深入闭包与作用域

闭包的底层实现

闭包的实现依赖于 JavaScript 引擎(如 V8)的词法环境(Lexical Environment)和执行上下文(Execution Context)。以下是其底层机制:

  • 词法环境:每个函数创建时,V8 为其生成一个词法环境对象,包含:
    • 变量对象:存储本地变量(如 letconst)。
    • 外部引用:指向外层函数的词法环境。
  • 执行上下文:包含变量环境、词法环境和 this 绑定,栈式管理(调用栈)。
  • 闭包捕获:当函数返回时,其词法环境被保留,外部变量引用不会被垃圾回收。

示例(深入分析):

function createCounter() {
    let count = 0;
    return {
        increment: () => ++count,
        getCount: () => count
    };
}

const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.getCount()); // 1
console.log(counter.increment()); // 2

逐步分析

  1. createCounter 创建词法环境,包含 count = 0
  2. 返回对象 { increment, getCount },两个函数共享同一词法环境。
  3. V8 引擎为 count 分配堆内存,闭包函数通过引用访问。
  4. 垃圾回收器无法回收 count,因为 incrementgetCount 仍在使用。

内存管理

  • 内存泄漏风险:闭包可能导致未释放的变量累积。例如,事件监听器未移除:
function setupLeak() {
    let data = new Array(1000000).fill(0); // 大数组
    document.getElementById('button').addEventListener('click', () => {
        console.log(data.length); // 闭包引用 data
    });
}

解决

  • 手动移除监听器:

    const button = document.getElementById('button');
    const handler = () => console.log('Clicked');
    button.addEventListener('click', handler);
    // 移除
    button.removeEventListener('click', handler);
    

作用域的进阶应用

块级作用域与 Temporal Dead Zone(TDZ)

ES6 的 letconst 引入块级作用域,并伴随 TDZ(暂时性死区),防止变量在声明前使用。

面试题:以下代码输出什么?

function testTDZ() {
    console.log(x); // ReferenceError
    let x = 10;
}
testTDZ();

分析

  • let x 在声明前不可访问,触发 TDZ 错误。
  • var 无 TDZ,可能导致 undefined

模块作用域

ES6 模块(ESM)引入模块作用域,变量默认私有。

// counter.js
let count = 0;
export function increment() {
    return ++count;
}
export function getCount() {
    return count;
}

// main.js
import { increment, getCount } from './counter.js';
console.log(increment()); // 1
console.log(getCount()); // 1
console.log(increment()); // 2

分析

  • 模块作用域类似闭包,count 仅在模块内可访问。
  • ESM 支持静态分析,优化 Tree Shaking。

面试题 4:闭包与模块

问题:使用闭包重写模块模式。

const counterModule = (function() {
    let count = 0;
    return {
        increment: () => ++count,
        getCount: () => count
    };
})();

console.log(counterModule.increment()); // 1
console.log(counterModule.getCount()); // 1

分析

  • IIFE(立即执行函数表达式)创建私有作用域,模拟模块。
  • 与 ESM 相比,IIFE 动态但不支持 Tree Shaking。

原型链与继承进阶

原型链的底层机制

原型链基于 JavaScript 的对象模型,V8 引擎通过 [[Prototype]] 实现属性查找。以下是关键点:

  • 原型对象Function.prototypeObject.prototype 是原型链的根。
  • 属性遮蔽:对象自身属性优先于原型属性。
  • 性能:深层原型链查找可能影响性能。

示例(属性遮蔽):

function Person(name) {
    this.name = name;
}
Person.prototype.name = "Default";

const person = new Person("Alice");
console.log(person.name); // Alice
delete person.name;
console.log(person.name); // Default

分析

  • delete person.name 移除自身属性,暴露原型属性。
  • 原型链:person -> Person.prototype -> Object.prototype

高级继承模式

寄生组合继承

寄生组合继承是高效的继承方式,避免重复调用父类构造函数。

function inherit(Child, Parent) {
    Child.prototype = Object.create(Parent.prototype);
    Child.prototype.constructor = Child;
}

function Animal(type) {
    this.type = type;
}
Animal.prototype.eat = function() {
    console.log(`${this.type} eats`);
};

function Dog(name, type) {
    Animal.call(this, type);
    this.name = name;
}
inherit(Dog, Animal);
Dog.prototype.bark = function() {
    console.log(`${this.name} barks`);
};

const dog = new Dog("Max", "Dog");
dog.eat(); // Dog eats
dog.bark(); // Max barks

分析

  • Object.create 创建中间对象,避免 Dog.prototype = new Animal() 的副作用。
  • Animal.call 继承属性,inherit 继承方法。

Mixin 模式

Mixin 允许多重继承,复用代码。

const canRun = {
    run() {
        console.log(`${this.name} runs`);
    }
};

function Dog(name) {
    this.name = name;
}
Object.assign(Dog.prototype, canRun);

const dog = new Dog("Max");
dog.run(); // Max runs

分析

  • Object.assign 将 Mixin 方法复制到原型。
  • 适合复用独立功能,如日志、事件处理。

面试题 5:原型链修改

问题:以下代码输出什么?如何避免问题?

function Person() {}
Person.prototype.name = "Shared";

const p1 = new Person();
const p2 = new Person();
p1.name = "Alice";
console.log(p1.name); // Alice
console.log(p2.name); // Shared
Person.prototype.name = "Modified";
console.log(p1.name); // Alice
console.log(p2.name); // Modified

分析

  • p1.name = "Alice"p1 自身创建属性,不影响原型。
  • 修改 Person.prototype.name 影响未遮蔽的实例(如 p2)。
  • 避免问题:避免直接修改原型,使用 Mixin 或实例属性。

面试题 6:instanceof 实现

问题:手动实现 instanceof

function myInstanceof(obj, constructor) {
    let proto = Object.getPrototypeOf(obj);
    while (proto) {
        if (proto === constructor.prototype) return true;
        proto = Object.getPrototypeOf(proto);
    }
    return false;
}

console.log(myInstanceof(dog, Dog)); // true
console.log(myInstanceof(dog, Animal)); // true

分析

  • 遍历 obj 的原型链,检查是否包含 constructor.prototype
  • 时间复杂度:O(n),n 为原型链长度。

数据结构与算法进阶

动态规划

面试题:最长公共子序列(LCS)

问题:求两个字符串的最长公共子序列长度。

function longestCommonSubsequence(text1, text2) {
    const m = text1.length, n = text2.length;
    const dp = Array(m + 1).fill().map(() => Array(n + 1).fill(0));
    
    for (let i = 1; i <= m; i++) {
        for (let j = 1; j <= n; j++) {
            if (text1[i - 1] === text2[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1] + 1;
            } else {
                dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
            }
        }
    }
    return dp[m][n];
}

console.log(longestCommonSubsequence("ABCD", "ACDF")); // 3 (ACD)

分析

  • 时间复杂度:O(m_n),空间复杂度:O(m_n)。
  • 前端应用:文本差异比较(如代码编辑器高亮)。

图算法

面试题:广度优先搜索(BFS)

function bfs(graph, start) {
    const visited = new Set();
    const queue = [start];
    visited.add(start);
    
    while (queue.length) {
        const node = queue.shift();
        console.log(node);
        for (let neighbor of graph[node]) {
            if (!visited.has(neighbor)) {
                visited.add(neighbor);
                queue.push(neighbor);
            }
        }
    }
}

const graph = {
    A: ['B', 'C'],
    B: ['A', 'D', 'E'],
    C: ['A', 'F'],
    D: ['B'],
    E: ['B', 'F'],
    F: ['C', 'E']
};
bfs(graph, 'A'); // A, B, C, D, E, F

分析

  • 时间复杂度:O(V + E),空间复杂度:O(V)。
  • 前端应用:组件依赖解析、路由导航。

LeetCode 高频题

面试题:两数之和

问题:给定数组和目标值,找出两个数的索引,使其和等于目标值。

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);
    }
    return [];
}

console.log(twoSum([2, 7, 11, 15], 9)); // [0, 1]

分析

  • 使用哈希表,时间复杂度:O(n),空间复杂度:O(n)。
  • 前端应用:快速查找 DOM 元素对。

前端性能优化

节流与防抖

节流(Throttle):限制函数在固定时间间隔内执行一次。

function throttle(fn, delay) {
    let last = 0;
    return function(...args) {
        const now = Date.now();
        if (now - last >= delay) {
            fn.apply(this, args);
            last = now;
        }
    };
}

const scrollHandler = throttle(() => console.log('Scrolled'), 1000);
window.addEventListener('scroll', scrollHandler);

防抖(Debounce):延迟执行,直到事件停止触发。

function debounce(fn, delay) {
    let timer;
    return function(...args) {
        clearTimeout(timer);
        timer = setTimeout(() => fn.apply(this, args), delay);
    };
}

const resizeHandler = debounce(() => console.log('Resized'), 500);
window.addEventListener('resize', resizeHandler);

分析

  • 节流适合高频事件(如滚动),防抖适合输入验证。
  • 优化前端交互性能,减少不必要的计算。

虚拟 DOM 优化

React 的虚拟 DOM 优化 DOM 操作:

class List extends React.Component {
    shouldComponentUpdate(nextProps) {
        return this.props.items !== nextProps.items;
    }
    
    render() {
        return (
            <div>
                {this.props.items.map(item => <div key={item.id}>{item.text}</div>)}
            </div>
        );
    }
}

分析

  • shouldComponentUpdate 避免不必要的重新渲染。
  • 时间复杂度:O(n) 比较虚拟 DOM 树。

与 CNN 项目的整合

前端可视化 CNN 结果

使用 Chart.js 可视化 Python CNN 训练结果:

fetch('/api/cnn_results')
    .then(response => response.json())
    .then(data => {
        const ctx = document.getElementById('accuracyChart').getContext('2d');
        new Chart(ctx, {
            type: 'line',
            data: {
                labels: data.epochs,
                datasets: [
                    {
                        label: '训练准确率',
                        data: data.accuracy,
                        borderColor: '#007bff',
                        fill: false
                    },
                    {
                        label: '验证准确率',
                        data: data.val_accuracy,
                        borderColor: '#28a745',
                        fill: false
                    }
                ]
            },
            options: {
                scales: {
                    y: {
                        beginAtZero: true,
                        max: 1
                    }
                }
            }
        });
    });

Python 后端(Flask):

from flask import Flask, jsonify
app = Flask(__name__)

@app.route('/api/cnn_results')
def cnn_results():
    return jsonify({
        'epochs': list(range(1, 51)),
        'accuracy': [0.65, 0.70, 0.75, ...], # 训练数据
        'val_accuracy': [0.60, 0.65, 0.70, ...]
    })

if __name__ == '__main__':
    app.run(port=5000)

分析

  • 前端通过 Fetch API 获取数据,Chart.js 绘制曲线。
  • 后端使用 Flask 提供 REST API,结合 Anaconda 环境运行。

WebAssembly 调用 CNN

使用 TensorFlow.js 或 ONNX.js 运行 CNN 模型:

import * as tf from '@tensorflow/tfjs';

async function predict(imageElement) {
    const model = await tf.loadLayersModel('/models/cifar10_model.json');
    const img = tf.browser.fromPixels(imageElement).resizeNearestNeighbor([32, 32]).toFloat().div(255).expandDims();
    const prediction = model.predict(img);
    const result = await prediction.data();
    console.log(result);
}

const img = document.getElementById('inputImage');
predict(img);

分析

  • TensorFlow.js 在浏览器运行 CNN 模型,无需后端。

  • 需将 Python 模型转换为 TF.js 格式:

    tensorflowjs_converter --input_format keras cifar10_project/models/cifar10_model.h5 cifar10_project/models/web_model
    

Node.js 与 Python 交互

Node.js 调用 Python CNN 预测:

const { spawn } = require('child_process');

function runPrediction(imagePath) {
    return new Promise((resolve, reject) => {
        const python = spawn('python', ['predict.py', imagePath]);
        let output = '';
        python.stdout.on('data', (data) => output += data);
        python.stderr.on('data', (data) => reject(data.toString()));
        python.on('close', () => resolve(output));
    });
}

runPrediction('image.jpg').then(result => console.log(`Prediction: ${result}`));

predict.py

import sys
import tensorflow as tf
import numpy as np

model = tf.keras.models.load_model('cifar10_project/models/cifar10_model.h5')
image = tf.keras.preprocessing.image.load_img(sys.argv[1], target_size=(32, 32))
image = tf.keras.preprocessing.image.img_to_array(image) / 255.0
image = np.expand_dims(image, axis=0)
prediction = model.predict(image)
print(np.argmax(prediction[0]))

分析

  • 使用 child_process 调用 Python 脚本。

  • Linux 命令管理进程:

    ps aux | grep python
    kill -9 <pid>
    

企业级实践

微前端架构

使用 Module Federation 实现微前端:

// webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
    plugins: [
        new ModuleFederationPlugin({
            name: 'host',
            remotes: {
                app1: 'app1@http://localhost:3001/remoteEntry.js'
            }
        })
    ]
};

// app1.js
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
    plugins: [
        new ModuleFederationPlugin({
            name: 'app1',
            filename: 'remoteEntry.js',
            exposes: {
                './Chart': './src/Chart.js'
            }
        })
    ]
};

分析

  • 微前端分解大型应用,独立部署。
  • 适合 CNN 可视化模块的动态加载。

CI/CD 集成

使用 GitHub Actions 自动化部署:

name: Deploy Frontend
on:
  push:
    branches: [main]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '16'
      - run: npm install
      - run: npm run build
      - name: Deploy to S3
        run: aws s3 sync ./dist s3://my-bucket

分析

  • 自动化构建和部署前端代码。

  • 结合 Docker 部署 CNN 后端:

    docker push myrepo/cnn_app:latest
    

Kubernetes 部署

部署前端与 CNN 服务:

kubectl create -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cnn-frontend
spec:
  replicas: 3
  selector:
    matchLabels:
      app: cnn-frontend
  template:
    metadata:
      labels:
        app: cnn-frontend
    spec:
      containers:
      - name: frontend
        image: frontend_app:latest
        ports:
        - containerPort: 3000
---
apiVersion: v1
kind: Service
metadata:
  name: cnn-frontend-service
spec:
  selector:
    app: cnn-frontend
  ports:
    - protocol: TCP
      port: 80
      targetPort: 3000
  type: LoadBalancer
EOF

分析

  • 部署前端服务,负载均衡提高可用性。
  • 可扩展到 CNN 后端,分配 GPU 资源。
昨天 — 2025年7月2日首页

告别FLIP动画:View Transition API带来的革命性变革

作者 柚子816
2025年7月2日 19:35

前端动画发展这么多年了,transition、animation等,但他们都有一个问题,就是页面元素结构发生变化时,无法应用动画。所以就有了FLIP动画,它可以在页面元素结构发生变化时,应用动画。

FLIP动画: 曾经的王者

FLIP是First, Last, Invert, Play的缩写,它是一种动画效果,它的核心思想是:在元素结构发生变化时,先记录元素的初始状态,然后记录元素的最终状态,然后计算出元素的变化量,最后应用这些变化量,就可以实现动画效果。

  1. First : 记录元素的当前状态(位置、大小)
  2. Last : 修改元素,记录元素的最终状态(位置、大小)
  3. Invert : 计算元素的变化量, 并通过transform将元素恢复到初始状态
  4. Play : 对transform应用动画,将元素移动到最终状态

这种方式被广泛应用于拖拽, 列表重排等场景中。但是它太麻烦了。需要手动计算元素初始及结束状态,及变化量,然后应用到元素上。

View Transition API: 革命性的变革

View Transition API是一种新的API,它可以在页面元素结构发生变化时,通过动画的方式完成变化

document.startViewTransition(() => {
  // 这里是修改页面元素的代码
  // 比如更新DOM,调整元素位置等
});

只需要将DOM变更的代码放在startViewTransition的回调函数中,就可以实现动画效果了。浏览器会自动:

  • 捕获前后状态
  • 自动为旧视图和新视图创建snapshot
  • 管理动画过程

使用这个新的api你只需要专注于我想改什么, 不再关注怎么应用动画

CSS

View Transition API 还有配套的CSS属性,用于控制动画效果,主要有以下几个属性:

::view-transition, ::view-transition-old, ::view-transition-new, ::view-transition-group, ::view-transition-image-pair

兼容性

除了Firefox, 主流现代浏览器都已经支持了


曾经我们用FLIP实现流畅的动画效果,是因为浏览器不给力。今天,浏览器已经足够给力,是时候说一句

再见,FLIP。你好,View Transition API。

告别盲测:Jest--JavaScript测试之道

作者 烛阴
2025年7月2日 19:04

为什么要测试,测试什么?

为什么要测试?

  • 提升代码质量和可靠性: 尽早发现并修复bug,减少线上事故。
  • 增强重构信心: 有测试用例保驾护航,你可以大胆地优化和重构代码,因为你知道它们会立即发现潜在的回归错误。
  • 提高开发效率: 避免了手动重复测试的繁琐,让你可以更快地迭代新功能。

测试什么?

我们通常将测试分为几个层次:

  • 单元测试 (Unit Tests): 针对代码中最小的可独立测试单元进行测试,如单个函数、类的方法。它们应该快速、独立、可重复。这是JTest的基础和核心。

  • 集成测试 (Integration Tests): 测试多个单元或模块协同工作时的行为,验证它们之间的接口和交互是否正确。例如,测试一个UI组件与数据层API的交互。


JTest初体验——告别盲测的第一步 (Jest入门)

环境搭建

首先,我们来安装Jest。这是一个零配置的测试框架,非常适合快速上手。

# 进入你的项目目录
cd your-js-project

# 安装 Jest
npm install --save-dev jest

然后,在 package.json 中添加一个 test 脚本:

{
  "name": "your-js-project",
  "version": "1.0.0",
  "scripts": {
    "test": "jest"
  },
  "devDependencies": {
    "jest": "^30.0.3"
  }
}

2.2 你的第一个JTest用例

让我们从一个最简单的纯函数开始:加法。

src/sum.js

// 这是一个简单的加法函数
function sum(a, b) {
  return a + b;
}

module.exports = sum; // 导出函数

接下来,我们为 sum.js 创建一个测试文件。根据Jest的约定,测试文件通常与源文件放在同一目录下,并以 .test.js.spec.js 结尾。

src/sum.test.js

const sum = require('./sum'); // 导入要测试的函数

// describe 块用于组织相关的测试用例
describe('sum 函数', () => {
    // test (或 it) 定义一个具体的测试用例
    test('应该正确计算两个正数的和', () => {
        // expect(value) 是 Jest 的全局函数,用于声明一个断言
        // .toBe(expected) 是一个匹配器 (matcher),用于比较值是否相等
        expect(sum(1, 2)).toBe(3);
    });

    test('应该正确计算一个正数和一个负数的和', () => {
        expect(sum(5, -3)).toBe(2);
    });

    test('应该正确计算两个零的和', () => {
        expect(sum(0, 0)).toBe(0);
    });
});

2.3 运行测试

现在,打开你的终端,运行测试命令:

npm test

你将看到类似以下的输出:

> jest

 PASS  ./sum.test.js
  sum 函数
     应该正确计算两个正数的和 (2 ms)
     应该正确计算一个正数和一个负数的和
     应该正确计算两个零的和

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        0.387 s, estimated 1 s
Ran all test suites.

恭喜你!你已经成功编写并运行了你的第一个JTest用例。PASS 意味着你的代码通过了测试,一切正常。


深入浅出:JTest核心断言与组织

更多的断言匹配器 (Matchers)

Jest提供了丰富的匹配器,用于检查各种条件。

匹配器 描述 示例
toBe(value) 严格相等 (===),用于基本类型 expect(1).toBe(1);
toEqual(value) 递归比较对象或数组的内容相等,用于引用类型 expect({a:1}).toEqual({a:1});
not.toBe(value) 不严格相等 expect(1).not.toBe(2);
toBeTruthy() 检查是否为真值 (truthy) expect(1).toBeTruthy();
toBeFalsy() 检查是否为假值 (falsy) expect(0).toBeFalsy();
toBeNull() 检查是否为null expect(null).toBeNull();
toBeUndefined() 检查是否为undefined expect(undefined).toBeUndefined();
toBeDefined() 检查是否已定义 expect(1).toBeDefined();
toBeInstanceOf(Class) 检查是否是某个类的实例 expect(new Array()).toBeInstanceOf(Array);
toContain(item) 检查数组中是否包含某个元素 expect([1, 2, 3]).toContain(2);
toMatch(regexp) 检查字符串是否匹配正则表达式 expect('hello').toMatch(/ll/);
toThrow(error?) 检查函数是否抛出错误 expect(() => { throw new Error(); }).toThrow();
resolves.toBe(value) 检查Promise是否成功解决并匹配值 await expect(Promise.resolve(1)).resolves.toBe(1);
rejects.toThrow(error?) 检查Promise是否失败并抛出错误 await expect(Promise.reject('error')).rejects.toThrow('error');

示例:src/stringUtils.js

function capitalize(str) {
  if (typeof str !== 'string' || str.length === 0) {
    throw new Error('Input must be a non-empty string.');
  }
  return str.charAt(0).toUpperCase() + str.slice(1);
}

module.exports = { capitalize };

src/stringUtils.test.js

const { capitalize } = require('./stringUtils');

describe('capitalize 函数', () => {
    test('应该将字符串的第一个字母大写', () => {
        expect(capitalize('hello')).toBe('Hello');
    });

    test('应该返回相同的大写字符串,如果第一个字母已经是大写', () => {
        expect(capitalize('World')).toBe('World');
    });

    test('应该处理单字符字符串', () => {
        expect(capitalize('a')).toBe('A');
    });

    test('应该抛出错误,如果输入不是字符串', () => {
        expect(() => capitalize(123)).toThrow('Input must be a non-empty string.');
        expect(() => capitalize(null)).toThrow('Input must be a non-empty string.');
    });

    test('应该抛出错误,如果输入是空字符串', () => {
        expect(() => capitalize('')).toThrow('Input must be a non-empty string.');
    });
});

测试生命周期函数:beforeEachafterEach

在某些场景下,你可能需要在每个测试用例运行之前或之后执行一些设置或清理工作。Jest提供了 beforeEach, afterEach, beforeAll, afterAll 等生命周期函数。

  • beforeEach(fn): 在每个 test (或 it) 运行之前执行。
  • afterEach(fn): 在每个 test (或 it) 运行之后执行。
  • beforeAll(fn): 在当前 describe 块中的所有 test 运行之前执行一次。
  • afterAll(fn): 在当前 describe 块中的所有 test 运行之后执行一次。

示例:计数器模块的测试

src/counter.js

let count = 0;

function increment() {
    count++;
}

function decrement() {
    count--;
}

function getCount() {
    return count;
}

function reset() {
    count = 0;
}

module.exports = {
    increment,
    decrement,
    getCount,
    reset
};

src/counter.test.js

const counter = require('./counter');

describe('计数器模块', () => {
    // 在每个测试用例运行前,将计数器重置为0,确保每个测试的独立性
    beforeEach(() => {
        counter.reset();
    });

    test('increment 应该使计数器加一', () => {
        counter.increment();
        expect(counter.getCount()).toBe(1);
    });

    test('decrement 应该使计数器减一', () => {
        counter.decrement();
        expect(counter.getCount()).toBe(-1);
    });

    test('连续调用 increment 应该正确累加', () => {
        counter.increment();
        counter.increment();
        expect(counter.getCount()).toBe(2);
    });

    test('reset 应该将计数器重置为0', () => {
        counter.increment();
        counter.increment();
        counter.reset();
        expect(counter.getCount()).toBe(0);
    });
});

测试覆盖率

衡量你的测试质量的一个重要指标是测试覆盖率 (Test Coverage) 。Jest内置了此功能。

package.jsontest 脚本中添加 --coverage 标志:

{
  "scripts": {
    "test": "jest --coverage"
  }
}

运行 npm test 后,你会在终端看到一个报告,也会在项目根目录生成一个 coverage 文件夹,其中包含详细的HTML报告,你可以打开 coverage/lcov-report/index.html 查看。

覆盖率指标:

  • Statements (语句): 代码中的语句有多少被执行了。
  • Branches (分支): if/else, switch, 三元表达式等分支有多少被执行了。
  • Functions (函数): 函数有多少被调用了。
  • Lines (行): 代码行有多少被执行了。

注意: 100% 覆盖率不代表代码没有bug,它只能说明你的测试执行了所有代码路径,但无法保证这些路径的逻辑都是正确的。你应该追求有意义的覆盖率,而不是盲目追求数字。


结语


如果你喜欢本教程,记得点赞+收藏!关注我获取更多JavaScript开发干货。

i18n-前端国际化实现原理与架构设计

作者 山河木马
2025年7月1日 11:24

国际化(Internationalization,简称 i18n)  是指设计、开发产品或服务(尤其是软件、网站、内容、业务流程等)时,使其能够轻松适应不同语言、地区和文化环境的过程和技术。它的核心目标是消除地域和文化障碍,为全球用户提供无障碍的体验。

为什么叫 i18n?
这是一个常见的缩写,源自单词 “Internationalization” 的首字母 I 和末字母 n,中间有 18 个字母,因此简写为 i18n

实现原理

  1. 使用键值对存储不同语言的翻译包
  2. 用一个管理器使用对应的语言进行翻译
  3. 代码中原本写死的中文,用函数替换,参数是语言包对应的key值,返回值就是对应的语言

构架设计

1.整体架构思路

// 架构层次
项目根目录
└── src/
    └── modules/
        └── i18n/
            ├── I18nManager.ts       // 核心管理器
            └── languages/           // 语言包目录
                ├── zh-CN.ts        // 中文语言包
                └── en-US.ts        // 英文语言包

管理器模式 + 语言包的架构:

  • I18nManager: 作为国际化的核心管理器,负责语言包管理、文本翻译、语言切换等功能
  • 语言包: 按语言分离的配置文件,便于维护和扩展
  • 统一导出: 全局单例模式,确保一致性

2.核心管理器设计

type LanguagePack = {
  [key: string]: string | LanguagePack;
};

interface I18nConfig {
  defaultLanguage: string;
}

class I18nManager {
  private static instance: I18nManager;
  private currentLanguage: string;
  private languagePacks: Map<string, LanguagePack> = new Map();

  constructor(config: I18nConfig) {
    this.currentLanguage = config.defaultLanguage;
  }

  static getInstance(config?: I18nConfig): I18nManager {
    if (!I18nManager.instance && config) {
      I18nManager.instance = new I18nManager(config);
    }
    return I18nManager.instance;
  }

  /**
   * 设置语言包
   */
  setLanguagePack(language: string, pack: LanguagePack): void {
    this.languagePacks.set(language, pack);
  }

  /**
   * 获取当前语言
   */
  getCurrentLanguage(): string {
    return this.currentLanguage;
  }

  /**
   * 设置当前语言
   */
  setCurrentLanguage(language: string): void {
    if (this.currentLanguage !== language) {
      this.currentLanguage = language;
    }
  }

  /**
   * 获取文本
   */
  t(key: string, params?: Record<string, string | number>): string {
    const pack = this.languagePacks.get(this.currentLanguage);

    if (!pack) {
      console.warn(`${this.currentLanguage} 语言包没有找到`);
      return key;
    }

    const value = this.getNestedValue(pack, key);
    if (typeof value !== 'string') {
      console.warn(`${key} 翻译键没有找到`);
      return key;
    }

    return this.interpolate(value, params);
  }

  /**
   * 获取嵌套值
   */
  private getNestedValue(obj: any, path: string): any {
    return path.split('.').reduce((current, key) => {
      return current && current[key] !== undefined ? current[key] : undefined;
    }, obj);
  }

  /**
   * 插值替换
   */
  private interpolate(text: string, params?: Record<string, string | number>): string {
    if (!params) return text;

    return text.replace(/\{(\w+)\}/g, (match, key) => {
      return params[key] !== undefined ? String(params[key]) : match;
    });
  }
}
  1. 单例模式: 确保全局唯一的国际化实例,避免状态不一致
  2. 类型安全: 使用 TypeScript 定义清晰的类型接口
  3. 嵌套支持: LanguagePack 类型支持多层嵌套结构
  4. 延迟初始化: 只有在需要时才创建实例

3.语言包组织

export const zhCN: Record<string, any> = {
  // 通用模块
  common: {
    close: '关闭',
    confirm: '确认',
    cancel: '取消',
    // ...
  },

  // 功能模块 - 设置
  settings: {
    title: '渲染设置',
    renderSettings: '渲染设置',
    showBorder: '显示边框',
    showBorderTip: '显示模型构件的边线',
    // ...
  },

  // 更多模块...
};
export const enUS: Record<string, any> = {
  // Common
  common: {
    close: 'Close',
    confirm: 'Confirm',
    cancel: 'Cancel',
    // ...
  },

  // Settings
  settings: {
    title: 'Render Settings',
    renderSettings: 'Render Settings',
    showBorder: 'Show Border',
    showBorderTip: 'Display model component edges',
    // ...
  },
  
  // 更多模块...
};
  1. 按功能模块分组: 将相关的文本放在同一个命名空间下
  2. 层次化结构: 使用嵌套对象组织复杂的文本结构
  3. 一致性命名: 保持命名规范的一致性,如 xxxTip 表示提示文本
  4. 完整性保证: 确保所有语言包的结构完全一致

初始化调用

1.全局初始化

import { zhCN } from './languages/zh-CN';
import { enUS } from './languages/en-US';

// 创建全局实例
const i18n = I18nManager.getInstance({
  defaultLanguage: 'zh-CN'
});

// 注册语言包
i18n.setLanguagePack('zh-CN', zhCN);
i18n.setLanguagePack('en-US', enUS);

// 设置当前语言
i18n.setCurrentLanguage('en-US');

export { i18n };

2.实际使用示例

// 在业务代码中的使用
import { i18n } from '../../i18n/I18nManager';

export class TooltipManager {
  private getAnnotationTooltipMessages() {
    return {
      [AnnotationDwgType.CLOUD]: {
        initial: i18n.t('annotationDwg.initialTip'),
        drawing: i18n.t('annotationDwg.drawingTip'),
        mobileInitial: i18n.t('annotationDwg.mobileInitialTip') + i18n.t('annotationDwg.cloud'),
        mobileDrawing: i18n.t('annotationDwg.mobileDrawingTip') + i18n.t('annotationDwg.cloud')
      },
      // ...
    };
  }
}

扩展其他语言

后续如果要支持日语,只需提供日语对应的键值对,然后在manager中使用即可。需要注意的是,不同的语言长短不一样,可能对界面有所影响,css需要微调

❌
❌