普通视图

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

我学习到的获取.vsix文件方法

2025年7月13日 23:23

一、官方市场下载方法

  1. URL构造法

    • 访问VS Code Marketplace,搜索目标插件(如Live Server)。

    • 从插件详情页获取以下参数:

      • 发布者ID‌:如ritwickdey(Live Server的发布者)
      • 插件名‌:如LiveServer
      • 版本号‌:在详情页的"Version History"中查看
    • 拼接下载链接模板:

      https://marketplace.visualstudio.com/_apis/public/gallery/publishers/{发布者}/vsextensions/{插件名}/{版本号}/vspackage
      
    • 示例(Live Server):

      https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ritwickdey/vsextensions/LiveServer/5.7.9/vspackage
      
  2. 开发者工具辅助下载

    • 在插件详情页按F12打开开发者工具,在控制台执行以下代码自动生成下载链接:

      const identifier = document.querySelector('.ux-item-name').textContent.split('.');
      const version = document.querySelector('[aria-labelledby="version"]').textContent;
      console.log(`https://marketplace.visualstudio.com/_apis/public/gallery/publishers/${identifier}/vsextensions/${identifier‌:ml-citation{ref="4" data="citationList"}}/${version}/vspackage`);
      

二、第三方资源下载

  1. Open VSX Registry

    • 访问open-vsx.org,搜索插件后直接下载.vsix文件。
    • 适用于部分开源插件(如Live Server可通过此平台获取)‌67。
  2. GitHub Releases

    • 部分插件(如Live Server)的GitHub仓库会发布.vsix文件:

  3. VSIXHub等存档站点

三、特定插件(Live Server)的获取步骤

  1. 参数确认

    • 发布者ID:ritwickdey
    • 插件名:LiveServer
    • 最新版本:通过Marketplace或GitHub查看‌810。
  2. 下载方式选择

    来源 操作步骤
    官方Marketplace 构造URL或使用开发者工具提取链接
    Open VSX 直接搜索下载
    GitHub 从Releases页下载.vsix文件

四、离线安装步骤

  1. 通过VSCode安装

    • 打开VSCode,进入扩展视图(Ctrl+Shift+X)。
    • 点击右上角...选择"Install from VSIX",导入下载的.vsix文件‌511。
  2. 手动安装(无GUI环境)

    • 将.vsix文件复制到VSCode的扩展目录:

      • Windows:%USERPROFILE%.vscode\extensions
      • macOS/Linux:~/.vscode/extensions
    • 重启VSCode生效‌1112。

注意事项

  • 版本兼容性‌:确保.vsix文件与VSCode版本匹配。
  • 安全性‌:优先从官方或可信源下载,避免第三方站点的篡改风险。
  • 平台差异‌:部分插件需指定平台参数(如?targetPlatform=win32-x64)‌313。

防抖与节流:如何让频繁触发的函数 “慢下来”?

作者 然我
2025年7月13日 23:15

在前端开发中,有些事件会被 “高频触发”—— 比如输入框打字时每秒触发多次keyup,滚动页面时每秒触发几十次scroll,快速点击按钮时瞬间触发多次click。如果每次触发都执行复杂逻辑(如发送请求、计算布局),会严重拖慢页面,甚至导致卡顿。

防抖(debounce)和节流(throttle)是解决这类问题的两种经典方案。它们通过不同的策略控制函数执行频率,既能保证功能正常,又能大幅提升性能。

防抖(debounce):等 “安静” 下来再执行

(1)核心逻辑:短时间内多次触发,只执行最后一次

防抖的规则很简单:当函数被连续触发时,只有在停止触发后等待指定时间(delay),才会执行一次;如果在等待期间再次触发,就重新计时。 举个例子(delay=500ms):

  • 用户在输入框快速打字,每次按键都会触发事件,但防抖会 “推迟” 执行,直到用户停手 500ms 后,才执行一次搜索请求;
  • 如果用户在 300ms 内又按了下一个键,之前的计时会被取消,重新从 0 开始算 500ms。

生活类比:像是电梯关门 —— 如果有人连续进入,电梯会不断推迟关门时间,直到最后一个人进入后,才会关门运行。

(2)实现代码与关键细节

function debounce(fn, delay) {
  // 用闭包保存定时器ID,确保多次触发时能访问到同一个定时器
  let timer = null;

  // 返回一个新函数,接收触发时的参数
  return function (...args) {
    const that = this; // 保存当前上下文(如DOM元素)

    // 如果已有定时器,先清除(重新计时)
    if (timer) {
      clearTimeout(timer);
    }

    // 重新设置定时器,delay毫秒后执行原函数
    timer = setTimeout(() => {
      // 用call确保原函数的this指向正确(如绑定到触发事件的DOM元素)
      fn.call(that, ...args);
      // 执行后清空定时器(非必需,但逻辑更清晰)
      timer = null;
    }, delay);
  };
}

关键细节:

  • 闭包的应用:通过timer变量在多次触发间共享状态,实现 “清除上一次定时器” 的逻辑;
  • this指向修正:用call(that, ...args)确保原函数内部的this指向正确(比如事件处理函数中,this应指向触发事件的 DOM 元素);
  • 参数传递:用扩展运算符...args接收所有参数,保证原函数能拿到触发时的参数(如输入框的value)。

(3)使用场景与实战示例

适用场景

  • 输入框实时搜索 / 联想:等待用户输入停顿后再发请求,减少接口调用次数;
  • 窗口resize事件:窗口调整完成后再计算元素布局,避免多次重排;
  • 按钮防重复提交:用户快速点击按钮时,只在最后一次点击后执行提交逻辑。

实战代码(输入框)

<input type="text" id="debounceInput" placeholder="防抖示例:输入后停顿500ms执行">

<script>
  // 防抖函数(同上)
  function debounce(fn, delay) { /* ... */ }

  // 模拟搜索请求
  function search(content) {
    console.log(`[防抖] 搜索内容:${content}`);
  }

  // 生成防抖处理后的搜索函数(延迟500ms)
  const debouncedSearch = debounce(search, 500);

  // 绑定输入框事件
  document.getElementById('debounceInput').addEventListener('keyup', function(e) {
    debouncedSearch(e.target.value);
  });
</script>

节流(throttle):固定间隔内必须执行一次

(1)核心逻辑:无论触发多频繁,固定间隔内只执行一次

节流的规则是:函数被触发后,立即执行一次;之后在指定时间(delay)内,无论触发多少次,都不会执行;直到 delay 时间过去,再次触发时才会执行第二次

举个例子(delay=1000ms):

  • 第一次触发时,立即执行函数,同时记录执行时间;
  • 接下来 1 秒内,无论触发多少次,都不执行;
  • 1 秒后再次触发,立即执行,并更新记录时间,以此类推。

与防抖的核心区别

  • 防抖:等待 “完全停止触发” 后才执行,可能长时间不执行;
  • 节流:固定间隔内 “必须执行一次”,保证函数有规律地执行。

实现代码与关键细节

function throttle(fn, delay) {
  let lastTime = 0; // 记录上一次执行的时间(初始为0)
  let timer = null; // 用于延迟执行的定时器

  return function (...args) {
    const that = this;
    const now = Date.now(); // 当前时间戳

    // 如果距离上一次执行不足delay,设置延迟执行
    if (now - lastTime < delay) {
      // 清除之前的定时器,避免重复延迟执行
      if (timer) clearTimeout(timer);
      timer = setTimeout(() => {
        lastTime = Date.now(); // 更新执行时间
        fn.call(that, ...args);
        timer = null;
      }, delay - (now - lastTime)); // 计算剩余时间
    } else {
      // 距离上一次执行超过delay,立即执行
      lastTime = now;
      fn.call(that, ...args);
    }
  };
}

关键细节:

  • 时间戳判断:通过now - lastTime计算与上次执行的间隔,决定是否立即执行;
  • 延迟执行兜底:当触发间隔小于 delay 时,用定时器保证 “在 delay 后必须执行一次”(避免因持续高频触发导致函数一直不执行);
  • 闭包保存状态:lastTimetimer在多次触发间共享,确保间隔计算准确。

(3)使用场景与实战示例

适用场景

  • 滚动事件scroll:计算滚动位置、加载懒加载图片时,每秒执行 1-2 次即可,无需高频触发;

  • 鼠标移动mousemove:拖拽元素时,固定间隔更新位置,避免过度计算;

  • 高频点击按钮:如游戏中的攻击按钮,限制每秒最多触发 5 次,防止操作过快。

实战代码

<input type="text" id="throttleInput" placeholder="节流示例:每1000ms最多执行一次">

<script>
  // 节流函数(同上)
  function throttle(fn, delay) { /* ... */ }

  // 模拟搜索请求
  function search(content) {
    console.log(`[节流] 搜索内容:${content}`);
  }

  // 生成节流处理后的搜索函数(间隔1000ms)
  const throttledSearch = throttle(search, 1000);

  // 绑定输入框事件
  document.getElementById('throttleInput').addEventListener('keyup', function(e) {
    throttledSearch(e.target.value);
  });
</script>

防抖与节流的核心区别与选择指南

特性 防抖(debounce) 节流(throttle)
执行时机 停止触发后等待 delay 执行一次 触发后立即执行,之后固定间隔执行
适用场景 等待 “完成” 后执行(如输入完成) 需要 “定期” 执行(如滚动计算)
极端情况 若一直触发,可能永远不执行 无论是否一直触发,固定间隔必执行

选择原则

  • 若需要 “操作完成后执行一次”(如搜索输入),用防抖;
  • 若需要 “操作过程中有规律地执行”(如滚动加载),用节流。

背后的核心知识点:闭包与高阶函数

防抖和节流的实现都依赖两个关键概念:

  1. 高阶函数debouncethrottle都是高阶函数 —— 它们接收一个函数(fn)作为参数,并返回一个新函数。这使得它们能对原函数进行 “包装”,添加额外的控制逻辑(如定时器)。
  2. 闭包:返回的新函数通过闭包访问timerlastTime等变量,这些变量在多次触发间保持状态,实现 “清除定时器”“计算时间间隔” 等核心逻辑。如果没有闭包,就需要将这些状态暴露为全局变量,导致代码污染和逻辑混乱。

2025前端人一文看懂 Broadcast Channel API 通信指南

作者 鱼樱前端
2025年7月13日 23:07

大家好,我是鱼樱!!!

关注公众号【鱼樱AI实验室】持续分享更多前端和AI辅助前端编码新知识~~

不定时写点笔记写点生活~写点前端经验。

在当前环境下,纯前端开发者可以通过技术深化、横向扩展、切入新兴领域以及产品化思维找到突破口。

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

前端最卷的开发语言一点不为过,三天一小更,五天一大更。。。一年一个框架升级~=嗯,要的就是这样感觉!与时俱进~

Broadcast Channel API 通信指南

基础概念

Broadcast Channel API 是一种同源通信机制,允许同源的不同浏览器上下文(如窗口、标签页、iframe、worker等)之间进行通信。这种机制提供了一种简单高效的方式,让同源的不同页面能够实时交换信息。

基础用法

创建和连接频道

// 创建或连接到名为"example-channel"的广播频道
const channel = new BroadcastChannel('example-channel');

发送消息

// 发送消息到所有监听该频道的接收者
channel.postMessage({
  type: 'UPDATE',
  payload: { message: '这是一条广播消息' },
  timestamp: Date.now()
});

接收消息

// 监听频道上的消息
channel.addEventListener('message', (event) => {
  console.log('收到消息:', event.data);
});

// 或者使用onmessage事件处理器
channel.onmessage = (event) => {
  console.log('收到消息:', event.data);
};

关闭频道

// 当不再需要频道时关闭它
channel.close();

与 window.postMessage 的区别

  1. 作用范围

    • Broadcast Channel API:仅限于同源的浏览上下文之间通信
    • window.postMessage:可以跨域通信
  2. 通信模式

    • Broadcast Channel API:一对多广播模式,发送者不需要知道接收者
    • window.postMessage:一对一通信模式,需要明确指定目标窗口
  3. 使用便捷性

    • Broadcast Channel API:API更简单,不需要引用其他窗口对象
    • window.postMessage:需要获取目标窗口的引用

使用场景

  1. 多标签页应用同步

    • 用户在一个标签页中进行的操作可以自动同步到同一应用的其他标签页
    • 例如:用户在一个标签页中更改设置,其他标签页立即更新
  2. 实时通知系统

    • 在不同标签页之间传递实时通知
    • 例如:在一个标签页收到新消息时,其他标签页显示通知
  3. 共享状态管理

    • 在不同标签页之间共享应用状态
    • 例如:用户认证状态同步,一个标签页登出后其他标签页也随之登出
  4. 协作应用

    • 在多个标签页之间协同工作
    • 例如:多人编辑文档时的实时协作
  5. Service Worker 通信

    • 在页面和 Service Worker 之间进行通信
    • 例如:Service Worker 接收到推送通知后广播给所有活动页面

安全注意事项

  1. 仅限同源通信: Broadcast Channel API 仅允许同源页面之间通信,这是一种内置的安全限制。

  2. 消息验证: 尽管是同源通信,仍然建议对接收到的消息进行验证,确保其格式和内容符合预期。

  3. 敏感数据处理: 避免通过广播频道传输敏感信息,因为同源的任何页面都可以监听该频道。

性能考虑

  1. 消息大小: 虽然理论上没有严格的大小限制,但发送大量数据可能影响性能,应尽量保持消息简洁。

  2. 频道数量: 合理控制频道数量,避免创建过多不必要的频道。

  3. 关闭不用的频道: 当不再需要某个频道时,调用 close() 方法释放资源。

浏览器兼容性

Broadcast Channel API 在现代浏览器中得到良好支持,包括 Chrome、Firefox、Edge 和 Safari。但在 IE 中不支持,需要使用 polyfill 或替代方案。

最佳实践

  1. 消息格式标准化

    {
      type: "ACTION_TYPE",
      payload: {}, // 实际数据
      timestamp: Date.now(),
      source: "tab-identifier" // 可选,标识发送源
    }
    
  2. 错误处理

    try {
      channel.postMessage(message);
    } catch (error) {
      console.error('发送消息失败:', error);
    }
    
  3. 生命周期管理: 在组件卸载或页面关闭前关闭频道:

    // React组件示例
    useEffect(() => {
      const channel = new BroadcastChannel('my-channel');
      
      // 设置监听器
      channel.onmessage = handleMessage;
      
      // 清理函数
      return () => {
        channel.close();
      };
    }, []);
    
  4. 消息去重: 对于某些应用场景,可能需要实现消息去重机制,避免重复处理相同的消息。

Broadcast Channel API 案例

image.png

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

// 购物车数据
const cartItems = ref([
  { id: 1, name: '商品A', price: 99, quantity: 1 },
  { id: 2, name: '商品B', price: 199, quantity: 1 },
])

// 广播频道实例
let broadcastChannel = null

// 当前标签页ID
const tabId = ref(`tab-${Date.now()}-${Math.floor(Math.random() * 1000)}`)

// 消息日志
const messageLog = ref([])

