普通视图

发现新文章,点击刷新页面。
昨天 — 2025年8月18日技术

Trae完成反应力测试小游戏

2025年8月18日 22:37

前言

前几天看到一个的反应力测试游戏,的网站,看起来还是很有趣的,想让 Trae 用代码复刻这个游戏的核心功能,顺便自己也测试一下自己的反应力属于什么水平,开干,玩得就是真实。

今天就看看 Trae 怎么实现这一款充满趣味的反应力测试游戏。

这个游戏的核心功能

先把这个核心逻辑发给 Trae,看看他完成的是不是你想要复刻的反应力测试小游戏。 image.png

最终的效果还是很不错的,来讲讲这一次实现都遇到那些bug了

image.png

让 Trae 把布局的样式做的很好看,没有明显违和感,我的要求不高,不要有明显的 bug 即可。

先来试试按下按键时的反应时间是不是准确,出现的反应时间数字,是符合实际反应的。 按下开始测试的按钮没有反应,打开控制台报错了,赶紧让Trae修复,这可是致命的bug

image.png

点击"开始测试"按钮无反应的问题是由于CSS层级冲突导致的,改一下z-index即可解决问题

Trae 代码解读

通过设定屏幕的初始状态,来初始化游戏的布局,通过事件监听来判断屏幕变绿时玩家的按键动作。

screen.addEventListener('click', (e) => {
    if (screen.style.backgroundColor === 'green') {
        startTime = new Date().getTime();
        screen.style.backgroundColor = 'black';
        recordReactionTime();
    }
});

通过逻辑判断实现玩家按键后的反应时间计算,判断反应时间并给出相应的评价。

function recordReactionTime() {
    let endTime = new Date().getTime();
    reactionTime = endTime - startTime;
    reactionTimeElement.textContent = reactionTime + 'ms';
    // 给出评价
    if (reactionTime < 200) {
        evaluationElement.textContent = '优秀!反应极快';
    } else if (reactionTime < 400) {
        evaluationElement.textContent = '良好!反应不错';
    } else if (reactionTime < 600) {
        evaluationElement.textContent = '一般!反应有待提高';
    } else {
        evaluationElement.textContent = '较慢!反应需要加强';
    }
    updateStatistics();
}

测试过程中避免提前按下鼠标的代码逻辑 image.png

最后是来自 Trae 自己对这款反应力测试的总结,主要是游戏功能和设计,还有考虑到游戏体验,非常的人性化,设计了颜色区分,给玩家一种强烈的视觉冲击,这一效果确实还不错。

image.png

反应太慢是红色颜色的字体评价 image.png

Trae 在生成时,考虑的情况,主要是反应时间计算、评价标准、统计更新等因素来给出评价。 image.png

总结

1、这个游戏的核心功能,主要是靠玩家的反应速度,Trae 非常人性化的支持多次测试,让玩家可以看到自己的进步。(我最好的成绩是25ms)

image.png

image.png 2、考虑到游戏玩家可能没玩过,Trae 也是帮我们设计了对应的游戏说明,不会让新手玩家一头雾水,可以直接上手测试。

虽然没办法百分百还原别人的网站,但是起码可以自己测试,感兴趣的可以自己动手测试Trae,看看能不能实现我这样的效果,快去试试ai编程的魅力吧~。

前端无法获取响应头(如 Content-Disposition)的原因与解决方案

作者 小小愿望
2025年8月18日 22:23

一、问题背景

在前后端分离项目中,前端通过 AJAX 或 Fetch 请求接口时,发现无法获取响应头中的 Content-Disposition(用于文件下载的文件名指定)但是在浏览器开发者工具的 Network 面板中,可以看到Content-Disposition,就是取不到值。例如:

  • 后端已设置 Content-Disposition: attachment; filename="test.txt"
  • 前端尝试通过 response.headers['content-disposition'] 获取时返回 null

二、核心原因

1. CORS 默认隐藏非简单响应头

浏览器默认只允许前端访问有限的“简单响应头”(如 Cache-Control、Content-Type 等),而 Content-Disposition 等自定义响应头默认被隐藏。

2. 未显式暴露目标响应头

服务器虽设置了 Content-Disposition,但未通过 Access-Control-Expose-Headers 明确允许前端访问该头,导致前端无法读取。


三、解决方案

1. 后端配置 CORS,暴露目标响应头

原理

通过 Access-Control-Expose-Headers 指定允许前端访问的响应头。

实现示例

Spring Boot (Java)
response.setHeader("Content-Disposition", "attachment; filename=\"test.txt\"");
response.setHeader("Access-Control-Expose-Headers", "Content-Disposition");
Node.js (Express)
const express = require('express');
const cors = require('cors');
const app = express();

app.use(cors({
    origin: 'http://frontend-domain', // 或 '*' 允许所有域名
    exposedHeaders: ['Content-Disposition'], // 关键配置
}));

app.get('/download', (req, res) => {
    res.set('Content-Disposition', 'attachment; filename="test.txt"');
    res.send('File content');
});

app.listen(3000);
Nginx 反向代理
server {
    listen 80;
    server_name your-domain.com;

    location /api/ {
        proxy_pass http://backend-server;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        add_header Access-Control-Expose-Headers "Content-Disposition"; // 关键配置
    }
}

2. 确保后端正确设置 Content-Disposition

示例

response.setHeader("Content-Disposition", "attachment; filename=\"test.txt\"");
response.setHeader("Access-Control-Expose-Headers", "Content-Disposition");

注意事项:

  • 避免仅在本地调试时设置该头,需确保生产环境代码中也包含。
  • 动态生成文件名的场景需注意特殊字符处理(如引号、空格等)。

3. 前端正确获取响应头

示例代码

// 使用 Fetch API 获取响应头并触发下载
fetch('https://api.example.com/download')
    .then(response => {
        // 获取 Content-Disposition 头
        const disposition = response.headers['content-disposition'];
        if (disposition && disposition.includes('attachment')) {
            const filename = disposition.split('filename=')[1].replace(/["']/g, '');
            return response.blob().then(blob => {
                // 创建下载链接
                const url = window.URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.href = url;
                a.download = filename;
                document.body.appendChild(a);
                a.click();
                a.remove();
                window.URL.revokeObjectURL(url);
            });
        }
        return response.json(); // 处理其他情况
    })
    .catch(error => console.error('Error:', error));

五、验证步骤

  1. 直接访问接口:在浏览器地址栏输入 http://localhost:8080/download,应自动触发文件下载。
  2. 跨域请求测试:将前端部署到其他域名(如 http://localhost:3000),点击按钮触发下载。
  3. 检查响应头:在浏览器开发者工具的 Network 面板中,确认响应头包含:
    • Access-Control-Expose-Headers: Content-Disposition
    • Content-Disposition: attachment; filename="test.txt"

六、常见问题排查

  • 问题1:前端仍然无法获取 Content-Disposition
    解决:检查后端是否真正配置了 exposedHeaders,代理服务器是否转发了该头。

  • 问题2:文件下载失败但接口返回正常
    解决:确保后端正确设置 Content-Disposition,且文件路径有效,前端请求时设置responseType: 'blob'

  • 问题3:下载文件无法打开
    解决:确保前端请求时设置responseType: 'blob'


通过以上配置,前端即可安全地获取 Content-Disposition 等自定义响应头,实现文件下载功能。

项目启功需要添加SKIP_PREFLIGHT_CHECK=true该怎么办?

作者 小小愿望
2025年8月18日 22:23

遇到问题不要慌:查资料→破难题→记妙招,今日份成长已签收! 👍 👍👍对对对!

分析这个问题的原因是依赖冲突导致的,然而为什么会有依赖冲突?Node.js 采用嵌套式依赖结构,当不同层级的包对同一依赖要求不同版本时,就会触发冲突。例如:

  • A 包依赖 lodash@^4.0.0
  • B 包依赖 lodash@^5.0.0
    此时 npm 需要裁决最终安装哪个版本,若无法调和则报错退出。

当执行 npm install 遇到依赖冲突时,可以使用 --force--legacy-peer-deps 参数来解决问题。

  • --force‌:这个参数会强制 npm 获取远程资源,即使磁盘上存在本地副本也是如此。它可能会忽略一些依赖冲突,但可能会导致不稳定的依赖关系,因此应谨慎使用。

  • ‌--legacy-peer-deps‌:这个参数是在 npm v7 中引入的,用于在安装时忽略所有 peerDependencies,采用 npm 版本 4 到版本 6 的样式。在很多情况下,npm v7 默认安装 peerDependencies 会导致版本冲突,从而中断安装过程。使用 --legacy-peer-deps 可以绕过这些冲突,继续安装。

之前项目都能正常使用,不知道做了什么,然后再执行npm install 的时候就会出现这种提示,很是烦人,不添加 --force 还安装不成功。

如果添加了`--force` 在启动的时候又会出现如下问题,虽然添加了`SKIP_PREFLIGHT_CHECK=true` 可以解决问题,也很麻烦。
If you would prefer to ignore this check, add SKIP_PREFLIGHT_CHECK=true to an .env file in your project.
That will permanently disable this message but you might encounter other issues.

To fix the dependency tree, try following the steps below in the exact order:

  1. Delete package-lock.json (not package.json!) and/or yarn.lock in your project folder.
  2. Delete node_modules in your project folder.
  3. Remove "webpack" from dependencies and/or devDependencies in the package.json file in your project folder.
  4. Run npm install or yarn, depending on the package manager you use.

接下来要分析原因。根据报错信息,Create React App工具链会进行预检(preflight check),确保依赖树中的特定包版本符合要求。例如,react-scripts可能需要某个确切版本的webpack,如果项目中存在更高或更低的版本,就会触发这个错误。这时候有两种选择:要么修复依赖关系,要么用SKIP_PREFLIGHT_CHECK=true来跳过检查。

⚠️ 警告:以上方法均属权宜之计,可能导致运行时不可预料的错误!建议仅用于紧急恢复开发环境。

使用npm ls webpack命令查看webpack版本,果然发现了异类,这个时候就要分析这个依赖了,如果能修复尽量修复,我是经过反复查找,发现项目中没有使用,然后就删除了,接下来删除lock文件或node_modules重新安装依赖,问题完美解决。

如果实在解决不了,还是老老实实的在 .env 文件中添加SKIP_PREFLIGHT_CHECK=true吧。此操作将永久禁用 Create React App 的依赖树前置检查机制。

精简之道:TypeScript 参数属性 (Parameter Properties) 详解

作者 烛阴
2025年8月18日 22:13

一、什么是参数属性?

参数属性是一种简洁的语法,是TypeScript独特的语法糖,它允许你在构造函数的参数列表中,通过添加访问修饰符(public, private, protected)或 readonly 关键字,来一次性完成属性的声明和初始化

示例:

class User {
    constructor(public name: string, private age: number, readonly id: number) {
        // 构造函数体可以是空的,因为声明和赋值已经自动完成了!
        // TypeScript 在幕后为你做了三件事:
        // 1. 声明了一个 public 的 name 属性。
        // 2. 声明了一个 private 的 age 属性。
        // 3. 声明了一个 readonly 的 id 属性。
        // 4. 自动完成了 this.name = name, this.age = age, this.id = id。
    }

    public getAge(): number {
        return this.age; // age 是 private 的,但可以在类内部访问
    }
}

const user = new User('Alice', 30, 123);
console.log(user.name); // "Alice" (public, 可访问)
// console.log(user.age); // Error: 属性'age'是私有的,只能在类'User'中访问。
console.log(user.getAge()); // 30 (通过公共方法访问)
console.log(user.id); // 123 (readonly, 可访问但不可修改)
// user.id = 456; // Error: 无法分配到 'id' ,因为它是只读属性。


二、参数属性的规则与组合

参数属性不仅仅是 private 的专利,它可以与所有访问修饰符以及 readonly 组合使用:

  • public:成员在任何地方都可见。(如果省略修饰符,参数默认不会成为属性)。
  • private:成员只能在声明它的类的内部访问。
  • protected:成员可以在声明它的类及其子类的内部访问。
  • readonly:成员在初始化后不能被再次赋值,有助于创建不可变(immutable)对象。

你可以自由组合它们(readonly 通常与访问修饰符一起使用)。

总结

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

JavaScript 异步编程全解析:Promise、Async/Await 与进阶技巧

作者 excel
2025年8月18日 20:18

目标:彻底搞懂 JS 异步模型、Promise/A+ 语义、微任务调度、错误传播、合成/并发策略、取消/超时/进度等“扩展技巧”,以及 async/await 的工程化实践。


1. 异步编程(为什么需要异步)

  • JS 单线程 + 事件循环:调用栈一次只跑一个任务。耗时 I/O(网络、磁盘、定时器)若同步执行会阻塞 UI/后续逻辑。
  • 运行时协作:浏览器/Node 把耗时操作委托给底层,完成后把“回调”(或 Promise 的处理程序)放回任务队列(宏任务/微任务)等待主线程空闲再执行。
  • 常见异步源fetch/XMLHttpRequestsetTimeout/setInterval、事件监听、MessageChannelprocess.nextTick(Node)、文件/数据库 I/O(Node)。

2. 同步 vs. 异步(发展脉络)

  1. 回调(Callback) → 简单但容易回调地狱、错误难传递、可组合性差。
  2. Promise(期约) → 统一状态机与链式处理,解决“控制反转”和错误传递。
  3. async/await(Promise 语法糖)→ 代码结构接近同步,可读性/调试性更好。

3. 以往的异步编程模式(回调时代)

(1)异步返回值

function getData(cb) {
  setTimeout(() => cb(null, "OK"), 1000);
}
getData((err, data) => { if (!err) console.log(data); });

不能 return 结果,只能通过回调“把结果推回去”。

(2)失败处理(错误优先回调)

function getData(cb) {
  setTimeout(() => cb(new Error("请求失败")), 1000);
}
getData((err) => { if (err) console.error(err.message); });

(3)嵌套异步回调(回调地狱)

setTimeout(() => {
  console.log("步骤1");
  setTimeout(() => {
    console.log("步骤2");
    setTimeout(() => console.log("步骤3"), 1000);
  }, 1000);
}, 1000);

结构呈金字塔,可读性差、错误处理分散、难以复用与组合。


期约(Promise)

1)Promises/A+ 规范(简述)

  • 状态机pending → fulfilled | rejected,且不可逆只结算一次
  • thenthen(onFulfilled?, onRejected?) 必须返回新 Promise,让链式/扁平化成为可能。
  • 同一处理序:处理程序是异步执行(微任务),保证非重入。

2)期约的基础

(1)状态机

const p = new Promise((resolve, reject) => {
  // 只能二选一,且只能一次性结算
  resolve("成功"); // 或者 reject(new Error("失败"))
});

(2)解决值(value)与拒绝理由(reason)

Promise.resolve({ id: 1 });       // fulfilled,value 为对象
Promise.reject(new Error("X"));    // rejected,reason 为 Error

(3)通过执行函数控制状态

const p = new Promise((resolve, reject) => {
  try {
    const ok = Math.random() > 0.5;
    ok ? resolve("OK") : reject(new Error("Fail"));
  } catch (e) {
    reject(e);
  }
});

(4)Promise.resolve(value)

  • valuethenable,会**“吸收/采用”**其状态。
Promise.resolve(42).then(v => console.log(v)); // 42

const thenable = { then(res) { res("来自 thenable"); } };
Promise.resolve(thenable).then(console.log); // "来自 thenable"

(5)Promise.reject(reason)

Promise.reject(new Error("Oops")).catch(e => console.log(e.message));

(6)同步/异步执行的“二次元边界”(try/throw/reject)

  • return new Error(...) 不会抛错,只是返回一个普通值。
  • throw new Error(...) 会被同步 try/catch 捕获。
  • Promise.reject(err) 不会被同步 try/catch 捕获(它是异步的拒绝),需要 .catch()await+try/catch
// A:return Error —— 不会被 try/catch 捕获
try {
  function f() { return new Error("只是返回值"); }
  f();
} catch (e) { console.log("不会触发"); }

// B:throw —— 会被捕获
try {
  function g() { throw new Error("会被捕获"); }
  g();
} catch (e) { console.log("捕获到:", e.message); }

// C:Promise.reject —— 同步 try/catch 捕不到
try {
  Promise.reject(new Error("reject!"));
} catch (e) {
  console.log("也不会触发");
}

// D:await + try/catch —— 可以捕获拒绝
(async () => {
  try {
    await Promise.reject(new Error("await 可捕获"));
  } catch (e) {
    console.log("捕获到:", e.message);
  }
})();

3)期约的实例方法(核心用法)

(1)Thenable 接口是什么、为什么

  • Thenable:任何形如 { then(resolve, reject) {} } 的对象。
  • Promise.resolve(thenable) 会“采用”该对象的结果。这让三方库、自定义异步体与 Promise 生态无缝衔接。

