阅读视图

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

【Promise.withResolvers】发现这个api还挺有用

Jym好😘,我是珑墨。

在 es 的异步编程世界中,Promise 已经成为处理异步操作的标准方式。然而,在某些场景下,传统的 Promise 构造函数模式显得不够灵活。Promise.withResolvers 是 ES2024(ES14)中引入的一个静态方法,它提供了一种更优雅的方式来创建 Promise,并同时获得其 resolve 和 reject 函数的引用。

look:

什么是 Promise.withResolvers

Promise.withResolvers 是一个静态方法,它返回一个对象,包含三个属性:

  • promise: 一个 Promise 对象
  • resolve: 用于解决(fulfill)该 Promise 的函数
  • reject: 用于拒绝(reject)该 Promise 的函数

基本语法

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

这个方法的核心优势在于:你可以在 Promise 外部控制其状态,这在许多场景下非常有用。


为什么 Promise.withResolvers挺实用?

先看传统 Promise 的局限性

Promise.withResolvers 出现之前,如果我们想要在 Promise 外部控制其状态,通常需要这样做:

let resolvePromise;
let rejectPromise;

const myPromise = new Promise((resolve, reject) => {
  resolvePromise = resolve;
  rejectPromise = reject;
});

// 现在可以在外部使用 resolvePromise 和 rejectPromise
setTimeout(() => {
  resolvePromise('成功!');
}, 1000);

这种方法虽然可行,但存在以下问题:

  1. 代码冗余:每次都需要创建临时变量,会导致一坨地雷
  2. 作用域污染:需要在外部作用域声明变量
  3. 不够优雅:代码结构不够清晰
  4. 容易出错:如果忘记赋值,会导致运行时错误

Promise.withResolvers解决了啥?

Promise.withResolvers 解决了上述所有问题:

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

// 简洁、清晰、安全
setTimeout(() => {
  resolve('成功!');
}, 1000);

语法和用法

基本语法

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

返回值

Promise.withResolvers() 返回一个普通对象,包含:

  • promise: 一个处于 pending 状态的 Promise 对象
  • resolve: 一个函数,调用时会将 promise 变为 fulfilled 状态
  • reject: 一个函数,调用时会将 promise 变为 rejected 状态

基本示例

示例 1:简单的延迟解析

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

// 1 秒后解析 Promise
setTimeout(() => {
  resolve('数据加载完成');
}, 1000);

promise.then(value => {
  console.log(value); // 1 秒后输出: "数据加载完成"
});

示例 2:处理错误

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

// 模拟异步操作
setTimeout(() => {
  const success = Math.random() > 0.5;
  if (success) {
    resolve('操作成功');
  } else {
    reject(new Error('操作失败'));
  }
}, 1000);

promise
  .then(value => console.log(value))
  .catch(error => console.error(error));

示例 3:多次调用 resolve/reject 的行为

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

resolve('第一次');
resolve('第二次'); // 无效,Promise 状态已确定
reject(new Error('错误')); // 无效,Promise 状态已确定

promise.then(value => {
  console.log(value); // 输出: "第一次"
});

重要提示:一旦 Promise 被 resolve 或 reject,其状态就确定了,后续的 resolve 或 reject 调用将被忽略。


与传统方法的对比

场景 1:事件监听器中的 Promise

传统方法

function waitForClick() {
  let resolveClick;
  let rejectClick;
  
  const promise = new Promise((resolve, reject) => {
    resolveClick = resolve;
    rejectClick = reject;
  });
  
  const button = document.getElementById('myButton');
  const timeout = setTimeout(() => {
    button.removeEventListener('click', onClick);
    rejectClick(new Error('超时'));
  }, 5000);
  
  function onClick(event) {
    clearTimeout(timeout);
    button.removeEventListener('click', onClick);
    resolveClick(event);
  }
  
  button.addEventListener('click', onClick);
  
  return promise;
}

使用 Promise.withResolvers

function waitForClick() {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  const button = document.getElementById('myButton');
  const timeout = setTimeout(() => {
    button.removeEventListener('click', onClick);
    reject(new Error('超时'));
  }, 5000);
  
  function onClick(event) {
    clearTimeout(timeout);
    button.removeEventListener('click', onClick);
    resolve(event);
  }
  
  button.addEventListener('click', onClick);
  
  return promise;
}

优势

  • 代码更简洁
  • 不需要在外部作用域声明变量
  • 结构更清晰

场景 2:流式数据处理

传统方法

function createStreamProcessor() {
  let resolveStream;
  let rejectStream;
  
  const promise = new Promise((resolve, reject) => {
    resolveStream = resolve;
    rejectStream = reject;
  });
  
  // 模拟流式处理
  const chunks = [];
  let isComplete = false;
  
  function processChunk(chunk) {
    if (isComplete) return;
    chunks.push(chunk);
    
    if (chunk.isLast) {
      isComplete = true;
      resolveStream(chunks);
    }
  }
  
  function handleError(error) {
    if (isComplete) return;
    isComplete = true;
    rejectStream(error);
  }
  
  return { promise, processChunk, handleError };
}

使用 Promise.withResolvers

function createStreamProcessor() {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  const chunks = [];
  let isComplete = false;
  
  function processChunk(chunk) {
    if (isComplete) return;
    chunks.push(chunk);
    
    if (chunk.isLast) {
      isComplete = true;
      resolve(chunks);
    }
  }
  
  function handleError(error) {
    if (isComplete) return;
    isComplete = true;
    reject(error);
  }
  
  return { promise, processChunk, handleError };
}

实际应用场景

场景 1:用户交互等待

// 等待用户确认操作
function waitForUserConfirmation(message) {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  const modal = document.createElement('div');
  modal.className = 'confirmation-modal';
  modal.innerHTML = `
    <p>${message}</p>
    <button class="confirm">确认</button>
    <button class="cancel">取消</button>
  `;
  
  modal.querySelector('.confirm').addEventListener('click', () => {
    if (modal.parentNode) {
      document.body.removeChild(modal);
    }
    resolve(true);
  });
  
  modal.querySelector('.cancel').addEventListener('click', () => {
    if (modal.parentNode) {
      document.body.removeChild(modal);
    }
    reject(new Error('用户取消'));
  });
  
  document.body.appendChild(modal);
  
  return promise;
}

// 使用
waitForUserConfirmation('确定要删除这个文件吗?')
  .then(() => console.log('用户确认'))
  .catch(() => console.log('用户取消'));

场景 2:WebSocket 消息等待

class WebSocketManager {
  constructor(url) {
    this.ws = new WebSocket(url);
    this.pendingRequests = new Map();
    this.requestId = 0;
    
    this.ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      const { requestId, response, error } = data;
      
      const pending = this.pendingRequests.get(requestId);
      if (pending) {
        this.pendingRequests.delete(requestId);
        if (error) {
          pending.reject(new Error(error));
        } else {
          pending.resolve(response);
        }
      }
    };
  }
  
  sendRequest(message) {
    const { promise, resolve, reject } = Promise.withResolvers();
    const requestId = ++this.requestId;
    
    this.pendingRequests.set(requestId, { resolve, reject });
    
    this.ws.send(JSON.stringify({
      requestId,
      message
    }));
    
    // 设置超时
    setTimeout(() => {
      if (this.pendingRequests.has(requestId)) {
        this.pendingRequests.delete(requestId);
        reject(new Error('请求超时'));
      }
    }, 5000);
    
    return promise;
  }
}

// 使用
const wsManager = new WebSocketManager('ws://example.com');
wsManager.sendRequest('获取用户信息')
  .then(data => console.log('收到响应:', data))
  .catch(error => console.error('错误:', error));

场景 3:文件上传进度

function uploadFileWithProgress(file, url) {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  const xhr = new XMLHttpRequest();
  const formData = new FormData();
  formData.append('file', file);
  
  xhr.upload.addEventListener('progress', (event) => {
    if (event.lengthComputable) {
      const percentComplete = (event.loaded / event.total) * 100;
      console.log(`上传进度: ${percentComplete.toFixed(2)}%`);
    }
  });
  
  xhr.addEventListener('load', () => {
    if (xhr.status === 200) {
      resolve(JSON.parse(xhr.responseText));
    } else {
      reject(new Error(`上传失败: ${xhr.status}`));
    }
  });
  
  xhr.addEventListener('error', () => {
    reject(new Error('网络错误'));
  });
  
  xhr.addEventListener('abort', () => {
    reject(new Error('上传已取消'));
  });
  
  xhr.open('POST', url);
  xhr.send(formData);
  
  // 返回 Promise 和取消函数
  return {
    promise,
    cancel: () => xhr.abort()
  };
}

// 使用
const { promise, cancel } = uploadFileWithProgress(file, '/api/upload');
promise
  .then(result => console.log('上传成功:', result))
  .catch(error => console.error('上传失败:', error));

场景 4:可取消的异步操作

function createCancellableOperation(operation) {
  const { promise, resolve, reject } = Promise.withResolvers();
  let cancelled = false;
  
  operation()
    .then(result => {
      if (!cancelled) {
        resolve(result);
      }
    })
    .catch(error => {
      if (!cancelled) {
        reject(error);
      }
    });
  
  return {
    promise,
    cancel: () => {
      cancelled = true;
      reject(new Error('操作已取消'));
    }
  };
}

// 使用
const { promise, cancel } = createCancellableOperation(
  () => fetch('/api/data').then(r => r.json())
);

// 3 秒后取消
setTimeout(() => cancel(), 3000);

promise
  .then(data => console.log('数据:', data))
  .catch(error => console.error('错误:', error));

场景 5:队列处理

class TaskQueue {
  constructor() {
    this.queue = [];
    this.processing = false;
  }
  
  add(task) {
    const { promise, resolve, reject } = Promise.withResolvers();
    
    this.queue.push({
      task,
      resolve,
      reject
    });
    
    this.process();
    
    return promise;
  }
  
  async process() {
    if (this.processing || this.queue.length === 0) {
      return;
    }
    
    this.processing = true;
    
    while (this.queue.length > 0) {
      const { task, resolve, reject } = this.queue.shift();
      
      try {
        const result = await task();
        resolve(result);
      } catch (error) {
        reject(error);
      }
    }
    
    this.processing = false;
  }
}

// 使用
const queue = new TaskQueue();

queue.add(() => fetch('/api/task1').then(r => r.json()))
  .then(result => console.log('任务1完成:', result));

queue.add(() => fetch('/api/task2').then(r => r.json()))
  .then(result => console.log('任务2完成:', result));

深入理解:工作原理

Promise.withResolvers 的实现原理

虽然 Promise.withResolvers 是原生 API,但我们可以通过理解其等价实现来加深理解:

// Promise.withResolvers 的等价实现
function withResolvers() {
  let resolve, reject;
  const promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
  });
  return { promise, resolve, reject };
}

内存和性能考虑

Promise.withResolvers 的实现是高度优化的。它:

  1. 避免闭包开销:原生实现避免了额外的闭包创建
  2. 内存效率:直接返回引用,无需额外的变量存储
  3. 性能优化:浏览器引擎级别的优化

与 Promise 构造函数的关系

// 这两种方式是等价的(在功能上)
const { promise, resolve, reject } = Promise.withResolvers();

// 等价于
let resolve, reject;
const promise = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});

Promise.withResolvers 提供了:

  • 更简洁的语法
  • 更好的可读性
  • 标准化的 API

浏览器兼容性和 Polyfill

浏览器支持

Promise.withResolvers 是 ES2024 的特性,目前(2024年)的支持情况:

  • ✅ Chrome 119+
  • ✅ Firefox 121+
  • ✅ Safari 17.4+
  • ✅ Node.js 22.0.0+
  • ❌ 旧版本浏览器不支持

Polyfill 实现

如果需要在不支持的浏览器中使用,可以使用以下 polyfill:

if (!Promise.withResolvers) {
  Promise.withResolvers = function() {
    let resolve, reject;
    const promise = new Promise((res, rej) => {
      resolve = res;
      reject = rej;
    });
    return { promise, resolve, reject };
  };
}

使用 Polyfill 的完整示例

// 在项目入口文件添加
(function() {
  if (typeof Promise.withResolvers !== 'function') {
    Promise.withResolvers = function() {
      let resolve, reject;
      const promise = new Promise((res, rej) => {
        resolve = res;
        reject = rej;
      });
      return { promise, resolve, reject };
    };
  }
})();

// 现在可以在任何地方使用
const { promise, resolve, reject } = Promise.withResolvers();

使用 core-js

如果你使用 core-js,可以导入相应的 polyfill:

import 'core-js/actual/promise/with-resolvers';

最佳实践和注意

1. 避免重复调用 resolve/reject

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

resolve('第一次');
resolve('第二次'); // 无效,但不会报错

// 最佳实践:添加状态检查
let isResolved = false;
function safeResolve(value) {
  if (!isResolved) {
    isResolved = true;
    resolve(value);
  }
}

2. 处理错误情况

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

try {
  // 某些可能抛出错误的操作
  const result = riskyOperation();
  resolve(result);
} catch (error) {
  reject(error);
}

3. 清理资源

function createResourceManager() {
  const { promise, resolve: originalResolve, reject: originalReject } = Promise.withResolvers();
  const resources = [];
  
  function cleanup() {
    resources.forEach(resource => resource.cleanup());
  }
  
  // 创建包装函数,确保在 resolve 或 reject 时清理资源
  const resolve = (value) => {
    cleanup();
    originalResolve(value);
  };
  
  const reject = (error) => {
    cleanup();
    originalReject(error);
  };
  
  return { promise, resolve, reject };
}

4. 类型安全(TypeScript)

在 TypeScript 中,Promise.withResolvers 的类型定义:

interface PromiseWithResolvers<T> {
  promise: Promise<T>;
  resolve: (value: T | PromiseLike<T>) => void;
  reject: (reason?: any) => void;
}

// 使用
const { promise, resolve, reject }: PromiseWithResolvers<string> = 
  Promise.withResolvers<string>();

5. 避免内存泄漏

// 不好的做法:持有大量未完成的 Promise,没有清理机制
const pendingPromises = new Map();

function createRequest(id) {
  const { promise, resolve } = Promise.withResolvers();
  pendingPromises.set(id, { promise, resolve });
  return promise;
  // 问题:如果 Promise 永远不会 resolve,会一直占用内存
}

// 好的做法:设置超时和清理机制
const pendingPromises = new Map(); // 在实际应用中,这应该是类或模块级别的变量

function createRequestWithTimeout(id, timeout = 5000) {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  const timeoutId = setTimeout(() => {
    if (pendingPromises.has(id)) {
      pendingPromises.delete(id);
      reject(new Error('请求超时'));
    }
  }, timeout);
  
  pendingPromises.set(id, {
    promise,
    resolve: (value) => {
      clearTimeout(timeoutId);
      pendingPromises.delete(id);
      resolve(value);
    },
    reject: (error) => {
      clearTimeout(timeoutId);
      pendingPromises.delete(id);
      reject(error);
    }
  });
  
  return promise;
}

6. 与 async/await 结合使用

async function processWithResolvers() {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  // 在异步操作中控制 Promise
  setTimeout(() => {
    resolve('完成');
  }, 1000);
  
  try {
    const result = await promise;
    console.log('结果:', result);
  } catch (error) {
    console.error('错误:', error);
  }
}

总结下

Promise.withResolvers 是 es 异步编程的一个重要补充,它解决了在 Promise 外部控制其状态的需求。通过本文的详细讲解,我们了解到:

核心要点

  1. 简洁性:提供了更优雅的 API 来创建可外部控制的 Promise
  2. 实用性:在事件处理、流式处理、WebSocket 等场景中非常有用
  3. 标准化:作为 ES2024 标准的一部分,提供了统一的解决方案

适用场景

  • ✅ 需要在 Promise 外部控制其状态
  • ✅ 事件驱动的异步操作
  • ✅ 流式数据处理
  • ✅ 可取消的异步操作
  • ✅ 队列和任务管理

注意

  • ⚠️ 浏览器兼容性(需要 polyfill 或现代浏览器)
  • ⚠️ 尤其得避免重复调用 resolve/reject
  • ⚠️ 注意资源清理和内存管理

参考资料


原来Webpack在大厂中这样进行性能优化!

性能优化方案

优化分类:

  1. 优化打包后的结果(分包、减小包体积、CDN 服务器) ==> 更重要
  2. 优化打包速度(exclude、cache-loader)

代码分割(Code Splitting)

一、主要目的

  • 减少首屏加载体积:避免一次性加载全部代码
  • 利用浏览器缓存:第三方库(如 React、Lodash)变动少,可单独缓存
  • 按需加载/并行请求:路由、组件、功能模块只在需要时加载(按需加载或者并行加载文件,而不是一次性加载所有代码)

二、三种主要的代码分割方式

1. 入口起点(Entry Points)手动分割

通过配置多个 entry 实现。

// webpack.config.js
module.exports = {
  entry: {
    main: './src/main.js',
    vendor: './src/vendor.js', // 手动引入公共依赖
  },
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
  },
};

缺点:

  • 无法自动提取公共依赖(比如 mainvendor 都用了 Lodash,会重复打包)
  • 维护成本高

上面写的是通用配置,但我们在公司一般会分别配置开发和生产环境的配置。大多数项目中,entry 在 dev 和 prod 基本一致,无需差异化配置。差异主要体现在 output 和其他插件/加载器行为上。

// webpack.config.prod.js
module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    filename: 'js/[name].[contenthash:8].js', // 生产环境用 [contenthash](而非 [hash] 或 [chunkhash]),确保精准缓存
    chunkFilename: 'js/[name].[contenthash:8].js',
    path: path.resolve(__dirname, 'dist'), // 必须输出到磁盘用于部署
    publicPath: '/static/', // 用于 CDN 或静态资源服务器
    clean: true, // 清理旧文件
  },
};
// webpack.config.dev.js
module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    filename: 'js/[name].js',  // 开发环境若加 hash,每次保存都会生成新文件,可能干扰热更新或者devtools混乱
    chunkFilename: 'js/[name].js',
    path: path.resolve(__dirname, 'dist'), // 通常仍写 dist,但实际不写入磁盘(webpack-dev-server 默认内存存储),节省IO,提高编译速度
    publicPath: '/', // 与 devServer 一致
    // clean: false (默认)
  },
};
2. SplitChunksPlugin(推荐!自动代码分割)

自动提取公共模块和第三方库。webpack 已默认安装相关插件。

默认行为(仅在 production 模式生效):

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'async', // 默认只分割异步模块
    },
  },
};

常用配置:

// webpack.config.prod.js
optimization: { 
  // 自动分割
  // https://twitter.com/wSokra/status/969633336732905474
  // https://medium.com/webpack/webpack-4-code-splitting-chunk-graph-and-the-splitchunks-optimization-be739a861366    
  splitChunks: {
    // chunks: async | initial(对通过的代码处理) | all(同步+异步都处理)
    chunks: 'initial',
    minSize: 20000, // 模块大于 20KB 才分割(Webpack 5 默认值)
    maxSize: 244000, // 单个 chunk 最大不超过 244KB(可选)
    cacheGroups: { // 拆分分组规则
      // 提取 node_modules 中的第三方库
      vendor: {
        test: /[\\/]node_modules[\\/]/, // 匹配符合规则的包
        name: 'vendors', // 拆分包的name 属性
        chunks: 'initial',
        priority: 10, // 优先级高于 default
        enforce: true,
      },
      // 提取多个 chunk 公共代码
      default: {
        minChunks: 2, // 至少被 2 个 chunk 引用
        priority: -20,
        reuseExistingChunk: true, // 复用已存在的 chunk
        maxInitialRequests: 5, // 默认限制太小,无法显示效果
        minSize: 0, // 这个示例太小,无法创建公共块
      },
    },
  },
  // runtime相关的代码是否抽取到一个单独的chunk中,比如import动态加载的代码就是通过runtime 代码完成的
  // 抽离出来利于浏览器缓存,比如修改了业务代码,那么runtime加载的chunk无需重新加载
  runtimeChunk: true,
}

在开发环境下 splitChunks: false, 即可。

生产环境:

  • 生成 vendors.xxxx.js(第三方库)
  • 生成 default.xxxx.js(项目公共代码)
  • 主 bundle 体积显著减小
3. 动态导入(Dynamic Imports)—— 按需加载

使用 import() 语法(符合 ES Module 规范),实现懒加载。

Webpack 会为每个 import() 创建一个独立的 chunk,并自动处理加载逻辑。

三、魔法注释(Magic Comments)—— 控制 chunk 名称等行为

// 自定义 chunk 名称(便于调试和长期缓存)
const module = await import(
  /* webpackChunkName: "my-module" */
  './my-module'
);

其他常见注释:

  • /* webpackPrefetch: true */:空闲时预加载(提升后续访问速度)
  • /* webpackPreload: true */:当前导航关键资源预加载(慎用)
// 预加载“下一个可能访问”的页面
import(
  /* webpackChunkName: "login-page" */
  /* webpackPrefetch: true */
  './LoginPage'
);