/**
 * 添加商品到购物车
 * @param {Object} product - 要添加的商品
 */
const addToCart = (product) => {
  // 检查商品是否已存在于购物车中
  const existingItem = cartItems.value.find((item) => item.id === product.id)

  if (existingItem) {
    // 如果商品已存在,增加数量
    existingItem.quantity += 1
  } else {
    // 如果商品不存在,添加到购物车
    cartItems.value.push({ ...product, quantity: 1 })
  }

  // 广播购物车更新消息
  broadcastCartUpdate()
}

/**
 * 从购物车中移除商品
 * @param {Number} productId - 要移除的商品ID
 */
const removeFromCart = (productId) => {
  const index = cartItems.value.findIndex((item) => item.id === productId)
  if (index !== -1) {
    cartItems.value.splice(index, 1)

    // 广播购物车更新消息
    broadcastCartUpdate()
  }
}

/**
 * 更新购物车中商品数量
 * @param {Number} productId - 商品ID
 * @param {Number} quantity - 新数量
 */
const updateQuantity = (productId, quantity) => {
  const item = cartItems.value.find((item) => item.id === productId)
  if (item) {
    item.quantity = Math.max(1, quantity) // 确保数量至少为1

    // 广播购物车更新消息
    broadcastCartUpdate()
  }
}

/**
 * 将响应式对象转换为普通对象
 * @param {Object} obj - 响应式对象
 * @returns {Object} - 普通对象
 */
const toRawObject = (obj) => {
  return JSON.parse(JSON.stringify(obj))
}

/**
 * 广播购物车更新消息
 */
const broadcastCartUpdate = () => {
  if (!broadcastChannel) return

  try {
    // 将响应式对象转换为普通对象
    const plainItems = toRawObject(cartItems.value)
    const total = calculateTotal()

    broadcastChannel.postMessage({
      type: 'CART_UPDATE',
      payload: {
        items: plainItems,
        total: total,
      },
      source: tabId.value,
      timestamp: Date.now(),
    })

    // 添加到消息日志
    addToMessageLog('发送', '购物车数据已广播到其他标签页')
  } catch (error) {
    console.error('广播购物车更新失败:', error)
    addToMessageLog('错误', `广播失败: ${error.message}`)
  }
}

/**
 * 处理接收到的消息
 * @param {MessageEvent} event - 消息事件
 */
const handleMessage = (event) => {
  const { type, payload, source, timestamp } = event.data

  // 忽略自己发送的消息
  if (source === tabId.value) return

  if (type === 'CART_UPDATE') {
    // 更新购物车数据
    cartItems.value = payload.items

    // 添加到消息日志
    addToMessageLog('接收', `从标签页 ${source} 接收到购物车更新`)
  }
}

/**
 * 添加消息到日志
 * @param {String} direction - 消息方向(发送/接收)
 * @param {String} content - 消息内容
 */
const addToMessageLog = (direction, content) => {
  messageLog.value.push({
    direction,
    content,
    time: new Date().toLocaleTimeString(),
  })

  // 限制日志条数
  if (messageLog.value.length > 10) {
    messageLog.value.shift()
  }
}

/**
 * 计算购物车总价
 * @returns {Number} 总价
 */
const calculateTotal = () => {
  return cartItems.value.reduce((total, item) => total + item.price * item.quantity, 0)
}

/**
 * 清空购物车
 */
const clearCart = () => {
  cartItems.value = []
  broadcastCartUpdate()
}

/**
 * 打开新标签页
 */
const openNewTab = () => {
  window.open(window.location.href, '_blank')
}

// 组件挂载时初始化广播频道
onMounted(() => {
  try {
    broadcastChannel = new BroadcastChannel('shopping-cart-channel')
    broadcastChannel.onmessage = handleMessage

    // 广播初始状态
    setTimeout(() => {
      broadcastCartUpdate()
    }, 500)

    addToMessageLog('系统', `标签页 ${tabId.value} 已连接到广播频道`)
  } catch (error) {
    console.error('创建广播频道失败:', error)
    addToMessageLog('错误', '创建广播频道失败,可能是浏览器不支持')
  }
})

// 组件卸载时关闭广播频道
onUnmounted(() => {
  if (broadcastChannel) {
    broadcastChannel.close()
    broadcastChannel = null
  }
})
</script>

<template>
  <div class="cart-container">
    <div class="cart-header">
      <h2>Broadcast Channel 购物车示例</h2>
      <p class="tab-id">当前标签页ID: {{ tabId }}</p>
    </div>

    <div class="cart-actions">
      <button @click="openNewTab" class="action-button">打开新标签页</button>
      <button @click="clearCart" class="action-button clear">清空购物车</button>
    </div>

    <div class="cart-content">
      <div class="cart-items">
        <h3>购物车商品</h3>

        <div v-if="cartItems.length === 0" class="empty-cart">购物车为空</div>

        <div v-else class="item-list">
          <div v-for="item in cartItems" :key="item.id" class="cart-item">
            <div class="item-info">
              <span class="item-name">{{ item.name }}</span>
              <span class="item-price">¥{{ item.price }}</span>
            </div>

            <div class="item-actions">
              <button @click="updateQuantity(item.id, item.quantity - 1)" class="quantity-btn">
                -
              </button>
              <span class="quantity">{{ item.quantity }}</span>
              <button @click="updateQuantity(item.id, item.quantity + 1)" class="quantity-btn">
                +
              </button>
              <button @click="removeFromCart(item.id)" class="remove-btn">删除</button>
            </div>
          </div>

          <div class="cart-total">总计: ¥{{ calculateTotal() }}</div>
        </div>
      </div>

      <div class="product-list">
        <h3>可购买商品</h3>

        <div class="products">
          <div class="product-item" @click="addToCart({ id: 3, name: '商品C', price: 299 })">
            <div class="product-name">商品C</div>
            <div class="product-price">¥299</div>
            <button class="add-btn">添加到购物车</button>
          </div>

          <div class="product-item" @click="addToCart({ id: 4, name: '商品D', price: 399 })">
            <div class="product-name">商品D</div>
            <div class="product-price">¥399</div>
            <button class="add-btn">添加到购物车</button>
          </div>

          <div class="product-item" @click="addToCart({ id: 5, name: '商品E', price: 499 })">
            <div class="product-name">商品E</div>
            <div class="product-price">¥499</div>
            <button class="add-btn">添加到购物车</button>
          </div>
        </div>
      </div>
    </div>

    <div class="message-log">
      <h3>通信日志</h3>

      <div class="log-entries">
        <div
          v-for="(log, index) in messageLog"
          :key="index"
          :class="[
            'log-entry',
            log.direction === '发送' ? 'sent' : log.direction === '接收' ? 'received' : 'system',
          ]"
        >
          <span class="log-time">{{ log.time }}</span>
          <span class="log-direction">[{{ log.direction }}]</span>
          <span class="log-content">{{ log.content }}</span>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.cart-container {
  max-width: 900px;
  margin: 0 auto;
  padding: 20px;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  background-color: #fff;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.cart-header {
  margin-bottom: 20px;
  padding-bottom: 10px;
  border-bottom: 1px solid #eee;
}

.cart-header h2 {
  color: #333;
  margin-top: 0;
}

.tab-id {
  font-size: 0.9em;
  color: #666;
  margin-top: 5px;
}

.cart-actions {
  display: flex;
  justify-content: space-between;
  margin-bottom: 20px;
}

.action-button {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  background-color: #4682b4;
  color: white;
  cursor: pointer;
  transition: background-color 0.3s;
}

.action-button:hover {
  background-color: #3a6d99;
}

.action-button.clear {
  background-color: #e74c3c;
}

.action-button.clear:hover {
  background-color: #c0392b;
}

.cart-content {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
  margin-bottom: 20px;
}

.cart-items,
.product-list {
  padding: 15px;
  border: 1px solid #eee;
  border-radius: 4px;
  background-color: #f9f9f9;
}

h3 {
  margin-top: 0;
  color: #333;
  border-bottom: 1px solid #eee;
  padding-bottom: 10px;
}

.empty-cart {
  padding: 20px;
  text-align: center;
  color: #999;
}

.cart-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 10px 0;
  border-bottom: 1px solid #eee;
}

.item-info {
  display: flex;
  flex-direction: column;
}

.item-name {
  font-weight: bold;
}

.item-price {
  color: #e74c3c;
}

.item-actions {
  display: flex;
  align-items: center;
}

.quantity-btn {
  width: 25px;
  height: 25px;
  border: 1px solid #ddd;
  background-color: #f5f5f5;
  border-radius: 3px;
  cursor: pointer;
}

.quantity {
  margin: 0 10px;
  min-width: 20px;
  text-align: center;
}

.remove-btn {
  margin-left: 10px;
  padding: 5px 10px;
  border: none;
  background-color: #e74c3c;
  color: white;
  border-radius: 3px;
  cursor: pointer;
}

.cart-total {
  margin-top: 15px;
  padding-top: 10px;
  border-top: 1px solid #eee;
  text-align: right;
  font-weight: bold;
}

.products {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 15px;
}

.product-item {
  padding: 15px;
  border: 1px solid #eee;
  border-radius: 4px;
  background-color: white;
  cursor: pointer;
  transition: transform 0.2s, box-shadow 0.2s;
}

.product-item:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}

.product-name {
  font-weight: bold;
  margin-bottom: 5px;
}

.product-price {
  color: #e74c3c;
  margin-bottom: 10px;
}

.add-btn {
  width: 100%;
  padding: 5px;
  border: none;
  background-color: #2ecc71;
  color: white;
  border-radius: 3px;
  cursor: pointer;
}

.message-log {
  margin-top: 20px;
  padding: 15px;
  border: 1px solid #eee;
  border-radius: 4px;
  background-color: #f9f9f9;
}

.log-entries {
  max-height: 200px;
  overflow-y: auto;
}

.log-entry {
  padding: 8px;
  margin-bottom: 5px;
  border-radius: 3px;
  font-size: 0.9em;
}

.log-entry.sent {
  background-color: #e8f4fd;
  border-left: 3px solid #3498db;
}

.log-entry.received {
  background-color: #f0fff0;
  border-left: 3px solid #2ecc71;
}

.log-entry.system {
  background-color: #f8f8f8;
  border-left: 3px solid #95a5a6;
}

.log-entry.error {
  background-color: #fff0f0;
  border-left: 3px solid #e74c3c;
}

.log-time {
  color: #666;
  margin-right: 5px;
}

.log-direction {
  font-weight: bold;
  margin-right: 5px;
}

.log-content {
  color: #333;
}
</style>

结尾

看懂上面的解释和说明结合vue3小案例,是不是瞬间完全明白 Broadcast Channel 怎么玩的了;以及需要注意些什么!!!

非空断言完全指南:解锁TypeScript/JavaScript的安全导航黑科技

作者 烛阴
2025年7月13日 22:45

一、空值问题:为什么需要非空断言?

1.1 空值的破坏力

interface User {
  name: string;
  age: number;
  email: string;
}

function getUserName(user: User | null): string {
  return user.name; // 编译错误:对象可能为"null"
}

// 运行时可能崩溃
console.log(getUserName(null).toUpperCase()); 
// TypeError: Cannot read properties of null

1.2 传统解决方案的局限

// 冗长的安全检查
function safeGetUserName(user: User | null): string {
  if (user === null) return 'Guest';
  return user.name;
}

// 可能导致虚假安全的可选链
const length = user?.name?.length || 0; // 无法区分空字符串和undefined

二、语法与使用

2.1 基本语法

interface User {
  name: string;
  address?: {
    street: string;
  };
}

// 属性断言
const userName = user!.name;

// 函数调用断言
const element = document.getElementById('app')!;

2.2 双重断言:处理复杂场景

// 当类型系统无法推断时
const inputValue = (document.getElementById('input')! as HTMLInputElement).value;

2.3 在类中的使用

class ApiClient {
  private token!: string; // 明确告诉TS稍后初始化
  
  initialize(token: string) {
    this.token = token;
  }
  
  fetchData() {
    // 安全使用:我们知道initialize已被调用
    const headers = { Authorization: `Bearer ${this.token}` };
    // ...
  }
}

三、非空断言的陷阱

3.1 虚假的安全感

const users: User[] = [];

// 错误使用:数组可能为空
const firstUserName = users[0]!.name; // 运行时错误!

3.2 破坏类型安全

function getStreet(user: User): string {
  return user.address!.street; // 编译通过但...
}

const user: User = { name: 'Alice' };
getStreet(user); // 运行时TypeError!

3.3 与可选链的冲突

// 危险组合:隐藏真实问题
const street = user?.address!.street; 
// 当user.address为undefined时,尝试访问street会出错

总结

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

2025前端人一文看懂 window.postMessage 通信

作者 鱼樱前端
2025年7月13日 22:43

大家好,我是鱼樱!!!

关注公众号【鱼樱AI实验室】持续分享更多前端和AI辅助前端编码新知识~~

不定时写点笔记写点生活~写点前端经验。

在当前环境下,纯前端开发者可以通过技术深化、横向扩展、切入新兴领域以及产品化思维找到突破口。

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

前端最卷的开发语言一点不为过,三天一小更,五天一大更。。。一年一个框架升级~=嗯,要的就是这样感觉!与时俱进~

window.postMessage 通信指南

基础概念

window.postMessage() 是一种跨源通信的方法,允许来自不同源(域、协议或端口)的页面之间安全地进行通信。这种机制提供了一种受控的方式来规避同源策略的限制。

基础用法

发送消息

targetWindow.postMessage(message, targetOrigin, [transfer]);

参数说明:

  • targetWindow:接收消息的窗口对象,如 iframe 的 contentWindow 属性、window.open 返回的窗口对象或命名的窗口/框架
  • message:要发送的数据,会被结构化克隆算法序列化
  • targetOrigin:指定接收消息的窗口的源,可以是具体的 URL 或 "*"(表示任意源)
  • transfer:(可选)是一组可转移对象,这些对象的所有权将被转移给接收方

接收消息

window.addEventListener("message", receiveMessage, false);

function receiveMessage(event) {
  // 验证发送方的源
  if (event.origin !== "https://trusted-domain.com") return;
  
  // 处理消息
  console.log("收到消息:", event.data);
  
  // 可以回复消息
  event.source.postMessage("收到你的消息", event.origin);
}

event 对象的主要属性:

  • data:从其他窗口发送过来的数据
  • origin:发送消息的窗口的源
  • source:发送消息的窗口对象的引用
  • ports:MessageChannel 的端口对象数组

使用场景

  1. iframe 通信:父页面与嵌入的 iframe 之间的数据交换
  2. 跨域窗口通信:通过 window.open 打开的不同域的窗口间通信
  3. Web Worker 通信:与 Web Worker 或 Service Worker 进行通信
  4. 第三方集成:与嵌入的第三方小部件或应用进行安全通信
  5. 单页应用路由:在复杂的单页应用中,不同路由间的状态同步
  6. 微前端架构:在微前端架构中,不同子应用间的数据交换和状态同步