(2)Promise.prototype.then(onFulfilled?, onRejected?)

  • 两个可选回调;无论你传不传,then 都返回一个新 Promise

  • 返回值与错误传播

    • 返回普通值 → 包装为 fulfilled。
    • 返回Promise/Thenable采用其状态。
    • 抛出异常/返回被拒绝的 Promise → 变为 rejected。
Promise.resolve(1)
  .then(v => v + 1)                 // 2(普通值)
  .then(v => Promise.resolve(v * 3))// 6(返回另一个 Promise)
  .then(() => { throw new Error("炸了"); }) // 抛出 → 进入后续 catch
  .catch(e => "已处理:" + e.message) // 转为 fulfilled("已处理:炸了")
  .then(console.log);               // 输出:已处理:炸了

区别:返回错误对象 vs 抛出错误

// 返回一个 Error 对象(普通值)——不会触发 catch
Promise.resolve()
  .then(() => new Error("只是个值"))
  .then(v => console.log("拿到的是值:", v instanceof Error)); // true

// 抛出错误(或返回 rejected)——会触发 catch
Promise.resolve()
  .then(() => { throw new Error("真的错了"); })
  .catch(e => console.log("被捕获:", e.message));

(3)Promise.prototype.catch(onRejected)

  • 等价于 .then(undefined, onRejected);更语义化,建议链尾统一使用:
doTask().then(handle).catch(logError);

(4)Promise.prototype.finally(onFinally)

  • 无论前面成功/失败都会执行;不改变链的值/理由(除非 finally 内抛错或返回拒绝):
Promise.resolve(42)
  .finally(() => console.log("清理"))
  .then(v => console.log(v)); // 42

Promise.reject("X")
  .finally(() => console.log("也会执行"))
  .catch(e => console.log(e)); // X

(5)非重入与微任务(执行顺序)

  • Promise 处理程序(then/catch/finally)总是放入微任务队列,在本轮同步代码结束后、下一个宏任务之前执行。
console.log("A");
Promise.resolve().then(() => console.log("微任务"));
console.log("B");
// 输出:A → B → 微任务
  • 即便 Promise 已同步 resolve,后面注册的 then不会立刻执行,而是入微任务。

(6)邻近处理程序的执行顺序

  • 同一个 Promise 上注册的多个 then,按注册顺序依次触发,彼此并行依附(不是链):
const p = Promise.resolve(0);
p.then(() => console.log(1));
p.then(() => console.log(2));
p.then(() => console.log(3));
// 输出:1 → 2 → 3

(7)传递解决值与拒绝理由

  • 值的传递规则:返回什么,下一步就拿到什么throw/返回拒绝 → 进入下一个可处理拒绝的处理程序(catchthen 的第二参)。

(8)拒约期约与错误处理(全景)

  • 链尾捕获:始终在链尾 .catch(),避免“游离拒绝”。

  • 全局兜底(避免崩溃 & 记录日志)

    • 浏览器:

      window.addEventListener('unhandledrejection', e => {
        console.error('未处理拒绝:', e.reason);
      });
      
    • Node:

      process.on('unhandledRejection', (reason, p) => {
        console.error('未处理拒绝:', reason);
      });
      

4)期约连锁与期约合成

(1)期约连锁(Promise Chaining)

  • 把一串依赖步骤扁平化,便于线性阅读与集中错误处理。
fetchJSON('/api/a')
  .then(a => fetchJSON(`/api/b?id=${a.id}`))
  .then(b => process(b))
  .catch(logError);

(2)期约图(Fan-out / Fan-in)

  • 一个节点输出分叉成多个并行子任务,再汇聚到下一步:
const base = Promise.resolve(1);
const p1 = base.then(v => v + 1);
const p2 = base.then(v => v + 2);
Promise.all([p1, p2]).then(([x, y]) => console.log(x, y)); // 2 3

(3)Promise.all vs Promise.race(另补:allSettledany

  • Promise.all([a,b,c])全部 fulfilled 才 fulfilled;任何一个 rejected → 立刻 rejected;结果是按原顺序的数组。
  • Promise.race([a,b,c]):**第一个 settle(无论成败)**就返回。
  • Promise.allSettled([...]):等待全部 settle,返回每个结果的 {status, value|reason}
  • Promise.any([...])第一个 fulfilled 就返回;若全 rejected → 抛 AggregateError
const slow = ms => new Promise(r => setTimeout(() => r(ms), ms));
Promise.all([slow(100), slow(200)]).then(console.log);   // [100, 200]
Promise.race([slow(100), slow(200)]).then(console.log);  // 100
Promise.allSettled([Promise.resolve(1), Promise.reject("X")])
  .then(console.log); // [{status:'fulfilled',value:1},{status:'rejected',reason:'X'}]
Promise.any([Promise.reject('a'), Promise.resolve('b')]).then(console.log); // 'b'

5)串行期约的合成

(1)什么是串行合成(Serial Composition)

  • 将一组任务按顺序执行,上一个的输出作为下一个的输入或前置条件。
const urls = ["/a", "/b", "/c"];
async function serialFetch(urls) {
  const out = [];
  for (const u of urls) {
    const res = await fetch(u);   // 串行:逐个等待
    out.push(await res.json());
  }
  return out;
}

(2)串行合成 vs Promise.all

  • Promise.all并行,总时长≈最长的那个;
  • 串行是逐个等待,总时长≈所有时长之和;
  • 何时用串行:有前后依赖或需要限流/降低压力。

(3)串行合成 vs race/allSettled/any

  • race 用于抢占式返回;串行强调顺序依赖
  • allSettled 用于需要完整结果矩阵;串行更像流水线
  • any 侧重“谁先成功”;串行则“必须按顺序全部完成”。

并发受控(限并发) :既不是“全部并行”也不是“完全串行”

// 简易限并发执行器(并发数 n)
function pLimit(n) {
  const queue = [];
  let active = 0;

  const next = () => {
    if (active >= n || queue.length === 0) return;
    active++;
    const { fn, resolve, reject } = queue.shift();
    fn().then(resolve, reject).finally(() => {
      active--;
      next();
    });
  };

  return (fn) => new Promise((resolve, reject) => {
    queue.push({ fn, resolve, reject });
    next();
  });
}

// 使用:
const limit = pLimit(3);
const tasks = Array.from({ length: 10 }, (_, i) => () =>
  new Promise(r => setTimeout(() => r(i), 200))
);
Promise.all(tasks.map(t => limit(t))).then(console.log);

6)期约的“扩展”技巧(取消/超时/进度/多值)

标准 Promise 不支持取消/进度/多次结算,但可以通过组合实现工程诉求。

(1)取消期约(推荐:AbortController

  • 声明:Promise 自身不能真正“取消”已开始的外部操作,但可提前决议当前 Promise,并让底层可取消的 API(如 fetch)停止。
const controller = new AbortController();
const p = fetch('/api', { signal: controller.signal });

// 某个条件触发“取消”
controller.abort(); // fetch 中止;p 变为 rejected,reason 为 DOMException('AbortError')
  • 自定义“可取消包装”(只能提前返回,不能强制终止底层不可取消操作):
function makeCancelable(task) {
  let cancel;
  const cancelPromise = new Promise((_, reject) => { cancel = () => reject(new Error("Canceled")); });
  return {
    promise: Promise.race([task, cancelPromise]),
    cancel
  };
}

const { promise, cancel } = makeCancelable(new Promise(r => setTimeout(() => r("OK"), 2000)));
setTimeout(cancel, 500);
promise.catch(e => console.log(e.message)); // "Canceled"

(3)进度通知

  • Promise 不支持过程性通知;常见做法:

    • 回调/事件:通过回调多次上报;Promise 只在完成时返回最终结果。
    • Observable/事件源/ReadableStreamasync iterator(更自然的多次产出)。
// 回调版
function download(url, onProgress) {
  let loaded = 0, total = 100;
  const timer = setInterval(() => {
    loaded += 10; onProgress(loaded / total);
    if (loaded >= total) { clearInterval(timer); }
  }, 100);
  return new Promise(r => setTimeout(() => r("DONE"), 1100));
}

download('/file', p => console.log('progress:', p))
  .then(console.log);
// 自定义一个带进度通知的 Promise
class NotifiablePromise extends Promise {
  constructor(executor) {
    let notifyFn; // 保存外部可用的 notify
    super((resolve, reject) => {
      executor(resolve, reject, (progress) => {
        if (notifyFn) notifyFn(progress);
      });
    });
    this._listeners = [];
    notifyFn = (progress) => {
      this._listeners.forEach(fn => fn(progress));
    };
  }

  onProgress(fn) {
    this._listeners.push(fn);
    return this; // 支持链式调用
  }
}

// 使用示例
function download(url) {
  return new NotifiablePromise((resolve, reject, notify) => {
    let loaded = 0, total = 100;
    const timer = setInterval(() => {
      loaded += 10;
      notify(loaded / total); // ⬅️ 触发进度事件
      if (loaded >= total) {
        clearInterval(timer);
        resolve("DONE");
      }
    }, 100);
  });
}

// 多监听器订阅进度
download("/file")
  .onProgress(p => console.log("监听器1:", p))
  .onProgress(p => console.log("监听器2:", (p * 100).toFixed(0) + "%"))
  .then(console.log);

异步函数(async/await)

7)异步函数(概念与语义)

  • async function 总是返回 Promise;函数体内 throw => 返回被拒绝的 Promise。

  • await x

    • x 是 Promise/thenable → 等其 settle;
    • x 是非 thenable 值 → 直接当作已解决值。
  • await 的对象并不要求是原生 Promise,实现 Thenable 即可。

(1)await 的使用场景与示例

// await 接 thenable
const thenable = { then(res) { setTimeout(() => res(42), 10); } };
(async () => {
  const v = await thenable; // 42
  console.log(v);
})();

(2)await 的限制

  • 只能在 async 函数或 ESM 模块的顶层 使用(Top-Level Await)。
async function main() {
  const data = await fetch('/api');
  return data;
}

(3)停止与恢复执行(可读的“同步风格”)

async function flow() {
  console.log('A');
  await sleep(500); // 这里“暂停”当前 async 函数
  console.log('B'); // Promise 结算后“恢复”
}

错误捕获差异

// 同步 try/catch 抓不到 Promise.reject
try { Promise.reject(new Error('x')); } catch (e) { /* 不会走 */ }

// async/await 里就能抓
(async () => {
  try { await Promise.reject(new Error('x')); }
  catch (e) { console.log('抓到了'); }
})();

8)异步函数策略(工程实践)

(1)实现 sleep 函数

export const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

// 使用
await sleep(1000);

(2)利用“平行执行”(先开 promise,再 await)

避免“串行等待”,显著降低总时长。

async function parallel() {
  const p1 = fetch('/a'); // 立即发起
  const p2 = fetch('/b'); // 立即发起
  const [a, b] = await Promise.all([p1, p2]); // 并行等待
  return [await a.json(), await b.json()];
}

async function serialThree() {
  // ❌ 串行等待(逐个 await)
  const tasks = [
    mockTask("任务1", 1000),
    mockTask("任务2", 2000),
    mockTask("任务3", 1500)
  ];

  const results = [];
  for (const t of tasks) {
    results.push(await t); // 每次都等上一个完成
  }
  console.log("全部完成(串行):", results);
}

// 执行
parallelThree().then(() => {
  console.log("------");
  serialThree();
});

(3)串行执行期约(有依赖或限流场景)

async function serial(urls) {
  const out = [];
  for (const u of urls) {
    const r = await fetch(u);      // 必须等上一个结束
    out.push(await r.text());
  }
  return out;
}

(4)栈追踪与内存管理(调试观感)

  • 直接 Promise 链抛错:栈可能跨越微任务边界,信息冗长。
  • await 抛错:引擎可提供更“线性”的异步栈,更接近同步调用链;调试可读性更好。
// 对比感受:两个函数抛同样的错误
function byThen() {
  return Promise.resolve().then(() => { throw new Error("bad"); });
}
async function byAwait() {
  await Promise.resolve();
  throw new Error("bad");
}
byThen().catch(e => console.error("then 栈:", e.stack));
byAwait().catch(e => console.error("await 栈:", e.stack));

关键细节与坑位清单

  • 务必链尾 .catch() ,否则可能触发全局 unhandledrejection

  • then第二参.catch() 任选其一;风格统一更重要,推荐链尾 .catch()

  • 不要在循环里无脑 await(若无依赖),先建数组并行await Promise.all

  • finally 不改变链的值(除非内部抛错/拒绝)。

  • 微任务优先于下一轮宏任务:Promise.then 回调总在 setTimeout(..., 0) 之前。

  • **不要把错误对象当“返回值”**交给下一个 then,真的错误就 throwreturn Promise.reject(e)

  • 取消要区分“提前返回”与“真正停止”:配合 AbortController 才能让底层 I/O 中断。

  • 合成选择

    • 等全部且“全成功” → all
    • 谁先 settle 就要谁 → race
    • 每个结果(成功/失败都要) → allSettled
    • 只要第一个成功 → any

不会canvas,让Trae来教你吧

2025年8月18日 19:19

很多前端同学想学Canvas,但看到官方文档的API就头大,瞬间打消学习的兴趣。

今天咱们就使用Trae,用一个超简单的动态时钟,把Canvas的核心概念一次讲清楚。

先来看看最终的效果,是不是很优雅 image.png

为什么要学Canvas?

网页上的动画、游戏、图表等效果,很多都用Canvas。你可以理解为一块画布,让你用JavaScript画画,想要啥就画啥。

准备画布

<canvas id="clock" width="280" height="280"></canvas>

这一步相当于在页面上有了一块可以画画的画布。

注意:canvas的宽高要在标签里写,别在CSS设置,不然会导致画布的形状变形。

拿到画笔(使用js获取)

const canvas = document.getElementById('clock');
const ctx = canvas.getContext('2d');

getContext('2d')就是拿到2D画笔,有了它才能开始画画。

第三步:坐标系搞清楚

Canvas的坐标原点在左上角,向右是x轴正方向,向下是y轴正方向。为了让时钟居中,咱们把原点移到画布中心:

const radius = canvas.width / 2;
ctx.translate(radius, radius);

现在(0,0)点就是画布中心了,画什么都方便。

第四步:画圆(表盘)

时钟就是个圆,用arc方法:

ctx.beginPath();
ctx.arc(0, 0, radius * 0.95, 0, 2 * Math.PI);
ctx.fillStyle = 'white';
ctx.fill();

image.png

arc(x, y, 半径, 起始角度, 结束角度),角度用弧度表示,2π就是一圈。

第五步:画刻度

时钟有12个小时刻度,每个刻度角度是30度(2π/12)。用循环画:

for (let num = 1; num <= 12; num++) {
    const angle = (num * Math.PI) / 6;
    ctx.rotate(angle);
    ctx.moveTo(0, -radius * 0.92);
    ctx.lineTo(0, -radius * 0.82);
    ctx.stroke();
    ctx.rotate(-angle); // 记得转回来
}

rotate是旋转画布,画完要转回来,不然下一条线就歪了。 image.png

第六步:画数字

数字12的位置在正上方,角度是-90度(-π/2):

ctx.font = "30px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("12", 0, -radius * 0.68);

image.png

第七步:让指针动起来

指针就是三条线,根据当前时间计算角度:

const now = new Date();
const hour = now.getHours();
const minute = now.getMinutes();
const second = now.getSeconds();
// 时针角度
const hourAngle = ((hour % 12) * 30 + minute * 0.5) * Math.
PI / 180;
// 分针角度
const minuteAngle = (minute * 6 + second * 0.1) * Math.
PI / 180;
// 秒针角度
const secondAngle = (second * 6) * Math.PI / 180;

第八步:动画效果

用setInterval让时钟动起来:

function drawClock() {
    ctx.clearRect(-radius, -radius, canvas.width, canvas.
    height);
    // 重画所有内容
}
setInterval(drawClock, 1000);

查看画面,功能是对的,但是歪的,刻度盖住了,让Trae修复一下

image.png Trae的第一次修复 image.png 还是有问题,再次让Trae修复,这波可真是一波三折,好在最后还是实现了 image.png

完整思路

1.先画静态的表盘(圆+刻度+数字)

2.再画动态的指针(根据时间计算角度)

  1. 用定时器每秒更新一次

小技巧

  • 先画大部件,再画小细节
  • 用save()和restore()保存和恢复画布状态
  • 角度计算用弧度制,180度=π弧度

看完这个,你应该明白Canvas的基本用法了。试着改改颜色、大小,或者加个日期显示,动手最重要!

vue3入门-v-model、ref和reactive讲解

作者 定栓
2025年8月18日 19:04

组件上v-model用法

在 Vue 2.0 发布后,开发者使用 v-model 指令必须使用为 valueprop。如果开发者出于不同的目的需要使用其他的 prop,他们就不得不使用 v-bind:propName.sync。此外,由于 v-modelvalue 之间的这种硬编码关系的原因,产生了如何处理原生元素和自定义元素的问题。

