阅读视图

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

js防抖技术:从原理到实践,如何解决高频事件导致的性能难题

在开发应用时,各位程序开发者们是否遇到一个性能问题,就是当用户网络卡顿时,点击一个按钮短时间内没反应后疯狂点击该按钮,频繁向后端发送请求,或者疯狂输入搜索框导致页面卡顿,频繁调整窗口大小导致重排重绘暴增等常见的性能问题。

一、 防抖的定义

此时聪明的你是否能想到一个解决方法,那就是,我们何不如设置一个定时器,当该事件被触发时,我们并不立即执行这个函数,而是等待一小段时间再触发,且当在等待的时间内没有被触发,便执行一次

想象你在坐电梯的时候,当你进入时,电梯不会立即关闭,而是会等待后面的人全部进入后再关闭(把每一次点击想象成按一次电梯按钮,每一次按按钮都会重置等待时间)

此时你就明白了解决频用户频繁发起请求导致的性能问题的解决方法:防抖

防抖 :在事件被频繁执行时,函数并不立即执行,而是在最后一次触发后等待指定的延迟时间,若延迟期间无新的触发,方才执行一次

二、防抖的实现逻辑

我们先假设在html界面中,我们添加了一个button按钮,且将其id命名为btn,此时我们想要为其添加一个点击事件,使其每点击一次便输出一次hello,我们需要写下如下js代码

1.实现具有打印功能的点击事件

const btn = document.getElementById('btn')
function ptn(){
   console.log('hello')
   }
btn.addEventListenner('click',ptn)

但是你会发现,将你带入疯狂点击该按钮的用户,你就会发现,此时前端会疯狂打印hello,若不能解决这个问题则不能解决相似的前端疯狂向后端发送请求的问题。

2.实现延迟打印(?不成功)

那你此时想到了有一个函数setTimeout(),诶,这个函数好像可以起到一种延迟的效果,或许可以添加请求的间隔?让我们看看

const btn = document.getElementById('btn')
function ptn(){
   console.log('hello')
   }
function debounce(fn,wait){
   return function(){
       setTimeout(function(){
          fn()
       
       },wait)
      }

   }   
   
btn.addEventListenner('click',debounce(ptn,1000))

此时你慢慢地点几次,你或许会发现,诶,我好像实现了这个功能,每过一秒才会打印一次hello,但是但是一旦你连续快速地的点击,你就会惊奇的发现。

啊?为什么在我停下来的时候还会继续打印hello,而且好像打印的间隔不可能有一秒

失败原因

没错,因为单纯使用setTimeout而未使用闭包来保持状态(若想了解闭包,请参考往期文章《js中作用域及闭包的概念与基础应用》导致每次点击都创建了一个打印事件,事件函数频繁触发,间隔时间无法保证,这种被称为防抖失效防抖函数未能实现

3.使用闭包解决未能更新定时器的

我们知道,闭包可以起到一个保存外部函数的变量,延迟销毁的作用

使用闭包处理后的代码
const btn = document.getElementById('btn')

function debounce(ptn,wait){

   var time 
   
   return function(){
   
      clearTimeout(time)
      
      time = setTimeout(function(){
      
         ptn() 
         
      },wait)   
   }
   
   }  
   function ptn(){
   console.log('hello')
   
   }
   
btn.addEventListener('click',debounce(ptn,1000))

经过检验后,你发现,你成功通过闭包实现了‘‘hello’’的延迟打印效果,同样的,也可以应用在解决用户大量提交等操作导致的大量计算或布局操作,节省了服务器的算力

扩展

一、添加防抖函数导致this本应的指向改变

上述代码虽然是完成了防抖的基本功能:‘频繁触发,只执行最后一次’,但是你或许不知道的是,这份代码仍有缺陷,试试仔细观察一下这份代码,添加防抖再运行后ptn内的this指向似乎发生了改变

没错,在btn.addEventListener('click',debounce(ptn,1000))的运行时,函数体被btn调用时,其内部的ptn函数在被调用时,是独立调用的,而我们知道,当一个函数被独立调用时,其this是指向window全局的。

但是但是,在我们添加这个防抖函数前,我们是这样实现这个点击事件的btn.addEventListenner('click',ptn),此时ptnaddEventListenner触发,出现隐式绑定,this应该是指向btn的。

而在我们添加防抖函数后,却导致ptn函数的this指向了window全局对象,那我们此时就做了一件很糟糕的事情,显然我们需要将它的this指回给btn

方法一

通过提前保存函数内this指向的btn,使用call函数控制其this指回btn

const btn = document.getElementById('btn')

function debounce(ptn,wait){

   var time 
   
   return function(){
      const _this = this
   
      clearTimeout(time)
      
      time = setTimeout(function(){
         
         ptn.call(_this) 
         
      },wait)   
   }
   
   }  
   function ptn(){
   console.log('hello')
   
   }
   
btn.addEventListener('click',debounce(ptn,1000))

方法二

通过利用箭头函数没有this对象,其内部的this是指向btn这一特点,使用call函数控制其this指回btn

const btn = document.getElementById('btn')

function debounce(ptn,wait){

   var time 
   
   return function(){
   
      clearTimeout(time)
      
      time = setTimeout(() => {
         
         ptn.call(this) 
         
      },wait)   
   }
   
   }  
   function ptn(){
   console.log('hello')
   
   }
   
btn.addEventListener('click',debounce(ptn,1000))

二、遗漏addEventListener提供的event事件参数

当我们使用addEventListener触发一个函数时,会默认向其内部传入一个事件函数event对象以及其它对象,用于记录事件发生的详情

而我们遗漏了向防抖函数内部接受这个event对象以及其它的对象,以及将其提供给原本被调用的ptn函数,此时我们需要为其接受这个event对象以及其它的对象,以及将其提供给原本被调用的ptn函数。

通过...arg接受及结构向其内部传递

const btn = document.getElementById('btn')

function debounce(ptn,wait){

   var time 
   
   return function(...arg){
   
      clearTimeout(time)
      
      time = setTimeout(() => {
         
         ptn.call(this,...arg) 
         
      },wait)   
   }
   
   }  
   function ptn(){
   console.log('hello')
   
   }
   
btn.addEventListener('click',debounce(ptn,1000))

到此,js防抖技术介绍为止,点赞+关注,后续继续提供实战演示题型

从爬楼梯到算斐波那契,我终于弄懂了递归和动态规划这俩 "磨人精"

最近在代码界摸爬滚打,总被两个词按在地上摩擦 —— 递归和动态规划。这俩货就像数学题里的 "小明",天天换着花样折磨人,今天就来好好扒一扒它们的底裤。

递归:自己调自己的 "套娃大师"

递归这东西,说白了就是函数自己喊自己的名字。就像小时候问妈妈 "我从哪来的",妈妈说 "你是妈妈生的",再问 "妈妈从哪来的",妈妈说 "外婆生的"... 一直问到祖宗十八代,这就是递归的精髓 —— 找规律 + 找出口

比如算个阶乘,用递归(时间复杂度过高)写出来是这样:

function mul(n){

   if(n === 1){ // 出口:问到祖宗了

       return 1

   }

   return n * mul(n-1) 

}

这代码简洁得像诗,但算个斐波那契数列就露馅了。那个 1,1,2,3,5,8... 的数列,递归写法看着简单:

递归的 "中年危机":重复计算让 CPU 原地冒烟

function fb(n){

   if(n === 1 || n === 2){

       return 1

   }

   return fb(n-1) + fb(n-2)

}

这代码算个 n=10 还行,要是算 n=40,能让你喝杯咖啡回来还没出结果。就像你查快递单号,每次都要从快递员刚取件的时候查起,哪怕昨天刚查过。

给递归装个 "备忘录":记忆化搜索救场

比如爬楼梯问题:一次能爬 1 或 2 阶,到第 n 阶有几种走法?

后来我灵机一动,给递归加了个小本本(数组 f):

const f = []
var climbStairs = function (n) {
    if (n === 1 || n === 2) {
        return n
    }
    if (f[n] === undefined) { // 查小本本,没记过才计算
        f[n] = climbStairs(n - 1) + climbStairs(n - 2)
    }
    return f[n]
};

这招叫 "记忆化搜索"(提效),相当于把算过的结果记在通讯录里,下次直接拨号不用重新查号。

后来才发现,这代码就像给老年机装了智能手机的通讯录 —— 思路对但效率不够。全局数组 f 在多组测试用例下会残留历史数据,而且递归调用本身就有函数栈的开销,n 太大时还是扛不住。

彻底换个活法:动态规划的 "自底向上" 哲学

  • 站在已知的角度,通过已知来定位未知 最后改用纯动态规划找到动态方程)写法,直接逆袭:
var climbStairs = function (n){
    const f = []
    // 先搞定已知的1楼和2楼
    f[1] = 1
    f[2] = 2
    // 从3楼开始往上爬,每步都踩在前人的肩膀上
    for(let i = 3;i<=n;i++){
        f[i] = f[i-1] + f[i-2]
    }
    return f[n]
 }

这思路就像盖楼,从 1 层开始一层层往上盖,每一层的建材都直接用前两层的,根本不用回头看。没有递归的函数调用开销,也没有重复计算,效率直接拉满。

总结:三种写法的生存现状

写法 特点 适合场景
纯递归 代码简洁如诗 理解思路用,n≤30
记忆化搜索 加了缓存的递归 教学演示,n≤1000
动态规划 自底向上迭代 实际开发,n多大都不怕

