普通视图

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

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>

async/await 的优雅外衣下:Generator 的核心原理与 JavaScript 执行引擎的精细管理

作者 karrigan
2025年8月18日 17:17

async/await 的优雅外衣下:Generator 的核心原理与 JavaScript 引擎的精细管理

在现代 JavaScript 的异步编程中,async/await 几乎成了主流。开发者们喜欢用它来编写逻辑清晰、易于维护的异步代码。然而,很少有人深入探究 async/await 背后强大的技术支撑——Generator(生成器)机制,以及 JavaScript 引擎在编译和运行阶段是如何巧妙管理这些复杂流程的。本文将系统性地揭开这层神秘面纱,带你从语法、原理一直深入到引擎内部的运作机制。


1. async/await:让异步世界感觉像同步

async/await 是 ES2017 引入的语法糖,它为基于 Promise 的异步操作带来了同步代码般的编写体验。典型的写法如下:

async function getData() {
  const user = await fetchUser();
  const posts = await fetchPosts(user.id);
  return { user, posts };
}

await 关键字遇到 Promise 时会暂停当前函数的执行,等待这个 Promise 完成(resolved 或 rejected),然后再继续向下执行。开发者可以用近乎同步的顺序来表达异步逻辑,不再需要繁琐的 .then()/.catch() 链或者嵌套的回调函数。


2. async/await 的底层基石:Generator 的自动调度

2.1 async 函数与 Promise 的本质

每个 async 函数本质上都会返回一个 Promise。函数内部任何未被捕获的异常都会导致这个返回的 Promise 变为 rejected 状态。因此,async/await 本质上是一种语法上的便捷包装:

// async/await 写法
async function foo() {
  const res = await bar();
  return res;
}

// 转换后的等效 Promise 写法
function foo() {
  return bar().then(res => res);
}

2.2 Generator:支撑 async/await 的核心机制

Generator(生成器)是 ES6 引入的一种特殊函数类型,它可以暂停执行,之后又能从暂停的地方恢复。使用 function* 声明,yield 关键字用于“暂停”函数的执行,并保留函数当前的执行状态(包括局部变量、上下文等)。

function* sequence() {
  yield 1;
  yield 2;
  return 3;
}

const it = sequence();
it.next(); // { value: 1, done: false }
it.next(); // { value: 2, done: false }
it.next(); // { value: 3, done: true }

每次在 yield 处暂停时,所有状态都被完整保存。当通过 .next() 方法恢复时,函数会从上次暂停的位置继续执行。这种“暂停与恢复”的能力,正是 async/await 实现顺序化异步操作的技术基础。

2.3 Generator 自动化控制流程

在 async/await 成为标准之前,社区库(如 co.js)就利用 Generator 实现了自动化的异步流程控制:

function* asyncFlow() {
  const user = yield fetchUser(); // 暂停,等待 fetchUser 结果
  const posts = yield fetchPosts(user.id); // 暂停,等待 fetchPosts 结果
  return { user, posts };
}

// 自动执行 Generator 的函数
function run(gen) {
  const iterator = gen();
  function step(prev) {
    const { value, done } = iterator.next(prev); // 恢复执行,传入上一个结果
    if (done) return Promise.resolve(value); // 如果结束,返回最终值
    return Promise.resolve(value).then(step); // 等待 Promise 完成,然后继续下一步
  }
  return step(); // 开始执行
}

// 使用
run(asyncFlow).then(result => console.log(result));

async/await 在底层本质上就是引擎自动帮你实现了类似 run 函数的功能,将 Generator 和 Promise 完美结合,只是语法上更加简洁直观。

2.4 Babel / 引擎的编译转换

现代的 JavaScript 引擎(或 Babel 这样的转译器)在内部会将 async/await 代码编译转换。转换的目标通常是类似上面 run 函数的逻辑(基于 Generator)或者是纯粹的 Promise 链。关键点在于:

  • 每当遇到 await,引擎会在运行时暂停函数的执行(相当于 Generator 的 yield),等待后面的 Promise 完成。
  • 编译阶段会生成管理函数执行状态(比如当前执行到哪里了)的代码,并确保函数恢复执行时,局部变量和作用域都能正确还原。

3. Generator 的本质:状态机与作用域快照

