普通视图

发现新文章,点击刷新页面。
今天 — 2025年11月22日掘金 前端

从“版本号打架”到 30 秒内提醒用户刷新:一个微前端团队的实践

作者 鹏北海
2025年11月22日 13:51

从“版本号打架”到 30 秒内提醒用户刷新:一个微前端团队的实践

1. 背景与痛点

我们团队维护着一个微前端子应用集群,每个子应用都需要同时服务 dev / test / release / online 多套环境。分支策略(master / release / test / dev / hotfix / feature_x.x.x)加上 Jenkins 自动化,让“一天多次发布”成为常态。但真正影响交付效率的并不是发布次数,而是一个顽固的问题:测试同学常年停留在旧版本页面

1.1 真实场景

  • 测试在早上打开 dev 页面,下午我们发布了新的组件样式;
  • 他们继续在旧页面里回归,反馈的问题我们一眼看出“这是老版本”;
  • 群里喊“刷新一下”并不靠谱,于是“无效缺陷 + 反复沟通”成了常态。

更严重的一次事故,是我们在版本检查逻辑里同时使用了 webpack DefinePlugin 与自定义插件,各自调用了一次 getAppVersion()。结果前端控制台打印的是 0.8.3-release-202511210828,而 version.json 里是 0.8.3-release-202511210829。两边只差 1 秒钟,却让线上用户始终被提示刷新,形象地被团队称为“版本号打架”。

1.2 我们的诉求

  1. 用户在 30 秒内感知版本更新;
  2. 弹窗里能看到“当前版本 / 最新版本 / 环境”;
  3. 支持“立即刷新 / 稍后再说”,不给用户造成中断;
  4. 方案需兼容现有微前端架构与 CI/CD 流程,不依赖后端改造。

2. 方案探索与取舍

在动手前,我们列出几种可行方式:

方案 实现复杂度 实时性 依赖 适配场景 关键优缺点
纯前端轮询 version.json 中(30s) 前端 + Nginx 多环境微前端 成本最低;轻微网络开销
Service Worker/PWA 较高 现代浏览器 PWA 应用 缓存控制好,但改造量大
WebSocket 推送 最高 后端服务 强实时场景 需要额外服务端开发
后端接口统一管理 前后端 版本集中管理 带来跨团队耦合

综合团队资源与落地速度,我们选择了 纯前端轮询 + 静态版本文件 的做法,并明确两个原则:

  • 版本号唯一,可追溯基础版本号-环境-时间戳
  • 发布零侵入:Jenkins 仍旧运行 npm run build-xxx,无需新增步骤。

3. 技术方案总览

  1. 构建阶段生成 version.json:在 vue.config.js 中提前计算版本号,既注入到前端(process.env.APP_VERSION),也写入输出目录的 version.json
  2. 前端轮询比对:应用启动后每 30 秒请求一次 version.json,禁用缓存并携带时间戳,比较版本号;
  3. 交互提示:复用 Ant Design Vue 的 Modal.confirm,展示当前/最新版本与环境;
  4. 缓存策略:Nginx 对 HTML/version.json 禁止缓存,对 JS/CSS/图片继续长缓存;
  5. CI/CD 配合:所有环境沿用既有脚本,只是构建产物目录多了一份实时的 version.json

4. 关键落地细节

4.1 版本号只生成一次(Build-time Deterministic Versioning)

vue.config.js 抽象 buildEnvNamebuildVersion,并在 DefinePlugin 与生成 version.json 时复用:

const buildEnvName = getEnvName();
const buildVersion = getAppVersion();

module.exports = {
  configureWebpack: {
    plugins: [
      new webpack.DefinePlugin({
        "process.env.APP_VERSION": JSON.stringify(buildVersion),
        "process.env.APP_ENV": JSON.stringify(buildEnvName),
      }),
    ],
  },
  chainWebpack(config) {
    config.plugin("generate-version-json").use({
      apply(compiler) {
        compiler.hooks.done.tap("GenerateVersionJsonPlugin", () => {
          fs.writeFileSync(
            path.resolve(__dirname, "edu/version.json"),
            JSON.stringify(
              {
                version: buildVersion,
                env: buildEnvName,
                timestamp: new Date().toISOString(),
                publicPath: "/child/edu",
              },
              null,
              2
            )
          );
        });
      },
    });
  },
};

这样即使构建过程持续 5~10 分钟,注入的版本号和静态文件里的版本仍保持一致。这其实是把“构建产物视为不可变工件”的原则落地——保证任何使用该工件的入口看到的元数据都是同一个快照。

4.2 版本检查器(Runtime Polling & Cache Busting)

class VersionChecker {
  currentVersion = process.env.APP_VERSION;
  publicPath = "/child/edu";
  checkInterval = 30 * 1000;

  init() {
    console.log(`📌 当前前端版本:${this.currentVersion}${process.env.APP_ENV})`);
    this.startChecking();
    document.addEventListener("visibilitychange", () => {
      if (document.visibilityState === "visible" && !this.hasNotified) {
        this.checkForUpdate();
      }
    });
  }

  async checkForUpdate() {
    const url = `${this.publicPath}/version.json?t=${Date.now()}`;
    const response = await fetch(url, { cache: "no-store" });
    if (!response.ok) return;
    const latestInfo = await response.json();
    if (latestInfo.version !== this.currentVersion && !this.hasNotified) {
      this.hasNotified = true;
      this.stopChecking();
      this.showUpdateModal(latestInfo.version, latestInfo.env);
    }
  }
}

这里有两个容易被忽略的细节:

  1. fetch 显式加 cache: "no-store",再叠加时间戳参数,防止 CDN / 浏览器任何一层干预;
  2. visibilitychange 监听,保证窗口重新激活时立即比对,避免用户在后台等了很久才看到弹窗。

入口 main.ts 在应用 mount 之后调用 versionChecker.init(),即可把整个检测链路串起来。

4.3 Nginx 缓存策略(Precise Cache Partition)

location / {
    if ($request_filename ~* .html$) {
        add_header Cache-Control "no-store, no-cache, must-revalidate";
    }
}

location /child/edu {
    if ($request_filename ~* .html$) {
        add_header Cache-Control "no-store, no-cache, must-revalidate";
    }
}

location ~* /child/edu/version.json$ {
    add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
    add_header Pragma "no-cache";
    add_header Expires "0";
    add_header Surrogate-Control "no-store";
}

这一层的思路是把资源分成两类:需要实时性(HTML、version.json)就 no-store,其余走长缓存。再配合 try_files 兜底 history 路由,微前端子应用的独立部署不会互相影响。

4.4 CI/CD 配置(Zero-touch Pipeline)

环境 构建命令 输出路径 说明
develop npm run build-develop /child/edu 日常开发验证
testing npm run build-testing /child/edu 集成测试
release npm run build-release /child/edu 预发布
production npm run build-production /child/edu 线上

所有命令都带 cross-env NODE_OPTIONS=--openssl-legacy-provider,以兼容不同系统的 OpenSSL 版本。更重要的是,这套方案没有“要求运维多做一步”——构建产物天然携带 version.json,任何环境拿到包即可上线。

5. 测试与验证

我们定义了一个完整的回归流程,确保方案不会给测试和上线带来额外负担:

  1. 首次访问:打开 dev 环境页面,确认控制台打印版本号,Network 里能看到 version.json 且响应头无缓存;

  2. 触发新版本:调整任意文案,重新发布,保持旧页面不刷新;

  3. 轮询验证:30 秒内弹出提示框,展示当前/最新版本和环境;

  4. 交互路径

    • 点击“立即刷新”:页面强制 reload,新版本生效;
    • 点击“稍后刷新”:记录取消动作并重新开启轮询;
  5. 边界场景:切 tab / 清缓存 / 新设备访问 / 短时间连续发布,均能正确感知最新版本。

6. 注意事项与常见问题

现象 可能原因 解决方案
没有弹窗 version.json 404 或版本未变 检查部署路径、确认构建是否生成文件
弹窗后刷新仍旧版本 静态资源被缓存 核实 Nginx 缓存策略、查看浏览器缓存设置
构建失败 cross-env 未安装或权限不足 补充依赖、确保 Jenkins 工作目录可写
持续误报更新 构建阶段多次生成版本号 vue.config.js 顶部缓存 buildVersion 并全局复用

7. 落地成效

  • 旧页面用户在 30 秒内收到提醒,测试效率显著提升;
  • “幽灵弹窗”彻底消失,版本对比逻辑稳定;
  • 方案只触碰前端与 Nginx 配置,发布流程无需改造;
  • 文档化后,其他子应用无需重复思考,直接复用。

8. 展望