在 Vue 2.2 中,我们引入了 model 组件选项,允许组件自定义用于 v-modelprop事件。但是,这仍然只允许在组件上使用一个 model

在 Vue 3 中,双向数据绑定的 API 已经标准化,减少了开发者在使用 v-model 指令时的混淆并且在使用 v-model 指令时可以更加灵活。

首先让我们回忆一下 v-model原生元素上的用法:

<input v-model="text" />

在代码背后,模板编译器会对 v-model 进行更冗长的等价展开。因此上面的代码其实等价于下面这段:

<input :value="text" @input="text = $event.target.value" />

接下来我们看下在自定义组件上的用法。

2.x语法

<ChildComponent v-model="text" />
<!-- 去除 v-model 语法糖后的写法 -->
<ChildComponent :value="text" @input="text = $event" />

ChildComponent.vue

<template>
  <input :value="value" @input="($event) => $emit('input', $event.target.value)" />
</template>
<script>
  export default {
    props: ['value'],
  }
</script>

如果要将属性或事件名称更改为其他名称,则需要在 ChildComponent 组件中添加 model 选项:

ParentComponent.vue

<myComponent v-model="isChecked" />

ChildComponent.vue

<template>
  <input type="checkbox" :checked="checked" @change="($event) => $emit('change', $event.target.checked)" />
</template>
<script>
  export default {
    model: {
      // 使用 `checked` 代替 `value` 作为 model 的 prop
      prop: 'checked',
      // 使用 `change` 代替 `input` 作为 model 的 event
      event: 'change'
    },
    props: {
      checked: Boolean
    }
  }
</script>

在这个例子中,父组件 v-model 的实际内部处理如下:

<ChildComponent :value="text" @change="text = $event" />

使用 v-bind.sync

在某些情况下,我们可能需要对某一个 prop 进行“双向绑定”(除了前面用 v-model 绑定 prop 的情况)。为此,我们建议使用 update:myPropName 抛出事件。

假设 ChildComponent 带有 title prop ,我们可以通过下面的方式将分配新 value 的意图传达给父级:

this.$emit('update:title', newValue)

如果需要的话,父级可以监听该事件并更新本地 data property。例如:

<ChildComponent :title="text" @update:title="text = $event" />

为了方便起见,我们可以使用 .sync 修饰符来缩写,如下所示:

<ChildComponent :title.sync="text" />

3.x语法

当使用在一个组件上时,v-model 会被展开为如下的形式:

<template> 
  <myComponent model-value="text" @update:model-value="($event) => text = $event" />
  <div>{{ text }}</div>
</template>
<script setup>
import { ref } from 'vue'
import myComponent from './components/myComponent.vue'
const text = ref('')
</script>

要让这个例子实际工作起来,<myComponent> 组件内部需要做两件事:

  • 将内部原生 <input> 元素的 value attribute 绑定到 modelValue prop
  • 当原生的 input 事件触发时,触发一个携带了新值的 update:modelValue 自定义事件

这里是相应的代码:

myComponent.vue

<template>
  <input :value="modelValue" @input="(e) => $emit('update:modelValue', e.target.value)" />
</template>
<script setup>
const props = defineProps(['modelValue'])
const emits = defineEmits(['update:modelValue'])
</script>

现在 v-model 可以在这个组件上正常工作了:

<myComponent v-model="text" />

另一种在组件内实现 v-model 的方式是使用一个可写的,同时具有 gettersettercomputed 属性。get 方法需返回 modelValue prop,而 set 方法需触发相应的事件:

myComponent.vue

<template>
  <input v-model="value" />
</template>
<script setup>
import { computed } from 'vue'
const { modelValue } = defineProps(['modelValue'])
const emits = defineEmits(['update:modelValue'])
const value = computed({
  get() {
    return modelValue
  },
  set(newVal) {
    return emits('update:modelValue', newVal)
  }
})
</script>

v-model 的参数

组件上的 v-model 也可以接受一个参数:

<MyComponent v-model:title="bookTitle" />

在这种情况下,子组件应该使用 title prop 和 update:title 事件来更新父组件的值,而非默认的 modelValue prop 和 update:modelValue 事件:

myComponent.vue

<template>
  <input :value="title" @input="(e) => $emit('update:title', e.target.value)" />
</template>
<script setup>
const { title } = defineProps(['title'])
const emits = defineEmits(['update:title'])
</script>

多个 v-model 绑定

利用刚才在 v-model 的参数小节中学到的指定参数与事件名的技巧,我们可以在单个组件实例上创建多个 v-model 双向绑定。

组件上的每一个 v-model 都会同步不同的 prop,而无需额外的选项:

<template> 
  <myComponent v-model:book-name="bookName" v-model:book-auther="bookAuther" />
  <div>bookName:{{ bookName }}、bookAuther:{{ bookAuther }}</div>
</template>
<script setup>
import { ref } from 'vue'
import myComponent from './components/myComponent.vue'
const bookName = ref('')
const bookAuther = ref('')
</script>

在这种情况下,子组件应该使用 bookNamebookAuther prop,以及 update:bookNameupdate:bookAuther 事件来更新父组件的值:

myComponent.vue

<template>
  <input :value="bookName" @input="(e) => $emit('update:bookName', e.target.value)" />
  <input :value="bookAuther" @input="(e) => $emit('update:bookAuther', e.target.value)" />
</template>
<script setup>
const { bookName, bookAuther } = defineProps(['bookName', 'bookAuther'])
const emits = defineEmits(['update:bookName', 'update:bookAuther'])
</script>

ref与reactive

在 Vue 3 中,响应式数据的创建主要依赖于 refreactive 两个 API。它们各自有不同的用途和适用场景。

ref 用于创建基本数据类型的响应式数据,而 reactive 用于创建复杂数据结构(如对象和数组)的响应式数据。

ref

这个方法需要在顶部引入:import { ref } from 'vue'。通常使用它定义响应式数据,不限数据类型。

let xxx = ref(初始值)

返回值: 传入的是基本数据类型,则返回 RefImpl 实例对象(简称ref)。如果传的是引用数据类型,则内部是通过 reactive 方法处理,最后形成了一个 Proxy 类型的对象。ref 对象的 value 属性是响应式的。

ref 创建的数据,js 中需要 .valuetemplate 中可省略(自动解包)。

<script setup>
import { ref, reactive } from 'vue';

  const text = ref('')
  console.log('ref text', text) // ref text RefImpl {dep: Dep, __v_isRef: true, __v_isShallow: false, _rawValue: '', _value: ''}

  const obj = reactive({
    name: 'caoyuan'
  })
  console.log('reactive obj', obj) // reactive obj Proxy(Object) {name: 'caoyuan'}

</script>

我们打印 obj,你会发现,它不再是 RefImpl 实例对象,变成了 Proxy 实例对象,vue3 底层把对象都变成了 Proxy 实例对象,对于基本数据类型就是按照 Object.defineProperty 里面的 getset 进行数据劫持然后进行响应式,但是如果是对象类型的话,是用到的 Proxy。vue3把它封装在新函数 reactive 里,就相当于,ref 中是对象,自动会调用 reactive

那为什么定义一个响应式数据,偏偏要用 .value 去操作呢,满篇的 .value 有什么必要吗,像 Vue2 里直接拿着变量名称处理不是很好?

在官网中得到了解答:

将值封装在一个对象中,看似没有必要,但为了保持 JavaScript 中不同数据类型的行为统一,这是必须的。这是因为在 JavaScript 中,NumberString 等基本类型是通过值而非引用传递的。

按引用传递与按值传递

在任何值周围都有一个封装对象,这样我们就可以在整个应用中安全地传递它,而不必担心在某个地方失去它的响应性。

提示: 换句话说,ref 为我们的值创建了一个响应式引用。在整个组合式 API 中会经常使用引用的概念。

注:安装 Vue - Official 插件后,搜索 Dot Value,勾选对应选项,插件会在使用 ref 创建的变量时自动添加 .value

reactive

前边提到,ref 可以返回任意类型的变量的响应式副本,那 reactive 还有什么必要吗?

当然是有必要的。上一段对 ref 了解,他在操作该响应式变量的时候,需要 .value 去取值,那有没有一个方法,可以避开 NumberString 等基本类型,操作时候无需 .value 呢?答案是有的。也就是 reactive 函数。

作用:定义一个对象类型的响应式数据,不能定义基本数据类型。

语法:const 代理对象 = reactive(源对象)

  • 接收一个对象(或数组),返回一个代理对象(Proxy 的实例对象,简称 proxy 对象)
  • reactive 定义的响应式数据是深层次的,意思是不管对象嵌套多少层,整个对象都是响应式的
  • 内部基于 ES6 的 Proxy 实现,通过代理对象操作源对象内部数据进行操作
<template>
  <div>{{  arr.toString()  }}</div>
  <div>{{ obj.info.schoolInfo.location }}</div>
</template>
<script setup>
import { reactive } from 'vue';

  const arr = reactive([1,2,3])
  // 3秒后值变化
  setTimeout(() => {
    arr.push(4)
  }, 3000);

  const obj = reactive({
    info: {
      name: 'caoyuan',
      schoolInfo: {
        location: 'henan'
      }
    }
  })
  // 6秒后值变化
  setTimeout(() => {
    obj.info.schoolInfo.location = 'shanghai'
  }, 6000);
</script>

ref与reactive的区别

从定义数据角度:

  • ref 用来定义基本类型数据、引用类型数据。定义引用数据类型时,内部会调用 reactive 转为代理对象
  • reactive 用来定义引用类型数据,不支持基本数据类型

从原理角度:

  • ref 通过 Object.defineProperty()getset 来实现响应式(数据劫持)
  • reactive 通过使用 Proxy 来实现响应式(数据劫持), 并通过 Reflect 操作源对象内部的数据
  • ref 遇到引用数据类型时,它的内部会自动通过 reactive 转为代理对象

从使用角度:

  • ref 定义的数据:操作数据需要 .value,读取数据时模板中直接读取不需要 .value
  • reactive 定义的数据:操作数据与读取数据:均不需要 .value

使用原则:

  • 若需要一个基本类型的响应式数据,必须使用 ref
  • 若需要一个响应式对象,层级不深,refreactive都可以
  • 若需要一个响应式对象,且层级较深,推荐使用 reactive

ref模板引用

在某些情况下,我们仍然需要直接访问底层 DOM 元素。要实现这一点,我们可以使用特殊的 ref attribute:

<input ref="input">

ref 是一个特殊的 attribute,它允许我们在一个特定的 DOM 元素子组件实例被挂载后,获得对它的直接引用。这可能很有用,比如说在组件挂载时将焦点设置到一个 input 元素上,或在一个元素上初始化一个第三方库。

访问模板引用

要在组合式 API 中获取引用,我们可以使用辅助函数 useTemplateRef()

<template>
  <input ref="my-input" />
</template>
<script setup>
import { useTemplateRef, onMounted } from 'vue'

// 第一个参数必须与模板中的 ref 值匹配
const inputRef = useTemplateRef('my-input')

onMounted(() => {
  inputRef.value.focus()
})
</script>

注意,你只可以在组件挂载后才能访问模板引用。如果你想在模板中的表达式上访问 input,在初次渲染时会是 null。这是因为在初次渲染前这个元素还不存在呢!

如果你需要侦听一个模板引用 ref 的变化,确保考虑到其值为 null 的情况:

watchEffect(() => {
  if (inputRef.value) {
    inputRef.value.focus()
  } else {
    // 此时还未挂载,或此元素已经被卸载(例如通过 v-if 控制)
  }
})

组件上的 ref

模板引用也可以被用在一个子组件上。这种情况下引用中获得的值是组件实例:

<template>
  <Child ref="child" />
</template>
<script setup>
import { useTemplateRef, onMounted } from 'vue'
import Child from './Child.vue'

const childRef = useTemplateRef('child')

onMounted(() => {
  // childRef.value 将持有 <Child /> 的实例
})
</script>

如果一个子组件使用的是选项式 API 或没有使用 <script setup>,被引用的组件实例和该子组件的 this 完全一致,这意味着父组件对子组件的每一个属性和方法都有完全的访问权。这使得在父组件和子组件之间创建紧密耦合的实现细节变得很容易,当然也因此,应该只在绝对需要时才使用组件引用。大多数情况下,你应该首先使用标准的 propsemit 接口来实现父子组件交互。

当使用了 <script setup> 的组件是默认私有的:一个父组件无法访问到一个使用了 <script setup> 的子组件中的任何东西,除非子组件在其中通过 defineExpose 宏显式暴露:

<script setup>
import { ref } from 'vue'

const a = 1
const b = ref(2)

// 像 defineExpose 这样的编译器宏不需要导入
defineExpose({
  a,
  b
})
</script>

当父组件通过模板引用获取到了该组件的实例时,得到的实例类型为 { a: number, b: number } (ref 都会自动解包,和一般的实例一样)。

请注意,defineExpose 必须在任何顶层 await 操作之前调用。否则,在 await 操作后暴露的属性和方法将无法访问。

v-for 中的模板引用

当在 v-for 中使用模板引用时,对应的 ref 中包含的值是一个数组,它将在元素被挂载后包含对应整个列表的所有元素:

<template>
  <ul>
    <li v-for="item in list" ref="items">
      {{ item }}
    </li>
  </ul>
</template>
<script setup>
import { ref, useTemplateRef, onMounted } from 'vue'

const list = ref([
  /* ... */
])

const itemRefs = useTemplateRef('items')

onMounted(() => console.log(itemRefs.value))
</script>

应该注意的是,ref 数组并不保证与源数组相同的顺序。

函数模板引用

除了使用字符串值作名字,ref attribute 还可以绑定为一个函数,会在每次组件更新时都被调用。该函数会收到元素引用作为其第一个参数:

<input :ref="(el) => { /* 将 el 赋值给一个数据属性或 ref 变量 */ }">

注意我们这里需要使用动态的 :ref 绑定才能够传入一个函数。当绑定的元素被卸载时,函数也会被调用一次,此时的 el 参数会是 null。你当然也可以绑定一个组件方法而不是内联函数。

PosterKit:跨框架海报生成工具

作者 曼妥思
2025年8月18日 18:39

引言

为什么需要它?

因为我的团队里面有用vue也有react,而我需要管理一些功能的实现,原来一个同意登录就写了2份。于是开始思考是否有一个跨平台库可以做到write once run anywhere

首先想到的是taro,于是去到taro寻找答案,于是发现了我想要的stenciljs

stenciljs

“Stencil is a library for building reusable, scalable component libraries. Generate small, blazing fast Web Components that run everywhere.”

框架将编写的代码转换成浏览器可识别的Web Components,所以只要是浏览器支持,那就能跑。

PosterKit

这是一个使用stenciljs来开发的海报生成工具

安装

npm i poster-kit

关于使用

这里就不直接贴代码了,直接上例子

在线体验

API

init()

如果需要批量的修改卡片的信息(width、height、x,y等),可以使用init()

一般情况下在数据回显的时候用

如果是一个空海报可以不执行init()

接收一个数组,数组内部是卡片的信息,卡片详细信息见下面add()

add()

在数组末尾插入一个元素

如果想在中间插入某个元素,请自行定义并修改数组结构,然后使用init()

传入一个对象,可传入图片类型或者文本类型

如果插入元素到指定位置的需求多,也不是不能加

// 图片类型

{
    id: new Date().getTime(),
    width: 300,
    height: 300,
    x: 0,
    y: 0,
    image,
    type: 'image',
}

// 文字类型

{
    id: new Date().getTime() + 1,
    width: 300,
    height: 200,
    x: 0,
    y: 0,
    text: '你好世界你好世界1234567890🤔abcdefghijklmnopqrstuvwxyz',
    type: 'text',
    fontSize: 32,
    fontFamily: 'cursive',
    color: '#db3f9178',
    fontWeight: 'bold',
    fontStyle: 'italic',
    decoration: 'line-through',
}

color最后是传入给svg标签的fill字段,只要是fill支持的格式就都可以

记住定义id,并且保证唯一性

updateCurrentData()

手动更新卡片信息

传入卡片对象

内部会通过id来匹配并更新信息

currentDataChange()

当前选中卡片的数据更新后的回调

如果是使用html来使用插件,需要使用DOMContentLoaded来监听元素的响应

document.addEventListener('DOMContentLoaded', function () {
  const kitBox = document.querySelector('#kitBox')
  kitBox.addEventListener('currentDataChange', function (event) {
    currentDataChange(event.detail)
  })
})

vuereact的用户直接在KitBox元素中传入你的监听方法就好

<KitBox
  ref={kitBoxRef}
  width={1080}
  height={1920}
  onCurrentDataChange={(e) => currentDataChange(e.detail)}