Generator 的技术核心是一个自带状态的迭代器

  • 每个 yield 语句对应函数执行中的一个特定状态点。
  • 当执行到 yield 暂停时,函数当前的所有局部变量、执行上下文状态都会被完整保存下来
  • 通过调用 .next()(传入值恢复)或 .throw()(抛出异常恢复),可以从暂停点恢复执行,并可以传入新的值或异常。

伪代码模拟底层状态机:

function* taskFlow() {
  const a = yield step1(); // 状态 0: 开始执行,调用 step1
  const b = yield step2(a); // 状态 1: 接收到 step1 结果 a,调用 step2(a)
  return b; // 状态 2: 接收到 step2 结果 b,结束
}

// 编译后可能类似于 (概念性伪代码):
function compiledTaskFlow() {
  let state = 0;
  let a, b;
  return {
    next: function (value) {
      switch (state) {
        case 0:
          state = 1;
          return { value: step1(), done: false }; // 启动 step1
        case 1:
          a = value; // 接收 step1 的结果
          state = 2;
          return { value: step2(a), done: false }; // 启动 step2(a)
        case 2:
          b = value; // 接收 step2 的结果
          state = -1;
          return { value: b, done: true }; // 结束
        default:
          return { value: undefined, done: true };
      }
    }
  };
}

Generator 的强大之处在于它高效地保存和恢复了函数执行环境的“快照”,特别是在处理并发异步逻辑时,为复杂的控制流提供了坚实基础。


4. JavaScript 引擎的编译与运行管理

4.1 编译期(准备阶段)

  • 分析代码: 引擎识别出 async/awaitfunction*/yield 语法。
  • 代码转换: 将这些语法结构转换为底层可执行的代码,通常是基于状态机的实现(如上文的伪代码概念)或 Promise 链。
  • 生成管理代码: 为每个暂停点(await/yield)生成管理执行状态(当前进行到哪一步)、保存/恢复局部变量和作用域链的代码。
  • 处理异常与外部控制: 设置好处理异常传播的路径以及外部控制(如 .next(), .throw())的接入点。

4.2 运行期(执行阶段)

  • 暂停与恢复: 当执行到 awaityield 时,引擎会挂起当前函数的整个执行上下文(包括变量、作用域链等)
  • 事件循环集成: 引擎将等待的 Promise 纳入事件循环的微任务队列管理。当 Promise 完成(resolved/rejected)时,对应的恢复操作(继续执行 Generator 或 async 函数)会被安排到微任务队列中。
  • 状态恢复: 引擎从微任务队列取出恢复任务,利用编译期生成的管理代码,精准地还原之前保存的执行上下文和状态,并从暂停点继续执行。
  • 异常处理: 如果等待的 Promise 被拒绝(rejected),引擎会将异常注入到暂停点,使其能被 async 函数内部的 try/catch 或 Generator 的 .catch / try/catch 捕获。
  • 性能与体验: 这套机制实现了“用同步语法写异步代码”的效果(非阻塞),在保证开发者良好体验的同时,也尽可能提升了性能。

5. 总结

Generator 是 JavaScript 异步编程能力实现飞跃的关键技术内核。 async/await 作为其上层封装,提供了一层优雅易用的语法糖衣。其底层核心依赖于 Generator 的暂停/恢复机制和 Promise 的异步状态管理。

在这个过程中,JavaScript 引擎扮演着至关重要的角色:在编译期,它进行复杂的代码分析和转换,生成状态管理逻辑;在运行期,它通过事件循环和微任务队列,精确地调度函数的暂停与恢复,并确保执行环境(作用域、变量)的正确保存与还原。这套精巧的协作机制,不仅让开发者能够编写出清晰、易维护的异步代码,也为构建高性能的现代 Web 应用提供了强大的底层支撑。


关键点回顾:

  • 理解 Generator 的工作原理(暂停、恢复、状态保存)是深入掌握 JavaScript 高级异步编程本质的关键。
  • async/await 的简洁性 得益于 JavaScript 引擎在幕后高效地实现了状态机管理和执行环境的保存/恢复。
  • 了解引擎在编译期和运行期如何协作管理异步流程,有助于开发者编写出性能更好、结构更优的复杂异步代码。

希望这篇解析能帮你真正看透 JavaScript 异步编程背后的“魔法”,从优雅的语法表面,深入到 Generator 的核心原理,再到引擎的精密运作机制,全方位提升你的技术洞察力!