下一步我们计划:

  1. 封装通用 SDK:抽象版本生成、轮询、弹窗逻辑,支持 Vue CLI / Vite;
  2. 可视化版本面板:在主应用汇总所有环境的版本和发布时间;
  3. 差异化策略:针对高优先级版本强制刷新,普通版本允许用户自行选择。

这次实践让我再次意识到:真正的坑往往藏在看似“微不足道”的细节里。当我们把问题和思考写成文档、沉淀成模板,团队就能以更小的代价获得更稳定的交付。如果你也在推进微前端版本同步,欢迎交流、互相借鉴。

Flutter组件封装:标签拖拽排序 NDragSortWrap

作者 SoaringHeart
2025年11月22日 12:53

一、需求来源

最近需要实现一个可拖拽标签需求,实现之后顺手封装一下,效果如下:

Simulator Screenshot - iPhone 16 - 2025-11-22 at 12.44.53.png

二、使用示例

//
//  DraggableDemo.dart
//  flutter_templet_project
//
//  Created by shang on 6/2/21 5:37 PM.
//  Copyright © 6/2/21 shang. All rights reserved.
//

import 'package:flutter/material.dart';
import 'package:flutter_templet_project/basicWidget/n_drag_sort_wrap.dart';
import 'package:flutter_templet_project/extension/extension_local.dart';

class DraggableDemo extends StatefulWidget {
  final String? title;

  const DraggableDemo({Key? key, this.title}) : super(key: key);

  @override
  _DraggableDemoState createState() => _DraggableDemoState();
}

class _DraggableDemoState extends State<DraggableDemo> with TickerProviderStateMixin {
  final scrollController = ScrollController();


  List<String> tags = List.generate(20, (i) => "标签$i");
  late List<String> others = List.generate(10, (i) => "其他${i + tags.length}");

  late var tabController = TabController(length: tags.length, vsync: this);