详细比较:

  • preload chunk 会在父 chunk 加载时,以并行方式开始加载。prefetch chunk 会在父 chunk 加载结束后开始加载。
  • preload chunk 具有中等优先级,并立即下载。prefetch chunk 在浏览器闲置时下载。

CND

内容分发网络(Content Delivery Network 或 Content Distribution Network)

它是指通过相互连接的网络系统,利用最靠近每个用户的服务器;更快、更可靠地将音乐、图片、视频、应用程序及其他文件发送给用户;提供高性能、可扩展性及低成本的网络内容传递。

工作中,我们使用 CDN 的主要方式有两种:

  1. 打包所有静态资源,放到 CDN 服务器,用户所有资源都是通过 CND 服务器加载的
    1. 通过 output.publicPath 改为自己的的 CDN 服务器,打包后就可以从上面获取资源
    2. 如果是自己的话,一般会从阿里、腾讯等买 CDN 服务器。
  2. 一些第三方资源放在 CDN 服务器上
    1. 一些库/框架会将打包后的源码放到一些免费的 CDN 上,比如 JSDeliver、bootcdn 等
    2. 这样的话,打包的时候就不需要对这些库进行打包,直接使用 CDN 服务器中的源码(通过 externals 配置排除某些包)

CSS 提取

将 css 提取到一个独立的 css 文件。

npm install mini-css-extract-plugin -D
// webpack.config.prod.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  mode: 'production',
  module: {
    rules: [
      // 生产环境:使用 MiniCssExtractPlugin.loader
      {
        test: /\.css$/i,
        use: [
          MiniCssExtractPlugin.loader, // 替换 style-loader
          'css-loader',
          'postcss-loader',
        ],
      },
      {
        test: /\.s[ac]ss$/i,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
          'postcss-loader',
          'sass-loader',
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: 'css/[name].[contenthash:8].css',
      chunkFilename: 'css/[name].[contenthash:8].css',
    }),
  ],
};

Terser 代码压缩

Terser 可以帮助我们压缩、丑化(混淆)我们的代码,让我们的 bundle 变得更小。

Terser 是一个单独的工具,拥有非常多的配置,这里我们只讲工作中如何使用,以一个工程的角度学习这个工具。

真实开发中,我们不需要手动的通过 terser 来处理我们的代码。webpack 中 minimizer 属性,在 production 模式下,默认就是使用的 TerserPlugin 来处理我们代码的。我们也可以手动创建 TerserPlugin 实例覆盖默认配置。

// webpack.prod.js 
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  mode: 'production',
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        parallel: true, // 多核 CPU 并行压缩,默认为true,并发数默认为os.cpus().length-1
        terserOptions: {
          compress: { // 压缩配置
            drop_console: true,
            drop_debugger: true, // 删除debugger
            pure_funcs: ['console.info', 'console.debug'], // 只删除特定的函数调用
          },
          mangle: true, // 是否丑化代码(变量)
          toplevel: true, // 顶层变量是否进行转换
          keep_classnames: true, // 是否保留类的名称
          keep_fnames: true, // 是否保留函数的名称
          format: {
            comments: /@license|@preserve/i, // 保留含 license/preserve 的注释(某些开源库要求保留版权注释)
          },
        },
        extractComments: true, // 默认为true会将注释提取到一个单独的文件(这里用于保留版权注释),false表示不希望保留注释
        sourceMap: true,   // 需要 webpack 配置 devtool 生成 source map
      }),
    ],
  },
};

不要在开发环境启动 terser,因为:

  • 压缩会拖慢构建速度
  • 混淆后的代码无法调试
  • hmr 和 source-map 会失效

CSS 压缩

CSS 压缩通常是去除无用的空格等,因为很难去修改选择器、属性的名称、值等;我们一般使用插件 css-minimizer-webpack-plugin;他的底层是使用 cssnano 工具来优化、压缩 CSS(也可以单独使用)。

使用也是非常简单:

minimizer: [
  new CssMiniMizerPlugin()({
    parallel: true
  })
]

Tree Shaking 摇树

详情见之前文章:《简单聊聊 webpack 摇树的原理》

HTTP 压缩

HTTP 压缩(HTTP Compression)是一种 在服务器和客户端之间传输数据时减小响应体体积 的技术,通过压缩 HTML、CSS、JavaScript、JSON 等文本资源,显著提升网页加载速度、节省带宽。

一、主流压缩算法

算法 兼容性 压缩率 速度 说明
gzip ✅ 几乎所有浏览器(IE6+) 最广泛使用,Web 标准推荐
Brotli (br) ✅ 现代浏览器(Chrome 49+, Firefox 44+, Safari 11+) ⭐ 更高(比 gzip 高 15%~30%) 较慢(压缩),解压快 推荐用于静态资源
deflate ⚠️ 支持不一致(部分浏览器实现有问题) 已基本淘汰,不推荐使用

二、工作原理(协商压缩)

HTTP 压缩基于 请求头 ↔ 响应头协商机制:

  1. 客户端请求(表明支持的压缩格式)
GET /app.js HTTP/1.1
Host: example.com
Accept-Encoding: gzip, deflate, br // 客户端支持的压缩算法列表
  1. 服务端响应(返回压缩后的内容)
HTTP/1.1 200 OK
Content-Encoding: br  // 服务端使用的压缩算法
Content-Type: application/javascript
Content-Length: 102400  // 注意:这是压缩后的大小!

...(二进制压缩数据)...
  • 浏览器自动解压,开发者无感知

三、如何启用 HTTP 压缩?

我们一般会优先使用 Nginx 配置做压缩(生产环境最常用),这样就无需应用层处理。

除此之外,我们还会进行预压缩 + 静态文件服务,这主要就是 webpack 要做的工作。

在构建阶段(Webpack/Vite)就生成 .gz.br 文件,部署到 CDN 或静态服务器。

// webpack.config.js
const CompressionPlugin = require('compression-webpack-plugin');

module.exports = {
  plugins: [
    // 生成 .gz 文件
    new CompressionPlugin({
      algorithm: 'gzip',
      test: /\.(js|css|html|svg)$/,
      threshold: 8192, // 大于 8KB 才压缩
      minRatio: 0.8,  // 至少的压缩比例
    }),
    // 生成 .br 文件(需额外安装)
    new CompressionPlugin({
      algorithm: 'brotliCompress',
      test: /\.(js|css|html|svg)$/,
      compressionOptions: { level: 11 }, // 最高压缩率
    }),
  ],
};

Nginx 配合预压缩文件:

gzip_static on;    # 优先返回 .gz 文件
brotli_static on;  # 优先返回 .br 文件

打包分析

打包时间分析

我们需要借助一个插件 speed-measure-webpack-plugin,即可看到每个 loader、每个 plugin 消耗的打包时间。

// webpack.config.js
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');

const smp = new SpeedMeasurePlugin();

const config = {
  // 你的正常 Webpack 配置
  entry: './src/index.js',
  module: { /* ... */ },
  plugins: [ /* ... */ ],
};

// 仅当环境变量 ANALYZE_SPEED=1 时包裹配置
module.exports = process.env.ANALYZE_SPEED ? smp.wrap(config) : config;

打包文件分析

方法一、生成 stats.json 文件
"build:stats": "w--config ./config/webpack.common.js --env production --profile --json=stats.json",

运行 npm run build:stats,可以获取到一个 stats.json 文件,然后放到到 webpack.github.com/analyse 进行分析。

方法二、webpack-bundle-analyzer

更常用的方式是使用 webpack-bundle-analyzer 插件分析。

// webpack.prod.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  mode: 'production',
  plugins: [
    // 其他插件...
    new BundleAnalyzerPlugin({
      analyzerMode: 'static', // 生成静态 HTML 报告(默认)
      openAnalyzer: false,    // 不自动打开浏览器
      reportFilename: 'bundle-report.html',
      generateStatsFile: true, // 可选:同时生成 stats.json
      statsFilename: 'stats.json',
    }),
  ],
};

MCP理论和实战,然后做个MCP脚手架吧

引言: 本文介绍了目前MCP Server的开发方式和原理,包括streamable HTTP和STDIO两种。并提供了一个npm脚手架工具帮你创建项目,每个模板项目都是可运行的。

streamable HTTP

原理分析

抓包「握手」

MCP Client总共发了三次请求,MCP Server响应2次。实际的握手流程是4次握手,第5次请求是为了通知后续的信息(比如进度,日志等。 目前规范实现来看,第5次握手不影响正常功能)

使用wiresshark抓包结果如下:

image.png

image.png

从官网的「initialization」流程来看,也就是4次(第5次未来应该会被普遍实现)

image.png

第1次 Post请求,initialize 方法

{
  "jsonrpc": "2.0",
  "id": 0,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-06-18",
    "capabilities": {
      "sampling": {},
      "elicitation": {},
      "roots": {
        "listChanged": true
      }
    },
    "clientInfo": {
      "name": "inspector-client",
      "version": "0.17.2"
    }
  }
}

第2次 :200 OK,响应体如下

{
  "result": {
    "protocolVersion": "2025-06-18",
    "capabilities": {
      "tools": {
        "listChanged": true
      }
    },
    "serverInfo": {
      "name": "weather",
      "version": "0.0.1"
    }
  },
  "jsonrpc": "2.0",
  "id": 0
}

第3次 :Post请求,notifications/initialized方法

{"jsonrpc":"2.0","method":"notifications/initialized"}

第4次 :202 Accepted,无响应体

第5次 :Get请求,此时要求服务端一定是SSE传输了-accept: text/event-stream

GET /mcp HTTP/1.1
accept: text/event-stream

总结「握手」流程

  1. POST /mcp (initialize)

    • 客户端:你好,我是 Inspector Client,我想初始化。
    • 服务器:收到,这是我的能力列表(200 OK)。
    • 状态:JSON-RPC 会话开始。
  2. POST /mcp (notifications/initialized)

    • 客户端:我已经收到你的能力了,初始化完成。
    • 服务器:收到 (202 Accepted)。
    • 状态:逻辑握手完成。
  3. GET /mcp (Header: accept: text/event-stream)

    • 目的:客户端现在试图建立长连接通道,以便在未来能收到服务器发来的通知(比如 notifications/message 或 roots/listChanged)。如果没有这个通道,服务器就变成了“哑巴”,无法主动联系客户端。

后续通信

tools/list (列出工具)

client->server 请求

请求头:

POST /mcp HTTP/1.1
accept: application/json, text/event-stream
accept-encoding: gzip, deflate, br
content-length: 85
content-type: application/json
mcp-protocol-version: 2025-06-18
user-agent: node-fetch
Host: localhost:3000
Connection: keep-alive

请求数据:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {
    "_meta": {
      "progressToken": 1
    }
  }
}

P.S. params中的progressToken是可以用于后续的进度通知的(通过SSE)

server->client 响应

响应头:

HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json
Date: Thu, 27 Nov 2025 11:52:31 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

响应体:

{
  "result": {
    "tools": [
      {
        "name": "get_weather_now",
        "title": "Get Weather Now",
        "description": "Get current weather for a location (city name)",
        "inputSchema": {
          "$schema": "http://json-schema.org/draft-07/schema#",
          "type": "object",
          "properties": {
            "location": {
              "description": "Location name or city (e.g. beijing, shanghai, new york, tokyo)",
              "type": "string"
            }
          },
          "required": [
            "location"
          ]
        }
      }
    ]
  },
  "jsonrpc": "2.0",
  "id": 1
}

这里列出一个工具:

  • get_weather_now,我们自己定义/注册的工具。我们可以拿到它的titledescriptioninputSchema,这些语义信息可以帮助LLM理解这个工具。
tools/call (调用tool)

这里通过 mcp inspector 工具调用了get_weather_now,请求体如下:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "_meta": {
      "progressToken": 2
    },
    "name": "get_weather_now",
    "arguments": {
      "location": "北京"
    }
  }
}

响应体:

{
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Weather for 北京, CN:\nCondition: 晴\nTemperature: 3°C\nLast Update: 2025-11-27T19:50:14+08:00"
      }
    ]
  },
  "jsonrpc": "2.0",
  "id": 2
}
方法小总结

上面我们列出了两种常见的方法

  • tools/list。MCP Client在向LLM发请求携带列出的tool,LLM会告诉客户端调用的tool name,然后由MCP client来触发tool调用。
  • tools/call。MCP Client告诉MCP Server 调用哪个tool。

可以结合官网的这张示意图,调用tool就是一次request/response。如果是长任务,可以通过_meta.progressToken作为关联,通过SSE持续通知进度(还记得「握手」流程的第5次握手吗)

image.png

代码实战 - 天气工具

准备天气API

这里我使用了心知天气的API,然后自己封装一个node API。 src/core/seniverse.ts

import * as crypto from 'node:crypto';
import * as querystring from 'node:querystring';
/**
 * 查询天气接口
 */
const API_URL = 'https://api.seniverse.com/v3/';
export class SeniverseApi {
    publicKey;
    secretKey;
    constructor(publicKey, secretKey) {
        this.publicKey = publicKey;
        this.secretKey = secretKey;
    }
    async getWeatherNow(location) {
        const params = {
            ts: Math.floor(Date.now() / 1000), // Current timestamp (seconds)
            ttl: 300, // Expiration time
            public_key: this.publicKey,
            location: location
        };
        // Step 2: Sort keys and construct the string for signature
        // "key=value" joined by "&", sorted by key
        const sortedKeys = Object.keys(params).sort();
        const str = sortedKeys.map(key => `${key}=${params[key]}`).join('&');
        // Step 3: HMAC-SHA1 signature
        const signature = crypto
            .createHmac('sha1', this.secretKey)
            .update(str)
            .digest('base64');
        // Step 4 & 5: Add sig to params and encode for URL
        // querystring.encode will handle URL encoding of the signature and other params
        params.sig = signature;
        const queryString = querystring.encode(params);
        const url = `${API_URL}weather/now.json?${queryString}`;
        try {
            const response = await fetch(url);
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            return await response.json();
        }
        catch (error) {
            console.error("Error making Seniverse request:", error);
            return null;
        }
    }
}

src/core/index.ts

import { SeniverseApi } from './seniverse.js';

export const seniverseApi = new SeniverseApi(
  process.env.SENIVERSE_PUBLIC_KEY || '',
  process.env.SENIVERSE_SECRET_KEY || '',
);

搭建streamable HTTP类型的MCP

1.使用express提供后端服务,然后设置/mcp endpoint(一般来说MCP client默认就是访问这个endpoint). 2.在MCP协议中,握手/工具调用等都是通过这个一个endpoint来完成的。

3.封装逻辑 封装了一个MyServer

  • run方法启动HTTP服务
  • init方法注册工具

4.核心是McpServerStreamableHTTPServerTransport两个API

  • McpServer: 负责注册tool.
  • StreamableHTTPServerTransport: 接管了/mcp endpoint的通信逻辑
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
import { z } from "zod";
import "dotenv/config";
import { seniverseApi } from "./core/index.js";

export class MyServer {
  private mcpServer: McpServer;
  private app: express.Express
  constructor() {
    this.mcpServer = new McpServer({
      name: "weather",
      version: "0.0.1",
    });

    // Set up Express and HTTP transport
    this.app = express();
    this.app.use(express.json());

    this.app.use('/mcp', async (req: express.Request, res: express.Response) => {
        // Create a new transport for each request to prevent request ID collisions
        const transport = new StreamableHTTPServerTransport({
            sessionIdGenerator: undefined,
            enableJsonResponse: true
        });

        res.on('close', () => {
            transport.close();
        });

        await this.mcpServer.connect(transport);
        await transport.handleRequest(req, res, req.body);
    });

  }

  /**
   * 在端口运行Server, 通过HTTP stream传输数据
   */
  async run(): Promise<void> {
    const port = parseInt(process.env.PORT || '3000');
    this.app.listen(port, () => {
        console.log(`Demo MCP Server running on http://localhost:${port}/mcp`);
    }).on('error', error => {
        console.error('Server error:', error);
        process.exit(1);
    });
    
  }

  /**
   * 初始化,注册工具
   */
  async init(): Promise<void> {
    // Register weather tool
    this.mcpServer.registerTool(
      "get_weather_now",
      {
        title: "Get Weather Now",
        description: "Get current weather for a location (city name)",
        inputSchema: {
          location: z.string().describe("Location name or city (e.g. beijing, shanghai, new york, tokyo)")
        }
      },
      async ({ location }) => {
        
        const weatherData = await seniverseApi.getWeatherNow(location);
        if (!weatherData || !weatherData.results || weatherData.results.length === 0) {
          return {
            content: [
              {
                type: "text",
                text: `Failed to retrieve weather data for location: ${location}. Please check the location name and try again.`,
              },
            ],
          };
        }

        const result = weatherData.results[0];
        const weatherText = `Weather for ${result.location.name}, ${result.location.country}:\n` +
                            `Condition: ${result.now.text}\n` +
                            `Temperature: ${result.now.temperature}°C\n` +
                            `Last Update: ${result.last_update}`;
        return {
          content: [
            {
              type: "text",
              text: weatherText,
            },
          ],
        };
      },
    );
  }
}

效果如下:

image.png

注意左侧侧边栏:

  • Transport Type选择Streamable HTTP
  • URL 填写你的express 服务地址和endpoint。

stdio

原理分析

我在项目中,通过监听process.stdin,查看通信Message

// 监听 stdin 输入,可以在inspector面板的"notifications/message"中看到(作为debug用)
    process.stdin.on("data", async (data) => {
      const input = data.toString().trim();
      console.error(input);
    });

通过mcp-inspector工具就可以观察到通信信息了,往下看👁

tools/list

image.png

tools/call

image.png

结合官网的stdio通信原理图

image.png

可以总结如下:

  • 连接一个stdio MCP服务,不同于streamable HTTP MCP服务需要进行「握手」,只要开启一个子进程(subprocess),就表示连接成功。
  • 后续的通信的信息格式遵循json-rpc:2.0,通过读写process.stdinprocess.stdout完成通信。

代码实战 - 统计文件数

比较简单,可以参考我的这篇博客 Node写MCP入门教程,基于StdioServerTransport实现的统计目录下文件夹的MCP Server,并且介绍了mcp inspector的调试和Trae安装使用。

创建MCP项目的脚手架

每次写个新MCP Server都要搭建项目模板,这种重复的工作当然该做成工具辣! 我自己写了一个create-mcp脚手架 Githubcreate-mcp cli工具已经发布在npm上了,可以npm安装使用。

cli 原理

1.脚手架原理,首先准备两个模板项目

  • template-stdio 模板
  • template-streamable 模板

2.然后用Node写一个cli工具,使用了以下依赖,通过命令行交互的方式创建项目

pnpm i minimist prompts fs-extra chalk

3.根据你选择的项目名称和模板,帮你拷贝模板,修改为你的「项目名称」

觉得这个cli项目不错的话,给个免费的star吧~ 👉 Github

使用 cli

使用@caikengren-cli/create-mcp创建项目

npx @caikengren-cli/create-mcp

image.png

然后依次分别运行下面两个命令

# 编译ts/运行node
pnpm dev

# 打开 mcp-inspector工具调试
pnpm inspect

参考

mcp官网
mcp中文网
mcp: typescript-sdk

搭建简易版monorepo + turborepo

背景

  • 项目结构:pnpm Monorepo
    • packages/ui:React 组件库(使用 Vite + TS 打包)
    • apps/react-demo:React 应用,依赖 @my-org/ui
  • 目标:
    • ✅ 开发环境:修改 ui 源码 → 自动热更新到 react-demo
    • ✅ 生产构建:react-demo 能正确打包 @my-org/ui

遇到的问题 & 解决方案

❌ 问题 1:生产构建时报错 —— @my-org/ui 无法解析

// 错误信息
[vite]: Rolldown failed to resolve import "@my-org/ui" ...

