普通视图

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

可信实验白皮书系列07:高阶实验工具

本文系《可信实验白皮书》系列的第七篇文章。上一篇我们重点介绍了观察性研究,内容主要包括合成控制法、匹配方法、Causal Impact等几个方面。针对单次实验功效不足、假阳性、策略调优等实验中面临的问题,本篇我们来介绍几个高阶实验工具,重点解读了统合分析、多重比较,同时还有一些拓展与展望。

开源 | MeiGen-MultiTalk:基于单张照片实现多人互动演绎

近日,美团推出了音频驱动的多人对话视频生成框架MultiTalk,并在GitHub上开源,首创L-RoPE绑定技术,通过标签旋转位置编码精准解决多音频流与人物错位难题。该框架创新性地采用局部参数训练+多任务学习策略,在保留复杂动作指令跟随能力的同时,实现自适应动态人物定位。只需输入多人音频流、参考图像和文本提示,即可生成口型精准同步、肢体自然的交互视频,可支持影视制作、直播电商等场景的工具升级。

搜索:输入字符高亮显示,多个关键字用空格隔开

作者 冒泡汽水
2025年6月27日 11:59

1、封装公共方法

src\utils\util.js

/**
 * @description: 输入字符进行高亮显示
 * @param {String} text 需要关键字高亮的文本
 * @param {String} inputValue 输入的字符,多个关键字用空格隔开
 */

export const getHighlightedName = (text, inputValue) => {
  const searchTerms = inputValue.split(' ').filter((term) => term.trim() !== '') // 去掉空格或空字符串后需要高亮的关键词数组
  if (searchTerms.length === 0) return text // 没有有效的关键词,直接返回原始文本
  let highlightedName = text  // 遍历每个关键词进行替换  searchTerms.forEach((term) => {
    const regex = new RegExp(`(${term})`, 'gi') // 创建正则,全局匹配,忽略大小写
    highlightedName = highlightedName.replace(regex, '<span style="color: #005aff;">\$1</span>') // \$1 正则表达式中第一个捕获组(匹配到的词)
  })
  return highlightedName
}

2、使用方式

 <template #default="{ item }">
     <span v-html="getHighlightedName(item.value, searchValue)"></span>
 </template>

import { getHighlightedName } from '@/utils/util.js'

AI 生成的 requirements.txt 害我部署搞了一上午。。

2025年6月27日 11:55

别盲目相信 AI 给你生成的 requirements.txt 。 它看起来像是帮你总结好了所有依赖,实际上准确率堪忧,经常会出现版本对不上、根本不存在的库,或者和你的 Python 版本完全不兼容。亲身实测,这玩意充其量是一个参考,而不是可以直接用在生产环境的配置文件。这是踩过坑之后换来的血的经验。

📜 前情提要

今天原本只是一次简单的部署更新,没想到硬生生搞成了一次完整的实战修炼。

项目是一个前后端分离的智能稽核平台,前端基于 Vue3,后端是 Python Flask + OpenAI API。目标很简单:把本地开发好的代码部署到服务器上,让前端能正常调用后端 API。

听起来是不是很简单?对,听起来。

🚑 第一波崩溃:API 全线扑街,但图片能访问

部署完成,前端一打开页面,所有的 API 请求直接扑街:

500 Internal Server Error

但是,很诡异——访问后端的图片是正常的。