  bool canEdit = true;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title ?? "$widget"),
      ),
      body: buildBody(),
    );
  }

  Widget buildBody() {
    return Scrollbar(
      controller: scrollController,
      child: SingleChildScrollView(
        controller: scrollController,
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            buildDragSortWrap(),
          ],
        ),
      ),
    );
  }

  Widget buildDragSortWrap() {
    return StatefulBuilder(
      builder: (BuildContext context, StateSetter setState) {
        tabController = TabController(length: tags.length, vsync: this);
        return Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Material(
              child: Row(
                children: [
                  Expanded(
                    child: TabBar(
                      controller: tabController,
                      isScrollable: true,
                      tabs: tags.map((e) => Tab(text: e)).toList(),
                      labelColor: Colors.black87,
                      unselectedLabelColor: Colors.black38,
                      indicatorColor: Colors.red,
                      indicatorSize: TabBarIndicatorSize.label,
                      indicatorPadding: EdgeInsets.symmetric(horizontal: 16),
                    ),
                  ),
                  GestureDetector(
                    onTap: () {
                      DLog.d("more");
                    },
                    child: Container(
                      padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                      child: Icon(Icons.keyboard_arrow_down),
                    ),
                  )
                ],
              ),
            ),
            buildTagBar(
              onEdit: () {
                canEdit = !canEdit;
                setState(() {});
              },
            ),
            NDragSortWrap<String>(
              spacing: 12,
              runSpacing: 8,
              items: tags,
              itemBuilder: (context, item, isDragging) {
                return buildItem(
                  isDragging: isDragging,
                  item: item,
                  isTopRightVisible: canEdit,
                  topRight: GestureDetector(
                    onTap: () {
                      DLog.d(item);
                      tags.remove(item);
                      setState(() {});
                    },
                    child: Icon(Icons.remove, size: 14, color: Colors.white),
                  ),
                );
              },
              onChanged: (newList) {
                tags = newList;
                setState(() {});
              },
            ),
            Divider(height: 16),
            Wrap(
              spacing: 12,
              runSpacing: 8,
              children: [
                ...others.map(
                  (item) {
                    return buildItem(
                      isDragging: false,
                      item: item,
                      isTopRightVisible: canEdit,
                      topRight: GestureDetector(
                        onTap: () {
                          DLog.d(item);
                          others.remove(item);
                          tags.add(item);
                          setState(() {});
                        },
                        child: Icon(Icons.add, size: 14, color: Colors.white),
                      ),
                    );
                  },
                ),
              ],
            )
          ],
        );
      },
    );
  }

  Widget buildItem({
    required bool isDragging,
    required String item,
    bool isTopRightVisible = true,
    required Widget topRight,
  }) {
    return Badge(
      backgroundColor: Colors.red,
      textColor: Colors.white,
      offset: Offset(4, -4),
      isLabelVisible: isTopRightVisible,
      label: topRight,
      child: AnimatedContainer(
        duration: Duration(milliseconds: 150),
        padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
        decoration: BoxDecoration(
          color: isDragging ? Colors.green.withOpacity(0.6) : Colors.green,
          borderRadius: BorderRadius.circular(8),
        ),
        child: Text(
          item,
          style: TextStyle(color: Colors.white),
        ),
      ),
    );
  }

  Widget buildTagBar({required VoidCallback onEdit}) {
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 10),
      clipBehavior: Clip.antiAlias,
      decoration: BoxDecoration(),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Container(
            clipBehavior: Clip.antiAlias,
            decoration: BoxDecoration(),
            child: Row(
              mainAxisSize: MainAxisSize.min,
              mainAxisAlignment: MainAxisAlignment.start,
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  '我的频道',
                  textAlign: TextAlign.center,
                  style: TextStyle(
                    color: const Color(0xFF303034),
                    fontSize: 15,
                    fontWeight: FontWeight.w600,
                  ),
                ),
                Text(
                  ' (点击编辑可排序)',
                  textAlign: TextAlign.center,
                  style: TextStyle(
                    color: const Color(0xFF7C7C85),
                    fontSize: 12,
                  ),
                ),
              ],
            ),
          ),
          GestureDetector(
            onTap: onEdit,
            child: Text(
              '编辑',
              textAlign: TextAlign.center,
              style: TextStyle(
                color: const Color(0xFF303034),
                fontSize: 14,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

三、源码

组件 NDragSortWrap

import 'package:flutter/material.dart';
import 'package:flutter_templet_project/extension/extension_local.dart';

class NDragSortWrap<T extends Object> extends StatefulWidget {
  const NDragSortWrap({
    super.key,
    required this.items,
    required this.itemBuilder,
    this.onChanged,
    this.spacing = 8,
    this.runSpacing = 8,
  });

  final List<T> items;
  final Widget Function(BuildContext context, T item, bool isDragging) itemBuilder;
  final void Function(List<T> newList)? onChanged;
  final double spacing;
  final double runSpacing;

  @override
  State<NDragSortWrap<T>> createState() => _NDragSortWrapState<T>();
}

class _NDragSortWrapState<T extends Object> extends State<NDragSortWrap<T>> {
  late List<T> _list;

  @override
  void initState() {
    super.initState();
    _list = List<T>.from(widget.items);
  }

  @override
  void didUpdateWidget(covariant NDragSortWrap<T> oldWidget) {
    super.didUpdateWidget(oldWidget);
    _list = List<T>.from(widget.items);
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    return Wrap(
      spacing: widget.spacing,
      runSpacing: widget.runSpacing,
      children: [
        for (int i = 0; i < _list.length; i++) _buildDraggableItem(context, i),
      ],
    );
  }

  Widget _buildDraggableItem(BuildContext context, int index) {
    final item = _list[index];

    return LongPressDraggable<T>(
      data: item,
      feedback: Material(
        color: Colors.transparent,
        child: widget.itemBuilder(context, item, true),
      ),
      childWhenDragging: Opacity(
        opacity: 0.3,
        child: widget.itemBuilder(context, item, false),
      ),
      onDragCompleted: () {},
      onDraggableCanceled: (_, __) {},
      child: DragTarget<T>(
        onAcceptWithDetails: (details) {
          final draggedItem = details.data;
          final oldIndex = _list.indexOf(draggedItem);
          final newIndex = index;

          final item = _list.removeAt(oldIndex);
          _list.insert(newIndex, item);
          setState(() {});

          widget.onChanged?.call(_list);
        },
        builder: (context, _, __) {
          return widget.itemBuilder(context, item, false);
        },
      ),
    );
  }
}

最后、总结

实现起来并不复杂,就是依赖官方组件 LongPressDraggable 提供的各种状态做数据处理,然后刷新页面即可。

github

把原型链画成地铁图:坐 3 站路就能看懂 JS 的“继承”怎么跑

2025年11月22日 11:43

前言

在 JavaScript 里,“原型”这个词听起来高大上,其实就是一个“默认备胎”:当对象自己找不到属性时,就沿着原型这条暗道去“亲戚家”借。没有类、没有蓝图,仅靠这条备胎链,就能把公共方法层层复用,让内存省一半、代码少一半。本文只聊“原型”本身——prototype__proto__ 这些眼前能用的工具,把“借东西”的流程画成一张家谱图,帮你先看清“亲戚”是谁、住哪、怎么串门。至于后面更高阶的封装、多态、模块化,等我们把这条链走熟再升级也不迟。

一: 原型 prototype

又称显示原型,函数天生拥有的一个属性 ,将构造函数中的一些固定的属性和方法挂载到原型上,在创建实例的时候,就不需要重复执行这些属性和方法了,我们先来创造一个环境,主角依然是我们的小米 su7 ,su7 的属性有无数个,但是各个车主只需要选择并改动的属性并没有那么多,这个时候我们就能用得到原型。

Car.prototype.name = 'su7-Ultra'
Car.prototype.lang = 4800
Car.prototype.height = 1400
Car.prototype.weight = 1.5

function Car(color) {
  this.color = color
}
const car1 = new Car('pink')
const car2 = new Car('green')
console.log(car1);

用原型之后我们只需要输入想要的颜色即可,不需要反反复复创建函数。同时挂载在原型上的属性是可以直接被实例对象访问到的(如下图)

原型1.png
并且实例对象无法修改 构造函数 原型上的属性值

Person.prototype.say = '我太帅了'
function Person() {
  this.name = '饶总'
}
const p = new Person()  
p.say = 'hello'
const p2 = new Person()
console.log(p2.say);

这个时候同时有两个 key 都为 say ,但 value 不相同,一个被挂在构造函数的原型上,一个被挂在第一个实例对象 p 上,按照上面说法实例对象无法修改构造函数原型上的属性值,但是打印出来真是这样吗,究竟是 '我太帅了' ,还是 'hello',我们来揭晓答案

原型2.png
果真是实例对象无法修改构造函数原型上的属性值。

二:对象原型 __proto__

又称隐式原型,每一个对象都拥有一个 __proto__ 属性,该属性值也是一个对象, v8在访问对象中的一个属性时,会先访问该对象中的显示属性,如果找不到,就回去对象的隐式原型中查找,实例对象的隐式原型 === 构造函数的显示原型,所以如果实例对象的隐式原型找不到那么就再会去构造函数的显示原型上找

这不得不再引出一个概念—— 原型链:v8 在访问对象中的属性时,会先访问该对象中的显示属性,如果找不到,就去对象的隐式原型上找,如果还找不到,就去__proto__.__proto__ 上找,层层往上,直到找到null为止。这种查找关系被称为原型链

为了更好的理解它,我们来举个继承例子

function Parent() {
  this.lastName = '张'
}
Child.prototype = new Parent()  // {lastName: '张'}.__proto__ = Parent.prototype
function Child() {
  this.age = 18
}
const c = new Child() 
console.log(c.lastName);

在实例对象中我们只能找到儿子的年龄属性,姓氏张是儿子从父亲那里继承的,我们要查到儿子的姓氏,根据原型链原理我们先从实例对象 c 中找有没有显示属性是关于姓氏的,很明显并没有,接着就去实例对象的隐式原型上找,也没有,最后就来到了构造函数的显示原型上查找,在代码的第四行可以看到构造函数的显示原型被赋值上了 lastName 属性,所以最终是否可以查找得到姓氏张呢?我们来直接看结果 原型3.png

好你说这个也太简单了吧,就父子继承而已。话不多说我再附上一串代码和打印结果

Grand.prototype.house = function() {
  console.log('四合院');
}
function Grand() {
  this.card = 10000
}
Parent.prototype = new Grand()  // {card: 10000}.__proto__ = Grand.prototype.__proto__ = Object.prototype.__proto__ = null
function Parent() {
  this.lastName = '张'
}
Child.prototype = new Parent()  // {lastName: '张'}.__proto__ = Parent.prototype
function Child() {
  this.age = 18
}
const c = new Child()  // {age: 18}.__proto__ = Child.prototype
console.log(c.card);
c.house()  
// console.log(c.toString());

原型4.png

这里我们要注意一点:如果让你查找一个整个页面都没有的属性又该会是什么打印结果呢?我们注意看上面最后一行注释掉的代码,他的输出结果如下

原型5.png

他是直接找到了全局的对象上,经历了一遍原型链查找在 Object.prototype上找到,如果再不找到最终就会停留在null上 ,下面放一张 js 界中广为流传的一张图,如果你能看懂那么你就是彻底会了!

原型链图.webp

三:new 在干什么?

这时候你会说什么?上篇文章不是讲了 new 究竟干了些什么吗,怎么又问,不必惊讶,其实上次没讲全,这次来带你真正看看 new 究竟究竟都干了些什么(这绝对是最终理解)直接一套小连招先上五个步骤

  1. 创建一个空对象
  2. 让构造函数中的 this 指向这个空对象
  3. 执行构造函数中的代码 (等同于往空对象中添加属性值)
  4. 将这个空对象的隐式原型(__proto__) 赋值成 构造函数的显示原型(prototype)
  5. 返回该对象

再上代码(加注释)

Car.prototype.run = function() {
  console.log('running');
}

function Car() {   // new Function()
  // const obj = {}      //1
  // Car.call(obj)  // call 方法将 Car 函数中的 this = obj    2
  this.name = 'su7'  // 3
  // obj.__proto__ = Car.prototype  // 4
  // return obj    5
}
const car = new Car() // {name: 'su7'}.__proto__  == Car.prototype
car.run()

最后输出

原型6.png

结语

  1. 显式原型(prototype)是函数自带的“样板房”,所有实例都能来蹭住。
  2. 隐式原型(__proto__)是实例手里的“门禁卡”,刷卡就能进样板房找方法。
  3. 原型链就是一张“门禁卡链”:刷不到就再刷上一层的卡,直到 null 到头。
  4. new 的五步曲:空对象→认证→绑卡→执行→返回,一口气把“样板房”继承给新实例。

把这四点串成一张地铁图,以后看任何“找不到属性”的问题,先问一句:它刷卡刷到第几站了?原型链通了,继承就不再是黑魔法。

从回调到async/await:JavaScript异步编程的进化之路

作者 xiaoxue_
2025年11月22日 11:28

从回调到async/await:JavaScript异步编程的进化之路

在JavaScript的世界里,异步编程是绕不开的核心命题。从最初的回调函数,到ES6的Promise,再到ES8的async/await,每一次语法升级都在解决前一阶段的痛点,让异步代码更贴近人类的线性思维。本文将结合文件读取的实际案例,带你看清JavaScript异步编程的进化脉络。

一、ES6之前:回调函数的“地狱”与坚守

在ES6引入Promise之前,JavaScript处理异步操作的唯一方案就是回调函数。其核心逻辑是:将异步操作完成后需要执行的代码,作为参数传入异步函数,当异步任务结束时,由JavaScript引擎自动调用这个回调函数。

以文件读取API fs.readFile 为例,传统回调写法如下:

fs.readFile('./1.html','utf-8',(err,data) => {
    if(err) {
        console.log(err);
        return;
    }
    console.log(data);
    console.log(111);
})

这种写法的优势是直观易懂,对于单一异步任务完全够用。但它的缺陷也极为明显:当多个异步任务存在依赖关系时,代码会嵌套成“回调地狱”。比如先读A文件,再根据A文件内容读B这段回调函数代码是ES6之前的主流异步写法,核心依赖Node.js的fs模块(文件系统模块)实现文件读取。我们从API参数到执行逻辑逐行拆解:

fs.readFile('./1.html','utf-8',(err,data) => { ... }) 中,fs.readFile 作为异步读取方法,接收三个关键参数:第一个参数'./1.html'是文件路径,指定读取当前目录下的1.html文件;第二个参数'utf-8'是编码格式,确保读取的二进制数据转为字符串而非Buffer对象;第三个参数是回调函数,这是异步的核心——JS引擎不会等待文件读取完成,而是继续执行后续代码,读取结束后自动调用此函数处理结果。

回调函数遵循“错误优先”规范,err参数优先接收错误信息:若读取失败(如文件不存在),err为错误对象,执行console.log(err)打印错误并通过return终止函数;若成功,err为null,data接收文件内容,随后打印内容与数字111。

这种写法对单一异步任务足够简洁,但多任务依赖时会陷入“回调地狱”。比如读完1.html后需根据内容读2.html,代码会嵌套成多层缩进,可读性与维护性急剧下降,这也催生了ES6的Promise方案。

二、ES6 Promise:异步流程的“标准化”升级

Promise是ES6为解决回调地狱推出的异步容器,它将异步操作的“成功/失败”状态标准化,通过链式调用替代嵌套。Promise封装代码,正是对文件读取异步任务的规范化改造:

// es6 Promise
const p = new Promise((resolve,reject) => {
    fs.readFile('./1.html', 'utf-8', (err, data) => {
        if (err) {
            reject(err); // 异步失败,传递错误
            return;
        }
        resolve(data); // 异步成功,传递结果
    })
})
 p.then(data => {
     console.log(data);
     console.log(111);
})

Promise构造函数接收一个“执行器函数”,该函数有两个内置参数resolvereject,均为函数:resolve用于标记异步成功,将结果数据传递给后续处理;reject用于标记失败,传递错误信息。

上述代码中,文件读取的回调逻辑被重构:失败时调用reject(err),成功时调用resolve(data),Promise实例p便承载了异步任务的状态。p.then(data => { ... })是结果处理方式,then方法接收resolve传递的数据,实现成功逻辑。若需处理错误,可链式调用.catch(err => { ... })捕获reject的错误。

Promise的核心优势是链式调用。若需连续读取两个文件,只需在第一个then中返回新的Promise,再链式调用then即可,代码始终保持扁平,彻底摆脱嵌套困境。但多个链式调用时,“then链”仍会略显冗余,ES8的async/await在此基础上实现了进一步优化。

三、ES8 async/await:异步代码的“同步化”终极方案

async/await是ES8推出的Promise语法糖,它让异步代码具备同步代码的线性逻辑,堪称异步编程的“终极形态”。async/await基于前文的Promise实例实现,大幅简化了结果获取逻辑:

// es8 async
const main = async() => {
    const html = await p;
    console.log(html);
}
main();

这段代码的核心是两个关键字的配合:async用于修饰函数(如这里的main函数),表明该函数是异步函数,其返回值必然是Promise;await只能在async函数内使用,用于等待Promise完成——它会“暂停”函数执行,直到Promise状态变为成功(fulfilled),再将resolve的数据赋值给左侧变量(如html)。

需要补充的是,实际开发中需完善错误处理:若Promise状态为失败(rejected),await会抛出异常,需用try/catch捕获,优化后的代码如下:

const main = async() => {
    try {
        const html = await p;
        console.log(html);
    } catch (err) {
        console.log('错误:', err); // 捕获reject的错误
    }
}
main();

async/await的价值不仅在于简洁,更在于逻辑贴近人类思维。比如连续执行三个异步任务,只需用三个await依次等待,代码顺序与任务执行顺序完全一致,无需关注Promise的链式调用细节。

四、进化总结:从工具到思维的贴近

回顾JavaScript异步的进化之路,每一步都是对“开发体验”的优化:回调函数是异步的基础工具,却违背线性思维;Promise通过标准化容器规范异步流程,解决嵌套问题;async/await则彻底抹平异步与同步的语法差异,让代码逻辑与人类思考顺序完全统一。

实际开发中无需拘泥于单一方案:简单异步任务可用回调;多任务依赖优先用Promise链式调用;复杂业务逻辑则首选async/await,兼顾可读性与维护性。理解三者的关联与演进逻辑,才能根据场景灵活选择最合适的异步方案,写出高效优雅的JavaScript代码。

如何快速实现响应式多屏幕适配

作者 MQliferecord
2025年11月22日 11:13

项目涉及的场景比较简单,所以我个人的配置也比较粗糙简单,如果要对于更详细的多端适配,可能需要更细致的设定,如果希望一键快速实现大体上过得去的pc端多端适配,可以用这个法子。

  1. 安装postcss(必须)+tailwindcss(可选)
  2. 安装postcss-plugin-px2rem(必须)
  3. 在豆包搜索postcss-plugin-px2rem如何配置应用在postcss.config.js文件里面按需配置(关键)
module.exports = {
  plugins: [
    require('postcss-plugin-px2rem')({
      rootValue: 16, // 根元素字体大小(默认 16px,即 1rem = 16px)
      unitPrecision: 5, // 转换后的 rem 保留小数位数(默认 5)
      propList: ['*'], // 需要转换的 CSS 属性(默认 ['*'],即所有属性)
      selectorBlackList: [], // 不转换的选择器(如 ['body'],则 body 下的 px 不转换)
      replace: true, // 是否直接替换原 px 值(默认 true,不保留原 px)
      mediaQuery: false, // 是否转换媒体查询中的 px(默认 false,不转换)
      minPixelValue: 0, // 最小转换像素值(默认 0,即所有 px 都转换)
      exclude: /node_modules/i // 排除的文件路径(如 node_modules 下的样式不转换)
    })
  ]
}

后续需要详细配置的话,需要关注的两个属性

selectorBlackList: [], 
mediaQuery: false, 
  1. 在App.vue 文件下添加(关键)
function setRootFontSize() {
  const screenWidth = document.documentElement.clientWidth;
  const rootFontSize = screenWidth / 7.5; 
  document.documentElement.style.fontSize = `${rootFontSize}px`;
}

setRootFontSize();
window.addEventListener('resize', setRootFontSize);

这里因为我们项目主要是大屏和超大屏工作,所以我针对我们的项目具体应用场景做了一下更改

function setRootFontSize() {
  const screenWidth = document.documentElement.clientWidth;
  if(screenWidth<1560){
      const rootFontSize = screenWidth / 75; 
      document.documentElement.style.fontSize = `${rootFontSize}px`;
  }
}

具体原理搜索rem是什么意思就行了

不用记复杂路径!3 步让你的 JS 脚本像 “vue create” 一样好用

2025年11月22日 10:44

一、开篇:为什么别人的命令能直接用?

日常开发中,你可能会有这样的疑问:为什么输入 vue create 就能快速创建项目,而自己写的 JavaScript 脚本,每次都要敲长长的 node ./xxx/xxx.js 才能运行?其实,这背后藏着 package.jsonbin 命令的 “小秘密”。

这篇文章就用最直白的方式,带你搞清楚 bin 命令、shebang 以及 npm link 的作用和用法,看完之后,你也能让自己的脚本像那些常用工具一样,一键就能运行。

二、package.json 的 bin 命令:让脚本 “一键启动”

1. 什么是 bin 命令?

简单来说,bin 命令就是给你的 JavaScript 脚本起一个 “简短别名”。有了这个别名,你不用再输入完整的脚本路径,直接喊出 “别名”,就能运行对应的脚本。

比如原本需要输入 node ./bin/my-script.js 才能执行的脚本,配置 bin 命令后,可能只需要输入 my-script 就能运行,大大减少了重复输入的麻烦。

2. 如何配置 bin 命令?

bin 命令的配置很灵活,主要分两种情况,根据你的脚本数量来选择即可:

情况一:只有一个脚本(简单写法)

如果你的项目里只有一个需要便捷运行的脚本,直接在 package.json 中写脚本的相对路径就行。这时,脚本的 “别名” 会默认和你项目的 “name” 字段一致(也就是 package.json"name": "xxx" 中的 xxx)。

举个例子:

{

&#x20; "name": "my-tool",

&#x20; "version": "1.0.0",

&#x20; "bin": "./bin/my-script.js"

}

上面的配置中,脚本 ./bin/my-script.js 的别名就是 my-tool,后续直接输入 my-tool 就能运行这个脚本。

情况二:多个脚本(对象写法)

如果项目里有多个需要便捷运行的脚本,就用对象的形式配置,键是你想给脚本起的 “别名”,值是脚本的相对路径。

举个例子:

{

&#x20; "name": "my-tool",

&#x20; "version": "1.0.0",

&#x20; "bin": {

&#x20;   "script-one": "./bin/1.js",

&#x20;   "script-two": "./bin/2.js"

&#x20; }

}

这样配置后,输入 script-one 就能运行 ./bin/1.js,输入 script-two 就能运行 ./bin/2.js,清晰又好记。

需要注意的是,脚本路径要从 package.json 所在的文件夹开始计算,比如 package.json 在项目根目录,脚本在根目录下的 bin 文件夹里,路径就写 ./bin/xxx.js,别写错了路径导致脚本找不到。

3. 配置完 bin 还不够,必须加 “shebang”

很多人配置完 bin 命令后,运行时会遇到 “命令未找到” 或 “无法执行脚本” 的错误,这大概率是因为没加 “shebang”。

为什么需要 shebang?

简单来说,shebang 是告诉系统 “用什么程序来运行这个脚本”。如果没有它,系统不知道该用 Node.js 还是其他程序来执行你的 JavaScript 脚本,自然会报错。

什么是 shebang?怎么加?

shebang 就是在你的 JavaScript 脚本文件的第一行,加上 #!/usr/bin/env node 这行代码。不用纠结这行代码的具体含义,直接复制粘贴到脚本第一行就行,它能兼容不同的电脑系统。

举个例子,你的脚本文件 ./bin/my-script.js 应该长这样:

\#!/usr/bin/env node

// 下面是你的脚本逻辑

console.log("脚本运行成功!");

为什么不能写死路径?

有些资料里可能会写 #!/usr/bin/node,但这种写法有个坑:它假设 Node.js 一定安装在 /usr/bin 这个路径下。但实际中,很多人会用 nvm(Node.js 版本管理器)来管理 Node.js,这时 Node.js 的安装路径可能是 ~/.nvm/versions/node/xxx/bin,或者 Windows 系统里的 C:\Program Files\nodejs。写死路径的话,换一台电脑可能就无法运行了,而 #!/usr/bin/env node 会让系统自动寻找 Node.js 的安装路径,避免这个问题。

4. 动手实操:3 步跑通自己的命令

看完理论,咱们来动手试一次,3 步就能让你的脚本实现 “一键运行”:

第一步:建文件夹和文件

先建一个项目文件夹,比如叫 my-first-cli,在里面新建两个东西:

  • 一个 package.json 文件(可以用 npm init -y 快速生成);

  • 一个 bin 文件夹,在 bin 文件夹里新建 cli.js 文件(这就是你的脚本文件)。

此时项目结构应该是这样:

my-first-cli/

├── bin/

│   └── cli.js

└── package.json

第二步:写脚本内容

打开 bin/cli.js,在第一行加上 shebang,再写点简单的逻辑,比如:

\#!/usr/bin/env node

console.log("我的第一个便捷脚本,运行成功啦!");

第三步:配置 package.json 的 bin 命令

打开 package.json,加上 bin 配置,比如:

{

&#x20; "name": "my-first-cli",

&#x20; "version": "1.0.0",

&#x20; "bin": {

&#x20;   "my-test": "./bin/cli.js"

&#x20; }

}

这里我们给脚本起的别名是 my-test,后续输入这个别名就能运行脚本。

三、shebang 详解:脚本的 “运行说明书”

1. 再深入理解:shebang 到底是什么?

前面我们知道了 shebang 是 #!/usr/bin/env node,但它本质上是 Unix/Linux 系统的一个通用规则 —— 只要脚本文件的第一行以 #! 开头,这行就是 shebang,用来指定执行这个脚本的程序。

对 JavaScript 脚本来说,shebang 就是告诉系统 “用 Node.js 来运行我”,相当于给脚本加了一份 “运行说明书”。需要注意的是,它不是注释,而是给系统看的指令,别把它删掉或放到其他行。

2. 为什么 #!/usr/bin/env node 能兼容所有系统?

这里拆解一下 #!/usr/bin/env node 的作用:

  • #!/usr/bin/envenv 是系统自带的一个工具,它能读取系统的 PATH 环境变量(PATH 里包含了系统所有可执行程序的安装目录);

  • nodeenv 会在 PATH 包含的目录里,找第一个名叫 node 的可执行文件,找到后就用这个 Node.js 来运行脚本。

不管你的 Node.js 装在 ~/.nvmC盘 还是其他路径,env 都能找到它,所以这行代码能兼容 Windows、macOS、Linux 等不同系统。

3. Windows 用户不用慌,shebang 兼容问题已解决

很多 Windows 用户会担心:Windows 系统不认识 shebang,会不会影响脚本运行?其实不用怕,npm 和 npx 已经帮我们处理了这个问题。

当你在 Windows 上运行通过 bin 命令配置的脚本时,npm 会自动忽略 shebang,直接用你电脑里当前的 Node.js 来执行脚本,所以不用额外做任何配置,和其他系统一样正常使用。

4. 不止 Node.js,其他脚本也用 shebang

shebang 不是 Node.js 专属的,其他类型的脚本也会用它来指定运行程序。比如:

  • Shell 脚本(.sh 文件):第一行通常是 #!/bin/bash,表示用 bash 程序来运行;

  • Python 脚本(.py 文件):第一行通常是 #!/usr/bin/env python3,表示用 Python3 来运行;

  • Perl 脚本(.pl 文件):第一行通常是 #!/usr/bin/perl,表示用 Perl 来运行。

了解这个知识点,以后看到其他类型的脚本,也能明白第一行代码的作用。

四、npm link:本地测试的 “神器”,不用反复装包

1. 为什么需要 npm link?

当你写完脚本并配置好 bin 命令后,肯定想测试一下能不能正常运行。如果每次修改代码后,都要先把项目发布到 npm 仓库,再用 npm install -g 安装到全局来测试,那也太麻烦了 —— 这时候,npm link 就能派上用场。

2. 什么是 npm link?

简单来说,npm link 是给你本地的项目创建一个 “快捷方式”,并把这个快捷方式链接到系统的全局 npm 目录里。这样一来,你不用发布项目,也不用反复安装,就能像使用 “已发布到 npm 仓库的工具” 一样,在任何目录下运行你的脚本,而且修改代码后,效果会实时生效,不用重新配置。

3. 动手实操:2 步用 npm link 测试命令

还是用前面的 my-first-cli 项目举例,2 步就能完成测试:

第一步:把项目链接到全局

打开终端,进入 my-first-cli 文件夹(也就是 package.json 所在的目录),输入命令:

npm link

执行完后,系统会提示 “链接成功”,此时你的 my-test 命令已经被添加到全局了。

第二步:测试命令

不用停留在 my-first-cli 文件夹,随便找一个其他目录(比如你的桌面),在终端输入:

my-test

如果看到输出 “我的第一个便捷脚本,运行成功啦!”,就说明测试成功了。

如果后续修改了 bin/cli.js 里的代码,比如把输出改成 “脚本更新啦!”,不用重新执行 npm link,直接在终端再次输入 my-test,就能看到更新后的效果,非常方便。

4. npm link 不止能测命令,还有 2 个常用场景

npm link 不止用来测试 bin 命令,在本地开发中还有两个非常实用的场景:

场景一:本地两个项目互相依赖

比如你有两个项目:

  • 项目 A:是一个工具包(比如叫 utils-package),里面有一些常用的工具函数;

  • 项目 B:是一个业务项目,需要用到项目 A 里的工具函数。

如果直接在项目 B 里用 npm install ../utils-package 安装项目 A,每次项目 A 的代码修改后,都要重新安装才能在项目 B 里看到更新,很麻烦。这时候用 npm link 就能解决:

  1. 进入项目 A 的文件夹,执行 npm link,把项目 A 链接到全局;

  2. 进入项目 B 的文件夹,执行 npm link utils-packageutils-package 是项目 A 的 package.json 里的 “name” 字段);

这样一来,项目 B 里就能像使用普通 npm 包一样,用 require('utils-package') 引入项目 A 的代码,而且项目 A 的代码修改后,项目 B 里能实时看到更新,不用反复安装。

场景二:调试已安装的依赖包

有时候你在项目里使用某个 npm 包(比如 lodash),发现某个功能有问题,想修改这个包的源码来调试(当然pnpm有更优雅的解决方案)。这时候也能用到 npm link

  1. 先从 GitHub 上下载这个包的源码(比如 lodash 的源码),解压后进入源码文件夹,执行 npm link,把修改后的源码链接到全局;

  2. 进入你的项目文件夹,执行 npm link lodash,把项目里的 lodash 替换成你本地修改后的源码;

  3. 调试完成后,再执行 npm unlink lodash,并重新执行 npm install lodash,恢复成官方的包即可。

5. 用完记得 “取消链接”,避免影响后续使用

当你测试完或者不再需要本地链接时,记得取消链接,避免本地的测试版本影响后续使用官方版本。

取消链接分两步:

  1. 进入你之前执行 npm link 的项目文件夹(比如 my-first-cli),执行 npm unlink,取消项目到全局的链接;

  2. 如果其他项目链接过这个项目(比如前面的项目 B 链接过项目 A),还要进入这些项目文件夹,执行 npm unlink 包名(比如 npm unlink utils-package),取消项目间的链接。

比如取消 my-first-cli 的链接:

\# 进入 my-first-cli 文件夹

cd my-first-cli

\# 取消全局链接

npm unlink

五、总结:3 个核心知识点要记牢

  1. bin 命令:给 JavaScript 脚本起 “简短别名”,配置后不用输完整路径,直接喊别名就能运行脚本,配置时注意路径别写错;

  2. shebang:脚本第一行的 #!/usr/bin/env node,告诉系统用 Node.js 运行脚本,别写死路径,兼容所有系统;

  3. npm link:本地测试和开发的 “神器”,能创建项目快捷方式,实现代码实时更新,不用反复安装,用完记得取消链接。

六、结尾:动手试试,一次就会

看完这篇文章,建议你动手实操一次:建一个简单的项目,配置 bin 命令、加 shebang、用 npm link 测试。过程中如果遇到报错,先检查三个地方:脚本路径对不对、shebang 加没加、link 有没有成功。

其实这些知识点都不复杂,关键是多动手,试一次就能完全掌握,以后开发脚本或工具时,就能像那些常用的 npm 包一样,实现 “一键运行”,大大提升开发效率。

牛刀小试:Vue 3的响应式系统和Proxy?

作者 ArkPppp
2025年11月21日 18:29

牛刀小试:Vue 3的响应式系统和Proxy?

引言

众所周知,Vue3 的响应式系统主要依赖于 ES6 的 Proxy 对象来实现。相比于 Vue2 使用的 Object.definePropertyProxy提供了更强大的功能和更好的性能。但我们在日常使用框架时却很少注意到背后的底层原理,本文就旨在通过重温Vue3的响应式系统,学习和回顾ES6的相关特性。

Proxy概述

ES6 Proxy 是一种新的 JavaScript 功能,它允许你创建一个对象的代理,从而可以拦截和自定义基本操作,例如属性查找、赋值、枚举和函数调用等。Proxy 可以被视为在目标对象之前设置的一层拦截,所有对该对象的访问都必须首先通过这层拦截,这提供了一种机制,可以对外界的访问进行过滤和改写。

语法

const p = new Proxy(target, handler)

target:要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。

handler:一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理 p 的行为。

例子

拦截器

这里我们创建了一个get handler,当程序试图访问对象时,如果属性存在于对象,则返回其对象的值,否则返回37。

以下是传递给 get 方法的参数,this 上下文绑定在handler 对象上。

target目标对象。property被获取的属性名。receiver是Proxy 或者继承 Proxy 的对象

const handler = {
  get: function (obj, prop) {
    return prop in obj ? obj[prop] : 37;
  },
};

const p = new Proxy({}, handler);
p.a = 1;
p.b = undefined;

console.log(p.a, p.b); // 1, undefined
console.log("c" in p, p.c); // false, 37

无操作转发

在以下例子中,我们使用了一个原生 JavaScript 对象,代理会将所有应用到它的操作转发到这个对象上。

let target = {};
let p = new Proxy(target, {});

p.a = 37; // 操作转发到目标

console.log(target.a); // 37 操作已经被正确地转发

验证功能

通过代理,你可以轻松地验证向一个对象的传值。向Proxy的handler设置相关的setter方法,可以拦截对对象的赋值操作,并确保预期之内的结果。

以下是传递给 set() 方法的参数。this 绑定在 handler 对象上。

target目标对象。

property将被设置的属性名或 Symbol

value新属性值。

receiver最初接收赋值的对象。通常是 proxy 本身,但 handler 的 set 方法也有可能在原型链上,或以其他方式被间接地调用(因此不一定是 proxy 本身)。

// Proxy & setter拦截器

let validator = {
    set: function (obj, prop, value) {
    if (prop === "age") {
      if (!Number.isInteger(value)) {
        throw new TypeError("The age is not an integer");
      }
      if (value > 200) {
        throw new RangeError("The age seems invalid");
      }
    }

    // 保存属性值的默认行为!
    obj[prop] = value;

    // 表示成功
    return true;
  },
}

let person = new Proxy({},validator)

person.age = 50
console.log(person.age)

// TypeError: The age is not an integer
person.age = 11.4

// RangeError: The age seems invalid
person.age = 3000

Reflect概述

ES6 Reflect 是一个内置的对象,它提供了一系列静态方法,用于执行可拦截的 JavaScript 操作。Reflect 并不是一个函数对象,因此它是不可构造的。

Reflect 的设计目的之一是与 Proxy 配合使用。Proxy handler 中的方法(如 get, set)与 Reflect 对象上的方法具有相同的名称和参数。这使得在 Proxy handler 中调用 Reflect 对应的方法来执行默认行为变得非常方便和规范。

主要方法

Reflect 上的所有方法都是静态的,与 Proxy 的 handler 方法一一对应。

  • Reflect.get(target, propertyKey[, receiver]): 获取对象属性的值,类似于 target[propertyKey]
  • Reflect.set(target, propertyKey, value[, receiver]): 设置对象属性的值,类似于 target[propertyKey] = value。它会返回一个布尔值表示是否设置成功。
  • Reflect.has(target, propertyKey): 判断一个对象是否存在某个属性,类似于 propertyKey in target
  • Reflect.deleteProperty(target, propertyKey): 删除对象的属性,类似于 delete target[propertyKey]

为什么与Proxy是最佳搭档?

Proxy 的拦截器中,我们通常需要执行原始操作。直接使用 obj[prop] = value 这样的语法虽然可行,但存在一些问题,尤其是在处理继承和 getter/setter 时。

Reflect 方法提供了执行这些默认操作的标准方式,并能正确处理 this 指向(通过 receiver 参数)。

例子

在这个例子中,我们使用 Reflect.set 来完成属性的赋值。这确保了即使对象有 setterthis 也会正确地指向代理对象 p

const target = {
    _name: 'Guest',
    get name() {
        return this._name;
    },
    set name(val) {
        console.log('Setter called!');
        this._name = val;
    }
};

const handler = {
    set(obj, prop, value, receiver) {
        console.log(`Setting ${prop} to ${value}`);
        // 使用 Reflect.set 来调用原始的 setter,并确保 this 指向 receiver (代理对象 p)
        return Reflect.set(obj, prop, value, receiver);
    }
};

const p = new Proxy(target, handler);

p.name = 'Admin';
// 输出:
// Setting name to Admin
// Setter called!

console.log(p.name); // Admin

使用 Reflect 不仅代码更简洁,而且更健壮,能够正确处理 JavaScript 复杂的内部机制。

手动挡响应式

了解完基本的原理后,我们将”手动挡”开始一步步实现Vue的响应式。

实现单个值的响应式

下面代码通过 3 个步骤,实现对 total 数据进行响应式变化:

① 初始化一个 Set 类型的 dep (Dependency 依赖)变量,用来存放需要执行的副作用( effect 函数),即修改 total 值的方法;

② 创建 track() 函数,用来将需要执行的副作用(Effect)保存到 dep 变量中(也称收集副作用);

③ 创建 trigger() 函数,用来执行 dep 变量中的所有副作用;

在每次修改 pricequantity 后,调用 trigger() 函数(扳机,触发器)执行所有副作用后, total 值将自动更新为最新值。

let price = 10, quantity = 2, total = 0;
const dep = new Set(); // ① 
const effect = () => { total = price * quantity };
const track = () => { dep.add(effect) };  // ②
const trigger = () => { dep.forEach( effect => effect() )};  // ③

track();
console.log(`total: ${total}`); // total: 0
trigger();
console.log(`total: ${total}`); // total: 20
price = 20;
trigger();
console.log(`total: ${total}`); // total: 40

实现单个对象的响应式

我们的对象具有多个属性,并且每个属性都需要自己的 dep(Dependency)。

我们将所有副作用保存在一个 Set 集合中,而该集合不会有重复项,这里我们引入一个 Map 类型集合(即 depsMap ),其 key 为对象的属性(如: price 属性), value 为前面保存副作用的 Set 集合(如: dep 对象)

下面的代码通过 3 个步骤,实现对 total 数据进行响应式变化:

① 初始化一个 Map 类型的 depsMap 变量,用来保存每个需要响应式变化的对象属性(key 为对象的属性, value 为前面 Set 集合);

② 创建 track() 函数,用来将需要执行的副作用保存到 depsMap 变量中对应的对象属性下(也称收集副作用);

③ 创建 trigger() 函数,用来执行 dep 变量中指定对象属性的所有副作用;

这样就实现监听对象的响应式变化,在 product 对象中的属性值发生变化, total 值也会跟着更新。

let product = { price: 10, quantity: 2 }, total = 0;
const depsMap = new Map(); // 1 创建依赖映射表
const effect = () => { total = product.price * product.quantity };
const track = key => {     // 2 查找该属性已有的依赖,若没有则自行创建一个
  let dep = depsMap.get(key);
  if(!dep) {
    depsMap.set(key, (dep = new Set()));
  }
  dep.add(effect);
}

const trigger = key => {  // 3 找到该属性对应的所有依赖,并执行相关的副作用函数
  let dep = depsMap.get(key);
  if(dep) {
    dep.forEach( effect => effect() );
  }
};

track('price');
console.log(`total: ${total}`); // total: 0
effect();
console.log(`total: ${total}`); // total: 20
product.price = 20;
trigger('price');
console.log(`total: ${total}`); // total: 40

为什么是Map充当依赖映射?

Map与Set的区别:

  • Set只储存唯一的值,只有值没有键,重复值不会被添加。
  • Map存储键值对,存在键值对的映射关系。
// Set 存储唯一值的集合
const effects = new Set(); // 只存储值,不存储键
effects.add(() => console.log('effect1'));
effects.add(() => console.log('effect2'));
effects.add(() => console.log('effect1')); // 重复值不会被添加

// Set 的内部结构
[effect1函数, effect2函数]
// 只有值,没有键

// Map 存储键值对
const depsMap = new Map(); // key -> value 的映射关系
depsMap.set('price', new Set([effect1, effect2]));
depsMap.set('quantity', new Set([effect3]));

// Map 的内部结构
{
  'price' -> Set([effect1, effect2]),
  'quantity' -> Set([effect3])
}
// 有明确的键值对应关系

Set用于储存副作用函数运算,Map用于保存依赖的键值对。

实现多个对象的响应式

下面代码通过 3 个步骤,实现对 total 数据进行响应式变化:

① 初始化一个 WeakMap 类型的 targetMap 变量,用来要观察每个响应式对象;

② 创建 track() 函数,用来将需要执行的副作用保存到指定对象( target )的依赖中(也称收集副作用);

③ 创建 trigger() 函数,用来执行指定对象( target )中指定属性( key )的所有副作用;

这样就实现监听对象的响应式变化,在 product 对象中的属性值发生变化, total 值也会跟着更新。

let product = { price: 10, quantity: 2 }, total = 0;
const targetMap = new WeakMap();     // 1 初始化 targetMap,保存观察对象
const effect = () => { total = product.price * product.quantity };
const track = (target, key) => {     // 2 收集依赖
  let depsMap = targetMap.get(target);
  if(!depsMap){
    targetMap.set(target, (depsMap = new Map()));
  }
  let dep = depsMap.get(key);
  if(!dep) {
    depsMap.set(key, (dep = new Set()));
  }
  dep.add(effect);
}

const trigger = (target, key) => {  // 3 执行指定对象的指定属性的所有副作用
  const depsMap = targetMap.get(target);
  if(!depsMap) return;
    let dep = depsMap.get(key);
  if(dep) {
    dep.forEach( effect => effect() );
  }
};

track(product, 'price');
console.log(`total: ${total}`); // total: 0
effect();
console.log(`total: ${total}`); // total: 20
product.price = 20;
trigger(product, 'price');
console.log(`total: ${total}`); // total: 40

"自动挡"的响应式系统:Proxy与Reflect的结合

到目前为止,我们已经建立了一个依赖追踪系统,但它还是“手动挡”的:

  1. 我们需要在代码中手动调用 track() 来收集依赖。
  2. 我们需要在数据更新后手动调用 trigger() 来触发更新。

这显然不够智能。现在,让我们利用 ProxyReflect 将它升级为“自动挡”。我们的目标是:当访问一个对象的属性时,自动执行 track();当修改一个对象的属性时,自动执行 trigger()

第一步:创建 reactive 函数

我们先创建一个 reactive 函数,它接收一个普通对象,并返回一个该对象的代理。所有的操作都将发生在这个代理的 handler 中。

const targetMap = new WeakMap(); // 依赖存储保持不变

// 副作用函数也保持不变
let activeEffect = null; // 我们需要一个变量来存储当前正在运行的副作用函数

function reactive(target) {
  const handler = {
    // get 拦截器:当读取属性时触发
    get(target, key, receiver) {
      console.log(`GET: 访问属性 ${key}`);
      // ... 在这里自动收集依赖
    },
    // set 拦截器:当设置属性时触发
    set(target, key, value, receiver) {
      console.log(`SET: 设置属性 ${key}${value}`);
      // ... 在这里自动触发更新
    }
  };
  return new Proxy(target, handler);
}

第二步:在 get 拦截器中自动收集依赖

当代码访问代理对象的属性时(例如 product.price),get 拦截器会被触发。这正是我们调用 track() 的时机。

同时,为了让 track 函数知道要收集哪个副作用,我们引入一个全局变量 activeEffect,用于存储当前正在执行的副作用函数。

// track 函数现在需要知道当前激活的副作用是哪个
function track(target, key) {
  if (activeEffect) { // 只有在 activeEffect 存在时才进行追踪
    let depsMap = targetMap.get(target);
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()));
    }
    let dep = depsMap.get(key);
    if (!dep) {
      depsMap.set(key, (dep = new Set()));
    }
    dep.add(activeEffect); // 收集当前激活的副作用
  }
}