/>
<kit-box
  ref="kitBoxRef"
  :width="1080"
  :height="1920"
  @currentDataChange="currentDataChange"
/>

这里注意下vue和react使用的元素写法不同

再讲几点

  1. reactvue在使用的时候需要安装对应的库,具体可以查看上面的例子
  2. 需要用一个div来包裹元素来包裹KitBox,并且需要设置div元素宽高,定义你的编辑区域最大有多大
  3. KitBox需要传入widthheight两个属性,这用来定义你的海报实际的大小。并且也会根据父级元素来调整自己在页面中的大小,根据传入的这个属性按比例调整,保证最后输出的海报所见即所得
  4. 如果对象是iamge,那么需要传入一个Image对象,关于这一点其实我还是在纠结,最先的想法是用户自己去处理跨域什么的,那么工具可以直接用这个Image来画进画布。但是传入一个src地址也可以工具自己来处理(好吧,我后面会对这块做调整,尽可能减少用户的心智负担)

项目地址: github.com/Fairfarren/…

文档地址: fairfarren.github.io/PosterKit-d…

大家有什么需求或者优化点可以提issue,也可以点一个start再走,谢谢🙏

H5开发,开发照相机,以及组件封装

作者 龙在天
2025年8月18日 18:30

为什么要封装H5照相机组件?

移动端网页中通过<input type="file">调用的原生相机体验较差:

  • 每次拍照后需要确认操作
  • 无法快速连续拍摄
  • 缺少放大缩小、手电筒等常用功能

封装自定义相机组件可以:

  1. 实现连拍功能,提高拍摄效率
  2. 添加更多相机功能
  3. 统一UI风格

核心实现步骤

1. 显示摄像头画面

// 获取摄像头权限
const getStream = async () => {
  const stream = await navigator.mediaDevices.getUserMedia({
    audio: false,
    video: {
      facingMode: "environment", // 后置摄像头
      width: 1920,
      height: 1440
    }
  });
  
  // 将视频流绑定到video元素
  videoRef.current.srcObject = stream;
  videoRef.current.play();
};

2. 拍照功能实现

const takePhoto = () => {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  
  // 设置画布大小
  canvas.width = 480;
  canvas.height = 640;
  
  // 从video元素捕获画面
  ctx.drawImage(
    videoRef.current,
    0, 0, videoWidth, videoHeight, // 源图像参数
    0, 0, canvas.width, canvas.height // 目标画布参数
  );
  
  // 转换为图片
  const photoData = canvas.toDataURL('image/jpeg');
  
  return photoData;
};

3. 连拍功能

const [photos, setPhotos] = useState([]);

const continuousShooting = () => {
  const timer = setInterval(() => {
    const newPhoto = takePhoto();
    setPhotos(prev => [...prev, newPhoto]);
  }, 1000); // 每秒拍一张
  
  // 5秒后停止
  setTimeout(() => clearInterval(timer), 5000);
};

4. 放大缩小功能

const [zoom, setZoom] = useState(1);

// 放大
const zoomIn = () => {
  if (zoom >= 4) return;
  setZoom(zoom + 0.2);
  videoRef.current.style.transform = `scale(${zoom + 0.2})`;
};

// 缩小
const zoomOut = () => {
  if (zoom <= 1) return;
  setZoom(zoom - 0.2);
  videoRef.current.style.transform = `scale(${zoom - 0.2})`;
};

5. 手电筒功能

const toggleFlash = () => {
  const track = videoRef.current.srcObject.getVideoTracks()[0];
  track.applyConstraints({
    advanced: [{ torch: !flashOn }]
  });
  setFlashOn(!flashOn);
};

6. 横竖屏适配

// 使用orientation.js检测屏幕方向
const orientation = new Orientation({
  onChange: (event) => {
    // 根据event.alpha/beta/gamma判断方向
    setIsLandscape(/* 判断逻辑 */);
  }
});

// 拍照时根据方向调整
if (isLandscape) {
  // 旋转画布90度
  ctx.rotate(Math.PI/2);
  // 调整绘制位置
}

完整组件结构

function Camera() {
  const videoRef = useRef();
  const [zoom, setZoom] = useState(1);
  const [photos, setPhotos] = useState([]);
  const [flashOn, setFlashOn] = useState(false);
  const [isLandscape, setIsLandscape] = useState(false);

  // 初始化摄像头
  useEffect(() => {
    initCamera();
    return () => stopCamera();
  }, []);

  return (
    <div className="camera-container">
      {/* 视频预览区域 */}
      <div className="video-wrapper">
        <video ref={videoRef} style={{ transform: `scale(${zoom})` }} />
      </div>
      
      {/* 控制区域 */}
      <div className="controls">
        <button onClick={zoomIn}>放大</button>
        <button onClick={zoomOut}>缩小</button>
        <button onClick={takePhoto}>拍照</button>
        <button onClick={continuousShooting}>连拍</button>
        <button onClick={toggleFlash}>
          {flashOn ? '关闭手电筒' : '打开手电筒'}
        </button>
      </div>
      
      {/* 照片预览 */}
      <div className="preview">
        {photos.map((photo, i) => (
          <img key={i} src={photo} alt={`预览${i}`} />
        ))}
      </div>
    </div>
  );
}

注意事项

  1. 兼容性处理:不同浏览器API可能有差异
  2. 性能优化:及时释放摄像头资源
  3. 移动端适配:处理横竖屏切换
  4. 权限处理:优雅处理用户拒绝权限的情况
  5. 图片压缩:大尺寸图片上传前需要压缩

总结

通过getUserMedia API获取摄像头数据流,结合canvas实现拍照功能,再添加各种控制功能,就能打造一个功能完善的H5相机组件。这种方案比原生<input type="file">提供了更好的用户体验和更多自定义功能。

react-router里的两种路由方式有什么不同

作者 随笔记
2025年8月18日 18:01

在React Router中,createBrowserRoutercreateHashRouter的主要区别在于路由模式的选择:

兼容性差异

  • ‌**createBrowserRouter**‌(基于HTML5 History API)不兼容IE9及以下版本,但支持现代主流浏览器。 ‌
  • ‌**createHashRouter**‌(基于URL哈希值)兼容性更好,可适配更多浏览器,包括低版本IE。 ‌

地址栏表现形式

  • ‌**createBrowserRouter**‌的URL路径中不含#(如localhost:3000/demo/test)。 ‌
  • ‌**createHashRouter**‌的URL路径包含#(如localhost:3000/#/demo/test)。 ‌

刷新页面影响

  • ‌**createBrowserRouter**‌刷新页面后,路由状态(如参数)不受影响,因状态保存在浏览器历史记录中。 ‌
  • ‌**createHashRouter**‌刷新页面会导致路由状态丢失,需重新加载数据。 ‌

使用场景建议

  • ‌**createBrowserRouter**‌适用于需要优雅URL且兼容性要求不高的场景(如公网项目)。
  • ‌**createHashRouter**‌适用于兼容性优先的场景(如内网项目或老旧浏览器环境)。 ‌

小公司前端多分支测试太痛苦?我自己写了个轻量 CLI

作者 HYI
2025年8月18日 18:00

小公司做前端,每次测试太折腾了,特别是哐哐哐一大堆项目、功能一起上线时。

  • 项目dev每次改都会影响别人测试
  • 切分支测试,端口记不清楚,上一个没关还会占用
  • 后台接口经常改,要不停切代理
  • Windows 下 Nginx?自己的电脑,不想安装。

原因

昨天我又切到xx 分支,本来想本地跑一下看看效果,结果上一个分支端口还被占用,只好手动换端口。后台接口正式库和测试库来回改,心态真的爆炸。

每天早上拉分支,下午给测试,真希望能秒开页面,端口别冲突,代理别动


解决方案

于是我自己撸了一个小工具,叫 vite-static-serve,特点就是轻量、快速、开箱即用:

  • 每个分支一个环境,支持多环境同时跑
  • 端口占用自动切换,不用记
  • 内置代理,调接口不用动 Nginx
  • 自动生成默认页面,拉分支就能直接预览

用起来也很简单:

# 添加环境
npx vserve add dev

# 启动环境
npx vserve start dev

# 列出当前环境
npx vserve list

# 删除环境
npx vserve remove dev

每天早上我拉两个分支:

npx vserve add feature-xxx
npx vserve add bugfix-yyy

下午给测试演示,浏览器自动打开页面,谁还想记端口啊


使用感受

  • 🚀 启动速度快,本地环境秒开
  • 😌 轻量,只依赖 Vite,Windows 下不用折腾 Nginx
  • 🔧 灵活,随时加/删/切环境

适用场景

  • 小团队开发,没有专用服务器
  • 多分支、多版本同时测试
  • 快速调接口,或者前后端联调

项目地址

github.com/HYI110100/v…

跨端实现之网络库拦截

作者 Hierifer
2025年8月18日 17:59

工作中有些一些场景要拦截或改写网络库,比如在做跨端基建的时候,移动端同学说:原生拦截不好使,我和你约定一个request 方法吧。你直接用这个 request 方法调用。在你质疑他技术水平 :)的同时,我们可以思考一下这个场景。如果 H5 侧要拦截所有网络请求并用 request,大家怎么做。

业务同学使用的第三方网络库都不一样,axios,fetch,fly,一大堆。但大家观察一下 chrome 的请求类型基础就两个 fetch 和 XHR。那么搞定这两个我们的这个问题就解决了。

基本结构很简单,你让 llm 帮你写即可,我们仅聊聊思路和跨端场景下的边界场景。首先是劫持或是替换 fetch 和 XHR,以 fetch 举例。

// ez code
const originalFetch = globalThis.fetch
const originalXMLHttpRequest = globalThis.XMLHttpRequest

globalThis.fetch = myFetch 
globalThis.XMLHttpRequest = myXMLHttpRequest

// my fetch

const myFetch = async function (
    input: RequestInfo | URL,
    options: RequestInit = {},
  ): Promise<Response> {
    try {
      // 处理请求参数
      const url = input.toString()
      const method = options?.method || 'GET'
      const headers = {}
      const body = xxx
  

      // customRequest 是移动端给的一个网络请求han'shu
      const response = await customRequest({
        body: JSON.stringify(body),
        url,
        method,
        headers,
        timeout: 10000,
      });

      try {
        response.data = JSON.parse(response.responseText);
      } catch {
        console.error(
          "Failed to parse response as JSON, using raw response text."
        );
        response.data = {};
      }
      const out = {
        ok: response.statusCode >= 200 && response.statusCode < 300,
        status: response.statusCode,
        statusText: `${response.statusCode}` || "",
        headers: new Headers(response.headers || {}),
        json: () => {
          return Promise.resolve(response.data);
        },
        text: () => Promise.resolve(response.responseText || ""),
        blob: () => Promise.reject(new Error("Blob not supported in fly")),
        formData: () =>
          Promise.reject(new Error("FormData not supported in fly")),
        arrayBuffer: () => Promise.resolve(response.data),
        clone: () => ({
          ok: response.statusCode >= 200 && response.statusCode < 300,
          status: response.statusCode,
          statusText: `${response.statusCode}` || "",
          headers: new Headers(response.headers || {}),
          json: () => Promise.resolve(response.data),
          text: () => Promise.resolve(response.responseText || ""),
          blob: () => Promise.reject(new Error("Blob not supported in fly")),
          formData: () =>
            Promise.reject(new Error("FormData not supported in fly")),
          arrayBuffer: () => Promise.resolve(response.data),
        }),
        data: {
          ...response.data,
          json: () => {
            return Promise.resolve(response.data);
          },
        },
        body: new ReadableStream({
          start(controller) {
            // 将响应数据推送到流中
            const data = response.data || response.responseText || "";
            const encoder = new TextEncoder();
            controller.enqueue(
              encoder.encode(
                typeof data === "string" ? data : JSON.stringify(data)
              )
            );
            controller.close();
          },
        }),
        redirected: false,
        type: "",
        bytes: null,
        bodyUsed: false,
        url,
      } as Response;
      return out
    } catch (error) {
      // 创建一个错误响应
    }
}

这一块需要注意 fetch 请求的 body 需要一个 ReadStream 类型。大多数第三方库会使用这个值。如果没有这个值,即使你请求正常也会拿到空对象(如果第三方库的默认是空)。

常见问题

这一块你基本上可以用 vibe coding。但是代码结构非常重要,因为会有一些边界场景,比如 override 代码(运行时代码执行比业务代码慢导致部分网络请求用原生 fetch / XHR)。有几个思路,

方案一:阻塞业务代码

阻塞主流程直到网络库覆盖完成。优点是直截了当,非常粗暴。但问题是

  1. 运行时代码侵入到业务代码里了
  2. 移动端提供的 request 方法可能不可用,因为 H5 和移动端往往通过 bridge 桥接。桥接流程如果存在同步操作。可能移动端在第一时间无法正常承接你的网络请求,但请求已经进去了。

方案二:网络库重发

相对和业务代码解耦,但问题是

  1. 业务代码注定要面临前几次网络请求被重发的问题。如果处理不好,用户可能会看到接口报错

方案三:容器侧注入运行时

相对常用的运行时注入方案,先对逻辑容器注入,运行时对象。

对于 iOS

jsContext.executeJavaScript(`globalfetch = xxx`)

等移动端确定后再加载 H5 的代码,建立业务实例。这样可以完全解耦。

🤩 用Babel自动埋点,原来这么简单!

作者 龙在天
2025年8月18日 17:56

大家好呀!今天给大家分享一个超实用的前端小技巧——用Babel自动给代码添加埋点功能。

听起来很高大上?其实超级简单,跟着我一步步来,保证你能学会!

什么是埋点?

埋点就是在代码里插入一些统计代码,用来记录用户行为,比如按钮点击、页面访问等。

传统做法是手动在每个函数里加统计代码,但这样太麻烦了!

为什么要用Babel自动埋点?

  1. 省时省力:不用手动加代码
  2. 干净整洁:业务代码和埋点代码分离
  3. 一劳永逸:一次配置,到处使用

手把手教你实现

第一步:准备环境

mkdir babel-tracker
cd babel-tracker
npm init -y
npm i -D @babel/core @babel/helper-plugin-utils

第二步:创建测试代码

新建src/sourceCode.js

import "./index.css";

// 各种函数类型
const test1 = () => {}; // 箭头函数
const test2 = function() {}; // 函数表达式
function test3() {} // 函数声明
class test4 { // 类方法
  test4_0() {}
  test4_1 = () => {};
  test4_2 = function() {};
}

第三步:创建Babel插件

新建src/babel-plugin-tracker.js

// 引入 Babel 提供的辅助函数,用于自动添加 import 语句
const { addDefault } = require("@babel/helper-module-imports");

// 导出 Babel 插件函数,接收 api 和 options 两个参数
module.exports = (api, options) => {
  // 返回插件对象
  return {
    // visitor 对象定义了对哪些 AST 节点类型感兴趣
    visitor: {
      // 使用 | 分隔符匹配多种函数类型
      "ArrowFunctionExpression|FunctionDeclaration|ClassMethod|FunctionExpression": {
        // 进入这些节点时的处理函数
        enter: (path, state) => {
          // 获取函数体的路径
          const bodyPath = path.get("body");
          // 从 state 中获取之前创建的埋点函数 AST
          const ast = state.trackerAst;
          
          // 判断函数体是否是块语句(即是否有 { } 包裹)
          if (api.types.isBlockStatement(bodyPath.node)) {
            // 如果是块语句,直接在开头插入埋点调用
            bodyPath.node.body.unshift(ast);
          } else {
            // 如果不是块语句(如箭头函数直接返回表达式)
            // 创建一个新的块语句 AST,包含埋点调用和原返回值
            const ast2 = api.template.statement(`{
              ${state.importTrackerId}();
              return BODY;
            }`)({ BODY: bodyPath.node });
            // 用新创建的块语句替换原来的函数体
            bodyPath.replaceWith(ast2);
          }
        }
      },
      
      // 处理整个程序(Program 是文件的根节点)
      Program: {
        enter: (path, state) => {
          // 从插件配置中获取 tracker 模块的路径
          const trackerPath = options.trackerPath;
          
          // 遍历当前程序的所有 import 声明
          path.traverse({
            ImportDeclaration(path) {
              // 检查是否已经导入了 tracker 模块
              if (path.node.source.value === trackerPath) {
                // 如果已导入,获取导入的变量名
                // specifiers.0.local 表示第一个导入说明符的本地名称
                state.importTrackerId = path.get("specifiers.0.local").toString();
                // 找到后停止遍历
                path.stop();
              }
            }
          });
          
          // 如果没有找到 tracker 的导入
          if (!state.importTrackerId) {
            // 使用 addDefault 添加默认导入
            // path.scope.generateUid("tracker") 生成唯一的变量名
            state.importTrackerId = addDefault(path, trackerPath, {
              nameHint: path.scope.generateUid("tracker")
            }).name; // 返回导入的变量名
          }
          
          // 创建埋点函数调用的 AST 节点
          // 使用之前获取或生成的变量名
          state.trackerAst = api.template.statement(`${state.importTrackerId}();`)();
        }
      }
    }
  };
};