总结:什么时候该套娃,什么时候该记笔记?

  • 递归适合简单问题或调试时用,写起来爽,但容易重复劳动

  • 动态规划适合复杂问题,虽然前期要多写几行,但跑起来飞快

  • 记住:所有动态规划问题,先建个空数组当小本本准没错

现在终于明白,递归是浪漫的诗人,只顾优雅不管效率; 动态规划是务实的会计,每一笔账都记得清清楚楚。 下次再遇到这俩货,我可不会再被它们忽悠了!

柯里化

函数柯里化的含义:将多个参数的函数 转化成 一个一个传入参数的函数。

目的:函数参数复用延迟执行 它使用闭包记住之前传递的参数。

image.png

✅ 使用柯里化(参数复用

我们将函数改造一下,让它先接收“规则”,返回一个专门检查这个规则的函数。

// 柯里化:第一层接收规则,第二层接收内容
function curriedCheck(reg) {
    // 闭包记住了 reg
    return function(txt) {
        return reg.test(txt);
    }
}

// 1. 参数复用:我们先生成一个“专门检查手机号”的函数// 这里我们将 reg 参数固定(复用)在了 checkPhone 函数内部
const checkPhone = curriedCheck(/^1\d{10}$/);

// 2. 以后使用,只需要传内容,不需要再传正则了
checkPhone('13800000001'); // true
checkPhone('13800000002'); // true
checkPhone('13800000003'); // true// 甚至可以再生成一个“专门检查邮箱”的函数const checkEmail = curriedCheck(/@/);
checkEmail('abc@qq.com');

结论: 在这里,正则表达式这个参数被复用了。checkPhone 就像是一个被填入了一半参数的模具,你只需要填入剩下的一半即可。

延迟执行

onClickreact 渲染的时候就会 直接求值执行

react在渲染时,onclick会执行{}中的函数。 如果 onclick={handlerDelete(id)} 那么在渲染的时候直接就执行了这个函数,还没有点击就删除了。

所以使用匿名函数 or 柯里化

匿名函数 onclick={()=> handlerDelete(id)}

柯里化:

image.png

image.png

深入理解 JavaScript 中的 “this”:从自由变量到绑定规则

🧠 深入理解 JavaScript 中的 this:从自由变量到绑定规则

this 是 JavaScript 最容易被误解的概念之一 —— 它不是由函数定义决定的,而是由调用方式决定的。”

在日常开发中,我们经常遇到这样的困惑:

  • 为什么同一个函数,有时 this 指向对象,有时却指向 window
  • 为什么在事件回调里 this 是 DOM 元素,而赋值后调用就变成全局对象?
  • 严格模式下 this 为什么会变成 undefined

本文将结合你可能写过的代码,系统梳理 this 的设计逻辑、绑定规则与常见陷阱,并告诉你:如何真正掌控 this


🔍 一、this 不是“自由变量”——它和作用域无关!

很多初学者会混淆 变量查找this 绑定,认为它们是一回事。其实:

特性 自由变量(如 myName this
查找时机 编译阶段(词法作用域) 执行阶段(动态绑定)
查找依据 函数定义位置(Lexical Scope) 函数调用方式
是否受作用域链影响 ✅ 是 ❌ 否

来看一段典型代码:

'use strict'; // 严格模式
var myName = '极客邦'; // 挂载到 window

var bar = {
    myName: 'time.geekbang.com',
    printName: function () {
        console.log(myName);       // ✅ 自由变量 → '极客邦'(全局)
        console.log(this.myName);  // ❓ this 取决于怎么调用!
    }
};

function foo() {
    let myName = '极客时间';
    return bar.printName;
}

var _printName = foo();
_printName();      // this → undefined(严格模式)
bar.printName();   // this → bar
  • myName 是自由变量,按词法作用域查找 → 总是取全局的 '极客邦'
  • this.myName 的值完全取决于函数如何被调用

💡 关键结论:this 和作用域链毫无关系!它是运行时的“调用上下文”决定的。


🎯 二、this 的五种绑定规则(优先级从高到低)

1️⃣ 显式绑定(Explicit Binding)

使用 call / apply / bind 强制指定 this

function foo() {
    this.myName = '极客时间';
}
let bar = { name: '极客邦' };
foo.call(bar); // this → bar
console.log(bar); // { name: '极客邦', myName: '极客时间' }

最高优先级,直接覆盖其他规则。


2️⃣ 隐式绑定(Implicit Binding)

作为对象的方法调用 → this 指向该对象

var myObj = {
    name: '极客时间',
    showThis: function() {
        console.log(this); // → myObj
    }
};
myObj.showThis(); // 隐式绑定

⚠️ 陷阱:隐式丢失(Implicit Loss)

var foo = myObj.showThis; // 函数引用被赋值
foo(); // 普通函数调用 → this = window(非严格)或 undefined(严格)

这就是为什么 setTimeout(myObj.method, 1000) 会丢失 this


3️⃣ 构造函数绑定(new Binding)

使用 new 调用函数 → this 指向新创建的实例

function CreateObj() {
    this.name = '极客时间';
}
var obj = new CreateObj(); // this → obj

内部机制相当于:

var temObj = {};
temObj.__proto__ = CreateObj.prototype;
CreateObj.call(temObj);
return temObj;

4️⃣ 普通函数调用(Default Binding)

既不是方法,也没用 newcall → 默认绑定

  • 非严格模式this → window(浏览器)或 global(Node)
  • 严格模式this → undefined
function foo() {
    console.log(this); // 非严格 → window;严格 → undefined
}
foo();

🚫 这是 JS 的一个“历史包袱”:作者 Brendan Eich 当年为了快速实现,让普通函数的 this 默认指向全局对象,导致大量意外污染。


5️⃣ 箭头函数(Arrow Function)

没有自己的 this!继承外层作用域的 this

class Button {
    constructor() {
        this.text = '点击';
        document.getElementById('btn').addEventListener('click', () => {
            console.log(this.text); // ✅ 正确指向 Button 实例
        });
    }
}

✅ 箭头函数是解决“回调中 this 丢失”的利器,但不能用于需要动态 this 的场景(如构造函数、对象方法)。


🌐 三、特殊场景:DOM 事件中的 this

<a href="#" id="link">点击我</a>
<script>
document.getElementById('link').addEventListener('click', function() {
    console.log(this); // → <a id="link"> 元素
});
</script>

这是 addEventListener 的规范行为

回调函数中的 this 自动绑定为注册事件的 DOM 元素

但注意:

  • 如果用箭头函数 → this 不再是元素!
  • 如果把函数赋值给变量再调用 → 隐式丢失!

⚠️ 四、为什么 this 的设计被认为是“不好”的?

  1. 违反直觉:函数定义时看不出 this 指向谁。
  2. 容易出错:隐式丢失、全局污染频发。
  3. 依赖调用方式:同一函数,不同调用,this 不同。

正因如此,ES6 引入了 class 和箭头函数,弱化对 this 的依赖


✅ 五、最佳实践建议

场景 推荐做法
对象方法 用普通函数,避免赋值导致隐式丢失
回调函数(如事件、定时器) 用箭头函数,或 .bind(this)
构造函数 用 class 替代传统函数
需要强制指定上下文 用 call / apply / bind
避免全局污染 使用严格模式 + let/const

🔚 结语

this 并不神秘,它只是 JavaScript 动态绑定机制的一部分。理解它的核心在于:

“谁调用了这个函数,this 就是谁。”

掌握五种绑定规则,避开隐式丢失陷阱,你就能在任何场景下准确预测 this 的指向。

最后记住:现代 JavaScript 已经提供了更安全的替代方案(如 class、箭头函数),不必死磕 this —— 但你必须懂它。


📚 延伸阅读

  • 《你不知道的JavaScript(上卷)》—— “this & 对象原型”章节
  • MDN: this
  • ECMAScript 规范:Function Calls

欢迎在评论区分享你踩过的 this 坑! 👇
如果觉得有帮助,别忘了点赞 + 关注~ ❤️


标签:#JavaScript #this #前端 #作用域 #掘金

Python避坑指南:基础玩家的3个"开挂"技巧

刚学会Python基础,写代码还在靠 for+append 凑数?别慌!这几个进阶偏基础的知识点,既能让代码变优雅,又不搞复杂概念,新手也能秒上手~

1. 推导式:一行搞定列表/字典(告别冗余循环)

还在这样写循环添加元素?

  
# 传统写法
nums = [1,2,3,4,5]
even_squares = []
for num in nums:
    if num % 2 == 0:
        even_squares.append(num**2)
print(even_squares)  # 输出: [4, 16]

 

试试列表推导式,一行搞定,逻辑更清晰:

  
# 列表推导式
nums = [1,2,3,4,5]
even_squares = [num**2 for num in nums if num % 2 == 0]
print(even_squares)  # 输出: [4, 16]

 

字典推导式也超实用,快速构建键值对:

fruits = ["apple", "banana", "cherry"]
fruit_len = {fruit: len(fruit) for fruit in fruits}
print(fruit_len)  # 输出: {'apple':5, 'banana':6, 'cherry':6}

 

2. 解包操作:变量交换/多返回值的优雅姿势

交换变量不用临时变量,解包直接拿捏:

a, b = 10, 20
a, b = b, a  # 一行交换,无需temp
print(a, b)  # 输出: 20 10

 

函数多返回值接收更简洁,还能忽略无用值:

def get_user_info():
    return "张三", 25, "北京"

name, age, _ = get_user_info()  # _ 忽略不需要的字段
print(f"姓名:{name},年龄:{age}")  # 输出: 姓名:张三,年龄:25

 

3. F-string:格式化输出的"天花板"

告别繁琐的 % 和 format ,F-string直观又强大:

score = 92.345
name = "李四"
# 直接嵌入变量,支持格式控制
print(f"{name}的成绩:{score:.1f}分")  # 输出: 李四的成绩:92.3分
print(f"及格率:{score/100

在flutter中dio应该如何封装和使用

在 Flutter 中,Dio 是最常用的网络请求库,合理的封装可以提升代码复用性、降低维护成本,还能统一处理请求拦截、错误处理、超时配置等通用逻辑。以下是一套标准化的 Dio 封装方案,包含核心封装、拦截器配置、业务适配和使用示例。

一、核心封装思路

  1. 单例模式:避免重复创建 Dio 实例,统一管理配置;
  2. 拦截器:统一处理请求头、请求 / 响应日志、token 刷新、错误处理;
  3. 通用方法封装:封装 GET/POST/PUT/DELETE 等基础请求,简化业务层调用;
  4. 数据解析:统一处理接口返回格式,剥离业务数据与通用包装;
  5. 异常处理:封装自定义异常,区分网络错误、业务错误、超时错误等。

二、完整封装实现

步骤 1:添加依赖

在 pubspec.yaml 中添加 Dio 依赖(建议指定稳定版本):

yaml

dependencies:
  dio: ^5.4.3+1  # 最新版本可查看 pub.dev
  flutter_dotenv: ^5.1.0  # 可选,用于管理环境变量(如 baseUrl)
  cookie_jar: ^4.0.1      # 可选,用于 cookie 管理

步骤 2:创建网络配置文件

新建 lib/core/net/network_config.dart,管理基础配置:

dart

import 'package:flutter_dotenv/flutter_dotenv.dart';

/// 网络请求配置
class NetworkConfig {
  /// 基础域名(可通过环境变量区分开发/生产环境)
  static String baseUrl = dotenv.env['BASE_URL'] ?? 'https://api.example.com';

  /// 请求超时时间(毫秒)
  static const int connectTimeout = 10000;

  /// 响应超时时间(毫秒)
  static const int receiveTimeout = 10000;

  /// 默认请求头
  static Map<String, String> defaultHeaders = {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  };
}

步骤 3:封装 Dio 实例(核心)

新建 lib/core/net/dio_client.dart,实现 Dio 单例和拦截器:

dart

import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
import 'package:cookie_jar/cookie_jar.dart';
import 'network_config.dart';
import 'network_exception.dart'; // 后续创建自定义异常

/// Dio 单例客户端
class DioClient {
  static DioClient? _instance;
  late Dio _dio;

  /// 私有构造方法
  DioClient._internal() {
    // 初始化 Dio
    _dio = Dio(
      BaseOptions(
        baseUrl: NetworkConfig.baseUrl,
        connectTimeout: Duration(milliseconds: NetworkConfig.connectTimeout),
        receiveTimeout: Duration(milliseconds: NetworkConfig.receiveTimeout),
        headers: NetworkConfig.defaultHeaders,
        responseType: ResponseType.json,
      ),
    );

    // 添加拦截器
    _addInterceptors();
  }

  /// 获取单例
  static DioClient get instance {
    _instance ??= DioClient._internal();
    return _instance!;
  }

  /// 获取原始 Dio 实例(特殊场景使用)
  Dio get dio => _dio;

  /// 添加拦截器
  void _addInterceptors() {
    // 1. Cookie 拦截器(可选,根据业务需求)
    _dio.interceptors.add(CookieManager(CookieJar()));

    // 2. 日志拦截器(开发环境开启,生产环境关闭)
    _dio.interceptors.add(
      LogInterceptor(
        request: true, // 打印请求信息
        requestHeader: true, // 打印请求头
        requestBody: true, // 打印请求体
        responseHeader: true, // 打印响应头
        responseBody: true, // 打印响应体
        error: true, // 打印错误信息
      ),
    );

    // 3. 自定义拦截器(处理 token、请求头、响应统一解析等)
    _dio.interceptors.add(
      InterceptorsWrapper(
        // 请求拦截
        onRequest: (RequestOptions options, RequestInterceptorHandler handler) async {
          // 示例:添加 token 到请求头
          String? token = _getToken(); // 从本地缓存获取 token
          if (token != null) {
            options.headers['Authorization'] = 'Bearer $token';
          }
          handler.next(options); // 继续请求
        },

        // 响应拦截
        onResponse: (Response response, ResponseInterceptorHandler handler) {
          // 统一解析响应数据(假设接口返回格式:{code: 200, msg: "成功", data: {...}})
          Map<String, dynamic> data = response.data;
          int code = data['code'] ?? -1;
          String msg = data['msg'] ?? '请求失败';

          if (code == 200) {
            // 业务成功,只返回 data 给上层
            response.data = data['data'];
            handler.next(response);
          } else {
            // 业务错误,抛出自定义异常
            handler.reject(
              DioException(
                requestOptions: response.requestOptions,
                error: NetworkException(code: code, msg: msg),
              ),
            );
          }
        },

        // 错误拦截
        onError: (DioException e, ErrorInterceptorHandler handler) {
          // 统一处理错误(网络错误、超时、业务错误等)
          NetworkException exception = _convertDioErrorToNetworkException(e);
          handler.reject(
            DioException(
              requestOptions: e.requestOptions,
              error: exception,
              type: e.type,
              message: exception.msg,
            ),
          );
        },
      ),
    );
  }

  /// 从本地缓存获取 token(示例方法,需根据实际实现)
  String? _getToken() {
    // 示例:从 SharedPreferences 获取
    // SharedPreferences prefs = await SharedPreferences.getInstance();
    // return prefs.getString('token');
    return null;
  }

  /// 将 Dio 错误转换为自定义网络异常
  NetworkException _convertDioErrorToNetworkException(DioException e) {
    if (e.type == DioExceptionType.connectionTimeout ||
        e.type == DioExceptionType.receiveTimeout ||
        e.type == DioExceptionType.sendTimeout) {
      return NetworkException(code: -1, msg: '请求超时,请检查网络');
    } else if (e.type == DioExceptionType.connectionError) {
      return NetworkException(code: -2, msg: '网络连接失败,请检查网络');
    } else if (e.type == DioExceptionType.badResponse) {
      // HTTP 状态码错误(404、500 等)
      int statusCode = e.response?.statusCode ?? -3;
      return NetworkException(code: statusCode, msg: '服务器错误($statusCode)');
    } else if (e.error is NetworkException) {
      // 业务错误(已在响应拦截器抛出)
      return e.error as NetworkException;
    } else {
      return NetworkException(code: -999, msg: '未知错误:${e.message}');
    }
  }

  // ---------------------- 通用请求方法封装 ----------------------
  /// GET 请求
  Future<T> get<T>(
    String path, {
    Map<String, dynamic>? params,
    Options? options,
    CancelToken? cancelToken,
  }) async {
    try {
      Response response = await _dio.get(
        path,
        queryParameters: params,
        options: options,
        cancelToken: cancelToken,
      );
      return response.data as T;
    } on DioException catch (e) {
      throw e.error as NetworkException;
    }
  }

  /// POST 请求
  Future<T> post<T>(
    String path, {
    dynamic data,
    Map<String, dynamic>? params,
    Options? options,
    CancelToken? cancelToken,
  }) async {
    try {
      Response response = await _dio.post(
        path,
        data: data,
        queryParameters: params,
        options: options,
        cancelToken: cancelToken,
      );
      return response.data as T;
    } on DioException catch (e) {
      throw e.error as NetworkException;
    }
  }

  /// PUT 请求
  Future<T> put<T>(
    String path, {
    dynamic data,
    Map<String, dynamic>? params,
    Options? options,
    CancelToken? cancelToken,
  }) async {
    try {
      Response response = await _dio.put(
        path,
        data: data,
        queryParameters: params,
        options: options,
        cancelToken: cancelToken,
      );
      return response.data as T;
    } on DioException catch (e) {
      throw e.error as NetworkException;
    }
  }

  /// DELETE 请求
  Future<T> delete<T>(
    String path, {
    Map<String, dynamic>? params,
    Options? options,
    CancelToken? cancelToken,
  }) async {
    try {
      Response response = await _dio.delete(
        path,
        queryParameters: params,
        options: options,
        cancelToken: cancelToken,
      );
      return response.data as T;
    } on DioException catch (e) {
      throw e.error as NetworkException;
    }
  }

  /// 上传文件
  Future<T> upload<T>(
    String path, {
    required MultipartFile file,
    String fileName = 'file',
    Map<String, dynamic>? params,
    CancelToken? cancelToken,
    ProgressCallback? onSendProgress,
  }) async {
    try {
      FormData formData = FormData.fromMap({
        fileName: file,
        if (params != null) ...params,
      });
      Response response = await _dio.post(
        path,
        data: formData,
        onSendProgress: onSendProgress,
        cancelToken: cancelToken,
        options: Options(
          contentType: 'multipart/form-data',
        ),
      );
      return response.data as T;
    } on DioException catch (e) {
      throw e.error as NetworkException;
    }
  }
}

步骤 4:封装自定义网络异常

新建 lib/core/net/network_exception.dart,统一异常类型:

/// 自定义网络异常(区分业务错误和系统错误)
class NetworkException implements Exception {
  final int code; // 错误码(业务码/HTTP状态码/系统码)
  final String msg; // 错误信息

  NetworkException({required this.code, required this.msg});

  @override
  String toString() => 'NetworkException(code: $code, msg: $msg)';
}

步骤 5:业务层 API 封装(示例)

新建 lib/api/user_api.dart,封装具体业务接口:

dart

import 'package:your_project/core/net/dio_client.dart';
import 'package:your_project/core/net/network_exception.dart';

/// 用户相关 API
class UserApi {
  static final DioClient _dioClient = DioClient.instance;

  /// 登录接口
  static Future<Map<String, dynamic>> login({
    required String username,
    required String password,
  }) async {
    try {
      return await _dioClient.post(
        '/user/login',
        data: {
          'username': username,
          'password': password,
        },
      );
    } on NetworkException catch (e) {
      // 可在此处针对特定错误码做处理(如登录失败、账号冻结等)
      if (e.code == 401) {
        throw NetworkException(code: 401, msg: '用户名或密码错误');
      }
      rethrow; // 其他错误抛给上层处理
    }
  }

  /// 获取用户信息
  static Future<Map<String, dynamic>> getUserInfo() async {
    return await _dioClient.get('/user/info');
  }
}

三、在页面中使用

dart

import 'package:flutter/material.dart';
import 'package:your_project/api/user_api.dart';
import 'package:your_project/core/net/network_exception.dart';

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

  @override
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final TextEditingController _usernameController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();

  /// 登录按钮点击事件
  Future<void> _login() async {
    String username = _usernameController.text.trim();
    String password = _passwordController.text.trim();

    if (username.isEmpty || password.isEmpty) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('请输入用户名和密码')),
      );
      return;
    }

    try {
      // 显示加载中
      showDialog(
        context: context,
        barrierDismissible: false,
        builder: (context) => const Center(child: CircularProgressIndicator()),
      );

      // 调用登录接口
      Map<String, dynamic> userData = await UserApi.login(
        username: username,
        password: password,
      );

      // 登录成功,处理逻辑(如保存 token、跳转到首页)
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('登录成功')),
      );
      Navigator.pop(context); // 关闭加载弹窗
      // Navigator.pushReplacementNamed(context, '/home');

    } on NetworkException catch (e) {
      // 处理错误
      Navigator.pop(context); // 关闭加载弹窗
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(e.msg)),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('登录')),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            TextField(
              controller: _usernameController,
              decoration: const InputDecoration(hintText: '用户名'),
            ),
            const SizedBox(height: 16),
            TextField(
              controller: _passwordController,
              obscureText: true,
              decoration: const InputDecoration(hintText: '密码'),
            ),
            const SizedBox(height: 32),
            ElevatedButton(
              onPressed: _login,
              child: const Text('登录'),
            ),
          ],
        ),
      ),
    );
  }
}