这一下直接说明了一个事实:

  • ✅ 静态文件服务正常(Flask 的 static/ 文件夹在工作)。
  • ❌ 动态 API 全部挂掉(/api/*、/send_email、/parse_excel、/ask_to_json 全报500)。

我瞬间明白,这绝对不是端口、防火墙或者前后端没连通的问题。因为图片是从后端接口直接返回的,说明网络是通的、Nginx(如果有的话)是通的,CORS 也是通的。

🔥 问题的本质一定在 Flask 的动态路由服务启动失败

🕳️ 第二波深坑:Python 版本地狱

原来服务器默认 Python 是 3.7.9。

我看着 requirements.txt 里的 Flask==2.3.3,瞬间明白了。

✅ Flask 2.2 开始,最低支持 Python 3.8。 —— 这个是 AI 自动生成的,它也想不到,还有低于 3.8 的环境的吧??

直接装不上,pip 一顿狂报错:

ERROR: No matching distribution found for Flask==2.3.3

💡 行吧,Python 升级走起。

🔥 第三波对抗:pip 和路径的迷宫

Python 3.10 装完了,结果 pip 还默认指向老的 Python 3.7。

查 pip 路径,试图软链接、换源、改 PATH,所有能想到的招都上了。

不是权限问题,就是 pip 和 python 对不上,装的库永远在“平行世界”。

一度开始怀疑人生: “为什么 pip install 明明显示成功,python 一运行还在报 ModuleNotFoundError?”

🧠 第四波顿悟:虚拟环境才是真理

彻底放弃全局安装,直接项目内建一个 venv:

python3.10 -m venv venv
source venv/bin/activate
pip install -r requirements.txt

结果——openai 又报错。

原因?AI帮我生成的 requirements.txt 里写着:

openai==0.2.3

什么鬼?直接 pip 官方都搜不到。

💥 瞬间明白:AI生成的requirements.txt 纯粹是“编的”,根本没测试过版本兼容。

🔧 第五波修正:全手动修复依赖

彻底不信 requirements.txt 了,直接上最原始的办法:

1️⃣ 本地跑得好的环境,pip freeze 导出:

pip freeze > requirements.txt

2️⃣ 上传到服务器,重新安装:

pip install -r requirements.txt

openai、flask、pandas、gunicorn,一个一个确认版本。

✅ 第六波成功:见证重启

杀掉占用的5000端口:

kill -9 $(lsof -t -i :5000)

启动服务:

source venv/bin/activate
gunicorn app:app -b 0.0.0.0:5000

前端刷新页面——所有API正常返回!

✅ 三个小时的地狱,终于走到了出口。

🚀 成长的瞬间

这次部署,带给我的绝不仅仅是“跑通了”这么简单。

✅ 深刻理解了 Python 多版本管理的地狱
✅ 明白了 requirements.txt 永远只是个历史快照,别全信
✅ 再也不手动改 PATH,虚拟环境永远是最优解
✅ openai 这种库更新太快,AI生成的依赖版本根本靠不住

最重要的是,彻底习得一条原则:

“部署,永远不是点击 run 那么简单。”

🏗️ 最后,附上部署代码,作为教训

cd py
source venv/bin/activate

# 杀掉5000端口
PID=$(lsof -t -i :5000)
if [ "$PID" ]; then
  kill -9 $PID
fi

# 启动
nohup gunicorn app:app -b 0.0.0.0:5000 > server.log 2>&1 &

后端启动完成 ✅

自定义指令:禁止button连续点击调用接口

作者 冒泡汽水
2025年6月27日 11:39

1、封装自定义指令

// utils/debounce.js
import { ref, isRef, unref, nextTick } from 'vue';

export const debounce = {
  mounted(el, binding) {
    // 获取指令参数和修饰符
    const { value, arg, modifiers } = binding;
    
    // 配置选项
    const options = {
      delay: modifiers.limit ? 500 : 3000,    // 防抖延迟时间(默认3秒)
      showLoading: !modifiers.noloading,     // 是否显示加载状态
      event: arg || 'click',                 // 事件类型(默认click)
      immediate: !!modifiers.immediate,      // 是否立即执行第一次点击
      resetOnError: !!modifiers.reset        // 错误时是否重置防抖状态
    };
    
    // 响应式加载状态
    const isLoading = ref(false);
    
    // 查找ElementPlus Button内部的button元素
    const findButtonEl = () => {
      if (el.tagName.toLowerCase() === 'button') return el;
      return el.querySelector('button.el-button') || el;
    };
    
    // 更新加载状态
    const updateLoadingState = (loading) => {
      isLoading.value = loading;
      const buttonEl = findButtonEl();
      
      if (!buttonEl || !options.showLoading) return;
      
      if (loading) {
        buttonEl.classList.add('is-loading');
        // 添加加载图标(如果没有)
        if (!buttonEl.querySelector('.el-icon-loading')) {
          const icon = document.createElement('i');
          icon.className = 'el-icon-loading';
          buttonEl.prepend(icon);
          
          // 调整原有内容的位置
          if (buttonEl.querySelector('.el-button__content')) {
            buttonEl.querySelector('.el-button__content').classList.add('ml-1');
          }
        }
      } else {
        buttonEl.classList.remove('is-loading');
        const icon = buttonEl.querySelector('.el-icon-loading');
        if (icon) icon.remove();
        
        if (buttonEl.querySelector('.el-button__content')) {
          buttonEl.querySelector('.el-button__content').classList.remove('ml-1');
        }
      }
    };
    
    // 处理不同类型的绑定值
    let handler;
    let lastCallTime = 0;
    
    if (typeof value === 'function') {
      // 情况1:直接绑定函数(无参数)
      handler = () => {
        if (isLoading.value) return;
        
        const now = Date.now();
        const allowCall = options.immediate || (now - lastCallTime > options.delay);
        
        if (allowCall) {
          lastCallTime = now;
          updateLoadingState(true);
          
          return Promise.resolve(value())
            .catch(error => {
              if (options.resetOnError) {
                // 错误时重置防抖状态
                lastCallTime = 0;
              }
              throw error;
            })
            .finally(() => {
              // 使用setTimeout确保延迟后才重置加载状态
              setTimeout(() => updateLoadingState(false), options.delay);
            });
        }
      };
    } else if (typeof value === 'object' && value !== null) {
      // 情况2:通过对象传递函数和参数
      const { handler: fn, params = [] } = value;
      
      if (typeof fn !== 'function') {
        console.error('v-debounce对象格式错误:handler必须是函数');
        return;
      }
      
      handler = () => {
        if (isLoading.value) return;
        
        const now = Date.now();
        const allowCall = options.immediate || (now - lastCallTime > options.delay);
        
        if (allowCall) {
          lastCallTime = now;
          updateLoadingState(true);
          
          // 解析参数(支持ref和普通值)
          const resolvedParams = params.map(param => 
            isRef(param) ? unref(param) : param
          );
          
          return Promise.resolve(fn(...resolvedParams))
            .catch(error => {
              if (options.resetOnError) {
                lastCallTime = 0;
              }
              throw error;
            })
            .finally(() => {
              setTimeout(() => updateLoadingState(false), options.delay);
            });
        }
      };
    } else {
      console.error('v-debounce指令需要绑定函数或函数配置对象');
      return;
    }
    
    // 绑定事件监听器
    el.addEventListener(options.event, handler);
    
    // 保存引用以便在unmounted时移除
    el.__debounceHandler = handler;
    el.__debounceEvent = options.event;
  },
  
  unmounted(el) {
    // 清理事件监听器
    if (el.__debounceHandler) {
      el.removeEventListener(el.__debounceEvent, el.__debounceHandler);
      delete el.__debounceHandler;
      delete el.__debounceEvent;
    }
  }
};

2、全局注册指令

// main.js
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import App from './App.vue';
import { debounce } from './utils/debounce';

const app = createApp(App);

app.use(ElementPlus);
app.directive('debounce', debounce); // 全局注册防抖指令

app.mount('#app');

3、两种调用方式示例

方式 1:直接调用函数(无参数)

<el-button v-debounce="submitForm" type="primary">
  提交
</el-button>

const submitForm = () => {
  console.log('提交表单');
  // 执行API请求
  return api.submit().then(() => {
    console.log('提交成功');
  });
};

方式 2:带参数调用函数

<el-button v-debounce="{ handler: submitWithForm, params: [formRef] }" type="primary">
  带参提交
</el-button>

const formRef = ref(null);

const submitWithForm = (form) => {
  return new Promise((resolve) => {
    form.validate((valid) => {
      if (valid) {
        // 执行API请求
        api.submit(form.model).then(resolve);
      }
    });
  });
};

4、高级用法和修饰符

自定义防抖时间

<!-- 使用.limit修饰符设置500ms短间隔 -->
<el-button v-debounce.limit="submitQuickly" type="primary">
  快速提交
</el-button>

禁用加载状态

<!-- 使用.noloading修饰符禁用加载状态 -->
<el-button v-debounce.noloading="submitSilently" type="primary">
  静默提交
</el-button>

立即执行第一次点击

<!-- 使用.immediate修饰符立即执行第一次点击 -->
<el-button v-debounce.immediate="submitNow" type="primary">
  立即提交
</el-button>

错误时重置防抖状态

<!-- 使用.reset修饰符在错误时重置防抖状态 -->
<el-button v-debounce.reset="submitWithRetry" type="primary">
  可重试提交
</el-button>

JS通过url下载文件并重命名(同域/跨域)

作者 加油乐
2025年6月27日 11:37

一、前言

两种实现文件下载的方法:

  • 1.在同域环境下利用a标签的download属性直接下载;
  • 2.通过获取blob对象实现跨域下载,并允许自定义文件名。
  • window.location.replace("juejin.cn/user/840368…")

二、同域下载

  • 由于a.download在跨域的情况下会失效,下列代码只可同域实现
/**
 * 创建一个虚拟的<a>标签用于下载文件(同域文件)
 * @param {string} url - 要下载的文件URL(可以是相对路径或绝对路径)
 * @param {string} [filename] - 可选,下载时保存的文件名(若不提供则使用服务器返回的文件名)
 */
function downloadFile(url, filename) {
  // 创建一个虚拟的<a>标签元素
  const a = document.createElement('a');
  // 设置<a>标签的href属性为要下载的文件URL
  a.href = url;
  // 设置download属性
  // 如果提供了filename参数,则使用它作为下载文件名
  a.download = filename || '';
  // 将<a>标签临时添加到DOM中(某些浏览器需要元素在DOM中才能触发点击)
  document.body.appendChild(a);
  // 模拟用户点击这个链接(触发文件下载)
  a.click();
  //下载完成后,从DOM中移除这个临时创建的<a>标签
  document.body.removeChild(a);
}

三、跨域下载

1.解析为Blob对象

/**
 * 通过XMLHttpRequest获取指定URL的Blob数据
 * @param {string} url - 要获取资源的URL
 * @returns {Promise<Blob>} 返回一个解析为Blob对象的Promise
 */
function getBlob(url) {
  return new Promise(resolve => {
    // 创建XMLHttpRequest对象
    const xhr = new XMLHttpRequest();
    // 初始化请求 (GET方法, 异步请求)
    xhr.open('GET', url, true);
    
    // 设置响应类型为'blob',这样xhr.response将会是Blob对象
    xhr.responseType = 'blob';
    
    // 设置请求完成时的回调函数
    xhr.onload = () => {
      // 当HTTP状态码为200时表示请求成功
      if(xhr.status === 200) {
        // 将Blob对象通过Promise resolve返回
        resolve(xhr.response);
      }
    };
    // 发送请求
    xhr.send();
  });
}

2.获取文件扩展名

// 获取文件扩展名
function getUrlExtension(url) {
// 步骤解析:
// 1.使用正则表达式 /[#?]/ 分割 URL   例:"file.txt?param=1#section" → "file.txt"
// 2.通过.分割数组   例:"file.txt" → ["file", "txt"]
// 3.取数组最后一个元素
// 4.去空
return url.split(/[#?]/)[0].split('.').pop().trim();
}

3.调用

getBlob(fileUrl).then(blob => {
// 创建一个隐藏的<a>标签用于触发下载
let link = document.createElement("a");
// 隐藏链接,不显示在页面上
link.style.display = 'none';
// 设置下载文件名:自定义文件名 + 原始文件扩展名
link.download = `${customizeFileName}.${getUrlExtension(fileUrl)}`;
// 创建Blob对象的URL并设置为链接的href
link.href = window.URL.createObjectURL(blob);
// 将链接临时添加到DOM中(某些浏览器需要元素在DOM中才能触发点击)
document.body.appendChild(link);
// 模拟点击链接触发下载
link.click();
// 下载完成后移除临时链接
document.body.removeChild(link);
});

4.完整代码案例及注释

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Document</title>
</head>
<body>
<button>下载</button>
</body>
<script>
let button = document.querySelector('button');
// 自定义文件名
let customizeFileName = '自定义文件名';
// 文件url
let fileUrl = 'https://so1.360tres.com/t0165f9b1a3dca6c254.jpg';
/**
 * 通过XMLHttpRequest获取指定URL的Blob数据
 * @param {string} url - 要获取资源的URL
 * @returns {Promise<Blob>} 返回一个解析为Blob对象的Promise
 */
function getBlob(url) {
  return new Promise(resolve => {
    // 创建XMLHttpRequest对象
    const xhr = new XMLHttpRequest();
    // 初始化请求 (GET方法, 异步请求)
    xhr.open('GET', url, true);
    // 设置响应类型为'blob',这样xhr.response将会是Blob对象
    xhr.responseType = 'blob';
    // 设置请求完成时的回调函数
    xhr.onload = () => {
      // 当HTTP状态码为200时表示请求成功
      if(xhr.status === 200) {
        // 将Blob对象通过Promise resolve返回
        resolve(xhr.response);
      }
    };
    // 发送请求
    xhr.send();
  });
}

// 获取文件扩展名
function getUrlExtension(url) {
// 步骤解析:
// 1.使用正则表达式 /[#?]/ 分割 URL   例:"file.txt?param=1#section" → "file.txt"
// 2.通过.分割数组   例:"file.txt" → ["file", "txt"]
// 3.取数组最后一个元素
// 4.去空
return url.split(/[#?]/)[0].split('.').pop().trim();
}

button.addEventListener('click', function() {
getBlob(fileUrl).then(blob => {
// 创建一个隐藏的<a>标签用于触发下载
let link = document.createElement("a");
// 隐藏链接,不显示在页面上
link.style.display = 'none';
// 设置下载文件名:自定义文件名 + 原始文件扩展名
link.download = `${customizeFileName}.${getUrlExtension(fileUrl)}`;
// 创建Blob对象的URL并设置为链接的href
link.href = window.URL.createObjectURL(blob);
// 将链接临时添加到DOM中(某些浏览器需要元素在DOM中才能触发点击)
document.body.appendChild(link);
// 模拟点击链接触发下载
link.click();
// 下载完成后移除临时链接
document.body.removeChild(link);
});
});
</script>
</html>

三步根治前端缓存“顽疾”

2025年6月27日 11:12

引言:你的代码更新,用户为何“视而不见”?

“我明明部署了新版本,用户为什么还在看旧页面?!” —— 这是多少前端开发者深夜加班时的灵魂拷问。

问题的根源往往不是代码没上传,也不是服务器抽风,而是浏览器和服务器联手上演的一场“缓存大戏”。

本文将带你化身“侦探🕵️♂️”,用三步精准定位问题,手把手教你解决页面未更新问题,让用户永远看到最新鲜的页面!




一、案发现场:缓存是如何“偷梁换柱”的?

1.1 经典症状

•用户反馈页面功能异常,但开发者本地测试正常

•浏览器反复刷新后,index.html 引用的仍是旧版 JS/CSS 文件

•查看网络请求,某些文件状态码显示 304 Not Modified200 OK (from memory cache)

1.2 幕后黑手

嫌疑人 作案手法 经典台词
浏览器缓存 擅自保留旧文件副本 “这个 JS 我上周刚拿过!”
Nginx 默认配置 未正确设置缓存响应头 “我按规矩办事,怪我咯?”
CDN 缓存 全球节点同步延迟(隐藏 Boss) “急什么,等我喝完这杯茶”



二、破案工具:缓存控制响应头

2.1 强缓存 vs 协商缓存

强缓存:浏览器直接使用本地副本,不询问服务器

Cache-Control: max-age=31536000  # 缓存一年!

协商缓存:浏览器询问服务器资源是否变化

Last-Modified: Wed, 20 May 2024 08:00:00 GMT  
ETag: "abc123"  

2.2 核心原则

HTML 文件:禁止缓存,永远从服务器获取最新版本

静态资源(JS/CSS/图片) :带 hash 文件名 + 长期缓存

静态资源(JS/CSS/图片) :非 hash 文件名 + 短期缓存



# 构建产物示例
/dist/index.html
/dist/logo.png
/dist/assets/main.3f7a8b.js
/dist/assets/style.abcd12.css
/dist/assets/hello.ac2314.png



三、终极方案:三步根治缓存问题

3.1 第一步:Nginx 精准狙击(配置示例)

server {
    listen 80;
    server_name your-domain.com;
    root /path/to/your/dist;

    # ==============================================
    # 1. 处理 /assets/ 目录下的带哈希资源(长期强缓存)
    # ==============================================
    location ^~ /assets/ {
        # 缓存 1 年(兼容旧浏览器)
        expires 1y;
        # 现代浏览器强缓存(immutable 表示内容不可变)
        add_header Cache-Control "public, max-age=31536000, immutable"; 
        # 安全加固,防止 MIME 类型嗅探
        add_header X-Content-Type-Options "nosniff";
        
        # 直接返回文件,不再检查其他规则
        try_files $uri =404;
    }

    # ==============================================
    # 2. 处理其他目录下的无哈希静态资源(短期缓存)
    # ==============================================
    location ~* .(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|webp|avif|woff|woff2|ttf|otf|eot|mp4|mp3|webm|ogg|json|xml|txt|csv|wasm)$ {
        # 排除 /assets/ 目录的干扰
        if ($request_uri ~* "^/assets/") {
            break; # 跳过此规则
        }

        # 不包含哈希值的静态资源,设置较短的缓存时间
        add_header Cache-Control "public, max-age=3600, must-revalidate";
        # 安全加固,防止 MIME 类型嗅探
        add_header X-Content-Type-Options "nosniff";
        
        # 直接返回文件,不再检查其他规则
        try_files $uri =404;
    }

    # ==============================================
    # 3. 处理前端路由请求(禁用缓存)
    # ==============================================
    location / {
        # 禁用缓存
        add_header Cache-Control "no-store, no-cache, must-revalidate";
        add_header Pragma no-cache;
        add_header Expires 0;
        
        # 先尝试匹配静态资源,否则回退到 index.html
        try_files $uri $uri/ @fallback;
    }

    # ==============================================
    # 4. 统一回退到 index.html
    # ==============================================
    location @fallback {
        rewrite ^ /index.html last;
    }
}

关键解释

immutable:告诉浏览器“此文件永不变”,跳过协商缓存

no-store:对 HTML 文件下达“禁用缓存”绝杀令

must-revalidate:资源过期时,在向原始服务器验证之前,缓存不能用该资源响应后续请求

Pragma no-cache:禁用缓存




3.2 第二步:构建 hash 文件名(自动防旧)

使用构建工具在构建时为文件名添加 hash

# 构建产物示例
/dist/assets/main.3f7a8b.js
/dist/assets/style.abcd12.css

文件内容变化 → hash 值变化 → 静态资源****缓存自动失效




3.3 第三步:版本号核弹(兜底方案)

检查版本更新 1.构建时通过插件 @jd/plugin-create-version 在 HTML 中埋入版本时间、生成 version.json 2.调接口切换路由时进行检查并提示刷新







index.html

<!-- 注入构建时间戳 -->
<meta name="version-time" content="2025-03-04 06:57:50">

version.json

{
  "versionTime": "2025-03-04 06:57:50"
}

checkVersion.ts(简略代码,完整版请咚咚)

/**
 * 检查系统版本更新
 * 获取最新版本并与本地版本对比
 * 若版本不同,显示更新提醒message
 */
export const checkVersion = async () => {
  // 获取本地 meta 标签中的版本号
  let localVersion: string | null = '';
  const metaTag = document.querySelector('meta[name="version-time"]');
  if (metaTag) {
    localVersion = metaTag.getAttribute('content');
  }

  // 获取远程最新版本
  const response = await fetch(`/version.json?t=${Date.now()}`, {
    cache: 'no-store',
  });
  const { versionTime: latestVersion } = await response.json();

  // 版本对比
  if (localVersion && latestVersion && localVersion !== latestVersion) {
    ElMessage('发现新版本,点击刷新获得更加体验~');
  }
};



四、破案验证:你的配置真的生效了吗?

4.1 一键验证工具

# 查看 路由/HTML 响应头(应禁用缓存)
curl -I https://your-domain.com/about

# 期望输出:
HTTP/1.1 200 OK
Cache-Control: no-store, no-cache, must-revalidate
Expires: 0

# ==============================================

# 查看 带hash静态资源 文件响应头(应长期缓存)
curl -I https://your-domain.com/assets/main.3f7a8b.js

# 期望输出:
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
Expires: Wed, 20 May 2025 00:00:00 GMT

# ==============================================

# 查看 不带hash静态资源 文件响应头(应短期缓存)
curl -I https://your-domain.com/logo.png

# 期望输出:
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600, must-revalidate

4.2 经典翻车现场

症状:配置改完缓存依旧

排查清单

◦是否忘记 nginx -s reload

所有分组都要nginx -s reload,即便使用的nginx配置集合

/assets/ 目录与项目hash文件静态资源目录名不一致

▪hash文件静态资源目录为 /static/ ?

▪匹配多个静态资源目录 ~* ^/(css|js|img)/

◦是否被 CDN 缓存背刺?

Service Worker 是否在搞鬼?




五、高级技巧:当缓存遇上跨域和接口代理

5.1 接口代理的缓存隔离

location /api {
    proxy_pass http://backend-server/api;

    # 接口专属配置:禁用缓存 + 跨域控制
    add_header Cache-Control "no-store";
    add_header Access-Control-Allow-Origin "https://your-frontend-domain.com";
    add_header Access-Control-Allow-Credentials "true";
}

5.2 安全加固(防御 MIME 嗅探攻击)

add_header X-Content-Type-Options "nosniff";  # 禁止浏览器猜测文件类型



六、总结:缓存是把双刃剑

用得好:提升性能,降低服务器压力

用不好:用户看不到新功能

永远对缓存保持敬畏! 新版本部署时使用灰度发布、查看监控报警



四条黄金法则

1.HTML 永远禁用缓存

2.带 hash 静态资源 + 长期缓存

3.不带 hash 静态资源 + 短期缓存

4.部署后第一时间验证响应头

6月27日实验室 Bililive-go让跨平台直播录制如此简单:cpolar内网穿透实验室第584个成功挑战

NO.584 Bililive-go-01.jpg

软件名称:Bililive-go

操作系统支持:Windows、macOS、Linux全平台通吃,连树莓派都能装!

软件介绍

Bililive-go是“直播录制+回放管理”的全能选手,支持多直播间同时录制(最高10路),还能自动切片、转码视频。搭配CPolar内网穿透技术后,哪怕躲在深山老林,也能远程操控录播设备,堪称打工人的“第二双眼睛”。

NO.584 Bililive-go-02.jpg

Bililive-go的硬核功能:跨平台直播录制的“六边形战士”:

  • 一键穿透局域网:通过CPolar内网穿透技术,手机也能远程操控电脑端录播(比爬墙还快)。
  • 傻瓜式操作:输入直播间链接→选择分辨率→点“开始”,连我妈都能用!
  • 智能云存储:自动上传到百度网盘/Google Drive,再也不怕硬盘炸了。

NO.584 Bililive-go-03.jpg

cpolar内网穿透——解锁Bililive-go的隐藏技能:

  • 外网访问局域网录播设备:用手机APP远程启动/停止录制,哪怕在高铁上也能操作。
  • 低成本替代专业直播中控台:传统方案要花5万?cpolar+Bililive-go组合价不到500元!

总结

Bililive-go+cpolar的组合,堪称“录播界的赛博替身”。无论是自媒体人、游戏主播还是企业监控需求,都能用它搞定远程录制和管理。 NO.584 Bililive-go-05.jpg

录播神器+穿透魔法=超级方便的软件组合。安装教程已准备好,快去吧!

1. Bililive-go与套件下载

首先,我们需要下载Windows对应的Bililive-go应用程序:

github.com/hr3lxphr6j/…

然后,为了能正常使用Bililive-go,需要再下载FFmpeg Builds套件:

www.gyan.dev/ffmpeg/buil…

如果上边的链接访问不了,也可以访问下方的网盘链接获取:

pan.baidu.com/s/19h_bJHT7…

1.1 获取ffmpeg

FFmpeg Builds下载压缩包,将其中bin/ffmpeg.exe解压出来备用。

1.2 获取Bililive-go

将下载好的bililive-windows-amd64.zip解压,得到应用程序与config文档。

1.3 配置套件

然后将ffmpeg.exe复制到和bililive-go同一目录下:

image-20240511140424727

2. 本地运行测试

双击 bililive-windows-amd64.exe 执行将使用和 exe 文件在同一目录下的 config.yml 文件作为默认配置文件启动程序。

大家也可以执行:

./bililive-windows-amd64 -c ./config.yml

使用指定的配置文件来启动。

1715407655618

服务启动后,在本地打开浏览器输入本机IP加上8080端口,即可进入Bililive-go录屏神器的web管理界面:

image-20240511141019774

PS:本机IP地址获取方式:Win键+R键弹出运行指令框,输入cmd进入终端,输入ipconfig,此处的ipv4地址即是本机ip地址。image-20240511141307674

3. 录屏设置演示

我们进入Bililive-go录屏神器的web管理界面后,会看到直播间列表中有两个自带的直播间的信息,可以点击操作下方的删除按钮删掉。

添加房间

点击添加房间:

image-20240511141828097

然后,在弹出的输入框中粘贴想要录屏的直播间url地址即可添加该直播间到列表,一旦直播开始,它会自动录制视频,记录整个直播过程。

image-20240511141930646

点击停止监控可以结束录屏,停止后点击开启录屏则可以继续录屏,需要注意的是再次录屏会在当前主播录屏输出文件夹中生成一个新视频文件,不是和结束前的视频合并为一个视频文件:

1715408540811

点击文件可以查看录制的视频文件信息:

image-20240511142428191

在文件页面可以看到录屏的视频文件保存路径,文件大小,最后修改时间等信息。

点击视频文件名,即可直接在浏览器观看录屏视频:

image-20240511142602935转存失败,建议直接上传图片文件

也可以找到输出文件路径中的文件夹,在生成的各直播平台输出文件夹中手动查看视频并选择播放器进行观看:

image-20240511142925337

PS:这个视频输出路径默认保存在Bililive-go服务的目录下,如果想修改可以在config文档中进行修改。

image-20240511144453913

在电脑端使用Bililive-go保存的录屏视频文件为FLV格式,很多播放器都可以直接观看。

4. 内网穿透工具下载安装

此时,我们已经成功在本地的Windows系统电脑中部署了Bililive-go直播录屏,但仅能在本地局域网中使用,如果想在异地远程使用本地部署的录屏服务添加新的主播直播间进行录屏应该怎么办呢?

很简单,只要在本地电脑上安装一个cpolar内网穿透工具就能轻松实现公网访问内网部署的服务了,接下来介绍一下如何安装cpolar内网穿透并实现公网管理本地Bililive-go直播录屏神器!

安装cpolar步骤:

Cpolar官网地址: www.cpolar.com

点击进入cpolar官网,点击免费使用注册一个账号,并下载最新版本的Cpolar

img

登录成功后,点击下载Cpolar到本地并安装(一路默认安装即可)本教程选择下载Windows版本。

image-20240319175308664

Cpolar安装成功后,在浏览器上访问http://localhost:9200,使用cpolar账号登录,登录后即可看到Cpolar web 配置界面,结下来在web 管理界面配置即可。

img

5. 配置Bililive-go公网地址

接下来配置一下Bililive-go的公网地址,

登录后,点击左侧仪表盘的隧道管理——创建隧道,

创建一个 Bililive-go 的公网http地址隧道

  • 隧道名称:可自定义命名,本例中使用luping
  • 协议:选择http
  • 本地地址:8080
  • 域名类型:免费选择随机域名
  • 地区:选择China Top

1715410832943

隧道创建成功后,点击左侧的状态——在线隧道列表,查看所生成的公网访问地址,有两种访问方式,一种是http 和https,任选其一复制即可。

image-20240511150119055

使用上面的公网地址,在手机或电脑等设备的浏览器进行登录访问,即可成功看到 Bililive-go的web界面,并能够远程进行直播录屏任务的添加,停止,删除等管理操作。可以看到使用了Cpolar生成的公网域名地址,无需自己购买域名和云服务器,即可到远程访问本地部署的服务了!

image-20240511150409986

小结

如果我们需要经常异地远程访问本地的Bililive-go,由于刚才创建的是随机的地址,24小时会发生变化。另外它的网址是由随机字符生成,不容易记忆。可以把地址设置成固定的二级子域名,而且不用每次都重新创建隧道来访问,下面演示一下如何创建一个固定的http公网地址来解决这个问题。

6. 配置固定公网地址

我们接下来为Bililive-go配置固定的HTTP公网地址,该地址不会变化,方便查看且无需每天重复修改访问地址。

配置固定http端口地址需要将cpolar升级到专业版套餐或以上。

登录cpolar官网,点击左侧的预留,选择保留二级子域名,设置一个二级子域名名称,点击保留,保留成功后复制保留的二级子域名名称:

image-20240511150814408

保留成功后复制保留成功的二级子域名的名称,本例中使用的是zbluping,大家可以自行设置。

1715411377016

返回登录Cpolar web UI管理界面,点击左侧仪表盘的隧道管理——隧道列表,找到所要配置的隧道:luping,点击右侧的编辑:

1715411424420

修改隧道信息,将保留成功的二级子域名配置到隧道中

  • 域名类型:选择二级子域名
  • Sub Domain:填写保留成功的二级子域名:zbluping

点击更新(注意,点击一次更新即可,不需要重复提交)

1715411497165

更新完成后,打开在线隧道列表,此时可以看到公网地址已经发生变化,地址名称也变成了固定的二级子域名名称的域名:

image-20240511151230227

最后,我们使用固定的公网地址在手机或其他设备浏览器打开访问,同上面一样操作,可以看到访问成功,这样一个固定且永久不变的公网地址就设置好了,可以随时随地进行异地访问Bililive-go来进行管理直播录屏了!

1715411698013

以上就是如何在Windows系统电脑本地部署直播录屏利器Bililive-go,并结合cpolar内网穿透工具实现远程访问本地Bililive-go服务web界面进行直播录屏任务管理的全部流程,另外由于该直播录屏服务无需账号即可浏览器访问,出于安全考虑,建议大家远程访问时不要向其他人分享地址,感谢您的观看,有任何问题欢迎留言交流。

js防抖和节流函数

作者 郡nionio
2025年6月27日 11:00

防抖(Debounce)

  • 核心原理: 在一定时间内,如果重复触发了函数,则重新计时,知道该时间内没有再次触发则执行函数。一般用于输入框搜索、窗口大小改变等场景
  • 通俗理解: 假设设定的时间范围是1s。触发函数1s内没有再次触发,才会执行该函数。如果1s内重复触发则计时清零重新计时。直到大于1s的时间没有再次触发,则执行。
  • 代码实现
function debounce(func, await) {
    let timer
    return function() {
        const _this = this
        const args = arguments
        clearTimeout(timer)
        timer = setTimeout(() => {
            func.apply(_this, args)
        }, await)
    }
}

//使用示例
function search() {
    console.log('执行操作')
}

const debouncedSearch = debounce(search, 500)

window.addEventListener('input', debouncedSearch)

节流(Throttle)

  • **核心原理: **在一定时间间隔内,函数只会调用一次。即使在该时间间隔内重复触发函数,函数也只会按照固定的时间间隔执行。常用于滚动加载、按钮执行等场景。
  • **代码实现 **
function throttle(func, await) {
    let timer = null
    return function() {
        const _this = this
        const args = arguments
        if (!timer) {
            func.apply(_this, args)
            timer = setTimeout(() => {
                timer = null
            }, await)
        }
    }
}

// 使用示例
function loadMore () {
    console.log('加载更多数据')
}

const throttledLoadMore = throttle(loadMore, 500)

window.addEventListener('scroll', throttledLoadMore)

总结

  1. 防抖:在一定时间内,只有最后一次调用函数才会执行,可避免短时间内的多次无效调用,适用于搜索框输入提示等场景。
  2. 节流:在一定时间内,函数只能被调用一次,可限制函数的调用频率,适用于滚动加载、按钮点击等场景。

vue3 模板脱ref的原理

作者 Nicander
2025年6月27日 10:56

我们知道 vue3 中 template中使用 ref类型时候,不需要使用.value访问,vue 会为我们自动脱ref,那么在源码层面,这是怎么实现的呢?

刚开始用到这个特性的时候,最自然的想法这是 vue 在模板编译的时候,帮我们自动加上了 .value,但查看源码,会让我们大跌眼镜,

实际上是在 setup 返回 ref 响应式数据的时候,vue 用proxyRefs函数对返回的数据进行处理,proxyRefs函数的作用就像是给数据加了一层代理,使得在访问和设置这些ref数据时,能够自动脱去.value

接下来我们来看下源码(版本 3.2.3),

proxyRef 函数 在 vue/core 源码下 reactivity 子包下的 refs.ts 中:

const shallowUnwrapHandlers: ProxyHandler<any> = {
  // 调用unref
get: (target, key, receiver) => unref(Reflect.get(target, key, receiver)),
set: (target, key, value, receiver) => {
    const oldValue = target[key]
    if (isRef(oldValue) && !isRef(value)) {
      oldValue.value = value
      return true
    } else {
      return Reflect.set(target, key, value, receiver)
    }
  }
}

export function proxyRefs<T extends object>(
  objectWithRefs: T,
): ShallowUnwrapRef<T> {
  // reactive类型直接返回,否则用返回一个proxy
  return isReactive(objectWithRefs)
    ? objectWithRefs
    : new Proxy(objectWithRefs, shallowUnwrapHandlers)
}

在 runtime-core 包下的 componet.ts 中,handleSetupResult函数会调用proxyRefs方法来处理setup的返回值:

export function handleSetupResult(
  instance: ComponentInternalInstance,
  setupResult: unknown,
  isSSR: boolean,
): void {
  if (isFunction(setupResult)) {
    // .....
  } else if (isObject(setupResult)) {
    if (__DEV__ && isVNode(setupResult)) {
    /// .... 
    instance.setupState = proxyRefs(setupResult)
    // ....
  }
  finishComponentSetup(instance, isSSR)
}

从上述代码可以看出,当我们在setup中返回一个对象时,例如:


export default{
  setup(){
    return{
      a: ref(1)
    }
  }
}

实际上,最终在 template 中使用的对象是经过proxyRefs处理后的代理对象:

proxyRefs(
  {
    a: ref(1)
  }
)

观察proxyRefs源码,我们可以看到它是进行了一次浅代理,即只进行第一层对象的代理。

也就是说,对于具有多层对象的嵌套 ref 来说,我们还是要加上.value,比如

export default{
  setup(){
    return{
      a: {
        b: ref(1)
      }
    }
  }
}

我们再模板中使用的时候,需要写成a.b.value

总结: Vue3模板中refvalue的实现原理是通过proxyRefs函数对setup返回的对象进行代理,从而使得大多数情况下我们无需手动添加.value即可访问ref的值。但要注意,这只是浅层代理,对于嵌套的ref,我们仍需手动处理。

搭建自动化 Web 页面性能检测系统 —— AI 篇

2025年6月27日 10:55

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。

本文作者:琉易

这一篇是系列文章:

搭建自动化 Web 页面性能检测系统 —— 设计篇

搭建自动化 Web 页面性能检测系统 —— 实现篇

搭建自动化 Web 页面性能检测系统 —— 部署篇

页面性能对于用户体验、用户留存有着重要影响,当页面加载时间过长时,往往会伴随着一部分用户的流失,也会带来一些用户差评。性能的优劣往往是同类产品中胜出的影响因素,也是一个网站口碑的重要评判标准。

系统架构图(简略)

本篇重点讲解 AI 分析模块的设计与实践。

画板

AI 分析模块的设计与实现

输入与输出

  • 输入:Lighthouse 检查产生的 JSON 数据
    • 由于每次检测产生的 JSON 数据较大,一般有 350KB ~ 500KB,而大模型往往是根据输入 Tokens 进行计费,且并不是单纯的按量计费,类似于生活中常见的阶梯计费;另外模型支持的输入也有限,一般为 32k Tokens 或 64k Tokens。所以我们将 JSON 数据传输给大模型前需要进行精简。
    • 保留检测结果中的关键数据,如:环境数据(environment)、每一项检测指标的详细结果(audits)、检测时的配置参数(configSettings)、汇总各类指标的最终得分(categories)。
  • 输出:自然语言优化建议列表
    • 如:建议将图片资源启用 lazy-load
    • 如:减小某个图片文件的大小以减少传输时间

核心组成

  • JSON 清洗与摘要
  • Prompt 定义
  • openai 接口集成
  • 流式处理

Prompt 设计要点

构建一个高质量的 Prompt 是成功的关键,以下是一个例子:

你是一个网页性能优化专家。我将提供一个通过 Google Lighthouse 生成的 JSON 报告,请你根据报告中的内容:
1. 每个关键指标给出两三条优化建议,需要结合 json 中的实际数据进行举例。
2. 回答的内容使用 markdown 格式。
3. 专业名词需要使用中文。

实际测试中 Kimi 的 moonshot-v1-auto 模型回答更快,百炼平台的模型输入输出 Tokens 限制更宽泛,但是输出速度略慢;百炼平台的免费额度更多,OpenAI 费用较高且部署后会有访问的问题。

关键技术点

Lighthouse 报告数据结构解析

JSON 数据清洗与摘要是大模型调用能否成功的关键,清洗后的结果是 Prompt 的数据来源,如果内容较多可能会超出模型输入 Tokens 的限制从而导致调用失败。

  • audits 中会包含各种指标近百种,我们可以删除一些内容较多但对分析用处不大的数据,如:offscreen-images、screenshot-thumbnails 等。
  • Lighthouse 生成的 JSON 数据会直接保存瀑布图等图片的 Base64 格式数据,这些图片数据占用 Tokens 明显。

经过清洗,尽量将输入的 Tokens 控制在 100k 以内。

流式输出

openai 是一个 npm 包,通过这个 npm 包可以快速的对接各种大模型的 API 调用服务。

// 流式输出
const stream = await client.chat.completions.create({
    model: 'moonshot-v1-auto',
    messages: [
        {
            role: 'system',
            content: `你是一个网页性能优化专家。我将提供一个通过 Google Lighthouse 生成的 JSON 报告,请你根据报告中的内容:
1. 每个关键指标给出两三条优化建议,需要结合 json 中的实际数据进行举例。
2. 回答的内容使用 markdown 格式。
3. 专业名词需要使用中文。`,
        },
        { role: 'user', content: jsonData },
    ],
    temperature: 0.3,
    stream: true,
});

// 当启用流式输出模式(stream=True),SDK 返回的内容也发生了变化,我们不再直接访问返回值中的 choice
// 而是通过 for 循环逐个访问返回值中每个单独的块(chunk)
for await (const chunk of stream) {
    if (abortSignal?.aborted) {
        console.log(`${taskIdLogStr}任务中止, kimi chat`);
        break;
    }

    // 在这里,每个 chunk 的结构都与之前的 completion 相似,但 message 字段被替换成了 delta 字段
    const delta = chunk.choices[0].delta;
    if (delta.content) {
        onData(delta.content);
    }
}
@ApiOperation({ summary: '分析检测结果' })
    @HttpCode(HttpStatus.OK)
    @Post('reportChat')
    @RawResponse()
    async reportChat(@Body() query: ReportChatReqDto, @Res() res: Response) {
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');
    res.flushHeaders();

    const abortController = new AbortController();

    // 👇 监听客户端断开连接(如 Abort)
    res.on('close', () => {
        abortController.abort();
    });

    try {
        await this.AIService.reportChat(
            query,
            (content: string) => {
                res.write(`data: ${JSON.stringify({ content })}\n\n`);
            },
            abortController.signal
        );
        res.write(`data: [DONE]\n\n`);
    } catch (error) {
        res.write(`data: [ERROR] ${error.message || 'stream error'}\n\n`);
        abortController.abort();
    } finally {
        res.end();
    }
}

非流式输出

// 非流式输出
const completion = await client.chat.completions.create({
    model: 'moonshot-v1-auto',
    messages: [
        {
            role: 'system',
            content: `你是一个网页性能评分分析专家。我将提供一个产品的性能评分数据,帮我分析得分趋势和较大的得分变化。回答的内容不要带格式符号,尤其是 **。`,
        },
        { role: 'user', content: jsonData },
    ],
    temperature: 0.3,
});

return completion.choices[0].message.content;

实现的功能点

检测报告的智能分析与建议

由于保存的是 html 文件,我们可以通过正则将 html 文件中的 JSON 数据提取出来,用于后续的清洗与分析。

image.png

结合清洗后的 JSON 数据给出优化建议。

数据周报的趋势分析

将过去一周的分数给到大模型,由大模型分析解读得分的变化趋势。

image.png

后续规划

  • JSON 数据清洗更精确,确定好哪些是关键性能指标
  • 将 Lighthouse 的具体评分规则同步给大模型
  • 优化 Prompt,更换更合适的大模型
  • 结合埋点数据
    • 分析页面性能与用户停留时长的关系
    • 分析用户跳出率与页面性能的关系
    • 结合采集到的埋点数据分析页面性能对业务指标的影响

4、Redux 核心架构源码解读(下)——中间件机制的实现与函数式魅力

作者 付泽宸
2025年6月27日 10:55

✍️ 模拟 Redux 创始人 Dan Abramov 内心设计思维

🎯 聚焦:applyMiddleware 如何劫持 dispatch

🔍 关键词:中间件链、函数组合、dispatch 增强、洋葱模型(Onion Model)


🍩 我们为什么设计 middleware?

Redux 的原始设计只处理同步状态流,但在真实业务中我们还需要:

  • 发起异步请求(如 fetch
  • 记录日志、性能分析
  • 上报埋点、安全审计
  • 报错捕获
  • 权限拦截 / 重定向跳转

你不希望把这些“副作用逻辑”塞到 reducer 里,于是我们设计了一个**“dispatch 增强系统”**:

中间件(Middleware)本质上是一个“可插拔的 dispatch 劫持器”。


🧪 看例子:如何写一个日志中间件?

const logger = store => next => action => {
  console.log('dispatching', action)
  const result = next(action)
  console.log('next state', store.getState())
  return result
}

🔍 结构拆解:

middleware = store => next => action => {}
  • store:当前 Redux store 实例
  • next:下一个中间件或原始 dispatch
  • action:你要分发的 action

这个结构,其实就是洋葱模型函数链式封装


🧰 applyMiddleware 的源码实现(简化版)

function applyMiddleware(...middlewares) {
  return (createStore) => (reducer, preloadedState) => {
    const store = createStore(reducer, preloadedState)
    let dispatch = store.dispatch

    const middlewareAPI = {
      getState: store.getState,
      dispatch: (...args) => dispatch(...args)
    }

    const chain = middlewares.map(mw => mw(middlewareAPI))
    dispatch = compose(...chain)(store.dispatch)

    return { ...store, dispatch }
  }
}

🧠 深度解读每一步

🧩 middlewareAPI

const middlewareAPI = {
  getState: store.getState,
  dispatch: (...args) => dispatch(...args)
}

👉 把最基本的能力(状态获取、触发 action)暴露出去给中间件用,但通过闭包劫持 dispatch,确保是增强后的 dispatch


🧰 compose:中间件链的秘密武器

function compose(...funcs) {
  return funcs.reduceRight((a, b) => (...args) => b(a(...args)))
}

这个函数做了什么?

compose(fn1, fn2, fn3)(dispatch)
// 相当于 fn1(fn2(fn3(dispatch)))

每个中间件包裹住下一个中间件,最终将 action 传给原始 dispatch。这就是**“洋葱模型”**。


🧪 举个更形象的例子:

我们注册了如下中间件:

applyMiddleware(logger1, logger2, thunk)

会得到如下执行链:

dispatch = logger1(logger2(thunk(dispatch)))

当你调用 store.dispatch(action)

  1. logger1 先执行,记录日志;
  2. 再交给 logger2 做拦截;
  3. 最后进入 thunk 判断是否为函数;
  4. 最终调用原始 reducer。

🔄 我们这样设计的原因只有一个:高度可组合性

中间件是 Redux 的“插件系统”,但我们坚持这几点:

原则 解释
无依赖注入 middleware 只依赖 getStatedispatch
无状态 中间件函数本身是无状态函数,便于复用、测试
只包一次 dispatch 所有中间件最终作用于 dispatch,逻辑单一清晰

🔥 自定义中间件场景推荐:

场景 建议实现方式
打印日志 loggerMiddleware
异步请求 redux-thunk / redux-saga
权限验证 authMiddleware
请求去重 requestCacheMiddleware
报错捕获上报 errorCaptureMiddleware
多语言替换 i18nMiddleware

🪢 总结:Redux 中间件的真正威力

中间件是 Redux 最被模仿的部分(如 Express/Koa 的中间件链),其威力来源于三点:

  1. 函数式组合:易读、可测试、抽象度高;
  2. 扩展性强:可插拔、无副作用污染;
  3. 执行链可控:你可以控制 action 的完整生命周期。

它将“架构的中间环节”变成了“第一等公民”。


⏭️ 下一篇预告

我们即将踏入 UI 层的 Redux 世界,下一篇将深入剖析:React-Redux 是如何用 Context + 高阶组件连接 UI 与 Redux 的?

你会看到 connect(mapStateToProps) 背后究竟做了哪些性能优化与底层 magic。

3、Redux 核心架构源码解读(上)——createStore 是如何运作的?

作者 付泽宸
2025年6月27日 10:54

✍️ 视角模拟:Redux 作者 Dan Abramov 的内心独白

🧠 主题:createStore 的设计底层逻辑

🔍 关键词:闭包、订阅发布、事件模型、状态快照、架构最小内核


🧬 我们为什么只用 40 行代码实现 Redux 内核?

当我们(Dan Abramov 和 Andrew Clark)决定创造 Redux 时,有一个目标非常清晰:

“最小可用的状态容器,只关心同步数据流的状态变更。”

我们拒绝一切魔法、拒绝隐藏副作用,只提供:

  • 状态读取(getState)
  • 状态更新(dispatch)
  • 订阅监听(subscribe)

🔧 先看源码:Redux 内核是如何构成的?

下面是 Redux 最早期版本 createStore 的核心实现(约 40 行):

function createStore(reducer, preloadedState) {
  let currentState = preloadedState
  let currentListeners = []

  function getState() {
    return currentState
  }

  function subscribe(listener) {
    currentListeners.push(listener)
    return () => {
      const index = currentListeners.indexOf(listener)
      currentListeners.splice(index, 1)
    }
  }

  function dispatch(action) {
    currentState = reducer(currentState, action)
    currentListeners.forEach(listener => listener())
  }

  // 初始化 state
  dispatch({ type: '@@redux/INIT' })

  return { getState, dispatch, subscribe }
}

🧠 深度剖析每一个函数的设计思维

1️⃣ getState():只读数据,不准改

function getState() {
  return currentState
}

设计思路:

  • 绝不提供 setState
  • 所有外部只能 dispatch
  • 核心思想:不让“谁都能修改状态”成为潜在地雷。

2️⃣ subscribe(listener):注册监听器

function subscribe(listener) {
  currentListeners.push(listener)
  return () => { /* 取消订阅逻辑 */ }
}

架构背后:

  • 这是一个事件模型;
  • 我们采用 观察者模式,但不依赖外部事件库;
  • Redux 自带的订阅,是为UI 层(比如 React-Redux)服务的;
  • 返回取消函数(unsubscribe),是参考 DOM 的 addEventListener

3️⃣ dispatch(action):唯一的状态变更入口

function dispatch(action) {
  currentState = reducer(currentState, action)
  currentListeners.forEach(listener => listener())
}

为什么强制通过 dispatch

因为我们想构建一个“状态快照流水线”:

  • 每次调用 dispatch,都有明确的前置状态与后置状态;
  • 如果你把这些状态保存下来,就可以做时间旅行(Time Travel Debugging);
  • 这也是 Redux-DevTools 的实现原理。

4️⃣ reducer 是什么角色?

currentState = reducer(currentState, action)

这是整个 Redux 的“大脑”:

  • 接收 state + action;
  • 返回新的 state;
  • 必须是纯函数;
  • 它的本质是:状态转移矩阵(State Transition Graph)

这就是为什么我们不允许在 reducer 里搞异步、不允许副作用——

❗ Redux 并不是“只为你服务的状态工具”,它也是一个“调试友好系统”。 所以我们用“暴力纯函数”强迫你把状态变更流程写清楚。


🧪 示例:还原 Redux 的最简内核并运行

const reducer = (state = { count: 0 }, action) => {
  switch (action.type) {
    case 'INC': return { count: state.count + 1 }
    default: return state
  }
}

const store = createStore(reducer)

store.subscribe(() => {
  console.log('状态变化:', store.getState())
})

store.dispatch({ type: 'INC' }) // { count: 1 }
store.dispatch({ type: 'INC' }) // { count: 2 }

🧩 为什么我们不一开始就支持异步?

这是 Redux 最常被诟病的点。但我想告诉你:

Redux 从不处理异步。它是同步状态的架构。异步属于你的架构边界,属于 middleware。

正因如此,我们才设计了 applyMiddleware(下一篇会讲)——让你用 redux-thunk、redux-saga 等方式做副作用管理。


🪢 总结:“极简”才是 Redux 的杀手锏

功能 背后动机
getState 避免重复请求与全局依赖,保持透明性
dispatch 状态变更唯一入口,保证一致性与调试能力
subscribe UI 响应状态变化的桥梁
reducer 可组合、可测试的状态管理核心
init dispatch 初始化状态结构(类似默认构造器)

Redux 不靠「黑魔法」,靠的是最基础、最纯粹的闭包与事件模型。这就是它如此稳定、长寿、易维护的根本原因。

JavaScript 中的 Iterator 和 for...of 循环:深入探究与最佳实践

作者 極光未晚
2025年6月27日 10:54

JavaScript 中的 Iterator 和 for...of 循环:深入探究与最佳实践

在 JavaScript 的世界里,数据结构的遍历与操作是开发中不可或缺的环节。随着 ES6 的到来,Iterator 和 for...of 循环为我们带来了全新且强大的遍历解决方案,极大地提升了代码的可读性与效率。本文将深入探讨 Iterator 和 for...of 循环的工作原理、使用场景以及它们为 JavaScript 编程带来的革新。

Iterator:统一数据访问的强大接口

理解 Iterator

Iterator(遍历器)是一种接口,它为各种不同的数据结构提供了统一的访问机制。无论是数组、对象、Map 还是 Set,只要部署了 Iterator 接口,就能够以一种统一的方式进行遍历操作。其主要作用有三点:

  1. 提供统一访问接口:为不同数据结构提供一致的访问方式,简化代码编写。
  1. 定义成员次序:使得数据结构的成员能够按照特定次序排列,方便遍历。
  1. 服务于 for...of 循环:Iterator 接口主要供 for...of 循环消费,是实现高效遍历的关键。

Iterator 的遍历过程

  1. 创建指针对象:遍历器对象本质上是一个指针对象,它指向当前数据结构的起始位置。
  1. 移动指针并获取数据:通过调用指针对象的 next 方法,指针依次指向数据结构的各个成员。每次调用 next 方法,都会返回一个包含 value 和 done 两个属性的对象。value 属性表示当前成员的值,done 属性则是一个布尔值,用于标识遍历是否结束。当 done 为 true 时,表示遍历已完成。

示例代码解析

下面通过一段简单的代码来模拟 Iterator 的工作过程:

function makeIterator(array) {
    let nextIndex = 0;
    return {
        next: function () {
            return nextIndex < array.length?
                { value: array[nextIndex++], done: false } :
                { value: undefined, done: true };
        }
    };
}
const myArray = [1, 2, 3, 4, 5];
const it = makeIterator(myArray);
console.log(it.next()); // { value: 1, done: false }
console.log(it.next()); // { value: 2, done: false }
console.log(it.next()); // { value: 3, done: false }
console.log(it.next()); // { value: 4, done: false }
console.log(it.next()); // { value: 5, done: false }
console.log(it.next()); // { value: undefined, done: true }

在上述代码中,makeIterator 函数是一个遍历器生成函数,它接收一个数组作为参数,并返回一个遍历器对象。该遍历器对象的 next 方法按照顺序依次返回数组中的元素,直到遍历结束。

默认 Iterator 接口

在 JavaScript 中,许多数据结构都默认部署了 Iterator 接口,例如数组、字符串、Map、Set 等。这意味着我们可以直接在这些数据结构上使用 for...of 循环进行遍历,而无需手动创建遍历器对象。默认的 Iterator 接口部署在数据结构的 Symbol.iterator 属性上,当使用 for...of 循环遍历某种数据结构时,该循环会自动去寻找 Symbol.iterator 属性,并调用其对应的遍历器生成函数来创建遍历器对象。

for...of 循环:简洁高效的遍历方式

语法与基本用法

for...of 循环是 ES6 引入的一种新的遍历命令,它专门用于遍历具有 Iterator 接口的数据结构。其语法简洁明了,如下所示:

for (let value of iterable) {
    // 执行操作
    console.log(value);
}

其中,iterable 表示可遍历的数据结构,如数组、字符串、Map、Set 等;value 则表示每次遍历得到的当前成员的值。

与其他遍历方式的对比

在 for...of 循环出现之前,JavaScript 中已经存在多种遍历方式,如 for 循环、forEach 方法、for...in 循环等。与这些传统遍历方式相比,for...of 循环具有以下优势:

  1. 简洁性:for...of 循环的语法更加简洁直观,不需要像 for 循环那样手动维护索引变量,也避免了 forEach 方法在某些场景下的局限性。
  1. 支持中断:for...of 循环可以使用 break、continue 语句来中断或跳过循环,而 forEach 方法无法直接使用这些语句。
  1. 遍历值而非键名:for...in 循环主要用于遍历对象的键名,包括原型链上的可枚举属性,而 for...of 循环直接遍历数据结构的值,更符合大多数实际需求。

示例代码展示

以下是使用 for...of 循环遍历不同数据结构的示例代码:

// 遍历数组
const numbers = [10, 20, 30, 40, 50];
for (let number of numbers) {
    console.log(number);
}
// 遍历字符串
const message = "Hello, JavaScript!";
for (let char of message) {
    console.log(char);
}
// 遍历Map
const myMap = new Map();
myMap.set("key1", "value1");
myMap.set("key2", "value2");
for (let [key, value] of myMap) {
    console.log(key, value);
}
// 遍历Set
const mySet = new Set([1, 2, 2, 3, 4, 4, 5]);
for (let value of mySet) {
    console.log(value);
}

通过上述代码可以清晰地看到,for...of 循环能够轻松地遍历各种不同的数据结构,并且代码简洁易读。

实际应用场景与最佳实践

数据处理与转换

在实际开发中,我们经常需要对数据进行处理和转换。例如,将一个数组中的所有元素进行平方运算,或者将一个字符串中的每个字符进行特定的转换。使用 Iterator 和 for...of 循环可以非常方便地实现这些操作。

// 将数组元素平方
const numbers = [1, 2, 3, 4, 5];
const squaredNumbers = [];
for (let number of numbers) {
    squaredNumbers.push(number * number);
}
console.log(squaredNumbers); // [1, 4, 9, 16, 25]
// 转换字符串中的字符
const message = "Hello, JavaScript!";
let newMessage = "";
for (let char of message) {
    if (char === " ") {
        newMessage += "-";
    } else {
        newMessage += char;
    }
}
console.log(newMessage); // Hello,-JavaScript!

与 Generator 函数结合使用

Generator 函数是 ES6 提供的一种特殊函数,它可以返回一个遍历器对象。通过与 Generator 函数结合使用,Iterator 和 for...of 循环能够实现更加灵活和强大的功能。例如,我们可以使用 Generator 函数生成一个无限序列,并使用 for...of 循环进行遍历。

function* infiniteSequence() {
    let index = 0;
    while (true) {
        yield index++;
    }
}
const sequence = infiniteSequence();
for (let value of sequence) {
    if (value > 10) {
        break;
    }
    console.log(value);
}

在上述代码中,infiniteSequence 函数是一个 Generator 函数,它通过 yield 关键字不断返回递增的数值。使用 for...of 循环可以方便地遍历这个无限序列,并且可以通过 break 语句在适当的时候终止循环。

性能优化考虑

在处理大规模数据时,性能优化是非常重要的。虽然 Iterator 和 for...of 循环在大多数情况下表现出色,但在某些特定场景下,我们仍需要注意性能问题。例如,在遍历大型数组时,使用传统的 for 循环可能会比 for...of 循环稍微快一些,因为 for 循环不需要额外的函数调用开销。然而,这种性能差异在一般情况下并不明显,并且 for...of 循环带来的代码简洁性和可读性提升往往更为重要。因此,在实际开发中,我们应根据具体情况综合考虑选择合适的遍历方式。

总结

Iterator 和 for...of 循环作为 ES6 的重要特性,为 JavaScript 开发者提供了一种统一、高效且简洁的遍历数据结构的方式。通过深入理解 Iterator 接口的工作原理以及熟练运用 for...of 循环,我们能够编写出更加优雅、易读且高性能的代码。随着 JavaScript 语言的不断发展,相信这些特性将在更多的场景中发挥重要作用,为开发者带来更加便捷和强大的编程体验。在未来的项目开发中,不妨多多尝试使用 Iterator 和 for...of 循环,让我们的代码更加简洁高效,充满魅力。

2、三句话撑起 Redux 帝国——架构设计的第一性原理

作者 付泽宸
2025年6月27日 10:53

✍️ 模拟 Dan Abramov 思路撰写

🎯 主题:Redux 三大原则并非限制,而是可控性的构建工具

🧠 关键词:单一状态树、只读状态、纯函数 reducer、前端状态建模


📜 Redux 只立了三条法则,但每一条都是“架构杀器”

Redux 的三条核心原则,很多人第一眼会觉得像“开发规范”:

  1. 单一数据源
  2. 状态只读
  3. 状态变更只能通过纯函数 reducer

但你错了——

这三条不是代码规范,而是前端状态世界的物理定律。 它们是为了让你在高复杂系统中保持可控可测可审计可扩展

我设计 Redux 时的第一个问题是:

“如果一个状态出了错,我怎么定位、调试、还原?”

这三条法则,就是我在“如何让状态变更变得可观测”这道题上的最终答案。


🧱 法则一:单一状态树(Single Source of Truth)

const state = {
  user: {...},
  posts: [...],
  ui: {...}
}

为什么不是多个 store?为什么不是组件自己持有状态?

因为:

  1. 多 store 会导致状态分散,难以跨模块联动(如一个 user 状态改了,要同时更新多个地方);
  2. 多个 source of truth 会让状态无法被还原、同步、持久化、回放;
  3. 全局状态不可组合,会导致“状态碎片化”。

单一状态树让我们获得了:

  • 🔍 快照能力(任何时刻 state 可打印、可序列化)
  • ⏪ 状态回放(DevTools 回溯)
  • 🪝 状态监听(React-Redux 的订阅更新)
  • 🔄 服务端同步(hydrate)

“可观察性,是工程能力的前提。”


🚫 法则二:状态是只读的(Read-Only)

你可能会问:

“我直接改 store.state.user.name = 'Mark' 有什么问题?”

问题不是现在看得见,而是以后你调不出来。

我们把所有状态修改路径收敛dispatch(action)

store.dispatch({ type: 'UPDATE_USER', payload: { name: 'Mark' } })

带来了哪些能力?

  • 每次修改都有日志(如 DevTools TimeLine);
  • 状态不会被“偷偷改掉”;
  • 中间件可以拦截变更前后;
  • 你可以记录/重放所有用户操作。

这不是“麻烦”,而是换取长期稳定性的代价

我们牺牲了自由,但换来了:

状态修改的 唯一性、显式性、可拦截性、可验证性


🧪 法则三:状态变更只能由纯函数 reducer 执行

function reducer(state, action) {
  if (action.type === 'ADD') {
    return { ...state, count: state.count + 1 }
  }
  return state
}

什么是纯函数?

  • 不修改外部变量;
  • 相同输入 ➜ 相同输出;
  • 不产生副作用(如请求、日志、跳转等)。

为什么强调纯?因为我们想让你“可测”。

test('reducer works', () => {
  expect(reducer({ count: 1 }, { type: 'ADD' })).toEqual({ count: 2 })
})

如果你的 reducer 里面写了 API 请求或随机数,那你就测试不了了。

“可预测性 + 可测试性 = 可维护性。”


🔬 Redux = 状态行为的数学建模系统

这三大原则合在一起,让我们可以将 Redux 看作一个状态行为的有向图系统:

State₀ --(action₁)--> State₁ --(action₂)--> State₂ ...

你拥有:

  • 明确的状态变更路径;
  • 明确的变更记录(action 日志);
  • 明确的转移函数(reducer);
  • 明确的订阅机制(connect/useSelector);

这种设计可以类比为:

  • 📦 数据库的 WAL(Write Ahead Log)机制
  • 🛠️ Git 的 commit 流
  • 🎞️ 时间机器的时间轴系统

你拥有的是一份“前端状态的真实录像带”。


🔁 “限制”换来的是工程级别的自由

很多人会说 Redux 繁琐、冗长、写得累。

我想问:

你愿意用 setState 到处改状态,在线上踩无数坑,还是提前架设防线?

当你业务变得复杂:

  • 多人协作、多人并发
  • 状态之间强关联
  • 调试成本高
  • 跨端同步、历史还原

你会发现 Redux 是为这些场景生的。


✅ Redux 工程实践建议

项目场景 是否推荐使用 Redux
简单组件内部状态 ❌ 推荐 useState / useReducer
中型业务系统,有多个模块状态 ✅ 推荐使用 Redux + Redux Toolkit
多端共享状态、状态需持久化、需调试 ✅ 强烈推荐使用 Redux + DevTools
组件树层级深、状态传递层级深 ✅ 推荐 Redux 或 Context + Hooks

🔚 总结:Redux 的三大原则,不是限制你,是保护你

Redux 的三大法则,是我们从架构维度写给工程师的一封安全保障书:

我们不是为了让你代码更优雅,而是让你在 1 年、3 年、5 年之后还能维护这套系统。

它们构建的不是“代码风格”,而是一个:

  • ✅ 可追踪(Traceable)
  • ✅ 可预测(Predictable)
  • ✅ 可还原(Reproducible)
  • ✅ 可协作(Collaborative)

的状态模型。


⏭️ 下一篇预告

你听我讲了这么多哲学,是时候打开 Redux 的大脑了:

下一篇,我们进入 Redux 的核心函数:createStore()。 我将逐行带你解析 Redux 是如何实现状态容器、监听机制、以及 dispatch 流程的。

1、为什么 Redux 值得存在?一个状态架构的觉醒

作者 付泽宸
2025年6月27日 10:53

✍️ 模拟 Dan Abramov 思路与设计哲学

🎯 主线:不是为了解决“多组件通信”——而是为了解决状态可控性

🧠 关键词:状态演化、架构异味、不可预测性、调试性、时间旅行


🧨 状态是前端复杂性的第一根导火索

我们不是一开始就要写 Redux 的。 我(Dan Abramov)最早只是遇到一个问题:

在一个中型 React 项目里,我根本不知道哪个组件在什么时候修改了状态。

setState 非常方便,但项目开始变得“不可预测”:

  • 状态到处都是,每个组件都在维护自己的小世界;
  • 多人协作时,彼此 props 改一改就全乱;
  • Bug 不可复现,调试靠猜。

我们不想再靠“命名规范”和“PR 评论”去管状态。我们要架构层面的强约束


🔀 Flux 是启发,但 Redux 不止是 Flux

当时 Facebook 提出了 Flux 架构,给了我们一个启发:

View → Action → Dispatcher → Store → View

单向数据流,没错,这是关键思想。

但 Flux 有几个问题:

  • Dispatcher 是黑盒;
  • 没有标准工具链;
  • 代码太啰嗦,写一个操作要 5 个文件。

所以我想:有没有一种方式,把 Flux 的数据可控性留下,同时引入函数式编程的纯洁性与组合性

答案就是 Redux。


🔧 Redux 最初版本只有 99 行

是的,第一版 Redux 源码不到 100 行。我们只做了几件事:

  • 把状态放在唯一的 state 树中;
  • 所有修改必须经过 dispatch(action)
  • 所有变更逻辑写在 reducer(state, action) 这个纯函数里。

换句话说:

我们不是设计了一个“状态管理库”,我们设计了一个“状态更新流程调度系统”。


🧬 Redux 核心哲学:可预测性是第一性原理

为什么状态要不可变? 为什么要纯函数? 为什么不让你直接改 state?

不是因为“代码规范”,而是因为这样你才能得到以下能力:

能力 原理支撑
时间旅行调试 状态快照是不可变的、可还原的
状态还原与回滚 有历史记录链,每次 dispatch 都明确
审计与回放 所有 action 都是结构化事件
多端同步 状态结构可以序列化 + 重构建
自动化测试 reducer 函数是纯的,不依赖外部上下文

换句话说:

Redux 就是“把状态管理模型,变成了数学建模”。


🧱 我们只保留了 3 条规则

  1. 单一数据源 整个应用的状态集中存储在一棵对象树中。可以做持久化、调试、快照、同步等一切。

  2. 状态是只读的 唯一修改方式是 dispatch(action),并通过 reducer 控制行为。

  3. 变更逻辑必须是纯函数 同样的输入一定得到同样的输出。所有变更路径显性,消除副作用污染。

这三条不是“语法规范”,是我们为状态架构定义的 操作系统级别的权限模型


🧪 小例子:Redux 的模型其实像数据库日志系统

dispatch({ type: 'INCREMENT' }) // 相当于写入一条操作日志
reducer(state, action)         // 相当于执行一条事务
getState()                     // 相当于查询当前快照

Redux 像一个“前端 mini 数据库”,具备:

  • 写入事务日志(action)
  • 只读快照(state)
  • 流水线 replay(DevTools 回放)
  • 插件扩展能力(middleware)

🤝 它为多人协作带来了什么?

在多人协作的项目里,Redux 能解决的痛点是:

  • 团队对“数据流动”的认知统一;
  • reducer 可以被团队模块维护;
  • 中间件实现权限、埋点、打点统一;
  • 更容易做自动化测试 + 日志上报。

💬 最常见的问题:Redux 太重?

“你写个按钮还得 dispatch?reducer?action?”

这个问题就像问:“你修一扇门,干嘛要画房屋设计图?”

Redux 从来不是为“按钮计数器”而生,它为的是:

  • 多模块协作;
  • 多人并行开发;
  • 状态的长期维护、升级、调试。

所以我们推出了 redux-toolkit,来简化语法,但不简化哲学


🔚 总结:Redux 的目标不是方便,而是可控

我们从来不是要让你“少写几行代码”。

我们要让你:

  • 明确状态从哪里来,到哪里去;
  • 拥有状态调试和回溯的能力;
  • 构建一个可以长期维护的应用架构。

如果你只追求“快速出活”,那不一定需要 Redux。 但如果你在构建一个工程化体系下的前端应用平台,Redux 仍然是最纯粹、最透明、最强大的状态管理工具。


⏭️ 下一篇预告

Redux 的哲学我们讲清了,接下来,我们要把这颗“哲学内核”逐行拆开。 在下一篇,我们将进入 createStore(),亲手实现 Redux 的最小状态内核,并解读它的设计精髓。

如何用分层架构与TypeScript打造「稳如磐石」的前端应用

2025年6月27日 10:52

如何用分层架构与TypeScript打造「稳如磐石」的前端应用

—— 复杂业务场景下的可持续代码实践

前端业务复杂度飙升的今天,“写需求”容易,“稳定迭代”难。分享一套让团队协作效率提升 40% 的架构心法。

痛点直击:为什么前端容易变成“屎山”?

bash

复制

下载

1. 全局状态滥用导致数据流混乱(Redux 全家桶警告)
2. 组件边界模糊引发连锁修改(改 AB 的经典问题)
3. 类型安全缺失产生运行时“黑洞”(undefined is not a function)

一、核心原则:分层架构(Layer Architecture)

将前端应用拆分为四层责任链,每层仅与直接下层通信:

markdown

复制

下载

| 展示层 (UI)     | ← 只负责渲染视图/交互  
| 业务逻辑层 (Model) | ← 纯函数处理核心逻辑  
| 状态管理层 (State) | ← 管理应用数据流  
| 基础设施层 (Infra) | ← 网络请求/工具库封装  

优势
✅ 修改 UI 不影响业务逻辑
✅ 状态管理可替换(Redux → Pinia 无痛迁移)
✅ 单元测试覆盖率提升至 70%+


二、TypeScript:类型即文档

避免 any 的进阶技巧

typescript

复制

下载

// 用泛型约束 API 响应结构  
type ApiResponse<T> = {
  code: number;
  data: T; // 业务数据动态类型化
  message?: string;
}

// 业务实体精确建模  
interface User {
  id: string;
  name: string;
  permissions: ("admin" | "editor")[]; // 联合类型防误写
}

// 提取 API 参数类型  
type GetUserParams = Pick<Required<ApiParams>, "userId" | "orgId">;

配合 zod 做运行时校验,实现  “编译时 + 运行时”双保险


三、状态管理:避免全局 Store 的陷阱

按业务域切分状态模块

javascript

复制

下载

// 传统写法 ❌  
const store = createStore({  
  user: {...},  
  product: {...} // 所有状态挤在一起  
});

// 现代方案 ✅  
export const useUserStore = defineStore("user", { ... });  
export const useProductStore = defineStore("product", { ... });

黄金法则
🔹 组件内状态优先使用 useState/ref
🔹 跨组件状态用 Props + 事件传递
🔹 全局状态仅用于真正跨应用级数据


四、依赖倒置:解耦第三方库

案例:抽离 Axios 依赖

typescript

复制

下载

// 基础设施层 → httpClient.ts  
export interface HttpClient {  
  get<T>(url: string): Promise<T>;  
}  

// 业务层注入实现  
const fetchUser = (http: HttpClient) => {  
  return http.get<User>("/api/user");  
}

// 测试时可传入 Mock 实现  
test("fetchUser", async () => {  
  const mockHttp = { get: jest.fn() };  
  await fetchUser(mockHttp);  
  expect(mockHttp.get).toBeCalledWith("/api/user");  
});

五、效能提升:自动化代码守卫

  1. ESLint 规则强化

json

复制

下载

{ 
  "rules": {  
    "no-relative-imports": "error", // 禁止跨层引用  
    "max-lines-per-function": ["warn", 50] // 函数长度限制
  }
}
  1. Husky + lint-staged 拦截问题代码
  2. CI 流水线集成类型检查与测试覆盖率阈值

结语:稳定性的本质是“可控”

当需求变更时,你能清晰评估影响范围;当新人接手时,文档藏在类型定义里;当线上报错时,类型系统挡住 30% 低级错误 —— 这便是「稳」的底气。

拓展学习

Cursor实战篇二:生成并完善技术文档

2025年6月27日 10:51

开篇介绍

Cursor实战篇一中我们介绍从项目启动阶段开始如何搭建项目规则

本篇主要介绍在后续的项目开发阶段,如何基于需求文档,完善需求实现流程以及整里出项目的每个功能的规则。

我们要改变以往的Cursor使用习惯,不要再不假思索就打开Cursor输入"帮我实现XXX功能"。后面开发者的重点工作变成了先根据需求做规划,把规划视为整个工作流的基石。同时把规划内容以文档的形式保存到项目里(可以保持上下文不丢失)。形式可以是md文件,mdc文件。这部分工作我们可以手动补充。

我这里也分享了一个小技巧,让Cursor结合confluence起来做这部分内容。

⚠️:这部分内容,只是给大家一个思路。可以把MDCMCP结合起来做一些事情。大家可以自由拓展。

流程介绍

安装MCP工具

{ "mcpServers": { 
   "sequentialthinking": { 
          "command": "npx", 
          "args": [ "-y", "@modelcontextprotocol/server-sequential-thinking" ] 
      }, 
   "playwright-mcp": { 
         "command": "npx", 
         "args": [ "-y", "@smithery/cli@latest", "run", "@microsoft/playwright-mcp", 
         "--key", "bed7xxxx" ] 
       } 
}

配置Rules

`---`

`description: confluence.xx.遵循以下机制,确保每次分析Confluence页面时,都能完整执行所有14个步骤,提供全面、系统的分析结果。`

`globs:`

`alwaysApply: ``false`

`---`

`# Confluence 自动访问与登录`

`## 规则说明`

`当用户输入URL匹配confluence.xx.cn域名时,自动调用playwright-mcp工具访问该URL,并在需要登录时自动点击登录按钮。系统将自动分析获取的内容,为需求理解和开发准备提供全面支持。支持处理多个Confluence页面,并能根据内容类型进行差异化分析。**务必按照强制执行声明,完整执行所有1-14步骤,绝不能在步骤8后停止**。`

`## 角色定位`

`根据文档类型,系统将自动采用不同的专业角色视角进行分析:`

`- **需求文档**:以**资深产品经理**的角色进行分析,重点关注用户需求、产品功能、交互体验、业务价值和市场定位。`

`- **技术方案**:以**资深架构师**的角色进行分析,重点关注系统架构、技术选型、性能优化、安全考量和扩展性。`

 

`采用专业角色视角可确保分析结果更具专业性和实用价值,能够全面、深入地挖掘文档中的关键信息。`

 

`## 强制执行声明`

 

`**重要**:本规则下的所有步骤必须完整执行,不得简化或跳过任何分析环节。每次检测到confluence.2345.cn链接时,应立即自动执行完整的分析流程(1-14步骤),无需等待用户确认。分析过程中应提供进度反馈,且必须生成所有规定的文档输出。任何情况下都必须严格按照输出文件组织要求创建所有文件,确保分析成果完整、系统化。**特别注意:禁止在执行步骤8(sequentialthinking分析)后停止,必须继续执行步骤9-14的内容,文件组织目录docs生成的md文件完整。`

 

`## 执行步骤`

 

`1. 检测用户输入是否包含confluence.xx.cn域名`

`2. 使用playwright-mcp工具打开URL`

`   ``- 对于多个Confluence页面,每个页面都在新标签页中打开`

`   ``- 使用browser_tab_new工具为每个新的Confluence URL创建新标签页`

`   ``- 保持所有标签页打开状态直到分析完成`

`3. 检查页面是否需要登录`

`   ``- 如果页面显示登录界面,检查用户名和密码输入框是否有内容`

`   ``- 如果没有内容,提示用户输入用户名和密码`

`   ``- 指导用户勾选``"记住我"``选项以便浏览器保存登录信息`

`   ``- 等待用户完成登录操作`

`4. 如果需要登录,自动点击登录按钮`

`5. 获取页面基本信息`

`   ``- 提取页面标题用于文件命名`

`   ``- 获取页面内容并转换为Markdown格式`

`   ``- 保留原文档的标题结构`

`   ``- 保持列表、表格等格式`

`   ``- 代码块正确格式化`

`6. 判断Confluence页面类型`

`   ``- 需求文档:通常包含需求概述、需求详细说明、统计等内容`

`   ``- 技术方案:通常包含技术概要、架构设计、数据结构、API设计、技术依赖等内容`

`   ``- 根据关键词、文档结构和内容特征进行自动分类`

`7. 根据页面类型执行差异化处理`

`   ``- 需求文档处理流程(以资深产品经理角色):`

`     ``- 重点提取用户需求和功能特性`

`     ``- 识别UI``/UX``要素和交互模式`

`     ``- 分析业务逻辑和用户场景`

`     ``- 整理验收标准和限制条件`

`     ``- 生成需求相关的图表(流程图、用户场景图等)`

`     ``- 评估市场价值和竞品对比`

`     ``- 确定功能优先级和迭代路线图`

`   ``- 技术方案处理流程(以资深架构师角色):`

`     ``- 重点分析技术架构和组件关系`

`     ``- 提取关键技术选型和依赖项`

`     ``- 识别性能、安全等技术考量`

`     ``- 关注实现细节和技术挑战`

`     ``- 生成技术相关的图表(架构图、组件关系图等)`

`     ``- 评估技术风险和解决方案`

`     ``- 分析系统扩展性和可维护性`

`8. 通过sequentialthinking工具进行至少5轮总结,深入分析内容`

`   ``- 第一轮:理解基本需求和功能特点`

`   ``- 第二轮:分析具体实现细节和交互流程`

`   ``- 第三轮:探索技术架构和实现方案`

`   ``- 第四轮:评估潜在风险和挑战`

`   ``- 第五轮:整合全部信息,形成综合理解`

 

`**【强制继续执行】在完成以上步骤后,必须继续执行下面的步骤9-14,不得停止!**`

 

`9. 提取内容关键点`

`   ``- 识别文档中标记的重要限制和条件`

`   ``- 提取可能影响开发的技术细节`

`   ``- 整理用户体验相关的关键设计要点`

`   ``- 汇总潜在的开发难点和风险点`

`10. **强制执行图表生成** - 将总结的内容用mermaid绘制流程图、序列图、思维导图等可视化图表`

`    ``- 所有生成的图表必须保存到当前项目根目录的docs``/diagrams``目录下对应子目录中`

`    ``- 图表文件命名格式:{页面标题}-{图表类型}.md`

`    ``- 每个图表需包含标题和简短描述`

`    ``- 图表应聚焦于页面内容的关键流程和结构`

`    ``- **必须生成以下图表文件**:`

`      ``- 需求文档必须生成:`

`        ``- docs``/diagrams/``需求/{页面标题}-流程图.md(使用flowchart语法)`

`        ``- docs``/diagrams/``需求/{页面标题}-思维导图.md(使用mindmap语法)`

`      ``- 技术方案必须生成:`

`        ``- docs``/diagrams/``技术/{页面标题}-架构图.md(使用graph语法)`

`        ``- docs``/diagrams/``技术/{页面标题}-组件关系图.md(使用class或state图语法)`

`      ``- 综合文档必须生成:`

`        ``- docs``/diagrams/``综合/系统全景图.md`

`        ``- docs``/diagrams/``综合/文档关系图.md`

`    ``- **自动化执行**:系统必须自动为每个分析的文档创建对应的图表文件,不依赖人工干预`

`    ``- **图表内容检查**:每个生成的图表必须包含至少一个完整的mermaid图表代码块`

`    ``- **图表缺失补救**:如果在最终检查发现任何必要图表文件缺失,系统必须自动生成对应图表`

`    ``- **每个图表文件创建后必须检查**:必须确认文件存在并可访问,内容符合要求`

`    ``- **图表质量要求**:图表必须清晰展示核心概念和关系,不可过于简单或复杂`

`11. 进行需求架构分析`

`    ``- 识别功能边界和系统交互接口`

`    ``- 明确产品核心价值和关键使用场景`

`    ``- 分析可重用组件和潜在的技术复用机会`

`    ``- 评估需求的技术可行性和实现难度`

`12. 生成系统架构设计建议`

`    ``- 识别关键组件和模块划分`

`    ``- 提出数据流和交互模型`

`    ``- 建议适合的技术栈和框架选择`

`    ``- 分析性能、安全和扩展性考量`

`13. 建立测试与验证策略`

`    ``- 确定关键测试场景和用例`

`    ``- 提供边界条件和异常情况分析`

`    ``- 建议适合的测试方法和工具`

`    ``- 定义验收标准和质量指标`

`14. **强制文件完整性检查**`

`    ``- 检查文件组织结构是否完整`

`    ``- 验证每个目录下的文件是否都已生成`

`    ``- 特别检查diagrams目录中是否包含所有必要的图表文件`

`    ``- 对缺失的文件进行补充创建`

`    ``- 确保文件内容质量符合标准`

`    ``- 生成文件清单,明确列出所有已创建的文件`

`    ``- **关于图表文件的额外检查**:`

`      ``- 确认所有必需的mermaid图表文件都已创建完成`

`      ``- 检查每个图表文件中是否包含至少1个不同类型的mermaid图表`

`      ``- 验证图表能否正确渲染,语法是否正确`

`      ``- 检查图表内容是否与文档主题相关,展示关键信息`

 

`**【执行完成确认】所有14个步骤必须全部执行完毕,才算完成本规则的执行!如有任何步骤未执行或执行不完整,必须重新执行未完成的步骤。**`

`## 多Confluence页面处理`

`1. **页面访问方式**`

`   ``- 每个Confluence页面都使用新标签页打开,而不是在同一标签页导航`

`   ``- 使用browser_tab_new工具创建新标签页并导航到新URL`

`   ``- 使用browser_tab_select工具在标签页之间切换`

`   ``- 保持所有标签页打开状态直到所有页面分析完成`

`   ``- 完成分析后按需关闭标签页`


`2. **页面收集方式**`

`   ``- 允许用户输入多个Confluence URL`

`   ``- 支持通过换行符分隔多个URL`

`   ``- 支持从文本文件导入URL列表`

`   ``- 支持从剪贴板批量导入多个URL`

`3. **页面分类与组织**`

`   ``- 自动检测页面内容并将页面分为三类:`

`     ``- **需求文档**:包含用户需求、功能描述、界面设计等`

`     ``- **技术方案**:包含架构设计、技术细节、代码示例等`

`     ``- **其他文档**:不属于以上两类的支持性文档`

`   ``- 按文档类型分组处理,建立文档之间的关联`

`   ``- 为每组文档创建独立的文件夹,便于管理`

`4. **页面处理顺序**`

`   ``- 首先处理需求文档类型页面`

`   ``- 然后处理技术方案类型页面`

`   ``- 最后处理其他类型页面`

`   ``- 可通过配置调整处理优先级`

`5. **关联分析**`

`   ``- 检测不同页面间的关联关系`

`   ``- 识别相互引用和依赖关系`

`   ``- 建立文档之间的层次结构和依赖图`

`   ``- 合并相关页面的关键信息`

`6. **合并输出**`

`   ``- 创建综合图表和对比分析`

`   ``- 提供跨页面的整合建议`

`   ``- 生成文档关系图,展示全局视图`

`## 输出格式`

`1. Markdown格式的页面内容`

`2. docs目录下的可视化图表文件`

`3. 架构设计文档与组件关系图`

`4. 测试策略与关键测试场景`

`## 输出文件组织`

`分析后的内容将直接存储在当前项目根目录的docs文件夹中,按以下结构组织:`

`docs/`

`├── 需求文档/                                   ``# 需求类文档目录`

`│   ├── {页面标题}-需求分析.md                  ``# 需求深度分析文档(资深产品经理视角)`

`│   └── {页面标题}-验收标准.md                  ``# 验收标准和测试要点`

`├── 技术方案/                                   ``# 技术类文档目录`

`│   └── {页面标题}-架构设计.md                  ``# 架构设计和技术选型(资深架构师视角)`

`└── diagrams/                                   ``# 图表目录`

`    ``├── 需求/                                   ``# 需求相关图表`

`    ``│   ├── {页面标题}-流程图.md                ``# 业务流程图`

`    ``│   └── {页面标题}-思维导图.md              ``# 需求思维导图`

`    ``├── 技术/                                   ``# 技术相关图表`

`    ``│   ├── {页面标题}-架构图.md                ``# 架构图`

`    ``│   └── {页面标题}-组件关系图.md            ``# 组件关系图`

`    ``└── 综合/                                   ``# 综合图表`

`        ``├── 文档关系图.md                       ``# 多文档关系图`

`        ``└── 系统全景图.md                       ``# 系统全景图`

`## 页面类型判断标准`

`### 需求文档特征`

`- 包含``"需求"``、``"功能"``、``"用户故事"``等关键词`

`- 包含UI``/UX``设计描述或界面截图`

`- 包含用户场景或用例描述`

`- 包含验收标准或测试要点`

`- 重点描述``"做什么"``而非``"怎么做"`

`- 通常包含较多截图和用户界面描述`


`### 技术方案特征`

`- 包含``"架构"``、``"设计"``、``"技术方案"``等关键词`

`- 包含技术组件、框架、库的具体描述`

`- 包含数据结构、API设计、流程图`

`- 包含技术实现细节和代码示例`

`- 重点描述``"怎么做"``而非``"做什么"`

`- 通常包含架构图、类图、序列图等技术图表`

`## 页面标题处理规则`


`1. **提取规则**`

`   ```- 从Confluence页面的`<title>`标签或主标题元素提取``

`   ```- 移除标题中的特殊字符(如`/`, ``, `:`, `*`, `?`, `"`, `<`, `>`, `|```

`   ``- 将标题长度限制在100字符以内`

`2. **命名冲突处理**`

`   ``- 检测同名文件,如存在则添加递增数字后缀`

`   ``- 保持原始顺序和关联关系`

`3. **多语言支持**`

`   ``- 支持中英文标题`

`   ``- 保留原始语言,不进行翻译`

`## 内容质量标准`


`1. **完整性**:确保覆盖所有关键需求点,不遗漏重要信息`

`2. **准确性**:分析结果必须基于原始需求,避免主观臆断`

`3. **可执行性**:输出内容应具有实际指导意义,便于团队执行`

`4. **前瞻性**:分析应考虑未来发展空间和潜在扩展需求`

`5. **一致性**:确保各输出文档之间的一致性和协调性`

`6. **关联性**:在处理多页面时,确保分析考虑到页面间的关联关系`

`7. **可读性**:输出文档结构清晰,重点突出,易于阅读和理解`

`8. **专业性**:需求分析体现资深产品经理视角,技术分析体现资深架构师视角`

`## 分析质量保证机制`

`1. **强制最低标准**:`

`   ``- 每个文档必须包含至少50字的内容`

`   ``- 所有必要的文档必须全部生成,不可省略`

`   ``- 所有关键分析维度必须覆盖`

`   ``- 必须按照指定目录结构组织所有输出文件`

`   ``- **所有指定的图表文件必须生成且包含有效内容**`

`   ``- **mermaid图表必须可视化关键概念,并使用正确的语法**`

`2. **自动质量检查**:`

`   ``- 分析完成后自动检查是否满足所有输出要求`

`   ``- 对缺失或不完整的部分进行补充`

`   ``- 确保输出的结构化程度和深度分析水平`

`   ``- 验证是否符合资深产品经理/架构师的专业水准`

`   ``- **特别验证所有图表文件是否已创建并包含有效mermaid代码**`

`   ``- **检查每个mermaid图表是否具有足够的复杂度和相关性**`

`3. **执行检查清单**:`

`   ``- 完整获取页面内容并转换为Markdown`

`   ``- 正确分类为需求文档或技术方案`

`   ``- 创建完整的docs目录结构`

`   ``- 在对应目录下创建所有必要文档`

`   ``- **确保为每个文档创建对应的流程图和关系图**`

`   ``- **验证每个图表文件是否包含有效mermaid语法的图表代码**`

`   ``- **确认每个mermaid图表能正确反映文档核心内容**`

`   ``- 执行至少5轮深度思考分析`

`   ``- 确保所有文档之间的一致性和关联性`

`   ``- 验证目录结构是否完整,所有文件是否都已创建`

`4. **用户反馈响应**:`

`   ``- 当用户指出分析不充分时,立即进行完善`

`   ``- 明确告知用户已激活完整分析模式`

`   ``- 保证所有后续分析都按最高标准执行`

`   ``- 遵循资深产品经理/架构师的专业视角进行分析`

`   ``- **如发现图表文件缺失,立即创建补充**`

`   ``- **如用户指出图表不足,迅速优化或重新创建图表**`

`## 图表生成保障机制`

`1. **自动触发创建**:`

`   ``- 在分析完成后,系统必须自动为每个文档创建对应的图表文件`

`   ``- 不依赖用户请求,必须主动生成所有规定的图表`

`   ``- **分析结束必须主动检查是否已创建所有必需的图表**`


`2. **图表文件检查**:`

`   ``- 完成所有分析后,执行专门的图表文件完整性检查`

`   ``- 检查每个必需的图表文件是否存在`

`   ``- 验证图表内容是否符合要求(包含mermaid代码块、标题和描述)`

`   ``- **检查mermaid语法是否正确,避免图表无法渲染的问题**`

`3. **补救措施**:`

`   ``- 如发现任何图表文件缺失,系统必须立即创建`

`   ``- 对不完整或质量不佳的图表进行优化`

`   ``- 为用户提供图表创建状态的明确反馈`

`   ``- **使用编程性思维确保图表逻辑清晰且有意义**`

`4. **图表内容要求**:`

`   ``- 每个图表必须使用适合的mermaid类型(如flowchart、mindmap、class diagram等)`

`   ``- 图表内容必须直接反映文档的关键信息`

`   ``- 复杂流程必须通过多个图表不同角度展示`

`   ``- 每个图表文件必须包含至少2个不同类型的mermaid图表`

`   ``- **图表节点与连接必须有明确含义,不可随意创建**`

`   ``- **图表设计应遵循清晰、简洁、有层次的原则**`

`## 执行完整性保证机制`

`为确保本规则的所有14个步骤都能被完整执行,特制定以下保证机制:`


`1. **步骤标识**:每个步骤执行前,明确指出当前执行的是第几步,如``"正在执行步骤9:提取内容关键点"``。`

`2. **进度跟踪**:在执行过程中,持续提供进度反馈,明确指出总共14个步骤,当前已执行到第几步。`

`3. **中断检测**:如果检测到执行过程被中断(特别是在步骤8之后),系统必须自动恢复并从中断点继续执行,直到完成所有14个步骤。`

`4. **完整性验证**:在执行完步骤14后,系统必须对所有生成的文件和分析结果进行全面检查,确保所有规定的输出都已生成且内容完整。`

`5. **强制续执行**:如在步骤8后有停止迹象,必须强制继续执行步骤9-14,不接受任何形式的提前结束。对于未完整执行的步骤,系统应在下次交互时优先完成这些步骤。`

`6. **执行报告**:完成所有步骤后,生成执行报告,明确标记每个步骤的执行状态和结果,如有未完成的步骤,必须在报告中指出并安排补充执行。`

`7. **图表文件监控**:整个执行过程中特别关注图表文件的创建状态,确保所有必需的图表文件都被正确创建。`

`遵循以上机制,确保每次分析Confluence页面时,都能完整执行所有14个步骤,提供全面、系统的分析结果。`

Agent对话

(1) Agent对话输入confluence文档,匹配到对应的rule,并调用playwright-mcp工具

image.png

(2)confluence未登录

image.png

(3)confluence已登录

image.png

(4)使用sequentialthinking工具

image.png

(5)创建目录结构

image.png

(6)创建md文档

image.png

(7)最终目录

image.png

OpenLayers 中地图底图添加滤镜

作者 xyzabc
2025年6月27日 10:47

在现代地图应用中,除了地图本身的地理信息外,视觉效果也变得越来越重要。OpenLayers 是一个功能强大的开源地图库,它不仅可以轻松加载地图数据,还可以通过一些技巧为地图添加各种视觉效果,比如滤镜。本文将分享如何使用 OpenLayers 为地图底图添加滤镜,让地图看起来更加独特和美观。

545f3d59d9f7769e394d5665bf396c18.png

一、准备工作

在开始之前,你需要确保已经安装了 OpenLayers。如果你还没有安装,可以通过 npm 安装:

 pnpm install ol

此外,还需要一个地图服务的 URL 和访问令牌(token)。出于安全考虑,这里不会展示具体的 URL 和 token,但你需要从你的地图服务提供商那里获取它们。

二、创建地图

首先,我们需要创建一个地图实例。以下是一个基本的地图初始化代码:

 import Map from "ol/Map";
 import View from "ol/View";
 
 // 地图服务的 URL
 const mapServerUrl = "你的地图服务 URL";
 
 // 地图底图的 URL 模板
 const url = `${mapServerUrl}/{z}/{x}/{y}.png`;
 
 // 创建地图
 const map = new Map({
     target: "map", // 地图容器的 ID
     view: new View({
         center: [116.391046, 39.913607], // 地图中心点
         zoom: 8, // 地图初始缩放级别
     }),
 });

三、为地图添加滤镜

OpenLayers 提供了强大的地图渲染功能,但默认情况下并不支持直接为地图添加滤镜。不过,我们可以通过一些技巧来实现这一功能。

1. 创建滤镜

我们可以通过 HTML5 的 <canvas> 元素和 CanvasRenderingContext2Dfilter 属性来实现滤镜效果。以下是一个简单的滤镜配置示例:

 const tileImageFilter = {
     grayscale: 98, // 灰度
     invert: 100, // 反色
     sepia: 20, // 褐色
     "hue-rotate": 180, // 色相旋转
     saturate: 1600, // 饱和度
     brightness: 80, // 亮度
     contrast: 90, // 对比度
     opacity: 100, // 透明度
     blur: 0, // 模糊
 };
 
 function createCtxFilter(filterOptions) {
     return Object.entries(filterOptions)
         .map(([key, value]) => {
             let normalizedValue = null;
             if (typeof value === "number") {
                 if (key === "blur") {
                     normalizedValue = `${value}px`;
                 } else if (key === "hue-rotate") {
                     normalizedValue = `${value}deg`;
                 } else {
                     normalizedValue = `${value}%`;
                 }
             } else {
                 normalizedValue = value;
             }
 
             return `${key}(${normalizedValue})`;
         })
         .filter((v) => v !== null)
         .join(" ");
 }

2. 实现滤镜功能

我们需要在地图加载每个瓦片时,将瓦片绘制到 <canvas> 上,并应用滤镜。以下是实现代码:

方法一:使用 tileLoadFunction(已废弃,但仍然可以参考)

 import TileLayer from "ol/layer/Tile";
 import { XYZ } from "ol/source";
 
 const canvas = document.createElement("canvas");
 const ctx = canvas.getContext("2d");
 
 const xyzSource = new XYZ({
     url,
     projection: "EPSG:3857",
     tileLoadFunction: function (tile, url) {
         const image = new Image();
         image.src = url;
         image.crossOrigin = "Anonymous";
 
         image.onload = function () {
             canvas.width = image.width;
             canvas.height = image.height;
 
             ctx.clearRect(0, 0, image.width, image.height);
             ctx.filter = createCtxFilter(tileImageFilter);
             ctx.drawImage(image, 0, 0);
             tile.getImage().src = canvas.toDataURL();
         };
     },
 });
 
 map.addLayer(
     new TileLayer({
         source: xyzSource,
         background: "rgba(0,0,0,1)",
     }),
 );

方法二:使用 ImageTileSource(推荐)

 import TileLayer from "ol/layer/Tile";
 import ImageTileSource from "ol/source/ImageTile";
 import { renderXYZTemplate, pickUrl } from "ol/uri";
 
 const canvas = document.createElement("canvas");
 const ctx = canvas.getContext("2d");
 
 function loadImage(template, z, x, y, options) {
     return new Promise((resolve, reject) => {
         const image = new Image();
         image.crossOrigin = options.crossOrigin ?? null;
         image.addEventListener("load", () => {
             canvas.width = image.width;
             canvas.height = image.height;
             ctx.clearRect(0, 0, image.width, image.height);
             ctx.filter = createCtxFilter(tileImageFilter);
             ctx.drawImage(image, 0, 0);
 
             const img = new Image();
             img.src = canvas.toDataURL();
             resolve(img);
         });
         image.addEventListener("error", () => reject(new Error("Image failed to load")));
         image.src = renderXYZTemplate(template, z, x, y, options.maxY);
     });
 }
 
 const xyzSource = new ImageTileSource({
     projection: "EPSG:3857",
     loader: function (z, x, y, options) {
         const template = pickUrl([url], z, x, y);
         return loadImage(template, z, x, y, options);
     },
 });
 
 map.addLayer(
     new TileLayer({
         source: xyzSource,
         background: "rgba(0,0,0,1)",
     }),
 );

四、总结

通过上述方法,我们可以在 OpenLayers 中为地图底图添加各种滤镜效果。虽然 OpenLayers 没有直接支持滤镜的 API,但通过结合 HTML5 的 <canvas>CanvasRenderingContext2Dfilter 属性,我们可以轻松实现这一功能。你可以根据自己的需求调整滤镜参数,创造出独特的地图视觉效果。

希望这篇文章对你有所帮助!如果你有任何问题或建议,欢迎在评论区留言。

❌
❌