第四步:使用插件处理代码

新建src/index.js

const { transformFileSync } = require("@babel/core");
const path = require("path");
const tracker = require("./babel-plugin-tracker");

const pathFile = path.resolve(__dirname, "./sourceCode.js");

// 转换代码
const { code } = transformFileSync(pathFile, {
  plugins: [
    [tracker, { trackerPath: "tracker" }] // 使用插件并配置
  ]
});

console.log(code);

第五步:运行看看效果

node ./src/index.js

你会看到输出结果中,所有函数都被自动加上了埋点代码,而且还自动导入了tracker模块!

原理揭秘

  1. AST转换:Babel先把代码转换成抽象语法树(AST)
  2. 遍历AST:插件会遍历AST找到各种函数
  3. 修改AST:在函数开头插入埋点函数调用
  4. 检查导入:确保埋点函数已导入,没有就自动添加
  5. 生成代码:把修改后的AST转换回代码

总结

用Babel自动埋点真的太方便了!一次配置,终身受用,再也不用在业务代码里到处写埋点了。赶紧试试吧,让你的代码更干净,开发更高效!

如果有任何问题,欢迎留言讨论哦~ 😊

异步任务并发控制

2025年8月18日 17:52

JavaScript 异步任务并发控制

🤔 问题背景

常见面试题 批量并发任务

📋 需求分析

一个完整的并发控制方案需要满足以下要求:

  • 并发限制:同时执行的任务数量不能超过指定上限
  • 任务队列:待执行的任务需要有序排队
  • 结果保序:无论任务何时完成,最终结果要按原始顺序返回
  • 错误处理:任何一个任务失败时,能够优雅地处理错误
  • 动态调度:任务完成后自动开始下一个待执行任务

💡 核心实现

让我们来看一个优雅的实现方案:

const runTask = async (tasks, maxTaskNum) => {
  // 参数校验和初始化
  const total = Array.isArray(tasks) ? tasks.length : 0;
  if (total === 0) return [];

  const limit = Math.max(1, Math.min(maxTaskNum, total));
  const result = new Array(total);

  // 使用Promise.withResolvers()创建可控制的Promise
  const { promise, resolve, reject } = Promise.withResolvers();
  let nextIndex = 0; // 下一个要执行的任务索引
  let finished = 0; // 已完成的任务计数

  const runNext = () => {
    const i = nextIndex++;
    if (i >= total) return; // 没有更多任务

    Promise.resolve()
      .then(() => tasks[i]())
      .then((res) => {
        result[i] = res; // 按索引存储,保证顺序
      })
      .catch((err) => {
        reject(err); // 任何一个任务失败,整体失败
      })
      .finally(() => {
        finished++;
        if (finished === total) {
          resolve(result); // 所有任务完成
        } else {
          runNext(); // 继续执行下一个任务
        }
      });
  };

  // 启动初始的并发任务
  for (let i = 0; i < limit; i++) {
    runNext();
  }

  await promise;
  return result;
};

🔍 代码详解

1. 参数处理与初始化

const total = Array.isArray(tasks) ? tasks.length : 0;
if (total === 0) return [];

const limit = Math.max(1, Math.min(maxTaskNum, total));
const result = new Array(total);

这部分代码确保了参数的合法性:

  • 验证任务数组的有效性
  • 计算实际并发数(不能超过总任务数,至少为 1)
  • 预先创建结果数组,确保索引对应关系

2. Promise 控制器

const { promise, resolve, reject } = Promise.withResolvers();

Promise.withResolvers()是 ES2024 的新特性,它返回一个包含 promise 及其控制函数的对象,让我们可以在外部控制 Promise 的状态。

3. 任务调度核心

const runNext = () => {
  const i = nextIndex++;
  if (i >= total) return;

  Promise.resolve()
    .then(() => tasks[i]())
    .then((res) => (result[i] = res))
    .catch((err) => reject(err))
    .finally(() => {
      finished++;
      if (finished === total) {
        resolve(result);
      } else {
        runNext();
      }
    });
};

这是整个调度器的核心逻辑:

  • nextIndex++:原子性地获取下一个任务索引
  • Promise.resolve().then():确保任务异步执行
  • result[i] = res:按原始索引存储结果
  • finally块:无论成功失败都要更新计数和调度

🎯 执行流程演示

让我们通过一个具体例子来理解执行流程:

runTask(
  [
    () => new Promise((resolve) => setTimeout(() => resolve(1), 6000)), // 6秒
    () => new Promise((resolve) => setTimeout(() => resolve(2), 1000)), // 1秒
    () => new Promise((resolve) => setTimeout(() => resolve(3), 100)), // 0.1秒
    () => new Promise((resolve) => setTimeout(() => resolve(4), 2000)), // 2秒
    () => new Promise((resolve) => setTimeout(() => resolve(5), 100)), // 0.1秒
  ],
  2
).then((res) => {
  console.log(res); // [1, 2, 3, 4, 5]
});

执行时间线(并发数=2):

0ms:     启动任务0(6s) 和 任务1(1s)      [执行中: 0,1]
1000ms:  任务1完成,启动任务2(0.1s)      [执行中: 0,2]
1100ms:  任务2完成,启动任务3(2s)        [执行中: 0,3]
3100ms:  任务3完成,启动任务4(0.1s)      [执行中: 0,4]
3200ms:  任务4完成,等待任务0            [执行中: 0]
6000ms:  任务0完成,所有任务结束         [完成]

总耗时: 6秒(相比串行执行的9.3秒,节省了3.3秒)

🚀 优化和扩展

1. 添加进度回调

const runTaskWithProgress = async (tasks, maxTaskNum, onProgress) => {
    // ... 原有代码

    .finally(() => {
        finished++;
        onProgress && onProgress({
            finished,
            total,
            percent: (finished / total * 100).toFixed(2)
        });
        // ... 后续逻辑
    });
};

2. 支持任务优先级

const runTaskWithPriority = async (tasks, maxTaskNum) => {
  // 按优先级排序任务
  const sortedTasks = tasks
    .map((task, index) => ({ task, index, priority: task.priority || 0 }))
    .sort((a, b) => b.priority - a.priority);

  // ... 使用排序后的任务执行
};

3. 失败重试机制

const executeWithRetry = async (task, retries = 3) => {
  for (let i = 0; i < retries; i++) {
    try {
      return await task();
    } catch (error) {
      if (i === retries - 1) throw error;
      await new Promise((resolve) => setTimeout(resolve, 1000 * i));
    }
  }
};

⚡ 性能考量

  1. 内存使用:预先创建结果数组会占用内存,对于大量任务需要考虑分批处理
  2. 错误处理:当前实现遇到错误会立即终止,可以考虑支持部分失败
  3. 取消机制:长时间运行的任务可能需要支持取消操作

🎉 总结

异步任务并发控制是前端开发中的重要技能,它能够:

  • 提升性能:合理利用并发,减少总执行时间
  • 保护资源:避免过度并发造成的资源浪费
  • 增强体验:提供可控的执行进度和错误处理

通过理解其核心原理和实现细节,我们可以根据具体场景进行定制和优化,构建出更加 robust 和高效的异步任务处理方案。

这种模式在现代前端框架中也有广泛应用,比如 Vue 的异步组件加载、React 的 Suspense 机制等,都体现了类似的并发控制思想。掌握这种技术,将让你在处理复杂异步场景时更加得心应手。


如果这篇文章对你有帮助,欢迎点赞 👍、收藏 ⭐ 和分享 📤!

关注我,获取更多前端技术干货和面试题解析 🚀

前端居中九种方式血泪史:面试官最爱问的送命题,我一次性整明白!

2025年8月18日 17:48

“你能说一下有哪几种方式实现居中吗?” —— 这句话堪称前端面试的经典开场白。无数面试者在这道看似简单的问题上折戟沉沙,今天我就带你彻底攻克这个“送命题”,用九种实用方案征服面试官!


🧱 一、经典基础方案(传统布局)

  1. 文本居中:text-align + line-height

    .parent { text-align: center; }
    .child { 
      display: inline-block; 
      line-height: 200px; /* 等于父级高度 */
    }
    

    适用场景:单行文本或行内元素垂直居中。

  2. 绝对定位 + margin:auto

    .child {
      position: absolute;
      top: 0; right: 0; bottom: 0; left: 0;
      margin: auto;
      width: 100px; height: 100px;
    }
    

    优势:兼容性好(IE8+),需指定宽高。

  3. 负边距偏移(经典居中)

    .child {
      position: absolute;
      top: 50%; left: 50%;
      margin-top: -50px; /* 高度一半 */
      margin-left: -50px; /* 宽度一半 */
    }
    

    痛点:需精确计算尺寸,响应式不友好。


⚡ 二、现代布局方案(Flex/Grid)

  1. Flex 布局(面试官最爱!)

    .parent {
      display: flex;
      justify-content: center; /* 水平居中 */
      align-items: center;     /* 垂直居中 */
    }
    

    适用场景:99%的居中需求,移动端首选。

  2. Grid 布局(降维打击)

    .parent {
      display: grid;
      place-items: center; /* 一行搞定水平和垂直居中 */
    }
    

    优势:代码极简,适合复杂布局。


🧪 三、黑科技方案(展示技术深度)

  1. transform 位移法(不依赖固定尺寸)

    .child {
      position: absolute;
      top: 50%; left: 50%;
      transform: translate(-50%, -50%);
    }
    

    适用场景:未知尺寸元素居中(IE9+)。

  2. Table-Cell 魔法(兼容老项目)

    .parent {
      display: table-cell;
      vertical-align: middle; /* 垂直居中 */
      text-align: center;      /* 水平居中 */
    }
    .child { display: inline-block; }
    
  3. 伪元素撑高法(垂直居中神器)

    .parent::before {
      content: "";
      display: inline-block;
      height: 100%;
      vertical-align: middle;
    }
    .child { display: inline-block; vertical-align: middle; }
    
  4. Writing-mode 文字流旋转

    .parent {
      writing-mode: vertical-lr; /* 改变流方向 */
      text-align: center;
    }
    .child {
      writing-mode: horizontal-tb;
      display: inline-block;
    }
    

    慎用:炫技专用,实际项目慎用!


💡 面试满分话术模板

“居中方案需根据场景选择

  • 移动端首选 Flex,代码简洁兼容好;
  • 未知尺寸用 Transform 位移;
  • 老项目可用 Table-Cell负边距
  • 文本居中优先 text-alignline-height
    现代开发中,Flex/Grid 是更优雅的解决方案。

📊 方案对比总结

方案 兼容性 是否需要定宽高 适用场景
Flex IE10+ 通用布局
Grid IE11+ 二维布局
Transform IE9+ 未知尺寸元素
绝对定位 + margin IE6+ ✔️ 传统固定尺寸元素
Table-Cell IE8+ 老项目兼容

下次面试官再问居中,直接甩出九连击:“从传统到现代,从兼容到黑科技,您想听哪种?” 技术深度与幽默感并存,offer拿到手软!

Vue3 响应式原理

作者 LIUENG
2025年8月18日 17:44

原理图

classDiagram
  note for ReactiveEffect "activeEffect 记录当前实例变量"
  note for ReactiveEffect "createDep<br />cleanupDepEffect<br />preCleanupEffect<br />postCleanupEffect<br />triggerComputed<br />pauseTrack<br />resetTrack<br />pauseScheduling<br />resetScheduling<br />"
  ReactiveEffect <|-- ComputedRefImpl : this.effect 实例化,等待响应式数据更新执行
  note for RefImpl "trackRefValue<br />triggerRefValue<br />track<br />trigger<br />trackEffect<br />triggerEffects"
  ReactiveEffect <|-- VueCreateApp : 初始实例化执行函数 render
  class ReactiveEffect {
    fn: Function 初始化函数
    trigger: Function 非渲染函数
    scheduler: Function 微任务函数
    active: true
    deps: [] 记录发布者消息
    _trackId: 0
    _dirtyLevel: 0 用于控制执行
    _runnings: 0
    _shouldSchedule: false
    _depsLength: 0
    ...
    get dirty()
    set dirty()
    run() 执行函数
    stop()
  }
  class RefImpl {
    dep: void 0
    _value: any
    ...
    get value()
    set value()
  }
  class ComputedRefImpl {
    getter
    _setter
    dep: void 0
    effect
      effect.computed
      effect.active: true
    ...
    get value()
    set value()
    get _dirty()
    set _dirty()
  }
  class VueCreateApp {
    ...
    mount()
  }

思考

以 Vue 组合式 API(ref computed) 解析

import { ref, computed } from 'vue';
// 响应式数据声明
const msg = ref('');
// 计算属性
const c_msg = computed(() => {
  return msg.value + ' world';
});
// 初始化
const app = Vue.createApp({
  setup() {
    return {
      msg,
      c_msg,
    };
  },
  render() {
    msg.value = 'hello';
    return c_msg.value;
  },
});
// 渲染
app.mount(/* '#root' */);

当响应式数据更新时,computed getter 函数如何执行,并且又是如何触发更新

ref

import { ref } from 'vue';
// 普通值
const ref1 = ref(1);
const ref2 = ref('1');
// 对象或者数组
const ref3 = ref({ a: 1 });
const ref4 = ref([1]);
flowchart TB
  A["ref(value)<br />createRef<br />new RefImpl()"] --> E{"value 基本类型"}
  E -- "是" --> F["直接通过.value方式监听响应值"]
  E -- "否" --> G["通过 toReactive/reactive 函数使用 proxy 监听响应值"]
  F --> H["get: trackRefValue<br/>set: triggerRefValue"]
  G --> I["proxy get: track<br />proxy set: trigger"]
  I --> J["triggerEffects 触发更新"]
  H --> J

描述

ref API 每一个响应数据监听都有一个 dep 发布者,等待订阅

  • 接收一个参数
  • 创建 .value 获取值 ref2 = new RefImpl
    • 基本类型
      • get 获取值 trackRefValue 添加发布者,等待订阅 ref2.dep = createDep
      • set 更新值 triggerValue
    • 引用类型
      • 使用 targetMap = new WeakMap 缓存
        • value 为 key, dep = createDep 为值
      • proxy get 获取值 track 添加发布者,等待订阅
      • proxy set 更新值 trigger
  • 触发的条件
    • activeEffect 变量, 当前初始化 ReactiveEffect 实例的渲染函数
  • 渲染函数
    • trackEffect 订阅触发
    • triggerEffects
      • _dirtyLevel 函数参数
        • 0 初始状态
        • 4 ref 值更新

computed

当定义的响应值触发变化时,触发更新

import { ref, computed } from 'vue';
const count = ref(1);
const plusOne = computed(() => {
  return count.value + 1;
});
// 更新值,触发变化
count.value = 2;
// const plusOne = computed({
//   get: () => count.value + 1,
//   set: (val) => {
//     count.value = val - 1
//   }
// })
// plusOne.value = 1
console.log(count.value); // 0
flowchart
  direction TB
    A["computed<br />computed$1<br />可自定义 get/set"] --> B["new ComputedRefImpl"]
    B -- "computed getter 是一个函数,因此需要实例化 ReactiveEffect 等待更新如何执行" --> C["监听响应值 .value"]
    C --> D["get value: trackRefValue/triggerRefValue"]

描述

computed API 同样也为响应式数据,为每一个 computed 数据实例化添加发布者,等待订阅更新

  • 接收 getter/setter 函数参数
    • 实例化 new ComputedRefImpl,创建 .value 值
      • 初始化 effect = ReactiveEffect getter 函数,等待执行
      • 添加发布者 dep
      • ...
    • 更新过程
      • set value 记录发布者列表,等待更新
        • triggerRefValue/triggerEffects
      • get value 获取响应式数据发布者
        • trackRefValue/trackEffect
        • 触发 getter 函数执行,获取新值,更新渲染

相关函数说明

trackRefValue 函数

当获取响应式数据 .value 时,会创建发布者并被缓存到当前响应式数据 dep = createDep,因此每个响应式数据都含有 dep 属性

triggerRefValue 函数

当响应式数据更新值 .value = ? 时,会通知当前的发布者更新消息,执行更新

track 函数

当响应式数据是非基本类型值时,通过变量 targetMap 创建当前的 dep = createDep

trackEffect

记录当前更新的响应式数据 dep 并且添加到 ReactiveEffect 实例化 deps 列表中

trigger 函数

当响应式数据更新时,获取当前的发布者列表,等待执行

ReactiveEffect _dirtyLevel 属性

_dirtyLevel(0 1 2 3 4)属性的作用,通过不同的状态来控制更新

测试

数据更新