四、进阶优化点

  1. 环境切换:通过 flutter_dotenv 管理开发 / 测试 / 生产环境的 baseUrl
  2. Token 刷新:在请求拦截器中检测 token 过期,自动刷新 token 并重试请求;
  3. 取消请求:使用 CancelToken 处理页面销毁时取消未完成的请求,避免内存泄漏;
  4. 缓存策略:结合 dio_cache_interceptor 实现 GET 请求缓存;
  5. 重试机制:添加重试拦截器,针对网络波动自动重试请求;
  6. SSL 证书校验:对接后台 HTTPS 证书,防止抓包(生产环境必备);
  7. 请求加密:对敏感请求(如登录)进行参数加密 / 签名。

五、核心优势

  1. 统一管理:所有网络配置、拦截器集中管理,修改方便;
  2. 简化调用:业务层只需关注接口参数和返回值,无需处理通用逻辑;
  3. 错误统一:所有错误转换为自定义异常,上层只需捕获 NetworkException
  4. 可扩展性:新增拦截器、修改配置不影响业务代码。

这套封装方案适配大多数 Flutter 项目的网络需求,可根据实际业务调整(如接口返回格式、token 逻辑、异常类型等)。

JavaScript类型侦探:四大神器让你一眼看穿变量真身

在JavaScript的江湖中,每个变量都有自己的"身份证",但有些家伙喜欢玩"变脸"。今天,我带大家认识四位顶尖的"类型侦探",帮你识破一切伪装!