// reactive 函数的 get handler
const handler = {
  get(target, key, receiver) {
    track(target, key); // 自动收集依赖
    // 使用 Reflect.get 返回属性的原始值,确保 this 指向正确
    return Reflect.get(target, key, receiver);
  },
  // ... set handler
};

第三步:在 set 拦截器中自动触发更新

当代码修改代理对象的属性时(例如 product.price = 20),set 拦截器会被触发。这时则调用 trigger()

// trigger 函数保持不变
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  let dep = depsMap.get(key);
  if (dep) {
    dep.forEach(effect => effect());
  }
}

// reactive 函数的 set handler
const handler = {
  // ... get handler
  set(target, key, value, receiver) {
    // 使用 Reflect.set 设置新值
    const result = Reflect.set(target, key, value, receiver);
    trigger(target, key); // 自动触发更新
    return result; // 返回设置操作是否成功
  }
};

第四步:整合与测试

现在,我们把所有部分整合起来,并创建一个 watchEffect 函数来管理 activeEffect

const targetMap = new WeakMap();
let activeEffect = null;

function track(target, key) {
  if (activeEffect) {
    let depsMap = targetMap.get(target);
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()));
    }
    let dep = depsMap.get(key);
    if (!dep) {
      depsMap.set(key, (dep = new Set()));
    }
    dep.add(activeEffect);
  }
}

