普通视图

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

谷歌浏览器插件 录制动态 DOM 元素

作者 rocky191
2025年4月15日 19:30

背景

在开发谷歌浏览器插件以录制用户行为时,针对使用 Vue.js 或 React 等现代前端框架的应用,动态添加的元素可能未被正确识别和记录。

解决方案

使用 MutationObserver 监听 DOM 变化:MutationObserver 是一个用于监听 DOM 树变化的接口,可以检测到元素的添加、删除或属性变化。通过在插件中使用 MutationObserver,可以实时捕获动态添加的元素及其事件。

需要注意的是,MutationObserver 本身只能监听 DOM 变化,而 event 对象只有在事件触发时才能获取,因此需要配合事件监听器来捕获事件对象的属性信息。

// 定义一个事件处理器,用于捕获事件对象的属性
function eventHandler(event) {
  // 这里可以获取到 event 对象的各项属性,如:
  console.log("事件类型:", event.type);
  console.log("事件目标:", event.target);
  console.log("鼠标坐标:", { x: event.clientX, y: event.clientY });
  // 如有需要,可记录其他属性,例如 event.timeStamp、event.currentTarget 等
}

// 定义 MutationObserver 回调函数,用于处理 DOM 新增的节点
const observerCallback = (mutationsList) => {
  mutationsList.forEach(mutation => {
    // 仅处理新增节点
    if (mutation.type === 'childList') {
      mutation.addedNodes.forEach(node => {
        // 判断 node 是否为元素节点
        if (node.nodeType === Node.ELEMENT_NODE) {
          // 为动态新增元素注册事件监听器,这里以 click 事件为例
          node.addEventListener('click', eventHandler, false);

          // 如果节点内部还有动态添加的子节点,也可以通过 querySelectorAll 为其绑定
          node.querySelectorAll('*').forEach(child => {
            child.addEventListener('click', eventHandler, false);
          });
        }
      });
    }
  });
};

// 创建 MutationObserver 实例,并传入回调函数
const observer = new MutationObserver(observerCallback);

// 开始观察整个文档 body 下的子节点变化(包括后代节点)
observer.observe(document.body, { childList: true, subtree: true });

说明

  1. MutationObserver 监听 DOM 变化
    上述代码中,我们使用 MutationObserver 来检测整个 document.body 内部新增的节点。当检测到某个节点被添加时,判断该节点是否为元素(nodeType 为 ELEMENT_NODE),然后为其注册 click 事件监听器。

  2. 事件监听器捕获 event 对象
    自定义的 eventHandler 方法会在 click 事件触发时被调用,此时可以通过传入的 event 参数获取诸如 event.typeevent.target、以及鼠标点击位置等属性。你可以根据需求扩展记录或处理更多的 event 对象属性。

  3. 处理深层嵌套的动态添加
    如果一个新增的节点内部还包含多个元素,且你希望每个元素都能捕获点击事件,可以使用 querySelectorAll('*') 遍历所有子节点,并为每个子节点绑定监听器。

  4. 初始页面元素
    考虑到页面初始加载时已经存在的元素也可能需要捕获事件,可以在脚本初始化时对文档中所有元素提前进行绑定。

  5. 注意点

    • 如果应用中采用了事件委托(例如大范围绑定一个 click 事件监听器在父容器上),则可以直接在父容器上捕获所有子元素的点击事件,而不必为每个元素单独绑定。
    • 动态绑定过多事件监听器可能会对性能产生一定影响,需要根据实际场景进行优化,或考虑使用事件委托方式。

通过这种方式,可以在监听 DOM 变化的同时,确保新添加的元素在触发事件时能够获取到完整的事件对象属性,从而实现用户行为的完整录制。

在GitHub action中使用添加项目中配置文件的值为环境变量

作者 1024小神
2025年4月15日 18:25

比如我项目的根目录有一个package.json文件,但是我想在工作流中使用某个值,例如使用version的值,就需要从package.json里面取出来,然后存储到环境变量中,供后续步骤使用这个值。

读值存储

读取项目根目录中的某个json文件的值,需要使用jq这个功能,非常方便,支持嵌套读取里面的值,然后使用echo存储到环境变量中

- name: Get version from package.json
  id: get_version
  run: echo "VERSION=$(jq -r .version package.json)" >> $GITHUB_ENV

使用

在后续步骤中直接使用就可以了

- name: Use version
  run: echo "Package version is $VERSION"

🚀 Vue 人看 React useRef:它不只是替代 ref

作者 JiangJiang
2025年4月15日 18:16

如果你是从 Vue 转到 React 的开发者,初见 useRef 可能会想:这不就是 React 版的 ref 吗?但真相是 —— 它能做的,比你想象得多得多。

👀 Vue 人初见 useRef

在 Vue 中,ref 是我们访问 DOM 或响应式数据的利器。但在 React 中,useRef 并不止是一个获取 DOM 的工具,它更像是一个“不会引起重新渲染的变量容器”。

如果你在想:

  • “为啥我改了 ref.current,界面却没更新?”
  • “这玩意儿跟 Vue 的 ref 好像不太一样?”
  • “它除了操作 DOM 还能干嘛?”

这篇文章,就帮你用 Vue 人的视角,彻底搞懂 useRef 的多种用法与常见陷阱。


🔍 什么是 useRef

引入 React 文档的话:

"useRef 是一个 React Hook,它能帮助引用一个不需要渲染的值"

useRef 创建的是一个普通的 Javascript 对象,里面仅有一个 current 属性,用于读取和修改。

import { useRef } from "react";

function Example() {
  const countRef = useRef(0);
  countRef.current += 1;
}

useRef 是一个 可变的盒子,你可以把任何值塞进去,它不会重新触发组件 render,但你可以随时取用。

🧪 举个栗子:
export default function Example() {
  const countRef = useRef(0);
  console.log("render");

  return (
    <button onClick={() => (countRef.current += 1)}>
      点击count:{countRef.current}
    </button>
  );
}

我们创建一个按钮去给 countRef 进行自增,我们看看组件有没有重新 render

ainimation1.gif

可以看到,虽然 countRef 数据自增了,但是却不会 触发新的渲染

"当你希望组件“记住”某些信息,但又不想让这些信息 触发新的渲染 时,你可以使用 useRef"

如果你比较熟悉 useState,我们可以举个更简单的例子:

import { useState } from "react";

export default function Example() {
  const [countRef, never] = useState({ current: 0 });

  return (
    <button onClick={() => (countRef.current += 1)}>
      点击count:{countRef.current}
    </button>
  );
}

原则上 useRef 可以在 useState 的基础上实现:

不使用setup函数去改变值,不去触发新的渲染。

了解了基本概念,我们再来看看它与 Vue 的 ref 有哪些关键不同。

⚔️ useRef 和 Vue ref 的区别

对比点 useRef Vue ref
响应性 不具备响应性,不触发 render 具备响应性,数据变化会更新视图
使用场景 DOM引用、缓存变量、定时器、历史值等 DOM引用、响应式数据
数据结构 { current: value } value 是响应式对象
是否引发视图更新

Vue 的 ref响应式容器,值变化会自动更新视图;

而 React 的 useRef 更像一个可读写但不具响应性的变量盒子。

🧩 useRef 常见使用场景

前面介绍完 useRef 的基本概念和使用方法,我们接下来看看平时开发中比较常见的使用场景:

1. 定时器引用

我们来实现一个 简易计时器

import { useState, useRef } from "react";

export default function Example() {
  const [time, setTime] = useState(0);
  const timerRef = useRef(null);

  const handleStart = () => {
    if (timerRef.current) return;
    const startTime = Date.now();
    timerRef.current = setInterval(() => {
      setTime(Date.now() - startTime);
    }, 10);
  };
  const handleStop = () => {
    if (!timerRef.current) return;
    clearInterval(timerRef.current);
    timerRef.current = null;
    console.log("销毁定时器:", timerRef.current);
  };

  return (
    <>
      <h1>计时器: {time}</h1>
      <button onClick={handleStart}>开始</button>
      <button onClick={handleStop}>停止</button>
    </>
  );
}
  • 按下开始键,计时器 开始进行计时,这时候把定时器存到 timerRef
  • 按下停止键,销毁当前定时器,防止出现 闭包导致的内存泄漏

ainimation2.gif

2. 操作 DOM

我们假定一个场景,用户进入页面时,我们需要用户光标默认 聚焦到输入框

import { useEffect, useRef } from "react";

export default function Example() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} />;
}

我们需要:

  • 使用 useRef 创建 inputRef,默认值为 null
  • 使用 ref={inputRef} 去存储当前 DOM 元素。
  • 通过 useEffect 在进入页面时进行 inputRef.current?.focus()

这里类似 Vue 的 ref="xxx" + this.$refs.xxx.focus()

注意: 不要在渲染过程中读取或写入ref.current,会使ref变得不可预测。

ainimation3.gif

使用 ref 去存储正常的标签都能正常获取其 DOM 元素,但是当你尝试将 ref 放在 自定义组件 上,会发生什么呢?

3. 绑定自定义组件的 ref

我们先来实践一下:

import { useEffect, useRef } from "react";

function MyInput(props) {
  return <input {...props} />;
}

export default function Example() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  return <MyInput ref={inputRef} />;
}

我们创建一个 MyInput 子组件,然后把 ref 绑定到我们子组件上。

控制台直接给我们弹了报错:

image-20250415174214785.png

React 向控制台打印一条错误消息,提示我们如果想操控子组件,需要去使用 forwardRef API。

forwardRef 的用法

  • forwardRef表示允许子组件将其 DOM 节点放入 ref 中,默认情况下是不允许的。
  • forwardRef会让传入的子组件多一个 ref 作为第二个参数传入,用于存储当前 DOM 节点,第一个参数为 props

我们改进下:

const MyInput = forwardRef((props, ref) => {
  return <input {...props} ref={ref} />;
});

我们将 ref 绑定到 MyInput 组件中的 input,再来看看效果:

ainimation4.gif

现在不会报错了,并且 input 框也正常 聚焦 了。


总结

  • useRef 用于存储值且不想去触发 render 的场景。
  • useRef 创建的值通过 .current 去进行 读取、修改
  • 常见用于存储 定时器、 DOM 节点
  • 存储自定义组件的 DOM 节点需配合 forwardRef API 使用。
  • 需要注意 不要在渲染过程中读取或写入 ref.current

如果你也是从 Vue 转过来的,看到这里可能已经对 useRef 有了更清晰的认知 —— 它并不是 Vue 的 ref 替代品,而是一种完全不同思路下的状态管理补充工具

希望这篇文章能帮你快速掌握 useRef,如果你觉得有帮助,别忘了点个赞👍或关注我后续的 重学 React 系列!

Vue3与Vite构建高性能记账应用 - LedgerX架构解析

作者 源柒
2025年4月15日 18:08

Vue3与Vite构建高性能记账应用 - LedgerX架构解析

发布日期: 2025-04-15

引言

在移动互联网时代,个人财务管理已成为许多用户日常生活的重要组成部分。随着 Vue3、Vite 等现代前端技术的成熟,我们有机会重新思考记账应用的设计与实现方式。本文将深入探讨 LedgerX 记账应用的前端架构设计,分享我们如何利用现代前端技术栈构建一个高性能、易扩展且用户体验卓越的记账应用。

LedgerX 技术栈概览

LedgerX 采用了当前最前沿的前端技术栈,主要包括:

  • 「核心框架」: Vue 3 (Composition API)
  • 「构建工具」: Vite
  • 「UI 组件库」: Element Plus
  • 「状态管理」: Pinia
  • 「路由管理」: Vue Router
  • 「图表库」: ECharts
  • 「图标库」: Font Awesome
  • 「移动端适配」: Capacitor
  • 「CSS 预处理器」: SCSS / CSS Variables
  • 「HTTP 客户端」: Axios