🔎 根本原因:

  • react-demonode_modules/@my-org/ui/没有 ****dist/ ****目录
  • 导致 Vite 找不到 JS 入口文件(如 ui.js

✅ 解决步骤:

  1. 确认 ****packages/ui/package.json ****包含 ****"files": ["dist"]
    → 否则 pnpm 不会把 dist 链接到 consumer 的 node_modules
  2. 先构建 UI 库

pnpm --filter @my-org/ui build
  1. 确保 ****react-demo ****声明了依赖

pnpm add @my-org/ui@workspace:* --filter react-demo
  1. 强制刷新链接

pnpm install --force

💡 关键认知:pnpm workspace 链接 ≠ 实时目录映射,它只链接 package.json 中声明的文件(通过 files),且需在 dist 存在后执行 install

❌ 问题 2:package.json 入口文件名与实际输出不一致

  • 配置写的是:

"main": "./dist/index.js"
  • 但 Vite 默认输出:

dist/ui.js
dist/ui.mjs

🔎 后果:

  • 即使 dist 被链接,Vite 仍尝试加载不存在的 index.js → 模块解析失败

✅ 解决方案(二选一):

方案 操作
A(推荐) 修改 package.json指向真实文件: "main": "./dist/ui.js"
B 修改 vite.config.ts强制输出 index.jsfileName: (format) => index.${format === 'es' ? 'mjs' : 'js'}``

✅ 最终选择 方案 A,避免改构建配置,更简单直接。

❌ 问题 3:即使 files 正确,dist 仍不出现

运行 pnpm install --force 后,node_modules/@my-org/ui/dist 依然不存在。

🔎 深层原因:

  • react-demo/package.json 未声明对 ****@my-org/ui ****的依赖
  • pnpm 不会自动链接未声明的 workspace 包

✅ 解决:


pnpm add @my-org/ui@workspace:* --filter react-demo

→ 显式建立依赖关系,pnpm 才会创建 symlink 并包含 dist/

📌 这是 Monorepo 的核心规则: “未声明 = 不存在”

✅ 问题 4:开发环境如何实现热更新?

生产构建成功后,需支持开发时实时编辑 ui 组件。

✅ 解决方案:

  1. ****react-demo/vite.config.ts ****中添加 alias

resolve: {
  alias: {
    '@my-org/ui': path.resolve(__dirname, '../../packages/ui/src')
  }
}
  1. 启动开发服务器

pnpm --filter react-demo dev

💡 原理:Vite 直接编译 ui/src 源码(而非 dist),天然支持 HMR 和 TSX。


🧪 验证清单(最终状态)

检查项 命令 预期结果
UI 库已构建 ls packages/ui/dist ui.js, ui.mjs, *.d.ts
依赖已声明 grep "@my-org/ui" apps/react-demo/package.json "@my-org/ui": "workspace:*"
链接已同步 ls apps/react-demo/node_modules/@my-org/ui/dist 文件存在
生产构建成功 pnpm --filter react-demo build 无报错,生成 dist/
开发热更新 修改 ui/src/Button.tsx→ 浏览器自动刷新 ✅ 实时生效

📚 经验总结

场景 关键配置
生产构建 package.jsonmain/module必须匹配真实文件名 + "files": ["dist"]
依赖链接 Consumer 必须在 package.json中显式声明 workspace:*依赖
开发体验 通过 Vite alias指向 src,绕过 dist,实现 HMR
构建顺序 build ui→ 再 pnpm install→ 最后 build app

🔄记住这张图,脑子跟着浏览器的事件循环(Event Loop)转起来了

一、前言

下面按照我的理解,纯手工画了一张在浏览器执行JavaScript代码的Event Loop(事件循环) 流程图。

后文会演示几个例子,把示例代码放到这个流程图演示其执行流程。

当然,这只是简单的事件循环流程,不过,却能让我们快速掌握其原理。

Event Loop.png

二、概念

事件循环JavaScript为了处理单线程执行代码时,能异步地处理用户交互、网络请求等任务 (异步Web API),而设计的一套任务调度机制。它就像一个永不停止的循环,不断地检查(结合上图就是不断检查Task QueueMicrotask Queue这两个队列)并需要运行的代码。


三、为什么需要事件循环

JavaScript是单线程的,这意味着它只有一个主线程来执行代码。如果所有任务(比如一个耗时的计算、一个网络请求)都同步执行,那么浏览器就会被卡住,无法响应用户的点击、输入,直到这个任务完成。这会造成极差的用户体验。

事件循环就是为了解决这个问题而生的:它让耗时的操作(如网络请求、文件读取)在后台异步执行,等这些操作完成后,再通过回调的方式来执行相应的代码,从而不阻塞主线程

四、事件循环流程图用法演示

演示一:小菜一碟

先来一个都是同步代码的小菜,先了解一下前面画的流程图是怎样在调用栈当中执行JavaScript代码的。

console.log(1)

function funcOne() {
  console.log(2)
}

function funcTwo() {
  funcOne()
  console.log(3)
}

funcTwo()

console.log(4)

控制台输出:

1 2 3 4

下图为调用栈执行流程

演示01.png

每执行完一个同步任务会把该任务进行出栈。在这个例子当中每次在控制台输出一次,则进行一次出栈处理,直至全部代码执行完成。

演示二:小试牛刀

setTimeout+Promise组合拳,了解异步代码是如何进入任务队列等待执行的。

console.log(1)

setTimeout(() => {
  console.log('setTimeout', 2)
}, 0)

const promise = new Promise((resolve, reject) => {
  console.log('promise', 3)
  resolve(4)
})

setTimeout(() => {
  console.log('setTimeout', 5)
}, 10)

promise.then(res => {
  console.log('then', res)
})

console.log(6)

控制台输出:

1 promise 3 6 then 4 setTimeout 2 setTimeout 5

流程图执行-步骤一:

先执行同步代码,如遇到异步代码,则把异步回调事件放到后台监听对应的任务队列

image.png

  1. 执行console.log(1),控制台输出1

  2. 执行定时器,遇到异步代码,后台注册定时器回调事件,时间到了,把回调函数() => {console.log('setTimeout', 2)},放到宏任务队列等待。

  3. 执行创建Promise实例,并执行其中同步代码:执行console.log('promise', 3),控制台输出promise 3;执行resolve(4),此时Promise已经确定为完成fulfilled状态,把promise.then()的回调函数响应值设为4

  4. 执行定时器,遇到异步代码,后台注册定时器回调事件,时间未到,把回调函数() => { console.log('setTimeout', 5) }放到后台监听。

  5. 执行promise.then(res => { console.log('then', res) }),出栈走异步代码,把回调函数4 => { console.log('then', 4) }放入微任务队列等待。

流程图执行-步骤二:

上面已经把同步代码执行完成,并且把对应异步回调事件放到了指定任务队列,接下来开始事件循环

image.png

  1. 扫描微任务队列,执行4 => { console.log('then', 4) }回调函数,控制台输出then 4

  2. 微任务队列为空,扫描宏任务队列,执行() => {console.log('setTimeout', 2)}回调函数,控制台输出setTimeout 2

  3. 每执行完一个宏任务,需要再次扫描微任务队列是否存在可执行任务(假设此时后台定时到了,则会把() => { console.log('setTimeout', 5) }加入到了宏任务队列末尾)。

  4. 微任务队列为空,扫描宏任务队列,执行() => { console.log('setTimeout', 5) },控制台输出setTimeout 5

演示三:稍有难度

setTimeout+Promise组合拳+多层嵌套Promise

console.log(1)

setTimeout(() => {
  console.log('setTimeout', 10)
}, 0)

new Promise((resolve, reject) => {
  console.log(2)
  resolve(7)

  new Promise((resolve, reject) => {
    resolve(5)
  }).then(res => {
    console.log(res)

    new Promise((resolve, reject) => {
      resolve('嵌套第三层 Promise')
    }).then(res => {
      console.log(res)
    })
  })

  Promise.resolve(6).then(res => {
    console.log(res)
  })

}).then(res => {
  console.log(res)
})

new Promise((resolve, reject) => {
  console.log(3)

  Promise.resolve(8).then(res => {
    console.log(res)
  })

  resolve(9)
}).then(res => {
  console.log(res)
})

console.log(4)

上一个演示说明了流程图执行的详细步骤,下面就不多加赘叙了,直接看图!

talk is cheap, show me the chart

image.png

上图,调用栈同步代码执行完成,开始事件循环,先看微任务队列,发现不为空,按顺序执行微任务事件:

嵌套02.png

上图,已经把刚才排队的微任务队列全部清空了。但是在执行第一个微任务时,发现还有嵌套微任务,则把该任务放到微任务队列末尾,然后接着一起执行完所有新增任务

嵌套03.png

最后微任务清空后,接着执行宏任务。到此全部事件已执行完毕!

控制台完整输出顺序:

1 2 3 4 5 6 7 8 9 10

演示四:setTimeout伪定时

setTimeout并不是设置的定时到了就马上执行,而是把定时回调放在task queue任务队列当中进行等待,待主线程调用栈中的同步任务执行完成后空闲时才会执行。

const startTime = Date.now()
setTimeout(() => {
  const endTime = Date.now()
  console.log('setTimeout cost time', endTime - startTime)
  // setTimeout cost time 2314
}, 100)

for (let i = 0; i < 300000; i++) {
  // 模拟执行耗时同步任务
  console.log(i)
}

控制台输出:

1 2 3 ··· 300000 setTimeout cost time 2314

下图演示了其执行流程:

setTimeout假延时.png

演示五:fetch网络请求和setTimeout

获取网络数据,fetch回调函数属于微任务,优于setTimeout先执行。

setTimeout(() => {
  console.log('setTimeout', 2)
}, 510)

const startTime = Date.now()
fetch('http://localhost:3000/test').then(res => {
  const endTime = Date.now()
  console.log('fetch cost time', endTime - startTime)
  return res.json()
}).then(data => {
  console.log('data', data)
})

下图当前Call Stack执行栈执行完同步代码后,由于fetchsetTimeout都是宏任务,所以走宏任务Web API流程后注册这两个事件回调,等待定时到后了,由于定时回调是个普通的同步函数,所以放到宏任务队列;等待fetch拿到服务器响应数据后,由于fetch回调为一个Promise对象,所以放到微任务队列。

fetch.png

经过多番刷新网页测试,下图控制台打印展示了setTimeout延时为510msfetch请求响应同样是510ms的情况下,.then(data => { console.log('data', data) })先执行了,也是由于fetch基于Promise实现,所以其回调为微任务。

b475cbb38b0161d3e7f5f97b45824b31.png

五、结语

这可能只是简单的JavaScript代码执行事件循环流程,目的也是让大家更直观理解其中原理。实际执行过程可能还会读取堆内存获取引用类型数据、操作dom的方法,可能还会触发页面的重排、重绘等过程、异步文件读取和写入操作、fetch发起网络请求,与服务器建立连接获取网络数据等情况。

但是,它们异步执行的回调函数都会经过图中的这个事件循环过程,从而构成完整的浏览器事件循环。

Mac 端企业微信调试工具开启指南:解决页面兼容性问题必备

前言:

本文主要分享以下两点:

  1. 如何打开 Mac 版企业微信中的调试控制台:由于 Mac 版企微调试工具的开启方式和 Windows 不一样,网上教程零散,所以整理了详细步骤,帮前端同学快速上手~

  2. 我遇到个坑:页面在 Windows 端企微正常,Mac 端打开时字体却闪一下变大,需要用企微调试工具定位问题,附解决方法。

一、背景介绍

随着公司企业微信的全员推广,内部业务页面逐步迁移至企微环境运行,既提升了协作效率,也对页面兼容性提出了更高要求。近期开发的页面在 Windows 端企微中表现正常,但 Mac 端企微打开时出现字体闪烁变大的异常,需通过 企微内置调试工具 定位问题。由于 Mac 版企微调试工具的开启路径与 Windows 端存在差异,特此整理详细操作流程,为同类场景提供参考。

二、打开步骤

  1. 首先 打开 debug模式:

    方法:同时按下快捷键 command + shift + control + D,会有debug模式开启的提示。

image.png

再按一次就是关闭提示:

image.png

  1. 然后点击左上方的“调试”菜单,即【调试】——>【浏览器、webView相关】——>【开启webView元素审查】。具体见下面截图:

1fe54945-47c8-44ca-bdce-052ac089e475.png

image.png

结束这个步骤之后,再次打开调试查看时,【开启webView元素审查】会变成【关闭webView元素审查】,这样就说明开启成功,即:

11b04d5a-014d-402e-860d-f8edc98253b6.png

  1. 最后 关闭 应用 重新打开 即可。这一步非常重要!如果不重新打开的话,右键时,也不会出来“检查因素”,即下面第四步就不会生效。

  2. 右键,出现 “检查因素”,打开就是调试控制台了:

4b1fab6a-d2f3-42a0-a125-f5d542413232.png

image.png

三、Mac的企业微信的网页,会默认给body加一个zoom属性

这一隐形的设置可能会成为你项目中bug的原因。就比如我项目中的问题,在 Windows 中,页面的字体没有问题,在 Mac 的企微中打开,页面中的字体会缩放一下,就是由于这个默认属性导致的。我需要对此单独处理一下,就能完美解决问题,解决方案如下图:

image.png

四、总结

这就是 Mac 端企业微信调试工具的完整开启步骤啦~ 按照流程操作后,就能像在浏览器控制台一样,调试企微内的页面样式、接口请求等,轻松定位字体异常、布局错乱、交互失效等问题。

如果你的工作中也需要在企微生态开发页面,本文的调试控制台开启方法能直接参考。若有其他企微调试的小技巧,也欢迎在评论区留言交流,一起避坑提效!

以上,希望对你有帮助!

CLI 工具开发的常用包对比和介绍

04-CLI 工具开发

CLI 工具开发涉及命令行交互、终端美化、文件操作和模板生成等核心功能。

📑 目录


快速参考

工具选型速查表

工具类型 推荐工具 适用场景 备选方案
命令行解析 commander 功能丰富、API 友好 yargs(灵活)、meow(轻量)
交互式输入 inquirer 功能全面、生态丰富 prompts(轻量)、enquirer
终端美化 chalk 功能丰富、API 友好 picocolors(极轻量)、kleur
加载动画 ora 简单易用 -
进度条 cli-progress 文件上传/下载进度 -
文件操作 fs-extra Promise API、功能增强 -
文件匹配 glob 通配符匹配 -
模板引擎 handlebars 轻量、逻辑少 ejs、mustache

快速开始

# 1. 安装核心工具
pnpm add commander inquirer chalk ora fs-extra glob handlebars

# 2. 创建 CLI 入口文件
# src/cli.ts

# 3. 配置 package.json bin 字段

命令行交互

commander(推荐)

commander 是一款 Node.js 命令行解析工具,核心用途是解析命令行参数,让 CLI 工具的命令行交互更友好、专业。

优势

  • ✅ API 友好,链式调用
  • ✅ 功能丰富,支持子命令、选项、帮助信息
  • ✅ 生态完善,文档详细
  • ✅ 自动生成帮助信息

劣势

  • ❌ 体积较大(相比 meow)
安装
pnpm add commander
pnpm add @types/commander -D
基础用法
// src/cli.ts
import { program } from 'commander';

program
  .version('1.0.0', '-v, --version')
  .description('一个基于 commander + inquirer 的 CLI 工具示例');

// 定义无参数命令
program
  .command('init')
  .description('初始化项目')
  .action(() => {
    console.log('开始初始化项目...');
  });

// 定义带选项的命令
program
  .command('build')
  .description('打包项目')
  .option('-e, --env <env>', '打包环境', 'development')
  .option('-o, --outDir <dir>', '输出目录', 'dist')
  .action((options) => {
    console.log('开始打包...');
    console.log('打包环境:', options.env);
    console.log('输出目录:', options.outDir);
  });

program.parse(process.argv);
选项配置
选项格式 说明 示例
.option('-s, --single') 布尔型选项(无参数,存在即 true) your-cli --single{ single: true }
.option('-n, --name <name>') 必填参数选项 your-cli --name test{ name: 'test' }
.option('-a, --age [age]') 可选参数选项 your-cli --age 25{ age: 25 };不传则为 undefined
.option('--env <env>', '描述', 'dev') 带默认值的选项 不传 --env 时,默认 { env: 'dev' }
高级用法
// 子命令
program
  .command('create <name>')
  .description('创建新项目')
  .option('-t, --template <template>', '模板类型', 'default')
  .action((name, options) => {
    console.log(`创建项目 ${name},使用模板 ${options.template}`);
  });

// 必需选项
program.requiredOption('-c, --config <path>', '配置文件路径').parse();

// 自定义帮助信息
program.addHelpText('after', '\n示例:\n  $ my-cli init\n  $ my-cli build --env production');

yargs

yargs 是功能强大的命令行解析工具,支持位置参数、命令补全等高级功能。

优势

  • ✅ 功能强大,支持位置参数
  • ✅ 灵活的配置方式
  • ✅ 支持命令补全

劣势

  • ❌ API 相对复杂
  • ❌ 学习曲线较陡
安装
pnpm add yargs
pnpm add @types/yargs -D
基础用法
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';

const argv = yargs(hideBin(process.argv))
  .option('name', {
    alias: 'n',
    type: 'string',
    description: '项目名称',
    demandOption: true,
  })
  .option('template', {
    alias: 't',
    type: 'string',
    default: 'default',
    description: '模板类型',
  })
  .command('init <name>', '初始化项目', (yargs) => {
    return yargs.positional('name', {
      describe: '项目名称',
      type: 'string',
    });
  })
  .parseSync();

console.log(argv);

meow

meow 是轻量级的命令行解析工具,适合简单场景。

优势

  • ✅ 轻量级,体积小
  • ✅ 配置简单
  • ✅ 自动处理帮助信息

劣势

  • ❌ 功能相对简单
  • ❌ 不支持复杂命令结构
安装
pnpm add meow
基础用法
import meow from 'meow';

const cli = meow(
  `
  用法
    $ my-cli <input>

  选项
    --name, -n  项目名称
    --template, -t  模板类型

  示例
    $ my-cli init --name my-project
`,
  {
    importMeta: import.meta,
    flags: {
      name: {
        type: 'string',
        alias: 'n',
      },
      template: {
        type: 'string',
        alias: 't',
        default: 'default',
      },
    },
  },
);

console.log(cli.input[0]); // 命令参数
console.log(cli.flags); // 选项

命令行解析工具对比

工具 体积 配置复杂度 功能丰富度 适用场景
commander 较大 ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ 功能丰富的 CLI 工具
yargs 较大 ⭐⭐ ⭐⭐⭐⭐⭐ 需要位置参数的场景
meow 轻量级 ⭐⭐⭐⭐⭐ ⭐⭐⭐ 简单 CLI 工具

选型建议

  • 功能丰富的 CLI 工具:commander(推荐)
  • 需要位置参数:yargs
  • 简单工具:meow

交互式输入

inquirer(推荐)

当命令行参数无法满足需求(如让用户选择框架、输入密码、确认操作)时,inquirer 提供「交互式输入」。

优势

  • ✅ 功能全面,支持多种交互类型
  • ✅ 生态丰富,插件多
  • ✅ 文档完善

劣势

  • ❌ 体积较大
  • ❌ 配置相对复杂
安装
pnpm add inquirer
pnpm add @types/inquirer -D
基础用法
import { program } from 'commander';
import inquirer from 'inquirer';
import chalk from 'chalk';

program
  .command('init')
  .description('初始化项目')
  .action(async () => {
    console.log(chalk.blue('📦 开始初始化项目...'));

    const answers = await inquirer.prompt([
      {
        type: 'input',
        name: 'projectName',
        message: '请输入项目名称:',
        default: 'my-project',
        validate: (value) => {
          if (!value.trim()) return '项目名称不能为空!';
          return true;
        },
      },
      {
        type: 'list',
        name: 'framework',
        message: '请选择项目框架:',
        choices: [
          { name: 'React + TypeScript', value: 'react-ts' },
          { name: 'Vue + TypeScript', value: 'vue-ts' },
          { name: 'Vanilla JS', value: 'vanilla' },
        ],
        default: 'react-ts',
      },
      {
        type: 'checkbox',
        name: 'modules',
        message: '请选择需要的功能模块:',
        choices: ['路由', '状态管理', 'UI 组件库', 'ESLint/Prettier'],
        default: ['路由', 'ESLint/Prettier'],
      },
      {
        type: 'confirm',
        name: 'initGit',
        message: '是否初始化 Git 仓库?',
        default: true,
      },
    ]);

    console.log(chalk.green('\n✅ 项目配置如下:'));
    console.log('项目名称:', answers.projectName);
    console.log('框架:', answers.framework);
    console.log('功能模块:', answers.modules.join(', '));
    console.log('初始化 Git:', answers.initGit ? '是' : '否');
  });

program.parse(process.argv);
核心交互类型
类型 用途 关键配置
input 普通文本输入(如项目名称、邮箱) message、default、validate
password 密码输入(输入内容隐藏) 同 input,自动隐藏输入
list 单选(如框架选择、环境选择) choices(选项数组)、default
checkbox 多选(如功能模块、依赖选择) choices、default(默认选中项)
confirm 二选一确认(是 / 否) message、default(true/false)
rawlist 带编号的单选(按数字选择) 同 list,选项前显示编号
autocomplete 带自动补全的输入(如文件路径) 需配合 inquirer-autocomplete-prompt 插件

prompts

prompts 是轻量级的交互式输入工具,API 简洁。

优势

  • ✅ 轻量级,体积小
  • ✅ API 简洁
  • ✅ 支持取消操作(Ctrl+C)

劣势

  • ❌ 功能相对简单
  • ❌ 生态较小
安装
pnpm add prompts
基础用法
import prompts from 'prompts';

const response = await prompts([
  {
    type: 'text',
    name: 'projectName',
    message: '项目名称',
    initial: 'my-project',
    validate: (value) => (value.trim() ? true : '项目名称不能为空'),
  },
  {
    type: 'select',
    name: 'framework',
    message: '选择框架',
    choices: [
      { title: 'React', value: 'react' },
      { title: 'Vue', value: 'vue' },
    ],
  },
]);

console.log(response);

enquirer

enquirer 是现代化的交互式输入工具,支持自定义提示符。

优势

  • ✅ 现代化设计
  • ✅ 支持自定义提示符
  • ✅ API 灵活

劣势

  • ❌ 文档相对较少
  • ❌ 生态较小
安装
pnpm add enquirer
基础用法
import { prompt } from 'enquirer';

const response = await prompt({
  type: 'input',
  name: 'projectName',
  message: '项目名称',
  initial: 'my-project',
});

console.log(response);

交互式输入工具对比

工具 体积 配置复杂度 功能丰富度 生态 适用场景
inquirer 较大 ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ 功能全面的 CLI 工具
prompts 轻量级 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ 简单交互场景
enquirer 中等 ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ 需要自定义提示符

选型建议

  • 功能全面的 CLI 工具:inquirer(推荐)
  • 简单交互场景:prompts
  • 需要自定义提示符:enquirer

终端美化

chalk(推荐)

chalk 是 Node.js 终端日志彩色打印工具,核心用途是给终端输出的文字添加颜色、背景色、加粗 / 下划线等样式。

优势

  • ✅ 功能丰富,API 友好
  • ✅ 支持链式调用
  • ✅ 生态完善

劣势

  • ❌ 体积较大(相比 picocolors)
安装
pnpm add chalk
封装日志函数
// src/utils/logger.ts
import chalk from 'chalk';

export enum LogType {
  SUCCESS = 'success',
  ERROR = 'error',
  WARN = 'warn',
  INFO = 'info',
}

export const log = (message: string, type: LogType = LogType.INFO) => {
  const prefixMap = {
    [LogType.SUCCESS]: chalk.green('✅'),
    [LogType.ERROR]: chalk.bold.red('❌'),
    [LogType.WARN]: chalk.yellow('⚠️'),
    [LogType.INFO]: chalk.blue('ℹ️'),
  };

  const colorMap = {
    [LogType.SUCCESS]: chalk.green,
    [LogType.ERROR]: chalk.red,
    [LogType.WARN]: chalk.yellow,
    [LogType.INFO]: chalk.blue,
  };

  const prefix = prefixMap[type];
  const color = colorMap[type];
  console.log(`${prefix} ${color(message)}`);
};

export const logSuccess = (message: string) => log(message, LogType.SUCCESS);
export const logError = (message: string) => log(message, LogType.ERROR);
export const logWarn = (message: string) => log(message, LogType.WARN);
export const logInfo = (message: string) => log(message, LogType.INFO);
使用
import { logSuccess, logError, logWarn, logInfo } from './utils/logger';

logInfo('正在初始化项目...');
logWarn('当前 Node 版本低于推荐版本');
logSuccess('项目初始化完成!');
logError('配置文件缺失,请检查 config.json');

picocolors

picocolors 是极轻量的终端颜色库,体积仅 0.5KB。

优势

  • ✅ 极轻量(0.5KB)
  • ✅ API 简洁
  • ✅ 性能好

劣势

  • ❌ 功能相对简单
  • ❌ 不支持链式调用
安装
pnpm add picocolors
基础用法
import pc from 'picocolors';

console.log(pc.green('成功'));
console.log(pc.red('错误'));
console.log(pc.bold(pc.blue('加粗蓝色')));

kleur

kleur 是轻量级的终端颜色库,API 类似 chalk。

优势

  • ✅ 轻量级(体积小)
  • ✅ API 类似 chalk,迁移成本低
  • ✅ 支持链式调用

劣势

  • ❌ 功能相对简单
安装
pnpm add kleur
基础用法
import kleur from 'kleur';

console.log(kleur.green('成功'));
console.log(kleur.red('错误'));
console.log(kleur.bold().blue('加粗蓝色'));

ora(展示加载动画)

ora 是一款 Node.js 终端加载动画工具,核心用途是在耗时操作时显示「加载中」动画 + 提示文字。

安装
pnpm add ora
封装加载动画函数
// src/utils/loader.ts
import ora from 'ora';
import chalk from 'chalk';

export const withLoader = async <T>(message: string, asyncFn: () => Promise<T>): Promise<T> => {
  const spinner = ora(chalk.bold.blue(message)).start();
  try {
    const result = await asyncFn();
    spinner.succeed(chalk.green('✅ 操作完成!'));
    return result;
  } catch (error) {
    spinner.fail(chalk.bold.red(`❌ 操作失败:${(error as Error).message}`));
    throw error;
  }
};
使用示例
import { withLoader } from './utils/loader';

await withLoader('正在请求接口数据...', async () => {
  await new Promise((resolve) => setTimeout(resolve, 1500));
});
高级用法
import ora from 'ora';

const spinner = ora('加载中...').start();

// 更新文本
spinner.text = '处理中...';

// 成功
spinner.succeed('完成!');

// 失败
spinner.fail('失败!');

// 警告
spinner.warn('警告!');

// 信息
spinner.info('信息');

cli-progress(进度条)

cli-progress 用于显示文件上传/下载、批量处理等操作的进度。

安装
pnpm add cli-progress
基础用法
import cliProgress from 'cli-progress';

const bar = new cliProgress.SingleBar({
  format: '进度 |{bar}| {percentage}% | {value}/{total}',
  barCompleteChar: '\u2588',
  barIncompleteChar: '\u2591',
  hideCursor: true,
});

bar.start(100, 0);

// 模拟进度
for (let i = 0; i <= 100; i++) {
  await new Promise((resolve) => setTimeout(resolve, 50));
  bar.update(i);
}

bar.stop();

boxen(边框框)

boxen 用于在终端中创建带边框的文本框,适合显示重要信息。

安装
pnpm add boxen
基础用法
import boxen from 'boxen';
import chalk from 'chalk';

const message = boxen(chalk.green('✅ 项目初始化完成!'), {
  padding: 1,
  margin: 1,
  borderStyle: 'round',
  borderColor: 'green',
});

console.log(message);

cli-table3(表格展示)

cli-table3 用于在终端中展示表格数据,适合显示配置信息、对比数据等。

安装
pnpm add cli-table3
pnpm add @types/cli-table3 -D
基础用法
import Table from 'cli-table3';

const table = new Table({
  head: ['工具', '用途', '推荐度'],
  colWidths: [20, 30, 10],
});

table.push(
  ['commander', '命令行解析', '⭐⭐⭐⭐⭐'],
  ['inquirer', '交互式输入', '⭐⭐⭐⭐⭐'],
  ['chalk', '终端美化', '⭐⭐⭐⭐⭐'],
);

console.log(table.toString());

终端美化工具对比

工具 体积 功能 适用场景
chalk 较大 颜色、样式 功能丰富的 CLI 工具
picocolors 极轻量 基础颜色 对体积敏感的项目
kleur 轻量级 颜色、样式 轻量级 CLI 工具
ora 中等 加载动画 耗时操作提示
cli-progress 中等 进度条 文件处理进度
boxen 轻量级 边框框 重要信息展示
cli-table3 中等 表格 数据展示

选型建议

  • 功能丰富的 CLI 工具:chalk(推荐)
  • 对体积敏感:picocolors
  • 需要进度条:cli-progress
  • 需要表格展示:cli-table3

文件操作

fs-extra(操作文件系统)

fs-extra 在 Node.js 原生 fs 模块基础上做了增强,核心优势:

  • 完全兼容原生 fs 模块(可直接替换 fs 使用)
  • 所有 API 支持 Promise(无需手动封装 util.promisify)
  • 新增高频实用功能(递归创建目录、递归删除目录、复制文件 / 目录等)
安装
pnpm add fs-extra
pnpm add @types/fs-extra -D
核心用法
import fs from 'fs-extra';
import path from 'path';

// 递归创建目录
await fs.ensureDir(path.resolve(__dirname, 'a/b/c'));

// 递归删除目录
await fs.remove(path.resolve(__dirname, 'a'));

// 复制文件/目录
await fs.copy(src, dest);

// 写入 JSON 文件
await fs.writeJson(jsonPath, { name: 'test', version: '1.0.0' }, { spaces: 2 });

// 读取 JSON 文件
const config = await fs.readJson(jsonPath);

// 判断文件/目录是否存在
const exists = await fs.pathExists(path);
常用 API
API 名称 核心用途 优势对比(vs 原生 fs)
fs.ensureDir(path) 递归创建目录(不存在则创建,存在则忽略) 原生需手动递归,fs-extra 一键实现
fs.remove(path) 递归删除文件 / 目录(支持任意层级) 原生需先遍历目录,fs-extra 一键删除
fs.copy(src, dest) 复制文件 / 目录(自动创建目标目录) 原生需区分文件 / 目录,fs-extra 自动适配
fs.writeJson(path, data) 写入 JSON 文件(自动 stringify) 原生需手动 JSON.stringify,fs-extra 简化步骤
fs.readJson(path) 读取 JSON 文件(自动 parse) 原生需手动 JSON.parse,fs-extra 简化步骤
fs.pathExists(path) 判断文件 / 目录是否存在(返回 boolean) 原生需用 fs.access 捕获错误,fs-extra 直接返回

glob(匹配文件)

glob 解决「按规则批量查找文件」的需求,支持用通配符(如 ***?)匹配文件路径。

安装
pnpm add glob
pnpm add @types/glob -D
核心用法
import glob from 'glob';
import path from 'path';

// 同步匹配
const files = glob.sync('src/**/*.ts', {
  cwd: process.cwd(),
  ignore: ['src/test/**/*'],
});

// 异步匹配(推荐)
const files = await glob.promise('src/**/*.{ts,js}', {
  cwd: process.cwd(),
  dot: true,
});

// 流式匹配(适合大量文件)
const stream = glob.stream('src/**/*.ts');
stream.on('data', (filePath) => {
  console.log('匹配到文件:', filePath);
});
常见通配符规则
通配符 含义 示例 匹配结果
* 匹配当前目录下的任意字符(不含子目录) src/*.ts src/index.tssrc/utils.ts
** 匹配任意层级的目录(递归) src/**/*.ts src/index.tssrc/a/b/utils.ts
? 匹配单个字符 src/file?.ts src/file1.tssrc/file2.ts
[] 匹配括号内的任意一个字符 src/[ab].ts src/a.tssrc/b.ts
! 排除匹配的文件 src/**/*.ts + !src/test.ts 所有 .ts 文件,排除 src/test.ts

模板生成

handlebars(生成模板文件)

handlebars 是一款逻辑少、轻量型的模板引擎,核心用途是「将数据与模板结合,动态生成文本内容」。

安装
pnpm add handlebars
pnpm add @types/handlebars -D
基础用法
import handlebars from 'handlebars';

// 1. 定义模板
const template = `{
  "name": "{{ projectName }}",
  "version": "{{ version }}",
  "description": "{{ description }}"
}`;

// 2. 编译模板
const compiledTemplate = handlebars.compile(template);

// 3. 传入数据渲染
const data = {
  projectName: 'my-ts-pkg',
  version: '1.0.0',
  description: '基于 TS + 双模式的 npm 包',
};

const result = compiledTemplate(data);
console.log(result);
核心语法
  • 变量占位符{{ 变量名 }}(支持嵌套对象)
  • 条件判断{{#if 条件}}...{{else}}...{{/if}}
  • 循环遍历{{#each 数组}}...{{/each}}
  • 注释{{! 注释内容 }}
高级用法
// 注册辅助函数
handlebars.registerHelper('uppercase', (str) => {
  return str.toUpperCase();
});

// 使用辅助函数
const template = `{{ uppercase name }}`;

完整示例

结合所有工具,实现一个完整的 CLI 工具:

import { program } from 'commander';
import inquirer from 'inquirer';
import chalk from 'chalk';
import ora from 'ora';
import fs from 'fs-extra';
import glob from 'glob';
import handlebars from 'handlebars';
import path from 'path';
import boxen from 'boxen';

program.version('1.0.0', '-v, --version').description('CLI 工具示例');

program
  .command('init [projectName]')
  .description('初始化项目')
  .option('-t, --template <template>', '模板类型', 'default')
  .option('-y, --yes', '跳过交互式询问', false)
  .action(async (projectName, options) => {
    console.log(chalk.blue('📦 开始初始化项目...'));

    let answers: any = {};

    // 如果提供了项目名称且使用了 --yes,跳过交互
    if (projectName && options.yes) {
      answers = {
        projectName,
        framework: 'react-ts',
        modules: ['路由', 'ESLint/Prettier'],
        initGit: true,
      };
    } else {
      // 交互式询问
      answers = await inquirer.prompt([
        {
          type: 'input',
          name: 'projectName',
          message: '项目名称',
          default: projectName || 'my-project',
          validate: (value) => {
            if (!value.trim()) return '项目名称不能为空!';
            if (!/^[a-z0-9-]+$/.test(value)) {
              return '项目名称只能包含小写字母、数字和连字符!';
            }
            return true;
          },
        },
        {
          type: 'list',
          name: 'framework',
          message: '选择框架',
          choices: [
            { name: 'React + TypeScript', value: 'react-ts' },
            { name: 'Vue + TypeScript', value: 'vue-ts' },
            { name: 'Vanilla JS', value: 'vanilla' },
          ],
          default: 'react-ts',
        },
        {
          type: 'checkbox',
          name: 'modules',
          message: '选择功能模块',
          choices: ['路由', '状态管理', 'UI 组件库', 'ESLint/Prettier'],
          default: ['路由', 'ESLint/Prettier'],
        },
        {
          type: 'confirm',
          name: 'initGit',
          message: '是否初始化 Git 仓库?',
          default: true,
        },
      ]);
    }

    const spinner = ora(chalk.blue('正在生成项目文件...')).start();

    try {
      // 1. 检查目录是否存在
      const targetDir = path.resolve(process.cwd(), answers.projectName);
      if (await fs.pathExists(targetDir)) {
        spinner.fail(chalk.red(`目录 ${answers.projectName} 已存在!`));
        process.exit(1);
      }

      // 2. 创建项目目录
      await fs.ensureDir(targetDir);

      // 3. 读取模板文件
      const templateDir = path.resolve(__dirname, '../templates', options.template);
      const templateFiles = await glob.promise('**/*.hbs', {
        cwd: templateDir,
        dot: true,
      });

      // 4. 渲染模板并写入文件
      for (const templateFile of templateFiles) {
        const templatePath = path.resolve(templateDir, templateFile);
        const templateContent = await fs.readFile(templatePath, 'utf8');

        const compiled = handlebars.compile(templateContent);
        const renderedContent = compiled(answers);

        const targetFile = templateFile.replace(/\.hbs$/, '');
        const targetPath = path.resolve(targetDir, targetFile);

        await fs.ensureDir(path.dirname(targetPath));
        await fs.writeFile(targetPath, renderedContent, 'utf8');
      }

      // 5. 初始化 Git(如果选择)
      if (answers.initGit) {
        spinner.text = '正在初始化 Git 仓库...';
        // 这里可以调用 git 命令
      }

      spinner.succeed(chalk.green('项目初始化完成!'));

      // 6. 显示成功信息
      const successMessage = boxen(
        chalk.green(`✅ 项目 ${answers.projectName} 创建成功!\n\n`) +
          chalk.cyan(`cd ${answers.projectName}\n`) +
          chalk.cyan('npm install\n') +
          chalk.cyan('npm run dev'),
        {
          padding: 1,
          margin: 1,
          borderStyle: 'round',
          borderColor: 'green',
        },
      );

      console.log(successMessage);
    } catch (error) {
      spinner.fail(chalk.red(`初始化失败:${(error as Error).message}`));
      process.exit(1);
    }
  });

program
  .command('build')
  .description('构建项目')
  .option('-e, --env <env>', '构建环境', 'production')
  .option('-o, --outDir <dir>', '输出目录', 'dist')
  .action(async (options) => {
    const spinner = ora(chalk.blue('正在构建项目...')).start();

    try {
      // 模拟构建过程
      await new Promise((resolve) => setTimeout(resolve, 2000));

      spinner.succeed(chalk.green(`构建完成!输出目录:${options.outDir}`));
    } catch (error) {
      spinner.fail(chalk.red(`构建失败:${(error as Error).message}`));
      process.exit(1);
    }
  });

program.parse(process.argv);

最佳实践

  1. 错误处理:使用 try-catch 捕获错误,提供友好的错误提示
  2. 用户体验:使用 ora 显示加载状态,使用 chalk 美化输出
  3. 参数验证:在 inquirer 中使用 validate 验证用户输入
  4. 文件操作:使用 fs-extra 的 Promise API,避免回调地狱
  5. 模板管理:将模板文件放在独立目录,使用 glob 批量处理
  6. 命令结构:使用 commander 组织命令,保持清晰的命令层次
  7. 帮助信息:为每个命令添加清晰的描述和示例

常见问题

命令行解析相关问题

Q: commander 和 yargs 如何选择?

A:

  • commander:API 友好,适合大多数场景(推荐)
  • yargs:需要位置参数或复杂参数解析时使用

Q: 如何获取未定义的选项?

A:

// commander
program.parse();
const unknownOptions = program.opts();

// yargs
const argv = yargs.parse();
const unknown = argv._; // 未定义的参数

交互式输入相关问题

Q: inquirer 和 prompts 如何选择?

A:

  • inquirer:功能全面,生态丰富(推荐)
  • prompts:轻量级,简单场景使用

Q: 如何中断交互式输入?

A:

// inquirer 会自动处理 Ctrl+C
// prompts 需要手动处理
const response = await prompts({
  type: 'text',
  name: 'value',
  message: '输入值',
  onCancel: () => {
    console.log('已取消');
    process.exit(0);
  },
});

终端美化相关问题

Q: chalk 和 picocolors 如何选择?

A:

  • chalk:功能丰富,适合大多数场景(推荐)
  • picocolors:对体积敏感的项目使用

Q: 如何检测终端是否支持颜色?

A:

import chalk from 'chalk';

// chalk 会自动检测,不支持时自动禁用颜色
// 手动检测
const supportsColor = chalk.supportsColor;

文件操作相关问题

Q: fs-extra 和原生 fs 的区别?

A:

  • fs-extra:Promise API、递归操作、JSON 操作更便捷
  • 原生 fs:需要手动封装 Promise、手动递归

Q: glob 如何排除多个文件?

A:

const files = await glob.promise('src/**/*.ts', {
  ignore: ['src/test/**/*', 'src/**/*.test.ts'],
});

模板生成相关问题

Q: handlebars 和其他模板引擎的区别?

A:

  • handlebars:逻辑少、轻量级(推荐)
  • ejs:支持 JavaScript 代码,功能强大但体积大
  • mustache:无逻辑模板,但功能较少

Q: 如何在模板中使用辅助函数?

A:

handlebars.registerHelper('eq', (a, b) => a === b);

// 模板中使用
// {{#if (eq value "test")}}...{{/if}}

参考资源

一次从“卡顿地狱”到“丝般顺滑”的 React 搜索优化实战

author: 大布布将军

前言:万恶之源

本故事有虚构成分。但来源于现实开发场景。 事情是这样的。

某个周五下午 4 点,产品经理(PM)迈着六亲不认的步伐向我们前端走来。他满面春风地说:“哎,那个列表页,用户反馈说找不到想要的数据,加个实时搜索功能吧?要那种一边打字一边过滤的,丝般顺滑的感觉,懂我意思吧?”

前端心想:这还不简单?input 绑定 onChange,拿到 value 往列表里一 filter,完事儿。半小时搞定,还能赶上 6 点的地铁。

于是,前端写下了那段让我后来后悔不已的代码。


第一阶段:由于过度自信导致的翻车

为了还原案发现场,前端写了一个简化版的 Demo。假设我们有一个包含 10,000 条数据的列表(别问为什么前端要处理一万条数据,问就是后端甩锅)。

也就是这几行“天真”的代码:


// 假装这里有一万个商品
const generateProducts = () => {
  return Array.from({ length: 10000 }, (_, i) => `超级无敌好用的商品 #${i}`);
};

const dummyProducts = generateProducts();

export default function SearchList() {
  const [query, setQuery] = useState('');

  // 🔴 罪魁祸首在这里:每次 render 都要遍历一万次
  const filteredProducts = dummyProducts.filter(p => 
    p.toLowerCase().includes(query.toLowerCase())
  );

  const handleChange = (e) => {
    setQuery(e.target.value); // 这一步更新 state,触发重渲染
  };

  return (
    <div className="p-4">
      <input 
        type="text" 
        value={query} 
        onChange={handleChange} 
        placeholder="搜索..." 
        className="border p-2 w-full"
      />
      <ul className="mt-4">
        {filteredProducts.map((p, index) => (
          <li key={index}>{p}</li>
        ))}
      </ul>
    </div>
  );
}

结果如何?

在前端的 ThinkBook 上跑了一下,输入的时候感觉像是在 PPT 里打字。每一个字符敲下去,都要顿个几百毫秒才会显示在输入框里。

为什么? 这是 React 的基本原理:

  1. 用户输入 'a' -> 触发 setQuery
  2. React 甚至还没来得及把 'a' 更新到 input 框里,就被迫去执行组件的 render 函数。
  3. render 函数里有一个极其昂贵的 filter 操作(遍历 10k 次)。
  4. 即使 filter 完了,React 还要把生成的几千个 DOM 节点和之前的做 Diff,然后挂载到页面上。
  5. JS 线程被堵死,UI 渲染被阻塞,用户看到的就是:卡顿

第二阶段:万金油防抖 (Debounce) —— 治标不治本

作为老油条,第一反应当然是:“切,防抖一下不就行了?”

只要让用户打字的时候不触发计算,停下来再计算,不就完了?

import { debounce } from 'lodash';

// ... 省略部分代码

const handleChange = debounce((e) => {
    setQuery(e.target.value);
}, 300);

效果: 输入框确实不卡了,打字很流畅。但是,当你停止打字 300ms 后,页面会突然“冻结”一下,然后列表瞬间刷新。

痛点: 这种体验就像是便秘。虽然没有一直在用力,但最后那一下还是很痛苦。而且,UI 的响应滞后感很强,依然没有达到 PM 要求的“丝般顺滑”。

第三阶段:祭出神器 useDeferredValue

React 18 发布这么久了,是时候让它出来干点活了。

这时候作为前端的我们需要引入一个概念:并发模式 (Concurrent Features)

简单来说,就是把更新任务分为“大哥”和“小弟”。

  • 大哥(紧急更新) :用户的打字输入、点击反馈。这玩意儿必须马上响应,不然用户会以为死机了。
  • 小弟(非紧急更新) :列表的过滤渲染。晚个几百毫秒没人在意。

React 18 给了我们一个 Hook 叫 useDeferredValue,专门用来处理这种场景。

改造后的代码:


export default function OptimizedSearchList() {
  const [query, setQuery] = useState('');
  
  //  魔法在这里:创建一个“滞后”的副本
  // React 会在空闲的时候更新这个值
  const deferredQuery = useDeferredValue(query);

  const handleChange = (e) => {
    // 这里依然是紧急更新,保证 input 框打字流畅
    setQuery(e.target.value); 
  };

  // 只有当 deferredQuery 变了,才去跑这个昂贵的 filter
  // 注意:这里要配合 useMemo,不然也没用
  const filteredProducts = useMemo(() => {
    return dummyProducts.filter(p => 
      p.toLowerCase().includes(deferredQuery.toLowerCase())
    );
  }, [deferredQuery]);

  return (
    <div className="p-4">
      <input 
        type="text" 
        value={query} // 绑定实时的 query
        onChange={handleChange} 
        className="border p-2 w-full"
      />
      
      {/* 甚至可以加个 loading 状态,判断 query 和 deferredQuery 是否同步 */}
      <div style={{ opacity: query !== deferredQuery ? 0.5 : 1 }}>
        <ul className="mt-4">
          {filteredProducts.map((p, index) => (
            <li key={index}>{p}</li>
          ))}
        </ul>
      </div>
    </div>
  );
}

这一波操作到底发生了什么?

  1. 输入 'a' :React 此时收到了两个任务。

    • 任务 A(高优先级):更新 query,让 input 框显示 'a'。
    • 任务 B(低优先级):更新 deferredQuery,并重新计算列表。
  2. React 的调度

    • React 说:“任务 A 甚至关乎到我的尊严,马上执行!” -> Input 框瞬间变了。
    • React 接着说:“任务 B 嘛,我先切片执行一点点... 哎?用户又输入 'b' 了?那任务 B 先暂停,我去执行新的任务 A!”
  3. 结果

    • JS 线程没有被长列表渲染锁死。
    • 输入框始终保持 60fps 的响应速度。
    • 列表会在资源空闲时“不知不觉”地更新完成。

深度解析:React 原理是咋搞的?

为了不显得只是个 API 调用侠,这里必须装一波,讲讲原理。

1. 以前的 React (Stack Reconciler)

想象你在厨房切洋葱(渲染组件)。老板(浏览器)跟你说:“客人要加单!”。以前的 React 是个死脑筋,一旦开始切洋葱(比如那 10,000 条数据),天王老子来了它也得切完才肯抬头。这时候浏览器就卡死了,用户点击没反应。

2. 现在的 React (Fiber & Concurrency)

现在的 React 学聪明了,它把切洋葱分成了无数个小步骤(Time Slicing)。

  • 切两刀,抬头看看:“老板,有急事吗?”
  • 老板:“没事,你继续。” -> 继续切。
  • 切两刀,抬头:“老板?”
  • 老板:“有!客人要喝水(用户输入了)!”
  • React:“好嘞!”(放下菜刀,先去倒水,倒完水回来再继续切洋葱,或者如果洋葱不用切了直接扔掉)。

useDeferredValue 本质上就是告诉 React:“这个 state 的更新是切洋葱,可以往后稍稍,先去给客人倒水。”

总结 & 避坑指南

这波优化上线后,PM 拍了拍前端的肩膀说:“行啊,有点东西。”

但是,兄弟们,请注意以下几点(防杠声明):

  1. 不要滥用:这玩意儿是有 overhead(开销)的。如果你的列表只有 50 条数据,用 useDeferredValue 纯属脱裤子放屁,反而更慢。
  2. 配合 useMemo:就像代码里写的,过滤逻辑必须包裹在 useMemo 里。否则每次 Parent Render,列表过滤还是会执行,useDeferredValue 就白用了。
  3. 性能优化的尽头是虚拟列表:如果数据量真到了 10 万级,别折腾 Concurrent Mode 了,直接上 react-windowreact-virtualized 吧,DOM 节点的数量才是真正的瓶颈。

好了,今天的摸鱼时间结束,撤退 。 我是大布布将军,一个前端开发。


下一步建议:如果你的项目里也有这种“输入卡顿”或者“Tab 切换卡顿”的场景,别急着重构,先试着把那个导致卡顿的状态用 useDeferredValue 包一下,说不定有奇效。

HTML iframe 标签

一、什么是 <iframe> ?

<iframe> 是一个内联框架,用于在当前HTML文档中嵌入另一个独立的HTML页面。它就像一个“窗口”,通过它可以加载并显示另一个网页的内容,且该内容拥有独立的 DOMCSSJavaScript环境。

基本语法如下:

    <iframe src="https://example.com" width="600" height="400" frameborder="0"></iframe>

二、核心属性

  • src:嵌入页面的 URL
  • width / height:尺寸(可设为 100%)
  • title:描述 iframe内容
  • loading="lazy":懒加载
  • sandbox:启用安全沙箱
  • allowfullscreen:允许嵌入的网页全屏显示,需要全屏API的支持。
  • frameborder:是否绘制边框,0为不绘制,1为绘制(默认值)。建议尽量少用这个属性,而是在CSS里面设置样式。

三、sandbox 沙箱机制(安全核心!)

这是<iframe>最重要的安全特性。启用后,默认禁止几乎所有危险操作,除非显式授权。

常用sandbox指令:

  • (空值):最严格:禁止脚本、表单提交、弹窗、同源访问等。
  • allow-scripts:允许执行JavaScript
  • allow-same-origin:允许被视为同源(谨慎!若同时允许脚本,可能绕过沙箱)
  • allow-forms:允许提交表单
  • allow-popups:允许 window.open()
  • allow-top-navigation:允许跳转顶层页面(危险!)

四、跨域通信:postMessage API

由于同源策略,父页面与iframe不能直接访问对方DOMJS变量。但可通过postMessage安全通信。

父页面 -> iframe

// 父页面
const iframe = document.getElementById('my-iframe')
iframe.contentWindow.postMessage(
    { type: 'AUTH', token: 'xxx' },
    'https://trusted-oframe.com' //指定目标 origin,防泄漏
)

iframe -> 父页面

// iframe 内部
window.parent.postMessage(
    { type: 'RESIZE', height: 800 },
    '*' // 或指定父页面 origin
)

监听消息(双方都要)

window.addEventListener('message', (event) => {
    if (event.origin !== 'https://expected-parent.com') return
    if (event.data.type === 'AUTH') {
        // 处理token
    }
})

五、性能优化建议

  • 懒加载:<iframe loading="lazy"> 减少首屏压力
  • 按需加载:用户点击“展开”后再设置 src
  • 避免深层嵌套:iframe 嵌套 iframe 会导致性能雪崩

如何自己构建一个Markdown增量渲染器

揭秘 Vue3 增量 Markdown 解析组件的神奇魔法

先上效果 演示demo

背景

相信很多大模型前端开发的小伙伴都已经处理过markdown实时解析翻译成html了,传统的方式类似使用Marked、markdown-it等组件全量渲染。但是全量渲染及其消耗性能,会造成大量的重排、重绘,导致页面抖动。

各位前端小伙伴们,今天我要给大家分享一个我最近开发的「Vue3 增量 Markdown 解析组件」。这个组件就像是一个「超级翻译官」,能把枯燥的 Markdown 文本瞬间变成生动的 HTML 页面,而且还支持数学公式、代码高亮等高级功能!废话不多说,让我们一起深入这个组件的「内部世界」吧!

开箱即用模式

# 安装命令
npm install v3-markdown-stream
# 或
yarn add v3-markdown-stream

组件使用示例

<template>
  <div>
    <MarkdownRender :markInfo="markdownContent" />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { MarkdownRender } from 'v3-markdown-stream';
import 'v3-markdown-stream/dist/v3-markdown-stream.css';

// 静态内容
const markdownContent = ref('# Hello World\n\nThis is a simple markdown example.')
</script>

组件概览

首先,让我们来看看这个组件的「身份证」(都是站在各位巨人的肩膀上

import { h, defineComponent, computed } from "vue";
import { Fragment, jsxs, jsx } from "vue/jsx-runtime";
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
import remarkParse from "remark-parse";
import rehypeKatex from "rehype-katex";
import "katex/dist/katex.min.css";
import 'highlight.js/styles/github-dark.css';
import remarkMath from "remark-math";
import remarkRehype from "remark-rehype";
import rehypeRaw from 'rehype-raw';
import rehypeHighlight from 'rehype-highlight'
import remarkFlexibleContainers from 'remark-flexible-containers'
import remarkGfm from "remark-gfm";
import { VFile } from "vfile";
import { unified } from "unified";

// 定义组件
export default defineComponent({
    name: 'VueMarkdownStreamRender',
    props: {
        markstr: {
            type: String,
            required: true,
            default: ''
        }
    },
    // 其他实现...
})

这个组件就像是一个「瑞士军刀」,集成了多种功能,让 Markdown 解析变得异常强大!

核心功能包解析 - 武林高手们的各司其职

1. Vue 核心团队

  • vue : 提供 h , defineComponent , computed 等核心 API,是整个组件的「骨架」
  • vue/jsx-runtime : 提供 Fragment , jsxs , jsx ,让我们可以在 Vue 中优雅地使用 JSX 语法,相当于给 Vue 「装上了 React 的小翅膀」

2. Unified 解析系统 - 解析界的「中央司令部」

  • unified : 这是整个解析系统的「大脑」,负责协调各个插件的工作。想象一下,它就像是一个「指挥官」,指挥着一群「小兵」(插件)协同作战
  • vfile : 提供文件处理功能,把 Markdown 字符串转换成统一的文件格式,相当于给文本「穿上了标准化的衣服」

3. Remark 家族 - Markdown 的「魔法师」

  • remark-parse : 将 Markdown 文本解析成抽象语法树(AST),就像是「翻译官」把中文翻译成一种中间语言
  • remark-math : 处理数学公式,让你的文档可以「高大上」地展示复杂数学表达式
  • remark-rehype : 将 Markdown AST 转换成 HTML AST,相当于「转换器」把中间语言翻译成另一种中间语言
  • remark-gfm : 支持 GitHub 风格的 Markdown 扩展功能,比如表格、任务列表等,让你的 Markdown 「与时俱进」
  • remark-flexible-containers : 提供灵活的容器功能,让你的内容布局更加多样化,就像是给内容「准备了各种形状的容器」

4. Rehype 家族 - HTML 的「美容师」

  • rehype-raw : 保留原始 HTML,让你的 Markdown 中混合的 HTML 代码也能正常工作,相当于「允许特殊人才保留自己的特色」
  • rehype-katex : 将数学公式渲染成漂亮的 HTML,让数学表达式「穿上漂亮的衣服」
  • rehype-highlight : 为代码块提供语法高亮,让你的代码「光彩照人」

5. 样式支持 - 颜值担当

  • katex.min.css : 数学公式的「时尚服饰」
  • github-dark.css : 代码高亮的「炫酷皮肤」

实现原理大揭秘 - 从文本到页面的神奇旅程

1. 组件结构设计

组件使用 Vue3 的 defineComponent 定义,接收一个必须的 markstr 属性,这是要解析的 Markdown 字符串。整个组件的设计非常简洁,就像一个「专注的翻译官」,只做一件事,但要做到极致!

2. 解析器链的构建

let unifiedProcessor = computed(() => {
    const processor = unified()
        .use(remarkParse, { allowDangerousHtml: true})
        .use(remarkFlexibleContainers)
        .use(remarkRehype, { allowDangerousHtml: true})
        .use(rehypeRaw)
        .use(remarkGfm)
        .use(rehypeKatex)
        .use(remarkMath)
        .use(rehypeHighlight);

    return processor;
});

这部分代码构建了一个「解析流水线」,就像工厂里的生产线一样,Markdown 文本会依次经过各个「加工环节」。这里使用 computed 确保解析器只在必要时重新创建,提高了性能。

3. 文件转换与 AST 处理

const createFile = (markstr) => {
    const file = new VFile();
    file.value = markstr;
    return file;
};

const generateVueNode = (tree) => {
    const vueVnode = toJsxRuntime(tree, {
        Fragment,
        jsx: jsx,
        jsxs: jsxs,
        passNode: true,
    });
    return vueVnode;
};

这两个函数分别负责:

  • createFile : 将 Markdown 字符串包装成 VFile 对象,就像是给文本「准备好行李,准备出发」
  • generateVueNode : 将解析后的 AST 树转换成 Vue 的虚拟 DOM 节点,相当于「将中间语言翻译成最终的目标语言」

4. 响应式渲染

const computedVNode = computed(() => {
    const processor = unifiedProcessor.value;
    const file = createFile(props.markstr);
    let result = generateVueNode(processor.runSync(processor.parse(file), file));
    return result;
});

return () => {
    return h(computedVNode.value);
};

这里是整个组件的「核心驱动」:

  • 使用 computed 响应式地计算虚拟 DOM,当 markstr 变化时,会自动重新解析并渲染
  • processor.parse(file) 将文件解析成 AST
  • processor.runSync(...) 运行所有插件处理 AST
  • 最后通过 h() 函数将生成的虚拟 DOM 渲染到页面上

技术亮点与设计精髓

  1. 响应式设计 : 利用 Vue3 的 computed ,实现了 Markdown 字符串变化时的自动重新解析和渲染
  2. 模块化插件链 : 采用统一的插件系统,各功能模块解耦,可以灵活地添加或移除功能
  3. 高性能优化 : 通过 computed 缓存解析器和虚拟 DOM,避免不必要的重复计算
  4. 丰富的功能支持 : 支持数学公式、代码高亮、GitHub 风格扩展等高级功能
  5. 错误处理机制 : 提供了 errorCaptured 钩子,捕获并记录解析过程中的错误

代码优化建议

虽然这个组件已经相当优秀,但还有一些小地方可以进一步完善:

  1. 插件顺序优化 : 目前的插件顺序可能不是最优的,建议调整为更合理的顺序:
const processor = unified()
    .use(remarkParse, { allowDangerousHtml: true})
    .use(remarkGfm) // GFM 应该在 early 阶段
    .use(remarkMath) // 数学支持也应该 early
    .use(remarkFlexibleContainers)
    .use(remarkRehype, { allowDangerousHtml: true})
    .use(rehypeRaw)
    .use(rehypeHighlight) // 代码高亮应该在 katex 之前
    .use(rehypeKatex); // 数学渲染作为最后一步
  1. 异步解析支持 : 考虑添加异步解析模式,对于大型文档可以提高性能和用户体验
  2. 缓存机制 : 可以添加基于内容哈希的缓存,避免相同内容的重复解析
  3. 错误边界 : 增强错误处理,提供更友好的错误提示给用户

总结

这个 Vue3 Markdown 解析组件就像是一个「智能翻译官 + 高级排版师」,它不仅能准确地将 Markdown 转换成 HTML,还能让最终的展示效果既美观又功能丰富。通过巧妙地组合各种开源工具,它实现了一个功能完备、性能优良的 Markdown 解析渲染系统。

无论是构建博客、文档系统还是知识库,这个组件都能为你的项目增添强大的内容展示能力。希望这篇文章能帮助你理解这个组件的实现原理,也欢迎大家提出宝贵的改进建议!

最后,如果你觉得这个组件对你有帮助,不妨点个赞并分享给更多的开发者朋友,让我们一起让 Markdown 解析变得更简单、更强大!

GitHub源码仓库地址 如果觉得好用,欢迎给个Star ⭐️ 支持一下!

JavaScript 词法作用域、作用域链与闭包:从代码看机制

在学习 JavaScript 的过程中,作用域 是一个绕不开的核心概念。很多人一开始会误以为“变量在哪调用,就在哪找”,但其实 JS 的作用域是 词法作用域(Lexical Scoping) ,也就是说,函数的作用域由它定义的位置决定,而不是调用位置。今天我们就通过几段简单的代码和图解,来深入浅出地理解 JavaScript 中的 词法作用域、作用域链 和 闭包 这三个重要机制。


一、什么是执行上下文?调用栈是如何工作的?

在 JavaScript 中,每当一个函数被调用时,都会创建一个「执行上下文」(Execution Context),并压入调用栈(Call Stack)。这个执行上下文包含两个关键部分:

  • 变量环境(Variable Environment) :存储用 var 声明的变量和函数声明。
  • 词法环境(Lexical Environment) :存储用 letconst 声明的块级作用域变量。

此外,每个执行上下文的词法环境中还有一个特殊的属性:outer,它指向该函数定义时所在的作用域的词法环境。

✅ 简单说:outer 指针决定了作用域查找路径,即“作用域链”

我们来看第一个例子(1.js):

function bar(){
  console.log(myName)
}

function foo(){
  var myName = '极客邦'
  bar()
}

var myName = '极客时间'
foo() // 输出: 极客时间

🤔 为什么输出的是 “极客时间” 而不是 “极客邦”?

很多人会误以为:bar() 是在 foo() 内部调用的,那它应该能访问到 foo() 里的 myName。但实际上,bar() 是在全局定义的,所以它的 outer 指向的是全局的词法环境。

bar() 执行时,它先在自己的词法环境中找 myName,没有;然后顺着 outer 指针去全局词法环境查找,找到了 var myName = '极客时间',于是打印出来。

👉 结论:作用域是由函数定义的位置决定的,而不是调用位置。这就是 词法作用域 的核心思想。


二、作用域链:查找变量的“路径”

作用域链就是由一个个执行上下文的 outer 指针串联而成的链条。我们可以通过以下代码进一步理解(2.js):

function bar(){
  var myName = '极客世界'
  let test1 = 100
  if(1){
    let myName = 'Chrome 浏览器'
    console.log(test) // ❌ 报错:test is not defined
  }
}

function foo(){
  var myName = '极客邦'
  let test = 2
  {
    let test = 3
    bar()
  }
}

var myName = '极客时间'

let myAge = 10

let test = 1

foo()

这段代码中,bar() 函数内部试图打印 test,但它找不到。

🔍 查找过程如下:

  1. bar() 的词法环境中找 test → 没有;
  2. bar()outer 指向的词法环境(全局)找 → 全局有 let test = 1,但注意!bar() 是在全局定义的,所以它只能访问全局的 test
  3. 但是 bar() 执行时,test 被重新赋值了吗?没有,因为 bar() 并不在 foo() 内部定义,所以它不会继承 foo() 的作用域。

因此,console.log(test) 实际上是在全局查找 test,结果是 1

⚠️ 注意:bar() 无法访问 foo() 中的 test,即使它是从 foo() 中调用的。这再次证明了:JS 是词法作用域,不是动态作用域

我们可以结合下面这张图来理解调用栈和作用域链的关系:

lQLPKHIJLY1ZLgPNAnrNBHawi45ov3eSr18JAvLiVN8GAA_1142_634.png

  • bar() 的执行上下文在栈顶;
  • 它的 outer 指向全局;
  • 因此查找 test 时,直接跳到了全局词法环境。

三、闭包:函数的“专属背包”

接下来是最有意思的——闭包(Closure)。

闭包的本质是:一个函数能够访问并记住其外部函数的变量,即使外部函数已经执行完毕

我们来看第三个例子(3.js):

function foo(){
  var myName = '极客时间'
  let test1 = 1
  const test2 = 2
  var innerBar = {
    getName: function(){
      console.log(test1)
      return myName
    },
    setName: function(newName){
      myName = newName
    }
  }
  return innerBar
}

var bar = foo() // foo 执行完毕,出栈
//它已经出栈了 那bar里面的变量应该回收吧?
//代码的执行证明 它不会回收
//foo函数确实是出栈了 但是getName/setName还需要foo()函数里面的变量 所以它会'打个包' (如果一个变量被引用的话 那么它们就不能顺利的进行垃圾回收)
bar.setName('极客邦')
bar.getName() // 输出: 极客邦

🤯 为什么 foo() 已经出栈了,还能修改和读取里面的变量?

因为在 foo() 返回 innerBar 对象时,getNamesetName 这两个方法都引用了 foo() 内部的变量 myNametest1。V8 引擎发现这些变量被“外部引用”了,就不会回收它们。

于是,foo() 的执行上下文虽然出栈了,但它的 词法环境被保留了下来,形成了一个“闭包”。

💡 这个被保留下来的词法环境,就是闭包本身。而其中被引用的变量,叫做 自由变量

我们再看一张图:

536a315a83aa48b870d03dd921b6c02a.png

  • setName 执行时,它的 outer 指向 foo() 的词法环境;
  • 即使 foo() 已经执行结束,这个环境依然存在;
  • 所以 myName 可以被修改为 '极客邦'
  • 后续调用 getName() 时,依然能拿到更新后的值。

✅ 闭包的形成条件:

  1. 函数嵌套函数;
  2. 内部函数被返回或暴露到外部;
  3. 内部函数引用了外部函数的变量。

四、闭包的生命周期:什么时候释放?

闭包并不会一直占用内存。只有当外部仍然持有对闭包函数的引用时,闭包才会被保留。

比如:

var bar = foo()
bar = null // 此时,bar 不再引用 innerBar,闭包可以被垃圾回收

一旦 bar 被置为 nullgetNamesetName 就不再被引用,V8 引擎就会回收 foo() 的词法环境,释放内存。

🔒 闭包是一种“记忆”机制,但也会带来内存泄漏的风险。使用完后记得释放引用!


五、总结:词法作用域 vs 动态作用域

特性 词法作用域(JavaScript) 动态作用域
查找依据 函数定义的位置 函数调用的位置
是否依赖调用栈顺序
示例语言 JavaScript、Python、C++ Bash、一些脚本语言

JavaScript 是典型的词法作用域语言,这意味着:

  • 函数的 outer 指针在编译阶段就确定;
  • 不管你在哪调用,只要函数定义在全局,它的 outer 就指向全局;
  • 闭包的存在正是基于这种静态作用域的特性。

六、常见误区澄清

❌ 误区一:“在哪个函数里调用,就查哪个函数的作用域”

这是动态作用域的思维。JavaScript 不是这样工作的。

✅ 正确做法:看函数定义在哪outer 指向哪里,就从哪里开始查。

❌ 误区二:“函数执行完,里面的变量就没了”

不一定!如果函数返回了一个引用了内部变量的函数,那么这些变量会被保留,形成闭包。

❌ 误区三:“闭包就是匿名函数”

不对。闭包是一种现象,不一定是匿名函数。只要满足条件,任何函数都可以形成闭包。


七、图解回顾

我们再来快速回顾一下几张关键图:

图1:bar() 调用时的作用域链

lQLPKHIJLY1ZLgPNAnrNBHawi45ov3eSr18JAvLiVN8GAA_1142_634.png

  • bar()outer 指向全局;
  • 查找 test 时,从全局找到 test = 1

图2:foo() 执行时的执行上下文

d70143c661ed9c209cdc5991f27fcab9.png

  • foo() 的词法环境包含 test1, test2
  • 变量环境包含 myName, innerBar

图3:闭包生效时的状态

lQLPJwCC0KWlAbPNA03NBHawINj2y-qMdT0JAv8hJbJKAA_1142_845.png

  • setName 执行时,outer 指向 foo() 的词法环境;
  • 即使 foo() 已出栈,数据依然可访问。

八、写在最后

JavaScript 的作用域机制看似复杂,但只要抓住一个核心:词法作用域 + outer 指针 + 闭包,就能轻松应对大多数场景。

记住一句话:

函数的作用域由它定义的位置决定,而不是调用的位置。

当你看到一个函数在别处被调用时,不要慌,先问一句:“它是在哪定义的?” 然后顺着 outer 指针去找,一切就清晰了。

闭包虽然强大,但也需要谨慎使用,避免不必要的内存占用。

希望这篇文章能帮你理清思路,下次遇到作用域问题时,不再迷茫!


📌 附注:本文所用代码和图解均来自个人学习笔记,图片仅为示意,实际运行时请自行验证逻辑。欢迎在评论区交流你的理解!

JavaScript 中 this 指向问题

一、Bug 场景

在一个 JavaScript 的网页交互项目中,有一个构造函数定义了一个对象,该对象包含一个方法用于更新 DOM 元素的文本内容。同时,为了实现异步操作,在这个方法内部使用了 setTimeout 来模拟一些延迟任务。

二、代码示例

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF - 8">
    <meta name="viewport" content="width=device - width, initial - scale = 1.0">
    <title>this指向问题</title>
</head>

<body>
    <div id="target"></div>

    <script>
        function DOMUpdater() {
            this.targetElement = document.getElementById('target');
            this.updateText = function () {
                setTimeout(function () {
                    this.targetElement.textContent = '更新后的文本';
                }, 1000);
            };
        }

        const updater = new DOMUpdater();
        updater.updateText();
    </script>
</body>

</html>

三、问题描述

  1. 预期行为:等待 1 秒后,id 为 target 的 div 元素的文本内容应更新为 “更新后的文本”。
  2. 实际行为:在控制台中会报错 Uncaught TypeError: Cannot set property 'textContent' of null。这是因为在 setTimeout 内部的回调函数中,this 的指向发生了变化。在非严格模式下,setTimeout 回调函数中的 this 指向全局对象(在浏览器环境中是 window),而不是 DOMUpdater 实例对象。由于 window 中没有 targetElement 属性,所以会导致 this.targetElement 为 null,进而引发错误。

四、解决方案

  1. 使用箭头函数:箭头函数没有自己的 this,它的 this 继承自外层作用域,这样就可以保持 this 指向 DOMUpdater 实例对象。
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF - 8">
    <meta name="viewport" content="width=device - width, initial - scale = 1.0">
    <title>this指向问题 - 箭头函数解决</title>
</head>

<body>
    <div id="target"></div>

    <script>
        function DOMUpdater() {
            this.targetElement = document.getElementById('target');
            this.updateText = function () {
                setTimeout(() => {
                    this.targetElement.textContent = '更新后的文本';
                }, 1000);
            };
        }

        const updater = new DOMUpdater();
        updater.updateText();
    </script>
</body>

</html>
  1. 使用变量保存 this:在 updateText 方法内部,使用一个变量(通常命名为 self 或 that)来保存 this 的值,然后在 setTimeout 回调函数中使用这个变量。
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF - 8">
    <meta name="viewport" content="width=device - width, initial - scale = 1.0">
    <title>this指向问题 - 变量保存this解决</title>
</head>

<body>
    <div id="target"></div>

    <script>
        function DOMUpdater() {
            this.targetElement = document.getElementById('target');
            this.updateText = function () {
                const self = this;
                setTimeout(function () {
                    self.targetElement.textContent = '更新后的文本';
                }, 1000);
            };
        }

        const updater = new DOMUpdater();
        updater.updateText();
    </script>
</body>

</html>
  1. 使用 bind 方法bind 方法可以创建一个新的函数,在这个新函数中,this 被绑定到指定的对象。
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF - 8">
    <meta name="viewport" content="width=device - width, initial - scale = 1.0">
    <title>this指向问题 - bind解决</title>
</head>

<body>
    <div id="target"></div>

    <script>
        function DOMUpdater() {
            this.targetElement = document.getElementById('target');
            this.updateText = function () {
                setTimeout(function () {
                    this.targetElement.textContent = '更新后的文本';
                }.bind(this), 1000);
            };
        }

        const updater = new DOMUpdater();
        updater.updateText();
    </script>
</body>

</html>

告别服务器!小程序纯前端“图片转 PDF”工具,隐私安全又高效

1. 背景与痛点:纯前端实践的动力

在开发小程序时,实现如“图片转 PDF”这样的功能时,常常面临以下挑战:

  • 隐私担忧:将图片上传到服务器进行转换,用户担心图片内容泄露。对于个人证件、私密照片等敏感内容,这一顾虑尤为突出。
  • 网络依赖与效率:转换过程需要频繁与服务器交互,在弱网环境下速度慢、不稳定,甚至可能因上传大文件而失败。
  • 服务器成本:每一次转换都意味着服务器资源的消耗(存储、计算、带宽),对于开发者而言,成本不容忽视。

为了解决这些痛点,我们探索了一个更优的实现路径:纯前端、在小程序本地完成图片到 PDF 的转换

2. 核心思路:本地文件系统与 pdf-lib 的巧妙结合

在小程序中实现纯前端图片转 PDF,我们的核心思路是:

  1. 图片本地化处理:充分利用小程序强大的本地文件系统能力,将用户选择的图片读取到本地临时路径。
  2. PDF 文档构建:引入功能丰富的 JavaScript 库 pdf-lib,在小程序运行时直接在前端环境创建和操作 PDF 文件。
  3. 最终文件保存:将 pdf-lib 生成的 PDF 数据流保存为本地文件,供用户直接预览或分享。

这种方式让整个转换过程都在用户的小程序沙箱环境内完成,图片数据不会离开用户手机,极大保障了数据隐私和安全性,同时显著提升了转换效率并降低了服务器成本。

3. 技术核心:pdf-lib 的引入与应用

pdf-lib 是一个强大的纯 JavaScript PDF 库,支持在多种 JavaScript 环境下创建和修改 PDF 文件,完美契合小程序这种前端应用场景。

3.1 库的引入

你需要将 pdf-lib 的小程序兼容版本(通常是 pdf-lib.min.js)放置在你的项目目录中,并通过 require 引入:

const { PDFDocument, degrees, PageSizes } = require('./pdf-lib.min.js');
const fs = wx.getFileSystemManager(); // 小程序文件管理器实例

3.2 转换逻辑概览

整个图片转 PDF 的流程可分解为以下几个关键步骤:

  1. 图片预处理:获取每张图片的尺寸、类型 (wx.getImageInfo),并将其读取为 Base64 格式 (fs.readFile),这是 pdf-lib 嵌入图片所需的标准数据格式。
  2. 创建 PDF 文档:初始化一个空的 PDFDocument 对象。
  3. 逐页添加图片:遍历所有图片,为每张图片创建一个新的 PDF 页面。根据图片的原始尺寸和类型,将其嵌入到 PDF 中,并进行智能缩放、居中。对于横向图片,还会自动旋转页面 90 度以更好地适应 A4 纸张。
  4. 生成与保存:将构建好的 PDF 文档保存为 Base64 编码的字符串,再通过小程序文件系统的 fs.writeFile 接口,写入到本地的临时文件路径。
  5. 返回结果:将生成的 PDF 文件本地路径返回给业务层,用于后续的预览或分享。

4. 核心代码:img2pdf.js

以下是我们帮小忙工具箱实现图片转 PDF 功能的核心源代码。

const { PDFDocument, degrees, PageSizes } = require('./pdf-lib.min.js');
const fs = wx.getFileSystemManager()
/**
 * 把图片转成pdf
 * @param {Array} urls 图片url数组
 * @returns {String} pdfUrl pdf文件url
 */
export async function img2pdf(urls) {
if (typeof urls == 'string') {
urls = [urls]
}

// 图片信息
const imageInfo = urls.map((url) => {
return wx.getImageInfo({
src: url
});
});
const imageInfoRes = await Promise.all(imageInfo);
console.log(imageInfoRes);

// 图片base64
const imageBase64 = urls.map((url) => {
return readFile(url, "base64");
});
const imageBase64Res = await Promise.all(imageBase64);
console.log(imageBase64Res);

const pdfDoc = await PDFDocument.create();

for (let i = 0; i < imageInfoRes.length; i++) {
const {
type,
width,
height
} = imageInfoRes[i];
let pdfImage = "";
if (type === 'jpeg') {
pdfImage = await pdfDoc.embedJpg(imageBase64Res[i]);
} else if (type === 'png') {
pdfImage = await pdfDoc.embedPng(imageBase64Res[i]);
}

const page = pdfDoc.addPage(PageSizes.A4);
const {
width: pageWidth,
height: pageHeight
} = page.getSize(); // 获取页面尺寸

let drawOptions = {};

// 如果图片是宽大于高,则旋转
if (width > height) {
// 页面旋转后,可用于绘制的"宽度"实际上是原始页面的高度,"高度"是原始页面的宽度
const scaled = pdfImage.scaleToFit(pageHeight, pageWidth); // 注意参数顺序因为页面旋转了

drawOptions = {
// x: scaled.height + (pageWidth - scaled.height) / 2,   // 注意这里用的是 scaled.height
x: (pageWidth - scaled.height) / 2,
y: (pageHeight - scaled.width) / 2 + scaled.width,
width: scaled.width,
height: scaled.height,
rotate: degrees(270),
};
console.log('drawOptions', drawOptions);
} else {
// 图片是纵向或方形的
const scaled = pdfImage.scaleToFit(pageWidth, pageHeight);
drawOptions = {
x: (pageWidth - scaled.width) / 2, // 居中 X
y: (pageHeight - scaled.height) / 2, // 居中 Y
width: scaled.width,
height: scaled.height,
};
}
page.drawImage(pdfImage, drawOptions);
}

// 3. 获取 PDF 的 Uint8Array
const docBase64 = await pdfDoc.saveAsBase64();
const timestamp = Date.now();
const pdfPath = await base64ToFile(docBase64, `/${timestamp}.pdf`);


return pdfPath;
}

/**
 * base64转本地文件
 * @param {string} base64 base64字符串
 * @param {string} fileName  文件名
 * @returns {Promise} Promise 文件路径
 */
function base64ToFile(base64, fileName) {
const {
promise,
resolve,
reject
} = Promise.withResolvers();
const filePath = wx.env.USER_DATA_PATH + fileName;
fs.writeFile({
filePath,
data: base64,
encoding: "base64",
success: res => {
resolve(filePath)
},
fail: err => {
reject(err)
}
});
return promise;
}

/**
 * 使用Promise读取文件
 * @param {string} filePath 文件路径
 * @param {string} encoding 文件编码
 * @returns {Promise} Promise对象
 */
function readFile(filePath, encoding = 'utf8') {
const {
promise,
resolve,
reject
} = Promise.withResolvers();
fs.readFile({
filePath,
encoding,
success(fileRes) {
resolve(fileRes.data)
},
fail(err) {
reject(err)
}
});
return promise;
}

5. 小程序端应用示例

在页面中,可以通过简单的交互完成转换。

// pages/image-to-pdf/index.js
import { img2pdf } from '../../utils/img2pdf'; // 引入转换工具

Page({
  data: {
    selectedImages: [], // 用户选择的图片临时路径数组
    pdfPath: '',
    loading: false
  },

  // 触发图片选择
  async chooseImage() {
    const { tempFiles } = await wx.chooseMedia({
      count: 9, // 最多选择 9 张图片
      mediaType: ['image'],
      sizeType: ['original', 'compressed'], // 可以选择原图或压缩图
      sourceType: ['album', 'camera'],
    });
    this.setData({ selectedImages: tempFiles.map(file => file.tempFilePath) });
  },

  // 执行图片转 PDF 转换
  async convertToPdf() {
    if (this.data.selectedImages.length === 0) {
      wx.showToast({ title: '请先选择图片', icon: 'none' });
      return;
    }

    this.setData({ loading: true });
    wx.showLoading({ title: '转换中...' });

    try {
      const pdfFilePath = await img2pdf(this.data.selectedImages);
      this.setData({ pdfPath: pdfFilePath });
      wx.hideLoading();
      wx.showToast({ title: '转换成功!', icon: 'success' });
      
      // 转换成功后,自动打开 PDF 预览
      wx.openDocument({
        filePath: pdfFilePath,
        fileType: 'pdf',
        success: res => console.log('打开 PDF 成功', res),
        fail: err => console.error('打开 PDF 失败', err)
      });

    } catch (error) {
      wx.hideLoading();
      wx.showToast({ title: '转换失败!', icon: 'error' });
      console.error('图片转 PDF 发生错误', error);
    } finally {
      this.setData({ loading: false });
    }
  }
})

6. 经验总结与注意事项

  1. 文件体积与性能

    • pdf-lib 库本身有一定体积(通常在几百 KB),会增加小程序包体大小,我们是使用分包,所以不影响主包。
    • 图片数量越多、分辨率越高,转换耗时越长,内存占用越大。建议在选择图片时提示用户合理数量或适当压缩。
    • pdf横向图片旋转需要额外计算和处理,可能会略微增加复杂性,如果觉得复杂,也可以直接判断图片是否是纵向,如果是横向使用canvas旋转图片,逻辑上就毕竟简单了。
  2. Promise.withResolvers() 兼容性

    • 代码使用了 Promise.withResolvers(),目前大多数小程序环境和浏览器中兼容性可能不好,我自己做了兼容。
  3. 本地文件系统限制

    • wx.env.USER_DATA_PATH 路径下的文件是小程序沙箱环境特有的,用户无法直接在系统文件管理器中找到。
    • 生成的文件是临时文件,小程序关闭或长时间不用可能被系统清理。如果需要长期保存,需引导用户通过 wx.saveFile (保存到相册或本地文件) 或上传云存储。
  4. 图片类型支持pdf-lib 主要支持 JPEG 和 PNG 格式。其他格式(如 WebP、GIF)需要先转换为 JPEG/PNG 再进行嵌入,可以利用canvas实现,后面会分享。

写在最后

纯前端实现“图片转 PDF”功能,不仅提升了用户体验,更重要的是有效保护了用户的数字隐私。这在追求用户信任和数据安全的小程序生态中,无疑是一个值得推广的实践。

希望这次分享能为你带来启发,共同探索小程序前端能力的更多可能性!


我的变量去哪了?JS 作用域入门指南

在 JavaScript 的学习过程中,作用域变量提升是两个绕不开的核心概念。它们不仅影响着代码的执行逻辑,也常常成为初学者“踩坑”的重灾区。本文将结合几段典型代码,从实际运行结果出发,梳理 JS 中作用域的演变过程,重点解释 var 的缺陷let/const 的改进,以及现代 JS 引擎如何通过执行上下文统一处理这两类变量声明。


一、变量提升:JS 的“历史包袱”

先看这段代码(1.js):

showName() // ✅ 正常执行
console.log(myname) // undefined

var myname = '路明非'

function showName(){
  console.log('函数showName 执行了')
}

这里体现了两个关键现象:

  • 函数声明提升showName 不仅声明被提升,函数体也被提升,因此可以在定义前调用。
  • 变量提升(仅声明)var myname 的声明被提升到顶部,但赋值仍在原位置执行,所以首次 console.log 输出 undefined

这就是经典的 hoisting(变量提升) 机制。它源于 JS 引擎的两阶段执行模型:编译阶段收集声明,执行阶段进行赋值和调用。

⚠️ 变量提升虽解决了早期 JS 的作用域问题,但也带来了不符合直觉的行为,被视为语言设计上的缺陷。


二、作用域链:全局 vs 局部

2.js 中:

var globalVar = '我是全局变量'

function myFunction(){
  var localVar = '我是局部变量'
  console.log(globalVar) // ✅ 打印全局变量
  console.log(localVar)  // ✅ 打印局部变量
}

myFunction()
console.log(globalVar) // ✅
console.log(localVar)  // ❌ ReferenceError

这展示了 作用域链 的查找规则:

  • 函数内部优先查找局部作用域
  • 若未找到,则沿作用域链向上查找至全局作用域
  • 全局无法访问函数内部的局部变量

这是 JS 作用域的基本规则,也是封装和避免命名冲突的基础。


三、var 的致命伤:不支持块级作用域

来看 3.js

var name = '刘锦苗'

function showName(){
  console.log(name) // undefined
  if(false){
    var name = '大厂的苗子'
  }
  console.log(name) // undefined
}

尽管 if(false) 块永远不会执行,但 var name 仍被提升到函数作用域顶部,导致函数内 name 被初始化为 undefined。这是因为 var 不支持块级作用域,其声明会被提升到最近的函数或全局作用域。

对比 4.js 使用 let

var name = '刘锦苗'

function showName() {
  console.log(name) // '刘锦苗'
  if (false) {
    let name = '大厂的苗子' // ❌ 不会影响外层
  }
}

由于 let 具有块级作用域if 内的 name 仅在该块中有效,不会污染函数作用域,因此外层仍能正确访问全局变量。


四、let/const 如何解决提升问题?

8.js 展示了一个关键特性:

let name = '刘锦苗'

{
  console.log(name) // ❌ ReferenceError: Cannot access 'name' before initialization
  let name = '大厂的苗子'
}

这里报错并非因为变量未声明,而是进入了 暂时性死区(Temporal Dead Zone, TDZ)
let/const 虽然也会“提升”,但不会像 var 那样初始化为 undefined,而是在声明前处于不可访问状态。

这正是 ES6 对变量提升缺陷的修正:提升存在,但禁止提前访问


五、执行上下文视角:变量环境 vs 词法环境

现代 JS 引擎(如 V8)通过 执行上下文(Execution Context) 统一管理变量:

  • 变量环境 :存放 var 声明的变量。
  • 词法环境 :存放 let/const 声明的变量,并支持块级作用域栈结构

7.js 为例:

function foo(){
  var a = 1
  let b = 2
  {
    let b = 3  // 新的 b,与外层无关
    var c = 4
    let d = 5
    console.log(a) // 1(从变量环境中找到)
    console.log(b) // 3(当前块级作用域栈顶)
  }
  console.log(b) // 2(块级作用域出栈,恢复外层 b)
  console.log(c) // 4(var 提升到函数作用域)
  console.log(d) // ❌ ReferenceError(d 已随块级作用域销毁)
}

这里的关键在于:

  • let 在块级作用域中创建独立的绑定,块执行完后自动出栈销毁;
  • var 无视块级作用域,始终属于函数或全局作用域;
  • 引擎通过词法环境的栈结构实现了对块级作用域的支持。

六、为什么早期 JS 要这样设计?

JavaScript 最初是“KPI 项目” ,设计周期极短,目标只是给网页加点动态效果。为了快速实现,设计者选择了最简单的方案:

  • 不引入复杂的块级作用域;
  • 用“变量提升”统一处理作用域问题;
  • 用函数模拟“类”,规避面向对象的复杂性。

这种设计在当时够用,但随着 JS 应用复杂度飙升,var 的缺陷日益凸显——变量覆盖、生命周期混乱、难以调试。

ES6 引入 let/const 和块级作用域,正是对这一历史问题的修复。


结语:拥抱 let/const,理解执行上下文

如今,我们应优先使用 letconst,避免 var 带来的陷阱。同时,理解 JS 引擎如何通过 变量环境 + 词法环境 的双轨机制,兼容新旧语法,是深入掌握作用域的关键。

JavaScript 的演进告诉我们:好的语言设计,既要向前兼容,也要勇于修正过去的错误

通过这几段小代码,我们不仅看到了变量提升的“坑”,更见证了 JS 如何在保持灵活性的同时,逐步走向严谨与规范。希望这篇文章能帮你理清思路,在掘金社区分享你的成长!

HelloGitHub 第 116 期

本期共有 40 个项目,包含 C 项目 (1),C# 项目 (2),C++ 项目 (4),Go 项目 (4),Java 项目 (2),JavaScript 项目 (5),Kotlin 项目 (2),PHP 项目 (1),Python 项目 (5),Rust 项目 (2),Swift 项目 (2),人工智能 (5),其它 (5)

还在为组件通信头疼?defineExpose让你彻底告别传值烦恼

最近在写Vue 3项目的时候,你是不是经常遇到这样的场景:父组件想要调用子组件里的方法,但在<script setup>里却不知道该怎么暴露出去?

每次都要翻文档查半天,最后可能还是用了不太优雅的解决方案。

别担心,今天我要给你介绍的defineExpose,就是专门解决这个痛点的神器。它能让你在<script setup>中轻松暴露组件方法,让组件通信变得前所未有的简单。

读完这篇文章,你不仅能掌握defineExpose的核心用法,还能学到几个实际项目中的最佳实践,从此再也不怕复杂的组件通信了!

为什么需要defineExpose?

在深入了解defineExpose之前,我们先来看看为什么会有这个API的出现。

在Vue 3的<script setup>语法糖出现之前,我们通常使用setup()函数来编写组件逻辑。在那个时候,如果要暴露方法给父组件,我们会这样做:

// 传统setup()函数写法
export default {
  setup() {
    const showMessage = () => {
      console.log('Hello from child component!')
    }
    
    // 需要手动返回
    return {
      showMessage
    }
  }
}

而在<script setup>中,默认情况下所有顶层的绑定(包括变量、函数)都是私有的,父组件无法直接访问。这就带来了一个问题:当父组件确实需要调用子组件的某些方法时,我们该怎么办?

这时候,defineExpose就闪亮登场了!

defineExpose基础用法

defineExpose是Vue 3专门为<script setup>设计的编译器宏,用来显式暴露组件实例上的属性和方法。

让我们从一个最简单的例子开始:

// ChildComponent.vue
<script setup>
import { ref } from 'vue'

// 子组件内部的状态
const count = ref(0)
const message = '这是子组件的消息'

// 子组件内部的方法
const increment = () => {
  count.value++
  console.log('计数器增加了:', count.value)
}

const showAlert = () => {
  alert('这是子组件暴露的方法!')
}

// 使用defineExpose暴露需要让父组件访问的属性和方法
defineExpose({
  increment,
  showAlert,
  count
})
</script>

<template>
  <div>
    <p>子组件计数: {{ count }}</p>
  </div>
</template>

在父组件中,我们可以这样使用:

// ParentComponent.vue
<script setup>
import { ref, onMounted } from 'vue'
import ChildComponent from './ChildComponent.vue'

// 创建子组件的模板引用
const childRef = ref(null)

onMounted(() => {
  // 组件挂载后,可以通过childRef访问暴露的方法和属性
  console.log('子组件的count值:', childRef.value.count)
})

// 调用子组件暴露的方法
const handleButtonClick = () => {
  if (childRef.value) {
    childRef.value.increment()
    childRef.value.showAlert()
  }
}
</script>

<template>
  <div>
    <ChildComponent ref="childRef" />
    <button @click="handleButtonClick">调用子组件方法</button>
  </div>
</template>

看到这里,你可能已经明白了defineExpose的基本用法。它就像是在组件内部开了一个小窗口,让父组件能够看到和使用你特意暴露出来的功能。

defineExpose的高级技巧

掌握了基础用法后,让我们来看看一些在实际项目中特别有用的高级技巧。

选择性暴露

在实际开发中,我们通常不希望把所有内部方法和状态都暴露出去。defineExpose让我们可以精确控制暴露的内容:

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

// 内部状态 - 不暴露
const internalData = ref('这是内部数据,父组件看不到')

// 需要暴露的状态
const userInfo = reactive({
  name: '张三',
  age: 25
})

// 内部方法 - 不暴露
const internalMethod = () => {
  console.log('这是内部方法')
}

// 需要暴露的方法
const publicMethod = () => {
  console.log('这是对外公开的方法')
  internalMethod() // 内部方法可以在暴露的方法内部调用
}

const updateUserInfo = (newInfo) => {
  Object.assign(userInfo, newInfo)
}

// 只暴露必要的部分
defineExpose({
  publicMethod,
  updateUserInfo,
  userInfo
  // internalData 和 internalMethod 不会被暴露
})
</script>

组合式函数与defineExpose的结合

在大型项目中,我们经常使用组合式函数来组织逻辑。结合defineExpose,可以让代码更加清晰:

// useFormValidation.js - 表单验证的组合式函数
import { ref, computed } from 'vue'

export function useFormValidation() {
  const formData = ref({
    username: '',
    email: '',
    password: ''
  })

  const errors = ref({})

  // 计算属性 - 验证用户名
  const isUsernameValid = computed(() => {
    return formData.value.username.length >= 3
  })

  // 验证邮箱
  const validateEmail = () => {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    errors.value.email = emailRegex.test(formData.value.email) 
      ? '' 
      : '邮箱格式不正确'
  }

  // 整体验证
  const validateForm = () => {
    validateEmail()
    return Object.values(errors.value).every(error => !error)
  }

  // 重置表单
  const resetForm = () => {
    formData.value = { username: '', email: '', password: '' }
    errors.value = {}
  }

  return {
    formData,
    errors,
    validateForm,
    resetForm,
    isUsernameValid
  }
}

在组件中使用:

// FormComponent.vue
<script setup>
import { useFormValidation } from './useFormValidation'

const { 
  formData, 
  errors, 
  validateForm, 
  resetForm,
  isUsernameValid 
} = useFormValidation()

// 提交表单的方法
const submitForm = () => {
  if (validateForm()) {
    console.log('表单验证通过,准备提交:', formData)
    // 这里可以添加提交逻辑
  }
}

// 只暴露父组件需要的方法
defineExpose({
  validateForm,
  resetForm,
  submitForm
})
</script>

<template>
  <form>
    <input v-model="formData.username" placeholder="用户名" />
    <span v-if="!isUsernameValid">用户名至少3个字符</span>
    
    <input v-model="formData.email" placeholder="邮箱" />
    <span>{{ errors.email }}</span>
    
    <button type="button" @click="submitForm">提交</button>
  </form>
</template>

实际项目中的最佳实践

在真实项目开发中,正确使用defineExpose能让你的代码更加健壮和可维护。

类型安全的defineExpose

如果你使用TypeScript,可以为暴露的内容添加类型定义:

<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)

const increment = () => {
  count.value++
}

const resetCount = (value: number = 0) => {
  count.value = value
}

// 定义暴露接口的类型
interface ExposedProps {
  increment: () => void
  resetCount: (value?: number) => void
  count: number
}

// 类型安全的暴露
defineExpose<ExposedProps>({
  increment,
  resetCount,
  count
})
</script>

表单组件的完整示例

让我们看一个更完整的表单组件示例,这在管理后台系统中非常常见:

// AdvancedForm.vue
<script setup>
import { ref, reactive, computed, watch } from 'vue'

// 表单数据
const formModel = reactive({
  title: '',
  content: '',
  category: '',
  tags: [],
  publishTime: ''
})

// 表单验证状态
const validationState = reactive({
  isTitleValid: false,
  isContentValid: false,
  isCategoryValid: false
})

// 计算属性 - 表单是否完整
const isFormComplete = computed(() => {
  return Object.values(validationState).every(valid => valid)
})

// 监听表单变化
watch(() => formModel.title, (newTitle) => {
  validationState.isTitleValid = newTitle.length >= 5
})

watch(() => formModel.content, (newContent) => {
  validationState.isContentValid = newContent.length >= 10
})

watch(() => formModel.category, (newCategory) => {
  validationState.isCategoryValid = !!newCategory
})

// 提交方法
const submit = async () => {
  if (!isFormComplete.value) {
    throw new Error('表单未填写完整')
  }
  
  // 模拟API调用
  console.log('提交数据:', formModel)
  return { success: true, message: '提交成功' }
}

// 重置方法
const reset = () => {
  Object.assign(formModel, {
    title: '',
    content: '',
    category: '',
    tags: [],
    publishTime: ''
  })
  Object.keys(validationState).forEach(key => {
    validationState[key] = false
  })
}

// 获取表单数据
const getFormData = () => {
  return { ...formModel }
}

// 设置表单数据
const setFormData = (newData) => {
  Object.assign(formModel, newData)
}

// 暴露给父组件的方法和属性
defineExpose({
  submit,
  reset,
  getFormData,
  setFormData,
  isFormComplete
})
</script>

<template>
  <div class="advanced-form">
    <input v-model="formModel.title" placeholder="文章标题" />
    <textarea v-model="formModel.content" placeholder="文章内容"></textarea>
    <select v-model="formModel.category">
      <option value="">选择分类</option>
      <option value="tech">技术</option>
      <option value="life">生活</option>
    </select>
  </div>
</template>

父组件使用示例:

// ParentPage.vue
<script setup>
import { ref } from 'vue'
import AdvancedForm from './AdvancedForm.vue'

const formRef = ref(null)

// 保存草稿
const saveDraft = async () => {
  try {
    const result = await formRef.value.submit()
    console.log('保存成功:', result)
  } catch (error) {
    console.error('保存失败:', error.message)
  }
}

// 重置表单
const clearForm = () => {
  formRef.value.reset()
}

// 从服务器加载数据到表单
const loadFormData = () => {
  const mockData = {
    title: 'Vue 3高级技巧',
    content: '这是一篇关于Vue 3的文章...',
    category: 'tech',
    tags: ['vue', 'javascript'],
    publishTime: '2024-01-20'
  }
  formRef.value.setFormData(mockData)
}
</script>

<template>
  <div>
    <AdvancedForm ref="formRef" />
    <button @click="saveDraft">保存草稿</button>
    <button @click="clearForm">清空表单</button>
    <button @click="loadFormData">加载数据</button>
  </div>
</template>

常见问题与解决方案

在实际使用defineExpose时,你可能会遇到一些典型问题,这里我为你整理了解决方案。

问题1:模板引用为null

这是最常见的问题之一,通常是因为在组件挂载完成前就尝试访问引用。

// ❌ 错误用法
const childRef = ref(null)
console.log(childRef.value) // 输出: null

// ✅ 正确用法
const childRef = ref(null)

onMounted(() => {
  console.log(childRef.value) // 输出: 组件实例
})

// 或者在事件处理程序中访问
const handleClick = () => {
  if (childRef.value) {
    childRef.value.someMethod()
  }
}

问题2:方法未定义

如果调用方法时出现"undefined"错误,检查是否正确定义和暴露了该方法。

// ❌ 忘记暴露方法
<script setup>
const myMethod = () => {
  console.log('hello')
}
// 忘记调用 defineExpose
</script>

// ✅ 正确暴露
<script setup>
const myMethod = () => {
  console.log('hello')
}

defineExpose({
  myMethod
})
</script>

问题3:响应式数据更新问题

当父组件修改子组件暴露的响应式数据时,需要注意:

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

const count = ref(0)

// 提供安全的方法来修改数据
const safeIncrement = () => {
  count.value++
}

const safeSetCount = (newValue) => {
  if (typeof newValue === 'number') {
    count.value = newValue
  }
}

defineExpose({
  count,
  safeIncrement,
  safeSetCount
  // 不直接暴露count的.value属性,而是通过方法控制
})
</script>

总结

通过今天的学习,相信你已经对Vue 3的defineExpose有了全面的了解。

defineExpose<script setup>中的编译器宏,专门用于暴露组件方法和属性给父组件。它的核心价值在于:

第一,提供了精确的控制能力,让你能够决定哪些内容对外可见,保持组件的封装性。

第二,与组合式函数完美配合,让复杂的组件逻辑能够清晰地组织和暴露。

第三,在TypeScript项目中提供完整的类型安全支持。

最重要的是,它解决了<script setup>中组件通信的关键痛点,让父组件能够以类型安全的方式调用子组件的功能。

在 React + React Router v7 SSR 项目里做多端适配,我踩的两个坑

前言

最近帮人维护一个 SSRReact 项目,需要在同一套代码里适配 PC、手机和平板。页面里大量逻辑是基于 deviceTypedesktop | tablet | mobile)来做布局和交互差异的。

刚开始看上去只是“判断一下宽度 + 写点媒体查询”的小需求,但在 SSR + iOS 真机 这两个维度叠加之后,踩了不少兼容性的坑,特别是:

  1. SSR 环境下拿不到 window,导致页面在客户端首次渲染时闪烁;
  2. iOS 真机上 window.innerWidth 获取存在延迟,导致偶发性识别错误。

这篇文章主要记录一下这两个问题的具体表现、解决思路,以及最终抽出来的一个 useDeviceType Hook

如果文章对你有帮助的话,记得一键三连哟。有问题和疑惑的话也可以在评论区留言。我会第一时间回复大家,如果觉得我的文章哪里有知识点错误的话,也恳请能够告知,把错的东西理解成对的,无论在什么行业,都是致命的。

问题背景

因为是 SSR 项目,服务端初始会把页面 HTML 渲染出来,再由客户端进行 hydration
同时,我们希望做到:

  • PC 端:展示完整布局;
  • 手机端:展示精简布局,组件层级也有差异;
  • 平板端:布局/交互介于两者之间。

项目里有大量类似下面这样的逻辑:

const { deviceType } = useDeviceType();

return (
  <>
    {deviceType === "desktop" && <DesktopLayout />}
    {deviceType === "tablet" && <TabletLayout />}
    {deviceType === "mobile" && <MobileLayout />}
  </>
);

这意味着 初始渲染阶段的设备类型判断非常关键,一旦前后不一致,就会导致闪烁、Hydration Mismatch 等问题。

SSR 环境中拿不到 window 导致页面闪烁

问题表现

  • deviceType 默认假定为 "desktop"
  • 用户在 手机 上打开页面时:
    • SSR 阶段:服务端根据默认值 "desktop" 渲染;
    • 客户端 hydration 后:useEffect / useLayoutEffect 中根据 window.innerWidth 判断出其实是 "mobile",然后状态更新;
  • 在这个切换的瞬间,布局会从 desktop 版“瞬间”跳为 mobile 版,非常明显的闪烁。

如果你用的是 useLayoutEffect,在 SSR 环境下还会看到:

Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output...

问题根源

  • SSR 环境没有 window,无法用宽度判断设备;
  • 初始渲染的 HTMLdesktop 版,客户端 hydration 时才“意识到”这是手机;
  • 由于初始 state 和实际设备不匹配,导致:
    • UI 闪烁;
    • 以及 hydration mismatch 警告。

解决思路:同构版的 useLayoutEffect

  • 在浏览器端:使用 useLayoutEffect,在浏览器绘制前完成 DOM 调整,尽量减少闪烁;
  • **在 SSR 端:不要使用 useLayoutEffect,避免警告,退化为普通的 useEffect

实现方式很简单,封装一个Hook

import { useEffect, useLayoutEffect } from "react";

const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? useLayoutEffect : useEffect;

然后在需要做首屏布局调整的地方,统一用 useIsomorphicLayoutEffect 替换 useLayoutEffect

再配合一个关键点:初始值保持一致

为了避免 hydration mismatch,初始 state 必须在 SSR 和客户端首次渲染时保持一致,所以可以这么写:

const [deviceInfo, setDeviceInfo] = useState<DeviceInfo>({
  deviceType: "desktop",  // 服务端 & 客户端初始都认为是 desktop
  isMinWidth: false,
  currentWidth: 1024,
});

挂载后,再通过 window.innerWidth + UA 去纠正这个默认值。

iOS 真机上 innerWidth 延迟导致设备误判

这个坑比第一个更隐蔽一些。

问题表现

在某些 iOS 版本的 Safari / PWA 中,出现了这样的情况(我的是 iOS26):

  1. 页面刚加载时,window.innerWidth 返回的值偏大(类似平板或桌面宽度);
  2. 在初次识别 deviceType 时,逻辑会认为是 "tablet""desktop"
  3. 用户一滚动/点击,触发重排(reflow)后,innerWidth 才变成实际的手机宽度;
  4. 于是 resize 事件触发,又重新判了一次设备类型,这次才变成 "mobile"
  5. 最终结果:页面偶发性地先渲染成平板布局,体验非常差。

解决思路:UA 为主,宽度为辅

单纯依赖 window.innerWidthiOS 上不够稳。
更稳妥的方式是:

  1. 使用 UA 作为第一判断依据
    利用 ua-parser-js 获取设备类型,如果 UA 明确告诉你是 mobile,那就直接按 mobile 处理,明确是 tablet,就直接当平板。
import { UAParser } from "ua-parser-js";

const parser = new UAParser(navigator.userAgent);
const result = parser.getResult();
const uaDeviceType = result.device.type; // 'mobile' | 'tablet' | 'console' | 'smarttv' | 'wearable' | undefined
  1. 针对 iPad Propad 设备做特殊识别
    iPad Pro+Air UA 会伪装成 Mac,因此需要结合 OS + 能否触摸判断:
const isIPadPro = result.os.name === "Mac OS" && navigator.maxTouchPoints > 1;

在这种情况下,即使 UA 看起来像 Mac 电脑,也要当平板处理。

  1. UA 不明确时,再用宽度兜底
    比如 UA 显示为桌面,或者 UA 不可靠时,可以退回到宽度判断:
if (width < 768) {
  return "mobile";
} else if (width >= 768 && width < 1280) {
  return "tablet";
}
return "desktop";
  1. 监听 resize 做矫正
    即使初次判断不完美,也可以在后续 resize(包括横竖屏切换、窗口变化等)时重新计算设备类型进行纠正。

最终实现:useDeviceType Hook

下面是一个最终整合后的 Hook,实现了:

  • SSR 环境兼容(useIsomorphicLayoutEffect);
  • UA + 宽度组合判断设备类型;
  • 动态设置 viewport / minWidth / 安全区域样式;
  • 监听 resize 自动更新。

设备类型判断逻辑

// hooks/useDeviceType.ts
import { useState, useEffect, useLayoutEffect } from "react";
import { UAParser } from "ua-parser-js";
import type {
  DeviceInfo,
  DeviceType,
  UseDeviceTypeOptions,
} from "~/types/GameDataType";

// SSR 下避免 useLayoutEffect 警告
const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? useLayoutEffect : useEffect;

// UA + 宽度综合判断设备类型
const getDeviceType = (width: number): DeviceType => {
  const parser = new UAParser(navigator.userAgent);
  const result = parser.getResult();
  const uaDeviceType = result.device.type;

  // iPad Pro 桌面模式的特殊处理:Mac OS + 支持触摸
  const isIPadPro = result.os.name === "Mac OS" && navigator.maxTouchPoints > 1;

  // 1. UA 明确识别为 mobile
  if (uaDeviceType === "mobile") {
    return "mobile";
  }

  // 2. UA 明确识别为 tablet(包括 iPad Pro)
  if (uaDeviceType === "tablet" || isIPadPro) {
    return "tablet";
  }

  // 3. 其它情况用宽度兜底
  if (width < 768) {
    return "mobile";
  } else if (width >= 768 && width < 1280) {
    return "tablet";
  }

  return "desktop";
};

Hook 主体

export const useDeviceType = (
  options: UseDeviceTypeOptions = {}
): DeviceInfo => {
  const {
    minWidth = 240,
    preventZoom = true,
    enableSafeAreas = true,
  } = options;

  // SSR & 客户端初始保持一致,避免 Hydration Mismatch
  const [deviceInfo, setDeviceInfo] = useState<DeviceInfo>({
    deviceType: "desktop",
    isMinWidth: false,
    currentWidth: 1024,
  });

  useIsomorphicLayoutEffect(() => {
    const checkDevice = () => {
      const width = window.innerWidth;
      const isAtMinWidth = width <= minWidth;

      const deviceType = getDeviceType(width);

      setDeviceInfo({
        deviceType,
        isMinWidth: isAtMinWidth,
        currentWidth: width,
      });

      // 如果有需要,可以持久化
      // localStorage.setItem("deviceType", deviceType);
    };

    const setViewport = () => {
      let viewport = document.querySelector('meta[name="viewport"]');

      if (!viewport) {
        viewport = document.createElement("meta");
        viewport.setAttribute("name", "viewport");
        document.head.appendChild(viewport);
      }

      if (preventZoom) {
        viewport.setAttribute(
          "content",
          "width=device-width, initial-scale=1.0, " +
            "minimum-scale=1.0, maximum-scale=1.0, " +
            "user-scalable=no, viewport-fit=cover"
        );
      } else {
        viewport.setAttribute(
          "content",
          "width=device-width, initial-scale=1.0"
        );
      }
    };

    const setMinWidthStyle = () => {
      const styleId = "min-width-style";
      let styleElement = document.getElementById(styleId) as HTMLStyleElement;

      if (!styleElement) {
        styleElement = document.createElement("style");
        styleElement.id = styleId;
        document.head.appendChild(styleElement);
      }

      styleElement.textContent = `
        body {
          min-width: ${minWidth}px;
          overflow-x: auto;
        }
        .container-limit {
          min-width: ${minWidth}px;
        }
        ${
          enableSafeAreas
            ? `
        .safe-area-bottom {
          padding-bottom: env(safe-area-inset-bottom);
        }
        .safe-area-left {
          padding-left: env(safe-area-inset-left);
        }
        .safe-area-right {
          padding-right: env(safe-area-inset-right);
        }
        `
            : ""
        }
      `;
    };

    const handleResize = () => {
      checkDevice();
      setViewport();
      setMinWidthStyle();
    };

    // 挂载后立即执行一次,尽量在首屏绘制前完成
    handleResize();

    // 监听 resize 做后续矫正,可以加防抖节流
    window.addEventListener("resize", handleResize);

    return () => {
      window.removeEventListener("resize", handleResize);
    };
  }, [minWidth, preventZoom, enableSafeAreas]);

  return deviceInfo;
};

使用时非常简单:

const { deviceType, currentWidth, isMinWidth } = useDeviceType();

if (deviceType === "desktop") {
  // ...
}

一些兼容性和架构层面的思考

即便上面这一套 UA + 宽度 + resize 的方案在大部分情况下能跑得比较稳,但仍有一些潜在问题:

  • UA 本身不可靠:被伪装、被浏览器修改;
  • 新设备 / 新系统版本出现新的 UA 组合,需要不断维护规则;
  • 某些嵌入式 WebView 或特殊浏览器的行为不确定。

从长期维护成本和复杂度来看,如果业务允许,我更推荐下面这种方式:

  • PCMobile/Pad 分两套代码
  • Mobile 内部用媒体查询区分 Phone / Tablet

前端高频面试题之CSS篇(一)

1、实现垂直居中的方式有哪些?

  • line-height:文本可以使用 line-height 等于容器高度。
  • Flex 布局:display: flex; align-items: center;)。
  • absolute + transform 或者 absolute + 负marginposition: absolute; top: 50%; transform: translateY(-50%);或者 position: absolute; top: 50%; margin-top: -50px;(容器高度为 100px)
  • absolute + margin: autoposition: absolute; inset: 0; margin: auto
  • Grid 布局display: grid; place-items: center;
  • table 布局display: table-cell;vertical-align: middle;

2、选择器权重和样式优先级是怎样的?

CSS 各选择器权重(从高到低):

选择器名称 选择器格式 权重
id选择器 #id 100
类选择器 .classname 10
属性选择器 [attr=value] 10
伪类选择器 li:first-child 10
标签选择器 a 1
伪元素选择器 div::after 1
相邻兄弟选择器 div+div 0
子选择器 div > a 0
后代选择器 div a 0
通配符选择器 * 0
  • 可以在样式后面使用 !important ,比如 display: none !important,此时该样式的权重最高,权重值为 Infinity,但需要慎用。
  • style内联样式的权重为1000
  • 权重相同,后出现的覆盖前面。

3、CSS 隐藏元素的方法有哪些?

  1. display:none: 元素不会渲染,不占据空间,也不响应绑定的事件。
  2. opacity: 0: 将元素的透明度设置为0,进而让元素从视觉上消失,但元素仍然会占据空间,并且会响应绑定的事件
  3. visibility: hidden: 这种方式隐藏会让元素依旧占据空间,但不会响应事件
  4. position: absolute;left: -9999px;top: -9999px;: 利用绝对定位将元素移到屏幕外。
  5. z-index: -9999: 降低元素层级,让当前元素被其它元素覆盖,间接达到元素隐藏的目的。
  6. overflow: hidden: 超出该元素范围内的元素将会隐藏显示。
  7. clip-path: inset(100%)(向内裁剪100%)或者clip-path: circle(0)(半径为0的圆形): 使用元素裁剪来实现元素隐藏。
  8. transform: scale(0,0): 利用 css3 的元素缩放能力,将元素缩放为0来实现元素的隐藏。

4、Link 和 @import 的区别

特性 link标签 @import
所属标准 XHTML/HTML标准 CSS标准
引用内容类型 可用于引入 CSS、RSS、图标等多种资源 仅支持css样式
加载时机 页面加载时同步加载 等待整个页面加载完成后再加载
JavaScript 控制 支持通过 DOM 操作修改样式链接 不支持动态控制
兼容性 无兼容问题 CSS2.1 才有的语法,在 IE5+ 才能识别
性能 更快,有利于首屏渲染 相对较慢,可能造成样式延迟加载

5、transition 和 animation 的区别

  • 过渡(transition):其核心是状态变化,如 transition: all 0.5s ease;,给所有属性加上一个 0.5s 的平滑过渡。
  • 动画(animation):其核心是对动画过程进行多帧关键帧控制,如 @keyframes name { 0% { ... } 100% { ... } },然后 animation: name 1s infinite;。动画相比过渡而言更加复杂,支持循环和暂停。

6、聊一聊盒模型

CSS 盒模型描述了元素在页面上的空间占用,包括内容(content)、内边距(padding)、边框(border)和外边距(margin)。

  • 标准盒模型(W3C 标准,默认盒模型):width 和 height 仅包含内容的宽度和高度,元素的总宽度 = width + 左右 padding + 左右 border + 左右 margin。
.box {
  box-sizing: content-box;
}
  • IE 盒模型(border-box),也叫怪异盒模型:元素的宽度 = width(width 里面包括内边距 padding 和边框 border) + 左右 margin。
.box {
  box-sizing: border-box;
}

7、聊一聊 CSS 预处理器

CSS 预处理器是一种工具或语言扩展,它可以让开发者以更加高级的语法来编写 CSS,比如可以定义变量、支持 CSS 嵌套写法、定义函数、使用循环等,在开发时可以提高我们的开发效率和项目的可维护性。但由于我们运行的平台,比如浏览器不支持这些高级语法,所以在代码的运行的时候,需要利用对应的工具编译成标准的 CSS。

CSS 常见的预处理器包括 SassLessStylusPostCSS 等。

8、什么是 CSS Sprites?

CSS Sprites 技术就是我们常说的雪碧图,通过将多张小图标拼接成一张大图,然后通过 CSS 的 background-imagebackground-position 属性来显示图像的特定部分,能有效的减少HTTP请求数量以达到加速显示内容的技术。

9、什么是BFC?它有什么作用?

BFC(block formatting context):简单来说,BFC 就是一种属性,这种属性会影响着元素的定位以及与其兄弟元素之间的相互作用。

形成 BFC 的条件:

  1. 浮动元素,floatnone 以外的值;
  2. 绝对定位元素,position(absolute,fixed)
  3. display 为以下其中之一的值:inline-blocks,table-cells,table-captions
  4. overflow 除了 visible 以外的值(hidden,auto,scroll)。

BFC常见作用:

  1. 包含浮动元素。
  2. 不被浮动元素覆盖。
  3. BFC 会阻止外边距折叠,可解决 margin 塌陷问题。

10、Flex 布局是什么?常用属性有哪些?

Flex(弹性盒布局)用于一维布局(如行或列),父容器设置 display: flex;

其常用属性如下:

  • 容器:flex-direction(方向)、justify-content(主轴对齐)、align-items(交叉轴对齐)、flex-wrap(换行)。
  • 子项:flex-grow(增长比例)、flex-shrink(收缩比例)、flex-basis(基础大小)。适合响应式设计。

11、flex:1 是哪些属性组成的?

flex 实际上是 flex-growflex-shrinkflex-basis 三个属性的缩写。

  • flex-grow:定义项目的的放大比例;
  • flex-shrink:定义项目的缩小比例;
  • flex-basis: 定义在分配多余空间之前,项目占据的主轴空间(main size),浏览器根据此属性计算主轴是否有多余空间。

12、flex-basis 和 width 的区别有哪些?

定义和作用:

  • flex-basis: 是 CSS Flexbox 布局中的专有属性,在其它地方使用不生效。
  • width 是一个通用的 CSS 属性。它适用于任何元素(块级、行内块等),不受布局模式限制。

优先级:

如果flex-basis的值为 auto(其默认值就是 auto,未显示设置采用的就是默认值),则 flex item 的初始大小会 fallbackwidth,也就是采用 width 设置的值作为初始大小,而如果 flex-basis 一旦设置了值,比如同时设置 width: 100px;flex-basis: 200px;, flex item 的初始大小会采用 flex-basis 设置的 200px

计算规则

  • flex-basis: flex item 的最终大小 = flex-basis + (剩余空间 * flex-grow) - (不足空间 * flex-shrink)
  • width,如果父级宽度足够,最终 flex-item 的最终大小 = width,如果空间不足,会进行宽度压缩, flex-item 的最终大小 < width,除非设置 flex-shark: 0,宽度就不会被压缩,最终宽度还是为 width的大小。

13、rem、em、px有什么区别?

  • **px(像素):**绝对单位,相对于屏幕分辨率大小固定。
  • rem(root em):CSS3 引入的相对长度单位,相对于 HTML 根元素的字体大小计算。可实现响应式布局。
  • em:是相对长度单位。相对于当前对象内文本的字体尺寸

这个很多人有个误解,em 在设置自身字体大小的时候是相对于父元素的字体大小; 在用 em设置其他属性单位的时候, 比如width,是相对于自身的字体属性大小, 只是很多时候自身字体属性是继承自父元素.

14、如何清除浮动?

浮动会导致父元素高度塌陷。清除方法如下:

  • 父元素添加 overflow: hidden;(触发 BFC)。
  • 使用伪元素:.clearfix::after { content: ''; display: block; clear: both; }
  • 父元素设置 floatdisplay: table;

现代布局更推荐使用 Flex/Grid 避免浮动。

15、CSS 性能优化的常见技巧?

  • CSS 加载性能优化:

    • 提取公共 CSS 文件。
    • 避免使用 @import
    • 压缩 CSS 文件。
    • 利用浏览器缓存。
    • 使用 CDN 加速。
    • 使用 CSS Sprite
    • CSS 样式抽离和去除无用 CSS
    • 合理使用内嵌 CSS
  • CSS 选择器性能优化:

    • 避免使用通配符选择器。
    • 使用子选择器代替后代选择器。
    • 优先使用类(Class)和 ID 选择器。
    • 避免深层嵌套的选择器。
  • CSS 选择器性能优化:

    • 避免使用过于复杂的属性。
    • 避免使用不必要的属性。
    • 避免使用 !important
  • CSS 动画性能优化:

    • 使用 transformopacity 属性来进行动画。
    • 避免使用过于复杂的动画效果。
    • 在动画中使用 will-change 属性。
    • 使用 requestAnimationFrame() 函数来优化动画。
  • CSS 渲染性能优化:

    • 使用 class 合并 DOM 的修改。
    • DOM 元素脱离文档流。

16、最后来一道考察 CSS 的 z-index 的面试真题

请按层级从高到低的顺序列出元素:

<body>
  <div id="dom-1" style="position: fixed; z-index: 100;">
    <div id="dom-2" style="position: absolute; z-index: 2000;"></div>
  </div>
  <div id="dom-3" style="position: relative; z-index: 1000;"></div>
</body>

这里主要考察 z-index 的两条层级计算规则:

  • 当父元素创建了一个层叠上下文 position: relative/absolute/fixed 时,此时父子元素的层级与 z-index 无关,就算 dom-2z-index99,其层级也比父级高。
  • 子元素的 z-index 受父元素限制。即使子元素 z-index 很高,如果父元素 z-index 低,整个子树都会在低层。

所以层级顺序从高到低依次是 dom-3 > dom-2 > dom-1

我们加一点样式就能很明显看出层级关系,代码如下:

<!-- z-index.html -->
<style>
  #dom-1 {
    width: 300px;
    height: 300px;
    background-color: red;
  }
  #dom-2 {
    width: 200px;
    height: 200px;
    background-color: blue;
  }
  #dom-3 {
    width: 100px;
    height: 100px;
    background-color: yellow;
  }