msg.value = 'hello1';
// 触发一次更新
// 渲染结果 hello1 world

批量更新

可以看到ReactiveEffect类中scheduler参数,记录当前需要更新函数的队列,采用Promise then微任务方式

// 初始组件渲染实例
const effect = new ReactiveEffect(/* ...,  */ trigger, () => queueJob(update));
const update = () => {
  if (effect.dirty) {
    effect.run();
  }
};
msg.value = 'hello1';
msg.value = 'hello2';
// 多次更新值,只触发一次渲染函数
// 渲染结果 hello2 world

附源码实现

从 Vue3 源码中实现部分

(function (exports, factory) {
  exports.Vue = factory();
})(self, function () {
  let shouldTrack = true;
  let activeEffect;
  const NOOP = () => {};
  const trackStack = [];
  let pauseScheduleStack = 0;
  const queueEffectSchedulers = [];
  const isObject = (v) => v != null && typeof v === 'object';

  function queueJob(job) {
    console.log('nextTick wait update');
    Promise.resolve().then(() => {
      job();
    });
  }

  function pauseScheduling() {
    pauseScheduleStack++;
  }
  function resetScheduling() {
    pauseScheduleStack--;
    while (!pauseScheduleStack && queueEffectSchedulers.length) {
      queueEffectSchedulers.shift()();
    }
  }

  function pauseTracking() {
    trackStack.push(shouldTrack);
    shouldTrack = false;
  }
  function resetTracking() {
    const last = trackStack.pop();
    shouldTrack = last === void 0 ? true : last;
  }
  function triggerComputed(computed) {
    return computed.value;
  }
  function preCleanupEffect(effect2) {
    effect2._trackId++;
    effect2._depsLength = 0;
  }
  function postCleanupEffect(effect2) {
    if (effect2.deps.length > effect2._depsLength) {
      for (let i = effect2._depsLength; i < effect2.dep.length; i++) {
        cleanupDepEffect(effect2.deps[i], effect2);
      }
      effect2.deps.length = effect2._depsLength;
    }
  }
  class ReactiveEffect {
    constructor(fn, trigger, scheduler, scope) {
      this.fn = fn;
      this.trigger = trigger;
      this.scheduler = scheduler;
      this.active = true;
      this.deps = [];

      this._trackId = 0;
      this._dirtyLevel = 4;
      this._runnings = 0;
      this._depsLength = 0;
      this._shouldSchedule = false;
    }
    get dirty() {
      if (this._dirtyLevel === 2 || this._dirtyLevel === 3) {
        this._dirtyLevel = 1;
        pauseTracking();
        for (let i = 0; i < this._depsLength; i++) {
          const dep = this.deps[i];
          if (dep.computed) {
            triggerComputed(dep.computed);
            if (this._dirtyLevel >= 4) {
              break;
            }
          }
        }
        if (this._dirtyLevel === 1) {
          this._dirtyLevel = 0;
        }
        resetTracking();
      }
      return this._dirtyLevel >= 4;
    }
    set dirty(v) {
      this._dirtyLevel = v ? 4 : 0;
    }
    run() {
      this._dirtyLevel = 0;
      if (!this.active) {
        return this.fn();
      }
      let lastTrack = shouldTrack;
      let lastEffect = activeEffect;
      try {
        shouldTrack = true;
        activeEffect = this;
        this._runnings++;
        preCleanupEffect(this);
        return this.fn();
      } finally {
        postCleanupEffect(this);
        this._runnings--;
        activeEffect = lastEffect;
        shouldTrack = lastTrack;
      }
    }
    stop() {
      if (this.active) {
        preCleanupEffect(this);
        postCleanupEffect(this);
        this.active = false;
      }
    }
  }

  function createDep(cleanup, computed) {
    const dep = new Map();
    dep.cleanup = cleanup;
    dep.computed = computed;
    return dep;
  }
  function cleanupDepEffect(dep, effect2) {
    const trackId = dep.get(effect2);
    if (trackId !== void 0 && trackId !== effect2.trackId) {
      dep.delete(effect2);
      if (dep.size === 0) {
        dep.cleanup();
      }
    }
  }
  function trackEffect(effect2, dep) {
    if (dep.get(effect2) !== effect2._trackId) {
      dep.set(effect2, effect2._trackId);
      const oldDep = effect2.deps[effect2._depsLength];
      if (oldDep !== dep) {
        // console.log('old dep', oldDep);
        if (oldDep) {
          cleanupDepEffect(oldDep, effect2);
        }
        effect2.deps[effect2._depsLength++] = dep;
      } else {
        effect2._depsLength++;
      }
    }
  }
  function triggerEffects(dep, dirtyLevel) {
    pauseScheduling();
    for (const effect2 of dep.keys()) {
      let tracking;
      if (effect2._dirtyLevel < dirtyLevel && (tracking != null ? tracking : (tracking = dep.get(effect2) === effect2._trackId))) {
        effect2._shouldSchedule || (effect2._shouldSchedule = effect2._dirtyLevel === 0);
        effect2._dirtyLevel = dirtyLevel;
      }
      if (effect2._shouldSchedule && (tracking != null ? tracking : (tracking = dep.get(effect2) === effect2._trackId))) {
        effect2.trigger();
        if (!effect2._runnings && effect2._dirtyLevel !== 2) {
          effect2._shouldSchedule = false;
          if (effect2.scheduler) {
            queueEffectSchedulers.push(effect2.scheduler);
          }
        }
      }
    }
    resetScheduling();
  }

  function trackRefValue(ref2) {
    let _a;
    if (shouldTrack && activeEffect) {
      _a = ref2.dep;
      if (_a != null) {
        _a = _a;
      } else {
        _a = ref2.dep = createDep(() => (ref2.dep = void 0), ref2 instanceof ComputedRefImpl ? ref2 : void 0);
      }
      trackEffect(activeEffect, _a);
    }
  }
  function triggerRefValue(ref2, dirtyLevel, newVal) {
    const dep = ref2.dep;
    if (dep) {
      triggerEffects(dep, dirtyLevel);
    }
  }

  const reactiveMap = new WeakMap();
  const targetMap = new WeakMap();

  function track(target, type, key) {
    if (shouldTrack && activeEffect) {
      let depsMap = targetMap.get(target);
      if (!depsMap) {
        targetMap.set(target, (depsMap = new Map()));
      }
      let dep = depsMap.get(key);
      if (!dep) {
        depsMap.set(key, (dep = createDep(() => depsMap.delete(key))));
      }
      trackEffect(activeEffect, dep);
    }
  }
  function trigger(target, type, key, newValue, oldValue) {
    const depsMap = targetMap.get(target);
    if (!depsMap) {
      return;
    }
    let deps = [];
    if (key !== void 0) {
      deps.push(depsMap.get(key));
    }
    switch (type) {
      case 'set':
        // nothing
        break;
    }
    pauseScheduling();
    for (let dep of deps) {
      if (dep) {
        triggerEffects(dep, 4);
      }
    }
    resetScheduling();
  }

  class BaseReactiveHandler {
    constructor() {}
    get(target, key, receiver) {
      const result = Reflect.get(target, key, receiver);
      // track
      // console.log('proxy get');
      track(target, 'get', key);
      return result;
    }
  }
  class MutableReactiveHandler extends BaseReactiveHandler {
    constructor() {
      super();
    }
    set(target, key, value, receiver) {
      // console.log('proxy set');
      const oldValue = target[key];
      const result = Reflect.set(target, key, value, receiver);
      // add set 这里考虑 set
      if (!Object.is(oldValue, value)) {
        trigger(target, 'set', key, value, oldValue);
      }
      return result;
    }
  }

  function createReactiveObject(target, isReadonly2, baseHandlers, collectionHandlers, proxyMap) {
    const existingProxy = proxyMap.get(target);
    if (existingProxy) {
      return existingProxy;
    }
    const proxy = new Proxy(target, baseHandlers);
    proxyMap.set(target, proxy);
    return proxy;
  }
  function reactive(target) {
    return createReactiveObject(target, false, new MutableReactiveHandler(), {}, reactiveMap);
  }
  function toReactive(value) {
    return isObject(value) ? reactive(value) : value;
  }
  class RefImpl {
    constructor(value, shallow) {
      this.dep = void 0;
      this._rawValue = value;
      this._value = toReactive(value);
      // shallow 不考虑
      // this.shallow = shallow;
    }
    get value() {
      // console.log('get');
      trackRefValue(this);
      return this._value;
    }
    set value(newVal) {
      // console.log('set', newVal, this._rawValue);
      if (Object.is(newVal, this._rawValue)) {
        return;
      }
      this._rawValue = newVal;
      this._value = toReactive(newVal);
      triggerRefValue(this, 4, newVal);
    }
  }
  function createRef(rawValue, shallow) {
    return new RefImpl(rawValue, shallow);
  }
  function ref(value) {
    return createRef(value, false);
  }

  // computed
  class ComputedRefImpl {
    constructor(getter, _setter, isReadonly) {
      this.getter = getter;
      this._setter = _setter;
      this.dep = void 0;
      this.effect = new ReactiveEffect(
        () => getter(this._value),
        () => {
          triggerRefValue(this, this.effect._dirtyLevel === 2 ? 2 : 3);
        }
      );
      this.effect.computed = this;
      this.effect.active = true;
    }
    get value() {
      if (this.effect.dirty && !Object.is(this._value, (this._value = this.effect.run()))) {
        triggerRefValue(this, 4);
      }
      trackRefValue(this);
      if (this.effect._dirtyLevel >= 2) {
        triggerRefValue(this, 2);
      }
      return this._value;
    }
    set value(newVal) {
      this._setter(newVal);
    }
    get _dirty() {
      return this.effect.dirty;
    }
    set _dirty(v) {
      this.effect.dirty = v;
    }
  }
  function computed$1(getterOrOptions) {
    // only getter
    let getter = getterOrOptions;
    const cRef = new ComputedRefImpl(getter, NOOP, true);
    return cRef;
  }
  function computed(getterOrOptions) {
    const c = computed$1(getterOrOptions);
    return c;
  }

  // init
  const createApp = (options) => {
    return {
      mount() {
        if (options.setup) {
          options.setup();
        }
        const effect = new ReactiveEffect(options.render, NOOP, () => queueJob(update));
        const update = () => {
          if (effect.dirty) {
            effect.run();
          }
        };
        update();
      },
    };
  };

  return {
    ref,
    computed,
    createApp,
  };
});

相关链接

代码实现

END

告别 DOM 的旧时代:从零重塑 Web 渲染的未来

2025年8月18日 17:34

引言

浏览器这玩意儿现在真够诡异的。WebAssembly 在服务器端混得风生水起,但客户端还是那副老样子,跟十年前没啥区别。

WASM 粉会跟你吹,通过点 JS 胶水代码就能调原生 Web API。但核心问题是:为啥非得用 DOM?这东西就是个默认选项罢了。本文直击 DOM 和相关 API 的痛点,为什么该让它们退场了,顺便脑洞下怎么改进。

作者不是浏览器全栈专家——没人能全懂了,这正是症结所在:东西太杂太乱

DOM 的“文档”模型:臃肿得像个大胖子

DOM 烂到什么程度?Chrome 里document.body有 350+个键值,大致分类:

  • 节点操作:appendChild、removeChild之类的。
  • 样式相关:style对象塞了 660 个 CSS 属性。
  • 事件处理:那些过气的onevent属性,比如onclick,基本没人用了。
  • 杂七杂八:innerHTML、className等。

属性和方法界限模糊,很多 getter 会偷偷触发重排,setter 藏在暗处。还有一堆历史遗毒。

DOM 不瘦身,还在发福。你是否感受到这痛苦,取决于你是搞静态页还是 Web App。作为开发者,我们大多避开直接操 DOM,转而用框架。但偶尔有纯 DOM 党吹它牛逼——纯属自欺欺人。DOM 的声明式功能,比如innerHTML,跟现代 UI 模式八竿子打不着。同一件事 DOM 有 N 种方式,全都不优雅。

Web Components 的尴尬处境

Web Components 是浏览器原生组件方案,但来得太晚,人气不高。API 设计笨重,Shadow DOM 加了层嵌套和作用域,调试起来头大。粉丝的辩护听着像在找借口。以下是一个简单的示例:

JavaScript class HelloWorld extends HTMLElement { connectedCallback() { const shadow = this.attachShadow({ mode: 'closed' }); const template = document.getElementById('hello-world').content.cloneNode(true); const hwMsg = `Hello ${this.getAttribute('name')}`; Array.from(template.querySelectorAll('.hw-text')).forEach(n => n.textContent = hwMsg); shadow.append(template); } } customElements.define('hello-world', HelloWorld);

看起来还行?但实际开发中,Shadow DOM 的复杂性和 DOM 的字符串化特性(stringly typed)让开发者头疼。相比之下,React、Vue 等框架的虚拟 DOM 完全避开了这些问题,因为它们的语法只是“长得像 XML”,而不是真的依赖 DOM。

HTML 的停滞不前

HTML10-15 年没大动静。ARIA 是亮点,但只是补语义 HTML 的漏。语义 HTML 从 2011 年就开始推,但到现在都没<thread>或<comment>标签。嵌套<article>来模拟评论?指导原则也奇葩。

HTML 总像在嫉妒纸媒,没能真正拥抱超文本本质,也不信开发者能守规矩。

WHATWG(浏览器厂商)接管后,没啥愿景,就在边边角角加补丁。CSS 甚至长出了表达式——每个模板语言都想变编程语言。

编辑 HTML?contentEditable理论上行,但实际搞成可用编辑器是黑魔法。Google Docs 和 Notion 的工程师肯定有吐不完的槽。

渐进增强、 markup/style 分离?做 App 的开发者早不信这套了。

现在 App 大多用 HTML/CSS/SVG 拼凑 UI,但开销巨大,越来越不像正经 UI 工具箱。

比如 Slack 的输入框:用一堆 div 模拟富文本。剪贴板 hack 用隐藏元素。列表/表格得手动虚拟化,自管布局、重绘、拖拽。聊天窗滚动条粘底每次都得重写。虚拟化越深,越得重造页面搜索、右键菜单等。

Web 混淆了 UI 和流式内容,当年新鲜,现在过时。UI 陈旧,内容同质化。

CSS 的“内外倒挂”:别用错心智模型

CSS 口碑一般,但问题在哪?很多人误以为它是约束求解器。看这例子:

HTML <div> <div style="height: 50%">...</div> <div style="height: 50%">...</div> </div>
HTML <div> <div style="height: 100%">...</div> <div style="height: 100%">...</div> </div>

第一个想分一半高?第二个自相矛盾?实际 CSS 忽略height,父元素收缩包裹内容。

CSS 是两趟约束:先外到内传尺寸,再内到外集内容大小。App 布局外到内:分空间,内容不影响面板大小。文档内到外:段落撑开父级。

CSS 默认内到外,文档导向。要外到内,得手动传约束,从body { height: 100%; }开始。这就是垂直对齐难的原因。

Flexbox 给显式控制:

用flex-grow/shrink做无溢出自适应布局,加 gap 间距。

但 Flex 混淆了简单模型:需先“猜测”子自然尺寸,布局两次——一次假设浮空,一次调整。递归深了可能爆栈,虽少见,但大内容一丢,一切变形。

避坑:用contain: size隔离,或手动设flex-basis。

CSS 有contain、will-change这类直击布局的,暴露底层层级本质。代替position: absolute包裹。

本质上,这些切断 DOM 全局约束流——默认太宽泛,太文档化

CSS 的好地方?

Flexbox 懂了这些坑,还挺靠谱。嵌套行列+gap,直观适配尺寸。CSS 好部分在这,但得用心打磨。Grid 类似,但语法太 CSS 味儿,啰嗦。

从零设计布局,不会这样:不会用减法 API 加屏障提示。会拆成组件,用外/内容器+放置模型,按需组合。

inline-block/inline-flex示意:内部 block/flex,外部 inline。盒模型两正交面。

文本/字体样式是特例:继承如font-size,为<b>工作。但 660 属性大多不继承——边框不递归子级,那会傻。

CSS 至少两东西混搭:继承的富文本样式系统 + 非继承的嵌套布局系统。用同语法/API 是错。

em相对缩放过时,现在逻辑/设备像素更 sane。

SVG 无缝入 DOM,动态形状/图标调色。但 SVG 非 CSS 子/超集,重叠处微差,如transform。坐标字符串化,烦。

CSS 加圆角/渐变/剪裁,有 SVG 嫉妒,但远不及。SVG 做多边 hit-testing,CSS 不行。SVG 有自己图形效果。

选 HTML/CSS 还是 SVG?基于具体 trade-off,全是向量后端。

注意一下的坑:

  • text-ellipsis只截单行文本,非段落。检测/测量文本 API 烂,大家数字母凑合。
  • position: sticky零抖动滚动固定,但有 bug。无条件 sticky 需荒谬嵌套,本该简单。
  • z-index绝对层级战,z-index-war.css 里 +1/-1 比拼。无相对 Z 概念。