这套技术栈的选择并非随意,而是经过深思熟虑,针对记账应用特定场景进行的优化选择。接下来,我们将详细分析每个技术选择背后的思考。

架构设计理念

1. 组件化设计与领域分离

LedgerX 应用遵循了严格的组件化设计原则,将应用功能按领域划分为不同模块:

src/
├── components/              # 组件目录
│   ├── common/              # 通用组件
│   ├── dashboard/           # 仪表盘相关组件
│   ├── ledger/              # 记账相关组件
│   ├── analysis/            # 分析相关组件
│   ├── categories/          # 分类相关组件
│   └── user/                # 用户相关组件
├── views/                   # 页面视图
│   ├── Dashboard.vue        # 仪表盘页面
│   ├── Ledger.vue           # 记账页面
│   ├── Analysis.vue         # 分析页面
│   ├── Categories.vue       # 分类管理页面
│   └── UserAccount.vue      # 用户账户页面

每个组件都遵循单一职责原则,这种设计带来几个关键优势:

  1. 「可维护性」: 业务逻辑被封装在特定组件中,降低了代码复杂度
  2. 「可重用性」: 通用组件可在不同页面复用,减少代码重复
  3. 「可测试性」: 组件化设计使单元测试更加容易实施
  4. 「协作效率」: 团队成员可以并行开发不同模块,减少冲突

2. 状态管理策略

财务数据的状态管理是记账应用的核心挑战。我们选择 Pinia 作为状态管理库,放弃 Vuex 的原因在于:

  • Pinia 提供了更简洁的 API 和更好的 TypeScript 支持
  • 使用 Composition API 风格,与 Vue 3 组件风格保持一致
  • 更好的开发体验和性能表现

LedgerX 的状态管理主要分为两类:

// 交易记录状态管理 (src/stores/transaction.js)
export const useTransactionStore = defineStore('transaction', {
  state() => ({
    transactions: [],
    loadingfalse,
    filters: {
      dateRangenull,
      categories: [],
      typenull
    }
  }),
  
  getters: {
    // 各类计算属性,如收入总和、支出总和、余额等
    totalIncome(state) => { /* ... */ },
    totalExpense(state) => { /* ... */ },
    balance(state) => { /* ... */ },
    // 按日期分组的交易
    transactionsByDate(state) => { /* ... */ }
  },
  
  actions: {
    // 增删改查交易记录
    addTransaction(transaction) { /* ... */ },
    updateTransaction(id, data) { /* ... */ },
    deleteTransaction(id) { /* ... */ },
    fetchTransactions(filters) { /* ... */ }
  }
});

// 用户状态管理 (src/stores/user.js)
export const useUserStore = defineStore('user', {
  state() => ({
    profilenull,
    settings: {},
    isAuthenticatedfalse
  }),
  
  actions: {
    // 用户相关操作
    login(credentials) { /* ... */ },
    logout() { /* ... */ },
    updateProfile(data) { /* ... */ }
  }
});

这种分离允许我们将不同领域的状态隔离,避免单一状态树过于庞大,同时便于按需加载,优化应用性能。

3. 响应式设计与移动适配

LedgerX 采用移动优先的设计理念,同时通过 Capacitor 支持跨平台部署。这要求我们的 UI 设计具有高度的响应性和适应性:

  1. 「弹性布局」: 使用 CSS Flexbox 和 Grid 创建弹性布局
  2. 「相对单位」: 大量使用 rem, vh, vw 等相对单位,而非固定像素
  3. 「媒体查询」: 针对不同屏幕尺寸设计适配方案
  4. 「条件渲染」: 在不同设备上渲染不同组件或布局
// 移动端优先的媒体查询示例
.card-grid {
  display: grid;
  grid-template-columns: 1fr;  // 移动端默认单列
  gap: 1rem;
  
  @media (min-width: 768px) {  // 平板设备
    grid-template-columns: 1fr 1fr;
  }
  
  @media (min-width: 1024px) {  // 桌面设备
    grid-template-columns: 1fr 1fr 1fr;
  }
}

4. 性能优化策略

记账应用需要处理大量数据和频繁的用户交互,性能优化至关重要。LedgerX 采取了以下策略:

代码分割与懒加载

利用 Vue Router 和 Vite 的特性实现组件懒加载:

// 路由懒加载示例
const routes = [
  {
    path'/',
    component() => import('./views/Dashboard.vue')
  },
  {
    path'/ledger',
    component() => import('./views/Ledger.vue')
  },
  // 其他路由...
]

这种方式可以显著减小初始加载包的大小,加快首屏渲染速度。

虚拟列表优化

对于交易记录列表等长列表场景,我们实现了虚拟滚动,只渲染视口内可见的项目:

<template>
  <div class="transaction-list-container">
    <virtual-list
      :data-key="'id'"
      :data-sources="transactionList"
      :data-component="TransactionItem"
      :estimate-size="70"
      :buffer="10"
    />
  </div>
</template>

这大大提高了长列表的渲染性能和滚动流畅度。

计算属性与缓存

充分利用 Vue 的计算属性进行数据缓存,避免重复计算:

// 带缓存的计算属性示例
const categoryTotals = computed(() => {
  // 计算各分类总额,仅在 transactions 变化时重新计算
  return transactions.value.reduce((acc, transaction) => {
    const { categoryId, amount, type } = transaction;
    if (!acc[categoryId]) acc[categoryId] = 0;
    acc[categoryId] += type === 'income' ? amount : -amount;
    return acc;
  }, {});
});
渲染优化

针对频繁更新的组件,使用 v-oncev-memo 等指令减少不必要的重渲染:

<!-- 使用 v-memo 优化列表渲染 -->
<div v-for="item in list" :key="item.id" v-memo="[item.id, item.amount]">
  {{ item.title }} - {{ item.amount }}
</div>

核心功能实现解析

1. 交易记录系统

交易记录是记账应用的核心功能,LedgerX 的交易记录系统设计包括:

  • 「数据模型」: 定义清晰的交易记录数据结构
  • 「表单验证」: 前端实时验证确保数据质量
  • 「分类管理」: 灵活的分类与子分类系统
  • 「批量操作」: 支持多条记录的批量操作

关键实现点在于表单组件与状态管理的结合:

<!-- 交易记录表单简化示例 -->
<template>
  <el-form :model="formData" :rules="rules">
    <el-form-item label="类型" prop="type">
      <el-radio-group v-model="formData.type">
        <el-radio label="expense">支出</el-radio>
        <el-radio label="income">收入</el-radio>
      </el-radio-group>
    </el-form-item>
    
    <el-form-item label="金额" prop="amount">
      <el-input-number v-model="formData.amount" :precision="2" />
    </el-form-item>
    
    <el-form-item label="分类" prop="categoryId">
      <category-selector v-model="formData.categoryId" :type="formData.type" />
    </el-form-item>
    
    <!-- 其他表单项... -->
    
    <el-button type="primary" @click="submitForm">保存</el-button>
  </el-form>
</template>

<script setup>
import { ref, reactive } from 'vue';
import { useTransactionStore } from '@/stores/transaction';

const transactionStore = useTransactionStore();

// 表单数据与验证规则
const formData = reactive({
  type: 'expense',
  amount: 0,
  categoryId: null,
  date: new Date(),
  note: ''
});

const rules = {
  amount: [
    { required: true, message: '请输入金额' },
    { type: 'number', min: 0.01, message: '金额必须大于0' }
  ],
  categoryId: [
    { required: true, message: '请选择分类' }
  ]
};

// 表单提交
const submitForm = async () => {
  try {
    // 验证通过后提交数据
    await transactionStore.addTransaction(formData);
    // 重置表单...
  } catch (error) {
    // 错误处理...
  }
};
</script>

2. 数据可视化与分析

财务分析是 LedgerX 的差异化特性,我们使用 ECharts 实现了丰富的数据可视化功能:

<!-- 收支趋势图表示例 -->
<template>
  <div class="chart-container">
    <div ref="chartRef" class="chart"></div>
  </div>
</template>

<script setup>
import { ref, onMounted, watch, computed } from 'vue';
import * as echarts from 'echarts/core';
import { LineChart } from 'echarts/charts';
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { useTransactionStore } from '@/stores/transaction';

// 注册 ECharts 组件
echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]);

const chartRef = ref(null);
const transactionStore = useTransactionStore();
let chart = null;

// 处理数据
const chartData = computed(() => {
  // 从 store 获取数据并处理成图表所需格式
  const { transactions } = transactionStore;
  // 数据处理逻辑...
  return {
    dates: ['1月', '2月', '3月', '...'],
    incomes: [5000, 6000, 5500, '...'],
    expenses: [3000, 3500, 4000, '...']
  };
});

// 初始化图表
onMounted(() => {
  if (chartRef.value) {
    chart = echarts.init(chartRef.value);
    updateChart();
  }
});

// 更新图表
const updateChart = () => {
  const { dates, incomes, expenses } = chartData.value;
  
  const option = {
    tooltip: {
      trigger: 'axis',
      formatter: '{b}<br />{a0}: {c0}<br />{a1}: {c1}'
    },
    legend: {
      data: ['收入', '支出']
    },
    grid: {
      left: '3%',
      right: '4%',
      bottom: '3%',
      containLabel: true
    },
    xAxis: {
      type: 'category',
      data: dates
    },
    yAxis: {
      type: 'value'
    },
    series: [
      {
        name: '收入',
        type: 'line',
        data: incomes,
        itemStyle: {
          color: '#67C23A'
        }
      },
      {
        name: '支出',
        type: 'line',
        data: expenses,
        itemStyle: {
          color: '#F56C6C'
        }
      }
    ]
  };
  
  chart.setOption(option);
};

// 监听数据变化,更新图表
watch(chartData, () => {
  if (chart) {
    updateChart();
  }
});
</script>

我们特别关注了图表的交互性和响应式,确保在不同设备上都能提供良好的用户体验。

3. 多端适配与离线功能

通过 Capacitor,LedgerX 实现了从 Web 到原生应用的平滑过渡。这里有几个关键实现点:

  1. 「插件系统」: 使用 Capacitor 插件访问设备原生功能
// 状态栏插件使用示例
import { StatusBarStyle } from '@capacitor/status-bar';

// 根据平台条件执行代码
const setupStatusBar = async () => {
  // 仅在移动应用环境中执行
  if (Capacitor.isNativePlatform()) {
    try {
      StatusBar.setStyle({ styleStyle.Light });
      StatusBar.setBackgroundColor({ color'#ffffff' });
    } catch (error) {
      console.error('状态栏设置失败', error);
    }
  }
};
  1. 「离线数据存储」: 实现本地数据缓存和同步机制
// 简化的离线存储示例
const saveTransactionOffline = async (transaction) => {
  try {
    // 保存到本地存储
    const existing = JSON.parse(localStorage.getItem('offlineTransactions') || '[]');
    existing.push({
      ...transaction,
      pendingSynctrue,
      localIdDate.now() // 本地临时ID
    });
    localStorage.setItem('offlineTransactions'JSON.stringify(existing));
    
    // 在网络恢复时同步
    window.addEventListener('online', syncOfflineData);
    
    return { successtruelocalId: transaction.localId };
  } catch (error) {
    console.error('离线保存失败', error);
    return { successfalse, error };
  }
};

结语

LedgerX 的前端架构设计体现了现代 Web 应用开发的最佳实践,从技术选型到架构设计、从性能优化到用户体验,每一环节都经过精心考量。我们相信,这种以用户为中心、技术为驱动的开发理念,将为用户带来更加高效、愉悦的记账体验。

在技术不断演进的今天,我们仍将持续关注前端领域的新技术、新思路,不断优化 LedgerX 的架构与性能,为用户提供更好的产品体验。