侦探档案:四大神探各显神通

1. typeof —— 初出茅庐的原始类型专家

这位侦探最擅长处理原始类型案件,但总在null面前栽跟头,但是它判断不了引用类型案件:

let n = 123  //number 数字类型
let s = 'hello' // string 字符串类型 
let f = true  //boolean 布尔类型
let u = undefined // undefined 未定义类型
let nu = null  // object 判断失败
let sy = Symbol(1)//symbol类型
let big = 123123123n// bigint类型

let arr = [] //object 失败
let obj = {} //object 失败
let fn = function() {} // function 特例判断成功
let date = new Date() //object 判断失败
consolelog(typeof xxx)

typeof会通过将值转化为二进制来判断类型,对于二进制数据前三位是0的统一识别为对象,所有引用类型转化为二进制前三位都是0,而null转化为二进制全是0,null的机器码是全0,所以被判定为对象。

2. instanceof —— 血统鉴定师

这位侦探专查家族关系,看你是否出自"名门望族":

let n = 123
let s = 'hello'
let f = true
let u = undefined 
let nu = null
let sy = Symbol(1)
let big = 123123123n

let arr = []
let obj = {}
let fn = function() {}
let date = new Date()


console.log(arr instanceof Array);//true
console.log(arr instanceof Object); //  Array.prototype.__proto__ == Object.prototype  true

console.log(obj instanceof Object); //true
console.log(date instanceof Date);  //true
console.log(fn instanceof Function); //true

console.log(n instanceof Number);  // false
console.log(nu instanceof Object);  // false

破案原理:沿着原型链向上找,看你祖上有没有这位"祖先",很显然,这样判断不了原始类型,而且这样还不好判断自己的上一级是不是就是自己的构造函数这是因为实例对象都是new一个构造函数得到的,也就是万物皆对象。

3. Object.prototype.toString.call() —— 法医鉴定中心

最权威的鉴定机构,能给出最准确的官方报告:

let n = 123
let s = 'hello'
let f = true 
let u = undefined
let nu = null 
let sy = Symbol(1)
let big = 123123123n
let arr = []
let obj = {}
let fn = function () { }
let date = new Date() 
console.log(Object.prototype.toString.call(n));
console.log(Object.prototype.toString.call(s));
console.log(Object.prototype.toString.call(f)); 
console.log(Object.prototype.toString.call(u)); 
console.log(Object.prototype.toString.call(nu)); 
console.log(Object.prototype.toString.call(sy)); 
console.log(Object.prototype.toString.call(big)); 
console.log(Object.prototype.toString.call(arr)); 
console.log(Object.prototype.toString.call(obj)); 
console.log(Object.prototype.toString.call(fn)); 
console.log(Object.prototype.toString.call(date));

这时候的检验结果如下

屏幕截图 2025-12-05 183427.png

法医鉴定中心完美地把每个变量的类型展示出来,但是这时候你看这个打印结果就会发现不对劲,怎么前面会有一个object,这是因为这个函数会将this值作为参数传递给ToObject,设o为调用ToObject的结果, 设一个变量class为O的[[class]]内部属性值,返回一个字符串,这个字符串由'[object' +class+ ']'组成,一种结构它的内部属性[[class]]对应的值 就是 创建它的那个构造函数。 如果想要去掉的话,就要用到一个它自身就有的方法slice

function getType(x) {
  const val = Object.prototype.toString.call(x)  // '[object String]'
  const valType = val.slice(8, -1)
  return valType
}
console.log(getType(s));  // String

Object.prototype.toString.call(arr)

slice顾名思义就是直接把你想要的东西剪切出来不影响原来的东西,前面那个数就是从下标多少开始切,后面那个数就是到哪里停止,-1就代表从后往前开始切,这样就能轻松识别变量的身份。

屏幕截图 2025-12-05 184644.png

4. Array.isArray() —— 数组特派员

专门负责数组鉴定,又快又准:

console.log(Array.isArray([]));//true

总结

选择哪种类型判断方法,就像选择不同的工具:

  • 🔧 日常快速判断 → typeof
  • 🧬 检查继承关系 → instanceof
  • 🏥 需要精确诊断 → Object.prototype.toString.call()
  • 📦 专门检查数组 → Array.isArray()

记住:没有最好的,只有最合适的。下次遇到类型判断问题时,想想我们的四位侦探,选对工具,事半功倍!

4.BTC-协议-北大肖臻老师客堂笔记

概念

下面给你结合概念 + 原理解释 + 示例类比 + 在设计加密货币时的作用深度讲解版,帮助你真正理解每个概念如何共同构成“一个加密货币系统”。

内容结构如下:

  1. 每个概念都有:定义 → 为什么需要 → 例子 → 在加密货币中的作用
  2. 内容逻辑自然衔接(从“如何设计货币 → 安全 → 共识 → 分布式系统”)

📘 一、怎么设计一个加密货币(核心框架)

设计一套加密货币,你必须解决 3 个根问题:

  1. 谁有权发行?(发行权)
  2. 怎么验证交易?(合法性)
  3. 怎么达成共识?(全网同步、防攻击)