API 设计难,得迭代建真东西,找漏。

SVG 与 CSS 的权衡

SVG 在 Web 中用于动态生成图形或调整图标样式,但它与 CSS 并非完全兼容。例如,SVG 的 transform 与 CSS 的变换属性有微妙差异,且 SVG 的坐标全是字符串序列化,增加了开发复杂性。

国内场景:假设你在开发一个数据可视化仪表盘,类似 ECharts 的柱状图。你可以选择用 SVG 绘制图形,或者用 CSS 实现类似效果。SVG 支持多边形点击检测(hit-testing),而 CSS 不行;但 CSS 的圆角、渐变等功能又让 SVG 显得多余。最终,你可能需要在两者间做痛苦的权衡

Canvas 上的油画:HTML in Canvas 的坑

DOM 坏,CSS 几成好,SVG 丑但必备……没人修?

诊断:中间层不合用。HTML6 先砍东西起步。

但关键解放现有功能。理想:用户空间 API 同逃生口,狗食自家。

HTML in Canvas 提案:画 HTML 到<canvas>,控视觉。不是好法。

API 形因塞 DOM:元素须 canvas 后代参与布局/样式/无障碍。离屏用有“技术关切”。

例子:旋转立方体交互用 hit 矩形+paint 事件。新 hit API,但只 2D——3D 纯装饰?问题多。

从零设计,不会这样!尤其浏览器有 CSS 3D transform,何须为自定义渲染全接交互?

未覆盖用例如曲面投影,需复杂 hit。想过下拉菜单吗?

像没法统 CSS/SVG 滤镜,或加 CSS shader。经 canvas 是剩选项。“至少可编程”?截 DOM 一好用,但非卖点。

Canvas 上复杂 UI 是为绕 DOM:虚拟化、JIT 布局/样式、效果、手势/hit 等。全低级。预备 DOM 内容反生产。

反应性上,路由回同树设循环。渲染 DOM 的 canvas 非文档元素了。

Canvas 真痛:无系统字体/文本布局/UI 工具。从零实现 Unicode 分词,就为包裹文本。

提案“DOM 黑箱内容”,但知套路:还得 CSS/SVG 拼凑。text-ellipsis仍破,得从 90 年代 UI 重造。

全或无,要中道。下层需开。

未来的方向:重新设计 DOM

DOM 和 CSS 的问题根源在于它们背负了太多的历史包袱。以下是一些可能的改进方向:

  1. 精简的数据模型:未来的 DOM 需要大幅减少属性数量(从 350+精简到几十个),专注于核心功能。类似 React 的虚拟 DOM,但直接内置于浏览器中。在开发类似头条的信息流应用时,开发者需要快速渲染大量卡片。精简的 DOM 模型可以减少不必要的 API 调用,提高性能。
  2. 统一的布局系统:将 CSS 的内外布局模式明确分开,支持更直观的“外部约束”和“内部自适应”。例如,垂直居中应该像 align-items: center 一样简单。在电商平台的商品详情页中,开发者希望轻松实现复杂的布局(例如商品图片和描述的动态对齐),而不是依赖一堆 CSS hack。
  3. WebGPU 的潜力:WebGPU 提供了更底层的渲染能力,可以完全抛弃 DOM 的复杂性。例如,Use.GPU 项目展示了一个基于 WebGPU 的简洁布局系统,代码量仅为传统 DOM/CSS 的几分之一。在开发类似 B 站的弹幕播放器时,WebGPU 可以用来高效渲染动态弹幕,省去 DOM 的性能开销。
  4. 多线程与隔离:现代浏览器已经是多进程架构,但 DOM 的设计没有跟上。未来的 DOM 需要支持更好的多线程和跨源隔离,适应复杂的 Web 应用需求。在企业级应用(如钉钉的协作平台)中,开发者需要集成第三方服务(如 OAuth 登录)。一个支持多线程的 DOM 可以显著提高安全性和性能。

结论

HTML、CSS 和 DOM 的现状就像一辆老旧的马车,虽然还能跑,但早已不适合现代 Web 应用的复杂需求。国内开发者在开发小程序、电商平台或社交应用时,常常需要用框架和 hack 来弥补 DOM 的不足。未来的 Web 需要一个更精简、更灵活的渲染模型,可能基于 WebGPU 或全新的 API 设计。

与其修补 DOM 的漏洞,不如从第一性原理出发,重新设计一个适合现代应用的 Web 渲染层。就像当年的 Netscape 开启了 Web 时代,今天的我们也有机会重新定义浏览器的未来。

推荐一个三维导航库:three-pathfinding-3d

2025年8月18日 17:32

three-pathfinding-3d

介绍

PS: 由于 three-pathfinding 作者目前没有维护,提交的pr也没有得到反馈,所以自己新建了一个库,其中大部分代码从 three-pathfinding 拷贝而来,解决此库未解决的几个问题。

如有侵权请联系我:email:526838933@qq.com

three-pathfingding-3d 由 three-pathfinding 优化而来,修复了该库存在的几个已知问题:

  1. funnel 算法缺失第一通道,该问题导致最终路径错误
  2. funnel 算法没有针对3d场景进行优化,该问题导致在特殊情况下,实际路径与算法生成路径差距过大,很多情况会异常中断算法执行。

该库解决了第一个问题,将缺失的第一通道补充。 该库解决了第二个问题,优化 funnel => funnel3d 使其在 3d 情况下能够生成合适的路径。

使用说明
git clone https://gitee.com/yjsdszz/three-pathfinding-3d

cd demo

npm i 

npm run dev
npm i three-pathfinding-3d

技术原理:

  1. navigation mesh : 导航网格,根据此mesh的几何数据构建图,A*算法基于此图搜索。
  2. 漏斗算法:基于A*搜索得到的结果,生成通道,根据漏斗算法得到最终路径。
  3. 三维场景需要考虑垂直差,在传统的2d算法情况下,需要进行优化部分场景。

有需要了解更多细节的,可以私信我详细讨论。

前端实现表格下拉框筛选和表头回显和删除

作者 成小白
2025年8月18日 17:23

前言

公司项目要实现在表格上通过表头添加筛选条件,筛选后再表格上方回显筛选后的数据可单独删除和全部删除。 效果如下:

动画.gif

实现思路

下拉筛选
  • 传递的给后端的数据为单个值时(如多选地区等):使用v-model双向绑定来传递数据
  • 传递的给后端的数据为两个值时(如时间段筛选 开始时间-结束时间等):使用v-bind传递数组来进行获取和设置
表头筛选条件
  • 通过下拉筛选改变的值所组装的数据来进行回显
  • 在表格组件里面传递所有传递给后端的值一个对象用来改变传递的数据

代码实现

下拉筛选代码

<template>
    <div class="dropDownFilter-container">
        <el-dropdown class="elDropdownBox" ref="dropDownFilterElDropDownRef" trigger="click" :hide-on-click="false" @visible-change="visibleChange">
            <div class="titleBox" ref="dropDownFilterTitleBoxRef">
                <div class="title">{{ title }}</div>
                <div class="titleIcon">
                    <i class="iconfont  iconBox" :class="[isFiltered?'icon-yishaixuan':'icon-sangedian']"></i>
                </div>
            </div>
            <el-dropdown-menu slot="dropdown" placement="bottom-end">
                <el-dropdown-item class="elDropContainer">
                    <!--单个 日期选择器 -->
                    <template v-if="['date','dateYear','dateMonth'].includes(type)">
                        <el-date-picker v-model="internalValue" :format="dateFormat" :value-format="dateFormat" :type="dateType" placeholder="选择日期" @change="dateChange" :popper-class="className">
                        </el-date-picker>
                    </template>
                    <!-- 日期时间范围选择器 -->
                    <template v-if="['dateRange','dateRangeMonth','dateRangeTime'].includes(type)">
                        <el-date-picker v-model="internalValue" :format="dateRangeFormat" :value-format="dateRangeFormat" :type="dateRangeType" @change="dateRangeChange" :append-to-body="false" range-separator="~" start-placeholder="开始日期" end-placeholder="结束日期" :clearable="false" :popper-class="className">
                        </el-date-picker>
                    </template>
                    <!-- 多选复选框 -->
                    <template v-if="type === 'checkbox'">
                        <div class="searchBox">
                            <el-input v-model="searchValue" placeholder="请输入内容"></el-input>
                        </div>
                        <div class="container">
                            <el-checkbox-group v-model="checkValue" @change="checkChange">
                                <div class="checkboxBox">
                                    <div v-for="item in selectOptions" :key="item[optValue]" class="checkItem">
                                        <el-checkbox :label="item[optValue]">{{item[optLabel]}}</el-checkbox>
                                    </div>
                                </div>
                            </el-checkbox-group>
                        </div>
                    </template>
                    <!-- 省市区 父子关联 -->
                    <template v-if="type ==='area'">
                        <el-cascader v-model="areaValue" ref="areaCascadredropDownFilterRef" placeholder="请选择" :options="areaList" @change="areaChange" :props="{label:optLabel,value:optValue,children:optChildren,multiple:true}" :append-to-body="false" :show-all-levels="false" clearable size="mini" collapse-tags></el-cascader>
                    </template>
                    <!-- 多级选择器 -->
                    <template v-if="type ==='cascader'">
                        <el-cascader v-model="areaValue" ref="cascadredropDownFilterRef" placeholder="请选择" :options="selectOptions" @change="cascaderChange" :props="{label:optLabel,value:optValue,children:optChildren,multiple:true}" :append-to-body="false" :popper-class="className" :show-all-levels="false" clearable size="mini" collapse-tags></el-cascader>
                    </template>
                    <!-- 输入框范围 -->
                    <template v-if="type ==='inputRange'">
                        <div class="inpitRangeBox">
                            <div class="iColBox">
                                <el-input v-model.number.trim="minNum" placeholder="请输入最小值" class="focusVisibleOutLine" style="width: 110px"></el-input>
                                <span class="iline" style="margin: 0 5px"></span>
                                <el-input v-model.number.trim="maxNum" placeholder="请输入最大值" class="focusVisibleOutLine" style="width: 110px"></el-input>
                            </div>
                            <div class="tipBox" v-if="tipText">{{ tipText }}</div>
                            <div class="inBtnBox">
                                <span class="clearBtn" @click="clearInputRange">清空</span>
                                <el-button size="mini" type="primary" @click="inputRangeChange">确定</el-button>
                            </div>
                        </div>
                    </template>
                </el-dropdown-item>
            </el-dropdown-menu>
        </el-dropdown>
    </div>
</template>

<script>
/**
 *  下拉框组件
 * 
 *  使用方法: 配合headerFilter使用,headerFilter是表头筛选条件组件 
 *     
 *  @type: 类型 
 *      
 *   1.单个日期选择器------date 日        dateMonth 月            dateYear 年
 *   2.日期范围选择器------dateRange 日   dateRangeTime 时分秒    dateRangeMonth 月 
 *   3.复选框-------------checkbox
 *   4.省市区选择器--------area
 *   5.多级选择器---------cascader
 *   6.输入框范围选择器----inputRange
 *  
 *  
 *   1.单个日期选择器
 *    <DropDownFilter type="date" title="日期" v-model="reqParams.dateData" valueKey="dateData"  @dropDownFilterChange="dropDownFilterChange" />
 *   2.日期范围选择器
 *    <DropDownFilter type="dateRange" title="时间" :valueKey="['startSubmitTime','endSubmitTime']" :params="reqParams" @dropDownFilterChange="dropDownFilterChange" />
 *   3.复选框
 *     <DropDownFilter type="checkbox" title="来源" v-model="reqParams.source"  valueKey="source"  :options="aOpt" optLabel="dictName" optValue="dictCode" @dropDownFilterChange="dropDownFilterChange" />
 *   4.省市区选择器
 *     <DropDownFilter type="area" title="地区" v-model="reqParams.cityList"  valueKey="cityList"  optValue="id" optChildren="children" @dropDownFilterChange="dropDownFilterChange" />
 *   5.多级选择器
 *     <DropDownFilter type="cascader" title="国标行业" v-model="reqParams.industryList" valueKey="industryList" :options="industryNameIdList" optValue="id" @dropDownFilterChange="dropDownFilterChange" />
 *   6.输入框范围选择器
 *     <DropDownFilter type="inputRange" title="积分" :valueKey="['pointsCountStart','pointsCountEnd']" :params="reqParams" @dropDownFilterChange="dropDownFilterChange" />
 * 
 * 
 *  // 下拉筛选改变触发
    dropDownFilterChange(params, assembleData) {
        this.selectedFilters = this.selectedFilters.filter(item => item.title !== assembleData.title);
        if (assembleData.value) {
            this.selectedFilters.push(assembleData);
        }
        // 自己的逻辑
        // this.reqParams.startIndex = 1
        // this.getPageList()
    }
    
    属性
 * @valueKey 选中值key 如果是单个日期选择器,则传字符串 ,如果是日期范围选择器,则传数组
 * @params  对象参数 
 * @options 下拉框选项
 * @optLabel 下拉框选项名字key
 * @optValue 下拉框选项vakue-key
 * @optChildren 下拉框选项子级key
 * 
 * 方法
 * @dropDownFilterChange 下拉筛选改变触发
 * 
 *
 */