</style>
<body>
  <div id="dom-1" style="position: fixed; z-index: 100">
    <div id="dom-2" style="position: absolute; z-index: 2000"></div>
  </div>
  <div id="dom-3" style="position: relative; z-index: 1000"></div>
</body>

其渲染结果如下:

小结

以上是整理的一部分 CSS 的相关面试题,如有错误或者可以优化的地方欢迎评论区指正,后续还会更新 CSS 一些常见布局实现的面试题。

浅记一下ElementPlus中的虚拟化表格(el-table-v2)的简单使用

我的需求

同时在一个表格中显示6个甚至更多的传感器数据,数据量在每个传感器每10秒1条数据,一般请求1天的数据。

以6个传感器计算一下数据量:

6 * 24 * 60 * 6 = 51840

一次只能请求到1个传感器的数据,也就是8640条,当查询时间范围变化时,循环请求接口获取数据,并且需要很快的渲染完毕。

效果像这样

image.png

我的经历

先使用的 Table(el-table)

调用一次接口反应时间(浏览器网络窗口查看)约在1-2s左右,一次接口的数据使用Table渲染一次时间约是4s左右。

最开始的想法

新添加一个传感器的数据,每一次需要重新渲染一次没有问题。

当切换查询时间后,循环调用时,采用的是——给tableData中直接添加。这会导致在循环调取数据时,每调取一次就会重新渲染一次。出现渲染卡顿问题,6个接口调用完毕且渲染完成,需要20-30s的时间,数据量大的时间会更长,有兴趣的朋友可以试一下。