因此,设计币必须包含:

  • 货币发行规则(coinbase / genesis / monetary policy)
  • 去中心化账本(区块链)
  • 防双花机制
  • 分布式共识机制(PoW / PoS)
  • 交易验证逻辑(签名、UTXO、余额检查)
  • 节点的通信与抗攻击机制

下面逐一拆解概念。


📗 二、谁有权发行数字货币

1️⃣ 传统货币(中心化)

央行发行。

2️⃣ 加密货币(去中心化)

发行权由 数学规则 决定:

  • 比特币发行者:矿工(通过挖矿)
  • 块奖励由协议写死(2110万上限)
  • 新币来自 coinbase transaction(铸币交易)

➡️ 结论:发行权不是人决定,是协议决定。


📘 三、怎么防止 Double Spending Attack(双花攻击)

双花:同一笔钱被花两次。

例如:

Alice 有 1 BTC 她同时向 Bob 和 Carol 各发 1 BTC 希望网络不同步导致都确认成功。

BTC 的防御:

① UTXO 模型

每个输出只能花一次,节点会检查:

该UTXO是否已被花过?

② 工作量证明 + 最长链规则

攻击者必须:

  • 重写历史
  • 超过全网算力
  • 重新挖出更长链

难度极高 → 成本巨大 → 无法双花


📙 四、怎么验证交易合法性

验证流程(比特币):

① 签名验证(身份合法)

检查:

交易输入引用的公钥 + 签名 是否匹配?

谁的币 → 谁的私钥 → 才能花。

② UTXO 是否存在(余额合法)

输入引用的UTXO必须存在且未花费。

③ 输入金额 ≥ 输出金额(无凭空造币)

④ Script 脚本(锁定/解锁逻辑)


📘 五、铸币交易(Coinbase Transaction)

这是 每个区块的第一笔交易,由矿工创建。

包含:

  • 区块奖励(新币发行)
  • 交易手续费(来自区块内其他交易)

示例:

coinbase:
  input: no previous output
  output: miner's address + block reward

→ 新币产生机制 → 挖矿收入来源


📗 六、Distributed Consensus(分布式共识)

定义: 在没有中心服务器的情况下,使整个网络对“世界状态”达成一致。

区块链中的一致内容包括:

  • 谁的余额是多少?
  • 哪些交易有效?
  • 哪个区块是最新的?

BTC 使用:

PoW + longest chain rule

  • 最难挖的链 = 最可信
  • 避免双花
  • 抗恶意节点

📘 七、Distributed Hash Table(DHT)

不是比特币核心,但常用于 P2P 网络和区块链项目。

定义: 一种分布式存储结构,用哈希定位数据位置。

例如:

BitTorrent 查找文件块 Filecoin 存储文件索引

作用:

  • 数据分散存
  • 无中心
  • 高扩展性

📕 八、CAP 定理(Consistency / Availability / Partition tolerance)

分布式系统的三大属性:

  1. Consistency(强一致性) 所有节点看到同一份数据。

  2. Availability(可用性) 节点持续可以响应请求。

  3. Partition Tolerance(分区容忍性) 网络分裂时仍可运行。

⚠️ 定理:三者不可同时满足,只能满足其中两个。

区块链选择:

AP → 最终一致性(eventual consistency)

因为:

  • 区块传播有延迟
  • 全网只能做到最终同步

📘 九、Paxos(传统共识算法)

Paxos 是分布式系统中解决一致性的经典算法。

  • 专为少量节点设计(一般 3–7 台)
  • 用于银行、数据库(Google、Zookeeper)

⚠️ 并不适用于加密货币:

  • 要求身份明确(区块链是匿名的)
  • 要求节点数量不多(区块链节点多且不可信)

📙 十、Membership(分布式系统中的成员管理)

指:

  • 哪些节点是合法成员?
  • 如何加入?
  • 如何踢出作恶节点?

传统系统:Paxos、Raft 都需要 membership control

比特币:

谁都能加入,不需要许可(permissionless)。

Membership 是公开开放的。


📘 十一、BTC 的共识机制是什么?

Proof of Work(工作量证明) + 最长链(Nakamoto Consensus)

核心:

  1. 挖矿 = 投入算力证明诚实
  2. 最长链视为“真实历史”
  3. 攻击者要篡改历史必须超过全网算力(几乎不可能)

📙 十二、Sybil Attack(女巫攻击)

定义: 攻击者创建大量虚假身份来控制系统。

例如:

  • 制造 1 万个节点试图影响共识
  • 在投票系统中伪造大量身份

区块链如何防御?

比特币:用 PoW 抗 Sybil

身份无意义 → 算力才是身份

你开 1000 个节点 = 你只是把自己的算力拆成 1000 份 = 总算力不变 = 没法影响共识

→ 这是 Nakamoto 共识的一个核心优势。


📌 总结(极简)

概念 问题 解决方式
发行权 谁能印钱? Coinbase(矿工)
双花 怎么防止一币两花? PoW + 最长链 + UTXO
交易验证 如何判断交易有效? 签名 + UTXO + Script
共识 全网怎么看法一致? PoW(Nakamoto Consensus)
Sybil 攻击 虚假身份攻击 算力证明(PoW)
CAP 分布式系统限制 区块链选择 AP

课程内容总结

这一讲《04-BTC-协议》主要讲的是“比特币到底是如何运行的”,也就是点对点电子现金系统的完整协议:节点需要遵守哪些规则、交易和区块如何在网络中传播、如何达成“哪条链是正确的”这一共识等。

协议整体框架

课程先从“什么叫协议”入手,把比特币协议拆成几个部分:节点规则、交易规则、区块规则、共识规则和网络传播规则。 目标是让你明白:任何一个想加入比特币网络的节点,只要实现并遵守这些公开规则,就能和全网自动达成一致,而不需要信任中心机构。

节点与角色

老师会区分几类典型节点角色:全节点(保存完整区块链并严格验证所有交易和区块)、轻节点/SPV 节点(只保存区块头,通过 Merkle 证明验证交易)、矿工节点(在全节点基础上额外参与出块和挖矿)。 不同节点在协议中的职责不同,但在“验证规则”上是一致的:不论是谁发来的区块或交易,只要不满足协议规则,就直接丢弃。

  • 全节点负责:校验每个交易的签名、余额是否足够、脚本是否执行通过,以及区块难度、时间戳、大小等是否符合规范。
  • 轻节点通过向全节点请求区块头和 Merkle 路径,来验证“某交易是否被某区块确认”,不需要保存全部历史数据。

交易验证规则

课程详细说明比特币中的“合法交易”必须满足的条件,例如:所有输入都引用现有未花费输出、签名正确、没有双花、输入金额大于等于输出金额(差额为手续费)、脚本执行结果为真等。 这样任何节点只要收到一笔新交易,就能本地独立判断它是否有效,而不是靠别人说“这笔是对的”。

  • 例子:如果某人试图用同一个 UTXO 同时给两个人转账,那么网络上最终只会有一笔交易成功进区块链,另一笔在后续验证中会被节点判定为“引用已花费输出”而被拒绝。
  • 交易费的规则也在协议里写死:输入总额减去输出总额就是矿工可获得的手续费,若不符合(比如输出之和大于输入),节点会直接判定交易无效。

区块与共识规则

在区块层面,协议规定了:区块头字段必须符合格式、区块中所有交易都要逐笔验证、区块大小有上限、首笔交易必须是 coinbase 交易且奖励金额不能超过当前补贴加手续费之和、区块哈希必须小于当前难度目标等。 每个节点只要本地验证通过,才会接受该区块并接着在其上继续挖矿或同步,验证不过就丢弃,从而保证“错误区块无法扩散”。

  • 协议还规定了“最长链(精确说是累计工作量最大链)原则”:当存在多条合法链分叉时,节点要选择累计难度最高的那条作为当前主链,将余额状态等都以这条链为准。
  • 这意味着短期内可能会出现临时分叉,但随着后续新区块叠加,最终只有一条链会“赢”,另一条会被视为孤块链,被网络逐渐抛弃。

网络与消息传播

最后,课程讲解 P2P 网络层面的协议:节点如何发现其他节点、如何广播交易和区块、如何防止网络被垃圾消息淹没等。 比特币使用的是去中心化的点对点网络,每个节点与若干“邻居”保持连接,收到新交易或区块后会进行验证,再向其他邻居转发,从而实现全网扩散。

  • 为了减少带宽浪费,协议设计了类似“先发哈希、再要正文”的消息交互方式:节点先告诉别人“我这里有哪些新对象的哈希”,对方只会请求自己缺少的那部分数据。
  • 课程也会提醒:由于网络是开放的,任何节点都可能不诚实,所以协议的设计必须让“只相信自己能验证的结果”成为默认行为,这样整体系统才能在敌对环境中依然保持一致性和安全性。 在这里插入图片描述

Next.js 16 Page Router 国际化 🌐

Next.js 16 Page Router 国际化 🌐

引言

在现代 Web 应用开发中,国际化(i18n)已经成为一个必备功能。传统的 Next.js 国际化方案通常采用 URL 前缀方式(如 /en/page/zh-CN/page),这种方式虽然实现简单,但存在一些明显的问题:

  1. URL 频繁变更:用户切换语言时,页面 URL 会发生变化
  2. SEO 分散:相同内容分散在不同 URL 下,影响搜索引擎优化
  3. 用户体验不佳:复制链接时需要考虑语言前缀