import { getCityTree } from '@/api/common'
export default {
    props: {
        // 类型
        type: {
            type: String,
            default: ''
        },
        // 标题
        title: {
            type: String,
            default: ''
        },
        // 选中值key 如果是单个日期选择器,则传字符串 ,如果是日期范围选择器,则传数组
        valueKey: {
            type: [String, Array],
            default: ''
        },
        //  v-model双向绑定
        value: {
            type: [String, Array, Number, Object],
            default: ''
        },
        // 下拉框选项
        options: {
            type: Array,
            default: () => []
        },
        // 下拉框选项名字key
        optLabel: {
            type: String,
            default: 'label'
        },
        // 下拉框选项vakue-key
        optValue: {
            type: String,
            default: 'value'
        },
        // 下拉框选项子级key
        optChildren: {
            type: String,
            default: 'children'
        },
        // 对象参数
        params: {
            type: Object,
            default: () => { }
        },
        // 下拉框选项是否右对齐
        isPopperRight: {
            type: Boolean,
            default: false
        }
    },
    data() {
        return {
            className: '',
            // 多选框选中的数据
            checkValue: [],
            // 单项选择选中的数据
            internalValue: '',
            // 搜索框
            searchValue: '',
            // 下拉框选项
            selectOptions: [],
            // 地区数据
            areaList: [],
            areaValue: [],
            // 选择后回显的名字
            selectNameList: [],
            minNum: '',
            maxNum: '',
            tipText: '',
            // 是否被筛选
            isFiltered: false
        }
    },
    watch: {
        // 监听下拉框选项数据
        options: {
            handler(val) {
                this.selectOptions = val
            },
            immediate: true,
            deep: true
        },
        // v-model双向绑定 
        value: {
            handler(val) {
                let typeS = this.type
                if (!val || val.length == 0) {
                    this.isFiltered = false
                }
                if (typeS == 'checkbox') {
                    this.checkValue = val
                } else if (typeS == 'area' || typeS == 'cascader') {
                    if (val.length == 0) {
                        this.areaValue = []
                        this.isFiltered = false
                    }
                } else {
                    this.internalValue = val

                }
            },
            deep: true,
        },
        // 监听 headerFilter表头筛选删除触发清空已选的数据
        params: {
            handler(val) {
                if (['dateRange', 'dateRangeMonth', 'dateRangeTime'].includes(this.type)) {
                    if (!val[this.valueKey[0]]) {
                        this.internalValue = ''
                        this.isFiltered = false
                    }
                }
                if (this.type == 'inputRange') {
                    if (!val[this.valueKey[0]]) {
                        this.internalValue = ''
                        this.minNum = ''
                        this.maxNum = ''
                        this.isFiltered = false
                    }
                }
            },
            deep: true,
        }
    },
    computed: {
        // 时间格式转换
        dateFormat() {
            switch (this.type) {
                case 'date':
                    return 'yyyy-MM-dd'
                case 'dateYear':
                    return 'yyyy'
                case 'dateMonth':
                    return 'yyyy-MM'
            }
        },
        // 时间类型转换
        dateType() {
            switch (this.type) {
                case 'date':
                    return 'date'
                case 'dateYear':
                    return 'year'
                case 'dateMonth':
                    return 'month'
            }
        },
        // 时间范围格式转换
        dateRangeFormat() {
            switch (this.type) {
                case 'dateRange':
                    return 'yyyy-MM-dd'
                case 'dateRangeMonth':
                    return 'yyyy-MM'
                case 'dateRangeTime':
                    return 'yyyy-MM-dd HH:mm:ss'
            }
        },
        // 时间范围类型转换
        dateRangeType() {
            switch (this.type) {
                case 'dateRange':
                    return 'daterange'
                case 'dateRangeMonth':
                    return 'monthrange'
                case 'dateRangeTime':
                    return 'datetimerange'
            }
        }
    },
    mounted() {
        if (this.type === 'area') {
            this.getAreaList()
        }
        this.getElementPostion()
    },
    methods: {
        // 输入框点击清除按钮触发
        clearInputRange() {
            this.minNum = ''
            this.maxNum = ''
        },
        // 输入框点击确定按钮触发
        inputRangeChange() {
            if (!this.minNum && !this.maxNum) {
                this.tipText = '请输入最小值或最大值'
                return
            }
            if (this.minNum && this.maxNum && this.minNum > this.maxNum) {
                this.tipText = '最小值不能大于最大值'
                return
            }
            this.tipText = ''
            if (this.valueKey && this.valueKey.length == 2) {
                this.params[this.valueKey[0]] = this.minNum
                this.params[this.valueKey[1]] = this.maxNum
            }
            this.internalValue = [this.minNum, this.maxNum]
            this.selectNameList = this.internalValue
            this.visibleChange(false);
            this.triggerChange()
        },
        // 多级选择器选择触发
        cascaderChange() {
            let list = this.$refs['cascadredropDownFilterRef'].getCheckedNodes()
            list = list.filter(item => !(item.parent && item.parent.checked))
            this.internalValue = list.map(v => v.value)
            this.selectNameList = list.map(v => v.label)
            this.triggerChange()
        },
        // 地区选择触发
        areaChange(val) {
            let list = this.$refs['areaCascadredropDownFilterRef'].getCheckedNodes()
            list = list.filter(item => !(item.parent && item.parent.checked))
            this.internalValue = list.map(v => v.value)
            this.selectNameList = list.map(v => v.label)
            this.triggerChange()
        },
        // 复选框选择触发
        checkChange(val) {
            this.internalValue = val
            this.selectNameList = this.getSelectName()
            this.triggerChange()
        },
        // 时间选择器触发
        dateRangeChange(val) {
            for (let i = 0; i < this.valueKey.length; i++) {
                this.params[this.valueKey[i]] = val[i]
            }
            this.selectNameList = this.internalValue
            this.visibleChange(false);
            this.triggerChange()
        },
        // 时间选择器触发
        dateChange(val) {
            this.selectNameList = [val]
            this.visibleChange(false);
            this.triggerChange()
        },
        // 获取选中的名字
        getSelectName() {
            let selectName = []
            if (this.checkValue.length) {
                for (let i = 0; i < this.checkValue.length; i++) {
                    let item = this.selectOptions.find(v => v[this.optValue] == this.checkValue[i])
                    selectName.push(item)
                }
            }
            selectName = selectName.map(v => v[this.optLabel])
            return selectName
        },
        // 选择完成后触发
        triggerChange() {
            this.$emit('input', this.internalValue)
            // 获取当前选中值
            let dataKeyMap = {}
            // 组装数据
            let assembleData = {
                // 类型
                type: this.type,
                // 标题
                title: this.title,
                // 选中的值
                value: {},
                // 当前的key
                key: this.valueKey,
                // 选中的名字
                nameList: this.selectNameList,
            }
            // 根据类型组装数据
            if (Array.isArray(this.valueKey)) {
                for (let i = 0; i < this.valueKey.length; i++) {
                    dataKeyMap[this.valueKey[i]] = this.internalValue[i]
                }
                assembleData.value = dataKeyMap

            } else {
                dataKeyMap = { [this.valueKey]: this.internalValue }
                assembleData.value = dataKeyMap[this.valueKey]
            }
            this.isFiltered = true
            // console.log(assembleData, 'assembleData');
            this.$emit('dropDownFilterChange', dataKeyMap, assembleData)
        },
        // 下拉框显示隐藏
        visibleChange(val) {
            if (!val) {
                this.$refs.dropDownFilterElDropDownRef.hide();
            }
        },
        // 获取地区列表
        getAreaList() {
            getCityTree().then(res => {
                if (res.code == 0) {
                    this.areaList = res.data
                }
            })
        },
        // 获取当前元素所在位置来判断下拉是在左开还是右开
        getElementPostion() {
            let el = this.$refs.dropDownFilterTitleBoxRef
            if (el) {
                let elLeft = el.getBoundingClientRect().left
                let sceenWidth = window.innerWidth
                if (sceenWidth - elLeft < 400) {
                    this.className = 'elDropDownFilterPopstionRight'
                }
            }
            if (this.isPopperRight && (this.type == 'area' || this.type == 'cascader')) {
                this.className = 'elDropDownFilterPopstionRight'
            }
        }
    }
}
</script>

<style>
.elDropDownFilterPopstionRight {
    right: 0 !important;
    left: auto !important;
}
</style>

<style lang="scss" scoped>
.inpitRangeBox,
.iline,
.tipBox,
.clearBtn,
.focusVisibleOutLine {
    &:focus,
    &:focus-visible {
        outline: unset;
    }
    &:hover {
        background: #fff;
    }
}
.el-dropdown-menu__item:focus,
.dropDownFilter-container {
    width: 100%;
    &:focus,
    &:focus-visible {
        outline: unset;
    }
    .elDropdownBox {
        width: 100%;
        .titleBox {
            display: flex;
            align-items: center;
            justify-content: space-between;
            &:focus,
            &:focus-visible {
                outline: unset;
            }
            .iconBox {
                cursor: pointer;
            }
        }
    }
}
.elDropContainer {
    // all: unset;
    padding: 0px 10px !important;
    border-radius: 4px;
    &:hover {
        background: #fff;
    }

    .container {
        width: 264px;
        .checkboxBox {
            .checkItem {
                width: 100%;
                line-height: 30px;
                height: 30px;
                font-size: 12px;
                &:hover {
                    background: #edecf0;
                }
            }
        }
    }
    .inpitRangeBox {
        padding: 10px 0;
        position: relative;
        .tipBox {
            font-size: 12px;
            color: red;
            position: absolute;
            line-height: 16px;
        }
        .inBtnBox {
            margin-top: 10px;
            display: flex;
            justify-content: flex-end;
            align-items: center;
            .clearBtn {
                margin-right: 10px;
                color: #0052cc;
            }
        }
    }
}
</style>

表头筛选代码

<template>
    <div class="headerFilter-container" v-if="selectData.length">
        <div class="container">
            <span>表头筛选条件:</span>
            <div class="switchBox">
                <i class="el-icon el-icon-caret-left"></i>
            </div>
            <div class="rowBox">
                <div class="colBox" v-for="item in selectData" :key="item.title">
                    <div class="left">
                        <div class="name"> {{ item.title }}:</div>
                        <div class="value">
                            <template v-if="['dateRange','inputRange','dateRangeMonth','dateRangeTime'].includes(item.type) ">
                                {{ item.nameList.join('~') }}
                            </template>
                            <template v-else>
                                {{ item.nameList.join(',') }}
                            </template>

                        </div>
                    </div>
                    <div class="iconBox" @click="removeFilter(item)">
                        <i class="el-icon-close"></i>
                    </div>
                </div>
            </div>
            <div class="switchBox">
                <i class="el-icon el-icon-caret-right"></i>
            </div>
            <div class="blueText mt4 cura" @click="clearAll">清除全部</div>
        </div>
    </div>
</template>

<script>
/**
 *  表头筛选条件组件 
 * 
 *    使用方法:配合dropDownFilter组件使用
 * 
 *       <HeaderFilter :option="selectedFilters" :params="reqParams" @change="headerChange" />
 *  
 *      // 表头筛选改变触发
        headerChange(val, data) {
            for (const key in val) {
                this.reqParams[key] = val[key]
            }
            this.selectedFilters = data
            // 自己的逻辑
            // this.reqParams.startIndex = 1
            // this.getPageList()
        }

        // 下拉筛选改变触发
        dropDownFilterChange(params, assembleData) {
            this.selectedFilters = this.selectedFilters.filter(item => item.title !== assembleData.title);
            if (assembleData.value) {
                this.selectedFilters.push(assembleData);
            }
            // 自己的逻辑
            // this.reqParams.startIndex = 1
            // this.getPageList()
        }

        属性
        @option 筛选条件数组
        @params 请求参数

        方法
        @change 筛选条件改变触发
 */
export default {
    props: {
        option: {
            type: Array,
            default: () => []
        },
        params: {
            type: Object,
            default: () => { }
        }
    },
    data() {
        return {
            selectData: this.option
        }
    },
    watch: {
        option: {
            handler(val) {
                if (val && val.length) {
                    this.selectData = val.filter(v => v.nameList && v.nameList.length)
                }
            },
            immediate: true,
            deep: true
        }
    },
    methods: {
        // 点击清除全部按钮
        clearAll() {
            for (let i = 0; i < this.selectData.length; i++) {
                this.changeData(this.selectData[i])
            }
            this.selectData = []
            this.$emit('change', this.params, [])
        },
        // 点击删除按钮
        removeFilter(item) {
            let index = this.selectData.findIndex((i) => i.title === item.title)
            if (index !== -1) {
                this.changeData(item)
                this.selectData.splice(index, 1)
                this.$emit('change', this.params, this.selectData)
            }
        },
        // 改变数据
        changeData(item) {
            if (Array.isArray(item.value)) {
                this.params[item.key] = []
            } else if (typeof item.value === 'string') {
                this.params[item.key] = ''
            } else {
                for (const key in item.value) {
                    this.params[key] = ''
                }
            }
        }
    }
}   
</script>

<style lang="scss" scoped>
.headerFilter-container {
    .container {
        display: flex;
        align-items: center;
        .rowBox {
            display: flex;
            align-items: center;
            gap: 10px;
            .colBox {
                width: 230px;
                padding: 0 10px;
                height: 24px;
                line-height: 24px;
                background-color: #f2f5f9;
                border-radius: 4px;
                display: flex;
                align-items: center;
                font-size: 12px;
                overflow: hidden;
                .left {
                    flex: 1;
                    flex-shrink: 0;
                    display: flex;
                    align-items: center;
                    overflow: hidden;
                    .name {
                        white-space: nowrap;
                    }
                    .value {
                        overflow: hidden;
                        text-overflow: ellipsis;
                        white-space: nowrap;
                    }
                }
                .iconBox {
                    width: 16px;
                    margin-left: 6px;
                    cursor: pointer;
                }
            }
        }
        .switchBox {
            width: 20px;
            height: 24px;
            line-height: 24px;
            margin: 0 10px;
            cursor: pointer;
            background-color: #fff;
            text-align: center;

            .el-icon-caret-left {
                color: #172b4d;
            }
        }
    }
}
</style>

使用

  • 在表格页面分别引用这两个组件
  • import HeaderFilter from '@/components/headerFilter/index.vue'
  • import DropDownFilter from '@/components/dropDownFilter/index.vue'
  <template>
    <div style="padding: 20px;">
        <!-- 表头筛选选择项 -->
        <div class="headBox" v-if="selectedFilters.length">
            <HeaderFilter :option="selectedFilters" :params="reqParams" @change="headerChange" />
        </div>
        <!-- 表格 -->
        <el-table :data="tableData">
            <el-table-column prop="address">
                <template #header>
                    <DropDownFilter v-model="reqParams.cityList" title="地区1" valueKey="cityList" type="area" optValue="id" optChildren="children" @dropDownFilterChange="dropDownFilterChange" />
                </template>
            </el-table-column>
            <el-table-column prop="date">
                <template #header>
                    <DropDownFilter type="date" title="日期" valueKey="dateData" v-model="reqParams.dateData" @dropDownFilterChange="dropDownFilterChange" />
                </template>
            </el-table-column>
            <el-table-column prop="date">
                <template #header>
                    <DropDownFilter title="年份" valueKey="year" type="dateYear" v-model="reqParams.year" @dropDownFilterChange="dropDownFilterChange" />
                </template>
            </el-table-column>
            <el-table-column prop="date">
                <template #header>
                    <DropDownFilter title="月份" valueKey="month" type="dateMonth" v-model="reqParams.month" @dropDownFilterChange="dropDownFilterChange" />
                </template>
            </el-table-column>
            <el-table-column>
                <template #header>
                    <DropDownFilter type="dateRange" title="时间" :valueKey="['startSubmitTime','endSubmitTime']" :params="reqParams" @dropDownFilterChange="dropDownFilterChange" />
                </template>
            </el-table-column>
            <el-table-column>
                <template #header>
                    <DropDownFilter type="dateRangeMonth" title="时间月份" :valueKey="['startYear','endYear']" :params="reqParams" @dropDownFilterChange="dropDownFilterChange" />
                </template>
            </el-table-column>
            <el-table-column>
                <template #header>
                    <DropDownFilter type="dateRangeTime" title="时间分钟" :valueKey="['startRanTime','endRanTime']" :params="reqParams" @dropDownFilterChange="dropDownFilterChange" />
                </template>
            </el-table-column>

            <el-table-column prop="name">
                <template #header>
                    <DropDownFilter type="inputRange" title="积分" :valueKey="['pointsCountStart','pointsCountEnd']" :params="reqParams" @dropDownFilterChange="dropDownFilterChange" />
                </template>
            </el-table-column>
            <el-table-column prop="address">
                <template #header>
                    <DropDownFilter type="cascader" title="地区" v-model="reqParams.cityList1" isPopperRight valueKey="cityList1" :options="industryNameIdList" optValue="id" @dropDownFilterChange="dropDownFilterChange" />
                </template>
            </el-table-column>
            <el-table-column prop="address">
                <template #header>
                    <DropDownFilter type="dateRange" title="提交时间" :valueKey="['startTime','endTime']" :params="reqParams" @dropDownFilterChange="dropDownFilterChange" />
                </template>
            </el-table-column>
            <el-table-column prop="address">
                <template #header>
                    <DropDownFilter v-model="reqParams.address" title="地址" valueKey="address" type="checkbox" :options="aOpt" optLabel="dictName" optValue="dictCode" @dropDownFilterChange="dropDownFilterChange" />
                </template>
            </el-table-column>

        </el-table>
    </div>
</template>

<script>
import Drag from './drag.vue'
import NoPower from '@/components/noPower/index.vue'
import { getDictList } from '@/api/common.js'
import HeaderFilter from '@/components/headerFilter/index.vue'
import DropDownFilter from '@/components/dropDownFilter/index.vue'
import { findIndustryList } from '@/api/user.js'
export default {
    components: {
        NoPower,
        DropDownFilter,
        HeaderFilter,
        Drag
    },
    data() {
        return {
            selectedFilters: [],
            tableData: [{
                date: '2016-05-02',
                name: '王小虎',
                address: '上海市普陀区金沙江路 1518 弄'
            }, {
                date: '2016-05-04',
                name: '王小虎',
                address: '上海市普陀区金沙江路 1517 弄'
            }, {
                date: '2016-05-01',
                name: '王小虎',
                address: '上海市普陀区金沙江路 1519 弄'
            }, {
                date: '2016-05-03',
                name: '王小虎',
                address: '上海市普陀区金沙江路 1516 弄'
            }],
            reqParams: {
                startRanTime: '',
                endRanTime: '',
                startYear: '',
                endYear: '',
                month: '',
                year: '',
                pointsCountStart: '',
                pointsCountEnd: '',
                cityList1: [],
                cityList: [],
                startTime: '',
                endTime: '',
                dateData: [],
                userName: '',
                address: [],
                startSubmitTime: '',
                endSubmitTime: '',
                startIndex: 1,
                pageSize: 10
            },
            nameOpt: [{
                label: '张三',
                value: 'zhangsan'
            },
            {
                label: '李四',
                value: 'lisi'
            },
            {
                label: '王五',
                value: 'wangwu'
            }],
            aOpt: [],

            industryNameIdList: []
        }
    },
    mounted() {
        this.getInit()
    },
    methods: {
        // 表头筛选改变触发
        headerChange(val, data) {
            for (const key in val) {
                this.reqParams[key] = val[key]
            }
            this.selectedFilters = data
            this.reqParams.startIndex = 1
        },
        // 下拉筛选改变触发
        dropDownFilterChange(params, assembleData) {
            this.selectedFilters = this.selectedFilters.filter(item => item.title !== assembleData.title);
            if (assembleData.value) {
                this.selectedFilters.push(assembleData);
            }
            // 自己的逻辑
            this.reqParams.startIndex = 1
        },
        handleClick() {
            console.log(this.reqParams);
        },
        getInit() {
            getDictList('info_source').then(res => {
                if (res.code == 0) {
                    this.aOpt = res.data
                }
            })
            // 查询国标行业
            findIndustryList().then(res => {
                this.industryNameIdList = res.data
            })
        }
    }
}
</script>

<style>
.headBox {
    padding-bottom: 20px;
}
</style>
❌
❌