function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  let dep = depsMap.get(key);
  if (dep) {
    dep.forEach(effect => effect());
  }
}

function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      track(target, key);
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver);
      trigger(target, key);
      return result;
    }
  };
  return new Proxy(target, handler);
}

// watchEffect 用于注册副作用函数
function watchEffect(effect) {
  activeEffect = effect;
  effect(); // 立即执行一次,以触发 get 从而收集依赖
  activeEffect = null;
}

// --- 测试 ---
let product = reactive({ price: 10, quantity: 2 });
let total = 0;

watchEffect(() => {
  // 这个函数现在是我们的副作用
  total = product.price * product.quantity;
});

console.log(`total: ${total}`); // total: 20

// 当我们修改 price 时,不再需要手动调用 trigger
product.price = 20;
console.log(`total: ${total}`); // total: 40 (自动更新!)

// 当我们修改 quantity 时,也同样会自动更新
product.quantity = 3;
console.log(`total: ${total}`); // total: 60 (自动更新!)

现在,我们拥有了一个真正的“自动挡”响应式系统。我们只需要用 reactive() 包裹我们的数据,并用 watchEffect() 注册依赖于这些数据的操作,剩下的依赖收集和触发更新都由 Proxy 自动完成了。

总结

在本文中,我们通过一个循序渐进的过程,亲手实现了一个迷你版的Vue 3响应式系统。让我们回顾一下这个旅程:

  1. 从基础开始:我们首先理解了响应式的核心概念——当数据变化时,依赖该数据的代码应该自动重新执行。我们用一个简单的 Set 来存储单个依赖(副作用函数),并手动调用 tracktrigger
  2. 支持对象属性:为了处理对象,我们引入了 Map,建立了从“属性名”到“依赖集合”的映射关系,使得每个属性都能独立追踪自己的依赖。
  3. 支持多个对象:为了管理多个响应式对象,我们引入了 WeakMap,构建了 targetMap -> depsMap -> dep 的三层依赖存储结构。至此,我们的“手动挡”响应式系统已经成型。
  4. 迈向自动化:我们认识到手动调用 tracktrigger 的繁琐和不可靠。于是,我们引入了 ES6 的 ProxyReflect
    • Proxy 允许我们拦截对象的 getset 操作。我们在 get 拦截器中自动调用 track,在 set 拦截器中自动调用 trigger
    • Reflect 则作为 Proxy 的最佳搭档,提供了执行对象默认操作的标准方法,确保了操作的健壮性和 this 指向的正确性。