本文是 LedgerX 技术博客系列的第一篇,后续我们将分享更多关于记账应用开发的技术细节和经验。欢迎关注 LedgerX 官方网站获取最新动态。

本文使用 markdown.com.cn 排版

qiankun微应用动态设置静态资源访问路径

作者 龙骑utr
2025年4月15日 18:07

背景:项目需要支持多个国家访问,为了提升子应用的访问速度,又因为成本原因导致使用DNS无法动态解析ip映射到对应的域名,因此需要为子应用申请多个域名,然后由主应用根据国家进行映射和路由。

由于微前端框架使用的是qiankun,子应用打包的时候需要设置publicPath,否则访问静态资源的时候会去主应用中找,可能会导致异常,这个问题qiankun官方给出的方案是将publicPath的相对路径设置成一个绝对地址的url(其中的技术细节,请参考qiankun - 常见问题)。

这样的话,根据我的需求,在多个域名的场景下,这种方式是行不通的。

image.png

小伙伴还是很机灵的,不隔一会就给出了解决方案:在打包配置里面多打几个html出来,再写个插件动态的把html引入的静态资源用正则匹配替换成相对应的域名,主应用在访问的时候,子应用根据域名找到对应的html文件,这样不就解决了嘛

// 编译
replaceDomainPlugin(env),
createHtmlPlugin({
    minify: true,
    pages: [
      {
        entry: "src/main.tsx",
        filename: "index.html",
        template: "index.html"
      },
      {
        entry: "src/main.tsx",
        filename: "malaysia.html",
        template: "malaysia.html"
      }
    ]
})
// 替换
const replaceDomain = (env: Record<string, string>) => {
  const {
    VITE_PUBLIC_STATIC_URL,
    VITE_PUBLIC_MY_STATIC_URL
  } = env;
  const regex = new RegExp(VITE_PUBLIC_STATIC_URL, "g");
  return {
    name: "html-transform",
    transformIndexHtml(html: string, options: any) {
      switch(options.path) {
        case "/malaysia.html":
          return html.replace(regex, VITE_PUBLIC_MY_STATIC_URL);
        default:
          return html;
      }
    }
  };
};
    
export default replaceDomain;

麻烦虽然是麻烦了点,但由于时间的因素,我们先给出问题的解决方案

image.png

空闲的时候我又翻起了qiankun的文档,难道就没有简单优雅一点的方法解决这个问题吗?直到我在常见问题的版块中看到了一个问题:如何解决由于运营商动态插入的脚本加载异常导致微应用加载失败的问题,它使用了自定义的 getTemplate 方法对子应用的模版进行了劫持并加以修改再返回回去。我的老天奶,那我是不是可以在主应用中对子应用的模版进行劫持,再把约定好的标识位使用正则替换掉,如此一来,打包的问题解决了,多方维护加速域名的问题也解决了,这不就是我想要的吗!!!!!

说干就干,由于我们使用的是手动加载子应用的方式,因此我在loadMicroApp方法中传入了第二个参数