node版本切换

2025年8月18日 17:03

目前,Node.js 版本管理工具主要有 Voltanvmn 以及一些可视化工具(如 nvm-desktop)。以下是它们的对比和推荐:

1. Volta(推荐)

特点

  • 自动切换:进入项目目录时自动检测并切换到正确的 Node.js 版本,无需手动操作。
  • 跨平台:支持 Windows、macOS 和 Linux。
  • 管理全局工具:可同时管理 npm、yarn、pnpm 的版本。
  • Rust 编写,性能高:比 nvm 更快、更稳定。

安装

curl https://get.volta.sh | bash  # Unix/macOS
winget install Volta.Volta       # Windows

使用

volta pin node@18  # 固定项目 Node 版本
volta install node@20  # 全局安装

2. nvm(传统选择)

特点

  • 成熟稳定:社区广泛使用,支持多版本管理。
  • 仅限 Unix 系统:Windows 需使用 nvm-windows(非官方)。
  • 需手动切换:需运行 nvm use <version> 切换版本。

安装

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.5/install.sh | bash

使用

nvm install 18
nvm use 18

3. n(轻量级)

特点

  • 简单易用:适合个人开发,仅需几个命令。
  • 仅支持 Unix/macOS:Windows 不友好。

安装

curl -L https://raw.githubusercontent.com/mklement0/n-install/stable/bin/n-install | bash

使用

n install 20
n 20  # 切换版本

4. nvm-desktop(可视化工具)

特点

  • 图形界面:适合不熟悉命令行的用户。
  • 支持 Windows/macOS:可分组管理项目版本。

下载

  • 官网或第三方镜像(如 Quark 网盘)。

总结推荐

  • 个人/团队开发首选 Volta:自动化、跨平台、高性能。
  • 习惯传统工具选 nvm:稳定但需手动切换。
  • 简单轻量选 n:适合 Unix/macOS 用户。
  • 图形界面需求选 nvm-desktop:适合新手。

如果需要更详细的对比或安装指南,可以参考各工具的官方文档或相关教程。

「Flink」业务搭建方法总结

作者 淡酒交魂
2025年8月18日 16:56

1.  合理设置并行度和TaskManager 的任务槽数

1.1 核心概念:

1.  并行度:指 Flink 作业中特定算子(Operator)或整个作业的执行并行实例(即子任务)的数量。例如,map 算子的并行度为 5,意味着这个 map 操作会被拆分成 5 个完全相同的任务,同时在集群的不同地方处理数据流的不同分区。

2.  JobManager: Flink 集群的管理节点,负责调度作业、协调检查点、故障恢复等

3.  TaskManager: Flink 的工作节点(Worker Node)。每个 TaskManager 是一个独立的 JVM 进程,负责执行实际的任务(即算子子任务),Slot 是 TM 上执行任务的基本资源单元。

4.  任务槽(slot): Flink 集群(如 TaskManager)中的基本资源单元。每个 TaskManager 是一个 JVM 进程,它可以提供一定数量的任务槽。一个任务槽可以执行一个算子并行度实例(即一个子任务)。JM 管理 Slot 的分配,TM 提供 Slot 的实际执行环境

1.2 并行度与资源的关系

1.  并行度决定所需任务槽总数:

● 作业中所有算子并行度实例的总和(即整个作业图的所有子任务)必须小于或等于集群中可用任务槽的总数。

● 总子任务数 = 所有算子并行度实例之和 <= 总可用任务槽数 = TaskManager 数量 * 每个 TaskManager 的任务槽数

● 开启 Slot Sharing 时总槽数 = 所有算子中最大并行度值 (或关键路径所需槽数)(Flink 作业运行时,所有算子子任务会被分配到槽位。通常以作业图中 最宽算子 的并行度作为总槽数需求,因为 Flink 会尝试 Slot Sharing 将多个算子子任务链化到同一个槽位)

● 示例:

○ 作业含 Source(并行度=4) → Map(并行度=8) → Sink(并行度=2)

○ 实际所需槽数 = max(4,8,2) = 8(开启 Slot Sharing 时)

● 更高的并行度需要更多的任务槽

2.  任务槽需要资源(CPU 和内存):

● 任务槽需要运行在一个 TaskManager JVM 进程中