通过这个过程,我们不仅深入理解了Vue 3响应式系统的核心原理,还掌握了 ProxyReflect 这两个强大的JavaScript特性。

参考

告别 Vue 多分辨率适配烦恼:vfit 让元素定位 “丝滑” 跨设备

2025年11月21日 18:26
pre-sales-poster.jpg 在前端开发中,“多分辨率适配”一直是个绕不开的坎。尤其是Vue项目,面对从手机到大屏的各种设备,既要保证元素比例不变,又要让位置精准,往往需要手写大量缩放计算或媒体查询,代码冗余且难维护。

今天推荐一个专为Vue 3设计的轻量方案——vfit,通过“全局缩放管理+组件化定位”的思路,让适配工作变得简单可控。

为什么需要vfit?

传统适配方案(如rem、vw/vh)的痛点在于:

  • • 仅能处理“大小”适配,难以保证“位置”在不同分辨率下的一致性;
  • • 需手动维护基准值,缩放逻辑与业务代码耦合;
  • • 对固定像素布局(如设计稿上精确到px的定位)支持不友好。

vfit的解决思路更直接:

  1. 以设计稿宽高(如1920×1080)为基准,实时计算容器的缩放比例(scale = 容器尺寸 / 设计稿尺寸);
  2. 通过FitContainer组件,根据缩放比例自动调整元素的位置和大小,同时支持两种定位模式(px/%)。