<el-table v-loading="loading" element-loading-text="数据加载中":data="tableData"
  height="100%">
  <el-table-column :min-width="leftWidth" :fixed="index == 0" v-for="(column, index) in columns"
    :key="column.prop" :label="column.label" :prop="column.prop"></el-table-column>
</el-table>
······
tableData.value = tableData.value.map((item: any, index: number) => {
    return {
      ...item,
      ['data' + String(sensor.id)]: Number(res.data.Data[index].data).toFixed(3)
    }
})

对最开始的想法优化

在循环调用时,调用一次渲染一次非常慢,第一次优化是拿到所有的数据之后,再将数据添加至tableData,之后同意进行渲染,这种方式快了一点,但没有太大改善。

const buildTableFromAllData = (allFetched: Array<any>) => {
  if (allFetched.length === 0) return;

  // 第一步:构建 columns
  const newColumns = [
    { prop: 'time', label: '时间' }
  ];
  allFetched.forEach(({ device, gateway }) => {
    newColumns.push({
      prop: 'data' + String(sensor.id),
      label: sensor.name
    });
  });

  // 第二步:对齐时间轴(假设所有设备返回的时间点一致)
  const firstData = allFetched[0].rawData;
  const timeList = firstData.map((item: any) => dayjs(item.time).format('YYYY-MM-DD HH:mm:ss'));

  // 第三步:构建 tableData 行数据
  const newTableData = timeList.map((time: string, index: number) => {
    const row: Record<string, any> = { time };
    allFetched.forEach((_, colIndex) => {
      const value = allFetched[colIndex].rawData[index]?.data;
      const formatted = value ? Number(value).toFixed(3) : '';
      row['data' + String(_.sensor.id)] = formatted;
    });
    return row;
  });

  // 第四步:构建 lineData(用于图表)
  const newLineData = allFetched.map(fetched =>
    fetched.rawData.map((item: any) => Number(item.data).toFixed(3))
  );

  // ✅ 一次性赋值!只触发一次响应式更新
  columns.value = newColumns;
  tableData.value = newTableData;
};