● 每个 TaskManager 配置了CPU 核心数和内存

● 任务槽的资源占用: 每个任务槽会占用其所在 TaskManager 的一部分 CPU 和内存资源。

● 结论:更多的任务槽意味着每个任务槽分得的 CPU 时间片和内存(尤其是用户代码内存)更少

3.  关系总结:

并行度↑ → 所需任务槽总数↑ → 所需 TaskManager 数量↑ 或 每个 TaskManager 的任务槽数↑

每个 TaskManager 的任务槽数↑ → 每个任务槽可用的 CPU 资源↓ (竞争加剧)

每个 TaskManager 的任务槽数↑ → 每个任务槽可用的用户堆内存↓

每个 TaskManager 的任务槽数↑ → 每个任务槽可用的用户堆外内存↓

每个 TaskManager 的任务槽数↑ → 共享内存区域(网络、托管)压力↑

1.3 常见问题与陷阱:

● 并行度过高,任务槽不足: 作业无法启动(NoResourceAvailableException)。

● 每个 TaskManager 任务槽数过多:

○ CPU 不足: 线程竞争激烈,CPU 利用率达到 100%,但吞吐量不增反降,延迟增大。

○ 内存不足: 每个槽分到的内存太少,导致用户代码频繁 GC 或 OutOfMemoryError (Java Heap Space)。

○ 网络内存不足: 导致反压加剧,吞吐量下降。

● 每个 TaskManager 任务槽数过少: 资源利用率低(CPU、内存闲置),成本高

2.  数据输入源

2.1 数据输入源设置uid

dataSource输入数据源默认都要设置uid,方便后续Checkpoint启动系统可以使用同一个uid,避免发送因输入源uid由系统随机产生,而后续更新无法使用Checkpoint启动,导致数据紊乱

2.2 uid和checkpoint的关系

● 状态恢复的桥梁: 当 Flink 从 Checkpoint/Savepoint 恢复作业时,它需要知道如何将 Checkpoint 里保存的状态数据“分配”给新运行的作业拓扑中的哪个算子实例。uid 就是这个分配的匹配依据。

● 匹配过程:

○ 恢复作业时,Flink 会读取 Checkpoint/Savepoint 的元数据,其中记录了每个状态片段对应的算子 uid。

○ 启动新的作业实例(可能是修改后的代码版本)。

○ Flink 将新作业拓扑中具有相同 uid 的算子与Checkpoint 中保存的对应 uid 的状态进行匹配。

○ 匹配成功,则该算子的状态从 Checkpoint 中恢复。

○ 匹配失败(找不到对应 uid 的算子),则根据配置(allowNonRestoredState)决定是失败还是忽略该部分状态继续启动

2.3 数据源处理和流转

多数据源处理时,建议所有数据源优先根据各自数据源的业务逻辑(如:联表查询字段)查出业务所需数据,最后各数据源整合为统一数据格式,进行最终的业务合并计算和处理

3.  流水线

3.1 系统时间(TumblingProcessingTimeWindows):

以Flink系统接收到这批数据的时间为准,通常与业务系统产生这批数据的实际时间有一定的时间差

3.2 事件时间(TumblingEventTimeWindows):

取业务数据中某个时间字段值作为流水线标准,相对于系统时间会更为精准计算业务数据

3.3 流水线设置

将所有数据源整合为统一数据格式后,可以以数据格式中的时间字段设为统一流水线,确保所有数据源合并(union)后使用统一流水线进行输出

WatermarkStrategy<InputModel> watermarkStrategy = WatermarkStrategy
                //表示允许的最大乱序时间为 5 秒
                .<InputModel>forBoundedOutOfOrderness(Duration.ofSeconds(5))
                // 取InputModel中time字段数据作为流水线
                .withTimestampAssigner((o, t) -> {
                    return o.getTime();
                })
                //表示如果某分区5秒没有数据,则标记为空闲
                .withIdleness(Duration.ofSeconds(5));
3.4 流水线使用
dataSource
    .union(otherDataSource1, otherDataSource2, otherDataSource3)
     // 设置流水线
    .assignTimestampsAndWatermarks(watermarkStrategy)
    // 根据Key进行分区
    .keyBy(InputModel::getKey)
    // 设置流水线窗口大小  5秒为一个窗口
    .window(TumblingEventTimeWindows.of(Time.seconds(5)))
    // 业务数据计算处理  Integer为Key的数据类型
    .process(new ProcessWindowFunction<InputModel, OutputModel, Integer, TimeWindow>())