核心能力解析

  1.  灵活的缩放模式
    vfit提供3种缩放策略,覆盖绝大多数场景:

    • • width:按容器宽度缩放(scale = 容器宽 / 设计稿宽);
    • • height:按容器高度缩放(scale = 容器高 / 设计稿高);
    • • auto:自动对比容器宽高比与设计稿宽高比,选择更合适的维度缩放(避免元素被截断)。
  2.  组件化定位
    内置的FitContainer组件是核心,通过top/bottom/left/right属性定义位置,配合unit参数控制定位逻辑:

    示例代码(像素定位):

    <template>  
      <div class="viewport" style="position: relative; width: 100%; height: 100vh;">  
        <FitContainer :top="90" :left="90" unit="px">  
          <div class="box">固定像素布局</div>  
        </FitContainer>  
      </div>  
    </template>  
    
    • • unit="%":位置基于容器百分比,不受缩放影响(适合居中、相对布局);
    • • unit="px":位置会自动乘以当前缩放值(适合固定像素定位,如设计稿上left:90px,缩放后实际位置为90×scale)。
  3.  全局缩放值访问
    通过useFitScale()钩子可在组件内获取当前缩放值(Ref<number>),方便自定义缩放逻辑:

    import { useFitScale } from 'vfit'  
    
    const scale = useFitScale()  
    console.log('当前缩放比例:', scale.value)  
    