安全注意事项

  1. 始终验证消息来源

    if (event.origin !== "https://trusted-domain.com") return;
    
  2. 避免使用 "*" 作为 targetOrigin: 尽量指定确切的目标源,而不是使用通配符 "*",以防止信息泄露给恶意网站。

  3. 验证消息内容: 不要假设收到的消息格式是正确的,始终进行验证和类型检查。

    if (typeof event.data !== "object" || !event.data.type) return;
    
  4. 避免执行来自消息的代码: 永远不要直接执行从消息中接收到的代码,如 eval(event.data)。

  5. 限制消息频率: 实现节流或防抖机制,防止消息风暴导致性能问题。

  6. 处理错误: 使用 try-catch 块处理消息处理过程中可能出现的错误。

性能考虑

  1. 消息大小:避免传输大量数据,这可能导致性能问题。
  2. 消息频率:控制消息发送的频率,避免过多的通信开销。
  3. 结构化克隆限制:了解结构化克隆算法的限制,如不能克隆函数、DOM 节点等。

浏览器兼容性

window.postMessage 在所有现代浏览器中都得到良好支持,包括 Chrome、Firefox、Safari、Edge 和 IE11。caniuse查看兼容性

image.png

调试技巧

  1. 使用浏览器开发者工具监控 message 事件
  2. 添加详细的日志记录
  3. 实现消息的确认机制
  4. 使用唯一标识符跟踪消息

最佳实践

  1. 消息格式标准化

    {
      type: "ACTION_TYPE",
      payload: {}, // 实际数据
      id: "unique-id", // 用于跟踪
      timestamp: Date.now()
    }
    
  2. 实现请求-响应模式

    // 发送方
    const messageId = generateUniqueId();
    const responsePromise = createResponsePromise(messageId);
    iframe.contentWindow.postMessage({
      type: "REQUEST_DATA",
      id: messageId
    }, "https://trusted-domain.com");
    
    responsePromise.then(response => {
      console.log("收到响应:", response);
    });
    
    // 接收方
    window.addEventListener("message", event => {
      if (event.origin !== "https://parent-domain.com") return;
      if (event.data.type === "REQUEST_DATA") {
        event.source.postMessage({
          type: "RESPONSE_DATA",
          id: event.data.id,
          payload: { result: "some data" }
        }, event.origin);
      }
    });
    
  3. 错误处理: 在消息协议中包含错误处理机制。

  4. 版本控制: 在消息中包含版本信息,以便处理 API 变更。

案例效果

image.png

vue3-project\public\child.html 下新建一个html

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>PostMessage 子页面</title>
    <style>
      body {
        font-family: Arial, sans-serif;
        padding: 20px;
        background-color: #f0f8ff;
        margin: 0;
      }
      .child-container {
        padding: 15px;
        border: 2px solid #4682b4;
        border-radius: 8px;
      }
      h2 {
        color: #4682b4;
        margin-top: 0;
      }
      .input-group {
        display: flex;
        align-items: center;
        gap: 10px;
        margin-bottom: 15px;
      }
      input {
        padding: 8px;
        border: 1px solid #ccc;
        border-radius: 4px;
        flex-grow: 1;
      }
      button {
        background-color: #4682b4;
        color: white;
        border: none;
        padding: 8px 16px;
        border-radius: 4px;
        cursor: pointer;
        transition: background-color 0.3s;
      }
      button:hover {
        background-color: #3a6d99;
      }
      .message-display {
        background-color: white;
        padding: 10px;
        border-radius: 4px;
        border-left: 4px solid #4682b4;
        margin-top: 15px;
      }
      .message-content {
        font-weight: bold;
        word-break: break-word;
      }
    </style>
  </head>
  <body>
    <div class="child-container">
      <h2>PostMessage 子页面</h2>

      <div class="input-group">
        <label for="message-input">发送消息:</label>
        <input
          id="message-input"
          type="text"
          placeholder="输入要发送的消息"
          value="你好,父页面!"
        />
        <button id="send-button">发送到父页面</button>
      </div>

      <div id="message-display" class="message-display" style="display: none">
        <h3>收到来自父页面的消息:</h3>
        <div id="message-content" class="message-content"></div>
      </div>
    </div>

    <script>
      // 页面元素
      const messageInput = document.getElementById('message-input')
      const sendButton = document.getElementById('send-button')
      const messageDisplay = document.getElementById('message-display')
      const messageContent = document.getElementById('message-content')

      /**
       * 发送消息到父窗口
       */
      function sendMessageToParent() {
        try {
          // 发送消息到父窗口
          window.parent.postMessage(
            {
              type: 'CHILD_MESSAGE',
              payload: messageInput.value,
              timestamp: Date.now(),
            },
            '*'
          ) // 在生产环境中应该指定具体的目标源
        } catch (error) {
          console.error('发送消息失败:', error)
        }
      }

      /**
       * 接收来自父窗口的消息
       */
      function handleMessage(event) {
        // 在实际应用中应该验证消息来源
        // if (event.origin !== 'https://trusted-domain.com') return;

        try {
          // 处理接收到的消息
          if (event.data && event.data.type === 'PARENT_MESSAGE') {
            messageContent.textContent = event.data.payload
            messageDisplay.style.display = 'block'
          }
        } catch (error) {
          console.error('处理消息失败:', error)
        }
      }

      // 添加事件监听器
      sendButton.addEventListener('click', sendMessageToParent)
      window.addEventListener('message', handleMessage)

      // 页面加载完成后通知父窗口
      window.addEventListener('load', function () {
        try {
          window.parent.postMessage(
            {
              type: 'CHILD_LOADED',
              payload: '子页面已加载完成',
              timestamp: Date.now(),
            },
            '*'
          )
        } catch (error) {
          console.error('通知父窗口失败:', error)
        }
      })
    </script>
  </body>
</html>

vue3-project\src\views\PostMessageView.vue

<script setup>
import ParentComponent from '../components/PostMessageDemo/ParentComponent.vue'
</script>

<template>
  <div class="post-message-view">
    <h1>window.postMessage 通信演示</h1>

    <div class="description">
      <p>
        本示例演示了如何使用
        <code>window.postMessage</code> 在父页面和嵌入的iframe之间进行安全通信。
        您可以在下方的输入框中输入消息并发送,然后观察两个页面之间的通信过程。
      </p>
    </div>

    <ParentComponent />

    <div class="documentation-link">
      <p>
        查看
        <a href="#" @click.prevent="downloadDoc">完整文档</a>
        了解更多关于window.postMessage的用法和最佳实践。
      </p>
    </div>
  </div>
</template>

<script>
// 导出组件以允许使用选项API添加方法
export default {
  methods: {
    // 下载文档方法
    downloadDoc() {
      // 在实际应用中,这里可以链接到文档或触发文档下载
      window.open('/docs/postMessage.md', '_blank')
    },
  },
}
</script>

<style scoped>
.post-message-view {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}

h1 {
  color: #42b883;
  text-align: center;
  margin-bottom: 30px;
}

.description {
  background-color: #f8f8f8;
  padding: 15px;
  border-radius: 8px;
  margin-bottom: 30px;
  border-left: 4px solid #42b883;
}

code {
  background-color: #e8e8e8;
  padding: 2px 5px;
  border-radius: 3px;
  font-family: monospace;
}

.documentation-link {
  margin-top: 30px;
  text-align: center;
}

.documentation-link a {
  color: #42b883;
  text-decoration: none;
  font-weight: bold;
}

.documentation-link a:hover {
  text-decoration: underline;
}
</style>

vue3-project\src\components\PostMessageDemo\ParentComponent.vue

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

// 通信状态
const messageReceived = ref(null)
const messageToSend = ref('你好,子页面!')
const iframeLoaded = ref(false)

// iframe引用
const iframeRef = ref(null)

/**
 * 发送消息到iframe
 */
const sendMessage = () => {
  if (!iframeRef.value || !iframeLoaded.value) return

  try {
    // 发送消息到iframe
    iframeRef.value.contentWindow.postMessage(
      {
        type: 'PARENT_MESSAGE',
        payload: messageToSend.value,
        timestamp: Date.now(),
      },
      '*'
    ) // 在生产环境中应该指定具体的目标源
  } catch (error) {
    console.error('发送消息失败:', error)
  }
}

/**
 * 接收来自iframe的消息
 */
const handleMessage = (event) => {
  // 在实际应用中应该验证消息来源
  // if (event.origin !== 'https://trusted-domain.com') return;

  try {
    // 处理接收到的消息
    if (event.data && event.data.type === 'CHILD_MESSAGE') {
      messageReceived.value = event.data.payload
    }
  } catch (error) {
    console.error('处理消息失败:', error)
  }
}

/**
 * 处理iframe加载完成事件
 */
const handleIframeLoad = () => {
  iframeLoaded.value = true
}

// 组件挂载时添加消息监听器
onMounted(() => {
  window.addEventListener('message', handleMessage)
})

// 组件卸载时移除消息监听器
onUnmounted(() => {
  window.removeEventListener('message', handleMessage)
})
</script>

<template>
  <div class="parent-container">
    <h2>PostMessage 父组件示例</h2>

    <div class="control-panel">
      <div class="input-group">
        <label for="message-input">发送消息:</label>
        <input
          id="message-input"
          v-model="messageToSend"
          type="text"
          placeholder="输入要发送的消息"
        />
        <button @click="sendMessage" :disabled="!iframeLoaded">发送到iframe</button>
      </div>

      <div class="message-display" v-if="messageReceived">
        <h3>收到来自iframe的消息:</h3>
        <div class="message-content">{{ messageReceived }}</div>
      </div>
    </div>

    <div class="iframe-container">
      <iframe
        ref="iframeRef"
        src="/child.html"
        @load="handleIframeLoad"
        width="100%"
        height="300"
      ></iframe>
    </div>
  </div>
</template>

<style scoped>
.parent-container {
  padding: 20px;
  border: 2px solid #42b883;
  border-radius: 8px;
  margin: 20px 0;
}

.control-panel {
  margin-bottom: 20px;
}

.input-group {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 15px;
}

input {
  padding: 8px;
  border: 1px solid #ccc;
  border-radius: 4px;
  flex-grow: 1;
}

button {
  background-color: #42b883;
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color 0.3s;
}

button:hover {
  background-color: #33a06f;
}

button:disabled {
  background-color: #cccccc;
  cursor: not-allowed;
}

.message-display {
  background-color: #f8f8f8;
  padding: 10px;
  border-radius: 4px;
  border-left: 4px solid #42b883;
}

.message-content {
  font-weight: bold;
  word-break: break-word;
}

.iframe-container {
  border: 1px solid #ddd;
  border-radius: 4px;
  overflow: hidden;
}

iframe {
  border: none;
}
</style>

结尾

看懂上面的解释和说明结合vue3小案例,是不是瞬间完全明白 postMessage 怎么玩的了;以及需要注意些什么!!!

nuxtjs+git submodule的微前端有没有搞头

作者 pe7er
2025年7月13日 21:58

背景介绍

在中大型前端项目中,模块拆分和团队协作开发是常态。传统的微前端方案如 Qiankun、Module Federation 更偏向于 SPA 应用,难以兼容服务端渲染(SSR)场景。

Nuxt.js 本身支持模块化架构,可以将功能封装为独立的 Nuxt 模块(Module),具备以下特点:

  • 支持 SSR,适合内容站、管理后台、运营平台等应用
  • 可复用性强,支持多个项目共享同一模块
  • 每个模块可独立开发、测试和发布,适合团队分工
  • 打包时主项目统一编译,避免子项目重复打包、加载

为了实现模块的独立性与可控性,本文结合 git submodule 和 Nuxt 模块机制,构建出一种基于 Git 仓库拆分、Nuxt 构建统一的“SSR 微前端”架构。每个模块作为一个 Git 子仓库单独维护,通过 pnpm workspace 管理依赖,在主项目中组合构建,实现模块解耦、分仓协作、统一部署的开发模式。

本文完整代码托管在

参考文档

nuxt.com.cn/docs/gettin…

nuxt.com.cn/docs/guide/…

nuxt.com.cn/docs/api/ki…

创建主项目

npx create nuxt nuxtjs-base-template

使用空格选中、a键全选、上下方向键切换,选择你需要的模块,建议不要选中@nuxt/content@nuxt/fonts@nuxt/icon。我这里全选后敲回车继续。

image.png

创建nuxt module,用于开发子项目

子项目和主项目的目录层级是平级的,每一个项目和模块都是单独的git仓库。

- nuxtjs-base-template
- nuxtjs-module-0
- nuxtjs-module-1

创建module

pnpm create nuxt -t module nuxtjs-module-0
pnpm create nuxt -t module nuxtjs-module-1

创建时根据实际需求选择,这里建议不要选择@nuxt/content@nuxt/fonts@nuxt/icon

image.png

创建完成后,将主项目和子模块都提交到远端仓库

# 主项目
cd nuxtjs-base-template
git remote add origin git@github.com:cbtpro/nuxtjs-base-template.git
git branch -M main
git push -u origin main

# 子模块1
cd nuxtjs-module-0
git remote add origin git@github.com:cbtpro/nuxtjs-module-0.git
git branch -M main
git push -u origin main

# 子模块2
cd nuxtjs-module-1
git remote add origin git@github.com:cbtpro/nuxtjs-module-1.git
git branch -M main
git push -u origin main

接下来所有的操作都在主项目中操作

建立父子模块关系

cd nuxtjs-base-template
# 添加模块到packages目录下
git submodule add git@github.com:cbtpro/nuxtjs-module-0.git packages/nuxtjs-module-0
git submodule add git@github.com:cbtpro/nuxtjs-module-1.git packages/nuxtjs-module-1

最终形成的目录结构

image.png

添加pnpm-workspace.yaml,并填入下面的内容。

packages:
  - packages/*

说明

  • packages是 pnpm 工作区(workspace) 的核心配置,用来告诉 pnpm 你的项目中有哪些子包(子模块),可以指定具体的目录,这里使用通配符

修改根目录的app.vue为以下内容

<template>
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
</template>

添加第一个页面pages/index.vue

<script setup lang="ts">
</script>

<template>
  <div>首页</div>
</template>

如果你在创建项目时开启了@nuxt/content,它需要安装better-sqlite3

pnpm install -D -w better-sqlite3

执行pnpm install时,控制台会报一个警告,这个警告必须处理,否则会影响后续的工程化执行。

image.png

配置完onlyBuiltDependencies,需要删除主项目和子项目的依赖,重新再根目录使用pnpm install执行一遍安装依赖。

rm -rf node_modules pnpm-lock.yaml
rm -rf packages/nuxtjs-module-0/{node_modules,pnpm-lock.yaml}
rm -rf packages/nuxtjs-module-1/{node_modules,pnpm-lock.yaml}
pnpm i

pnpm approve-builds
pnpm run dev

这个命令会列出当前 pnpm install 阻止执行构建脚本的依赖项,让你手动批准是否信任这些依赖,批准后,会在pnpm-workspace.yaml中生成onlyBuiltDependencies,如果全部批准过,则不会出现警告,也不会有选择的操作。

image.png

image.png

可以检查下预先执行了哪些脚本

image.png

可以检查下pnpm-workspace.yaml,发现增加了配置onlyBuiltDependencies

image.png

说明

  • onlyBuiltDependencies 是解决默认安全行为,会忽略node_modules的内部脚本(例如:husky、axide、esbuild、vue-demi)

如果启用了@nuxt/content

启用了@nuxt/content,则需要在项目根目录添加content.config.ts

/**
 * @see https://content.nuxt.com/docs/getting-started/configuration
 */