虚拟化表格的接触

在AI的建议下使用虚拟化表格。

ElementPlus中的说明通过虚拟化表格组件,超大数据渲染将不再是一个头疼的问题

使用虚拟化表格之后,渲染速度至少提升一半时间以上。

<el-auto-resizer>
  <template #default="{ height, width }">
    <el-table-v2 v-loading="loading" element-loading-text="数据加载中" :columns="columns" :data="tableData"
      :width="width" :height="height" fixed />
  </template>
</el-auto-resizer>
······
const buildTableFromAllData = (allFetched: Array<any>) => {
  if (allFetched.length === 0) return;

  const newColumns: any[] = [
    {
      key: 'time',
      dataKey: 'time',
      title: '时间',
      width: 200 * Number(ratio.value),
      fixed: 'left'
    }
  ];

  allFetched.forEach(({ sensor }) => {
    newColumns.push({
      key: 'data' + String(sensor.id),
      dataKey: 'data' + String(device.Deviceid),
      title: sensor.name,
      width: 200 * Number(ratio.value)
    })
  });

  const firstData = allFetched[0].rawData;
  const timeList = firstData.map((item: any) => dayjs(item.time).format('YYYY-MM-DD HH:mm:ss'));

  const newTableData = timeList.map((time: string, index: number) => {
    const row: Record<string, any> = { time };
    allFetched.forEach((_, colIndex) => {
      const value = allFetched[colIndex].rawData[index]?.data;
      const formatted = value ? Number(value).toFixed(3) : '';
      row['data' + String(_.sensor.id)] = formatted;
    });
    return row;
  });
  
  const newLineData = allFetched.map(fetched =>
    fetched.rawData.map((item: any) => Number(item.data).toFixed(3))
  );

  // ✅ 一次性赋值!只触发一次响应式更新
  columns.value = newColumns;
  tableData.value = newTableData;
};