上手成本极低

安装初始化仅需两步:

npm i vfit  
// main.ts  
import { createFitScale } from 'vfit'  
import 'vfit/style.css'  

app.use(createFitScale({  
  target'#app'// 默认为#app,可指定其他容器  
  designWidth1920// 设计稿宽度(默认1920)  
  designHeight1080// 设计稿高度(默认1080)  
  scaleMode'auto' // 默认auto  
}))  

适用场景与优势

  •  优势:轻量(无冗余依赖)、Vue 3原生支持、定位与缩放逻辑解耦、API简洁;
  • 场景:数据大屏、管理系统、多设备兼容的活动页等需要精确布局的场景。

如果你正在为Vue项目的多分辨率适配头疼,不妨试试vfit——它不追求大而全,只专注于把“缩放与定位”这件事做好。现在就去npm安装体验,让适配工作少走弯路~

光图片就300多M,微信小游戏给再大的分包也难啊!

2025年11月22日 08:06

点击上方亿元程序员+关注和★星标

引言

哈喽大家好,有小伙伴说他们公司的游戏项目,光图片资源就高达300M,最近正在考虑上架微信小游戏。

但是,如此庞大的游戏资源,想通过分包的形式肯定是不行的,只能放到服务器上,通过CDN让玩家下载。

笔者还是很好奇,什么样的资源如此巨大,通过对小伙伴的深入了解

惊讶发现,他们的图片资源居然没有压缩!且不说图片是否过于精致、美术手笔是否过于奔放,不压缩实在是太难受了!

言归正传,本期将带小伙伴们一起来看下,在Cocos游戏开发中,如何省心省力地压缩图片,330M能压缩到多少。

本文源工程可在文末获取,小伙伴们自行前往。

图片压缩

图片压缩是一种在尽可能保持图片质量的前提下,减小PNG文件大小的技术。

游戏开发中常用的图片压缩工具有TinyPNGpngquantCompressor.io等等。

pngquant是一款用于PNG图像有损压缩的命令行工具和函数库。

该转换工具能显著减小文件大小(通常高达70%),同时保持完整的Alpha通道透明度。生成的图像兼容所有主流网页浏览器和操作系统。

常用的参数包括:

  • quality min-max:质量,使用满足或超过“最大质量”所需的最少颜色数量。若转换后的质量低于“最低质量”要求,图像将不会被保存。
  • speed N:速度,从1(暴力)到10(最快)。默认为3。速度10的质量降低5%,但比默认速度快8倍。

图片压缩实例

下面一起来看下,在Cocos游戏开发中,如何通过插件集成到项目,使其构建后自动压缩图片。

1.资源准备

先准备一张PNG图片,原图大小0.97M,用来确认压缩是否成功,压缩质量如何。

2.创建扩展

新建一个项目,通过菜单扩展->创建扩展打开扩展创建面板。

我们选择构建插件,这是官方自定义构建插件的一个简单示例,点击创建扩展,我们直接在上面扩展我们的自动压缩。

3.启用扩展

通过菜单扩展->扩展管理器打开扩展管理器,在已安装扩展中找到我们新建的插件,将其改成开启状态。

4.扩展界面

首先我们删掉我们不需要的asset-handlers.tspanel.ts·,在builder.ts中只保留hooks配置。

接下来我们给构建面板加上一组配置,用来控制压缩是否开启、压缩的质量以及压缩的速度:

代码整理如下:

  • 启用复选框(ui-checkbox):默认不开启。
  • 最小质量滑块(ui-slider):0-100,默认65,步进1
  • 最大质量滑块(ui-slider):0-100,默认80,步进1
  • 速度滑块(ui-slider):1-10,默认3,步进1
  • 校验最大质量>=最小质量:

最后通过npm installnpm run build编译即可。

打开构建面板就可以看到我们添加的内容:

5.构建后压缩

界面控制添加完之后,我们需要把pngquant压缩植入到构建完成的钩子(onAfterBuild)里,使其构建完成后自动按照配置的参数进行压缩。

代码整理如下:

  • 检查是否启用了质量控制:
  • 提前准备好压缩的工具,这里包括winmac的,并且根据系统选择:
  • 在构建后目录递归查找所有图片文件:
  • 组装命令开始逐个压缩

6.测试

编译代码后,打开我们的构建面板,开始进行构建,构建完成后可以点开日志查看:

从日志可以看出原文件大小: 1002.9KB, 压缩后大小: 179.6KB, 压缩了82.1%,提示压缩成功。

打开构建好的目录,找到我们的资源,可以看到已经压缩完成:

图片压缩进阶

1.过滤

通常游戏中会有一些图片为了避免模糊,例如主界面,会通过配置的或者特殊文件名开头的形式进行过滤,不进行压缩,小伙伴们在面板或者文件配置,然后压缩前过滤即可。

2.记录

图片比较多,每次构建耗时比较长的话,可以把压缩提前到构建之前,并通过MD5记录表示图片已经压缩,直接跳过该文件,避免重复压缩。

结语

通过压缩,小伙伴的图片资源从330M降到了273M,压缩了18%。感觉剩余资源还是比较大,建议从其他方式进行检查和处理,例如查找没依赖的资源删除找美术重造等等。

不知道小伙伴们有没有其他更好的办法呢?

本文源工程可通过私信发送 PngExtension 获取。

我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。

AD:笔者线上的小游戏《打螺丝闯关》《贪吃蛇掌机经典》《重力迷宫球》《填色之旅》《方块掌机经典》大家可以自行点击搜索体验。

实不相瞒,想要个爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!

推荐专栏:

知识付费专栏

你知道和不知道的微信小游戏常用API整理,赶紧收藏用起来~

100个Cocos实例

8年主程手把手打造Cocos独立游戏开发框架

和8年游戏主程一起学习设计模式

从零开始开发贪吃蛇小游戏到上线系列

点击下方灰色按钮+关注。

❌
❌