那么,有没有一种方式可以在不改变 URL 的情况下实现国际化呢?答案是肯定的!本文将详细介绍我在 Next.js 16 项目中实现的无 URL 变更的国际化方案,采用浏览器缓存 + Cookie 机制管理语言切换,保持 URL 稳定的同时提供流畅的国际化体验。

技术栈

技术 版本 用途
Next.js 16.0.7 前端框架
React 19.2.0 UI 库
TypeScript 5.5.4 类型系统
next-i18next 15.4.3 国际化核心库
react-i18next 16.3.5 React 国际化集成
js-cookie 3.0.5 Cookie 管理
ahooks 3.9.6 React Hooks 工具库

项目结构

src/
├── components/            # 组件目录
│   └── I18nLngSelector.tsx  # 语言选择器组件
├── i18n/                 # 国际化配置目录
│   ├── hooks/            # 自定义 Hooks
│   │   └── useI18n.ts    # 语言切换钩子
│   ├── locales/          # 翻译资源文件
│   │   ├── en/           # 英文翻译
│   │   │   ├── common.json       # 通用翻译
│   │   │   └── index_page.json   # 首页翻译
│   │   └── zh-CN/        # 中文翻译
│   │       ├── common.json       # 通用翻译
│   │       └── index_page.json   # 首页翻译
│   ├── type.ts           # TypeScript 类型定义
│   └── i18next.d.ts      # 类型声明文件
└── pages/                # 页面目录
    ├── _app.tsx          # 应用入口(语言初始化)
    └── index.tsx         # 首页

核心实现

1. next-i18next 配置

首先,我们需要配置 next-i18next,创建 next-i18next.config.js 文件:

// next-i18next.config.js
// @ts-check

/**
 * @type {import('next-i18next').UserConfig}
 */
module.exports = {
  // 开发环境下启用调试模式
  debug: process.env.NODE_ENV === 'development',
  // 国际化配置
  i18n: {
    // 默认语言
    defaultLocale: 'zh-CN',
    // 支持的语言列表
    locales: ['zh-CN', 'en'],
    // 禁用自动语言检测,使用自定义逻辑
    localeDetection: false,
  },
  // 语言资源文件路径
  localePath: './src/i18n/locales',
  // 开发环境下在预渲染时重新加载语言资源
  reloadOnPrerender: process.env.NODE_ENV === 'development',
}

配置说明

  • debug: true:开发环境下启用调试模式,便于开发调试
  • defaultLocale: 'zh-CN':设置默认语言为中文
  • locales: ['zh-CN', 'en']:配置支持的语言列表
  • localeDetection: false:禁用自动语言检测,使用自定义的语言检测和切换逻辑
  • localePath: './src/i18n/locales':指定语言资源文件的存放路径

2. 自定义语言切换钩子

核心逻辑在于自定义的 useI18nLng 钩子,它负责处理语言的存储、切换和初始化:

// src/i18n/hooks/useI18n.ts
import { useTranslation } from 'next-i18next';
import { LangEnum } from '@/i18n/type';
import Cookies from "js-cookie";

// 语言存储的键名
const LANG_KEY = 'NEXT_LOCALE';

/**
 * 检查当前是否在 iframe 中
 */
const isInIframe = () => {
  try {
    return window.self !== window.top;
  } catch (e) {
    return true; // 发生异常时默认认为在 iframe 中
  }
};

/**
 * 设置语言到存储中
 */
const setLang = (value: string) => {
  if (isInIframe()) {
    // 在 iframe 中只使用 localStorage
    localStorage.setItem(LANG_KEY, value);
  } else {
    // 不在 iframe 中,同时使用 Cookie 和 localStorage
    Cookies.set(LANG_KEY, value, { expires: 30 }); // Cookie 有效期30天
    localStorage.setItem(LANG_KEY, value);
  }
};

/**
 * 从存储中获取语言
 */
const getLang = () => {
  return localStorage.getItem(LANG_KEY) || Cookies.get(LANG_KEY);
};

/**
 * 自定义 i18n 语言切换钩子
 */
export const useI18nLng = () => {
  // 获取 i18n 实例
  const { i18n } = useTranslation();
  
  // 语言映射表,确保语言代码的一致性
  const languageMap: Record<string, string> = {
    'zh-CN': LangEnum.zh_CN,
    en: LangEnum.en,
  };

  /**
   * 切换语言的方法
   */
  const onChangeLng = async (lng: string) => {
    // 确保语言代码的正确性
    const lang = languageMap[lng] || 'en';
    const prevLang = getLang();

    // 将语言保存到存储中
    setLang(lang);

    // 调用 i18n 实例切换语言
    await i18n?.changeLanguage?.(lang);

    // 如果没有资源包且语言发生了变化,则刷新页面
    if (!i18n?.hasResourceBundle?.(lang, 'common') && prevLang !== lang) {
      window?.location?.reload?.();
    }
  };

  /**
   * 设置用户默认语言
   */
  const setUserDefaultLng = (forceGetDefaultLng: boolean = false) => {
    // 确保在浏览器环境中运行
    if (!navigator || !localStorage) return;

    // 如果已经有存储的语言且不是强制获取,则使用存储的语言
    if (getLang() && !forceGetDefaultLng) return onChangeLng(getLang() as string);

    // 获取浏览器语言并映射到支持的语言
    const lang = languageMap[navigator.language] || 'en';

    // 切换到获取的语言
    return onChangeLng(lang);
  };

  // 返回钩子方法
  return {
    onChangeLng,  // 语言切换方法
    setUserDefaultLng // 设置默认语言方法
  };
};

核心亮点

  • 双重存储机制:同时使用 localStorage 和 Cookie 存储语言选择,确保在不同场景下都能正确获取
  • iframe 兼容性:检测是否在 iframe 中运行,针对性处理存储方式
  • 智能语言切换:切换语言时先检查是否有资源包,避免因资源缺失导致的错误
  • 浏览器语言检测:首次访问时根据浏览器语言自动设置默认语言

3. 应用入口初始化

_app.tsx 中实现默认语言的初始化,确保页面刷新后能保持用户的语言选择:

// src/pages/_app.tsx
// 导入应用组件类型定义
import type { AppProps } from 'next/app'
// 导入 i18n 应用包装组件
import { appWithTranslation } from 'next-i18next'
// 导入自定义的 i18n 语言钩子
import { useI18nLng } from '@/i18n/hooks/useI18n'
// 导入 React 副作用钩子
import { useEffect } from 'react'

/**
 * 主应用组件,所有页面的容器组件
 * @param Component 当前渲染的页面组件
 * @param pageProps 页面属性和初始数据
 */
const MyApp = ({ Component, pageProps }: AppProps) => {
  // 获取设置默认语言的方法
  const { setUserDefaultLng } = useI18nLng()

  // 组件挂载时设置默认语言
  useEffect(() => {
    setUserDefaultLng()
  }, [])

  // 渲染当前页面组件
  return <Component {...pageProps} />
}

// 使用 i18n 包装应用组件,提供国际化功能
export default appWithTranslation(MyApp)

初始化流程

  1. 应用启动时,组件挂载
  2. 调用 setUserDefaultLng() 方法
  3. 检查是否有存储的语言设置
  4. 如果有,使用存储的语言;如果没有,根据浏览器语言设置默认语言
  5. 确保用户每次访问时都能看到自己选择的语言

4. 语言选择器组件

创建一个语言选择器组件,让用户可以方便地切换语言:

// src/components/I18nLngSelector.tsx
// 导入自定义的 i18n 语言钩子
import { useI18nLng } from '@/i18n/hooks/useI18n';
// 导入 i18n 翻译钩子
import { useTranslation } from 'next-i18next';
// 导入 React 记忆化钩子
import { useMemo } from 'react';
// 导入语言映射表
import { langMap } from '@/i18n/type';

/**
 * 语言选择器组件
 * 提供UI界面让用户切换应用语言
 */
const I18nLngSelector = () => {
  // 获取 i18n 实例
  const { i18n } = useTranslation();
  // 获取语言切换方法
  const { onChangeLng } = useI18nLng();

  // 记忆化处理语言列表,避免重复计算
  const list = useMemo(() => {
    return Object.entries(langMap).map(([key, lang]) => ({
      label: lang.label, // 显示标签
      value: key         // 语言代码值
    }));
  }, []);

  return (
    // 语言选择下拉框
    <select 
      value={i18n.language} // 当前选中的语言
      onChange={(e) => onChangeLng(e.target.value)} // 语言变更处理
    >
      {/* 渲染语言选项列表 */}
      {list.map((item) => (
        <option key={item.value} value={item.value}>
          {item.label}
        </option>
      ))}
    </select>
  );
};

// 导出语言选择器组件
export default I18nLngSelector;

组件特点

  • 使用原生 select 元素,简洁高效
  • 绑定当前语言状态,确保 UI 与实际语言一致
  • 调用自定义的 onChangeLng 方法处理语言切换
  • 支持多语言显示语言选项

5. 类型定义

为了提供更好的类型安全,我们需要定义相关的 TypeScript 类型:

// src/i18n/type.ts
// 导入语言资源文件
import { resources } from "./resources";

/**
 * 国际化字符串类型定义
 * 要求必须提供中文,英文为可选
 */
export type I18nStringType = {
  'zh-CN': string; // 中文简体
  en?: string;     // 英文(可选)
};