其它问题

问题

其实以上用法已经完全解决了我的问题,但是在电脑浏览器中使用无误,但是一换到手机浏览器或者平板浏览器中,虚拟表格直接滑不动,能看到滚动条,但是就是滑不动,以下是我的解决尝试过程。

AI给的解决方案——添加CSS

查询到滚动的内容是这样的<div class="el-vl__wrapper el-table-v2__body",于是我添加了下面的样式

:deep(.el-table-v2__main .el-table-v2__body) {
  -webkit-overflow-scrolling: touch !important;
  overflow-y: auto !important;
  overflow-x: auto !important;
  touch-action: pan-x pan-y !important;
}

结果就是没有结果,并没有解决我的问题。

最终解决方案——还是添加CSS,但略有不同

在审查元素的时候发现了一个东西,第二行div中的overflow: hidden;

<div class="el-vl__wrapper el-table-v2__body" role="rowgroup">
    <div class="" style="position: relative; overflow: hidden; will-change: transform; direction: ltr; height: 1313.11px; width: 2143.33px;"><div style="height: 3000px; width: 2137.33px;">
    ······
    </div>
</div>

怎么给body设置滚动都没有用,因为里面给hidden了,于是添加的CSS变成了这样

:deep(.el-table-v2__main .el-table-v2__body) {
  -webkit-overflow-scrolling: touch !important;
  overflow-y: auto !important;
  overflow-x: auto !important;
  touch-action: pan-x pan-y !important;
}

OK!成功解决问题,以上就是虚拟化表格的浅用过程,得下次有机会再深入使用!

❌