4.  数据业务处理

主要数据通过collect输出, 次要数据通过sideOut侧输出流输出

// 定义侧输出流
OutputTag<OutputModel> outputTag = new OutputTag<OutputModel>("public") {};
 
SingleOutputStreamOperator<OutputModel> process = dataSource
    .union(otherDataSource1, otherDataSource2, otherDataSource3)
     // 设置流水线
    .assignTimestampsAndWatermarks(watermarkStrategy)
    // 根据Key进行分区
    .keyBy(InputModel::getKey)
    // 设置流水线窗口大小  5秒为一个窗口
    .window(TumblingEventTimeWindows.of(Time.seconds(5)))
    // 业务数据计算处理  Integer为Key的数据类型
    .process(.process(new ProcessWindowFunction<InputModel, OutputModel, Integer, TimeWindow>() {
    @Override
    public void process(Integer key, ProcessWindowFunction<InputModel, OutputModel, Integer, TimeWindow>.Context context, Iterable<InputModel> iterable, Collector<OutputModel> collector) throws Exception {
        // 获取窗口结束时间点
        long end = context.window().getEnd();
        LocalDateTime windowEndTime = Instant.ofEpochMilli(end).atZone(ZoneOffset.ofHours(8)).toLocalDateTime();
    
        // 业务逻辑计算
        
        // 侧输出流统计
        contect.output(outputTag, new OutPutModel(xxx, xxx, xxx, xxx));
        
        // 主要统计数据
        collector.collect(new OutPutModel(xxx,xxx,xxx, xxx));
    }
});

5.  数据输出

5.1 数据Sink输出

使用JDBC连接池进行连接提交至数据库,建议继承AbstractSink<OutPutModel>类,进行连接池资源共用,减少资源浪费

示例:

OutputSink

public class OutputSink extends AbstractSink<OutputModel> {
    //SQL
    private static final String OUTPUT_SQL = "insert into table_name(id,price,window_end_time,create_time) values(?,?,?,?)";
 
    @Override
    public void invoke(GMVResultOutput value, SinkFunction.Context context) throws Exception {
        //  获取写入数据库连接资源
        Connection imsConn = connManager.getImsConnection();
        PreparedStatement outputStmt = null;
 
        try {
            outputStmt = imsConn.prepareStatement(OUTPUT_SQL);
            outputStmt.setLong(1, IdUtil.nextId());
            outputStmt.setBigDecimal(2, value.getPrice());
            outputStmt.setTimestamp(3, java.sql.Timestamp.valueOf(value.getWindowEndTime()));
            outputStmt.setTimestamp(4, java.sql.Timestamp.valueOf(LocalDateTime.now()));
            int i = outputStmt.executeUpdate();
 
            System.out.println("写入数据成功:" + i + "条");
 
        } catch (Exception e) {
            e.printStackTrace();
 
        } finally {
            closeResources(outputStmt);
            if (imsConn != null) {
                imsConn.close();
            }
        }
    }
}

AbstractSink<I>

public abstract class AbstractSink<I> extends RichSinkFunction<I> {
 
    protected transient JdbcConnectionManager connManager;
 
    @Override
    public void open(Configuration parameters) throws Exception {
        connManager = new JdbcConnectionManager();
        connManager.open(); // 初始化连接
    }
 
    @Override
    public void close() throws Exception {
        connManager.close(); // 关闭连接
    }
 
    // 辅助方法:关闭资源
    protected void closeResources(AutoCloseable... resources) {
        for (AutoCloseable res : resources) {
            if (res != null) {
                try { res.close(); } catch (Exception e) { /* Ignore */ }
            }
        }
    }
}
5.2 连接池

数据库连接使用连接池进行系统统一管理

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
 
import java.io.Serializable;
import java.sql.Connection;
import java.sql.SQLException;
 
public class JdbcConnectionManager implements Serializable {
    private transient HikariDataSource omsDataSource;
    private transient HikariDataSource imsDataSource;
 
    public void open() throws SQLException {
        // MySQL连接池配置(OMS)
        HikariConfig omsConfig = new HikariConfig();
        omsConfig.setJdbcUrl(OmsConstant.JDBC_URL);
        omsConfig.setUsername(OmsConstant.MYSQL_USER_NAME);
        omsConfig.setPassword(OmsConstant.MYSQL_PASSWORD);
        omsConfig.setMaximumPoolSize(20);       // 最大连接数(按需调整)
        omsConfig.setMinimumIdle(5);            // 最小空闲连接
        omsConfig.setConnectionTimeout(2000);    // 连接超时2秒
        omsConfig.setIdleTimeout(30000);        // 空闲超时30秒
        omsDataSource = new HikariDataSource(omsConfig);
 
        // MySQL连接池配置(IMS)
        HikariConfig imsConfig = new HikariConfig();
        imsConfig.setJdbcUrl(ImsConstant.IMS_JDBC_URL);
        imsConfig.setUsername(ImsConstant.IMS_USER_NAME);
        imsConfig.setPassword(ImsConstant.IMS_PASSWORD);
        imsConfig.setMaximumPoolSize(20);       // 最大连接数(按需调整)
        imsConfig.setMinimumIdle(5);            // 最小空闲连接
        imsConfig.setConnectionTimeout(2000);    // 连接超时2秒
        imsConfig.setIdleTimeout(30000);        // 空闲超时30秒
        imsDataSource = new HikariDataSource(imsConfig);
    }
 
    // 关闭连接池
    public void close() {
        if (omsDataSource != null) {
            omsDataSource.close();
        }
        if  (imsDataSource != null) {
            imsDataSource.close();
        }
    }
 
    // 从连接池获取连接(非物理关闭)
    public Connection getOmsConnection() throws SQLException {
        return omsDataSource.getConnection();
    }
 
    public Connection getImsConnection() throws SQLException {
        return imsDataSource.getConnection();
    }
}
5.3 数据输出源设置uid

业务数据处理好之后,使用Sink进行输出,输出时与输入源一样,需设置uid,以确保CheckPoint启动时可以正常启动

//  初始化outputSink
OutputSink outputSink = new OutputSink();
 
 
// 明细
process.getSideOutput(outputTag)
    .addSink(outputSink).name("side output").setParallelism(1).uid("side output Sink");
// 总和
process
    .addSink(outputSink).name("total output").setParallelism(1).uid("total output Sink");

6. 总结

以上的一些方法是近期基于业务的开发中遇到的一些坑点后,总结出来的一套相对比较完善的业务开发方法,便于后续Flink实时计算业务数据使用。如果大家有更好的建议和方法,也欢迎共同讨论学习!

Vue2实践(3)之用component做一个动态表单(二)

作者 wycode
2025年8月18日 16:56

前言

在上一篇中,我们已经通过<component>实现了组件的动态渲染,为这个动态表单功能定下框架。

在这篇中,我们将着重实现功能。

功能实现

在上一篇中,我们定下了由设置组件来制作具体组件的方案,我们先来完善这一功能——从设置组件中获取完整的具体组件信息。

在这里我选用SelectInputSetting来做例子

<template>
    <div>
        <TextInput :field="labelField" v-model="setting.name" />
        <div class="option" v-for="(item, index) in setting.options" :key="item.key"> 
            <TextInput class="option-content" :field="item" v-model="setting.options[index].value" />
            <button @click="deleteOption(index)">删除</button>
        </div>
        <button @click="addOption">添加选项</button>
    </div>
</template>
<script>
import TextInput from './TextInput.vue';
export default {
    components: {
        TextInput
    },
    data: () => ({
        labelField: {
            name: '选项名称',
            placeholder: '请输入选项名称',
            value: '', // 通过之前的源码文档,我们得知初始的object其中的属性是响应式的
        },
        setting: {
            id: '',
            editor: 'SelectInputSetting',
            type: 'SelectInput',
            name: '',
            value: '',
            options:[],
            optionCount: 0, // 内部自增标识
        },
    }),
    methods: {
        addOption() {
            this.setting.options.push({
                name: '选项内容',
                placeholder: '请输入选项名称',
                value: '',
                key: this.setting.optionCount++
            })
        },
        deleteOption(index) {            
            this.setting.options.splice(index, 1); // 通过之前的源码文档,我们得知vue通过劫持数组原型方法实现数组响应式,splice就是其中之一
        },
    }
}
</script>

设置组件与预览表单的数据交互

分析

目前在设置组件SelectInputSetting中,通过setting收集用户输入,已经能够得到一份“下拉框组件定义”数据;

接下来,只要把这份数据传递到“表单预览”中,即可。所以我们需要实现它们之间的数据交互,通常来说有许多方案,但是考虑到用户操作性,数据交互可以通过:点击、拖拽等交互实现。在这里我们选用“拖拽”交互。

实现拖拽交互

实现拖拽交互,需要使用浏览器提供的一些API。

SelectInputSetting.vue
<template>
    <div>
        <TextInput draggable="true" :field="labelField" v-model="setting.name" @dragstart="dragstart" />
        <div class="option" v-for="(item, index) in setting.options" :key="item.key"> 
            <TextInput class="option-content" :field="item" v-model="setting.options[index].value" />
            <button @click="deleteOption(index)">删除</button>
        </div>
        <button @click="addOption">添加选项</button>
    </div>
</template>
<script>
import TextInput from './TextInput.vue';
export default {
    components: {
        TextInput
    },
    data: () => ({
        labelField: {
            name: '选项名称',
            placeholder: '请输入选项名称',
            value: '',
        },
        setting: {
            id: '',
            editor: 'SelectInputSetting',
            type: 'SelectInput',
            name: '',
            value: '',
            options:[],
            optionCount: 0,
        },
    }),
    methods: {
        addOption() {
            this.setting.options.push({
                name: '选项内容',
                placeholder: '请输入选项名称',
                value: '',
                key: this.setting.optionCount++
            })
        },
        deleteOption(index) {            
            this.setting.options.splice(index, 1);
        },
        // 开始拖动事件
        dragstart(e) {
            const dataStr = JSON.stringify(this.setting);
            e.dataTransfer.setData('application/json', dataStr);
        }
    }
}
</script>

draggable标识: 应用于HTML元素,用于标识元素是否允许使用浏览器原生行为或HTML 拖放操作 API拖动。true时元素可以被拖动。

dragstart事件: dragstart 事件在用户开始拖动元素或被选择的文本时调用。

通过HTML的拖放API,我们将数据传递通过event进行传递。

DynamicForm.vue
<template>
    <div class="container">
        <div class="main-area" @drop="addComponent" @dragover.prevent>
            <!-- 表单预览域 -->
            <div class="form-title">
                <TextInput :field="titleField" />
            </div>
            <div class="form-content" v-for="(item) in fields" :key="item.id">
                <component class="form-component" :is="item.type" :field="item" />
            </div>
        </div>
        <div class="sidebar">
            <!-- 表单组件域 -->
            <SelectInput v-model="componentValue" :field="createField" />
            <div>
                <component class="form-component" :is="componentValue" />
            </div>
        </div>
    </div>
</template>

<script>
import TextInput from './FieldTypes/TextInput.vue';
import TextInputSetting from './FieldTypes/TextInputSetting.vue';
import SelectInput from './FieldTypes/SelectInput.vue';
import SelectInputSetting from './FieldTypes/SelectInputSetting.vue';
export default {
    components: {
        TextInput,
        TextInputSetting,
        SelectInput,
        SelectInputSetting
    },
    data: () => ({
        titleField: {
            name: '表单名称',
            placeholder: '请输入表单名称',
            value: ''
        },
        componentValue: '',
        createField: {
            name: '选择要创建的组件',
            placeholder: '',
            value: '',
            options: [
                { 'value': 'TextInputSetting', 'name': '文本框' },
                { 'value': 'SelectInputSetting', 'name': '下拉单选框' },
            ]
        },
        fields: [],
    }),
    methods: {
        addComponent(e) {
            e.preventDefault(); // drop事件必须阻止默认行为

            const dataStr = e.dataTransfer.getData('application/json');
            const data = JSON.parse(dataStr);
            data.id = Date.now().toString(); // 添加一个唯一标识用于diff
            this.fields.push(data);
        }
    }
}
</script>

<style lang="scss" scoped>
.container {
    display: flex;
    border: 2px solid #000;
    padding: 10px;
}

.main-area {
    flex-grow: 4;
    margin-right: 10px;
    padding: 0 10px;
    border: 2px solid #000;
    border-radius: 10px;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: flex-start;

    .form-title {
        width: auto;
        text-align: center;
        margin-bottom: 8px;
    }

    .form-content {
        border-radius: 10px;
        padding: 8px;
        width: 90%;
        border: 1px solid #ccc; // 默认边框

        .form-component {
            width: 300px;
        }

        &:hover {
            border: 1px solid #ccc;
            cursor: all-scroll;
        }
    }
}

.sidebar {
    display: flex;
    flex-direction: column;
    border: 2px solid #000;
    border-radius: 10px;
    padding: 10px;
    flex-grow: 1;

    .form-component {
        border: 1px solid #555;
        border-radius: 10px;
        padding: 8px;
        margin-bottom: 10px;
    }

    label {
        margin-bottom: 5px;
    }

    input {
        margin-top: 10px;
        padding: 5px;
        border: 1px solid #000;
        border-radius: 5px;
    }
}
</style>

drop事件: 事件在元素或文本选择被放置到有效的放置目标上时触发。为确保 drop 事件始终按预期触发,应当在处理 dragover 事件的代码部分始终包含 preventDefault() 调用。

dragover.prevent: 只有在 dragenterdragover 事件中调用了 event.preventDefault(),浏览器才会允许元素的拖放操作。

通过HTML的拖放API,我们将数据传递通过event进行接接收。

功能完善

  • SelectInputSetting中,选项删除按钮在当前选项hover时才出现
<style lang="scss" scoped>
    .option {
        button {
            visibility: hidden;
        }
        &:hover {
            button {
                visibility: visible;
            }
        }
    }
</style>

使用visibility属性的修改只会触发重绘,使用display实现的话会触发重排。这个跟使用v-show还是v-if的问题相似;

使用css元素实现而不是vue指令,在于css更好控制:hover

  • 组件设置完成后应该有保存选项来进行锁定,避免误操作
<!-- TextInput -->
<template>
  <div class="text-input-container">
    <label :for="field.name" class="text-input-label">{{ field.name }}:</label>
    <input class="text-input" type="text" :placeholder="field.placeholder" :value="value"
      @input="$emit('input', $event.target.value)" :required="field.required" :readonly="disabled" />
  </div>
</template>

<script>
export default {
  props: ['field', 'disabled', 'value'],
}
</script>
<!-- SelectInputSetting -->
<template>
    <div :class="{'drag':isFinished}" :draggable="isFinished" @dragstart="dragstart">
        <TextInput :disabled="isFinished" :field="labelField" v-model="setting.name" />
        <!-- 这里如果不使用item.key而是使用index,会因为节点复用导致显示错误 -->
        <div class="option" v-for="(item, index) in setting.options" :key="item.key"> 
            <TextInput :disabled="isFinished" class="option-content" :field="item" v-model="setting.options[index].value" />
            <button v-show="!isFinished" @click="deleteOption(index)">删除</button>
        </div>
        <button v-show="!isFinished" @click="addOption">添加选项</button>
        <button v-show="!isFinished" @click="finish">完成</button>
    </div>
</template>
<script>
import TextInput from './TextInput.vue';
export default {
    components: {
        TextInput
    },
    data: () => ({
        labelField: {
            name: '选项名称',
            placeholder: '请输入选项名称',
            value: '',
        },
        
        setting: {
            id: '',
            editor: 'SelectInputSetting',
            type: 'SelectInput',
            name: '',
            value: '',
            options:[],
            optionCount: 0
        },
        isFinished: false,
    }),
    methods: {
        addOption() {
            this.setting.options.push({
                name: '选项内容',
                placeholder: '请输入选项名称',
                value: '',
                key: this.setting.optionCount++
            })
        },
        deleteOption(index) {            
            this.setting.options.splice(index, 1);
        },
        dragstart(e) {
            const dataStr = JSON.stringify(this.setting);
            e.dataTransfer.setData('application/json', dataStr);
        },
        finish() {
            this.isFinished = true;
        },
    }
}
</script>
<style lang="scss" scoped>
.drag {
    &:hover {
        cursor: all-scroll; // 修改鼠标样式,更符合移动组件的暗示
    }
}
</style>

小结

至此我们已经完成了一个相对简单的动态表单组件。能从中体会组件的设计思想、代码组织,并且了解到一些具体实现需要调用的API。

接下来我们还将继续实现类似的有趣实践——导航栏

❌
❌