loadMicroApp(
  {
    name: 'microApp',
    entry: self.entry,
    container: '#microAppContainer',
    activeRule: '/#/first/second',
  },
  {
    getTemplate(tpl) {
      const newTpl = tpl.replace(/\/regionTag\//g, self.entry);
      console.log(`${new Date()}  🚀🚀🚀 ~ newTpl ~ newTpl:`, newTpl);
      return newTpl;
    }
  }
);

从打印的结果来看,完全符合我们的需求,发布到线上后,替换后的模版也按照我们的预期正常运行。

踩坑:getTemplate中无法获取到vue实例this,因此我上面的代码片段中使用了self替代了this

至此,Ending!!

渐变边框设计

作者 Mnxj
2025年4月15日 18:04

如何实现一个边框颜色的渐变

首先border是没有这个属性的,

想到的方案是借助div包裹的方式

parent div 设计渐变背景

child div 设置padding

grandson div 内容

使用taliwind实现

  1. 外层 div:
    // 自定义样式
   bg-gradient: linear-gradient(100.18deg, #5456C8 0%, #8CD5FF 100%)
  
   <div className="w-full bg-gradient">
   ```
   - `w-full`: 宽度设置为 100%。
   - `bg-gradient-border-right`: 这是一个自定义的 Tailwind CSS 类,用于创建右侧的渐变边框效果。这个类可能是在 Tailwind CSS 的配置文件中定义的。

  1. 中间层 div:

    <div className="flex w-full m-[1px]">
    
    • flex: 使用 Flexbox 布局。
    • w-full: 宽度设置为 100%。
    • m-[1px]: 设置外边距为 1px,用于在内外层 div 之间创建一个细小的间隙,从而显示外层 div 的渐变边框。
  2. 内层 div:

    <div className="flex w-full flex-col justify-center">
    
    • flex: 使用 Flexbox 布局。
    • w-full: 宽度设置为 100%。
    • flex-col: 将 Flexbox 方向设置为列。
    • justify-center: 在垂直方向上居中对齐子元素。

通过这种嵌套结构,最外层的 div 提供了一个渐变边框,中间层的 div 设置了背景颜色并留出细小的间隙来显示外层的渐变边框,而内层的 div 则设置了背景颜色和透明度,并包含了实际的内容。

如果有圆角需求还可以给属性加上rounded-xl

- `rounded-xl`: 设置圆角大小为 `xl`

TypeScript基础一

2025年4月15日 18:03

一、TypeScript简介

  1. TypeScript由微软开发,是基于javaScript的一个扩展语言。
  2. TypeScript包含了JavaScript的所有内容,即:TypeScript是JavaScript的超集,
  3. TypeScript增加了:静态类型检查、接口、泛型等很多现代开发特性,因此更适合大型项目的开发。
  4. TypeScript需要编译为JavaScript,然后交给浏览器或其他JavaScript运行环境执行。

一、TypeScript出现原因

1. js存在以下问题:js不会报错,不利于项目维护

  • 不清不楚的数据类型
  • 有漏洞的逻辑
  • 访问不存在的属性
  • 低级拼写错误

2.ts支持静态类型检查

在代码运行前进行检查,发现代码的错误或不合理之处,减小运行时异常的出现的几率

3.TypeScript的代码结构更加清晰,便于维护

三、TS编译

从html文件引入编译后的js文件

  1. 全局安装ts
npm install -g typescript
  1. 初始化
tsc init  

3.tsc --watch 监听ts文件

  tsc --watch xx.ts

四、TS数据类型

1. js数据类型

  • 基本类型 :string、number、boolean、null、undefined、symbol;
  • 引用/对象类型:String、Number、Boolean、Array、Function、Date、Error、Math、RegExp、Global、Object

2. TS新增类型

2.1 any:任意类型。

any类型可以赋值给任意类型变量,对定义类型变量产生破坏

image.png

2.2 unknown:未知类型

(1) 可以理解为类型安全的any,适用于:不确定数据的具体类型

image.png (2)解决unknown报错方法(typeOf、断言)

image.png

2.3 never:任何值都不是,不能有任何值,几乎用不到

(1) 几乎不用never去直接定义变量,没有意义

image.png (2) never一般是TS主动推断出的

image.png

(3) never可用于限制函数的类型(函数永远也执行不完或者直接抛出异常的情况返回nerver)

image.png

2.4 void:通常用于函数返回值声明【函数不返回任何值/返回值为空,调用者也不应依赖其返回值做任何操作】

image.png 注意:不能接受void定义的函数返回值做操作,即使函数隐式返回undefined image.png

2.5 object(object和Object):实际应用很少,范围太宽

(1) object image.png (2) Object(除了null和undefined 都可以存)

image.png (3) 声明对象类型

1)字面量类型声明(“ ?”为可选属性) image.png 2)索引签名:允许定义对象可以具有任意数据量的属性,这些属性的键和类型是可变的,常用于描述类型不确定的属性,(具有动态属性的对象)

image.png (4) 声明函数类型

image.png

```js
注意:
1.TS 中的=>在函数类型声明时表示函数类型,描述其参数类型和返回类型
2.JS 中的=>是一种定义函数的语法,是具体函数的实现
3.函数类型声明还可以使用接口,自定义类型等方式
```

(4) 声明数组类型
image.png

2.6 tuple元组

元组是一种特殊的数组类型,可以存固定数量的元素,并且每个元素的类型是已知的且可以不同。元组用于精确描述一组数值的类型,?表示可选元素

image.png

2.7 enum枚举(重点)

作用:增加代码可读性,便于后期维护

(1) 数字枚举

数字枚举一种最常见的枚举类型,其成员的值自动递增且数字枚举还具备反向映射的特点,在下面 代码的打印中,不难发现:可以通过值来获取对应的枚举成员名称。

image.png

不给初始值,默认值从0开始自动递增,否则从当前值递增 image.png (2) 字符串枚举

会丢失反向映射特点 image.png (3) 常量枚举 会减少编译后生成的js代码量,提高运行时的性能

  • 使用常量枚举,编译后js文件只生成我们需要的js代码 image.png
  • 不使用常量枚举,编译后会生成一些额外的js代码 image.png

3.两个自定义类型方式

3.1 type(重点)

type可以为任意类型创建别名,让代码更简洁,可读性更强,同时能更方便的进行类型复用和扩展

(1) 基础用法

image.png

(2) 联合类型(或类型 “ | ”)

联合类型是一种高级类型,值可以是类型中的一种 image.png

(3)交叉类型(且类型 “ & ”)

允许多个类型合并为一个类型,合并后会拥有类型所有成员,常用于对象类型 image.png (4)特殊情况

在函数定义时,限制函数返回值为void,那么函数得返回值必须是空

image.png

使用类型声明限制函数返回值为void时,ts并不会严格要求函数返回空

image.png 原因:是为了确保如下代码成立,我们知道Array,prototype.push的返回一个数字,而Array.prototy pe,forEach方法期望其回调的返回类型是void image.png

3.2 interface(重点)

UniApp项目中的多服务环境配置与跨域代理实现

2025年4月15日 18:01

在前后端分离的开发模式下,前端应用经常需要与多个后端服务进行交互。本文将详细介绍如何在UniApp项目中配置和管理多个后台服务地址,以及如何处理跨域请求问题,特别是在H5环境下的代理配置。

1. 环境变量配置

在UniApp项目中,我们可以通过环境变量文件来管理不同环境下的配置信息。以下是开发环境的配置示例:

# env/.env.development
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV = 'development'
# 是否去除console 和 debugger
VITE_DELETE_CONSOLE = false
# 是否开启sourcemap
VITE_SHOW_SOURCEMAP = true

# 后端服务上传地址
VITE_UPLOAD_BASEURL = 'https://example-upload.com/upload'


# 多个后台服务地址
VITE_SERVER_BASEURLS='[{"service1":"https://example-api.com/service1"},{"service2":"https://example-api.com/service2"},{"service3":"https://example-api.com/service3"}]'

在基础环境配置文件中,我们还定义了代理相关的设置:

# env/.env
# h5是否需要配置代理
VITE_APP_PROXY=true
VITE_APP_PROXY_PREFIX = '/api'

1.1 多服务地址配置说明

VITE_SERVER_BASEURLS变量使用JSON格式存储多个后台服务地址,每个服务以键值对形式定义,键为服务名称,值为对应的API基础URL。这种设计允许我们在一个应用中同时连接多个不同的后端服务。

2. TypeScript类型定义

为了在TypeScript环境中获得良好的类型支持,我们需要为环境变量定义接口:

// src/env.d.ts
interface ImportMetaEnv {
  /** 网站标题,应用名称 */
  readonly VITE_APP_TITLE: string
  /** 服务端口号 */
  readonly VITE_SERVER_PORT: string
  /** 后台接口地址 */
  readonly VITE_SERVER_BASEURL: string
  /** H5是否需要代理 */
  readonly VITE_APP_PROXY: 'true' | 'false'
  /** H5是否需要代理,需要的话有个前缀 */
  readonly VITE_APP_PROXY_PREFIX: string // 一般是/api
  /** 上传图片地址 */
  readonly VITE_UPLOAD_BASEURL: string
  /** 是否清除console */
  readonly VITE_DELETE_CONSOLE: string
  // 更多环境变量...
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

3. Vite配置中的代理设置

在Vite配置文件中,我们需要设置代理规则,特别是在H5环境下:

// vite.config.ts
server: {
  host: '0.0.0.0',
  hmr: true,
  port: Number.parseInt(VITE_APP_PORT, 10),
  // 仅 H5 端且 VITE_APP_PROXY 为 true 时生效
  proxy: UNI_PLATFORM === 'h5' && JSON.parse(VITE_APP_PROXY)
    ? (() => {
        const proxyConfig = {}

        // 处理多个后端服务地址
        if (VITE_SERVER_BASEURLS) {
          try {
            const services = JSON.parse(VITE_SERVER_BASEURLS)
            services.forEach((service) => {
              const [serviceName, url] = Object.entries(service)[0]
              proxyConfig[`^/${serviceName}`] = {
                target: url,
                changeOrigin: true,
                rewrite: path => path.replace(new RegExp(`^/${serviceName}`), ''),
              }
            })
            // 打印代理规则
            console.log('====== 代理规则配置 ======')
            Object.entries(proxyConfig).forEach(([path, config]) => {
              console.log(`路径模式: ${path}`)
              console.log(`目标地址: ${config.target}`)
              console.log(`重写规则: ${path} -> ${config.target}`)
              console.log('------------------------')
            })
            console.log('=========================')
          }
          catch (error) {
            console.error('解析 VITE_SERVER_BASEURLS 失败:', error)
          }
        }

        return Object.keys(proxyConfig).length > 0 ? proxyConfig : undefined
      })()
    : undefined,
},

这段配置的核心逻辑是:

  1. 仅在H5环境且启用代理时生效
  2. 解析VITE_SERVER_BASEURLS中的服务配置
  3. 为每个服务创建对应的代理规则
  4. 代理规则使用服务名作为路径前缀,如/service1/service2
  5. 请求会被重写,去掉服务名前缀后转发到目标服务器

4. 请求拦截器实现

为了统一处理请求,我们实现了请求拦截器:

// src/interceptors/request.ts
// 请求基准地址
const baseUrls = (() => {
  try {
    return JSON.parse(import.meta.env.VITE_SERVER_BASEURLS || '[]').reduce((acc, item) => {
      const [key, value] = Object.entries(item)[0]
      return { ...acc, [key]: value }
    }, {})
  }
  catch (e) {
    console.error('解析 VITE_SERVER_BASEURLS 失败:', e)
    return {}
  }
})()

function getServiceBaseUrl(serviceName = 'service1') {
  // 小程序端环境区分
  if (isMp) {
    const { miniProgram } = uni.getAccountInfoSync()
    const envVersion = miniProgram.envVersion

    // 根据环境返回不同的URL
    if (serviceName === 'service1') {
      switch (envVersion) {
        case 'develop':
          return 'https://example-dev.com'
        case 'trial':
          return 'https://example-test.com'
        case 'release':
          return 'https://example-prod.com'
        default:
          return 'https://example-dev.com'
      }
    }
  }

  return baseUrls[serviceName] || baseUrls.service1
}

拦截器的核心功能是处理请求URL:

// 拦截器配置
const httpInterceptor = {
  // 拦截前触发
  invoke(options: CustomRequestOptions) {
    // ... 其他处理逻辑 ...

    // 非 http 开头需拼接地址
    if (!options.url.startsWith('http')) {
      // #ifdef H5
      if (JSON.parse(import.meta.env.VITE_APP_PROXY)) {
        // 检查是否指定了服务名
        if (options.serviceName) {
          // 在 H5 环境中启用了代理时,将服务名作为请求路径前缀
          options.url = `/${options.serviceName}${options.url}`
        }
        else {
          // 如果没有指定服务名,使用默认服务名'service1'
          options.url = `/service1${options.url}`
        }
      }
      else {
        // H5 但没有启用代理时,使用完整 URL
        const serviceBaseUrl = getServiceBaseUrl(options.serviceName)
        options.url = serviceBaseUrl + options.url
      }
      // #endif
      // 非H5正常拼接
      // #ifndef H5
      const serviceBaseUrl = getServiceBaseUrl(options.serviceName)
      options.url = serviceBaseUrl + options.url
      // #endif
    }

    // ... 添加token等其他处理 ...
  },
}

5. HTTP请求工具封装

基于拦截器,我们可以封装HTTP请求工具,支持指定服务名:

// src/utils/http.ts
function get<T>(url: string, query?: Record<string, any>, options?: Partial<ExtendedRequestOptions> & { serviceName?: string }) {
  return request<T>({
    method: 'GET',
    url,
    query,
    ...options,
  })
}

function post<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, options?: Partial<ExtendedRequestOptions> & { serviceName?: string }) {
  return request<T>({
    method: 'POST',
    url,
    data,
    query,
    ...options,
  })
}

6. 使用示例

6.1 基本使用

// 默认使用service1服务
http.get('/api/user/info')

// 指定使用service2服务
http.get('/api/articles', null, { serviceName: 'service2' })

// 指定使用service3服务
http.post('/api/comments', { content: '评论内容' }, null, { serviceName: 'service3' })

6.2 在组件中使用

<script setup lang="ts">
import { http } from '@/utils'
import { ref } from 'vue'

const articles = ref([])

// 从service2服务获取文章列表
async function fetchArticles() {
  try {
    const res = await http.get('/api/articles', null, { serviceName: 'service2' })
    articles.value = res.data
  }
  catch (error) {
    console.error('获取文章失败', error)
  }
}

// 向service3服务提交表单
async function submitForm(data) {
  try {
    await http.post('/api/submit', data, null, { serviceName: 'service3' })
    uni.showToast({ title: '提交成功' })
  }
  catch (error) {
    uni.showToast({ title: '提交失败', icon: 'none' })
  }
}

onMounted(() => {
  fetchArticles()
})
</script>

7. 总结

通过以上配置和实现,我们成功地在UniApp项目中实现了多服务环境的支持:

  1. 灵活的服务配置:通过环境变量配置多个后台服务地址
  2. 统一的请求处理:使用拦截器统一处理不同服务的请求
  3. 平台适配:针对H5和小程序等不同平台提供不同的处理逻辑
  4. 开发便利性:在H5开发环境中通过代理解决跨域问题

这种架构设计使得前端应用能够灵活地与多个后端服务进行交互,同时保持代码的清晰和可维护性。对于需要对接多个服务的复杂应用,这种方案提供了一个可靠的解决方案。

由Umi升级到Next方案

2025年4月15日 17:46

由Umi升级到Next方案

Umi Max 比较适合做中后台管理系统,尤其是结合 Ant Design Pro,用起来确实很方便,开发效率也挺高。但我们后来发现一个问题,Umi4 开始貌似已经不支持服务端渲染(SSR)了,这在做后台的时候没啥影响,但如果前台页面还用它,就很难做好 SEO 了,尤其是需要被搜索引擎收录的内容页面。

为了改善这个问题,我们决定把前端架构做一个分离:后台部分继续用 Umi Max,走纯 CSR 的方式,这样原有代码改动也不大;而用户端我们选用了 Next.js 来开发,利用它自带的 SSR 能力,提升首屏加载速度和 SEO 效果。

在这个重构过程中,确实遇到了很多坑,一方面是因为我们之前没接触过 Next.js,算是第一次上手就直接重构生产项目,很多东西一开始都是摸着文档走的。好在 Next.js 的文档还是挺不错的,绝大多数问题都能在里面找到答案:nextjs.org

请求库更换

在 Ant Design Pro(基于 Umi Max)中,默认封装的请求库是 umi-request,它对请求和响应做了一些统一处理,用起来也比较顺手。不过在迁移到 Next.js 后,我们决定不再继续用 umi-request,而是换成更常见、更灵活的 axios 来处理前后端通信。

这里有一个技巧,就是如果访问某个页面需要调用很多接口,这些接口又都没有登录,那么可能会多次弹出未登录的消息,想要只显示一个消息,可以设置一个上次弹出时间,保证再一定时间内只弹出一个消息。

 import axios, { AxiosRequestConfig } from "axios";
 import { message } from "antd";
 
 // 创建 Axios 示例
 const myAxios = axios.create({
   baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000/api",
   timeout100000,
   withCredentialstrue,
 });
 
 // 创建请求拦截器
 myAxios.interceptors.request.use(
   function (config) {
     return config;
  },
   function (error) {
     // 处理请求错误
     return Promise.reject(error);
  },
 );
 
 let lastErrorTime = 0// 上次弹出提示的时间
 
 // 创建响应拦截器
 myAxios.interceptors.response.use(
   function (response) {
     const data = response.data;
     const currentTime = Date.now();
     if (data.code === 11002) {
       if (currentTime - lastErrorTime > 2000) {
         message.error("未登录,请先登录");
         lastErrorTime = currentTime;
      }
       if (!localStorage.getItem("redirectUrl")) {
         // 如果 localStorage 中没有保存过 URL,则直接存储当前的 URL
         localStorage.setItem("redirectUrl"window.location.href); // 直接存储完整的当前 URL
      }
       setTimeout(() => {
         if (!localStorage.getItem("redirectUrl")) {
           localStorage.setItem("redirectUrl"window.location.href);
        }
         window.location.href = "/user/login";
      }, 1000); // 1秒后跳转
       return Promise.reject(data.message ?? "未登录");
    } else if (data.code !== 0) {
       return Promise.reject(data.message ?? "请求失败");
    }
     return data;
  },
   // 非 2xx 响应触发
   function (error) {
     // 处理响应错误
     return Promise.reject(error);
  },
 );
 
 export default async function request<T = any>(
   url: string,
   config?: AxiosRequestConfig,
 ): Promise<T> {
   return myAxios.request<any, T>({
     url,
     ...config,
  });
 }

这时候如果还需要在请求头里加上token,需要在请求的时候设置:

 myAxios.interceptors.request.use(
   function (config) {
     const token=xxx
     config.headers.token = token;
     return config;
  },
   function (error) {
     // 处理请求错误
     return Promise.reject(error);
  },
 );

如果这里的token从localStorage中去取出,那么这样会带来一个问题,Error: localStorage is not defined,这个报错是因为你在 服务端运行时环境中调用了 localStorage,而 localStorage浏览器(客户端)环境特有的 API,Node.js(服务端)中并不存在。

为什么会这样?

这个 Axios 实例是在模块加载时就创建并设置好了拦截器 —— 所以即使你最终只在客户端调用 request() 方法,这段代码也有可能在 SSR(服务端渲染)阶段运行,从而触发:

正确做法:

可以把拦截器中的 localStorage 读取逻辑包裹在一个运行时检查中,只在客户端环境执行:

 myAxios.interceptors.request.use(
 function (config) {
  if (typeof window !== "undefined") {
    const token = xxx
    if (token !== "") {
      config.headers.token = token;
    }
  }
  return config;
 },
 function (error) {
  return Promise.reject(error);
 },
 );
 

或者,把拦截器的注册逻辑放在 if (typeof window !== "undefined") 中,只在客户端注册拦截器,也就是我最终选择的方案

 
 if (typeof window !== "undefined") {
   // 创建请求拦截器
   myAxios.interceptors.request.use(
     function (config) {
       // 请求执行前执行
       const token = xxx
       config.headers.token = token;
       return config;
    },
     function (error) {
       // 处理请求错误
       return Promise.reject(error);
    },
  );
 }

状态管理更换

在 Umi Max 中,我们习惯使用 model 来做状态管理,不管是全局的还是页面级的,配合起来用非常方便,逻辑也比较集中。但到了 Next.js,默认是没有内建状态管理方案的,一开始我们也考虑过用 React 生态中比较经典的 Redux,不过用了一下之后发现实在太繁琐了,特别是我们这种状态不多的项目,搞一堆 action、reducer、dispatch,实在太重了,最后我们就放弃了这个方案。

后来我们选择了 zustand,它的 API 非常简单,语法也很现代化,基本就是一个函数搞定创建、读取和修改状态,写起来清爽很多。对于像侧边栏这种比较简单的全局 UI 状态,zustand 真的非常合适。

比如下面这个例子就是我们用 zustand 管理侧边栏伸缩状态的方式:

 import { create } from "zustand/react";
 
 const useSidebarStore = create((set) => {
   return {
     collapsedfalse,
     toggleCollapsed: () =>
       set((state) => {
         return { collapsed: !state.collapsed };
      }),
  };
 });
 
 export default useSidebarStore;

然后在组件中直接使用::

 const collapsed = useSidebarStore((state:any) => state.collapsed);
 const toggleCollapsed = useSidebarStore((state: any) => state.toggleCollapsed);

整套写下来非常简洁,不需要额外的 Provider 包裹,甚至可以和服务端渲染一起用(官方也支持),目前我们项目中已经逐步把 UI 相关的状态都切到 zustand 上了。

图片组件更换

在 Umi Max 中,我们主要用的是 Ant Design 提供的 Image 组件,功能也挺全的,比如加载占位图、预览大图这些在中后台场景里都挺实用的。

不过迁移到 Next.js 后,我们发现其实可以直接使用它自带的 next/image 组件。这个组件是专门为服务端渲染和性能优化做过处理的,像图片懒加载、自动压缩、响应式处理、格式优化(比如 WebP)这些都是开箱即用的,对首屏加载和整体性能提升挺有帮助。

当然,next/image 也有些限制,比如必须要配置域名白名单(不然加载外部图会报错),默认是用 layout 机制做图片自适应的,和普通 HTML 的 <img> 有点不一样,一开始用的时候可能需要适应一下。

但整体来说,既然我们前台是做 SSR 的,next/image 就是更合适的选择,尤其是在对性能和 SEO 有要求的场景里。

路由API更换

在 Umi 项目里,路由操作非常统一,基本上直接用 history 就能搞定,无论跳转页面还是获取当前路径都挺方便的。例如我们以前经常这样写:

 import { history } from '@umijs/max';
 
 history.push('/');

不过到了 Next.js,路由的用法就有些不同了,尤其是在区分服务端和客户端这块。Next.js 默认是支持服务端渲染的,所以如果要在客户端使用路由相关的 hooks,比如跳转或者获取 query 参数,组件必须是客户端组件,也就是文件或模块顶部要加上 'use client' 声明。

获取查询参数

在客户端组件中,可以使用 useSearchParams 来获取 URL 上的查询参数,写法大概是这样:

 'use client';
 import { useSearchParams } from 'next/navigation';
 
 export default function MyClientComponent() {
   const searchParams = useSearchParams();
   const code = searchParams.get('code');
 
   return <p>Code from URL: {code}</p>;
 }

需要注意的是,这个 hook 只能在客户端用,如果你写在服务端组件里是会报错的。

路由跳转

如果要在客户端做路由跳转,使用的是 useRouter() 这个 hook:

 'use client';
 import { useRouter } from 'next/navigation';
 
 const router = useRouter();
 router.push('/');

这就有点类似于以前的 history.push(),但同样地,必须放在客户端组件里用才行。

客户端跳转的方式:next/link

除了用 router.push 手动跳转,Next.js 推荐在页面跳转上使用 next/link 组件。它能自动优化跳转行为,比如预加载目标页面等等:

 import Link from 'next/link';
 
 <Link href="/about">Go to About</Link>

这个方式更适合用于 JSX 里的跳转按钮、菜单、Tab 等场景,性能也更好一些。

总的来说,Next.js 在路由这块还是比较现代的,只是服务端和客户端的概念得先搞清楚,不然一不小心就会遇到 “hook 只能在客户端用” 的报错 😅。

适应 Next.js 的目录结构

在使用 Umi Max 的时候,我们是手动在一个route.ts中配置路由和对应的组件配置。在 Next.js 的 app 目录中,目录结构 就是路由结构,每一个文件夹就代表一个路由路径:

  • app/page.tsx/ 路由
  • app/about/page.tsx/about 路由
  • app/blog/[slug]/page.tsx → 动态路由,例如 /blog/123/blog/hello-world

每个文件夹下面可以包含多个特殊的文件,最常用的是:

  • page.tsx:这个就是对应路由的页面组件;
  • layout.tsx:用来包裹当前路由以及子路由的布局,适合放 Header、Sidebar、Footer 等公共区域;
  • loading.tsx:用于当前路由懒加载时的 Loading 状态展示;
  • error.tsx:这个页面的报错处理;
  • template.tsx:与 layout 类似,但每次进入都会重新渲染,不会缓存。

动态路由

Next.js 使用方括号 [slug] 的形式来定义动态路由,举个例子:

 app/blog/[slug]/page.tsx

这就代表了一个动态路径,比如:

  • /blog/a
  • /blog/nextjs-routing
  • /blog/123

page.tsx 文件里,我们可以通过 params 来获取这个动态值,例如:

 export default function BlogDetail({ params }: { params: { slug: string } }) {
   return <div>当前访问的博客 slug 是:{params.slug}</div>;
 }

这样就不需要额外定义路由表了,所有的路由都可以通过目录结构直接体现出来,简洁而清晰。

多级嵌套路由和布局继承

一个很强大的地方是,Next.js 的 layout.tsx 是可以嵌套的。比如你有一个后台管理页面 /admin,你可以这样组织:

 app/
  admin/
    layout.tsx   // 后台通用布局
    users/
      page.tsx   // /admin/users 页面
    settings/
      page.tsx   // /admin/settings 页面

这样所有 /admin/* 路由下的页面都会自动套上 admin/layout.tsx 的布局,而且嵌套非常灵活,不需要像以前那样手动包一堆 Layout 组件。


Next.js 的 app/ 路由模式一开始可能不太直观,但用习惯了以后真的非常爽,目录就是路由,配合 layout 还能优雅地解决嵌套、公共区域复用的问题。

具体文档可以参考这里 👉 nextjs.org/docs/app/ge…

Docker部署

需要部署到测试服务器,生产服务器两种环境,next提供了dev,test,prod三种环境,但是感觉更换环境配置异常繁琐,在stackoverflow找到了一个比较简单暴力的方式:

stackoverflow.com/questions/5…

nextjs会优先使用.env.local中的配置,那么我们可以在build的时候,把.dev.test,.prod的配置文件复制一份到.env.local,从而实现多环境配置:

     "build:dev": "cp .dev .env.local && next build && npm run afterbuild",
     "build:test": "cp .test .env.local && next build && npm run afterbuild",
     "build:prod": "cp .prod .env.local&& next build && npm run afterbuild",

nextjs构建完成之后还需要拷贝一些文件:

     "afterbuild": "cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/",

我这里使用的是standalone模式,可以在next.config.mjs中进行配置:

nextjs.org/docs/app/ap…

 const nextConfig = {
     eslint: {
         dirs: ['src'],
    },
     reactStrictMode: false,
     typescript: {
         ignoreBuildErrors: true,
    },
     output: 'standalone',
     swcMinify: true,
     // 简单的webpack配置,避免Monaco Editor工作器加载问题
     webpack: (config) => {
         config.resolve.fallback = {
             ...config.resolve.fallback,
             fs: false,
             path: false,
             os: false
        };
         return config;
    }
 };
 export default nextConfig;

Docker构建脚本:

 FROM node:20.19.0-slim
 WORKDIR /app
 COPY .next/standalone ./
 # 暴露端口
 EXPOSE 3000
 CMD ["node", "server.js"]

之后构建镜像:

 docker build -t xxx:latest .

构建部署会遇到很多问题,如下所示:

问题1

  ⨯ The requested resource isn't a valid image for /campus/image/2025/02/1739962254476-微信截图_20250219185026.png received text/html; charset=utf-8
  ⨯ Error: 'sharp' is required to be installed in standalone mode for the image optimization to function correctly. Read more at: https://nextjs.org/docs/messages/sharp-missing-in-production
 ^C%     

解决办法:按照sharp

 npm i sharp

问题2

 Error occurred prerendering page "/tools/mindmap". Read more: https://nextjs.org/docs/messages/prerender-error
 
 ReferenceError: document is not defined

解决办法

这种就属于:

5. Disable server-side rendering for components using browser APIs

If a component relies on browser-only APIs like window, you can disable server-side rendering for that component:

问题3

  ⨯ useSearchParams() should be wrapped in a suspense boundary at page "/user/login". Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout

Reading search parameters through useSearchParams() without a Suspense boundary will opt the entire page into client-side rendering. This could cause your page to be blank until the client-side JavaScript has loaded.

参考地址:

就是要我们使用Suspense组件把需要用到useSearchParams的地方包起来,同时,尽可能力度小

 <Suspense fallback={<div>Loading...</div>}>
 </Suspense>

问题4

 Error occurred prerendering page "/tools/coderunning"Read morehttps://nextjs.org/docs/messages/prerender-error
 ReferenceErrorwindow is not defined

nextjs.org/docs/messag…

 "use client"
 import React from 'react';
 import Editor, {loader} from '@monaco-editor/react';
 import * as monaco from 'monaco-editor';
 
 const MonacoEditor = (props) => {
   loader.config({monaco})
   return <Editor {...props}  />;
 };
 
 export default MonacoEditor;

这个组件是需要客户端API的,所以可以使用动态导入:

 import dynamic from "next/dynamic";
 
 const DynamicComponentWithNoSSR = dynamic(() => import("../MonacoEditor"), {
   ssrfalse,
 });
 
 const MonacoEditorNoSSR = (props) => {
   return <DynamicComponentWithNoSSR {...props} />;
 };
 
 export default MonacoEditorNoSSR;

其他问题

使用最新版的next之后,发现tailwindcss不提示了,IDEA和Vscode都是,发现新版的少了文件tailwind.config.js,可以在根目录里加一个文件,这样就可以有提示了。

 /** @type {import('tailwindcss').Config} */
 //todo 这个文件只是为了让IDEA可以有代码提示,最新版的tailwindcss已经不需要这个文件了
 module.exports = {
     content: ["./src/**/*.{html,js,ts,jsx,tsx}"],
     theme: {
         extend: {},
    },
     plugins: [],
 }

参考资料

一次构建多处部署 - Next.js Runtime Env

Prerender Error with Next.js

Missing Suspense boundary with useSearchParams

如何优雅地部署一个 Next.js 应用

nextjs.org/docs/app/ap…

常用 Git 命令详解

作者 Danny_FD
2025年4月15日 17:44

在进行协同开发时,Git 是一个不可或缺的工具。它帮助开发者们管理代码变更、协作开发以及处理问题。以下是一些常用的 Git 命令,按照提交代码或处理问题时的使用顺序给出。

1. 克隆仓库

在开始工作之前,首先需要从远程仓库克隆一份代码到本地。

git clone <远程仓库地址>

2. 检查状态

在进行任何操作之前,先检查一下工作目录和暂存区的状态。

git status

3. 添加文件到暂存区

当修改或新增文件后,需要将这些文件添加到暂存区。

git add <文件名>

或者添加所有修改和新增的文件:

git add .

4. 提交更改

将暂存区的更改提交到本地仓库,并添加提交信息。

git commit -m "提交信息"

5. 拉取最新代码

在提交之前,先从远程仓库拉取最新的代码,避免冲突。

git pull origin <分支名>

6. 解决冲突(如果没有请跳过)

如果在拉取过程中出现冲突,需要手动解决冲突。解决后,再次添加并提交。

git add <冲突文件>
git commit

7. 推送代码

将本地提交的代码推送到远程仓库。

git push origin <分支名>

8. 创建分支

在进行新功能开发或修复问题时,通常需要创建一个新的分支。

git branch <分支名>

9. 切换分支

切换到刚刚创建的分支。

git checkout <分支名>

或者同时创建并切换到新分支:

git checkout -b <分支名>

10. 合并分支

当分支开发完成后,需要将分支合并到主分支(通常是 mainmaster)。

git checkout main
git merge <分支名>

11. 删除分支

合并完成后,可以删除已经合并的分支。

git branch -d <分支名>

12. 查看提交历史

查看提交历史,了解代码的变更记录。

git log

13. 撤销更改

如果需要撤销某次提交,可以使用以下命令:

git revert <提交ID>

14. 重置工作区

如果需要重置工作区到某个提交点,可以使用以下命令:

git reset --hard <提交ID>

15. 标签管理

为重要的提交点打标签,方便后续查找和回滚。

git tag <标签名>
git push origin <标签名>

总结

以上是协同开发中常用的 Git 命令,掌握了这些命令,可以更高效地进行代码管理和协作开发。当然,Git 的功能远不止这些,随着使用的深入,你还会发现更多强大的功能。

JavaScript 中的 ?? 与 || 运算符详解

作者 卫崽
2025年4月15日 17:34

在 JavaScript 开发中,我们经常需要处理默认值或回退值的情况。两个常用的运算符是空值合并运算符(??)和逻辑或运算符(||)。虽然它们看起来功能相似,但实际应用场景却大不相同。

逻辑或运算符 ||

|| 运算符是 JavaScript 中的传统选择,它会返回第一个"真值",如果左侧操作数为假值(如 false、0、""、null、undefined 或 NaN),则返回右侧操作数。

const name = userName || "默认用户";

问题是什么?当我们明确想使用 0 或空字符串 "" 时,|| 会跳过这些值,因为它们被视为假值。

空值合并运算符 ??

ES2020 引入的 ?? 运算符更加精确,它只在左侧操作数为 null 或 undefined 时才返回右侧操作数。

const count = userCount ?? 0;

这意味着空字符串和数字 0 会被保留,而不会被默认值替代。

实际对比案例

// 使用 ||
0 || 10           // 返回 10
"" || "默认文本"   // 返回 "默认文本"
false || true     // 返回 true

// 使用 ??
0 ?? 10           // 返回 0
"" ?? "默认文本"   // 返回 ""
false ?? true     // 返回 false
null ?? "默认值"   // 返回 "默认值"
undefined ?? 42   // 返回 42

如何选择?

  • 当你只想在值为 null 或 undefined 时使用默认值,选择 ??

  • 当你想在任何假值情况下使用默认值,选择 ||

实际应用场景

表单处理时,空字符串可能是有效输入:

// 更好的选择
const userInput = formValue ?? "默认消息";

// 可能有问题的选择
const userInput = formValue || "默认消息"; // 空字符串会被替换

计数场景中,0 是有效值:

// 更好的选择
const displayCount = count ?? "无数据";

// 可能有问题的选择
const displayCount = count || "无数据"; // 0 会被替换为"无数据"

结语

?? 和 || 各有用处,关键是了解它们处理假值的差异。?? 只关心 null 和 undefined,而 || 会检查所有假值。根据具体需求选择合适的运算符,可以让代码更精确、更易于维护。希望这篇文章能帮助你更好地理解并运用这两个运算符!

Webpack 打包未使用组件的原因

作者 糯糯机器
2025年4月15日 17:30

在项目开发过程中,近期对项目进行了双页面配置,针对两个不同页面分别单独配置了路由。然而,在单独启动新页面(该页面模块数量较少)时,却发现启动耗时较长。经过深入排查,最终确定是以下这行代码导致了该问题:

return () => import(`@/pages/PRD/views/${realPath}`);

Webpack 在遇到import()语法时,会依据给定路径将所有符合条件的模块进行打包。由于上述导入代码的最后一个部分是变量,对于 Webpack 而言,这等同于@/pages/PRD/views/**/*,因此 Webpack 会将 views 目录下的所有模块及其相关依赖一同打包。

为深入探究 Webpack 对import的处理机制,我创建了一个项目,并针对不同情况进行打包测试,最终得出以下结论:

// `import`的参数不能是完全动态的语句,例如`import(util)`是不被允许的,至少需要包含部分模块路径信息,以便Webpack确定打包范围。
const component = () => {
  const path = "xxx";
  return import(path); 
}
// 这种导入方式会将xxx目录下的所有组件和js文件都进行打包。
const componentB = (name) => {
  const component = "component" + name;
  return import(`./xxx/${component}`); 
}
// 这种导入方式会将xxx目录下以component开头的文件进行打包。
// 如果xxx目录下存在/component*/这样的文件夹,那么文件夹内的所有组件都会被打包。
const componentC = (name) => {
  const component = 
  return import(`./xxx/component${name}`); 
}
// 较为合适的动态引入方式
const componentD = (name) => {
  const component = 
  return import(`./xxx/component${name}.vue`); 
}

Webpack 解析 import 注释指令

在 Webpack 中,import()函数的注释指令是一种特殊语法,用于控制 Webpack 对动态导入模块的处理方式,主要包含以下几种:

webpackChunkName

  • 功能:该指令用于手动指定 Webpack 拆分代码时生成的代码块(chunk)名称。合理命名代码块,有助于在打包结果中清晰区分不同功能模块的代码块,对缓存管理、调试以及性能分析等工作都非常有利。
  • 示例

    import(/* webpackChunkName: "user - module - chunk" */ './userModule.js');
    

    在上述代码中,Webpack 会将./userModule.js及其依赖模块打包到名为user - module - chunk的代码块中。从 Webpack 2.6.0 版本开始,还支持使用占位符,例如(index)会使代码块名称以递增数字进行命名,(request)则会使用实际解析的文件名。示例如下:

    // 假设导入多个模块,会生成类似userModule1、userModule2等命名的代码块
    import(/* webpackChunkName: "userModule(index)" */ './userModule.js'); 
    

webpackMode

  • 功能:该指令用于指定 Webpack 解析动态导入的模式。在不同模式下,Webpack 对动态导入模块的处理逻辑以及生成代码块的方式会有所不同。
  • 模式类型及示例
  • lazy:这是默认模式。在这种模式下,Webpack 会为每个import()调用单独生成可延迟加载的代码块。也就是说,每次遇到import(),Webpack 都会创建一个新的独立代码块,在运行时按需加载。例如:

    import(/* webpackMode: "lazy" */ './module1.js');
    import(/* webpackMode: "lazy" */ './module2.js');
    

    上述代码会分别生成两个独立的代码块,用于加载module1.js和module2.js。

  • lazy - once:此模式下,Webpack 会生成单个可延迟加载的代码块,该代码块能够满足多个import()调用的需求。适用于一些动态导入语句,比如import(./locales/${language}.json),可能会根据不同的language值请求多个模块路径的情况。首次调用import()时获取代码块,后续调用则复用该代码块。示例如下:

    // 多个动态导入可能共用一个代码块
    import(/* webpackMode: "lazy - once" */ `./locales/${language}.json`); 
    import(/* webpackMode: "lazy - once" */ `./locales/${anotherLanguage}.json`); 
    
  • eager:在该模式下,Webpack 不会生成额外的代码块,而是将被导入的模块直接引入当前代码块,并返回已解析状态的Promise。与静态导入不同的是,在import()调用完成前,模块不会执行。例如:

    import(/* webpackMode: "eager" */ './eagerModule.js').then((module) => {
      module.someFunction();
    });
    

js封装系列(一)

作者 wsz7777
2025年4月15日 17:05

js封装系列(一)

适用场景

封装 sdk 场景下,在 sdk 中对公共对象以及方法进行 proxy 操作。

团队 A 有 A-sdk 对一个全局对象进行了改写 proxy 操作。此时,团队 B 开发了 B-sdk 对相同的全局对象的方法也进行了对应的改写。

出现问题

在一个项目中同时使用了 A-sdk 和 B-sdk,该项目研发同学在测试时发现 A 团队提供的 A-sdk 的对应功能失效了。

问题复现

创建原型

创建一个 base 工厂函数并配置对应的原型链方法 x

// 原型
function base(){};
// 创建原型链方法
base.prototype.x = function(){
    return "base";
}

// 实例化
const baseEg = new base();

第一次劫持

模拟 A-sdk 劫持 x 方法,并添加一些业务逻辑

const getX = baseEg.x;
baseEg.x = function(){
    return `proxy-${getX()}`;
}
baseEg.x(); // 输出 "proxy-base" 证明劫持成功

第二次劫持

模拟 B-sdk 劫持 x 方法,并添加 B 团队的业务逻辑

方案1
// func1 实现
const func1GetX = baseEg.x;
baseEg.x = function() {
    return `func1-${func1GetX()}`;
}
baseEg.x();
// 输出 "func1-proxy-base"
方案2:
// func2 实现
const func2GetX = Object.getPrototypeOf(baseEg).x;
baseEg.x = function() {
    return `func2-${func2GetX()}`;
}
baseEg.x(); // 输出 "func2-base"

综合对比

方案1 方案2
思路 基于当前对象上的某个方法进行封装 基于当前对象原型链上的某个方法进行封装
输出 "func1-proxy-base" "func2-base"
结论 不会破坏其他库对该方法的封装 基于原型链方法进行封装会破坏其他库对

至此问题已浮出水面。

警示

在封装sdk过程中:

  1. 尽量不修改全局对象(遵循如无必要勿增实体的原则);
  2. 不要覆盖存在于原型链上的方法(上面方案2的做法);
  3. 提醒用户检查其他sdk是不是有类似的操作(上面方案2的做法);

【Electron】macOS发布第二个应用(签名+公正部分)

2025年4月15日 17:03

背景

已经在自己mac电脑上做过一个客户端应用,并且成功签名、公正。 现在要开发第二个应用,需要对第二个应用进行签名和公正。 以此记录,方便备查。 若想查看前期的准备详情及操作步骤,请参考

【Electron】macOS - electron-egg应用如何做公证(notarization)本篇主要介绍ele - 掘金

前期准备

1 申请开发者账号

image.png

2 创建 Developer ID 证书。

  • 此处需要注意,如果不仅仅是签名APP,还想签名安装包的话,需要两个证书(Developer ID Application/Developer ID Installer)。在创建证书时,需要注意勾选必要的扩展服务。

image.png

本次操作

1 新建appId.

  • 每一个appId对应着一个应用,多个应用的appId必须不一样。自动升级或者版本查询时appId核心是关键信息。
  • 有几个应用就必须有几个appId.
  • -生成的这个id就是你应用的appId。要注意打包应用后不能随意更改新的appId字段。否则会被认为是两个不同的应用而无法更新。下图中的id就是你生成的id,这两个要一样! image.png

2. 新建app专用密码。为你的账号生成一个特定于应用程序的密码。点击查看生成App专用密码的方法教程。 生密码成界面如图所示

如何生成 App 专用密码
  1. 在 account.apple.com 上,登录你的 Apple 账户。
  2. 在“登录和安全”部分中,选择“App 专用密码”。
  3. 选择“生成 App 专用密码”,然后按照屏幕上的步骤操作。

3 代码中配置参数

1 build.json中appId,copyright等。

2 build/notarization/notarize.js中关于公正的参数:
const result = await notarize({

appBundleId: "com.xxxxxxxx.xxxxxx",

appPath: `${appOutDir}/${appName}.app`, //打包后的放置app文件的命名和路径【固定写法】

appleId: "开发者账户",

appleIdPassword: "xxxx-xxxx-xxxx-xxxx", // 申请的app专用密码

ascProvider: "开发组Id(teamId)",

tool: "notarytool", // 公证工具 固定写法

teamId: "开发组Id(teamId)",

});

4 代码打包

npm run ce-build-arm64-xxxx

JavaScript 开发必备规范:命名、语法与代码结构指南

2025年4月15日 17:01

在 JavaScript 开发中,遵循良好的编程规范对于构建高效、可维护的代码至关重要。它不仅能提升代码的可读性,让团队成员之间更容易理解和协作,还能减少错误的发生,提高开发效率。本文将详细介绍 JavaScript 编程中的一些重要规范。

一、命名规范

变量和函数命名

采用小驼峰命名法,第一个单词首字母小写,后续单词首字母大写。例如firstName用于表示名字变量,getUserName函数用于获取用户名。这种命名方式能够清晰地区分变量和函数,并且让名称具有语义化,便于理解其用途。避免使用单字母或无意义的命名,如ab等,除非在特定的循环等场景下有约定俗成的用法。

常量命名

常量通常使用全大写字母,单词之间用下划线分隔,比如MAX_COUNT表示最大计数,API_URL表示 API 的链接地址。这样的命名方式能够直观地让开发者知道该变量是一个常量,其值在程序运行过程中不会改变。

二、语法规范

使用严格模式

在脚本或函数的开头添加'use strict';开启严格模式。严格模式下,JavaScript 会进行更严格的语法检查,比如禁止使用未声明的变量,防止意外创建全局变量等常见错误。它有助于开发者养成良好的编程习惯,提高代码的质量和稳定性。

// 严格模式
function strictWithExample() {
    'use strict';
    var obj = { x: 1 };
    // 抛出 SyntaxError
    with (obj) {
        console.log(x);
    }
}
strictWithExample();

语句结束加分号

尽管 JavaScript 在某些情况下可以省略分号,但为了避免潜在的错误和代码歧义,强烈建议在每条语句结束后都加上分号。

例如let num = 5let num = 5;,前者在一些复杂的代码结构中可能会因为自动分号插入机制而出现意想不到的问题,而后者则明确地表示了语句的结束。

let num = 5
console.log(num)
[1, 2, 3].forEach(function (element) {
    console.log(element);
});

在上述代码中,let num = 5 后面没有分号,由于 [ 是 JavaScript 中的数组字面量符号,同时也可以用于数组的索引访问操作(例如 arr[0]),所以引擎会认为你可能想要对 num 进行某种与数组相关的操作,比如 num[1, 2, 3](虽然这在语法上是错误的,因为 num 是一个数字,不是数组)。

代码缩进

统一使用 2 个或 4 个空格进行缩进,这能让代码的层次结构一目了然。比如在嵌套的if - else语句、循环语句等结构中,合理的缩进能清晰地展示代码的逻辑关系,使代码更易于阅读和维护。

代码块使用大括号

即使代码块中只有一条语句,也建议使用大括号括起来。例如:

if (condition) {
    doSomething();
}

这样在后续需要添加更多语句到代码块中时,能避免因遗漏大括号而导致的语法错误。

三、比较操作规范

尽量使用===!==进行比较操作,避免使用==!=。因为==!=在比较时会进行类型转换,这可能会带来意外结果。例如'5' == 5会返回true,而'5' === 5会返回false,在实际开发中,明确知道数据类型并使用全等操作符能减少错误的发生。

四、代码结构规范

避免全局变量污染

在 JavaScript 开发中,尤其是构建大型项目时,全局变量带来的问题不容小觑。全局变量如同在公共空间随意摆放的物品,极易引发混乱。在一个复杂项目中,可能有多个开发人员同时工作,不同模块的代码相互交织。如果每个模块都随意创建全局变量,很容易出现命名冲突。

  • 例如,一个模块定义了全局变量count用于记录某个操作的次数,另一个模块可能也需要使用count变量来记录其他信息,这就会导致变量值被意外覆盖,引发难以排查的错误。

同时,在大型项目中,代码的维护和调试本身就具有挑战性。全局变量的存在会使问题变得更加棘手。因为全局变量在整个程序的生命周期内都存在,其值可能在程序的任何地方被修改。当出现错误时,开发人员很难确定是哪个部分的代码对全局变量进行了不恰当的修改,增加了调试的难度和时间成本。

模块化

为了解决这些问题,模块化是一种非常有效的手段。通过将相关的功能代码封装在一个模块中,每个模块都有自己独立的作用域。在 JavaScript 中,ES6 引入了模块系统,使用exportimport关键字来管理模块的导出和导入。例如,有一个处理用户数据的模块userModule.js

// userModule.js
const userData = {
    name: '',
    age: 0
};

function setUserName(name) {
    userData.name = name;
}

function getUserName() {
    return userData.name;
}

export { setUserName, getUserName };

在这个模块中,userDatasetUserNamegetUserName函数都在模块内部作用域中,外部无法直接访问userData。只有通过导出的setUserNamegetUserName函数,其他模块才能间接操作userData。在其他模块中使用时,可以这样导入:

// main.js
import { setUserName, getUserName } from './userModule.js';

setUserName('John');
console.log(getUserName()); 

这样就有效地避免了全局变量的使用,降低了命名冲突的风险,同时也使得代码的结构更加清晰,易于维护和调试。

立即执行函数表达式(IIFE)

另一种方式是使用立即执行函数表达式(IIFE)。在 JavaScript 中,通过将函数定义包裹在括号中,并紧接着在后面加上括号进行调用,便形成了一个 IIFE。IIFE 能够创建一个独立的函数作用域,在该作用域内定义的变量和函数均为私有。这就确保了函数内部的变量和函数不会被外部随意访问和修改 。例如:

const app = (function () {
    let privateVariable = 10;

    function privateFunction() {
        console.log('This is a private function.');
    }

    return {
        publicFunction: function () {
            privateFunction();
            console.log('The value of private variable is:', privateVariable);
        }
    };
})();

app.publicFunction(); 

在上述代码中,

  • (function () {... })():在包裹匿名函数的括号后面再添加一对括号(),这对括号用于立即调用前面定义的匿名函数。当 JavaScript 引擎执行到这部分代码时,就会立即调用这个匿名函数,所以称为 “立即执行函数”。
  • privateVariableprivateFunction都在 IIFE 内部的私有作用域中,因此外部无法直接访问它们。通过返回一个包含publicFunction的对象,向外暴露了一个公共接口,这样一来既实现了功能,又避免了全局变量污染。

合理使用注释

在关键代码逻辑处添加注释,解释代码的功能、用途、算法思路等。注释要简洁准确,避免过度注释。

  • 例如在一个复杂的算法函数前,可以注释说明该算法的作用、输入参数和返回值的含义,方便其他开发者理解代码。
  • 但不要在过于简单的代码上添加冗余注释,如let num = 1; // 定义一个数字变量,这样的注释对理解代码没有实质性帮助。

五、注释规范

注释分为单行注释和多行注释。单行注释使用//,用于对某一行代码进行简单解释。多行注释使用/* */,适合对一段代码块进行详细说明。在写注释时,要确保注释与代码同步更新,避免代码修改后注释不再准确的情况。

六、异步编程规范

随着 JavaScript 在前端和后端开发中的广泛应用,异步编程变得越来越重要。使用async/await语法可以让异步代码看起来更像同步代码,提高代码的可读性。例如:

async function getData() {
    try {
        let response = await fetch('https://example.com/api');
        let data = await response.json();
        return data;
    } catch (error) {
        console.error('获取数据失败', error);
    }
}

在处理多个异步操作时,要注意合理控制并发数量,避免因过多并发请求导致性能问题。

七、代码格式化规范

使用代码格式化工具,如 Prettier、ESLint 等,能够自动按照设定的规则对代码进行格式化。它可以统一代码风格,包括缩进、空格、换行等,使团队成员的代码风格保持一致,减少因风格差异带来的冲突和阅读障碍。

八、代码复用

尽量编写可复用的代码,通过函数封装、模块封装等方式,将重复使用的代码逻辑提取出来。

  • 例如,在多个地方需要对数据进行格式化处理,可以编写一个通用的数据格式化函数,在需要的地方调用,这样不仅能减少代码量,还方便维护和修改。

九、错误处理

在代码中要合理处理错误,使用try - catch块捕获可能出现的异常。对于异步操作,也要通过try - catch或者.catch方法来处理错误。

  • 例如在网络请求失败时,要及时向用户反馈错误信息,而不是让程序崩溃。同时,可以自定义错误类型,以便在不同的业务场景下进行更精准的错误处理。

遵循这些 JavaScript 编程规范,能够帮助开发者写出更整洁、高效、易于维护的代码。在实际开发中,团队可以根据项目需求进一步细化和完善这些规范,以提升整个项目的质量。

GitHub action中的 jq 是什么? 常用方法有哪些

作者 1024小神
2025年4月15日 16:58

jq 是一个轻量且强大的命令行工具,用于解析、过滤、修改、以及格式化 JSON 数据。它在 GitHub Actions 中非常常用,因为很多配置文件(比如 package.json)都是 JSON 格式的。

在 GitHub Actions 的 Ubuntu Runner(比如 ubuntu-latest)中,jq预装的,你可以直接使用它。


🧩 常用 jq 方法和语法

假设我们有一个简单的 package.json

{
  "name": "my-app",
  "version": "1.2.3",
  "scripts": {
    "build": "vite build"
  },
  "dependencies": {
    "vue": "^3.2.0"
  }
}

📌 1. 读取某个字段

jq .version package.json

输出(带引号):

"1.2.3"

如果你只想要值,不要引号:

jq -r .version package.json

输出:

1.2.3

📌 2. 读取嵌套字段

jq -r .scripts.build package.json

输出:

vite build

📌 3. 获取多个字段

jq -r '{name: .name, version: .version}' package.json

输出:

{
  "name": "my-app",
  "version": "1.2.3"
}

📌 4. 格式化输出(美化 JSON)

jq . package.json

会美化输出整个 package.json 内容。

📌 5. 把某个字段赋值给变量(在 GitHub Actions 中)

VERSION=$(jq -r .version package.json)

然后就可以用 $VERSION 了。


🛠️ GitHub Actions 中的实战小片段

- name: Get version from package.json
  id: get_version
  run: echo "VERSION=$(jq -r .version package.json)" >> $GITHUB_ENV

- name: Use version
  run: echo "Package version is $VERSION"

Yarn Workspaces 和 Lerna 在 Monorepo 中的依赖管理

2025年4月15日 16:55

仅个人遇见问题记录

在 Monorepo 项目中,Yarn Workspaces 和 Lerna 通常一起使用来简化多包管理,统一版本控制和依赖管理。本文将详细分析在使用这些工具时常见的问题,包括如何正确配置工作区、如何使用本地库、如何避免依赖冲突,以及如何强制 Yarn 使用特定版本的依赖。

1. Yarn Workspaces 和 Lerna 基本配置

配置 lerna.json

首先,我们需要配置 Lerna 来管理 Monorepo 中的多个包,并控制版本和发布策略。lerna.json 是 Lerna 的主要配置文件,必须包括一个 packages 字段来指定所有包的位置。

例如,假设你的项目结构如下:

root/ packages/ dva/ utils/ examples/ test-fn/ package.json lerna.json

你的 lerna.json 文件应该如下配置:

{
  "packages": [
    "packages/*",
    "examples/*"
  ],
  "version": "independent",  // 每个包独立管理版本
  "npmClient": "yarn",  // 使用 Yarn 作为包管理工具
  "command": {
    "version": {
      "exact": true  // 发布版本时,版本号保持一致
    }
  }
}

配置根目录的 package.json

在根目录的 package.json 中,我们需要设置 workspaces 字段来告诉 Yarn 这是一个使用 Workspaces 的项目,并指定哪些文件夹是工作区。

{
  "private": true,  // 确保根目录不是一个独立的包
  "workspaces": [
    "packages/*",  // 工作区目录
    "examples/*"
  ]
}

根目录的 package.json 配置了 workspaces,这将使得 Yarn 知道它需要管理工作区中的所有依赖。

2. 使用 workspace:* 语法引用本地包

Yarn Workspaces 的核心优势之一是它可以让你在 Monorepo 中的不同包之间共享依赖。在某个工作区中,你可以通过 workspace:* 语法来引用其他工作区的包。

例如,在 test-fnpackage.json 中:

{
  "dependencies": {
    "dva": "workspace:*"
  }
}

这样做的目的是让 test-fn 使用 packages/dva 目录中的 dva 包,而无需手动指定路径。Yarn 会自动将依赖解析为工作区中的包。

问题:

  • Yarn 忽略本地的 beta 版本,选择线上版本。如果你的 dva 包是一个 beta 版本(如 2.6.0-beta.22),但在根目录中已经安装了线上稳定版本,Yarn 可能会忽略本地的 beta 版本,直接选择稳定的线上版本。

解决方案:

  1. 确保本地版本优先:如果你希望强制使用本地的 beta 版本,可以在根目录的 package.json 中使用 resolutions 来锁定本地版本:

    "resolutions": {
      "dva": "2.6.0-beta.22"
    }
    
  2. 显式指定版本:在 test-fnpackage.json 中,显式指定本地的 beta 版本,避免 Yarn 自动选择稳定的线上版本:

    "dva": "workspace:2.6.0-beta.22"
    
  3. 清理和重新安装依赖:在遇到 Yarn 安装冲突或版本选择问题时,删除现有的依赖并重新安装:

    rm -rf node_modules
    rm -f yarn.lock
    yarn install
    

3. 工作区依赖无法正确解析

test-fn 中明确声明了依赖,对于一些外部依赖可能仍然会遇到依赖无法正确解析的问题。如可能在工作区中无法找到babel

问题:

  • 在根目录的 node_modules 中已正确安装,但在工作区中找不到依赖。

解决方案:

  1. 确保依赖声明完整:即使某个依赖在根目录已经安装,工作区中的 package.json 仍需要显式声明依赖:

    {
      "dependencies": {
        "babel-runtime": "^6.9.2"
      }
    }
    
  2. 使用 yarn list 检查依赖:查看是否所有依赖都已安装,并且版本一致:

    yarn list babel
    
  3. 清理并重新安装依赖:确保依赖正确安装并解析:

    rm -rf node_modules
    rm -f yarn.lock
    yarn install
    

4. 依赖提升问题

在 Monorepo 中,Yarn 会根据需要将一些依赖提升到根目录的 node_modules 中。这通常会导致在某些工作区中找不到本地依赖。

问题:

  • 某些依赖被提升到根目录,导致工作区中找不到这些依赖。

解决方案:

  1. 避免依赖提升:使用 nohoist 配置来避免依赖提升,确保每个工作区都有自己的依赖副本。

    "nohoist": [
      "**/*"
    ]
    
  2. 检查依赖树:通过 yarn list 查看依赖树,确保所有工作区都可以访问到所需的依赖。

sv-print可视化打印组件不完全指南⑤

作者 不简说
2025年4月15日 16:50

上期探讨了sv-print简单的插件机制,以及实现了参数的重写,项目是TS创建的,参数的写法就显得那么格格不入了。所以本期探索探索 用ES6的写法来重构它,让后续编写更加的方便。

前言

如果你对 ES6 或者 原型链不熟悉。那么本期,你将有所收获,本着授人以鱼不如授人以渔的思想。 本篇从调试分析,到实战落地

调试分析

上篇说的,它几个核心的方法: createTarget、getValue、setValue、destroy 以及 可选的 css。

见下方代码,看着就难受😢

const fieldOptionFun = (configs: any) => {
  return function () {
    function t() {
      this.name = "field";
    }
    return (
      (t.prototype.createTarget = function (t, i, e) {
      }),
      (t.prototype.getValue = function () {
      }),
      (t.prototype.setValue = function (t: any) {
      }),
      (t.prototype.destroy = function () {
      }),
      t
    );
  }
};

看不懂先别慌,发个朋友圈先(console.log)

const testFun = filedOption();
const test = new testFun();
console.log(test);

思考🤔: 为什么要这样写? 咱们可以复制的函数,让 AI 解释一下。

亮相参数对象:

ES6改写

为了继承,为了简洁。

新增一个基类:baseOption.ts

export interface BaseOption {
  /**
   * 参数名称
   */
  name: string;
  /**
   * 会修改 DOM 样式的属性才需要: eg: color, backgroundColor
   * @param printElement 元素对象
   * @param value 该属性值
   */
  css?(printElement: HTMLElement, value: string | number | boolean): void;
  /**
   * 创建参数DOM
   * @param printElement 元素对象
   * @param options 元素参数
   * @param printElementType 元素类型对象
   */
  createTarget(printElement: any, options: any, printElementType: any): HTMLElement;

  getValue(): string | number | boolean;

  setValue(value: string | number | boolean): void;

  destroy(): void;
}

重新实现fieldOption

新建一个 fieldOption.ts

export class FieldOption implements BaseOption {
  public name: string = "field";
  isSelect: boolean = false;
  target: any = null;
  vueApp: any = null;
  vueAppIns: any = null;
  configs: any = {};

  constructor(configs: any) {
    this.configs = configs;
  }

  createTarget(printElement: any, options: any, printElementType: any): HTMLElement {
    const fileds = printElement.getFields();
    this.isSelect = fileds && fileds.length > 0;
    const el = globalThis.$(
      `<div class="hiprint-option-item hiprint-option-item-row">
          <div class="hiprint-option-item-label">字段名</div>
          <div class="hiprint-option-item-field">
            <div id="fieldOption">
            </div>
          </div>
          `
    );
    this.target = el;
    this.vueApp = createApp(fieldVueApp, {
      onChange: (value) => {
        console.log("onChange", value);
        printElement && printElement.submitOption();
      },
      options: fileds || this.configs.fieldList,
      dialog: this.configs.dialog,
    });
    this.vueApp.use(Button);
    this.vueApp.use(Modal);
    this.vueApp.use(AutoComplete);
    setTimeout(() => {
      this.vueAppIns = this.vueApp.mount("#fieldOption");
    }, 0);
    return this.target;
  }
  getValue(): string | number | boolean {
    return this.vueAppIns && this.vueAppIns.getValue();
  }
  setValue(value: string | number | boolean): void {
    setTimeout(() => {
      if (this.vueAppIns) {
        this.vueAppIns.setValue(value);
      }
    }, 0);
  }
  destroy(): void {
    if (this.vueApp) {
      this.vueApp.unmount();
    }
    this.target.remove();
  }
}

稍微改吧改吧,亮个相,测试一下效果,它的原型链是否正常:

可以看到,核心方法在原型链上。 咱们存储为全局变量测试一下方法

啊哈,能调用就行成了~ 但是整体流程还是有报错的,如下图:

不要慌,不要慌,报错意思是咱们传的不是一个构造函数

嘿嘿,那不就好说了嘛。 套一套

export const FieldOptionFun = (configs: any) => {
  return function () {
    return new FieldOption(configs);
  };
};

咱们对比输出看看实际对象:

后者,我们明显可以看到对象的痕迹

看看整个fieldOption.ts文件

这样思路一打开,咱们可以有更好的去拓展实现参数的处理。

所以控制台是再基础不过的调试手段了。大家一定要仔细探索观察输出的对象格式。

总结

本篇深入探索了参数对象的格式,在控制台中寻找到了重要线索。结合AI可以快速知晓函数具体作用。以及匿名函数、立即执行函数。

控制台输出的对象,其实也是很细致的,比如文本、字符串它的颜色是不一样的,function 也有特殊的标识。

问题并没有你想象的那么难处理,重要的是找对方法。

如果需要源码,公众号回复:plugin-demo

如果看到这里,你还是疑问,想要一对一技术指导,欢迎私信联系我。

记得点个赞咯~

评论区也可交流,想要不简说说什么技术,疑难解答。

下期再见👋🏻

Java中的正则表达式(Regular Expression)

作者 amagi600
2025年4月15日 16:47

一.正则表达式的概念

正则表达式(简称 regex)是一种强大的文本处理工具,用于匹配、查找、替换字符串中的特定模式。它由一系列特殊字符和普通字符组成,可以定义复杂的搜索模式。

二.正则表达式的引用方式

1.基本用法示例

在Java中,正则表达式主要通过Java.util.regex的两个包实现:

  • Pattern:编译后的正则表达式模式
  • Matcher:用于对字符串执行匹配操作的引擎
import java.util.regex.*;

public class RegexExample {
    public static void main(String[] args) {
        String text = "The quick brown fox jumps over the lazy dog.";
        String patternString = "fox";
        
        // 1. 创建Pattern对象
        Pattern pattern = Pattern.compile(patternString);
        
        // 2. 创建Matcher对象
        Matcher matcher = pattern.matcher(text);
        
        // 3. 查找匹配
        if (matcher.find()) {
            System.out.println("Found '" + matcher.group() + "' at position " + matcher.start());
        } else {
            System.out.println("No match found.");
        }
    }
}

2. 常用正则表达式元字符

元字符 描述 示例
. 匹配任意单个字符 a.c 匹配 "abc", "a1c"
^ 匹配字符串开头 ^The 匹配以 "The" 开头的字符串
$ 匹配字符串结尾 dog.$ 匹配以 "dog." 结尾的字符串
* 匹配前一个字符0次或多次 ab*c 匹配 "ac", "abc", "abbc"
+ 匹配前一个字符1次或多次 ab+c 匹配 "abc", "abbc" 但不匹配 "ac"
? 匹配前一个字符0次或1次 colou?r 匹配 "color" 和 "colour"
\d 匹配数字 \d\d 匹配两个连续数字
\w 匹配单词字符(字母、数字、下划线) \w+ 匹配一个或多个单词字符
[] 匹配括号内的任意字符 [aeiou] 匹配任何元音字母
[^] 匹配不在括号内的任意字符 [^aeiou] 匹配非元音字符
` ` 或操作 `cat dog` 匹配 "cat" 或 "dog"

三.正则表达式的实用场景示例

1. 验证电子邮件格式

public boolean isValidEmail(String email) {
    String regex = "^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$";
    return email.matches(regex);
}

2. 提取字符串中的所有数字

public List<String> extractNumbers(String text) {
    List<String> numbers = new ArrayList<>();
    Pattern pattern = Pattern.compile("\\d+");
    Matcher matcher = pattern.matcher(text);
    
    while (matcher.find()) {
        numbers.add(matcher.group());
    }
    
    return numbers;
}

3. 替换字符串中的特定内容

public String replaceDates(String text) {
    // 将 MM/DD/YYYY 格式替换为 YYYY-MM-DD
    return text.replaceAll("(\\d{2})/(\\d{2})/(\\d{4})", "$3-$1-$2");
}

4. 分割字符串

public String[] splitByCommas(String text) {
    // 按逗号分割,忽略前后空格
    return text.split("\\s*,\\s*");
}

5.注意事项

  1. Java 中正则表达式需要双重转义:

    • \d 表示数字(在正则中通常写作 \d
    • \\ 表示单个反斜杠(在正则中写作 \
  2. 常用方法:

    • String.matches(regex):检查整个字符串是否匹配
    • String.split(regex):按正则分割字符串
    • String.replaceAll(regex, replacement):替换所有匹配项
    • String.replaceFirst(regex, replacement):替换第一个匹配项
  3. 对于复杂或频繁使用的正则表达式,建议预编译 Pattern 对象以提高性能。

四.总结

正则表达式是一个非常强大的工具,掌握它可以极大地提高文本处理的效率和灵活性。

❌
❌