import { defineContentConfig, defineCollection } from '@nuxt/content';

export default defineContentConfig({
  collections: {
    content: defineCollection({
      type: 'page',
      source: '**/*.md'
    })
  }
});

添加content\index.md

# My First Page

Here is some content.

修改pages\index.vue

<script setup lang="ts">
const { data: home } = await useAsyncData(() => queryCollection('content').path('/').first());

useSeoMeta({
  title: home.value?.title,
  description: home.value?.description
});
</script>

<template>
  <ContentRenderer v-if="home" :value="home" />
  <div v-else>Home not found</div>
</template>

修改端口

可以修改下默认端口,方便固定开发地址。

export default defineNuxtConfig({
  compatibilityDate: '2025-05-15',
  devServer: {
    host: '0.0.0.0',
    port: 8864,
    cors: {},
    https: false,
  },
  // 其他配置
}

启动项目

pnpm run dev

这时候主项目就启动了,如果你没有开启魔法上网,控制台会报谷歌字体文件加载失败。

image.png

在nuxtjs.config.ts中禁用即可


// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
  compatibilityDate: '2025-05-15',
  devServer: {
    host: '0.0.0.0',
    port: 8864,
    cors: {},
    https: false,
  },
  devtools: { enabled: true },
  // 禁用默认的 google 字体提供商
  fonts: {
    providers: {
      google: false,
      googleicons: false,
      bunny: false,
    }
  },
  modules: [
    '@nuxt/content',
    '@nuxt/eslint',
    // '@nuxt/fonts',
    '@nuxt/icon',
    '@nuxt/image',
    '@nuxt/scripts',
    '@nuxt/test-utils',
    '@nuxt/ui',
  ]
});

image.png

此时主项目已经运行成功了,可以通过ip地址进行访问。

image.png

添加开发脚本

检查下子模块的packages\nuxtjs-module-0\package.json中的scripts