/**
 * 语言枚举类型
 * 定义支持的语言代码
 */
export enum LangEnum {
  'zh_CN' = 'zh-CN', // 中文简体
  'en' = 'en'        // 英文
}

/**
 * 语言类型,基于LangEnum的字符串类型
 */
export type localeType = `${LangEnum}`;

/**
 * 支持的语言列表常量
 */
export const LocaleList = ['en', 'zh-CN'] as const;

/**
 * 语言映射表,用于UI显示
 */
export const langMap = {
  [LangEnum.en]: {
    label: 'English(US)', // 英文显示名称
  },
  [LangEnum.zh_CN]: {
    label: '简体中文',     // 中文显示名称
  }
};

/**
 * 国际化命名空间类型,基于resources的类型
 */
export type I18nNamespaces = typeof resources;

/**
 * 国际化命名空间数组类型
 */
export type I18nNsType = (keyof I18nNamespaces)[];

类型安全优势

  • 避免拼写错误:使用枚举和类型定义确保语言代码的正确性
  • 智能提示:在使用翻译键时提供自动补全
  • 类型检查:在编译时就能发现翻译资源的错误使用

翻译资源文件示例

中文翻译

// src/i18n/locales/zh-CN/common.json
{
  "change-locale": "切换到 \"{{changeTo}}\" 语言",
  "welcome": "欢迎使用 Next.js 国际化方案"
}

英文翻译

// src/i18n/locales/en/common.json
{
  "change-locale": "Change locale to \"{{changeTo}}\"",
  "welcome": "Welcome to Next.js i18n Solution"
}

首页翻译资源

// src/i18n/locales/zh-CN/index_page.json
{
  "title": "next-i18next 示例"
}
// src/i18n/locales/en/index_page.json
{
  "title": "next-i18next example"
}

翻译资源管理

为了更好地管理翻译资源,我们可以创建一个 resources.ts 文件来集中导入和导出所有翻译资源:

// src/i18n/resources.ts
// 导入英文的通用语言资源
import common from './locales/en/common.json';
// 导入英文的首页语言资源
import indexPage from "./locales/en/index_page.json";

/**
 * 语言资源导出
 * 定义应用中使用的所有国际化命名空间
 */
export const resources = {
  common,         // 通用语言资源
  'index_page': indexPage,  // 首页语言资源
} as const;

类型定义增强

为了提供更好的 TypeScript 类型支持,我们可以创建 i18next.d.ts 文件来扩展 i18next 的类型定义:

// src/i18n/i18next.d.ts
/**
 * If you want to enable locale keys typechecking and enhance IDE experience.
 *
 * Requires `resolveJsonModule:true` in your tsconfig.json.
 *
 * @link https://www.i18next.com/overview/typescript
 */
import 'i18next'

// resources.ts file is generated with `npm run toc`
import { I18nNamespaces } from './type'

declare module 'i18next' {
  interface CustomTypeOptions {
    defaultNS: 'common'
    resources: I18nNamespaces
  }
}

开发体验优化:i18n-ally 插件

为了提升国际化开发体验,我们可以使用 i18n-ally 插件,它提供了实时翻译预览、自动补全、错误检查等功能。

.vscode/settings.json 中配置:

{
  "i18n-ally.localesPaths": "src/i18n/locales",
  "i18n-ally.enableNamespace": true,
  "i18n-ally.pathMatcher": "{locale}/{namespace}.json"
}

插件优势

  • 实时预览:在编辑器中直接看到翻译结果
  • 自动补全:输入翻译键时提供智能提示
  • 错误检查:检测缺失的翻译键和格式错误
  • 批量操作:方便地管理和同步翻译资源

国际化功能使用指南

1. 在页面中使用翻译

在 Next.js 页面中,我们可以使用 useTranslation 钩子来获取翻译函数:

// src/pages/index.tsx
import { useTranslation } from 'next-i18next'

export default function Page() {
  // 获取翻译函数
  const { t } = useTranslation()
  
  return (
    <>
      {/* 使用翻译 */}
      <h1>{t('welcome')}</h1>
    </>
  )
}

2. 多命名空间处理

对于大型项目,我们可以使用多个命名空间来组织翻译资源。例如,首页使用 index_page 命名空间:

// src/pages/index.tsx
import { useTranslation } from 'next-i18next'

export default function Page() {
  // 获取翻译函数
  const { t } = useTranslation()
  
  return (
    <>
      {/* 使用index_page命名空间的翻译 */}
      <h1>{t('title', { ns: 'index_page' })}</h1>
    </>
  )
}

3. 服务端翻译属性获取

为了确保服务端渲染时能正确获取翻译资源,我们需要在页面中定义 getStaticPropsgetServerSideProps 函数:

// src/pages/index.tsx
// 导入静态属性类型定义
import { GetStaticProps } from 'next'
// 导入自定义的服务端翻译属性获取函数
import { serviceSideProps } from '@/i18n/utils'

export const getStaticProps: GetStaticProps = async (context) => ({
  props: {
    // 获取服务端翻译属性,包含common和index_page命名空间
    ...(await serviceSideProps(context, ['common', 'index_page'])),
  },
})

服务端翻译工具函数实现:

// src/i18n/utils.ts
// 导入国际化命名空间类型
import { type I18nNsType } from '@/i18n/type';
// 导入服务端翻译函数
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';

/**
 * 获取服务端翻译属性的自定义函数
 * @param content 上下文对象,包含请求、响应等信息
 * @param ns 需要加载的国际化命名空间数组
 * @returns 包含翻译资源的属性对象
 */
export const serviceSideProps = async (content: any, ns: I18nNsType = []) => {
  // 从 Cookie 或上下文获取当前语言
  const lang = content.req?.cookies?.NEXT_LOCALE || content.locale;
  // 如果有 Cookie 中的语言,则不需要额外语言,否则使用上下文中的所有语言
  const extraLng = content.req?.cookies?.NEXT_LOCALE ? undefined : content.locales;

  // 从 Cookie 获取设备尺寸信息
  const deviceSize = content.req?.cookies?.NEXT_DEVICE_SIZE || null;

  return {
    // 获取服务端翻译资源,默认包含 common 命名空间
    ...(await serverSideTranslations(lang, ['common', ...ns], undefined, extraLng)),
    // 传递设备尺寸信息
    deviceSize
  };
};

4. 变量插值的使用

翻译资源支持变量插值,我们可以在翻译字符串中使用占位符:

// 翻译资源文件
{
  "change-locale": "切换到 \"{{changeTo}}\" 语言"
}

在组件中使用:

const { t } = useTranslation()

// 带变量的翻译调用
<p>{t('common:change-locale', { changeTo: 'English' })}</p>

5. 完整使用示例

// src/pages/index.tsx
// 导入语言选择器组件
import I18nLngSelector from '@/components/I18nLngSelector'
// 导入自定义的服务端翻译属性获取函数
import { serviceSideProps } from '@/i18n/utils'
// 导入静态属性类型定义
import { GetStaticProps } from 'next'
// 导入翻译钩子
import { useTranslation } from 'next-i18next'

/**
 * 首页组件
 */
export default function Page() {
  // 获取翻译函数
  const { t } = useTranslation()
  
  return (
    <>
      {/* 翻译后的标题,使用index_page命名空间 */}
      <h1>{t('title', { ns: 'index_page' })}</h1>
      {/* 语言选择器 */}
      <I18nLngSelector />
    </>
  )
}

/**
 * 静态属性生成函数
 * 用于在构建时获取翻译资源
 */
export const getStaticProps: GetStaticProps = async (context) => ({
  props: {
    // 获取服务端翻译属性,包含common和index_page命名空间
    ...(await serviceSideProps(context, ['common', 'index_page'])),
  },
})

实现原理总结

1. 语言存储机制

采用浏览器缓存 + Cookie的双重存储机制:

  • localStorage:用于客户端持久化存储用户的语言选择
  • Cookie:用于服务端渲染时获取用户的语言偏好

2. 语言切换流程

  1. 用户点击语言选择器
  2. 调用 onChangeLng 方法
  3. 将选择的语言保存到 localStorage 和 Cookie 中
  4. 调用 i18n 实例的 changeLanguage 方法切换语言
  5. 检查是否有对应的语言资源包
  6. 如果没有资源包且语言发生了变化,则刷新页面确保资源加载

3. 默认语言设置

  1. 应用启动时,调用 setUserDefaultLng 方法
  2. 检查是否有存储的语言设置
  3. 如果有,使用存储的语言
  4. 如果没有,根据浏览器语言自动设置默认语言
  5. 确保用户每次访问时都能看到一致的语言界面

注意事项和最佳实践

  1. Cookie 依赖:确保服务器环境支持 Cookie,以便在服务端渲染时获取用户的语言偏好

  2. 服务端渲染:在服务端渲染时,需要从 Cookie 中获取语言设置,确保首次渲染的语言正确

  3. 缓存策略:注意语言切换后的缓存处理,避免出现缓存导致的语言不一致问题

  4. 多命名空间管理:对于大型项目,建议使用多命名空间管理翻译资源,提高可维护性

  5. 类型安全:充分利用 TypeScript 的类型系统,确保翻译资源的正确使用

  6. 开发工具:使用 i18n-ally 等工具提升开发体验,减少手动编写翻译的错误

总结和展望