{
  "name": "my-module",
  "version": "1.0.0",
  "scripts": {
    "prepack": "nuxt-module-build build",
    "dev": "npm run dev:prepare && nuxi dev playground",
    "dev:build": "nuxi build playground",
    "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground",
    "release": "npm run lint && npm run test && npm run prepack && changelogen --release && npm publish && git push --follow-tags",
    "lint": "eslint .",
    "test": "vitest run",
    "test:watch": "vitest watch",
    "test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit"
  },

我们需要用到的命令有打包prepack、开发dev,我们需要将命令配置到父项目的文件package.jsonscripts中。增加dev:moduleprepack:module

{
  "name": "nuxt-app",
  "private": true,
  "type": "module",
  "scripts": {
    "dev:module": "pnpm -r --filter ./packages/** run dev",
    "prepack:module": "pnpm -r --filter ./packages/** run prepack",
  },
}

修改子模块的内容

子模块的目录结构如下,playground里的内容用于调试测试,编译打包时不会打进主项目中。而src目录是我们的子模块的开发目录,

image.png

修改packages\nuxtjs-module-0\src\runtime\plugin.ts,增加具有标志的内容,方便控制台查看日志, console.log('Plugin injected by my-module!')可以修改成打印任何内容,这句话会在开发启动控制台看到。

import { defineNuxtPlugin } from '#app'

export default defineNuxtPlugin((_nuxtApp) => {
  console.log('Plugin injected by my-module!')
})

添加页面和组件 页面和组件都添加到packages\my-module-0\src\runtime目录下

image.png

在模块中的packages\nuxtjs-module-0\src\module.ts中添加路由并修改模块id

import { resolve } from 'node:path';
import { defineNuxtModule, addPlugin, createResolver } from '@nuxt/kit';

// Module options TypeScript interface definition
export interface ModuleOptions { }

const routes = [
  {
    name: 'module-0-home',
    path: '/module-0-home',
    file: resolve(__dirname, './runtime/pages/index.vue')
  },
];
export default defineNuxtModule<ModuleOptions>({
  meta: {
    name: 'my-module-0',
    configKey: 'myModule0',
  },
  // Default configuration options of the Nuxt module
  defaults: {},
  setup(_options, _nuxt) {
    const resolver = createResolver(import.meta.url);

    // Do not add the extension since the `.ts` will be transpiled to `.mjs` after `npm run prepack`
    addPlugin(resolver.resolve('./runtime/plugin'));
  },
  hooks: {
    'pages:extend'(pages) {
      pages.push(...routes);
    }
  }
});

image.png 同理,其余的模块也按相同的办法修改。

在nuxtjs.config.ts中添加子模块的依赖

// https://nuxt.com/docs/api/configuration/nuxt-config
import module0 from './packages/nuxtjs-module-0';
import module1 from './packages/nuxtjs-module-1';

export default defineNuxtConfig({
  compatibilityDate: '2025-05-15',
  devServer: {
    host: '0.0.0.0',
    port: 8864,
    cors: {},
    https: false,
  },
  devtools: { enabled: true },
  // 禁用默认的 google 字体提供商
  fonts: {
    providers: {
      google: false,
      googleicons: false,
      bunny: false,
    }
  },
  modules: [
    '@nuxt/content',
    '@nuxt/eslint',
    // '@nuxt/fonts',
    '@nuxt/icon',
    '@nuxt/image',
    '@nuxt/scripts',
    '@nuxt/test-utils',
    '@nuxt/ui',
    module0,
    module1
  ]
});

image.png

此时一定会报错,是因为子模块没有编译dist,只需要执行编译或开发命令,就会自动编译dist,然后重启ts服务即可。

[21:20:35]  ERROR  Cannot find module 'D:\chenbitao\Developer\github\nuxtjs-base-template\packages\nuxtjs-module-0\dist\module.mjs'. Please verify that the package.json has a valid "main" entry

image.png

image.png

编译打包

常用命令

# 编译子模块
pnpm run prepack:module
# 开发主站
pnpm run dev
# 开发子模块
pnpm run dev:module
# 生成静态文件并预览
pnpm run generate && pnpm run preview

在打包主项目的代码时,会自动将子模块的dist代码打入主站代码中。

image.png

提交代码

先提交子模块的代码,最后需要push根目录的项目代码,最终在仓库中显示是如下图,会将最后一次的commit id显示在文件夹后面。

image.png

新同事开发也只需要clone主站的代码,子模块的结果会自动保持,不需要再次使用git submodule来生成

常见问题

  • 下载失败,如下,大概率是网络问题,有多种方法解决,推荐使用switchHost来进行host管理。
[nuxi 18:42:34]  ERROR  Error: Failed to download template from registry: Failed to download https://raw.githubusercontent.com/nuxt/starter/templates/templates/v3.json: TypeError: fetch failed

在host中添加下面的配置

140.82.114.4 github.com
199.232.69.194 github.global.ssl.fastly.net
185.199.108.133 raw.githubusercontent.com
185.199.109.133 raw.githubusercontent.com
185.199.110.133 raw.githubusercontent.com
185.199.111.133 raw.githubusercontent.com

配置完成如下

image.png

  • Could not locate the bindings file.
 ERROR  Cannot start nuxt:  Could not locate the bindings file. Tried:       nuxi 20:35:39  
 → D:\chenbitao\Developer\github\node_modules\.pnpm\better-sqlite3@12.2.0\node_modules\better-sqlite3\build\better_sqlite3.node

better-sqlite3没有安装

  • fonts.google.com加载失败

WARN Could not fetch from fonts.google.com/metadata/fo…. Will retry in 1000ms. 3 retries left. 20:37:41

[20:37:41] WARN Could not fetch from fonts.google.com/metadata/ic…. Will retry in 1000ms. 3 retries left.

项目使用了@nuxt/fonts,创建项目是启用了@nuxt/fonts,后续可以在nuxtjs.config.ts中注释掉@nuxt/fonts

一文理清 node.js 模块查找策略

2025年7月13日 21:26

清楚 node 模块查找策略可以帮我们更好熟悉工程化,毕竟模块化是工程化之基,本篇文章就带大家一起学习 node.js 模块查找策略

参考文献:CommonJS 模块 | Node.js v24 文档

node.js 模块查找算法

node 官网中关于 node.js 模块查找算法的解释总结如下

1.png

翻译出来就是下面的意思,记住这张图,里面的函数稍后会作解释

2.jpg

这里有两个参数,其中 X 就是 require 的入参,Y 就是当前文件的路径,一个文件就是一个模块,因此我们也可以说 Y 就是当前模块的所在路径

可以看到这个算法有 7 个步骤,除了里面涉及到的几个函数,其余我们都可以理解

第一步:内置模块去查找

node 内置模块有很多,比如 fs,http, path 等,其余的模块就是一些需要 install 的依赖,比如 lodash,因此 node 会优先识别内置模块,要是找不到就往后续步骤执行

3.jpg

第二步:X/ 开头,则识别为绝对路径去查找

这里我以 windows 举例,windows 的绝对路径和 mac/linux 不同,windows 用的是反斜杠 \,而反斜杠 \ 又恰好是 js 的转义字符,所以这里需要写成 两个 \ 才行

4.jpg

第三步:X.//../ 开头,则识别为相对路径去查找

如果 X 等于 '.',或者 X 以 './'、'/' 或 '../' 开头,那么就会依次执行下面三个步骤

  1. LOAD_AS_FILE(Y + X)
  2. LOAD_AS_DIRECTORY(Y + X)
  3. 抛出 "not found" 错误

先看 LOAD_AS_FILE(Y + X) 的内容

5.jpg 若不考虑 LOAD_AS_FILE(Y + X) 的第二小步的继续深入,其实 LOAD_AS_FILE(Y + X) 的意思就是

  1. 直接加载对应后缀的文件,比如 require('./test.js')
  2. 自动添加 .js 后缀,比如当前目录下有 test.js ,我们还可以直接写 require('./test')
  3. 直接加载 .json 文件,比如 require('./config.json')
  4. 直接加载 .node 文件,比如 require('./test.node'),一般 node 都是二进制无法阅读

现在我们进入 LOAD_AS_FILE(Y + X) 的第二小步,也就是假设 require('./test') 时当前目录存在 test.js 时,node 应该如何加载 这个 test.js

a. 找到距离 X 最近的包作用域 SCOPE。
b. 如果没有找到作用域
  1. MAYBE_DETECT_AND_LOAD(X.js)
c. 如果 SCOPE/package.json 包含 "type" 字段,
  1. 如果 "type" 字段是 "module",将 X.js 作为 ECMAScript 模块加载。停止。
  2. 如果 "type" 字段是 "commonjs",将 X.js 作为 CommonJS 模块加载。停止。
d. MAYBE_DETECT_AND_LOAD(X.js)

这里解释什么是 包作用域 SCOPE,所谓包作用域 SCOPE 就是包含 package.json 的目录,比如 vue 源码里面的 各种 package ,其中有 compiler-sfc ,那么这个目录下的范围就是一个 包作用域 SCOPE

也就是说一个包的 package.json 里面的 type 字段直接影响了这个包内所有的 .js 文件的加载方式,比如 type: module 就意味着这个包只能使用 ES 模块加载方式,与之对应的 type: commonjs 就只能使用 commonjs 模块加载方式

如果没有找到 package.json ,或者有 package.json 但是没有 type 字段,那么就会调用 MAYBE_DETECT_AND_LOAD(X.js) ,这个函数作用是自动检测模块语法

🙋‍♀️🌰,我在 package.json 设置了 type: module

{
  "name": "demo",
  "type": "module"
}

那么我在 app.js 想要导入 test.js ,那么就不能用 require() ,只能 import

import testFunc from './test.js';

要是 没有 package.json 或者 package.json 没有设置 type 字段,MAYBE_DETECT_AND_LOAD(X.js) 是如何自动检测模块语法的呢

其实很简单,我们也可以想到,import/exportesm 语法,module.exports/requirecommonjs 语法,它会扫描这个关键字来自动判断

第一个函数没有结果就执行 第二个函数 LOAD_AS_DIRECTORY,也就是说文件找不到时,那就当做文件夹去找

9.jpg

总共就两个步骤,先深入第一步

这个函数处理的就是文件夹的情况,先看 若 X 是文件夹,那其下的 package.jsonmain 字段,也就是包的入口文件,一般都会设置成 main.js,若字段为空,执行 第二小步 的 LOAD_INDEX(X),这个函数下面解释

第三小步,加载 X + main 字段的值

第四小步,main 文件不存在,尝试以 main 作为 目录,也就是执行上面的 LOAD_AS_FILE

第五小步,若 main 字段的文件不存在,尝试加载 x/index.js ,可以理解为 main 的默认值就是 index.js

第二步就是 LOAD_INDEX(X) ,这个函数的步骤,就是针对 index 换后缀,顺序依次为 jsjsonnode

第四步:X# 开头

require() 的参数若 以 # 开头,就是表示这个包是私有包,外部无法访问,这个还比较少见

比如我在当前目录下 app.js 文件中 require('#utils')

那么意味着当前目录的 package.json 配置就需要在 imports 字段中声明这个私有包的位置

{
  "name": "demo",
  "imports": {
    "#utils": "./src/utils.js"
  }
}

我若是在 当前目录下新建一个 包(包含package.json),然后里面去通过 # 引用外层的私有包 utils,那么就会报错,不过依旧可以去用 相对路径 引用,所以这么看这个私有好像也没啥用

回到第四步,若 参数 以 # 开头,那么就会调用函数 LOAD_PACKAGE_IMPORTS(X, dirname(Y))

6.jpg

DIR 是入参 dirname(Y),前面提到过 Y 是当前 require() 所在文件的路径,那么 dirname(Y) 就是当前文件所在的文件夹的路径,也就是去掉了当前文件,所以 DIR 就是当前包

Y (当前文件路径): C:\Users\22922\Desktop\module\app.js
dirname(Y) (目录部分): C:\Users\22922\Desktop\module

第二步,找最近的 SCOPE指的是从当前 SCOPE 向外层找

第四步,若启用了 --experimental-require-module ,指的是命令行,比如 scripts 中我们可以设置 这个命令行

{
  "scripts": {
    "dev": "node --experimental-require-module app.js"
  }
}

启用了这个 ,node 内部会设置 conditions 数组,启用后的作用就是可以让 commonjs 同步 require esm 语法导出的模块,require 本身就是同步的,这个启动配置可以让其支持 同步 esm 语法的导出

第五步其实就是将 X 也就是 # 开头的参数用 pathToFileURL 解析成 file://URL 格式,其实就是 #utils 映射成 相对路径,然后 内部添加 conditions 数组

第六步,将 file://URL 转换回文件系统路径,验证文件真实存在,以及后缀,成功加载并返回模块内容

第五步:LOAD_PACKAGE_SELF(X, dirname(Y))

7.jpg 同样,前两步从自身 SCOPE 往外层查找,其实就是找 package.json

第三步检查 package.jsonexports 字段

{
  "name": "my-package",
  "exports": {
    ".": "./main.js",
    "./utils": "./utils.js",
    "./config": "./config.json"
  }
}

第四步检查包名匹配,比如这里 require('my-package') 就会加载 main.js

第五步 PACKAGE_EXPORTS_RESOLVE 解析路径

require('my-package') 就是 "." + "" 找到 ./main.js

require('my-pckage/utils') 就是 "." + "/utils" 找到 ./utils.js

第六步 就是将 上一步 返回的 match (url) 转换回文件系统路径,然后检查文件真实存在,依据后缀名返回实际的模块内容

第六步:LOAD_NODE_MODULES(X, dirname(Y))

8.jpg

第一步生成 node_modules 目录路径(可能包含多种情况),比如 当前 scope/home/user/project/src/components

那么 DIRS 数组可能为

DIRS = [
  '/home/user/project/src/components/node_modules',
  '/home/user/project/src/node_modules', 
  '/home/user/project/node_modules',
  '/home/user/node_modules',
  '/home/node_modules',
  '/node_modules',
  ...全局目录
]

第二步,尝试在每个 DIR 中尝试三种加载方式

第一种使用包的 exports 字段加载,这就要看 package.jsonexports 字段配置了

第二种将当前 require 入参作为文件加载

第三种将当前 require 入参作为目录加载

第七步:return 'not found'

简洁版

看完肯定很绕,而且一些内容是我们平时开发中很少接触的,那就总结一下 node 模块查找算法的简洁版

  1. 内置模块(比如 fs,path,file)

  2. 路径(先绝对后相对)

  3. 尝试作为文件查找(LOAD_AS_FILE

    a. ./config

    b. ./config.js(根据最近包(从自身往外层)的 package.jsontype字段)

    c. ./config.json

    d. ./config.node

  4. 尝试作为文件夹查找(LOAD_AS_DIRECTORY,看 package.jsonmain 入口文件,要是没注册就默认 index.js

  5. # 开头的私有包(需要 package.jsonimports 去注册)

  6. 包内部引用自己的子模块(require('my-package/utils'),需要 exports 字段注册)

  7. 查找依赖

  8. 找不到报错

这么看第三步和第四步才是核心,原来从内置模块查找第三方库的依赖需要调尽这个算法全部过程

文章中若出现错误内容还请各位大佬见谅并指正。如果有任何问题或建议,欢迎指出,另外,有不懂之处欢迎在评论区留言。如果觉得文章对你的学习有所帮助,还请 关注、点赞、收藏 一键三连,感谢支持!欢迎关注我的公众号: Dolphin_Fung

昨天 — 2025年7月13日技术

揭秘Flutter图片编辑器核心技术:从状态驱动架构到高保真图像处理

2025年7月13日 18:28

前言

大家好,我是[小林同学],专注于跨端开发。在许多应用中,图片编辑都是一个不可或缺的功能,从社交App的内容发布到电商平台的商品展示。然而,要亲手打造一个体验流畅、功能强大且输出专业的图片编辑器,并非易事。它不仅考验你对UI和手势的驾驭能力,更深层次地,是对状态管理、渲染性能和坐标系变换等底层原理的综合运用。

因此,我投入了大量精力并结合过往在 [北京某一线大厂] 的工作经历,从零开始设计并实现了一款高性能、高可定制的Flutter图片编辑组件 image_editor 。今天,我想与你分享的,不仅仅是这个组件的功能展示,更是其背后完整的设计哲学、核心技术的攻坚过程,以及它在真实项目中的应用价值。

✨ 核心功能,不止于“编辑”

在我深入技术细节之前,先快速浏览一下这个编辑器能做什么,以及它为何与众不同。

  • 🖼️ 专业级裁剪工具: 自由与约束并存: 支持无限制的自由拖拽裁剪,同时内置16:9、4:3、1:1等多种固定宽高比,满足不同场景下的构图需求。 高保真输出: 这是本编辑器的核心亮点。无论在预览时如何缩放,最终导出的图片都将基于原始图像数据进行裁剪,杜绝二次采样带来的清晰度损失。
  • 🔄 360° 旋转控制: 步进与微调: 提供便捷的90°步进旋转,也支持通过自定义滑块实现-45°到+45°的精细微调,让用户对画面倾斜有像素级的控制力。
  • ✍️ 图层化文字叠加: 所见即所得: 可以在图片上自由添加、拖动和编辑多个独立的文本图层。 丰富的自定义: 支持实时修改文本颜色和字体大小,所有操作都将精确地反映在最终导出的图片上。

成果展示

  • 打开图片编辑器

2.png

  • 裁剪

3.png

  • 旋转

4.png

  • 文本图层

5.png

  • 导出成功

6.png

🏛️ 设计原理与实现思路:从骨架到血肉

一个健壮的组件源于一个清晰的设计。我将整个开发过程归纳为一套设计哲学,并围绕它逐步实现各个功能模块。

1️⃣顶层设计:状态驱动与关注点分离

这是整个项目的基石。我遵循了两大原则:

  • 状态驱动 (State-Driven UI):

    • 我创建了一个 ImageEditorController 并让它继承自 ChangeNotifier
    • 这个 Controller 是所有编辑状态的唯一数据源 (Single Source of Truth)。
    • 任何用户操作(如拖动、点击)都只会调用 Controller 的方法来更新内部状态(如 _cropRect, _currentRotationAngle )。
    • UI层则通过 ListenableBuilder 监听 Controller 的变化并自动重建。
    • 这保证了数据流的单向、可预测,彻底避免了混乱的 setState 调用。
  • 关注点分离 (Separation of Concerns): 组件被严格划分为三层:

    • 控制层 (Controller): 纯粹的业务逻辑与状态管理,无任何UI代码。
    • 视图层 (View/Widgets): 负责UI展示和用户交互,如 MainToolbar 仅负责将点击事件传递给 Controller
    • 绘制层 (Painter): ImageEditorPainter (一个 CustomPainter) 只做一件事:根据 Controller 的当前状态,将图片、裁剪框、文字等精确绘制到 Canvas 上。
    • 这种分离使得修改UI样式(如裁剪框颜色)或优化业务逻辑(如裁剪算法)可以独立进行,互不干扰。

2️⃣核心攻坚:高保真裁剪的实现

这是整个编辑器的灵魂功能,也是技术含量最高的部分,也是技术难点所在。我的目标是:无论用户在屏幕上如何缩放预览图进行裁剪,最终导出的图片都必须是基于原始高分辨率图像的,绝不能有任何精度损失。要实现不失真的裁剪,关键在于避免对屏幕预览图进行截图,而是始终操作原始图像数据。

  • 实现思路如下:

    • 绘制与交互UI:

      • 当裁剪工具激活时, ImageEditorPainter 会根据 Controller 中的 isCroppingActive 状态,在画布上绘制半透明遮罩、网格线和8个拖动控制点。
      • 同时,GestureDetectoronScaleStartonScaleUpdate 会结合用户触摸位置,判断是对裁剪框进行整体拖动还是通过控制点进行缩放,并实时更新 Controller 中的 _cropRect 状态。
    • “反向映射”算法: 这是高保真裁剪的精髓。当用户点击“应用”时,执行以下步骤:

      • 构建正向矩阵: 首先,我们计算一个 Matrix4 变换矩阵,它描述了从“原始图像坐标系”到“屏幕坐标系”的完整变换,包含了平移、缩放和旋转。
      • 求逆矩阵: 我们调用 Matrix4.inverted() 得到逆矩阵。这个逆矩阵可以将屏幕上的坐标点反向映射回原始图像的坐标系中。
      • 变换裁剪框: 利用这个逆矩阵,我们将屏幕坐标下的 _cropRect 的四个顶点,精确地转换成原始 ui.Image 上的坐标,得到 sourceCropRect
      • 高精度绘制: 最后,我们创建一个新的 Canvas ,并调用 canvas.drawImageRect。这个强大的API允许我们从原始高分辨率 ui.Image 中,精确提取 sourceCropRect 区域的像素,并将其绘制到新的画布上,从而生成一张全新的、与原图同样清晰的裁剪后图片。

具体步骤如下:

第一步:绘制裁剪UI

当用户激活裁剪工具时 (controller.activeTool == EditToolsMenu.crop),我们需要在 Painter 中绘制一个交互式的UI,包括:

  • 一个半透明的遮罩层,突出裁剪区域。
  • 裁剪框的白色边框和九宫格网格线。
  • 边角和边上的8个拖动控制点。

第二步:实现裁剪框的交互

这需要精细的手势判断。在 onScaleStart 中,我们需要判断用户的触摸点落在了哪个区域:是裁剪框内部(拖动),还是某个控制点上(缩放)。

// controller/image_editor_controller.dart
enum DragHandlePosition { topLeft, top, topRight, /* ...其他位置 */, inside, none }

DragHandlePosition _getDragHandleForPosition(Offset localPosition) {
  // 遍历8个控制点和内部区域,返回用户点击的位置
  // ...
}

void _onCropDragUpdate(ScaleUpdateDetails details) {
  // 根据不同的控制点,结合用户拖拽的delta,更新_cropRect
  // 如果有固定比例,还需要在这里进行约束
  // ...
  notifyListeners();
}

第三步:实现高保真裁剪算法 (_applyCrop)

  • 理论先行:为什么不能直接截图?

如果直接对屏幕上的 CustomPaint进行截图,得到的是一张低分辨率的、已经被屏幕像素化过的图像。当用户放大这个裁剪后的图片时,就会看到满屏的马赛克。

  • 正确的做法是:“反向映射”。

我的 _cropRect 是在屏幕坐标系下的一个矩形。需要找到这个矩形对应在原始 ui.Image 坐标系下的精确位置。这需要借助矩阵变换。

代码实现:

  • 构建正向变换矩阵 (Matrix4): 我们需要一个能将“原始图片坐标”转换为“屏幕显示坐标”的矩阵。这个矩阵综合了图片的平移(_offset)和缩放(_scale)。
final matrix = Matrix4.identity()
  ..translate(controller.offset.dx, controller.offset.dy)
  ..scale(controller.scale);
  • 求逆矩阵: 调用 Matrix4.inverted()。这个逆矩阵的魔力在于,它可以将“屏幕显示坐标”反向转换回“原始图片坐标”。
final inverseMatrix = Matrix4.tryInvert(matrix);
if (inverseMatrix == null) return; // 矩阵不可逆,异常情况
  • 反向变换裁剪框: 使用逆矩阵,将屏幕上的 _cropRect 变换回原始图片上的 sourceCropRect。
// 注意:Matrix4.transformRect 需要一个围绕原点变换的Rect
// 而我们的_cropRect是带偏移的,所以需要先平移到原点,变换后再平移回去
final sourceCropRect = inverseMatrix.transformRect(_cropRect);
  • 使用 canvas.drawImageRect 精确提取: 这是最关键的一步。这个方法允许我们从源图像(sourceImage)中,截取 sourceCropRect 这个区域,然后将它绘制到新画布的目标区域(destinationRect)上。
// controller/_applyCrop.dart
Future<ui.Image> _renderCroppedImage() async {
  final recorder = ui.PictureRecorder();
  final canvas = Canvas(recorder); // 创建一个新画布用于导出

  // ... 计算 sourceCropRect 如上 ...

  final paint = Paint()..isAntiAlias = false; // 高质量绘制

  canvas.drawImageRect(
    _sourceImage!,     // 源:原始高分辨率图
    sourceCropRect,    // SrcRect:在原图上计算出的精确裁剪区
    Rect.fromLTWH(0, 0, sourceCropRect.width, sourceCropRect.height), // DstRect:绘制到新画布的哪个位置
    paint,
  );

  // 从recorder中生成新的 ui.Image
  return await recorder.endRecording().toImage(sourceCropRect.width.round(),sourceCropRect.height.round());
}

重置状态: 得到裁剪后的新 ui.Image 后,用它替换掉 Controller 中的 _sourceImage ,并重置所有变换状态(缩放、偏移等),编辑器回到一个干净的初始状态,等待用户的下一步操作。

通过这个流程,我们确保了每一次裁剪都是无损的,这是专业级编辑器的核心标志。

3️⃣功能扩展:旋转与文字图层

第一步:玩转角度:旋转工具:

  • 90度旋转

    • Controller 中维护一个 _currentRotationAngle 状态,每次点击增加90度。
    • 通过累加 _currentRotationAngle 并在 Painter 中,在 translate 之后、scale 之前,应用 canvas.rotate() 实现。
  • 自由旋转滑块

    • 则通过一个自定义的 FreeRotateSlider 控件(使用一个横向的 ListViewSingleChildScrollView 来模拟一个刻度尺),将滚动偏移量映射为角度值。
    • 通过监听 ScrollController 的 offset,将其线性映射到 -45° 到 +45° 的角度范围。
    • 为了体验更佳,在 ScrollEndNotification 中加入一个吸附效果,让指针自动对准最近的刻度。
  • 当用户应用旋转时,我会执行“烘焙”操作:计算能容纳旋转后图像的新尺寸,在一个更大的新画布上绘制旋转后的原始图像,生成一张新的 ui.Image,从而将变换固化。

第二步:图层魔法:文字叠加系统:

文字功能的核心是图层化。

  • 数据模型: 定义一个 TextLayerData 类,包含 id, text, offset, color, fontSize 等属性。在 Controller 中维护一个 List<TextLayerData> textLayers

  • 绘制: 在 Painter 中,遍历 textLayers 列表。对于每个文字图层,使用 ui.ParagraphBuilder 构建段落,设置样式,然后调用 canvas.drawParagraph 将其绘制到指定位置。

  • 交互:

    • 选中: 通过 onTapDown 检测点击位置,遍历 textLayers 判断是否点中了某个文字的包围盒,并更新 Controller 中的 selectedTextLayerId
    • 拖动: 在 onScaleUpdate 中,如果当前有选中的文字图层,则更新其 offset 属性。
    • 编辑: 选中文字后,弹出一个 TextPropertiesToolbar ,提供颜色、字号选择器,修改后直接更-新 Controller 中对应 TextLayerData 对象的属性,并调用 notifyListeners(),UI便会魔法般地自动更新。

一个重要的反思:在我最初的设计中,文字的 offset 是基于屏幕坐标的。这导致了一个问题:当图片本身被裁剪或旋转后,文字还傻傻地停在原地。这是一个典型的坐标系依赖错误。正确的做法是:将文字的 offset 存储为相对于原始图片的归一化坐标(例如,(0.5, 0.5) 代表图片正中心)。在绘制时,再通过我们之前构建的变换矩阵,将其动态计算到当前屏幕的正确位置上。这个重构正在计划中,也提醒了我在设计之初统一坐标系的重要性。

✍️ 整合与导出:完成闭环

最后,将所有模块整合起来。通过一个动态工具栏系统,根据 Controller 的当前激活工具(activeTool)或选中对象(selectedTextLayerId)来智能地显示不同的操作菜单。

最终的图像导出 (exportImage) 过程,是整个绘制逻辑的终极复现。我们会创建一个全新的画布,严格按照 Painter 的顺序,将最终的图像变换(裁剪、旋转等)和所有文字图层一次性绘制上去,生成一张包含所有编辑效果的、完美的最终成品。

🎯 真实项目中的使用场景

这个组件的价值在于其可以直接嵌入到各类应用中,提供原生、流畅的编辑体验。

  • 社交与内容平台 (如小红书、朋友圈): 用户发布动态前,可以使用自由裁剪和固定比例裁剪(如3:4)来优化构图,通过旋转校正地平线,并用文字叠加功能添加心情或水印。
  • 用户中心 (头像上传): 提供一个标准的1:1比例裁剪器,让用户能方便地从任意照片中截取最满意的部分作为头像。高保真特性保证了头像即使在高清屏上显示也依然清晰。
  • 电商App (商品发布): 商家可以使用固定比例(如16:9)裁剪功能,快速将商品图处理成统一尺寸,保证商品列表页的视觉整洁性。
  • 工具类应用 (如文档扫描、壁纸制作): 利用自由裁剪和旋转微调,可以精确地裁剪出文档的边缘,或者将一张风景照调整到最适合屏幕壁纸的角度和构图。

🚀 总结与展望

由于项目尚未发布到 pub.dev,可以通过 Git 依赖的方式轻松集成的项目中。这对于内部项目或希望进行二次开发的团队来说非常方便。

从一个想法到最终实现这个功能完备的图片编辑器,是一段充满挑战但收获巨大的旅程。它不仅让我对Flutter的渲染管线、手势处理和矩阵变换有了更深刻的理解,也再次印证了良好架构设计(如状态驱动)在应对复杂需求时的巨大威力。

这个项目本身就是一个极佳的例证,展示了如何运用Flutter的底层能力去构建真正专业和高性能的组件。它不仅是一个可以使用的工具,更是一个可以写进简历、在面试中深入探讨的亮点。

希望这篇详尽的分享能对你有所启发。如果你对这个项目感兴趣,或者有任何建议与问题,非常欢迎在评论区与我交流。

如果觉得这篇文章对你有帮助,请不要吝啬你的 “点赞”“关注” ,这是我继续分享深度技术内容的最大动力!

浏览器渲染全过程解析

作者 海底火旺
2025年7月13日 18:10

深入探索浏览器如何将代码转化为视觉盛宴,揭示高效渲染背后的技术原理

网络线程到主线程

当你在浏览器地址栏输入URL并按下回车时,浏览器网络线程首先接收到HTML文档。这个文档只是一串字符,浏览器需要将其转化为用户可见的像素。网络线程会产生一个渲染任务,并将其传递给渲染主线程的消息队列

渲染起点 -> 网络线程接收HTML文档 -> 创建渲染任务 -> 加入渲染主线程队列

CSS加载为何不阻塞HTML解析?

我们在写html文档时,会将css放入body上面部分,以style或link等方式接入css,不只是为了快速渲染页面样式。

在HTML解析过程中遇到CSS资源时,浏览器会启动一个预解析线程专门处理CSS,生成CSSOM树(CSS对象模型)。这个预解析线程与主渲染线程并行工作,互不干扰。所以早放早运行。

image.png

JavaScript阻塞渲染,为什么不也用一个线程?

JavaScript需要直接操作DOM,而DOM树构建是渲染主线程的核心任务。如果JavaScript在单独的线程中执行,会导致复杂的线程同步问题,因此浏览器设计为在渲染主线程中顺序执行JavaScript。简单来说就是没有那个必要。

HTML解析开始 -> 遇到JavaScript -> 暂停HTML解析 -> 下载并执行JS -> 恢复HTML解析

行盒与块盒的奥秘

在CSS中,盒子的类型由display属性决定,而不是HTML元素类型。W3C规定,内容必须包含在行盒中<p>a</p>这在块盒中影响规定,浏览器会自动生成匿名行盒包裹 a。同时,行盒和块盒不能相邻,当行盒和块盒相邻时,浏览器会自动生成匿名盒。

<div>
  <p>a</p>
  b
  <p>c</p>
</div>

4d366bb3d58526da287590e0ea7aad40.jpg

DOM树与布局树的差异

DOM树表示文档结构,而布局树(渲染树)只包含可见元素及其样式信息。两者并非一一对应:

  • display:none节点没有几何信息,所以不会生成到布局树,但是会生成到dom树
  • 伪元素有几何信息,会生成到布局树,但是dom树不存在伪元素节点(css中写的)

不知道伪元素是什么可以看看我之前的文章CSS 伪元素

分层与合成(渲染加速)

为了提高渲染效率,浏览器会将页面分成多个层,独立处理:

主线程会使用自己的策略对布局树分层

  • 为了在某个层改变仅对修改层处理,提升效率
  • 滚动条、堆叠上下文、transform、opacity 等样式都会或多或少影响分层结果
  • 通过will-change属性更大程度的影响分层结果
  • 但也都是影响,还是看浏览器怎么分

百度首页的分层效果:

image.png

渲染流水线

浏览器渲染的最后阶段将分层后的内容转化为屏幕上的像素:

image.png

绘制

  • 主线程会为每个层单独生成绘制指令集(描述这层怎么画)
  • 然后将每个图层的绘制信息交给合成线程

分块

  • 合成线程会为每个图层生成分块信息

光栅化

  • 合成线程将分块信息给GPU进程,快速完成光栅化
  • GPU进程会开启多个线程来完成光栅化,并优先处理靠近视口区的块
  • 最终呈现一块一块的位图

  • 合成线程计算出每个位图在屏幕上的位置
  • 交给GPU进行最终呈现

为什么交给GPU,而不是自己

1.渲染进程(在沙盒)独立 安全

2.渲染主线程

  • 合成线程(transform也在这,不用参与前面的步骤,效率极高)//滚动也是
  • 渲染进程和操作系统的硬件是隔离的,所以合成线程无法直接交给硬件

transform优势展示

transfrom 直接就走最后像素展示的流程,所以不管你JS怎么折腾(卡死循环)都无济于事。滚动条也是在这里所以卡死循环也不影响页面展示滚动条,大家可以自己试试。

屏幕录制 2025-07-13 180034_20250713_180234.gif

完整代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        .ball{
            width: 100px;
            height: 100px;
            background: pink;
            border-radius: 50%;
            margin: 30px;
        }
        .ball1{
            animation: move1 1s alternate infinite ease-in-out;
        }
        .ball2{
            position: fixed;
            left: 0;
            animation: move2 1s alternate infinite ease-in-out;
        }
        @keyframes move1{
            to{
                transform: translateX(100px);
            }
        }
        @keyframes move2{
            to{
                left: 100px;
            }
        }
    </style>
</head>
<body>
    <button id="btn">卡你五秒如何呢</button>
    <div class="ball ball1"></div>
    <div class="ball ball2"></div>
    <script>
        function delay(time){
            var start = Date.now();
            while(Date.now() - start < time){}
        }
        document.getElementById('btn').onclick = function(){
            delay(5000);
        }
    </script>
</body>
</html>

重排(Reflow)与重绘(Repaint)

理解重排和重绘对性能优化至关重要,以下是重排重绘的代码简单示例,大家可以自己玩一下:

image.png

<!DOCTYPE html>
<html>
<head>
  <title>重排与重绘</title>
  <style>
    .reflow-demo {
      width: 300px;
      height: 200px;
      border: 2px solid #3498db;
      padding: 15px;
      margin: 20px auto;
      position: relative;
    }
    .box {
      width: 100px;
      height: 100px;
      background: #2ecc71;
      transition: all 0.3s;
    }
    .controls {
      text-align: center;
      margin: 20px 0;
    }
    button {
      padding: 8px 15px;
      margin: 0 5px;
      background: #3498db;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
    }
    .console {
      background: #2c3e50;
      color: #ecf0f1;
      padding: 15px;
      font-family: monospace;
      height: 100px;
      overflow-y: auto;
      margin-top: 20px;
    }
  </style>
</head>
<body>
  <div class="reflow-demo">
    <div class="box" id="demoBox"></div>
  </div>
  
  <div class="controls">
    <button onclick="changeSize()">改变尺寸(重排)</button>
    <button onclick="changeColor()">改变颜色(重绘)</button>
    <button onclick="transformBox()">Transform(合成)</button>
  </div>
  
  <div class="console" id="console"></div>
  
  <script>
    const box = document.getElementById('demoBox');
    const consoleEl = document.getElementById('console');
    
    function log(message) {
      consoleEl.innerHTML += `[${new Date().toLocaleTimeString()}] ${message}\n`;
      consoleEl.scrollTop = consoleEl.scrollHeight;
    }
    
    function changeSize() {
      log('开始:改变元素尺寸...');
      const start = performance.now();
      
      // 触发重排
      box.style.width = box.offsetWidth === 100 ? '150px' : '100px';
      
      // 读取布局信息 - 强制同步重排
      const width = box.offsetWidth;
      
      const duration = performance.now() - start;
      log(`完成:尺寸改变 (${duration.toFixed(2)}ms)`);
      log(`读取宽度: ${width}px (强制同步重排)`);
    }
    
    function changeColor() {
      log('开始:改变背景颜色...');
      const start = performance.now();
      
      // 只触发重绘
      box.style.backgroundColor = box.style.backgroundColor === 'rgb(46, 204, 113)' ? 
                                 '#9b59b6' : '#2ecc71';
      
      const duration = performance.now() - start;
      log(`完成:颜色改变 (${duration.toFixed(2)}ms)`);
    }
    
    function transformBox() {
      log('开始:应用transform...');
      const start = performance.now();
      
      // 使用transform只触发合成
      box.style.transform = box.style.transform === 'translateX(50px)' ? 
                           'translateX(0)' : 'translateX(50px)';
      
      const duration = performance.now() - start;
      log(`完成:transform应用 (${duration.toFixed(2)}ms)`);
    }
  </script>
  
  <div class="explanation">
    <p><strong>性能关键点:</strong></p>
    <ul>
      <li>重排(reflow)会重新计算布局 - 代价最高</li>
      <li>重绘(repaint)不改变布局,但需要重新绘制</li>
      <li>transform和opacity只触发合成(composite) - 最高效</li>
      <li>重排是异步的,但读取布局属性会强制同步重排</li>
    </ul>
  </div>
</body>
</html>

reflow 它是异步的,会等js执行完成一起提交给合成线程

但是在读取的时候是同步的(因为读取的时候如果js将其中元素改变,异步的话,读取就会还是原来元素的状态)

总体流程

image.png

总结

浏览器渲染是一个复杂而精密的流水线作业,从接收HTML字符串开始,经历解析、样式计算、布局、分层、绘制、光栅化到最终合成,每一步都经过高度优化。理解这个过程有助于开发者:

  1. 优化页面性能:减少重排和重绘,优先使用transform和opacity
  2. 合理组织资源:CSS放头部,JS放底部或使用async/defer
  3. 避免布局抖动:避免在循环中交替读写布局属性
  4. 利用分层优势:合理使用will-change提示浏览器优化渲染

浏览器是否支持webp图像的判断

2025年7月13日 18:05

本文目的主要是想记录下webp图像的兼容性判断

生成1px的正方形透明的webp图片

该步骤目的是为了获取一个1px x 1px的透明webp图片链接

// 生成一个1x1的透明的WebP图片
function generateWebP() {
  const canvas = document.createElement('canvas')
  canvas.width = 1
  canvas.height = 1

  const ctx = canvas.getContext('2d')
  ctx.clearRect(0, 0, 1, 1) // 清除画布,确保透明

  const dataURL = canvas.toDataURL('image/webp')

  return dataURL
}

最后能得到一个base64链接:data:image/webp;base64,UklGRhACAABXRUJQV....

判断是否支持webp格式图片

此时刚好用上刚才获取到的图片链接

function checkWebPSupport() {
  return new Promise((resolve) => {
    const img = new Image()

    img.onload = function () {
      const isSupported = img.width === 1 && img.height === 1
      resolve(isSupported)
    }

    img.onerror = function () {
      resolve(false)
    }

    // 一个 1x1 的透明 WebP 图片(Base64)
    img.src =
      'data:image/webp;base64,UklGRhACAABXRUJQVlA4WAoAAAAwAAAAAAAAAAAASUNDUMgBAAAAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADZBTFBIAgAAAAAAVlA4IBgAAAAwAQCdASoBAAEAAUAmJaQAA3AA/v02aAA='
  })
}

怎么测试有没有问题

如果想具体看看实际效果,可以通过 codepen + browserstack Screenshots 2者结合进行查看

  • codepen 提供代码片段在线链接
  • browserstack Screenshots 可以对多个平台的不同浏览器进行截图

我的 codepen代码片段

image.png

我的测试结果

image.png

题外话

刚好看到了一个 Modernizr:HTML5/CSS3 的功能检测库 ,他主要是一个 JavaScript 库,主要用于 检测浏览器对 HTML5 和 CSS3 特性的支持情况

Modernizr 会在页面加载时运行,并检查浏览器是否支持各种前端新技术,比如:

  • HTML5 元素(如 <canvas><video><audio> 等)
  • CSS3 特性(如 Flexbox、Grid、动画、媒体查询等)
  • JavaScript API(如 geolocation、localStorage、serviceWorker 等)

它会在 window.Modernizr 对象中添加检测结果,并在 <html> 标签上加上对应的 class 名。例如:

<html class="no-webgl csstransforms3d video localstorage">

比如 no-webgl 这个class就是不支持的标识,也可以 window.Modernizr.xxxx 判断

React 实现 useMemo

作者 june18
2025年7月13日 17:49

本文将带大家实现 useMemo。

先看下如何使用。

function FunctionComponent() {
  const [count1, setCount1] = useState(1);
  const [count2, setCount2] = useState(1);
  
  // 昂贵运算
  const expensive = useMemo(() => {
      let sum = 0;
      for (let i = 0; i < count1; i++) {
          sum += i;
      }
      return sum
  }, [count1])

  return (
    <div className="border">
      <button
        onClick={() => {
          setCount1(count1 + 1);
        }}
      >
        {count1}
      </button>
      <button
        onClick={() => {
          setCount2(count2 + 1);
        }}
      >
        {count2}
      </button>
      <p>{expensive}</p>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  (<FunctionComponent />) as any
);

实现 useMemo

// 检查 hook 依赖是否变化
export function areHookInputsEqual(
  nextDeps: Array<any>,
  prevDeps: Array<any> | null
): boolean {
  if (prevDeps === null) {
    return false;
  }

  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    if (Object.is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  return true;
}

export function useMemo<T>(
  nextCreate: () => T,
  deps: Array<any> | void | null
): T {
  // 获取当前 hook
  const hook = updateWorkInProgressHook();

  const nextDeps = deps === undefined ? null : deps;

  const prevState = hook.memoizedState;
  // 检查依赖项是否发生变化
  if (prevState !== null) {
    if (nextDeps !== null) {
      const prevDeps = prevState[1];
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        // 依赖项没有变化,返回上一次计算的结果,就是缓存的值
        return prevState[0];
      }
    }
  }

  const nextValue = nextCreate();

  // mount 阶段存储
  hook.memoizedState = [nextValue, nextDeps];

  return nextValue;
}

💡前端入门:AbortController 能中断哪些事件?一文搞懂!

2025年7月13日 17:20

凌晨你盯着监控大盘:用户点击“取消”后,页面还在拼命转圈,后台接口却像失控的火车,带着 200 MB 的文件流在公网上狂奔。 这是我们迫切想中断这个请求,但 Promise 内部也没给我们通过中断请求的方法

这个时候不径想到了角落里那个不起眼的 AbortController
一个 3 行代码就能“真·取消”任何 fetch/stream/setTimeout 的小工具, 今天,我们就把它从冷宫里捞出来,看看它到底怎么把异步任务从“失控”变成“可控”。

1、AbortController核心思想

  • 创建一个 AbortController 实例。

  • 实例获取一个 signal 属性。

  • 将这个 signal 传递给一个支持取消的异步操作。

  • 当你想取消这个操作时,调用实例abort() 方法。

1.1、实例 signal 属性

signal 是一个 AbortSignal 实例,可被用于任何支持中止的 API(如 fetch()、事件监听器等)

一旦 abort() 被调用,signal 就会触发 abort 事件,标记为已中止

1.2、实例 abort() 方法

  • 调用后会让该 signal 上监听的所有异步操作同时中止
const controller = new AbortController();
window.addEventListener('resize', () => {
  console.log('Window resized!');
}, { signal: controller.signal });
// 调用 abort(),会自动移除 resize 监听器
document.getElementById('but').onclick = () => {
  controller.abort();
}

2、AbortController能中止哪些操作?

  1. Promise (通过手动检查信号状态)
  2. fetch() 请求
  3. XMLHttpRequest (XHR) 请求
  4. EventSource 连接
  5. WebSocket 连接 (部分实现)
  6. FileReader 操作
  7. Image 加载
  8. Js事件

2.1、fetch中断

fetch原生支持AbortController事件

let controller = new AbortController();
fetch(url, {
  signal: controller.signal
}).catch((err)=>
         if (error.name === 'AbortError') {
         console.log('Abort中断');
         });
setTimeout(() => controller.abort(), 1000);//中断
  • 我们创建了一个 controller 和它的获取 signal

  • fetch 请求的 signal 选项被设置为 controller.signal

  • controller.abort() 被调用时,signal 会触发 abort 事件,fetch 请求会立即终止,并抛出一个 AbortError

  • catch 块中,我们可以通过 error.name === 'AbortError' 来区分是中止错误还是其他网络错误。

2.1.1、批量 fetch 中断

let urls = [...]; // 要并行 fetch 的 url 列表

let controller = new AbortController();

// 一个 fetch promise 的数组
let fetchJobs = urls.map(url => fetch(url, {
  signal: controller.signal
}));

let results = await Promise.all(fetchJobs);

 controller.abort() 
// 它都将中止所有 fetch

2.2、中断Promise

当调用abort时,将在Promise内部改变Promise状态,达到中断Promise的目标

// 1. 一个需要 5 秒才能完成的异步任务
function slowTask(signal) {
  return new Promise((resolve, reject) => {
    // 如果信号已经 abort,立即退出
    if (signal.aborted) {
      return reject(new Error('Aborted before start'));
    }
    const timer = setTimeout(() => {
      resolve('任务完成');
    }, 5000);
    // 监听 abort 事件
    signal.addEventListener('abort', () => {
      clearTimeout(timer);          // 清理资源
      reject(new Error('Aborted by user')); // 主动让 Promise rejected
    });
  });
}
// 2. 使用 AbortController
const controller = new AbortController();
const signal = controller.signal;
// 3. 启动任务
slowTask(signal)
  .then((res)=>consloe.log(res))
// 4. 2 秒后手动中断
setTimeout(() => {
  console.log('手动 abort');
  controller.abort();
}, 2000);

2.3、中断xhr

2.3.1、原生中断

xhr原生就支持中断,内部就存在一个abort函数可进行中断

const xhr = new XMLHttpRequest();
const method = 'GET';
const url = 'https://xxx';
xhr.open(method, url, true);
xhr.onreadystatechange = () => {
  if (xhr.readyState === 4) {
  }
}
xhr.send();
setTimeout(()=>{
    xhr.abort()}
,1000)   

2.3.2、abortController中断

const controller = new AbortController();
const signal = controller.signal;
signal.addEventListener('abort', () => {
  console.log('XHR request aborted!');
});
function loadXHRData() {
  const xhr = new XMLHttpRequest();
  xhr.open('GET', 'https://jsonplaceholder.typicode.com/todos/1');
 
  // 将 signal 绑定到 XHR
  xhr.signal = signal; // 注意:这是较新的 XHR 规范,旧浏览器可能不支持
  xhr.onload = function() {
    if (xhr.status >= 200 && xhr.status < 300) {
      console.log('XHR data received:', JSON.parse(xhr.responseText));
    } else {
      console.error('XHR error:', xhr.status, xhr.statusText);
    }
  };
  xhr.send();
}
loadXHRData();
setTimeout(() => {
  console.log('Attempting to abort XHR request...');
  controller.abort();
}, 100);


2.4、中断js事件

AbortController也能控制Js事件,包括dom事件。
在注册事件时,可以控制signal将直接将事件取消掉

const controller = new AbortController();
window.addEventListener('resize', () => {
  console.log('Window resized!');
}, { signal: controller.signal });
// 调用 abort(),会自动移除 resize 监听器
document.getElementById('but').onclick = () => {
  controller.abort();
}

三、注意事项

  1. 及时清理资源
    当请求被取消后,确保及时清理与请求相关的资源,避免内存泄漏或其他潜在问题。
  2. 错误处理
    在处理fetch请求的Promise链时,要特别注意AbortError的处理。确保能够区分是因取消请求而引发的错误还是其他类型的错误,以便进行正确的错误处理。
  3. 多次调用abort
    abort方法可以被多次调用,但第二次及以后的调用不会有任何效果。一旦请求被取消,它将保持取消状态。
  4. 与其他API的兼容性
    虽然AbortController在现代浏览器中的支持已经相当广泛,但在一些较老的浏览器版本中可能还不支持。因此,在使用AbortController时,要注意检查目标浏览器的兼容性情况,并考虑使用Polyfill或备选方案来确保功能的可用性。
  5. 不要滥用
    虽然AbortController提供了取消请求的能力,但并不意味着我们应该滥用它。频繁地取消和重新发起请求可能会对服务器造成不必要的负担,也可能影响用户体验。因此,在使用AbortController时,要谨慎考虑是否真的需要取消请求,并尽量避免不必要的取消操作。

6.29 drilling notes

2025年7月13日 16:12

ts泛型复习

function valueProjectorReturnSameType<T>(arg1:T):T{
    return arg1
}

function valueProjectorReturnSameType2<T,G>(arg1:T|G):T|G{
    return arg1
}

函数名<T>指定使用泛型T

泛型,但要求有某个属性,要用比如T.name? extends泛型约束

interface SomeType{
    name:string
}

function genericTypeWithRestraint<T extends SomeType>(arg1:T){
    return arg1.name
}

console.log(genericTypeWithRestraint({name:'wang',age:10}))

函数名<T extends 一个接口> 添加约束

类型推断练习

套路:type TargetType<T> = T extends 问题类型,要推理的类型使用infer ? K:T

1.写一个PromiseType类型能够获取Promise<string>里的string

type PromiseType<T> = ? // T extends Promise<infer K>?K:T

type MyPromiseReturnType = PromiseType<Promise<string>>

const a:MyPromiseReturnType = 'a'


有一个类型组成的数组[string, number],写一个类型推断出该数组元素的类型

type ArrayTypeList<T> = ?? // T extends (infer I)[]?I: T

type ItemType1 = ArrayTypeList<[string,number]>

const item1:ItemType1='a' // string|number
  1. 函数有关的推断
\\推断第一个参数的类型
type FirstArg<T> = T extends (first: infer F, ...args:any[])=>any?F:T;

type FA = FirstArg<(name:string,age:number)=>void>


type PersonHere = {
    person:string
}

type MyReturnType<T> = T extends (name:string,age:number)=>infer F?F:T;

type ReturnedType = MyReturnType<(name:string,age:number)=>PersonHere>

const mannn:ReturnedType = {person:'aaa'}

协变(Covariance) 与 逆变(Contravariance)

面向对象的概念。 协变:子类对象赋值给父类型的变量:Parent A = new Child() 逆变:Child A = new Parent()

逆变: 父类型的形参,可接受子类型对象。

interface Person{
    name:string
}

interface Programmer extends Person{
    skill:string
}

type pf=(arg:Person)=>void  //定义一个pf类型的函数
type tf=(arg:Programmer)=>void //定义一个tf类型的函数

let f1:pf=(c:Person)=>{}
let f2:tf=(p:Programmer)=>{}

f1=f2 //error! skill is missing in pf
//f1是pf类型,他想要接收一个Person类型的参数,
//如果调用f1,因为f1是pf类型,所以调用时传进去一个Person类型的变量,
//但函数体变成了f2,它是对Programmer处理的,比如可能使用c.skill,而Person类型的变量没有skill。
//于是造成了类型不安全

f2=f1 //ok. f2变成f1后,因为f1接受子类对象o,而调用f2时,使用子类对象,以及f1中定义的逻辑,这个o一定都能用

返回值时:

interface Person{
    name:string
}

interface Programmer extends Person{
    skill:string
}

type pf=()=>Person
type tf=()=>Programmer

let f1:pf=()=>({name:'wang'})
let f2:tf=()=>({name:'wang',skill:'singing'})
f2=f1//error!!! 这时候是f1的函数体功能是构建一个Person类型返回值,但它赋值给f2后,缺了skill,

15、前端可配置化系统设计:从硬编码到可视化配置

作者 金泽宸
2025年7月13日 15:30

🪜 写在前面

可配置系统是从前端工程师到架构师的分水岭。 硬编码功能能跑,配置化功能能扩展,平台化功能能赚钱。

从业务角度看,你应该具备这样的能力:

  • 不用改代码就能上线新活动、新表单、新逻辑
  • 通过 JSON、平台配置、低代码表达业务需求
  • 通用模块 + 配置渲染,即搭即用

本篇我们将设计一套表单/表格/页面/按钮等可配置系统的前端架构方案,并覆盖渲染引擎、配置结构、动态组件加载与平台对接等核心内容。


📦 一、常见可配置化场景

模块 可配置内容
表单 字段列表、校验规则、联动逻辑、默认值
表格 列字段、格式化规则、操作按钮、嵌套配置
页面 区块布局、Tab、组件嵌套、内容模块
按钮 权限控制、事件类型、弹窗配置
活动运营 时间规则、奖品配置、条件组合表达式

🧱 二、配置驱动渲染的核心思想

把逻辑写死在 JS 里,不如让配置表达逻辑,让系统表达场景。

设计一套结构如下的 JSON 配置:

{
  "type": "form",
  "fields": [
    {
      "type": "input",
      "label": "用户名",
      "field": "username",
      "required": true
    },
    {
      "type": "select",
      "label": "角色",
      "field": "role",
      "options": [
        { "label": "管理员", "value": "admin" },
        { "label": "普通用户", "value": "user" }
      ]
    }
  ]
}

传入组件:<DynamicForm :config="formConfig" /> 前端动态渲染 UI,并接入事件、校验、接口等逻辑。


✅ 三、统一组件渲染引擎设计

// schema 类型定义(核心)
interface ConfigSchema {
  type: 'form' | 'table' | 'page'
  fields?: FieldSchema[]
  columns?: ColumnSchema[]
  layout?: LayoutSchema[]
}

组件映射表:

const componentMap = {
  input: InputField,
  select: SelectField,
  table: DynamicTable,
  form: DynamicForm,
  page: PageRenderer,
}

渲染器封装:

<component :is="componentMap[schema.type]" v-bind="schema" />

🧩 四、平台化配置系统对接建议

后台配置管理:

  • 配置 JSON 存入数据库(关联业务类型/版本)
  • 页面加载时通过接口获取配置项:
const config = await fetchConfig('user-create-form')

渲染方式:

<ConfigRenderer :config="config" />

🔁 五、配置中支持联动/权限/表达式

能力 实现方式
字段联动 showWhen: (formModel) => formModel.role === 'admin'
条件判断 visible: "role == 'admin' && status == 'active'"
权限控制 permission: 'user.create',配合 v-permission 实现
动态值注入 使用变量模板:"label": "欢迎{{user.name}}"

搭配 @vueuse/coreevaluateExpression()lodash.template 实现表达式解析。


📑 六、配置字段样例(支持功能扩展)

{
  type: 'select',
  field: 'status',
  label: '状态',
  required: true,
  options: [
    { label: '启用', value: 'active' },
    { label: '停用', value: 'disabled' }
  ],
  disabledWhen: (model) => model.role !== 'admin'
}

🔄 七、平台编辑器建议(后期拓展)

模块 功能
拖拽区 拖入组件、修改属性
属性区 表单配置字段编辑
JSON 模式 支持直接修改 Schema
预览模式 右侧实时渲染
发布/保存 JSON 存入数据库,可设版本

搭配 lowcode-engineform-builder-vue 可快速实现前端低代码配置器。


🧠 八、配置系统中的抽象思维

原业务 配置抽象
创建用户页面 表单 Schema + 提交按钮组件
商品列表页 表格组件 + columns 配置
活动编辑页面 页面布局 Schema + 表单嵌套
公共弹窗 弹窗组件 + type/config 渲染具体组件

✅ 可配置化的关键不是写死功能,而是提供足够通用的 UI 容器 + 渲染引擎 + 插槽


🧲 九、常见陷阱与解决方案

问题 建议
配置项太多太乱 分层设计:字段层 → 表单层 → 页面层
动态值注入太灵活 限制字段表达式语法,仅支持特定变量
过于依赖 JS 逻辑 配置只控制结构和 UI,逻辑用插件注入
多端配置复用困难 统一配置协议,组件库适配不同端

✅ 十、落地建议(适用于企业级系统)

  1. 所有业务页面结构、字段、操作尽量抽象为 Schema

  2. 表单 + 表格配置可共用字段抽象类型

  3. 配置支持后端拉取、动态切换、版本管理

  4. 用户操作记录/日志接入埋点系统

  5. 平台维护配置时建议增加:

    • 配置校验器
    • 配置版本快照
    • 权限限制(谁能编辑、发布)

🧠 总结一句话

可配置系统是前端工程架构抽象能力的巅峰体现,让功能由工程师驱动,逐步转向由产品和数据平台驱动。


下一篇我们将开启性能与稳定性优化阶段的首篇: 👉 《前端性能优化全景图与落地方案》

React 实现 useState

作者 june18
2025年7月13日 15:17

本文将带大家实现 useState。

先看下如何使用。

function FunctionComponent() {
  const [count1, setCount1] = useState(1);

  return (
    <div className="border">
      <button
        onClick={() => {
          setCount1(count1 + 1);
        }}
      >
        {count1}
      </button>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  (<FunctionComponent />) as any
);

useState 是基于 useReducer 实现的,参考文章 React 实现 useReducer

实现 useState

// 源码中 useState 与 useReducer 对比:
// useState 如果 state 没有改变,不引起组件更新。useReducer 不是如此。(老的源码中,state 没有改变,两者都不更新)
// reducer 代表 state 修改规则,useReducer 比较方便复用这个规则
export function useState<S>(initialState: (() => S) | S) {
  const init = isFn(initialState) ? (initialState as any)() : initialState;
  return useReducer(null, init);
}

useReducer 修改

为了适配 useStateuseReducerdispatchReducerAction 两个函数的 reducer 参数增加了 null。

export function useReducer<S, I, A>(
  reducer: ((state: S, action: A) => S) | null,
  initialArg: I,
  init?: (initialArg: I) => S
) {
    ...
}

function dispatchReducerAction<S, I, A>(
  fiber: Fiber,
  hook: Hook,
  reducer: ((state: S, action: A) => S) | null,
  action: any
) {
    ...
}

再聊 scheduleUpdateOnFiber

Fiber hooks 调用 scheduleUpdateOnFiber 时,第三个参数传的都是 true。

因为 React 考虑到 ensureRootIsScheduled 使用到了调度器,会增加复杂度。

export function scheduleUpdateOnFiber(
  root: FiberRoot,
  fiber: Fiber,
  isSync?: boolean
) {
  workInProgressRoot = root;
  workInProgress = fiber;

  if (isSync) {
    // queueMicrotask 是 JS 原生 API
    queueMicrotask(() => performConcurrentWorkOnRoot(root));
  } else {
    ensureRootIsScheduled(root);
  }
}

export function performConcurrentWorkOnRoot(root: FiberRoot) {
  // 1. render, 构建 fiber 树,即 VDOM(beginWork|completeWork)
  renderRootSync(root);

  const finishedWork = root.current.alternate;
  root.finishedWork = finishedWork; // 根 Fiber

  // 2. commit, VDOM -> DOM
  commitRoot(root);
}

学习总结 关于DSL的领悟

2025年7月13日 14:58

前言

本文是对elpis学习过程中DSL(Domain Specific Language)的学习感悟,通过声明式配置和模块化架构,该系统实现了近乎"零编码"的管理界面生成能力。

核心技术优势

1. 声明式配置驱动开发

该 DSL 最大的优势在于采用了声明式的配置方式。开发者只需要通过简单的 JSON 配置就能定义完整的管理界面:

{
  mode: 'dashboard',
  name: '电商管理系统',
  menu: [{
    key: 'product',
    name: '商品管理',
    moduleType: 'schema',
    schemaConfig: {
      api: '/api/proj/product',
      schema: {
        type: 'object',
        properties: {
          product_name: {
            type: 'string',
            label: '商品名称',
            tableOption: { width: 200 },
            searchOption: { comType: 'input' }
          }
        }
      }
    }
  }]
}

优势体现:

  • 无需编写复杂的组件代码
  • 配置即文档,易于理解和维护
  • 快速原型开发,大幅提升开发效率

2. 模块化架构设计

2.1 Schema 模块 - 数据驱动的管理界面

moduleType: 'schema',
schemaConfig: {
  api: '/api/proj/product',
  schema: {
    properties: {
      product_id: {
        type: 'string',
        label: '商品ID',
        tableOption: { width: 300 },
        searchOption: { comType: 'input' }
      }
    }
  }
}

技术亮点:

  • 自动生成搜索表单和数据表格
  • 支持多种组件类型(input、select、dynamicSelect、dateRange)
  • 内置分页、排序、过滤功能
  • 一个配置同时定义表格列和搜索条件

2.2 Sider 模块 - 递归菜单系统

moduleType: 'sider',
siderConfig: {
  menu: [{
    key: 'coupon',
    name: '优惠券',
    moduleType: 'custom',
    customConfig: { path: '/todo' }
  }]
}

优势:

  • 支持无限层级的菜单嵌套
  • 动态路由生成
  • 灵活的子模块组合

2.3 Iframe 和 Custom 模块

  • Iframe 模块: 快速集成第三方系统
  • Custom 模块: 完全自定义的页面内容

3. 智能的模型继承机制

系统实现了独特的模型继承机制,项目可以继承和扩展基础模型:

// 基础模型(model/business/model.js)
module.exports = {
  model: 'dashboard',
  name: '电商系统',
  menu: [基础菜单配置]
}

// 项目扩展(model/business/project/jd.js)
module.exports = {
  name: '京东',
  menu: [扩展的菜单配置]  // 会与基础模型合并
}

核心优势:

  • 代码复用:避免重复配置
  • 灵活扩展:项目可以覆盖或新增功能
  • 版本管理:基础模型升级自动传播到所有项目

4. 动态组件系统

系统内置了丰富的动态组件,支持多种交互场景:

// 动态下拉框
searchOption: {
  comType: 'dynamicSelect',
  api: '/api/proj/product_enum/list'
}

// 静态选项
searchOption: {
  comType: 'select',
  enumList: [{
    label: '全部',
    value: ''
  }]
}

技术特点:

  • 组件自动加载和初始化
  • 支持远程数据源
  • 统一的组件接口设计

总结

  1. 开发效率的提升 - 从编码到配置的转变
  2. 高度的可复用性 - 模型继承和组件化设计
  3. 优秀的可维护性 - 配置即文档,易于理解和修改
  4. 强大的扩展能力 - 支持多种模块类型和自定义扩展

😁深入JS(九): 简单了解Fetch使用

2025年7月13日 14:36

fetch()可以说是 XMLHttpRequest 的全面升级版(除了监听文件上传进度),用于在 JavaScript 脚本里面发出 HTTP 请求。

1、基本使用

1.1 fetch的特点

fetch()的功能与 XMLHttpRequest 基本相同,但有三个主要的差异。

(1)fetch()使用 Promise

(2)fetch()采用模块化设计,API 分散在多个对象上(Response 对象、Request 对象、Headers 对象)

(3)fetch()通过数据流(Stream 对象)处理数据,可以分块读取,有利于提高网站性能表现,减少内存占用,对于请求大文件或者网速慢的场景相当有用。

1.1 xhr与fetch的使用对比

fetch的使用

fetch('https://example.org/foo', {
    method: 'POST',
    mode: 'cors',
    headers: {
        'content-type': 'application/json'
    },
    body: JSON.stringify({ foo: 'bar' })
}).then(res => res.json()).then((res)=>console.log(res))

xhr使用

const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://example.org/foo');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.responseType = 'json';
xhr.withCredentials = true;
xhr.onreadystatechange = function () {
// 请求完成
if (xhr.readyState === 4 && xhr.status === 200) {
       console.log(xhr.response)
  } 
}
};
xhr.send(JSON.stringify({ foo: 'bar' }))

1.3、两次Promise

// first await
let response = await fetch("/some-url")
// second await
let res = await response.json()

两个 await 的本质是:

  1. 获取响应头(获取 Response 对象)。
  2. 等待响应体解析(响应体可能很大,需要异步解析,因此需要第二个 await)。

1.4、读取内容的方法

  • text(): 将响应体解析为纯文本字符串并返回。

  • json(): 将响应体解析为JSON格式并返回一个JavaScript对象。

  • blob(): 将响应体解析为二进制数据并返回一个Blob对象。

  • arrayBuffer(): 将响应体解析为二进制数据并返回一个ArrayBuffer对象。

  • formData(): 将响应体解析为FormData对象。

1.5、Response.body

Response.body属性是 Response 对象暴露出的底层接口,返回一个 ReadableStream 对象

const response = await fetch('flower.jpg');
const reader = response.body.getReader();
while(true) {
  const {done, value} = await reader.read();
  if (done) {
    break;
  }
  console.log(`Received ${value.length} bytes`)
}

1.6、Request header

let response = fetch(protectedUrl, {
  headers: {
    Authentication: 'secret'
  }
});

1.7、其他配置(cache,mode,keepalive)

cache

cache属性指定如何处理缓存。

  • no-store:不缓存。
  • no-cache:优先协商缓存 mode

mode属性指定请求的模式。可能的取值如下:

  • cors:默认值,允许跨域请求。

keepalive

keepalive属性用于页面卸载时,告诉浏览器在后台保持连接,继续发送数据。

一个典型的场景就是,用户离开网页时,脚本向服务器提交一些用户行为的统计信息。这时,如果不用keepalive属性,数据可能无法发送,因为浏览器已经把页面卸载了。

拓展运算符和剩余参数

2025年7月13日 14:33

在JavaScript中,扩展运算符(...)和剩余参数(也是用 ... 表示)是紧密相关但用途不同的两个概念。以下为你详细解析,方便面试作答:

1. 扩展运算符

  • 定义与外观:扩展运算符(...)用于在函数调用、数组字面量或对象字面量中展开数组或对象的元素或属性。
  • 在数组中的应用
    • 数组展开:将数组展开成独立元素,常用于函数调用场景。例如,Math.min() 函数接收多个数字参数并返回最小值,若已有数字数组,可借助扩展运算符实现功能。
let numbers = [3, 5, 1];
let minNumber = Math.min(...numbers);
console.log(minNumber); 

这里 ...numbers 将数组 numbers 展开为 3, 5, 1,作为 Math.min() 的参数,如同直接传递这些数字。 - 数组合并:简洁地合并多个数组。

let arr1 = [1, 2];
let arr2 = [3, 4];
let combinedArray = [...arr1, ...arr2];
console.log(combinedArray); 

通过 ...arr1...arr2,将两个数组合并为 [1, 2, 3, 4],相比 concat() 方法,代码更简洁,合并多个数组时优势明显。 - 数组复制:实现数组浅拷贝。

let originalArray = [1, 2, 3];
let copiedArray = [...originalArray];
console.log(copiedArray); 

copiedArrayoriginalArray 的浅拷贝,两个数组内容相同但内存独立,修改其中一个不影响另一个。

  • 在对象中的应用
    • 对象展开:在对象字面量中展开对象的可枚举属性。
let obj1 = { a: 1, b: 2 };
let obj2 = { ...obj1, c: 3 };
console.log(obj2); 

...obj1obj1 的属性 ab 展开到 obj2 中,再添加新属性 c,最终 obj2{ a: 1, b: 2, c: 3 }。 - 对象合并:合并多个对象。

let objA = { x: 10 };
let objB = { y: 20 };
let mergedObj = { ...objA, ...objB };
console.log(mergedObj); 

mergedObj 合并了 objAobjB 的属性,若有相同属性名,后面对象的属性会覆盖前面对象的属性。

2. 剩余参数

  • 定义与外观:剩余参数(...)用于在函数定义中收集多余的参数到一个数组中。
  • 应用场景
    • 处理不定数量参数:在函数定义时,不确定会传入多少参数的情况下非常有用。例如实现一个求和函数,可处理任意数量参数。
function sum(...nums) {
    return nums.reduce((acc, num) => acc + num, 0);
}
let result = sum(1, 2, 3);
console.log(result); 

这里 ...nums 收集了所有传递给 sum() 函数的参数,无论参数个数,都能方便处理。与 arguments 对象相比,剩余参数是真正的数组,具备数组的方法,使用更便捷直观。 - 函数参数解构:在函数参数解构时,剩余参数可收集未匹配的参数。

function showArgs([a, b,...rest]) {
    console.log(a); 
    console.log(b); 
    console.log(rest); 
}
showArgs([1, 2, 3, 4]); 

在这个例子中,a 匹配数组第一个元素 1b 匹配第二个元素 2...rest 收集剩余元素 [3, 4]

3. 两者区别

  • 使用位置:扩展运算符用于函数调用、数组字面量和对象字面量中,展开数组或对象;剩余参数仅用于函数定义的参数列表中,收集多余参数。
  • 功能侧重:扩展运算符主要是将已有数据结构展开,方便操作;剩余参数则专注于收集函数调用时传入的多余参数,便于在函数内统一处理。

eval执行字符串

2025年7月13日 14:31

在JavaScript中,eval() 函数用于计算并执行一个字符串形式的JavaScript代码。以下从多个方面介绍,适用于面试回复:

1. 基本用法

eval() 接受一个字符串参数,如果该字符串包含有效的JavaScript代码,eval() 会执行这段代码,并返回执行结果。例如:

let result = eval('2 + 3');
console.log(result); // 输出 5

这里将字符串 '2 + 3' 传递给 eval(),它会像执行普通JavaScript表达式一样计算并返回结果 5

2. 作用域影响

  • 在全局作用域调用:在全局作用域下调用 eval(),它会在全局作用域中执行代码。这意味着代码中声明的变量和函数会成为全局变量和全局函数。例如:
eval('var globalVar = 10;');
console.log(globalVar); // 输出 10

这里在全局作用域调用 eval() 创建了一个全局变量 globalVar

  • 在函数作用域调用:在函数内部调用 eval(),情况较为复杂。如果直接调用 eval(),它会在当前函数作用域执行代码,能访问和修改函数作用域内的变量。例如:
function testEval() {
    let localVar = 5;
    eval('localVar = localVar * 2;');
    console.log(localVar); // 输出 10
}
testEval();

然而,如果使用 eval 的严格模式(在严格模式的函数内),或者通过 window.eval() 调用(即使在函数内),eval() 会在全局作用域执行,无法访问函数内的局部变量。例如:

function strictEval() {
    'use strict';
    let localVar = 5;
    eval('var globalVar = localVar * 2;'); // 这里会报错,因为严格模式下eval无法访问localVar
    console.log(globalVar);
}
strictEval();

3. 安全性考量

eval() 存在安全风险,不建议随意使用。主要原因是如果传入 eval() 的字符串来自用户输入,恶意用户可能输入恶意代码,导致代码注入攻击。例如:

<input type="text" id="userInput">
<button onclick="executeEval()">执行</button>

<script>
function executeEval() {
    let userInput = document.getElementById('userInput').value;
    eval(userInput);
}
</script>

如果恶意用户在输入框中输入 alert('XSS');,点击按钮后就会触发弹窗,可能导致跨站脚本攻击(XSS),窃取用户信息等。

4. 替代方案

在大多数情况下,有更好的替代方案避免使用 eval()

  • 使用函数:如果是简单的计算,可以封装成函数。例如,替代 eval('2 + 3'),可以定义 function add(a, b) { return a + b; } 然后调用 add(2, 3)
  • 使用 new Function():当确实需要动态创建函数时,可以使用 new Function(),它相对 eval() 更安全,因为它总是在全局作用域创建函数。例如:
let addFunction = new Function('a', 'b', 'return a + b;');
let result = addFunction(2, 3);
console.log(result); // 输出 5

总之,eval() 虽然能执行字符串形式的JavaScript代码,但由于安全风险和作用域的复杂性,在实际开发中应谨慎使用,优先考虑更安全和可读的替代方法。

❌
❌