本文详细介绍了在 Next.js 16 项目中实现无 URL 变更的国际化方案,主要包括:

  1. 核心实现:使用 next-i18next 作为基础,自定义语言切换钩子处理语言存储和切换逻辑

  2. 创新点:采用浏览器缓存 + Cookie 机制管理语言切换,不需要 URL 前缀,保持 URL 稳定

  3. 用户体验:实现了语言设置的持久化,确保页面刷新后不会丢失用户的语言选择

  4. 开发体验:使用 TypeScript 提供类型安全,结合 i18n-ally 插件提升开发效率

这个国际化方案解决了传统 URL 前缀方式的问题,提供了更好的用户体验和 SEO 效果。未来可以考虑:

  • 支持更多语言的动态加载
  • 实现翻译资源的自动同步和管理
  • 提供更多的语言切换动画和交互效果

希望本文的实现方案能够帮助到正在寻找 Next.js 国际化解决方案的开发者们,也欢迎大家提出宝贵的意见和建议!

项目地址

GitHub 仓库


如果觉得这篇文章对你有帮助,欢迎点赞、评论和分享!👍

#Next.js #国际化 #i18n #前端开发 #TypeScript

前端文本分割工具,“他”来了

大家好,我是CC,在这里欢迎大家的到来~

简介

在日常开发场景中大多数是使用空字符串、空格或者换行符来进行文本分割。现在可以试试新的分割工具-Intl.Segmenter

Intl.Segmenter 支持根据语言进行的文本分割,将一个字符串分割成片段,分割类型包括字、词和句。

试试分割效果

以简体中文为例,这里我们先设置按照词(word)分割:

按词分割-多语言分词

const segmenter = new Intl.Segmenter("zh-Hans-CN", { granularity: "word" });
const string = "前端文本分割工具,“他”来了";

const iterator = segmenter.segment(string)[Symbol.iterator]();

for(let item of iterator) {
  console.log(item.segment);
}
// 前端
// 文本
// 分割
// 工具
// ,
// “
// 他
// ”
// 来
// 了

效果还不错,有点类似词义分割了。再看看其他分割方式。

按字分割(默认)-处理复杂字符

在简体中文场景下效果与'前端文本分割工具,“他”来了'.split("")相同。

const segmenter = new Intl.Segmenter("zh-Hans-CN", { granularity: "grapheme" });
const string = "前端文本分割工具,“他”来了";

const iterator = segmenter.segment(string)[Symbol.iterator]();

for(let item of iterator) {
  console.log(item.segment);
}
// 前
// 端
// 文
// 本
// 分
// 割
// 工
// 具
// ,
// “
// 他
// ”
// 来
// 了

按句分割-多语言句子分析

加了分号、感叹号、问号、句号这些标点符号,还是可以完整分割出来的。

const segmenter = new Intl.Segmenter("zh-Hans-CN", { granularity: "sentence" });
const string = "前端文本分割工具,“他”来了,前端文本分割工具,“他”来了;前端文本分割工具,“他”来了。前端文本分割工具,“他”来了,前端文本分割工具,“他”来了;前端文本分割工具,“他”来了!前端文本分割工具,“他”来了,前端文本分割工具,“他”来了;前端文本分割工具,“他”来了?";

const iterator = segmenter.segment(string)[Symbol.iterator]();

for(let item of iterator) {
  console.log(item.segment);
}
// 前端文本分割工具,“他”来了,前端文本分割工具,“他”来了;前端文本分割工具,“他”来了。
// 前端文本分割工具,“他”来了,前端文本分割工具,“他”来了;前端文本分割工具,“他”来了!
// 前端文本分割工具,“他”来了,前端文本分割工具,“他”来了;前端文本分割工具,“他”来了?

完整数据格式

实际上分割出来的每个单位都是一个 JSON 对象,除了 segment 代表文本内容外,还有 index 代表索引、input 表示原文本完整内容、isWordLike 表示是否像文本单词(如果是标点符号类型的就会是 false)。

{
    "segment": "前端",
    "index": 0,
    "input": "前端文本分割工具,“他”来了",
    "isWordLike": true
}

分割的参数

locales

当然除了简体中文,Intl.Segmenter 也支持其他语言。

Intl.Segmenter 的第一个参数 locales 支持填写带有 BCP 47 语言区域标记的一个字符串,或者一个这样的字符串数组。

在 BCP 47 中表示语言、脚本、国家(区域)和变体(少用)的语言子标记含义可以在IANA 语言子标记注册 中找到。

localeMatcher

Intl.Segmenter 的第二个参数中除了可以选择如何分割外,还可以根据 ****locales ****在 ****lookup ****和 ****best fit ****之间选择一个匹配算法来配置 localeMatcher 参数。

best fit

默认值,运行时可能会选择一个可能比查找算法的结果更加合适的语言区域。

lookup

使用 BCP47 查找算法从 locales 参数中选择语言区域。像如果运行时支持 "de" 但不支持 "de-CH",用户传入的 "de-CH" 可能就会以 "de" 为结果进行使用。

分割对象的方法

分割字符串

上文中对分割文本对象进行分割的 segment 方法。

判断返回支持的 locale

在给定的 locales 数组中判断出 Segmenter支持的 locales。但是可能每个浏览器支持的不大一样。

const locales = ["ban", "id-u-co-pinyin", "de-ID"];
const options = { localeMatcher: "lookup" };
console.log(Intl.Segmenter.supportedLocalesOf(locales, options));
// ["id-u-co-pinyin", "de-ID"]

获取分割对象的配置参数

const spanishSegmenter = new Intl.Segmenter("es", { granularity: "sentence" });
const options = spanishSegmenter.resolvedOptions();

console.log(options.locale); // "es"
console.log(options.granularity); // "sentence"

在参数不支持的情况下会取当前运行环境中默认语言环境。

const banSegmenter = new Intl.Segmenter("ban");
const options = banSegmenter.resolvedOptions();

console.log(options.locale); // "zh-CN"
console.log(options.granularity); // "grapheme"

总结

Intl.Segmenter 很适合在文本处理场景下使用,给了一定的分割标准。它在文本编辑器中计算光标索引、搜索建议的生成、文本计算长度和文本过滤,甚至在自然语言处理场景中都可以使用到。

目前在 fabric.js 中也在考虑使用 Intl.Segmenter 来优化文本分割,可以研究学习。

大家可能也注意到了,Segmenter 只是 Intl 下的一个对象,还有针对像数字、复数、日期、地区等国际转化,这些后续去研究。

nvm安装node低版本失败-解决方案

最近接手了一个新的前端项目需要进行二开,结果发现前端使用的node版本比较高,跟我系统安装的node版本不一致。需要安装node v20.x 以上版本才可以正常运行。但是我本地很多前端项目 比较老使用的node版本都是V14.X。为了解决这个问题在网上查找到nvm 版本管理工具,可以解决灵活切换版本的问题。于是就卸载了本地的nodejs,安装了nvm版本管理工具。于是开始了踩坑之路。 安装nvm很容易,网上一搜有大把的教程,注意不要有空格,最好是下划线也不要有吧(这个不太确定是否会影响)。安装好后,很容易的安装了node v20.X版本,可以顺利的运行新接手的前端项目了。但是很快遇到一个问题,我在运行自己的老项目的时候,需要安装node V14.X版本,让后经过不断的尝试,最低只能安装到V16.20.2版本,低于这个版本号的node就无法安装了,有的也可以安装node但是npm又会遇到问题。问题截图:

nvm1.png

C:\Users\Administrator>nvm install 14.18.0
Downloading node.js version 14.18.0 (64-bit)...
Complete
Downloading npm...
Creating C:\Users\Administrator\AppData\Local\Temp\nvm-install-3583324651\temp

Downloading npm version 6.14.15... Complete
Installing npm v6.14.15...
error installing 14.18.0: open C:\Users\Administrator\AppData\Local\Temp\nvm-npm-4192048246\npm-v6.14.15.zip: The system cannot find the file specified.

根据这个错误提示查了很久,给出以下两个解决方案:

方案一:卸载1.2.X版本nvm安装1.1.0nvm

在谷歌的强力搜索下,发现很多ITer反馈,nvm1.2.X版本有bug,nvm开发者也确认了这是个bug,但是下个版本才会修复。也就是说解决办法是: 彻底卸载删除nvm 1.2.X版本,安装老的版本,网上搜索结果说最少要推倒nvm的V1.2.0版本才会解决低版本node无法安装的bug。

我是第一次使用nvm,让后去查怎么彻底卸载,需要删除环境变量,删除目录,删除……感觉一堆删除很麻烦,懒癌瞬间发作,不想卸载重新安装,就冒着浪费时间丢掉效率的风险继续搜索解决办法。

方案二: 手动安装对应版本node

第一步:访问node版本仓库:Index of /download/release/下载你需要的版本node压缩包。网址(https://nodejs.org/download/release/

nvm2.png

第二步:下载需要的node版本压缩包(node-v14.17.0-win-x64.zip

nvm3.png

第三步:把下载的node node-v14.17.0-win-x64.zip 压缩包文件全部解压到nvm的安装目录,注意要新建一个V14.17.0文件夹喔!!!!

nvm6.png

经过以上步骤,低版本node安装不了的问题就解决了。实际测试是可以正常运行项目的。同理,其他低版本无法使用nvm install 12.X等都可以通过这个办法安装。

以上就是经过大半天索索出来的解决办法。当然有参考网上的资料,把这些资料整合到一起,写下这篇文章却是我的原创啊。转载请注明出处。

❌